@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.
- package/.claude-plugin/plugin.json +1 -1
- package/API.md +43 -1
- package/GRAPH.md +51 -21
- package/bin/spor.js +339 -9
- package/lib/analytics.js +155 -0
- package/lib/kernel/analytics.js +272 -0
- package/lib/kernel/queue.js +23 -1
- package/lib/queue.js +9 -1
- package/lib/seed/schema-capture-pending.md +42 -16
- package/lib/seed/schema-decision.md +38 -13
- package/lib/seed/schema-edge-reviewed-by.md +12 -4
- package/lib/seed/schema-issue.md +43 -10
- package/lib/seed/schema-person.md +13 -1
- package/lib/seed/schema-question.md +37 -11
- package/lib/seed/schema-task.md +44 -10
- package/lib/shell/gittime.js +20 -3
- package/package.json +1 -1
- package/skills/spor/SKILL.md +10 -0
- package/skills/triage/SKILL.md +248 -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.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
|
-
|
|
223
|
-
|
|
224
|
-
|
|
225
|
-
|
|
226
|
-
|
|
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()
|
|
296
|
-
|
|
297
|
-
|
|
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
|
|
311
|
-
// the
|
|
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? }.
|
|
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
|
|
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.
|