@dougharness/cli 1.2.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 (47) 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/agents/implementer.md +1 -1
  13. package/node_modules/@dougharness/flow/agents/planner.md +1 -1
  14. package/node_modules/@dougharness/flow/agents/researcher.md +2 -1
  15. package/node_modules/@dougharness/flow/agents/reviewer.md +3 -1
  16. package/node_modules/@dougharness/flow/hooks/register.tsx +67 -32
  17. package/node_modules/@dougharness/flow/lib/autopilot-core.mjs +94 -7
  18. package/node_modules/@dougharness/flow/lib/autopilot-view.mjs +31 -4
  19. package/node_modules/@dougharness/flow/lib/board.mjs +366 -21
  20. package/node_modules/@dougharness/flow/lib/card-loop.mjs +229 -23
  21. package/node_modules/@dougharness/flow/lib/coordinator.mjs +25 -0
  22. package/node_modules/@dougharness/flow/lib/cost.mjs +9 -0
  23. package/node_modules/@dougharness/flow/lib/decisions.mjs +4 -2
  24. package/node_modules/@dougharness/flow/lib/memory.mjs +382 -65
  25. package/node_modules/@dougharness/flow/lib/plan.mjs +1 -1
  26. package/node_modules/@dougharness/flow/package.json +1 -1
  27. package/node_modules/@dougharness/flow/scripts/autopilot.mjs +228 -14
  28. package/node_modules/@dougharness/flow/scripts/board.mjs +122 -13
  29. package/node_modules/@dougharness/flow/scripts/cost.mjs +6 -3
  30. package/node_modules/@dougharness/flow/scripts/memory.mjs +27 -8
  31. package/node_modules/@dougharness/flow/scripts/plan.mjs +5 -1
  32. package/node_modules/@dougharness/flow/scripts/session-recall.mjs +11 -4
  33. package/node_modules/@dougharness/flow/skills/doug-hand/SKILL.md +1 -1
  34. package/node_modules/@dougharness/flow/skills/doug-next/SKILL.md +9 -2
  35. package/node_modules/@dougharness/flow/skills/research/SKILL.md +2 -2
  36. package/node_modules/@dougharness/flow/skills/run-report/SKILL.md +2 -2
  37. package/node_modules/@dougharness/flow/workflows/card-queen.js +238 -25
  38. package/node_modules/@dougharness/flow/workflows/doug-implement.js +77 -34
  39. package/node_modules/@dougharness/gates/.claude-plugin/plugin.json +1 -1
  40. package/node_modules/@dougharness/gates/lib/baseline.mjs +23 -0
  41. package/node_modules/@dougharness/gates/lib/block-ledger.mjs +41 -3
  42. package/node_modules/@dougharness/gates/lib/docs-gate.mjs +6 -2
  43. package/node_modules/@dougharness/gates/package.json +1 -1
  44. package/node_modules/@dougharness/gates/scripts/research-cap.mjs +119 -15
  45. package/node_modules/@dougharness/gates/scripts/stop-gate.mjs +107 -20
  46. package/package.json +3 -3
  47. package/templates/board-app.js +240 -30
@@ -30,7 +30,10 @@ const STOP_REASONS = [
30
30
  "setup-failed",
31
31
  "tester-red-claim-missing",
32
32
  "reviewer-blocker-open",
33
+ "reviewer-major-open",
33
34
  "gate-red-twice",
35
+ "gate-timeout",
36
+ "commit-tree-mismatch",
34
37
  ];
35
38
  // The Models-table row each seat runs on. setup, gate and commit have no sourced row; cheap is a choice.
36
39
  const SEAT_ROWS = {
@@ -59,10 +62,18 @@ const BRIEF_RULES = {
59
62
  text: "If a hook blocks a step, do not work around it: report it in hookBlocked with the hook's message.",
60
63
  seats: ALL,
61
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
+ },
62
69
  "stay-in-goal": {
63
70
  text: "Do only what the goal asks. A defect found on the way goes in leftOut, not into the change.",
64
71
  seats: ["tester", "coder", "reviewer"],
65
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
+ },
66
77
  "lessons-verbatim": {
67
78
  text: "Return the memory.mjs brief-lessons block for this card verbatim in lessons; never paraphrase it.",
68
79
  seats: ["lead"],
@@ -72,11 +83,20 @@ const BRIEF_RULES = {
72
83
  seats: ["lead"],
73
84
  },
74
85
  "mutation-per-case": {
75
- 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.",
76
96
  seats: ["lead"],
77
97
  },
78
98
  "red-claim": {
79
- 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.",
80
100
  seats: ["tester"],
81
101
  when: "full",
82
102
  },
@@ -102,6 +122,38 @@ const BRIEF_RULES = {
102
122
  text: "Commit without Co-Authored-By or Claude-Session trailers.",
103
123
  seats: ["commit"],
104
124
  },
125
+ "commit-foreground": {
126
+ text: "Run git commit in the foreground with the Bash tool timeout set to 600000 ms: the pre-commit hook runs the full unit suite and takes over the 2-minute default.",
127
+ seats: ["commit"],
128
+ },
129
+ "commit-no-background": {
130
+ text: "Never use run_in_background, a Monitor, or a sleep loop for git commit: a background commit dies when your seat ends. Wait for the command to return.",
131
+ seats: ["commit"],
132
+ },
133
+ "commit-sha-from-git-log": {
134
+ text: "Report the sha only from git log -1 --format=%H after git commit has returned; never report a sha you did not read from it.",
135
+ seats: ["commit"],
136
+ },
137
+ "gate-no-git-writes": {
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.",
139
+ seats: ["gate"],
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
+ },
105
157
  "leave-plan-anchor-out": {
106
158
  text: "Leave .doug/plan.json and .doug/anchor.md out of the commit unless the card is about them.",
107
159
  seats: ["commit"],
@@ -112,11 +164,13 @@ const BRIEF_RULES = {
112
164
 
113
165
  const S = { type: "string" };
114
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})$" };
115
169
  const INT_OR_NULL = { type: ["integer", "null"] };
116
170
  const STRS = { type: "array", items: S };
117
171
  const CMD = {
118
172
  type: "object",
119
- properties: { command: S, exitCode: INT_OR_NULL },
173
+ properties: { command: S, exitCode: INT_OR_NULL, timedOut: { type: "boolean" } },
120
174
  required: ["command", "exitCode"],
121
175
  };
122
176
  const MUT_RESULT = {
@@ -124,6 +178,11 @@ const MUT_RESULT = {
124
178
  properties: { id: S, failed: { type: "boolean" }, test: S, assertion: S, own: { type: "boolean" } },
125
179
  required: ["id", "failed", "test"],
126
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
+ };
127
186
  const ENVELOPE = {
128
187
  seat: { type: "string", enum: SEATS },
129
188
  hookBlocked: { type: "object", properties: { message: NES }, required: ["message"] },
@@ -155,11 +214,12 @@ const HAND_BACK_SCHEMAS = {
155
214
  },
156
215
  unverified: STRS,
157
216
  testFiles: STRS,
217
+ reviewerChecks: STRS,
158
218
  mutations: {
159
219
  type: "array",
160
220
  items: {
161
221
  type: "object",
162
- 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 },
163
223
  required: ["id", "case", "mutation", "test"],
164
224
  },
165
225
  },
@@ -184,7 +244,7 @@ const HAND_BACK_SCHEMAS = {
184
244
  type: "array",
185
245
  items: { type: "object", properties: { file: S, reason: NES }, required: ["file", "reason"] },
186
246
  },
187
- mutationResults: { type: "array", items: MUT_RESULT },
247
+ mutationResults: { type: "array", items: CODER_MUT_RESULT },
188
248
  },
189
249
  ["filesChanged", "commandsRun", "testsEdited"],
190
250
  ),
@@ -213,12 +273,25 @@ const HAND_BACK_SCHEMAS = {
213
273
  ),
214
274
  gate: schema(
215
275
  "gate",
216
- { commands: { type: "array", items: CMD, minItems: 1 }, gateLine: NES, lockWaitMs: INT_OR_NULL },
217
- ["commands", "gateLine"],
276
+ { commands: { type: "array", items: CMD, minItems: 1 }, gateLine: NES, lockWaitMs: INT_OR_NULL, tree: TREE },
277
+ ["commands", "gateLine", "tree"],
218
278
  ),
219
- 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"]),
220
280
  };
221
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
+
222
295
  // ---- small helpers ----
223
296
 
224
297
  const cp = (x) => JSON.parse(JSON.stringify(x));
@@ -242,14 +315,25 @@ function checkCmds(list, path, errs, needOne) {
242
315
  });
243
316
  }
244
317
 
245
- 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) {
246
325
  if (!Array.isArray(list)) {
247
326
  errs.push(path + " must be an array");
248
327
  return false;
249
328
  }
250
329
  let good = true;
251
330
  list.forEach((m, i) => {
252
- 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)) {
253
337
  errs.push(`${path}[${i}] needs id, failed (boolean) and test`);
254
338
  good = false;
255
339
  }
@@ -266,6 +350,21 @@ function laneFor(card) {
266
350
  return "full";
267
351
  }
268
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
+
269
368
  function initialState(card, options = {}) {
270
369
  if (!isObj(card) || !nonBlank(card.id) || !isStr(card.goal)) {
271
370
  throw new TypeError("card needs an id and a goal string");
@@ -273,6 +372,7 @@ function initialState(card, options = {}) {
273
372
  const o = isObj(options) ? options : {};
274
373
  return {
275
374
  version: 1,
375
+ answers: checkAnswers(o.answers),
276
376
  card: {
277
377
  id: card.id,
278
378
  title: card.title ?? null,
@@ -289,13 +389,15 @@ function initialState(card, options = {}) {
289
389
  base: o.base ?? "main",
290
390
  worktree: null,
291
391
  researchNote: null,
292
- material: { lessons: "", facts: [], unverified: [], testFiles: [], mutations: [] },
392
+ material: { lessons: "", facts: [], unverified: [], testFiles: [], mutations: [], reviewerChecks: [] },
393
+ notRun: [],
293
394
  redTests: [],
294
395
  counters: { coderRounds: 0, fixPasses: 0, redGates: 0 },
295
396
  seatRuns: {},
296
397
  fixFor: null,
297
398
  lastFindings: [],
298
399
  lastGate: null,
400
+ gateTree: null,
299
401
  pending: "setup",
300
402
  outcome: null,
301
403
  history: [],
@@ -345,21 +447,27 @@ function buildBrief(s) {
345
447
  unverified: cp(s.material.unverified),
346
448
  testFiles: cp(s.material.testFiles),
347
449
  mutations: cp(effectiveMutations(s)),
450
+ reviewerChecks: seat === "reviewer" ? cp(s.material.reviewerChecks || []) : [],
451
+ notRun: seat === "reviewer" ? cp(s.notRun || []) : [],
348
452
  rules: Object.entries(BRIEF_RULES)
349
453
  .filter(([, r]) => ruleApplies(r, seat, s))
350
454
  .map(([id, r]) => ({ id, text: r.text })),
351
455
  findings: fixing && s.fixFor === "review" ? cp(s.lastFindings) : [],
352
456
  gate: fixing && s.fixFor === "gate" ? cp(s.lastGate) : null,
353
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 })),
354
461
  writeTestsFirst: seat === "coder" && s.counters.coderRounds === 0 && (s.lane === "lean" || !s.testerAgent),
355
462
  researchNote: s.researchNote,
356
- handBack: cp(HAND_BACK_SCHEMAS[seat]),
463
+ handBack: cp(handBackSchema(seat, s.lane)),
357
464
  };
358
465
  if (seat === "setup") {
359
466
  brief.install = s.install;
360
467
  brief.base = s.base;
361
468
  }
362
469
  if (seat === "gate" || seat === "reviewer") brief.gateCommand = s.gateCommand;
470
+ if (seat === "commit") brief.gateTree = s.gateTree;
363
471
  return brief;
364
472
  }
365
473
 
@@ -385,6 +493,7 @@ function buildReport(s) {
385
493
  testsEdited: cp(s.acc.testsEdited),
386
494
  filesChanged: cp(s.acc.filesChanged),
387
495
  followUps: cp(s.acc.followUps),
496
+ researchNote: s.researchNote ?? null,
388
497
  cost: null,
389
498
  };
390
499
  }
@@ -429,8 +538,8 @@ function validate(state, hb) {
429
538
  const muts = effectiveMutations(state);
430
539
  const needCover = (list, path, ids) => {
431
540
  if (!Array.isArray(list)) return;
432
- const have = new Set(list.filter(isObj).map((m) => m.id));
433
- 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}`);
434
543
  };
435
544
  const first = state.counters.coderRounds === 0;
436
545
 
@@ -445,7 +554,7 @@ function validate(state, hb) {
445
554
  break;
446
555
  }
447
556
  case "research":
448
- 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");
449
558
  break;
450
559
  case "lead": {
451
560
  if (!isStr(hb.lessons)) errs.push("lessons must be a string");
@@ -457,6 +566,7 @@ function validate(state, hb) {
457
566
  }
458
567
  if (!strArr(hb.unverified)) errs.push("unverified must be an array of strings");
459
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");
460
570
  if (!Array.isArray(hb.mutations)) errs.push("mutations must be an array");
461
571
  else {
462
572
  const ids = new Set();
@@ -464,6 +574,7 @@ function validate(state, hb) {
464
574
  if (!isObj(m) || !nonBlank(m.id) || !isStr(m.case) || !isStr(m.mutation) || !isStr(m.test)) {
465
575
  errs.push(`mutations[${i}] needs id, case, mutation and test`);
466
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, ...)`);
467
578
  else ids.add(m.id);
468
579
  });
469
580
  }
@@ -477,7 +588,14 @@ function validate(state, hb) {
477
588
  errs.push("tests_red_by_design must be an array of strings");
478
589
  }
479
590
  let shaped = true;
480
- 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
+ }
481
599
  if (state.lane === "tests-only" && first && muts.length > 0) {
482
600
  if (hb.mutationResults === undefined) errs.push("mutationResults is required on the first run");
483
601
  else if (shaped) needCover(hb.mutationResults, "mutationResults", muts.map((m) => m.id));
@@ -494,7 +612,14 @@ function validate(state, hb) {
494
612
  });
495
613
  }
496
614
  let shaped = true;
497
- 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
+ }
498
623
  if (first && muts.length > 0) {
499
624
  if (hb.mutationResults === undefined) errs.push("mutationResults is required on the first round");
500
625
  else if (shaped) needCover(hb.mutationResults, "mutationResults", muts.map((m) => m.id));
@@ -521,18 +646,27 @@ function validate(state, hb) {
521
646
  }
522
647
  if (hb.verdict === "pass" && blocker) errs.push("verdict pass contradicts an evidenced blocker");
523
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
+ }
524
654
  if (checkMutResults(hb.mutationResults, "mutationResults", errs)) {
525
655
  const listed = muts.map((m) => m.id);
526
656
  needCover(hb.mutationResults, "mutationResults", listed);
527
657
  for (const m of hb.mutationResults) {
528
- 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`);
529
659
  }
530
660
  }
531
661
  break;
532
662
  }
533
663
  case "gate": {
534
664
  checkCmds(hb.commands, "commands", errs, true);
665
+ if (Array.isArray(hb.commands) && hb.commands.some((c) => c && isStr(c.command) && GIT_WRITE_RE.test(c.command))) {
666
+ errs.push("gate seat must not run git add or git commit");
667
+ }
535
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");
536
670
  if (hb.lockWaitMs !== undefined && hb.lockWaitMs !== null && !Number.isInteger(hb.lockWaitMs)) {
537
671
  errs.push("lockWaitMs must be an integer or null");
538
672
  }
@@ -541,6 +675,7 @@ function validate(state, hb) {
541
675
  case "commit": {
542
676
  if (!(isStr(hb.sha) && /^[0-9a-f]{7,40}$/.test(hb.sha))) errs.push("sha must be 7 to 40 lowercase hex characters");
543
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");
544
679
  break;
545
680
  }
546
681
  default:
@@ -551,6 +686,11 @@ function validate(state, hb) {
551
686
 
552
687
  // ---- next ----
553
688
 
689
+ const TREE_RE = /^(?:[0-9a-f]{40}|[0-9a-f]{64})$/;
690
+
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.
692
+ const GIT_WRITE_RE = /(?:^|[;&|(\n]\s*)git(?:\s+(?:-[Cc]\s+(?:"[^"]*"|'[^']*'|\S+)|--[\w-]+(?:=\S+)?))*\s+(?:add|commit)\b/;
693
+
554
694
  function stopWith(s, reason, detail) {
555
695
  const seat = s.pending;
556
696
  const state = {
@@ -597,6 +737,9 @@ function next(state, handBack) {
597
737
  // C3 .. C6
598
738
  if (isObj(H.hookBlocked) && nonBlank(H.hookBlocked.message)) return stopWith(s, "hook-blocked", H.hookBlocked.message);
599
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
+ }
600
743
  if (nonBlank(H.blocked)) return stopWith(s, "seat-blocked", H.blocked);
601
744
  const errs = validateHandBack(s, H);
602
745
  if (errs.length > 0) return stopWith(s, "hand-back-invalid", errs.join("; "));
@@ -617,6 +760,7 @@ function next(state, handBack) {
617
760
  unverified: cp(H.unverified),
618
761
  testFiles: cp(H.testFiles),
619
762
  mutations: cp(H.mutations),
763
+ reviewerChecks: strArr(H.reviewerChecks) ? cp(H.reviewerChecks) : [],
620
764
  };
621
765
  const to = s.lane === "lean" || !s.testerAgent ? "coder" : "tester";
622
766
  return advance(s, to, "ok", { material });
@@ -625,13 +769,41 @@ function next(state, handBack) {
625
769
  const acc = { ...s.acc, filesChanged: union(s.acc.filesChanged, H.files) };
626
770
  if (Array.isArray(H.mutationResults) && s.lane === "tests-only") acc.mutations = cp(H.mutationResults);
627
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
+ }
628
780
  return advance(s, "reviewer", "ok", {
629
781
  acc,
782
+ notRun: notRunT,
630
783
  fixFor: null,
631
784
  counters: { ...s.counters, coderRounds: s.counters.coderRounds + 1 },
632
785
  });
633
786
  }
634
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
+ }
635
807
  if (!(Array.isArray(claim) && claim.length > 0 && claim.every(nonBlank))) {
636
808
  return stopWith(s, "tester-red-claim-missing", "the tester returned no tests_red_by_design claim");
637
809
  }
@@ -643,8 +815,17 @@ function next(state, handBack) {
643
815
  filesChanged: union(s.acc.filesChanged, H.filesChanged),
644
816
  testsEdited: [...s.acc.testsEdited, ...H.testsEdited.map((t) => ({ round, file: t.file, reason: t.reason }))],
645
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
+ }
646
826
  return advance(s, "reviewer", "ok", {
647
827
  acc,
828
+ notRun,
648
829
  fixFor: null,
649
830
  counters: { ...s.counters, coderRounds: s.counters.coderRounds + 1 },
650
831
  });
@@ -670,12 +851,21 @@ function next(state, handBack) {
670
851
  mutations: cp(H.mutationResults),
671
852
  };
672
853
  const blockers = findings.filter((f) => f.severity === "blocker");
673
- if (blockers.length === 0) return advance(s, "gate", "ok", { acc });
854
+ // An evidenced major gets the same one fix pass; a major without evidence and a minor stay advisory.
855
+ const majors = findings.filter((f) => f.severity === "major" && nonBlank(f.evidence));
856
+ if (blockers.length === 0 && majors.length === 0) return advance(s, "gate", "ok", { acc });
674
857
  if (s.counters.fixPasses >= 1) {
858
+ if (blockers.length > 0) {
859
+ return stopWith(
860
+ { ...s, acc },
861
+ "reviewer-blocker-open",
862
+ "blocker still open after one fix pass: " + blockers.map((f) => f.id).join(", "),
863
+ );
864
+ }
675
865
  return stopWith(
676
866
  { ...s, acc },
677
- "reviewer-blocker-open",
678
- "blocker still open after one fix pass: " + blockers.map((f) => f.id).join(", "),
867
+ "reviewer-major-open",
868
+ "major with evidence still open after one fix pass: " + majors.map((f) => f.id).join(", "),
679
869
  );
680
870
  }
681
871
  return advance(s, buildSeat(s), "blockers", {
@@ -690,7 +880,7 @@ function next(state, handBack) {
690
880
  const waited = { ...s.acc, lockWaitMs: (s.acc.lockWaitMs || 0) + (Number.isInteger(H.lockWaitMs) ? H.lockWaitMs : 0) };
691
881
  s.acc = waited;
692
882
  if (H.commands.every((c) => c.exitCode === 0)) {
693
- 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 } });
694
884
  }
695
885
  const redGates = s.counters.redGates + 1;
696
886
  const counters = { ...s.counters, redGates };
@@ -699,6 +889,9 @@ function next(state, handBack) {
699
889
  }
700
890
  default: {
701
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
+ }
702
895
  const state = {
703
896
  ...s,
704
897
  pending: null,
@@ -721,6 +914,9 @@ function briefPrompt(brief, { repoRoot, worktreePath } = {}) {
721
914
  out.push(`SEAT: ${b.seat} (round ${b.round}, lane ${b.lane}, model row ${b.row})`);
722
915
  out.push(`CARD ${b.card.id}${b.card.title ? ": " + b.card.title : ""}`);
723
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
+ }
724
920
  out.push("WORKTREE: " + worktreePath);
725
921
  if (b.seat === "setup") {
726
922
  out.push(
@@ -731,14 +927,23 @@ function briefPrompt(brief, { repoRoot, worktreePath } = {}) {
731
927
  : "There is no install command; skip the install and report install as null."),
732
928
  );
733
929
  }
734
- if (b.researchNote) out.push("RESEARCH NOTE: " + b.researchNote);
930
+ if (b.researchNote) out.push("RESEARCH NOTE:" + (b.researchNote.includes("\n") ? "\n" : " ") + b.researchNote);
735
931
  if (b.lessons) out.push("LESSONS BLOCK verbatim:\n" + b.lessons);
736
932
  if (b.facts.length > 0) out.push("FACTS (with sources):\n" + bullets(b.facts.map((f) => `${f.text} [${f.source}]`)));
737
933
  if (b.unverified.length > 0) out.push("UNVERIFIED:\n" + bullets(b.unverified));
738
934
  if (b.testFiles.length > 0) out.push("TEST FILES:\n" + bullets(b.testFiles));
739
935
  if (b.mutations.length > 0) {
740
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
+ }
741
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
+ );
945
+ }
946
+ if (b.reviewerChecks && b.reviewerChecks.length > 0) out.push("REVIEWER CHECKS:\n" + bullets(b.reviewerChecks));
742
947
  if (b.redTests.length > 0) out.push("TESTS RED BY DESIGN:\n" + bullets(b.redTests));
743
948
  if (b.writeTestsFirst) out.push("Write the test first, in the file that covers the module, then the change.");
744
949
  if (b.findings.length > 0) {
@@ -756,6 +961,7 @@ function briefPrompt(brief, { repoRoot, worktreePath } = {}) {
756
961
  );
757
962
  }
758
963
  if (b.gateCommand) out.push("GATE COMMAND: " + b.gateCommand);
964
+ if (b.gateTree) out.push("GATED TREE: " + b.gateTree);
759
965
  out.push("RULES:\n" + bullets(b.rules.map((r) => r.text)));
760
966
  out.push("HAND-BACK: return JSON matching this schema:\n" + JSON.stringify(b.handBack, null, 2));
761
967
  return out.join("\n\n") + "\n";
@@ -788,7 +994,7 @@ function seatOpts(seat, brief) {
788
994
  const row = SEAT_ROWS[seat]
789
995
  const src = (models.roles && models.roles[row]) || (models.tiers && models.tiers[row]) || {}
790
996
  const opts = {
791
- schema: HAND_BACK_SCHEMAS[seat],
997
+ schema: brief.handBack,
792
998
  label: seat + ':' + args.card.id + ':' + brief.round,
793
999
  agentType: AGENT_TYPES[seat],
794
1000
  }
@@ -801,7 +1007,14 @@ let s = initialState(args.card, args.options)
801
1007
  let a = currentAction(s)
802
1008
  while (a.kind === 'seat') {
803
1009
  phase(PHASE_TITLES[a.seat])
804
- const hb = await agent(briefPrompt(a.brief, { repoRoot: args.repoRoot, worktreePath: args.worktreePath }), seatOpts(a.seat, a.brief))
1010
+ let hb
1011
+ try {
1012
+ hb = await agent(briefPrompt(a.brief, { repoRoot: args.repoRoot, worktreePath: args.worktreePath }), seatOpts(a.seat, a.brief))
1013
+ } catch (err) {
1014
+ // The runtime throws after repeated schema-validation failures; a null hand-back lets the contract stop with agent-null.
1015
+ log('card-queen: ' + a.seat + ' seat agent() threw: ' + (err && err.message ? err.message : String(err)))
1016
+ hb = null
1017
+ }
805
1018
  ;({ state: s, action: a } = next(s, hb))
806
1019
  }
807
1020
  return { ...a.report, history: s.history }