agent-coord-mcp 0.26.20 → 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 +177 -0
- package/dist/gated-head.js.map +1 -0
- package/dist/server-spread.js +195 -0
- package/dist/server-spread.js.map +1 -0
- package/dist/server.js +4 -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/queue-write.js +431 -0
- package/dist/tools/queue-write.js.map +1 -0
- package/dist/tools/record-events.js +85 -5
- package/dist/tools/record-events.js.map +1 -1
- package/dist/tools/records.js +384 -33
- 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/tree-provenance.js +107 -0
- package/dist/tools/tree-provenance.js.map +1 -0
- 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 +236 -0
- package/src/server-spread.ts +233 -0
- package/src/server.ts +10 -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/queue-write.ts +485 -0
- package/src/tools/record-events.ts +78 -5
- package/src/tools/records.ts +423 -31
- 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/tree-provenance.ts +136 -0
- 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
|
}
|