@sporhq/spor 0.25.0 → 0.27.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (57) hide show
  1. package/.claude-plugin/plugin.json +1 -1
  2. package/.codex-plugin/plugin.json +1 -1
  3. package/API.md +71 -17
  4. package/GRAPH.md +92 -10
  5. package/QUEUE.md +7 -2
  6. package/README.md +250 -12
  7. package/bin/spor.js +5163 -377
  8. package/lib/candidates.js +179 -0
  9. package/lib/config.js +47 -0
  10. package/lib/graph.js +44 -0
  11. package/lib/kernel/gates.js +1458 -0
  12. package/lib/kernel/queue.js +11 -0
  13. package/lib/kernel/registry.js +118 -2
  14. package/lib/kernel/satisfiability.js +107 -5
  15. package/lib/schema.js +23 -1
  16. package/lib/seed/candidates/schema-edge-member-of-program.md +41 -0
  17. package/lib/seed/candidates/schema-factory.md +131 -0
  18. package/lib/seed/candidates/schema-gate.md +101 -0
  19. package/lib/seed/schema-agent.md +4 -3
  20. package/lib/seed/schema-artifact.md +21 -1
  21. package/lib/seed/schema-capture-pending.md +19 -2
  22. package/lib/seed/schema-correction.md +14 -1
  23. package/lib/seed/schema-decision.md +21 -1
  24. package/lib/seed/schema-issue.md +26 -2
  25. package/lib/seed/schema-profile.md +13 -1
  26. package/lib/seed/schema-question.md +21 -2
  27. package/lib/seed/schema-task.md +26 -2
  28. package/lib/shell/agent-dispatch-runner.js +868 -47
  29. package/lib/shell/dispatch-harnesses.js +1015 -23
  30. package/lib/shell/dispatch-terminal.js +645 -0
  31. package/lib/shell/gate-runner.js +1604 -0
  32. package/lib/shell/integration-runner.js +1033 -0
  33. package/lib/shell/work-loop.js +1365 -0
  34. package/lib/shell/worker-contract.js +138 -0
  35. package/package.json +5 -2
  36. package/prompts/client/distill-local.md +1 -1
  37. package/scripts/engines/doctor.js +34 -0
  38. package/scripts/engines/util.js +37 -4
  39. package/skills/brief/SKILL.md +1 -1
  40. package/skills/factory/SKILL.md +254 -0
  41. package/skills/factory/evals/evals.json +47 -0
  42. package/skills/factory/fixtures/README.md +48 -0
  43. package/skills/factory/fixtures/interview-acme-checkout.md +120 -0
  44. package/skills/factory/fixtures/nodes/factory-acme-checkout.md +78 -0
  45. package/skills/factory/fixtures/nodes/gate-adversarial-review.md +31 -0
  46. package/skills/factory/fixtures/nodes/profile-acme-rescue.md +28 -0
  47. package/skills/factory/fixtures/nodes/profile-acme-test-writer.md +15 -0
  48. package/skills/factory/fixtures/nodes/profile-codex-review.md +20 -0
  49. package/skills/factory/fixtures/nodes/task-acme-checkout-acceptance-suite.md +32 -0
  50. package/skills/factory/references/emitting.md +420 -0
  51. package/skills/factory/references/interview.md +183 -0
  52. package/skills/factory/references/maintenance.md +183 -0
  53. package/skills/onboard/SKILL.md +1 -1
  54. package/skills/spor/SKILL.md +7 -4
  55. package/skills/spor/references/authoring-schemas.md +25 -1
  56. package/skills/spor/references/concepts.md +5 -5
  57. package/skills/triage/SKILL.md +4 -3
@@ -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.25.0",
5
+ "version": "0.27.0",
6
6
  "author": {
7
7
  "name": "losthammer"
8
8
  }
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "spor",
3
- "version": "0.25.0",
3
+ "version": "0.27.0",
4
4
  "description": "Maintains a typed, versioned knowledge graph and compiles compact briefings from it: session-start injection, per-prompt relevance digests, capture at discovery, end-of-session distillation, decision queue.",
5
5
  "author": {
6
6
  "name": "Spor",
package/API.md CHANGED
@@ -8,7 +8,10 @@ at `/mcp`) for model-driven clients — Cowork, claude.ai connectors,
8
8
  in-session tool calls. Both doors require bearer auth (§4), are thin adapters
9
9
  over the same core, and a tool call and its REST twin return byte-identical
10
10
  payloads. Companion specs: [GRAPH.md](GRAPH.md) (node/edge format),
11
- [QUEUE.md](QUEUE.md) (capture, decision queue, schema registry).
11
+ [QUEUE.md](QUEUE.md) (capture, decision queue, schema registry),
12
+ [WORKERS.md](WORKERS.md) (the worker protocol — claim/brief/work/report/
13
+ resolve, lease semantics, and the terminal-state contract dispatched agents
14
+ are built from).
12
15
 
13
16
  ## 1. Write semantics (both surfaces)
14
17
 
@@ -382,6 +385,17 @@ queue, not what the firehose hid. The type compared is the type the item
382
385
  surfaces as, so `exclude_types: ["schema"]` also hides schema-approval items.
383
386
  Omitting both (or passing empty arrays) filters nothing.
384
387
 
388
+ **Unknown project scope** (task-spor-remote-project-validation-warning). A
389
+ `project` that matches no repo, grouping, or project stamp on any resident node
390
+ is not an error: the result is a normal envelope with empty `items` plus the
391
+ additive `project_warning` string (the `/v1/analytics` shape), and the text
392
+ output carries the same line. It is absent when the scope is known — a known
393
+ project with nothing queued has no warning. Clients that print the queue for a
394
+ person should surface it (remote `spor next` prints it verbatim on stderr and
395
+ strips it from `--json`, matching local mode); a caller that ignores it sees
396
+ exactly what an empty queue looks like, which is the typo this field exists to
397
+ catch.
398
+
385
399
  **Per-person scope** (task-cc-queue-assignee-filtering). `assignee` scopes the
386
400
  ranked queue to the work one person carries — the union of nodes with an
387
401
  `assigned` edge to them and the nodes they `steward`; `assignee: "me"` binds
@@ -578,7 +592,7 @@ agent subject sees no graph content; a coarse read-only/CI flag is not a bypass.
578
592
  | Endpoint | Typical caller | Semantics |
579
593
  |---|---|---|
580
594
  | `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 |
581
- | `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, 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). The REST/MCP twin of the `spor schema` CLI: all three render one `graph.registry.snapshot()` so they never drift |
595
+ | `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, 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. The REST/MCP twin of the `spor schema` CLI: all three render one `graph.registry.snapshot()` so they never drift |
582
596
  | `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 |
583
597
  | `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) |
584
598
  | `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) |
@@ -610,7 +624,7 @@ agent subject sees no graph content; a coarse read-only/CI flag is not a bypass.
610
624
  | `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) |
611
625
  | `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 |
612
626
  | `POST /v1/corrections` | /spor:correct | `propose_correction` semantics → 201 `{status, id, revision, warnings}` |
613
- | `GET /v1/queue?project=&assignee=&type=&exclude_type=&limit=&offset=` | /spor:next, session-start | the ranked decision queue: `{items, count, offset, returned_count, total_count, truncated, next_offset, counts_by_type, counts_by_project, counts_by_suggest, muted?, dormant?, questions, asked, findings, pending, reviews, policy?, generated_at}` — items retired by a live resolves/answers edge are excluded; items hidden by the viewer's `queue_mute` or parked by a future `wake:` date (QUEUE.md §4) are counted, never silently dropped; `questions`/`findings`/`pending` are the routed-to-me-plus-unrouted views for the authenticated identity, `asked` is the questions you filed, and `reviews` is the nodes whose review is requested of you (an open `review-requested` edge to your person node — explicitly targeted, no unrouted fallback). `limit` is the page size (default 20, **max 100**, clamped not rejected) and `offset` skips that many items in the ranked order (default 0); the `counts_*`/`total_count` aggregates always cover the FULL ranked set regardless of the page, so one call answers "how many issues vs tasks" without paging, while `truncated`/`next_offset` let a client walk the rest by re-requesting with `offset=next_offset` until `next_offset` is null. Pagination is offset over a point-in-time ranked slice (the queue re-ranks every call), not a cursor — it resumes the same slice only across an unchanged ranking. `project` resolves through the shared up-resolution (dec-spor-queue-slug-resolves-to-grouping): a bare repo slug unions its home-project grouping's member queues, the repo NODE id (`repo-<slug>`) pins one repo, a grouping id (`proj-<slug>`) is used directly; **omitting `project` is the cross-project firehose** (every repo's queue at once). `assignee=<person-id>` scopes to the work that person carries (their `assigned`/`stewards` edges) — a manager's "who is carrying what"; `assignee=me` binds to the caller (empty if the token maps to no person node). `type=`/`exclude_type=` (comma-separated, repeatable) whitelist/blacklist node types from the ranking (exclude wins on overlap) — a hard scope filter applied before scoring, so the aggregates describe the filtered queue (task-cc-queue-filtering-enhancements) |
627
+ | `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) |
614
628
  | `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) |
615
629
  | `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`) |
616
630
  | `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 |
@@ -717,20 +731,60 @@ anything with a token.
717
731
  (Not to be confused with `spor dispatch --agent`, the unrelated `claude
718
732
  --agent` harness passthrough.) The
719
733
  token is minted **session-deferred** and bound to the real run session AFTER
720
- launch (dec-spor-dispatch-bg-session-late-bind): `claude --bg` ignores
721
- `--session-id` and self-allocates its session, so dispatch reads the real one
722
- from `claude agents --json` and binds it via `POST /v1/agents/session` (§3) — the
723
- one place an agent token's session is set, write-once. The session can't be
724
- forged a-priori (it isn't known until the run exists) and can't ride the write
725
- payload (token-derived, §1), so the binding is always the actual run. A
726
- **Codex**-harness dispatch follows the same late-bind contract from its own
727
- supervisor process instead: it reads the session id off the `thread.started`
728
- event in the run's supervised JSONL log rather than `claude agents --json`,
729
- then binds it the same way. Token transport also differs per harness —
730
- Claude Code gets a strict `--mcp-config` file, Codex gets the token via an
731
- env var its own config references (`--config
732
- mcp_servers.spor.bearer_token_env_var=SPOR_DISPATCH_MCP_TOKEN`) — but both
733
- land at the same self-serve mint/bind pair above.
734
+ launch (dec-spor-dispatch-bg-session-late-bind): a harness self-allocates its
735
+ session, so dispatch reads the real one and binds it via `POST
736
+ /v1/agents/session` (§3) — the one place an agent token's session is set,
737
+ write-once. The session can't be forged a-priori (it isn't known until the run
738
+ exists) and can't ride the write payload (token-derived, §1), so the binding
739
+ is always the actual run. For the opt-in native launch (`spor dispatch --bg`,
740
+ `claude --bg` ignores `--session-id`) the launcher reads it from `claude agents
741
+ --json`. A **supervised**-harness dispatch (Claude Code by default, Codex,
742
+ OpenCode, GitHub Copilot CLI) follows
743
+ the same late-bind contract from its own supervisor process instead: it reads
744
+ the session id out of the run's supervised JSONL log rather than
745
+ `claude agents --json` — Claude Code off the `session_id` every stream-json
746
+ event carries (first on its `system`/`init` event), Codex off its
747
+ `thread.started` event, OpenCode off
748
+ the `sessionID` every event carries, Copilot off the `sessionId` on its
749
+ terminal `result` event (so a Copilot run binds only at exit, still before its
750
+ record goes terminal) — then binds it the same way. Token transport also
751
+ differs per harness: Claude Code gets a strict `--mcp-config` file, Codex gets
752
+ the token via an env var its own config references (`--config
753
+ mcp_servers.spor.bearer_token_env_var=SPOR_DISPATCH_MCP_TOKEN`), and OpenCode
754
+ and Copilot — neither of which can be handed an MCP server on the command line
755
+ without publishing the bearer to argv — get the agent-scoped token as
756
+ `SPOR_TOKEN` in the run's environment, so the `spor` CLI inside the run is
757
+ agent-attributed with no injected MCP. All of them land at the same self-serve
758
+ mint/bind pair above. A **declared** harness (below) is `env-token` too, and
759
+ binds off whatever JSON path its declaration names.
760
+ - **Declared custom harnesses — the graph names it, the machine binds it**
761
+ (task-spor-dispatch-declarative-custom-harness). A `profile` may select a
762
+ harness this client ships no adapter for; the profile then carries **only**
763
+ `harness: <id>`, and the id is bound to something executable by a
764
+ MACHINE-LOCAL declaration in the client config cascade,
765
+ `dispatch.harness.<id>` — machine-specific like `dispatch.bin.<harness>` and
766
+ `dispatch.repos`, and read ONLY from the user `$SPOR_HOME/config.json` or the
767
+ global one: both `dispatch.harness` and `dispatch.bin` are stripped from a
768
+ committable repo `.spor.json` with a warning (`REPO_FORBIDDEN_PATHS`,
769
+ lib/config.js), the way a repo-level `token` is, so a repo write is no more
770
+ able to choose what this box executes than a graph write is. The declaration carries `command`, an `args`
771
+ template (`{cwd}` / `{report}` / `{model}` tokens), a `label`, report recovery
772
+ (`report: "lastText"` with a `report.text` JSON path into the harness's event
773
+ stream, or `report: "file"` when the harness writes the report itself at the
774
+ `{report}` path), and an optional `session` JSON path for the late-bind above.
775
+ Everything else is FIXED by v1 scope and naming it is an error: launch mode is
776
+ supervised-jsonl, the prompt goes on stdin, identity is `env-token`.
777
+ **A graph write must never define what a machine executes**, and that is
778
+ enforced on both sides: a profile node carrying any launch-defining field
779
+ (`command`, `args`, `argv`, `bin`, `exec`, `entrypoint`, `env`, `report`,
780
+ `session`, `launch_mode`, `identity_mode`) is refused outright by `spor
781
+ dispatch` rather than honoured or silently ignored, and a machine with no
782
+ local binding for the id fails satisfiability — refusing loudly, leaving the
783
+ assignment and its lease untouched, and (in team mode) naming the fleet hosts
784
+ that CAN run it. The capability probe adds valid declared ids to
785
+ `machine.harnesses`, so `spor capabilities` and the fleet publish above
786
+ reflect them; a declaration whose `command` does not resolve is not reported
787
+ as available.
734
788
  - **OAuth 2.1 for MCP connectors** (Cowork/claude.ai, which cannot carry a
735
789
  static bearer token): protected-resource metadata discovery (RFC 9728,
736
790
  advertised on the `/mcp` 401 via `WWW-Authenticate`), authorization-server
package/GRAPH.md CHANGED
@@ -302,8 +302,8 @@ separately, but never shows a complete custom type in one piece.
302
302
 
303
303
  **The constraint model is procedural, not declarative.** A schema's `json`
304
304
  payload declares only *registry knobs* — `node_type`, `prefix`, `queueable`,
305
- `traversable`, `always_on`, `capturable`, an edge `weight`, and the three status
306
- partitions: `status.non_resolving` (resolver semantics — whether a node in this
305
+ `traversable`, `always_on`, `capturable`, an edge `weight`, the completion
306
+ policy (below), and the three status partitions: `status.non_resolving` (resolver semantics — whether a node in this
307
307
  status retires the targets it points at), `status.terminal` (own-lifecycle
308
308
  completion — the statuses in which a node of this type is *done*, unioned with the
309
309
  kernel's legacy set and read by work-analytics so a schema-only terminal status
@@ -314,8 +314,33 @@ issue-spor-analytics-completion-ignores-schema-terminal-status), and
314
314
  `terminal-status` register below; a schema that declares no `inert` set
315
315
  INHERITS its `terminal` set, so only a schema whose two sets genuinely differ
316
316
  declares it — the seed decision schema pins `settled` terminal but NOT inert,
317
- dec-spor-status-inert-third-partition). There is **no
318
- declarative field list and no status enum.** Custom fields are free-form: any flat frontmatter key the
317
+ dec-spor-status-inert-third-partition).
318
+
319
+ **The completion policy — declared for readers, still enforced by code**
320
+ (task-spor-registry-declarative-terminal-status-policy). Three further `status`
321
+ keys say, as registry data, what the hooks below enforce: `status.vocabulary`
322
+ (the closed status enum the type's own `validate()` gates membership on),
323
+ `status.completion` (the single SUCCESS terminal value — task `done`, issue
324
+ `resolved`, question `answered` — as distinct from `status.terminal`, which is
325
+ the full set *including* the give-up outcomes `abandoned`/`superseded`/
326
+ `rejected`), and `status.resolver_required` (whether reaching that value also
327
+ demands a live resolving `decision`/`artifact`, the completion-resolver
328
+ invariant). **Declaring them gates nothing** — the hooks are still the only
329
+ write door, and this is exactly why they are not a field list or an enforced
330
+ enum. They exist so a READER can name the right terminal status without
331
+ parsing hook source: the gardener's finding remedies used to keep hand-written
332
+ tables of "task → done, everything else → resolved" and shipped remedies whose
333
+ `set_status` the door then refused
334
+ (issue-spor-gardener-terminal-status-fallback-off-vocab). A type whose terminal
335
+ values are several distinct OUTCOMES rather than one success (decision
336
+ settled/superseded/rejected, artifact merged/released/done, capture-pending
337
+ merged/rejected) declares a `vocabulary` and NO `completion` — that absence is
338
+ the machine-readable form of "there is no mechanical close here". Declaration
339
+ and hook are pinned together by `test/seed-declarative-status-policy.test.js`,
340
+ which drives every seed schema's hooks through the sandbox and fails if the two
341
+ disagree; read the live values with `spor schema <type>`.
342
+
343
+ Otherwise there is **no declarative field list and no status enum.** Custom fields are free-form: any flat frontmatter key the
319
344
  regex parser accepts (simple `key: value` scalars, YAML-folded multi-line
320
345
  values, `pin:`/`exclude:` inline lists, `- {type: X, to: Y}` edges — and nothing
321
346
  fancier) is carried verbatim on the node. What a field MUST contain, and which
@@ -400,6 +425,46 @@ warns). A schema node goes through the same propose→activate flow it governs
400
425
  bump the CalVer and add an `upgrades` chain only when the change is not
401
426
  backward-readable.
402
427
 
428
+ Rollout-stage schemas the *product* ships ride the **candidate pack**
429
+ (`lib/seed/candidates/`): full schema-node markdown that travels with the npm
430
+ package but never enters the registry until a graph adopts it as a
431
+ graph-resident node — `spor schema candidates` lists each candidate's adoption
432
+ state, `spor schema adopt <id>` writes it through the validated node surface
433
+ (`status: proposed`; `--activate` is the trusted-admin form for solo/local
434
+ graphs), stamping `adopted_from`/`adopted_sha` provenance. Re-running adopt
435
+ after a package upgrade is CalVer-aware and idempotent: a pristine older copy
436
+ (canonical hash — `schema_version` + body — still matching its stamp) upgrades
437
+ in place with its status preserved; a locally modified or unstamped resident
438
+ refuses without `--force`. When a candidate stabilizes it is promoted into the
439
+ seed pack at a release; the resident copy then shadows the seed (the
440
+ stale-override warning above) and should be retired (`status: retired`).
441
+
442
+ Three candidates ship today: `schema-edge-member-of-program` (program
443
+ membership as its own edge type) and the pair the **software-factory gate
444
+ pipeline** reads — `schema-factory` and `schema-gate`
445
+ (task-spor-work-gate-pipeline). A `type: factory` node declares, in a fenced
446
+ JSON payload, the ordered gate list a worker enforces between claim and resolve
447
+ (`spor work --factory <id>`), plus the trusted ref its acceptance suite is taken
448
+ from, the protected test paths that fail a gate CLOSED, the separate
449
+ test-change lane those route to, and the named risk classes a human gate keys
450
+ on. A `type: gate` node is ONE such gate standing alone, so an org vets a
451
+ `gate-security-review` once and every factory references it by id — inline and
452
+ referenced gates are the same object to the runner. The same payload may also
453
+ declare an optional `integration` block — the merge-queue landing stage that
454
+ runs once every gate has passed (dec-spor-factory-integration-step): it is
455
+ deliberately NOT a fourth gate kind (a gate judges the branch; integration
456
+ mutates the target ref and serializes across workers), so it is its own key
457
+ beside `gates`, parsed the same fail-closed way — and an optional `rescue`
458
+ block (task-spor-factory-rescue-lane): a strong-model profile the runner
459
+ dispatches at a gate's exhaustion BEFORE any human escalation, to diagnose,
460
+ fix and file factory-improvement tasks, re-running the gates on what it
461
+ commits (WORKERS.md §10.10). Both `gate` and `factory` are
462
+ `capturable: false` (the distiller never drafts one: a factory changes what a
463
+ worker will accept, so it is written deliberately) and both arrive by adoption
464
+ rather than in the seed, for the same reason. WORKERS.md §10 documents the
465
+ runtime contract; the payload keys are documented on the candidate nodes
466
+ themselves.
467
+
403
468
  A complete worked example — a `escalation` type with a required `severity`
404
469
  field (enforced in `validate`) and an `open → mitigated → closed` status machine
405
470
  whose terminal `closed` demands a resolver (enforced in `transitions`):
@@ -827,7 +892,8 @@ server-side (issue-cc-onboarding-email-mismatch-silent-degradation).
827
892
  ### Agents (person-owned principals)
828
893
 
829
894
  An `agent` node (prefix `agent-`) is a person's automation principal — the
830
- durable identity of a dispatched `claude --bg` session
895
+ durable identity of a dispatched Claude Code run (a supervised `claude -p`, or
896
+ an opt-in `claude --bg` session)
831
897
  (dec-spor-agent-identity-nodes). It generalizes the workflow-run principal: a
832
898
  dispatched session is just another principal kind owned by a person, so work it
833
899
  creates reads "agent **on behalf of** person" rather than person-direct.
@@ -907,11 +973,24 @@ date: 2026-06-18
907
973
  ```
908
974
 
909
975
  - `harness:` (`claude-code` | `codex` | `opencode` | …) selects the launcher
910
- (dec-cc-portable-core-adapters: claude-code → `claude --bg`, others → their
911
- CLIs); `model:` → the harness `--model`; `skills`/`plugins` are preloaded;
976
+ (dec-cc-portable-core-adapters: claude-code → `claude -p` under the shared
977
+ supervisor, others → their CLIs); `model:` → the harness `--model`; `skills`/`plugins` are preloaded;
912
978
  `mcp` is merged into the strict `--mcp-config` dispatch writes, so the agent's
913
979
  toolset is exactly the profile plus the agent-spor server, nothing ambient
914
980
  (dec-spor-session-identity-active-record).
981
+ - **The graph names the harness; the MACHINE binds what that name runs**
982
+ (task-spor-dispatch-declarative-custom-harness). `harness:` may name a
983
+ launcher the client ships no in-code adapter for — a team's modified build,
984
+ an internal wrapper — in which case the profile still carries nothing but the
985
+ id, and each machine that should run it declares `dispatch.harness.<id>`
986
+ ({`command`, `args`, `label`, `report`, `session`}) in its own
987
+ `$SPOR_HOME/config.json`. **A graph write must never define what a machine
988
+ executes:** a profile carrying `command`, `args`, `argv`, `bin`, `exec`,
989
+ `entrypoint`, `env`, `report`, `session`, `launch_mode` or `identity_mode` is
990
+ REFUSED by `spor dispatch`, not honoured and not silently ignored. So an org
991
+ can publish a profile naming an unadapted harness and only the boxes whose
992
+ OWNER bound that id will take the work (a machine with no binding fails
993
+ satisfiability below).
915
994
  - **The runtime fields ARE the satisfiability spec** — there is no separate
916
995
  requirements block (dec-spor-machine-profile-satisfiability). A machine
917
996
  declares ATOMIC capabilities in a machine-local `dispatch.capabilities` map
@@ -1103,9 +1182,12 @@ program keeps rendering. The preference is all-or-nothing at a node — declare
1103
1182
  every member of an umbrella in one write, or the undeclared rest read as
1104
1183
  "blocking but outside the program" rather than silently dropping. It ships as
1105
1184
  a graph-resident schema node (`schema-edge-member-of-program`), not the seed
1106
- pack, so it needs a *different* identity to activate it (the standing
1107
- propose→activate flow above) before writes of this edge type validate; check
1108
- `spor schema member-of-program` for its live status rather than assuming.
1185
+ pack — delivered as a packaged candidate (`spor schema adopt
1186
+ schema-edge-member-of-program` writes it into a graph that doesn't have it
1187
+ yet; see "Resolution and rollout" above) — so it needs activation (a
1188
+ *different* identity in team graphs; `--activate` in solo/local ones) before
1189
+ writes of this edge type validate; check `spor schema member-of-program` for
1190
+ its live status rather than assuming.
1109
1191
  `capturable: false` — the distiller and capture nudge never emit it; only a
1110
1192
  person or an agent working the program explicitly wires membership.
1111
1193
 
package/QUEUE.md CHANGED
@@ -617,8 +617,13 @@ schemas already self-surface in the queue, so no duplicate findings are
617
617
  filed for them. Trigger: `POST /v1/gardener` on demand (the `spor admin
618
618
  gardener` CLI verb is the shell front-door), or
619
619
  `SPOR_GARDENER_MS` (a server-side env var) for
620
- an in-process interval (off by default — the schedule is ops' choice). Deferred: "done but contradicted" (needs
621
- git-history analysis of resolving artifacts).
620
+ an in-process interval. Self-host/local: off by default — the schedule is
621
+ ops' choice. Hosted tenants (`SPOR_HOSTED`): on by default at 6h, and the
622
+ control plane bakes the same `SPOR_GARDENER_MS` onto every tenant it
623
+ provisions (`SPOR_CP_GARDENER_MS`; explicit `0` disables) — a suspended
624
+ tenant's timer fires on resume, so the cadence is per hour of awake time
625
+ (issue-spor-hosting-tenant-gardener-silent). Deferred: "done but
626
+ contradicted" (needs git-history analysis of resolving artifacts).
622
627
 
623
628
  Findings route to stewards the same way questions do
624
629
  (task-cc-findings-steward-routing): at filing time the finding's edge