@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.
- package/.claude-plugin/plugin.json +1 -1
- package/API.md +13 -0
- package/GRAPH.md +162 -0
- package/README.md +14 -3
- package/bin/spor.js +125 -12
- package/lib/config.js +54 -4
- package/lib/kernel/graph.js +70 -6
- package/lib/seed/schema-norm.md +24 -0
- package/lib/seed/schema-repo.md +13 -0
- package/package.json +1 -1
- package/scripts/engines/doctor.js +1 -1
- package/skills/correct/SKILL.md +6 -0
- package/skills/spor/SKILL.md +158 -0
- package/skills/spor/references/authoring-schemas.md +202 -0
- package/skills/spor/references/concepts.md +142 -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.
|
|
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":
|
|
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/lib/kernel/graph.js
CHANGED
|
@@ -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
|
|
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
|
-
//
|
|
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
|
-
|
|
610
|
-
|
|
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}'`);
|
package/lib/seed/schema-norm.md
CHANGED
|
@@ -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",
|
package/lib/seed/schema-repo.md
CHANGED
|
@@ -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
|
+
"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 ? "" : " (
|
|
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()) {
|
package/skills/correct/SKILL.md
CHANGED
|
@@ -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.
|