@sporhq/spor 0.2.6 → 0.2.8
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/API.md +10 -1
- package/GRAPH.md +25 -1
- package/README.md +21 -28
- package/bin/spor.js +62 -4
- package/lib/kernel/registry.js +38 -0
- package/lib/kernel/resolution.js +11 -2
- package/lib/seed/schema-artifact.md +53 -3
- package/lib/seed/schema-decision.md +14 -2
- package/lib/seed/schema-task.md +37 -10
- package/package.json +1 -1
- package/scripts/engines/session-start.js +22 -4
|
@@ -2,6 +2,6 @@
|
|
|
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.2.
|
|
5
|
+
"version": "0.2.8",
|
|
6
6
|
"author": { "name": "losthammer" }
|
|
7
7
|
}
|
package/API.md
CHANGED
|
@@ -251,7 +251,8 @@ endpoint is the REST twin of a core call:
|
|
|
251
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
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
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.
|
|
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. 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 |
|
|
255
|
+
| `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 |
|
|
255
256
|
| `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
257
|
| `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
258
|
| `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 |
|
|
@@ -310,6 +311,14 @@ anything with a token.
|
|
|
310
311
|
`{name, email}` attribution record. Access tokens are `spor_oat_…` (30d;
|
|
311
312
|
legacy `sub_oat_…` accepted); refresh tokens are `spor_ort_…` (90d,
|
|
312
313
|
rotating, single-use). Authorization codes are single-use, 10-minute.
|
|
314
|
+
- **Render tickets (shared lens links).** `POST /v1/lens/{id}/ticket` (§3)
|
|
315
|
+
mints a signed, expiring, **read-only** ticket carrying `{lens_id,
|
|
316
|
+
sharer_person_id, exp}` — the credential a *shared* view link carries instead
|
|
317
|
+
of the sharer's PAT. It binds `$viewer` to the recorded sharer (rendered with
|
|
318
|
+
a "Viewing as" banner), is honored only on `GET /v1/lens/{id}/render`, and can
|
|
319
|
+
never authorize a write. Stateless (HMAC over a server-held key — no
|
|
320
|
+
revocation list, expiry is the bound); per-recipient/revocable grants are a
|
|
321
|
+
later fine-grained-authz refinement.
|
|
313
322
|
|
|
314
323
|
Unauthenticated MCP calls are hard-rejected — there is no anonymous author.
|
|
315
324
|
|
package/GRAPH.md
CHANGED
|
@@ -88,7 +88,7 @@ Rules:
|
|
|
88
88
|
| task | `task-` | active or planned work (status `open`/`active`/`done`/`abandoned`, gated; `done` requires a `decision`/`artifact` resolver — see below) |
|
|
89
89
|
| 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) |
|
|
90
90
|
| incident | `inc-` | something that went wrong in operation (queueable: live incidents join the decision queue) |
|
|
91
|
-
| artifact | `spec-`, `art-` | a document, spec, module, or build product worth referencing |
|
|
91
|
+
| artifact | `spec-`, `art-` | a document, spec, module, or build product worth referencing; when it represents a change it may carry an optional delivery-stage status `in-review`/`approved`/`merged`/`released` — see below |
|
|
92
92
|
| norm | `norm-` | a standing convention or constraint (rides along in every project-relevant compile) |
|
|
93
93
|
| briefing | `brief-` | a compiled briefing (output of this system; never traversed) |
|
|
94
94
|
| correction | `corr-` | standing fix to a briefing: pin/exclude/guidance (never traversed) |
|
|
@@ -112,6 +112,30 @@ either satisfies the gate. `abandoned` (task) is exempt: won't-do work produces
|
|
|
112
112
|
nothing to record. The gate runs at write time on UPDATE only (the create path
|
|
113
113
|
is ungated); it is backward-readable, so existing terminal nodes are untouched.
|
|
114
114
|
|
|
115
|
+
The resolver must also be in a **resolving** state, not merely present
|
|
116
|
+
(dec-spor-definition-of-done-org-policy). Completion bundled three axes —
|
|
117
|
+
*recorded* (a why exists), *reviewed*, and *delivered* — and a resolver that is
|
|
118
|
+
a change still in review has not delivered. So:
|
|
119
|
+
|
|
120
|
+
- An `artifact` representing a change may carry a delivery-stage status:
|
|
121
|
+
`in-review`/`approved` are **non-resolving** (they keep the resolved target
|
|
122
|
+
live); `merged`/`released`, and any other/empty status, are **resolving**.
|
|
123
|
+
Plus flat scalar metadata the regex frontmatter parser already supports —
|
|
124
|
+
`delivery_ref` (PR url/commit/tag), `delivery_source` (e.g. `github`),
|
|
125
|
+
`size`, `labels`, `paths` (comma-scalars). The shape is source-blind, so a
|
|
126
|
+
GitHub reflection adapter and a native Spor review surface write the same
|
|
127
|
+
thing. Who may assert `merged`/`released` (the self-approval trust seam) is
|
|
128
|
+
later-stage policy, not a write gate here.
|
|
129
|
+
- The read-time truth (`resolutionMap`) and the write-time `done`-gate both read
|
|
130
|
+
this **resolving-status partition off `graph.registry`** — never a hardcoded
|
|
131
|
+
table. The partition is the union of each node-schema's `status.non_resolving`
|
|
132
|
+
list; the seed declares `decision: [rejected]`, `task: [abandoned]`,
|
|
133
|
+
`artifact: [in-review, approved]`, reproducing the prior behavior
|
|
134
|
+
byte-identically. An org or team retunes the bar by editing a schema node, no
|
|
135
|
+
code change. A resolver with no delivery stage (the common case) resolves
|
|
136
|
+
exactly as before, so a change still in review keeps its task live without any
|
|
137
|
+
hand-managed `open` status.
|
|
138
|
+
|
|
115
139
|
## Norm ride-along
|
|
116
140
|
|
|
117
141
|
A `norm` node (any `always_on` type) rides along on every compile — but the
|
package/README.md
CHANGED
|
@@ -24,21 +24,8 @@ Requires Node 20+ and nothing else — the client is zero-dependency. To run
|
|
|
24
24
|
from a checkout instead (e.g. to hack on it), clone the repo and `npm link`
|
|
25
25
|
from its root; that symlinks the same two commands onto your PATH.
|
|
26
26
|
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
```bash
|
|
30
|
-
spor init # creates ~/.spor/nodes, git-inits it, writes .gitignore
|
|
31
|
-
```
|
|
32
|
-
|
|
33
|
-
`spor init` is idempotent. `spor status` then tells you the resolved mode,
|
|
34
|
-
graph, project, and (in remote mode) server health and identity — run it any
|
|
35
|
-
time you're unsure whether Spor is active or which graph you're on. (Without
|
|
36
|
-
the `spor` CLI on your PATH the equivalent is
|
|
37
|
-
`mkdir -p ~/.spor/nodes && git -C ~/.spor init && printf 'journal/\n' > ~/.spor/.gitignore`.)
|
|
38
|
-
|
|
39
|
-
Then install for your agent. One verb wires up any supported host — it
|
|
40
|
-
resolves the adapter manifest to this checkout and drops it into the host's
|
|
41
|
-
config:
|
|
27
|
+
Wire Spor into your agent. One verb resolves the adapter manifest to this
|
|
28
|
+
install and drops it into the host's config:
|
|
42
29
|
|
|
43
30
|
```bash
|
|
44
31
|
spor install claude # Claude Code (via its plugin CLI — no marketplace browsing)
|
|
@@ -50,27 +37,33 @@ spor install # no host => list the hosts detected on this machine
|
|
|
50
37
|
per-repo config. `--all` installs every detected host, `--print` is a dry run,
|
|
51
38
|
and `--server <url> --token <tok>` also points the client at a team graph in
|
|
52
39
|
the same step. Re-running is idempotent — it refreshes the path and never
|
|
53
|
-
duplicates your other hooks.
|
|
40
|
+
duplicates your other hooks. (In Claude Code you can also install by hand:
|
|
41
|
+
`/plugin marketplace add sporhq/spor` then `/plugin install spor@spor`.)
|
|
54
42
|
|
|
55
|
-
|
|
56
|
-
prefer:
|
|
43
|
+
Then onboard a repo — one command, from inside it:
|
|
57
44
|
|
|
45
|
+
```bash
|
|
46
|
+
cd ~/my-repo && spor dispatch --backfill
|
|
58
47
|
```
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
48
|
+
|
|
49
|
+
That does the whole setup in one step: creates your graph home if it doesn't
|
|
50
|
+
exist yet (`~/.spor/nodes`, git-initialised), registers the repo so Spor knows
|
|
51
|
+
where it lives on this machine, makes sure Spor is enabled for it, and launches
|
|
52
|
+
the `/spor:backfill` agent in a Claude Code background session — it mines git
|
|
53
|
+
history, design docs, and issue trackers (edges first) and proposes how to
|
|
54
|
+
group your repos into projects. Watch or attach to it with `claude agents`.
|
|
55
|
+
Re-run it whenever you add a repo, or skip it entirely and just work —
|
|
56
|
+
distillation grows the graph one session at a time.
|
|
57
|
+
|
|
58
|
+
`spor status` tells you the resolved mode, graph, project, and (on a team
|
|
59
|
+
graph) server health and identity — run it any time you're unsure whether Spor
|
|
60
|
+
is active or which graph you're on. `spor init` does the graph-home setup on
|
|
61
|
+
its own if you'd rather not dispatch anything yet.
|
|
62
62
|
|
|
63
63
|
For the per-host event mapping, fidelity notes, distiller backend, and the
|
|
64
64
|
`AGENTS.md` fallback for hosts with no hook support, see
|
|
65
65
|
[adapters/](adapters/).
|
|
66
66
|
|
|
67
|
-
To start with a populated graph, run `/spor:backfill` — the onboarding door. It
|
|
68
|
-
dispatches the heavy mining (git history, design docs, issue trackers, edges
|
|
69
|
-
first) to the bundled `spor-backfill` **subagent**, which runs in its own
|
|
70
|
-
context, and then proposes how to group your repos into projects (re-run it as
|
|
71
|
-
you add repos). Or skip it and just work — distillation grows the graph one
|
|
72
|
-
session at a time.
|
|
73
|
-
|
|
74
67
|
## Dispatching background agents
|
|
75
68
|
|
|
76
69
|
`spor dispatch` hands a task to Claude Code's background-agent machinery
|
package/bin/spor.js
CHANGED
|
@@ -101,7 +101,10 @@ function nodeCount(nodesDir) {
|
|
|
101
101
|
}
|
|
102
102
|
}
|
|
103
103
|
|
|
104
|
-
|
|
104
|
+
// Idempotently create the local graph home (nodes/, git, .gitignore). Returns
|
|
105
|
+
// { home, nodesDir, created } and prints nothing — callers do their own UX.
|
|
106
|
+
// Shared by `spor init` and the `spor dispatch --backfill` onboarding path.
|
|
107
|
+
function ensureGraphHome(cfg) {
|
|
105
108
|
const home = cfg.graphHome();
|
|
106
109
|
const nodesDir = path.join(home, "nodes");
|
|
107
110
|
let created = false;
|
|
@@ -122,6 +125,11 @@ function cmdInit(cfg) {
|
|
|
122
125
|
/* non-fatal */
|
|
123
126
|
}
|
|
124
127
|
}
|
|
128
|
+
return { home, nodesDir, created };
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
function cmdInit(cfg) {
|
|
132
|
+
const { home, nodesDir, created } = ensureGraphHome(cfg);
|
|
125
133
|
out(`${created ? "Created" : "Graph already present at"} ${home}`);
|
|
126
134
|
out(` nodes: ${nodesDir} (${nodeCount(nodesDir) ?? 0} nodes)`);
|
|
127
135
|
out(` mode: ${cfg.mode()}`);
|
|
@@ -1004,6 +1012,45 @@ function shellQuote(s) {
|
|
|
1004
1012
|
return /[^\w./:-]/.test(s) ? `'${String(s).replace(/'/g, "'\\''")}'` : s;
|
|
1005
1013
|
}
|
|
1006
1014
|
|
|
1015
|
+
// Re-enable Spor for a repo by merging { enabled: true } into its committable
|
|
1016
|
+
// .spor.json (and clearing a `mode: off`, which also disables). Used by the
|
|
1017
|
+
// --backfill onboarding to repair a repo a prior `spor disable` turned off.
|
|
1018
|
+
function enableRepoAt(dir) {
|
|
1019
|
+
const file = path.join(dir, ".spor.json");
|
|
1020
|
+
let data = {};
|
|
1021
|
+
try {
|
|
1022
|
+
data = JSON.parse(fs.readFileSync(file, "utf8")) || {};
|
|
1023
|
+
} catch {
|
|
1024
|
+
/* absent or malformed — start fresh */
|
|
1025
|
+
}
|
|
1026
|
+
data.enabled = true;
|
|
1027
|
+
if (data.mode === "off") delete data.mode;
|
|
1028
|
+
try {
|
|
1029
|
+
fs.writeFileSync(file, JSON.stringify(data, null, 2) + "\n");
|
|
1030
|
+
} catch {
|
|
1031
|
+
/* non-fatal */
|
|
1032
|
+
}
|
|
1033
|
+
}
|
|
1034
|
+
|
|
1035
|
+
// `spor dispatch --backfill` is the onboarding door (task-spor-cli-dispatch-
|
|
1036
|
+
// background-agents): set the repo up before launching its backfill agent.
|
|
1037
|
+
// Idempotent; prints what it did. The dir-registration happens in cmdDispatch
|
|
1038
|
+
// (it applies to every dispatch), this adds the init + enable steps.
|
|
1039
|
+
function onboardRepo(cfg, dir) {
|
|
1040
|
+
// Init the local graph home — but only in local mode; remote mode keeps the
|
|
1041
|
+
// graph on the server, so there is nothing to create locally.
|
|
1042
|
+
if (cfg.mode() !== "remote") {
|
|
1043
|
+
const r = ensureGraphHome(cfg);
|
|
1044
|
+
out(r.created ? `initialized graph home at ${r.home}` : `graph home ready: ${r.home}`);
|
|
1045
|
+
}
|
|
1046
|
+
// Re-enable the repo if a prior `spor disable` turned it off, so onboarding a
|
|
1047
|
+
// disabled repo actually works instead of silently launching into a no-op.
|
|
1048
|
+
if (!cfg.enabled()) {
|
|
1049
|
+
enableRepoAt(dir);
|
|
1050
|
+
out(`re-enabled Spor for ${dir}`);
|
|
1051
|
+
}
|
|
1052
|
+
}
|
|
1053
|
+
|
|
1007
1054
|
async function cmdDispatch(cfg, args) {
|
|
1008
1055
|
const dryRun = args.includes("--print") || args.includes("--dry-run");
|
|
1009
1056
|
const full = args.includes("--full");
|
|
@@ -1089,9 +1136,6 @@ async function cmdDispatch(cfg, args) {
|
|
|
1089
1136
|
err(`target dir does not exist: ${res.dir}`);
|
|
1090
1137
|
return 1;
|
|
1091
1138
|
}
|
|
1092
|
-
// Learn the mapping from this CLI use too.
|
|
1093
|
-
u.registerRepo(cfg.graphHome(), res.slug, res.dir);
|
|
1094
|
-
|
|
1095
1139
|
const prompt = brief
|
|
1096
1140
|
? `# Spor briefing (compiled for this task — your standing context)\n\n${brief}\n\n---\n\n# Task\n\n${instruction}\n`
|
|
1097
1141
|
: instruction;
|
|
@@ -1106,12 +1150,26 @@ async function cmdDispatch(cfg, args) {
|
|
|
1106
1150
|
|
|
1107
1151
|
if (dryRun) {
|
|
1108
1152
|
out(`dir: ${res.dir} (slug: ${res.slug}, via ${res.source})`);
|
|
1153
|
+
if (backfill) {
|
|
1154
|
+
const steps = [];
|
|
1155
|
+
if (cfg.mode() !== "remote") steps.push(fs.existsSync(cfg.nodesDir()) ? "graph home ready" : "init graph home");
|
|
1156
|
+
steps.push(`register ${res.slug} → ${res.dir}`);
|
|
1157
|
+
if (!cfg.enabled()) steps.push("re-enable repo (currently disabled)");
|
|
1158
|
+
out(`onboard: ${steps.join("; ")}`);
|
|
1159
|
+
}
|
|
1109
1160
|
out(`brief: ${brief ? `${brief.length} bytes` : "(none — graph had nothing relevant, or --no-brief/--backfill)"}`);
|
|
1110
1161
|
out(`run: ${claudeBin} ${claudeArgs.slice(0, -1).map(shellQuote).join(" ")} <prompt>`);
|
|
1111
1162
|
out(`\n--- prompt ---\n${prompt}`);
|
|
1112
1163
|
return 0;
|
|
1113
1164
|
}
|
|
1114
1165
|
|
|
1166
|
+
// Side effects (real run only — --print writes nothing). --backfill is the
|
|
1167
|
+
// onboarding door, so it sets the repo up (init + enable) first; every
|
|
1168
|
+
// dispatch self-registers the dir it resolved.
|
|
1169
|
+
if (backfill) onboardRepo(cfg, res.dir);
|
|
1170
|
+
u.registerRepo(cfg.graphHome(), res.slug, res.dir);
|
|
1171
|
+
if (backfill) out(`registered ${res.slug} → ${res.dir}; launching the backfill agent…`);
|
|
1172
|
+
|
|
1115
1173
|
if (claudeBin === "claude" && !hasCmd("claude")) {
|
|
1116
1174
|
err("claude CLI not on PATH — install Claude Code, then re-run (or 'spor dispatch … --print' to see the prompt).");
|
|
1117
1175
|
return 1;
|
package/lib/kernel/registry.js
CHANGED
|
@@ -167,6 +167,21 @@ function parseSchemaNode(node) {
|
|
|
167
167
|
const p = Array.isArray(payload.prefix) ? payload.prefix : [payload.prefix];
|
|
168
168
|
if (!p.length || p.some((x) => typeof x !== "string" || !x)) errors.push(`prefix must be a non-empty string or array of strings`);
|
|
169
169
|
}
|
|
170
|
+
// status disposition (dec-spor-definition-of-done-org-policy): the
|
|
171
|
+
// resolving-status partition the kernel reads off the registry instead of
|
|
172
|
+
// a hardcoded table. `non_resolving` lists the statuses in which a node of
|
|
173
|
+
// this type, acting as a RESOLVER, does NOT retire its targets (a withdrawn
|
|
174
|
+
// decision, an in-review change). Optional; unlisted statuses resolve.
|
|
175
|
+
if (payload.status != null) {
|
|
176
|
+
if (typeof payload.status !== "object" || Array.isArray(payload.status)) {
|
|
177
|
+
errors.push(`status must be an object`);
|
|
178
|
+
} else if (payload.status.non_resolving != null) {
|
|
179
|
+
const nr = payload.status.non_resolving;
|
|
180
|
+
if (!Array.isArray(nr) || nr.some((x) => typeof x !== "string" || !x)) {
|
|
181
|
+
errors.push(`status.non_resolving must be an array of non-empty strings`);
|
|
182
|
+
}
|
|
183
|
+
}
|
|
184
|
+
}
|
|
170
185
|
} else if (node.kind === "queue-policy") {
|
|
171
186
|
// Singleton: the registry holds at most one (graph beats seed, higher
|
|
172
187
|
// version wins). The blend lives in attached code — a `rank` export is
|
|
@@ -336,6 +351,29 @@ class Registry {
|
|
|
336
351
|
return !!(s && s.payload.queueable === true);
|
|
337
352
|
}
|
|
338
353
|
|
|
354
|
+
// Resolution partition (dec-spor-definition-of-done-org-policy): the set of
|
|
355
|
+
// statuses in which a node, acting as a RESOLVER, does NOT retire its targets.
|
|
356
|
+
// Unioned across every node-schema's `status.non_resolving` (type-blind, like
|
|
357
|
+
// the hardcoded NON_RESOLVING set it replaces in resolution.js). Lowercased.
|
|
358
|
+
// The kernel reads this off graph.registry instead of owning a table; an org
|
|
359
|
+
// reconfigures the bar by editing a schema node, no code change. Seed
|
|
360
|
+
// assignments reproduce the old {rejected, abandoned} set byte-identically.
|
|
361
|
+
nonResolvingStatuses() {
|
|
362
|
+
const out = new Set();
|
|
363
|
+
for (const s of this.nodeSchemas.values()) {
|
|
364
|
+
const nr = s.payload.status && s.payload.status.non_resolving;
|
|
365
|
+
if (Array.isArray(nr)) for (const v of nr) if (typeof v === "string" && v) out.add(v.toLowerCase());
|
|
366
|
+
}
|
|
367
|
+
return out;
|
|
368
|
+
}
|
|
369
|
+
|
|
370
|
+
// A resolver in this status retires its targets (the read-time completion
|
|
371
|
+
// truth). Empty/unlisted statuses resolve; only declared non-resolving
|
|
372
|
+
// statuses (withdrawn decisions, in-review changes) keep the target live.
|
|
373
|
+
isResolvingStatus(status) {
|
|
374
|
+
return !this.nonResolvingStatuses().has((status || "").toLowerCase());
|
|
375
|
+
}
|
|
376
|
+
|
|
339
377
|
// ---- snapshots (back-compat exports in graph.js are derived from these) ----
|
|
340
378
|
|
|
341
379
|
edgeWeights() {
|
package/lib/kernel/resolution.js
CHANGED
|
@@ -28,15 +28,24 @@ const TERMINAL = new Set([
|
|
|
28
28
|
"done", "resolved", "superseded", "rejected", "closed", "completed", "abandoned", "answered",
|
|
29
29
|
"merged", // a triaged capture-pending: its content now lives in proper nodes
|
|
30
30
|
]);
|
|
31
|
-
|
|
31
|
+
// Fallback resolving partition, used ONLY when a graph carries no registry
|
|
32
|
+
// (hand-built test graphs). The live partition is read off graph.registry
|
|
33
|
+
// (dec-spor-definition-of-done-org-policy): resolution.js no longer owns the
|
|
34
|
+
// table — it is compiled from each node-schema's `status.non_resolving`, and
|
|
35
|
+
// the seed reproduces this exact set byte-identically. The kernel never learns
|
|
36
|
+
// the words delivery/merged/in-review; it checks status against the partition.
|
|
37
|
+
const FALLBACK_NON_RESOLVING = new Set(["rejected", "abandoned"]);
|
|
32
38
|
|
|
33
39
|
const isTerminalStatus = (s) => TERMINAL.has((s || "").toLowerCase());
|
|
34
40
|
|
|
35
41
|
function resolutionMap(graph) {
|
|
36
42
|
const out = {};
|
|
43
|
+
const nonResolving = graph.registry && typeof graph.registry.nonResolvingStatuses === "function"
|
|
44
|
+
? graph.registry.nonResolvingStatuses()
|
|
45
|
+
: FALLBACK_NON_RESOLVING;
|
|
37
46
|
for (const r of Object.values(graph.nodes)) {
|
|
38
47
|
if (graph.supersededBy[r.id]) continue;
|
|
39
|
-
if (
|
|
48
|
+
if (nonResolving.has((r.status || "").toLowerCase())) continue;
|
|
40
49
|
for (const e of r.edges ?? []) {
|
|
41
50
|
if (e.type !== "resolves" && e.type !== "answers") continue;
|
|
42
51
|
const target = graph.nodes[e.to];
|
|
@@ -2,9 +2,9 @@
|
|
|
2
2
|
id: schema-artifact
|
|
3
3
|
type: schema
|
|
4
4
|
kind: node-schema
|
|
5
|
-
schema_version: 2026.06.
|
|
5
|
+
schema_version: 2026.06.15.1
|
|
6
6
|
title: Seed schema for artifact nodes
|
|
7
|
-
summary: Node schema for the artifact type — a document, spec, module, or build product worth referencing. Seed-pack mirror of the GRAPH.md ontology; a graph-resident schema node for this type overrides it.
|
|
7
|
+
summary: Node schema for the artifact type — a document, spec, module, or build product worth referencing, optionally carrying a delivery-stage status when it represents a change. Seed-pack mirror of the GRAPH.md ontology; a graph-resident schema node for this type overrides it.
|
|
8
8
|
date: 2026-06-10
|
|
9
9
|
---
|
|
10
10
|
|
|
@@ -12,6 +12,42 @@ Seed schema for the `artifact` node type, shipped with the plugin as a
|
|
|
12
12
|
registry default (QUEUE.md §2). A `type: schema` node in the graph with
|
|
13
13
|
`kind: node-schema` and the same `node_type` overrides this entry.
|
|
14
14
|
|
|
15
|
+
Delivery-stage vocab (2026.06.15.1, dec-spor-definition-of-done-org-policy):
|
|
16
|
+
an artifact that represents a *change* (a PR, a branch, a release) — rather than
|
|
17
|
+
a static doc or spec — may carry an OPTIONAL delivery-stage `status`:
|
|
18
|
+
|
|
19
|
+
| status | resolving? | meaning |
|
|
20
|
+
|-------------|------------|----------------------------------------------------|
|
|
21
|
+
| `in-review` | no | change submitted, under review — keeps its task live |
|
|
22
|
+
| `approved` | no | reviewed and approved, not yet landed — task still live |
|
|
23
|
+
| `merged` | yes | landed on the default branch — retires its task |
|
|
24
|
+
| `released` | yes | shipped/released |
|
|
25
|
+
|
|
26
|
+
The non-resolving stages are declared in `status.non_resolving`, the artifact's
|
|
27
|
+
half of the resolving partition the kernel reads off `graph.registry`: a
|
|
28
|
+
resolver in `in-review`/`approved` does not retire its targets, so a task whose
|
|
29
|
+
only resolver is a change still in review stays live (this is what dissolves the
|
|
30
|
+
overnight-review smell — no `open` status to hand-manage). `merged`/`released`
|
|
31
|
+
and any unlisted/empty status resolve, so existing artifacts (no delivery stage)
|
|
32
|
+
are unaffected and the seed is byte-identical with the prior hardcoded set.
|
|
33
|
+
|
|
34
|
+
The stage is one half of a **source-blind** resolver contract (so a GitHub
|
|
35
|
+
reflection adapter and a native Spor review surface write the same shape). The
|
|
36
|
+
other half is flat scalar frontmatter the regex parser already supports — no
|
|
37
|
+
inline-list generalization:
|
|
38
|
+
|
|
39
|
+
- `delivery_ref` — the change's address (PR url, commit sha, tag).
|
|
40
|
+
- `delivery_source` — where it lives (e.g. `github`, `spor`).
|
|
41
|
+
- `size`, `labels`, `paths` — diff metadata as comma-scalars
|
|
42
|
+
(`paths: lib/x.js, lib/y.js`).
|
|
43
|
+
|
|
44
|
+
The kernel never reads these keys; it reads `status` against the partition only.
|
|
45
|
+
The **trust seam** — *who* may assert `merged`/`released` (the self-approval
|
|
46
|
+
floor: an author cannot land their own change) — is policy that lands in a later
|
|
47
|
+
stage (the policy kind + the reflection adapter), not here. There is no status
|
|
48
|
+
gate on this type: the stages are optional recognized values, and an artifact
|
|
49
|
+
remains free to be a plain doc with no status.
|
|
50
|
+
|
|
15
51
|
```json
|
|
16
52
|
{
|
|
17
53
|
"node_type": "artifact",
|
|
@@ -19,6 +55,20 @@ registry default (QUEUE.md §2). A `type: schema` node in the graph with
|
|
|
19
55
|
"prefix": [
|
|
20
56
|
"spec-",
|
|
21
57
|
"art-"
|
|
22
|
-
]
|
|
58
|
+
],
|
|
59
|
+
"status": {
|
|
60
|
+
"non_resolving": [
|
|
61
|
+
"in-review",
|
|
62
|
+
"approved"
|
|
63
|
+
]
|
|
64
|
+
},
|
|
65
|
+
"display": {
|
|
66
|
+
"statuses": {
|
|
67
|
+
"in-review": "active",
|
|
68
|
+
"approved": "active",
|
|
69
|
+
"merged": "positive",
|
|
70
|
+
"released": "positive"
|
|
71
|
+
}
|
|
72
|
+
}
|
|
23
73
|
}
|
|
24
74
|
```
|
|
@@ -2,7 +2,7 @@
|
|
|
2
2
|
id: schema-decision
|
|
3
3
|
type: schema
|
|
4
4
|
kind: node-schema
|
|
5
|
-
schema_version: 2026.06.
|
|
5
|
+
schema_version: 2026.06.15.1
|
|
6
6
|
title: Seed schema for decision nodes
|
|
7
7
|
summary: Node schema for the decision type — a choice that was made, with the why. Seed-pack mirror of the GRAPH.md ontology; a graph-resident schema node for this type overrides it.
|
|
8
8
|
date: 2026-06-10
|
|
@@ -19,13 +19,25 @@ queue-terminal values are not shadowed by synonyms
|
|
|
19
19
|
approach is filed (the distiller writes it, prompts/client/distill-local.md).
|
|
20
20
|
Write-time gate, backward-readable, no upgrade chain.
|
|
21
21
|
|
|
22
|
+
`status.non_resolving` (2026.06.15.1): a `rejected` decision — a recorded-then-
|
|
23
|
+
declined choice — resolves nothing, so as a resolver it does not retire its
|
|
24
|
+
targets. This is the registry-declared half of the resolving partition the
|
|
25
|
+
kernel reads off `graph.registry` (dec-spor-definition-of-done-org-policy);
|
|
26
|
+
resolution.js no longer hardcodes the `{rejected, abandoned}` set. Registry
|
|
27
|
+
behavior only, no node-shape change, backward-readable, no upgrade chain.
|
|
28
|
+
|
|
22
29
|
```json
|
|
23
30
|
{
|
|
24
31
|
"node_type": "decision",
|
|
25
32
|
"description": "a choice that was made, with the why",
|
|
26
33
|
"prefix": [
|
|
27
34
|
"dec-"
|
|
28
|
-
]
|
|
35
|
+
],
|
|
36
|
+
"status": {
|
|
37
|
+
"non_resolving": [
|
|
38
|
+
"rejected"
|
|
39
|
+
]
|
|
40
|
+
}
|
|
29
41
|
}
|
|
30
42
|
```
|
|
31
43
|
|
package/lib/seed/schema-task.md
CHANGED
|
@@ -2,7 +2,7 @@
|
|
|
2
2
|
id: schema-task
|
|
3
3
|
type: schema
|
|
4
4
|
kind: node-schema
|
|
5
|
-
schema_version: 2026.06.
|
|
5
|
+
schema_version: 2026.06.15.1
|
|
6
6
|
title: Seed schema for task nodes
|
|
7
7
|
summary: Node schema for the task type — active or planned work. Seed-pack mirror of the GRAPH.md ontology; a graph-resident schema node for this type overrides it.
|
|
8
8
|
date: 2026-06-10
|
|
@@ -25,6 +25,18 @@ node surfaces in the neighborhood. `abandoned` (won't do) is exempt; nothing
|
|
|
25
25
|
was produced to record. Both are write-time gates, backward-readable (no
|
|
26
26
|
stored-shape change; existing `done` tasks are untouched), no upgrade chain.
|
|
27
27
|
|
|
28
|
+
`transitions()` + `status` (2026.06.15.1, dec-spor-definition-of-done-org-policy):
|
|
29
|
+
the completion gate tightens — `done` requires the inbound resolver to be in a
|
|
30
|
+
*resolving* state, not merely present. The host supplies the registry's
|
|
31
|
+
resolving partition on the view as `non_resolving_statuses` (the same
|
|
32
|
+
`status.non_resolving` the kernel's `resolutionMap` reads), and the gate counts a
|
|
33
|
+
decision/artifact resolver only when its status is not named there. So a human
|
|
34
|
+
cannot hand-flip `done` past an in-review change — the write-time mirror of the
|
|
35
|
+
read-time retirement rule. `status.non_resolving: [abandoned]` declares the
|
|
36
|
+
type's half of the partition: an abandoned task resolves nothing. Absent the
|
|
37
|
+
partition on the view (an older server) every resolver counts exactly as before,
|
|
38
|
+
so the gate is backward-readable with no node-shape change and no upgrade chain.
|
|
39
|
+
|
|
28
40
|
```json
|
|
29
41
|
{
|
|
30
42
|
"node_type": "task",
|
|
@@ -32,7 +44,12 @@ stored-shape change; existing `done` tasks are untouched), no upgrade chain.
|
|
|
32
44
|
"prefix": [
|
|
33
45
|
"task-"
|
|
34
46
|
],
|
|
35
|
-
"queueable": true
|
|
47
|
+
"queueable": true,
|
|
48
|
+
"status": {
|
|
49
|
+
"non_resolving": [
|
|
50
|
+
"abandoned"
|
|
51
|
+
]
|
|
52
|
+
}
|
|
36
53
|
}
|
|
37
54
|
```
|
|
38
55
|
|
|
@@ -55,22 +72,32 @@ export function transitions(current, proposed, view) {
|
|
|
55
72
|
};
|
|
56
73
|
}
|
|
57
74
|
// (2) completion must record a durable outcome on the graph: a decision or
|
|
58
|
-
// artifact that resolves this task (task-cc-terminal-status-requires-resolver)
|
|
59
|
-
//
|
|
60
|
-
//
|
|
75
|
+
// artifact that resolves this task (task-cc-terminal-status-requires-resolver),
|
|
76
|
+
// AND that resolver must be in a RESOLVING state — not an in-review change
|
|
77
|
+
// (dec-spor-definition-of-done-org-policy). `abandoned` is exempt.
|
|
78
|
+
// view.resolvers = live inbound resolves/answers edges with their source type
|
|
79
|
+
// and status; view.non_resolving_statuses = the registry's resolving partition
|
|
80
|
+
// the host supplies (the same status.non_resolving the kernel's resolutionMap
|
|
81
|
+
// reads). A resolver counts unless its status is named non-resolving, so an
|
|
82
|
+
// older host that omits the partition behaves exactly as before
|
|
83
|
+
// (backward-readable).
|
|
61
84
|
if (next === "done") {
|
|
62
85
|
const rs = (view && view.resolvers) || [];
|
|
86
|
+
const nonResolving = (view && view.non_resolving_statuses) || [];
|
|
63
87
|
let ok = false;
|
|
64
88
|
for (let i = 0; i < rs.length; i++) {
|
|
65
|
-
|
|
89
|
+
const isChange = rs[i].type === "decision" || rs[i].type === "artifact";
|
|
90
|
+
const st = ((rs[i] && rs[i].status) || "").toLowerCase();
|
|
91
|
+
if (isChange && nonResolving.indexOf(st) === -1) { ok = true; break; }
|
|
66
92
|
}
|
|
67
93
|
if (!ok) {
|
|
68
94
|
return {
|
|
69
95
|
allow: false,
|
|
70
|
-
reason: "done requires a decision or artifact node
|
|
71
|
-
"task (an inbound resolves edge) — record the
|
|
72
|
-
"even a few lines like a commit message, so it
|
|
73
|
-
"neighborhood;
|
|
96
|
+
reason: "done requires a decision or artifact node in a RESOLVING state " +
|
|
97
|
+
"that resolves this task (an inbound resolves edge) — record the " +
|
|
98
|
+
"outcome on the graph, even a few lines like a commit message, so it " +
|
|
99
|
+
"surfaces in the neighborhood; a change still in review keeps the task " +
|
|
100
|
+
"live until it lands. Or set abandoned if it won't be done. " +
|
|
74
101
|
"(task-cc-terminal-status-requires-resolver)",
|
|
75
102
|
};
|
|
76
103
|
}
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@sporhq/spor",
|
|
3
|
-
"version": "0.2.
|
|
3
|
+
"version": "0.2.8",
|
|
4
4
|
"description": "Spor — a shared memory substrate for teams and agents. Decisions, their reasons, and the traces they leave. Knowledge-graph context compiler: session-start briefings, per-prompt digests, capture at discovery, end-of-session distillation, decision queue.",
|
|
5
5
|
"license": "Apache-2.0",
|
|
6
6
|
"author": "Anthony Allen",
|
|
@@ -272,17 +272,35 @@ ${body}`;
|
|
|
272
272
|
}
|
|
273
273
|
|
|
274
274
|
// -------------------------------------------------------------------------
|
|
275
|
-
// LOCAL MODE (original behavior — byte-identical to session-start.sh
|
|
275
|
+
// LOCAL MODE (original behavior — byte-identical to session-start.sh, except
|
|
276
|
+
// the empty/absent-graph onboarding line below)
|
|
276
277
|
// -------------------------------------------------------------------------
|
|
277
|
-
if (!fs.existsSync(nodes)) return null;
|
|
278
|
-
|
|
279
278
|
let files = [];
|
|
280
279
|
try {
|
|
281
280
|
files = fs.readdirSync(nodes).filter((f) => f.endsWith(".md"));
|
|
282
281
|
} catch {
|
|
283
|
-
|
|
282
|
+
/* no nodes/ dir yet (fresh/onboarding home) or unreadable — count stays 0 */
|
|
284
283
|
}
|
|
285
284
|
const count = files.length;
|
|
285
|
+
|
|
286
|
+
// Empty or absent local graph — the onboarding moment, e.g. right after
|
|
287
|
+
// `spor dispatch --backfill` inits the home (it creates an empty nodes/ dir)
|
|
288
|
+
// or a fresh `spor init`. Remote mode always emits a status line here ("no
|
|
289
|
+
// standing briefing for <slug> yet"); local mode used to return null (no dir)
|
|
290
|
+
// or claim "0 nodes active" (empty dir), so the SessionStart hook looked like
|
|
291
|
+
// it never ran during onboarding — the parity gap reported when SessionStart
|
|
292
|
+
// appeared dead for `claude --bg` (it fires; source=startup). Emit the parity
|
|
293
|
+
// line so onboarding is visibly underway and points at the bootstrap path
|
|
294
|
+
// (issue-cc-local-mode-session-start-empty-graph-fallback). Side effects above
|
|
295
|
+
// (registerRepo, plugin-root) already ran; the repo-identity write below is
|
|
296
|
+
// gated on projCount>0, so nothing else is skipped by returning early here.
|
|
297
|
+
if (count === 0) {
|
|
298
|
+
return envelope(
|
|
299
|
+
`No Spor briefing for ${slug} yet — the local graph at ${nodes} is empty. ` +
|
|
300
|
+
`Bootstrap this repo with /spor:backfill (or 'spor dispatch --backfill'); ` +
|
|
301
|
+
`knowledge you capture lands here as you work. ${USAGE_LOCAL}`
|
|
302
|
+
);
|
|
303
|
+
}
|
|
286
304
|
// Repo identity (task-cc-project-identity-nodes): a resident `type: repo`
|
|
287
305
|
// node (renamed from the former `type: project`,
|
|
288
306
|
// dec-cc-repo-project-two-layer-identity) whose `slugs:` register (or id)
|