agent-coord-mcp 0.26.20 → 0.26.21

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.
@@ -15,6 +15,8 @@ import { execFileSync } from "node:child_process";
15
15
  import { existsSync, readFileSync, writeFileSync } from "node:fs";
16
16
  import path from "node:path";
17
17
  import { z } from "zod";
18
+
19
+ import { treeProvenance } from "./tree-provenance.js";
18
20
  import {
19
21
  parseWorkDoc,
20
22
  renderWorkDoc,
@@ -34,6 +36,8 @@ import {
34
36
  type DoneEntry,
35
37
  } from "@davidbalzan/groundwork-seam";
36
38
  import { ensureWorktreeTool } from "./worktrees.js";
39
+ import { ROOT } from "../store.js";
40
+ import { verdictsFor, gatedAt } from "../gated-head.js";
37
41
  import { boardRefFor, classifyBoardRef } from "./board-ref.js";
38
42
  import { haltState, isInFlightStatus } from "./stall.js";
39
43
  import { readSubs, evaluate, commitEvaluation, eventIsDerived, type RecordEvent } from "./events.js";
@@ -387,8 +391,15 @@ export async function nextUnblockedTool(args: { project: string; repo?: string }
387
391
  };
388
392
  }
389
393
  const repo = args.repo ?? process.cwd();
394
+ // ⛔⛆ SAY WHICH TREE THIS ANSWER CAME FROM (⟨q-c1af2db3⟩). This verb reads
395
+ // `docs/QUEUE.md` out of a working tree nobody owns, and a stale one produced
396
+ // a confidently wrong routing decision in both directions inside ten minutes —
397
+ // a row ruled un-claimable from a stale file, and a worker told to hold on a
398
+ // row that was already split. The answer was well-formed and said nothing
399
+ // about its source, which is what made it invisible.
400
+ const tree = treeProvenance(repo);
390
401
  const q = readDoc(repo, QUEUE_DOC);
391
- if (!q) return { ok: false as const, error: `no ${QUEUE_DOC} under '${repo}'` };
402
+ if (!q) return { ok: false as const, error: `no ${QUEUE_DOC} under '${repo}'`, tree };
392
403
  const items = queueItemsOf(q.doc);
393
404
  // The seam's own count of unticked rows — the number every axis must add back
394
405
  // up to. Taken BEFORE any exclusion so it cannot inherit one.
@@ -533,6 +544,10 @@ export async function nextUnblockedTool(args: { project: string; repo?: string }
533
544
  // the severity finding one file over. Say the axis is uninformative instead.
534
545
  const undiscriminating = silent.length === open.length && open.length > 1;
535
546
  return {
547
+ // ⛔ THE PROVENANCE TRAVELS WITH THE ANSWER, not in a second call. A
548
+ // routing answer whose tree is unnamed is the defect this row exists for.
549
+ tree,
550
+ ...(tree.warning ? { staleWarning: tree.warning } : {}),
536
551
  ok: true as const,
537
552
  project: args.project,
538
553
  open: open.length,
@@ -822,7 +837,29 @@ export const landSchema = {
822
837
  result: z.string().optional(),
823
838
  };
824
839
 
825
- export async function landTool(args: {
840
+ /**
841
+ * What the recording step needs to know about a merge: which head actually
842
+ * landed, and when. Injected so the report is provable without the network.
843
+ */
844
+ export type MergeFacts = { headRefOid: string; mergedAt: string } | null;
845
+ export type ReadMergeFacts = (repo: string, n: string) => MergeFacts;
846
+ const ghMergeFacts: ReadMergeFacts = (repo, n) => {
847
+ try {
848
+ const out = execFileSync("gh", ["pr", "view", n, "--json", "headRefOid,mergedAt"], {
849
+ cwd: repo,
850
+ encoding: "utf8",
851
+ stdio: ["ignore", "pipe", "ignore"],
852
+ });
853
+ const j = JSON.parse(out) as { headRefOid?: string; mergedAt?: string };
854
+ if (!j.headRefOid || !j.mergedAt) return null;
855
+ return { headRefOid: String(j.headRefOid), mergedAt: String(j.mergedAt) };
856
+ } catch {
857
+ return null;
858
+ }
859
+ };
860
+
861
+ export async function landTool(
862
+ args: {
826
863
  project: string;
827
864
  pr: string;
828
865
  queueItemId?: string;
@@ -830,7 +867,10 @@ export async function landTool(args: {
830
867
  base?: string;
831
868
  write?: boolean;
832
869
  result?: string;
833
- }) {
870
+ },
871
+ readMergeFacts: ReadMergeFacts = ghMergeFacts,
872
+ readVerdictLog: ReadVerdictLog = roomLog,
873
+ ) {
834
874
  const repo = args.repo ?? process.cwd();
835
875
  const base = args.base ?? "main";
836
876
  const n = prNumber(args.pr);
@@ -945,8 +985,41 @@ export async function landTool(args: {
945
985
  const bareRef = !/[\w.-]+\/[\w.-]+#\d+/.test(String(args.pr));
946
986
  const summary = originalText !== null ? summarize(originalText) : null;
947
987
 
988
+ /*
989
+ * ⛔⛆⛆ THE ROW'S ID LEADS THE ENTRY, BECAUSE A TRUNCATED HEADLINE CANNOT CARRY IT
990
+ * — `⟨q-d046204d⟩`.
991
+ *
992
+ * `summarize` cuts at 96 characters and appends `…`. The sigil was never omitted
993
+ * from these entries: IT WAS CUT, because in the row it sits later than the
994
+ * truncation point. So the entry came out with a PERFECT `ref` slot and a `text`
995
+ * that names no row — and `closingRefs` needs BOTH halves, so the row reads
996
+ * untied and `main` goes red. `⟨q-c1af2db3⟩` at `17a9ffc` is that, measured.
997
+ *
998
+ * ⭐⭐ THE FIX IS POSITION, NOT LENGTH. Putting the id BEFORE the summary takes it
999
+ * out of the truncated region BY CONSTRUCTION, so it survives any headline length
1000
+ * — where raising `max` only moves the cliff. The row is explicit that "write
1001
+ * longer headlines" is not an acceptable fix, and it is right: a length that is
1002
+ * enough today is a truncation tomorrow.
1003
+ *
1004
+ * ✅ AND THE CONSUMER ALREADY EXPECTS THIS SPELLING. `doneRecordsDelivery` strips
1005
+ * a leading `⟨q-…⟩` before comparing, and says why: "an entry written as `⟨id⟩
1006
+ * <summary>` and one written as `<summary>` agree". So the composed-summary arm
1007
+ * keeps matching and this needed no change there.
1008
+ *
1009
+ * ⚠ THIS IS NOT THE ARM `⟨q-4a1e70c5⟩` REMOVED, and the difference is the whole
1010
+ * reason this is safe. That arm INFERRED delivery from a sigil appearing ANYWHERE
1011
+ * in an entry — a reader-side guess that produced eleven false positives, because
1012
+ * this repo's canon requires entries to cite item ids for other reasons. This
1013
+ * writes the id in a DETERMINISTIC LEADING POSITION so the tie can be read. It
1014
+ * infers nothing, and re-reading a sigil as "delivered" is still wrong.
1015
+ *
1016
+ * The id is the ITEM's, taken from the record rather than re-derived: `target` is
1017
+ * non-null whenever `summary` is, since the summary comes from its text.
1018
+ */
948
1019
  const doneLine =
949
- already || !summary ? null : `- [x] ${summary} — ${args.pr} · ${new Date().toISOString().slice(0, 10)}`;
1020
+ already || !summary || !target
1021
+ ? null
1022
+ : `- [x] ⟨${target.id}⟩ ${summary} — ${args.pr} · ${new Date().toISOString().slice(0, 10)}`;
950
1023
 
951
1024
  // APPEND TO DONE.md — the half this verb exists for.
952
1025
  //
@@ -1034,12 +1107,53 @@ export async function landTool(args: {
1034
1107
  }
1035
1108
  }
1036
1109
 
1110
+ /*
1111
+ * ⛔⛆ SECOND LINE: WAS THE THING MERGED THE THING GATED? (kit#268, ⟨q-6a4f0c38⟩)
1112
+ *
1113
+ * `merge` refuses this BEFORE the fact and is the control that prevents. This
1114
+ * one runs after, and its job is different: it is how the fleet LEARNS a
1115
+ * crossing happened anyway — through a merge done by hand, by `gh` directly,
1116
+ * or by a seat that never called the verb.
1117
+ *
1118
+ * It REPORTS and never refuses. A closure is not the right place to litigate a
1119
+ * merge that already happened: the verdict is QA's artefact, the merge may have
1120
+ * been someone else's, and blocking the record would leave the work landed and
1121
+ * unrecorded — strictly worse than landed and recorded with a flag on it.
1122
+ *
1123
+ * ⚠ AND IT SAYS SO WHEN IT COULD NOT LOOK. An omitted field reads as "fine".
1124
+ */
1125
+ const gatedHead = (() => {
1126
+ const mf = readMergeFacts(repo, n);
1127
+ if (!mf) return { checked: false as const, note: `could not read #${n}'s merged head and merge time — whether the merged head was gated is UNKNOWN, not clean.` };
1128
+ const log = readVerdictLog(args.project);
1129
+ if (log === null) return { checked: false as const, note: `could not read the verdict log for '${args.project}' — whether #${n}'s merged head was gated is UNKNOWN, not clean.` };
1130
+ const at = Date.parse(mf.mergedAt);
1131
+ if (!Number.isFinite(at)) return { checked: false as const, note: `#${n} reports an unparseable mergedAt ('${mf.mergedAt}') — cannot place the merge in time.` };
1132
+ const { verdicts, unparsed } = verdictsFor(log, n);
1133
+ const a = gatedAt(verdicts, mf.headRefOid, at);
1134
+ if (a.gated) {
1135
+ return { checked: true as const, gated: true as const, head: mf.headRefOid.slice(0, 8), by: { from: a.by.from, sha: a.by.head.slice(0, 8) } };
1136
+ }
1137
+ return {
1138
+ checked: true as const,
1139
+ gated: false as const,
1140
+ head: mf.headRefOid.slice(0, 8),
1141
+ reason: a.reason,
1142
+ gatedInstead: (a.crossed ?? []).map((c) => c.gatedSha.slice(0, 8)),
1143
+ note:
1144
+ `UNGATED MERGE RECORDED: #${n} merged ${mf.headRefOid.slice(0, 8)} and ${a.reason}. ` +
1145
+ `The record is written — this is a report, not a refusal — but the merge was not covered by a verdict when it happened.` +
1146
+ (unparsed ? ` (${unparsed} log line(s) unreadable and skipped.)` : ""),
1147
+ };
1148
+ })();
1149
+
1037
1150
  return {
1038
1151
  ok: true as const,
1039
1152
  project: args.project,
1040
1153
  pr: `#${n}`,
1041
1154
  comparedAgainst: ref,
1042
1155
  landedIn: landedIn.slice(0, 8),
1156
+ gatedHead,
1043
1157
  // THE ABSORBING WRITER LEARNS IT ABSORBED SOMETHING. The aide's
1044
1158
  // diff-before-rename guard REFUSES on a foreign change; this REPORTS one it
1045
1159
  // fixed, so a foreign row cannot pass silently in either direction.
@@ -1151,13 +1265,15 @@ export type PrFacts = {
1151
1265
  state: string;
1152
1266
  mergeable: string;
1153
1267
  checks: unknown[];
1268
+ /** The branch tip RIGHT NOW — what would actually be merged (⟨q-6a4f0c38⟩). */
1269
+ headRefOid?: string;
1154
1270
  /** Unified diff of the PR, for the ticked-box audit. */
1155
1271
  diff?: string;
1156
1272
  /** Everything that will survive the merge as a citation: title + commit subjects + body. */
1157
1273
  citationText?: string;
1158
1274
  };
1159
1275
  const ghFacts = (repo: string, n: string): PrFacts => {
1160
- const out = execFileSync("gh", ["pr", "view", n, "--json", "state,mergeable,statusCheckRollup"], {
1276
+ const out = execFileSync("gh", ["pr", "view", n, "--json", "state,mergeable,statusCheckRollup,headRefOid"], {
1161
1277
  cwd: repo,
1162
1278
  encoding: "utf8",
1163
1279
  stdio: ["ignore", "pipe", "ignore"],
@@ -1181,6 +1297,7 @@ const ghFacts = (repo: string, n: string): PrFacts => {
1181
1297
  return {
1182
1298
  state: String(j.state ?? ""),
1183
1299
  mergeable: String(j.mergeable ?? ""),
1300
+ headRefOid: String(j.headRefOid ?? ""),
1184
1301
  checks: (j.statusCheckRollup as unknown[]) ?? [],
1185
1302
  diff: gh(["pr", "diff", n]),
1186
1303
  citationText: [String(meta.title ?? ""), subjects, String(meta.body ?? "")].join("\n"),
@@ -1206,10 +1323,28 @@ const ghMerge: DoMerge = (repo, n, method) => {
1206
1323
  });
1207
1324
  };
1208
1325
 
1326
+ /**
1327
+ * The room log, injected for the same reason `doMerge` is: a test must be able to
1328
+ * drive every refusal branch without this host's ~/agent-coord.
1329
+ *
1330
+ * Returns null when the log cannot be read — distinct from "" (read, and empty),
1331
+ * because "no verdicts recorded" and "could not look" are different answers and
1332
+ * only one of them is safe to merge on.
1333
+ */
1334
+ export type ReadVerdictLog = (project: string) => string | null;
1335
+ const roomLog: ReadVerdictLog = (project) => {
1336
+ try {
1337
+ return readFileSync(path.join(ROOT, "rooms", `${project}.jsonl`), "utf8");
1338
+ } catch {
1339
+ return null;
1340
+ }
1341
+ };
1342
+
1209
1343
  export async function mergeTool(
1210
1344
  args: { project: string; pr: string; repo?: string; method?: string; write?: boolean },
1211
1345
  facts: (repo: string, n: string) => PrFacts = ghFacts,
1212
1346
  doMerge: DoMerge = ghMerge,
1347
+ readVerdictLog: ReadVerdictLog = roomLog,
1213
1348
  ) {
1214
1349
  const repo = args.repo ?? process.cwd();
1215
1350
  const n = prNumber(args.pr);
@@ -1318,8 +1453,55 @@ export async function mergeTool(
1318
1453
  if (f.mergeable === "CONFLICTING")
1319
1454
  return { ok: false as const, error: `#${n} is CONFLICTING with its base.`, verdict };
1320
1455
 
1456
+ /*
1457
+ * ⛔⛆ THE THING MERGED MUST BE THE THING GATED — kit#268, ⟨q-6a4f0c38⟩.
1458
+ *
1459
+ * A PASS was posted for `6051193` at 10:23:14Z; the merge ran 8 seconds later
1460
+ * and took `1d6deab`, because the author force-pushed in between. Checks were
1461
+ * green on BOTH heads, so every refusal above passed honestly. Nothing asked
1462
+ * the only question that mattered: is the head in front of me the head the
1463
+ * verdict named?
1464
+ *
1465
+ * THIS IS THE FIRST-LINE CONTROL because it PREVENTS. The recording step can
1466
+ * only report afterwards. It sits before the dry-run return on purpose: a dry
1467
+ * run must say it would refuse, or the preview disagrees with the act.
1468
+ */
1469
+ const head = f.headRefOid ?? "";
1470
+ if (!head) {
1471
+ return {
1472
+ ok: false as const,
1473
+ error: `could not read #${n}'s current head — NOT read, which is not the same as read and matching the verdict.`,
1474
+ verdict,
1475
+ };
1476
+ }
1477
+ const log = readVerdictLog(args.project);
1478
+ if (log === null) {
1479
+ return {
1480
+ ok: false as const,
1481
+ error:
1482
+ `could not read the verdict log for project '${args.project}' — so whether #${n}'s head ${head.slice(0, 7)} ` +
1483
+ `was ever gated is UNKNOWN, and unknown is not gated.`,
1484
+ verdict,
1485
+ };
1486
+ }
1487
+ const { verdicts, unparsed } = verdictsFor(log, n);
1488
+ const gate = gatedAt(verdicts, head, Date.now());
1489
+ if (!gate.gated) {
1490
+ const named = (gate.crossed ?? []).map((c) => c.gatedSha.slice(0, 7)).join(", ");
1491
+ return {
1492
+ ok: false as const,
1493
+ error:
1494
+ `#${n}'s head is ${head.slice(0, 7)} and ${gate.reason}. ` +
1495
+ (named ? `Gated instead: ${named}. ` : "") +
1496
+ `Re-gate this head before merging — a PASS that must be re-issued is cheap, a merge nobody gated is not.` +
1497
+ (unparsed ? ` (${unparsed} log line(s) unreadable and skipped.)` : ""),
1498
+ verdict,
1499
+ gatedHead: { head, gated: false as const, reason: gate.reason },
1500
+ };
1501
+ }
1502
+
1321
1503
  if (!args.write)
1322
- return { ok: true as const, merged: false as const, verdict, note: `#${n} would merge: all ${checks.length} check(s) pass. Pass write:true to apply.` };
1504
+ return { ok: true as const, merged: false as const, verdict, note: `#${n} would merge: all ${checks.length} check(s) pass, and head ${head.slice(0, 7)} is gated by a PASS from ${gate.by.from}. Pass write:true to apply.` };
1323
1505
 
1324
1506
  doMerge(repo, n, args.method ?? "squash");
1325
1507
  return { ok: true as const, merged: true as const, verdict };
@@ -0,0 +1,136 @@
1
+ /**
2
+ * ⟨q-c1af2db3⟩ — WHICH TREE DID THIS VERB JUST READ, AND HOW OLD IS IT?
3
+ *
4
+ * ⛔ THE BUG THIS EXISTS FOR ROUTED THE FLEET. `next_unblocked` reads
5
+ * `docs/QUEUE.md` out of whatever working tree it is handed, and that tree
6
+ * belongs to nobody: its freshness is a property of whoever last worked in it.
7
+ * Measured 2026-09-12 16:36 — the primary checkout sat FOUR commits behind
8
+ * `origin/main`, its `QUEUE.md` had zero hits for a tag committed six minutes
9
+ * earlier, and the verb returned the PRE-EDIT text as `next`.
10
+ *
11
+ * ⛆ AND THE HARM LANDED IN BOTH DIRECTIONS INSIDE TEN MINUTES: a row was ruled
12
+ * un-claimable from an observation that was actually a stale file, and a worker
13
+ * was told to hold on a row that was already split. ⭐ ***A true conclusion
14
+ * reached through an untrue reading is the shape, and here the INSTRUMENT
15
+ * supplied the untrue reading — the answer was well-formed, internally
16
+ * consistent, `accounting.reconciles` was TRUE, and nothing in it said which
17
+ * tree it read.***
18
+ *
19
+ * ⛔⛆⛆ THE TRAP THAT BREAKS THE OBVIOUS FIX, AND IT IS WHY THIS FETCHES:
20
+ *
21
+ * a checkout that has not fetched CANNOT REPORT that it has not fetched.
22
+ *
23
+ * `git rev-list --left-right --count origin/main...HEAD` in an unfetched clone
24
+ * compares HEAD against a STALE `origin/main` ref and returns `0 0` — *"I am
25
+ * perfectly current"* — while the real remote has moved. Measured: HEAD
26
+ * `1cab5c2`, its own `origin/main` also `1cab5c2`, true `origin/main` `558365d`,
27
+ * three commits behind, reported as ZERO. ⭐⭐ ***A distance of `0` from an
28
+ * unfetched ref and a distance of `0` from a fetched one are BYTE-IDENTICAL and
29
+ * mean opposite things. That distinction is the whole point of this module: the
30
+ * ref is fetched IN THIS CALL, and when the fetch fails the answer is
31
+ * `"unknown"` and never `0`.***
32
+ *
33
+ * ⚠ AND FETCHING IS NOT THE FIX — that is the row's named anti-control.
34
+ * Fetching updates REFS; the verb reads the TREE, which is still stale
35
+ * afterwards. So a fetch only makes the staleness *legible*; the caller must
36
+ * still refuse or flag. This module reports; it never silently repairs, because
37
+ * making someone else's working tree current is a destructive write into a
38
+ * checkout a seat may be mid-work in.
39
+ */
40
+ import { execFileSync } from "node:child_process";
41
+ import path from "node:path";
42
+
43
+ export type TreeProvenance = {
44
+ /** The tree actually read, resolved — not the string the caller passed. */
45
+ path: string;
46
+ head: string | null;
47
+ /** The ref the distance is measured against, e.g. `origin/main`. */
48
+ base: string;
49
+ baseSha: string | null;
50
+ /**
51
+ * Commits the tree is behind `base`.
52
+ *
53
+ * ⛔ `"unknown"` WHEN THE REF COULD NOT BE FRESHENED IN THIS CALL. Never `0`
54
+ * in that case: `0` is a claim of currency and an unfetched ref cannot make it.
55
+ */
56
+ behind: number | "unknown";
57
+ /** Whether the ref this distance rests on was freshened in THIS call. */
58
+ fetched: boolean;
59
+ fetchError?: string;
60
+ dirty: boolean | "unknown";
61
+ /** True only when the tree is MEASURABLY behind a ref fetched in this call. */
62
+ stale: boolean;
63
+ /** Present whenever the answer cannot be trusted as current. */
64
+ warning?: string;
65
+ };
66
+
67
+ const run = (repo: string, args: string[]): string | null => {
68
+ try {
69
+ return execFileSync("git", args, {
70
+ cwd: repo,
71
+ encoding: "utf8",
72
+ stdio: ["ignore", "pipe", "ignore"],
73
+ timeout: 20_000,
74
+ }).trim();
75
+ } catch {
76
+ return null;
77
+ }
78
+ };
79
+
80
+ /**
81
+ * What the caller needs to say out loud about the tree it just read.
82
+ *
83
+ * @param repo the working tree a verb was handed
84
+ * @param baseBranch the branch the tree should be current with
85
+ */
86
+ export function treeProvenance(repo: string, baseBranch = "main"): TreeProvenance {
87
+ const resolved = run(repo, ["rev-parse", "--show-toplevel"]) ?? path.resolve(repo);
88
+ const base = `origin/${baseBranch}`;
89
+ const head = run(resolved, ["rev-parse", "HEAD"]);
90
+ const status = run(resolved, ["status", "--porcelain"]);
91
+
92
+ // ⛔ FETCH FIRST, AND THE ORDER IS THE POINT. Every number below is worthless
93
+ // if it rests on a ref the tree last updated at some unknown past moment.
94
+ const fetched = run(resolved, ["fetch", "--quiet", "origin", baseBranch]) !== null;
95
+
96
+ const out: TreeProvenance = {
97
+ path: resolved,
98
+ head,
99
+ base,
100
+ baseSha: null,
101
+ behind: "unknown",
102
+ fetched,
103
+ dirty: status === null ? "unknown" : status.length > 0,
104
+ stale: false,
105
+ };
106
+
107
+ if (!fetched) {
108
+ out.fetchError = `could not fetch origin/${baseBranch} — the distance cannot be computed`;
109
+ out.warning =
110
+ `TREE FRESHNESS UNKNOWN: ${resolved} could not reach origin/${baseBranch} in this call, so its ` +
111
+ `distance is UNKNOWN rather than 0. A checkout that has not fetched cannot report that it has not ` +
112
+ `fetched — an unfetched ref answers "0 behind" and means nothing.`;
113
+ return out;
114
+ }
115
+
116
+ out.baseSha = run(resolved, ["rev-parse", base]);
117
+ const counts = run(resolved, ["rev-list", "--left-right", "--count", `${base}...HEAD`]);
118
+ const behind = counts ? Number(counts.split(/\s+/)[0]) : NaN;
119
+ if (!Number.isFinite(behind)) {
120
+ out.warning = `TREE FRESHNESS UNKNOWN: could not count ${base}...HEAD in ${resolved}.`;
121
+ return out;
122
+ }
123
+
124
+ out.behind = behind;
125
+ out.stale = behind > 0;
126
+ if (out.stale) {
127
+ // ⚠ FETCHING DID NOT FIX THE TREE — it only made this sentence possible.
128
+ // The file the verb read is still the old one.
129
+ out.warning =
130
+ `STALE TREE: ${resolved} is ${behind} commit(s) behind ${base} (HEAD ${head?.slice(0, 7)}, ` +
131
+ `${base} ${out.baseSha?.slice(0, 7)}). The answer above was read from THAT tree's files, so a row ` +
132
+ `filed or amended on ${base} since is invisible here and a superseded row can be offered as current. ` +
133
+ `Fetching refreshed the REF, not the working tree.`;
134
+ }
135
+ return out;
136
+ }