@sporhq/spor 0.12.0 → 0.13.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -2,6 +2,6 @@
2
2
  "name": "spor",
3
3
  "displayName": "Spor Context Compiler",
4
4
  "description": "Maintains a typed, versioned knowledge graph and compiles compact briefings from it: session-start injection, per-prompt relevance digests, capture at discovery, end-of-session distillation, decision queue.",
5
- "version": "0.12.0",
5
+ "version": "0.13.0",
6
6
  "author": { "name": "losthammer" }
7
7
  }
package/API.md CHANGED
@@ -137,6 +137,32 @@ different attributes → the edge's attribute set is replaced (not merged) in
137
137
  place. Omitting `attrs` never touches an existing edge's attributes, so a bare
138
138
  `add_edge` is unchanged.
139
139
 
140
+ **Submitting a review is one call.** The review-outcome edges
141
+ (`review-requested` / `reviewed-by` / `changes-requested-by`, plus the
142
+ `approved-by` approval synonym) are mutually exclusive per `(node, person)` —
143
+ the edge type *is* that reviewer's current verdict — so adding one review edge
144
+ to a person **flips** any sibling review edge to that same person in place
145
+ instead of leaving two contradictory edges. A reviewer thus turns a pending
146
+ `review-requested` into `reviewed-by` (or `changes-requested-by`, or back) with
147
+ one `add_edge`, no remove-and-re-add or whole-node `put_node`; the flip is
148
+ reported in `warnings` (`flipped review-requested -> reviewed-by for <person>`).
149
+ This is scoped strictly to the review family — every other edge type keeps the
150
+ plain append-or-idempotent behavior above.
151
+
152
+ ### `remove_edge`
153
+
154
+ Micro-mutation, the withdrawal twin of `add_edge`. Input `{ "id": "<node>",
155
+ "type": "<edge type>", "to": "<target>" }` — accepts canonical, alias, and
156
+ inverse forms, normalized exactly as `add_edge` (an inverse form removes the
157
+ canonical edge on the *other* node and echoes that node's id). Output
158
+ `{ "status": "updated|skipped", "id": <node actually modified>, "revision",
159
+ "warnings" }`; a missing edge is an idempotent `skipped`, never an error. Use it
160
+ when a relationship should simply cease to exist — a *withdrawn* review request
161
+ or a *dismissed* review — which the `add_edge` review flip cannot express (the
162
+ flip only swaps one verdict for another *within* the family; it never drops an
163
+ edge). To change a verdict in place, prefer `add_edge`; reach for `remove_edge`
164
+ to withdraw one. The REST twin is `DELETE /v1/nodes/{id}/edges` (§3).
165
+
140
166
  ### `set_status`
141
167
 
142
168
  Micro-mutation. Input `{ "id": "<node>", "status": "<value>" }`. Output
@@ -146,6 +172,20 @@ reason, exactly as on a full put. set_status on a `type: schema` node is how
146
172
  a human flips `proposed → active` (it carries the same authority as the
147
173
  equivalent put).
148
174
 
175
+ ### `set_priority`
176
+
177
+ Micro-mutation, the human-override half of the decision queue
178
+ (dec-cc-opinionated-queue-blend). Input `{ "id": "<node>", "priority":
179
+ "<value>" }`, where `<value>` is `p1` (highest), `p2`, `p3`, or a clearing
180
+ form (`none`/`clear`/`""`/`p0`) to remove it. Output `{ "status": "updated",
181
+ "id", "revision", "warnings" }`; an unknown value is `invalid_node` with the
182
+ allowed list in `details`. Like `set_status` it is a server-side
183
+ read-modify-write — no client revision round-trip — and it stamps
184
+ `priority_by` (acting identity), `priority_at`, and `priority_via` (the door)
185
+ so an agent-set priority is distinguishable from human triage
186
+ (issue-cc-priority-attribution-gap). The CLI wrapper is `spor priority <id>
187
+ <p1|p2|p3|clear>`.
188
+
149
189
  ### `propose_correction`
150
190
 
151
191
  Sugar over `put_node` for the correction loop. Input:
@@ -324,8 +364,10 @@ endpoint is the REST twin of a core call:
324
364
  | `POST /v1/digest` `{query, root?, project?, min_sim?}` | prompt-context, /spor:brief | digest-mode compile → `{found, text}`; `found: false` is a successful empty result. `root` is the structural-walk twin of `query` (the two are mutually exclusive; `root` wins, an unknown id is `422`). Optional `project` is the session slug: the server scopes the compile to it — the same-project relevance boost, the grouping union, and the `always_on` norm `applies_to_*` ride-along — resolving the slug through project-node aliases/groupings inside compile (dec-spor-queue-slug-resolves-to-grouping), exactly as `/v1/queue` does. A bad slug is `422`; **omitting `project` runs the digest project-blind (byte-identical to before)**, so older clients that send only `{query}` are unaffected |
325
365
  | `GET /v1/nodes/{id}` | /spor:brief | `get_node` semantics; the node's active schema may attach read-time enrichment via a `get(node, ctx)` hook (GRAPH.md) — the seed `question`/`issue`/`task`/`incident` schemas attach `resolution`: a live inbound resolves/answers edge carrying the resolver's `summary`/`title` and a `lagging` flag (set when it contradicts a still-open status, clear when the node is already terminal, e.g. an answered question pointing at its answer). Open gardener findings about the node ride along as `open_findings`, and a node marked stale by an inbound supersedes edge as `superseded_by`. All enrichment is additive top-level keys; ignore unknown ones |
326
366
  | `POST /v1/nodes` | drain-outbox, mechanical writers | `put_node` semantics, batch: `{nodes: [...], if_exists: "skip"}` (entries may be raw strings or `{node, if_exists, revision}`) → `{results: [...]}`, 207 when any entry failed |
327
- | `POST /v1/nodes/{id}/edges` `{type, to, attrs?}` | scripts, mechanical writers | `add_edge` semantics (§1): normalize/flip, dedupe, append — no revision echo. Optional `attrs` adds trailing flat edge attributes (e.g. a per-assignment `profile:` override); re-adding the same edge with different attrs upserts the set |
367
+ | `POST /v1/nodes/{id}/edges` `{type, to, attrs?}` | scripts, mechanical writers | `add_edge` semantics (§1): normalize/flip, dedupe, append — no revision echo. Optional `attrs` adds trailing flat edge attributes (e.g. a per-assignment `profile:` override); re-adding the same edge with different attrs upserts the set. Adding a review-outcome edge (`reviewed-by`/`changes-requested-by`/`review-requested`) flips a sibling review edge to the same person in place — the one-call submit-review primitive |
368
+ | `DELETE /v1/nodes/{id}/edges` `{type, to}` | scripts, mechanical writers | `remove_edge` semantics (§1): the withdrawal twin of the POST above — drop one typed edge by `{type, to}`, normalize/flip exactly as `add_edge` (an inverse form removes the canonical edge on the *other* node and echoes its id), no revision echo. A missing edge is an idempotent `skipped`. For *withdrawing* a relationship the review flip can't express — a pulled review request, a dismissed review |
328
369
  | `POST /v1/nodes/{id}/status` `{status}` | scripts, mechanical writers | `set_status` semantics (§1): one-scalar update through the `transitions()` gate. Setting a work node to an in-progress status also CLAIMS it (same lease as `/claim` below) |
370
+ | `POST /v1/nodes/{id}/priority` `{priority}` | `spor priority`, queue triage | `set_priority` semantics (§1): one-scalar human-override update — `p1`/`p2`/`p3` or a clearing form (`none`/`clear`/`""`/`p0`). Server-side read-modify-write (no revision), stamping `priority_by`/`priority_at`/`priority_via` for the audit trail (issue-cc-priority-attribution-gap). Unknown value → `invalid_node` with the allowed list |
329
371
  | `POST /v1/nodes/{id}/claim` `{session?}` | `claim`/`set_status` MCP tools, `spor dispatch` | take the heartbeat-renewed lease (dec-cc-task-claim-lease): writes the durable `assigned` edge once, attributes to `$viewer` from the token (never an argument), and creates the ephemeral lease → `{ok, status, lease: {node_id, by, expires, expires_at, session, claimed_at}, edge}`. A live lease held by ANOTHER person is `409 conflict` naming the holder + expiry (re-claiming your OWN live claim just renews it). `session` scopes the heartbeat (omit to leave it person-scoped, so any of the claimer's sessions may renew — what `spor dispatch` does at the PRE-launch claim, since `claude --bg` self-allocates the run session only at launch; dispatch then renews with the real session once it has read it from `claude agents --json`, dec-spor-dispatch-bg-session-late-bind) |
330
372
  | `POST /v1/nodes/{id}/renew` `{session?}` | post-tool heartbeat, `renew` MCP tool, `spor dispatch` | bump the live lease's expiry only — no commit; the heartbeat that keeps a claim from lapsing. A lapsed/stolen lease is `409` (names the current holder). Person-scoped: any of the claimer's sessions may renew; a `session` binds the lease to that run (`spor dispatch` uses this to bind the captured `claude --bg` session post-launch) |
331
373
  | `POST /v1/nodes/{id}/release` | `release` MCP tool | drop the lease AND retire the durable `assigned` edge, returning the node to the pool. Idempotent (releasing a node you hold no lease on still succeeds, cleaning up any lingering `assigned` edge of yours); releasing a claim someone else holds is `409` naming the holder |
package/GRAPH.md CHANGED
@@ -218,12 +218,22 @@ the server runs on the write path:
218
218
  human-readable error strings; `[]` means accept. A non-empty array rejects the
219
219
  write (`invalid_node`), each string surfaced to the writer as
220
220
  `<schema-id> validate(): <your message>`. This is where required/typed custom
221
- fields are enforced.
222
- - **`transitions(current, proposed, view) -> { allow, reason? }`** the status
223
- gate. Runs on **update only** (the create path is ungated, so a status-less or
224
- any first write is always allowed). `current` is the stored node (or `null` if
225
- its file is unparseable), `proposed` is the incoming node, and `view` is a
226
- read-only join the server computes for the gate:
221
+ fields — and **status-vocabulary membership** — are enforced. Membership is a
222
+ property of the node in isolation, so it belongs at the door, where create and
223
+ update see it alike; the seed `task`/`issue`/`decision`/`question`/
224
+ `capture-pending` schemas check it here. Putting it ONLY in `transitions()`
225
+ (update-only) let a node be BORN with an off-vocabulary status that a later
226
+ re-validating write then rejected (issue-spor-node-create-bypasses-status-
227
+ vocabulary); `validate()` and `transitions()` share one `VALID` list so the
228
+ two paths can't drift.
229
+ - **`transitions(current, proposed, view) -> { allow, reason? }`** — the
230
+ *transition* gate. Runs on **update only** (the create path is ungated by it,
231
+ so a status-less or any first write passes; status-vocabulary membership is
232
+ gated separately in `validate()` above, on create too). This is where
233
+ state-machine legality and the completion-resolver gate live — both properties
234
+ of the current→proposed *transition*, not of the node alone. `current` is the
235
+ stored node (or `null` if its file is unparseable), `proposed` is the incoming
236
+ node, and `view` is a read-only join the server computes for the gate:
227
237
  - `view.targets[id]` — `{ exists, type, status, superseded }` for each node
228
238
  this one points an edge at (outbound);
229
239
  - `view.resolvers` — live **inbound** `resolves`/`answers` edges pointing at
@@ -292,9 +302,11 @@ date: 2026-06-16
292
302
 
293
303
  Escalation nodes track a customer-facing incident from raise to close.
294
304
  `severity` is a free-form frontmatter field this schema makes mandatory and
295
- constrains in `validate()`; the status machine and the close-time resolver gate
296
- live in `transitions()`. Both are the same procedural model the seed types use —
297
- there is no declarative field or status enum to fill in.
305
+ constrains in `validate()`, alongside status-vocabulary membership (the door
306
+ runs on create AND update, so the two paths agree on the enum); the close-time
307
+ resolver gate a property of the *transition* lives in `transitions()`
308
+ (update only). Both are the same procedural model the seed types use — there is
309
+ no declarative field or status enum to fill in.
298
310
 
299
311
  ```json
300
312
  {
@@ -306,9 +318,17 @@ there is no declarative field or status enum to fill in.
306
318
  ```
307
319
 
308
320
  ```js
321
+ // The status vocabulary, shared by validate() (membership; create AND update)
322
+ // and transitions() (the close-time gate; update only) so the two paths agree
323
+ // on the enum (issue-spor-node-create-bypasses-status-vocabulary).
324
+ const VALID = ["open", "mitigated", "closed"];
325
+
309
326
  // validate(node) — runs at the door on EVERY write (create and update). Returns
310
- // an array of error strings; [] accepts. Enforce the free-form custom field here:
311
- // the payload declares no field list, so a required/typed field is code.
327
+ // an array of error strings; [] accepts. Enforce node-in-isolation properties
328
+ // here: the free-form custom field (no payload field list, so a required/typed
329
+ // field is code) AND status-vocabulary membership — checking membership at the
330
+ // door is what makes create and update agree on the enum (a node can never be
331
+ // born with an off-vocabulary status that the close gate below would reject).
312
332
  export function validate(node) {
313
333
  const errors = [];
314
334
  const VALID_SEVERITY = ["sev1", "sev2", "sev3"];
@@ -317,22 +337,21 @@ export function validate(node) {
317
337
  } else if (VALID_SEVERITY.indexOf(String(node.severity)) === -1) {
318
338
  errors.push("invalid severity '" + node.severity + "': use sev1, sev2, or sev3");
319
339
  }
340
+ const s = ((node && node.status) || "").toLowerCase();
341
+ if (s !== "" && VALID.indexOf(s) === -1) {
342
+ errors.push("invalid escalation status '" + s + "': valid statuses are " +
343
+ "open, mitigated, closed — or none, meaning live");
344
+ }
320
345
  return errors;
321
346
  }
322
347
 
323
348
  // transitions(current, proposed, view) — runs on UPDATE only; returns
324
- // { allow, reason? }. Empty status (status-less = live) is always allowed.
349
+ // { allow, reason? }. Gates the *transition*, not membership (validate() owns
350
+ // that): closing requires a durable outcome on the graph. Empty status
351
+ // (status-less = live) is always allowed.
325
352
  export function transitions(current, proposed, view) {
326
- const VALID = ["open", "mitigated", "closed"];
327
353
  const next = ((proposed && proposed.status) || "").toLowerCase();
328
354
  if (next === "") return { allow: true };
329
- if (VALID.indexOf(next) === -1) {
330
- return {
331
- allow: false,
332
- reason: "invalid escalation status '" + next + "': valid statuses are " +
333
- "open, mitigated, closed — or none, meaning live",
334
- };
335
- }
336
355
  // closed must record a durable outcome on the graph: a decision or artifact
337
356
  // that resolves this escalation (an inbound resolves edge). view.resolvers is
338
357
  // already filtered to resolving states, so an in-review fix does not count.
@@ -520,6 +539,7 @@ type: person
520
539
  title: Anthony Allen
521
540
  summary: Maintainer; stewards the schema registry and the hook engines.
522
541
  email: losthammer@gmail.com
542
+ github: losthammer
523
543
  roles: [reviewer, maintainer]
524
544
  queue_mute: [some-noisy-project, task-noisy-job@2026-07-01]
525
545
  date: 2026-06-10
@@ -557,6 +577,15 @@ edges:
557
577
  definition-of-done quorum gate is the first such rule (see "The org-defined
558
578
  policy layer" below). Declarative data only; absent it, a person holds no
559
579
  roles and the field has no effect.
580
+ - **`github`** (flat inline scalar, e.g. `github: octocat`) is the person's
581
+ GitHub handle — the login→person key the Spor server's GitHub review
582
+ reflection maps by. When a GitHub review or merge is reflected into the graph,
583
+ the event's GitHub login is matched (case-insensitively) to the person whose
584
+ `github` equals it, so the approval attaches to the right person's review
585
+ edges and counts toward the policy quorum gate; `github_login` is an accepted
586
+ alias, and an operator login→email map is the fallback. Declarative data only;
587
+ absent it, the person can't be resolved from a GitHub login and the field has
588
+ no effect.
560
589
 
561
590
  ### Onboarding a team member
562
591
 
@@ -813,7 +842,8 @@ Edges are written in the canonical direction above, but the server's write
813
842
  path accepts two normalized forms (API.md §1, registry data on each
814
843
  edge schema): **aliases** — same-direction synonyms renamed in place
815
844
  (`related-to` → `relates-to`, `derives-from` → `derived-from`,
816
- `supercedes` → `supersedes`) — and **inverse labels** — the edge read from
845
+ `supercedes` → `supersedes`, `approved-by` → `reviewed-by`) — and **inverse
846
+ labels** — the edge read from
817
847
  the target's side, flipped onto the target node on write (`blocked-by` →
818
848
  `blocks`, `answered-by` → `answers`, `superseded-by` → `supersedes`).
819
849
  Hand-written nodes should still use the canonical forms.