do-better 1.0.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/LICENSE +21 -0
- package/README.md +265 -0
- package/bin/cli.js +262 -0
- package/do-better/SKILL.md +417 -0
- package/do-better/references/refute-charter.md +170 -0
- package/do-better/references/scoring.md +98 -0
- package/do-better/references/taxonomy.md +136 -0
- package/do-better/references/templates/charter-template.md +71 -0
- package/do-better/references/templates/finding-template.md +56 -0
- package/do-better/references/templates/rail-template.md +74 -0
- package/do-better/references/templates/roadmap-template.md +67 -0
- package/do-better/references/templates/ticket-template.md +78 -0
- package/do-better/references/verification.md +129 -0
- package/package.json +20 -0
- package/src/adlc.js +331 -0
- package/src/artifacts.js +580 -0
- package/src/charter.js +657 -0
- package/src/comprehend.js +553 -0
- package/src/identify.js +1119 -0
- package/src/llm.js +530 -0
- package/src/rail.js +533 -0
- package/src/refresh.js +256 -0
- package/src/roadmap.js +630 -0
- package/src/scan.js +313 -0
- package/src/state.js +260 -0
- package/src/utils.js +449 -0
package/src/scan.js
ADDED
|
@@ -0,0 +1,313 @@
|
|
|
1
|
+
// src/scan.js — D-1: cheap repo scan. Deterministic facts (codemap inputs,
|
|
2
|
+
// incantations, sizes) + a cheap-tier codemap draft. No gate; feeds D0 charter
|
|
3
|
+
// questions with concrete code facts (spec D8, §6 cheap tier).
|
|
4
|
+
import fs from "node:fs";
|
|
5
|
+
import path from "node:path";
|
|
6
|
+
import { OpError, git, gitHeadSha, readJsonSafe, truncate, warnIfDirtyTree } from "./utils.js";
|
|
7
|
+
import { addSpend, pinSha, recordPhase } from "./state.js";
|
|
8
|
+
import { LAYOUT, writeArtifact } from "./artifacts.js";
|
|
9
|
+
import { withFallback } from "./llm.js";
|
|
10
|
+
|
|
11
|
+
export const PHASE_ID = "scan";
|
|
12
|
+
|
|
13
|
+
const MAX_LOC_FILE_BYTES = 1024 * 1024; // LOC counted only for files ≤ 1MB
|
|
14
|
+
const TOP_FILES = 20;
|
|
15
|
+
const TOP_DIRS = 15;
|
|
16
|
+
const README_CHARS = 1024;
|
|
17
|
+
const MANIFEST_NAMES = new Set([
|
|
18
|
+
"package.json", "go.mod", "Cargo.toml", "pyproject.toml", "requirements.txt",
|
|
19
|
+
"Gemfile", "pom.xml", "build.gradle", "composer.json", "mix.exs",
|
|
20
|
+
]);
|
|
21
|
+
const TEST_DIR_SEGMENTS = new Set(["test", "tests", "__tests__", "spec", "specs"]);
|
|
22
|
+
const OFFLINE_MARKER = Symbol("scan-offline");
|
|
23
|
+
|
|
24
|
+
function requireHeadSha(root, exec) {
|
|
25
|
+
try {
|
|
26
|
+
return gitHeadSha(root, exec);
|
|
27
|
+
} catch (err) {
|
|
28
|
+
throw new OpError(
|
|
29
|
+
"do-better requires a git repository with at least one commit (claims are SHA-pinned). " +
|
|
30
|
+
`Could not resolve HEAD in ${root}: ${err.message}`
|
|
31
|
+
);
|
|
32
|
+
}
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
function countNewlines(buf) {
|
|
36
|
+
let n = 0;
|
|
37
|
+
for (let i = 0; i < buf.length; i++) if (buf[i] === 0x0a) n += 1;
|
|
38
|
+
return n;
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
function statFile(root, file) {
|
|
42
|
+
let size = 0;
|
|
43
|
+
let loc = 0;
|
|
44
|
+
try {
|
|
45
|
+
const abs = path.join(root, file);
|
|
46
|
+
const st = fs.statSync(abs);
|
|
47
|
+
size = st.size;
|
|
48
|
+
if (st.isFile() && size > 0 && size <= MAX_LOC_FILE_BYTES) {
|
|
49
|
+
loc = countNewlines(fs.readFileSync(abs));
|
|
50
|
+
}
|
|
51
|
+
} catch {
|
|
52
|
+
// tracked but absent from the worktree — keep zeros
|
|
53
|
+
}
|
|
54
|
+
const extRaw = path.extname(path.basename(file));
|
|
55
|
+
const ext = extRaw ? extRaw.slice(1).toLowerCase() : "(none)";
|
|
56
|
+
return { file, size, loc, ext };
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
function computeExtHistogram(infos) {
|
|
60
|
+
const counts = new Map();
|
|
61
|
+
for (const info of infos) counts.set(info.ext, (counts.get(info.ext) ?? 0) + 1);
|
|
62
|
+
const sorted = [...counts.entries()].sort((a, b) => b[1] - a[1] || a[0].localeCompare(b[0]));
|
|
63
|
+
return Object.fromEntries(sorted);
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
function computeTopDirs(infos) {
|
|
67
|
+
const map = new Map();
|
|
68
|
+
for (const info of infos) {
|
|
69
|
+
const dir = info.file.includes("/") ? info.file.split("/")[0] : "(root)";
|
|
70
|
+
const cur = map.get(dir) ?? { dir, files: 0, loc: 0 };
|
|
71
|
+
map.set(dir, { dir, files: cur.files + 1, loc: cur.loc + info.loc });
|
|
72
|
+
}
|
|
73
|
+
return [...map.values()]
|
|
74
|
+
.sort((a, b) => b.loc - a.loc || b.files - a.files || a.dir.localeCompare(b.dir))
|
|
75
|
+
.slice(0, TOP_DIRS);
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
function readMakefileTargets(root, files) {
|
|
79
|
+
if (!files.includes("Makefile")) return [];
|
|
80
|
+
let text;
|
|
81
|
+
try {
|
|
82
|
+
text = fs.readFileSync(path.join(root, "Makefile"), "utf8");
|
|
83
|
+
} catch {
|
|
84
|
+
return [];
|
|
85
|
+
}
|
|
86
|
+
const targets = [];
|
|
87
|
+
for (const line of text.split("\n")) {
|
|
88
|
+
const m = /^([A-Za-z0-9][A-Za-z0-9_.-]*)\s*:(?!=)/.exec(line);
|
|
89
|
+
if (m && !targets.includes(m[1])) targets.push(m[1]);
|
|
90
|
+
}
|
|
91
|
+
return targets;
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
function collectIncantations(root, files) {
|
|
95
|
+
const scripts = {};
|
|
96
|
+
const pkg = readJsonSafe(path.join(root, "package.json"));
|
|
97
|
+
if (pkg && pkg.scripts && typeof pkg.scripts === "object" && !Array.isArray(pkg.scripts)) {
|
|
98
|
+
for (const [name, cmd] of Object.entries(pkg.scripts)) {
|
|
99
|
+
if (typeof cmd === "string") scripts[name] = cmd;
|
|
100
|
+
}
|
|
101
|
+
}
|
|
102
|
+
for (const target of readMakefileTargets(root, files)) {
|
|
103
|
+
if (!(target in scripts)) scripts[target] = `make ${target}`;
|
|
104
|
+
}
|
|
105
|
+
const ci = files.filter(
|
|
106
|
+
(f) =>
|
|
107
|
+
f.startsWith(".github/workflows/") ||
|
|
108
|
+
f === ".gitlab-ci.yml" ||
|
|
109
|
+
path.basename(f) === "Jenkinsfile"
|
|
110
|
+
);
|
|
111
|
+
const docker = files.filter((f) => {
|
|
112
|
+
const b = path.basename(f);
|
|
113
|
+
return /^Dockerfile(\..+)?$/.test(b) || /^(docker-)?compose([.-][\w.-]*)?\.ya?ml$/.test(b);
|
|
114
|
+
});
|
|
115
|
+
return { scripts, ci, docker };
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
function collectManifests(files) {
|
|
119
|
+
return files.filter((f) => MANIFEST_NAMES.has(path.basename(f)));
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
function collectDepCounts(root) {
|
|
123
|
+
const pkg = readJsonSafe(path.join(root, "package.json"));
|
|
124
|
+
const count = (v) => (v && typeof v === "object" && !Array.isArray(v) ? Object.keys(v).length : 0);
|
|
125
|
+
return { prod: count(pkg?.dependencies), dev: count(pkg?.devDependencies) };
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
function countTodoMarkers(root, exec) {
|
|
129
|
+
const res = exec("git", ["grep", "-c", "-I", "-E", "TODO|FIXME|HACK", "--", ".", ":(exclude).dobetter"], {
|
|
130
|
+
cwd: root,
|
|
131
|
+
});
|
|
132
|
+
if (!res || res.status !== 0 || !res.stdout) return 0; // exit 1 = no matches
|
|
133
|
+
return res.stdout
|
|
134
|
+
.split("\n")
|
|
135
|
+
.filter(Boolean)
|
|
136
|
+
.reduce((sum, line) => {
|
|
137
|
+
const n = Number.parseInt(line.slice(line.lastIndexOf(":") + 1), 10);
|
|
138
|
+
return sum + (Number.isFinite(n) ? n : 0);
|
|
139
|
+
}, 0);
|
|
140
|
+
}
|
|
141
|
+
|
|
142
|
+
function collectTestDirs(files) {
|
|
143
|
+
const dirs = new Set();
|
|
144
|
+
for (const file of files) {
|
|
145
|
+
const segs = file.split("/");
|
|
146
|
+
for (let i = 0; i < segs.length - 1; i++) {
|
|
147
|
+
if (TEST_DIR_SEGMENTS.has(segs[i].toLowerCase())) {
|
|
148
|
+
dirs.add(segs.slice(0, i + 1).join("/"));
|
|
149
|
+
break;
|
|
150
|
+
}
|
|
151
|
+
}
|
|
152
|
+
}
|
|
153
|
+
return [...dirs].sort();
|
|
154
|
+
}
|
|
155
|
+
|
|
156
|
+
function readReadmeExcerpt(root, files) {
|
|
157
|
+
const candidate = files.find((f) => !f.includes("/") && /^readme(\.[\w.]+)?$/i.test(f));
|
|
158
|
+
if (!candidate) return "";
|
|
159
|
+
try {
|
|
160
|
+
return fs.readFileSync(path.join(root, candidate), "utf8").slice(0, README_CHARS);
|
|
161
|
+
} catch {
|
|
162
|
+
return "";
|
|
163
|
+
}
|
|
164
|
+
}
|
|
165
|
+
|
|
166
|
+
// Deterministic, no-LLM fact collection. Shape is the D-1 Facts contract (§7).
|
|
167
|
+
export function collectRepoFacts(root, exec) {
|
|
168
|
+
const headSha = requireHeadSha(root, exec);
|
|
169
|
+
const files = git(root, ["ls-files"], exec)
|
|
170
|
+
.split("\n")
|
|
171
|
+
.map((l) => l.trim())
|
|
172
|
+
.filter(Boolean);
|
|
173
|
+
const infos = files.map((file) => statFile(root, file));
|
|
174
|
+
const largestFiles = [...infos]
|
|
175
|
+
.sort((a, b) => b.loc - a.loc || a.file.localeCompare(b.file))
|
|
176
|
+
.slice(0, TOP_FILES)
|
|
177
|
+
.map(({ file, loc }) => ({ file, loc }));
|
|
178
|
+
return {
|
|
179
|
+
headSha,
|
|
180
|
+
fileCount: infos.length,
|
|
181
|
+
locTotal: infos.reduce((sum, f) => sum + f.loc, 0),
|
|
182
|
+
extHistogram: computeExtHistogram(infos),
|
|
183
|
+
topDirs: computeTopDirs(infos),
|
|
184
|
+
largestFiles,
|
|
185
|
+
incantations: collectIncantations(root, files),
|
|
186
|
+
manifests: collectManifests(files),
|
|
187
|
+
depCounts: collectDepCounts(root),
|
|
188
|
+
todoCount: countTodoMarkers(root, exec),
|
|
189
|
+
testDirs: collectTestDirs(files),
|
|
190
|
+
readmeFirstKB: readReadmeExcerpt(root, files),
|
|
191
|
+
};
|
|
192
|
+
}
|
|
193
|
+
|
|
194
|
+
function formatList(arr, empty = "(none)") {
|
|
195
|
+
return arr && arr.length ? arr.join(", ") : empty;
|
|
196
|
+
}
|
|
197
|
+
|
|
198
|
+
function deterministicTreeCodemap(facts) {
|
|
199
|
+
const lines = [
|
|
200
|
+
"# Codemap (draft)",
|
|
201
|
+
"",
|
|
202
|
+
`_Generated by \`do-better scan\` @ ${facts.headSha.slice(0, 7)} (structure-only). D1 comprehend verifies and extends this draft._`,
|
|
203
|
+
"",
|
|
204
|
+
"## Top-level directories",
|
|
205
|
+
];
|
|
206
|
+
for (const d of facts.topDirs) {
|
|
207
|
+
lines.push(`- \`${d.dir}\` — ${d.files} file(s), ${d.loc} LOC (structure-only)`);
|
|
208
|
+
}
|
|
209
|
+
lines.push("", "## Largest files");
|
|
210
|
+
for (const f of facts.largestFiles.slice(0, 10)) {
|
|
211
|
+
lines.push(`- \`${f.file}\` — ${f.loc} LOC (structure-only)`);
|
|
212
|
+
}
|
|
213
|
+
lines.push(
|
|
214
|
+
"",
|
|
215
|
+
"## Incantations",
|
|
216
|
+
`- Scripts: ${formatList(Object.keys(facts.incantations.scripts))}`,
|
|
217
|
+
`- CI: ${formatList(facts.incantations.ci)}`,
|
|
218
|
+
`- Docker: ${formatList(facts.incantations.docker)}`,
|
|
219
|
+
"",
|
|
220
|
+
"## Manifests",
|
|
221
|
+
`- ${formatList(facts.manifests)} (${facts.depCounts.prod} prod / ${facts.depCounts.dev} dev deps)`,
|
|
222
|
+
""
|
|
223
|
+
);
|
|
224
|
+
return lines.join("\n");
|
|
225
|
+
}
|
|
226
|
+
|
|
227
|
+
function buildCodemapPrompt(facts) {
|
|
228
|
+
const summary = {
|
|
229
|
+
fileCount: facts.fileCount,
|
|
230
|
+
locTotal: facts.locTotal,
|
|
231
|
+
extHistogram: facts.extHistogram,
|
|
232
|
+
topDirs: facts.topDirs,
|
|
233
|
+
largestFiles: facts.largestFiles,
|
|
234
|
+
incantations: facts.incantations,
|
|
235
|
+
manifests: facts.manifests,
|
|
236
|
+
testDirs: facts.testDirs,
|
|
237
|
+
};
|
|
238
|
+
return [
|
|
239
|
+
"Draft a codemap for an existing (brownfield) repository.",
|
|
240
|
+
"Give a one-line purpose for each top-level directory and each major file.",
|
|
241
|
+
"Return ONLY markdown starting with `# Codemap (draft)`, with sections:",
|
|
242
|
+
"`## Top-level directories`, `## Largest files`, `## Incantations`.",
|
|
243
|
+
"Do not invent files — describe only what appears in the facts below.",
|
|
244
|
+
"",
|
|
245
|
+
"Repository facts (deterministic scan output):",
|
|
246
|
+
JSON.stringify(summary, null, 2),
|
|
247
|
+
"",
|
|
248
|
+
"README excerpt:",
|
|
249
|
+
truncate(facts.readmeFirstKB || "(no README found)", 1000),
|
|
250
|
+
].join("\n");
|
|
251
|
+
}
|
|
252
|
+
|
|
253
|
+
function buildSummary(facts) {
|
|
254
|
+
const topExts = Object.entries(facts.extHistogram)
|
|
255
|
+
.slice(0, 3)
|
|
256
|
+
.map(([ext, n]) => `${ext}×${n}`)
|
|
257
|
+
.join(", ");
|
|
258
|
+
return (
|
|
259
|
+
`Scanned ${facts.fileCount} files (${facts.locTotal} LOC; ${topExts}) @ ${facts.headSha.slice(0, 7)}. ` +
|
|
260
|
+
`Scripts: ${formatList(Object.keys(facts.incantations.scripts))}. ` +
|
|
261
|
+
`CI: ${facts.incantations.ci.length} config(s). ` +
|
|
262
|
+
`TODO/FIXME/HACK markers: ${facts.todoCount}. ` +
|
|
263
|
+
`Test dirs: ${formatList(facts.testDirs, "none")}. ` +
|
|
264
|
+
`Codemap draft → .dobetter/${LAYOUT.comprehension.codemap}`
|
|
265
|
+
);
|
|
266
|
+
}
|
|
267
|
+
|
|
268
|
+
export async function run(ctx) {
|
|
269
|
+
const { root, dotdir, exec, llm, log } = ctx;
|
|
270
|
+
log.phase("D-1", "Scan");
|
|
271
|
+
const headSha = requireHeadSha(root, exec);
|
|
272
|
+
warnIfDirtyTree(root, exec, log, "D-1 scan"); // H10 — declared, never silent
|
|
273
|
+
|
|
274
|
+
log.step("Collecting deterministic repo facts (no LLM)");
|
|
275
|
+
const facts = collectRepoFacts(root, exec);
|
|
276
|
+
|
|
277
|
+
let state = ctx.state;
|
|
278
|
+
try {
|
|
279
|
+
log.step("Drafting codemap (cheap tier)");
|
|
280
|
+
const value = await withFallback(
|
|
281
|
+
llm,
|
|
282
|
+
{ prompt: buildCodemapPrompt(facts), tier: "cheap", label: "codemap" },
|
|
283
|
+
() => OFFLINE_MARKER
|
|
284
|
+
);
|
|
285
|
+
const body =
|
|
286
|
+
value === OFFLINE_MARKER || typeof value !== "string" || !value.trim()
|
|
287
|
+
? deterministicTreeCodemap(facts)
|
|
288
|
+
: value;
|
|
289
|
+
|
|
290
|
+
// writeArtifact/recordPhase run inside the try so a post-call failure
|
|
291
|
+
// (disk full, permissions) still attaches accumulated spend to err.state
|
|
292
|
+
// below — otherwise the paid codemap call's cost vanishes from
|
|
293
|
+
// budget.spentUSD and later runs under-count against --budget (H11).
|
|
294
|
+
writeArtifact(dotdir, LAYOUT.comprehension.codemap, {
|
|
295
|
+
meta: { generatedBy: "scan", headSha, draft: true },
|
|
296
|
+
body,
|
|
297
|
+
});
|
|
298
|
+
|
|
299
|
+
state = addSpend(state, PHASE_ID, llm.drainSpend());
|
|
300
|
+
state = recordPhase(state, PHASE_ID, { status: "done", sha: headSha, now: ctx.now(), facts });
|
|
301
|
+
state = pinSha(state, PHASE_ID, headSha);
|
|
302
|
+
} catch (err) {
|
|
303
|
+
// Persist spend accumulated before the failure (CLI saves err.state).
|
|
304
|
+
// Guard against double-drain: if state already absorbed the spend (failure
|
|
305
|
+
// after addSpend), the accumulator is empty and this is a no-op.
|
|
306
|
+
if (!err.state) err.state = addSpend(state, PHASE_ID, llm.drainSpend());
|
|
307
|
+
throw err;
|
|
308
|
+
}
|
|
309
|
+
|
|
310
|
+
const summary = buildSummary(facts);
|
|
311
|
+
log.success("Scan complete");
|
|
312
|
+
return { state, gate: null, summary };
|
|
313
|
+
}
|
package/src/state.js
ADDED
|
@@ -0,0 +1,260 @@
|
|
|
1
|
+
// src/state.js — state.json schema v1: load/save, run history, spend, gates,
|
|
2
|
+
// SHA pins. All functions are pure (return new objects); loadState/saveState
|
|
3
|
+
// are the only I/O.
|
|
4
|
+
|
|
5
|
+
import fs from "node:fs";
|
|
6
|
+
import path from "node:path";
|
|
7
|
+
import { OpError, TAXONOMY, nowIso, writeFileAtomic } from "./utils.js";
|
|
8
|
+
|
|
9
|
+
export const STATE_VERSION = 1;
|
|
10
|
+
export const PHASES = ["scan", "charter", "comprehend", "identify", "roadmap", "rail", "refresh"];
|
|
11
|
+
|
|
12
|
+
const MAX_RUNS = 50;
|
|
13
|
+
const TOOL_VERSION = "0.1.0";
|
|
14
|
+
|
|
15
|
+
const round6 = (v) => Math.round(v * 1e6) / 1e6;
|
|
16
|
+
const clone = (s) => structuredClone(s);
|
|
17
|
+
|
|
18
|
+
function baseSpend() {
|
|
19
|
+
return { calls: 0, tokensIn: 0, tokensOut: 0, costUSD: 0 };
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
function basePhase(extra = {}) {
|
|
23
|
+
return { status: "pending", completedAt: null, sha: null, spend: baseSpend(), ...extra };
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
export function defaultState({ headSha = null, now = nowIso() } = {}) {
|
|
27
|
+
return {
|
|
28
|
+
version: STATE_VERSION,
|
|
29
|
+
createdAt: now,
|
|
30
|
+
tool: { name: "do-better", version: TOOL_VERSION },
|
|
31
|
+
target: { root: ".", headSha },
|
|
32
|
+
pins: {
|
|
33
|
+
scan: null,
|
|
34
|
+
charter: null,
|
|
35
|
+
comprehend: null,
|
|
36
|
+
identify: null,
|
|
37
|
+
roadmap: null,
|
|
38
|
+
rail: null,
|
|
39
|
+
refresh: null,
|
|
40
|
+
},
|
|
41
|
+
phases: {
|
|
42
|
+
scan: basePhase({ facts: null }),
|
|
43
|
+
charter: basePhase(),
|
|
44
|
+
comprehend: basePhase({ divergence: null, readings: null }),
|
|
45
|
+
identify: basePhase({ passesByDimension: {}, killed: 0, verified: 0 }),
|
|
46
|
+
roadmap: basePhase({ ticketCount: 0, declinedCount: 0 }),
|
|
47
|
+
rail: basePhase({ railsAuthored: 0, behaviorsCovered: 0, behaviorsGapped: 0 }),
|
|
48
|
+
refresh: basePhase({ changedFiles: 0, staleClaims: 0 }),
|
|
49
|
+
},
|
|
50
|
+
gates: {
|
|
51
|
+
charter: { approved: false, approvedAt: null, charterSha256: null },
|
|
52
|
+
comprehend: { passed: false, divergence: null, threshold: 0.25, degraded: null },
|
|
53
|
+
identify: { passed: false, dryPassesByDimension: {}, unverified: 0 },
|
|
54
|
+
roadmap: {
|
|
55
|
+
approved: false,
|
|
56
|
+
approvedAt: null,
|
|
57
|
+
coldstartClean: false,
|
|
58
|
+
coldstartDegraded: null,
|
|
59
|
+
roadmapSha256: null,
|
|
60
|
+
},
|
|
61
|
+
rail: {
|
|
62
|
+
passed: false,
|
|
63
|
+
railsGreen: false,
|
|
64
|
+
hollowAudited: false,
|
|
65
|
+
hollowSurvivors: 0,
|
|
66
|
+
preflight: null,
|
|
67
|
+
},
|
|
68
|
+
},
|
|
69
|
+
budget: { limitUSD: null, spentUSD: 0 },
|
|
70
|
+
runs: [],
|
|
71
|
+
roadmapHistory: [],
|
|
72
|
+
counters: {
|
|
73
|
+
findings: Object.fromEntries(TAXONOMY.map((d) => [d.id, 0])),
|
|
74
|
+
tickets: 0,
|
|
75
|
+
},
|
|
76
|
+
adlc: {
|
|
77
|
+
mode: null,
|
|
78
|
+
dir: null,
|
|
79
|
+
probedAt: null,
|
|
80
|
+
available: {
|
|
81
|
+
parallax: false,
|
|
82
|
+
coldstart: false,
|
|
83
|
+
"hollow-test": false,
|
|
84
|
+
"behavior-diff": false,
|
|
85
|
+
preflight: false,
|
|
86
|
+
"skill-mining": false,
|
|
87
|
+
},
|
|
88
|
+
},
|
|
89
|
+
};
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
export function loadState(dotdir) {
|
|
93
|
+
const p = path.join(dotdir, "state.json");
|
|
94
|
+
if (!fs.existsSync(p)) return { state: null, existed: false };
|
|
95
|
+
let raw;
|
|
96
|
+
try {
|
|
97
|
+
raw = fs.readFileSync(p, "utf8");
|
|
98
|
+
} catch (e) {
|
|
99
|
+
throw new OpError(`Cannot read state file at ${p}: ${e.message}`);
|
|
100
|
+
}
|
|
101
|
+
let parsed;
|
|
102
|
+
try {
|
|
103
|
+
parsed = JSON.parse(raw);
|
|
104
|
+
} catch {
|
|
105
|
+
throw new OpError(
|
|
106
|
+
`Corrupt state file at ${p} — repair it by hand or delete .dobetter/state.json to start fresh (artifacts are kept).`,
|
|
107
|
+
);
|
|
108
|
+
}
|
|
109
|
+
if (parsed.version !== STATE_VERSION) {
|
|
110
|
+
throw new OpError(
|
|
111
|
+
`Unsupported state.json version ${JSON.stringify(parsed.version)} at ${p} (this build expects ${STATE_VERSION}). ` +
|
|
112
|
+
`Delete .dobetter/state.json to re-initialize (artifacts are kept).`,
|
|
113
|
+
);
|
|
114
|
+
}
|
|
115
|
+
return { state: parsed, existed: true };
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
export function saveState(dotdir, state) {
|
|
119
|
+
writeFileAtomic(path.join(dotdir, "state.json"), `${JSON.stringify(state, null, 2)}\n`);
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
function assertPhase(phase) {
|
|
123
|
+
if (!PHASES.includes(phase)) throw new OpError(`Unknown phase: ${JSON.stringify(phase)}`);
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
export function beginRun(state, { command, provider = null, headSha = null, now = nowIso() } = {}) {
|
|
127
|
+
const s = clone(state);
|
|
128
|
+
const base = `run-${String(now).slice(0, 19).replace(/:/g, "-")}`;
|
|
129
|
+
let id = base;
|
|
130
|
+
let n = 2;
|
|
131
|
+
while (s.runs.some((r) => r.id === id)) id = `${base}-${n++}`;
|
|
132
|
+
s.runs.push({
|
|
133
|
+
id,
|
|
134
|
+
command,
|
|
135
|
+
provider,
|
|
136
|
+
startedAt: now,
|
|
137
|
+
finishedAt: null,
|
|
138
|
+
headSha,
|
|
139
|
+
ok: null,
|
|
140
|
+
spendUSD: 0,
|
|
141
|
+
});
|
|
142
|
+
while (s.runs.length > MAX_RUNS) s.runs.shift();
|
|
143
|
+
return { state: s, runId: id };
|
|
144
|
+
}
|
|
145
|
+
|
|
146
|
+
export function finishRun(state, runId, { now = nowIso(), ok } = {}) {
|
|
147
|
+
const s = clone(state);
|
|
148
|
+
const run = s.runs.find((r) => r.id === runId);
|
|
149
|
+
if (!run) throw new OpError(`Unknown run id: ${runId}`);
|
|
150
|
+
const otherSpend = s.runs
|
|
151
|
+
.filter((r) => r.id !== runId)
|
|
152
|
+
.reduce((acc, r) => acc + (r.spendUSD || 0), 0);
|
|
153
|
+
run.finishedAt = now;
|
|
154
|
+
run.ok = ok === true;
|
|
155
|
+
run.spendUSD = round6(Math.max(0, s.budget.spentUSD - otherSpend));
|
|
156
|
+
return s;
|
|
157
|
+
}
|
|
158
|
+
|
|
159
|
+
export function recordPhase(state, phase, { status, sha = null, now = nowIso(), facts } = {}) {
|
|
160
|
+
assertPhase(phase);
|
|
161
|
+
if (!["done", "failed", "stale"].includes(status)) {
|
|
162
|
+
throw new OpError(`Invalid phase status: ${JSON.stringify(status)} (expected done|failed|stale)`);
|
|
163
|
+
}
|
|
164
|
+
const s = clone(state);
|
|
165
|
+
const p = s.phases[phase];
|
|
166
|
+
p.status = status;
|
|
167
|
+
if (status === "done") p.completedAt = now;
|
|
168
|
+
if (sha != null) p.sha = sha;
|
|
169
|
+
if (status === "done" && sha != null) s.target.headSha = sha;
|
|
170
|
+
if (phase === "scan" && facts !== undefined) p.facts = facts;
|
|
171
|
+
return s;
|
|
172
|
+
}
|
|
173
|
+
|
|
174
|
+
export function addSpend(state, phase, { calls = 0, tokensIn = 0, tokensOut = 0, costUSD = 0 } = {}) {
|
|
175
|
+
assertPhase(phase);
|
|
176
|
+
const s = clone(state);
|
|
177
|
+
const sp = s.phases[phase].spend;
|
|
178
|
+
sp.calls += Number(calls) || 0;
|
|
179
|
+
sp.tokensIn += Number(tokensIn) || 0;
|
|
180
|
+
sp.tokensOut += Number(tokensOut) || 0;
|
|
181
|
+
sp.costUSD += Number(costUSD) || 0;
|
|
182
|
+
s.budget.spentUSD += Number(costUSD) || 0;
|
|
183
|
+
return s;
|
|
184
|
+
}
|
|
185
|
+
|
|
186
|
+
export function setGate(state, gateName, patch) {
|
|
187
|
+
if (!state.gates || !(gateName in state.gates)) {
|
|
188
|
+
throw new OpError(`Unknown gate: ${JSON.stringify(gateName)}`);
|
|
189
|
+
}
|
|
190
|
+
const s = clone(state);
|
|
191
|
+
Object.assign(s.gates[gateName], patch);
|
|
192
|
+
return s;
|
|
193
|
+
}
|
|
194
|
+
|
|
195
|
+
export function pinSha(state, phase, sha) {
|
|
196
|
+
assertPhase(phase);
|
|
197
|
+
const s = clone(state);
|
|
198
|
+
s.pins[phase] = sha;
|
|
199
|
+
return s;
|
|
200
|
+
}
|
|
201
|
+
|
|
202
|
+
export function nextFindingId(state, dimensionId) {
|
|
203
|
+
if (typeof dimensionId !== "string" || dimensionId.length === 0) {
|
|
204
|
+
throw new OpError(`Invalid dimension id: ${JSON.stringify(dimensionId)}`);
|
|
205
|
+
}
|
|
206
|
+
const s = clone(state);
|
|
207
|
+
const current = s.counters.findings[dimensionId] ?? 0;
|
|
208
|
+
const n = current + 1;
|
|
209
|
+
s.counters.findings[dimensionId] = n;
|
|
210
|
+
const abbr = dimensionId.replace(/[^a-z0-9]/gi, "").slice(0, 4).toUpperCase() || "GEN";
|
|
211
|
+
const id = `F-${abbr}-${String(n).padStart(4, "0")}`;
|
|
212
|
+
return { state: s, id };
|
|
213
|
+
}
|
|
214
|
+
|
|
215
|
+
export function recordRoadmapHash(state, { sha256, headSha = null, now = nowIso() } = {}) {
|
|
216
|
+
const s = clone(state);
|
|
217
|
+
const last = s.roadmapHistory[s.roadmapHistory.length - 1];
|
|
218
|
+
if (last && last.sha256 === sha256) return s; // dedupe consecutive
|
|
219
|
+
s.roadmapHistory.push({ sha256, headSha, generatedAt: now });
|
|
220
|
+
return s;
|
|
221
|
+
}
|
|
222
|
+
|
|
223
|
+
export function remainingBudgetUSD(state) {
|
|
224
|
+
const limit = state.budget?.limitUSD;
|
|
225
|
+
if (limit === null || limit === undefined) return null;
|
|
226
|
+
return round6(limit - (state.budget.spentUSD || 0));
|
|
227
|
+
}
|
|
228
|
+
|
|
229
|
+
// Resume pointer for `do-better run` (D8/D9). Single source of truth.
|
|
230
|
+
// A phase is complete when status === "done" AND its gate (if any) passed /
|
|
231
|
+
// approved; charter complete ⇔ gates.charter.approved; roadmap complete ⇔
|
|
232
|
+
// gates.roadmap.approved && gates.roadmap.coldstartClean.
|
|
233
|
+
const RUN_ORDER = ["scan", "charter", "comprehend", "identify", "roadmap", "rail"];
|
|
234
|
+
|
|
235
|
+
function phaseComplete(state, phase) {
|
|
236
|
+
const done = state.phases[phase]?.status === "done";
|
|
237
|
+
switch (phase) {
|
|
238
|
+
case "scan":
|
|
239
|
+
return done;
|
|
240
|
+
case "charter":
|
|
241
|
+
return state.gates.charter.approved === true;
|
|
242
|
+
case "comprehend":
|
|
243
|
+
return done && state.gates.comprehend.passed === true;
|
|
244
|
+
case "identify":
|
|
245
|
+
return done && state.gates.identify.passed === true;
|
|
246
|
+
case "roadmap":
|
|
247
|
+
return state.gates.roadmap.approved === true && state.gates.roadmap.coldstartClean === true;
|
|
248
|
+
case "rail":
|
|
249
|
+
return done && state.gates.rail.passed === true;
|
|
250
|
+
default:
|
|
251
|
+
return false;
|
|
252
|
+
}
|
|
253
|
+
}
|
|
254
|
+
|
|
255
|
+
export function nextIncompletePhase(state) {
|
|
256
|
+
for (const phase of RUN_ORDER) {
|
|
257
|
+
if (!phaseComplete(state, phase)) return phase;
|
|
258
|
+
}
|
|
259
|
+
return null;
|
|
260
|
+
}
|