@sporhq/spor 0.13.0 → 0.14.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/.claude-plugin/plugin.json +4 -2
- package/API.md +59 -18
- package/GRAPH.md +70 -28
- package/QUEUE.md +21 -10
- package/README.md +25 -0
- package/agents/backfill.md +7 -1
- package/bin/spor.js +1899 -44
- package/lib/analytics.js +88 -11
- package/lib/changes.js +215 -0
- package/lib/config.js +42 -3
- package/lib/history.js +272 -0
- package/lib/kernel/analytics.js +69 -21
- package/lib/kernel/graph.js +35 -1
- package/lib/kernel/queue.js +61 -21
- package/lib/kernel/registry.js +158 -4
- package/lib/kernel/resolution.js +8 -1
- package/lib/query.js +31 -1
- package/lib/remote.js +47 -1
- package/lib/schema.js +280 -0
- package/lib/seed/schema-decision.md +46 -3
- package/lib/seed/schema-issue.md +52 -19
- package/lib/seed/schema-task.md +57 -30
- package/lib/shell/gittime.js +63 -30
- package/lib/tar.js +89 -0
- package/package.json +1 -1
- package/scripts/engines/drain-outbox.js +13 -6
- package/scripts/engines/session-start.js +74 -4
- package/scripts/engines/util.js +63 -1
- package/skills/backfill/SKILL.md +5 -2
- package/skills/defer/SKILL.md +2 -2
- package/skills/next/SKILL.md +4 -2
- package/skills/spor/SKILL.md +35 -3
- package/skills/spor/references/authoring-schemas.md +13 -3
- package/skills/spor/references/concepts.md +14 -6
- package/skills/triage/SKILL.md +27 -17
|
@@ -2,6 +2,8 @@
|
|
|
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.
|
|
6
|
-
"author": {
|
|
5
|
+
"version": "0.14.0",
|
|
6
|
+
"author": {
|
|
7
|
+
"name": "losthammer"
|
|
8
|
+
}
|
|
7
9
|
}
|
package/API.md
CHANGED
|
@@ -338,6 +338,39 @@ rendered digest/briefing hides. The tool returns the changed nodes as data;
|
|
|
338
338
|
the model writes the prose summary (no LLM on this path). It is the MCP twin of
|
|
339
339
|
`GET /v1/changes` (§3), sharing one core so the two surfaces never drift.
|
|
340
340
|
|
|
341
|
+
### `analytics`
|
|
342
|
+
|
|
343
|
+
Work-flow analytics over the team graph — the created-vs-completed view a
|
|
344
|
+
remote/Cowork teammate cannot get from the local-only `spor analytics` (which
|
|
345
|
+
folds a LOCAL graph repo's git history). Input `{ "project"?, "types"?,
|
|
346
|
+
"weeks"?, "top"?, "aging"? }` → the rendered report text plus the structured
|
|
347
|
+
report `{window, weekly, totals, throughput, cycleTimeDays, wip, bottlenecks,
|
|
348
|
+
coverage}`: weekly cohorts (created / completed / net / open backlog),
|
|
349
|
+
throughput, cycle-time median and p90, current WIP by node type, and the
|
|
350
|
+
oldest-open bottlenecks. **Completion is a node's status-TRANSITION time** (when
|
|
351
|
+
it entered its final terminal run), never `updated_at`, so a later edge append
|
|
352
|
+
can't corrupt the "completed last week" signal (dec-spor-git-derived-timestamps).
|
|
353
|
+
`project` (a repo slug or grouping id) scopes it through the same up-resolution
|
|
354
|
+
as `my_queue`; `types` restricts node types; `weeks`/`top`/`aging` shape the
|
|
355
|
+
window. The MCP twin of `GET /v1/analytics` (§3) over the same `store.analytics`
|
|
356
|
+
core — for the shell-less Cowork audience that can't run the CLI.
|
|
357
|
+
|
|
358
|
+
### `schema`
|
|
359
|
+
|
|
360
|
+
Introspect the live schema registry — the contract as data
|
|
361
|
+
(task-spor-schema-introspection-surface; server half
|
|
362
|
+
task-spor-server-schema-endpoint). Input `{ "type"?, "code"? }`. With no `type`
|
|
363
|
+
it returns the full snapshot (the same shape as `GET /v1/schema`: `node_types`,
|
|
364
|
+
`edge_types`, `queue_policy`, `policies`, `registers`, `default_edge_weight`,
|
|
365
|
+
`stale_overrides`, `alias_collisions`), the seed pack merged with graph-resident
|
|
366
|
+
`type: schema` overrides and each entry tagged by `source`
|
|
367
|
+
(`seed`/`graph`/`native`). With `type` it returns just that node/edge type's
|
|
368
|
+
entry. `code: true` embeds each `validate()`/`transitions()`/`get()` hook's
|
|
369
|
+
source. The MCP twin of the `spor schema` CLI and `GET /v1/schema`, sharing one
|
|
370
|
+
`graph.registry.snapshot()` core. Read this instead of reverse-engineering the
|
|
371
|
+
contract from `lib/seed/` files — those miss resident overrides
|
|
372
|
+
(norm-cc-registry-is-contract).
|
|
373
|
+
|
|
341
374
|
### The MCP-app widget (`ui://spor/view-tree.html`)
|
|
342
375
|
|
|
343
376
|
`my_queue` and `render_lens` declare a UI resource via
|
|
@@ -358,31 +391,36 @@ endpoint is the REST twin of a core call:
|
|
|
358
391
|
| Endpoint | Typical caller | Semantics |
|
|
359
392
|
|---|---|---|
|
|
360
393
|
| `GET /v1/status` | session-start, monitoring | `{node_count, projects: {...}, head, uptime, metrics}`; doubles as the health check. `?titles=1` adds `titles: [{id, type, project, title}]` — the one-round-trip graph index the distiller dedups against |
|
|
394
|
+
| `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 |
|
|
361
395
|
| `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 |
|
|
362
396
|
| `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) |
|
|
363
397
|
| `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 |
|
|
364
398
|
| `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 |
|
|
365
399
|
| `GET /v1/nodes/{id}` | /spor:brief | `get_node` semantics; 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 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 gardener findings about the node ride along as `open_findings`, and a node marked stale by an inbound supersedes edge as `superseded_by`. All enrichment is additive top-level keys; ignore unknown ones |
|
|
366
|
-
| `
|
|
400
|
+
| `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) |
|
|
401
|
+
| `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` |
|
|
402
|
+
| `POST /v1/nodes` | 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 |
|
|
367
403
|
| `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 |
|
|
368
404
|
| `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 |
|
|
369
405
|
| `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) |
|
|
370
406
|
| `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 |
|
|
371
|
-
| `POST /v1/nodes/{id}/claim` `{session?}` | `claim`/`set_status` MCP tools, `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
|
|
372
|
-
| `POST /v1/nodes/{id}/renew` `{session?}` | post-tool heartbeat, `renew` MCP tool, `spor dispatch` | bump the live lease's expiry only — no commit; the heartbeat that keeps a claim from lapsing. A lapsed/stolen lease is `409` (names 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) |
|
|
373
|
-
| `POST /v1/nodes/{id}/
|
|
407
|
+
| `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) |
|
|
408
|
+
| `POST /v1/nodes/{id}/renew` `{session?}` | post-tool heartbeat, `renew` MCP tool, `spor renew` CLI, `spor dispatch` | bump the live lease's expiry only — no commit; the heartbeat that keeps a claim from lapsing. A lapsed/stolen lease is `409` (names 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) |
|
|
409
|
+
| `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 lapsed/stolen lease is `409 lease_lost` naming the holder |
|
|
410
|
+
| `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 |
|
|
374
411
|
| `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 |
|
|
375
|
-
| `GET /v1/commits/{sha}?repo=` | 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 |
|
|
412
|
+
| `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`) |
|
|
376
413
|
| `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 |
|
|
377
414
|
| `POST /v1/capture` | distill, /spor:defer | `capture` semantics: `{text, context: {project, during, blocks?, needed_by?}, source?}` → ingestion model + validate + commit → `{status, ids, nodes, summary, warnings}`. `source: "distill"` marks backstop captures in the journal. `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 |
|
|
378
415
|
| `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 |
|
|
379
416
|
| `POST /v1/corrections` | /spor:correct | `propose_correction` semantics → 201 `{status, id, revision, warnings}` |
|
|
380
417
|
| `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) |
|
|
418
|
+
| `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) |
|
|
381
419
|
| `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 |
|
|
382
|
-
| `POST /v1/gardener` | ops cron / on demand | run a gardener sweep now; findings filed as queue items → `{filed, resolved,
|
|
420
|
+
| `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 |
|
|
383
421
|
| `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 |
|
|
384
422
|
| `POST /v1/lens/{id}/ticket` `{expires?}` | sharing a view | 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 |
|
|
385
|
-
| `GET /v1/export` | bootstrap/offline | ustar tarball of `nodes/` for seeding a local read replica (`?gzip=1` compresses); see §5 for the response headers. `curl … \| tar x` reproduces `nodes/` byte-for-byte |
|
|
423
|
+
| `GET /v1/export` | bootstrap/offline; `spor export` | ustar tarball of `nodes/` for seeding a local read replica (`?gzip=1` compresses); see §5 for the response headers. `curl … \| tar x` reproduces `nodes/` byte-for-byte. `?history=1` instead streams a `git bundle --all` of the repo (`application/x-git-bundle`, full commit provenance, the customer data-exit path — `git clone <bundle> graph`); `?auth=1` ALSO bundles `auth/*.json` so a disaster restore reproduces the credential set (admin-gated: stewards-root → `403` otherwise). The `spor export [--gzip] [--history\|--auth] [--out <file>]` CLI verb is the shell front-door (remote downloads this; `--gzip`/`--out` also build the same `nodes/` tarball locally, while `--history`/`--auth` are remote-only) |
|
|
386
424
|
| `GET /v1/admin/tokens` | offboarding / audit | list PATs → `{tokens: [{hash_prefix, person, name, email, created, expires, expired}], count}` — never plaintext, never full hashes. Admin-only (§4) |
|
|
387
425
|
| `POST /v1/admin/tokens` `{person, expires?}` | onboarding | mint a PAT bound to an existing person node (`expires` is `<N>d` or an ISO date) → 201 `{token, hash_prefix, person, name, email, expires}`; the plaintext `token` is returned **once**. Admin-only |
|
|
388
426
|
| `DELETE /v1/admin/tokens/{hash-prefix}` | offboarding / rotation | revoke the single PAT matching the hash prefix (≥8 hex chars; an ambiguous prefix is a 409) → `{revoked, hash_prefix}`. Admin-only |
|
|
@@ -391,7 +429,7 @@ endpoint is the REST twin of a core call:
|
|
|
391
429
|
| `POST /v1/agents/{id}/token` `{session?, audience?, expires?}` | `spor dispatch` | **self-serve** (NOT admin): mint a short-TTL, per-session token scoped to agent `{id}` → 201 `{token, expires_at, agent, session}` (`session: null` when deferred). Authorized iff the caller's person **owns** the agent (its `owned-by` edge) — else `403`; `404` unknown agent; `422` a SUPPLIED `session` that is malformed. `session` is now **OPTIONAL** (deferred binding, dec-spor-dispatch-bg-session-late-bind): `spor dispatch` mints it deferred because `claude --bg` self-allocates the run session only at launch, then binds the real one via `POST /v1/agents/session` below. The token carries `{agent, session?}` (the person is derived from the `owned-by` edge at verify time); a write under it is stamped agent-on-behalf-of-person (§1). A caller `expires` may only shorten the default TTL, never extend it |
|
|
392
430
|
| `POST /v1/agents/session` `{session}` | `spor dispatch` (post-launch) | **late session binding** for a session-deferred agent token (dec-spor-dispatch-bg-session-late-bind). Authenticated by the **agent token itself** (the bearer hash identifies its own record — no agent id in the path, no ownership re-check), so only an agent-scoped token may call it (`403` otherwise). Sets that token's `session` → `200 {ok, agent, session}`. **Write-once**: idempotent on the same value (`{unchanged: true}`), `409 conflict` on a different one (a token's session is provenance, not a mutable field); `422` missing/malformed `session`. Every subsequent write under the token then stamps the bound session |
|
|
393
431
|
| `POST /v1/agents/{id}/capabilities` `{harnesses?, reachable_mcp?, skills?, plugins?, deny?}` | `spor capabilities publish`, session-start auto-publish | **publish** this box's machine capabilities to the fleet scheduler (task-spor-remote-fleet-scheduler, dec-spor-machine-profile-satisfiability). The remote twin of the machine-local `dispatch.capabilities` map: the server collapses the body with the SAME `effectiveCapabilities()` the client runs (so a raw `{probed,declared,deny}` map or the already-flat axes both work; also accepts a `{capabilities: {...}}` envelope) and stores it BESIDE the agent node (operational store, not the durable git-tracked node — capabilities are machine-local, probe-refreshed, never committed) → `200 {agent, capabilities, published_at, last_seen, published_by, session?, changed}`. Authorized iff the caller **owns** the agent (its `owned-by` edge) OR is the agent itself (a self-publish under an agent token) — else `403`; `404` unknown agent; `422` a malformed map. A publish stamps both `published_at` (when the CAPS last changed) and `last_seen` (last contact); `last_seen` ALSO advances on the cheap `POST .../heartbeat` below, and the host-match keys staleness off `last_seen` not `published_at`. Beyond the manual verb, `session-start` AUTO-publishes here in remote mode whenever a `dispatch.agent` is configured (task-spor-fleet-capabilities-autopublish-session-start) — bounded + fail-open, so every session refreshes this box's caps and last-contact without a manual call; disable with `SPOR_CAPABILITIES_PUBLISH=0` |
|
|
394
|
-
| `GET /v1/agents/{id}/capabilities` | steward fleet view, debugging | read back an agent's published capabilities → `200 {agent, capabilities, published_at, last_seen, published_by, session?}`; `404` if none published. Readable by the **owner**, the **agent itself**, or an **admin** (a stewards→root fleet-capacity view) — else `403` |
|
|
432
|
+
| `GET /v1/agents/{id}/capabilities` | `spor capabilities show <agent>`, steward fleet view, debugging | read back an agent's published capabilities → `200 {agent, capabilities, published_at, last_seen, published_by, session?}`; `404` if none published. Readable by the **owner**, the **agent itself**, or an **admin** (a stewards→root fleet-capacity view) — else `403`. The CLIENT reader (task-spor-capabilities-read-agent-cli-verb): `spor capabilities show <agent-id>` (`me` = this box's `dispatch.agent`) renders the stored caps + timestamps without raw REST — the read twin of `spor capabilities publish` and the per-agent companion to `spor capabilities hosts`; remote-only, fail-soft |
|
|
395
433
|
| `POST /v1/agents/{id}/heartbeat` | post-tool mid-session liveness tick | **liveness ping** (task-spor-fleet-scheduler-hardening): refresh this box's `last_seen` WITHOUT re-uploading capabilities — the cheap "still here" signal, decoupled from a caps re-publish, so a box that published once and runs for hours stays a live fleet host. The host-match keys staleness off `last_seen`, so a box that keeps heartbeating is never demoted while a genuinely dead one ages out under `max_age` → `200 {agent, capabilities, published_at, last_seen, …}` (the refreshed record). Same owner/self gate as publish — else `403`; `404` unknown agent OR nothing published yet (publish before heartbeat — liveness without caps is meaningless to the scheduler); `422` a malformed agent id. The CLIENT caller (task-spor-fleet-scheduler-client-heartbeat-tick): the `post-tool` hook ticks this in REMOTE mode whenever a `dispatch.agent` is configured (the SAME opt-in as the session-start auto-publish), piggybacking on write-activity but THROTTLED to one ping per `dispatch.heartbeatIntervalMs` (default 5min) — so a long session keeps `last_seen` fresh between session-starts (which today refresh it the expensive way, via a full re-publish) without re-probing. Bounded + fail-open; disable with `SPOR_HEARTBEAT=0` |
|
|
396
434
|
| `GET /v1/profiles/{id}/hosts` `?owner=me\|person-X&max_age=<dur>` | `spor capabilities hosts <profile>`; `spor dispatch` (auto on a FORK B refusal) | **host-match** a `type: profile` against every agent's published capabilities using the SAME pure `satisfies()` matcher the client runs locally → `200 {profile, satisfiable: [{agent, owner, published_at, last_seen, age_seconds}], unsatisfiable: [{agent, owner, published_at, last_seen, age_seconds, reasons}], counts}`. Satisfiable hosts are freshest-first (by `last_seen`); the unsatisfiable carry the matcher's own reasons (the failing atoms), enabling **substitution-free re-routing** — pick a box that satisfies the profile, NEVER substitute a different one (dec-spor-machine-profile-satisfiability FORK B). The CLIENT consumer (task-spor-fleet-scheduler-autoroute-dispatch): `spor capabilities hosts` lists the re-route targets directly, and when `spor dispatch` refuses because THIS box can't satisfy the resolved profile it calls this endpoint and names the satisfiable hosts to re-route to — or, when none satisfy it, escalates to the owner (fail-soft: an unreachable scheduler degrades to a generic hint). **Visibility is steward-scoped** (task-spor-fleet-scheduler-hardening): the whole-fleet view (every member's boxes + caps) is a multi-tenant cross-member disclosure, so an **admin** (stewards→root) sees the whole fleet and may scope to any `owner=person-X`, while an ordinary **member** is scoped to THEIR OWN boxes (default `owner` = the caller's person; an agent token resolves to its owner; `owner=me` is the explicit form) and a member asking for a colleague's `owner=person-X` is `403`. `max_age` (`30m`/`12h`/`7d`/ms) demotes hosts whose `last_seen` is older than it to unsatisfiable (the liveness filter). `404` unknown/non-profile id; `422` bad `max_age`/`owner` |
|
|
397
435
|
|
|
@@ -405,13 +443,13 @@ The run engine's claim/complete API. Full contract and the reference worker
|
|
|
405
443
|
live with [workers/shim/README.md](workers/shim/README.md); a worker is
|
|
406
444
|
anything with a token.
|
|
407
445
|
|
|
408
|
-
| Endpoint | Semantics |
|
|
409
|
-
|
|
410
|
-
| `POST /v1/workflows/{id}/run` `{inputs?}` | start a run on an ACTIVE workflow → `{run_id, revision, workflow, workflow_version, state}` |
|
|
411
|
-
| `GET /v1/work?capability=a,b` | claimable steps across live runs, filtered by capability → `{work, count, generated_at}`; approval steps are excluded (they surface in the queue, not as worker-claimable work) |
|
|
412
|
-
| `POST /v1/runs/{id}/steps/{sid}/claim` `{iteration?}` | claim a ready step → `{run_id, step, lease, state}`; a step that isn't claimable is a 409 |
|
|
413
|
-
| `POST /v1/runs/{id}/steps/{sid}/complete` `{lease, status, result?, log?, iteration?}` | report a verdict (`status: succeeded \| failed` only — anything else is 422). An expired/superseded lease is `409 lease_expired`; a same-generation retry that disagrees with the recorded outcome is `409 outcome_conflict` — redo the work under a fresh lease |
|
|
414
|
-
| `GET /v1/runs/{id}` | full run record: `{run_id, status, project, title, initiator, workflow, workflow_version, lineage, state, revision, timestamps?}` |
|
|
446
|
+
| Endpoint | Typical caller | Semantics |
|
|
447
|
+
|---|---|---|
|
|
448
|
+
| `POST /v1/workflows/{id}/run` `{inputs?}` | `spor run`, `run_workflow` MCP tool | start a run on an ACTIVE workflow → `{run_id, revision, workflow, workflow_version, state}` |
|
|
449
|
+
| `GET /v1/work?capability=a,b` | run worker (workers/shim) | claimable steps across live runs, filtered by capability → `{work, count, generated_at}`; approval steps are excluded (they surface in the queue, not as worker-claimable work) |
|
|
450
|
+
| `POST /v1/runs/{id}/steps/{sid}/claim` `{iteration?}` | run worker (workers/shim) | claim a ready step → `{run_id, step, lease, state}`; a step that isn't claimable is a 409 |
|
|
451
|
+
| `POST /v1/runs/{id}/steps/{sid}/complete` `{lease, status, result?, log?, iteration?}` | run worker (workers/shim) | report a verdict (`status: succeeded \| failed` only — anything else is 422). An expired/superseded lease is `409 lease_expired`; a same-generation retry that disagrees with the recorded outcome is `409 outcome_conflict` — redo the work under a fresh lease |
|
|
452
|
+
| `GET /v1/runs/{id}` | `spor run status` | full run record: `{run_id, status, project, title, initiator, workflow, workflow_version, lineage, state, revision, timestamps?}` |
|
|
415
453
|
|
|
416
454
|
## 4. Identity and auth
|
|
417
455
|
|
|
@@ -539,9 +577,12 @@ retried with backoff.
|
|
|
539
577
|
|
|
540
578
|
`GET /v1/export` response headers: `x-substrate-head` carries the graph
|
|
541
579
|
commit, `x-substrate-node-count` the entry count (plus
|
|
542
|
-
`x-substrate-skipped` when any entry was omitted
|
|
543
|
-
|
|
544
|
-
|
|
580
|
+
`x-substrate-skipped` when any entry was omitted, and `x-substrate-auth-files`
|
|
581
|
+
on an `?auth=1` export — the count of `auth/*.json` files bundled). A
|
|
582
|
+
`?history=1` bundle carries only `x-substrate-head` (a git bundle has no node
|
|
583
|
+
count). These header names are a wire contract and were deliberately **not**
|
|
584
|
+
renamed in the Spor rename — clients should keep reading the `x-substrate-*`
|
|
585
|
+
spellings.
|
|
545
586
|
|
|
546
587
|
## 6. Client configuration
|
|
547
588
|
|
package/GRAPH.md
CHANGED
|
@@ -8,7 +8,11 @@
|
|
|
8
8
|
> graph-resident `type: schema` node overrides or extends any entry here.
|
|
9
9
|
> This file remains accurate documentation of the seed pack and of the node
|
|
10
10
|
> file format, but when this prose and the registry disagree, the registry
|
|
11
|
-
> wins — see QUEUE.md §2.
|
|
11
|
+
> wins — see QUEUE.md §2. To read the LIVE registry (seed + resident overrides
|
|
12
|
+
> merged, provenance-tagged) rather than this prose, run `spor schema`
|
|
13
|
+
> (`spor schema <type>` for one type's gates) or `GET /v1/schema` — don't
|
|
14
|
+
> reverse-engineer the contract from `lib/seed/` files
|
|
15
|
+
> (task-spor-schema-introspection-surface).
|
|
12
16
|
|
|
13
17
|
The Spor graph lives OUTSIDE code repos, at `$SPOR_HOME` (the legacy
|
|
14
18
|
`SUBSTRATE_HOME` spelling is still read; default `~/.spor/`, or the legacy
|
|
@@ -96,7 +100,7 @@ Rules:
|
|
|
96
100
|
|
|
97
101
|
| type | prefix | what it is |
|
|
98
102
|
|------------|-----------|-----------------------------------------------------------|
|
|
99
|
-
| decision | `dec-` | a choice that was made, with the why (status `active`/`superseded`/`rejected`, gated) |
|
|
103
|
+
| decision | `dec-` | a choice that was made, with the why (status `active`/`superseded`/`rejected`/`settled`, gated; `settled` = in force but acknowledged as just-context, exempt from the gardener decay-sweep — optional `reviewed_at` ISO scalar snoozes that sweep) |
|
|
100
104
|
| task | `task-` | active or planned work (status `open`/`active`/`done`/`abandoned`, gated; `done` requires a `decision`/`artifact` resolver — see below) |
|
|
101
105
|
| issue | `issue-` | a defect/finding and its resolution lineage (queueable: open issues join the decision queue; status `open`/`active`/`resolved`, gated; `resolved` requires a `decision`/`artifact` resolver — see below) |
|
|
102
106
|
| incident | `inc-` | something that went wrong in operation (queueable: live incidents join the decision queue) |
|
|
@@ -124,8 +128,27 @@ lives ON THE GRAPH, where the neighborhood can surface it, instead of evaporatin
|
|
|
124
128
|
into a status flip: a heavyweight closure earns a `decision` (the why), a
|
|
125
129
|
trivial one earns a few-line `artifact` (what was done, like a commit message) —
|
|
126
130
|
either satisfies the gate. `abandoned` (task) is exempt: won't-do work produces
|
|
127
|
-
nothing to record. The gate runs at write time on
|
|
128
|
-
|
|
131
|
+
nothing to record. The gate runs at write time on **both create and update**
|
|
132
|
+
(issue-spor-node-create-ungated-for-completion-resolver-gate): a node may no more
|
|
133
|
+
be BORN terminal without a resolver than flipped there. A create is not a
|
|
134
|
+
transition, so the host calls `transitions()` with `current` = the proposed node —
|
|
135
|
+
state-framed gates like this one apply to the born status, while change-framed
|
|
136
|
+
gates (status-change-requires-author) see no transition. It is backward-readable,
|
|
137
|
+
so existing terminal nodes are untouched.
|
|
138
|
+
|
|
139
|
+
Because the gate runs per node at write time, **a born-terminal node needs its
|
|
140
|
+
resolver to already exist on the graph**, which has one ordering consequence for
|
|
141
|
+
automated multi-node writers. A batch `POST /v1/nodes` applies its entries
|
|
142
|
+
sequentially — each is fully validated before the next — so a batch that lists a
|
|
143
|
+
born-terminal node BEFORE the `decision`/`artifact` that resolves it is rejected
|
|
144
|
+
(`transition_denied`) on that node, even though the resolver appears later in the
|
|
145
|
+
same batch. The batch path does **not** defer the gate to end-of-batch
|
|
146
|
+
(dec-spor-batch-create-gate-resolver-first-ordering): the contract is
|
|
147
|
+
**resolver-first ordering** — emit each resolver before the terminal node it
|
|
148
|
+
resolves, or build the node open→resolve→done. The normal authoring flow
|
|
149
|
+
(create-open → record outcome → set_status) and `/spor:backfill` (whose
|
|
150
|
+
`spor-backfill` subagent orders resolvers first) both satisfy this by
|
|
151
|
+
construction; local-mode file writes are ungated and so order-free.
|
|
129
152
|
|
|
130
153
|
The resolver must also be in a **resolving** state, not merely present
|
|
131
154
|
(dec-spor-definition-of-done-org-policy). Completion bundled three axes —
|
|
@@ -141,15 +164,18 @@ a change still in review has not delivered. So:
|
|
|
141
164
|
GitHub reflection adapter and a native Spor review surface write the same
|
|
142
165
|
thing. Who may assert `merged`/`released` (the self-approval trust seam) is
|
|
143
166
|
later-stage policy, not a write gate here.
|
|
144
|
-
- The read-time truth (`resolutionMap`) and the write-time
|
|
145
|
-
|
|
167
|
+
- The read-time truth (`resolutionMap`) and the write-time completion gate (a
|
|
168
|
+
task reaching `done`, an issue reaching `resolved`) both read this
|
|
169
|
+
**resolving-status partition off `graph.registry`** — never a hardcoded
|
|
146
170
|
table. The partition is the union of each node-schema's `status.non_resolving`
|
|
147
171
|
list; the seed declares `decision: [rejected]`, `task: [abandoned]`,
|
|
148
172
|
`artifact: [in-review, approved]`, reproducing the prior behavior
|
|
149
|
-
byte-identically
|
|
150
|
-
|
|
151
|
-
|
|
152
|
-
|
|
173
|
+
byte-identically (`issue` carries no `status.non_resolving` of its own — its
|
|
174
|
+
`open`/`active`/`resolved` vocabulary names no withdrawn or in-review state, so
|
|
175
|
+
the type only READS the partition). An org or team retunes the bar by editing a
|
|
176
|
+
schema node, no code change. A resolver with no delivery stage (the common
|
|
177
|
+
case) resolves exactly as before, so a change still in review keeps its task or
|
|
178
|
+
issue live without any hand-managed `open` status.
|
|
153
179
|
|
|
154
180
|
## The org-defined policy layer
|
|
155
181
|
|
|
@@ -204,9 +230,14 @@ separately, but never shows a complete custom type in one piece.
|
|
|
204
230
|
|
|
205
231
|
**The constraint model is procedural, not declarative.** A schema's `json`
|
|
206
232
|
payload declares only *registry knobs* — `node_type`, `prefix`, `queueable`,
|
|
207
|
-
`traversable`, `always_on`, `capturable`, an edge `weight`, the
|
|
208
|
-
`status.non_resolving`
|
|
209
|
-
status
|
|
233
|
+
`traversable`, `always_on`, `capturable`, an edge `weight`, and the two status
|
|
234
|
+
partitions: `status.non_resolving` (resolver semantics — whether a node in this
|
|
235
|
+
status retires the targets it points at) and `status.terminal` (own-lifecycle
|
|
236
|
+
completion — the statuses in which a node of this type is *done*, unioned with the
|
|
237
|
+
kernel's legacy set and read by work-analytics so a schema-only terminal status
|
|
238
|
+
like decision `settled` counts as completed,
|
|
239
|
+
issue-spor-analytics-completion-ignores-schema-terminal-status). There is **no
|
|
240
|
+
declarative field list and no status enum.** Custom fields are free-form: any flat frontmatter key the
|
|
210
241
|
regex parser accepts (simple `key: value` scalars, YAML-folded multi-line
|
|
211
242
|
values, `pin:`/`exclude:` inline lists, `- {type: X, to: Y}` edges — and nothing
|
|
212
243
|
fancier) is carried verbatim on the node. What a field MUST contain, and which
|
|
@@ -227,13 +258,20 @@ the server runs on the write path:
|
|
|
227
258
|
vocabulary); `validate()` and `transitions()` share one `VALID` list so the
|
|
228
259
|
two paths can't drift.
|
|
229
260
|
- **`transitions(current, proposed, view) -> { allow, reason? }`** — the
|
|
230
|
-
*transition* gate. Runs on **
|
|
231
|
-
|
|
232
|
-
|
|
233
|
-
state-machine legality and the
|
|
234
|
-
|
|
235
|
-
|
|
236
|
-
|
|
261
|
+
*transition* gate. Runs on **every write (create AND update)**. On UPDATE
|
|
262
|
+
`current` is the stored node (or `null` if its file is unparseable); on CREATE
|
|
263
|
+
there is no prior state, so the server passes `current` = the proposed node — a
|
|
264
|
+
create is *not* a transition. This is where state-machine legality and the
|
|
265
|
+
completion-resolver gate live. Frame each rule by what it judges:
|
|
266
|
+
**state-framed** rules read `proposed.status` (the completion-resolver gate, a
|
|
267
|
+
quorum policy) and so apply to a node's status however it was reached —
|
|
268
|
+
including a BORN-terminal create, so a task cannot be created `done` nor an
|
|
269
|
+
issue `resolved` without a resolver
|
|
270
|
+
(issue-spor-node-create-ungated-for-completion-resolver-gate); **change-framed**
|
|
271
|
+
rules read `current.status !== proposed.status` (status-change-requires-author,
|
|
272
|
+
no-reopen) and, seeing `current === proposed` on a create, correctly pass.
|
|
273
|
+
`proposed` is the incoming node, and `view` is a read-only join the server
|
|
274
|
+
computes for the gate:
|
|
237
275
|
- `view.targets[id]` — `{ exists, type, status, superseded }` for each node
|
|
238
276
|
this one points an edge at (outbound);
|
|
239
277
|
- `view.resolvers` — live **inbound** `resolves`/`answers` edges pointing at
|
|
@@ -304,8 +342,10 @@ Escalation nodes track a customer-facing incident from raise to close.
|
|
|
304
342
|
`severity` is a free-form frontmatter field this schema makes mandatory and
|
|
305
343
|
constrains in `validate()`, alongside status-vocabulary membership (the door
|
|
306
344
|
runs on create AND update, so the two paths agree on the enum); the close-time
|
|
307
|
-
resolver gate
|
|
308
|
-
|
|
345
|
+
resolver gate lives in `transitions()`, which also runs on create (with `current`
|
|
346
|
+
= the proposed node). Because that gate is *state-framed* — it reads
|
|
347
|
+
`proposed.status` — it stops a born-`closed` escalation exactly as it stops a
|
|
348
|
+
close transition. Both are the same procedural model the seed types use — there is
|
|
309
349
|
no declarative field or status enum to fill in.
|
|
310
350
|
|
|
311
351
|
```json
|
|
@@ -319,8 +359,8 @@ no declarative field or status enum to fill in.
|
|
|
319
359
|
|
|
320
360
|
```js
|
|
321
361
|
// The status vocabulary, shared by validate() (membership; create AND update)
|
|
322
|
-
// and transitions() (the close-time gate;
|
|
323
|
-
// on the enum (issue-spor-node-create-bypasses-status-vocabulary).
|
|
362
|
+
// and transitions() (the close-time gate; runs on every write) so the two paths
|
|
363
|
+
// agree on the enum (issue-spor-node-create-bypasses-status-vocabulary).
|
|
324
364
|
const VALID = ["open", "mitigated", "closed"];
|
|
325
365
|
|
|
326
366
|
// validate(node) — runs at the door on EVERY write (create and update). Returns
|
|
@@ -345,10 +385,12 @@ export function validate(node) {
|
|
|
345
385
|
return errors;
|
|
346
386
|
}
|
|
347
387
|
|
|
348
|
-
// transitions(current, proposed, view) — runs on
|
|
349
|
-
// { allow, reason? }.
|
|
350
|
-
//
|
|
351
|
-
// (
|
|
388
|
+
// transitions(current, proposed, view) — runs on every write; returns
|
|
389
|
+
// { allow, reason? }. On create there is no prior state, so `current` is the
|
|
390
|
+
// proposed node (a create is not a transition). Gates the *transition*, not
|
|
391
|
+
// membership (validate() owns that): closing requires a durable outcome on the
|
|
392
|
+
// graph, and because the check is state-framed (it reads proposed.status) it
|
|
393
|
+
// also stops a born-`closed` create. Empty status (status-less = live) is allowed.
|
|
352
394
|
export function transitions(current, proposed, view) {
|
|
353
395
|
const next = ((proposed && proposed.status) || "").toLowerCase();
|
|
354
396
|
if (next === "") return { allow: true };
|
package/QUEUE.md
CHANGED
|
@@ -416,14 +416,20 @@ signals via its schema's `queueSignals()`:
|
|
|
416
416
|
gate**, which has nothing to resolve, so each held pass re-raises front and
|
|
417
417
|
re-surfaces it. The **held-task self-limit** breaks that loop
|
|
418
418
|
(task-spor-queue-front-loop-self-limit-on-held-tasks): an OPEN task carrying
|
|
419
|
-
an inbound non-resolving outcome (an artifact/decision
|
|
420
|
-
|
|
421
|
-
|
|
422
|
-
|
|
423
|
-
|
|
424
|
-
|
|
425
|
-
|
|
426
|
-
`
|
|
419
|
+
an inbound non-resolving outcome (an artifact/decision **work product**) with
|
|
420
|
+
no live resolving edge and no live blocker has its front damped to 0 in the
|
|
421
|
+
score and its suggestion flipped `do → triage` ("resolve, gate with
|
|
422
|
+
blocked-by, set wake, or abandon") — the same posture staleness takes (flip
|
|
423
|
+
the suggestion, don't boost the score). Two guards keep that flip OFF ready,
|
|
424
|
+
never-started work (task-spor-queue-held-guard-residual-reference-and-priority-
|
|
425
|
+
front): the inbound outcome must be a real work product — a bare
|
|
426
|
+
`relates-to`/`derived-from`/`mentions` REFERENCE (a prior-art citation, an
|
|
427
|
+
"informed by") does not count (fix a), so the surviving outcome edge is
|
|
428
|
+
`decided-in`; and front must clear a small floor (a lone create + a `priority:`
|
|
429
|
+
bump is metadata, not churn — fix b). `signals.front` keeps the raw count so a
|
|
430
|
+
`queue-policy` rank() can re-weight. The read-time twin is the `schema-task`
|
|
431
|
+
`get()` hook, which rides a `held` note along on `get_node` (it shares the
|
|
432
|
+
reference-edge narrowing; it has no `front`, so the floor has no twin there).
|
|
427
433
|
- **staleness** — anchors superseded or gone; high staleness suggests
|
|
428
434
|
closing, not doing.
|
|
429
435
|
- **cold_neighbors** — the count of the node's traversable neighbors whose
|
|
@@ -557,7 +563,8 @@ non-queueable node edged to a superseded target — its own `supersedes`
|
|
|
557
563
|
edges excluded) and **cold-work** (`in-progress` with no neighborhood
|
|
558
564
|
activity over 14 days). Stale *tasks*, capture-pending nodes, and proposed
|
|
559
565
|
schemas already self-surface in the queue, so no duplicate findings are
|
|
560
|
-
filed for them. Trigger: `POST /v1/gardener` on demand
|
|
566
|
+
filed for them. Trigger: `POST /v1/gardener` on demand (the `spor admin
|
|
567
|
+
gardener` CLI verb is the shell front-door), or
|
|
561
568
|
`SPOR_GARDENER_MS` (a server-side env var) for
|
|
562
569
|
an in-process interval (off by default — the schedule is ops' choice). Deferred: "done but contradicted" (needs
|
|
563
570
|
git-history analysis of resolving artifacts).
|
|
@@ -639,7 +646,11 @@ the structural truth:
|
|
|
639
646
|
min(log₂(1+front), 5) + log₂(1+heat) + age/30 (capped) + neededBy urgency (0-5)`, with staleness ≥ 0.5 flipping the item's suggestion to
|
|
640
647
|
"close" and the held-task self-limit flipping it to "triage" (front damped
|
|
641
648
|
to 0) for an open task with a recorded non-resolving outcome but no resolver
|
|
642
|
-
and no blocker (task-spor-queue-front-loop-self-limit-on-held-tasks)
|
|
649
|
+
and no blocker (task-spor-queue-front-loop-self-limit-on-held-tasks) — where
|
|
650
|
+
the outcome must be a real work product (not a bare relates-to/derived-from/
|
|
651
|
+
mentions reference) and the front must clear a two-write floor, so a referenced
|
|
652
|
+
artifact plus a lone priority bump can't flip ready work
|
|
653
|
+
(task-spor-queue-held-guard-residual-reference-and-priority-front). The
|
|
643
654
|
`needed_by: YYYY-MM-DD` deadline term is the inverse of
|
|
644
655
|
`wake`: where `wake` hides a node until its date, `needed_by` keeps it
|
|
645
656
|
visible from creation and ramps its score linearly over a 30-day window to
|
package/README.md
CHANGED
|
@@ -317,10 +317,25 @@ repo to see whether it's active.
|
|
|
317
317
|
"exclude": ["personal-blog"], // drop these from ranking entirely
|
|
318
318
|
"boost": { "spor": 1.5 } // favor a project's nodes in ranking
|
|
319
319
|
}
|
|
320
|
+
},
|
|
321
|
+
"briefs": { // path-scoped sub-briefs for a monorepo:
|
|
322
|
+
"auth/": "brief-myrepo-auth", // each subtree -> its own brief node id
|
|
323
|
+
"frontend-router/": "brief-myrepo-frontend-router"
|
|
320
324
|
}
|
|
321
325
|
}
|
|
322
326
|
```
|
|
323
327
|
|
|
328
|
+
**Path-scoped briefs (monorepos).** A repo split into separately-owned subtrees
|
|
329
|
+
can declare one brief per subtree in `briefs` — a relative-path → brief-id map.
|
|
330
|
+
At session start the nearest-ancestor subtree containing your cwd is the *active
|
|
331
|
+
area*: its brief is injected alongside the repo brief, while the sibling areas
|
|
332
|
+
are surfaced as a one-line discovery list (`/spor:brief <id>` to open one)
|
|
333
|
+
without injecting their bodies. A session in no declared subtree (e.g. the repo
|
|
334
|
+
root) just gets the discovery list. This is a routing layer over the existing
|
|
335
|
+
`brief` concept — a covered subtree is an "area", a label on a brief, not a new
|
|
336
|
+
identity type; distilled nodes still stamp the repo. Name siblings
|
|
337
|
+
`brief-<repo>-<area>` so they group.
|
|
338
|
+
|
|
324
339
|
Other recognized keys mirror their env var: `server`, `token`, `home`,
|
|
325
340
|
`nodes`, `mode` (`auto`/`local`/`remote`/`off`), and the `distill`, `nudge`,
|
|
326
341
|
and `inferCommits` groups. `spor validate` prints config warnings (an unknown
|
|
@@ -368,6 +383,16 @@ lines from `journal/remote.log` and `journal/distill.log`. When captures
|
|
|
368
383
|
have been stranded (a dead-letter pile-up or a deep outbox), session-start
|
|
369
384
|
also surfaces a one-line nudge alongside its status banner pointing you here.
|
|
370
385
|
|
|
386
|
+
When the outbox has stranded captures, flush it on demand with:
|
|
387
|
+
|
|
388
|
+
```bash
|
|
389
|
+
spor drain
|
|
390
|
+
```
|
|
391
|
+
|
|
392
|
+
It replays each spooled capture to the team server (the same drain a session
|
|
393
|
+
runs at start), so a pure-CLI user who never opens a session can still ship
|
|
394
|
+
them. A successful remote `spor add` drains opportunistically too.
|
|
395
|
+
|
|
371
396
|
## Pointers
|
|
372
397
|
|
|
373
398
|
- [GRAPH.md](GRAPH.md) — the node and edge format: what a node file looks
|
package/agents/backfill.md
CHANGED
|
@@ -21,7 +21,13 @@ Method:
|
|
|
21
21
|
1. Inventory the source first (git log --stat, issue lists, doc indexes) and
|
|
22
22
|
draft the id list BEFORE writing bodies — ids must be predictable so edges
|
|
23
23
|
written in parallel resolve. Check existing nodes to avoid duplicate ids
|
|
24
|
-
and to find edge targets.
|
|
24
|
+
and to find edge targets. When you emit through a **gated** path (a remote
|
|
25
|
+
server's REST/MCP write rather than local file writes), order matters beyond
|
|
26
|
+
id prediction: a born-terminal node — a `done` task, a `resolved` issue — is
|
|
27
|
+
rejected unless its resolving `decision`/`artifact` already exists on the
|
|
28
|
+
graph (the completion-resolver gate, GRAPH.md), so emit each resolver BEFORE
|
|
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.
|
|
25
31
|
2. Aggregate, don't transcribe. One node per durable fact: a decision with its
|
|
26
32
|
why, an issue with its full resolution lineage (found → fixed-in → verified),
|
|
27
33
|
a spec with its current status. NEVER one node per commit; collapse
|