@sporhq/spor 0.29.0 → 0.29.2

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (53) hide show
  1. package/.claude-plugin/plugin.json +1 -1
  2. package/.codex-plugin/plugin.json +1 -1
  3. package/API.md +78 -22
  4. package/GRAPH.md +32 -6
  5. package/QUEUE.md +6 -7
  6. package/README.md +15 -18
  7. package/agents/backfill.md +9 -1
  8. package/bin/spor-hook.js +37 -2
  9. package/bin/spor.js +2566 -1151
  10. package/lib/candidates.js +10 -7
  11. package/lib/config-keys.js +176 -0
  12. package/lib/config.js +233 -174
  13. package/lib/kernel/candidate.js +28 -8
  14. package/lib/kernel/frontmatter.js +475 -0
  15. package/lib/kernel/gates.js +256 -1
  16. package/lib/kernel/graph.js +99 -241
  17. package/lib/kernel/landed.js +238 -0
  18. package/lib/kernel/queue.js +30 -1
  19. package/lib/kernel/resolution.js +20 -6
  20. package/lib/kernel/tokenizer.js +149 -0
  21. package/lib/remote.js +76 -9
  22. package/lib/seed/candidates/schema-gate.md +16 -2
  23. package/lib/seed/schema-agent.md +3 -3
  24. package/lib/seed/schema-profile.md +9 -1
  25. package/lib/shell/agent-dispatch-runner.js +258 -588
  26. package/lib/shell/attestation.js +5 -1
  27. package/lib/shell/candidate-publish.js +7 -5
  28. package/lib/shell/completion.js +4 -23
  29. package/lib/shell/dispatch-harnesses.js +2 -87
  30. package/lib/shell/dispatch-terminal.js +6 -8
  31. package/lib/shell/gate-runner.js +311 -26
  32. package/lib/shell/git-exec.js +31 -1
  33. package/lib/shell/implementation-stage.js +15 -4
  34. package/lib/shell/integration-runner.js +116 -31
  35. package/lib/shell/landed.js +141 -0
  36. package/lib/shell/preflight.js +21 -39
  37. package/lib/shell/spool.js +349 -0
  38. package/lib/shell/work-loop.js +227 -14
  39. package/lib/tar.js +79 -24
  40. package/lib/validate.js +110 -5
  41. package/package.json +3 -2
  42. package/scripts/engines/debounce-watcher.js +4 -1
  43. package/scripts/engines/distill.js +37 -80
  44. package/scripts/engines/drain-outbox.js +109 -13
  45. package/scripts/engines/post-tool.js +26 -2
  46. package/scripts/engines/prompt-context.js +62 -18
  47. package/scripts/engines/session-start.js +37 -22
  48. package/scripts/engines/spool-sweeper.js +424 -0
  49. package/scripts/engines/util.js +82 -181
  50. package/skills/factory/SKILL.md +9 -3
  51. package/skills/factory/references/emitting.md +8 -8
  52. package/skills/next/SKILL.md +7 -7
  53. package/skills/spor/SKILL.md +29 -7
@@ -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.29.0",
5
+ "version": "0.29.2",
6
6
  "author": {
7
7
  "name": "losthammer"
8
8
  }
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "spor",
3
- "version": "0.29.0",
3
+ "version": "0.29.2",
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
@@ -338,6 +338,13 @@ commits. Output `{ "status": "captured|pending", "node_ids": [...],
338
338
  text fit no schema (or failed validation twice) and was preserved as a
339
339
  `cap-…` capture-pending node — ingestion-quality failures never lose text.
340
340
  Only an unreachable ingestion model is an error (`ingestion_unavailable`).
341
+ An optional `auto_write_id` additionally rides the result when the capture
342
+ landed on a mistyped ELABORATE target via the flag-gated near-miss fold
343
+ (`SPOR_GARDENER_AUTO_WRITE`, task-spor-capture-near-miss-ledger-and-undo) — a
344
+ merged capture-pending close or a body-append elaboration onto the matched
345
+ node, either way written through the gardener's reversible auto-write class
346
+ rather than a bare status/body edit. It is the ledger id `undo_auto_write`
347
+ takes to reverse that specific write; absent when no near-miss fold fired.
341
348
 
342
349
  ### `show_queue`
343
350
 
@@ -418,10 +425,14 @@ queue tool for hosts (and turns) that just need the answer.
418
425
 
419
426
  File a question the graph could not answer. Input `{ "text": "<the
420
427
  question>", "title"?: "<short title>", "mentions"?: ["<node id>", ...],
421
- "project"?: "<slug>" }` (routing considers `mentions` first). The question
422
- becomes a durable node, deterministically routed to the steward of the closest
423
- relevant node (unrouted if none matches), and joins the decision queue until
424
- answered. Answer by writing a node with an `answers` edge to the question.
428
+ "project"?: "<slug>", "to"?: "<person node id>" }` (routing considers
429
+ `mentions` first, unless `to` is given). The question becomes a durable node,
430
+ deterministically routed to the steward of the closest relevant node (unrouted
431
+ if none matches), and joins the decision queue until answered. `to` names a
432
+ person node to route to directly, ahead of every inferred signal
433
+ (`routed_by: "explicit"`); it must name an existing person node, and naming
434
+ the asker themselves falls through to ordinary routing with a warning instead
435
+ of a dead-end. Answer by writing a node with an `answers` edge to the question.
425
436
 
426
437
  By default the question's project is derived from its relevance neighborhood
427
438
  (its `mentions`, then the compiler's picks), falling back to the asker's home
@@ -522,6 +533,40 @@ rendered digest/briefing hides. The tool returns the changed nodes as data;
522
533
  the model writes the prose summary (no LLM on this path). It is the MCP twin of
523
534
  `GET /v1/changes` (§3), sharing one core so the two surfaces never drift.
524
535
 
536
+ ### `list_auto_writes`
537
+
538
+ Read-only view into the gardener's REVERSIBLE AUTO-WRITE ledger
539
+ (dec-spor-gardener-reversible-auto-write-class): every write the gardener (or
540
+ the capture near-miss fold, task-spor-capture-near-miss-ledger-and-undo) made
541
+ on its own at high confidence under the per-org `SPOR_GARDENER_AUTO_WRITE`
542
+ flag, newest first. Input `{ "days"?, "limit"?, "node"? }` (`node` filters to
543
+ one subject/target id) → `{ "enabled", "via",
544
+ "days", "since", "count", "total", "auto_writes": [{id, ts, event: "apply",
545
+ kind, node, verdict_id, judgment, confidence, undone}] }` (no `generated_at`
546
+ — that's REST-only, below). `enabled` echoes the flag so an empty ledger
547
+ reads differently from an unarmed gardener; `undone` is the reversal record
548
+ (`{id, ts, by, via}`) or `null` while the write stands. The MCP twin of
549
+ `GET /v1/gardener/auto-writes` (§3).
550
+
551
+ ### `undo_auto_write`
552
+
553
+ The mechanical undo of one or more gardener auto-writes by their ledger id
554
+ (from `list_auto_writes`) — same batch contract as finding remediation: one
555
+ `id` or `ids`, per-item outcomes, one item's refusal never blocks the rest.
556
+ Input `{ "id"?, "ids"? }` → `{ "status": "undone", "results": [...], "count",
557
+ "undone", "skipped", "failed" }`, each result `{ok, auto_write_id, kind,
558
+ node?, status?: "undone"|"already_undone", undo_id?, code?, message?,
559
+ details?}`. `already_undone` is the idempotent re-run of an undo already
560
+ applied; a refusal (`not_in_place` — the node changed by hand or by a later
561
+ auto-write since; `not_found`; `not_reversible` — a ledger kind this server
562
+ has no undo registered for) leaves the node untouched. A `body_append` undo
563
+ (the capture near-miss fold's elaboration append) additionally re-files the
564
+ stripped text as a fresh `cap-…` capture-pending node instead of discarding
565
+ it, so an undo never drops a capture from triage — its id rides back as
566
+ `refiled` on that item; if the re-file itself fails, the undo still lands and
567
+ a line naming the ledger entry rides back in `warnings` instead. The MCP twin
568
+ of `POST /v1/gardener/auto-writes/undo` (§3).
569
+
525
570
  ### `analytics`
526
571
 
527
572
  Work-flow analytics over the team graph — the created-vs-completed view a
@@ -601,11 +646,11 @@ agent subject sees no graph content; a coarse read-only/CI flag is not a bypass.
601
646
  | `DELETE /v1/me/tokens/{hash-prefix}` | `spor token revoke` | revoke one of the caller's OWN PATs by hash prefix → `{revoked, hash_prefix, oauth_grants_revoked}`; a prefix that isn't one of the caller's is `404` (never another person's token). `403` if unbound. Shares the admin revoke's OAuth-grant cascade-completeness invariant (issue-cc-pat-revoke-cascades-all-oauth-grants) |
602
647
  | `GET /v1/briefing/{project}` | session-start | read the `brief-<project>` node → `{found, version, body, project_brief?, graph_status}`. The slug resolves through project-node aliases (GRAPH.md "Project identity nodes") before lookup. A BARE repo slug also rides up to its home-project grouping: the grouping's `brief-<grouping>` node returns alongside as `project_brief` (the product context spanning sibling repos), matching the shared up-resolution (dec-spor-queue-slug-resolves-to-grouping); passing the repo NODE id (`repo-<slug>`) is the escape hatch that returns only the repo brief, no `project_brief`. Optional `?fp=root:<sha>,remote:<host/path>,...` carries the repo's fingerprints: the server learns them onto the owning project node, and an unknown slug with a known fingerprint files an alias proposal in the queue |
603
648
  | `POST /v1/digest` `{query, root?, project?, min_sim?}` | prompt-context, /spor:brief | digest-mode compile → `{found, text}`; `found: false` is a successful empty result. `root` is the structural-walk twin of `query` (the two are mutually exclusive; `root` wins, an unknown id is `422`). Optional `project` is the session slug: the server scopes the compile to it — the same-project relevance boost, the grouping union, and the `always_on` norm `applies_to_*` ride-along — resolving the slug through project-node aliases/groupings inside compile (dec-spor-queue-slug-resolves-to-grouping), exactly as `/v1/queue` does. A bad slug is `422`; **omitting `project` runs the digest project-blind (byte-identical to before)**, so older clients that send only `{query}` are unaffected. **Digest rerank (task-spor-compile-jev-rerank-stage, per-org opt-in, default OFF, server half task-split-spor-server-eb6de3fca31e):** when the org has opted in, the server scores the candidate pool (cosine top-30 ∪ top structural) with `jev.rerank` and hands the verdicts to `compile()` as `opts.rerankScores` — a `{ [id]: { score, noul } }` map, `score` an ordinal 0-3 (irrelevant / background / directly relevant / required) and `noul` a 0-1 probability that that candidate answers or resolves the query. `compile()` stays pure — it never calls Jev itself, it only reorders the section it already builds under `DIGEST_CAP`: pinned → scored candidates (`noul` desc, `score` secondary, cosine-sim tiebreak) → remaining structural (unscored, original score-desc order) → remaining content (unscored, original sim-desc order) — and reports it back as `meta.rerank: {applied: true, candidates: <n>}` (absent when rerank did not run) for the response's own additive `rerank` summary field to echo. Fail-open: an org that hasn't opted in, local mode (no server to call Jev with), or a call that omits/empties `opts.rerankScores` all render byte-identical to before (norm-cc-byte-identical-refactor). **Optional `intent`** (task-spor-digest-intent-jev-gate / task-split-spor-server-bca885114354): when the org has Jev enabled and the request carries a `query`, the server also returns `intent: {warranted: bool, needs_history: 0–1, digest_helps: 0–1, source: "jev"}` — the digest-intent verdict computed server-side (`warranted` = the deterministic prompt heuristics, then max(noul) ≥ 0.5). It is **absent** whenever it was not computed (Jev disabled/shed/timed out/failed, a `root` walk, an older server): fail-open, never `warranted: false` by default. The prompt-context hook suppresses THIS prompt's digest only on an explicit boolean `warranted: false`, and only when its digest intent gate is on (`digest.async`, a tri-state: explicit `true`/`false`, unset = the client default); an absent or non-boolean field is ignored, and so is one on a `found: false` response (its `digest_helps` judged an empty team digest, not the client's personal-graph merge) |
604
- | `GET /v1/nodes/{id}` | /spor:brief | `get_node` semantics; hidden and absent ids both return `404 not_found`. The node's active schema may attach read-time enrichment via a `get(node, ctx)` hook (GRAPH.md) — the seed `question`/`issue`/`task`/`incident` schemas attach `resolution`: a live **visible** inbound resolves/answers edge carrying the resolver's `summary`/`title` and a `lagging` flag (set when it contradicts a still-open status, clear when the node is already terminal, e.g. an answered question pointing at its answer). Open visible gardener findings about the node ride along as `open_findings` (`bin/spor.js`'s `dispatchDeclineFindingCheck` reads it to refuse re-dispatching a node a prior run DECLINED — task-spor-decline-finding-gates-redispatch — filtering for a live `find-declined-*` entry), and a node marked stale by a visible inbound supersedes edge as `superseded_by`. Returned `raw`/`frontmatter.edges` redact invisible edge targets. All enrichment is additive top-level keys; ignore unknown ones. Reserved: a top-level `inert` boolean — this node's status evaluated against the FULL type-aware queue-liveness-dead partition, including graph-resident schema overrides a graph-less client can't see (issue-spor-type-blind-terminal-status-fallbacks). The graph-less client callers that need it (`bin/spor.js` `dispatchResolutionReason`, `distill.js` `sessionEndLease`) already read it opportunistically when present and fall back to an offline seed-registry check otherwise, but no server response emits the key yet — implementing it server-side is separate, tracked work |
649
+ | `GET /v1/nodes/{id}` | /spor:brief | `get_node` semantics; hidden and absent ids both return `404 not_found`. The node's active schema may attach read-time enrichment via a `get(node, ctx)` hook (GRAPH.md) — the seed `question`/`issue`/`task`/`incident` schemas attach `resolution`: a live **visible** inbound resolves/answers edge carrying the resolver's `summary`/`title` and a `lagging` flag (set when it contradicts a still-open status, clear when the node is already terminal, e.g. an answered question pointing at its answer). Open visible gardener findings about the node ride along as `open_findings` (`bin/spor.js`'s `dispatchDeclineFindingCheck` reads it to refuse re-dispatching a node a prior run DECLINED — task-spor-decline-finding-gates-redispatch — filtering for a live `find-declined-*` entry), and a node marked stale by a visible inbound supersedes edge as `superseded_by`. Returned `raw`/`frontmatter.edges` redact invisible edge targets. `?inbound=1` adds `inbound_edges`: every inbound edge as `{from, type}` in the order a `GET /v1/export` sweep yields (sources sorted by id, each source's edges in file order) — what `spor get --json` uses instead of downloading the export per read (issue-spor-remote-get-json-full-export-per-call); omitted by default. All enrichment is additive top-level keys; ignore unknown ones. Reserved: a top-level `inert` boolean — this node's status evaluated against the FULL type-aware queue-liveness-dead partition, including graph-resident schema overrides a graph-less client can't see (issue-spor-type-blind-terminal-status-fallbacks). The graph-less client callers that need it (`bin/spor.js` `dispatchResolutionReason`, `distill.js` `sessionEndLease`) already read it opportunistically when present and fall back to an offline seed-registry check otherwise, but no server response emits the key yet — implementing it server-side is separate, tracked work |
605
650
  | `POST /v1/nodes/batch` `{ids?\|cursor?}` | `get_nodes` | bounded plural `get_node`: start with 1–100 ids, deduplicated by first occurrence; hidden/absent ids appear only in `missing_ids`. Returns `{nodes, returned_ids, missing_ids, truncated, next_cursor}` with the same per-node read enrichment as the single route, capped at 48 KiB without splitting entries. Resume a truncated logical request by sending only its opaque `cursor`; the final page carries `next_cursor: null` |
606
651
  | `GET /v1/nodes/{id}/history?limit=N` | `spor history <id>`, the `node_history` MCP tool | per-node commit lineage — a `git log` projection over `nodes/{id}.md` → `{id, head, count, history: [{sha, short, actor, actor_name, actor_email, date, message, internal, person}]}`, newest first. Each revision is labeled `internal:true` for a server-internal write (boot reconcile / migration, `server@spor.invalid`) vs. a real actor, and mapped to its `person` node by author email. Deliberately NOT `git log --follow` (node files share heavy frontmatter boilerplate, so similarity-based rename detection crosses node boundaries — dec-spor-node-history-git-log-projection). `limit` defaults to 50, max 200. The frontmatter `author` re-stamps to the LAST editor on every write, so this is the only durable record of the full chain of editors. A node with no commit history (unknown id) is `404`; a bad id is `422`. The `spor history <id>` CLI verb is the shell front-door (remote reads this; local mode runs the same projection over the graph home) |
607
652
  | `GET /v1/nodes/{id}/history/{sha}` | `spor history <id> <sha>`, `node_history` (sha mode) | one revision's detail, the expensive half gated behind an explicit per-sha fetch → the history record for that commit plus `{change, patch, content}`: the change type (`A`/`M`/`D`/`R`), the patch this commit introduced to the node file, and the full node content at that revision (`null` when the commit deleted it). The `sha` must be one from the node's own history — a sha that didn't touch the node, or an unresolvable sha, is `404`; a malformed sha is `422` |
608
- | `POST /v1/nodes` | `spor put-node`, drain-outbox, mechanical writers | `put_node` semantics, batch: `{nodes: [...], if_exists: "skip"}` (entries may be raw strings or `{node, if_exists, revision}`) → `{results: [...]}`, 207 when any entry failed. Entries are applied **sequentially** and each is fully validated before the next — including the completion-resolver gate that runs on create (GRAPH.md "the resolver gate") — so a born-terminal node (`done` task / `resolved` issue) must have its resolving `decision`/`artifact` EARLIER in the same batch (**resolver-first ordering**; the batch does not defer the gate to end-of-batch, dec-spor-batch-create-gate-resolver-first-ordering). The 207 is partial-success: entries already applied before a later entry's failure are not rolled back |
653
+ | `POST /v1/nodes` | `spor put-node` (one node, or a `--dir`/multi-document batch), drain-outbox, mechanical writers | `put_node` semantics, batch: `{nodes: [...], if_exists: "skip"}` (entries may be raw strings or `{node, if_exists, revision}`) → `{results: [...]}`, 207 when any entry failed. Entries are applied **sequentially** and each is fully validated before the next — including the completion-resolver gate that runs on create (GRAPH.md "the resolver gate") — so a born-terminal node (`done` task / `resolved` issue) must have its resolving `decision`/`artifact` EARLIER in the same batch (**resolver-first ordering**; the batch does not defer the gate to end-of-batch, dec-spor-batch-create-gate-resolver-first-ordering). The 207 is partial-success: entries already applied before a later entry's failure are not rolled back. (`spor put-node`'s LOCAL-mode batch differs: it validates the whole batch against one temp graph and writes nothing if any entry is invalid; its remote mode is this per-entry route) |
609
654
  | `POST /v1/nodes/{id}/edges` `{type, to, attrs?}` | scripts, mechanical writers | `add_edge` semantics (§1): normalize/flip, dedupe, append — no revision echo. Optional `attrs` adds trailing flat edge attributes (e.g. a per-assignment `profile:` override); re-adding the same edge with different attrs upserts the set. Adding a review-outcome edge (`reviewed-by`/`changes-requested-by`/`review-requested`) flips a sibling review edge to the same person in place — the one-call submit-review primitive |
610
655
  | `DELETE /v1/nodes/{id}/edges` `{type, to}` | scripts, mechanical writers | `remove_edge` semantics (§1): the withdrawal twin of the POST above — drop one typed edge by `{type, to}`, normalize/flip exactly as `add_edge` (an inverse form removes the canonical edge on the *other* node and echoes its id), no revision echo. A missing edge is an idempotent `skipped`. For *withdrawing* a relationship the review flip can't express — a pulled review request, a dismissed review |
611
656
  | `POST /v1/nodes/{id}/status` `{status}` | scripts, mechanical writers | `set_status` semantics (§1): one-scalar update through the `transitions()` gate. Setting a work node to an in-progress status also CLAIMS it (same lease as `/claim` below) |
@@ -622,15 +667,18 @@ agent subject sees no graph content; a coarse read-only/CI flag is not a bypass.
622
667
  | `POST /v1/nodes/{id}/commits` `{repo, sha}` | post-tool / link-commits | `link_commit`: append `repo@sha` to the node's `commits:` list (kebab-case repo slug, 7–40 lowercase hex, ≤40 commits per node); idempotent, prefix-aware dedup |
623
668
  | `POST /v1/nodes/{id}/elaborations` `{text, note?, date?}` | scripts, mechanical writers filing into a running log | the deterministic elaboration append: the same fold the capture ELABORATE outcome lands through, minus the model — the server appends a dated `> elaboration (date): note` block to the node's body, following the `art-<stem>-<n>` continuation chain to its live tail and, when that tail is at the 8KB body cap, rolling the next continuation part itself and folding there (dec-spor-capture-fold-auto-spill-continuation) → `{status, id, revision, warnings, spilled_from?}`. `id` is where the text LANDED (a continuation when it spilled), `status` the underlying put's token (`updated` in place / `created` on a spill); `200` in place, `201` with `spilled_from` naming the full part when a spill rolled. `date` is `YYYY-MM-DD` (defaults to today); a blank `text` is `422`; a missing target `404`; an elaboration too large for even an empty part is `body_full`. No idempotency of its own — a re-sent elaboration appends again, so a caller that must land exactly once scans the family first (`scripts/harvest-file-datapoints.js` dedups by tenant + window) |
624
669
  | `GET /v1/commits/{sha}?repo=` | `spor blame`/`commits` CLI verb; sessions doing git archaeology | sha → nodes lookup over the `commits:` fields (≥7 hex, abbreviated or full); each match carries `{repo, sha, id, type, title, summary, status, project}` — blame a line, get the why. The `spor blame <sha> [--repo <slug>]` CLI verb (alias `spor commits <sha>`) wraps this remotely and runs the same lookup over the local graph in local mode (`lib/query.js` `lookupCommit`) |
670
+ | (client-side, no route) | `spor reconcile-landed` CLI verb; the `spor work` integration stage after a land; the orchestrator merge step | **landed-work detection** (task-spor-landing-detect-shipped-resolver-draft): the client, where the checkouts are, scans a code repo for commits REACHABLE FROM the trunk ref (`git merge-base --is-ancestor <sha> <ref>` — a commit that exists only on a branch never counts) that name an open task/issue via a `Spor:`/`Substrate:` trailer or a `commits:` stamp for that repo, and for each writes (`POST /v1/nodes`, `if_exists: skip`, deterministic ids — a re-run files nothing new) an UNLINKED draft resolver `art-shipped-<id>` (`status: in-review`, `commits:` the landed shas, a `mentions` edge — never `resolves`) plus a finding `find-shipped-on-main-<id>` (`relates-to` the item and the draft) naming the sha(s), the repo and the reachability check. Terminal status is never flipped by a scan; `spor reconcile-landed --confirm <finding ids…>` is the batch confirm (per id: draft → `merged`, `add_edge resolves`, the item's completion status via `/v1/nodes/{id}/status`, the finding → `resolved`; one failure never aborts the rest). Range: `--since <ref>` (`<ref>..tip`) or `--last N` (default 200); `--ref` (default the first of main/master/origin/main/origin/master), `--dir`, `--dry-run`, `--json`. The finding kind `shipped-on-main` is not a gardener kind, so a gardener sweep neither resolves nor re-opens it. Remote mode judges liveness against `GET /v1/export` |
625
671
  | `GET /v1/changes?since=&project=&limit=` | `recent_changes`'s REST twin; audit review | the remote audit trail: a git-log projection over `nodes/` → `{changes: [{id, change, commit, date, committed_by, type, title, authored_via, author}], count, head, since, generated_at}`, newest change per node first. `since` is a 7–40 hex sha (`sha..HEAD`) or a date/relative phrase git understands (`--since`); an unresolvable sha is `422`. `project` scopes to one project's nodes (deletions are omitted when scoped, their project being gone). `limit` bounds nodes returned (default 100, **max 500**). Each entry's `authored_via` is the current machine-vs-human signal (`capture`/`distill`/`gardener` = machine). Lets a remote client review what agents wrote without the whole `/v1/export` tarball |
626
- | `POST /v1/capture` | distill, /spor:defer | `capture` semantics: `{text, context: {project, project_explicit?, during, blocks?, needed_by?}, source?, idempotency_key?}` → ingestion model + validate + commit → `{status, ids, nodes, summary, warnings}`. `source: "distill"` marks backstop captures in the journal. `idempotency_key` (client-generated; equivalently the `Idempotency-Key` header) guards the whole capture against the timeout-then-server-completes race (issue-cc-capture-transport-idempotency): a key the server has already seen returns the original result instead of re-ingesting, so a client that aborted at its read timeout but landed server-side does NOT double-write when the spooled body is replayed by `spor drain`. The client puts the key in the BODY so the verbatim outbox replay carries it for free (issue-spor-add-cli-duplicate-on-timeout-drain). `spor add --dedupe-key <key>` promotes a CALLER-chosen key into that slot instead of the per-invocation UUID (task-spor-add-dedupe-key-first-class), so a caller that re-files the same logical capture across separate invocations — a cron monitor re-alerting on one onset — dedupes too: within the window the repeat replays and the response carries `idempotent_replay: true`. The key is caller-supplied only, never derived from the text, and must match the server's key grammar (`^[A-Za-z0-9][A-Za-z0-9._-]{0,199}$`) — the client rejects anything else rather than let the server silently run the capture unguarded. `context.blocks` (a node id, must exist) and `context.needed_by` (`YYYY-MM-DD`) declare a cross-project dependency (task-cc-xproject-dependency-loop): set `context.project` to the SERVING project and the server attaches a `blocks` edge to the requester + the deadline deterministically (not via the model) onto the primary node. A missing `blocks` target is `404`; a non-date `needed_by` is `422` — both rejected before any model call. `context.project_explicit` (additive boolean, task-spor-thread-explicit-project-flag) distinguishes a user-declared `context.project` from an ambient cwd default: only a literal `false` silences the fold-mismatch warning on a cross-project capture; **absent means explicit** (old-client back-compat, so a pre-flag client keeps today's warn-on-mismatch behavior) |
672
+ | `POST /v1/capture` | distill, /spor:defer | `capture` semantics: `{text, context: {project, project_explicit?, during, blocks?, needed_by?}, source?, idempotency_key?}` → ingestion model + validate + commit → `{status, ids, nodes, summary, warnings}`. `source: "distill"` marks backstop captures in the journal. `idempotency_key` (client-generated; equivalently the `Idempotency-Key` header) guards the whole capture against the timeout-then-server-completes race (issue-cc-capture-transport-idempotency): a key the server has already seen returns the original result instead of re-ingesting, so a client that aborted at its read timeout but landed server-side does NOT double-write when the spooled body is replayed by `spor drain`. The client puts the key in the BODY so the verbatim outbox replay carries it for free (issue-spor-add-cli-duplicate-on-timeout-drain). `spor add --dedupe-key <key>` promotes a CALLER-chosen key into that slot instead of the per-invocation UUID (task-spor-add-dedupe-key-first-class), so a caller that re-files the same logical capture across separate invocations — a cron monitor re-alerting on one onset — dedupes too: within the window the repeat replays and the response carries `idempotent_replay: true`. The key is caller-supplied only, never derived from the text, and must match the server's key grammar (`^[A-Za-z0-9][A-Za-z0-9._-]{0,199}$`) — the client rejects anything else rather than let the server silently run the capture unguarded. `context.blocks` (a node id, must exist) and `context.needed_by` (`YYYY-MM-DD`) declare a cross-project dependency (task-cc-xproject-dependency-loop): set `context.project` to the SERVING project and the server attaches a `blocks` edge to the requester + the deadline deterministically (not via the model) onto the primary node. A missing `blocks` target is `404`; a non-date `needed_by` is `422` — both rejected before any model call. `context.project_explicit` (additive boolean, task-spor-thread-explicit-project-flag) distinguishes a user-declared `context.project` from an ambient cwd default: only a literal `false` silences the fold-mismatch warning on a cross-project capture; **absent means explicit** (old-client back-compat, so a pre-flag client keeps today's warn-on-mismatch behavior). An additive `auto_write_id` rides the response when a flag-gated near-miss fold (`SPOR_GARDENER_AUTO_WRITE`, task-spor-capture-near-miss-ledger-and-undo) auto-wrote a merged capture-pending close or a body-append elaboration in place of filing to triage — the ledger id `POST /v1/gardener/auto-writes/undo` takes to reverse it; absent when no near-miss fold fired |
627
673
  | `POST /v1/distill/report` | distill | sweep telemetry, journal-only (no store mutation): `{facts, captured?, spooled?, rejected?, project?, session?}` → `{status: "reported"}`; zero-fact sweeps report too |
628
674
  | `POST /v1/corrections` | /spor:correct | `propose_correction` semantics → 201 `{status, id, revision, warnings}` |
629
- | `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?, project_warning?, 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). A `project` token that matches no repo, grouping, or project stamp on any resident node is still `200` with empty `items` — it rides back as the additive `project_warning` string (the same field and wording shape as `/v1/analytics`; absent when the scope is known, even if legitimately empty), the remote twin of local `spor next`'s projectKnown() check: remote `spor next`, `spor work`, and `spor dispatch --from-queue` print it verbatim on stderr and strip it from the envelope so `--json` matches local (norm-spor-cli-mode-parity, task-spor-remote-next-print-project-warning). `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) |
675
+ | `GET /v1/queue?project=&assignee=&type=&exclude_type=&readiness=&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, counts_by_readiness?, muted?, dormant?, questions, asked, findings, pending, reviews, policy?, project_warning?, 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). A `project` token that matches no repo, grouping, or project stamp on any resident node is still `200` with empty `items` — it rides back as the additive `project_warning` string (the same field and wording shape as `/v1/analytics`; absent when the scope is known, even if legitimately empty), the remote twin of local `spor next`'s projectKnown() check: remote `spor next`, `spor work`, and `spor dispatch --from-queue` print it verbatim on stderr and strip it from the envelope so `--json` matches local (norm-spor-cli-mode-parity, task-spor-remote-next-print-project-warning). `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). `readiness=` (comma-separated, repeatable; `agent`\|`human`\|`untriaged`) is the agent-readiness twin — a hard scope filter on the derived classification (dec-spor-agent-readiness-derived-classification, QUEUE.md "Agent-readiness"); an unknown value is `422 invalid_node`. `counts_by_readiness` (`{agent, human, untriaged}`) rides the envelope whenever the graph carries readiness signal or a `readiness=` facet was asked for — the remote twin of local `spor next`'s readiness lead line, forwarded by remote `spor next --readiness` (task-spor-queue-remote-readiness-ignored) |
630
676
  | `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) |
631
677
  | `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`) |
632
- | `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 |
678
+ | `POST /v1/questions` `{text, title?, mentions?, project?, to?}` | ask_question's REST twin | file a question node; `to` (a person node id) routes it there directly, ahead of every inferred signal — an unknown or non-person id is `400`, and naming the asker themselves falls through to ordinary routing with a warning instead of a dead-end. Absent `to`, routing is deterministic over the mentioned nodes' live claim holder, `assigned` edge, then author, then an explicit `stewards` edge, then a one-hop neighbor of those, then a text-derived steward walk, then (skipping a person-direct asker throughout) the tenant-owner fallback — unrouted if nothing matches → 201 `{status, id, project, routed_to, via, routed_by, asker, revision, warnings}`. `routed_by` names which signal won (`explicit`\|`steward`\|`claim`\|`assigned`\|`author`\|`owner`, `null` if unrouted); `warnings` carries a line when the owner fallback fired, the question is genuinely unrouted, or an explicit `to` named the asker themselves. `GET /v1/status`'s `capabilities.ask_question_explicit_to` advertises this field — `spor ask --to <person-id>` sends it there when present, else falls back to the pre-capability leading-mention nudge. `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 |
633
679
  | `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 |
680
+ | `GET /v1/gardener/auto-writes?days=&limit=&node=` | `list_auto_writes`'s REST twin; audit review | the gardener's reversible auto-write ledger → `{ok, enabled, via, days, since, count, total, auto_writes: [...], generated_at}`, newest first (`days` default 7 max 365, `limit` default 200 max 1000, `node` filters to one subject/target id); see `list_auto_writes` (§2) for the entry shape |
681
+ | `POST /v1/gardener/auto-writes/undo` `{id}` or `{ids}` | `undo_auto_write`'s REST twin | reverse one or a bounded batch of auto-writes by ledger id → `{ok, status, results, count, undone, skipped, failed}`; see `undo_auto_write` (§2) for the per-item result shape, including the `body_append` undo's additive `refiled` (the id of the capture-pending node the stripped text was re-filed under) and `warnings` (non-empty only when that re-file itself failed) |
634
682
  | `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 membership tree of everything gating `{id}`, preferring inbound `member-of-program` edges per node and falling back to inbound `blocks` where none are declared (see `render_program`, §2, for the per-node preference and the pending-activation caveat), 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 |
635
683
  | `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 |
636
684
  | `POST /v1/lens/{id}/ticket` `{expires?}` | sharing a view; `spor share` | mint a signed, expiring, read-only render ticket for the lens/workspace, recording the authenticated caller as the sharer → `{ticket, url, lens_id, sharer_person_id, exp}`. `expires` is `<N>d` or an ISO date (default `7d`, max `30d`); the caller must be bound to a person node (else `422 no_person`). The ticket carries no write scope and is honored only on the render route (directly, or via the app host's ticket exchange below). The minted `url` depends on host role: an MCP-only host (`SPOR_HOST_ROLE=mcp`) with `SPOR_APP_URL` set mints an **absolute** `${SPOR_APP_URL}/views/{id}?ticket=...` — the app host's own render page, since the MCP host itself 404s on HTML renders; unset, it falls back to today's relative shape; every other role keeps its existing `oauth.baseUrl(request.raw)`-based absolute `/v1/lens/{id}/render?ticket=...`. On the app host, `GET /views/{id}` accepts that `?ticket=` exactly once: it is exchanged via a 302 into an HttpOnly `spor_render_ticket` cookie (stripped from the URL, kept out of logs and view-to-view hrefs) and replayed to api as the credential on the render fetch — but a **live app-host session outranks the ticket**: if the visitor is already signed in, their own session is used and the ticket cookie is ignored, so a shared link can never pin a signed-in user's view to the sharer's `$viewer`. The `spor share <lens-id> [--expires <Nd>]` CLI verb is the shell front-door (remote-only — tickets are minted and signed server-side); it prints the shareable link ready to paste, `--json` for the raw envelope |
@@ -746,13 +794,11 @@ anything with a token.
746
794
  /v1/agents/session` (§3) — the one place an agent token's session is set,
747
795
  write-once. The session can't be forged a-priori (it isn't known until the run
748
796
  exists) and can't ride the write payload (token-derived, §1), so the binding
749
- is always the actual run. For the opt-in native launch (`spor dispatch --bg`,
750
- `claude --bg` ignores `--session-id`) the launcher reads it from `claude agents
751
- --json`. A **supervised**-harness dispatch (Claude Code by default, Codex,
752
- OpenCode, GitHub Copilot CLI) follows
753
- the same late-bind contract from its own supervisor process instead: it reads
754
- the session id out of the run's supervised JSONL log rather than
755
- `claude agents --json` — Claude Code off the `session_id` every stream-json
797
+ is always the actual run. Every dispatch is **supervised** (Claude Code,
798
+ Codex, OpenCode, GitHub Copilot CLI; the native `spor dispatch --bg` launch,
799
+ which read the session from `claude agents --json`, is retired), so the
800
+ supervisor process binds it: it reads the session id out of the run's
801
+ supervised JSONL log — Claude Code off the `session_id` every stream-json
756
802
  event carries (first on its `system`/`init` event), Codex off its
757
803
  `thread.started` event, OpenCode off
758
804
  the `sessionID` every event carries, Copilot off the `sessionId` on its
@@ -888,9 +934,12 @@ rather than re-POSTed forever; `429` and `5xx` stay transient and are
888
934
  retried with backoff.
889
935
 
890
936
  `GET /v1/export` response headers: `x-substrate-head` carries the graph
891
- commit, `x-substrate-node-count` the entry count (plus
892
- `x-substrate-skipped` when any entry was omitted, and `x-substrate-auth-files`
893
- on an `?auth=1` export — the count of `auth/*.json` files bundled). A
937
+ commit, `x-substrate-node-count` the entry count (plus `x-substrate-auth-files`
938
+ on an `?auth=1` export — the count of `auth/*.json` files bundled). A path too
939
+ long for any ustar name/prefix split (a node id near the 200-byte limit) is
940
+ written under a POSIX pax extended header carrying its full `path`, so no entry
941
+ is omitted; the legacy `x-substrate-skipped` header is no longer emitted and a
942
+ client should treat its absence as zero. A
894
943
  `?history=1` bundle carries only `x-substrate-head` (a git bundle has no node
895
944
  count). These header names are a wire contract and were deliberately **not**
896
945
  renamed in the Spor rename — clients should keep reading the `x-substrate-*`
@@ -1052,9 +1101,16 @@ machine-local — never committed, always in the shared-graph `.gitignore`):
1052
1101
  refuse like any other verb, since acting on the active tenant is exactly the
1053
1102
  hazard. An `--org` given an **empty** value (an unset shell variable, in
1054
1103
  either the `--org ""` or the dangling `--org` spelling) refuses everywhere,
1055
- acquisition included: it is malformed input, not "use the default". The
1056
- ambient selectors (`SPOR_ORG`, the repo `org:` marker) still fall
1057
- through — they also ride the fail-open hook engines.
1104
+ acquisition included: it is malformed input, not "use the default".
1105
+ - **Unknown ambient org refuses too.** `SPOR_ORG` and a repo `.spor` `org:`
1106
+ marker naming an org with no stored credential refuse exactly like `--org`
1107
+ (issue-spor-ambient-org-selector-silent-fallback): the CLI exits 1 naming the
1108
+ selector and the stored orgs (acquisition invocations exempt), and the hook
1109
+ engines inject nothing and write nothing to EITHER graph — no remote call and
1110
+ no fall-through to the local graph home — journaling a warning to
1111
+ `journal/remote.log`. Under an explicit `mode: local`/`off` no tenant is
1112
+ consulted, so a stray ambient org is moot there. `spor config explain` shows
1113
+ which selector chose (or refused) the tenant.
1058
1114
  - **Refresh.** A 401/403 on a tenant carrying a `refresh_token` transparently
1059
1115
  refreshes against its issuer (`grant_type=refresh_token`) and retries once.
1060
1116
  - **Byte-identical.** With no credential store and only a flat
package/GRAPH.md CHANGED
@@ -68,7 +68,19 @@ Rules:
68
68
  it writes (`capture` marks nodes drafted by the ingestion path, QUEUE.md
69
69
  §2.3; `gardener` marks sweep findings, §6); any payload-supplied value is
70
70
  discarded. Both are simple `key: value` scalars. Locally written nodes may
71
- omit them.
71
+ omit them. `author` means the most recent writer, not the original one — it
72
+ is restamped on every write the server makes to a node (a `put_node`
73
+ update, `add_edge`, `set_status`, or `claim` alike).
74
+ - `created_by` is optional and, where present, immutable: the server stamps
75
+ `created_by: Name <email>` from the verified identity the first time a node
76
+ is written (CREATE only) and carries it forward unchanged on every later
77
+ write to that node, discarding any payload-supplied value
78
+ (dec-spor-node-created-by-write-once). It is the write-once counterpart to
79
+ `author`/`authored_via` above — read `created_by` for who originated a
80
+ node, `author` for who last touched it. Nodes written before this field
81
+ shipped, or written locally, may lack it; an operator-run backfill in the
82
+ private server repo (`server/backfill-created-by.js`) fills it in from each
83
+ node file's first commit.
72
84
  - Edges may point at ids that don't exist yet; the compiler skips them. Don't
73
85
  delete an edge just because the target is missing — it marks a node worth
74
86
  creating. An edge may also carry extra flat attributes after `to:` —
@@ -379,9 +391,11 @@ above, **declaring it gates nothing** — it tells a reader what the hooks
379
391
  already do.
380
392
 
381
393
  Otherwise there is **no declarative field list and no status enum.** Custom fields are free-form: any flat frontmatter key the
382
- regex parser accepts (simple `key: value` scalars, YAML-folded multi-line
383
- values, `pin:`/`exclude:` inline lists, `- {type: X, to: Y}` edges — and nothing
384
- fancier) is carried verbatim on the node. What a field MUST contain, and which
394
+ regex parser (`lib/kernel/frontmatter.js`, the one node-file grammar) accepts
395
+ (simple `key: value` scalars, YAML-folded multi-line values, the allowlisted
396
+ list keys as `[a, b]` inline or block lists, `- {type: X, to: Y}` flow-form or
397
+ `- type: X` / `to: Y` block-form edges — and nothing fancier) is carried
398
+ verbatim on the node. What a field MUST contain, and which
385
399
  status changes are legal, are enforced **in attached code** — two pure functions
386
400
  the server runs on the write path:
387
401
 
@@ -929,6 +943,14 @@ edges:
929
943
  optional `@YYYY-MM-DD` expiry) is per-viewer presentation only: the queue
930
944
  hides those items for this person and reports how many it hid; they stay live
931
945
  and visible to everyone else (QUEUE.md §4).
946
+ - **`covers_tests`** (flat inline list of repo-relative test file paths) on a
947
+ flake issue — or the task that fixes one — declares which tests that flake
948
+ covers; the gate pipeline stamps it on every `issue-flake-*` it files. Its
949
+ twin **`failing_tests`** is written by the gate pipeline on a `task-gate-*`
950
+ escalation: the test files a command gate's refusal failed in. Once every
951
+ failing test of a refusal is covered by a FIXED node (a live resolver, or
952
+ `done`/`resolved`), `spor work --regate-flakes` re-gates the run unattended
953
+ and retires the escalation only on a pass (WORKERS.md §10.7).
932
954
  - **`roles`** (flat inline list, e.g. `roles: [reviewer, maintainer]`) is the
933
955
  qualification register the org-defined policy layer reads. A scoped `policy`
934
956
  node's gate counts approvals from persons holding a named role — the
@@ -975,8 +997,7 @@ server-side (issue-cc-onboarding-email-mismatch-silent-degradation).
975
997
  ### Agents (person-owned principals)
976
998
 
977
999
  An `agent` node (prefix `agent-`) is a person's automation principal — the
978
- durable identity of a dispatched Claude Code run (a supervised `claude -p`, or
979
- an opt-in `claude --bg` session)
1000
+ durable identity of a dispatched Claude Code run (a supervised `claude -p`)
980
1001
  (dec-spor-agent-identity-nodes). It generalizes the workflow-run principal: a
981
1002
  dispatched session is just another principal kind owned by a person, so work it
982
1003
  creates reads "agent **on behalf of** person" rather than person-direct.
@@ -1061,6 +1082,11 @@ date: 2026-06-18
1061
1082
  `mcp` is merged into the strict `--mcp-config` dispatch writes, so the agent's
1062
1083
  toolset is exactly the profile plus the agent-spor server, nothing ambient
1063
1084
  (dec-spor-session-identity-active-record).
1085
+ - `model_family:` (optional) names the canonical model family (`gpt-5`,
1086
+ `claude`, …). It is not a satisfiability field; it is what a review gate's
1087
+ `fallback_profile` is judged independent of the implementer on — an unknown
1088
+ or equal family refuses the fallback
1089
+ (dec-spor-reviewer-reset-pause-budget-and-provenance).
1064
1090
  - **The graph names the harness; the MACHINE binds what that name runs**
1065
1091
  (task-spor-dispatch-declarative-custom-harness). `harness:` may name a
1066
1092
  launcher the client ships no in-code adapter for — a team's modified build,
package/QUEUE.md CHANGED
@@ -563,13 +563,12 @@ plain-actionable `do` base upgrades: the triage dispositions
563
563
  overridden by readiness. The envelope gains `counts_by_readiness` ({agent, human, untriaged},
564
564
  present only when there is readiness signal or a readiness facet was asked
565
565
  for) — the headline "how much of my queue can an agent take right now?" A
566
- **readiness filter** (`rankQueue({readiness})`, a class or array; today on the
567
- kernel opt and the local CLI's `--readiness`, comma-separated — the `GET
568
- /v1/queue?readiness=` param, `show_queue {readiness}`, and remote `spor next
569
- --readiness` forwarding land with the server render-surfaces slice,
570
- task-spor-queue-readiness-render-surfaces) narrows the queue to a class as a
571
- hard scope like `project`/`type` (schema-approval items, outside the
572
- classification, are excluded). A graph with no readiness data is byte-identical to before — no
566
+ **readiness filter** (`rankQueue({readiness})`, a class or array — the
567
+ kernel opt, the local CLI's `--readiness`, the `GET /v1/queue?readiness=`
568
+ param, `show_queue {readiness}`, and remote `spor next --readiness`, all
569
+ comma-separated) narrows the queue to a class as a hard scope like
570
+ `project`/`type` (schema-approval items, outside the classification, are
571
+ excluded); an unknown value is rejected rather than silently dropped. A graph with no readiness data is byte-identical to before — no
573
572
  fields, no clause, no count. Hard triage gaps recorded during a make-ready pass
574
573
  become explicit `blocks` edges (which correctly remove the item until
575
574
  answered); readiness handles the soft/derived side.
package/README.md CHANGED
@@ -321,8 +321,8 @@ attestation paths — the resolving edge, and the terminal own-status a
321
321
  status-only type is retired by — are judged, and a target that either path finds
322
322
  unfinished takes the same file-then-hand-back route, whichever one judged it.
323
323
  What does cost it is a posture where nothing could be verified at all: a
324
- native-background run (`spor dispatch --bg`, the opt-in `claude --bg` launch), a
325
- free-text dispatch with no target node, and a graph that could not be reached are
324
+ legacy native-background run record (from the retired `spor dispatch --bg`
325
+ launch), a free-text dispatch with no target node, and a graph that could not be reached are
326
326
  classified best-effort and marked `terminal_enforced: false` — an unenforced run
327
327
  can never read `resolved`. Local mode is not excluded: it has no server door to
328
328
  file a report or hand a lease back through, but it does have a graph, so a local
@@ -384,15 +384,13 @@ is what keeps two workers off one node, so a loop always takes it. Stopping
384
384
  (`SIGINT`/`SIGTERM`, or `--once`/`--max`) stops picking up new work; runs
385
385
  already in flight are detached, keep going, and self-report through `spor runs`.
386
386
 
387
- A native-background run (`claude --bg`, reached only through `spor dispatch
388
- --bg` or a standing `dispatch.claudeLaunchMode: native-background`) is the weak
389
- spot, for the same reason its outcome is unenforced: its termination is not
390
- deterministically observable, so a slot is freed from the harness's own
391
- live-agent listing. If that listing cannot be read, the slot stays held and the
392
- worker says so; `--run-max` (default 24 hours) is the backstop that stops
393
- following such a run. A supervised harness — Claude Code by default, Codex,
394
- OpenCode, Copilot CLI, or a declared one — has none of this, which is why the
395
- worker never passes `--bg`.
387
+ Every run is supervised — Claude Code, Codex, OpenCode, Copilot CLI, or a
388
+ declared harness — so a slot frees when the run's own supervisor closes its
389
+ record; `--run-max` (default 24 hours) is the backstop that stops following a
390
+ run that never goes terminal. (The native `claude --bg` launch, whose ending
391
+ could only be inferred by scraping `claude agents --json` and the session
392
+ transcript, is retired; a legacy record from it is judged from the record alone
393
+ and closed an hour after its launch.)
396
394
 
397
395
  Run it as a service and read it back:
398
396
 
@@ -473,13 +471,12 @@ By default, `spor dispatch` launches a Claude Code agent in headless print mode
473
471
  prompt goes in on stdin, the run's session id and final report are read off its
474
472
  event stream, the run record goes terminal when the process does, and the
475
473
  terminal-state contract judges the outcome like any other supervised harness.
476
- Pass `--bg` (or set `dispatch.claudeLaunchMode: native-background` in your user
477
- config) to launch the native background session instead (`claude --bg`) — the
478
- attachable, interactive form (`claude attach`), at the cost of an unenforced
479
- outcome and no report channel. Both are `spor dispatch`'s alone: `spor work`
480
- launches every run supervised (its runs must be followed, judged and gated),
481
- and a worker started under a standing `native-background` says so once on
482
- stderr rather than silently ignoring it. To dispatch under a different coding-agent CLI —
474
+ This is the only launch mode: the native background session (`spor dispatch
475
+ --bg`, `claude --bg`) is retired — its outcome could only be inferred by
476
+ scraping `claude agents --json` and the session transcript, which shifted with
477
+ every Claude Code release. `--bg` is now refused (run `claude --bg` yourself
478
+ for an attachable session), and a leftover `dispatch.claudeLaunchMode:
479
+ native-background` is ignored with a warning. To dispatch under a different coding-agent CLI —
483
480
  Codex, OpenCode, and GitHub Copilot CLI are also supported — resolve a
484
481
  **profile**: a node that bundles a harness, model, and toolset.
485
482
 
@@ -27,7 +27,15 @@ Method:
27
27
  rejected unless its resolving `decision`/`artifact` already exists on the
28
28
  graph (the completion-resolver gate, GRAPH.md), so emit each resolver BEFORE
29
29
  the terminal node it resolves, or build the node open→resolve→done. Local
30
- file writes (the default below) are ungated and order-free.
30
+ file writes (the default below) are ungated and order-free. To push many
31
+ nodes to a remote graph, write them to a directory and submit them in ONE
32
+ call — `spor put-node --dir <dir> --if-exists skip` — not one `put-node`
33
+ per node: it batches the POSTs, orders resolvers ahead of the nodes they
34
+ resolve for you, prints a created/skipped/error line per node (so a re-run
35
+ is auditable — remote entries land or fail one by one, so a partial batch
36
+ is fixed by re-running with `--if-exists skip`; a local batch is
37
+ all-or-nothing and writes nothing if any entry is invalid), and stamps a `priority: p1|p2|p3` in a new node's frontmatter
38
+ the way `spor priority` would, so priorities need no second pass.
31
39
  2. Aggregate, don't transcribe. One node per durable fact: a decision with its
32
40
  why, an issue with its full resolution lineage (found → fixed-in → verified),
33
41
  a spec with its current status. NEVER one node per commit; collapse
package/bin/spor-hook.js CHANGED
@@ -77,6 +77,33 @@ function normalize(payload, host) {
77
77
  return payload;
78
78
  }
79
79
 
80
+ // True (after journaling a warning to remote.log) when the cascade REFUSED to
81
+ // resolve a tenant — see Config.tenantError(). Every hook then no-ops.
82
+ function tenantRefused(cfg) {
83
+ const te = cfg.tenantError();
84
+ if (!te) return false;
85
+ try {
86
+ // Throttled to one line an hour: a refused repo refuses EVERY hook call
87
+ // (each tool call fires post-tool), and one line says it all.
88
+ const journal = path.join(u.graphHome(), "journal");
89
+ u.ensureDir(journal);
90
+ const stamp = path.join(journal, "tenant-refused.stamp");
91
+ let last = 0;
92
+ try {
93
+ last = fs.statSync(stamp).mtimeMs;
94
+ } catch {
95
+ /* first refusal */
96
+ }
97
+ if (Date.now() - last < 3600000) return true;
98
+ fs.writeFileSync(stamp, "");
99
+ const log = u.makeLogger(path.join(journal, "remote.log"), "config: ");
100
+ log(`org '${te.org}' (from ${te.origin}) has no stored credential — hook skipped; run 'spor auth login --org ${te.org}' or fix the selector`);
101
+ } catch {
102
+ /* logging must never break fail-open */
103
+ }
104
+ return true;
105
+ }
106
+
80
107
  async function main() {
81
108
  const argv = process.argv.slice(2);
82
109
  const event = argv.shift() ?? "";
@@ -112,7 +139,8 @@ async function main() {
112
139
  const ci = args.indexOf("--cwd");
113
140
  if (ci >= 0 && args[ci + 1]) amdCwd = args[ci + 1];
114
141
  else if (payload && payload.cwd) amdCwd = payload.cwd;
115
- if (!u.useConfig({ cwd: amdCwd }).enabled()) return;
142
+ const amdCfg = u.useConfig({ cwd: amdCwd });
143
+ if (!amdCfg.enabled() || tenantRefused(amdCfg)) return;
116
144
  await agentsMd(payload, args);
117
145
  return;
118
146
  }
@@ -177,6 +205,13 @@ async function main() {
177
205
  return;
178
206
  }
179
207
 
208
+ // A bound org this box holds no credential for (SPOR_ORG, a repo `.spor`
209
+ // org: marker) is a REFUSAL, not a hint (issue-spor-ambient-org-selector-
210
+ // silent-fallback): inject nothing and write nothing to EITHER graph — the
211
+ // null tenant would otherwise resolve LOCAL mode and quietly distill into the
212
+ // personal graph home. Journaled, since a hook can only fail silently.
213
+ if (tenantRefused(cfg)) return;
214
+
180
215
  // Debounced distill: spool the payload and hand off to a per-session
181
216
  // watcher (one at a time — the lock holds the watcher's pid; stale locks
182
217
  // from a dead watcher are reclaimed). The watcher fires after quiesce.
@@ -194,7 +229,7 @@ async function main() {
194
229
  // live claim on a false positive would silently strand active work.
195
230
  payload.spor_debounced = true;
196
231
  try {
197
- fs.writeFileSync(pendingFile, JSON.stringify(payload));
232
+ u.writeSpoolFile(pendingFile, JSON.stringify(payload));
198
233
  } catch {
199
234
  return;
200
235
  }