wdi-method 0.6.4 → 0.6.6

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/bin/wdi-method.js CHANGED
@@ -1,1636 +1,1641 @@
1
- #!/usr/bin/env node
2
- import fs from "node:fs";
3
- import os from "node:os";
4
- import path from "node:path";
5
- import { spawnSync } from "node:child_process";
6
- import { fileURLToPath } from "node:url";
7
- import * as p from "@clack/prompts";
8
- import {
9
- fillProductTitle,
10
- upsertMethodBlock,
11
- } from "../lib/agents-block.mjs";
12
- import {
13
- identityIsPlaceholder,
14
- humaniseFolderName,
15
- readLanguagePolicy,
16
- writeLanguagePolicy,
17
- DEFAULT_DOC_LANGUAGE,
18
- readProductIdentity,
19
- writeProductIdentity,
20
- } from "../lib/identity.mjs";
21
- import {
22
- detectPlatforms,
23
- formatPlatformList,
24
- isKnownPlatform,
25
- normalizePlatformIds,
26
- platformSelectOptions,
27
- platformUsesHook,
28
- PREFERRED_PLATFORM_IDS,
29
- skillDestinations,
30
- } from "../lib/platforms.mjs";
31
- import { opencodeCommandsDir, syncOpencodeCommands } from "../lib/opencode-commands.mjs";
32
-
33
- const ROOT = path.resolve(path.dirname(fileURLToPath(import.meta.url)), "..");
34
- const KIT = path.join(ROOT, "kit");
35
- const OVERLAY = path.join(ROOT, "kit-overlay");
36
- const SCAFFOLD = path.join(ROOT, "scaffold", ".control");
37
- const PKG = JSON.parse(fs.readFileSync(path.join(ROOT, "package.json"), "utf8"));
38
-
39
- const WDI_SKILLS = [
40
- "wdi-init",
41
- "wdi-problem",
42
- "wdi-product",
43
- "wdi-ux",
44
- "wdi-blueprint",
45
- "wdi-component",
46
- "wdi-build",
47
- "wdi-decision",
48
- "wdi-question",
49
- "wdi-log",
50
- "wdi-help",
51
- "wdi-explain-to-me",
52
- "wdi-autopilot",
53
- "wdi-reconcile",
54
- "wdi-review",
55
- "wdi-report",
56
- "wdi-systematic-debugging",
57
- "wdi-upgrade",
58
- ];
59
-
60
- const PRD_SLUG_PLACEHOLDER = "FILL-initiative-slug";
61
- const GENERIC_FOLDER_PATTERNS = new Set([
62
- "_product-brief",
63
- "ux",
64
- "architecture",
65
- PRD_SLUG_PLACEHOLDER,
66
- ]);
67
-
68
- const BMAD_INSTALL = `npx bmad-method install`;
69
- // The ticket engines G5 runs. BMad writes the documents; these cut the work. They are a Claude Code
70
- // plugin installed per USER, not per repo, so the check reads the plugin registry — and the check
71
- // warns instead of blocking, because G1–G4 run without them and a first install has no G5 yet.
72
- const ENGINES_REPO = "https://github.com/mattpocock/skills";
73
- const ENGINES_PLUGIN = "mattpocock-skills";
74
- const ENGINES_INSTALL = `/plugin install ${ENGINES_PLUGIN}`;
75
- const ENGINES_INSTALL_ANY = "npx skills@latest add mattpocock/skills";
76
- const ENGINES_SETUP = "/setup-matt-pocock-skills";
77
- const REPO_URL = "https://github.com/wiradigitalid/wdi-method";
78
- const HELP_SKILL = "wdi-help";
79
- const INIT_SKILL = "wdi-init";
80
- // The room's readers file is seeded as a skeleton and is useless until a product writes it. The
81
- // flag is the skeleton's own declaration, so this reads the same thing the engine does rather than
82
- // guessing from the file's size or its age.
83
- function readersAreSkeleton(target) {
84
- const file = path.join(target, ".constitution", "project", "inventory-readers.py");
85
- if (!fs.existsSync(file)) return false;
86
- return /^SKELETON\s*=\s*True\b/m.test(fs.readFileSync(file, "utf8"));
87
- }
88
- const BMAD_REPO = "https://github.com/bmad-code-org/BMAD-METHOD";
89
- const WDI_REPO = "https://github.com/wiradigitalid/wdi-method";
90
-
91
- const RED = "\x1b[31m";
92
- const GREEN = "\x1b[32m";
93
- const DIM = "\x1b[2m";
94
- const RESET = "\x1b[0m";
95
-
96
- function die(msg) {
97
- console.error(`${RED}error:${RESET} ${msg}`);
98
- process.exit(1);
99
- }
100
-
101
- function ok(msg) {
102
- console.log(`${GREEN}ok${RESET} ${msg}`);
103
- }
104
-
105
- function note(msg) {
106
- console.log(`${DIM}·${RESET} ${msg}`);
107
- }
108
-
109
- function usage() {
110
- console.log(`wdi-method ${PKG.version}
111
-
112
- (no command) interactive TUI — detects install vs update
113
- install [dir] first install (TUI unless --yes)
114
- update [dir] update (TUI unless --yes)
115
- verify [dir]
116
- promote <live-dir> --rescue pull a method change back out of a consumer (not the normal flow)
117
-
118
- --yes non-interactive
119
- --agents a,b platform IDs (same as BMad --tools; legacy: claude = claude-code)
120
- --list-agents print supported platform IDs
121
- --product NAME written to index.yaml product.name
122
- --client NAME written to index.yaml product.client (optional)
123
- --doc-language <text> prose of working documents; free text, default English
124
- --doc-filename-language <text> slug part of document filenames; free text, default English
125
- --skip-bmad-check
126
- --skip-engines-check install without to-spec / to-tickets / implement
127
-
128
- BMad first, then this package. ${WDI_REPO}
129
- `);
130
- }
131
-
132
- function parseArgs(argv) {
133
- const args = {
134
- cmd: null,
135
- dir: null,
136
- agents: null,
137
- skipBmad: false,
138
- rescue: false,
139
- yes: false,
140
- product: null,
141
- client: null,
142
- docLanguage: null,
143
- docFilenameLanguage: null,
144
- };
145
- const rest = argv.slice(2);
146
- if (rest[0] === "-h" || rest[0] === "--help") {
147
- usage();
148
- process.exit(0);
149
- }
150
- if (rest[0] === "--list-agents") {
151
- console.log(formatPlatformList());
152
- process.exit(0);
153
- }
154
- if (rest.length === 0) {
155
- args.cmd = "wizard";
156
- return args;
157
- }
158
- const first = rest[0];
159
- if (["install", "update", "verify", "promote"].includes(first)) {
160
- args.cmd = rest.shift();
161
- } else if (first.startsWith("-")) {
162
- args.cmd = "wizard";
163
- } else {
164
- args.cmd = "wizard";
165
- args.dir = rest.shift();
166
- }
167
- while (rest.length) {
168
- const t = rest.shift();
169
- if (t === "--skip-bmad-check") args.skipBmad = true;
170
- else if (t === "--skip-engines-check") args.skipEngines = true;
171
- else if (t === "--rescue") args.rescue = true;
172
- else if (t === "--yes" || t === "-y") args.yes = true;
173
- else if (t === "--agents") {
174
- const raw = rest.shift();
175
- if (!raw) die("--agents needs a comma-separated list");
176
- args.agents = normalizePlatformIds(raw.split(",").map((s) => s.trim()).filter(Boolean));
177
- const unknown = raw.split(",").map((s) => s.trim()).filter(Boolean)
178
- .filter((a) => !isKnownPlatform(a));
179
- if (unknown.length) die(`unknown platform: ${unknown.join(", ")} (run --list-agents)`);
180
- if (!args.agents.length) die("--agents needs at least one known platform");
181
- } else if (t === "--product") args.product = rest.shift();
182
- else if (t === "--client") args.client = rest.shift();
183
- else if (t === "--doc-language" || t === "--doc-filename-language") {
184
- // Free text: "English", "Bahasa Indonesia", "id" — a model reads it, so no list to match.
185
- const raw = (rest.shift() || "").trim();
186
- if (!raw) die(`${t} needs a value, for example: English`);
187
- if (t === "--doc-language") args.docLanguage = raw;
188
- else args.docFilenameLanguage = raw;
189
- }
190
- else if (t.startsWith("-")) die(`unknown flag: ${t}`);
191
- else if (!args.dir) args.dir = t;
192
- else die(`unexpected argument: ${t}`);
193
- }
194
- return args;
195
- }
196
-
197
- // Build output and editor droppings MUST NOT reach the kit. This repository is public, and a
198
- // __pycache__/*.pyc carries the ABSOLUTE PATH of the source it was compiled from — which means a
199
- // product name and a client folder leak into a public package through a file nobody wrote.
200
- // Found 2026-08-18 on the first real promote: inventory.cpython-314.pyc embedded the live repo path.
201
- const SKIP_DIRS = new Set(["__pycache__", "node_modules", ".git", ".pytest_cache", ".ruff_cache",
202
- ".mypy_cache", ".venv", "venv", "dist", "build", ".idea", ".vscode"]);
203
- const SKIP_FILE = /(\.pyc|\.pyo|\.pyd|\.log|\.tmp|\.swp|\.orig|\.rej|\.bak)$|^\.DS_Store$|^Thumbs\.db$/i;
204
-
205
- function walkFiles(dir) {
206
- const out = [];
207
- if (!fs.existsSync(dir)) return out;
208
- for (const entry of fs.readdirSync(dir, { withFileTypes: true })) {
209
- const p = path.join(dir, entry.name);
210
- if (entry.isDirectory()) {
211
- if (SKIP_DIRS.has(entry.name)) continue;
212
- out.push(...walkFiles(p));
213
- } else if (entry.isFile()) {
214
- if (SKIP_FILE.test(entry.name)) continue;
215
- out.push(p);
216
- }
217
- }
218
- return out;
219
- }
220
-
221
- function copyFile(src, dest) {
222
- fs.mkdirSync(path.dirname(dest), { recursive: true });
223
- fs.copyFileSync(src, dest);
224
- }
225
-
226
- function copyTree(src, dest, skipRel) {
227
- let n = 0;
228
- for (const p of walkFiles(src)) {
229
- const rel = posixRel(src, p);
230
- if (skipRel && skipRel(rel)) continue;
231
- copyFile(p, path.join(dest, path.relative(src, p)));
232
- n += 1;
233
- }
234
- return n;
235
- }
236
-
237
- function posixRel(from, to) {
238
- return path.relative(from, to).split(path.sep).join("/");
239
- }
240
-
241
- function bmadPresent(target) {
242
- const markers = [
243
- path.join(target, ".claude", "skills", "bmad-help", "SKILL.md"),
244
- path.join(target, "_bmad", "core", "config.yaml"),
245
- path.join(target, "_bmad", "_config", "manifest.yaml"),
246
- ];
247
- return markers.some((p) => fs.existsSync(p));
248
- }
249
-
250
- function wdiPresent(target) {
251
- return (
252
- fs.existsSync(path.join(target, ".control", "wdi-method.yaml")) ||
253
- fs.existsSync(path.join(target, ".constitution", "method", "README.md"))
254
- );
255
- }
256
-
257
- function dirNonEmpty(target) {
258
- if (!fs.existsSync(target)) return false;
259
- return fs.readdirSync(target).some((n) => n !== ".git" && n !== ".gitignore");
260
- }
261
-
262
- function readBmadVersion(target) {
263
- const manifest = path.join(target, "_bmad", "_config", "manifest.yaml");
264
- if (!fs.existsSync(manifest)) return "";
265
- const text = fs.readFileSync(manifest, "utf8");
266
- const m = text.match(/installation:\s*\n\s*version:\s*(\S+)/);
267
- return m ? m[1] : "";
268
- }
269
-
270
- function gitHead(repo) {
271
- const r = spawnSync("git", ["-C", repo, "rev-parse", "--short", "HEAD"], {
272
- encoding: "utf8",
273
- });
274
- if (r.status !== 0) return "unknown";
275
- return r.stdout.trim();
276
- }
277
-
278
- function today() {
279
- return new Date().toISOString().slice(0, 10);
280
- }
281
-
282
- function requireKit() {
283
- if (!fs.existsSync(path.join(KIT, ".constitution"))) {
284
- die(`kit missing at ${KIT}`);
285
- }
286
- }
287
-
288
- function requireTarget(dir) {
289
- const target = path.resolve(dir || process.cwd());
290
- if (!fs.existsSync(target) || !fs.statSync(target).isDirectory()) {
291
- die(`target is not a directory: ${target}`);
292
- }
293
- return target;
294
- }
295
-
296
- /** `to-spec` · `to-tickets` · `implement` — present as a user-level plugin, or copied into the repo. */
297
- function enginesPresent(target) {
298
- for (const dir of [".claude", ".agents", ".agent", ".cursor", ".codex"]) {
299
- if (fs.existsSync(path.join(target, dir, "skills", "to-tickets", "SKILL.md"))) return true;
300
- }
301
- const cfg = process.env.CLAUDE_CONFIG_DIR || path.join(os.homedir(), ".claude");
302
- const registry = path.join(cfg, "plugins", "installed_plugins.json");
303
- if (!fs.existsSync(registry)) return false;
304
- try {
305
- const plugins = JSON.parse(fs.readFileSync(registry, "utf8")).plugins || {};
306
- return Object.keys(plugins).some((k) => k.startsWith("mattpocock-skills@"));
307
- } catch {
308
- return false;
309
- }
310
- }
311
-
312
- function bmadMissingMessage() {
313
- return [
314
- "BMad Method is not installed in this repo. Install it first, then run this installer again.",
315
- "",
316
- ` ${BMAD_INSTALL}`,
317
- "",
318
- `Source: ${BMAD_REPO}`,
319
- "In the BMad installer, pick the same agents (Claude Code, Cursor, …).",
320
- ].join("\n");
321
- }
322
-
323
- // The engines used to WARN and let the install through, on the reasoning that G1-G4 run without them and
324
- // a first install has no G5 yet. Both halves are still true, and the reasoning stopped being enough:
325
- // `wdi-autopilot` needs all three from its first iteration, and a warning inside a forty-line summary is
326
- // read exactly as often as it is skipped. The failure it was meant to prevent — learning they are missing
327
- // inside `wdi-build`, with a spec already open — kept happening anyway.
328
- //
329
- // So it blocks, and `--skip-engines-check` is the escape, exactly as `--skip-bmad-check` is for BMad. The
330
- // escape matters: CI installs into a bare checkout, and a repo that will never reach G5 is a real case.
331
- function enginesMissingMessage() {
332
- return [
333
- "The ticket engines are not installed. G5 (wdi-build) and wdi-autopilot need all three.",
334
- "",
335
- ` Claude Code: ${ENGINES_INSTALL}`,
336
- ` Other agents: ${ENGINES_INSTALL_ANY}`,
337
- "",
338
- "You do NOT need to run the setup skill after this — the installer seeds docs/agents/ already",
339
- `answered for this method. Run ${ENGINES_SETUP} only to change tracker.`,
340
- `Source: ${ENGINES_REPO}`,
341
- "",
342
- "G1-G4 run without them. To install anyway and add them later: --skip-engines-check",
343
- ].join("\n");
344
- }
345
-
346
- // The product's custom room. Three properties, and all three MUST hold together:
347
- // install/update seeds its content ONLY when absent — never written again after that
348
- // promote SKIPS it entirely, so a product's own rules can never reach the public repo
349
- // agent loads it like any other guide, so it BINDS
350
- // The deliberate consequence: this room's README is authored in the package and never comes home
351
- // through promote.
352
- const PROJECT_ROOM = "project/";
353
-
354
- // 0.5.0 moved `.constitution/` to exactly two folders: `method/` is the method's and is overwritten,
355
- // `project/` is the product's and is never touched. Before it, generic and product-owned files sat
356
- // side by side at the root, `codebase/` was a third product-owned room nobody had written down, and
357
- // `constitution.md` was ONE file holding both — which is why `update` had to keep the whole thing and
358
- // the product never received a fixed generic Article.
359
- //
360
- // Without this migration an installed repo would end up carrying BOTH layouts: the kit writes the new
361
- // paths while the old files stay behind, and an agent reading `AGENTS.md` routing would find two
362
- // copies of most guides and no way to tell which binds.
363
- const OLD_ROOT_GUIDES = ["README", "language-guide", "method-glossary", "repo-guide", "structure-guide"];
364
- const OLD_WHY = ["README", "artifact-map", "portability", "rationale"];
365
- const OLD_CODEBASE = ["stack", "conventions", "brownfield"];
366
-
367
- function mv(from, to) {
368
- fs.mkdirSync(path.dirname(to), { recursive: true });
369
- fs.renameSync(from, to);
370
- }
371
-
372
- /** Article numbers that belong to the method half. The product keeps 1, 2, and 5. */
373
- const METHOD_ARTICLES = [3, 4, 6, 7];
374
-
375
- /**
376
- * Cut the method's articles out of a product's constitution.md, and repoint its relative links.
377
- *
378
- * Returns {cut, kept, relinked}, or null when the file does not look like a constitution at all —
379
- * in which case it is left ALONE rather than guessed at.
380
- *
381
- * 0.5.0 moved the file whole and printed "delete Articles 3, 4, 6, 7 yourself", on the grounds that
382
- * no script can tell an edited copy from the original. That reasoning was wrong in the way that
383
- * matters: the split does not need to know whether a section was edited, only which article numbers
384
- * are the method's — and the file states them in its own headings. Leaving it whole left every
385
- * migrated repo carrying those articles in TWO files, one of them frozen and drifting, plus relative
386
- * links that no longer resolve one level down. It is all in git, so cutting is reversible; not
387
- * cutting is what nobody notices.
388
- */
389
- function splitProductConstitution(file) {
390
- if (!fs.existsSync(file)) return null;
391
- const raw = fs.readFileSync(file, "utf8");
392
- const crlf = raw.includes("\r\n");
393
- const text = crlf ? raw.replaceAll("\r\n", "\n") : raw;
394
- const marks = [...text.matchAll(/^## Article (\d+)\b.*$/gm)];
395
- if (marks.length < 2) return null; // not the shape we know; do not touch it
396
-
397
- const kept = [];
398
- const cut = [];
399
- let out = text.slice(0, marks[0].index);
400
- for (let i = 0; i < marks.length; i += 1) {
401
- const n = Number(marks[i][1]);
402
- const end = i + 1 < marks.length ? marks[i + 1].index : text.length;
403
- if (METHOD_ARTICLES.includes(n)) cut.push(n);
404
- else {
405
- kept.push(n);
406
- out += text.slice(marks[i].index, end);
407
- }
408
- }
409
- if (!cut.length) return { cut, kept, relinked: 0 };
410
-
411
- // The file sits one level deeper than it did, and its former siblings moved into method/. A link
412
- // left as `repo-guide.md` now resolves to .constitution/project/repo-guide.md, which does not exist.
413
- let relinked = 0;
414
- const bump = (re, to) => {
415
- out = out.replace(re, (m, ...rest) => {
416
- relinked += 1;
417
- return typeof to === "function" ? to(m, ...rest) : to + m;
418
- });
419
- };
420
- for (const name of ["repo-guide.md", "structure-guide.md", "language-guide.md",
421
- "method-glossary.md"]) {
422
- bump(new RegExp(`(?<![\\w./-])${name.replace(".", "\\.")}`, "g"), "../method/");
423
- }
424
- bump(/(?<![\w./-])document\//g, "../method/");
425
- bump(/(?<![\w./-])codebase\/([a-z]+)-guide\.md/g, (_m, kind) => `codebase-${kind}-guide.md`);
426
- out = out.replaceAll("../method/../method/", "../method/");
427
-
428
- const banner = [
429
- "",
430
- `> **Articles ${cut.join(", ")} were removed from this file on migration to the two-folder layout.**`,
431
- "> They are the method's and live in [`../method/constitution.md`](../method/constitution.md), which",
432
- `> \`update\` replaces. Only Articles ${kept.join(", ")} are yours. The removed text is in git.`,
433
- "",
434
- ].join("\n");
435
- const firstArticle = out.search(/^## Article /m);
436
- out = firstArticle === -1
437
- ? out + banner
438
- : out.slice(0, firstArticle) + banner.trimStart() + "\n" + out.slice(firstArticle);
439
-
440
- fs.writeFileSync(file, crlf ? out.replaceAll("\n", "\r\n") : out, "utf8");
441
- return { cut, kept, relinked };
442
- }
443
-
444
- // `waves.yaml` holds the PRODUCT's plan, not the package's. When the method retired `wave` for
445
- // `spec` the registry had to follow, and a rename is the only part of that a tool can safely do:
446
- // the file MOVES, its content is left exactly as written. Rewriting the rows — `W1` to `SPEC-1`,
447
- // `epics`/`stories` to `tickets` — is the product's own migration, run by `wdi-build` where a human
448
- // can see it, because a guess there silently rewrites months of real work.
449
- //
450
- // Two refusals matter more than the move. It never writes over an existing `specs.yaml`, and it
451
- // never deletes a `waves.yaml` whose content has nowhere to go: a half-finished hand migration
452
- // leaves BOTH files present, and which one is real is not something an installer can know.
453
- // `wdi-autopilot` named its ledger for the DAY before 0.6.2 — `autopilot-<YYYY-MM-DD>.md`. The mandate
454
- // it belongs to is named for the MANDATE now — `autopilot-<DEC-id>.md` — because two mandates opened
455
- // on the same day would otherwise append to one file and destroy both as a record, and because
456
- // `mandate-accept` (the validator introduced alongside the rename) looks for the file at that path and
457
- // nowhere else. This is a pure rename, like `waves.yaml` → `specs.yaml`: the ledger's own content is
458
- // never touched, only found and moved. Renaming it is what a script can safely do; restructuring its
459
- // CONTENT into the `## Resume` / `## Decisions` split is not — that has to read git and the registry to
460
- // know where the run actually stands, so it is the skill's own job on the next iteration it runs, not
461
- // this installer's.
462
- // `/setup-matt-pocock-skills` interviews the owner and writes `docs/agents/`. Two of its answers are
463
- // wrong for a WDI repo, and BOTH repos that ran it had to hand-correct the SAME file afterwards:
464
- //
465
- // - `domain.md` tells agents to read and lazily create a root `CONTEXT.md` and `docs/adr/`. Article 3
466
- // says this method has no `docs/` layer for corpus or rules, and `wdi-reconcile` reports both as
467
- // findings. The homes already exist: `.control/product-glossary.md`, `.what/`, `.how/`, `DEC-`.
468
- // - `issue-tracker.md`'s local-markdown default puts every ticket under `.scratch/<feature>/`, while
469
- // `wdi-build` owns tickets at `{spec_folder}/issues/`. Two homes for one ticket set.
470
- //
471
- // Seeding them removes the interview for the answers WDI Method actually has a requirement on. Seeded
472
- // ONCE and never overwritten — after the first install they are the product's, like every other file
473
- // under a path the product owns. An owner who wants a different tracker re-runs the setup skill; the
474
- // seeded file says which three invariants have to survive that.
475
- function seedAgentDocs(target) {
476
- const dir = path.join(target, "docs", "agents");
477
- let wrote = 0;
478
- for (const name of ["domain.md", "issue-tracker.md"]) {
479
- const to = path.join(dir, name);
480
- if (fs.existsSync(to)) continue;
481
- const seed = path.join(ROOT, "scaffold", "docs", "agents", name);
482
- if (!fs.existsSync(seed)) continue;
483
- copyFile(seed, to);
484
- wrote += 1;
485
- }
486
- if (wrote) {
487
- note(`seeded docs/agents/ (${wrote} file${wrote === 1 ? "" : "s"}) — the engines' config, pre-answered`);
488
- note(" do NOT run /setup-matt-pocock-skills to redo these; re-run it only to change tracker");
489
- }
490
- return wrote > 0;
491
- }
492
-
493
- // A repo that ran the setup skill BEFORE installing this package still carries the default `domain.md`,
494
- // and it is actively misleading: it sends every engineering skill looking for a root `CONTEXT.md` and
495
- // `docs/adr/`, and tells them to create both lazily. Seeding cannot fix it, because the file already
496
- // exists and a file under a product-owned path is never overwritten. So it is named instead.
497
- function warnStaleAgentDocs(target) {
498
- const file = path.join(target, "docs", "agents", "domain.md");
499
- if (!fs.existsSync(file)) return;
500
- const text = fs.readFileSync(file, "utf8");
501
- if (!/CONTEXT\.md|docs\/adr/.test(text)) return;
502
- // An override note is what both real repos added by hand. Recognising it is what stops this warning
503
- // from firing forever on a file somebody already fixed.
504
- if (/does not use|MUST NOT be created|no `docs\/` layer/i.test(text)) return;
505
- note("docs/agents/domain.md still points agents at a root CONTEXT.md and docs/adr/");
506
- note(" Article 3: this method has no `docs/` layer for corpus or rules, and wdi-reconcile");
507
- note(" reports both as findings. Say so at the top of that file — the glossary is at");
508
- note(" .control/product-glossary.md and a decision is a DEC-, never an ADR");
509
- }
510
-
511
- function migrateAutopilotLedgers(target) {
512
- const dir = path.join(target, ".control", "memlog");
513
- if (!fs.existsSync(dir)) return;
514
- const OLD = /^autopilot-(\d{4}-\d{2}-\d{2})\.md$/;
515
- for (const name of fs.readdirSync(dir)) {
516
- const m = OLD.exec(name);
517
- if (!m) continue;
518
- const from = path.join(dir, name);
519
- const text = fs.readFileSync(from, "utf8");
520
- const artifact = /^artifact:\s*(\S.*)$/m.exec(text)?.[1]?.trim();
521
- const id = artifact && /(DEC-\d+)/.exec(artifact)?.[1];
522
- if (!id) {
523
- note(`.control/memlog/${name} looks like a pre-0.6.2 autopilot ledger, but its \`artifact:\` does`);
524
- note(` not resolve to a DEC- id — rename it to autopilot-<the mandate's DEC- id>.md yourself`);
525
- continue;
526
- }
527
- const to = path.join(dir, `autopilot-${id}.md`);
528
- if (fs.existsSync(to)) {
529
- note(`BOTH .control/memlog/${name} and autopilot-${id}.md exist — neither was touched`);
530
- note(` the run's ledger is in one of them and I cannot tell which. Merge them, then delete the other`);
531
- continue;
532
- }
533
- mv(from, to);
534
- note(`renamed .control/memlog/${name} → autopilot-${id}.md (content unchanged)`);
535
- note(` \`mandate-accept\` looks for a mandate's ledger at this exact path`);
536
- }
537
- }
538
-
539
- // A mandate opened before 0.6.2 recorded `parked: []` under the OLD default — full authority, AD-N
540
- // contradictions included. 0.6.2 changed the DEFAULT for a NEW mandate to park `ad-n`, because
541
- // decision-guide.md says narrowing an invariant MUST NOT be softened further. A default only applies
542
- // at the moment a mandate is written, so an EXISTING accepted mandate keeps whatever it already says —
543
- // silently adding `ad-n` to it would be overwriting a value the owner already chose, which `update`
544
- // MUST NOT do to anything in the product's own registry. So this only ever WARNS, naming the mandate
545
- // and the one line that would close the gap, and leaves the decision to whoever reads the summary.
546
- function warnStaleMandates(target) {
547
- const file = path.join(target, ".control", "registry", "decisions.yaml");
548
- if (!fs.existsSync(file)) return;
549
- const text = fs.readFileSync(file, "utf8");
550
- const blocks = text.split(/\n(?=\s*-\s*id:\s*DEC-)/);
551
- for (const block of blocks) {
552
- if (!/type:\s*mandate/.test(block)) continue;
553
- if (!/status:\s*accepted/.test(block)) continue;
554
- const id = /id:\s*(DEC-\d+)/.exec(block)?.[1];
555
- const parkedLine = /parked:\s*(\[[^\]]*\]|.*)$/m.exec(block)?.[0] || "";
556
- const parkedBlockList = /parked:\s*\n((?:\s+-\s*\S.*\n?)*)/.exec(block)?.[1] || "";
557
- if (/ad-n/.test(parkedLine) || /ad-n/.test(parkedBlockList)) continue;
558
- note(`${id || "a mandate"} predates the \`ad-n\`-parked-by-default protection (0.6.2) — its \`parked\``);
559
- note(` list does not name it, so it still decides an AD-N contradiction on its own`);
560
- note(` add \`ad-n\` to its \`parked\` list in decisions.yaml yourself if you want the new default`);
561
- }
562
- }
563
-
564
- function migrateRegistryNames(target) {
565
- const reg = path.join(target, ".control", "registry");
566
- const from = path.join(reg, "waves.yaml");
567
- const to = path.join(reg, "specs.yaml");
568
- if (!fs.existsSync(from)) return false;
569
- if (fs.existsSync(to)) {
570
- note("BOTH .control/registry/waves.yaml and specs.yaml exist — neither was touched");
571
- note(" the plan is in one of them and I cannot tell which. Merge them yourself, then delete waves.yaml");
572
- return false;
573
- }
574
- mv(from, to);
575
- note("renamed .control/registry/waves.yaml → specs.yaml (content unchanged)");
576
- note(" the rows still say `W<N>` and `epics`/`stories`. Re-cut them through the wdi-build skill");
577
- return true;
578
- }
579
-
580
- // The requirement registry split into `goals.yaml` (the product's `BG`, written by `wdi-problem` at
581
- // G1) plus one `requirements-<slug>.yaml` per PRD (`CAP`, `FR`, `NFR`, `UJ`, written by
582
- // `wdi-product` at G2). One file, one writer, one gate. What a tool can do here is SEED `goals.yaml`;
583
- // what it MUST NOT do is move the rows.
584
- //
585
- // Splitting the rows needs one fact the registry has never recorded: which PRD an `FR` belongs to.
586
- // Before the split nothing wrote it down, and deriving it — FR → UC → ticket → spec → `prd:` — only
587
- // works for FRs that already have tickets. A guess would file a promise under the wrong initiative,
588
- // which is worse than leaving it where it is. So `requirements.yaml` is left ALONE and still read:
589
- // `validate.py` unions every requirement file it finds, so a half-split corpus stays green while its
590
- // owner cuts the rows through the skill that owns each one.
591
- function seedRequirementSplit(target) {
592
- const reg = path.join(target, ".control", "registry");
593
- if (!fs.existsSync(reg)) return false;
594
- const product = path.join(reg, "goals.yaml");
595
- if (fs.existsSync(product)) return false;
596
- const seed = path.join(SCAFFOLD, "registry", "goals.yaml");
597
- if (!fs.existsSync(seed)) return false;
598
- copyFile(seed, product);
599
- note("seeded .control/registry/goals.yaml");
600
- if (fs.existsSync(path.join(reg, "requirements.yaml"))) {
601
- note(" requirements.yaml was left exactly as it is, and is still read — nothing broke");
602
- note(" the wdi-upgrade skill moves `goals:` into goals.yaml and cuts `capabilities:`,");
603
- note(" `functional:`, `nonfunctional:`, and `journeys:` into requirements-<slug>.yaml per PRD.");
604
- note(" <slug> is the PRD's folder name under .what/_prd/");
605
- }
606
- return true;
607
- }
608
-
609
- function migrateToTwoFolders(target) {
610
- const c = path.join(target, ".constitution");
611
- if (!fs.existsSync(c)) return false; // a first install has nothing to migrate
612
- const at = (...p) => path.join(c, ...p);
613
- // The old layout is identified by `document/` at the ROOT — in the new layout that folder only ever
614
- // exists under `method/`. Checking a loose guide instead would misfire on a repo that added one.
615
- if (!fs.existsSync(at("document")) && !fs.existsSync(at("codebase"))
616
- && !fs.existsSync(at("constitution.md")) && !fs.existsSync(at("scripts"))) {
617
- return false;
618
- }
619
- note("pre-0.5.0 .constitution/ found — migrating to method/ + project/");
620
-
621
- // 1. The four Reference files go one level deeper. This MUST run before the kit is written, or the
622
- // kit's own why/ files land while the old copies still sit at method/ root.
623
- for (const name of OLD_WHY) {
624
- const from = at("method", `${name}.md`);
625
- if (fs.existsSync(from)) {
626
- mv(from, at("method", "why", `${name}.md`));
627
- note(` moved method/${name}.md → method/why/${name}.md`);
628
- }
629
- }
630
- // 2. and 3. whole folders
631
- for (const dir of ["document", "scripts"]) {
632
- if (fs.existsSync(at(dir)) && !fs.existsSync(at("method", dir))) {
633
- mv(at(dir), at("method", dir));
634
- note(` moved ${dir}/ → method/${dir}/`);
635
- }
636
- }
637
- // 4. the loose generic guides
638
- for (const name of OLD_ROOT_GUIDES) {
639
- const from = at(`${name}.md`);
640
- if (fs.existsSync(from)) {
641
- mv(from, at("method", `${name}.md`));
642
- note(` moved ${name}.md → method/${name}.md`);
643
- }
644
- }
645
- // 5. codebase/ was a product-owned room all along — it becomes flat files in the room that says so
646
- for (const name of OLD_CODEBASE) {
647
- const from = at("codebase", `${name}-guide.md`);
648
- if (fs.existsSync(from)) {
649
- mv(from, at("project", `codebase-${name}-guide.md`));
650
- note(` moved codebase/${name}-guide.md → project/codebase-${name}-guide.md`);
651
- }
652
- }
653
- if (fs.existsSync(at("codebase"))) {
654
- const left = fs.readdirSync(at("codebase"));
655
- if (!left.length) fs.rmdirSync(at("codebase"));
656
- else note(` codebase/ still holds ${left.join(", ")} — left in place, move them yourself`);
657
- }
658
- // 6. The product's constitution.md moves WHOLE into the room, so its Articles 1, 2, and 5 survive
659
- // exactly as written. The generic half then arrives fresh at method/constitution.md.
660
- let split = null;
661
- if (fs.existsSync(at("constitution.md")) && !fs.existsSync(at("project", "constitution.md"))) {
662
- mv(at("constitution.md"), at("project", "constitution.md"));
663
- note(" moved constitution.md → project/constitution.md");
664
- split = splitProductConstitution(at("project", "constitution.md"));
665
- if (split && split.cut.length) {
666
- note(` kept Articles ${split.kept.join(", ")}, removed ${split.cut.join(", ")} `
667
- + "(the method's — they arrive in method/constitution.md)");
668
- if (split.relinked) note(` repointed ${split.relinked} relative links one level up`);
669
- } else if (split === null) {
670
- note(" it does not carry `## Article N` headings, so it was moved but NOT split — yours to check");
671
- }
672
- }
673
- // Anything else loose at the root is a file this product ADDED. It is NOT moved: it may be routed
674
- // from AGENTS.md by its current path, and guessing a destination would break that silently.
675
- const stray = fs.existsSync(c)
676
- ? fs.readdirSync(c, { withFileTypes: true })
677
- .filter((e) => e.isFile() && e.name.endsWith(".md"))
678
- .map((e) => e.name)
679
- : [];
680
- if (stray.length) {
681
- note(` left at .constitution/ root, yours to place: ${stray.join(", ")}`);
682
- note(" a file you added belongs in project/ — but moving it would break any pointer that");
683
- note(" names its current path, so the choice is yours. repo-guide.md states the rule.");
684
- }
685
- return split;
686
- }
687
-
688
- function syncConstitution(target) {
689
- const kitConst = path.join(KIT, ".constitution");
690
- const destConst = path.join(target, ".constitution");
691
- fs.mkdirSync(destConst, { recursive: true });
692
- let written = 0;
693
- let skipped = 0;
694
- for (const file of walkFiles(kitConst)) {
695
- const rel = posixRel(kitConst, file);
696
- const dest = path.join(destConst, rel);
697
- // ONE rule for everything the product owns, because 0.5.0 put all of it in one folder. Before
698
- // that this loop had three branches — the mixed constitution.md kept whole, `codebase/` gated on
699
- // `status: Accepted` (which is what silently destroyed a half-written guide), and the room — and
700
- // the three disagreed about when a file was the product's. Seeded when absent, never written
701
- // again: the same rule as the language policy.
702
- // ONE file in the room is the package's and is refreshed like any method file: the room's own
703
- // README. It explains what the room is FOR and carries no product decision, so a stale copy does
704
- // not preserve anybody's work — it just misinforms. worship-presenter-web proved that: its copy
705
- // still pointed at `.constitution/codebase/*-guide.md`, a folder 0.5.0 deleted, and no update
706
- // would ever have corrected it while the file claimed in its own text to be "authored in the
707
- // package". Either the package writes it or it stops claiming authorship; this is the first.
708
- if (rel === `${PROJECT_ROOM}README.md`) {
709
- copyFile(file, dest);
710
- written += 1;
711
- continue;
712
- }
713
- if (rel.startsWith(PROJECT_ROOM) && fs.existsSync(dest)) {
714
- skipped += 1;
715
- note(`keep ${rel} (yours — the project room)`);
716
- continue;
717
- }
718
- copyFile(file, dest);
719
- written += 1;
720
- }
721
- return { written, skipped };
722
- }
723
-
724
- function syncSkills(target, agents) {
725
- let n = 0;
726
- const dests = skillDestinations(target, agents);
727
- if (dests.length === 0) {
728
- note("no skill destinations for selected platforms — AGENTS.md still applies");
729
- return { files: 0, removed: 0 };
730
- }
731
- for (const name of WDI_SKILLS) {
732
- const src = path.join(KIT, "skills", name);
733
- if (!fs.existsSync(src)) die(`kit missing skill ${name}`);
734
- for (const root of dests) {
735
- const dest = path.join(root, name);
736
- fs.rmSync(dest, { recursive: true, force: true });
737
- n += copyTree(src, dest);
738
- }
739
- }
740
- const removed = pruneRetiredSkills(dests);
741
- return { files: n, removed };
742
- }
743
-
744
- // A wrapper the method RETIRED is worse than a wrapper missing: the folder is still there, its
745
- // SKILL.md still reads like an instruction, and an agent will invoke it — while the guide it points
746
- // at is gone. Renaming five wrappers (wdi-apply, wdi-analysis, wdi-structure, …) left exactly that
747
- // in every repo installed before the rename, because update only ever touched the names it knows.
748
- //
749
- // `wdi-` is the method's namespace, so a `wdi-*` folder carrying a SKILL.md and not in WDI_SKILLS is
750
- // ours and retired. Each removal is PRINTED: silent deletion in someone else's repo is not a fix.
751
- function pruneRetiredSkills(dests) {
752
- let removed = 0;
753
- const keep = new Set(WDI_SKILLS);
754
- for (const root of dests) {
755
- if (!fs.existsSync(root)) continue;
756
- for (const entry of fs.readdirSync(root, { withFileTypes: true })) {
757
- if (!entry.isDirectory() || !entry.name.startsWith("wdi-") || keep.has(entry.name)) continue;
758
- const dir = path.join(root, entry.name);
759
- if (!fs.existsSync(path.join(dir, "SKILL.md"))) {
760
- note(`kept ${entry.name} (no SKILL.md — not one of ours)`);
761
- continue;
762
- }
763
- fs.rmSync(dir, { recursive: true, force: true });
764
- note(`removed retired skill ${entry.name}`);
765
- removed += 1;
766
- }
767
- }
768
- return removed;
769
- }
770
-
771
- // `promote` scrubs a product's initiative slug out of bmad-prd.toml before publishing, which is right.
772
- // Writing the scrubbed PLACEHOLDER back into a product repo is not: the first real install replaced a
773
- // live `run_folder_pattern = "some-real-slug"` with `FILL-initiative-slug`, and nothing said so. A value
774
- // the product already chose is not the installer's to overwrite — same rule as the custom room and the
775
- // language policy.
776
- const PLACEHOLDER_SLUG = "FILL-initiative-slug";
777
- const RUN_FOLDER_LINE = /^(\s*run_folder_pattern\s*=\s*)(".*?"|'.*?')/m;
778
-
779
- // The slug appears MORE THAN ONCE — bmad-prd.toml carries it in `run_folder_pattern` and again inside a
780
- // memlog path, and the file itself says the two lines MUST change together. The first version of this
781
- // function restored only the first line and so produced exactly the inconsistency that file forbids.
782
- // So: read the product's slug once, then put it back everywhere the placeholder appears.
783
- function keepProductSlug(incoming, existing) {
784
- const mineNow = existing.match(RUN_FOLDER_LINE);
785
- if (!mineNow) return null;
786
- const slug = mineNow[2].slice(1, -1);
787
- if (!slug || slug === PLACEHOLDER_SLUG) return null;
788
- if (!incoming.includes(PLACEHOLDER_SLUG)) return null;
789
- // Only where the slug is a VALUE: the quoted setting, and the memlog path built from it. A bare
790
- // mention inside a comment stays the placeholder — that sentence explains the pattern, and rewriting
791
- // it would turn a generic explanation into a statement about one initiative.
792
- return incoming
793
- .replaceAll(`"${PLACEHOLDER_SLUG}"`, `"${slug}"`)
794
- .replaceAll(`prd-${PLACEHOLDER_SLUG}`, `prd-${slug}`);
795
- }
796
-
797
- function syncTomls(target) {
798
- const src = path.join(KIT, "assets", "bmad-custom");
799
- const dest = path.join(target, "_bmad", "custom");
800
- fs.mkdirSync(dest, { recursive: true });
801
- let n = 0;
802
- let slugsKept = 0;
803
- for (const file of walkFiles(src)) {
804
- if (!file.endsWith(".toml") || file.endsWith(".user.toml")) continue;
805
- const to = path.join(dest, path.basename(file));
806
- if (fs.existsSync(to)) {
807
- const merged = keepProductSlug(fs.readFileSync(file, "utf8"), fs.readFileSync(to, "utf8"));
808
- if (merged !== null) {
809
- fs.writeFileSync(to, merged);
810
- note(`kept run_folder_pattern in ${path.basename(file)}`);
811
- slugsKept += 1;
812
- n += 1;
813
- continue;
814
- }
815
- }
816
- copyFile(file, to);
817
- n += 1;
818
- }
819
- return { files: n, slugsKept };
820
- }
821
-
822
- // The same argument pruneRetiredSkills makes, one folder over — with one difference that changes
823
- // the rule. `wdi-` is this method's namespace, so "a wdi-* folder not in WDI_SKILLS" is safely ours.
824
- // `_bmad/custom/` is NOT: a product may put its own override there, and `.user.toml` is the
825
- // product's half of every override by convention. So removal here is by an EXPLICIT list of files
826
- // this package once shipped and has now withdrawn — never by "absent from the kit".
827
- //
828
- // Why remove them at all: an override for a retired engine is worse than no override. It is still
829
- // installed and still read, and bmad-retrospective.toml instructs an agent to archive an `RTR-`
830
- // against a validator, V19, that no longer exists.
831
- const RETIRED_TOMLS = [
832
- "bmad-spec.toml", "bmad-build.toml", "bmad-build-auto.toml",
833
- "bmad-code-review.toml", "bmad-retrospective.toml",
834
- ];
835
-
836
- function pruneRetiredTomls(target) {
837
- const dir = path.join(target, "_bmad", "custom");
838
- if (!fs.existsSync(dir)) return 0;
839
- let removed = 0;
840
- for (const name of RETIRED_TOMLS) {
841
- const file = path.join(dir, name);
842
- if (!fs.existsSync(file)) continue;
843
- fs.rmSync(file);
844
- note(`removed retired override ${name}`);
845
- removed += 1;
846
- }
847
- return removed;
848
- }
849
-
850
- function seedControlIfMissing(target) {
851
- const control = path.join(target, ".control");
852
- if (fs.existsSync(control)) {
853
- note(".control/ already present — left untouched");
854
- return;
855
- }
856
- if (!fs.existsSync(SCAFFOLD)) die(`scaffold missing: ${SCAFFOLD}`);
857
- const n = copyTree(SCAFFOLD, control);
858
- ok(`seeded empty .control/ (${n} files)`);
859
- }
860
-
861
- // On a FIRST install these folders are the corpus taking shape. On an UPDATE their absence means
862
- // somebody removed them on purpose — `.work/` and `_bmad-output/prior-knowledge/` are exactly the two a
863
- // product retires once its migration is done, and one repo retired them through an applied decision.
864
- // Recreating them then is an installer overruling a decision it cannot read. Seed once, never resurrect.
865
- function seedEmptyLayers(target, { first }) {
866
- const always = [".what", path.join(".how", "_platform")];
867
- const firstOnly = [".work", path.join("_bmad-output", "prior-knowledge")];
868
- for (const rel of first ? [...always, ...firstOnly] : always) {
869
- const dest = path.join(target, rel);
870
- if (!fs.existsSync(dest)) {
871
- fs.mkdirSync(dest, { recursive: true });
872
- note(`created ${rel.replaceAll(path.sep, "/")}/`);
873
- }
874
- }
875
- if (!first) {
876
- for (const rel of firstOnly) {
877
- if (!fs.existsSync(path.join(target, rel))) {
878
- note(`left ${rel.replaceAll(path.sep, "/")}/ absent — a product retires it, not the installer`);
879
- }
880
- }
881
- }
882
- }
883
-
884
- function writeStamp(target) {
885
- const control = path.join(target, ".control");
886
- if (!fs.existsSync(control)) return;
887
- const stamp = [
888
- "# Written by wdi-method install/update. A trace, not a lockfile.",
889
- `wdi_method: ${PKG.version}`,
890
- `bmad_method: ${readBmadVersion(target) || '""'}`,
891
- `installed_at: ${today()}`,
892
- "",
893
- ].join("\n");
894
- fs.writeFileSync(path.join(control, "wdi-method.yaml"), stamp, "utf8");
895
- note("stamped .control/wdi-method.yaml");
896
- }
897
-
898
- function setProductIdentity(target, { name, client }) {
899
- if (!name || identityIsPlaceholder(name)) return;
900
- const file = path.join(target, ".control", "registry", "index.yaml");
901
- if (!fs.existsSync(file)) return;
902
- const next = writeProductIdentity(fs.readFileSync(file, "utf8"), {
903
- name,
904
- client: client ?? "",
905
- });
906
- fs.writeFileSync(file, next.endsWith("\n") ? next : `${next}\n`);
907
- note(`product.name = ${name}`);
908
- }
909
-
910
- // The document language belongs to the PRODUCT, so update MUST NOT overwrite it. It is written only
911
- // when absent — same as the custom room, and for the same reason: a setting somebody already chose
912
- // is not the installer's to change behind their back.
913
- function setLanguagePolicy(target, { docLanguage, docFilenameLanguage, chosen }) {
914
- const file = path.join(target, ".control", "registry", "index.yaml");
915
- if (!fs.existsSync(file)) return;
916
- const text = fs.readFileSync(file, "utf8");
917
- const existing = readLanguagePolicy(text);
918
- // `chosen` means somebody actually answered — in the TUI, or through an explicit flag. Only then
919
- // does the answer take effect. Without it the incoming value is just a default, and a default
920
- // MUST NOT overwrite a choice somebody already made.
921
- if (!chosen && existing.docLanguage && existing.docFilenameLanguage) {
922
- note(`kept policy.doc_language = ${existing.docLanguage}, ` +
923
- `doc_filename_language = ${existing.docFilenameLanguage}`);
924
- return;
925
- }
926
- const next = writeLanguagePolicy(text, {
927
- docLanguage: docLanguage || existing.docLanguage || DEFAULT_DOC_LANGUAGE,
928
- docFilenameLanguage:
929
- docFilenameLanguage || existing.docFilenameLanguage || DEFAULT_DOC_LANGUAGE,
930
- });
931
- fs.writeFileSync(file, next.endsWith("\n") ? next : `${next}\n`);
932
- const after = readLanguagePolicy(next);
933
- note(`policy.doc_language = ${after.docLanguage}, ` +
934
- `doc_filename_language = ${after.docFilenameLanguage}`);
935
- }
936
-
937
- // After `update`, some of the corpus can still be in the OLD shape — content the installer MUST NOT
938
- // move, because moving it takes a decision about meaning: which PRD an `FR` belongs to, whether a
939
- // sentence was an assumption or a constraint. The `wdi-upgrade` skill does that half. This only
940
- // DETECTS it, cheaply, so the summary can say how much is waiting and where.
941
- function pendingUpgrades(target) {
942
- const has = (...p) => fs.existsSync(path.join(target, ...p));
943
- const read = (...p) => (has(...p) ? fs.readFileSync(path.join(target, ...p), "utf8") : "");
944
- const anyIn = (dir, glob, re) => {
945
- const d = path.join(target, dir);
946
- if (!fs.existsSync(d)) return false;
947
- return fs.readdirSync(d).some((n) => {
948
- const f = path.join(d, n, glob);
949
- return fs.existsSync(f) && re.test(fs.readFileSync(f, "utf8"));
950
- });
951
- };
952
- const items = [];
953
- if (has(".control", "registry", "requirements.yaml")) items.push("requirements.yaml goals.yaml + requirements-<slug>.yaml");
954
- if (/^\s*-\s*id:\s*W\d+|^\s*(epics|stories):/m.test(read(".control", "registry", "specs.yaml"))) items.push("specs.yaml rows still W<n>/epics/stories (wdi-build re-cuts)");
955
- if (/^## (Executive Summary|Vision|Assumptions|Prerequisites)\s*$/m.test(read(".what", "_product-brief", "brief.md"))) items.push("brief.md in the 14-section shape");
956
- // Sections by NAME: the numbers moved between kits (Non-Goals was §7 in one, §5 in the next).
957
- if (anyIn(".what/_prd", "prd.md", /^## (\d+\.\s*)?(Document Purpose|Glossary|Non-Goals|Open Questions|Assumptions Index)\b|\*\*Proof of done:\*\*/m)) items.push("a prd.md in the 12-section shape, or with FR blocks");
958
- const whatDir = path.join(target, ".what");
959
- if (fs.existsSync(whatDir)) {
960
- for (const pc of fs.readdirSync(whatDir)) {
961
- if (pc.startsWith("_")) continue;
962
- const srs = read(".what", pc, `SRS-${pc}.md`);
963
- if (/^\|\s*UC-\d+\s*\|/m.test(srs)) { items.push("an SRS with a UC Catalogue table (now a pointer)"); break; }
964
- }
965
- }
966
- const howDir = path.join(target, ".how");
967
- if (fs.existsSync(howDir)) {
968
- for (const pc of fs.readdirSync(howDir)) {
969
- if (pc.startsWith("_")) continue;
970
- if (/\|\s*Quoted rule\s*\||Quoted verbatim from/.test(read(".how", pc, `SDD-${pc}.md`))) { items.push("an SDD quoting AD-N text (now ids only)"); break; }
971
- }
972
- }
973
- if (/\|\s*Container\s*\|\s*Product Components living in it\s*\|/.test(read(".how", "_platform", "c4-l2-containers.md"))) items.push("c4-l2 with a PC x container table (now a pointer)");
974
- if (has(".control", "generated", "brief.md") || has(".control", "generated", "blueprint.md")) items.push("human pages still in .control/generated/ (render clears them)");
975
- if (has(".what", "_product-brief", "brief.md") && !has(".what-rendered")) items.push("no .what-rendered/ yet (render creates it)");
976
- // Skipped: what the validator never reads (kit copies, rendered output, dependencies) and what it
977
- // treats as a record of the PAST — memlog, decisions, reports, _bmad-output. A stale path in a log
978
- // is history, not a finding, and repointing it would falsify the record.
979
- const SKIP = new Set([".git", "node_modules", "target", ".constitution", ".claude", ".agents", ".agent",
980
- ".what-rendered", ".how-rendered", "dist", "build", "memlog", "decisions", "reports", "meetings", "_bmad-output", ".work"]);
981
- const OLD_PAGE = /\.control\/generated\/(brief|blueprint|prd-[a-z0-9-]+)\.md/;
982
- const citesOldPage = (dir, depth) => {
983
- if (depth > 8) return false;
984
- for (const e of fs.readdirSync(dir, { withFileTypes: true })) {
985
- if (e.isDirectory()) { if (!SKIP.has(e.name) && citesOldPage(path.join(dir, e.name), depth + 1)) return true; continue; }
986
- if (e.name === "answered.md") continue;
987
- if (e.name.endsWith(".md") && OLD_PAGE.test(fs.readFileSync(path.join(dir, e.name), "utf8"))) return true;
988
- }
989
- return false;
990
- };
991
- if (citesOldPage(target, 0)) items.push("a document cites .control/generated/brief|blueprint|prd-*.md (pages moved to the rendered trees)");
992
- return items;
993
- }
994
-
995
- // Read BEFORE writeStamp overwrites it. Without this there is no version transition to print, and
996
- // an "updated" with no from-to tells the reader nothing they can use.
997
- function readStampVersion(target) {
998
- const file = path.join(target, ".control", "wdi-method.yaml");
999
- if (!fs.existsSync(file)) return "";
1000
- const m = fs.readFileSync(file, "utf8").match(/^wdi_method:\s*"?([^"\s]+)"?/m);
1001
- return m ? m[1] : "";
1002
- }
1003
-
1004
- function readIndexPolicy(target) {
1005
- const file = path.join(target, ".control", "registry", "index.yaml");
1006
- if (!fs.existsSync(file)) return { docLanguage: "", docFilenameLanguage: "" };
1007
- return readLanguagePolicy(fs.readFileSync(file, "utf8"));
1008
- }
1009
-
1010
- function readIndexIdentity(target) {
1011
- const file = path.join(target, ".control", "registry", "index.yaml");
1012
- if (!fs.existsSync(file)) return { name: "", client: "" };
1013
- return readProductIdentity(fs.readFileSync(file, "utf8"));
1014
- }
1015
-
1016
- function upsertAgentFiles(target, platforms, productName) {
1017
- const template = fs.readFileSync(path.join(OVERLAY, "AGENTS.md"), "utf8");
1018
- const agentsFile = path.join(target, "AGENTS.md");
1019
- let next;
1020
- if (!fs.existsSync(agentsFile)) {
1021
- next = fillProductTitle(template, productName || "{product}");
1022
- ok("AGENTS.md created — rewrite ## Code for this product");
1023
- } else {
1024
- next = upsertMethodBlock(fs.readFileSync(agentsFile, "utf8"), template);
1025
- note("AGENTS.md method block refreshed; product sections kept");
1026
- }
1027
- if (!next.endsWith("\n")) next += "\n";
1028
- fs.writeFileSync(agentsFile, next);
1029
-
1030
- const mirrors = [];
1031
- if (platformUsesHook(platforms, "cursorrules")) {
1032
- mirrors.push(path.join(target, ".cursorrules"));
1033
- }
1034
- if (platformUsesHook(platforms, "agents-mirror")) {
1035
- mirrors.push(path.join(target, ".agents", "AGENTS.md"));
1036
- }
1037
- for (const mirror of mirrors) {
1038
- fs.mkdirSync(path.dirname(mirror), { recursive: true });
1039
- if (fs.existsSync(mirror)) {
1040
- const patched = upsertMethodBlock(fs.readFileSync(mirror, "utf8"), template);
1041
- fs.writeFileSync(mirror, patched.endsWith("\n") ? patched : `${patched}\n`);
1042
- note(`method block refreshed in ${posixRel(target, mirror)}`);
1043
- } else {
1044
- fs.writeFileSync(mirror, next);
1045
- note(`created ${posixRel(target, mirror)}`);
1046
- }
1047
- }
1048
-
1049
- if (platformUsesHook(platforms, "claude-md")) {
1050
- const claude = path.join(target, "CLAUDE.md");
1051
- if (!fs.existsSync(claude)) {
1052
- fs.writeFileSync(claude, "@AGENTS.md\n");
1053
- note("CLAUDE.md created as @AGENTS.md");
1054
- }
1055
- }
1056
- }
1057
-
1058
- // What a run MUST leave a reader able to answer: which version replaced which, what was written, what
1059
- // was KEPT, and what to do next. The third is the one usually missing, and it is the one that decides
1060
- // whether somebody trusts running this over a repo they have already put work into.
1061
- function summaryLine(label, value) {
1062
- console.log(` ${DIM}${label.padEnd(11)}${RESET}${value}`);
1063
- }
1064
-
1065
- function printSummary(target, agents, { first, was, written, skipped, skills, tomls, opencodeCmds }) {
1066
- const now = PKG.version;
1067
- const version = first
1068
- ? `${now} — first install`
1069
- : was && was !== now
1070
- ? `${was} ${DIM}→${RESET} ${now}`
1071
- : `${now} ${DIM}(unchanged)${RESET}`;
1072
- const bmad = readBmadVersion(target);
1073
-
1074
- const kept = [];
1075
- if (skipped) kept.push(`${skipped} constitution file${skipped === 1 ? "" : "s"}`);
1076
- if (tomls.slugsKept) kept.push(`${tomls.slugsKept} initiative slug${tomls.slugsKept === 1 ? "" : "s"}`);
1077
- // On a first install the language was just CHOSEN, not kept — saying "kept" there reads as if the
1078
- // installer had found something it decided to leave alone, which is the opposite of what happened.
1079
- const policy = readIndexPolicy(target);
1080
- if (policy.docLanguage && !first) kept.push(`language (${policy.docLanguage})`);
1081
- if (fs.existsSync(path.join(target, ".constitution", "project"))) kept.push(".constitution/project/");
1082
-
1083
- console.log("");
1084
- console.log(`${DIM}────${RESET} WDI Method ${DIM}${"─".repeat(46)}${RESET}`);
1085
- summaryLine("version", version);
1086
- if (bmad) summaryLine("bmad", bmad);
1087
- summaryLine("target", target);
1088
- console.log("");
1089
- summaryLine("written", `${written} constitution · ${skills.files} skill files · ${tomls.files} bmad overrides`
1090
- + (opencodeCmds?.written ? ` · ${opencodeCmds.written} opencode commands` : ""));
1091
- if (kept.length) summaryLine("kept", kept.join(" · "));
1092
- const gone = [];
1093
- if (skills.removed) gone.push(`${skills.removed} retired wrapper${skills.removed === 1 ? "" : "s"}`);
1094
- if (tomls.removed) gone.push(`${tomls.removed} retired override${tomls.removed === 1 ? "" : "s"}`);
1095
- if (gone.length) summaryLine("removed", gone.join(" · "));
1096
- if (first && policy.docLanguage) {
1097
- summaryLine("language", `${policy.docLanguage} · filenames ${policy.docFilenameLanguage}`);
1098
- }
1099
- summaryLine("platforms", agents.join(", ") || "none");
1100
- console.log("");
1101
- // The readers are the one seeded file that does nothing until somebody writes it, and its
1102
- // silence is expensive: inventory.py refuses to run and the reason is a folder deep. One line
1103
- // here, only while it is still the skeleton, so it stops appearing once it is done.
1104
- if (readersAreSkeleton(target)) {
1105
- summaryLine("todo", `${DIM}.constitution/project/inventory-readers.py${RESET} is a skeleton — ` +
1106
- `run the ${INIT_SKILL} skill, intent ${DIM}readers${RESET}, ` +
1107
- `to write it for this repo's stack`);
1108
- }
1109
- summaryLine("engines", enginesPresent(target)
1110
- ? `to-spec · to-tickets · implement found (${ENGINES_PLUGIN})`
1111
- : `to-spec · to-tickets · implement — NOT found. G5 (wdi-build) and the Fast Path need them; G1–G4 run without them`);
1112
- if (!enginesPresent(target)) {
1113
- summaryLine("", `${DIM}·${RESET} Claude Code: ${DIM}${ENGINES_INSTALL}${RESET} — other agents: ${DIM}${ENGINES_INSTALL_ANY}${RESET}`);
1114
- summaryLine("", `${DIM}·${RESET} then ${DIM}${ENGINES_SETUP}${RESET} once, to name the tracker · ${ENGINES_REPO}`);
1115
- }
1116
- const pending = first ? [] : pendingUpgrades(target);
1117
- if (pending.length) {
1118
- summaryLine("upgrade", `${pending.length} item${pending.length === 1 ? "" : "s"} still in the OLD shape — ` +
1119
- `run the ${DIM}wdi-upgrade${RESET} skill; it moves content, never invents it`);
1120
- for (const item of pending) summaryLine("", `${DIM}·${RESET} ${item}`);
1121
- }
1122
- summaryLine("next", pending.length
1123
- ? `run the ${DIM}wdi-upgrade${RESET} skill first, then ${HELP_SKILL}`
1124
- : `invoke the ${HELP_SKILL} skill and ask what to do`);
1125
- summaryLine("", REPO_URL);
1126
- console.log(`${DIM}${"─".repeat(62)}${RESET}`);
1127
- }
1128
-
1129
- function printNextSteps({ first, productSet, upgradePending }) {
1130
- console.log("");
1131
- console.log(first ? "After install:" : "After update:");
1132
- if (first) {
1133
- if (!productSet) {
1134
- console.log(" 1. Fill product.name (and product.client if there is one) in .control/registry/index.yaml.");
1135
- } else {
1136
- console.log(" 1. product.name is set. G1 confirms it in the brief.");
1137
- }
1138
- console.log(" 2. Rewrite .constitution/constitution.md Articles 2 and 5 for this product.");
1139
- console.log(" Article 1 cites index.yaml do not become a second source for the name.");
1140
- console.log(" 3. Write ## Code in AGENTS.md (where the app lives). Leave the BEGIN:wdi-method block alone.");
1141
- console.log(" 4. Run the wdi-init skill, intent setup.");
1142
- console.log(" 5. Sort the documents you already have. Do not move any of them in this step.");
1143
- console.log("");
1144
- console.log("Next update:");
1145
- console.log(" npx wdi-method");
1146
- console.log(" (the TUI offers the update) or: npx wdi-method update --yes");
1147
- } else {
1148
- console.log(" 1. The <!-- BEGIN:wdi-method --> block in AGENTS.md was replaced. Read the diff.");
1149
- console.log(" 2. constitution.md Articles 1-2-5, ## Code, and *.user.toml were not overwritten.");
1150
- console.log(" 3. If BMad has new skills, install those first, then run this update again.");
1151
- if (upgradePending) {
1152
- console.log(" 4. The summary listed an `upgrade` line: run the wdi-upgrade skill before any other skill.");
1153
- console.log(" It moves content into the new shape and never invents any; one commit.");
1154
- }
1155
- }
1156
- }
1157
-
1158
- function apply(target, agents,
1159
- { first, product, client, docLanguage, docFilenameLanguage, languageChosen }) {
1160
- requireKit();
1161
- const was = readStampVersion(target);
1162
- // MUST run before the kit is written: it moves the product's files out of the way of paths the kit
1163
- // is about to occupy. Running it after would leave two copies of most guides.
1164
- const migrated = migrateToTwoFolders(target);
1165
- migrateRegistryNames(target);
1166
- migrateAutopilotLedgers(target);
1167
- warnStaleMandates(target);
1168
- seedAgentDocs(target);
1169
- warnStaleAgentDocs(target);
1170
- seedRequirementSplit(target);
1171
- // The split MUST also be reachable without a migration. 0.5.2 only ran it from inside
1172
- // migrateToTwoFolders, which returns early when the old layout is absent — so a repo that took
1173
- // 0.5.0 or 0.5.1, whose project/constitution.md was moved WHOLE and never split, could never be
1174
- // fixed by any later update. That is precisely the repo that needs it. Running it here on every
1175
- // update closes that, and it is idempotent: after a split there are no method articles left to cut.
1176
- const lateSplit = splitProductConstitution(path.join(target, ".constitution", "project",
1177
- "constitution.md"));
1178
- if (!migrated && lateSplit && lateSplit.cut.length) {
1179
- note(`project/constitution.md still carried Articles ${lateSplit.cut.join(", ")} removed`);
1180
- note(` they are the method's and live in method/constitution.md; kept ${lateSplit.kept.join(", ")}`);
1181
- if (lateSplit.relinked) note(` repointed ${lateSplit.relinked} relative links`);
1182
- }
1183
- const splitConstitution = migrated;
1184
- const { written, skipped } = syncConstitution(target);
1185
- note(`constitution wrote ${written}, kept ${skipped}`);
1186
- // A migrated repo also carries derived output stamped against the OLD layout: .control/generated/*
1187
- // still names the pre-0.5.0 script path, and the two structure maps still draw the old tree. The
1188
- // installer MUST NOT write either — one is generated, the other is re-derived by a skill — so it
1189
- // says so instead of leaving them to be found by whoever trusts them next.
1190
- if (splitConstitution) {
1191
- note(" derived output still describes the OLD layout, and neither is mine to write:");
1192
- note(" uv run .constitution/method/scripts/validate.py --generate → .control/generated/");
1193
- note(" then the wdi-init skill, intent `structure` → the two structure maps");
1194
- }
1195
- const skills = syncSkills(target, agents);
1196
- note(`skills ${skills.files} files`);
1197
- let opencodeCmds = { written: 0, removed: 0 };
1198
- if (platformUsesHook(agents, "opencode-commands")) {
1199
- opencodeCmds = syncOpencodeCommands(target, WDI_SKILLS, path.join(KIT, "skills"));
1200
- note(`opencode commands ${opencodeCmds.written} files → ${opencodeCommandsDir()}/`);
1201
- if (opencodeCmds.removed) {
1202
- note(`removed ${opencodeCmds.removed} retired opencode command${opencodeCmds.removed === 1 ? "" : "s"}`);
1203
- }
1204
- }
1205
- const tomls = syncTomls(target);
1206
- tomls.removed = pruneRetiredTomls(target);
1207
- note(`bmad custom ${tomls.files} toml _bmad/custom/`);
1208
- if (first) seedControlIfMissing(target);
1209
- seedEmptyLayers(target, { first });
1210
- setProductIdentity(target, { name: product, client });
1211
- setLanguagePolicy(target, { docLanguage, docFilenameLanguage, chosen: languageChosen });
1212
- upsertAgentFiles(target, agents, product);
1213
- writeStamp(target);
1214
- printSummary(target, agents, { first, was, written, skipped, skills, tomls, opencodeCmds });
1215
- printNextSteps({
1216
- first,
1217
- productSet: Boolean(product) && !identityIsPlaceholder(product),
1218
- upgradePending: !first && pendingUpgrades(target).length > 0,
1219
- });
1220
- }
1221
-
1222
- function verify(target, agents) {
1223
- requireKit();
1224
- const missing = [];
1225
- const kitConst = path.join(KIT, ".constitution");
1226
- for (const file of walkFiles(kitConst)) {
1227
- const rel = posixRel(kitConst, file);
1228
- const dest = path.join(target, ".constitution", rel);
1229
- if (!fs.existsSync(dest)) missing.push(`.constitution/${rel}`);
1230
- }
1231
- for (const name of WDI_SKILLS) {
1232
- for (const root of skillDestinations(target, agents)) {
1233
- const dest = path.join(root, name, "SKILL.md");
1234
- if (!fs.existsSync(dest)) missing.push(posixRel(target, dest));
1235
- }
1236
- }
1237
- if (platformUsesHook(agents, "opencode-commands")) {
1238
- for (const name of WDI_SKILLS) {
1239
- const dest = path.join(target, opencodeCommandsDir(), `${name}.md`);
1240
- if (!fs.existsSync(dest)) missing.push(posixRel(target, dest));
1241
- }
1242
- }
1243
- const custom = path.join(KIT, "assets", "bmad-custom");
1244
- for (const file of walkFiles(custom)) {
1245
- if (!file.endsWith(".toml")) continue;
1246
- const dest = path.join(target, "_bmad", "custom", path.basename(file));
1247
- if (!fs.existsSync(dest)) missing.push(`_bmad/custom/${path.basename(file)}`);
1248
- }
1249
- if (fs.existsSync(path.join(target, ".control"))) {
1250
- for (const file of walkFiles(SCAFFOLD)) {
1251
- const rel = posixRel(SCAFFOLD, file);
1252
- const dest = path.join(target, ".control", rel);
1253
- if (!fs.existsSync(dest)) missing.push(`.control/${rel}`);
1254
- }
1255
- } else {
1256
- missing.push(".control/ (folder missing first install should have seeded it)");
1257
- }
1258
- // `.constitution/constitution.md` was the pre-0.5.0 path. Demanding it here made `verify` report a
1259
- // file MISSING that the split deliberately removed — a check telling the truth about the wrong world.
1260
- for (const required of ["AGENTS.md", path.join(".constitution", "project", "constitution.md")]) {
1261
- if (!fs.existsSync(path.join(target, required))) missing.push(required.replaceAll(path.sep, "/"));
1262
- }
1263
- if (missing.length) {
1264
- console.error(`${RED}missing ${missing.length}${RESET}`);
1265
- for (const m of missing) console.error(` ${m}`);
1266
- process.exit(1);
1267
- }
1268
- ok(`method files present in ${target}`);
1269
-
1270
- // Present-and-correct is not the same as consistent. These three are states `update` cannot fix on
1271
- // its own — it MUST NOT write over the room, and it cannot know what a product meant — so `verify`
1272
- // is where they get said out loud instead of waiting to be tripped over.
1273
- const judgement = [];
1274
- const room = path.join(target, ".constitution", "project", "constitution.md");
1275
- if (fs.existsSync(room)) {
1276
- const carried = [...fs.readFileSync(room, "utf8").matchAll(/^## Article (\d+)\b/gm)]
1277
- .map((m) => Number(m[1])).filter((n) => METHOD_ARTICLES.includes(n));
1278
- if (carried.length) {
1279
- judgement.push(`project/constitution.md still carries Articles ${carried.join(", ")} — the `
1280
- + "method's. They are duplicated in method/constitution.md and will drift. Run update again.");
1281
- }
1282
- }
1283
- const constRoot = path.join(target, ".constitution");
1284
- const loose = fs.existsSync(constRoot)
1285
- ? fs.readdirSync(constRoot, { withFileTypes: true })
1286
- .filter((e) => e.isFile() && e.name.endsWith(".md")).map((e) => e.name)
1287
- : [];
1288
- if (loose.length) {
1289
- judgement.push(`loose at .constitution/ root: ${loose.join(", ")} — .constitution/ holds two `
1290
- + "folders and nothing else the method knows about. Move it into project/, or name it from "
1291
- + "Article 2 so the next reader knows why it is there. repo-guide.md states the rule.");
1292
- }
1293
- if (judgement.length) {
1294
- console.log("");
1295
- for (const j of judgement) note(j);
1296
- }
1297
- note("extra product files are expected and were not checked");
1298
- }
1299
-
1300
- function scrubPrdToml(file) {
1301
- const raw = fs.readFileSync(file, "utf8");
1302
- const m = raw.match(/run_folder_pattern\s*=\s*"([^"]+)"/);
1303
- if (!m) return;
1304
- const slug = m[1];
1305
- if (GENERIC_FOLDER_PATTERNS.has(slug)) return;
1306
- fs.writeFileSync(file, raw.split(slug).join(PRD_SLUG_PLACEHOLDER), "utf8");
1307
- note("bmad-prd.toml initiative slug scrubbed to placeholder");
1308
- }
1309
-
1310
- function promote(live) {
1311
- live = path.resolve(live);
1312
- if (!fs.existsSync(path.join(live, ".constitution"))) {
1313
- die(`${live} has no .constitution/ — is this a method-carrying repo?`);
1314
- }
1315
- // EVERY file in the room is authored in the package and MUST survive the rmSync below — the room's
1316
- // README, the generic Articles 1-2-5, and the three empty codebase templates. Read here, not
1317
- // after: the first version of this preserved only README.md and read it AFTER the kit was deleted,
1318
- // so it was always null and the file vanished on every promote. Two tests cover it now.
1319
- const roomKit = path.join(KIT, ".constitution", PROJECT_ROOM);
1320
- const roomKept = fs.existsSync(roomKit)
1321
- ? Object.fromEntries(walkFiles(roomKit).map((f) => [posixRel(roomKit, f), fs.readFileSync(f, "utf8")]))
1322
- : {};
1323
-
1324
- fs.rmSync(KIT, { recursive: true, force: true });
1325
- fs.mkdirSync(KIT, { recursive: true });
1326
-
1327
- // ONE skip, because 0.5.0 put everything the product owns in one folder. It covers the codebase
1328
- // guides too, which used to need a rule of their own: promoting a filled-in stack guide would leak
1329
- // one product's conventions possibly written in its own `doc_language` — into a public package.
1330
- const nConst = copyTree(path.join(live, ".constitution"), path.join(KIT, ".constitution"),
1331
- (rel) => rel.startsWith(PROJECT_ROOM));
1332
- note(`constitution ${nConst} files (${PROJECT_ROOM} skipped it is the product's)`);
1333
- for (const [rel, text] of Object.entries(roomKept)) {
1334
- const dest = path.join(roomKit, rel);
1335
- fs.mkdirSync(path.dirname(dest), { recursive: true });
1336
- fs.writeFileSync(dest, text, "utf8");
1337
- }
1338
- if (Object.keys(roomKept).length) {
1339
- note(`${PROJECT_ROOM} restored from the package (${Object.keys(roomKept).length} files) — `
1340
- + "promote never carries the room home");
1341
- }
1342
-
1343
- let copiedSkills = 0;
1344
- const skillsSrc = path.join(live, ".claude", "skills");
1345
- for (const name of WDI_SKILLS) {
1346
- const src = path.join(skillsSrc, name);
1347
- if (!fs.existsSync(src)) die(`skill missing in live repo: ${src}`);
1348
- copiedSkills += copyTree(src, path.join(KIT, "skills", name));
1349
- }
1350
- note(`skills ${copiedSkills} files (${WDI_SKILLS.length} wrappers)`);
1351
-
1352
- const customSrc = path.join(live, "_bmad", "custom");
1353
- const customDst = path.join(KIT, "assets", "bmad-custom");
1354
- fs.mkdirSync(customDst, { recursive: true });
1355
- let tomls = 0;
1356
- if (fs.existsSync(customSrc)) {
1357
- for (const file of walkFiles(customSrc)) {
1358
- if (!file.endsWith(".toml") || file.endsWith(".user.toml")) continue;
1359
- copyFile(file, path.join(customDst, path.basename(file)));
1360
- tomls += 1;
1361
- }
1362
- }
1363
- const prd = path.join(customDst, "bmad-prd.toml");
1364
- if (fs.existsSync(prd)) scrubPrdToml(prd);
1365
- note(`bmad custom ${tomls} toml`);
1366
-
1367
- const replacements = {
1368
- "constitution.md": path.join(KIT, ".constitution", "method", "constitution.md"),
1369
- "portability.md": path.join(KIT, ".constitution", "method", "why", "portability.md"),
1370
- "repo-guide.md": path.join(KIT, ".constitution", "method", "repo-guide.md"),
1371
- "README.md": path.join(KIT, ".constitution", "method", "README.md"),
1372
- };
1373
- for (const [name, dest] of Object.entries(replacements)) {
1374
- const src = path.join(OVERLAY, name);
1375
- if (fs.existsSync(src)) {
1376
- copyFile(src, dest);
1377
- note(`${name} replaced with kit overlay`);
1378
- }
1379
- }
1380
-
1381
- const source = [
1382
- `date: ${today()}`,
1383
- `commit: ${gitHead(live)}`,
1384
- "kind: working copy that currently carries a newer method",
1385
- "note: the repo path and product name MUST NOT be recorded here",
1386
- "",
1387
- ].join("\n");
1388
- fs.writeFileSync(path.join(ROOT, "SOURCE"), source, "utf8");
1389
- ok(`SOURCE stamped ${today()} @ ${gitHead(live)}`);
1390
- ok(`promoted into ${KIT}`);
1391
- }
1392
-
1393
- function cancelIf(value) {
1394
- if (p.isCancel(value)) {
1395
- p.cancel("Cancelled.");
1396
- process.exit(0);
1397
- }
1398
- return value;
1399
- }
1400
-
1401
- async function runWizard(pre) {
1402
- p.intro(`WDI Method ${PKG.version}`);
1403
-
1404
- const dirValue = cancelIf(
1405
- await p.text({
1406
- message: "Target repo (the product folder)",
1407
- placeholder: process.cwd(),
1408
- defaultValue: pre.dir || process.cwd(),
1409
- }),
1410
- );
1411
- const target = path.resolve(String(dirValue).trim() || process.cwd());
1412
-
1413
- if (!fs.existsSync(target)) {
1414
- const create = cancelIf(
1415
- await p.confirm({ message: `${target} does not exist. Create it?`, initialValue: true }),
1416
- );
1417
- if (!create) {
1418
- p.cancel("No target folder.");
1419
- process.exit(1);
1420
- }
1421
- fs.mkdirSync(target, { recursive: true });
1422
- }
1423
-
1424
- const hasBmad = bmadPresent(target);
1425
- const hasWdi = wdiPresent(target);
1426
- const nonempty = dirNonEmpty(target);
1427
-
1428
- const facts = [
1429
- hasBmad
1430
- ? `BMad Method: installed${readBmadVersion(target) ? ` (${readBmadVersion(target)})` : ""}`
1431
- : "BMad Method: not installed",
1432
- hasWdi ? "WDI Method: already present — the installer will offer an update" : "WDI Method: not present",
1433
- enginesPresent(target)
1434
- ? "Ticket engines (mattpocock-skills): installed"
1435
- : `Ticket engines (mattpocock-skills): not found — needed at G5 only; ${ENGINES_INSTALL} (${ENGINES_REPO})`,
1436
- nonempty ? "Folder is not empty (normal for a product repo already under way)" : "Folder is empty",
1437
- ].join("\n");
1438
- p.note(facts, "Detected");
1439
-
1440
- if (!hasBmad && !pre.skipBmad) {
1441
- p.note(bmadMissingMessage(), "BMad first");
1442
- p.outro("Install BMad, then run this again: npx wdi-method");
1443
- process.exit(1);
1444
- }
1445
-
1446
- let first = !hasWdi;
1447
- if (hasWdi) {
1448
- const update = cancelIf(
1449
- await p.confirm({
1450
- message: "WDI Method is already installed. Update it now?",
1451
- initialValue: true,
1452
- }),
1453
- );
1454
- first = !update;
1455
- if (first) {
1456
- p.cancel("Update declined.");
1457
- process.exit(0);
1458
- }
1459
- } else {
1460
- const go = cancelIf(
1461
- await p.confirm({
1462
- message: `Install WDI Method into ${target}?`,
1463
- initialValue: true,
1464
- }),
1465
- );
1466
- if (!go) {
1467
- p.cancel("Install declined.");
1468
- process.exit(0);
1469
- }
1470
- }
1471
-
1472
- // Every field arrives with an answer already in it, and Enter accepts it. On an update that answer is
1473
- // what the repo already says; on a first install it is the folder name made readable. Nothing here is
1474
- // validated as required: a prompt that refuses an empty submission when it already holds a sensible
1475
- // default is asking the owner to retype something the installer knows.
1476
- const existing = readIndexIdentity(target);
1477
- const suggestedName = identityIsPlaceholder(existing.name)
1478
- ? humaniseFolderName(path.basename(target))
1479
- : existing.name;
1480
- const product = cancelIf(
1481
- await p.text({
1482
- message: "Product name (one room: index.yaml product.name)",
1483
- placeholder: suggestedName,
1484
- defaultValue: suggestedName,
1485
- }),
1486
- ).trim() || suggestedName;
1487
- const client = cancelIf(
1488
- await p.text({
1489
- message: "Client name (Enter to leave it as it is)",
1490
- placeholder: existing.client || "(none)",
1491
- defaultValue: existing.client || "",
1492
- }),
1493
- ).trim();
1494
-
1495
- // Two questions, and only two. Method terminology, document code prefixes, machine-facing
1496
- // markers, and code identifiers are always English — MUST NOT be asked about.
1497
- const policy = readIndexPolicy(target);
1498
- // Free text, not a list. Write whatever a model understands — "English", "Bahasa Indonesia",
1499
- // "id". The only value refused is empty.
1500
- const askLanguage = async (message, current) =>
1501
- (cancelIf(
1502
- await p.text({
1503
- message,
1504
- placeholder: current || DEFAULT_DOC_LANGUAGE,
1505
- defaultValue: current || DEFAULT_DOC_LANGUAGE,
1506
- }),
1507
- ) || DEFAULT_DOC_LANGUAGE).trim();
1508
- const docLanguage = await askLanguage(
1509
- "Language of working-document prose (.what/ .how/ .control/) — free text",
1510
- policy.docLanguage || pre.docLanguage);
1511
- const docFilenameLanguage = await askLanguage(
1512
- "Language of document filename slugs — the `UC-` `DEC-` codes stay English",
1513
- policy.docFilenameLanguage || pre.docFilenameLanguage || docLanguage);
1514
-
1515
- const detected = pre.agents
1516
- ? normalizePlatformIds(pre.agents)
1517
- : detectPlatforms(target, fs);
1518
- const selected = cancelIf(
1519
- await p.autocompleteMultiselect({
1520
- message: "Which tools get the wdi-* skills? (⭐ = recommended)",
1521
- options: platformSelectOptions(detected),
1522
- initialValues: detected,
1523
- required: true,
1524
- maxItems: 8,
1525
- placeholder: "Type to search…",
1526
- }),
1527
- );
1528
-
1529
- p.note(
1530
- [
1531
- "The corpus folder names are fixed — they are not an install option:",
1532
- " .constitution .control .what .how .work _bmad-output",
1533
- "",
1534
- "What gets written for the platforms you picked:",
1535
- " AGENTS.md (the BEGIN:wdi-method block — always)",
1536
- platformUsesHook(selected, "claude-md") ? " CLAUDE.md → @AGENTS.md" : "",
1537
- platformUsesHook(selected, "cursorrules") ? " .cursorrules (method block mirror)" : "",
1538
- platformUsesHook(selected, "agents-mirror") ? " .agents/AGENTS.md (method block mirror)" : "",
1539
- platformUsesHook(selected, "opencode-commands")
1540
- ? ` ${opencodeCommandsDir()}/wdi-*.md (slash commands skills)`
1541
- : "",
1542
- ` wdi-* skills → ${skillDestinations(target, selected).map((d) => posixRel(target, d)).join(", ") || "(none)"}`,
1543
- ]
1544
- .filter(Boolean)
1545
- .join("\n"),
1546
- "Write targets",
1547
- );
1548
-
1549
- const okGo = cancelIf(await p.confirm({ message: first ? "Run the install?" : "Run the update?", initialValue: true }));
1550
- if (!okGo) {
1551
- p.cancel("Dibatalkan.");
1552
- process.exit(0);
1553
- }
1554
-
1555
- const spinner = p.spinner();
1556
- spinner.start(first ? "Memasang…" : "Meng-update…");
1557
- apply(target, selected, {
1558
- docLanguage,
1559
- docFilenameLanguage,
1560
- languageChosen: true,
1561
- first,
1562
- product: String(product).trim(),
1563
- client: String(client).trim(),
1564
- });
1565
- spinner.stop(first ? "Terpasang" : "Ter-update");
1566
- p.outro(first ? "Done. Take the after-install steps above." : "Done. Read the method-block diff in AGENTS.md.");
1567
- }
1568
-
1569
- function runNonInteractive(args) {
1570
- const target = requireTarget(args.dir);
1571
- const agents = args.agents || detectPlatforms(target, fs) || PREFERRED_PLATFORM_IDS.slice();
1572
- if (args.cmd === "verify") {
1573
- verify(target, agents);
1574
- return;
1575
- }
1576
- if (!args.skipBmad && !bmadPresent(target)) {
1577
- die(bmadMissingMessage());
1578
- }
1579
- if (!args.skipEngines && !enginesPresent(target)) {
1580
- die(enginesMissingMessage());
1581
- }
1582
- const existing = readIndexIdentity(target);
1583
- const product = args.product || existing.name;
1584
- const client = args.client ?? existing.client;
1585
- const first = args.cmd === "install" || (args.cmd === "wizard" && !wdiPresent(target));
1586
- apply(target, agents, {
1587
- first: args.cmd === "update" ? false : first,
1588
- product,
1589
- client,
1590
- docLanguage: args.docLanguage,
1591
- docFilenameLanguage: args.docFilenameLanguage,
1592
- languageChosen: Boolean(args.docLanguage || args.docFilenameLanguage),
1593
- });
1594
- }
1595
-
1596
- async function main() {
1597
- const args = parseArgs(process.argv);
1598
- if (!["wizard", "install", "update", "verify", "promote"].includes(args.cmd)) {
1599
- usage();
1600
- process.exit(2);
1601
- }
1602
- if (args.cmd === "promote") {
1603
- if (!args.dir) die("promote needs a path to the working copy");
1604
- // `promote` used to BE the workflow: author a rule in a product repo, run it, carry it here.
1605
- // It is now a rescue tool, and the flag is what makes that structural rather than a paragraph
1606
- // nobody rereads. Running it by habit overwrites the whole kit with one consumer's copy —
1607
- // silently reverting every change made here since that repo last updated.
1608
- if (!args.rescue) {
1609
- die([
1610
- "promote overwrites the whole kit from a consumer's copy, and this package is now where a",
1611
- " method change is authored see CONTRIBUTING.md. If a change really was made in a",
1612
- " product repo by mistake and needs rescuing, say so:",
1613
- "",
1614
- " npx wdi-method promote <dir> --rescue",
1615
- ].join("\n"));
1616
- }
1617
- note("--rescue: pulling the method back out of a consumer. Read the diff before committing.");
1618
- promote(args.dir);
1619
- return;
1620
- }
1621
- const wantTui = !args.yes && args.cmd !== "verify" && process.stdin.isTTY && process.stdout.isTTY;
1622
- if (wantTui) {
1623
- await runWizard(args);
1624
- return;
1625
- }
1626
- if (args.cmd === "wizard" && !args.yes) {
1627
- die("not a TTY. Use `install --yes` / `update --yes`, or run this in a terminal.");
1628
- }
1629
- if (args.cmd === "wizard") args.cmd = wdiPresent(requireTarget(args.dir)) ? "update" : "install";
1630
- runNonInteractive(args);
1631
- }
1632
-
1633
- main().catch((err) => {
1634
- console.error(err);
1635
- process.exit(1);
1636
- });
1
+ #!/usr/bin/env node
2
+ import fs from "node:fs";
3
+ import os from "node:os";
4
+ import path from "node:path";
5
+ import { spawnSync } from "node:child_process";
6
+ import { fileURLToPath } from "node:url";
7
+ import * as p from "@clack/prompts";
8
+ import {
9
+ fillProductTitle,
10
+ upsertMethodBlock,
11
+ } from "../lib/agents-block.mjs";
12
+ import {
13
+ identityIsPlaceholder,
14
+ humaniseFolderName,
15
+ readLanguagePolicy,
16
+ writeLanguagePolicy,
17
+ DEFAULT_DOC_LANGUAGE,
18
+ readProductIdentity,
19
+ writeProductIdentity,
20
+ } from "../lib/identity.mjs";
21
+ import {
22
+ detectPlatforms,
23
+ formatPlatformList,
24
+ isKnownPlatform,
25
+ normalizePlatformIds,
26
+ platformSelectOptions,
27
+ platformUsesHook,
28
+ PREFERRED_PLATFORM_IDS,
29
+ skillDestinations,
30
+ } from "../lib/platforms.mjs";
31
+ import { opencodeCommandsDir, syncOpencodeCommands } from "../lib/opencode-commands.mjs";
32
+
33
+ const ROOT = path.resolve(path.dirname(fileURLToPath(import.meta.url)), "..");
34
+ const KIT = path.join(ROOT, "kit");
35
+ const OVERLAY = path.join(ROOT, "kit-overlay");
36
+ const SCAFFOLD = path.join(ROOT, "scaffold", ".control");
37
+ const PKG = JSON.parse(fs.readFileSync(path.join(ROOT, "package.json"), "utf8"));
38
+
39
+ const WDI_SKILLS = [
40
+ "wdi-init",
41
+ "wdi-problem",
42
+ "wdi-product",
43
+ "wdi-ux",
44
+ "wdi-blueprint",
45
+ "wdi-component",
46
+ "wdi-build",
47
+ "wdi-decision",
48
+ "wdi-question",
49
+ "wdi-log",
50
+ "wdi-help",
51
+ "wdi-explain-to-me",
52
+ "wdi-autopilot",
53
+ "wdi-reconcile",
54
+ "wdi-review",
55
+ "wdi-report",
56
+ "wdi-systematic-debugging",
57
+ "wdi-upgrade",
58
+ ];
59
+
60
+ const PRD_SLUG_PLACEHOLDER = "FILL-initiative-slug";
61
+ const GENERIC_FOLDER_PATTERNS = new Set([
62
+ "_product-brief",
63
+ "ux",
64
+ "architecture",
65
+ PRD_SLUG_PLACEHOLDER,
66
+ ]);
67
+
68
+ const BMAD_INSTALL = `npx bmad-method install`;
69
+ // The ticket engines G5 runs. BMad writes the documents; these cut the work. They are a Claude Code
70
+ // plugin installed per USER, not per repo, so the check reads the plugin registry — and the check
71
+ // warns instead of blocking, because G1–G4 run without them and a first install has no G5 yet.
72
+ const ENGINES_REPO = "https://github.com/mattpocock/skills";
73
+ const ENGINES_PLUGIN = "mattpocock-skills";
74
+ const ENGINES_INSTALL = `/plugin install ${ENGINES_PLUGIN}`;
75
+ const ENGINES_INSTALL_ANY = "npx skills@latest add mattpocock/skills";
76
+ const ENGINES_SETUP = "/setup-matt-pocock-skills";
77
+ const REPO_URL = "https://github.com/wiradigitalid/wdi-method";
78
+ const HELP_SKILL = "wdi-help";
79
+ const INIT_SKILL = "wdi-init";
80
+ // The room's readers file is seeded as a skeleton and is useless until a product writes it. The
81
+ // flag is the skeleton's own declaration, so this reads the same thing the engine does rather than
82
+ // guessing from the file's size or its age.
83
+ function readersAreSkeleton(target) {
84
+ const file = path.join(target, ".constitution", "project", "inventory-readers.py");
85
+ if (!fs.existsSync(file)) return false;
86
+ return /^SKELETON\s*=\s*True\b/m.test(fs.readFileSync(file, "utf8"));
87
+ }
88
+ const BMAD_REPO = "https://github.com/bmad-code-org/BMAD-METHOD";
89
+ const WDI_REPO = "https://github.com/wiradigitalid/wdi-method";
90
+
91
+ const RED = "\x1b[31m";
92
+ const GREEN = "\x1b[32m";
93
+ const DIM = "\x1b[2m";
94
+ const RESET = "\x1b[0m";
95
+
96
+ function die(msg) {
97
+ console.error(`${RED}error:${RESET} ${msg}`);
98
+ process.exit(1);
99
+ }
100
+
101
+ function ok(msg) {
102
+ console.log(`${GREEN}ok${RESET} ${msg}`);
103
+ }
104
+
105
+ function note(msg) {
106
+ console.log(`${DIM}·${RESET} ${msg}`);
107
+ }
108
+
109
+ function usage() {
110
+ console.log(`wdi-method ${PKG.version}
111
+
112
+ (no command) interactive TUI — detects install vs update
113
+ install [dir] first install (TUI unless --yes)
114
+ update [dir] update (TUI unless --yes)
115
+ verify [dir]
116
+ promote <live-dir> --rescue pull a method change back out of a consumer (not the normal flow)
117
+
118
+ --yes non-interactive
119
+ --agents a,b platform IDs (same as BMad --tools; legacy: claude = claude-code)
120
+ --list-agents print supported platform IDs
121
+ --product NAME written to index.yaml product.name
122
+ --client NAME written to index.yaml product.client (optional)
123
+ --doc-language <text> prose of working documents; free text, default English
124
+ --doc-filename-language <text> slug part of document filenames; free text, default English
125
+ --skip-bmad-check
126
+ --skip-engines-check install without to-spec / to-tickets / implement
127
+
128
+ BMad first, then this package. ${WDI_REPO}
129
+ `);
130
+ }
131
+
132
+ function parseArgs(argv) {
133
+ const args = {
134
+ cmd: null,
135
+ dir: null,
136
+ agents: null,
137
+ skipBmad: false,
138
+ rescue: false,
139
+ yes: false,
140
+ product: null,
141
+ client: null,
142
+ docLanguage: null,
143
+ docFilenameLanguage: null,
144
+ };
145
+ const rest = argv.slice(2);
146
+ if (rest[0] === "-h" || rest[0] === "--help") {
147
+ usage();
148
+ process.exit(0);
149
+ }
150
+ if (rest[0] === "--list-agents") {
151
+ console.log(formatPlatformList());
152
+ process.exit(0);
153
+ }
154
+ if (rest.length === 0) {
155
+ args.cmd = "wizard";
156
+ return args;
157
+ }
158
+ const first = rest[0];
159
+ if (["install", "update", "verify", "promote"].includes(first)) {
160
+ args.cmd = rest.shift();
161
+ } else if (first.startsWith("-")) {
162
+ args.cmd = "wizard";
163
+ } else {
164
+ args.cmd = "wizard";
165
+ args.dir = rest.shift();
166
+ }
167
+ while (rest.length) {
168
+ const t = rest.shift();
169
+ if (t === "--skip-bmad-check") args.skipBmad = true;
170
+ else if (t === "--skip-engines-check") args.skipEngines = true;
171
+ else if (t === "--rescue") args.rescue = true;
172
+ else if (t === "--yes" || t === "-y") args.yes = true;
173
+ else if (t === "--agents") {
174
+ const raw = rest.shift();
175
+ if (!raw) die("--agents needs a comma-separated list");
176
+ args.agents = normalizePlatformIds(raw.split(",").map((s) => s.trim()).filter(Boolean));
177
+ const unknown = raw.split(",").map((s) => s.trim()).filter(Boolean)
178
+ .filter((a) => !isKnownPlatform(a));
179
+ if (unknown.length) die(`unknown platform: ${unknown.join(", ")} (run --list-agents)`);
180
+ if (!args.agents.length) die("--agents needs at least one known platform");
181
+ } else if (t === "--product") args.product = rest.shift();
182
+ else if (t === "--client") args.client = rest.shift();
183
+ else if (t === "--doc-language" || t === "--doc-filename-language") {
184
+ // Free text: "English", "Bahasa Indonesia", "id" — a model reads it, so no list to match.
185
+ const raw = (rest.shift() || "").trim();
186
+ if (!raw) die(`${t} needs a value, for example: English`);
187
+ if (t === "--doc-language") args.docLanguage = raw;
188
+ else args.docFilenameLanguage = raw;
189
+ }
190
+ else if (t.startsWith("-")) die(`unknown flag: ${t}`);
191
+ else if (!args.dir) args.dir = t;
192
+ else die(`unexpected argument: ${t}`);
193
+ }
194
+ return args;
195
+ }
196
+
197
+ // Build output and editor droppings MUST NOT reach the kit. This repository is public, and a
198
+ // __pycache__/*.pyc carries the ABSOLUTE PATH of the source it was compiled from — which means a
199
+ // product name and a client folder leak into a public package through a file nobody wrote.
200
+ // Found 2026-08-18 on the first real promote: inventory.cpython-314.pyc embedded the live repo path.
201
+ const SKIP_DIRS = new Set(["__pycache__", "node_modules", ".git", ".pytest_cache", ".ruff_cache",
202
+ ".mypy_cache", ".venv", "venv", "dist", "build", ".idea", ".vscode"]);
203
+ const SKIP_FILE = /(\.pyc|\.pyo|\.pyd|\.log|\.tmp|\.swp|\.orig|\.rej|\.bak)$|^\.DS_Store$|^Thumbs\.db$/i;
204
+
205
+ function walkFiles(dir) {
206
+ const out = [];
207
+ if (!fs.existsSync(dir)) return out;
208
+ for (const entry of fs.readdirSync(dir, { withFileTypes: true })) {
209
+ const p = path.join(dir, entry.name);
210
+ if (entry.isDirectory()) {
211
+ if (SKIP_DIRS.has(entry.name)) continue;
212
+ out.push(...walkFiles(p));
213
+ } else if (entry.isFile()) {
214
+ if (SKIP_FILE.test(entry.name)) continue;
215
+ out.push(p);
216
+ }
217
+ }
218
+ return out;
219
+ }
220
+
221
+ function copyFile(src, dest) {
222
+ fs.mkdirSync(path.dirname(dest), { recursive: true });
223
+ fs.copyFileSync(src, dest);
224
+ }
225
+
226
+ function copyTree(src, dest, skipRel) {
227
+ let n = 0;
228
+ for (const p of walkFiles(src)) {
229
+ const rel = posixRel(src, p);
230
+ if (skipRel && skipRel(rel)) continue;
231
+ copyFile(p, path.join(dest, path.relative(src, p)));
232
+ n += 1;
233
+ }
234
+ return n;
235
+ }
236
+
237
+ function posixRel(from, to) {
238
+ return path.relative(from, to).split(path.sep).join("/");
239
+ }
240
+
241
+ function bmadPresent(target) {
242
+ const markers = [
243
+ path.join(target, ".claude", "skills", "bmad-help", "SKILL.md"),
244
+ path.join(target, "_bmad", "core", "config.yaml"),
245
+ path.join(target, "_bmad", "_config", "manifest.yaml"),
246
+ ];
247
+ return markers.some((p) => fs.existsSync(p));
248
+ }
249
+
250
+ function wdiPresent(target) {
251
+ return (
252
+ fs.existsSync(path.join(target, ".control", "wdi-method.yaml")) ||
253
+ fs.existsSync(path.join(target, ".constitution", "method", "README.md"))
254
+ );
255
+ }
256
+
257
+ function dirNonEmpty(target) {
258
+ if (!fs.existsSync(target)) return false;
259
+ return fs.readdirSync(target).some((n) => n !== ".git" && n !== ".gitignore");
260
+ }
261
+
262
+ function readBmadVersion(target) {
263
+ const manifest = path.join(target, "_bmad", "_config", "manifest.yaml");
264
+ if (!fs.existsSync(manifest)) return "";
265
+ const text = fs.readFileSync(manifest, "utf8");
266
+ const m = text.match(/installation:\s*\n\s*version:\s*(\S+)/);
267
+ return m ? m[1] : "";
268
+ }
269
+
270
+ function gitHead(repo) {
271
+ const r = spawnSync("git", ["-C", repo, "rev-parse", "--short", "HEAD"], {
272
+ encoding: "utf8",
273
+ });
274
+ if (r.status !== 0) return "unknown";
275
+ return r.stdout.trim();
276
+ }
277
+
278
+ function today() {
279
+ return new Date().toISOString().slice(0, 10);
280
+ }
281
+
282
+ function requireKit() {
283
+ if (!fs.existsSync(path.join(KIT, ".constitution"))) {
284
+ die(`kit missing at ${KIT}`);
285
+ }
286
+ }
287
+
288
+ function requireTarget(dir) {
289
+ const target = path.resolve(dir || process.cwd());
290
+ if (!fs.existsSync(target) || !fs.statSync(target).isDirectory()) {
291
+ die(`target is not a directory: ${target}`);
292
+ }
293
+ return target;
294
+ }
295
+
296
+ /** `to-spec` · `to-tickets` · `implement` — present as a user-level plugin, or copied into the repo. */
297
+ function enginesPresent(target) {
298
+ for (const dir of [".claude", ".agents", ".agent", ".cursor", ".codex"]) {
299
+ if (fs.existsSync(path.join(target, dir, "skills", "to-tickets", "SKILL.md"))) return true;
300
+ }
301
+ const cfg = process.env.CLAUDE_CONFIG_DIR || path.join(os.homedir(), ".claude");
302
+ const registry = path.join(cfg, "plugins", "installed_plugins.json");
303
+ if (!fs.existsSync(registry)) return false;
304
+ try {
305
+ const plugins = JSON.parse(fs.readFileSync(registry, "utf8")).plugins || {};
306
+ return Object.keys(plugins).some((k) => k.startsWith("mattpocock-skills@"));
307
+ } catch {
308
+ return false;
309
+ }
310
+ }
311
+
312
+ function bmadMissingMessage() {
313
+ return [
314
+ "BMad Method is not installed in this repo. Install it first, then run this installer again.",
315
+ "",
316
+ ` ${BMAD_INSTALL}`,
317
+ "",
318
+ `Source: ${BMAD_REPO}`,
319
+ "In the BMad installer, pick the same agents (Claude Code, Cursor, …).",
320
+ ].join("\n");
321
+ }
322
+
323
+ // The engines used to WARN and let the install through, on the reasoning that G1-G4 run without them and
324
+ // a first install has no G5 yet. Both halves are still true, and the reasoning stopped being enough:
325
+ // `wdi-autopilot` needs all three from its first iteration, and a warning inside a forty-line summary is
326
+ // read exactly as often as it is skipped. The failure it was meant to prevent — learning they are missing
327
+ // inside `wdi-build`, with a spec already open — kept happening anyway.
328
+ //
329
+ // So it blocks, and `--skip-engines-check` is the escape, exactly as `--skip-bmad-check` is for BMad. The
330
+ // escape matters: CI installs into a bare checkout, and a repo that will never reach G5 is a real case.
331
+ function enginesMissingMessage() {
332
+ return [
333
+ "The ticket engines are not installed. G5 (wdi-build) and wdi-autopilot need all three.",
334
+ "",
335
+ ` Claude Code: ${ENGINES_INSTALL}`,
336
+ ` Other agents: ${ENGINES_INSTALL_ANY}`,
337
+ "",
338
+ "You do NOT need to run the setup skill after this — the installer seeds docs/agents/ already",
339
+ `answered for this method. Run ${ENGINES_SETUP} only to change tracker.`,
340
+ `Source: ${ENGINES_REPO}`,
341
+ "",
342
+ "G1-G4 run without them. To install anyway and add them later: --skip-engines-check",
343
+ ].join("\n");
344
+ }
345
+
346
+ // The product's custom room. Three properties, and all three MUST hold together:
347
+ // install/update seeds its content ONLY when absent — never written again after that
348
+ // promote SKIPS it entirely, so a product's own rules can never reach the public repo
349
+ // agent loads it like any other guide, so it BINDS
350
+ // The deliberate consequence: this room's README is authored in the package and never comes home
351
+ // through promote.
352
+ const PROJECT_ROOM = "project/";
353
+
354
+ // 0.5.0 moved `.constitution/` to exactly two folders: `method/` is the method's and is overwritten,
355
+ // `project/` is the product's and is never touched. Before it, generic and product-owned files sat
356
+ // side by side at the root, `codebase/` was a third product-owned room nobody had written down, and
357
+ // `constitution.md` was ONE file holding both — which is why `update` had to keep the whole thing and
358
+ // the product never received a fixed generic Article.
359
+ //
360
+ // Without this migration an installed repo would end up carrying BOTH layouts: the kit writes the new
361
+ // paths while the old files stay behind, and an agent reading `AGENTS.md` routing would find two
362
+ // copies of most guides and no way to tell which binds.
363
+ const OLD_ROOT_GUIDES = ["README", "language-guide", "method-glossary", "repo-guide", "structure-guide"];
364
+ const OLD_WHY = ["README", "artifact-map", "portability", "rationale"];
365
+ const OLD_CODEBASE = ["stack", "conventions", "brownfield"];
366
+
367
+ function mv(from, to) {
368
+ fs.mkdirSync(path.dirname(to), { recursive: true });
369
+ fs.renameSync(from, to);
370
+ }
371
+
372
+ /** Article numbers that belong to the method half. The product keeps 1, 2, and 5. */
373
+ const METHOD_ARTICLES = [3, 4, 6, 7];
374
+
375
+ /**
376
+ * Cut the method's articles out of a product's constitution.md, and repoint its relative links.
377
+ *
378
+ * Returns {cut, kept, relinked}, or null when the file does not look like a constitution at all —
379
+ * in which case it is left ALONE rather than guessed at.
380
+ *
381
+ * 0.5.0 moved the file whole and printed "delete Articles 3, 4, 6, 7 yourself", on the grounds that
382
+ * no script can tell an edited copy from the original. That reasoning was wrong in the way that
383
+ * matters: the split does not need to know whether a section was edited, only which article numbers
384
+ * are the method's — and the file states them in its own headings. Leaving it whole left every
385
+ * migrated repo carrying those articles in TWO files, one of them frozen and drifting, plus relative
386
+ * links that no longer resolve one level down. It is all in git, so cutting is reversible; not
387
+ * cutting is what nobody notices.
388
+ */
389
+ function splitProductConstitution(file) {
390
+ if (!fs.existsSync(file)) return null;
391
+ const raw = fs.readFileSync(file, "utf8");
392
+ const crlf = raw.includes("\r\n");
393
+ const text = crlf ? raw.replaceAll("\r\n", "\n") : raw;
394
+ const marks = [...text.matchAll(/^## Article (\d+)\b.*$/gm)];
395
+ if (marks.length < 2) return null; // not the shape we know; do not touch it
396
+
397
+ const kept = [];
398
+ const cut = [];
399
+ let out = text.slice(0, marks[0].index);
400
+ for (let i = 0; i < marks.length; i += 1) {
401
+ const n = Number(marks[i][1]);
402
+ const end = i + 1 < marks.length ? marks[i + 1].index : text.length;
403
+ if (METHOD_ARTICLES.includes(n)) cut.push(n);
404
+ else {
405
+ kept.push(n);
406
+ out += text.slice(marks[i].index, end);
407
+ }
408
+ }
409
+ if (!cut.length) return { cut, kept, relinked: 0 };
410
+
411
+ // The file sits one level deeper than it did, and its former siblings moved into method/. A link
412
+ // left as `repo-guide.md` now resolves to .constitution/project/repo-guide.md, which does not exist.
413
+ let relinked = 0;
414
+ const bump = (re, to) => {
415
+ out = out.replace(re, (m, ...rest) => {
416
+ relinked += 1;
417
+ return typeof to === "function" ? to(m, ...rest) : to + m;
418
+ });
419
+ };
420
+ for (const name of ["repo-guide.md", "structure-guide.md", "language-guide.md",
421
+ "method-glossary.md"]) {
422
+ bump(new RegExp(`(?<![\\w./-])${name.replace(".", "\\.")}`, "g"), "../method/");
423
+ }
424
+ bump(/(?<![\w./-])document\//g, "../method/");
425
+ bump(/(?<![\w./-])codebase\/([a-z]+)-guide\.md/g, (_m, kind) => `codebase-${kind}-guide.md`);
426
+ out = out.replaceAll("../method/../method/", "../method/");
427
+
428
+ const banner = [
429
+ "",
430
+ `> **Articles ${cut.join(", ")} were removed from this file on migration to the two-folder layout.**`,
431
+ "> They are the method's and live in [`../method/constitution.md`](../method/constitution.md), which",
432
+ `> \`update\` replaces. Only Articles ${kept.join(", ")} are yours. The removed text is in git.`,
433
+ "",
434
+ ].join("\n");
435
+ const firstArticle = out.search(/^## Article /m);
436
+ out = firstArticle === -1
437
+ ? out + banner
438
+ : out.slice(0, firstArticle) + banner.trimStart() + "\n" + out.slice(firstArticle);
439
+
440
+ fs.writeFileSync(file, crlf ? out.replaceAll("\n", "\r\n") : out, "utf8");
441
+ return { cut, kept, relinked };
442
+ }
443
+
444
+ // `waves.yaml` holds the PRODUCT's plan, not the package's. When the method retired `wave` for
445
+ // `spec` the registry had to follow, and a rename is the only part of that a tool can safely do:
446
+ // the file MOVES, its content is left exactly as written. Rewriting the rows — `W1` to `SPEC-1`,
447
+ // `epics`/`stories` to `tickets` — is the product's own migration, run by `wdi-build` where a human
448
+ // can see it, because a guess there silently rewrites months of real work.
449
+ //
450
+ // Two refusals matter more than the move. It never writes over an existing `specs.yaml`, and it
451
+ // never deletes a `waves.yaml` whose content has nowhere to go: a half-finished hand migration
452
+ // leaves BOTH files present, and which one is real is not something an installer can know.
453
+ // `wdi-autopilot` named its ledger for the DAY before 0.6.2 — `autopilot-<YYYY-MM-DD>.md`. The mandate
454
+ // it belongs to is named for the MANDATE now — `autopilot-<DEC-id>.md` — because two mandates opened
455
+ // on the same day would otherwise append to one file and destroy both as a record, and because
456
+ // `mandate-accept` (the validator introduced alongside the rename) looks for the file at that path and
457
+ // nowhere else. This is a pure rename, like `waves.yaml` → `specs.yaml`: the ledger's own content is
458
+ // never touched, only found and moved. Renaming it is what a script can safely do; restructuring its
459
+ // CONTENT into the `## Resume` / `## Decisions` split is not — that has to read git and the registry to
460
+ // know where the run actually stands, so it is the skill's own job on the next iteration it runs, not
461
+ // this installer's.
462
+ // `/setup-matt-pocock-skills` interviews the owner and writes `docs/agents/`. Two of its answers are
463
+ // wrong for a WDI repo, and BOTH repos that ran it had to hand-correct the SAME file afterwards:
464
+ //
465
+ // - `domain.md` tells agents to read and lazily create a root `CONTEXT.md` and `docs/adr/`. Article 3
466
+ // says this method has no `docs/` layer for corpus or rules, and `wdi-reconcile` reports both as
467
+ // findings. The homes already exist: `.control/product-glossary.md`, `.what/`, `.how/`, `DEC-`.
468
+ // - `issue-tracker.md`'s local-markdown default puts every ticket under `.scratch/<feature>/`, while
469
+ // `wdi-build` owns tickets at `{spec_folder}/issues/`. Two homes for one ticket set.
470
+ //
471
+ // Seeding them removes the interview for the answers WDI Method actually has a requirement on. Seeded
472
+ // ONCE and never overwritten — after the first install they are the product's, like every other file
473
+ // under a path the product owns. An owner who wants a different tracker re-runs the setup skill; the
474
+ // seeded file says which three invariants have to survive that.
475
+ function seedAgentDocs(target) {
476
+ const dir = path.join(target, "docs", "agents");
477
+ let wrote = 0;
478
+ for (const name of ["domain.md", "issue-tracker.md"]) {
479
+ const to = path.join(dir, name);
480
+ if (fs.existsSync(to)) continue;
481
+ const seed = path.join(ROOT, "scaffold", "docs", "agents", name);
482
+ if (!fs.existsSync(seed)) continue;
483
+ copyFile(seed, to);
484
+ wrote += 1;
485
+ }
486
+ if (wrote) {
487
+ note(`seeded docs/agents/ (${wrote} file${wrote === 1 ? "" : "s"}) — the engines' config, pre-answered`);
488
+ note(" do NOT run /setup-matt-pocock-skills to redo these; re-run it only to change tracker");
489
+ }
490
+ return wrote > 0;
491
+ }
492
+
493
+ // A repo that ran the setup skill BEFORE installing this package still carries the default `domain.md`,
494
+ // and it is actively misleading: it sends every engineering skill looking for a root `CONTEXT.md` and
495
+ // `docs/adr/`, and tells them to create both lazily. Seeding cannot fix it, because the file already
496
+ // exists and a file under a product-owned path is never overwritten. So it is named instead.
497
+ function warnStaleAgentDocs(target) {
498
+ const file = path.join(target, "docs", "agents", "domain.md");
499
+ if (!fs.existsSync(file)) return;
500
+ const text = fs.readFileSync(file, "utf8");
501
+ if (!/CONTEXT\.md|docs\/adr/.test(text)) return;
502
+ // An override note is what both real repos added by hand. Recognising it is what stops this warning
503
+ // from firing forever on a file somebody already fixed.
504
+ if (/does not use|MUST NOT be created|no `docs\/` layer/i.test(text)) return;
505
+ note("docs/agents/domain.md still points agents at a root CONTEXT.md and docs/adr/");
506
+ note(" Article 3: this method has no `docs/` layer for corpus or rules, and wdi-reconcile");
507
+ note(" reports both as findings. Say so at the top of that file — the glossary is at");
508
+ note(" .control/product-glossary.md and a decision is a DEC-, never an ADR");
509
+ }
510
+
511
+ function migrateAutopilotLedgers(target) {
512
+ const dir = path.join(target, ".control", "memlog");
513
+ if (!fs.existsSync(dir)) return;
514
+ const OLD = /^autopilot-(\d{4}-\d{2}-\d{2})\.md$/;
515
+ for (const name of fs.readdirSync(dir)) {
516
+ const m = OLD.exec(name);
517
+ if (!m) continue;
518
+ const from = path.join(dir, name);
519
+ const text = fs.readFileSync(from, "utf8");
520
+ const artifact = /^artifact:\s*(\S.*)$/m.exec(text)?.[1]?.trim();
521
+ const id = artifact && /(DEC-\d+)/.exec(artifact)?.[1];
522
+ if (!id) {
523
+ note(`.control/memlog/${name} looks like a pre-0.6.2 autopilot ledger, but its \`artifact:\` does`);
524
+ note(` not resolve to a DEC- id — rename it to autopilot-<the mandate's DEC- id>.md yourself`);
525
+ continue;
526
+ }
527
+ const to = path.join(dir, `autopilot-${id}.md`);
528
+ if (fs.existsSync(to)) {
529
+ note(`BOTH .control/memlog/${name} and autopilot-${id}.md exist — neither was touched`);
530
+ note(` the run's ledger is in one of them and I cannot tell which. Merge them, then delete the other`);
531
+ continue;
532
+ }
533
+ mv(from, to);
534
+ note(`renamed .control/memlog/${name} → autopilot-${id}.md (content unchanged)`);
535
+ note(` \`mandate-accept\` looks for a mandate's ledger at this exact path`);
536
+ }
537
+ }
538
+
539
+ // A mandate opened before 0.6.2 recorded `parked: []` under the OLD default — full authority, AD-N
540
+ // contradictions included. 0.6.2 changed the DEFAULT for a NEW mandate to park `ad-n`, because
541
+ // decision-guide.md says narrowing an invariant MUST NOT be softened further. A default only applies
542
+ // at the moment a mandate is written, so an EXISTING accepted mandate keeps whatever it already says —
543
+ // silently adding `ad-n` to it would be overwriting a value the owner already chose, which `update`
544
+ // MUST NOT do to anything in the product's own registry. So this only ever WARNS, naming the mandate
545
+ // and the one line that would close the gap, and leaves the decision to whoever reads the summary.
546
+ function warnStaleMandates(target) {
547
+ const file = path.join(target, ".control", "registry", "decisions.yaml");
548
+ if (!fs.existsSync(file)) return;
549
+ const text = fs.readFileSync(file, "utf8");
550
+ const blocks = text.split(/\n(?=\s*-\s*id:\s*DEC-)/);
551
+ for (const block of blocks) {
552
+ if (!/type:\s*mandate/.test(block)) continue;
553
+ if (!/status:\s*accepted/.test(block)) continue;
554
+ const id = /id:\s*(DEC-\d+)/.exec(block)?.[1];
555
+ const parkedLine = /parked:\s*(\[[^\]]*\]|.*)$/m.exec(block)?.[0] || "";
556
+ const parkedBlockList = /parked:\s*\n((?:\s+-\s*\S.*\n?)*)/.exec(block)?.[1] || "";
557
+ if (/ad-n/.test(parkedLine) || /ad-n/.test(parkedBlockList)) continue;
558
+ note(`${id || "a mandate"} predates the \`ad-n\`-parked-by-default protection (0.6.2) — its \`parked\``);
559
+ note(` list does not name it, so it still decides an AD-N contradiction on its own`);
560
+ note(` add \`ad-n\` to its \`parked\` list in decisions.yaml yourself if you want the new default`);
561
+ }
562
+ }
563
+
564
+ function migrateRegistryNames(target) {
565
+ const reg = path.join(target, ".control", "registry");
566
+ const from = path.join(reg, "waves.yaml");
567
+ const to = path.join(reg, "specs.yaml");
568
+ if (!fs.existsSync(from)) return false;
569
+ if (fs.existsSync(to)) {
570
+ note("BOTH .control/registry/waves.yaml and specs.yaml exist — neither was touched");
571
+ note(" the plan is in one of them and I cannot tell which. Merge them yourself, then delete waves.yaml");
572
+ return false;
573
+ }
574
+ mv(from, to);
575
+ note("renamed .control/registry/waves.yaml → specs.yaml (content unchanged)");
576
+ note(" the rows still say `W<N>` and `epics`/`stories`. Re-cut them through the wdi-build skill");
577
+ return true;
578
+ }
579
+
580
+ // The requirement registry split into `goals.yaml` (the product's `BG`, written by `wdi-problem` at
581
+ // G1) plus one `requirements-<slug>.yaml` per PRD (`CAP`, `FR`, `NFR`, `UJ`, written by
582
+ // `wdi-product` at G2). One file, one writer, one gate. What a tool can do here is SEED `goals.yaml`;
583
+ // what it MUST NOT do is move the rows.
584
+ //
585
+ // Splitting the rows needs one fact the registry has never recorded: which PRD an `FR` belongs to.
586
+ // Before the split nothing wrote it down, and deriving it — FR → UC → ticket → spec → `prd:` — only
587
+ // works for FRs that already have tickets. A guess would file a promise under the wrong initiative,
588
+ // which is worse than leaving it where it is. So `requirements.yaml` is left ALONE and still read:
589
+ // `validate.py` unions every requirement file it finds, so a half-split corpus stays green while its
590
+ // owner cuts the rows through the skill that owns each one.
591
+ function seedRequirementSplit(target) {
592
+ const reg = path.join(target, ".control", "registry");
593
+ if (!fs.existsSync(reg)) return false;
594
+ const product = path.join(reg, "goals.yaml");
595
+ if (fs.existsSync(product)) return false;
596
+ const seed = path.join(SCAFFOLD, "registry", "goals.yaml");
597
+ if (!fs.existsSync(seed)) return false;
598
+ copyFile(seed, product);
599
+ note("seeded .control/registry/goals.yaml");
600
+ if (fs.existsSync(path.join(reg, "requirements.yaml"))) {
601
+ note(" requirements.yaml was left exactly as it is, and is still read — nothing broke");
602
+ note(" the wdi-upgrade skill moves `goals:` into goals.yaml and cuts `capabilities:`,");
603
+ note(" `functional:`, `nonfunctional:`, and `journeys:` into requirements-<slug>.yaml per PRD.");
604
+ note(" <slug> is the PRD's folder name under .what/_prd/");
605
+ }
606
+ return true;
607
+ }
608
+
609
+ function migrateToTwoFolders(target) {
610
+ const c = path.join(target, ".constitution");
611
+ if (!fs.existsSync(c)) return false; // a first install has nothing to migrate
612
+ const at = (...p) => path.join(c, ...p);
613
+ // The old layout is identified by `document/` at the ROOT — in the new layout that folder only ever
614
+ // exists under `method/`. Checking a loose guide instead would misfire on a repo that added one.
615
+ if (!fs.existsSync(at("document")) && !fs.existsSync(at("codebase"))
616
+ && !fs.existsSync(at("constitution.md")) && !fs.existsSync(at("scripts"))) {
617
+ return false;
618
+ }
619
+ note("pre-0.5.0 .constitution/ found — migrating to method/ + project/");
620
+
621
+ // 1. The four Reference files go one level deeper. This MUST run before the kit is written, or the
622
+ // kit's own why/ files land while the old copies still sit at method/ root.
623
+ for (const name of OLD_WHY) {
624
+ const from = at("method", `${name}.md`);
625
+ if (fs.existsSync(from)) {
626
+ mv(from, at("method", "why", `${name}.md`));
627
+ note(` moved method/${name}.md → method/why/${name}.md`);
628
+ }
629
+ }
630
+ // 2. and 3. whole folders
631
+ for (const dir of ["document", "scripts"]) {
632
+ if (fs.existsSync(at(dir)) && !fs.existsSync(at("method", dir))) {
633
+ mv(at(dir), at("method", dir));
634
+ note(` moved ${dir}/ → method/${dir}/`);
635
+ }
636
+ }
637
+ // 4. the loose generic guides
638
+ for (const name of OLD_ROOT_GUIDES) {
639
+ const from = at(`${name}.md`);
640
+ if (fs.existsSync(from)) {
641
+ mv(from, at("method", `${name}.md`));
642
+ note(` moved ${name}.md → method/${name}.md`);
643
+ }
644
+ }
645
+ // 5. codebase/ was a product-owned room all along — it becomes flat files in the room that says so
646
+ for (const name of OLD_CODEBASE) {
647
+ const from = at("codebase", `${name}-guide.md`);
648
+ if (fs.existsSync(from)) {
649
+ mv(from, at("project", `codebase-${name}-guide.md`));
650
+ note(` moved codebase/${name}-guide.md → project/codebase-${name}-guide.md`);
651
+ }
652
+ }
653
+ if (fs.existsSync(at("codebase"))) {
654
+ const left = fs.readdirSync(at("codebase"));
655
+ if (!left.length) fs.rmdirSync(at("codebase"));
656
+ else note(` codebase/ still holds ${left.join(", ")} — left in place, move them yourself`);
657
+ }
658
+ // 6. The product's constitution.md moves WHOLE into the room, so its Articles 1, 2, and 5 survive
659
+ // exactly as written. The generic half then arrives fresh at method/constitution.md.
660
+ let split = null;
661
+ if (fs.existsSync(at("constitution.md")) && !fs.existsSync(at("project", "constitution.md"))) {
662
+ mv(at("constitution.md"), at("project", "constitution.md"));
663
+ note(" moved constitution.md → project/constitution.md");
664
+ split = splitProductConstitution(at("project", "constitution.md"));
665
+ if (split && split.cut.length) {
666
+ note(` kept Articles ${split.kept.join(", ")}, removed ${split.cut.join(", ")} `
667
+ + "(the method's — they arrive in method/constitution.md)");
668
+ if (split.relinked) note(` repointed ${split.relinked} relative links one level up`);
669
+ } else if (split === null) {
670
+ note(" it does not carry `## Article N` headings, so it was moved but NOT split — yours to check");
671
+ }
672
+ }
673
+ // Anything else loose at the root is a file this product ADDED. It is NOT moved: it may be routed
674
+ // from AGENTS.md by its current path, and guessing a destination would break that silently.
675
+ const stray = fs.existsSync(c)
676
+ ? fs.readdirSync(c, { withFileTypes: true })
677
+ .filter((e) => e.isFile() && e.name.endsWith(".md"))
678
+ .map((e) => e.name)
679
+ : [];
680
+ if (stray.length) {
681
+ note(` left at .constitution/ root, yours to place: ${stray.join(", ")}`);
682
+ note(" a file you added belongs in project/ — but moving it would break any pointer that");
683
+ note(" names its current path, so the choice is yours. repo-guide.md states the rule.");
684
+ }
685
+ return split;
686
+ }
687
+
688
+ function syncConstitution(target) {
689
+ const kitConst = path.join(KIT, ".constitution");
690
+ const destConst = path.join(target, ".constitution");
691
+ fs.mkdirSync(destConst, { recursive: true });
692
+ let written = 0;
693
+ let skipped = 0;
694
+ for (const file of walkFiles(kitConst)) {
695
+ const rel = posixRel(kitConst, file);
696
+ const dest = path.join(destConst, rel);
697
+ // ONE rule for everything the product owns, because 0.5.0 put all of it in one folder. Before
698
+ // that this loop had three branches — the mixed constitution.md kept whole, `codebase/` gated on
699
+ // `status: Accepted` (which is what silently destroyed a half-written guide), and the room — and
700
+ // the three disagreed about when a file was the product's. Seeded when absent, never written
701
+ // again: the same rule as the language policy.
702
+ // ONE file in the room is the package's and is refreshed like any method file: the room's own
703
+ // README. It explains what the room is FOR and carries no product decision, so a stale copy does
704
+ // not preserve anybody's work — it just misinforms. worship-presenter-web proved that: its copy
705
+ // still pointed at `.constitution/codebase/*-guide.md`, a folder 0.5.0 deleted, and no update
706
+ // would ever have corrected it while the file claimed in its own text to be "authored in the
707
+ // package". Either the package writes it or it stops claiming authorship; this is the first.
708
+ if (rel === `${PROJECT_ROOM}README.md`) {
709
+ copyFile(file, dest);
710
+ written += 1;
711
+ continue;
712
+ }
713
+ if (rel.startsWith(PROJECT_ROOM) && fs.existsSync(dest)) {
714
+ skipped += 1;
715
+ note(`keep ${rel} (yours — the project room)`);
716
+ continue;
717
+ }
718
+ copyFile(file, dest);
719
+ written += 1;
720
+ }
721
+ return { written, skipped };
722
+ }
723
+
724
+ function syncSkills(target, agents) {
725
+ let n = 0;
726
+ const dests = skillDestinations(target, agents);
727
+ if (dests.length === 0) {
728
+ note("no skill destinations for selected platforms — AGENTS.md still applies");
729
+ return { files: 0, removed: 0 };
730
+ }
731
+ for (const name of WDI_SKILLS) {
732
+ const src = path.join(KIT, "skills", name);
733
+ if (!fs.existsSync(src)) die(`kit missing skill ${name}`);
734
+ for (const root of dests) {
735
+ const dest = path.join(root, name);
736
+ fs.rmSync(dest, { recursive: true, force: true });
737
+ n += copyTree(src, dest);
738
+ }
739
+ }
740
+ const removed = pruneRetiredSkills(dests);
741
+ return { files: n, removed };
742
+ }
743
+
744
+ // A wrapper the method RETIRED is worse than a wrapper missing: the folder is still there, its
745
+ // SKILL.md still reads like an instruction, and an agent will invoke it — while the guide it points
746
+ // at is gone. Renaming five wrappers (wdi-apply, wdi-analysis, wdi-structure, …) left exactly that
747
+ // in every repo installed before the rename, because update only ever touched the names it knows.
748
+ //
749
+ // `wdi-` is the method's namespace, so a `wdi-*` folder carrying a SKILL.md and not in WDI_SKILLS is
750
+ // ours and retired. Each removal is PRINTED: silent deletion in someone else's repo is not a fix.
751
+ function pruneRetiredSkills(dests) {
752
+ let removed = 0;
753
+ const keep = new Set(WDI_SKILLS);
754
+ for (const root of dests) {
755
+ if (!fs.existsSync(root)) continue;
756
+ for (const entry of fs.readdirSync(root, { withFileTypes: true })) {
757
+ if (!entry.isDirectory() || !entry.name.startsWith("wdi-") || keep.has(entry.name)) continue;
758
+ const dir = path.join(root, entry.name);
759
+ if (!fs.existsSync(path.join(dir, "SKILL.md"))) {
760
+ note(`kept ${entry.name} (no SKILL.md — not one of ours)`);
761
+ continue;
762
+ }
763
+ fs.rmSync(dir, { recursive: true, force: true });
764
+ note(`removed retired skill ${entry.name}`);
765
+ removed += 1;
766
+ }
767
+ }
768
+ return removed;
769
+ }
770
+
771
+ // `promote` scrubs a product's initiative slug out of bmad-prd.toml before publishing, which is right.
772
+ // Writing the scrubbed PLACEHOLDER back into a product repo is not: the first real install replaced a
773
+ // live `run_folder_pattern = "some-real-slug"` with `FILL-initiative-slug`, and nothing said so. A value
774
+ // the product already chose is not the installer's to overwrite — same rule as the custom room and the
775
+ // language policy.
776
+ const PLACEHOLDER_SLUG = "FILL-initiative-slug";
777
+ const RUN_FOLDER_LINE = /^(\s*run_folder_pattern\s*=\s*)(".*?"|'.*?')/m;
778
+
779
+ // The slug appears MORE THAN ONCE — bmad-prd.toml carries it in `run_folder_pattern` and again inside a
780
+ // memlog path, and the file itself says the two lines MUST change together. The first version of this
781
+ // function restored only the first line and so produced exactly the inconsistency that file forbids.
782
+ // So: read the product's slug once, then put it back everywhere the placeholder appears.
783
+ function keepProductSlug(incoming, existing) {
784
+ const mineNow = existing.match(RUN_FOLDER_LINE);
785
+ if (!mineNow) return null;
786
+ const slug = mineNow[2].slice(1, -1);
787
+ if (!slug || slug === PLACEHOLDER_SLUG) return null;
788
+ if (!incoming.includes(PLACEHOLDER_SLUG)) return null;
789
+ // Only where the slug is a VALUE: the quoted setting, and the memlog path built from it. A bare
790
+ // mention inside a comment stays the placeholder — that sentence explains the pattern, and rewriting
791
+ // it would turn a generic explanation into a statement about one initiative.
792
+ return incoming
793
+ .replaceAll(`"${PLACEHOLDER_SLUG}"`, `"${slug}"`)
794
+ .replaceAll(`prd-${PLACEHOLDER_SLUG}`, `prd-${slug}`);
795
+ }
796
+
797
+ function syncTomls(target) {
798
+ const src = path.join(KIT, "assets", "bmad-custom");
799
+ const dest = path.join(target, "_bmad", "custom");
800
+ fs.mkdirSync(dest, { recursive: true });
801
+ let n = 0;
802
+ let slugsKept = 0;
803
+ for (const file of walkFiles(src)) {
804
+ if (!file.endsWith(".toml") || file.endsWith(".user.toml")) continue;
805
+ const to = path.join(dest, path.basename(file));
806
+ if (fs.existsSync(to)) {
807
+ const merged = keepProductSlug(fs.readFileSync(file, "utf8"), fs.readFileSync(to, "utf8"));
808
+ if (merged !== null) {
809
+ fs.writeFileSync(to, merged);
810
+ note(`kept run_folder_pattern in ${path.basename(file)}`);
811
+ slugsKept += 1;
812
+ n += 1;
813
+ continue;
814
+ }
815
+ }
816
+ copyFile(file, to);
817
+ n += 1;
818
+ }
819
+ return { files: n, slugsKept };
820
+ }
821
+
822
+ // The same argument pruneRetiredSkills makes, one folder over — with one difference that changes
823
+ // the rule. `wdi-` is this method's namespace, so "a wdi-* folder not in WDI_SKILLS" is safely ours.
824
+ // `_bmad/custom/` is NOT: a product may put its own override there, and `.user.toml` is the
825
+ // product's half of every override by convention. So removal here is by an EXPLICIT list of files
826
+ // this package once shipped and has now withdrawn — never by "absent from the kit".
827
+ //
828
+ // Why remove them at all: an override for a retired engine is worse than no override. It is still
829
+ // installed and still read, and bmad-retrospective.toml instructs an agent to archive an `RTR-`
830
+ // against a validator, V19, that no longer exists.
831
+ const RETIRED_TOMLS = [
832
+ "bmad-spec.toml", "bmad-build.toml", "bmad-build-auto.toml",
833
+ "bmad-code-review.toml", "bmad-retrospective.toml",
834
+ ];
835
+
836
+ function pruneRetiredTomls(target) {
837
+ const dir = path.join(target, "_bmad", "custom");
838
+ if (!fs.existsSync(dir)) return 0;
839
+ let removed = 0;
840
+ for (const name of RETIRED_TOMLS) {
841
+ const file = path.join(dir, name);
842
+ if (!fs.existsSync(file)) continue;
843
+ fs.rmSync(file);
844
+ note(`removed retired override ${name}`);
845
+ removed += 1;
846
+ }
847
+ return removed;
848
+ }
849
+
850
+ function seedControlIfMissing(target) {
851
+ const control = path.join(target, ".control");
852
+ if (fs.existsSync(control)) {
853
+ note(".control/ already present — left untouched");
854
+ return;
855
+ }
856
+ if (!fs.existsSync(SCAFFOLD)) die(`scaffold missing: ${SCAFFOLD}`);
857
+ const n = copyTree(SCAFFOLD, control);
858
+ ok(`seeded empty .control/ (${n} files)`);
859
+ }
860
+
861
+ // On a FIRST install these folders are the corpus taking shape. On an UPDATE their absence means
862
+ // somebody removed them on purpose — `.work/` and `_bmad-output/prior-knowledge/` are exactly the two a
863
+ // product retires once its migration is done, and one repo retired them through an applied decision.
864
+ // Recreating them then is an installer overruling a decision it cannot read. Seed once, never resurrect.
865
+ function seedEmptyLayers(target, { first }) {
866
+ const always = [".what", path.join(".how", "_platform")];
867
+ const firstOnly = [".work", path.join("_bmad-output", "prior-knowledge")];
868
+ for (const rel of first ? [...always, ...firstOnly] : always) {
869
+ const dest = path.join(target, rel);
870
+ if (!fs.existsSync(dest)) {
871
+ fs.mkdirSync(dest, { recursive: true });
872
+ // Git tracks files, not directories: an empty folder does not reach the next clone. The
873
+ // scaffold already puts a `.gitkeep` in each of its empty rooms, and these four were the
874
+ // exception — `.work/` invisible from birth is half the reason a bootstrap read it as
875
+ // ignorable and wrote it into `.gitignore`, which corpus-in-git now reports.
876
+ fs.writeFileSync(path.join(dest, ".gitkeep"), "");
877
+ note(`created ${rel.replaceAll(path.sep, "/")}/`);
878
+ }
879
+ }
880
+ if (!first) {
881
+ for (const rel of firstOnly) {
882
+ if (!fs.existsSync(path.join(target, rel))) {
883
+ note(`left ${rel.replaceAll(path.sep, "/")}/ absent — a product retires it, not the installer`);
884
+ }
885
+ }
886
+ }
887
+ }
888
+
889
+ function writeStamp(target) {
890
+ const control = path.join(target, ".control");
891
+ if (!fs.existsSync(control)) return;
892
+ const stamp = [
893
+ "# Written by wdi-method install/update. A trace, not a lockfile.",
894
+ `wdi_method: ${PKG.version}`,
895
+ `bmad_method: ${readBmadVersion(target) || '""'}`,
896
+ `installed_at: ${today()}`,
897
+ "",
898
+ ].join("\n");
899
+ fs.writeFileSync(path.join(control, "wdi-method.yaml"), stamp, "utf8");
900
+ note("stamped .control/wdi-method.yaml");
901
+ }
902
+
903
+ function setProductIdentity(target, { name, client }) {
904
+ if (!name || identityIsPlaceholder(name)) return;
905
+ const file = path.join(target, ".control", "registry", "index.yaml");
906
+ if (!fs.existsSync(file)) return;
907
+ const next = writeProductIdentity(fs.readFileSync(file, "utf8"), {
908
+ name,
909
+ client: client ?? "",
910
+ });
911
+ fs.writeFileSync(file, next.endsWith("\n") ? next : `${next}\n`);
912
+ note(`product.name = ${name}`);
913
+ }
914
+
915
+ // The document language belongs to the PRODUCT, so update MUST NOT overwrite it. It is written only
916
+ // when absent — same as the custom room, and for the same reason: a setting somebody already chose
917
+ // is not the installer's to change behind their back.
918
+ function setLanguagePolicy(target, { docLanguage, docFilenameLanguage, chosen }) {
919
+ const file = path.join(target, ".control", "registry", "index.yaml");
920
+ if (!fs.existsSync(file)) return;
921
+ const text = fs.readFileSync(file, "utf8");
922
+ const existing = readLanguagePolicy(text);
923
+ // `chosen` means somebody actually answered — in the TUI, or through an explicit flag. Only then
924
+ // does the answer take effect. Without it the incoming value is just a default, and a default
925
+ // MUST NOT overwrite a choice somebody already made.
926
+ if (!chosen && existing.docLanguage && existing.docFilenameLanguage) {
927
+ note(`kept policy.doc_language = ${existing.docLanguage}, ` +
928
+ `doc_filename_language = ${existing.docFilenameLanguage}`);
929
+ return;
930
+ }
931
+ const next = writeLanguagePolicy(text, {
932
+ docLanguage: docLanguage || existing.docLanguage || DEFAULT_DOC_LANGUAGE,
933
+ docFilenameLanguage:
934
+ docFilenameLanguage || existing.docFilenameLanguage || DEFAULT_DOC_LANGUAGE,
935
+ });
936
+ fs.writeFileSync(file, next.endsWith("\n") ? next : `${next}\n`);
937
+ const after = readLanguagePolicy(next);
938
+ note(`policy.doc_language = ${after.docLanguage}, ` +
939
+ `doc_filename_language = ${after.docFilenameLanguage}`);
940
+ }
941
+
942
+ // After `update`, some of the corpus can still be in the OLD shape — content the installer MUST NOT
943
+ // move, because moving it takes a decision about meaning: which PRD an `FR` belongs to, whether a
944
+ // sentence was an assumption or a constraint. The `wdi-upgrade` skill does that half. This only
945
+ // DETECTS it, cheaply, so the summary can say how much is waiting and where.
946
+ function pendingUpgrades(target) {
947
+ const has = (...p) => fs.existsSync(path.join(target, ...p));
948
+ const read = (...p) => (has(...p) ? fs.readFileSync(path.join(target, ...p), "utf8") : "");
949
+ const anyIn = (dir, glob, re) => {
950
+ const d = path.join(target, dir);
951
+ if (!fs.existsSync(d)) return false;
952
+ return fs.readdirSync(d).some((n) => {
953
+ const f = path.join(d, n, glob);
954
+ return fs.existsSync(f) && re.test(fs.readFileSync(f, "utf8"));
955
+ });
956
+ };
957
+ const items = [];
958
+ if (has(".control", "registry", "requirements.yaml")) items.push("requirements.yaml → goals.yaml + requirements-<slug>.yaml");
959
+ if (/^\s*-\s*id:\s*W\d+|^\s*(epics|stories):/m.test(read(".control", "registry", "specs.yaml"))) items.push("specs.yaml rows still W<n>/epics/stories (wdi-build re-cuts)");
960
+ if (/^## (Executive Summary|Vision|Assumptions|Prerequisites)\s*$/m.test(read(".what", "_product-brief", "brief.md"))) items.push("brief.md in the 14-section shape");
961
+ // Sections by NAME: the numbers moved between kits (Non-Goals was §7 in one, §5 in the next).
962
+ if (anyIn(".what/_prd", "prd.md", /^## (\d+\.\s*)?(Document Purpose|Glossary|Non-Goals|Open Questions|Assumptions Index)\b|\*\*Proof of done:\*\*/m)) items.push("a prd.md in the 12-section shape, or with FR blocks");
963
+ const whatDir = path.join(target, ".what");
964
+ if (fs.existsSync(whatDir)) {
965
+ for (const pc of fs.readdirSync(whatDir)) {
966
+ if (pc.startsWith("_")) continue;
967
+ const srs = read(".what", pc, `SRS-${pc}.md`);
968
+ if (/^\|\s*UC-\d+\s*\|/m.test(srs)) { items.push("an SRS with a UC Catalogue table (now a pointer)"); break; }
969
+ }
970
+ }
971
+ const howDir = path.join(target, ".how");
972
+ if (fs.existsSync(howDir)) {
973
+ for (const pc of fs.readdirSync(howDir)) {
974
+ if (pc.startsWith("_")) continue;
975
+ if (/\|\s*Quoted rule\s*\||Quoted verbatim from/.test(read(".how", pc, `SDD-${pc}.md`))) { items.push("an SDD quoting AD-N text (now ids only)"); break; }
976
+ }
977
+ }
978
+ if (/\|\s*Container\s*\|\s*Product Components living in it\s*\|/.test(read(".how", "_platform", "c4-l2-containers.md"))) items.push("c4-l2 with a PC x container table (now a pointer)");
979
+ if (has(".control", "generated", "brief.md") || has(".control", "generated", "blueprint.md")) items.push("human pages still in .control/generated/ (render clears them)");
980
+ if (has(".what", "_product-brief", "brief.md") && !has(".what-rendered")) items.push("no .what-rendered/ yet (render creates it)");
981
+ // Skipped: what the validator never reads (kit copies, rendered output, dependencies) and what it
982
+ // treats as a record of the PAST — memlog, decisions, reports, _bmad-output. A stale path in a log
983
+ // is history, not a finding, and repointing it would falsify the record.
984
+ const SKIP = new Set([".git", "node_modules", "target", ".constitution", ".claude", ".agents", ".agent",
985
+ ".what-rendered", ".how-rendered", "dist", "build", "memlog", "decisions", "reports", "meetings", "_bmad-output", ".work"]);
986
+ const OLD_PAGE = /\.control\/generated\/(brief|blueprint|prd-[a-z0-9-]+)\.md/;
987
+ const citesOldPage = (dir, depth) => {
988
+ if (depth > 8) return false;
989
+ for (const e of fs.readdirSync(dir, { withFileTypes: true })) {
990
+ if (e.isDirectory()) { if (!SKIP.has(e.name) && citesOldPage(path.join(dir, e.name), depth + 1)) return true; continue; }
991
+ if (e.name === "answered.md") continue;
992
+ if (e.name.endsWith(".md") && OLD_PAGE.test(fs.readFileSync(path.join(dir, e.name), "utf8"))) return true;
993
+ }
994
+ return false;
995
+ };
996
+ if (citesOldPage(target, 0)) items.push("a document cites .control/generated/brief|blueprint|prd-*.md (pages moved to the rendered trees)");
997
+ return items;
998
+ }
999
+
1000
+ // Read BEFORE writeStamp overwrites it. Without this there is no version transition to print, and
1001
+ // an "updated" with no from-to tells the reader nothing they can use.
1002
+ function readStampVersion(target) {
1003
+ const file = path.join(target, ".control", "wdi-method.yaml");
1004
+ if (!fs.existsSync(file)) return "";
1005
+ const m = fs.readFileSync(file, "utf8").match(/^wdi_method:\s*"?([^"\s]+)"?/m);
1006
+ return m ? m[1] : "";
1007
+ }
1008
+
1009
+ function readIndexPolicy(target) {
1010
+ const file = path.join(target, ".control", "registry", "index.yaml");
1011
+ if (!fs.existsSync(file)) return { docLanguage: "", docFilenameLanguage: "" };
1012
+ return readLanguagePolicy(fs.readFileSync(file, "utf8"));
1013
+ }
1014
+
1015
+ function readIndexIdentity(target) {
1016
+ const file = path.join(target, ".control", "registry", "index.yaml");
1017
+ if (!fs.existsSync(file)) return { name: "", client: "" };
1018
+ return readProductIdentity(fs.readFileSync(file, "utf8"));
1019
+ }
1020
+
1021
+ function upsertAgentFiles(target, platforms, productName) {
1022
+ const template = fs.readFileSync(path.join(OVERLAY, "AGENTS.md"), "utf8");
1023
+ const agentsFile = path.join(target, "AGENTS.md");
1024
+ let next;
1025
+ if (!fs.existsSync(agentsFile)) {
1026
+ next = fillProductTitle(template, productName || "{product}");
1027
+ ok("AGENTS.md created rewrite ## Code for this product");
1028
+ } else {
1029
+ next = upsertMethodBlock(fs.readFileSync(agentsFile, "utf8"), template);
1030
+ note("AGENTS.md method block refreshed; product sections kept");
1031
+ }
1032
+ if (!next.endsWith("\n")) next += "\n";
1033
+ fs.writeFileSync(agentsFile, next);
1034
+
1035
+ const mirrors = [];
1036
+ if (platformUsesHook(platforms, "cursorrules")) {
1037
+ mirrors.push(path.join(target, ".cursorrules"));
1038
+ }
1039
+ if (platformUsesHook(platforms, "agents-mirror")) {
1040
+ mirrors.push(path.join(target, ".agents", "AGENTS.md"));
1041
+ }
1042
+ for (const mirror of mirrors) {
1043
+ fs.mkdirSync(path.dirname(mirror), { recursive: true });
1044
+ if (fs.existsSync(mirror)) {
1045
+ const patched = upsertMethodBlock(fs.readFileSync(mirror, "utf8"), template);
1046
+ fs.writeFileSync(mirror, patched.endsWith("\n") ? patched : `${patched}\n`);
1047
+ note(`method block refreshed in ${posixRel(target, mirror)}`);
1048
+ } else {
1049
+ fs.writeFileSync(mirror, next);
1050
+ note(`created ${posixRel(target, mirror)}`);
1051
+ }
1052
+ }
1053
+
1054
+ if (platformUsesHook(platforms, "claude-md")) {
1055
+ const claude = path.join(target, "CLAUDE.md");
1056
+ if (!fs.existsSync(claude)) {
1057
+ fs.writeFileSync(claude, "@AGENTS.md\n");
1058
+ note("CLAUDE.md created as @AGENTS.md");
1059
+ }
1060
+ }
1061
+ }
1062
+
1063
+ // What a run MUST leave a reader able to answer: which version replaced which, what was written, what
1064
+ // was KEPT, and what to do next. The third is the one usually missing, and it is the one that decides
1065
+ // whether somebody trusts running this over a repo they have already put work into.
1066
+ function summaryLine(label, value) {
1067
+ console.log(` ${DIM}${label.padEnd(11)}${RESET}${value}`);
1068
+ }
1069
+
1070
+ function printSummary(target, agents, { first, was, written, skipped, skills, tomls, opencodeCmds }) {
1071
+ const now = PKG.version;
1072
+ const version = first
1073
+ ? `${now} — first install`
1074
+ : was && was !== now
1075
+ ? `${was} ${DIM}→${RESET} ${now}`
1076
+ : `${now} ${DIM}(unchanged)${RESET}`;
1077
+ const bmad = readBmadVersion(target);
1078
+
1079
+ const kept = [];
1080
+ if (skipped) kept.push(`${skipped} constitution file${skipped === 1 ? "" : "s"}`);
1081
+ if (tomls.slugsKept) kept.push(`${tomls.slugsKept} initiative slug${tomls.slugsKept === 1 ? "" : "s"}`);
1082
+ // On a first install the language was just CHOSEN, not kept — saying "kept" there reads as if the
1083
+ // installer had found something it decided to leave alone, which is the opposite of what happened.
1084
+ const policy = readIndexPolicy(target);
1085
+ if (policy.docLanguage && !first) kept.push(`language (${policy.docLanguage})`);
1086
+ if (fs.existsSync(path.join(target, ".constitution", "project"))) kept.push(".constitution/project/");
1087
+
1088
+ console.log("");
1089
+ console.log(`${DIM}────${RESET} WDI Method ${DIM}${"─".repeat(46)}${RESET}`);
1090
+ summaryLine("version", version);
1091
+ if (bmad) summaryLine("bmad", bmad);
1092
+ summaryLine("target", target);
1093
+ console.log("");
1094
+ summaryLine("written", `${written} constitution · ${skills.files} skill files · ${tomls.files} bmad overrides`
1095
+ + (opencodeCmds?.written ? ` · ${opencodeCmds.written} opencode commands` : ""));
1096
+ if (kept.length) summaryLine("kept", kept.join(" · "));
1097
+ const gone = [];
1098
+ if (skills.removed) gone.push(`${skills.removed} retired wrapper${skills.removed === 1 ? "" : "s"}`);
1099
+ if (tomls.removed) gone.push(`${tomls.removed} retired override${tomls.removed === 1 ? "" : "s"}`);
1100
+ if (gone.length) summaryLine("removed", gone.join(" · "));
1101
+ if (first && policy.docLanguage) {
1102
+ summaryLine("language", `${policy.docLanguage} · filenames ${policy.docFilenameLanguage}`);
1103
+ }
1104
+ summaryLine("platforms", agents.join(", ") || "none");
1105
+ console.log("");
1106
+ // The readers are the one seeded file that does nothing until somebody writes it, and its
1107
+ // silence is expensive: inventory.py refuses to run and the reason is a folder deep. One line
1108
+ // here, only while it is still the skeleton, so it stops appearing once it is done.
1109
+ if (readersAreSkeleton(target)) {
1110
+ summaryLine("todo", `${DIM}.constitution/project/inventory-readers.py${RESET} is a skeleton` +
1111
+ `run the ${INIT_SKILL} skill, intent ${DIM}readers${RESET}, ` +
1112
+ `to write it for this repo's stack`);
1113
+ }
1114
+ summaryLine("engines", enginesPresent(target)
1115
+ ? `to-spec · to-tickets · implement — found (${ENGINES_PLUGIN})`
1116
+ : `to-spec · to-tickets · implement NOT found. G5 (wdi-build) and the Fast Path need them; G1–G4 run without them`);
1117
+ if (!enginesPresent(target)) {
1118
+ summaryLine("", `${DIM}·${RESET} Claude Code: ${DIM}${ENGINES_INSTALL}${RESET} other agents: ${DIM}${ENGINES_INSTALL_ANY}${RESET}`);
1119
+ summaryLine("", `${DIM}·${RESET} then ${DIM}${ENGINES_SETUP}${RESET} once, to name the tracker · ${ENGINES_REPO}`);
1120
+ }
1121
+ const pending = first ? [] : pendingUpgrades(target);
1122
+ if (pending.length) {
1123
+ summaryLine("upgrade", `${pending.length} item${pending.length === 1 ? "" : "s"} still in the OLD shape — ` +
1124
+ `run the ${DIM}wdi-upgrade${RESET} skill; it moves content, never invents it`);
1125
+ for (const item of pending) summaryLine("", `${DIM}·${RESET} ${item}`);
1126
+ }
1127
+ summaryLine("next", pending.length
1128
+ ? `run the ${DIM}wdi-upgrade${RESET} skill first, then ${HELP_SKILL}`
1129
+ : `invoke the ${HELP_SKILL} skill and ask what to do`);
1130
+ summaryLine("", REPO_URL);
1131
+ console.log(`${DIM}${"".repeat(62)}${RESET}`);
1132
+ }
1133
+
1134
+ function printNextSteps({ first, productSet, upgradePending }) {
1135
+ console.log("");
1136
+ console.log(first ? "After install:" : "After update:");
1137
+ if (first) {
1138
+ if (!productSet) {
1139
+ console.log(" 1. Fill product.name (and product.client if there is one) in .control/registry/index.yaml.");
1140
+ } else {
1141
+ console.log(" 1. product.name is set. G1 confirms it in the brief.");
1142
+ }
1143
+ console.log(" 2. Rewrite .constitution/constitution.md Articles 2 and 5 for this product.");
1144
+ console.log(" Article 1 cites index.yaml — do not become a second source for the name.");
1145
+ console.log(" 3. Write ## Code in AGENTS.md (where the app lives). Leave the BEGIN:wdi-method block alone.");
1146
+ console.log(" 4. Run the wdi-init skill, intent setup.");
1147
+ console.log(" 5. Sort the documents you already have. Do not move any of them in this step.");
1148
+ console.log("");
1149
+ console.log("Next update:");
1150
+ console.log(" npx wdi-method");
1151
+ console.log(" (the TUI offers the update) or: npx wdi-method update --yes");
1152
+ } else {
1153
+ console.log(" 1. The <!-- BEGIN:wdi-method --> block in AGENTS.md was replaced. Read the diff.");
1154
+ console.log(" 2. constitution.md Articles 1-2-5, ## Code, and *.user.toml were not overwritten.");
1155
+ console.log(" 3. If BMad has new skills, install those first, then run this update again.");
1156
+ if (upgradePending) {
1157
+ console.log(" 4. The summary listed an `upgrade` line: run the wdi-upgrade skill before any other skill.");
1158
+ console.log(" It moves content into the new shape and never invents any; one commit.");
1159
+ }
1160
+ }
1161
+ }
1162
+
1163
+ function apply(target, agents,
1164
+ { first, product, client, docLanguage, docFilenameLanguage, languageChosen }) {
1165
+ requireKit();
1166
+ const was = readStampVersion(target);
1167
+ // MUST run before the kit is written: it moves the product's files out of the way of paths the kit
1168
+ // is about to occupy. Running it after would leave two copies of most guides.
1169
+ const migrated = migrateToTwoFolders(target);
1170
+ migrateRegistryNames(target);
1171
+ migrateAutopilotLedgers(target);
1172
+ warnStaleMandates(target);
1173
+ seedAgentDocs(target);
1174
+ warnStaleAgentDocs(target);
1175
+ seedRequirementSplit(target);
1176
+ // The split MUST also be reachable without a migration. 0.5.2 only ran it from inside
1177
+ // migrateToTwoFolders, which returns early when the old layout is absent — so a repo that took
1178
+ // 0.5.0 or 0.5.1, whose project/constitution.md was moved WHOLE and never split, could never be
1179
+ // fixed by any later update. That is precisely the repo that needs it. Running it here on every
1180
+ // update closes that, and it is idempotent: after a split there are no method articles left to cut.
1181
+ const lateSplit = splitProductConstitution(path.join(target, ".constitution", "project",
1182
+ "constitution.md"));
1183
+ if (!migrated && lateSplit && lateSplit.cut.length) {
1184
+ note(`project/constitution.md still carried Articles ${lateSplit.cut.join(", ")} removed`);
1185
+ note(` they are the method's and live in method/constitution.md; kept ${lateSplit.kept.join(", ")}`);
1186
+ if (lateSplit.relinked) note(` repointed ${lateSplit.relinked} relative links`);
1187
+ }
1188
+ const splitConstitution = migrated;
1189
+ const { written, skipped } = syncConstitution(target);
1190
+ note(`constitution wrote ${written}, kept ${skipped}`);
1191
+ // A migrated repo also carries derived output stamped against the OLD layout: .control/generated/*
1192
+ // still names the pre-0.5.0 script path, and the two structure maps still draw the old tree. The
1193
+ // installer MUST NOT write either — one is generated, the other is re-derived by a skill so it
1194
+ // says so instead of leaving them to be found by whoever trusts them next.
1195
+ if (splitConstitution) {
1196
+ note(" derived output still describes the OLD layout, and neither is mine to write:");
1197
+ note(" uv run .constitution/method/scripts/validate.py --generate → .control/generated/");
1198
+ note(" then the wdi-init skill, intent `structure` → the two structure maps");
1199
+ }
1200
+ const skills = syncSkills(target, agents);
1201
+ note(`skills ${skills.files} files`);
1202
+ let opencodeCmds = { written: 0, removed: 0 };
1203
+ if (platformUsesHook(agents, "opencode-commands")) {
1204
+ opencodeCmds = syncOpencodeCommands(target, WDI_SKILLS, path.join(KIT, "skills"));
1205
+ note(`opencode commands ${opencodeCmds.written} files → ${opencodeCommandsDir()}/`);
1206
+ if (opencodeCmds.removed) {
1207
+ note(`removed ${opencodeCmds.removed} retired opencode command${opencodeCmds.removed === 1 ? "" : "s"}`);
1208
+ }
1209
+ }
1210
+ const tomls = syncTomls(target);
1211
+ tomls.removed = pruneRetiredTomls(target);
1212
+ note(`bmad custom ${tomls.files} toml → _bmad/custom/`);
1213
+ if (first) seedControlIfMissing(target);
1214
+ seedEmptyLayers(target, { first });
1215
+ setProductIdentity(target, { name: product, client });
1216
+ setLanguagePolicy(target, { docLanguage, docFilenameLanguage, chosen: languageChosen });
1217
+ upsertAgentFiles(target, agents, product);
1218
+ writeStamp(target);
1219
+ printSummary(target, agents, { first, was, written, skipped, skills, tomls, opencodeCmds });
1220
+ printNextSteps({
1221
+ first,
1222
+ productSet: Boolean(product) && !identityIsPlaceholder(product),
1223
+ upgradePending: !first && pendingUpgrades(target).length > 0,
1224
+ });
1225
+ }
1226
+
1227
+ function verify(target, agents) {
1228
+ requireKit();
1229
+ const missing = [];
1230
+ const kitConst = path.join(KIT, ".constitution");
1231
+ for (const file of walkFiles(kitConst)) {
1232
+ const rel = posixRel(kitConst, file);
1233
+ const dest = path.join(target, ".constitution", rel);
1234
+ if (!fs.existsSync(dest)) missing.push(`.constitution/${rel}`);
1235
+ }
1236
+ for (const name of WDI_SKILLS) {
1237
+ for (const root of skillDestinations(target, agents)) {
1238
+ const dest = path.join(root, name, "SKILL.md");
1239
+ if (!fs.existsSync(dest)) missing.push(posixRel(target, dest));
1240
+ }
1241
+ }
1242
+ if (platformUsesHook(agents, "opencode-commands")) {
1243
+ for (const name of WDI_SKILLS) {
1244
+ const dest = path.join(target, opencodeCommandsDir(), `${name}.md`);
1245
+ if (!fs.existsSync(dest)) missing.push(posixRel(target, dest));
1246
+ }
1247
+ }
1248
+ const custom = path.join(KIT, "assets", "bmad-custom");
1249
+ for (const file of walkFiles(custom)) {
1250
+ if (!file.endsWith(".toml")) continue;
1251
+ const dest = path.join(target, "_bmad", "custom", path.basename(file));
1252
+ if (!fs.existsSync(dest)) missing.push(`_bmad/custom/${path.basename(file)}`);
1253
+ }
1254
+ if (fs.existsSync(path.join(target, ".control"))) {
1255
+ for (const file of walkFiles(SCAFFOLD)) {
1256
+ const rel = posixRel(SCAFFOLD, file);
1257
+ const dest = path.join(target, ".control", rel);
1258
+ if (!fs.existsSync(dest)) missing.push(`.control/${rel}`);
1259
+ }
1260
+ } else {
1261
+ missing.push(".control/ (folder missing — first install should have seeded it)");
1262
+ }
1263
+ // `.constitution/constitution.md` was the pre-0.5.0 path. Demanding it here made `verify` report a
1264
+ // file MISSING that the split deliberately removed — a check telling the truth about the wrong world.
1265
+ for (const required of ["AGENTS.md", path.join(".constitution", "project", "constitution.md")]) {
1266
+ if (!fs.existsSync(path.join(target, required))) missing.push(required.replaceAll(path.sep, "/"));
1267
+ }
1268
+ if (missing.length) {
1269
+ console.error(`${RED}missing ${missing.length}${RESET}`);
1270
+ for (const m of missing) console.error(` ${m}`);
1271
+ process.exit(1);
1272
+ }
1273
+ ok(`method files present in ${target}`);
1274
+
1275
+ // Present-and-correct is not the same as consistent. These three are states `update` cannot fix on
1276
+ // its own — it MUST NOT write over the room, and it cannot know what a product meant — so `verify`
1277
+ // is where they get said out loud instead of waiting to be tripped over.
1278
+ const judgement = [];
1279
+ const room = path.join(target, ".constitution", "project", "constitution.md");
1280
+ if (fs.existsSync(room)) {
1281
+ const carried = [...fs.readFileSync(room, "utf8").matchAll(/^## Article (\d+)\b/gm)]
1282
+ .map((m) => Number(m[1])).filter((n) => METHOD_ARTICLES.includes(n));
1283
+ if (carried.length) {
1284
+ judgement.push(`project/constitution.md still carries Articles ${carried.join(", ")} — the `
1285
+ + "method's. They are duplicated in method/constitution.md and will drift. Run update again.");
1286
+ }
1287
+ }
1288
+ const constRoot = path.join(target, ".constitution");
1289
+ const loose = fs.existsSync(constRoot)
1290
+ ? fs.readdirSync(constRoot, { withFileTypes: true })
1291
+ .filter((e) => e.isFile() && e.name.endsWith(".md")).map((e) => e.name)
1292
+ : [];
1293
+ if (loose.length) {
1294
+ judgement.push(`loose at .constitution/ root: ${loose.join(", ")} — .constitution/ holds two `
1295
+ + "folders and nothing else the method knows about. Move it into project/, or name it from "
1296
+ + "Article 2 so the next reader knows why it is there. repo-guide.md states the rule.");
1297
+ }
1298
+ if (judgement.length) {
1299
+ console.log("");
1300
+ for (const j of judgement) note(j);
1301
+ }
1302
+ note("extra product files are expected and were not checked");
1303
+ }
1304
+
1305
+ function scrubPrdToml(file) {
1306
+ const raw = fs.readFileSync(file, "utf8");
1307
+ const m = raw.match(/run_folder_pattern\s*=\s*"([^"]+)"/);
1308
+ if (!m) return;
1309
+ const slug = m[1];
1310
+ if (GENERIC_FOLDER_PATTERNS.has(slug)) return;
1311
+ fs.writeFileSync(file, raw.split(slug).join(PRD_SLUG_PLACEHOLDER), "utf8");
1312
+ note("bmad-prd.toml initiative slug scrubbed to placeholder");
1313
+ }
1314
+
1315
+ function promote(live) {
1316
+ live = path.resolve(live);
1317
+ if (!fs.existsSync(path.join(live, ".constitution"))) {
1318
+ die(`${live} has no .constitution/ is this a method-carrying repo?`);
1319
+ }
1320
+ // EVERY file in the room is authored in the package and MUST survive the rmSync below — the room's
1321
+ // README, the generic Articles 1-2-5, and the three empty codebase templates. Read here, not
1322
+ // after: the first version of this preserved only README.md and read it AFTER the kit was deleted,
1323
+ // so it was always null and the file vanished on every promote. Two tests cover it now.
1324
+ const roomKit = path.join(KIT, ".constitution", PROJECT_ROOM);
1325
+ const roomKept = fs.existsSync(roomKit)
1326
+ ? Object.fromEntries(walkFiles(roomKit).map((f) => [posixRel(roomKit, f), fs.readFileSync(f, "utf8")]))
1327
+ : {};
1328
+
1329
+ fs.rmSync(KIT, { recursive: true, force: true });
1330
+ fs.mkdirSync(KIT, { recursive: true });
1331
+
1332
+ // ONE skip, because 0.5.0 put everything the product owns in one folder. It covers the codebase
1333
+ // guides too, which used to need a rule of their own: promoting a filled-in stack guide would leak
1334
+ // one product's conventions — possibly written in its own `doc_language` — into a public package.
1335
+ const nConst = copyTree(path.join(live, ".constitution"), path.join(KIT, ".constitution"),
1336
+ (rel) => rel.startsWith(PROJECT_ROOM));
1337
+ note(`constitution ${nConst} files (${PROJECT_ROOM} skipped — it is the product's)`);
1338
+ for (const [rel, text] of Object.entries(roomKept)) {
1339
+ const dest = path.join(roomKit, rel);
1340
+ fs.mkdirSync(path.dirname(dest), { recursive: true });
1341
+ fs.writeFileSync(dest, text, "utf8");
1342
+ }
1343
+ if (Object.keys(roomKept).length) {
1344
+ note(`${PROJECT_ROOM} restored from the package (${Object.keys(roomKept).length} files) — `
1345
+ + "promote never carries the room home");
1346
+ }
1347
+
1348
+ let copiedSkills = 0;
1349
+ const skillsSrc = path.join(live, ".claude", "skills");
1350
+ for (const name of WDI_SKILLS) {
1351
+ const src = path.join(skillsSrc, name);
1352
+ if (!fs.existsSync(src)) die(`skill missing in live repo: ${src}`);
1353
+ copiedSkills += copyTree(src, path.join(KIT, "skills", name));
1354
+ }
1355
+ note(`skills ${copiedSkills} files (${WDI_SKILLS.length} wrappers)`);
1356
+
1357
+ const customSrc = path.join(live, "_bmad", "custom");
1358
+ const customDst = path.join(KIT, "assets", "bmad-custom");
1359
+ fs.mkdirSync(customDst, { recursive: true });
1360
+ let tomls = 0;
1361
+ if (fs.existsSync(customSrc)) {
1362
+ for (const file of walkFiles(customSrc)) {
1363
+ if (!file.endsWith(".toml") || file.endsWith(".user.toml")) continue;
1364
+ copyFile(file, path.join(customDst, path.basename(file)));
1365
+ tomls += 1;
1366
+ }
1367
+ }
1368
+ const prd = path.join(customDst, "bmad-prd.toml");
1369
+ if (fs.existsSync(prd)) scrubPrdToml(prd);
1370
+ note(`bmad custom ${tomls} toml`);
1371
+
1372
+ const replacements = {
1373
+ "constitution.md": path.join(KIT, ".constitution", "method", "constitution.md"),
1374
+ "portability.md": path.join(KIT, ".constitution", "method", "why", "portability.md"),
1375
+ "repo-guide.md": path.join(KIT, ".constitution", "method", "repo-guide.md"),
1376
+ "README.md": path.join(KIT, ".constitution", "method", "README.md"),
1377
+ };
1378
+ for (const [name, dest] of Object.entries(replacements)) {
1379
+ const src = path.join(OVERLAY, name);
1380
+ if (fs.existsSync(src)) {
1381
+ copyFile(src, dest);
1382
+ note(`${name} replaced with kit overlay`);
1383
+ }
1384
+ }
1385
+
1386
+ const source = [
1387
+ `date: ${today()}`,
1388
+ `commit: ${gitHead(live)}`,
1389
+ "kind: working copy that currently carries a newer method",
1390
+ "note: the repo path and product name MUST NOT be recorded here",
1391
+ "",
1392
+ ].join("\n");
1393
+ fs.writeFileSync(path.join(ROOT, "SOURCE"), source, "utf8");
1394
+ ok(`SOURCE stamped ${today()} @ ${gitHead(live)}`);
1395
+ ok(`promoted into ${KIT}`);
1396
+ }
1397
+
1398
+ function cancelIf(value) {
1399
+ if (p.isCancel(value)) {
1400
+ p.cancel("Cancelled.");
1401
+ process.exit(0);
1402
+ }
1403
+ return value;
1404
+ }
1405
+
1406
+ async function runWizard(pre) {
1407
+ p.intro(`WDI Method ${PKG.version}`);
1408
+
1409
+ const dirValue = cancelIf(
1410
+ await p.text({
1411
+ message: "Target repo (the product folder)",
1412
+ placeholder: process.cwd(),
1413
+ defaultValue: pre.dir || process.cwd(),
1414
+ }),
1415
+ );
1416
+ const target = path.resolve(String(dirValue).trim() || process.cwd());
1417
+
1418
+ if (!fs.existsSync(target)) {
1419
+ const create = cancelIf(
1420
+ await p.confirm({ message: `${target} does not exist. Create it?`, initialValue: true }),
1421
+ );
1422
+ if (!create) {
1423
+ p.cancel("No target folder.");
1424
+ process.exit(1);
1425
+ }
1426
+ fs.mkdirSync(target, { recursive: true });
1427
+ }
1428
+
1429
+ const hasBmad = bmadPresent(target);
1430
+ const hasWdi = wdiPresent(target);
1431
+ const nonempty = dirNonEmpty(target);
1432
+
1433
+ const facts = [
1434
+ hasBmad
1435
+ ? `BMad Method: installed${readBmadVersion(target) ? ` (${readBmadVersion(target)})` : ""}`
1436
+ : "BMad Method: not installed",
1437
+ hasWdi ? "WDI Method: already present — the installer will offer an update" : "WDI Method: not present",
1438
+ enginesPresent(target)
1439
+ ? "Ticket engines (mattpocock-skills): installed"
1440
+ : `Ticket engines (mattpocock-skills): not found — needed at G5 only; ${ENGINES_INSTALL} (${ENGINES_REPO})`,
1441
+ nonempty ? "Folder is not empty (normal for a product repo already under way)" : "Folder is empty",
1442
+ ].join("\n");
1443
+ p.note(facts, "Detected");
1444
+
1445
+ if (!hasBmad && !pre.skipBmad) {
1446
+ p.note(bmadMissingMessage(), "BMad first");
1447
+ p.outro("Install BMad, then run this again: npx wdi-method");
1448
+ process.exit(1);
1449
+ }
1450
+
1451
+ let first = !hasWdi;
1452
+ if (hasWdi) {
1453
+ const update = cancelIf(
1454
+ await p.confirm({
1455
+ message: "WDI Method is already installed. Update it now?",
1456
+ initialValue: true,
1457
+ }),
1458
+ );
1459
+ first = !update;
1460
+ if (first) {
1461
+ p.cancel("Update declined.");
1462
+ process.exit(0);
1463
+ }
1464
+ } else {
1465
+ const go = cancelIf(
1466
+ await p.confirm({
1467
+ message: `Install WDI Method into ${target}?`,
1468
+ initialValue: true,
1469
+ }),
1470
+ );
1471
+ if (!go) {
1472
+ p.cancel("Install declined.");
1473
+ process.exit(0);
1474
+ }
1475
+ }
1476
+
1477
+ // Every field arrives with an answer already in it, and Enter accepts it. On an update that answer is
1478
+ // what the repo already says; on a first install it is the folder name made readable. Nothing here is
1479
+ // validated as required: a prompt that refuses an empty submission when it already holds a sensible
1480
+ // default is asking the owner to retype something the installer knows.
1481
+ const existing = readIndexIdentity(target);
1482
+ const suggestedName = identityIsPlaceholder(existing.name)
1483
+ ? humaniseFolderName(path.basename(target))
1484
+ : existing.name;
1485
+ const product = cancelIf(
1486
+ await p.text({
1487
+ message: "Product name (one room: index.yaml product.name)",
1488
+ placeholder: suggestedName,
1489
+ defaultValue: suggestedName,
1490
+ }),
1491
+ ).trim() || suggestedName;
1492
+ const client = cancelIf(
1493
+ await p.text({
1494
+ message: "Client name (Enter to leave it as it is)",
1495
+ placeholder: existing.client || "(none)",
1496
+ defaultValue: existing.client || "",
1497
+ }),
1498
+ ).trim();
1499
+
1500
+ // Two questions, and only two. Method terminology, document code prefixes, machine-facing
1501
+ // markers, and code identifiers are always English — MUST NOT be asked about.
1502
+ const policy = readIndexPolicy(target);
1503
+ // Free text, not a list. Write whatever a model understands — "English", "Bahasa Indonesia",
1504
+ // "id". The only value refused is empty.
1505
+ const askLanguage = async (message, current) =>
1506
+ (cancelIf(
1507
+ await p.text({
1508
+ message,
1509
+ placeholder: current || DEFAULT_DOC_LANGUAGE,
1510
+ defaultValue: current || DEFAULT_DOC_LANGUAGE,
1511
+ }),
1512
+ ) || DEFAULT_DOC_LANGUAGE).trim();
1513
+ const docLanguage = await askLanguage(
1514
+ "Language of working-document prose (.what/ .how/ .control/) — free text",
1515
+ policy.docLanguage || pre.docLanguage);
1516
+ const docFilenameLanguage = await askLanguage(
1517
+ "Language of document filename slugs — the `UC-` `DEC-` codes stay English",
1518
+ policy.docFilenameLanguage || pre.docFilenameLanguage || docLanguage);
1519
+
1520
+ const detected = pre.agents
1521
+ ? normalizePlatformIds(pre.agents)
1522
+ : detectPlatforms(target, fs);
1523
+ const selected = cancelIf(
1524
+ await p.autocompleteMultiselect({
1525
+ message: "Which tools get the wdi-* skills? (⭐ = recommended)",
1526
+ options: platformSelectOptions(detected),
1527
+ initialValues: detected,
1528
+ required: true,
1529
+ maxItems: 8,
1530
+ placeholder: "Type to search…",
1531
+ }),
1532
+ );
1533
+
1534
+ p.note(
1535
+ [
1536
+ "The corpus folder names are fixed — they are not an install option:",
1537
+ " .constitution .control .what .how .work _bmad-output",
1538
+ "",
1539
+ "What gets written for the platforms you picked:",
1540
+ " AGENTS.md (the BEGIN:wdi-method block — always)",
1541
+ platformUsesHook(selected, "claude-md") ? " CLAUDE.md → @AGENTS.md" : "",
1542
+ platformUsesHook(selected, "cursorrules") ? " .cursorrules (method block mirror)" : "",
1543
+ platformUsesHook(selected, "agents-mirror") ? " .agents/AGENTS.md (method block mirror)" : "",
1544
+ platformUsesHook(selected, "opencode-commands")
1545
+ ? ` ${opencodeCommandsDir()}/wdi-*.md (slash commands → skills)`
1546
+ : "",
1547
+ ` wdi-* skills → ${skillDestinations(target, selected).map((d) => posixRel(target, d)).join(", ") || "(none)"}`,
1548
+ ]
1549
+ .filter(Boolean)
1550
+ .join("\n"),
1551
+ "Write targets",
1552
+ );
1553
+
1554
+ const okGo = cancelIf(await p.confirm({ message: first ? "Run the install?" : "Run the update?", initialValue: true }));
1555
+ if (!okGo) {
1556
+ p.cancel("Dibatalkan.");
1557
+ process.exit(0);
1558
+ }
1559
+
1560
+ const spinner = p.spinner();
1561
+ spinner.start(first ? "Memasang…" : "Meng-update…");
1562
+ apply(target, selected, {
1563
+ docLanguage,
1564
+ docFilenameLanguage,
1565
+ languageChosen: true,
1566
+ first,
1567
+ product: String(product).trim(),
1568
+ client: String(client).trim(),
1569
+ });
1570
+ spinner.stop(first ? "Terpasang" : "Ter-update");
1571
+ p.outro(first ? "Done. Take the after-install steps above." : "Done. Read the method-block diff in AGENTS.md.");
1572
+ }
1573
+
1574
+ function runNonInteractive(args) {
1575
+ const target = requireTarget(args.dir);
1576
+ const agents = args.agents || detectPlatforms(target, fs) || PREFERRED_PLATFORM_IDS.slice();
1577
+ if (args.cmd === "verify") {
1578
+ verify(target, agents);
1579
+ return;
1580
+ }
1581
+ if (!args.skipBmad && !bmadPresent(target)) {
1582
+ die(bmadMissingMessage());
1583
+ }
1584
+ if (!args.skipEngines && !enginesPresent(target)) {
1585
+ die(enginesMissingMessage());
1586
+ }
1587
+ const existing = readIndexIdentity(target);
1588
+ const product = args.product || existing.name;
1589
+ const client = args.client ?? existing.client;
1590
+ const first = args.cmd === "install" || (args.cmd === "wizard" && !wdiPresent(target));
1591
+ apply(target, agents, {
1592
+ first: args.cmd === "update" ? false : first,
1593
+ product,
1594
+ client,
1595
+ docLanguage: args.docLanguage,
1596
+ docFilenameLanguage: args.docFilenameLanguage,
1597
+ languageChosen: Boolean(args.docLanguage || args.docFilenameLanguage),
1598
+ });
1599
+ }
1600
+
1601
+ async function main() {
1602
+ const args = parseArgs(process.argv);
1603
+ if (!["wizard", "install", "update", "verify", "promote"].includes(args.cmd)) {
1604
+ usage();
1605
+ process.exit(2);
1606
+ }
1607
+ if (args.cmd === "promote") {
1608
+ if (!args.dir) die("promote needs a path to the working copy");
1609
+ // `promote` used to BE the workflow: author a rule in a product repo, run it, carry it here.
1610
+ // It is now a rescue tool, and the flag is what makes that structural rather than a paragraph
1611
+ // nobody rereads. Running it by habit overwrites the whole kit with one consumer's copy —
1612
+ // silently reverting every change made here since that repo last updated.
1613
+ if (!args.rescue) {
1614
+ die([
1615
+ "promote overwrites the whole kit from a consumer's copy, and this package is now where a",
1616
+ " method change is authored — see CONTRIBUTING.md. If a change really was made in a",
1617
+ " product repo by mistake and needs rescuing, say so:",
1618
+ "",
1619
+ " npx wdi-method promote <dir> --rescue",
1620
+ ].join("\n"));
1621
+ }
1622
+ note("--rescue: pulling the method back out of a consumer. Read the diff before committing.");
1623
+ promote(args.dir);
1624
+ return;
1625
+ }
1626
+ const wantTui = !args.yes && args.cmd !== "verify" && process.stdin.isTTY && process.stdout.isTTY;
1627
+ if (wantTui) {
1628
+ await runWizard(args);
1629
+ return;
1630
+ }
1631
+ if (args.cmd === "wizard" && !args.yes) {
1632
+ die("not a TTY. Use `install --yes` / `update --yes`, or run this in a terminal.");
1633
+ }
1634
+ if (args.cmd === "wizard") args.cmd = wdiPresent(requireTarget(args.dir)) ? "update" : "install";
1635
+ runNonInteractive(args);
1636
+ }
1637
+
1638
+ main().catch((err) => {
1639
+ console.error(err);
1640
+ process.exit(1);
1641
+ });