@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.
- package/.claude-plugin/plugin.json +1 -1
- package/API.md +13 -0
- package/GRAPH.md +147 -0
- package/README.md +14 -3
- package/bin/spor.js +125 -12
- package/lib/config.js +54 -4
- package/package.json +1 -1
- package/scripts/engines/doctor.js +1 -1
- package/skills/spor/SKILL.md +158 -0
- package/skills/spor/references/authoring-schemas.md +199 -0
- package/skills/spor/references/concepts.md +130 -0
|
@@ -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.
|
|
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":
|
|
282
|
-
//
|
|
283
|
-
//
|
|
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() ? "" : " (
|
|
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
|
-
|
|
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
|
-
|
|
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
|
|
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 ? "" : "
|
|
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: "
|
|
1849
|
-
help: "Set { enabled: true } in this repo's committable .spor.json
|
|
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
|
|
1862
|
-
help:
|
|
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>'.
|
|
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) =>
|
|
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:
|
|
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
|
-
|
|
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
|
-
|
|
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.
|
|
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 ? "" : " (
|
|
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.
|