@sporhq/spor 0.2.8 → 0.2.9

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.
@@ -2,6 +2,6 @@
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.2.8",
5
+ "version": "0.2.9",
6
6
  "author": { "name": "losthammer" }
7
7
  }
package/API.md CHANGED
@@ -52,7 +52,8 @@ The server advertises **`instructions`** (the SDK initialize result, surfaced
52
52
  by clients as an "MCP Server Instructions" block). It frames the eleven tools
53
53
  as an **ORIENT → TRAVERSE → COMMIT** loop rather than eleven independent
54
54
  verbs, so an assistant can infer a recursive research chain — e.g. `my_queue`
55
- `query_graph` with `root_id` (deepen) → `render_lens` on a lineage lens →
55
+ (or `recent_changes` for "what happened lately") → `query_graph` with `root_id`
56
+ (deepen) → `render_lens` on a lineage lens →
56
57
  `put_node`/`capture` the outcome — instead of reconstructing it from per-tool
57
58
  descriptions. `query_graph`'s `root_id` is the recursive-deepen move (walk
58
59
  neighbor → neighbor); `render_lens` lineage lenses trace why a node exists,
@@ -172,14 +173,32 @@ Only an unreachable ingestion model is an error (`ingestion_unavailable`).
172
173
  ### `my_queue`
173
174
 
174
175
  The decision queue (QUEUE.md §4/§5). Input `{ "project"?: "slug",
175
- "limit"?: 20 }` → `{ "items": [{id, title, type, status, priority, score,
176
- signals: {blocking, heat, staleness, age_days}, suggest: "do|close", why}],
177
- "count": N, "questions": [] }` — queueable live nodes ranked by the default
178
- blend, each with a one-line *why*. Items already retired by a live inbound
179
- resolves/answers edge are excluded whatever their status field reads; open
180
- gardener findings ride along per item as `findings`. Structured output
181
- additionally carries `view` the queue projected into the view-tree catalog
182
- for the MCP-app widget (below).
176
+ "limit"?: 20, "offset"?: 0 }` → `{ "items": [{id, title, type, status,
177
+ priority, score, signals: {blocking, heat, staleness, age_days}, suggest:
178
+ "do|close", why}], "count": N, "offset": 0, "returned_count": N,
179
+ "total_count": N, "truncated": false, "next_offset": null, "questions": []
180
+ }` queueable live nodes ranked by the default blend, each with a one-line
181
+ *why*. Items already retired by a live inbound resolves/answers edge are
182
+ excluded whatever their status field reads; open gardener findings ride
183
+ along per item as `findings`. Structured output additionally carries
184
+ `view` — the queue projected into the view-tree catalog for the MCP-app
185
+ widget (below).
186
+
187
+ **Limit and pagination.** `limit` is the page size (default 20, **max
188
+ 100** — values above the max are clamped, not rejected); `offset` skips that
189
+ many items in the ranked order before the page (default 0). The aggregate
190
+ counts (`counts_by_type` / `_project` / `_suggest`, `total_count`) always
191
+ cover the **full** ranked set regardless of the page, so a single call
192
+ answers "how many issues vs tasks" without paging or project-splitting.
193
+ `returned_count` is the size of this page, `truncated` is true when more
194
+ items follow it, and `next_offset` is the offset to pass next to continue
195
+ (null on the last page). Pagination is **offset over a point-in-time ranked
196
+ slice, not a cursor**: the queue re-ranks on every call (heat, age, leases,
197
+ and status all shift), so an offset resumes the same slice only if the
198
+ ranking has not changed between calls — the benign failure mode is an item
199
+ seen twice or skipped once across a re-rank, never a hard error. Walk the
200
+ whole queue by re-calling with `offset = next_offset` until `next_offset` is
201
+ null.
183
202
 
184
203
  ### `ask_question`
185
204
 
@@ -216,6 +235,25 @@ render). Unknown `lens_id` still errors, carrying the same `catalog`/`lenses`
216
235
  list; engine failures (missing param, broken blocks) error with the message
217
236
  verbatim.
218
237
 
238
+ ### `recent_changes`
239
+
240
+ The team's recent-activity feed — the temporal entry point the other read
241
+ tools lack (`query_graph` is semantic search, `my_queue` is forward-looking
242
+ open work, `render_lens` renders current state). It answers "what changed /
243
+ what was done in the last N hours", "what did the agents write overnight", and
244
+ "what landed since `<commit>`". Input `{ "since"?, "project"?, "limit"? }` →
245
+ `{ "changes": [{id, change, commit, date, committed_by, type, title,
246
+ authored_via, author}], "count", "head", "since", "project", "generated_at",
247
+ "node_ids" }`. `since` is a 7–40 hex commit sha (changes in `sha..HEAD`) or a
248
+ date/relative phrase git understands (`"12 hours ago"`, `"2026-06-15"`);
249
+ omitted, it returns the most recent changes. `project` scopes to one project's
250
+ nodes (deletions, whose project is gone, are necessarily omitted when scoped).
251
+ Each entry is decorated with the node's CURRENT `authored_via`
252
+ (`capture`/`distill`/`gardener` = machine, else human) — the trust signal the
253
+ rendered digest/briefing hides. The tool returns the changed nodes as data;
254
+ the model writes the prose summary (no LLM on this path). It is the MCP twin of
255
+ `GET /v1/changes` (§3), sharing one core so the two surfaces never drift.
256
+
219
257
  ### The MCP-app widget (`ui://spor/view-tree.html`)
220
258
 
221
259
  `my_queue` and `render_lens` declare a UI resource via
@@ -237,7 +275,7 @@ endpoint is the REST twin of a core call:
237
275
  |---|---|---|
238
276
  | `GET /v1/status` | session-start, monitoring | `{node_count, projects: {...}, head, uptime, metrics}`; doubles as the health check. `?titles=1` adds `titles: [{id, type, project, title}]` — the one-round-trip graph index the distiller dedups against |
239
277
  | `GET /v1/me` | `spor whoami`/`status`, onboarding | identity echo for the bearer token → `{person, name, email, bound, is_admin}`. `bound:false` means the token authenticates but maps to **no person node** (legacy/OAuth, or minted before the node existed), so routed questions and the personal queue will be empty — the client warns on it (the silent identity-degradation signal). `is_admin` reflects the `stewards→root` edge that gates the token-admin surface |
240
- | `GET /v1/briefing/{project}` | session-start | read the `brief-<project>` node → `{found, version, body, graph_status}`. The slug resolves through project-node aliases (GRAPH.md "Project identity nodes") before lookup. 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 |
278
+ | `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 |
241
279
  | `POST /v1/digest` `{query, min_sim?}` | prompt-context | digest-mode compile → `{found, text}`; `found: false` is a successful empty result |
242
280
  | `GET /v1/nodes/{id}` | /spor:brief | `get_node` semantics; when a live inbound resolves/answers edge contradicts a still-open status the response carries `resolution`, and open gardener findings about the node ride along as `open_findings` |
243
281
  | `POST /v1/nodes` | 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 |
@@ -245,10 +283,11 @@ endpoint is the REST twin of a core call:
245
283
  | `POST /v1/nodes/{id}/status` `{status}` | scripts, mechanical writers | `set_status` semantics (§1): one-scalar update through the `transitions()` gate |
246
284
  | `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 |
247
285
  | `GET /v1/commits/{sha}?repo=` | 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 |
286
+ | `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 |
248
287
  | `POST /v1/capture` | distill, /spor:defer | `capture` semantics: `{text, context: {project, during, blocks?, needed_by?}, source?}` → ingestion model + validate + commit → `{status, ids, nodes, summary, warnings}`. `source: "distill"` marks backstop captures in the journal. `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 |
249
288
  | `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 |
250
289
  | `POST /v1/corrections` | /spor:correct | `propose_correction` semantics → 201 `{status, id, revision, warnings}` |
251
- | `GET /v1/queue?project=&limit=` | /spor:next, session-start | the ranked decision queue: `{items, count, muted?, dormant?, questions, findings, 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` are the routed-to-me-plus-unrouted views for the authenticated identity |
290
+ | `GET /v1/queue?project=&assignee=&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, findings, 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` are the routed-to-me-plus-unrouted views for the authenticated identity. `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. `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) |
252
291
  | `POST /v1/questions` `{text, title?, mentions?}` | ask_question's REST twin | file a question node; deterministically routed to the steward of the closest relevance-neighborhood node, unrouted if none → 201 `{status, id, routed_to, via, asker, revision, warnings}` |
253
292
  | `POST /v1/gardener` | ops cron / on demand | run a gardener sweep now; findings filed as queue items → `{filed, resolved, ..., generated_at}` |
254
293
  | `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 |
@@ -366,3 +405,55 @@ SPOR_TOKEN=spor_pat_... # per-user token (§4)
366
405
  Failure policy: **fail open, never block** — a hook must never break a
367
406
  session; connection refused, timeout, 5xx, and auth failure all collapse to
368
407
  "the graph has nothing for you".
408
+
409
+ Because fail-open hides degradation by design — a crashing engine and a
410
+ quiet success look identical, and stranded captures pile up unseen in
411
+ `outbox/dead/` — the client carries three operability surfaces
412
+ (task-cc-client-hook-operability-diagnostics):
413
+
414
+ - **Crash telemetry.** The dispatcher's top-level catch appends one line to
415
+ `journal/remote.log` (`dispatcher <event>: crashed (fail-open, exit 0):
416
+ …`) before honoring the exit-0 contract, so a crash is distinguishable
417
+ from healthy silence after the fact.
418
+ - **Session-start nudge.** When `outbox/dead/` is non-empty or the outbox
419
+ spool exceeds a depth threshold, session-start splices a one-line warning
420
+ into the same channel as the `OFFLINE`/`AUTH FAILED` banner, pointing at
421
+ `spor-hook doctor`.
422
+ - **`spor-hook doctor`.** An operator-run, read-only diagnostic (no stdin,
423
+ exits 0) that reports resolved mode, server reachability, token validity,
424
+ outbox + dead-letter counts with the oldest file's age, cached-briefing
425
+ freshness, and the trailing error lines from `journal/remote.log` and
426
+ `journal/distill.log`.
427
+
428
+ ### 6.1 Per-repo graph home (local-mode git sharing)
429
+
430
+ In **local mode** a code repo can bind itself to a specific graph home with a
431
+ `graph: <path>` key in its committed `.spor` marker (the same flat `key: value`
432
+ marker that carries `repo:`/`project:`). This is how a team shares one graph
433
+ for free over plain git, with no server (dec-spor-local-mode-sharing-boundary):
434
+
435
+ ```
436
+ # .spor at the repo root
437
+ repo: my-service
438
+ graph: ../my-team-graph # path, resolved relative to this marker
439
+ ```
440
+
441
+ Contract:
442
+
443
+ - **Precedence.** The `graph:` binding **overrides `SPOR_HOME`** — it is the
444
+ one input above the environment — but loses to an explicit CLI `--home`. A
445
+ `home` set in `.spor.json` config stays *below* the environment, unchanged; only the
446
+ `.spor` marker `graph:` key beats env. (A contributor with a personal global
447
+ `SPOR_HOME` therefore still inherits the shared graph inside a shared-graph
448
+ repo.)
449
+ - **Resolution.** Relative to the marker's own directory (so a committed
450
+ relative path is stable regardless of cwd); nearest-ancestor marker carrying
451
+ a `graph:` key wins.
452
+ - **Mode.** Local mode only. With `SPOR_SERVER` set the server is the graph and
453
+ the marker is ignored.
454
+ - **Hygiene.** When a marker home is in force the client maintains a
455
+ `.gitignore` in it covering the machine-local, per-person state
456
+ (`journal/`, `cache/`, `outbox/`, `auth/`, `config.json`); only the durable
457
+ `nodes/` and brief `history/` are committed. The SessionEnd distiller leaves
458
+ distilled nodes **uncommitted** (for the human PR flow) instead of
459
+ auto-committing when the graph home is the same git repo as the code repo.
package/GRAPH.md CHANGED
@@ -136,6 +136,46 @@ a change still in review has not delivered. So:
136
136
  exactly as before, so a change still in review keeps its task live without any
137
137
  hand-managed `open` status.
138
138
 
139
+ ## The org-defined policy layer
140
+
141
+ "What it takes to reach a resolving/done state" beyond the native floor —
142
+ quorum, qualified approvers, agent-vs-human distinctions — lives in **`policy`
143
+ nodes**, a reserved schema kind layered ON TOP of the per-type
144
+ `transitions()` gate (task-cc-policy-layer, dec-spor-policy-layer-activate;
145
+ dec-spor-definition-of-done-org-policy Stage 2). A policy node is an ordinary
146
+ `type: schema` node with `kind: policy`, a `governs` scope block in its fenced
147
+ `json` payload, and an attached fenced `js` block exporting
148
+ `gate(current, proposed, view) -> { allow, reason? }`. The payload declares the
149
+ scope; the gate is the rule. For example, a policy `governs`-ing tasks in
150
+ project `my-team` with a payload `{ "governs": { "types": ["task"], "projects":
151
+ ["my-team"] } }` and a `gate` that, on a `done` transition, counts
152
+ `view.approvals` whose `roles` include `reviewer` and denies unless there are at
153
+ least two — the definition-of-done quorum gate.
154
+
155
+ - **Selection is governs-traversal.** `governs.types` restricts the policy to
156
+ those node types, `governs.projects` to those project slugs; an absent or
157
+ empty axis means "any". A node is governed by every policy whose every
158
+ present axis matches it — so an org-wide policy (no `governs`) and a
159
+ team-scoped one can both apply, most-specific first.
160
+ - **The gate is AND-ed with `transitions()`, never replaces it.** Every
161
+ governing policy's `gate()` must also `allow` the write; any deny stops it.
162
+ A policy can only ADD a constraint — it can never loosen a type's
163
+ `transitions()` or the native self-approval floor beneath both
164
+ (dec-cc-policy-floor-now-layer-deferred). Like `transitions()`, the gate is
165
+ fail-closed (a crashing gate denies) and runs on UPDATE only.
166
+ - **Approvals are review edges.** The gate context carries `view.approvals`:
167
+ the node's own `reviewed-by`/`approved-by` edges to `person` nodes, each
168
+ joined to that person's `roles` register, with the node's own author
169
+ excluded (the self-approval floor — a policy can't be used to launder it).
170
+ The first concrete rule is the **definition-of-done quorum gate**: a work
171
+ node's `done` transition additionally requires a quorum of approvals from
172
+ qualified roles. (The `reviewed-by`/`approved-by` edge types and a native
173
+ review surface ship with review-as-graph-object; the policy kind, scope
174
+ selection, and the gate AND are landed now.)
175
+ - **Policy nodes go through the same proposal/activation flow they govern** —
176
+ a proposed policy is inert until a *different* identity activates it (the
177
+ native floor protects against self-amendment circularity).
178
+
139
179
  ## Norm ride-along
140
180
 
141
181
  A `norm` node (any `always_on` type) rides along on every compile — but the
@@ -210,6 +250,13 @@ edges:
210
250
  into distinct identities. With no subtree marker the search reaches the root
211
251
  and inference is unchanged. Zero-config slug inference stays the default, and
212
252
  a graph with no repo nodes behaves exactly as before.
253
+ - The same flat marker can also carry a `graph: <path>` key — a per-repo
254
+ **graph home binding** (NOT identity), for free local-mode graph sharing over
255
+ plain git. It points the repo at a shared graph home (resolved relative to the
256
+ marker dir) and overrides `SPOR_HOME` in local mode; a contributor with their
257
+ own personal `SPOR_HOME` still inherits the shared graph inside the repo. See
258
+ API.md §6.1 for the full contract (precedence, the generated `.gitignore`, and
259
+ the distiller's PR-flow behavior).
213
260
  - **Git worktrees** resolve to their main repo, not the worktree directory's
214
261
  basename. A linked worktree shares the main repo's root-commit sha and
215
262
  remotes, so inferring identity from its (markerless, often throwaway-named)
@@ -245,9 +292,16 @@ date: 2026-06-13
245
292
  ---
246
293
  ```
247
294
 
248
- - **Reads.** repo-scoped = nodes stamped that repo's slug; project-scoped = the
249
- union over the nodes of every repo `grouped-under` the project. Session-start
250
- injects the repo brief AND the project brief.
295
+ - **Reads.** Every read surface (queue, brief, digest) resolves its scope token
296
+ through one shared up-resolution step (dec-spor-queue-slug-resolves-to-grouping):
297
+ a BARE repo slug resolves up to its home-project grouping and reads the union
298
+ over every repo `grouped-under` it — the intuitive token returns the whole
299
+ product, matching the project brief session-start already injects. The repo
300
+ NODE id (`repo-<slug>`) is the escape hatch back to single-repo scope; an exact
301
+ grouping id (`proj-<slug>`) is used directly; an ungrouped repo (or a slug no
302
+ repo node claims) falls back to itself. So project-scoped = the grouping union,
303
+ single-repo-scoped = that one repo via its node id. Session-start injects the
304
+ repo brief AND the project brief.
251
305
  - **Active project** (dec-cc-active-project-declared-default). When a repo
252
306
  serves more than one project, cwd no longer names the active one. A session's
253
307
  active project is the repo's home project (its `grouped-under` edge) by
@@ -274,6 +328,7 @@ type: person
274
328
  title: Anthony Allen
275
329
  summary: Maintainer; stewards the schema registry and the hook engines.
276
330
  email: losthammer@gmail.com
331
+ roles: [reviewer, maintainer]
277
332
  queue_mute: [some-noisy-project, task-noisy-job@2026-07-01]
278
333
  date: 2026-06-10
279
334
  edges:
@@ -295,13 +350,21 @@ edges:
295
350
  neighborhood to the closest steward and writes a `routed-to` edge to that
296
351
  person; an unrouted question (no steward matched) surfaces to everyone.
297
352
  - **`assigned`** points work (task/issue) at a person; per-person queues filter
298
- on it. **`answers`** points any answer node back at the `question-` it
353
+ on it (the queue's `assignee` parameter `GET /v1/queue?assignee=<person>`,
354
+ unioned with the person's `stewards` edges; QUEUE.md §5). **`answers`** points
355
+ any answer node back at the `question-` it
299
356
  resolves — the answer loop is lineage, not messaging, so the asker's next
300
357
  compile pulls the answer through the question's neighborhood.
301
358
  - **`queue_mute`** (flat inline list of project slugs or node ids, each with an
302
359
  optional `@YYYY-MM-DD` expiry) is per-viewer presentation only: the queue
303
360
  hides those items for this person and reports how many it hid; they stay live
304
361
  and visible to everyone else (QUEUE.md §4).
362
+ - **`roles`** (flat inline list, e.g. `roles: [reviewer, maintainer]`) is the
363
+ qualification register the org-defined policy layer reads. A scoped `policy`
364
+ node's gate counts approvals from persons holding a named role — the
365
+ definition-of-done quorum gate is the first such rule (see "The org-defined
366
+ policy layer" below). Declarative data only; absent it, a person holds no
367
+ roles and the field has no effect.
305
368
 
306
369
  ### Onboarding a team member
307
370
 
package/QUEUE.md CHANGED
@@ -287,12 +287,34 @@ levels, and keeping them distinct is the design:
287
287
  gate context so identity-aware rules are expressible.
288
288
 
289
289
  A wider org-defined **policy layer** — a reserved `policy` kind in the
290
- schema family whose gate would run on every write AND-ed with the per-type
290
+ schema family whose gate runs on a write AND-ed with the per-type
291
291
  `transitions()`, carrying role lists, quorum rules, agent-vs-human
292
- distinctions, and the queue-blend override as one mechanism — is
293
- deliberately deferred until a real org rule demands more than the floor
294
- (dec-cc-policy-floor-now-layer-deferred). The enabling hook (`view.actor`)
295
- ships now so the layer needs no core change when it comes.
292
+ distinctions, and (eventually) the queue-blend override as one mechanism — was
293
+ deferred until a real org rule demanded more than the floor
294
+ (dec-cc-policy-floor-now-layer-deferred), then activated once one did
295
+ (dec-spor-policy-layer-activate). The first rule it expresses is the
296
+ **definition-of-done quorum gate** (dec-spor-definition-of-done-org-policy
297
+ Stage 2): a `policy` schema node, selected for a node by **governs-traversal**
298
+ (`governs.{types,projects}`; nearest scope wins, an org-wide policy still
299
+ applies), runs `gate(current, proposed, view)` AND-ed with the type's
300
+ `transitions()` — any deny stops the write, so a policy can only ADD a
301
+ constraint, never loosen the gate or the native floor beneath it. The
302
+ quorum gate requires a work node's `done` transition to carry a quorum of
303
+ approvals from qualified roles: `view.approvals` is the node's
304
+ `reviewed-by`/`approved-by` edges to `person` nodes joined to each person's
305
+ `roles` register, with the node's own author excluded (the self-approval floor).
306
+ The enabling hook (`view.actor`) shipped with the floor, so this was net-new
307
+ policy-kind work, not core surgery (GRAPH.md "The org-defined policy layer").
308
+
309
+ Still on the layer's roadmap, beyond the quorum gate: the agent-vs-human
310
+ claim-eligibility promoted-set (task-cc-claim-eligibility-policy), the
311
+ queue-blend override absorbed as a `policy` rather than the separate
312
+ `queue-policy` singleton (task-cc-schema-queue-policy-override), the seed
313
+ `reviewed-by`/`approved-by` edge types + a native review surface
314
+ (review-as-graph-object, Stage 3, which supplies the approval edges the quorum
315
+ gate counts), and a fuller governs-traversal that walks the project/path
316
+ hierarchy via `governs`/`governed-by` edges rather than matching the flat
317
+ `governs.{types,projects}` scope.
296
318
 
297
319
  ## 3. The capture flow, end to end
298
320
 
@@ -339,11 +361,28 @@ signals via its schema's `queueSignals()`:
339
361
  merely retrieved — a single prompt-submit digest lists ~60 — so only
340
362
  work-class ops count (issue-cc-heat-amplification and its digest round).
341
363
  - **front** — the viewer's own write-class ops on the node (puts, edges,
342
- status flips, captures during it) over the last week, identity-scoped
343
- from the same journal (`store.writeActivity`). Counts the node itself
344
- only no neighborhood propagation so provenance hubs can't ride it;
345
- capped below the p1 bump so human priority stays supreme
346
- (dec-cc-queue-front-from-attribution).
364
+ status flips, captures during it) over a rolling window (default 7 days),
365
+ identity-scoped. In **remote** mode the server injects it from the same
366
+ request journal (`store.writeActivity`). In **local** mode there is no
367
+ request log, so it is reconstructed from **git history**
368
+ (`gitFront()` in `lib/queue.js`, task-cc-local-front-productionize): the
369
+ graph home is a git repo the distiller auto-commits into, the local
370
+ `git config user.email` is the viewer identity, and a commit that
371
+ adds/modifies/renames `nodes/<id>.md` is a write-class op on that node.
372
+ `git log --since=<days> days ago --author=<email> --diff-filter=ACMR
373
+ --name-only -- nodes/` yields the same `{nodeId: count}` map the server
374
+ builds; each commit lists a touched file once, so occurrences across the
375
+ log = the node's write count. Pure deletes (D) are excluded — a removed
376
+ node isn't live work — and there is no neighborhood spread, matching the
377
+ server. It is best-effort and fail-open: not a git repo, no commits, or an
378
+ unset `user.email` yields an empty map (front 0 everywhere, the pre-front
379
+ ordering). The window and an on/off toggle live in the config cascade
380
+ (`queue.front.days` / `queue.front.enabled`, env `SPOR_QUEUE_FRONT_DAYS` /
381
+ `SPOR_QUEUE_FRONT`); the `lib/queue.js` CLI flags `--days` / `--no-front`
382
+ override them. Counts the node itself only — no neighborhood propagation —
383
+ so provenance hubs can't ride it; capped below the p1 bump so human
384
+ priority stays supreme; and the why-line states the actual window
385
+ ("N writes in the last D days") (dec-cc-queue-front-from-attribution).
347
386
  - **staleness** — anchors superseded or gone; high staleness suggests
348
387
  closing, not doing.
349
388
  - **age**, and any org-specific signal the schema's code adds (SLA clocks,
@@ -392,10 +431,17 @@ and edges see it normally; only queue ranking waits.
392
431
 
393
432
  The queue is a compile mode, not a new store:
394
433
 
395
- - `rankQueue(graph, {person?, project?})` in the core: collect nodes whose
396
- schema says `queueable: true` and status is live, run `queueSignals()`,
434
+ - `rankQueue(graph, {assignee?, project?, ...})` in the core: collect nodes
435
+ whose schema says `queueable: true` and status is live, run `queueSignals()`,
397
436
  blend, return ranked items each carrying its one-line *why* ("blocks 3
398
- open tasks; anchors hot this week").
437
+ open tasks; anchors hot this week"). `assignee` (a person node id) narrows
438
+ the queue to the work that person carries — see "Per-person queues" below.
439
+ - The `project` filter resolves through the one shared up-resolution step every
440
+ read surface shares (dec-spor-queue-slug-resolves-to-grouping): a BARE repo
441
+ slug resolves up to its home-project grouping and unions the member repos'
442
+ queues — the intuitive token returns the whole product; the repo NODE id
443
+ (`repo-<slug>`) is the escape hatch back to one repo; an exact grouping id
444
+ (`proj-<slug>`) is used directly; an ungrouped repo falls back to itself.
399
445
  - Exposed as `GET /v1/queue` (hooks, session-start "open front" line),
400
446
  `my_queue` (the registered MCP stub finally does work — and when Tier 2
401
447
  routing lands, routed questions and stewarded items join the same queue),
@@ -406,8 +452,28 @@ The queue is a compile mode, not a new store:
406
452
  Tier-2 routed questions are all queueable schemas. "Home is a decision
407
453
  queue" falls out of one mechanism.
408
454
 
409
- Per-person queues need only Tier 2's person nodes and `stewards`/`assigned`
410
- edge schemasassignment is an edge like everything else.
455
+ **Per-person queues** (task-cc-queue-assignee-filtering). `rankQueue`'s
456
+ `assignee` parameterand `GET /v1/queue?assignee=<person-id>` /
457
+ `my_queue {assignee}` (use `assignee=me` to bind to the caller) — scopes the
458
+ ranked queue to the work one person carries: the union of nodes with an
459
+ outbound `assigned` edge to them (work→person) and the nodes they `steward`
460
+ (person→node). Assignment is an edge like everything else (Tier-2's person
461
+ nodes plus the seed `assigned`/`stewards` schemas), so this needed no new
462
+ store — the same blend, signals, and why-lines, narrowed to a person. A
463
+ manager answers "who is carrying what" and "what is X blocked on" by naming
464
+ each person in turn; an unknown or departed person id returns an empty queue,
465
+ never the whole team's work. The filter is a hard scope like `project` (it
466
+ composes with it) and never overrides liveness — a terminal task assigned to
467
+ the person still leaves the queue.
468
+
469
+ A **grouped** team-capacity view (everyone's open work at once, bucketed by
470
+ owner) is a first-party manager need, not merely a customer illustration —
471
+ the `lens-team-capacity` example (in the meridian fixture corpus) is the
472
+ shape. Shipping it as a live-native default lens is deferred: the lens engine
473
+ groups by a frontmatter field, so a live grouping by the `assigned` *edge*
474
+ awaits group-by-edge-target support; the meridian fixture works because it
475
+ also carries an `owner:` field. Until then the `assignee` filter is the
476
+ shipped per-person surface (iterate it per person for the capacity picture).
411
477
 
412
478
  ## 6. The gardener
413
479
 
package/README.md CHANGED
@@ -40,6 +40,21 @@ the same step. Re-running is idempotent — it refreshes the path and never
40
40
  duplicates your other hooks. (In Claude Code you can also install by hand:
41
41
  `/plugin marketplace add sporhq/spor` then `/plugin install spor@spor`.)
42
42
 
43
+ Upgrading. Bumping the package does **not** refresh what an agent already
44
+ loaded — Claude Code runs its own cached copy of the plugin, so new
45
+ skills/hooks won't appear until that copy is updated. After an npm bump, run:
46
+
47
+ ```bash
48
+ npm install -g @sporhq/spor # update the package on disk
49
+ spor upgrade # refresh every wired host to it, then restart
50
+ ```
51
+
52
+ `spor upgrade` updates Claude Code's plugin (`marketplace update` +
53
+ `plugin update`) and re-points the hook hosts at the new install; pass a host
54
+ (`spor upgrade claude`) to scope it, or `--print` for a dry run. `spor status`
55
+ flags the gap on its own — it shows the loaded plugin version and marks it
56
+ `STALE` when the package on disk is newer.
57
+
43
58
  Then onboard a repo — one command, from inside it:
44
59
 
45
60
  ```bash
@@ -74,6 +89,7 @@ spor dispatch "wire up token rotation in the pipeline" # free-text task, brief
74
89
  spor dispatch issue-86 # a node id — briefs its neighborhood
75
90
  spor dispatch --from-queue # the top item from 'spor next'
76
91
  spor dispatch --backfill # onboard this repo via /spor:backfill
92
+ spor dispatch <task> --template prompt.tpl # launch your own prompt, context injected
77
93
  spor dispatch <task> --print # dry run: show dir, prompt, argv
78
94
  ```
79
95
 
@@ -88,6 +104,16 @@ Spor already knows where that repo lives. Flags pass through to `claude`
88
104
  (`--model`, `--permission-mode`, `--agent`, `--name`); `--full` embeds the whole
89
105
  neighborhood and `--no-brief` skips the briefing.
90
106
 
107
+ **Your own prompt.** By default the briefing is prepended to a built-in prompt
108
+ shell. Pass `--template <file>` (or set a default path in `dispatch.template`)
109
+ to supply your own prompt instead, with Handlebars-style `{{placeholder}}`
110
+ tokens substituted from the dispatch context: `{{brief}}` (the compiled
111
+ briefing, aka `{{briefing}}`/`{{neighbourhood}}`), `{{task}}`, `{{node}}`,
112
+ `{{title}}`, `{{slug}}`, `{{dir}}`, and `{{default}}` (the whole built-in
113
+ prompt, so you can wrap rather than replace it). Unknown placeholders blank out
114
+ with a warning. The template takes over the prompt entirely — you decide where
115
+ the brief and task land.
116
+
91
117
  ## What your agent gets, and gives back
92
118
 
93
119
  The loop runs without you having to drive it:
@@ -153,10 +179,11 @@ edges to the nodes it relates to; the format is documented in
153
179
  ## Team mode
154
180
 
155
181
  Single-player Spor is the whole client. Team mode is what you reach for when
156
- the graph should be shared: one graph served to your entire team — the people
157
- and their agents alike — with per-identity attribution on every node,
182
+ the graph should be shared *live*: one graph served to your entire team — the
183
+ people and their agents alike — with per-identity attribution on every node,
158
184
  transactional writes so concurrent work doesn't clobber, and a shared
159
- decision queue ranked across the team.
185
+ decision queue ranked across the team. (A team can also share a graph for free
186
+ over plain git, with no server — see "Sharing a graph over git" below.)
160
187
 
161
188
  Team mode adds something a personal graph can't do: when a question can't be
162
189
  answered from what's already there, it routes to the person most likely to
@@ -176,6 +203,59 @@ server is ever unreachable, the client fails open — it falls back to a local
176
203
  cache or to nothing, never blocking your session. The full contract a client
177
204
  programs against is in [API.md](API.md).
178
205
 
206
+ > **Split-brain warning (local mode + a claude.ai Spor connector).** If your
207
+ > Claude Code is logged into a claude.ai account that has a **Spor MCP
208
+ > connector**, that connector is live in your sessions too — even in local
209
+ > mode — so you have *two* write surfaces at once: the local `$SPOR_HOME` file
210
+ > graph (skills, CLI, and ambient hook captures) and the remote team graph
211
+ > behind the connector (MCP-tool captures). Captures can split across them
212
+ > with no signal which got which. Pick one surface: either set `SPOR_SERVER`/
213
+ > `SPOR_TOKEN` to go fully remote, or disable the claude.ai Spor connector to
214
+ > stay fully local. `spor status` detects this and warns.
215
+
216
+ A team graph can also carry **lenses** — saved views (a board, a table, a
217
+ lineage tree) defined as nodes and rendered by the server. View them from the
218
+ shell:
219
+
220
+ ```bash
221
+ spor lens # list the available lenses
222
+ spor lens lens-roadmap # render one to the terminal (text)
223
+ spor lens lens-roadmap --format json # the raw view tree, for piping
224
+ spor lens lens-roadmap --project wf # pass a lens parameter
225
+ ```
226
+
227
+ Rendering happens server-side (the same engine as the `render_lens` MCP tool),
228
+ so `spor lens` is a team-mode verb — in local mode it tells you to point at a
229
+ team graph rather than failing.
230
+
231
+ ## Sharing a graph over git — no server
232
+
233
+ A team can share one graph for free, with no live server, by treating the
234
+ graph as the ordinary git repo it already is — everyone clones, pulls, and
235
+ pushes it. Point a code repo at a shared graph with a `graph:` line in its
236
+ committed `.spor` marker:
237
+
238
+ ```
239
+ # .spor — committed at the repo root
240
+ repo: my-service
241
+ graph: ../my-team-graph
242
+ ```
243
+
244
+ The path resolves relative to the marker, so the conventional layout is the
245
+ graph as a **sibling** repo (`../my-team-graph`) each teammate clones alongside
246
+ the code. This binding **overrides `SPOR_HOME`**: even a contributor with their
247
+ own personal `~/.spor` inherits the shared graph while working in this repo, so
248
+ prior decisions and dismissed approaches come for free. Distilled nodes land in
249
+ the shared graph as plain markdown and ride your normal PR flow.
250
+
251
+ Spor keeps the shared graph clean for you: it writes a `.gitignore` covering
252
+ the machine-local, per-person state (`journal/`, `cache/`, `outbox/`, `auth/`,
253
+ `config.json`) so only the durable `nodes/` (and brief `history/`) are
254
+ committed, and the end-of-session distiller leaves nodes uncommitted for your
255
+ PR — rather than auto-committing — when the graph lives inside the code repo
256
+ itself. This is the free tier's sharing model; the live **Team mode** server
257
+ above adds real-time concurrent writes, question routing, and hosted isolation.
258
+
179
259
  ## Configuration
180
260
 
181
261
  Settings can live in config files instead of environment variables, cascading
@@ -227,12 +307,38 @@ verifiable rather than asserted:
227
307
  - `SPOR_DISTILL=0` (or `distill.enabled: false`) disables distillation — you
228
308
  keep briefings with no SessionEnd model spend.
229
309
  - `SPOR_NUDGE=0` (or `nudge.enabled: false`) disables the capture nudge.
310
+ - The capture nudge runs synchronously after a `.md` write, so its backend's
311
+ latency is felt in the tool loop. `SPOR_NUDGE_CMD` (or `nudge.cmd`) points it
312
+ at a faster classifier — Gemini Flash via the bundled
313
+ `scripts/distill-gemini.sh` returns in ~2–7s versus ~17s for a `claude -p`
314
+ cold boot, with no quality regression. Two bounds keep a bad session cheap:
315
+ `SPOR_NUDGE_MAX` (or `nudge.maxCalls`, default 20) caps classifier calls per
316
+ session, and `SPOR_NUDGE_TIMEOUT` (or `nudge.timeoutMs`, default 30000ms)
317
+ kills a hung backend. The distiller has the same `distill.cmd` and
318
+ `distill.timeoutMs` (`SPOR_DISTILL_TIMEOUT`, default 120000ms) levers. See
319
+ [adapters/README.md](adapters/README.md) for the backend contract.
230
320
  - Every call appends a row to `$SPOR_HOME/journal/llm-calls/<date>.jsonl` with
231
321
  token usage and the model-reported cost. `spor cost` (`--since YYYY-MM-DD`,
232
322
  `--project <slug>`, `--json`) totals it by source. Custom `SPOR_DISTILL_CMD`
233
323
  /`SPOR_NUDGE_CMD` backends return text only, so their rows count as
234
324
  cost-unknown.
235
325
 
326
+ ### Health and diagnostics
327
+
328
+ The hooks fail open — they never break a session — which also means a dead
329
+ server or a revoked token degrades quietly. To make that legible, run:
330
+
331
+ ```bash
332
+ spor-hook doctor
333
+ ```
334
+
335
+ It prints a one-shot health report: resolved mode, server reachability and
336
+ token validity, the outbox and dead-letter depth (with the oldest stranded
337
+ capture's age), how fresh the cached briefing is, and the most recent error
338
+ lines from `journal/remote.log` and `journal/distill.log`. When captures
339
+ have been stranded (a dead-letter pile-up or a deep outbox), session-start
340
+ also surfaces a one-line nudge alongside its status banner pointing you here.
341
+
236
342
  ## Pointers
237
343
 
238
344
  - [GRAPH.md](GRAPH.md) — the node and edge format: what a node file looks
@@ -56,3 +56,35 @@ export SPOR_DISTILL_CMD="$HOME/repos/spor/scripts/distill-gemini.sh"
56
56
 
57
57
  The journal records the backend per call (`journal/llm-calls/`), so distill
58
58
  quality stays observable across backends through the same eval loop.
59
+
60
+ ## Capture-nudge backend
61
+
62
+ The post-tool capture nudge (a Write/Edit of substantial prose to a `.md`
63
+ outside the graph runs a classifier and, if it finds capturable facts, injects
64
+ a capture-or-dismiss nudge) uses the **same backend contract** as the
65
+ distiller — prompt on stdin, response on stdout — but a separate variable so
66
+ you can point the two at different models:
67
+
68
+ - `SPOR_NUDGE_CMD` — custom classifier command (defaults to `claude -p --model
69
+ haiku`, same as the distiller). Because the contract is identical,
70
+ `scripts/distill-gemini.sh` doubles as a nudge backend (it keys off
71
+ `SPOR_DISTILL_MODEL`, default `gemini-3.5-flash`):
72
+
73
+ ```sh
74
+ export SPOR_NUDGE_CMD="$HOME/repos/spor/scripts/distill-gemini.sh"
75
+ ```
76
+
77
+ This is the recommended latency fix: the nudge runs **synchronously** in the
78
+ tool loop, and Gemini Flash returns in ~2–7s versus ~17s for a `claude -p`
79
+ cold boot, with no quality regression (dec-cc-nudge-flash-latency).
80
+ - `SPOR_NUDGE=0` — disable the nudge entirely.
81
+ - `SPOR_NUDGE_MAX` — per-session ceiling on classifier calls (default 20),
82
+ bounding spend/latency in a docs-heavy session where many `.md` files each
83
+ classify to nothing. (A separate cap stops after 3 *fired* nudges.)
84
+ - `SPOR_NUDGE_TIMEOUT` — milliseconds before a hung classifier is killed
85
+ (default 30000), so a wedged backend can't block the tool loop.
86
+
87
+ (Legacy `SUBSTRATE_*` spellings are still read for all four.) The same
88
+ `SPOR_DISTILLING=1` recursion guard is exported around the call, and the
89
+ journal records each nudge call under `journal/llm-calls/` (source `nudge`)
90
+ through the same eval loop as the distiller.
@@ -27,11 +27,15 @@ is just a manifest over `bin/spor-hook`.
27
27
  # Distiller backend — Codex hosts usually won't have the claude CLI.
28
28
  # Contract: prompt on stdin, response on stdout.
29
29
  export SPOR_DISTILL_CMD='codex exec -'
30
+ # Capture-nudge backend — same contract, but it runs synchronously in the
31
+ # tool loop, so prefer a fast model. SPOR_NUDGE=0 disables it.
32
+ export SPOR_NUDGE_CMD='codex exec -'
30
33
  ```
31
34
 
32
- (Legacy `SUBSTRATE_*` names are still read.) Without `SPOR_DISTILL_CMD`
33
- the distiller defaults to `claude -p --model haiku`, which is fine if the
34
- claude CLI is installed.
35
+ (Legacy `SUBSTRATE_*` names are still read.) Without `SPOR_DISTILL_CMD` /
36
+ `SPOR_NUDGE_CMD` both default to `claude -p --model haiku`, which is fine if
37
+ the claude CLI is installed. See [adapters/README.md](../README.md) for the
38
+ nudge bounds (`SPOR_NUDGE_MAX`, `SPOR_NUDGE_TIMEOUT`).
35
39
 
36
40
  ## Event mapping
37
41