@sporhq/spor 0.3.0 → 0.3.1

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.3.0",
5
+ "version": "0.3.1",
6
6
  "author": { "name": "losthammer" }
7
7
  }
package/API.md CHANGED
@@ -402,6 +402,19 @@ SPOR_SERVER=https://spor.example.com
402
402
  SPOR_TOKEN=spor_pat_... # per-user token (§4)
403
403
  ```
404
404
 
405
+ **Opt-in activation.** Spor is opt-in per repo: with the plugin installed, the
406
+ hooks are a full no-op (no context injected, nothing distilled) in any repo that
407
+ has not opted in. A repo is active when its mode is not `off` AND either an
408
+ `enabled` flag is set anywhere in the cascade (`enabled:true`/`false` in a config
409
+ layer, `SPOR_ENABLED=1`/`0`, or a CLI `--enabled`) OR a repo-level `.spor` /
410
+ `.spor.json` marker is present in the cwd ancestry — what `spor enable`, `spor
411
+ link`, and `spor dispatch --backfill` write. An explicit flag wins over marker
412
+ presence (so `enabled:false` forces a no-op even where a marker exists). The
413
+ default — no flag, no marker — is OFF, including in remote mode: a globally
414
+ configured `SPOR_SERVER` resolves the *mode* to remote but does not by itself
415
+ *enable* an unrelated repo, so side projects never distill into the team graph.
416
+ `spor status` / `spor-hook doctor` report whether the current repo is active.
417
+
405
418
  Failure policy: **fail open, never block** — a hook must never break a
406
419
  session; connection refused, timeout, 5xx, and auth failure all collapse to
407
420
  "the graph has nothing for you".
package/GRAPH.md CHANGED
@@ -176,6 +176,153 @@ least two — the definition-of-done quorum gate.
176
176
  a proposed policy is inert until a *different* identity activates it (the
177
177
  native floor protects against self-amendment circularity).
178
178
 
179
+ ## Authoring a custom schema
180
+
181
+ A team extends the ontology by writing a `type: schema` node into its graph (it
182
+ overrides or extends the seed pack; QUEUE.md §2). This section assembles one
183
+ end-to-end — the seed pack documents the JSON payload and the attached code
184
+ separately, but never shows a complete custom type in one piece.
185
+
186
+ **The constraint model is procedural, not declarative.** A schema's `json`
187
+ payload declares only *registry knobs* — `node_type`, `prefix`, `queueable`,
188
+ `traversable`, `always_on`, `capturable`, an edge `weight`, the
189
+ `status.non_resolving` partition. There is **no declarative field list and no
190
+ status enum.** Custom fields are free-form: any flat frontmatter key the
191
+ regex parser accepts (simple `key: value` scalars, YAML-folded multi-line
192
+ values, `pin:`/`exclude:` inline lists, `- {type: X, to: Y}` edges — and nothing
193
+ fancier) is carried verbatim on the node. What a field MUST contain, and which
194
+ status changes are legal, are enforced **in attached code** — two pure functions
195
+ the server runs on the write path:
196
+
197
+ - **`validate(node) -> string[]`** — the door. Runs on **every write (create
198
+ AND update)**. It receives the parsed proposed node and returns an array of
199
+ human-readable error strings; `[]` means accept. A non-empty array rejects the
200
+ write (`invalid_node`), each string surfaced to the writer as
201
+ `<schema-id> validate(): <your message>`. This is where required/typed custom
202
+ fields are enforced.
203
+ - **`transitions(current, proposed, view) -> { allow, reason? }`** — the status
204
+ gate. Runs on **update only** (the create path is ungated, so a status-less or
205
+ any first write is always allowed). `current` is the stored node (or `null` if
206
+ its file is unparseable), `proposed` is the incoming node, and `view` is a
207
+ read-only join the server computes for the gate:
208
+ - `view.targets[id]` — `{ exists, type, status, superseded }` for each node
209
+ this one points an edge at (outbound);
210
+ - `view.resolvers` — live **inbound** `resolves`/`answers` edges pointing at
211
+ this node, each `{ id, type, status }`, already filtered to *resolving*
212
+ states (a withdrawn or in-review resolver is excluded). This is how a type
213
+ requires a durable outcome on the graph before going terminal (the resolver
214
+ gate the seed `task`/`issue` schemas use);
215
+ - `view.non_resolving_statuses` — the registry's resolving-status partition,
216
+ if the gate wants to judge resolver states itself;
217
+ - `view.actor` — `{ name, email, via }`, the authenticated writer (for
218
+ ownership/role gates);
219
+ - `view.approvals` — the node's review edges to `person` nodes joined to their
220
+ `roles` (the quorum-gate input; the writer's own self-approval is excluded).
221
+ Return `{ allow: true }` to permit, or `{ allow: false, reason: "…" }` to deny
222
+ with an actionable message.
223
+
224
+ Both hooks are **sandboxed, pure, and fail-closed**: the server runs them in a
225
+ QuickJS-in-wasm sandbox (`SPOR_SANDBOX=vm` is an ops-only escape hatch) across a
226
+ strict JSON boundary — arguments arrive as plain guest data and only JSON-clonable
227
+ return values cross back, so `require`, `process`, `eval`, the `Function`
228
+ constructor, host prototypes, ambient time/IO, and unbounded loops are all
229
+ unavailable (a runaway hits a fuel/memory interrupt). A hook that throws does not
230
+ wave the write through — it **rejects** it. Write the functions accordingly: no
231
+ external state, no side effects, decide only from the arguments. Two more
232
+ attached exports are recognized: `queueSignals(node, ctx)` (a `{ name: number }`
233
+ map blended into the decision-queue ranking) and named upgrade functions
234
+ referenced from `payload.upgrades` (lazy, forward-only field migrations on a
235
+ `schema_version` bump). The **client** half (hooks, the `spor` CLI, `lib/`) only
236
+ *parses and indexes* this code for the registry knobs — it never executes it;
237
+ the server is the sole executor.
238
+
239
+ Resolution and rollout: a graph-resident schema always beats the seed pack, and
240
+ within a source the higher CalVer `schema_version` wins, so an override must be
241
+ bumped in lockstep with seed changes or it silently shadows them (`validateGraph`
242
+ warns). A schema node goes through the same propose→activate flow it governs — a
243
+ *different* identity must activate it (or a trusted admin git-writes it `active`);
244
+ bump the CalVer and add an `upgrades` chain only when the change is not
245
+ backward-readable.
246
+
247
+ A complete worked example — a `escalation` type with a required `severity`
248
+ field (enforced in `validate`) and an `open → mitigated → closed` status machine
249
+ whose terminal `closed` demands a resolver (enforced in `transitions`):
250
+
251
+ ````markdown
252
+ ---
253
+ id: schema-escalation
254
+ type: schema
255
+ kind: node-schema
256
+ schema_version: 2026.06.16.1
257
+ title: Custom schema for customer-escalation nodes
258
+ summary: A customer escalation with a severity field and an open/mitigated/closed status machine; closing one requires a decision or artifact that resolves it.
259
+ status: active
260
+ date: 2026-06-16
261
+ ---
262
+
263
+ Escalation nodes track a customer-facing incident from raise to close.
264
+ `severity` is a free-form frontmatter field this schema makes mandatory and
265
+ constrains in `validate()`; the status machine and the close-time resolver gate
266
+ live in `transitions()`. Both are the same procedural model the seed types use —
267
+ there is no declarative field or status enum to fill in.
268
+
269
+ ```json
270
+ {
271
+ "node_type": "escalation",
272
+ "description": "a customer-facing escalation and its resolution lineage",
273
+ "prefix": ["esc-"],
274
+ "queueable": true
275
+ }
276
+ ```
277
+
278
+ ```js
279
+ // validate(node) — runs at the door on EVERY write (create and update). Returns
280
+ // an array of error strings; [] accepts. Enforce the free-form custom field here:
281
+ // the payload declares no field list, so a required/typed field is code.
282
+ export function validate(node) {
283
+ const errors = [];
284
+ const VALID_SEVERITY = ["sev1", "sev2", "sev3"];
285
+ if (!node.severity) {
286
+ errors.push("escalation requires a severity field (sev1 | sev2 | sev3)");
287
+ } else if (VALID_SEVERITY.indexOf(String(node.severity)) === -1) {
288
+ errors.push("invalid severity '" + node.severity + "': use sev1, sev2, or sev3");
289
+ }
290
+ return errors;
291
+ }
292
+
293
+ // transitions(current, proposed, view) — runs on UPDATE only; returns
294
+ // { allow, reason? }. Empty status (status-less = live) is always allowed.
295
+ export function transitions(current, proposed, view) {
296
+ const VALID = ["open", "mitigated", "closed"];
297
+ const next = ((proposed && proposed.status) || "").toLowerCase();
298
+ if (next === "") return { allow: true };
299
+ if (VALID.indexOf(next) === -1) {
300
+ return {
301
+ allow: false,
302
+ reason: "invalid escalation status '" + next + "': valid statuses are " +
303
+ "open, mitigated, closed — or none, meaning live",
304
+ };
305
+ }
306
+ // closed must record a durable outcome on the graph: a decision or artifact
307
+ // that resolves this escalation (an inbound resolves edge). view.resolvers is
308
+ // already filtered to resolving states, so an in-review fix does not count.
309
+ if (next === "closed") {
310
+ const rs = (view && view.resolvers) || [];
311
+ const ok = rs.some((r) => r.type === "decision" || r.type === "artifact");
312
+ if (!ok) {
313
+ return {
314
+ allow: false,
315
+ reason: "closed requires a decision or artifact node that resolves this " +
316
+ "escalation (an inbound resolves edge) — record how it was handled on " +
317
+ "the graph so the neighborhood can surface it",
318
+ };
319
+ }
320
+ }
321
+ return { allow: true };
322
+ }
323
+ ```
324
+ ````
325
+
179
326
  ## Norm ride-along
180
327
 
181
328
  A `norm` node (any `always_on` type) rides along on every compile — but the
package/README.md CHANGED
@@ -275,12 +275,23 @@ add files only when you want them. A `.spor.json` (committable) is for
275
275
  settings the whole repo should share — never put a `token` there; it is
276
276
  honored only from the environment or your user/global config.
277
277
 
278
+ **Spor is opt-in per repo.** Installing the plugin does not make every repo you
279
+ open participate: a repo is a no-op (no context injected, nothing distilled into
280
+ the shared graph) until it opts in — either it carries a `.spor`/`.spor.json`
281
+ marker, or `enabled` is set anywhere in the cascade (`SPOR_ENABLED=1`, or
282
+ `enabled:true` in user/global config to turn it on everywhere). `spor enable`
283
+ writes the marker below for you; `spor dispatch --backfill` does it as part of
284
+ onboarding. This keeps unrelated side projects out of your team graph even when
285
+ a server is configured globally. Run `spor status` (or `spor-hook doctor`) in a
286
+ repo to see whether it's active.
287
+
278
288
  ```jsonc
279
289
  // .spor.json — committed at a repo root
280
290
  {
281
- "enabled": false, // make the plugin a no-op in this repo:
282
- // unrelated side projects don't pollute
283
- // the shared graph (default true)
291
+ "enabled": true, // opt this repo in (what `spor enable`
292
+ // writes). Spor is OFF in a repo with no
293
+ // marker; set false to force a no-op even
294
+ // where a marker would otherwise enable it
284
295
  "search": {
285
296
  "minSim": 0.10, // raise/lower the relevance gate
286
297
  "projects": {
package/bin/spor.js CHANGED
@@ -111,7 +111,7 @@ async function cmdStatus(cfg) {
111
111
  const home = cfg.graphHome();
112
112
  const nodesDir = cfg.nodesDir();
113
113
  const slug = safeSlug();
114
- out(`mode: ${mode}${cfg.enabled() ? "" : " (DISABLED here — plugin is a no-op)"}`);
114
+ out(`mode: ${mode}${cfg.enabled() ? "" : " (not enabled here — run 'spor enable' to opt in; hooks are a no-op)"}`);
115
115
  out(`project: ${slug}`);
116
116
  if (mode === "remote") {
117
117
  const server = remote.base(cfg);
@@ -354,17 +354,125 @@ async function cmdLens(cfg, args) {
354
354
  return 0;
355
355
  }
356
356
 
357
- function cmdCompile(cfg, verb, args) {
357
+ // compile / brief / validate are LOCAL-graph verbs: byte-identical passthrough
358
+ // to lib/compile.js / lib/validate.js, which read $SPOR_HOME/nodes. In REMOTE
359
+ // mode that dir is absent, so the old passthrough exited with a bare
360
+ // "no Spor graph at ~/.spor/nodes" — reads like a broken install
361
+ // (issue-spor-cli-remote-mode-local-verbs). So they branch on mode: dispatch to
362
+ // the server where an equivalent exists (brief/compile, mirroring the
363
+ // /spor:brief skill), fail fast naming the remote path where it does not
364
+ // (validate, compile --skeleton). An explicit --nodes names a local checkout on
365
+ // purpose, so it always takes the local path even under a configured server —
366
+ // which also keeps local-mode output byte-identical (norm-cc-byte-identical-refactor).
367
+ function namesLocalGraph(args) {
368
+ return args.includes("--nodes");
369
+ }
370
+
371
+ async function cmdCompile(cfg, verb, args) {
358
372
  // brief <id> is sugar for compile --root <id>.
373
+ let compileArgs = args;
359
374
  if (verb === "brief") {
360
375
  const id = args[0];
361
376
  if (!id) {
362
377
  err("usage: spor brief <id>");
363
378
  return 1;
364
379
  }
365
- return passthrough("compile.js", ["--root", id, ...args.slice(1)]);
380
+ compileArgs = ["--root", id, ...args.slice(1)];
381
+ }
382
+ if (cfg.mode() === "remote" && !namesLocalGraph(compileArgs)) {
383
+ return await compileRemote(cfg, compileArgs);
384
+ }
385
+ return passthrough("compile.js", compileArgs);
386
+ }
387
+
388
+ // The remote arm of compile/brief. Mirrors the /spor:brief skill's remote
389
+ // resolution: a node id -> the raw node plus a title/summary-seeded /v1/digest
390
+ // for its neighborhood; free text -> POST /v1/digest. --skeleton has no server
391
+ // equivalent (it writes a local briefing-node file), so it fails fast. Output
392
+ // matches the local "nothing relevant" contract: exit 0 with empty stdout.
393
+ async function compileRemote(cfg, args) {
394
+ const root = optVal(args, "root");
395
+ const query = optVal(args, "query");
396
+ const project = optVal(args, "project");
397
+ const outFile = optVal(args, "out");
398
+ const minSim = optVal(args, "min-sim");
399
+
400
+ if (args.includes("--skeleton")) {
401
+ err("compile --skeleton is local-only — it writes a briefing-node skeleton from a local graph.");
402
+ err(" in remote mode the server compiles; use 'spor brief <id>' for a node's briefing,");
403
+ err(" or run in local mode (unset SPOR_SERVER, or pass --nodes <dir>) against a checkout.");
404
+ return 1;
405
+ }
406
+ if (!root && !query) {
407
+ err('usage: spor compile (--root <id> | --query "text") [--digest] [--project <slug>]');
408
+ return 1;
409
+ }
410
+
411
+ let text = "";
412
+ if (root) {
413
+ const r = await remote.get(cfg, `/v1/nodes/${encodeURIComponent(root)}`, { timeoutMs: 8000 });
414
+ if (r.transport) {
415
+ err(`offline — could not reach server (${r.error})`);
416
+ return 1;
417
+ }
418
+ if (r.status === 404) {
419
+ err(`no such node: ${root}`);
420
+ return 1;
421
+ }
422
+ if (!r.ok) {
423
+ err(`error ${r.status}`);
424
+ return 1;
425
+ }
426
+ const raw = (r.json && r.json.raw) || r.text || "";
427
+ // Seed the neighborhood digest from the node's own title/summary, exactly as
428
+ // the /spor:brief skill does (the REST /v1/digest is query-mode only — root
429
+ // compile is not exposed over REST, only via the query_graph MCP tool).
430
+ const seed = (r.json && (r.json.title || r.json.summary)) || fmField(raw, "title") || fmField(raw, "summary") || root;
431
+ const d = await remote.post(cfg, "/v1/digest", project ? { query: seed, project } : { query: seed }, { timeoutMs: 8000 });
432
+ const neighborhood = d.ok && d.json && d.json.found !== false ? d.json.text || "" : "";
433
+ text = neighborhood ? `${raw}\n\n${neighborhood}` : raw;
434
+ } else {
435
+ const body = { query };
436
+ if (project) body.project = project;
437
+ if (minSim != null) body.min_sim = parseFloat(minSim);
438
+ const d = await remote.post(cfg, "/v1/digest", body, { timeoutMs: 8000 });
439
+ if (d.transport) {
440
+ err(`offline — could not reach server (${d.error})`);
441
+ return 1;
442
+ }
443
+ if (!d.ok) {
444
+ err(`digest error ${d.status}`);
445
+ return 1;
446
+ }
447
+ if (!d.json || d.json.found === false) return 0; // nothing relevant — mirror local empty
448
+ text = d.json.text || "";
449
+ }
450
+
451
+ if (!text) return 0;
452
+ if (outFile) {
453
+ try {
454
+ fs.writeFileSync(outFile, text);
455
+ } catch (e) {
456
+ err(`could not write ${outFile}: ${e.message}`);
457
+ return 1;
458
+ }
459
+ } else {
460
+ out(text);
366
461
  }
367
- return passthrough("compile.js", args);
462
+ return 0;
463
+ }
464
+
465
+ // validate lints a LOCAL graph (lib/validate.js). Remote mode has no
466
+ // whole-graph lint endpoint — the server validates every write per node — so
467
+ // fail fast naming that, unless --nodes points at a local checkout to lint.
468
+ function cmdValidate(cfg, args) {
469
+ if (cfg.mode() === "remote" && !namesLocalGraph(args)) {
470
+ err("validate lints a LOCAL graph; in remote mode the server validates every write,");
471
+ err(" so there is no whole-graph lint over the API. Point --nodes at a local checkout");
472
+ err(" to lint it, or unset SPOR_SERVER to validate the local graph home.");
473
+ return 1;
474
+ }
475
+ return passthrough("validate.js", args);
368
476
  }
369
477
 
370
478
  // --- spor add / capture -------------------------------------------------
@@ -730,7 +838,7 @@ function cmdScope(enabled) {
730
838
  data.enabled = enabled;
731
839
  fs.writeFileSync(file, JSON.stringify(data, null, 2) + "\n");
732
840
  out(`${enabled ? "enabled" : "disabled"} Spor for ${root}`);
733
- out(` ${file}${enabled ? "" : " — hooks are now a no-op here; commit it to share the setting"}`);
841
+ out(` ${file} — hooks are now ${enabled ? "active" : "a no-op"} here; commit it to share the setting`);
734
842
  return 0;
735
843
  }
736
844
 
@@ -1845,8 +1953,8 @@ const COMMANDS = {
1845
1953
  },
1846
1954
  enable: {
1847
1955
  group: "Repo scoping", parse: "strict", args: "", options: {},
1848
- summary: "turn Spor back on for this repo (.spor.json)",
1849
- help: "Set { enabled: true } in this repo's committable .spor.json, undoing a prior\n'spor disable'.",
1956
+ summary: "opt this repo in (.spor.json)",
1957
+ help: "Set { enabled: true } in this repo's committable .spor.json. Spor is opt-in\nper repo — a repo with no .spor/.spor.json marker is a no-op — so this is how\nyou turn it on (and how you undo a prior 'spor disable'). Commit the file to\nshare the setting.",
1850
1958
  run: (cfg) => cmdScope(true),
1851
1959
  },
1852
1960
  link: {
@@ -1858,8 +1966,13 @@ const COMMANDS = {
1858
1966
  },
1859
1967
  compile: {
1860
1968
  group: "Repo scoping", parse: "raw", args: "<args>",
1861
- summary: "full neighborhood / digest (local; byte-identical)",
1862
- help: "Compile a node neighborhood or a prompt-time digest from the local graph.\nByte-identical passthrough to lib/compile.js (norm-cc-byte-identical-refactor).",
1969
+ summary: "full neighborhood / digest (local byte-identical; remote via the server)",
1970
+ help:
1971
+ "Compile a node neighborhood or a prompt-time digest. In local mode this is a\n" +
1972
+ "byte-identical passthrough to lib/compile.js (norm-cc-byte-identical-refactor).\n" +
1973
+ "In remote mode it dispatches to the server (--root/--query mirror the\n" +
1974
+ "/spor:brief skill: GET /v1/nodes then POST /v1/digest); --skeleton is local-\n" +
1975
+ "only. An explicit --nodes always names a local checkout, even under a server.",
1863
1976
  options: {
1864
1977
  root: { type: "string", value: "id", desc: "compile a node's neighborhood" },
1865
1978
  query: { type: "string", value: "text", desc: "compile from free-text (query mode)" },
@@ -1877,16 +1990,16 @@ const COMMANDS = {
1877
1990
  brief: {
1878
1991
  group: "Repo scoping", parse: "raw", args: "<id>",
1879
1992
  summary: "compile a briefing for a node (sugar for compile --root <id>)",
1880
- help: "Compile a briefing for one node — sugar for 'compile --root <id>'. Byte-\nidentical passthrough to lib/compile.js.",
1993
+ help: "Compile a briefing for one node — sugar for 'compile --root <id>'. Local mode\nis a byte-identical passthrough to lib/compile.js; remote mode dispatches to the\nserver (the raw node plus a /v1/digest neighborhood), like the /spor:brief skill.",
1881
1994
  examples: ["spor brief dec-cc-zero-dep-client"],
1882
1995
  run: (cfg, args) => cmdCompile(cfg, "brief", args),
1883
1996
  },
1884
1997
  validate: {
1885
1998
  group: "Repo scoping", parse: "raw", args: "",
1886
1999
  summary: "lint the local graph (byte-identical)",
1887
- help: "Lint the local graph and exit 1 on errors. Byte-identical passthrough to\nlib/validate.js.",
2000
+ help: "Lint the local graph and exit 1 on errors. Byte-identical passthrough to\nlib/validate.js. Local-only — in remote mode the server validates every write,\nso this fails fast unless --nodes points at a local checkout.",
1888
2001
  options: { nodes: { type: "string", value: "dir", desc: "graph nodes dir to lint" } },
1889
- run: (cfg, args) => passthrough("validate.js", args),
2002
+ run: (cfg, args) => cmdValidate(cfg, args),
1890
2003
  },
1891
2004
 
1892
2005
  // --- Dispatch ---
package/lib/config.js CHANGED
@@ -34,7 +34,10 @@ const home = require("./shell/home.js");
34
34
  // fallback, keeping byte-identical behavior independent of this table.
35
35
  const DEFAULTS = {
36
36
  mode: "auto", // auto | local | remote | off
37
- enabled: true, // per-repo no-op disable
37
+ // `enabled` is intentionally ABSENT from the defaults: the plugin is opt-IN
38
+ // per repo (task-spor-plugin-opt-in-default). Leaving it unset lets
39
+ // Config.enabled() tell "no one set this" (fall back to repo-marker presence)
40
+ // apart from an explicit true/false anywhere in the cascade. See enabled().
38
41
  search: { projects: { include: [], exclude: [], boost: {} } },
39
42
  // Local-mode `front` queue signal reconstructed from git history
40
43
  // (task-cc-local-front-productionize, dec-cc-queue-front-from-attribution):
@@ -66,6 +69,7 @@ const ENV_MAP = [
66
69
  ["TOKEN", "token"],
67
70
  ["HOME", "home"],
68
71
  ["NODES", "nodes"],
72
+ ["ENABLED", "enabled"], // SPOR_ENABLED=1 opts a repo in (=0 disables) via the cascade
69
73
  ["DISTILL_CMD", "distill.cmd"],
70
74
  ["DISTILL_MODEL", "distill.model"],
71
75
  ["DEBOUNCE", "distill.debounce"],
@@ -163,6 +167,26 @@ function repoConfigFiles(cwd) {
163
167
  return files.reverse(); // shallowest first
164
168
  }
165
169
 
170
+ // True when a repo-level opt-in MARKER exists anywhere from cwd up to the
171
+ // filesystem root: either a flat `.spor` identity marker or a `.spor.json`
172
+ // config file. Presence is the opt-in signal Config.enabled() falls back to
173
+ // when no explicit `enabled` flag is set anywhere in the cascade
174
+ // (task-spor-plugin-opt-in-default) — it marks a repo that `spor enable`,
175
+ // `spor link`, or `spor dispatch --backfill` has touched. Mirrors the
176
+ // nearest-ancestor walks used for `.spor.json` config and the `.spor` graph
177
+ // binding. Fail-open: existsSync never throws, so an unreadable level reads as
178
+ // absent rather than erroring.
179
+ function repoMarkerPresent(cwd) {
180
+ const seen = new Set();
181
+ for (let dir = cwd || ""; dir; dir = path.dirname(dir)) {
182
+ if (seen.has(dir)) break;
183
+ seen.add(dir);
184
+ if (fs.existsSync(path.join(dir, ".spor")) || fs.existsSync(path.join(dir, ".spor.json"))) return true;
185
+ if (dir === path.dirname(dir)) break; // hit fs root
186
+ }
187
+ return false;
188
+ }
189
+
166
190
  // A per-repo `.spor` marker may bind this repo to a specific graph home via a
167
191
  // `graph: <path>` key (issue-cc-local-mode-graph-sharing-gap,
168
192
  // dec-spor-local-mode-sharing-boundary) — free local mode's async, git-shared
@@ -288,7 +312,12 @@ function loadConfig(opts = {}) {
288
312
  if (!KNOWN_KEYS.has(k)) warnings.push(`unknown config key '${k}' ignored`);
289
313
  }
290
314
 
291
- return new Config(merged, { warnings, env, cwd, markerGraph });
315
+ // Opt-in marker presence (task-spor-plugin-opt-in-default): computed from the
316
+ // same cwd ancestry as the config/graph walks. enabled() uses it only when no
317
+ // explicit `enabled` flag was resolved above.
318
+ const repoMarker = repoMarkerPresent(cwd);
319
+
320
+ return new Config(merged, { warnings, env, cwd, markerGraph, repoMarker });
292
321
  }
293
322
 
294
323
  class Config {
@@ -298,6 +327,7 @@ class Config {
298
327
  this._env = meta.env;
299
328
  this._cwd = meta.cwd;
300
329
  this._markerGraph = meta.markerGraph || null;
330
+ this._repoMarker = !!meta.repoMarker;
301
331
  }
302
332
 
303
333
  // Raw resolved value at a dotted path, else fallback. Pass the caller's
@@ -370,9 +400,29 @@ class Config {
370
400
  if (m && m !== "auto") return m;
371
401
  return this.get("server", undefined) ? "remote" : "local";
372
402
  }
403
+ // Opt-in activation (task-spor-plugin-opt-in-default). Installing the npm
404
+ // package + Claude Code plugin must NOT make every repo you open participate:
405
+ // a markerless side project stays a full no-op so it never injects context or
406
+ // distills nodes into the shared graph just because you ran an agent there. A
407
+ // repo is active when, checked in order:
408
+ // 1. mode is not "off" (an explicit mode:off is the hard kill, unchanged); AND
409
+ // 2a. an `enabled` flag was resolved anywhere in the cascade — repo
410
+ // `.spor.json`, user/global config.json, SPOR_ENABLED env, or a CLI
411
+ // flag — honored verbatim (true activates, false disables); OR
412
+ // 2b. no explicit flag, in which case the repo is active iff a repo-level
413
+ // opt-in marker (`.spor` or `.spor.json`) sits in the cwd ancestry,
414
+ // i.e. `spor enable` / `spor link` / `spor dispatch --backfill` touched
415
+ // it.
416
+ // Default — no flag, no marker — is OFF. This is a deliberate behavior change
417
+ // from the prior default-on (so the activation gate is NOT byte-identical);
418
+ // every other resolved value stays byte-identical (norm-cc-byte-identical-
419
+ // refactor).
373
420
  enabled() {
374
- return this.get("mode", "auto") !== "off" && this.getBool("enabled", true);
421
+ if (this.get("mode", "auto") === "off") return false;
422
+ const explicit = this.get("enabled", undefined);
423
+ if (explicit !== undefined) return this.getBool("enabled", true);
424
+ return this._repoMarker;
375
425
  }
376
426
  }
377
427
 
378
- module.exports = { loadConfig, Config, DEFAULTS, ENV_MAP, repoMarkerGraph };
428
+ module.exports = { loadConfig, Config, DEFAULTS, ENV_MAP, repoMarkerGraph, repoMarkerPresent };
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@sporhq/spor",
3
- "version": "0.3.0",
3
+ "version": "0.3.1",
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",
@@ -96,7 +96,7 @@ async function doctor() {
96
96
  const kv = (label, value) => L.push(` ${(label + ":").padEnd(13)}${value}`);
97
97
 
98
98
  L.push("spor doctor — client health");
99
- kv("mode", `${mode}${enabled ? "" : " (DISABLED here — plugin is a no-op for this repo)"}`);
99
+ kv("mode", `${mode}${enabled ? "" : " (not enabled here — run 'spor enable' to opt in; hooks are a no-op for this repo)"}`);
100
100
  kv("graph home", graph);
101
101
 
102
102
  if (u.serverBase()) {
@@ -0,0 +1,158 @@
1
+ ---
2
+ name: spor
3
+ description: Load this skill whenever you work with Spor — a project-specific knowledge-graph tool your training does not cover — so you use the right CLI syntax, node and edge format, MCP tools, and REST API instead of rediscovering them each time. Read it before any Spor graph operation — querying or searching the graph, reading or writing nodes, adding edges, capturing or deferring work, running spor CLI commands or Spor MCP tools, working in local vs remote mode, or defining a new node/edge type or schema. Also use it for any conceptual question about Spor (what it is, how it works, the mental model of nodes and edges, what the auto-injected briefing is) and to choose among the /spor commands — defer, brief, next, correct, backfill. When unsure about anything Spor, consult this first rather than guessing.
4
+ ---
5
+
6
+ # Orient yourself in Spor
7
+
8
+ Spor is a typed, versioned **knowledge graph** that holds the durable
9
+ outcomes of work — decisions (including the ones that were dismissed, and
10
+ why), tasks, issues, norms, specs, questions — one fact per node, joined by
11
+ typed edges. It exists because that knowledge otherwise dies in branches, PR
12
+ threads, and chat scrollback: the next session starts cold and the team
13
+ relitigates settled questions. Spor persists it across sessions and
14
+ teammates, indexes it for relevance, **resurfaces deferred work** the moment
15
+ a session touches its neighborhood, and keeps dismissed approaches — the "why
16
+ we didn't" — retrievable.
17
+
18
+ Much of the time you don't touch it by hand: in Claude Code, hooks compile a
19
+ briefing at session start and a per-prompt digest automatically, and at session
20
+ end a distiller writes new nodes back. But when you need to operate on the graph
21
+ yourself — query or search it, read or write nodes and edges, run a `spor`
22
+ command or a Spor MCP tool, or extend the schema — this skill is your standing
23
+ reference, so you use the right syntax, formats, and tools instead of
24
+ rediscovering them each time. It also explains the model when you (or the user)
25
+ just need to understand Spor, and routes you to the action skills for specific
26
+ operations.
27
+
28
+ ## The model in one minute
29
+
30
+ - **Nodes** are markdown files, one fact each, with a `type` and a kebab-case
31
+ `id` that starts with the type's prefix (`dec-`, `task-`, `issue-`, `norm-`,
32
+ `spec-`/`art-`, `question-`, …). The `summary` line must stand on its own —
33
+ most consumers only ever see it.
34
+ - **Edges** are typed and directional, written from the source node's
35
+ perspective: `supersedes`, `blocks`, `resolves`, `derived-from`,
36
+ `constrained-by`, `governed-by`, `decided-in`, `relates-to`, `mentions`, …
37
+ They carry the lineage a flat list can't — *why* a node exists and what it
38
+ depends on. An edge may point at an id that doesn't exist yet; the compiler
39
+ just skips it (a marker that the node is worth creating).
40
+ - **The ontology is data you can extend.** Those type and edge tables aren't
41
+ fixed — each type is itself a `type: schema` node (QUEUE.md §2). The seed
42
+ pack shipped in `lib/seed/` is the default set; a schema node you add to a
43
+ graph overrides or extends it, so an org can shape its own ontology.
44
+
45
+ For the full node-type and edge-type registries, the node file format, and the
46
+ project-slug rule, read **`references/concepts.md`**.
47
+
48
+ ## Are you local or remote? (resolve it silently)
49
+
50
+ Spor runs against your **personal** graph (local mode) or a **team server**
51
+ (remote mode). Tell which from the status line injected at session start:
52
+ `team graph: …` = remote, `A Spor knowledge graph is active: …` = local. If it
53
+ isn't in context, run `spor status` (or test `[ -n "$SPOR_SERVER" ]`). Don't echo
54
+ `SPOR_SERVER`/`SPOR_TOKEN`/`SPOR_HOME` or announce the mode unless the user
55
+ asks — it's plumbing.
56
+
57
+ What actually differs:
58
+
59
+ | | local (`SPOR_SERVER` unset) | remote (`SPOR_SERVER` set) |
60
+ |---|---|---|
61
+ | **the graph** | files under `$SPOR_HOME/nodes/` (default `~/.spor/`) | lives on the server; client caches |
62
+ | **writing** | you write the node markdown yourself; the distiller commits it | the server's ingestion model types raw text into nodes; writes are attributed to you |
63
+ | **reading** | the `spor` CLI, against your local files | the `spor` CLI, the MCP tools, or their REST twins (API.md §3) |
64
+
65
+ `spor status` prints the resolved mode, graph home, project, and identity if
66
+ you need to confirm. Settings resolve through a cascade (CLI flag > env >
67
+ repo `.spor.json` > user/global config > defaults), so when in doubt let
68
+ `spor status` report the effective values rather than guessing.
69
+
70
+ ## Reading and writing the graph
71
+
72
+ Work the graph in a loop: **ORIENT → TRAVERSE → COMMIT**. Find where to start,
73
+ walk outward through edges until you have enough context, then write the
74
+ outcome back so the next session inherits it.
75
+
76
+ **The `spor` CLI** is the simplest surface. A few verbs work in either mode;
77
+ the rest are mode-specific (`spor status` confirms which mode you're in):
78
+
79
+ ```bash
80
+ # either mode
81
+ spor status # resolved mode, graph, project, identity, health
82
+ spor next [--project <slug>] # the ranked decision queue — "what's next"
83
+ spor get <id> # one node by id
84
+ spor add "<2-3 sentences>" # capture a node (typed file locally; /v1/capture remotely)
85
+
86
+ # remote (team server) only
87
+ spor lens [<id>] # list saved views, or render one
88
+
89
+ # dual-mode (local passthrough / remote dispatch to the server)
90
+ spor compile --query "<text>" # search → compiled neighborhood (--digest for compact)
91
+ spor brief <id> # a briefing for one node (compile --root <id>)
92
+
93
+ # local (personal graph) only — fail fast with a redirect in remote mode
94
+ spor validate # lint the local graph (server validates per-write remotely)
95
+ spor compile --root <id> --skeleton # writes a local briefing-node skeleton
96
+ ```
97
+
98
+ `compile`/`brief` are mode-aware: local mode runs the in-repo compiler, remote
99
+ mode dispatches to the server (mirroring `/spor:brief`). Much of this is what
100
+ the session hooks already inject for you automatically; pulling one on demand
101
+ with `spor brief`, `spor compile`, or `/spor:brief` is the same briefing.
102
+ Passing `--nodes <dir>` always targets that local checkout, even under a server.
103
+
104
+ **In Cowork (Anthropic's chat workspace) and Claude Code with the connector**
105
+ there is no shell and no ambient injection — reach the graph through the
106
+ **Spor MCP tools** instead:
107
+ `query_graph` (ORIENT/TRAVERSE: free-text search, or `root_id` to compile one
108
+ node's neighborhood), `get_node` (raw node + revision), `my_queue` (the ranked
109
+ queue), `render_lens` (a saved board/table/lineage view; no id lists them),
110
+ and to COMMIT: `capture` (raw prose → server types it — reach for this when
111
+ unsure of the shape), or the precise writes `put_node` / `add_edge` /
112
+ `set_status`. Close loops with edges: answer a question with a node carrying an
113
+ `answers` edge; close work with a `resolves` edge from a `decision`/`artifact`.
114
+
115
+ The deeper, mode-specific flows — the exact REST calls, and the rule that
116
+ *completing* a task or issue needs a resolving node on the graph first — live
117
+ in the action skills below; reach for those rather than reinventing them.
118
+
119
+ ## Adding a node or edge type
120
+
121
+ Because schemas are themselves nodes, you extend the ontology by **writing a
122
+ node**, not editing code: a `type: schema` node with `kind: node-schema` (or
123
+ `edge-schema`), a CalVer `schema_version`, a fenced-JSON payload, and optional
124
+ sandboxed `validate`/`transitions`/`queueSignals` functions. A resident schema
125
+ overrides its seed equivalent; the server forces new schemas to `proposed` and
126
+ a *different* identity must activate them (no self-approval).
127
+
128
+ For an org-specific type, that schema node lives *in your graph* and overrides
129
+ the seed default — you don't edit the plugin's source. It's still a contract
130
+ change (every node of that type depends on it), so version it with CalVer and
131
+ keep it backward-readable. Before doing it, read
132
+ **`references/authoring-schemas.md`**.
133
+
134
+ ## Which skill do I use?
135
+
136
+ This skill orients; these do the work. Route to them rather than improvising:
137
+
138
+ | You want to… | Use | Trigger |
139
+ |---|---|---|
140
+ | File deferred / discovered work, a follow-up, a dismissed approach | **/spor:defer** | "remember / file / defer this", work postponed mid-session |
141
+ | Get a briefing for a task or node before starting | **/spor:brief** | starting non-trivial work; `/spor:brief <query\|id>` |
142
+ | See what to work on next | **/spor:next** | "what's next / my queue / the backlog", triage |
143
+ | Fix a briefing that was wrong, missing, or stale | **/spor:correct** | "the briefing was wrong / missed / included junk" |
144
+ | Bootstrap a repo's graph, or group repos into projects | **/spor:backfill** | onboarding a repo, "organize my repos" |
145
+ | Read/write the team graph in Cowork (no hooks there) | **/spor:team-graph** | any graph work in Cowork |
146
+
147
+ If a request is just "explain Spor" or "which Spor thing do I use for X",
148
+ answer from this skill — in plain language for a newcomer, without dumping the
149
+ whole ontology unless asked.
150
+
151
+ ## Read more
152
+
153
+ - **`references/concepts.md`** — full node/edge registries, node file format,
154
+ project-slug convention.
155
+ - **`references/authoring-schemas.md`** — how to add or change a schema.
156
+ - **README.md** (architecture, roadmap), **GRAPH.md** (node format + seed
157
+ ontology), **QUEUE.md** (capture, queue, schema registry), **API.md** (the
158
+ MCP + REST contract). All at the plugin root.
@@ -0,0 +1,199 @@
1
+ # Authoring and changing Spor schemas
2
+
3
+ Node and edge types are **data, not code**. Each type is a `type: schema`
4
+ node. The set Spor runs on is assembled at load time from two layers: the seed
5
+ pack shipped in `lib/seed/` (the defaults) and any schema nodes resident in
6
+ your graph, which override the seed entry of the same `node_type`/`edge_type`
7
+ **wholesale**.
8
+
9
+ So you extend or change the ontology by writing a node — usually a
10
+ graph-resident schema node carrying an org-specific rule. That is deliberately
11
+ easy mechanically and deliberately disciplined contractually. Read both halves
12
+ below before you do it.
13
+
14
+ ## A schema node's shape
15
+
16
+ Frontmatter: `id: schema-<type>` (or `schema-edge-<type>`), `type: schema`,
17
+ `kind: node-schema` or `edge-schema`, a CalVer `schema_version`
18
+ (`YYYY.MM.DD.MICRO`), a `title`, a stand-alone `summary`, and a `date`. The
19
+ body holds prose, then a fenced **JSON payload**, then optional fenced **JS
20
+ functions**.
21
+
22
+ ### Node schema — JSON payload keys
23
+
24
+ ```json
25
+ {
26
+ "node_type": "task",
27
+ "description": "active or planned work",
28
+ "prefix": ["task-"],
29
+ "queueable": true,
30
+ "always_on": false,
31
+ "traversable": true,
32
+ "capturable": true,
33
+ "status": { "non_resolving": ["abandoned"] }
34
+ }
35
+ ```
36
+
37
+ - `node_type` / `prefix` — the type and its id prefix(es).
38
+ - `queueable` — may appear in the decision queue.
39
+ - `always_on` — rides along in every project-relevant compile (norms).
40
+ - `traversable: false` — excluded from lineage walks (briefings, corrections).
41
+ - `capturable: false` — never produced by the capture/ingest path.
42
+ - `status.non_resolving` — statuses that count as *not* resolving for the
43
+ completion gate (an `abandoned` task resolves nothing).
44
+
45
+ Note what the payload does **not** hold: there is no field list and no status
46
+ *enum*. Extra frontmatter fields are allowed as-is — a custom `severity:` line
47
+ on your nodes just works, no declaration needed. To *require* a field or pin a
48
+ status to a fixed set, enforce it in the attached functions below, not in JSON.
49
+
50
+ ### Edge schema — JSON payload keys
51
+
52
+ ```json
53
+ {
54
+ "edge_type": "blocks",
55
+ "description": "target cannot proceed until this node does",
56
+ "weight": 0.7,
57
+ "inverse_label": "blocked-by",
58
+ "aliases": ["block"]
59
+ }
60
+ ```
61
+
62
+ - `weight` — decay across compile hops (1.0 structural → 0.3 weak).
63
+ - `inverse_label` — how the edge reads from the target; inverse forms are
64
+ accepted on write and flipped onto the target.
65
+ - `aliases` — same-direction synonyms normalized to the canonical spelling.
66
+
67
+ ### Attached behavior (optional JS)
68
+
69
+ A schema may carry fenced `js` blocks exporting **pure** functions, run
70
+ server-side in a sandbox (no I/O, no clock) at the JSON boundary. The one most
71
+ types use:
72
+
73
+ - `transitions(current, proposed, view)` — gate a status change; runs on
74
+ **update only** (the create path is ungated). Return `{allow: true}` or
75
+ `{allow: false, reason: "..."}`. Make the reason actionable: a writing agent
76
+ reads it and retries. This is also where you pin a type's legal status set —
77
+ reject any proposed status outside it. `current` is the stored node (`null` if
78
+ unparseable), `proposed` the incoming one, and `view` a read-only join the
79
+ server computes: `view.resolvers` (live inbound `resolves`/`answers` edges,
80
+ pre-filtered to resolving states), `view.targets`, `view.actor`,
81
+ `view.approvals`, `view.non_resolving_statuses`. (The task type's gate uses
82
+ `view.resolvers` to require a resolving `decision`/`artifact` before `done`.)
83
+
84
+ Two more hooks are supported but unused by the seed types:
85
+
86
+ - `validate(node)` — runs at the door on **every write (create and update)**.
87
+ Extra field checks beyond the base validator (e.g. "a `severity` is required
88
+ and must be one of …"). Return a list of error message strings, `[]` meaning
89
+ valid; the server prefixes each with `<schema-id> validate():`. The sandbox
90
+ passes only the node — there is no second argument.
91
+ - `queueSignals(node, ctx)` — contribute a `{name: number}` map of ranking
92
+ signals to the decision queue.
93
+
94
+ To read a real, current attached function, fetch a live schema:
95
+ `spor get schema-task` shows a two-gate `transitions`. (The seed schemas also
96
+ ship in the plugin's `lib/seed/` if you have a checkout.)
97
+
98
+ ### A complete example
99
+
100
+ A custom `escalation` type — queueable, with an `open → mitigated → closed`
101
+ status machine and a required `severity` — is one whole node. The outer fence
102
+ is shown with four backticks only so the inner blocks display; in the file you
103
+ write, use normal three-backtick fences.
104
+
105
+ ````markdown
106
+ ---
107
+ id: schema-escalation
108
+ type: schema
109
+ kind: node-schema
110
+ schema_version: 2026.06.16.0
111
+ title: Customer escalation
112
+ summary: A customer escalation tracked open -> mitigated -> closed, with a severity.
113
+ date: 2026-06-16
114
+ ---
115
+
116
+ Escalations as first-class, queueable nodes, so live ones surface in the queue
117
+ and the mitigation history is kept.
118
+
119
+ ```json
120
+ {
121
+ "node_type": "escalation",
122
+ "description": "a customer escalation and its mitigation lineage",
123
+ "prefix": ["esc-"],
124
+ "queueable": true,
125
+ "status": { "non_resolving": ["closed"] }
126
+ }
127
+ ```
128
+
129
+ ```js
130
+ const SEVERITIES = ["sev1", "sev2", "sev3"];
131
+ const FLOW = { open: ["mitigated", "closed"], mitigated: ["closed", "open"], closed: ["open"] };
132
+
133
+ export function validate(node) {
134
+ if (!node.severity) return ["escalation requires a severity"];
135
+ if (!SEVERITIES.includes(node.severity)) return [`severity must be one of: ${SEVERITIES.join(", ")}`];
136
+ return [];
137
+ }
138
+
139
+ export function transitions(current, proposed) {
140
+ const from = (current && current.status) || "";
141
+ const to = (proposed && proposed.status) || "";
142
+ if (!to || to === from) return { allow: true };
143
+ if ((FLOW[from] || []).includes(to)) return { allow: true };
144
+ return { allow: false, reason: `escalation can't go ${from || "(new)"} -> ${to}; allowed: ${(FLOW[from] || []).join(", ") || "(none)"}` };
145
+ }
146
+ ```
147
+ ````
148
+
149
+ An escalation node then just carries `severity: sev2` in its frontmatter and
150
+ moves through the gated statuses. The `transitions` contract here is exactly
151
+ what the seed `schema-task` uses (`spor get schema-task` to see a live one);
152
+ `validate` follows the validator's usual error-list convention.
153
+
154
+ ## Activating a schema (remote)
155
+
156
+ When you write a schema node through the server, it is forced to
157
+ `status: proposed` (any payload status is discarded) and stays **inert** until
158
+ a *different* identity flips it to `active` — there is no self-approval. It
159
+ surfaces in the decision queue as a `suggest: approve` item; a human (or a
160
+ second agent) reviews and runs `set_status schema-<type> active`. Locally
161
+ there's no ingester, so you write the file yourself (e.g.
162
+ `$SPOR_HOME/nodes/schema-escalation.md`) and it's live once it validates.
163
+
164
+ ## The discipline
165
+
166
+ Changing a schema is a contract change — every node of that type, and every
167
+ reader of them, depends on it. Treat it with care:
168
+
169
+ 1. **Prefer a graph-resident override to a seed edit.** If only your graph
170
+ needs a rule, write a schema node *in your graph* that overrides the seed
171
+ entry — don't fork the seed pack. The seed is the shared default for
172
+ everyone; your override is yours alone and travels with your graph.
173
+ 2. **Version honestly.** Bump `schema_version` (CalVer) with a migration only
174
+ when the change is **not** backward-readable. If existing nodes still parse
175
+ and mean the same thing — a new write-time gate, a new optional field —
176
+ it's backward-readable: no node-shape change, no migration needed. The seed
177
+ `schema-task` gates are written this way; each header notes whether it
178
+ needed an upgrade chain.
179
+ 3. **Keep the payload and any attached functions consistent.** A schema whose
180
+ `transitions()` rejects a status its own `description` advertises will
181
+ frustrate every writer. Test the change (below) before relying on it.
182
+
183
+ (If you're *contributing to the plugin itself* by editing the seed pack rather
184
+ than overriding it in your graph, more has to stay in step — the GRAPH.md docs,
185
+ the distiller prompts, the bundled skills, and the test suite — and such
186
+ refactors must stay byte-identical against a real graph. Most users never need
187
+ this: an org rule belongs in a graph-resident schema, not a seed fork.)
188
+
189
+ ## Sanity checks
190
+
191
+ ```bash
192
+ spor validate # lint the graph; fix anything it flags
193
+ spor get schema-<type> # read back the effective schema your graph will use
194
+ spor compile --root <id> # confirm a node of the new type compiles cleanly
195
+ ```
196
+
197
+ If the automatic distiller starts emitting an edge variant you didn't define,
198
+ that's a schema gap — add or adjust the edge schema rather than accepting the
199
+ stray form.
@@ -0,0 +1,130 @@
1
+ # Spor concepts reference
2
+
3
+ The full ontology behind the orientation in SKILL.md. The seed pack
4
+ (`lib/seed/`) is the source of truth; an org's graph-resident `type: schema`
5
+ nodes override or extend it. Anything here may be overridden in a given graph
6
+ — check the effective schema in your own graph (`spor get schema-<type>`) when
7
+ exactness matters.
8
+
9
+ ## Node types
10
+
11
+ One file per type at `lib/seed/schema-<type>.md`. "Queueable" = can appear in
12
+ the decision queue (QUEUE.md §4).
13
+
14
+ | type | id prefix | purpose | notes |
15
+ |---|---|---|---|
16
+ | decision | `dec-` | a choice that was made, with the why | status `active`/`superseded`/`rejected`; a **rejected** decision is a dismissed approach — keep the reason |
17
+ | task | `task-` | active or planned work | status `open`/`active`/`done`/`abandoned`; queueable; `done` needs a resolving `decision`/`artifact` (see SKILL routing → /spor:next) |
18
+ | issue | `issue-` | a defect and its resolution lineage | status `open`/`active`/`resolved`; queueable |
19
+ | incident | `inc-` | something that went wrong in operation | queueable |
20
+ | artifact | `art-`, `spec-` | a document, spec, module, or build product | optional delivery status `in-review`/`approved`/`merged`/`released` |
21
+ | norm | `norm-` | a standing convention or constraint | `always_on: true` — rides along in every project-relevant compile (capped to the topically relevant subset) |
22
+ | briefing | `brief-` | a compiled briefing (output of the system) | `traversable: false` (never walked) and `capturable: false` |
23
+ | correction | `corr-` | a standing fix to a briefing (pin/exclude/guidance) | `traversable: false`; applied at every future compile of its target |
24
+ | question | `question-` | a routed ask the graph couldn't answer | queueable; status `open`/`answered`; joins the queue until answered |
25
+ | person | `person-` | an org member | anchor for `$viewer` binding and question routing |
26
+ | capture-pending | `cap-` | raw captured text that fit no schema | born status-less; closes only as `merged` or `rejected` |
27
+ | finding | `find-` | a gardener observation (stale anchor, cold work) | filed as a queue item |
28
+ | repo | `repo-` | a durable git-repo identity | carries `slugs:` aliases + `fingerprints:`; heals renames at read time |
29
+ | project | `proj-` | a stable grouping above repos | owns members via inbound `grouped-under` edges; owns no slugs/fingerprints itself |
30
+ | workflow | `wf-` | a repeatable automation DAG | created `proposed`, inert until activated; queueable |
31
+ | workflow-run | `run-` | one execution of a workflow | queueable when stuck; `capturable: false` |
32
+
33
+ ## Edge types
34
+
35
+ One file per type at `lib/seed/schema-edge-<type>.md`. Written **source →
36
+ target**. `weight` sets how much the edge decays across compile hops (high =
37
+ structural). `inverse_label` is how the edge reads from the target's side;
38
+ inverse forms are accepted on write and **flipped onto the target**. `aliases`
39
+ are same-direction synonyms renamed at write time.
40
+
41
+ | edge | weight | meaning (source → target) | inverse / aliases |
42
+ |---|---|---|---|
43
+ | supersedes | 1.0 | this replaces the target; target is stale | inverse `superseded-by`; alias `supercedes` |
44
+ | constrained-by | 1.0 | the target limits what this may do | — |
45
+ | governed-by | 0.95 | the target is the norm/policy this falls under | — |
46
+ | derived-from | 0.9 | this was produced from the target | alias `derives-from` |
47
+ | decided-in | 0.9 | the choice here was made in the target | — |
48
+ | resolves | 0.9 | this fixes/closes the target | — |
49
+ | triggered-by | 0.7 | this run was triggered by the target | — |
50
+ | performs | 0.8 | this run is an execution of the target workflow | — |
51
+ | blocks | 0.7 | the target can't proceed until this does | inverse `blocked-by` |
52
+ | answers | 0.7 | this answers the target question | inverse `answered-by` |
53
+ | assigned | 0.5 | work assigned to this person | — |
54
+ | relates-to | 0.5 | weak association | alias `related-to` |
55
+ | mentions | 0.5 | weakest association | — |
56
+ | stewards | 0.4 | this person stewards the target area/spec/norm | question-routing key |
57
+ | grouped-under | 0.3 | this repo's home project grouping (structural) | inverse `groups` |
58
+ | routed-to | 0.3 | this question is routed to that person | — |
59
+ | compiled-for | — | briefing → the task/query it was compiled for | provenance only |
60
+ | shaped-by | — | briefing → the corrections that shaped it | provenance only |
61
+
62
+ **Ride-along flags** (set in a schema's JSON payload):
63
+ - `always_on: true` (norm) — injected into every project-relevant compile.
64
+ - `traversable: false` (briefing, correction) — excluded from lineage walks.
65
+ - `capturable: false` (briefing, workflow-run) — never produced by capture.
66
+
67
+ Don't invent edge variants. The automatic distiller sometimes emits forms like
68
+ `related-to`/`supercedes`/`derives-from`; those normalize to the canonical
69
+ spelling on write — they're the *only* accepted non-canonical forms. A genuinely
70
+ new relationship needs a new edge schema (see `authoring-schemas.md`).
71
+
72
+ ## Node file format
73
+
74
+ A node is one markdown file; `id` = filename minus `.md`. The frontmatter
75
+ parser is **regex-based, not a YAML library** — it supports simple
76
+ `key: value`, YAML folded multi-line values (indented continuations),
77
+ `pin:`/`exclude:` inline lists, and `- {type: X, to: Y}` edges. Don't use any
78
+ other YAML construct.
79
+
80
+ ```markdown
81
+ ---
82
+ id: dec-export-csv-format # required; kebab-case, starts with the type prefix
83
+ type: decision # required; a type in the registry
84
+ project: meridian # the repo/project slug (legacy spelling: repo:)
85
+ title: Export defaults to CSV # required; a one-line human title
86
+ summary: One or two sentences that stand entirely on their own. # required
87
+ status: active # optional; the legal set is enforced by the type's schema, not listed here
88
+ date: 2026-06-09 # required; the EVENT date (not creation date), YYYY-MM-DD
89
+ edges:
90
+ - {type: derived-from, to: spec-export-schema}
91
+ - {type: supersedes, to: dec-export-json-only}
92
+ ---
93
+
94
+ The body — a few paragraphs at most. Shown at full resolution when the node
95
+ scores high in a compile, so write for a reader with zero session context. If
96
+ a load-bearing reason exists ("because the release is Friday"), record it here.
97
+ One fact per node; if you're writing "and also…", split it.
98
+ ```
99
+
100
+ Server-stamped fields you don't set by hand: `author`, `authored_via`. Other
101
+ type-specific fields exist (`wake:` dormancy date; `commits:` linked git shas;
102
+ `pin:`/`exclude:` on corrections; `slugs:`/`fingerprints:` on repo nodes;
103
+ `roles:`/`queue_mute:` on person nodes) — see GRAPH.md for the complete list.
104
+
105
+ Validate any local node you write: `spor validate` (or
106
+ `node lib/validate.js`), and fix what it flags.
107
+
108
+ ## Project slug convention
109
+
110
+ A node's `project:` stamp and most reads are scoped by a **project slug**,
111
+ derived from the session's cwd:
112
+
113
+ 1. A committed `.spor` marker file wins — `repo: <slug>` (legacy `project:`),
114
+ read from the nearest ancestor (cwd → repo root), so a monorepo subtree
115
+ marker can split one repo into distinct identities. The value must already
116
+ be canonical (`^[a-z0-9][a-z0-9-]*$`); a non-matching value is ignored.
117
+ 2. Otherwise: the kebab-cased basename of `git rev-parse --show-toplevel`
118
+ (lowercased, runs of non-alphanumerics → `-`, trimmed). `My_Repo` →
119
+ `my-repo`.
120
+ 3. A git **worktree** infers from its main repo's basename, so every worktree
121
+ of one repo shares one identity.
122
+
123
+ The client and server compute the slug identically, so a slug derived locally
124
+ always matches what the server expects (the canonical form is
125
+ `^[a-z0-9][a-z0-9-]*$`). A repo's home is a `type: project` grouping it sits
126
+ under via a `grouped-under` edge;
127
+ project-scoped reads union every repo grouped under that project, and a bare
128
+ repo slug resolves *up* to its grouping. Renaming a repo changes its slug;
129
+ `type: project`/`type: repo` identity nodes with `slugs:` alias lists heal that
130
+ at read time so old `project:` stamps still match.