@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.
- package/.claude-plugin/plugin.json +1 -1
- package/.codex-plugin/plugin.json +1 -1
- package/API.md +59 -11
- package/GRAPH.md +43 -3
- package/QUEUE.md +7 -2
- package/README.md +212 -5
- package/bin/spor.js +4713 -224
- package/lib/config.js +47 -0
- package/lib/graph.js +44 -0
- package/lib/kernel/gates.js +1468 -0
- package/lib/kernel/queue.js +8 -0
- package/lib/kernel/registry.js +19 -0
- package/lib/kernel/satisfiability.js +72 -2
- package/lib/seed/candidates/schema-factory.md +131 -0
- package/lib/seed/candidates/schema-gate.md +106 -0
- package/lib/seed/schema-agent.md +4 -3
- package/lib/seed/schema-profile.md +13 -1
- package/lib/shell/agent-dispatch-runner.js +842 -47
- package/lib/shell/dispatch-harnesses.js +739 -37
- package/lib/shell/dispatch-terminal.js +645 -0
- package/lib/shell/gate-runner.js +1645 -0
- package/lib/shell/integration-runner.js +1033 -0
- package/lib/shell/work-loop.js +1365 -0
- package/lib/shell/worker-contract.js +138 -0
- package/package.json +3 -2
- package/scripts/engines/util.js +15 -2
- package/skills/factory/SKILL.md +256 -0
- package/skills/factory/evals/evals.json +47 -0
- package/skills/factory/fixtures/README.md +48 -0
- package/skills/factory/fixtures/interview-acme-checkout.md +120 -0
- package/skills/factory/fixtures/nodes/factory-acme-checkout.md +78 -0
- package/skills/factory/fixtures/nodes/gate-adversarial-review.md +31 -0
- package/skills/factory/fixtures/nodes/profile-acme-rescue.md +28 -0
- package/skills/factory/fixtures/nodes/profile-acme-test-writer.md +15 -0
- package/skills/factory/fixtures/nodes/profile-codex-review.md +20 -0
- package/skills/factory/fixtures/nodes/task-acme-checkout-acceptance-suite.md +32 -0
- package/skills/factory/references/emitting.md +424 -0
- package/skills/factory/references/interview.md +183 -0
- package/skills/factory/references/maintenance.md +183 -0
- package/skills/onboard/SKILL.md +1 -1
- package/skills/spor/SKILL.md +1 -0
- 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.
|
|
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.
|
|
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):
|
|
721
|
-
|
|
722
|
-
|
|
723
|
-
|
|
724
|
-
|
|
725
|
-
|
|
726
|
-
|
|
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` —
|
|
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
|
|
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
|
|
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
|
|
621
|
-
|
|
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
|
|
286
|
-
|
|
287
|
-
|
|
288
|
-
|
|
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
|