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/roadmap.js
ADDED
|
@@ -0,0 +1,630 @@
|
|
|
1
|
+
// src/roadmap.js — D3: score, sequence, phase; ROADMAP.md + backlog tickets;
|
|
2
|
+
// coldstart gate; human approval gate 2. See SPEC §2 (D3), blueprint §7 D3.
|
|
3
|
+
import fs from "node:fs";
|
|
4
|
+
import path from "node:path";
|
|
5
|
+
import { OpError, gateError, sha256Hex, gitHeadSha, readPackageFile, truncate } from "./utils.js";
|
|
6
|
+
import { recordPhase, addSpend, setGate, pinSha, recordRoadmapHash } from "./state.js";
|
|
7
|
+
import {
|
|
8
|
+
LAYOUT, ensureLayout, readArtifact, writeArtifact, readFindings,
|
|
9
|
+
writeTickets, validateTicket, formatCitation, runReproCheck,
|
|
10
|
+
} from "./artifacts.js";
|
|
11
|
+
import { withFallback } from "./llm.js";
|
|
12
|
+
import { runColdstart } from "./adlc.js";
|
|
13
|
+
|
|
14
|
+
export const PHASE_ID = "roadmap";
|
|
15
|
+
export const COLDSTART_FIX_ROUNDS = 2;
|
|
16
|
+
|
|
17
|
+
const TSHIRT = { S: 1, M: 2, L: 3, XL: 5 };
|
|
18
|
+
const SEVERITY_IMPACT = { critical: "XL", high: "L", medium: "M", low: "S" };
|
|
19
|
+
const QUICK_WIN_SCORE = 1.5;
|
|
20
|
+
const NOW_SCORE = 1.0;
|
|
21
|
+
const NEXT_SCORE = 0.5;
|
|
22
|
+
const DECLINE_SCORE = 0.3;
|
|
23
|
+
const PHASE_ORDER = ["phase0", "now", "next", "later"];
|
|
24
|
+
const EFFORT_HOURS = { S: 2, M: 4, L: 8, XL: 16 };
|
|
25
|
+
const APPROVE_HINT = "Roadmap drafted — review .dobetter/ROADMAP.md + backlog/, then run: do-better roadmap --approve";
|
|
26
|
+
|
|
27
|
+
// ---------- pure scoring / sequencing ----------
|
|
28
|
+
|
|
29
|
+
export function scoreItem({ impact, confidence, effort }) {
|
|
30
|
+
const i = TSHIRT[impact];
|
|
31
|
+
const e = TSHIRT[effort];
|
|
32
|
+
const c = Number(confidence);
|
|
33
|
+
if (!i) throw new OpError(`Invalid impact t-shirt size: ${impact}`);
|
|
34
|
+
if (!e) throw new OpError(`Invalid effort t-shirt size: ${effort}`);
|
|
35
|
+
if (!Number.isFinite(c) || c < 0 || c > 1) throw new OpError(`Invalid confidence: ${confidence}`);
|
|
36
|
+
return (i * c) / e;
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
export function applyCharterWeight(score, dimension, weights) {
|
|
40
|
+
const w = Number(weights?.[dimension]);
|
|
41
|
+
return score * ((Number.isFinite(w) && w > 0 ? w : 3) / 3);
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
// Dependency-ordered, rails-first Phase 0, quick wins front-loaded. Pure —
|
|
45
|
+
// returns new item objects with `phase`, `quickWin` and (when a dependency
|
|
46
|
+
// cycle had to be broken at this item) `cycleBroken: true`.
|
|
47
|
+
export function sequence(items) {
|
|
48
|
+
const byId = new Map(items.map((i) => [i.id, i]));
|
|
49
|
+
const deps = new Map(items.map((i) => [i.id, (i.dependsOn ?? []).filter((d) => byId.has(d) && d !== i.id)]));
|
|
50
|
+
const broken = new Set();
|
|
51
|
+
const order = [];
|
|
52
|
+
const placed = new Set();
|
|
53
|
+
let remaining = items.map((i) => i.id);
|
|
54
|
+
while (remaining.length) {
|
|
55
|
+
const ready = remaining.filter((id) => deps.get(id).every((d) => placed.has(d)));
|
|
56
|
+
if (!ready.length) {
|
|
57
|
+
const victim = remaining
|
|
58
|
+
.slice()
|
|
59
|
+
.sort((a, b) => (byId.get(a).score - byId.get(b).score) || a.localeCompare(b))[0];
|
|
60
|
+
const unmet = deps.get(victim).filter((d) => !placed.has(d));
|
|
61
|
+
deps.set(victim, deps.get(victim).filter((d) => d !== unmet[0]));
|
|
62
|
+
broken.add(victim);
|
|
63
|
+
continue;
|
|
64
|
+
}
|
|
65
|
+
ready.sort((a, b) => (byId.get(b).score - byId.get(a).score) || a.localeCompare(b));
|
|
66
|
+
for (const id of ready) { placed.add(id); order.push(id); }
|
|
67
|
+
remaining = remaining.filter((id) => !placed.has(id));
|
|
68
|
+
}
|
|
69
|
+
const topoIndex = new Map(order.map((id, i) => [id, i]));
|
|
70
|
+
const phaseIdx = new Map();
|
|
71
|
+
for (const id of order) {
|
|
72
|
+
const it = byId.get(id);
|
|
73
|
+
let idx = it.phase0 ? 0 : it.score >= NOW_SCORE ? 1 : it.score >= NEXT_SCORE ? 2 : 3;
|
|
74
|
+
for (const d of deps.get(id)) idx = Math.max(idx, phaseIdx.get(d) ?? 0);
|
|
75
|
+
phaseIdx.set(id, idx);
|
|
76
|
+
}
|
|
77
|
+
const result = order.map((id) => {
|
|
78
|
+
const it = byId.get(id);
|
|
79
|
+
const idx = phaseIdx.get(id);
|
|
80
|
+
const quickWin = idx === 1 && it.effort === "S" && it.score >= QUICK_WIN_SCORE
|
|
81
|
+
&& deps.get(id).every((d) => phaseIdx.get(d) < 1);
|
|
82
|
+
return {
|
|
83
|
+
...it,
|
|
84
|
+
dependsOn: deps.get(id),
|
|
85
|
+
phase: PHASE_ORDER[idx],
|
|
86
|
+
quickWin,
|
|
87
|
+
...(broken.has(id) ? { cycleBroken: true } : {}),
|
|
88
|
+
};
|
|
89
|
+
});
|
|
90
|
+
result.sort((a, b) =>
|
|
91
|
+
(PHASE_ORDER.indexOf(a.phase) - PHASE_ORDER.indexOf(b.phase))
|
|
92
|
+
|| ((a.quickWin === b.quickWin) ? 0 : (a.quickWin ? -1 : 1))
|
|
93
|
+
|| (topoIndex.get(a.id) - topoIndex.get(b.id)));
|
|
94
|
+
return result;
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
// ---------- shared helpers (also used by refresh.js) ----------
|
|
98
|
+
|
|
99
|
+
export function shellSplit(command) {
|
|
100
|
+
const out = []; let cur = ""; let quote = null; let pending = false;
|
|
101
|
+
for (const ch of String(command ?? "")) {
|
|
102
|
+
if (quote) { if (ch === quote) quote = null; else cur += ch; continue; }
|
|
103
|
+
if (ch === '"' || ch === "'") { quote = ch; pending = true; continue; }
|
|
104
|
+
if (/\s/.test(ch)) { if (cur || pending) { out.push(cur); cur = ""; pending = false; } continue; }
|
|
105
|
+
cur += ch;
|
|
106
|
+
}
|
|
107
|
+
if (quote) throw new OpError(`Unbalanced quote in command: ${command}`);
|
|
108
|
+
if (cur || pending) out.push(cur);
|
|
109
|
+
return out;
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
// Re-run a finding's recorded reproduction. reproduced: true | false | null —
|
|
113
|
+
// null means UNKNOWABLE here, and callers must never treat it as resolved
|
|
114
|
+
// (D6/D9: falsely marking a still-reproducing finding "done" silently
|
|
115
|
+
// falsifies the living document). Re-runs prefer the machine-re-runnable
|
|
116
|
+
// fields identify persists (`reproduction.check` spec / `reproduction.cmd`
|
|
117
|
+
// argv); the human-readable `record` string is only attempted when it is a
|
|
118
|
+
// single-line runnable command, and a spawn failure is never "resolved".
|
|
119
|
+
export function rerunReproduction(root, finding, exec) {
|
|
120
|
+
const rep = finding?.reproduction;
|
|
121
|
+
if (!rep || rep.method === "reread") return { reproduced: null, exitCode: null };
|
|
122
|
+
// 1. deterministic check spec (static checks + native-grep reproductions)
|
|
123
|
+
if (rep.check && typeof rep.check === "object") {
|
|
124
|
+
const res = runReproCheck(root, rep.check);
|
|
125
|
+
return { reproduced: res.ok, exitCode: null };
|
|
126
|
+
}
|
|
127
|
+
// 2. persisted argv (command reproductions)
|
|
128
|
+
let argv = Array.isArray(rep.cmd) && rep.cmd.length > 0 ? rep.cmd.map(String) : null;
|
|
129
|
+
// 3. legacy fallback: only a single-line record whose argv[0] is a plausible
|
|
130
|
+
// executable. Human-readable records ("$ node …", "native grep …",
|
|
131
|
+
// "static-check …") are not re-runnable → unknowable, never resolved.
|
|
132
|
+
if (!argv) {
|
|
133
|
+
const record = String(rep.record ?? "");
|
|
134
|
+
if (!record || record.includes("\n")) return { reproduced: null, exitCode: null };
|
|
135
|
+
try { argv = shellSplit(record); } catch { return { reproduced: null, exitCode: null }; }
|
|
136
|
+
if (!argv.length || !/^[A-Za-z0-9_.\/-]+$/.test(argv[0]) || argv[0] === "native" || argv[0] === "static-check") {
|
|
137
|
+
return { reproduced: null, exitCode: null };
|
|
138
|
+
}
|
|
139
|
+
}
|
|
140
|
+
if (!argv.length) return { reproduced: null, exitCode: null };
|
|
141
|
+
let res;
|
|
142
|
+
try { res = exec(argv[0], argv.slice(1), { cwd: root, timeout: 30000 }); }
|
|
143
|
+
catch { return { reproduced: null, exitCode: null }; }
|
|
144
|
+
// Spawn failure (ENOENT → status -1/null) means the check never ran.
|
|
145
|
+
if (!Number.isInteger(res.status) || res.status < 0) return { reproduced: null, exitCode: null };
|
|
146
|
+
const expected = rep.exitCode ?? 0;
|
|
147
|
+
return { reproduced: res.status === expected, exitCode: res.status };
|
|
148
|
+
}
|
|
149
|
+
|
|
150
|
+
function appendDoneRegressed(body, line) {
|
|
151
|
+
if (body.includes(line)) return body;
|
|
152
|
+
const lines = body.split("\n");
|
|
153
|
+
const idx = lines.findIndex((l) => /^## Done \/ Regressed/.test(l));
|
|
154
|
+
if (idx === -1) return `${body}\n\n## Done / Regressed\n\n${line}\n`;
|
|
155
|
+
lines.splice(idx + 1, 0, "", line);
|
|
156
|
+
return lines.join("\n");
|
|
157
|
+
}
|
|
158
|
+
|
|
159
|
+
export function markRoadmapResolved(body, findingId, sha) {
|
|
160
|
+
const tag = ` (resolved @ ${String(sha).slice(0, 7)})`;
|
|
161
|
+
if (body.split("\n").some((l) => l.includes(findingId) && l.includes("✅ done"))) return body; // idempotent
|
|
162
|
+
let hit = false;
|
|
163
|
+
const out = body.split("\n").map((line) => {
|
|
164
|
+
if (!line.includes(findingId) || line.includes("✅ done") || !/^\s*- /.test(line)) return line;
|
|
165
|
+
hit = true;
|
|
166
|
+
return `${line.replace(/^(\s*)- /, "$1- ✅ done: ")}${tag}`;
|
|
167
|
+
}).join("\n");
|
|
168
|
+
if (hit) return out;
|
|
169
|
+
return appendDoneRegressed(body, `- ✅ done: ${findingId}${tag}`);
|
|
170
|
+
}
|
|
171
|
+
|
|
172
|
+
export function markRoadmapRegressed(body, label, note) {
|
|
173
|
+
return appendDoneRegressed(body, `- ⚠ regressed: ${label}${note ? ` — ${note}` : ""}`);
|
|
174
|
+
}
|
|
175
|
+
|
|
176
|
+
function refText(rel) {
|
|
177
|
+
try { return readPackageFile(`do-better/references/${rel}`); } catch { return ""; }
|
|
178
|
+
}
|
|
179
|
+
|
|
180
|
+
function coerceJson(raw, label) {
|
|
181
|
+
if (raw === null || raw === undefined) return null;
|
|
182
|
+
if (typeof raw === "object") return raw;
|
|
183
|
+
let s = String(raw).trim();
|
|
184
|
+
if (!s) return null;
|
|
185
|
+
s = s.replace(/^```[a-zA-Z]*\n?/, "").replace(/```\s*$/, "").trim();
|
|
186
|
+
const a = s.indexOf("{"); const b = s.lastIndexOf("}");
|
|
187
|
+
const c = s.indexOf("["); const d = s.lastIndexOf("]");
|
|
188
|
+
if (a !== -1 && b > a && (c === -1 || a < c)) s = s.slice(a, b + 1);
|
|
189
|
+
else if (c !== -1 && d > c) s = s.slice(c, d + 1);
|
|
190
|
+
try { return JSON.parse(s); } catch { throw new OpError(`[${label}] returned unparseable JSON`); }
|
|
191
|
+
}
|
|
192
|
+
|
|
193
|
+
function gateFail(state, gate, detail) {
|
|
194
|
+
return gateError(gate, detail, state); // H15 — shared, well-formed message
|
|
195
|
+
}
|
|
196
|
+
|
|
197
|
+
// ---------- living-document reconciliation (D6) ----------
|
|
198
|
+
|
|
199
|
+
export function reconcilePrior({ priorBody, findings, root, exec }) {
|
|
200
|
+
const byId = new Map(findings.map((f) => [f.id, f]));
|
|
201
|
+
const done = []; const regressed = []; const seen = new Set();
|
|
202
|
+
for (const line of String(priorBody ?? "").split("\n")) {
|
|
203
|
+
if (!/^\s*- /.test(line)) continue;
|
|
204
|
+
const m = line.match(/\bF-[A-Z0-9]+-\d{4}\b/);
|
|
205
|
+
if (!m || seen.has(m[0])) continue;
|
|
206
|
+
seen.add(m[0]);
|
|
207
|
+
const id = m[0];
|
|
208
|
+
const title = (line.match(/\*\*(.+?)\*\*/) ?? [, id])[1];
|
|
209
|
+
const finding = byId.get(id);
|
|
210
|
+
if (/✅/.test(line)) {
|
|
211
|
+
// previously done; finding re-verified → regressed, else carry done forward
|
|
212
|
+
if (finding && rerunReproduction(root, finding, exec).reproduced === true) regressed.push({ id, title });
|
|
213
|
+
else done.push({ id, title });
|
|
214
|
+
continue;
|
|
215
|
+
}
|
|
216
|
+
if (!finding) { done.push({ id, title }); continue; }
|
|
217
|
+
if (rerunReproduction(root, finding, exec).reproduced === false) done.push({ id, title });
|
|
218
|
+
}
|
|
219
|
+
return { done, regressed };
|
|
220
|
+
}
|
|
221
|
+
|
|
222
|
+
// ---------- scoring (frontier proposes, code computes) ----------
|
|
223
|
+
|
|
224
|
+
// deterministic offline-fallback proposal (severity-derived)
|
|
225
|
+
function defaultProposal(finding) {
|
|
226
|
+
return {
|
|
227
|
+
impact: SEVERITY_IMPACT[finding.severity] ?? "M",
|
|
228
|
+
effort: "M",
|
|
229
|
+
confidence: Number.isFinite(Number(finding.confidence)) ? Number(finding.confidence) : 0.5,
|
|
230
|
+
dependsOn: [],
|
|
231
|
+
railsNeeded: false,
|
|
232
|
+
phase0: false,
|
|
233
|
+
declineReason: null,
|
|
234
|
+
riskOfInaction: null,
|
|
235
|
+
};
|
|
236
|
+
}
|
|
237
|
+
|
|
238
|
+
// conservative default for findings the LLM omitted (by-id reconciliation: M/M/0.5, never dropped)
|
|
239
|
+
function conservativeProposal() {
|
|
240
|
+
return {
|
|
241
|
+
impact: "M", effort: "M", confidence: 0.5,
|
|
242
|
+
dependsOn: [], railsNeeded: false, phase0: false,
|
|
243
|
+
declineReason: null, riskOfInaction: null,
|
|
244
|
+
};
|
|
245
|
+
}
|
|
246
|
+
|
|
247
|
+
function normalizeProposal(raw, finding, knownIds) {
|
|
248
|
+
if (!raw || typeof raw !== "object") return conservativeProposal();
|
|
249
|
+
const d = defaultProposal(finding);
|
|
250
|
+
const conf = Number(raw.confidence);
|
|
251
|
+
return {
|
|
252
|
+
impact: TSHIRT[raw.impact] ? raw.impact : d.impact,
|
|
253
|
+
effort: TSHIRT[raw.effort] ? raw.effort : d.effort,
|
|
254
|
+
confidence: Number.isFinite(conf) && conf >= 0 && conf <= 1 ? conf : 0.5,
|
|
255
|
+
dependsOn: Array.isArray(raw.dependsOn) ? raw.dependsOn.filter((x) => knownIds.has(x) && x !== finding.id) : [],
|
|
256
|
+
railsNeeded: Boolean(raw.railsNeeded),
|
|
257
|
+
phase0: Boolean(raw.phase0),
|
|
258
|
+
declineReason: typeof raw.declineReason === "string" && raw.declineReason ? raw.declineReason : null,
|
|
259
|
+
riskOfInaction: typeof raw.riskOfInaction === "string" && raw.riskOfInaction ? raw.riskOfInaction : null,
|
|
260
|
+
};
|
|
261
|
+
}
|
|
262
|
+
|
|
263
|
+
async function proposeScores(ctx, findings) {
|
|
264
|
+
const knownIds = new Set(findings.map((f) => f.id));
|
|
265
|
+
const brief = findings.map((f) => ({
|
|
266
|
+
id: f.id, dimension: f.dimension, title: f.title, severity: f.severity,
|
|
267
|
+
confidence: f.confidence, evidence: (f.evidence ?? []).map(formatCitation),
|
|
268
|
+
}));
|
|
269
|
+
const prompt = [
|
|
270
|
+
"Assign roadmap scoring fields per verified finding. Return ONLY JSON:",
|
|
271
|
+
'{"items":[{"id","impact":"S|M|L|XL","effort":"S|M|L|XL","confidence":0..1,',
|
|
272
|
+
'"dependsOn":["F-..."],"railsNeeded":bool,"phase0":bool,"declineReason"?,"riskOfInaction"?}]}',
|
|
273
|
+
"Findings:", JSON.stringify(brief, null, 2),
|
|
274
|
+
].join("\n");
|
|
275
|
+
const raw = await withFallback(
|
|
276
|
+
ctx.llm,
|
|
277
|
+
{ prompt, system: refText("scoring.md"), tier: "frontier", label: "score", jsonMode: true },
|
|
278
|
+
() => ({ items: findings.map((f) => ({ id: f.id, ...defaultProposal(f) })) }),
|
|
279
|
+
);
|
|
280
|
+
const obj = coerceJson(raw, "score") ?? { items: [] };
|
|
281
|
+
const list = Array.isArray(obj) ? obj : Array.isArray(obj.items) ? obj.items : [];
|
|
282
|
+
const map = new Map();
|
|
283
|
+
for (const entry of list) {
|
|
284
|
+
if (entry && knownIds.has(entry.id)) map.set(entry.id, entry);
|
|
285
|
+
}
|
|
286
|
+
// by-id reconciliation: omitted findings get conservative defaults, never dropped
|
|
287
|
+
return new Map(findings.map((f) => [f.id, normalizeProposal(map.get(f.id), f, knownIds)]));
|
|
288
|
+
}
|
|
289
|
+
|
|
290
|
+
function buildItem(finding, proposal, weights) {
|
|
291
|
+
const base = scoreItem({ impact: proposal.impact, confidence: proposal.confidence, effort: proposal.effort });
|
|
292
|
+
const score = applyCharterWeight(base, finding.dimension, weights);
|
|
293
|
+
const declined = Boolean(proposal.declineReason) || score < DECLINE_SCORE;
|
|
294
|
+
return {
|
|
295
|
+
id: finding.id,
|
|
296
|
+
title: finding.title,
|
|
297
|
+
dimension: finding.dimension,
|
|
298
|
+
severity: finding.severity,
|
|
299
|
+
impact: proposal.impact,
|
|
300
|
+
effort: proposal.effort,
|
|
301
|
+
confidence: proposal.confidence,
|
|
302
|
+
dependsOn: proposal.dependsOn,
|
|
303
|
+
railsNeeded: proposal.railsNeeded,
|
|
304
|
+
phase0: proposal.phase0,
|
|
305
|
+
score,
|
|
306
|
+
declined,
|
|
307
|
+
declineReason: proposal.declineReason ?? (declined ? `score ${score.toFixed(2)} below ${DECLINE_SCORE}` : null),
|
|
308
|
+
riskOfInaction: proposal.riskOfInaction
|
|
309
|
+
?? `unaddressed ${finding.severity} ${finding.dimension} issue persists (confidence ${proposal.confidence})`,
|
|
310
|
+
evidence: finding.evidence ?? [],
|
|
311
|
+
};
|
|
312
|
+
}
|
|
313
|
+
|
|
314
|
+
// ---------- document rendering ----------
|
|
315
|
+
|
|
316
|
+
function renderItemLines(it) {
|
|
317
|
+
const cites = (it.evidence ?? []).map(formatCitation).join(", ");
|
|
318
|
+
return [
|
|
319
|
+
`- **${it.title}** (${it.id}, score ${it.score.toFixed(2)}, impact ${it.impact}, effort ${it.effort}, confidence ${it.confidence})${it.quickWin ? " ⚡ quick win" : ""}`,
|
|
320
|
+
` - Evidence: [${it.id}](findings/${it.id}.md)${cites ? ` — ${cites}` : ""}`,
|
|
321
|
+
` - Risk of inaction: ${it.riskOfInaction}`,
|
|
322
|
+
...(it.dependsOn?.length ? [` - Depends on: ${it.dependsOn.join(", ")}`] : []),
|
|
323
|
+
];
|
|
324
|
+
}
|
|
325
|
+
|
|
326
|
+
export function renderRoadmap({ items, declined, recon, headSha, now, findingsCount }) {
|
|
327
|
+
const meta = { generatedAt: now, headSha, basedOnFindings: findingsCount, approved: false };
|
|
328
|
+
const byPhase = (p) => items.filter((i) => i.phase === p);
|
|
329
|
+
const counts = PHASE_ORDER.map((p) => `${byPhase(p).length} ${p}`).join(", ");
|
|
330
|
+
const lines = [
|
|
331
|
+
"# Technical Roadmap", "",
|
|
332
|
+
"## Executive summary", "",
|
|
333
|
+
`Generated from ${findingsCount} verified findings at \`${String(headSha).slice(0, 7)}\`: ${counts}; ${declined.length} declined. Every item cites verified evidence in \`findings/\`; unverified claims never reach this document.`, "",
|
|
334
|
+
];
|
|
335
|
+
const sections = [
|
|
336
|
+
["phase0", "Phase 0 — Rails & runnability"],
|
|
337
|
+
["now", "Now"], ["next", "Next"], ["later", "Later"],
|
|
338
|
+
];
|
|
339
|
+
for (const [p, heading] of sections) {
|
|
340
|
+
lines.push(`## ${heading}`, "");
|
|
341
|
+
const list = byPhase(p);
|
|
342
|
+
if (!list.length) lines.push("_None._");
|
|
343
|
+
else for (const it of list) lines.push(...renderItemLines(it));
|
|
344
|
+
lines.push("");
|
|
345
|
+
}
|
|
346
|
+
lines.push("## Done / Regressed", "");
|
|
347
|
+
if (!recon.done.length && !recon.regressed.length) lines.push("_None._");
|
|
348
|
+
for (const d of recon.done) lines.push(`- ✅ done: **${d.title}** (${d.id}) — resolved @ ${String(headSha).slice(0, 7)}`);
|
|
349
|
+
for (const r of recon.regressed) lines.push(`- ⚠ regressed: **${r.title}** (${r.id}) — finding re-verified against current code`);
|
|
350
|
+
lines.push("", "## Declined", "");
|
|
351
|
+
if (!declined.length) lines.push("_None._");
|
|
352
|
+
for (const it of declined) {
|
|
353
|
+
lines.push(`- **${it.title}** (${it.id}) — declined: ${it.declineReason}. Risk of inaction: ${it.riskOfInaction}`);
|
|
354
|
+
}
|
|
355
|
+
lines.push("");
|
|
356
|
+
return { meta, body: lines.join("\n") };
|
|
357
|
+
}
|
|
358
|
+
|
|
359
|
+
// ---------- tickets (ADLC P2 shape) ----------
|
|
360
|
+
|
|
361
|
+
function deterministicTicket(item, id, finding) {
|
|
362
|
+
const repro = finding?.reproduction?.record;
|
|
363
|
+
const scope = [...new Set((finding?.evidence ?? []).map((c) => c.file))];
|
|
364
|
+
return {
|
|
365
|
+
id,
|
|
366
|
+
title: item.title,
|
|
367
|
+
body: [
|
|
368
|
+
"## Motivation",
|
|
369
|
+
`Addresses [${item.id}](../findings/${item.id}.md): ${item.title} (${item.severity} ${item.dimension}).`,
|
|
370
|
+
`Evidence: ${(finding?.evidence ?? []).map(formatCitation).join(", ") || "see finding file"}.`,
|
|
371
|
+
"",
|
|
372
|
+
"## Acceptance Criteria",
|
|
373
|
+
`- [ ] The finding no longer reproduces — verification: a command whose output is asserted (\`${repro || "re-read of the cited code slice"}\`).`,
|
|
374
|
+
"- [ ] Characterization rails covering touched behaviors stay green — verification: a test to be written/run.",
|
|
375
|
+
"",
|
|
376
|
+
"## Partition hints",
|
|
377
|
+
"- Scope is limited to the cited files; contracts live at module boundaries.",
|
|
378
|
+
].join("\n"),
|
|
379
|
+
scope: scope.length ? scope : ["package.json"],
|
|
380
|
+
rails: [],
|
|
381
|
+
edges: [],
|
|
382
|
+
duration: EFFORT_HOURS[item.effort] ?? 4,
|
|
383
|
+
category: item.dimension,
|
|
384
|
+
};
|
|
385
|
+
}
|
|
386
|
+
|
|
387
|
+
function normalizeTicket(raw, item, id, finding) {
|
|
388
|
+
const base = deterministicTicket(item, id, finding);
|
|
389
|
+
if (!raw || typeof raw !== "object") return base;
|
|
390
|
+
return {
|
|
391
|
+
id,
|
|
392
|
+
title: typeof raw.title === "string" && raw.title ? raw.title : base.title,
|
|
393
|
+
body: typeof raw.body === "string" && raw.body.length >= 20 ? raw.body : base.body,
|
|
394
|
+
scope: Array.isArray(raw.scope) && raw.scope.length ? raw.scope.map(String) : base.scope,
|
|
395
|
+
rails: Array.isArray(raw.rails) ? raw.rails.map(String) : base.rails,
|
|
396
|
+
edges: Array.isArray(raw.edges) ? raw.edges.filter((e) => e && e.to && e.contract) : base.edges,
|
|
397
|
+
duration: typeof raw.duration === "number" && raw.duration > 0 ? raw.duration : base.duration,
|
|
398
|
+
category: typeof raw.category === "string" && raw.category ? raw.category : base.category,
|
|
399
|
+
...(typeof raw.budget === "number" ? { budget: raw.budget } : {}),
|
|
400
|
+
};
|
|
401
|
+
}
|
|
402
|
+
|
|
403
|
+
async function draftTicket(ctx, item, id, finding) {
|
|
404
|
+
const prompt = [
|
|
405
|
+
`Write an ADLC P2 ticket (id ${id}) for this roadmap item. Return ONLY JSON`,
|
|
406
|
+
'{"title","body","scope":[globs],"rails":[],"edges":[{"to","contract"}],"duration":hours,"category"}.',
|
|
407
|
+
"The body must be self-contained: Motivation linking the finding file, machine-verifiable",
|
|
408
|
+
"Acceptance Criteria each naming its verification method, and Partition hints.",
|
|
409
|
+
"Item:", JSON.stringify({ ...item, evidence: (item.evidence ?? []).map(formatCitation) }, null, 2),
|
|
410
|
+
"Finding reproduction:", JSON.stringify(finding?.reproduction ?? null),
|
|
411
|
+
refText("templates/ticket-template.md"),
|
|
412
|
+
].join("\n");
|
|
413
|
+
const raw = await withFallback(
|
|
414
|
+
ctx.llm,
|
|
415
|
+
{ prompt, tier: "frontier", label: `ticket:${id}`, jsonMode: true },
|
|
416
|
+
() => deterministicTicket(item, id, finding),
|
|
417
|
+
);
|
|
418
|
+
return normalizeTicket(coerceJson(raw, `ticket:${id}`), item, id, finding);
|
|
419
|
+
}
|
|
420
|
+
|
|
421
|
+
async function repairTicket(ctx, ticket, problems, item, finding) {
|
|
422
|
+
const prompt = [
|
|
423
|
+
`Repair this ticket so it is fresh-agent executable. Problems:`,
|
|
424
|
+
...problems.map((p) => `- ${typeof p === "string" ? p : `${p.what}: ${p.why_blocking ?? ""}`}`),
|
|
425
|
+
"Embed the missing data shapes / contracts / concrete acceptance criteria directly in the body.",
|
|
426
|
+
"Return ONLY JSON with the full ticket fields.",
|
|
427
|
+
"Ticket:", JSON.stringify(ticket, null, 2),
|
|
428
|
+
].join("\n");
|
|
429
|
+
const raw = await withFallback(
|
|
430
|
+
ctx.llm,
|
|
431
|
+
{ prompt, tier: "frontier", label: "ticket-repair", jsonMode: true },
|
|
432
|
+
() => ticket,
|
|
433
|
+
);
|
|
434
|
+
const patch = coerceJson(raw, "ticket-repair");
|
|
435
|
+
const merged = patch && typeof patch === "object" ? { ...ticket, ...patch } : ticket;
|
|
436
|
+
return normalizeTicket(merged, item, ticket.id, finding);
|
|
437
|
+
}
|
|
438
|
+
|
|
439
|
+
// ---------- coldstart gate (D6) ----------
|
|
440
|
+
|
|
441
|
+
const PROBE_GAPS = "missing data shapes, absent contracts, unverifiable acceptance criteria, vague scope, unstated files";
|
|
442
|
+
|
|
443
|
+
async function coldstartCheck(ctx, tickets) {
|
|
444
|
+
const ticketsPath = path.join(ctx.dotdir, LAYOUT.backlogJson);
|
|
445
|
+
const res = runColdstart(ctx.adlc, { ticketsPath, all: true, cwd: ctx.root, exec: ctx.exec });
|
|
446
|
+
if (!res.skipped) {
|
|
447
|
+
const gapsById = new Map();
|
|
448
|
+
for (const r of res.results ?? []) {
|
|
449
|
+
if (r && r.pass === false) gapsById.set(r.id, Array.isArray(r.gaps) ? r.gaps : []);
|
|
450
|
+
}
|
|
451
|
+
return { degraded: null, gapsById };
|
|
452
|
+
}
|
|
453
|
+
const allIds = tickets.map((t) => t.id);
|
|
454
|
+
if (ctx.llm.offline) {
|
|
455
|
+
const gapsById = new Map();
|
|
456
|
+
for (const t of tickets) {
|
|
457
|
+
const errs = validateTicket(t, allIds);
|
|
458
|
+
if (errs.length) gapsById.set(t.id, errs.map((e) => ({ what: e, why_blocking: "static lint failure" })));
|
|
459
|
+
}
|
|
460
|
+
return { degraded: "static-lint", gapsById };
|
|
461
|
+
}
|
|
462
|
+
const gapsById = new Map();
|
|
463
|
+
for (const t of tickets) {
|
|
464
|
+
const prompt = [
|
|
465
|
+
"You are a fresh agent cold-starting this ticket with no other context.",
|
|
466
|
+
`Report blocking gaps in these categories: ${PROBE_GAPS}.`,
|
|
467
|
+
'Return ONLY JSON {"pass":bool,"gaps":[{"what","why_blocking"}]}.',
|
|
468
|
+
"Ticket:", JSON.stringify(t, null, 2),
|
|
469
|
+
].join("\n");
|
|
470
|
+
const raw = await withFallback(
|
|
471
|
+
ctx.llm,
|
|
472
|
+
{ prompt, tier: "cheap", label: "coldstart-probe", jsonMode: true },
|
|
473
|
+
() => ({ pass: true, gaps: [] }),
|
|
474
|
+
);
|
|
475
|
+
const obj = coerceJson(raw, "coldstart-probe");
|
|
476
|
+
if (obj && obj.pass === false) gapsById.set(t.id, Array.isArray(obj.gaps) ? obj.gaps : []);
|
|
477
|
+
}
|
|
478
|
+
return { degraded: "native-probe", gapsById };
|
|
479
|
+
}
|
|
480
|
+
|
|
481
|
+
// ---------- phase entrypoints ----------
|
|
482
|
+
|
|
483
|
+
export async function run(ctx) {
|
|
484
|
+
const { root, dotdir, log } = ctx;
|
|
485
|
+
let state = ctx.state;
|
|
486
|
+
if (!state?.gates?.identify?.passed) {
|
|
487
|
+
throw new OpError("Identify gate has not passed — run `do-better audit` first.");
|
|
488
|
+
}
|
|
489
|
+
ensureLayout(dotdir);
|
|
490
|
+
const headSha = gitHeadSha(root, ctx.exec);
|
|
491
|
+
const findings = readFindings(dotdir);
|
|
492
|
+
if (!findings.length) log.warn("No verified findings — the roadmap will be empty.");
|
|
493
|
+
const weights = readArtifact(dotdir, LAYOUT.charter)?.meta?.weights ?? {};
|
|
494
|
+
|
|
495
|
+
// 2. living-document reconciliation
|
|
496
|
+
const prior = readArtifact(dotdir, LAYOUT.roadmap);
|
|
497
|
+
const recon = prior
|
|
498
|
+
? reconcilePrior({ priorBody: prior.body, findings, root, exec: ctx.exec })
|
|
499
|
+
: { done: [], regressed: [] };
|
|
500
|
+
|
|
501
|
+
// 3-4. score + sequence
|
|
502
|
+
const proposals = await proposeScores(ctx, findings);
|
|
503
|
+
const allItems = findings.map((f) => buildItem(f, proposals.get(f.id), weights));
|
|
504
|
+
const declined = allItems.filter((i) => i.declined);
|
|
505
|
+
let items = sequence(allItems.filter((i) => !i.declined));
|
|
506
|
+
for (const it of items) if (it.cycleBroken) log.warn(`Dependency cycle broken at ${it.id} (lowest score in cycle).`);
|
|
507
|
+
|
|
508
|
+
// 7. tickets for Phase 0 + Now + Next items
|
|
509
|
+
const findingById = new Map(findings.map((f) => [f.id, f]));
|
|
510
|
+
let counter = state.counters?.tickets ?? 0;
|
|
511
|
+
let tickets = [];
|
|
512
|
+
const demotedIds = new Set();
|
|
513
|
+
for (const item of items.filter((i) => i.phase !== "later")) {
|
|
514
|
+
counter += 1;
|
|
515
|
+
const id = `T${counter}`;
|
|
516
|
+
let ticket = await draftTicket(ctx, item, id, findingById.get(item.id));
|
|
517
|
+
let errs = validateTicket(ticket, [...tickets.map((t) => t.id), id]);
|
|
518
|
+
if (errs.length) {
|
|
519
|
+
ticket = await repairTicket(ctx, ticket, errs, item, findingById.get(item.id));
|
|
520
|
+
errs = validateTicket(ticket, [...tickets.map((t) => t.id), id]);
|
|
521
|
+
}
|
|
522
|
+
if (errs.length) {
|
|
523
|
+
log.warn(`Ticket ${id} invalid after repair (${errs.join("; ")}) — demoting ${item.id} to Later.`);
|
|
524
|
+
demotedIds.add(item.id);
|
|
525
|
+
continue;
|
|
526
|
+
}
|
|
527
|
+
tickets.push({ ...ticket, findingId: item.id });
|
|
528
|
+
}
|
|
529
|
+
items = items.map((i) => (demotedIds.has(i.id) ? { ...i, phase: "later" } : i));
|
|
530
|
+
state = { ...state, counters: { ...state.counters, tickets: counter } };
|
|
531
|
+
const persistTickets = (list) => writeTickets(dotdir, list.map(({ findingId, ...t }) => t));
|
|
532
|
+
persistTickets(tickets);
|
|
533
|
+
|
|
534
|
+
// 8. coldstart gate with repair loop
|
|
535
|
+
let coldstart = { degraded: null, gapsById: new Map() };
|
|
536
|
+
let clean = tickets.length === 0;
|
|
537
|
+
if (tickets.length) {
|
|
538
|
+
for (let round = 0; round <= COLDSTART_FIX_ROUNDS; round++) {
|
|
539
|
+
coldstart = await coldstartCheck(ctx, tickets);
|
|
540
|
+
if (!coldstart.gapsById.size) { clean = true; break; }
|
|
541
|
+
if (round === COLDSTART_FIX_ROUNDS) break;
|
|
542
|
+
tickets = await Promise.all(tickets.map(async (t) => {
|
|
543
|
+
const gaps = coldstart.gapsById.get(t.id);
|
|
544
|
+
if (!gaps) return t;
|
|
545
|
+
const item = items.find((i) => i.id === t.findingId) ?? { id: t.findingId, severity: "medium", dimension: t.category, title: t.title, effort: "M" };
|
|
546
|
+
const repaired = await repairTicket(ctx, t, gaps, item, findingById.get(t.findingId));
|
|
547
|
+
return { ...repaired, findingId: t.findingId };
|
|
548
|
+
}));
|
|
549
|
+
persistTickets(tickets);
|
|
550
|
+
}
|
|
551
|
+
}
|
|
552
|
+
|
|
553
|
+
if (!clean) {
|
|
554
|
+
// demote gapped items + flag tickets, persist everything, then fail the gate (exit 2)
|
|
555
|
+
const gappedTicketIds = [...coldstart.gapsById.keys()];
|
|
556
|
+
const gappedFindingIds = new Set(tickets.filter((t) => gappedTicketIds.includes(t.id)).map((t) => t.findingId));
|
|
557
|
+
items = items.map((i) => (gappedFindingIds.has(i.id) ? { ...i, phase: "later" } : i));
|
|
558
|
+
tickets = tickets.map((t) => (gappedTicketIds.includes(t.id)
|
|
559
|
+
? { ...t, body: `> coldstart: failed — demoted to Later after ${COLDSTART_FIX_ROUNDS} repair rounds.\n\n${t.body}` }
|
|
560
|
+
: t));
|
|
561
|
+
persistTickets(tickets);
|
|
562
|
+
state = writeRoadmapDoc(state, ctx, { items, declined, recon, headSha });
|
|
563
|
+
state = setGate(state, "roadmap", { coldstartClean: false, coldstartDegraded: coldstart.degraded, approved: false, approvedAt: null });
|
|
564
|
+
state = finishState(state, ctx, { headSha, status: "failed", ticketCount: tickets.length, declinedCount: declined.length });
|
|
565
|
+
throw gateFail(state, "roadmap", `coldstart gaps persist after ${COLDSTART_FIX_ROUNDS} repair rounds in: ${gappedTicketIds.join(", ")}`);
|
|
566
|
+
}
|
|
567
|
+
|
|
568
|
+
// 6. final document + hash, gate state, human gate 2
|
|
569
|
+
state = writeRoadmapDoc(state, ctx, { items, declined, recon, headSha });
|
|
570
|
+
state = setGate(state, "roadmap", {
|
|
571
|
+
coldstartClean: true,
|
|
572
|
+
coldstartDegraded: coldstart.degraded,
|
|
573
|
+
approved: false,
|
|
574
|
+
approvedAt: null,
|
|
575
|
+
});
|
|
576
|
+
state = finishState(state, ctx, { headSha, status: "done", ticketCount: tickets.length, declinedCount: declined.length });
|
|
577
|
+
const degradedNote = coldstart.degraded ? ` (coldstart degraded: ${coldstart.degraded})` : "";
|
|
578
|
+
return {
|
|
579
|
+
state,
|
|
580
|
+
gate: { name: "roadmap-approval", passed: false, human: true, detail: APPROVE_HINT },
|
|
581
|
+
summary: `Roadmap drafted from ${findings.length} findings: ${tickets.length} tickets, ${declined.length} declined, ${recon.done.length} done, ${recon.regressed.length} regressed${degradedNote}. ${APPROVE_HINT}`,
|
|
582
|
+
};
|
|
583
|
+
}
|
|
584
|
+
|
|
585
|
+
function writeRoadmapDoc(state, ctx, { items, declined, recon, headSha }) {
|
|
586
|
+
const doc = renderRoadmap({
|
|
587
|
+
items, declined, recon, headSha,
|
|
588
|
+
now: ctx.now(),
|
|
589
|
+
findingsCount: items.length + declined.length,
|
|
590
|
+
});
|
|
591
|
+
writeArtifact(ctx.dotdir, LAYOUT.roadmap, doc);
|
|
592
|
+
const raw = fs.readFileSync(path.join(ctx.dotdir, LAYOUT.roadmap), "utf8");
|
|
593
|
+
return recordRoadmapHash(state, { sha256: sha256Hex(raw), headSha, now: ctx.now() });
|
|
594
|
+
}
|
|
595
|
+
|
|
596
|
+
function finishState(state, ctx, { headSha, status, ticketCount, declinedCount }) {
|
|
597
|
+
let next = addSpend(state, PHASE_ID, ctx.llm.drainSpend());
|
|
598
|
+
next = recordPhase(next, PHASE_ID, { status, sha: headSha, now: ctx.now() });
|
|
599
|
+
next = {
|
|
600
|
+
...next,
|
|
601
|
+
phases: { ...next.phases, [PHASE_ID]: { ...next.phases[PHASE_ID], ticketCount, declinedCount } },
|
|
602
|
+
};
|
|
603
|
+
if (status === "done") next = pinSha(next, PHASE_ID, headSha);
|
|
604
|
+
return next;
|
|
605
|
+
}
|
|
606
|
+
|
|
607
|
+
export async function approve(ctx) {
|
|
608
|
+
const { dotdir, log } = ctx;
|
|
609
|
+
let state = ctx.state;
|
|
610
|
+
const abs = path.join(dotdir, LAYOUT.roadmap);
|
|
611
|
+
if (!fs.existsSync(abs)) throw new OpError("No .dobetter/ROADMAP.md found — run `do-better roadmap` first.");
|
|
612
|
+
if (!state?.gates?.roadmap?.coldstartClean) {
|
|
613
|
+
throw new OpError("Roadmap coldstart gate is not clean — run `do-better roadmap` first.");
|
|
614
|
+
}
|
|
615
|
+
const hash = sha256Hex(fs.readFileSync(abs, "utf8"));
|
|
616
|
+
const hist = state.roadmapHistory ?? [];
|
|
617
|
+
const last = hist[hist.length - 1];
|
|
618
|
+
if (last && last.sha256 !== hash) {
|
|
619
|
+
log.warn("ROADMAP.md changed since generation (human edits are fine) — recording the new hash.");
|
|
620
|
+
}
|
|
621
|
+
if (!last || last.sha256 !== hash) {
|
|
622
|
+
state = recordRoadmapHash(state, { sha256: hash, headSha: gitHeadSha(ctx.root, ctx.exec), now: ctx.now() });
|
|
623
|
+
}
|
|
624
|
+
state = setGate(state, "roadmap", { approved: true, approvedAt: ctx.now(), roadmapSha256: hash });
|
|
625
|
+
return {
|
|
626
|
+
state,
|
|
627
|
+
gate: { name: "roadmap-approval", passed: true, human: true, detail: "Roadmap approved." },
|
|
628
|
+
summary: `Roadmap approved (${truncate(hash, 12)}). Next: do-better rail`,
|
|
629
|
+
};
|
|
630
|
+
}
|