@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
|
@@ -14,7 +14,10 @@ export const STOP_REASONS = [
|
|
|
14
14
|
"setup-failed",
|
|
15
15
|
"tester-red-claim-missing",
|
|
16
16
|
"reviewer-blocker-open",
|
|
17
|
+
"reviewer-major-open",
|
|
17
18
|
"gate-red-twice",
|
|
19
|
+
"gate-timeout",
|
|
20
|
+
"commit-tree-mismatch",
|
|
18
21
|
];
|
|
19
22
|
// The Models-table row each seat runs on. setup, gate and commit have no sourced row; cheap is a choice.
|
|
20
23
|
export const SEAT_ROWS = {
|
|
@@ -43,10 +46,18 @@ export const BRIEF_RULES = {
|
|
|
43
46
|
text: "If a hook blocks a step, do not work around it: report it in hookBlocked with the hook's message.",
|
|
44
47
|
seats: ALL,
|
|
45
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
|
+
},
|
|
46
53
|
"stay-in-goal": {
|
|
47
54
|
text: "Do only what the goal asks. A defect found on the way goes in leftOut, not into the change.",
|
|
48
55
|
seats: ["tester", "coder", "reviewer"],
|
|
49
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
|
+
},
|
|
50
61
|
"lessons-verbatim": {
|
|
51
62
|
text: "Return the memory.mjs brief-lessons block for this card verbatim in lessons; never paraphrase it.",
|
|
52
63
|
seats: ["lead"],
|
|
@@ -56,11 +67,20 @@ export const BRIEF_RULES = {
|
|
|
56
67
|
seats: ["lead"],
|
|
57
68
|
},
|
|
58
69
|
"mutation-per-case": {
|
|
59
|
-
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.",
|
|
60
80
|
seats: ["lead"],
|
|
61
81
|
},
|
|
62
82
|
"red-claim": {
|
|
63
|
-
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.",
|
|
64
84
|
seats: ["tester"],
|
|
65
85
|
when: "full",
|
|
66
86
|
},
|
|
@@ -86,6 +106,38 @@ export const BRIEF_RULES = {
|
|
|
86
106
|
text: "Commit without Co-Authored-By or Claude-Session trailers.",
|
|
87
107
|
seats: ["commit"],
|
|
88
108
|
},
|
|
109
|
+
"commit-foreground": {
|
|
110
|
+
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.",
|
|
111
|
+
seats: ["commit"],
|
|
112
|
+
},
|
|
113
|
+
"commit-no-background": {
|
|
114
|
+
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.",
|
|
115
|
+
seats: ["commit"],
|
|
116
|
+
},
|
|
117
|
+
"commit-sha-from-git-log": {
|
|
118
|
+
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.",
|
|
119
|
+
seats: ["commit"],
|
|
120
|
+
},
|
|
121
|
+
"gate-no-git-writes": {
|
|
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.",
|
|
123
|
+
seats: ["gate"],
|
|
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
|
+
},
|
|
89
141
|
"leave-plan-anchor-out": {
|
|
90
142
|
text: "Leave .doug/plan.json and .doug/anchor.md out of the commit unless the card is about them.",
|
|
91
143
|
seats: ["commit"],
|
|
@@ -96,11 +148,13 @@ export const BRIEF_RULES = {
|
|
|
96
148
|
|
|
97
149
|
const S = { type: "string" };
|
|
98
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})$" };
|
|
99
153
|
const INT_OR_NULL = { type: ["integer", "null"] };
|
|
100
154
|
const STRS = { type: "array", items: S };
|
|
101
155
|
const CMD = {
|
|
102
156
|
type: "object",
|
|
103
|
-
properties: { command: S, exitCode: INT_OR_NULL },
|
|
157
|
+
properties: { command: S, exitCode: INT_OR_NULL, timedOut: { type: "boolean" } },
|
|
104
158
|
required: ["command", "exitCode"],
|
|
105
159
|
};
|
|
106
160
|
const MUT_RESULT = {
|
|
@@ -108,6 +162,11 @@ const MUT_RESULT = {
|
|
|
108
162
|
properties: { id: S, failed: { type: "boolean" }, test: S, assertion: S, own: { type: "boolean" } },
|
|
109
163
|
required: ["id", "failed", "test"],
|
|
110
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
|
+
};
|
|
111
170
|
const ENVELOPE = {
|
|
112
171
|
seat: { type: "string", enum: SEATS },
|
|
113
172
|
hookBlocked: { type: "object", properties: { message: NES }, required: ["message"] },
|
|
@@ -139,11 +198,12 @@ export const HAND_BACK_SCHEMAS = {
|
|
|
139
198
|
},
|
|
140
199
|
unverified: STRS,
|
|
141
200
|
testFiles: STRS,
|
|
201
|
+
reviewerChecks: STRS,
|
|
142
202
|
mutations: {
|
|
143
203
|
type: "array",
|
|
144
204
|
items: {
|
|
145
205
|
type: "object",
|
|
146
|
-
properties: { id:
|
|
206
|
+
properties: { id: { type: "string", minLength: 1, pattern: MUTATION_ID_PATTERN }, case: S, mutation: S, test: S },
|
|
147
207
|
required: ["id", "case", "mutation", "test"],
|
|
148
208
|
},
|
|
149
209
|
},
|
|
@@ -168,7 +228,7 @@ export const HAND_BACK_SCHEMAS = {
|
|
|
168
228
|
type: "array",
|
|
169
229
|
items: { type: "object", properties: { file: S, reason: NES }, required: ["file", "reason"] },
|
|
170
230
|
},
|
|
171
|
-
mutationResults: { type: "array", items:
|
|
231
|
+
mutationResults: { type: "array", items: CODER_MUT_RESULT },
|
|
172
232
|
},
|
|
173
233
|
["filesChanged", "commandsRun", "testsEdited"],
|
|
174
234
|
),
|
|
@@ -197,12 +257,25 @@ export const HAND_BACK_SCHEMAS = {
|
|
|
197
257
|
),
|
|
198
258
|
gate: schema(
|
|
199
259
|
"gate",
|
|
200
|
-
{ commands: { type: "array", items: CMD, minItems: 1 }, gateLine: NES, lockWaitMs: INT_OR_NULL },
|
|
201
|
-
["commands", "gateLine"],
|
|
260
|
+
{ commands: { type: "array", items: CMD, minItems: 1 }, gateLine: NES, lockWaitMs: INT_OR_NULL, tree: TREE },
|
|
261
|
+
["commands", "gateLine", "tree"],
|
|
202
262
|
),
|
|
203
|
-
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"]),
|
|
204
264
|
};
|
|
205
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
|
+
|
|
206
279
|
// ---- small helpers ----
|
|
207
280
|
|
|
208
281
|
const cp = (x) => JSON.parse(JSON.stringify(x));
|
|
@@ -226,14 +299,25 @@ function checkCmds(list, path, errs, needOne) {
|
|
|
226
299
|
});
|
|
227
300
|
}
|
|
228
301
|
|
|
229
|
-
|
|
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) {
|
|
230
309
|
if (!Array.isArray(list)) {
|
|
231
310
|
errs.push(path + " must be an array");
|
|
232
311
|
return false;
|
|
233
312
|
}
|
|
234
313
|
let good = true;
|
|
235
314
|
list.forEach((m, i) => {
|
|
236
|
-
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)) {
|
|
237
321
|
errs.push(`${path}[${i}] needs id, failed (boolean) and test`);
|
|
238
322
|
good = false;
|
|
239
323
|
}
|
|
@@ -250,6 +334,21 @@ export function laneFor(card) {
|
|
|
250
334
|
return "full";
|
|
251
335
|
}
|
|
252
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
|
+
|
|
253
352
|
export function initialState(card, options = {}) {
|
|
254
353
|
if (!isObj(card) || !nonBlank(card.id) || !isStr(card.goal)) {
|
|
255
354
|
throw new TypeError("card needs an id and a goal string");
|
|
@@ -257,6 +356,7 @@ export function initialState(card, options = {}) {
|
|
|
257
356
|
const o = isObj(options) ? options : {};
|
|
258
357
|
return {
|
|
259
358
|
version: 1,
|
|
359
|
+
answers: checkAnswers(o.answers),
|
|
260
360
|
card: {
|
|
261
361
|
id: card.id,
|
|
262
362
|
title: card.title ?? null,
|
|
@@ -273,13 +373,15 @@ export function initialState(card, options = {}) {
|
|
|
273
373
|
base: o.base ?? "main",
|
|
274
374
|
worktree: null,
|
|
275
375
|
researchNote: null,
|
|
276
|
-
material: { lessons: "", facts: [], unverified: [], testFiles: [], mutations: [] },
|
|
376
|
+
material: { lessons: "", facts: [], unverified: [], testFiles: [], mutations: [], reviewerChecks: [] },
|
|
377
|
+
notRun: [],
|
|
277
378
|
redTests: [],
|
|
278
379
|
counters: { coderRounds: 0, fixPasses: 0, redGates: 0 },
|
|
279
380
|
seatRuns: {},
|
|
280
381
|
fixFor: null,
|
|
281
382
|
lastFindings: [],
|
|
282
383
|
lastGate: null,
|
|
384
|
+
gateTree: null,
|
|
283
385
|
pending: "setup",
|
|
284
386
|
outcome: null,
|
|
285
387
|
history: [],
|
|
@@ -329,21 +431,27 @@ function buildBrief(s) {
|
|
|
329
431
|
unverified: cp(s.material.unverified),
|
|
330
432
|
testFiles: cp(s.material.testFiles),
|
|
331
433
|
mutations: cp(effectiveMutations(s)),
|
|
434
|
+
reviewerChecks: seat === "reviewer" ? cp(s.material.reviewerChecks || []) : [],
|
|
435
|
+
notRun: seat === "reviewer" ? cp(s.notRun || []) : [],
|
|
332
436
|
rules: Object.entries(BRIEF_RULES)
|
|
333
437
|
.filter(([, r]) => ruleApplies(r, seat, s))
|
|
334
438
|
.map(([id, r]) => ({ id, text: r.text })),
|
|
335
439
|
findings: fixing && s.fixFor === "review" ? cp(s.lastFindings) : [],
|
|
336
440
|
gate: fixing && s.fixFor === "gate" ? cp(s.lastGate) : null,
|
|
337
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 })),
|
|
338
445
|
writeTestsFirst: seat === "coder" && s.counters.coderRounds === 0 && (s.lane === "lean" || !s.testerAgent),
|
|
339
446
|
researchNote: s.researchNote,
|
|
340
|
-
handBack: cp(
|
|
447
|
+
handBack: cp(handBackSchema(seat, s.lane)),
|
|
341
448
|
};
|
|
342
449
|
if (seat === "setup") {
|
|
343
450
|
brief.install = s.install;
|
|
344
451
|
brief.base = s.base;
|
|
345
452
|
}
|
|
346
453
|
if (seat === "gate" || seat === "reviewer") brief.gateCommand = s.gateCommand;
|
|
454
|
+
if (seat === "commit") brief.gateTree = s.gateTree;
|
|
347
455
|
return brief;
|
|
348
456
|
}
|
|
349
457
|
|
|
@@ -369,6 +477,7 @@ function buildReport(s) {
|
|
|
369
477
|
testsEdited: cp(s.acc.testsEdited),
|
|
370
478
|
filesChanged: cp(s.acc.filesChanged),
|
|
371
479
|
followUps: cp(s.acc.followUps),
|
|
480
|
+
researchNote: s.researchNote ?? null,
|
|
372
481
|
cost: null,
|
|
373
482
|
};
|
|
374
483
|
}
|
|
@@ -413,8 +522,8 @@ function validate(state, hb) {
|
|
|
413
522
|
const muts = effectiveMutations(state);
|
|
414
523
|
const needCover = (list, path, ids) => {
|
|
415
524
|
if (!Array.isArray(list)) return;
|
|
416
|
-
const have =
|
|
417
|
-
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}`);
|
|
418
527
|
};
|
|
419
528
|
const first = state.counters.coderRounds === 0;
|
|
420
529
|
|
|
@@ -429,7 +538,7 @@ function validate(state, hb) {
|
|
|
429
538
|
break;
|
|
430
539
|
}
|
|
431
540
|
case "research":
|
|
432
|
-
if (!
|
|
541
|
+
if (!nonBlank(hb.note)) errs.push("note must be the researcher's cited note text or a path, not blank");
|
|
433
542
|
break;
|
|
434
543
|
case "lead": {
|
|
435
544
|
if (!isStr(hb.lessons)) errs.push("lessons must be a string");
|
|
@@ -441,6 +550,7 @@ function validate(state, hb) {
|
|
|
441
550
|
}
|
|
442
551
|
if (!strArr(hb.unverified)) errs.push("unverified must be an array of strings");
|
|
443
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");
|
|
444
554
|
if (!Array.isArray(hb.mutations)) errs.push("mutations must be an array");
|
|
445
555
|
else {
|
|
446
556
|
const ids = new Set();
|
|
@@ -448,6 +558,7 @@ function validate(state, hb) {
|
|
|
448
558
|
if (!isObj(m) || !nonBlank(m.id) || !isStr(m.case) || !isStr(m.mutation) || !isStr(m.test)) {
|
|
449
559
|
errs.push(`mutations[${i}] needs id, case, mutation and test`);
|
|
450
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, ...)`);
|
|
451
562
|
else ids.add(m.id);
|
|
452
563
|
});
|
|
453
564
|
}
|
|
@@ -461,7 +572,14 @@ function validate(state, hb) {
|
|
|
461
572
|
errs.push("tests_red_by_design must be an array of strings");
|
|
462
573
|
}
|
|
463
574
|
let shaped = true;
|
|
464
|
-
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
|
+
}
|
|
465
583
|
if (state.lane === "tests-only" && first && muts.length > 0) {
|
|
466
584
|
if (hb.mutationResults === undefined) errs.push("mutationResults is required on the first run");
|
|
467
585
|
else if (shaped) needCover(hb.mutationResults, "mutationResults", muts.map((m) => m.id));
|
|
@@ -478,7 +596,14 @@ function validate(state, hb) {
|
|
|
478
596
|
});
|
|
479
597
|
}
|
|
480
598
|
let shaped = true;
|
|
481
|
-
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
|
+
}
|
|
482
607
|
if (first && muts.length > 0) {
|
|
483
608
|
if (hb.mutationResults === undefined) errs.push("mutationResults is required on the first round");
|
|
484
609
|
else if (shaped) needCover(hb.mutationResults, "mutationResults", muts.map((m) => m.id));
|
|
@@ -505,18 +630,27 @@ function validate(state, hb) {
|
|
|
505
630
|
}
|
|
506
631
|
if (hb.verdict === "pass" && blocker) errs.push("verdict pass contradicts an evidenced blocker");
|
|
507
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
|
+
}
|
|
508
638
|
if (checkMutResults(hb.mutationResults, "mutationResults", errs)) {
|
|
509
639
|
const listed = muts.map((m) => m.id);
|
|
510
640
|
needCover(hb.mutationResults, "mutationResults", listed);
|
|
511
641
|
for (const m of hb.mutationResults) {
|
|
512
|
-
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`);
|
|
513
643
|
}
|
|
514
644
|
}
|
|
515
645
|
break;
|
|
516
646
|
}
|
|
517
647
|
case "gate": {
|
|
518
648
|
checkCmds(hb.commands, "commands", errs, true);
|
|
649
|
+
if (Array.isArray(hb.commands) && hb.commands.some((c) => c && isStr(c.command) && GIT_WRITE_RE.test(c.command))) {
|
|
650
|
+
errs.push("gate seat must not run git add or git commit");
|
|
651
|
+
}
|
|
519
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");
|
|
520
654
|
if (hb.lockWaitMs !== undefined && hb.lockWaitMs !== null && !Number.isInteger(hb.lockWaitMs)) {
|
|
521
655
|
errs.push("lockWaitMs must be an integer or null");
|
|
522
656
|
}
|
|
@@ -525,6 +659,7 @@ function validate(state, hb) {
|
|
|
525
659
|
case "commit": {
|
|
526
660
|
if (!(isStr(hb.sha) && /^[0-9a-f]{7,40}$/.test(hb.sha))) errs.push("sha must be 7 to 40 lowercase hex characters");
|
|
527
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");
|
|
528
663
|
break;
|
|
529
664
|
}
|
|
530
665
|
default:
|
|
@@ -535,6 +670,11 @@ function validate(state, hb) {
|
|
|
535
670
|
|
|
536
671
|
// ---- next ----
|
|
537
672
|
|
|
673
|
+
const TREE_RE = /^(?:[0-9a-f]{40}|[0-9a-f]{64})$/;
|
|
674
|
+
|
|
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.
|
|
676
|
+
const GIT_WRITE_RE = /(?:^|[;&|(\n]\s*)git(?:\s+(?:-[Cc]\s+(?:"[^"]*"|'[^']*'|\S+)|--[\w-]+(?:=\S+)?))*\s+(?:add|commit)\b/;
|
|
677
|
+
|
|
538
678
|
function stopWith(s, reason, detail) {
|
|
539
679
|
const seat = s.pending;
|
|
540
680
|
const state = {
|
|
@@ -581,6 +721,9 @@ export function next(state, handBack) {
|
|
|
581
721
|
// C3 .. C6
|
|
582
722
|
if (isObj(H.hookBlocked) && nonBlank(H.hookBlocked.message)) return stopWith(s, "hook-blocked", H.hookBlocked.message);
|
|
583
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
|
+
}
|
|
584
727
|
if (nonBlank(H.blocked)) return stopWith(s, "seat-blocked", H.blocked);
|
|
585
728
|
const errs = validateHandBack(s, H);
|
|
586
729
|
if (errs.length > 0) return stopWith(s, "hand-back-invalid", errs.join("; "));
|
|
@@ -601,6 +744,7 @@ export function next(state, handBack) {
|
|
|
601
744
|
unverified: cp(H.unverified),
|
|
602
745
|
testFiles: cp(H.testFiles),
|
|
603
746
|
mutations: cp(H.mutations),
|
|
747
|
+
reviewerChecks: strArr(H.reviewerChecks) ? cp(H.reviewerChecks) : [],
|
|
604
748
|
};
|
|
605
749
|
const to = s.lane === "lean" || !s.testerAgent ? "coder" : "tester";
|
|
606
750
|
return advance(s, to, "ok", { material });
|
|
@@ -609,13 +753,41 @@ export function next(state, handBack) {
|
|
|
609
753
|
const acc = { ...s.acc, filesChanged: union(s.acc.filesChanged, H.files) };
|
|
610
754
|
if (Array.isArray(H.mutationResults) && s.lane === "tests-only") acc.mutations = cp(H.mutationResults);
|
|
611
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
|
+
}
|
|
612
764
|
return advance(s, "reviewer", "ok", {
|
|
613
765
|
acc,
|
|
766
|
+
notRun: notRunT,
|
|
614
767
|
fixFor: null,
|
|
615
768
|
counters: { ...s.counters, coderRounds: s.counters.coderRounds + 1 },
|
|
616
769
|
});
|
|
617
770
|
}
|
|
618
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
|
+
}
|
|
619
791
|
if (!(Array.isArray(claim) && claim.length > 0 && claim.every(nonBlank))) {
|
|
620
792
|
return stopWith(s, "tester-red-claim-missing", "the tester returned no tests_red_by_design claim");
|
|
621
793
|
}
|
|
@@ -627,8 +799,17 @@ export function next(state, handBack) {
|
|
|
627
799
|
filesChanged: union(s.acc.filesChanged, H.filesChanged),
|
|
628
800
|
testsEdited: [...s.acc.testsEdited, ...H.testsEdited.map((t) => ({ round, file: t.file, reason: t.reason }))],
|
|
629
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
|
+
}
|
|
630
810
|
return advance(s, "reviewer", "ok", {
|
|
631
811
|
acc,
|
|
812
|
+
notRun,
|
|
632
813
|
fixFor: null,
|
|
633
814
|
counters: { ...s.counters, coderRounds: s.counters.coderRounds + 1 },
|
|
634
815
|
});
|
|
@@ -654,12 +835,21 @@ export function next(state, handBack) {
|
|
|
654
835
|
mutations: cp(H.mutationResults),
|
|
655
836
|
};
|
|
656
837
|
const blockers = findings.filter((f) => f.severity === "blocker");
|
|
657
|
-
|
|
838
|
+
// An evidenced major gets the same one fix pass; a major without evidence and a minor stay advisory.
|
|
839
|
+
const majors = findings.filter((f) => f.severity === "major" && nonBlank(f.evidence));
|
|
840
|
+
if (blockers.length === 0 && majors.length === 0) return advance(s, "gate", "ok", { acc });
|
|
658
841
|
if (s.counters.fixPasses >= 1) {
|
|
842
|
+
if (blockers.length > 0) {
|
|
843
|
+
return stopWith(
|
|
844
|
+
{ ...s, acc },
|
|
845
|
+
"reviewer-blocker-open",
|
|
846
|
+
"blocker still open after one fix pass: " + blockers.map((f) => f.id).join(", "),
|
|
847
|
+
);
|
|
848
|
+
}
|
|
659
849
|
return stopWith(
|
|
660
850
|
{ ...s, acc },
|
|
661
|
-
"reviewer-
|
|
662
|
-
"
|
|
851
|
+
"reviewer-major-open",
|
|
852
|
+
"major with evidence still open after one fix pass: " + majors.map((f) => f.id).join(", "),
|
|
663
853
|
);
|
|
664
854
|
}
|
|
665
855
|
return advance(s, buildSeat(s), "blockers", {
|
|
@@ -674,7 +864,7 @@ export function next(state, handBack) {
|
|
|
674
864
|
const waited = { ...s.acc, lockWaitMs: (s.acc.lockWaitMs || 0) + (Number.isInteger(H.lockWaitMs) ? H.lockWaitMs : 0) };
|
|
675
865
|
s.acc = waited;
|
|
676
866
|
if (H.commands.every((c) => c.exitCode === 0)) {
|
|
677
|
-
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 } });
|
|
678
868
|
}
|
|
679
869
|
const redGates = s.counters.redGates + 1;
|
|
680
870
|
const counters = { ...s.counters, redGates };
|
|
@@ -683,6 +873,9 @@ export function next(state, handBack) {
|
|
|
683
873
|
}
|
|
684
874
|
default: {
|
|
685
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
|
+
}
|
|
686
879
|
const state = {
|
|
687
880
|
...s,
|
|
688
881
|
pending: null,
|
|
@@ -705,6 +898,9 @@ export function briefPrompt(brief, { repoRoot, worktreePath } = {}) {
|
|
|
705
898
|
out.push(`SEAT: ${b.seat} (round ${b.round}, lane ${b.lane}, model row ${b.row})`);
|
|
706
899
|
out.push(`CARD ${b.card.id}${b.card.title ? ": " + b.card.title : ""}`);
|
|
707
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
|
+
}
|
|
708
904
|
out.push("WORKTREE: " + worktreePath);
|
|
709
905
|
if (b.seat === "setup") {
|
|
710
906
|
out.push(
|
|
@@ -715,14 +911,23 @@ export function briefPrompt(brief, { repoRoot, worktreePath } = {}) {
|
|
|
715
911
|
: "There is no install command; skip the install and report install as null."),
|
|
716
912
|
);
|
|
717
913
|
}
|
|
718
|
-
if (b.researchNote) out.push("RESEARCH NOTE: " + b.researchNote);
|
|
914
|
+
if (b.researchNote) out.push("RESEARCH NOTE:" + (b.researchNote.includes("\n") ? "\n" : " ") + b.researchNote);
|
|
719
915
|
if (b.lessons) out.push("LESSONS BLOCK verbatim:\n" + b.lessons);
|
|
720
916
|
if (b.facts.length > 0) out.push("FACTS (with sources):\n" + bullets(b.facts.map((f) => `${f.text} [${f.source}]`)));
|
|
721
917
|
if (b.unverified.length > 0) out.push("UNVERIFIED:\n" + bullets(b.unverified));
|
|
722
918
|
if (b.testFiles.length > 0) out.push("TEST FILES:\n" + bullets(b.testFiles));
|
|
723
919
|
if (b.mutations.length > 0) {
|
|
724
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
|
+
);
|
|
725
929
|
}
|
|
930
|
+
if (b.reviewerChecks && b.reviewerChecks.length > 0) out.push("REVIEWER CHECKS:\n" + bullets(b.reviewerChecks));
|
|
726
931
|
if (b.redTests.length > 0) out.push("TESTS RED BY DESIGN:\n" + bullets(b.redTests));
|
|
727
932
|
if (b.writeTestsFirst) out.push("Write the test first, in the file that covers the module, then the change.");
|
|
728
933
|
if (b.findings.length > 0) {
|
|
@@ -740,6 +945,7 @@ export function briefPrompt(brief, { repoRoot, worktreePath } = {}) {
|
|
|
740
945
|
);
|
|
741
946
|
}
|
|
742
947
|
if (b.gateCommand) out.push("GATE COMMAND: " + b.gateCommand);
|
|
948
|
+
if (b.gateTree) out.push("GATED TREE: " + b.gateTree);
|
|
743
949
|
out.push("RULES:\n" + bullets(b.rules.map((r) => r.text)));
|
|
744
950
|
out.push("HAND-BACK: return JSON matching this schema:\n" + JSON.stringify(b.handBack, null, 2));
|
|
745
951
|
return out.join("\n\n") + "\n";
|
|
@@ -366,3 +366,28 @@ export function capsReached(caps, used) {
|
|
|
366
366
|
}
|
|
367
367
|
return out;
|
|
368
368
|
}
|
|
369
|
+
|
|
370
|
+
// The queen's short run label (card queen-run-name): the first two hyphen segments of the card id (the id itself
|
|
371
|
+
// with fewer), or the full id when another id in `others` (the card's own id excluded) shortens to the same form.
|
|
372
|
+
const shortForm = (id) => String(id).split("-").slice(0, 2).join("-");
|
|
373
|
+
export function queenLabel(id, others = []) {
|
|
374
|
+
const short = shortForm(id);
|
|
375
|
+
return others.some((o) => o !== id && shortForm(o) === short) ? String(id) : short;
|
|
376
|
+
}
|
|
377
|
+
|
|
378
|
+
// The queen script's source with only its meta literal renamed: name becomes "<label> card-queen" (hand) or
|
|
379
|
+
// "<label> flow-queen" (flow), description gets "<label>: " in front. Throws when either is missing from the meta.
|
|
380
|
+
export function renameQueenMeta(source, { kind, label }) {
|
|
381
|
+
const start = source.indexOf("export const meta = {");
|
|
382
|
+
if (start < 0) throw new Error("no meta literal in the queen script");
|
|
383
|
+
const e = source.indexOf("\n}", start);
|
|
384
|
+
const end = e < 0 ? source.length : e;
|
|
385
|
+
let meta = source.slice(start, end);
|
|
386
|
+
const nameRe = /^(\s*name: ')[^']*(',)/m;
|
|
387
|
+
const descRe = /^(\s*description: ')/m;
|
|
388
|
+
if (!nameRe.test(meta)) throw new Error("no meta name in the queen script");
|
|
389
|
+
if (!descRe.test(meta)) throw new Error("no meta description in the queen script");
|
|
390
|
+
const suffix = kind === "flow" ? "flow-queen" : "card-queen";
|
|
391
|
+
meta = meta.replace(nameRe, (_m, a, b) => `${a}${label} ${suffix}${b}`).replace(descRe, (_m, a) => `${a}${label}: `);
|
|
392
|
+
return source.slice(0, start) + meta + source.slice(end);
|
|
393
|
+
}
|
|
@@ -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
|
|
@@ -250,8 +250,9 @@ function commandsBlockText(text) {
|
|
|
250
250
|
// writes it - only ever calls writeOne/writeProposals (learn.mjs), which writes under .doug/.state/learn/. Every
|
|
251
251
|
// refusal returns { ok:false, reason } with nothing written; `edits` can be ANY value JSON.parse can produce
|
|
252
252
|
// (null, an array, a string, a number, or an object whose fields are the wrong shape) and this never throws -
|
|
253
|
-
// every field is type-checked before use (lesson 1ee7d426432aca82).
|
|
254
|
-
|
|
253
|
+
// every field is type-checked before use (lesson 1ee7d426432aca82). With `dryRun: true` every check still runs
|
|
254
|
+
// and the result is { ok:true, reason, diff } with nothing written (card reflect-offers-claude-md).
|
|
255
|
+
export function proposeClaudeMd({ dir, edits, now = new Date(), dryRun = false } = {}) {
|
|
255
256
|
const target = join(dir, CLAUDE_MD_RELPATH);
|
|
256
257
|
if (!existsSync(target)) return { ok: false, reason: `${CLAUDE_MD_RELPATH} does not exist under ${dir}` };
|
|
257
258
|
|
|
@@ -310,5 +311,6 @@ export function proposeClaudeMd({ dir, edits, now = new Date() } = {}) {
|
|
|
310
311
|
lines: { before, after },
|
|
311
312
|
};
|
|
312
313
|
const proposal = { id: "01", kind: "claude-md", target: CLAUDE_MD_RELPATH, reason, rationale, diff: diffResult.diff };
|
|
314
|
+
if (dryRun) return { ok: true, reason, diff: diffResult.diff };
|
|
313
315
|
return writeOne(proposal, { dir, now });
|
|
314
316
|
}
|