@sporhq/spor 0.22.0 → 0.23.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/.claude-plugin/plugin.json +1 -1
- package/.codex-plugin/plugin.json +1 -1
- package/API.md +33 -12
- package/GRAPH.md +139 -5
- package/QUEUE.md +6 -1
- package/README.md +9 -0
- package/adapters/README.md +8 -4
- package/adapters/codex/README.md +2 -1
- package/adapters/copilot/README.md +2 -1
- package/adapters/gemini/README.md +2 -1
- package/adapters/opencode/README.md +1 -1
- package/bin/spor-hook.js +9 -0
- package/bin/spor.js +824 -264
- package/hooks/hooks.json +12 -0
- package/lib/analytics.js +114 -35
- package/lib/changes.js +6 -3
- package/lib/check.js +18 -6
- package/lib/config.js +98 -26
- package/lib/graph.js +52 -0
- package/lib/history.js +5 -2
- package/lib/kernel/analytics.js +10 -3
- package/lib/kernel/coupling.js +51 -6
- package/lib/kernel/graph.js +165 -44
- package/lib/kernel/program.js +105 -0
- package/lib/kernel/queue.js +27 -11
- package/lib/kernel/registry.js +489 -279
- package/lib/kernel/resolution.js +117 -6
- package/lib/program.js +62 -0
- package/lib/queue.js +6 -10
- package/lib/schema.js +17 -11
- package/lib/seed/schema-artifact.md +115 -4
- package/lib/seed/schema-correction.md +74 -2
- package/lib/seed/schema-decision.md +19 -1
- package/lib/seed/schema-edge-focuses-on.md +29 -0
- package/lib/seed/schema-lens.md +124 -0
- package/lib/seed/schema-register-terminal-status.md +86 -0
- package/lib/shell/atomic-write.js +31 -0
- package/lib/shell/git-exec.js +43 -0
- package/lib/shell/gittime.js +25 -20
- package/package.json +1 -1
- package/prompts/client/distill-local.md +1 -1
- package/scripts/engines/agents-md.js +188 -13
- package/scripts/engines/capture-health.js +9 -3
- package/scripts/engines/digest-worker.js +5 -44
- package/scripts/engines/distill.js +192 -18
- package/scripts/engines/doctor.js +8 -6
- package/scripts/engines/infer-commits.js +1 -1
- package/scripts/engines/link-commits.js +1 -3
- package/scripts/engines/nudge-worker.js +5 -44
- package/scripts/engines/post-tool.js +31 -64
- package/scripts/engines/pre-tool.js +340 -0
- package/scripts/engines/prompt-context.js +15 -52
- package/scripts/engines/session-start.js +5 -3
- package/scripts/engines/util.js +185 -17
- package/skills/brief/SKILL.md +20 -7
- package/skills/correct/SKILL.md +12 -0
- package/skills/defer/SKILL.md +29 -0
- package/skills/spor/SKILL.md +45 -7
- package/skills/spor/references/authoring-schemas.md +27 -7
- package/skills/spor/references/concepts.md +6 -4
|
@@ -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.23.0",
|
|
6
6
|
"author": {
|
|
7
7
|
"name": "losthammer"
|
|
8
8
|
}
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "spor",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.23.0",
|
|
4
4
|
"description": "Maintains a typed, versioned knowledge graph and compiles compact briefings from it: session-start injection, per-prompt relevance digests, capture at discovery, end-of-session distillation, decision queue.",
|
|
5
5
|
"author": {
|
|
6
6
|
"name": "Spor",
|
package/API.md
CHANGED
|
@@ -318,7 +318,7 @@ The decision queue (QUEUE.md §4/§5) — the data answer to "show my queue" /
|
|
|
318
318
|
"<person-id>|me", "limit"?: 20,
|
|
319
319
|
"offset"?: 0 }` → `{ "items": [{id, title, type, status,
|
|
320
320
|
priority, score, signals: {blocking, heat, staleness, age_days}, suggest:
|
|
321
|
-
"do|close", why}], "count": N, "offset": 0, "returned_count": N,
|
|
321
|
+
"do|dispatch|blocked|triage|close|approve", why}], "count": N, "offset": 0, "returned_count": N,
|
|
322
322
|
"total_count": N, "truncated": false, "next_offset": null, "questions": []
|
|
323
323
|
}` — queueable live nodes ranked by the default blend, each with a one-line
|
|
324
324
|
*why*. Items already retired by a live inbound resolves/answers edge are
|
|
@@ -537,7 +537,7 @@ endpoint is the REST twin of a core call:
|
|
|
537
537
|
| `DELETE /v1/me/tokens/{hash-prefix}` | `spor token revoke` | revoke one of the caller's OWN PATs by hash prefix → `{revoked, hash_prefix, oauth_grants_revoked}`; a prefix that isn't one of the caller's is `404` (never another person's token). `403` if unbound. Shares the admin revoke's OAuth-grant cascade-completeness invariant (issue-cc-pat-revoke-cascades-all-oauth-grants) |
|
|
538
538
|
| `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 |
|
|
539
539
|
| `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 |
|
|
540
|
-
| `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 |
|
|
540
|
+
| `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. 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 |
|
|
541
541
|
| `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) |
|
|
542
542
|
| `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` |
|
|
543
543
|
| `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 |
|
|
@@ -554,7 +554,7 @@ endpoint is the REST twin of a core call:
|
|
|
554
554
|
| `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 |
|
|
555
555
|
| `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`) |
|
|
556
556
|
| `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 |
|
|
557
|
-
| `POST /v1/capture` | distill, /spor:defer | `capture` semantics: `{text, context: {project, 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 |
|
|
557
|
+
| `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) |
|
|
558
558
|
| `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 |
|
|
559
559
|
| `POST /v1/corrections` | /spor:correct | `propose_correction` semantics → 201 `{status, id, revision, warnings}` |
|
|
560
560
|
| `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) |
|
|
@@ -564,10 +564,13 @@ endpoint is the REST twin of a core call:
|
|
|
564
564
|
| `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 |
|
|
565
565
|
| `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 gating tree of everything that `blocks` `{id}` transitively, 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 |
|
|
566
566
|
| `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 |
|
|
567
|
-
| `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. 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 |
|
|
567
|
+
| `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 |
|
|
568
568
|
| `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) |
|
|
569
569
|
| `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) |
|
|
570
|
-
| `GET /v1/admin/
|
|
570
|
+
| `GET /v1/admin/people` | tenant-admin console; offboarding / audit | list every person **subject** → `{people: [{id, name, email, roles, is_admin, status, tokens, active_tokens, last_used}], count}`. `is_admin` reflects the `stewards→root` edge (§4, the same per-person check the admin gate itself runs); `tokens`/`active_tokens`/`last_used` summarize the subject's PATs from the same store `GET /v1/admin/tokens` below lists. `status` is the node's own frontmatter status, stamped `active` at creation — offboarding (below) revokes access without touching it, so it is not an offboarded/active signal today. Admin-only (§4) |
|
|
571
|
+
| `POST /v1/admin/people` `{name, email, id?, roles?, invite?, connection_id?, org?}` | onboarding; the on-box `mint-person.js` CLI, the tenant-admin console | create the canonical person **subject** a PAT or provider callback binds to (task-spor-pilot-person-node-onboarding) — `POST /v1/admin/tokens` below and a provider (IdP) callback both refuse to conjure one, so this is the deliberate step onboarding a new teammate needs FIRST → 201 `{id, name, email, roles, revision, org_invitation?}`. `name`/`email` are required, single-line, ≤200 chars; `id` defaults to an opaque, deterministic email-hash when omitted (never the mutable display name) and must be a `person-<slug>` kebab id; `roles` is an optional array of ≤20 kebab-case role slugs. No `stewards` edge is written — a regular teammate is not an admin (grant that separately, §4). `invite:true` additionally issues a WorkOS Organization invitation for the same email (`connection_id` disambiguates when more than one WorkOS connection is configured; `org` pins the invitation's org, and is honored only on an unbound/self-host server — on a server already bound to one org (the hosted default), a supplied `org` that disagrees with it is `403 forbidden` rather than silently corrected, and omitting it just pins to the bound org) — the invite shape is validated up front so a bad arg never leaves a half-done onboarding, but an invitation failure never rolls back the created person: `org_invitation: {status: "issued"|"failed", ...}` reports either outcome. Admin-only, same `stewards→root` gate as `/v1/admin/tokens`. `409` on a colliding id; `422` invalid |
|
|
572
|
+
| `DELETE /v1/admin/people/{id}` | offboarding | **offboard** a member: revoke every PAT and OAuth grant bound to the subject (the same cascade a directory deprovision runs) → `{offboarded, tokens_revoked, oauth_grants_revoked}`. Does **not** delete the person node — it stays the canonical subject every attribution reference points at, so removal here means "revoke access", not "erase the record". Self-offboarding is refused (`422`) so an admin can't lock themselves out; a malformed or non-`person-` id is also `422` (checked before lookup). Admin-only (§4); `404` unknown id |
|
|
573
|
+
| `GET /v1/admin/tokens` | offboarding / audit; `spor admin token list` (= `spor token list --all`) | list PATs → `{tokens: [{hash_prefix, person, name, email, created, expires, expired, last_used}], count}` — never plaintext, never full hashes. Admin-only (§4). The team-wide view; the caller's own PATs are the self-serve `GET /v1/me/tokens` above |
|
|
571
574
|
| `POST /v1/admin/tokens` `{person, expires?}` | onboarding; `spor invite` | 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. Binds someone *else* (onboarding); the self-serve mint is `POST /v1/me/tokens` above |
|
|
572
575
|
| `DELETE /v1/admin/tokens/{hash-prefix}` | offboarding / rotation; `spor admin token revoke` (= `spor token revoke --all`) | revoke the single PAT matching the hash prefix (≥8 hex chars; an ambiguous prefix is a 409) → `{revoked, hash_prefix}`. Admin-only. Revokes ANY token; the self-serve revoke (the caller's own) is `DELETE /v1/me/tokens/{hash-prefix}` above |
|
|
573
576
|
| `GET /v1/agents` | `spor agent list` | list the agents the caller **owns** → `{agents: [{id, label, owner, spiffe, pubkey, status}], count}`; `?all=1` lists every agent (admin-only) |
|
|
@@ -624,6 +627,18 @@ anything with a token.
|
|
|
624
627
|
--admin --person <id>` (it writes that `stewards` edge, creating the person
|
|
625
628
|
node from `--name`/`--email` if needed). Hand-editing the token file stays
|
|
626
629
|
as the break-glass path.
|
|
630
|
+
- **Person subject lifecycle admin** (task-spor-pilot-person-node-onboarding).
|
|
631
|
+
A person node is the canonical subject a PAT or provider callback binds to,
|
|
632
|
+
and `POST /v1/admin/tokens` above refuses to conjure one — so onboarding a
|
|
633
|
+
new teammate is two admin calls, no server-box shell required:
|
|
634
|
+
`POST /v1/admin/people` (§3) creates the subject, then
|
|
635
|
+
`POST /v1/admin/tokens` binds its PAT. `GET /v1/admin/people` lists every
|
|
636
|
+
subject with its admin-vs-teammate flag and a PAT summary;
|
|
637
|
+
`DELETE /v1/admin/people/{id}` offboards one — revoking every PAT and OAuth
|
|
638
|
+
grant bound to it, but never deleting the node itself, since it remains the
|
|
639
|
+
subject every attribution reference points at — and refuses to offboard
|
|
640
|
+
the caller's own account. Same `stewards→root` gate as token lifecycle
|
|
641
|
+
above.
|
|
627
642
|
- **Agent-scoped session tokens.** A person mints a short-lived, per-session
|
|
628
643
|
token for an `agent` they **own** through the self-serve
|
|
629
644
|
`POST /v1/agents/{id}/token` (§3) — authorized by ownership (the agent's
|
|
@@ -639,11 +654,15 @@ anything with a token.
|
|
|
639
654
|
`spor agent use <agent-id>`, or `SPOR_DISPATCH_AGENT`), which `spor dispatch
|
|
640
655
|
--as <agent-id>` overrides for a single run. The `<agent-id>` is the agent's
|
|
641
656
|
`agent-`-prefixed NODE id (what `spor agent list` prints), **not** its bare
|
|
642
|
-
label — the token endpoint requires the prefix, so
|
|
643
|
-
|
|
644
|
-
|
|
645
|
-
|
|
646
|
-
|
|
657
|
+
label — the token endpoint requires the prefix, so `dispatch --as` rejects a
|
|
658
|
+
prefix-less id with a `did you mean agent-…?` hint rather than persist one
|
|
659
|
+
every dispatch would 422 on. `spor agent use` goes one step further: a
|
|
660
|
+
prefix-less argument is first resolved against the caller's own agents (their
|
|
661
|
+
label or a plain `agent-<label>` guess) and normalized to the canonical id
|
|
662
|
+
before it's written — a re-typed label just works, and only an id that
|
|
663
|
+
matches none of the caller's agents falls back to the same prefix-hint error.
|
|
664
|
+
(Not to be confused with `spor dispatch --agent`, the unrelated `claude
|
|
665
|
+
--agent` harness passthrough.) The
|
|
647
666
|
token is minted **session-deferred** and bound to the real run session AFTER
|
|
648
667
|
launch (dec-spor-dispatch-bg-session-late-bind): `claude --bg` ignores
|
|
649
668
|
`--session-id` and self-allocates its session, so dispatch reads the real one
|
|
@@ -710,8 +729,10 @@ anything with a token.
|
|
|
710
729
|
mints a signed, expiring, **read-only** ticket carrying `{lens_id,
|
|
711
730
|
sharer_person_id, exp}` — the credential a *shared* view link carries instead
|
|
712
731
|
of the sharer's PAT. It binds `$viewer` to the recorded sharer (rendered with
|
|
713
|
-
a "Viewing as" banner), is honored only on `GET /v1/lens/{id}/render
|
|
714
|
-
|
|
732
|
+
a "Viewing as" banner), is honored only on `GET /v1/lens/{id}/render`
|
|
733
|
+
(directly, or via the app host's `GET /views/{id}` ticket-to-cookie
|
|
734
|
+
exchange), and can never authorize a write. Stateless (HMAC over a
|
|
735
|
+
server-held key — no
|
|
715
736
|
revocation list, expiry is the bound); per-recipient/revocable grants are a
|
|
716
737
|
later fine-grained-authz refinement.
|
|
717
738
|
|
package/GRAPH.md
CHANGED
|
@@ -106,7 +106,7 @@ Rules:
|
|
|
106
106
|
| task | `task-` | active or planned work (status `open`/`active`/`done`/`abandoned`, gated; `done` requires a `decision`/`artifact` resolver — see below) |
|
|
107
107
|
| 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) |
|
|
108
108
|
| incident | `inc-` | something that went wrong in operation (queueable: live incidents join the decision queue) |
|
|
109
|
-
| artifact | `spec-`, `art-` | a document, spec, module, or build product worth referencing; when it represents a change
|
|
109
|
+
| artifact | `spec-`, `art-` | a document, spec, module, or build product worth referencing; status `in-review`/`approved`/`merged`/`released` (the delivery stages, when it represents a change) or `done`/`active` (a finished/living doc), gated at the `validate()` door; or none, a plain reference doc — see below |
|
|
110
110
|
| norm | `norm-` | a standing convention or constraint (rides along in every project-relevant compile) |
|
|
111
111
|
| briefing | `brief-` | a compiled briefing (output of this system; never traversed) |
|
|
112
112
|
| correction | `corr-` | standing fix to a briefing: pin/exclude/guidance (never traversed) |
|
|
@@ -120,6 +120,7 @@ Rules:
|
|
|
120
120
|
| finding | `find-` | a gardener observation about another node, filed as a queue item (QUEUE.md §6) |
|
|
121
121
|
| repo | `repo-` | durable git-repo identity: slug aliases + repo fingerprints; heals renames at read time (below) |
|
|
122
122
|
| project | `proj-` | a grouping above repos; owns its member repos via inbound `grouped-under` edges (below) |
|
|
123
|
+
| 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") |
|
|
123
124
|
|
|
124
125
|
## Completing work needs a durable why (the resolver gate)
|
|
125
126
|
|
|
@@ -161,6 +162,12 @@ a change still in review has not delivered. So:
|
|
|
161
162
|
- An `artifact` representing a change may carry a delivery-stage status:
|
|
162
163
|
`in-review`/`approved` are **non-resolving** (they keep the resolved target
|
|
163
164
|
live); `merged`/`released`, and any other/empty status, are **resolving**.
|
|
165
|
+
The full artifact vocabulary is those four stages plus `done` (a finished
|
|
166
|
+
doc/spec/build product that is not a change) and `active` (a living/current
|
|
167
|
+
doc), or none — enforced at the schema's `validate()` door on create and
|
|
168
|
+
update (issue-spor-off-vocab-artifact-statuses). There is no `transitions()`
|
|
169
|
+
on this type: the stages are not a state machine (a change may be born
|
|
170
|
+
`merged`), so only membership is gated, never order.
|
|
164
171
|
Plus flat scalar metadata the regex frontmatter parser already supports —
|
|
165
172
|
`delivery_ref` (PR url/commit/tag), `delivery_source` (e.g. `github`),
|
|
166
173
|
`size`, `labels`, `paths` (comma-scalars). The shape is source-blind, so a
|
|
@@ -233,13 +240,19 @@ separately, but never shows a complete custom type in one piece.
|
|
|
233
240
|
|
|
234
241
|
**The constraint model is procedural, not declarative.** A schema's `json`
|
|
235
242
|
payload declares only *registry knobs* — `node_type`, `prefix`, `queueable`,
|
|
236
|
-
`traversable`, `always_on`, `capturable`, an edge `weight`, and the
|
|
243
|
+
`traversable`, `always_on`, `capturable`, an edge `weight`, and the three status
|
|
237
244
|
partitions: `status.non_resolving` (resolver semantics — whether a node in this
|
|
238
|
-
status retires the targets it points at)
|
|
245
|
+
status retires the targets it points at), `status.terminal` (own-lifecycle
|
|
239
246
|
completion — the statuses in which a node of this type is *done*, unioned with the
|
|
240
247
|
kernel's legacy set and read by work-analytics so a schema-only terminal status
|
|
241
248
|
like decision `settled` counts as completed,
|
|
242
|
-
issue-spor-analytics-completion-ignores-schema-terminal-status)
|
|
249
|
+
issue-spor-analytics-completion-ignores-schema-terminal-status), and
|
|
250
|
+
`status.inert` (queue-liveness-dead — the per-type overlay the type-aware
|
|
251
|
+
`isTerminalStatus(status, type, graph)` unions with the type-blind
|
|
252
|
+
`terminal-status` register below; a schema that declares no `inert` set
|
|
253
|
+
INHERITS its `terminal` set, so only a schema whose two sets genuinely differ
|
|
254
|
+
declares it — the seed decision schema pins `settled` terminal but NOT inert,
|
|
255
|
+
dec-spor-status-inert-third-partition). There is **no
|
|
243
256
|
declarative field list and no status enum.** Custom fields are free-form: any flat frontmatter key the
|
|
244
257
|
regex parser accepts (simple `key: value` scalars, YAML-folded multi-line
|
|
245
258
|
values, `pin:`/`exclude:` inline lists, `- {type: X, to: Y}` edges — and nothing
|
|
@@ -483,6 +496,29 @@ agreeing invariant suppresses the untouched heuristic, and a disagreement
|
|
|
483
496
|
reports even when both files were touched. A half-declared or malformed pair
|
|
484
497
|
is inert (validate warns).
|
|
485
498
|
|
|
499
|
+
An in-repo tracked symlink (`frontend -> packages/web`) has two valid
|
|
500
|
+
repo-relative spellings for the same file, and the matcher tests a glob
|
|
501
|
+
against every candidate spelling it is HANDED
|
|
502
|
+
(task-spor-coupling-matcher-symlink-alias) — but deriving those candidates is
|
|
503
|
+
one-way: it can turn an alias spelling into its git-resolved canonical form,
|
|
504
|
+
never the reverse. A coupling glob authored against an alias
|
|
505
|
+
(`couples_when: [frontend/**]`) therefore still misses an edit reported only
|
|
506
|
+
under its canonical path (`packages/web/app.js`) — the environment may hand
|
|
507
|
+
the matcher an already-resolved path with no alias spelling left to derive
|
|
508
|
+
from — because discovering which alias points at a given canonical path would
|
|
509
|
+
need a filesystem-wide symlink scan, rejected as too expensive for the
|
|
510
|
+
edit-time hot path (dec-spor-dismiss-reverse-symlink-path-lookup,
|
|
511
|
+
issue-spor-coupling-matcher-reverse-symlink-gap). The declared fix is a
|
|
512
|
+
**coupling alias map**: `.spor.json`'s `coupling.aliases`, a flat `{ "<alias
|
|
513
|
+
prefix>": "<canonical prefix>" }` object (repo-root-relative on both sides,
|
|
514
|
+
e.g. `{ "frontend": "packages/web" }`). Every declared entry is expanded in
|
|
515
|
+
BOTH directions at match time, at zero runtime cost (no scanning) — a path
|
|
516
|
+
under either side also produces the spelling under the other side, for both
|
|
517
|
+
`couples_when` triggers and `couples_also` targets. **Declaring nothing is
|
|
518
|
+
the default posture**, and it keeps the one-way limitation above: only
|
|
519
|
+
author coupling globs against the canonical (git-resolved) spelling of a
|
|
520
|
+
symlinked subtree unless its alias is declared in `coupling.aliases`.
|
|
521
|
+
|
|
486
522
|
Because a norm rides along with no relevance gate and the team trust model lets
|
|
487
523
|
every writer author one, the briefing renderer treats norm bodies as an
|
|
488
524
|
**injection surface** (issue-cc-norm-always-on-injection): each is quoted as
|
|
@@ -888,6 +924,77 @@ thread 4). The seed set is small — `shell`, `prod-creds`, `browser`, `network`
|
|
|
888
924
|
`human`, `filesystem-write`, `paid-api`. `human` is unsatisfiable by any agent:
|
|
889
925
|
assign that work to a person.
|
|
890
926
|
|
|
927
|
+
### The `terminal-status` register
|
|
928
|
+
|
|
929
|
+
A second registry-declared enum (seed: `schema-register-terminal-status`,
|
|
930
|
+
`register: terminal-status`) names the **type-blind** status vocabulary that
|
|
931
|
+
retires ANY node from queue liveness (`lib/kernel/queue.js` `isLive`), briefing
|
|
932
|
+
"live work" surfacing (`lib/kernel/graph.js` status tag/warning), and
|
|
933
|
+
coupling-norm matching (`lib/kernel/coupling.js`) — the single source those two
|
|
934
|
+
kernel modules read (`graph.registry.registerClasses("terminal-status")`)
|
|
935
|
+
instead of two separately hardcoded, previously-divergent tables
|
|
936
|
+
(issue-spor-coupling-resolution-terminal-status-divergence). The seed set is
|
|
937
|
+
`abandoned`, `answered`, `closed`, `completed`, `deprecated`, `dismissed`,
|
|
938
|
+
`done`, `merged`, `rejected`, `resolved`, `retired`, `superseded` — only
|
|
939
|
+
genuinely universal completion words; a type-scoped status belongs in its
|
|
940
|
+
owning schema's `status.inert`/`status.terminal` instead (artifact `released`
|
|
941
|
+
lives there, so a non-artifact marked `released` stays live,
|
|
942
|
+
task-spor-terminal-status-type-aware-migration).
|
|
943
|
+
|
|
944
|
+
The full liveness check is **type-aware**
|
|
945
|
+
(dec-spor-status-inert-third-partition): `isTerminalStatus(status, type,
|
|
946
|
+
graph)` unions this register with the registry's per-type `status.inert`
|
|
947
|
+
overlay (declared, or inherited from `status.terminal`). The union is one-way
|
|
948
|
+
additive — a per-type declaration scopes a status to its own type but can
|
|
949
|
+
never remove a universal word. The register is **DISTINCT** from the
|
|
950
|
+
per-node-schema partitions above: a decision's `settled` status is terminal
|
|
951
|
+
for its OWN lifecycle (`status.terminal`, read by work-analytics) but is
|
|
952
|
+
deliberately absent from this register AND from the decision schema's
|
|
953
|
+
declared `inert`, so a settled decision keeps surfacing as live guidance in
|
|
954
|
+
queues and briefings (dec-spor-decision-lifecycle-surfacing).
|
|
955
|
+
`lib/kernel/coupling.js` scans node files in the hook tool loop without a
|
|
956
|
+
loaded graph/registry, so it (and any other graph-less caller) reads a
|
|
957
|
+
hardcoded fallback that reproduces this register's seed classes
|
|
958
|
+
byte-identically; a graph-resident override — and every per-type overlay —
|
|
959
|
+
only reaches callers that pass a loaded `graph`.
|
|
960
|
+
|
|
961
|
+
## Lenses
|
|
962
|
+
|
|
963
|
+
A saved view over the graph is itself a graph node (dec-lenses-as-nodes), so it
|
|
964
|
+
is versioned, attributed, shareable, and forkable by copying the node — the same
|
|
965
|
+
inversion the queue makes for prioritization, made for presentation. The seed
|
|
966
|
+
ships the vocabulary (`schema-lens`, `schema-edge-focuses-on`) so a fresh graph
|
|
967
|
+
can author views without importing a schema first; the runner that executes them
|
|
968
|
+
is a server surface (`render_lens`, API.md §2).
|
|
969
|
+
|
|
970
|
+
A lens body carries fenced blocks:
|
|
971
|
+
|
|
972
|
+
- `## query` — declarative select/traverse/group/sort, JSON. Required.
|
|
973
|
+
- `## render` — builtin renderer config, JSON. `as: custom` requires a `##
|
|
974
|
+
custom` block.
|
|
975
|
+
- `## custom` — an optional js escape hatch, executed in the same
|
|
976
|
+
no-clock/no-randomness sandbox as schema attached code, so a render is a pure
|
|
977
|
+
function of (graph snapshot, lens node, params, now).
|
|
978
|
+
- `## actions` — optional write affordances `{id, label, on?, set, confirm?}`
|
|
979
|
+
(dec-ui-actions-as-transitions): `on` selects which rendered items carry the
|
|
980
|
+
affordance, `set` is a flat object of frontmatter changes (scalars or
|
|
981
|
+
`"$param"` bindings; never `id`/`type`). Invoking one is exactly one
|
|
982
|
+
revision-checked node update through the ordinary write path, arbitrated
|
|
983
|
+
fail-closed by the TARGET schema's `transitions()` gate. The lens declares
|
|
984
|
+
intent, the renderer surfaces it, the registry decides legality — only trusted
|
|
985
|
+
renderer hosts turn actions into controls, and the `## custom` sandbox never
|
|
986
|
+
sees them.
|
|
987
|
+
|
|
988
|
+
A lens is parameterized by an edge, not config: `{type: focuses-on, to: <node>}`
|
|
989
|
+
points it at the node it watches, and the runner resolves `"$focus"` in the query
|
|
990
|
+
from that edge when no runtime parameter is given. Re-pointing a view is then an
|
|
991
|
+
edge edit, and "which lenses watch this node" is graph traversal.
|
|
992
|
+
|
|
993
|
+
`traversable: false` keeps lenses out of compiler lineage walks — a lens says
|
|
994
|
+
what someone wants to look at, not what the graph knows. `capturable: false` for
|
|
995
|
+
the reason briefing and correction opt out: a lens is authored deliberately
|
|
996
|
+
against a query language, never drafted from a capture or a distilled transcript.
|
|
997
|
+
|
|
891
998
|
## Edge types and traversal weights
|
|
892
999
|
|
|
893
1000
|
| edge | weight | meaning |
|
|
@@ -912,6 +1019,7 @@ assign that work to a person.
|
|
|
912
1019
|
| `uses-profile` | 0.3 | this agent's default profile (the runtime+capability bundle it dispatches under); structural config binding, overridable per assignment/dispatch |
|
|
913
1020
|
| `routed-to` | 0.3 | a question routed to this person for answering |
|
|
914
1021
|
| `review-requested` | 0.3 | a review of this node is requested of this person (pending) — surfaces in their queue |
|
|
1022
|
+
| `focuses-on` | 0.2 | this lens is parameterized on that node (see "Lenses"); a view watching a node says little about the node's own lineage |
|
|
915
1023
|
| `compiled-for` | — | briefing → its task/query (provenance only) |
|
|
916
1024
|
| `shaped-by` | — | briefing → corrections applied (provenance only) |
|
|
917
1025
|
|
|
@@ -947,6 +1055,15 @@ is a single-org-graph relevance-topology fix — shared vocabulary ("auth",
|
|
|
947
1055
|
"deploy", "migration") otherwise dilutes the gate across teams. A project-blind
|
|
948
1056
|
compile (no `project`) ranks every node equally, exactly as before.
|
|
949
1057
|
|
|
1058
|
+
A node whose `authored_via` is `capture`, `distill`, or `gardener` — written
|
|
1059
|
+
with no human review at write time — is labeled `machine·<via>` (e.g.
|
|
1060
|
+
`machine·capture`) in every compiled digest/briefing line, the same
|
|
1061
|
+
machine-vs-human taxonomy `spor changes` already surfaces
|
|
1062
|
+
(task-cc-digest-render-authorship-marker). Without it a Haiku-distilled
|
|
1063
|
+
capture rendered typographically identical to a human-reviewed decision in
|
|
1064
|
+
the ambient session-start context; `mcp`/`rest`/`dispatch` writes and nodes
|
|
1065
|
+
with no `authored_via` at all render exactly as before (unmarked).
|
|
1066
|
+
|
|
950
1067
|
A seed (the compile root, or each query-mode content match) always contributes
|
|
951
1068
|
its **direct 1-hop lineage** to the structural arm, even when score-decay would
|
|
952
1069
|
push a low-weight edge under the traversal threshold
|
|
@@ -986,10 +1103,27 @@ Free-text guidance, injected verbatim into the compile for the target.
|
|
|
986
1103
|
how humans debug the context instead of the model: fix it once, it applies to
|
|
987
1104
|
every future compile.
|
|
988
1105
|
|
|
1106
|
+
**Lifecycle** (`status`, 2026.07.15.1, issue-spor-corrections-no-applied-
|
|
1107
|
+
lifecycle): `active` (or no `status` at all) is the default and means the
|
|
1108
|
+
guidance is still standing — it keeps injecting at every in-scope compile.
|
|
1109
|
+
`applied` means a recompile already absorbed the correction's guidance into
|
|
1110
|
+
the target's briefing body, so it is retired and stops injecting — otherwise
|
|
1111
|
+
an absorbed correction is dead weight that keeps re-injecting into every
|
|
1112
|
+
future compile/serve of its target forever. Both the client compile
|
|
1113
|
+
(`correctionInScope`/`corrections` in `lib/kernel/graph.js`) and the server's
|
|
1114
|
+
serve-time gate (`correctionsForBriefing`/`applyBriefingCorrections`) filter
|
|
1115
|
+
out non-`active` corrections — keep the two in sync, held-guard-style. A
|
|
1116
|
+
node-targeted correction (`target: <node-id>`, not `global`/`project:<slug>`)
|
|
1117
|
+
is flipped to `applied` by the recompile flow that absorbs it (`/spor:brief`
|
|
1118
|
+
step 3); `global`/`project:<slug>` corrections are standing, broad-scope
|
|
1119
|
+
guidance and are not auto-retired by any single recompile.
|
|
1120
|
+
|
|
989
1121
|
## Briefing nodes
|
|
990
1122
|
|
|
991
1123
|
Created by the distiller or `/spor:brief`. Carry `derived-from` edges to
|
|
992
|
-
every source node and `shaped-by` edges to
|
|
1124
|
+
every source node and `shaped-by` edges to the corrections that fired for this
|
|
1125
|
+
compile (not necessarily `status: applied` yet — a `global`/`project:<slug>`
|
|
1126
|
+
correction can `shaped-by` many briefings over its lifetime), plus a
|
|
993
1127
|
`version:` integer. On recompile the old version is archived to
|
|
994
1128
|
the graph home's `history/` and the version bumps. `brief-project` is the standing
|
|
995
1129
|
project briefing injected at session start.
|
package/QUEUE.md
CHANGED
|
@@ -551,7 +551,12 @@ later open question or `requires: human` edit flips a stamped item back:
|
|
|
551
551
|
byte-identical when no readiness data exists.
|
|
552
552
|
|
|
553
553
|
Readiness leads the why-line when decisive (`agent-ready: …` / `needs human:
|
|
554
|
-
…`), and
|
|
554
|
+
…`), and an agent-ready item whose disposition would otherwise be `do` upgrades
|
|
555
|
+
its `suggest` to **`dispatch`** (issue-spor-suggest-dispatch-specified-not-emitted)
|
|
556
|
+
— the "hand this to an agent" signal the widget/render surfaces read. Only the
|
|
557
|
+
plain-actionable `do` base upgrades: the triage dispositions
|
|
558
|
+
(`close`/`blocked`/`triage`, and schema `approve`) stay supreme and are never
|
|
559
|
+
overridden by readiness. The envelope gains `counts_by_readiness` ({agent, human, untriaged},
|
|
555
560
|
present only when there is readiness signal or a readiness facet was asked
|
|
556
561
|
for) — the headline "how much of my queue can an agent take right now?" A
|
|
557
562
|
**readiness filter** (`rankQueue({readiness})`, a class or array; today on the
|
package/README.md
CHANGED
|
@@ -246,12 +246,21 @@ Templates can use placeholders such as:
|
|
|
246
246
|
{{brief}}
|
|
247
247
|
{{task}}
|
|
248
248
|
{{node}}
|
|
249
|
+
{{id}}
|
|
249
250
|
{{title}}
|
|
251
|
+
{{summary}}
|
|
252
|
+
{{type}}
|
|
253
|
+
{{status}}
|
|
254
|
+
{{date}}
|
|
250
255
|
{{slug}}
|
|
251
256
|
{{dir}}
|
|
252
257
|
{{default}}
|
|
253
258
|
```
|
|
254
259
|
|
|
260
|
+
`{{id}}`, `{{summary}}`, `{{type}}`, `{{status}}`, and `{{date}}` come from the
|
|
261
|
+
dispatched node's own frontmatter fields (blank in free-text or `--backfill`
|
|
262
|
+
dispatch, where there is no target node).
|
|
263
|
+
|
|
255
264
|
## Local mode
|
|
256
265
|
|
|
257
266
|
By default, Spor can run entirely on your machine.
|
package/adapters/README.md
CHANGED
|
@@ -3,10 +3,14 @@
|
|
|
3
3
|
> **Installing:** `spor install <host>` (e.g. `spor install codex`) automates
|
|
4
4
|
> the per-host recipe below — it resolves the `__SPOR_ROOT__` placeholder to
|
|
5
5
|
> your checkout and merges the manifest into the host's config (idempotently;
|
|
6
|
-
> `--scope user|repo`, `--all`, `--print`).
|
|
7
|
-
>
|
|
8
|
-
>
|
|
9
|
-
>
|
|
6
|
+
> `--scope user|repo`, `--all`, `--print`). Add `--mcp` (needs a configured
|
|
7
|
+
> server — `--server`/`--token` or `spor join`) to also auto-write the host's
|
|
8
|
+
> MCP server config (codex/gemini/opencode/copilot — see each README's "MCP:"
|
|
9
|
+
> section for the shape) and run `agents-md` to populate `AGENTS.md`, so one
|
|
10
|
+
> command finishes setup with no manual file edits. The manual steps in each
|
|
11
|
+
> adapter's README remain valid for hand-installs or when you want to see
|
|
12
|
+
> exactly what lands where. Claude Code installs via its own plugin CLI (`spor
|
|
13
|
+
> install claude`), not a file drop.
|
|
10
14
|
|
|
11
15
|
The Spor client is a portable core behind per-host adapters
|
|
12
16
|
(dec-cc-portable-core-adapters):
|
package/adapters/codex/README.md
CHANGED
|
@@ -80,7 +80,8 @@ is just a manifest over `bin/spor-hook.js`.
|
|
|
80
80
|
the distill engine short-circuits on it — so a `codex exec` distiller that
|
|
81
81
|
fires its own Stop hook cannot recurse.
|
|
82
82
|
- MCP: add the Spor server to `~/.codex/config.toml` for on-demand graph
|
|
83
|
-
access (`query_graph`, `capture`, `show_queue`)
|
|
83
|
+
access (`query_graph`, `capture`, `show_queue`) — `spor install codex --mcp`
|
|
84
|
+
writes this automatically:
|
|
84
85
|
|
|
85
86
|
```toml
|
|
86
87
|
[mcp_servers.spor]
|
|
@@ -34,7 +34,8 @@ to skip it; legacy `SUBSTRATE_*` names are still read.
|
|
|
34
34
|
| distill (capture) | `agentStop` + `--debounce 900` | turn-scoped, carries `transcriptPath`; debounced like Codex |
|
|
35
35
|
|
|
36
36
|
For on-demand graph access, add the Spor MCP server to
|
|
37
|
-
`~/.copilot/mcp-config.json
|
|
37
|
+
`~/.copilot/mcp-config.json` (`spor install copilot --mcp` writes this
|
|
38
|
+
automatically):
|
|
38
39
|
|
|
39
40
|
```json
|
|
40
41
|
{ "mcpServers": { "spor": { "type": "http", "url": "https://spor.example.com/mcp", "headers": { "Authorization": "Bearer $SPOR_TOKEN" } } } }
|
|
@@ -53,7 +53,8 @@ export SPOR_NUDGE_CMD='gemini --model gemini-2.5-flash'
|
|
|
53
53
|
to a generic extractor (every nested `.text` string).
|
|
54
54
|
- Hook stdout must be pure JSON on Gemini; `bin/spor-hook.js` already
|
|
55
55
|
discards engine stderr and emits either one JSON object or nothing.
|
|
56
|
-
- For on-demand graph access, add the Spor MCP server to settings
|
|
56
|
+
- For on-demand graph access, add the Spor MCP server to settings
|
|
57
|
+
(`spor install gemini --mcp` writes this automatically):
|
|
57
58
|
|
|
58
59
|
```json
|
|
59
60
|
{ "mcpServers": { "spor": { "httpUrl": "https://spor.example.com/mcp", "headers": { "Authorization": "Bearer $SPOR_TOKEN" } } } }
|
|
@@ -47,7 +47,7 @@ export SPOR_NUDGE_CMD='opencode run "$(cat)"'
|
|
|
47
47
|
- The transcript handed to the distiller is rebuilt from the SDK on each
|
|
48
48
|
idle event, so the debounced distill always sees the full final session.
|
|
49
49
|
- MCP: add the Spor server to `opencode.json` for on-demand graph
|
|
50
|
-
access:
|
|
50
|
+
access (`spor install opencode --mcp` writes this automatically):
|
|
51
51
|
|
|
52
52
|
```json
|
|
53
53
|
{ "mcp": { "spor": { "type": "remote", "url": "https://spor.example.com/mcp", "headers": { "Authorization": "Bearer {env:SPOR_TOKEN}" } } } }
|
package/bin/spor-hook.js
CHANGED
|
@@ -12,6 +12,7 @@
|
|
|
12
12
|
// Usage (from a host's hooks config; see adapters/):
|
|
13
13
|
// spor-hook session-start [--host claude-code|codex|gemini|cursor|copilot|opencode]
|
|
14
14
|
// spor-hook prompt-context [--host ...]
|
|
15
|
+
// spor-hook pre-tool [--host ...]
|
|
15
16
|
// spor-hook post-tool [--host ...]
|
|
16
17
|
// spor-hook distill [--host ...] [--debounce SECONDS]
|
|
17
18
|
// spor-hook agents-md [--cwd DIR] # AGENTS.md floor; no stdin
|
|
@@ -27,6 +28,7 @@ const u = require(path.join(__dirname, "..", "scripts", "engines", "util"));
|
|
|
27
28
|
const ENGINES = {
|
|
28
29
|
"session-start": () => require("../scripts/engines/session-start").sessionStart,
|
|
29
30
|
"prompt-context": () => require("../scripts/engines/prompt-context").promptContext,
|
|
31
|
+
"pre-tool": () => require("../scripts/engines/pre-tool").preTool,
|
|
30
32
|
"post-tool": () => require("../scripts/engines/post-tool").postTool,
|
|
31
33
|
distill: () => require("../scripts/engines/distill").distill,
|
|
32
34
|
};
|
|
@@ -184,6 +186,13 @@ async function main() {
|
|
|
184
186
|
const pend = path.join(graph, "journal", "pending-distill");
|
|
185
187
|
if (!u.ensureDir(pend)) return;
|
|
186
188
|
const pendingFile = path.join(pend, `${session}.json`);
|
|
189
|
+
// Mark the spooled payload as a debounce-approximated firing (turn-scoped
|
|
190
|
+
// quiescence on Codex/Copilot/OpenCode, not a genuine host session-end
|
|
191
|
+
// signal — a mid-session pause can trip it just as easily as a real
|
|
192
|
+
// goodbye) so the SessionEnd lease branch skips it
|
|
193
|
+
// (task-cc-client-sessionend-reserve-hook): reserving/releasing a still-
|
|
194
|
+
// live claim on a false positive would silently strand active work.
|
|
195
|
+
payload.spor_debounced = true;
|
|
187
196
|
try {
|
|
188
197
|
fs.writeFileSync(pendingFile, JSON.stringify(payload));
|
|
189
198
|
} catch {
|