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/artifacts.js
ADDED
|
@@ -0,0 +1,580 @@
|
|
|
1
|
+
// src/artifacts.js — .dobetter/ layout, frontmatter codec (documented YAML
|
|
2
|
+
// subset), citation parse/verify, findings + tickets I/O. Deterministic; no
|
|
3
|
+
// LLM, no network.
|
|
4
|
+
|
|
5
|
+
import fs from "node:fs";
|
|
6
|
+
import path from "node:path";
|
|
7
|
+
import { OpError, isSafeRelPath, log, readJsonSafe, writeFileAtomic } from "./utils.js";
|
|
8
|
+
|
|
9
|
+
// ---------------------------------------------------------------------------
|
|
10
|
+
// Layout (§3 of the spec, verbatim — relative to dotdir)
|
|
11
|
+
// ---------------------------------------------------------------------------
|
|
12
|
+
|
|
13
|
+
export const LAYOUT = {
|
|
14
|
+
charter: "charter.md",
|
|
15
|
+
comprehension: {
|
|
16
|
+
codemap: "comprehension/codemap.md",
|
|
17
|
+
architecture: "comprehension/architecture.md",
|
|
18
|
+
behaviorInventory: "comprehension/behavior-inventory.md",
|
|
19
|
+
dependencies: "comprehension/dependencies.md",
|
|
20
|
+
railsMap: "comprehension/rails-map.md",
|
|
21
|
+
glossary: "comprehension/glossary.md",
|
|
22
|
+
coverageManifest: "comprehension/coverage-manifest.md",
|
|
23
|
+
},
|
|
24
|
+
findingsDir: "findings",
|
|
25
|
+
roadmap: "ROADMAP.md",
|
|
26
|
+
backlogDir: "backlog",
|
|
27
|
+
backlogJson: "backlog/tickets.json",
|
|
28
|
+
railsManifest: "rails/manifest.md",
|
|
29
|
+
state: "state.json",
|
|
30
|
+
};
|
|
31
|
+
|
|
32
|
+
export function ensureLayout(dotdir) {
|
|
33
|
+
const dirs = [
|
|
34
|
+
dotdir,
|
|
35
|
+
path.join(dotdir, "comprehension"),
|
|
36
|
+
path.join(dotdir, LAYOUT.findingsDir),
|
|
37
|
+
path.join(dotdir, LAYOUT.backlogDir),
|
|
38
|
+
path.join(dotdir, "rails"),
|
|
39
|
+
path.join(dotdir, "tmp"),
|
|
40
|
+
];
|
|
41
|
+
const created = [];
|
|
42
|
+
for (const dir of dirs) {
|
|
43
|
+
if (!fs.existsSync(dir)) {
|
|
44
|
+
fs.mkdirSync(dir, { recursive: true });
|
|
45
|
+
created.push(dir);
|
|
46
|
+
}
|
|
47
|
+
}
|
|
48
|
+
return created;
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
// ---------------------------------------------------------------------------
|
|
52
|
+
// Frontmatter codec — zero-dep YAML subset: flat `key: value` scalars
|
|
53
|
+
// (string/number/bool/null), `key: [a, b]` inline arrays of scalars, and ONE
|
|
54
|
+
// level of `key:\n sub: val` nesting. Anything else throws (fail closed).
|
|
55
|
+
// ---------------------------------------------------------------------------
|
|
56
|
+
|
|
57
|
+
const KEY_RE = /^[A-Za-z0-9_-]+$/;
|
|
58
|
+
|
|
59
|
+
function parseScalarToken(tokRaw) {
|
|
60
|
+
const tok = tokRaw.trim();
|
|
61
|
+
if (tok === "") return "";
|
|
62
|
+
if (tok.startsWith('"')) {
|
|
63
|
+
try {
|
|
64
|
+
return JSON.parse(tok);
|
|
65
|
+
} catch {
|
|
66
|
+
throw new OpError(`Invalid quoted frontmatter scalar: ${tok}`);
|
|
67
|
+
}
|
|
68
|
+
}
|
|
69
|
+
if (tok === "true") return true;
|
|
70
|
+
if (tok === "false") return false;
|
|
71
|
+
if (tok === "null") return null;
|
|
72
|
+
if (/^-?\d+(\.\d+)?$/.test(tok)) return Number(tok);
|
|
73
|
+
return tok;
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
function splitInlineArray(inner) {
|
|
77
|
+
if (inner.trim() === "") return [];
|
|
78
|
+
const items = [];
|
|
79
|
+
let buf = "";
|
|
80
|
+
let inQuote = false;
|
|
81
|
+
for (let i = 0; i < inner.length; i++) {
|
|
82
|
+
const ch = inner[i];
|
|
83
|
+
if (inQuote) {
|
|
84
|
+
buf += ch;
|
|
85
|
+
if (ch === "\\") {
|
|
86
|
+
buf += inner[i + 1] ?? "";
|
|
87
|
+
i++;
|
|
88
|
+
} else if (ch === '"') {
|
|
89
|
+
inQuote = false;
|
|
90
|
+
}
|
|
91
|
+
continue;
|
|
92
|
+
}
|
|
93
|
+
if (ch === '"') {
|
|
94
|
+
inQuote = true;
|
|
95
|
+
buf += ch;
|
|
96
|
+
continue;
|
|
97
|
+
}
|
|
98
|
+
if (ch === "[" || ch === "{") {
|
|
99
|
+
throw new OpError("Nested structures are not supported in inline frontmatter arrays");
|
|
100
|
+
}
|
|
101
|
+
if (ch === ",") {
|
|
102
|
+
items.push(buf);
|
|
103
|
+
buf = "";
|
|
104
|
+
continue;
|
|
105
|
+
}
|
|
106
|
+
buf += ch;
|
|
107
|
+
}
|
|
108
|
+
if (inQuote) throw new OpError("Unterminated quote in inline frontmatter array");
|
|
109
|
+
items.push(buf);
|
|
110
|
+
return items;
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
export function parseFrontmatter(text) {
|
|
114
|
+
if (typeof text !== "string") throw new OpError("parseFrontmatter expects a string");
|
|
115
|
+
const lines = text.split("\n");
|
|
116
|
+
if (lines[0] !== "---") return { meta: {}, body: text };
|
|
117
|
+
let end = -1;
|
|
118
|
+
for (let i = 1; i < lines.length; i++) {
|
|
119
|
+
if (lines[i] === "---") {
|
|
120
|
+
end = i;
|
|
121
|
+
break;
|
|
122
|
+
}
|
|
123
|
+
}
|
|
124
|
+
if (end === -1) throw new OpError("Unterminated frontmatter block (missing closing ---)");
|
|
125
|
+
|
|
126
|
+
const meta = {};
|
|
127
|
+
let i = 1;
|
|
128
|
+
while (i < end) {
|
|
129
|
+
const line = lines[i];
|
|
130
|
+
if (line.trim() === "") {
|
|
131
|
+
i++;
|
|
132
|
+
continue;
|
|
133
|
+
}
|
|
134
|
+
const top = line.match(/^([A-Za-z0-9_-]+):\s?(.*)$/);
|
|
135
|
+
if (!top) throw new OpError(`Unsupported frontmatter syntax: ${JSON.stringify(line)}`);
|
|
136
|
+
const key = top[1];
|
|
137
|
+
const rest = top[2].trim();
|
|
138
|
+
|
|
139
|
+
if (rest === "") {
|
|
140
|
+
// One level of nesting: `key:` followed by ` sub: val` lines.
|
|
141
|
+
const obj = {};
|
|
142
|
+
let j = i + 1;
|
|
143
|
+
while (j < end) {
|
|
144
|
+
const sub = lines[j];
|
|
145
|
+
if (!sub.startsWith(" ") || sub.trim() === "") break;
|
|
146
|
+
const m = sub.match(/^ {2}([A-Za-z0-9_-]+):\s?(.*)$/);
|
|
147
|
+
if (!m) throw new OpError(`Unsupported frontmatter nesting (only one level allowed): ${JSON.stringify(sub)}`);
|
|
148
|
+
const subVal = m[2].trim();
|
|
149
|
+
if (subVal === "" || subVal.startsWith("[")) {
|
|
150
|
+
throw new OpError(`Unsupported frontmatter nesting under "${key}.${m[1]}" (nested values must be scalars)`);
|
|
151
|
+
}
|
|
152
|
+
obj[m[1]] = parseScalarToken(subVal);
|
|
153
|
+
j++;
|
|
154
|
+
}
|
|
155
|
+
meta[key] = Object.keys(obj).length === 0 ? null : obj;
|
|
156
|
+
i = Math.max(j, i + 1);
|
|
157
|
+
continue;
|
|
158
|
+
}
|
|
159
|
+
if (rest.startsWith("[")) {
|
|
160
|
+
if (!rest.endsWith("]")) throw new OpError(`Unterminated inline array for frontmatter key "${key}"`);
|
|
161
|
+
meta[key] = splitInlineArray(rest.slice(1, -1)).map(parseScalarToken);
|
|
162
|
+
i++;
|
|
163
|
+
continue;
|
|
164
|
+
}
|
|
165
|
+
meta[key] = parseScalarToken(rest);
|
|
166
|
+
i++;
|
|
167
|
+
}
|
|
168
|
+
|
|
169
|
+
const body = lines.slice(end + 1).join("\n");
|
|
170
|
+
return { meta, body };
|
|
171
|
+
}
|
|
172
|
+
|
|
173
|
+
function needsQuote(s) {
|
|
174
|
+
if (s === "") return true;
|
|
175
|
+
if (/[:,#"\\\n\[\]{}]/.test(s)) return true;
|
|
176
|
+
if (/^\s|\s$/.test(s)) return true;
|
|
177
|
+
if (["true", "false", "null"].includes(s)) return true;
|
|
178
|
+
if (/^-?\d+(\.\d+)?$/.test(s)) return true;
|
|
179
|
+
return false;
|
|
180
|
+
}
|
|
181
|
+
|
|
182
|
+
function serializeScalar(v, context = "frontmatter") {
|
|
183
|
+
if (v === null || v === undefined) return "null";
|
|
184
|
+
if (typeof v === "boolean") return v ? "true" : "false";
|
|
185
|
+
if (typeof v === "number") {
|
|
186
|
+
if (!Number.isFinite(v)) throw new OpError(`Non-finite number in ${context}`);
|
|
187
|
+
return String(v);
|
|
188
|
+
}
|
|
189
|
+
if (typeof v === "string") {
|
|
190
|
+
if (v.includes("\n")) return JSON.stringify(v);
|
|
191
|
+
return needsQuote(v) ? JSON.stringify(v) : v;
|
|
192
|
+
}
|
|
193
|
+
throw new OpError(`Unsupported ${context} value type: ${typeof v}`);
|
|
194
|
+
}
|
|
195
|
+
|
|
196
|
+
export function serializeFrontmatter(meta, body) {
|
|
197
|
+
const lines = ["---"];
|
|
198
|
+
for (const [k, v] of Object.entries(meta ?? {})) {
|
|
199
|
+
if (!KEY_RE.test(k)) throw new OpError(`Invalid frontmatter key: ${JSON.stringify(k)}`);
|
|
200
|
+
if (Array.isArray(v)) {
|
|
201
|
+
lines.push(`${k}: [${v.map((x) => serializeScalar(x, `array "${k}"`)).join(", ")}]`);
|
|
202
|
+
} else if (v !== null && typeof v === "object") {
|
|
203
|
+
lines.push(`${k}:`);
|
|
204
|
+
for (const [sk, sv] of Object.entries(v)) {
|
|
205
|
+
if (!KEY_RE.test(sk)) throw new OpError(`Invalid frontmatter key: ${JSON.stringify(sk)}`);
|
|
206
|
+
if (sv !== null && typeof sv === "object") {
|
|
207
|
+
throw new OpError(`Frontmatter nesting deeper than one level is not supported (key "${k}.${sk}")`);
|
|
208
|
+
}
|
|
209
|
+
lines.push(` ${sk}: ${serializeScalar(sv, `nested "${k}.${sk}"`)}`);
|
|
210
|
+
}
|
|
211
|
+
} else {
|
|
212
|
+
lines.push(`${k}: ${serializeScalar(v, `key "${k}"`)}`);
|
|
213
|
+
}
|
|
214
|
+
}
|
|
215
|
+
lines.push("---");
|
|
216
|
+
return `${lines.join("\n")}\n${body ?? ""}`;
|
|
217
|
+
}
|
|
218
|
+
|
|
219
|
+
// ---------------------------------------------------------------------------
|
|
220
|
+
// Generic artifact I/O
|
|
221
|
+
// ---------------------------------------------------------------------------
|
|
222
|
+
|
|
223
|
+
export function writeArtifact(dotdir, relPath, { meta = {}, body = "" } = {}) {
|
|
224
|
+
if (!isSafeRelPath(relPath)) throw new OpError(`Unsafe artifact path: ${JSON.stringify(relPath)}`);
|
|
225
|
+
const abs = path.join(dotdir, relPath);
|
|
226
|
+
const content = Object.keys(meta).length > 0 ? serializeFrontmatter(meta, body) : body;
|
|
227
|
+
writeFileAtomic(abs, content);
|
|
228
|
+
return abs;
|
|
229
|
+
}
|
|
230
|
+
|
|
231
|
+
export function readArtifact(dotdir, relPath) {
|
|
232
|
+
if (!isSafeRelPath(relPath)) throw new OpError(`Unsafe artifact path: ${JSON.stringify(relPath)}`);
|
|
233
|
+
const abs = path.join(dotdir, relPath);
|
|
234
|
+
if (!fs.existsSync(abs)) return null;
|
|
235
|
+
return parseFrontmatter(fs.readFileSync(abs, "utf8"));
|
|
236
|
+
}
|
|
237
|
+
|
|
238
|
+
// ---------------------------------------------------------------------------
|
|
239
|
+
// Citations — canonical inline format: `path/to/file.js:123@a1b2c3d`
|
|
240
|
+
// ---------------------------------------------------------------------------
|
|
241
|
+
|
|
242
|
+
const CITATION_RE = /([A-Za-z0-9_][A-Za-z0-9_.\/-]*):(\d{1,7})@([0-9a-fA-F]{7,40})\b/g;
|
|
243
|
+
|
|
244
|
+
export function formatCitation({ file, line, sha }) {
|
|
245
|
+
return `${file}:${line}@${sha}`;
|
|
246
|
+
}
|
|
247
|
+
|
|
248
|
+
export function parseCitations(text) {
|
|
249
|
+
if (typeof text !== "string") return [];
|
|
250
|
+
const seen = new Set();
|
|
251
|
+
const out = [];
|
|
252
|
+
for (const m of text.matchAll(CITATION_RE)) {
|
|
253
|
+
const citation = { file: m[1], line: Number(m[2]), sha: m[3].toLowerCase() };
|
|
254
|
+
const key = formatCitation(citation);
|
|
255
|
+
if (seen.has(key)) continue;
|
|
256
|
+
seen.add(key);
|
|
257
|
+
out.push(citation);
|
|
258
|
+
}
|
|
259
|
+
return out;
|
|
260
|
+
}
|
|
261
|
+
|
|
262
|
+
// Deterministic, no LLM: file exists in worktree AND 1 ≤ line ≤ line-count.
|
|
263
|
+
export function verifyCitations(root, citations, exec) {
|
|
264
|
+
const verified = [];
|
|
265
|
+
const failed = [];
|
|
266
|
+
const lineCountCache = new Map();
|
|
267
|
+
for (const citation of citations ?? []) {
|
|
268
|
+
if (!isSafeRelPath(citation.file)) {
|
|
269
|
+
failed.push({ citation, reason: "unsafe path" });
|
|
270
|
+
continue;
|
|
271
|
+
}
|
|
272
|
+
const abs = path.join(root, citation.file);
|
|
273
|
+
let lineCount = lineCountCache.get(abs);
|
|
274
|
+
if (lineCount === undefined) {
|
|
275
|
+
if (!fs.existsSync(abs) || !fs.statSync(abs).isFile()) {
|
|
276
|
+
lineCount = -1;
|
|
277
|
+
} else {
|
|
278
|
+
const content = fs.readFileSync(abs, "utf8");
|
|
279
|
+
lineCount =
|
|
280
|
+
content === "" ? 0 : content.split("\n").length - (content.endsWith("\n") ? 1 : 0);
|
|
281
|
+
}
|
|
282
|
+
lineCountCache.set(abs, lineCount);
|
|
283
|
+
}
|
|
284
|
+
if (lineCount === -1) {
|
|
285
|
+
failed.push({ citation, reason: "file not found in worktree" });
|
|
286
|
+
continue;
|
|
287
|
+
}
|
|
288
|
+
if (!Number.isInteger(citation.line) || citation.line < 1 || citation.line > lineCount) {
|
|
289
|
+
failed.push({ citation, reason: `line out of range (file has ${lineCount} lines)` });
|
|
290
|
+
continue;
|
|
291
|
+
}
|
|
292
|
+
verified.push(citation);
|
|
293
|
+
}
|
|
294
|
+
return { verified, failed };
|
|
295
|
+
}
|
|
296
|
+
|
|
297
|
+
// ---------------------------------------------------------------------------
|
|
298
|
+
// Machine-re-runnable reproduction checks (D6/D9). The check spec is persisted
|
|
299
|
+
// in finding frontmatter so refresh/roadmap re-runs can actually re-execute a
|
|
300
|
+
// reproduction instead of guessing from a human-readable record string.
|
|
301
|
+
// ok: true = still reproduces, false = no longer reproduces, null = unknowable
|
|
302
|
+
// (callers must NEVER treat null as resolved — a stale claim is misinformation
|
|
303
|
+
// with the voice of authority, but a falsely-resolved one is worse).
|
|
304
|
+
// ---------------------------------------------------------------------------
|
|
305
|
+
|
|
306
|
+
export function runReproCheck(root, check) {
|
|
307
|
+
try {
|
|
308
|
+
const type = check?.type;
|
|
309
|
+
if (type === "regex" || type === "grep") {
|
|
310
|
+
if (typeof check.pattern !== "string" || !isSafeRelPath(check.file ?? "")) {
|
|
311
|
+
return { ok: null, record: "repro-check: malformed regex/grep spec" };
|
|
312
|
+
}
|
|
313
|
+
const abs = path.join(root, check.file);
|
|
314
|
+
if (!fs.existsSync(abs) || !fs.statSync(abs).isFile()) {
|
|
315
|
+
return { ok: false, record: `repro-check ${type} /${check.pattern}/ in ${check.file}: file absent` };
|
|
316
|
+
}
|
|
317
|
+
const ok = new RegExp(check.pattern, check.flags ?? "").test(fs.readFileSync(abs, "utf8"));
|
|
318
|
+
return { ok, record: `repro-check ${type} /${check.pattern}/ in ${check.file}: ${ok ? "matched" : "no match"}` };
|
|
319
|
+
}
|
|
320
|
+
if (type === "no-tests") {
|
|
321
|
+
const ok = !["test", "tests", "__tests__"].some((d) => fs.existsSync(path.join(root, d)));
|
|
322
|
+
return { ok, record: `repro-check no-tests: ${ok ? "no test dirs present" : "test dirs exist"}` };
|
|
323
|
+
}
|
|
324
|
+
if (type === "no-lockfile") {
|
|
325
|
+
const ok = !["package-lock.json", "pnpm-lock.yaml", "yarn.lock"].some((f) => fs.existsSync(path.join(root, f)));
|
|
326
|
+
return { ok, record: `repro-check no-lockfile: ${ok ? "no lockfile present" : "lockfile exists"}` };
|
|
327
|
+
}
|
|
328
|
+
if (type === "no-readme") {
|
|
329
|
+
const ok = !fs.existsSync(path.join(root, "README.md"));
|
|
330
|
+
return { ok, record: `repro-check no-readme: ${ok ? "README.md absent" : "README.md exists"}` };
|
|
331
|
+
}
|
|
332
|
+
return { ok: null, record: `repro-check: unknown check type ${String(type)}` };
|
|
333
|
+
} catch (e) {
|
|
334
|
+
return { ok: null, record: `repro-check error: ${e.message}` };
|
|
335
|
+
}
|
|
336
|
+
}
|
|
337
|
+
|
|
338
|
+
// ---------------------------------------------------------------------------
|
|
339
|
+
// Findings
|
|
340
|
+
// ---------------------------------------------------------------------------
|
|
341
|
+
|
|
342
|
+
const SEVERITIES = new Set(["critical", "high", "medium", "low"]);
|
|
343
|
+
const REPRO_METHODS = new Set(["command", "reread", "static"]);
|
|
344
|
+
|
|
345
|
+
export function writeFinding(dotdir, finding) {
|
|
346
|
+
if (!finding || typeof finding.id !== "string" || finding.id.length === 0) {
|
|
347
|
+
throw new OpError("Finding requires an id");
|
|
348
|
+
}
|
|
349
|
+
if (typeof finding.dimension !== "string" || finding.dimension.length === 0) {
|
|
350
|
+
throw new OpError(`Finding ${finding.id}: dimension is required`);
|
|
351
|
+
}
|
|
352
|
+
if (typeof finding.title !== "string" || finding.title.length === 0) {
|
|
353
|
+
throw new OpError(`Finding ${finding.id}: title is required`);
|
|
354
|
+
}
|
|
355
|
+
if (!SEVERITIES.has(finding.severity)) {
|
|
356
|
+
throw new OpError(`Finding ${finding.id}: invalid severity ${JSON.stringify(finding.severity)}`);
|
|
357
|
+
}
|
|
358
|
+
if (!(typeof finding.confidence === "number" && finding.confidence >= 0 && finding.confidence <= 1)) {
|
|
359
|
+
throw new OpError(`Finding ${finding.id}: confidence must be a number in 0..1`);
|
|
360
|
+
}
|
|
361
|
+
if (!Array.isArray(finding.evidence) || finding.evidence.length === 0) {
|
|
362
|
+
throw new OpError(`Finding ${finding.id}: at least one evidence citation is required`);
|
|
363
|
+
}
|
|
364
|
+
if (finding.status !== "verified") {
|
|
365
|
+
throw new OpError(`Finding ${finding.id}: unverified findings are never written (status must be "verified")`);
|
|
366
|
+
}
|
|
367
|
+
const repro = finding.reproduction ?? {};
|
|
368
|
+
if (!REPRO_METHODS.has(repro.method)) {
|
|
369
|
+
throw new OpError(`Finding ${finding.id}: invalid reproduction method ${JSON.stringify(repro.method)}`);
|
|
370
|
+
}
|
|
371
|
+
|
|
372
|
+
const meta = {
|
|
373
|
+
id: finding.id,
|
|
374
|
+
dimension: finding.dimension,
|
|
375
|
+
title: finding.title,
|
|
376
|
+
...(typeof finding.claim === "string" && finding.claim.length > 0 ? { claim: finding.claim } : {}),
|
|
377
|
+
severity: finding.severity,
|
|
378
|
+
confidence: finding.confidence,
|
|
379
|
+
evidence: finding.evidence.map(formatCitation),
|
|
380
|
+
reproduction: {
|
|
381
|
+
method: repro.method,
|
|
382
|
+
record: String(repro.record ?? ""),
|
|
383
|
+
exitCode: repro.exitCode ?? null,
|
|
384
|
+
},
|
|
385
|
+
status: "verified",
|
|
386
|
+
foundAt: finding.foundAt ?? null,
|
|
387
|
+
headSha: finding.headSha ?? null,
|
|
388
|
+
stale: finding.stale === true,
|
|
389
|
+
};
|
|
390
|
+
// Machine-re-runnable reproduction (D6/D9): persist the argv array and/or the
|
|
391
|
+
// deterministic check spec so refresh/roadmap re-runs can actually re-execute
|
|
392
|
+
// the reproduction — the `record` string alone is human-readable, not runnable.
|
|
393
|
+
if (Array.isArray(repro.cmd) && repro.cmd.length > 0) {
|
|
394
|
+
if (repro.cmd.some((a) => typeof a !== "string" || a.length === 0)) {
|
|
395
|
+
throw new OpError(`Finding ${finding.id}: reproduction.cmd must be an array of non-empty strings`);
|
|
396
|
+
}
|
|
397
|
+
meta.reproCmd = [...repro.cmd];
|
|
398
|
+
}
|
|
399
|
+
if (repro.check && typeof repro.check === "object") {
|
|
400
|
+
if (typeof repro.check.type !== "string" || repro.check.type.length === 0) {
|
|
401
|
+
throw new OpError(`Finding ${finding.id}: reproduction.check requires a string "type"`);
|
|
402
|
+
}
|
|
403
|
+
const checkMeta = {};
|
|
404
|
+
for (const [k, v] of Object.entries(repro.check)) {
|
|
405
|
+
if (v === null || ["string", "number", "boolean"].includes(typeof v)) checkMeta[k] = v;
|
|
406
|
+
}
|
|
407
|
+
meta.reproCheck = checkMeta;
|
|
408
|
+
}
|
|
409
|
+
const body =
|
|
410
|
+
typeof finding.body === "string" && finding.body.length > 0
|
|
411
|
+
? finding.body
|
|
412
|
+
: [
|
|
413
|
+
"",
|
|
414
|
+
`# ${finding.title}`,
|
|
415
|
+
"",
|
|
416
|
+
`Dimension: ${finding.dimension} · Severity: ${finding.severity} · Confidence: ${finding.confidence}`,
|
|
417
|
+
"",
|
|
418
|
+
"## Evidence",
|
|
419
|
+
...finding.evidence.map((c) => `- ${formatCitation(c)}`),
|
|
420
|
+
"",
|
|
421
|
+
"## Reproduction",
|
|
422
|
+
`- method: ${meta.reproduction.method}`,
|
|
423
|
+
`- exit code: ${meta.reproduction.exitCode === null ? "n/a" : meta.reproduction.exitCode}`,
|
|
424
|
+
"",
|
|
425
|
+
"```",
|
|
426
|
+
meta.reproduction.record,
|
|
427
|
+
"```",
|
|
428
|
+
"",
|
|
429
|
+
].join("\n");
|
|
430
|
+
return writeArtifact(dotdir, `${LAYOUT.findingsDir}/${finding.id}.md`, { meta, body });
|
|
431
|
+
}
|
|
432
|
+
|
|
433
|
+
export function readFindings(dotdir) {
|
|
434
|
+
const dir = path.join(dotdir, LAYOUT.findingsDir);
|
|
435
|
+
if (!fs.existsSync(dir)) return [];
|
|
436
|
+
const findings = [];
|
|
437
|
+
for (const file of fs.readdirSync(dir).filter((f) => f.endsWith(".md")).sort()) {
|
|
438
|
+
try {
|
|
439
|
+
const art = readArtifact(dotdir, `${LAYOUT.findingsDir}/${file}`);
|
|
440
|
+
const m = art.meta;
|
|
441
|
+
if (!m.id || !m.dimension || !m.title) throw new OpError("missing id/dimension/title");
|
|
442
|
+
const evidence = (Array.isArray(m.evidence) ? m.evidence : [])
|
|
443
|
+
.map((s) => parseCitations(String(s))[0])
|
|
444
|
+
.filter(Boolean);
|
|
445
|
+
const repro = m.reproduction ?? {};
|
|
446
|
+
const reproduction = {
|
|
447
|
+
method: String(repro.method ?? "static"),
|
|
448
|
+
record: String(repro.record ?? ""),
|
|
449
|
+
exitCode: repro.exitCode === null || repro.exitCode === undefined ? null : Number(repro.exitCode),
|
|
450
|
+
};
|
|
451
|
+
if (Array.isArray(m.reproCmd) && m.reproCmd.length > 0) {
|
|
452
|
+
reproduction.cmd = m.reproCmd.map(String);
|
|
453
|
+
}
|
|
454
|
+
if (m.reproCheck && typeof m.reproCheck === "object") {
|
|
455
|
+
reproduction.check = { ...m.reproCheck };
|
|
456
|
+
}
|
|
457
|
+
findings.push({
|
|
458
|
+
id: String(m.id),
|
|
459
|
+
dimension: String(m.dimension),
|
|
460
|
+
title: String(m.title),
|
|
461
|
+
...(typeof m.claim === "string" && m.claim.length > 0 ? { claim: m.claim } : {}),
|
|
462
|
+
severity: String(m.severity),
|
|
463
|
+
confidence: Number(m.confidence),
|
|
464
|
+
evidence,
|
|
465
|
+
reproduction,
|
|
466
|
+
status: String(m.status ?? ""),
|
|
467
|
+
foundAt: m.foundAt ?? null,
|
|
468
|
+
headSha: m.headSha ?? null,
|
|
469
|
+
stale: m.stale === true,
|
|
470
|
+
});
|
|
471
|
+
} catch (e) {
|
|
472
|
+
log.warn(`Skipping corrupt finding ${file}: ${e.message}`);
|
|
473
|
+
}
|
|
474
|
+
}
|
|
475
|
+
return findings;
|
|
476
|
+
}
|
|
477
|
+
|
|
478
|
+
// ---------------------------------------------------------------------------
|
|
479
|
+
// Tickets — backlog/<id>.md (human view) + backlog/tickets.json (source of
|
|
480
|
+
// truth, byte-compatible with aidlc's .adlc/tickets.json schema).
|
|
481
|
+
// ---------------------------------------------------------------------------
|
|
482
|
+
|
|
483
|
+
export function writeTickets(dotdir, tickets) {
|
|
484
|
+
if (!Array.isArray(tickets)) throw new OpError("writeTickets expects an array of tickets");
|
|
485
|
+
writeFileAtomic(
|
|
486
|
+
path.join(dotdir, LAYOUT.backlogJson),
|
|
487
|
+
`${JSON.stringify({ tickets }, null, 2)}\n`,
|
|
488
|
+
);
|
|
489
|
+
for (const t of tickets) {
|
|
490
|
+
if (!t || typeof t.id !== "string" || t.id.length === 0) {
|
|
491
|
+
throw new OpError("Every ticket requires a string id");
|
|
492
|
+
}
|
|
493
|
+
if (!isSafeRelPath(`${t.id}.md`)) throw new OpError(`Unsafe ticket id: ${JSON.stringify(t.id)}`);
|
|
494
|
+
const meta = {
|
|
495
|
+
id: t.id,
|
|
496
|
+
title: t.title ?? "",
|
|
497
|
+
category: t.category ?? "",
|
|
498
|
+
duration: typeof t.duration === "number" ? t.duration : 0,
|
|
499
|
+
scope: Array.isArray(t.scope) ? t.scope : [],
|
|
500
|
+
rails: Array.isArray(t.rails) ? t.rails : [],
|
|
501
|
+
};
|
|
502
|
+
if (typeof t.budget === "number") meta.budget = t.budget;
|
|
503
|
+
const sections = ["", t.body ?? ""];
|
|
504
|
+
const edges = Array.isArray(t.edges) ? t.edges : [];
|
|
505
|
+
if (edges.length > 0) {
|
|
506
|
+
sections.push("", "## Edges", ...edges.map((e) => `- ${e.to} — contract: ${e.contract}`));
|
|
507
|
+
}
|
|
508
|
+
sections.push("");
|
|
509
|
+
writeArtifact(dotdir, `${LAYOUT.backlogDir}/${t.id}.md`, { meta, body: sections.join("\n") });
|
|
510
|
+
}
|
|
511
|
+
}
|
|
512
|
+
|
|
513
|
+
export function readTickets(dotdir) {
|
|
514
|
+
const json = readJsonSafe(path.join(dotdir, LAYOUT.backlogJson));
|
|
515
|
+
if (json === null) return [];
|
|
516
|
+
if (!Array.isArray(json.tickets)) {
|
|
517
|
+
throw new OpError(`Malformed ${LAYOUT.backlogJson}: expected { "tickets": [...] }`);
|
|
518
|
+
}
|
|
519
|
+
return json.tickets;
|
|
520
|
+
}
|
|
521
|
+
|
|
522
|
+
// Mirror of aidlc validateTicket rules; [] = valid.
|
|
523
|
+
export function validateTicket(ticket, allIds = []) {
|
|
524
|
+
const errors = [];
|
|
525
|
+
const idSet = new Set(allIds);
|
|
526
|
+
const isStr = (v) => typeof v === "string" && v.length > 0;
|
|
527
|
+
if (!isStr(ticket?.id)) errors.push("id must be a non-empty string");
|
|
528
|
+
if (!isStr(ticket?.title)) errors.push("title must be a non-empty string");
|
|
529
|
+
if (!isStr(ticket?.body)) errors.push("body must be a non-empty string");
|
|
530
|
+
if (!Array.isArray(ticket?.scope) || ticket.scope.some((s) => !isStr(s))) {
|
|
531
|
+
errors.push("scope must be an array of non-empty strings");
|
|
532
|
+
}
|
|
533
|
+
if (!Array.isArray(ticket?.rails) || ticket.rails.some((s) => !isStr(s))) {
|
|
534
|
+
errors.push("rails must be an array of non-empty strings");
|
|
535
|
+
}
|
|
536
|
+
if (!Array.isArray(ticket?.edges)) {
|
|
537
|
+
errors.push("edges must be an array");
|
|
538
|
+
} else {
|
|
539
|
+
for (const e of ticket.edges) {
|
|
540
|
+
if (!e || !isStr(e.to)) errors.push('edge missing "to"');
|
|
541
|
+
else if (idSet.size > 0 && !idSet.has(e.to)) errors.push(`edge to unknown ticket "${e.to}"`);
|
|
542
|
+
if (!e || !isStr(e.contract)) errors.push(`edge ${e?.to ?? "?"} missing contract`);
|
|
543
|
+
}
|
|
544
|
+
}
|
|
545
|
+
if (!(Number.isFinite(ticket?.duration) && ticket.duration > 0)) {
|
|
546
|
+
errors.push("duration must be a positive number");
|
|
547
|
+
}
|
|
548
|
+
if (!isStr(ticket?.category)) errors.push("category must be a non-empty string");
|
|
549
|
+
if (ticket?.budget !== undefined && !(Number.isFinite(ticket.budget) && ticket.budget > 0)) {
|
|
550
|
+
errors.push("budget must be a positive number when present");
|
|
551
|
+
}
|
|
552
|
+
return errors;
|
|
553
|
+
}
|
|
554
|
+
|
|
555
|
+
// ---------------------------------------------------------------------------
|
|
556
|
+
// Stale-claim annotation (skill-rot doctrine — flag, never trust). Idempotent.
|
|
557
|
+
// ---------------------------------------------------------------------------
|
|
558
|
+
|
|
559
|
+
const STALE_PREFIX = "> ⚠ STALE @";
|
|
560
|
+
|
|
561
|
+
export function annotateStale(body, { changedFiles = [], asOfSha, now } = {}) {
|
|
562
|
+
const changed = new Set(changedFiles);
|
|
563
|
+
const marker = `${STALE_PREFIX} ${now} (changed since ${asOfSha}):`;
|
|
564
|
+
const out = [];
|
|
565
|
+
let staleCount = 0;
|
|
566
|
+
for (const line of String(body).split("\n")) {
|
|
567
|
+
if (line.startsWith(STALE_PREFIX)) {
|
|
568
|
+
out.push(line);
|
|
569
|
+
continue;
|
|
570
|
+
}
|
|
571
|
+
const isStale = parseCitations(line).some((c) => changed.has(c.file));
|
|
572
|
+
if (isStale) {
|
|
573
|
+
staleCount++;
|
|
574
|
+
const prev = out[out.length - 1];
|
|
575
|
+
if (!(prev && prev.startsWith(STALE_PREFIX))) out.push(marker);
|
|
576
|
+
}
|
|
577
|
+
out.push(line);
|
|
578
|
+
}
|
|
579
|
+
return { body: out.join("\n"), staleCount };
|
|
580
|
+
}
|