agent-coord-mcp 0.26.11 → 0.26.13
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/server.js +87 -3
- package/dist/server.js.map +1 -1
- package/dist/tools/away.js +180 -59
- package/dist/tools/away.js.map +1 -1
- package/dist/tools/event-kinds.js +39 -0
- package/dist/tools/event-kinds.js.map +1 -0
- package/dist/tools/events.js +7 -1
- package/dist/tools/events.js.map +1 -1
- package/dist/tools/index.js +2 -0
- package/dist/tools/index.js.map +1 -1
- package/dist/tools/record-events.js +375 -0
- package/dist/tools/record-events.js.map +1 -0
- package/dist/tools/registry.js +10 -1
- package/dist/tools/registry.js.map +1 -1
- package/dist/tools/stall.js +100 -8
- package/dist/tools/stall.js.map +1 -1
- package/dist/tools/transport.js +19 -1
- package/dist/tools/transport.js.map +1 -1
- package/package.json +3 -3
- package/scripts/check-test-count.mjs +1 -1
- package/src/server.ts +97 -4
- package/src/tools/away.ts +221 -55
- package/src/tools/event-kinds.ts +60 -0
- package/src/tools/events.ts +8 -4
- package/src/tools/index.ts +2 -0
- package/src/tools/record-events.ts +390 -0
- package/src/tools/registry.ts +10 -1
- package/src/tools/stall.ts +104 -11
- package/src/tools/transport.ts +19 -1
|
@@ -0,0 +1,390 @@
|
|
|
1
|
+
/*
|
|
2
|
+
* Phase 5.1 Task 9 — the four subscribable kinds all emit, and the TRIGGER is
|
|
3
|
+
* the record's COMMITTED CHANGE rather than the `land` verb (David's ruling,
|
|
4
|
+
* option (b), 2026-08-30).
|
|
5
|
+
*
|
|
6
|
+
* WHY NOT (a), "emit from the verbs": it bets on every fleet adopting the
|
|
7
|
+
* workflow layer, and the only evidence available says they do not — a consumer fleet called
|
|
8
|
+
* `claim`/`land`/`merge`/`next_unblocked`/`rotate`/`set_halt` ZERO times in a day
|
|
9
|
+
* of heavy use. A `pr` subscription tested against a real PR came back
|
|
10
|
+
* `health: error — never evaluated`, and the fleet unsubscribed. Kinds that can
|
|
11
|
+
* be subscribed to and can never fire are a permanent, honestly-reported error.
|
|
12
|
+
*
|
|
13
|
+
* WHAT (b) CHANGES, AND WHAT IT DOES NOT: the record stays the source — only the
|
|
14
|
+
* trigger moves from the verb to the change. That still satisfies 6.2 ("events
|
|
15
|
+
* derived from the record, never parallel to it"), because the derivation still
|
|
16
|
+
* reads the record; it just no longer requires that a particular verb performed
|
|
17
|
+
* the write. THE POINT IS THAT A HAND-EDIT BECOMES A FIRST-CLASS CAUSE rather
|
|
18
|
+
* than an invisible one, which is what fleets actually do: they merge with `gh`
|
|
19
|
+
* and edit `docs/DONE.md` by hand. `land` keeps emitting, as ONE WRITER AMONG
|
|
20
|
+
* SEVERAL rather than as the gate — the idempotency key makes the overlap safe,
|
|
21
|
+
* since both paths derive the same key from the same event.
|
|
22
|
+
*
|
|
23
|
+
* THE COMMIT IS THE BOUNDARY, not the working tree. An uncommitted edit is not
|
|
24
|
+
* yet a record: it can be reverted, rebased away, or never pushed, and a
|
|
25
|
+
* notification for work that then vanishes is worse than a late one.
|
|
26
|
+
*/
|
|
27
|
+
import { execFileSync } from "node:child_process";
|
|
28
|
+
import { newlyTickedInDiff, parseWorkDoc, queueItemsOf, doneEntriesOf } from "@davidbalzan/groundwork-seam";
|
|
29
|
+
import { EVENT_KINDS, type RecordEvent, type SubKind } from "./event-kinds.js";
|
|
30
|
+
export { EVENT_KINDS, EVENT_KIND_IDS } from "./event-kinds.js";
|
|
31
|
+
export type { RecordEvent, SubKind } from "./event-kinds.js";
|
|
32
|
+
|
|
33
|
+
|
|
34
|
+
/**
|
|
35
|
+
* One pass over the diff → per-file added and removed lines.
|
|
36
|
+
*
|
|
37
|
+
* Per FILE, not per predicate: the phase rule needs to know WHICH document a
|
|
38
|
+
* tick came from, and a flat "all added lines" view cannot answer that. It
|
|
39
|
+
* emitted `phase 5 complete` off a tick in the phase 5.1 document on the first
|
|
40
|
+
* real-data run.
|
|
41
|
+
*/
|
|
42
|
+
export function diffByFile(diff: string): Map<string, { added: string[]; removed: string[] }> {
|
|
43
|
+
const out = new Map<string, { added: string[]; removed: string[] }>();
|
|
44
|
+
let cur: { added: string[]; removed: string[] } | null = null;
|
|
45
|
+
for (const line of String(diff ?? "").split("\n")) {
|
|
46
|
+
if (line.startsWith("diff --git ")) { cur = null; continue; }
|
|
47
|
+
if (line.startsWith("+++ b/")) {
|
|
48
|
+
const p = line.slice(6).trim();
|
|
49
|
+
if (p === "/dev/null") { cur = null; continue; }
|
|
50
|
+
cur = out.get(p) ?? { added: [], removed: [] };
|
|
51
|
+
out.set(p, cur);
|
|
52
|
+
continue;
|
|
53
|
+
}
|
|
54
|
+
if (line.startsWith("--- a/") || line.startsWith("@@")) continue;
|
|
55
|
+
if (!cur) continue;
|
|
56
|
+
if (line.startsWith("+")) cur.added.push(line.slice(1));
|
|
57
|
+
else if (line.startsWith("-")) cur.removed.push(line.slice(1));
|
|
58
|
+
}
|
|
59
|
+
return out;
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
const isDone = (p: string) => /(^|\/)DONE\.md$/.test(p);
|
|
63
|
+
const isQueue = (p: string) => /(^|\/)QUEUE\.md$/.test(p);
|
|
64
|
+
const isPhaseDoc = (p: string) => /PHASE[^/]*TASKS\.md$/i.test(p);
|
|
65
|
+
|
|
66
|
+
const linesWhere = (byFile: ReturnType<typeof diffByFile>, match: (p: string) => boolean, side: "added" | "removed") =>
|
|
67
|
+
[...byFile.entries()].filter(([p]) => match(p)).flatMap(([, v]) => v[side]);
|
|
68
|
+
|
|
69
|
+
/**
|
|
70
|
+
* A ref that actually identifies a pull request.
|
|
71
|
+
*
|
|
72
|
+
* MEASURED ON REAL DATA, and this is why the check exists: the done-entry
|
|
73
|
+
* parser splits on the LAST ` — `, so an entry whose own text contains an em
|
|
74
|
+
* dash yields a "ref" of `aide-verified, coordinator-closed`. Emitting that as
|
|
75
|
+
* a `pr` event would put a target on the bus that names no PR, and a
|
|
76
|
+
* subscription could never match it — a silent, permanent miss dressed as a
|
|
77
|
+
* delivery. Parsing correctly is not the same as the parse MEANING what you
|
|
78
|
+
* assumed.
|
|
79
|
+
*/
|
|
80
|
+
const PR_REF = /^(?:[\w.-]+\/[\w.-]+#\d+|#\d+|https:\/\/github\.com\/[\w.-]+\/[\w.-]+\/pull\/\d+)$/;
|
|
81
|
+
|
|
82
|
+
|
|
83
|
+
/**
|
|
84
|
+
* EVERY pr ref in a citation field, not "is the whole field one ref".
|
|
85
|
+
*
|
|
86
|
+
* MEASURED, and it is the mirror image of the garbage-ref defect: an anchored
|
|
87
|
+
* whole-field match rejects `owner/repo#170, #173` — a perfectly good citation
|
|
88
|
+
* that happens to name TWO PRs. A real closure was reported unattributed for
|
|
89
|
+
* exactly this, and a pair of PRs is the repo's normal house style for work
|
|
90
|
+
* that landed across two.
|
|
91
|
+
*
|
|
92
|
+
* Tightening to reject prose was right. Rejecting a real citation because it
|
|
93
|
+
* cites more than one thing was not, and from inside a function that only asks
|
|
94
|
+
* "does the field EQUAL a ref" the two failures are indistinguishable.
|
|
95
|
+
*
|
|
96
|
+
* A BARE `#N` IS EXPANDED against a qualified ref in the same field: in
|
|
97
|
+
* `owner/repo#170, #173` the second plainly means the same repository, and
|
|
98
|
+
* emitting a bare `#173` would be an under-qualified target that no
|
|
99
|
+
* subscription written against `owner/repo#173` could ever match.
|
|
100
|
+
*/
|
|
101
|
+
export function prRefsIn(field: string | undefined): string[] {
|
|
102
|
+
const text = String(field ?? "");
|
|
103
|
+
const out: string[] = [];
|
|
104
|
+
for (const m of text.matchAll(/(?:[\w.-]+\/[\w.-]+#\d+|https:\/\/github\.com\/[\w.-]+\/[\w.-]+\/pull\/\d+)/g)) out.push(m[0]);
|
|
105
|
+
const owner = out[0]?.match(/^([\w.-]+\/[\w.-]+)#/)?.[1];
|
|
106
|
+
for (const m of text.matchAll(/(?:^|[\s,\u00b7;])#(\d+)\b/g)) {
|
|
107
|
+
const ref = owner ? `${owner}#${m[1]}` : `#${m[1]}`;
|
|
108
|
+
if (!out.includes(ref)) out.push(ref);
|
|
109
|
+
}
|
|
110
|
+
return out;
|
|
111
|
+
}
|
|
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
|
+
/** Queue and done text, compared for the pairing below. */
|
|
116
|
+
const norm = (s: string) => String(s).toLowerCase().replace(/[`*_]/g, "").replace(/\s+/g, " ").trim();
|
|
117
|
+
|
|
118
|
+
/**
|
|
119
|
+
* Events implied by one committed change.
|
|
120
|
+
*
|
|
121
|
+
* `after` carries the documents AS THEY NOW STAND — every event is checked
|
|
122
|
+
* against them by the caller (`eventIsDerived`), so a diff that has since been
|
|
123
|
+
* reverted cannot produce an event that outlives the record.
|
|
124
|
+
*/
|
|
125
|
+
export function eventsFromCommittedChange(
|
|
126
|
+
diff: string,
|
|
127
|
+
after: { done?: string; phases?: Record<string, string> } = {},
|
|
128
|
+
): RecordEvent[] {
|
|
129
|
+
const events: RecordEvent[] = [];
|
|
130
|
+
const unattributedItems: string[] = [];
|
|
131
|
+
const byFile = diffByFile(diff);
|
|
132
|
+
|
|
133
|
+
// ── pr: a new DONE.md entry naming a PR ───────────────────────────────────
|
|
134
|
+
// Parsed, never regexed off the raw line: the glyph contract (` — ` U+2014,
|
|
135
|
+
// ` · ` U+00B7) is exact, and a hand-written entry using a plain hyphen must
|
|
136
|
+
// NOT quietly become an event with a mangled ref. It fails to parse, and a
|
|
137
|
+
// line that does not parse is not a record entry.
|
|
138
|
+
const addedDone = linesWhere(byFile, isDone, "added");
|
|
139
|
+
const newEntries = doneEntriesOf(parseWorkDoc(`## Done\n${addedDone.join("\n")}\n`)) as Array<{ ref?: string; text?: string }>;
|
|
140
|
+
|
|
141
|
+
for (const entry of newEntries) {
|
|
142
|
+
// ONE EVENT PER PR NAMED. An entry closing work that spanned two PRs should
|
|
143
|
+
// wake a subscriber to either; collapsing to the first makes the second
|
|
144
|
+
// silently unwatchable.
|
|
145
|
+
for (const ref of prRefsIn(entry.ref)) {
|
|
146
|
+
events.push({ kind: "pr", target: ref, ref, summary: entry.text?.slice(0, 120) || ref });
|
|
147
|
+
}
|
|
148
|
+
}
|
|
149
|
+
|
|
150
|
+
// ── item: a queue item that closed ────────────────────────────────────────
|
|
151
|
+
//
|
|
152
|
+
// ATTRIBUTION IS THE WHOLE PROBLEM, and the markdown does not carry it. A
|
|
153
|
+
// queue item and the DONE entry that closes it share no id and, on real data,
|
|
154
|
+
// no wording either: the item states what to do and the entry states what was
|
|
155
|
+
// done. `land` only knows because its caller passed `queueItemId`.
|
|
156
|
+
//
|
|
157
|
+
// So: pair on text when the texts DO correspond, otherwise pair only when the
|
|
158
|
+
// commit is unambiguous — exactly one item removed and exactly one entry
|
|
159
|
+
// added. Anything else is reported as unattributed rather than guessed. The
|
|
160
|
+
// naive version attributed all 15 items removed in one real commit to the
|
|
161
|
+
// first PR it saw, which is a wrong claim about fourteen of them, and a wrong
|
|
162
|
+
// claim on this bus is worse than a missing one.
|
|
163
|
+
const removedQueue = linesWhere(byFile, isQueue, "removed");
|
|
164
|
+
const removedItems = (queueItemsOf(parseWorkDoc(`## Queue\n${removedQueue.join("\n")}\n`)) as Array<{ id?: string; text?: string }>)
|
|
165
|
+
.filter((i) => i.id);
|
|
166
|
+
// An entry qualifies to CLOSE an item if it cites anything resolvable — a PR,
|
|
167
|
+
// or an `@sha` commit. `land` requires a PR by rule; the scan reads what the
|
|
168
|
+
// record actually says, and a commit-cited entry is still the record stating
|
|
169
|
+
// that the item closed.
|
|
170
|
+
const citedEntries = newEntries.filter((e) => prRefsIn(e.ref).length > 0 || COMMIT_REF.test(e.ref ?? ""));
|
|
171
|
+
const unattributed: string[] = [];
|
|
172
|
+
|
|
173
|
+
for (const item of removedItems) {
|
|
174
|
+
const itemText = norm(item.text ?? "");
|
|
175
|
+
let entry = itemText
|
|
176
|
+
? citedEntries.find((e) => {
|
|
177
|
+
const t = norm(e.text ?? "");
|
|
178
|
+
return t && (t === itemText || t.startsWith(itemText) || itemText.startsWith(t));
|
|
179
|
+
})
|
|
180
|
+
: undefined;
|
|
181
|
+
// The unambiguous-commit case: one out, one in. This is the shape the fleet
|
|
182
|
+
// actually commits ("close its queue item"), and it is the case the aide's
|
|
183
|
+
// dead `item` subscriptions need.
|
|
184
|
+
if (!entry && removedItems.length === 1 && citedEntries.length === 1) entry = citedEntries[0];
|
|
185
|
+
if (!entry) {
|
|
186
|
+
unattributed.push(item.id!);
|
|
187
|
+
continue;
|
|
188
|
+
}
|
|
189
|
+
// The FIRST pr ref is the item's, falling back to the commit citation: an
|
|
190
|
+
// item closes once, so it gets one ref, and it is the one a reader follows.
|
|
191
|
+
const ref = prRefsIn(entry.ref)[0] ?? entry.ref?.trim();
|
|
192
|
+
if (!ref) {
|
|
193
|
+
unattributed.push(item.id!);
|
|
194
|
+
continue;
|
|
195
|
+
}
|
|
196
|
+
events.push({ kind: "item", target: item.id!, ref, summary: entry.text?.slice(0, 120) ?? item.id! });
|
|
197
|
+
}
|
|
198
|
+
if (unattributed.length) unattributedItems.push(...unattributed);
|
|
199
|
+
|
|
200
|
+
// ── task: checkbox transitions ────────────────────────────────────────────
|
|
201
|
+
// `newlyTickedInDiff` requires BOTH a removed open box and an added ticked one
|
|
202
|
+
// for the same id, so a moved or reformatted line cannot manufacture a
|
|
203
|
+
// completion — that rule is the seam's and is reused rather than re-derived.
|
|
204
|
+
const ticked = [...newlyTickedInDiff(diff)];
|
|
205
|
+
const tickedLines = linesWhere(byFile, isPhaseDoc, "added");
|
|
206
|
+
for (const key of ticked) {
|
|
207
|
+
const id = key.split(":")[1] ?? "";
|
|
208
|
+
// Ref is the ticked LINE, not the bare id: `12.1` appears in prose all over
|
|
209
|
+
// a phase doc, so a bare id would pass the derivation check against a
|
|
210
|
+
// document that never ticked anything.
|
|
211
|
+
const line = tickedLines.find((l) => new RegExp(`^\\s*-\\s*\\[x\\]\\s*\\*{0,2}${id.replace(".", "\\.")}\\b`, "i").test(l));
|
|
212
|
+
if (!line) continue;
|
|
213
|
+
events.push({ kind: "task", target: key, ref: line.trim(), summary: line.trim().slice(0, 120) });
|
|
214
|
+
}
|
|
215
|
+
|
|
216
|
+
// ── phase: the last open box in ONE document ──────────────────────────────
|
|
217
|
+
// Keyed on the FILE, both for "is it complete" and for "did this change close
|
|
218
|
+
// it". `newlyTickedInDiff` keys ticks by the integer in the path, so
|
|
219
|
+
// `phase5.1` and `phase5` collapse to the same `5:` — the first real-data run
|
|
220
|
+
// announced PHASE 5 COMPLETE on the strength of a tick in the 5.1 document.
|
|
221
|
+
// A false completion is worse than a missing one: it closes a phase nobody
|
|
222
|
+
// finished.
|
|
223
|
+
for (const [rel, text] of Object.entries(after.phases ?? {})) {
|
|
224
|
+
if (!isPhaseDoc(rel)) continue;
|
|
225
|
+
const boxes = [...String(text).matchAll(/^\s*-\s*\[( |x)\]\s*\*{0,2}(\d+\.\d+[a-z]?)\b/gim)];
|
|
226
|
+
if (!boxes.length || boxes.some((m) => m[1] !== "x")) continue;
|
|
227
|
+
// This commit must have ticked a box IN THIS FILE.
|
|
228
|
+
const closedHere = (byFile.get(rel)?.added ?? []).some((l) => /^\s*-\s*\[x\]\s*\*{0,2}\d+\.\d+/.test(l));
|
|
229
|
+
if (!closedHere) continue;
|
|
230
|
+
const phase = /phase(\d+(?:\.\d+)?)/i.exec(rel)?.[1];
|
|
231
|
+
if (!phase) continue;
|
|
232
|
+
events.push({ kind: "phase", target: phase, ref: boxes[boxes.length - 1]![0].trim(), summary: `phase ${phase} — every task box ticked` });
|
|
233
|
+
}
|
|
234
|
+
|
|
235
|
+
lastUnattributedItems = unattributedItems;
|
|
236
|
+
return events;
|
|
237
|
+
}
|
|
238
|
+
|
|
239
|
+
/**
|
|
240
|
+
* Queue item ids removed in the last scanned change that could NOT be tied to a
|
|
241
|
+
* done entry. Reported by `scan_record_events` rather than dropped: "we saw
|
|
242
|
+
* items close and could not say which PR closed them" and "nothing closed" are
|
|
243
|
+
* different facts, and only one of them needs a human.
|
|
244
|
+
*/
|
|
245
|
+
export let lastUnattributedItems: string[] = [];
|
|
246
|
+
|
|
247
|
+
/** `git` in a repo, returning "" rather than throwing — a scan is read-only. */
|
|
248
|
+
export function git(repo: string, args: string[]): string {
|
|
249
|
+
try {
|
|
250
|
+
return execFileSync("git", args, { cwd: repo, encoding: "utf8", maxBuffer: 32 * 1024 * 1024 });
|
|
251
|
+
} catch {
|
|
252
|
+
return "";
|
|
253
|
+
}
|
|
254
|
+
}
|
|
255
|
+
|
|
256
|
+
/* ── the verb ──────────────────────────────────────────────────────────────── */
|
|
257
|
+
|
|
258
|
+
import { existsSync, readFileSync, writeFileSync, mkdirSync } from "node:fs";
|
|
259
|
+
import path from "node:path";
|
|
260
|
+
import { z } from "zod";
|
|
261
|
+
import { ROOT } from "../store.js";
|
|
262
|
+
import { readSubs, evaluate, commitEvaluation, eventIsDerived } from "./events.js";
|
|
263
|
+
|
|
264
|
+
/**
|
|
265
|
+
* The watermark: the last commit whose record change has been turned into
|
|
266
|
+
* events, per repo.
|
|
267
|
+
*
|
|
268
|
+
* It is stored rather than inferred, because "what have I already emitted" is
|
|
269
|
+
* not derivable from the repo — and the alternative, re-deriving from some
|
|
270
|
+
* fixed point every time, would re-announce a year of closures on first run.
|
|
271
|
+
* Losing the file is safe in the direction that matters: the idempotency key is
|
|
272
|
+
* derived from the EVENT, so a re-scan of already-delivered events reports
|
|
273
|
+
* `duplicate-suppressed` rather than waking anyone twice.
|
|
274
|
+
*/
|
|
275
|
+
const watermarkFile = () => path.join(ROOT, "record-events.json");
|
|
276
|
+
|
|
277
|
+
type Watermarks = Record<string, { sha: string; at: number }>;
|
|
278
|
+
|
|
279
|
+
function readWatermarks(): Watermarks {
|
|
280
|
+
const f = watermarkFile();
|
|
281
|
+
if (!existsSync(f)) return {};
|
|
282
|
+
try {
|
|
283
|
+
return (JSON.parse(readFileSync(f, "utf8")).repos ?? {}) as Watermarks;
|
|
284
|
+
} catch {
|
|
285
|
+
return {};
|
|
286
|
+
}
|
|
287
|
+
}
|
|
288
|
+
|
|
289
|
+
function writeWatermark(repo: string, sha: string): void {
|
|
290
|
+
mkdirSync(ROOT, { recursive: true });
|
|
291
|
+
const all = readWatermarks();
|
|
292
|
+
all[repo] = { sha, at: Date.now() };
|
|
293
|
+
writeFileSync(watermarkFile(), `${JSON.stringify({ repos: all }, null, 2)}\n`);
|
|
294
|
+
}
|
|
295
|
+
|
|
296
|
+
export const scanRecordEventsSchema = {
|
|
297
|
+
repo: z.string().min(1),
|
|
298
|
+
/** Defaults to the stored watermark; first run with none scans HEAD~1..HEAD. */
|
|
299
|
+
since: z.string().optional(),
|
|
300
|
+
/** Report only. Default true — same posture as `land` and `next_unblocked`. */
|
|
301
|
+
write: z.boolean().optional(),
|
|
302
|
+
};
|
|
303
|
+
|
|
304
|
+
export async function scanRecordEventsTool(args: { repo: string; since?: string; write?: boolean }) {
|
|
305
|
+
const repo = path.resolve(args.repo);
|
|
306
|
+
if (!existsSync(path.join(repo, ".git"))) {
|
|
307
|
+
return { ok: false as const, error: `'${repo}' is not a git repository — the commit is the boundary for a record change, so there is nothing to scan` };
|
|
308
|
+
}
|
|
309
|
+
|
|
310
|
+
const head = git(repo, ["rev-parse", "HEAD"]).trim();
|
|
311
|
+
if (!head) return { ok: false as const, error: `could not resolve HEAD in ${repo}` };
|
|
312
|
+
|
|
313
|
+
const stored = readWatermarks()[repo]?.sha;
|
|
314
|
+
const since = args.since ?? stored ?? `${head}~1`;
|
|
315
|
+
// A watermark from a rebased-away commit resolves to nothing. Say so rather
|
|
316
|
+
// than silently falling back to HEAD~1 and reporting a one-commit scan as if
|
|
317
|
+
// it covered the gap — that is the shape where a miss looks like a clean run.
|
|
318
|
+
if (!git(repo, ["cat-file", "-e", `${since}^{commit}`]) && !git(repo, ["rev-parse", "--verify", `${since}^{commit}`]).trim()) {
|
|
319
|
+
return {
|
|
320
|
+
ok: false as const,
|
|
321
|
+
error:
|
|
322
|
+
`base '${since}' does not resolve in ${repo} — it was probably rebased away. ` +
|
|
323
|
+
`Nothing was scanned and the watermark was NOT advanced: a scan that silently narrows its window ` +
|
|
324
|
+
`reports a clean run over the commits it never looked at. Pass an explicit 'since'.`,
|
|
325
|
+
};
|
|
326
|
+
}
|
|
327
|
+
|
|
328
|
+
const diff = git(repo, ["diff", "--unified=0", `${since}..${head}`, "--", "docs/"]);
|
|
329
|
+
const doneText = existsSync(path.join(repo, "docs/DONE.md")) ? readFileSync(path.join(repo, "docs/DONE.md"), "utf8") : "";
|
|
330
|
+
const phases: Record<string, string> = {};
|
|
331
|
+
for (const rel of git(repo, ["ls-files", "docs/phases/"]).split("\n").filter((p) => /PHASE[^/]*TASKS\.md$/i.test(p))) {
|
|
332
|
+
const p = path.join(repo, rel);
|
|
333
|
+
if (existsSync(p)) phases[rel] = readFileSync(p, "utf8");
|
|
334
|
+
}
|
|
335
|
+
|
|
336
|
+
const candidates = eventsFromCommittedChange(diff, { done: doneText, phases });
|
|
337
|
+
const unattributed = [...lastUnattributedItems];
|
|
338
|
+
|
|
339
|
+
// Every event is checked against the record AS IT NOW STANDS (6.2). A change
|
|
340
|
+
// that has since been reverted produces a candidate the record no longer
|
|
341
|
+
// supports, and it is refused — the stream can never claim what the
|
|
342
|
+
// authoritative markdown does not.
|
|
343
|
+
const emitted: RecordEvent[] = [];
|
|
344
|
+
const refused: string[] = [];
|
|
345
|
+
for (const ev of candidates) {
|
|
346
|
+
const recordText = ev.kind === "task" || ev.kind === "phase" ? Object.values(phases).join("\n") : doneText;
|
|
347
|
+
const derived = eventIsDerived(recordText, ev);
|
|
348
|
+
if (derived.ok) emitted.push(ev);
|
|
349
|
+
else refused.push(derived.error);
|
|
350
|
+
}
|
|
351
|
+
|
|
352
|
+
const deliveries: unknown[] = [];
|
|
353
|
+
if (args.write) {
|
|
354
|
+
let subs = readSubs();
|
|
355
|
+
const now = Date.now();
|
|
356
|
+
for (const ev of emitted) {
|
|
357
|
+
const r = evaluate(subs, ev, now);
|
|
358
|
+
subs = r.subs;
|
|
359
|
+
deliveries.push(...r.deliveries);
|
|
360
|
+
}
|
|
361
|
+
commitEvaluation(subs);
|
|
362
|
+
writeWatermark(repo, head);
|
|
363
|
+
}
|
|
364
|
+
|
|
365
|
+
return {
|
|
366
|
+
ok: true as const,
|
|
367
|
+
repo,
|
|
368
|
+
scanned: { from: since, to: head },
|
|
369
|
+
emitted,
|
|
370
|
+
refused,
|
|
371
|
+
deliveries,
|
|
372
|
+
...(unattributed.length
|
|
373
|
+
? {
|
|
374
|
+
unattributedItems: unattributed,
|
|
375
|
+
unattributedNote:
|
|
376
|
+
`${unattributed.length} queue item(s) left docs/QUEUE.md in this range without a done entry they could be tied to. ` +
|
|
377
|
+
`They emitted NOTHING: the markdown carries no link between an item and the entry that closes it, so attributing them ` +
|
|
378
|
+
`would be a guess. "Closed by an unknown PR" and "not closed" are different facts and this is the first.`,
|
|
379
|
+
}
|
|
380
|
+
: {}),
|
|
381
|
+
...(args.write
|
|
382
|
+
? { watermark: head }
|
|
383
|
+
: {
|
|
384
|
+
note:
|
|
385
|
+
"REPORT ONLY — nothing was delivered and the watermark was not advanced. Pass write:true to deliver. " +
|
|
386
|
+
"Re-scanning an already-delivered range is safe: the idempotency key is derived from the event, so it reports duplicate-suppressed.",
|
|
387
|
+
}),
|
|
388
|
+
kinds: EVENT_KINDS,
|
|
389
|
+
};
|
|
390
|
+
}
|
package/src/tools/registry.ts
CHANGED
|
@@ -363,8 +363,17 @@ export async function listAgentsTool() {
|
|
|
363
363
|
const { lastHeartbeat, ...rest } = a;
|
|
364
364
|
const heartbeatFields = transport
|
|
365
365
|
? {
|
|
366
|
+
// NOT "refreshed BY this call" — that write was removed in #137
|
|
367
|
+
// precisely because it stamped every OTHER fleet's agents too
|
|
368
|
+
// (agents.json is shared, list_agents takes no project argument).
|
|
369
|
+
// Measured after the removal (Task 13.5): calling list_agents and
|
|
370
|
+
// re-reading agents.json directly leaves lastHeartbeat UNCHANGED.
|
|
371
|
+
// The field is omitted because the value is STALE-BY-DESIGN for a
|
|
372
|
+
// live-transport agent, not because this call would overwrite it —
|
|
373
|
+
// an explanation that outlived the code it explained is the same
|
|
374
|
+
// carrier-gap shape as a stated cause nobody re-checked.
|
|
366
375
|
heartbeatSource:
|
|
367
|
-
"omitted —
|
|
376
|
+
"omitted — a live-transport agent's raw lastHeartbeat measures time since it last JOINED, not activity (nothing else writes it for a local transport; Task 13.3/13.4). Liveness is `online`/`transport`; for per-agent freshness read from server state, use `ping`.",
|
|
368
377
|
}
|
|
369
378
|
: { lastHeartbeat, secondsSinceHeartbeat: Math.floor((now - lastHeartbeat) / 1000) };
|
|
370
379
|
return {
|
package/src/tools/stall.ts
CHANGED
|
@@ -93,9 +93,9 @@ export function markRunFailure(reason: string): void {
|
|
|
93
93
|
writeFileSync(runFile(), JSON.stringify({ history: history.slice(-200) }, null, 2));
|
|
94
94
|
}
|
|
95
95
|
|
|
96
|
-
type RunMark = { at: number; hits: number; checked: number; failed?: string };
|
|
96
|
+
type RunMark = { at: number; hits: number; checked: number; measurable?: number; failed?: string };
|
|
97
97
|
|
|
98
|
-
function markRun(result: { hits: StallHit[]; checked: number }) {
|
|
98
|
+
function markRun(result: { hits: StallHit[]; checked: number; measurable?: number }) {
|
|
99
99
|
mkdirSync(ROOT, { recursive: true });
|
|
100
100
|
let history: RunMark[] = [];
|
|
101
101
|
try {
|
|
@@ -103,7 +103,12 @@ function markRun(result: { hits: StallHit[]; checked: number }) {
|
|
|
103
103
|
} catch {
|
|
104
104
|
/* first run */
|
|
105
105
|
}
|
|
106
|
-
|
|
106
|
+
// COVERAGE IS RECORDED WITH THE RUN, because a status tool that cannot
|
|
107
|
+
// express its own blindness is worse than no status tool. Measured: the clock
|
|
108
|
+
// reported `runs 8, failures 0` — all green — while every one of those runs
|
|
109
|
+
// had covered 0 of 3 agents. Nothing in the mark could say so, and this is the
|
|
110
|
+
// instrument meant to cover an absence.
|
|
111
|
+
history.push({ at: Date.now(), hits: result.hits.length, checked: result.checked, measurable: result.measurable ?? 0 });
|
|
107
112
|
// A RUN OF MISSES MUST BE VISIBLE AS RUNS, not as absence — so the marks are a
|
|
108
113
|
// list, not a single timestamp. "Ten quiet checks" and "one check ten hours ago"
|
|
109
114
|
// are different states and only the first is a healthy fleet.
|
|
@@ -114,7 +119,7 @@ export const lastRanSchema = { maxAgeMinutes: z.number().optional() };
|
|
|
114
119
|
|
|
115
120
|
/**
|
|
116
121
|
* Is the clock alive? Readable by a human or another check, which is the point —
|
|
117
|
-
* MISS is silent to the
|
|
122
|
+
* MISS is silent to the WATCHER, not to the record.
|
|
118
123
|
*/
|
|
119
124
|
export async function stallClockStatusTool(args: { maxAgeMinutes?: number }) {
|
|
120
125
|
const maxAge = (args.maxAgeMinutes ?? 60) * 60 * 1000;
|
|
@@ -150,11 +155,19 @@ export async function stallClockStatusTool(args: { maxAgeMinutes?: number }) {
|
|
|
150
155
|
const stale = age >= maxAge;
|
|
151
156
|
const misses = history.filter((h) => !h.failed && h.hits === 0).length;
|
|
152
157
|
const hits = history.filter((h) => !h.failed && h.hits > 0).length;
|
|
158
|
+
// A RUN THAT COVERED NOTHING IS NOT A RUN THAT FOUND NOTHING. Marks written
|
|
159
|
+
// before coverage was recorded carry no `measurable` field: they are reported
|
|
160
|
+
// as UNKNOWN rather than assumed covered, because assuming is what produced
|
|
161
|
+
// the green this exists to correct.
|
|
162
|
+
const graded = history.filter((h) => !h.failed && typeof h.measurable === "number");
|
|
163
|
+
const blindRuns = graded.filter((h) => h.checked > 0 && h.measurable === 0).length;
|
|
164
|
+
const ungraded = history.filter((h) => !h.failed && typeof h.measurable !== "number").length;
|
|
165
|
+
const lastBlind = last && !last.failed && (last.checked ?? 0) > 0 && last.measurable === 0;
|
|
153
166
|
return {
|
|
154
167
|
// A FAILING CLOCK IS NOT A HEALTHY ONE. It writes marks on schedule, so the
|
|
155
168
|
// age looks fresh while nothing is being measured — a fresh heartbeat from
|
|
156
169
|
// a stuck agent, one level up.
|
|
157
|
-
ok: !stale && !lastFailed,
|
|
170
|
+
ok: !stale && !lastFailed && !lastBlind,
|
|
158
171
|
...(stale
|
|
159
172
|
? {
|
|
160
173
|
error: `stall_check last ran ${Math.round(age / 60000)}m ago, past the ${args.maxAgeMinutes ?? 60}m window — THE CLOCK IS STOPPED. No alerts is not the same as no stalls.`,
|
|
@@ -163,12 +176,28 @@ export async function stallClockStatusTool(args: { maxAgeMinutes?: number }) {
|
|
|
163
176
|
? {
|
|
164
177
|
error: `the clock is RUNNING but its last run FAILED: ${lastFailed}. It is firing on schedule and measuring nothing, which reads as fresh and is not.`,
|
|
165
178
|
}
|
|
166
|
-
:
|
|
179
|
+
: lastBlind
|
|
180
|
+
? {
|
|
181
|
+
error:
|
|
182
|
+
`the clock is RUNNING and BLIND: its last run checked ${last?.checked} in-flight row(s) and could measure ${last?.measurable} of them. ` +
|
|
183
|
+
`It fires on schedule, reports no hits, and that "no hits" is not evidence of a healthy fleet — it is evidence of nothing. ` +
|
|
184
|
+
`Read stall_check's own 'unmeasurable' list for the cause; the usual one is a board 'Branch · Worktree' cell holding a PATH rather than a branch ref.`,
|
|
185
|
+
}
|
|
186
|
+
: {}),
|
|
167
187
|
lastRanMinutesAgo: Math.round(age / 60000),
|
|
168
188
|
runs: history.length,
|
|
169
189
|
misses,
|
|
170
190
|
hits,
|
|
171
191
|
failures: failures.length,
|
|
192
|
+
// The number the old shape could not express.
|
|
193
|
+
coverage: {
|
|
194
|
+
lastRun: last && !last.failed ? { checked: last.checked, measurable: last.measurable ?? null } : null,
|
|
195
|
+
blindRuns,
|
|
196
|
+
ungraded,
|
|
197
|
+
note:
|
|
198
|
+
"blindRuns are runs that checked rows and measured none of them — green by every other field. " +
|
|
199
|
+
"ungraded are marks written before coverage was recorded: UNKNOWN, never assumed covered.",
|
|
200
|
+
},
|
|
172
201
|
};
|
|
173
202
|
}
|
|
174
203
|
|
|
@@ -190,10 +219,27 @@ export async function stallCheckTool(args: { repo?: string; stallMinutes?: numbe
|
|
|
190
219
|
const hits: StallHit[] = [];
|
|
191
220
|
/** Rows whose VCS activity could not be measured. NOT stall claims. */
|
|
192
221
|
const unmeasurable: { agentId: string; value: string; why: string }[] = [];
|
|
222
|
+
/**
|
|
223
|
+
* Rows a predicate actually reached a VERDICT about — hit or clean.
|
|
224
|
+
*
|
|
225
|
+
* Tracked explicitly rather than inferred from "2 predicates minus the
|
|
226
|
+
* unmeasurable ones", because a predicate that is SKIPPED pushes no
|
|
227
|
+
* unmeasurable entry and the arithmetic then counts it as having measured.
|
|
228
|
+
* That is the same defect as the `continue` above, one level up: a silent
|
|
229
|
+
* skip and a clean pass produce the same number.
|
|
230
|
+
*/
|
|
231
|
+
const measured = new Set<string>();
|
|
193
232
|
|
|
194
233
|
for (const row of inFlight) {
|
|
195
234
|
const agentId = row.owner.replace(/[`*]/g, "").trim();
|
|
196
235
|
const entry = reg[agentId];
|
|
236
|
+
if (!entry) {
|
|
237
|
+
// NOT SKIPPED IN SILENCE. An owner with no registry entry has no
|
|
238
|
+
// heartbeat to read, which is a missing signal and must be reported as
|
|
239
|
+
// one — a row whose owner the bus has never heard of is exactly the row
|
|
240
|
+
// you would want named before going away.
|
|
241
|
+
unmeasurable.push({ agentId, value: "", why: "no registry entry for this owner — there is no heartbeat to read, and the name may not be an agent id at all" });
|
|
242
|
+
}
|
|
197
243
|
if (entry) {
|
|
198
244
|
const age = now - entry.lastHeartbeat;
|
|
199
245
|
// A HEARTBEAT IS ONLY EVIDENCE WHERE SOMETHING WRITES ONE.
|
|
@@ -230,12 +276,51 @@ export async function stallCheckTool(args: { repo?: string; stallMinutes?: numbe
|
|
|
230
276
|
value: marker.transport,
|
|
231
277
|
why: `heartbeat is ${Math.round(age / 60000)}m old, but nothing writes heartbeats for a local 'tmux-push' transport — hooks/tmux-pusher.mjs does not call heartbeat, and this marker's liveness is its pid. There is no heartbeat SOURCE here, so the age measures nothing about this agent`,
|
|
232
278
|
});
|
|
233
|
-
continue
|
|
234
|
-
|
|
235
|
-
|
|
279
|
+
// FALL THROUGH TO THE VCS PREDICATE — do not `continue`.
|
|
280
|
+
//
|
|
281
|
+
// This used to skip the row entirely, and the consequence was total: on
|
|
282
|
+
// this fleet, 3 of 3 in-flight rows were local tmux-push agents, so
|
|
283
|
+
// EVERY row exited here and the check reported `checked 3 · measurable
|
|
284
|
+
// 0`. The half that was blind (heartbeat, deliberately, for the reason
|
|
285
|
+
// above) was taking the half that works (vcs activity) down with it.
|
|
286
|
+
//
|
|
287
|
+
// The two predicates answer different questions and only one of them is
|
|
288
|
+
// unanswerable for a local transport. "Is it alive" has no source here;
|
|
289
|
+
// "is its branch moving" has a perfectly good one, and it is the half
|
|
290
|
+
// that catches the case this verb exists for — alive and not
|
|
291
|
+
// progressing. An unmeasurable heartbeat is a missing signal, not a
|
|
292
|
+
// reason to stop measuring the signal that is present.
|
|
293
|
+
} else if (age > limit) {
|
|
236
294
|
hits.push({ kind: "no-heartbeat", agentId, stream: row.stream.slice(0, 60), minutes: Math.round(age / 60000) });
|
|
295
|
+
measured.add(agentId);
|
|
237
296
|
continue;
|
|
238
297
|
}
|
|
298
|
+
// A FRESH HEARTBEAT IS *NOT* A VERDICT, AND THIS IS A CORRECTION TO WHAT
|
|
299
|
+
// THIS CODE CLAIMED WHEN IT MERGED.
|
|
300
|
+
//
|
|
301
|
+
// It read "a fresh heartbeat IS a verdict — alive" and credited coverage
|
|
302
|
+
// for it. Measured since, by reading agents.json directly (`list_agents`
|
|
303
|
+
// refreshes the CALLER's mark, so it cannot be used to measure the
|
|
304
|
+
// caller): an agent's mark aged from 9611s to 9623s across a `post_status`
|
|
305
|
+
// call. BUS TOOL CALLS DO NOT WRITE HEARTBEATS.
|
|
306
|
+
//
|
|
307
|
+
// `heartbeat` age is TIME SINCE JOIN. Not activity, and not even
|
|
308
|
+
// time-since-restart — a server can restart mid-session without the mark
|
|
309
|
+
// moving, because nothing rejoined. So freshness says "recently joined",
|
|
310
|
+
// and crediting it as coverage lets `/coord-away` arm on a fleet whose
|
|
311
|
+
// only evidence is that somebody reconnected.
|
|
312
|
+
//
|
|
313
|
+
// The inverse is worse and is why this is not merely tidiness: reading
|
|
314
|
+
// age as activity marks the two most CONTINUOUSLY ACTIVE agents on a bus
|
|
315
|
+
// as stale at 2.6h, while a freshly-rejoined idle agent reads healthy —
|
|
316
|
+
// a false-stall generator aimed at exactly the agents that must not be
|
|
317
|
+
// false-stalled while nobody is watching.
|
|
318
|
+
//
|
|
319
|
+
// The HIT above is kept: for an agent with no transport at all, "has not
|
|
320
|
+
// rejoined and has no live marker" is still the death signal Task 3.5
|
|
321
|
+
// specifies. What is removed is the coverage credit for freshness, which
|
|
322
|
+
// leaves coverage resting on the vcs predicate — the one that measures
|
|
323
|
+
// something the agent DID.
|
|
239
324
|
}
|
|
240
325
|
// A FRESH HEARTBEAT IS NOT PROGRESS. An agent can be alive and stuck, which is
|
|
241
326
|
// the case "notice the room" never catches: the pane is responsive, so nobody
|
|
@@ -286,6 +371,7 @@ export async function stallCheckTool(args: { repo?: string; stallMinutes?: numbe
|
|
|
286
371
|
cwd: repo, encoding: "utf8", stdio: ["ignore", "pipe", "ignore"],
|
|
287
372
|
}).trim();
|
|
288
373
|
const age = now - Date.parse(iso);
|
|
374
|
+
measured.add(agentId);
|
|
289
375
|
if (age > limit) {
|
|
290
376
|
hits.push({ kind: "no-vcs-activity", agentId, branch: raw, minutes: Math.round(age / 60000) });
|
|
291
377
|
}
|
|
@@ -294,8 +380,15 @@ export async function stallCheckTool(args: { repo?: string; stallMinutes?: numbe
|
|
|
294
380
|
}
|
|
295
381
|
}
|
|
296
382
|
|
|
297
|
-
|
|
298
|
-
|
|
383
|
+
// COVERAGE, not "did it run". A row is COVERED when at least one predicate
|
|
384
|
+
// produced a verdict about it; a row where every predicate came back
|
|
385
|
+
// unmeasurable was looked at and not measured, and counting it as checked is
|
|
386
|
+
// how "the clock ran" gets mistaken for "the fleet is observed".
|
|
387
|
+
const owners = inFlight.map((r) => r.owner.replace(/[`*]/g, "").trim()).filter(Boolean);
|
|
388
|
+
const blind = [...new Set(owners)].filter((id) => !measured.has(id));
|
|
389
|
+
const measurable = Math.max(0, inFlight.length - blind.length);
|
|
390
|
+
const result = { hits, checked: inFlight.length, unmeasurable, measurable, blind };
|
|
391
|
+
markRun({ hits, checked: inFlight.length, measurable });
|
|
299
392
|
return {
|
|
300
393
|
ok: true as const,
|
|
301
394
|
...result,
|
package/src/tools/transport.ts
CHANGED
|
@@ -124,7 +124,20 @@ export async function pingTool(args: { from: string; to: string; echo?: boolean
|
|
|
124
124
|
}
|
|
125
125
|
|
|
126
126
|
const reachable = transportLive && paneAlive !== false;
|
|
127
|
-
|
|
127
|
+
// heartbeatFresh is only valid EVIDENCE where something writes a heartbeat.
|
|
128
|
+
// A local tmux-push transport's liveness is its pid (isPidAlive) —
|
|
129
|
+
// hooks/tmux-pusher.mjs never calls `heartbeat`, so the field measures time
|
|
130
|
+
// since JOIN, not activity (Task 13.3/13.4, stall.ts's own established
|
|
131
|
+
// rule for exactly this transport type). Crediting it here for a
|
|
132
|
+
// tmux-push agent produced the incident this fix exists for: `alive` and
|
|
133
|
+
// `reachable` read fully healthy, `heartbeatFresh` sat in `checks` reading
|
|
134
|
+
// false, and the top-line boolean never surfaced the disagreement — a
|
|
135
|
+
// status layer discarding what its own lower layer already reported, the
|
|
136
|
+
// same shape as `stall_clock_status` before Task 13.1. A REMOTE pusher, or
|
|
137
|
+
// an agent with no probeable marker at all, has no pid to fall back on —
|
|
138
|
+
// there heartbeat genuinely IS the liveness mechanism, unchanged.
|
|
139
|
+
const heartbeatIsValidSignal = !marker || marker.transport !== "tmux-push";
|
|
140
|
+
const alive = reachable || (heartbeatIsValidSignal && heartbeatFresh);
|
|
128
141
|
|
|
129
142
|
let echoSent = false;
|
|
130
143
|
if (args.echo && alive) {
|
|
@@ -146,6 +159,11 @@ export async function pingTool(args: { from: string; to: string; echo?: boolean
|
|
|
146
159
|
registered: true,
|
|
147
160
|
heartbeatFresh,
|
|
148
161
|
heartbeatAgeSec,
|
|
162
|
+
// Whether heartbeatFresh above is real evidence for this transport, or
|
|
163
|
+
// just time-since-join. A reader who sees `heartbeatFresh: false` next
|
|
164
|
+
// to `heartbeatValid: false` should not read that as a dissenting
|
|
165
|
+
// signal — there is no signal there to dissent.
|
|
166
|
+
heartbeatValid: heartbeatIsValidSignal,
|
|
149
167
|
transport: marker?.transport ?? null,
|
|
150
168
|
transportLive,
|
|
151
169
|
...(paneAlive !== undefined ? { paneAlive, tmuxTarget: marker?.tmuxTarget } : {}),
|