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/charter.js
ADDED
|
@@ -0,0 +1,657 @@
|
|
|
1
|
+
// src/charter.js — D0: grill-me-style stakeholder interview seeded by D-1 scan
|
|
2
|
+
// facts. Enforces the fixed taxonomy floor (D5), writes .dobetter/charter.md,
|
|
3
|
+
// and ends at HUMAN GATE 1 (charter approval, D8). Frontier tier for question
|
|
4
|
+
// synthesis + charter synthesis (§6); the markdown itself is assembled in code
|
|
5
|
+
// so the frontmatter contract is always exact.
|
|
6
|
+
import fs from "node:fs";
|
|
7
|
+
import { OpError, TAXONOMY, gitHeadSha, sha256Hex } from "./utils.js";
|
|
8
|
+
import { addSpend, pinSha, recordPhase, setGate } from "./state.js";
|
|
9
|
+
import {
|
|
10
|
+
LAYOUT,
|
|
11
|
+
parseFrontmatter,
|
|
12
|
+
readArtifact,
|
|
13
|
+
serializeFrontmatter,
|
|
14
|
+
writeArtifact,
|
|
15
|
+
} from "./artifacts.js";
|
|
16
|
+
import { cleanJsonResponse, withFallback } from "./llm.js";
|
|
17
|
+
|
|
18
|
+
export const PHASE_ID = "charter";
|
|
19
|
+
|
|
20
|
+
const INTENTS = ["stabilize", "scale", "extend", "handoff"];
|
|
21
|
+
const MAX_QUESTIONS = 12;
|
|
22
|
+
const MIN_LLM_QUESTIONS = 4; // fewer valid LLM questions than this → static plan
|
|
23
|
+
const DEFAULT_WEIGHT = 3;
|
|
24
|
+
const WEIGHT_MAX = 5;
|
|
25
|
+
const APPROVE_HINT =
|
|
26
|
+
"Charter drafted at .dobetter/charter.md — review/edit, then run: do-better charter --approve";
|
|
27
|
+
const OFFLINE_MARKER = Symbol("charter-offline");
|
|
28
|
+
|
|
29
|
+
// ---------------------------------------------------------------- questions
|
|
30
|
+
|
|
31
|
+
function listOr(arr, empty = "none") {
|
|
32
|
+
return arr && arr.length ? arr.join(", ") : empty;
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
function dimensionFact(dimId, facts) {
|
|
36
|
+
const scripts = Object.keys(facts.incantations?.scripts ?? {});
|
|
37
|
+
switch (dimId) {
|
|
38
|
+
case "correctness":
|
|
39
|
+
return `scan found ${facts.todoCount} TODO/FIXME/HACK markers across ${facts.fileCount} files`;
|
|
40
|
+
case "security":
|
|
41
|
+
return `${facts.depCounts?.prod ?? 0} production dependencies; manifests: ${listOr(facts.manifests)}`;
|
|
42
|
+
case "maintainability": {
|
|
43
|
+
const top = facts.largestFiles?.[0];
|
|
44
|
+
return top ? `largest file is ${top.file} at ${top.loc} LOC` : `${facts.locTotal} LOC total`;
|
|
45
|
+
}
|
|
46
|
+
case "performance":
|
|
47
|
+
return `${facts.locTotal} LOC across ${facts.fileCount} files`;
|
|
48
|
+
case "operability":
|
|
49
|
+
return facts.incantations?.ci?.length
|
|
50
|
+
? `CI config present: ${listOr(facts.incantations.ci)}`
|
|
51
|
+
: "no CI configuration detected";
|
|
52
|
+
case "test-quality":
|
|
53
|
+
return facts.testDirs?.length
|
|
54
|
+
? `test dirs present: ${listOr(facts.testDirs)}`
|
|
55
|
+
: "no test directories detected";
|
|
56
|
+
case "dependency-health":
|
|
57
|
+
return `${facts.depCounts?.prod ?? 0} prod / ${facts.depCounts?.dev ?? 0} dev dependencies`;
|
|
58
|
+
case "dx":
|
|
59
|
+
return scripts.length
|
|
60
|
+
? `${scripts.length} script(s)/target(s): ${listOr(scripts.slice(0, 6))}`
|
|
61
|
+
: "no package scripts or Make targets detected";
|
|
62
|
+
default:
|
|
63
|
+
return `${facts.fileCount} tracked files`;
|
|
64
|
+
}
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
function recommendedWeight(dimId, facts) {
|
|
68
|
+
if (dimId === "test-quality" && !(facts.testDirs?.length)) return "5";
|
|
69
|
+
if (dimId === "operability" && !(facts.incantations?.ci?.length)) return "4";
|
|
70
|
+
if (dimId === "dependency-health" && !(facts.manifests?.length)) return "1";
|
|
71
|
+
return String(DEFAULT_WEIGHT);
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
// Offline/static fallback plan. Always covers pain, 12-month intent,
|
|
75
|
+
// constraints, and one weight question per taxonomy dimension — every
|
|
76
|
+
// question cites a concrete scan fact (grill-me, seeded by D-1).
|
|
77
|
+
export function buildStaticQuestionPlan(facts) {
|
|
78
|
+
const questions = [
|
|
79
|
+
{
|
|
80
|
+
id: "pain",
|
|
81
|
+
text:
|
|
82
|
+
`What hurts most about this codebase today? ` +
|
|
83
|
+
`(scan: ${facts.fileCount} files, ${facts.locTotal} LOC, ${facts.todoCount} TODO/FIXME/HACK markers)`,
|
|
84
|
+
recommended:
|
|
85
|
+
facts.todoCount > 0
|
|
86
|
+
? `Accumulated debt — ${facts.todoCount} TODO/FIXME/HACK markers in the tree`
|
|
87
|
+
: "General maintainability",
|
|
88
|
+
dimension: null,
|
|
89
|
+
},
|
|
90
|
+
{
|
|
91
|
+
id: "intent",
|
|
92
|
+
text: "What is the 12-month intent for this codebase? (stabilize / scale / extend / handoff)",
|
|
93
|
+
recommended: "stabilize",
|
|
94
|
+
dimension: null,
|
|
95
|
+
},
|
|
96
|
+
{
|
|
97
|
+
id: "constraints",
|
|
98
|
+
text: "Any constraints? (compliance regimes, freeze windows, team capacity, no-touch areas)",
|
|
99
|
+
recommended: "None",
|
|
100
|
+
dimension: null,
|
|
101
|
+
},
|
|
102
|
+
...TAXONOMY.map((dim) => ({
|
|
103
|
+
id: `weight-${dim.id}`,
|
|
104
|
+
text: `How much should "${dim.label}" weigh in this engagement, 0-5? (${dimensionFact(dim.id, facts)})`,
|
|
105
|
+
recommended: recommendedWeight(dim.id, facts),
|
|
106
|
+
dimension: dim.id,
|
|
107
|
+
})),
|
|
108
|
+
];
|
|
109
|
+
return questions.slice(0, MAX_QUESTIONS);
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
function isValidQuestion(q) {
|
|
113
|
+
return (
|
|
114
|
+
q &&
|
|
115
|
+
typeof q === "object" &&
|
|
116
|
+
(typeof q.id === "string" || typeof q.id === "number") &&
|
|
117
|
+
typeof q.text === "string" &&
|
|
118
|
+
q.text.trim().length > 0 &&
|
|
119
|
+
q.recommended != null &&
|
|
120
|
+
(q.dimension == null || typeof q.dimension === "string")
|
|
121
|
+
);
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
function normalizeQuestionPlan(value, staticPlan) {
|
|
125
|
+
let candidate = value;
|
|
126
|
+
if (typeof candidate === "string") {
|
|
127
|
+
try {
|
|
128
|
+
candidate = JSON.parse(cleanJsonResponse(candidate));
|
|
129
|
+
} catch {
|
|
130
|
+
return staticPlan; // documented fallback: bad plan → static plan
|
|
131
|
+
}
|
|
132
|
+
}
|
|
133
|
+
const list = Array.isArray(candidate)
|
|
134
|
+
? candidate
|
|
135
|
+
: Array.isArray(candidate?.questions)
|
|
136
|
+
? candidate.questions
|
|
137
|
+
: null;
|
|
138
|
+
if (!list) return staticPlan;
|
|
139
|
+
const valid = list
|
|
140
|
+
.filter(isValidQuestion)
|
|
141
|
+
.map((q) => ({
|
|
142
|
+
id: String(q.id),
|
|
143
|
+
text: String(q.text).trim(),
|
|
144
|
+
recommended: String(q.recommended),
|
|
145
|
+
dimension: q.dimension == null ? null : String(q.dimension),
|
|
146
|
+
}))
|
|
147
|
+
.slice(0, MAX_QUESTIONS);
|
|
148
|
+
return valid.length >= MIN_LLM_QUESTIONS ? valid : staticPlan;
|
|
149
|
+
}
|
|
150
|
+
|
|
151
|
+
function buildQuestionPrompt(facts) {
|
|
152
|
+
return [
|
|
153
|
+
"You are preparing a grill-me-style stakeholder interview for a brownfield codebase engagement.",
|
|
154
|
+
`Produce at most ${MAX_QUESTIONS} interview questions. Each question MUST cite a concrete scan fact`,
|
|
155
|
+
"from the data below and MUST carry a recommended answer the stakeholder can accept as-is.",
|
|
156
|
+
"Always include: the stakeholder's pain, the 12-month intent (stabilize/scale/extend/handoff),",
|
|
157
|
+
"constraints, and weight questions (0-5) for the quality taxonomy dimensions:",
|
|
158
|
+
TAXONOMY.map((d) => `${d.id} (${d.label})`).join(", ") + ".",
|
|
159
|
+
'Return ONLY JSON: {"questions":[{"id":"...","text":"...","recommended":"...","dimension":"<taxonomy id or null>"}]}',
|
|
160
|
+
"",
|
|
161
|
+
"Scan facts:",
|
|
162
|
+
JSON.stringify(facts, null, 2),
|
|
163
|
+
].join("\n");
|
|
164
|
+
}
|
|
165
|
+
|
|
166
|
+
async function buildQuestionPlan(ctx, facts) {
|
|
167
|
+
const staticPlan = buildStaticQuestionPlan(facts);
|
|
168
|
+
const value = await withFallback(
|
|
169
|
+
ctx.llm,
|
|
170
|
+
{
|
|
171
|
+
prompt: buildQuestionPrompt(facts),
|
|
172
|
+
system: "You design sharp, fact-grounded stakeholder interviews. JSON only.",
|
|
173
|
+
tier: "frontier",
|
|
174
|
+
label: "charter-questions",
|
|
175
|
+
jsonMode: true,
|
|
176
|
+
},
|
|
177
|
+
() => OFFLINE_MARKER
|
|
178
|
+
);
|
|
179
|
+
if (value === OFFLINE_MARKER) return staticPlan;
|
|
180
|
+
return normalizeQuestionPlan(value, staticPlan);
|
|
181
|
+
}
|
|
182
|
+
|
|
183
|
+
// ------------------------------------------------- codebase-check + answers
|
|
184
|
+
|
|
185
|
+
// Grill-me codebase-check clause: answer deterministically from scan facts
|
|
186
|
+
// when the evidence is decisive; such questions are never asked.
|
|
187
|
+
function establishFromFacts(question, facts) {
|
|
188
|
+
if (question.dimension === "test-quality" && !(facts.testDirs?.length)) {
|
|
189
|
+
return { answer: "5", evidence: `no test directories among ${facts.fileCount} tracked files` };
|
|
190
|
+
}
|
|
191
|
+
if (question.dimension === "dependency-health" && !(facts.manifests?.length)) {
|
|
192
|
+
return { answer: "1", evidence: "no dependency manifests found in the repository" };
|
|
193
|
+
}
|
|
194
|
+
return null;
|
|
195
|
+
}
|
|
196
|
+
|
|
197
|
+
function loadScriptedAnswers(answersPath) {
|
|
198
|
+
let raw;
|
|
199
|
+
try {
|
|
200
|
+
raw = fs.readFileSync(answersPath, "utf8");
|
|
201
|
+
} catch (err) {
|
|
202
|
+
throw new OpError(`DOBETTER_ANSWERS file not readable: ${answersPath} (${err.message})`);
|
|
203
|
+
}
|
|
204
|
+
let parsed;
|
|
205
|
+
try {
|
|
206
|
+
parsed = JSON.parse(raw);
|
|
207
|
+
} catch {
|
|
208
|
+
throw new OpError(`DOBETTER_ANSWERS must be a JSON array of answer strings: ${answersPath}`);
|
|
209
|
+
}
|
|
210
|
+
if (!Array.isArray(parsed)) {
|
|
211
|
+
throw new OpError(`DOBETTER_ANSWERS must be a JSON array of answer strings: ${answersPath}`);
|
|
212
|
+
}
|
|
213
|
+
return parsed.map((a) => String(a));
|
|
214
|
+
}
|
|
215
|
+
|
|
216
|
+
// Answerer seam: DOBETTER_ANSWERS (scripted, in order; exhausted → "" which
|
|
217
|
+
// accepts the recommended answer) beats interactive ctx.ask. Neither → OpError.
|
|
218
|
+
function createAnswerer(ctx, env) {
|
|
219
|
+
if (env.DOBETTER_ANSWERS) {
|
|
220
|
+
const answers = loadScriptedAnswers(env.DOBETTER_ANSWERS);
|
|
221
|
+
let i = 0;
|
|
222
|
+
return async () => (i < answers.length ? answers[i++] : "");
|
|
223
|
+
}
|
|
224
|
+
if (typeof ctx.ask === "function") {
|
|
225
|
+
return async (prompt) => String((await ctx.ask(prompt)) ?? "");
|
|
226
|
+
}
|
|
227
|
+
throw new OpError(
|
|
228
|
+
"Charter interview needs answers: run interactively in a TTY, or set " +
|
|
229
|
+
"DOBETTER_ANSWERS=<path to a JSON array of answer strings>."
|
|
230
|
+
);
|
|
231
|
+
}
|
|
232
|
+
|
|
233
|
+
async function conductInterview(ctx, answerer, plan, facts) {
|
|
234
|
+
const answers = [];
|
|
235
|
+
const established = [];
|
|
236
|
+
for (const question of plan) {
|
|
237
|
+
const auto = establishFromFacts(question, facts);
|
|
238
|
+
if (auto) {
|
|
239
|
+
ctx.log.substep(`Established from codebase: ${question.id} → ${auto.answer} (${auto.evidence})`);
|
|
240
|
+
established.push({ question, answer: auto.answer, evidence: auto.evidence });
|
|
241
|
+
continue;
|
|
242
|
+
}
|
|
243
|
+
ctx.log.step(question.text);
|
|
244
|
+
const raw = (await answerer(`${question.text}\n [recommended: ${question.recommended}] > `)).trim();
|
|
245
|
+
answers.push({ question, answer: raw === "" ? question.recommended : raw });
|
|
246
|
+
}
|
|
247
|
+
return { answers, established };
|
|
248
|
+
}
|
|
249
|
+
|
|
250
|
+
// ---------------------------------------------------------------- synthesis
|
|
251
|
+
|
|
252
|
+
function parseWeightAnswer(text) {
|
|
253
|
+
const m = /-?\d+/.exec(String(text ?? ""));
|
|
254
|
+
return m ? Number.parseInt(m[0], 10) : null;
|
|
255
|
+
}
|
|
256
|
+
|
|
257
|
+
function clampWeight(n) {
|
|
258
|
+
return Math.max(0, Math.min(WEIGHT_MAX, Math.round(n)));
|
|
259
|
+
}
|
|
260
|
+
|
|
261
|
+
// Deterministic synthesis from raw answers (offline path and reconciliation
|
|
262
|
+
// baseline). Template fill only — no prose invention.
|
|
263
|
+
function offlineSynthesis({ answers, established }) {
|
|
264
|
+
const find = (id) => answers.find((a) => a.question.id === id)?.answer ?? null;
|
|
265
|
+
const weights = {};
|
|
266
|
+
const rationale = {};
|
|
267
|
+
for (const dim of TAXONOMY) {
|
|
268
|
+
const asked = answers.find((a) => a.question.dimension === dim.id);
|
|
269
|
+
const auto = established.find((e) => e.question.dimension === dim.id);
|
|
270
|
+
const source = asked ?? auto;
|
|
271
|
+
const parsed = parseWeightAnswer(source?.answer);
|
|
272
|
+
weights[dim.id] = parsed == null ? DEFAULT_WEIGHT : clampWeight(parsed);
|
|
273
|
+
rationale[dim.id] = asked
|
|
274
|
+
? `stakeholder: ${asked.answer}`
|
|
275
|
+
: auto
|
|
276
|
+
? `established from codebase: ${auto.evidence}`
|
|
277
|
+
: "default weight (no answer recorded)";
|
|
278
|
+
}
|
|
279
|
+
const intentRaw = (find("intent") ?? "stabilize").toLowerCase();
|
|
280
|
+
const intent =
|
|
281
|
+
INTENTS.find((i) => intentRaw.includes(i)) ?? (intentRaw.includes("hand") ? "handoff" : "stabilize");
|
|
282
|
+
const pain = find("pain") ? [find("pain")] : [];
|
|
283
|
+
const constraintsRaw = find("constraints");
|
|
284
|
+
const constraints =
|
|
285
|
+
!constraintsRaw || /^none\.?$/i.test(constraintsRaw.trim()) ? [] : [constraintsRaw];
|
|
286
|
+
return { intent, weights, extraDimensions: [], pain, constraints, rationale };
|
|
287
|
+
}
|
|
288
|
+
|
|
289
|
+
function toStringList(value) {
|
|
290
|
+
if (Array.isArray(value)) {
|
|
291
|
+
return value.filter((s) => typeof s === "string" && s.trim()).map((s) => s.trim());
|
|
292
|
+
}
|
|
293
|
+
if (typeof value === "string" && value.trim()) return [value.trim()];
|
|
294
|
+
return null;
|
|
295
|
+
}
|
|
296
|
+
|
|
297
|
+
function normalizeExtraDimension(d) {
|
|
298
|
+
if (!d || typeof d !== "object") return null;
|
|
299
|
+
const id = String(d.id ?? d.label ?? "")
|
|
300
|
+
.toLowerCase()
|
|
301
|
+
.trim()
|
|
302
|
+
.replace(/[^a-z0-9]+/g, "-")
|
|
303
|
+
.replace(/^-+|-+$/g, "");
|
|
304
|
+
if (!/^[a-z0-9][a-z0-9-]*$/.test(id)) return null;
|
|
305
|
+
if (TAXONOMY.some((t) => t.id === id)) return null; // taxonomy dims live in weights
|
|
306
|
+
const label = String(d.label ?? d.id).replace(/[|,\n]/g, "/").trim() || id;
|
|
307
|
+
const parsed = parseWeightAnswer(d.weight);
|
|
308
|
+
const weight = parsed == null ? DEFAULT_WEIGHT : Math.max(1, clampWeight(parsed));
|
|
309
|
+
return { id, label, weight };
|
|
310
|
+
}
|
|
311
|
+
|
|
312
|
+
// Field-by-field reconciliation of frontier synthesis JSON against the
|
|
313
|
+
// deterministic baseline. When the LLM supplies a weights map it is
|
|
314
|
+
// authoritative — missing taxonomy dims are floor-corrected later (D5).
|
|
315
|
+
function reconcileSynthesis(parsed, baseline) {
|
|
316
|
+
const out = { ...baseline };
|
|
317
|
+
if (typeof parsed.intent === "string" && INTENTS.includes(parsed.intent.toLowerCase().trim())) {
|
|
318
|
+
out.intent = parsed.intent.toLowerCase().trim();
|
|
319
|
+
}
|
|
320
|
+
if (parsed.weights && typeof parsed.weights === "object" && !Array.isArray(parsed.weights)) {
|
|
321
|
+
const coerced = {};
|
|
322
|
+
for (const dim of TAXONOMY) {
|
|
323
|
+
const n = parseWeightAnswer(parsed.weights[dim.id]);
|
|
324
|
+
if (n != null) coerced[dim.id] = clampWeight(n);
|
|
325
|
+
}
|
|
326
|
+
out.weights = coerced;
|
|
327
|
+
}
|
|
328
|
+
const pain = toStringList(parsed.pain);
|
|
329
|
+
if (pain) out.pain = pain;
|
|
330
|
+
if (Array.isArray(parsed.constraints)) {
|
|
331
|
+
out.constraints = toStringList(parsed.constraints) ?? []; // explicit [] = "no constraints"
|
|
332
|
+
} else {
|
|
333
|
+
const constraints = toStringList(parsed.constraints);
|
|
334
|
+
if (constraints) out.constraints = constraints;
|
|
335
|
+
}
|
|
336
|
+
if (Array.isArray(parsed.extraDimensions)) {
|
|
337
|
+
out.extraDimensions = parsed.extraDimensions.map(normalizeExtraDimension).filter(Boolean);
|
|
338
|
+
}
|
|
339
|
+
if (parsed.rationale && typeof parsed.rationale === "object" && !Array.isArray(parsed.rationale)) {
|
|
340
|
+
const merged = { ...baseline.rationale };
|
|
341
|
+
for (const [k, v] of Object.entries(parsed.rationale)) {
|
|
342
|
+
if (typeof v === "string" && v.trim()) merged[k] = v.trim();
|
|
343
|
+
}
|
|
344
|
+
out.rationale = merged;
|
|
345
|
+
}
|
|
346
|
+
return out;
|
|
347
|
+
}
|
|
348
|
+
|
|
349
|
+
function buildSynthesisPrompt({ facts, answers, established }) {
|
|
350
|
+
const transcript = answers.map((a) => `Q (${a.question.id}): ${a.question.text}\nA: ${a.answer}`);
|
|
351
|
+
const auto = established.map(
|
|
352
|
+
(e) => `Q (${e.question.id}): ${e.question.text}\nA (from codebase): ${e.answer} — ${e.evidence}`
|
|
353
|
+
);
|
|
354
|
+
return [
|
|
355
|
+
"Synthesize a quality charter for a brownfield engagement from this stakeholder interview.",
|
|
356
|
+
"The fixed taxonomy floor (all dimensions MUST be weighted 1-5):",
|
|
357
|
+
TAXONOMY.map((d) => `${d.id} (${d.label})`).join(", ") + ".",
|
|
358
|
+
"Return ONLY JSON:",
|
|
359
|
+
'{"intent":"stabilize|scale|extend|handoff","weights":{"<taxonomy id>":1-5,...},',
|
|
360
|
+
'"extraDimensions":[{"id":"slug","label":"...","weight":1-5}],"pain":["..."],',
|
|
361
|
+
'"constraints":["..."],"rationale":{"<taxonomy id>":"why this weight"}}',
|
|
362
|
+
"",
|
|
363
|
+
"Interview transcript:",
|
|
364
|
+
...transcript,
|
|
365
|
+
"",
|
|
366
|
+
"Established from the codebase (deterministic, not asked):",
|
|
367
|
+
...(auto.length ? auto : ["(nothing auto-established)"]),
|
|
368
|
+
"",
|
|
369
|
+
"Scan headline facts:",
|
|
370
|
+
JSON.stringify(
|
|
371
|
+
{
|
|
372
|
+
fileCount: facts.fileCount,
|
|
373
|
+
locTotal: facts.locTotal,
|
|
374
|
+
todoCount: facts.todoCount,
|
|
375
|
+
testDirs: facts.testDirs,
|
|
376
|
+
manifests: facts.manifests,
|
|
377
|
+
incantations: facts.incantations,
|
|
378
|
+
},
|
|
379
|
+
null,
|
|
380
|
+
2
|
|
381
|
+
),
|
|
382
|
+
].join("\n");
|
|
383
|
+
}
|
|
384
|
+
|
|
385
|
+
async function synthesizeCharter(ctx, data) {
|
|
386
|
+
const baseline = offlineSynthesis(data);
|
|
387
|
+
const value = await withFallback(
|
|
388
|
+
ctx.llm,
|
|
389
|
+
{
|
|
390
|
+
prompt: buildSynthesisPrompt(data),
|
|
391
|
+
system: "You synthesize engagement quality charters. JSON only.",
|
|
392
|
+
tier: "frontier",
|
|
393
|
+
label: "charter-synthesis",
|
|
394
|
+
jsonMode: true,
|
|
395
|
+
},
|
|
396
|
+
() => OFFLINE_MARKER
|
|
397
|
+
);
|
|
398
|
+
if (value === OFFLINE_MARKER) return baseline;
|
|
399
|
+
let parsed = value;
|
|
400
|
+
if (typeof parsed === "string") {
|
|
401
|
+
try {
|
|
402
|
+
parsed = JSON.parse(cleanJsonResponse(parsed));
|
|
403
|
+
} catch {
|
|
404
|
+
throw new OpError("[charter-synthesis] returned unparseable JSON"); // fail closed
|
|
405
|
+
}
|
|
406
|
+
}
|
|
407
|
+
if (!parsed || typeof parsed !== "object" || Array.isArray(parsed)) {
|
|
408
|
+
throw new OpError("[charter-synthesis] returned unparseable JSON");
|
|
409
|
+
}
|
|
410
|
+
return reconcileSynthesis(parsed, baseline);
|
|
411
|
+
}
|
|
412
|
+
|
|
413
|
+
// Taxonomy floor (D5): every taxonomy id appears with weight ≥1. Missing or
|
|
414
|
+
// sub-floor weights are corrected to 1 with a "(floor)" note — never dropped.
|
|
415
|
+
function applyTaxonomyFloor(rawWeights) {
|
|
416
|
+
const weights = {};
|
|
417
|
+
const floored = [];
|
|
418
|
+
for (const dim of TAXONOMY) {
|
|
419
|
+
const n = Number(rawWeights?.[dim.id]);
|
|
420
|
+
if (Number.isFinite(n) && Math.round(n) >= 1) {
|
|
421
|
+
weights[dim.id] = Math.min(Math.round(n), WEIGHT_MAX);
|
|
422
|
+
} else {
|
|
423
|
+
weights[dim.id] = 1;
|
|
424
|
+
floored.push(dim.id);
|
|
425
|
+
}
|
|
426
|
+
}
|
|
427
|
+
return { weights, floored };
|
|
428
|
+
}
|
|
429
|
+
|
|
430
|
+
// ----------------------------------------------------------------- assembly
|
|
431
|
+
|
|
432
|
+
function tableCell(text) {
|
|
433
|
+
return String(text ?? "").replace(/\|/g, "/").replace(/\n/g, " ");
|
|
434
|
+
}
|
|
435
|
+
|
|
436
|
+
function assembleCharter({ synthesis, floored, established, headSha, generatedAt }) {
|
|
437
|
+
const meta = {
|
|
438
|
+
approved: false,
|
|
439
|
+
headSha,
|
|
440
|
+
generatedAt,
|
|
441
|
+
intent: synthesis.intent,
|
|
442
|
+
weights: Object.fromEntries(TAXONOMY.map((dim) => [dim.id, synthesis.weights[dim.id]])),
|
|
443
|
+
};
|
|
444
|
+
if (synthesis.extraDimensions.length) {
|
|
445
|
+
// Frontmatter codec supports scalar arrays only — encode as id|label|weight.
|
|
446
|
+
meta.extraDimensions = synthesis.extraDimensions.map((d) => `${d.id}|${d.label}|${d.weight}`);
|
|
447
|
+
}
|
|
448
|
+
const lines = [
|
|
449
|
+
"# Quality Charter",
|
|
450
|
+
"",
|
|
451
|
+
`_Seeded by \`do-better scan\` facts @ ${headSha.slice(0, 7)}; drafted by \`do-better charter\` (D0)._`,
|
|
452
|
+
"",
|
|
453
|
+
"## Pain",
|
|
454
|
+
];
|
|
455
|
+
if (synthesis.pain.length) for (const p of synthesis.pain) lines.push(`- ${p}`);
|
|
456
|
+
else lines.push("_None recorded._");
|
|
457
|
+
lines.push("", "## Intent", `Twelve-month intent: **${synthesis.intent}**.`, "", "## Constraints");
|
|
458
|
+
if (synthesis.constraints.length) for (const c of synthesis.constraints) lines.push(`- ${c}`);
|
|
459
|
+
else lines.push("_None recorded._");
|
|
460
|
+
lines.push("", "## Dimension weights", "| Dimension | Weight | Rationale |", "| --- | --- | --- |");
|
|
461
|
+
for (const dim of TAXONOMY) {
|
|
462
|
+
const w = synthesis.weights[dim.id];
|
|
463
|
+
const isFloored = floored.includes(dim.id);
|
|
464
|
+
const weightCell = isFloored ? `${w} (floor)` : String(w);
|
|
465
|
+
const why = isFloored
|
|
466
|
+
? "not weighted by synthesis — taxonomy floor applied (D5)"
|
|
467
|
+
: synthesis.rationale?.[dim.id] ?? "";
|
|
468
|
+
lines.push(`| ${dim.label} | ${weightCell} | ${tableCell(why)} |`);
|
|
469
|
+
}
|
|
470
|
+
lines.push("", "## Established from the codebase");
|
|
471
|
+
if (established.length) {
|
|
472
|
+
for (const e of established) {
|
|
473
|
+
lines.push(`- ${e.question.text} → **${e.answer}** (evidence: ${e.evidence})`);
|
|
474
|
+
}
|
|
475
|
+
} else {
|
|
476
|
+
lines.push("_Nothing auto-established — every question was asked._");
|
|
477
|
+
}
|
|
478
|
+
lines.push("", "## Engagement dimensions");
|
|
479
|
+
if (synthesis.extraDimensions.length) {
|
|
480
|
+
for (const d of synthesis.extraDimensions) lines.push(`- **${d.label}** (\`${d.id}\`) — weight ${d.weight}`);
|
|
481
|
+
} else {
|
|
482
|
+
lines.push("_None — fixed taxonomy only._");
|
|
483
|
+
}
|
|
484
|
+
lines.push("");
|
|
485
|
+
return { meta, body: lines.join("\n") };
|
|
486
|
+
}
|
|
487
|
+
|
|
488
|
+
// ------------------------------------------------------------------ parsing
|
|
489
|
+
|
|
490
|
+
function parseBulletSection(body, heading) {
|
|
491
|
+
const out = [];
|
|
492
|
+
let inSection = false;
|
|
493
|
+
for (const line of String(body).split("\n")) {
|
|
494
|
+
if (/^##\s+/.test(line)) {
|
|
495
|
+
inSection = line.replace(/^##\s+/, "").trim().toLowerCase() === heading.toLowerCase();
|
|
496
|
+
continue;
|
|
497
|
+
}
|
|
498
|
+
if (inSection && line.startsWith("- ")) out.push(line.slice(2).trim());
|
|
499
|
+
}
|
|
500
|
+
return out;
|
|
501
|
+
}
|
|
502
|
+
|
|
503
|
+
function parseExtraDimensions(value) {
|
|
504
|
+
if (value == null) return [];
|
|
505
|
+
const list = Array.isArray(value) ? value : [value];
|
|
506
|
+
const out = [];
|
|
507
|
+
for (const entry of list) {
|
|
508
|
+
const [id, label, weight] = String(entry).split("|");
|
|
509
|
+
if (!id || !id.trim()) continue;
|
|
510
|
+
const parsed = parseWeightAnswer(weight);
|
|
511
|
+
out.push({
|
|
512
|
+
id: id.trim(),
|
|
513
|
+
label: (label ?? id).trim() || id.trim(),
|
|
514
|
+
weight: parsed == null ? 1 : Math.max(1, clampWeight(parsed)),
|
|
515
|
+
});
|
|
516
|
+
}
|
|
517
|
+
return out;
|
|
518
|
+
}
|
|
519
|
+
|
|
520
|
+
// Strict charter validation — used by approve() and downstream phases' tests.
|
|
521
|
+
// Throws OpError on any missing taxonomy weight (fixed taxonomy floor, D5).
|
|
522
|
+
export function parseCharter(text) {
|
|
523
|
+
if (typeof text !== "string" || !text.trim()) {
|
|
524
|
+
throw new OpError("Charter is empty or unreadable — run `do-better charter` to regenerate it.");
|
|
525
|
+
}
|
|
526
|
+
const { meta, body } = parseFrontmatter(text);
|
|
527
|
+
const weightsRaw = meta.weights;
|
|
528
|
+
if (!weightsRaw || typeof weightsRaw !== "object" || Array.isArray(weightsRaw)) {
|
|
529
|
+
throw new OpError(
|
|
530
|
+
"Charter frontmatter is missing the `weights` map — regenerate with `do-better charter`."
|
|
531
|
+
);
|
|
532
|
+
}
|
|
533
|
+
const weights = {};
|
|
534
|
+
for (const dim of TAXONOMY) {
|
|
535
|
+
const n = Number(weightsRaw[dim.id]);
|
|
536
|
+
if (!Number.isFinite(n) || Math.round(n) < 1) {
|
|
537
|
+
throw new OpError(
|
|
538
|
+
`Charter is missing taxonomy weight for "${dim.id}" (fixed taxonomy floor, D5). ` +
|
|
539
|
+
`Add \`${dim.id}: <1-5>\` under \`weights:\` in .dobetter/charter.md.`
|
|
540
|
+
);
|
|
541
|
+
}
|
|
542
|
+
weights[dim.id] = Math.min(Math.round(n), WEIGHT_MAX);
|
|
543
|
+
}
|
|
544
|
+
const intent = typeof meta.intent === "string" ? meta.intent.toLowerCase().trim() : null;
|
|
545
|
+
if (!INTENTS.includes(intent)) {
|
|
546
|
+
throw new OpError(
|
|
547
|
+
`Charter intent must be one of ${INTENTS.join("/")} (got ${JSON.stringify(meta.intent ?? null)}).`
|
|
548
|
+
);
|
|
549
|
+
}
|
|
550
|
+
return {
|
|
551
|
+
weights,
|
|
552
|
+
extraDimensions: parseExtraDimensions(meta.extraDimensions),
|
|
553
|
+
intent,
|
|
554
|
+
constraints: parseBulletSection(body, "Constraints"),
|
|
555
|
+
pain: parseBulletSection(body, "Pain"),
|
|
556
|
+
approved: meta.approved === true,
|
|
557
|
+
};
|
|
558
|
+
}
|
|
559
|
+
|
|
560
|
+
// ----------------------------------------------------------------- approval
|
|
561
|
+
|
|
562
|
+
function approveCharterFile(ctx, state) {
|
|
563
|
+
const existing = readArtifact(ctx.dotdir, LAYOUT.charter);
|
|
564
|
+
if (!existing) {
|
|
565
|
+
throw new OpError("No charter found at .dobetter/charter.md — run `do-better charter` first.");
|
|
566
|
+
}
|
|
567
|
+
// Validate the floor before approving (humans may have edited the file).
|
|
568
|
+
parseCharter(serializeFrontmatter(existing.meta, existing.body));
|
|
569
|
+
const meta = { ...existing.meta, approved: true };
|
|
570
|
+
const content = serializeFrontmatter(meta, existing.body);
|
|
571
|
+
writeArtifact(ctx.dotdir, LAYOUT.charter, { meta, body: existing.body });
|
|
572
|
+
const charterSha256 = sha256Hex(content);
|
|
573
|
+
const approvedAt = ctx.now();
|
|
574
|
+
const next = setGate(state, "charter", { approved: true, approvedAt, charterSha256 });
|
|
575
|
+
return { state: next, charterSha256 };
|
|
576
|
+
}
|
|
577
|
+
|
|
578
|
+
// `do-better charter --approve` path: re-read charter.md, validate the
|
|
579
|
+
// taxonomy floor, hash it, set HUMAN GATE 1.
|
|
580
|
+
export async function approve(ctx) {
|
|
581
|
+
const headSha = gitHeadSha(ctx.root, ctx.exec);
|
|
582
|
+
let state = ctx.state;
|
|
583
|
+
const approved = approveCharterFile(ctx, state);
|
|
584
|
+
state = approved.state;
|
|
585
|
+
state = recordPhase(state, PHASE_ID, { status: "done", sha: headSha, now: ctx.now() });
|
|
586
|
+
state = pinSha(state, PHASE_ID, headSha);
|
|
587
|
+
const summary =
|
|
588
|
+
`Charter approved (sha256 ${approved.charterSha256.slice(0, 12)}…). ` +
|
|
589
|
+
"HUMAN GATE 1 passed — next: do-better audit.";
|
|
590
|
+
return {
|
|
591
|
+
state,
|
|
592
|
+
gate: { name: "charter", passed: true, human: true, detail: "Charter approved." },
|
|
593
|
+
summary,
|
|
594
|
+
};
|
|
595
|
+
}
|
|
596
|
+
|
|
597
|
+
// --------------------------------------------------------------------- run
|
|
598
|
+
|
|
599
|
+
export async function run(ctx) {
|
|
600
|
+
const facts = ctx.state?.phases?.scan?.facts;
|
|
601
|
+
if (!facts) {
|
|
602
|
+
throw new OpError(
|
|
603
|
+
"No scan facts found — run `do-better scan` first (charter questions cite scan facts, D8)."
|
|
604
|
+
);
|
|
605
|
+
}
|
|
606
|
+
ctx.log.phase("D0", "Charter");
|
|
607
|
+
const headSha = gitHeadSha(ctx.root, ctx.exec);
|
|
608
|
+
const answerer = createAnswerer(ctx, process.env); // fail fast before spending tokens
|
|
609
|
+
let state = ctx.state;
|
|
610
|
+
try {
|
|
611
|
+
ctx.log.step("Building interview question plan (frontier tier)");
|
|
612
|
+
const plan = await buildQuestionPlan(ctx, facts);
|
|
613
|
+
|
|
614
|
+
const { answers, established } = await conductInterview(ctx, answerer, plan, facts);
|
|
615
|
+
|
|
616
|
+
ctx.log.step("Synthesizing charter (frontier tier)");
|
|
617
|
+
const synthesis = await synthesizeCharter(ctx, { facts, answers, established });
|
|
618
|
+
const { weights, floored } = applyTaxonomyFloor(synthesis.weights);
|
|
619
|
+
for (const dimId of floored) {
|
|
620
|
+
ctx.log.warn(`Taxonomy floor applied: ${dimId} weight corrected to 1 (D5)`);
|
|
621
|
+
}
|
|
622
|
+
const { meta, body } = assembleCharter({
|
|
623
|
+
synthesis: { ...synthesis, weights },
|
|
624
|
+
floored,
|
|
625
|
+
established,
|
|
626
|
+
headSha,
|
|
627
|
+
generatedAt: ctx.now(),
|
|
628
|
+
});
|
|
629
|
+
writeArtifact(ctx.dotdir, LAYOUT.charter, { meta, body });
|
|
630
|
+
parseCharter(serializeFrontmatter(meta, body)); // round-trip guard: floor must hold
|
|
631
|
+
|
|
632
|
+
state = addSpend(state, PHASE_ID, ctx.llm.drainSpend());
|
|
633
|
+
state = recordPhase(state, PHASE_ID, { status: "done", sha: headSha, now: ctx.now() });
|
|
634
|
+
state = pinSha(state, PHASE_ID, headSha);
|
|
635
|
+
|
|
636
|
+
const decision = (await answerer("Approve charter now? [y/N] > ")).trim().toLowerCase();
|
|
637
|
+
if (decision === "y" || decision === "yes") {
|
|
638
|
+
const approved = approveCharterFile(ctx, state);
|
|
639
|
+
state = approved.state;
|
|
640
|
+
return {
|
|
641
|
+
state,
|
|
642
|
+
gate: { name: "charter", passed: true, human: true, detail: "Charter approved." },
|
|
643
|
+
summary:
|
|
644
|
+
`Charter written to .dobetter/${LAYOUT.charter} and approved ` +
|
|
645
|
+
`(sha256 ${approved.charterSha256.slice(0, 12)}…). Next: do-better audit.`,
|
|
646
|
+
};
|
|
647
|
+
}
|
|
648
|
+
return {
|
|
649
|
+
state,
|
|
650
|
+
gate: { name: "charter", passed: false, human: true, detail: APPROVE_HINT },
|
|
651
|
+
summary: APPROVE_HINT,
|
|
652
|
+
};
|
|
653
|
+
} catch (err) {
|
|
654
|
+
err.state = addSpend(state, PHASE_ID, ctx.llm.drainSpend());
|
|
655
|
+
throw err;
|
|
656
|
+
}
|
|
657
|
+
}
|