wdi-method 0.4.5 → 0.4.6

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (37) hide show
  1. package/README.md +252 -222
  2. package/bin/wdi-method.js +1030 -1029
  3. package/kit/.constitution/document/delivery-flow-guide.md +1 -1
  4. package/kit/.constitution/document/templates/cross-cutting.md +4 -4
  5. package/kit/.constitution/document/templates/model.md +2 -2
  6. package/kit/.constitution/document/templates/questions.md +10 -9
  7. package/kit/.constitution/document/templates/srs.md +2 -2
  8. package/kit/.constitution/scripts/inventory.py +102 -100
  9. package/kit/.constitution/scripts/timeline.py +665 -665
  10. package/kit/.constitution/scripts/validate.py +314 -312
  11. package/kit/assets/bmad-custom/bmad-advanced-elicitation.toml +15 -15
  12. package/kit/assets/bmad-custom/bmad-architecture.toml +17 -15
  13. package/kit/assets/bmad-custom/bmad-build-auto.toml +5 -5
  14. package/kit/assets/bmad-custom/bmad-build.toml +52 -52
  15. package/kit/assets/bmad-custom/bmad-code-review.toml +6 -5
  16. package/kit/assets/bmad-custom/bmad-correct-course.toml +28 -27
  17. package/kit/assets/bmad-custom/bmad-deep-recon.toml +12 -11
  18. package/kit/assets/bmad-custom/bmad-prd.toml +22 -22
  19. package/kit/assets/bmad-custom/bmad-product-brief.toml +34 -34
  20. package/kit/assets/bmad-custom/bmad-retrospective.toml +9 -9
  21. package/kit/assets/bmad-custom/bmad-spec.toml +9 -8
  22. package/kit/assets/bmad-custom/bmad-ux.toml +7 -7
  23. package/kit/assets/bmad-custom/config.toml +3 -3
  24. package/kit/skills/wdi-report/SKILL.md +5 -5
  25. package/package.json +1 -1
  26. package/scaffold/.control/product-glossary.md +21 -21
  27. package/scaffold/.control/project-non-technical-log.md +23 -23
  28. package/scaffold/.control/questions/answered.md +11 -11
  29. package/scaffold/.control/questions/assumptions.md +15 -15
  30. package/scaffold/.control/questions/blocking.md +21 -21
  31. package/scaffold/.control/questions/external.md +11 -11
  32. package/scaffold/.control/registry/components.yaml +21 -21
  33. package/scaffold/.control/registry/defects.yaml +3 -3
  34. package/scaffold/.control/registry/index.yaml +46 -46
  35. package/scaffold/.control/registry/requirements.yaml +15 -15
  36. package/scaffold/.control/registry/risks.yaml +5 -5
  37. package/scaffold/.control/registry/usecases.yaml +6 -6
package/bin/wdi-method.js CHANGED
@@ -1,1029 +1,1030 @@
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 PRODUCT_CONSTITUTION = "constitution.md";
46
- const PRD_SLUG_PLACEHOLDER = "ISI-slug-inisiatif";
47
- const GENERIC_FOLDER_PATTERNS = new Set([
48
- "_product-brief",
49
- "ux",
50
- "architecture",
51
- PRD_SLUG_PLACEHOLDER,
52
- ]);
53
-
54
- const ALL_AGENTS = ["claude", "cursor", "codex", "antigravity"];
55
- const AGENT_LABELS = {
56
- claude: "Claude Code → .claude/skills, CLAUDE.md",
57
- cursor: "Cursor → .agents/skills, .cursorrules",
58
- codex: "Codex → AGENTS.md",
59
- antigravity: "Antigravity → .agents/skills, .agents/AGENTS.md",
60
- };
61
-
62
- const BMAD_INSTALL = `npx bmad-method install`;
63
- const REPO_URL = "https://github.com/wiradigitalid/wdi-method";
64
- const HELP_SKILL = "wdi-help";
65
- const BMAD_REPO = "https://github.com/bmad-code-org/BMAD-METHOD";
66
- const WDI_REPO = "https://github.com/wiradigitalid/wdi-method";
67
-
68
- const RED = "\x1b[31m";
69
- const GREEN = "\x1b[32m";
70
- const DIM = "\x1b[2m";
71
- const RESET = "\x1b[0m";
72
-
73
- function die(msg) {
74
- console.error(`${RED}error:${RESET} ${msg}`);
75
- process.exit(1);
76
- }
77
-
78
- function ok(msg) {
79
- console.log(`${GREEN}ok${RESET} ${msg}`);
80
- }
81
-
82
- function note(msg) {
83
- console.log(`${DIM}·${RESET} ${msg}`);
84
- }
85
-
86
- function usage() {
87
- console.log(`wdi-method ${PKG.version}
88
-
89
- (no command) interactive TUI — detects install vs update
90
- install [dir] first install (TUI unless --yes)
91
- update [dir] update (TUI unless --yes)
92
- verify [dir]
93
- promote <live-dir>
94
-
95
- --yes non-interactive
96
- --agents a,b claude,cursor,codex,antigravity
97
- --product NAME written to index.yaml product.name
98
- --client NAME written to index.yaml product.client (optional)
99
- --doc-language <text> prose of working documents; free text, default English
100
- --doc-filename-language <text> slug part of document filenames; free text, default English
101
- --skip-bmad-check
102
-
103
- BMad first, then this package. ${WDI_REPO}
104
- `);
105
- }
106
-
107
- function parseArgs(argv) {
108
- const args = {
109
- cmd: null,
110
- dir: null,
111
- agents: null,
112
- skipBmad: 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 === "--yes" || t === "-y") args.yes = true;
141
- else if (t === "--agents") {
142
- const raw = rest.shift();
143
- if (!raw) die("--agents needs a comma-separated list");
144
- args.agents = raw.split(",").map((s) => s.trim()).filter(Boolean);
145
- for (const a of args.agents) {
146
- if (!ALL_AGENTS.includes(a)) die(`unknown agent: ${a}`);
147
- }
148
- } else if (t === "--product") args.product = rest.shift();
149
- else if (t === "--client") args.client = rest.shift();
150
- else if (t === "--doc-language" || t === "--doc-filename-language") {
151
- // Free text: "English", "Bahasa Indonesia", "id" — a model reads it, so no list to match.
152
- const raw = (rest.shift() || "").trim();
153
- if (!raw) die(`${t} needs a value, for example: English`);
154
- if (t === "--doc-language") args.docLanguage = raw;
155
- else args.docFilenameLanguage = raw;
156
- }
157
- else if (t.startsWith("-")) die(`unknown flag: ${t}`);
158
- else if (!args.dir) args.dir = t;
159
- else die(`unexpected argument: ${t}`);
160
- }
161
- return args;
162
- }
163
-
164
- // Build output and editor droppings MUST NOT reach the kit. This repository is public, and a
165
- // __pycache__/*.pyc carries the ABSOLUTE PATH of the source it was compiled from — which means a
166
- // product name and a client folder leak into a public package through a file nobody wrote.
167
- // Found 2026-08-18 on the first real promote: inventory.cpython-314.pyc embedded the live repo path.
168
- const SKIP_DIRS = new Set(["__pycache__", "node_modules", ".git", ".pytest_cache", ".ruff_cache",
169
- ".mypy_cache", ".venv", "venv", "dist", "build", ".idea", ".vscode"]);
170
- const SKIP_FILE = /(\.pyc|\.pyo|\.pyd|\.log|\.tmp|\.swp|\.orig|\.rej|\.bak)$|^\.DS_Store$|^Thumbs\.db$/i;
171
-
172
- function walkFiles(dir) {
173
- const out = [];
174
- if (!fs.existsSync(dir)) return out;
175
- for (const entry of fs.readdirSync(dir, { withFileTypes: true })) {
176
- const p = path.join(dir, entry.name);
177
- if (entry.isDirectory()) {
178
- if (SKIP_DIRS.has(entry.name)) continue;
179
- out.push(...walkFiles(p));
180
- } else if (entry.isFile()) {
181
- if (SKIP_FILE.test(entry.name)) continue;
182
- out.push(p);
183
- }
184
- }
185
- return out;
186
- }
187
-
188
- function copyFile(src, dest) {
189
- fs.mkdirSync(path.dirname(dest), { recursive: true });
190
- fs.copyFileSync(src, dest);
191
- }
192
-
193
- function copyTree(src, dest, skipRel) {
194
- let n = 0;
195
- for (const p of walkFiles(src)) {
196
- const rel = posixRel(src, p);
197
- if (skipRel && skipRel(rel)) continue;
198
- copyFile(p, path.join(dest, path.relative(src, p)));
199
- n += 1;
200
- }
201
- return n;
202
- }
203
-
204
- function posixRel(from, to) {
205
- return path.relative(from, to).split(path.sep).join("/");
206
- }
207
-
208
- function acceptedCodebase(file) {
209
- try {
210
- const head = fs.readFileSync(file, "utf8").slice(0, 400);
211
- return /status:\s*Accepted/i.test(head);
212
- } catch {
213
- return false;
214
- }
215
- }
216
-
217
- function bmadPresent(target) {
218
- const markers = [
219
- path.join(target, ".claude", "skills", "bmad-help", "SKILL.md"),
220
- path.join(target, "_bmad", "core", "config.yaml"),
221
- path.join(target, "_bmad", "_config", "manifest.yaml"),
222
- ];
223
- return markers.some((p) => fs.existsSync(p));
224
- }
225
-
226
- function wdiPresent(target) {
227
- return (
228
- fs.existsSync(path.join(target, ".control", "wdi-method.yaml")) ||
229
- fs.existsSync(path.join(target, ".constitution", "method", "README.md"))
230
- );
231
- }
232
-
233
- function dirNonEmpty(target) {
234
- if (!fs.existsSync(target)) return false;
235
- return fs.readdirSync(target).some((n) => n !== ".git" && n !== ".gitignore");
236
- }
237
-
238
- function readBmadVersion(target) {
239
- const manifest = path.join(target, "_bmad", "_config", "manifest.yaml");
240
- if (!fs.existsSync(manifest)) return "";
241
- const text = fs.readFileSync(manifest, "utf8");
242
- const m = text.match(/installation:\s*\n\s*version:\s*(\S+)/);
243
- return m ? m[1] : "";
244
- }
245
-
246
- function detectAgents(target) {
247
- const found = [];
248
- if (
249
- fs.existsSync(path.join(target, ".claude", "skills", "wdi-init", "SKILL.md")) ||
250
- fs.existsSync(path.join(target, ".claude", "skills", "bmad-help", "SKILL.md"))
251
- ) {
252
- found.push("claude");
253
- }
254
- if (
255
- fs.existsSync(path.join(target, ".cursorrules")) ||
256
- fs.existsSync(path.join(target, ".agents", "skills", "wdi-init", "SKILL.md"))
257
- ) {
258
- found.push("cursor");
259
- }
260
- if (fs.existsSync(path.join(target, "AGENTS.md"))) found.push("codex");
261
- if (fs.existsSync(path.join(target, ".agents", "AGENTS.md"))) found.push("antigravity");
262
- return found.length ? [...new Set(found)] : ALL_AGENTS.slice();
263
- }
264
-
265
- function gitHead(repo) {
266
- const r = spawnSync("git", ["-C", repo, "rev-parse", "--short", "HEAD"], {
267
- encoding: "utf8",
268
- });
269
- if (r.status !== 0) return "unknown";
270
- return r.stdout.trim();
271
- }
272
-
273
- function today() {
274
- return new Date().toISOString().slice(0, 10);
275
- }
276
-
277
- function requireKit() {
278
- if (!fs.existsSync(path.join(KIT, ".constitution"))) {
279
- die(`kit missing at ${KIT}`);
280
- }
281
- }
282
-
283
- function requireTarget(dir) {
284
- const target = path.resolve(dir || process.cwd());
285
- if (!fs.existsSync(target) || !fs.statSync(target).isDirectory()) {
286
- die(`target is not a directory: ${target}`);
287
- }
288
- return target;
289
- }
290
-
291
- function skillDests(target, agents) {
292
- const dests = [];
293
- if (agents.includes("claude")) dests.push(path.join(target, ".claude", "skills"));
294
- if (agents.includes("cursor") || agents.includes("antigravity")) {
295
- dests.push(path.join(target, ".agents", "skills"));
296
- }
297
- return dests;
298
- }
299
-
300
- function bmadMissingMessage() {
301
- return [
302
- "BMad Method is not installed in this repo. Install it first, then run this installer again.",
303
- "",
304
- ` ${BMAD_INSTALL}`,
305
- "",
306
- `Source: ${BMAD_REPO}`,
307
- "In the BMad installer, pick the same agents (Claude Code, Cursor, …).",
308
- ].join("\n");
309
- }
310
-
311
- // Kamar custom milik produk. Tiga sifatnya, dan ketiganya harus dipegang bersama:
312
- // install/update menyemai isinya HANYA bila belum adasesudah itu ia tak pernah ditulis lagi
313
- // promote MELEWATINYA seluruhnya, jadi aturan khusus produk tidak mungkin terbit ke repo publik
314
- // agent memuatnya seperti guide lain, jadi ia MENGIKAT
315
- // Konsekuensi yang disengaja: README kamar ini diarang di paket dan tidak pernah pulang lewat promote.
316
- const PROJECT_ROOM = "project/";
317
-
318
- function syncConstitution(target) {
319
- const kitConst = path.join(KIT, ".constitution");
320
- const destConst = path.join(target, ".constitution");
321
- fs.mkdirSync(destConst, { recursive: true });
322
- let written = 0;
323
- let skipped = 0;
324
- for (const file of walkFiles(kitConst)) {
325
- const rel = posixRel(kitConst, file);
326
- const dest = path.join(destConst, rel);
327
- if (rel === PRODUCT_CONSTITUTION && fs.existsSync(dest)) {
328
- skipped += 1;
329
- note(`keep ${rel} (product articles)`);
330
- continue;
331
- }
332
- if (rel.startsWith("codebase/") && fs.existsSync(dest) && acceptedCodebase(dest)) {
333
- skipped += 1;
334
- note(`keep ${rel} (Accepted codebase guide)`);
335
- continue;
336
- }
337
- if (rel.startsWith(PROJECT_ROOM) && fs.existsSync(dest)) {
338
- skipped += 1;
339
- note(`keep ${rel} (product custom room)`);
340
- continue;
341
- }
342
- copyFile(file, dest);
343
- written += 1;
344
- }
345
- return { written, skipped };
346
- }
347
-
348
- function syncSkills(target, agents) {
349
- let n = 0;
350
- const dests = skillDests(target, agents);
351
- if (dests.length === 0) {
352
- note("no skill destinations for selected agents — AGENTS.md still applies");
353
- return 0;
354
- }
355
- for (const name of WDI_SKILLS) {
356
- const src = path.join(KIT, "skills", name);
357
- if (!fs.existsSync(src)) die(`kit missing skill ${name}`);
358
- for (const root of dests) {
359
- const dest = path.join(root, name);
360
- fs.rmSync(dest, { recursive: true, force: true });
361
- n += copyTree(src, dest);
362
- }
363
- }
364
- const removed = pruneRetiredSkills(dests);
365
- return { files: n, removed };
366
- }
367
-
368
- // A wrapper the method RETIRED is worse than a wrapper missing: the folder is still there, its
369
- // SKILL.md still reads like an instruction, and an agent will invoke it while the guide it points
370
- // at is gone. Renaming five wrappers (wdi-apply, wdi-analysis, wdi-structure, …) left exactly that
371
- // in every repo installed before the rename, because update only ever touched the names it knows.
372
- //
373
- // `wdi-` is the method's namespace, so a `wdi-*` folder carrying a SKILL.md and not in WDI_SKILLS is
374
- // ours and retired. Each removal is PRINTED: silent deletion in someone else's repo is not a fix.
375
- function pruneRetiredSkills(dests) {
376
- let removed = 0;
377
- const keep = new Set(WDI_SKILLS);
378
- for (const root of dests) {
379
- if (!fs.existsSync(root)) continue;
380
- for (const entry of fs.readdirSync(root, { withFileTypes: true })) {
381
- if (!entry.isDirectory() || !entry.name.startsWith("wdi-") || keep.has(entry.name)) continue;
382
- const dir = path.join(root, entry.name);
383
- if (!fs.existsSync(path.join(dir, "SKILL.md"))) {
384
- note(`kept ${entry.name} (no SKILL.md — not one of ours)`);
385
- continue;
386
- }
387
- fs.rmSync(dir, { recursive: true, force: true });
388
- note(`removed retired skill ${entry.name}`);
389
- removed += 1;
390
- }
391
- }
392
- return removed;
393
- }
394
-
395
- // `promote` scrubs a product's initiative slug out of bmad-prd.toml before publishing, which is right.
396
- // Writing the scrubbed PLACEHOLDER back into a product repo is not: the first real install replaced a
397
- // live `run_folder_pattern = "toko-tanpa-akun"` with `ISI-slug-inisiatif`, and nothing said so. A value
398
- // the product already chose is not the installer's to overwrite same rule as the custom room and the
399
- // language policy.
400
- const PLACEHOLDER_SLUG = "ISI-slug-inisiatif";
401
- const RUN_FOLDER_LINE = /^(\s*run_folder_pattern\s*=\s*)(".*?"|'.*?')/m;
402
-
403
- // The slug appears MORE THAN ONCE — bmad-prd.toml carries it in `run_folder_pattern` and again inside a
404
- // memlog path, and the file itself says the two lines MUST change together. The first version of this
405
- // function restored only the first line and so produced exactly the inconsistency that file forbids.
406
- // So: read the product's slug once, then put it back everywhere the placeholder appears.
407
- function keepProductSlug(incoming, existing) {
408
- const mineNow = existing.match(RUN_FOLDER_LINE);
409
- if (!mineNow) return null;
410
- const slug = mineNow[2].slice(1, -1);
411
- if (!slug || slug === PLACEHOLDER_SLUG) return null;
412
- if (!incoming.includes(PLACEHOLDER_SLUG)) return null;
413
- // Only where the slug is a VALUE: the quoted setting, and the memlog path built from it. A bare
414
- // mention inside a comment stays the placeholder that sentence explains the pattern, and rewriting
415
- // it would turn a generic explanation into a statement about one initiative.
416
- return incoming
417
- .replaceAll(`"${PLACEHOLDER_SLUG}"`, `"${slug}"`)
418
- .replaceAll(`prd-${PLACEHOLDER_SLUG}`, `prd-${slug}`);
419
- }
420
-
421
- function syncTomls(target) {
422
- const src = path.join(KIT, "assets", "bmad-custom");
423
- const dest = path.join(target, "_bmad", "custom");
424
- fs.mkdirSync(dest, { recursive: true });
425
- let n = 0;
426
- let slugsKept = 0;
427
- for (const file of walkFiles(src)) {
428
- if (!file.endsWith(".toml") || file.endsWith(".user.toml")) continue;
429
- const to = path.join(dest, path.basename(file));
430
- if (fs.existsSync(to)) {
431
- const merged = keepProductSlug(fs.readFileSync(file, "utf8"), fs.readFileSync(to, "utf8"));
432
- if (merged !== null) {
433
- fs.writeFileSync(to, merged);
434
- note(`kept run_folder_pattern in ${path.basename(file)}`);
435
- slugsKept += 1;
436
- n += 1;
437
- continue;
438
- }
439
- }
440
- copyFile(file, to);
441
- n += 1;
442
- }
443
- return { files: n, slugsKept };
444
- }
445
-
446
- function seedControlIfMissing(target) {
447
- const control = path.join(target, ".control");
448
- if (fs.existsSync(control)) {
449
- note(".control/ already present — left untouched");
450
- return;
451
- }
452
- if (!fs.existsSync(SCAFFOLD)) die(`scaffold missing: ${SCAFFOLD}`);
453
- const n = copyTree(SCAFFOLD, control);
454
- ok(`seeded empty .control/ (${n} files)`);
455
- }
456
-
457
- // On a FIRST install these folders are the corpus taking shape. On an UPDATE their absence means
458
- // somebody removed them on purpose `.work/` and `_bmad-output/prior-knowledge/` are exactly the two a
459
- // product retires once its migration is done, and one repo retired them through an applied decision.
460
- // Recreating them then is an installer overruling a decision it cannot read. Seed once, never resurrect.
461
- function seedEmptyLayers(target, { first }) {
462
- const always = [".what", path.join(".how", "_platform")];
463
- const firstOnly = [".work", path.join("_bmad-output", "prior-knowledge")];
464
- for (const rel of first ? [...always, ...firstOnly] : always) {
465
- const dest = path.join(target, rel);
466
- if (!fs.existsSync(dest)) {
467
- fs.mkdirSync(dest, { recursive: true });
468
- note(`created ${rel.replaceAll(path.sep, "/")}/`);
469
- }
470
- }
471
- if (!first) {
472
- for (const rel of firstOnly) {
473
- if (!fs.existsSync(path.join(target, rel))) {
474
- note(`left ${rel.replaceAll(path.sep, "/")}/ absent — a product retires it, not the installer`);
475
- }
476
- }
477
- }
478
- }
479
-
480
- function writeStamp(target) {
481
- const control = path.join(target, ".control");
482
- if (!fs.existsSync(control)) return;
483
- const stamp = [
484
- "# Written by wdi-method install/update. A trace, not a lockfile.",
485
- `wdi_method: ${PKG.version}`,
486
- `bmad_method: ${readBmadVersion(target) || '""'}`,
487
- `installed_at: ${today()}`,
488
- "",
489
- ].join("\n");
490
- fs.writeFileSync(path.join(control, "wdi-method.yaml"), stamp, "utf8");
491
- note("stamped .control/wdi-method.yaml");
492
- }
493
-
494
- function setProductIdentity(target, { name, client }) {
495
- if (!name || identityIsPlaceholder(name)) return;
496
- const file = path.join(target, ".control", "registry", "index.yaml");
497
- if (!fs.existsSync(file)) return;
498
- const next = writeProductIdentity(fs.readFileSync(file, "utf8"), {
499
- name,
500
- client: client ?? "",
501
- });
502
- fs.writeFileSync(file, next.endsWith("\n") ? next : `${next}\n`);
503
- note(`product.name = ${name}`);
504
- }
505
-
506
- // Bahasa dokumen milik PRODUK, jadi update MUST NOT menimpanya. Ia ditulis hanya ketika belum ada —
507
- // sama seperti kamar custom, dan dengan alasan yang sama: setelan yang pernah dipilih seseorang bukan
508
- // milik installer untuk diubah di belakangnya.
509
- function setLanguagePolicy(target, { docLanguage, docFilenameLanguage, chosen }) {
510
- const file = path.join(target, ".control", "registry", "index.yaml");
511
- if (!fs.existsSync(file)) return;
512
- const text = fs.readFileSync(file, "utf8");
513
- const existing = readLanguagePolicy(text);
514
- // `chosen` berarti seseorang benar-benar menjawab — di TUI, atau lewat flag eksplisit. Maka
515
- // jawabannya berlaku. Tanpa itu nilai yang masuk hanyalah default, dan default MUST NOT menimpa
516
- // pilihan yang sudah pernah diambil seseorang.
517
- if (!chosen && existing.docLanguage && existing.docFilenameLanguage) {
518
- note(`kept policy.doc_language = ${existing.docLanguage}, ` +
519
- `doc_filename_language = ${existing.docFilenameLanguage}`);
520
- return;
521
- }
522
- const next = writeLanguagePolicy(text, {
523
- docLanguage: docLanguage || existing.docLanguage || DEFAULT_DOC_LANGUAGE,
524
- docFilenameLanguage:
525
- docFilenameLanguage || existing.docFilenameLanguage || DEFAULT_DOC_LANGUAGE,
526
- });
527
- fs.writeFileSync(file, next.endsWith("\n") ? next : `${next}\n`);
528
- const after = readLanguagePolicy(next);
529
- note(`policy.doc_language = ${after.docLanguage}, ` +
530
- `doc_filename_language = ${after.docFilenameLanguage}`);
531
- }
532
-
533
- // Dibaca SEBELUM writeStamp menimpanya. Tanpa ini tidak ada transisi versi yang bisa dicetak, dan
534
- // "updated" tanpa dari-ke tidak memberi tahu apa pun yang bisa dipakai.
535
- function readStampVersion(target) {
536
- const file = path.join(target, ".control", "wdi-method.yaml");
537
- if (!fs.existsSync(file)) return "";
538
- const m = fs.readFileSync(file, "utf8").match(/^wdi_method:\s*"?([^"\s]+)"?/m);
539
- return m ? m[1] : "";
540
- }
541
-
542
- function readIndexPolicy(target) {
543
- const file = path.join(target, ".control", "registry", "index.yaml");
544
- if (!fs.existsSync(file)) return { docLanguage: "", docFilenameLanguage: "" };
545
- return readLanguagePolicy(fs.readFileSync(file, "utf8"));
546
- }
547
-
548
- function readIndexIdentity(target) {
549
- const file = path.join(target, ".control", "registry", "index.yaml");
550
- if (!fs.existsSync(file)) return { name: "", client: "" };
551
- return readProductIdentity(fs.readFileSync(file, "utf8"));
552
- }
553
-
554
- function upsertAgentFiles(target, agents, productName) {
555
- const template = fs.readFileSync(path.join(OVERLAY, "AGENTS.md"), "utf8");
556
- const agentsFile = path.join(target, "AGENTS.md");
557
- let next;
558
- if (!fs.existsSync(agentsFile)) {
559
- next = fillProductTitle(template, productName || "{product}");
560
- ok("AGENTS.md created rewrite ## Code for this product");
561
- } else {
562
- next = upsertMethodBlock(fs.readFileSync(agentsFile, "utf8"), template);
563
- note("AGENTS.md method block refreshed; product sections kept");
564
- }
565
- if (!next.endsWith("\n")) next += "\n";
566
- fs.writeFileSync(agentsFile, next);
567
-
568
- const mirrors = [];
569
- if (agents.includes("cursor")) mirrors.push(path.join(target, ".cursorrules"));
570
- if (agents.includes("cursor") || agents.includes("antigravity")) {
571
- mirrors.push(path.join(target, ".agents", "AGENTS.md"));
572
- }
573
- for (const mirror of mirrors) {
574
- fs.mkdirSync(path.dirname(mirror), { recursive: true });
575
- if (fs.existsSync(mirror)) {
576
- const patched = upsertMethodBlock(fs.readFileSync(mirror, "utf8"), template);
577
- fs.writeFileSync(mirror, patched.endsWith("\n") ? patched : `${patched}\n`);
578
- note(`method block refreshed in ${posixRel(target, mirror)}`);
579
- } else {
580
- fs.writeFileSync(mirror, next);
581
- note(`created ${posixRel(target, mirror)}`);
582
- }
583
- }
584
-
585
- if (agents.includes("claude")) {
586
- const claude = path.join(target, "CLAUDE.md");
587
- if (!fs.existsSync(claude)) {
588
- fs.writeFileSync(claude, "@AGENTS.md\n");
589
- note("CLAUDE.md created as @AGENTS.md");
590
- }
591
- }
592
- }
593
-
594
- // What a run MUST leave a reader able to answer: which version replaced which, what was written, what
595
- // was KEPT, and what to do next. The third is the one usually missing, and it is the one that decides
596
- // whether somebody trusts running this over a repo they have already put work into.
597
- function summaryLine(label, value) {
598
- console.log(` ${DIM}${label.padEnd(11)}${RESET}${value}`);
599
- }
600
-
601
- function printSummary(target, agents, { first, was, written, skipped, skills, tomls }) {
602
- const now = PKG.version;
603
- const version = first
604
- ? `${now} first install`
605
- : was && was !== now
606
- ? `${was} ${DIM}→${RESET} ${now}`
607
- : `${now} ${DIM}(unchanged)${RESET}`;
608
- const bmad = readBmadVersion(target);
609
-
610
- const kept = [];
611
- if (skipped) kept.push(`${skipped} constitution file${skipped === 1 ? "" : "s"}`);
612
- if (tomls.slugsKept) kept.push(`${tomls.slugsKept} initiative slug${tomls.slugsKept === 1 ? "" : "s"}`);
613
- // On a first install the language was just CHOSEN, not kept — saying "kept" there reads as if the
614
- // installer had found something it decided to leave alone, which is the opposite of what happened.
615
- const policy = readIndexPolicy(target);
616
- if (policy.docLanguage && !first) kept.push(`language (${policy.docLanguage})`);
617
- if (fs.existsSync(path.join(target, ".constitution", "project"))) kept.push(".constitution/project/");
618
-
619
- console.log("");
620
- console.log(`${DIM}────${RESET} WDI Method ${DIM}${"".repeat(46)}${RESET}`);
621
- summaryLine("version", version);
622
- if (bmad) summaryLine("bmad", bmad);
623
- summaryLine("target", target);
624
- console.log("");
625
- summaryLine("written", `${written} constitution · ${skills.files} skill files · ${tomls.files} bmad overrides`);
626
- if (kept.length) summaryLine("kept", kept.join(" · "));
627
- if (skills.removed) {
628
- summaryLine("removed", `${skills.removed} retired wrapper${skills.removed === 1 ? "" : "s"}`);
629
- }
630
- if (first && policy.docLanguage) {
631
- summaryLine("language", `${policy.docLanguage} · filenames ${policy.docFilenameLanguage}`);
632
- }
633
- summaryLine("agents", agents.join(", ") || "none");
634
- console.log("");
635
- summaryLine("next", `invoke the ${HELP_SKILL} skill and ask what to do`);
636
- summaryLine("", REPO_URL);
637
- console.log(`${DIM}${"".repeat(62)}${RESET}`);
638
- }
639
-
640
- function printNextSteps({ first, productSet }) {
641
- console.log("");
642
- console.log(first ? "After install:" : "After update:");
643
- if (first) {
644
- if (!productSet) {
645
- console.log(" 1. Fill product.name (and product.client if there is one) in .control/registry/index.yaml.");
646
- } else {
647
- console.log(" 1. product.name is set. G1 confirms it in the brief.");
648
- }
649
- console.log(" 2. Rewrite .constitution/constitution.md Articles 2 and 5 for this product.");
650
- console.log(" Article 1 cites index.yaml do not become a second source for the name.");
651
- console.log(" 3. Write ## Code in AGENTS.md (where the app lives). Leave the BEGIN:wdi-method block alone.");
652
- console.log(" 4. Run the wdi-init skill, intent setup.");
653
- console.log(" 5. Sort the documents you already have. Do not move any of them in this step.");
654
- console.log("");
655
- console.log("Next update:");
656
- console.log(" npx wdi-method");
657
- console.log(" (the TUI offers the update) or: npx wdi-method update --yes");
658
- } else {
659
- console.log(" 1. The <!-- BEGIN:wdi-method --> block in AGENTS.md was replaced. Read the diff.");
660
- console.log(" 2. constitution.md Articles 1-2-5, ## Code, and *.user.toml were not overwritten.");
661
- console.log(" 3. If BMad has new skills, install those first, then run this update again.");
662
- }
663
- }
664
-
665
- function apply(target, agents,
666
- { first, product, client, docLanguage, docFilenameLanguage, languageChosen }) {
667
- requireKit();
668
- const was = readStampVersion(target);
669
- const { written, skipped } = syncConstitution(target);
670
- note(`constitution wrote ${written}, kept ${skipped}`);
671
- const skills = syncSkills(target, agents);
672
- note(`skills ${skills.files} files`);
673
- const tomls = syncTomls(target);
674
- note(`bmad custom ${tomls.files} toml → _bmad/custom/`);
675
- if (first) seedControlIfMissing(target);
676
- seedEmptyLayers(target, { first });
677
- setProductIdentity(target, { name: product, client });
678
- setLanguagePolicy(target, { docLanguage, docFilenameLanguage, chosen: languageChosen });
679
- upsertAgentFiles(target, agents, product);
680
- writeStamp(target);
681
- printSummary(target, agents, { first, was, written, skipped, skills, tomls });
682
- printNextSteps({
683
- first,
684
- productSet: Boolean(product) && !identityIsPlaceholder(product),
685
- });
686
- }
687
-
688
- function verify(target, agents) {
689
- requireKit();
690
- const missing = [];
691
- const kitConst = path.join(KIT, ".constitution");
692
- for (const file of walkFiles(kitConst)) {
693
- const rel = posixRel(kitConst, file);
694
- const dest = path.join(target, ".constitution", rel);
695
- if (!fs.existsSync(dest)) missing.push(`.constitution/${rel}`);
696
- }
697
- for (const name of WDI_SKILLS) {
698
- for (const root of skillDests(target, agents)) {
699
- const dest = path.join(root, name, "SKILL.md");
700
- if (!fs.existsSync(dest)) missing.push(posixRel(target, dest));
701
- }
702
- }
703
- const custom = path.join(KIT, "assets", "bmad-custom");
704
- for (const file of walkFiles(custom)) {
705
- if (!file.endsWith(".toml")) continue;
706
- const dest = path.join(target, "_bmad", "custom", path.basename(file));
707
- if (!fs.existsSync(dest)) missing.push(`_bmad/custom/${path.basename(file)}`);
708
- }
709
- if (fs.existsSync(path.join(target, ".control"))) {
710
- for (const file of walkFiles(SCAFFOLD)) {
711
- const rel = posixRel(SCAFFOLD, file);
712
- const dest = path.join(target, ".control", rel);
713
- if (!fs.existsSync(dest)) missing.push(`.control/${rel}`);
714
- }
715
- } else {
716
- missing.push(".control/ (folder missing — first install should have seeded it)");
717
- }
718
- for (const required of ["AGENTS.md", path.join(".constitution", "constitution.md")]) {
719
- if (!fs.existsSync(path.join(target, required))) missing.push(required.replaceAll(path.sep, "/"));
720
- }
721
- if (missing.length) {
722
- console.error(`${RED}missing ${missing.length}${RESET}`);
723
- for (const m of missing) console.error(` ${m}`);
724
- process.exit(1);
725
- }
726
- ok(`method files present in ${target}`);
727
- note("extra product files are expected and were not checked");
728
- }
729
-
730
- function scrubPrdToml(file) {
731
- const raw = fs.readFileSync(file, "utf8");
732
- const m = raw.match(/run_folder_pattern\s*=\s*"([^"]+)"/);
733
- if (!m) return;
734
- const slug = m[1];
735
- if (GENERIC_FOLDER_PATTERNS.has(slug)) return;
736
- fs.writeFileSync(file, raw.split(slug).join(PRD_SLUG_PLACEHOLDER), "utf8");
737
- note("bmad-prd.toml initiative slug scrubbed to placeholder");
738
- }
739
-
740
- function promote(live) {
741
- live = path.resolve(live);
742
- if (!fs.existsSync(path.join(live, ".constitution"))) {
743
- die(`${live} has no .constitution/ — is this a method-carrying repo?`);
744
- }
745
- // README kamar custom dikarang di paket dan MUST bertahan melewati rmSync di bawah. Dibaca di
746
- // sini, bukan sesudahnya versi pertama patch ini membacanya sesudah kit dihapus, sehingga
747
- // nilainya selalu null dan README-nya hilang tiap promote. Tes project-room yang menemukannya.
748
- const roomKit = path.join(KIT, ".constitution", PROJECT_ROOM, "README.md");
749
- const roomKept = fs.existsSync(roomKit) ? fs.readFileSync(roomKit, "utf8") : null;
750
-
751
- fs.rmSync(KIT, { recursive: true, force: true });
752
- fs.mkdirSync(KIT, { recursive: true });
753
-
754
- const nConst = copyTree(path.join(live, ".constitution"), path.join(KIT, ".constitution"),
755
- (rel) => rel.startsWith(PROJECT_ROOM));
756
- note(`constitution ${nConst} files (${PROJECT_ROOM} skipped — it is the product's)`);
757
- if (roomKept !== null) {
758
- fs.mkdirSync(path.dirname(roomKit), { recursive: true });
759
- fs.writeFileSync(roomKit, roomKept, "utf8");
760
- note(`${PROJECT_ROOM}README.md restored from the package — promote never carries it home`);
761
- }
762
-
763
- let copiedSkills = 0;
764
- const skillsSrc = path.join(live, ".claude", "skills");
765
- for (const name of WDI_SKILLS) {
766
- const src = path.join(skillsSrc, name);
767
- if (!fs.existsSync(src)) die(`skill missing in live repo: ${src}`);
768
- copiedSkills += copyTree(src, path.join(KIT, "skills", name));
769
- }
770
- note(`skills ${copiedSkills} files (${WDI_SKILLS.length} wrappers)`);
771
-
772
- const customSrc = path.join(live, "_bmad", "custom");
773
- const customDst = path.join(KIT, "assets", "bmad-custom");
774
- fs.mkdirSync(customDst, { recursive: true });
775
- let tomls = 0;
776
- if (fs.existsSync(customSrc)) {
777
- for (const file of walkFiles(customSrc)) {
778
- if (!file.endsWith(".toml") || file.endsWith(".user.toml")) continue;
779
- copyFile(file, path.join(customDst, path.basename(file)));
780
- tomls += 1;
781
- }
782
- }
783
- const prd = path.join(customDst, "bmad-prd.toml");
784
- if (fs.existsSync(prd)) scrubPrdToml(prd);
785
- note(`bmad custom ${tomls} toml`);
786
-
787
- const replacements = {
788
- "constitution.md": path.join(KIT, ".constitution", "constitution.md"),
789
- "portability.md": path.join(KIT, ".constitution", "method", "portability.md"),
790
- "repo-guide.md": path.join(KIT, ".constitution", "repo-guide.md"),
791
- "README.md": path.join(KIT, ".constitution", "README.md"),
792
- };
793
- for (const [name, dest] of Object.entries(replacements)) {
794
- const src = path.join(OVERLAY, name);
795
- if (fs.existsSync(src)) {
796
- copyFile(src, dest);
797
- note(`${name} replaced with kit overlay`);
798
- }
799
- }
800
-
801
- const source = [
802
- `date: ${today()}`,
803
- `commit: ${gitHead(live)}`,
804
- "kind: working copy that currently carries a newer method",
805
- "note: the repo path and product name MUST NOT be recorded here",
806
- "",
807
- ].join("\n");
808
- fs.writeFileSync(path.join(ROOT, "SOURCE"), source, "utf8");
809
- ok(`SOURCE stamped ${today()} @ ${gitHead(live)}`);
810
- ok(`promoted into ${KIT}`);
811
- }
812
-
813
- function cancelIf(value) {
814
- if (p.isCancel(value)) {
815
- p.cancel("Cancelled.");
816
- process.exit(0);
817
- }
818
- return value;
819
- }
820
-
821
- async function runWizard(pre) {
822
- p.intro(`WDI Method ${PKG.version}`);
823
-
824
- const dirValue = cancelIf(
825
- await p.text({
826
- message: "Target repo (the product folder)",
827
- placeholder: process.cwd(),
828
- defaultValue: pre.dir || process.cwd(),
829
- }),
830
- );
831
- const target = path.resolve(String(dirValue).trim() || process.cwd());
832
-
833
- if (!fs.existsSync(target)) {
834
- const create = cancelIf(
835
- await p.confirm({ message: `${target} does not exist. Create it?`, initialValue: true }),
836
- );
837
- if (!create) {
838
- p.cancel("No target folder.");
839
- process.exit(1);
840
- }
841
- fs.mkdirSync(target, { recursive: true });
842
- }
843
-
844
- const hasBmad = bmadPresent(target);
845
- const hasWdi = wdiPresent(target);
846
- const nonempty = dirNonEmpty(target);
847
-
848
- const facts = [
849
- hasBmad
850
- ? `BMad Method: terpasang${readBmadVersion(target) ? ` (${readBmadVersion(target)})` : ""}`
851
- : "BMad Method: belum terpasang",
852
- hasWdi ? "WDI Method: sudah ada — installer akan menawarkan update" : "WDI Method: belum ada",
853
- nonempty ? "Folder tidak kosong (repo produk yang sudah jalan itu biasa)" : "Folder masih kosong",
854
- ].join("\n");
855
- p.note(facts, "Deteksi");
856
-
857
- if (!hasBmad && !pre.skipBmad) {
858
- p.note(bmadMissingMessage(), "BMad first");
859
- p.outro("Install BMad, then run this again: npx wdi-method");
860
- process.exit(1);
861
- }
862
-
863
- let first = !hasWdi;
864
- if (hasWdi) {
865
- const update = cancelIf(
866
- await p.confirm({
867
- message: "WDI Method is already installed. Update it now?",
868
- initialValue: true,
869
- }),
870
- );
871
- first = !update;
872
- if (first) {
873
- p.cancel("Update declined.");
874
- process.exit(0);
875
- }
876
- } else {
877
- const go = cancelIf(
878
- await p.confirm({
879
- message: `Install WDI Method into ${target}?`,
880
- initialValue: true,
881
- }),
882
- );
883
- if (!go) {
884
- p.cancel("Install declined.");
885
- process.exit(0);
886
- }
887
- }
888
-
889
- // Every field arrives with an answer already in it, and Enter accepts it. On an update that answer is
890
- // what the repo already says; on a first install it is the folder name made readable. Nothing here is
891
- // validated as required: a prompt that refuses an empty submission when it already holds a sensible
892
- // default is asking the owner to retype something the installer knows.
893
- const existing = readIndexIdentity(target);
894
- const suggestedName = identityIsPlaceholder(existing.name)
895
- ? humaniseFolderName(path.basename(target))
896
- : existing.name;
897
- const product = cancelIf(
898
- await p.text({
899
- message: "Product name (one room: index.yaml product.name)",
900
- placeholder: suggestedName,
901
- defaultValue: suggestedName,
902
- }),
903
- ).trim() || suggestedName;
904
- const client = cancelIf(
905
- await p.text({
906
- message: "Client name (Enter to leave it as it is)",
907
- placeholder: existing.client || "(none)",
908
- defaultValue: existing.client || "",
909
- }),
910
- ).trim();
911
-
912
- // Dua pertanyaan, dan hanya dua. Istilah metodologi, kode di depan nama berkas, penanda
913
- // machine-facing, dan identifier kode selalu English MUST NOT ditanyakan.
914
- const policy = readIndexPolicy(target);
915
- // Teks bebas, bukan daftar. Tulis apa saja yang dimengerti sebuah model — "English",
916
- // "Bahasa Indonesia", "id". Yang ditolak hanya kosong.
917
- const askLanguage = async (message, current) =>
918
- (cancelIf(
919
- await p.text({
920
- message,
921
- placeholder: current || DEFAULT_DOC_LANGUAGE,
922
- defaultValue: current || DEFAULT_DOC_LANGUAGE,
923
- }),
924
- ) || DEFAULT_DOC_LANGUAGE).trim();
925
- const docLanguage = await askLanguage(
926
- "Language of working-document prose (.what/ .how/ .control/) — free text",
927
- policy.docLanguage || pre.docLanguage);
928
- const docFilenameLanguage = await askLanguage(
929
- "Language of document filename slugs — the `UC-` `DEC-` codes stay English",
930
- policy.docFilenameLanguage || pre.docFilenameLanguage || docLanguage);
931
-
932
- const selected = cancelIf(
933
- await p.multiselect({
934
- message: "Which agents get the skills? (space to select)",
935
- options: ALL_AGENTS.map((id) => ({ value: id, label: AGENT_LABELS[id] })),
936
- initialValues: pre.agents || detectAgents(target),
937
- required: true,
938
- }),
939
- );
940
-
941
- p.note(
942
- [
943
- "The corpus folder names are fixed — they are not an install option:",
944
- " .constitution .control .what .how .work _bmad-output",
945
- "",
946
- "What gets written for the agents you picked:",
947
- selected.includes("claude") ? " .claude/skills/wdi-* CLAUDE.md" : "",
948
- selected.includes("cursor") ? " .agents/skills/wdi-* .cursorrules" : "",
949
- selected.includes("codex") || selected.includes("cursor") || selected.includes("antigravity")
950
- ? " AGENTS.md (the BEGIN:wdi-method block)"
951
- : "",
952
- selected.includes("antigravity") ? " .agents/AGENTS.md" : "",
953
- ]
954
- .filter(Boolean)
955
- .join("\n"),
956
- "Write targets",
957
- );
958
-
959
- const okGo = cancelIf(await p.confirm({ message: first ? "Run the install?" : "Run the update?", initialValue: true }));
960
- if (!okGo) {
961
- p.cancel("Dibatalkan.");
962
- process.exit(0);
963
- }
964
-
965
- const spinner = p.spinner();
966
- spinner.start(first ? "Memasang…" : "Meng-update…");
967
- apply(target, selected, {
968
- docLanguage,
969
- docFilenameLanguage,
970
- languageChosen: true,
971
- first,
972
- product: String(product).trim(),
973
- client: String(client).trim(),
974
- });
975
- spinner.stop(first ? "Terpasang" : "Ter-update");
976
- p.outro(first ? "Done. Take the after-install steps above." : "Done. Read the method-block diff in AGENTS.md.");
977
- }
978
-
979
- function runNonInteractive(args) {
980
- const target = requireTarget(args.dir);
981
- const agents = args.agents || detectAgents(target) || ALL_AGENTS.slice();
982
- if (args.cmd === "verify") {
983
- verify(target, agents);
984
- return;
985
- }
986
- if (!args.skipBmad && !bmadPresent(target)) {
987
- die(bmadMissingMessage());
988
- }
989
- const existing = readIndexIdentity(target);
990
- const product = args.product || existing.name;
991
- const client = args.client ?? existing.client;
992
- const first = args.cmd === "install" || (args.cmd === "wizard" && !wdiPresent(target));
993
- apply(target, agents, {
994
- first: args.cmd === "update" ? false : first,
995
- product,
996
- client,
997
- docLanguage: args.docLanguage,
998
- docFilenameLanguage: args.docFilenameLanguage,
999
- languageChosen: Boolean(args.docLanguage || args.docFilenameLanguage),
1000
- });
1001
- }
1002
-
1003
- async function main() {
1004
- const args = parseArgs(process.argv);
1005
- if (!["wizard", "install", "update", "verify", "promote"].includes(args.cmd)) {
1006
- usage();
1007
- process.exit(2);
1008
- }
1009
- if (args.cmd === "promote") {
1010
- if (!args.dir) die("promote needs a path to the working copy");
1011
- promote(args.dir);
1012
- return;
1013
- }
1014
- const wantTui = !args.yes && args.cmd !== "verify" && process.stdin.isTTY && process.stdout.isTTY;
1015
- if (wantTui) {
1016
- await runWizard(args);
1017
- return;
1018
- }
1019
- if (args.cmd === "wizard" && !args.yes) {
1020
- die("bukan TTY. Pakai `install --yes` / `update --yes`, atau jalankan di terminal.");
1021
- }
1022
- if (args.cmd === "wizard") args.cmd = wdiPresent(requireTarget(args.dir)) ? "update" : "install";
1023
- runNonInteractive(args);
1024
- }
1025
-
1026
- main().catch((err) => {
1027
- console.error(err);
1028
- process.exit(1);
1029
- });
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 PRODUCT_CONSTITUTION = "constitution.md";
46
+ const PRD_SLUG_PLACEHOLDER = "FILL-initiative-slug";
47
+ const GENERIC_FOLDER_PATTERNS = new Set([
48
+ "_product-brief",
49
+ "ux",
50
+ "architecture",
51
+ PRD_SLUG_PLACEHOLDER,
52
+ ]);
53
+
54
+ const ALL_AGENTS = ["claude", "cursor", "codex", "antigravity"];
55
+ const AGENT_LABELS = {
56
+ claude: "Claude Code → .claude/skills, CLAUDE.md",
57
+ cursor: "Cursor → .agents/skills, .cursorrules",
58
+ codex: "Codex → AGENTS.md",
59
+ antigravity: "Antigravity → .agents/skills, .agents/AGENTS.md",
60
+ };
61
+
62
+ const BMAD_INSTALL = `npx bmad-method install`;
63
+ const REPO_URL = "https://github.com/wiradigitalid/wdi-method";
64
+ const HELP_SKILL = "wdi-help";
65
+ const BMAD_REPO = "https://github.com/bmad-code-org/BMAD-METHOD";
66
+ const WDI_REPO = "https://github.com/wiradigitalid/wdi-method";
67
+
68
+ const RED = "\x1b[31m";
69
+ const GREEN = "\x1b[32m";
70
+ const DIM = "\x1b[2m";
71
+ const RESET = "\x1b[0m";
72
+
73
+ function die(msg) {
74
+ console.error(`${RED}error:${RESET} ${msg}`);
75
+ process.exit(1);
76
+ }
77
+
78
+ function ok(msg) {
79
+ console.log(`${GREEN}ok${RESET} ${msg}`);
80
+ }
81
+
82
+ function note(msg) {
83
+ console.log(`${DIM}·${RESET} ${msg}`);
84
+ }
85
+
86
+ function usage() {
87
+ console.log(`wdi-method ${PKG.version}
88
+
89
+ (no command) interactive TUI — detects install vs update
90
+ install [dir] first install (TUI unless --yes)
91
+ update [dir] update (TUI unless --yes)
92
+ verify [dir]
93
+ promote <live-dir>
94
+
95
+ --yes non-interactive
96
+ --agents a,b claude,cursor,codex,antigravity
97
+ --product NAME written to index.yaml product.name
98
+ --client NAME written to index.yaml product.client (optional)
99
+ --doc-language <text> prose of working documents; free text, default English
100
+ --doc-filename-language <text> slug part of document filenames; free text, default English
101
+ --skip-bmad-check
102
+
103
+ BMad first, then this package. ${WDI_REPO}
104
+ `);
105
+ }
106
+
107
+ function parseArgs(argv) {
108
+ const args = {
109
+ cmd: null,
110
+ dir: null,
111
+ agents: null,
112
+ skipBmad: 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 === "--yes" || t === "-y") args.yes = true;
141
+ else if (t === "--agents") {
142
+ const raw = rest.shift();
143
+ if (!raw) die("--agents needs a comma-separated list");
144
+ args.agents = raw.split(",").map((s) => s.trim()).filter(Boolean);
145
+ for (const a of args.agents) {
146
+ if (!ALL_AGENTS.includes(a)) die(`unknown agent: ${a}`);
147
+ }
148
+ } else if (t === "--product") args.product = rest.shift();
149
+ else if (t === "--client") args.client = rest.shift();
150
+ else if (t === "--doc-language" || t === "--doc-filename-language") {
151
+ // Free text: "English", "Bahasa Indonesia", "id" — a model reads it, so no list to match.
152
+ const raw = (rest.shift() || "").trim();
153
+ if (!raw) die(`${t} needs a value, for example: English`);
154
+ if (t === "--doc-language") args.docLanguage = raw;
155
+ else args.docFilenameLanguage = raw;
156
+ }
157
+ else if (t.startsWith("-")) die(`unknown flag: ${t}`);
158
+ else if (!args.dir) args.dir = t;
159
+ else die(`unexpected argument: ${t}`);
160
+ }
161
+ return args;
162
+ }
163
+
164
+ // Build output and editor droppings MUST NOT reach the kit. This repository is public, and a
165
+ // __pycache__/*.pyc carries the ABSOLUTE PATH of the source it was compiled from — which means a
166
+ // product name and a client folder leak into a public package through a file nobody wrote.
167
+ // Found 2026-08-18 on the first real promote: inventory.cpython-314.pyc embedded the live repo path.
168
+ const SKIP_DIRS = new Set(["__pycache__", "node_modules", ".git", ".pytest_cache", ".ruff_cache",
169
+ ".mypy_cache", ".venv", "venv", "dist", "build", ".idea", ".vscode"]);
170
+ const SKIP_FILE = /(\.pyc|\.pyo|\.pyd|\.log|\.tmp|\.swp|\.orig|\.rej|\.bak)$|^\.DS_Store$|^Thumbs\.db$/i;
171
+
172
+ function walkFiles(dir) {
173
+ const out = [];
174
+ if (!fs.existsSync(dir)) return out;
175
+ for (const entry of fs.readdirSync(dir, { withFileTypes: true })) {
176
+ const p = path.join(dir, entry.name);
177
+ if (entry.isDirectory()) {
178
+ if (SKIP_DIRS.has(entry.name)) continue;
179
+ out.push(...walkFiles(p));
180
+ } else if (entry.isFile()) {
181
+ if (SKIP_FILE.test(entry.name)) continue;
182
+ out.push(p);
183
+ }
184
+ }
185
+ return out;
186
+ }
187
+
188
+ function copyFile(src, dest) {
189
+ fs.mkdirSync(path.dirname(dest), { recursive: true });
190
+ fs.copyFileSync(src, dest);
191
+ }
192
+
193
+ function copyTree(src, dest, skipRel) {
194
+ let n = 0;
195
+ for (const p of walkFiles(src)) {
196
+ const rel = posixRel(src, p);
197
+ if (skipRel && skipRel(rel)) continue;
198
+ copyFile(p, path.join(dest, path.relative(src, p)));
199
+ n += 1;
200
+ }
201
+ return n;
202
+ }
203
+
204
+ function posixRel(from, to) {
205
+ return path.relative(from, to).split(path.sep).join("/");
206
+ }
207
+
208
+ function acceptedCodebase(file) {
209
+ try {
210
+ const head = fs.readFileSync(file, "utf8").slice(0, 400);
211
+ return /status:\s*Accepted/i.test(head);
212
+ } catch {
213
+ return false;
214
+ }
215
+ }
216
+
217
+ function bmadPresent(target) {
218
+ const markers = [
219
+ path.join(target, ".claude", "skills", "bmad-help", "SKILL.md"),
220
+ path.join(target, "_bmad", "core", "config.yaml"),
221
+ path.join(target, "_bmad", "_config", "manifest.yaml"),
222
+ ];
223
+ return markers.some((p) => fs.existsSync(p));
224
+ }
225
+
226
+ function wdiPresent(target) {
227
+ return (
228
+ fs.existsSync(path.join(target, ".control", "wdi-method.yaml")) ||
229
+ fs.existsSync(path.join(target, ".constitution", "method", "README.md"))
230
+ );
231
+ }
232
+
233
+ function dirNonEmpty(target) {
234
+ if (!fs.existsSync(target)) return false;
235
+ return fs.readdirSync(target).some((n) => n !== ".git" && n !== ".gitignore");
236
+ }
237
+
238
+ function readBmadVersion(target) {
239
+ const manifest = path.join(target, "_bmad", "_config", "manifest.yaml");
240
+ if (!fs.existsSync(manifest)) return "";
241
+ const text = fs.readFileSync(manifest, "utf8");
242
+ const m = text.match(/installation:\s*\n\s*version:\s*(\S+)/);
243
+ return m ? m[1] : "";
244
+ }
245
+
246
+ function detectAgents(target) {
247
+ const found = [];
248
+ if (
249
+ fs.existsSync(path.join(target, ".claude", "skills", "wdi-init", "SKILL.md")) ||
250
+ fs.existsSync(path.join(target, ".claude", "skills", "bmad-help", "SKILL.md"))
251
+ ) {
252
+ found.push("claude");
253
+ }
254
+ if (
255
+ fs.existsSync(path.join(target, ".cursorrules")) ||
256
+ fs.existsSync(path.join(target, ".agents", "skills", "wdi-init", "SKILL.md"))
257
+ ) {
258
+ found.push("cursor");
259
+ }
260
+ if (fs.existsSync(path.join(target, "AGENTS.md"))) found.push("codex");
261
+ if (fs.existsSync(path.join(target, ".agents", "AGENTS.md"))) found.push("antigravity");
262
+ return found.length ? [...new Set(found)] : ALL_AGENTS.slice();
263
+ }
264
+
265
+ function gitHead(repo) {
266
+ const r = spawnSync("git", ["-C", repo, "rev-parse", "--short", "HEAD"], {
267
+ encoding: "utf8",
268
+ });
269
+ if (r.status !== 0) return "unknown";
270
+ return r.stdout.trim();
271
+ }
272
+
273
+ function today() {
274
+ return new Date().toISOString().slice(0, 10);
275
+ }
276
+
277
+ function requireKit() {
278
+ if (!fs.existsSync(path.join(KIT, ".constitution"))) {
279
+ die(`kit missing at ${KIT}`);
280
+ }
281
+ }
282
+
283
+ function requireTarget(dir) {
284
+ const target = path.resolve(dir || process.cwd());
285
+ if (!fs.existsSync(target) || !fs.statSync(target).isDirectory()) {
286
+ die(`target is not a directory: ${target}`);
287
+ }
288
+ return target;
289
+ }
290
+
291
+ function skillDests(target, agents) {
292
+ const dests = [];
293
+ if (agents.includes("claude")) dests.push(path.join(target, ".claude", "skills"));
294
+ if (agents.includes("cursor") || agents.includes("antigravity")) {
295
+ dests.push(path.join(target, ".agents", "skills"));
296
+ }
297
+ return dests;
298
+ }
299
+
300
+ function bmadMissingMessage() {
301
+ return [
302
+ "BMad Method is not installed in this repo. Install it first, then run this installer again.",
303
+ "",
304
+ ` ${BMAD_INSTALL}`,
305
+ "",
306
+ `Source: ${BMAD_REPO}`,
307
+ "In the BMad installer, pick the same agents (Claude Code, Cursor, …).",
308
+ ].join("\n");
309
+ }
310
+
311
+ // The product's custom room. Three properties, and all three MUST hold together:
312
+ // install/update seeds its content ONLY when absentnever written again after that
313
+ // promote SKIPS it entirely, so a product's own rules can never reach the public repo
314
+ // agent loads it like any other guide, so it BINDS
315
+ // The deliberate consequence: this room's README is authored in the package and never comes home
316
+ // through promote.
317
+ const PROJECT_ROOM = "project/";
318
+
319
+ function syncConstitution(target) {
320
+ const kitConst = path.join(KIT, ".constitution");
321
+ const destConst = path.join(target, ".constitution");
322
+ fs.mkdirSync(destConst, { recursive: true });
323
+ let written = 0;
324
+ let skipped = 0;
325
+ for (const file of walkFiles(kitConst)) {
326
+ const rel = posixRel(kitConst, file);
327
+ const dest = path.join(destConst, rel);
328
+ if (rel === PRODUCT_CONSTITUTION && fs.existsSync(dest)) {
329
+ skipped += 1;
330
+ note(`keep ${rel} (product articles)`);
331
+ continue;
332
+ }
333
+ if (rel.startsWith("codebase/") && fs.existsSync(dest) && acceptedCodebase(dest)) {
334
+ skipped += 1;
335
+ note(`keep ${rel} (Accepted codebase guide)`);
336
+ continue;
337
+ }
338
+ if (rel.startsWith(PROJECT_ROOM) && fs.existsSync(dest)) {
339
+ skipped += 1;
340
+ note(`keep ${rel} (product custom room)`);
341
+ continue;
342
+ }
343
+ copyFile(file, dest);
344
+ written += 1;
345
+ }
346
+ return { written, skipped };
347
+ }
348
+
349
+ function syncSkills(target, agents) {
350
+ let n = 0;
351
+ const dests = skillDests(target, agents);
352
+ if (dests.length === 0) {
353
+ note("no skill destinations for selected agents — AGENTS.md still applies");
354
+ return 0;
355
+ }
356
+ for (const name of WDI_SKILLS) {
357
+ const src = path.join(KIT, "skills", name);
358
+ if (!fs.existsSync(src)) die(`kit missing skill ${name}`);
359
+ for (const root of dests) {
360
+ const dest = path.join(root, name);
361
+ fs.rmSync(dest, { recursive: true, force: true });
362
+ n += copyTree(src, dest);
363
+ }
364
+ }
365
+ const removed = pruneRetiredSkills(dests);
366
+ return { files: n, removed };
367
+ }
368
+
369
+ // A wrapper the method RETIRED is worse than a wrapper missing: the folder is still there, its
370
+ // SKILL.md still reads like an instruction, and an agent will invoke it — while the guide it points
371
+ // at is gone. Renaming five wrappers (wdi-apply, wdi-analysis, wdi-structure, …) left exactly that
372
+ // in every repo installed before the rename, because update only ever touched the names it knows.
373
+ //
374
+ // `wdi-` is the method's namespace, so a `wdi-*` folder carrying a SKILL.md and not in WDI_SKILLS is
375
+ // ours and retired. Each removal is PRINTED: silent deletion in someone else's repo is not a fix.
376
+ function pruneRetiredSkills(dests) {
377
+ let removed = 0;
378
+ const keep = new Set(WDI_SKILLS);
379
+ for (const root of dests) {
380
+ if (!fs.existsSync(root)) continue;
381
+ for (const entry of fs.readdirSync(root, { withFileTypes: true })) {
382
+ if (!entry.isDirectory() || !entry.name.startsWith("wdi-") || keep.has(entry.name)) continue;
383
+ const dir = path.join(root, entry.name);
384
+ if (!fs.existsSync(path.join(dir, "SKILL.md"))) {
385
+ note(`kept ${entry.name} (no SKILL.md — not one of ours)`);
386
+ continue;
387
+ }
388
+ fs.rmSync(dir, { recursive: true, force: true });
389
+ note(`removed retired skill ${entry.name}`);
390
+ removed += 1;
391
+ }
392
+ }
393
+ return removed;
394
+ }
395
+
396
+ // `promote` scrubs a product's initiative slug out of bmad-prd.toml before publishing, which is right.
397
+ // Writing the scrubbed PLACEHOLDER back into a product repo is not: the first real install replaced a
398
+ // live `run_folder_pattern = "some-real-slug"` with `FILL-initiative-slug`, and nothing said so. A value
399
+ // the product already chose is not the installer's to overwrite — same rule as the custom room and the
400
+ // language policy.
401
+ const PLACEHOLDER_SLUG = "FILL-initiative-slug";
402
+ const RUN_FOLDER_LINE = /^(\s*run_folder_pattern\s*=\s*)(".*?"|'.*?')/m;
403
+
404
+ // The slug appears MORE THAN ONCE bmad-prd.toml carries it in `run_folder_pattern` and again inside a
405
+ // memlog path, and the file itself says the two lines MUST change together. The first version of this
406
+ // function restored only the first line and so produced exactly the inconsistency that file forbids.
407
+ // So: read the product's slug once, then put it back everywhere the placeholder appears.
408
+ function keepProductSlug(incoming, existing) {
409
+ const mineNow = existing.match(RUN_FOLDER_LINE);
410
+ if (!mineNow) return null;
411
+ const slug = mineNow[2].slice(1, -1);
412
+ if (!slug || slug === PLACEHOLDER_SLUG) return null;
413
+ if (!incoming.includes(PLACEHOLDER_SLUG)) return null;
414
+ // Only where the slug is a VALUE: the quoted setting, and the memlog path built from it. A bare
415
+ // mention inside a comment stays the placeholder that sentence explains the pattern, and rewriting
416
+ // it would turn a generic explanation into a statement about one initiative.
417
+ return incoming
418
+ .replaceAll(`"${PLACEHOLDER_SLUG}"`, `"${slug}"`)
419
+ .replaceAll(`prd-${PLACEHOLDER_SLUG}`, `prd-${slug}`);
420
+ }
421
+
422
+ function syncTomls(target) {
423
+ const src = path.join(KIT, "assets", "bmad-custom");
424
+ const dest = path.join(target, "_bmad", "custom");
425
+ fs.mkdirSync(dest, { recursive: true });
426
+ let n = 0;
427
+ let slugsKept = 0;
428
+ for (const file of walkFiles(src)) {
429
+ if (!file.endsWith(".toml") || file.endsWith(".user.toml")) continue;
430
+ const to = path.join(dest, path.basename(file));
431
+ if (fs.existsSync(to)) {
432
+ const merged = keepProductSlug(fs.readFileSync(file, "utf8"), fs.readFileSync(to, "utf8"));
433
+ if (merged !== null) {
434
+ fs.writeFileSync(to, merged);
435
+ note(`kept run_folder_pattern in ${path.basename(file)}`);
436
+ slugsKept += 1;
437
+ n += 1;
438
+ continue;
439
+ }
440
+ }
441
+ copyFile(file, to);
442
+ n += 1;
443
+ }
444
+ return { files: n, slugsKept };
445
+ }
446
+
447
+ function seedControlIfMissing(target) {
448
+ const control = path.join(target, ".control");
449
+ if (fs.existsSync(control)) {
450
+ note(".control/ already present — left untouched");
451
+ return;
452
+ }
453
+ if (!fs.existsSync(SCAFFOLD)) die(`scaffold missing: ${SCAFFOLD}`);
454
+ const n = copyTree(SCAFFOLD, control);
455
+ ok(`seeded empty .control/ (${n} files)`);
456
+ }
457
+
458
+ // On a FIRST install these folders are the corpus taking shape. On an UPDATE their absence means
459
+ // somebody removed them on purpose `.work/` and `_bmad-output/prior-knowledge/` are exactly the two a
460
+ // product retires once its migration is done, and one repo retired them through an applied decision.
461
+ // Recreating them then is an installer overruling a decision it cannot read. Seed once, never resurrect.
462
+ function seedEmptyLayers(target, { first }) {
463
+ const always = [".what", path.join(".how", "_platform")];
464
+ const firstOnly = [".work", path.join("_bmad-output", "prior-knowledge")];
465
+ for (const rel of first ? [...always, ...firstOnly] : always) {
466
+ const dest = path.join(target, rel);
467
+ if (!fs.existsSync(dest)) {
468
+ fs.mkdirSync(dest, { recursive: true });
469
+ note(`created ${rel.replaceAll(path.sep, "/")}/`);
470
+ }
471
+ }
472
+ if (!first) {
473
+ for (const rel of firstOnly) {
474
+ if (!fs.existsSync(path.join(target, rel))) {
475
+ note(`left ${rel.replaceAll(path.sep, "/")}/ absent — a product retires it, not the installer`);
476
+ }
477
+ }
478
+ }
479
+ }
480
+
481
+ function writeStamp(target) {
482
+ const control = path.join(target, ".control");
483
+ if (!fs.existsSync(control)) return;
484
+ const stamp = [
485
+ "# Written by wdi-method install/update. A trace, not a lockfile.",
486
+ `wdi_method: ${PKG.version}`,
487
+ `bmad_method: ${readBmadVersion(target) || '""'}`,
488
+ `installed_at: ${today()}`,
489
+ "",
490
+ ].join("\n");
491
+ fs.writeFileSync(path.join(control, "wdi-method.yaml"), stamp, "utf8");
492
+ note("stamped .control/wdi-method.yaml");
493
+ }
494
+
495
+ function setProductIdentity(target, { name, client }) {
496
+ if (!name || identityIsPlaceholder(name)) return;
497
+ const file = path.join(target, ".control", "registry", "index.yaml");
498
+ if (!fs.existsSync(file)) return;
499
+ const next = writeProductIdentity(fs.readFileSync(file, "utf8"), {
500
+ name,
501
+ client: client ?? "",
502
+ });
503
+ fs.writeFileSync(file, next.endsWith("\n") ? next : `${next}\n`);
504
+ note(`product.name = ${name}`);
505
+ }
506
+
507
+ // The document language belongs to the PRODUCT, so update MUST NOT overwrite it. It is written only
508
+ // when absent same as the custom room, and for the same reason: a setting somebody already chose
509
+ // is not the installer's to change behind their back.
510
+ function setLanguagePolicy(target, { docLanguage, docFilenameLanguage, chosen }) {
511
+ const file = path.join(target, ".control", "registry", "index.yaml");
512
+ if (!fs.existsSync(file)) return;
513
+ const text = fs.readFileSync(file, "utf8");
514
+ const existing = readLanguagePolicy(text);
515
+ // `chosen` means somebody actually answered in the TUI, or through an explicit flag. Only then
516
+ // does the answer take effect. Without it the incoming value is just a default, and a default
517
+ // MUST NOT overwrite a choice somebody already made.
518
+ if (!chosen && existing.docLanguage && existing.docFilenameLanguage) {
519
+ note(`kept policy.doc_language = ${existing.docLanguage}, ` +
520
+ `doc_filename_language = ${existing.docFilenameLanguage}`);
521
+ return;
522
+ }
523
+ const next = writeLanguagePolicy(text, {
524
+ docLanguage: docLanguage || existing.docLanguage || DEFAULT_DOC_LANGUAGE,
525
+ docFilenameLanguage:
526
+ docFilenameLanguage || existing.docFilenameLanguage || DEFAULT_DOC_LANGUAGE,
527
+ });
528
+ fs.writeFileSync(file, next.endsWith("\n") ? next : `${next}\n`);
529
+ const after = readLanguagePolicy(next);
530
+ note(`policy.doc_language = ${after.docLanguage}, ` +
531
+ `doc_filename_language = ${after.docFilenameLanguage}`);
532
+ }
533
+
534
+ // Read BEFORE writeStamp overwrites it. Without this there is no version transition to print, and
535
+ // an "updated" with no from-to tells the reader nothing they can use.
536
+ function readStampVersion(target) {
537
+ const file = path.join(target, ".control", "wdi-method.yaml");
538
+ if (!fs.existsSync(file)) return "";
539
+ const m = fs.readFileSync(file, "utf8").match(/^wdi_method:\s*"?([^"\s]+)"?/m);
540
+ return m ? m[1] : "";
541
+ }
542
+
543
+ function readIndexPolicy(target) {
544
+ const file = path.join(target, ".control", "registry", "index.yaml");
545
+ if (!fs.existsSync(file)) return { docLanguage: "", docFilenameLanguage: "" };
546
+ return readLanguagePolicy(fs.readFileSync(file, "utf8"));
547
+ }
548
+
549
+ function readIndexIdentity(target) {
550
+ const file = path.join(target, ".control", "registry", "index.yaml");
551
+ if (!fs.existsSync(file)) return { name: "", client: "" };
552
+ return readProductIdentity(fs.readFileSync(file, "utf8"));
553
+ }
554
+
555
+ function upsertAgentFiles(target, agents, productName) {
556
+ const template = fs.readFileSync(path.join(OVERLAY, "AGENTS.md"), "utf8");
557
+ const agentsFile = path.join(target, "AGENTS.md");
558
+ let next;
559
+ if (!fs.existsSync(agentsFile)) {
560
+ next = fillProductTitle(template, productName || "{product}");
561
+ ok("AGENTS.md created — rewrite ## Code for this product");
562
+ } else {
563
+ next = upsertMethodBlock(fs.readFileSync(agentsFile, "utf8"), template);
564
+ note("AGENTS.md method block refreshed; product sections kept");
565
+ }
566
+ if (!next.endsWith("\n")) next += "\n";
567
+ fs.writeFileSync(agentsFile, next);
568
+
569
+ const mirrors = [];
570
+ if (agents.includes("cursor")) mirrors.push(path.join(target, ".cursorrules"));
571
+ if (agents.includes("cursor") || agents.includes("antigravity")) {
572
+ mirrors.push(path.join(target, ".agents", "AGENTS.md"));
573
+ }
574
+ for (const mirror of mirrors) {
575
+ fs.mkdirSync(path.dirname(mirror), { recursive: true });
576
+ if (fs.existsSync(mirror)) {
577
+ const patched = upsertMethodBlock(fs.readFileSync(mirror, "utf8"), template);
578
+ fs.writeFileSync(mirror, patched.endsWith("\n") ? patched : `${patched}\n`);
579
+ note(`method block refreshed in ${posixRel(target, mirror)}`);
580
+ } else {
581
+ fs.writeFileSync(mirror, next);
582
+ note(`created ${posixRel(target, mirror)}`);
583
+ }
584
+ }
585
+
586
+ if (agents.includes("claude")) {
587
+ const claude = path.join(target, "CLAUDE.md");
588
+ if (!fs.existsSync(claude)) {
589
+ fs.writeFileSync(claude, "@AGENTS.md\n");
590
+ note("CLAUDE.md created as @AGENTS.md");
591
+ }
592
+ }
593
+ }
594
+
595
+ // What a run MUST leave a reader able to answer: which version replaced which, what was written, what
596
+ // was KEPT, and what to do next. The third is the one usually missing, and it is the one that decides
597
+ // whether somebody trusts running this over a repo they have already put work into.
598
+ function summaryLine(label, value) {
599
+ console.log(` ${DIM}${label.padEnd(11)}${RESET}${value}`);
600
+ }
601
+
602
+ function printSummary(target, agents, { first, was, written, skipped, skills, tomls }) {
603
+ const now = PKG.version;
604
+ const version = first
605
+ ? `${now} first install`
606
+ : was && was !== now
607
+ ? `${was} ${DIM}→${RESET} ${now}`
608
+ : `${now} ${DIM}(unchanged)${RESET}`;
609
+ const bmad = readBmadVersion(target);
610
+
611
+ const kept = [];
612
+ if (skipped) kept.push(`${skipped} constitution file${skipped === 1 ? "" : "s"}`);
613
+ if (tomls.slugsKept) kept.push(`${tomls.slugsKept} initiative slug${tomls.slugsKept === 1 ? "" : "s"}`);
614
+ // On a first install the language was just CHOSEN, not kept saying "kept" there reads as if the
615
+ // installer had found something it decided to leave alone, which is the opposite of what happened.
616
+ const policy = readIndexPolicy(target);
617
+ if (policy.docLanguage && !first) kept.push(`language (${policy.docLanguage})`);
618
+ if (fs.existsSync(path.join(target, ".constitution", "project"))) kept.push(".constitution/project/");
619
+
620
+ console.log("");
621
+ console.log(`${DIM}────${RESET} WDI Method ${DIM}${"".repeat(46)}${RESET}`);
622
+ summaryLine("version", version);
623
+ if (bmad) summaryLine("bmad", bmad);
624
+ summaryLine("target", target);
625
+ console.log("");
626
+ summaryLine("written", `${written} constitution · ${skills.files} skill files · ${tomls.files} bmad overrides`);
627
+ if (kept.length) summaryLine("kept", kept.join(" · "));
628
+ if (skills.removed) {
629
+ summaryLine("removed", `${skills.removed} retired wrapper${skills.removed === 1 ? "" : "s"}`);
630
+ }
631
+ if (first && policy.docLanguage) {
632
+ summaryLine("language", `${policy.docLanguage} · filenames ${policy.docFilenameLanguage}`);
633
+ }
634
+ summaryLine("agents", agents.join(", ") || "none");
635
+ console.log("");
636
+ summaryLine("next", `invoke the ${HELP_SKILL} skill and ask what to do`);
637
+ summaryLine("", REPO_URL);
638
+ console.log(`${DIM}${"─".repeat(62)}${RESET}`);
639
+ }
640
+
641
+ function printNextSteps({ first, productSet }) {
642
+ console.log("");
643
+ console.log(first ? "After install:" : "After update:");
644
+ if (first) {
645
+ if (!productSet) {
646
+ console.log(" 1. Fill product.name (and product.client if there is one) in .control/registry/index.yaml.");
647
+ } else {
648
+ console.log(" 1. product.name is set. G1 confirms it in the brief.");
649
+ }
650
+ console.log(" 2. Rewrite .constitution/constitution.md Articles 2 and 5 for this product.");
651
+ console.log(" Article 1 cites index.yaml do not become a second source for the name.");
652
+ console.log(" 3. Write ## Code in AGENTS.md (where the app lives). Leave the BEGIN:wdi-method block alone.");
653
+ console.log(" 4. Run the wdi-init skill, intent setup.");
654
+ console.log(" 5. Sort the documents you already have. Do not move any of them in this step.");
655
+ console.log("");
656
+ console.log("Next update:");
657
+ console.log(" npx wdi-method");
658
+ console.log(" (the TUI offers the update) or: npx wdi-method update --yes");
659
+ } else {
660
+ console.log(" 1. The <!-- BEGIN:wdi-method --> block in AGENTS.md was replaced. Read the diff.");
661
+ console.log(" 2. constitution.md Articles 1-2-5, ## Code, and *.user.toml were not overwritten.");
662
+ console.log(" 3. If BMad has new skills, install those first, then run this update again.");
663
+ }
664
+ }
665
+
666
+ function apply(target, agents,
667
+ { first, product, client, docLanguage, docFilenameLanguage, languageChosen }) {
668
+ requireKit();
669
+ const was = readStampVersion(target);
670
+ const { written, skipped } = syncConstitution(target);
671
+ note(`constitution wrote ${written}, kept ${skipped}`);
672
+ const skills = syncSkills(target, agents);
673
+ note(`skills ${skills.files} files`);
674
+ const tomls = syncTomls(target);
675
+ note(`bmad custom ${tomls.files} toml → _bmad/custom/`);
676
+ if (first) seedControlIfMissing(target);
677
+ seedEmptyLayers(target, { first });
678
+ setProductIdentity(target, { name: product, client });
679
+ setLanguagePolicy(target, { docLanguage, docFilenameLanguage, chosen: languageChosen });
680
+ upsertAgentFiles(target, agents, product);
681
+ writeStamp(target);
682
+ printSummary(target, agents, { first, was, written, skipped, skills, tomls });
683
+ printNextSteps({
684
+ first,
685
+ productSet: Boolean(product) && !identityIsPlaceholder(product),
686
+ });
687
+ }
688
+
689
+ function verify(target, agents) {
690
+ requireKit();
691
+ const missing = [];
692
+ const kitConst = path.join(KIT, ".constitution");
693
+ for (const file of walkFiles(kitConst)) {
694
+ const rel = posixRel(kitConst, file);
695
+ const dest = path.join(target, ".constitution", rel);
696
+ if (!fs.existsSync(dest)) missing.push(`.constitution/${rel}`);
697
+ }
698
+ for (const name of WDI_SKILLS) {
699
+ for (const root of skillDests(target, agents)) {
700
+ const dest = path.join(root, name, "SKILL.md");
701
+ if (!fs.existsSync(dest)) missing.push(posixRel(target, dest));
702
+ }
703
+ }
704
+ const custom = path.join(KIT, "assets", "bmad-custom");
705
+ for (const file of walkFiles(custom)) {
706
+ if (!file.endsWith(".toml")) continue;
707
+ const dest = path.join(target, "_bmad", "custom", path.basename(file));
708
+ if (!fs.existsSync(dest)) missing.push(`_bmad/custom/${path.basename(file)}`);
709
+ }
710
+ if (fs.existsSync(path.join(target, ".control"))) {
711
+ for (const file of walkFiles(SCAFFOLD)) {
712
+ const rel = posixRel(SCAFFOLD, file);
713
+ const dest = path.join(target, ".control", rel);
714
+ if (!fs.existsSync(dest)) missing.push(`.control/${rel}`);
715
+ }
716
+ } else {
717
+ missing.push(".control/ (folder missing — first install should have seeded it)");
718
+ }
719
+ for (const required of ["AGENTS.md", path.join(".constitution", "constitution.md")]) {
720
+ if (!fs.existsSync(path.join(target, required))) missing.push(required.replaceAll(path.sep, "/"));
721
+ }
722
+ if (missing.length) {
723
+ console.error(`${RED}missing ${missing.length}${RESET}`);
724
+ for (const m of missing) console.error(` ${m}`);
725
+ process.exit(1);
726
+ }
727
+ ok(`method files present in ${target}`);
728
+ note("extra product files are expected and were not checked");
729
+ }
730
+
731
+ function scrubPrdToml(file) {
732
+ const raw = fs.readFileSync(file, "utf8");
733
+ const m = raw.match(/run_folder_pattern\s*=\s*"([^"]+)"/);
734
+ if (!m) return;
735
+ const slug = m[1];
736
+ if (GENERIC_FOLDER_PATTERNS.has(slug)) return;
737
+ fs.writeFileSync(file, raw.split(slug).join(PRD_SLUG_PLACEHOLDER), "utf8");
738
+ note("bmad-prd.toml initiative slug scrubbed to placeholder");
739
+ }
740
+
741
+ function promote(live) {
742
+ live = path.resolve(live);
743
+ if (!fs.existsSync(path.join(live, ".constitution"))) {
744
+ die(`${live} has no .constitution/ — is this a method-carrying repo?`);
745
+ }
746
+ // The custom room's README is authored in the package and MUST survive the rmSync below. Read
747
+ // here, not after the first version of this fix read it after the kit was deleted, so it was
748
+ // always null and the README vanished on every promote. The project-room test caught it.
749
+ const roomKit = path.join(KIT, ".constitution", PROJECT_ROOM, "README.md");
750
+ const roomKept = fs.existsSync(roomKit) ? fs.readFileSync(roomKit, "utf8") : null;
751
+
752
+ fs.rmSync(KIT, { recursive: true, force: true });
753
+ fs.mkdirSync(KIT, { recursive: true });
754
+
755
+ const nConst = copyTree(path.join(live, ".constitution"), path.join(KIT, ".constitution"),
756
+ (rel) => rel.startsWith(PROJECT_ROOM));
757
+ note(`constitution ${nConst} files (${PROJECT_ROOM} skipped — it is the product's)`);
758
+ if (roomKept !== null) {
759
+ fs.mkdirSync(path.dirname(roomKit), { recursive: true });
760
+ fs.writeFileSync(roomKit, roomKept, "utf8");
761
+ note(`${PROJECT_ROOM}README.md restored from the package — promote never carries it home`);
762
+ }
763
+
764
+ let copiedSkills = 0;
765
+ const skillsSrc = path.join(live, ".claude", "skills");
766
+ for (const name of WDI_SKILLS) {
767
+ const src = path.join(skillsSrc, name);
768
+ if (!fs.existsSync(src)) die(`skill missing in live repo: ${src}`);
769
+ copiedSkills += copyTree(src, path.join(KIT, "skills", name));
770
+ }
771
+ note(`skills ${copiedSkills} files (${WDI_SKILLS.length} wrappers)`);
772
+
773
+ const customSrc = path.join(live, "_bmad", "custom");
774
+ const customDst = path.join(KIT, "assets", "bmad-custom");
775
+ fs.mkdirSync(customDst, { recursive: true });
776
+ let tomls = 0;
777
+ if (fs.existsSync(customSrc)) {
778
+ for (const file of walkFiles(customSrc)) {
779
+ if (!file.endsWith(".toml") || file.endsWith(".user.toml")) continue;
780
+ copyFile(file, path.join(customDst, path.basename(file)));
781
+ tomls += 1;
782
+ }
783
+ }
784
+ const prd = path.join(customDst, "bmad-prd.toml");
785
+ if (fs.existsSync(prd)) scrubPrdToml(prd);
786
+ note(`bmad custom ${tomls} toml`);
787
+
788
+ const replacements = {
789
+ "constitution.md": path.join(KIT, ".constitution", "constitution.md"),
790
+ "portability.md": path.join(KIT, ".constitution", "method", "portability.md"),
791
+ "repo-guide.md": path.join(KIT, ".constitution", "repo-guide.md"),
792
+ "README.md": path.join(KIT, ".constitution", "README.md"),
793
+ };
794
+ for (const [name, dest] of Object.entries(replacements)) {
795
+ const src = path.join(OVERLAY, name);
796
+ if (fs.existsSync(src)) {
797
+ copyFile(src, dest);
798
+ note(`${name} replaced with kit overlay`);
799
+ }
800
+ }
801
+
802
+ const source = [
803
+ `date: ${today()}`,
804
+ `commit: ${gitHead(live)}`,
805
+ "kind: working copy that currently carries a newer method",
806
+ "note: the repo path and product name MUST NOT be recorded here",
807
+ "",
808
+ ].join("\n");
809
+ fs.writeFileSync(path.join(ROOT, "SOURCE"), source, "utf8");
810
+ ok(`SOURCE stamped ${today()} @ ${gitHead(live)}`);
811
+ ok(`promoted into ${KIT}`);
812
+ }
813
+
814
+ function cancelIf(value) {
815
+ if (p.isCancel(value)) {
816
+ p.cancel("Cancelled.");
817
+ process.exit(0);
818
+ }
819
+ return value;
820
+ }
821
+
822
+ async function runWizard(pre) {
823
+ p.intro(`WDI Method ${PKG.version}`);
824
+
825
+ const dirValue = cancelIf(
826
+ await p.text({
827
+ message: "Target repo (the product folder)",
828
+ placeholder: process.cwd(),
829
+ defaultValue: pre.dir || process.cwd(),
830
+ }),
831
+ );
832
+ const target = path.resolve(String(dirValue).trim() || process.cwd());
833
+
834
+ if (!fs.existsSync(target)) {
835
+ const create = cancelIf(
836
+ await p.confirm({ message: `${target} does not exist. Create it?`, initialValue: true }),
837
+ );
838
+ if (!create) {
839
+ p.cancel("No target folder.");
840
+ process.exit(1);
841
+ }
842
+ fs.mkdirSync(target, { recursive: true });
843
+ }
844
+
845
+ const hasBmad = bmadPresent(target);
846
+ const hasWdi = wdiPresent(target);
847
+ const nonempty = dirNonEmpty(target);
848
+
849
+ const facts = [
850
+ hasBmad
851
+ ? `BMad Method: installed${readBmadVersion(target) ? ` (${readBmadVersion(target)})` : ""}`
852
+ : "BMad Method: not installed",
853
+ hasWdi ? "WDI Method: already present the installer will offer an update" : "WDI Method: not present",
854
+ nonempty ? "Folder is not empty (normal for a product repo already under way)" : "Folder is empty",
855
+ ].join("\n");
856
+ p.note(facts, "Detected");
857
+
858
+ if (!hasBmad && !pre.skipBmad) {
859
+ p.note(bmadMissingMessage(), "BMad first");
860
+ p.outro("Install BMad, then run this again: npx wdi-method");
861
+ process.exit(1);
862
+ }
863
+
864
+ let first = !hasWdi;
865
+ if (hasWdi) {
866
+ const update = cancelIf(
867
+ await p.confirm({
868
+ message: "WDI Method is already installed. Update it now?",
869
+ initialValue: true,
870
+ }),
871
+ );
872
+ first = !update;
873
+ if (first) {
874
+ p.cancel("Update declined.");
875
+ process.exit(0);
876
+ }
877
+ } else {
878
+ const go = cancelIf(
879
+ await p.confirm({
880
+ message: `Install WDI Method into ${target}?`,
881
+ initialValue: true,
882
+ }),
883
+ );
884
+ if (!go) {
885
+ p.cancel("Install declined.");
886
+ process.exit(0);
887
+ }
888
+ }
889
+
890
+ // Every field arrives with an answer already in it, and Enter accepts it. On an update that answer is
891
+ // what the repo already says; on a first install it is the folder name made readable. Nothing here is
892
+ // validated as required: a prompt that refuses an empty submission when it already holds a sensible
893
+ // default is asking the owner to retype something the installer knows.
894
+ const existing = readIndexIdentity(target);
895
+ const suggestedName = identityIsPlaceholder(existing.name)
896
+ ? humaniseFolderName(path.basename(target))
897
+ : existing.name;
898
+ const product = cancelIf(
899
+ await p.text({
900
+ message: "Product name (one room: index.yaml product.name)",
901
+ placeholder: suggestedName,
902
+ defaultValue: suggestedName,
903
+ }),
904
+ ).trim() || suggestedName;
905
+ const client = cancelIf(
906
+ await p.text({
907
+ message: "Client name (Enter to leave it as it is)",
908
+ placeholder: existing.client || "(none)",
909
+ defaultValue: existing.client || "",
910
+ }),
911
+ ).trim();
912
+
913
+ // Two questions, and only two. Method terminology, document code prefixes, machine-facing
914
+ // markers, and code identifiers are always English — MUST NOT be asked about.
915
+ const policy = readIndexPolicy(target);
916
+ // Free text, not a list. Write whatever a model understands — "English", "Bahasa Indonesia",
917
+ // "id". The only value refused is empty.
918
+ const askLanguage = async (message, current) =>
919
+ (cancelIf(
920
+ await p.text({
921
+ message,
922
+ placeholder: current || DEFAULT_DOC_LANGUAGE,
923
+ defaultValue: current || DEFAULT_DOC_LANGUAGE,
924
+ }),
925
+ ) || DEFAULT_DOC_LANGUAGE).trim();
926
+ const docLanguage = await askLanguage(
927
+ "Language of working-document prose (.what/ .how/ .control/) — free text",
928
+ policy.docLanguage || pre.docLanguage);
929
+ const docFilenameLanguage = await askLanguage(
930
+ "Language of document filename slugs — the `UC-` `DEC-` codes stay English",
931
+ policy.docFilenameLanguage || pre.docFilenameLanguage || docLanguage);
932
+
933
+ const selected = cancelIf(
934
+ await p.multiselect({
935
+ message: "Which agents get the skills? (space to select)",
936
+ options: ALL_AGENTS.map((id) => ({ value: id, label: AGENT_LABELS[id] })),
937
+ initialValues: pre.agents || detectAgents(target),
938
+ required: true,
939
+ }),
940
+ );
941
+
942
+ p.note(
943
+ [
944
+ "The corpus folder names are fixed — they are not an install option:",
945
+ " .constitution .control .what .how .work _bmad-output",
946
+ "",
947
+ "What gets written for the agents you picked:",
948
+ selected.includes("claude") ? " .claude/skills/wdi-* CLAUDE.md" : "",
949
+ selected.includes("cursor") ? " .agents/skills/wdi-* .cursorrules" : "",
950
+ selected.includes("codex") || selected.includes("cursor") || selected.includes("antigravity")
951
+ ? " AGENTS.md (the BEGIN:wdi-method block)"
952
+ : "",
953
+ selected.includes("antigravity") ? " .agents/AGENTS.md" : "",
954
+ ]
955
+ .filter(Boolean)
956
+ .join("\n"),
957
+ "Write targets",
958
+ );
959
+
960
+ const okGo = cancelIf(await p.confirm({ message: first ? "Run the install?" : "Run the update?", initialValue: true }));
961
+ if (!okGo) {
962
+ p.cancel("Dibatalkan.");
963
+ process.exit(0);
964
+ }
965
+
966
+ const spinner = p.spinner();
967
+ spinner.start(first ? "Memasang…" : "Meng-update…");
968
+ apply(target, selected, {
969
+ docLanguage,
970
+ docFilenameLanguage,
971
+ languageChosen: true,
972
+ first,
973
+ product: String(product).trim(),
974
+ client: String(client).trim(),
975
+ });
976
+ spinner.stop(first ? "Terpasang" : "Ter-update");
977
+ p.outro(first ? "Done. Take the after-install steps above." : "Done. Read the method-block diff in AGENTS.md.");
978
+ }
979
+
980
+ function runNonInteractive(args) {
981
+ const target = requireTarget(args.dir);
982
+ const agents = args.agents || detectAgents(target) || ALL_AGENTS.slice();
983
+ if (args.cmd === "verify") {
984
+ verify(target, agents);
985
+ return;
986
+ }
987
+ if (!args.skipBmad && !bmadPresent(target)) {
988
+ die(bmadMissingMessage());
989
+ }
990
+ const existing = readIndexIdentity(target);
991
+ const product = args.product || existing.name;
992
+ const client = args.client ?? existing.client;
993
+ const first = args.cmd === "install" || (args.cmd === "wizard" && !wdiPresent(target));
994
+ apply(target, agents, {
995
+ first: args.cmd === "update" ? false : first,
996
+ product,
997
+ client,
998
+ docLanguage: args.docLanguage,
999
+ docFilenameLanguage: args.docFilenameLanguage,
1000
+ languageChosen: Boolean(args.docLanguage || args.docFilenameLanguage),
1001
+ });
1002
+ }
1003
+
1004
+ async function main() {
1005
+ const args = parseArgs(process.argv);
1006
+ if (!["wizard", "install", "update", "verify", "promote"].includes(args.cmd)) {
1007
+ usage();
1008
+ process.exit(2);
1009
+ }
1010
+ if (args.cmd === "promote") {
1011
+ if (!args.dir) die("promote needs a path to the working copy");
1012
+ promote(args.dir);
1013
+ return;
1014
+ }
1015
+ const wantTui = !args.yes && args.cmd !== "verify" && process.stdin.isTTY && process.stdout.isTTY;
1016
+ if (wantTui) {
1017
+ await runWizard(args);
1018
+ return;
1019
+ }
1020
+ if (args.cmd === "wizard" && !args.yes) {
1021
+ die("not a TTY. Use `install --yes` / `update --yes`, or run this in a terminal.");
1022
+ }
1023
+ if (args.cmd === "wizard") args.cmd = wdiPresent(requireTarget(args.dir)) ? "update" : "install";
1024
+ runNonInteractive(args);
1025
+ }
1026
+
1027
+ main().catch((err) => {
1028
+ console.error(err);
1029
+ process.exit(1);
1030
+ });