@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.
- package/dist/bin.js +13 -4
- package/dist/bin.js.map +1 -1
- package/dist/board-page.d.ts +5 -1
- package/dist/board-page.js +19 -5
- package/dist/board-page.js.map +1 -1
- package/dist/board-serve.js +34 -10
- package/dist/board-serve.js.map +1 -1
- package/dist/board.d.ts +1 -1
- package/dist/board.js +160 -13
- package/dist/board.js.map +1 -1
- package/node_modules/@dougharness/flow/.claude-plugin/plugin.json +1 -1
- package/node_modules/@dougharness/flow/agents/implementer.md +1 -1
- package/node_modules/@dougharness/flow/agents/planner.md +1 -1
- package/node_modules/@dougharness/flow/agents/researcher.md +2 -1
- package/node_modules/@dougharness/flow/agents/reviewer.md +3 -1
- package/node_modules/@dougharness/flow/hooks/register.tsx +67 -32
- package/node_modules/@dougharness/flow/lib/autopilot-core.mjs +94 -7
- package/node_modules/@dougharness/flow/lib/autopilot-view.mjs +31 -4
- package/node_modules/@dougharness/flow/lib/board.mjs +366 -21
- package/node_modules/@dougharness/flow/lib/card-loop.mjs +229 -23
- package/node_modules/@dougharness/flow/lib/coordinator.mjs +25 -0
- package/node_modules/@dougharness/flow/lib/cost.mjs +9 -0
- package/node_modules/@dougharness/flow/lib/decisions.mjs +4 -2
- package/node_modules/@dougharness/flow/lib/memory.mjs +382 -65
- package/node_modules/@dougharness/flow/lib/plan.mjs +1 -1
- package/node_modules/@dougharness/flow/package.json +1 -1
- package/node_modules/@dougharness/flow/scripts/autopilot.mjs +228 -14
- package/node_modules/@dougharness/flow/scripts/board.mjs +122 -13
- package/node_modules/@dougharness/flow/scripts/cost.mjs +6 -3
- package/node_modules/@dougharness/flow/scripts/memory.mjs +27 -8
- package/node_modules/@dougharness/flow/scripts/plan.mjs +5 -1
- package/node_modules/@dougharness/flow/scripts/session-recall.mjs +11 -4
- package/node_modules/@dougharness/flow/skills/doug-hand/SKILL.md +1 -1
- package/node_modules/@dougharness/flow/skills/doug-next/SKILL.md +9 -2
- package/node_modules/@dougharness/flow/skills/research/SKILL.md +2 -2
- package/node_modules/@dougharness/flow/skills/run-report/SKILL.md +2 -2
- package/node_modules/@dougharness/flow/workflows/card-queen.js +238 -25
- package/node_modules/@dougharness/flow/workflows/doug-implement.js +77 -34
- package/node_modules/@dougharness/gates/.claude-plugin/plugin.json +1 -1
- package/node_modules/@dougharness/gates/lib/baseline.mjs +23 -0
- package/node_modules/@dougharness/gates/lib/block-ledger.mjs +41 -3
- package/node_modules/@dougharness/gates/lib/docs-gate.mjs +6 -2
- package/node_modules/@dougharness/gates/package.json +1 -1
- package/node_modules/@dougharness/gates/scripts/research-cap.mjs +119 -15
- package/node_modules/@dougharness/gates/scripts/stop-gate.mjs +107 -20
- package/package.json +3 -3
- 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:
|
|
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:
|
|
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
|
-
|
|
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 (
|
|
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(
|
|
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 =
|
|
433
|
-
for (const id of ids) if (!have.
|
|
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 (!
|
|
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.
|
|
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
|
-
|
|
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-
|
|
678
|
-
"
|
|
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:
|
|
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
|
-
|
|
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 }
|