agent-coord-mcp 0.26.21 → 0.26.22
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/dist/capabilities.js +250 -2
- package/dist/capabilities.js.map +1 -1
- package/dist/closing-line.js +83 -0
- package/dist/closing-line.js.map +1 -0
- package/dist/commit-cite.js +55 -0
- package/dist/commit-cite.js.map +1 -0
- package/dist/gated-head.js +67 -20
- package/dist/gated-head.js.map +1 -1
- package/dist/server-spread.js +195 -0
- package/dist/server-spread.js.map +1 -0
- package/dist/server.js +2 -2
- package/dist/server.js.map +1 -1
- package/dist/store.js +32 -0
- package/dist/store.js.map +1 -1
- package/dist/tools/away.js +67 -7
- package/dist/tools/away.js.map +1 -1
- package/dist/tools/board-ref.js +44 -4
- package/dist/tools/board-ref.js.map +1 -1
- package/dist/tools/event-kinds.js +5 -1
- package/dist/tools/event-kinds.js.map +1 -1
- package/dist/tools/events.js +31 -2
- package/dist/tools/events.js.map +1 -1
- package/dist/tools/messaging.js +64 -6
- package/dist/tools/messaging.js.map +1 -1
- package/dist/tools/record-events.js +85 -5
- package/dist/tools/record-events.js.map +1 -1
- package/dist/tools/records.js +231 -38
- package/dist/tools/records.js.map +1 -1
- package/dist/tools/registry.js +52 -2
- package/dist/tools/registry.js.map +1 -1
- package/dist/tools/seat-build.js +173 -0
- package/dist/tools/seat-build.js.map +1 -0
- package/dist/tools/shared.js.map +1 -1
- package/dist/tools/stall.js +1095 -18
- package/dist/tools/stall.js.map +1 -1
- package/dist/tools/transport.js +21 -2
- package/dist/tools/transport.js.map +1 -1
- package/dist/tools/worktrees.js +14 -0
- package/dist/tools/worktrees.js.map +1 -1
- package/package.json +1 -1
- package/scripts/coord-attention-clock.mjs +2 -0
- package/scripts/coord-stall-clock.mjs +52 -11
- package/src/capabilities.ts +264 -2
- package/src/closing-line.ts +85 -0
- package/src/commit-cite.ts +58 -0
- package/src/gated-head.ts +128 -26
- package/src/server-spread.ts +233 -0
- package/src/server.ts +2 -2
- package/src/store.ts +32 -0
- package/src/tools/away.ts +82 -9
- package/src/tools/board-ref.ts +70 -3
- package/src/tools/event-kinds.ts +17 -1
- package/src/tools/events.ts +33 -2
- package/src/tools/messaging.ts +63 -6
- package/src/tools/record-events.ts +78 -5
- package/src/tools/records.ts +248 -38
- package/src/tools/registry.ts +54 -3
- package/src/tools/seat-build.ts +194 -0
- package/src/tools/shared.ts +22 -0
- package/src/tools/stall.ts +1266 -23
- package/src/tools/transport.ts +21 -2
- package/src/tools/worktrees.ts +13 -0
package/src/tools/away.ts
CHANGED
|
@@ -75,7 +75,46 @@ export const PARKED_CATEGORIES = [
|
|
|
75
75
|
"destructive machine actions",
|
|
76
76
|
] as const;
|
|
77
77
|
|
|
78
|
-
|
|
78
|
+
/*
|
|
79
|
+
* ⟨q-5a71fc3b⟩ — THE THRESHOLD, NAMED. `coord_away` armed a fleet that was 3/5
|
|
80
|
+
* blind, silently: the refusal only fired at ZERO measurable lanes, so "2 of
|
|
81
|
+
* 5" — three seats the clock could not see — read as covered enough. A
|
|
82
|
+
* partially blind fleet IS the incident, not a lesser case of it: the seats
|
|
83
|
+
* nobody can see are exactly the ones an absence leaves unattended. So the
|
|
84
|
+
* threshold is every in-flight lane the clock scores, the refusal names the
|
|
85
|
+
* seats short of it, and `acknowledgeBlindFleet` is the only way past.
|
|
86
|
+
*/
|
|
87
|
+
export const COVERAGE_THRESHOLD = "EVERY in-flight lane the clock scores must be measurable";
|
|
88
|
+
/*
|
|
89
|
+
* ⟨q-ce585029⟩ — A QUERY REPORTS THE COVERAGE MEASURED AT ARM TIME, AND SAYS
|
|
90
|
+
* SO. "Coverage 2/5" read on 09-11 was three days old by the time it was
|
|
91
|
+
* quoted as current. The query stays free (no re-measure); the summary
|
|
92
|
+
* carries the reading's age and labels it stale past this many minutes.
|
|
93
|
+
*/
|
|
94
|
+
export const COVERAGE_STALE_MINUTES = 60;
|
|
95
|
+
export const fmtAge = (ms: number): string => (ms < 60_000 ? `${Math.max(0, Math.round(ms / 1000))}s` : ms < 3_600_000 ? `${Math.floor(ms / 60_000)}m` : ms < 86_400_000 ? `${Math.floor(ms / 3_600_000)}h` : `${Math.floor(ms / 86_400_000)}d`);
|
|
96
|
+
export function coverageAgeOf(coverage: { at: string }, now = Date.now()): { ageMs: number | null; stale: boolean; label: string } {
|
|
97
|
+
const at = Date.parse(coverage.at);
|
|
98
|
+
if (!Number.isFinite(at)) return { ageMs: null, stale: true, label: "measured at an unreadable time — treat as stale" };
|
|
99
|
+
const ageMs = Math.max(0, now - at);
|
|
100
|
+
const stale = ageMs > COVERAGE_STALE_MINUTES * 60_000;
|
|
101
|
+
return { ageMs, stale, label: `measured ${fmtAge(ageMs)} ago${stale ? ` — STALE (older than ${COVERAGE_STALE_MINUTES}m; re-arm or run stall_check for a current reading)` : ""}` };
|
|
102
|
+
}
|
|
103
|
+
/** Met when nothing scored is unseen — an empty board (0 of 0) is idle, not blind, and needs no special case. */
|
|
104
|
+
export const meetsCoverageThreshold = (c: { checked: number; measurable: number }): boolean => c.measurable >= c.checked;
|
|
105
|
+
|
|
106
|
+
export type AwayCoverage = {
|
|
107
|
+
checked: number;
|
|
108
|
+
measurable: number;
|
|
109
|
+
blind: string[];
|
|
110
|
+
/** ⟨q-5d1c8e04⟩ — standing seats on the board: present, not scored, and therefore not blind. */
|
|
111
|
+
roles?: number;
|
|
112
|
+
/** Lanes that declare no per-agent branch: out of the population by their own statement. */
|
|
113
|
+
deliberate?: number;
|
|
114
|
+
/** ⟨q-3d82f1a9⟩ — the board could not be read: rows present vs parsed, as stall_check stated it. */
|
|
115
|
+
unreadable?: { rowsPresent: number; rowsParsed: number; why: string };
|
|
116
|
+
at: string;
|
|
117
|
+
};
|
|
79
118
|
|
|
80
119
|
export type AwayState = {
|
|
81
120
|
on: boolean;
|
|
@@ -180,10 +219,28 @@ async function measureCoverage(repo?: string): Promise<AwayCoverage | null> {
|
|
|
180
219
|
if (!repo) return null;
|
|
181
220
|
try {
|
|
182
221
|
const r = (await stallCheckTool({ repo })) as unknown as {
|
|
183
|
-
ok?: boolean; checked?: number; measurable?: number; blind?: string[];
|
|
222
|
+
ok?: boolean; checked?: number; measurable?: number; blind?: string[]; roles?: unknown[]; deliberate?: unknown[];
|
|
223
|
+
boardParse?: { readable: boolean; rowsPresent: number; rowsParsed: number; why: string };
|
|
184
224
|
};
|
|
225
|
+
// ⟨q-3d82f1a9⟩ — AN UNREADABLE BOARD IS A NAMED STATE, not "could not measure".
|
|
226
|
+
// The clock refused to answer because the board broke; arming on it would
|
|
227
|
+
// promise a watched fleet over a table nobody can read.
|
|
228
|
+
if (r?.boardParse && !r.boardParse.readable) {
|
|
229
|
+
return {
|
|
230
|
+
checked: 0, measurable: 0, blind: [], roles: 0, deliberate: 0,
|
|
231
|
+
unreadable: { rowsPresent: r.boardParse.rowsPresent, rowsParsed: r.boardParse.rowsParsed, why: r.boardParse.why },
|
|
232
|
+
at: new Date().toISOString(),
|
|
233
|
+
};
|
|
234
|
+
}
|
|
185
235
|
if (!r?.ok) return null;
|
|
186
|
-
return {
|
|
236
|
+
return {
|
|
237
|
+
checked: r.checked ?? 0,
|
|
238
|
+
measurable: r.measurable ?? 0,
|
|
239
|
+
blind: r.blind ?? [],
|
|
240
|
+
roles: r.roles?.length ?? 0,
|
|
241
|
+
deliberate: r.deliberate?.length ?? 0,
|
|
242
|
+
at: new Date().toISOString(),
|
|
243
|
+
};
|
|
187
244
|
} catch {
|
|
188
245
|
return null;
|
|
189
246
|
}
|
|
@@ -225,10 +282,12 @@ export async function coordAwayTool(args: {
|
|
|
225
282
|
project: args.project,
|
|
226
283
|
armed: true,
|
|
227
284
|
state: prior,
|
|
285
|
+
coverageAge: coverageAgeOf(prior.coverage),
|
|
228
286
|
summary:
|
|
229
287
|
`'${args.project}' is ARMED — '${prior.leadId}' leads since ${prior.at}` +
|
|
230
288
|
`${prior.until ? ` until ${prior.until}` : ""}. ` +
|
|
231
|
-
`Coverage ${prior.coverage.measurable}/${prior.coverage.checked}` +
|
|
289
|
+
`Coverage ${prior.coverage.measurable}/${prior.coverage.checked} lane(s) ${coverageAgeOf(prior.coverage).label}` +
|
|
290
|
+
`${prior.coverage.roles ? ` · ${prior.coverage.roles} role(s) present, not scored` : ""}` +
|
|
232
291
|
`${prior.blindAcknowledged ? " (ACKNOWLEDGED BLIND)" : ""}. ` +
|
|
233
292
|
`Decisions logged to ${prior.decisionLog}.`,
|
|
234
293
|
};
|
|
@@ -265,17 +324,31 @@ export async function coordAwayTool(args: {
|
|
|
265
324
|
`record that the promise is being made anyway.`,
|
|
266
325
|
};
|
|
267
326
|
}
|
|
327
|
+
// ⟨q-3d82f1a9⟩ — REFUSED OUTRIGHT, and not acknowledgeable: a blind fleet is
|
|
328
|
+
// one the clock can see and cannot measure; an unreadable board is one the
|
|
329
|
+
// clock cannot even see. "0 of 0" here is not idle, and no flag makes it so.
|
|
330
|
+
if (coverage.unreadable) {
|
|
331
|
+
return {
|
|
332
|
+
ok: false as const,
|
|
333
|
+
error:
|
|
334
|
+
`REFUSING: docs/WORKSTREAMS.md is UNREADABLE — ${coverage.unreadable.rowsPresent} row(s) present in the Active Streams table, ` +
|
|
335
|
+
`${coverage.unreadable.rowsParsed} parsed. ${coverage.unreadable.why} ` +
|
|
336
|
+
`Away mode promises a watched fleet; a board the clock cannot read is not a quiet fleet, it is an unwatched one. Fix the row and re-arm.`,
|
|
337
|
+
};
|
|
338
|
+
}
|
|
268
339
|
// `checked > 0` MATTERS: an empty board is an IDLE fleet, not a blind one.
|
|
269
340
|
// Without it, arming while nothing is in flight refuses with "the clock
|
|
270
341
|
// covers 0 of 0" — a refusal that reads as a fleet nobody can see when in
|
|
271
342
|
// fact there is nothing to see, and the only way past it would be to
|
|
272
343
|
// acknowledge a blindness that does not exist.
|
|
273
|
-
|
|
344
|
+
// ⟨q-5a71fc3b⟩ — below the threshold, not only at zero: "2 of 5" refuses.
|
|
345
|
+
if (!meetsCoverageThreshold(coverage) && !args.acknowledgeBlindFleet) {
|
|
274
346
|
return {
|
|
275
347
|
ok: false as const,
|
|
276
348
|
error:
|
|
277
|
-
`REFUSING: the stall clock covers ${coverage.measurable} of ${coverage.checked} in-flight row(s)
|
|
278
|
-
`
|
|
349
|
+
`REFUSING: the stall clock covers ${coverage.measurable} of ${coverage.checked} in-flight row(s); the threshold is that ${COVERAGE_THRESHOLD} ` +
|
|
350
|
+
`(${coverage.checked - coverage.measurable} short)${coverage.blind.length ? ` — blind on: ${coverage.blind.join(", ")}` : ""}. ` +
|
|
351
|
+
`${coverage.measurable === 0 ? "It would RUN and see nothing. " : "The seats it cannot see are exactly the ones an absence leaves unattended. "}` +
|
|
279
352
|
`"The clock ran" and "the fleet is observed" are different facts, and this verb must not treat the first as the second. ` +
|
|
280
353
|
`The usual cause is a board 'Branch · Worktree' cell holding a PATH rather than a branch ref: a path resolves for ` +
|
|
281
354
|
`git and measures the wrong thing, so the check reports it unmeasurable rather than guessing. Fix those cells and ` +
|
|
@@ -292,7 +365,7 @@ export async function coordAwayTool(args: {
|
|
|
292
365
|
at: new Date().toISOString(),
|
|
293
366
|
decisionLog: args.decisionLog,
|
|
294
367
|
coverage,
|
|
295
|
-
...(coverage
|
|
368
|
+
...(!meetsCoverageThreshold(coverage) ? { blindAcknowledged: true } : {}),
|
|
296
369
|
};
|
|
297
370
|
writeAway(state);
|
|
298
371
|
return {
|
|
@@ -309,7 +382,7 @@ export async function coordAwayTool(args: {
|
|
|
309
382
|
`Decides: planning · priority · curation · roadmap · canon · releases under standing authorisation. ` +
|
|
310
383
|
`Never: merges, gates, or takes a code lane. Parks for David: ${PARKED_CATEGORIES.join(" · ")}. ` +
|
|
311
384
|
`Decisions logged to ${args.decisionLog}. ` +
|
|
312
|
-
`Stall coverage ${coverage.measurable}/${coverage.checked}${coverage
|
|
385
|
+
`Stall coverage ${coverage.measurable}/${coverage.checked}${!meetsCoverageThreshold(coverage) ? ` — ACKNOWLEDGED BLIND${coverage.measurable === 0 ? ": the clock runs and sees nothing" : ""}${coverage.blind.length ? ` on: ${coverage.blind.join(", ")}` : ""}` : ""}.`,
|
|
313
386
|
};
|
|
314
387
|
}
|
|
315
388
|
|
package/src/tools/board-ref.ts
CHANGED
|
@@ -49,6 +49,16 @@ import { execFileSync } from "node:child_process";
|
|
|
49
49
|
export type BoardRefVerdict =
|
|
50
50
|
| { kind: "measurable"; ref: string }
|
|
51
51
|
| { kind: "empty"; why: string }
|
|
52
|
+
/**
|
|
53
|
+
* ⟨q-5d1c8e04⟩ — A DELIBERATE NO-BRANCH. The cell holds WORDS in the ref
|
|
54
|
+
* position (`docs-direct · no per-agent branch`, `own clone …`): a human
|
|
55
|
+
* statement that this lane carries no per-agent branch. Out of the stall
|
|
56
|
+
* clock's population, never "unmeasurable" — five seats wrote this shape
|
|
57
|
+
* because the grammar gave them no other way to say it.
|
|
58
|
+
*/
|
|
59
|
+
| { kind: "none"; why: string }
|
|
60
|
+
/** A ref a human ABBREVIATED for display (`…/kit-worker-lane`) — unreadable by construction, and said so. */
|
|
61
|
+
| { kind: "elided"; why: string }
|
|
52
62
|
| { kind: "path"; why: string }
|
|
53
63
|
| { kind: "shared"; why: string }
|
|
54
64
|
| { kind: "unscoped"; why: string }
|
|
@@ -84,9 +94,51 @@ const isSyntacticallyValidRef = (name: string): boolean => {
|
|
|
84
94
|
}
|
|
85
95
|
};
|
|
86
96
|
|
|
87
|
-
|
|
97
|
+
/*
|
|
98
|
+
* ⟨q-5d1c8e04⟩ — THE CELL GRAMMAR, read by POSITION rather than by scanning.
|
|
99
|
+
*
|
|
100
|
+
* \`ref\` · <path> the ref is the LEADING backticked token; what follows is a note
|
|
101
|
+
* — (or blank) nothing declared — on a lane row this is "lacks a branch"
|
|
102
|
+
* <prose> a deliberate statement: this row carries no per-agent branch
|
|
103
|
+
*
|
|
104
|
+
* `refInCell` used to take the FIRST backticked token ANYWHERE, so the aide's
|
|
105
|
+
* `**own clone \`groundwork-kit-aide-write\`** (ADR-016, \`a7bd341\`)` yielded a
|
|
106
|
+
* clone name that was then looked up as `origin/groundwork-kit-aide-write` and
|
|
107
|
+
* reported "unpushed" — a ref manufactured from prose. Measured live: five
|
|
108
|
+
* prose cells on one board, three of them reaching `unmeasurable` under three
|
|
109
|
+
* different mis-parses. A position cannot be mis-parsed that way.
|
|
110
|
+
*/
|
|
111
|
+
export type CellKind = "ref" | "empty" | "prose";
|
|
112
|
+
export function cellKindOf(cell: string): CellKind {
|
|
113
|
+
const s = String(cell ?? "").trim();
|
|
114
|
+
if (!s || s === "—" || s === "-" || s === "–") return "empty";
|
|
115
|
+
return /^`[^`]+`/.test(s) ? "ref" : "prose";
|
|
116
|
+
}
|
|
117
|
+
/** The `\`ref\`` in a `Branch · Worktree` cell's REF POSITION (leading token), or "". */
|
|
88
118
|
export function refInCell(cell: string): string {
|
|
89
|
-
return (String(cell ?? "").match(
|
|
119
|
+
return (String(cell ?? "").trim().match(/^`([^`]+)`/)?.[1] ?? "").trim();
|
|
120
|
+
}
|
|
121
|
+
/** A display elision a human typed into a machine-read field. */
|
|
122
|
+
const ELIDED = /…|\.\.\./;
|
|
123
|
+
|
|
124
|
+
/*
|
|
125
|
+
* ⟨q-5d1c8e04⟩ — LANES vs ROLES. A ROLE row is a standing seat (coordinator,
|
|
126
|
+
* aide) with nothing to score: no branch, no slice, no stall. It is written with
|
|
127
|
+
* its own status glyph and a `—` branch cell, in the same table. The glyph is
|
|
128
|
+
* a bus-level ROW KIND, not a work state: the seam's `workStateOf` reads it as
|
|
129
|
+
* `unknown`, which is correct — a role is not work, and the seam is not asked
|
|
130
|
+
* to invent a state for it.
|
|
131
|
+
*
|
|
132
|
+
* ⚠ A 🪑 ROW THAT CARRIES A REF IS A LANE IN DISGUISE and is scored as one —
|
|
133
|
+
* relabelling work as a role must not exempt it from the clock.
|
|
134
|
+
*/
|
|
135
|
+
export const ROLE_GLYPH = "🪑";
|
|
136
|
+
const STATUS_DECORATION = /^[\s*⭐]+/u;
|
|
137
|
+
export const isRoleStatus = (status: string): boolean => String(status ?? "").replace(STATUS_DECORATION, "").startsWith(ROLE_GLYPH);
|
|
138
|
+
export type RowKind = "lane" | "role" | "role-with-ref";
|
|
139
|
+
export function rowKindOf(row: { status: string; branchWorktree: string }): RowKind {
|
|
140
|
+
if (!isRoleStatus(row.status)) return "lane";
|
|
141
|
+
return cellKindOf(row.branchWorktree) === "ref" ? "role-with-ref" : "role";
|
|
90
142
|
}
|
|
91
143
|
|
|
92
144
|
/** The remote-tracking form a board cell should name for `branch`. */
|
|
@@ -106,7 +158,22 @@ export function classifyBoardRef(
|
|
|
106
158
|
base = "origin/main",
|
|
107
159
|
): BoardRefVerdict {
|
|
108
160
|
const raw = refInCell(cell);
|
|
109
|
-
if (!raw)
|
|
161
|
+
if (!raw) {
|
|
162
|
+
// Words in the ref position are a DECLARATION, not an absence: the row
|
|
163
|
+
// says it has no per-agent branch. Only a blank or `—` is "nothing said".
|
|
164
|
+
if (cellKindOf(cell) === "prose") {
|
|
165
|
+
return { kind: "none", why: `the Branch · Worktree cell declares in words that this row carries no per-agent branch: "${String(cell).trim().slice(0, 60)}"` };
|
|
166
|
+
}
|
|
167
|
+
return { kind: "empty", why: "no ref in the Branch · Worktree cell, and nothing declared in its place" };
|
|
168
|
+
}
|
|
169
|
+
if (ELIDED.test(raw)) {
|
|
170
|
+
return {
|
|
171
|
+
kind: "elided",
|
|
172
|
+
why:
|
|
173
|
+
`'${raw}' is a ref a human abbreviated for display — it contains an elision and cannot be looked up as written. ` +
|
|
174
|
+
`Unreadable by construction, not "unpushed": the real ref may well exist. Write the full ref in the cell; the note can carry the shape.`,
|
|
175
|
+
};
|
|
176
|
+
}
|
|
110
177
|
|
|
111
178
|
const bare = raw.replace(/^origin\//, "");
|
|
112
179
|
|
package/src/tools/event-kinds.ts
CHANGED
|
@@ -17,6 +17,18 @@
|
|
|
17
17
|
* widen without adding an emitter in the same file.
|
|
18
18
|
*/
|
|
19
19
|
export type SubKind = keyof typeof EVENT_KINDS;
|
|
20
|
+
/**
|
|
21
|
+
* ⟨q-2b7d9f04⟩ — EVERY KIND SHIPS WITH THE CHANGE THAT MUST PRODUCE IT. The
|
|
22
|
+
* `item` subscription sat at `health: ok` for three days while structurally
|
|
23
|
+
* unable to fire: an emitter existed, so the registry was satisfied, and the
|
|
24
|
+
* scanner RAN, so liveness was satisfied — but the grammar never read the
|
|
25
|
+
* leading item id `land` writes, so no live commit could ever produce the
|
|
26
|
+
* kind. A probe is the LIVE shape, not the smallest one: two items out, two
|
|
27
|
+
* leading-id entries in, one PR — the #328 commit. Health asks the scanner to
|
|
28
|
+
* produce each kind from its probe; a kind whose probe yields nothing CANNOT
|
|
29
|
+
* FIRE, whatever the emitter list and the scan clock say.
|
|
30
|
+
*/
|
|
31
|
+
export type KindProbe = { diff: string; after?: { done?: string; phases?: Record<string, string> } };
|
|
20
32
|
export type RecordEvent = { kind: SubKind; target: string; ref: string; summary: string };
|
|
21
33
|
|
|
22
34
|
/*
|
|
@@ -37,23 +49,27 @@ export type RecordEvent = { kind: SubKind; target: string; ref: string; summary:
|
|
|
37
49
|
export const EVENT_KINDS = {
|
|
38
50
|
item: {
|
|
39
51
|
record: "docs/QUEUE.md + docs/DONE.md",
|
|
40
|
-
what: "a queue item closed —
|
|
52
|
+
what: "a queue item closed — a DONE.md entry beginning with its ⟨q-…⟩ id appeared (land writes it), or it left QUEUE.md against one unambiguous entry",
|
|
41
53
|
targetIs: "the queue item id",
|
|
54
|
+
probe: { diff: "diff --git a/docs/QUEUE.md b/docs/QUEUE.md\n--- a/docs/QUEUE.md\n+++ b/docs/QUEUE.md\n@@ -1,1 +1,1 @@\n-- [ ] (P1) ⟨q-0a0a0a01⟩ Kit: the first thing to do\n-- [ ] (P1) ⟨q-0a0a0a02⟩ Kit: the second thing to do\n\ndiff --git a/docs/DONE.md b/docs/DONE.md\n--- a/docs/DONE.md\n+++ b/docs/DONE.md\n@@ -1,1 +1,1 @@\n+- [x] ⟨q-0a0a0a01⟩ Kit: what was done for the first… — owner/repo#77 · 2026-09-14\n+- [x] ⟨q-0a0a0a02⟩ Kit: what was done for the second… — owner/repo#77 · 2026-09-14\n" } as KindProbe,
|
|
42
55
|
},
|
|
43
56
|
pr: {
|
|
44
57
|
record: "docs/DONE.md",
|
|
45
58
|
what: "a PR recorded in the completion log",
|
|
46
59
|
targetIs: "the PR ref, e.g. owner/repo#163",
|
|
60
|
+
probe: { diff: "diff --git a/docs/DONE.md b/docs/DONE.md\n--- a/docs/DONE.md\n+++ b/docs/DONE.md\n@@ -1,1 +1,1 @@\n+- [x] a PR recorded in the completion log — owner/repo#78 · 2026-09-14\n" } as KindProbe,
|
|
47
61
|
},
|
|
48
62
|
task: {
|
|
49
63
|
record: "docs/phases/**/PHASE*_TASKS.md",
|
|
50
64
|
what: "a phase task checkbox newly ticked",
|
|
51
65
|
targetIs: "the task key, e.g. 5:12.1",
|
|
66
|
+
probe: { diff: "diff --git a/docs/phases/phase9/PHASE9_TASKS.md b/docs/phases/phase9/PHASE9_TASKS.md\n--- a/docs/phases/phase9/PHASE9_TASKS.md\n+++ b/docs/phases/phase9/PHASE9_TASKS.md\n@@ -1,1 +1,1 @@\n-- [ ] 1.1 first\n+- [x] 1.1 first\n" } as KindProbe,
|
|
52
67
|
},
|
|
53
68
|
phase: {
|
|
54
69
|
record: "docs/phases/**/PHASE*_TASKS.md",
|
|
55
70
|
what: "the last open checkbox in a phase document ticked",
|
|
56
71
|
targetIs: "the phase number, e.g. 5",
|
|
72
|
+
probe: { diff: "diff --git a/docs/phases/phase9/PHASE9_TASKS.md b/docs/phases/phase9/PHASE9_TASKS.md\n--- a/docs/phases/phase9/PHASE9_TASKS.md\n+++ b/docs/phases/phase9/PHASE9_TASKS.md\n@@ -1,1 +1,1 @@\n-- [ ] 1.2 second\n+- [x] 1.2 second\n", after: { phases: { "docs/phases/phase9/PHASE9_TASKS.md": "# Phase 9\n\n- [x] 1.1 first\n- [x] 1.2 second\n" } } } as KindProbe,
|
|
57
73
|
},
|
|
58
74
|
} as const;
|
|
59
75
|
|
package/src/tools/events.ts
CHANGED
|
@@ -78,8 +78,33 @@ function writeSubs(subs: Subscription[]): void {
|
|
|
78
78
|
* subscription with no last-evaluated mark has produced no evidence of
|
|
79
79
|
* anything, and "no events" is the same output a broken subscription gives.
|
|
80
80
|
*/
|
|
81
|
+
/**
|
|
82
|
+
* ⟨q-2b7d9f04⟩ — CAPABILITY, NOT LIVENESS. The `item` subscription read
|
|
83
|
+
* `health: ok — no events yet` for three days while the scanner's grammar
|
|
84
|
+
* could not produce its kind from any commit: the scan clock was fresh, the
|
|
85
|
+
* emitter list had an entry, and the field answered the easier question. The
|
|
86
|
+
* probe is registered by record-events.ts (which owns the grammar) and asks
|
|
87
|
+
* "can this kind be produced at all?"; a kind that cannot is an ERROR, however
|
|
88
|
+
* recently the scanner ran. Absent probe → no claim either way.
|
|
89
|
+
*/
|
|
90
|
+
let capabilityProbe: ((kind: SubKind) => boolean) | null = null;
|
|
91
|
+
export function setCapabilityProbe(fn: ((kind: SubKind) => boolean) | null): void {
|
|
92
|
+
capabilityProbe = fn;
|
|
93
|
+
}
|
|
94
|
+
export const kindCanFire = (kind: SubKind): boolean | null => (capabilityProbe ? capabilityProbe(kind) : null);
|
|
95
|
+
|
|
81
96
|
export function subscriptionHealth(s: Subscription): { level: "ok" | "error" | "unknown"; detail: string } {
|
|
82
97
|
const iso = (n: number) => new Date(n).toISOString();
|
|
98
|
+
// ⟨q-2b7d9f04⟩ — a kind the grammar cannot produce is not ok, scanned or not.
|
|
99
|
+
if (kindCanFire(s.kind) === false) {
|
|
100
|
+
return {
|
|
101
|
+
level: "error",
|
|
102
|
+
detail:
|
|
103
|
+
`CANNOT FIRE — the scanner's grammar produces no '${s.kind}' event from the kind's own probe, so this subscription is structurally unable to deliver ` +
|
|
104
|
+
`${s.lastScannedAt ? `(scanned ${iso(s.lastScannedAt)}: the clock is live, the capability is not)` : "(and has never been scanned)"}. ` +
|
|
105
|
+
"Never fired and cannot fire are different facts; this is the second.",
|
|
106
|
+
};
|
|
107
|
+
}
|
|
83
108
|
|
|
84
109
|
// THREE STATES, BECAUSE THERE ARE THREE FACTS. They were two, and the
|
|
85
110
|
// collapse cost a week: an `item` subscriber read `error — never evaluated`
|
|
@@ -258,10 +283,16 @@ export async function listSubscriptionsTool(args: { agentId?: string }) {
|
|
|
258
283
|
const all = readSubs();
|
|
259
284
|
const subs = args.agentId ? all.filter((s) => s.agentId === args.agentId) : all;
|
|
260
285
|
const rows = subs.map((s) => ({ ...s, health: subscriptionHealth(s) }));
|
|
261
|
-
const
|
|
286
|
+
const cannotFire = rows.filter((r) => r.health.level === "error" && /^CANNOT FIRE/.test(r.health.detail));
|
|
287
|
+
const neverEvaluated = rows.filter((r) => r.health.level === "error" && !/^CANNOT FIRE/.test(r.health.detail));
|
|
262
288
|
const undetermined = rows.filter((r) => r.health.level === "unknown");
|
|
263
289
|
return {
|
|
264
|
-
ok: neverEvaluated.length === 0,
|
|
290
|
+
ok: neverEvaluated.length === 0 && cannotFire.length === 0,
|
|
291
|
+
// ⟨q-2b7d9f04⟩ — capability per kind, beside each row's liveness.
|
|
292
|
+
capability: Object.fromEntries(EVENT_KIND_IDS.map((k) => [k, kindCanFire(k)])),
|
|
293
|
+
...(cannotFire.length
|
|
294
|
+
? { cannotFire: `${cannotFire.length} of ${rows.length} subscription(s) are to a kind the scanner CANNOT PRODUCE — they will never fire, however live the scan clock reads: ${[...new Set(cannotFire.map((r) => r.kind))].join(", ")}.` }
|
|
295
|
+
: {}),
|
|
265
296
|
// Population beside the verdict, always: "no subscriptions" and "none
|
|
266
297
|
// listed for you" are different claims.
|
|
267
298
|
population: { listed: rows.length, total: all.length },
|
package/src/tools/messaging.ts
CHANGED
|
@@ -13,6 +13,7 @@ import { promises as fsp } from "node:fs";
|
|
|
13
13
|
import { spawn, spawnSync } from "node:child_process";
|
|
14
14
|
import { fileURLToPath } from "node:url";
|
|
15
15
|
import { z } from "zod";
|
|
16
|
+
import { checkClosingLine } from "../closing-line.js";
|
|
16
17
|
import { readLog } from "./logwatch.js";
|
|
17
18
|
// The replay grammar is single-sourced in hooks/replay.mjs and shared with both
|
|
18
19
|
// pushers — see the note at the annotation site below. `hooks/` ships beside
|
|
@@ -82,6 +83,8 @@ import {
|
|
|
82
83
|
MAX_WAIT_MS,
|
|
83
84
|
isDecision,
|
|
84
85
|
} from "./shared.js";
|
|
86
|
+
import { isKnownHuman } from "../store.js";
|
|
87
|
+
import { verifyCommitCite } from "../commit-cite.js";
|
|
85
88
|
|
|
86
89
|
// ---------- send_message ----------
|
|
87
90
|
|
|
@@ -110,10 +113,18 @@ const decisionPayload = z.looseObject({
|
|
|
110
113
|
ifNoAction: z.string().min(1),
|
|
111
114
|
});
|
|
112
115
|
|
|
116
|
+
// ⟨q-dcbaf544⟩ — `gatedBy` is the seat that JUDGED when it is not the sender;
|
|
117
|
+
// `scribe` is the sender that TRANSCRIBED it. Fields, not prose: a name in
|
|
118
|
+
// prose is a mention, a name in a field is a position, and only the position
|
|
119
|
+
// survives a scanner. Authority does not move (David's ruling 2026-09-14):
|
|
120
|
+
// the roles that may emit `verdict` are unchanged; this is how a gate routed
|
|
121
|
+
// to any other seat reaches the record without lying about who gated.
|
|
113
122
|
const verdictPayload = z.looseObject({
|
|
114
123
|
result: z.enum(["pass", "fail"]),
|
|
115
124
|
headRefOid: z.string().min(1),
|
|
116
125
|
notes: z.string().optional(),
|
|
126
|
+
gatedBy: z.string().min(1).optional(),
|
|
127
|
+
scribe: z.string().min(1).optional(),
|
|
117
128
|
});
|
|
118
129
|
|
|
119
130
|
// Discriminated on `type`, so an unknown type is rejected outright while a
|
|
@@ -170,6 +181,21 @@ export async function checkRecordAuthority(
|
|
|
170
181
|
};
|
|
171
182
|
}
|
|
172
183
|
|
|
184
|
+
/** ⟨q-dcbaf544⟩ — `scribe`, when present, must be the sender; `gatedBy` may name anyone (that is its point). */
|
|
185
|
+
export function checkVerdictScribe(from: string, record: MessageRecord | undefined): { ok: true } | { ok: false; error: string } {
|
|
186
|
+
if (!record || record.type !== "verdict") return { ok: true };
|
|
187
|
+
const p = record.payload as { scribe?: string; gatedBy?: string } | undefined;
|
|
188
|
+
if (p?.scribe && p.scribe !== from) {
|
|
189
|
+
return {
|
|
190
|
+
ok: false,
|
|
191
|
+
error:
|
|
192
|
+
`verdict payload.scribe is '${p.scribe}' but the sender is '${from}' — the scribe is the seat that SENDS the record. ` +
|
|
193
|
+
`Put the seat that judged in payload.gatedBy and either omit scribe or set it to '${from}'.`,
|
|
194
|
+
};
|
|
195
|
+
}
|
|
196
|
+
return { ok: true };
|
|
197
|
+
}
|
|
198
|
+
|
|
173
199
|
// ---------- typed records obligatory (Phase 5.1 Task 12) ----------
|
|
174
200
|
|
|
175
201
|
// An untyped agent→agent message must not be able to EXIST. Enforced HERE, at
|
|
@@ -202,7 +228,10 @@ async function typedRecordCheck(args: {
|
|
|
202
228
|
// David-facing prose stays prose. An UNREGISTERED recipient is treated as an
|
|
203
229
|
// agent, not as a human: the safe reading of "I cannot tell" is the rule, and
|
|
204
230
|
// a human on this bus has a registry entry (that is how the pane is found).
|
|
205
|
-
|
|
231
|
+
// ⟨q-178878aa⟩ — and the human need not be a REGISTERED agent: a registry entry for a
|
|
232
|
+
// human is evicted after EVICT_MS (no heartbeat), so the exemption keys on the durable
|
|
233
|
+
// human set the server knows (humans.json + AGENT_COORD_HUMANS), read here, at send time.
|
|
234
|
+
if (args.to && (isHuman(reg[args.to]) || (await isKnownHuman(args.to)))) return { ok: true };
|
|
206
235
|
|
|
207
236
|
const sender = reg[args.from];
|
|
208
237
|
if (sender?.proseOnly) return { ok: true };
|
|
@@ -227,7 +256,8 @@ async function typedRecordCheck(args: {
|
|
|
227
256
|
`${guidance} ` +
|
|
228
257
|
`Types: decision · verdict · done · blocker · risk · fyi · action · go · scope; 'fyi' is the honest ` +
|
|
229
258
|
`catch-all — do not force a false 'decision'/'risk' to get past this. ` +
|
|
230
|
-
`Messages TO a human are exempt,
|
|
259
|
+
`Messages TO a human are exempt (a recipient registered with a human role, or an id the server knows as human: ` +
|
|
260
|
+
`humans.json / AGENT_COORD_HUMANS — see list_agents.humans), and an agent that cannot pick a type declares proseOnly:true at join.`,
|
|
231
261
|
};
|
|
232
262
|
}
|
|
233
263
|
|
|
@@ -245,6 +275,9 @@ export const sendMessageSchema = {
|
|
|
245
275
|
// unknown → store + warn) so the rejection shape matches identity-binding
|
|
246
276
|
// / unknown-recipient, not a schema throw. Existing records unchanged.
|
|
247
277
|
inReplyTo: z.string().optional(),
|
|
278
|
+
// ⟨q-cc0819dc⟩ — needed only for a `done` cited by COMMIT: the repository whose
|
|
279
|
+
// origin/main must carry the sha (one local git call, no network).
|
|
280
|
+
repo: z.string().optional(),
|
|
248
281
|
};
|
|
249
282
|
|
|
250
283
|
const MESSAGE_ID_RE = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i;
|
|
@@ -298,25 +331,47 @@ export async function sendMessageTool(args: {
|
|
|
298
331
|
kind?: "decision" | "status" | "chatter";
|
|
299
332
|
record?: MessageRecord;
|
|
300
333
|
inReplyTo?: string;
|
|
334
|
+
repo?: string;
|
|
301
335
|
}) {
|
|
302
336
|
// Record authority first — a sender who may not emit this type is refused
|
|
303
337
|
// before any other check runs, so a rejected record writes nothing anywhere.
|
|
304
338
|
const authority = await checkRecordAuthority(args.from, args.record);
|
|
305
339
|
if (!authority.ok) return { ok: false as const, error: authority.error };
|
|
340
|
+
// ⟨q-dcbaf544⟩ — a scribe is the sender by definition; a verdict whose
|
|
341
|
+
// `scribe` names someone else has its two positions crossed and would be
|
|
342
|
+
// attributed wrongly by every reader. Refused as a value, nothing written.
|
|
343
|
+
const scribeCheck = checkVerdictScribe(args.from, args.record);
|
|
344
|
+
if (!scribeCheck.ok) return { ok: false as const, error: scribeCheck.error };
|
|
306
345
|
|
|
307
346
|
// A `done` must cite the work it claims. Presence and shape only — resolving
|
|
308
347
|
// the ref against gh/git is a consumer's job, and the send path makes no
|
|
309
348
|
// network calls. Rejected as a value, not a throw, mirroring the
|
|
310
349
|
// identity-binding rejection in src/server.ts.
|
|
311
350
|
if (args.record?.type === "done") {
|
|
312
|
-
|
|
313
|
-
|
|
351
|
+
// ⟨q-fee7239f⟩ — the same check that reads the closing citation reads the
|
|
352
|
+
// closing GRAMMAR: a merge closing may not assert a deletion it has not
|
|
353
|
+
// read. Refused as a value, naming the two accepted forms; nothing written.
|
|
354
|
+
const closing = checkClosingLine(String(args.text ?? ""));
|
|
355
|
+
if (!closing.ok) return { ok: false as const, error: closing.error };
|
|
356
|
+
const cites = args.record.cites ?? [];
|
|
357
|
+
const hasPr = cites.some((c) => c.kind === "pr" && c.ref.trim().length > 0);
|
|
358
|
+
const commitCites = cites.filter((c) => c.kind === "commit");
|
|
359
|
+
if (!hasPr && commitCites.length === 0) {
|
|
314
360
|
return {
|
|
315
361
|
ok: false as const,
|
|
316
362
|
error:
|
|
317
|
-
"a 'done' record must carry at least one {kind:'pr'} citation — an uncited DONE is an unverifiable claim"
|
|
363
|
+
"a 'done' record must carry at least one {kind:'pr'} citation — an uncited DONE is an unverifiable claim. " +
|
|
364
|
+
"Work that has no PR by rule (docs pushed straight to the shared branch) cites {kind:'commit', ref:<full 40-hex sha>} " +
|
|
365
|
+
"and passes `repo`, the repository whose origin/main carries it.",
|
|
318
366
|
};
|
|
319
367
|
}
|
|
368
|
+
// ⟨q-cc0819dc⟩ — a COMMIT cite satisfies a `done` only when the commit is real and on the
|
|
369
|
+
// shared branch: every commit cite is verified, so a fabricated or short sha is refused
|
|
370
|
+
// by name and nothing is written. A PR cite beside it does not excuse a bad commit cite.
|
|
371
|
+
for (const c of commitCites) {
|
|
372
|
+
const v = verifyCommitCite(c.ref, args.repo);
|
|
373
|
+
if (!v.ok) return { ok: false as const, error: `a 'done' record cited by commit was refused — ${v.why}` };
|
|
374
|
+
}
|
|
320
375
|
}
|
|
321
376
|
|
|
322
377
|
// `text` is what every consumer reads, so it must exist. The author's
|
|
@@ -405,7 +460,9 @@ export async function sendMessageTool(args: {
|
|
|
405
460
|
const reg = await readJson<AgentRegistry>(AGENTS_FILE, {});
|
|
406
461
|
const recipientWarning = reg[args.to]
|
|
407
462
|
? undefined
|
|
408
|
-
:
|
|
463
|
+
: (await isKnownHuman(args.to))
|
|
464
|
+
? undefined // a human is not an agent and is not expected to be registered; the inbox is theirs to read
|
|
465
|
+
: `recipient '${args.to}' is not a registered agent — message stored in their inbox but no one may be listening`;
|
|
409
466
|
const warning = [typedWarning, recipientWarning, replyWarning, slashWarning].filter(Boolean).join("; ") || undefined;
|
|
410
467
|
return { ok: true, id: msg.id, target, room: undefined, warning };
|
|
411
468
|
}
|
|
@@ -159,8 +159,28 @@ export function eventsFromCommittedChange(
|
|
|
159
159
|
// first PR it saw, which is a wrong claim about fourteen of them, and a wrong
|
|
160
160
|
// claim on this bus is worse than a missing one.
|
|
161
161
|
const removedQueue = linesWhere(byFile, isQueue, "removed");
|
|
162
|
+
// ⟨q-d527f435⟩ — A REMOVED LINE IS NOT A DEPARTED ITEM. The scan diffed text,
|
|
163
|
+
// so the aide's in-place rewrite of q-a1c9d4e7 (70c730a) read as a departure
|
|
164
|
+
// and was reported unattributed. The question is whether the ⟨q-…⟩ id is
|
|
165
|
+
// still present at the end of the range; three cases, never conflated:
|
|
166
|
+
// id still present in the ADDED lines → an EDIT — no event, not unattributed
|
|
167
|
+
// id absent → a DEPARTURE — attributed or reported
|
|
168
|
+
// id CHANGED (same text, new id) → a RE-ID — reported as such (⟨q-1c4f8ae3⟩)
|
|
169
|
+
const addedQueue = linesWhere(byFile, isQueue, "added");
|
|
170
|
+
// Only rows still OPEN count as present: the house style closes an item by
|
|
171
|
+
// flipping `[ ]` → `[x]` in place, and that row is closed, not edited.
|
|
172
|
+
const addedItems = (queueItemsOf(parseWorkDoc(`## Queue\n${addedQueue.join("\n")}\n`)) as Array<{ id?: string; text?: string; done?: boolean }>).filter((i) => i.id && !i.done);
|
|
173
|
+
const addedIds = new Set(addedItems.map((i) => i.id!));
|
|
174
|
+
const reIdentified: { from: string; to: string }[] = [];
|
|
162
175
|
const removedItems = (queueItemsOf(parseWorkDoc(`## Queue\n${removedQueue.join("\n")}\n`)) as Array<{ id?: string; text?: string }>)
|
|
163
|
-
.filter((i) => i.id)
|
|
176
|
+
.filter((i) => i.id)
|
|
177
|
+
.filter((i) => {
|
|
178
|
+
if (addedIds.has(i.id!)) return false; // edited in place: still on the queue
|
|
179
|
+
const twin = addedItems.find((a) => a.id !== i.id && norm(a.text ?? "") === norm(i.text ?? "") && norm(i.text ?? ""));
|
|
180
|
+
if (twin) { reIdentified.push({ from: i.id!, to: twin.id! }); return false; }
|
|
181
|
+
return true;
|
|
182
|
+
});
|
|
183
|
+
lastReIdentifiedItems = reIdentified;
|
|
164
184
|
// An entry qualifies to CLOSE an item if it cites anything resolvable — a PR,
|
|
165
185
|
// or an `@sha` commit. `land` requires a PR by rule; the scan reads what the
|
|
166
186
|
// record actually says, and a commit-cited entry is still the record stating
|
|
@@ -168,7 +188,24 @@ export function eventsFromCommittedChange(
|
|
|
168
188
|
const citedEntries = newEntries.filter((e) => prRefsIn(e.ref).length > 0 || hasCommitRef(e.ref ?? ""));
|
|
169
189
|
const unattributed: string[] = [];
|
|
170
190
|
|
|
191
|
+
// ⟨q-2b7d9f04⟩ — THE LINK THE MARKDOWN DOES CARRY. `land` writes the item's
|
|
192
|
+
// id in LEADING position on the DONE line (#286), and a hand-written line in
|
|
193
|
+
// land's format carries it the same way. An entry that begins with ⟨q-…⟩ IS
|
|
194
|
+
// the record of that item closing — read by position, never guessed. The
|
|
195
|
+
// `item` subscription never fired in three days because this read was
|
|
196
|
+
// missing while the paragraph above said no link existed.
|
|
197
|
+
const attributedByLeadingId = new Set<string>();
|
|
198
|
+
for (const entry of citedEntries) {
|
|
199
|
+
const id = leadingItemIdOf(entry.text);
|
|
200
|
+
if (!id || attributedByLeadingId.has(id)) continue;
|
|
201
|
+
const ref = prRefsIn(entry.ref)[0] ?? entry.ref?.trim();
|
|
202
|
+
if (!ref) continue;
|
|
203
|
+
attributedByLeadingId.add(id);
|
|
204
|
+
events.push({ kind: "item", target: id, ref, summary: entry.text?.slice(0, 120) ?? id });
|
|
205
|
+
}
|
|
206
|
+
|
|
171
207
|
for (const item of removedItems) {
|
|
208
|
+
if (attributedByLeadingId.has(item.id!)) continue;
|
|
172
209
|
const itemText = norm(item.text ?? "");
|
|
173
210
|
let entry = itemText
|
|
174
211
|
? citedEntries.find((e) => {
|
|
@@ -241,6 +278,36 @@ export function eventsFromCommittedChange(
|
|
|
241
278
|
* different facts, and only one of them needs a human.
|
|
242
279
|
*/
|
|
243
280
|
export let lastUnattributedItems: string[] = [];
|
|
281
|
+
/** ⟨q-d527f435⟩ — removed rows whose TEXT reappeared under a NEW id in the same change: re-identified, not departed. */
|
|
282
|
+
export let lastReIdentifiedItems: { from: string; to: string }[] = [];
|
|
283
|
+
/** ⟨q-2b7d9f04⟩ — the ⟨q-…⟩ id in LEADING position on a DONE entry's text (bold allowed), or null. Position, not occurrence: an id quoted mid-sentence is a mention. */
|
|
284
|
+
export function leadingItemIdOf(text: string | undefined): string | null {
|
|
285
|
+
const m = /^\s*(?:\*\*)?⟨(q-[0-9a-f]{8})⟩/.exec(String(text ?? ""));
|
|
286
|
+
return m ? m[1]! : null;
|
|
287
|
+
}
|
|
288
|
+
/**
|
|
289
|
+
* ⟨q-2b7d9f04⟩ — CAN THIS KIND FIRE AT ALL? Runs the scanner over the kind's
|
|
290
|
+
* own probe (event-kinds.ts) and asks whether an event of that kind comes out.
|
|
291
|
+
* Health reads this as CAPABILITY, beside the scan clock's LIVENESS: a kind
|
|
292
|
+
* whose probe yields nothing cannot be produced by the grammar, and a
|
|
293
|
+
* subscription to it is not `ok` however recently the scanner ran.
|
|
294
|
+
*/
|
|
295
|
+
export function kindCapability(kind: SubKind): { capable: boolean; produced: number; why: string } {
|
|
296
|
+
const probe = (EVENT_KINDS[kind] as { probe?: { diff: string; after?: { done?: string; phases?: Record<string, string> } } }).probe;
|
|
297
|
+
if (!probe) return { capable: false, produced: 0, why: `'${kind}' ships no probe — its capability cannot be shown` };
|
|
298
|
+
const saved = lastUnattributedItems;
|
|
299
|
+
let produced = 0;
|
|
300
|
+
try {
|
|
301
|
+
produced = eventsFromCommittedChange(probe.diff, probe.after ?? {}).filter((e) => e.kind === kind).length;
|
|
302
|
+
} finally {
|
|
303
|
+
lastUnattributedItems = saved;
|
|
304
|
+
}
|
|
305
|
+
return produced > 0
|
|
306
|
+
? { capable: true, produced, why: `the scanner produced ${produced} '${kind}' event(s) from the kind's own probe` }
|
|
307
|
+
: { capable: false, produced: 0, why: `the scanner produced NO '${kind}' event from the kind's own probe — this kind CANNOT FIRE on any commit, whatever the scan clock says` };
|
|
308
|
+
}
|
|
309
|
+
export const kindCapabilities = (): Record<SubKind, ReturnType<typeof kindCapability>> =>
|
|
310
|
+
Object.fromEntries((Object.keys(EVENT_KINDS) as SubKind[]).map((k) => [k, kindCapability(k)])) as Record<SubKind, ReturnType<typeof kindCapability>>;
|
|
244
311
|
|
|
245
312
|
/** `git` in a repo, returning "" rather than throwing — a scan is read-only. */
|
|
246
313
|
export function git(repo: string, args: string[]): string {
|
|
@@ -257,7 +324,9 @@ import { existsSync, readFileSync, writeFileSync, mkdirSync } from "node:fs";
|
|
|
257
324
|
import path from "node:path";
|
|
258
325
|
import { z } from "zod";
|
|
259
326
|
import { ROOT } from "../store.js";
|
|
260
|
-
import { readSubs, evaluate, commitEvaluation, eventIsDerived, markScanned } from "./events.js";
|
|
327
|
+
import { readSubs, evaluate, commitEvaluation, eventIsDerived, markScanned, setCapabilityProbe } from "./events.js";
|
|
328
|
+
// ⟨q-2b7d9f04⟩ — health consults the scanner's grammar through this hook (events.ts cannot import this module: the kinds are its leaf and record-events imports events).
|
|
329
|
+
setCapabilityProbe((kind) => kindCapability(kind).capable);
|
|
261
330
|
|
|
262
331
|
/**
|
|
263
332
|
* The watermark: the last commit whose record change has been turned into
|
|
@@ -333,6 +402,7 @@ export async function scanRecordEventsTool(args: { repo: string; since?: string;
|
|
|
333
402
|
|
|
334
403
|
const candidates = eventsFromCommittedChange(diff, { done: doneText, phases });
|
|
335
404
|
const unattributed = [...lastUnattributedItems];
|
|
405
|
+
const reIdentified = [...lastReIdentifiedItems];
|
|
336
406
|
|
|
337
407
|
// Every event is checked against the record AS IT NOW STANDS (6.2). A change
|
|
338
408
|
// that has since been reverted produces a candidate the record no longer
|
|
@@ -378,11 +448,14 @@ export async function scanRecordEventsTool(args: { repo: string; since?: string;
|
|
|
378
448
|
? {
|
|
379
449
|
unattributedItems: unattributed,
|
|
380
450
|
unattributedNote:
|
|
381
|
-
`${unattributed.length} queue item(s) left docs/QUEUE.md in this range
|
|
382
|
-
`
|
|
383
|
-
`
|
|
451
|
+
`${unattributed.length} queue item(s) left docs/QUEUE.md in this range with NO done entry carrying their id in leading position ` +
|
|
452
|
+
`(\`- [x] ⟨q-…⟩ …\`, the form land writes) and no unambiguous pairing. They emitted NOTHING rather than a guess: a DONE line ` +
|
|
453
|
+
`without a leading id names no item. "Closed by an unknown entry" and "not closed" are different facts and this is the first.`,
|
|
384
454
|
}
|
|
385
455
|
: {}),
|
|
456
|
+
...(reIdentified.length
|
|
457
|
+
? { reIdentifiedItems: reIdentified, reIdentifiedNote: `${reIdentified.length} queue row(s) reappeared under a NEW id with the same text — re-identified, neither departed nor closed (⟨q-1c4f8ae3⟩'s subject).` }
|
|
458
|
+
: {}),
|
|
386
459
|
...(args.write
|
|
387
460
|
? { watermark: head }
|
|
388
461
|
: {
|