@dougharness/cli 1.3.0 → 1.5.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/ablate.js +2 -2
- package/dist/ablate.js.map +1 -1
- 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/planner.md +1 -0
- package/node_modules/@dougharness/flow/hooks/register.tsx +18 -2
- 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 +375 -30
- package/node_modules/@dougharness/flow/lib/card-loop.mjs +237 -24
- package/node_modules/@dougharness/flow/lib/cost.mjs +9 -0
- package/node_modules/@dougharness/flow/lib/decisions.mjs +2 -4
- package/node_modules/@dougharness/flow/lib/learn.mjs +95 -54
- package/node_modules/@dougharness/flow/lib/memory.mjs +1403 -44
- package/node_modules/@dougharness/flow/lib/plan.mjs +40 -0
- package/node_modules/@dougharness/flow/package.json +1 -1
- package/node_modules/@dougharness/flow/scripts/autopilot.mjs +355 -13
- package/node_modules/@dougharness/flow/scripts/board.mjs +160 -27
- package/node_modules/@dougharness/flow/scripts/cost.mjs +6 -3
- package/node_modules/@dougharness/flow/scripts/learn.mjs +1 -1
- package/node_modules/@dougharness/flow/scripts/memory.mjs +668 -39
- package/node_modules/@dougharness/flow/scripts/plan.mjs +3 -3
- package/node_modules/@dougharness/flow/scripts/session-recall.mjs +11 -4
- package/node_modules/@dougharness/flow/skills/doug-hand/SKILL.md +7 -3
- package/node_modules/@dougharness/flow/skills/doug-learn/SKILL.md +1 -1
- package/node_modules/@dougharness/flow/skills/doug-next/SKILL.md +11 -2
- package/node_modules/@dougharness/flow/skills/doug-start/SKILL.md +9 -8
- package/node_modules/@dougharness/flow/skills/doug-swarm/SKILL.md +1 -1
- package/node_modules/@dougharness/flow/skills/research/SKILL.md +1 -1
- package/node_modules/@dougharness/flow/skills/run-report/SKILL.md +3 -3
- package/node_modules/@dougharness/flow/workflows/card-queen.js +238 -25
- package/node_modules/@dougharness/flow/workflows/doug-implement.js +55 -37
- package/node_modules/@dougharness/gates/.claude-plugin/plugin.json +1 -1
- package/node_modules/@dougharness/gates/lib/baseline.mjs +32 -0
- package/node_modules/@dougharness/gates/lib/block-ledger.mjs +45 -5
- package/node_modules/@dougharness/gates/lib/docs-gate.mjs +6 -2
- package/node_modules/@dougharness/gates/lib/verified-tree.mjs +33 -0
- package/node_modules/@dougharness/gates/package.json +1 -1
- package/node_modules/@dougharness/gates/scripts/stop-gate.mjs +130 -42
- package/node_modules/@dougharness/gates/scripts/verify.mjs +20 -4
- package/package.json +3 -3
- 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,12 +62,24 @@ 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
|
-
text: "
|
|
78
|
+
text: "Run memory.mjs brief-lessons <card-id> <repo-root>, passing the REPO ROOT named in this brief as [dir], and return its block for this card verbatim in lessons; never paraphrase it.",
|
|
79
|
+
seats: ["lead"],
|
|
80
|
+
},
|
|
81
|
+
"lessons-fetch": {
|
|
82
|
+
text: "For each indexed lesson the card needs, run memory.mjs lesson show <id> <repo-root> --card <card-id>, passing the REPO ROOT named in this brief as [dir], and append its output verbatim to lessons below the brief-lessons block; never cut or paraphrase a fetched lesson.",
|
|
69
83
|
seats: ["lead"],
|
|
70
84
|
},
|
|
71
85
|
"facts-cited": {
|
|
@@ -73,11 +87,20 @@ const BRIEF_RULES = {
|
|
|
73
87
|
seats: ["lead"],
|
|
74
88
|
},
|
|
75
89
|
"mutation-per-case": {
|
|
76
|
-
text: "List one mutation per case the goal names, each with a unique id and the test that must fail.",
|
|
90
|
+
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.",
|
|
91
|
+
seats: ["lead"],
|
|
92
|
+
},
|
|
93
|
+
"mutation-ids-verbatim": {
|
|
94
|
+
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).",
|
|
95
|
+
seats: ["tester", "coder", "reviewer"],
|
|
96
|
+
when: "mutations",
|
|
97
|
+
},
|
|
98
|
+
"mutation-is-code-change": {
|
|
99
|
+
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
100
|
seats: ["lead"],
|
|
78
101
|
},
|
|
79
102
|
"red-claim": {
|
|
80
|
-
text: "Tests that are red by design are named in tests_red_by_design; without the claim the card stops.",
|
|
103
|
+
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
104
|
seats: ["tester"],
|
|
82
105
|
when: "full",
|
|
83
106
|
},
|
|
@@ -101,7 +124,7 @@ const BRIEF_RULES = {
|
|
|
101
124
|
},
|
|
102
125
|
"no-trailers": {
|
|
103
126
|
text: "Commit without Co-Authored-By or Claude-Session trailers.",
|
|
104
|
-
seats: ["commit"],
|
|
127
|
+
seats: ["tester", "coder", "reviewer", "commit"],
|
|
105
128
|
},
|
|
106
129
|
"commit-foreground": {
|
|
107
130
|
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.",
|
|
@@ -119,6 +142,27 @@ const BRIEF_RULES = {
|
|
|
119
142
|
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
143
|
seats: ["gate"],
|
|
121
144
|
},
|
|
145
|
+
"gate-reinstall-missing-deps": {
|
|
146
|
+
text: "Before the gate commands, check that node_modules exists in the card worktree. Only when it is missing, run the INSTALL COMMAND and report it as the first commands item, then run the gate. Never install when node_modules is present.",
|
|
147
|
+
seats: ["gate"],
|
|
148
|
+
when: "install",
|
|
149
|
+
},
|
|
150
|
+
"gate-timeout-is-load": {
|
|
151
|
+
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.",
|
|
152
|
+
seats: ["gate"],
|
|
153
|
+
},
|
|
154
|
+
"commit-no-file-edits": {
|
|
155
|
+
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.",
|
|
156
|
+
seats: ["commit"],
|
|
157
|
+
},
|
|
158
|
+
"commit-reports-tree": {
|
|
159
|
+
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.",
|
|
160
|
+
seats: ["commit"],
|
|
161
|
+
},
|
|
162
|
+
"gate-reports-tree": {
|
|
163
|
+
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.",
|
|
164
|
+
seats: ["gate"],
|
|
165
|
+
},
|
|
122
166
|
"leave-plan-anchor-out": {
|
|
123
167
|
text: "Leave .doug/plan.json and .doug/anchor.md out of the commit unless the card is about them.",
|
|
124
168
|
seats: ["commit"],
|
|
@@ -129,11 +173,13 @@ const BRIEF_RULES = {
|
|
|
129
173
|
|
|
130
174
|
const S = { type: "string" };
|
|
131
175
|
const NES = { type: "string", minLength: 1 };
|
|
176
|
+
const MUTATION_ID_PATTERN = "^M[1-9][0-9]*$";
|
|
177
|
+
const TREE = { type: "string", pattern: "^([0-9a-f]{40}|[0-9a-f]{64})$" };
|
|
132
178
|
const INT_OR_NULL = { type: ["integer", "null"] };
|
|
133
179
|
const STRS = { type: "array", items: S };
|
|
134
180
|
const CMD = {
|
|
135
181
|
type: "object",
|
|
136
|
-
properties: { command: S, exitCode: INT_OR_NULL },
|
|
182
|
+
properties: { command: S, exitCode: INT_OR_NULL, timedOut: { type: "boolean" } },
|
|
137
183
|
required: ["command", "exitCode"],
|
|
138
184
|
};
|
|
139
185
|
const MUT_RESULT = {
|
|
@@ -141,6 +187,11 @@ const MUT_RESULT = {
|
|
|
141
187
|
properties: { id: S, failed: { type: "boolean" }, test: S, assertion: S, own: { type: "boolean" } },
|
|
142
188
|
required: ["id", "failed", "test"],
|
|
143
189
|
};
|
|
190
|
+
const CODER_MUT_RESULT = {
|
|
191
|
+
type: "object",
|
|
192
|
+
properties: { id: S, failed: { type: "boolean" }, test: S, assertion: S, own: { type: "boolean" }, notRun: NES },
|
|
193
|
+
required: ["id"],
|
|
194
|
+
};
|
|
144
195
|
const ENVELOPE = {
|
|
145
196
|
seat: { type: "string", enum: SEATS },
|
|
146
197
|
hookBlocked: { type: "object", properties: { message: NES }, required: ["message"] },
|
|
@@ -172,11 +223,12 @@ const HAND_BACK_SCHEMAS = {
|
|
|
172
223
|
},
|
|
173
224
|
unverified: STRS,
|
|
174
225
|
testFiles: STRS,
|
|
226
|
+
reviewerChecks: STRS,
|
|
175
227
|
mutations: {
|
|
176
228
|
type: "array",
|
|
177
229
|
items: {
|
|
178
230
|
type: "object",
|
|
179
|
-
properties: { id:
|
|
231
|
+
properties: { id: { type: "string", minLength: 1, pattern: MUTATION_ID_PATTERN }, case: S, mutation: S, test: S },
|
|
180
232
|
required: ["id", "case", "mutation", "test"],
|
|
181
233
|
},
|
|
182
234
|
},
|
|
@@ -201,7 +253,7 @@ const HAND_BACK_SCHEMAS = {
|
|
|
201
253
|
type: "array",
|
|
202
254
|
items: { type: "object", properties: { file: S, reason: NES }, required: ["file", "reason"] },
|
|
203
255
|
},
|
|
204
|
-
mutationResults: { type: "array", items:
|
|
256
|
+
mutationResults: { type: "array", items: CODER_MUT_RESULT },
|
|
205
257
|
},
|
|
206
258
|
["filesChanged", "commandsRun", "testsEdited"],
|
|
207
259
|
),
|
|
@@ -230,12 +282,25 @@ const HAND_BACK_SCHEMAS = {
|
|
|
230
282
|
),
|
|
231
283
|
gate: schema(
|
|
232
284
|
"gate",
|
|
233
|
-
{ commands: { type: "array", items: CMD, minItems: 1 }, gateLine: NES, lockWaitMs: INT_OR_NULL },
|
|
234
|
-
["commands", "gateLine"],
|
|
285
|
+
{ commands: { type: "array", items: CMD, minItems: 1 }, gateLine: NES, lockWaitMs: INT_OR_NULL, tree: TREE },
|
|
286
|
+
["commands", "gateLine", "tree"],
|
|
235
287
|
),
|
|
236
|
-
commit: schema("commit", { sha: { type: "string", pattern: "^[0-9a-f]{7,40}$" }, branch: S }, ["sha", "branch"]),
|
|
288
|
+
commit: schema("commit", { sha: { type: "string", pattern: "^[0-9a-f]{7,40}$" }, branch: S, tree: TREE }, ["sha", "branch", "tree"]),
|
|
237
289
|
};
|
|
238
290
|
|
|
291
|
+
// The schema a seat is handed on a lane. Only the tests-only tester may report notRun (validateHandBack's tester
|
|
292
|
+
// case), so only there are its mutationResults items CODER_MUT_RESULT; the plain table is the full-lane contract.
|
|
293
|
+
function handBackSchema(seat, lane) {
|
|
294
|
+
if (seat === "tester" && lane === "tests-only") {
|
|
295
|
+
const base = HAND_BACK_SCHEMAS.tester;
|
|
296
|
+
return {
|
|
297
|
+
...base,
|
|
298
|
+
properties: { ...base.properties, mutationResults: { type: "array", items: CODER_MUT_RESULT } },
|
|
299
|
+
};
|
|
300
|
+
}
|
|
301
|
+
return HAND_BACK_SCHEMAS[seat];
|
|
302
|
+
}
|
|
303
|
+
|
|
239
304
|
// ---- small helpers ----
|
|
240
305
|
|
|
241
306
|
const cp = (x) => JSON.parse(JSON.stringify(x));
|
|
@@ -259,14 +324,25 @@ function checkCmds(list, path, errs, needOne) {
|
|
|
259
324
|
});
|
|
260
325
|
}
|
|
261
326
|
|
|
262
|
-
|
|
327
|
+
// A reported id covers listed id L when it is L or L plus one lowercase letter (a split such as M1a, M1b).
|
|
328
|
+
function coversId(reported, listed) {
|
|
329
|
+
if (reported === listed) return true;
|
|
330
|
+
return typeof reported === "string" && reported.length === listed.length + 1 && reported.startsWith(listed) && /[a-z]$/.test(reported);
|
|
331
|
+
}
|
|
332
|
+
|
|
333
|
+
function checkMutResults(list, path, errs, allowNotRun = false) {
|
|
263
334
|
if (!Array.isArray(list)) {
|
|
264
335
|
errs.push(path + " must be an array");
|
|
265
336
|
return false;
|
|
266
337
|
}
|
|
267
338
|
let good = true;
|
|
268
339
|
list.forEach((m, i) => {
|
|
269
|
-
if (
|
|
340
|
+
if (allowNotRun && isObj(m) && m.notRun !== undefined) {
|
|
341
|
+
if (!nonBlank(m.id) || !nonBlank(m.notRun)) {
|
|
342
|
+
errs.push(`${path}[${i}] notRun needs id and a non-empty reason string`);
|
|
343
|
+
good = false;
|
|
344
|
+
}
|
|
345
|
+
} else if (!isObj(m) || !nonBlank(m.id) || typeof m.failed !== "boolean" || !isStr(m.test)) {
|
|
270
346
|
errs.push(`${path}[${i}] needs id, failed (boolean) and test`);
|
|
271
347
|
good = false;
|
|
272
348
|
}
|
|
@@ -283,6 +359,21 @@ function laneFor(card) {
|
|
|
283
359
|
return "full";
|
|
284
360
|
}
|
|
285
361
|
|
|
362
|
+
// options.answers: the user's answers to design questions, { seat, question, answer, round?, run? } each.
|
|
363
|
+
function checkAnswers(answers) {
|
|
364
|
+
if (answers === undefined || answers === null) return [];
|
|
365
|
+
const bad = (why) => new TypeError("options.answers " + why);
|
|
366
|
+
if (!Array.isArray(answers)) throw bad("must be an array");
|
|
367
|
+
return answers.map((a, i) => {
|
|
368
|
+
if (!isObj(a)) throw bad(`[${i}] must be an object`);
|
|
369
|
+
if (!SEATS.includes(a.seat)) throw bad(`[${i}].seat must be one of the seats`);
|
|
370
|
+
if (!isStr(a.question)) throw bad(`[${i}].question must be a string`);
|
|
371
|
+
if (!nonBlank(a.answer)) throw bad(`[${i}].answer must be a non-blank string`);
|
|
372
|
+
if (a.round !== undefined && !(Number.isInteger(a.round) && a.round > 0)) throw bad(`[${i}].round must be a positive integer`);
|
|
373
|
+
return { ...a };
|
|
374
|
+
});
|
|
375
|
+
}
|
|
376
|
+
|
|
286
377
|
function initialState(card, options = {}) {
|
|
287
378
|
if (!isObj(card) || !nonBlank(card.id) || !isStr(card.goal)) {
|
|
288
379
|
throw new TypeError("card needs an id and a goal string");
|
|
@@ -290,6 +381,7 @@ function initialState(card, options = {}) {
|
|
|
290
381
|
const o = isObj(options) ? options : {};
|
|
291
382
|
return {
|
|
292
383
|
version: 1,
|
|
384
|
+
answers: checkAnswers(o.answers),
|
|
293
385
|
card: {
|
|
294
386
|
id: card.id,
|
|
295
387
|
title: card.title ?? null,
|
|
@@ -306,13 +398,15 @@ function initialState(card, options = {}) {
|
|
|
306
398
|
base: o.base ?? "main",
|
|
307
399
|
worktree: null,
|
|
308
400
|
researchNote: null,
|
|
309
|
-
material: { lessons: "", facts: [], unverified: [], testFiles: [], mutations: [] },
|
|
401
|
+
material: { lessons: "", facts: [], unverified: [], testFiles: [], mutations: [], reviewerChecks: [] },
|
|
402
|
+
notRun: [],
|
|
310
403
|
redTests: [],
|
|
311
404
|
counters: { coderRounds: 0, fixPasses: 0, redGates: 0 },
|
|
312
405
|
seatRuns: {},
|
|
313
406
|
fixFor: null,
|
|
314
407
|
lastFindings: [],
|
|
315
408
|
lastGate: null,
|
|
409
|
+
gateTree: null,
|
|
316
410
|
pending: "setup",
|
|
317
411
|
outcome: null,
|
|
318
412
|
history: [],
|
|
@@ -337,6 +431,7 @@ const effectiveMutations = (s) => (s.lane === "lean" ? [] : s.material.mutations
|
|
|
337
431
|
|
|
338
432
|
function ruleApplies(rule, seat, s) {
|
|
339
433
|
if (!rule.seats.includes(seat)) return false;
|
|
434
|
+
if (rule.when === "install") return s.install !== null && s.install !== undefined;
|
|
340
435
|
if (rule.when === "full") return s.lane === "full";
|
|
341
436
|
if (rule.when === "tests-only") return s.lane === "tests-only";
|
|
342
437
|
if (rule.when === "mutations") {
|
|
@@ -362,21 +457,33 @@ function buildBrief(s) {
|
|
|
362
457
|
unverified: cp(s.material.unverified),
|
|
363
458
|
testFiles: cp(s.material.testFiles),
|
|
364
459
|
mutations: cp(effectiveMutations(s)),
|
|
460
|
+
reviewerChecks: seat === "reviewer" ? cp(s.material.reviewerChecks || []) : [],
|
|
461
|
+
notRun: seat === "reviewer" ? cp(s.notRun || []) : [],
|
|
365
462
|
rules: Object.entries(BRIEF_RULES)
|
|
366
463
|
.filter(([, r]) => ruleApplies(r, seat, s))
|
|
367
464
|
.map(([id, r]) => ({ id, text: r.text })),
|
|
368
465
|
findings: fixing && s.fixFor === "review" ? cp(s.lastFindings) : [],
|
|
369
466
|
gate: fixing && s.fixFor === "gate" ? cp(s.lastGate) : null,
|
|
370
467
|
redTests: cp(s.redTests),
|
|
468
|
+
answers: (s.answers || [])
|
|
469
|
+
.filter(
|
|
470
|
+
(a) =>
|
|
471
|
+
(a.seat === seat && (a.round === undefined || a.round <= (s.seatRuns[seat] || 0) + 1)) ||
|
|
472
|
+
// A reviewer answer reaches the build seat once the reviewer round it targets has run.
|
|
473
|
+
(seat === buildSeat(s) && a.seat === "reviewer" && (a.round ?? 1) <= (s.seatRuns.reviewer || 0)),
|
|
474
|
+
)
|
|
475
|
+
.map((a) => ({ question: a.question, answer: a.answer })),
|
|
371
476
|
writeTestsFirst: seat === "coder" && s.counters.coderRounds === 0 && (s.lane === "lean" || !s.testerAgent),
|
|
372
477
|
researchNote: s.researchNote,
|
|
373
|
-
handBack: cp(
|
|
478
|
+
handBack: cp(handBackSchema(seat, s.lane)),
|
|
374
479
|
};
|
|
375
480
|
if (seat === "setup") {
|
|
376
481
|
brief.install = s.install;
|
|
377
482
|
brief.base = s.base;
|
|
378
483
|
}
|
|
484
|
+
if (seat === "gate" && s.install) brief.install = s.install;
|
|
379
485
|
if (seat === "gate" || seat === "reviewer") brief.gateCommand = s.gateCommand;
|
|
486
|
+
if (seat === "commit") brief.gateTree = s.gateTree;
|
|
380
487
|
return brief;
|
|
381
488
|
}
|
|
382
489
|
|
|
@@ -402,6 +509,7 @@ function buildReport(s) {
|
|
|
402
509
|
testsEdited: cp(s.acc.testsEdited),
|
|
403
510
|
filesChanged: cp(s.acc.filesChanged),
|
|
404
511
|
followUps: cp(s.acc.followUps),
|
|
512
|
+
researchNote: s.researchNote ?? null,
|
|
405
513
|
cost: null,
|
|
406
514
|
};
|
|
407
515
|
}
|
|
@@ -446,8 +554,8 @@ function validate(state, hb) {
|
|
|
446
554
|
const muts = effectiveMutations(state);
|
|
447
555
|
const needCover = (list, path, ids) => {
|
|
448
556
|
if (!Array.isArray(list)) return;
|
|
449
|
-
const have =
|
|
450
|
-
for (const id of ids) if (!have.
|
|
557
|
+
const have = list.filter(isObj).map((m) => m.id);
|
|
558
|
+
for (const id of ids) if (!have.some((h) => coversId(h, id))) errs.push(`${path} is missing listed mutation ${id}`);
|
|
451
559
|
};
|
|
452
560
|
const first = state.counters.coderRounds === 0;
|
|
453
561
|
|
|
@@ -462,7 +570,7 @@ function validate(state, hb) {
|
|
|
462
570
|
break;
|
|
463
571
|
}
|
|
464
572
|
case "research":
|
|
465
|
-
if (!
|
|
573
|
+
if (!nonBlank(hb.note)) errs.push("note must be the researcher's cited note text or a path, not blank");
|
|
466
574
|
break;
|
|
467
575
|
case "lead": {
|
|
468
576
|
if (!isStr(hb.lessons)) errs.push("lessons must be a string");
|
|
@@ -474,6 +582,7 @@ function validate(state, hb) {
|
|
|
474
582
|
}
|
|
475
583
|
if (!strArr(hb.unverified)) errs.push("unverified must be an array of strings");
|
|
476
584
|
if (!strArr(hb.testFiles)) errs.push("testFiles must be an array of strings");
|
|
585
|
+
if (hb.reviewerChecks !== undefined && !strArr(hb.reviewerChecks)) errs.push("reviewerChecks must be an array of strings");
|
|
477
586
|
if (!Array.isArray(hb.mutations)) errs.push("mutations must be an array");
|
|
478
587
|
else {
|
|
479
588
|
const ids = new Set();
|
|
@@ -481,6 +590,7 @@ function validate(state, hb) {
|
|
|
481
590
|
if (!isObj(m) || !nonBlank(m.id) || !isStr(m.case) || !isStr(m.mutation) || !isStr(m.test)) {
|
|
482
591
|
errs.push(`mutations[${i}] needs id, case, mutation and test`);
|
|
483
592
|
} else if (ids.has(m.id)) errs.push(`mutations[${i}] repeats id ${m.id}`);
|
|
593
|
+
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
594
|
else ids.add(m.id);
|
|
485
595
|
});
|
|
486
596
|
}
|
|
@@ -494,7 +604,14 @@ function validate(state, hb) {
|
|
|
494
604
|
errs.push("tests_red_by_design must be an array of strings");
|
|
495
605
|
}
|
|
496
606
|
let shaped = true;
|
|
497
|
-
if (hb.mutationResults !== undefined) shaped = checkMutResults(hb.mutationResults, "mutationResults", errs);
|
|
607
|
+
if (hb.mutationResults !== undefined) shaped = checkMutResults(hb.mutationResults, "mutationResults", errs, state.lane === "tests-only");
|
|
608
|
+
if (state.lane === "tests-only" && shaped && Array.isArray(hb.mutationResults)) {
|
|
609
|
+
hb.mutationResults.forEach((m, i) => {
|
|
610
|
+
if (isObj(m) && m.notRun !== undefined && !muts.some((x) => x.id === m.id)) {
|
|
611
|
+
errs.push(`mutationResults[${i}] notRun id ${m.id} is not a listed mutation`);
|
|
612
|
+
}
|
|
613
|
+
});
|
|
614
|
+
}
|
|
498
615
|
if (state.lane === "tests-only" && first && muts.length > 0) {
|
|
499
616
|
if (hb.mutationResults === undefined) errs.push("mutationResults is required on the first run");
|
|
500
617
|
else if (shaped) needCover(hb.mutationResults, "mutationResults", muts.map((m) => m.id));
|
|
@@ -511,7 +628,14 @@ function validate(state, hb) {
|
|
|
511
628
|
});
|
|
512
629
|
}
|
|
513
630
|
let shaped = true;
|
|
514
|
-
if (hb.mutationResults !== undefined) shaped = checkMutResults(hb.mutationResults, "mutationResults", errs);
|
|
631
|
+
if (hb.mutationResults !== undefined) shaped = checkMutResults(hb.mutationResults, "mutationResults", errs, true);
|
|
632
|
+
if (shaped && Array.isArray(hb.mutationResults)) {
|
|
633
|
+
hb.mutationResults.forEach((m, i) => {
|
|
634
|
+
if (isObj(m) && m.notRun !== undefined && !muts.some((x) => x.id === m.id)) {
|
|
635
|
+
errs.push(`mutationResults[${i}] notRun id ${m.id} is not a listed mutation`);
|
|
636
|
+
}
|
|
637
|
+
});
|
|
638
|
+
}
|
|
515
639
|
if (first && muts.length > 0) {
|
|
516
640
|
if (hb.mutationResults === undefined) errs.push("mutationResults is required on the first round");
|
|
517
641
|
else if (shaped) needCover(hb.mutationResults, "mutationResults", muts.map((m) => m.id));
|
|
@@ -538,11 +662,16 @@ function validate(state, hb) {
|
|
|
538
662
|
}
|
|
539
663
|
if (hb.verdict === "pass" && blocker) errs.push("verdict pass contradicts an evidenced blocker");
|
|
540
664
|
checkCmds(hb.commandsRun, "commandsRun", errs, false);
|
|
665
|
+
if (Array.isArray(hb.mutationResults)) {
|
|
666
|
+
hb.mutationResults.forEach((m, i) => {
|
|
667
|
+
if (isObj(m) && m.notRun !== undefined) errs.push(`mutationResults[${i}] notRun is not allowed for the reviewer`);
|
|
668
|
+
});
|
|
669
|
+
}
|
|
541
670
|
if (checkMutResults(hb.mutationResults, "mutationResults", errs)) {
|
|
542
671
|
const listed = muts.map((m) => m.id);
|
|
543
672
|
needCover(hb.mutationResults, "mutationResults", listed);
|
|
544
673
|
for (const m of hb.mutationResults) {
|
|
545
|
-
if (!listed.
|
|
674
|
+
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
675
|
}
|
|
547
676
|
}
|
|
548
677
|
break;
|
|
@@ -553,6 +682,7 @@ function validate(state, hb) {
|
|
|
553
682
|
errs.push("gate seat must not run git add or git commit");
|
|
554
683
|
}
|
|
555
684
|
if (!nonBlank(hb.gateLine)) errs.push("gateLine must be a non-empty string");
|
|
685
|
+
if (!(isStr(hb.tree) && TREE_RE.test(hb.tree))) errs.push("tree must be 40 or 64 lowercase hex characters");
|
|
556
686
|
if (hb.lockWaitMs !== undefined && hb.lockWaitMs !== null && !Number.isInteger(hb.lockWaitMs)) {
|
|
557
687
|
errs.push("lockWaitMs must be an integer or null");
|
|
558
688
|
}
|
|
@@ -561,6 +691,7 @@ function validate(state, hb) {
|
|
|
561
691
|
case "commit": {
|
|
562
692
|
if (!(isStr(hb.sha) && /^[0-9a-f]{7,40}$/.test(hb.sha))) errs.push("sha must be 7 to 40 lowercase hex characters");
|
|
563
693
|
if (!nonBlank(hb.branch)) errs.push("branch must be a non-empty string");
|
|
694
|
+
if (!(isStr(hb.tree) && TREE_RE.test(hb.tree))) errs.push("tree must be 40 or 64 lowercase hex characters");
|
|
564
695
|
break;
|
|
565
696
|
}
|
|
566
697
|
default:
|
|
@@ -571,6 +702,8 @@ function validate(state, hb) {
|
|
|
571
702
|
|
|
572
703
|
// ---- next ----
|
|
573
704
|
|
|
705
|
+
const TREE_RE = /^(?:[0-9a-f]{40}|[0-9a-f]{64})$/;
|
|
706
|
+
|
|
574
707
|
// 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
708
|
const GIT_WRITE_RE = /(?:^|[;&|(\n]\s*)git(?:\s+(?:-[Cc]\s+(?:"[^"]*"|'[^']*'|\S+)|--[\w-]+(?:=\S+)?))*\s+(?:add|commit)\b/;
|
|
576
709
|
|
|
@@ -620,6 +753,9 @@ function next(state, handBack) {
|
|
|
620
753
|
// C3 .. C6
|
|
621
754
|
if (isObj(H.hookBlocked) && nonBlank(H.hookBlocked.message)) return stopWith(s, "hook-blocked", H.hookBlocked.message);
|
|
622
755
|
if (nonBlank(H.designQuestion)) return stopWith(s, "design-decision", H.designQuestion);
|
|
756
|
+
if (P === "gate" && Array.isArray(H.commands) && H.commands.some((c) => isObj(c) && c.timedOut === true)) {
|
|
757
|
+
return stopWith({ ...s, lastGate: { commands: cp(H.commands), gateLine: H.gateLine } }, "gate-timeout", typeof H.gateLine === "string" ? H.gateLine : "a gate command timed out");
|
|
758
|
+
}
|
|
623
759
|
if (nonBlank(H.blocked)) return stopWith(s, "seat-blocked", H.blocked);
|
|
624
760
|
const errs = validateHandBack(s, H);
|
|
625
761
|
if (errs.length > 0) return stopWith(s, "hand-back-invalid", errs.join("; "));
|
|
@@ -640,6 +776,7 @@ function next(state, handBack) {
|
|
|
640
776
|
unverified: cp(H.unverified),
|
|
641
777
|
testFiles: cp(H.testFiles),
|
|
642
778
|
mutations: cp(H.mutations),
|
|
779
|
+
reviewerChecks: strArr(H.reviewerChecks) ? cp(H.reviewerChecks) : [],
|
|
643
780
|
};
|
|
644
781
|
const to = s.lane === "lean" || !s.testerAgent ? "coder" : "tester";
|
|
645
782
|
return advance(s, to, "ok", { material });
|
|
@@ -648,13 +785,41 @@ function next(state, handBack) {
|
|
|
648
785
|
const acc = { ...s.acc, filesChanged: union(s.acc.filesChanged, H.files) };
|
|
649
786
|
if (Array.isArray(H.mutationResults) && s.lane === "tests-only") acc.mutations = cp(H.mutationResults);
|
|
650
787
|
if (s.lane === "tests-only") {
|
|
788
|
+
const seenT = new Set((s.notRun || []).map((n) => n.id));
|
|
789
|
+
const notRunT = [...(s.notRun || [])];
|
|
790
|
+
for (const m of Array.isArray(H.mutationResults) ? H.mutationResults : []) {
|
|
791
|
+
if (isObj(m) && m.notRun !== undefined && !seenT.has(m.id)) {
|
|
792
|
+
seenT.add(m.id);
|
|
793
|
+
notRunT.push({ id: m.id, reason: m.notRun });
|
|
794
|
+
}
|
|
795
|
+
}
|
|
651
796
|
return advance(s, "reviewer", "ok", {
|
|
652
797
|
acc,
|
|
798
|
+
notRun: notRunT,
|
|
653
799
|
fixFor: null,
|
|
654
800
|
counters: { ...s.counters, coderRounds: s.counters.coderRounds + 1 },
|
|
655
801
|
});
|
|
656
802
|
}
|
|
657
803
|
const claim = H.tests_red_by_design;
|
|
804
|
+
if (Array.isArray(claim) && claim.length === 0) {
|
|
805
|
+
// Pin-only card: the new tests pass at baseline. Go on only when every listed mutation was run and failed.
|
|
806
|
+
const listed = effectiveMutations(s).map((m) => m.id);
|
|
807
|
+
const results = Array.isArray(H.mutationResults) ? H.mutationResults.filter(isObj) : [];
|
|
808
|
+
const bad = listed.filter((id) => {
|
|
809
|
+
const cov = results.filter((m) => coversId(m.id, id));
|
|
810
|
+
return cov.length === 0 || cov.some((m) => m.failed !== true);
|
|
811
|
+
});
|
|
812
|
+
if (listed.length > 0 && bad.length === 0) {
|
|
813
|
+
return advance(s, "coder", "ok", { acc, redTests: [] });
|
|
814
|
+
}
|
|
815
|
+
return stopWith(
|
|
816
|
+
s,
|
|
817
|
+
"tester-red-claim-missing",
|
|
818
|
+
listed.length === 0
|
|
819
|
+
? "the tester returned an empty tests_red_by_design claim and no mutation is listed to show the pins bite"
|
|
820
|
+
: `the tester returned an empty tests_red_by_design claim but mutation ${bad.join(", ")} was not run or survived`,
|
|
821
|
+
);
|
|
822
|
+
}
|
|
658
823
|
if (!(Array.isArray(claim) && claim.length > 0 && claim.every(nonBlank))) {
|
|
659
824
|
return stopWith(s, "tester-red-claim-missing", "the tester returned no tests_red_by_design claim");
|
|
660
825
|
}
|
|
@@ -666,8 +831,17 @@ function next(state, handBack) {
|
|
|
666
831
|
filesChanged: union(s.acc.filesChanged, H.filesChanged),
|
|
667
832
|
testsEdited: [...s.acc.testsEdited, ...H.testsEdited.map((t) => ({ round, file: t.file, reason: t.reason }))],
|
|
668
833
|
};
|
|
834
|
+
const seen = new Set((s.notRun || []).map((n) => n.id));
|
|
835
|
+
const notRun = [...(s.notRun || [])];
|
|
836
|
+
for (const m of Array.isArray(H.mutationResults) ? H.mutationResults : []) {
|
|
837
|
+
if (isObj(m) && m.notRun !== undefined && !seen.has(m.id)) {
|
|
838
|
+
seen.add(m.id);
|
|
839
|
+
notRun.push({ id: m.id, reason: m.notRun });
|
|
840
|
+
}
|
|
841
|
+
}
|
|
669
842
|
return advance(s, "reviewer", "ok", {
|
|
670
843
|
acc,
|
|
844
|
+
notRun,
|
|
671
845
|
fixFor: null,
|
|
672
846
|
counters: { ...s.counters, coderRounds: s.counters.coderRounds + 1 },
|
|
673
847
|
});
|
|
@@ -696,6 +870,18 @@ function next(state, handBack) {
|
|
|
696
870
|
// An evidenced major gets the same one fix pass; a major without evidence and a minor stay advisory.
|
|
697
871
|
const majors = findings.filter((f) => f.severity === "major" && nonBlank(f.evidence));
|
|
698
872
|
if (blockers.length === 0 && majors.length === 0) return advance(s, "gate", "ok", { acc });
|
|
873
|
+
// A reviewer round that carried a user answer and still has an open finding gets one extra coder round.
|
|
874
|
+
const answered = (s.answers || []).some(
|
|
875
|
+
(a) => a.seat === "reviewer" && (a.round ?? 1) <= (s.seatRuns.reviewer || 0),
|
|
876
|
+
);
|
|
877
|
+
if (s.counters.fixPasses >= 1 && answered && !s.answerCoderUsed) {
|
|
878
|
+
return advance(s, buildSeat(s), "blockers", {
|
|
879
|
+
acc,
|
|
880
|
+
fixFor: "review",
|
|
881
|
+
answerCoderUsed: true,
|
|
882
|
+
lastFindings: findings.filter((f) => f.severity !== "minor"),
|
|
883
|
+
});
|
|
884
|
+
}
|
|
699
885
|
if (s.counters.fixPasses >= 1) {
|
|
700
886
|
if (blockers.length > 0) {
|
|
701
887
|
return stopWith(
|
|
@@ -721,8 +907,17 @@ function next(state, handBack) {
|
|
|
721
907
|
const lastGate = { commands: cp(H.commands), gateLine: H.gateLine };
|
|
722
908
|
const waited = { ...s.acc, lockWaitMs: (s.acc.lockWaitMs || 0) + (Number.isInteger(H.lockWaitMs) ? H.lockWaitMs : 0) };
|
|
723
909
|
s.acc = waited;
|
|
724
|
-
|
|
725
|
-
|
|
910
|
+
const isInstall = (c) => s.install && typeof c.command === "string" && c.command.trim() === `cd ${s.worktree} && ${s.install}`;
|
|
911
|
+
const failedInstall = H.commands.find((c) => isInstall(c) && c.exitCode !== 0);
|
|
912
|
+
if (failedInstall) {
|
|
913
|
+
return stopWith({ ...s, lastGate }, "setup-failed", `gate reinstall exited ${failedInstall.exitCode === null ? "without a code" : failedInstall.exitCode}`);
|
|
914
|
+
}
|
|
915
|
+
const nonInstall = H.commands.filter((c) => !isInstall(c));
|
|
916
|
+
if (nonInstall.every((c) => c.exitCode === 0)) {
|
|
917
|
+
if (!nonInstall.some((c) => typeof c.command === "string" && c.command.includes(s.gateCommand))) {
|
|
918
|
+
return stopWith({ ...s, lastGate }, "hand-back-invalid", `gate hand-back has no run of the gate command ${s.gateCommand}`);
|
|
919
|
+
}
|
|
920
|
+
return advance(s, "commit", "ok", { lastGate, gateTree: H.tree, acc: { ...waited, gateLine: H.gateLine } });
|
|
726
921
|
}
|
|
727
922
|
const redGates = s.counters.redGates + 1;
|
|
728
923
|
const counters = { ...s.counters, redGates };
|
|
@@ -731,6 +926,9 @@ function next(state, handBack) {
|
|
|
731
926
|
}
|
|
732
927
|
default: {
|
|
733
928
|
// commit
|
|
929
|
+
if (H.tree !== s.gateTree) {
|
|
930
|
+
return stopWith(s, "commit-tree-mismatch", `commit ${H.sha} tree ${H.tree} differs from the gated tree ${s.gateTree}`);
|
|
931
|
+
}
|
|
734
932
|
const state = {
|
|
735
933
|
...s,
|
|
736
934
|
pending: null,
|
|
@@ -753,24 +951,38 @@ function briefPrompt(brief, { repoRoot, worktreePath } = {}) {
|
|
|
753
951
|
out.push(`SEAT: ${b.seat} (round ${b.round}, lane ${b.lane}, model row ${b.row})`);
|
|
754
952
|
out.push(`CARD ${b.card.id}${b.card.title ? ": " + b.card.title : ""}`);
|
|
755
953
|
out.push("GOAL:\n" + b.card.goal);
|
|
954
|
+
if (b.answers && b.answers.length > 0) {
|
|
955
|
+
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}`)));
|
|
956
|
+
}
|
|
756
957
|
out.push("WORKTREE: " + worktreePath);
|
|
958
|
+
if (b.seat === "lead" && typeof repoRoot === "string" && repoRoot !== "") out.push("REPO ROOT: " + repoRoot);
|
|
757
959
|
if (b.seat === "setup") {
|
|
758
960
|
out.push(
|
|
759
961
|
"SETUP:\n" +
|
|
760
962
|
`Create the card worktree: git -C ${repoRoot} worktree add -b ${b.branch} ${worktreePath} ${b.base}\n` +
|
|
761
963
|
(b.install
|
|
762
|
-
? `Then run the install command
|
|
964
|
+
? `Then run the install command as this one literal command: cd ${worktreePath} && ${b.install}`
|
|
763
965
|
: "There is no install command; skip the install and report install as null."),
|
|
764
966
|
);
|
|
765
967
|
}
|
|
766
|
-
if (b.
|
|
968
|
+
if (b.seat === "gate" && b.install) out.push(`INSTALL COMMAND: cd ${worktreePath} && ${b.install}`);
|
|
969
|
+
if (b.researchNote) out.push("RESEARCH NOTE:" + (b.researchNote.includes("\n") ? "\n" : " ") + b.researchNote);
|
|
767
970
|
if (b.lessons) out.push("LESSONS BLOCK verbatim:\n" + b.lessons);
|
|
768
971
|
if (b.facts.length > 0) out.push("FACTS (with sources):\n" + bullets(b.facts.map((f) => `${f.text} [${f.source}]`)));
|
|
769
972
|
if (b.unverified.length > 0) out.push("UNVERIFIED:\n" + bullets(b.unverified));
|
|
770
973
|
if (b.testFiles.length > 0) out.push("TEST FILES:\n" + bullets(b.testFiles));
|
|
771
974
|
if (b.mutations.length > 0) {
|
|
772
975
|
out.push("MUTATIONS:\n" + bullets(b.mutations.map((m) => `${m.id} (${m.case}): ${m.mutation}; test that must fail: ${m.test}`)));
|
|
976
|
+
if ((b.rules || []).some((r) => r.id === "mutation-ids-verbatim")) {
|
|
977
|
+
out.push("MUTATION IDS (report each verbatim in mutationResults): " + b.mutations.map((m) => m.id).join(", "));
|
|
978
|
+
}
|
|
979
|
+
}
|
|
980
|
+
if (b.notRun && b.notRun.length > 0) {
|
|
981
|
+
out.push(
|
|
982
|
+
"NOT RUN BY THE CODER (report each with failed true or false):\n" + bullets(b.notRun.map((n) => `${n.id}: ${n.reason}`)),
|
|
983
|
+
);
|
|
773
984
|
}
|
|
985
|
+
if (b.reviewerChecks && b.reviewerChecks.length > 0) out.push("REVIEWER CHECKS:\n" + bullets(b.reviewerChecks));
|
|
774
986
|
if (b.redTests.length > 0) out.push("TESTS RED BY DESIGN:\n" + bullets(b.redTests));
|
|
775
987
|
if (b.writeTestsFirst) out.push("Write the test first, in the file that covers the module, then the change.");
|
|
776
988
|
if (b.findings.length > 0) {
|
|
@@ -788,6 +1000,7 @@ function briefPrompt(brief, { repoRoot, worktreePath } = {}) {
|
|
|
788
1000
|
);
|
|
789
1001
|
}
|
|
790
1002
|
if (b.gateCommand) out.push("GATE COMMAND: " + b.gateCommand);
|
|
1003
|
+
if (b.gateTree) out.push("GATED TREE: " + b.gateTree);
|
|
791
1004
|
out.push("RULES:\n" + bullets(b.rules.map((r) => r.text)));
|
|
792
1005
|
out.push("HAND-BACK: return JSON matching this schema:\n" + JSON.stringify(b.handBack, null, 2));
|
|
793
1006
|
return out.join("\n\n") + "\n";
|
|
@@ -820,7 +1033,7 @@ function seatOpts(seat, brief) {
|
|
|
820
1033
|
const row = SEAT_ROWS[seat]
|
|
821
1034
|
const src = (models.roles && models.roles[row]) || (models.tiers && models.tiers[row]) || {}
|
|
822
1035
|
const opts = {
|
|
823
|
-
schema:
|
|
1036
|
+
schema: brief.handBack,
|
|
824
1037
|
label: seat + ':' + args.card.id + ':' + brief.round,
|
|
825
1038
|
agentType: AGENT_TYPES[seat],
|
|
826
1039
|
}
|