snagd
v0.1.3
Published
snagd: the local Snag daemon, and `snagd init`, which sets Snag up on an app.
Readme
snagd
snagd — the local daemon. Owns .snag/ and the SQLite index derived from it.
Licence: Apache-2.0 · Spec: docs/02-BACKEND-SPEC.md §6, §7, §10, §11.
Locked decisions live in docs/00-MASTER-BUILD-PLAN.md §7 — do not relitigate them.
npx snagd [--root <dir>] [--port <n>] [--web-root <dir>] [--no-map] [--no-watch]Detects the git root, creates .snag/ (including the shipped .gitignore from
D8), builds the repo map, starts the file watcher, binds the first free port in
7654–7664, writes it to .snag/.port, and prints the token.
The four rules everything here follows
- Files are the API.
.snag/is the contract;index.dbis a rebuildable index. Delete it and restart — nothing is lost (D7, doc 02 §6.2). - Every route except
GET /healthrequires the token. Any web page can otherwise reachhttp://127.0.0.1:<port>(doc 02 §14, gotcha 21). The token is generated on first run into~/.snag/token, mode0600. - Nothing hard-codes 7654. The extension has no filesystem access, so it
probes
GET /healthacross the range; the daemon binds the first free port and reports it (doc 02 §7.3). - The transport is an interface.
Transportinsrc/transport/index.ts;WebSocketTransportis one implementation, the §7.4 native-messaging fallback would be a second, andLoopbackTransportis the third.
Layout
src/
├─ cli.ts npx snagd — git root, .snag/, map, watcher, token, port
├─ server.ts Fastify assembly; every route in doc 02 §7.1
├─ auth.ts ~/.snag/token, the global preHandler guard
├─ health.ts GET /health — unauthenticated, counts only, never captures
├─ port.ts first free port in 7654-7664
├─ paths.ts the .snag/ layout, blob kinds and content types
├─ context.ts Daemon — one root, one store, one index, one transport
├─ watcher.ts debounced .snag/ watcher → reindex
├─ transport/ { id, type, payload }; one JSON object per message
├─ routes/ capture · blob · threads · tasks · plan · map · tokens · verify · export
├─ store/ files · db · rebuild · search · task-md · counter
├─ derive/ severity · kind · enrich · relations · threads (no LLM calls)
├─ verify/ run · diff — the §10 loop
└─ export/ the Exporter interface and shared mapping (adapters live in integrations/)Things that look like details and are not
- Severity rule 3 must precede the catch-all, or
p3is unreachable while still being a shipped token and a visible chip.derive/severity.tskeeps the list as ordered data andtest/severity.test.tsprovesp3fires. GET /blob/:capId/:kindis load-bearing. The extension cannot readfile://, so every byte it renders comes through it — with the right content type, and with paths that cannot escape the project root.- Binary blobs arrive as HTTP multipart, never over the WebSocket.
resolvedis only ever set by Verify.applyTransition(..., 'verify')is called fromverify/run.tsand nowhere else; the HTTP route cannot spoof the actor. Withplan.verify_on_resolve: false,in_progress → resolvedis legal and recordsverified: false.- A still-unresolvable anchor is
needs_review, never a guess. - Task labels are global, never reused, never renumbered. Un-merging mints
new ones (
store/counter.ts). - Token counts are real or explicitly approximate. Offline they fall back to
a character heuristic and the response says so, so the UI can render
≈.
Optional dependencies, resolved at runtime
@frmwrkd/snag-plan is source-available (D12) and is not a dependency of this
package; POST /plan loads it dynamically and answers 501 plan_unavailable
when it is absent, with a deterministic one-task-per-capture fallback
available on request. The three exporters in integrations/ load the same way.
Development
pnpm --filter snagd build # tsc
pnpm --filter snagd typecheck # src + test
pnpm --filter snagd test # vitest