agent-coord-mcp 0.26.22 → 0.26.24
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 +70 -0
- package/dist/capabilities.js.map +1 -1
- package/dist/gated-head.js +160 -5
- package/dist/gated-head.js.map +1 -1
- package/dist/tools/away.js +20 -2
- package/dist/tools/away.js.map +1 -1
- package/dist/tools/event-kinds.js.map +1 -1
- package/dist/tools/events.js +9 -2
- package/dist/tools/events.js.map +1 -1
- package/dist/tools/herdr-delivery.js +99 -0
- package/dist/tools/herdr-delivery.js.map +1 -0
- package/dist/tools/messaging.js +8 -0
- package/dist/tools/messaging.js.map +1 -1
- package/dist/tools/queue-write.js +4 -1
- package/dist/tools/queue-write.js.map +1 -1
- package/dist/tools/record-events.js +42 -4
- package/dist/tools/record-events.js.map +1 -1
- package/dist/tools/records.js +85 -9
- package/dist/tools/records.js.map +1 -1
- package/dist/tools/registry.js +15 -1
- package/dist/tools/registry.js.map +1 -1
- package/dist/tools/seat-build.js +10 -1
- package/dist/tools/seat-build.js.map +1 -1
- package/dist/tools/stall.js +73 -9
- package/dist/tools/stall.js.map +1 -1
- package/dist/tools/tick.js +77 -0
- package/dist/tools/tick.js.map +1 -0
- package/dist/tools/transport.js +50 -1
- package/dist/tools/transport.js.map +1 -1
- package/dist/transports/herdr.js +381 -0
- package/dist/transports/herdr.js.map +1 -0
- package/dist/transports/index.js +10 -4
- package/dist/transports/index.js.map +1 -1
- package/dist/transports/types.js +41 -0
- package/dist/transports/types.js.map +1 -1
- package/package.json +1 -1
- package/src/capabilities.ts +73 -0
- package/src/gated-head.ts +181 -5
- package/src/tools/away.ts +38 -2
- package/src/tools/event-kinds.ts +2 -1
- package/src/tools/events.ts +9 -2
- package/src/tools/herdr-delivery.ts +87 -0
- package/src/tools/messaging.ts +8 -0
- package/src/tools/queue-write.ts +4 -1
- package/src/tools/record-events.ts +46 -4
- package/src/tools/records.ts +74 -8
- package/src/tools/registry.ts +14 -1
- package/src/tools/seat-build.ts +8 -1
- package/src/tools/stall.ts +80 -12
- package/src/tools/tick.ts +117 -0
- package/src/tools/transport.ts +48 -0
- package/src/transports/herdr.ts +385 -0
- package/src/transports/index.ts +10 -4
- package/src/transports/types.ts +66 -0
package/src/gated-head.ts
CHANGED
|
@@ -207,13 +207,189 @@ export function gatedBy(verdicts: PassVerdict[], head: string, mergedAt: number
|
|
|
207
207
|
}
|
|
208
208
|
|
|
209
209
|
/**
|
|
210
|
-
*
|
|
211
|
-
*
|
|
212
|
-
*
|
|
213
|
-
*
|
|
214
|
-
*
|
|
210
|
+
* ⛔ #313's GRAMMAR, KEPT ONLY FOR BACKWARDS COMPATIBILITY — IT MATCHED NOTHING IN
|
|
211
|
+
* THE FIELD. `QA GATE — **PASS** @ \`sha\`` was ruled canonical and then had ZERO
|
|
212
|
+
* ADOPTION: measured against the 9 merges of 2026-09-15, it matched 0 verdict
|
|
213
|
+
* lines while all 9 merges carried one, so `unverdicted-merge` fired 9 times on 9
|
|
214
|
+
* correct merges — 100% false positive, which trains the room to ignore the one
|
|
215
|
+
* instrument protecting the convention.
|
|
216
|
+
*
|
|
217
|
+
* ⭐ THE LESSON IS WHY THIS CONSTANT IS NO LONGER THE PREDICATE: A REGEX OVER PROSE
|
|
218
|
+
* TESTS THE PHRASING, NOT THE CLAIM. The verdicts were never missing; only the
|
|
219
|
+
* agreed wording was. Nobody noticed because every verdict is read BY EYE at gate
|
|
220
|
+
* time and they all read fine — the instrument and the humans disagreed silently
|
|
221
|
+
* for a day.
|
|
222
|
+
*
|
|
223
|
+
* ⚠ SCOPE, CORRECTED: "zero adoption" IS TRUE OF A CONSUMER FLEET AND FALSE OF THIS REPO.
|
|
224
|
+
* Measured: 0 of 9 of that fleet's merges carry this grammar; 4 of 6 kit merges do, verbatim
|
|
225
|
+
* (`#342`, `#340`, `#337`, `#336`). The original write-up scoped a measurement to one
|
|
226
|
+
* consumer and stated it universally.
|
|
227
|
+
*
|
|
228
|
+
* ⛔⛔ AND DO NOT BELIEVE THIS CONSTANT IS WHAT KEEPS THAT KIT GRAMMAR WORKING — I
|
|
229
|
+
* CLAIMED THAT AND IT IS FALSE. `verdictBoundTo` → `verdictClaimsIn` NEVER references
|
|
230
|
+
* `VERDICT_COMMENT`; it survives only in the legacy `verdictShasIn`. Compatibility comes
|
|
231
|
+
* from `DECLARES` accepting the `(?:QA )?GATE\b` opener. ⇒ THE TRAP THAT MAKES THIS
|
|
232
|
+
* WORTH WRITING DOWN: deleting this constant as dead code leaves the kit grammar working,
|
|
233
|
+
* so the experiment CONFIRMS the false mechanism — whoever ran it would conclude they had
|
|
234
|
+
* removed something load-bearing and got away with it. If you are removing it, the thing
|
|
235
|
+
* to check is `DECLARES`, not this.
|
|
215
236
|
*/
|
|
216
237
|
export const VERDICT_COMMENT = /^(?:QA GATE|\*\*GATE|GATE)\b[^\n]*?\b(PASS|FAIL)\b(?:\s*\([^)\n]*\))?[^\n]*?@\s*`?([0-9a-f]{7,40})`?/;
|
|
238
|
+
|
|
239
|
+
/**
|
|
240
|
+
* WHAT MAKES A VERDICT BINDING, expressed as a shape instead of a phrase: a
|
|
241
|
+
* DISPOSITION and a SHA, on ONE LINE, with the disposition ahead of the `@`. The
|
|
242
|
+
* caller then decides bindingness by comparing that sha to the head it cares
|
|
243
|
+
* about — the claim, not the header.
|
|
244
|
+
*
|
|
245
|
+
* Derived from the five spellings actually in the field on 2026-09-15, none of
|
|
246
|
+
* which the constant above matches:
|
|
247
|
+
* `## QA verdict — DONE: PASS \`#970\` @ \`sha\` · mode: full-local · base …`
|
|
248
|
+
* `## QA verdict — DONE: CONTENT PASS \`#964\` @ \`sha\``
|
|
249
|
+
* `## QA verdict — DONE: READINESS RELEASED \`#964\` @ \`sha\``
|
|
250
|
+
* `## QA-2 — FULL PASS (content + readiness) @ \`sha\``
|
|
251
|
+
* `## qa-2 — CONTENT PASS (re-bind) @ \`sha\` · READINESS NOT CLEAR`
|
|
252
|
+
*
|
|
253
|
+
* ⛔ THE HALVES ARE DISTINGUISHED, BECAUSE A CONTENT PASS IS NOT A CLEARANCE. It
|
|
254
|
+
* says so in its own body ("READINESS NOT ESTABLISHED — do not merge yet"), so
|
|
255
|
+
* counting it as a whole verdict would license exactly the merge it forbids. A
|
|
256
|
+
* caller wanting "was this clear to merge" needs a WHOLE verdict, or BOTH halves.
|
|
257
|
+
*
|
|
258
|
+
* ⛔ ORDER MATTERS IN THE CLASSIFIER: `CONTENT PASS` and `READINESS RELEASED` are
|
|
259
|
+
* tested before bare `PASS`, and `READINESS NOT CLEAR`/`NOT ESTABLISHED` must not
|
|
260
|
+
* read as a readiness half — the live `## qa-2 — CONTENT PASS (re-bind) @ sha ·
|
|
261
|
+
* READINESS NOT CLEAR` line carries both phrases and is a CONTENT half only.
|
|
262
|
+
*/
|
|
263
|
+
export type VerdictClaim = { result: string; sha: string; half: "whole" | "content" | "readiness" };
|
|
264
|
+
const NEGATED_READINESS = /READINESS\s+(?:NOT|IS\s+NOT)\b/i;
|
|
265
|
+
/**
|
|
266
|
+
* ⛔ THE LINE MUST DECLARE, NOT REMARK — and this anchor is here because removing it
|
|
267
|
+
* BROKE THE AIDE'S #299 CONTROL, which caught it immediately. That control's sharpest
|
|
268
|
+
* fixture is `Looks like a PASS to me — see the run @ \`sha\` for the log; not a gate.`:
|
|
269
|
+
* uppercase PASS, an `@`, and the MERGED HEAD's own sha. Disposition-plus-bound-sha alone
|
|
270
|
+
* counts it, and a chat remark then silences the check for a genuinely unverdicted merge.
|
|
271
|
+
*
|
|
272
|
+
* So a claim is a HEADING (every live verdict on 2026-09-15 is `## …`) or the legacy gate
|
|
273
|
+
* opener. That is still structure rather than phrasing — what it requires is that someone
|
|
274
|
+
* DECLARED a disposition, not that they used a particular sentence — and it is the part of
|
|
275
|
+
* #313's grammar worth keeping: `QA GATE` was the wrong half to demand, `is a declaration`
|
|
276
|
+
* was the right one.
|
|
277
|
+
*/
|
|
278
|
+
const DECLARES = /^\s*(?:#{1,6}\s+|\*\*(?:QA )?GATE|(?:QA )?GATE\b)/;
|
|
279
|
+
/** An explicit disclaimer on the same line is dispositive — the author says it is not one. */
|
|
280
|
+
const DISCLAIMED = /\bnot a (?:gate|verdict)\b/i;
|
|
281
|
+
|
|
282
|
+
/**
|
|
283
|
+
* ⛔⛔ A WITHDRAWAL IS NOT THE VERDICT IT WITHDRAWS, AND THE NAIVE READING IS THE DANGEROUS ONE.
|
|
284
|
+
* A consumer fleet's gate seat posted these on one of its PRs; both bound as a clean PASS before this line
|
|
285
|
+
* existed:
|
|
286
|
+
* `## ⛔ VOIDING my PASS-HOLD @ <sha> — that head is two moves dead`
|
|
287
|
+
* `## ⛔ VOIDING my PASS @ <sha>`
|
|
288
|
+
* A line that revokes a verdict names that verdict — so matching the disposition word inside it
|
|
289
|
+
* turns a RETRACTION into a CLEARANCE. That is a FALSE NEGATIVE in a watchdog, which is
|
|
290
|
+
* indistinguishable from a clean run: `unverdicted-merge` would stay silent on a PR merged after
|
|
291
|
+
* its verdict was voided, which is precisely the merge it exists to catch.
|
|
292
|
+
*
|
|
293
|
+
* ⛔⛔ AND POSITION IS THE DISCRIMINATOR, NOT PRESENCE — the first version tested this pattern
|
|
294
|
+
* ANYWHERE on the line and failed in the OPPOSITE direction. A verdict that REPLACES an earlier
|
|
295
|
+
* one routinely says so in its own header, which is the practice that keeps a superseded verdict
|
|
296
|
+
* VISIBLE instead of silently edited:
|
|
297
|
+
* `## QA GATE — **FAIL** @ <sha> — superseding my CONTENT PASS above` ← verbatim, a consumer fleet's PR
|
|
298
|
+
* Discarding that one makes `unverdicted-merge` FIRE ON A PROPERLY GATED MERGE — and it
|
|
299
|
+
* concentrates where gating was MOST active, because a PR whose verdict got corrected is one that
|
|
300
|
+
* received MORE scrutiny. The instrument would go loudest exactly where it is least needed.
|
|
301
|
+
*
|
|
302
|
+
* ⇒ IN A WITHDRAWAL THE VERB PRECEDES THE DISPOSITION; IN A SUPERSEDING VERDICT THE DISPOSITION
|
|
303
|
+
* COMES FIRST. qa-2's shape, and it is measured rather than reasoned: across ALL 124 verdict-shaped
|
|
304
|
+
* lines in both repos (101 of them declaration-shaped), exactly TWO carry a revocation verb, and
|
|
305
|
+
* they split correctly by position — 1 withdrawal, 1 superseding verdict, 99 plain verdicts.
|
|
306
|
+
*
|
|
307
|
+
* ⚠ THE RESIDUAL, STATED WITH ITS DENOMINATOR RATHER THAN LEFT IMPLIED: `PASS @ <sha> —
|
|
308
|
+
* SUPERSEDED, see below` puts the verb after the disposition and would be ACCEPTED. qa-2 raised it
|
|
309
|
+
* as its own invention and flagged it as not field-observed; the corpus agrees — ZERO of 124. It is
|
|
310
|
+
* also partly self-correcting, since whatever supersedes it binds the same sha later.
|
|
311
|
+
*/
|
|
312
|
+
const REVOCATION_VERB = /\b(?:VOID(?:ING|ED)?|WITHDRAW(?:N|ING)?|RETRACT(?:ED|ING)?|SUPERSED(?:ED|ING|ES))\b/i;
|
|
313
|
+
/** True only when the revocation verb comes BEFORE the disposition it names. */
|
|
314
|
+
function isWithdrawal(line: string, dispositionIndex: number): boolean {
|
|
315
|
+
const verb = REVOCATION_VERB.exec(line);
|
|
316
|
+
return verb !== null && verb.index < dispositionIndex;
|
|
317
|
+
}
|
|
318
|
+
|
|
319
|
+
/**
|
|
320
|
+
* ⛔ `PASS-HOLD` IS A DOCUMENTED DO-NOT-MERGE VERDICT (`coord-qa/SKILL.md:71,74`) AND `\bPASS\b`
|
|
321
|
+
* MATCHED INSIDE IT, because `-` is a non-word character. So the one verdict type whose entire
|
|
322
|
+
* meaning is "do not merge" classified as a clearance and BOUND.
|
|
323
|
+
* ⚠ It had not yet fired, and that was LUCK: qa-2's own `#974` PASS-HOLD missed only because the
|
|
324
|
+
* head moved and the sha stopped agreeing. Nothing in the classifier prevented it.
|
|
325
|
+
*/
|
|
326
|
+
const HOLD = /\bPASS-HOLD\b/i;
|
|
327
|
+
|
|
328
|
+
/**
|
|
329
|
+
* ⭐ FOUND BY THE CORPUS, NOT BY A GATE — the fourth non-clearance form, and the first one this
|
|
330
|
+
* suite caught itself rather than a reviewer catching it:
|
|
331
|
+
* `## QA — **READINESS REFUSED** @ <sha> · the CONTENT PASS above still stands` (a consumer fleet's PR)
|
|
332
|
+
* ⚠ IT ALREADY BEHAVED CORRECTLY, AND ONLY BY ACCIDENT: `READINESS REFUSED` is not a disposition,
|
|
333
|
+
* and the `CONTENT PASS` it mentions sits AFTER the `@ <sha>`, so no claim matched. Reorder that
|
|
334
|
+
* sentence — `the CONTENT PASS above still stands @ <sha>` — and the refusal would have BOUND as a
|
|
335
|
+
* content half. Naming it makes the behaviour a decision instead of a word-order coincidence.
|
|
336
|
+
*/
|
|
337
|
+
const REFUSED = /\bREADINESS\s+REFUSED\b/i;
|
|
338
|
+
/** A disposition ahead of an `@ sha` on the same line. The sha's binding is the caller's test. */
|
|
339
|
+
const CLAIM_LINE = /\b(READINESS\s+RELEASED|CONTENT\s+PASS|FULL\s+PASS|PASS-HOLD|PASS|FAIL)\b[^\n]*?@\s*`?([0-9a-f]{7,40})`?/i;
|
|
340
|
+
export function verdictClaimsIn(comments: { body: string }[]): VerdictClaim[] {
|
|
341
|
+
const out: VerdictClaim[] = [];
|
|
342
|
+
for (const c of comments) {
|
|
343
|
+
for (const line of String(c.body ?? "").split("\n")) {
|
|
344
|
+
// ⛔ A HOLD IS A NON-CLEARANCE THAT NAMES ITS OWN DISPOSITION, so it is rejected outright.
|
|
345
|
+
if (!DECLARES.test(line) || DISCLAIMED.test(line) || HOLD.test(line) || REFUSED.test(line)) continue;
|
|
346
|
+
const m = CLAIM_LINE.exec(line);
|
|
347
|
+
if (!m) continue;
|
|
348
|
+
// ⛔ A WITHDRAWAL IS REJECTED BY POSITION, NOT BY PRESENCE — see isWithdrawal. Testing the
|
|
349
|
+
// verb anywhere on the line discards the superseding verdicts that carry it AFTER the
|
|
350
|
+
// disposition, which fires the watchdog on properly gated merges.
|
|
351
|
+
if (isWithdrawal(line, m.index)) continue;
|
|
352
|
+
const word = m[1]!.toUpperCase().replace(/\s+/g, " ");
|
|
353
|
+
const sha = m[2]!;
|
|
354
|
+
if (word === "READINESS RELEASED") {
|
|
355
|
+
out.push({ result: "PASS", sha, half: "readiness" });
|
|
356
|
+
} else if (word === "CONTENT PASS") {
|
|
357
|
+
out.push({ result: "PASS", sha, half: "content" });
|
|
358
|
+
} else if (word === "FAIL") {
|
|
359
|
+
out.push({ result: "FAIL", sha, half: "whole" });
|
|
360
|
+
} else {
|
|
361
|
+
// bare PASS / FULL PASS — a whole verdict unless the same line withholds readiness
|
|
362
|
+
out.push({ result: "PASS", sha, half: NEGATED_READINESS.test(line) ? "content" : "whole" });
|
|
363
|
+
}
|
|
364
|
+
}
|
|
365
|
+
}
|
|
366
|
+
return out;
|
|
367
|
+
}
|
|
368
|
+
|
|
369
|
+
/**
|
|
370
|
+
* Is there a verdict BOUND TO `head` — a whole one, or both halves? This is the
|
|
371
|
+
* question `unverdicted-merge` actually asks, and it is the seam #964 exposed:
|
|
372
|
+
* that PR published CONTENT PASS and READINESS RELEASED as two separate comments,
|
|
373
|
+
* so a rule scoped to a whole verdict misses a merge that was fully gated.
|
|
374
|
+
*
|
|
375
|
+
* ⭐ A SUPERSEDED VERDICT CANNOT SATISFY THIS, BY CONSTRUCTION RATHER THAN BY A
|
|
376
|
+
* SPECIAL CASE. #965's FAIL cited `ab58f9455` — an object that does not exist —
|
|
377
|
+
* and was corrected in a later comment. Requiring agreement with the head that
|
|
378
|
+
* MERGED means a sha naming any other tree (wrong, stale, or nonexistent) simply
|
|
379
|
+
* does not bind. No "is this superseded" heuristic is needed and none is used.
|
|
380
|
+
*/
|
|
381
|
+
export function verdictBoundTo(comments: { body: string }[], head: string): { bound: boolean; why: string } {
|
|
382
|
+
const claims = verdictClaimsIn(comments).filter((v) => shaAgrees(v.sha, head));
|
|
383
|
+
const whole = claims.find((v) => v.half === "whole");
|
|
384
|
+
if (whole) return { bound: true, why: `${whole.result} bound to ${head.slice(0, 7)}` };
|
|
385
|
+
const content = claims.some((v) => v.half === "content");
|
|
386
|
+
const readiness = claims.some((v) => v.half === "readiness");
|
|
387
|
+
if (content && readiness) return { bound: true, why: `CONTENT PASS + READINESS RELEASED bound to ${head.slice(0, 7)} (split halves)` };
|
|
388
|
+
if (content) return { bound: false, why: `only a CONTENT half at ${head.slice(0, 7)} — readiness never released, which is the half that clears a merge` };
|
|
389
|
+
if (readiness) return { bound: false, why: `only a READINESS half at ${head.slice(0, 7)} — no content verdict` };
|
|
390
|
+
return { bound: false, why: `no disposition bound to ${head.slice(0, 7)}` };
|
|
391
|
+
}
|
|
392
|
+
|
|
217
393
|
export function verdictShasIn(comments: { body: string }[]): { result: string; sha: string }[] {
|
|
218
394
|
const out: { result: string; sha: string }[] = [];
|
|
219
395
|
for (const c of comments) {
|
package/src/tools/away.ts
CHANGED
|
@@ -92,6 +92,18 @@ export const COVERAGE_THRESHOLD = "EVERY in-flight lane the clock scores must be
|
|
|
92
92
|
* carries the reading's age and labels it stale past this many minutes.
|
|
93
93
|
*/
|
|
94
94
|
export const COVERAGE_STALE_MINUTES = 60;
|
|
95
|
+
/**
|
|
96
|
+
* ⟨q-1c95f7d4⟩ 5.4 — the tick's own sentence for the announcement and the refusal. A fleet
|
|
97
|
+
* with NO tick source says so (that is this fleet today: six tmux seats, zero herdr), and a
|
|
98
|
+
* fleet with a source that answered for nobody says BLIND rather than saying nothing.
|
|
99
|
+
*/
|
|
100
|
+
export const tickSentence = (t?: AwayTick): string => {
|
|
101
|
+
if (!t || t.seats === 0) return " No external tick: no seat has an observer to ask, so every lane above was measured from its branch and the board alone.";
|
|
102
|
+
if (t.readable === 0) return ` External tick BLIND: ${t.seats} seat(s) have an observer and none answered — a source that cannot be read is not a quiet fleet.`;
|
|
103
|
+
const states = Object.entries(t.states).map(([k, v]) => `${v} ${k}`).join(", ");
|
|
104
|
+
return ` External tick: ${t.readable}/${t.seats} seat(s) answered${states ? ` (${states})` : ""} — evidence about the seats, beside the branch axes, never instead of them.`;
|
|
105
|
+
};
|
|
106
|
+
|
|
95
107
|
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
108
|
export function coverageAgeOf(coverage: { at: string }, now = Date.now()): { ageMs: number | null; stale: boolean; label: string } {
|
|
97
109
|
const at = Date.parse(coverage.at);
|
|
@@ -103,10 +115,28 @@ export function coverageAgeOf(coverage: { at: string }, now = Date.now()): { age
|
|
|
103
115
|
/** Met when nothing scored is unseen — an empty board (0 of 0) is idle, not blind, and needs no special case. */
|
|
104
116
|
export const meetsCoverageThreshold = (c: { checked: number; measurable: number }): boolean => c.measurable >= c.checked;
|
|
105
117
|
|
|
118
|
+
/**
|
|
119
|
+
* ⟨q-1c95f7d4⟩ Task 5.4 — WHAT THE EXTERNAL TICK CONTRIBUTES TO THE AWAY PRECONDITION.
|
|
120
|
+
*
|
|
121
|
+
* The precondition asks what the clock could actually SEE. A seat observed from OUTSIDE
|
|
122
|
+
* this process — herdr answering `idle | working | blocked` for its pane — is seen in a way
|
|
123
|
+
* a pid and a branch cannot manage: it is the only signal here that separates a thinking
|
|
124
|
+
* lane from a wedged one. So a readable tick counts as measured, through `stall_check`'s
|
|
125
|
+
* own coverage arithmetic, and nothing is special-cased here.
|
|
126
|
+
*
|
|
127
|
+
* ⛔ WHAT IS NOT DONE, DELIBERATELY: a tick NEVER raises coverage for a seat the branch
|
|
128
|
+
* axes could not measure into "observed enough to go away on". It credits the seat it read
|
|
129
|
+
* and nothing else, and a fleet with a tick source that answered for NOBODY is reported as
|
|
130
|
+
* blind — `0 of N readable` is the blind case this verb exists to refuse, not a quiet one.
|
|
131
|
+
*/
|
|
132
|
+
export type AwayTick = { seats: number; readable: number; states: Record<string, number>; note: string };
|
|
133
|
+
|
|
106
134
|
export type AwayCoverage = {
|
|
107
135
|
checked: number;
|
|
108
136
|
measurable: number;
|
|
109
137
|
blind: string[];
|
|
138
|
+
/** ⟨q-1c95f7d4⟩ the external tick behind this coverage number, named so the announcement can say what watched the fleet. */
|
|
139
|
+
tick?: AwayTick;
|
|
110
140
|
/** ⟨q-5d1c8e04⟩ — standing seats on the board: present, not scored, and therefore not blind. */
|
|
111
141
|
roles?: number;
|
|
112
142
|
/** Lanes that declare no per-agent branch: out of the population by their own statement. */
|
|
@@ -220,6 +250,7 @@ async function measureCoverage(repo?: string): Promise<AwayCoverage | null> {
|
|
|
220
250
|
try {
|
|
221
251
|
const r = (await stallCheckTool({ repo })) as unknown as {
|
|
222
252
|
ok?: boolean; checked?: number; measurable?: number; blind?: string[]; roles?: unknown[]; deliberate?: unknown[];
|
|
253
|
+
tick?: { seats?: unknown[]; readable?: number; states?: Record<string, number>; note?: string };
|
|
223
254
|
boardParse?: { readable: boolean; rowsPresent: number; rowsParsed: number; why: string };
|
|
224
255
|
};
|
|
225
256
|
// ⟨q-3d82f1a9⟩ — AN UNREADABLE BOARD IS A NAMED STATE, not "could not measure".
|
|
@@ -237,6 +268,9 @@ async function measureCoverage(repo?: string): Promise<AwayCoverage | null> {
|
|
|
237
268
|
checked: r.checked ?? 0,
|
|
238
269
|
measurable: r.measurable ?? 0,
|
|
239
270
|
blind: r.blind ?? [],
|
|
271
|
+
tick: r.tick
|
|
272
|
+
? { seats: r.tick.seats?.length ?? 0, readable: r.tick.readable ?? 0, states: (r.tick.states ?? {}) as Record<string, number>, note: r.tick.note ?? "" }
|
|
273
|
+
: undefined,
|
|
240
274
|
roles: r.roles?.length ?? 0,
|
|
241
275
|
deliberate: r.deliberate?.length ?? 0,
|
|
242
276
|
at: new Date().toISOString(),
|
|
@@ -352,7 +386,8 @@ export async function coordAwayTool(args: {
|
|
|
352
386
|
`"The clock ran" and "the fleet is observed" are different facts, and this verb must not treat the first as the second. ` +
|
|
353
387
|
`The usual cause is a board 'Branch · Worktree' cell holding a PATH rather than a branch ref: a path resolves for ` +
|
|
354
388
|
`git and measures the wrong thing, so the check reports it unmeasurable rather than guessing. Fix those cells and ` +
|
|
355
|
-
`coverage returns. If you mean to go anyway, pass acknowledgeBlindFleet:true — it is recorded in the state and in the announcement
|
|
389
|
+
`coverage returns. If you mean to go anyway, pass acknowledgeBlindFleet:true — it is recorded in the state and in the announcement.` +
|
|
390
|
+
tickSentence(coverage.tick),
|
|
356
391
|
};
|
|
357
392
|
}
|
|
358
393
|
|
|
@@ -382,7 +417,8 @@ export async function coordAwayTool(args: {
|
|
|
382
417
|
`Decides: planning · priority · curation · roadmap · canon · releases under standing authorisation. ` +
|
|
383
418
|
`Never: merges, gates, or takes a code lane. Parks for David: ${PARKED_CATEGORIES.join(" · ")}. ` +
|
|
384
419
|
`Decisions logged to ${args.decisionLog}. ` +
|
|
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(", ")}` : ""}` : ""}
|
|
420
|
+
`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(", ")}` : ""}` : ""}.` +
|
|
421
|
+
tickSentence(coverage.tick),
|
|
386
422
|
};
|
|
387
423
|
}
|
|
388
424
|
|
package/src/tools/event-kinds.ts
CHANGED
|
@@ -29,7 +29,8 @@ export type SubKind = keyof typeof EVENT_KINDS;
|
|
|
29
29
|
* FIRE, whatever the emitter list and the scan clock say.
|
|
30
30
|
*/
|
|
31
31
|
export type KindProbe = { diff: string; after?: { done?: string; phases?: Record<string, string> } };
|
|
32
|
-
|
|
32
|
+
/** `refs`: ⟨q-cbace757⟩ — a closure cited by N PRs carries all N, each checked against the record on its own; `ref` stays the joined form for the event key. */
|
|
33
|
+
export type RecordEvent = { kind: SubKind; target: string; ref: string; summary: string; refs?: string[] };
|
|
33
34
|
|
|
34
35
|
/*
|
|
35
36
|
* THE KIND REGISTRY IS THE SINGLE SOURCE, AND THAT IS TASK 9.2.
|
package/src/tools/events.ts
CHANGED
|
@@ -177,11 +177,18 @@ export const eventKey = (kind: SubKind, target: string, ref: string): string =>
|
|
|
177
177
|
*/
|
|
178
178
|
export function eventIsDerived(recordText: string, ev: RecordEvent): { ok: true } | { ok: false; error: string } {
|
|
179
179
|
if (!ev.ref) return { ok: false, error: `event for ${ev.kind} ${ev.target} carries no ref — nothing ties it to a record entry` };
|
|
180
|
-
|
|
180
|
+
// ⟨q-cbace757⟩ — a multi-PR closer is N citations, checked ONE BY ONE. The
|
|
181
|
+
// joined string ("a#1, a#2, a#3") is how the event is keyed, not how the record
|
|
182
|
+
// is read: a DONE line citing the same three PRs in another arrangement carries
|
|
183
|
+
// every ref and none of the joined form. The guard stays exactly as strict per
|
|
184
|
+
// ref — a citation absent from the record still refuses, and is named.
|
|
185
|
+
const refs = ev.refs?.length ? ev.refs : [ev.ref];
|
|
186
|
+
const missing = refs.filter((r) => !String(recordText).includes(r));
|
|
187
|
+
if (missing.length)
|
|
181
188
|
return {
|
|
182
189
|
ok: false,
|
|
183
190
|
error:
|
|
184
|
-
`refusing to emit ${ev.kind} ${ev.target}: its ref ${ev.ref} is NOT in the record. ` +
|
|
191
|
+
`refusing to emit ${ev.kind} ${ev.target}: ${missing.length === refs.length && refs.length === 1 ? `its ref ${ev.ref}` : `${missing.length} of its ${refs.length} cited ref(s) — ${missing.join(", ")} —`} is NOT in the record. ` +
|
|
185
192
|
`An event that exists without the record change that caused it is a second source of truth — ` +
|
|
186
193
|
`the stream would claim something the authoritative document does not.`,
|
|
187
194
|
};
|
|
@@ -0,0 +1,87 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* IN-PROCESS DELIVERY FOR HERDR SEATS (Phase 5.4 Task 4, box 4.4).
|
|
3
|
+
*
|
|
4
|
+
* A tmux seat has a pusher that tails its inbox and the rooms it joined, formats a batch,
|
|
5
|
+
* types it into the pane and advances the PUSH cursor once the paste is verified submitted
|
|
6
|
+
* (hooks/tmux-pusher.mjs + hooks/push-cursor.mjs). A herdr seat has no pusher, so the
|
|
7
|
+
* server does the same at SEND time: the message it just appended is formatted by the very
|
|
8
|
+
* same formatter the pusher uses (hooks/tier.mjs — one renderer, so the pane sees one
|
|
9
|
+
* shape on either transport), pushed through the transport, and ONLY on a verified
|
|
10
|
+
* delivery is the seat's push cursor advanced past it.
|
|
11
|
+
*
|
|
12
|
+
* THE CURSOR RULE IS THE PUSHER'S, UNCHANGED (⟨q-7be94b5e⟩): a paste that did not reach the
|
|
13
|
+
* pane must not mark the message consumed. The push cursor is advanced to the offset the
|
|
14
|
+
* append produced, and never touched when the transport reports not-delivered — the
|
|
15
|
+
* message stays where `read_messages` still serves it. The READ cursor is the agent's and
|
|
16
|
+
* is never written here.
|
|
17
|
+
*
|
|
18
|
+
* Simplification stated: the pusher batches routine room traffic into digests on a
|
|
19
|
+
* debounce; a herdr seat receives each message as it is sent, in the digest FORMAT (the
|
|
20
|
+
* `[agent-coord] …` banner and the attributed line) but one at a time. Tiers still decide
|
|
21
|
+
* the banner text. Batching for herdr is Task 5 territory if it is wanted.
|
|
22
|
+
*/
|
|
23
|
+
import { statSync } from "node:fs";
|
|
24
|
+
import path from "node:path";
|
|
25
|
+
import { pathToFileURL } from "node:url";
|
|
26
|
+
import { activeTransport, HERDR, type TransportMarker } from "../transports/index.js";
|
|
27
|
+
import { ROOT, inboxFile, roomFile } from "../store.js";
|
|
28
|
+
import { loadLiveTransports } from "./registry.js";
|
|
29
|
+
import type { Message } from "./shared.js";
|
|
30
|
+
|
|
31
|
+
type Tier = { formatBatch: (batch: unknown[], agentId: string, rooms: string[]) => string; classifyTier: (m: unknown, opts?: unknown) => string };
|
|
32
|
+
type PushCursor = { readPushCursor: (root: string, safeId: string) => Record<string, unknown>; writePushCursor: (root: string, safeId: string, c: Record<string, unknown>) => void };
|
|
33
|
+
let hooks: Promise<{ tier: Tier; cursor: PushCursor }> | undefined;
|
|
34
|
+
function loadHooks(): Promise<{ tier: Tier; cursor: PushCursor }> {
|
|
35
|
+
hooks ??= (async () => {
|
|
36
|
+
const base = path.resolve(path.dirname(new URL(import.meta.url).pathname), "..", "..", "hooks");
|
|
37
|
+
const tier = (await import(pathToFileURL(path.join(base, "tier.mjs")).href)) as Tier;
|
|
38
|
+
const cursor = (await import(pathToFileURL(path.join(base, "push-cursor.mjs")).href)) as PushCursor;
|
|
39
|
+
return { tier, cursor };
|
|
40
|
+
})();
|
|
41
|
+
return hooks;
|
|
42
|
+
}
|
|
43
|
+
const safeId = (id: string) => id.replace(/[^A-Za-z0-9._-]/g, "_");
|
|
44
|
+
|
|
45
|
+
export type HerdrDeliveryOutcome = { agentId: string; delivered: boolean; enters?: number; error?: string; cursorAdvanced: boolean };
|
|
46
|
+
|
|
47
|
+
/** Deliver one just-appended message to every herdr-attached recipient among `recipients`. */
|
|
48
|
+
export async function deliverToHerdrSeats(msg: Message, recipients: string[], where: { kind: "dm" } | { kind: "room"; chan: string }): Promise<HerdrDeliveryOutcome[]> {
|
|
49
|
+
const t = activeTransport();
|
|
50
|
+
if (!t || t.kind !== HERDR) return [];
|
|
51
|
+
let markers: Map<string, TransportMarker>;
|
|
52
|
+
try { markers = await loadLiveTransports(); } catch { return []; }
|
|
53
|
+
const out: HerdrDeliveryOutcome[] = [];
|
|
54
|
+
for (const agentId of recipients) {
|
|
55
|
+
const marker = markers.get(agentId);
|
|
56
|
+
if (!marker || marker.transport !== HERDR) continue;
|
|
57
|
+
if (where.kind === "room" && marker.rooms === false) continue;
|
|
58
|
+
if (msg.from === agentId) continue;
|
|
59
|
+
const { tier, cursor } = await loadHooks();
|
|
60
|
+
const tagged = { ...msg, tag: where.kind === "dm" ? "DM" : `room #${where.chan}` };
|
|
61
|
+
const rendered = tier.formatBatch([{ ...tagged, tier: tier.classifyTier(tagged) }], agentId, where.kind === "room" ? [where.chan] : []);
|
|
62
|
+
// A control never comes through here: send_command hands a herdr seat's control to the
|
|
63
|
+
// transport's sendControl directly (a raw-vs-rendered branch here was dead and its
|
|
64
|
+
// mutation survived, so it is gone). Everything delivered here is a rendered message.
|
|
65
|
+
let r: { delivered: boolean; error?: string; enters?: number };
|
|
66
|
+
try { r = await t.push(marker, rendered); } catch (e) { r = { delivered: false, error: (e as Error).message }; }
|
|
67
|
+
let cursorAdvanced = false;
|
|
68
|
+
if (r.delivered) {
|
|
69
|
+
try {
|
|
70
|
+
const file = where.kind === "dm" ? inboxFile(agentId) : roomFile(where.chan);
|
|
71
|
+
const size = statSync(file).size;
|
|
72
|
+
const id = safeId(agentId);
|
|
73
|
+
const c = cursor.readPushCursor(ROOT, id) ?? {};
|
|
74
|
+
if (where.kind === "dm") c.inboxOffset = Math.max(Number(c.inboxOffset ?? 0), size);
|
|
75
|
+
else {
|
|
76
|
+
const offsets = ((c.roomOffsets as Record<string, number> | undefined) ?? {});
|
|
77
|
+
offsets[where.chan] = Math.max(Number(offsets[where.chan] ?? 0), size);
|
|
78
|
+
c.roomOffsets = offsets;
|
|
79
|
+
}
|
|
80
|
+
cursor.writePushCursor(ROOT, id, c);
|
|
81
|
+
cursorAdvanced = true;
|
|
82
|
+
} catch { cursorAdvanced = false; }
|
|
83
|
+
}
|
|
84
|
+
out.push({ agentId, delivered: r.delivered, enters: r.enters, error: r.error, cursorAdvanced });
|
|
85
|
+
}
|
|
86
|
+
return out;
|
|
87
|
+
}
|
package/src/tools/messaging.ts
CHANGED
|
@@ -84,6 +84,7 @@ import {
|
|
|
84
84
|
isDecision,
|
|
85
85
|
} from "./shared.js";
|
|
86
86
|
import { isKnownHuman } from "../store.js";
|
|
87
|
+
import { deliverToHerdrSeats } from "./herdr-delivery.js";
|
|
87
88
|
import { verifyCommitCite } from "../commit-cite.js";
|
|
88
89
|
|
|
89
90
|
// ---------- send_message ----------
|
|
@@ -454,6 +455,9 @@ export async function sendMessageTool(args: {
|
|
|
454
455
|
};
|
|
455
456
|
const target = inboxFile(args.to);
|
|
456
457
|
await appendJsonl(target, msg);
|
|
458
|
+
// Phase 5.4 Task 4 — a herdr-attached recipient has no pusher: deliver now, in-process.
|
|
459
|
+
const herdr = await deliverToHerdrSeats(msg, [args.to], { kind: "dm" });
|
|
460
|
+
void herdr;
|
|
457
461
|
// Offline delivery is intentional (the inbox is created on demand), but a
|
|
458
462
|
// typo'd recipient shouldn't vanish silently — surface a warning when the
|
|
459
463
|
// target isn't a known agent so the caller can catch the mistake.
|
|
@@ -482,6 +486,10 @@ export async function sendMessageTool(args: {
|
|
|
482
486
|
};
|
|
483
487
|
const target = roomFile(chan);
|
|
484
488
|
await appendJsonl(target, msg);
|
|
489
|
+
// Phase 5.4 Task 4 — herdr-attached members with rooms on receive the post now, in-process.
|
|
490
|
+
const roomMembers = (await getRooms())[chan]?.members ?? [];
|
|
491
|
+
const herdrRoom = await deliverToHerdrSeats(msg, roomMembers, { kind: "room", chan });
|
|
492
|
+
void herdrRoom;
|
|
485
493
|
await maybeCompactRoom(chan);
|
|
486
494
|
const roomWarning = [typedWarning, replyWarning, slashWarning].filter(Boolean).join("; ") || undefined;
|
|
487
495
|
return { ok: true, id: msg.id, target, room: chan, ...(roomWarning ? { warning: roomWarning } : {}) };
|
package/src/tools/queue-write.ts
CHANGED
|
@@ -395,7 +395,10 @@ export async function queueWriteTool(args: {
|
|
|
395
395
|
`item by hand once, or move the queue section last.`,
|
|
396
396
|
);
|
|
397
397
|
}
|
|
398
|
-
|
|
398
|
+
// `own` names the row this call authored. It is already recorded above, so this
|
|
399
|
+
// changes nothing today — it is stated so the guarantee survives a refactor that
|
|
400
|
+
// stops pre-stamping, rather than depending on line 361 staying where it is.
|
|
401
|
+
const w0 = writeDoc(repo, QUEUE_DOC, reparsed, original, [], [target.id]);
|
|
399
402
|
return {
|
|
400
403
|
ok: true,
|
|
401
404
|
op: args.op,
|
|
@@ -120,6 +120,48 @@ const norm = (s: string) => String(s).toLowerCase().replace(/[`*_]/g, "").replac
|
|
|
120
120
|
* against them by the caller (`eventIsDerived`), so a diff that has since been
|
|
121
121
|
* reverted cannot produce an event that outlives the record.
|
|
122
122
|
*/
|
|
123
|
+
/**
|
|
124
|
+
* ⟨q-…⟩ PHASE COMPLETENESS IS THE ABSENCE OF AN UNTICKED BOX, AND IT MUST NOT DEPEND ON WHAT
|
|
125
|
+
* A BOX IS CALLED.
|
|
126
|
+
*
|
|
127
|
+
* ⛔ MEASURED, NOT REASONED — a false "phase 5.4 complete" event was emitted and DELIVERED on
|
|
128
|
+
* 2026-09-16 at `579db9d`. The detector's box pattern required a label shaped `\d+\.\d+`, so
|
|
129
|
+
* against the real document it saw **31 of 36** checkboxes, every one of them ticked, and
|
|
130
|
+
* fired. The three it could not see were `3B.2`, `3B.3` and `3B.4` — labels with a LETTER
|
|
131
|
+
* inside the number — and `3B.3` is deliberately unticked and not recoverable.
|
|
132
|
+
*
|
|
133
|
+
* ⚠ SO THE CAUSE IS THE LABEL PATTERN, NOT THE POSITION OF THE LAST BOX. The event cited
|
|
134
|
+
* `- [x] 6.4`, the document's final line, which made it look like a last-line rule; the ref is
|
|
135
|
+
* only what it cites. Any doc whose remaining work carries an unanticipated label reported
|
|
136
|
+
* itself finished, wherever that work sat.
|
|
137
|
+
*
|
|
138
|
+
* ⭐ THE FIX IS A DIFFERENT QUESTION, not a wider regex for the same one: "is any checkbox in
|
|
139
|
+
* this document unticked" is asked over EVERY markdown checkbox, whatever follows it. A label
|
|
140
|
+
* shape nobody has invented yet cannot defeat it. The failure direction is deliberate — an
|
|
141
|
+
* unticked box anywhere blocks the event, so an unrecognised box costs a MISSED completion,
|
|
142
|
+
* never a FALSE one. This code's own comment already said which way to lean: *"A false
|
|
143
|
+
* completion is worse than a missing one: it closes a phase nobody finished."*
|
|
144
|
+
*/
|
|
145
|
+
export const ANY_BOX = /^[ \t]*[-*+][ \t]*\[([ xX])\][ \t]*(.*)$/;
|
|
146
|
+
export const TICKED_BOX = /^[ \t]*[-*+][ \t]*\[[xX]\]/;
|
|
147
|
+
|
|
148
|
+
export type PhaseBox = { ticked: boolean; label: string | null; line: string };
|
|
149
|
+
|
|
150
|
+
/** Every markdown checkbox in a phase document, in file order, label-agnostic. */
|
|
151
|
+
export function checkboxesIn(text: string): PhaseBox[] {
|
|
152
|
+
const out: PhaseBox[] = [];
|
|
153
|
+
for (const raw of String(text).split("\n")) {
|
|
154
|
+
const m = ANY_BOX.exec(raw);
|
|
155
|
+
if (!m) continue;
|
|
156
|
+
const rest = m[2] ?? "";
|
|
157
|
+
out.push({ ticked: m[1]!.toLowerCase() === "x", label: (/^\*{0,2}([A-Za-z0-9][A-Za-z0-9.]*)\b/.exec(rest) ?? [])[1] ?? null, line: raw.trim() });
|
|
158
|
+
}
|
|
159
|
+
return out;
|
|
160
|
+
}
|
|
161
|
+
|
|
162
|
+
/** The unticked boxes, for a caller that wants to say WHY it did not fire. */
|
|
163
|
+
export const untickedIn = (text: string): PhaseBox[] => checkboxesIn(text).filter((b) => !b.ticked);
|
|
164
|
+
|
|
123
165
|
export function eventsFromCommittedChange(
|
|
124
166
|
diff: string,
|
|
125
167
|
after: { done?: string; phases?: Record<string, string> } = {},
|
|
@@ -257,14 +299,14 @@ export function eventsFromCommittedChange(
|
|
|
257
299
|
// finished.
|
|
258
300
|
for (const [rel, text] of Object.entries(after.phases ?? {})) {
|
|
259
301
|
if (!isPhaseDoc(rel)) continue;
|
|
260
|
-
const boxes =
|
|
261
|
-
if (!boxes.length || boxes.some((
|
|
302
|
+
const boxes = checkboxesIn(String(text));
|
|
303
|
+
if (!boxes.length || boxes.some((b) => !b.ticked)) continue;
|
|
262
304
|
// This commit must have ticked a box IN THIS FILE.
|
|
263
|
-
const closedHere = (byFile.get(rel)?.added ?? []).some((l) =>
|
|
305
|
+
const closedHere = (byFile.get(rel)?.added ?? []).some((l) => TICKED_BOX.test(l));
|
|
264
306
|
if (!closedHere) continue;
|
|
265
307
|
const phase = /phase(\d+(?:\.\d+)?)/i.exec(rel)?.[1];
|
|
266
308
|
if (!phase) continue;
|
|
267
|
-
events.push({ kind: "phase", target: phase, ref: boxes[boxes.length - 1]
|
|
309
|
+
events.push({ kind: "phase", target: phase, ref: boxes[boxes.length - 1]!.line, summary: `phase ${phase} — every task box ticked` });
|
|
268
310
|
}
|
|
269
311
|
|
|
270
312
|
lastUnattributedItems = unattributedItems;
|
package/src/tools/records.ts
CHANGED
|
@@ -45,6 +45,7 @@ import { verdictsFor, gatedBy, prVerdictsIn } from "../gated-head.js";
|
|
|
45
45
|
import { boardRefFor, classifyBoardRef } from "./board-ref.js";
|
|
46
46
|
import { haltState } from "./stall.js";
|
|
47
47
|
import { readSubs, evaluate, commitEvaluation, eventIsDerived, type RecordEvent } from "./events.js";
|
|
48
|
+
import { prRefsIn } from "./record-events.js";
|
|
48
49
|
|
|
49
50
|
const QUEUE_DOC = "docs/QUEUE.md";
|
|
50
51
|
const DONE_DOC = "docs/DONE.md";
|
|
@@ -148,8 +149,12 @@ export function writeDoc(
|
|
|
148
149
|
doc: WorkDoc,
|
|
149
150
|
original: string,
|
|
150
151
|
alreadyWritten: string[] = [],
|
|
152
|
+
own: readonly string[] = [],
|
|
151
153
|
): { written: boolean; stamped: string[] } {
|
|
152
|
-
|
|
154
|
+
// `own` = the rows THIS CALL authored. Everything else is stamped only if the
|
|
155
|
+
// document already speaks the recorded-id grammar — see `stampQueueIds`, which
|
|
156
|
+
// holds the rule so both writers cannot drift apart on it.
|
|
157
|
+
const { text, stamped } = renderWorkDocForWrite(doc, { own });
|
|
153
158
|
if (text === original) return { written: false, stamped: [] };
|
|
154
159
|
const p = path.join(repo, rel);
|
|
155
160
|
const current = existsSync(p) ? readFileSync(p, "utf8") : "";
|
|
@@ -169,6 +174,53 @@ function prNumber(pr: string): string | null {
|
|
|
169
174
|
const m = /#(\d+)\b/.exec(String(pr)) ?? /^(\d+)$/.exec(String(pr).trim());
|
|
170
175
|
return m ? (m[1] as string) : null;
|
|
171
176
|
}
|
|
177
|
+
/** ⟨q-cbace757⟩ — EVERY PR number a citation names, in order; a multi-PR closer is N citations. */
|
|
178
|
+
function prNumbersOf(pr: string): string[] {
|
|
179
|
+
const out: string[] = [];
|
|
180
|
+
for (const m of String(pr).matchAll(/#(\d+)\b/g)) if (!out.includes(m[1]!)) out.push(m[1]!);
|
|
181
|
+
if (!out.length) { const one = prNumber(pr); if (one) out.push(one); }
|
|
182
|
+
return out;
|
|
183
|
+
}
|
|
184
|
+
/*
|
|
185
|
+
* ⟨q-b7198479⟩ — THE LOOKUP IS NOT BOUNDED BY A WINDOW. `git log -n 400` missed
|
|
186
|
+
* #245's squash 777 commits back on an 1806-commit main and the verb then
|
|
187
|
+
* asserted "#245 is not on origin/main" as a fact — a truncated search reported
|
|
188
|
+
* as an absence, at the verb that writes the delivery record, and worded with a
|
|
189
|
+
* mechanism (target tip vs merge base) that had nothing to do with it. At the
|
|
190
|
+
* fleet's speed (~200 record commits a day) 400 is about two days, so it fires
|
|
191
|
+
* precisely on the oldest rows, the ones closed late.
|
|
192
|
+
*
|
|
193
|
+
* Now: `git log --grep` over the WHOLE of the ref, then the SUBJECT is judged
|
|
194
|
+
* BY IDENTITY, the rule #327's mergeOf and the closing-line grammar already use:
|
|
195
|
+
* the landing is the commit whose subject ENDS with the forge's `(#N)` marker.
|
|
196
|
+
* A bare `#N` anywhere is a mention, never a landing — qa measured the first
|
|
197
|
+
* cut of this (`\(#N\)|#N\b`, newest first) on live origin/main: 149 of 322
|
|
198
|
+
* squash-merged PRs resolved to their CLOSURE or board commit ("docs(record):
|
|
199
|
+
* close … by content on #245"), which carry the bare number in the subject and
|
|
200
|
+
* sit newer than the squash. Among trailing-(#N) subjects the OLDEST wins: a
|
|
201
|
+
* hand-written "docs: log Task 10 (#164)" copied the marker 36 s after #164's
|
|
202
|
+
* squash and would otherwise be taken for it. The one PR of 323 on this main
|
|
203
|
+
* that landed as a merge commit (#108, "Merge pull request #108 from …") is the
|
|
204
|
+
* second identity form — the forge's own, not a mention — and is the last
|
|
205
|
+
* resort, consulted only when no trailing form exists. A miss reports what was
|
|
206
|
+
* searched — every subject on the ref — never a mechanism it did not test.
|
|
207
|
+
*/
|
|
208
|
+
export function landingCommitOf(repo: string, ref: string, n: string): { sha: string | null; searched: number } {
|
|
209
|
+
const searched = Number(git(repo, ["rev-list", "--count", ref])) || 0;
|
|
210
|
+
// git's --grep is a basic regex with no `\b`; identity is decided on the SUBJECT below.
|
|
211
|
+
const out = git(repo, ["log", ref, "--format=%H%x00%s", `--grep=#${n}`]);
|
|
212
|
+
const trailing = new RegExp(`\\(#${n}\\)\\s*$`);
|
|
213
|
+
const mergeForm = new RegExp(`^Merge pull request #${n}(?:\\s|$)`);
|
|
214
|
+
let landing: string | null = null;
|
|
215
|
+
let merge: string | null = null;
|
|
216
|
+
for (const line of out.split("\n")) {
|
|
217
|
+
const [sha, subject = ""] = line.split("\x00");
|
|
218
|
+
if (!sha) continue;
|
|
219
|
+
if (trailing.test(subject)) landing = sha; // newest first: the last hit is the OLDEST
|
|
220
|
+
else if (!merge && mergeForm.test(subject)) merge = sha;
|
|
221
|
+
}
|
|
222
|
+
return { sha: landing ?? merge, searched };
|
|
223
|
+
}
|
|
172
224
|
|
|
173
225
|
/**
|
|
174
226
|
* The `owner/repo` a BARE `#N` in this repo's docs should be read as naming —
|
|
@@ -1015,19 +1067,31 @@ export async function landTool(
|
|
|
1015
1067
|
const ref = `origin/${base}`;
|
|
1016
1068
|
let landedIn: string | null = null;
|
|
1017
1069
|
let reason: string | null = null;
|
|
1070
|
+
// ⟨q-cbace757⟩ — every cited PR must be on the ref, not only the first.
|
|
1071
|
+
const numbers = prNumbersOf(args.pr);
|
|
1072
|
+
const landings: Record<string, string> = {};
|
|
1073
|
+
let searched = 0;
|
|
1074
|
+
const missing: string[] = [];
|
|
1018
1075
|
try {
|
|
1019
|
-
|
|
1020
|
-
|
|
1021
|
-
|
|
1076
|
+
for (const num of numbers) {
|
|
1077
|
+
const found = landingCommitOf(repo, ref, num);
|
|
1078
|
+
searched = found.searched;
|
|
1079
|
+
if (found.sha) landings[num] = found.sha; else missing.push(num);
|
|
1080
|
+
}
|
|
1081
|
+
landedIn = landings[n] ?? null;
|
|
1022
1082
|
} catch (e) {
|
|
1023
1083
|
reason = `could not read ${ref} (${String((e as Error).message).split("\n")[0]}) — NOT checked, which is not the same as checked and absent`;
|
|
1024
1084
|
}
|
|
1025
1085
|
if (reason) return { ok: false as const, error: reason };
|
|
1026
|
-
if (!landedIn) {
|
|
1086
|
+
if (!landedIn || missing.length) {
|
|
1027
1087
|
return {
|
|
1028
1088
|
ok: false as const,
|
|
1029
|
-
error:
|
|
1089
|
+
error:
|
|
1090
|
+
`${missing.map((m) => `#${m}`).join(", ")} not found on ${ref}: none of the ${searched} commit subject(s) on ${ref} carries ` +
|
|
1091
|
+
`${missing.map((m) => `"(#${m})"`).join(" or ")} — refusing to record it as landed. The whole of ${ref} was searched, not a window; ` +
|
|
1092
|
+
`a PR merged under another number or never merged reads the same here — check \`gh pr view\`.`,
|
|
1030
1093
|
comparedAgainst: ref,
|
|
1094
|
+
searched,
|
|
1031
1095
|
};
|
|
1032
1096
|
}
|
|
1033
1097
|
|
|
@@ -1205,7 +1269,7 @@ export async function landTool(
|
|
|
1205
1269
|
try {
|
|
1206
1270
|
if (args.write) {
|
|
1207
1271
|
if (queueChanged) {
|
|
1208
|
-
const w = writeDoc(repo, QUEUE_DOC, q.doc, q.text, wrote);
|
|
1272
|
+
const w = writeDoc(repo, QUEUE_DOC, q.doc, q.text, wrote, args.queueItemId ? [args.queueItemId] : []);
|
|
1209
1273
|
if (w.written) wrote.push(QUEUE_DOC);
|
|
1210
1274
|
// Rows stamped that this call did not ask to touch. `queueItemId` is the
|
|
1211
1275
|
// one item the caller supplied, so anything else here is a row that
|
|
@@ -1259,7 +1323,9 @@ export async function landTool(
|
|
|
1259
1323
|
if (args.write && target) {
|
|
1260
1324
|
const after = readDoc(repo, DONE_DOC);
|
|
1261
1325
|
const recordText = after?.text ?? "";
|
|
1262
|
-
|
|
1326
|
+
// ⟨q-cbace757⟩ — one event, every cited ref; each checked against the record on its own.
|
|
1327
|
+
const refs = prRefsIn(args.pr);
|
|
1328
|
+
const ev: RecordEvent = { kind: "item", target: target.id, ref: refs.length ? refs.join(", ") : args.pr, summary: summarize(originalText ?? ""), ...(refs.length > 1 ? { refs } : {}) };
|
|
1263
1329
|
const derived = eventIsDerived(recordText, ev);
|
|
1264
1330
|
if (!derived.ok) {
|
|
1265
1331
|
events.refused.push(derived.error);
|