@dougharness/cli 1.3.0 → 1.4.0

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.
Files changed (38) hide show
  1. package/dist/bin.js +13 -4
  2. package/dist/bin.js.map +1 -1
  3. package/dist/board-page.d.ts +5 -1
  4. package/dist/board-page.js +19 -5
  5. package/dist/board-page.js.map +1 -1
  6. package/dist/board-serve.js +34 -10
  7. package/dist/board-serve.js.map +1 -1
  8. package/dist/board.d.ts +1 -1
  9. package/dist/board.js +160 -13
  10. package/dist/board.js.map +1 -1
  11. package/node_modules/@dougharness/flow/.claude-plugin/plugin.json +1 -1
  12. package/node_modules/@dougharness/flow/hooks/register.tsx +18 -2
  13. package/node_modules/@dougharness/flow/lib/autopilot-core.mjs +94 -7
  14. package/node_modules/@dougharness/flow/lib/autopilot-view.mjs +31 -4
  15. package/node_modules/@dougharness/flow/lib/board.mjs +366 -21
  16. package/node_modules/@dougharness/flow/lib/card-loop.mjs +194 -20
  17. package/node_modules/@dougharness/flow/lib/cost.mjs +9 -0
  18. package/node_modules/@dougharness/flow/lib/memory.mjs +24 -1
  19. package/node_modules/@dougharness/flow/package.json +1 -1
  20. package/node_modules/@dougharness/flow/scripts/autopilot.mjs +193 -12
  21. package/node_modules/@dougharness/flow/scripts/board.mjs +122 -13
  22. package/node_modules/@dougharness/flow/scripts/cost.mjs +6 -3
  23. package/node_modules/@dougharness/flow/scripts/memory.mjs +14 -7
  24. package/node_modules/@dougharness/flow/scripts/session-recall.mjs +11 -4
  25. package/node_modules/@dougharness/flow/skills/doug-hand/SKILL.md +1 -1
  26. package/node_modules/@dougharness/flow/skills/doug-next/SKILL.md +9 -2
  27. package/node_modules/@dougharness/flow/skills/research/SKILL.md +1 -1
  28. package/node_modules/@dougharness/flow/skills/run-report/SKILL.md +2 -2
  29. package/node_modules/@dougharness/flow/workflows/card-queen.js +195 -21
  30. package/node_modules/@dougharness/flow/workflows/doug-implement.js +21 -18
  31. package/node_modules/@dougharness/gates/.claude-plugin/plugin.json +1 -1
  32. package/node_modules/@dougharness/gates/lib/baseline.mjs +23 -0
  33. package/node_modules/@dougharness/gates/lib/block-ledger.mjs +41 -3
  34. package/node_modules/@dougharness/gates/lib/docs-gate.mjs +6 -2
  35. package/node_modules/@dougharness/gates/package.json +1 -1
  36. package/node_modules/@dougharness/gates/scripts/stop-gate.mjs +91 -18
  37. package/package.json +3 -3
  38. package/templates/board-app.js +240 -30
@@ -32,6 +32,8 @@ const STOP_REASONS = [
32
32
  "reviewer-blocker-open",
33
33
  "reviewer-major-open",
34
34
  "gate-red-twice",
35
+ "gate-timeout",
36
+ "commit-tree-mismatch",
35
37
  ];
36
38
  // The Models-table row each seat runs on. setup, gate and commit have no sourced row; cheap is a choice.
37
39
  const SEAT_ROWS = {
@@ -60,10 +62,18 @@ const BRIEF_RULES = {
60
62
  text: "If a hook blocks a step, do not work around it: report it in hookBlocked with the hook's message.",
61
63
  seats: ALL,
62
64
  },
65
+ "relayed-message-outside-card": {
66
+ text: "Your task is the card in this brief. A relayed user message about other work is outside this seat's card: note it in leftOut and carry on; it is never a reason to stop or to ask a designQuestion.",
67
+ seats: ALL,
68
+ },
63
69
  "stay-in-goal": {
64
70
  text: "Do only what the goal asks. A defect found on the way goes in leftOut, not into the change.",
65
71
  seats: ["tester", "coder", "reviewer"],
66
72
  },
73
+ "no-cpu-burners": {
74
+ text: "A burner is any process started only to load the machine (yes > /dev/null, stress, a busy loop), bounded or not; concurrent pnpm test:unit runs are not burners. Never start a burner without the user's yes: a seat that wants one stops and returns a designQuestion naming the load it would start (the process, how many, the timeout, and why), and starts it only after an answer from the user says yes. Before hand-back, confirm with ps that no load process you started survives, orphans reparented to PID 1 included, and kill any that does.",
75
+ seats: ["tester", "coder", "reviewer"],
76
+ },
67
77
  "lessons-verbatim": {
68
78
  text: "Return the memory.mjs brief-lessons block for this card verbatim in lessons; never paraphrase it.",
69
79
  seats: ["lead"],
@@ -73,11 +83,20 @@ const BRIEF_RULES = {
73
83
  seats: ["lead"],
74
84
  },
75
85
  "mutation-per-case": {
76
- text: "List one mutation per case the goal names, each with a unique id and the test that must fail.",
86
+ text: "List one mutation per case the goal names, each with a unique id of the form M<n> (M1, M2, ...) and the test that must fail.",
87
+ seats: ["lead"],
88
+ },
89
+ "mutation-ids-verbatim": {
90
+ text: "Report each listed mutation in mutationResults under its exact listed id from MUTATION IDS; never renumber, shorten, or rename an id (a split mutation adds one letter: M8a, M8b).",
91
+ seats: ["tester", "coder", "reviewer"],
92
+ when: "mutations",
93
+ },
94
+ "mutation-is-code-change": {
95
+ text: "A mutation is a code change to a file in the card worktree that a test catches; a check that is not a code mutation (a git diff, a file-absence check) goes in reviewerChecks, never in mutations.",
77
96
  seats: ["lead"],
78
97
  },
79
98
  "red-claim": {
80
- text: "Tests that are red by design are named in tests_red_by_design; without the claim the card stops.",
99
+ text: "Tests that are red by design are named in tests_red_by_design; without the claim the card stops. If the new tests pass at baseline (pins), return tests_red_by_design [] and report every listed mutation in mutationResults, run in a scratch copy; the card goes on only when each one failed.",
81
100
  seats: ["tester"],
82
101
  when: "full",
83
102
  },
@@ -119,6 +138,22 @@ const BRIEF_RULES = {
119
138
  text: "Run the gate commands only. Never run git add, git commit, or any other command that writes to git; the commit seat is the only seat that commits.",
120
139
  seats: ["gate"],
121
140
  },
141
+ "gate-timeout-is-load": {
142
+ text: "When verify.mjs prints `exit timeout` for a command, report that command with timedOut: true; a timeout is load, not a red or a block, so leave blocked unset.",
143
+ seats: ["gate"],
144
+ },
145
+ "commit-no-file-edits": {
146
+ text: "Never edit, create, delete, or format a file: commit exactly the tree the gate seat passed (GATED TREE). A change the commit seems to need goes in blocked, never into the tree.",
147
+ seats: ["commit"],
148
+ },
149
+ "commit-reports-tree": {
150
+ text: "After git commit returns, run node .doug/hooks/lib/verified-tree.mjs tree with the card worktree path as its argument and report its output as tree; a tree that differs from GATED TREE stops the card.",
151
+ seats: ["commit"],
152
+ },
153
+ "gate-reports-tree": {
154
+ text: "After the gate commands, run node .doug/hooks/lib/verified-tree.mjs tree with the card worktree path as its argument and report its output as tree.",
155
+ seats: ["gate"],
156
+ },
122
157
  "leave-plan-anchor-out": {
123
158
  text: "Leave .doug/plan.json and .doug/anchor.md out of the commit unless the card is about them.",
124
159
  seats: ["commit"],
@@ -129,11 +164,13 @@ const BRIEF_RULES = {
129
164
 
130
165
  const S = { type: "string" };
131
166
  const NES = { type: "string", minLength: 1 };
167
+ const MUTATION_ID_PATTERN = "^M[1-9][0-9]*$";
168
+ const TREE = { type: "string", pattern: "^([0-9a-f]{40}|[0-9a-f]{64})$" };
132
169
  const INT_OR_NULL = { type: ["integer", "null"] };
133
170
  const STRS = { type: "array", items: S };
134
171
  const CMD = {
135
172
  type: "object",
136
- properties: { command: S, exitCode: INT_OR_NULL },
173
+ properties: { command: S, exitCode: INT_OR_NULL, timedOut: { type: "boolean" } },
137
174
  required: ["command", "exitCode"],
138
175
  };
139
176
  const MUT_RESULT = {
@@ -141,6 +178,11 @@ const MUT_RESULT = {
141
178
  properties: { id: S, failed: { type: "boolean" }, test: S, assertion: S, own: { type: "boolean" } },
142
179
  required: ["id", "failed", "test"],
143
180
  };
181
+ const CODER_MUT_RESULT = {
182
+ type: "object",
183
+ properties: { id: S, failed: { type: "boolean" }, test: S, assertion: S, own: { type: "boolean" }, notRun: NES },
184
+ required: ["id"],
185
+ };
144
186
  const ENVELOPE = {
145
187
  seat: { type: "string", enum: SEATS },
146
188
  hookBlocked: { type: "object", properties: { message: NES }, required: ["message"] },
@@ -172,11 +214,12 @@ const HAND_BACK_SCHEMAS = {
172
214
  },
173
215
  unverified: STRS,
174
216
  testFiles: STRS,
217
+ reviewerChecks: STRS,
175
218
  mutations: {
176
219
  type: "array",
177
220
  items: {
178
221
  type: "object",
179
- properties: { id: NES, case: S, mutation: S, test: S },
222
+ properties: { id: { type: "string", minLength: 1, pattern: MUTATION_ID_PATTERN }, case: S, mutation: S, test: S },
180
223
  required: ["id", "case", "mutation", "test"],
181
224
  },
182
225
  },
@@ -201,7 +244,7 @@ const HAND_BACK_SCHEMAS = {
201
244
  type: "array",
202
245
  items: { type: "object", properties: { file: S, reason: NES }, required: ["file", "reason"] },
203
246
  },
204
- mutationResults: { type: "array", items: MUT_RESULT },
247
+ mutationResults: { type: "array", items: CODER_MUT_RESULT },
205
248
  },
206
249
  ["filesChanged", "commandsRun", "testsEdited"],
207
250
  ),
@@ -230,12 +273,25 @@ const HAND_BACK_SCHEMAS = {
230
273
  ),
231
274
  gate: schema(
232
275
  "gate",
233
- { commands: { type: "array", items: CMD, minItems: 1 }, gateLine: NES, lockWaitMs: INT_OR_NULL },
234
- ["commands", "gateLine"],
276
+ { commands: { type: "array", items: CMD, minItems: 1 }, gateLine: NES, lockWaitMs: INT_OR_NULL, tree: TREE },
277
+ ["commands", "gateLine", "tree"],
235
278
  ),
236
- commit: schema("commit", { sha: { type: "string", pattern: "^[0-9a-f]{7,40}$" }, branch: S }, ["sha", "branch"]),
279
+ commit: schema("commit", { sha: { type: "string", pattern: "^[0-9a-f]{7,40}$" }, branch: S, tree: TREE }, ["sha", "branch", "tree"]),
237
280
  };
238
281
 
282
+ // The schema a seat is handed on a lane. Only the tests-only tester may report notRun (validateHandBack's tester
283
+ // case), so only there are its mutationResults items CODER_MUT_RESULT; the plain table is the full-lane contract.
284
+ function handBackSchema(seat, lane) {
285
+ if (seat === "tester" && lane === "tests-only") {
286
+ const base = HAND_BACK_SCHEMAS.tester;
287
+ return {
288
+ ...base,
289
+ properties: { ...base.properties, mutationResults: { type: "array", items: CODER_MUT_RESULT } },
290
+ };
291
+ }
292
+ return HAND_BACK_SCHEMAS[seat];
293
+ }
294
+
239
295
  // ---- small helpers ----
240
296
 
241
297
  const cp = (x) => JSON.parse(JSON.stringify(x));
@@ -259,14 +315,25 @@ function checkCmds(list, path, errs, needOne) {
259
315
  });
260
316
  }
261
317
 
262
- function checkMutResults(list, path, errs) {
318
+ // A reported id covers listed id L when it is L or L plus one lowercase letter (a split such as M1a, M1b).
319
+ function coversId(reported, listed) {
320
+ if (reported === listed) return true;
321
+ return typeof reported === "string" && reported.length === listed.length + 1 && reported.startsWith(listed) && /[a-z]$/.test(reported);
322
+ }
323
+
324
+ function checkMutResults(list, path, errs, allowNotRun = false) {
263
325
  if (!Array.isArray(list)) {
264
326
  errs.push(path + " must be an array");
265
327
  return false;
266
328
  }
267
329
  let good = true;
268
330
  list.forEach((m, i) => {
269
- if (!isObj(m) || !nonBlank(m.id) || typeof m.failed !== "boolean" || !isStr(m.test)) {
331
+ if (allowNotRun && isObj(m) && m.notRun !== undefined) {
332
+ if (!nonBlank(m.id) || !nonBlank(m.notRun)) {
333
+ errs.push(`${path}[${i}] notRun needs id and a non-empty reason string`);
334
+ good = false;
335
+ }
336
+ } else if (!isObj(m) || !nonBlank(m.id) || typeof m.failed !== "boolean" || !isStr(m.test)) {
270
337
  errs.push(`${path}[${i}] needs id, failed (boolean) and test`);
271
338
  good = false;
272
339
  }
@@ -283,6 +350,21 @@ function laneFor(card) {
283
350
  return "full";
284
351
  }
285
352
 
353
+ // options.answers: the user's answers to design questions, { seat, question, answer, round?, run? } each.
354
+ function checkAnswers(answers) {
355
+ if (answers === undefined || answers === null) return [];
356
+ const bad = (why) => new TypeError("options.answers " + why);
357
+ if (!Array.isArray(answers)) throw bad("must be an array");
358
+ return answers.map((a, i) => {
359
+ if (!isObj(a)) throw bad(`[${i}] must be an object`);
360
+ if (!SEATS.includes(a.seat)) throw bad(`[${i}].seat must be one of the seats`);
361
+ if (!isStr(a.question)) throw bad(`[${i}].question must be a string`);
362
+ if (!nonBlank(a.answer)) throw bad(`[${i}].answer must be a non-blank string`);
363
+ if (a.round !== undefined && !(Number.isInteger(a.round) && a.round > 0)) throw bad(`[${i}].round must be a positive integer`);
364
+ return { ...a };
365
+ });
366
+ }
367
+
286
368
  function initialState(card, options = {}) {
287
369
  if (!isObj(card) || !nonBlank(card.id) || !isStr(card.goal)) {
288
370
  throw new TypeError("card needs an id and a goal string");
@@ -290,6 +372,7 @@ function initialState(card, options = {}) {
290
372
  const o = isObj(options) ? options : {};
291
373
  return {
292
374
  version: 1,
375
+ answers: checkAnswers(o.answers),
293
376
  card: {
294
377
  id: card.id,
295
378
  title: card.title ?? null,
@@ -306,13 +389,15 @@ function initialState(card, options = {}) {
306
389
  base: o.base ?? "main",
307
390
  worktree: null,
308
391
  researchNote: null,
309
- material: { lessons: "", facts: [], unverified: [], testFiles: [], mutations: [] },
392
+ material: { lessons: "", facts: [], unverified: [], testFiles: [], mutations: [], reviewerChecks: [] },
393
+ notRun: [],
310
394
  redTests: [],
311
395
  counters: { coderRounds: 0, fixPasses: 0, redGates: 0 },
312
396
  seatRuns: {},
313
397
  fixFor: null,
314
398
  lastFindings: [],
315
399
  lastGate: null,
400
+ gateTree: null,
316
401
  pending: "setup",
317
402
  outcome: null,
318
403
  history: [],
@@ -362,21 +447,27 @@ function buildBrief(s) {
362
447
  unverified: cp(s.material.unverified),
363
448
  testFiles: cp(s.material.testFiles),
364
449
  mutations: cp(effectiveMutations(s)),
450
+ reviewerChecks: seat === "reviewer" ? cp(s.material.reviewerChecks || []) : [],
451
+ notRun: seat === "reviewer" ? cp(s.notRun || []) : [],
365
452
  rules: Object.entries(BRIEF_RULES)
366
453
  .filter(([, r]) => ruleApplies(r, seat, s))
367
454
  .map(([id, r]) => ({ id, text: r.text })),
368
455
  findings: fixing && s.fixFor === "review" ? cp(s.lastFindings) : [],
369
456
  gate: fixing && s.fixFor === "gate" ? cp(s.lastGate) : null,
370
457
  redTests: cp(s.redTests),
458
+ answers: (s.answers || [])
459
+ .filter((a) => a.seat === seat && (a.round === undefined || a.round <= (s.seatRuns[seat] || 0) + 1))
460
+ .map((a) => ({ question: a.question, answer: a.answer })),
371
461
  writeTestsFirst: seat === "coder" && s.counters.coderRounds === 0 && (s.lane === "lean" || !s.testerAgent),
372
462
  researchNote: s.researchNote,
373
- handBack: cp(HAND_BACK_SCHEMAS[seat]),
463
+ handBack: cp(handBackSchema(seat, s.lane)),
374
464
  };
375
465
  if (seat === "setup") {
376
466
  brief.install = s.install;
377
467
  brief.base = s.base;
378
468
  }
379
469
  if (seat === "gate" || seat === "reviewer") brief.gateCommand = s.gateCommand;
470
+ if (seat === "commit") brief.gateTree = s.gateTree;
380
471
  return brief;
381
472
  }
382
473
 
@@ -402,6 +493,7 @@ function buildReport(s) {
402
493
  testsEdited: cp(s.acc.testsEdited),
403
494
  filesChanged: cp(s.acc.filesChanged),
404
495
  followUps: cp(s.acc.followUps),
496
+ researchNote: s.researchNote ?? null,
405
497
  cost: null,
406
498
  };
407
499
  }
@@ -446,8 +538,8 @@ function validate(state, hb) {
446
538
  const muts = effectiveMutations(state);
447
539
  const needCover = (list, path, ids) => {
448
540
  if (!Array.isArray(list)) return;
449
- const have = new Set(list.filter(isObj).map((m) => m.id));
450
- for (const id of ids) if (!have.has(id)) errs.push(`${path} is missing listed mutation ${id}`);
541
+ const have = list.filter(isObj).map((m) => m.id);
542
+ for (const id of ids) if (!have.some((h) => coversId(h, id))) errs.push(`${path} is missing listed mutation ${id}`);
451
543
  };
452
544
  const first = state.counters.coderRounds === 0;
453
545
 
@@ -462,7 +554,7 @@ function validate(state, hb) {
462
554
  break;
463
555
  }
464
556
  case "research":
465
- if (!isAbs(hb.note)) errs.push("note must be an absolute path");
557
+ if (!nonBlank(hb.note)) errs.push("note must be the researcher's cited note text or a path, not blank");
466
558
  break;
467
559
  case "lead": {
468
560
  if (!isStr(hb.lessons)) errs.push("lessons must be a string");
@@ -474,6 +566,7 @@ function validate(state, hb) {
474
566
  }
475
567
  if (!strArr(hb.unverified)) errs.push("unverified must be an array of strings");
476
568
  if (!strArr(hb.testFiles)) errs.push("testFiles must be an array of strings");
569
+ if (hb.reviewerChecks !== undefined && !strArr(hb.reviewerChecks)) errs.push("reviewerChecks must be an array of strings");
477
570
  if (!Array.isArray(hb.mutations)) errs.push("mutations must be an array");
478
571
  else {
479
572
  const ids = new Set();
@@ -481,6 +574,7 @@ function validate(state, hb) {
481
574
  if (!isObj(m) || !nonBlank(m.id) || !isStr(m.case) || !isStr(m.mutation) || !isStr(m.test)) {
482
575
  errs.push(`mutations[${i}] needs id, case, mutation and test`);
483
576
  } else if (ids.has(m.id)) errs.push(`mutations[${i}] repeats id ${m.id}`);
577
+ else if (!new RegExp(MUTATION_ID_PATTERN).test(m.id)) errs.push(`mutations[${i}] id ${m.id} must have the form M<n> (M1, M2, ...)`);
484
578
  else ids.add(m.id);
485
579
  });
486
580
  }
@@ -494,7 +588,14 @@ function validate(state, hb) {
494
588
  errs.push("tests_red_by_design must be an array of strings");
495
589
  }
496
590
  let shaped = true;
497
- if (hb.mutationResults !== undefined) shaped = checkMutResults(hb.mutationResults, "mutationResults", errs);
591
+ if (hb.mutationResults !== undefined) shaped = checkMutResults(hb.mutationResults, "mutationResults", errs, state.lane === "tests-only");
592
+ if (state.lane === "tests-only" && shaped && Array.isArray(hb.mutationResults)) {
593
+ hb.mutationResults.forEach((m, i) => {
594
+ if (isObj(m) && m.notRun !== undefined && !muts.some((x) => x.id === m.id)) {
595
+ errs.push(`mutationResults[${i}] notRun id ${m.id} is not a listed mutation`);
596
+ }
597
+ });
598
+ }
498
599
  if (state.lane === "tests-only" && first && muts.length > 0) {
499
600
  if (hb.mutationResults === undefined) errs.push("mutationResults is required on the first run");
500
601
  else if (shaped) needCover(hb.mutationResults, "mutationResults", muts.map((m) => m.id));
@@ -511,7 +612,14 @@ function validate(state, hb) {
511
612
  });
512
613
  }
513
614
  let shaped = true;
514
- if (hb.mutationResults !== undefined) shaped = checkMutResults(hb.mutationResults, "mutationResults", errs);
615
+ if (hb.mutationResults !== undefined) shaped = checkMutResults(hb.mutationResults, "mutationResults", errs, true);
616
+ if (shaped && Array.isArray(hb.mutationResults)) {
617
+ hb.mutationResults.forEach((m, i) => {
618
+ if (isObj(m) && m.notRun !== undefined && !muts.some((x) => x.id === m.id)) {
619
+ errs.push(`mutationResults[${i}] notRun id ${m.id} is not a listed mutation`);
620
+ }
621
+ });
622
+ }
515
623
  if (first && muts.length > 0) {
516
624
  if (hb.mutationResults === undefined) errs.push("mutationResults is required on the first round");
517
625
  else if (shaped) needCover(hb.mutationResults, "mutationResults", muts.map((m) => m.id));
@@ -538,11 +646,16 @@ function validate(state, hb) {
538
646
  }
539
647
  if (hb.verdict === "pass" && blocker) errs.push("verdict pass contradicts an evidenced blocker");
540
648
  checkCmds(hb.commandsRun, "commandsRun", errs, false);
649
+ if (Array.isArray(hb.mutationResults)) {
650
+ hb.mutationResults.forEach((m, i) => {
651
+ if (isObj(m) && m.notRun !== undefined) errs.push(`mutationResults[${i}] notRun is not allowed for the reviewer`);
652
+ });
653
+ }
541
654
  if (checkMutResults(hb.mutationResults, "mutationResults", errs)) {
542
655
  const listed = muts.map((m) => m.id);
543
656
  needCover(hb.mutationResults, "mutationResults", listed);
544
657
  for (const m of hb.mutationResults) {
545
- if (!listed.includes(m.id) && m.own !== true) errs.push(`mutationResults id ${m.id} is not listed and not marked own`);
658
+ if (!listed.some((l) => coversId(m.id, l)) && m.own !== true) errs.push(`mutationResults id ${m.id} is not listed and not marked own`);
546
659
  }
547
660
  }
548
661
  break;
@@ -553,6 +666,7 @@ function validate(state, hb) {
553
666
  errs.push("gate seat must not run git add or git commit");
554
667
  }
555
668
  if (!nonBlank(hb.gateLine)) errs.push("gateLine must be a non-empty string");
669
+ if (!(isStr(hb.tree) && TREE_RE.test(hb.tree))) errs.push("tree must be 40 or 64 lowercase hex characters");
556
670
  if (hb.lockWaitMs !== undefined && hb.lockWaitMs !== null && !Number.isInteger(hb.lockWaitMs)) {
557
671
  errs.push("lockWaitMs must be an integer or null");
558
672
  }
@@ -561,6 +675,7 @@ function validate(state, hb) {
561
675
  case "commit": {
562
676
  if (!(isStr(hb.sha) && /^[0-9a-f]{7,40}$/.test(hb.sha))) errs.push("sha must be 7 to 40 lowercase hex characters");
563
677
  if (!nonBlank(hb.branch)) errs.push("branch must be a non-empty string");
678
+ if (!(isStr(hb.tree) && TREE_RE.test(hb.tree))) errs.push("tree must be 40 or 64 lowercase hex characters");
564
679
  break;
565
680
  }
566
681
  default:
@@ -571,6 +686,8 @@ function validate(state, hb) {
571
686
 
572
687
  // ---- next ----
573
688
 
689
+ const TREE_RE = /^(?:[0-9a-f]{40}|[0-9a-f]{64})$/;
690
+
574
691
  // git add / git commit at the start of a command or after a shell separator, global options (-C <path>, -c k=v, --flag) allowed before the verb.
575
692
  const GIT_WRITE_RE = /(?:^|[;&|(\n]\s*)git(?:\s+(?:-[Cc]\s+(?:"[^"]*"|'[^']*'|\S+)|--[\w-]+(?:=\S+)?))*\s+(?:add|commit)\b/;
576
693
 
@@ -620,6 +737,9 @@ function next(state, handBack) {
620
737
  // C3 .. C6
621
738
  if (isObj(H.hookBlocked) && nonBlank(H.hookBlocked.message)) return stopWith(s, "hook-blocked", H.hookBlocked.message);
622
739
  if (nonBlank(H.designQuestion)) return stopWith(s, "design-decision", H.designQuestion);
740
+ if (P === "gate" && Array.isArray(H.commands) && H.commands.some((c) => isObj(c) && c.timedOut === true)) {
741
+ return stopWith({ ...s, lastGate: { commands: cp(H.commands), gateLine: H.gateLine } }, "gate-timeout", typeof H.gateLine === "string" ? H.gateLine : "a gate command timed out");
742
+ }
623
743
  if (nonBlank(H.blocked)) return stopWith(s, "seat-blocked", H.blocked);
624
744
  const errs = validateHandBack(s, H);
625
745
  if (errs.length > 0) return stopWith(s, "hand-back-invalid", errs.join("; "));
@@ -640,6 +760,7 @@ function next(state, handBack) {
640
760
  unverified: cp(H.unverified),
641
761
  testFiles: cp(H.testFiles),
642
762
  mutations: cp(H.mutations),
763
+ reviewerChecks: strArr(H.reviewerChecks) ? cp(H.reviewerChecks) : [],
643
764
  };
644
765
  const to = s.lane === "lean" || !s.testerAgent ? "coder" : "tester";
645
766
  return advance(s, to, "ok", { material });
@@ -648,13 +769,41 @@ function next(state, handBack) {
648
769
  const acc = { ...s.acc, filesChanged: union(s.acc.filesChanged, H.files) };
649
770
  if (Array.isArray(H.mutationResults) && s.lane === "tests-only") acc.mutations = cp(H.mutationResults);
650
771
  if (s.lane === "tests-only") {
772
+ const seenT = new Set((s.notRun || []).map((n) => n.id));
773
+ const notRunT = [...(s.notRun || [])];
774
+ for (const m of Array.isArray(H.mutationResults) ? H.mutationResults : []) {
775
+ if (isObj(m) && m.notRun !== undefined && !seenT.has(m.id)) {
776
+ seenT.add(m.id);
777
+ notRunT.push({ id: m.id, reason: m.notRun });
778
+ }
779
+ }
651
780
  return advance(s, "reviewer", "ok", {
652
781
  acc,
782
+ notRun: notRunT,
653
783
  fixFor: null,
654
784
  counters: { ...s.counters, coderRounds: s.counters.coderRounds + 1 },
655
785
  });
656
786
  }
657
787
  const claim = H.tests_red_by_design;
788
+ if (Array.isArray(claim) && claim.length === 0) {
789
+ // Pin-only card: the new tests pass at baseline. Go on only when every listed mutation was run and failed.
790
+ const listed = effectiveMutations(s).map((m) => m.id);
791
+ const results = Array.isArray(H.mutationResults) ? H.mutationResults.filter(isObj) : [];
792
+ const bad = listed.filter((id) => {
793
+ const cov = results.filter((m) => coversId(m.id, id));
794
+ return cov.length === 0 || cov.some((m) => m.failed !== true);
795
+ });
796
+ if (listed.length > 0 && bad.length === 0) {
797
+ return advance(s, "coder", "ok", { acc, redTests: [] });
798
+ }
799
+ return stopWith(
800
+ s,
801
+ "tester-red-claim-missing",
802
+ listed.length === 0
803
+ ? "the tester returned an empty tests_red_by_design claim and no mutation is listed to show the pins bite"
804
+ : `the tester returned an empty tests_red_by_design claim but mutation ${bad.join(", ")} was not run or survived`,
805
+ );
806
+ }
658
807
  if (!(Array.isArray(claim) && claim.length > 0 && claim.every(nonBlank))) {
659
808
  return stopWith(s, "tester-red-claim-missing", "the tester returned no tests_red_by_design claim");
660
809
  }
@@ -666,8 +815,17 @@ function next(state, handBack) {
666
815
  filesChanged: union(s.acc.filesChanged, H.filesChanged),
667
816
  testsEdited: [...s.acc.testsEdited, ...H.testsEdited.map((t) => ({ round, file: t.file, reason: t.reason }))],
668
817
  };
818
+ const seen = new Set((s.notRun || []).map((n) => n.id));
819
+ const notRun = [...(s.notRun || [])];
820
+ for (const m of Array.isArray(H.mutationResults) ? H.mutationResults : []) {
821
+ if (isObj(m) && m.notRun !== undefined && !seen.has(m.id)) {
822
+ seen.add(m.id);
823
+ notRun.push({ id: m.id, reason: m.notRun });
824
+ }
825
+ }
669
826
  return advance(s, "reviewer", "ok", {
670
827
  acc,
828
+ notRun,
671
829
  fixFor: null,
672
830
  counters: { ...s.counters, coderRounds: s.counters.coderRounds + 1 },
673
831
  });
@@ -722,7 +880,7 @@ function next(state, handBack) {
722
880
  const waited = { ...s.acc, lockWaitMs: (s.acc.lockWaitMs || 0) + (Number.isInteger(H.lockWaitMs) ? H.lockWaitMs : 0) };
723
881
  s.acc = waited;
724
882
  if (H.commands.every((c) => c.exitCode === 0)) {
725
- return advance(s, "commit", "ok", { lastGate, acc: { ...waited, gateLine: H.gateLine } });
883
+ return advance(s, "commit", "ok", { lastGate, gateTree: H.tree, acc: { ...waited, gateLine: H.gateLine } });
726
884
  }
727
885
  const redGates = s.counters.redGates + 1;
728
886
  const counters = { ...s.counters, redGates };
@@ -731,6 +889,9 @@ function next(state, handBack) {
731
889
  }
732
890
  default: {
733
891
  // commit
892
+ if (H.tree !== s.gateTree) {
893
+ return stopWith(s, "commit-tree-mismatch", `commit ${H.sha} tree ${H.tree} differs from the gated tree ${s.gateTree}`);
894
+ }
734
895
  const state = {
735
896
  ...s,
736
897
  pending: null,
@@ -753,6 +914,9 @@ function briefPrompt(brief, { repoRoot, worktreePath } = {}) {
753
914
  out.push(`SEAT: ${b.seat} (round ${b.round}, lane ${b.lane}, model row ${b.row})`);
754
915
  out.push(`CARD ${b.card.id}${b.card.title ? ": " + b.card.title : ""}`);
755
916
  out.push("GOAL:\n" + b.card.goal);
917
+ if (b.answers && b.answers.length > 0) {
918
+ out.push("ANSWERS FROM THE USER (to your design questions; build on them):\n" + bullets(b.answers.map((a) => `Q: ${a.question} A: ${a.answer}`)));
919
+ }
756
920
  out.push("WORKTREE: " + worktreePath);
757
921
  if (b.seat === "setup") {
758
922
  out.push(
@@ -763,14 +927,23 @@ function briefPrompt(brief, { repoRoot, worktreePath } = {}) {
763
927
  : "There is no install command; skip the install and report install as null."),
764
928
  );
765
929
  }
766
- if (b.researchNote) out.push("RESEARCH NOTE: " + b.researchNote);
930
+ if (b.researchNote) out.push("RESEARCH NOTE:" + (b.researchNote.includes("\n") ? "\n" : " ") + b.researchNote);
767
931
  if (b.lessons) out.push("LESSONS BLOCK verbatim:\n" + b.lessons);
768
932
  if (b.facts.length > 0) out.push("FACTS (with sources):\n" + bullets(b.facts.map((f) => `${f.text} [${f.source}]`)));
769
933
  if (b.unverified.length > 0) out.push("UNVERIFIED:\n" + bullets(b.unverified));
770
934
  if (b.testFiles.length > 0) out.push("TEST FILES:\n" + bullets(b.testFiles));
771
935
  if (b.mutations.length > 0) {
772
936
  out.push("MUTATIONS:\n" + bullets(b.mutations.map((m) => `${m.id} (${m.case}): ${m.mutation}; test that must fail: ${m.test}`)));
937
+ if ((b.rules || []).some((r) => r.id === "mutation-ids-verbatim")) {
938
+ out.push("MUTATION IDS (report each verbatim in mutationResults): " + b.mutations.map((m) => m.id).join(", "));
939
+ }
940
+ }
941
+ if (b.notRun && b.notRun.length > 0) {
942
+ out.push(
943
+ "NOT RUN BY THE CODER (report each with failed true or false):\n" + bullets(b.notRun.map((n) => `${n.id}: ${n.reason}`)),
944
+ );
773
945
  }
946
+ if (b.reviewerChecks && b.reviewerChecks.length > 0) out.push("REVIEWER CHECKS:\n" + bullets(b.reviewerChecks));
774
947
  if (b.redTests.length > 0) out.push("TESTS RED BY DESIGN:\n" + bullets(b.redTests));
775
948
  if (b.writeTestsFirst) out.push("Write the test first, in the file that covers the module, then the change.");
776
949
  if (b.findings.length > 0) {
@@ -788,6 +961,7 @@ function briefPrompt(brief, { repoRoot, worktreePath } = {}) {
788
961
  );
789
962
  }
790
963
  if (b.gateCommand) out.push("GATE COMMAND: " + b.gateCommand);
964
+ if (b.gateTree) out.push("GATED TREE: " + b.gateTree);
791
965
  out.push("RULES:\n" + bullets(b.rules.map((r) => r.text)));
792
966
  out.push("HAND-BACK: return JSON matching this schema:\n" + JSON.stringify(b.handBack, null, 2));
793
967
  return out.join("\n\n") + "\n";
@@ -820,7 +994,7 @@ function seatOpts(seat, brief) {
820
994
  const row = SEAT_ROWS[seat]
821
995
  const src = (models.roles && models.roles[row]) || (models.tiers && models.tiers[row]) || {}
822
996
  const opts = {
823
- schema: HAND_BACK_SCHEMAS[seat],
997
+ schema: brief.handBack,
824
998
  label: seat + ':' + args.card.id + ':' + brief.round,
825
999
  agentType: AGENT_TYPES[seat],
826
1000
  }