@sporhq/spor 0.3.0 → 0.4.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -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.4.0",
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
@@ -189,6 +336,21 @@ their original order), so the briefing degrades by relevance rather than by the
189
336
  downstream 7KB session-start body truncation. A project-blind compile keeps
190
337
  every norm, exactly as before.
191
338
 
339
+ Project scope resolves to the whole home-project **grouping union**, so a
340
+ grouping that deliberately spans heterogeneous repos (a terraform IaC repo, a
341
+ Go service, a Python service) would still cross-pollinate norms. A norm may
342
+ **narrow** its ride-along with optional flat `applies_to_*` selectors, matched
343
+ against the session's OWN repo (task-cc-norm-ride-along-repo-tag-scope):
344
+ `applies_to_tags: [python]` (∩ the session repo node's `tags`, schema-repo),
345
+ `applies_to_repos: [repo-x]` (the session repo), `applies_to_projects:
346
+ [proj-y]` (a grouping the session repo belongs to). Matching is OR across axes,
347
+ ANY within an axis — deliberately unlike the policy layer's `governs`
348
+ (AND-across-axes). A norm that declares any `applies_to_*` and matches none is
349
+ **excluded** (strict, including in a repo with no `tags` — repo tagging is the
350
+ opt-in that turns scoped norms on); a norm with none keeps the project-scoped
351
+ behavior above, so a graph using no `applies_to_*` is byte-identical
352
+ (norm-cc-byte-identical-refactor).
353
+
192
354
  Because a norm rides along with no relevance gate and the team trust model lets
193
355
  every writer author one, the briefing renderer treats norm bodies as an
194
356
  **injection surface** (issue-cc-norm-always-on-injection): each is quoted as
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 };
@@ -89,13 +89,16 @@ const DEFAULT_MIN_SIM = 0.08;
89
89
  // Regex-based frontmatter parser (hard rule: no YAML library — zero deps).
90
90
  // Supports simple
91
91
  // key: value, YAML folded multi-line values (indented continuations),
92
- // pin:/exclude:/queue_mute:/commits:/slugs:/fingerprints:/roles: inline lists,
92
+ // pin:/exclude:/queue_mute:/commits:/slugs:/fingerprints:/roles:/tags:/
93
+ // applies_to_tags:/applies_to_repos:/applies_to_projects: inline lists,
93
94
  // and "- {type: X, to: Y}" edges. commits entries are repo-qualified shas
94
95
  // ("repo@sha", task-cc-commit-linking); slugs/fingerprints are the project
95
96
  // node's alias and repo-evidence registers (task-cc-project-identity-nodes);
96
97
  // roles is the person node's role-list register the org-defined policy layer's
97
- // quorum gate counts qualified approvals against (task-cc-policy-layer)
98
- // flat strings, not objects, by parser design.
98
+ // quorum gate counts qualified approvals against (task-cc-policy-layer); tags is
99
+ // the repo node's free-form label register, and applies_to_* are a norm's
100
+ // repo/tag-scope selectors matched against it (task-cc-norm-ride-along-repo-tag-
101
+ // scope) — flat strings, not objects, by parser design.
99
102
  function parseFrontmatter(raw, file) {
100
103
  const m = raw.match(/^---\n([\s\S]*?)\n---\n?([\s\S]*)$/);
101
104
  if (!m) throw new Error(`no frontmatter in ${file}`);
@@ -103,7 +106,7 @@ function parseFrontmatter(raw, file) {
103
106
  const node = { edges: [], pin: [], exclude: [], body: body.trim(), file };
104
107
  let lastKey = null; // for YAML folded scalars (indented continuation lines)
105
108
  for (const line of fm.split("\n")) {
106
- const list = line.match(/^(pin|exclude|queue_mute|commits|slugs|fingerprints|roles):\s*\[([^\]]*)\]/);
109
+ const list = line.match(/^(pin|exclude|queue_mute|commits|slugs|fingerprints|roles|tags|applies_to_tags|applies_to_repos|applies_to_projects):\s*\[([^\]]*)\]/);
107
110
  if (list) { node[list[1]] = list[2].split(",").map((s) => s.trim()).filter(Boolean); lastKey = null; continue; }
108
111
  const kv = line.match(/^(\w[\w-]*):\s*(.*)$/);
109
112
  if (kv) {
@@ -606,8 +609,43 @@ function compile(graph, opts = {}) {
606
609
  // Project-blind compiles (sessionProject == null) keep every norm, so that
607
610
  // path stays byte-identical. Then cap the section to NORM_CAP, ordered by
608
611
  // topical relevance, so it degrades by relevance rather than byte truncation.
609
- const normRidesAlong = (n) =>
610
- sessionProject == null || !n.project || sameProject(n.id);
612
+ //
613
+ // Repo/tag-scoped ride-along (task-cc-norm-ride-along-repo-tag-scope): the
614
+ // project-scope above resolves to the GROUPING UNION (scopeFor expands the
615
+ // session repo slug to its whole home-project grouping), so a grouping that
616
+ // spans heterogeneous repos — a terraform IaC repo, a Go service, a Python
617
+ // service — cross-pollinates norms: a `uv` norm stamped to the Python repo
618
+ // rode along into the terraform and Go briefs. A norm may now NARROW itself
619
+ // with flat `applies_to_*` keys matched against the session's OWN repo (not
620
+ // the union): `applies_to_tags` ∩ the session repo node's `tags`,
621
+ // `applies_to_repos` = the session repo, `applies_to_projects` = a grouping
622
+ // the session repo belongs to. Semantics are OR across axes (inclusion union
623
+ // — "apply in these repos OR anything python-tagged"), ANY within an axis —
624
+ // deliberately UNLIKE the policy layer's `governs`, which is AND-across-axes
625
+ // (governance narrowing); do not "fix" this to match it. A norm that declares
626
+ // any `applies_to_*` and matches none is EXCLUDED (strict — the whole point
627
+ // is to stop the bleed), including in a repo with no `tags` at all, so repo
628
+ // tagging is the opt-in that turns scoped norms on. A norm with NO
629
+ // `applies_to_*` falls through to the project-scope behavior above unchanged,
630
+ // so a graph that uses no `applies_to_*` is byte-identical
631
+ // (norm-cc-byte-identical-refactor).
632
+ // Array-safe reads: a malformed scalar (e.g. `applies_to_tags: python` with
633
+ // no brackets parses to a string) is treated as ABSENT, so the norm falls
634
+ // through to project-scope rather than throwing on the un-wrapped kernel path
635
+ // — the mistake is surfaced as a validateGraphFiles warning, not a crash.
636
+ const arr = (v) => (Array.isArray(v) ? v : []);
637
+ const sessionRepoTags = new Set(arr(nodes[sessionProject]?.tags));
638
+ const hasAppliesTo = (n) =>
639
+ arr(n.applies_to_tags).length || arr(n.applies_to_repos).length || arr(n.applies_to_projects).length;
640
+ const appliesToHit = (n) =>
641
+ arr(n.applies_to_tags).some((t) => sessionRepoTags.has(t)) ||
642
+ arr(n.applies_to_repos).some((r) => resolveProject(graph, r) === sessionProject) ||
643
+ arr(n.applies_to_projects).some((p) => scopeFor(graph, p)?.has(sessionProject));
644
+ const normRidesAlong = (n) => {
645
+ if (sessionProject == null) return true; // project-blind: unchanged
646
+ if (hasAppliesTo(n)) return appliesToHit(n); // explicit scope wins
647
+ return !n.project || sameProject(n.id); // current project-scope
648
+ };
611
649
  const normCandidates = Object.values(nodes).filter((n) =>
612
650
  reg.isAlwaysOn(n.type) && normRidesAlong(n) &&
613
651
  !structuralSet.has(n.id) && !contentSet.has(n.id) && !pinned.has(n.id) && !excluded.has(n.id));
@@ -937,6 +975,32 @@ function validateGraphFiles(files, seedSchemas = []) {
937
975
  }
938
976
  }
939
977
 
978
+ // Ride-along scoping fields (task-cc-norm-ride-along-repo-tag-scope): `tags`
979
+ // is consulted only on repo nodes; `applies_to_*` only narrows always_on
980
+ // norms. The compiler treats a misplaced or malformed (non-list) value as
981
+ // absent — fail-safe but silent — so surface the mistake here. (The lenient
982
+ // validate parser stores inline lists as raw strings, so well-formedness is
983
+ // checked against the raw text, like the slug claim above.)
984
+ const RIDE_KEYS = ["applies_to_tags", "applies_to_repos", "applies_to_projects"];
985
+ for (const n of fileNodes) {
986
+ const raw = files[n.file] ?? "";
987
+ const listed = (key) => new RegExp(`^${key}:\\s*\\[[^\\]]*\\]`, "m").test(raw);
988
+ const present = (key) => new RegExp(`^${key}:`, "m").test(raw);
989
+ if (n.type && n.type !== "repo" && present("tags")) {
990
+ warnings.push(`${n.file}: tags on non-repo type '${n.type}' is ignored (only repo nodes carry ride-along tags)`);
991
+ } else if (n.type === "repo" && present("tags") && !listed("tags")) {
992
+ warnings.push(`${n.file}: tags must be an inline list '[a, b]' — a scalar value is ignored`);
993
+ }
994
+ for (const key of RIDE_KEYS) {
995
+ if (!present(key)) continue;
996
+ if (!listed(key)) {
997
+ warnings.push(`${n.file}: ${key} must be an inline list '[a, b]' — a scalar value is ignored`);
998
+ } else if (n.type && reg.isKnownType(n.type) && !reg.isAlwaysOn(n.type)) {
999
+ warnings.push(`${n.file}: ${key} on non-always_on type '${n.type}' has no effect (only always_on norms ride along)`);
1000
+ }
1001
+ }
1002
+ }
1003
+
940
1004
  for (const n of Object.values(nodes)) {
941
1005
  for (const e of n.edges) {
942
1006
  if (!reg.isKnownEdge(e.type)) warnings.push(`${n.file}: unknown edge type '${e.type}'`);
@@ -12,6 +12,30 @@ Seed schema for the `norm` 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
+ A norm's `always_on` ride-along is project-scoped by default
16
+ (issue-cc-norm-ride-along-unscoped-bloat), but "project" resolves to the whole
17
+ home-project GROUPING union — so a grouping that spans heterogeneous repos
18
+ (a terraform IaC repo, a Go service, a Python service) would still cross-
19
+ pollinate norms. A norm may NARROW its ride-along with optional flat
20
+ `applies_to_*` selectors, matched against the session's OWN repo
21
+ (task-cc-norm-ride-along-repo-tag-scope):
22
+
23
+ - `applies_to_tags: [python]` — rides along only into repos tagged `python`
24
+ (matched against the session repo node's `tags`, schema-repo). A `uv` norm
25
+ scoped this way stays out of a terraform or Go sibling.
26
+ - `applies_to_repos: [repo-my-svc]` — rides along only in the named repo(s)
27
+ (slug or `repo-` id; resolved through the alias map).
28
+ - `applies_to_projects: [proj-platform]` — rides along only when the session
29
+ repo belongs to the named project grouping.
30
+
31
+ Matching is **OR across axes** (inclusion union — "apply in these repos OR
32
+ anything python-tagged"), ANY within an axis — deliberately unlike the policy
33
+ layer's `governs`, which is AND-across-axes. A norm that declares any
34
+ `applies_to_*` and matches none is EXCLUDED (strict, including in a repo with
35
+ no tags). A norm with no `applies_to_*` keeps today's project-scoped ride-along
36
+ unchanged, so a graph that uses none is byte-identical. The selectors are flat
37
+ inline-list strings (the frontmatter parser takes no nested maps).
38
+
15
39
  ```json
16
40
  {
17
41
  "node_type": "norm",
@@ -35,6 +35,19 @@ inline-list fields:
35
35
  the alias as a queue item for human confirmation. An accumulating set,
36
36
  not a derivation rule — no single fingerprint survives every rewrite.
37
37
 
38
+ A repo may also carry an optional free-form `tags` register
39
+ (task-cc-norm-ride-along-repo-tag-scope):
40
+
41
+ - `tags: [python, backend]` — labels describing this repo, the matching key
42
+ for a norm's `applies_to_tags` ride-along selector (schema-norm). A norm
43
+ scoped `applies_to_tags: [python]` rides along into a session's briefing
44
+ only when this repo (the session's OWN repo) is tagged `python` — so a
45
+ `uv` norm stays out of a terraform or Go sibling under the same project
46
+ grouping. An untagged repo matches no tag-scoped norm (strict — repo
47
+ tagging is the opt-in that turns scoped norms on). Tags are flat strings,
48
+ consulted only on repo nodes; a graph with no `tags` behaves exactly as
49
+ before.
50
+
38
51
  An optional `status: archived` retires the whole repo at end-of-life
39
52
  (issue-cc-project-lifecycle-queue-pollution). One identity-level edit hides
40
53
  the repo's open tasks/questions from the decision queue for EVERY viewer —
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@sporhq/spor",
3
- "version": "0.3.0",
3
+ "version": "0.4.0",
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()) {
@@ -67,6 +67,12 @@ still used when `~/.spor` is absent).
67
67
  `~/.spor/nodes/`; if the knowledge isn't a node yet, create that node
68
68
  first per the plugin's GRAPH.md, then pin it)
69
69
  - an irrelevant/stale node was included → `exclude: [that-id]`
70
+ - exception: if the irrelevant node is a **norm bleeding in across repos**
71
+ (e.g. a `uv` norm showing up in a terraform or Go brief under the same
72
+ project), the durable fix is to scope the norm at its source — add
73
+ `applies_to_tags:`/`applies_to_repos:`/`applies_to_projects:` to the norm
74
+ node and `tags:` to the repos (see GRAPH.md / concepts.md) — not a
75
+ per-briefing `exclude` you'd have to repeat everywhere it bleeds.
70
76
  - emphasis/framing was wrong → free-text guidance in the body
71
77
 
72
78
  3. Write `~/.spor/nodes/corr-<target>-<n>.md` (n = next free integer):
@@ -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,202 @@
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). A
40
+ norm INSTANCE can narrow that ride-along to specific repos with flat
41
+ `applies_to_tags:`/`applies_to_repos:`/`applies_to_projects:` frontmatter
42
+ keys (not schema flags) — see concepts.md "Ride-along flags".
43
+ - `traversable: false` — excluded from lineage walks (briefings, corrections).
44
+ - `capturable: false` — never produced by the capture/ingest path.
45
+ - `status.non_resolving` — statuses that count as *not* resolving for the
46
+ completion gate (an `abandoned` task resolves nothing).
47
+
48
+ Note what the payload does **not** hold: there is no field list and no status
49
+ *enum*. Extra frontmatter fields are allowed as-is — a custom `severity:` line
50
+ on your nodes just works, no declaration needed. To *require* a field or pin a
51
+ status to a fixed set, enforce it in the attached functions below, not in JSON.
52
+
53
+ ### Edge schema — JSON payload keys
54
+
55
+ ```json
56
+ {
57
+ "edge_type": "blocks",
58
+ "description": "target cannot proceed until this node does",
59
+ "weight": 0.7,
60
+ "inverse_label": "blocked-by",
61
+ "aliases": ["block"]
62
+ }
63
+ ```
64
+
65
+ - `weight` — decay across compile hops (1.0 structural → 0.3 weak).
66
+ - `inverse_label` — how the edge reads from the target; inverse forms are
67
+ accepted on write and flipped onto the target.
68
+ - `aliases` — same-direction synonyms normalized to the canonical spelling.
69
+
70
+ ### Attached behavior (optional JS)
71
+
72
+ A schema may carry fenced `js` blocks exporting **pure** functions, run
73
+ server-side in a sandbox (no I/O, no clock) at the JSON boundary. The one most
74
+ types use:
75
+
76
+ - `transitions(current, proposed, view)` — gate a status change; runs on
77
+ **update only** (the create path is ungated). Return `{allow: true}` or
78
+ `{allow: false, reason: "..."}`. Make the reason actionable: a writing agent
79
+ reads it and retries. This is also where you pin a type's legal status set —
80
+ reject any proposed status outside it. `current` is the stored node (`null` if
81
+ unparseable), `proposed` the incoming one, and `view` a read-only join the
82
+ server computes: `view.resolvers` (live inbound `resolves`/`answers` edges,
83
+ pre-filtered to resolving states), `view.targets`, `view.actor`,
84
+ `view.approvals`, `view.non_resolving_statuses`. (The task type's gate uses
85
+ `view.resolvers` to require a resolving `decision`/`artifact` before `done`.)
86
+
87
+ Two more hooks are supported but unused by the seed types:
88
+
89
+ - `validate(node)` — runs at the door on **every write (create and update)**.
90
+ Extra field checks beyond the base validator (e.g. "a `severity` is required
91
+ and must be one of …"). Return a list of error message strings, `[]` meaning
92
+ valid; the server prefixes each with `<schema-id> validate():`. The sandbox
93
+ passes only the node — there is no second argument.
94
+ - `queueSignals(node, ctx)` — contribute a `{name: number}` map of ranking
95
+ signals to the decision queue.
96
+
97
+ To read a real, current attached function, fetch a live schema:
98
+ `spor get schema-task` shows a two-gate `transitions`. (The seed schemas also
99
+ ship in the plugin's `lib/seed/` if you have a checkout.)
100
+
101
+ ### A complete example
102
+
103
+ A custom `escalation` type — queueable, with an `open → mitigated → closed`
104
+ status machine and a required `severity` — is one whole node. The outer fence
105
+ is shown with four backticks only so the inner blocks display; in the file you
106
+ write, use normal three-backtick fences.
107
+
108
+ ````markdown
109
+ ---
110
+ id: schema-escalation
111
+ type: schema
112
+ kind: node-schema
113
+ schema_version: 2026.06.16.0
114
+ title: Customer escalation
115
+ summary: A customer escalation tracked open -> mitigated -> closed, with a severity.
116
+ date: 2026-06-16
117
+ ---
118
+
119
+ Escalations as first-class, queueable nodes, so live ones surface in the queue
120
+ and the mitigation history is kept.
121
+
122
+ ```json
123
+ {
124
+ "node_type": "escalation",
125
+ "description": "a customer escalation and its mitigation lineage",
126
+ "prefix": ["esc-"],
127
+ "queueable": true,
128
+ "status": { "non_resolving": ["closed"] }
129
+ }
130
+ ```
131
+
132
+ ```js
133
+ const SEVERITIES = ["sev1", "sev2", "sev3"];
134
+ const FLOW = { open: ["mitigated", "closed"], mitigated: ["closed", "open"], closed: ["open"] };
135
+
136
+ export function validate(node) {
137
+ if (!node.severity) return ["escalation requires a severity"];
138
+ if (!SEVERITIES.includes(node.severity)) return [`severity must be one of: ${SEVERITIES.join(", ")}`];
139
+ return [];
140
+ }
141
+
142
+ export function transitions(current, proposed) {
143
+ const from = (current && current.status) || "";
144
+ const to = (proposed && proposed.status) || "";
145
+ if (!to || to === from) return { allow: true };
146
+ if ((FLOW[from] || []).includes(to)) return { allow: true };
147
+ return { allow: false, reason: `escalation can't go ${from || "(new)"} -> ${to}; allowed: ${(FLOW[from] || []).join(", ") || "(none)"}` };
148
+ }
149
+ ```
150
+ ````
151
+
152
+ An escalation node then just carries `severity: sev2` in its frontmatter and
153
+ moves through the gated statuses. The `transitions` contract here is exactly
154
+ what the seed `schema-task` uses (`spor get schema-task` to see a live one);
155
+ `validate` follows the validator's usual error-list convention.
156
+
157
+ ## Activating a schema (remote)
158
+
159
+ When you write a schema node through the server, it is forced to
160
+ `status: proposed` (any payload status is discarded) and stays **inert** until
161
+ a *different* identity flips it to `active` — there is no self-approval. It
162
+ surfaces in the decision queue as a `suggest: approve` item; a human (or a
163
+ second agent) reviews and runs `set_status schema-<type> active`. Locally
164
+ there's no ingester, so you write the file yourself (e.g.
165
+ `$SPOR_HOME/nodes/schema-escalation.md`) and it's live once it validates.
166
+
167
+ ## The discipline
168
+
169
+ Changing a schema is a contract change — every node of that type, and every
170
+ reader of them, depends on it. Treat it with care:
171
+
172
+ 1. **Prefer a graph-resident override to a seed edit.** If only your graph
173
+ needs a rule, write a schema node *in your graph* that overrides the seed
174
+ entry — don't fork the seed pack. The seed is the shared default for
175
+ everyone; your override is yours alone and travels with your graph.
176
+ 2. **Version honestly.** Bump `schema_version` (CalVer) with a migration only
177
+ when the change is **not** backward-readable. If existing nodes still parse
178
+ and mean the same thing — a new write-time gate, a new optional field —
179
+ it's backward-readable: no node-shape change, no migration needed. The seed
180
+ `schema-task` gates are written this way; each header notes whether it
181
+ needed an upgrade chain.
182
+ 3. **Keep the payload and any attached functions consistent.** A schema whose
183
+ `transitions()` rejects a status its own `description` advertises will
184
+ frustrate every writer. Test the change (below) before relying on it.
185
+
186
+ (If you're *contributing to the plugin itself* by editing the seed pack rather
187
+ than overriding it in your graph, more has to stay in step — the GRAPH.md docs,
188
+ the distiller prompts, the bundled skills, and the test suite — and such
189
+ refactors must stay byte-identical against a real graph. Most users never need
190
+ this: an org rule belongs in a graph-resident schema, not a seed fork.)
191
+
192
+ ## Sanity checks
193
+
194
+ ```bash
195
+ spor validate # lint the graph; fix anything it flags
196
+ spor get schema-<type> # read back the effective schema your graph will use
197
+ spor compile --root <id> # confirm a node of the new type compiles cleanly
198
+ ```
199
+
200
+ If the automatic distiller starts emitting an edge variant you didn't define,
201
+ that's a schema gap — add or adjust the edge schema rather than accepting the
202
+ stray form.
@@ -0,0 +1,142 @@
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); narrow it to specific repos with `applies_to_tags:`/`applies_to_repos:`/`applies_to_projects:` |
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; optional `tags:` are the match key for a norm's `applies_to_tags` |
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
+ An `always_on` norm rides along project-wide by default, but that scope is the
68
+ whole home-project **grouping** — so under a project that spans heterogeneous
69
+ repos (a terraform repo, a Go service, a Python service) a `uv` norm would bleed
70
+ into all three. Narrow it with flat per-instance selectors on the norm node
71
+ (not the schema): `applies_to_tags: [python]` (matched against the session
72
+ repo's `tags:`), `applies_to_repos: [repo-x]`, `applies_to_projects: [proj-y]`.
73
+ A norm that declares any `applies_to_*` and matches none is **excluded** —
74
+ including in a repo with no `tags:` — so repo tagging is the opt-in. A norm with
75
+ none keeps the default project-wide ride-along.
76
+
77
+ Don't invent edge variants. The automatic distiller sometimes emits forms like
78
+ `related-to`/`supercedes`/`derives-from`; those normalize to the canonical
79
+ spelling on write — they're the *only* accepted non-canonical forms. A genuinely
80
+ new relationship needs a new edge schema (see `authoring-schemas.md`).
81
+
82
+ ## Node file format
83
+
84
+ A node is one markdown file; `id` = filename minus `.md`. The frontmatter
85
+ parser is **regex-based, not a YAML library** — it supports simple
86
+ `key: value`, YAML folded multi-line values (indented continuations),
87
+ `pin:`/`exclude:` inline lists, and `- {type: X, to: Y}` edges. Don't use any
88
+ other YAML construct.
89
+
90
+ ```markdown
91
+ ---
92
+ id: dec-export-csv-format # required; kebab-case, starts with the type prefix
93
+ type: decision # required; a type in the registry
94
+ project: meridian # the repo/project slug (legacy spelling: repo:)
95
+ title: Export defaults to CSV # required; a one-line human title
96
+ summary: One or two sentences that stand entirely on their own. # required
97
+ status: active # optional; the legal set is enforced by the type's schema, not listed here
98
+ date: 2026-06-09 # required; the EVENT date (not creation date), YYYY-MM-DD
99
+ edges:
100
+ - {type: derived-from, to: spec-export-schema}
101
+ - {type: supersedes, to: dec-export-json-only}
102
+ ---
103
+
104
+ The body — a few paragraphs at most. Shown at full resolution when the node
105
+ scores high in a compile, so write for a reader with zero session context. If
106
+ a load-bearing reason exists ("because the release is Friday"), record it here.
107
+ One fact per node; if you're writing "and also…", split it.
108
+ ```
109
+
110
+ Server-stamped fields you don't set by hand: `author`, `authored_via`. Other
111
+ type-specific fields exist (`wake:` dormancy date; `commits:` linked git shas;
112
+ `pin:`/`exclude:` on corrections; `slugs:`/`fingerprints:`/`tags:` on repo nodes;
113
+ `applies_to_tags:`/`applies_to_repos:`/`applies_to_projects:` ride-along
114
+ selectors on norms; `roles:`/`queue_mute:` on person nodes) — see GRAPH.md for
115
+ the complete list.
116
+
117
+ Validate any local node you write: `spor validate` (or
118
+ `node lib/validate.js`), and fix what it flags.
119
+
120
+ ## Project slug convention
121
+
122
+ A node's `project:` stamp and most reads are scoped by a **project slug**,
123
+ derived from the session's cwd:
124
+
125
+ 1. A committed `.spor` marker file wins — `repo: <slug>` (legacy `project:`),
126
+ read from the nearest ancestor (cwd → repo root), so a monorepo subtree
127
+ marker can split one repo into distinct identities. The value must already
128
+ be canonical (`^[a-z0-9][a-z0-9-]*$`); a non-matching value is ignored.
129
+ 2. Otherwise: the kebab-cased basename of `git rev-parse --show-toplevel`
130
+ (lowercased, runs of non-alphanumerics → `-`, trimmed). `My_Repo` →
131
+ `my-repo`.
132
+ 3. A git **worktree** infers from its main repo's basename, so every worktree
133
+ of one repo shares one identity.
134
+
135
+ The client and server compute the slug identically, so a slug derived locally
136
+ always matches what the server expects (the canonical form is
137
+ `^[a-z0-9][a-z0-9-]*$`). A repo's home is a `type: project` grouping it sits
138
+ under via a `grouped-under` edge;
139
+ project-scoped reads union every repo grouped under that project, and a bare
140
+ repo slug resolves *up* to its grouping. Renaming a repo changes its slug;
141
+ `type: project`/`type: repo` identity nodes with `slugs:` alias lists heal that
142
+ at read time so old `project:` stamps still match.