@sporhq/spor 0.2.8 → 0.3.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.
- package/.claude-plugin/plugin.json +1 -1
- package/API.md +102 -11
- package/GRAPH.md +67 -4
- package/QUEUE.md +81 -15
- package/README.md +109 -3
- package/adapters/README.md +32 -0
- package/adapters/codex/README.md +7 -3
- package/adapters/copilot/README.md +3 -2
- package/adapters/cursor/README.md +3 -2
- package/adapters/gemini/README.md +3 -0
- package/adapters/opencode/README.md +3 -0
- package/bin/spor-hook +26 -2
- package/bin/spor-hook.js +18 -0
- package/bin/spor.js +992 -185
- package/lib/config.js +113 -5
- package/lib/graph.js +3 -0
- package/lib/kernel/graph.js +45 -33
- package/lib/kernel/queue.js +199 -29
- package/lib/kernel/registry.js +80 -1
- package/lib/queue.js +64 -2
- package/lib/seed/schema-edge-assigned.md +3 -1
- package/lib/seed/schema-edge-stewards.md +3 -1
- package/lib/seed/schema-person.md +10 -1
- package/package.json +5 -1
- package/scripts/engines/debounce-watcher.js +13 -0
- package/scripts/engines/distill.js +45 -8
- package/scripts/engines/doctor.js +188 -0
- package/scripts/engines/post-tool.js +166 -4
- package/scripts/engines/session-start.js +70 -6
- package/scripts/engines/util.js +109 -4
- package/skills/next/SKILL.md +5 -2
|
@@ -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.
|
|
5
|
+
"version": "0.3.0",
|
|
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
|
-
|
|
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,
|
|
176
|
-
signals: {blocking, heat, staleness, age_days}, suggest:
|
|
177
|
-
"count": N, "
|
|
178
|
-
|
|
179
|
-
|
|
180
|
-
|
|
181
|
-
|
|
182
|
-
|
|
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 <sharer>" 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.**
|
|
249
|
-
|
|
250
|
-
|
|
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
|
|
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
|
|
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 —
|
|
293
|
-
|
|
294
|
-
(dec-cc-policy-floor-now-layer-deferred)
|
|
295
|
-
|
|
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
|
|
343
|
-
|
|
344
|
-
|
|
345
|
-
|
|
346
|
-
(
|
|
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, {
|
|
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
|
|
410
|
-
|
|
455
|
+
**Per-person queues** (task-cc-queue-assignee-filtering). `rankQueue`'s
|
|
456
|
+
`assignee` parameter — and `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
|
|
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
|
package/adapters/README.md
CHANGED
|
@@ -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.
|
package/adapters/codex/README.md
CHANGED
|
@@ -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
|
-
|
|
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
|
|