@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,840 @@
|
|
|
1
|
+
// The development board (.doug/board.json, with docs/board.json read as a fallback) as the /doug-next
|
|
2
|
+
// skill sees it: pick the next Ready card, move a card, reorder a card within its column, add a card,
|
|
3
|
+
// validate the record, and append a run entry to the live-run log. Pure functions plus load/save/append.
|
|
4
|
+
// Whichever record file exists is the one written; a repo with neither gets .doug/board.json.
|
|
5
|
+
// The board page is a view of the same file, rendered by `doug board build` and served by `doug board serve`.
|
|
6
|
+
|
|
7
|
+
import { existsSync, mkdirSync, readFileSync, renameSync, writeFileSync, appendFileSync, copyFileSync } from "node:fs";
|
|
8
|
+
import { dirname, join } from "node:path";
|
|
9
|
+
|
|
10
|
+
export const BOARD_RELPATH = ".doug/board.json";
|
|
11
|
+
export const FALLBACK_RELPATH = "docs/board.json";
|
|
12
|
+
export const LIVE_RUNS_RELPATH = "docs/live-runs.md";
|
|
13
|
+
export const RESEARCH_NOTE_DIR = ".doug/.state/research";
|
|
14
|
+
export const RESEARCH_DOCS_DIR = "docs/research";
|
|
15
|
+
export const READY = "ready";
|
|
16
|
+
export const FLOW = "flow";
|
|
17
|
+
export const DONE = "done";
|
|
18
|
+
export const SIZES = ["S", "M", "L"];
|
|
19
|
+
export const TRACKS = ["flow", "hand"];
|
|
20
|
+
export const CLASSES = ["tests-only", "prose", "gate-script", "code", "docs", "eval", "decision"];
|
|
21
|
+
export const DEFAULT_TAGS = ["bug", "feature", "chore", "docs", "refactor", "spike"];
|
|
22
|
+
|
|
23
|
+
export const DEFAULT_COLUMNS = [
|
|
24
|
+
{ id: "decide", title: "Decide", hint: "needs a decision from the owner before it can be planned" },
|
|
25
|
+
{ id: "backlog", title: "Backlog", hint: "planned work, not yet specced tightly enough for a plan" },
|
|
26
|
+
{ id: "ready", title: "Ready", hint: "has a /goal statement; a doug flow can start it" },
|
|
27
|
+
{ id: "flow", title: "In flow", hint: "plan approved, doug-implement running or awaiting integration; keep to two at a time" },
|
|
28
|
+
{ id: "done", title: "Done", hint: "merged, gate green, documented" },
|
|
29
|
+
];
|
|
30
|
+
|
|
31
|
+
function today() {
|
|
32
|
+
return new Date().toISOString().slice(0, 10);
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
// The record path: .doug/board.json if present, else docs/board.json if present, else .doug/board.json.
|
|
36
|
+
export function boardPath(dir) {
|
|
37
|
+
const canonical = join(dir, BOARD_RELPATH);
|
|
38
|
+
if (existsSync(canonical)) return canonical;
|
|
39
|
+
const fallback = join(dir, FALLBACK_RELPATH);
|
|
40
|
+
if (existsSync(fallback)) return fallback;
|
|
41
|
+
return canonical;
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
function relpathOf(dir, file) {
|
|
45
|
+
return file === join(dir, FALLBACK_RELPATH) ? FALLBACK_RELPATH : BOARD_RELPATH;
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
export function newBoard({ date } = {}) {
|
|
49
|
+
return { version: 1, updated: date || today(), columns: DEFAULT_COLUMNS.map((c) => ({ ...c })), components: [], tags: [...DEFAULT_TAGS], cards: [] };
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
// The tag vocabulary a board uses: its own `tags` array when it has one (an installed project's board can
|
|
53
|
+
// pick its own set by editing the record), else DEFAULT_TAGS. .doug/board.json is not migrated by this
|
|
54
|
+
// function; a record with no `tags` field falls back here rather than being rewritten.
|
|
55
|
+
export function boardTags(board) {
|
|
56
|
+
return board && Array.isArray(board.tags) && board.tags.every((t) => typeof t === "string") ? board.tags : DEFAULT_TAGS;
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
function isNonEmptyString(v) {
|
|
60
|
+
return typeof v === "string" && v.length > 0;
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
// A value as it appears in an error message; never throws (Symbol, BigInt, functions, objects included).
|
|
64
|
+
function show(v) {
|
|
65
|
+
if (typeof v === "string") return v;
|
|
66
|
+
try {
|
|
67
|
+
return String(v);
|
|
68
|
+
} catch {
|
|
69
|
+
return typeof v;
|
|
70
|
+
}
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
// Every problem with the board as a list of messages; [] when valid. Never throws: when the value itself
|
|
74
|
+
// cannot be inspected (a throwing getter, a revoked Proxy) the single message says so.
|
|
75
|
+
export function validateBoard(board) {
|
|
76
|
+
try {
|
|
77
|
+
return inspectBoard(board);
|
|
78
|
+
} catch (err) {
|
|
79
|
+
try {
|
|
80
|
+
const message = err && err.message ? err.message : String(err);
|
|
81
|
+
return [`board could not be inspected: ${show(message)}`];
|
|
82
|
+
} catch {
|
|
83
|
+
return ["board could not be inspected"];
|
|
84
|
+
}
|
|
85
|
+
}
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
function inspectBoard(board) {
|
|
89
|
+
const errors = [];
|
|
90
|
+
if (!board || typeof board !== "object" || Array.isArray(board)) return ["board is not an object"];
|
|
91
|
+
|
|
92
|
+
const columnIds = [];
|
|
93
|
+
if (!Array.isArray(board.columns) || board.columns.length === 0) {
|
|
94
|
+
errors.push("columns must be a non-empty array");
|
|
95
|
+
} else {
|
|
96
|
+
board.columns.forEach((col, i) => {
|
|
97
|
+
if (!col || typeof col !== "object" || Array.isArray(col)) return errors.push(`column at index ${i}: not an object`);
|
|
98
|
+
if (!isNonEmptyString(col.id)) errors.push(`column at index ${i}: missing id`);
|
|
99
|
+
else if (columnIds.includes(col.id)) errors.push(`duplicate column "${show(col.id)}"`);
|
|
100
|
+
else columnIds.push(col.id);
|
|
101
|
+
if (!isNonEmptyString(col.title)) errors.push(`column at index ${i}: missing title`);
|
|
102
|
+
if (col.hint !== undefined && typeof col.hint !== "string") errors.push(`column at index ${i}: hint must be a string`);
|
|
103
|
+
});
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
const components = Array.isArray(board.components) && board.components.every((c) => typeof c === "string") ? board.components : null;
|
|
107
|
+
if (!components) errors.push("components must be an array of strings");
|
|
108
|
+
|
|
109
|
+
if (board.tags !== undefined) {
|
|
110
|
+
if (!Array.isArray(board.tags) || !board.tags.every((t) => isNonEmptyString(t))) {
|
|
111
|
+
errors.push("tags must be an array of strings when present");
|
|
112
|
+
} else {
|
|
113
|
+
const seenTags = new Set();
|
|
114
|
+
for (const t of board.tags) {
|
|
115
|
+
if (seenTags.has(t)) errors.push(`duplicate tag "${t}"`);
|
|
116
|
+
seenTags.add(t);
|
|
117
|
+
}
|
|
118
|
+
}
|
|
119
|
+
}
|
|
120
|
+
const vocabulary = boardTags(board);
|
|
121
|
+
|
|
122
|
+
if (!Array.isArray(board.cards)) {
|
|
123
|
+
errors.push("cards must be an array");
|
|
124
|
+
return errors;
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
const ids = new Set();
|
|
128
|
+
const seen = new Set();
|
|
129
|
+
for (const c of board.cards) if (c && isNonEmptyString(c.id)) ids.add(c.id);
|
|
130
|
+
board.cards.forEach((card, i) => {
|
|
131
|
+
if (!card || typeof card !== "object" || Array.isArray(card)) return errors.push(`card at index ${i}: not an object`);
|
|
132
|
+
if (!isNonEmptyString(card.id)) return errors.push(`card at index ${i}: missing id`);
|
|
133
|
+
const id = card.id;
|
|
134
|
+
if (seen.has(id)) errors.push(`duplicate id "${id}"`);
|
|
135
|
+
seen.add(id);
|
|
136
|
+
if (!columnIds.includes(card.column)) errors.push(`card "${id}": unknown column "${show(card.column)}"; columns are ${columnIds.join(", ")}`);
|
|
137
|
+
if (card.component !== undefined && components && !components.includes(card.component)) {
|
|
138
|
+
errors.push(`card "${id}": unknown component "${show(card.component)}"; ${components.length ? `components are ${components.join(", ")}` : "no components are defined"}`);
|
|
139
|
+
}
|
|
140
|
+
if (!isNonEmptyString(card.title)) errors.push(`card "${id}": missing title`);
|
|
141
|
+
if (!isNonEmptyString(card.goal)) errors.push(`card "${id}": missing goal`);
|
|
142
|
+
if (card.size !== undefined && !SIZES.includes(card.size)) errors.push(`card "${id}": size must be S, M, or L`);
|
|
143
|
+
if (card.track !== undefined && !TRACKS.includes(card.track)) errors.push(`card "${id}": track must be flow or hand`);
|
|
144
|
+
if (card.class !== undefined && !CLASSES.includes(card.class)) errors.push(`card "${id}": class must be ${CLASSES.slice(0, -1).join(", ")}, or ${CLASSES[CLASSES.length - 1]}`);
|
|
145
|
+
if (card.deps !== undefined) {
|
|
146
|
+
if (!Array.isArray(card.deps) || !card.deps.every((d) => typeof d === "string")) errors.push(`card "${id}": deps must be an array of strings`);
|
|
147
|
+
else for (const d of card.deps) if (!ids.has(d)) errors.push(`card "${id}": unknown dep "${show(d)}"`);
|
|
148
|
+
}
|
|
149
|
+
if (card.source !== undefined && typeof card.source !== "string") errors.push(`card "${id}": source must be a string`);
|
|
150
|
+
if (card.tags !== undefined) {
|
|
151
|
+
if (!Array.isArray(card.tags) || !card.tags.every((t) => typeof t === "string")) {
|
|
152
|
+
errors.push(`card "${id}": tags must be an array of strings`);
|
|
153
|
+
} else {
|
|
154
|
+
const seenCardTags = new Set();
|
|
155
|
+
for (const t of card.tags) {
|
|
156
|
+
if (seenCardTags.has(t)) errors.push(`card "${id}": duplicate tag "${t}"`);
|
|
157
|
+
else if (!vocabulary.includes(t)) errors.push(`card "${id}": unknown tag "${show(t)}"; tags are ${vocabulary.join(", ")}`);
|
|
158
|
+
seenCardTags.add(t);
|
|
159
|
+
}
|
|
160
|
+
}
|
|
161
|
+
}
|
|
162
|
+
});
|
|
163
|
+
return errors;
|
|
164
|
+
}
|
|
165
|
+
|
|
166
|
+
function errorList(head, errors) {
|
|
167
|
+
return [head, ...errors.map((e) => `- ${e}`)].join("\n");
|
|
168
|
+
}
|
|
169
|
+
|
|
170
|
+
export function loadBoard(dir) {
|
|
171
|
+
const file = boardPath(dir);
|
|
172
|
+
if (!existsSync(file)) throw new Error(`no board at ${join(dir, BOARD_RELPATH)} (nor ${FALLBACK_RELPATH})`);
|
|
173
|
+
const raw = JSON.parse(readFileSync(file, "utf8"));
|
|
174
|
+
const errors = validateBoard(raw);
|
|
175
|
+
if (errors.length) throw new Error(errorList(`${relpathOf(dir, file)} is not a valid board:`, errors));
|
|
176
|
+
// artifactUrl is a retired field (card artifact-path-removal): a record still carrying it (any value,
|
|
177
|
+
// valid or not — validateBoard no longer checks it) loads fine, with the key dropped on the way out so
|
|
178
|
+
// the next saveBoard drops it too.
|
|
179
|
+
const { artifactUrl, ...board } = raw;
|
|
180
|
+
return board;
|
|
181
|
+
}
|
|
182
|
+
|
|
183
|
+
export function saveBoard(dir, board) {
|
|
184
|
+
const file = boardPath(dir);
|
|
185
|
+
mkdirSync(dirname(file), { recursive: true });
|
|
186
|
+
const tmp = file + "." + process.pid + ".tmp";
|
|
187
|
+
writeFileSync(tmp, JSON.stringify(board, null, 2) + "\n");
|
|
188
|
+
renameSync(tmp, file);
|
|
189
|
+
}
|
|
190
|
+
|
|
191
|
+
export function findCard(board, id) {
|
|
192
|
+
const card = board.cards.find((c) => c.id === id);
|
|
193
|
+
if (!card) throw new Error(`no card "${id}" on the board`);
|
|
194
|
+
return card;
|
|
195
|
+
}
|
|
196
|
+
|
|
197
|
+
// The first Ready card, in board order, whose dependencies are all Done. Ready cards that are waiting
|
|
198
|
+
// on something are reported so the user can see why they were skipped.
|
|
199
|
+
// The first Ready card on the given track whose deps are Done. A card on the hand track is built directly in the
|
|
200
|
+
// checkout (tests, gate, commit) by /core-next, never by /doug-next; a flow card the other way round. A Ready card on
|
|
201
|
+
// the other track is skipped with hand: true or flow: true so the skill can say so. Decision 0005.
|
|
202
|
+
// `tag`, when given, filters to Ready cards carrying it: a card without it is skipped SILENTLY (not pushed onto
|
|
203
|
+
// `skipped`, since a caller-requested filter should not print a "skipping" line per card it filters out); a `tag`
|
|
204
|
+
// outside boardTags(board) throws.
|
|
205
|
+
export function nextReadyCard(board, { track = "flow", tag } = {}) {
|
|
206
|
+
if (tag !== undefined && !boardTags(board).includes(tag)) throw new Error(`unknown tag "${tag}"; tags are ${boardTags(board).join(", ")}`);
|
|
207
|
+
const done = new Set(board.cards.filter((c) => c.column === DONE).map((c) => c.id));
|
|
208
|
+
const skipped = [];
|
|
209
|
+
for (const c of board.cards) {
|
|
210
|
+
if (c.column !== READY) continue;
|
|
211
|
+
if (tag !== undefined && !(c.tags || []).includes(tag)) continue;
|
|
212
|
+
const cardTrack = c.track === "hand" ? "hand" : "flow";
|
|
213
|
+
if (cardTrack !== track) {
|
|
214
|
+
skipped.push(cardTrack === "hand" ? { id: c.id, hand: true } : { id: c.id, flow: true });
|
|
215
|
+
continue;
|
|
216
|
+
}
|
|
217
|
+
const waitingOn = (c.deps || []).filter((d) => !done.has(d));
|
|
218
|
+
if (waitingOn.length) {
|
|
219
|
+
skipped.push({ id: c.id, waitingOn });
|
|
220
|
+
continue;
|
|
221
|
+
}
|
|
222
|
+
return { card: c, skipped };
|
|
223
|
+
}
|
|
224
|
+
return { card: null, skipped };
|
|
225
|
+
}
|
|
226
|
+
|
|
227
|
+
// The first `batch` runnable Ready cards on the track, in board order, with the skipped ones (card parallel-cards):
|
|
228
|
+
// what `board next --batch <n>` selects for one plan. Fewer than n when the board has fewer runnable cards.
|
|
229
|
+
// `tag`, like nextReadyCard's, filters to cards carrying it, silently, and throws on a tag outside boardTags(board).
|
|
230
|
+
export function nextReadyCards(board, { track = "flow", batch, tag } = {}) {
|
|
231
|
+
if (!(Number.isInteger(batch) && batch > 0)) throw new Error("batch must be a positive integer");
|
|
232
|
+
if (tag !== undefined && !boardTags(board).includes(tag)) throw new Error(`unknown tag "${tag}"; tags are ${boardTags(board).join(", ")}`);
|
|
233
|
+
const done = new Set(board.cards.filter((c) => c.column === DONE).map((c) => c.id));
|
|
234
|
+
const cards = [];
|
|
235
|
+
const skipped = [];
|
|
236
|
+
for (const c of board.cards) {
|
|
237
|
+
if (c.column !== READY || cards.length >= batch) continue;
|
|
238
|
+
if (tag !== undefined && !(c.tags || []).includes(tag)) continue;
|
|
239
|
+
const cardTrack = c.track === "hand" ? "hand" : "flow";
|
|
240
|
+
if (cardTrack !== track) {
|
|
241
|
+
skipped.push(cardTrack === "hand" ? { id: c.id, hand: true } : { id: c.id, flow: true });
|
|
242
|
+
continue;
|
|
243
|
+
}
|
|
244
|
+
const waitingOn = (c.deps || []).filter((d) => !done.has(d));
|
|
245
|
+
if (waitingOn.length) {
|
|
246
|
+
skipped.push({ id: c.id, waitingOn });
|
|
247
|
+
continue;
|
|
248
|
+
}
|
|
249
|
+
cards.push(c);
|
|
250
|
+
}
|
|
251
|
+
return { cards, skipped };
|
|
252
|
+
}
|
|
253
|
+
|
|
254
|
+
// Returns a new board with the card moved. `source` replaces the card's source line (the landing commit).
|
|
255
|
+
// A move keeps the card's place in the record (order is priority in Ready) except a move to Done, which appends
|
|
256
|
+
// it: Done is then chronological, and the page shows it newest first.
|
|
257
|
+
export function moveCard(board, id, column, { source, date } = {}) {
|
|
258
|
+
if (!board.columns.some((c) => c.id === column)) throw new Error(`unknown column "${column}"; columns are ${board.columns.map((c) => c.id).join(", ")}`);
|
|
259
|
+
findCard(board, id);
|
|
260
|
+
const moved = { ...findCard(board, id), column, ...(source ? { source } : {}) };
|
|
261
|
+
const cards = column === DONE ? [...board.cards.filter((c) => c.id !== id), moved] : board.cards.map((c) => (c.id === id ? moved : c));
|
|
262
|
+
return { ...board, updated: date || today(), cards };
|
|
263
|
+
}
|
|
264
|
+
|
|
265
|
+
// Returns a new board with the card reordered within its column. `placement` is exactly one of
|
|
266
|
+
// { before }, { after }, or { index }: before/after places the card relative to another card in the same
|
|
267
|
+
// column (a target elsewhere throws), index places it at that position (clamped to the column's last slot).
|
|
268
|
+
// The column's cards keep the same array slots they occupied; only the order within those slots changes.
|
|
269
|
+
// Never mutates the input; always returns a new board with the date stamped, even when the order is unchanged.
|
|
270
|
+
export function reorderCard(board, id, placement, { date } = {}) {
|
|
271
|
+
const keys = placement && typeof placement === "object" ? ["before", "after", "index"].filter((k) => Object.prototype.hasOwnProperty.call(placement, k)) : [];
|
|
272
|
+
if (!placement || typeof placement !== "object" || Array.isArray(placement) || keys.length !== 1) {
|
|
273
|
+
throw new Error("reorderCard needs exactly one of before, after, or index");
|
|
274
|
+
}
|
|
275
|
+
const card = findCard(board, id);
|
|
276
|
+
const column = card.column;
|
|
277
|
+
const indices = [];
|
|
278
|
+
board.cards.forEach((c, i) => {
|
|
279
|
+
if (c.column === column) indices.push(i);
|
|
280
|
+
});
|
|
281
|
+
const others = indices.map((i) => board.cards[i]).filter((c) => c.id !== id);
|
|
282
|
+
|
|
283
|
+
let insertAt;
|
|
284
|
+
if (keys[0] === "index") {
|
|
285
|
+
const idx = placement.index;
|
|
286
|
+
if (!(Number.isInteger(idx) && idx >= 0)) throw new Error("index must be a non-negative integer");
|
|
287
|
+
insertAt = Math.min(idx, others.length);
|
|
288
|
+
} else {
|
|
289
|
+
const key = keys[0];
|
|
290
|
+
const targetId = placement[key];
|
|
291
|
+
if (targetId === id) throw new Error(`cannot reorder "${id}" relative to itself`);
|
|
292
|
+
const target = findCard(board, targetId);
|
|
293
|
+
if (target.column !== column) {
|
|
294
|
+
throw new Error(`cannot reorder "${id}" ${key} "${targetId}": "${targetId}" is in ${target.column}, not ${column}`);
|
|
295
|
+
}
|
|
296
|
+
const targetPos = others.findIndex((c) => c.id === targetId);
|
|
297
|
+
insertAt = key === "before" ? targetPos : targetPos + 1;
|
|
298
|
+
}
|
|
299
|
+
|
|
300
|
+
const reordered = [...others.slice(0, insertAt), card, ...others.slice(insertAt)];
|
|
301
|
+
const cards = board.cards.slice();
|
|
302
|
+
indices.forEach((slot, i) => {
|
|
303
|
+
cards[slot] = reordered[i];
|
|
304
|
+
});
|
|
305
|
+
return { ...board, updated: date || today(), cards };
|
|
306
|
+
}
|
|
307
|
+
|
|
308
|
+
// Returns a new board with a card appended (column defaults to backlog, deps to []). The result is
|
|
309
|
+
// validated; a duplicate id, unknown column, component, or dep, or a missing field throws.
|
|
310
|
+
export function addCard(board, { id, title, goal, component, size, track, deps, column, tags, class: cls }, { date } = {}) {
|
|
311
|
+
const card = {
|
|
312
|
+
id,
|
|
313
|
+
column: column === undefined ? "backlog" : column,
|
|
314
|
+
...(component !== undefined ? { component } : {}),
|
|
315
|
+
title,
|
|
316
|
+
...(size !== undefined ? { size } : {}),
|
|
317
|
+
...(track !== undefined ? { track } : {}),
|
|
318
|
+
...(tags !== undefined ? { tags } : {}),
|
|
319
|
+
deps: deps === undefined ? [] : deps,
|
|
320
|
+
goal,
|
|
321
|
+
...(cls !== undefined ? { class: cls } : {}),
|
|
322
|
+
};
|
|
323
|
+
const next = { ...board, updated: date || today(), cards: [...(board.cards || []), card] };
|
|
324
|
+
const errors = validateBoard(next);
|
|
325
|
+
if (errors.length) throw new Error(errorList(`cannot add card "${id}":`, errors));
|
|
326
|
+
return next;
|
|
327
|
+
}
|
|
328
|
+
|
|
329
|
+
// Fields editCard accepts; the column is never one of them (move stays the way to change a column).
|
|
330
|
+
const EDITABLE_FIELDS = ["title", "goal", "size", "component", "track", "deps", "source", "tags", "class"];
|
|
331
|
+
|
|
332
|
+
// Returns a new board with the named card's fields changed (a new board, input untouched, `updated` stamped
|
|
333
|
+
// like moveCard) and `warnings`, an array of strings (empty when none). `fields` may carry any of
|
|
334
|
+
// EDITABLE_FIELDS; anything else, or an empty `fields`, is refused, as is editing the id. A card in `done`
|
|
335
|
+
// accepts only goal and source, and only with `force`; a card in `flow`, or named by `plan` (its `card`,
|
|
336
|
+
// `cards`, or a task's `card`), needs `force` only for goal or deps, since an approved plan's acceptance may
|
|
337
|
+
// no longer describe the card once those change. Either force path returns its sentence as a warning rather
|
|
338
|
+
// than refusing, so the caller (a CLI, printing `warning: <text>` on stderr) can decide what to do with it.
|
|
339
|
+
// The result is validated with validateBoard the same way addCard's result is, so a bad size, unknown
|
|
340
|
+
// component, unknown dep, empty title or goal, or non-string source is refused with the add-shaped message.
|
|
341
|
+
export function editCard(board, id, fields, { force = false, date, plan = null } = {}) {
|
|
342
|
+
const prefix = `cannot edit card "${id}":`;
|
|
343
|
+
const keys = fields && typeof fields === "object" && !Array.isArray(fields) ? Object.keys(fields) : [];
|
|
344
|
+
if (!keys.length) throw new Error(`${prefix} no field to change`);
|
|
345
|
+
if (Object.prototype.hasOwnProperty.call(fields, "id")) {
|
|
346
|
+
throw new Error(`${prefix} the id is not editable (deps, the plan's card field, commit messages, and docs/live-runs.md cite it)`);
|
|
347
|
+
}
|
|
348
|
+
for (const k of keys) {
|
|
349
|
+
if (!EDITABLE_FIELDS.includes(k)) throw new Error(`${prefix} unknown field "${k}"; editable fields are ${EDITABLE_FIELDS.join(", ")}`);
|
|
350
|
+
}
|
|
351
|
+
|
|
352
|
+
const card = findCard(board, id);
|
|
353
|
+
const warnings = [];
|
|
354
|
+
|
|
355
|
+
if (card.column === DONE) {
|
|
356
|
+
if (keys.some((k) => k !== "goal" && k !== "source")) throw new Error(`${prefix} a done card accepts only --goal and --source, with --force`);
|
|
357
|
+
const landed = card.source || "an unrecorded commit";
|
|
358
|
+
if (!force) throw new Error(`${prefix} a done card changes only with --force; it landed as ${landed}`);
|
|
359
|
+
warnings.push(`editing done card "${id}", landed as ${landed}: docs/live-runs.md and the landing commit still describe the old text`);
|
|
360
|
+
}
|
|
361
|
+
|
|
362
|
+
const planNamed = plan && (plan.card === id || (Array.isArray(plan.cards) && plan.cards.includes(id)) || (Array.isArray(plan.tasks) && plan.tasks.some((t) => t && t.card === id)));
|
|
363
|
+
if (card.column === FLOW || planNamed) {
|
|
364
|
+
const sentence = `"${id}" is in flow / is named by .doug/plan.json: --goal and --deps need --force, since an approved plan's acceptance may no longer describe the card`;
|
|
365
|
+
if (keys.some((k) => k === "goal" || k === "deps")) {
|
|
366
|
+
if (!force) throw new Error(`${prefix} ${sentence}`);
|
|
367
|
+
warnings.push(sentence);
|
|
368
|
+
}
|
|
369
|
+
}
|
|
370
|
+
|
|
371
|
+
const nextCard = { ...card };
|
|
372
|
+
for (const k of keys) nextCard[k] = fields[k];
|
|
373
|
+
const cards = board.cards.map((c) => (c.id === id ? nextCard : c));
|
|
374
|
+
const next = { ...board, updated: date || today(), cards };
|
|
375
|
+
const errors = validateBoard(next);
|
|
376
|
+
if (errors.length) throw new Error(errorList(prefix, errors));
|
|
377
|
+
return { board: next, warnings };
|
|
378
|
+
}
|
|
379
|
+
|
|
380
|
+
// Returns { board, removed, warnings }: a new board with the card gone (input untouched, `updated` stamped like
|
|
381
|
+
// moveCard), the removed card object, and warnings (empty when none). Message prefix: `cannot remove card "<id>":`.
|
|
382
|
+
// An unknown id throws findCard's own message. Refusals, checked in order: (a) a card another card lists in
|
|
383
|
+
// `deps` is refused ALWAYS, force included, because the result would fail validateBoard's unknown-dep rule and
|
|
384
|
+
// loadBoard would then refuse to read the record; (b) a done card needs --force (warns which commit still cites
|
|
385
|
+
// it); (c) a card in `flow`, or named by `plan` the same way editCard checks, needs --force (warns the same
|
|
386
|
+
// sentence editCard's flow/plan rule uses). The result is validated with validateBoard like addCard and editCard.
|
|
387
|
+
export function removeCard(board, id, { force = false, date, plan = null } = {}) {
|
|
388
|
+
const prefix = `cannot remove card "${id}":`;
|
|
389
|
+
const card = findCard(board, id);
|
|
390
|
+
const warnings = [];
|
|
391
|
+
|
|
392
|
+
const dependents = board.cards.filter((c) => c.id !== id && Array.isArray(c.deps) && c.deps.includes(id)).map((c) => c.id);
|
|
393
|
+
if (dependents.length) {
|
|
394
|
+
throw new Error(`${prefix} cards depend on it: ${dependents.join(", ")}; remove the dep first (the record would not load)`);
|
|
395
|
+
}
|
|
396
|
+
|
|
397
|
+
if (card.column === DONE) {
|
|
398
|
+
const landed = card.source || "an unrecorded commit";
|
|
399
|
+
if (!force) throw new Error(`${prefix} a done card is removed only with --force; it landed as ${landed}`);
|
|
400
|
+
warnings.push(`removed done card "${id}", landed as ${landed}: the run log and the landing commit still cite the id`);
|
|
401
|
+
}
|
|
402
|
+
|
|
403
|
+
const planNamed = plan && (plan.card === id || (Array.isArray(plan.cards) && plan.cards.includes(id)) || (Array.isArray(plan.tasks) && plan.tasks.some((t) => t && t.card === id)));
|
|
404
|
+
if (card.column === FLOW || planNamed) {
|
|
405
|
+
const sentence = `"${id}" is in flow / is named by .doug/plan.json: removing it needs --force, since an approved plan still names the card`;
|
|
406
|
+
if (!force) throw new Error(`${prefix} ${sentence}`);
|
|
407
|
+
warnings.push(sentence);
|
|
408
|
+
}
|
|
409
|
+
|
|
410
|
+
const cards = board.cards.filter((c) => c.id !== id);
|
|
411
|
+
const next = { ...board, updated: date || today(), cards };
|
|
412
|
+
const errors = validateBoard(next);
|
|
413
|
+
if (errors.length) throw new Error(errorList(prefix, errors));
|
|
414
|
+
return { board: next, removed: card, warnings };
|
|
415
|
+
}
|
|
416
|
+
|
|
417
|
+
function yesNo(v) {
|
|
418
|
+
return v ? "yes" : "no";
|
|
419
|
+
}
|
|
420
|
+
|
|
421
|
+
// One markdown section for docs/live-runs.md from a doug-implement report. Numbers are the ones passed
|
|
422
|
+
// in (measured by the caller) or absent; nothing is estimated.
|
|
423
|
+
// The "Model tier" cell of a run entry: `sonnet / medium (tier cheap)`, `inherit`, or an older string form.
|
|
424
|
+
// A reused task ran no implementer: the checkout agent's model is what it cost.
|
|
425
|
+
function describeTierCell(models, reused = false) {
|
|
426
|
+
if (reused && models && typeof models.checkout === "object" && models.checkout !== null) return `${models.checkout.model || "inherit"} (checkout)`;
|
|
427
|
+
const m = models && models.implement;
|
|
428
|
+
if (!m) return "inherit";
|
|
429
|
+
if (typeof m === "string") return m;
|
|
430
|
+
const model = m.model || "inherit";
|
|
431
|
+
const effort = m.effort && m.effort !== "inherit" ? ` / ${m.effort}` : "";
|
|
432
|
+
const tier = models.tier ? ` (tier ${models.tier})` : "";
|
|
433
|
+
return `${model}${effort}${tier}`;
|
|
434
|
+
}
|
|
435
|
+
|
|
436
|
+
export const ADVERSARY_CLASSES = ["real", "marginal", "false"];
|
|
437
|
+
|
|
438
|
+
// Every adversary block a report records, one per finding. With a ledger (the fix loop since 3d9049d) a block is a
|
|
439
|
+
// ledger entry the adversary raised as a blocker, with the pass that raised it from attempts[].newFindings; for a
|
|
440
|
+
// report from before the ledger, one block per pass the adversary blocked, named pass-<n>, described by that pass's
|
|
441
|
+
// blocker issue when the report kept it (only the last pass's issues survive) and otherwise by its summary.
|
|
442
|
+
export function adversaryBlocks(report) {
|
|
443
|
+
const out = [];
|
|
444
|
+
for (const level of report.levels || []) {
|
|
445
|
+
for (const t of level.tasks || []) {
|
|
446
|
+
const attempts = Array.isArray(t.attempts) ? t.attempts : [];
|
|
447
|
+
if (Array.isArray(t.ledger)) {
|
|
448
|
+
for (const e of t.ledger) {
|
|
449
|
+
if (e.stage !== "adversary" || e.severity !== "blocker") continue;
|
|
450
|
+
const raised = attempts.find((a) => Array.isArray(a.newFindings) && a.newFindings.includes(e.id));
|
|
451
|
+
out.push({ task: t.id, id: e.id, pass: raised ? raised.pass : null, status: e.status || null, description: String(e.description || "") });
|
|
452
|
+
}
|
|
453
|
+
continue;
|
|
454
|
+
}
|
|
455
|
+
const blocked = attempts.filter((a) => a.adversary && a.adversary.blocked);
|
|
456
|
+
const last = blocked.length ? blocked[blocked.length - 1] : null;
|
|
457
|
+
const finalBlocker = t.adversary && t.adversary.blocked ? (t.adversary.issues || []).find((i) => i.severity === "blocker") : null;
|
|
458
|
+
for (const a of blocked) {
|
|
459
|
+
const description = a === last && finalBlocker ? finalBlocker.description : a.adversary.summary || "";
|
|
460
|
+
out.push({ task: t.id, id: `pass-${a.pass}`, pass: a.pass, status: null, description: String(description) });
|
|
461
|
+
}
|
|
462
|
+
if (!blocked.length && t.adversary && t.adversary.blocked) out.push({ task: t.id, id: "pass-1", pass: 1, status: null, description: String(finalBlocker ? finalBlocker.description : t.adversary.summary || "") });
|
|
463
|
+
}
|
|
464
|
+
}
|
|
465
|
+
return out;
|
|
466
|
+
}
|
|
467
|
+
|
|
468
|
+
// The name a block is classified under: its id when no other task's block shares it, else task/id.
|
|
469
|
+
export function blockKey(block, blocks) {
|
|
470
|
+
return blocks.filter((b) => b.id === block.id).length > 1 ? `${block.task}/${block.id}` : block.id;
|
|
471
|
+
}
|
|
472
|
+
|
|
473
|
+
// `--adversary` values: each is `<id>=<class>[: <reason>]`; a value without a reason may list several, comma-separated.
|
|
474
|
+
export function parseAdversaryClasses(values) {
|
|
475
|
+
const out = [];
|
|
476
|
+
for (const value of Array.isArray(values) ? values : [values]) {
|
|
477
|
+
const items = String(value).includes(":") ? [String(value)] : String(value).split(",");
|
|
478
|
+
for (const raw of items) {
|
|
479
|
+
const item = raw.trim();
|
|
480
|
+
if (!item) continue;
|
|
481
|
+
const m = /^([^=\s]+)=([a-z]+)(?::\s*(.*))?$/s.exec(item);
|
|
482
|
+
if (!m) throw new Error(`cannot read adversary classification "${item}": expected <id>=<real|marginal|false>[: <reason>]`);
|
|
483
|
+
if (!ADVERSARY_CLASSES.includes(m[2])) throw new Error(`unknown adversary class "${m[2]}" for ${m[1]}; classes are ${ADVERSARY_CLASSES.join(", ")}`);
|
|
484
|
+
out.push({ id: m[1], class: m[2], reason: (m[3] || "").trim() || null });
|
|
485
|
+
}
|
|
486
|
+
}
|
|
487
|
+
return out;
|
|
488
|
+
}
|
|
489
|
+
|
|
490
|
+
// The report's blocks with their classes attached. An id that names no block is an error (the message lists the
|
|
491
|
+
// report's blocks); a block nobody classified stays unclassified and is counted as such.
|
|
492
|
+
export function classifyBlocks(report, classes = []) {
|
|
493
|
+
const blocks = adversaryBlocks(report);
|
|
494
|
+
const classified = blocks.map((b) => ({ ...b, key: blockKey(b, blocks), class: null, reason: null }));
|
|
495
|
+
for (const c of classes) {
|
|
496
|
+
const hit = classified.find((b) => b.key === c.id || `${b.task}/${b.id}` === c.id);
|
|
497
|
+
if (!hit) throw new Error(`no adversary block named ${c.id} in this report; its blocks are: ${classified.length ? classified.map((b) => b.key).join(", ") : "none"}`);
|
|
498
|
+
hit.class = c.class;
|
|
499
|
+
hit.reason = c.reason;
|
|
500
|
+
}
|
|
501
|
+
return classified;
|
|
502
|
+
}
|
|
503
|
+
|
|
504
|
+
// "1 real / 0 marginal / 1 false", with "/ n unclassified" when a block has no class; "no blocks" for a clean run.
|
|
505
|
+
export function precisionLine(blocks) {
|
|
506
|
+
if (!blocks.length) return "no blocks";
|
|
507
|
+
const n = (cls) => blocks.filter((b) => b.class === cls).length;
|
|
508
|
+
const unclassified = blocks.filter((b) => !b.class).length;
|
|
509
|
+
return `${n("real")} real / ${n("marginal")} marginal / ${n("false")} false${unclassified ? ` / ${unclassified} unclassified` : ""}`;
|
|
510
|
+
}
|
|
511
|
+
|
|
512
|
+
export function blockLines(blocks) {
|
|
513
|
+
return blocks.map((b) => `- ${b.key} (${b.task}${b.pass !== null ? `, pass ${b.pass}` : ""}): ${b.class || "unclassified"}. ${b.class ? b.reason || "no reason given" : b.description.length > 160 ? b.description.slice(0, 157) + "..." : b.description}`);
|
|
514
|
+
}
|
|
515
|
+
|
|
516
|
+
// A per-task adversary cell (card fix-loop-minor-verdict): a real block still reads "verdict (blocked)", unchanged;
|
|
517
|
+
// a `fail` that did not block (only major/minor issues, so it never reached the ledger as a blocker) carries the
|
|
518
|
+
// notes it kept, so it never reads as a bare "fail" with nothing said about why it did not stop the task.
|
|
519
|
+
function adversaryVerdictCell(adv) {
|
|
520
|
+
if (adv.blocked) return `${adv.verdict} (blocked)`;
|
|
521
|
+
if (adv.verdict === "fail") {
|
|
522
|
+
const n = Array.isArray(adv.issues) ? adv.issues.length : 0;
|
|
523
|
+
return n ? `fail (${n} note${n === 1 ? "" : "s"})` : "fail (notes)";
|
|
524
|
+
}
|
|
525
|
+
return adv.verdict;
|
|
526
|
+
}
|
|
527
|
+
|
|
528
|
+
// The integration row's last cell: what the level adversary (the one review of a level's size-S tasks on the
|
|
529
|
+
// integration branch) found, the fix pass it caused, and whether its confirmation cleared the level.
|
|
530
|
+
function levelAdversaryCell(la) {
|
|
531
|
+
if (!la) return "";
|
|
532
|
+
const verdict = la.ran ? adversaryVerdictCell(la) : `did not run${la.error ? `: ${la.error}` : ""}`;
|
|
533
|
+
const parts = [`level adversary on ${(la.tasks || []).join(", ") || "no task"}: ${verdict}`];
|
|
534
|
+
if (la.unowned && la.unowned.length) parts.push(`no owner for ${la.unowned.join(", ")}`);
|
|
535
|
+
if (Array.isArray(la.fixed) && la.fixed.length) parts.push(`fix pass on ${la.fixed.map((f) => `${f.task}${f.ready ? "" : " (not ready)"}`).join(", ")}`);
|
|
536
|
+
if (la.reintegration) parts.push(`re-integration ${la.reintegration.ok ? "ok" : "failed"}`);
|
|
537
|
+
if (la.confirm) parts.push(`confirm ${la.confirm.ran ? adversaryVerdictCell(la.confirm) : "did not run"}`);
|
|
538
|
+
return parts.join("; ");
|
|
539
|
+
}
|
|
540
|
+
|
|
541
|
+
// The part of a batch run's report that is one card's: only that card's tasks (a task carries `card` since the
|
|
542
|
+
// workflow records it; a report from before that is matched by `taskIds`, the plan's task ids for the card), and
|
|
543
|
+
// only the levels that hold one of them. The integration rows of those levels stay: they are shared by the batch.
|
|
544
|
+
// Also carries `batchCards`, the sorted unique `card` values of every task in the UNFILTERED report, present only
|
|
545
|
+
// when at least one task carries `card` (absent for a report from before batching): acceptanceUnmet needs the
|
|
546
|
+
// whole batch's card set to tell a confirmed other-card entry (dropped) from one naming a card the report never
|
|
547
|
+
// had (card batch-summary-acceptance-noise, MINOR 1 regression) - a filtered-down task list alone cannot answer
|
|
548
|
+
// that, since filtering to one card is exactly what makes every other card invisible to it.
|
|
549
|
+
export function filterReportForCard(report, cardId, taskIds = []) {
|
|
550
|
+
const ids = new Set(Array.isArray(taskIds) ? taskIds : []);
|
|
551
|
+
const mine = (t) => t && (t.card === cardId || (t.card === undefined && ids.has(t.id)));
|
|
552
|
+
const levels = (report.levels || []).map((l) => ({ ...l, tasks: (l.tasks || []).filter(mine) })).filter((l) => l.tasks.length);
|
|
553
|
+
const allCards = new Set((report.levels || []).flatMap((l) => (l.tasks || []).map((t) => t && t.card)).filter((c) => c !== undefined));
|
|
554
|
+
return { ...report, levels, ...(allCards.size ? { batchCards: [...allCards].sort() } : {}) };
|
|
555
|
+
}
|
|
556
|
+
|
|
557
|
+
function listOthers(batch, cardId) {
|
|
558
|
+
const others = batch.filter((c) => c !== cardId);
|
|
559
|
+
return others.length > 1 ? `${others.slice(0, -1).join(", ")} and ${others[others.length - 1]}` : others[0] || "";
|
|
560
|
+
}
|
|
561
|
+
|
|
562
|
+
// `batch` (the plan's cards, in plan order) makes this one card's entry of a batch run: its tasks only, `cost` as
|
|
563
|
+
// that card's tasks' measured cost, and the shared integration agents' cost (`sharedCost`) counted once, under the
|
|
564
|
+
// batch's first card. `taskIds` is the fallback for a report whose tasks carry no `card`.
|
|
565
|
+
// The one-phrase outcome of a report, shared by the record and the summary. A run that stopped never reached a
|
|
566
|
+
// gate, so stoppedAtLevel wins; a paused run (typed gates, d07f06f) has ok false with every stage passed, and
|
|
567
|
+
// saying "not green" for it misread the report until 2026-09-07.
|
|
568
|
+
function pausedAt(report) {
|
|
569
|
+
return report.paused && typeof report.paused === "object" && Number.isInteger(report.paused.level) ? report.paused : null;
|
|
570
|
+
}
|
|
571
|
+
function listNext(report) {
|
|
572
|
+
const next = Array.isArray(pausedAt(report)?.next) ? pausedAt(report).next : [];
|
|
573
|
+
return next.length ? next.join(", ") : "none";
|
|
574
|
+
}
|
|
575
|
+
function outcomeOf(report) {
|
|
576
|
+
if (report.ok) return "green";
|
|
577
|
+
if (report.stoppedAtLevel !== undefined) return `stopped at level ${report.stoppedAtLevel}`;
|
|
578
|
+
if (pausedAt(report)) return `paused at the human gate after level ${pausedAt(report).level}`;
|
|
579
|
+
return "not green";
|
|
580
|
+
}
|
|
581
|
+
|
|
582
|
+
export function runEntry({ card, report, cost = null, codexCost = null, wallClock = null, mergeCommit = null, record = null, adversary = [], date = new Date().toISOString().slice(0, 10), batch = null, sharedCost = null, taskIds = [], note = null, rehearsal = null }) {
|
|
583
|
+
const inBatch = Array.isArray(batch) && batch.length > 1;
|
|
584
|
+
if (inBatch) report = filterReportForCard(report, card.id, taskIds);
|
|
585
|
+
const lines = [];
|
|
586
|
+
lines.push(`## ${date}, ${card.id}: ${card.title}`);
|
|
587
|
+
lines.push("");
|
|
588
|
+
const batchNote = inBatch ? `, in one plan with ${listOthers(batch, card.id)} (one approval, one run, one landing)` : "";
|
|
589
|
+
lines.push(
|
|
590
|
+
rehearsal
|
|
591
|
+
? `Rehearsal ${rehearsal} for card \`${card.id}\` on the ts-basic fixture (plugins/doug-flow/scripts/rehearse.mjs); the commit and gate below are the fixture's, not this repository's.`
|
|
592
|
+
: `Ran through /doug-next on this repo from card \`${card.id}\`${record ? ` in \`${record}\`` : ""}${batchNote}. Plan "${report.plan}", integration branch \`${report.integrationBranch}\`${report.modelsSource ? `, models from ${report.modelsSource}` : ""}.`,
|
|
593
|
+
);
|
|
594
|
+
lines.push("");
|
|
595
|
+
lines.push("| Measure | Value |");
|
|
596
|
+
lines.push("|---|---|");
|
|
597
|
+
lines.push(`| Outcome | ${outcomeOf(report)}${pausedAt(report) ? `; next: ${listNext(report)}` : ""} |`);
|
|
598
|
+
lines.push(`| Cost | ${cost === null ? "not measured" : `$${Number(cost).toFixed(2)}${inBatch ? " for this card's tasks" : ""}`} |`);
|
|
599
|
+
if (!inBatch || batch[0] === card.id) {
|
|
600
|
+
lines.push(
|
|
601
|
+
`| Codex adversary | ${
|
|
602
|
+
codexCost === null
|
|
603
|
+
? "not measured"
|
|
604
|
+
: inBatch
|
|
605
|
+
? `$${Number(codexCost).toFixed(2)} for the whole batch, counted once here, not included in Cost`
|
|
606
|
+
: `$${Number(codexCost).toFixed(2)}, not included in Cost`
|
|
607
|
+
} |`,
|
|
608
|
+
);
|
|
609
|
+
}
|
|
610
|
+
if (inBatch) lines.push(`| Shared integration agents | ${sharedCost === null ? "not measured" : `$${Number(sharedCost).toFixed(2)}, counted once for the batch ${batch[0] === card.id ? "here" : `under ${batch[0]}`}`} |`);
|
|
611
|
+
lines.push(`| Wall clock | ${wallClock === null ? "not measured" : wallClock} |`);
|
|
612
|
+
const blocks = classifyBlocks(report, adversary);
|
|
613
|
+
const shownCommit = rehearsal && mergeCommit ? String(mergeCommit).slice(0, 7) : mergeCommit;
|
|
614
|
+
lines.push(`| Landed as | ${shownCommit ? `\`${shownCommit}\`` : "not landed"} |`);
|
|
615
|
+
lines.push(`| Adversary precision | ${precisionLine(blocks)} |`);
|
|
616
|
+
lines.push("");
|
|
617
|
+
if (note) {
|
|
618
|
+
lines.push(String(note));
|
|
619
|
+
lines.push("");
|
|
620
|
+
}
|
|
621
|
+
lines.push("| Level | Task | Implemented | Verified | Reviewed | Adversary | Model tier | Shape |");
|
|
622
|
+
lines.push("|---|---|---|---|---|---|---|---|");
|
|
623
|
+
for (const level of report.levels || []) {
|
|
624
|
+
for (const t of level.tasks || []) {
|
|
625
|
+
const adv = t.adversary ? (t.adversary.ran ? adversaryVerdictCell(t.adversary) : `did not run${t.adversary.error ? `: ${t.adversary.error}` : ""}`) : "skipped";
|
|
626
|
+
const tier = describeTierCell(t.models, t.reused);
|
|
627
|
+
// A reuse task that did not implement (a thrown stage, or a checkout agent that returned nothing) must read
|
|
628
|
+
// as blocked/not-implemented, not as reused: `reused` records the task's intent for the memory outcome log,
|
|
629
|
+
// but this table says what happened, and a task that never implemented never actually checked the branch
|
|
630
|
+
// out (card thrown-reuse-reads-as-reused). So `!implemented` owns the branch outright.
|
|
631
|
+
let implemented = !t.implemented ? (t.blockedReason ? `blocked: ${t.blockedReason}` : "no") : typeof t.reused === "string" && t.reused ? `reused \`${t.reused}\`` : "yes";
|
|
632
|
+
if (Array.isArray(t.attempts) && t.attempts.length > 1) implemented += ` (${t.attempts.length} passes)`;
|
|
633
|
+
lines.push(`| ${level.index} | ${t.id} | ${implemented} | ${yesNo(t.verified)} | ${yesNo(t.reviewed)} | ${adv} | ${tier} | ${t.shape === "S" ? "S" : "full"} |`);
|
|
634
|
+
}
|
|
635
|
+
lines.push(`| ${level.index} | integration | ${level.integration ? (level.integration.ok ? "ok" : "failed") : "not run"} | | | | ${describeTierCell({ implement: level.integrationModel })} | ${levelAdversaryCell(level.levelAdversary)} |`);
|
|
636
|
+
}
|
|
637
|
+
if (blocks.length) {
|
|
638
|
+
lines.push("");
|
|
639
|
+
lines.push("Adversary blocks, classified (real: a defect a user would hit; marginal: true to the spec, no user impact; false: wrong):");
|
|
640
|
+
lines.push(...blockLines(blocks));
|
|
641
|
+
}
|
|
642
|
+
lines.push("");
|
|
643
|
+
return lines.join("\n") + "\n";
|
|
644
|
+
}
|
|
645
|
+
|
|
646
|
+
// The live-runs entry for a hand-track card (decision 0005): no workflow report and no levels, so it records what
|
|
647
|
+
// /core-next can measure: the landing commit, the wall clock, and the gate result, plus an optional note.
|
|
648
|
+
export function handEntry({ card, commit = null, wallClock = null, gate = null, note = null, record = null, date = new Date().toISOString().slice(0, 10), rehearsal = null }) {
|
|
649
|
+
const lines = [];
|
|
650
|
+
lines.push(`## ${date}, ${card.id}: ${card.title}`);
|
|
651
|
+
lines.push("");
|
|
652
|
+
lines.push(
|
|
653
|
+
rehearsal
|
|
654
|
+
? `Rehearsal ${rehearsal} for card \`${card.id}\` on the ts-basic fixture (plugins/doug-flow/scripts/rehearse.mjs); the commit and gate below are the fixture's, not this repository's.`
|
|
655
|
+
: `Built by hand through /core-next from card \`${card.id}\`${record ? ` in \`${record}\`` : ""} (hand track, decision 0005); no workflow run.`,
|
|
656
|
+
);
|
|
657
|
+
lines.push("");
|
|
658
|
+
lines.push("| Measure | Value |");
|
|
659
|
+
lines.push("|---|---|");
|
|
660
|
+
lines.push(`| Outcome | ${commit ? "landed" : "not landed"} |`);
|
|
661
|
+
const shownCommit = rehearsal && commit ? String(commit).slice(0, 7) : commit;
|
|
662
|
+
lines.push(`| Commit | ${shownCommit ? `\`${shownCommit}\`` : "none"} |`);
|
|
663
|
+
lines.push(`| Wall clock | ${wallClock === null ? "not measured" : wallClock} |`);
|
|
664
|
+
lines.push(`| Gate | ${gate === null ? "not recorded" : gate} |`);
|
|
665
|
+
lines.push("");
|
|
666
|
+
if (note) {
|
|
667
|
+
lines.push(String(note));
|
|
668
|
+
lines.push("");
|
|
669
|
+
}
|
|
670
|
+
return lines.join("\n") + "\n";
|
|
671
|
+
}
|
|
672
|
+
|
|
673
|
+
function truncateAcceptanceText(text) {
|
|
674
|
+
const s = String(text);
|
|
675
|
+
return s.length > 80 ? `${s.slice(0, 80)}...` : s;
|
|
676
|
+
}
|
|
677
|
+
|
|
678
|
+
// A batch report's `[<card>] ` tag, written onto every plan acceptance text by plan.mjs merge (lib/plan.mjs).
|
|
679
|
+
const ACCEPTANCE_TAG = /^\[([^\]]+)\]\s/;
|
|
680
|
+
|
|
681
|
+
// The acceptance entries runSummary's third line reads as unmet, and how it says so (card
|
|
682
|
+
// batch-summary-acceptance-noise): a task's verifier runs every plan acceptance command on that task's own
|
|
683
|
+
// branch, before the rest of a batch's cards or later levels exist, so most of a batch report's `ok: false`
|
|
684
|
+
// entries are not real failures of that task's card. Precedence:
|
|
685
|
+
// 1. The final level's `integration.acceptance` (written by the workflow's integrate stage since card
|
|
686
|
+
// integration-acceptance-recorded), when it is a non-empty array: the plan's acceptance run once, after
|
|
687
|
+
// everything integrated, so nothing per-task needs reconciling and this wins regardless of tags.
|
|
688
|
+
// 2. Otherwise, whenever the report is a batch (some task carries `card`; a report from before batching never
|
|
689
|
+
// does): the gate is `isBatch` alone, not whether any entry anywhere is tagged, because a whole task can
|
|
690
|
+
// lose its tag (the real report: task cli-remove-and-tag-flags returned 12/12 untagged) and that must not
|
|
691
|
+
// fall through to rule 3's every-entry-counts reading with the rest of the batch's noise still in it. The
|
|
692
|
+
// known-card set is `report.batchCards` (every card the whole, unfiltered batch has, written by
|
|
693
|
+
// filterReportForCard) when present, unioned with the cards this task list itself carries, so a report
|
|
694
|
+
// from before that field existed still falls back to what it can see. Per entry:
|
|
695
|
+
// - tagged `[<t.card>] ` (this task's own card): counts as unmet when `ok === false`.
|
|
696
|
+
// - tagged with a card the known-card set has but that isn't this task's own: genuine cross-card noise,
|
|
697
|
+
// dropped outright (not shown, not counted anywhere) - true even under `--card`, where the task list
|
|
698
|
+
// holds only one card, since `batchCards` still names the others.
|
|
699
|
+
// - anything else - no tag at all, a tag naming a card the known-card set does not have at all (a typo,
|
|
700
|
+
// or a report with no `batchCards` and no other task carrying that card in view), or any tag at all on
|
|
701
|
+
// a task whose own `card` is undefined (a mixed report) - cannot be attributed either way, so it is set
|
|
702
|
+
// aside rather than guessed at, and the source clause names how many were set aside, how many of those
|
|
703
|
+
// failed, and for which task(s), so a real failure hidden behind an unreadable tag is never silent.
|
|
704
|
+
// 3. Otherwise (no task in the report carries `card` at all): every `ok: false` entry counts, unchanged from
|
|
705
|
+
// before this card.
|
|
706
|
+
// Returns { unmet, source, dirty }: `unmet` the display strings for "Acceptance not met: ...", `source` the
|
|
707
|
+
// clause (with its own leading space, no trailing punctuation) to append to the third line ("" under rule 3, so
|
|
708
|
+
// an old report's summary is byte-identical to before), and `dirty` (rule 2 only) true when a set-aside entry
|
|
709
|
+
// itself failed, so a batch report never reads as a clean pass while one of those is left unresolved.
|
|
710
|
+
function acceptanceUnmet(report, tasks) {
|
|
711
|
+
const levels = report.levels || [];
|
|
712
|
+
const finalLevel = levels.length ? levels[levels.length - 1] : null;
|
|
713
|
+
const integrationAcceptance = finalLevel && finalLevel.integration && Array.isArray(finalLevel.integration.acceptance) ? finalLevel.integration.acceptance : null;
|
|
714
|
+
if (integrationAcceptance && integrationAcceptance.length) {
|
|
715
|
+
const unmet = integrationAcceptance.filter((a) => a && a.ok === false).map((a) => truncateAcceptanceText(a.text));
|
|
716
|
+
return { unmet, source: " (acceptance read from the final integration level)", dirty: false };
|
|
717
|
+
}
|
|
718
|
+
|
|
719
|
+
const isBatch = tasks.some(({ task: t }) => t.card !== undefined);
|
|
720
|
+
if (!isBatch) {
|
|
721
|
+
const unmet = tasks.flatMap(({ task: t }) => (t.acceptance || []).filter((a) => a && a.ok === false).map((a) => `${truncateAcceptanceText(a.text)} (${t.id})`));
|
|
722
|
+
return { unmet, source: "", dirty: false };
|
|
723
|
+
}
|
|
724
|
+
|
|
725
|
+
const knownCards = new Set(tasks.map(({ task: t }) => t.card).filter((c) => c !== undefined));
|
|
726
|
+
if (Array.isArray(report.batchCards)) for (const c of report.batchCards) knownCards.add(c);
|
|
727
|
+
const unmet = [];
|
|
728
|
+
const setAside = new Map(); // task id -> { count, failing }
|
|
729
|
+
const noteAside = (id, ok) => {
|
|
730
|
+
const entry = setAside.get(id) || { count: 0, failing: 0 };
|
|
731
|
+
entry.count += 1;
|
|
732
|
+
if (ok === false) entry.failing += 1;
|
|
733
|
+
setAside.set(id, entry);
|
|
734
|
+
};
|
|
735
|
+
for (const { task: t } of tasks) {
|
|
736
|
+
for (const a of t.acceptance || []) {
|
|
737
|
+
if (!a) continue;
|
|
738
|
+
const m = typeof a.text === "string" ? ACCEPTANCE_TAG.exec(a.text) : null;
|
|
739
|
+
if (!m || t.card === undefined) {
|
|
740
|
+
noteAside(t.id, a.ok); // no tag, or a tag we cannot compare against this task's own (missing) card
|
|
741
|
+
continue;
|
|
742
|
+
}
|
|
743
|
+
if (m[1] === t.card) {
|
|
744
|
+
if (a.ok === false) unmet.push(`${truncateAcceptanceText(a.text)} (${t.id})`);
|
|
745
|
+
continue;
|
|
746
|
+
}
|
|
747
|
+
if (!knownCards.has(m[1])) {
|
|
748
|
+
noteAside(t.id, a.ok); // names a card the whole batch never carries: unreadable, not confirmed cross-card noise
|
|
749
|
+
continue;
|
|
750
|
+
}
|
|
751
|
+
// else: another card actually in this task list owns it, evaluated on this task's branch before that
|
|
752
|
+
// card's own work existed: real cross-card noise, dropped outright.
|
|
753
|
+
}
|
|
754
|
+
}
|
|
755
|
+
let asideCount = 0;
|
|
756
|
+
let asideFailing = 0;
|
|
757
|
+
for (const v of setAside.values()) {
|
|
758
|
+
asideCount += v.count;
|
|
759
|
+
asideFailing += v.failing;
|
|
760
|
+
}
|
|
761
|
+
const asideNote = asideCount
|
|
762
|
+
? `; ${asideCount} unattributed ${asideCount === 1 ? "entry" : "entries"} set aside${asideFailing ? `, ${asideFailing} failing` : ""}: ${[...setAside.keys()].join(", ")}`
|
|
763
|
+
: "";
|
|
764
|
+
return { unmet, source: ` (acceptance read per task by [card] tag${asideNote})`, dirty: asideFailing > 0 };
|
|
765
|
+
}
|
|
766
|
+
|
|
767
|
+
// Three lines for the chat after a run: the outcome, the tasks, and what stopped or was not met. Everything in it
|
|
768
|
+
// is read from the report; nothing is estimated.
|
|
769
|
+
export function runSummary({ report, cost = null, codexCost = null, wallClock = null, mergeCommit = null }) {
|
|
770
|
+
const tasks = (report.levels || []).flatMap((l) => (l.tasks || []).map((t) => ({ level: l, task: t })));
|
|
771
|
+
const integrated = tasks.filter(({ level }) => level.integration && level.integration.ok).length;
|
|
772
|
+
const outcome = outcomeOf(report);
|
|
773
|
+
const first = `${report.plan}: ${outcome}; ${tasks.length} task${tasks.length === 1 ? "" : "s"}, ${integrated} integrated; wall ${wallClock === null ? "not measured" : wallClock}; cost ${cost === null ? "not measured" : `$${Number(cost).toFixed(2)}`}${codexCost === null ? "" : `; Codex $${Number(codexCost).toFixed(2)}`}${mergeCommit ? `; landed as ${mergeCommit}` : ""}.`;
|
|
774
|
+
const second = tasks.length
|
|
775
|
+
? tasks.map(({ task: t }) => {
|
|
776
|
+
const passes = Array.isArray(t.attempts) && t.attempts.length ? t.attempts.length : 1;
|
|
777
|
+
const adv = t.adversary ? (t.adversary.ran ? adversaryVerdictCell(t.adversary) : "did not run") : "skipped";
|
|
778
|
+
// Same precedence as the run-entry table above: `!implemented` owns the branch outright.
|
|
779
|
+
const impl = !t.implemented ? (t.blockedReason ? `blocked: ${t.blockedReason}` : "not implemented") : typeof t.reused === "string" && t.reused ? `reused ${t.reused}` : "implemented";
|
|
780
|
+
return `${t.id}: ${impl}, ${passes} pass${passes === 1 ? "" : "es"}, verify ${yesNo(t.verified)}, review ${yesNo(t.reviewed)}, adversary ${adv}`;
|
|
781
|
+
}).join("; ")
|
|
782
|
+
: "no tasks";
|
|
783
|
+
const stops = tasks.filter(({ task: t }) => t.stopReason).map(({ task: t }) => `${t.id}: "${t.stopReason}"`);
|
|
784
|
+
const { unmet, source, dirty } = acceptanceUnmet(report, tasks);
|
|
785
|
+
const pause = pausedAt(report) ? `Paused at the human gate after level ${pausedAt(report).level}; next: ${listNext(report)}. Open it with plan.mjs gate open ${pausedAt(report).level} and resume the run.` : "";
|
|
786
|
+
const third = pause || stops.length || unmet.length
|
|
787
|
+
? [pause, stops.length ? `Stopped: ${stops.join("; ")}` : "", unmet.length ? `Acceptance not met: ${unmet.join("; ")}` : ""].filter(Boolean).join(" | ") + source
|
|
788
|
+
: `${dirty ? "No attributed acceptance command failed" : "Every stage and acceptance command passed"}${source}.`;
|
|
789
|
+
return `${first}\n${second}\n${third}\n`;
|
|
790
|
+
}
|
|
791
|
+
|
|
792
|
+
// Promotes card <id>'s research note into docs/ when recording a landing (card research-notes-survive): a note
|
|
793
|
+
// under .doug/.state/research/ is session state, gitignored and disposable, which is right for a note the
|
|
794
|
+
// planner used and discarded and wrong for a note a landed commit cites — nothing is there to read afterward.
|
|
795
|
+
// Copies, never moves: .doug/.state/ may still be read this session, and it stays gitignored either way. Returns
|
|
796
|
+
// the docs-relative path it wrote, or null when the card has no note (most cards; nothing is printed for those).
|
|
797
|
+
// The mechanism is one note per card, keyed by card id: a note not named for a card (memory-architecture.md) and
|
|
798
|
+
// a directory of parts (memory-parts/) are out of it by shape, not by oversight, and stay in state, unpromoted.
|
|
799
|
+
// A failure (a bad id, or the filesystem) throws rather than promoting nothing and staying quiet about it — the
|
|
800
|
+
// silent loss of a cited note is the defect this function exists to close — so the caller must promote before
|
|
801
|
+
// it writes anything else, or a failed retry duplicates what did get written.
|
|
802
|
+
// When the docs copy already exists and is byte-identical to the state note, nothing is copied and null is
|
|
803
|
+
// returned (the caller prints nothing): re-recording a card whose note has not changed used to overwrite an
|
|
804
|
+
// unchanged file and print "Promoted the research note" every time, which reads as if something happened when
|
|
805
|
+
// nothing did (card rehearsal-first-live-findings #4).
|
|
806
|
+
const SAFE_RESEARCH_ID = /^[a-z0-9._-]+$/;
|
|
807
|
+
export function promoteResearchNote(dir, id) {
|
|
808
|
+
if (!SAFE_RESEARCH_ID.test(id)) {
|
|
809
|
+
throw new Error(`cannot promote the research note for "${id}": card ids must match ${SAFE_RESEARCH_ID} to stay inside ${RESEARCH_DOCS_DIR}/`);
|
|
810
|
+
}
|
|
811
|
+
const src = join(dir, RESEARCH_NOTE_DIR, `${id}.md`);
|
|
812
|
+
if (!existsSync(src)) return null;
|
|
813
|
+
const destDir = join(dir, RESEARCH_DOCS_DIR);
|
|
814
|
+
const relPath = `${RESEARCH_DOCS_DIR}/${id}.md`;
|
|
815
|
+
const destPath = join(dir, relPath);
|
|
816
|
+
if (existsSync(destPath) && readFileSync(destPath).equals(readFileSync(src))) return null;
|
|
817
|
+
mkdirSync(destDir, { recursive: true });
|
|
818
|
+
copyFileSync(src, destPath);
|
|
819
|
+
return relPath;
|
|
820
|
+
}
|
|
821
|
+
|
|
822
|
+
export function appendRun(dir, markdown) {
|
|
823
|
+
const file = join(dir, LIVE_RUNS_RELPATH);
|
|
824
|
+
mkdirSync(dirname(file), { recursive: true });
|
|
825
|
+
if (!existsSync(file)) writeFileSync(file, "# Live runs of doug-flow\n\n");
|
|
826
|
+
const current = readFileSync(file, "utf8");
|
|
827
|
+
appendFileSync(file, (current.endsWith("\n\n") ? "" : current.endsWith("\n") ? "\n" : "\n\n") + markdown);
|
|
828
|
+
return file;
|
|
829
|
+
}
|
|
830
|
+
|
|
831
|
+
// The one step both `board.mjs record` and `doug board record` take when landing a card: promote its research
|
|
832
|
+
// note (if any), then append the run entry. Both command layers call this instead of each re-implementing the
|
|
833
|
+
// two calls, so the promotion cannot be added to one surface and not the other (card cli-record-promotion-drift).
|
|
834
|
+
// Promote first: appendFileSync is not idempotent, so a promotion failure must leave the entry unwritten, not
|
|
835
|
+
// written twice on the retry that follows a loud failure.
|
|
836
|
+
export function recordLanding(dir, id, markdown) {
|
|
837
|
+
const promoted = promoteResearchNote(dir, id);
|
|
838
|
+
const file = appendRun(dir, markdown);
|
|
839
|
+
return { file, promoted };
|
|
840
|
+
}
|