@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.
@@ -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.2.9",
5
+ "version": "0.3.1",
6
6
  "author": { "name": "losthammer" }
7
7
  }
package/API.md CHANGED
@@ -402,6 +402,19 @@ SPOR_SERVER=https://spor.example.com
402
402
  SPOR_TOKEN=spor_pat_... # per-user token (§4)
403
403
  ```
404
404
 
405
+ **Opt-in activation.** Spor is opt-in per repo: with the plugin installed, the
406
+ hooks are a full no-op (no context injected, nothing distilled) in any repo that
407
+ has not opted in. A repo is active when its mode is not `off` AND either an
408
+ `enabled` flag is set anywhere in the cascade (`enabled:true`/`false` in a config
409
+ layer, `SPOR_ENABLED=1`/`0`, or a CLI `--enabled`) OR a repo-level `.spor` /
410
+ `.spor.json` marker is present in the cwd ancestry — what `spor enable`, `spor
411
+ link`, and `spor dispatch --backfill` write. An explicit flag wins over marker
412
+ presence (so `enabled:false` forces a no-op even where a marker exists). The
413
+ default — no flag, no marker — is OFF, including in remote mode: a globally
414
+ configured `SPOR_SERVER` resolves the *mode* to remote but does not by itself
415
+ *enable* an unrelated repo, so side projects never distill into the team graph.
416
+ `spor status` / `spor-hook doctor` report whether the current repo is active.
417
+
405
418
  Failure policy: **fail open, never block** — a hook must never break a
406
419
  session; connection refused, timeout, 5xx, and auth failure all collapse to
407
420
  "the graph has nothing for you".
package/GRAPH.md CHANGED
@@ -176,6 +176,153 @@ least two — the definition-of-done quorum gate.
176
176
  a proposed policy is inert until a *different* identity activates it (the
177
177
  native floor protects against self-amendment circularity).
178
178
 
179
+ ## Authoring a custom schema
180
+
181
+ A team extends the ontology by writing a `type: schema` node into its graph (it
182
+ overrides or extends the seed pack; QUEUE.md §2). This section assembles one
183
+ end-to-end — the seed pack documents the JSON payload and the attached code
184
+ separately, but never shows a complete custom type in one piece.
185
+
186
+ **The constraint model is procedural, not declarative.** A schema's `json`
187
+ payload declares only *registry knobs* — `node_type`, `prefix`, `queueable`,
188
+ `traversable`, `always_on`, `capturable`, an edge `weight`, the
189
+ `status.non_resolving` partition. There is **no declarative field list and no
190
+ status enum.** Custom fields are free-form: any flat frontmatter key the
191
+ regex parser accepts (simple `key: value` scalars, YAML-folded multi-line
192
+ values, `pin:`/`exclude:` inline lists, `- {type: X, to: Y}` edges — and nothing
193
+ fancier) is carried verbatim on the node. What a field MUST contain, and which
194
+ status changes are legal, are enforced **in attached code** — two pure functions
195
+ the server runs on the write path:
196
+
197
+ - **`validate(node) -> string[]`** — the door. Runs on **every write (create
198
+ AND update)**. It receives the parsed proposed node and returns an array of
199
+ human-readable error strings; `[]` means accept. A non-empty array rejects the
200
+ write (`invalid_node`), each string surfaced to the writer as
201
+ `<schema-id> validate(): <your message>`. This is where required/typed custom
202
+ fields are enforced.
203
+ - **`transitions(current, proposed, view) -> { allow, reason? }`** — the status
204
+ gate. Runs on **update only** (the create path is ungated, so a status-less or
205
+ any first write is always allowed). `current` is the stored node (or `null` if
206
+ its file is unparseable), `proposed` is the incoming node, and `view` is a
207
+ read-only join the server computes for the gate:
208
+ - `view.targets[id]` — `{ exists, type, status, superseded }` for each node
209
+ this one points an edge at (outbound);
210
+ - `view.resolvers` — live **inbound** `resolves`/`answers` edges pointing at
211
+ this node, each `{ id, type, status }`, already filtered to *resolving*
212
+ states (a withdrawn or in-review resolver is excluded). This is how a type
213
+ requires a durable outcome on the graph before going terminal (the resolver
214
+ gate the seed `task`/`issue` schemas use);
215
+ - `view.non_resolving_statuses` — the registry's resolving-status partition,
216
+ if the gate wants to judge resolver states itself;
217
+ - `view.actor` — `{ name, email, via }`, the authenticated writer (for
218
+ ownership/role gates);
219
+ - `view.approvals` — the node's review edges to `person` nodes joined to their
220
+ `roles` (the quorum-gate input; the writer's own self-approval is excluded).
221
+ Return `{ allow: true }` to permit, or `{ allow: false, reason: "…" }` to deny
222
+ with an actionable message.
223
+
224
+ Both hooks are **sandboxed, pure, and fail-closed**: the server runs them in a
225
+ QuickJS-in-wasm sandbox (`SPOR_SANDBOX=vm` is an ops-only escape hatch) across a
226
+ strict JSON boundary — arguments arrive as plain guest data and only JSON-clonable
227
+ return values cross back, so `require`, `process`, `eval`, the `Function`
228
+ constructor, host prototypes, ambient time/IO, and unbounded loops are all
229
+ unavailable (a runaway hits a fuel/memory interrupt). A hook that throws does not
230
+ wave the write through — it **rejects** it. Write the functions accordingly: no
231
+ external state, no side effects, decide only from the arguments. Two more
232
+ attached exports are recognized: `queueSignals(node, ctx)` (a `{ name: number }`
233
+ map blended into the decision-queue ranking) and named upgrade functions
234
+ referenced from `payload.upgrades` (lazy, forward-only field migrations on a
235
+ `schema_version` bump). The **client** half (hooks, the `spor` CLI, `lib/`) only
236
+ *parses and indexes* this code for the registry knobs — it never executes it;
237
+ the server is the sole executor.
238
+
239
+ Resolution and rollout: a graph-resident schema always beats the seed pack, and
240
+ within a source the higher CalVer `schema_version` wins, so an override must be
241
+ bumped in lockstep with seed changes or it silently shadows them (`validateGraph`
242
+ warns). A schema node goes through the same propose→activate flow it governs — a
243
+ *different* identity must activate it (or a trusted admin git-writes it `active`);
244
+ bump the CalVer and add an `upgrades` chain only when the change is not
245
+ backward-readable.
246
+
247
+ A complete worked example — a `escalation` type with a required `severity`
248
+ field (enforced in `validate`) and an `open → mitigated → closed` status machine
249
+ whose terminal `closed` demands a resolver (enforced in `transitions`):
250
+
251
+ ````markdown
252
+ ---
253
+ id: schema-escalation
254
+ type: schema
255
+ kind: node-schema
256
+ schema_version: 2026.06.16.1
257
+ title: Custom schema for customer-escalation nodes
258
+ summary: A customer escalation with a severity field and an open/mitigated/closed status machine; closing one requires a decision or artifact that resolves it.
259
+ status: active
260
+ date: 2026-06-16
261
+ ---
262
+
263
+ Escalation nodes track a customer-facing incident from raise to close.
264
+ `severity` is a free-form frontmatter field this schema makes mandatory and
265
+ constrains in `validate()`; the status machine and the close-time resolver gate
266
+ live in `transitions()`. Both are the same procedural model the seed types use —
267
+ there is no declarative field or status enum to fill in.
268
+
269
+ ```json
270
+ {
271
+ "node_type": "escalation",
272
+ "description": "a customer-facing escalation and its resolution lineage",
273
+ "prefix": ["esc-"],
274
+ "queueable": true
275
+ }
276
+ ```
277
+
278
+ ```js
279
+ // validate(node) — runs at the door on EVERY write (create and update). Returns
280
+ // an array of error strings; [] accepts. Enforce the free-form custom field here:
281
+ // the payload declares no field list, so a required/typed field is code.
282
+ export function validate(node) {
283
+ const errors = [];
284
+ const VALID_SEVERITY = ["sev1", "sev2", "sev3"];
285
+ if (!node.severity) {
286
+ errors.push("escalation requires a severity field (sev1 | sev2 | sev3)");
287
+ } else if (VALID_SEVERITY.indexOf(String(node.severity)) === -1) {
288
+ errors.push("invalid severity '" + node.severity + "': use sev1, sev2, or sev3");
289
+ }
290
+ return errors;
291
+ }
292
+
293
+ // transitions(current, proposed, view) — runs on UPDATE only; returns
294
+ // { allow, reason? }. Empty status (status-less = live) is always allowed.
295
+ export function transitions(current, proposed, view) {
296
+ const VALID = ["open", "mitigated", "closed"];
297
+ const next = ((proposed && proposed.status) || "").toLowerCase();
298
+ if (next === "") return { allow: true };
299
+ if (VALID.indexOf(next) === -1) {
300
+ return {
301
+ allow: false,
302
+ reason: "invalid escalation status '" + next + "': valid statuses are " +
303
+ "open, mitigated, closed — or none, meaning live",
304
+ };
305
+ }
306
+ // closed must record a durable outcome on the graph: a decision or artifact
307
+ // that resolves this escalation (an inbound resolves edge). view.resolvers is
308
+ // already filtered to resolving states, so an in-review fix does not count.
309
+ if (next === "closed") {
310
+ const rs = (view && view.resolvers) || [];
311
+ const ok = rs.some((r) => r.type === "decision" || r.type === "artifact");
312
+ if (!ok) {
313
+ return {
314
+ allow: false,
315
+ reason: "closed requires a decision or artifact node that resolves this " +
316
+ "escalation (an inbound resolves edge) — record how it was handled on " +
317
+ "the graph so the neighborhood can surface it",
318
+ };
319
+ }
320
+ }
321
+ return { allow: true };
322
+ }
323
+ ```
324
+ ````
325
+
179
326
  ## Norm ride-along
180
327
 
181
328
  A `norm` node (any `always_on` type) rides along on every compile — but the
package/README.md CHANGED
@@ -275,12 +275,23 @@ add files only when you want them. A `.spor.json` (committable) is for
275
275
  settings the whole repo should share — never put a `token` there; it is
276
276
  honored only from the environment or your user/global config.
277
277
 
278
+ **Spor is opt-in per repo.** Installing the plugin does not make every repo you
279
+ open participate: a repo is a no-op (no context injected, nothing distilled into
280
+ the shared graph) until it opts in — either it carries a `.spor`/`.spor.json`
281
+ marker, or `enabled` is set anywhere in the cascade (`SPOR_ENABLED=1`, or
282
+ `enabled:true` in user/global config to turn it on everywhere). `spor enable`
283
+ writes the marker below for you; `spor dispatch --backfill` does it as part of
284
+ onboarding. This keeps unrelated side projects out of your team graph even when
285
+ a server is configured globally. Run `spor status` (or `spor-hook doctor`) in a
286
+ repo to see whether it's active.
287
+
278
288
  ```jsonc
279
289
  // .spor.json — committed at a repo root
280
290
  {
281
- "enabled": false, // make the plugin a no-op in this repo:
282
- // unrelated side projects don't pollute
283
- // the shared graph (default true)
291
+ "enabled": true, // opt this repo in (what `spor enable`
292
+ // writes). Spor is OFF in a repo with no
293
+ // marker; set false to force a no-op even
294
+ // where a marker would otherwise enable it
284
295
  "search": {
285
296
  "minSim": 0.10, // raise/lower the relevance gate
286
297
  "projects": {