/* * Phase 5.1 Task 9 — the four subscribable kinds all emit, and the TRIGGER is * the record's COMMITTED CHANGE rather than the `land` verb (David's ruling, * option (b), 2026-08-30). * * WHY NOT (a), "emit from the verbs": it bets on every fleet adopting the * workflow layer, and the only evidence available says they do not — a consumer fleet called * `claim`/`land`/`merge`/`next_unblocked`/`rotate`/`set_halt` ZERO times in a day * of heavy use. A `pr` subscription tested against a real PR came back * `health: error — never evaluated`, and the fleet unsubscribed. Kinds that can * be subscribed to and can never fire are a permanent, honestly-reported error. * * WHAT (b) CHANGES, AND WHAT IT DOES NOT: the record stays the source — only the * trigger moves from the verb to the change. That still satisfies 6.2 ("events * derived from the record, never parallel to it"), because the derivation still * reads the record; it just no longer requires that a particular verb performed * the write. THE POINT IS THAT A HAND-EDIT BECOMES A FIRST-CLASS CAUSE rather * than an invisible one, which is what fleets actually do: they merge with `gh` * and edit `docs/DONE.md` by hand. `land` keeps emitting, as ONE WRITER AMONG * SEVERAL rather than as the gate — the idempotency key makes the overlap safe, * since both paths derive the same key from the same event. * * THE COMMIT IS THE BOUNDARY, not the working tree. An uncommitted edit is not * yet a record: it can be reverted, rebased away, or never pushed, and a * notification for work that then vanishes is worse than a late one. */ import { execFileSync } from "node:child_process"; import { newlyTickedInDiff, parseWorkDoc, queueItemsOf, doneEntriesOf, hasCommitRef } from "@davidbalzan/groundwork-seam"; import { EVENT_KINDS, type RecordEvent, type SubKind } from "./event-kinds.js"; export { EVENT_KINDS, EVENT_KIND_IDS } from "./event-kinds.js"; export type { RecordEvent, SubKind } from "./event-kinds.js"; /** * One pass over the diff → per-file added and removed lines. * * Per FILE, not per predicate: the phase rule needs to know WHICH document a * tick came from, and a flat "all added lines" view cannot answer that. It * emitted `phase 5 complete` off a tick in the phase 5.1 document on the first * real-data run. */ export function diffByFile(diff: string): Map { const out = new Map(); let cur: { added: string[]; removed: string[] } | null = null; for (const line of String(diff ?? "").split("\n")) { if (line.startsWith("diff --git ")) { cur = null; continue; } if (line.startsWith("+++ b/")) { const p = line.slice(6).trim(); if (p === "/dev/null") { cur = null; continue; } cur = out.get(p) ?? { added: [], removed: [] }; out.set(p, cur); continue; } if (line.startsWith("--- a/") || line.startsWith("@@")) continue; if (!cur) continue; if (line.startsWith("+")) cur.added.push(line.slice(1)); else if (line.startsWith("-")) cur.removed.push(line.slice(1)); } return out; } const isDone = (p: string) => /(^|\/)DONE\.md$/.test(p); const isQueue = (p: string) => /(^|\/)QUEUE\.md$/.test(p); const isPhaseDoc = (p: string) => /PHASE[^/]*TASKS\.md$/i.test(p); const linesWhere = (byFile: ReturnType, match: (p: string) => boolean, side: "added" | "removed") => [...byFile.entries()].filter(([p]) => match(p)).flatMap(([, v]) => v[side]); /** * A ref that actually identifies a pull request. * * MEASURED ON REAL DATA, and this is why the check exists: the done-entry * parser splits on the LAST ` — `, so an entry whose own text contains an em * dash yields a "ref" of `aide-verified, coordinator-closed`. Emitting that as * a `pr` event would put a target on the bus that names no PR, and a * subscription could never match it — a silent, permanent miss dressed as a * delivery. Parsing correctly is not the same as the parse MEANING what you * assumed. */ const PR_REF = /^(?:[\w.-]+\/[\w.-]+#\d+|#\d+|https:\/\/github\.com\/[\w.-]+\/[\w.-]+\/pull\/\d+)$/; /** * EVERY pr ref in a citation field, not "is the whole field one ref". * * MEASURED, and it is the mirror image of the garbage-ref defect: an anchored * whole-field match rejects `owner/repo#170, #173` — a perfectly good citation * that happens to name TWO PRs. A real closure was reported unattributed for * exactly this, and a pair of PRs is the repo's normal house style for work * that landed across two. * * Tightening to reject prose was right. Rejecting a real citation because it * cites more than one thing was not, and from inside a function that only asks * "does the field EQUAL a ref" the two failures are indistinguishable. * * A BARE `#N` IS EXPANDED against a qualified ref in the same field: in * `owner/repo#170, #173` the second plainly means the same repository, and * emitting a bare `#173` would be an under-qualified target that no * subscription written against `owner/repo#173` could ever match. */ export function prRefsIn(field: string | undefined): string[] { const text = String(field ?? ""); const out: string[] = []; for (const m of text.matchAll(/(?:[\w.-]+\/[\w.-]+#\d+|https:\/\/github\.com\/[\w.-]+\/[\w.-]+\/pull\/\d+)/g)) out.push(m[0]); const owner = out[0]?.match(/^([\w.-]+\/[\w.-]+)#/)?.[1]; for (const m of text.matchAll(/(?:^|[\s,\u00b7;])#(\d+)\b/g)) { const ref = owner ? `${owner}#${m[1]}` : `#${m[1]}`; if (!out.includes(ref)) out.push(ref); } return out; } /** Queue and done text, compared for the pairing below. */ const norm = (s: string) => String(s).toLowerCase().replace(/[`*_]/g, "").replace(/\s+/g, " ").trim(); /** * Events implied by one committed change. * * `after` carries the documents AS THEY NOW STAND — every event is checked * against them by the caller (`eventIsDerived`), so a diff that has since been * reverted cannot produce an event that outlives the record. */ /** * ⟨q-…⟩ PHASE COMPLETENESS IS THE ABSENCE OF AN UNTICKED BOX, AND IT MUST NOT DEPEND ON WHAT * A BOX IS CALLED. * * ⛔ MEASURED, NOT REASONED — a false "phase 5.4 complete" event was emitted and DELIVERED on * 2026-09-16 at `579db9d`. The detector's box pattern required a label shaped `\d+\.\d+`, so * against the real document it saw **31 of 36** checkboxes, every one of them ticked, and * fired. The three it could not see were `3B.2`, `3B.3` and `3B.4` — labels with a LETTER * inside the number — and `3B.3` is deliberately unticked and not recoverable. * * ⚠ SO THE CAUSE IS THE LABEL PATTERN, NOT THE POSITION OF THE LAST BOX. The event cited * `- [x] 6.4`, the document's final line, which made it look like a last-line rule; the ref is * only what it cites. Any doc whose remaining work carries an unanticipated label reported * itself finished, wherever that work sat. * * ⭐ THE FIX IS A DIFFERENT QUESTION, not a wider regex for the same one: "is any checkbox in * this document unticked" is asked over EVERY markdown checkbox, whatever follows it. A label * shape nobody has invented yet cannot defeat it. The failure direction is deliberate — an * unticked box anywhere blocks the event, so an unrecognised box costs a MISSED completion, * never a FALSE one. This code's own comment already said which way to lean: *"A false * completion is worse than a missing one: it closes a phase nobody finished."* */ export const ANY_BOX = /^[ \t]*[-*+][ \t]*\[([ xX])\][ \t]*(.*)$/; export const TICKED_BOX = /^[ \t]*[-*+][ \t]*\[[xX]\]/; export type PhaseBox = { ticked: boolean; label: string | null; line: string }; /** Every markdown checkbox in a phase document, in file order, label-agnostic. */ export function checkboxesIn(text: string): PhaseBox[] { const out: PhaseBox[] = []; for (const raw of String(text).split("\n")) { const m = ANY_BOX.exec(raw); if (!m) continue; const rest = m[2] ?? ""; 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() }); } return out; } /** The unticked boxes, for a caller that wants to say WHY it did not fire. */ export const untickedIn = (text: string): PhaseBox[] => checkboxesIn(text).filter((b) => !b.ticked); export function eventsFromCommittedChange( diff: string, after: { done?: string; phases?: Record } = {}, ): RecordEvent[] { const events: RecordEvent[] = []; const unattributedItems: string[] = []; const byFile = diffByFile(diff); // ── pr: a new DONE.md entry naming a PR ─────────────────────────────────── // Parsed, never regexed off the raw line: the glyph contract (` — ` U+2014, // ` · ` U+00B7) is exact, and a hand-written entry using a plain hyphen must // NOT quietly become an event with a mangled ref. It fails to parse, and a // line that does not parse is not a record entry. const addedDone = linesWhere(byFile, isDone, "added"); const newEntries = doneEntriesOf(parseWorkDoc(`## Done\n${addedDone.join("\n")}\n`)) as Array<{ ref?: string; text?: string }>; for (const entry of newEntries) { // ONE EVENT PER PR NAMED. An entry closing work that spanned two PRs should // wake a subscriber to either; collapsing to the first makes the second // silently unwatchable. for (const ref of prRefsIn(entry.ref)) { events.push({ kind: "pr", target: ref, ref, summary: entry.text?.slice(0, 120) || ref }); } } // ── item: a queue item that closed ──────────────────────────────────────── // // ATTRIBUTION IS THE WHOLE PROBLEM, and the markdown does not carry it. A // queue item and the DONE entry that closes it share no id and, on real data, // no wording either: the item states what to do and the entry states what was // done. `land` only knows because its caller passed `queueItemId`. // // So: pair on text when the texts DO correspond, otherwise pair only when the // commit is unambiguous — exactly one item removed and exactly one entry // added. Anything else is reported as unattributed rather than guessed. The // naive version attributed all 15 items removed in one real commit to the // first PR it saw, which is a wrong claim about fourteen of them, and a wrong // claim on this bus is worse than a missing one. const removedQueue = linesWhere(byFile, isQueue, "removed"); // ⟨q-d527f435⟩ — A REMOVED LINE IS NOT A DEPARTED ITEM. The scan diffed text, // so the aide's in-place rewrite of q-a1c9d4e7 (70c730a) read as a departure // and was reported unattributed. The question is whether the ⟨q-…⟩ id is // still present at the end of the range; three cases, never conflated: // id still present in the ADDED lines → an EDIT — no event, not unattributed // id absent → a DEPARTURE — attributed or reported // id CHANGED (same text, new id) → a RE-ID — reported as such (⟨q-1c4f8ae3⟩) const addedQueue = linesWhere(byFile, isQueue, "added"); // Only rows still OPEN count as present: the house style closes an item by // flipping `[ ]` → `[x]` in place, and that row is closed, not edited. const addedItems = (queueItemsOf(parseWorkDoc(`## Queue\n${addedQueue.join("\n")}\n`)) as Array<{ id?: string; text?: string; done?: boolean }>).filter((i) => i.id && !i.done); const addedIds = new Set(addedItems.map((i) => i.id!)); const reIdentified: { from: string; to: string }[] = []; const removedItems = (queueItemsOf(parseWorkDoc(`## Queue\n${removedQueue.join("\n")}\n`)) as Array<{ id?: string; text?: string }>) .filter((i) => i.id) .filter((i) => { if (addedIds.has(i.id!)) return false; // edited in place: still on the queue const twin = addedItems.find((a) => a.id !== i.id && norm(a.text ?? "") === norm(i.text ?? "") && norm(i.text ?? "")); if (twin) { reIdentified.push({ from: i.id!, to: twin.id! }); return false; } return true; }); lastReIdentifiedItems = reIdentified; // An entry qualifies to CLOSE an item if it cites anything resolvable — a PR, // or an `@sha` commit. `land` requires a PR by rule; the scan reads what the // record actually says, and a commit-cited entry is still the record stating // that the item closed. const citedEntries = newEntries.filter((e) => prRefsIn(e.ref).length > 0 || hasCommitRef(e.ref ?? "")); const unattributed: string[] = []; // ⟨q-2b7d9f04⟩ — THE LINK THE MARKDOWN DOES CARRY. `land` writes the item's // id in LEADING position on the DONE line (#286), and a hand-written line in // land's format carries it the same way. An entry that begins with ⟨q-…⟩ IS // the record of that item closing — read by position, never guessed. The // `item` subscription never fired in three days because this read was // missing while the paragraph above said no link existed. const attributedByLeadingId = new Set(); for (const entry of citedEntries) { const id = leadingItemIdOf(entry.text); if (!id || attributedByLeadingId.has(id)) continue; const ref = prRefsIn(entry.ref)[0] ?? entry.ref?.trim(); if (!ref) continue; attributedByLeadingId.add(id); events.push({ kind: "item", target: id, ref, summary: entry.text?.slice(0, 120) ?? id }); } for (const item of removedItems) { if (attributedByLeadingId.has(item.id!)) continue; const itemText = norm(item.text ?? ""); let entry = itemText ? citedEntries.find((e) => { const t = norm(e.text ?? ""); return t && (t === itemText || t.startsWith(itemText) || itemText.startsWith(t)); }) : undefined; // The unambiguous-commit case: one out, one in. This is the shape the fleet // actually commits ("close its queue item"), and it is the case the aide's // dead `item` subscriptions need. if (!entry && removedItems.length === 1 && citedEntries.length === 1) entry = citedEntries[0]; if (!entry) { unattributed.push(item.id!); continue; } // The FIRST pr ref is the item's, falling back to the commit citation: an // item closes once, so it gets one ref, and it is the one a reader follows. const ref = prRefsIn(entry.ref)[0] ?? entry.ref?.trim(); if (!ref) { unattributed.push(item.id!); continue; } events.push({ kind: "item", target: item.id!, ref, summary: entry.text?.slice(0, 120) ?? item.id! }); } if (unattributed.length) unattributedItems.push(...unattributed); // ── task: checkbox transitions ──────────────────────────────────────────── // `newlyTickedInDiff` requires BOTH a removed open box and an added ticked one // for the same id, so a moved or reformatted line cannot manufacture a // completion — that rule is the seam's and is reused rather than re-derived. const ticked = [...newlyTickedInDiff(diff)]; const tickedLines = linesWhere(byFile, isPhaseDoc, "added"); for (const key of ticked) { const id = key.split(":")[1] ?? ""; // Ref is the ticked LINE, not the bare id: `12.1` appears in prose all over // a phase doc, so a bare id would pass the derivation check against a // document that never ticked anything. const line = tickedLines.find((l) => new RegExp(`^\\s*-\\s*\\[x\\]\\s*\\*{0,2}${id.replace(".", "\\.")}\\b`, "i").test(l)); if (!line) continue; events.push({ kind: "task", target: key, ref: line.trim(), summary: line.trim().slice(0, 120) }); } // ── phase: the last open box in ONE document ────────────────────────────── // Keyed on the FILE, both for "is it complete" and for "did this change close // it". `newlyTickedInDiff` keys ticks by the integer in the path, so // `phase5.1` and `phase5` collapse to the same `5:` — the first real-data run // announced PHASE 5 COMPLETE on the strength of a tick in the 5.1 document. // A false completion is worse than a missing one: it closes a phase nobody // finished. for (const [rel, text] of Object.entries(after.phases ?? {})) { if (!isPhaseDoc(rel)) continue; const boxes = checkboxesIn(String(text)); if (!boxes.length || boxes.some((b) => !b.ticked)) continue; // This commit must have ticked a box IN THIS FILE. const closedHere = (byFile.get(rel)?.added ?? []).some((l) => TICKED_BOX.test(l)); if (!closedHere) continue; const phase = /phase(\d+(?:\.\d+)?)/i.exec(rel)?.[1]; if (!phase) continue; events.push({ kind: "phase", target: phase, ref: boxes[boxes.length - 1]!.line, summary: `phase ${phase} — every task box ticked` }); } lastUnattributedItems = unattributedItems; return events; } /** * Queue item ids removed in the last scanned change that could NOT be tied to a * done entry. Reported by `scan_record_events` rather than dropped: "we saw * items close and could not say which PR closed them" and "nothing closed" are * different facts, and only one of them needs a human. */ export let lastUnattributedItems: string[] = []; /** ⟨q-d527f435⟩ — removed rows whose TEXT reappeared under a NEW id in the same change: re-identified, not departed. */ export let lastReIdentifiedItems: { from: string; to: string }[] = []; /** ⟨q-2b7d9f04⟩ — the ⟨q-…⟩ id in LEADING position on a DONE entry's text (bold allowed), or null. Position, not occurrence: an id quoted mid-sentence is a mention. */ export function leadingItemIdOf(text: string | undefined): string | null { const m = /^\s*(?:\*\*)?⟨(q-[0-9a-f]{8})⟩/.exec(String(text ?? "")); return m ? m[1]! : null; } /** * ⟨q-2b7d9f04⟩ — CAN THIS KIND FIRE AT ALL? Runs the scanner over the kind's * own probe (event-kinds.ts) and asks whether an event of that kind comes out. * Health reads this as CAPABILITY, beside the scan clock's LIVENESS: a kind * whose probe yields nothing cannot be produced by the grammar, and a * subscription to it is not `ok` however recently the scanner ran. */ export function kindCapability(kind: SubKind): { capable: boolean; produced: number; why: string } { const probe = (EVENT_KINDS[kind] as { probe?: { diff: string; after?: { done?: string; phases?: Record } } }).probe; if (!probe) return { capable: false, produced: 0, why: `'${kind}' ships no probe — its capability cannot be shown` }; const saved = lastUnattributedItems; let produced = 0; try { produced = eventsFromCommittedChange(probe.diff, probe.after ?? {}).filter((e) => e.kind === kind).length; } finally { lastUnattributedItems = saved; } return produced > 0 ? { capable: true, produced, why: `the scanner produced ${produced} '${kind}' event(s) from the kind's own probe` } : { capable: false, produced: 0, why: `the scanner produced NO '${kind}' event from the kind's own probe — this kind CANNOT FIRE on any commit, whatever the scan clock says` }; } export const kindCapabilities = (): Record> => Object.fromEntries((Object.keys(EVENT_KINDS) as SubKind[]).map((k) => [k, kindCapability(k)])) as Record>; /** `git` in a repo, returning "" rather than throwing — a scan is read-only. */ export function git(repo: string, args: string[]): string { try { return execFileSync("git", args, { cwd: repo, encoding: "utf8", maxBuffer: 32 * 1024 * 1024 }); } catch { return ""; } } /* ── the verb ──────────────────────────────────────────────────────────────── */ import { existsSync, readFileSync, writeFileSync, mkdirSync } from "node:fs"; import path from "node:path"; import { z } from "zod"; import { ROOT } from "../store.js"; import { readSubs, evaluate, commitEvaluation, eventIsDerived, markScanned, setCapabilityProbe } from "./events.js"; // ⟨q-2b7d9f04⟩ — health consults the scanner's grammar through this hook (events.ts cannot import this module: the kinds are its leaf and record-events imports events). setCapabilityProbe((kind) => kindCapability(kind).capable); /** * The watermark: the last commit whose record change has been turned into * events, per repo. * * It is stored rather than inferred, because "what have I already emitted" is * not derivable from the repo — and the alternative, re-deriving from some * fixed point every time, would re-announce a year of closures on first run. * Losing the file is safe in the direction that matters: the idempotency key is * derived from the EVENT, so a re-scan of already-delivered events reports * `duplicate-suppressed` rather than waking anyone twice. */ const watermarkFile = () => path.join(ROOT, "record-events.json"); type Watermarks = Record; function readWatermarks(): Watermarks { const f = watermarkFile(); if (!existsSync(f)) return {}; try { return (JSON.parse(readFileSync(f, "utf8")).repos ?? {}) as Watermarks; } catch { return {}; } } function writeWatermark(repo: string, sha: string): void { mkdirSync(ROOT, { recursive: true }); const all = readWatermarks(); all[repo] = { sha, at: Date.now() }; writeFileSync(watermarkFile(), `${JSON.stringify({ repos: all }, null, 2)}\n`); } export const scanRecordEventsSchema = { repo: z.string().min(1), /** Defaults to the stored watermark; first run with none scans HEAD~1..HEAD. */ since: z.string().optional(), /** Report only. Default true — same posture as `land` and `next_unblocked`. */ write: z.boolean().optional(), }; export async function scanRecordEventsTool(args: { repo: string; since?: string; write?: boolean }) { const repo = path.resolve(args.repo); if (!existsSync(path.join(repo, ".git"))) { 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` }; } const head = git(repo, ["rev-parse", "HEAD"]).trim(); if (!head) return { ok: false as const, error: `could not resolve HEAD in ${repo}` }; const stored = readWatermarks()[repo]?.sha; const since = args.since ?? stored ?? `${head}~1`; // A watermark from a rebased-away commit resolves to nothing. Say so rather // than silently falling back to HEAD~1 and reporting a one-commit scan as if // it covered the gap — that is the shape where a miss looks like a clean run. if (!git(repo, ["cat-file", "-e", `${since}^{commit}`]) && !git(repo, ["rev-parse", "--verify", `${since}^{commit}`]).trim()) { return { ok: false as const, error: `base '${since}' does not resolve in ${repo} — it was probably rebased away. ` + `Nothing was scanned and the watermark was NOT advanced: a scan that silently narrows its window ` + `reports a clean run over the commits it never looked at. Pass an explicit 'since'.`, }; } const diff = git(repo, ["diff", "--unified=0", `${since}..${head}`, "--", "docs/"]); const doneText = existsSync(path.join(repo, "docs/DONE.md")) ? readFileSync(path.join(repo, "docs/DONE.md"), "utf8") : ""; const phases: Record = {}; for (const rel of git(repo, ["ls-files", "docs/phases/"]).split("\n").filter((p) => /PHASE[^/]*TASKS\.md$/i.test(p))) { const p = path.join(repo, rel); if (existsSync(p)) phases[rel] = readFileSync(p, "utf8"); } const candidates = eventsFromCommittedChange(diff, { done: doneText, phases }); const unattributed = [...lastUnattributedItems]; const reIdentified = [...lastReIdentifiedItems]; // Every event is checked against the record AS IT NOW STANDS (6.2). A change // that has since been reverted produces a candidate the record no longer // supports, and it is refused — the stream can never claim what the // authoritative markdown does not. const emitted: RecordEvent[] = []; const refused: string[] = []; for (const ev of candidates) { const recordText = ev.kind === "task" || ev.kind === "phase" ? Object.values(phases).join("\n") : doneText; const derived = eventIsDerived(recordText, ev); if (derived.ok) emitted.push(ev); else refused.push(derived.error); } const deliveries: unknown[] = []; if (args.write) { // MARK THE SCAN BEFORE THE EVENTS, and unconditionally. // // A scan that produced NO events is exactly the case that starved the old // health field: `evaluate` is per-event, so a quiet scan touched nothing // and every subscription kept reading "never evaluated" — indistinguishable // from unwired. The scan RAN; that is a fact about the machinery and it is // recorded whether or not anything fired. const now = Date.now(); let subs = markScanned(readSubs(), now); for (const ev of emitted) { const r = evaluate(subs, ev, now); subs = r.subs; deliveries.push(...r.deliveries); } commitEvaluation(subs); writeWatermark(repo, head); } return { ok: true as const, repo, scanned: { from: since, to: head }, emitted, refused, deliveries, ...(unattributed.length ? { unattributedItems: unattributed, unattributedNote: `${unattributed.length} queue item(s) left docs/QUEUE.md in this range with NO done entry carrying their id in leading position ` + `(\`- [x] ⟨q-…⟩ …\`, the form land writes) and no unambiguous pairing. They emitted NOTHING rather than a guess: a DONE line ` + `without a leading id names no item. "Closed by an unknown entry" and "not closed" are different facts and this is the first.`, } : {}), ...(reIdentified.length ? { reIdentifiedItems: reIdentified, reIdentifiedNote: `${reIdentified.length} queue row(s) reappeared under a NEW id with the same text — re-identified, neither departed nor closed (⟨q-1c4f8ae3⟩'s subject).` } : {}), ...(args.write ? { watermark: head } : { note: "REPORT ONLY — nothing was delivered and the watermark was not advanced. Pass write:true to deliver. " + "Re-scanning an already-delivered range is safe: the idempotency key is derived from the event, so it reports duplicate-suppressed.", }), kinds: EVENT_KINDS, }; }