@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
|
@@ -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,12 +46,24 @@ 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
|
-
text: "
|
|
62
|
+
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.",
|
|
63
|
+
seats: ["lead"],
|
|
64
|
+
},
|
|
65
|
+
"lessons-fetch": {
|
|
66
|
+
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.",
|
|
53
67
|
seats: ["lead"],
|
|
54
68
|
},
|
|
55
69
|
"facts-cited": {
|
|
@@ -57,11 +71,20 @@ export const BRIEF_RULES = {
|
|
|
57
71
|
seats: ["lead"],
|
|
58
72
|
},
|
|
59
73
|
"mutation-per-case": {
|
|
60
|
-
text: "List one mutation per case the goal names, each with a unique id and the test that must fail.",
|
|
74
|
+
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.",
|
|
75
|
+
seats: ["lead"],
|
|
76
|
+
},
|
|
77
|
+
"mutation-ids-verbatim": {
|
|
78
|
+
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).",
|
|
79
|
+
seats: ["tester", "coder", "reviewer"],
|
|
80
|
+
when: "mutations",
|
|
81
|
+
},
|
|
82
|
+
"mutation-is-code-change": {
|
|
83
|
+
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
84
|
seats: ["lead"],
|
|
62
85
|
},
|
|
63
86
|
"red-claim": {
|
|
64
|
-
text: "Tests that are red by design are named in tests_red_by_design; without the claim the card stops.",
|
|
87
|
+
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
88
|
seats: ["tester"],
|
|
66
89
|
when: "full",
|
|
67
90
|
},
|
|
@@ -85,7 +108,7 @@ export const BRIEF_RULES = {
|
|
|
85
108
|
},
|
|
86
109
|
"no-trailers": {
|
|
87
110
|
text: "Commit without Co-Authored-By or Claude-Session trailers.",
|
|
88
|
-
seats: ["commit"],
|
|
111
|
+
seats: ["tester", "coder", "reviewer", "commit"],
|
|
89
112
|
},
|
|
90
113
|
"commit-foreground": {
|
|
91
114
|
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.",
|
|
@@ -103,6 +126,27 @@ export const BRIEF_RULES = {
|
|
|
103
126
|
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
127
|
seats: ["gate"],
|
|
105
128
|
},
|
|
129
|
+
"gate-reinstall-missing-deps": {
|
|
130
|
+
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.",
|
|
131
|
+
seats: ["gate"],
|
|
132
|
+
when: "install",
|
|
133
|
+
},
|
|
134
|
+
"gate-timeout-is-load": {
|
|
135
|
+
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.",
|
|
136
|
+
seats: ["gate"],
|
|
137
|
+
},
|
|
138
|
+
"commit-no-file-edits": {
|
|
139
|
+
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.",
|
|
140
|
+
seats: ["commit"],
|
|
141
|
+
},
|
|
142
|
+
"commit-reports-tree": {
|
|
143
|
+
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.",
|
|
144
|
+
seats: ["commit"],
|
|
145
|
+
},
|
|
146
|
+
"gate-reports-tree": {
|
|
147
|
+
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.",
|
|
148
|
+
seats: ["gate"],
|
|
149
|
+
},
|
|
106
150
|
"leave-plan-anchor-out": {
|
|
107
151
|
text: "Leave .doug/plan.json and .doug/anchor.md out of the commit unless the card is about them.",
|
|
108
152
|
seats: ["commit"],
|
|
@@ -113,11 +157,13 @@ export const BRIEF_RULES = {
|
|
|
113
157
|
|
|
114
158
|
const S = { type: "string" };
|
|
115
159
|
const NES = { type: "string", minLength: 1 };
|
|
160
|
+
const MUTATION_ID_PATTERN = "^M[1-9][0-9]*$";
|
|
161
|
+
const TREE = { type: "string", pattern: "^([0-9a-f]{40}|[0-9a-f]{64})$" };
|
|
116
162
|
const INT_OR_NULL = { type: ["integer", "null"] };
|
|
117
163
|
const STRS = { type: "array", items: S };
|
|
118
164
|
const CMD = {
|
|
119
165
|
type: "object",
|
|
120
|
-
properties: { command: S, exitCode: INT_OR_NULL },
|
|
166
|
+
properties: { command: S, exitCode: INT_OR_NULL, timedOut: { type: "boolean" } },
|
|
121
167
|
required: ["command", "exitCode"],
|
|
122
168
|
};
|
|
123
169
|
const MUT_RESULT = {
|
|
@@ -125,6 +171,11 @@ const MUT_RESULT = {
|
|
|
125
171
|
properties: { id: S, failed: { type: "boolean" }, test: S, assertion: S, own: { type: "boolean" } },
|
|
126
172
|
required: ["id", "failed", "test"],
|
|
127
173
|
};
|
|
174
|
+
const CODER_MUT_RESULT = {
|
|
175
|
+
type: "object",
|
|
176
|
+
properties: { id: S, failed: { type: "boolean" }, test: S, assertion: S, own: { type: "boolean" }, notRun: NES },
|
|
177
|
+
required: ["id"],
|
|
178
|
+
};
|
|
128
179
|
const ENVELOPE = {
|
|
129
180
|
seat: { type: "string", enum: SEATS },
|
|
130
181
|
hookBlocked: { type: "object", properties: { message: NES }, required: ["message"] },
|
|
@@ -156,11 +207,12 @@ export const HAND_BACK_SCHEMAS = {
|
|
|
156
207
|
},
|
|
157
208
|
unverified: STRS,
|
|
158
209
|
testFiles: STRS,
|
|
210
|
+
reviewerChecks: STRS,
|
|
159
211
|
mutations: {
|
|
160
212
|
type: "array",
|
|
161
213
|
items: {
|
|
162
214
|
type: "object",
|
|
163
|
-
properties: { id:
|
|
215
|
+
properties: { id: { type: "string", minLength: 1, pattern: MUTATION_ID_PATTERN }, case: S, mutation: S, test: S },
|
|
164
216
|
required: ["id", "case", "mutation", "test"],
|
|
165
217
|
},
|
|
166
218
|
},
|
|
@@ -185,7 +237,7 @@ export const HAND_BACK_SCHEMAS = {
|
|
|
185
237
|
type: "array",
|
|
186
238
|
items: { type: "object", properties: { file: S, reason: NES }, required: ["file", "reason"] },
|
|
187
239
|
},
|
|
188
|
-
mutationResults: { type: "array", items:
|
|
240
|
+
mutationResults: { type: "array", items: CODER_MUT_RESULT },
|
|
189
241
|
},
|
|
190
242
|
["filesChanged", "commandsRun", "testsEdited"],
|
|
191
243
|
),
|
|
@@ -214,12 +266,25 @@ export const HAND_BACK_SCHEMAS = {
|
|
|
214
266
|
),
|
|
215
267
|
gate: schema(
|
|
216
268
|
"gate",
|
|
217
|
-
{ commands: { type: "array", items: CMD, minItems: 1 }, gateLine: NES, lockWaitMs: INT_OR_NULL },
|
|
218
|
-
["commands", "gateLine"],
|
|
269
|
+
{ commands: { type: "array", items: CMD, minItems: 1 }, gateLine: NES, lockWaitMs: INT_OR_NULL, tree: TREE },
|
|
270
|
+
["commands", "gateLine", "tree"],
|
|
219
271
|
),
|
|
220
|
-
commit: schema("commit", { sha: { type: "string", pattern: "^[0-9a-f]{7,40}$" }, branch: S }, ["sha", "branch"]),
|
|
272
|
+
commit: schema("commit", { sha: { type: "string", pattern: "^[0-9a-f]{7,40}$" }, branch: S, tree: TREE }, ["sha", "branch", "tree"]),
|
|
221
273
|
};
|
|
222
274
|
|
|
275
|
+
// The schema a seat is handed on a lane. Only the tests-only tester may report notRun (validateHandBack's tester
|
|
276
|
+
// case), so only there are its mutationResults items CODER_MUT_RESULT; the plain table is the full-lane contract.
|
|
277
|
+
export function handBackSchema(seat, lane) {
|
|
278
|
+
if (seat === "tester" && lane === "tests-only") {
|
|
279
|
+
const base = HAND_BACK_SCHEMAS.tester;
|
|
280
|
+
return {
|
|
281
|
+
...base,
|
|
282
|
+
properties: { ...base.properties, mutationResults: { type: "array", items: CODER_MUT_RESULT } },
|
|
283
|
+
};
|
|
284
|
+
}
|
|
285
|
+
return HAND_BACK_SCHEMAS[seat];
|
|
286
|
+
}
|
|
287
|
+
|
|
223
288
|
// ---- small helpers ----
|
|
224
289
|
|
|
225
290
|
const cp = (x) => JSON.parse(JSON.stringify(x));
|
|
@@ -243,14 +308,25 @@ function checkCmds(list, path, errs, needOne) {
|
|
|
243
308
|
});
|
|
244
309
|
}
|
|
245
310
|
|
|
246
|
-
|
|
311
|
+
// A reported id covers listed id L when it is L or L plus one lowercase letter (a split such as M1a, M1b).
|
|
312
|
+
function coversId(reported, listed) {
|
|
313
|
+
if (reported === listed) return true;
|
|
314
|
+
return typeof reported === "string" && reported.length === listed.length + 1 && reported.startsWith(listed) && /[a-z]$/.test(reported);
|
|
315
|
+
}
|
|
316
|
+
|
|
317
|
+
function checkMutResults(list, path, errs, allowNotRun = false) {
|
|
247
318
|
if (!Array.isArray(list)) {
|
|
248
319
|
errs.push(path + " must be an array");
|
|
249
320
|
return false;
|
|
250
321
|
}
|
|
251
322
|
let good = true;
|
|
252
323
|
list.forEach((m, i) => {
|
|
253
|
-
if (
|
|
324
|
+
if (allowNotRun && isObj(m) && m.notRun !== undefined) {
|
|
325
|
+
if (!nonBlank(m.id) || !nonBlank(m.notRun)) {
|
|
326
|
+
errs.push(`${path}[${i}] notRun needs id and a non-empty reason string`);
|
|
327
|
+
good = false;
|
|
328
|
+
}
|
|
329
|
+
} else if (!isObj(m) || !nonBlank(m.id) || typeof m.failed !== "boolean" || !isStr(m.test)) {
|
|
254
330
|
errs.push(`${path}[${i}] needs id, failed (boolean) and test`);
|
|
255
331
|
good = false;
|
|
256
332
|
}
|
|
@@ -267,6 +343,21 @@ export function laneFor(card) {
|
|
|
267
343
|
return "full";
|
|
268
344
|
}
|
|
269
345
|
|
|
346
|
+
// options.answers: the user's answers to design questions, { seat, question, answer, round?, run? } each.
|
|
347
|
+
function checkAnswers(answers) {
|
|
348
|
+
if (answers === undefined || answers === null) return [];
|
|
349
|
+
const bad = (why) => new TypeError("options.answers " + why);
|
|
350
|
+
if (!Array.isArray(answers)) throw bad("must be an array");
|
|
351
|
+
return answers.map((a, i) => {
|
|
352
|
+
if (!isObj(a)) throw bad(`[${i}] must be an object`);
|
|
353
|
+
if (!SEATS.includes(a.seat)) throw bad(`[${i}].seat must be one of the seats`);
|
|
354
|
+
if (!isStr(a.question)) throw bad(`[${i}].question must be a string`);
|
|
355
|
+
if (!nonBlank(a.answer)) throw bad(`[${i}].answer must be a non-blank string`);
|
|
356
|
+
if (a.round !== undefined && !(Number.isInteger(a.round) && a.round > 0)) throw bad(`[${i}].round must be a positive integer`);
|
|
357
|
+
return { ...a };
|
|
358
|
+
});
|
|
359
|
+
}
|
|
360
|
+
|
|
270
361
|
export function initialState(card, options = {}) {
|
|
271
362
|
if (!isObj(card) || !nonBlank(card.id) || !isStr(card.goal)) {
|
|
272
363
|
throw new TypeError("card needs an id and a goal string");
|
|
@@ -274,6 +365,7 @@ export function initialState(card, options = {}) {
|
|
|
274
365
|
const o = isObj(options) ? options : {};
|
|
275
366
|
return {
|
|
276
367
|
version: 1,
|
|
368
|
+
answers: checkAnswers(o.answers),
|
|
277
369
|
card: {
|
|
278
370
|
id: card.id,
|
|
279
371
|
title: card.title ?? null,
|
|
@@ -290,13 +382,15 @@ export function initialState(card, options = {}) {
|
|
|
290
382
|
base: o.base ?? "main",
|
|
291
383
|
worktree: null,
|
|
292
384
|
researchNote: null,
|
|
293
|
-
material: { lessons: "", facts: [], unverified: [], testFiles: [], mutations: [] },
|
|
385
|
+
material: { lessons: "", facts: [], unverified: [], testFiles: [], mutations: [], reviewerChecks: [] },
|
|
386
|
+
notRun: [],
|
|
294
387
|
redTests: [],
|
|
295
388
|
counters: { coderRounds: 0, fixPasses: 0, redGates: 0 },
|
|
296
389
|
seatRuns: {},
|
|
297
390
|
fixFor: null,
|
|
298
391
|
lastFindings: [],
|
|
299
392
|
lastGate: null,
|
|
393
|
+
gateTree: null,
|
|
300
394
|
pending: "setup",
|
|
301
395
|
outcome: null,
|
|
302
396
|
history: [],
|
|
@@ -321,6 +415,7 @@ const effectiveMutations = (s) => (s.lane === "lean" ? [] : s.material.mutations
|
|
|
321
415
|
|
|
322
416
|
function ruleApplies(rule, seat, s) {
|
|
323
417
|
if (!rule.seats.includes(seat)) return false;
|
|
418
|
+
if (rule.when === "install") return s.install !== null && s.install !== undefined;
|
|
324
419
|
if (rule.when === "full") return s.lane === "full";
|
|
325
420
|
if (rule.when === "tests-only") return s.lane === "tests-only";
|
|
326
421
|
if (rule.when === "mutations") {
|
|
@@ -346,21 +441,33 @@ function buildBrief(s) {
|
|
|
346
441
|
unverified: cp(s.material.unverified),
|
|
347
442
|
testFiles: cp(s.material.testFiles),
|
|
348
443
|
mutations: cp(effectiveMutations(s)),
|
|
444
|
+
reviewerChecks: seat === "reviewer" ? cp(s.material.reviewerChecks || []) : [],
|
|
445
|
+
notRun: seat === "reviewer" ? cp(s.notRun || []) : [],
|
|
349
446
|
rules: Object.entries(BRIEF_RULES)
|
|
350
447
|
.filter(([, r]) => ruleApplies(r, seat, s))
|
|
351
448
|
.map(([id, r]) => ({ id, text: r.text })),
|
|
352
449
|
findings: fixing && s.fixFor === "review" ? cp(s.lastFindings) : [],
|
|
353
450
|
gate: fixing && s.fixFor === "gate" ? cp(s.lastGate) : null,
|
|
354
451
|
redTests: cp(s.redTests),
|
|
452
|
+
answers: (s.answers || [])
|
|
453
|
+
.filter(
|
|
454
|
+
(a) =>
|
|
455
|
+
(a.seat === seat && (a.round === undefined || a.round <= (s.seatRuns[seat] || 0) + 1)) ||
|
|
456
|
+
// A reviewer answer reaches the build seat once the reviewer round it targets has run.
|
|
457
|
+
(seat === buildSeat(s) && a.seat === "reviewer" && (a.round ?? 1) <= (s.seatRuns.reviewer || 0)),
|
|
458
|
+
)
|
|
459
|
+
.map((a) => ({ question: a.question, answer: a.answer })),
|
|
355
460
|
writeTestsFirst: seat === "coder" && s.counters.coderRounds === 0 && (s.lane === "lean" || !s.testerAgent),
|
|
356
461
|
researchNote: s.researchNote,
|
|
357
|
-
handBack: cp(
|
|
462
|
+
handBack: cp(handBackSchema(seat, s.lane)),
|
|
358
463
|
};
|
|
359
464
|
if (seat === "setup") {
|
|
360
465
|
brief.install = s.install;
|
|
361
466
|
brief.base = s.base;
|
|
362
467
|
}
|
|
468
|
+
if (seat === "gate" && s.install) brief.install = s.install;
|
|
363
469
|
if (seat === "gate" || seat === "reviewer") brief.gateCommand = s.gateCommand;
|
|
470
|
+
if (seat === "commit") brief.gateTree = s.gateTree;
|
|
364
471
|
return brief;
|
|
365
472
|
}
|
|
366
473
|
|
|
@@ -386,6 +493,7 @@ function buildReport(s) {
|
|
|
386
493
|
testsEdited: cp(s.acc.testsEdited),
|
|
387
494
|
filesChanged: cp(s.acc.filesChanged),
|
|
388
495
|
followUps: cp(s.acc.followUps),
|
|
496
|
+
researchNote: s.researchNote ?? null,
|
|
389
497
|
cost: null,
|
|
390
498
|
};
|
|
391
499
|
}
|
|
@@ -430,8 +538,8 @@ function validate(state, hb) {
|
|
|
430
538
|
const muts = effectiveMutations(state);
|
|
431
539
|
const needCover = (list, path, ids) => {
|
|
432
540
|
if (!Array.isArray(list)) return;
|
|
433
|
-
const have =
|
|
434
|
-
for (const id of ids) if (!have.
|
|
541
|
+
const have = list.filter(isObj).map((m) => m.id);
|
|
542
|
+
for (const id of ids) if (!have.some((h) => coversId(h, id))) errs.push(`${path} is missing listed mutation ${id}`);
|
|
435
543
|
};
|
|
436
544
|
const first = state.counters.coderRounds === 0;
|
|
437
545
|
|
|
@@ -446,7 +554,7 @@ function validate(state, hb) {
|
|
|
446
554
|
break;
|
|
447
555
|
}
|
|
448
556
|
case "research":
|
|
449
|
-
if (!
|
|
557
|
+
if (!nonBlank(hb.note)) errs.push("note must be the researcher's cited note text or a path, not blank");
|
|
450
558
|
break;
|
|
451
559
|
case "lead": {
|
|
452
560
|
if (!isStr(hb.lessons)) errs.push("lessons must be a string");
|
|
@@ -458,6 +566,7 @@ function validate(state, hb) {
|
|
|
458
566
|
}
|
|
459
567
|
if (!strArr(hb.unverified)) errs.push("unverified must be an array of strings");
|
|
460
568
|
if (!strArr(hb.testFiles)) errs.push("testFiles must be an array of strings");
|
|
569
|
+
if (hb.reviewerChecks !== undefined && !strArr(hb.reviewerChecks)) errs.push("reviewerChecks must be an array of strings");
|
|
461
570
|
if (!Array.isArray(hb.mutations)) errs.push("mutations must be an array");
|
|
462
571
|
else {
|
|
463
572
|
const ids = new Set();
|
|
@@ -465,6 +574,7 @@ function validate(state, hb) {
|
|
|
465
574
|
if (!isObj(m) || !nonBlank(m.id) || !isStr(m.case) || !isStr(m.mutation) || !isStr(m.test)) {
|
|
466
575
|
errs.push(`mutations[${i}] needs id, case, mutation and test`);
|
|
467
576
|
} else if (ids.has(m.id)) errs.push(`mutations[${i}] repeats id ${m.id}`);
|
|
577
|
+
else if (!new RegExp(MUTATION_ID_PATTERN).test(m.id)) errs.push(`mutations[${i}] id ${m.id} must have the form M<n> (M1, M2, ...)`);
|
|
468
578
|
else ids.add(m.id);
|
|
469
579
|
});
|
|
470
580
|
}
|
|
@@ -478,7 +588,14 @@ function validate(state, hb) {
|
|
|
478
588
|
errs.push("tests_red_by_design must be an array of strings");
|
|
479
589
|
}
|
|
480
590
|
let shaped = true;
|
|
481
|
-
if (hb.mutationResults !== undefined) shaped = checkMutResults(hb.mutationResults, "mutationResults", errs);
|
|
591
|
+
if (hb.mutationResults !== undefined) shaped = checkMutResults(hb.mutationResults, "mutationResults", errs, state.lane === "tests-only");
|
|
592
|
+
if (state.lane === "tests-only" && shaped && Array.isArray(hb.mutationResults)) {
|
|
593
|
+
hb.mutationResults.forEach((m, i) => {
|
|
594
|
+
if (isObj(m) && m.notRun !== undefined && !muts.some((x) => x.id === m.id)) {
|
|
595
|
+
errs.push(`mutationResults[${i}] notRun id ${m.id} is not a listed mutation`);
|
|
596
|
+
}
|
|
597
|
+
});
|
|
598
|
+
}
|
|
482
599
|
if (state.lane === "tests-only" && first && muts.length > 0) {
|
|
483
600
|
if (hb.mutationResults === undefined) errs.push("mutationResults is required on the first run");
|
|
484
601
|
else if (shaped) needCover(hb.mutationResults, "mutationResults", muts.map((m) => m.id));
|
|
@@ -495,7 +612,14 @@ function validate(state, hb) {
|
|
|
495
612
|
});
|
|
496
613
|
}
|
|
497
614
|
let shaped = true;
|
|
498
|
-
if (hb.mutationResults !== undefined) shaped = checkMutResults(hb.mutationResults, "mutationResults", errs);
|
|
615
|
+
if (hb.mutationResults !== undefined) shaped = checkMutResults(hb.mutationResults, "mutationResults", errs, true);
|
|
616
|
+
if (shaped && Array.isArray(hb.mutationResults)) {
|
|
617
|
+
hb.mutationResults.forEach((m, i) => {
|
|
618
|
+
if (isObj(m) && m.notRun !== undefined && !muts.some((x) => x.id === m.id)) {
|
|
619
|
+
errs.push(`mutationResults[${i}] notRun id ${m.id} is not a listed mutation`);
|
|
620
|
+
}
|
|
621
|
+
});
|
|
622
|
+
}
|
|
499
623
|
if (first && muts.length > 0) {
|
|
500
624
|
if (hb.mutationResults === undefined) errs.push("mutationResults is required on the first round");
|
|
501
625
|
else if (shaped) needCover(hb.mutationResults, "mutationResults", muts.map((m) => m.id));
|
|
@@ -522,11 +646,16 @@ function validate(state, hb) {
|
|
|
522
646
|
}
|
|
523
647
|
if (hb.verdict === "pass" && blocker) errs.push("verdict pass contradicts an evidenced blocker");
|
|
524
648
|
checkCmds(hb.commandsRun, "commandsRun", errs, false);
|
|
649
|
+
if (Array.isArray(hb.mutationResults)) {
|
|
650
|
+
hb.mutationResults.forEach((m, i) => {
|
|
651
|
+
if (isObj(m) && m.notRun !== undefined) errs.push(`mutationResults[${i}] notRun is not allowed for the reviewer`);
|
|
652
|
+
});
|
|
653
|
+
}
|
|
525
654
|
if (checkMutResults(hb.mutationResults, "mutationResults", errs)) {
|
|
526
655
|
const listed = muts.map((m) => m.id);
|
|
527
656
|
needCover(hb.mutationResults, "mutationResults", listed);
|
|
528
657
|
for (const m of hb.mutationResults) {
|
|
529
|
-
if (!listed.
|
|
658
|
+
if (!listed.some((l) => coversId(m.id, l)) && m.own !== true) errs.push(`mutationResults id ${m.id} is not listed and not marked own`);
|
|
530
659
|
}
|
|
531
660
|
}
|
|
532
661
|
break;
|
|
@@ -537,6 +666,7 @@ function validate(state, hb) {
|
|
|
537
666
|
errs.push("gate seat must not run git add or git commit");
|
|
538
667
|
}
|
|
539
668
|
if (!nonBlank(hb.gateLine)) errs.push("gateLine must be a non-empty string");
|
|
669
|
+
if (!(isStr(hb.tree) && TREE_RE.test(hb.tree))) errs.push("tree must be 40 or 64 lowercase hex characters");
|
|
540
670
|
if (hb.lockWaitMs !== undefined && hb.lockWaitMs !== null && !Number.isInteger(hb.lockWaitMs)) {
|
|
541
671
|
errs.push("lockWaitMs must be an integer or null");
|
|
542
672
|
}
|
|
@@ -545,6 +675,7 @@ function validate(state, hb) {
|
|
|
545
675
|
case "commit": {
|
|
546
676
|
if (!(isStr(hb.sha) && /^[0-9a-f]{7,40}$/.test(hb.sha))) errs.push("sha must be 7 to 40 lowercase hex characters");
|
|
547
677
|
if (!nonBlank(hb.branch)) errs.push("branch must be a non-empty string");
|
|
678
|
+
if (!(isStr(hb.tree) && TREE_RE.test(hb.tree))) errs.push("tree must be 40 or 64 lowercase hex characters");
|
|
548
679
|
break;
|
|
549
680
|
}
|
|
550
681
|
default:
|
|
@@ -555,6 +686,8 @@ function validate(state, hb) {
|
|
|
555
686
|
|
|
556
687
|
// ---- next ----
|
|
557
688
|
|
|
689
|
+
const TREE_RE = /^(?:[0-9a-f]{40}|[0-9a-f]{64})$/;
|
|
690
|
+
|
|
558
691
|
// git add / git commit at the start of a command or after a shell separator, global options (-C <path>, -c k=v, --flag) allowed before the verb.
|
|
559
692
|
const GIT_WRITE_RE = /(?:^|[;&|(\n]\s*)git(?:\s+(?:-[Cc]\s+(?:"[^"]*"|'[^']*'|\S+)|--[\w-]+(?:=\S+)?))*\s+(?:add|commit)\b/;
|
|
560
693
|
|
|
@@ -604,6 +737,9 @@ export function next(state, handBack) {
|
|
|
604
737
|
// C3 .. C6
|
|
605
738
|
if (isObj(H.hookBlocked) && nonBlank(H.hookBlocked.message)) return stopWith(s, "hook-blocked", H.hookBlocked.message);
|
|
606
739
|
if (nonBlank(H.designQuestion)) return stopWith(s, "design-decision", H.designQuestion);
|
|
740
|
+
if (P === "gate" && Array.isArray(H.commands) && H.commands.some((c) => isObj(c) && c.timedOut === true)) {
|
|
741
|
+
return stopWith({ ...s, lastGate: { commands: cp(H.commands), gateLine: H.gateLine } }, "gate-timeout", typeof H.gateLine === "string" ? H.gateLine : "a gate command timed out");
|
|
742
|
+
}
|
|
607
743
|
if (nonBlank(H.blocked)) return stopWith(s, "seat-blocked", H.blocked);
|
|
608
744
|
const errs = validateHandBack(s, H);
|
|
609
745
|
if (errs.length > 0) return stopWith(s, "hand-back-invalid", errs.join("; "));
|
|
@@ -624,6 +760,7 @@ export function next(state, handBack) {
|
|
|
624
760
|
unverified: cp(H.unverified),
|
|
625
761
|
testFiles: cp(H.testFiles),
|
|
626
762
|
mutations: cp(H.mutations),
|
|
763
|
+
reviewerChecks: strArr(H.reviewerChecks) ? cp(H.reviewerChecks) : [],
|
|
627
764
|
};
|
|
628
765
|
const to = s.lane === "lean" || !s.testerAgent ? "coder" : "tester";
|
|
629
766
|
return advance(s, to, "ok", { material });
|
|
@@ -632,13 +769,41 @@ export function next(state, handBack) {
|
|
|
632
769
|
const acc = { ...s.acc, filesChanged: union(s.acc.filesChanged, H.files) };
|
|
633
770
|
if (Array.isArray(H.mutationResults) && s.lane === "tests-only") acc.mutations = cp(H.mutationResults);
|
|
634
771
|
if (s.lane === "tests-only") {
|
|
772
|
+
const seenT = new Set((s.notRun || []).map((n) => n.id));
|
|
773
|
+
const notRunT = [...(s.notRun || [])];
|
|
774
|
+
for (const m of Array.isArray(H.mutationResults) ? H.mutationResults : []) {
|
|
775
|
+
if (isObj(m) && m.notRun !== undefined && !seenT.has(m.id)) {
|
|
776
|
+
seenT.add(m.id);
|
|
777
|
+
notRunT.push({ id: m.id, reason: m.notRun });
|
|
778
|
+
}
|
|
779
|
+
}
|
|
635
780
|
return advance(s, "reviewer", "ok", {
|
|
636
781
|
acc,
|
|
782
|
+
notRun: notRunT,
|
|
637
783
|
fixFor: null,
|
|
638
784
|
counters: { ...s.counters, coderRounds: s.counters.coderRounds + 1 },
|
|
639
785
|
});
|
|
640
786
|
}
|
|
641
787
|
const claim = H.tests_red_by_design;
|
|
788
|
+
if (Array.isArray(claim) && claim.length === 0) {
|
|
789
|
+
// Pin-only card: the new tests pass at baseline. Go on only when every listed mutation was run and failed.
|
|
790
|
+
const listed = effectiveMutations(s).map((m) => m.id);
|
|
791
|
+
const results = Array.isArray(H.mutationResults) ? H.mutationResults.filter(isObj) : [];
|
|
792
|
+
const bad = listed.filter((id) => {
|
|
793
|
+
const cov = results.filter((m) => coversId(m.id, id));
|
|
794
|
+
return cov.length === 0 || cov.some((m) => m.failed !== true);
|
|
795
|
+
});
|
|
796
|
+
if (listed.length > 0 && bad.length === 0) {
|
|
797
|
+
return advance(s, "coder", "ok", { acc, redTests: [] });
|
|
798
|
+
}
|
|
799
|
+
return stopWith(
|
|
800
|
+
s,
|
|
801
|
+
"tester-red-claim-missing",
|
|
802
|
+
listed.length === 0
|
|
803
|
+
? "the tester returned an empty tests_red_by_design claim and no mutation is listed to show the pins bite"
|
|
804
|
+
: `the tester returned an empty tests_red_by_design claim but mutation ${bad.join(", ")} was not run or survived`,
|
|
805
|
+
);
|
|
806
|
+
}
|
|
642
807
|
if (!(Array.isArray(claim) && claim.length > 0 && claim.every(nonBlank))) {
|
|
643
808
|
return stopWith(s, "tester-red-claim-missing", "the tester returned no tests_red_by_design claim");
|
|
644
809
|
}
|
|
@@ -650,8 +815,17 @@ export function next(state, handBack) {
|
|
|
650
815
|
filesChanged: union(s.acc.filesChanged, H.filesChanged),
|
|
651
816
|
testsEdited: [...s.acc.testsEdited, ...H.testsEdited.map((t) => ({ round, file: t.file, reason: t.reason }))],
|
|
652
817
|
};
|
|
818
|
+
const seen = new Set((s.notRun || []).map((n) => n.id));
|
|
819
|
+
const notRun = [...(s.notRun || [])];
|
|
820
|
+
for (const m of Array.isArray(H.mutationResults) ? H.mutationResults : []) {
|
|
821
|
+
if (isObj(m) && m.notRun !== undefined && !seen.has(m.id)) {
|
|
822
|
+
seen.add(m.id);
|
|
823
|
+
notRun.push({ id: m.id, reason: m.notRun });
|
|
824
|
+
}
|
|
825
|
+
}
|
|
653
826
|
return advance(s, "reviewer", "ok", {
|
|
654
827
|
acc,
|
|
828
|
+
notRun,
|
|
655
829
|
fixFor: null,
|
|
656
830
|
counters: { ...s.counters, coderRounds: s.counters.coderRounds + 1 },
|
|
657
831
|
});
|
|
@@ -680,6 +854,18 @@ export function next(state, handBack) {
|
|
|
680
854
|
// An evidenced major gets the same one fix pass; a major without evidence and a minor stay advisory.
|
|
681
855
|
const majors = findings.filter((f) => f.severity === "major" && nonBlank(f.evidence));
|
|
682
856
|
if (blockers.length === 0 && majors.length === 0) return advance(s, "gate", "ok", { acc });
|
|
857
|
+
// A reviewer round that carried a user answer and still has an open finding gets one extra coder round.
|
|
858
|
+
const answered = (s.answers || []).some(
|
|
859
|
+
(a) => a.seat === "reviewer" && (a.round ?? 1) <= (s.seatRuns.reviewer || 0),
|
|
860
|
+
);
|
|
861
|
+
if (s.counters.fixPasses >= 1 && answered && !s.answerCoderUsed) {
|
|
862
|
+
return advance(s, buildSeat(s), "blockers", {
|
|
863
|
+
acc,
|
|
864
|
+
fixFor: "review",
|
|
865
|
+
answerCoderUsed: true,
|
|
866
|
+
lastFindings: findings.filter((f) => f.severity !== "minor"),
|
|
867
|
+
});
|
|
868
|
+
}
|
|
683
869
|
if (s.counters.fixPasses >= 1) {
|
|
684
870
|
if (blockers.length > 0) {
|
|
685
871
|
return stopWith(
|
|
@@ -705,8 +891,17 @@ export function next(state, handBack) {
|
|
|
705
891
|
const lastGate = { commands: cp(H.commands), gateLine: H.gateLine };
|
|
706
892
|
const waited = { ...s.acc, lockWaitMs: (s.acc.lockWaitMs || 0) + (Number.isInteger(H.lockWaitMs) ? H.lockWaitMs : 0) };
|
|
707
893
|
s.acc = waited;
|
|
708
|
-
|
|
709
|
-
|
|
894
|
+
const isInstall = (c) => s.install && typeof c.command === "string" && c.command.trim() === `cd ${s.worktree} && ${s.install}`;
|
|
895
|
+
const failedInstall = H.commands.find((c) => isInstall(c) && c.exitCode !== 0);
|
|
896
|
+
if (failedInstall) {
|
|
897
|
+
return stopWith({ ...s, lastGate }, "setup-failed", `gate reinstall exited ${failedInstall.exitCode === null ? "without a code" : failedInstall.exitCode}`);
|
|
898
|
+
}
|
|
899
|
+
const nonInstall = H.commands.filter((c) => !isInstall(c));
|
|
900
|
+
if (nonInstall.every((c) => c.exitCode === 0)) {
|
|
901
|
+
if (!nonInstall.some((c) => typeof c.command === "string" && c.command.includes(s.gateCommand))) {
|
|
902
|
+
return stopWith({ ...s, lastGate }, "hand-back-invalid", `gate hand-back has no run of the gate command ${s.gateCommand}`);
|
|
903
|
+
}
|
|
904
|
+
return advance(s, "commit", "ok", { lastGate, gateTree: H.tree, acc: { ...waited, gateLine: H.gateLine } });
|
|
710
905
|
}
|
|
711
906
|
const redGates = s.counters.redGates + 1;
|
|
712
907
|
const counters = { ...s.counters, redGates };
|
|
@@ -715,6 +910,9 @@ export function next(state, handBack) {
|
|
|
715
910
|
}
|
|
716
911
|
default: {
|
|
717
912
|
// commit
|
|
913
|
+
if (H.tree !== s.gateTree) {
|
|
914
|
+
return stopWith(s, "commit-tree-mismatch", `commit ${H.sha} tree ${H.tree} differs from the gated tree ${s.gateTree}`);
|
|
915
|
+
}
|
|
718
916
|
const state = {
|
|
719
917
|
...s,
|
|
720
918
|
pending: null,
|
|
@@ -737,24 +935,38 @@ export function briefPrompt(brief, { repoRoot, worktreePath } = {}) {
|
|
|
737
935
|
out.push(`SEAT: ${b.seat} (round ${b.round}, lane ${b.lane}, model row ${b.row})`);
|
|
738
936
|
out.push(`CARD ${b.card.id}${b.card.title ? ": " + b.card.title : ""}`);
|
|
739
937
|
out.push("GOAL:\n" + b.card.goal);
|
|
938
|
+
if (b.answers && b.answers.length > 0) {
|
|
939
|
+
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}`)));
|
|
940
|
+
}
|
|
740
941
|
out.push("WORKTREE: " + worktreePath);
|
|
942
|
+
if (b.seat === "lead" && typeof repoRoot === "string" && repoRoot !== "") out.push("REPO ROOT: " + repoRoot);
|
|
741
943
|
if (b.seat === "setup") {
|
|
742
944
|
out.push(
|
|
743
945
|
"SETUP:\n" +
|
|
744
946
|
`Create the card worktree: git -C ${repoRoot} worktree add -b ${b.branch} ${worktreePath} ${b.base}\n` +
|
|
745
947
|
(b.install
|
|
746
|
-
? `Then run the install command
|
|
948
|
+
? `Then run the install command as this one literal command: cd ${worktreePath} && ${b.install}`
|
|
747
949
|
: "There is no install command; skip the install and report install as null."),
|
|
748
950
|
);
|
|
749
951
|
}
|
|
750
|
-
if (b.
|
|
952
|
+
if (b.seat === "gate" && b.install) out.push(`INSTALL COMMAND: cd ${worktreePath} && ${b.install}`);
|
|
953
|
+
if (b.researchNote) out.push("RESEARCH NOTE:" + (b.researchNote.includes("\n") ? "\n" : " ") + b.researchNote);
|
|
751
954
|
if (b.lessons) out.push("LESSONS BLOCK verbatim:\n" + b.lessons);
|
|
752
955
|
if (b.facts.length > 0) out.push("FACTS (with sources):\n" + bullets(b.facts.map((f) => `${f.text} [${f.source}]`)));
|
|
753
956
|
if (b.unverified.length > 0) out.push("UNVERIFIED:\n" + bullets(b.unverified));
|
|
754
957
|
if (b.testFiles.length > 0) out.push("TEST FILES:\n" + bullets(b.testFiles));
|
|
755
958
|
if (b.mutations.length > 0) {
|
|
756
959
|
out.push("MUTATIONS:\n" + bullets(b.mutations.map((m) => `${m.id} (${m.case}): ${m.mutation}; test that must fail: ${m.test}`)));
|
|
960
|
+
if ((b.rules || []).some((r) => r.id === "mutation-ids-verbatim")) {
|
|
961
|
+
out.push("MUTATION IDS (report each verbatim in mutationResults): " + b.mutations.map((m) => m.id).join(", "));
|
|
962
|
+
}
|
|
963
|
+
}
|
|
964
|
+
if (b.notRun && b.notRun.length > 0) {
|
|
965
|
+
out.push(
|
|
966
|
+
"NOT RUN BY THE CODER (report each with failed true or false):\n" + bullets(b.notRun.map((n) => `${n.id}: ${n.reason}`)),
|
|
967
|
+
);
|
|
757
968
|
}
|
|
969
|
+
if (b.reviewerChecks && b.reviewerChecks.length > 0) out.push("REVIEWER CHECKS:\n" + bullets(b.reviewerChecks));
|
|
758
970
|
if (b.redTests.length > 0) out.push("TESTS RED BY DESIGN:\n" + bullets(b.redTests));
|
|
759
971
|
if (b.writeTestsFirst) out.push("Write the test first, in the file that covers the module, then the change.");
|
|
760
972
|
if (b.findings.length > 0) {
|
|
@@ -772,6 +984,7 @@ export function briefPrompt(brief, { repoRoot, worktreePath } = {}) {
|
|
|
772
984
|
);
|
|
773
985
|
}
|
|
774
986
|
if (b.gateCommand) out.push("GATE COMMAND: " + b.gateCommand);
|
|
987
|
+
if (b.gateTree) out.push("GATED TREE: " + b.gateTree);
|
|
775
988
|
out.push("RULES:\n" + bullets(b.rules.map((r) => r.text)));
|
|
776
989
|
out.push("HAND-BACK: return JSON matching this schema:\n" + JSON.stringify(b.handBack, null, 2));
|
|
777
990
|
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
|
|
@@ -10,7 +10,7 @@
|
|
|
10
10
|
// when a matching file is read. Source: https://code.claude.com/docs/en/memory.md#path-specific-rules
|
|
11
11
|
import { existsSync, readFileSync, readdirSync } from "node:fs";
|
|
12
12
|
import { join } from "node:path";
|
|
13
|
-
import { unifiedDiff, writeProposals, countLines, insertBulletUnderHeading, removeExactLine } from "./learn.mjs";
|
|
13
|
+
import { unifiedDiff, writeProposals, countLines, insertBulletUnderHeading, removeExactLine, renderRule } from "./learn.mjs";
|
|
14
14
|
|
|
15
15
|
const DECISIONS_RELDIR = "docs/decisions";
|
|
16
16
|
const RULES_RELDIR = ".claude/rules";
|
|
@@ -155,9 +155,7 @@ export function proposeRule({ dir, name, paths = [], body, now = new Date() } =
|
|
|
155
155
|
if (!validRuleName(name)) return { ok: false, reason: `"${name}" is not a valid rule name (expected [a-z0-9][a-z0-9-]*, optionally one /subdir level)` };
|
|
156
156
|
if (!String(body || "").trim()) return { ok: false, reason: "body is required" };
|
|
157
157
|
const relPath = `${RULES_RELDIR}/${name}.md`;
|
|
158
|
-
const
|
|
159
|
-
const list = Array.isArray(paths) ? paths.filter((p) => String(p || "").trim()) : [];
|
|
160
|
-
const content = list.length ? `---\npaths:\n${list.map((p) => ` - "${p}"`).join("\n")}\n---\n\n${trimmedBody}\n` : `${trimmedBody}\n`;
|
|
158
|
+
const content = renderRule(paths, body);
|
|
161
159
|
const exists = existsSync(join(dir, relPath));
|
|
162
160
|
const built = buildProposal(dir, relPath, content, { kind: "rule", id: "01", reason: `${exists ? "update" : "new"} rule ${relPath}` });
|
|
163
161
|
if (!built.ok) return built;
|