@dougharness/cli 1.3.0 → 1.4.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- 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/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 +366 -21
- package/node_modules/@dougharness/flow/lib/card-loop.mjs +194 -20
- package/node_modules/@dougharness/flow/lib/cost.mjs +9 -0
- package/node_modules/@dougharness/flow/lib/memory.mjs +24 -1
- package/node_modules/@dougharness/flow/package.json +1 -1
- package/node_modules/@dougharness/flow/scripts/autopilot.mjs +193 -12
- 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 +14 -7
- 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 +1 -1
- package/node_modules/@dougharness/flow/skills/run-report/SKILL.md +2 -2
- package/node_modules/@dougharness/flow/workflows/card-queen.js +195 -21
- package/node_modules/@dougharness/flow/workflows/doug-implement.js +21 -18
- 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/stop-gate.mjs +91 -18
- package/package.json +3 -3
- package/templates/board-app.js +240 -30
|
@@ -16,6 +16,8 @@ export const STOP_REASONS = [
|
|
|
16
16
|
"reviewer-blocker-open",
|
|
17
17
|
"reviewer-major-open",
|
|
18
18
|
"gate-red-twice",
|
|
19
|
+
"gate-timeout",
|
|
20
|
+
"commit-tree-mismatch",
|
|
19
21
|
];
|
|
20
22
|
// The Models-table row each seat runs on. setup, gate and commit have no sourced row; cheap is a choice.
|
|
21
23
|
export const SEAT_ROWS = {
|
|
@@ -44,10 +46,18 @@ export const BRIEF_RULES = {
|
|
|
44
46
|
text: "If a hook blocks a step, do not work around it: report it in hookBlocked with the hook's message.",
|
|
45
47
|
seats: ALL,
|
|
46
48
|
},
|
|
49
|
+
"relayed-message-outside-card": {
|
|
50
|
+
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.",
|
|
51
|
+
seats: ALL,
|
|
52
|
+
},
|
|
47
53
|
"stay-in-goal": {
|
|
48
54
|
text: "Do only what the goal asks. A defect found on the way goes in leftOut, not into the change.",
|
|
49
55
|
seats: ["tester", "coder", "reviewer"],
|
|
50
56
|
},
|
|
57
|
+
"no-cpu-burners": {
|
|
58
|
+
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.",
|
|
59
|
+
seats: ["tester", "coder", "reviewer"],
|
|
60
|
+
},
|
|
51
61
|
"lessons-verbatim": {
|
|
52
62
|
text: "Return the memory.mjs brief-lessons block for this card verbatim in lessons; never paraphrase it.",
|
|
53
63
|
seats: ["lead"],
|
|
@@ -57,11 +67,20 @@ export const BRIEF_RULES = {
|
|
|
57
67
|
seats: ["lead"],
|
|
58
68
|
},
|
|
59
69
|
"mutation-per-case": {
|
|
60
|
-
text: "List one mutation per case the goal names, each with a unique id and the test that must fail.",
|
|
70
|
+
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.",
|
|
71
|
+
seats: ["lead"],
|
|
72
|
+
},
|
|
73
|
+
"mutation-ids-verbatim": {
|
|
74
|
+
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).",
|
|
75
|
+
seats: ["tester", "coder", "reviewer"],
|
|
76
|
+
when: "mutations",
|
|
77
|
+
},
|
|
78
|
+
"mutation-is-code-change": {
|
|
79
|
+
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.",
|
|
61
80
|
seats: ["lead"],
|
|
62
81
|
},
|
|
63
82
|
"red-claim": {
|
|
64
|
-
text: "Tests that are red by design are named in tests_red_by_design; without the claim the card stops.",
|
|
83
|
+
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.",
|
|
65
84
|
seats: ["tester"],
|
|
66
85
|
when: "full",
|
|
67
86
|
},
|
|
@@ -103,6 +122,22 @@ export const BRIEF_RULES = {
|
|
|
103
122
|
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.",
|
|
104
123
|
seats: ["gate"],
|
|
105
124
|
},
|
|
125
|
+
"gate-timeout-is-load": {
|
|
126
|
+
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.",
|
|
127
|
+
seats: ["gate"],
|
|
128
|
+
},
|
|
129
|
+
"commit-no-file-edits": {
|
|
130
|
+
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.",
|
|
131
|
+
seats: ["commit"],
|
|
132
|
+
},
|
|
133
|
+
"commit-reports-tree": {
|
|
134
|
+
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.",
|
|
135
|
+
seats: ["commit"],
|
|
136
|
+
},
|
|
137
|
+
"gate-reports-tree": {
|
|
138
|
+
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.",
|
|
139
|
+
seats: ["gate"],
|
|
140
|
+
},
|
|
106
141
|
"leave-plan-anchor-out": {
|
|
107
142
|
text: "Leave .doug/plan.json and .doug/anchor.md out of the commit unless the card is about them.",
|
|
108
143
|
seats: ["commit"],
|
|
@@ -113,11 +148,13 @@ export const BRIEF_RULES = {
|
|
|
113
148
|
|
|
114
149
|
const S = { type: "string" };
|
|
115
150
|
const NES = { type: "string", minLength: 1 };
|
|
151
|
+
const MUTATION_ID_PATTERN = "^M[1-9][0-9]*$";
|
|
152
|
+
const TREE = { type: "string", pattern: "^([0-9a-f]{40}|[0-9a-f]{64})$" };
|
|
116
153
|
const INT_OR_NULL = { type: ["integer", "null"] };
|
|
117
154
|
const STRS = { type: "array", items: S };
|
|
118
155
|
const CMD = {
|
|
119
156
|
type: "object",
|
|
120
|
-
properties: { command: S, exitCode: INT_OR_NULL },
|
|
157
|
+
properties: { command: S, exitCode: INT_OR_NULL, timedOut: { type: "boolean" } },
|
|
121
158
|
required: ["command", "exitCode"],
|
|
122
159
|
};
|
|
123
160
|
const MUT_RESULT = {
|
|
@@ -125,6 +162,11 @@ const MUT_RESULT = {
|
|
|
125
162
|
properties: { id: S, failed: { type: "boolean" }, test: S, assertion: S, own: { type: "boolean" } },
|
|
126
163
|
required: ["id", "failed", "test"],
|
|
127
164
|
};
|
|
165
|
+
const CODER_MUT_RESULT = {
|
|
166
|
+
type: "object",
|
|
167
|
+
properties: { id: S, failed: { type: "boolean" }, test: S, assertion: S, own: { type: "boolean" }, notRun: NES },
|
|
168
|
+
required: ["id"],
|
|
169
|
+
};
|
|
128
170
|
const ENVELOPE = {
|
|
129
171
|
seat: { type: "string", enum: SEATS },
|
|
130
172
|
hookBlocked: { type: "object", properties: { message: NES }, required: ["message"] },
|
|
@@ -156,11 +198,12 @@ export const HAND_BACK_SCHEMAS = {
|
|
|
156
198
|
},
|
|
157
199
|
unverified: STRS,
|
|
158
200
|
testFiles: STRS,
|
|
201
|
+
reviewerChecks: STRS,
|
|
159
202
|
mutations: {
|
|
160
203
|
type: "array",
|
|
161
204
|
items: {
|
|
162
205
|
type: "object",
|
|
163
|
-
properties: { id:
|
|
206
|
+
properties: { id: { type: "string", minLength: 1, pattern: MUTATION_ID_PATTERN }, case: S, mutation: S, test: S },
|
|
164
207
|
required: ["id", "case", "mutation", "test"],
|
|
165
208
|
},
|
|
166
209
|
},
|
|
@@ -185,7 +228,7 @@ export const HAND_BACK_SCHEMAS = {
|
|
|
185
228
|
type: "array",
|
|
186
229
|
items: { type: "object", properties: { file: S, reason: NES }, required: ["file", "reason"] },
|
|
187
230
|
},
|
|
188
|
-
mutationResults: { type: "array", items:
|
|
231
|
+
mutationResults: { type: "array", items: CODER_MUT_RESULT },
|
|
189
232
|
},
|
|
190
233
|
["filesChanged", "commandsRun", "testsEdited"],
|
|
191
234
|
),
|
|
@@ -214,12 +257,25 @@ export const HAND_BACK_SCHEMAS = {
|
|
|
214
257
|
),
|
|
215
258
|
gate: schema(
|
|
216
259
|
"gate",
|
|
217
|
-
{ commands: { type: "array", items: CMD, minItems: 1 }, gateLine: NES, lockWaitMs: INT_OR_NULL },
|
|
218
|
-
["commands", "gateLine"],
|
|
260
|
+
{ commands: { type: "array", items: CMD, minItems: 1 }, gateLine: NES, lockWaitMs: INT_OR_NULL, tree: TREE },
|
|
261
|
+
["commands", "gateLine", "tree"],
|
|
219
262
|
),
|
|
220
|
-
commit: schema("commit", { sha: { type: "string", pattern: "^[0-9a-f]{7,40}$" }, branch: S }, ["sha", "branch"]),
|
|
263
|
+
commit: schema("commit", { sha: { type: "string", pattern: "^[0-9a-f]{7,40}$" }, branch: S, tree: TREE }, ["sha", "branch", "tree"]),
|
|
221
264
|
};
|
|
222
265
|
|
|
266
|
+
// The schema a seat is handed on a lane. Only the tests-only tester may report notRun (validateHandBack's tester
|
|
267
|
+
// case), so only there are its mutationResults items CODER_MUT_RESULT; the plain table is the full-lane contract.
|
|
268
|
+
export function handBackSchema(seat, lane) {
|
|
269
|
+
if (seat === "tester" && lane === "tests-only") {
|
|
270
|
+
const base = HAND_BACK_SCHEMAS.tester;
|
|
271
|
+
return {
|
|
272
|
+
...base,
|
|
273
|
+
properties: { ...base.properties, mutationResults: { type: "array", items: CODER_MUT_RESULT } },
|
|
274
|
+
};
|
|
275
|
+
}
|
|
276
|
+
return HAND_BACK_SCHEMAS[seat];
|
|
277
|
+
}
|
|
278
|
+
|
|
223
279
|
// ---- small helpers ----
|
|
224
280
|
|
|
225
281
|
const cp = (x) => JSON.parse(JSON.stringify(x));
|
|
@@ -243,14 +299,25 @@ function checkCmds(list, path, errs, needOne) {
|
|
|
243
299
|
});
|
|
244
300
|
}
|
|
245
301
|
|
|
246
|
-
|
|
302
|
+
// A reported id covers listed id L when it is L or L plus one lowercase letter (a split such as M1a, M1b).
|
|
303
|
+
function coversId(reported, listed) {
|
|
304
|
+
if (reported === listed) return true;
|
|
305
|
+
return typeof reported === "string" && reported.length === listed.length + 1 && reported.startsWith(listed) && /[a-z]$/.test(reported);
|
|
306
|
+
}
|
|
307
|
+
|
|
308
|
+
function checkMutResults(list, path, errs, allowNotRun = false) {
|
|
247
309
|
if (!Array.isArray(list)) {
|
|
248
310
|
errs.push(path + " must be an array");
|
|
249
311
|
return false;
|
|
250
312
|
}
|
|
251
313
|
let good = true;
|
|
252
314
|
list.forEach((m, i) => {
|
|
253
|
-
if (
|
|
315
|
+
if (allowNotRun && isObj(m) && m.notRun !== undefined) {
|
|
316
|
+
if (!nonBlank(m.id) || !nonBlank(m.notRun)) {
|
|
317
|
+
errs.push(`${path}[${i}] notRun needs id and a non-empty reason string`);
|
|
318
|
+
good = false;
|
|
319
|
+
}
|
|
320
|
+
} else if (!isObj(m) || !nonBlank(m.id) || typeof m.failed !== "boolean" || !isStr(m.test)) {
|
|
254
321
|
errs.push(`${path}[${i}] needs id, failed (boolean) and test`);
|
|
255
322
|
good = false;
|
|
256
323
|
}
|
|
@@ -267,6 +334,21 @@ export function laneFor(card) {
|
|
|
267
334
|
return "full";
|
|
268
335
|
}
|
|
269
336
|
|
|
337
|
+
// options.answers: the user's answers to design questions, { seat, question, answer, round?, run? } each.
|
|
338
|
+
function checkAnswers(answers) {
|
|
339
|
+
if (answers === undefined || answers === null) return [];
|
|
340
|
+
const bad = (why) => new TypeError("options.answers " + why);
|
|
341
|
+
if (!Array.isArray(answers)) throw bad("must be an array");
|
|
342
|
+
return answers.map((a, i) => {
|
|
343
|
+
if (!isObj(a)) throw bad(`[${i}] must be an object`);
|
|
344
|
+
if (!SEATS.includes(a.seat)) throw bad(`[${i}].seat must be one of the seats`);
|
|
345
|
+
if (!isStr(a.question)) throw bad(`[${i}].question must be a string`);
|
|
346
|
+
if (!nonBlank(a.answer)) throw bad(`[${i}].answer must be a non-blank string`);
|
|
347
|
+
if (a.round !== undefined && !(Number.isInteger(a.round) && a.round > 0)) throw bad(`[${i}].round must be a positive integer`);
|
|
348
|
+
return { ...a };
|
|
349
|
+
});
|
|
350
|
+
}
|
|
351
|
+
|
|
270
352
|
export function initialState(card, options = {}) {
|
|
271
353
|
if (!isObj(card) || !nonBlank(card.id) || !isStr(card.goal)) {
|
|
272
354
|
throw new TypeError("card needs an id and a goal string");
|
|
@@ -274,6 +356,7 @@ export function initialState(card, options = {}) {
|
|
|
274
356
|
const o = isObj(options) ? options : {};
|
|
275
357
|
return {
|
|
276
358
|
version: 1,
|
|
359
|
+
answers: checkAnswers(o.answers),
|
|
277
360
|
card: {
|
|
278
361
|
id: card.id,
|
|
279
362
|
title: card.title ?? null,
|
|
@@ -290,13 +373,15 @@ export function initialState(card, options = {}) {
|
|
|
290
373
|
base: o.base ?? "main",
|
|
291
374
|
worktree: null,
|
|
292
375
|
researchNote: null,
|
|
293
|
-
material: { lessons: "", facts: [], unverified: [], testFiles: [], mutations: [] },
|
|
376
|
+
material: { lessons: "", facts: [], unverified: [], testFiles: [], mutations: [], reviewerChecks: [] },
|
|
377
|
+
notRun: [],
|
|
294
378
|
redTests: [],
|
|
295
379
|
counters: { coderRounds: 0, fixPasses: 0, redGates: 0 },
|
|
296
380
|
seatRuns: {},
|
|
297
381
|
fixFor: null,
|
|
298
382
|
lastFindings: [],
|
|
299
383
|
lastGate: null,
|
|
384
|
+
gateTree: null,
|
|
300
385
|
pending: "setup",
|
|
301
386
|
outcome: null,
|
|
302
387
|
history: [],
|
|
@@ -346,21 +431,27 @@ function buildBrief(s) {
|
|
|
346
431
|
unverified: cp(s.material.unverified),
|
|
347
432
|
testFiles: cp(s.material.testFiles),
|
|
348
433
|
mutations: cp(effectiveMutations(s)),
|
|
434
|
+
reviewerChecks: seat === "reviewer" ? cp(s.material.reviewerChecks || []) : [],
|
|
435
|
+
notRun: seat === "reviewer" ? cp(s.notRun || []) : [],
|
|
349
436
|
rules: Object.entries(BRIEF_RULES)
|
|
350
437
|
.filter(([, r]) => ruleApplies(r, seat, s))
|
|
351
438
|
.map(([id, r]) => ({ id, text: r.text })),
|
|
352
439
|
findings: fixing && s.fixFor === "review" ? cp(s.lastFindings) : [],
|
|
353
440
|
gate: fixing && s.fixFor === "gate" ? cp(s.lastGate) : null,
|
|
354
441
|
redTests: cp(s.redTests),
|
|
442
|
+
answers: (s.answers || [])
|
|
443
|
+
.filter((a) => a.seat === seat && (a.round === undefined || a.round <= (s.seatRuns[seat] || 0) + 1))
|
|
444
|
+
.map((a) => ({ question: a.question, answer: a.answer })),
|
|
355
445
|
writeTestsFirst: seat === "coder" && s.counters.coderRounds === 0 && (s.lane === "lean" || !s.testerAgent),
|
|
356
446
|
researchNote: s.researchNote,
|
|
357
|
-
handBack: cp(
|
|
447
|
+
handBack: cp(handBackSchema(seat, s.lane)),
|
|
358
448
|
};
|
|
359
449
|
if (seat === "setup") {
|
|
360
450
|
brief.install = s.install;
|
|
361
451
|
brief.base = s.base;
|
|
362
452
|
}
|
|
363
453
|
if (seat === "gate" || seat === "reviewer") brief.gateCommand = s.gateCommand;
|
|
454
|
+
if (seat === "commit") brief.gateTree = s.gateTree;
|
|
364
455
|
return brief;
|
|
365
456
|
}
|
|
366
457
|
|
|
@@ -386,6 +477,7 @@ function buildReport(s) {
|
|
|
386
477
|
testsEdited: cp(s.acc.testsEdited),
|
|
387
478
|
filesChanged: cp(s.acc.filesChanged),
|
|
388
479
|
followUps: cp(s.acc.followUps),
|
|
480
|
+
researchNote: s.researchNote ?? null,
|
|
389
481
|
cost: null,
|
|
390
482
|
};
|
|
391
483
|
}
|
|
@@ -430,8 +522,8 @@ function validate(state, hb) {
|
|
|
430
522
|
const muts = effectiveMutations(state);
|
|
431
523
|
const needCover = (list, path, ids) => {
|
|
432
524
|
if (!Array.isArray(list)) return;
|
|
433
|
-
const have =
|
|
434
|
-
for (const id of ids) if (!have.
|
|
525
|
+
const have = list.filter(isObj).map((m) => m.id);
|
|
526
|
+
for (const id of ids) if (!have.some((h) => coversId(h, id))) errs.push(`${path} is missing listed mutation ${id}`);
|
|
435
527
|
};
|
|
436
528
|
const first = state.counters.coderRounds === 0;
|
|
437
529
|
|
|
@@ -446,7 +538,7 @@ function validate(state, hb) {
|
|
|
446
538
|
break;
|
|
447
539
|
}
|
|
448
540
|
case "research":
|
|
449
|
-
if (!
|
|
541
|
+
if (!nonBlank(hb.note)) errs.push("note must be the researcher's cited note text or a path, not blank");
|
|
450
542
|
break;
|
|
451
543
|
case "lead": {
|
|
452
544
|
if (!isStr(hb.lessons)) errs.push("lessons must be a string");
|
|
@@ -458,6 +550,7 @@ function validate(state, hb) {
|
|
|
458
550
|
}
|
|
459
551
|
if (!strArr(hb.unverified)) errs.push("unverified must be an array of strings");
|
|
460
552
|
if (!strArr(hb.testFiles)) errs.push("testFiles must be an array of strings");
|
|
553
|
+
if (hb.reviewerChecks !== undefined && !strArr(hb.reviewerChecks)) errs.push("reviewerChecks must be an array of strings");
|
|
461
554
|
if (!Array.isArray(hb.mutations)) errs.push("mutations must be an array");
|
|
462
555
|
else {
|
|
463
556
|
const ids = new Set();
|
|
@@ -465,6 +558,7 @@ function validate(state, hb) {
|
|
|
465
558
|
if (!isObj(m) || !nonBlank(m.id) || !isStr(m.case) || !isStr(m.mutation) || !isStr(m.test)) {
|
|
466
559
|
errs.push(`mutations[${i}] needs id, case, mutation and test`);
|
|
467
560
|
} else if (ids.has(m.id)) errs.push(`mutations[${i}] repeats id ${m.id}`);
|
|
561
|
+
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, ...)`);
|
|
468
562
|
else ids.add(m.id);
|
|
469
563
|
});
|
|
470
564
|
}
|
|
@@ -478,7 +572,14 @@ function validate(state, hb) {
|
|
|
478
572
|
errs.push("tests_red_by_design must be an array of strings");
|
|
479
573
|
}
|
|
480
574
|
let shaped = true;
|
|
481
|
-
if (hb.mutationResults !== undefined) shaped = checkMutResults(hb.mutationResults, "mutationResults", errs);
|
|
575
|
+
if (hb.mutationResults !== undefined) shaped = checkMutResults(hb.mutationResults, "mutationResults", errs, state.lane === "tests-only");
|
|
576
|
+
if (state.lane === "tests-only" && shaped && Array.isArray(hb.mutationResults)) {
|
|
577
|
+
hb.mutationResults.forEach((m, i) => {
|
|
578
|
+
if (isObj(m) && m.notRun !== undefined && !muts.some((x) => x.id === m.id)) {
|
|
579
|
+
errs.push(`mutationResults[${i}] notRun id ${m.id} is not a listed mutation`);
|
|
580
|
+
}
|
|
581
|
+
});
|
|
582
|
+
}
|
|
482
583
|
if (state.lane === "tests-only" && first && muts.length > 0) {
|
|
483
584
|
if (hb.mutationResults === undefined) errs.push("mutationResults is required on the first run");
|
|
484
585
|
else if (shaped) needCover(hb.mutationResults, "mutationResults", muts.map((m) => m.id));
|
|
@@ -495,7 +596,14 @@ function validate(state, hb) {
|
|
|
495
596
|
});
|
|
496
597
|
}
|
|
497
598
|
let shaped = true;
|
|
498
|
-
if (hb.mutationResults !== undefined) shaped = checkMutResults(hb.mutationResults, "mutationResults", errs);
|
|
599
|
+
if (hb.mutationResults !== undefined) shaped = checkMutResults(hb.mutationResults, "mutationResults", errs, true);
|
|
600
|
+
if (shaped && Array.isArray(hb.mutationResults)) {
|
|
601
|
+
hb.mutationResults.forEach((m, i) => {
|
|
602
|
+
if (isObj(m) && m.notRun !== undefined && !muts.some((x) => x.id === m.id)) {
|
|
603
|
+
errs.push(`mutationResults[${i}] notRun id ${m.id} is not a listed mutation`);
|
|
604
|
+
}
|
|
605
|
+
});
|
|
606
|
+
}
|
|
499
607
|
if (first && muts.length > 0) {
|
|
500
608
|
if (hb.mutationResults === undefined) errs.push("mutationResults is required on the first round");
|
|
501
609
|
else if (shaped) needCover(hb.mutationResults, "mutationResults", muts.map((m) => m.id));
|
|
@@ -522,11 +630,16 @@ function validate(state, hb) {
|
|
|
522
630
|
}
|
|
523
631
|
if (hb.verdict === "pass" && blocker) errs.push("verdict pass contradicts an evidenced blocker");
|
|
524
632
|
checkCmds(hb.commandsRun, "commandsRun", errs, false);
|
|
633
|
+
if (Array.isArray(hb.mutationResults)) {
|
|
634
|
+
hb.mutationResults.forEach((m, i) => {
|
|
635
|
+
if (isObj(m) && m.notRun !== undefined) errs.push(`mutationResults[${i}] notRun is not allowed for the reviewer`);
|
|
636
|
+
});
|
|
637
|
+
}
|
|
525
638
|
if (checkMutResults(hb.mutationResults, "mutationResults", errs)) {
|
|
526
639
|
const listed = muts.map((m) => m.id);
|
|
527
640
|
needCover(hb.mutationResults, "mutationResults", listed);
|
|
528
641
|
for (const m of hb.mutationResults) {
|
|
529
|
-
if (!listed.
|
|
642
|
+
if (!listed.some((l) => coversId(m.id, l)) && m.own !== true) errs.push(`mutationResults id ${m.id} is not listed and not marked own`);
|
|
530
643
|
}
|
|
531
644
|
}
|
|
532
645
|
break;
|
|
@@ -537,6 +650,7 @@ function validate(state, hb) {
|
|
|
537
650
|
errs.push("gate seat must not run git add or git commit");
|
|
538
651
|
}
|
|
539
652
|
if (!nonBlank(hb.gateLine)) errs.push("gateLine must be a non-empty string");
|
|
653
|
+
if (!(isStr(hb.tree) && TREE_RE.test(hb.tree))) errs.push("tree must be 40 or 64 lowercase hex characters");
|
|
540
654
|
if (hb.lockWaitMs !== undefined && hb.lockWaitMs !== null && !Number.isInteger(hb.lockWaitMs)) {
|
|
541
655
|
errs.push("lockWaitMs must be an integer or null");
|
|
542
656
|
}
|
|
@@ -545,6 +659,7 @@ function validate(state, hb) {
|
|
|
545
659
|
case "commit": {
|
|
546
660
|
if (!(isStr(hb.sha) && /^[0-9a-f]{7,40}$/.test(hb.sha))) errs.push("sha must be 7 to 40 lowercase hex characters");
|
|
547
661
|
if (!nonBlank(hb.branch)) errs.push("branch must be a non-empty string");
|
|
662
|
+
if (!(isStr(hb.tree) && TREE_RE.test(hb.tree))) errs.push("tree must be 40 or 64 lowercase hex characters");
|
|
548
663
|
break;
|
|
549
664
|
}
|
|
550
665
|
default:
|
|
@@ -555,6 +670,8 @@ function validate(state, hb) {
|
|
|
555
670
|
|
|
556
671
|
// ---- next ----
|
|
557
672
|
|
|
673
|
+
const TREE_RE = /^(?:[0-9a-f]{40}|[0-9a-f]{64})$/;
|
|
674
|
+
|
|
558
675
|
// 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.
|
|
559
676
|
const GIT_WRITE_RE = /(?:^|[;&|(\n]\s*)git(?:\s+(?:-[Cc]\s+(?:"[^"]*"|'[^']*'|\S+)|--[\w-]+(?:=\S+)?))*\s+(?:add|commit)\b/;
|
|
560
677
|
|
|
@@ -604,6 +721,9 @@ export function next(state, handBack) {
|
|
|
604
721
|
// C3 .. C6
|
|
605
722
|
if (isObj(H.hookBlocked) && nonBlank(H.hookBlocked.message)) return stopWith(s, "hook-blocked", H.hookBlocked.message);
|
|
606
723
|
if (nonBlank(H.designQuestion)) return stopWith(s, "design-decision", H.designQuestion);
|
|
724
|
+
if (P === "gate" && Array.isArray(H.commands) && H.commands.some((c) => isObj(c) && c.timedOut === true)) {
|
|
725
|
+
return stopWith({ ...s, lastGate: { commands: cp(H.commands), gateLine: H.gateLine } }, "gate-timeout", typeof H.gateLine === "string" ? H.gateLine : "a gate command timed out");
|
|
726
|
+
}
|
|
607
727
|
if (nonBlank(H.blocked)) return stopWith(s, "seat-blocked", H.blocked);
|
|
608
728
|
const errs = validateHandBack(s, H);
|
|
609
729
|
if (errs.length > 0) return stopWith(s, "hand-back-invalid", errs.join("; "));
|
|
@@ -624,6 +744,7 @@ export function next(state, handBack) {
|
|
|
624
744
|
unverified: cp(H.unverified),
|
|
625
745
|
testFiles: cp(H.testFiles),
|
|
626
746
|
mutations: cp(H.mutations),
|
|
747
|
+
reviewerChecks: strArr(H.reviewerChecks) ? cp(H.reviewerChecks) : [],
|
|
627
748
|
};
|
|
628
749
|
const to = s.lane === "lean" || !s.testerAgent ? "coder" : "tester";
|
|
629
750
|
return advance(s, to, "ok", { material });
|
|
@@ -632,13 +753,41 @@ export function next(state, handBack) {
|
|
|
632
753
|
const acc = { ...s.acc, filesChanged: union(s.acc.filesChanged, H.files) };
|
|
633
754
|
if (Array.isArray(H.mutationResults) && s.lane === "tests-only") acc.mutations = cp(H.mutationResults);
|
|
634
755
|
if (s.lane === "tests-only") {
|
|
756
|
+
const seenT = new Set((s.notRun || []).map((n) => n.id));
|
|
757
|
+
const notRunT = [...(s.notRun || [])];
|
|
758
|
+
for (const m of Array.isArray(H.mutationResults) ? H.mutationResults : []) {
|
|
759
|
+
if (isObj(m) && m.notRun !== undefined && !seenT.has(m.id)) {
|
|
760
|
+
seenT.add(m.id);
|
|
761
|
+
notRunT.push({ id: m.id, reason: m.notRun });
|
|
762
|
+
}
|
|
763
|
+
}
|
|
635
764
|
return advance(s, "reviewer", "ok", {
|
|
636
765
|
acc,
|
|
766
|
+
notRun: notRunT,
|
|
637
767
|
fixFor: null,
|
|
638
768
|
counters: { ...s.counters, coderRounds: s.counters.coderRounds + 1 },
|
|
639
769
|
});
|
|
640
770
|
}
|
|
641
771
|
const claim = H.tests_red_by_design;
|
|
772
|
+
if (Array.isArray(claim) && claim.length === 0) {
|
|
773
|
+
// Pin-only card: the new tests pass at baseline. Go on only when every listed mutation was run and failed.
|
|
774
|
+
const listed = effectiveMutations(s).map((m) => m.id);
|
|
775
|
+
const results = Array.isArray(H.mutationResults) ? H.mutationResults.filter(isObj) : [];
|
|
776
|
+
const bad = listed.filter((id) => {
|
|
777
|
+
const cov = results.filter((m) => coversId(m.id, id));
|
|
778
|
+
return cov.length === 0 || cov.some((m) => m.failed !== true);
|
|
779
|
+
});
|
|
780
|
+
if (listed.length > 0 && bad.length === 0) {
|
|
781
|
+
return advance(s, "coder", "ok", { acc, redTests: [] });
|
|
782
|
+
}
|
|
783
|
+
return stopWith(
|
|
784
|
+
s,
|
|
785
|
+
"tester-red-claim-missing",
|
|
786
|
+
listed.length === 0
|
|
787
|
+
? "the tester returned an empty tests_red_by_design claim and no mutation is listed to show the pins bite"
|
|
788
|
+
: `the tester returned an empty tests_red_by_design claim but mutation ${bad.join(", ")} was not run or survived`,
|
|
789
|
+
);
|
|
790
|
+
}
|
|
642
791
|
if (!(Array.isArray(claim) && claim.length > 0 && claim.every(nonBlank))) {
|
|
643
792
|
return stopWith(s, "tester-red-claim-missing", "the tester returned no tests_red_by_design claim");
|
|
644
793
|
}
|
|
@@ -650,8 +799,17 @@ export function next(state, handBack) {
|
|
|
650
799
|
filesChanged: union(s.acc.filesChanged, H.filesChanged),
|
|
651
800
|
testsEdited: [...s.acc.testsEdited, ...H.testsEdited.map((t) => ({ round, file: t.file, reason: t.reason }))],
|
|
652
801
|
};
|
|
802
|
+
const seen = new Set((s.notRun || []).map((n) => n.id));
|
|
803
|
+
const notRun = [...(s.notRun || [])];
|
|
804
|
+
for (const m of Array.isArray(H.mutationResults) ? H.mutationResults : []) {
|
|
805
|
+
if (isObj(m) && m.notRun !== undefined && !seen.has(m.id)) {
|
|
806
|
+
seen.add(m.id);
|
|
807
|
+
notRun.push({ id: m.id, reason: m.notRun });
|
|
808
|
+
}
|
|
809
|
+
}
|
|
653
810
|
return advance(s, "reviewer", "ok", {
|
|
654
811
|
acc,
|
|
812
|
+
notRun,
|
|
655
813
|
fixFor: null,
|
|
656
814
|
counters: { ...s.counters, coderRounds: s.counters.coderRounds + 1 },
|
|
657
815
|
});
|
|
@@ -706,7 +864,7 @@ export function next(state, handBack) {
|
|
|
706
864
|
const waited = { ...s.acc, lockWaitMs: (s.acc.lockWaitMs || 0) + (Number.isInteger(H.lockWaitMs) ? H.lockWaitMs : 0) };
|
|
707
865
|
s.acc = waited;
|
|
708
866
|
if (H.commands.every((c) => c.exitCode === 0)) {
|
|
709
|
-
return advance(s, "commit", "ok", { lastGate, acc: { ...waited, gateLine: H.gateLine } });
|
|
867
|
+
return advance(s, "commit", "ok", { lastGate, gateTree: H.tree, acc: { ...waited, gateLine: H.gateLine } });
|
|
710
868
|
}
|
|
711
869
|
const redGates = s.counters.redGates + 1;
|
|
712
870
|
const counters = { ...s.counters, redGates };
|
|
@@ -715,6 +873,9 @@ export function next(state, handBack) {
|
|
|
715
873
|
}
|
|
716
874
|
default: {
|
|
717
875
|
// commit
|
|
876
|
+
if (H.tree !== s.gateTree) {
|
|
877
|
+
return stopWith(s, "commit-tree-mismatch", `commit ${H.sha} tree ${H.tree} differs from the gated tree ${s.gateTree}`);
|
|
878
|
+
}
|
|
718
879
|
const state = {
|
|
719
880
|
...s,
|
|
720
881
|
pending: null,
|
|
@@ -737,6 +898,9 @@ export function briefPrompt(brief, { repoRoot, worktreePath } = {}) {
|
|
|
737
898
|
out.push(`SEAT: ${b.seat} (round ${b.round}, lane ${b.lane}, model row ${b.row})`);
|
|
738
899
|
out.push(`CARD ${b.card.id}${b.card.title ? ": " + b.card.title : ""}`);
|
|
739
900
|
out.push("GOAL:\n" + b.card.goal);
|
|
901
|
+
if (b.answers && b.answers.length > 0) {
|
|
902
|
+
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}`)));
|
|
903
|
+
}
|
|
740
904
|
out.push("WORKTREE: " + worktreePath);
|
|
741
905
|
if (b.seat === "setup") {
|
|
742
906
|
out.push(
|
|
@@ -747,14 +911,23 @@ export function briefPrompt(brief, { repoRoot, worktreePath } = {}) {
|
|
|
747
911
|
: "There is no install command; skip the install and report install as null."),
|
|
748
912
|
);
|
|
749
913
|
}
|
|
750
|
-
if (b.researchNote) out.push("RESEARCH NOTE: " + b.researchNote);
|
|
914
|
+
if (b.researchNote) out.push("RESEARCH NOTE:" + (b.researchNote.includes("\n") ? "\n" : " ") + b.researchNote);
|
|
751
915
|
if (b.lessons) out.push("LESSONS BLOCK verbatim:\n" + b.lessons);
|
|
752
916
|
if (b.facts.length > 0) out.push("FACTS (with sources):\n" + bullets(b.facts.map((f) => `${f.text} [${f.source}]`)));
|
|
753
917
|
if (b.unverified.length > 0) out.push("UNVERIFIED:\n" + bullets(b.unverified));
|
|
754
918
|
if (b.testFiles.length > 0) out.push("TEST FILES:\n" + bullets(b.testFiles));
|
|
755
919
|
if (b.mutations.length > 0) {
|
|
756
920
|
out.push("MUTATIONS:\n" + bullets(b.mutations.map((m) => `${m.id} (${m.case}): ${m.mutation}; test that must fail: ${m.test}`)));
|
|
921
|
+
if ((b.rules || []).some((r) => r.id === "mutation-ids-verbatim")) {
|
|
922
|
+
out.push("MUTATION IDS (report each verbatim in mutationResults): " + b.mutations.map((m) => m.id).join(", "));
|
|
923
|
+
}
|
|
924
|
+
}
|
|
925
|
+
if (b.notRun && b.notRun.length > 0) {
|
|
926
|
+
out.push(
|
|
927
|
+
"NOT RUN BY THE CODER (report each with failed true or false):\n" + bullets(b.notRun.map((n) => `${n.id}: ${n.reason}`)),
|
|
928
|
+
);
|
|
757
929
|
}
|
|
930
|
+
if (b.reviewerChecks && b.reviewerChecks.length > 0) out.push("REVIEWER CHECKS:\n" + bullets(b.reviewerChecks));
|
|
758
931
|
if (b.redTests.length > 0) out.push("TESTS RED BY DESIGN:\n" + bullets(b.redTests));
|
|
759
932
|
if (b.writeTestsFirst) out.push("Write the test first, in the file that covers the module, then the change.");
|
|
760
933
|
if (b.findings.length > 0) {
|
|
@@ -772,6 +945,7 @@ export function briefPrompt(brief, { repoRoot, worktreePath } = {}) {
|
|
|
772
945
|
);
|
|
773
946
|
}
|
|
774
947
|
if (b.gateCommand) out.push("GATE COMMAND: " + b.gateCommand);
|
|
948
|
+
if (b.gateTree) out.push("GATED TREE: " + b.gateTree);
|
|
775
949
|
out.push("RULES:\n" + bullets(b.rules.map((r) => r.text)));
|
|
776
950
|
out.push("HAND-BACK: return JSON matching this schema:\n" + JSON.stringify(b.handBack, null, 2));
|
|
777
951
|
return out.join("\n\n") + "\n";
|
|
@@ -243,6 +243,15 @@ export function priceCodexUsage(codexUsage, model = CODEX_DEFAULT_MODEL) {
|
|
|
243
243
|
|
|
244
244
|
const CODEX_RELAY = "doug-flow:adversary";
|
|
245
245
|
|
|
246
|
+
// The report a cost line may use for `runId`: a report carrying a runId attaches only on an exact match; one
|
|
247
|
+
// carrying none attaches only when the caller named the file (`named`). Anything else is null.
|
|
248
|
+
export function reportForRun(report, runId, { named = false } = {}) {
|
|
249
|
+
if (!report || typeof report !== "object") return null;
|
|
250
|
+
const id = typeof report.runId === "string" && report.runId !== "" ? report.runId : null;
|
|
251
|
+
if (id === null) return named ? report : null;
|
|
252
|
+
return id === runId ? report : null;
|
|
253
|
+
}
|
|
254
|
+
|
|
246
255
|
// Sum and price every agent of one run. Throws when the run cannot be found; an agent without a transcript or on
|
|
247
256
|
// an unpriced model is reported in `notes`, never estimated.
|
|
248
257
|
// `report` (card adversary-usage-in-report): a parsed workflow report, or null/omitted. When given and it carries
|
|
@@ -1003,6 +1003,28 @@ function followOwnWrites(m, entry, written, confirmedIso, sigBefore) {
|
|
|
1003
1003
|
entry.sig = sigAfter;
|
|
1004
1004
|
}
|
|
1005
1005
|
|
|
1006
|
+
// What the stale window hides (card recall-window-report): over live rows (not superseded, not stale), the
|
|
1007
|
+
// count outside the window by exactly ageDaysOf's rule, the live total, and the oldest hidden age in whole days.
|
|
1008
|
+
export function windowHidden(m, { staleDays = 30, now = new Date() } = {}) {
|
|
1009
|
+
const nowMs = (now instanceof Date ? now : new Date(now)).getTime();
|
|
1010
|
+
const rows = m.prepare("SELECT created, confirmed FROM lessons WHERE superseded_by IS NULL AND stale IS NULL").all();
|
|
1011
|
+
let count = 0;
|
|
1012
|
+
let oldest = -Infinity;
|
|
1013
|
+
for (const r of rows) {
|
|
1014
|
+
const age = ageDaysOf(r.confirmed || r.created, nowMs);
|
|
1015
|
+
if (age <= staleDays) continue;
|
|
1016
|
+
count++;
|
|
1017
|
+
if (age > oldest) oldest = age;
|
|
1018
|
+
}
|
|
1019
|
+
return { count, total: rows.length, oldestDays: count ? Math.floor(oldest) : null };
|
|
1020
|
+
}
|
|
1021
|
+
|
|
1022
|
+
// The one line that says so; null when the window hid nothing.
|
|
1023
|
+
export function windowNote(hidden, staleDays = 30) {
|
|
1024
|
+
if (!hidden || !hidden.count) return null;
|
|
1025
|
+
return `window: hid ${hidden.count} of ${hidden.total} live lessons older than ${staleDays} days (oldest ${hidden.oldestDays} days)`;
|
|
1026
|
+
}
|
|
1027
|
+
|
|
1006
1028
|
// --- Dense-ranked recall -----------------------------------------------------------------------------------
|
|
1007
1029
|
// With a provider and vectors stored under its model/dims, every vectored live in-window lesson is ranked by
|
|
1008
1030
|
// cosine similarity x recency weight x scope boost (memory-benchmark section 9: cosine x recency beat the old
|
|
@@ -1186,7 +1208,8 @@ export async function recallLessons(m, query, opts = {}) {
|
|
|
1186
1208
|
}
|
|
1187
1209
|
if (rows && written.length) followOwnWrites(m, rows, written, nowIsoStr, sigBefore);
|
|
1188
1210
|
|
|
1189
|
-
|
|
1211
|
+
const hidden = windowHidden(m, { staleDays, now: nowDate });
|
|
1212
|
+
return { mode, reason: mode === "hybrid" ? null : reason, provider: providerName, checked, degraded, ...coverage, hidden, lessons: result };
|
|
1190
1213
|
}
|
|
1191
1214
|
|
|
1192
1215
|
// Renders recalled lessons as one line per lesson, in rank order. Each lesson's own text gets a fixed budget
|