@sporhq/spor 0.5.2 → 0.6.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.6.0",
6
6
  "author": { "name": "losthammer" }
7
7
  }
package/API.md CHANGED
@@ -173,16 +173,21 @@ Only an unreachable ingestion model is an error (`ingestion_unavailable`).
173
173
  ### `my_queue`
174
174
 
175
175
  The decision queue (QUEUE.md §4/§5). Input `{ "project"?: "slug",
176
- "limit"?: 20, "offset"?: 0 }` → `{ "items": [{id, title, type, status,
176
+ "types"?: ["task"], "exclude_types"?: ["capture-pending"], "limit"?: 20,
177
+ "offset"?: 0 }` → `{ "items": [{id, title, type, status,
177
178
  priority, score, signals: {blocking, heat, staleness, age_days}, suggest:
178
179
  "do|close", why}], "count": N, "offset": 0, "returned_count": N,
179
180
  "total_count": N, "truncated": false, "next_offset": null, "questions": []
180
181
  }` — queueable live nodes ranked by the default blend, each with a one-line
181
182
  *why*. Items already retired by a live inbound resolves/answers edge are
182
183
  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).
184
+ along per item as `findings`, capture-pending triage as `pending`, questions
185
+ you asked as `asked`, and nodes whose review is requested of you as `reviews`
186
+ (an open `review-requested` edge to your person node — the reviewer-facing
187
+ twin of `questions`, surfaced through the same per-person routing filter;
188
+ respond by flipping the edge to `reviewed-by` or `changes-requested-by`).
189
+ Structured output additionally carries `view` — the queue projected into the
190
+ view-tree catalog for the MCP-app widget (below).
186
191
 
187
192
  **Limit and pagination.** `limit` is the page size (default 20, **max
188
193
  100** — values above the max are clamped, not rejected); `offset` skips that
@@ -200,6 +205,16 @@ seen twice or skipped once across a re-rank, never a hard error. Walk the
200
205
  whole queue by re-calling with `offset = next_offset` until `next_offset` is
201
206
  null.
202
207
 
208
+ **Node-type filter** (task-cc-queue-filtering-enhancements). `types` keeps only
209
+ those node types in the ranking (a whitelist); `exclude_types` drops them (a
210
+ blacklist). Given both, the include set is narrowed and then the excludes are
211
+ removed from it (exclude wins on overlap). Like `project`/`assignee` this is a
212
+ hard scope filter applied **before** scoring, so a filtered-out node is simply
213
+ out of scope — the `counts_*`/`total_count` aggregates describe the filtered
214
+ queue, not what the firehose hid. The type compared is the type the item
215
+ surfaces as, so `exclude_types: ["schema"]` also hides schema-approval items.
216
+ Omitting both (or passing empty arrays) filters nothing.
217
+
203
218
  ### `ask_question`
204
219
 
205
220
  File a question the graph could not answer. Input `{ "text": "<the
@@ -280,14 +295,17 @@ endpoint is the REST twin of a core call:
280
295
  | `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
296
  | `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
297
  | `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 |
298
+ | `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) |
299
+ | `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) |
300
+ | `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 |
301
+ | `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
302
  | `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
303
  | `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
304
  | `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
305
  | `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
306
  | `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
307
  | `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) |
308
+ | `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
309
  | `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
310
  | `POST /v1/gardener` | ops cron / on demand | run a gardener sweep now; findings filed as queue items → `{filed, resolved, ..., generated_at}` |
293
311
  | `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 |
package/GRAPH.md CHANGED
@@ -169,9 +169,13 @@ least two — the definition-of-done quorum gate.
169
169
  excluded (the self-approval floor — a policy can't be used to launder it).
170
170
  The first concrete rule is the **definition-of-done quorum gate**: a work
171
171
  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.)
172
+ qualified roles. The `review-requested`/`reviewed-by`/`changes-requested-by`
173
+ edge types ship in the seed pack (review-as-graph-object); a single edge
174
+ flips type in place across the review lifecycle, `reviewed-by` is what the
175
+ quorum counts, and an open `review-requested` edge surfaces the node in the
176
+ named reviewer's queue. The gate context also carries `view.changes_requested`
177
+ (the `changes-requested-by` edges, same author-excluded shape) so a policy
178
+ may block `done` while a qualified reviewer's change request is outstanding.
175
179
  - **Policy nodes go through the same proposal/activation flow they govern** —
176
180
  a proposed policy is inert until a *different* identity activates it (the
177
181
  native floor protects against self-amendment circularity).
@@ -564,11 +568,14 @@ server-side (issue-cc-onboarding-email-mismatch-silent-degradation).
564
568
  | `blocks` | 0.7 | target cannot proceed until this node does |
565
569
  | `answers` | 0.7 | this node answers that question (inverse `answered-by`); pulls the answer through the asker's next compile |
566
570
  | `assigned` | 0.5 | work is assigned to this person |
571
+ | `reviewed-by` | 0.5 | this person reviewed and approved the node — counts toward a policy quorum |
572
+ | `changes-requested-by` | 0.5 | this person reviewed the node and requested changes — not an approval |
567
573
  | `relates-to` | 0.5 | weak association |
568
574
  | `mentions` | 0.5 | weakest association |
569
575
  | `stewards` | 0.4 | this person stewards an area/spec/norm — the Tier-2 question-routing key |
570
576
  | `grouped-under` | 0.3 | this repo's home project grouping (inverse `groups`); structural membership, not work dependency |
571
577
  | `routed-to` | 0.3 | a question routed to this person for answering |
578
+ | `review-requested` | 0.3 | a review of this node is requested of this person (pending) — surfaces in their queue |
572
579
  | `compiled-for` | — | briefing → its task/query (provenance only) |
573
580
  | `shaped-by` | — | briefing → corrections applied (provenance only) |
574
581
 
@@ -579,6 +586,16 @@ documented under "People, routing, and onboarding" above. `assigned` and
579
586
  the area they own; `answers` points from any answer node back at the
580
587
  `question-` it resolves.
581
588
 
589
+ `review-requested`, `reviewed-by`, and `changes-requested-by` are the
590
+ review-as-graph-object edges (a work node → a `person-`): a single edge that
591
+ flips type in place across the review lifecycle — `review-requested` while a
592
+ reviewer's verdict is pending (surfaced into their queue), `reviewed-by` once
593
+ they approve (counted by the definition-of-done quorum gate), or
594
+ `changes-requested-by` once they ask for changes. They are the canonical,
595
+ source-blind record a native review surface and the GitHub adapter both write;
596
+ a review *node* is deferred to when Spor owns the thread
597
+ (dec-spor-definition-of-done-org-policy).
598
+
582
599
  High-weight edges decay slowly across hops; they are what makes structural
583
600
  traversal beat similarity search. Prefer one precise high-weight edge over
584
601
  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}}`
package/bin/spor.js CHANGED
@@ -242,10 +242,47 @@ async function cmdNext(cfg, args) {
242
242
  const pi = args.indexOf("--project");
243
243
  const explicit = pi >= 0 && args[pi + 1] ? args[pi + 1] : null;
244
244
  const pinned = cfg.get("queue.project", null);
245
+ // Cross-project scope (task-cc-queue-filtering-enhancements): --all-projects
246
+ // (alias --all) widens to the whole-graph firehose by dropping the cwd/pinned
247
+ // default scope. An explicit --project is more specific and still wins.
248
+ const allProjects = args.includes("--all-projects") || args.includes("--all");
249
+ // Node-type allow/deny (task-cc-queue-filtering-enhancements): repeatable +
250
+ // comma-splittable (--type task --type issue, or --type task,issue). Forwarded
251
+ // to the server as ?type=/?exclude_type= in remote mode; in local mode the raw
252
+ // flags pass straight through to lib/queue.js, which speaks the same flags.
253
+ const collectMulti = (name) => {
254
+ const out = [];
255
+ for (let i = 0; i < args.length; i++) {
256
+ if (args[i] === `--${name}` && args[i + 1] != null) {
257
+ out.push(...args[i + 1].split(",").map((s) => s.trim()).filter(Boolean));
258
+ }
259
+ }
260
+ return out;
261
+ };
262
+ const inclTypes = collectMulti("type");
263
+ const exclTypes = collectMulti("exclude-type");
264
+
265
+ // In-flight agent surface (task-spor-cli-in-flight-surface). `spor next --json`
266
+ // stamps each item with an `in_flight` flag by cross-referencing the live
267
+ // background agents (`claude agents --json`); --hide-dispatched drops the items
268
+ // that already have one. Both are CLIENT-SIDE presentation: the server's queue
269
+ // can't see local agents, so this is computed here over either render path. The
270
+ // cross-reference only runs when one of the two flags asks for it, so the
271
+ // default queue path stays byte-identical (and never shells out to claude).
272
+ const wantJson = args.includes("--json");
273
+ const hideDispatched = args.includes("--hide-dispatched");
274
+ const needAgents = wantJson || hideDispatched;
245
275
 
246
276
  if (cfg.mode() === "remote") {
247
- const slug = explicit ?? pinned ?? safeSlug();
248
- const r = await remote.get(cfg, `/v1/queue?project=${encodeURIComponent(slug)}&limit=10`, { timeoutMs: 6000 });
277
+ // --all-projects drops the default scope (firehose); an explicit --project
278
+ // still wins over it. Otherwise fall back to the pinned default, then cwd.
279
+ const scopeSlug = allProjects && !explicit ? null : (explicit ?? pinned ?? safeSlug());
280
+ const qs = new URLSearchParams();
281
+ if (scopeSlug) qs.set("project", scopeSlug);
282
+ qs.set("limit", "10");
283
+ if (inclTypes.length) qs.set("type", inclTypes.join(","));
284
+ if (exclTypes.length) qs.set("exclude_type", exclTypes.join(","));
285
+ const r = await remote.get(cfg, `/v1/queue?${qs.toString()}`, { timeoutMs: 6000 });
249
286
  if (r.transport) {
250
287
  err(`offline — could not reach server (${r.error})`);
251
288
  return 1;
@@ -258,13 +295,27 @@ async function cmdNext(cfg, args) {
258
295
  // unknown-token detection is authoritative only locally (where we hold the
259
296
  // graph); remotely we can only observe an empty result for a SCOPED read and
260
297
  // softly say so on stderr. The cwd-default firehose (no explicit/pinned scope)
261
- // is deliberately not flagged — an empty default queue is normal, not a typo.
262
- const scoped = explicit ?? pinned;
298
+ // and an explicit --all-projects are deliberately not flagged — an empty
299
+ // result there is normal, not a typo.
300
+ const scoped = (allProjects && !explicit) ? null : (explicit ?? pinned);
263
301
  const count = (r.json && (r.json.count ?? (Array.isArray(r.json.items) ? r.json.items.length : null)));
264
302
  if (scoped && count === 0) {
265
303
  err(`project '${scoped}' returned an empty queue — check the slug / grouping id (the server scoped to it and found nothing)`);
266
304
  }
267
- if (args.includes("--json")) {
305
+ if (needAgents) {
306
+ const q = r.json || {};
307
+ const { items, hidden } = annotateInFlight(q.items || [], dispatchedAgents(), hideDispatched);
308
+ q.items = items;
309
+ if (typeof q.count === "number") q.count = Math.max(0, q.count - hidden);
310
+ if (hideDispatched) q.hidden_dispatched = hidden;
311
+ if (wantJson) {
312
+ out(JSON.stringify(q));
313
+ return 0;
314
+ }
315
+ renderQueue(q, hidden);
316
+ return 0;
317
+ }
318
+ if (wantJson) {
268
319
  out(JSON.stringify(r.json));
269
320
  return 0;
270
321
  }
@@ -273,22 +324,145 @@ async function cmdNext(cfg, args) {
273
324
  }
274
325
  // local: byte-identical passthrough. When no --project was given but a default
275
326
  // is pinned, inject it so the local read inherits the same default scope as
276
- // remote; otherwise pass args untouched (preserving the local->global default
277
- // we never inject safeSlug() locally).
278
- const localArgs = !explicit && pinned ? [...args, "--project", pinned] : args;
279
- return passthrough("queue.js", localArgs);
327
+ // remote UNLESS --all-projects asked for the firehose. Otherwise pass args
328
+ // untouched (preserving the local->global default — we never inject safeSlug()
329
+ // locally). --type/--exclude-type ride through; lib/queue.js parses them.
330
+ const localArgs = (!explicit && pinned && !allProjects) ? [...args, "--project", pinned] : args;
331
+ // Default path: byte-identical passthrough (no agent cross-reference). Only the
332
+ // --json / --hide-dispatched view captures queue.js's result to annotate it.
333
+ if (!needAgents) return passthrough("queue.js", localArgs);
334
+ return nextLocalInFlight(localArgs, { wantJson, hideDispatched });
335
+ }
336
+
337
+ // Local in-flight surface (task-spor-cli-in-flight-surface). The default local
338
+ // `next` is a byte-identical passthrough to lib/queue.js; when --json or
339
+ // --hide-dispatched asks for the agent-aware view we run queue.js with --json,
340
+ // capture its ranked result, cross-reference dispatchedAgents(), and re-emit.
341
+ // queue.js's stderr (e.g. the unknown-project note) is inherited so it still
342
+ // surfaces; an unparseable stdout falls back to forwarding it verbatim, so an
343
+ // error path is never swallowed. The flags are presentation-only — strip them
344
+ // before handing argv to queue.js (which doesn't know them) and force --json.
345
+ function nextLocalInFlight(localArgs, { wantJson, hideDispatched }) {
346
+ const passArgs = localArgs.filter((a) => a !== "--json" && a !== "--hide-dispatched");
347
+ passArgs.push("--json");
348
+ const r = spawnSync(process.execPath, [path.join(ROOT, "lib", "queue.js"), ...passArgs], {
349
+ encoding: "utf8",
350
+ stdio: ["ignore", "pipe", "inherit"],
351
+ });
352
+ const status = r.status == null ? 1 : r.status;
353
+ let q;
354
+ try {
355
+ q = JSON.parse(r.stdout);
356
+ } catch {
357
+ if (r.stdout) process.stdout.write(r.stdout); // forward queue.js's own output
358
+ return status;
359
+ }
360
+ const { items, hidden } = annotateInFlight(q.items || [], dispatchedAgents(), hideDispatched);
361
+ q.items = items;
362
+ if (typeof q.count === "number") q.count = Math.max(0, q.count - hidden);
363
+ if (hideDispatched) q.hidden_dispatched = hidden;
364
+ if (wantJson) {
365
+ out(JSON.stringify(q, null, 2)); // match queue.js --json (pretty, 2-space)
366
+ return status;
367
+ }
368
+ renderQueueLocalText(q, hidden);
369
+ return status;
370
+ }
371
+
372
+ // Mirror lib/queue.js's HUMAN render for the local --hide-dispatched text path
373
+ // (the --json path re-emits queue.js's own object, so only this form is
374
+ // reconstructed). Kept byte-identical to queue.js by a conformance test — if
375
+ // queue.js's line format moves, that test fails and both must move together
376
+ // (norm-cc-byte-identical-refactor). count was already decremented by `hidden`,
377
+ // so the "(N more — raise --limit)" overflow math is unaffected by hiding.
378
+ function renderQueueLocalText(q, hidden = 0) {
379
+ const items = (q && q.items) || [];
380
+ if (!items.length) out("queue empty — nothing queueable and live");
381
+ for (const [i, it] of items.entries()) {
382
+ out(`${i + 1}. [${it.score}] ${it.id} — ${it.title} (${it.type}${it.status ? `, ${it.status}` : ""}${it.suggest === "close" ? ", suggest: close" : ""})`);
383
+ out(` ${it.why}`);
384
+ }
385
+ if (q.count > items.length) out(`(${q.count - items.length} more — raise --limit)`);
386
+ if (q.muted > 0) out(`(${q.muted} muted — your queue_mute)`);
387
+ if (hidden > 0) out(`(${hidden} in-flight hidden — --hide-dispatched)`);
388
+ }
389
+
390
+ // Active background agents keyed by node id (task-spor-cli-in-flight-surface).
391
+ // `spor dispatch` names each background agent after the node id it works
392
+ // (cmdDispatch: name = name || nodeId), so `claude agents --json` lets the queue
393
+ // CLI mark which items already have an agent in flight — a NO-LLM, parseable
394
+ // cross-reference that needs no model guidance. Returns Map<node-id, agent[]> of
395
+ // the BACKGROUND agents still active (state !== "done"), each summarized to
396
+ // {id, name, state, status, cwd}. FAIL-SOFT by contract (the feature is a pure
397
+ // enhancement): the claude binary absent / a nonzero exit / a timeout /
398
+ // unparseable output all yield an EMPTY map, never an error — so `spor next
399
+ // --json` still works in Cowork and plain-shell contexts where claude is absent
400
+ // (every item then reads in_flight:false). SPOR_FAKE_AGENTS_JSON injects canned
401
+ // output for tests, mirroring SPOR_FAKE_MCP_LIST; all claude shell-outs route
402
+ // through claudeCmd() so an SPOR_CLAUDE_CMD stub works too.
403
+ function dispatchedAgents() {
404
+ try {
405
+ let text = process.env.SPOR_FAKE_AGENTS_JSON;
406
+ if (text == null) {
407
+ const cmd = claudeCmd();
408
+ if (cmd === "claude" && !hasCmd("claude")) return new Map();
409
+ const r = spawnSync(cmd, ["agents", "--json"], { encoding: "utf8", timeout: 5000 });
410
+ if (r.status !== 0 || !r.stdout) return new Map();
411
+ text = r.stdout;
412
+ }
413
+ const arr = JSON.parse(text);
414
+ if (!Array.isArray(arr)) return new Map();
415
+ const map = new Map();
416
+ for (const a of arr) {
417
+ if (!a || a.kind !== "background" || typeof a.name !== "string") continue;
418
+ if (a.state === "done") continue; // finished — not in flight
419
+ const list = map.get(a.name) || [];
420
+ list.push({ id: a.id, name: a.name, state: a.state, status: a.status, cwd: a.cwd });
421
+ map.set(a.name, list);
422
+ }
423
+ return map;
424
+ } catch {
425
+ return new Map();
426
+ }
427
+ }
428
+
429
+ // Stamp items[].in_flight from the dispatched-agent map, optionally dropping the
430
+ // in-flight ones (--hide-dispatched). Every kept item gets an in_flight boolean
431
+ // (so the flag is present on all of them — claude absent => uniformly false);
432
+ // an in-flight item also carries a `dispatched` array of agent summaries.
433
+ // Returns the kept items and the count of hidden ones.
434
+ function annotateInFlight(items, agentMap, hide) {
435
+ const kept = [];
436
+ let hidden = 0;
437
+ for (const it of items || []) {
438
+ const agents = (it && it.id && agentMap.get(it.id)) || null;
439
+ const inFlight = !!(agents && agents.length);
440
+ if (inFlight && hide) {
441
+ hidden++;
442
+ continue;
443
+ }
444
+ if (it && typeof it === "object") {
445
+ it.in_flight = inFlight;
446
+ if (inFlight) it.dispatched = agents;
447
+ }
448
+ kept.push(it);
449
+ }
450
+ return { items: kept, hidden };
280
451
  }
281
452
 
282
- function renderQueue(q) {
453
+ function renderQueue(q, hidden = 0) {
283
454
  const items = (q && q.items) || [];
284
455
  if (!items.length) {
285
456
  out("queue empty — nothing queueable and live");
286
- return;
287
- }
288
- for (const it of items) {
289
- out(`${(it.score ?? 0).toFixed ? it.score.toFixed(2) : it.score} ${it.suggest || "do"} ${it.id}`);
290
- if (it.why) out(` ${it.why}`);
457
+ } else {
458
+ for (const it of items) {
459
+ out(`${(it.score ?? 0).toFixed ? it.score.toFixed(2) : it.score} ${it.suggest || "do"} ${it.id}`);
460
+ if (it.why) out(` ${it.why}`);
461
+ }
291
462
  }
463
+ // Never-silent truncation (task-spor-cli-in-flight-surface): report what
464
+ // --hide-dispatched removed, the way queue.js surfaces the muted count.
465
+ if (hidden > 0) out(`(${hidden} in-flight hidden — --hide-dispatched)`);
292
466
  }
293
467
 
294
468
  async function cmdGet(cfg, { positionals }) {
@@ -830,12 +1004,27 @@ function safeSlug() {
830
1004
  }
831
1005
 
832
1006
  // The git toplevel of the cwd, or cwd itself — where repo-scoped files live.
1007
+ // Worktree-local on purpose: cmdScope/cmdLink/targetPath write committable
1008
+ // files (.spor.json, .spor, repo-scoped hook config) into the user's CURRENT
1009
+ // checkout, so a linked worktree keeps its own dir, not the main one.
833
1010
  function repoRoot() {
834
1011
  const r = spawnSync("git", ["rev-parse", "--show-toplevel"], { encoding: "utf8" });
835
1012
  const top = (r.stdout || "").trim();
836
1013
  return top || process.cwd();
837
1014
  }
838
1015
 
1016
+ // The DURABLE repo root for dispatch: like repoRoot(), but inside a linked git
1017
+ // worktree it resolves to the MAIN checkout (dirname --git-common-dir), not the
1018
+ // ephemeral worktree dir. dispatch persists this dir into the machine-local
1019
+ // dispatch.repos slug->path map, so stamping a worktree path would leave a dead
1020
+ // mapping the instant the worktree is removed
1021
+ // (issue-spor-dispatch-worktree-dir-stamping). This is the same inferenceRoot()
1022
+ // session-start already registers with, so the slug (safeSlug -> projectSlug)
1023
+ // and the path stay consistent. Byte-identical to repoRoot() outside a worktree.
1024
+ function dispatchRoot() {
1025
+ return u.inferenceRoot(process.cwd()) || repoRoot();
1026
+ }
1027
+
839
1028
  // A git invocation inside a given working tree. Captures output so callers can
840
1029
  // branch on status/stderr; never throws (a missing git binary surfaces as
841
1030
  // r.error, handled by hasGit() before we get here).
@@ -1584,8 +1773,45 @@ async function topQueueItem(cfg, slug) {
1584
1773
  }
1585
1774
  }
1586
1775
 
1776
+ // Auto-claim a dispatched node so its lease is established at dispatch time
1777
+ // (task-spor-dispatch-auto-claim), reusing the same claim/renew lease the
1778
+ // post-tool heartbeat drives (dec-cc-task-claim-lease, task-cc-claim-nudge-hook).
1779
+ // REMOTE-MODE ONLY: a claim is a server-held lease; local mode has no pool or
1780
+ // contention (dec-cc-task-claim-lease "Local mode"), so the caller skips it and
1781
+ // local dispatch stays byte-identical. No session is bound on purpose — the
1782
+ // dispatching CLI session ends immediately, while the launched bg agent (a
1783
+ // DIFFERENT session) keeps the lease alive: its post-tool hook renews any
1784
+ // in_progress lease the person holds (assignee=me). Leaving the lease
1785
+ // person-scoped (session:null) is what lets that cross-session heartbeat work;
1786
+ // binding it to this short-lived session would orphan the heartbeat. Returns
1787
+ // {ok} on success/idempotent-renew, {conflict, message} when the node is already
1788
+ // held (the concurrent-dispatch case this guards), or {error} for any other
1789
+ // failure (fail-open: the caller warns and dispatches anyway).
1790
+ async function claimDispatch(cfg, nodeId) {
1791
+ const r = await remote.post(cfg, `/v1/nodes/${encodeURIComponent(nodeId)}/claim`, {}, { timeoutMs: 6000 });
1792
+ if (r.ok) return { ok: true, lease: r.json && r.json.lease };
1793
+ // 409 = the node can't be claimed right now — almost always a live lease held
1794
+ // by ANOTHER person (the concurrent-dispatch conflict this feature exists to
1795
+ // catch), occasionally a closed/terminal node. Either way don't launch a
1796
+ // duplicate: surface the server's message (it names holder + expiry for the
1797
+ // lease case) and let the caller abort.
1798
+ if (r.status === 409) {
1799
+ const e = (r.json && r.json.error) || {};
1800
+ return { ok: false, conflict: true, code: e.code || "conflict", message: e.message || "already claimed" };
1801
+ }
1802
+ // Anything else (transport down, 5xx, auth, a non-claimable node type) means
1803
+ // we couldn't establish the lease. Fail-open like the rest of the remote path
1804
+ // (dec-cc-fail-open-hooks / the fail-soft briefing compile): warn and dispatch
1805
+ // without a claim rather than blocking on an outage.
1806
+ const code = r.json && r.json.error && r.json.error.code;
1807
+ return { ok: false, error: r.transport ? r.error : `HTTP ${r.status}${code ? ` (${code})` : ""}` };
1808
+ }
1809
+
1587
1810
  // Resolve the directory to launch in. --dir wins; else a known slug is looked up
1588
- // in the map; else the cwd's repo root. { dir:null } means "slug unknown here".
1811
+ // in the map; else the cwd's durable repo root. { dir:null } means "slug unknown
1812
+ // here". The cwd fallback uses dispatchRoot() (not repoRoot()) so a dispatch run
1813
+ // from inside a linked worktree registers the main checkout, never the ephemeral
1814
+ // worktree path (issue-spor-dispatch-worktree-dir-stamping).
1589
1815
  function resolveDir(cfg, { dir, slug }) {
1590
1816
  if (dir) {
1591
1817
  const abs = path.resolve(dir);
@@ -1595,7 +1821,7 @@ function resolveDir(cfg, { dir, slug }) {
1595
1821
  const p = (cfg.get("dispatch.repos", {}) || {})[slug];
1596
1822
  return p ? { dir: p, slug, source: "config" } : { dir: null, slug, source: "unknown" };
1597
1823
  }
1598
- return { dir: repoRoot(), slug: safeSlug(), source: "cwd" };
1824
+ return { dir: dispatchRoot(), slug: safeSlug(), source: "cwd" };
1599
1825
  }
1600
1826
 
1601
1827
  // Quote an argv element for the --print display only (never used to spawn).
@@ -1665,6 +1891,8 @@ async function cmdDispatch(cfg, { values, positionals: pos }) {
1665
1891
  const dryRun = !!(values.print || values["dry-run"]);
1666
1892
  const full = !!values.full;
1667
1893
  const noBrief = !!values["no-brief"];
1894
+ const noClaim = !!values["no-claim"];
1895
+ const force = !!values.force;
1668
1896
  const backfill = !!values.backfill;
1669
1897
  const fromQueue = !!values["from-queue"];
1670
1898
  const dirOpt = values.dir || null;
@@ -1781,6 +2009,17 @@ async function cmdDispatch(cfg, { values, positionals: pos }) {
1781
2009
  prompt = r.text;
1782
2010
  }
1783
2011
 
2012
+ // Same-machine duplicate-dispatch guard (task-spor-dispatch-same-machine-guard).
2013
+ // `spor dispatch` names each background agent after its node id, so an active
2014
+ // agent with this name means this person already has this node in flight on THIS
2015
+ // machine — a duplicate the auto-claim can't catch (a same-person re-claim is an
2016
+ // idempotent renew by design, dec-cc-task-claim-lease). dispatchedAgents() is the
2017
+ // same NO-LLM, fail-soft cross-reference `spor next --hide-dispatched` uses; node
2018
+ // mode only (mirrors the auto-claim's scope), in BOTH local and remote (it's a
2019
+ // local agent read, independent of the graph backend). claude absent / a stale
2020
+ // exit / unparseable output => empty => no guard (fail-open); --force overrides.
2021
+ const inFlight = nodeId && !backfill ? dispatchedAgents().get(name) || [] : [];
2022
+
1784
2023
  const claudeBin = claudeCmd();
1785
2024
  const claudeArgs = ["--bg"];
1786
2025
  if (name) claudeArgs.push("--name", name);
@@ -1799,12 +2038,36 @@ async function cmdDispatch(cfg, { values, positionals: pos }) {
1799
2038
  out(`onboard: ${steps.join("; ")}`);
1800
2039
  }
1801
2040
  out(`brief: ${brief ? `${brief.length} bytes` : "(none — graph had nothing relevant, or --no-brief/--backfill)"}`);
2041
+ // Same-machine guard preview (node mode, any mode): a real dispatch would
2042
+ // refuse if an agent with this name is already in flight here. Shown only on a
2043
+ // hit, so a clean node --print stays byte-identical to before.
2044
+ if (inFlight.length) {
2045
+ out(
2046
+ `in-flight: ${name} already has ${inFlight.length} agent(s) in flight here` +
2047
+ (force ? " — --force set, dispatching anyway" : " — real dispatch would refuse (--force overrides)")
2048
+ );
2049
+ }
2050
+ // Auto-claim preview (remote node dispatch only — local mode has no lease, so
2051
+ // nothing is announced there and local --print stays byte-identical).
2052
+ if (nodeId && !backfill && cfg.mode() === "remote") {
2053
+ out(`claim: ${noClaim ? "(--no-claim — lease not established)" : `would establish a lease on ${nodeId} at launch`}`);
2054
+ }
1802
2055
  if (template != null) out(`template: ${path.resolve(templateOpt)}`);
1803
2056
  out(`run: ${claudeBin} ${claudeArgs.slice(0, -1).map(shellQuote).join(" ")} <prompt>`);
1804
2057
  out(`\n--- prompt ---\n${prompt}`);
1805
2058
  return 0;
1806
2059
  }
1807
2060
 
2061
+ // Refuse a same-machine duplicate BEFORE any side effect or claim
2062
+ // (task-spor-dispatch-same-machine-guard): no repo registration, no lease, no
2063
+ // launch for a node already in flight here. --force overrides.
2064
+ if (inFlight.length && !force) {
2065
+ err(`${name} already has a background agent in flight on this machine — not dispatching a duplicate.`);
2066
+ err(` in flight: ${inFlight.map((a) => `${a.id || "?"}${a.state ? ` (${a.state})` : ""}`).join(", ")}`);
2067
+ err(` re-run with --force to dispatch anyway, or 'spor next --json' to review what's already running.`);
2068
+ return 1;
2069
+ }
2070
+
1808
2071
  // Side effects (real run only — --print writes nothing). --backfill is the
1809
2072
  // onboarding door, so it sets the repo up (init + enable) first; every
1810
2073
  // dispatch self-registers the dir it resolved.
@@ -1819,6 +2082,23 @@ async function cmdDispatch(cfg, { values, positionals: pos }) {
1819
2082
  err("claude CLI not on PATH — install Claude Code, then re-run (or 'spor dispatch … --print' to see the prompt).");
1820
2083
  return 1;
1821
2084
  }
2085
+ // Establish the claim/lease BEFORE launching (task-spor-dispatch-auto-claim):
2086
+ // a node already claimed by someone else is caught here, so we never launch a
2087
+ // duplicate agent onto contested work, and the lease is live the moment the
2088
+ // agent starts (its post-tool writes then renew it — and seeing its own held
2089
+ // claim, it skips the redundant claim-nudge). Remote node-mode only; --no-claim
2090
+ // opts out (dispatch with no lease, the prior behavior).
2091
+ if (nodeId && !backfill && !noClaim && cfg.mode() === "remote") {
2092
+ const c = await claimDispatch(cfg, nodeId);
2093
+ if (c.conflict) {
2094
+ err(`${nodeId} is already claimed — ${c.message}`);
2095
+ err(` not dispatching a duplicate. Re-run with --no-claim to dispatch anyway (no lease),`);
2096
+ err(` or pick another task with 'spor next'.`);
2097
+ return 1;
2098
+ }
2099
+ if (c.ok) out(`claimed ${nodeId} (lease established; the agent's writes will renew it)`);
2100
+ else err(`warning: could not establish a lease on ${nodeId}: ${c.error} — dispatching without a claim`);
2101
+ }
1822
2102
  const r = spawnSync(claudeBin, claudeArgs, { cwd: res.dir, stdio: "inherit" });
1823
2103
  if (r.error) {
1824
2104
  err(`could not launch ${claudeBin}: ${r.error.message}`);
@@ -2021,14 +2301,18 @@ const COMMANDS = {
2021
2301
  run: (cfg, p) => cmdAdd(cfg, p),
2022
2302
  },
2023
2303
  next: {
2024
- group: "Graph", parse: "raw", args: "[--project S]", aliases: ["queue"],
2304
+ group: "Graph", parse: "raw", args: "[--project S | --all-projects] [--type T] [--exclude-type T]", aliases: ["queue"],
2025
2305
  summary: "the decision queue (local: lib/queue; remote: /v1/queue)",
2026
- help: "Show the ranked decision queue. Remote mode reads /v1/queue; local mode is a\nbyte-identical passthrough to lib/queue.js, so it also accepts that script's\nflags (--days, --no-front, --limit, --name-only, --nodes).\n\n--project accepts a repo slug (-> its home-project grouping union), a repo-<slug>\nnode id (-> that single repo), or a grouping id (-> the grouping union); an\nunknown token warns and yields an empty queue. Pin a default scope for both modes\nwith the queue.project config key (SPOR_QUEUE_PROJECT or .spor.json\n{\"queue\":{\"project\":\"...\"}}); an explicit --project still wins.",
2306
+ help: "Show the ranked decision queue. Remote mode reads /v1/queue; local mode is a\nbyte-identical passthrough to lib/queue.js, so it also accepts that script's\nflags (--days, --no-front, --limit, --name-only, --nodes).\n\nSCOPE. --project accepts a repo slug (-> its home-project grouping union), a\nrepo-<slug> node id (-> that single repo), or a grouping id (-> the grouping\nunion); an unknown token warns and yields an empty queue. Pin a default scope\nfor both modes with the queue.project config key (SPOR_QUEUE_PROJECT or\n.spor.json {\"queue\":{\"project\":\"...\"}}); an explicit --project still wins.\n--all-projects (alias --all) widens to the whole-graph cross-project firehose,\ndropping the cwd/pinned default scope (an explicit --project still wins over it).\n\nNODE TYPES. --type/--exclude-type whitelist/blacklist node types from the\nranking; both are repeatable and comma-splittable (--type task,issue). Given\nboth, the include set is narrowed and then the excludes are removed (exclude\nwins on overlap). They compose with --project/--all-projects.\n\nIN-FLIGHT. --json stamps each item with an `in_flight` flag (and a `dispatched`\nagent summary when true) by cross-referencing live background agents from\n`claude agents --json` — `spor dispatch` names each agent after its node id, so\nan active agent on a queued item is detectable without model guidance.\n--hide-dispatched drops the items that already have an agent in flight. Both are\nclient-side (the server can't see local agents) and fail soft when the claude\nbinary is absent (every item then reads in_flight:false).",
2027
2307
  options: {
2028
2308
  project: { type: "string", value: "S", desc: "scope to a project slug (default: queue.project config, else inferred)" },
2029
- json: { type: "boolean", desc: "machine-readable JSON output" },
2309
+ "all-projects": { type: "boolean", desc: "cross-project firehose — drop the default project scope (alias --all)" },
2310
+ type: { type: "string", value: "T", desc: "include only these node types (repeatable, comma-ok)" },
2311
+ "exclude-type": { type: "string", value: "T", desc: "exclude these node types from the ranking (repeatable, comma-ok)" },
2312
+ json: { type: "boolean", desc: "machine-readable JSON output (adds the in_flight flag per item)" },
2313
+ "hide-dispatched": { type: "boolean", desc: "drop items that already have a background agent in flight" },
2030
2314
  },
2031
- examples: ["spor next", "spor next --project spor --json"],
2315
+ examples: ["spor next", "spor next --json", "spor next --json --hide-dispatched", "spor next --all-projects --type task,issue", "spor next --exclude-type capture-pending"],
2032
2316
  run: (cfg, args) => cmdNext(cfg, args),
2033
2317
  },
2034
2318
  get: {
@@ -2153,6 +2437,12 @@ const COMMANDS = {
2153
2437
  "right repo. Give free-text, a <node-id>, --node <id>, --from-queue (the top\n" +
2154
2438
  "ranked item), or --backfill (onboard/repair a repo). The target dir is the\n" +
2155
2439
  "slug->path map ('spor repos'), overridable with --dir.\n\n" +
2440
+ "In remote mode a node dispatch auto-claims the task — it establishes the\n" +
2441
+ "heartbeat lease at dispatch time, so concurrent dispatch of the same node is\n" +
2442
+ "refused (the holder is named). --no-claim opts out (dispatch with no lease).\n\n" +
2443
+ "A node dispatch is also refused if an agent for that node is already in flight\n" +
2444
+ "on THIS machine (each agent is named after its node id) — catches the\n" +
2445
+ "same-person duplicate the lease's idempotent renew can't. --force overrides.\n\n" +
2156
2446
  "--template supplies your own prompt with {{brief}}/{{task}}/{{node}}/{{title}}/\n" +
2157
2447
  "{{slug}}/{{dir}}/{{default}} placeholders.",
2158
2448
  options: {
@@ -2166,6 +2456,8 @@ const COMMANDS = {
2166
2456
  template: { type: "string", value: "F", desc: "prompt template file (placeholders above)" },
2167
2457
  full: { type: "boolean", desc: "full briefing instead of the digest" },
2168
2458
  "no-brief": { type: "boolean", desc: "raw task prompt, no briefing block" },
2459
+ "no-claim": { type: "boolean", desc: "don't auto-claim the lease (remote node dispatch)" },
2460
+ force: { type: "boolean", desc: "dispatch even if an agent for this node is already in flight here" },
2169
2461
  "from-queue": { type: "boolean", desc: "dispatch the top-ranked queue item" },
2170
2462
  backfill: { type: "boolean", desc: "onboard/repair this repo (runs /spor:backfill)" },
2171
2463
  print: { type: "boolean", desc: "dry run — print the prompt, launch nothing" },
@@ -421,8 +421,13 @@ function attachedSignals(reg, node, baseSignals, neighbors, sandboxFor) {
421
421
  }
422
422
  }
423
423
 
424
- // rankQueue(graph, { project?, assignee?, activity?, front?, leases?, now?, limit?, viewer?, sandboxFor? })
424
+ // rankQueue(graph, { project?, assignee?, includeTypes?, excludeTypes?, activity?, front?, leases?, now?, limit?, viewer?, sandboxFor? })
425
425
  // -> { items, count, muted?, dormant?, archived?, leased?, reserved? }
426
+ // `includeTypes`/`excludeTypes` (task-cc-queue-filtering-enhancements) are
427
+ // node-type allow/deny lists applied as a HARD scope filter before scoring,
428
+ // like `project`/`assignee`: includeTypes keeps only those types, excludeTypes
429
+ // drops them (exclude wins on overlap), and a filtered-out node is out of scope,
430
+ // not counted. Empty/absent => no filter, byte-identical to before.
426
431
  // `assignee` is a person node id (task-cc-queue-assignee-filtering): when set,
427
432
  // the queue is scoped to the work that person carries — nodes with an outbound
428
433
  // `assigned` edge to them, plus the nodes they `steward`. It answers a manager's
@@ -503,6 +508,22 @@ function rankQueue(graph, opts = {}) {
503
508
  const assigneeSet = assigneeScope(graph, assignee);
504
509
  const inAssignee = (n) => !assigneeSet || assigneeSet.has(n.id);
505
510
 
511
+ // Node-type include/exclude (task-cc-queue-filtering-enhancements): whitelist
512
+ // or blacklist node types FROM THE RANKING. `includeTypes` keeps only those
513
+ // types; `excludeTypes` drops them; given both, the include set is narrowed
514
+ // and then the excludes are removed from it (exclude wins on overlap). Like
515
+ // `project`/`assignee` this is a HARD scope filter applied before scoring, so
516
+ // a filtered-out node is simply out of scope — NOT counted like
517
+ // mute/dormant/archived (the count describes the filtered queue, not what the
518
+ // firehose hid). Empty/absent arrays => no filter, so the queue is
519
+ // byte-identical to before these parameters existed
520
+ // (norm-cc-byte-identical-refactor). The type compared is the type the item
521
+ // surfaces AS: `schema` for a proposed-schema queue item, `node.type`
522
+ // otherwise — so excluding `schema` hides schema-approval items too.
523
+ const typeIncl = Array.isArray(opts.includeTypes) && opts.includeTypes.length ? new Set(opts.includeTypes) : null;
524
+ const typeExcl = Array.isArray(opts.excludeTypes) && opts.excludeTypes.length ? new Set(opts.excludeTypes) : null;
525
+ const inTypes = (t) => (!typeIncl || typeIncl.has(t)) && (!typeExcl || !typeExcl.has(t));
526
+
506
527
  // Task claim-lease (dec-cc-task-claim-lease / dec-cc-task-resumption-
507
528
  // reservation): an in-force `leases` entry held by ANOTHER viewer is
508
529
  // owner-exclusive — hidden from this viewer's actionable list, COUNTED. A
@@ -539,6 +560,7 @@ function rankQueue(graph, opts = {}) {
539
560
  if (node.type === "schema" && (node.status || "") === "proposed") {
540
561
  if (!inProject(node)) continue;
541
562
  if (!inAssignee(node)) continue;
563
+ if (!inTypes("schema")) continue; // hard type scope, uncounted (like project)
542
564
  if (isArchived(node)) { archivedCount++; continue; }
543
565
  if (isMuted(node)) { muted++; continue; }
544
566
  const age = ageDays(node, now);
@@ -559,6 +581,7 @@ function rankQueue(graph, opts = {}) {
559
581
  if (!reg.isQueueable(node.type)) continue;
560
582
  if (!inProject(node)) continue;
561
583
  if (!inAssignee(node)) continue;
584
+ if (!inTypes(node.type)) continue; // hard type scope, uncounted (like project)
562
585
  if (isArchived(node)) { archivedCount++; continue; } // counted, not silent
563
586
  if (!isLive(node, graph.supersededBy)) continue;
564
587
  if (resolvedBy[node.id]) continue; // retired by a live resolves/answers edge
package/lib/queue.js CHANGED
@@ -6,7 +6,9 @@
6
6
  //
7
7
  // Also a CLI (local mode / debugging):
8
8
  // node lib/queue.js [--nodes <dir>] [--project <slug>] [--assignee <person-id>]
9
- // [--limit <n>] [--days <n>] [--no-front] [--json]
9
+ // [--type <t>] [--exclude-type <t>] [--limit <n>]
10
+ // [--days <n>] [--no-front] [--json]
11
+ // --type / --exclude-type are repeatable and comma-splittable (--type task,issue).
10
12
 
11
13
  const kernel = require("./kernel/queue.js");
12
14
 
@@ -112,6 +114,18 @@ if (require.main === module) {
112
114
  const i = argv.indexOf(`--${n}`);
113
115
  return i >= 0 && argv[i + 1] != null ? argv[i + 1] : d;
114
116
  };
117
+ // Repeatable + comma-splittable flag (task-cc-queue-filtering-enhancements):
118
+ // `--type task --type issue` and `--type task,issue` both yield ["task","issue"].
119
+ // null when the flag is absent so the kernel filter stays inert (byte-identical).
120
+ const multi = (n) => {
121
+ const out = [];
122
+ for (let i = 0; i < argv.length; i++) {
123
+ if (argv[i] === `--${n}` && argv[i + 1] != null) {
124
+ out.push(...argv[i + 1].split(",").map((s) => s.trim()).filter(Boolean));
125
+ }
126
+ }
127
+ return out.length ? out : null;
128
+ };
115
129
  const home = require(path.join(__dirname, "shell", "home.js"));
116
130
  // Client config cascade (dec-spor-client-config-cascade); nodesDir() honors
117
131
  // config.nodes / SPOR_NODES then the graph-home default — byte-identical when
@@ -147,6 +161,8 @@ if (require.main === module) {
147
161
  const r = rankQueue(g, {
148
162
  project,
149
163
  assignee: opt("assignee", null),
164
+ includeTypes: multi("type"),
165
+ excludeTypes: multi("exclude-type"),
150
166
  limit: parseInt(opt("limit", String(kernel.DEFAULT_LIMIT)), 10),
151
167
  front,
152
168
  frontDays: days,
@@ -0,0 +1,35 @@
1
+ ---
2
+ id: schema-edge-changes-requested-by
3
+ type: schema
4
+ kind: edge-schema
5
+ schema_version: 2026.06.16.1
6
+ title: Seed schema for changes-requested-by edges
7
+ summary: Edge schema for the changes-requested-by type — this person reviewed the node and requested changes; the blocking outcome of the review-as-graph-object lifecycle. Does NOT count as an approval; the policy layer can surface it as an outstanding change request. Seed-pack default; a graph-resident schema node overrides it.
8
+ date: 2026-06-16
9
+ ---
10
+
11
+ Seed schema for the `changes-requested-by` edge type (work node → person),
12
+ shipped with the plugin as a registry default (QUEUE.md §2). It is the
13
+ **changes-requested** outcome of the native review lifecycle
14
+ (review-as-graph-object, dec-spor-definition-of-done-org-policy): a
15
+ `review-requested` edge to a reviewer flips in place to `changes-requested-by`
16
+ when that reviewer asks for changes; addressing them and asking again flips it
17
+ back to `review-requested`.
18
+
19
+ It is explicitly **not** an approval: it never enters `view.approvals` and so
20
+ never counts toward the definition-of-done quorum. The write path additionally
21
+ hands these edges to the policy gate as `view.changes_requested` (reviewer +
22
+ `roles`, author excluded), so a policy may require that no change request from
23
+ a qualified role is outstanding before a work node may go `done` — the
24
+ blocking complement to the quorum of `reviewed-by` approvals.
25
+
26
+ Weight is mid (0.5, the `relates-to`/`assigned` tier): a recorded
27
+ changes-requested verdict is a real outcome, not a structural dependency.
28
+
29
+ ```json
30
+ {
31
+ "edge_type": "changes-requested-by",
32
+ "description": "this person reviewed the node and requested changes (does not count as an approval)",
33
+ "weight": 0.5
34
+ }
35
+ ```
@@ -0,0 +1,39 @@
1
+ ---
2
+ id: schema-edge-review-requested
3
+ type: schema
4
+ kind: edge-schema
5
+ schema_version: 2026.06.16.1
6
+ title: Seed schema for review-requested edges
7
+ summary: Edge schema for the review-requested type — a review of this node is requested OF a person; the open state of the review-as-graph-object lifecycle, surfaced into the reviewer's queue. Low traversal weight (routing wiring, not knowledge lineage). Seed-pack default; a graph-resident schema node overrides it.
8
+ date: 2026-06-16
9
+ ---
10
+
11
+ Seed schema for the `review-requested` edge type (work node → person),
12
+ shipped with the plugin as a registry default (QUEUE.md §2). It is the
13
+ *open* state of the native review lifecycle (review-as-graph-object,
14
+ dec-spor-definition-of-done-org-policy): a resolving artifact (a change/PR)
15
+ or any work node points a `review-requested` edge at each `person` a review
16
+ is wanted from. The server surfaces those nodes into each named reviewer's
17
+ personal queue (`my_queue` `reviews`), the same per-person filter questions
18
+ and findings route through.
19
+
20
+ An outcome **flips the edge in place**: an approval rewrites it to
21
+ `reviewed-by`, a request for changes to `changes-requested-by`; a re-review
22
+ flips it back to `review-requested`. The edge type *is* the state, so a node
23
+ carries a `review-requested` edge only while that reviewer's verdict is
24
+ still pending. Reviewer *selection* (who is asked) is a distinct point from
25
+ gate-time *enforcement* (is the bar met, counted from `reviewed-by`): for the
26
+ GitHub-reflected path selection comes from CODEOWNERS via the adapter, not a
27
+ Spor router.
28
+
29
+ Weight is low (0.3, like `routed-to`): a pending review request is routing
30
+ wiring, not knowledge lineage, so it should not pull the requested node into
31
+ unrelated briefings.
32
+
33
+ ```json
34
+ {
35
+ "edge_type": "review-requested",
36
+ "description": "a review of this node is requested of this person (pending)",
37
+ "weight": 0.3
38
+ }
39
+ ```
@@ -0,0 +1,35 @@
1
+ ---
2
+ id: schema-edge-reviewed-by
3
+ type: schema
4
+ kind: edge-schema
5
+ schema_version: 2026.06.16.1
6
+ title: Seed schema for reviewed-by edges
7
+ summary: Edge schema for the reviewed-by type — this person reviewed and approved the node; the approving outcome of the review-as-graph-object lifecycle that the org-defined policy layer's quorum gate counts. Seed-pack default; a graph-resident schema node overrides it.
8
+ date: 2026-06-16
9
+ ---
10
+
11
+ Seed schema for the `reviewed-by` edge type (work node → person), shipped
12
+ with the plugin as a registry default (QUEUE.md §2). It is the **approving**
13
+ outcome of the native review lifecycle (review-as-graph-object,
14
+ dec-spor-definition-of-done-org-policy): a `review-requested` edge to a
15
+ reviewer flips in place to `reviewed-by` when that reviewer approves.
16
+
17
+ This is the edge the org-defined policy layer's **definition-of-done quorum
18
+ gate** counts (GRAPH.md "The org-defined policy layer"). On the write path
19
+ the server joins each `reviewed-by` (and the synonym `approved-by`) edge to
20
+ the reviewer's `roles` register and hands them to the gate as
21
+ `view.approvals`, with the node's own author excluded — so a policy may
22
+ require a quorum of approvals from a named role before a work node reaches a
23
+ resolving/`done` state, and self-approval can never launder past the native
24
+ floor.
25
+
26
+ Weight is mid (0.5, the `relates-to`/`assigned` tier): an approval is a real
27
+ outcome worth pulling through a briefing, but not a structural dependency.
28
+
29
+ ```json
30
+ {
31
+ "edge_type": "reviewed-by",
32
+ "description": "this person reviewed and approved the node (counts toward a policy quorum)",
33
+ "weight": 0.5
34
+ }
35
+ ```
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@sporhq/spor",
3
- "version": "0.5.2",
3
+ "version": "0.6.0",
4
4
  "description": "Spor — a shared memory substrate for teams and agents. Decisions, their reasons, and the traces they leave. Knowledge-graph context compiler: session-start briefings, per-prompt digests, capture at discovery, end-of-session distillation, decision queue.",
5
5
  "license": "Apache-2.0",
6
6
  "author": "Anthony Allen",
@@ -45,6 +45,26 @@ curl -sS --max-time 6 -H "Authorization: Bearer $SPOR_TOKEN" \
45
45
 
46
46
  In Cowork, call the `my_queue` MCP tool with the same fields.
47
47
 
48
+ **Scope and filter flags** (task-cc-queue-filtering-enhancements). The queue
49
+ defaults to the session's project; three optional, composable levers widen or
50
+ narrow it. Reach for them when the user asks for a wider/narrower view ("what's
51
+ next across everything", "show me beta's queue", "just the open issues, ignore
52
+ pending captures"):
53
+
54
+ - **Cross-project firehose** — drop `?project=` entirely (`/v1/queue?limit=10`)
55
+ to rank every repo's queue at once. CLI: `spor next --all-projects`.
56
+ - **A different project** — `?project=<slug>` (repo slug → grouping union;
57
+ `repo-<slug>` id → one repo; `proj-<slug>` id → the grouping). CLI:
58
+ `spor next --project <slug>`.
59
+ - **Node-type allow/deny** — `?type=task,issue` keeps only those types;
60
+ `?exclude_type=capture-pending` drops them (comma-separated, repeatable,
61
+ exclude wins on overlap). They apply *before* ranking, so the aggregate counts
62
+ describe the filtered queue. CLI: `spor next --type task,issue`,
63
+ `spor next --exclude-type capture-pending`.
64
+
65
+ The `my_queue` MCP tool takes the same as fields: `project`, `types`,
66
+ `exclude_types` (arrays), plus `limit`/`offset`.
67
+
48
68
  ## Local mode (personal graph) — `SPOR_SERVER` unset
49
69
 
50
70
  `${CLAUDE_PLUGIN_ROOT}` is empty in the Bash tool, so first resolve the
@@ -56,6 +76,10 @@ SPOR_ROOT="$(cat "${SPOR_HOME:-$HOME/.spor}/cache/plugin-root" 2>/dev/null \
56
76
  || cat "$HOME/.substrate/cache/plugin-root" 2>/dev/null)"
57
77
  SPOR_ROOT="${SPOR_ROOT:-$CLAUDE_PLUGIN_ROOT}"
58
78
  node "$SPOR_ROOT/lib/queue.js" --json # or --project <slug>, --limit <n>
79
+ # Same scope/filter flags as remote: --type task,issue / --exclude-type
80
+ # capture-pending (whitelist/blacklist node types), and omit --project for the
81
+ # cross-project firehose. (`spor next --all-projects` is the CLI shorthand for
82
+ # dropping the default scope.)
59
83
  ```
60
84
 
61
85
  Use `--json` and compose the human view from it — don't show the bare CLI
@@ -65,6 +89,43 @@ to surface (see Presenting).
65
89
  (No server means no activity feed, so heat is 0 locally; the other signals
66
90
  are identical.)
67
91
 
92
+ ## In-flight awareness — don't re-pick work an agent is already on (Claude Code only)
93
+
94
+ `spor dispatch` launches Claude Code background agents named after the node id
95
+ they work (art-res-task-spor-cli-dispatch-background-agents). Before you
96
+ recommend an item, cross-reference live agent state so you don't surface work
97
+ that's already in flight — otherwise whoever triages the queue picks up or
98
+ re-dispatches a task an agent is already doing. This is **presentation-only and
99
+ Claude-Code-only**: the `my_queue` / `GET /v1/queue` server surface cannot see
100
+ local background agents, so the cross-reference happens here, at present time.
101
+ Best-effort — if the `claude` binary is absent or errors (e.g. in Cowork or a
102
+ plain shell), it is skipped silently and the queue presents as usual.
103
+
104
+ **The `spor next` CLI does this cross-reference for you**
105
+ (task-spor-cli-in-flight-surface). `spor next --json` (or
106
+ `node "$SPOR_ROOT/bin/spor.js" next --json`, which carries the flag where the
107
+ bare `lib/queue.js --json` does not) stamps each item with an `in_flight`
108
+ boolean — and a `dispatched` summary (`{id, name, state, status, cwd}`) on the
109
+ in-flight ones — by matching live background agents to node ids. Add
110
+ `--hide-dispatched` to drop the in-flight items entirely (it reports a
111
+ `hidden_dispatched` count, never silently). Prefer reading that flag over
112
+ shelling out yourself.
113
+
114
+ Under the hood (and what to do when you only have the raw `lib/queue.js --json`
115
+ or `GET /v1/queue` output, which don't carry the flag):
116
+
117
+ ```bash
118
+ claude agents --json 2>/dev/null # array of {name, kind, state, cwd, ...}
119
+ ```
120
+
121
+ A queue item is **in flight** when a `kind: "background"` agent has `name`
122
+ equal to the item's id and `state` is not `"done"` (e.g. `working`). For those
123
+ items: badge them "🤖 agent dispatched — in progress" and keep them OUT of the
124
+ top "pick this next" recommendation (the work is already moving; surfacing it
125
+ invites duplication). Mention them so the human can still choose to look —
126
+ counted, never silently dropped, the same discipline the queue uses for mutes
127
+ and leases. Items with no matching live agent are presented normally.
128
+
68
129
  ## Presenting and acting
69
130
 
70
131
  Present in plain language — you are talking to a human who may be new to Spor.