@sporhq/spor 0.5.2 → 0.7.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 +48 -9
- package/GRAPH.md +72 -3
- package/QUEUE.md +27 -6
- package/README.md +8 -0
- package/bin/spor.js +693 -23
- package/lib/config.js +1 -0
- package/lib/graph.js +2 -0
- package/lib/kernel/graph.js +27 -1
- package/lib/kernel/queue.js +24 -1
- package/lib/queue.js +17 -1
- package/lib/seed/schema-agent.md +67 -0
- package/lib/seed/schema-edge-changes-requested-by.md +35 -0
- package/lib/seed/schema-edge-owned-by.md +37 -0
- package/lib/seed/schema-edge-review-requested.md +39 -0
- package/lib/seed/schema-edge-reviewed-by.md +35 -0
- package/package.json +1 -1
- package/scripts/engines/prompt-context.js +11 -1
- package/skills/brief/SKILL.md +20 -7
- package/skills/next/SKILL.md +61 -0
- package/skills/spor/SKILL.md +4 -0
- package/skills/spor/references/concepts.md +6 -2
|
@@ -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.7.0",
|
|
6
6
|
"author": { "name": "losthammer" }
|
|
7
7
|
}
|
package/API.md
CHANGED
|
@@ -16,8 +16,13 @@ Every mutation is validated, attributed, serialized, and committed to the
|
|
|
16
16
|
graph's git repo. What a client sees:
|
|
17
17
|
|
|
18
18
|
- **Attribution**: the server stamps `author: <identity>` and
|
|
19
|
-
`authored_via: mcp|rest|capture` from the authenticated token — any
|
|
20
|
-
`author:` supplied in the payload is discarded.
|
|
19
|
+
`authored_via: mcp|rest|capture|dispatch` from the authenticated token — any
|
|
20
|
+
`author:` supplied in the payload is discarded. A write under an
|
|
21
|
+
**agent-scoped token** (§4) additionally stamps `authored_by_agent: <agent-id>`
|
|
22
|
+
and `session: <id>` and uses `authored_via: dispatch`, while `author:` stays
|
|
23
|
+
the agent's **owning person** — so the node reads "agent on behalf of person".
|
|
24
|
+
These ride-along fields are token-derived too; any supplied in the payload are
|
|
25
|
+
discarded.
|
|
21
26
|
- **Create**: `if_exists: "skip"` → id collision is reported as `skipped`
|
|
22
27
|
(the distiller default); `if_exists: "error"` → id collision is a
|
|
23
28
|
`conflict` error.
|
|
@@ -173,16 +178,21 @@ Only an unreachable ingestion model is an error (`ingestion_unavailable`).
|
|
|
173
178
|
### `my_queue`
|
|
174
179
|
|
|
175
180
|
The decision queue (QUEUE.md §4/§5). Input `{ "project"?: "slug",
|
|
176
|
-
"
|
|
181
|
+
"types"?: ["task"], "exclude_types"?: ["capture-pending"], "limit"?: 20,
|
|
182
|
+
"offset"?: 0 }` → `{ "items": [{id, title, type, status,
|
|
177
183
|
priority, score, signals: {blocking, heat, staleness, age_days}, suggest:
|
|
178
184
|
"do|close", why}], "count": N, "offset": 0, "returned_count": N,
|
|
179
185
|
"total_count": N, "truncated": false, "next_offset": null, "questions": []
|
|
180
186
|
}` — queueable live nodes ranked by the default blend, each with a one-line
|
|
181
187
|
*why*. Items already retired by a live inbound resolves/answers edge are
|
|
182
188
|
excluded whatever their status field reads; open gardener findings ride
|
|
183
|
-
along per item as `findings
|
|
184
|
-
`
|
|
185
|
-
|
|
189
|
+
along per item as `findings`, capture-pending triage as `pending`, questions
|
|
190
|
+
you asked as `asked`, and nodes whose review is requested of you as `reviews`
|
|
191
|
+
(an open `review-requested` edge to your person node — the reviewer-facing
|
|
192
|
+
twin of `questions`, surfaced through the same per-person routing filter;
|
|
193
|
+
respond by flipping the edge to `reviewed-by` or `changes-requested-by`).
|
|
194
|
+
Structured output additionally carries `view` — the queue projected into the
|
|
195
|
+
view-tree catalog for the MCP-app widget (below).
|
|
186
196
|
|
|
187
197
|
**Limit and pagination.** `limit` is the page size (default 20, **max
|
|
188
198
|
100** — values above the max are clamped, not rejected); `offset` skips that
|
|
@@ -200,6 +210,16 @@ seen twice or skipped once across a re-rank, never a hard error. Walk the
|
|
|
200
210
|
whole queue by re-calling with `offset = next_offset` until `next_offset` is
|
|
201
211
|
null.
|
|
202
212
|
|
|
213
|
+
**Node-type filter** (task-cc-queue-filtering-enhancements). `types` keeps only
|
|
214
|
+
those node types in the ranking (a whitelist); `exclude_types` drops them (a
|
|
215
|
+
blacklist). Given both, the include set is narrowed and then the excludes are
|
|
216
|
+
removed from it (exclude wins on overlap). Like `project`/`assignee` this is a
|
|
217
|
+
hard scope filter applied **before** scoring, so a filtered-out node is simply
|
|
218
|
+
out of scope — the `counts_*`/`total_count` aggregates describe the filtered
|
|
219
|
+
queue, not what the firehose hid. The type compared is the type the item
|
|
220
|
+
surfaces as, so `exclude_types: ["schema"]` also hides schema-approval items.
|
|
221
|
+
Omitting both (or passing empty arrays) filters nothing.
|
|
222
|
+
|
|
203
223
|
### `ask_question`
|
|
204
224
|
|
|
205
225
|
File a question the graph could not answer. Input `{ "text": "<the
|
|
@@ -276,18 +296,21 @@ endpoint is the REST twin of a core call:
|
|
|
276
296
|
| `GET /v1/status` | session-start, monitoring | `{node_count, projects: {...}, head, uptime, metrics}`; doubles as the health check. `?titles=1` adds `titles: [{id, type, project, title}]` — the one-round-trip graph index the distiller dedups against |
|
|
277
297
|
| `GET /v1/me` | `spor whoami`/`status`, onboarding | identity echo for the bearer token → `{person, name, email, bound, is_admin}`. `bound:false` means the token authenticates but maps to **no person node** (legacy/OAuth, or minted before the node existed), so routed questions and the personal queue will be empty — the client warns on it (the silent identity-degradation signal). `is_admin` reflects the `stewards→root` edge that gates the token-admin surface |
|
|
278
298
|
| `GET /v1/briefing/{project}` | session-start | read the `brief-<project>` node → `{found, version, body, project_brief?, graph_status}`. The slug resolves through project-node aliases (GRAPH.md "Project identity nodes") before lookup. A BARE repo slug also rides up to its home-project grouping: the grouping's `brief-<grouping>` node returns alongside as `project_brief` (the product context spanning sibling repos), matching the shared up-resolution (dec-spor-queue-slug-resolves-to-grouping); passing the repo NODE id (`repo-<slug>`) is the escape hatch that returns only the repo brief, no `project_brief`. Optional `?fp=root:<sha>,remote:<host/path>,...` carries the repo's fingerprints: the server learns them onto the owning project node, and an unknown slug with a known fingerprint files an alias proposal in the queue |
|
|
279
|
-
| `POST /v1/digest` `{query, min_sim?}` | prompt-context | digest-mode compile → `{found, text}`; `found: false` is a successful empty result |
|
|
299
|
+
| `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 |
|
|
280
300
|
| `GET /v1/nodes/{id}` | /spor:brief | `get_node` semantics; when a live inbound resolves/answers edge contradicts a still-open status the response carries `resolution`, and open gardener findings about the node ride along as `open_findings` |
|
|
281
301
|
| `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 |
|
|
282
302
|
| `POST /v1/nodes/{id}/edges` `{type, to}` | scripts, mechanical writers | `add_edge` semantics (§1): normalize/flip, dedupe, append — no revision echo |
|
|
283
|
-
| `POST /v1/nodes/{id}/status` `{status}` | scripts, mechanical writers | `set_status` semantics (§1): one-scalar update through the `transitions()` gate |
|
|
303
|
+
| `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) |
|
|
304
|
+
| `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, since the launched agent renews from a different session) |
|
|
305
|
+
| `POST /v1/nodes/{id}/renew` `{session?}` | post-tool heartbeat, `renew` MCP tool | 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 |
|
|
306
|
+
| `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 |
|
|
284
307
|
| `POST /v1/nodes/{id}/commits` `{repo, sha}` | post-tool / link-commits | `link_commit`: append `repo@sha` to the node's `commits:` list (kebab-case repo slug, 7–40 lowercase hex, ≤40 commits per node); idempotent, prefix-aware dedup |
|
|
285
308
|
| `GET /v1/commits/{sha}?repo=` | sessions doing git archaeology | sha → nodes lookup over the `commits:` fields (≥7 hex, abbreviated or full); each match carries `{repo, sha, id, type, title, summary, status, project}` — blame a line, get the why |
|
|
286
309
|
| `GET /v1/changes?since=&project=&limit=` | `recent_changes`'s REST twin; audit review | the remote audit trail: a git-log projection over `nodes/` → `{changes: [{id, change, commit, date, committed_by, type, title, authored_via, author}], count, head, since, generated_at}`, newest change per node first. `since` is a 7–40 hex sha (`sha..HEAD`) or a date/relative phrase git understands (`--since`); an unresolvable sha is `422`. `project` scopes to one project's nodes (deletions are omitted when scoped, their project being gone). `limit` bounds nodes returned (default 100, **max 500**). Each entry's `authored_via` is the current machine-vs-human signal (`capture`/`distill`/`gardener` = machine). Lets a remote client review what agents wrote without the whole `/v1/export` tarball |
|
|
287
310
|
| `POST /v1/capture` | distill, /spor:defer | `capture` semantics: `{text, context: {project, during, blocks?, needed_by?}, source?}` → ingestion model + validate + commit → `{status, ids, nodes, summary, warnings}`. `source: "distill"` marks backstop captures in the journal. `context.blocks` (a node id, must exist) and `context.needed_by` (`YYYY-MM-DD`) declare a cross-project dependency (task-cc-xproject-dependency-loop): set `context.project` to the SERVING project and the server attaches a `blocks` edge to the requester + the deadline deterministically (not via the model) onto the primary node. A missing `blocks` target is `404`; a non-date `needed_by` is `422` — both rejected before any model call |
|
|
288
311
|
| `POST /v1/distill/report` | distill | sweep telemetry, journal-only (no store mutation): `{facts, captured?, spooled?, rejected?, project?, session?}` → `{status: "reported"}`; zero-fact sweeps report too |
|
|
289
312
|
| `POST /v1/corrections` | /spor:correct | `propose_correction` semantics → 201 `{status, id, revision, warnings}` |
|
|
290
|
-
| `GET /v1/queue?project=&assignee=&limit=&offset=` | /spor:next, session-start | the ranked decision queue: `{items, count, offset, returned_count, total_count, truncated, next_offset, counts_by_type, counts_by_project, counts_by_suggest, muted?, dormant?, questions, findings, policy?, generated_at}` — items retired by a live resolves/answers edge are excluded; items hidden by the viewer's `queue_mute` or parked by a future `wake:` date (QUEUE.md §4) are counted, never silently dropped; `questions`/`findings` are the routed-to-me-plus-unrouted views for the authenticated identity. `limit` is the page size (default 20, **max 100**, clamped not rejected) and `offset` skips that many items in the ranked order (default 0); the `counts_*`/`total_count` aggregates always cover the FULL ranked set regardless of the page, so one call answers "how many issues vs tasks" without paging, while `truncated`/`next_offset` let a client walk the rest by re-requesting with `offset=next_offset` until `next_offset` is null. Pagination is offset over a point-in-time ranked slice (the queue re-ranks every call), not a cursor — it resumes the same slice only across an unchanged ranking. `project` resolves through the shared up-resolution (dec-spor-queue-slug-resolves-to-grouping): a bare repo slug unions its home-project grouping's member queues, the repo NODE id (`repo-<slug>`) pins one repo, a grouping id (`proj-<slug>`) is used directly. `assignee=<person-id>` scopes to the work that person carries (their `assigned`/`stewards` edges) — a manager's "who is carrying what"; `assignee=me` binds to the caller (empty if the token maps to no person node) |
|
|
313
|
+
| `GET /v1/queue?project=&assignee=&type=&exclude_type=&limit=&offset=` | /spor:next, session-start | the ranked decision queue: `{items, count, offset, returned_count, total_count, truncated, next_offset, counts_by_type, counts_by_project, counts_by_suggest, muted?, dormant?, questions, asked, findings, pending, reviews, policy?, generated_at}` — items retired by a live resolves/answers edge are excluded; items hidden by the viewer's `queue_mute` or parked by a future `wake:` date (QUEUE.md §4) are counted, never silently dropped; `questions`/`findings`/`pending` are the routed-to-me-plus-unrouted views for the authenticated identity, `asked` is the questions you filed, and `reviews` is the nodes whose review is requested of you (an open `review-requested` edge to your person node — explicitly targeted, no unrouted fallback). `limit` is the page size (default 20, **max 100**, clamped not rejected) and `offset` skips that many items in the ranked order (default 0); the `counts_*`/`total_count` aggregates always cover the FULL ranked set regardless of the page, so one call answers "how many issues vs tasks" without paging, while `truncated`/`next_offset` let a client walk the rest by re-requesting with `offset=next_offset` until `next_offset` is null. Pagination is offset over a point-in-time ranked slice (the queue re-ranks every call), not a cursor — it resumes the same slice only across an unchanged ranking. `project` resolves through the shared up-resolution (dec-spor-queue-slug-resolves-to-grouping): a bare repo slug unions its home-project grouping's member queues, the repo NODE id (`repo-<slug>`) pins one repo, a grouping id (`proj-<slug>`) is used directly; **omitting `project` is the cross-project firehose** (every repo's queue at once). `assignee=<person-id>` scopes to the work that person carries (their `assigned`/`stewards` edges) — a manager's "who is carrying what"; `assignee=me` binds to the caller (empty if the token maps to no person node). `type=`/`exclude_type=` (comma-separated, repeatable) whitelist/blacklist node types from the ranking (exclude wins on overlap) — a hard scope filter applied before scoring, so the aggregates describe the filtered queue (task-cc-queue-filtering-enhancements) |
|
|
291
314
|
| `POST /v1/questions` `{text, title?, mentions?}` | ask_question's REST twin | file a question node; deterministically routed to the steward of the closest relevance-neighborhood node, unrouted if none → 201 `{status, id, routed_to, via, asker, revision, warnings}` |
|
|
292
315
|
| `POST /v1/gardener` | ops cron / on demand | run a gardener sweep now; findings filed as queue items → `{filed, resolved, ..., generated_at}` |
|
|
293
316
|
| `GET /v1/lens/{id}/render?format=html\|text\|json` | browsers, teammates without a checkout | run a lens OR workspace node and render its view tree (html default, plain text, or the raw tree as json). Read-only — no action forms; writes stay with `/v1/nodes` and the MCP tools. Auth is the caller's bearer header OR a signed read-only **render ticket** for shared links (browser links can't carry an Authorization header): `?ticket=<blob>` is accepted once and exchanged via a 302 for an HttpOnly `spor_render_ticket` cookie (kept out of URLs, logs, and view-to-view hrefs). The ticket binds `$viewer` to the recorded sharer and the render shows a "Viewing as <sharer>" banner. The former `?token=<PAT>` sharing path is **removed** — a shared link can never carry a write-capable credential |
|
|
@@ -296,6 +319,9 @@ endpoint is the REST twin of a core call:
|
|
|
296
319
|
| `GET /v1/admin/tokens` | offboarding / audit | list PATs → `{tokens: [{hash_prefix, person, name, email, created, expires, expired}], count}` — never plaintext, never full hashes. Admin-only (§4) |
|
|
297
320
|
| `POST /v1/admin/tokens` `{person, expires?}` | onboarding | mint a PAT bound to an existing person node (`expires` is `<N>d` or an ISO date) → 201 `{token, hash_prefix, person, name, email, expires}`; the plaintext `token` is returned **once**. Admin-only |
|
|
298
321
|
| `DELETE /v1/admin/tokens/{hash-prefix}` | offboarding / rotation | revoke the single PAT matching the hash prefix (≥8 hex chars; an ambiguous prefix is a 409) → `{revoked, hash_prefix}`. Admin-only |
|
|
322
|
+
| `GET /v1/agents` | `spor agent list` | list the agents the caller **owns** → `{agents: [{id, label, owner, spiffe, pubkey, status}], count}`; `?all=1` lists every agent (admin-only) |
|
|
323
|
+
| `POST /v1/admin/agents` `{label, owner?, id?, pubkey?}` | `spor agent create`, onboarding | create a person-owned `agent` node + its `owned-by` edge (`owner` defaults to the caller's person; `id` derives from `label`) → 201 `{id, owner, spiffe, pubkey, status, revision}`. 409 dup id / 422 invalid / 403 non-admin. Admin-only, same `stewards→root` gate as `/v1/admin/tokens` |
|
|
324
|
+
| `POST /v1/agents/{id}/token` `{session, audience?, expires?}` | `spor dispatch` | **self-serve** (NOT admin): mint a short-TTL, per-session token scoped to agent `{id}` → 201 `{token, expires_at, agent, session}`. Authorized iff the caller's person **owns** the agent (its `owned-by` edge) — else `403`; `404` unknown agent; `422` bad/missing `session`. The token carries `{agent, session}` (the person is derived from the `owned-by` edge at verify time); a write under it is stamped agent-on-behalf-of-person (§1). A caller `expires` may only shorten the default TTL, never extend it |
|
|
299
325
|
|
|
300
326
|
Path parameters (node ids, project slugs) must match
|
|
301
327
|
`^[a-z0-9][a-z0-9-]*$`. Request bodies are capped at 1MB
|
|
@@ -339,6 +365,19 @@ anything with a token.
|
|
|
339
365
|
--admin --person <id>` (it writes that `stewards` edge, creating the person
|
|
340
366
|
node from `--name`/`--email` if needed). Hand-editing the token file stays
|
|
341
367
|
as the break-glass path.
|
|
368
|
+
- **Agent-scoped session tokens.** A person mints a short-lived, per-session
|
|
369
|
+
token for an `agent` they **own** through the self-serve
|
|
370
|
+
`POST /v1/agents/{id}/token` (§3) — authorized by ownership (the agent's
|
|
371
|
+
`owned-by → person` edge), never admin, so a dispatcher needs no special
|
|
372
|
+
privilege to run their own agents. The token's record carries `{agent,
|
|
373
|
+
session}` and no person; the owning person (and its `{name, email}`) resolves
|
|
374
|
+
from the `owned-by` edge at verify time, so a deleted agent or owner makes the
|
|
375
|
+
token fail closed rather than impersonate. Writes under it are attributed
|
|
376
|
+
agent-on-behalf-of-person (§1) — the `person → agent` chain is the audit
|
|
377
|
+
trail. `spor dispatch` mints one per run and injects it into the launched
|
|
378
|
+
background agent (so the agent's own graph writes carry its identity), picking
|
|
379
|
+
the machine's default agent from the `dispatch.agent` client config
|
|
380
|
+
(`SPOR_DISPATCH_AGENT`).
|
|
342
381
|
- **OAuth 2.1 for MCP connectors** (Cowork/claude.ai, which cannot carry a
|
|
343
382
|
static bearer token): protected-resource metadata discovery (RFC 9728,
|
|
344
383
|
advertised on the `/mcp` 401 via `WWW-Authenticate`), authorization-server
|
package/GRAPH.md
CHANGED
|
@@ -94,6 +94,7 @@ Rules:
|
|
|
94
94
|
| correction | `corr-` | standing fix to a briefing: pin/exclude/guidance (never traversed) |
|
|
95
95
|
| question | `question-` | a routed ask the graph could not answer (queueable; status `open`/`answered`, gated) |
|
|
96
96
|
| person | `person-` | a member of the org — the identity anchor for `$viewer` binding and Tier-2 question routing (team mode; see "People, routing, and onboarding") |
|
|
97
|
+
| agent | `agent-` | a person-owned automation principal — a dispatched session's durable identity, owned by a person via an `owned-by` edge; its writes attribute "agent on behalf of person" (see "Agents") |
|
|
97
98
|
| capture-pending | `cap-` | raw captured text that fit no schema; filed by the server for later triage (QUEUE.md §2.3); born status-less, closed only as `merged` (content now in proper node(s)) or `rejected` (no durable fact) — a `transitions()` gate rejects other statuses at write time |
|
|
98
99
|
| finding | `find-` | a gardener observation about another node, filed as a queue item (QUEUE.md §6) |
|
|
99
100
|
| repo | `repo-` | durable git-repo identity: slug aliases + repo fingerprints; heals renames at read time (below) |
|
|
@@ -169,9 +170,13 @@ least two — the definition-of-done quorum gate.
|
|
|
169
170
|
excluded (the self-approval floor — a policy can't be used to launder it).
|
|
170
171
|
The first concrete rule is the **definition-of-done quorum gate**: a work
|
|
171
172
|
node's `done` transition additionally requires a quorum of approvals from
|
|
172
|
-
qualified roles.
|
|
173
|
-
|
|
174
|
-
|
|
173
|
+
qualified roles. The `review-requested`/`reviewed-by`/`changes-requested-by`
|
|
174
|
+
edge types ship in the seed pack (review-as-graph-object); a single edge
|
|
175
|
+
flips type in place across the review lifecycle, `reviewed-by` is what the
|
|
176
|
+
quorum counts, and an open `review-requested` edge surfaces the node in the
|
|
177
|
+
named reviewer's queue. The gate context also carries `view.changes_requested`
|
|
178
|
+
(the `changes-requested-by` edges, same author-excluded shape) so a policy
|
|
179
|
+
may block `done` while a qualified reviewer's change request is outstanding.
|
|
175
180
|
- **Policy nodes go through the same proposal/activation flow they govern** —
|
|
176
181
|
a proposed policy is inert until a *different* identity activates it (the
|
|
177
182
|
native floor protects against self-amendment circularity).
|
|
@@ -551,6 +556,56 @@ client surfaces no warning when the authenticated identity maps to no person
|
|
|
551
556
|
node. Surfacing that unbound state in queue and briefing responses is tracked
|
|
552
557
|
server-side (issue-cc-onboarding-email-mismatch-silent-degradation).
|
|
553
558
|
|
|
559
|
+
### Agents (person-owned principals)
|
|
560
|
+
|
|
561
|
+
An `agent` node (prefix `agent-`) is a person's automation principal — the
|
|
562
|
+
durable identity of a dispatched `claude --bg` session
|
|
563
|
+
(dec-spor-agent-identity-nodes). It generalizes the workflow-run principal: a
|
|
564
|
+
dispatched session is just another principal kind owned by a person, so work it
|
|
565
|
+
creates reads "agent **on behalf of** person" rather than person-direct.
|
|
566
|
+
|
|
567
|
+
```markdown
|
|
568
|
+
---
|
|
569
|
+
id: agent-anthony-laptop
|
|
570
|
+
type: agent
|
|
571
|
+
title: Anthony's laptop agent
|
|
572
|
+
summary: Dispatched-session principal on Anthony's laptop; owned by person-anthony.
|
|
573
|
+
spiffe: spiffe://spor.sporhq/person/person-anthony/agent/anthony-laptop
|
|
574
|
+
pubkey: ""
|
|
575
|
+
status: active
|
|
576
|
+
date: 2026-06-16
|
|
577
|
+
edges:
|
|
578
|
+
- {type: owned-by, to: person-anthony}
|
|
579
|
+
---
|
|
580
|
+
```
|
|
581
|
+
|
|
582
|
+
- **Grain: persistent, one per machine/install.** An agent node is created once
|
|
583
|
+
(`spor agent create <label>`, or the admin endpoint) and REUSED across every
|
|
584
|
+
dispatch — NOT one node per session. Each dispatch's Claude Code `session_id`
|
|
585
|
+
is the ephemeral which-run, recorded as an additive `session:` stamp on the
|
|
586
|
+
nodes it writes, not as its own node (promote to a `run-<id>` node only if run
|
|
587
|
+
metadata later needs a home).
|
|
588
|
+
- **`owned-by` is the ownership edge** (agent → person, inverse `owns`). Like
|
|
589
|
+
`grouped-under` it is a low-weight (0.3) structural identity binding, not a
|
|
590
|
+
work dependency — the owner is not pulled into the agent's work neighborhoods.
|
|
591
|
+
Ownership lives on this edge, NOT in frontmatter.
|
|
592
|
+
- **`spiffe:` / `pubkey:` are forward-compat shape, unenforced.** The `spiffe:`
|
|
593
|
+
URI encodes the binding in its path
|
|
594
|
+
(`spiffe://spor.<org>/person/<id>/agent/<label>`, extended to
|
|
595
|
+
`…/session/<uuid>` per dispatch); `pubkey:` records the agent's key
|
|
596
|
+
fingerprint (may be empty). Both adopt the SHAPE (dec-cc-spiffe-forward-compat)
|
|
597
|
+
— no signature verification, JWKS, or signed commits ship in this cut.
|
|
598
|
+
- **Attribution stamps (additive).** Work an agent creates keeps `author:` = the
|
|
599
|
+
owning person (so `$viewer`, routing, history, and the queue are unchanged) and
|
|
600
|
+
ADDS `authored_by_agent: agent-<id>`, `authored_via: dispatch`, and `session:
|
|
601
|
+
<id>`. These are token-derived, never from the payload
|
|
602
|
+
(dec-cc-attribution-from-token); old nodes lack them and read as person-direct.
|
|
603
|
+
This is purely additive — the stamp itself needs no `schema_version` bump.
|
|
604
|
+
- `capturable: false` (agent node and `owned-by` edge): both are created
|
|
605
|
+
deliberately, never drafted from a capture or distilled from a transcript —
|
|
606
|
+
mirroring `person`, `repo`, and `workflow-run`. (So the distiller's emit
|
|
607
|
+
vocabulary deliberately omits `agent`/`owned-by`.)
|
|
608
|
+
|
|
554
609
|
## Edge types and traversal weights
|
|
555
610
|
|
|
556
611
|
| edge | weight | meaning |
|
|
@@ -564,11 +619,15 @@ server-side (issue-cc-onboarding-email-mismatch-silent-degradation).
|
|
|
564
619
|
| `blocks` | 0.7 | target cannot proceed until this node does |
|
|
565
620
|
| `answers` | 0.7 | this node answers that question (inverse `answered-by`); pulls the answer through the asker's next compile |
|
|
566
621
|
| `assigned` | 0.5 | work is assigned to this person |
|
|
622
|
+
| `reviewed-by` | 0.5 | this person reviewed and approved the node — counts toward a policy quorum |
|
|
623
|
+
| `changes-requested-by` | 0.5 | this person reviewed the node and requested changes — not an approval |
|
|
567
624
|
| `relates-to` | 0.5 | weak association |
|
|
568
625
|
| `mentions` | 0.5 | weakest association |
|
|
569
626
|
| `stewards` | 0.4 | this person stewards an area/spec/norm — the Tier-2 question-routing key |
|
|
570
627
|
| `grouped-under` | 0.3 | this repo's home project grouping (inverse `groups`); structural membership, not work dependency |
|
|
628
|
+
| `owned-by` | 0.3 | this agent is owned by that person (inverse `owns`); structural identity binding, not work dependency |
|
|
571
629
|
| `routed-to` | 0.3 | a question routed to this person for answering |
|
|
630
|
+
| `review-requested` | 0.3 | a review of this node is requested of this person (pending) — surfaces in their queue |
|
|
572
631
|
| `compiled-for` | — | briefing → its task/query (provenance only) |
|
|
573
632
|
| `shaped-by` | — | briefing → corrections applied (provenance only) |
|
|
574
633
|
|
|
@@ -579,6 +638,16 @@ documented under "People, routing, and onboarding" above. `assigned` and
|
|
|
579
638
|
the area they own; `answers` points from any answer node back at the
|
|
580
639
|
`question-` it resolves.
|
|
581
640
|
|
|
641
|
+
`review-requested`, `reviewed-by`, and `changes-requested-by` are the
|
|
642
|
+
review-as-graph-object edges (a work node → a `person-`): a single edge that
|
|
643
|
+
flips type in place across the review lifecycle — `review-requested` while a
|
|
644
|
+
reviewer's verdict is pending (surfaced into their queue), `reviewed-by` once
|
|
645
|
+
they approve (counted by the definition-of-done quorum gate), or
|
|
646
|
+
`changes-requested-by` once they ask for changes. They are the canonical,
|
|
647
|
+
source-blind record a native review surface and the GitHub adapter both write;
|
|
648
|
+
a review *node* is deferred to when Spor owns the thread
|
|
649
|
+
(dec-spor-definition-of-done-org-policy).
|
|
650
|
+
|
|
582
651
|
High-weight edges decay slowly across hops; they are what makes structural
|
|
583
652
|
traversal beat similarity search. Prefer one precise high-weight edge over
|
|
584
653
|
three `relates-to`.
|
package/QUEUE.md
CHANGED
|
@@ -306,15 +306,25 @@ approvals from qualified roles: `view.approvals` is the node's
|
|
|
306
306
|
The enabling hook (`view.actor`) shipped with the floor, so this was net-new
|
|
307
307
|
policy-kind work, not core surgery (GRAPH.md "The org-defined policy layer").
|
|
308
308
|
|
|
309
|
+
**Review as a graph object** (review-as-graph-object, Stage 3) supplies the
|
|
310
|
+
edges the gate counts. The seed pack ships three review-outcome edge types
|
|
311
|
+
(work node → `person`): `review-requested` (a review is pending of that
|
|
312
|
+
person), `reviewed-by` (they approved — what `view.approvals` carries and the
|
|
313
|
+
quorum counts), and `changes-requested-by` (they asked for changes — carried
|
|
314
|
+
separately as `view.changes_requested`, never an approval). A single edge
|
|
315
|
+
flips type in place across the lifecycle. An open `review-requested` edge
|
|
316
|
+
surfaces the node in that reviewer's `my_queue` `reviews` set, through the same
|
|
317
|
+
per-person routing filter questions and findings use — reviewer *selection* at
|
|
318
|
+
request time and quorum *enforcement* at gate time are distinct points.
|
|
319
|
+
|
|
309
320
|
Still on the layer's roadmap, beyond the quorum gate: the agent-vs-human
|
|
310
321
|
claim-eligibility promoted-set (task-cc-claim-eligibility-policy), the
|
|
311
322
|
queue-blend override absorbed as a `policy` rather than the separate
|
|
312
|
-
`queue-policy` singleton (task-cc-schema-queue-policy-override), the
|
|
313
|
-
|
|
314
|
-
(
|
|
315
|
-
|
|
316
|
-
|
|
317
|
-
`governs.{types,projects}` scope.
|
|
323
|
+
`queue-policy` singleton (task-cc-schema-queue-policy-override), the GitHub
|
|
324
|
+
review/merge reflection adapter that writes these same edges from PR events
|
|
325
|
+
(task-spor-github-review-adapter, Stage 4), and a fuller governs-traversal that
|
|
326
|
+
walks the project/path hierarchy via `governs`/`governed-by` edges rather than
|
|
327
|
+
matching the flat `governs.{types,projects}` scope.
|
|
318
328
|
|
|
319
329
|
## 3. The capture flow, end to end
|
|
320
330
|
|
|
@@ -442,6 +452,17 @@ The queue is a compile mode, not a new store:
|
|
|
442
452
|
queues — the intuitive token returns the whole product; the repo NODE id
|
|
443
453
|
(`repo-<slug>`) is the escape hatch back to one repo; an exact grouping id
|
|
444
454
|
(`proj-<slug>`) is used directly; an ungrouped repo falls back to itself.
|
|
455
|
+
**Omitting `project` entirely is the cross-project firehose** (every repo's
|
|
456
|
+
queue at once); `/spor:next --all-projects` and `my_queue` / `GET /v1/queue`
|
|
457
|
+
with no `project` reach it.
|
|
458
|
+
- `includeTypes`/`excludeTypes` (task-cc-queue-filtering-enhancements) whitelist
|
|
459
|
+
or blacklist node types FROM THE RANKING — `GET /v1/queue?type=&exclude_type=`
|
|
460
|
+
(comma-separated), `my_queue {types, exclude_types}`, and
|
|
461
|
+
`spor next --type/--exclude-type`. Given both, the include set is narrowed and
|
|
462
|
+
then the excludes are removed from it (exclude wins on overlap). Like `project`
|
|
463
|
+
it is a hard scope applied before scoring (the aggregates describe the filtered
|
|
464
|
+
queue), it composes with `project`/`assignee`, and the type compared is the one
|
|
465
|
+
the item surfaces as (so excluding `schema` hides schema-approval items too).
|
|
445
466
|
- Exposed as `GET /v1/queue` (hooks, session-start "open front" line),
|
|
446
467
|
`my_queue` (the registered MCP stub finally does work — and when Tier 2
|
|
447
468
|
routing lands, routed questions and stewarded items join the same queue),
|
package/README.md
CHANGED
|
@@ -104,6 +104,14 @@ Spor already knows where that repo lives. Flags pass through to `claude`
|
|
|
104
104
|
(`--model`, `--permission-mode`, `--agent`, `--name`); `--full` embeds the whole
|
|
105
105
|
neighborhood and `--no-brief` skips the briefing.
|
|
106
106
|
|
|
107
|
+
**No accidental duplicates.** A node dispatch won't double up on work already
|
|
108
|
+
underway. In remote mode it auto-claims the task's heartbeat lease at launch, so
|
|
109
|
+
dispatching a node a teammate already holds is refused with the holder named
|
|
110
|
+
(`--no-claim` opts out). And in either mode, dispatching a node that already has
|
|
111
|
+
a background agent in flight *on this machine* — each agent is named after its
|
|
112
|
+
node id — is refused too, catching the same-person duplicate the lease's
|
|
113
|
+
idempotent renew can't. `--force` overrides the local guard.
|
|
114
|
+
|
|
107
115
|
**Your own prompt.** By default the briefing is prepended to a built-in prompt
|
|
108
116
|
shell. Pass `--template <file>` (or set a default path in `dispatch.template`)
|
|
109
117
|
to supply your own prompt instead, with Handlebars-style `{{placeholder}}`
|