@sporhq/spor 0.24.0 → 0.26.1
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 +92 -36
- package/GRAPH.md +135 -6
- package/QUEUE.md +32 -5
- package/README.md +117 -0
- package/bin/spor.js +939 -121
- package/lib/candidates.js +179 -0
- package/lib/compile.js +12 -0
- package/lib/config.js +50 -14
- package/lib/graph.js +45 -4
- package/lib/history.js +10 -5
- package/lib/kernel/graph.js +222 -25
- package/lib/kernel/queue.js +3 -0
- package/lib/kernel/registry.js +99 -2
- package/lib/kernel/satisfiability.js +36 -4
- package/lib/schema.js +23 -1
- package/lib/seed/candidates/schema-edge-member-of-program.md +41 -0
- package/lib/seed/schema-artifact.md +21 -1
- package/lib/seed/schema-capture-pending.md +19 -2
- package/lib/seed/schema-correction.md +14 -1
- package/lib/seed/schema-decision.md +21 -1
- package/lib/seed/schema-issue.md +26 -2
- package/lib/seed/schema-question.md +21 -2
- package/lib/seed/schema-task.md +26 -2
- package/lib/shell/agent-dispatch-runner.js +711 -3
- package/lib/shell/dispatch-harnesses.js +400 -7
- package/lib/shell/files.js +28 -2
- package/lib/tar.js +51 -11
- package/lib/validate.js +8 -2
- package/package.json +3 -1
- package/prompts/client/distill-local.md +7 -1
- package/scripts/engines/distill.js +200 -7
- package/scripts/engines/doctor.js +34 -0
- package/scripts/engines/post-tool.js +64 -16
- package/scripts/engines/util.js +106 -9
- package/skills/brief/SKILL.md +1 -1
- package/skills/next/SKILL.md +5 -1
- package/skills/spor/SKILL.md +53 -20
- package/skills/spor/references/authoring-schemas.md +25 -1
- package/skills/spor/references/concepts.md +9 -5
- package/skills/triage/SKILL.md +42 -12
|
@@ -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.26.1",
|
|
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.26.1",
|
|
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
|
@@ -36,7 +36,11 @@ graph's git repo. What a client sees:
|
|
|
36
36
|
- **Validation**: id/filename agreement, kebab-case, type prefix, mandatory
|
|
37
37
|
standalone summary, known node type, `date:` format, edge syntax. Failures
|
|
38
38
|
return the validator's error list verbatim so a calling model can
|
|
39
|
-
self-correct. Size limits: body ≤ 8KB, summary ≤ 500 chars, ≤ 40 edges.
|
|
39
|
+
self-correct. Size limits: body ≤ 8KB, summary ≤ 500 chars, ≤ 40 edges. (A
|
|
40
|
+
node designed to accrete indefinitely — a running-log artifact — hits this
|
|
41
|
+
wall by construction; GRAPH.md "Accreting running-log artifacts" is the
|
|
42
|
+
rollover convention for that case, and `spor validate` warns as a node's
|
|
43
|
+
body nears the cap.)
|
|
40
44
|
- **Edge normalization**: edge types accept canonical names, registry-declared
|
|
41
45
|
**aliases** (renamed in place), and **inverse labels** (the edge read from
|
|
42
46
|
the target's side — `{blocked-by, to: X}` on N is flipped and written to X
|
|
@@ -110,6 +114,22 @@ schema may carry a `get(node, ctx)` hook (GRAPH.md) that attaches derived
|
|
|
110
114
|
context as extra top-level keys — e.g. `resolution` (what answered/resolved
|
|
111
115
|
this node). These are additive; a client ignores keys it does not know.
|
|
112
116
|
|
|
117
|
+
### `get_nodes`
|
|
118
|
+
|
|
119
|
+
Batch-hydrate nodes through the same read path as `get_node`. Start with
|
|
120
|
+
`{ "ids": ["task-a", "issue-b", ...] }` (at most 100 ids); repeated ids are
|
|
121
|
+
deduplicated by first occurrence and results preserve that order. Each entry in
|
|
122
|
+
`nodes` carries the same `raw`, parsed `frontmatter`, `revision`, visibility
|
|
123
|
+
filtering, and additive schema/host enrichment as a single read. Hidden and
|
|
124
|
+
absent ids are deliberately indistinguishable and appear only in `missing_ids`.
|
|
125
|
+
|
|
126
|
+
The response is `{nodes, returned_ids, missing_ids, truncated, next_cursor}` and
|
|
127
|
+
is bounded to 48 KiB without splitting an entry. When `truncated` is true, call
|
|
128
|
+
again with `{ "cursor": "<next_cursor>" }` alone; the opaque, validated cursor
|
|
129
|
+
resumes the original deduplicated order without re-sending ids. The last page
|
|
130
|
+
has `next_cursor: null`. The REST twin is `POST /v1/nodes/batch` with the same
|
|
131
|
+
request and response shapes.
|
|
132
|
+
|
|
113
133
|
### `explore_graph`
|
|
114
134
|
|
|
115
135
|
Browse/map the team graph's **structure** — a bounded neighborhood as plain
|
|
@@ -118,7 +138,8 @@ nodes + typed edges, each node carrying truth flags
|
|
|
118
138
|
neighbors (`more`). Input `{ "root_id"?, "query"?, "depth"?, "limit"? }` → the
|
|
119
139
|
view-tree slice (`view`, `node_ids`) plus a text rendering. Call with **no
|
|
120
140
|
arguments** for the birds-eye programs overview — every umbrella root (any
|
|
121
|
-
node other work `blocks`
|
|
141
|
+
node other work `blocks` or declares a `member-of-program` edge to) with
|
|
142
|
+
resolution-derived completion %, most complete
|
|
122
143
|
first. Pass `root_id` to walk outward from one node (depth 1-2, deterministic,
|
|
123
144
|
no LLM; default depth 1, limit 40 capped at 80); pass `query` instead to seed
|
|
124
145
|
the roots by relevance. The two are mutually exclusive; `root_id` wins when
|
|
@@ -249,20 +270,24 @@ when a session ends cleanly with the task advanced but unfinished: the
|
|
|
249
270
|
heartbeat is dropped, `expires` is re-pointed at a grace-window expiry
|
|
250
271
|
(~2 days, tenant policy — a timestamp, not a graph edge), and the durable
|
|
251
272
|
`assigned` edge is kept (so a steward/capacity view still reads "reserved by
|
|
252
|
-
you"). Input `{ "id": "<task node id
|
|
253
|
-
`{ "ok": true, "status": "reserved", "lease", "grace_window_ms" }`.
|
|
273
|
+
you"). Input `{ "id": "<task node id>", "session"? }` →
|
|
274
|
+
`{ "ok": true, "status": "reserved", "lease", "expires_in_ms", "grace_window_ms", "reclaimed"? }`.
|
|
254
275
|
`rankQueue` floats a reservation to the top of the owner's queue while
|
|
255
276
|
dropping it from teammates' actionable lists. Within the grace window the
|
|
256
277
|
reservation still counts as a live lease, so the owner claiming, renewing, or
|
|
257
278
|
extending it drops the `reserved` flag and re-establishes a normal Tier-1
|
|
258
279
|
heartbeat lease; once the grace window lapses the entry is gone (full pool,
|
|
259
|
-
everyone)
|
|
260
|
-
|
|
261
|
-
|
|
262
|
-
|
|
263
|
-
|
|
264
|
-
|
|
265
|
-
|
|
280
|
+
everyone). `reserve` no longer requires a live claim to get it back — like
|
|
281
|
+
`renew`/`extend` (dec-spor-lease-auto-reclaim-and-deadline-exposure), it
|
|
282
|
+
AUTO-RECLAIMS an unheld node (never claimed, or your own claim/reservation
|
|
283
|
+
merely lapsed under it) under the SAME call — a real claim, durable
|
|
284
|
+
`assigned` edge included — and the result reports `reclaimed: true`, instead
|
|
285
|
+
of making you `claim` first and reserve second. The one refusal that
|
|
286
|
+
survives is contention: a LIVE lease held by SOMEONE ELSE is never taken, so
|
|
287
|
+
that still fails `409 lease_lost`, naming the current holder. The client
|
|
288
|
+
SessionEnd hook (task-cc-client-sessionend-reserve-hook) is the intended
|
|
289
|
+
caller: it holds the transcript, so it is the one thing that can tell
|
|
290
|
+
"advanced but unfinished" (→ reserve) from "finished" (→ release) apart.
|
|
266
291
|
|
|
267
292
|
### `propose_correction`
|
|
268
293
|
|
|
@@ -418,26 +443,38 @@ verbatim.
|
|
|
418
443
|
|
|
419
444
|
### `render_program`
|
|
420
445
|
|
|
421
|
-
The program/progress view over
|
|
422
|
-
stand" for a large workstream, auto-derived on demand with no
|
|
423
|
-
Input `{ "id": "<root-node-id>", "max_depth"?, "max_nodes"? }`
|
|
424
|
-
true, "root_id", "progress": {"total", "done", "active",
|
|
425
|
-
"pct", "statuses"}, "count", "truncated"?, "view",
|
|
426
|
-
node (an umbrella task, a milestone — anything
|
|
427
|
-
server walks its
|
|
428
|
-
inbound `
|
|
429
|
-
|
|
430
|
-
|
|
431
|
-
|
|
432
|
-
|
|
446
|
+
The program/progress view over program-membership topology — the birds-eye
|
|
447
|
+
"where do we stand" for a large workstream, auto-derived on demand with no
|
|
448
|
+
lens authoring. Input `{ "id": "<root-node-id>", "max_depth"?, "max_nodes"? }`
|
|
449
|
+
→ `{ "found": true, "root_id", "progress": {"total", "done", "active",
|
|
450
|
+
"blocked", "open", "pct", "statuses"}, "count", "truncated"?, "view",
|
|
451
|
+
"node_ids" }`. Given a root node (an umbrella task, a milestone — anything
|
|
452
|
+
other work joins), the server walks its membership tree: **per node**, it
|
|
453
|
+
prefers inbound `member-of-program` edges where any are declared (the
|
|
454
|
+
all-or-nothing preference, dec-spor-program-membership-per-node-preference)
|
|
455
|
+
and falls back to inbound `blocks` edges where none are declared (the
|
|
456
|
+
original blocks-topology inference, unchanged) — so an unmigrated or
|
|
457
|
+
partially migrated program keeps rendering, and a half-migrated umbrella
|
|
458
|
+
never silently shrinks. `member-of-program`
|
|
459
|
+
(dec-spor-program-membership-dedicated-edge-type) is additive with `blocks`
|
|
460
|
+
— `blocks` keeps meaning only gating, `member-of-program` records pure
|
|
461
|
+
topology — and ships as a graph-resident schema node
|
|
462
|
+
(`schema-edge-member-of-program`), pending activation like any resident
|
|
463
|
+
schema; check `spor schema member-of-program` for its live status rather than
|
|
464
|
+
assuming. Each node's bucket then derives from the same truth the queue uses:
|
|
465
|
+
terminal statuses, supersession, and live `resolves`/`answers` edges count as
|
|
466
|
+
**done** (even while the status field lags — the effective status then reads
|
|
467
|
+
`resolved` with a `resolved_by` ride-along); a node gated by its own live
|
|
468
|
+
unresolved `blocks` predecessors is **blocked**; live unblocked work splits
|
|
433
469
|
**active** vs **open**. `view` is the standard view tree (`as: "tree"` with an
|
|
434
470
|
additive `progress` block); the text content is a progress-bar header plus the
|
|
435
|
-
glyphed gating tree. Shared
|
|
471
|
+
glyphed gating tree. Shared members render once and repeat as `repeat: true`
|
|
436
472
|
leaves (counted once); `max_depth`/`max_nodes` caps count skipped branches
|
|
437
|
-
into `truncated`, never silently. A root
|
|
438
|
-
result whose prose says how to model the program (add `
|
|
439
|
-
|
|
440
|
-
"unknown_root" }`. The REST twin is `GET /v1/program/{id}`
|
|
473
|
+
into `truncated`, never silently. A root with no members is a successful
|
|
474
|
+
empty result whose prose says how to model the program (add `member-of-program`
|
|
475
|
+
or `blocks` edges from the member tasks). Unknown `id` errors with `{ "found":
|
|
476
|
+
false, "error": "unknown_root" }`. The REST twin is `GET /v1/program/{id}`
|
|
477
|
+
(§3).
|
|
441
478
|
|
|
442
479
|
### `apply_lens_action`
|
|
443
480
|
|
|
@@ -541,7 +578,7 @@ agent subject sees no graph content; a coarse read-only/CI flag is not a bypass.
|
|
|
541
578
|
| Endpoint | Typical caller | Semantics |
|
|
542
579
|
|---|---|---|
|
|
543
580
|
| `GET /v1/status` | session-start, monitoring | `{node_count, projects: {...}, head, uptime, metrics}`; doubles as the health check. Graph counts/projects are the viewer-visible projection. `?titles=1` adds viewer-visible `titles: [{id, type, project, title}]` — the one-round-trip graph index the distiller dedups against |
|
|
544
|
-
| `GET /v1/schema` | `spor schema`, agents introspecting the contract | the live schema registry as data (task-spor-schema-introspection-surface; server half task-spor-server-schema-endpoint): `{default_edge_weight, node_types: [{type, description, prefix, always_on, traversable, capturable, queueable, non_resolving, hooks, schema_id, schema_version, source}], edge_types: [{type, description, weight, weight_default, inverse_label, aliases, capturable, hooks, ...}], queue_policy, policies, registers, stale_overrides, alias_collisions}` — the seed pack MERGED with graph-resident `type: schema` overrides, each entry tagged by `source` (`seed`/`graph`/`native`) and the active schema node's id+version. `?code=1` embeds each hook's source under `code: {name: src}` (omitted by default to keep the response lean). The registry IS the contract (norm-cc-registry-is-contract); this read surface closes the failure mode of agents reverse-engineering it from `lib/seed/` files (which miss resident overrides). The REST/MCP twin of the `spor schema` CLI: all three render one `graph.registry.snapshot()` so they never drift |
|
|
581
|
+
| `GET /v1/schema` | `spor schema`, agents introspecting the contract | the live schema registry as data (task-spor-schema-introspection-surface; server half task-spor-server-schema-endpoint): `{default_edge_weight, node_types: [{type, description, prefix, always_on, traversable, capturable, queueable, non_resolving, terminal, inert, inert_inherited, vocabulary, completion, resolver_required, hooks, schema_id, schema_version, source}], edge_types: [{type, description, weight, weight_default, inverse_label, aliases, capturable, hooks, ...}], queue_policy, policies, registers, stale_overrides, alias_collisions}` — the seed pack MERGED with graph-resident `type: schema` overrides, each entry tagged by `source` (`seed`/`graph`/`native`) and the active schema node's id+version. `?code=1` embeds each hook's source under `code: {name: src}` (omitted by default to keep the response lean). The registry IS the contract (norm-cc-registry-is-contract); this read surface closes the failure mode of agents reverse-engineering it from `lib/seed/` files (which miss resident overrides). `vocabulary`/`completion`/`resolver_required` are the declarative completion policy (task-spor-registry-declarative-terminal-status-policy): the closed status enum a type's `validate()` gates on, its single SUCCESS terminal value (`null` when the type's terminal values are several distinct outcomes rather than one success, i.e. no mechanical close exists), and whether that value also needs a live resolving decision/artifact. They DECLARE what the hooks enforce — a reader (the gardener's finding remedies) names the right terminal status from here instead of hand-mirroring hook source. The REST/MCP twin of the `spor schema` CLI: all three render one `graph.registry.snapshot()` so they never drift |
|
|
545
582
|
| `GET /v1/me` | `spor whoami`/`status`, onboarding | identity echo for the bearer token → `{person, name, email, bound, is_admin, org}`. `bound:false` means the token authenticates but maps to **no person node** (legacy/OAuth, or minted before the node existed), so routed questions and the personal queue will be empty — the client warns on it (the silent identity-degradation signal). `is_admin` reflects the `stewards→root` edge that gates the token-admin surface. `org` is the slug this tenant routes to (`SPOR_ORG`/legacy `SUBSTRATE_ORG`, else `"local"`); it lets a client key its `(issuer, org)` credential store for an **opaque** `spor_oat_`/`spor_pat_` token that carries no readable `org` claim — the client falls back to it after `--org` and the JWT `org` claim (task-spor-frontdoor-me-org-echo). A connector JWT's `org` claim is enforced equal to this echo |
|
|
546
583
|
| `GET /v1/me/org-choices` | `spor auth list` (live membership refresh) | re-queries the IdP's *current* org membership for the held credential's subject and returns `{org_choices: [{slug, label, default?}], source: "idp"\|"bound"}` — `source:"idp"` is a true live enumeration (orgs added/removed since the last login surface without re-authenticating); `source:"bound"` means a single org-scoped token the server couldn't expand (no enumeration). The client treats only `source:"idp"` as live and **fails open** to its cached tenant listing on anything else — `source:"bound"`, a `502 {error.code:"membership_requery_failed"}` (IdP unreachable), a `404` (older server without the endpoint), or any transport/parse error (task-spor-cli-auth-list-live-membership-requery; server half task-spor-frontdoor-held-credential-membership-requery) |
|
|
547
584
|
| `GET /v1/me/tokens` | `spor token list` | list the caller's OWN personal access tokens → `{tokens: [{hash_prefix, person, label, name, email, created, expires, expired, last_used}], count}` — caller-scoped (only their person-bound PATs; agent session tokens excluded), never plaintext, never full hashes. `403 forbidden` if the bearer maps to **no person node** (you need a bound identity to own a PAT). The self-serve, no-admin twin of `GET /v1/admin/tokens` below (task-spor-app-me-tokens-self-serve) |
|
|
@@ -550,6 +587,7 @@ agent subject sees no graph content; a coarse read-only/CI flag is not a bypass.
|
|
|
550
587
|
| `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 |
|
|
551
588
|
| `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 |
|
|
552
589
|
| `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`, 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 |
|
|
590
|
+
| `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` |
|
|
553
591
|
| `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) |
|
|
554
592
|
| `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` |
|
|
555
593
|
| `POST /v1/nodes` | `spor put-node`, drain-outbox, mechanical writers | `put_node` semantics, batch: `{nodes: [...], if_exists: "skip"}` (entries may be raw strings or `{node, if_exists, revision}`) → `{results: [...]}`, 207 when any entry failed. Entries are applied **sequentially** and each is fully validated before the next — including the completion-resolver gate that runs on create (GRAPH.md "the resolver gate") — so a born-terminal node (`done` task / `resolved` issue) must have its resolving `decision`/`artifact` EARLIER in the same batch (**resolver-first ordering**; the batch does not defer the gate to end-of-batch, dec-spor-batch-create-gate-resolver-first-ordering). The 207 is partial-success: entries already applied before a later entry's failure are not rolled back |
|
|
@@ -558,15 +596,18 @@ agent subject sees no graph content; a coarse read-only/CI flag is not a bypass.
|
|
|
558
596
|
| `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) |
|
|
559
597
|
| `POST /v1/nodes/{id}/priority` `{priority}` | `spor priority`, queue triage | `set_priority` semantics (§1): one-scalar human-override update — `p1`/`p2`/`p3` or a clearing form (`none`/`clear`/`""`/`p0`). Server-side read-modify-write (no revision), stamping `priority_by`/`priority_at`/`priority_via` for the audit trail (issue-cc-priority-attribution-gap). Unknown value → `invalid_node` with the allowed list |
|
|
560
598
|
| `POST /v1/nodes/{id}/readiness` `{readiness}` | `spor ready`, triage make-ready pass | `set_readiness` semantics (§1): one-scalar agent-readiness override — `agent` or a clearing form (`none`/`clear`/`""`) to demote back to derived. No hand-settable `human` value (always structurally derived, always wins). Server-side read-modify-write (no revision), stamping `readiness_by`/`readiness_at`/`readiness_via`. Unknown value → `invalid_node` with the allowed value |
|
|
561
|
-
| `POST /v1/nodes/{id}/claim` `{session?}` | `claim`/`set_status` MCP tools, `spor claim` CLI, `spor dispatch` | take the heartbeat-renewed lease (dec-cc-task-claim-lease): writes the durable `assigned` edge once, attributes to `$viewer` from the token (never an argument), and creates the ephemeral lease → `{ok, status, lease: {node_id, by, expires, expires_at, session, claimed_at}, edge}`. A live lease held by ANOTHER person is `409 conflict` naming the holder + expiry (re-claiming your OWN live claim just renews it). `session` scopes the heartbeat (omit to leave it person-scoped, so any of the claimer's sessions may renew — what `spor claim` and `spor dispatch` do, since `claude --bg` self-allocates the run session only at launch; dispatch then renews with the real session once it has read it from `claude agents --json`, dec-spor-dispatch-bg-session-late-bind) |
|
|
562
|
-
| `POST /v1/nodes/{id}/renew` `{session?}` | post-tool heartbeat, `renew` MCP tool, `spor renew` CLI, `spor dispatch` | bump the live lease's expiry
|
|
563
|
-
| `POST /v1/nodes/{id}/extend` `{ms, session?}` | `extend` MCP tool, `spor extend` CLI | manually stretch your live lease by `ms` milliseconds for a known long idle gap → `{ok, status, lease, capped_to_max?, claim_ttl_max_ms?}`. Bounded by the tenant's `claim_ttl_max` policy (a request past the ceiling caps to it, flagged `capped_to_max`); never shortens a lease. `ms` must be a positive number (`spor extend <id> <2h|45m|…>` parses the human duration client-side). A
|
|
599
|
+
| `POST /v1/nodes/{id}/claim` `{session?}` | `claim`/`set_status` MCP tools, `spor claim` CLI, `spor dispatch` | take the heartbeat-renewed lease (dec-cc-task-claim-lease): writes the durable `assigned` edge once, attributes to `$viewer` from the token (never an argument), and creates the ephemeral lease → `{ok, status, lease: {node_id, by, expires, expires_at, session, claimed_at}, expires_in_ms, edge}`. `expires_in_ms` is the renewal horizon relative to when the call ran — skew-free against the caller's own clock (dec-spor-lease-auto-reclaim-and-deadline-exposure). A live lease held by ANOTHER person is `409 conflict` naming the holder + expiry (re-claiming your OWN live claim just renews it). `session` scopes the heartbeat (omit to leave it person-scoped, so any of the claimer's sessions may renew — what `spor claim` and `spor dispatch` do, since `claude --bg` self-allocates the run session only at launch; dispatch then renews with the real session once it has read it from `claude agents --json`, dec-spor-dispatch-bg-session-late-bind) |
|
|
600
|
+
| `POST /v1/nodes/{id}/renew` `{session?}` | post-tool heartbeat, `renew` MCP tool, `spor renew` CLI, `spor dispatch` | bump the live lease's expiry — no commit, UNLESS the lease had lapsed: renew then AUTO-RECLAIMS it first, under the same per-node lock (a real claim, durable `assigned` edge included), rather than refusing (dec-spor-lease-auto-reclaim-and-deadline-exposure) → `{ok, status, lease, expires_in_ms, reclaimed?}`, `reclaimed: true` marking a re-established lease apart from an ordinary bump. A LIVE lease held by SOMEONE ELSE is still `409 lease_lost`, naming the current holder. Person-scoped: any of the claimer's sessions may renew; a `session` binds the lease to that run (`spor dispatch` uses this to bind the captured `claude --bg` session post-launch) |
|
|
601
|
+
| `POST /v1/nodes/{id}/extend` `{ms, session?}` | `extend` MCP tool, `spor extend` CLI | manually stretch your live lease by `ms` milliseconds for a known long idle gap, auto-reclaiming first if the lease had lapsed (same discipline as `renew`, reported `reclaimed: true`, dec-spor-lease-auto-reclaim-and-deadline-exposure) → `{ok, status, lease, expires_in_ms, reclaimed?, capped_to_max?, claim_ttl_max_ms?}`. Bounded by the tenant's `claim_ttl_max` policy (a request past the ceiling caps to it, flagged `capped_to_max`); never shortens a lease. `ms` must be a positive number (`spor extend <id> <2h|45m|…>` parses the human duration client-side). A LIVE lease held by SOMEONE ELSE is `409 lease_lost` naming the holder |
|
|
564
602
|
| `POST /v1/nodes/{id}/release` | `release` MCP tool, `spor release` CLI | drop the lease AND retire the durable `assigned` edge, returning the node to the pool. Idempotent (releasing a node you hold no lease on still succeeds, cleaning up any lingering `assigned` edge of yours); releasing a claim someone else holds is `409` naming the holder |
|
|
565
|
-
| `POST /v1/nodes/{id}/reserve` `{session?}` | `reserve` MCP tool, client SessionEnd hook (task-cc-client-sessionend-reserve-hook) | convert your live claim into an owner-exclusive resumption reservation (dec-cc-task-resumption-reservation) when a session ends cleanly with the task advanced but unfinished → `{ok, status: "reserved", lease, grace_window_ms}`. Drops the heartbeat, re-points `expires` at a grace-window expiry (~2 days, tenant policy — a timestamp, not a graph edge), and keeps the durable `assigned` edge so a steward view still reads "reserved by you"; `rankQueue` floats it to the top of the owner's queue while dropping it from teammates' actionable lists until the grace window lapses (full pool, everyone) or the owner claims/renews/extends it within that window (drops the `reserved` flag, back to a normal heartbeat lease). `409 lease_lost` (naming the holder)
|
|
603
|
+
| `POST /v1/nodes/{id}/reserve` `{session?}` | `reserve` MCP tool, client SessionEnd hook (task-cc-client-sessionend-reserve-hook) | convert your live claim into an owner-exclusive resumption reservation (dec-cc-task-resumption-reservation) when a session ends cleanly with the task advanced but unfinished, auto-reclaiming first if the node is unheld — never claimed, or your own claim/reservation merely lapsed under it (reported `reclaimed: true`, dec-spor-lease-auto-reclaim-and-deadline-exposure) → `{ok, status: "reserved", lease, expires_in_ms, grace_window_ms, reclaimed?}`. Drops the heartbeat, re-points `expires` at a grace-window expiry (~2 days, tenant policy — a timestamp, not a graph edge), and keeps the durable `assigned` edge so a steward view still reads "reserved by you"; `rankQueue` floats it to the top of the owner's queue while dropping it from teammates' actionable lists until the grace window lapses (full pool, everyone) or the owner claims/renews/extends it within that window (drops the `reserved` flag, back to a normal heartbeat lease). `409 lease_lost` (naming the holder) only when SOMEONE ELSE holds a live claim — the one boundary that still matters |
|
|
604
|
+
| `POST /v1/queue/release` `{session?}` | session-end handoff drain; bulk-lease clients | **`releaseAll`**: release every live claim the caller holds, optionally narrowed to one `session`, in ONE round-trip instead of N sequential `POST /v1/nodes/{id}/release` calls (task-spor-queue-bulk-release-for-handoff-drain). Delegates each drop to the same per-node `release` logic — durable `assigned` edge retirement included — so it only ever enumerates and releases the CALLER's own leases; a node re-claimed by the caller under a different session, or held by someone else, between the enumeration and its turn simply isn't counted as released, never a `409` → `{ok: true, status: "released", released: [ids], count}`. Unlike `claimAll`/`renewAll` below there is no holder mismatch to report, so it carries no `failed` array |
|
|
605
|
+
| `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
|
+
| `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 |
|
|
566
607
|
| `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 |
|
|
567
608
|
| `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`) |
|
|
568
609
|
| `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 |
|
|
569
|
-
| `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). `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) |
|
|
610
|
+
| `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) |
|
|
570
611
|
| `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 |
|
|
571
612
|
| `POST /v1/corrections` | /spor:correct | `propose_correction` semantics → 201 `{status, id, revision, warnings}` |
|
|
572
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) |
|
|
@@ -574,7 +615,7 @@ agent subject sees no graph content; a coarse read-only/CI flag is not a bypass.
|
|
|
574
615
|
| `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`) |
|
|
575
616
|
| `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 |
|
|
576
617
|
| `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 |
|
|
577
|
-
| `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
|
|
618
|
+
| `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 |
|
|
578
619
|
| `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 |
|
|
579
620
|
| `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 |
|
|
580
621
|
| `POST /v1/merge` `{nodes: [...], mode?: "plan"\|"apply", id_map?: {...}, trust_attached_code?: bool, force?: bool}` | admin promoting one graph into another — pilot-to-org, or a local dogfood graph into a hosted tenant | bring another graph's exported node files (`nodes`: an array of raw node markdown strings) into this one without the failure mode of the naive `GET /v1/export \| POST /v1/nodes --if-exists skip` — silently DROPPING every colliding node while imported edges that pointed at it re-bind to this graph's unrelated same-named node (ordinal id schemes like `cap-<date>-<n>` collide across any two independently started graphs). **Admin-gated** (stewards→root, else `403 forbidden`): `mode:"apply"` writes through the same trusted bulk-import door the server uses internally, which preserves each incoming node's original attribution (a merge moves history, it does not re-author it) and skips the `transitions()`/policy gates (content validation still runs per node; create-only, one deferred commit). Every incoming node classifies as **imported** (id unknown here), **deduped** (id collides, content identical — attribution-blind, this graph's copy wins), **remapped** (id collides, content differs, and the id's final dash-segment is all-digits/*ordinal* — rewritten to `<id>-<sha256(content)[:7]>`, with every reference to the old id across the incoming batch rewritten to match), or **conflict** (id collides with different content and a *semantic* id; a `person` node's email already bound to a different id; or a `schema`/`workflow`/`workflow-run` node or a `stewards`-to-this-graph's-root edge — none of these ever merge silently). Conflicts are reported for manual triage and never written; the schema/workflow class is the one skippable via `trust_attached_code: true` (only for a whole graph you own). `mode:"plan"` (the default) runs the same classification and validation and returns the report without writing anything; `mode:"apply"` **refuses with `409 conflict`** (nothing written) whenever the plan still carries conflicts or validation errors, unless `force: true` — which imports the clean subset and knowingly leaves any reference to a skipped id unresolved. `id_map` (`{"old-id": "new-id"}`) seeds cross-id rewrites; feed a plan's own `id_map` back into the next request when a graph is too large for one batch (plan every batch first to build the complete map, then apply each). Response: `{mode, counts: {incoming, imported, deduped, remapped, conflicts, errors}, imported, deduped, remapped, conflicts, errors, id_map, results?, generated_at}` — `imported`/`deduped`/`remapped`/`conflicts` are arrays of `{id, new_id?, title?, reason?}`; `errors` is `{id, index?, errors: [...]}` (unparseable/invalid entries); `results` (apply mode only) carries the import door's per-entry write verdicts. Deterministic and idempotent — re-running an identical merge dedups everything the first run imported, safe after a partial failure. Called directly today (bearer admin token + `curl`); a CLI wrapper defaulting to plan mode is tracked separately (task-spor-cli-merge-verb) |
|
|
@@ -681,7 +722,22 @@ anything with a token.
|
|
|
681
722
|
from `claude agents --json` and binds it via `POST /v1/agents/session` (§3) — the
|
|
682
723
|
one place an agent token's session is set, write-once. The session can't be
|
|
683
724
|
forged a-priori (it isn't known until the run exists) and can't ride the write
|
|
684
|
-
payload (token-derived, §1), so the binding is always the actual run.
|
|
725
|
+
payload (token-derived, §1), so the binding is always the actual run. A
|
|
726
|
+
**supervised**-harness dispatch (Codex, OpenCode, GitHub Copilot CLI) follows
|
|
727
|
+
the same late-bind contract from its own supervisor process instead: it reads
|
|
728
|
+
the session id out of the run's supervised JSONL log rather than
|
|
729
|
+
`claude agents --json` — Codex off its `thread.started` event, OpenCode off
|
|
730
|
+
the `sessionID` every event carries, Copilot off the `sessionId` on its
|
|
731
|
+
terminal `result` event (so a Copilot run binds only at exit, still before its
|
|
732
|
+
record goes terminal) — then binds it the same way. Token transport also
|
|
733
|
+
differs per harness: Claude Code gets a strict `--mcp-config` file, Codex gets
|
|
734
|
+
the token via an env var its own config references (`--config
|
|
735
|
+
mcp_servers.spor.bearer_token_env_var=SPOR_DISPATCH_MCP_TOKEN`), and OpenCode
|
|
736
|
+
and Copilot — neither of which can be handed an MCP server on the command line
|
|
737
|
+
without publishing the bearer to argv — get the agent-scoped token as
|
|
738
|
+
`SPOR_TOKEN` in the run's environment, so the `spor` CLI inside the run is
|
|
739
|
+
agent-attributed with no injected MCP. All of them land at the same self-serve
|
|
740
|
+
mint/bind pair above.
|
|
685
741
|
- **OAuth 2.1 for MCP connectors** (Cowork/claude.ai, which cannot carry a
|
|
686
742
|
static bearer token): protected-resource metadata discovery (RFC 9728,
|
|
687
743
|
advertised on the `/mcp` 401 via `WWW-Authenticate`), authorization-server
|
package/GRAPH.md
CHANGED
|
@@ -97,6 +97,26 @@ Rules:
|
|
|
97
97
|
work instead of in one person's calendar. Everything else (compiles,
|
|
98
98
|
briefings, edges) sees a dormant node normally.
|
|
99
99
|
- One fact per node. If you're writing "also" a lot, split it.
|
|
100
|
+
- A node file the parser cannot read is **skipped, not fatal**
|
|
101
|
+
(dec-spor-buildgraph-per-node-fault-isolation). `loadGraph` isolates each
|
|
102
|
+
file's parse: a fault leaves that node out of the graph entirely — no node,
|
|
103
|
+
no edges, no search doc, and no registry override if it was a `type: schema`
|
|
104
|
+
node — and the rest of the graph loads normally, so one corrupt file can't
|
|
105
|
+
refuse a whole graph's boot. Every skip is named on stderr
|
|
106
|
+
(`spor: SKIPPED unparseable node file <path>: <why>`) and listed on
|
|
107
|
+
`graph.skipped`; an edge pointing at a skipped node simply dangles. Treat the
|
|
108
|
+
warning as corruption to fix, not noise to live with: `spor validate` is the
|
|
109
|
+
gate, and reports the same files as an **error** (exit 1), naming the
|
|
110
|
+
offending line — a node the loader drops is missing from every briefing,
|
|
111
|
+
digest and queue, so a lint that passed would be a lie. Isolation covers
|
|
112
|
+
malformed FILES only; a bug in the parser itself still throws. The same
|
|
113
|
+
isolation also covers a file `readdirSync` lists but `readFileSync` then
|
|
114
|
+
fails to read at all — EACCES, or an ENOENT race against a live graph home
|
|
115
|
+
the server/gardener writes concurrently
|
|
116
|
+
(issue-spor-read-graph-files-single-file-abort) — which rides the same
|
|
117
|
+
`graph.skipped` record under a distinct stderr wording
|
|
118
|
+
(`spor: SKIPPED unreadable node file <path>: <why>`, since the file never
|
|
119
|
+
reached the parser).
|
|
100
120
|
|
|
101
121
|
## Node types and id prefixes
|
|
102
122
|
|
|
@@ -122,6 +142,48 @@ Rules:
|
|
|
122
142
|
| project | `proj-` | a grouping above repos; owns its member repos via inbound `grouped-under` edges (below) |
|
|
123
143
|
| lens | `lens-` | a saved view over the graph — declarative `## query`/`## render` json blocks, an optional sandboxed `## custom` js block, and an optional `## actions` block; `traversable: false` and `capturable: false` (see "Lenses") |
|
|
124
144
|
|
|
145
|
+
## Accreting running-log artifacts
|
|
146
|
+
|
|
147
|
+
An `artifact` node is sometimes used as a **running log** — one node a session
|
|
148
|
+
appends a data point to over and over, rather than one fact written once
|
|
149
|
+
(task-cc-dogfood-capture-discipline's `art-cc-capture-discipline-results` is the
|
|
150
|
+
prototype: one entry per dogfood session). That pattern collides with the
|
|
151
|
+
server's per-node body cap (API.md §1: body ≤ 8KB) — the log scales for a
|
|
152
|
+
session or two, then every further append is rejected
|
|
153
|
+
(issue-cc-accreting-log-body-cap-collision). The cap itself stays: it is what
|
|
154
|
+
keeps "one fact per node" true for every OTHER node type, and weakening it for
|
|
155
|
+
one pattern would just move the failure mode elsewhere.
|
|
156
|
+
|
|
157
|
+
The fix is to let the log **roll over**, not to grow one node forever:
|
|
158
|
+
|
|
159
|
+
- When a running-log artifact is at or near the cap, start a new artifact node
|
|
160
|
+
for the next stretch of entries — same subject, new id with an incrementing
|
|
161
|
+
suffix (`art-cc-capture-discipline-results` → `art-cc-capture-discipline-results-2`
|
|
162
|
+
→ `-3`, …) and a title noting it ("part 2 (2026-07 onward)").
|
|
163
|
+
`spor validate` warns once a node's body passes ~90% of the cap, specifically
|
|
164
|
+
so there is still room to make this move before a write is rejected outright
|
|
165
|
+
— don't wait for the hard failure. (That early warning belongs to `spor
|
|
166
|
+
validate` — including the SessionEnd distiller's own lint pass, which logs it
|
|
167
|
+
to `journal/distill.log` — and it is not a per-write gate: `spor add` doesn't
|
|
168
|
+
check it at all, and the graph-wide lint `spor put-node` runs *before* the
|
|
169
|
+
write it gates deliberately does not ask for it — nor does the server
|
|
170
|
+
gardener's sweep — because on a finished node "approaching the cap" is advice
|
|
171
|
+
nobody can act on. So run `spor validate` periodically on a running log's
|
|
172
|
+
home graph rather than relying on the write itself to flag it. A body already
|
|
173
|
+
OVER the cap is a different matter and warns everywhere: the server
|
|
174
|
+
re-validates on every write, so an
|
|
175
|
+
over-cap node is frozen until it is split.)
|
|
176
|
+
- Link the new part back to the one it continues with a `derived-from` edge
|
|
177
|
+
(this node was produced from — i.e. continues — the target), and update the
|
|
178
|
+
earlier part's summary to say it is superseded/continued so a reader
|
|
179
|
+
landing on it knows to follow the edge forward.
|
|
180
|
+
- Anything that reads "the log" (a task's `derived-from`/`relates-to` edge, a
|
|
181
|
+
briefing, a human) should expect a chain of parts, not one node — walk
|
|
182
|
+
`derived-from` to find the latest.
|
|
183
|
+
|
|
184
|
+
This is the same shape as any other multi-part document; it just needed
|
|
185
|
+
naming so it stops being reinvented ad hoc per artifact.
|
|
186
|
+
|
|
125
187
|
## Completing work needs a durable why (the resolver gate)
|
|
126
188
|
|
|
127
189
|
A `task` reaching `done`, or an `issue` reaching `resolved`, requires a **live
|
|
@@ -240,8 +302,8 @@ separately, but never shows a complete custom type in one piece.
|
|
|
240
302
|
|
|
241
303
|
**The constraint model is procedural, not declarative.** A schema's `json`
|
|
242
304
|
payload declares only *registry knobs* — `node_type`, `prefix`, `queueable`,
|
|
243
|
-
`traversable`, `always_on`, `capturable`, an edge `weight`,
|
|
244
|
-
partitions: `status.non_resolving` (resolver semantics — whether a node in this
|
|
305
|
+
`traversable`, `always_on`, `capturable`, an edge `weight`, the completion
|
|
306
|
+
policy (below), and the three status partitions: `status.non_resolving` (resolver semantics — whether a node in this
|
|
245
307
|
status retires the targets it points at), `status.terminal` (own-lifecycle
|
|
246
308
|
completion — the statuses in which a node of this type is *done*, unioned with the
|
|
247
309
|
kernel's legacy set and read by work-analytics so a schema-only terminal status
|
|
@@ -252,8 +314,33 @@ issue-spor-analytics-completion-ignores-schema-terminal-status), and
|
|
|
252
314
|
`terminal-status` register below; a schema that declares no `inert` set
|
|
253
315
|
INHERITS its `terminal` set, so only a schema whose two sets genuinely differ
|
|
254
316
|
declares it — the seed decision schema pins `settled` terminal but NOT inert,
|
|
255
|
-
dec-spor-status-inert-third-partition).
|
|
256
|
-
|
|
317
|
+
dec-spor-status-inert-third-partition).
|
|
318
|
+
|
|
319
|
+
**The completion policy — declared for readers, still enforced by code**
|
|
320
|
+
(task-spor-registry-declarative-terminal-status-policy). Three further `status`
|
|
321
|
+
keys say, as registry data, what the hooks below enforce: `status.vocabulary`
|
|
322
|
+
(the closed status enum the type's own `validate()` gates membership on),
|
|
323
|
+
`status.completion` (the single SUCCESS terminal value — task `done`, issue
|
|
324
|
+
`resolved`, question `answered` — as distinct from `status.terminal`, which is
|
|
325
|
+
the full set *including* the give-up outcomes `abandoned`/`superseded`/
|
|
326
|
+
`rejected`), and `status.resolver_required` (whether reaching that value also
|
|
327
|
+
demands a live resolving `decision`/`artifact`, the completion-resolver
|
|
328
|
+
invariant). **Declaring them gates nothing** — the hooks are still the only
|
|
329
|
+
write door, and this is exactly why they are not a field list or an enforced
|
|
330
|
+
enum. They exist so a READER can name the right terminal status without
|
|
331
|
+
parsing hook source: the gardener's finding remedies used to keep hand-written
|
|
332
|
+
tables of "task → done, everything else → resolved" and shipped remedies whose
|
|
333
|
+
`set_status` the door then refused
|
|
334
|
+
(issue-spor-gardener-terminal-status-fallback-off-vocab). A type whose terminal
|
|
335
|
+
values are several distinct OUTCOMES rather than one success (decision
|
|
336
|
+
settled/superseded/rejected, artifact merged/released/done, capture-pending
|
|
337
|
+
merged/rejected) declares a `vocabulary` and NO `completion` — that absence is
|
|
338
|
+
the machine-readable form of "there is no mechanical close here". Declaration
|
|
339
|
+
and hook are pinned together by `test/seed-declarative-status-policy.test.js`,
|
|
340
|
+
which drives every seed schema's hooks through the sandbox and fails if the two
|
|
341
|
+
disagree; read the live values with `spor schema <type>`.
|
|
342
|
+
|
|
343
|
+
Otherwise there is **no declarative field list and no status enum.** Custom fields are free-form: any flat frontmatter key the
|
|
257
344
|
regex parser accepts (simple `key: value` scalars, YAML-folded multi-line
|
|
258
345
|
values, `pin:`/`exclude:` inline lists, `- {type: X, to: Y}` edges — and nothing
|
|
259
346
|
fancier) is carried verbatim on the node. What a field MUST contain, and which
|
|
@@ -338,6 +425,20 @@ warns). A schema node goes through the same propose→activate flow it governs
|
|
|
338
425
|
bump the CalVer and add an `upgrades` chain only when the change is not
|
|
339
426
|
backward-readable.
|
|
340
427
|
|
|
428
|
+
Rollout-stage schemas the *product* ships ride the **candidate pack**
|
|
429
|
+
(`lib/seed/candidates/`): full schema-node markdown that travels with the npm
|
|
430
|
+
package but never enters the registry until a graph adopts it as a
|
|
431
|
+
graph-resident node — `spor schema candidates` lists each candidate's adoption
|
|
432
|
+
state, `spor schema adopt <id>` writes it through the validated node surface
|
|
433
|
+
(`status: proposed`; `--activate` is the trusted-admin form for solo/local
|
|
434
|
+
graphs), stamping `adopted_from`/`adopted_sha` provenance. Re-running adopt
|
|
435
|
+
after a package upgrade is CalVer-aware and idempotent: a pristine older copy
|
|
436
|
+
(canonical hash — `schema_version` + body — still matching its stamp) upgrades
|
|
437
|
+
in place with its status preserved; a locally modified or unstamped resident
|
|
438
|
+
refuses without `--force`. When a candidate stabilizes it is promoted into the
|
|
439
|
+
seed pack at a release; the resident copy then shadows the seed (the
|
|
440
|
+
stale-override warning above) and should be retired (`status: retired`).
|
|
441
|
+
|
|
341
442
|
A complete worked example — a `escalation` type with a required `severity`
|
|
342
443
|
field (enforced in `validate`) and an `open → mitigated → closed` status machine
|
|
343
444
|
whose terminal `closed` demands a resolver (enforced in `transitions`):
|
|
@@ -1006,6 +1107,7 @@ against a query language, never drafted from a capture or a distilled transcript
|
|
|
1006
1107
|
| `decided-in` | 0.9 | the choice in this node was made in the target |
|
|
1007
1108
|
| `resolves` | 0.9 | this node fixes/closes the target |
|
|
1008
1109
|
| `blocks` | 0.7 | target cannot proceed until this node does |
|
|
1110
|
+
| `member-of-program` | 0.7 | this node is a member of the target program umbrella (inverse `has-program-member`); pure topology, independent of gating; `capturable: false` |
|
|
1009
1111
|
| `answers` | 0.7 | this node answers that question (inverse `answered-by`); pulls the answer through the asker's next compile |
|
|
1010
1112
|
| `assigned` | 0.5 | work is assigned to this person OR agent (the explicit-routing edge; an agent target may carry a `profile:` per-assignment override) |
|
|
1011
1113
|
| `reviewed-by` | 0.5 | this person reviewed and approved the node — counts toward a policy quorum |
|
|
@@ -1023,6 +1125,32 @@ against a query language, never drafted from a capture or a distilled transcript
|
|
|
1023
1125
|
| `compiled-for` | — | briefing → its task/query (provenance only) |
|
|
1024
1126
|
| `shaped-by` | — | briefing → corrections applied (provenance only) |
|
|
1025
1127
|
|
|
1128
|
+
`member-of-program` is the dedicated program-membership edge
|
|
1129
|
+
(dec-spor-program-membership-dedicated-edge-type), written from the member's
|
|
1130
|
+
perspective like `blocks`: `member -> umbrella`. It is **additive with `blocks`,
|
|
1131
|
+
never a replacement**: `blocks` keeps meaning only gating (this node gates the
|
|
1132
|
+
umbrella's completion), while `member-of-program` records pure topology (this
|
|
1133
|
+
node belongs to the program) — a member usually carries both, but a
|
|
1134
|
+
non-gating member carries only this edge, and a prerequisite that gates the
|
|
1135
|
+
umbrella without being part of its program carries only `blocks`, a
|
|
1136
|
+
distinction blocks-topology inference alone could never draw. Readers
|
|
1137
|
+
(`render_program`, the gardener's program-completion pass) prefer these edges
|
|
1138
|
+
**per node**: at a node with any inbound `member-of-program` edges, those are
|
|
1139
|
+
its members; at a node with none, its members are still inferred from inbound
|
|
1140
|
+
`blocks` (today's behavior, unchanged), so an unmigrated or partially migrated
|
|
1141
|
+
program keeps rendering. The preference is all-or-nothing at a node — declare
|
|
1142
|
+
every member of an umbrella in one write, or the undeclared rest read as
|
|
1143
|
+
"blocking but outside the program" rather than silently dropping. It ships as
|
|
1144
|
+
a graph-resident schema node (`schema-edge-member-of-program`), not the seed
|
|
1145
|
+
pack — delivered as a packaged candidate (`spor schema adopt
|
|
1146
|
+
schema-edge-member-of-program` writes it into a graph that doesn't have it
|
|
1147
|
+
yet; see "Resolution and rollout" above) — so it needs activation (a
|
|
1148
|
+
*different* identity in team graphs; `--activate` in solo/local ones) before
|
|
1149
|
+
writes of this edge type validate; check `spor schema member-of-program` for
|
|
1150
|
+
its live status rather than assuming.
|
|
1151
|
+
`capturable: false` — the distiller and capture nudge never emit it; only a
|
|
1152
|
+
person or an agent working the program explicitly wires membership.
|
|
1153
|
+
|
|
1026
1154
|
`answers`, `assigned`, `stewards`, `member-of-org`, and `routed-to` are person-graph
|
|
1027
1155
|
edges of Tier-2 question routing; they ship in the seed pack and are
|
|
1028
1156
|
documented under "People, routing, and onboarding" above. `assigned` and
|
|
@@ -1080,8 +1208,9 @@ edge schema): **aliases** — same-direction synonyms renamed in place
|
|
|
1080
1208
|
`supercedes` → `supersedes`, `approved-by` → `reviewed-by`) — and **inverse
|
|
1081
1209
|
labels** — the edge read from
|
|
1082
1210
|
the target's side, flipped onto the target node on write (`blocked-by` →
|
|
1083
|
-
`blocks`, `answered-by` → `answers`, `superseded-by` → `supersedes
|
|
1084
|
-
Hand-written nodes should still
|
|
1211
|
+
`blocks`, `answered-by` → `answers`, `superseded-by` → `supersedes`,
|
|
1212
|
+
`has-program-member` → `member-of-program`). Hand-written nodes should still
|
|
1213
|
+
use the canonical forms.
|
|
1085
1214
|
|
|
1086
1215
|
## Correction nodes
|
|
1087
1216
|
|
package/QUEUE.md
CHANGED
|
@@ -635,11 +635,38 @@ Two gate checks joined the sweep with the edge-write UX work
|
|
|
635
635
|
(issue-cc-edge-write-ux-friction):
|
|
636
636
|
|
|
637
637
|
- **inert-gate** — a `blocks` edge whose source has reached a terminal
|
|
638
|
-
status while its target is still live
|
|
639
|
-
|
|
640
|
-
`
|
|
641
|
-
|
|
642
|
-
|
|
638
|
+
status while its target is still live, claimed by no one, and not itself
|
|
639
|
+
a *program* (more than one node hanging beneath it in the membership
|
|
640
|
+
tree `render_program` walks — per node, preferring inbound
|
|
641
|
+
`member-of-program` edges where any are declared and falling back to
|
|
642
|
+
inbound `blocks` where none are, dec-spor-program-membership-per-node-preference;
|
|
643
|
+
`member-of-program` ships as a graph-resident schema node pending
|
|
644
|
+
activation, so this fallback is today's live behavior). The gate has
|
|
645
|
+
cleared but the graph still says "blocked": the finding (on the target's
|
|
646
|
+
side, `find-inert-gate-<source>-<target>`) says the target can proceed —
|
|
647
|
+
drop the edge or pick up the work. This is the "the blocker was already
|
|
648
|
+
done" discovery made manually during dogfooding, mechanized. It carries a
|
|
649
|
+
mechanical `remove_edge` remedy, so a batch `resolve_finding` can drop
|
|
650
|
+
the stale edge without a human re-deriving the fix
|
|
651
|
+
(issue-spor-gardener-mechanical-remediation-gap).
|
|
652
|
+
- **gates-cleared** — the same cleared-gate situation, but on a dependent
|
|
653
|
+
that *heads a program*: every member in its membership tree has landed,
|
|
654
|
+
nothing live gates it anywhere in that tree, and nobody has claimed it.
|
|
655
|
+
Firing one inert-gate per membership edge would hand a batch
|
|
656
|
+
`resolve_finding` a `remove_edge` remedy for edges that are the
|
|
657
|
+
program's own progress record — `render_program` reads exactly those
|
|
658
|
+
edges (explicit inbound `member-of-program` per node, or inbound `blocks`
|
|
659
|
+
where none are declared) to render the gating tree, so mechanically
|
|
660
|
+
dropping them would silently erase a finished program's membership.
|
|
661
|
+
Instead the gardener files ONE `find-gates-cleared-<target>` finding on
|
|
662
|
+
the umbrella itself, naming its members, and deliberately gives it **no
|
|
663
|
+
remedy**: resolve_finding refuses the kind outright, so closing the
|
|
664
|
+
umbrella out (a resolver plus a terminal-status flip) or picking up
|
|
665
|
+
whatever closing work remains is a human judgment call, not a mechanical
|
|
666
|
+
fix (issue-spor-inert-gate-remedy-destroys-program-topology,
|
|
667
|
+
dec-spor-finished-program-files-one-non-mechanical-gates-cleared-finding).
|
|
668
|
+
A program with anything still live anywhere in its tree is suppressed
|
|
669
|
+
outright — neither finding fires until every level has cleared.
|
|
643
670
|
- **unedged-gate** — a live decision whose body uses gate vocabulary
|
|
644
671
|
(`gate`/`gated`/`blocks`/`blocked`) and literally mentions two or more
|
|
645
672
|
*live, queueable* node ids, none of which are connected to another
|