wdi-method 0.5.2 → 0.5.4

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,1221 +1,1276 @@
1
- #!/usr/bin/env node
2
- import fs from "node:fs";
3
- import path from "node:path";
4
- import { spawnSync } from "node:child_process";
5
- import { fileURLToPath } from "node:url";
6
- import * as p from "@clack/prompts";
7
- import {
8
- fillProductTitle,
9
- upsertMethodBlock,
10
- } from "../lib/agents-block.mjs";
11
- import {
12
- identityIsPlaceholder,
13
- humaniseFolderName,
14
- readLanguagePolicy,
15
- writeLanguagePolicy,
16
- DEFAULT_DOC_LANGUAGE,
17
- readProductIdentity,
18
- writeProductIdentity,
19
- } from "../lib/identity.mjs";
20
-
21
- const ROOT = path.resolve(path.dirname(fileURLToPath(import.meta.url)), "..");
22
- const KIT = path.join(ROOT, "kit");
23
- const OVERLAY = path.join(ROOT, "kit-overlay");
24
- const SCAFFOLD = path.join(ROOT, "scaffold", ".control");
25
- const PKG = JSON.parse(fs.readFileSync(path.join(ROOT, "package.json"), "utf8"));
26
-
27
- const WDI_SKILLS = [
28
- "wdi-init",
29
- "wdi-problem",
30
- "wdi-product",
31
- "wdi-ux",
32
- "wdi-blueprint",
33
- "wdi-component",
34
- "wdi-build",
35
- "wdi-decision",
36
- "wdi-question",
37
- "wdi-log",
38
- "wdi-help",
39
- "wdi-reconcile",
40
- "wdi-review",
41
- "wdi-report",
42
- "wdi-systematic-debugging",
43
- ];
44
-
45
- const PRD_SLUG_PLACEHOLDER = "FILL-initiative-slug";
46
- const GENERIC_FOLDER_PATTERNS = new Set([
47
- "_product-brief",
48
- "ux",
49
- "architecture",
50
- PRD_SLUG_PLACEHOLDER,
51
- ]);
52
-
53
- const ALL_AGENTS = ["claude", "cursor", "codex", "antigravity"];
54
- const AGENT_LABELS = {
55
- claude: "Claude Code → .claude/skills, CLAUDE.md",
56
- cursor: "Cursor → .agents/skills, .cursorrules",
57
- codex: "Codex → AGENTS.md",
58
- antigravity: "Antigravity → .agents/skills, .agents/AGENTS.md",
59
- };
60
-
61
- const BMAD_INSTALL = `npx bmad-method install`;
62
- const REPO_URL = "https://github.com/wiradigitalid/wdi-method";
63
- const HELP_SKILL = "wdi-help";
64
- const BMAD_REPO = "https://github.com/bmad-code-org/BMAD-METHOD";
65
- const WDI_REPO = "https://github.com/wiradigitalid/wdi-method";
66
-
67
- const RED = "\x1b[31m";
68
- const GREEN = "\x1b[32m";
69
- const DIM = "\x1b[2m";
70
- const RESET = "\x1b[0m";
71
-
72
- function die(msg) {
73
- console.error(`${RED}error:${RESET} ${msg}`);
74
- process.exit(1);
75
- }
76
-
77
- function ok(msg) {
78
- console.log(`${GREEN}ok${RESET} ${msg}`);
79
- }
80
-
81
- function note(msg) {
82
- console.log(`${DIM}·${RESET} ${msg}`);
83
- }
84
-
85
- function usage() {
86
- console.log(`wdi-method ${PKG.version}
87
-
88
- (no command) interactive TUI — detects install vs update
89
- install [dir] first install (TUI unless --yes)
90
- update [dir] update (TUI unless --yes)
91
- verify [dir]
92
- promote <live-dir> --rescue pull a method change back out of a consumer (not the normal flow)
93
-
94
- --yes non-interactive
95
- --agents a,b claude,cursor,codex,antigravity
96
- --product NAME written to index.yaml product.name
97
- --client NAME written to index.yaml product.client (optional)
98
- --doc-language <text> prose of working documents; free text, default English
99
- --doc-filename-language <text> slug part of document filenames; free text, default English
100
- --skip-bmad-check
101
-
102
- BMad first, then this package. ${WDI_REPO}
103
- `);
104
- }
105
-
106
- function parseArgs(argv) {
107
- const args = {
108
- cmd: null,
109
- dir: null,
110
- agents: null,
111
- skipBmad: false,
112
- rescue: false,
113
- yes: false,
114
- product: null,
115
- client: null,
116
- docLanguage: null,
117
- docFilenameLanguage: null,
118
- };
119
- const rest = argv.slice(2);
120
- if (rest[0] === "-h" || rest[0] === "--help") {
121
- usage();
122
- process.exit(0);
123
- }
124
- if (rest.length === 0) {
125
- args.cmd = "wizard";
126
- return args;
127
- }
128
- const first = rest[0];
129
- if (["install", "update", "verify", "promote"].includes(first)) {
130
- args.cmd = rest.shift();
131
- } else if (first.startsWith("-")) {
132
- args.cmd = "wizard";
133
- } else {
134
- args.cmd = "wizard";
135
- args.dir = rest.shift();
136
- }
137
- while (rest.length) {
138
- const t = rest.shift();
139
- if (t === "--skip-bmad-check") args.skipBmad = true;
140
- else if (t === "--rescue") args.rescue = true;
141
- else if (t === "--yes" || t === "-y") args.yes = true;
142
- else if (t === "--agents") {
143
- const raw = rest.shift();
144
- if (!raw) die("--agents needs a comma-separated list");
145
- args.agents = raw.split(",").map((s) => s.trim()).filter(Boolean);
146
- for (const a of args.agents) {
147
- if (!ALL_AGENTS.includes(a)) die(`unknown agent: ${a}`);
148
- }
149
- } else if (t === "--product") args.product = rest.shift();
150
- else if (t === "--client") args.client = rest.shift();
151
- else if (t === "--doc-language" || t === "--doc-filename-language") {
152
- // Free text: "English", "Bahasa Indonesia", "id" — a model reads it, so no list to match.
153
- const raw = (rest.shift() || "").trim();
154
- if (!raw) die(`${t} needs a value, for example: English`);
155
- if (t === "--doc-language") args.docLanguage = raw;
156
- else args.docFilenameLanguage = raw;
157
- }
158
- else if (t.startsWith("-")) die(`unknown flag: ${t}`);
159
- else if (!args.dir) args.dir = t;
160
- else die(`unexpected argument: ${t}`);
161
- }
162
- return args;
163
- }
164
-
165
- // Build output and editor droppings MUST NOT reach the kit. This repository is public, and a
166
- // __pycache__/*.pyc carries the ABSOLUTE PATH of the source it was compiled from — which means a
167
- // product name and a client folder leak into a public package through a file nobody wrote.
168
- // Found 2026-08-18 on the first real promote: inventory.cpython-314.pyc embedded the live repo path.
169
- const SKIP_DIRS = new Set(["__pycache__", "node_modules", ".git", ".pytest_cache", ".ruff_cache",
170
- ".mypy_cache", ".venv", "venv", "dist", "build", ".idea", ".vscode"]);
171
- const SKIP_FILE = /(\.pyc|\.pyo|\.pyd|\.log|\.tmp|\.swp|\.orig|\.rej|\.bak)$|^\.DS_Store$|^Thumbs\.db$/i;
172
-
173
- function walkFiles(dir) {
174
- const out = [];
175
- if (!fs.existsSync(dir)) return out;
176
- for (const entry of fs.readdirSync(dir, { withFileTypes: true })) {
177
- const p = path.join(dir, entry.name);
178
- if (entry.isDirectory()) {
179
- if (SKIP_DIRS.has(entry.name)) continue;
180
- out.push(...walkFiles(p));
181
- } else if (entry.isFile()) {
182
- if (SKIP_FILE.test(entry.name)) continue;
183
- out.push(p);
184
- }
185
- }
186
- return out;
187
- }
188
-
189
- function copyFile(src, dest) {
190
- fs.mkdirSync(path.dirname(dest), { recursive: true });
191
- fs.copyFileSync(src, dest);
192
- }
193
-
194
- function copyTree(src, dest, skipRel) {
195
- let n = 0;
196
- for (const p of walkFiles(src)) {
197
- const rel = posixRel(src, p);
198
- if (skipRel && skipRel(rel)) continue;
199
- copyFile(p, path.join(dest, path.relative(src, p)));
200
- n += 1;
201
- }
202
- return n;
203
- }
204
-
205
- function posixRel(from, to) {
206
- return path.relative(from, to).split(path.sep).join("/");
207
- }
208
-
209
- function bmadPresent(target) {
210
- const markers = [
211
- path.join(target, ".claude", "skills", "bmad-help", "SKILL.md"),
212
- path.join(target, "_bmad", "core", "config.yaml"),
213
- path.join(target, "_bmad", "_config", "manifest.yaml"),
214
- ];
215
- return markers.some((p) => fs.existsSync(p));
216
- }
217
-
218
- function wdiPresent(target) {
219
- return (
220
- fs.existsSync(path.join(target, ".control", "wdi-method.yaml")) ||
221
- fs.existsSync(path.join(target, ".constitution", "method", "README.md"))
222
- );
223
- }
224
-
225
- function dirNonEmpty(target) {
226
- if (!fs.existsSync(target)) return false;
227
- return fs.readdirSync(target).some((n) => n !== ".git" && n !== ".gitignore");
228
- }
229
-
230
- function readBmadVersion(target) {
231
- const manifest = path.join(target, "_bmad", "_config", "manifest.yaml");
232
- if (!fs.existsSync(manifest)) return "";
233
- const text = fs.readFileSync(manifest, "utf8");
234
- const m = text.match(/installation:\s*\n\s*version:\s*(\S+)/);
235
- return m ? m[1] : "";
236
- }
237
-
238
- function detectAgents(target) {
239
- const found = [];
240
- if (
241
- fs.existsSync(path.join(target, ".claude", "skills", "wdi-init", "SKILL.md")) ||
242
- fs.existsSync(path.join(target, ".claude", "skills", "bmad-help", "SKILL.md"))
243
- ) {
244
- found.push("claude");
245
- }
246
- if (
247
- fs.existsSync(path.join(target, ".cursorrules")) ||
248
- fs.existsSync(path.join(target, ".agents", "skills", "wdi-init", "SKILL.md"))
249
- ) {
250
- found.push("cursor");
251
- }
252
- if (fs.existsSync(path.join(target, "AGENTS.md"))) found.push("codex");
253
- if (fs.existsSync(path.join(target, ".agents", "AGENTS.md"))) found.push("antigravity");
254
- return found.length ? [...new Set(found)] : ALL_AGENTS.slice();
255
- }
256
-
257
- function gitHead(repo) {
258
- const r = spawnSync("git", ["-C", repo, "rev-parse", "--short", "HEAD"], {
259
- encoding: "utf8",
260
- });
261
- if (r.status !== 0) return "unknown";
262
- return r.stdout.trim();
263
- }
264
-
265
- function today() {
266
- return new Date().toISOString().slice(0, 10);
267
- }
268
-
269
- function requireKit() {
270
- if (!fs.existsSync(path.join(KIT, ".constitution"))) {
271
- die(`kit missing at ${KIT}`);
272
- }
273
- }
274
-
275
- function requireTarget(dir) {
276
- const target = path.resolve(dir || process.cwd());
277
- if (!fs.existsSync(target) || !fs.statSync(target).isDirectory()) {
278
- die(`target is not a directory: ${target}`);
279
- }
280
- return target;
281
- }
282
-
283
- function skillDests(target, agents) {
284
- const dests = [];
285
- if (agents.includes("claude")) dests.push(path.join(target, ".claude", "skills"));
286
- if (agents.includes("cursor") || agents.includes("antigravity")) {
287
- dests.push(path.join(target, ".agents", "skills"));
288
- }
289
- return dests;
290
- }
291
-
292
- function bmadMissingMessage() {
293
- return [
294
- "BMad Method is not installed in this repo. Install it first, then run this installer again.",
295
- "",
296
- ` ${BMAD_INSTALL}`,
297
- "",
298
- `Source: ${BMAD_REPO}`,
299
- "In the BMad installer, pick the same agents (Claude Code, Cursor, …).",
300
- ].join("\n");
301
- }
302
-
303
- // The product's custom room. Three properties, and all three MUST hold together:
304
- // install/update seeds its content ONLY when absent — never written again after that
305
- // promote SKIPS it entirely, so a product's own rules can never reach the public repo
306
- // agent loads it like any other guide, so it BINDS
307
- // The deliberate consequence: this room's README is authored in the package and never comes home
308
- // through promote.
309
- const PROJECT_ROOM = "project/";
310
-
311
- // 0.5.0 moved `.constitution/` to exactly two folders: `method/` is the method's and is overwritten,
312
- // `project/` is the product's and is never touched. Before it, generic and product-owned files sat
313
- // side by side at the root, `codebase/` was a third product-owned room nobody had written down, and
314
- // `constitution.md` was ONE file holding both — which is why `update` had to keep the whole thing and
315
- // the product never received a fixed generic Article.
316
- //
317
- // Without this migration an installed repo would end up carrying BOTH layouts: the kit writes the new
318
- // paths while the old files stay behind, and an agent reading `AGENTS.md` routing would find two
319
- // copies of most guides and no way to tell which binds.
320
- const OLD_ROOT_GUIDES = ["README", "language-guide", "method-glossary", "repo-guide", "structure-guide"];
321
- const OLD_WHY = ["README", "artifact-map", "portability", "rationale"];
322
- const OLD_CODEBASE = ["stack", "conventions", "brownfield"];
323
-
324
- function mv(from, to) {
325
- fs.mkdirSync(path.dirname(to), { recursive: true });
326
- fs.renameSync(from, to);
327
- }
328
-
329
- /** Article numbers that belong to the method half. The product keeps 1, 2, and 5. */
330
- const METHOD_ARTICLES = [3, 4, 6, 7];
331
-
332
- /**
333
- * Cut the method's articles out of a product's constitution.md, and repoint its relative links.
334
- *
335
- * Returns {cut, kept, relinked}, or null when the file does not look like a constitution at all —
336
- * in which case it is left ALONE rather than guessed at.
337
- *
338
- * 0.5.0 moved the file whole and printed "delete Articles 3, 4, 6, 7 yourself", on the grounds that
339
- * no script can tell an edited copy from the original. That reasoning was wrong in the way that
340
- * matters: the split does not need to know whether a section was edited, only which article numbers
341
- * are the method's — and the file states them in its own headings. Leaving it whole left every
342
- * migrated repo carrying those articles in TWO files, one of them frozen and drifting, plus relative
343
- * links that no longer resolve one level down. It is all in git, so cutting is reversible; not
344
- * cutting is what nobody notices.
345
- */
346
- function splitProductConstitution(file) {
347
- const raw = fs.readFileSync(file, "utf8");
348
- const crlf = raw.includes("\r\n");
349
- const text = crlf ? raw.replaceAll("\r\n", "\n") : raw;
350
- const marks = [...text.matchAll(/^## Article (\d+)\b.*$/gm)];
351
- if (marks.length < 2) return null; // not the shape we know; do not touch it
352
-
353
- const kept = [];
354
- const cut = [];
355
- let out = text.slice(0, marks[0].index);
356
- for (let i = 0; i < marks.length; i += 1) {
357
- const n = Number(marks[i][1]);
358
- const end = i + 1 < marks.length ? marks[i + 1].index : text.length;
359
- if (METHOD_ARTICLES.includes(n)) cut.push(n);
360
- else {
361
- kept.push(n);
362
- out += text.slice(marks[i].index, end);
363
- }
364
- }
365
- if (!cut.length) return { cut, kept, relinked: 0 };
366
-
367
- // The file sits one level deeper than it did, and its former siblings moved into method/. A link
368
- // left as `repo-guide.md` now resolves to .constitution/project/repo-guide.md, which does not exist.
369
- let relinked = 0;
370
- const bump = (re, to) => {
371
- out = out.replace(re, (m, ...rest) => {
372
- relinked += 1;
373
- return typeof to === "function" ? to(m, ...rest) : to + m;
374
- });
375
- };
376
- for (const name of ["repo-guide.md", "structure-guide.md", "language-guide.md",
377
- "method-glossary.md"]) {
378
- bump(new RegExp(`(?<![\\w./-])${name.replace(".", "\\.")}`, "g"), "../method/");
379
- }
380
- bump(/(?<![\w./-])document\//g, "../method/");
381
- bump(/(?<![\w./-])codebase\/([a-z]+)-guide\.md/g, (_m, kind) => `codebase-${kind}-guide.md`);
382
- out = out.replaceAll("../method/../method/", "../method/");
383
-
384
- const banner = [
385
- "",
386
- `> **Articles ${cut.join(", ")} were removed from this file on migration to the two-folder layout.**`,
387
- "> They are the method's and live in [`../method/constitution.md`](../method/constitution.md), which",
388
- `> \`update\` replaces. Only Articles ${kept.join(", ")} are yours. The removed text is in git.`,
389
- "",
390
- ].join("\n");
391
- const firstArticle = out.search(/^## Article /m);
392
- out = firstArticle === -1
393
- ? out + banner
394
- : out.slice(0, firstArticle) + banner.trimStart() + "\n" + out.slice(firstArticle);
395
-
396
- fs.writeFileSync(file, crlf ? out.replaceAll("\n", "\r\n") : out, "utf8");
397
- return { cut, kept, relinked };
398
- }
399
-
400
- function migrateToTwoFolders(target) {
401
- const c = path.join(target, ".constitution");
402
- if (!fs.existsSync(c)) return false; // a first install has nothing to migrate
403
- const at = (...p) => path.join(c, ...p);
404
- // The old layout is identified by `document/` at the ROOT in the new layout that folder only ever
405
- // exists under `method/`. Checking a loose guide instead would misfire on a repo that added one.
406
- if (!fs.existsSync(at("document")) && !fs.existsSync(at("codebase"))
407
- && !fs.existsSync(at("constitution.md")) && !fs.existsSync(at("scripts"))) {
408
- return false;
409
- }
410
- note("pre-0.5.0 .constitution/ found — migrating to method/ + project/");
411
-
412
- // 1. The four Reference files go one level deeper. This MUST run before the kit is written, or the
413
- // kit's own why/ files land while the old copies still sit at method/ root.
414
- for (const name of OLD_WHY) {
415
- const from = at("method", `${name}.md`);
416
- if (fs.existsSync(from)) {
417
- mv(from, at("method", "why", `${name}.md`));
418
- note(` moved method/${name}.md method/why/${name}.md`);
419
- }
420
- }
421
- // 2. and 3. whole folders
422
- for (const dir of ["document", "scripts"]) {
423
- if (fs.existsSync(at(dir)) && !fs.existsSync(at("method", dir))) {
424
- mv(at(dir), at("method", dir));
425
- note(` moved ${dir}/ method/${dir}/`);
426
- }
427
- }
428
- // 4. the loose generic guides
429
- for (const name of OLD_ROOT_GUIDES) {
430
- const from = at(`${name}.md`);
431
- if (fs.existsSync(from)) {
432
- mv(from, at("method", `${name}.md`));
433
- note(` moved ${name}.md → method/${name}.md`);
434
- }
435
- }
436
- // 5. codebase/ was a product-owned room all along — it becomes flat files in the room that says so
437
- for (const name of OLD_CODEBASE) {
438
- const from = at("codebase", `${name}-guide.md`);
439
- if (fs.existsSync(from)) {
440
- mv(from, at("project", `codebase-${name}-guide.md`));
441
- note(` moved codebase/${name}-guide.md → project/codebase-${name}-guide.md`);
442
- }
443
- }
444
- if (fs.existsSync(at("codebase"))) {
445
- const left = fs.readdirSync(at("codebase"));
446
- if (!left.length) fs.rmdirSync(at("codebase"));
447
- else note(` codebase/ still holds ${left.join(", ")} — left in place, move them yourself`);
448
- }
449
- // 6. The product's constitution.md moves WHOLE into the room, so its Articles 1, 2, and 5 survive
450
- // exactly as written. The generic half then arrives fresh at method/constitution.md.
451
- let split = null;
452
- if (fs.existsSync(at("constitution.md")) && !fs.existsSync(at("project", "constitution.md"))) {
453
- mv(at("constitution.md"), at("project", "constitution.md"));
454
- note(" moved constitution.md project/constitution.md");
455
- split = splitProductConstitution(at("project", "constitution.md"));
456
- if (split && split.cut.length) {
457
- note(` kept Articles ${split.kept.join(", ")}, removed ${split.cut.join(", ")} `
458
- + "(the method's they arrive in method/constitution.md)");
459
- if (split.relinked) note(` repointed ${split.relinked} relative links one level up`);
460
- } else if (split === null) {
461
- note(" it does not carry `## Article N` headings, so it was moved but NOT split yours to check");
462
- }
463
- }
464
- // Anything else loose at the root is a file this product ADDED. It is NOT moved: it may be routed
465
- // from AGENTS.md by its current path, and guessing a destination would break that silently.
466
- const stray = fs.existsSync(c)
467
- ? fs.readdirSync(c, { withFileTypes: true })
468
- .filter((e) => e.isFile() && e.name.endsWith(".md"))
469
- .map((e) => e.name)
470
- : [];
471
- if (stray.length) {
472
- note(` left at .constitution/ root, yours to place: ${stray.join(", ")}`);
473
- note(" a file you added belongs in project/ but moving it would break any pointer that");
474
- note(" names its current path, so the choice is yours. repo-guide.md states the rule.");
475
- }
476
- return split;
477
- }
478
-
479
- function syncConstitution(target) {
480
- const kitConst = path.join(KIT, ".constitution");
481
- const destConst = path.join(target, ".constitution");
482
- fs.mkdirSync(destConst, { recursive: true });
483
- let written = 0;
484
- let skipped = 0;
485
- for (const file of walkFiles(kitConst)) {
486
- const rel = posixRel(kitConst, file);
487
- const dest = path.join(destConst, rel);
488
- // ONE rule for everything the product owns, because 0.5.0 put all of it in one folder. Before
489
- // that this loop had three branches the mixed constitution.md kept whole, `codebase/` gated on
490
- // `status: Accepted` (which is what silently destroyed a half-written guide), and the room and
491
- // the three disagreed about when a file was the product's. Seeded when absent, never written
492
- // again: the same rule as the language policy.
493
- if (rel.startsWith(PROJECT_ROOM) && fs.existsSync(dest)) {
494
- skipped += 1;
495
- note(`keep ${rel} (yours the project room)`);
496
- continue;
497
- }
498
- copyFile(file, dest);
499
- written += 1;
500
- }
501
- return { written, skipped };
502
- }
503
-
504
- function syncSkills(target, agents) {
505
- let n = 0;
506
- const dests = skillDests(target, agents);
507
- if (dests.length === 0) {
508
- note("no skill destinations for selected agents — AGENTS.md still applies");
509
- return 0;
510
- }
511
- for (const name of WDI_SKILLS) {
512
- const src = path.join(KIT, "skills", name);
513
- if (!fs.existsSync(src)) die(`kit missing skill ${name}`);
514
- for (const root of dests) {
515
- const dest = path.join(root, name);
516
- fs.rmSync(dest, { recursive: true, force: true });
517
- n += copyTree(src, dest);
518
- }
519
- }
520
- const removed = pruneRetiredSkills(dests);
521
- return { files: n, removed };
522
- }
523
-
524
- // A wrapper the method RETIRED is worse than a wrapper missing: the folder is still there, its
525
- // SKILL.md still reads like an instruction, and an agent will invoke it — while the guide it points
526
- // at is gone. Renaming five wrappers (wdi-apply, wdi-analysis, wdi-structure, ) left exactly that
527
- // in every repo installed before the rename, because update only ever touched the names it knows.
528
- //
529
- // `wdi-` is the method's namespace, so a `wdi-*` folder carrying a SKILL.md and not in WDI_SKILLS is
530
- // ours and retired. Each removal is PRINTED: silent deletion in someone else's repo is not a fix.
531
- function pruneRetiredSkills(dests) {
532
- let removed = 0;
533
- const keep = new Set(WDI_SKILLS);
534
- for (const root of dests) {
535
- if (!fs.existsSync(root)) continue;
536
- for (const entry of fs.readdirSync(root, { withFileTypes: true })) {
537
- if (!entry.isDirectory() || !entry.name.startsWith("wdi-") || keep.has(entry.name)) continue;
538
- const dir = path.join(root, entry.name);
539
- if (!fs.existsSync(path.join(dir, "SKILL.md"))) {
540
- note(`kept ${entry.name} (no SKILL.md — not one of ours)`);
541
- continue;
542
- }
543
- fs.rmSync(dir, { recursive: true, force: true });
544
- note(`removed retired skill ${entry.name}`);
545
- removed += 1;
546
- }
547
- }
548
- return removed;
549
- }
550
-
551
- // `promote` scrubs a product's initiative slug out of bmad-prd.toml before publishing, which is right.
552
- // Writing the scrubbed PLACEHOLDER back into a product repo is not: the first real install replaced a
553
- // live `run_folder_pattern = "some-real-slug"` with `FILL-initiative-slug`, and nothing said so. A value
554
- // the product already chose is not the installer's to overwrite — same rule as the custom room and the
555
- // language policy.
556
- const PLACEHOLDER_SLUG = "FILL-initiative-slug";
557
- const RUN_FOLDER_LINE = /^(\s*run_folder_pattern\s*=\s*)(".*?"|'.*?')/m;
558
-
559
- // The slug appears MORE THAN ONCE — bmad-prd.toml carries it in `run_folder_pattern` and again inside a
560
- // memlog path, and the file itself says the two lines MUST change together. The first version of this
561
- // function restored only the first line and so produced exactly the inconsistency that file forbids.
562
- // So: read the product's slug once, then put it back everywhere the placeholder appears.
563
- function keepProductSlug(incoming, existing) {
564
- const mineNow = existing.match(RUN_FOLDER_LINE);
565
- if (!mineNow) return null;
566
- const slug = mineNow[2].slice(1, -1);
567
- if (!slug || slug === PLACEHOLDER_SLUG) return null;
568
- if (!incoming.includes(PLACEHOLDER_SLUG)) return null;
569
- // Only where the slug is a VALUE: the quoted setting, and the memlog path built from it. A bare
570
- // mention inside a comment stays the placeholder — that sentence explains the pattern, and rewriting
571
- // it would turn a generic explanation into a statement about one initiative.
572
- return incoming
573
- .replaceAll(`"${PLACEHOLDER_SLUG}"`, `"${slug}"`)
574
- .replaceAll(`prd-${PLACEHOLDER_SLUG}`, `prd-${slug}`);
575
- }
576
-
577
- function syncTomls(target) {
578
- const src = path.join(KIT, "assets", "bmad-custom");
579
- const dest = path.join(target, "_bmad", "custom");
580
- fs.mkdirSync(dest, { recursive: true });
581
- let n = 0;
582
- let slugsKept = 0;
583
- for (const file of walkFiles(src)) {
584
- if (!file.endsWith(".toml") || file.endsWith(".user.toml")) continue;
585
- const to = path.join(dest, path.basename(file));
586
- if (fs.existsSync(to)) {
587
- const merged = keepProductSlug(fs.readFileSync(file, "utf8"), fs.readFileSync(to, "utf8"));
588
- if (merged !== null) {
589
- fs.writeFileSync(to, merged);
590
- note(`kept run_folder_pattern in ${path.basename(file)}`);
591
- slugsKept += 1;
592
- n += 1;
593
- continue;
594
- }
595
- }
596
- copyFile(file, to);
597
- n += 1;
598
- }
599
- return { files: n, slugsKept };
600
- }
601
-
602
- function seedControlIfMissing(target) {
603
- const control = path.join(target, ".control");
604
- if (fs.existsSync(control)) {
605
- note(".control/ already present — left untouched");
606
- return;
607
- }
608
- if (!fs.existsSync(SCAFFOLD)) die(`scaffold missing: ${SCAFFOLD}`);
609
- const n = copyTree(SCAFFOLD, control);
610
- ok(`seeded empty .control/ (${n} files)`);
611
- }
612
-
613
- // On a FIRST install these folders are the corpus taking shape. On an UPDATE their absence means
614
- // somebody removed them on purpose — `.work/` and `_bmad-output/prior-knowledge/` are exactly the two a
615
- // product retires once its migration is done, and one repo retired them through an applied decision.
616
- // Recreating them then is an installer overruling a decision it cannot read. Seed once, never resurrect.
617
- function seedEmptyLayers(target, { first }) {
618
- const always = [".what", path.join(".how", "_platform")];
619
- const firstOnly = [".work", path.join("_bmad-output", "prior-knowledge")];
620
- for (const rel of first ? [...always, ...firstOnly] : always) {
621
- const dest = path.join(target, rel);
622
- if (!fs.existsSync(dest)) {
623
- fs.mkdirSync(dest, { recursive: true });
624
- note(`created ${rel.replaceAll(path.sep, "/")}/`);
625
- }
626
- }
627
- if (!first) {
628
- for (const rel of firstOnly) {
629
- if (!fs.existsSync(path.join(target, rel))) {
630
- note(`left ${rel.replaceAll(path.sep, "/")}/ absent — a product retires it, not the installer`);
631
- }
632
- }
633
- }
634
- }
635
-
636
- function writeStamp(target) {
637
- const control = path.join(target, ".control");
638
- if (!fs.existsSync(control)) return;
639
- const stamp = [
640
- "# Written by wdi-method install/update. A trace, not a lockfile.",
641
- `wdi_method: ${PKG.version}`,
642
- `bmad_method: ${readBmadVersion(target) || '""'}`,
643
- `installed_at: ${today()}`,
644
- "",
645
- ].join("\n");
646
- fs.writeFileSync(path.join(control, "wdi-method.yaml"), stamp, "utf8");
647
- note("stamped .control/wdi-method.yaml");
648
- }
649
-
650
- function setProductIdentity(target, { name, client }) {
651
- if (!name || identityIsPlaceholder(name)) return;
652
- const file = path.join(target, ".control", "registry", "index.yaml");
653
- if (!fs.existsSync(file)) return;
654
- const next = writeProductIdentity(fs.readFileSync(file, "utf8"), {
655
- name,
656
- client: client ?? "",
657
- });
658
- fs.writeFileSync(file, next.endsWith("\n") ? next : `${next}\n`);
659
- note(`product.name = ${name}`);
660
- }
661
-
662
- // The document language belongs to the PRODUCT, so update MUST NOT overwrite it. It is written only
663
- // when absent same as the custom room, and for the same reason: a setting somebody already chose
664
- // is not the installer's to change behind their back.
665
- function setLanguagePolicy(target, { docLanguage, docFilenameLanguage, chosen }) {
666
- const file = path.join(target, ".control", "registry", "index.yaml");
667
- if (!fs.existsSync(file)) return;
668
- const text = fs.readFileSync(file, "utf8");
669
- const existing = readLanguagePolicy(text);
670
- // `chosen` means somebody actually answered — in the TUI, or through an explicit flag. Only then
671
- // does the answer take effect. Without it the incoming value is just a default, and a default
672
- // MUST NOT overwrite a choice somebody already made.
673
- if (!chosen && existing.docLanguage && existing.docFilenameLanguage) {
674
- note(`kept policy.doc_language = ${existing.docLanguage}, ` +
675
- `doc_filename_language = ${existing.docFilenameLanguage}`);
676
- return;
677
- }
678
- const next = writeLanguagePolicy(text, {
679
- docLanguage: docLanguage || existing.docLanguage || DEFAULT_DOC_LANGUAGE,
680
- docFilenameLanguage:
681
- docFilenameLanguage || existing.docFilenameLanguage || DEFAULT_DOC_LANGUAGE,
682
- });
683
- fs.writeFileSync(file, next.endsWith("\n") ? next : `${next}\n`);
684
- const after = readLanguagePolicy(next);
685
- note(`policy.doc_language = ${after.docLanguage}, ` +
686
- `doc_filename_language = ${after.docFilenameLanguage}`);
687
- }
688
-
689
- // Read BEFORE writeStamp overwrites it. Without this there is no version transition to print, and
690
- // an "updated" with no from-to tells the reader nothing they can use.
691
- function readStampVersion(target) {
692
- const file = path.join(target, ".control", "wdi-method.yaml");
693
- if (!fs.existsSync(file)) return "";
694
- const m = fs.readFileSync(file, "utf8").match(/^wdi_method:\s*"?([^"\s]+)"?/m);
695
- return m ? m[1] : "";
696
- }
697
-
698
- function readIndexPolicy(target) {
699
- const file = path.join(target, ".control", "registry", "index.yaml");
700
- if (!fs.existsSync(file)) return { docLanguage: "", docFilenameLanguage: "" };
701
- return readLanguagePolicy(fs.readFileSync(file, "utf8"));
702
- }
703
-
704
- function readIndexIdentity(target) {
705
- const file = path.join(target, ".control", "registry", "index.yaml");
706
- if (!fs.existsSync(file)) return { name: "", client: "" };
707
- return readProductIdentity(fs.readFileSync(file, "utf8"));
708
- }
709
-
710
- function upsertAgentFiles(target, agents, productName) {
711
- const template = fs.readFileSync(path.join(OVERLAY, "AGENTS.md"), "utf8");
712
- const agentsFile = path.join(target, "AGENTS.md");
713
- let next;
714
- if (!fs.existsSync(agentsFile)) {
715
- next = fillProductTitle(template, productName || "{product}");
716
- ok("AGENTS.md created — rewrite ## Code for this product");
717
- } else {
718
- next = upsertMethodBlock(fs.readFileSync(agentsFile, "utf8"), template);
719
- note("AGENTS.md method block refreshed; product sections kept");
720
- }
721
- if (!next.endsWith("\n")) next += "\n";
722
- fs.writeFileSync(agentsFile, next);
723
-
724
- const mirrors = [];
725
- if (agents.includes("cursor")) mirrors.push(path.join(target, ".cursorrules"));
726
- if (agents.includes("cursor") || agents.includes("antigravity")) {
727
- mirrors.push(path.join(target, ".agents", "AGENTS.md"));
728
- }
729
- for (const mirror of mirrors) {
730
- fs.mkdirSync(path.dirname(mirror), { recursive: true });
731
- if (fs.existsSync(mirror)) {
732
- const patched = upsertMethodBlock(fs.readFileSync(mirror, "utf8"), template);
733
- fs.writeFileSync(mirror, patched.endsWith("\n") ? patched : `${patched}\n`);
734
- note(`method block refreshed in ${posixRel(target, mirror)}`);
735
- } else {
736
- fs.writeFileSync(mirror, next);
737
- note(`created ${posixRel(target, mirror)}`);
738
- }
739
- }
740
-
741
- if (agents.includes("claude")) {
742
- const claude = path.join(target, "CLAUDE.md");
743
- if (!fs.existsSync(claude)) {
744
- fs.writeFileSync(claude, "@AGENTS.md\n");
745
- note("CLAUDE.md created as @AGENTS.md");
746
- }
747
- }
748
- }
749
-
750
- // What a run MUST leave a reader able to answer: which version replaced which, what was written, what
751
- // was KEPT, and what to do next. The third is the one usually missing, and it is the one that decides
752
- // whether somebody trusts running this over a repo they have already put work into.
753
- function summaryLine(label, value) {
754
- console.log(` ${DIM}${label.padEnd(11)}${RESET}${value}`);
755
- }
756
-
757
- function printSummary(target, agents, { first, was, written, skipped, skills, tomls }) {
758
- const now = PKG.version;
759
- const version = first
760
- ? `${now} — first install`
761
- : was && was !== now
762
- ? `${was} ${DIM}→${RESET} ${now}`
763
- : `${now} ${DIM}(unchanged)${RESET}`;
764
- const bmad = readBmadVersion(target);
765
-
766
- const kept = [];
767
- if (skipped) kept.push(`${skipped} constitution file${skipped === 1 ? "" : "s"}`);
768
- if (tomls.slugsKept) kept.push(`${tomls.slugsKept} initiative slug${tomls.slugsKept === 1 ? "" : "s"}`);
769
- // On a first install the language was just CHOSEN, not kept saying "kept" there reads as if the
770
- // installer had found something it decided to leave alone, which is the opposite of what happened.
771
- const policy = readIndexPolicy(target);
772
- if (policy.docLanguage && !first) kept.push(`language (${policy.docLanguage})`);
773
- if (fs.existsSync(path.join(target, ".constitution", "project"))) kept.push(".constitution/project/");
774
-
775
- console.log("");
776
- console.log(`${DIM}────${RESET} WDI Method ${DIM}${"─".repeat(46)}${RESET}`);
777
- summaryLine("version", version);
778
- if (bmad) summaryLine("bmad", bmad);
779
- summaryLine("target", target);
780
- console.log("");
781
- summaryLine("written", `${written} constitution · ${skills.files} skill files · ${tomls.files} bmad overrides`);
782
- if (kept.length) summaryLine("kept", kept.join(" · "));
783
- if (skills.removed) {
784
- summaryLine("removed", `${skills.removed} retired wrapper${skills.removed === 1 ? "" : "s"}`);
785
- }
786
- if (first && policy.docLanguage) {
787
- summaryLine("language", `${policy.docLanguage} · filenames ${policy.docFilenameLanguage}`);
788
- }
789
- summaryLine("agents", agents.join(", ") || "none");
790
- console.log("");
791
- summaryLine("next", `invoke the ${HELP_SKILL} skill and ask what to do`);
792
- summaryLine("", REPO_URL);
793
- console.log(`${DIM}${"─".repeat(62)}${RESET}`);
794
- }
795
-
796
- function printNextSteps({ first, productSet }) {
797
- console.log("");
798
- console.log(first ? "After install:" : "After update:");
799
- if (first) {
800
- if (!productSet) {
801
- console.log(" 1. Fill product.name (and product.client if there is one) in .control/registry/index.yaml.");
802
- } else {
803
- console.log(" 1. product.name is set. G1 confirms it in the brief.");
804
- }
805
- console.log(" 2. Rewrite .constitution/constitution.md Articles 2 and 5 for this product.");
806
- console.log(" Article 1 cites index.yaml — do not become a second source for the name.");
807
- console.log(" 3. Write ## Code in AGENTS.md (where the app lives). Leave the BEGIN:wdi-method block alone.");
808
- console.log(" 4. Run the wdi-init skill, intent setup.");
809
- console.log(" 5. Sort the documents you already have. Do not move any of them in this step.");
810
- console.log("");
811
- console.log("Next update:");
812
- console.log(" npx wdi-method");
813
- console.log(" (the TUI offers the update) or: npx wdi-method update --yes");
814
- } else {
815
- console.log(" 1. The <!-- BEGIN:wdi-method --> block in AGENTS.md was replaced. Read the diff.");
816
- console.log(" 2. constitution.md Articles 1-2-5, ## Code, and *.user.toml were not overwritten.");
817
- console.log(" 3. If BMad has new skills, install those first, then run this update again.");
818
- }
819
- }
820
-
821
- function apply(target, agents,
822
- { first, product, client, docLanguage, docFilenameLanguage, languageChosen }) {
823
- requireKit();
824
- const was = readStampVersion(target);
825
- // MUST run before the kit is written: it moves the product's files out of the way of paths the kit
826
- // is about to occupy. Running it after would leave two copies of most guides.
827
- const splitConstitution = migrateToTwoFolders(target);
828
- const { written, skipped } = syncConstitution(target);
829
- note(`constitution wrote ${written}, kept ${skipped}`);
830
- // A migrated repo also carries derived output stamped against the OLD layout: .control/generated/*
831
- // still names the pre-0.5.0 script path, and the two structure maps still draw the old tree. The
832
- // installer MUST NOT write either — one is generated, the other is re-derived by a skill — so it
833
- // says so instead of leaving them to be found by whoever trusts them next.
834
- if (splitConstitution) {
835
- note(" derived output still describes the OLD layout, and neither is mine to write:");
836
- note(" uv run .constitution/method/scripts/validate.py --generate → .control/generated/");
837
- note(" then the wdi-init skill, intent `structure` → the two structure maps");
838
- }
839
- const skills = syncSkills(target, agents);
840
- note(`skills ${skills.files} files`);
841
- const tomls = syncTomls(target);
842
- note(`bmad custom ${tomls.files} toml _bmad/custom/`);
843
- if (first) seedControlIfMissing(target);
844
- seedEmptyLayers(target, { first });
845
- setProductIdentity(target, { name: product, client });
846
- setLanguagePolicy(target, { docLanguage, docFilenameLanguage, chosen: languageChosen });
847
- upsertAgentFiles(target, agents, product);
848
- writeStamp(target);
849
- printSummary(target, agents, { first, was, written, skipped, skills, tomls });
850
- printNextSteps({
851
- first,
852
- productSet: Boolean(product) && !identityIsPlaceholder(product),
853
- });
854
- }
855
-
856
- function verify(target, agents) {
857
- requireKit();
858
- const missing = [];
859
- const kitConst = path.join(KIT, ".constitution");
860
- for (const file of walkFiles(kitConst)) {
861
- const rel = posixRel(kitConst, file);
862
- const dest = path.join(target, ".constitution", rel);
863
- if (!fs.existsSync(dest)) missing.push(`.constitution/${rel}`);
864
- }
865
- for (const name of WDI_SKILLS) {
866
- for (const root of skillDests(target, agents)) {
867
- const dest = path.join(root, name, "SKILL.md");
868
- if (!fs.existsSync(dest)) missing.push(posixRel(target, dest));
869
- }
870
- }
871
- const custom = path.join(KIT, "assets", "bmad-custom");
872
- for (const file of walkFiles(custom)) {
873
- if (!file.endsWith(".toml")) continue;
874
- const dest = path.join(target, "_bmad", "custom", path.basename(file));
875
- if (!fs.existsSync(dest)) missing.push(`_bmad/custom/${path.basename(file)}`);
876
- }
877
- if (fs.existsSync(path.join(target, ".control"))) {
878
- for (const file of walkFiles(SCAFFOLD)) {
879
- const rel = posixRel(SCAFFOLD, file);
880
- const dest = path.join(target, ".control", rel);
881
- if (!fs.existsSync(dest)) missing.push(`.control/${rel}`);
882
- }
883
- } else {
884
- missing.push(".control/ (folder missing first install should have seeded it)");
885
- }
886
- for (const required of ["AGENTS.md", path.join(".constitution", "constitution.md")]) {
887
- if (!fs.existsSync(path.join(target, required))) missing.push(required.replaceAll(path.sep, "/"));
888
- }
889
- if (missing.length) {
890
- console.error(`${RED}missing ${missing.length}${RESET}`);
891
- for (const m of missing) console.error(` ${m}`);
892
- process.exit(1);
893
- }
894
- ok(`method files present in ${target}`);
895
- note("extra product files are expected and were not checked");
896
- }
897
-
898
- function scrubPrdToml(file) {
899
- const raw = fs.readFileSync(file, "utf8");
900
- const m = raw.match(/run_folder_pattern\s*=\s*"([^"]+)"/);
901
- if (!m) return;
902
- const slug = m[1];
903
- if (GENERIC_FOLDER_PATTERNS.has(slug)) return;
904
- fs.writeFileSync(file, raw.split(slug).join(PRD_SLUG_PLACEHOLDER), "utf8");
905
- note("bmad-prd.toml initiative slug scrubbed to placeholder");
906
- }
907
-
908
- function promote(live) {
909
- live = path.resolve(live);
910
- if (!fs.existsSync(path.join(live, ".constitution"))) {
911
- die(`${live} has no .constitution/ is this a method-carrying repo?`);
912
- }
913
- // EVERY file in the room is authored in the package and MUST survive the rmSync below — the room's
914
- // README, the generic Articles 1-2-5, and the three empty codebase templates. Read here, not
915
- // after: the first version of this preserved only README.md and read it AFTER the kit was deleted,
916
- // so it was always null and the file vanished on every promote. Two tests cover it now.
917
- const roomKit = path.join(KIT, ".constitution", PROJECT_ROOM);
918
- const roomKept = fs.existsSync(roomKit)
919
- ? Object.fromEntries(walkFiles(roomKit).map((f) => [posixRel(roomKit, f), fs.readFileSync(f, "utf8")]))
920
- : {};
921
-
922
- fs.rmSync(KIT, { recursive: true, force: true });
923
- fs.mkdirSync(KIT, { recursive: true });
924
-
925
- // ONE skip, because 0.5.0 put everything the product owns in one folder. It covers the codebase
926
- // guides too, which used to need a rule of their own: promoting a filled-in stack guide would leak
927
- // one product's conventions possibly written in its own `doc_language` — into a public package.
928
- const nConst = copyTree(path.join(live, ".constitution"), path.join(KIT, ".constitution"),
929
- (rel) => rel.startsWith(PROJECT_ROOM));
930
- note(`constitution ${nConst} files (${PROJECT_ROOM} skipped — it is the product's)`);
931
- for (const [rel, text] of Object.entries(roomKept)) {
932
- const dest = path.join(roomKit, rel);
933
- fs.mkdirSync(path.dirname(dest), { recursive: true });
934
- fs.writeFileSync(dest, text, "utf8");
935
- }
936
- if (Object.keys(roomKept).length) {
937
- note(`${PROJECT_ROOM} restored from the package (${Object.keys(roomKept).length} files) — `
938
- + "promote never carries the room home");
939
- }
940
-
941
- let copiedSkills = 0;
942
- const skillsSrc = path.join(live, ".claude", "skills");
943
- for (const name of WDI_SKILLS) {
944
- const src = path.join(skillsSrc, name);
945
- if (!fs.existsSync(src)) die(`skill missing in live repo: ${src}`);
946
- copiedSkills += copyTree(src, path.join(KIT, "skills", name));
947
- }
948
- note(`skills ${copiedSkills} files (${WDI_SKILLS.length} wrappers)`);
949
-
950
- const customSrc = path.join(live, "_bmad", "custom");
951
- const customDst = path.join(KIT, "assets", "bmad-custom");
952
- fs.mkdirSync(customDst, { recursive: true });
953
- let tomls = 0;
954
- if (fs.existsSync(customSrc)) {
955
- for (const file of walkFiles(customSrc)) {
956
- if (!file.endsWith(".toml") || file.endsWith(".user.toml")) continue;
957
- copyFile(file, path.join(customDst, path.basename(file)));
958
- tomls += 1;
959
- }
960
- }
961
- const prd = path.join(customDst, "bmad-prd.toml");
962
- if (fs.existsSync(prd)) scrubPrdToml(prd);
963
- note(`bmad custom ${tomls} toml`);
964
-
965
- const replacements = {
966
- "constitution.md": path.join(KIT, ".constitution", "method", "constitution.md"),
967
- "portability.md": path.join(KIT, ".constitution", "method", "why", "portability.md"),
968
- "repo-guide.md": path.join(KIT, ".constitution", "method", "repo-guide.md"),
969
- "README.md": path.join(KIT, ".constitution", "method", "README.md"),
970
- };
971
- for (const [name, dest] of Object.entries(replacements)) {
972
- const src = path.join(OVERLAY, name);
973
- if (fs.existsSync(src)) {
974
- copyFile(src, dest);
975
- note(`${name} replaced with kit overlay`);
976
- }
977
- }
978
-
979
- const source = [
980
- `date: ${today()}`,
981
- `commit: ${gitHead(live)}`,
982
- "kind: working copy that currently carries a newer method",
983
- "note: the repo path and product name MUST NOT be recorded here",
984
- "",
985
- ].join("\n");
986
- fs.writeFileSync(path.join(ROOT, "SOURCE"), source, "utf8");
987
- ok(`SOURCE stamped ${today()} @ ${gitHead(live)}`);
988
- ok(`promoted into ${KIT}`);
989
- }
990
-
991
- function cancelIf(value) {
992
- if (p.isCancel(value)) {
993
- p.cancel("Cancelled.");
994
- process.exit(0);
995
- }
996
- return value;
997
- }
998
-
999
- async function runWizard(pre) {
1000
- p.intro(`WDI Method ${PKG.version}`);
1001
-
1002
- const dirValue = cancelIf(
1003
- await p.text({
1004
- message: "Target repo (the product folder)",
1005
- placeholder: process.cwd(),
1006
- defaultValue: pre.dir || process.cwd(),
1007
- }),
1008
- );
1009
- const target = path.resolve(String(dirValue).trim() || process.cwd());
1010
-
1011
- if (!fs.existsSync(target)) {
1012
- const create = cancelIf(
1013
- await p.confirm({ message: `${target} does not exist. Create it?`, initialValue: true }),
1014
- );
1015
- if (!create) {
1016
- p.cancel("No target folder.");
1017
- process.exit(1);
1018
- }
1019
- fs.mkdirSync(target, { recursive: true });
1020
- }
1021
-
1022
- const hasBmad = bmadPresent(target);
1023
- const hasWdi = wdiPresent(target);
1024
- const nonempty = dirNonEmpty(target);
1025
-
1026
- const facts = [
1027
- hasBmad
1028
- ? `BMad Method: installed${readBmadVersion(target) ? ` (${readBmadVersion(target)})` : ""}`
1029
- : "BMad Method: not installed",
1030
- hasWdi ? "WDI Method: already present — the installer will offer an update" : "WDI Method: not present",
1031
- nonempty ? "Folder is not empty (normal for a product repo already under way)" : "Folder is empty",
1032
- ].join("\n");
1033
- p.note(facts, "Detected");
1034
-
1035
- if (!hasBmad && !pre.skipBmad) {
1036
- p.note(bmadMissingMessage(), "BMad first");
1037
- p.outro("Install BMad, then run this again: npx wdi-method");
1038
- process.exit(1);
1039
- }
1040
-
1041
- let first = !hasWdi;
1042
- if (hasWdi) {
1043
- const update = cancelIf(
1044
- await p.confirm({
1045
- message: "WDI Method is already installed. Update it now?",
1046
- initialValue: true,
1047
- }),
1048
- );
1049
- first = !update;
1050
- if (first) {
1051
- p.cancel("Update declined.");
1052
- process.exit(0);
1053
- }
1054
- } else {
1055
- const go = cancelIf(
1056
- await p.confirm({
1057
- message: `Install WDI Method into ${target}?`,
1058
- initialValue: true,
1059
- }),
1060
- );
1061
- if (!go) {
1062
- p.cancel("Install declined.");
1063
- process.exit(0);
1064
- }
1065
- }
1066
-
1067
- // Every field arrives with an answer already in it, and Enter accepts it. On an update that answer is
1068
- // what the repo already says; on a first install it is the folder name made readable. Nothing here is
1069
- // validated as required: a prompt that refuses an empty submission when it already holds a sensible
1070
- // default is asking the owner to retype something the installer knows.
1071
- const existing = readIndexIdentity(target);
1072
- const suggestedName = identityIsPlaceholder(existing.name)
1073
- ? humaniseFolderName(path.basename(target))
1074
- : existing.name;
1075
- const product = cancelIf(
1076
- await p.text({
1077
- message: "Product name (one room: index.yaml product.name)",
1078
- placeholder: suggestedName,
1079
- defaultValue: suggestedName,
1080
- }),
1081
- ).trim() || suggestedName;
1082
- const client = cancelIf(
1083
- await p.text({
1084
- message: "Client name (Enter to leave it as it is)",
1085
- placeholder: existing.client || "(none)",
1086
- defaultValue: existing.client || "",
1087
- }),
1088
- ).trim();
1089
-
1090
- // Two questions, and only two. Method terminology, document code prefixes, machine-facing
1091
- // markers, and code identifiers are always English — MUST NOT be asked about.
1092
- const policy = readIndexPolicy(target);
1093
- // Free text, not a list. Write whatever a model understands — "English", "Bahasa Indonesia",
1094
- // "id". The only value refused is empty.
1095
- const askLanguage = async (message, current) =>
1096
- (cancelIf(
1097
- await p.text({
1098
- message,
1099
- placeholder: current || DEFAULT_DOC_LANGUAGE,
1100
- defaultValue: current || DEFAULT_DOC_LANGUAGE,
1101
- }),
1102
- ) || DEFAULT_DOC_LANGUAGE).trim();
1103
- const docLanguage = await askLanguage(
1104
- "Language of working-document prose (.what/ .how/ .control/) — free text",
1105
- policy.docLanguage || pre.docLanguage);
1106
- const docFilenameLanguage = await askLanguage(
1107
- "Language of document filename slugs — the `UC-` `DEC-` codes stay English",
1108
- policy.docFilenameLanguage || pre.docFilenameLanguage || docLanguage);
1109
-
1110
- const selected = cancelIf(
1111
- await p.multiselect({
1112
- message: "Which agents get the skills? (space to select)",
1113
- options: ALL_AGENTS.map((id) => ({ value: id, label: AGENT_LABELS[id] })),
1114
- initialValues: pre.agents || detectAgents(target),
1115
- required: true,
1116
- }),
1117
- );
1118
-
1119
- p.note(
1120
- [
1121
- "The corpus folder names are fixed — they are not an install option:",
1122
- " .constitution .control .what .how .work _bmad-output",
1123
- "",
1124
- "What gets written for the agents you picked:",
1125
- selected.includes("claude") ? " .claude/skills/wdi-* CLAUDE.md" : "",
1126
- selected.includes("cursor") ? " .agents/skills/wdi-* .cursorrules" : "",
1127
- selected.includes("codex") || selected.includes("cursor") || selected.includes("antigravity")
1128
- ? " AGENTS.md (the BEGIN:wdi-method block)"
1129
- : "",
1130
- selected.includes("antigravity") ? " .agents/AGENTS.md" : "",
1131
- ]
1132
- .filter(Boolean)
1133
- .join("\n"),
1134
- "Write targets",
1135
- );
1136
-
1137
- const okGo = cancelIf(await p.confirm({ message: first ? "Run the install?" : "Run the update?", initialValue: true }));
1138
- if (!okGo) {
1139
- p.cancel("Dibatalkan.");
1140
- process.exit(0);
1141
- }
1142
-
1143
- const spinner = p.spinner();
1144
- spinner.start(first ? "Memasang…" : "Meng-update…");
1145
- apply(target, selected, {
1146
- docLanguage,
1147
- docFilenameLanguage,
1148
- languageChosen: true,
1149
- first,
1150
- product: String(product).trim(),
1151
- client: String(client).trim(),
1152
- });
1153
- spinner.stop(first ? "Terpasang" : "Ter-update");
1154
- p.outro(first ? "Done. Take the after-install steps above." : "Done. Read the method-block diff in AGENTS.md.");
1155
- }
1156
-
1157
- function runNonInteractive(args) {
1158
- const target = requireTarget(args.dir);
1159
- const agents = args.agents || detectAgents(target) || ALL_AGENTS.slice();
1160
- if (args.cmd === "verify") {
1161
- verify(target, agents);
1162
- return;
1163
- }
1164
- if (!args.skipBmad && !bmadPresent(target)) {
1165
- die(bmadMissingMessage());
1166
- }
1167
- const existing = readIndexIdentity(target);
1168
- const product = args.product || existing.name;
1169
- const client = args.client ?? existing.client;
1170
- const first = args.cmd === "install" || (args.cmd === "wizard" && !wdiPresent(target));
1171
- apply(target, agents, {
1172
- first: args.cmd === "update" ? false : first,
1173
- product,
1174
- client,
1175
- docLanguage: args.docLanguage,
1176
- docFilenameLanguage: args.docFilenameLanguage,
1177
- languageChosen: Boolean(args.docLanguage || args.docFilenameLanguage),
1178
- });
1179
- }
1180
-
1181
- async function main() {
1182
- const args = parseArgs(process.argv);
1183
- if (!["wizard", "install", "update", "verify", "promote"].includes(args.cmd)) {
1184
- usage();
1185
- process.exit(2);
1186
- }
1187
- if (args.cmd === "promote") {
1188
- if (!args.dir) die("promote needs a path to the working copy");
1189
- // `promote` used to BE the workflow: author a rule in a product repo, run it, carry it here.
1190
- // It is now a rescue tool, and the flag is what makes that structural rather than a paragraph
1191
- // nobody rereads. Running it by habit overwrites the whole kit with one consumer's copy —
1192
- // silently reverting every change made here since that repo last updated.
1193
- if (!args.rescue) {
1194
- die([
1195
- "promote overwrites the whole kit from a consumer's copy, and this package is now where a",
1196
- " method change is authored — see CONTRIBUTING.md. If a change really was made in a",
1197
- " product repo by mistake and needs rescuing, say so:",
1198
- "",
1199
- " npx wdi-method promote <dir> --rescue",
1200
- ].join("\n"));
1201
- }
1202
- note("--rescue: pulling the method back out of a consumer. Read the diff before committing.");
1203
- promote(args.dir);
1204
- return;
1205
- }
1206
- const wantTui = !args.yes && args.cmd !== "verify" && process.stdin.isTTY && process.stdout.isTTY;
1207
- if (wantTui) {
1208
- await runWizard(args);
1209
- return;
1210
- }
1211
- if (args.cmd === "wizard" && !args.yes) {
1212
- die("not a TTY. Use `install --yes` / `update --yes`, or run this in a terminal.");
1213
- }
1214
- if (args.cmd === "wizard") args.cmd = wdiPresent(requireTarget(args.dir)) ? "update" : "install";
1215
- runNonInteractive(args);
1216
- }
1217
-
1218
- main().catch((err) => {
1219
- console.error(err);
1220
- process.exit(1);
1221
- });
1
+ #!/usr/bin/env node
2
+ import fs from "node:fs";
3
+ import path from "node:path";
4
+ import { spawnSync } from "node:child_process";
5
+ import { fileURLToPath } from "node:url";
6
+ import * as p from "@clack/prompts";
7
+ import {
8
+ fillProductTitle,
9
+ upsertMethodBlock,
10
+ } from "../lib/agents-block.mjs";
11
+ import {
12
+ identityIsPlaceholder,
13
+ humaniseFolderName,
14
+ readLanguagePolicy,
15
+ writeLanguagePolicy,
16
+ DEFAULT_DOC_LANGUAGE,
17
+ readProductIdentity,
18
+ writeProductIdentity,
19
+ } from "../lib/identity.mjs";
20
+
21
+ const ROOT = path.resolve(path.dirname(fileURLToPath(import.meta.url)), "..");
22
+ const KIT = path.join(ROOT, "kit");
23
+ const OVERLAY = path.join(ROOT, "kit-overlay");
24
+ const SCAFFOLD = path.join(ROOT, "scaffold", ".control");
25
+ const PKG = JSON.parse(fs.readFileSync(path.join(ROOT, "package.json"), "utf8"));
26
+
27
+ const WDI_SKILLS = [
28
+ "wdi-init",
29
+ "wdi-problem",
30
+ "wdi-product",
31
+ "wdi-ux",
32
+ "wdi-blueprint",
33
+ "wdi-component",
34
+ "wdi-build",
35
+ "wdi-decision",
36
+ "wdi-question",
37
+ "wdi-log",
38
+ "wdi-help",
39
+ "wdi-reconcile",
40
+ "wdi-review",
41
+ "wdi-report",
42
+ "wdi-systematic-debugging",
43
+ ];
44
+
45
+ const PRD_SLUG_PLACEHOLDER = "FILL-initiative-slug";
46
+ const GENERIC_FOLDER_PATTERNS = new Set([
47
+ "_product-brief",
48
+ "ux",
49
+ "architecture",
50
+ PRD_SLUG_PLACEHOLDER,
51
+ ]);
52
+
53
+ const ALL_AGENTS = ["claude", "cursor", "codex", "antigravity"];
54
+ const AGENT_LABELS = {
55
+ claude: "Claude Code → .claude/skills, CLAUDE.md",
56
+ cursor: "Cursor → .agents/skills, .cursorrules",
57
+ codex: "Codex → AGENTS.md",
58
+ antigravity: "Antigravity → .agents/skills, .agents/AGENTS.md",
59
+ };
60
+
61
+ const BMAD_INSTALL = `npx bmad-method install`;
62
+ const REPO_URL = "https://github.com/wiradigitalid/wdi-method";
63
+ const HELP_SKILL = "wdi-help";
64
+ const BMAD_REPO = "https://github.com/bmad-code-org/BMAD-METHOD";
65
+ const WDI_REPO = "https://github.com/wiradigitalid/wdi-method";
66
+
67
+ const RED = "\x1b[31m";
68
+ const GREEN = "\x1b[32m";
69
+ const DIM = "\x1b[2m";
70
+ const RESET = "\x1b[0m";
71
+
72
+ function die(msg) {
73
+ console.error(`${RED}error:${RESET} ${msg}`);
74
+ process.exit(1);
75
+ }
76
+
77
+ function ok(msg) {
78
+ console.log(`${GREEN}ok${RESET} ${msg}`);
79
+ }
80
+
81
+ function note(msg) {
82
+ console.log(`${DIM}·${RESET} ${msg}`);
83
+ }
84
+
85
+ function usage() {
86
+ console.log(`wdi-method ${PKG.version}
87
+
88
+ (no command) interactive TUI — detects install vs update
89
+ install [dir] first install (TUI unless --yes)
90
+ update [dir] update (TUI unless --yes)
91
+ verify [dir]
92
+ promote <live-dir> --rescue pull a method change back out of a consumer (not the normal flow)
93
+
94
+ --yes non-interactive
95
+ --agents a,b claude,cursor,codex,antigravity
96
+ --product NAME written to index.yaml product.name
97
+ --client NAME written to index.yaml product.client (optional)
98
+ --doc-language <text> prose of working documents; free text, default English
99
+ --doc-filename-language <text> slug part of document filenames; free text, default English
100
+ --skip-bmad-check
101
+
102
+ BMad first, then this package. ${WDI_REPO}
103
+ `);
104
+ }
105
+
106
+ function parseArgs(argv) {
107
+ const args = {
108
+ cmd: null,
109
+ dir: null,
110
+ agents: null,
111
+ skipBmad: false,
112
+ rescue: false,
113
+ yes: false,
114
+ product: null,
115
+ client: null,
116
+ docLanguage: null,
117
+ docFilenameLanguage: null,
118
+ };
119
+ const rest = argv.slice(2);
120
+ if (rest[0] === "-h" || rest[0] === "--help") {
121
+ usage();
122
+ process.exit(0);
123
+ }
124
+ if (rest.length === 0) {
125
+ args.cmd = "wizard";
126
+ return args;
127
+ }
128
+ const first = rest[0];
129
+ if (["install", "update", "verify", "promote"].includes(first)) {
130
+ args.cmd = rest.shift();
131
+ } else if (first.startsWith("-")) {
132
+ args.cmd = "wizard";
133
+ } else {
134
+ args.cmd = "wizard";
135
+ args.dir = rest.shift();
136
+ }
137
+ while (rest.length) {
138
+ const t = rest.shift();
139
+ if (t === "--skip-bmad-check") args.skipBmad = true;
140
+ else if (t === "--rescue") args.rescue = true;
141
+ else if (t === "--yes" || t === "-y") args.yes = true;
142
+ else if (t === "--agents") {
143
+ const raw = rest.shift();
144
+ if (!raw) die("--agents needs a comma-separated list");
145
+ args.agents = raw.split(",").map((s) => s.trim()).filter(Boolean);
146
+ for (const a of args.agents) {
147
+ if (!ALL_AGENTS.includes(a)) die(`unknown agent: ${a}`);
148
+ }
149
+ } else if (t === "--product") args.product = rest.shift();
150
+ else if (t === "--client") args.client = rest.shift();
151
+ else if (t === "--doc-language" || t === "--doc-filename-language") {
152
+ // Free text: "English", "Bahasa Indonesia", "id" — a model reads it, so no list to match.
153
+ const raw = (rest.shift() || "").trim();
154
+ if (!raw) die(`${t} needs a value, for example: English`);
155
+ if (t === "--doc-language") args.docLanguage = raw;
156
+ else args.docFilenameLanguage = raw;
157
+ }
158
+ else if (t.startsWith("-")) die(`unknown flag: ${t}`);
159
+ else if (!args.dir) args.dir = t;
160
+ else die(`unexpected argument: ${t}`);
161
+ }
162
+ return args;
163
+ }
164
+
165
+ // Build output and editor droppings MUST NOT reach the kit. This repository is public, and a
166
+ // __pycache__/*.pyc carries the ABSOLUTE PATH of the source it was compiled from — which means a
167
+ // product name and a client folder leak into a public package through a file nobody wrote.
168
+ // Found 2026-08-18 on the first real promote: inventory.cpython-314.pyc embedded the live repo path.
169
+ const SKIP_DIRS = new Set(["__pycache__", "node_modules", ".git", ".pytest_cache", ".ruff_cache",
170
+ ".mypy_cache", ".venv", "venv", "dist", "build", ".idea", ".vscode"]);
171
+ const SKIP_FILE = /(\.pyc|\.pyo|\.pyd|\.log|\.tmp|\.swp|\.orig|\.rej|\.bak)$|^\.DS_Store$|^Thumbs\.db$/i;
172
+
173
+ function walkFiles(dir) {
174
+ const out = [];
175
+ if (!fs.existsSync(dir)) return out;
176
+ for (const entry of fs.readdirSync(dir, { withFileTypes: true })) {
177
+ const p = path.join(dir, entry.name);
178
+ if (entry.isDirectory()) {
179
+ if (SKIP_DIRS.has(entry.name)) continue;
180
+ out.push(...walkFiles(p));
181
+ } else if (entry.isFile()) {
182
+ if (SKIP_FILE.test(entry.name)) continue;
183
+ out.push(p);
184
+ }
185
+ }
186
+ return out;
187
+ }
188
+
189
+ function copyFile(src, dest) {
190
+ fs.mkdirSync(path.dirname(dest), { recursive: true });
191
+ fs.copyFileSync(src, dest);
192
+ }
193
+
194
+ function copyTree(src, dest, skipRel) {
195
+ let n = 0;
196
+ for (const p of walkFiles(src)) {
197
+ const rel = posixRel(src, p);
198
+ if (skipRel && skipRel(rel)) continue;
199
+ copyFile(p, path.join(dest, path.relative(src, p)));
200
+ n += 1;
201
+ }
202
+ return n;
203
+ }
204
+
205
+ function posixRel(from, to) {
206
+ return path.relative(from, to).split(path.sep).join("/");
207
+ }
208
+
209
+ function bmadPresent(target) {
210
+ const markers = [
211
+ path.join(target, ".claude", "skills", "bmad-help", "SKILL.md"),
212
+ path.join(target, "_bmad", "core", "config.yaml"),
213
+ path.join(target, "_bmad", "_config", "manifest.yaml"),
214
+ ];
215
+ return markers.some((p) => fs.existsSync(p));
216
+ }
217
+
218
+ function wdiPresent(target) {
219
+ return (
220
+ fs.existsSync(path.join(target, ".control", "wdi-method.yaml")) ||
221
+ fs.existsSync(path.join(target, ".constitution", "method", "README.md"))
222
+ );
223
+ }
224
+
225
+ function dirNonEmpty(target) {
226
+ if (!fs.existsSync(target)) return false;
227
+ return fs.readdirSync(target).some((n) => n !== ".git" && n !== ".gitignore");
228
+ }
229
+
230
+ function readBmadVersion(target) {
231
+ const manifest = path.join(target, "_bmad", "_config", "manifest.yaml");
232
+ if (!fs.existsSync(manifest)) return "";
233
+ const text = fs.readFileSync(manifest, "utf8");
234
+ const m = text.match(/installation:\s*\n\s*version:\s*(\S+)/);
235
+ return m ? m[1] : "";
236
+ }
237
+
238
+ function detectAgents(target) {
239
+ const found = [];
240
+ if (
241
+ fs.existsSync(path.join(target, ".claude", "skills", "wdi-init", "SKILL.md")) ||
242
+ fs.existsSync(path.join(target, ".claude", "skills", "bmad-help", "SKILL.md"))
243
+ ) {
244
+ found.push("claude");
245
+ }
246
+ if (
247
+ fs.existsSync(path.join(target, ".cursorrules")) ||
248
+ fs.existsSync(path.join(target, ".agents", "skills", "wdi-init", "SKILL.md"))
249
+ ) {
250
+ found.push("cursor");
251
+ }
252
+ if (fs.existsSync(path.join(target, "AGENTS.md"))) found.push("codex");
253
+ if (fs.existsSync(path.join(target, ".agents", "AGENTS.md"))) found.push("antigravity");
254
+ return found.length ? [...new Set(found)] : ALL_AGENTS.slice();
255
+ }
256
+
257
+ function gitHead(repo) {
258
+ const r = spawnSync("git", ["-C", repo, "rev-parse", "--short", "HEAD"], {
259
+ encoding: "utf8",
260
+ });
261
+ if (r.status !== 0) return "unknown";
262
+ return r.stdout.trim();
263
+ }
264
+
265
+ function today() {
266
+ return new Date().toISOString().slice(0, 10);
267
+ }
268
+
269
+ function requireKit() {
270
+ if (!fs.existsSync(path.join(KIT, ".constitution"))) {
271
+ die(`kit missing at ${KIT}`);
272
+ }
273
+ }
274
+
275
+ function requireTarget(dir) {
276
+ const target = path.resolve(dir || process.cwd());
277
+ if (!fs.existsSync(target) || !fs.statSync(target).isDirectory()) {
278
+ die(`target is not a directory: ${target}`);
279
+ }
280
+ return target;
281
+ }
282
+
283
+ function skillDests(target, agents) {
284
+ const dests = [];
285
+ if (agents.includes("claude")) dests.push(path.join(target, ".claude", "skills"));
286
+ if (agents.includes("cursor") || agents.includes("antigravity")) {
287
+ dests.push(path.join(target, ".agents", "skills"));
288
+ }
289
+ return dests;
290
+ }
291
+
292
+ function bmadMissingMessage() {
293
+ return [
294
+ "BMad Method is not installed in this repo. Install it first, then run this installer again.",
295
+ "",
296
+ ` ${BMAD_INSTALL}`,
297
+ "",
298
+ `Source: ${BMAD_REPO}`,
299
+ "In the BMad installer, pick the same agents (Claude Code, Cursor, …).",
300
+ ].join("\n");
301
+ }
302
+
303
+ // The product's custom room. Three properties, and all three MUST hold together:
304
+ // install/update seeds its content ONLY when absent — never written again after that
305
+ // promote SKIPS it entirely, so a product's own rules can never reach the public repo
306
+ // agent loads it like any other guide, so it BINDS
307
+ // The deliberate consequence: this room's README is authored in the package and never comes home
308
+ // through promote.
309
+ const PROJECT_ROOM = "project/";
310
+
311
+ // 0.5.0 moved `.constitution/` to exactly two folders: `method/` is the method's and is overwritten,
312
+ // `project/` is the product's and is never touched. Before it, generic and product-owned files sat
313
+ // side by side at the root, `codebase/` was a third product-owned room nobody had written down, and
314
+ // `constitution.md` was ONE file holding both — which is why `update` had to keep the whole thing and
315
+ // the product never received a fixed generic Article.
316
+ //
317
+ // Without this migration an installed repo would end up carrying BOTH layouts: the kit writes the new
318
+ // paths while the old files stay behind, and an agent reading `AGENTS.md` routing would find two
319
+ // copies of most guides and no way to tell which binds.
320
+ const OLD_ROOT_GUIDES = ["README", "language-guide", "method-glossary", "repo-guide", "structure-guide"];
321
+ const OLD_WHY = ["README", "artifact-map", "portability", "rationale"];
322
+ const OLD_CODEBASE = ["stack", "conventions", "brownfield"];
323
+
324
+ function mv(from, to) {
325
+ fs.mkdirSync(path.dirname(to), { recursive: true });
326
+ fs.renameSync(from, to);
327
+ }
328
+
329
+ /** Article numbers that belong to the method half. The product keeps 1, 2, and 5. */
330
+ const METHOD_ARTICLES = [3, 4, 6, 7];
331
+
332
+ /**
333
+ * Cut the method's articles out of a product's constitution.md, and repoint its relative links.
334
+ *
335
+ * Returns {cut, kept, relinked}, or null when the file does not look like a constitution at all —
336
+ * in which case it is left ALONE rather than guessed at.
337
+ *
338
+ * 0.5.0 moved the file whole and printed "delete Articles 3, 4, 6, 7 yourself", on the grounds that
339
+ * no script can tell an edited copy from the original. That reasoning was wrong in the way that
340
+ * matters: the split does not need to know whether a section was edited, only which article numbers
341
+ * are the method's — and the file states them in its own headings. Leaving it whole left every
342
+ * migrated repo carrying those articles in TWO files, one of them frozen and drifting, plus relative
343
+ * links that no longer resolve one level down. It is all in git, so cutting is reversible; not
344
+ * cutting is what nobody notices.
345
+ */
346
+ function splitProductConstitution(file) {
347
+ if (!fs.existsSync(file)) return null;
348
+ const raw = fs.readFileSync(file, "utf8");
349
+ const crlf = raw.includes("\r\n");
350
+ const text = crlf ? raw.replaceAll("\r\n", "\n") : raw;
351
+ const marks = [...text.matchAll(/^## Article (\d+)\b.*$/gm)];
352
+ if (marks.length < 2) return null; // not the shape we know; do not touch it
353
+
354
+ const kept = [];
355
+ const cut = [];
356
+ let out = text.slice(0, marks[0].index);
357
+ for (let i = 0; i < marks.length; i += 1) {
358
+ const n = Number(marks[i][1]);
359
+ const end = i + 1 < marks.length ? marks[i + 1].index : text.length;
360
+ if (METHOD_ARTICLES.includes(n)) cut.push(n);
361
+ else {
362
+ kept.push(n);
363
+ out += text.slice(marks[i].index, end);
364
+ }
365
+ }
366
+ if (!cut.length) return { cut, kept, relinked: 0 };
367
+
368
+ // The file sits one level deeper than it did, and its former siblings moved into method/. A link
369
+ // left as `repo-guide.md` now resolves to .constitution/project/repo-guide.md, which does not exist.
370
+ let relinked = 0;
371
+ const bump = (re, to) => {
372
+ out = out.replace(re, (m, ...rest) => {
373
+ relinked += 1;
374
+ return typeof to === "function" ? to(m, ...rest) : to + m;
375
+ });
376
+ };
377
+ for (const name of ["repo-guide.md", "structure-guide.md", "language-guide.md",
378
+ "method-glossary.md"]) {
379
+ bump(new RegExp(`(?<![\\w./-])${name.replace(".", "\\.")}`, "g"), "../method/");
380
+ }
381
+ bump(/(?<![\w./-])document\//g, "../method/");
382
+ bump(/(?<![\w./-])codebase\/([a-z]+)-guide\.md/g, (_m, kind) => `codebase-${kind}-guide.md`);
383
+ out = out.replaceAll("../method/../method/", "../method/");
384
+
385
+ const banner = [
386
+ "",
387
+ `> **Articles ${cut.join(", ")} were removed from this file on migration to the two-folder layout.**`,
388
+ "> They are the method's and live in [`../method/constitution.md`](../method/constitution.md), which",
389
+ `> \`update\` replaces. Only Articles ${kept.join(", ")} are yours. The removed text is in git.`,
390
+ "",
391
+ ].join("\n");
392
+ const firstArticle = out.search(/^## Article /m);
393
+ out = firstArticle === -1
394
+ ? out + banner
395
+ : out.slice(0, firstArticle) + banner.trimStart() + "\n" + out.slice(firstArticle);
396
+
397
+ fs.writeFileSync(file, crlf ? out.replaceAll("\n", "\r\n") : out, "utf8");
398
+ return { cut, kept, relinked };
399
+ }
400
+
401
+ function migrateToTwoFolders(target) {
402
+ const c = path.join(target, ".constitution");
403
+ if (!fs.existsSync(c)) return false; // a first install has nothing to migrate
404
+ const at = (...p) => path.join(c, ...p);
405
+ // The old layout is identified by `document/` at the ROOT in the new layout that folder only ever
406
+ // exists under `method/`. Checking a loose guide instead would misfire on a repo that added one.
407
+ if (!fs.existsSync(at("document")) && !fs.existsSync(at("codebase"))
408
+ && !fs.existsSync(at("constitution.md")) && !fs.existsSync(at("scripts"))) {
409
+ return false;
410
+ }
411
+ note("pre-0.5.0 .constitution/ found — migrating to method/ + project/");
412
+
413
+ // 1. The four Reference files go one level deeper. This MUST run before the kit is written, or the
414
+ // kit's own why/ files land while the old copies still sit at method/ root.
415
+ for (const name of OLD_WHY) {
416
+ const from = at("method", `${name}.md`);
417
+ if (fs.existsSync(from)) {
418
+ mv(from, at("method", "why", `${name}.md`));
419
+ note(` moved method/${name}.md → method/why/${name}.md`);
420
+ }
421
+ }
422
+ // 2. and 3. whole folders
423
+ for (const dir of ["document", "scripts"]) {
424
+ if (fs.existsSync(at(dir)) && !fs.existsSync(at("method", dir))) {
425
+ mv(at(dir), at("method", dir));
426
+ note(` moved ${dir}/ → method/${dir}/`);
427
+ }
428
+ }
429
+ // 4. the loose generic guides
430
+ for (const name of OLD_ROOT_GUIDES) {
431
+ const from = at(`${name}.md`);
432
+ if (fs.existsSync(from)) {
433
+ mv(from, at("method", `${name}.md`));
434
+ note(` moved ${name}.md → method/${name}.md`);
435
+ }
436
+ }
437
+ // 5. codebase/ was a product-owned room all along — it becomes flat files in the room that says so
438
+ for (const name of OLD_CODEBASE) {
439
+ const from = at("codebase", `${name}-guide.md`);
440
+ if (fs.existsSync(from)) {
441
+ mv(from, at("project", `codebase-${name}-guide.md`));
442
+ note(` moved codebase/${name}-guide.md → project/codebase-${name}-guide.md`);
443
+ }
444
+ }
445
+ if (fs.existsSync(at("codebase"))) {
446
+ const left = fs.readdirSync(at("codebase"));
447
+ if (!left.length) fs.rmdirSync(at("codebase"));
448
+ else note(` codebase/ still holds ${left.join(", ")} — left in place, move them yourself`);
449
+ }
450
+ // 6. The product's constitution.md moves WHOLE into the room, so its Articles 1, 2, and 5 survive
451
+ // exactly as written. The generic half then arrives fresh at method/constitution.md.
452
+ let split = null;
453
+ if (fs.existsSync(at("constitution.md")) && !fs.existsSync(at("project", "constitution.md"))) {
454
+ mv(at("constitution.md"), at("project", "constitution.md"));
455
+ note(" moved constitution.md → project/constitution.md");
456
+ split = splitProductConstitution(at("project", "constitution.md"));
457
+ if (split && split.cut.length) {
458
+ note(` kept Articles ${split.kept.join(", ")}, removed ${split.cut.join(", ")} `
459
+ + "(the method's they arrive in method/constitution.md)");
460
+ if (split.relinked) note(` repointed ${split.relinked} relative links one level up`);
461
+ } else if (split === null) {
462
+ note(" it does not carry `## Article N` headings, so it was moved but NOT split — yours to check");
463
+ }
464
+ }
465
+ // Anything else loose at the root is a file this product ADDED. It is NOT moved: it may be routed
466
+ // from AGENTS.md by its current path, and guessing a destination would break that silently.
467
+ const stray = fs.existsSync(c)
468
+ ? fs.readdirSync(c, { withFileTypes: true })
469
+ .filter((e) => e.isFile() && e.name.endsWith(".md"))
470
+ .map((e) => e.name)
471
+ : [];
472
+ if (stray.length) {
473
+ note(` left at .constitution/ root, yours to place: ${stray.join(", ")}`);
474
+ note(" a file you added belongs in project/ but moving it would break any pointer that");
475
+ note(" names its current path, so the choice is yours. repo-guide.md states the rule.");
476
+ }
477
+ return split;
478
+ }
479
+
480
+ function syncConstitution(target) {
481
+ const kitConst = path.join(KIT, ".constitution");
482
+ const destConst = path.join(target, ".constitution");
483
+ fs.mkdirSync(destConst, { recursive: true });
484
+ let written = 0;
485
+ let skipped = 0;
486
+ for (const file of walkFiles(kitConst)) {
487
+ const rel = posixRel(kitConst, file);
488
+ const dest = path.join(destConst, rel);
489
+ // ONE rule for everything the product owns, because 0.5.0 put all of it in one folder. Before
490
+ // that this loop had three branches the mixed constitution.md kept whole, `codebase/` gated on
491
+ // `status: Accepted` (which is what silently destroyed a half-written guide), and the room and
492
+ // the three disagreed about when a file was the product's. Seeded when absent, never written
493
+ // again: the same rule as the language policy.
494
+ // ONE file in the room is the package's and is refreshed like any method file: the room's own
495
+ // README. It explains what the room is FOR and carries no product decision, so a stale copy does
496
+ // not preserve anybody's work — it just misinforms. worship-presenter-web proved that: its copy
497
+ // still pointed at `.constitution/codebase/*-guide.md`, a folder 0.5.0 deleted, and no update
498
+ // would ever have corrected it while the file claimed in its own text to be "authored in the
499
+ // package". Either the package writes it or it stops claiming authorship; this is the first.
500
+ if (rel === `${PROJECT_ROOM}README.md`) {
501
+ copyFile(file, dest);
502
+ written += 1;
503
+ continue;
504
+ }
505
+ if (rel.startsWith(PROJECT_ROOM) && fs.existsSync(dest)) {
506
+ skipped += 1;
507
+ note(`keep ${rel} (yours the project room)`);
508
+ continue;
509
+ }
510
+ copyFile(file, dest);
511
+ written += 1;
512
+ }
513
+ return { written, skipped };
514
+ }
515
+
516
+ function syncSkills(target, agents) {
517
+ let n = 0;
518
+ const dests = skillDests(target, agents);
519
+ if (dests.length === 0) {
520
+ note("no skill destinations for selected agents — AGENTS.md still applies");
521
+ return 0;
522
+ }
523
+ for (const name of WDI_SKILLS) {
524
+ const src = path.join(KIT, "skills", name);
525
+ if (!fs.existsSync(src)) die(`kit missing skill ${name}`);
526
+ for (const root of dests) {
527
+ const dest = path.join(root, name);
528
+ fs.rmSync(dest, { recursive: true, force: true });
529
+ n += copyTree(src, dest);
530
+ }
531
+ }
532
+ const removed = pruneRetiredSkills(dests);
533
+ return { files: n, removed };
534
+ }
535
+
536
+ // A wrapper the method RETIRED is worse than a wrapper missing: the folder is still there, its
537
+ // SKILL.md still reads like an instruction, and an agent will invoke it — while the guide it points
538
+ // at is gone. Renaming five wrappers (wdi-apply, wdi-analysis, wdi-structure, …) left exactly that
539
+ // in every repo installed before the rename, because update only ever touched the names it knows.
540
+ //
541
+ // `wdi-` is the method's namespace, so a `wdi-*` folder carrying a SKILL.md and not in WDI_SKILLS is
542
+ // ours and retired. Each removal is PRINTED: silent deletion in someone else's repo is not a fix.
543
+ function pruneRetiredSkills(dests) {
544
+ let removed = 0;
545
+ const keep = new Set(WDI_SKILLS);
546
+ for (const root of dests) {
547
+ if (!fs.existsSync(root)) continue;
548
+ for (const entry of fs.readdirSync(root, { withFileTypes: true })) {
549
+ if (!entry.isDirectory() || !entry.name.startsWith("wdi-") || keep.has(entry.name)) continue;
550
+ const dir = path.join(root, entry.name);
551
+ if (!fs.existsSync(path.join(dir, "SKILL.md"))) {
552
+ note(`kept ${entry.name} (no SKILL.md not one of ours)`);
553
+ continue;
554
+ }
555
+ fs.rmSync(dir, { recursive: true, force: true });
556
+ note(`removed retired skill ${entry.name}`);
557
+ removed += 1;
558
+ }
559
+ }
560
+ return removed;
561
+ }
562
+
563
+ // `promote` scrubs a product's initiative slug out of bmad-prd.toml before publishing, which is right.
564
+ // Writing the scrubbed PLACEHOLDER back into a product repo is not: the first real install replaced a
565
+ // live `run_folder_pattern = "some-real-slug"` with `FILL-initiative-slug`, and nothing said so. A value
566
+ // the product already chose is not the installer's to overwrite — same rule as the custom room and the
567
+ // language policy.
568
+ const PLACEHOLDER_SLUG = "FILL-initiative-slug";
569
+ const RUN_FOLDER_LINE = /^(\s*run_folder_pattern\s*=\s*)(".*?"|'.*?')/m;
570
+
571
+ // The slug appears MORE THAN ONCE bmad-prd.toml carries it in `run_folder_pattern` and again inside a
572
+ // memlog path, and the file itself says the two lines MUST change together. The first version of this
573
+ // function restored only the first line and so produced exactly the inconsistency that file forbids.
574
+ // So: read the product's slug once, then put it back everywhere the placeholder appears.
575
+ function keepProductSlug(incoming, existing) {
576
+ const mineNow = existing.match(RUN_FOLDER_LINE);
577
+ if (!mineNow) return null;
578
+ const slug = mineNow[2].slice(1, -1);
579
+ if (!slug || slug === PLACEHOLDER_SLUG) return null;
580
+ if (!incoming.includes(PLACEHOLDER_SLUG)) return null;
581
+ // Only where the slug is a VALUE: the quoted setting, and the memlog path built from it. A bare
582
+ // mention inside a comment stays the placeholder — that sentence explains the pattern, and rewriting
583
+ // it would turn a generic explanation into a statement about one initiative.
584
+ return incoming
585
+ .replaceAll(`"${PLACEHOLDER_SLUG}"`, `"${slug}"`)
586
+ .replaceAll(`prd-${PLACEHOLDER_SLUG}`, `prd-${slug}`);
587
+ }
588
+
589
+ function syncTomls(target) {
590
+ const src = path.join(KIT, "assets", "bmad-custom");
591
+ const dest = path.join(target, "_bmad", "custom");
592
+ fs.mkdirSync(dest, { recursive: true });
593
+ let n = 0;
594
+ let slugsKept = 0;
595
+ for (const file of walkFiles(src)) {
596
+ if (!file.endsWith(".toml") || file.endsWith(".user.toml")) continue;
597
+ const to = path.join(dest, path.basename(file));
598
+ if (fs.existsSync(to)) {
599
+ const merged = keepProductSlug(fs.readFileSync(file, "utf8"), fs.readFileSync(to, "utf8"));
600
+ if (merged !== null) {
601
+ fs.writeFileSync(to, merged);
602
+ note(`kept run_folder_pattern in ${path.basename(file)}`);
603
+ slugsKept += 1;
604
+ n += 1;
605
+ continue;
606
+ }
607
+ }
608
+ copyFile(file, to);
609
+ n += 1;
610
+ }
611
+ return { files: n, slugsKept };
612
+ }
613
+
614
+ function seedControlIfMissing(target) {
615
+ const control = path.join(target, ".control");
616
+ if (fs.existsSync(control)) {
617
+ note(".control/ already present — left untouched");
618
+ return;
619
+ }
620
+ if (!fs.existsSync(SCAFFOLD)) die(`scaffold missing: ${SCAFFOLD}`);
621
+ const n = copyTree(SCAFFOLD, control);
622
+ ok(`seeded empty .control/ (${n} files)`);
623
+ }
624
+
625
+ // On a FIRST install these folders are the corpus taking shape. On an UPDATE their absence means
626
+ // somebody removed them on purpose — `.work/` and `_bmad-output/prior-knowledge/` are exactly the two a
627
+ // product retires once its migration is done, and one repo retired them through an applied decision.
628
+ // Recreating them then is an installer overruling a decision it cannot read. Seed once, never resurrect.
629
+ function seedEmptyLayers(target, { first }) {
630
+ const always = [".what", path.join(".how", "_platform")];
631
+ const firstOnly = [".work", path.join("_bmad-output", "prior-knowledge")];
632
+ for (const rel of first ? [...always, ...firstOnly] : always) {
633
+ const dest = path.join(target, rel);
634
+ if (!fs.existsSync(dest)) {
635
+ fs.mkdirSync(dest, { recursive: true });
636
+ note(`created ${rel.replaceAll(path.sep, "/")}/`);
637
+ }
638
+ }
639
+ if (!first) {
640
+ for (const rel of firstOnly) {
641
+ if (!fs.existsSync(path.join(target, rel))) {
642
+ note(`left ${rel.replaceAll(path.sep, "/")}/ absent — a product retires it, not the installer`);
643
+ }
644
+ }
645
+ }
646
+ }
647
+
648
+ function writeStamp(target) {
649
+ const control = path.join(target, ".control");
650
+ if (!fs.existsSync(control)) return;
651
+ const stamp = [
652
+ "# Written by wdi-method install/update. A trace, not a lockfile.",
653
+ `wdi_method: ${PKG.version}`,
654
+ `bmad_method: ${readBmadVersion(target) || '""'}`,
655
+ `installed_at: ${today()}`,
656
+ "",
657
+ ].join("\n");
658
+ fs.writeFileSync(path.join(control, "wdi-method.yaml"), stamp, "utf8");
659
+ note("stamped .control/wdi-method.yaml");
660
+ }
661
+
662
+ function setProductIdentity(target, { name, client }) {
663
+ if (!name || identityIsPlaceholder(name)) return;
664
+ const file = path.join(target, ".control", "registry", "index.yaml");
665
+ if (!fs.existsSync(file)) return;
666
+ const next = writeProductIdentity(fs.readFileSync(file, "utf8"), {
667
+ name,
668
+ client: client ?? "",
669
+ });
670
+ fs.writeFileSync(file, next.endsWith("\n") ? next : `${next}\n`);
671
+ note(`product.name = ${name}`);
672
+ }
673
+
674
+ // The document language belongs to the PRODUCT, so update MUST NOT overwrite it. It is written only
675
+ // when absent — same as the custom room, and for the same reason: a setting somebody already chose
676
+ // is not the installer's to change behind their back.
677
+ function setLanguagePolicy(target, { docLanguage, docFilenameLanguage, chosen }) {
678
+ const file = path.join(target, ".control", "registry", "index.yaml");
679
+ if (!fs.existsSync(file)) return;
680
+ const text = fs.readFileSync(file, "utf8");
681
+ const existing = readLanguagePolicy(text);
682
+ // `chosen` means somebody actually answered — in the TUI, or through an explicit flag. Only then
683
+ // does the answer take effect. Without it the incoming value is just a default, and a default
684
+ // MUST NOT overwrite a choice somebody already made.
685
+ if (!chosen && existing.docLanguage && existing.docFilenameLanguage) {
686
+ note(`kept policy.doc_language = ${existing.docLanguage}, ` +
687
+ `doc_filename_language = ${existing.docFilenameLanguage}`);
688
+ return;
689
+ }
690
+ const next = writeLanguagePolicy(text, {
691
+ docLanguage: docLanguage || existing.docLanguage || DEFAULT_DOC_LANGUAGE,
692
+ docFilenameLanguage:
693
+ docFilenameLanguage || existing.docFilenameLanguage || DEFAULT_DOC_LANGUAGE,
694
+ });
695
+ fs.writeFileSync(file, next.endsWith("\n") ? next : `${next}\n`);
696
+ const after = readLanguagePolicy(next);
697
+ note(`policy.doc_language = ${after.docLanguage}, ` +
698
+ `doc_filename_language = ${after.docFilenameLanguage}`);
699
+ }
700
+
701
+ // Read BEFORE writeStamp overwrites it. Without this there is no version transition to print, and
702
+ // an "updated" with no from-to tells the reader nothing they can use.
703
+ function readStampVersion(target) {
704
+ const file = path.join(target, ".control", "wdi-method.yaml");
705
+ if (!fs.existsSync(file)) return "";
706
+ const m = fs.readFileSync(file, "utf8").match(/^wdi_method:\s*"?([^"\s]+)"?/m);
707
+ return m ? m[1] : "";
708
+ }
709
+
710
+ function readIndexPolicy(target) {
711
+ const file = path.join(target, ".control", "registry", "index.yaml");
712
+ if (!fs.existsSync(file)) return { docLanguage: "", docFilenameLanguage: "" };
713
+ return readLanguagePolicy(fs.readFileSync(file, "utf8"));
714
+ }
715
+
716
+ function readIndexIdentity(target) {
717
+ const file = path.join(target, ".control", "registry", "index.yaml");
718
+ if (!fs.existsSync(file)) return { name: "", client: "" };
719
+ return readProductIdentity(fs.readFileSync(file, "utf8"));
720
+ }
721
+
722
+ function upsertAgentFiles(target, agents, productName) {
723
+ const template = fs.readFileSync(path.join(OVERLAY, "AGENTS.md"), "utf8");
724
+ const agentsFile = path.join(target, "AGENTS.md");
725
+ let next;
726
+ if (!fs.existsSync(agentsFile)) {
727
+ next = fillProductTitle(template, productName || "{product}");
728
+ ok("AGENTS.md created — rewrite ## Code for this product");
729
+ } else {
730
+ next = upsertMethodBlock(fs.readFileSync(agentsFile, "utf8"), template);
731
+ note("AGENTS.md method block refreshed; product sections kept");
732
+ }
733
+ if (!next.endsWith("\n")) next += "\n";
734
+ fs.writeFileSync(agentsFile, next);
735
+
736
+ const mirrors = [];
737
+ if (agents.includes("cursor")) mirrors.push(path.join(target, ".cursorrules"));
738
+ if (agents.includes("cursor") || agents.includes("antigravity")) {
739
+ mirrors.push(path.join(target, ".agents", "AGENTS.md"));
740
+ }
741
+ for (const mirror of mirrors) {
742
+ fs.mkdirSync(path.dirname(mirror), { recursive: true });
743
+ if (fs.existsSync(mirror)) {
744
+ const patched = upsertMethodBlock(fs.readFileSync(mirror, "utf8"), template);
745
+ fs.writeFileSync(mirror, patched.endsWith("\n") ? patched : `${patched}\n`);
746
+ note(`method block refreshed in ${posixRel(target, mirror)}`);
747
+ } else {
748
+ fs.writeFileSync(mirror, next);
749
+ note(`created ${posixRel(target, mirror)}`);
750
+ }
751
+ }
752
+
753
+ if (agents.includes("claude")) {
754
+ const claude = path.join(target, "CLAUDE.md");
755
+ if (!fs.existsSync(claude)) {
756
+ fs.writeFileSync(claude, "@AGENTS.md\n");
757
+ note("CLAUDE.md created as @AGENTS.md");
758
+ }
759
+ }
760
+ }
761
+
762
+ // What a run MUST leave a reader able to answer: which version replaced which, what was written, what
763
+ // was KEPT, and what to do next. The third is the one usually missing, and it is the one that decides
764
+ // whether somebody trusts running this over a repo they have already put work into.
765
+ function summaryLine(label, value) {
766
+ console.log(` ${DIM}${label.padEnd(11)}${RESET}${value}`);
767
+ }
768
+
769
+ function printSummary(target, agents, { first, was, written, skipped, skills, tomls }) {
770
+ const now = PKG.version;
771
+ const version = first
772
+ ? `${now} first install`
773
+ : was && was !== now
774
+ ? `${was} ${DIM}→${RESET} ${now}`
775
+ : `${now} ${DIM}(unchanged)${RESET}`;
776
+ const bmad = readBmadVersion(target);
777
+
778
+ const kept = [];
779
+ if (skipped) kept.push(`${skipped} constitution file${skipped === 1 ? "" : "s"}`);
780
+ if (tomls.slugsKept) kept.push(`${tomls.slugsKept} initiative slug${tomls.slugsKept === 1 ? "" : "s"}`);
781
+ // On a first install the language was just CHOSEN, not kept saying "kept" there reads as if the
782
+ // installer had found something it decided to leave alone, which is the opposite of what happened.
783
+ const policy = readIndexPolicy(target);
784
+ if (policy.docLanguage && !first) kept.push(`language (${policy.docLanguage})`);
785
+ if (fs.existsSync(path.join(target, ".constitution", "project"))) kept.push(".constitution/project/");
786
+
787
+ console.log("");
788
+ console.log(`${DIM}────${RESET} WDI Method ${DIM}${"─".repeat(46)}${RESET}`);
789
+ summaryLine("version", version);
790
+ if (bmad) summaryLine("bmad", bmad);
791
+ summaryLine("target", target);
792
+ console.log("");
793
+ summaryLine("written", `${written} constitution · ${skills.files} skill files · ${tomls.files} bmad overrides`);
794
+ if (kept.length) summaryLine("kept", kept.join(" · "));
795
+ if (skills.removed) {
796
+ summaryLine("removed", `${skills.removed} retired wrapper${skills.removed === 1 ? "" : "s"}`);
797
+ }
798
+ if (first && policy.docLanguage) {
799
+ summaryLine("language", `${policy.docLanguage} · filenames ${policy.docFilenameLanguage}`);
800
+ }
801
+ summaryLine("agents", agents.join(", ") || "none");
802
+ console.log("");
803
+ summaryLine("next", `invoke the ${HELP_SKILL} skill and ask what to do`);
804
+ summaryLine("", REPO_URL);
805
+ console.log(`${DIM}${"─".repeat(62)}${RESET}`);
806
+ }
807
+
808
+ function printNextSteps({ first, productSet }) {
809
+ console.log("");
810
+ console.log(first ? "After install:" : "After update:");
811
+ if (first) {
812
+ if (!productSet) {
813
+ console.log(" 1. Fill product.name (and product.client if there is one) in .control/registry/index.yaml.");
814
+ } else {
815
+ console.log(" 1. product.name is set. G1 confirms it in the brief.");
816
+ }
817
+ console.log(" 2. Rewrite .constitution/constitution.md Articles 2 and 5 for this product.");
818
+ console.log(" Article 1 cites index.yaml — do not become a second source for the name.");
819
+ console.log(" 3. Write ## Code in AGENTS.md (where the app lives). Leave the BEGIN:wdi-method block alone.");
820
+ console.log(" 4. Run the wdi-init skill, intent setup.");
821
+ console.log(" 5. Sort the documents you already have. Do not move any of them in this step.");
822
+ console.log("");
823
+ console.log("Next update:");
824
+ console.log(" npx wdi-method");
825
+ console.log(" (the TUI offers the update) or: npx wdi-method update --yes");
826
+ } else {
827
+ console.log(" 1. The <!-- BEGIN:wdi-method --> block in AGENTS.md was replaced. Read the diff.");
828
+ console.log(" 2. constitution.md Articles 1-2-5, ## Code, and *.user.toml were not overwritten.");
829
+ console.log(" 3. If BMad has new skills, install those first, then run this update again.");
830
+ }
831
+ }
832
+
833
+ function apply(target, agents,
834
+ { first, product, client, docLanguage, docFilenameLanguage, languageChosen }) {
835
+ requireKit();
836
+ const was = readStampVersion(target);
837
+ // MUST run before the kit is written: it moves the product's files out of the way of paths the kit
838
+ // is about to occupy. Running it after would leave two copies of most guides.
839
+ const migrated = migrateToTwoFolders(target);
840
+ // The split MUST also be reachable without a migration. 0.5.2 only ran it from inside
841
+ // migrateToTwoFolders, which returns early when the old layout is absent — so a repo that took
842
+ // 0.5.0 or 0.5.1, whose project/constitution.md was moved WHOLE and never split, could never be
843
+ // fixed by any later update. That is precisely the repo that needs it. Running it here on every
844
+ // update closes that, and it is idempotent: after a split there are no method articles left to cut.
845
+ const lateSplit = splitProductConstitution(path.join(target, ".constitution", "project",
846
+ "constitution.md"));
847
+ if (!migrated && lateSplit && lateSplit.cut.length) {
848
+ note(`project/constitution.md still carried Articles ${lateSplit.cut.join(", ")} — removed`);
849
+ note(` they are the method's and live in method/constitution.md; kept ${lateSplit.kept.join(", ")}`);
850
+ if (lateSplit.relinked) note(` repointed ${lateSplit.relinked} relative links`);
851
+ }
852
+ const splitConstitution = migrated;
853
+ const { written, skipped } = syncConstitution(target);
854
+ note(`constitution wrote ${written}, kept ${skipped}`);
855
+ // A migrated repo also carries derived output stamped against the OLD layout: .control/generated/*
856
+ // still names the pre-0.5.0 script path, and the two structure maps still draw the old tree. The
857
+ // installer MUST NOT write either — one is generated, the other is re-derived by a skill — so it
858
+ // says so instead of leaving them to be found by whoever trusts them next.
859
+ if (splitConstitution) {
860
+ note(" derived output still describes the OLD layout, and neither is mine to write:");
861
+ note(" uv run .constitution/method/scripts/validate.py --generate → .control/generated/");
862
+ note(" then the wdi-init skill, intent `structure` → the two structure maps");
863
+ }
864
+ const skills = syncSkills(target, agents);
865
+ note(`skills ${skills.files} files`);
866
+ const tomls = syncTomls(target);
867
+ note(`bmad custom ${tomls.files} toml → _bmad/custom/`);
868
+ if (first) seedControlIfMissing(target);
869
+ seedEmptyLayers(target, { first });
870
+ setProductIdentity(target, { name: product, client });
871
+ setLanguagePolicy(target, { docLanguage, docFilenameLanguage, chosen: languageChosen });
872
+ upsertAgentFiles(target, agents, product);
873
+ writeStamp(target);
874
+ printSummary(target, agents, { first, was, written, skipped, skills, tomls });
875
+ printNextSteps({
876
+ first,
877
+ productSet: Boolean(product) && !identityIsPlaceholder(product),
878
+ });
879
+ }
880
+
881
+ function verify(target, agents) {
882
+ requireKit();
883
+ const missing = [];
884
+ const kitConst = path.join(KIT, ".constitution");
885
+ for (const file of walkFiles(kitConst)) {
886
+ const rel = posixRel(kitConst, file);
887
+ const dest = path.join(target, ".constitution", rel);
888
+ if (!fs.existsSync(dest)) missing.push(`.constitution/${rel}`);
889
+ }
890
+ for (const name of WDI_SKILLS) {
891
+ for (const root of skillDests(target, agents)) {
892
+ const dest = path.join(root, name, "SKILL.md");
893
+ if (!fs.existsSync(dest)) missing.push(posixRel(target, dest));
894
+ }
895
+ }
896
+ const custom = path.join(KIT, "assets", "bmad-custom");
897
+ for (const file of walkFiles(custom)) {
898
+ if (!file.endsWith(".toml")) continue;
899
+ const dest = path.join(target, "_bmad", "custom", path.basename(file));
900
+ if (!fs.existsSync(dest)) missing.push(`_bmad/custom/${path.basename(file)}`);
901
+ }
902
+ if (fs.existsSync(path.join(target, ".control"))) {
903
+ for (const file of walkFiles(SCAFFOLD)) {
904
+ const rel = posixRel(SCAFFOLD, file);
905
+ const dest = path.join(target, ".control", rel);
906
+ if (!fs.existsSync(dest)) missing.push(`.control/${rel}`);
907
+ }
908
+ } else {
909
+ missing.push(".control/ (folder missing — first install should have seeded it)");
910
+ }
911
+ // `.constitution/constitution.md` was the pre-0.5.0 path. Demanding it here made `verify` report a
912
+ // file MISSING that the split deliberately removed — a check telling the truth about the wrong world.
913
+ for (const required of ["AGENTS.md", path.join(".constitution", "project", "constitution.md")]) {
914
+ if (!fs.existsSync(path.join(target, required))) missing.push(required.replaceAll(path.sep, "/"));
915
+ }
916
+ if (missing.length) {
917
+ console.error(`${RED}missing ${missing.length}${RESET}`);
918
+ for (const m of missing) console.error(` ${m}`);
919
+ process.exit(1);
920
+ }
921
+ ok(`method files present in ${target}`);
922
+
923
+ // Present-and-correct is not the same as consistent. These three are states `update` cannot fix on
924
+ // its own — it MUST NOT write over the room, and it cannot know what a product meant — so `verify`
925
+ // is where they get said out loud instead of waiting to be tripped over.
926
+ const judgement = [];
927
+ const room = path.join(target, ".constitution", "project", "constitution.md");
928
+ if (fs.existsSync(room)) {
929
+ const carried = [...fs.readFileSync(room, "utf8").matchAll(/^## Article (\d+)\b/gm)]
930
+ .map((m) => Number(m[1])).filter((n) => METHOD_ARTICLES.includes(n));
931
+ if (carried.length) {
932
+ judgement.push(`project/constitution.md still carries Articles ${carried.join(", ")} — the `
933
+ + "method's. They are duplicated in method/constitution.md and will drift. Run update again.");
934
+ }
935
+ }
936
+ const constRoot = path.join(target, ".constitution");
937
+ const loose = fs.existsSync(constRoot)
938
+ ? fs.readdirSync(constRoot, { withFileTypes: true })
939
+ .filter((e) => e.isFile() && e.name.endsWith(".md")).map((e) => e.name)
940
+ : [];
941
+ if (loose.length) {
942
+ judgement.push(`loose at .constitution/ root: ${loose.join(", ")} — .constitution/ holds two `
943
+ + "folders and nothing else the method knows about. Move it into project/, or name it from "
944
+ + "Article 2 so the next reader knows why it is there. repo-guide.md states the rule.");
945
+ }
946
+ if (judgement.length) {
947
+ console.log("");
948
+ for (const j of judgement) note(j);
949
+ }
950
+ note("extra product files are expected and were not checked");
951
+ }
952
+
953
+ function scrubPrdToml(file) {
954
+ const raw = fs.readFileSync(file, "utf8");
955
+ const m = raw.match(/run_folder_pattern\s*=\s*"([^"]+)"/);
956
+ if (!m) return;
957
+ const slug = m[1];
958
+ if (GENERIC_FOLDER_PATTERNS.has(slug)) return;
959
+ fs.writeFileSync(file, raw.split(slug).join(PRD_SLUG_PLACEHOLDER), "utf8");
960
+ note("bmad-prd.toml initiative slug scrubbed to placeholder");
961
+ }
962
+
963
+ function promote(live) {
964
+ live = path.resolve(live);
965
+ if (!fs.existsSync(path.join(live, ".constitution"))) {
966
+ die(`${live} has no .constitution/ — is this a method-carrying repo?`);
967
+ }
968
+ // EVERY file in the room is authored in the package and MUST survive the rmSync below — the room's
969
+ // README, the generic Articles 1-2-5, and the three empty codebase templates. Read here, not
970
+ // after: the first version of this preserved only README.md and read it AFTER the kit was deleted,
971
+ // so it was always null and the file vanished on every promote. Two tests cover it now.
972
+ const roomKit = path.join(KIT, ".constitution", PROJECT_ROOM);
973
+ const roomKept = fs.existsSync(roomKit)
974
+ ? Object.fromEntries(walkFiles(roomKit).map((f) => [posixRel(roomKit, f), fs.readFileSync(f, "utf8")]))
975
+ : {};
976
+
977
+ fs.rmSync(KIT, { recursive: true, force: true });
978
+ fs.mkdirSync(KIT, { recursive: true });
979
+
980
+ // ONE skip, because 0.5.0 put everything the product owns in one folder. It covers the codebase
981
+ // guides too, which used to need a rule of their own: promoting a filled-in stack guide would leak
982
+ // one product's conventions possibly written in its own `doc_language` — into a public package.
983
+ const nConst = copyTree(path.join(live, ".constitution"), path.join(KIT, ".constitution"),
984
+ (rel) => rel.startsWith(PROJECT_ROOM));
985
+ note(`constitution ${nConst} files (${PROJECT_ROOM} skipped — it is the product's)`);
986
+ for (const [rel, text] of Object.entries(roomKept)) {
987
+ const dest = path.join(roomKit, rel);
988
+ fs.mkdirSync(path.dirname(dest), { recursive: true });
989
+ fs.writeFileSync(dest, text, "utf8");
990
+ }
991
+ if (Object.keys(roomKept).length) {
992
+ note(`${PROJECT_ROOM} restored from the package (${Object.keys(roomKept).length} files) — `
993
+ + "promote never carries the room home");
994
+ }
995
+
996
+ let copiedSkills = 0;
997
+ const skillsSrc = path.join(live, ".claude", "skills");
998
+ for (const name of WDI_SKILLS) {
999
+ const src = path.join(skillsSrc, name);
1000
+ if (!fs.existsSync(src)) die(`skill missing in live repo: ${src}`);
1001
+ copiedSkills += copyTree(src, path.join(KIT, "skills", name));
1002
+ }
1003
+ note(`skills ${copiedSkills} files (${WDI_SKILLS.length} wrappers)`);
1004
+
1005
+ const customSrc = path.join(live, "_bmad", "custom");
1006
+ const customDst = path.join(KIT, "assets", "bmad-custom");
1007
+ fs.mkdirSync(customDst, { recursive: true });
1008
+ let tomls = 0;
1009
+ if (fs.existsSync(customSrc)) {
1010
+ for (const file of walkFiles(customSrc)) {
1011
+ if (!file.endsWith(".toml") || file.endsWith(".user.toml")) continue;
1012
+ copyFile(file, path.join(customDst, path.basename(file)));
1013
+ tomls += 1;
1014
+ }
1015
+ }
1016
+ const prd = path.join(customDst, "bmad-prd.toml");
1017
+ if (fs.existsSync(prd)) scrubPrdToml(prd);
1018
+ note(`bmad custom ${tomls} toml`);
1019
+
1020
+ const replacements = {
1021
+ "constitution.md": path.join(KIT, ".constitution", "method", "constitution.md"),
1022
+ "portability.md": path.join(KIT, ".constitution", "method", "why", "portability.md"),
1023
+ "repo-guide.md": path.join(KIT, ".constitution", "method", "repo-guide.md"),
1024
+ "README.md": path.join(KIT, ".constitution", "method", "README.md"),
1025
+ };
1026
+ for (const [name, dest] of Object.entries(replacements)) {
1027
+ const src = path.join(OVERLAY, name);
1028
+ if (fs.existsSync(src)) {
1029
+ copyFile(src, dest);
1030
+ note(`${name} replaced with kit overlay`);
1031
+ }
1032
+ }
1033
+
1034
+ const source = [
1035
+ `date: ${today()}`,
1036
+ `commit: ${gitHead(live)}`,
1037
+ "kind: working copy that currently carries a newer method",
1038
+ "note: the repo path and product name MUST NOT be recorded here",
1039
+ "",
1040
+ ].join("\n");
1041
+ fs.writeFileSync(path.join(ROOT, "SOURCE"), source, "utf8");
1042
+ ok(`SOURCE stamped ${today()} @ ${gitHead(live)}`);
1043
+ ok(`promoted into ${KIT}`);
1044
+ }
1045
+
1046
+ function cancelIf(value) {
1047
+ if (p.isCancel(value)) {
1048
+ p.cancel("Cancelled.");
1049
+ process.exit(0);
1050
+ }
1051
+ return value;
1052
+ }
1053
+
1054
+ async function runWizard(pre) {
1055
+ p.intro(`WDI Method ${PKG.version}`);
1056
+
1057
+ const dirValue = cancelIf(
1058
+ await p.text({
1059
+ message: "Target repo (the product folder)",
1060
+ placeholder: process.cwd(),
1061
+ defaultValue: pre.dir || process.cwd(),
1062
+ }),
1063
+ );
1064
+ const target = path.resolve(String(dirValue).trim() || process.cwd());
1065
+
1066
+ if (!fs.existsSync(target)) {
1067
+ const create = cancelIf(
1068
+ await p.confirm({ message: `${target} does not exist. Create it?`, initialValue: true }),
1069
+ );
1070
+ if (!create) {
1071
+ p.cancel("No target folder.");
1072
+ process.exit(1);
1073
+ }
1074
+ fs.mkdirSync(target, { recursive: true });
1075
+ }
1076
+
1077
+ const hasBmad = bmadPresent(target);
1078
+ const hasWdi = wdiPresent(target);
1079
+ const nonempty = dirNonEmpty(target);
1080
+
1081
+ const facts = [
1082
+ hasBmad
1083
+ ? `BMad Method: installed${readBmadVersion(target) ? ` (${readBmadVersion(target)})` : ""}`
1084
+ : "BMad Method: not installed",
1085
+ hasWdi ? "WDI Method: already present — the installer will offer an update" : "WDI Method: not present",
1086
+ nonempty ? "Folder is not empty (normal for a product repo already under way)" : "Folder is empty",
1087
+ ].join("\n");
1088
+ p.note(facts, "Detected");
1089
+
1090
+ if (!hasBmad && !pre.skipBmad) {
1091
+ p.note(bmadMissingMessage(), "BMad first");
1092
+ p.outro("Install BMad, then run this again: npx wdi-method");
1093
+ process.exit(1);
1094
+ }
1095
+
1096
+ let first = !hasWdi;
1097
+ if (hasWdi) {
1098
+ const update = cancelIf(
1099
+ await p.confirm({
1100
+ message: "WDI Method is already installed. Update it now?",
1101
+ initialValue: true,
1102
+ }),
1103
+ );
1104
+ first = !update;
1105
+ if (first) {
1106
+ p.cancel("Update declined.");
1107
+ process.exit(0);
1108
+ }
1109
+ } else {
1110
+ const go = cancelIf(
1111
+ await p.confirm({
1112
+ message: `Install WDI Method into ${target}?`,
1113
+ initialValue: true,
1114
+ }),
1115
+ );
1116
+ if (!go) {
1117
+ p.cancel("Install declined.");
1118
+ process.exit(0);
1119
+ }
1120
+ }
1121
+
1122
+ // Every field arrives with an answer already in it, and Enter accepts it. On an update that answer is
1123
+ // what the repo already says; on a first install it is the folder name made readable. Nothing here is
1124
+ // validated as required: a prompt that refuses an empty submission when it already holds a sensible
1125
+ // default is asking the owner to retype something the installer knows.
1126
+ const existing = readIndexIdentity(target);
1127
+ const suggestedName = identityIsPlaceholder(existing.name)
1128
+ ? humaniseFolderName(path.basename(target))
1129
+ : existing.name;
1130
+ const product = cancelIf(
1131
+ await p.text({
1132
+ message: "Product name (one room: index.yaml product.name)",
1133
+ placeholder: suggestedName,
1134
+ defaultValue: suggestedName,
1135
+ }),
1136
+ ).trim() || suggestedName;
1137
+ const client = cancelIf(
1138
+ await p.text({
1139
+ message: "Client name (Enter to leave it as it is)",
1140
+ placeholder: existing.client || "(none)",
1141
+ defaultValue: existing.client || "",
1142
+ }),
1143
+ ).trim();
1144
+
1145
+ // Two questions, and only two. Method terminology, document code prefixes, machine-facing
1146
+ // markers, and code identifiers are always English — MUST NOT be asked about.
1147
+ const policy = readIndexPolicy(target);
1148
+ // Free text, not a list. Write whatever a model understands — "English", "Bahasa Indonesia",
1149
+ // "id". The only value refused is empty.
1150
+ const askLanguage = async (message, current) =>
1151
+ (cancelIf(
1152
+ await p.text({
1153
+ message,
1154
+ placeholder: current || DEFAULT_DOC_LANGUAGE,
1155
+ defaultValue: current || DEFAULT_DOC_LANGUAGE,
1156
+ }),
1157
+ ) || DEFAULT_DOC_LANGUAGE).trim();
1158
+ const docLanguage = await askLanguage(
1159
+ "Language of working-document prose (.what/ .how/ .control/) free text",
1160
+ policy.docLanguage || pre.docLanguage);
1161
+ const docFilenameLanguage = await askLanguage(
1162
+ "Language of document filename slugs — the `UC-` `DEC-` codes stay English",
1163
+ policy.docFilenameLanguage || pre.docFilenameLanguage || docLanguage);
1164
+
1165
+ const selected = cancelIf(
1166
+ await p.multiselect({
1167
+ message: "Which agents get the skills? (space to select)",
1168
+ options: ALL_AGENTS.map((id) => ({ value: id, label: AGENT_LABELS[id] })),
1169
+ initialValues: pre.agents || detectAgents(target),
1170
+ required: true,
1171
+ }),
1172
+ );
1173
+
1174
+ p.note(
1175
+ [
1176
+ "The corpus folder names are fixed — they are not an install option:",
1177
+ " .constitution .control .what .how .work _bmad-output",
1178
+ "",
1179
+ "What gets written for the agents you picked:",
1180
+ selected.includes("claude") ? " .claude/skills/wdi-* CLAUDE.md" : "",
1181
+ selected.includes("cursor") ? " .agents/skills/wdi-* .cursorrules" : "",
1182
+ selected.includes("codex") || selected.includes("cursor") || selected.includes("antigravity")
1183
+ ? " AGENTS.md (the BEGIN:wdi-method block)"
1184
+ : "",
1185
+ selected.includes("antigravity") ? " .agents/AGENTS.md" : "",
1186
+ ]
1187
+ .filter(Boolean)
1188
+ .join("\n"),
1189
+ "Write targets",
1190
+ );
1191
+
1192
+ const okGo = cancelIf(await p.confirm({ message: first ? "Run the install?" : "Run the update?", initialValue: true }));
1193
+ if (!okGo) {
1194
+ p.cancel("Dibatalkan.");
1195
+ process.exit(0);
1196
+ }
1197
+
1198
+ const spinner = p.spinner();
1199
+ spinner.start(first ? "Memasang…" : "Meng-update…");
1200
+ apply(target, selected, {
1201
+ docLanguage,
1202
+ docFilenameLanguage,
1203
+ languageChosen: true,
1204
+ first,
1205
+ product: String(product).trim(),
1206
+ client: String(client).trim(),
1207
+ });
1208
+ spinner.stop(first ? "Terpasang" : "Ter-update");
1209
+ p.outro(first ? "Done. Take the after-install steps above." : "Done. Read the method-block diff in AGENTS.md.");
1210
+ }
1211
+
1212
+ function runNonInteractive(args) {
1213
+ const target = requireTarget(args.dir);
1214
+ const agents = args.agents || detectAgents(target) || ALL_AGENTS.slice();
1215
+ if (args.cmd === "verify") {
1216
+ verify(target, agents);
1217
+ return;
1218
+ }
1219
+ if (!args.skipBmad && !bmadPresent(target)) {
1220
+ die(bmadMissingMessage());
1221
+ }
1222
+ const existing = readIndexIdentity(target);
1223
+ const product = args.product || existing.name;
1224
+ const client = args.client ?? existing.client;
1225
+ const first = args.cmd === "install" || (args.cmd === "wizard" && !wdiPresent(target));
1226
+ apply(target, agents, {
1227
+ first: args.cmd === "update" ? false : first,
1228
+ product,
1229
+ client,
1230
+ docLanguage: args.docLanguage,
1231
+ docFilenameLanguage: args.docFilenameLanguage,
1232
+ languageChosen: Boolean(args.docLanguage || args.docFilenameLanguage),
1233
+ });
1234
+ }
1235
+
1236
+ async function main() {
1237
+ const args = parseArgs(process.argv);
1238
+ if (!["wizard", "install", "update", "verify", "promote"].includes(args.cmd)) {
1239
+ usage();
1240
+ process.exit(2);
1241
+ }
1242
+ if (args.cmd === "promote") {
1243
+ if (!args.dir) die("promote needs a path to the working copy");
1244
+ // `promote` used to BE the workflow: author a rule in a product repo, run it, carry it here.
1245
+ // It is now a rescue tool, and the flag is what makes that structural rather than a paragraph
1246
+ // nobody rereads. Running it by habit overwrites the whole kit with one consumer's copy —
1247
+ // silently reverting every change made here since that repo last updated.
1248
+ if (!args.rescue) {
1249
+ die([
1250
+ "promote overwrites the whole kit from a consumer's copy, and this package is now where a",
1251
+ " method change is authored — see CONTRIBUTING.md. If a change really was made in a",
1252
+ " product repo by mistake and needs rescuing, say so:",
1253
+ "",
1254
+ " npx wdi-method promote <dir> --rescue",
1255
+ ].join("\n"));
1256
+ }
1257
+ note("--rescue: pulling the method back out of a consumer. Read the diff before committing.");
1258
+ promote(args.dir);
1259
+ return;
1260
+ }
1261
+ const wantTui = !args.yes && args.cmd !== "verify" && process.stdin.isTTY && process.stdout.isTTY;
1262
+ if (wantTui) {
1263
+ await runWizard(args);
1264
+ return;
1265
+ }
1266
+ if (args.cmd === "wizard" && !args.yes) {
1267
+ die("not a TTY. Use `install --yes` / `update --yes`, or run this in a terminal.");
1268
+ }
1269
+ if (args.cmd === "wizard") args.cmd = wdiPresent(requireTarget(args.dir)) ? "update" : "install";
1270
+ runNonInteractive(args);
1271
+ }
1272
+
1273
+ main().catch((err) => {
1274
+ console.error(err);
1275
+ process.exit(1);
1276
+ });