@dougharness/cli 1.0.1
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/LICENSE +21 -0
- package/dist/ablate.d.ts +96 -0
- package/dist/ablate.js +440 -0
- package/dist/ablate.js.map +1 -0
- package/dist/apply.d.ts +2 -0
- package/dist/apply.js +19 -0
- package/dist/apply.js.map +1 -0
- package/dist/bin.d.ts +2 -0
- package/dist/bin.js +80 -0
- package/dist/bin.js.map +1 -0
- package/dist/board-page.d.ts +15 -0
- package/dist/board-page.js +27 -0
- package/dist/board-page.js.map +1 -0
- package/dist/board-serve-process.d.ts +24 -0
- package/dist/board-serve-process.js +227 -0
- package/dist/board-serve-process.js.map +1 -0
- package/dist/board-serve.d.ts +9 -0
- package/dist/board-serve.js +302 -0
- package/dist/board-serve.js.map +1 -0
- package/dist/board.d.ts +6 -0
- package/dist/board.js +515 -0
- package/dist/board.js.map +1 -0
- package/dist/detect/fs.d.ts +6 -0
- package/dist/detect/fs.js +50 -0
- package/dist/detect/fs.js.map +1 -0
- package/dist/detect/index.d.ts +3 -0
- package/dist/detect/index.js +13 -0
- package/dist/detect/index.js.map +1 -0
- package/dist/detect/node.d.ts +3 -0
- package/dist/detect/node.js +179 -0
- package/dist/detect/node.js.map +1 -0
- package/dist/detect/python.d.ts +2 -0
- package/dist/detect/python.js +388 -0
- package/dist/detect/python.js.map +1 -0
- package/dist/detect/repo.d.ts +2 -0
- package/dist/detect/repo.js +102 -0
- package/dist/detect/repo.js.map +1 -0
- package/dist/detect/types.d.ts +98 -0
- package/dist/detect/types.js +2 -0
- package/dist/detect/types.js.map +1 -0
- package/dist/diff.d.ts +12 -0
- package/dist/diff.js +105 -0
- package/dist/diff.js.map +1 -0
- package/dist/generate/agents.d.ts +19 -0
- package/dist/generate/agents.js +184 -0
- package/dist/generate/agents.js.map +1 -0
- package/dist/generate/claude-md.d.ts +9 -0
- package/dist/generate/claude-md.js +111 -0
- package/dist/generate/claude-md.js.map +1 -0
- package/dist/generate/config.d.ts +51 -0
- package/dist/generate/config.js +155 -0
- package/dist/generate/config.js.map +1 -0
- package/dist/generate/proposal.d.ts +11 -0
- package/dist/generate/proposal.js +175 -0
- package/dist/generate/proposal.js.map +1 -0
- package/dist/generate/settings.d.ts +35 -0
- package/dist/generate/settings.js +213 -0
- package/dist/generate/settings.js.map +1 -0
- package/dist/generate/skills.d.ts +8 -0
- package/dist/generate/skills.js +264 -0
- package/dist/generate/skills.js.map +1 -0
- package/dist/init.d.ts +10 -0
- package/dist/init.js +69 -0
- package/dist/init.js.map +1 -0
- package/dist/trace.d.ts +9 -0
- package/dist/trace.js +106 -0
- package/dist/trace.js.map +1 -0
- package/node_modules/@dougharness/flow/.claude-plugin/plugin.json +10 -0
- package/node_modules/@dougharness/flow/LICENSE +21 -0
- package/node_modules/@dougharness/flow/agents/adversary-claude.md +33 -0
- package/node_modules/@dougharness/flow/agents/adversary.md +29 -0
- package/node_modules/@dougharness/flow/agents/implementer.md +23 -0
- package/node_modules/@dougharness/flow/agents/lead.md +35 -0
- package/node_modules/@dougharness/flow/agents/planner.md +70 -0
- package/node_modules/@dougharness/flow/agents/researcher.md +31 -0
- package/node_modules/@dougharness/flow/agents/reviewer.md +26 -0
- package/node_modules/@dougharness/flow/agents/verifier.md +24 -0
- package/node_modules/@dougharness/flow/lib/board.mjs +840 -0
- package/node_modules/@dougharness/flow/lib/code-index.mjs +629 -0
- package/node_modules/@dougharness/flow/lib/cost.mjs +392 -0
- package/node_modules/@dougharness/flow/lib/decisions.mjs +314 -0
- package/node_modules/@dougharness/flow/lib/embeddings.mjs +539 -0
- package/node_modules/@dougharness/flow/lib/land.mjs +219 -0
- package/node_modules/@dougharness/flow/lib/learn.mjs +742 -0
- package/node_modules/@dougharness/flow/lib/memory-import.mjs +145 -0
- package/node_modules/@dougharness/flow/lib/memory.mjs +1641 -0
- package/node_modules/@dougharness/flow/lib/models.mjs +148 -0
- package/node_modules/@dougharness/flow/lib/plan.mjs +773 -0
- package/node_modules/@dougharness/flow/lib/rehearse.mjs +825 -0
- package/node_modules/@dougharness/flow/lib/worktrees.mjs +215 -0
- package/node_modules/@dougharness/flow/package.json +19 -0
- package/node_modules/@dougharness/flow/scripts/board.mjs +260 -0
- package/node_modules/@dougharness/flow/scripts/cost.mjs +66 -0
- package/node_modules/@dougharness/flow/scripts/learn.mjs +161 -0
- package/node_modules/@dougharness/flow/scripts/memory.mjs +1274 -0
- package/node_modules/@dougharness/flow/scripts/plan.mjs +547 -0
- package/node_modules/@dougharness/flow/scripts/rehearse.mjs +129 -0
- package/node_modules/@dougharness/flow/skills/core-next/SKILL.md +92 -0
- package/node_modules/@dougharness/flow/skills/doug-approve/SKILL.md +15 -0
- package/node_modules/@dougharness/flow/skills/doug-decide/SKILL.md +26 -0
- package/node_modules/@dougharness/flow/skills/doug-implement/SKILL.md +22 -0
- package/node_modules/@dougharness/flow/skills/doug-learn/SKILL.md +20 -0
- package/node_modules/@dougharness/flow/skills/doug-next/SKILL.md +87 -0
- package/node_modules/@dougharness/flow/skills/doug-plan/SKILL.md +22 -0
- package/node_modules/@dougharness/flow/skills/doug-swarm/SKILL.md +55 -0
- package/node_modules/@dougharness/flow/skills/harness-fix/SKILL.md +85 -0
- package/node_modules/@dougharness/flow/skills/research/SKILL.md +41 -0
- package/node_modules/@dougharness/flow/skills/run-report/SKILL.md +51 -0
- package/node_modules/@dougharness/flow/skills/swarm-launch/SKILL.md +27 -0
- package/node_modules/@dougharness/flow/workflows/doug-implement.js +2687 -0
- package/node_modules/@dougharness/gates/.claude-plugin/plugin.json +10 -0
- package/node_modules/@dougharness/gates/LICENSE +21 -0
- package/node_modules/@dougharness/gates/hooks/hooks.json +184 -0
- package/node_modules/@dougharness/gates/lib/agent-context.mjs +111 -0
- package/node_modules/@dougharness/gates/lib/anchor.mjs +110 -0
- package/node_modules/@dougharness/gates/lib/baseline.mjs +160 -0
- package/node_modules/@dougharness/gates/lib/bash-rules.mjs +1522 -0
- package/node_modules/@dougharness/gates/lib/block-ledger.mjs +127 -0
- package/node_modules/@dougharness/gates/lib/checkpoint.mjs +97 -0
- package/node_modules/@dougharness/gates/lib/config.mjs +168 -0
- package/node_modules/@dougharness/gates/lib/evidence.mjs +59 -0
- package/node_modules/@dougharness/gates/lib/glob.mjs +86 -0
- package/node_modules/@dougharness/gates/lib/io.mjs +91 -0
- package/node_modules/@dougharness/gates/lib/paths.mjs +101 -0
- package/node_modules/@dougharness/gates/lib/proposals.mjs +201 -0
- package/node_modules/@dougharness/gates/lib/run.mjs +147 -0
- package/node_modules/@dougharness/gates/lib/scope.mjs +117 -0
- package/node_modules/@dougharness/gates/lib/secret-rules.mjs +268 -0
- package/node_modules/@dougharness/gates/lib/state.mjs +114 -0
- package/node_modules/@dougharness/gates/lib/trace.mjs +196 -0
- package/node_modules/@dougharness/gates/package.json +17 -0
- package/node_modules/@dougharness/gates/scripts/edit-loop.mjs +39 -0
- package/node_modules/@dougharness/gates/scripts/format-on-edit.mjs +45 -0
- package/node_modules/@dougharness/gates/scripts/guard-bash.mjs +32 -0
- package/node_modules/@dougharness/gates/scripts/protect-paths.mjs +49 -0
- package/node_modules/@dougharness/gates/scripts/reanchor.mjs +81 -0
- package/node_modules/@dougharness/gates/scripts/research-cap.mjs +295 -0
- package/node_modules/@dougharness/gates/scripts/secret-scan.mjs +26 -0
- package/node_modules/@dougharness/gates/scripts/session-start-baseline.mjs +57 -0
- package/node_modules/@dougharness/gates/scripts/statusline.mjs +133 -0
- package/node_modules/@dougharness/gates/scripts/stop-gate.mjs +1422 -0
- package/node_modules/@dougharness/gates/scripts/trace.mjs +75 -0
- package/package.json +33 -0
- package/templates/board-app.js +533 -0
|
@@ -0,0 +1,773 @@
|
|
|
1
|
+
// The plan file (.doug/plan.json): schema, validation, levelization, and status changes.
|
|
2
|
+
// This is the single source of truth for what gets implemented. The workflow refuses to run
|
|
3
|
+
// on anything but an approved plan. Pure functions; no I/O except load/save.
|
|
4
|
+
|
|
5
|
+
import { readFileSync, writeFileSync, renameSync, mkdirSync, existsSync } from "node:fs";
|
|
6
|
+
import { join, dirname } from "node:path";
|
|
7
|
+
import { createHash } from "node:crypto";
|
|
8
|
+
import { isModelName, EFFORTS } from "./models.mjs";
|
|
9
|
+
|
|
10
|
+
export const PLAN_RELPATH = ".doug/plan.json";
|
|
11
|
+
export const REPORT_RELPATH = ".doug/.state/last-report.json";
|
|
12
|
+
// One draft per card of a batch (card parallel-cards): the planner for card <id> writes .doug/.state/drafts/<id>.json
|
|
13
|
+
// and `plan.mjs merge` joins them into the plan file.
|
|
14
|
+
export const DRAFTS_RELPATH = ".doug/.state/drafts";
|
|
15
|
+
export const STATUSES = ["draft", "approved", "done", "rejected"];
|
|
16
|
+
export const SIZES = ["S", "M", "L"];
|
|
17
|
+
export const GATES = ["auto", "human"];
|
|
18
|
+
// Crew per role (card crew-sizing): researchers before planning, reviewers and adversaries after implementation,
|
|
19
|
+
// default one each. More coders is a split into more tasks, so there is no coders row.
|
|
20
|
+
export const CREW_ROLES = ["researchers", "reviewers", "adversaries"];
|
|
21
|
+
|
|
22
|
+
// Error strings for a crew object at `where` (the plan or a task); [] when valid or absent.
|
|
23
|
+
function crewErrors(crew, where) {
|
|
24
|
+
if (crew === undefined) return [];
|
|
25
|
+
if (!crew || typeof crew !== "object" || Array.isArray(crew)) return [`${where} must be an object with ${CREW_ROLES.join(", ")} when present`];
|
|
26
|
+
const errors = [];
|
|
27
|
+
for (const k of Object.keys(crew)) {
|
|
28
|
+
if (!CREW_ROLES.includes(k)) errors.push(`${where}.${k} is not a crew role (${CREW_ROLES.join(", ")})`);
|
|
29
|
+
else if (!(Number.isInteger(crew[k]) && crew[k] > 0)) errors.push(`${where}.${k} must be a positive integer`);
|
|
30
|
+
}
|
|
31
|
+
return errors;
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
// The crew a task runs with: the task's own over the plan's, one per role by default.
|
|
35
|
+
export function crewOf(plan, task) {
|
|
36
|
+
const src = { ...(plan && plan.crew && typeof plan.crew === "object" ? plan.crew : {}), ...(task && task.crew && typeof task.crew === "object" ? task.crew : {}) };
|
|
37
|
+
const out = {};
|
|
38
|
+
for (const k of CREW_ROLES) out[k] = Number.isInteger(src[k]) && src[k] > 0 ? src[k] : 1;
|
|
39
|
+
return out;
|
|
40
|
+
}
|
|
41
|
+
// Agent scaling (proposal C, card agent-scaling-rules): one task for single-file work, three to five for a
|
|
42
|
+
// feature, never more than eight. The validator warns; it does not refuse, since the planner may have a reason.
|
|
43
|
+
export const FEATURE_TASKS = 5;
|
|
44
|
+
export const TASK_CAP = 8;
|
|
45
|
+
export const DEFAULT_BUDGET = { agents: 12, tokens: 400000, wallMinutes: 40 };
|
|
46
|
+
|
|
47
|
+
export function loadPlan(dir, relpath = PLAN_RELPATH) {
|
|
48
|
+
const file = join(dir, relpath);
|
|
49
|
+
if (!existsSync(file)) return null;
|
|
50
|
+
return JSON.parse(readFileSync(file, "utf8"));
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
export function draftPath(card) {
|
|
54
|
+
return `${DRAFTS_RELPATH}/${card}.json`;
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
export function savePlan(dir, plan) {
|
|
58
|
+
const file = join(dir, PLAN_RELPATH);
|
|
59
|
+
mkdirSync(dirname(file), { recursive: true });
|
|
60
|
+
const tmp = file + "." + process.pid + ".tmp";
|
|
61
|
+
writeFileSync(tmp, JSON.stringify(plan, null, 2) + "\n");
|
|
62
|
+
renameSync(tmp, file);
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
// Sha256 hex over a task's contract (spec, files, verify command). A task whose contract changed since
|
|
66
|
+
// the recorded pass loses its `specHash` match and so cannot be reused. Computed here in Node because the
|
|
67
|
+
// workflow template may not import node:crypto; the workflow only ever copies task.specHash into its report.
|
|
68
|
+
export function specHash(task) {
|
|
69
|
+
return createHash("sha256").update(JSON.stringify([task.spec, task.files, task.verify === undefined ? null : task.verify])).digest("hex");
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
// A copy of the plan whose every task carries its current specHash, for `plan.mjs json` to hand the
|
|
73
|
+
// workflow (the plan file itself is not rewritten).
|
|
74
|
+
export function withSpecHashes(plan) {
|
|
75
|
+
return { ...plan, tasks: plan.tasks.map((t) => ({ ...t, specHash: specHash(t) })) };
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
export function loadReport(dir) {
|
|
79
|
+
const file = join(dir, REPORT_RELPATH);
|
|
80
|
+
if (!existsSync(file)) return null;
|
|
81
|
+
try {
|
|
82
|
+
return unwrapReport(JSON.parse(readFileSync(file, "utf8")));
|
|
83
|
+
} catch {
|
|
84
|
+
return null;
|
|
85
|
+
}
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
// Card report-save-wrapper: the Workflow tool's own output is { summary, agentCount, logs, result, ... }; the
|
|
89
|
+
// report every reader expects is `result`. Unwraps only when `obj` itself carries no array `levels` and
|
|
90
|
+
// `obj.result` is a non-null object that does carry an array `levels`; otherwise returns `obj` unchanged (a bare
|
|
91
|
+
// report, null, a non-object, and a wrapper whose result has no levels are all left alone). Never throws.
|
|
92
|
+
export function unwrapReport(obj) {
|
|
93
|
+
if (!obj || typeof obj !== "object" || Array.isArray(obj.levels)) return obj;
|
|
94
|
+
const result = obj.result;
|
|
95
|
+
if (result && typeof result === "object" && Array.isArray(result.levels)) return result;
|
|
96
|
+
return obj;
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
// The report entry's recorded shape (what actually ran, and what its recorded checks correspond to): "S" only when
|
|
100
|
+
// entry.shape is exactly "S"; a full-shape entry or an older report predating the `shape` field both come back
|
|
101
|
+
// "full".
|
|
102
|
+
function entryShape(entry) {
|
|
103
|
+
return entry && entry.shape === "S" ? "S" : "full";
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
// Whether a level's one shared adversary review (levelAdversary: the level adversary reviews an S task alongside
|
|
107
|
+
// the rest of its level's S tasks together, on the integration branch, after the merge — not per task) satisfies a
|
|
108
|
+
// size-S task's own adversary requirement in its place; the reason it does not, or null when it does. The review
|
|
109
|
+
// only ever covers the level's ready S tasks (doug-implement.js's sReady — a task with a stopReason, not ready,
|
|
110
|
+
// never made it into the branch the review ran against), recorded by id in `levelAdversary.tasks`: a task whose id
|
|
111
|
+
// is not in that list is treated as not covered, same as a report predating the `tasks` field entirely (a missing
|
|
112
|
+
// list must never be read as "covers everything"). A block later routed to one S task and cleared by a re-review
|
|
113
|
+
// (`confirm.blocked === false`) counts as satisfied, same as the routed-blocker path's own per-task re-review would.
|
|
114
|
+
function levelAdversaryFailure(levelAdversary, taskId) {
|
|
115
|
+
if (!levelAdversary || !levelAdversary.ran) return "level adversary did not run";
|
|
116
|
+
if (!Array.isArray(levelAdversary.tasks) || !levelAdversary.tasks.includes(taskId)) {
|
|
117
|
+
return "level adversary did not review this task";
|
|
118
|
+
}
|
|
119
|
+
if (!levelAdversary.blocked) return null;
|
|
120
|
+
if (levelAdversary.confirm && levelAdversary.confirm.blocked === false) return null;
|
|
121
|
+
if (Array.isArray(levelAdversary.unowned) && levelAdversary.unowned.length) {
|
|
122
|
+
return `level adversary blocked on files no task of the level owned: ${levelAdversary.summary || levelAdversary.verdict}`;
|
|
123
|
+
}
|
|
124
|
+
return `level adversary blocked: ${levelAdversary.summary || levelAdversary.verdict}`;
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
// A full-shape task's own adversary can pass while its level's shared adversary later blocks, uncleared, on a
|
|
128
|
+
// file that task owns (card level-adversary-routes-owner, rule 2: the old workflow recorded such a blocker as
|
|
129
|
+
// unowned, or a routed fix pass never cleared it -- either way the task must not be judged solely on its own
|
|
130
|
+
// passed adversary). Ownership is matched the same way the workflow's level-adversary routing matches it (see
|
|
131
|
+
// samePath below), not the exact `task.files` match plan.mjs uses elsewhere (planWarnings/validatePlan's
|
|
132
|
+
// `mine.has(f)`). Returns the reason, or null when nothing applies.
|
|
133
|
+
// Two paths name the same file when they are equal, when one is the other shortened from the left (a verifier
|
|
134
|
+
// writes lib/secret-rules.mjs for plugins/doug-gates/lib/secret-rules.mjs), or when the first is a directory the
|
|
135
|
+
// second is under. Mirrors doug-implement.js samePath exactly (the workflow file cannot import, so the logic is
|
|
136
|
+
// mirrored here, the way R12 is mirrored); ownsPath's `.doug/`/`.claude/` catch-all is deliberately left out, since
|
|
137
|
+
// in the workflow it hands such a blocker to the first ready task, which replan cannot identify, and those paths
|
|
138
|
+
// are never code a task owns.
|
|
139
|
+
function samePath(owned, p) {
|
|
140
|
+
return owned === p || owned.endsWith("/" + p) || p.endsWith("/" + owned) || p.startsWith(owned + "/");
|
|
141
|
+
}
|
|
142
|
+
|
|
143
|
+
function levelAdversaryOwnedBlocker(task, levelAdversary) {
|
|
144
|
+
if (!levelAdversary || !levelAdversary.blocked) return null;
|
|
145
|
+
if (levelAdversary.confirm && levelAdversary.confirm.blocked === false) return null;
|
|
146
|
+
const files = Array.isArray(task.files) ? task.files : [];
|
|
147
|
+
const issues = Array.isArray(levelAdversary.issues) ? levelAdversary.issues : [];
|
|
148
|
+
const hit = issues.find((i) => {
|
|
149
|
+
if (!i || i.severity !== "blocker" || typeof i.file !== "string") return false;
|
|
150
|
+
const file = i.file.trim().replace(/^\.\//, "");
|
|
151
|
+
return file && files.some((f) => samePath(f, file));
|
|
152
|
+
});
|
|
153
|
+
if (!hit) return null;
|
|
154
|
+
return `level adversary blocked on ${hit.file}, owned by this task, and was not cleared: ${levelAdversary.summary || levelAdversary.verdict}`;
|
|
155
|
+
}
|
|
156
|
+
|
|
157
|
+
// Why a task's final pass did not pass every checking stage, or null when it did. A size-S task carries no
|
|
158
|
+
// adversary of its own (the level adversary reviews a level's S tasks together, after the merge): when its own
|
|
159
|
+
// entry never ran one, `levelAdversary` (that task's level's one shared review) is consulted in its place instead
|
|
160
|
+
// of the usual "adversary did not run"/"adversary blocked" reasons, which would be false for an S task whose own
|
|
161
|
+
// check passed and whose level adversary passed. A size-S task whose own adversary DID run (the level blocker was
|
|
162
|
+
// routed to it as a fix pass) is unaffected by any of this and falls through to the same checks a full-shape task
|
|
163
|
+
// gets. A full-shape task whose own adversary passed still fails here when its level's shared adversary blocked,
|
|
164
|
+
// uncleared, on a file it owns (levelAdversaryOwnedBlocker, rule 2); a full-shape task whose own adversary already
|
|
165
|
+
// blocked keeps that reason instead, unaffected by rule 2.
|
|
166
|
+
function stageFailure(plan, task, entry, levelAdversary) {
|
|
167
|
+
if (!entry.verified) return "verification failed";
|
|
168
|
+
if (!entry.reviewed) return "review rejected";
|
|
169
|
+
if (plan.adversary === false) return null;
|
|
170
|
+
const ownRan = Boolean(entry.adversary && entry.adversary.ran);
|
|
171
|
+
if (entryShape(entry) === "S" && !ownRan) return levelAdversaryFailure(levelAdversary, entry.id);
|
|
172
|
+
if (!ownRan) return "adversary did not run";
|
|
173
|
+
if (entry.adversary.blocked) return `adversary blocked: ${entry.adversary.summary || entry.adversary.verdict}`;
|
|
174
|
+
return levelAdversaryOwnedBlocker(task, levelAdversary);
|
|
175
|
+
}
|
|
176
|
+
|
|
177
|
+
// The latest pass of the run on which the branch head passed verify, review, and the adversary, for a task whose
|
|
178
|
+
// final pass did not: a fix pass that made no commit runs no stage, and a pass can stop on a ledger hold with every
|
|
179
|
+
// stage green. Only a pass whose commit is still the recorded head counts, because reuse starts from the head; a
|
|
180
|
+
// later commit that then failed leaves nothing to reuse. On a fix pass (doug-implement.js runChecks, pass > 1),
|
|
181
|
+
// the full verifier or reviewer reruns only when it was the stage that blocked the previous pass; otherwise (and
|
|
182
|
+
// always after that) the focused check stands in for both, via readCheck mapping the check onto ver/rev. Either
|
|
183
|
+
// way, a.verified/a.reviewed on the attempt record reflect whatever actually ran for that pass (the full stage or
|
|
184
|
+
// the check), true only when it passed. For a size-S task (entry.shape === "S") whose own attempt record ran no
|
|
185
|
+
// adversary, `levelAdversary` (that task's level's one shared review, level-wide rather than per pass, so the same
|
|
186
|
+
// value applies to every attempt at the recorded head) stands in for it, same as stageFailure. A full-shape task's
|
|
187
|
+
// attempt whose own adversary ran and passed is still not a satisfying pass when the level-wide blocker applies
|
|
188
|
+
// (levelAdversaryOwnedBlocker, rule 2, review round 2 B1): the block is level-wide, not per-attempt, so it rules
|
|
189
|
+
// out every attempt at the head the same way, and skipping this check here would let this fallback hand a routed
|
|
190
|
+
// owner straight back to reuse even though stageFailure just judged it failed for exactly that reason.
|
|
191
|
+
function passedAttempt(plan, task, entry, levelAdversary) {
|
|
192
|
+
const head = typeof entry.commit === "string" && entry.commit ? entry.commit : null;
|
|
193
|
+
if (!head || !Array.isArray(entry.attempts)) return null;
|
|
194
|
+
const shape = entryShape(entry);
|
|
195
|
+
for (let i = entry.attempts.length - 1; i >= 0; i--) {
|
|
196
|
+
const a = entry.attempts[i];
|
|
197
|
+
if (!a || a.commit !== head || !a.verified || !a.reviewed) continue;
|
|
198
|
+
if (plan.adversary === false) return a;
|
|
199
|
+
const ownRan = Boolean(a.adversary && a.adversary.ran);
|
|
200
|
+
if (shape === "S" && !ownRan) {
|
|
201
|
+
if (levelAdversaryFailure(levelAdversary, entry.id)) continue;
|
|
202
|
+
return a;
|
|
203
|
+
}
|
|
204
|
+
if (!ownRan || a.adversary.blocked) continue;
|
|
205
|
+
if (levelAdversaryOwnedBlocker(task, levelAdversary)) continue;
|
|
206
|
+
return a;
|
|
207
|
+
}
|
|
208
|
+
return null;
|
|
209
|
+
}
|
|
210
|
+
|
|
211
|
+
// staleName (worktrees.mjs) numbers a rename by the smallest n not yet taken, but a rename can later be deleted
|
|
212
|
+
// (by hand, or by some other cleanup) leaving a gap; the scan below cannot assume the names are contiguous.
|
|
213
|
+
const MAX_STALE_RENAME_SCAN = 50;
|
|
214
|
+
|
|
215
|
+
// Resolves a recorded reuse branch against the working tree. `git`, when given, is `{ branchHead(name) }` ->
|
|
216
|
+
// sha|null; without it (the default, keeping reusePlan pure) the branch is used exactly as recorded, no existence
|
|
217
|
+
// or head check performed. With it, a recorded branch is only ever reused from a head the report actually saw: the
|
|
218
|
+
// branch of the recorded name is accepted on its name alone when the report recorded no commit for the task
|
|
219
|
+
// (`commit` absent, null, or "" — nothing was ever captured to check a head against, so requiring one here would
|
|
220
|
+
// refuse every such reuse; the same `typeof === "string" && commit` guard as passedAttempt's `head` above), otherwise
|
|
221
|
+
// its head must equal `commit` by exact string comparison, the same as the stale scan below and passedAttempt's own
|
|
222
|
+
// head check: an abbreviated or otherwise non-sha `commit` in a report will not match a full sha head and so now
|
|
223
|
+
// goes fresh, where before this card it would have been accepted by name regardless. When the recorded name's head
|
|
224
|
+
// does not so qualify (the branch is gone entirely, or it exists but points somewhere else — e.g. a stray
|
|
225
|
+
// `doug/task-<id>` the implementer's `git checkout -b` left behind after a run died before reporting), the highest
|
|
226
|
+
// `<branch>-stale-<n>` (n from 1 to MAX_STALE_RENAME_SCAN) whose head equals `commit` is used instead: a stale
|
|
227
|
+
// rename wins even over a same-named branch with the wrong head, because the recorded work is really there, just
|
|
228
|
+
// filed under another name, and a stray branch must not be allowed to hide it. But that scan only runs when a
|
|
229
|
+
// commit was recorded to match against (it needs something to compare a candidate's head to); with no commit
|
|
230
|
+
// recorded, a stray branch of the recorded name is accepted by name and the scan for a rename of the real work is
|
|
231
|
+
// not attempted — that is the same trade the no-commit acceptance above makes, on the same reasoning: without a
|
|
232
|
+
// commit there is nothing to tell the two apart by. Returns { ok: true, branch, renamed } on success, or
|
|
233
|
+
// { ok: false, mismatch, head } when nothing resolves and the task must go fresh: `mismatch` is true when the
|
|
234
|
+
// recorded name exists but disqualified it (so the caller can name the mismatch rather than claim the branch is
|
|
235
|
+
// gone), false when the name was simply absent; `head` is that disqualifying head, or null when the name was absent.
|
|
236
|
+
function resolveReuseBranch(branch, commit, git) {
|
|
237
|
+
if (!git || typeof git.branchHead !== "function") return { ok: true, branch, renamed: false };
|
|
238
|
+
const hasCommit = typeof commit === "string" && commit;
|
|
239
|
+
const head = git.branchHead(branch);
|
|
240
|
+
if (head && (!hasCommit || head === commit)) return { ok: true, branch, renamed: false };
|
|
241
|
+
let match = null;
|
|
242
|
+
if (hasCommit) {
|
|
243
|
+
for (let n = 1; n <= MAX_STALE_RENAME_SCAN; n++) {
|
|
244
|
+
const name = `${branch}-stale-${n}`;
|
|
245
|
+
const h = git.branchHead(name);
|
|
246
|
+
if (h && h === commit) match = name;
|
|
247
|
+
}
|
|
248
|
+
}
|
|
249
|
+
if (match) return { ok: true, branch: match, renamed: true };
|
|
250
|
+
return { ok: false, mismatch: Boolean(head), head: head || null };
|
|
251
|
+
}
|
|
252
|
+
|
|
253
|
+
// Pure by default: decides which tasks from a prior workflow report can be reused (implementer skipped; verify,
|
|
254
|
+
// review, and adversary run again) rather than re-implemented from scratch. `report` may be null. `opts` (any of
|
|
255
|
+
// undefined, null, or an object) may carry `git`, an optional `{ branchHead(name) -> sha|null }` resolver; without
|
|
256
|
+
// it the function touches no git state and every existing caller is unaffected. With it, reuse never starts from a
|
|
257
|
+
// head the report never saw: a recorded branch must have the recorded commit as its head (see resolveReuseBranch
|
|
258
|
+
// for the no-commit-recorded exception), or it is resolved to its `-stale-<n>` rename when that rename's head is
|
|
259
|
+
// the commit this pass recorded (a prior replan may have renamed it because the task went fresh, and this replan
|
|
260
|
+
// finds the task's specHash matches again — or the recorded name was reused elsewhere, e.g. a stray
|
|
261
|
+
// `git checkout -b` from a run that died before reporting), or the task goes fresh with a reason naming which case
|
|
262
|
+
// it was: the branch gone entirely, or present with a head that does not match. Each decision also carries
|
|
263
|
+
// `renamed`: true only when the reuse was resolved to such a rename, so a caller can report that without parsing
|
|
264
|
+
// `reason`.
|
|
265
|
+
export function reusePlan(plan, report, opts) {
|
|
266
|
+
const { git } = opts || {};
|
|
267
|
+
const entries = new Map(); // id -> { entry, levelAdversary }
|
|
268
|
+
for (const level of (report && Array.isArray(report.levels) ? report.levels : [])) {
|
|
269
|
+
const levelAdversary = level && level.levelAdversary !== undefined ? level.levelAdversary : null;
|
|
270
|
+
for (const t of (level && Array.isArray(level.tasks) ? level.tasks : [])) {
|
|
271
|
+
if (t && typeof t.id === "string") entries.set(t.id, { entry: t, levelAdversary });
|
|
272
|
+
}
|
|
273
|
+
}
|
|
274
|
+
|
|
275
|
+
const decided = new Map(); // id -> { reuse, reason }
|
|
276
|
+
const levels = levelize(plan.tasks);
|
|
277
|
+
for (const level of levels) {
|
|
278
|
+
for (const task of level) {
|
|
279
|
+
const rec = entries.get(task.id);
|
|
280
|
+
const entry = rec ? rec.entry : undefined;
|
|
281
|
+
const levelAdversary = rec ? rec.levelAdversary : null;
|
|
282
|
+
const failed = entry && entry.implemented ? stageFailure(plan, task, entry, levelAdversary) : null;
|
|
283
|
+
const viaPass = failed ? passedAttempt(plan, task, entry, levelAdversary) : null;
|
|
284
|
+
let reuse = null;
|
|
285
|
+
let renamed = false;
|
|
286
|
+
let reason;
|
|
287
|
+
if (!entry) {
|
|
288
|
+
reason = "not in the last report";
|
|
289
|
+
} else if (!entry.implemented) {
|
|
290
|
+
reason = typeof entry.blockedReason === "string" ? `implementer blocked: ${entry.blockedReason}` : "not implemented";
|
|
291
|
+
} else if (failed && !viaPass) {
|
|
292
|
+
reason = failed;
|
|
293
|
+
} else if (typeof entry.branch !== "string" || !entry.branch) {
|
|
294
|
+
reason = "no branch recorded";
|
|
295
|
+
} else if (typeof entry.specHash !== "string") {
|
|
296
|
+
reason = "no spec hash recorded (report predates replan)";
|
|
297
|
+
} else if (entry.specHash !== specHash(task)) {
|
|
298
|
+
reason = "spec changed since the recorded pass";
|
|
299
|
+
} else {
|
|
300
|
+
const badDep = (task.dependsOn || []).find((d) => !(decided.get(d) && decided.get(d).reuse));
|
|
301
|
+
if (badDep !== undefined) {
|
|
302
|
+
reason = `depends on ${badDep}, which is not reused`;
|
|
303
|
+
} else {
|
|
304
|
+
const resolved = resolveReuseBranch(entry.branch, entry.commit, git);
|
|
305
|
+
if (!resolved.ok) {
|
|
306
|
+
reason = resolved.mismatch
|
|
307
|
+
? `recorded branch ${entry.branch} head ${resolved.head.slice(0, 7)} is not the recorded commit ${entry.commit}; re-implementing`
|
|
308
|
+
: `recorded branch ${entry.branch} no longer exists; re-implementing`;
|
|
309
|
+
} else {
|
|
310
|
+
reuse = resolved.branch;
|
|
311
|
+
renamed = resolved.renamed;
|
|
312
|
+
// Which record (the entry's own, or the matched earlier attempt's own) actually satisfied the
|
|
313
|
+
// adversary requirement decides the wording: the task's own adversary keeps the existing "adversary"
|
|
314
|
+
// wording unchanged, but a size-S task satisfied instead by its level's shared review must not claim a
|
|
315
|
+
// task adversary that never ran.
|
|
316
|
+
const satisfiedBy = viaPass || entry;
|
|
317
|
+
const viaLevel = plan.adversary !== false && entryShape(entry) === "S" && !(satisfiedBy.adversary && satisfiedBy.adversary.ran);
|
|
318
|
+
const adversaryWord = viaLevel ? "the level adversary" : "adversary";
|
|
319
|
+
const base = viaPass
|
|
320
|
+
? `passed verify, review, and ${adversaryWord} on pass ${viaPass.pass} of the last run; the branch head ${entry.commit.slice(0, 7)} is unchanged since`
|
|
321
|
+
: `passed verify, review, and ${adversaryWord} on ${entry.branch}`;
|
|
322
|
+
reason = renamed ? `${base}; ${entry.branch} was renamed to ${resolved.branch} since the last report` : base;
|
|
323
|
+
}
|
|
324
|
+
}
|
|
325
|
+
}
|
|
326
|
+
decided.set(task.id, { reuse, reason, renamed });
|
|
327
|
+
}
|
|
328
|
+
}
|
|
329
|
+
|
|
330
|
+
const decisions = plan.tasks.map((t) => ({ id: t.id, reuse: decided.get(t.id).reuse, reason: decided.get(t.id).reason, renamed: decided.get(t.id).renamed }));
|
|
331
|
+
const next = { ...plan, status: "draft" };
|
|
332
|
+
delete next.approvedAt;
|
|
333
|
+
delete next.doneAt;
|
|
334
|
+
next.tasks = plan.tasks.map((t) => {
|
|
335
|
+
const { reuse } = decided.get(t.id);
|
|
336
|
+
const { reuse: _drop, ...rest } = t;
|
|
337
|
+
return reuse ? { ...rest, reuse } : rest;
|
|
338
|
+
});
|
|
339
|
+
return { plan: next, decisions };
|
|
340
|
+
}
|
|
341
|
+
|
|
342
|
+
// Error strings for tasks whose `reuse` mark no longer matches what a fresh replan decision would give
|
|
343
|
+
// (e.g. the report was regenerated, or the spec changed after the mark was set). Empty when no task
|
|
344
|
+
// carries `reuse`. `opts` is passed through to reusePlan unchanged (see its `git` option) so a reuse mark
|
|
345
|
+
// resolved to a renamed branch by replan is not flagged stale here for want of the same resolver.
|
|
346
|
+
export function staleReuseErrors(plan, report, opts = {}) {
|
|
347
|
+
if (!plan.tasks.some((t) => t.reuse !== undefined)) return [];
|
|
348
|
+
const { decisions } = reusePlan(plan, report, opts);
|
|
349
|
+
const byId = new Map(decisions.map((d) => [d.id, d]));
|
|
350
|
+
const errors = [];
|
|
351
|
+
for (const t of plan.tasks) {
|
|
352
|
+
if (t.reuse === undefined) continue;
|
|
353
|
+
const fresh = byId.get(t.id);
|
|
354
|
+
if (!fresh || fresh.reuse !== t.reuse) {
|
|
355
|
+
errors.push(`task "${t.id}" is marked reuse: ${t.reuse} but ${fresh ? fresh.reason : "not in the last report"}; run plan.mjs replan`);
|
|
356
|
+
}
|
|
357
|
+
}
|
|
358
|
+
return errors;
|
|
359
|
+
}
|
|
360
|
+
|
|
361
|
+
// Normalizes an acceptance list (already validated) into one { text, command } per entry, in order.
|
|
362
|
+
// A string entry gives command: null. An object entry gives its trimmed-nonempty command, or null.
|
|
363
|
+
// Any other entry (null, number, array) is stringified into text with command: null.
|
|
364
|
+
export function acceptanceEntries(list) {
|
|
365
|
+
if (!Array.isArray(list)) return [];
|
|
366
|
+
return list.map((a) => {
|
|
367
|
+
if (typeof a === "string") return { text: a, command: null };
|
|
368
|
+
if (a && typeof a === "object" && !Array.isArray(a)) {
|
|
369
|
+
const command = typeof a.command === "string" && a.command.trim() ? a.command : null;
|
|
370
|
+
return { text: String(a.text), command };
|
|
371
|
+
}
|
|
372
|
+
return { text: String(a), command: null };
|
|
373
|
+
});
|
|
374
|
+
}
|
|
375
|
+
|
|
376
|
+
function isRelPath(p) {
|
|
377
|
+
return typeof p === "string" && p.length > 0 && !p.startsWith("/") && !/^[A-Za-z]:[\\/]/.test(p) && !p.split(/[\\/]/).includes("..");
|
|
378
|
+
}
|
|
379
|
+
|
|
380
|
+
// Returns an array of error strings. Empty means valid.
|
|
381
|
+
export function validatePlan(plan) {
|
|
382
|
+
const errors = [];
|
|
383
|
+
if (!plan || typeof plan !== "object") return ["plan must be an object"];
|
|
384
|
+
if (plan.version !== 1) errors.push("version must be 1");
|
|
385
|
+
if (typeof plan.title !== "string" || !plan.title.trim()) errors.push("title is required");
|
|
386
|
+
if (typeof plan.goal !== "string" || !plan.goal.trim()) errors.push("goal is required");
|
|
387
|
+
if (!STATUSES.includes(plan.status)) errors.push(`status must be one of ${STATUSES.join(", ")}`);
|
|
388
|
+
if (!Array.isArray(plan.acceptance) || plan.acceptance.length === 0) {
|
|
389
|
+
errors.push("acceptance must be a non-empty array");
|
|
390
|
+
} else {
|
|
391
|
+
plan.acceptance.forEach((a, i) => {
|
|
392
|
+
const validString = typeof a === "string" && a.trim();
|
|
393
|
+
const validObject = a && typeof a === "object" && !Array.isArray(a) && typeof a.text === "string" && a.text.trim() && typeof a.command === "string" && a.command.trim();
|
|
394
|
+
if (!validString && !validObject) errors.push(`acceptance[${i}] must be a non-empty string or { text, command } with non-empty strings`);
|
|
395
|
+
});
|
|
396
|
+
}
|
|
397
|
+
if (!Array.isArray(plan.verify) || !plan.verify.every((v) => typeof v === "string" && v.trim())) errors.push("verify must be an array of command strings");
|
|
398
|
+
if (plan.card !== undefined && (typeof plan.card !== "string" || !plan.card.trim())) errors.push("card must be a non-empty string when present");
|
|
399
|
+
// A batch (several cards in one plan) lists its cards; every task then names the card it belongs to.
|
|
400
|
+
let cards = null;
|
|
401
|
+
if (plan.cards !== undefined) {
|
|
402
|
+
if (!Array.isArray(plan.cards) || plan.cards.length === 0 || !plan.cards.every((c) => typeof c === "string" && c.trim())) errors.push("cards must be an array of card ids when present");
|
|
403
|
+
else {
|
|
404
|
+
cards = plan.cards;
|
|
405
|
+
const seen = new Set();
|
|
406
|
+
for (const c of cards) {
|
|
407
|
+
if (seen.has(c)) errors.push(`cards lists "${c}" twice`);
|
|
408
|
+
seen.add(c);
|
|
409
|
+
}
|
|
410
|
+
}
|
|
411
|
+
}
|
|
412
|
+
if (plan.fixAttempts !== undefined && !(Number.isInteger(plan.fixAttempts) && plan.fixAttempts >= 0)) errors.push("fixAttempts must be a non-negative integer when present");
|
|
413
|
+
errors.push(...crewErrors(plan.crew, "crew"));
|
|
414
|
+
// The swarm opt-in (card swarm-lead): a lead splits each full-shape task into worker briefs inside its owned files.
|
|
415
|
+
if (plan.swarm !== undefined && typeof plan.swarm !== "boolean") errors.push("swarm must be true or false when present");
|
|
416
|
+
// Card swarm-topology (candidate 3): the deterministic per-worker check before the merge, behind the swarm opt-in.
|
|
417
|
+
if (plan.workerCheck !== undefined && typeof plan.workerCheck !== "boolean") errors.push("workerCheck must be true or false when present");
|
|
418
|
+
if (plan.gatesOpened !== undefined && (!Array.isArray(plan.gatesOpened) || !plan.gatesOpened.every((n) => Number.isInteger(n) && n >= 0))) errors.push("gatesOpened must be an array of level indexes when present");
|
|
419
|
+
if (plan.budget !== undefined) {
|
|
420
|
+
if (plan.budget === null || Array.isArray(plan.budget) || typeof plan.budget !== "object" || Object.getPrototypeOf(plan.budget) !== Object.prototype) {
|
|
421
|
+
errors.push("budget must be an object with agents, tokens, and/or wallMinutes when present");
|
|
422
|
+
} else {
|
|
423
|
+
if (plan.budget.agents !== undefined) {
|
|
424
|
+
if (!(Number.isInteger(plan.budget.agents) && plan.budget.agents > 0)) {
|
|
425
|
+
errors.push("budget.agents must be a positive integer when present");
|
|
426
|
+
}
|
|
427
|
+
}
|
|
428
|
+
if (plan.budget.tokens !== undefined) {
|
|
429
|
+
if (!(Number.isInteger(plan.budget.tokens) && plan.budget.tokens > 0)) {
|
|
430
|
+
errors.push("budget.tokens must be a positive integer when present");
|
|
431
|
+
}
|
|
432
|
+
}
|
|
433
|
+
if (plan.budget.wallMinutes !== undefined) {
|
|
434
|
+
if (!(typeof plan.budget.wallMinutes === "number" && Number.isFinite(plan.budget.wallMinutes) && plan.budget.wallMinutes > 0)) {
|
|
435
|
+
errors.push("budget.wallMinutes must be a positive number when present");
|
|
436
|
+
}
|
|
437
|
+
}
|
|
438
|
+
for (const key of Object.keys(plan.budget)) {
|
|
439
|
+
if (!["agents", "tokens", "wallMinutes"].includes(key)) {
|
|
440
|
+
errors.push(`budget.${key} is not a budget measure (agents, tokens, wallMinutes)`);
|
|
441
|
+
}
|
|
442
|
+
}
|
|
443
|
+
}
|
|
444
|
+
}
|
|
445
|
+
if (plan.adversary !== undefined && plan.adversary !== false) {
|
|
446
|
+
if (!plan.adversary || typeof plan.adversary !== "object" || Array.isArray(plan.adversary)) errors.push("adversary must be false or an object when present");
|
|
447
|
+
else if (plan.adversary.fallback !== undefined && plan.adversary.fallback !== false) {
|
|
448
|
+
const fb = plan.adversary.fallback;
|
|
449
|
+
if (!fb || typeof fb !== "object" || !isModelName(fb.model)) errors.push("adversary.fallback must be false or { model, effort? } with a model name (opus, sonnet, haiku, fable, inherit, or a model id)");
|
|
450
|
+
else if (fb.effort !== undefined && !EFFORTS.includes(fb.effort)) errors.push(`adversary.fallback.effort must be one of ${EFFORTS.join(", ")}`);
|
|
451
|
+
}
|
|
452
|
+
}
|
|
453
|
+
if (!Array.isArray(plan.tasks) || plan.tasks.length === 0) {
|
|
454
|
+
errors.push("tasks must be a non-empty array");
|
|
455
|
+
return errors;
|
|
456
|
+
}
|
|
457
|
+
|
|
458
|
+
const ids = new Set();
|
|
459
|
+
const owners = new Map(); // file -> taskId
|
|
460
|
+
for (const [i, t] of plan.tasks.entries()) {
|
|
461
|
+
const where = `tasks[${i}]`;
|
|
462
|
+
if (!t || typeof t !== "object") {
|
|
463
|
+
errors.push(`${where} must be an object`);
|
|
464
|
+
continue;
|
|
465
|
+
}
|
|
466
|
+
if (typeof t.id !== "string" || !/^[a-z0-9][a-z0-9-]{0,39}$/.test(t.id)) errors.push(`${where}.id must match ^[a-z0-9][a-z0-9-]{0,39}$`);
|
|
467
|
+
else if (ids.has(t.id)) errors.push(`${where}.id "${t.id}" is duplicated`);
|
|
468
|
+
else ids.add(t.id);
|
|
469
|
+
if (typeof t.title !== "string" || !t.title.trim()) errors.push(`${where}.title is required`);
|
|
470
|
+
if (typeof t.spec !== "string" || t.spec.trim().length < 20) errors.push(`${where}.spec must describe the task (at least 20 characters)`);
|
|
471
|
+
if (!Array.isArray(t.files) || t.files.length === 0) errors.push(`${where}.files must list at least one owned file`);
|
|
472
|
+
else {
|
|
473
|
+
for (const f of t.files) {
|
|
474
|
+
if (!isRelPath(f)) errors.push(`${where}.files contains an invalid path: ${JSON.stringify(f)}`);
|
|
475
|
+
}
|
|
476
|
+
}
|
|
477
|
+
if (t.verify !== undefined && (typeof t.verify !== "string" || !t.verify.trim())) errors.push(`${where}.verify must be a command string when present`);
|
|
478
|
+
if (t.dependsOn !== undefined && (!Array.isArray(t.dependsOn) || !t.dependsOn.every((d) => typeof d === "string"))) errors.push(`${where}.dependsOn must be an array of task ids`);
|
|
479
|
+
if (t.reuse !== undefined && (typeof t.reuse !== "string" || !t.reuse.trim())) errors.push(`${where}.reuse must be a non-empty branch name when present`);
|
|
480
|
+
// Size decides the pipeline shape (decision 0005): S runs implement and one focused check, with the adversary once
|
|
481
|
+
// per level on the integration branch; M and L keep the full shape. Unsized means full.
|
|
482
|
+
if (t.size !== undefined && !SIZES.includes(t.size)) errors.push(`${where}.size must be one of ${SIZES.join(", ")} when present`);
|
|
483
|
+
// A human gate (decision 0002 #2) pauses the run after this task's level has integrated, until the user opens it.
|
|
484
|
+
if (t.gate !== undefined && !GATES.includes(t.gate)) errors.push(`${where}.gate must be one of ${GATES.join(", ")} when present`);
|
|
485
|
+
// Model tier fields are optional strings; their values are checked against CLAUDE.md by lib/models.mjs.
|
|
486
|
+
for (const k of ["model", "effort", "tier"]) {
|
|
487
|
+
if (t[k] !== undefined && (typeof t[k] !== "string" || !t[k].trim())) errors.push(`${where}.${k} must be a non-empty string when present`);
|
|
488
|
+
}
|
|
489
|
+
errors.push(...crewErrors(t.crew, `${where}.crew`));
|
|
490
|
+
if (t.card !== undefined && (typeof t.card !== "string" || !t.card.trim())) errors.push(`${where}.card must be a non-empty string when present`);
|
|
491
|
+
else if (cards && t.card === undefined) errors.push(`${where}.card is required when the plan has cards`);
|
|
492
|
+
else if (cards && !cards.includes(t.card)) errors.push(`${where}.card "${t.card}" is not one of the plan's cards (${cards.join(", ")})`);
|
|
493
|
+
}
|
|
494
|
+
|
|
495
|
+
// Dependencies must exist and be acyclic.
|
|
496
|
+
for (const t of plan.tasks) {
|
|
497
|
+
for (const d of t.dependsOn || []) {
|
|
498
|
+
if (!ids.has(d)) errors.push(`task "${t.id}" depends on unknown task "${d}"`);
|
|
499
|
+
if (d === t.id) errors.push(`task "${t.id}" depends on itself`);
|
|
500
|
+
}
|
|
501
|
+
}
|
|
502
|
+
if (errors.length === 0) {
|
|
503
|
+
try {
|
|
504
|
+
levelize(plan.tasks);
|
|
505
|
+
} catch (e) {
|
|
506
|
+
errors.push(e.message);
|
|
507
|
+
}
|
|
508
|
+
}
|
|
509
|
+
|
|
510
|
+
// One owner per file, unless the tasks are ordered by a dependency edge.
|
|
511
|
+
if (errors.length === 0) {
|
|
512
|
+
const order = levelOrder(plan.tasks);
|
|
513
|
+
for (const t of plan.tasks) {
|
|
514
|
+
for (const f of t.files) {
|
|
515
|
+
const prev = owners.get(f);
|
|
516
|
+
if (prev && prev !== t.id) {
|
|
517
|
+
const a = plan.tasks.find((x) => x.id === prev);
|
|
518
|
+
const related = (t.dependsOn || []).includes(prev) || (a.dependsOn || []).includes(t.id) || order.get(prev) !== order.get(t.id);
|
|
519
|
+
if (!related) errors.push(`file "${f}" is owned by both "${prev}" and "${t.id}" with no dependency between them`);
|
|
520
|
+
}
|
|
521
|
+
owners.set(f, t.id);
|
|
522
|
+
}
|
|
523
|
+
}
|
|
524
|
+
}
|
|
525
|
+
// A task that names another task's file in its spec or verify command consumes what that task builds: it must
|
|
526
|
+
// depend on it, so it lands in a later level (card planner-producer-level, 2026-09-07: the adversary judges a task
|
|
527
|
+
// branch against the whole plan goal, and a consumer in the producer's level blocked every run). A dependency in
|
|
528
|
+
// either direction, direct or transitive, satisfies the rule: a producer may describe its consumer.
|
|
529
|
+
if (errors.length === 0) {
|
|
530
|
+
const byId = new Map(plan.tasks.map((t) => [t.id, t]));
|
|
531
|
+
const ancestors = (id, seen = new Set()) => {
|
|
532
|
+
for (const d of byId.get(id).dependsOn || []) if (!seen.has(d)) { seen.add(d); ancestors(d, seen); }
|
|
533
|
+
return seen;
|
|
534
|
+
};
|
|
535
|
+
plan.tasks.forEach((t, i) => {
|
|
536
|
+
const text = `${t.spec} ${t.verify || ""}`;
|
|
537
|
+
const mine = new Set(t.files);
|
|
538
|
+
const up = ancestors(t.id);
|
|
539
|
+
const reported = new Set();
|
|
540
|
+
for (const other of plan.tasks) {
|
|
541
|
+
if (other.id === t.id || reported.has(other.id) || up.has(other.id) || ancestors(other.id).has(t.id)) continue;
|
|
542
|
+
const named = other.files.find((f) => !mine.has(f) && text.includes(f));
|
|
543
|
+
if (named) {
|
|
544
|
+
reported.add(other.id);
|
|
545
|
+
errors.push(`task "${t.id}" names "${named}", owned by "${other.id}", but does not depend on it; add "${other.id}" to tasks[${i}].dependsOn so "${t.id}" lands in a later level`);
|
|
546
|
+
}
|
|
547
|
+
}
|
|
548
|
+
});
|
|
549
|
+
}
|
|
550
|
+
return errors;
|
|
551
|
+
}
|
|
552
|
+
|
|
553
|
+
// Warnings (not errors) about the plan's shape: too many tasks for one plan, or several tasks for one file.
|
|
554
|
+
export function planWarnings(plan) {
|
|
555
|
+
const out = [];
|
|
556
|
+
if (!plan || !Array.isArray(plan.tasks)) return out;
|
|
557
|
+
const n = plan.tasks.length;
|
|
558
|
+
if (n > TASK_CAP) out.push(`${n} tasks: never more than eight in one plan; split the work into two cards or merge tasks that share a file group`);
|
|
559
|
+
else if (n > FEATURE_TASKS) out.push(`${n} tasks: a feature is three to five; merge tasks that share a file group or split the plan`);
|
|
560
|
+
const files = new Set(plan.tasks.flatMap((t) => (Array.isArray(t.files) ? t.files : [])));
|
|
561
|
+
if (n > 1 && files.size === 1) out.push(`${n} tasks own the same single file (${[...files][0]}): single-file work is one task`);
|
|
562
|
+
return out;
|
|
563
|
+
}
|
|
564
|
+
|
|
565
|
+
// Level indexes that end at a human gate: any task in the level carries gate: "human".
|
|
566
|
+
export function humanGateLevels(plan) {
|
|
567
|
+
return levelize(plan.tasks).map((lvl, i) => (lvl.some((t) => t.gate === "human") ? i : -1)).filter((i) => i >= 0);
|
|
568
|
+
}
|
|
569
|
+
|
|
570
|
+
// Groups tasks into dependency levels. Level 0 has no deps; level N depends only on levels < N.
|
|
571
|
+
export function levelize(tasks) {
|
|
572
|
+
const byId = new Map(tasks.map((t) => [t.id, t]));
|
|
573
|
+
const level = new Map();
|
|
574
|
+
const visiting = new Set();
|
|
575
|
+
function depth(id, chain) {
|
|
576
|
+
if (level.has(id)) return level.get(id);
|
|
577
|
+
if (visiting.has(id)) throw new Error(`dependency cycle: ${[...chain, id].join(" -> ")}`);
|
|
578
|
+
visiting.add(id);
|
|
579
|
+
const t = byId.get(id);
|
|
580
|
+
let d = 0;
|
|
581
|
+
for (const dep of t.dependsOn || []) d = Math.max(d, depth(dep, [...chain, id]) + 1);
|
|
582
|
+
visiting.delete(id);
|
|
583
|
+
level.set(id, d);
|
|
584
|
+
return d;
|
|
585
|
+
}
|
|
586
|
+
for (const t of tasks) depth(t.id, []);
|
|
587
|
+
const max = Math.max(...level.values());
|
|
588
|
+
const levels = [];
|
|
589
|
+
for (let i = 0; i <= max; i++) levels.push(tasks.filter((t) => level.get(t.id) === i));
|
|
590
|
+
return levels;
|
|
591
|
+
}
|
|
592
|
+
|
|
593
|
+
function levelOrder(tasks) {
|
|
594
|
+
const m = new Map();
|
|
595
|
+
levelize(tasks).forEach((lvl, i) => lvl.forEach((t) => m.set(t.id, i)));
|
|
596
|
+
return m;
|
|
597
|
+
}
|
|
598
|
+
|
|
599
|
+
// The branch the workflow integrates into. Must stay identical to the expression in
|
|
600
|
+
// workflows/doug-implement.js (the workflow cannot import this file); tests/template.test.mjs checks that.
|
|
601
|
+
export function integrationBranchFor(plan) {
|
|
602
|
+
return plan.integrationBranch || `doug/${plan.tasks.map((t) => t.id).join("-").slice(0, 40)}`;
|
|
603
|
+
}
|
|
604
|
+
|
|
605
|
+
// Several cards in one plan (card parallel-cards). `drafts` is [{ card, plan }], one validated draft per card;
|
|
606
|
+
// `order` (card ids, board order) decides the sequence when given, else the order passed. The result is one draft
|
|
607
|
+
// plan whose tasks carry their card: level k of the batch is the union of each card's level k, since every draft's
|
|
608
|
+
// own dependsOn edges are kept and nothing else moves a task. A file two cards own is not refused: the later card's
|
|
609
|
+
// task gains a dependsOn edge to every task of the earlier card that owns it, so the file is edited in sequence with
|
|
610
|
+
// each card keeping its own spec, verify, and acceptance; `sequenced` lists each edge added. A task id a later card
|
|
611
|
+
// reuses is renamed <card>-<id> (its own dependsOn rewritten; `renamed` lists it). Only a cycle across cards is
|
|
612
|
+
// refused, by card name. Plan-level settings (install, baseBranch, adversary, budget, fixAttempts) come from the
|
|
613
|
+
// first draft that sets them; a later draft that disagrees is named in `notes`.
|
|
614
|
+
export function mergePlans(drafts, { order = null } = {}) {
|
|
615
|
+
if (!Array.isArray(drafts) || drafts.length < 2) throw new Error("a batch needs at least two cards");
|
|
616
|
+
const ids = drafts.map((d) => d.card);
|
|
617
|
+
for (const [i, id] of ids.entries()) if (ids.indexOf(id) !== i) throw new Error(`card "${id}" is listed twice`);
|
|
618
|
+
let ordered = drafts;
|
|
619
|
+
if (Array.isArray(order)) {
|
|
620
|
+
const rank = (id) => (order.includes(id) ? order.indexOf(id) : order.length + ids.indexOf(id));
|
|
621
|
+
ordered = [...drafts].sort((a, b) => rank(a.card) - rank(b.card));
|
|
622
|
+
}
|
|
623
|
+
// A draft may depend on a task of another draft in the batch (a hand edit, or a deliberate sequence); only a
|
|
624
|
+
// dependency no draft has is unknown here. Whether such edges form a cycle is checked on the merged tasks below.
|
|
625
|
+
const elsewhere = new Set(ordered.flatMap((d) => (Array.isArray(d.plan && d.plan.tasks) ? d.plan.tasks : []).map((t) => t && t.id)));
|
|
626
|
+
for (const d of ordered) {
|
|
627
|
+
const errors = validatePlan(d.plan).filter((e) => {
|
|
628
|
+
const m = /^task "[^"]+" depends on unknown task "([^"]+)"$/.exec(e);
|
|
629
|
+
return !(m && elsewhere.has(m[1]));
|
|
630
|
+
});
|
|
631
|
+
if (errors.length) throw new Error(`draft for card ${d.card} is invalid:\n${errors.map((e) => " - " + e).join("\n")}`);
|
|
632
|
+
}
|
|
633
|
+
|
|
634
|
+
const cards = ordered.map((d) => d.card);
|
|
635
|
+
const taken = new Set();
|
|
636
|
+
const renamed = [];
|
|
637
|
+
const tasks = [];
|
|
638
|
+
const cardOf = new Map(); // merged task id -> card
|
|
639
|
+
for (const d of ordered) {
|
|
640
|
+
const rename = new Map();
|
|
641
|
+
for (const t of d.plan.tasks) {
|
|
642
|
+
let id = t.id;
|
|
643
|
+
if (taken.has(id)) {
|
|
644
|
+
id = `${d.card}-${t.id}`;
|
|
645
|
+
renamed.push({ card: d.card, from: t.id, to: id });
|
|
646
|
+
}
|
|
647
|
+
rename.set(t.id, id);
|
|
648
|
+
taken.add(id);
|
|
649
|
+
}
|
|
650
|
+
for (const t of d.plan.tasks) {
|
|
651
|
+
const next = { ...t, id: rename.get(t.id), card: d.card };
|
|
652
|
+
if (Array.isArray(t.dependsOn)) next.dependsOn = t.dependsOn.map((x) => rename.get(x) || x);
|
|
653
|
+
tasks.push(next);
|
|
654
|
+
cardOf.set(next.id, d.card);
|
|
655
|
+
}
|
|
656
|
+
}
|
|
657
|
+
|
|
658
|
+
// Sequence overlapping files: later card's task after every earlier-card task that owns the file.
|
|
659
|
+
const sequenced = [];
|
|
660
|
+
const owners = new Map(); // file -> [{ card, task }] in merge order
|
|
661
|
+
for (const t of tasks) {
|
|
662
|
+
for (const f of t.files) {
|
|
663
|
+
const prev = owners.get(f) || [];
|
|
664
|
+
for (const p of prev) {
|
|
665
|
+
if (p.card === t.card) continue;
|
|
666
|
+
const deps = Array.isArray(t.dependsOn) ? t.dependsOn : [];
|
|
667
|
+
if (!deps.includes(p.task)) t.dependsOn = [...deps, p.task];
|
|
668
|
+
sequenced.push({ file: f, after: { card: p.card, task: p.task }, task: { card: t.card, task: t.id } });
|
|
669
|
+
}
|
|
670
|
+
owners.set(f, [...prev, { card: t.card, task: t.id }]);
|
|
671
|
+
}
|
|
672
|
+
}
|
|
673
|
+
|
|
674
|
+
try {
|
|
675
|
+
levelize(tasks);
|
|
676
|
+
} catch (e) {
|
|
677
|
+
const m = /^dependency cycle: (.*)$/.exec(e.message);
|
|
678
|
+
if (!m) throw e;
|
|
679
|
+
const chain = m[1].split(" -> ");
|
|
680
|
+
const involved = [...new Set(chain.map((id) => cardOf.get(id)).filter(Boolean))];
|
|
681
|
+
const named = involved.length > 1 ? `across cards ${involved.slice(0, -1).join(", ")} and ${involved[involved.length - 1]}` : `within card ${involved[0]}`;
|
|
682
|
+
throw new Error(`dependency cycle ${named}: ${m[1]}`);
|
|
683
|
+
}
|
|
684
|
+
|
|
685
|
+
const first = ordered[0].plan;
|
|
686
|
+
const plan = {
|
|
687
|
+
version: 1,
|
|
688
|
+
title: `${cards.length} cards: ${cards.join(", ")}`,
|
|
689
|
+
goal: ordered.map((d) => `${d.card}: ${d.plan.goal}`).join("\n\n"),
|
|
690
|
+
status: "draft",
|
|
691
|
+
cards,
|
|
692
|
+
};
|
|
693
|
+
const notes = [];
|
|
694
|
+
for (const key of ["baseBranch", "install", "adversary", "budget", "fixAttempts"]) {
|
|
695
|
+
let from = null;
|
|
696
|
+
for (const d of ordered) {
|
|
697
|
+
if (d.plan[key] === undefined) continue;
|
|
698
|
+
if (from === null) {
|
|
699
|
+
plan[key] = d.plan[key];
|
|
700
|
+
from = d.card;
|
|
701
|
+
} else if (JSON.stringify(d.plan[key]) !== JSON.stringify(plan[key])) {
|
|
702
|
+
notes.push(`${key}: kept ${JSON.stringify(plan[key])} from ${from}; ${d.card} asked for ${JSON.stringify(d.plan[key])}`);
|
|
703
|
+
}
|
|
704
|
+
}
|
|
705
|
+
}
|
|
706
|
+
plan.acceptance = ordered.flatMap((d) => d.plan.acceptance.map((a) => (typeof a === "string" ? `[${d.card}] ${a}` : { ...a, text: `[${d.card}] ${a.text}` })));
|
|
707
|
+
plan.verify = [...new Set(ordered.flatMap((d) => d.plan.verify || []))];
|
|
708
|
+
plan.tasks = tasks;
|
|
709
|
+
return { plan, sequenced, renamed, notes };
|
|
710
|
+
}
|
|
711
|
+
|
|
712
|
+
export function setStatus(plan, status, at) {
|
|
713
|
+
if (!STATUSES.includes(status)) throw new Error(`invalid status ${status}`);
|
|
714
|
+
const next = { ...plan, status };
|
|
715
|
+
if (status === "approved") next.approvedAt = at;
|
|
716
|
+
if (status === "done") next.doneAt = at;
|
|
717
|
+
return next;
|
|
718
|
+
}
|
|
719
|
+
|
|
720
|
+
// Text summary for humans, used by `plan show` and the approval skill.
|
|
721
|
+
export function renderPlan(plan) {
|
|
722
|
+
const out = [];
|
|
723
|
+
out.push(`${plan.title} [${plan.status}]`);
|
|
724
|
+
if (typeof plan.card === "string" && plan.card.trim()) out.push(`Card: ${plan.card}`);
|
|
725
|
+
if (Array.isArray(plan.cards) && plan.cards.length) out.push(`Cards: ${plan.cards.join(", ")}`);
|
|
726
|
+
out.push(`Goal: ${plan.goal}`);
|
|
727
|
+
out.push("");
|
|
728
|
+
out.push("Acceptance:");
|
|
729
|
+
for (const { text, command } of acceptanceEntries(plan.acceptance)) {
|
|
730
|
+
out.push(` - ${text}`);
|
|
731
|
+
if (command) out.push(` $ ${command}`);
|
|
732
|
+
}
|
|
733
|
+
if (plan.verify && plan.verify.length) {
|
|
734
|
+
out.push("Verify after integration:");
|
|
735
|
+
for (const v of plan.verify) out.push(` $ ${v}`);
|
|
736
|
+
}
|
|
737
|
+
out.push(`Fix attempts per blocked task: ${plan.fixAttempts === undefined ? "5 (default)" : plan.fixAttempts}`);
|
|
738
|
+
const agents = plan.budget?.agents ?? DEFAULT_BUDGET.agents;
|
|
739
|
+
const tokens = plan.budget?.tokens ?? DEFAULT_BUDGET.tokens;
|
|
740
|
+
const wallMinutes = plan.budget?.wallMinutes ?? DEFAULT_BUDGET.wallMinutes;
|
|
741
|
+
const agentsStr = plan.budget?.agents !== undefined ? `${agents}` : `${agents} (default)`;
|
|
742
|
+
const tokensStr = plan.budget?.tokens !== undefined ? `${tokens}` : `${tokens} (default)`;
|
|
743
|
+
const wallStr = plan.budget?.wallMinutes !== undefined ? `${wallMinutes} min` : `${wallMinutes} min (default)`;
|
|
744
|
+
out.push(`Budget per task: agents ${agentsStr}, tokens ${tokensStr}, wall ${wallStr}`);
|
|
745
|
+
if (plan.crew && typeof plan.crew === "object") out.push(`Crew per task: ${CREW_ROLES.map((k) => `${k} ${crewOf(plan, null)[k]}`).join(", ")} (a task's own crew overrides)`);
|
|
746
|
+
if (plan.swarm === true) out.push("Swarm: on (a lead on the lead row splits each full-shape task into worker briefs inside its owned files; workers on the worker row implement them in parallel; the lead merges before the checks)");
|
|
747
|
+
if (plan.workerCheck === true) out.push("Worker check: on (before the merge the workflow checks each swarm worker's filesTouched against its brief and that it committed; a failure is a block the lead may re-brief once)");
|
|
748
|
+
if (plan.adversary === false) out.push("Adversary: off");
|
|
749
|
+
else {
|
|
750
|
+
const fb = plan.adversary && plan.adversary.fallback;
|
|
751
|
+
const cmd = plan.adversary && typeof plan.adversary.command === "string" && plan.adversary.command.trim() ? plan.adversary.command : "codex-review (or .doug/config.json commands.adversary)";
|
|
752
|
+
const fallback = fb === false ? "off; a review that does not run blocks" : `Claude adversary on ${(fb && fb.model) || "opus"} / ${(fb && fb.effort) || "high"}`;
|
|
753
|
+
out.push(`Adversary: ${cmd}; if it cannot run: ${fallback}`);
|
|
754
|
+
}
|
|
755
|
+
out.push("");
|
|
756
|
+
const levels = levelize(plan.tasks);
|
|
757
|
+
levels.forEach((lvl, i) => {
|
|
758
|
+
out.push(`Level ${i}${i === 0 ? " (no dependencies, runs in parallel)" : ""}:`);
|
|
759
|
+
for (const t of lvl) {
|
|
760
|
+
out.push(` ${t.id}: ${t.title}`);
|
|
761
|
+
if (typeof t.card === "string" && t.card) out.push(` card: ${t.card}`);
|
|
762
|
+
out.push(` owns: ${t.files.join(", ")}`);
|
|
763
|
+
if (t.dependsOn && t.dependsOn.length) out.push(` after: ${t.dependsOn.join(", ")}`);
|
|
764
|
+
if (t.verify) out.push(` verify: ${t.verify}`);
|
|
765
|
+
if (t.reuse) out.push(` reuse: ${t.reuse} (implementer skipped; verify, review, adversary run again)`);
|
|
766
|
+
out.push(` size: ${t.size || "unsized"} (${t.size === "S" ? "implement, one focused check; the adversary reviews the level on the integration branch" : "full shape: implement, verify and review together, adversary"})`);
|
|
767
|
+
const crew = crewOf(plan, t);
|
|
768
|
+
if (CREW_ROLES.some((k) => crew[k] > 1)) out.push(` crew: ${CREW_ROLES.filter((k) => crew[k] > 1).map((k) => `${k} ${crew[k]}`).join(", ")}${t.size === "S" ? " (a size-S task runs one focused check; the crew applies to the full shape)" : ""}`);
|
|
769
|
+
if (t.gate === "human") out.push(` gate: human (${(plan.gatesOpened || []).includes(i) ? "opened; the run continues past this level" : `the run pauses after level ${i} integrates; open it with plan.mjs gate open ${i} and resume the run with its id`})`);
|
|
770
|
+
}
|
|
771
|
+
});
|
|
772
|
+
return out.join("\n");
|
|
773
|
+
}
|