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/utils.js
ADDED
|
@@ -0,0 +1,449 @@
|
|
|
1
|
+
// src/utils.js — shared utilities: argv parsing, logging, error classes,
|
|
2
|
+
// git/exec helpers, guards. Zero runtime dependencies (node builtins only).
|
|
3
|
+
|
|
4
|
+
import { spawnSync } from "node:child_process";
|
|
5
|
+
import { createHash } from "node:crypto";
|
|
6
|
+
import fs from "node:fs";
|
|
7
|
+
import path from "node:path";
|
|
8
|
+
import { fileURLToPath } from "node:url";
|
|
9
|
+
|
|
10
|
+
// ---------------------------------------------------------------------------
|
|
11
|
+
// Colors + logging (skill-mining style: raw ANSI fns, disabled when not a TTY)
|
|
12
|
+
// ---------------------------------------------------------------------------
|
|
13
|
+
|
|
14
|
+
const colorEnabled = !process.env.NO_COLOR && process.stdout.isTTY === true;
|
|
15
|
+
const wrap = (open, close) => (s) =>
|
|
16
|
+
colorEnabled ? `[${open}m${s}[${close}m` : String(s);
|
|
17
|
+
|
|
18
|
+
export const colors = {
|
|
19
|
+
bold: wrap(1, 22),
|
|
20
|
+
dim: wrap(2, 22),
|
|
21
|
+
red: wrap(31, 39),
|
|
22
|
+
green: wrap(32, 39),
|
|
23
|
+
yellow: wrap(33, 39),
|
|
24
|
+
blue: wrap(34, 39),
|
|
25
|
+
cyan: wrap(36, 39),
|
|
26
|
+
magenta: wrap(35, 39),
|
|
27
|
+
};
|
|
28
|
+
|
|
29
|
+
export const log = {
|
|
30
|
+
info: (m) => console.log(`${colors.blue("ℹ")} ${m}`),
|
|
31
|
+
success: (m) => console.log(`${colors.green("✔")} ${m}`),
|
|
32
|
+
warn: (m) => console.error(`${colors.yellow("⚠")} ${m}`),
|
|
33
|
+
error: (m) => console.error(`${colors.red("✖")} ${m}`),
|
|
34
|
+
phase: (id, name) => console.log(colors.bold(colors.cyan(`\n=== ${id}: ${name} ===`))),
|
|
35
|
+
gate: (name, human) =>
|
|
36
|
+
console.log(colors.bold(colors.magenta(`\n=== ⟂ Gate: ${name}${human ? " (HUMAN)" : ""} ===`))),
|
|
37
|
+
step: (m) => console.log(` ${m}`),
|
|
38
|
+
substep: (m) => console.log(colors.dim(` ${m}`)),
|
|
39
|
+
errorTrace: (err) => console.error(colors.dim(String((err && err.stack) || err))),
|
|
40
|
+
};
|
|
41
|
+
|
|
42
|
+
// Under --json (H17), decorative progress must NOT pollute stdout — stdout has
|
|
43
|
+
// to carry ONLY the final JSON object so a CI wrapper can parse the whole
|
|
44
|
+
// stream. This variant routes every human-facing line (including the ones phase
|
|
45
|
+
// modules emit via ctx.log: phase/step/substep/success/info) to STDERR; warn
|
|
46
|
+
// and error already go to stderr. Callers pass this as ctx.log when flags.json.
|
|
47
|
+
export const stderrLog = {
|
|
48
|
+
info: (m) => console.error(`${colors.blue("ℹ")} ${m}`),
|
|
49
|
+
success: (m) => console.error(`${colors.green("✔")} ${m}`),
|
|
50
|
+
warn: log.warn,
|
|
51
|
+
error: log.error,
|
|
52
|
+
phase: (id, name) => console.error(colors.bold(colors.cyan(`\n=== ${id}: ${name} ===`))),
|
|
53
|
+
gate: (name, human) =>
|
|
54
|
+
console.error(colors.bold(colors.magenta(`\n=== ⟂ Gate: ${name}${human ? " (HUMAN)" : ""} ===`))),
|
|
55
|
+
step: (m) => console.error(` ${m}`),
|
|
56
|
+
substep: (m) => console.error(colors.dim(` ${m}`)),
|
|
57
|
+
errorTrace: log.errorTrace,
|
|
58
|
+
};
|
|
59
|
+
|
|
60
|
+
// ---------------------------------------------------------------------------
|
|
61
|
+
// Error classes (exit-code contract: 0 success/human pause, 1 operational,
|
|
62
|
+
// 2 deterministic gate failure)
|
|
63
|
+
// ---------------------------------------------------------------------------
|
|
64
|
+
|
|
65
|
+
export class OpError extends Error {
|
|
66
|
+
constructor(message) {
|
|
67
|
+
super(message);
|
|
68
|
+
this.name = "OpError";
|
|
69
|
+
this.exitCode = 1;
|
|
70
|
+
}
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
export class BudgetError extends OpError {
|
|
74
|
+
constructor(message) {
|
|
75
|
+
super(message);
|
|
76
|
+
this.name = "BudgetError";
|
|
77
|
+
}
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
export class OfflineError extends OpError {
|
|
81
|
+
constructor(message) {
|
|
82
|
+
super(message);
|
|
83
|
+
this.name = "OfflineError";
|
|
84
|
+
}
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
export class GateError extends Error {
|
|
88
|
+
constructor(gate, detail) {
|
|
89
|
+
super(`Gate failed: ${gate} — ${detail}`);
|
|
90
|
+
this.name = "GateError";
|
|
91
|
+
this.exitCode = 2;
|
|
92
|
+
this.gate = gate;
|
|
93
|
+
this.detail = detail;
|
|
94
|
+
}
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
// Single constructor for a gate failure (H15) — the ONE correct way to build a
|
|
98
|
+
// GateError, replacing five divergent per-phase copies that misused the
|
|
99
|
+
// `(gate, detail)` constructor (passing a pre-formatted `${gate}: ${detail}`
|
|
100
|
+
// string as the gate arg produced garbled "Gate failed: X: Y — Y" messages).
|
|
101
|
+
// Attaches `state` for the CLI to persist when supplied. Message is well-formed:
|
|
102
|
+
// "Gate failed: <gate> — <detail>".
|
|
103
|
+
export function gateError(gate, detail, state) {
|
|
104
|
+
const err = new GateError(gate, detail);
|
|
105
|
+
if (state !== undefined) err.state = state;
|
|
106
|
+
return err;
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
// ---------------------------------------------------------------------------
|
|
110
|
+
// Commands + fixed taxonomy floor (D5 — order is canonical)
|
|
111
|
+
// ---------------------------------------------------------------------------
|
|
112
|
+
|
|
113
|
+
export const COMMANDS = new Set(["scan", "charter", "audit", "roadmap", "rail", "run", "refresh"]);
|
|
114
|
+
|
|
115
|
+
export const TAXONOMY = [
|
|
116
|
+
{ id: "correctness", label: "Correctness risk" },
|
|
117
|
+
{ id: "security", label: "Security" },
|
|
118
|
+
{ id: "maintainability", label: "Maintainability / debt" },
|
|
119
|
+
{ id: "performance", label: "Performance" },
|
|
120
|
+
{ id: "operability", label: "Operability" },
|
|
121
|
+
{ id: "test-quality", label: "Test quality" },
|
|
122
|
+
{ id: "dependency-health", label: "Dependency health" },
|
|
123
|
+
{ id: "dx", label: "Developer experience" },
|
|
124
|
+
];
|
|
125
|
+
|
|
126
|
+
// ---------------------------------------------------------------------------
|
|
127
|
+
// Argv parsing — hand-rolled, supports `--flag value` and `--flag=value`;
|
|
128
|
+
// warns on unknown flags, never dies. First positional consumed as command
|
|
129
|
+
// iff ∈ COMMANDS; second positional → flags.target.
|
|
130
|
+
// ---------------------------------------------------------------------------
|
|
131
|
+
|
|
132
|
+
const VALUE_FLAGS = new Map([
|
|
133
|
+
["--provider", "provider"],
|
|
134
|
+
["--budget", "budget"],
|
|
135
|
+
["--target", "target"],
|
|
136
|
+
["--model-cheap", "modelCheap"],
|
|
137
|
+
["--model-mid", "modelMid"],
|
|
138
|
+
["--model-frontier", "modelFrontier"],
|
|
139
|
+
["--n", "n"],
|
|
140
|
+
["--threshold", "threshold"],
|
|
141
|
+
]);
|
|
142
|
+
|
|
143
|
+
const BOOL_FLAGS = new Map([
|
|
144
|
+
["--offline", "offline"],
|
|
145
|
+
["--approve", "approve"],
|
|
146
|
+
["--yes", "yes"],
|
|
147
|
+
["--json", "json"],
|
|
148
|
+
["--help", "help"],
|
|
149
|
+
["-h", "help"],
|
|
150
|
+
]);
|
|
151
|
+
|
|
152
|
+
function coerceFlagValue(name, value) {
|
|
153
|
+
if (name === "--budget") {
|
|
154
|
+
const n = Number(value);
|
|
155
|
+
if (!Number.isFinite(n) || n <= 0) throw new OpError(`Invalid --budget: "${value}" (expected a positive number of USD)`);
|
|
156
|
+
return n;
|
|
157
|
+
}
|
|
158
|
+
if (name === "--n") {
|
|
159
|
+
const n = Number(value);
|
|
160
|
+
if (!Number.isInteger(n) || n < 1) throw new OpError(`Invalid --n: "${value}" (expected a positive integer)`);
|
|
161
|
+
return n;
|
|
162
|
+
}
|
|
163
|
+
if (name === "--threshold") {
|
|
164
|
+
const n = Number(value);
|
|
165
|
+
if (!Number.isFinite(n) || n < 0) throw new OpError(`Invalid --threshold: "${value}" (expected a non-negative number)`);
|
|
166
|
+
return n;
|
|
167
|
+
}
|
|
168
|
+
return value;
|
|
169
|
+
}
|
|
170
|
+
|
|
171
|
+
export function parseArgs(argv) {
|
|
172
|
+
const flags = {
|
|
173
|
+
command: null,
|
|
174
|
+
target: ".",
|
|
175
|
+
provider: null,
|
|
176
|
+
budget: null,
|
|
177
|
+
offline: false,
|
|
178
|
+
modelCheap: null,
|
|
179
|
+
modelMid: null,
|
|
180
|
+
modelFrontier: null,
|
|
181
|
+
n: null,
|
|
182
|
+
threshold: null,
|
|
183
|
+
approve: false,
|
|
184
|
+
yes: false,
|
|
185
|
+
json: false,
|
|
186
|
+
help: false,
|
|
187
|
+
};
|
|
188
|
+
const positionals = [];
|
|
189
|
+
|
|
190
|
+
for (let i = 0; i < argv.length; i++) {
|
|
191
|
+
const arg = argv[i];
|
|
192
|
+
if (!arg.startsWith("-")) {
|
|
193
|
+
positionals.push(arg);
|
|
194
|
+
continue;
|
|
195
|
+
}
|
|
196
|
+
let name = arg;
|
|
197
|
+
let value = null;
|
|
198
|
+
const eq = arg.indexOf("=");
|
|
199
|
+
if (eq !== -1) {
|
|
200
|
+
name = arg.slice(0, eq);
|
|
201
|
+
value = arg.slice(eq + 1);
|
|
202
|
+
}
|
|
203
|
+
if (BOOL_FLAGS.has(name)) {
|
|
204
|
+
flags[BOOL_FLAGS.get(name)] = true;
|
|
205
|
+
continue;
|
|
206
|
+
}
|
|
207
|
+
if (VALUE_FLAGS.has(name)) {
|
|
208
|
+
if (value === null) {
|
|
209
|
+
value = argv[i + 1];
|
|
210
|
+
if (value === undefined) throw new OpError(`Missing value for ${name}`);
|
|
211
|
+
i++;
|
|
212
|
+
}
|
|
213
|
+
flags[VALUE_FLAGS.get(name)] = coerceFlagValue(name, value);
|
|
214
|
+
continue;
|
|
215
|
+
}
|
|
216
|
+
log.warn(`Unknown flag ignored: ${arg}`);
|
|
217
|
+
}
|
|
218
|
+
|
|
219
|
+
if (positionals.length > 0) {
|
|
220
|
+
if (COMMANDS.has(positionals[0])) {
|
|
221
|
+
flags.command = positionals[0];
|
|
222
|
+
if (positionals[1] !== undefined) flags.target = positionals[1];
|
|
223
|
+
for (const extra of positionals.slice(2)) log.warn(`Extra positional ignored: ${extra}`);
|
|
224
|
+
} else {
|
|
225
|
+
flags.target = positionals[0];
|
|
226
|
+
for (const extra of positionals.slice(1)) log.warn(`Extra positional ignored: ${extra}`);
|
|
227
|
+
}
|
|
228
|
+
}
|
|
229
|
+
|
|
230
|
+
return flags;
|
|
231
|
+
}
|
|
232
|
+
|
|
233
|
+
// ---------------------------------------------------------------------------
|
|
234
|
+
// Help text
|
|
235
|
+
// ---------------------------------------------------------------------------
|
|
236
|
+
|
|
237
|
+
export const HELP_TEXT = `
|
|
238
|
+
${colors.bold("do-better")} — brownfield codebase analysis → verified findings → technical roadmap
|
|
239
|
+
|
|
240
|
+
${colors.bold("USAGE")}
|
|
241
|
+
do-better <command> [target] [flags]
|
|
242
|
+
|
|
243
|
+
${colors.bold("COMMANDS")}
|
|
244
|
+
scan D-1 Cheap repo scan: facts, incantations, draft codemap
|
|
245
|
+
charter D0 Stakeholder interview → quality charter [HUMAN GATE 1]
|
|
246
|
+
audit D1+2 Comprehend (7 artifacts) + identify verified findings
|
|
247
|
+
roadmap D3 Score, sequence, phase → ROADMAP.md + backlog tickets [HUMAN GATE 2]
|
|
248
|
+
rail D4 Characterization rails + hollow-test audit
|
|
249
|
+
run Full pipeline; stops cleanly at human gates; resumable
|
|
250
|
+
refresh Idempotent re-run: stale-claim flagging, behavior diff
|
|
251
|
+
|
|
252
|
+
${colors.bold("FLAGS")}
|
|
253
|
+
--provider <anthropic|gemini|openai|local> LLM provider (default: autodetect, anthropic-first;
|
|
254
|
+
local = OpenAI-compatible endpoint via DOBETTER_LOCAL_BASE_URL)
|
|
255
|
+
--budget <usd> Hard spend cap; resumable when exceeded
|
|
256
|
+
--offline No LLM calls; static analysis + structure-only artifacts
|
|
257
|
+
--model-cheap|--model-mid|--model-frontier <id> Per-tier model overrides
|
|
258
|
+
--target <dir> Target repo (or 2nd positional; default ".")
|
|
259
|
+
--approve Approve charter/roadmap (human gates)
|
|
260
|
+
--n <int> Parallax fan width override (audit)
|
|
261
|
+
--threshold <num> Divergence threshold override (audit)
|
|
262
|
+
--yes Skip confirmations
|
|
263
|
+
--json Machine summary on stdout
|
|
264
|
+
-h, --help Show this help
|
|
265
|
+
|
|
266
|
+
${colors.bold("ENVIRONMENT")}
|
|
267
|
+
ANTHROPIC_API_KEY | GEMINI_API_KEY | OPENAI_API_KEY provider keys (autodetected in that order)
|
|
268
|
+
DOBETTER_LOCAL_BASE_URL OpenAI-compatible endpoint for --provider local (e.g. http://localhost:11434/v1)
|
|
269
|
+
DOBETTER_LOCAL_MODEL default model for --provider local (per-tier --model-* flags override)
|
|
270
|
+
DOBETTER_LOCAL_API_KEY optional bearer token for --provider local
|
|
271
|
+
DOBETTER_ADLC_DIR path to an aidlc checkout (parallax, coldstart, hollow-test, …)
|
|
272
|
+
DOBETTER_SKILL_MINING_DIR path to a skill-mining checkout
|
|
273
|
+
DOBETTER_ANSWERS path to JSON string[] of scripted charter answers
|
|
274
|
+
DOBETTER_FAKE_LLM path to a fake-LLM module (test seam; no network)
|
|
275
|
+
DOBETTER_DEBUG print stack traces on error
|
|
276
|
+
|
|
277
|
+
${colors.bold("EXIT CODES")}
|
|
278
|
+
0 success, or a clean human-gate pause with printed resume instructions
|
|
279
|
+
1 operational error (bad input, missing provider, network, budget exceeded)
|
|
280
|
+
2 deterministic gate failure (divergence, unverified findings, coldstart gaps,
|
|
281
|
+
rails red, hollow audit survivor)
|
|
282
|
+
`;
|
|
283
|
+
|
|
284
|
+
// ---------------------------------------------------------------------------
|
|
285
|
+
// Crypto / time
|
|
286
|
+
// ---------------------------------------------------------------------------
|
|
287
|
+
|
|
288
|
+
export function sha256Hex(text) {
|
|
289
|
+
return createHash("sha256").update(text).digest("hex");
|
|
290
|
+
}
|
|
291
|
+
|
|
292
|
+
export function nowIso() {
|
|
293
|
+
return new Date().toISOString();
|
|
294
|
+
}
|
|
295
|
+
|
|
296
|
+
// ---------------------------------------------------------------------------
|
|
297
|
+
// Guards (validate external input: argv, LLM-emitted paths/model names)
|
|
298
|
+
// ---------------------------------------------------------------------------
|
|
299
|
+
|
|
300
|
+
export function assertSafeModelName(name, flagLabel) {
|
|
301
|
+
if (typeof name !== "string" || !/^[\w.:/-]+$/.test(name)) {
|
|
302
|
+
throw new OpError(`Invalid model name for ${flagLabel}: ${JSON.stringify(name)} (allowed: letters, digits, ., :, /, -, _)`);
|
|
303
|
+
}
|
|
304
|
+
}
|
|
305
|
+
|
|
306
|
+
export function isSafeRelPath(p) {
|
|
307
|
+
if (typeof p !== "string" || p.length === 0) return false;
|
|
308
|
+
if (p.includes("\0")) return false;
|
|
309
|
+
if (path.isAbsolute(p) || p.startsWith("/") || p.startsWith("\\")) return false;
|
|
310
|
+
if (p.split(/[\\/]/).includes("..")) return false;
|
|
311
|
+
return true;
|
|
312
|
+
}
|
|
313
|
+
|
|
314
|
+
// ---------------------------------------------------------------------------
|
|
315
|
+
// Exec / git
|
|
316
|
+
// ---------------------------------------------------------------------------
|
|
317
|
+
|
|
318
|
+
export function makeExec() {
|
|
319
|
+
return (cmd, args, opts = {}) => {
|
|
320
|
+
let r;
|
|
321
|
+
try {
|
|
322
|
+
r = spawnSync(cmd, args, {
|
|
323
|
+
encoding: "utf8",
|
|
324
|
+
maxBuffer: 16 * 1024 * 1024,
|
|
325
|
+
shell: false,
|
|
326
|
+
...opts,
|
|
327
|
+
});
|
|
328
|
+
} catch (e) {
|
|
329
|
+
return { status: -1, stdout: "", stderr: String(e && e.message ? e.message : e) };
|
|
330
|
+
}
|
|
331
|
+
return {
|
|
332
|
+
status: typeof r.status === "number" ? r.status : -1,
|
|
333
|
+
stdout: r.stdout ?? "",
|
|
334
|
+
stderr: r.error ? String(r.error.message) : r.stderr ?? "",
|
|
335
|
+
};
|
|
336
|
+
};
|
|
337
|
+
}
|
|
338
|
+
|
|
339
|
+
export function git(root, args, exec = makeExec()) {
|
|
340
|
+
const r = exec("git", args, { cwd: root });
|
|
341
|
+
if (r.status !== 0) {
|
|
342
|
+
throw new OpError(`git ${args.join(" ")} failed in ${root}: ${(r.stderr || r.stdout || "unknown error").trim()}`);
|
|
343
|
+
}
|
|
344
|
+
return (r.stdout ?? "").trim();
|
|
345
|
+
}
|
|
346
|
+
|
|
347
|
+
export function gitHeadSha(root, exec = makeExec()) {
|
|
348
|
+
return git(root, ["rev-parse", "HEAD"], exec);
|
|
349
|
+
}
|
|
350
|
+
|
|
351
|
+
// Working-tree cleanliness (H10). All read phases pin citations to HEAD's sha
|
|
352
|
+
// but read the WORKING TREE via fs; on a dirty tree those `file:line@sha` claims
|
|
353
|
+
// are attributed to a commit that does not contain that content. Returns
|
|
354
|
+
// { clean, dirtyCount }; a failed status probe is treated as clean (never
|
|
355
|
+
// blocks a run — the warning is advisory, degrade loudly not fatally).
|
|
356
|
+
export function workingTreeStatus(root, exec = makeExec()) {
|
|
357
|
+
const r = exec("git", ["status", "--porcelain"], { cwd: root });
|
|
358
|
+
if (r.status !== 0) return { clean: true, dirtyCount: 0 };
|
|
359
|
+
const lines = (r.stdout ?? "").split("\n").filter((l) => {
|
|
360
|
+
if (l.trim() === "") return false;
|
|
361
|
+
// Ignore do-better's OWN output dir (.dobetter/) — it is the artifact of the
|
|
362
|
+
// run, not a source change that would misattribute a citation, and it would
|
|
363
|
+
// otherwise flag every re-run as "dirty".
|
|
364
|
+
const p = l.slice(3).replace(/^"|"$/g, "");
|
|
365
|
+
return !(p === ".dobetter" || p.startsWith(".dobetter/"));
|
|
366
|
+
});
|
|
367
|
+
return { clean: lines.length === 0, dirtyCount: lines.length };
|
|
368
|
+
}
|
|
369
|
+
|
|
370
|
+
// A one-line declared warning that a phase is minting @sha citations against a
|
|
371
|
+
// dirty working tree (H10) — "declared, never silent" per the doctrine. Emits
|
|
372
|
+
// via log.warn when dirty; a no-op when clean. Returns the status for callers
|
|
373
|
+
// that want to record it.
|
|
374
|
+
export function warnIfDirtyTree(root, exec, log, phaseLabel) {
|
|
375
|
+
const status = workingTreeStatus(root, exec);
|
|
376
|
+
if (!status.clean) {
|
|
377
|
+
log?.warn?.(
|
|
378
|
+
`${phaseLabel}: working tree has ${status.dirtyCount} uncommitted change(s) — ` +
|
|
379
|
+
"file:line@sha citations are pinned to HEAD but read from the working tree, " +
|
|
380
|
+
"so claims may not match the committed blob. Commit before a citable run for exact provenance.",
|
|
381
|
+
);
|
|
382
|
+
}
|
|
383
|
+
return status;
|
|
384
|
+
}
|
|
385
|
+
|
|
386
|
+
// ---------------------------------------------------------------------------
|
|
387
|
+
// Files
|
|
388
|
+
// ---------------------------------------------------------------------------
|
|
389
|
+
|
|
390
|
+
export function readJsonSafe(absPath) {
|
|
391
|
+
if (!fs.existsSync(absPath)) return null;
|
|
392
|
+
const raw = fs.readFileSync(absPath, "utf8");
|
|
393
|
+
try {
|
|
394
|
+
return JSON.parse(raw);
|
|
395
|
+
} catch (e) {
|
|
396
|
+
throw new OpError(`Unparseable JSON at ${absPath}: ${e.message}`);
|
|
397
|
+
}
|
|
398
|
+
}
|
|
399
|
+
|
|
400
|
+
export function writeFileAtomic(absPath, content) {
|
|
401
|
+
fs.mkdirSync(path.dirname(absPath), { recursive: true });
|
|
402
|
+
const tmp = `${absPath}.${process.pid}.${Math.random().toString(36).slice(2)}.tmp`;
|
|
403
|
+
fs.writeFileSync(tmp, content);
|
|
404
|
+
fs.renameSync(tmp, absPath);
|
|
405
|
+
}
|
|
406
|
+
|
|
407
|
+
export function truncate(text, maxChars) {
|
|
408
|
+
const s = String(text);
|
|
409
|
+
if (s.length <= maxChars) return s;
|
|
410
|
+
return `${s.slice(0, Math.max(0, maxChars - 1))}…`;
|
|
411
|
+
}
|
|
412
|
+
|
|
413
|
+
// Zero-dep bounded-concurrency map (H8). Runs `fn(item, index)` over `items`
|
|
414
|
+
// with at most `limit` in flight, returns results in INPUT order (never
|
|
415
|
+
// completion order), and on the first rejection stops pulling new work, lets
|
|
416
|
+
// the in-flight tasks settle, then rejects with that first error. limit is
|
|
417
|
+
// clamped to >= 1. Used to fan out independent LLM calls (D2 pooled finders,
|
|
418
|
+
// D1 readers) without changing result ordering or gate semantics.
|
|
419
|
+
export async function mapLimit(items, limit, fn) {
|
|
420
|
+
const arr = Array.from(items);
|
|
421
|
+
const results = new Array(arr.length);
|
|
422
|
+
const lim = Math.max(1, Math.trunc(Number(limit)) || 1);
|
|
423
|
+
let next = 0;
|
|
424
|
+
let firstErr = null;
|
|
425
|
+
async function worker() {
|
|
426
|
+
while (next < arr.length && firstErr === null) {
|
|
427
|
+
const i = next++;
|
|
428
|
+
try {
|
|
429
|
+
results[i] = await fn(arr[i], i);
|
|
430
|
+
} catch (err) {
|
|
431
|
+
if (firstErr === null) firstErr = err;
|
|
432
|
+
}
|
|
433
|
+
}
|
|
434
|
+
}
|
|
435
|
+
const workers = [];
|
|
436
|
+
for (let w = 0; w < Math.min(lim, arr.length); w++) workers.push(worker());
|
|
437
|
+
await Promise.all(workers);
|
|
438
|
+
if (firstErr !== null) throw firstErr;
|
|
439
|
+
return results;
|
|
440
|
+
}
|
|
441
|
+
|
|
442
|
+
// Resolve a file relative to THIS package's root (not the target repo) — used
|
|
443
|
+
// to load do-better/SKILL.md and do-better/references/*.md as prompt sources.
|
|
444
|
+
export function readPackageFile(relPath) {
|
|
445
|
+
const packageRoot = path.resolve(path.dirname(fileURLToPath(import.meta.url)), "..");
|
|
446
|
+
const abs = path.join(packageRoot, relPath);
|
|
447
|
+
if (!fs.existsSync(abs)) throw new OpError(`Missing package file: ${relPath} (looked in ${packageRoot})`);
|
|
448
|
+
return fs.readFileSync(abs, "utf8");
|
|
449
|
+
}
|