@sporhq/spor 0.22.0 → 0.23.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (60) hide show
  1. package/.claude-plugin/plugin.json +1 -1
  2. package/.codex-plugin/plugin.json +1 -1
  3. package/API.md +33 -12
  4. package/GRAPH.md +139 -5
  5. package/QUEUE.md +6 -1
  6. package/README.md +9 -0
  7. package/adapters/README.md +8 -4
  8. package/adapters/codex/README.md +2 -1
  9. package/adapters/copilot/README.md +2 -1
  10. package/adapters/gemini/README.md +2 -1
  11. package/adapters/opencode/README.md +1 -1
  12. package/bin/spor-hook.js +9 -0
  13. package/bin/spor.js +824 -264
  14. package/hooks/hooks.json +12 -0
  15. package/lib/analytics.js +114 -35
  16. package/lib/changes.js +6 -3
  17. package/lib/check.js +18 -6
  18. package/lib/config.js +98 -26
  19. package/lib/graph.js +52 -0
  20. package/lib/history.js +5 -2
  21. package/lib/kernel/analytics.js +10 -3
  22. package/lib/kernel/coupling.js +51 -6
  23. package/lib/kernel/graph.js +165 -44
  24. package/lib/kernel/program.js +105 -0
  25. package/lib/kernel/queue.js +27 -11
  26. package/lib/kernel/registry.js +489 -279
  27. package/lib/kernel/resolution.js +117 -6
  28. package/lib/program.js +62 -0
  29. package/lib/queue.js +6 -10
  30. package/lib/schema.js +17 -11
  31. package/lib/seed/schema-artifact.md +115 -4
  32. package/lib/seed/schema-correction.md +74 -2
  33. package/lib/seed/schema-decision.md +19 -1
  34. package/lib/seed/schema-edge-focuses-on.md +29 -0
  35. package/lib/seed/schema-lens.md +124 -0
  36. package/lib/seed/schema-register-terminal-status.md +86 -0
  37. package/lib/shell/atomic-write.js +31 -0
  38. package/lib/shell/git-exec.js +43 -0
  39. package/lib/shell/gittime.js +25 -20
  40. package/package.json +1 -1
  41. package/prompts/client/distill-local.md +1 -1
  42. package/scripts/engines/agents-md.js +188 -13
  43. package/scripts/engines/capture-health.js +9 -3
  44. package/scripts/engines/digest-worker.js +5 -44
  45. package/scripts/engines/distill.js +192 -18
  46. package/scripts/engines/doctor.js +8 -6
  47. package/scripts/engines/infer-commits.js +1 -1
  48. package/scripts/engines/link-commits.js +1 -3
  49. package/scripts/engines/nudge-worker.js +5 -44
  50. package/scripts/engines/post-tool.js +31 -64
  51. package/scripts/engines/pre-tool.js +340 -0
  52. package/scripts/engines/prompt-context.js +15 -52
  53. package/scripts/engines/session-start.js +5 -3
  54. package/scripts/engines/util.js +185 -17
  55. package/skills/brief/SKILL.md +20 -7
  56. package/skills/correct/SKILL.md +12 -0
  57. package/skills/defer/SKILL.md +29 -0
  58. package/skills/spor/SKILL.md +45 -7
  59. package/skills/spor/references/authoring-schemas.md +27 -7
  60. package/skills/spor/references/concepts.md +6 -4
@@ -2,7 +2,7 @@
2
2
  "name": "spor",
3
3
  "displayName": "Spor Context Compiler",
4
4
  "description": "Maintains a typed, versioned knowledge graph and compiles compact briefings from it: session-start injection, per-prompt relevance digests, capture at discovery, end-of-session distillation, decision queue.",
5
- "version": "0.22.0",
5
+ "version": "0.23.0",
6
6
  "author": {
7
7
  "name": "losthammer"
8
8
  }
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "spor",
3
- "version": "0.22.0",
3
+ "version": "0.23.0",
4
4
  "description": "Maintains a typed, versioned knowledge graph and compiles compact briefings from it: session-start injection, per-prompt relevance digests, capture at discovery, end-of-session distillation, decision queue.",
5
5
  "author": {
6
6
  "name": "Spor",
package/API.md CHANGED
@@ -318,7 +318,7 @@ The decision queue (QUEUE.md §4/§5) — the data answer to "show my queue" /
318
318
  "<person-id>|me", "limit"?: 20,
319
319
  "offset"?: 0 }` → `{ "items": [{id, title, type, status,
320
320
  priority, score, signals: {blocking, heat, staleness, age_days}, suggest:
321
- "do|close", why}], "count": N, "offset": 0, "returned_count": N,
321
+ "do|dispatch|blocked|triage|close|approve", why}], "count": N, "offset": 0, "returned_count": N,
322
322
  "total_count": N, "truncated": false, "next_offset": null, "questions": []
323
323
  }` — queueable live nodes ranked by the default blend, each with a one-line
324
324
  *why*. Items already retired by a live inbound resolves/answers edge are
@@ -537,7 +537,7 @@ endpoint is the REST twin of a core call:
537
537
  | `DELETE /v1/me/tokens/{hash-prefix}` | `spor token revoke` | revoke one of the caller's OWN PATs by hash prefix → `{revoked, hash_prefix, oauth_grants_revoked}`; a prefix that isn't one of the caller's is `404` (never another person's token). `403` if unbound. Shares the admin revoke's OAuth-grant cascade-completeness invariant (issue-cc-pat-revoke-cascades-all-oauth-grants) |
538
538
  | `GET /v1/briefing/{project}` | session-start | read the `brief-<project>` node → `{found, version, body, project_brief?, graph_status}`. The slug resolves through project-node aliases (GRAPH.md "Project identity nodes") before lookup. A BARE repo slug also rides up to its home-project grouping: the grouping's `brief-<grouping>` node returns alongside as `project_brief` (the product context spanning sibling repos), matching the shared up-resolution (dec-spor-queue-slug-resolves-to-grouping); passing the repo NODE id (`repo-<slug>`) is the escape hatch that returns only the repo brief, no `project_brief`. Optional `?fp=root:<sha>,remote:<host/path>,...` carries the repo's fingerprints: the server learns them onto the owning project node, and an unknown slug with a known fingerprint files an alias proposal in the queue |
539
539
  | `POST /v1/digest` `{query, root?, project?, min_sim?}` | prompt-context, /spor:brief | digest-mode compile → `{found, text}`; `found: false` is a successful empty result. `root` is the structural-walk twin of `query` (the two are mutually exclusive; `root` wins, an unknown id is `422`). Optional `project` is the session slug: the server scopes the compile to it — the same-project relevance boost, the grouping union, and the `always_on` norm `applies_to_*` ride-along — resolving the slug through project-node aliases/groupings inside compile (dec-spor-queue-slug-resolves-to-grouping), exactly as `/v1/queue` does. A bad slug is `422`; **omitting `project` runs the digest project-blind (byte-identical to before)**, so older clients that send only `{query}` are unaffected |
540
- | `GET /v1/nodes/{id}` | /spor:brief | `get_node` semantics; the node's active schema may attach read-time enrichment via a `get(node, ctx)` hook (GRAPH.md) — the seed `question`/`issue`/`task`/`incident` schemas attach `resolution`: a live inbound resolves/answers edge carrying the resolver's `summary`/`title` and a `lagging` flag (set when it contradicts a still-open status, clear when the node is already terminal, e.g. an answered question pointing at its answer). Open gardener findings about the node ride along as `open_findings`, and a node marked stale by an inbound supersedes edge as `superseded_by`. All enrichment is additive top-level keys; ignore unknown ones |
540
+ | `GET /v1/nodes/{id}` | /spor:brief | `get_node` semantics; the node's active schema may attach read-time enrichment via a `get(node, ctx)` hook (GRAPH.md) — the seed `question`/`issue`/`task`/`incident` schemas attach `resolution`: a live inbound resolves/answers edge carrying the resolver's `summary`/`title` and a `lagging` flag (set when it contradicts a still-open status, clear when the node is already terminal, e.g. an answered question pointing at its answer). Open gardener findings about the node ride along as `open_findings`, and a node marked stale by an inbound supersedes edge as `superseded_by`. All enrichment is additive top-level keys; ignore unknown ones. Reserved: a top-level `inert` boolean — this node's status evaluated against the FULL type-aware queue-liveness-dead partition, including graph-resident schema overrides a graph-less client can't see (issue-spor-type-blind-terminal-status-fallbacks). The graph-less client callers that need it (`bin/spor.js` `dispatchResolutionReason`, `distill.js` `sessionEndLease`) already read it opportunistically when present and fall back to an offline seed-registry check otherwise, but no server response emits the key yet — implementing it server-side is separate, tracked work |
541
541
  | `GET /v1/nodes/{id}/history?limit=N` | `spor history <id>`, the `node_history` MCP tool | per-node commit lineage — a `git log` projection over `nodes/{id}.md` → `{id, head, count, history: [{sha, short, actor, actor_name, actor_email, date, message, internal, person}]}`, newest first. Each revision is labeled `internal:true` for a server-internal write (boot reconcile / migration, `server@spor.invalid`) vs. a real actor, and mapped to its `person` node by author email. Deliberately NOT `git log --follow` (node files share heavy frontmatter boilerplate, so similarity-based rename detection crosses node boundaries — dec-spor-node-history-git-log-projection). `limit` defaults to 50, max 200. The frontmatter `author` re-stamps to the LAST editor on every write, so this is the only durable record of the full chain of editors. A node with no commit history (unknown id) is `404`; a bad id is `422`. The `spor history <id>` CLI verb is the shell front-door (remote reads this; local mode runs the same projection over the graph home) |
542
542
  | `GET /v1/nodes/{id}/history/{sha}` | `spor history <id> <sha>`, `node_history` (sha mode) | one revision's detail, the expensive half gated behind an explicit per-sha fetch → the history record for that commit plus `{change, patch, content}`: the change type (`A`/`M`/`D`/`R`), the patch this commit introduced to the node file, and the full node content at that revision (`null` when the commit deleted it). The `sha` must be one from the node's own history — a sha that didn't touch the node, or an unresolvable sha, is `404`; a malformed sha is `422` |
543
543
  | `POST /v1/nodes` | `spor put-node`, drain-outbox, mechanical writers | `put_node` semantics, batch: `{nodes: [...], if_exists: "skip"}` (entries may be raw strings or `{node, if_exists, revision}`) → `{results: [...]}`, 207 when any entry failed. Entries are applied **sequentially** and each is fully validated before the next — including the completion-resolver gate that runs on create (GRAPH.md "the resolver gate") — so a born-terminal node (`done` task / `resolved` issue) must have its resolving `decision`/`artifact` EARLIER in the same batch (**resolver-first ordering**; the batch does not defer the gate to end-of-batch, dec-spor-batch-create-gate-resolver-first-ordering). The 207 is partial-success: entries already applied before a later entry's failure are not rolled back |
@@ -554,7 +554,7 @@ endpoint is the REST twin of a core call:
554
554
  | `POST /v1/nodes/{id}/commits` `{repo, sha}` | post-tool / link-commits | `link_commit`: append `repo@sha` to the node's `commits:` list (kebab-case repo slug, 7–40 lowercase hex, ≤40 commits per node); idempotent, prefix-aware dedup |
555
555
  | `GET /v1/commits/{sha}?repo=` | `spor blame`/`commits` CLI verb; sessions doing git archaeology | sha → nodes lookup over the `commits:` fields (≥7 hex, abbreviated or full); each match carries `{repo, sha, id, type, title, summary, status, project}` — blame a line, get the why. The `spor blame <sha> [--repo <slug>]` CLI verb (alias `spor commits <sha>`) wraps this remotely and runs the same lookup over the local graph in local mode (`lib/query.js` `lookupCommit`) |
556
556
  | `GET /v1/changes?since=&project=&limit=` | `recent_changes`'s REST twin; audit review | the remote audit trail: a git-log projection over `nodes/` → `{changes: [{id, change, commit, date, committed_by, type, title, authored_via, author}], count, head, since, generated_at}`, newest change per node first. `since` is a 7–40 hex sha (`sha..HEAD`) or a date/relative phrase git understands (`--since`); an unresolvable sha is `422`. `project` scopes to one project's nodes (deletions are omitted when scoped, their project being gone). `limit` bounds nodes returned (default 100, **max 500**). Each entry's `authored_via` is the current machine-vs-human signal (`capture`/`distill`/`gardener` = machine). Lets a remote client review what agents wrote without the whole `/v1/export` tarball |
557
- | `POST /v1/capture` | distill, /spor:defer | `capture` semantics: `{text, context: {project, during, blocks?, needed_by?}, source?, idempotency_key?}` → ingestion model + validate + commit → `{status, ids, nodes, summary, warnings}`. `source: "distill"` marks backstop captures in the journal. `idempotency_key` (client-generated; equivalently the `Idempotency-Key` header) guards the whole capture against the timeout-then-server-completes race (issue-cc-capture-transport-idempotency): a key the server has already seen returns the original result instead of re-ingesting, so a client that aborted at its read timeout but landed server-side does NOT double-write when the spooled body is replayed by `spor drain`. The client puts the key in the BODY so the verbatim outbox replay carries it for free (issue-spor-add-cli-duplicate-on-timeout-drain). `context.blocks` (a node id, must exist) and `context.needed_by` (`YYYY-MM-DD`) declare a cross-project dependency (task-cc-xproject-dependency-loop): set `context.project` to the SERVING project and the server attaches a `blocks` edge to the requester + the deadline deterministically (not via the model) onto the primary node. A missing `blocks` target is `404`; a non-date `needed_by` is `422` — both rejected before any model call |
557
+ | `POST /v1/capture` | distill, /spor:defer | `capture` semantics: `{text, context: {project, project_explicit?, during, blocks?, needed_by?}, source?, idempotency_key?}` → ingestion model + validate + commit → `{status, ids, nodes, summary, warnings}`. `source: "distill"` marks backstop captures in the journal. `idempotency_key` (client-generated; equivalently the `Idempotency-Key` header) guards the whole capture against the timeout-then-server-completes race (issue-cc-capture-transport-idempotency): a key the server has already seen returns the original result instead of re-ingesting, so a client that aborted at its read timeout but landed server-side does NOT double-write when the spooled body is replayed by `spor drain`. The client puts the key in the BODY so the verbatim outbox replay carries it for free (issue-spor-add-cli-duplicate-on-timeout-drain). `context.blocks` (a node id, must exist) and `context.needed_by` (`YYYY-MM-DD`) declare a cross-project dependency (task-cc-xproject-dependency-loop): set `context.project` to the SERVING project and the server attaches a `blocks` edge to the requester + the deadline deterministically (not via the model) onto the primary node. A missing `blocks` target is `404`; a non-date `needed_by` is `422` — both rejected before any model call. `context.project_explicit` (additive boolean, task-spor-thread-explicit-project-flag) distinguishes a user-declared `context.project` from an ambient cwd default: only a literal `false` silences the fold-mismatch warning on a cross-project capture; **absent means explicit** (old-client back-compat, so a pre-flag client keeps today's warn-on-mismatch behavior) |
558
558
  | `POST /v1/distill/report` | distill | sweep telemetry, journal-only (no store mutation): `{facts, captured?, spooled?, rejected?, project?, session?}` → `{status: "reported"}`; zero-fact sweeps report too |
559
559
  | `POST /v1/corrections` | /spor:correct | `propose_correction` semantics → 201 `{status, id, revision, warnings}` |
560
560
  | `GET /v1/queue?project=&assignee=&type=&exclude_type=&limit=&offset=` | /spor:next, session-start | the ranked decision queue: `{items, count, offset, returned_count, total_count, truncated, next_offset, counts_by_type, counts_by_project, counts_by_suggest, muted?, dormant?, questions, asked, findings, pending, reviews, policy?, generated_at}` — items retired by a live resolves/answers edge are excluded; items hidden by the viewer's `queue_mute` or parked by a future `wake:` date (QUEUE.md §4) are counted, never silently dropped; `questions`/`findings`/`pending` are the routed-to-me-plus-unrouted views for the authenticated identity, `asked` is the questions you filed, and `reviews` is the nodes whose review is requested of you (an open `review-requested` edge to your person node — explicitly targeted, no unrouted fallback). `limit` is the page size (default 20, **max 100**, clamped not rejected) and `offset` skips that many items in the ranked order (default 0); the `counts_*`/`total_count` aggregates always cover the FULL ranked set regardless of the page, so one call answers "how many issues vs tasks" without paging, while `truncated`/`next_offset` let a client walk the rest by re-requesting with `offset=next_offset` until `next_offset` is null. Pagination is offset over a point-in-time ranked slice (the queue re-ranks every call), not a cursor — it resumes the same slice only across an unchanged ranking. `project` resolves through the shared up-resolution (dec-spor-queue-slug-resolves-to-grouping): a bare repo slug unions its home-project grouping's member queues, the repo NODE id (`repo-<slug>`) pins one repo, a grouping id (`proj-<slug>`) is used directly; **omitting `project` is the cross-project firehose** (every repo's queue at once). `assignee=<person-id>` scopes to the work that person carries (their `assigned`/`stewards` edges) — a manager's "who is carrying what"; `assignee=me` binds to the caller (empty if the token maps to no person node). `type=`/`exclude_type=` (comma-separated, repeatable) whitelist/blacklist node types from the ranking (exclude wins on overlap) — a hard scope filter applied before scoring, so the aggregates describe the filtered queue (task-cc-queue-filtering-enhancements) |
@@ -564,10 +564,13 @@ endpoint is the REST twin of a core call:
564
564
  | `POST /v1/gardener` | ops cron / on demand; `spor admin gardener` | run a gardener sweep now; findings filed as queue items → `{checked, filed, resolved, skipped, generated_at}` (`filed`/`resolved`/`skipped` are id lists, `checked` a count). The `spor admin gardener [--json]` CLI verb is the shell front-door (remote-only — the server owns the gardener); authenticated but **not** admin-gated server-side today (unlike `/v1/backup`), so any valid team token can trigger it — the verb still surfaces a 403 as an admin-privilege (stewards→root) hint for a deployment that adds the gate |
565
565
  | `GET /v1/program/{id}?format=json\|text&depth=&max_nodes=` | program oversight, /spor:brief follow-ups | the program/progress view (`render_program`'s REST twin, one kernel behind both doors): the gating tree of everything that `blocks` `{id}` transitively, with resolution-derived progress (`{progress: {total, done, active, blocked, open, pct, statuses}}` on the view root; done = terminal status / supersession / live resolves-answers edge, exactly the queue's truth). JSON view tree by default, `?format=text` for the terminal rendering; `depth`/`max_nodes` bound expansion and count skipped branches into `truncated`, never silently. 404 for an unknown id |
566
566
  | `GET /v1/lens/{id}/render?format=html\|text\|json` | browsers, teammates without a checkout | run a lens OR workspace node and render its view tree (html default, plain text, or the raw tree as json). Read-only — no action forms; writes stay with `/v1/nodes` and the MCP tools. Auth is the caller's bearer header OR a signed read-only **render ticket** for shared links (browser links can't carry an Authorization header): `?ticket=<blob>` is accepted once and exchanged via a 302 for an HttpOnly `spor_render_ticket` cookie (kept out of URLs, logs, and view-to-view hrefs). The ticket binds `$viewer` to the recorded sharer and the render shows a "Viewing as &lt;sharer&gt;" banner. The former `?token=<PAT>` sharing path is **removed** — a shared link can never carry a write-capable credential |
567
- | `POST /v1/lens/{id}/ticket` `{expires?}` | sharing a view; `spor share` | mint a signed, expiring, read-only render ticket for the lens/workspace, recording the authenticated caller as the sharer → `{ticket, url, lens_id, sharer_person_id, exp}`. `expires` is `<N>d` or an ISO date (default `7d`, max `30d`); the caller must be bound to a person node (else `422 no_person`). The ticket carries no write scope and is honored only on the render route. The `spor share <lens-id> [--expires <Nd>]` CLI verb is the shell front-door (remote-only — tickets are minted and signed server-side); it prints the shareable link ready to paste, `--json` for the raw envelope |
567
+ | `POST /v1/lens/{id}/ticket` `{expires?}` | sharing a view; `spor share` | mint a signed, expiring, read-only render ticket for the lens/workspace, recording the authenticated caller as the sharer → `{ticket, url, lens_id, sharer_person_id, exp}`. `expires` is `<N>d` or an ISO date (default `7d`, max `30d`); the caller must be bound to a person node (else `422 no_person`). The ticket carries no write scope and is honored only on the render route (directly, or via the app host's ticket exchange below). The minted `url` depends on host role: an MCP-only host (`SPOR_HOST_ROLE=mcp`) with `SPOR_APP_URL` set mints an **absolute** `${SPOR_APP_URL}/views/{id}?ticket=...` — the app host's own render page, since the MCP host itself 404s on HTML renders; unset, it falls back to today's relative shape; every other role keeps its existing `oauth.baseUrl(request.raw)`-based absolute `/v1/lens/{id}/render?ticket=...`. On the app host, `GET /views/{id}` accepts that `?ticket=` exactly once: it is exchanged via a 302 into an HttpOnly `spor_render_ticket` cookie (stripped from the URL, kept out of logs and view-to-view hrefs) and replayed to api as the credential on the render fetch — but a **live app-host session outranks the ticket**: if the visitor is already signed in, their own session is used and the ticket cookie is ignored, so a shared link can never pin a signed-in user's view to the sharer's `$viewer`. The `spor share <lens-id> [--expires <Nd>]` CLI verb is the shell front-door (remote-only — tickets are minted and signed server-side); it prints the shareable link ready to paste, `--json` for the raw envelope |
568
568
  | `POST /v1/merge` `{nodes: [...], mode?: "plan"\|"apply", id_map?: {...}, trust_attached_code?: bool, force?: bool}` | admin promoting one graph into another — pilot-to-org, or a local dogfood graph into a hosted tenant | bring another graph's exported node files (`nodes`: an array of raw node markdown strings) into this one without the failure mode of the naive `GET /v1/export \| POST /v1/nodes --if-exists skip` — silently DROPPING every colliding node while imported edges that pointed at it re-bind to this graph's unrelated same-named node (ordinal id schemes like `cap-<date>-<n>` collide across any two independently started graphs). **Admin-gated** (stewards→root, else `403 forbidden`): `mode:"apply"` writes through the same trusted bulk-import door the server uses internally, which preserves each incoming node's original attribution (a merge moves history, it does not re-author it) and skips the `transitions()`/policy gates (content validation still runs per node; create-only, one deferred commit). Every incoming node classifies as **imported** (id unknown here), **deduped** (id collides, content identical — attribution-blind, this graph's copy wins), **remapped** (id collides, content differs, and the id's final dash-segment is all-digits/*ordinal* — rewritten to `<id>-<sha256(content)[:7]>`, with every reference to the old id across the incoming batch rewritten to match), or **conflict** (id collides with different content and a *semantic* id; a `person` node's email already bound to a different id; or a `schema`/`workflow`/`workflow-run` node or a `stewards`-to-this-graph's-root edge — none of these ever merge silently). Conflicts are reported for manual triage and never written; the schema/workflow class is the one skippable via `trust_attached_code: true` (only for a whole graph you own). `mode:"plan"` (the default) runs the same classification and validation and returns the report without writing anything; `mode:"apply"` **refuses with `409 conflict`** (nothing written) whenever the plan still carries conflicts or validation errors, unless `force: true` — which imports the clean subset and knowingly leaves any reference to a skipped id unresolved. `id_map` (`{"old-id": "new-id"}`) seeds cross-id rewrites; feed a plan's own `id_map` back into the next request when a graph is too large for one batch (plan every batch first to build the complete map, then apply each). Response: `{mode, counts: {incoming, imported, deduped, remapped, conflicts, errors}, imported, deduped, remapped, conflicts, errors, id_map, results?, generated_at}` — `imported`/`deduped`/`remapped`/`conflicts` are arrays of `{id, new_id?, title?, reason?}`; `errors` is `{id, index?, errors: [...]}` (unparseable/invalid entries); `results` (apply mode only) carries the import door's per-entry write verdicts. Deterministic and idempotent — re-running an identical merge dedups everything the first run imported, safe after a partial failure. Called directly today (bearer admin token + `curl`); a CLI wrapper defaulting to plan mode is tracked separately (task-spor-cli-merge-verb) |
569
569
  | `GET /v1/export` | bootstrap/offline; `spor export` | ustar tarball of `nodes/` for seeding a local read replica (`?gzip=1` compresses); see §5 for the response headers. `curl … \| tar x` reproduces `nodes/` byte-for-byte. `?history=1` instead streams a `git bundle --all` of the repo (`application/x-git-bundle`, full commit provenance, the customer data-exit path — `git clone <bundle> graph`); `?auth=1` ALSO bundles `auth/*.json` so a disaster restore reproduces the credential set (admin-gated: stewards-root → `403` otherwise). The `spor export [--gzip] [--history\|--auth] [--out <file>]` CLI verb is the shell front-door (remote downloads this; `--gzip`/`--out` also build the same `nodes/` tarball locally, while `--history`/`--auth` are remote-only) |
570
- | `GET /v1/admin/tokens` | offboarding / audit; `spor admin token list` (= `spor token list --all`) | list PATs → `{tokens: [{hash_prefix, person, name, email, created, expires, expired}], count}` — never plaintext, never full hashes. Admin-only (§4). The team-wide view; the caller's own PATs are the self-serve `GET /v1/me/tokens` above |
570
+ | `GET /v1/admin/people` | tenant-admin console; offboarding / audit | list every person **subject** → `{people: [{id, name, email, roles, is_admin, status, tokens, active_tokens, last_used}], count}`. `is_admin` reflects the `stewards→root` edge (§4, the same per-person check the admin gate itself runs); `tokens`/`active_tokens`/`last_used` summarize the subject's PATs from the same store `GET /v1/admin/tokens` below lists. `status` is the node's own frontmatter status, stamped `active` at creation — offboarding (below) revokes access without touching it, so it is not an offboarded/active signal today. Admin-only (§4) |
571
+ | `POST /v1/admin/people` `{name, email, id?, roles?, invite?, connection_id?, org?}` | onboarding; the on-box `mint-person.js` CLI, the tenant-admin console | create the canonical person **subject** a PAT or provider callback binds to (task-spor-pilot-person-node-onboarding) — `POST /v1/admin/tokens` below and a provider (IdP) callback both refuse to conjure one, so this is the deliberate step onboarding a new teammate needs FIRST → 201 `{id, name, email, roles, revision, org_invitation?}`. `name`/`email` are required, single-line, ≤200 chars; `id` defaults to an opaque, deterministic email-hash when omitted (never the mutable display name) and must be a `person-<slug>` kebab id; `roles` is an optional array of ≤20 kebab-case role slugs. No `stewards` edge is written — a regular teammate is not an admin (grant that separately, §4). `invite:true` additionally issues a WorkOS Organization invitation for the same email (`connection_id` disambiguates when more than one WorkOS connection is configured; `org` pins the invitation's org, and is honored only on an unbound/self-host server — on a server already bound to one org (the hosted default), a supplied `org` that disagrees with it is `403 forbidden` rather than silently corrected, and omitting it just pins to the bound org) — the invite shape is validated up front so a bad arg never leaves a half-done onboarding, but an invitation failure never rolls back the created person: `org_invitation: {status: "issued"|"failed", ...}` reports either outcome. Admin-only, same `stewards→root` gate as `/v1/admin/tokens`. `409` on a colliding id; `422` invalid |
572
+ | `DELETE /v1/admin/people/{id}` | offboarding | **offboard** a member: revoke every PAT and OAuth grant bound to the subject (the same cascade a directory deprovision runs) → `{offboarded, tokens_revoked, oauth_grants_revoked}`. Does **not** delete the person node — it stays the canonical subject every attribution reference points at, so removal here means "revoke access", not "erase the record". Self-offboarding is refused (`422`) so an admin can't lock themselves out; a malformed or non-`person-` id is also `422` (checked before lookup). Admin-only (§4); `404` unknown id |
573
+ | `GET /v1/admin/tokens` | offboarding / audit; `spor admin token list` (= `spor token list --all`) | list PATs → `{tokens: [{hash_prefix, person, name, email, created, expires, expired, last_used}], count}` — never plaintext, never full hashes. Admin-only (§4). The team-wide view; the caller's own PATs are the self-serve `GET /v1/me/tokens` above |
571
574
  | `POST /v1/admin/tokens` `{person, expires?}` | onboarding; `spor invite` | mint a PAT bound to an existing person node (`expires` is `<N>d` or an ISO date) → 201 `{token, hash_prefix, person, name, email, expires}`; the plaintext `token` is returned **once**. Admin-only. Binds someone *else* (onboarding); the self-serve mint is `POST /v1/me/tokens` above |
572
575
  | `DELETE /v1/admin/tokens/{hash-prefix}` | offboarding / rotation; `spor admin token revoke` (= `spor token revoke --all`) | revoke the single PAT matching the hash prefix (≥8 hex chars; an ambiguous prefix is a 409) → `{revoked, hash_prefix}`. Admin-only. Revokes ANY token; the self-serve revoke (the caller's own) is `DELETE /v1/me/tokens/{hash-prefix}` above |
573
576
  | `GET /v1/agents` | `spor agent list` | list the agents the caller **owns** → `{agents: [{id, label, owner, spiffe, pubkey, status}], count}`; `?all=1` lists every agent (admin-only) |
@@ -624,6 +627,18 @@ anything with a token.
624
627
  --admin --person <id>` (it writes that `stewards` edge, creating the person
625
628
  node from `--name`/`--email` if needed). Hand-editing the token file stays
626
629
  as the break-glass path.
630
+ - **Person subject lifecycle admin** (task-spor-pilot-person-node-onboarding).
631
+ A person node is the canonical subject a PAT or provider callback binds to,
632
+ and `POST /v1/admin/tokens` above refuses to conjure one — so onboarding a
633
+ new teammate is two admin calls, no server-box shell required:
634
+ `POST /v1/admin/people` (§3) creates the subject, then
635
+ `POST /v1/admin/tokens` binds its PAT. `GET /v1/admin/people` lists every
636
+ subject with its admin-vs-teammate flag and a PAT summary;
637
+ `DELETE /v1/admin/people/{id}` offboards one — revoking every PAT and OAuth
638
+ grant bound to it, but never deleting the node itself, since it remains the
639
+ subject every attribution reference points at — and refuses to offboard
640
+ the caller's own account. Same `stewards→root` gate as token lifecycle
641
+ above.
627
642
  - **Agent-scoped session tokens.** A person mints a short-lived, per-session
628
643
  token for an `agent` they **own** through the self-serve
629
644
  `POST /v1/agents/{id}/token` (§3) — authorized by ownership (the agent's
@@ -639,11 +654,15 @@ anything with a token.
639
654
  `spor agent use <agent-id>`, or `SPOR_DISPATCH_AGENT`), which `spor dispatch
640
655
  --as <agent-id>` overrides for a single run. The `<agent-id>` is the agent's
641
656
  `agent-`-prefixed NODE id (what `spor agent list` prints), **not** its bare
642
- label — the token endpoint requires the prefix, so the client setters
643
- (`spor agent use`, `--as`) reject a prefix-less id with a `did you mean
644
- agent-…?` hint rather than persist one every dispatch would 422 on. (Not to be
645
- confused with `spor dispatch --agent`, the unrelated `claude --agent` harness
646
- passthrough.) The
657
+ label — the token endpoint requires the prefix, so `dispatch --as` rejects a
658
+ prefix-less id with a `did you mean agent-…?` hint rather than persist one
659
+ every dispatch would 422 on. `spor agent use` goes one step further: a
660
+ prefix-less argument is first resolved against the caller's own agents (their
661
+ label or a plain `agent-<label>` guess) and normalized to the canonical id
662
+ before it's written — a re-typed label just works, and only an id that
663
+ matches none of the caller's agents falls back to the same prefix-hint error.
664
+ (Not to be confused with `spor dispatch --agent`, the unrelated `claude
665
+ --agent` harness passthrough.) The
647
666
  token is minted **session-deferred** and bound to the real run session AFTER
648
667
  launch (dec-spor-dispatch-bg-session-late-bind): `claude --bg` ignores
649
668
  `--session-id` and self-allocates its session, so dispatch reads the real one
@@ -710,8 +729,10 @@ anything with a token.
710
729
  mints a signed, expiring, **read-only** ticket carrying `{lens_id,
711
730
  sharer_person_id, exp}` — the credential a *shared* view link carries instead
712
731
  of the sharer's PAT. It binds `$viewer` to the recorded sharer (rendered with
713
- a "Viewing as" banner), is honored only on `GET /v1/lens/{id}/render`, and can
714
- never authorize a write. Stateless (HMAC over a server-held key — no
732
+ a "Viewing as" banner), is honored only on `GET /v1/lens/{id}/render`
733
+ (directly, or via the app host's `GET /views/{id}` ticket-to-cookie
734
+ exchange), and can never authorize a write. Stateless (HMAC over a
735
+ server-held key — no
715
736
  revocation list, expiry is the bound); per-recipient/revocable grants are a
716
737
  later fine-grained-authz refinement.
717
738
 
package/GRAPH.md CHANGED
@@ -106,7 +106,7 @@ Rules:
106
106
  | task | `task-` | active or planned work (status `open`/`active`/`done`/`abandoned`, gated; `done` requires a `decision`/`artifact` resolver — see below) |
107
107
  | issue | `issue-` | a defect/finding and its resolution lineage (queueable: open issues join the decision queue; status `open`/`active`/`resolved`, gated; `resolved` requires a `decision`/`artifact` resolver — see below) |
108
108
  | incident | `inc-` | something that went wrong in operation (queueable: live incidents join the decision queue) |
109
- | artifact | `spec-`, `art-` | a document, spec, module, or build product worth referencing; when it represents a change it may carry an optional delivery-stage status `in-review`/`approved`/`merged`/`released` — see below |
109
+ | artifact | `spec-`, `art-` | a document, spec, module, or build product worth referencing; status `in-review`/`approved`/`merged`/`released` (the delivery stages, when it represents a change) or `done`/`active` (a finished/living doc), gated at the `validate()` door; or none, a plain reference doc — see below |
110
110
  | norm | `norm-` | a standing convention or constraint (rides along in every project-relevant compile) |
111
111
  | briefing | `brief-` | a compiled briefing (output of this system; never traversed) |
112
112
  | correction | `corr-` | standing fix to a briefing: pin/exclude/guidance (never traversed) |
@@ -120,6 +120,7 @@ Rules:
120
120
  | finding | `find-` | a gardener observation about another node, filed as a queue item (QUEUE.md §6) |
121
121
  | repo | `repo-` | durable git-repo identity: slug aliases + repo fingerprints; heals renames at read time (below) |
122
122
  | project | `proj-` | a grouping above repos; owns its member repos via inbound `grouped-under` edges (below) |
123
+ | lens | `lens-` | a saved view over the graph — declarative `## query`/`## render` json blocks, an optional sandboxed `## custom` js block, and an optional `## actions` block; `traversable: false` and `capturable: false` (see "Lenses") |
123
124
 
124
125
  ## Completing work needs a durable why (the resolver gate)
125
126
 
@@ -161,6 +162,12 @@ a change still in review has not delivered. So:
161
162
  - An `artifact` representing a change may carry a delivery-stage status:
162
163
  `in-review`/`approved` are **non-resolving** (they keep the resolved target
163
164
  live); `merged`/`released`, and any other/empty status, are **resolving**.
165
+ The full artifact vocabulary is those four stages plus `done` (a finished
166
+ doc/spec/build product that is not a change) and `active` (a living/current
167
+ doc), or none — enforced at the schema's `validate()` door on create and
168
+ update (issue-spor-off-vocab-artifact-statuses). There is no `transitions()`
169
+ on this type: the stages are not a state machine (a change may be born
170
+ `merged`), so only membership is gated, never order.
164
171
  Plus flat scalar metadata the regex frontmatter parser already supports —
165
172
  `delivery_ref` (PR url/commit/tag), `delivery_source` (e.g. `github`),
166
173
  `size`, `labels`, `paths` (comma-scalars). The shape is source-blind, so a
@@ -233,13 +240,19 @@ separately, but never shows a complete custom type in one piece.
233
240
 
234
241
  **The constraint model is procedural, not declarative.** A schema's `json`
235
242
  payload declares only *registry knobs* — `node_type`, `prefix`, `queueable`,
236
- `traversable`, `always_on`, `capturable`, an edge `weight`, and the two status
243
+ `traversable`, `always_on`, `capturable`, an edge `weight`, and the three status
237
244
  partitions: `status.non_resolving` (resolver semantics — whether a node in this
238
- status retires the targets it points at) and `status.terminal` (own-lifecycle
245
+ status retires the targets it points at), `status.terminal` (own-lifecycle
239
246
  completion — the statuses in which a node of this type is *done*, unioned with the
240
247
  kernel's legacy set and read by work-analytics so a schema-only terminal status
241
248
  like decision `settled` counts as completed,
242
- issue-spor-analytics-completion-ignores-schema-terminal-status). There is **no
249
+ issue-spor-analytics-completion-ignores-schema-terminal-status), and
250
+ `status.inert` (queue-liveness-dead — the per-type overlay the type-aware
251
+ `isTerminalStatus(status, type, graph)` unions with the type-blind
252
+ `terminal-status` register below; a schema that declares no `inert` set
253
+ INHERITS its `terminal` set, so only a schema whose two sets genuinely differ
254
+ declares it — the seed decision schema pins `settled` terminal but NOT inert,
255
+ dec-spor-status-inert-third-partition). There is **no
243
256
  declarative field list and no status enum.** Custom fields are free-form: any flat frontmatter key the
244
257
  regex parser accepts (simple `key: value` scalars, YAML-folded multi-line
245
258
  values, `pin:`/`exclude:` inline lists, `- {type: X, to: Y}` edges — and nothing
@@ -483,6 +496,29 @@ agreeing invariant suppresses the untouched heuristic, and a disagreement
483
496
  reports even when both files were touched. A half-declared or malformed pair
484
497
  is inert (validate warns).
485
498
 
499
+ An in-repo tracked symlink (`frontend -> packages/web`) has two valid
500
+ repo-relative spellings for the same file, and the matcher tests a glob
501
+ against every candidate spelling it is HANDED
502
+ (task-spor-coupling-matcher-symlink-alias) — but deriving those candidates is
503
+ one-way: it can turn an alias spelling into its git-resolved canonical form,
504
+ never the reverse. A coupling glob authored against an alias
505
+ (`couples_when: [frontend/**]`) therefore still misses an edit reported only
506
+ under its canonical path (`packages/web/app.js`) — the environment may hand
507
+ the matcher an already-resolved path with no alias spelling left to derive
508
+ from — because discovering which alias points at a given canonical path would
509
+ need a filesystem-wide symlink scan, rejected as too expensive for the
510
+ edit-time hot path (dec-spor-dismiss-reverse-symlink-path-lookup,
511
+ issue-spor-coupling-matcher-reverse-symlink-gap). The declared fix is a
512
+ **coupling alias map**: `.spor.json`'s `coupling.aliases`, a flat `{ "<alias
513
+ prefix>": "<canonical prefix>" }` object (repo-root-relative on both sides,
514
+ e.g. `{ "frontend": "packages/web" }`). Every declared entry is expanded in
515
+ BOTH directions at match time, at zero runtime cost (no scanning) — a path
516
+ under either side also produces the spelling under the other side, for both
517
+ `couples_when` triggers and `couples_also` targets. **Declaring nothing is
518
+ the default posture**, and it keeps the one-way limitation above: only
519
+ author coupling globs against the canonical (git-resolved) spelling of a
520
+ symlinked subtree unless its alias is declared in `coupling.aliases`.
521
+
486
522
  Because a norm rides along with no relevance gate and the team trust model lets
487
523
  every writer author one, the briefing renderer treats norm bodies as an
488
524
  **injection surface** (issue-cc-norm-always-on-injection): each is quoted as
@@ -888,6 +924,77 @@ thread 4). The seed set is small — `shell`, `prod-creds`, `browser`, `network`
888
924
  `human`, `filesystem-write`, `paid-api`. `human` is unsatisfiable by any agent:
889
925
  assign that work to a person.
890
926
 
927
+ ### The `terminal-status` register
928
+
929
+ A second registry-declared enum (seed: `schema-register-terminal-status`,
930
+ `register: terminal-status`) names the **type-blind** status vocabulary that
931
+ retires ANY node from queue liveness (`lib/kernel/queue.js` `isLive`), briefing
932
+ "live work" surfacing (`lib/kernel/graph.js` status tag/warning), and
933
+ coupling-norm matching (`lib/kernel/coupling.js`) — the single source those two
934
+ kernel modules read (`graph.registry.registerClasses("terminal-status")`)
935
+ instead of two separately hardcoded, previously-divergent tables
936
+ (issue-spor-coupling-resolution-terminal-status-divergence). The seed set is
937
+ `abandoned`, `answered`, `closed`, `completed`, `deprecated`, `dismissed`,
938
+ `done`, `merged`, `rejected`, `resolved`, `retired`, `superseded` — only
939
+ genuinely universal completion words; a type-scoped status belongs in its
940
+ owning schema's `status.inert`/`status.terminal` instead (artifact `released`
941
+ lives there, so a non-artifact marked `released` stays live,
942
+ task-spor-terminal-status-type-aware-migration).
943
+
944
+ The full liveness check is **type-aware**
945
+ (dec-spor-status-inert-third-partition): `isTerminalStatus(status, type,
946
+ graph)` unions this register with the registry's per-type `status.inert`
947
+ overlay (declared, or inherited from `status.terminal`). The union is one-way
948
+ additive — a per-type declaration scopes a status to its own type but can
949
+ never remove a universal word. The register is **DISTINCT** from the
950
+ per-node-schema partitions above: a decision's `settled` status is terminal
951
+ for its OWN lifecycle (`status.terminal`, read by work-analytics) but is
952
+ deliberately absent from this register AND from the decision schema's
953
+ declared `inert`, so a settled decision keeps surfacing as live guidance in
954
+ queues and briefings (dec-spor-decision-lifecycle-surfacing).
955
+ `lib/kernel/coupling.js` scans node files in the hook tool loop without a
956
+ loaded graph/registry, so it (and any other graph-less caller) reads a
957
+ hardcoded fallback that reproduces this register's seed classes
958
+ byte-identically; a graph-resident override — and every per-type overlay —
959
+ only reaches callers that pass a loaded `graph`.
960
+
961
+ ## Lenses
962
+
963
+ A saved view over the graph is itself a graph node (dec-lenses-as-nodes), so it
964
+ is versioned, attributed, shareable, and forkable by copying the node — the same
965
+ inversion the queue makes for prioritization, made for presentation. The seed
966
+ ships the vocabulary (`schema-lens`, `schema-edge-focuses-on`) so a fresh graph
967
+ can author views without importing a schema first; the runner that executes them
968
+ is a server surface (`render_lens`, API.md §2).
969
+
970
+ A lens body carries fenced blocks:
971
+
972
+ - `## query` — declarative select/traverse/group/sort, JSON. Required.
973
+ - `## render` — builtin renderer config, JSON. `as: custom` requires a `##
974
+ custom` block.
975
+ - `## custom` — an optional js escape hatch, executed in the same
976
+ no-clock/no-randomness sandbox as schema attached code, so a render is a pure
977
+ function of (graph snapshot, lens node, params, now).
978
+ - `## actions` — optional write affordances `{id, label, on?, set, confirm?}`
979
+ (dec-ui-actions-as-transitions): `on` selects which rendered items carry the
980
+ affordance, `set` is a flat object of frontmatter changes (scalars or
981
+ `"$param"` bindings; never `id`/`type`). Invoking one is exactly one
982
+ revision-checked node update through the ordinary write path, arbitrated
983
+ fail-closed by the TARGET schema's `transitions()` gate. The lens declares
984
+ intent, the renderer surfaces it, the registry decides legality — only trusted
985
+ renderer hosts turn actions into controls, and the `## custom` sandbox never
986
+ sees them.
987
+
988
+ A lens is parameterized by an edge, not config: `{type: focuses-on, to: <node>}`
989
+ points it at the node it watches, and the runner resolves `"$focus"` in the query
990
+ from that edge when no runtime parameter is given. Re-pointing a view is then an
991
+ edge edit, and "which lenses watch this node" is graph traversal.
992
+
993
+ `traversable: false` keeps lenses out of compiler lineage walks — a lens says
994
+ what someone wants to look at, not what the graph knows. `capturable: false` for
995
+ the reason briefing and correction opt out: a lens is authored deliberately
996
+ against a query language, never drafted from a capture or a distilled transcript.
997
+
891
998
  ## Edge types and traversal weights
892
999
 
893
1000
  | edge | weight | meaning |
@@ -912,6 +1019,7 @@ assign that work to a person.
912
1019
  | `uses-profile` | 0.3 | this agent's default profile (the runtime+capability bundle it dispatches under); structural config binding, overridable per assignment/dispatch |
913
1020
  | `routed-to` | 0.3 | a question routed to this person for answering |
914
1021
  | `review-requested` | 0.3 | a review of this node is requested of this person (pending) — surfaces in their queue |
1022
+ | `focuses-on` | 0.2 | this lens is parameterized on that node (see "Lenses"); a view watching a node says little about the node's own lineage |
915
1023
  | `compiled-for` | — | briefing → its task/query (provenance only) |
916
1024
  | `shaped-by` | — | briefing → corrections applied (provenance only) |
917
1025
 
@@ -947,6 +1055,15 @@ is a single-org-graph relevance-topology fix — shared vocabulary ("auth",
947
1055
  "deploy", "migration") otherwise dilutes the gate across teams. A project-blind
948
1056
  compile (no `project`) ranks every node equally, exactly as before.
949
1057
 
1058
+ A node whose `authored_via` is `capture`, `distill`, or `gardener` — written
1059
+ with no human review at write time — is labeled `machine·<via>` (e.g.
1060
+ `machine·capture`) in every compiled digest/briefing line, the same
1061
+ machine-vs-human taxonomy `spor changes` already surfaces
1062
+ (task-cc-digest-render-authorship-marker). Without it a Haiku-distilled
1063
+ capture rendered typographically identical to a human-reviewed decision in
1064
+ the ambient session-start context; `mcp`/`rest`/`dispatch` writes and nodes
1065
+ with no `authored_via` at all render exactly as before (unmarked).
1066
+
950
1067
  A seed (the compile root, or each query-mode content match) always contributes
951
1068
  its **direct 1-hop lineage** to the structural arm, even when score-decay would
952
1069
  push a low-weight edge under the traversal threshold
@@ -986,10 +1103,27 @@ Free-text guidance, injected verbatim into the compile for the target.
986
1103
  how humans debug the context instead of the model: fix it once, it applies to
987
1104
  every future compile.
988
1105
 
1106
+ **Lifecycle** (`status`, 2026.07.15.1, issue-spor-corrections-no-applied-
1107
+ lifecycle): `active` (or no `status` at all) is the default and means the
1108
+ guidance is still standing — it keeps injecting at every in-scope compile.
1109
+ `applied` means a recompile already absorbed the correction's guidance into
1110
+ the target's briefing body, so it is retired and stops injecting — otherwise
1111
+ an absorbed correction is dead weight that keeps re-injecting into every
1112
+ future compile/serve of its target forever. Both the client compile
1113
+ (`correctionInScope`/`corrections` in `lib/kernel/graph.js`) and the server's
1114
+ serve-time gate (`correctionsForBriefing`/`applyBriefingCorrections`) filter
1115
+ out non-`active` corrections — keep the two in sync, held-guard-style. A
1116
+ node-targeted correction (`target: <node-id>`, not `global`/`project:<slug>`)
1117
+ is flipped to `applied` by the recompile flow that absorbs it (`/spor:brief`
1118
+ step 3); `global`/`project:<slug>` corrections are standing, broad-scope
1119
+ guidance and are not auto-retired by any single recompile.
1120
+
989
1121
  ## Briefing nodes
990
1122
 
991
1123
  Created by the distiller or `/spor:brief`. Carry `derived-from` edges to
992
- every source node and `shaped-by` edges to applied corrections, plus a
1124
+ every source node and `shaped-by` edges to the corrections that fired for this
1125
+ compile (not necessarily `status: applied` yet — a `global`/`project:<slug>`
1126
+ correction can `shaped-by` many briefings over its lifetime), plus a
993
1127
  `version:` integer. On recompile the old version is archived to
994
1128
  the graph home's `history/` and the version bumps. `brief-project` is the standing
995
1129
  project briefing injected at session start.
package/QUEUE.md CHANGED
@@ -551,7 +551,12 @@ later open question or `requires: human` edit flips a stamped item back:
551
551
  byte-identical when no readiness data exists.
552
552
 
553
553
  Readiness leads the why-line when decisive (`agent-ready: …` / `needs human:
554
- …`), and the envelope gains `counts_by_readiness` ({agent, human, untriaged},
554
+ …`), and an agent-ready item whose disposition would otherwise be `do` upgrades
555
+ its `suggest` to **`dispatch`** (issue-spor-suggest-dispatch-specified-not-emitted)
556
+ — the "hand this to an agent" signal the widget/render surfaces read. Only the
557
+ plain-actionable `do` base upgrades: the triage dispositions
558
+ (`close`/`blocked`/`triage`, and schema `approve`) stay supreme and are never
559
+ overridden by readiness. The envelope gains `counts_by_readiness` ({agent, human, untriaged},
555
560
  present only when there is readiness signal or a readiness facet was asked
556
561
  for) — the headline "how much of my queue can an agent take right now?" A
557
562
  **readiness filter** (`rankQueue({readiness})`, a class or array; today on the
package/README.md CHANGED
@@ -246,12 +246,21 @@ Templates can use placeholders such as:
246
246
  {{brief}}
247
247
  {{task}}
248
248
  {{node}}
249
+ {{id}}
249
250
  {{title}}
251
+ {{summary}}
252
+ {{type}}
253
+ {{status}}
254
+ {{date}}
250
255
  {{slug}}
251
256
  {{dir}}
252
257
  {{default}}
253
258
  ```
254
259
 
260
+ `{{id}}`, `{{summary}}`, `{{type}}`, `{{status}}`, and `{{date}}` come from the
261
+ dispatched node's own frontmatter fields (blank in free-text or `--backfill`
262
+ dispatch, where there is no target node).
263
+
255
264
  ## Local mode
256
265
 
257
266
  By default, Spor can run entirely on your machine.
@@ -3,10 +3,14 @@
3
3
  > **Installing:** `spor install <host>` (e.g. `spor install codex`) automates
4
4
  > the per-host recipe below — it resolves the `__SPOR_ROOT__` placeholder to
5
5
  > your checkout and merges the manifest into the host's config (idempotently;
6
- > `--scope user|repo`, `--all`, `--print`). The manual steps in each adapter's
7
- > README remain valid for hand-installs or when you want to see exactly what
8
- > lands where. Claude Code installs via its own plugin CLI (`spor install
9
- > claude`), not a file drop.
6
+ > `--scope user|repo`, `--all`, `--print`). Add `--mcp` (needs a configured
7
+ > server — `--server`/`--token` or `spor join`) to also auto-write the host's
8
+ > MCP server config (codex/gemini/opencode/copilot — see each README's "MCP:"
9
+ > section for the shape) and run `agents-md` to populate `AGENTS.md`, so one
10
+ > command finishes setup with no manual file edits. The manual steps in each
11
+ > adapter's README remain valid for hand-installs or when you want to see
12
+ > exactly what lands where. Claude Code installs via its own plugin CLI (`spor
13
+ > install claude`), not a file drop.
10
14
 
11
15
  The Spor client is a portable core behind per-host adapters
12
16
  (dec-cc-portable-core-adapters):
@@ -80,7 +80,8 @@ is just a manifest over `bin/spor-hook.js`.
80
80
  the distill engine short-circuits on it — so a `codex exec` distiller that
81
81
  fires its own Stop hook cannot recurse.
82
82
  - MCP: add the Spor server to `~/.codex/config.toml` for on-demand graph
83
- access (`query_graph`, `capture`, `show_queue`):
83
+ access (`query_graph`, `capture`, `show_queue`) — `spor install codex --mcp`
84
+ writes this automatically:
84
85
 
85
86
  ```toml
86
87
  [mcp_servers.spor]
@@ -34,7 +34,8 @@ to skip it; legacy `SUBSTRATE_*` names are still read.
34
34
  | distill (capture) | `agentStop` + `--debounce 900` | turn-scoped, carries `transcriptPath`; debounced like Codex |
35
35
 
36
36
  For on-demand graph access, add the Spor MCP server to
37
- `~/.copilot/mcp-config.json`:
37
+ `~/.copilot/mcp-config.json` (`spor install copilot --mcp` writes this
38
+ automatically):
38
39
 
39
40
  ```json
40
41
  { "mcpServers": { "spor": { "type": "http", "url": "https://spor.example.com/mcp", "headers": { "Authorization": "Bearer $SPOR_TOKEN" } } } }
@@ -53,7 +53,8 @@ export SPOR_NUDGE_CMD='gemini --model gemini-2.5-flash'
53
53
  to a generic extractor (every nested `.text` string).
54
54
  - Hook stdout must be pure JSON on Gemini; `bin/spor-hook.js` already
55
55
  discards engine stderr and emits either one JSON object or nothing.
56
- - For on-demand graph access, add the Spor MCP server to settings:
56
+ - For on-demand graph access, add the Spor MCP server to settings
57
+ (`spor install gemini --mcp` writes this automatically):
57
58
 
58
59
  ```json
59
60
  { "mcpServers": { "spor": { "httpUrl": "https://spor.example.com/mcp", "headers": { "Authorization": "Bearer $SPOR_TOKEN" } } } }
@@ -47,7 +47,7 @@ export SPOR_NUDGE_CMD='opencode run "$(cat)"'
47
47
  - The transcript handed to the distiller is rebuilt from the SDK on each
48
48
  idle event, so the debounced distill always sees the full final session.
49
49
  - MCP: add the Spor server to `opencode.json` for on-demand graph
50
- access:
50
+ access (`spor install opencode --mcp` writes this automatically):
51
51
 
52
52
  ```json
53
53
  { "mcp": { "spor": { "type": "remote", "url": "https://spor.example.com/mcp", "headers": { "Authorization": "Bearer {env:SPOR_TOKEN}" } } } }
package/bin/spor-hook.js CHANGED
@@ -12,6 +12,7 @@
12
12
  // Usage (from a host's hooks config; see adapters/):
13
13
  // spor-hook session-start [--host claude-code|codex|gemini|cursor|copilot|opencode]
14
14
  // spor-hook prompt-context [--host ...]
15
+ // spor-hook pre-tool [--host ...]
15
16
  // spor-hook post-tool [--host ...]
16
17
  // spor-hook distill [--host ...] [--debounce SECONDS]
17
18
  // spor-hook agents-md [--cwd DIR] # AGENTS.md floor; no stdin
@@ -27,6 +28,7 @@ const u = require(path.join(__dirname, "..", "scripts", "engines", "util"));
27
28
  const ENGINES = {
28
29
  "session-start": () => require("../scripts/engines/session-start").sessionStart,
29
30
  "prompt-context": () => require("../scripts/engines/prompt-context").promptContext,
31
+ "pre-tool": () => require("../scripts/engines/pre-tool").preTool,
30
32
  "post-tool": () => require("../scripts/engines/post-tool").postTool,
31
33
  distill: () => require("../scripts/engines/distill").distill,
32
34
  };
@@ -184,6 +186,13 @@ async function main() {
184
186
  const pend = path.join(graph, "journal", "pending-distill");
185
187
  if (!u.ensureDir(pend)) return;
186
188
  const pendingFile = path.join(pend, `${session}.json`);
189
+ // Mark the spooled payload as a debounce-approximated firing (turn-scoped
190
+ // quiescence on Codex/Copilot/OpenCode, not a genuine host session-end
191
+ // signal — a mid-session pause can trip it just as easily as a real
192
+ // goodbye) so the SessionEnd lease branch skips it
193
+ // (task-cc-client-sessionend-reserve-hook): reserving/releasing a still-
194
+ // live claim on a false positive would silently strand active work.
195
+ payload.spor_debounced = true;
187
196
  try {
188
197
  fs.writeFileSync(pendingFile, JSON.stringify(payload));
189
198
  } catch {