@sporhq/spor 0.2.9 → 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 +603 -197
- 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.
|
|
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": {
|