@sporhq/spor 0.18.4 → 0.18.6

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.4",
5
+ "version": "0.18.6",
6
6
  "author": {
7
7
  "name": "losthammer"
8
8
  }
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "spor",
3
- "version": "0.18.4",
3
+ "version": "0.18.6",
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
@@ -319,10 +338,33 @@ render). Unknown `lens_id` still errors, carrying the same `catalog`/`lenses`
319
338
  list; engine failures (missing param, broken blocks) error with the message
320
339
  verbatim.
321
340
 
341
+ ### `render_program`
342
+
343
+ The program/progress view over `blocks` topology — the birds-eye "where do we
344
+ stand" for a large workstream, auto-derived on demand with no lens authoring.
345
+ Input `{ "id": "<root-node-id>", "max_depth"?, "max_nodes"? }` → `{ "found":
346
+ true, "root_id", "progress": {"total", "done", "active", "blocked", "open",
347
+ "pct", "statuses"}, "count", "truncated"?, "view", "node_ids" }`. Given a root
348
+ node (an umbrella task, a milestone — anything other work `blocks`), the
349
+ server walks its gating tree — every node that blocks it, transitively over
350
+ inbound `blocks` edges — and derives each node's bucket from the same truth
351
+ the queue uses: terminal statuses, supersession, and live `resolves`/`answers`
352
+ edges count as **done** (even while the status field lags — the effective
353
+ status then reads `resolved` with a `resolved_by` ride-along); a node gated by
354
+ its own live unresolved blockers is **blocked**; live unblocked work splits
355
+ **active** vs **open**. `view` is the standard view tree (`as: "tree"` with an
356
+ additive `progress` block); the text content is a progress-bar header plus the
357
+ glyphed gating tree. Shared blockers render once and repeat as `repeat: true`
358
+ leaves (counted once); `max_depth`/`max_nodes` caps count skipped branches
359
+ into `truncated`, never silently. A root nothing blocks is a successful empty
360
+ result whose prose says how to model the program (add `blocks` edges from the
361
+ gating tasks). Unknown `id` errors with `{ "found": false, "error":
362
+ "unknown_root" }`. The REST twin is `GET /v1/program/{id}` (§3).
363
+
322
364
  ### `recent_changes`
323
365
 
324
366
  The team's recent-activity feed — the temporal entry point the other read
325
- 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
326
368
  open work, `render_lens` renders current state). It answers "what changed /
327
369
  what was done in the last N hours", "what did the agents write overnight", and
328
370
  "what landed since `<commit>`". Input `{ "since"?, "project"?, "limit"? }` →
@@ -351,7 +393,7 @@ oldest-open bottlenecks. **Completion is a node's status-TRANSITION time** (when
351
393
  it entered its final terminal run), never `updated_at`, so a later edge append
352
394
  can't corrupt the "completed last week" signal (dec-spor-git-derived-timestamps).
353
395
  `project` (a repo slug or grouping id) scopes it through the same up-resolution
354
- 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
355
397
  window. The MCP twin of `GET /v1/analytics` (§3) over the same `store.analytics`
356
398
  core — for the shell-less Cowork audience that can't run the CLI.
357
399
 
@@ -373,7 +415,7 @@ contract from `lib/seed/` files — those miss resident overrides
373
415
 
374
416
  ### The MCP-app widget (`ui://spor/view-tree.html`)
375
417
 
376
- `my_queue` and `render_lens` declare a UI resource via
418
+ `render_queue`, `render_lens`, and `render_program` declare a UI resource via
377
419
  `_meta.ui.resourceUri`: a single trusted interpreter of the view-tree
378
420
  component catalog that MCP-apps hosts (Claude, Goose, VS Code) render as an
379
421
  interactive iframe — status chips, progress bars, lineage trees, node detail
@@ -419,8 +461,10 @@ endpoint is the REST twin of a core call:
419
461
  | `POST /v1/corrections` | /spor:correct | `propose_correction` semantics → 201 `{status, id, revision, warnings}` |
420
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) |
421
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`) |
422
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 |
423
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 |
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 |
424
468
  | `GET /v1/lens/{id}/render?format=html\|text\|json` | browsers, teammates without a checkout | run a lens OR workspace node and render its view tree (html default, plain text, or the raw tree as json). Read-only — no action forms; writes stay with `/v1/nodes` and the MCP tools. Auth is the caller's bearer header OR a signed read-only **render ticket** for shared links (browser links can't carry an Authorization header): `?ticket=<blob>` is accepted once and exchanged via a 302 for an HttpOnly `spor_render_ticket` cookie (kept out of URLs, logs, and view-to-view hrefs). The ticket binds `$viewer` to the recorded sharer and the render shows a "Viewing as &lt;sharer&gt;" banner. The former `?token=<PAT>` sharing path is **removed** — a shared link can never carry a write-capable credential |
425
469
  | `POST /v1/lens/{id}/ticket` `{expires?}` | sharing a view; `spor share` | mint a signed, expiring, read-only render ticket for the lens/workspace, recording the authenticated caller as the sharer → `{ticket, url, lens_id, sharer_person_id, exp}`. `expires` is `<N>d` or an ISO date (default `7d`, max `30d`); the caller must be bound to a person node (else `422 no_person`). The ticket carries no write scope and is honored only on the render route. The `spor share <lens-id> [--expires <Nd>]` CLI verb is the shell front-door (remote-only — tickets are minted and signed server-side); it prints the shareable link ready to paste, `--json` for the raw envelope |
426
470
  | `GET /v1/export` | bootstrap/offline; `spor export` | ustar tarball of `nodes/` for seeding a local read replica (`?gzip=1` compresses); see §5 for the response headers. `curl … \| tar x` reproduces `nodes/` byte-for-byte. `?history=1` instead streams a `git bundle --all` of the repo (`application/x-git-bundle`, full commit provenance, the customer data-exit path — `git clone <bundle> graph`); `?auth=1` ALSO bundles `auth/*.json` so a disaster restore reproduces the credential set (admin-gated: stewards-root → `403` otherwise). The `spor export [--gzip] [--history\|--auth] [--out <file>]` CLI verb is the shell front-door (remote downloads this; `--gzip`/`--out` also build the same `nodes/` tarball locally, while `--history`/`--auth` are remote-only) |
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.
@@ -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.js CHANGED
@@ -4859,6 +4859,57 @@ function cmdLink(cfg, { positionals }) {
4859
4859
  return 0;
4860
4860
  }
4861
4861
 
4862
+ // --- spor agents-md: committed capture-discipline directive ----------------
4863
+ // (task-spor-agents-md-capture-discipline-directive) Write/refresh the managed
4864
+ // AGENTS.md block with the standing user-voice directive to keep the graph
4865
+ // current. The 2026-07 retrospective found front-loaded user-voice directives
4866
+ // were the one condition that reliably produced unprompted capture (~8/8 vs a
4867
+ // ~0-10% baseline), while the hook-injected preamble saying the same thing in
4868
+ // system-reminder voice underperformed — so the directive belongs in a
4869
+ // COMMITTED instructions file, where it reaches every contributor and every
4870
+ // dispatched agent. Default is directive-only: hooked hosts already receive
4871
+ // the briefing at session start, and a committed briefing snapshot stales;
4872
+ // --briefing restores the full hook-less floor (directive + briefing embed).
4873
+ async function cmdAgentsMd(cfg, { values }) {
4874
+ const root = repoRoot();
4875
+ const { writeAgentsBlock } = require(path.join(ROOT, "scripts", "engines", "agents-md.js"));
4876
+ const { file, meta } = await writeAgentsBlock({ cwd: root, briefing: !!values.briefing });
4877
+ out(`updated ${file} (${values.briefing ? meta || "no briefing yet, MCP pointers only" : "capture-discipline directive"})`);
4878
+ // CLAUDE.md rides along via an @AGENTS.md import (Claude Code resolves
4879
+ // @-imports): if the repo has a CLAUDE.md that never mentions AGENTS.md,
4880
+ // Claude Code sessions would miss the directive entirely. Append the import
4881
+ // once; never CREATE a CLAUDE.md (AGENTS.md alone is the portable surface).
4882
+ if (!values["no-claude-md"]) {
4883
+ const claudeMd = path.join(root, "CLAUDE.md");
4884
+ if (fs.existsSync(claudeMd)) {
4885
+ const txt = fs.readFileSync(claudeMd, "utf8");
4886
+ if (!/AGENTS\.md/.test(txt)) {
4887
+ fs.writeFileSync(claudeMd, txt + (txt.endsWith("\n") ? "" : "\n") + "\n@AGENTS.md\n");
4888
+ out(`updated ${claudeMd} (@AGENTS.md import appended)`);
4889
+ }
4890
+ }
4891
+ }
4892
+ out(" commit the file(s) so every contributor and dispatched agent inherits the directive");
4893
+ return 0;
4894
+ }
4895
+
4896
+ // `spor upgrade` rider: the committed directive tracks the packaged wording —
4897
+ // refresh the current repo's managed block IF one exists, preserving whether
4898
+ // it embedded a briefing. A repo that never opted into the block is untouched.
4899
+ async function refreshAgentsBlockIfManaged(root = repoRoot()) {
4900
+ const file = path.join(root, "AGENTS.md");
4901
+ let existing = "";
4902
+ try {
4903
+ existing = fs.readFileSync(file, "utf8");
4904
+ } catch {
4905
+ return;
4906
+ }
4907
+ if (!existing.includes("<!-- spor:begin -->") && !existing.includes("<!-- substrate:begin -->")) return;
4908
+ const { writeAgentsBlock } = require(path.join(ROOT, "scripts", "engines", "agents-md.js"));
4909
+ await writeAgentsBlock({ cwd: root, briefing: /### Standing project briefing/.test(existing) });
4910
+ out(`refreshed ${file} (managed Spor block — commit if the wording changed)`);
4911
+ }
4912
+
4862
4913
  // --- spor install / setup: wire spor into a host agent ---------------------
4863
4914
  // dec-cc-portable-core-adapters ships a manifest per host under adapters/<host>/
4864
4915
  // with a __SPOR_ROOT__ placeholder; installing one resolves the placeholder to
@@ -5519,6 +5570,10 @@ async function cmdUpgrade(cfg, { values, positionals: pos }) {
5519
5570
  if (r !== 0) rc = r;
5520
5571
  }
5521
5572
  if (!dryRun) {
5573
+ // The committed AGENTS.md directive versions with the package — refresh
5574
+ // the current repo's managed block so wording changes actually ship
5575
+ // (task-spor-agents-md-capture-discipline-directive). No-op without one.
5576
+ await refreshAgentsBlockIfManaged();
5522
5577
  out("");
5523
5578
  out("Restart any running sessions so the refreshed hooks/plugin load.");
5524
5579
  // The refresh above closes the loaded-vs-installed gap; this closes the
@@ -8472,10 +8527,40 @@ const COMMANDS = {
8472
8527
  run: (cfg) => cmdScope(false),
8473
8528
  },
8474
8529
  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),
8530
+ group: "Repo scoping", parse: "strict", args: "",
8531
+ options: { "no-agents": { type: "boolean", desc: "skip writing the AGENTS.md capture-discipline directive" } },
8532
+ summary: "opt this repo in (.spor.json + AGENTS.md directive)",
8533
+ 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.",
8534
+ run: async (cfg, p) => {
8535
+ const rc = cmdScope(true);
8536
+ // Enabling is the moment this repo's work was decided to belong in the
8537
+ // graph — the standing directive rides along by default.
8538
+ if (rc === 0 && !p.values["no-agents"]) await cmdAgentsMd(cfg, { values: {} });
8539
+ return rc;
8540
+ },
8541
+ },
8542
+ "agents-md": {
8543
+ group: "Repo scoping", parse: "strict", args: "", aliases: ["agents"],
8544
+ summary: "write/refresh the committed AGENTS.md graph-upkeep directive",
8545
+ help:
8546
+ "Write (or idempotently refresh) the managed Spor block in AGENTS.md at the\n" +
8547
+ "repo root: standing user-voice instructions to keep the graph current —\n" +
8548
+ "capture discovered work when it appears, file issues before fixing, prefer\n" +
8549
+ "the graph over private auto-memory for durable facts, resolve with\n" +
8550
+ "artifacts, add Spor: commit trailers. Committed, it reaches every\n" +
8551
+ "contributor and dispatched agent; 'spor upgrade' refreshes the wording.\n" +
8552
+ "If a CLAUDE.md exists that never mentions AGENTS.md, an @AGENTS.md import\n" +
8553
+ "is appended so Claude Code sessions inherit the directive too.\n" +
8554
+ "By default the block carries the directive only (hooked hosts get their\n" +
8555
+ "briefing at session start); --briefing also embeds the standing project\n" +
8556
+ "briefing — the floor for hosts without hooks (same block 'spor-hook\n" +
8557
+ "agents-md' maintains from adapter session-start hooks).",
8558
+ options: {
8559
+ briefing: { type: "boolean", desc: "also embed the standing project briefing (hook-less floor)" },
8560
+ "no-claude-md": { type: "boolean", desc: "don't append the @AGENTS.md import to an existing CLAUDE.md" },
8561
+ },
8562
+ examples: ["spor agents-md", "spor agents-md --briefing"],
8563
+ run: (cfg, p) => cmdAgentsMd(cfg, p),
8479
8564
  },
8480
8565
  link: {
8481
8566
  group: "Repo scoping", parse: "strict", args: "<slug>", options: {},
@@ -8854,7 +8939,7 @@ async function main() {
8854
8939
  // Expose the pure helpers for unit tests (the version-check logic has no I/O),
8855
8940
  // and only run the CLI when invoked directly — requiring this file must not
8856
8941
  // kick off main() and call process.exit under the test runner.
8857
- module.exports = { nodeFloor, nodeRuntimeCheck, verCmp, sporConnectorBound, COMMANDS, resolveVerb, getNodeJson, gitBlobSha };
8942
+ module.exports = { nodeFloor, nodeRuntimeCheck, verCmp, sporConnectorBound, COMMANDS, resolveVerb, getNodeJson, gitBlobSha, refreshAgentsBlockIfManaged };
8858
8943
 
8859
8944
  if (require.main === module) {
8860
8945
  main()
@@ -954,7 +954,7 @@ function compile(graph, opts = {}) {
954
954
  // The digest/briefing renders used to emit only (type, project, date) +
955
955
  // summary, so a done/rejected/resolved node read as live guidance — inviting
956
956
  // relitigation of dismissed approaches. Two truth signals get surfaced, the
957
- // same ones get_node/my_queue already honor:
957
+ // same ones get_node/show_queue already honor:
958
958
  // - a TERMINAL status field (done, rejected, resolved, …) is appended to
959
959
  // the parenthetical, so the reader sees the node is not live work.
960
960
  // - an inbound resolves/answers edge that retires a still-open node
@@ -14,7 +14,7 @@ shipped with the plugin as a registry default (QUEUE.md §2). It is the
14
14
  dec-spor-definition-of-done-org-policy): a resolving artifact (a change/PR)
15
15
  or any work node points a `review-requested` edge at each `person` a review
16
16
  is wanted from. The server surfaces those nodes into each named reviewer's
17
- personal queue (`my_queue` `reviews`), the same per-person filter questions
17
+ personal queue (`show_queue` `reviews`), the same per-person filter questions
18
18
  and findings route through.
19
19
 
20
20
  An outcome **flips the edge in place**: an approval rewrites it to
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@sporhq/spor",
3
- "version": "0.18.4",
3
+ "version": "0.18.6",
4
4
  "description": "Spor — a shared memory substrate for teams and agents. Decisions, their reasons, and the traces they leave. Knowledge-graph context compiler: session-start briefings, per-prompt digests, capture at discovery, end-of-session distillation, decision queue.",
5
5
  "license": "Apache-2.0",
6
6
  "author": "Anthony Allen",
@@ -4,6 +4,15 @@
4
4
  // local briefing sources, same append-or-replace semantics. The session-start
5
5
  // floor for hosts without hooks; status goes to stderr (hook hosts treat
6
6
  // stdout as the hook's response).
7
+ //
8
+ // Two callers, two shapes (task-spor-agents-md-capture-discipline-directive):
9
+ // - `spor-hook agents-md` (adapter session-start floor): directive +
10
+ // standing-briefing embed — hook-less hosts get their briefing here.
11
+ // - `spor agents-md` (CLI verb, also ridden by `spor enable` / `spor
12
+ // upgrade`): directive only by default — hooked hosts already get the
13
+ // briefing at session start, and the committed block should carry the
14
+ // durable instruction, not a briefing snapshot that stales between
15
+ // refreshes.
7
16
 
8
17
  const fs = require("fs");
9
18
  const path = require("path");
@@ -19,9 +28,34 @@ const LEGACY_END = "<!-- substrate:end -->";
19
28
  function toolsLine() {
20
29
  const server = u.serverBase();
21
30
  const mcp = server ? ` It is reachable over MCP at ${server}/mcp (bearer token).` : "";
22
- return `A team knowledge graph (Spor) holds prior decisions, constraints, dismissed approaches, and deferred work.${mcp} Before designing or deciding anything non-trivial, check it (query_graph). When you defer discovered work or make a decision worth keeping, record it (capture — 2-3 sentences; the server types and links it). Ask my_queue what to work on next. When a git commit implements a tracked node (a task, decision, or issue), add a 'Spor: <node-id>' trailer to the commit message, in the final trailer block alongside any Co-Authored-By (no blank line between trailers) — git then records which node the commit serves, and the graph records the commit's sha.`;
31
+ return `A team knowledge graph (Spor) holds prior decisions, constraints, dismissed approaches, and deferred work.${mcp} Before designing or deciding anything non-trivial, check it (query_graph). Ask show_queue what to work on next. When a git commit implements a tracked node (a task, decision, or issue), add a 'Spor: <node-id>' trailer to the commit message, in the final trailer block alongside any Co-Authored-By (no blank line between trailers) — git then records which node the commit serves, and the graph records the commit's sha.`;
23
32
  }
24
33
 
34
+ // The capture-discipline directive — user-voice standing instructions that
35
+ // make graph upkeep part of the work instead of an afterthought. One source
36
+ // of truth, versioned with the package: the hook floor, the CLI verb, and
37
+ // `spor enable`/`spor upgrade` all write THIS text, so a wording change ships
38
+ // to every managed block on the next refresh. Each bullet encodes a failure
39
+ // mode observed in the 2026-07-04 capture retrospective
40
+ // (art-cc-capture-discipline-results-2): work discovered but never filed,
41
+ // fix-before-issue, decisions kept only in chat, durable facts leaking to
42
+ // private auto-memory, and bare status flips.
43
+ const DIRECTIVE = `Keep the graph current as you work — do these unprompted:
44
+
45
+ - The moment work is discovered that you won't do right now (an out-of-scope
46
+ bug, a follow-up, a dismissed approach), capture it before moving on:
47
+ /spor:defer (or \`spor add "..."\`) — 2-3 sentences in your own words; the
48
+ server types and links it.
49
+ - Found a defect you ARE about to fix? File it first, fix second — the issue
50
+ node is the lineage the fix resolves.
51
+ - Made a decision worth keeping (approach chosen, alternative ruled out,
52
+ gotcha paid for)? Capture it at the moment it is made, not at session end.
53
+ - Durable, team-relevant facts belong in the graph, never only in private
54
+ auto-memory or scratch notes. If you are about to "remember" something a
55
+ teammate or future session could need, capture it to Spor as well.
56
+ - When tracked work finishes, close the loop: record the resolution (a
57
+ decision or artifact node with a \`resolves\` edge), not a bare status flip.`;
58
+
25
59
  // Body after the second '---' line (awk), head -c 7000, $() newline strip.
26
60
  function nodeBody(raw) {
27
61
  const lines = raw.split("\n");
@@ -35,59 +69,57 @@ function nodeBody(raw) {
35
69
  return u.stripTrailingNewlines(u.byteHead(awkOut, 7000));
36
70
  }
37
71
 
38
- async function agentsMd(input, args = []) {
72
+ // Core writer: compose the managed block and splice it into AGENTS.md at the
73
+ // repo root. `briefing: false` skips the standing-briefing fetch/embed
74
+ // entirely (no server round-trip). Returns { file, meta, hadBriefing }.
75
+ async function writeAgentsBlock({ cwd, briefing = true }) {
39
76
  const graph = u.graphHome();
40
- let cwd = "";
41
- for (let i = 0; i < args.length; i++) {
42
- if (args[i] === "--cwd") cwd = args[i + 1] ?? "";
43
- }
44
- if (!cwd && input && input.cwd) cwd = input.cwd;
45
- if (!cwd) cwd = process.cwd();
46
-
47
77
  const root = u.git(cwd, ["rev-parse", "--show-toplevel"])?.trim() || cwd;
48
78
  const slug = u.projectSlug(root);
49
79
 
50
80
  let body = "";
51
81
  let meta = "";
52
- if (u.serverBase()) {
53
- const resp = await u.curl(`${u.serverBase()}/v1/briefing/${slug}`, {
54
- headers: u.bearer(),
55
- timeoutMs: 6000,
56
- });
57
- try {
58
- const parsed = JSON.parse(resp.body);
59
- if (parsed.found === true) {
60
- // jq -r emits a trailing newline; head -c counts it; $() strips it.
61
- body = u.stripTrailingNewlines(u.byteHead((parsed.body ?? "") + "\n", 7000));
62
- const version = parsed.version ?? 1;
63
- meta = `brief-${slug} v${version} @ ${u.serverHost()}`;
64
- }
65
- } catch {}
66
- } else {
67
- const brief = path.join(graph, "nodes", `brief-${slug}.md`);
68
- if (fs.existsSync(brief)) {
69
- let raw = "";
82
+ if (briefing) {
83
+ if (u.serverBase()) {
84
+ const resp = await u.curl(`${u.serverBase()}/v1/briefing/${slug}`, {
85
+ headers: u.bearer(),
86
+ timeoutMs: 6000,
87
+ });
70
88
  try {
71
- raw = fs.readFileSync(brief, "utf8");
89
+ const parsed = JSON.parse(resp.body);
90
+ if (parsed.found === true) {
91
+ // jq -r emits a trailing newline; head -c counts it; $() strips it.
92
+ body = u.stripTrailingNewlines(u.byteHead((parsed.body ?? "") + "\n", 7000));
93
+ const version = parsed.version ?? 1;
94
+ meta = `brief-${slug} v${version} @ ${u.serverHost()}`;
95
+ }
72
96
  } catch {}
73
- body = nodeBody(raw);
74
- const version = raw.match(/^version: *(.*)$/m)?.[1] ?? "";
75
- meta = `brief-${slug} v${version || "1"} (local)`;
97
+ } else {
98
+ const brief = path.join(graph, "nodes", `brief-${slug}.md`);
99
+ if (fs.existsSync(brief)) {
100
+ let raw = "";
101
+ try {
102
+ raw = fs.readFileSync(brief, "utf8");
103
+ } catch {}
104
+ body = nodeBody(raw);
105
+ const version = raw.match(/^version: *(.*)$/m)?.[1] ?? "";
106
+ meta = `brief-${slug} v${version || "1"} (local)`;
107
+ }
76
108
  }
77
109
  }
78
110
 
79
- const tools = toolsLine();
80
- const section = body
81
- ? `## Spor team graph
111
+ const directive = `## Spor team graph
82
112
 
83
- ${tools}
113
+ ${toolsLine()}
84
114
 
85
- ### Standing project briefing (${meta}, machine-compiled ${u.localDate()} — do not hand-edit this section; refresh with \`spor-hook agents-md\`)
115
+ ${DIRECTIVE}`;
116
+ const section = body
117
+ ? `${directive}
86
118
 
87
- ${body}`
88
- : `## Spor team graph
119
+ ### Standing project briefing (${meta}, machine-compiled ${u.localDate()} — do not hand-edit this section; refresh with \`spor agents-md --briefing\`)
89
120
 
90
- ${tools}`;
121
+ ${body}`
122
+ : directive;
91
123
 
92
124
  const file = path.join(root, "AGENTS.md");
93
125
  const block = `${BEGIN}\n${section}\n${END}`;
@@ -127,8 +159,24 @@ ${tools}`;
127
159
  const tmp = file + `.spor-tmp-${process.pid}`;
128
160
  fs.writeFileSync(tmp, out);
129
161
  fs.renameSync(tmp, file);
130
- process.stderr.write(`updated ${file} (${meta || "no briefing yet, MCP pointers only"})\n`);
162
+ return { file, meta, hadBriefing: !!body };
163
+ }
164
+
165
+ async function agentsMd(input, args = []) {
166
+ let cwd = "";
167
+ let briefing = true;
168
+ for (let i = 0; i < args.length; i++) {
169
+ if (args[i] === "--cwd") cwd = args[i + 1] ?? "";
170
+ if (args[i] === "--directive-only") briefing = false;
171
+ }
172
+ if (!cwd && input && input.cwd) cwd = input.cwd;
173
+ if (!cwd) cwd = process.cwd();
174
+
175
+ const { file, meta } = await writeAgentsBlock({ cwd, briefing });
176
+ process.stderr.write(
177
+ `updated ${file} (${briefing ? meta || "no briefing yet, MCP pointers only" : "directive only"})\n`
178
+ );
131
179
  return null;
132
180
  }
133
181
 
134
- module.exports = { agentsMd };
182
+ module.exports = { agentsMd, writeAgentsBlock, DIRECTIVE };
@@ -26,8 +26,16 @@ to surface (see Presenting). Don't echo `SPOR_SERVER`/`SPOR_TOKEN`/`SPOR_HOME`
26
26
  or announce which mode is running unless the user asks — it's plumbing; if you
27
27
  ever need to confirm the resolved mode/scope, `spor status` reports it.
28
28
 
29
- **In Cowork (no shell)**, call the `my_queue` MCP tool instead, with the same
30
- fields (`project`, `types`, `exclude_types` arrays, `limit`/`offset`).
29
+ **In Cowork (no shell)**, call the `show_queue` MCP tool instead, with the same
30
+ fields (`project`, `types`, `exclude_types` arrays, `limit`/`offset`). Omit
31
+ `assignee` for the ordinary "my queue" answer — `assignee: "me"` narrows to
32
+ directly assigned/stewarded work only, a narrower carrying view. In an
33
+ MCP-Apps host that should attach the interactive queue widget, use
34
+ `render_queue` (same fields; `show_queue` stays the data answer).
35
+
36
+ (A different question — "how far along is the whole workstream?", progress
37
+ rather than next actions — is the program view: the `render_program` MCP tool
38
+ or `GET /v1/program/<root-id>`; see /spor:spor.)
31
39
 
32
40
  **Scope and filter flags** (task-cc-queue-filtering-enhancements). The queue
33
41
  defaults to this repo's project; three optional, composable levers widen or
@@ -46,7 +54,7 @@ pending captures"):
46
54
  (comma-separated, repeatable, exclude wins on overlap). They apply *before*
47
55
  ranking, so the aggregate counts describe the filtered queue.
48
56
 
49
- The `my_queue` MCP tool takes the same as fields.
57
+ The `show_queue` MCP tool takes the same fields.
50
58
 
51
59
  ## In-flight awareness — don't re-pick work an agent is already on (Claude Code only)
52
60
 
@@ -62,12 +70,12 @@ boolean — and a `dispatched` summary (`{id, name, state, status, cwd}`) on the
62
70
  in-flight ones — by matching live background agents to node ids. Add
63
71
  `--hide-dispatched` to drop the in-flight items entirely (it reports a
64
72
  `hidden_dispatched` count, never silently). This is best-effort and
65
- Claude-Code-only: the `my_queue` / server queue can't see local background
73
+ Claude-Code-only: the `show_queue` / server queue can't see local background
66
74
  agents, so the flag fails soft (every item reads `in_flight:false`) when the
67
75
  `claude` binary is absent (e.g. in Cowork or a plain shell).
68
76
 
69
77
  Prefer reading that flag over shelling out yourself. When you only have the raw
70
- `my_queue` output (which doesn't carry the flag), an item is **in flight** when
78
+ `show_queue` output (which doesn't carry the flag), an item is **in flight** when
71
79
  a `kind: "background"` agent from `claude agents --json` has `name` equal to the
72
80
  item's id and `state` is not `"done"`. For those items: badge them "🤖 agent
73
81
  dispatched — in progress" and keep them OUT of the top "pick this next"
@@ -200,13 +200,62 @@ In Cowork (Anthropic's chat workspace) and Claude Code with the connector there
200
200
  is no shell and no ambient injection — reach the graph through the **Spor MCP
201
201
  tools** instead: `query_graph` (ORIENT/TRAVERSE: free-text search, or `root_id`
202
202
  to compile one node's neighborhood), `get_node` (raw node + revision),
203
- `my_queue` (the ranked queue), `render_lens` (a saved board/table/lineage view;
204
- no id lists them), and to COMMIT: `capture` (raw prose server types it — reach
203
+ `show_queue` (the ranked queue — omit `assignee` for the ordinary "my queue"
204
+ answer; `assignee: "me"` narrows to directly assigned/stewarded work only),
205
+ `render_queue` (the same queue when an MCP-Apps host should attach the
206
+ interactive widget; `show_queue` stays the data answer), `render_lens` (a saved
207
+ board/table/lineage view; no id lists them), `render_program` (the
208
+ program/progress view for a workstream: given a root node other work `blocks`,
209
+ the gating tree of everything blocking it transitively, with
210
+ resolution-derived progress — "how far along is X?", where `show_queue`
211
+ answers "what's next?"; REST twin `GET /v1/program/{id}`; an empty result
212
+ means nothing carries a `blocks` edge to that root yet — see "Grouping work
213
+ under an umbrella node" below for how to model one), and to COMMIT:
214
+ `capture` (raw prose → server types it — reach
205
215
  for this when unsure of the shape), or the precise writes `put_node` /
206
216
  `add_edge` / `set_status`. Close loops with edges: answer a question with a node
207
217
  carrying an `answers` edge; close work with a `resolves` edge from a
208
218
  `decision`/`artifact`.
209
219
 
220
+ ## Grouping work under an umbrella node (programs)
221
+
222
+ When a workstream outgrows a handful of nodes — a milestone, a refactoring
223
+ program, a launch — group it under an **umbrella node** so the program view
224
+ can render it. There is no `program` node type: the umbrella is an ordinary
225
+ node, almost always a `task` (id like `task-<stem>-program`) whose body says
226
+ what the program is and what "done" means. (`type: project` /
227
+ `grouped-under` is the unrelated repo-identity layer — never use it to group
228
+ work.)
229
+
230
+ Membership is **`blocks` topology**: each member carries a `blocks` edge TO
231
+ the umbrella — "the umbrella can't finish until this does":
232
+
233
+ ```bash
234
+ spor edge task-api-rate-limit blocks task-platform-hardening-program
235
+ # MCP: add_edge {id: "task-api-rate-limit", type: "blocks", to: "task-platform-hardening-program"}
236
+ ```
237
+
238
+ - Write it from the member's perspective (member `blocks` umbrella). The
239
+ inverse spelling (`blocked-by` on the umbrella) is accepted and flipped;
240
+ duplicate edges are a no-op.
241
+ - **Only `blocks` edges count.** The program view walks inbound `blocks`
242
+ edges transitively and nothing else — members joined by `relates-to`,
243
+ `mentions`, or `derived-from` are invisible to it (and to the queue's
244
+ blocked-gating).
245
+ - Nest freely: a workstream hub that `blocks` the program hub hangs its whole
246
+ subtree under it — the walk is transitive, so one edge per level is enough.
247
+ - The gating is real, not cosmetic: the umbrella leaves the actionable queue
248
+ until its members land, and closing it at the end still needs a resolver
249
+ like any task.
250
+
251
+ Read it back with `render_program` (`{id: "<umbrella-id>"}`; REST `GET
252
+ /v1/program/<id>`): the gating tree with resolution-derived progress, where
253
+ **done** = terminal status, supersession, or a live `resolves`/`answers`
254
+ edge — the queue's truth, even while a status field lags. An empty result
255
+ means nothing `blocks` the root yet — add the member edges above.
256
+ `show_queue` answers "what's next?"; `render_program` answers "how far along
257
+ is the whole thing?".
258
+
210
259
  ## Adding a node or edge type
211
260
 
212
261
  Because schemas are themselves nodes, you extend the ontology by **writing a
@@ -60,7 +60,7 @@ are same-direction synonyms renamed at write time.
60
60
  | resolves | 0.9 | this fixes/closes the target | — |
61
61
  | triggered-by | 0.7 | this run was triggered by the target | — |
62
62
  | performs | 0.8 | this run is an execution of the target workflow | — |
63
- | blocks | 0.7 | the target can't proceed until this does | inverse `blocked-by` |
63
+ | blocks | 0.7 | the target can't proceed until this does | inverse `blocked-by`; also program membership — member `blocks` umbrella, rendered by `render_program` |
64
64
  | answers | 0.7 | this answers the target question | inverse `answered-by` |
65
65
  | assigned | 0.5 | work assigned to this person OR agent (explicit routing; an agent target may carry a `profile:` per-assignment override) | — |
66
66
  | relates-to | 0.5 | weak association | alias `related-to` |
@@ -42,8 +42,9 @@ finish the whole queue in one sitting — say what you triaged and what's left.
42
42
 
43
43
  ## 1. Pull the queue and read the landscape
44
44
 
45
- Get it with the queue's own resolver — `spor next --json` (or the `my_queue`
46
- MCP tool in Cowork). Read the **aggregates**, not just the page: `total_count`,
45
+ Get it with the queue's own resolver — `spor next --json` (or the `show_queue`
46
+ MCP tool in Cowork; omit `assignee` `assignee: "me"` narrows to directly
47
+ assigned/stewarded work only). Read the **aggregates**, not just the page: `total_count`,
47
48
  `counts_by_type`, `counts_by_suggest`, and the side-channels `pending`
48
49
  (unprocessed captures), `questions` (routed/unrouted to you), `findings`
49
50
  (gardener observations), plus `dormant`/`muted` counts.
@@ -210,6 +211,12 @@ Record a confirmed one as a `blocks` edge from the prerequisite to the dependent
210
211
  (mirroring how an issue records its block). The dependent then leaves the
211
212
  actionable queue until its blocker resolves — which is the point.
212
213
 
214
+ Once the topology is recorded you can sanity-check it from the top: the
215
+ program/progress view (`render_program` MCP tool, or `GET
216
+ /v1/program/<root-id>` in a shell) walks a root node's gating tree over
217
+ `blocks` edges with resolution-derived progress — a fast way to confirm the
218
+ edges you just wrote hang the work under the right umbrella.
219
+
213
220
  ## 7. Open questions → brief the lineage, then answer
214
221
 
215
222
  This is where triage earns its keep: the queue's `questions` are decisions