@sporhq/spor 0.29.1 → 0.29.3

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 (73) hide show
  1. package/.claude-plugin/plugin.json +1 -1
  2. package/.codex-plugin/plugin.json +1 -1
  3. package/API.md +101 -22
  4. package/GRAPH.md +36 -7
  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 +39 -2
  9. package/bin/spor.js +2730 -5674
  10. package/lib/analytics.js +9 -8
  11. package/lib/candidates.js +10 -7
  12. package/lib/changes.js +10 -2
  13. package/lib/config-keys.js +176 -0
  14. package/lib/config.js +447 -183
  15. package/lib/graph.js +20 -26
  16. package/lib/kernel/candidate.js +28 -8
  17. package/lib/kernel/coupling.js +16 -10
  18. package/lib/kernel/frontmatter.js +475 -0
  19. package/lib/kernel/gates.js +432 -2
  20. package/lib/kernel/graph.js +451 -637
  21. package/lib/kernel/landed.js +238 -0
  22. package/lib/kernel/queue.js +105 -1
  23. package/lib/kernel/ranker.js +184 -0
  24. package/lib/kernel/registry.js +32 -0
  25. package/lib/kernel/resolution.js +137 -87
  26. package/lib/kernel/satisfiability.js +19 -5
  27. package/lib/kernel/tokenizer.js +185 -0
  28. package/lib/queue.js +51 -31
  29. package/lib/remote.js +80 -9
  30. package/lib/schema.js +1 -1
  31. package/lib/seed/candidates/schema-factory.md +9 -1
  32. package/lib/seed/candidates/schema-gate.md +33 -2
  33. package/lib/seed/schema-agent.md +3 -3
  34. package/lib/seed/schema-issue.md +16 -2
  35. package/lib/seed/schema-profile.md +9 -1
  36. package/lib/seed/schema-task.md +16 -2
  37. package/lib/shell/agent-dispatch-runner.js +302 -653
  38. package/lib/shell/attestation.js +5 -1
  39. package/lib/shell/candidate-publish.js +24 -11
  40. package/lib/shell/ci-gate.js +229 -0
  41. package/lib/shell/completion.js +5 -24
  42. package/lib/shell/dispatch-harnesses.js +2 -87
  43. package/lib/shell/dispatch-terminal.js +6 -8
  44. package/lib/shell/dispatch.js +1463 -0
  45. package/lib/shell/gate-deps.js +2585 -0
  46. package/lib/shell/gate-runner.js +365 -41
  47. package/lib/shell/git-exec.js +106 -1
  48. package/lib/shell/implementation-stage.js +15 -4
  49. package/lib/shell/integration-runner.js +141 -31
  50. package/lib/shell/landed.js +141 -0
  51. package/lib/shell/local-execution-lock.js +7 -7
  52. package/lib/shell/preflight.js +21 -39
  53. package/lib/shell/process-identity.js +89 -0
  54. package/lib/shell/seed.js +40 -0
  55. package/lib/shell/spool.js +348 -0
  56. package/lib/shell/work-loop.js +227 -14
  57. package/lib/shell/work-outcome.js +228 -0
  58. package/lib/shell/work.js +936 -0
  59. package/lib/validate.js +110 -5
  60. package/package.json +3 -2
  61. package/scripts/engines/debounce-watcher.js +4 -1
  62. package/scripts/engines/distill.js +79 -103
  63. package/scripts/engines/drain-outbox.js +128 -26
  64. package/scripts/engines/post-tool.js +30 -6
  65. package/scripts/engines/prompt-context.js +75 -26
  66. package/scripts/engines/session-start.js +71 -27
  67. package/scripts/engines/spool-sweeper.js +424 -0
  68. package/scripts/engines/util.js +264 -195
  69. package/skills/factory/SKILL.md +9 -3
  70. package/skills/factory/references/emitting.md +20 -9
  71. package/skills/next/SKILL.md +7 -7
  72. package/skills/spor/SKILL.md +29 -7
  73. package/skills/spor/references/authoring-schemas.md +5 -2
@@ -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.1",
5
+ "version": "0.29.3",
6
6
  "author": {
7
7
  "name": "losthammer"
8
8
  }
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "spor",
3
- "version": "0.29.1",
3
+ "version": "0.29.3",
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
@@ -593,7 +638,7 @@ agent subject sees no graph content; a coarse read-only/CI flag is not a bypass.
593
638
  | Endpoint | Typical caller | Semantics |
594
639
  |---|---|---|
595
640
  | `GET /v1/status` | session-start, monitoring | `{node_count, projects: {...}, head, uptime, metrics}`; doubles as the health check. Graph counts/projects are the viewer-visible projection. `?titles=1` adds viewer-visible `titles: [{id, type, project, title}]` — the one-round-trip graph index the distiller dedups against |
596
- | `GET /v1/schema` | `spor schema`, agents introspecting the contract | the live schema registry as data (task-spor-schema-introspection-surface; server half task-spor-server-schema-endpoint): `{default_edge_weight, node_types: [{type, description, prefix, always_on, traversable, capturable, queueable, non_resolving, terminal, inert, inert_inherited, vocabulary, completion, resolver_required, resolution, hooks, schema_id, schema_version, source}], edge_types: [{type, description, weight, weight_default, inverse_label, aliases, capturable, hooks, ...}], queue_policy, policies, registers, stale_overrides, alias_collisions}` — the seed pack MERGED with graph-resident `type: schema` overrides, each entry tagged by `source` (`seed`/`graph`/`native`) and the active schema node's id+version. `?code=1` embeds each hook's source under `code: {name: src}` (omitted by default to keep the response lean). The registry IS the contract (norm-cc-registry-is-contract); this read surface closes the failure mode of agents reverse-engineering it from `lib/seed/` files (which miss resident overrides). `vocabulary`/`completion`/`resolver_required` are the declarative completion policy (task-spor-registry-declarative-terminal-status-policy): the closed status enum a type's `validate()` gates on, its single SUCCESS terminal value (`null` when the type's terminal values are several distinct outcomes rather than one success, i.e. no mechanical close exists), and whether that value also needs a live resolving decision/artifact. They DECLARE what the hooks enforce — a reader (the gardener's finding remedies) names the right terminal status from here instead of hand-mirroring hook source. `resolution` is the per-type ATTESTATION path (issue-spor-offline-check-get-hook-resolution-proxy): `edge` — this type's completion is attested only by a live inbound resolving edge — or `status` — its own terminal status retires it — or `null` when the schema declares neither, which is a resident schema authored before the key existed (a reader falls back to the legacy proxy, `hooks` containing `get`) rather than a graph asserting either answer. A client verifying a dispatched run's outcome reads this, never the `hooks` array. The REST/MCP twin of the `spor schema` CLI: all three render one `graph.registry.snapshot()` so they never drift |
641
+ | `GET /v1/schema` | `spor schema`, agents introspecting the contract | the live schema registry as data (task-spor-schema-introspection-surface; server half task-spor-server-schema-endpoint): `{default_edge_weight, node_types: [{type, description, prefix, always_on, traversable, capturable, queueable, non_resolving, terminal, inert, inert_inherited, vocabulary, completion, resolver_required, resolver_types, resolution, hooks, schema_id, schema_version, source}], edge_types: [{type, description, weight, weight_default, inverse_label, aliases, capturable, hooks, ...}], queue_policy, policies, registers, stale_overrides, alias_collisions}` — the seed pack MERGED with graph-resident `type: schema` overrides, each entry tagged by `source` (`seed`/`graph`/`native`) and the active schema node's id+version. `?code=1` embeds each hook's source under `code: {name: src}` (omitted by default to keep the response lean). The registry IS the contract (norm-cc-registry-is-contract); this read surface closes the failure mode of agents reverse-engineering it from `lib/seed/` files (which miss resident overrides). `vocabulary`/`completion`/`resolver_required` are the declarative completion policy (task-spor-registry-declarative-terminal-status-policy): the closed status enum a type's `validate()` gates on, its single SUCCESS terminal value (`null` when the type's terminal values are several distinct outcomes rather than one success, i.e. no mechanical close exists), and whether that value also needs a live resolving decision/artifact (`resolver_types`: which node types count as that resolver, `[]` when undeclared). They DECLARE what the hooks enforce — a reader (the gardener's finding remedies) names the right terminal status from here instead of hand-mirroring hook source. `resolution` is the per-type ATTESTATION path (issue-spor-offline-check-get-hook-resolution-proxy): `edge` — this type's completion is attested only by a live inbound resolving edge — or `status` — its own terminal status retires it — or `null` when the schema declares neither, which is a resident schema authored before the key existed (a reader falls back to the legacy proxy, `hooks` containing `get`) rather than a graph asserting either answer. A client verifying a dispatched run's outcome reads this, never the `hooks` array. The REST/MCP twin of the `spor schema` CLI: all three render one `graph.registry.snapshot()` so they never drift |
597
642
  | `GET /v1/me` | `spor whoami`/`status`, onboarding | identity echo for the bearer token → `{person, name, email, bound, is_admin, org}`. `bound:false` means the token authenticates but maps to **no person node** (legacy/OAuth, or minted before the node existed), so routed questions and the personal queue will be empty — the client warns on it (the silent identity-degradation signal). `is_admin` reflects the `stewards→root` edge that gates the token-admin surface. `org` is the slug this tenant routes to (`SPOR_ORG`/legacy `SUBSTRATE_ORG`, else `"local"`); it lets a client key its `(issuer, org)` credential store for an **opaque** `spor_oat_`/`spor_pat_` token that carries no readable `org` claim — the client falls back to it after `--org` and the JWT `org` claim (task-spor-frontdoor-me-org-echo). A connector JWT's `org` claim is enforced equal to this echo |
598
643
  | `GET /v1/me/org-choices` | `spor auth list` (live membership refresh) | re-queries the IdP's *current* org membership for the held credential's subject and returns `{org_choices: [{slug, label, default?}], source: "idp"\|"bound"}` — `source:"idp"` is a true live enumeration (orgs added/removed since the last login surface without re-authenticating); `source:"bound"` means a single org-scoped token the server couldn't expand (no enumeration). The client treats only `source:"idp"` as live and **fails open** to its cached tenant listing on anything else — `source:"bound"`, a `502 {error.code:"membership_requery_failed"}` (IdP unreachable), a `404` (older server without the endpoint), or any transport/parse error (task-spor-cli-auth-list-live-membership-requery; server half task-spor-frontdoor-held-credential-membership-requery) |
599
644
  | `GET /v1/me/tokens` | `spor token list` | list the caller's OWN personal access tokens → `{tokens: [{hash_prefix, person, label, name, email, created, expires, expired, last_used}], count}` — caller-scoped (only their person-bound PATs; agent session tokens excluded), never plaintext, never full hashes. `403 forbidden` if the bearer maps to **no person node** (you need a bound identity to own a PAT). The self-serve, no-admin twin of `GET /v1/admin/tokens` below (task-spor-app-me-tokens-self-serve) |
@@ -605,7 +650,7 @@ agent subject sees no graph content; a coarse read-only/CI flag is not a bypass.
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). **This body is the canonical queue envelope** (task-spor-local-remote-single-renderer-conformance): every field except the per-viewer routing lists and the stamp (`awaiting_you`/`questions`/`asked`/`findings`/`pending`/`reviews`/`generated_at`, `SERVER_ONLY_QUEUE_FIELDS`) is shaped by the client kernel's `shapeQueueEnvelope`/`queueAggregates` (lib/kernel/queue.js) and the warning by `unknownProjectWarning` (lib/kernel/graph.js); local `spor next --json` emits the same envelope over a local graph, both modes render it through ONE `renderReport` (lib/queue.js), and test/mode-parity.test.js diffs the two against a stub of this route |
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
@@ -881,11 +927,15 @@ as if the graph is empty" (§6).
881
927
  A `429 rate_limited` response SHOULD carry a `Retry-After` header (delay
882
928
  seconds or an HTTP-date); clients honor it, otherwise backing off
883
929
  exponentially, capped, before retrying. Mechanical writers
884
- (drain-outbox, distill) classify `401`, `400`, `413`, and `422` as
885
- **permanent** — a revoked token will not un-revoke, so these are
930
+ (drain-outbox, distill) classify `401`/`403`, `400`, `413`, and `422` as
931
+ **permanent** (one shared classifier, `classifyHttpFailure` in
932
+ `scripts/engines/util.js`, which session-start also reads its banner from) — a revoked token will not un-revoke, so these are
886
933
  dead-lettered to `outbox/dead/` with a loud `journal/remote.log` line
887
934
  rather than re-POSTed forever; `429` and `5xx` stay transient and are
888
- retried with backoff.
935
+ retried with backoff. Before a `401`/`403` is read as permanent, a writer
936
+ whose tenant carries a `refresh_token` refreshes it once and retries the
937
+ POST (`curlWithRefresh`, the same refresh the CLI does, §6.2) — an expired
938
+ short-lived token is not a revoked one.
889
939
 
890
940
  `GET /v1/export` response headers: `x-substrate-head` carries the graph
891
941
  commit, `x-substrate-node-count` the entry count (plus `x-substrate-auth-files`
@@ -1055,11 +1105,40 @@ machine-local — never committed, always in the shared-graph `.gitignore`):
1055
1105
  refuse like any other verb, since acting on the active tenant is exactly the
1056
1106
  hazard. An `--org` given an **empty** value (an unset shell variable, in
1057
1107
  either the `--org ""` or the dangling `--org` spelling) refuses everywhere,
1058
- acquisition included: it is malformed input, not "use the default". The
1059
- ambient selectors (`SPOR_ORG`, the repo `org:` marker) still fall
1060
- through — they also ride the fail-open hook engines.
1108
+ acquisition included: it is malformed input, not "use the default".
1109
+ - **Unknown ambient org refuses too.** `SPOR_ORG` and a repo `.spor` `org:`
1110
+ marker naming an org with no stored credential refuse exactly like `--org`
1111
+ (issue-spor-ambient-org-selector-silent-fallback): the CLI exits 1 naming the
1112
+ selector and the stored orgs (acquisition invocations exempt), and the hook
1113
+ engines inject nothing and write nothing to EITHER graph — no remote call and
1114
+ no fall-through to the local graph home — journaling a warning to
1115
+ `journal/remote.log`. Under an explicit `mode: local`/`off` no tenant is
1116
+ consulted, so a stray ambient org is moot there. `spor config explain` shows
1117
+ which selector chose (or refused) the tenant.
1118
+ - **A repo `server` gets only a credential recorded for it.** A committed repo
1119
+ `.spor.json` may set `server` (how a repo points contributors at a team
1120
+ server), but the flat `token` is never repo-sourced — it was recorded beside
1121
+ the server the user/global config names. When the repo layer wins `server`
1122
+ (legacy flat path, or an ambient org satisfied by it), that token is sent only
1123
+ if its recorded server equals the repo's; otherwise a store credential
1124
+ recorded for exactly that server is used, else the cascade refuses
1125
+ (`server-mismatch`, naming both servers) the same way an unknown ambient org
1126
+ does (dec-spor-repo-server-key-requires-matching-credential). `spor auth
1127
+ login` is exempt and defaults to the repo's server — signing in there is the
1128
+ cure, and that credential is stored for that server only, not as the store
1129
+ default (which would move every other repo onto it). A repo `server` with no credential on hand at all resolves tokenless.
1061
1130
  - **Refresh.** A 401/403 on a tenant carrying a `refresh_token` transparently
1062
1131
  refreshes against its issuer (`grant_type=refresh_token`) and retries once.
1132
+ Only the store tenant's OWN bearer carries one: an explicit `SPOR_TOKEN` /
1133
+ `--token` that is not the stored `access_token` for that server — a
1134
+ dispatched agent's scoped token under the person's HOME is exactly this
1135
+ shape — resolves with no `refresh_token`, no store `key`, and its own JWT
1136
+ `org` claim, so an agent's 401 is never retried as the person
1137
+ (issue-spor-agent-token-scope-escalation-via-refresh-and-store-default).
1138
+ For the same reason `spor dispatch` exports `SPOR_SERVER` beside the
1139
+ agent-scoped `SPOR_TOKEN` to every remote-mode harness child: without the
1140
+ server the child's cascade never reads the env token and selects the store
1141
+ default — the person — instead.
1063
1142
  - **Byte-identical.** With no credential store and only a flat
1064
1143
  `server`+`token` or `SPOR_*` env set, every resolved value equals the prior
1065
1144
  single-tenant behavior (norm-cc-byte-identical-refactor).
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:` —
@@ -340,7 +352,10 @@ keys say, as registry data, what the hooks below enforce: `status.vocabulary`
340
352
  the full set *including* the give-up outcomes `abandoned`/`superseded`/
341
353
  `rejected`), and `status.resolver_required` (whether reaching that value also
342
354
  demands a live resolving `decision`/`artifact`, the completion-resolver
343
- invariant). **Declaring them gates nothing** — the hooks are still the only
355
+ invariant), with `status.resolver_types` naming WHICH node types count as that
356
+ resolver (task/issue: `["decision", "artifact"]`; only legal beside
357
+ `resolver_required: true`; read as `registry.resolverTypes(type)`,
358
+ task-spor-registry-sole-terminal-status-source). **Declaring them gates nothing** — the hooks are still the only
344
359
  write door, and this is exactly why they are not a field list or an enforced
345
360
  enum. They exist so a READER can name the right terminal status without
346
361
  parsing hook source: the gardener's finding remedies used to keep hand-written
@@ -379,9 +394,11 @@ above, **declaring it gates nothing** — it tells a reader what the hooks
379
394
  already do.
380
395
 
381
396
  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
397
+ regex parser (`lib/kernel/frontmatter.js`, the one node-file grammar) accepts
398
+ (simple `key: value` scalars, YAML-folded multi-line values, the allowlisted
399
+ list keys as `[a, b]` inline or block lists, `- {type: X, to: Y}` flow-form or
400
+ `- type: X` / `to: Y` block-form edges — and nothing fancier) is carried
401
+ verbatim on the node. What a field MUST contain, and which
385
402
  status changes are legal, are enforced **in attached code** — two pure functions
386
403
  the server runs on the write path:
387
404
 
@@ -929,6 +946,14 @@ edges:
929
946
  optional `@YYYY-MM-DD` expiry) is per-viewer presentation only: the queue
930
947
  hides those items for this person and reports how many it hid; they stay live
931
948
  and visible to everyone else (QUEUE.md §4).
949
+ - **`covers_tests`** (flat inline list of repo-relative test file paths) on a
950
+ flake issue — or the task that fixes one — declares which tests that flake
951
+ covers; the gate pipeline stamps it on every `issue-flake-*` it files. Its
952
+ twin **`failing_tests`** is written by the gate pipeline on a `task-gate-*`
953
+ escalation: the test files a command gate's refusal failed in. Once every
954
+ failing test of a refusal is covered by a FIXED node (a live resolver, or
955
+ `done`/`resolved`), `spor work --regate-flakes` re-gates the run unattended
956
+ and retires the escalation only on a pass (WORKERS.md §10.7).
932
957
  - **`roles`** (flat inline list, e.g. `roles: [reviewer, maintainer]`) is the
933
958
  qualification register the org-defined policy layer reads. A scoped `policy`
934
959
  node's gate counts approvals from persons holding a named role — the
@@ -975,8 +1000,7 @@ server-side (issue-cc-onboarding-email-mismatch-silent-degradation).
975
1000
  ### Agents (person-owned principals)
976
1001
 
977
1002
  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)
1003
+ durable identity of a dispatched Claude Code run (a supervised `claude -p`)
980
1004
  (dec-spor-agent-identity-nodes). It generalizes the workflow-run principal: a
981
1005
  dispatched session is just another principal kind owned by a person, so work it
982
1006
  creates reads "agent **on behalf of** person" rather than person-direct.
@@ -1061,6 +1085,11 @@ date: 2026-06-18
1061
1085
  `mcp` is merged into the strict `--mcp-config` dispatch writes, so the agent's
1062
1086
  toolset is exactly the profile plus the agent-spor server, nothing ambient
1063
1087
  (dec-spor-session-identity-active-record).
1088
+ - `model_family:` (optional) names the canonical model family (`gpt-5`,
1089
+ `claude`, …). It is not a satisfiability field; it is what a review gate's
1090
+ `fallback_profile` is judged independent of the implementer on — an unknown
1091
+ or equal family refuses the fallback
1092
+ (dec-spor-reviewer-reset-pause-budget-and-provenance).
1064
1093
  - **The graph names the harness; the MACHINE binds what that name runs**
1065
1094
  (task-spor-dispatch-declarative-custom-harness). `harness:` may name a
1066
1095
  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,35 @@ 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
+ if (te.kind === "server-mismatch") log(`${require("../lib/config.js").describeTenantRefusal(te)} — hook skipped; run 'spor auth login' for that server or override it`);
101
+ else if (te.kind === "agent-org" || te.kind === "agent-no-token") log(`${require("../lib/config.js").describeTenantRefusal(te)} — hook skipped; an agent run never selects the person's stored credential`);
102
+ else log(`org '${te.org}' (from ${te.origin}) has no stored credential — hook skipped; run 'spor auth login --org ${te.org}' or fix the selector`);
103
+ } catch {
104
+ /* logging must never break fail-open */
105
+ }
106
+ return true;
107
+ }
108
+
80
109
  async function main() {
81
110
  const argv = process.argv.slice(2);
82
111
  const event = argv.shift() ?? "";
@@ -112,7 +141,8 @@ async function main() {
112
141
  const ci = args.indexOf("--cwd");
113
142
  if (ci >= 0 && args[ci + 1]) amdCwd = args[ci + 1];
114
143
  else if (payload && payload.cwd) amdCwd = payload.cwd;
115
- if (!u.useConfig({ cwd: amdCwd }).enabled()) return;
144
+ const amdCfg = u.useConfig({ cwd: amdCwd });
145
+ if (!amdCfg.enabled() || tenantRefused(amdCfg)) return;
116
146
  await agentsMd(payload, args);
117
147
  return;
118
148
  }
@@ -177,6 +207,13 @@ async function main() {
177
207
  return;
178
208
  }
179
209
 
210
+ // A bound org this box holds no credential for (SPOR_ORG, a repo `.spor`
211
+ // org: marker) is a REFUSAL, not a hint (issue-spor-ambient-org-selector-
212
+ // silent-fallback): inject nothing and write nothing to EITHER graph — the
213
+ // null tenant would otherwise resolve LOCAL mode and quietly distill into the
214
+ // personal graph home. Journaled, since a hook can only fail silently.
215
+ if (tenantRefused(cfg)) return;
216
+
180
217
  // Debounced distill: spool the payload and hand off to a per-session
181
218
  // watcher (one at a time — the lock holds the watcher's pid; stale locks
182
219
  // from a dead watcher are reclaimed). The watcher fires after quiesce.
@@ -194,7 +231,7 @@ async function main() {
194
231
  // live claim on a false positive would silently strand active work.
195
232
  payload.spor_debounced = true;
196
233
  try {
197
- fs.writeFileSync(pendingFile, JSON.stringify(payload));
234
+ u.writeSpoolFile(pendingFile, JSON.stringify(payload));
198
235
  } catch {
199
236
  return;
200
237
  }