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/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
+ }