@sporhq/spor 0.18.5 → 0.19.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.
@@ -2,7 +2,7 @@
2
2
  "name": "spor",
3
3
  "displayName": "Spor Context Compiler",
4
4
  "description": "Maintains a typed, versioned knowledge graph and compiles compact briefings from it: session-start injection, per-prompt relevance digests, capture at discovery, end-of-session distillation, decision queue.",
5
- "version": "0.18.5",
5
+ "version": "0.19.0",
6
6
  "author": {
7
7
  "name": "losthammer"
8
8
  }
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "spor",
3
- "version": "0.18.5",
3
+ "version": "0.19.0",
4
4
  "description": "Maintains a typed, versioned knowledge graph and compiles compact briefings from it: session-start injection, per-prompt relevance digests, capture at discovery, end-of-session distillation, decision queue.",
5
5
  "author": {
6
6
  "name": "Spor",
package/API.md CHANGED
@@ -58,9 +58,9 @@ Streamable HTTP, implemented with the official SDK. All tool results are
58
58
  returned as both human-readable text content and structured JSON.
59
59
 
60
60
  The server advertises **`instructions`** (the SDK initialize result, surfaced
61
- by clients as an "MCP Server Instructions" block). It frames the eleven tools
62
- as an **ORIENT → TRAVERSE → COMMIT** loop rather than eleven independent
63
- verbs, so an assistant can infer a recursive research chain — e.g. `my_queue`
61
+ by clients as an "MCP Server Instructions" block). It frames the tools
62
+ as an **ORIENT → TRAVERSE → COMMIT** loop rather than a flat list of independent
63
+ verbs, so an assistant can infer a recursive research chain — e.g. `show_queue`
64
64
  (or `recent_changes` for "what happened lately") → `query_graph` with `root_id`
65
65
  (deepen) → `render_lens` on a lineage lens →
66
66
  `put_node`/`capture` the outcome — instead of reconstructing it from per-tool
@@ -232,10 +232,12 @@ text fit no schema (or failed validation twice) and was preserved as a
232
232
  `cap-…` capture-pending node — ingestion-quality failures never lose text.
233
233
  Only an unreachable ingestion model is an error (`ingestion_unavailable`).
234
234
 
235
- ### `my_queue`
235
+ ### `show_queue`
236
236
 
237
- The decision queue (QUEUE.md §4/§5). Input `{ "project"?: "slug",
238
- "types"?: ["task"], "exclude_types"?: ["capture-pending"], "limit"?: 20,
237
+ The decision queue (QUEUE.md §4/§5) the data answer to "show my queue" /
238
+ "what's next" / "the backlog". Input `{ "project"?: "slug",
239
+ "types"?: ["task"], "exclude_types"?: ["capture-pending"], "assignee"?:
240
+ "<person-id>|me", "limit"?: 20,
239
241
  "offset"?: 0 }` → `{ "items": [{id, title, type, status,
240
242
  priority, score, signals: {blocking, heat, staleness, age_days}, suggest:
241
243
  "do|close", why}], "count": N, "offset": 0, "returned_count": N,
@@ -277,6 +279,23 @@ queue, not what the firehose hid. The type compared is the type the item
277
279
  surfaces as, so `exclude_types: ["schema"]` also hides schema-approval items.
278
280
  Omitting both (or passing empty arrays) filters nothing.
279
281
 
282
+ **Per-person scope** (task-cc-queue-assignee-filtering). `assignee` scopes the
283
+ ranked queue to the work one person carries — the union of nodes with an
284
+ `assigned` edge to them and the nodes they `steward`; `assignee: "me"` binds
285
+ to the caller (QUEUE.md §4). This is a **narrower carrying view**, not the
286
+ queue: for the ordinary "my queue" / "what's next" answer, omit `assignee`
287
+ entirely — pass it only when the caller explicitly asks for directly
288
+ assigned or stewarded work.
289
+
290
+ ### `render_queue`
291
+
292
+ The widget twin of `show_queue`: same input, same queue, but this tool
293
+ declares the view-tree UI resource (below) so an MCP-Apps host reliably
294
+ attaches the interactive queue widget. Semantics, ranking, filters, and
295
+ pagination match `show_queue` exactly — it exists only to make widget
296
+ attachment an explicit choice; `show_queue` remains the data-oriented
297
+ queue tool for hosts (and turns) that just need the answer.
298
+
280
299
  ### `ask_question`
281
300
 
282
301
  File a question the graph could not answer. Input `{ "text": "<the
@@ -345,7 +364,7 @@ gating tasks). Unknown `id` errors with `{ "found": false, "error":
345
364
  ### `recent_changes`
346
365
 
347
366
  The team's recent-activity feed — the temporal entry point the other read
348
- tools lack (`query_graph` is semantic search, `my_queue` is forward-looking
367
+ tools lack (`query_graph` is semantic search, `show_queue` is forward-looking
349
368
  open work, `render_lens` renders current state). It answers "what changed /
350
369
  what was done in the last N hours", "what did the agents write overnight", and
351
370
  "what landed since `<commit>`". Input `{ "since"?, "project"?, "limit"? }` →
@@ -374,7 +393,7 @@ oldest-open bottlenecks. **Completion is a node's status-TRANSITION time** (when
374
393
  it entered its final terminal run), never `updated_at`, so a later edge append
375
394
  can't corrupt the "completed last week" signal (dec-spor-git-derived-timestamps).
376
395
  `project` (a repo slug or grouping id) scopes it through the same up-resolution
377
- as `my_queue`; `types` restricts node types; `weeks`/`top`/`aging` shape the
396
+ as `show_queue`; `types` restricts node types; `weeks`/`top`/`aging` shape the
378
397
  window. The MCP twin of `GET /v1/analytics` (§3) over the same `store.analytics`
379
398
  core — for the shell-less Cowork audience that can't run the CLI.
380
399
 
@@ -396,7 +415,7 @@ contract from `lib/seed/` files — those miss resident overrides
396
415
 
397
416
  ### The MCP-app widget (`ui://spor/view-tree.html`)
398
417
 
399
- `my_queue`, `render_lens`, and `render_program` declare a UI resource via
418
+ `render_queue`, `render_lens`, and `render_program` declare a UI resource via
400
419
  `_meta.ui.resourceUri`: a single trusted interpreter of the view-tree
401
420
  component catalog that MCP-apps hosts (Claude, Goose, VS Code) render as an
402
421
  interactive iframe — status chips, progress bars, lineage trees, node detail
@@ -442,6 +461,7 @@ endpoint is the REST twin of a core call:
442
461
  | `POST /v1/corrections` | /spor:correct | `propose_correction` semantics → 201 `{status, id, revision, warnings}` |
443
462
  | `GET /v1/queue?project=&assignee=&type=&exclude_type=&limit=&offset=` | /spor:next, session-start | the ranked decision queue: `{items, count, offset, returned_count, total_count, truncated, next_offset, counts_by_type, counts_by_project, counts_by_suggest, muted?, dormant?, questions, asked, findings, pending, reviews, policy?, generated_at}` — items retired by a live resolves/answers edge are excluded; items hidden by the viewer's `queue_mute` or parked by a future `wake:` date (QUEUE.md §4) are counted, never silently dropped; `questions`/`findings`/`pending` are the routed-to-me-plus-unrouted views for the authenticated identity, `asked` is the questions you filed, and `reviews` is the nodes whose review is requested of you (an open `review-requested` edge to your person node — explicitly targeted, no unrouted fallback). `limit` is the page size (default 20, **max 100**, clamped not rejected) and `offset` skips that many items in the ranked order (default 0); the `counts_*`/`total_count` aggregates always cover the FULL ranked set regardless of the page, so one call answers "how many issues vs tasks" without paging, while `truncated`/`next_offset` let a client walk the rest by re-requesting with `offset=next_offset` until `next_offset` is null. Pagination is offset over a point-in-time ranked slice (the queue re-ranks every call), not a cursor — it resumes the same slice only across an unchanged ranking. `project` resolves through the shared up-resolution (dec-spor-queue-slug-resolves-to-grouping): a bare repo slug unions its home-project grouping's member queues, the repo NODE id (`repo-<slug>`) pins one repo, a grouping id (`proj-<slug>`) is used directly; **omitting `project` is the cross-project firehose** (every repo's queue at once). `assignee=<person-id>` scopes to the work that person carries (their `assigned`/`stewards` edges) — a manager's "who is carrying what"; `assignee=me` binds to the caller (empty if the token maps to no person node). `type=`/`exclude_type=` (comma-separated, repeatable) whitelist/blacklist node types from the ranking (exclude wins on overlap) — a hard scope filter applied before scoring, so the aggregates describe the filtered queue (task-cc-queue-filtering-enhancements) |
444
463
  | `GET /v1/analytics?project=&type=&weeks=&top=&aging=&format=` | remote `spor analytics`, the `analytics` MCP tool | work-flow analytics — the SERVER twin of the local-only `spor analytics` consumer, for a remote/Cowork teammate with no local graph repo to fold (task-spor-server-analytics-surface): created-vs-completed weekly cohorts, throughput, cycle-time median/p90, current WIP by node type, and the oldest-open bottlenecks, computed by the pure analytics kernel over the resident graph + a HEAD-keyed status-transition fold. **Completion is a node's status-TRANSITION time** (when it entered its final terminal run, from git content history), never `updated_at`, so a later edge append can't corrupt the "completed last week" signal (dec-spor-git-derived-timestamps). Default returns the machine (JSON) report `{window, weekly, totals, throughput, cycleTimeDays, wip, bottlenecks, coverage}`; `?format=text` renders the human report. `project` resolves through the shared up-resolution like `/v1/queue` (bare repo slug → grouping union; `repo-<slug>`/`proj-<slug>` id pins) — a zero-match scope rides back as the additive `project_warning` field (text mode prefixes a `# ` line). `type=` (comma-separated, repeatable) restricts node types; `weeks`/`top`/`aging` shape the window (clamped 1–52 / 1–100 / 1–365). A bad slug/type is `422`. The remote arm of `spor analytics` fetches the JSON and renders it with the SAME `renderReport` the local consumer uses, so remote and local output match (norm-spor-cli-mode-parity, task-spor-analytics-remote-cli-dispatch) |
464
+ | `GET /v1/metrics/capture?since=` | the cross-author capture-discipline eval harvest (task-spor-tenant-capture-metrics-export) | capture-discipline aggregates for an **opted-in** deployment — the same kernel the dogfood CLI runs (`lib-engine/kernel/capture-metrics.js`), computed server-side over the resident graph plus the FULL request journal (every rotated `server.log` segment). Three gates stack (dec-spor-tenant-metrics-aggregates-only): the per-machine opt-in env `SPOR_METRICS_EXPORT` (unset → the route 404s, so a never-opted tenant shows no surface), admin auth (stewards→root, 403), and **unconditional redaction** — the body carries counts/rates only: by-identity keys are stable per-tenant pseudonyms (`author-<hash12>`, salted at `cache/metrics-salt` so per-author trends survive across windows), closure entries keep `{edge, latency_days}` but drop node ids, and id lists reduce to `open_count`/`slug_smell_count`. No journal lines, node bodies, or capture prose ever exit. `?since=YYYY-MM-DD` bounds the window (malformed → `422`) |
445
465
  | `POST /v1/questions` `{text, title?, mentions?, project?}` | 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, project, routed_to, via, asker, revision, warnings}`. `project` is derived from the relevance neighborhood (then the asker's home project) unless an explicit `project` slug overrides it — pass that for a mention-less question (a dispatched agent injects its session project); a malformed slug → 400 |
446
466
  | `POST /v1/gardener` | ops cron / on demand; `spor admin gardener` | run a gardener sweep now; findings filed as queue items → `{checked, filed, resolved, skipped, generated_at}` (`filed`/`resolved`/`skipped` are id lists, `checked` a count). The `spor admin gardener [--json]` CLI verb is the shell front-door (remote-only — the server owns the gardener); authenticated but **not** admin-gated server-side today (unlike `/v1/backup`), so any valid team token can trigger it — the verb still surfaces a 403 as an admin-privilege (stewards→root) hint for a deployment that adds the gate |
447
467
  | `GET /v1/program/{id}?format=json\|text&depth=&max_nodes=` | program oversight, /spor:brief follow-ups | the program/progress view (`render_program`'s REST twin, one kernel behind both doors): the gating tree of everything that `blocks` `{id}` transitively, with resolution-derived progress (`{progress: {total, done, active, blocked, open, pct, statuses}}` on the view root; done = terminal status / supersession / live resolves-answers edge, exactly the queue's truth). JSON view tree by default, `?format=text` for the terminal rendering; `depth`/`max_nodes` bound expansion and count skipped branches into `truncated`, never silently. 404 for an unknown id |
package/GRAPH.md CHANGED
@@ -444,6 +444,43 @@ opt-in that turns scoped norms on; set them with `spor repos tag <slug>
444
444
  keeps the project-scoped behavior above, so a graph using no `applies_to_*` is
445
445
  byte-identical (norm-cc-byte-identical-refactor).
446
446
 
447
+ A norm may also declare **coupling anchors**
448
+ (dec-spor-coupling-norms-declared-first): two flat inline lists, `couples_when:`
449
+ (trigger file globs — "when files matching these change") and `couples_also:`
450
+ (the coupled artifacts that should change in the same edit, or be consciously
451
+ dismissed). A norm carrying both becomes a **coupling norm**: the post-tool
452
+ hook glob-matches every Write/Edit's repo-relative path against the trigger
453
+ sets and, on a hit, injects the targets as an edit-time nudge — once per
454
+ session per norm, deterministic, no LLM (task-spor-coupling-nudge-posttool;
455
+ `SPOR_COUPLING_NUDGE=0` disables). The glob dialect is small: `**` crosses
456
+ path segments, `*` stays within one, `?` is one character, a trailing `/`
457
+ means the whole subtree, and a bare `API.md` anchors at the repo root. An
458
+ entry may be **repo-qualified** as `<slug>:<glob>` (or `repo-<slug>:<glob>`)
459
+ so one norm couples artifacts across repos — a qualified trigger fires only in
460
+ that repo and bypasses the norm's scope (the pin IS the scope), while an
461
+ unqualified trigger applies wherever the norm itself applies: its
462
+ `applies_to_*` selectors when declared, else its `project:` stamp (unstamped =
463
+ every repo — the org-wide case, e.g. `couples_when: [.nvmrc]` /
464
+ `couples_also: [Dockerfile]`). Targets may be qualified the same way for
465
+ display. Either key alone, a scalar value, or the keys on a non-norm type are
466
+ inert (validate warns). The matcher is `lib/kernel/coupling.js`; a graph with
467
+ no coupling norms is byte-identical.
468
+
469
+ The boundary-time consumer of the same anchors is **`spor check`**
470
+ (task-spor-cli-check-coupling-verb): given a change set (uncommitted vs HEAD
471
+ by default; `--staged`, `--range a..b`, or `--files`), it reports each
472
+ coupling norm whose triggers are touched while its same-repo targets are not —
473
+ advisory by default, `--strict` exits 1 for CI/pre-commit; targets pinned to
474
+ another repo surface as reminders, never failures. A coupling norm may
475
+ additionally declare a machine-checkable **value invariant** — two scalar
476
+ keys, `couples_value_a:`/`couples_value_b:`, each `<path>#<regex>` (first
477
+ capture group = the value, e.g. `couples_value_a: .nvmrc#v?(\d+)` /
478
+ `couples_value_b: Dockerfile#FROM node:(\d+)`) — and `spor check` compares the
479
+ two extracted values: "these now disagree" beats "you probably forgot", an
480
+ agreeing invariant suppresses the untouched heuristic, and a disagreement
481
+ reports even when both files were touched. A half-declared or malformed pair
482
+ is inert (validate warns).
483
+
447
484
  Because a norm rides along with no relevance gate and the team trust model lets
448
485
  every writer author one, the briefing renderer treats norm bodies as an
449
486
  **injection surface** (issue-cc-norm-always-on-injection): each is quoted as
@@ -616,6 +653,10 @@ edges:
616
653
  the deterministic router walks `stewards` edges from the question's relevance
617
654
  neighborhood to the closest steward and writes a `routed-to` edge to that
618
655
  person; an unrouted question (no steward matched) surfaces to everyone.
656
+ The question schema's `validate()` door rejects a title/summary/body that is
657
+ ONLY an unfilled template token (`<question>`, `{{text}}`, `[id]`) so a docs
658
+ example run verbatim can't mint an information-free routed ask
659
+ (issue-spor-ask-question-template-placeholder-validation).
619
660
  - **`assigned`** points work (task/issue) at a person; per-person queues filter
620
661
  on it (the queue's `assignee` parameter — `GET /v1/queue?assignee=<person>`,
621
662
  unioned with the person's `stewards` edges; QUEUE.md §5). **`answers`** points
package/QUEUE.md CHANGED
@@ -331,7 +331,7 @@ person), `reviewed-by` (they approved — what `view.approvals` carries and the
331
331
  quorum counts), and `changes-requested-by` (they asked for changes — carried
332
332
  separately as `view.changes_requested`, never an approval). A single edge
333
333
  flips type in place across the lifecycle. An open `review-requested` edge
334
- surfaces the node in that reviewer's `my_queue` `reviews` set, through the same
334
+ surfaces the node in that reviewer's `show_queue` `reviews` set, through the same
335
335
  per-person routing filter questions and findings use — reviewer *selection* at
336
336
  request time and quorum *enforcement* at gate time are distinct points.
337
337
 
@@ -474,7 +474,7 @@ node id; an optional `@YYYY-MM-DD` makes the mute self-expiring, so
474
474
  "sideline this project for now" can't silently rot into a permanent blind
475
475
  spot. Muting is per-viewer presentation at queue-compile time, not graph
476
476
  state — items stay live and visible to everyone else — and the hidden
477
- count is reported (`muted: N` on the result, a trailing line on `my_queue`)
477
+ count is reported (`muted: N` on the result, a trailing line on `show_queue`)
478
478
  so the queue never silently truncates.
479
479
 
480
480
  Scheduled dormancy is the graph-state counterpart of the mute: a queueable
@@ -508,18 +508,18 @@ The queue is a compile mode, not a new store:
508
508
  (`repo-<slug>`) is the escape hatch back to one repo; an exact grouping id
509
509
  (`proj-<slug>`) is used directly; an ungrouped repo falls back to itself.
510
510
  **Omitting `project` entirely is the cross-project firehose** (every repo's
511
- queue at once); `/spor:next --all-projects` and `my_queue` / `GET /v1/queue`
511
+ queue at once); `/spor:next --all-projects` and `show_queue` / `GET /v1/queue`
512
512
  with no `project` reach it.
513
513
  - `includeTypes`/`excludeTypes` (task-cc-queue-filtering-enhancements) whitelist
514
514
  or blacklist node types FROM THE RANKING — `GET /v1/queue?type=&exclude_type=`
515
- (comma-separated), `my_queue {types, exclude_types}`, and
515
+ (comma-separated), `show_queue {types, exclude_types}`, and
516
516
  `spor next --type/--exclude-type`. Given both, the include set is narrowed and
517
517
  then the excludes are removed from it (exclude wins on overlap). Like `project`
518
518
  it is a hard scope applied before scoring (the aggregates describe the filtered
519
519
  queue), it composes with `project`/`assignee`, and the type compared is the one
520
520
  the item surfaces as (so excluding `schema` hides schema-approval items too).
521
521
  - Exposed as `GET /v1/queue` (hooks, session-start "open front" line),
522
- `my_queue` (the registered MCP stub finally does work — and when Tier 2
522
+ `show_queue` (the registered MCP stub finally does work — and when Tier 2
523
523
  routing lands, routed questions and stewarded items join the same queue),
524
524
  and `/spor:next` (presents the queue; picking an item triggers a
525
525
  full `--root <task-id>` compile so work starts pre-briefed).
@@ -530,7 +530,7 @@ The queue is a compile mode, not a new store:
530
530
 
531
531
  **Per-person queues** (task-cc-queue-assignee-filtering). `rankQueue`'s
532
532
  `assignee` parameter — and `GET /v1/queue?assignee=<person-id>` /
533
- `my_queue {assignee}` (use `assignee=me` to bind to the caller) — scopes the
533
+ `show_queue {assignee}` (use `assignee=me` to bind to the caller) — scopes the
534
534
  ranked queue to the work one person carries: the union of nodes with an
535
535
  outbound `assigned` edge to them (work→person) and the nodes they `steward`
536
536
  (person→node). Assignment is an edge like everything else (Tier-2's person
@@ -584,7 +584,7 @@ targets — subject first — form its relevance neighborhood, the first
584
584
  stewarded node wins (the server's deterministic Tier-2 routing walk), and
585
585
  the route materializes as a `routed-to` edge plus a body note. No steward
586
586
  anywhere leaves the finding unrouted. The queue surfaces (`GET /v1/queue`,
587
- `my_queue`) carry a `findings` field mirroring `questions`: open findings
587
+ `show_queue`) carry a `findings` field mirroring `questions`: open findings
588
588
  routed to the authenticated identity plus unrouted ones — a finding routed
589
589
  to someone else stays out of your view, so gardener output lands with the
590
590
  people who steward what it observed instead of everyone.
@@ -646,7 +646,7 @@ the structural truth:
646
646
  in TODOs, that's evidence for a post-tool TODO-comment nudge, not more
647
647
  prompt.)
648
648
  3. **Queue** — ✅ SHIPPED. `rankQueue()` in the core (`lib/queue.js`, also a
649
- CLI for local mode), `GET /v1/queue`, real `my_queue`, `/spor:next`.
649
+ CLI for local mode), `GET /v1/queue`, real `show_queue`, `/spor:next`.
650
650
  The task and capture-pending seed schemas are `queueable: true`; the §4
651
651
  signals (blocking / heat / staleness / age) are computed natively in the
652
652
  core for now — per-schema `queueSignals()` attached code joins via the
@@ -740,5 +740,5 @@ which held: each landed as one commit on top of the registry.
740
740
  computed item list in the sandbox — accepts `[{id, score}]` or an
741
741
  `{id: score}` map; unmentioned items keep their default-blend score.
742
742
  Fail-soft: a broken policy annotates the result (`policy: {id, applied,
743
- error?}` on `/v1/queue` and `my_queue`) and the built-in blend stands; a
743
+ error?}` on `/v1/queue` and `show_queue`) and the built-in blend stands; a
744
744
  proposed policy is inert until approved like any schema node.
package/README.md CHANGED
@@ -6,6 +6,8 @@ It keeps track of the useful things that normally disappear into chat history: d
6
6
 
7
7
  The name comes from Norwegian: *spor* means the track something leaves behind.
8
8
 
9
+ 📖 **Full documentation: [docs.sporhq.io](https://docs.sporhq.io)** — concepts, getting started, the CLI, the REST API, the MCP surface, and the hosted product.
10
+
9
11
  ## Why use Spor?
10
12
 
11
13
  Coding agents are good at working inside a single session. The problem is everything before and after that session.
@@ -515,6 +517,7 @@ This matters because some hosts cache plugins or hook definitions. Updating the
515
517
 
516
518
  ## More docs
517
519
 
520
+ * [docs.sporhq.io](https://docs.sporhq.io) — the full documentation site: concepts, getting started, CLI, REST API, MCP, and the hosted guide
518
521
  * `GRAPH.md` — graph format, node types, edges, and schema behaviour
519
522
  * `API.md` — REST and MCP server contract
520
523
  * `QUEUE.md` — queue, capture, routing, and workflow details
@@ -18,7 +18,10 @@ The Spor client is a portable core behind per-host adapters
18
18
  nothing and exits 0.
19
19
  - **Universal surfaces**: the server's MCP endpoint (`/mcp`) and REST
20
20
  (`/v1/*`) work from any host; `spor-hook agents-md` maintains a managed
21
- Spor section in `AGENTS.md` as the floor for hook-less hosts.
21
+ Spor section in `AGENTS.md` (capture-discipline directive + standing
22
+ briefing) as the floor for hook-less hosts. The committable directive-only
23
+ form of the same block is `spor agents-md` (written by `spor enable` by
24
+ default, refreshed by `spor upgrade`) — see `spor help agents-md`.
22
25
  - **Adapters**: a manifest per host mapping its event names onto the
23
26
  dispatcher.
24
27
 
@@ -80,7 +80,7 @@ is just a manifest over `bin/spor-hook.js`.
80
80
  the distill engine short-circuits on it — so a `codex exec` distiller that
81
81
  fires its own Stop hook cannot recurse.
82
82
  - MCP: add the Spor server to `~/.codex/config.toml` for on-demand graph
83
- access (`query_graph`, `capture`, `my_queue`):
83
+ access (`query_graph`, `capture`, `show_queue`):
84
84
 
85
85
  ```toml
86
86
  [mcp_servers.spor]
package/bin/spor-hook.js CHANGED
@@ -162,7 +162,18 @@ async function main() {
162
162
  /* logging must never break fail-open */
163
163
  }
164
164
  }
165
- if (!cfg.enabled()) return;
165
+ if (!cfg.enabled()) {
166
+ // A repo disabled purely by the opt-in DEFAULT that this machine has prior
167
+ // Spor history for gets a one-time discovery hint instead of silence
168
+ // (issue-spor-opt-in-silent-disable-no-indication) — session-start only,
169
+ // evidence-gated, once per repo; see scripts/engines/enable-hint.js.
170
+ if (event === "session-start") {
171
+ const { enableHint } = require("../scripts/engines/enable-hint");
172
+ const hint = enableHint(payload);
173
+ if (hint) emit(hint, payload, host);
174
+ }
175
+ return;
176
+ }
166
177
 
167
178
  // Debounced distill: spool the payload and hand off to a per-session
168
179
  // watcher (one at a time — the lock holds the watcher's pid; stale locks
@@ -204,7 +215,11 @@ async function main() {
204
215
  const engine = ENGINES[event]();
205
216
  const out = await engine(payload);
206
217
  if (!out) return;
218
+ emit(out, payload, host);
219
+ }
207
220
 
221
+ // Write an engine's output envelope to stdout in the host's dialect.
222
+ function emit(out, payload, host) {
208
223
  // Cursor speaks a flat snake_case output: {additional_context} only.
209
224
  // (jq -c framing: compact JSON with a trailing newline.)
210
225
  if (host === "cursor") {
package/bin/spor.js CHANGED
@@ -1701,6 +1701,140 @@ async function fetchRemoteExportNodes(cfg, label) {
1701
1701
  }
1702
1702
  }
1703
1703
 
1704
+ // `spor check` — the coupling-drift report over a diff
1705
+ // (task-spor-cli-check-coupling-verb, dec-spor-coupling-norms-declared-first):
1706
+ // the boundary-time consumer of the same coupling norms the post-tool nudge
1707
+ // serves at edit time (one matcher, lib/kernel/coupling.js). Resolve a change
1708
+ // set via LOCAL git (always — the diff is where you run), load the graph's
1709
+ // coupling norms (local nodes dir, or the team graph via GET /v1/export in
1710
+ // remote mode, the documented graph-wide-sweep path), and report each norm
1711
+ // whose `couples_when` triggers are touched while its `couples_also` targets
1712
+ // are not — plus value-invariant disagreement for norms carrying
1713
+ // couples_value_a/b. Advisory by default (exit 0); --strict exits 1 on
1714
+ // findings for CI/pre-commit enforcement.
1715
+ async function cmdCheck(cfg, args) {
1716
+ const cwd = process.cwd();
1717
+ const topR = git(cwd, ["rev-parse", "--show-toplevel"]);
1718
+ const top = topR.status === 0 ? topR.stdout.trim() : "";
1719
+ if (!top) {
1720
+ err("check: not inside a git repository (the change set is a git diff)");
1721
+ return 1;
1722
+ }
1723
+ const slug = u.projectSlug(top);
1724
+ const range = optVal(args, "range");
1725
+ const staged = args.includes("--staged");
1726
+ const strict = args.includes("--strict");
1727
+ const json = args.includes("--json");
1728
+ let files = null;
1729
+ const fi = args.indexOf("--files");
1730
+ if (fi >= 0) {
1731
+ files = [];
1732
+ for (let i = fi + 1; i < args.length && !String(args[i]).startsWith("--"); i++) files.push(args[i]);
1733
+ if (!files.length) {
1734
+ err("check: --files needs at least one path");
1735
+ return 1;
1736
+ }
1737
+ }
1738
+ if ((range ? 1 : 0) + (staged ? 1 : 0) + (files ? 1 : 0) > 1) {
1739
+ err("check: --range, --staged, and --files are mutually exclusive");
1740
+ return 1;
1741
+ }
1742
+
1743
+ // The change set, as repo-relative forward-slash paths (git's own spelling).
1744
+ let changed = [];
1745
+ let rightRev = null; // where value invariants read from (--range reads the range's right side)
1746
+ if (files) {
1747
+ changed = files.map((f) => {
1748
+ const abs = path.isAbsolute(f) ? f : path.resolve(cwd, f);
1749
+ return path.relative(top, abs).split(path.sep).join("/");
1750
+ });
1751
+ } else if (range) {
1752
+ const r = git(top, ["diff", "--name-only", range]);
1753
+ if (r.status !== 0) {
1754
+ err(`check: could not resolve --range '${range}' (${(r.stderr || "").trim().split("\n")[0]})`);
1755
+ return 1;
1756
+ }
1757
+ changed = r.stdout.split("\n").filter(Boolean);
1758
+ const i = range.lastIndexOf("..");
1759
+ rightRev = i > 0 ? range.slice(i + 2) || "HEAD" : null;
1760
+ } else if (staged) {
1761
+ const r = git(top, ["diff", "--name-only", "--cached"]);
1762
+ if (r.status !== 0) {
1763
+ err("check: git diff --cached failed");
1764
+ return 1;
1765
+ }
1766
+ changed = r.stdout.split("\n").filter(Boolean);
1767
+ } else {
1768
+ // Default: everything uncommitted vs HEAD (staged + unstaged) plus
1769
+ // untracked files — the mid-session / pre-commit superset, so an unstaged
1770
+ // edit can't silently pass. (The task sketch said "staged by default";
1771
+ // staged-only reports NOTHING for the common unstaged-working-tree case —
1772
+ // false confidence — so the default is the honest superset and --staged is
1773
+ // the narrow pre-commit view.) A repo with no commits yet falls back to
1774
+ // the index.
1775
+ const d = git(top, ["diff", "--name-only", "HEAD"]);
1776
+ if (d.status === 0) changed = d.stdout.split("\n").filter(Boolean);
1777
+ else {
1778
+ const c = git(top, ["diff", "--name-only", "--cached"]);
1779
+ if (c.status === 0) changed = c.stdout.split("\n").filter(Boolean);
1780
+ }
1781
+ const un = git(top, ["ls-files", "--others", "--exclude-standard"]);
1782
+ if (un.status === 0) changed.push(...un.stdout.split("\n").filter(Boolean));
1783
+ }
1784
+ changed = [...new Set(changed)];
1785
+
1786
+ // The coupling norms: local nodes dir, or the team graph via /v1/export.
1787
+ const couplingLib = require(path.join(ROOT, "lib", "kernel", "coupling.js"));
1788
+ const scanDir = (nodesDir) => {
1789
+ let names = [];
1790
+ try {
1791
+ names = fs.readdirSync(nodesDir).sort();
1792
+ } catch {}
1793
+ return couplingLib.scanCouplingEntries((f) => fs.readFileSync(path.join(nodesDir, f), "utf8"), names);
1794
+ };
1795
+ let scan;
1796
+ let cleanupFetched = null;
1797
+ if (cfg.mode() === "remote" && !namesLocalGraph(args)) {
1798
+ const fetched = await fetchRemoteExportNodes(cfg, "check");
1799
+ if (fetched.error) return 1; // already reported (offline / HTTP error)
1800
+ cleanupFetched = fetched.cleanup;
1801
+ scan = scanDir(fetched.nodesDir);
1802
+ } else {
1803
+ const nodesDir = optVal(args, "nodes") || cfg.nodesDir();
1804
+ if (!fs.existsSync(nodesDir)) {
1805
+ err(`no graph at ${nodesDir} — run 'spor init' first`);
1806
+ return 1;
1807
+ }
1808
+ scan = scanDir(nodesDir);
1809
+ }
1810
+ try {
1811
+ const checkLib = require(path.join(ROOT, "lib", "check.js"));
1812
+ const readFile = (rel) => {
1813
+ try {
1814
+ if (rightRev) {
1815
+ const r = git(top, ["show", `${rightRev}:${rel}`]);
1816
+ return r.status === 0 ? r.stdout : null;
1817
+ }
1818
+ return fs.readFileSync(path.join(top, rel), "utf8");
1819
+ } catch {
1820
+ return null;
1821
+ }
1822
+ };
1823
+ const { checked, findings, reminders } = checkLib.runCheck({
1824
+ slug,
1825
+ changed,
1826
+ norms: scan.norms,
1827
+ repoTags: scan.repo_tags[slug] ?? [],
1828
+ readFile,
1829
+ });
1830
+ if (json) out(JSON.stringify({ project: slug, changed, checked, findings, reminders, strict }, null, 2));
1831
+ else out(checkLib.renderReport({ slug, changed, checked, findings, reminders }, { strict }));
1832
+ return strict && findings.length ? 1 : 0;
1833
+ } finally {
1834
+ if (cleanupFetched) cleanupFetched();
1835
+ }
1836
+ }
1837
+
1704
1838
  // analytics folds a graph's git history into created-vs-completed metrics
1705
1839
  // (task-spor-work-analytics-consumer). Unlike query/validate (no server twin) it
1706
1840
  // is dual-mode: local mode runs the in-repo consumer (lib/analytics.js) over
@@ -4859,6 +4993,57 @@ function cmdLink(cfg, { positionals }) {
4859
4993
  return 0;
4860
4994
  }
4861
4995
 
4996
+ // --- spor agents-md: committed capture-discipline directive ----------------
4997
+ // (task-spor-agents-md-capture-discipline-directive) Write/refresh the managed
4998
+ // AGENTS.md block with the standing user-voice directive to keep the graph
4999
+ // current. The 2026-07 retrospective found front-loaded user-voice directives
5000
+ // were the one condition that reliably produced unprompted capture (~8/8 vs a
5001
+ // ~0-10% baseline), while the hook-injected preamble saying the same thing in
5002
+ // system-reminder voice underperformed — so the directive belongs in a
5003
+ // COMMITTED instructions file, where it reaches every contributor and every
5004
+ // dispatched agent. Default is directive-only: hooked hosts already receive
5005
+ // the briefing at session start, and a committed briefing snapshot stales;
5006
+ // --briefing restores the full hook-less floor (directive + briefing embed).
5007
+ async function cmdAgentsMd(cfg, { values }) {
5008
+ const root = repoRoot();
5009
+ const { writeAgentsBlock } = require(path.join(ROOT, "scripts", "engines", "agents-md.js"));
5010
+ const { file, meta } = await writeAgentsBlock({ cwd: root, briefing: !!values.briefing });
5011
+ out(`updated ${file} (${values.briefing ? meta || "no briefing yet, MCP pointers only" : "capture-discipline directive"})`);
5012
+ // CLAUDE.md rides along via an @AGENTS.md import (Claude Code resolves
5013
+ // @-imports): if the repo has a CLAUDE.md that never mentions AGENTS.md,
5014
+ // Claude Code sessions would miss the directive entirely. Append the import
5015
+ // once; never CREATE a CLAUDE.md (AGENTS.md alone is the portable surface).
5016
+ if (!values["no-claude-md"]) {
5017
+ const claudeMd = path.join(root, "CLAUDE.md");
5018
+ if (fs.existsSync(claudeMd)) {
5019
+ const txt = fs.readFileSync(claudeMd, "utf8");
5020
+ if (!/AGENTS\.md/.test(txt)) {
5021
+ fs.writeFileSync(claudeMd, txt + (txt.endsWith("\n") ? "" : "\n") + "\n@AGENTS.md\n");
5022
+ out(`updated ${claudeMd} (@AGENTS.md import appended)`);
5023
+ }
5024
+ }
5025
+ }
5026
+ out(" commit the file(s) so every contributor and dispatched agent inherits the directive");
5027
+ return 0;
5028
+ }
5029
+
5030
+ // `spor upgrade` rider: the committed directive tracks the packaged wording —
5031
+ // refresh the current repo's managed block IF one exists, preserving whether
5032
+ // it embedded a briefing. A repo that never opted into the block is untouched.
5033
+ async function refreshAgentsBlockIfManaged(root = repoRoot()) {
5034
+ const file = path.join(root, "AGENTS.md");
5035
+ let existing = "";
5036
+ try {
5037
+ existing = fs.readFileSync(file, "utf8");
5038
+ } catch {
5039
+ return;
5040
+ }
5041
+ if (!existing.includes("<!-- spor:begin -->") && !existing.includes("<!-- substrate:begin -->")) return;
5042
+ const { writeAgentsBlock } = require(path.join(ROOT, "scripts", "engines", "agents-md.js"));
5043
+ await writeAgentsBlock({ cwd: root, briefing: /### Standing project briefing/.test(existing) });
5044
+ out(`refreshed ${file} (managed Spor block — commit if the wording changed)`);
5045
+ }
5046
+
4862
5047
  // --- spor install / setup: wire spor into a host agent ---------------------
4863
5048
  // dec-cc-portable-core-adapters ships a manifest per host under adapters/<host>/
4864
5049
  // with a __SPOR_ROOT__ placeholder; installing one resolves the placeholder to
@@ -5519,6 +5704,10 @@ async function cmdUpgrade(cfg, { values, positionals: pos }) {
5519
5704
  if (r !== 0) rc = r;
5520
5705
  }
5521
5706
  if (!dryRun) {
5707
+ // The committed AGENTS.md directive versions with the package — refresh
5708
+ // the current repo's managed block so wording changes actually ship
5709
+ // (task-spor-agents-md-capture-discipline-directive). No-op without one.
5710
+ await refreshAgentsBlockIfManaged();
5522
5711
  out("");
5523
5712
  out("Restart any running sessions so the refreshed hooks/plugin load.");
5524
5713
  // The refresh above closes the loaded-vs-installed gap; this closes the
@@ -8276,6 +8465,38 @@ const COMMANDS = {
8276
8465
  ],
8277
8466
  run: (cfg, args) => cmdChanges(cfg, args),
8278
8467
  },
8468
+ check: {
8469
+ group: "Graph", parse: "raw", args: "[--staged|--range <a..b>|--files <f...>] [--strict] [--json]",
8470
+ summary: "coupling-drift report over a diff — triggers touched, targets not",
8471
+ help:
8472
+ "Check a change set against the graph's COUPLING NORMS (norm nodes carrying\n" +
8473
+ "couples_when/couples_also file globs — GRAPH.md \"coupling anchors\"): report\n" +
8474
+ "each norm whose trigger set is touched while its target set is not — the\n" +
8475
+ "boundary-time twin of the edit-time post-tool coupling nudge, sharing one\n" +
8476
+ "matcher (lib/kernel/coupling.js). A norm carrying a value invariant\n" +
8477
+ "(couples_value_a/b: <path>#<regex>) has its two extracted values compared:\n" +
8478
+ "\"these now disagree\" beats \"you probably forgot\", and an agreeing invariant\n" +
8479
+ "suppresses the untouched heuristic. Targets pinned to another repo are\n" +
8480
+ "surfaced as reminders, never failures (verify them there).\n" +
8481
+ "\n" +
8482
+ "The change set is always LOCAL git; the norms are mode-aware (local graph, or\n" +
8483
+ "the team graph via GET /v1/export in remote mode).\n" +
8484
+ "\n" +
8485
+ " --staged check the index only (the pre-commit view); default is\n" +
8486
+ " everything uncommitted vs HEAD plus untracked files\n" +
8487
+ " --range <a..b> check a commit range (CI; value invariants read the right side)\n" +
8488
+ " --files <f...> an explicit file list (paths, repo-relative or absolute)\n" +
8489
+ " --strict exit 1 when findings exist (CI / pre-commit enforcement)\n" +
8490
+ " --json machine-readable {project, changed, checked, findings}\n" +
8491
+ " --nodes <dir> read this local graph dir instead of the resolved home",
8492
+ examples: [
8493
+ "spor check",
8494
+ "spor check --staged --strict",
8495
+ "spor check --range origin/main..HEAD --strict",
8496
+ "spor check --files lib/seed/schema-task.md --json",
8497
+ ],
8498
+ run: (cfg, args) => cmdCheck(cfg, args),
8499
+ },
8279
8500
  export: {
8280
8501
  group: "Graph", parse: "strict", args: "[--gzip] [--history|--auth] [--out <file>]",
8281
8502
  summary: "the nodes/ tarball, or the --history bundle / --auth restore backup (GET /v1/export)",
@@ -8472,10 +8693,40 @@ const COMMANDS = {
8472
8693
  run: (cfg) => cmdScope(false),
8473
8694
  },
8474
8695
  enable: {
8475
- group: "Repo scoping", parse: "strict", args: "", options: {},
8476
- summary: "opt this repo in (.spor.json)",
8477
- help: "Set { enabled: true } in this repo's committable .spor.json. Spor is opt-in\nper repo — a repo with no .spor/.spor.json marker is a no-op — so this is how\nyou turn it on (and how you undo a prior 'spor disable'). Commit the file to\nshare the setting.",
8478
- run: (cfg) => cmdScope(true),
8696
+ group: "Repo scoping", parse: "strict", args: "",
8697
+ options: { "no-agents": { type: "boolean", desc: "skip writing the AGENTS.md capture-discipline directive" } },
8698
+ summary: "opt this repo in (.spor.json + AGENTS.md directive)",
8699
+ help: "Set { enabled: true } in this repo's committable .spor.json. Spor is opt-in\nper repo — a repo with no .spor/.spor.json marker is a no-op — so this is how\nyou turn it on (and how you undo a prior 'spor disable'). Also writes the\nAGENTS.md capture-discipline directive (see 'spor help agents-md'; skip with\n--no-agents). Commit the files to share the setting.",
8700
+ run: async (cfg, p) => {
8701
+ const rc = cmdScope(true);
8702
+ // Enabling is the moment this repo's work was decided to belong in the
8703
+ // graph — the standing directive rides along by default.
8704
+ if (rc === 0 && !p.values["no-agents"]) await cmdAgentsMd(cfg, { values: {} });
8705
+ return rc;
8706
+ },
8707
+ },
8708
+ "agents-md": {
8709
+ group: "Repo scoping", parse: "strict", args: "", aliases: ["agents"],
8710
+ summary: "write/refresh the committed AGENTS.md graph-upkeep directive",
8711
+ help:
8712
+ "Write (or idempotently refresh) the managed Spor block in AGENTS.md at the\n" +
8713
+ "repo root: standing user-voice instructions to keep the graph current —\n" +
8714
+ "capture discovered work when it appears, file issues before fixing, prefer\n" +
8715
+ "the graph over private auto-memory for durable facts, resolve with\n" +
8716
+ "artifacts, add Spor: commit trailers. Committed, it reaches every\n" +
8717
+ "contributor and dispatched agent; 'spor upgrade' refreshes the wording.\n" +
8718
+ "If a CLAUDE.md exists that never mentions AGENTS.md, an @AGENTS.md import\n" +
8719
+ "is appended so Claude Code sessions inherit the directive too.\n" +
8720
+ "By default the block carries the directive only (hooked hosts get their\n" +
8721
+ "briefing at session start); --briefing also embeds the standing project\n" +
8722
+ "briefing — the floor for hosts without hooks (same block 'spor-hook\n" +
8723
+ "agents-md' maintains from adapter session-start hooks).",
8724
+ options: {
8725
+ briefing: { type: "boolean", desc: "also embed the standing project briefing (hook-less floor)" },
8726
+ "no-claude-md": { type: "boolean", desc: "don't append the @AGENTS.md import to an existing CLAUDE.md" },
8727
+ },
8728
+ examples: ["spor agents-md", "spor agents-md --briefing"],
8729
+ run: (cfg, p) => cmdAgentsMd(cfg, p),
8479
8730
  },
8480
8731
  link: {
8481
8732
  group: "Repo scoping", parse: "strict", args: "<slug>", options: {},
@@ -8854,7 +9105,7 @@ async function main() {
8854
9105
  // Expose the pure helpers for unit tests (the version-check logic has no I/O),
8855
9106
  // and only run the CLI when invoked directly — requiring this file must not
8856
9107
  // kick off main() and call process.exit under the test runner.
8857
- module.exports = { nodeFloor, nodeRuntimeCheck, verCmp, sporConnectorBound, COMMANDS, resolveVerb, getNodeJson, gitBlobSha };
9108
+ module.exports = { nodeFloor, nodeRuntimeCheck, verCmp, sporConnectorBound, COMMANDS, resolveVerb, getNodeJson, gitBlobSha, refreshAgentsBlockIfManaged };
8858
9109
 
8859
9110
  if (require.main === module) {
8860
9111
  main()