@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.
@@ -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.6",
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. Accepts `?token=<PAT>` on this route only (browser links can't carry an Authorization header; the request log records the pathname, never the query) |
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 &lt;sharer&gt;" 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
- Then create the graph home (this is yours, kept outside any code repo):
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
- In Claude Code you can still install from the marketplace by hand if you
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
- /plugin marketplace add sporhq/spor
60
- /plugin install spor@spor
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
- function cmdInit(cfg) {
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;
@@ -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() {
@@ -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
- const NON_RESOLVING = new Set(["rejected", "abandoned"]);
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 (NON_RESOLVING.has((r.status || "").toLowerCase())) continue;
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.10.2
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.13.1
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
 
@@ -2,7 +2,7 @@
2
2
  id: schema-task
3
3
  type: schema
4
4
  kind: node-schema
5
- schema_version: 2026.06.14.1
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
- // `abandoned` is exempt. view.resolvers = live inbound resolves/answers edges
60
- // with their source type.
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
- if (rs[i].type === "decision" || rs[i].type === "artifact") { ok = true; break; }
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 that resolves this " +
71
- "task (an inbound resolves edge) — record the outcome on the graph, " +
72
- "even a few lines like a commit message, so it surfaces in the " +
73
- "neighborhood; or set abandoned if it won't be done. " +
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.6",
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
- return null;
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)