@sporhq/spor 0.26.1 → 0.28.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 (42) hide show
  1. package/.claude-plugin/plugin.json +1 -1
  2. package/.codex-plugin/plugin.json +1 -1
  3. package/API.md +59 -11
  4. package/GRAPH.md +43 -3
  5. package/QUEUE.md +7 -2
  6. package/README.md +212 -5
  7. package/bin/spor.js +4713 -224
  8. package/lib/config.js +47 -0
  9. package/lib/graph.js +44 -0
  10. package/lib/kernel/gates.js +1468 -0
  11. package/lib/kernel/queue.js +8 -0
  12. package/lib/kernel/registry.js +19 -0
  13. package/lib/kernel/satisfiability.js +72 -2
  14. package/lib/seed/candidates/schema-factory.md +131 -0
  15. package/lib/seed/candidates/schema-gate.md +106 -0
  16. package/lib/seed/schema-agent.md +4 -3
  17. package/lib/seed/schema-profile.md +13 -1
  18. package/lib/shell/agent-dispatch-runner.js +842 -47
  19. package/lib/shell/dispatch-harnesses.js +739 -37
  20. package/lib/shell/dispatch-terminal.js +645 -0
  21. package/lib/shell/gate-runner.js +1645 -0
  22. package/lib/shell/integration-runner.js +1033 -0
  23. package/lib/shell/work-loop.js +1365 -0
  24. package/lib/shell/worker-contract.js +138 -0
  25. package/package.json +3 -2
  26. package/scripts/engines/util.js +15 -2
  27. package/skills/factory/SKILL.md +256 -0
  28. package/skills/factory/evals/evals.json +47 -0
  29. package/skills/factory/fixtures/README.md +48 -0
  30. package/skills/factory/fixtures/interview-acme-checkout.md +120 -0
  31. package/skills/factory/fixtures/nodes/factory-acme-checkout.md +78 -0
  32. package/skills/factory/fixtures/nodes/gate-adversarial-review.md +31 -0
  33. package/skills/factory/fixtures/nodes/profile-acme-rescue.md +28 -0
  34. package/skills/factory/fixtures/nodes/profile-acme-test-writer.md +15 -0
  35. package/skills/factory/fixtures/nodes/profile-codex-review.md +20 -0
  36. package/skills/factory/fixtures/nodes/task-acme-checkout-acceptance-suite.md +32 -0
  37. package/skills/factory/references/emitting.md +424 -0
  38. package/skills/factory/references/interview.md +183 -0
  39. package/skills/factory/references/maintenance.md +183 -0
  40. package/skills/onboard/SKILL.md +1 -1
  41. package/skills/spor/SKILL.md +1 -0
  42. package/skills/spor/references/concepts.md +1 -1
@@ -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.26.1",
5
+ "version": "0.28.0",
6
6
  "author": {
7
7
  "name": "losthammer"
8
8
  }
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "spor",
3
- "version": "0.26.1",
3
+ "version": "0.28.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
@@ -605,12 +619,13 @@ agent subject sees no graph content; a coarse read-only/CI flag is not a bypass.
605
619
  | `POST /v1/queue/claim` `{ids, session?}` | `spor dispatch` claiming a working set; bulk-lease clients | **`claimAll`**: claim a whole working set in ONE round-trip instead of one `POST /v1/nodes/{id}/claim` per node (task-spor-bulk-claim-renew-apis). `ids` is REQUIRED (unlike `renewAll` below there is nothing to enumerate — a claim creates the lease it would have enumerated), bounded and deduped server-side. Each id runs through the same per-node `claim` logic, so one holder's node losing the race to another claimant lands in `failed` (`already_claimed`, naming the holder) while the rest of the batch still claims — a partial batch is reported, never rolled back → `{ok: true, status: "claimed"\|"partial"\|"refused", count, claimed: [ids], leases: [...], failed: [{node_id, code, message, holder?}], expires_in_ms?}`. `expires_in_ms` is the batch's own renewal horizon — the soonest deadline among the leases that landed, omitted (not nulled) on an empty or wholly-refused batch. `status` reads `"partial"` when some items landed and some didn't, `"refused"` when the whole batch was refused — **`ok: true` only means the call was well-formed; read `failed` for what didn't land, never `ok` alone**. The dispatch nonce and `force` (accepted on the singular `/claim`) are deliberately NOT accepted here — a dispatch tags a single agent launch at a single node, so that stays on the singular door |
606
620
  | `POST /v1/queue/renew` `{ids?, session?}` | post-tool heartbeat (batched), `spor dispatch`, bulk-lease clients | **`renewAll`**: the heartbeat for a whole working set in ONE round-trip (task-spor-bulk-claim-renew-apis) — what the client-side claim heartbeat uses instead of one `POST /v1/nodes/{id}/renew` per held node (task-spor-client-heartbeat-bulk-renew), through the **`ids`-omitted arm, with no `session` either** (dec-spor-heartbeat-adopts-blanket-renew-arm): a beat renews the person's whole live working set and never re-acquires what dropped out of it, and it omits `session` because in this arm that field is a FILTER, so sending it would skip the leases claimed outside a session (`spor claim`, `spor dispatch`'s pre-launch claim) and let them lapse mid-session. Two consequences ride with that choice: a beat renews the person's leases in EVERY project (this arm takes no project scope), so a lease nobody releases stops self-healing back into the pool at its TTL while its owner keeps writing anywhere; and since this arm SELECTS on `session` rather than stamping it, a lease's session binding is no longer re-pointed at the last editing session. Two modes, and they differ on auto-reclaim (dec-spor-lease-auto-reclaim-and-deadline-exposure — the ONE exemption, whose intended caller is the heartbeat): **`ids` omitted** enumerates every LIVE Tier-1 lease this caller holds (optionally narrowed to one `session`) and renews them all — the motivating case, one call per heartbeat instead of one per held node — but NEVER reclaims: its contract is "renew what you hold" from a snapshot, so a lease that lapsed (or was taken) lands in `failed` as `lease_lost` and simply drops out of the set, never silently re-acquired; **`ids` supplied** renews exactly that named set (bounded/deduped) with the SAME reclaim semantics as the singular `/renew` — a lapsed lease is auto-reclaimed (a real claim, durable `assigned` edge included), its id lands in `reclaimed`, and only a lease held by someone else still lands in `failed` as `lease_lost` (naming the holder). `session` is forwarded unchanged exactly as the singular `/renew` does — one contested node never costs the whole working set its heartbeat → `{ok: true, status: "renewed"\|"partial"\|"refused", count, renewed: [ids], leases: [...], failed: [...], expires_in_ms?, reclaimed?, skipped_other_session?, skipped_reserved?}`. `expires_in_ms` is the batch's own renewal horizon (soonest deadline among the leases that landed, omitted on an empty/wholly-refused batch); `reclaimed` (explicit-`ids` arm only) lists the ids whose lease had lapsed and was just re-established, so a batch reading "N/N renewed" doesn't hide that one of them was silently taken back off the pool. The two `skipped_*` counts ride ONLY on the enumerate arm: `skipped_other_session` names live leases excluded because they're bound to a different session (a zero renewed count there means "not under this session", not "you hold nothing"), `skipped_reserved` names Tier-2 resumption reservations a blanket heartbeat deliberately leaves parked at their grace-window expiry rather than demoting to a Tier-1 horizon |
607
621
  | `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 |
622
+ | `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) |
608
623
  | `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`) |
609
624
  | `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 |
610
625
  | `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
626
  | `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
627
  | `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) |
628
+ | `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
629
  | `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
630
  | `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
631
  | `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,16 +732,20 @@ anything with a token.
717
732
  (Not to be confused with `spor dispatch --agent`, the unrelated `claude
718
733
  --agent` harness passthrough.) The
719
734
  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
- **supervised**-harness dispatch (Codex, OpenCode, GitHub Copilot CLI) follows
735
+ launch (dec-spor-dispatch-bg-session-late-bind): a harness self-allocates its
736
+ session, so dispatch reads the real one and binds it via `POST
737
+ /v1/agents/session` (§3) — the one place an agent token's session is set,
738
+ write-once. The session can't be forged a-priori (it isn't known until the run
739
+ exists) and can't ride the write payload (token-derived, §1), so the binding
740
+ is always the actual run. For the opt-in native launch (`spor dispatch --bg`,
741
+ `claude --bg` ignores `--session-id`) the launcher reads it from `claude agents
742
+ --json`. A **supervised**-harness dispatch (Claude Code by default, Codex,
743
+ OpenCode, GitHub Copilot CLI) follows
727
744
  the same late-bind contract from its own supervisor process instead: it reads
728
745
  the session id out of the run's supervised JSONL log rather than
729
- `claude agents --json` — Codex off its `thread.started` event, OpenCode off
746
+ `claude agents --json` — Claude Code off the `session_id` every stream-json
747
+ event carries (first on its `system`/`init` event), Codex off its
748
+ `thread.started` event, OpenCode off
730
749
  the `sessionID` every event carries, Copilot off the `sessionId` on its
731
750
  terminal `result` event (so a Copilot run binds only at exit, still before its
732
751
  record goes terminal) — then binds it the same way. Token transport also
@@ -737,7 +756,36 @@ anything with a token.
737
756
  without publishing the bearer to argv — get the agent-scoped token as
738
757
  `SPOR_TOKEN` in the run's environment, so the `spor` CLI inside the run is
739
758
  agent-attributed with no injected MCP. All of them land at the same self-serve
740
- mint/bind pair above.
759
+ mint/bind pair above. A **declared** harness (below) is `env-token` too, and
760
+ binds off whatever JSON path its declaration names.
761
+ - **Declared custom harnesses — the graph names it, the machine binds it**
762
+ (task-spor-dispatch-declarative-custom-harness). A `profile` may select a
763
+ harness this client ships no adapter for; the profile then carries **only**
764
+ `harness: <id>`, and the id is bound to something executable by a
765
+ MACHINE-LOCAL declaration in the client config cascade,
766
+ `dispatch.harness.<id>` — machine-specific like `dispatch.bin.<harness>` and
767
+ `dispatch.repos`, and read ONLY from the user `$SPOR_HOME/config.json` or the
768
+ global one: both `dispatch.harness` and `dispatch.bin` are stripped from a
769
+ committable repo `.spor.json` with a warning (`REPO_FORBIDDEN_PATHS`,
770
+ lib/config.js), the way a repo-level `token` is, so a repo write is no more
771
+ able to choose what this box executes than a graph write is. The declaration carries `command`, an `args`
772
+ template (`{cwd}` / `{report}` / `{model}` tokens), a `label`, report recovery
773
+ (`report: "lastText"` with a `report.text` JSON path into the harness's event
774
+ stream, or `report: "file"` when the harness writes the report itself at the
775
+ `{report}` path), and an optional `session` JSON path for the late-bind above.
776
+ Everything else is FIXED by v1 scope and naming it is an error: launch mode is
777
+ supervised-jsonl, the prompt goes on stdin, identity is `env-token`.
778
+ **A graph write must never define what a machine executes**, and that is
779
+ enforced on both sides: a profile node carrying any launch-defining field
780
+ (`command`, `args`, `argv`, `bin`, `exec`, `entrypoint`, `env`, `report`,
781
+ `session`, `launch_mode`, `identity_mode`) is refused outright by `spor
782
+ dispatch` rather than honoured or silently ignored, and a machine with no
783
+ local binding for the id fails satisfiability — refusing loudly, leaving the
784
+ assignment and its lease untouched, and (in team mode) naming the fleet hosts
785
+ that CAN run it. The capability probe adds valid declared ids to
786
+ `machine.harnesses`, so `spor capabilities` and the fleet publish above
787
+ reflect them; a declaration whose `command` does not resolve is not reported
788
+ as available.
741
789
  - **OAuth 2.1 for MCP connectors** (Cowork/claude.ai, which cannot carry a
742
790
  static bearer token): protected-resource metadata discovery (RFC 9728,
743
791
  advertised on the `/mcp` 401 via `WWW-Authenticate`), authorization-server
package/GRAPH.md CHANGED
@@ -439,6 +439,32 @@ refuses without `--force`. When a candidate stabilizes it is promoted into the
439
439
  seed pack at a release; the resident copy then shadows the seed (the
440
440
  stale-override warning above) and should be retired (`status: retired`).
441
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
+
442
468
  A complete worked example — a `escalation` type with a required `severity`
443
469
  field (enforced in `validate`) and an `open → mitigated → closed` status machine
444
470
  whose terminal `closed` demands a resolver (enforced in `transitions`):
@@ -866,7 +892,8 @@ server-side (issue-cc-onboarding-email-mismatch-silent-degradation).
866
892
  ### Agents (person-owned principals)
867
893
 
868
894
  An `agent` node (prefix `agent-`) is a person's automation principal — the
869
- 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)
870
897
  (dec-spor-agent-identity-nodes). It generalizes the workflow-run principal: a
871
898
  dispatched session is just another principal kind owned by a person, so work it
872
899
  creates reads "agent **on behalf of** person" rather than person-direct.
@@ -946,11 +973,24 @@ date: 2026-06-18
946
973
  ```
947
974
 
948
975
  - `harness:` (`claude-code` | `codex` | `opencode` | …) selects the launcher
949
- (dec-cc-portable-core-adapters: claude-code → `claude --bg`, others → their
950
- 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;
951
978
  `mcp` is merged into the strict `--mcp-config` dispatch writes, so the agent's
952
979
  toolset is exactly the profile plus the agent-spor server, nothing ambient
953
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).
954
994
  - **The runtime fields ARE the satisfiability spec** — there is no separate
955
995
  requirements block (dec-spor-machine-profile-satisfiability). A machine
956
996
  declares ATOMIC capabilities in a machine-local `dispatch.capabilities` map
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
package/README.md CHANGED
@@ -193,13 +193,16 @@ At the start of a session, your agent gets a briefing: a short, task-aware summa
193
193
  In Claude Code, the main commands are:
194
194
 
195
195
  ```text
196
+ /spor:spor # the operating manual — load before any graph operation
196
197
  /spor:brief # get a briefing for a task or area
197
198
  /spor:correct # fix stale or wrong context
198
199
  /spor:defer # capture something to return to later
199
200
  /spor:ask # record a question the graph cannot answer
200
201
  /spor:next # show the next useful thing to work on
202
+ /spor:triage # actively work the queue: dedupe, groom, close readiness gaps
201
203
  /spor:onboard # first-time setup
202
204
  /spor:backfill # extend the graph from existing sources
205
+ /spor:factory # compile a factory: what has to be true before work counts as done
203
206
  ```
204
207
 
205
208
  From the shell, `spor status` is the first thing to run when something is unclear:
@@ -280,12 +283,151 @@ equivalent evidence is its own log instead. One that never bound a session says
280
283
  so rather than borrowing a transcript from whatever else ran in that checkout.
281
284
  Terminal records age out after `dispatch.runRetentionMs` (default 14 days).
282
285
 
286
+ That state describes how the **process** ended. Alongside it every run also
287
+ carries its **outcome** — what the run did to the graph — as exactly one of
288
+ `resolved`, `reported`, or `failed`:
289
+
290
+ | outcome | meaning |
291
+ |---|---|
292
+ | `resolved` | the graph itself shows a live resolving edge (`resolves`/`answers`) onto the target node. Verified by re-reading the node after the run, never inferred from an exit code and never taken from the agent's own word |
293
+ | `reported` | no resolution, but the agent left a final report. It is filed as an artifact node linked to the target (`relates-to`) and **then** the lease is released, so the item returns to the queue carrying the work instead of vanishing into a dead run. `report_node_id` names the artifact — a filed report always reads `reported`, enforced or not |
294
+ | `failed` | no resolution and no report filed — a launch failure, a crash before any report, an empty one, or a graph that refused the write. `terminal_note` carries the failure note. The lease is released, except where the report could not be filed (see the ordering rule below) or the target was one this runner cannot judge |
295
+
296
+ The ordering is the contract: the report is filed before the lease goes back to
297
+ the pool, so an interrupted run can leave a held lease with the report filed but
298
+ never a released lease with nothing attached.
299
+
300
+ Enforcement covers **supervised** launches (Claude Code, Codex, OpenCode, Copilot
301
+ CLI — every built-in) against a team graph, targeting a node type whose
302
+ completion is a resolving edge (`task`, `issue`, `question`, `incident`). A
303
+ native-background run (`spor dispatch --bg`, the opt-in `claude --bg` launch), a
304
+ local-mode dispatch, a free-text dispatch, a target retired by status instead of
305
+ by an edge, and a run whose graph could not be reached are all classified
306
+ best-effort and marked `terminal_enforced: false` — an unenforced run can never
307
+ read `resolved`, and only an **enforced** `reported` promises a `report_node_id`
308
+ (an unenforced run that merely ended cleanly reads `reported` with no artifact).
309
+ A report is still filed wherever one exists and the graph is reachable, including
310
+ for a target this runner cannot judge — the verdict is scoped, the agent's work
311
+ reaching the graph is not.
312
+ `spor runs` prints the outcome (tagging `(unenforced)`), the note, and the report
313
+ artifact id; `spor runs --json` carries the same fields on each record.
314
+
315
+ ### Working the queue continuously
316
+
317
+ `spor dispatch` does one item. `spor work` does them all: it polls the queue,
318
+ takes the items this machine may actually run, dispatches each one under its
319
+ routed profile, waits for its **terminal state**, and goes round again.
320
+
321
+ ```bash
322
+ spor work # work the whole queue, one run at a time
323
+ spor work --project spor --concurrency 2 # two runs in flight, scoped to one project
324
+ spor work --once --print # show scope, pacing and candidates; launch nothing
325
+ ```
326
+
327
+ It is pull, not push: nothing schedules a worker, it takes work. That is safe
328
+ because the claim is a server-held lease with a per-launch nonce — two workers
329
+ racing for one node end with one claim and one refusal, and a worker that dies
330
+ drops its lease by lapsing. Capabilities stay machine-local facts and the fleet
331
+ scheduler stays advisory, so a worker that cannot reach the scheduler degrades
332
+ to "work the queue with what I have" rather than stopping.
333
+
334
+ It adds no guards of its own. Every launch goes through the same code path as
335
+ `spor dispatch --node <id>`, so already-resolved, `requires: human`, a profile
336
+ this box cannot satisfy (never substituted), a profile that tries to declare
337
+ what to execute, the same-machine duplicate guard, the auto-claim, worktree
338
+ isolation and the terminal-state contract all apply exactly as they do one-shot.
339
+ Selection is the same filtered page `--from-queue` picks its one item from,
340
+ minus anything whose derived readiness is `human` — a worker never claims work
341
+ meant for a person — and minus anything already in flight on this machine. An
342
+ item that is refused, or whose run ended without resolving it, is remembered
343
+ with the reason and retried after `--retry-after` instead of being re-attempted
344
+ on the next poll.
345
+
346
+ A slot frees when the **run record** goes terminal and its outcome is settled,
347
+ not when a launcher returns — by then the terminal-state contract has filed the
348
+ report and released or held the lease. There is no `--no-claim` here: the lease
349
+ is what keeps two workers off one node, so a loop always takes it. Stopping
350
+ (`SIGINT`/`SIGTERM`, or `--once`/`--max`) stops picking up new work; runs
351
+ already in flight are detached, keep going, and self-report through `spor runs`.
352
+
353
+ A native-background run (`claude --bg`, reached only through `spor dispatch
354
+ --bg` or a standing `dispatch.claudeLaunchMode: native-background`) is the weak
355
+ spot, for the same reason its outcome is unenforced: its termination is not
356
+ deterministically observable, so a slot is freed from the harness's own
357
+ live-agent listing. If that listing cannot be read, the slot stays held and the
358
+ worker says so; `--run-max` (default 24 hours) is the backstop that stops
359
+ following such a run. A supervised harness — Claude Code by default, Codex,
360
+ OpenCode, Copilot CLI, or a declared one — has none of this, which is why the
361
+ worker never passes `--bg`.
362
+
363
+ Run it as a service and read it back:
364
+
365
+ ```bash
366
+ spor work --status # every worker on this box: slots, outcomes, what it is skipping and why
367
+ spor work --regate <run-id> --factory <id> # re-judge one refused run after fixing what refused it (no redo)
368
+ spor work --status --json
369
+ ```
370
+
371
+ Records live under the machine-local journal; a worker whose process is gone
372
+ reads as stale, never as running. The `work.*` config keys (`concurrency`,
373
+ `intervalMs`, `maxIntervalMs`, `retryAfterMs`, `project`) let a unit file be a
374
+ bare `spor work`.
375
+
376
+ ### Gates — what has to be true before work counts as done
377
+
378
+ A worker with no factory declared runs bare: dispatch, await, repeat. Point it
379
+ at a **factory definition** — a `type: factory` node in the graph — and its
380
+ ordered gate list is enforced, in code, between the claim and the resolve.
381
+
382
+ ```bash
383
+ spor schema adopt schema-factory # the factory/gate schemas ship as candidates
384
+ spor work --factory factory-spor-default # (or set work.factory)
385
+ spor work --print --factory factory-spor-default # see the gates without launching anything
386
+ ```
387
+
388
+ Three kinds of gate, written inline in the factory or referenced as shareable
389
+ `type: gate` nodes an org vets once and reuses (the runner cannot tell them
390
+ apart):
391
+
392
+ - **command** — runs the declared acceptance suite from the **trusted ref**,
393
+ never the implementer branch's copy of the tests. A change that touched a
394
+ declared protected test path fails **closed**, unrun, and the test change is
395
+ filed for a separate lane under a different profile.
396
+ - **agent-review** — dispatches a profile-routed, cross-model review and parses
397
+ its structured findings verdict in code. An unreadable verdict is a failure,
398
+ never a pass. Failures loop implementer fix cycles up to a declared cap, then
399
+ escalate by filing an item a person has to answer.
400
+ - **human** — armed by declared risk classes (`touches:auth`, …); files an
401
+ approval item and blocks the resolve until someone answers it.
402
+
403
+ Every gate outcome is written to the graph as a fact linked to the work item,
404
+ and a factory that does not validate refuses to start the worker rather than
405
+ letting it run ungated. [WORKERS.md](WORKERS.md) §10 is the full contract.
406
+
407
+ You do not have to hand-write the definition. `/spor:factory` is the compiler:
408
+ it interviews you — product questions if you are the owner, pipeline questions
409
+ if you are the engineer — reads your CI config, suites and graph, proposes a
410
+ pipeline, and emits the factory, its gates and the profiles they route to as
411
+ nodes. It also maintains one from its own telemetry ("why did the last three
412
+ fail review"), and seeds a test-writer lane when there is no acceptance suite
413
+ to gate on yet. It authors data only; enforcement stays in `spor work`.
414
+
283
415
  ### Choosing a harness
284
416
 
285
- By default, `spor dispatch` launches a Claude Code agent (`claude --bg`). To
286
- dispatch under a different coding-agent CLI — Codex, OpenCode, and GitHub
287
- Copilot CLI are also supported — resolve a **profile**: a node that bundles a
288
- harness, model, and toolset.
417
+ By default, `spor dispatch` launches a Claude Code agent in headless print mode
418
+ (`claude -p --output-format stream-json`) under the shared supervisor: the
419
+ prompt goes in on stdin, the run's session id and final report are read off its
420
+ event stream, the run record goes terminal when the process does, and the
421
+ terminal-state contract judges the outcome like any other supervised harness.
422
+ Pass `--bg` (or set `dispatch.claudeLaunchMode: native-background` in your user
423
+ config) to launch the native background session instead (`claude --bg`) — the
424
+ attachable, interactive form (`claude attach`), at the cost of an unenforced
425
+ outcome and no report channel. Both are `spor dispatch`'s alone: `spor work`
426
+ launches every run supervised (its runs must be followed, judged and gated),
427
+ and a worker started under a standing `native-background` says so once on
428
+ stderr rather than silently ignoring it. To dispatch under a different coding-agent CLI —
429
+ Codex, OpenCode, and GitHub Copilot CLI are also supported — resolve a
430
+ **profile**: a node that bundles a harness, model, and toolset.
289
431
 
290
432
  ```bash
291
433
  spor dispatch issue-86 --profile profile-codex-sol
@@ -342,7 +484,8 @@ interactive shell, so a CLI installed under a prefix that only an interactive
342
484
  shell sees (a common Homebrew setup) resolves when you check it by hand and
343
485
  resolves to nothing when Spor launches it. Point Spor at the binary directly
344
486
  rather than relying on `PATH`, in `~/.spor/config.json` — machine-specific, like
345
- `dispatch.repos`, so it never belongs in a committable `.spor.json`:
487
+ `dispatch.repos`, so it never belongs in a committable `.spor.json` (and is
488
+ dropped with a warning if it turns up in one):
346
489
 
347
490
  ```json
348
491
  { "dispatch": { "bin": { "opencode": "/home/linuxbrew/.linuxbrew/bin/opencode" } } }
@@ -353,6 +496,67 @@ override the same thing per harness and win over the config file. An explicit
353
496
  launcher is used verbatim and is never quietly swapped for something on `PATH`;
354
497
  with none set, the bare name resolves on `PATH` as before.
355
498
 
499
+ **Declaring a harness Spor has no adapter for.** A profile can name a harness
500
+ this client ships no adapter for at all — a team's modified Claude Code build,
501
+ an internal wrapper. The graph carries only the *name*; the machine binds what
502
+ that name runs, in the same machine-local config:
503
+
504
+ ```json
505
+ {
506
+ "dispatch": {
507
+ "harness": {
508
+ "oxalpha": {
509
+ "command": "/opt/ox/bin/ox",
510
+ "args": ["run", "--jsonl", "--dir={cwd}", "--model={model}"],
511
+ "label": "Ox Alpha",
512
+ "report": { "from": "lastText", "text": "message.text" },
513
+ "session": "session.id"
514
+ }
515
+ }
516
+ }
517
+ }
518
+ ```
519
+
520
+ A profile then selects it with nothing but `harness: oxalpha`. That split is
521
+ the point: **a graph write must never define what a machine executes.** A
522
+ profile carrying a `command`, `args`, `env` or any other launch-defining field
523
+ is refused outright rather than honoured, and a machine that never declared the
524
+ id refuses the dispatch and leaves the item assigned — so an org can publish a
525
+ profile naming `oxalpha` and only the boxes whose owner bound that id will take
526
+ the work. `spor capabilities` lists a declared harness alongside the built-in
527
+ ones, and, in team mode, publishes it to the fleet so re-routing can find it.
528
+
529
+ The same rule holds for a write anyone else could land in your repo:
530
+ `dispatch.harness` (and `dispatch.bin`) are dropped with a warning if they
531
+ appear in a committable `.spor.json`, so cloning a repo — or pulling a PR
532
+ branch into one — can never choose a command this box will run. They are read
533
+ only from your own `~/.spor/config.json` (or the global one).
534
+
535
+ What the declaration may set, and nothing else:
536
+
537
+ * `command` — the launcher: an absolute path, or a bare name resolved on `PATH`.
538
+ * `args` — the argv template. `{cwd}` becomes the run's directory, `{report}`
539
+ the run's report path, `{model}` the resolved model. An entry carrying
540
+ `{model}` is dropped **whole** when no model resolves, so it has to be the
541
+ flag itself — `--model={model}`, or `--model=anthropic/{model}` — never a
542
+ bare value after a separate `--model`, which would leave that flag to swallow
543
+ the next argument. A declaration that gets this wrong is refused, not
544
+ launched.
545
+ * `label` — what `spor dispatch` and `spor runs` call it.
546
+ * `report` — how the run's final message is recovered: `"lastText"` (the
547
+ default) keeps the last string found at the `report.text` JSON path in the
548
+ harness's own event stream, and `"file"` means the harness writes the report
549
+ itself at the `{report}` path you passed it.
550
+ * `session` — the JSON path (or paths) carrying the harness's session id, so
551
+ the run can be bound to its agent session. Optional; without it the run
552
+ simply is not bound.
553
+
554
+ Everything else is fixed, and naming it is an error: a declared harness always
555
+ runs under the supervisor below, always takes its prompt on **stdin** (so a
556
+ compiled briefing never lands in a process listing), and always gets its
557
+ agent-scoped token as `SPOR_TOKEN` in the run's environment. A malformed
558
+ declaration is refused by name — Spor never falls back to guessing.
559
+
356
560
  Claude Code dispatch detaches into Claude Code's own background-agent daemon —
357
561
  the launcher exits immediately, and `spor dispatch` can only reconcile what
358
562
  happened to it afterwards from the harness's own session transcript. Every
@@ -647,6 +851,9 @@ This matters because some hosts cache plugins or hook definitions. Updating the
647
851
  * `GRAPH.md` — graph format, node types, edges, and schema behaviour
648
852
  * `API.md` — REST and MCP server contract
649
853
  * `QUEUE.md` — queue, capture, routing, and workflow details
854
+ * `WORKERS.md` — the worker protocol: claim/brief/work/report/resolve, lease
855
+ semantics, agent identity, and the terminal-state contract — for
856
+ implementing a Spor factory worker without this client
650
857
  * `adapters/` — host-specific adapter notes
651
858
  * `CONTRIBUTING.md` — contributing guide
652
859
  * `SECURITY.md` — security policy