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