@sporhq/spor 0.5.1 → 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.
- package/.claude-plugin/plugin.json +1 -1
- package/API.md +24 -6
- package/GRAPH.md +20 -3
- package/QUEUE.md +27 -6
- package/README.md +8 -0
- package/bin/spor.js +313 -21
- package/lib/kernel/queue.js +24 -1
- package/lib/query.js +15 -8
- package/lib/queue.js +17 -1
- package/lib/seed/schema-edge-changes-requested-by.md +35 -0
- package/lib/seed/schema-edge-review-requested.md +39 -0
- package/lib/seed/schema-edge-reviewed-by.md +35 -0
- package/package.json +1 -1
- package/skills/next/SKILL.md +61 -0
|
@@ -2,6 +2,6 @@
|
|
|
2
2
|
"name": "spor",
|
|
3
3
|
"displayName": "Spor Context Compiler",
|
|
4
4
|
"description": "Maintains a typed, versioned knowledge graph and compiles compact briefings from it: session-start injection, per-prompt relevance digests, capture at discovery, end-of-session distillation, decision queue.",
|
|
5
|
-
"version": "0.
|
|
5
|
+
"version": "0.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
|
-
"
|
|
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
|
|
184
|
-
`
|
|
185
|
-
|
|
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 <sharer>" 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.
|
|
173
|
-
|
|
174
|
-
|
|
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
|
|
313
|
-
|
|
314
|
-
(
|
|
315
|
-
|
|
316
|
-
|
|
317
|
-
`governs.{types,projects}` scope.
|
|
323
|
+
`queue-policy` singleton (task-cc-schema-queue-policy-override), the GitHub
|
|
324
|
+
review/merge reflection adapter that writes these same edges from PR events
|
|
325
|
+
(task-spor-github-review-adapter, Stage 4), and a fuller governs-traversal that
|
|
326
|
+
walks the project/path hierarchy via `governs`/`governed-by` edges rather than
|
|
327
|
+
matching the flat `governs.{types,projects}` scope.
|
|
318
328
|
|
|
319
329
|
## 3. The capture flow, end to end
|
|
320
330
|
|
|
@@ -442,6 +452,17 @@ The queue is a compile mode, not a new store:
|
|
|
442
452
|
queues — the intuitive token returns the whole product; the repo NODE id
|
|
443
453
|
(`repo-<slug>`) is the escape hatch back to one repo; an exact grouping id
|
|
444
454
|
(`proj-<slug>`) is used directly; an ungrouped repo falls back to itself.
|
|
455
|
+
**Omitting `project` entirely is the cross-project firehose** (every repo's
|
|
456
|
+
queue at once); `/spor:next --all-projects` and `my_queue` / `GET /v1/queue`
|
|
457
|
+
with no `project` reach it.
|
|
458
|
+
- `includeTypes`/`excludeTypes` (task-cc-queue-filtering-enhancements) whitelist
|
|
459
|
+
or blacklist node types FROM THE RANKING — `GET /v1/queue?type=&exclude_type=`
|
|
460
|
+
(comma-separated), `my_queue {types, exclude_types}`, and
|
|
461
|
+
`spor next --type/--exclude-type`. Given both, the include set is narrowed and
|
|
462
|
+
then the excludes are removed from it (exclude wins on overlap). Like `project`
|
|
463
|
+
it is a hard scope applied before scoring (the aggregates describe the filtered
|
|
464
|
+
queue), it composes with `project`/`assignee`, and the type compared is the one
|
|
465
|
+
the item surfaces as (so excluding `schema` hides schema-approval items too).
|
|
445
466
|
- Exposed as `GET /v1/queue` (hooks, session-start "open front" line),
|
|
446
467
|
`my_queue` (the registered MCP stub finally does work — and when Tier 2
|
|
447
468
|
routing lands, routed questions and stewarded items join the same queue),
|
package/README.md
CHANGED
|
@@ -104,6 +104,14 @@ Spor already knows where that repo lives. Flags pass through to `claude`
|
|
|
104
104
|
(`--model`, `--permission-mode`, `--agent`, `--name`); `--full` embeds the whole
|
|
105
105
|
neighborhood and `--no-brief` skips the briefing.
|
|
106
106
|
|
|
107
|
+
**No accidental duplicates.** A node dispatch won't double up on work already
|
|
108
|
+
underway. In remote mode it auto-claims the task's heartbeat lease at launch, so
|
|
109
|
+
dispatching a node a teammate already holds is refused with the holder named
|
|
110
|
+
(`--no-claim` opts out). And in either mode, dispatching a node that already has
|
|
111
|
+
a background agent in flight *on this machine* — each agent is named after its
|
|
112
|
+
node id — is refused too, catching the same-person duplicate the lease's
|
|
113
|
+
idempotent renew can't. `--force` overrides the local guard.
|
|
114
|
+
|
|
107
115
|
**Your own prompt.** By default the briefing is prepended to a built-in prompt
|
|
108
116
|
shell. Pass `--template <file>` (or set a default path in `dispatch.template`)
|
|
109
117
|
to supply your own prompt instead, with Handlebars-style `{{placeholder}}`
|
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
|
-
|
|
248
|
-
|
|
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
|
-
//
|
|
262
|
-
|
|
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 (
|
|
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
|
|
277
|
-
// we never inject safeSlug()
|
|
278
|
-
|
|
279
|
-
|
|
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
|
-
|
|
287
|
-
|
|
288
|
-
|
|
289
|
-
|
|
290
|
-
|
|
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
|
|
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:
|
|
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\
|
|
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
|
-
|
|
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 --
|
|
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" },
|
package/lib/kernel/queue.js
CHANGED
|
@@ -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/query.js
CHANGED
|
@@ -145,14 +145,22 @@ if (require.main === module) {
|
|
|
145
145
|
to: has("to") ? opt("to", null) : null,
|
|
146
146
|
});
|
|
147
147
|
|
|
148
|
+
// Project a loaded node for JSON output: drop the parser's load-time `file`
|
|
149
|
+
// artifact, and drop pin/exclude when empty — the regex parser initializes
|
|
150
|
+
// both to [] on every node, so they are noise unless a briefing/correction
|
|
151
|
+
// actually populated them (a non-empty list is kept). Deletes from a shallow
|
|
152
|
+
// copy so the original key order is preserved.
|
|
153
|
+
const cleanNode = (n) => {
|
|
154
|
+
const out = { ...n };
|
|
155
|
+
delete out.file;
|
|
156
|
+
if (Array.isArray(out.pin) && !out.pin.length) delete out.pin;
|
|
157
|
+
if (Array.isArray(out.exclude) && !out.exclude.length) delete out.exclude;
|
|
158
|
+
return out;
|
|
159
|
+
};
|
|
160
|
+
|
|
148
161
|
if (has("json")) {
|
|
149
162
|
if (r.edges) process.stdout.write(JSON.stringify(r.edges, null, 2) + "\n");
|
|
150
|
-
else
|
|
151
|
-
// Strip the parser's internal `file` field from JSON node output (it's a
|
|
152
|
-
// load-time artifact, not graph data).
|
|
153
|
-
const clean = r.nodes.map(({ file, ...rest }) => rest);
|
|
154
|
-
process.stdout.write(JSON.stringify(clean, null, 2) + "\n");
|
|
155
|
-
}
|
|
163
|
+
else process.stdout.write(JSON.stringify(r.nodes.map(cleanNode), null, 2) + "\n");
|
|
156
164
|
process.exit(0);
|
|
157
165
|
}
|
|
158
166
|
|
|
@@ -183,8 +191,7 @@ if (require.main === module) {
|
|
|
183
191
|
if (raw != null) {
|
|
184
192
|
process.stdout.write(raw.endsWith("\n") ? raw : raw + "\n");
|
|
185
193
|
} else {
|
|
186
|
-
|
|
187
|
-
process.stdout.write(JSON.stringify(rest, null, 2) + "\n");
|
|
194
|
+
process.stdout.write(JSON.stringify(cleanNode(n), null, 2) + "\n"); // raw file unreadable: reconstructed view
|
|
188
195
|
}
|
|
189
196
|
console.log("---");
|
|
190
197
|
}
|
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
|
-
// [--
|
|
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.
|
|
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",
|
package/skills/next/SKILL.md
CHANGED
|
@@ -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.
|