@sporhq/spor 0.1.0 → 0.2.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/API.md +359 -0
- package/GRAPH.md +391 -0
- package/LICENSE +201 -0
- package/NOTICE +13 -0
- package/QUEUE.md +594 -0
- package/README.md +233 -7
- package/TRADEMARKS.md +102 -0
- package/adapters/README.md +58 -0
- package/adapters/codex/README.md +64 -0
- package/adapters/codex/hooks.json +34 -0
- package/adapters/copilot/README.md +40 -0
- package/adapters/copilot/spor.json +14 -0
- package/adapters/cursor/README.md +34 -0
- package/adapters/cursor/hooks.json +14 -0
- package/adapters/gemini/README.md +60 -0
- package/adapters/gemini/gemini-extension.json +5 -0
- package/adapters/gemini/hooks/hooks.json +33 -0
- package/adapters/opencode/README.md +51 -0
- package/adapters/opencode/spor.js +158 -0
- package/agents/backfill.md +41 -0
- package/bin/spor +6 -0
- package/bin/spor-hook +7 -0
- package/bin/spor-hook.cmd +3 -0
- package/bin/spor-hook.js +236 -0
- package/bin/spor.cmd +3 -0
- package/bin/spor.js +985 -0
- package/bin/substrate-hook +6 -0
- package/bin/substrate-hook.cmd +3 -0
- package/bin/substrate-hook.js +5 -0
- package/hooks/hooks.json +51 -0
- package/lib/commit-inference.js +61 -0
- package/lib/compile.js +96 -0
- package/lib/config.js +270 -0
- package/lib/cost.js +180 -0
- package/lib/graph.js +108 -0
- package/lib/kernel/commit-inference.js +337 -0
- package/lib/kernel/graph.js +960 -0
- package/lib/kernel/queue.js +556 -0
- package/lib/kernel/registry.js +441 -0
- package/lib/kernel/resolution.js +71 -0
- package/lib/queue.js +59 -0
- package/lib/registry.js +4 -0
- package/lib/remote.js +66 -0
- package/lib/resolution.js +4 -0
- package/lib/sandbox.js +106 -0
- package/lib/seed/schema-artifact.md +24 -0
- package/lib/seed/schema-briefing.md +25 -0
- package/lib/seed/schema-capture-pending.md +66 -0
- package/lib/seed/schema-correction.md +25 -0
- package/lib/seed/schema-decision.md +51 -0
- package/lib/seed/schema-edge-answers.md +24 -0
- package/lib/seed/schema-edge-assigned.md +22 -0
- package/lib/seed/schema-edge-blocks.md +22 -0
- package/lib/seed/schema-edge-compiled-for.md +21 -0
- package/lib/seed/schema-edge-constrained-by.md +21 -0
- package/lib/seed/schema-edge-decided-in.md +21 -0
- package/lib/seed/schema-edge-derived-from.md +22 -0
- package/lib/seed/schema-edge-governed-by.md +21 -0
- package/lib/seed/schema-edge-grouped-under.md +28 -0
- package/lib/seed/schema-edge-mentions.md +21 -0
- package/lib/seed/schema-edge-performs.md +24 -0
- package/lib/seed/schema-edge-relates-to.md +22 -0
- package/lib/seed/schema-edge-resolves.md +21 -0
- package/lib/seed/schema-edge-routed-to.md +22 -0
- package/lib/seed/schema-edge-shaped-by.md +21 -0
- package/lib/seed/schema-edge-stewards.md +21 -0
- package/lib/seed/schema-edge-supersedes.md +23 -0
- package/lib/seed/schema-edge-triggered-by.md +26 -0
- package/lib/seed/schema-finding.md +23 -0
- package/lib/seed/schema-incident.md +29 -0
- package/lib/seed/schema-issue.md +83 -0
- package/lib/seed/schema-norm.md +24 -0
- package/lib/seed/schema-person.md +37 -0
- package/lib/seed/schema-project.md +48 -0
- package/lib/seed/schema-question.md +58 -0
- package/lib/seed/schema-repo.md +72 -0
- package/lib/seed/schema-task.md +80 -0
- package/lib/seed/schema-workflow-run.md +126 -0
- package/lib/seed/schema-workflow.md +82 -0
- package/lib/shell/files.js +69 -0
- package/lib/shell/home.js +34 -0
- package/lib/validate.js +34 -0
- package/package.json +42 -11
- package/prompts/README.md +36 -0
- package/prompts/client/distill-local.md +36 -0
- package/prompts/client/distill-remote.md +26 -0
- package/prompts/client/nudge.md +21 -0
- package/scripts/distill-gemini.sh +34 -0
- package/scripts/engines/agents-md.js +134 -0
- package/scripts/engines/debounce-watcher.js +66 -0
- package/scripts/engines/distill.js +390 -0
- package/scripts/engines/drain-outbox.js +100 -0
- package/scripts/engines/infer-commits.js +116 -0
- package/scripts/engines/link-commits.js +95 -0
- package/scripts/engines/post-tool.js +261 -0
- package/scripts/engines/prompt-context.js +184 -0
- package/scripts/engines/session-start.js +418 -0
- package/scripts/engines/util.js +631 -0
- package/skills/brief/SKILL.md +64 -0
- package/skills/correct/SKILL.md +91 -0
- package/skills/defer/SKILL.md +105 -0
- package/skills/next/SKILL.md +85 -0
- package/skills/team-graph/SKILL.md +93 -0
- package/workers/shim/README.md +101 -0
- package/workers/shim/shim.js +327 -0
- package/index.js +0 -11
package/API.md
ADDED
|
@@ -0,0 +1,359 @@
|
|
|
1
|
+
# Spor server — public API
|
|
2
|
+
|
|
3
|
+
The contract that adapters, hook engines, and third-party clients program
|
|
4
|
+
against. From a client's perspective the Spor server is one org graph behind
|
|
5
|
+
two doors: a **REST surface** (`/v1/*`, plain HTTPS + JSON) for deterministic
|
|
6
|
+
hook scripts and mechanical writers, and an **MCP surface** (Streamable HTTP
|
|
7
|
+
at `/mcp`) for model-driven clients — Cowork, claude.ai connectors,
|
|
8
|
+
in-session tool calls. Both doors require bearer auth (§4), are thin adapters
|
|
9
|
+
over the same core, and a tool call and its REST twin return byte-identical
|
|
10
|
+
payloads. Companion specs: [GRAPH.md](GRAPH.md) (node/edge format),
|
|
11
|
+
[QUEUE.md](QUEUE.md) (capture, decision queue, schema registry).
|
|
12
|
+
|
|
13
|
+
## 1. Write semantics (both surfaces)
|
|
14
|
+
|
|
15
|
+
Every mutation is validated, attributed, serialized, and committed to the
|
|
16
|
+
graph's git repo. What a client sees:
|
|
17
|
+
|
|
18
|
+
- **Attribution**: the server stamps `author: <identity>` and
|
|
19
|
+
`authored_via: mcp|rest|capture` from the authenticated token — any
|
|
20
|
+
`author:` supplied in the payload is discarded.
|
|
21
|
+
- **Create**: `if_exists: "skip"` → id collision is reported as `skipped`
|
|
22
|
+
(the distiller default); `if_exists: "error"` → id collision is a
|
|
23
|
+
`conflict` error.
|
|
24
|
+
- **Update**: the caller must send `revision` — the git blob SHA of the
|
|
25
|
+
version it read (returned by `get_node`). Mismatch → `conflict` error with
|
|
26
|
+
the current revision; re-read and retry. No silent last-write-wins.
|
|
27
|
+
- **Validation**: id/filename agreement, kebab-case, type prefix, mandatory
|
|
28
|
+
standalone summary, known node type, `date:` format, edge syntax. Failures
|
|
29
|
+
return the validator's error list verbatim so a calling model can
|
|
30
|
+
self-correct. Size limits: body ≤ 8KB, summary ≤ 500 chars, ≤ 40 edges.
|
|
31
|
+
- **Edge normalization**: edge types accept canonical names, registry-declared
|
|
32
|
+
**aliases** (renamed in place), and **inverse labels** (the edge read from
|
|
33
|
+
the target's side — `{blocked-by, to: X}` on N is flipped and written to X
|
|
34
|
+
as `{blocks, to: N}`, reported in `warnings`; the target must exist).
|
|
35
|
+
Unknown edge types beyond that vocabulary are rejected, not defaulted.
|
|
36
|
+
Edges to nonexistent ids are allowed on full puts (they mark nodes worth
|
|
37
|
+
creating).
|
|
38
|
+
- **Schema gating**: the active schema's `transitions()` gate arbitrates
|
|
39
|
+
updates — a denial is `409 transition_denied` with the schema's reason.
|
|
40
|
+
Schema nodes created through the server are forced to `status: proposed`;
|
|
41
|
+
flipping one `proposed → active` requires an identity different from the
|
|
42
|
+
proposal's last author (the self-approval ban).
|
|
43
|
+
- Successful writes return `{status, id, revision, warnings}`
|
|
44
|
+
(`status: created|updated|skipped`).
|
|
45
|
+
|
|
46
|
+
## 2. MCP surface (`/mcp`)
|
|
47
|
+
|
|
48
|
+
Streamable HTTP, implemented with the official SDK. All tool results are
|
|
49
|
+
returned as both human-readable text content and structured JSON.
|
|
50
|
+
|
|
51
|
+
The server advertises **`instructions`** (the SDK initialize result, surfaced
|
|
52
|
+
by clients as an "MCP Server Instructions" block). It frames the eleven tools
|
|
53
|
+
as an **ORIENT → TRAVERSE → COMMIT** loop rather than eleven independent
|
|
54
|
+
verbs, so an assistant can infer a recursive research chain — e.g. `my_queue`
|
|
55
|
+
→ `query_graph` with `root_id` (deepen) → `render_lens` on a lineage lens →
|
|
56
|
+
`put_node`/`capture` the outcome — instead of reconstructing it from per-tool
|
|
57
|
+
descriptions. `query_graph`'s `root_id` is the recursive-deepen move (walk
|
|
58
|
+
neighbor → neighbor); `render_lens` lineage lenses trace why a node exists,
|
|
59
|
+
and `render_lens` with no `lens_id` returns the lens catalog (the discovery
|
|
60
|
+
step before rendering).
|
|
61
|
+
|
|
62
|
+
### `query_graph`
|
|
63
|
+
|
|
64
|
+
The compiler over the wire. Input:
|
|
65
|
+
|
|
66
|
+
```json
|
|
67
|
+
{
|
|
68
|
+
"query": "free text — the task, question, or prompt",
|
|
69
|
+
"root_id": "optional node id; overrides query (root-mode compile)",
|
|
70
|
+
"mode": "digest | full", // default digest
|
|
71
|
+
"min_sim": 0.08 // optional; relevance gate
|
|
72
|
+
}
|
|
73
|
+
```
|
|
74
|
+
|
|
75
|
+
Output: `{ "found": bool, "text": "<digest or full neighborhood>",
|
|
76
|
+
"node_ids": [...], "top_sim": 0.31 }`. `found: false` (gate not met) is a
|
|
77
|
+
**successful empty result**, not an error.
|
|
78
|
+
|
|
79
|
+
### `get_node`
|
|
80
|
+
|
|
81
|
+
Input `{ "id": "dec-..." }` → full raw markdown, parsed frontmatter, and
|
|
82
|
+
`revision` (git blob SHA) for use in updates. Unknown id → not-found error.
|
|
83
|
+
|
|
84
|
+
### `put_node`
|
|
85
|
+
|
|
86
|
+
Input:
|
|
87
|
+
|
|
88
|
+
```json
|
|
89
|
+
{
|
|
90
|
+
"node": "<full markdown file content, frontmatter + body>",
|
|
91
|
+
"if_exists": "skip | error | update", // default error
|
|
92
|
+
"revision": "<blob sha>" // required when update
|
|
93
|
+
}
|
|
94
|
+
```
|
|
95
|
+
|
|
96
|
+
The server parses, validates, normalizes, stamps attribution (§1), writes,
|
|
97
|
+
commits. Output `{ "status": "created|skipped|updated", "id": ...,
|
|
98
|
+
"revision": ..., "warnings": [...] }`. Validation failure returns the
|
|
99
|
+
validator's error list verbatim.
|
|
100
|
+
|
|
101
|
+
The tool description **embeds the registry's edge vocabulary** — every
|
|
102
|
+
canonical edge type with its schema's one-line description (direction is
|
|
103
|
+
written from the source node's perspective) plus the accepted inverse forms.
|
|
104
|
+
Generated from the live registry, so an org schema that adds an edge type
|
|
105
|
+
changes the description without a deploy.
|
|
106
|
+
|
|
107
|
+
### `add_edge`
|
|
108
|
+
|
|
109
|
+
Micro-mutation. Input `{ "id": "<node>", "type": "<edge type>",
|
|
110
|
+
"to": "<target>" }` — accepts canonical, alias, and inverse forms; inverse
|
|
111
|
+
forms are flipped onto the target before writing. No revision echo is needed.
|
|
112
|
+
Output `{ "status": "updated|skipped", "id": <node actually modified>,
|
|
113
|
+
"revision", "warnings" }` (`skipped` = edge already present — the call is
|
|
114
|
+
idempotent). Both nodes must exist. The tool description carries the same
|
|
115
|
+
registry-generated vocabulary as `put_node`.
|
|
116
|
+
|
|
117
|
+
### `set_status`
|
|
118
|
+
|
|
119
|
+
Micro-mutation. Input `{ "id": "<node>", "status": "<value>" }`. Output
|
|
120
|
+
`{ "status": "updated", "id", "revision", "warnings" }`. Denials from the
|
|
121
|
+
schema's `transitions()` gate return `transition_denied` with the gate's
|
|
122
|
+
reason, exactly as on a full put. set_status on a `type: schema` node is how
|
|
123
|
+
a human flips `proposed → active` (it carries the same authority as the
|
|
124
|
+
equivalent put).
|
|
125
|
+
|
|
126
|
+
### `propose_correction`
|
|
127
|
+
|
|
128
|
+
Sugar over `put_node` for the correction loop. Input:
|
|
129
|
+
|
|
130
|
+
```json
|
|
131
|
+
{
|
|
132
|
+
"target": "node id | \"project:<slug>\" | \"global\"",
|
|
133
|
+
"pin": ["spec-actor-model"],
|
|
134
|
+
"exclude": ["art-stale-notes"],
|
|
135
|
+
"guidance": "free text injected into compiles for the target",
|
|
136
|
+
"title": "one line"
|
|
137
|
+
}
|
|
138
|
+
```
|
|
139
|
+
|
|
140
|
+
The server generates the `corr-<target>-<n>` id (next free ordinal), builds
|
|
141
|
+
the node per GRAPH.md, and routes it through the same write path. `target`
|
|
142
|
+
is one of: an existing **node id** (fires when that node is the compile root
|
|
143
|
+
or a query-matched seed — query/digest mode included, per
|
|
144
|
+
issue-cc-corrections-silent-noop-query-mode); **`project:<slug>`** (fires on
|
|
145
|
+
every compile for that project, slug resolved through project aliases); or
|
|
146
|
+
**`global`** (every compile, graph-wide). A node-id target must exist; the
|
|
147
|
+
`project:`/`global` forms are accepted verbatim. The kernel `compile()`
|
|
148
|
+
honors all three when handed `opts.project` (the session slug).
|
|
149
|
+
|
|
150
|
+
### `capture`
|
|
151
|
+
|
|
152
|
+
The default write door for sessions and Cowork: raw text in, typed nodes
|
|
153
|
+
out. Input:
|
|
154
|
+
|
|
155
|
+
```json
|
|
156
|
+
{
|
|
157
|
+
"text": "2-3 standalone sentences — the fact, what + why",
|
|
158
|
+
"project": "optional project slug",
|
|
159
|
+
"during": "optional node id the work was discovered during (provenance)"
|
|
160
|
+
}
|
|
161
|
+
```
|
|
162
|
+
|
|
163
|
+
The server-side ingestion model drafts node(s) against the live registry and
|
|
164
|
+
the similarity-ranked node index; the deterministic half validates (one
|
|
165
|
+
self-correction bounce), normalizes, stamps `authored_via: capture`, and
|
|
166
|
+
commits. Output `{ "status": "captured|pending", "node_ids": [...],
|
|
167
|
+
"nodes": [...], "summary": "...", "warnings": [...] }`. `pending` means the
|
|
168
|
+
text fit no schema (or failed validation twice) and was preserved as a
|
|
169
|
+
`cap-…` capture-pending node — ingestion-quality failures never lose text.
|
|
170
|
+
Only an unreachable ingestion model is an error (`ingestion_unavailable`).
|
|
171
|
+
|
|
172
|
+
### `my_queue`
|
|
173
|
+
|
|
174
|
+
The decision queue (QUEUE.md §4/§5). Input `{ "project"?: "slug",
|
|
175
|
+
"limit"?: 20 }` → `{ "items": [{id, title, type, status, priority, score,
|
|
176
|
+
signals: {blocking, heat, staleness, age_days}, suggest: "do|close", why}],
|
|
177
|
+
"count": N, "questions": [] }` — queueable live nodes ranked by the default
|
|
178
|
+
blend, each with a one-line *why*. Items already retired by a live inbound
|
|
179
|
+
resolves/answers edge are excluded whatever their status field reads; open
|
|
180
|
+
gardener findings ride along per item as `findings`. Structured output
|
|
181
|
+
additionally carries `view` — the queue projected into the view-tree catalog
|
|
182
|
+
for the MCP-app widget (below).
|
|
183
|
+
|
|
184
|
+
### `ask_question`
|
|
185
|
+
|
|
186
|
+
File a question the graph could not answer. Input `{ "text": "<the
|
|
187
|
+
question>", "title"?: "<short title>", "mentions"?: ["<node id>", ...] }`
|
|
188
|
+
(routing considers `mentions` first). The question becomes a durable node,
|
|
189
|
+
deterministically routed to the steward of the closest relevant node
|
|
190
|
+
(unrouted if none matches), and joins the decision queue until answered.
|
|
191
|
+
Answer by writing a node with an `answers` edge to the question.
|
|
192
|
+
|
|
193
|
+
### `run_workflow`
|
|
194
|
+
|
|
195
|
+
Start a run of an ACTIVE workflow by hand. Input
|
|
196
|
+
`{ "workflow_id": "wf-...", "inputs"?: {...} }`. Creates a workflow-run node
|
|
197
|
+
with lineage and returns the run id and its initial step states. The
|
|
198
|
+
workflow must already be active (a proposed workflow must be activated by a
|
|
199
|
+
different identity first — the self-approval ban). This tool only starts the
|
|
200
|
+
run; workers then claim ready steps over the REST claim API (§3.1). It never
|
|
201
|
+
executes effects.
|
|
202
|
+
|
|
203
|
+
### `render_lens`
|
|
204
|
+
|
|
205
|
+
Run a saved lens — a `type: lens` node (schema-lens) — against the live
|
|
206
|
+
graph. Input `{ "lens_id": "lens-...", "params"?: {"project": "wf", "focus":
|
|
207
|
+
"<node-id>", ...} }` → `{ "found": true, "lens_id", "count", "view",
|
|
208
|
+
"node_ids" }`, where `view` is the plain-JSON view tree
|
|
209
|
+
(view/list/group/item/tree/table/text catalog) and the text content is its
|
|
210
|
+
terminal rendering for the model.
|
|
211
|
+
|
|
212
|
+
`lens_id` is **optional**: call with no `lens_id` to get the lens catalog —
|
|
213
|
+
a successful `{ "found": true, "catalog": [{"id", "title"}], "lenses": [...],
|
|
214
|
+
"count" }` listing every available lens (the discovery step before you
|
|
215
|
+
render). Unknown `lens_id` still errors, carrying the same `catalog`/`lenses`
|
|
216
|
+
list; engine failures (missing param, broken blocks) error with the message
|
|
217
|
+
verbatim.
|
|
218
|
+
|
|
219
|
+
### The MCP-app widget (`ui://spor/view-tree.html`)
|
|
220
|
+
|
|
221
|
+
`my_queue` and `render_lens` declare a UI resource via
|
|
222
|
+
`_meta.ui.resourceUri`: a single trusted interpreter of the view-tree
|
|
223
|
+
component catalog that MCP-apps hosts (Claude, Goose, VS Code) render as an
|
|
224
|
+
interactive iframe — status chips, progress bars, lineage trees, node detail
|
|
225
|
+
on click (`callServerTool(get_node)`, no model round-trip), and
|
|
226
|
+
conversational affordances (`sendMessage`) for queue items. Strictly
|
|
227
|
+
additive: hosts without the apps surface ignore `_meta.ui` and show the text
|
|
228
|
+
content. Write-path actions are not emitted; writes stay with the tools
|
|
229
|
+
above.
|
|
230
|
+
|
|
231
|
+
## 3. REST surface (`/v1/*`)
|
|
232
|
+
|
|
233
|
+
Plain HTTPS + JSON, bearer auth on every route, versioned under `/v1/`. Each
|
|
234
|
+
endpoint is the REST twin of a core call:
|
|
235
|
+
|
|
236
|
+
| Endpoint | Typical caller | Semantics |
|
|
237
|
+
|---|---|---|
|
|
238
|
+
| `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 |
|
|
239
|
+
| `GET /v1/me` | `spor whoami`/`status`, onboarding | identity echo for the bearer token → `{person, name, email, bound, is_admin}`. `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 |
|
|
240
|
+
| `GET /v1/briefing/{project}` | session-start | read the `brief-<project>` node → `{found, version, body, graph_status}`. The slug resolves through project-node aliases (GRAPH.md "Project identity nodes") before lookup. 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 |
|
|
241
|
+
| `POST /v1/digest` `{query, min_sim?}` | prompt-context | digest-mode compile → `{found, text}`; `found: false` is a successful empty result |
|
|
242
|
+
| `GET /v1/nodes/{id}` | /spor:brief | `get_node` semantics; when a live inbound resolves/answers edge contradicts a still-open status the response carries `resolution`, and open gardener findings about the node ride along as `open_findings` |
|
|
243
|
+
| `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 |
|
|
244
|
+
| `POST /v1/nodes/{id}/edges` `{type, to}` | scripts, mechanical writers | `add_edge` semantics (§1): normalize/flip, dedupe, append — no revision echo |
|
|
245
|
+
| `POST /v1/nodes/{id}/status` `{status}` | scripts, mechanical writers | `set_status` semantics (§1): one-scalar update through the `transitions()` gate |
|
|
246
|
+
| `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 |
|
|
247
|
+
| `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 |
|
|
248
|
+
| `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 |
|
|
249
|
+
| `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 |
|
|
250
|
+
| `POST /v1/corrections` | /spor:correct | `propose_correction` semantics → 201 `{status, id, revision, warnings}` |
|
|
251
|
+
| `GET /v1/queue?project=&limit=` | /spor:next, session-start | the ranked decision queue: `{items, count, muted?, dormant?, questions, findings, 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` are the routed-to-me-plus-unrouted views for the authenticated identity |
|
|
252
|
+
| `POST /v1/questions` `{text, title?, mentions?}` | 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, routed_to, via, asker, revision, warnings}` |
|
|
253
|
+
| `POST /v1/gardener` | ops cron / on demand | run a gardener sweep now; findings filed as queue items → `{filed, resolved, ..., generated_at}` |
|
|
254
|
+
| `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. Accepts `?token=<PAT>` on this route only (browser links can't carry an Authorization header; the request log records the pathname, never the query) |
|
|
255
|
+
| `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 |
|
|
256
|
+
| `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) |
|
|
257
|
+
| `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 |
|
|
258
|
+
| `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 |
|
|
259
|
+
|
|
260
|
+
Path parameters (node ids, project slugs) must match
|
|
261
|
+
`^[a-z0-9][a-z0-9-]*$`. Request bodies are capped at 1MB
|
|
262
|
+
(`413 too_large`).
|
|
263
|
+
|
|
264
|
+
### 3.1 Workflow runs
|
|
265
|
+
|
|
266
|
+
The run engine's claim/complete API. Full contract and the reference worker
|
|
267
|
+
live with [workers/shim/README.md](workers/shim/README.md); a worker is
|
|
268
|
+
anything with a token.
|
|
269
|
+
|
|
270
|
+
| Endpoint | Semantics |
|
|
271
|
+
|---|---|
|
|
272
|
+
| `POST /v1/workflows/{id}/run` `{inputs?}` | start a run on an ACTIVE workflow → `{run_id, revision, workflow, workflow_version, state}` |
|
|
273
|
+
| `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) |
|
|
274
|
+
| `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 |
|
|
275
|
+
| `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 |
|
|
276
|
+
| `GET /v1/runs/{id}` | full run record: `{run_id, status, project, title, initiator, workflow, workflow_version, lineage, state, revision, timestamps?}` |
|
|
277
|
+
|
|
278
|
+
## 4. Identity and auth
|
|
279
|
+
|
|
280
|
+
- **Bearer tokens (REST + MCP).** Per-user tokens `spor_pat_…` (legacy
|
|
281
|
+
`sub_pat_…` tokens stay valid, no re-mint required). Minted by a server
|
|
282
|
+
admin with `spor-mint-token --person <person-node-id>` on the server box;
|
|
283
|
+
the token's canonical subject is that **person node**, and its
|
|
284
|
+
`{name, email}` attribution resolves from the node at read time, so an
|
|
285
|
+
email change re-points the token instead of severing it. Send
|
|
286
|
+
`Authorization: Bearer <token>` on every request. Tokens grant full
|
|
287
|
+
read/write — the trust model is "everyone on the team can read and write
|
|
288
|
+
the team graph", same as a shared repo. Transport is HTTPS only. A token
|
|
289
|
+
may carry an expiry (`spor-mint-token --expires <N>d|<date>`, or the REST
|
|
290
|
+
`expires` field); once past it the token is rejected like a revoked one.
|
|
291
|
+
- **Token lifecycle admin.** Mint, list, and revoke run over REST
|
|
292
|
+
(`/v1/admin/tokens`, §3) so onboarding/offboarding needs no server-box
|
|
293
|
+
shell — but every one of those operations is **admin-only**. A caller is an
|
|
294
|
+
admin iff their person node carries a `stewards` edge to the graph root
|
|
295
|
+
(`$SPOR_ROOT_ID`, default `org-root`); without it the admin routes return
|
|
296
|
+
`403 forbidden`. This is the one privileged distinction in the otherwise
|
|
297
|
+
flat trust model, and the seam the future fine-grained model generalizes.
|
|
298
|
+
The first admin is bootstrapped on the server box with `spor-mint-token
|
|
299
|
+
--admin --person <id>` (it writes that `stewards` edge, creating the person
|
|
300
|
+
node from `--name`/`--email` if needed). Hand-editing the token file stays
|
|
301
|
+
as the break-glass path.
|
|
302
|
+
- **OAuth 2.1 for MCP connectors** (Cowork/claude.ai, which cannot carry a
|
|
303
|
+
static bearer token): protected-resource metadata discovery (RFC 9728,
|
|
304
|
+
advertised on the `/mcp` 401 via `WWW-Authenticate`), authorization-server
|
|
305
|
+
metadata (RFC 8414), dynamic client registration (RFC 7591), and
|
|
306
|
+
authorization-code + PKCE (S256 only, public clients). The consent step is
|
|
307
|
+
a **PAT exchange**: the authorize page asks the user to paste their
|
|
308
|
+
existing `spor_pat_…` token into the server's own page — it never reaches
|
|
309
|
+
the connector host — so the OAuth identity is exactly the PAT's
|
|
310
|
+
`{name, email}` attribution record. Access tokens are `spor_oat_…` (30d;
|
|
311
|
+
legacy `sub_oat_…` accepted); refresh tokens are `spor_ort_…` (90d,
|
|
312
|
+
rotating, single-use). Authorization codes are single-use, 10-minute.
|
|
313
|
+
|
|
314
|
+
Unauthenticated MCP calls are hard-rejected — there is no anonymous author.
|
|
315
|
+
|
|
316
|
+
## 5. Errors and wire constants
|
|
317
|
+
|
|
318
|
+
Non-2xx responses carry the envelope:
|
|
319
|
+
|
|
320
|
+
```json
|
|
321
|
+
{ "error": { "code": "...", "message": "...", "details": [...] } }
|
|
322
|
+
```
|
|
323
|
+
|
|
324
|
+
Codes and their HTTP statuses: `unauthorized` 401, `forbidden` 403,
|
|
325
|
+
`not_found` 404, `conflict` 409, `transition_denied` 409, `lease_expired` 409,
|
|
326
|
+
`outcome_conflict` 409, `not_ready` 409, `invalid_node` 422, `rate_limited`
|
|
327
|
+
429, `too_large` 413, `ingestion_unavailable` 503, `unimplemented` 501,
|
|
328
|
+
`internal` 500. Hooks never parse error bodies — any non-200 means "behave
|
|
329
|
+
as if the graph is empty" (§6).
|
|
330
|
+
|
|
331
|
+
A `429 rate_limited` response SHOULD carry a `Retry-After` header (delay
|
|
332
|
+
seconds or an HTTP-date); clients honor it, otherwise backing off
|
|
333
|
+
exponentially, capped, before retrying. Mechanical writers
|
|
334
|
+
(drain-outbox, distill) classify `401`, `400`, `413`, and `422` as
|
|
335
|
+
**permanent** — a revoked token will not un-revoke, so these are
|
|
336
|
+
dead-lettered to `outbox/dead/` with a loud `journal/remote.log` line
|
|
337
|
+
rather than re-POSTed forever; `429` and `5xx` stay transient and are
|
|
338
|
+
retried with backoff.
|
|
339
|
+
|
|
340
|
+
`GET /v1/export` response headers: `x-substrate-head` carries the graph
|
|
341
|
+
commit, `x-substrate-node-count` the entry count (plus
|
|
342
|
+
`x-substrate-skipped` when any entry was omitted). These header names are a
|
|
343
|
+
wire contract and were deliberately **not** renamed in the Spor rename —
|
|
344
|
+
clients should keep reading the `x-substrate-*` spellings.
|
|
345
|
+
|
|
346
|
+
## 6. Client configuration
|
|
347
|
+
|
|
348
|
+
Two env vars switch a client into remote mode (the legacy `SUBSTRATE_*`
|
|
349
|
+
spellings are still read — dual-read back-compat window); unset means local
|
|
350
|
+
mode, reading `$SPOR_HOME` directly:
|
|
351
|
+
|
|
352
|
+
```
|
|
353
|
+
SPOR_SERVER=https://spor.example.com
|
|
354
|
+
SPOR_TOKEN=spor_pat_... # per-user token (§4)
|
|
355
|
+
```
|
|
356
|
+
|
|
357
|
+
Failure policy: **fail open, never block** — a hook must never break a
|
|
358
|
+
session; connection refused, timeout, 5xx, and auth failure all collapse to
|
|
359
|
+
"the graph has nothing for you".
|