agent-coord-mcp 0.26.13 → 0.26.15

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.
Files changed (39) hide show
  1. package/dist/capabilities.js +158 -0
  2. package/dist/capabilities.js.map +1 -0
  3. package/dist/server-identity.js +82 -0
  4. package/dist/server-identity.js.map +1 -0
  5. package/dist/server.js +4 -2
  6. package/dist/server.js.map +1 -1
  7. package/dist/tools/events.js +64 -7
  8. package/dist/tools/events.js.map +1 -1
  9. package/dist/tools/record-events.js +11 -6
  10. package/dist/tools/record-events.js.map +1 -1
  11. package/dist/tools/records.js +23 -2
  12. package/dist/tools/records.js.map +1 -1
  13. package/dist/tools/transport.js +26 -56
  14. package/dist/tools/transport.js.map +1 -1
  15. package/dist/tools/work.js +37 -1
  16. package/dist/tools/work.js.map +1 -1
  17. package/dist/tools/worktrees.js +46 -1
  18. package/dist/tools/worktrees.js.map +1 -1
  19. package/hooks/peek-coord.mjs +0 -0
  20. package/hooks/tmux-pusher.mjs +0 -0
  21. package/package.json +14 -11
  22. package/scripts/check-test-count.mjs +1 -1
  23. package/scripts/coord-attention-clock.mjs +0 -0
  24. package/scripts/coord-node.sh +0 -0
  25. package/scripts/coord-stall-clock.mjs +0 -0
  26. package/scripts/coord-token.mjs +0 -0
  27. package/scripts/probe-tmux-liveness.sh +0 -0
  28. package/scripts/spawn-agent.sh +0 -0
  29. package/scripts/stop-agent.sh +0 -0
  30. package/scripts/typed-record-stats.mjs +0 -0
  31. package/src/capabilities.ts +188 -0
  32. package/src/server-identity.ts +83 -0
  33. package/src/server.ts +10 -2
  34. package/src/tools/events.ts +89 -9
  35. package/src/tools/record-events.ts +11 -6
  36. package/src/tools/records.ts +23 -1
  37. package/src/tools/transport.ts +26 -57
  38. package/src/tools/work.ts +54 -2
  39. package/src/tools/worktrees.ts +45 -1
@@ -0,0 +1,188 @@
1
+ /*
2
+ * Phase 5.1 Task 11 — CLOSE THE LOOP: answer a capability question by
3
+ * EXERCISING the capability, in the process that would serve it.
4
+ *
5
+ * 11.1: NO TIMESTAMP ON THIS BOX CAN ANSWER A CAPABILITY QUESTION. Every
6
+ * artefact people reach for is a proxy that fails differently:
7
+ *
8
+ * an mtime tracks WRITES. A reinstall of byte-identical code moves
9
+ * it, and an edit that never got built does not.
10
+ * serverBuildMtime is stamped at ATTACH, so it tells you when a transport
11
+ * started, not what the running process contains.
12
+ * a version string is a LABEL someone typed into package.json. Measured
13
+ * today: a tarball published as 0.5.23 contained a change
14
+ * the version numbering said it could not, and the number
15
+ * was believed over the artefact for an hour.
16
+ * `npm view` answers from a cache, and told this fleet the wrong
17
+ * published version twice in one morning.
18
+ *
19
+ * Each is honest about something and dishonest about capability, and the
20
+ * failure is always the same shape: an ANSWER ABOUT A LABEL read as an answer
21
+ * about behaviour.
22
+ *
23
+ * 11.2: A RELEASE IS NOT DELIVERED UNTIL A SERVER THAT RESTARTED ANSWERS.
24
+ * `merged · published · installed · restarted · observed` — five states, and
25
+ * the last two are the ones that keep being skipped. A probe run INSIDE the
26
+ * server process is the only artefact that speaks for the loaded code: it
27
+ * cannot be satisfied by a file that exists, a version that matches, or a
28
+ * package that installed, because it calls the code and reports what happened.
29
+ *
30
+ * WHY A PROBE MAY NEVER READ A VERSION: if a probe branched on a version
31
+ * string it would inherit that string's dishonesty, and a fleet would then
32
+ * have a capability check that passes on a restarted-but-not-upgraded server.
33
+ * Probes call behaviour. The version travels beside the answer as CONTEXT and
34
+ * is labelled as such.
35
+ */
36
+ import { prRefsIn } from "./tools/record-events.js";
37
+ import { EVENT_KIND_IDS } from "./tools/event-kinds.js";
38
+ import { suggestRecordType, typedRecordMode } from "./typed-records.js";
39
+ import { LEAD_REFUSED, PARKED_CATEGORIES } from "./tools/away.js";
40
+ import { recordAuthorityFor } from "./roles.js";
41
+ import { subscriptionHealth } from "./tools/events.js";
42
+
43
+ export type ProbeResult = {
44
+ id: string;
45
+ /** The release the behaviour arrived in — reported, never TESTED against. */
46
+ since: string;
47
+ present: boolean;
48
+ /** What was actually called and what came back. The evidence, not a claim. */
49
+ evidence: string;
50
+ };
51
+
52
+ type Probe = { id: string; since: string; run: () => { present: boolean; evidence: string } };
53
+
54
+ /**
55
+ * Each probe calls a behaviour that did not exist before its release and
56
+ * reports what it observed. A probe that could pass without the code being
57
+ * loaded is not a probe — it is a restatement of the version.
58
+ */
59
+ const PROBES: Probe[] = [
60
+ {
61
+ id: "typed-records-obligatory",
62
+ since: "0.26.10",
63
+ run: () => {
64
+ const s = suggestRecordType("DONE: shipped it", []);
65
+ const mode = typedRecordMode();
66
+ return {
67
+ present: s.type === "done" && (mode === "warn" || mode === "refuse"),
68
+ evidence: `suggestRecordType("DONE: …") -> '${s.type}', policy mode '${mode}'`,
69
+ };
70
+ },
71
+ },
72
+ {
73
+ id: "record-events-all-kinds",
74
+ since: "0.26.12",
75
+ run: () => {
76
+ const kinds = [...EVENT_KIND_IDS].sort();
77
+ const present = ["item", "phase", "pr", "task"].every((k) => kinds.includes(k as never));
78
+ return { present, evidence: `subscribable kinds: ${kinds.join(", ")}` };
79
+ },
80
+ },
81
+ {
82
+ id: "multi-pr-citation",
83
+ since: "0.26.14",
84
+ run: () => {
85
+ const refs = prRefsIn("owner/repo#170, #173");
86
+ return {
87
+ present: refs.length === 2 && refs[1] === "owner/repo#173",
88
+ evidence: `prRefsIn("owner/repo#170, #173") -> [${refs.join(", ")}]`,
89
+ };
90
+ },
91
+ },
92
+ {
93
+ id: "away-means-david-away",
94
+ since: "0.26.13",
95
+ run: () => {
96
+ const present = "merge" in LEAD_REFUSED && PARKED_CATEGORIES.includes("licence" as never);
97
+ return {
98
+ present,
99
+ evidence: `coord_away refuses [${Object.keys(LEAD_REFUSED).join(", ")}] for the lead; parks ${PARKED_CATEGORIES.length} categories`,
100
+ };
101
+ },
102
+ },
103
+ {
104
+ id: "subscription-scanned-vs-evaluated",
105
+ since: "0.26.15",
106
+ run: () => {
107
+ // The capability is that "the machinery ran" and "your kind fired" are
108
+ // separable. Probed by asking for the state that used to be reported as
109
+ // broken: scanned, never evaluated — a healthy idle watch.
110
+ const base = { id: "p", agentId: "p", kind: "item", target: "t", createdAt: 0, lastEvaluatedAt: null, lastEventAt: null, delivered: [] } as never;
111
+ const quiet = subscriptionHealth({ ...(base as object), lastScannedAt: Date.now() } as never);
112
+ const unscanned = subscriptionHealth({ ...(base as object), lastScannedAt: null } as never);
113
+ return {
114
+ present: quiet.level === "ok" && unscanned.level === "error",
115
+ evidence: `scanned+quiet -> '${quiet.level}', never-scanned -> '${unscanned.level}'`,
116
+ };
117
+ },
118
+ },
119
+ {
120
+ id: "record-authority",
121
+ since: "0.24.0",
122
+ run: () => {
123
+ const worker = recordAuthorityFor({ roleId: "worker" });
124
+ return {
125
+ present: worker.mayNotEmit.includes("verdict"),
126
+ evidence: `a worker mayNotEmit: [${worker.mayNotEmit.join(", ")}]`,
127
+ };
128
+ },
129
+ },
130
+ ];
131
+
132
+ export type CapabilityReport = {
133
+ /**
134
+ * WHICH PROCESS ANSWERED. Not evidence of anything — context, so a reader can
135
+ * tell two servers apart when their answers disagree. Deliberately beside the
136
+ * probe results rather than above them: the temptation this whole verb exists
137
+ * to remove is reading the identity INSTEAD of the answers.
138
+ */
139
+ answeredBy: { pid: number; startedAtIso: string; module: string; versionLabel: string };
140
+ probes: ProbeResult[];
141
+ missing: string[];
142
+ ok: boolean;
143
+ note: string;
144
+ };
145
+
146
+ export function probeCapabilities(context: { module: string; versionLabel: string }): CapabilityReport {
147
+ const probes: ProbeResult[] = PROBES.map((p) => {
148
+ try {
149
+ const r = p.run();
150
+ return { id: p.id, since: p.since, present: r.present, evidence: r.evidence };
151
+ } catch (e) {
152
+ // A THROWN PROBE IS AN ABSENT CAPABILITY, NOT A BROKEN CHECK. Older code
153
+ // that lacks the symbol throws exactly here, and reporting that as an
154
+ // error rather than an absence would make the common case look like a
155
+ // malfunction.
156
+ return { id: p.id, since: p.since, present: false, evidence: `probe threw: ${(e as Error).message}` };
157
+ }
158
+ });
159
+ const missing = probes.filter((p) => !p.present).map((p) => p.id);
160
+ return {
161
+ answeredBy: {
162
+ pid: process.pid,
163
+ startedAtIso: new Date(Date.now() - Math.round(process.uptime() * 1000)).toISOString(),
164
+ module: context.module,
165
+ versionLabel: context.versionLabel,
166
+ },
167
+ probes,
168
+ missing,
169
+ ok: missing.length === 0,
170
+ note:
171
+ "Every line above was produced by CALLING the code in this process. `versionLabel` is a label someone typed " +
172
+ "into package.json and is context, never evidence — a published tarball has already been observed carrying a " +
173
+ "change its version said it could not. THIS ANSWER IS ABOUT ONE PROCESS: a release is delivered when every " +
174
+ "live agent's OWN server answers, which is the fifth state (merged · published · installed · restarted · observed). " +
175
+ "A server that has not restarted answers honestly about the old code it is still running.",
176
+ };
177
+ }
178
+
179
+ /* ── the verb ──────────────────────────────────────────────────────────────── */
180
+
181
+ import { resolveServerIdentity } from "./server-identity.js";
182
+
183
+ export const capabilitiesSchema = {} as const;
184
+
185
+ export async function capabilitiesTool() {
186
+ const id = resolveServerIdentity();
187
+ return probeCapabilities({ module: id.path, versionLabel: id.version });
188
+ }
@@ -0,0 +1,83 @@
1
+ /*
2
+ * WHICH SERVER PACKAGE IS RUNNING — a LEAF module, importing nothing from
3
+ * `tools/`.
4
+ *
5
+ * It lived in `transport.ts`, and `capabilities.ts` imported it from there. The
6
+ * moment `status` needs to REPORT capabilities, that becomes
7
+ * transport -> capabilities -> transport: a cycle, and the same one Task 9
8
+ * produced when the kind registry sat inside `record-events.ts`. That one only
9
+ * worked because `tools/index.ts` happened to import in the surviving order —
10
+ * a load-ORDER dependency, invisible until something imported a module
11
+ * directly, which is what a consumer or a test does.
12
+ *
13
+ * A cycle that works by luck is not a working cycle, so the shared fact moves
14
+ * out rather than the edge being added on top of it.
15
+ *
16
+ * NOTE WHAT THIS REPORTS AND WHAT IT DOES NOT. `version` is the label in the
17
+ * package.json this process loaded from — CONTEXT for a reader, never evidence
18
+ * of a capability. A published tarball has already been observed carrying a
19
+ * change its version said it could not. Capability questions are answered by
20
+ * `capabilities`, which CALLS the code.
21
+ */
22
+ import { existsSync, readFileSync } from "node:fs";
23
+ import { spawnSync } from "node:child_process";
24
+ import path from "node:path";
25
+ import { fileURLToPath } from "node:url";
26
+ import { isGitRepo } from "./tools/scopes.js";
27
+
28
+ /** Running server package + optional git identity. `fromDir` is a test seam. */
29
+ export function resolveServerIdentity(fromDir?: string): {
30
+ path: string;
31
+ version: string;
32
+ branch?: string;
33
+ sha?: string;
34
+ } {
35
+ const start = fromDir ?? path.dirname(fileURLToPath(import.meta.url));
36
+ let dir = start;
37
+ let pkgFile: string | undefined;
38
+ for (let i = 0; i < 8; i++) {
39
+ const candidate = path.join(dir, "package.json");
40
+ if (existsSync(candidate)) {
41
+ pkgFile = candidate;
42
+ break;
43
+ }
44
+ const parent = path.dirname(dir);
45
+ if (parent === dir) break;
46
+ dir = parent;
47
+ }
48
+ const pkgPath = pkgFile ?? path.resolve(start, "..", "..", "package.json");
49
+ const pkgDir = path.dirname(pkgPath);
50
+ let version = "unknown";
51
+ try {
52
+ const raw = JSON.parse(readFileSync(pkgPath, "utf8")) as { version?: string };
53
+ if (typeof raw.version === "string" && raw.version) version = raw.version;
54
+ } catch {
55
+ /* leave unknown — never invent a version */
56
+ }
57
+
58
+ const out: { path: string; version: string; branch?: string; sha?: string } = {
59
+ path: pkgDir,
60
+ version,
61
+ };
62
+
63
+ dir = pkgDir;
64
+ let gitRoot: string | undefined;
65
+ for (let i = 0; i < 10; i++) {
66
+ if (existsSync(path.join(dir, ".git"))) {
67
+ gitRoot = dir;
68
+ break;
69
+ }
70
+ const parent = path.dirname(dir);
71
+ if (parent === dir) break;
72
+ dir = parent;
73
+ }
74
+ if (gitRoot && isGitRepo(gitRoot)) {
75
+ const branch = spawnSync("git", ["-C", gitRoot, "rev-parse", "--abbrev-ref", "HEAD"], { encoding: "utf8" });
76
+ const sha = spawnSync("git", ["-C", gitRoot, "rev-parse", "HEAD"], { encoding: "utf8" });
77
+ const b = branch.status === 0 ? String(branch.stdout).trim() : "";
78
+ const s = sha.status === 0 ? String(sha.stdout).trim() : "";
79
+ if (b) out.branch = b;
80
+ if (s) out.sha = s;
81
+ }
82
+ return out;
83
+ }
package/src/server.ts CHANGED
@@ -10,6 +10,7 @@ import { coordAwaySchema, coordAwayTool, readAway, awayRefusal, secondCoordinato
10
10
  import { rotateSchema, rotateTool, rotateReconcileSchema, rotateReconcileTool } from "./tools/rotate.js";
11
11
  import { subscribeSchema, subscribeTool, unsubscribeSchema, unsubscribeTool, listSubscriptionsSchema, listSubscriptionsTool } from "./tools/events.js";
12
12
  import { scanRecordEventsSchema, scanRecordEventsTool } from "./tools/record-events.js";
13
+ import { capabilitiesSchema, capabilitiesTool } from "./capabilities.js";
13
14
  import {
14
15
  ensureDirs,
15
16
  getTokenMap,
@@ -539,7 +540,7 @@ function buildServer(initialBound?: string, opts: { trackSession?: boolean } = {
539
540
 
540
541
  addTool(
541
542
  "list_work",
542
- "Query a project's work state as records instead of parsing markdown: open queue items (filter by priority), done entries with their ref and date as separate fields, the board's lane rows, and facts. Falls back to reading the documents directly when nothing has been imported, so it works with no store at all.",
543
+ "A re-parsing VIEW over a project's work state, not an index: it re-reads the documents (or the last import_work snapshot, reported as source:\"store\" vs \"markdown\") rather than answering from a maintained store, so treat repeated calls as costing what a fresh parse costs. Queue items come back as IDENTITY ONLY — id, priority, a bounded headline (truncated:true when cut, never silently), blockedBy — never the full body; pass id back on a second call to fetch one item's or one DONE entry's record whole. Done entries, board lane rows, and facts are returned in full. Filter queue by priority; falls back to reading the documents directly when nothing has been imported, so it works with no store at all.",
543
544
  listWorkSchema,
544
545
  gate(null, listWorkTool as (a: Record<string, unknown>) => Promise<unknown>),
545
546
  );
@@ -581,7 +582,7 @@ function buildServer(initialBound?: string, opts: { trackSession?: boolean } = {
581
582
 
582
583
  addTool(
583
584
  "next_unblocked",
584
- "The next queue item to work: re-reads docs/QUEUE.md via the seam, orders P1>P2>P3 with document order breaking ties, and SKIPS a blocked item rather than stalling the lane on a reorder (returning the board hunk to record the skip). Also reports items NOTHING WAITS ON as their own axis: an item that blocks nothing announces nothing when it stalls, so its absence is silent and needs an explicit check at a stage boundary.",
585
+ "The next queue item a WORKER can claim: re-reads docs/QUEUE.md via the seam, orders P1>P2>P3 with document order breaking ties, and SKIPS a blocked item rather than stalling the lane on a reorder (returning the board hunk to record the skip). Also skips — as its own reported axis, `notClaimable`, never silently — canon prose (`[SWEEP:canon]`/`[SWEEP:canon.N]`) that the aide/coordinator author directly and no worker takes as code work. Also reports items NOTHING WAITS ON as their own axis: an item that blocks nothing announces nothing when it stalls, so its absence is silent and needs an explicit check at a stage boundary.",
585
586
  nextUnblockedSchema,
586
587
  gate(null, nextUnblockedTool as (a: Record<string, unknown>) => Promise<unknown>),
587
588
  );
@@ -635,6 +636,13 @@ function buildServer(initialBound?: string, opts: { trackSession?: boolean } = {
635
636
  gate("agentId", subscribeTool as (a: Record<string, unknown>) => Promise<unknown>),
636
637
  );
637
638
 
639
+ addTool(
640
+ "capabilities",
641
+ "What THIS SERVER PROCESS can actually do, answered by CALLING each capability rather than by reading a version, an mtime, or a file. No timestamp on the box can answer a capability question: an mtime tracks writes (a reinstall of identical bytes moves it), `serverBuildMtime` is stamped at attach, and a version is a label someone typed — a published tarball has been observed carrying a change its version said it could not. Each probe reports the call it made and what came back, so an absent capability reads as an absence rather than an error. THE ANSWER IS ABOUT ONE PROCESS: a release is delivered only when every live agent's OWN server answers, which is the fifth state (merged \u00b7 published \u00b7 installed \u00b7 restarted \u00b7 observed). A server that has not restarted answers honestly about the old code it still runs.",
642
+ capabilitiesSchema,
643
+ gate(null, capabilitiesTool as () => Promise<unknown>),
644
+ );
645
+
638
646
  addTool(
639
647
  "scan_record_events",
640
648
  "Turn a repo's COMMITTED record changes into events: new `docs/DONE.md` entries, queue items that left `docs/QUEUE.md`, and newly-ticked phase checkboxes, between the stored watermark and HEAD. The commit is the boundary \u2014 an uncommitted edit is not yet a record. A HAND-EDIT fires exactly like `land` does, which is the point: fleets merge with `gh` and edit DONE.md directly. Reports by default; `write:true` delivers and advances the watermark. Re-scanning a delivered range is safe (the idempotency key comes from the event, so it reports duplicate-suppressed), and a watermark that no longer resolves REFUSES rather than silently narrowing its window.",
@@ -29,8 +29,26 @@ export type Subscription = {
29
29
  kind: SubKind;
30
30
  target: string;
31
31
  createdAt: number;
32
- /** null until this subscription has been EVALUATED at least once. */
32
+ /**
33
+ * null until an event OF THIS KIND was evaluated against this subscription.
34
+ *
35
+ * NOT "the machinery ran" — see lastScannedAt. Those were one field, and the
36
+ * collapse is the defect: `evaluate()` only touches subscriptions whose kind
37
+ * matches the event, so an `item` subscriber sat at `never evaluated` for a
38
+ * week while `pr` events flowed past it. Its health said the wiring was dead;
39
+ * the wiring was fine and nothing of its kind had happened.
40
+ */
33
41
  lastEvaluatedAt: number | null;
42
+ /**
43
+ * null until a scan RAN with this subscription registered — regardless of
44
+ * kind. A scan that ran is evidence the wiring works; it says nothing about
45
+ * whether your kind fired, which is the other field.
46
+ *
47
+ * Optional on disk: subscriptions written before this existed have no value,
48
+ * and absent must read as UNKNOWN rather than as "never scanned". Fabricating
49
+ * a scan nobody observed is the failure this whole phase is about.
50
+ */
51
+ lastScannedAt?: number | null;
34
52
  lastEventAt: number | null;
35
53
  /** Idempotency keys already delivered, for 6.4. */
36
54
  delivered: string[];
@@ -60,17 +78,53 @@ function writeSubs(subs: Subscription[]): void {
60
78
  * subscription with no last-evaluated mark has produced no evidence of
61
79
  * anything, and "no events" is the same output a broken subscription gives.
62
80
  */
63
- export function subscriptionHealth(s: Subscription): { level: "ok" | "error"; detail: string } {
64
- if (s.lastEvaluatedAt === null)
81
+ export function subscriptionHealth(s: Subscription): { level: "ok" | "error" | "unknown"; detail: string } {
82
+ const iso = (n: number) => new Date(n).toISOString();
83
+
84
+ // THREE STATES, BECAUSE THERE ARE THREE FACTS. They were two, and the
85
+ // collapse cost a week: an `item` subscriber read `error — never evaluated`
86
+ // the whole time, because `evaluate()` only touches subscriptions whose kind
87
+ // matches the event and no `item` event had fired. The wiring was fine. The
88
+ // health field said it was dead, and the agent holding it believed the field.
89
+ //
90
+ // Same shape as `alive` swallowing `heartbeatFresh`, and as
91
+ // `stall_clock_status` reporting green while covering nothing: one field
92
+ // answering two questions always answers the easier one.
93
+ if (s.lastScannedAt === undefined && s.lastEvaluatedAt === null) {
94
+ // Written before this field existed AND never evaluated. Absent is UNKNOWN,
95
+ // never "never scanned" — inventing a scan nobody observed is the failure
96
+ // this phase is about, and asserting one never happened is the same error
97
+ // pointed the other way.
98
+ return {
99
+ level: "unknown",
100
+ detail:
101
+ "predates scan tracking and has never been evaluated — whether the machinery has run for it CANNOT be determined from this record. " +
102
+ "It will resolve to ok or error on the next scan; until then this is an absence of evidence, not evidence of absence.",
103
+ };
104
+ }
105
+ if (!s.lastScannedAt) {
65
106
  return {
66
107
  level: "error",
67
- detail: `never evaluated — this subscription has produced no evidence it is wired to anything. "No events yet" and "never ran" are the same output, and only one of them is healthy.`,
108
+ detail:
109
+ "NEVER SCANNED — no scan has run with this subscription registered, so it has produced no evidence of being wired to anything. " +
110
+ "This is the state that means broken.",
111
+ };
112
+ }
113
+ if (s.lastEvaluatedAt === null) {
114
+ // THE STATE THAT USED TO READ AS BROKEN. The machinery ran and nothing of
115
+ // this kind happened, which is a healthy idle watch.
116
+ return {
117
+ level: "ok",
118
+ detail:
119
+ `scanned ${iso(s.lastScannedAt)}, never evaluated — the machinery RAN and no '${s.kind}' event has occurred yet. ` +
120
+ "Scanned-and-quiet is healthy; it is not the same as never run, and those shared a field until now.",
68
121
  };
122
+ }
69
123
  return {
70
124
  level: "ok",
71
125
  detail: s.lastEventAt
72
- ? `last evaluated ${new Date(s.lastEvaluatedAt).toISOString()}, last event ${new Date(s.lastEventAt).toISOString()}`
73
- : `last evaluated ${new Date(s.lastEvaluatedAt).toISOString()}, no events yet — evaluated and quiet, which is different from never run`,
126
+ ? `scanned ${iso(s.lastScannedAt)}, last evaluated ${iso(s.lastEvaluatedAt)}, last event ${iso(s.lastEventAt)}`
127
+ : `scanned ${iso(s.lastScannedAt)}, last evaluated ${iso(s.lastEvaluatedAt)}, no events yet`,
74
128
  };
75
129
  }
76
130
 
@@ -122,12 +176,26 @@ export type Delivery = { subscriptionId: string; agentId: string; key: string; s
122
176
  * A subscription only learns it is alive by being evaluated, so the mark is
123
177
  * written for every subscription of that kind, not only the ones that fired.
124
178
  */
179
+ /**
180
+ * Record that a scan RAN, for every live subscription regardless of kind.
181
+ *
182
+ * Called once per scan — including a scan that produced NO events, which is
183
+ * exactly the case that starved the old field: no events of your kind means
184
+ * `evaluate` never touches you, and a subscription that is never touched cannot
185
+ * be told from one that is not wired to anything.
186
+ */
187
+ export function markScanned(subs: Subscription[], now: number): Subscription[] {
188
+ return subs.map((s) => ({ ...s, lastScannedAt: now }));
189
+ }
190
+
125
191
  export function evaluate(subs: Subscription[], ev: RecordEvent, now: number): { subs: Subscription[]; deliveries: Delivery[] } {
126
192
  const key = eventKey(ev.kind, ev.target, ev.ref);
127
193
  const deliveries: Delivery[] = [];
128
194
  const next = subs.map((s) => {
129
- if (s.kind !== ev.kind) return s;
130
- const evaluated = { ...s, lastEvaluatedAt: now };
195
+ // Every subscription observed this scan, whatever its kind.
196
+ const scanned = { ...s, lastScannedAt: now };
197
+ if (s.kind !== ev.kind) return scanned;
198
+ const evaluated = { ...scanned, lastEvaluatedAt: now };
131
199
  if (s.target !== ev.target) return evaluated;
132
200
  if (s.delivered.includes(key)) {
133
201
  deliveries.push({ subscriptionId: s.id, agentId: s.agentId, key, status: "duplicate-suppressed" });
@@ -157,6 +225,12 @@ export async function subscribeTool(args: { agentId: string; kind: SubKind; targ
157
225
  kind: args.kind,
158
226
  target: args.target,
159
227
  createdAt: Date.now(),
228
+ // EXPLICIT null, never left absent. `null` means "we know no scan has run
229
+ // for this"; `undefined` means "this record predates the field and we
230
+ // cannot say". A subscription created now is the first case, and writing it
231
+ // explicitly is what keeps a brand-new registration from being reported as
232
+ // unknowable.
233
+ lastScannedAt: null,
160
234
  lastEvaluatedAt: null,
161
235
  lastEventAt: null,
162
236
  delivered: [],
@@ -185,6 +259,7 @@ export async function listSubscriptionsTool(args: { agentId?: string }) {
185
259
  const subs = args.agentId ? all.filter((s) => s.agentId === args.agentId) : all;
186
260
  const rows = subs.map((s) => ({ ...s, health: subscriptionHealth(s) }));
187
261
  const neverEvaluated = rows.filter((r) => r.health.level === "error");
262
+ const undetermined = rows.filter((r) => r.health.level === "unknown");
188
263
  return {
189
264
  ok: neverEvaluated.length === 0,
190
265
  // Population beside the verdict, always: "no subscriptions" and "none
@@ -192,7 +267,12 @@ export async function listSubscriptionsTool(args: { agentId?: string }) {
192
267
  population: { listed: rows.length, total: all.length },
193
268
  subscriptions: rows,
194
269
  ...(neverEvaluated.length
195
- ? { error: `${neverEvaluated.length} of ${rows.length} subscription(s) have NEVER been evaluated — they have produced no evidence of being wired to anything.` }
270
+ ? { error: `${neverEvaluated.length} of ${rows.length} subscription(s) have NEVER BEEN SCANNEDno scan has run with them registered, so they have produced no evidence of being wired to anything.` }
271
+ : {}),
272
+ ...(undetermined.length
273
+ ? {
274
+ undetermined: `${undetermined.length} of ${rows.length} subscription(s) predate scan tracking and cannot be judged yet — they resolve on the next scan. Reported rather than counted as either healthy or broken.`,
275
+ }
196
276
  : {}),
197
277
  };
198
278
  }
@@ -25,7 +25,7 @@
25
25
  * notification for work that then vanishes is worse than a late one.
26
26
  */
27
27
  import { execFileSync } from "node:child_process";
28
- import { newlyTickedInDiff, parseWorkDoc, queueItemsOf, doneEntriesOf } from "@davidbalzan/groundwork-seam";
28
+ import { newlyTickedInDiff, parseWorkDoc, queueItemsOf, doneEntriesOf, hasCommitRef } from "@davidbalzan/groundwork-seam";
29
29
  import { EVENT_KINDS, type RecordEvent, type SubKind } from "./event-kinds.js";
30
30
  export { EVENT_KINDS, EVENT_KIND_IDS } from "./event-kinds.js";
31
31
  export type { RecordEvent, SubKind } from "./event-kinds.js";
@@ -110,8 +110,6 @@ export function prRefsIn(field: string | undefined): string[] {
110
110
  return out;
111
111
  }
112
112
 
113
- /** A citation that closes an item without naming a PR — `owner/repo@sha`. */
114
- const COMMIT_REF = /@[0-9a-f]{7,40}\b/;
115
113
  /** Queue and done text, compared for the pairing below. */
116
114
  const norm = (s: string) => String(s).toLowerCase().replace(/[`*_]/g, "").replace(/\s+/g, " ").trim();
117
115
 
@@ -167,7 +165,7 @@ export function eventsFromCommittedChange(
167
165
  // or an `@sha` commit. `land` requires a PR by rule; the scan reads what the
168
166
  // record actually says, and a commit-cited entry is still the record stating
169
167
  // that the item closed.
170
- const citedEntries = newEntries.filter((e) => prRefsIn(e.ref).length > 0 || COMMIT_REF.test(e.ref ?? ""));
168
+ const citedEntries = newEntries.filter((e) => prRefsIn(e.ref).length > 0 || hasCommitRef(e.ref ?? ""));
171
169
  const unattributed: string[] = [];
172
170
 
173
171
  for (const item of removedItems) {
@@ -259,7 +257,7 @@ import { existsSync, readFileSync, writeFileSync, mkdirSync } from "node:fs";
259
257
  import path from "node:path";
260
258
  import { z } from "zod";
261
259
  import { ROOT } from "../store.js";
262
- import { readSubs, evaluate, commitEvaluation, eventIsDerived } from "./events.js";
260
+ import { readSubs, evaluate, commitEvaluation, eventIsDerived, markScanned } from "./events.js";
263
261
 
264
262
  /**
265
263
  * The watermark: the last commit whose record change has been turned into
@@ -351,8 +349,15 @@ export async function scanRecordEventsTool(args: { repo: string; since?: string;
351
349
 
352
350
  const deliveries: unknown[] = [];
353
351
  if (args.write) {
354
- let subs = readSubs();
352
+ // MARK THE SCAN BEFORE THE EVENTS, and unconditionally.
353
+ //
354
+ // A scan that produced NO events is exactly the case that starved the old
355
+ // health field: `evaluate` is per-event, so a quiet scan touched nothing
356
+ // and every subscription kept reading "never evaluated" — indistinguishable
357
+ // from unwired. The scan RAN; that is a fact about the machinery and it is
358
+ // recorded whether or not anything fired.
355
359
  const now = Date.now();
360
+ let subs = markScanned(readSubs(), now);
356
361
  for (const ev of emitted) {
357
362
  const r = evaluate(subs, ev, now);
358
363
  subs = r.subs;
@@ -24,6 +24,7 @@ import {
24
24
  type WorkDoc,
25
25
  phaseCitationsIn,
26
26
  newlyTickedInDiff,
27
+ sweepTagOf,
27
28
  } from "@davidbalzan/groundwork-seam";
28
29
  import { ensureWorktreeTool } from "./worktrees.js";
29
30
  import { haltState } from "./stall.js";
@@ -151,8 +152,21 @@ export async function nextUnblockedTool(args: { project: string; repo?: string }
151
152
  .sort((a, b) => (PRIORITY_ORDER[a.i.priority ?? "P3"] ?? 3) - (PRIORITY_ORDER[b.i.priority ?? "P3"] ?? 3) || a.idx - b.idx);
152
153
 
153
154
  const skipped: { item: string; blockedBy: string }[] = [];
155
+ // NOT WORKER-CLAIMABLE (Task 15.4). Measured: 13-14 of the open queue is
156
+ // `[SWEEP:canon]`/`[SWEEP:canon.N]` — canon prose the aide and coordinator
157
+ // author directly into the playbook, never assigned as code work. Handing
158
+ // one out here is the exact defect: `next_unblocked` offered one outside
159
+ // the caller's lane while the rest of what remained was this same family.
160
+ // Skipped VISIBLY, same discipline as a blocked top item — never silently
161
+ // dropped, so a caller can tell "nothing left for me" from "nothing left".
162
+ const notClaimable: { item: string; sweepTag: string }[] = [];
154
163
  let pick: QueueItem | null = null;
155
164
  for (const { i } of ranked) {
165
+ const tag = sweepTagOf(i);
166
+ if (tag && /^canon(\.\d+)?$/.test(tag)) {
167
+ notClaimable.push({ item: keyOf(i), sweepTag: tag });
168
+ continue;
169
+ }
156
170
  const b = blockedBy(i);
157
171
  // NEVER STALL THE LANE waiting on a reorder: a blocked top item is skipped,
158
172
  // visibly, and the next unblocked one is taken.
@@ -176,7 +190,15 @@ export async function nextUnblockedTool(args: { project: string; repo?: string }
176
190
  open: open.length,
177
191
  next: pick ? { id: pick.id, priority: pick.priority, text: pick.text } : null,
178
192
  skipped,
179
- boardHunks: skipped.map((s) => `⏭ skipped blocked by ${s.blockedBy}`),
193
+ // A SEPARATE AXIS from `skipped` (blocked) this is "not this caller's
194
+ // to take" rather than "blocked on something else". Collapsing the two
195
+ // would read a claimability gap as a dependency, which is a different
196
+ // remedy (route it, don't wait for it).
197
+ notClaimable,
198
+ boardHunks: [
199
+ ...skipped.map((s) => `⏭ skipped — blocked by ${s.blockedBy}`),
200
+ ...notClaimable.map((s) => `⏭ skipped — not worker-claimable (${s.sweepTag})`),
201
+ ],
180
202
  // A SEPARATE AXIS, deliberately. See noDownstream().
181
203
  noDownstream: undiscriminating
182
204
  ? {