@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.
@@ -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.2",
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
- "limit"?: 20, "offset"?: 0 }` → `{ "items": [{id, title, type, status,
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`. Structured output additionally carries
184
- `view` the queue projected into the view-tree catalog for the MCP-app
185
- widget (below).
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 &lt;sharer&gt;" 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. (The `reviewed-by`/`approved-by` edge types and a native
173
- review surface ship with review-as-graph-object; the policy kind, scope
174
- selection, and the gate AND are landed now.)
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 seed
313
- `reviewed-by`/`approved-by` edge types + a native review surface
314
- (review-as-graph-object, Stage 3, which supplies the approval edges the quorum
315
- gate counts), and a fuller governs-traversal that walks the project/path
316
- hierarchy via `governs`/`governed-by` edges rather than matching the flat
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}}`