wdi-method 0.6.18 → 0.6.24

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 (34) hide show
  1. package/CHANGELOG.md +105 -0
  2. package/LICENSE +21 -21
  3. package/NOTICE +28 -0
  4. package/README.id.md +190 -0
  5. package/README.ja.md +188 -0
  6. package/README.md +190 -530
  7. package/README.zh.md +188 -0
  8. package/bin/wdi-method.js +2199 -2112
  9. package/kit/.constitution/method/README.md +1 -0
  10. package/kit/.constitution/method/branch-guide.md +87 -0
  11. package/kit/.constitution/method/ci-guide.md +169 -0
  12. package/kit/.constitution/method/constitution.md +1 -0
  13. package/kit/.constitution/method/scripts/lifecycle.py +416 -0
  14. package/kit/.constitution/method/scripts/validate.py +126 -9
  15. package/kit/skills/wdi-autopilot/SKILL.md +461 -384
  16. package/kit/skills/wdi-build/SKILL.md +404 -393
  17. package/kit/skills/wdi-daily-autopilot/SKILL.md +138 -0
  18. package/kit/skills/wdi-daily-what-to-build/SKILL.md +155 -0
  19. package/kit/skills/wdi-daily-what-to-test/SKILL.md +127 -0
  20. package/kit/skills/wdi-explain-to-me/SKILL.md +1 -1
  21. package/kit/skills/wdi-help/SKILL.md +21 -7
  22. package/kit/skills/wdi-init/SKILL.md +16 -0
  23. package/kit/skills/wdi-prune-or-archive/SKILL.md +76 -0
  24. package/kit/skills/wdi-review/SKILL.md +4 -1
  25. package/kit-overlay/AGENTS.md +37 -4
  26. package/kit-overlay/README.md +1 -0
  27. package/kit-overlay/constitution.md +1 -0
  28. package/lib/identity.mjs +246 -117
  29. package/package.json +8 -4
  30. package/scaffold/.control/custom-dispatch.yaml.example +65 -0
  31. package/scaffold/.control/registry/index.yaml +9 -0
  32. package/scaffold/.control/test-targets/desktop.md +15 -0
  33. package/scaffold/.control/test-targets/mobile.md +6 -0
  34. package/scaffold/.control/test-targets/web.md +6 -0
package/bin/wdi-method.js CHANGED
@@ -1,2112 +1,2199 @@
1
- #!/usr/bin/env node
2
- import fs from "node:fs";
3
- import os from "node:os";
4
- import path from "node:path";
5
- import { spawnSync } from "node:child_process";
6
- import { createHash } from "node:crypto";
7
- import { fileURLToPath } from "node:url";
8
- import * as p from "@clack/prompts";
9
- import {
10
- fillProductTitle,
11
- upsertMethodBlock,
12
- } from "../lib/agents-block.mjs";
13
- import {
14
- identityIsPlaceholder,
15
- humaniseFolderName,
16
- readLanguagePolicy,
17
- writeLanguagePolicy,
18
- DEFAULT_DOC_LANGUAGE,
19
- readProductIdentity,
20
- writeProductIdentity,
21
- } from "../lib/identity.mjs";
22
- import {
23
- detectPlatforms,
24
- formatPlatformList,
25
- isKnownPlatform,
26
- normalizePlatformIds,
27
- platformSelectOptions,
28
- platformUsesHook,
29
- PREFERRED_PLATFORM_IDS,
30
- skillDestinations,
31
- } from "../lib/platforms.mjs";
32
- import { opencodeCommandsDir, syncOpencodeCommands } from "../lib/opencode-commands.mjs";
33
-
34
- const ROOT = path.resolve(path.dirname(fileURLToPath(import.meta.url)), "..");
35
- const KIT = path.join(ROOT, "kit");
36
- const OVERLAY = path.join(ROOT, "kit-overlay");
37
- const SCAFFOLD = path.join(ROOT, "scaffold", ".control");
38
- const PKG = JSON.parse(fs.readFileSync(path.join(ROOT, "package.json"), "utf8"));
39
-
40
- const WDI_SKILLS = [
41
- "wdi-init",
42
- "wdi-problem",
43
- "wdi-product",
44
- "wdi-ux",
45
- "wdi-blueprint",
46
- "wdi-component",
47
- "wdi-build",
48
- "wdi-decision",
49
- "wdi-question",
50
- "wdi-log",
51
- "wdi-help",
52
- "wdi-explain-to-me",
53
- "wdi-autopilot",
54
- "wdi-reconcile",
55
- "wdi-review",
56
- "wdi-report",
57
- "wdi-systematic-debugging",
58
- "wdi-upgrade",
59
- ];
60
-
61
- const PRD_SLUG_PLACEHOLDER = "FILL-initiative-slug";
62
- const GENERIC_FOLDER_PATTERNS = new Set([
63
- "_product-brief",
64
- "ux",
65
- "architecture",
66
- PRD_SLUG_PLACEHOLDER,
67
- ]);
68
-
69
- const BMAD_INSTALL = `npx bmad-method install`;
70
- // The engines G5 runs. BMad writes the documents; these cut the work.
71
- //
72
- // They are installed IN THE REPO, and a user-level plugin no longer counts. Three reasons, and the
73
- // third is what forced it: a method whose G5 depends on what the operator happened to install on
74
- // their laptop behaves differently per machine; `.control/wdi-method.yaml` cannot record a version
75
- // it does not own; and `to-spec`, `to-tickets` and `implement` ship with
76
- // `disable-model-invocation: true`, which nothing outside the file can lift — the gate reads the
77
- // frontmatter and consults no setting, and `skillOverrides` only ever tightens. Owning the file is
78
- // the only route to an engine a skill can invoke, so owning the file is now the requirement.
79
- // Upstream ships that route deliberately: the plugin is "subscribe rather than fork", `skills.sh`
80
- // "copies editable skill files into your project, so you can hack on them and make them your own".
81
- const ENGINES_REPO = "https://github.com/mattpocock/skills";
82
- const ENGINES_PLUGIN = "mattpocock-skills";
83
- const ENGINES_INSTALL = `/plugin install ${ENGINES_PLUGIN}`;
84
- const ENGINES_INSTALL_ANY = "npx skills@latest add mattpocock/skills";
85
- const ENGINES_SETUP = "/setup-matt-pocock-skills";
86
- // Six, not five. `domain-modeling` is G3's — `wdi-blueprint` invokes it — and it used to be reached
87
- // by its plugin-namespaced name. With the plugin no longer required that name resolves to nothing,
88
- // so the skill joins the local install and every reference to it dropped the prefix.
89
- const ENGINE_SKILLS = ["to-spec", "to-tickets", "implement", "tdd", "code-review", "domain-modeling"];
90
- // The three that arrive flagged. `tdd`, `code-review` and `domain-modeling` never carried the flag
91
- // and MUST NOT gain one.
92
- const ENGINE_FLAGGED = ["to-spec", "to-tickets", "implement"];
93
- const ENGINE_LOCK = "skills-lock.json";
94
- const GUARD_MARK = "Driven by `wdi-build` and `wdi-autopilot`";
95
- const GUARD_LINE = `> **${GUARD_MARK}.** \`wdi-method\` unlocked model invocation for this `
96
- + "engine in this repo so those two can drive it unattended. Invoked from anywhere else — a stray "
97
- + "session, a subagent that thought this looked relevant — stop and say so: this engine publishes "
98
- + "to the tracker and writes code.";
99
-
100
- // Every folder a platform reads skills from. One list, because a repo installs the engines wherever
101
- // `npx skills add` was pointed, and that installer offers symlinks across several of them.
102
- const SKILL_HOMES = [".claude", ".agents", ".agent", ".cursor", ".codex"];
103
-
104
- // BMad skills RETIRED at G5. This array is the single home of that list: `bmad-skill-register.md`
105
- // carries the same names for a reader, and a test fails when the two disagree.
106
- //
107
- // The criterion, and it is why the list is this long and not longer: a BMad skill is retired only
108
- // where this method has a NAMED replacement for what it produces. `bmad-build` and `bmad-agent-dev`
109
- // produce code that `implement` produces; `bmad-spec` a contract that `to-spec` produces;
110
- // `bmad-create-epics-and-stories` an `epics` level this method REPEALED in code, not merely in
111
- // prose. `bmad-qa-generate-e2e-tests` and `bmad-checkpoint-preview` have no replacement here, so
112
- // they are NOT retired — banning a capability with nothing in its place is how a method gets
113
- // worked around instead of followed.
114
- const BMAD_RETIRED_G5 = [
115
- "bmad-spec",
116
- "bmad-build",
117
- "bmad-build-auto",
118
- "bmad-code-review",
119
- "bmad-retrospective",
120
- "bmad-agent-dev",
121
- "bmad-create-epics-and-stories",
122
- "bmad-create-story",
123
- "bmad-dev-story",
124
- "bmad-dev-auto",
125
- "bmad-quick-dev",
126
- "bmad-sprint-planning",
127
- "bmad-sprint-status",
128
- ];
129
- const REPO_URL = "https://github.com/wiradigitalid/wdi-method";
130
- const HELP_SKILL = "wdi-help";
131
- const INIT_SKILL = "wdi-init";
132
- // The room's readers file is seeded as a skeleton and is useless until a product writes it. The
133
- // flag is the skeleton's own declaration, so this reads the same thing the engine does rather than
134
- // guessing from the file's size or its age.
135
- function readersAreSkeleton(target) {
136
- const file = path.join(target, ".constitution", "project", "inventory-readers.py");
137
- if (!fs.existsSync(file)) return false;
138
- return /^SKELETON\s*=\s*True\b/m.test(fs.readFileSync(file, "utf8"));
139
- }
140
- const BMAD_REPO = "https://github.com/bmad-code-org/BMAD-METHOD";
141
- const WDI_REPO = "https://github.com/wiradigitalid/wdi-method";
142
-
143
- const RED = "\x1b[31m";
144
- const GREEN = "\x1b[32m";
145
- const DIM = "\x1b[2m";
146
- const RESET = "\x1b[0m";
147
-
148
- function die(msg) {
149
- console.error(`${RED}error:${RESET} ${msg}`);
150
- process.exit(1);
151
- }
152
-
153
- function ok(msg) {
154
- console.log(`${GREEN}ok${RESET} ${msg}`);
155
- }
156
-
157
- function note(msg) {
158
- console.log(`${DIM}·${RESET} ${msg}`);
159
- }
160
-
161
- function usage() {
162
- console.log(`wdi-method ${PKG.version}
163
-
164
- (no command) interactive TUI — detects install vs update
165
- install [dir] first install (TUI unless --yes)
166
- update [dir] update (TUI unless --yes)
167
- verify [dir]
168
- engines [dir] [--fix] report the six engines, their invocation state, and the BMad G5 ban
169
- promote <live-dir> --rescue pull a method change back out of a consumer (not the normal flow)
170
-
171
- --yes non-interactive
172
- --agents a,b platform IDs (same as BMad --tools; legacy: claude = claude-code)
173
- --list-agents print supported platform IDs
174
- --product NAME written to index.yaml product.name
175
- --client NAME written to index.yaml product.client (optional)
176
- --doc-language <text> prose of working documents; free text, default English
177
- --doc-filename-language <text> slug part of document filenames; free text, default English
178
- --skip-bmad-check
179
- --skip-engines-check install without to-spec / to-tickets / implement
180
-
181
- BMad first, then this package. ${WDI_REPO}
182
- `);
183
- }
184
-
185
- function parseArgs(argv) {
186
- const args = {
187
- cmd: null,
188
- dir: null,
189
- agents: null,
190
- skipBmad: false,
191
- rescue: false,
192
- fix: false,
193
- yes: false,
194
- product: null,
195
- client: null,
196
- docLanguage: null,
197
- docFilenameLanguage: null,
198
- };
199
- const rest = argv.slice(2);
200
- if (rest[0] === "-h" || rest[0] === "--help") {
201
- usage();
202
- process.exit(0);
203
- }
204
- if (rest[0] === "--list-agents") {
205
- console.log(formatPlatformList());
206
- process.exit(0);
207
- }
208
- if (rest.length === 0) {
209
- args.cmd = "wizard";
210
- return args;
211
- }
212
- const first = rest[0];
213
- if (["install", "update", "verify", "promote", "engines"].includes(first)) {
214
- args.cmd = rest.shift();
215
- } else if (first.startsWith("-")) {
216
- args.cmd = "wizard";
217
- } else {
218
- args.cmd = "wizard";
219
- args.dir = rest.shift();
220
- }
221
- while (rest.length) {
222
- const t = rest.shift();
223
- if (t === "--skip-bmad-check") args.skipBmad = true;
224
- else if (t === "--fix") args.fix = true;
225
- else if (t === "--skip-engines-check") args.skipEngines = true;
226
- else if (t === "--rescue") args.rescue = true;
227
- else if (t === "--yes" || t === "-y") args.yes = true;
228
- else if (t === "--agents") {
229
- const raw = rest.shift();
230
- if (!raw) die("--agents needs a comma-separated list");
231
- args.agents = normalizePlatformIds(raw.split(",").map((s) => s.trim()).filter(Boolean));
232
- const unknown = raw.split(",").map((s) => s.trim()).filter(Boolean)
233
- .filter((a) => !isKnownPlatform(a));
234
- if (unknown.length) die(`unknown platform: ${unknown.join(", ")} (run --list-agents)`);
235
- if (!args.agents.length) die("--agents needs at least one known platform");
236
- } else if (t === "--product") args.product = rest.shift();
237
- else if (t === "--client") args.client = rest.shift();
238
- else if (t === "--doc-language" || t === "--doc-filename-language") {
239
- // Free text: "English", "Bahasa Indonesia", "id" — a model reads it, so no list to match.
240
- const raw = (rest.shift() || "").trim();
241
- if (!raw) die(`${t} needs a value, for example: English`);
242
- if (t === "--doc-language") args.docLanguage = raw;
243
- else args.docFilenameLanguage = raw;
244
- }
245
- else if (t.startsWith("-")) die(`unknown flag: ${t}`);
246
- else if (!args.dir) args.dir = t;
247
- else die(`unexpected argument: ${t}`);
248
- }
249
- return args;
250
- }
251
-
252
- // Build output and editor droppings MUST NOT reach the kit. This repository is public, and a
253
- // __pycache__/*.pyc carries the ABSOLUTE PATH of the source it was compiled from — which means a
254
- // product name and a client folder leak into a public package through a file nobody wrote.
255
- // Found 2026-08-18 on the first real promote: inventory.cpython-314.pyc embedded the live repo path.
256
- const SKIP_DIRS = new Set(["__pycache__", "node_modules", ".git", ".pytest_cache", ".ruff_cache",
257
- ".mypy_cache", ".venv", "venv", "dist", "build", ".idea", ".vscode"]);
258
- const SKIP_FILE = /(\.pyc|\.pyo|\.pyd|\.log|\.tmp|\.swp|\.orig|\.rej|\.bak)$|^\.DS_Store$|^Thumbs\.db$/i;
259
-
260
- function walkFiles(dir) {
261
- const out = [];
262
- if (!fs.existsSync(dir)) return out;
263
- for (const entry of fs.readdirSync(dir, { withFileTypes: true })) {
264
- const p = path.join(dir, entry.name);
265
- if (entry.isDirectory()) {
266
- if (SKIP_DIRS.has(entry.name)) continue;
267
- out.push(...walkFiles(p));
268
- } else if (entry.isFile()) {
269
- if (SKIP_FILE.test(entry.name)) continue;
270
- out.push(p);
271
- }
272
- }
273
- return out;
274
- }
275
-
276
- function copyFile(src, dest) {
277
- fs.mkdirSync(path.dirname(dest), { recursive: true });
278
- fs.copyFileSync(src, dest);
279
- }
280
-
281
- function copyTree(src, dest, skipRel) {
282
- let n = 0;
283
- for (const p of walkFiles(src)) {
284
- const rel = posixRel(src, p);
285
- if (skipRel && skipRel(rel)) continue;
286
- copyFile(p, path.join(dest, path.relative(src, p)));
287
- n += 1;
288
- }
289
- return n;
290
- }
291
-
292
- function posixRel(from, to) {
293
- return path.relative(from, to).split(path.sep).join("/");
294
- }
295
-
296
- function bmadPresent(target) {
297
- const markers = [
298
- path.join(target, ".claude", "skills", "bmad-help", "SKILL.md"),
299
- path.join(target, "_bmad", "core", "config.yaml"),
300
- path.join(target, "_bmad", "_config", "manifest.yaml"),
301
- ];
302
- return markers.some((p) => fs.existsSync(p));
303
- }
304
-
305
- function wdiPresent(target) {
306
- return (
307
- fs.existsSync(path.join(target, ".control", "wdi-method.yaml")) ||
308
- fs.existsSync(path.join(target, ".constitution", "method", "README.md"))
309
- );
310
- }
311
-
312
- function dirNonEmpty(target) {
313
- if (!fs.existsSync(target)) return false;
314
- return fs.readdirSync(target).some((n) => n !== ".git" && n !== ".gitignore");
315
- }
316
-
317
- function readBmadVersion(target) {
318
- const manifest = path.join(target, "_bmad", "_config", "manifest.yaml");
319
- if (!fs.existsSync(manifest)) return "";
320
- const text = fs.readFileSync(manifest, "utf8");
321
- const m = text.match(/installation:\s*\n\s*version:\s*(\S+)/);
322
- return m ? m[1] : "";
323
- }
324
-
325
- function gitHead(repo) {
326
- const r = spawnSync("git", ["-C", repo, "rev-parse", "--short", "HEAD"], {
327
- encoding: "utf8",
328
- });
329
- if (r.status !== 0) return "unknown";
330
- return r.stdout.trim();
331
- }
332
-
333
- function today() {
334
- return new Date().toISOString().slice(0, 10);
335
- }
336
-
337
- function requireKit() {
338
- if (!fs.existsSync(path.join(KIT, ".constitution"))) {
339
- die(`kit missing at ${KIT}`);
340
- }
341
- }
342
-
343
- function requireTarget(dir) {
344
- const target = path.resolve(dir || process.cwd());
345
- if (!fs.existsSync(target) || !fs.statSync(target).isDirectory()) {
346
- die(`target is not a directory: ${target}`);
347
- }
348
- return target;
349
- }
350
-
351
- /** Skill files in the repo, keyed by name, de-duplicated by the file each one REALLY is.
352
- *
353
- * The de-duplication is the point. `npx skills add` offers "symlink — single source of truth" when
354
- * more than one agent is selected, so one SKILL.md is reachable through `.claude/skills/` and
355
- * `.agents/skills/` at once. Walking directories would patch it twice — and where the link points
356
- * into `node_modules`, patching it at all would edit a dependency.
357
- */
358
- function repoSkillFiles(target, names) {
359
- const out = new Map();
360
- for (const home of SKILL_HOMES) {
361
- for (const name of names) {
362
- const file = path.join(target, home, "skills", name, "SKILL.md");
363
- if (!fs.existsSync(file)) continue;
364
- let real = file;
365
- try {
366
- real = fs.realpathSync(file);
367
- } catch {}
368
- if (!out.has(name)) out.set(name, new Map());
369
- out.get(name).set(real, file);
370
- }
371
- }
372
- return out;
373
- }
374
-
375
- /** The six engines, in the REPO. A user-level plugin is not an answer here — see ENGINE_SKILLS. */
376
- function enginesReport(target) {
377
- const files = repoSkillFiles(target, ENGINE_SKILLS);
378
- const missing = ENGINE_SKILLS.filter((n) => !files.has(n));
379
- return { files, missing, present: missing.length === 0 };
380
- }
381
-
382
- function enginesPresent(target) {
383
- return enginesReport(target).present;
384
- }
385
-
386
- /** Only ever a WARNING. The plugin's copies are namespaced and still flagged, so they can neither be
387
- * invoked nor shadow the repo's — but `/to-spec` in the UI becomes ambiguous, and `npx skills
388
- * update` run against a plugin-shaped install is one way the flag comes back. */
389
- function pluginEnginesRegistered() {
390
- const cfg = process.env.CLAUDE_CONFIG_DIR || path.join(os.homedir(), ".claude");
391
- const registry = path.join(cfg, "plugins", "installed_plugins.json");
392
- if (!fs.existsSync(registry)) return false;
393
- try {
394
- const plugins = JSON.parse(fs.readFileSync(registry, "utf8")).plugins || {};
395
- return Object.keys(plugins).some((k) => k.startsWith("mattpocock-skills@"));
396
- } catch {
397
- return false;
398
- }
399
- }
400
-
401
- /** Strip the author's flag, and write one guard line where it stood.
402
- *
403
- * The flag was the only thing stopping a stray session from publishing tickets. Removing it without
404
- * naming who may drive the engine trades a hard gate for nothing, so the two arrive together.
405
- * Idempotent by construction: no flag and a guard already present means the file is returned as-is,
406
- * which is what keeps a second `update` from stacking a second line.
407
- */
408
- function withInvocationEnabled(text) {
409
- let out = text;
410
- if (/^disable-model-invocation\s*:.*$/m.test(out)) {
411
- out = out.replace(/^disable-model-invocation\s*:.*\r?\n/m, "");
412
- }
413
- if (!out.includes(GUARD_MARK)) {
414
- out = out.replace(/^(---\r?\n[\s\S]*?\r?\n---\r?\n)/, `$1\n${GUARD_LINE}\n`);
415
- }
416
- return out;
417
- }
418
-
419
- function enableEngineInvocation(target) {
420
- const { files } = enginesReport(target);
421
- const patched = new Set();
422
- for (const name of ENGINE_FLAGGED) {
423
- const copies = files.get(name);
424
- if (!copies) continue;
425
- for (const real of copies.keys()) {
426
- const before = fs.readFileSync(real, "utf8");
427
- const after = withInvocationEnabled(before);
428
- if (after === before) continue;
429
- fs.writeFileSync(real, after, "utf8");
430
- patched.add(name);
431
- }
432
- }
433
- if (patched.size) {
434
- note(`engines invocable: ${[...patched].join(" · ")} — author's flag removed, guard line written`);
435
- }
436
- return [...patched];
437
- }
438
-
439
- /** The mirror image, pointed at BMad's G5 wrappers: the flag ADDED rather than removed.
440
- *
441
- * A rule in a document lost this argument for three releases. `bmad-build` sits in the repo's own
442
- * skill folder claiming it "implements any user intent, requirement, story, bug fix or change
443
- * request", model-invocable, while the sanctioned engines sat in a plugin the model could not call.
444
- * The harness rewarded the forbidden path. This is what stops rewarding it — and it leaves the
445
- * human route open, because the gate only refuses the Skill tool: `/bmad-build` typed by a person
446
- * still runs.
447
- */
448
- function withModelInvocationDisabled(text) {
449
- if (/^disable-model-invocation\s*:\s*true/m.test(text)) return text;
450
- if (!/^---\r?\n/.test(text)) return text; // no frontmatter of its own: not ours to invent one
451
- if (/^disable-model-invocation\s*:/m.test(text)) {
452
- return text.replace(/^disable-model-invocation\s*:.*$/m, "disable-model-invocation: true");
453
- }
454
- return text.replace(/^---\r?\n/, "---\ndisable-model-invocation: true\n");
455
- }
456
-
457
- function retireBmadG5(target) {
458
- const files = repoSkillFiles(target, BMAD_RETIRED_G5);
459
- const patched = new Set();
460
- for (const [name, copies] of files) {
461
- for (const real of copies.keys()) {
462
- const before = fs.readFileSync(real, "utf8");
463
- const after = withModelInvocationDisabled(before);
464
- if (after === before) continue;
465
- fs.writeFileSync(real, after, "utf8");
466
- patched.add(name);
467
- }
468
- }
469
- if (patched.size) {
470
- note(`retired at G5: ${patched.size} BMad skill${patched.size === 1 ? "" : "s"} can no longer be `
471
- + `model-invoked (a person typing the slash command still can)`);
472
- }
473
- return [...patched];
474
- }
475
-
476
- /** Second layer, and the only one that survives BMad reinstalling its own wrappers mid-week.
477
- *
478
- * Merged, never replaced: a product's own permissions are its own. Invalid JSON is reported rather
479
- * than repaired — rewriting a settings file nobody can parse is how a repo loses its allowlist.
480
- */
481
- function writeDenyRules(target) {
482
- const file = path.join(target, ".claude", "settings.json");
483
- let settings = {};
484
- if (fs.existsSync(file)) {
485
- try {
486
- settings = JSON.parse(fs.readFileSync(file, "utf8"));
487
- } catch {
488
- note(".claude/settings.json is not valid JSON — deny rules NOT written; fix it and re-run");
489
- return 0;
490
- }
491
- if (!settings || typeof settings !== "object" || Array.isArray(settings)) return 0;
492
- }
493
- const perms = settings.permissions && typeof settings.permissions === "object"
494
- && !Array.isArray(settings.permissions) ? settings.permissions : {};
495
- const deny = Array.isArray(perms.deny) ? perms.deny : [];
496
- const want = BMAD_RETIRED_G5.map((n) => `Skill(${n})`);
497
- const added = want.filter((rule) => !deny.includes(rule));
498
- if (!added.length) return 0;
499
- perms.deny = [...deny, ...added];
500
- settings.permissions = perms;
501
- fs.mkdirSync(path.dirname(file), { recursive: true });
502
- fs.writeFileSync(file, `${JSON.stringify(settings, null, 2)}\n`, "utf8");
503
- note(`deny rules for ${added.length} retired BMad skill${added.length === 1 ? "" : "s"} `
504
- + `→ .claude/settings.json`);
505
- return added.length;
506
- }
507
-
508
- /** A trace of what the engines were when the method last looked — not a lockfile.
509
- *
510
- * `npx skills add` writes its own `skills-lock.json` with a folder hash per skill, and that hash
511
- * stops matching the moment the flag is stripped. So the register records the hash of the file the
512
- * method actually reads, AFTER the patch. It is informational: `engines-invocable` decides by
513
- * looking for the flag, not by comparing hashes, because a legitimate content change MUST NOT read
514
- * as a defect.
515
- */
516
- function engineFingerprints(target) {
517
- const { files } = enginesReport(target);
518
- const out = {};
519
- for (const name of ENGINE_SKILLS) {
520
- const copies = files.get(name);
521
- if (!copies) continue;
522
- const [real] = [...copies.keys()].sort();
523
- out[name] = createHash("sha256").update(fs.readFileSync(real)).digest("hex").slice(0, 12);
524
- }
525
- return out;
526
- }
527
-
528
- function engineInvocationState(target) {
529
- const { files } = enginesReport(target);
530
- const blocked = [];
531
- for (const name of ENGINE_FLAGGED) {
532
- const copies = files.get(name);
533
- if (!copies) continue;
534
- for (const real of copies.keys()) {
535
- if (/^disable-model-invocation\s*:\s*true/m.test(fs.readFileSync(real, "utf8"))) {
536
- blocked.push(name);
537
- break;
538
- }
539
- }
540
- }
541
- return { blocked };
542
- }
543
-
544
- function bmadMissingMessage() {
545
- return [
546
- "BMad Method is not installed in this repo. Install it first, then run this installer again.",
547
- "",
548
- ` ${BMAD_INSTALL}`,
549
- "",
550
- `Source: ${BMAD_REPO}`,
551
- "In the BMad installer, pick the same agents (Claude Code, Cursor, …).",
552
- ].join("\n");
553
- }
554
-
555
- // The engines used to WARN and let the install through, on the reasoning that G1-G4 run without them and
556
- // a first install has no G5 yet. Both halves are still true, and the reasoning stopped being enough:
557
- // `wdi-autopilot` needs all three from its first iteration, and a warning inside a forty-line summary is
558
- // read exactly as often as it is skipped. The failure it was meant to prevent — learning they are missing
559
- // inside `wdi-build`, with a spec already open — kept happening anyway.
560
- //
561
- // So it blocks, and `--skip-engines-check` is the escape, exactly as `--skip-bmad-check` is for BMad. The
562
- // escape matters: CI installs into a bare checkout, and a repo that will never reach G5 is a real case.
563
- function enginesMissingMessage(missing) {
564
- const names = (missing && missing.length ? missing : ENGINE_SKILLS).join(" · ");
565
- return [
566
- `The engines are not in this repo. Missing: ${names}`,
567
- "",
568
- "They MUST be installed INTO the repo, not as a user-level plugin — the method strips",
569
- "`disable-model-invocation` from its own copies so `wdi-build` and `wdi-autopilot` can drive",
570
- "them, and a plugin's files are not the repo's to edit.",
571
- "",
572
- ` ${ENGINES_INSTALL_ANY}`,
573
- "",
574
- `Take all six: ${ENGINE_SKILLS.join(" · ")}. Choose "copy" or "symlink" — either is read.`,
575
- "",
576
- "You do NOT need to run the setup skill after this — the installer seeds docs/agents/ already",
577
- `answered for this method. Run ${ENGINES_SETUP} only to change tracker.`,
578
- `Source: ${ENGINES_REPO}`,
579
- "",
580
- "G1-G4 run without them. To install anyway and add them later: --skip-engines-check",
581
- ].join("\n");
582
- }
583
-
584
- /** `docs/agents/` is the engines' config, and its PATH is the author's: `to-spec`, `to-tickets`,
585
- * `implement` and `triage` read `docs/agents/issue-tracker.md` and `docs/agents/domain.md` and
586
- * nowhere else. What the files SAY is this method's, and that is the half that kept going wrong:
587
- * a repo that ran `/setup-matt-pocock-skills` carries upstream's answer, which sends every engine to
588
- * `.scratch/` with no registry behind it and never mentions `specs.yaml`. Two of four live repos
589
- * still had it.
590
- *
591
- * The installer does not touch a product-owned file, and that rule stays. This is the repair, run
592
- * from `wdi-method engines --fix` — by `wdi-init` or `wdi-upgrade`, knowingly — and it keeps the
593
- * previous text beside it as `.bak` rather than deleting an answer somebody may have meant.
594
- */
595
- function repairAgentDocs(target) {
596
- const dir = path.join(target, "docs", "agents");
597
- const fixed = [];
598
- for (const name of ["issue-tracker.md", "domain.md"]) {
599
- const seed = path.join(ROOT, "scaffold", "docs", "agents", name);
600
- if (!fs.existsSync(seed)) continue;
601
- const to = path.join(dir, name);
602
- if (!fs.existsSync(to)) {
603
- copyFile(seed, to);
604
- fixed.push(`${name} (seeded)`);
605
- continue;
606
- }
607
- const text = fs.readFileSync(to, "utf8");
608
- if (text.includes("seeded by `wdi-method`")) continue;
609
- fs.writeFileSync(`${to}.bak`, text, "utf8");
610
- copyFile(seed, to);
611
- fixed.push(`${name} (was upstream's — previous text kept as ${name}.bak)`);
612
- }
613
- for (const line of fixed) note(`repaired docs/agents/${line}`);
614
- return fixed;
615
- }
616
-
617
- // The product's custom room. Three properties, and all three MUST hold together:
618
- // install/update seeds its content ONLY when absent — never written again after that
619
- // promote SKIPS it entirely, so a product's own rules can never reach the public repo
620
- // agent loads it like any other guide, so it BINDS
621
- // The deliberate consequence: this room's README is authored in the package and never comes home
622
- // through promote.
623
- const PROJECT_ROOM = "project/";
624
-
625
- // 0.5.0 moved `.constitution/` to exactly two folders: `method/` is the method's and is overwritten,
626
- // `project/` is the product's and is never touched. Before it, generic and product-owned files sat
627
- // side by side at the root, `codebase/` was a third product-owned room nobody had written down, and
628
- // `constitution.md` was ONE file holding both — which is why `update` had to keep the whole thing and
629
- // the product never received a fixed generic Article.
630
- //
631
- // Without this migration an installed repo would end up carrying BOTH layouts: the kit writes the new
632
- // paths while the old files stay behind, and an agent reading `AGENTS.md` routing would find two
633
- // copies of most guides and no way to tell which binds.
634
- const OLD_ROOT_GUIDES = ["README", "language-guide", "method-glossary", "repo-guide", "structure-guide"];
635
- const OLD_WHY = ["README", "artifact-map", "portability", "rationale"];
636
- const OLD_CODEBASE = ["stack", "conventions", "brownfield"];
637
-
638
- function mv(from, to) {
639
- fs.mkdirSync(path.dirname(to), { recursive: true });
640
- fs.renameSync(from, to);
641
- }
642
-
643
- /** Article numbers that belong to the method half. The product keeps 1, 2, and 5. */
644
- const METHOD_ARTICLES = [3, 4, 6, 7];
645
-
646
- /**
647
- * Cut the method's articles out of a product's constitution.md, and repoint its relative links.
648
- *
649
- * Returns {cut, kept, relinked}, or null when the file does not look like a constitution at all —
650
- * in which case it is left ALONE rather than guessed at.
651
- *
652
- * 0.5.0 moved the file whole and printed "delete Articles 3, 4, 6, 7 yourself", on the grounds that
653
- * no script can tell an edited copy from the original. That reasoning was wrong in the way that
654
- * matters: the split does not need to know whether a section was edited, only which article numbers
655
- * are the method's — and the file states them in its own headings. Leaving it whole left every
656
- * migrated repo carrying those articles in TWO files, one of them frozen and drifting, plus relative
657
- * links that no longer resolve one level down. It is all in git, so cutting is reversible; not
658
- * cutting is what nobody notices.
659
- */
660
- function splitProductConstitution(file) {
661
- if (!fs.existsSync(file)) return null;
662
- const raw = fs.readFileSync(file, "utf8");
663
- const crlf = raw.includes("\r\n");
664
- const text = crlf ? raw.replaceAll("\r\n", "\n") : raw;
665
- const marks = [...text.matchAll(/^## Article (\d+)\b.*$/gm)];
666
- if (marks.length < 2) return null; // not the shape we know; do not touch it
667
-
668
- const kept = [];
669
- const cut = [];
670
- let out = text.slice(0, marks[0].index);
671
- for (let i = 0; i < marks.length; i += 1) {
672
- const n = Number(marks[i][1]);
673
- const end = i + 1 < marks.length ? marks[i + 1].index : text.length;
674
- if (METHOD_ARTICLES.includes(n)) cut.push(n);
675
- else {
676
- kept.push(n);
677
- out += text.slice(marks[i].index, end);
678
- }
679
- }
680
- if (!cut.length) return { cut, kept, relinked: 0 };
681
-
682
- // The file sits one level deeper than it did, and its former siblings moved into method/. A link
683
- // left as `repo-guide.md` now resolves to .constitution/project/repo-guide.md, which does not exist.
684
- let relinked = 0;
685
- const bump = (re, to) => {
686
- out = out.replace(re, (m, ...rest) => {
687
- relinked += 1;
688
- return typeof to === "function" ? to(m, ...rest) : to + m;
689
- });
690
- };
691
- for (const name of ["repo-guide.md", "structure-guide.md", "language-guide.md",
692
- "method-glossary.md"]) {
693
- bump(new RegExp(`(?<![\\w./-])${name.replace(".", "\\.")}`, "g"), "../method/");
694
- }
695
- bump(/(?<![\w./-])document\//g, "../method/");
696
- bump(/(?<![\w./-])codebase\/([a-z]+)-guide\.md/g, (_m, kind) => `codebase-${kind}-guide.md`);
697
- out = out.replaceAll("../method/../method/", "../method/");
698
-
699
- const banner = [
700
- "",
701
- `> **Articles ${cut.join(", ")} were removed from this file on migration to the two-folder layout.**`,
702
- "> They are the method's and live in [`../method/constitution.md`](../method/constitution.md), which",
703
- `> \`update\` replaces. Only Articles ${kept.join(", ")} are yours. The removed text is in git.`,
704
- "",
705
- ].join("\n");
706
- const firstArticle = out.search(/^## Article /m);
707
- out = firstArticle === -1
708
- ? out + banner
709
- : out.slice(0, firstArticle) + banner.trimStart() + "\n" + out.slice(firstArticle);
710
-
711
- fs.writeFileSync(file, crlf ? out.replaceAll("\n", "\r\n") : out, "utf8");
712
- return { cut, kept, relinked };
713
- }
714
-
715
- // `waves.yaml` holds the PRODUCT's plan, not the package's. When the method retired `wave` for
716
- // `spec` the registry had to follow, and a rename is the only part of that a tool can safely do:
717
- // the file MOVES, its content is left exactly as written. Rewriting the rows — `W1` to `SPEC-1`,
718
- // `epics`/`stories` to `tickets` — is the product's own migration, run by `wdi-build` where a human
719
- // can see it, because a guess there silently rewrites months of real work.
720
- //
721
- // Two refusals matter more than the move. It never writes over an existing `specs.yaml`, and it
722
- // never deletes a `waves.yaml` whose content has nowhere to go: a half-finished hand migration
723
- // leaves BOTH files present, and which one is real is not something an installer can know.
724
- // `wdi-autopilot` named its ledger for the DAY before 0.6.2 — `autopilot-<YYYY-MM-DD>.md`. The mandate
725
- // it belongs to is named for the MANDATE now — `autopilot-<DEC-id>.md` — because two mandates opened
726
- // on the same day would otherwise append to one file and destroy both as a record, and because
727
- // `mandate-accept` (the validator introduced alongside the rename) looks for the file at that path and
728
- // nowhere else. This is a pure rename, like `waves.yaml` → `specs.yaml`: the ledger's own content is
729
- // never touched, only found and moved. Renaming it is what a script can safely do; restructuring its
730
- // CONTENT into the `## Resume` / `## Decisions` split is not — that has to read git and the registry to
731
- // know where the run actually stands, so it is the skill's own job on the next iteration it runs, not
732
- // this installer's.
733
- // `/setup-matt-pocock-skills` interviews the owner and writes `docs/agents/`. Two of its answers are
734
- // wrong for a WDI repo, and BOTH repos that ran it had to hand-correct the SAME file afterwards:
735
- //
736
- // - `domain.md` tells agents to read and lazily create a root `CONTEXT.md` and `docs/adr/`. Article 3
737
- // says this method has no `docs/` layer for corpus or rules, and `wdi-reconcile` reports both as
738
- // findings. The homes already exist: `.control/product-glossary.md`, `.what/`, `.how/`, `DEC-`.
739
- // - `issue-tracker.md`'s local-markdown default puts every ticket under `.scratch/<feature>/`, while
740
- // `wdi-build` owns tickets at `{spec_folder}/issues/`. Two homes for one ticket set.
741
- //
742
- // Seeding them removes the interview for the answers WDI Method actually has a requirement on. Seeded
743
- // ONCE and never overwritten — after the first install they are the product's, like every other file
744
- // under a path the product owns. An owner who wants a different tracker re-runs the setup skill; the
745
- // seeded file says which three invariants have to survive that.
746
- function seedAgentDocs(target) {
747
- const dir = path.join(target, "docs", "agents");
748
- let wrote = 0;
749
- for (const name of ["domain.md", "issue-tracker.md"]) {
750
- const to = path.join(dir, name);
751
- if (fs.existsSync(to)) continue;
752
- const seed = path.join(ROOT, "scaffold", "docs", "agents", name);
753
- if (!fs.existsSync(seed)) continue;
754
- copyFile(seed, to);
755
- wrote += 1;
756
- }
757
- if (wrote) {
758
- note(`seeded docs/agents/ (${wrote} file${wrote === 1 ? "" : "s"}) — the engines' config, pre-answered`);
759
- note(" do NOT run /setup-matt-pocock-skills to redo these; re-run it only to change tracker");
760
- }
761
- return wrote > 0;
762
- }
763
-
764
- // A repo that ran the setup skill BEFORE installing this package still carries the default `domain.md`,
765
- // and it is actively misleading: it sends every engineering skill looking for a root `CONTEXT.md` and
766
- // `docs/adr/`, and tells them to create both lazily. Seeding cannot fix it, because the file already
767
- // exists and a file under a product-owned path is never overwritten. So it is named instead.
768
- function warnStaleAgentDocs(target) {
769
- const file = path.join(target, "docs", "agents", "domain.md");
770
- if (!fs.existsSync(file)) return;
771
- const text = fs.readFileSync(file, "utf8");
772
- if (!/CONTEXT\.md|docs\/adr/.test(text)) return;
773
- // An override note is what both real repos added by hand. Recognising it is what stops this warning
774
- // from firing forever on a file somebody already fixed.
775
- if (/does not use|MUST NOT be created|no `docs\/` layer/i.test(text)) return;
776
- note("docs/agents/domain.md still points agents at a root CONTEXT.md and docs/adr/");
777
- note(" Article 3: this method has no `docs/` layer for corpus or rules, and wdi-reconcile");
778
- note(" reports both as findings. Say so at the top of that file — the glossary is at");
779
- note(" .control/product-glossary.md and a decision is a DEC-, never an ADR");
780
- }
781
-
782
- function migrateAutopilotLedgers(target) {
783
- const dir = path.join(target, ".control", "memlog");
784
- if (!fs.existsSync(dir)) return;
785
- const OLD = /^autopilot-(\d{4}-\d{2}-\d{2})\.md$/;
786
- for (const name of fs.readdirSync(dir)) {
787
- const m = OLD.exec(name);
788
- if (!m) continue;
789
- const from = path.join(dir, name);
790
- const text = fs.readFileSync(from, "utf8");
791
- const artifact = /^artifact:\s*(\S.*)$/m.exec(text)?.[1]?.trim();
792
- const id = artifact && /(DEC-\d+)/.exec(artifact)?.[1];
793
- if (!id) {
794
- note(`.control/memlog/${name} looks like a pre-0.6.2 autopilot ledger, but its \`artifact:\` does`);
795
- note(` not resolve to a DEC- id — rename it to autopilot-<the mandate's DEC- id>.md yourself`);
796
- continue;
797
- }
798
- const to = path.join(dir, `autopilot-${id}.md`);
799
- if (fs.existsSync(to)) {
800
- note(`BOTH .control/memlog/${name} and autopilot-${id}.md exist — neither was touched`);
801
- note(` the run's ledger is in one of them and I cannot tell which. Merge them, then delete the other`);
802
- continue;
803
- }
804
- mv(from, to);
805
- note(`renamed .control/memlog/${name} → autopilot-${id}.md (content unchanged)`);
806
- note(` \`mandate-accept\` looks for a mandate's ledger at this exact path`);
807
- }
808
- }
809
-
810
- // A mandate opened before 0.6.2 recorded `parked: []` under the OLD default — full authority, AD-N
811
- // contradictions included. 0.6.2 changed the DEFAULT for a NEW mandate to park `ad-n`, because
812
- // decision-guide.md says narrowing an invariant MUST NOT be softened further. A default only applies
813
- // at the moment a mandate is written, so an EXISTING accepted mandate keeps whatever it already says —
814
- // silently adding `ad-n` to it would be overwriting a value the owner already chose, which `update`
815
- // MUST NOT do to anything in the product's own registry. So this only ever WARNS, naming the mandate
816
- // and the one line that would close the gap, and leaves the decision to whoever reads the summary.
817
- function warnStaleMandates(target) {
818
- const file = path.join(target, ".control", "registry", "decisions.yaml");
819
- if (!fs.existsSync(file)) return;
820
- const text = fs.readFileSync(file, "utf8");
821
- const blocks = text.split(/\n(?=\s*-\s*id:\s*DEC-)/);
822
- for (const block of blocks) {
823
- if (!/type:\s*mandate/.test(block)) continue;
824
- if (!/status:\s*accepted/.test(block)) continue;
825
- const id = /id:\s*(DEC-\d+)/.exec(block)?.[1];
826
- const parkedLine = /parked:\s*(\[[^\]]*\]|.*)$/m.exec(block)?.[0] || "";
827
- const parkedBlockList = /parked:\s*\n((?:\s+-\s*\S.*\n?)*)/.exec(block)?.[1] || "";
828
- if (/ad-n/.test(parkedLine) || /ad-n/.test(parkedBlockList)) continue;
829
- note(`${id || "a mandate"} predates the \`ad-n\`-parked-by-default protection (0.6.2) — its \`parked\``);
830
- note(` list does not name it, so it still decides an AD-N contradiction on its own`);
831
- note(` add \`ad-n\` to its \`parked\` list in decisions.yaml yourself if you want the new default`);
832
- }
833
- }
834
-
835
- function migrateRegistryNames(target) {
836
- const reg = path.join(target, ".control", "registry");
837
- const from = path.join(reg, "waves.yaml");
838
- const to = path.join(reg, "specs.yaml");
839
- if (!fs.existsSync(from)) return false;
840
- if (fs.existsSync(to)) {
841
- note("BOTH .control/registry/waves.yaml and specs.yaml exist — neither was touched");
842
- note(" the plan is in one of them and I cannot tell which. Merge them yourself, then delete waves.yaml");
843
- return false;
844
- }
845
- mv(from, to);
846
- note("renamed .control/registry/waves.yaml → specs.yaml (content unchanged)");
847
- note(" the rows still say `W<N>` and `epics`/`stories`. Re-cut them through the wdi-build skill");
848
- return true;
849
- }
850
-
851
- // The requirement registry split into `goals.yaml` (the product's `BG`, written by `wdi-problem` at
852
- // G1) plus one `requirements-<slug>.yaml` per PRD (`CAP`, `FR`, `NFR`, `UJ`, written by
853
- // `wdi-product` at G2). One file, one writer, one gate. What a tool can do here is SEED `goals.yaml`;
854
- // what it MUST NOT do is move the rows.
855
- //
856
- // Splitting the rows needs one fact the registry has never recorded: which PRD an `FR` belongs to.
857
- // Before the split nothing wrote it down, and deriving it — FR → UC → ticket → spec → `prd:` — only
858
- // works for FRs that already have tickets. A guess would file a promise under the wrong initiative,
859
- // which is worse than leaving it where it is. So `requirements.yaml` is left ALONE and still read:
860
- // `validate.py` unions every requirement file it finds, so a half-split corpus stays green while its
861
- // owner cuts the rows through the skill that owns each one.
862
- function seedRequirementSplit(target) {
863
- const reg = path.join(target, ".control", "registry");
864
- if (!fs.existsSync(reg)) return false;
865
- const product = path.join(reg, "goals.yaml");
866
- if (fs.existsSync(product)) return false;
867
- const seed = path.join(SCAFFOLD, "registry", "goals.yaml");
868
- if (!fs.existsSync(seed)) return false;
869
- copyFile(seed, product);
870
- note("seeded .control/registry/goals.yaml");
871
- if (fs.existsSync(path.join(reg, "requirements.yaml"))) {
872
- note(" requirements.yaml was left exactly as it is, and is still read — nothing broke");
873
- note(" the wdi-upgrade skill moves `goals:` into goals.yaml and cuts `capabilities:`,");
874
- note(" `functional:`, `nonfunctional:`, and `journeys:` into requirements-<slug>.yaml per PRD.");
875
- note(" <slug> is the PRD's folder name under .what/_prd/");
876
- }
877
- return true;
878
- }
879
-
880
- function migrateToTwoFolders(target) {
881
- const c = path.join(target, ".constitution");
882
- if (!fs.existsSync(c)) return false; // a first install has nothing to migrate
883
- const at = (...p) => path.join(c, ...p);
884
- // The old layout is identified by `document/` at the ROOT — in the new layout that folder only ever
885
- // exists under `method/`. Checking a loose guide instead would misfire on a repo that added one.
886
- if (!fs.existsSync(at("document")) && !fs.existsSync(at("codebase"))
887
- && !fs.existsSync(at("constitution.md")) && !fs.existsSync(at("scripts"))) {
888
- return false;
889
- }
890
- note("pre-0.5.0 .constitution/ found — migrating to method/ + project/");
891
-
892
- // 1. The four Reference files go one level deeper. This MUST run before the kit is written, or the
893
- // kit's own why/ files land while the old copies still sit at method/ root.
894
- for (const name of OLD_WHY) {
895
- const from = at("method", `${name}.md`);
896
- if (fs.existsSync(from)) {
897
- mv(from, at("method", "why", `${name}.md`));
898
- note(` moved method/${name}.md → method/why/${name}.md`);
899
- }
900
- }
901
- // 2. and 3. whole folders
902
- for (const dir of ["document", "scripts"]) {
903
- if (fs.existsSync(at(dir)) && !fs.existsSync(at("method", dir))) {
904
- mv(at(dir), at("method", dir));
905
- note(` moved ${dir}/ → method/${dir}/`);
906
- }
907
- }
908
- // 4. the loose generic guides
909
- for (const name of OLD_ROOT_GUIDES) {
910
- const from = at(`${name}.md`);
911
- if (fs.existsSync(from)) {
912
- mv(from, at("method", `${name}.md`));
913
- note(` moved ${name}.md → method/${name}.md`);
914
- }
915
- }
916
- // 5. codebase/ was a product-owned room all along — it becomes flat files in the room that says so
917
- for (const name of OLD_CODEBASE) {
918
- const from = at("codebase", `${name}-guide.md`);
919
- if (fs.existsSync(from)) {
920
- mv(from, at("project", `codebase-${name}-guide.md`));
921
- note(` moved codebase/${name}-guide.md → project/codebase-${name}-guide.md`);
922
- }
923
- }
924
- if (fs.existsSync(at("codebase"))) {
925
- const left = fs.readdirSync(at("codebase"));
926
- if (!left.length) fs.rmdirSync(at("codebase"));
927
- else note(` codebase/ still holds ${left.join(", ")} — left in place, move them yourself`);
928
- }
929
- // 6. The product's constitution.md moves WHOLE into the room, so its Articles 1, 2, and 5 survive
930
- // exactly as written. The generic half then arrives fresh at method/constitution.md.
931
- let split = null;
932
- if (fs.existsSync(at("constitution.md")) && !fs.existsSync(at("project", "constitution.md"))) {
933
- mv(at("constitution.md"), at("project", "constitution.md"));
934
- note(" moved constitution.md → project/constitution.md");
935
- split = splitProductConstitution(at("project", "constitution.md"));
936
- if (split && split.cut.length) {
937
- note(` kept Articles ${split.kept.join(", ")}, removed ${split.cut.join(", ")} `
938
- + "(the method's — they arrive in method/constitution.md)");
939
- if (split.relinked) note(` repointed ${split.relinked} relative links one level up`);
940
- } else if (split === null) {
941
- note(" it does not carry `## Article N` headings, so it was moved but NOT split — yours to check");
942
- }
943
- }
944
- // Anything else loose at the root is a file this product ADDED. It is NOT moved: it may be routed
945
- // from AGENTS.md by its current path, and guessing a destination would break that silently.
946
- const stray = fs.existsSync(c)
947
- ? fs.readdirSync(c, { withFileTypes: true })
948
- .filter((e) => e.isFile() && e.name.endsWith(".md"))
949
- .map((e) => e.name)
950
- : [];
951
- if (stray.length) {
952
- note(` left at .constitution/ root, yours to place: ${stray.join(", ")}`);
953
- note(" a file you added belongs in project/ — but moving it would break any pointer that");
954
- note(" names its current path, so the choice is yours. repo-guide.md states the rule.");
955
- }
956
- return split;
957
- }
958
-
959
- function syncConstitution(target) {
960
- const kitConst = path.join(KIT, ".constitution");
961
- const destConst = path.join(target, ".constitution");
962
- fs.mkdirSync(destConst, { recursive: true });
963
- let written = 0;
964
- let skipped = 0;
965
- for (const file of walkFiles(kitConst)) {
966
- const rel = posixRel(kitConst, file);
967
- const dest = path.join(destConst, rel);
968
- // ONE rule for everything the product owns, because 0.5.0 put all of it in one folder. Before
969
- // that this loop had three branches — the mixed constitution.md kept whole, `codebase/` gated on
970
- // `status: Accepted` (which is what silently destroyed a half-written guide), and the room — and
971
- // the three disagreed about when a file was the product's. Seeded when absent, never written
972
- // again: the same rule as the language policy.
973
- // ONE file in the room is the package's and is refreshed like any method file: the room's own
974
- // README. It explains what the room is FOR and carries no product decision, so a stale copy does
975
- // not preserve anybody's work — it just misinforms. worship-presenter-web proved that: its copy
976
- // still pointed at `.constitution/codebase/*-guide.md`, a folder 0.5.0 deleted, and no update
977
- // would ever have corrected it while the file claimed in its own text to be "authored in the
978
- // package". Either the package writes it or it stops claiming authorship; this is the first.
979
- if (rel === `${PROJECT_ROOM}README.md`) {
980
- copyFile(file, dest);
981
- written += 1;
982
- continue;
983
- }
984
- if (rel.startsWith(PROJECT_ROOM) && fs.existsSync(dest)) {
985
- skipped += 1;
986
- note(`keep ${rel} (yours — the project room)`);
987
- continue;
988
- }
989
- copyFile(file, dest);
990
- written += 1;
991
- }
992
- return { written, skipped };
993
- }
994
-
995
- function syncSkills(target, agents) {
996
- let n = 0;
997
- const dests = skillDestinations(target, agents);
998
- if (dests.length === 0) {
999
- note("no skill destinations for selected platforms — AGENTS.md still applies");
1000
- return { files: 0, removed: 0 };
1001
- }
1002
- for (const name of WDI_SKILLS) {
1003
- const src = path.join(KIT, "skills", name);
1004
- if (!fs.existsSync(src)) die(`kit missing skill ${name}`);
1005
- for (const root of dests) {
1006
- const dest = path.join(root, name);
1007
- fs.rmSync(dest, { recursive: true, force: true });
1008
- n += copyTree(src, dest);
1009
- }
1010
- }
1011
- const removed = pruneRetiredSkills(dests);
1012
- return { files: n, removed };
1013
- }
1014
-
1015
- // A wrapper the method RETIRED is worse than a wrapper missing: the folder is still there, its
1016
- // SKILL.md still reads like an instruction, and an agent will invoke it — while the guide it points
1017
- // at is gone. Renaming five wrappers (wdi-apply, wdi-analysis, wdi-structure, …) left exactly that
1018
- // in every repo installed before the rename, because update only ever touched the names it knows.
1019
- //
1020
- // `wdi-` is the method's namespace, so a `wdi-*` folder carrying a SKILL.md and not in WDI_SKILLS is
1021
- // ours and retired. Each removal is PRINTED: silent deletion in someone else's repo is not a fix.
1022
- function pruneRetiredSkills(dests) {
1023
- let removed = 0;
1024
- const keep = new Set(WDI_SKILLS);
1025
- for (const root of dests) {
1026
- if (!fs.existsSync(root)) continue;
1027
- for (const entry of fs.readdirSync(root, { withFileTypes: true })) {
1028
- if (!entry.isDirectory() || !entry.name.startsWith("wdi-") || keep.has(entry.name)) continue;
1029
- const dir = path.join(root, entry.name);
1030
- if (!fs.existsSync(path.join(dir, "SKILL.md"))) {
1031
- note(`kept ${entry.name} (no SKILL.md — not one of ours)`);
1032
- continue;
1033
- }
1034
- fs.rmSync(dir, { recursive: true, force: true });
1035
- note(`removed retired skill ${entry.name}`);
1036
- removed += 1;
1037
- }
1038
- }
1039
- return removed;
1040
- }
1041
-
1042
- // `promote` scrubs a product's initiative slug out of bmad-prd.toml before publishing, which is right.
1043
- // Writing the scrubbed PLACEHOLDER back into a product repo is not: the first real install replaced a
1044
- // live `run_folder_pattern = "some-real-slug"` with `FILL-initiative-slug`, and nothing said so. A value
1045
- // the product already chose is not the installer's to overwrite — same rule as the custom room and the
1046
- // language policy.
1047
- const PLACEHOLDER_SLUG = "FILL-initiative-slug";
1048
- const RUN_FOLDER_LINE = /^(\s*run_folder_pattern\s*=\s*)(".*?"|'.*?')/m;
1049
-
1050
- // The slug appears MORE THAN ONCE — bmad-prd.toml carries it in `run_folder_pattern` and again inside a
1051
- // memlog path, and the file itself says the two lines MUST change together. The first version of this
1052
- // function restored only the first line and so produced exactly the inconsistency that file forbids.
1053
- // So: read the product's slug once, then put it back everywhere the placeholder appears.
1054
- function keepProductSlug(incoming, existing) {
1055
- const mineNow = existing.match(RUN_FOLDER_LINE);
1056
- if (!mineNow) return null;
1057
- const slug = mineNow[2].slice(1, -1);
1058
- if (!slug || slug === PLACEHOLDER_SLUG) return null;
1059
- if (!incoming.includes(PLACEHOLDER_SLUG)) return null;
1060
- // Only where the slug is a VALUE: the quoted setting, and the memlog path built from it. A bare
1061
- // mention inside a comment stays the placeholder — that sentence explains the pattern, and rewriting
1062
- // it would turn a generic explanation into a statement about one initiative.
1063
- return incoming
1064
- .replaceAll(`"${PLACEHOLDER_SLUG}"`, `"${slug}"`)
1065
- .replaceAll(`prd-${PLACEHOLDER_SLUG}`, `prd-${slug}`);
1066
- }
1067
-
1068
- function syncTomls(target) {
1069
- const src = path.join(KIT, "assets", "bmad-custom");
1070
- const dest = path.join(target, "_bmad", "custom");
1071
- fs.mkdirSync(dest, { recursive: true });
1072
- let n = 0;
1073
- let slugsKept = 0;
1074
- for (const file of walkFiles(src)) {
1075
- if (!file.endsWith(".toml") || file.endsWith(".user.toml")) continue;
1076
- const to = path.join(dest, path.basename(file));
1077
- if (fs.existsSync(to)) {
1078
- const merged = keepProductSlug(fs.readFileSync(file, "utf8"), fs.readFileSync(to, "utf8"));
1079
- if (merged !== null) {
1080
- fs.writeFileSync(to, merged);
1081
- note(`kept run_folder_pattern in ${path.basename(file)}`);
1082
- slugsKept += 1;
1083
- n += 1;
1084
- continue;
1085
- }
1086
- }
1087
- copyFile(file, to);
1088
- n += 1;
1089
- }
1090
- return { files: n, slugsKept };
1091
- }
1092
-
1093
- // The same argument pruneRetiredSkills makes, one folder over — with one difference that changes
1094
- // the rule. `wdi-` is this method's namespace, so "a wdi-* folder not in WDI_SKILLS" is safely ours.
1095
- // `_bmad/custom/` is NOT: a product may put its own override there, and `.user.toml` is the
1096
- // product's half of every override by convention. So removal here is by an EXPLICIT list of files
1097
- // this package once shipped and has now withdrawn — never by "absent from the kit".
1098
- //
1099
- // Why remove them at all: an override for a retired engine is worse than no override. It is still
1100
- // installed and still read, and bmad-retrospective.toml instructs an agent to archive an `RTR-`
1101
- // against a validator, V19, that no longer exists.
1102
- const RETIRED_TOMLS = [
1103
- "bmad-spec.toml", "bmad-build.toml", "bmad-build-auto.toml",
1104
- "bmad-code-review.toml", "bmad-retrospective.toml",
1105
- ];
1106
-
1107
- function pruneRetiredTomls(target) {
1108
- const dir = path.join(target, "_bmad", "custom");
1109
- if (!fs.existsSync(dir)) return 0;
1110
- let removed = 0;
1111
- for (const name of RETIRED_TOMLS) {
1112
- const file = path.join(dir, name);
1113
- if (!fs.existsSync(file)) continue;
1114
- fs.rmSync(file);
1115
- note(`removed retired override ${name}`);
1116
- removed += 1;
1117
- }
1118
- return removed;
1119
- }
1120
-
1121
- function seedControlIfMissing(target) {
1122
- const control = path.join(target, ".control");
1123
- if (fs.existsSync(control)) {
1124
- note(".control/ already present — left untouched");
1125
- return;
1126
- }
1127
- if (!fs.existsSync(SCAFFOLD)) die(`scaffold missing: ${SCAFFOLD}`);
1128
- const n = copyTree(SCAFFOLD, control);
1129
- ok(`seeded empty .control/ (${n} files)`);
1130
- }
1131
-
1132
- // On a FIRST install these folders are the corpus taking shape. On an UPDATE their absence means
1133
- // somebody removed them on purpose — `.work/` and `_bmad-output/prior-knowledge/` are exactly the two a
1134
- // product retires once its migration is done, and one repo retired them through an applied decision.
1135
- // Recreating them then is an installer overruling a decision it cannot read. Seed once, never resurrect.
1136
- function seedEmptyLayers(target, { first }) {
1137
- const always = [".what", path.join(".how", "_platform")];
1138
- const firstOnly = [".work", path.join("_bmad-output", "prior-knowledge")];
1139
- for (const rel of first ? [...always, ...firstOnly] : always) {
1140
- const dest = path.join(target, rel);
1141
- if (!fs.existsSync(dest)) {
1142
- fs.mkdirSync(dest, { recursive: true });
1143
- // Git tracks files, not directories: an empty folder does not reach the next clone. The
1144
- // scaffold already puts a `.gitkeep` in each of its empty rooms, and these four were the
1145
- // exception — `.work/` invisible from birth is half the reason a bootstrap read it as
1146
- // ignorable and wrote it into `.gitignore`, which corpus-in-git now reports.
1147
- fs.writeFileSync(path.join(dest, ".gitkeep"), "");
1148
- note(`created ${rel.replaceAll(path.sep, "/")}/`);
1149
- }
1150
- }
1151
- if (!first) {
1152
- for (const rel of firstOnly) {
1153
- if (!fs.existsSync(path.join(target, rel))) {
1154
- note(`left ${rel.replaceAll(path.sep, "/")}/ absent — a product retires it, not the installer`);
1155
- }
1156
- }
1157
- }
1158
- }
1159
-
1160
- function writeStamp(target) {
1161
- const control = path.join(target, ".control");
1162
- if (!fs.existsSync(control)) return;
1163
- const eng = enginesReport(target);
1164
- const fp = engineFingerprints(target);
1165
- const names = Object.keys(fp);
1166
- const lines = [
1167
- "# Written by wdi-method install/update. A trace, not a lockfile.",
1168
- `wdi_method: ${PKG.version}`,
1169
- `bmad_method: ${readBmadVersion(target) || '""'}`,
1170
- ];
1171
- if (names.length) {
1172
- const blocked = engineInvocationState(target).blocked;
1173
- lines.push("engines:");
1174
- lines.push(" source: local");
1175
- lines.push(" package: mattpocock/skills");
1176
- lines.push(` lock: ${fs.existsSync(path.join(target, ENGINE_LOCK)) ? ENGINE_LOCK : '""'}`);
1177
- lines.push(` model_invocation: ${blocked.length ? "blocked" : "enabled"}`);
1178
- if (eng.missing.length) lines.push(` missing: [${eng.missing.join(", ")}]`);
1179
- lines.push(" # sha256 of each SKILL.md AFTER the flag was stripped, first 12. Informational:");
1180
- lines.push(" # engines-invocable decides by looking for the flag, not by comparing these.");
1181
- lines.push(" skills:");
1182
- for (const name of names) lines.push(` ${name}: ${fp[name]}`);
1183
- } else {
1184
- lines.push("engines:");
1185
- lines.push(" source: none # none in this repo — G5 cannot run until they are installed");
1186
- }
1187
- lines.push(`installed_at: ${today()}`);
1188
- lines.push("");
1189
- const stamp = lines.join("\n");
1190
- fs.writeFileSync(path.join(control, "wdi-method.yaml"), stamp, "utf8");
1191
- note("stamped .control/wdi-method.yaml");
1192
- }
1193
-
1194
- function setProductIdentity(target, { name, client }) {
1195
- if (!name || identityIsPlaceholder(name)) return;
1196
- const file = path.join(target, ".control", "registry", "index.yaml");
1197
- if (!fs.existsSync(file)) return;
1198
- const next = writeProductIdentity(fs.readFileSync(file, "utf8"), {
1199
- name,
1200
- client: client ?? "",
1201
- });
1202
- fs.writeFileSync(file, next.endsWith("\n") ? next : `${next}\n`);
1203
- note(`product.name = ${name}`);
1204
- }
1205
-
1206
- // The document language belongs to the PRODUCT, so update MUST NOT overwrite it. It is written only
1207
- // when absent — same as the custom room, and for the same reason: a setting somebody already chose
1208
- // is not the installer's to change behind their back.
1209
- function setLanguagePolicy(target, { docLanguage, docFilenameLanguage, chosen }) {
1210
- const file = path.join(target, ".control", "registry", "index.yaml");
1211
- if (!fs.existsSync(file)) return;
1212
- const text = fs.readFileSync(file, "utf8");
1213
- const existing = readLanguagePolicy(text);
1214
- // `chosen` means somebody actually answered — in the TUI, or through an explicit flag. Only then
1215
- // does the answer take effect. Without it the incoming value is just a default, and a default
1216
- // MUST NOT overwrite a choice somebody already made.
1217
- if (!chosen && existing.docLanguage && existing.docFilenameLanguage) {
1218
- note(`kept policy.doc_language = ${existing.docLanguage}, ` +
1219
- `doc_filename_language = ${existing.docFilenameLanguage}`);
1220
- return;
1221
- }
1222
- const next = writeLanguagePolicy(text, {
1223
- docLanguage: docLanguage || existing.docLanguage || DEFAULT_DOC_LANGUAGE,
1224
- docFilenameLanguage:
1225
- docFilenameLanguage || existing.docFilenameLanguage || DEFAULT_DOC_LANGUAGE,
1226
- });
1227
- fs.writeFileSync(file, next.endsWith("\n") ? next : `${next}\n`);
1228
- const after = readLanguagePolicy(next);
1229
- note(`policy.doc_language = ${after.docLanguage}, ` +
1230
- `doc_filename_language = ${after.docFilenameLanguage}`);
1231
- }
1232
-
1233
- // After `update`, some of the corpus can still be in the OLD shape — content the installer MUST NOT
1234
- // move, because moving it takes a decision about meaning: which PRD an `FR` belongs to, whether a
1235
- // sentence was an assumption or a constraint. The `wdi-upgrade` skill does that half. This only
1236
- // DETECTS it, cheaply, so the summary can say how much is waiting and where.
1237
- /** Specs whose folder is not where the convention puts it — and that are still WORK.
1238
- *
1239
- * A closed spec is exempt, and one measured repo is why: ten closed specs, none open. Reporting all
1240
- * ten would ask somebody to move ten folders of finished work and repoint every cite into them, for
1241
- * nothing — `spec_folder` still resolves, and a closed spec's ticket file is already allowed to be
1242
- * gone. The same exemption `ticket-status-one-home` grants, for the same reason: the convention binds
1243
- * work, not the record of work that is done.
1244
- *
1245
- * Scanned line by line rather than parsed: this installer has no YAML reader, and both the flat
1246
- * `specs:` shape and the pre-rename `waves:` one open a row the same way.
1247
- */
1248
- function specsOutsideScratch(text) {
1249
- const out = [];
1250
- let id = "";
1251
- let status = "";
1252
- let folder = "";
1253
- const flush = () => {
1254
- if (id && folder && status !== "closed" && !folder.startsWith(".scratch/")) out.push(id);
1255
- id = "";
1256
- status = "";
1257
- folder = "";
1258
- };
1259
- for (const line of text.split(/\r?\n/)) {
1260
- const row = /^\s{2}-\s+id:\s*(\S+)/.exec(line);
1261
- if (row) {
1262
- flush();
1263
- id = row[1].replace(/['"]/g, "");
1264
- continue;
1265
- }
1266
- if (!id) continue;
1267
- const st = /^\s+status:\s*(\S+)/.exec(line);
1268
- if (st && !status) status = st[1].replace(/['"]/g, "");
1269
- const sf = /^\s+spec_folder:\s*(\S+)/.exec(line);
1270
- if (sf && !folder) folder = sf[1].replace(/['"]/g, "");
1271
- }
1272
- flush();
1273
- return out;
1274
- }
1275
-
1276
- /** Specs still in the pre-rename plan shape that are NOT closed — the ones with work left in them.
1277
- *
1278
- * Same scanner shape as `specsOutsideScratch`, and the same exemption for the same reason: the
1279
- * convention binds work, not the record of work that is done.
1280
- */
1281
- function specsInLegacyShape(text) {
1282
- const out = [];
1283
- let id = "";
1284
- let status = "";
1285
- let legacy = false;
1286
- const flush = () => {
1287
- if (id && legacy && status !== "closed") out.push(id);
1288
- id = "";
1289
- status = "";
1290
- legacy = false;
1291
- };
1292
- for (const line of text.split(/\r?\n/)) {
1293
- const row = /^\s{2}-\s+id:\s*(\S+)/.exec(line);
1294
- if (row) {
1295
- flush();
1296
- id = row[1].replace(/['"]/g, "");
1297
- if (/^W\d+$/.test(id)) legacy = true;
1298
- continue;
1299
- }
1300
- if (!id) continue;
1301
- const st = /^\s+status:\s*(\S+)/.exec(line);
1302
- if (st && !status) status = st[1].replace(/['"]/g, "");
1303
- if (/^\s+(epics|stories):/.test(line)) legacy = true;
1304
- }
1305
- flush();
1306
- return out;
1307
- }
1308
-
1309
- function pendingUpgrades(target) {
1310
- const has = (...p) => fs.existsSync(path.join(target, ...p));
1311
- const read = (...p) => (has(...p) ? fs.readFileSync(path.join(target, ...p), "utf8") : "");
1312
- const anyIn = (dir, glob, re) => {
1313
- const d = path.join(target, dir);
1314
- if (!fs.existsSync(d)) return false;
1315
- return fs.readdirSync(d).some((n) => {
1316
- const f = path.join(d, n, glob);
1317
- return fs.existsSync(f) && re.test(fs.readFileSync(f, "utf8"));
1318
- });
1319
- };
1320
- const items = [];
1321
- if (has(".control", "registry", "requirements.yaml")) items.push("requirements.yaml → goals.yaml + requirements-<slug>.yaml");
1322
- // The file the engines actually read. `/setup-matt-pocock-skills` writes its own answer here — no
1323
- // `specs.yaml`, no predefined path — and `seedAgentDocs` will not overwrite a file the product owns,
1324
- // so without this probe the repo never learns why its tickets scatter.
1325
- if (has("docs", "agents", "issue-tracker.md")
1326
- && !read("docs", "agents", "issue-tracker.md").includes("seeded by `wdi-method`")) {
1327
- items.push("docs/agents/issue-tracker.md is not the method's answer (npx wdi-method engines --fix)");
1328
- }
1329
- const strays = specsOutsideScratch(read(".control", "registry", "specs.yaml"));
1330
- if (strays.length) {
1331
- items.push(`spec_folder outside .scratch/<spec-id>-<slug>/ on ${strays.join(", ")} `
1332
- + `(the folder moves, then its cites)`);
1333
- }
1334
- // Reported only where it is still WORK. A closed pre-rename wave is read correctly (0.6.7 taught
1335
- // `Corpus.tickets()` to flatten `epics`/`stories` in memory), its `W<n>` id is a retired alias by
1336
- // design, and its ticket files are already allowed to be gone. Nothing there is waiting to move.
1337
- //
1338
- // Until 0.6.11 this fired on every legacy row and pointed at `wdi-build` to "re-cut" it. That
1339
- // instruction outlived the design it came from: `wdi-build` Phase 2 invokes `to-spec`/`to-tickets`
1340
- // to write a NEW contract and publish new tickets, and has no mode that converts an old wave.
1341
- // Three repos carrying twenty, forty-five and ten closed legacy rows were each told to run a skill
1342
- // that would answer "not mine" and stop.
1343
- const legacyOpen = specsInLegacyShape(read(".control", "registry", "specs.yaml"));
1344
- if (legacyOpen.length) {
1345
- items.push(`${legacyOpen.join(", ")} still in the W<n>/epics/stories shape and not closed `
1346
- + `(flattened into tickets, id kept as its retired alias)`);
1347
- }
1348
- if (/^## (Executive Summary|Vision|Assumptions|Prerequisites)\s*$/m.test(read(".what", "_product-brief", "brief.md"))) items.push("brief.md in the 14-section shape");
1349
- // Sections by NAME: the numbers moved between kits (Non-Goals was §7 in one, §5 in the next).
1350
- if (anyIn(".what/_prd", "prd.md", /^## (\d+\.\s*)?(Document Purpose|Glossary|Non-Goals|Open Questions|Assumptions Index)\b|\*\*Proof of done:\*\*/m)) items.push("a prd.md in the 12-section shape, or with FR blocks");
1351
- const whatDir = path.join(target, ".what");
1352
- if (fs.existsSync(whatDir)) {
1353
- for (const pc of fs.readdirSync(whatDir)) {
1354
- if (pc.startsWith("_")) continue;
1355
- const srs = read(".what", pc, `SRS-${pc}.md`);
1356
- if (/^\|\s*UC-\d+\s*\|/m.test(srs)) { items.push("an SRS with a UC Catalogue table (now a pointer)"); break; }
1357
- }
1358
- }
1359
- const howDir = path.join(target, ".how");
1360
- if (fs.existsSync(howDir)) {
1361
- for (const pc of fs.readdirSync(howDir)) {
1362
- if (pc.startsWith("_")) continue;
1363
- if (/\|\s*Quoted rule\s*\||Quoted verbatim from/.test(read(".how", pc, `SDD-${pc}.md`))) { items.push("an SDD quoting AD-N text (now ids only)"); break; }
1364
- }
1365
- }
1366
- if (/\|\s*Container\s*\|\s*Product Components living in it\s*\|/.test(read(".how", "_platform", "c4-l2-containers.md"))) items.push("c4-l2 with a PC x container table (now a pointer)");
1367
- if (has(".control", "generated", "brief.md") || has(".control", "generated", "blueprint.md")) items.push("human pages still in .control/generated/ (render clears them)");
1368
- if (has(".what", "_product-brief", "brief.md") && !has(".what-rendered")) items.push("no .what-rendered/ yet (render creates it)");
1369
- // Skipped: what the validator never reads (kit copies, rendered output, dependencies) and what it
1370
- // treats as a record of the PAST — memlog, decisions, reports, _bmad-output. A stale path in a log
1371
- // is history, not a finding, and repointing it would falsify the record.
1372
- const SKIP = new Set([".git", "node_modules", "target", ".constitution", ".claude", ".agents", ".agent",
1373
- ".what-rendered", ".how-rendered", "dist", "build", "memlog", "decisions", "reports", "meetings", "_bmad-output", ".work"]);
1374
- const OLD_PAGE = /\.control\/generated\/(brief|blueprint|prd-[a-z0-9-]+)\.md/;
1375
- const citesOldPage = (dir, depth) => {
1376
- if (depth > 8) return false;
1377
- for (const e of fs.readdirSync(dir, { withFileTypes: true })) {
1378
- if (e.isDirectory()) { if (!SKIP.has(e.name) && citesOldPage(path.join(dir, e.name), depth + 1)) return true; continue; }
1379
- if (e.name === "answered.md") continue;
1380
- if (e.name.endsWith(".md") && OLD_PAGE.test(fs.readFileSync(path.join(dir, e.name), "utf8"))) return true;
1381
- }
1382
- return false;
1383
- };
1384
- if (citesOldPage(target, 0)) items.push("a document cites .control/generated/brief|blueprint|prd-*.md (pages moved to the rendered trees)");
1385
- return items;
1386
- }
1387
-
1388
- // Read BEFORE writeStamp overwrites it. Without this there is no version transition to print, and
1389
- // an "updated" with no from-to tells the reader nothing they can use.
1390
- function readStampVersion(target) {
1391
- const file = path.join(target, ".control", "wdi-method.yaml");
1392
- if (!fs.existsSync(file)) return "";
1393
- const m = fs.readFileSync(file, "utf8").match(/^wdi_method:\s*"?([^"\s]+)"?/m);
1394
- return m ? m[1] : "";
1395
- }
1396
-
1397
- function readIndexPolicy(target) {
1398
- const file = path.join(target, ".control", "registry", "index.yaml");
1399
- if (!fs.existsSync(file)) return { docLanguage: "", docFilenameLanguage: "" };
1400
- return readLanguagePolicy(fs.readFileSync(file, "utf8"));
1401
- }
1402
-
1403
- function readIndexIdentity(target) {
1404
- const file = path.join(target, ".control", "registry", "index.yaml");
1405
- if (!fs.existsSync(file)) return { name: "", client: "" };
1406
- return readProductIdentity(fs.readFileSync(file, "utf8"));
1407
- }
1408
-
1409
- function upsertAgentFiles(target, platforms, productName) {
1410
- const template = fs.readFileSync(path.join(OVERLAY, "AGENTS.md"), "utf8");
1411
- const agentsFile = path.join(target, "AGENTS.md");
1412
- let next;
1413
- if (!fs.existsSync(agentsFile)) {
1414
- next = fillProductTitle(template, productName || "{product}");
1415
- ok("AGENTS.md created — rewrite ## Code for this product");
1416
- } else {
1417
- next = upsertMethodBlock(fs.readFileSync(agentsFile, "utf8"), template);
1418
- note("AGENTS.md method block refreshed; product sections kept");
1419
- }
1420
- if (!next.endsWith("\n")) next += "\n";
1421
- fs.writeFileSync(agentsFile, next);
1422
-
1423
- const mirrors = [];
1424
- if (platformUsesHook(platforms, "cursorrules")) {
1425
- mirrors.push(path.join(target, ".cursorrules"));
1426
- }
1427
- if (platformUsesHook(platforms, "agents-mirror")) {
1428
- mirrors.push(path.join(target, ".agents", "AGENTS.md"));
1429
- }
1430
- for (const mirror of mirrors) {
1431
- fs.mkdirSync(path.dirname(mirror), { recursive: true });
1432
- if (fs.existsSync(mirror)) {
1433
- const patched = upsertMethodBlock(fs.readFileSync(mirror, "utf8"), template);
1434
- fs.writeFileSync(mirror, patched.endsWith("\n") ? patched : `${patched}\n`);
1435
- note(`method block refreshed in ${posixRel(target, mirror)}`);
1436
- } else {
1437
- fs.writeFileSync(mirror, next);
1438
- note(`created ${posixRel(target, mirror)}`);
1439
- }
1440
- }
1441
-
1442
- if (platformUsesHook(platforms, "claude-md")) {
1443
- const claude = path.join(target, "CLAUDE.md");
1444
- if (!fs.existsSync(claude)) {
1445
- fs.writeFileSync(claude, "@AGENTS.md\n");
1446
- note("CLAUDE.md created as @AGENTS.md");
1447
- }
1448
- }
1449
- }
1450
-
1451
- // What a run MUST leave a reader able to answer: which version replaced which, what was written, what
1452
- // was KEPT, and what to do next. The third is the one usually missing, and it is the one that decides
1453
- // whether somebody trusts running this over a repo they have already put work into.
1454
- function summaryLine(label, value) {
1455
- console.log(` ${DIM}${label.padEnd(11)}${RESET}${value}`);
1456
- }
1457
-
1458
- function printSummary(target, agents, { first, was, written, skipped, skills, tomls, opencodeCmds }) {
1459
- const now = PKG.version;
1460
- const version = first
1461
- ? `${now} — first install`
1462
- : was && was !== now
1463
- ? `${was} ${DIM}→${RESET} ${now}`
1464
- : `${now} ${DIM}(unchanged)${RESET}`;
1465
- const bmad = readBmadVersion(target);
1466
-
1467
- const kept = [];
1468
- if (skipped) kept.push(`${skipped} constitution file${skipped === 1 ? "" : "s"}`);
1469
- if (tomls.slugsKept) kept.push(`${tomls.slugsKept} initiative slug${tomls.slugsKept === 1 ? "" : "s"}`);
1470
- // On a first install the language was just CHOSEN, not kept — saying "kept" there reads as if the
1471
- // installer had found something it decided to leave alone, which is the opposite of what happened.
1472
- const policy = readIndexPolicy(target);
1473
- if (policy.docLanguage && !first) kept.push(`language (${policy.docLanguage})`);
1474
- if (fs.existsSync(path.join(target, ".constitution", "project"))) kept.push(".constitution/project/");
1475
-
1476
- console.log("");
1477
- console.log(`${DIM}────${RESET} WDI Method ${DIM}${"─".repeat(46)}${RESET}`);
1478
- summaryLine("version", version);
1479
- if (bmad) summaryLine("bmad", bmad);
1480
- summaryLine("target", target);
1481
- console.log("");
1482
- summaryLine("written", `${written} constitution · ${skills.files} skill files · ${tomls.files} bmad overrides`
1483
- + (opencodeCmds?.written ? ` · ${opencodeCmds.written} opencode commands` : ""));
1484
- if (kept.length) summaryLine("kept", kept.join(" · "));
1485
- const gone = [];
1486
- if (skills.removed) gone.push(`${skills.removed} retired wrapper${skills.removed === 1 ? "" : "s"}`);
1487
- if (tomls.removed) gone.push(`${tomls.removed} retired override${tomls.removed === 1 ? "" : "s"}`);
1488
- if (gone.length) summaryLine("removed", gone.join(" · "));
1489
- if (first && policy.docLanguage) {
1490
- summaryLine("language", `${policy.docLanguage} · filenames ${policy.docFilenameLanguage}`);
1491
- }
1492
- summaryLine("platforms", agents.join(", ") || "none");
1493
- console.log("");
1494
- // The readers are the one seeded file that does nothing until somebody writes it, and its
1495
- // silence is expensive: inventory.py refuses to run and the reason is a folder deep. One line
1496
- // here, only while it is still the skeleton, so it stops appearing once it is done.
1497
- if (readersAreSkeleton(target)) {
1498
- summaryLine("todo", `${DIM}.constitution/project/inventory-readers.py${RESET} is a skeleton — ` +
1499
- `run the ${INIT_SKILL} skill, intent ${DIM}readers${RESET}, ` +
1500
- `to write it for this repo's stack`);
1501
- }
1502
- const engReport = enginesReport(target);
1503
- summaryLine("engines", engReport.present
1504
- ? `${ENGINE_SKILLS.join(" · ")} — found (in this repo)`
1505
- : `NOT found: ${engReport.missing.join(" · ")}. G5 (wdi-build) and the Fast Path need them; G1–G4 run without them`);
1506
- if (!engReport.present) {
1507
- summaryLine("", `${DIM}·${RESET} into THIS repo: ${DIM}${ENGINES_INSTALL_ANY}${RESET} — a user-level plugin does not count`);
1508
- summaryLine("", `${DIM}·${RESET} docs/agents/ is already seeded, so ${DIM}${ENGINES_SETUP}${RESET} is not needed · ${ENGINES_REPO}`);
1509
- } else {
1510
- const blocked = engineInvocationState(target).blocked;
1511
- if (blocked.length) {
1512
- summaryLine("", `${DIM}·${RESET} still flagged, so no skill can invoke ${blocked.join(" · ")} — run ${DIM}npx wdi-method engines --fix${RESET}`);
1513
- }
1514
- }
1515
- // Upstream's own warning: "installing both leaves you with every skill twice." It is survivable —
1516
- // the plugin's copies are namespaced and still flagged, so they can neither be invoked nor shadow
1517
- // the repo's — but `/to-spec` in the UI stops being one thing, so it is said out loud.
1518
- if (pluginEnginesRegistered()) {
1519
- summaryLine("", `${DIM}·${RESET} the ${ENGINES_PLUGIN} plugin is ALSO installed for this user — the repo's copies are what run;`);
1520
- summaryLine("", `${DIM}·${RESET} remove the plugin to keep ${DIM}/to-spec${RESET} unambiguous`);
1521
- }
1522
- const pending = first ? [] : pendingUpgrades(target);
1523
- if (pending.length) {
1524
- summaryLine("upgrade", `${pending.length} item${pending.length === 1 ? "" : "s"} still in the OLD shape — ` +
1525
- `run the ${DIM}wdi-upgrade${RESET} skill; it moves content, never invents it`);
1526
- for (const item of pending) summaryLine("", `${DIM}·${RESET} ${item}`);
1527
- }
1528
- summaryLine("next", pending.length
1529
- ? `run the ${DIM}wdi-upgrade${RESET} skill first, then ${HELP_SKILL}`
1530
- : `invoke the ${HELP_SKILL} skill and ask what to do`);
1531
- summaryLine("", REPO_URL);
1532
- console.log(`${DIM}${"─".repeat(62)}${RESET}`);
1533
- }
1534
-
1535
- function printNextSteps({ first, productSet, upgradePending }) {
1536
- console.log("");
1537
- console.log(first ? "After install:" : "After update:");
1538
- if (first) {
1539
- if (!productSet) {
1540
- console.log(" 1. Fill product.name (and product.client if there is one) in .control/registry/index.yaml.");
1541
- } else {
1542
- console.log(" 1. product.name is set. G1 confirms it in the brief.");
1543
- }
1544
- console.log(" 2. Rewrite .constitution/constitution.md Articles 2 and 5 for this product.");
1545
- console.log(" Article 1 cites index.yaml — do not become a second source for the name.");
1546
- console.log(" 3. Write ## Code in AGENTS.md (where the app lives). Leave the BEGIN:wdi-method block alone.");
1547
- console.log(" 4. Run the wdi-init skill, intent setup.");
1548
- console.log(" 5. Sort the documents you already have. Do not move any of them in this step.");
1549
- console.log("");
1550
- console.log("Next update:");
1551
- console.log(" npx wdi-method");
1552
- console.log(" (the TUI offers the update) or: npx wdi-method update --yes");
1553
- } else {
1554
- console.log(" 1. The <!-- BEGIN:wdi-method --> block in AGENTS.md was replaced. Read the diff.");
1555
- console.log(" 2. constitution.md Articles 1-2-5, ## Code, and *.user.toml were not overwritten.");
1556
- console.log(" 3. If BMad has new skills, install those first, then run this update again.");
1557
- if (upgradePending) {
1558
- console.log(" 4. The summary listed an `upgrade` line: run the wdi-upgrade skill before any other skill.");
1559
- console.log(" It moves content into the new shape and never invents any; one commit.");
1560
- }
1561
- }
1562
- }
1563
-
1564
- function apply(target, agents,
1565
- { first, product, client, docLanguage, docFilenameLanguage, languageChosen }) {
1566
- requireKit();
1567
- const was = readStampVersion(target);
1568
- // MUST run before the kit is written: it moves the product's files out of the way of paths the kit
1569
- // is about to occupy. Running it after would leave two copies of most guides.
1570
- const migrated = migrateToTwoFolders(target);
1571
- migrateRegistryNames(target);
1572
- migrateAutopilotLedgers(target);
1573
- warnStaleMandates(target);
1574
- seedAgentDocs(target);
1575
- warnStaleAgentDocs(target);
1576
- seedRequirementSplit(target);
1577
- // The split MUST also be reachable without a migration. 0.5.2 only ran it from inside
1578
- // migrateToTwoFolders, which returns early when the old layout is absent — so a repo that took
1579
- // 0.5.0 or 0.5.1, whose project/constitution.md was moved WHOLE and never split, could never be
1580
- // fixed by any later update. That is precisely the repo that needs it. Running it here on every
1581
- // update closes that, and it is idempotent: after a split there are no method articles left to cut.
1582
- const lateSplit = splitProductConstitution(path.join(target, ".constitution", "project",
1583
- "constitution.md"));
1584
- if (!migrated && lateSplit && lateSplit.cut.length) {
1585
- note(`project/constitution.md still carried Articles ${lateSplit.cut.join(", ")} — removed`);
1586
- note(` they are the method's and live in method/constitution.md; kept ${lateSplit.kept.join(", ")}`);
1587
- if (lateSplit.relinked) note(` repointed ${lateSplit.relinked} relative links`);
1588
- }
1589
- const splitConstitution = migrated;
1590
- const { written, skipped } = syncConstitution(target);
1591
- note(`constitution wrote ${written}, kept ${skipped}`);
1592
- // A migrated repo also carries derived output stamped against the OLD layout: .control/generated/*
1593
- // still names the pre-0.5.0 script path, and the two structure maps still draw the old tree. The
1594
- // installer MUST NOT write either — one is generated, the other is re-derived by a skill — so it
1595
- // says so instead of leaving them to be found by whoever trusts them next.
1596
- if (splitConstitution) {
1597
- note(" derived output still describes the OLD layout, and neither is mine to write:");
1598
- note(" uv run .constitution/method/scripts/validate.py --generate → .control/generated/");
1599
- note(" then the wdi-init skill, intent `structure` → the two structure maps");
1600
- }
1601
- const skills = syncSkills(target, agents);
1602
- note(`skills ${skills.files} files`);
1603
- let opencodeCmds = { written: 0, removed: 0 };
1604
- if (platformUsesHook(agents, "opencode-commands")) {
1605
- opencodeCmds = syncOpencodeCommands(target, WDI_SKILLS, path.join(KIT, "skills"));
1606
- note(`opencode commands ${opencodeCmds.written} files → ${opencodeCommandsDir()}/`);
1607
- if (opencodeCmds.removed) {
1608
- note(`removed ${opencodeCmds.removed} retired opencode command${opencodeCmds.removed === 1 ? "" : "s"}`);
1609
- }
1610
- }
1611
- const tomls = syncTomls(target);
1612
- tomls.removed = pruneRetiredTomls(target);
1613
- note(`bmad custom ${tomls.files} toml → _bmad/custom/`);
1614
- if (first) seedControlIfMissing(target);
1615
- seedEmptyLayers(target, { first });
1616
- setProductIdentity(target, { name: product, client });
1617
- setLanguagePolicy(target, { docLanguage, docFilenameLanguage, chosen: languageChosen });
1618
- upsertAgentFiles(target, agents, product);
1619
- // Mechanical, idempotent, and re-run on EVERY update because both sides are restored behind our
1620
- // back: `npx skills update` puts the author's flag back, and BMad's installer rewrites its own
1621
- // wrappers. A one-time fix would hold for about a week.
1622
- enableEngineInvocation(target);
1623
- retireBmadG5(target);
1624
- writeDenyRules(target);
1625
- writeStamp(target);
1626
- printSummary(target, agents, { first, was, written, skipped, skills, tomls, opencodeCmds });
1627
- printNextSteps({
1628
- first,
1629
- productSet: Boolean(product) && !identityIsPlaceholder(product),
1630
- upgradePending: !first && pendingUpgrades(target).length > 0,
1631
- });
1632
- }
1633
-
1634
- function enginesCommand(target, { fix }) {
1635
- const before = enginesReport(target);
1636
- console.log("");
1637
- console.log(` engines ${before.present ? "all present" : `MISSING ${before.missing.join(" · ")}`}`);
1638
- for (const name of ENGINE_SKILLS) {
1639
- const copies = before.files.get(name);
1640
- if (!copies) continue;
1641
- const flagged = [...copies.keys()].some((f) =>
1642
- /^disable-model-invocation\s*:\s*true/m.test(fs.readFileSync(f, "utf8")));
1643
- const where = [...copies.values()].map((f) => posixRel(target, f)).join(", ");
1644
- console.log(` ${name.padEnd(17)}${flagged ? "flagged — no skill can invoke it" : "invocable"} ${DIM}${where}${RESET}`);
1645
- }
1646
- const banned = repoSkillFiles(target, BMAD_RETIRED_G5);
1647
- const open = [];
1648
- for (const [name, copies] of banned) {
1649
- const shut = [...copies.keys()].every((f) =>
1650
- /^disable-model-invocation\s*:\s*true/m.test(fs.readFileSync(f, "utf8")));
1651
- if (!shut) open.push(name);
1652
- }
1653
- console.log(` bmad G5 ${banned.size} installed, ${open.length ? `STILL model-invocable: ${open.join(" · ")}` : "all retired"}`);
1654
- const tracker = path.join(target, "docs", "agents", "issue-tracker.md");
1655
- const trackerOwn = fs.existsSync(tracker)
1656
- && fs.readFileSync(tracker, "utf8").includes("seeded by `wdi-method`");
1657
- console.log(` config docs/agents/issue-tracker.md ${trackerOwn ? "is the method's" : "is NOT the method's — upstream's answer sends the engines to the wrong place"}`);
1658
- console.log("");
1659
-
1660
- if (!fix) {
1661
- if (!before.present || open.length || !trackerOwn
1662
- || engineInvocationState(target).blocked.length) {
1663
- console.log(` ${DIM}to repair what can be repaired:${RESET} npx wdi-method engines --fix`);
1664
- console.log("");
1665
- }
1666
- return;
1667
- }
1668
- repairAgentDocs(target);
1669
- enableEngineInvocation(target);
1670
- retireBmadG5(target);
1671
- writeDenyRules(target);
1672
- writeStamp(target);
1673
- ok("engines aligned");
1674
- if (!before.present) {
1675
- console.log("");
1676
- console.log(enginesMissingMessage(before.missing));
1677
- }
1678
- }
1679
-
1680
- function verify(target, agents) {
1681
- requireKit();
1682
- const missing = [];
1683
- const kitConst = path.join(KIT, ".constitution");
1684
- for (const file of walkFiles(kitConst)) {
1685
- const rel = posixRel(kitConst, file);
1686
- const dest = path.join(target, ".constitution", rel);
1687
- if (!fs.existsSync(dest)) missing.push(`.constitution/${rel}`);
1688
- }
1689
- for (const name of WDI_SKILLS) {
1690
- for (const root of skillDestinations(target, agents)) {
1691
- const dest = path.join(root, name, "SKILL.md");
1692
- if (!fs.existsSync(dest)) missing.push(posixRel(target, dest));
1693
- }
1694
- }
1695
- if (platformUsesHook(agents, "opencode-commands")) {
1696
- for (const name of WDI_SKILLS) {
1697
- const dest = path.join(target, opencodeCommandsDir(), `${name}.md`);
1698
- if (!fs.existsSync(dest)) missing.push(posixRel(target, dest));
1699
- }
1700
- }
1701
- const custom = path.join(KIT, "assets", "bmad-custom");
1702
- for (const file of walkFiles(custom)) {
1703
- if (!file.endsWith(".toml")) continue;
1704
- const dest = path.join(target, "_bmad", "custom", path.basename(file));
1705
- if (!fs.existsSync(dest)) missing.push(`_bmad/custom/${path.basename(file)}`);
1706
- }
1707
- if (fs.existsSync(path.join(target, ".control"))) {
1708
- for (const file of walkFiles(SCAFFOLD)) {
1709
- const rel = posixRel(SCAFFOLD, file);
1710
- const dest = path.join(target, ".control", rel);
1711
- if (!fs.existsSync(dest)) missing.push(`.control/${rel}`);
1712
- }
1713
- } else {
1714
- missing.push(".control/ (folder missing — first install should have seeded it)");
1715
- }
1716
- // `.constitution/constitution.md` was the pre-0.5.0 path. Demanding it here made `verify` report a
1717
- // file MISSING that the split deliberately removed — a check telling the truth about the wrong world.
1718
- for (const required of ["AGENTS.md", path.join(".constitution", "project", "constitution.md")]) {
1719
- if (!fs.existsSync(path.join(target, required))) missing.push(required.replaceAll(path.sep, "/"));
1720
- }
1721
- if (missing.length) {
1722
- console.error(`${RED}missing ${missing.length}${RESET}`);
1723
- for (const m of missing) console.error(` ${m}`);
1724
- process.exit(1);
1725
- }
1726
- ok(`method files present in ${target}`);
1727
-
1728
- // Present-and-correct is not the same as consistent. These three are states `update` cannot fix on
1729
- // its own — it MUST NOT write over the room, and it cannot know what a product meant — so `verify`
1730
- // is where they get said out loud instead of waiting to be tripped over.
1731
- const judgement = [];
1732
- const room = path.join(target, ".constitution", "project", "constitution.md");
1733
- if (fs.existsSync(room)) {
1734
- const carried = [...fs.readFileSync(room, "utf8").matchAll(/^## Article (\d+)\b/gm)]
1735
- .map((m) => Number(m[1])).filter((n) => METHOD_ARTICLES.includes(n));
1736
- if (carried.length) {
1737
- judgement.push(`project/constitution.md still carries Articles ${carried.join(", ")} — the `
1738
- + "method's. They are duplicated in method/constitution.md and will drift. Run update again.");
1739
- }
1740
- }
1741
- const constRoot = path.join(target, ".constitution");
1742
- const loose = fs.existsSync(constRoot)
1743
- ? fs.readdirSync(constRoot, { withFileTypes: true })
1744
- .filter((e) => e.isFile() && e.name.endsWith(".md")).map((e) => e.name)
1745
- : [];
1746
- if (loose.length) {
1747
- judgement.push(`loose at .constitution/ root: ${loose.join(", ")} — .constitution/ holds two `
1748
- + "folders and nothing else the method knows about. Move it into project/, or name it from "
1749
- + "Article 2 so the next reader knows why it is there. repo-guide.md states the rule.");
1750
- }
1751
- if (judgement.length) {
1752
- console.log("");
1753
- for (const j of judgement) note(j);
1754
- }
1755
- note("extra product files are expected and were not checked");
1756
- }
1757
-
1758
- function scrubPrdToml(file) {
1759
- const raw = fs.readFileSync(file, "utf8");
1760
- const m = raw.match(/run_folder_pattern\s*=\s*"([^"]+)"/);
1761
- if (!m) return;
1762
- const slug = m[1];
1763
- if (GENERIC_FOLDER_PATTERNS.has(slug)) return;
1764
- fs.writeFileSync(file, raw.split(slug).join(PRD_SLUG_PLACEHOLDER), "utf8");
1765
- note("bmad-prd.toml initiative slug scrubbed to placeholder");
1766
- }
1767
-
1768
- function promote(live) {
1769
- live = path.resolve(live);
1770
- if (!fs.existsSync(path.join(live, ".constitution"))) {
1771
- die(`${live} has no .constitution/ — is this a method-carrying repo?`);
1772
- }
1773
- // EVERY file in the room is authored in the package and MUST survive the rmSync below — the room's
1774
- // README, the generic Articles 1-2-5, and the three empty codebase templates. Read here, not
1775
- // after: the first version of this preserved only README.md and read it AFTER the kit was deleted,
1776
- // so it was always null and the file vanished on every promote. Two tests cover it now.
1777
- const roomKit = path.join(KIT, ".constitution", PROJECT_ROOM);
1778
- const roomKept = fs.existsSync(roomKit)
1779
- ? Object.fromEntries(walkFiles(roomKit).map((f) => [posixRel(roomKit, f), fs.readFileSync(f, "utf8")]))
1780
- : {};
1781
-
1782
- fs.rmSync(KIT, { recursive: true, force: true });
1783
- fs.mkdirSync(KIT, { recursive: true });
1784
-
1785
- // ONE skip, because 0.5.0 put everything the product owns in one folder. It covers the codebase
1786
- // guides too, which used to need a rule of their own: promoting a filled-in stack guide would leak
1787
- // one product's conventions — possibly written in its own `doc_language` — into a public package.
1788
- const nConst = copyTree(path.join(live, ".constitution"), path.join(KIT, ".constitution"),
1789
- (rel) => rel.startsWith(PROJECT_ROOM));
1790
- note(`constitution ${nConst} files (${PROJECT_ROOM} skipped — it is the product's)`);
1791
- for (const [rel, text] of Object.entries(roomKept)) {
1792
- const dest = path.join(roomKit, rel);
1793
- fs.mkdirSync(path.dirname(dest), { recursive: true });
1794
- fs.writeFileSync(dest, text, "utf8");
1795
- }
1796
- if (Object.keys(roomKept).length) {
1797
- note(`${PROJECT_ROOM} restored from the package (${Object.keys(roomKept).length} files) — `
1798
- + "promote never carries the room home");
1799
- }
1800
-
1801
- let copiedSkills = 0;
1802
- const skillsSrc = path.join(live, ".claude", "skills");
1803
- for (const name of WDI_SKILLS) {
1804
- const src = path.join(skillsSrc, name);
1805
- if (!fs.existsSync(src)) die(`skill missing in live repo: ${src}`);
1806
- copiedSkills += copyTree(src, path.join(KIT, "skills", name));
1807
- }
1808
- note(`skills ${copiedSkills} files (${WDI_SKILLS.length} wrappers)`);
1809
-
1810
- const customSrc = path.join(live, "_bmad", "custom");
1811
- const customDst = path.join(KIT, "assets", "bmad-custom");
1812
- fs.mkdirSync(customDst, { recursive: true });
1813
- let tomls = 0;
1814
- if (fs.existsSync(customSrc)) {
1815
- for (const file of walkFiles(customSrc)) {
1816
- if (!file.endsWith(".toml") || file.endsWith(".user.toml")) continue;
1817
- copyFile(file, path.join(customDst, path.basename(file)));
1818
- tomls += 1;
1819
- }
1820
- }
1821
- const prd = path.join(customDst, "bmad-prd.toml");
1822
- if (fs.existsSync(prd)) scrubPrdToml(prd);
1823
- note(`bmad custom ${tomls} toml`);
1824
-
1825
- const replacements = {
1826
- "constitution.md": path.join(KIT, ".constitution", "method", "constitution.md"),
1827
- "portability.md": path.join(KIT, ".constitution", "method", "why", "portability.md"),
1828
- "repo-guide.md": path.join(KIT, ".constitution", "method", "repo-guide.md"),
1829
- "README.md": path.join(KIT, ".constitution", "method", "README.md"),
1830
- };
1831
- for (const [name, dest] of Object.entries(replacements)) {
1832
- const src = path.join(OVERLAY, name);
1833
- if (fs.existsSync(src)) {
1834
- copyFile(src, dest);
1835
- note(`${name} replaced with kit overlay`);
1836
- }
1837
- }
1838
-
1839
- const source = [
1840
- `date: ${today()}`,
1841
- `commit: ${gitHead(live)}`,
1842
- "kind: working copy that currently carries a newer method",
1843
- "note: the repo path and product name MUST NOT be recorded here",
1844
- "",
1845
- ].join("\n");
1846
- fs.writeFileSync(path.join(ROOT, "SOURCE"), source, "utf8");
1847
- ok(`SOURCE stamped ${today()} @ ${gitHead(live)}`);
1848
- ok(`promoted into ${KIT}`);
1849
- }
1850
-
1851
- function cancelIf(value) {
1852
- if (p.isCancel(value)) {
1853
- p.cancel("Cancelled.");
1854
- process.exit(0);
1855
- }
1856
- return value;
1857
- }
1858
-
1859
- async function runWizard(pre) {
1860
- p.intro(`WDI Method ${PKG.version}`);
1861
-
1862
- const dirValue = cancelIf(
1863
- await p.text({
1864
- message: "Target repo (the product folder)",
1865
- placeholder: process.cwd(),
1866
- defaultValue: pre.dir || process.cwd(),
1867
- }),
1868
- );
1869
- const target = path.resolve(String(dirValue).trim() || process.cwd());
1870
-
1871
- if (!fs.existsSync(target)) {
1872
- const create = cancelIf(
1873
- await p.confirm({ message: `${target} does not exist. Create it?`, initialValue: true }),
1874
- );
1875
- if (!create) {
1876
- p.cancel("No target folder.");
1877
- process.exit(1);
1878
- }
1879
- fs.mkdirSync(target, { recursive: true });
1880
- }
1881
-
1882
- const hasBmad = bmadPresent(target);
1883
- const hasWdi = wdiPresent(target);
1884
- const nonempty = dirNonEmpty(target);
1885
-
1886
- const facts = [
1887
- hasBmad
1888
- ? `BMad Method: installed${readBmadVersion(target) ? ` (${readBmadVersion(target)})` : ""}`
1889
- : "BMad Method: not installed",
1890
- hasWdi ? "WDI Method: already present — the installer will offer an update" : "WDI Method: not present",
1891
- enginesPresent(target)
1892
- ? `Engines (mattpocock/skills, in this repo): all ${ENGINE_SKILLS.length} present`
1893
- : `Engines: MISSING ${enginesReport(target).missing.join(" · ")} — ${ENGINES_INSTALL_ANY} (${ENGINES_REPO})`,
1894
- engineInvocationState(target).blocked.length
1895
- ? `Engine invocation: BLOCKED for ${engineInvocationState(target).blocked.join(" · ")} — npx wdi-method engines --fix`
1896
- : "Engine invocation: enabled (the author's disable-model-invocation is stripped from the repo's copies)",
1897
- nonempty ? "Folder is not empty (normal for a product repo already under way)" : "Folder is empty",
1898
- ].join("\n");
1899
- p.note(facts, "Detected");
1900
-
1901
- if (!hasBmad && !pre.skipBmad) {
1902
- p.note(bmadMissingMessage(), "BMad first");
1903
- p.outro("Install BMad, then run this again: npx wdi-method");
1904
- process.exit(1);
1905
- }
1906
-
1907
- // Step 2, refused in step 2's place. This used to be a line in the Detected note and nothing more,
1908
- // so an interactive install or update sailed past a repo with no engines in it — the same repo the
1909
- // `--yes` path refuses. The order matters as much as the stop: BMad is step 1, so a repo missing
1910
- // both is told about BMad first rather than sent to install the second thing.
1911
- const engineGate = enginesReport(target);
1912
- if (!engineGate.present && !pre.skipEngines) {
1913
- p.note(enginesMissingMessage(engineGate.missing), "Engines next");
1914
- p.outro("Install them into this repo, then run this again: npx wdi-method");
1915
- process.exit(1);
1916
- }
1917
-
1918
- let first = !hasWdi;
1919
- if (hasWdi) {
1920
- const update = cancelIf(
1921
- await p.confirm({
1922
- message: "WDI Method is already installed. Update it now?",
1923
- initialValue: true,
1924
- }),
1925
- );
1926
- first = !update;
1927
- if (first) {
1928
- p.cancel("Update declined.");
1929
- process.exit(0);
1930
- }
1931
- } else {
1932
- const go = cancelIf(
1933
- await p.confirm({
1934
- message: `Install WDI Method into ${target}?`,
1935
- initialValue: true,
1936
- }),
1937
- );
1938
- if (!go) {
1939
- p.cancel("Install declined.");
1940
- process.exit(0);
1941
- }
1942
- }
1943
-
1944
- // Every field arrives with an answer already in it, and Enter accepts it. On an update that answer is
1945
- // what the repo already says; on a first install it is the folder name made readable. Nothing here is
1946
- // validated as required: a prompt that refuses an empty submission when it already holds a sensible
1947
- // default is asking the owner to retype something the installer knows.
1948
- const existing = readIndexIdentity(target);
1949
- const suggestedName = identityIsPlaceholder(existing.name)
1950
- ? humaniseFolderName(path.basename(target))
1951
- : existing.name;
1952
- const product = cancelIf(
1953
- await p.text({
1954
- message: "Product name (one room: index.yaml product.name)",
1955
- placeholder: suggestedName,
1956
- defaultValue: suggestedName,
1957
- }),
1958
- ).trim() || suggestedName;
1959
- const client = cancelIf(
1960
- await p.text({
1961
- message: "Client name (Enter to leave it as it is)",
1962
- placeholder: existing.client || "(none)",
1963
- defaultValue: existing.client || "",
1964
- }),
1965
- ).trim();
1966
-
1967
- // Two questions, and only two. Method terminology, document code prefixes, machine-facing
1968
- // markers, and code identifiers are always English — MUST NOT be asked about.
1969
- const policy = readIndexPolicy(target);
1970
- // Free text, not a list. Write whatever a model understands — "English", "Bahasa Indonesia",
1971
- // "id". The only value refused is empty.
1972
- const askLanguage = async (message, current) =>
1973
- (cancelIf(
1974
- await p.text({
1975
- message,
1976
- placeholder: current || DEFAULT_DOC_LANGUAGE,
1977
- defaultValue: current || DEFAULT_DOC_LANGUAGE,
1978
- }),
1979
- ) || DEFAULT_DOC_LANGUAGE).trim();
1980
- const docLanguage = await askLanguage(
1981
- "Language of working-document prose (.what/ .how/ .control/) — free text",
1982
- policy.docLanguage || pre.docLanguage);
1983
- const docFilenameLanguage = await askLanguage(
1984
- "Language of document filename slugs — the `UC-` `DEC-` codes stay English",
1985
- policy.docFilenameLanguage || pre.docFilenameLanguage || docLanguage);
1986
-
1987
- const detected = pre.agents
1988
- ? normalizePlatformIds(pre.agents)
1989
- : detectPlatforms(target, fs);
1990
- const selected = cancelIf(
1991
- await p.autocompleteMultiselect({
1992
- message: "Which tools get the wdi-* skills? (⭐ = recommended)",
1993
- options: platformSelectOptions(detected),
1994
- initialValues: detected,
1995
- required: true,
1996
- maxItems: 8,
1997
- placeholder: "Type to search…",
1998
- }),
1999
- );
2000
-
2001
- p.note(
2002
- [
2003
- "The corpus folder names are fixed — they are not an install option:",
2004
- " .constitution .control .what .how .work _bmad-output",
2005
- "",
2006
- "What gets written for the platforms you picked:",
2007
- " AGENTS.md (the BEGIN:wdi-method block — always)",
2008
- platformUsesHook(selected, "claude-md") ? " CLAUDE.md → @AGENTS.md" : "",
2009
- platformUsesHook(selected, "cursorrules") ? " .cursorrules (method block mirror)" : "",
2010
- platformUsesHook(selected, "agents-mirror") ? " .agents/AGENTS.md (method block mirror)" : "",
2011
- platformUsesHook(selected, "opencode-commands")
2012
- ? ` ${opencodeCommandsDir()}/wdi-*.md (slash commands → skills)`
2013
- : "",
2014
- ` wdi-* skills → ${skillDestinations(target, selected).map((d) => posixRel(target, d)).join(", ") || "(none)"}`,
2015
- ]
2016
- .filter(Boolean)
2017
- .join("\n"),
2018
- "Write targets",
2019
- );
2020
-
2021
- const okGo = cancelIf(await p.confirm({ message: first ? "Run the install?" : "Run the update?", initialValue: true }));
2022
- if (!okGo) {
2023
- p.cancel("Dibatalkan.");
2024
- process.exit(0);
2025
- }
2026
-
2027
- const spinner = p.spinner();
2028
- spinner.start(first ? "Memasang…" : "Meng-update…");
2029
- apply(target, selected, {
2030
- docLanguage,
2031
- docFilenameLanguage,
2032
- languageChosen: true,
2033
- first,
2034
- product: String(product).trim(),
2035
- client: String(client).trim(),
2036
- });
2037
- spinner.stop(first ? "Terpasang" : "Ter-update");
2038
- p.outro(first ? "Done. Take the after-install steps above." : "Done. Read the method-block diff in AGENTS.md.");
2039
- }
2040
-
2041
- function runNonInteractive(args) {
2042
- const target = requireTarget(args.dir);
2043
- const agents = args.agents || detectPlatforms(target, fs) || PREFERRED_PLATFORM_IDS.slice();
2044
- if (args.cmd === "verify") {
2045
- verify(target, agents);
2046
- return;
2047
- }
2048
- if (!args.skipBmad && !bmadPresent(target)) {
2049
- die(bmadMissingMessage());
2050
- }
2051
- if (!args.skipEngines && !enginesPresent(target)) {
2052
- die(enginesMissingMessage(enginesReport(target).missing));
2053
- }
2054
- const existing = readIndexIdentity(target);
2055
- const product = args.product || existing.name;
2056
- const client = args.client ?? existing.client;
2057
- const first = args.cmd === "install" || (args.cmd === "wizard" && !wdiPresent(target));
2058
- apply(target, agents, {
2059
- first: args.cmd === "update" ? false : first,
2060
- product,
2061
- client,
2062
- docLanguage: args.docLanguage,
2063
- docFilenameLanguage: args.docFilenameLanguage,
2064
- languageChosen: Boolean(args.docLanguage || args.docFilenameLanguage),
2065
- });
2066
- }
2067
-
2068
- async function main() {
2069
- const args = parseArgs(process.argv);
2070
- if (!["wizard", "install", "update", "verify", "promote", "engines"].includes(args.cmd)) {
2071
- usage();
2072
- process.exit(2);
2073
- }
2074
- if (args.cmd === "promote") {
2075
- if (!args.dir) die("promote needs a path to the working copy");
2076
- // `promote` used to BE the workflow: author a rule in a product repo, run it, carry it here.
2077
- // It is now a rescue tool, and the flag is what makes that structural rather than a paragraph
2078
- // nobody rereads. Running it by habit overwrites the whole kit with one consumer's copy —
2079
- // silently reverting every change made here since that repo last updated.
2080
- if (!args.rescue) {
2081
- die([
2082
- "promote overwrites the whole kit from a consumer's copy, and this package is now where a",
2083
- " method change is authored — see CONTRIBUTING.md. If a change really was made in a",
2084
- " product repo by mistake and needs rescuing, say so:",
2085
- "",
2086
- " npx wdi-method promote <dir> --rescue",
2087
- ].join("\n"));
2088
- }
2089
- note("--rescue: pulling the method back out of a consumer. Read the diff before committing.");
2090
- promote(args.dir);
2091
- return;
2092
- }
2093
- if (args.cmd === "engines") {
2094
- enginesCommand(requireTarget(args.dir), { fix: Boolean(args.fix) });
2095
- return;
2096
- }
2097
- const wantTui = !args.yes && args.cmd !== "verify" && process.stdin.isTTY && process.stdout.isTTY;
2098
- if (wantTui) {
2099
- await runWizard(args);
2100
- return;
2101
- }
2102
- if (args.cmd === "wizard" && !args.yes) {
2103
- die("not a TTY. Use `install --yes` / `update --yes`, or run this in a terminal.");
2104
- }
2105
- if (args.cmd === "wizard") args.cmd = wdiPresent(requireTarget(args.dir)) ? "update" : "install";
2106
- runNonInteractive(args);
2107
- }
2108
-
2109
- main().catch((err) => {
2110
- console.error(err);
2111
- process.exit(1);
2112
- });
1
+ #!/usr/bin/env node
2
+ import fs from "node:fs";
3
+ import os from "node:os";
4
+ import path from "node:path";
5
+ import { spawnSync } from "node:child_process";
6
+ import { createHash } from "node:crypto";
7
+ import { fileURLToPath } from "node:url";
8
+ import * as p from "@clack/prompts";
9
+ import {
10
+ fillProductTitle,
11
+ upsertMethodBlock,
12
+ } from "../lib/agents-block.mjs";
13
+ import {
14
+ identityIsPlaceholder,
15
+ humaniseFolderName,
16
+ readLanguagePolicy,
17
+ writeLanguagePolicy,
18
+ DEFAULT_DOC_LANGUAGE,
19
+ readProductIdentity,
20
+ writeProductIdentity,
21
+ } from "../lib/identity.mjs";
22
+ import {
23
+ detectPlatforms,
24
+ formatPlatformList,
25
+ isKnownPlatform,
26
+ normalizePlatformIds,
27
+ platformSelectOptions,
28
+ platformUsesHook,
29
+ PREFERRED_PLATFORM_IDS,
30
+ skillDestinations,
31
+ } from "../lib/platforms.mjs";
32
+ import { opencodeCommandsDir, syncOpencodeCommands } from "../lib/opencode-commands.mjs";
33
+
34
+ const ROOT = path.resolve(path.dirname(fileURLToPath(import.meta.url)), "..");
35
+ const KIT = path.join(ROOT, "kit");
36
+ const OVERLAY = path.join(ROOT, "kit-overlay");
37
+ const SCAFFOLD = path.join(ROOT, "scaffold", ".control");
38
+ const PKG = JSON.parse(fs.readFileSync(path.join(ROOT, "package.json"), "utf8"));
39
+
40
+ const WDI_SKILLS = [
41
+ "wdi-init",
42
+ "wdi-problem",
43
+ "wdi-product",
44
+ "wdi-ux",
45
+ "wdi-blueprint",
46
+ "wdi-component",
47
+ "wdi-build",
48
+ "wdi-decision",
49
+ "wdi-question",
50
+ "wdi-log",
51
+ "wdi-help",
52
+ "wdi-explain-to-me",
53
+ "wdi-autopilot",
54
+ "wdi-reconcile",
55
+ "wdi-review",
56
+ "wdi-report",
57
+ "wdi-systematic-debugging",
58
+ "wdi-upgrade",
59
+ "wdi-prune-or-archive",
60
+ "wdi-daily-what-to-build",
61
+ "wdi-daily-autopilot",
62
+ "wdi-daily-what-to-test",
63
+ ];
64
+
65
+ const PRD_SLUG_PLACEHOLDER = "FILL-initiative-slug";
66
+ const GENERIC_FOLDER_PATTERNS = new Set([
67
+ "_product-brief",
68
+ "ux",
69
+ "architecture",
70
+ PRD_SLUG_PLACEHOLDER,
71
+ ]);
72
+
73
+ const BMAD_INSTALL = `npx bmad-method install`;
74
+ // The engines G5 runs. BMad writes the documents; these cut the work.
75
+ //
76
+ // They are installed IN THE REPO, and a user-level plugin no longer counts. Three reasons, and the
77
+ // third is what forced it: a method whose G5 depends on what the operator happened to install on
78
+ // their laptop behaves differently per machine; `.control/wdi-method.yaml` cannot record a version
79
+ // it does not own; and `to-spec`, `to-tickets` and `implement` ship with
80
+ // `disable-model-invocation: true`, which nothing outside the file can lift — the gate reads the
81
+ // frontmatter and consults no setting, and `skillOverrides` only ever tightens. Owning the file is
82
+ // the only route to an engine a skill can invoke, so owning the file is now the requirement.
83
+ // Upstream ships that route deliberately: the plugin is "subscribe rather than fork", `skills.sh`
84
+ // "copies editable skill files into your project, so you can hack on them and make them your own".
85
+ const ENGINES_REPO = "https://github.com/mattpocock/skills";
86
+ const ENGINES_PLUGIN = "mattpocock-skills";
87
+ const ENGINES_INSTALL = `/plugin install ${ENGINES_PLUGIN}`;
88
+ const ENGINES_INSTALL_ANY = "npx skills@latest add mattpocock/skills";
89
+ const ENGINES_SETUP = "/setup-matt-pocock-skills";
90
+ // Six, not five. `domain-modeling` is G3's — `wdi-blueprint` invokes it — and it used to be reached
91
+ // by its plugin-namespaced name. With the plugin no longer required that name resolves to nothing,
92
+ // so the skill joins the local install and every reference to it dropped the prefix.
93
+ const ENGINE_SKILLS = ["to-spec", "to-tickets", "implement", "tdd", "code-review", "domain-modeling"];
94
+ // The three that arrive flagged. `tdd`, `code-review` and `domain-modeling` never carried the flag
95
+ // and MUST NOT gain one.
96
+ const ENGINE_FLAGGED = ["to-spec", "to-tickets", "implement"];
97
+ const ENGINE_LOCK = "skills-lock.json";
98
+ const GUARD_MARK = "Driven by `wdi-build` and `wdi-autopilot`";
99
+ const GUARD_LINE = `> **${GUARD_MARK}.** \`wdi-method\` unlocked model invocation for this `
100
+ + "engine in this repo so those two can drive it unattended. Invoked from anywhere else — a stray "
101
+ + "session, a subagent that thought this looked relevant — stop and say so: this engine publishes "
102
+ + "to the tracker and writes code.";
103
+
104
+ // Every folder a platform reads skills from. One list, because a repo installs the engines wherever
105
+ // `npx skills add` was pointed, and that installer offers symlinks across several of them.
106
+ const SKILL_HOMES = [".claude", ".agents", ".agent", ".cursor", ".codex"];
107
+
108
+ // BMad skills RETIRED at G5. This array is the single home of that list: `bmad-skill-register.md`
109
+ // carries the same names for a reader, and a test fails when the two disagree.
110
+ //
111
+ // The criterion, and it is why the list is this long and not longer: a BMad skill is retired only
112
+ // where this method has a NAMED replacement for what it produces. `bmad-build` and `bmad-agent-dev`
113
+ // produce code that `implement` produces; `bmad-spec` a contract that `to-spec` produces;
114
+ // `bmad-create-epics-and-stories` an `epics` level this method REPEALED in code, not merely in
115
+ // prose. `bmad-qa-generate-e2e-tests` and `bmad-checkpoint-preview` have no replacement here, so
116
+ // they are NOT retired — banning a capability with nothing in its place is how a method gets
117
+ // worked around instead of followed.
118
+ const BMAD_RETIRED_G5 = [
119
+ "bmad-spec",
120
+ "bmad-build",
121
+ "bmad-build-auto",
122
+ "bmad-code-review",
123
+ "bmad-retrospective",
124
+ "bmad-agent-dev",
125
+ "bmad-create-epics-and-stories",
126
+ "bmad-create-story",
127
+ "bmad-dev-story",
128
+ "bmad-dev-auto",
129
+ "bmad-quick-dev",
130
+ "bmad-sprint-planning",
131
+ "bmad-sprint-status",
132
+ ];
133
+ const REPO_URL = "https://github.com/wiradeltaid/wdi-method";
134
+ const HELP_SKILL = "wdi-help";
135
+ const INIT_SKILL = "wdi-init";
136
+ // The room's readers file is seeded as a skeleton and is useless until a product writes it. The
137
+ // flag is the skeleton's own declaration, so this reads the same thing the engine does rather than
138
+ // guessing from the file's size or its age.
139
+ function readersAreSkeleton(target) {
140
+ const file = path.join(target, ".constitution", "project", "inventory-readers.py");
141
+ if (!fs.existsSync(file)) return false;
142
+ return /^SKELETON\s*=\s*True\b/m.test(fs.readFileSync(file, "utf8"));
143
+ }
144
+ const BMAD_REPO = "https://github.com/bmad-code-org/BMAD-METHOD";
145
+ const WDI_REPO = "https://github.com/wiradeltaid/wdi-method";
146
+
147
+ const RED = "\x1b[31m";
148
+ const GREEN = "\x1b[32m";
149
+ const DIM = "\x1b[2m";
150
+ const RESET = "\x1b[0m";
151
+
152
+ function die(msg) {
153
+ console.error(`${RED}error:${RESET} ${msg}`);
154
+ process.exit(1);
155
+ }
156
+
157
+ function ok(msg) {
158
+ console.log(`${GREEN}ok${RESET} ${msg}`);
159
+ }
160
+
161
+ function note(msg) {
162
+ console.log(`${DIM}·${RESET} ${msg}`);
163
+ }
164
+
165
+ function usage() {
166
+ console.log(`wdi-method ${PKG.version}
167
+
168
+ (no command) interactive TUI — detects install vs update
169
+ install [dir] first install (TUI unless --yes)
170
+ update [dir] update (TUI unless --yes)
171
+ verify [dir]
172
+ engines [dir] [--fix] report the six engines, their invocation state, and the BMad G5 ban
173
+ promote <live-dir> --rescue pull a method change back out of a consumer (not the normal flow)
174
+
175
+ --yes non-interactive
176
+ --agents a,b platform IDs (same as BMad --tools; legacy: claude = claude-code)
177
+ --list-agents print supported platform IDs
178
+ --product NAME written to index.yaml product.name
179
+ --client NAME written to index.yaml product.client (optional)
180
+ --doc-language <text> prose of working documents; free text, default English
181
+ --doc-filename-language <text> slug part of document filenames; free text, default English
182
+ --skip-bmad-check
183
+ --skip-engines-check install without to-spec / to-tickets / implement
184
+
185
+ BMad first, then this package. ${WDI_REPO}
186
+ `);
187
+ }
188
+
189
+ function parseArgs(argv) {
190
+ const args = {
191
+ cmd: null,
192
+ dir: null,
193
+ agents: null,
194
+ skipBmad: false,
195
+ rescue: false,
196
+ fix: false,
197
+ yes: false,
198
+ product: null,
199
+ client: null,
200
+ docLanguage: null,
201
+ docFilenameLanguage: null,
202
+ };
203
+ const rest = argv.slice(2);
204
+ if (rest[0] === "-h" || rest[0] === "--help") {
205
+ usage();
206
+ process.exit(0);
207
+ }
208
+ if (rest[0] === "--list-agents") {
209
+ console.log(formatPlatformList());
210
+ process.exit(0);
211
+ }
212
+ if (rest.length === 0) {
213
+ args.cmd = "wizard";
214
+ return args;
215
+ }
216
+ const first = rest[0];
217
+ if (["install", "update", "verify", "promote", "engines"].includes(first)) {
218
+ args.cmd = rest.shift();
219
+ } else if (first.startsWith("-")) {
220
+ args.cmd = "wizard";
221
+ } else {
222
+ args.cmd = "wizard";
223
+ args.dir = rest.shift();
224
+ }
225
+ while (rest.length) {
226
+ const t = rest.shift();
227
+ if (t === "--skip-bmad-check") args.skipBmad = true;
228
+ else if (t === "--fix") args.fix = true;
229
+ else if (t === "--skip-engines-check") args.skipEngines = true;
230
+ else if (t === "--rescue") args.rescue = true;
231
+ else if (t === "--yes" || t === "-y") args.yes = true;
232
+ else if (t === "--agents") {
233
+ const raw = rest.shift();
234
+ if (!raw) die("--agents needs a comma-separated list");
235
+ args.agents = normalizePlatformIds(raw.split(",").map((s) => s.trim()).filter(Boolean));
236
+ const unknown = raw.split(",").map((s) => s.trim()).filter(Boolean)
237
+ .filter((a) => !isKnownPlatform(a));
238
+ if (unknown.length) die(`unknown platform: ${unknown.join(", ")} (run --list-agents)`);
239
+ if (!args.agents.length) die("--agents needs at least one known platform");
240
+ } else if (t === "--product") args.product = rest.shift();
241
+ else if (t === "--client") args.client = rest.shift();
242
+ else if (t === "--doc-language" || t === "--doc-filename-language") {
243
+ // Free text: "English", "Bahasa Indonesia", "id" — a model reads it, so no list to match.
244
+ const raw = (rest.shift() || "").trim();
245
+ if (!raw) die(`${t} needs a value, for example: English`);
246
+ if (t === "--doc-language") args.docLanguage = raw;
247
+ else args.docFilenameLanguage = raw;
248
+ }
249
+ else if (t.startsWith("-")) die(`unknown flag: ${t}`);
250
+ else if (!args.dir) args.dir = t;
251
+ else die(`unexpected argument: ${t}`);
252
+ }
253
+ return args;
254
+ }
255
+
256
+ // Build output and editor droppings MUST NOT reach the kit. This repository is public, and a
257
+ // __pycache__/*.pyc carries the ABSOLUTE PATH of the source it was compiled from — which means a
258
+ // product name and a client folder leak into a public package through a file nobody wrote.
259
+ // Found 2026-08-18 on the first real promote: inventory.cpython-314.pyc embedded the live repo path.
260
+ const SKIP_DIRS = new Set(["__pycache__", "node_modules", ".git", ".pytest_cache", ".ruff_cache",
261
+ ".mypy_cache", ".venv", "venv", "dist", "build", ".idea", ".vscode"]);
262
+ const SKIP_FILE = /(\.pyc|\.pyo|\.pyd|\.log|\.tmp|\.swp|\.orig|\.rej|\.bak)$|^\.DS_Store$|^Thumbs\.db$/i;
263
+
264
+ function walkFiles(dir) {
265
+ const out = [];
266
+ if (!fs.existsSync(dir)) return out;
267
+ for (const entry of fs.readdirSync(dir, { withFileTypes: true })) {
268
+ const p = path.join(dir, entry.name);
269
+ if (entry.isDirectory()) {
270
+ if (SKIP_DIRS.has(entry.name)) continue;
271
+ out.push(...walkFiles(p));
272
+ } else if (entry.isFile()) {
273
+ if (SKIP_FILE.test(entry.name)) continue;
274
+ out.push(p);
275
+ }
276
+ }
277
+ return out;
278
+ }
279
+
280
+ function copyFile(src, dest) {
281
+ fs.mkdirSync(path.dirname(dest), { recursive: true });
282
+ fs.copyFileSync(src, dest);
283
+ }
284
+
285
+ function copyTree(src, dest, skipRel) {
286
+ let n = 0;
287
+ for (const p of walkFiles(src)) {
288
+ const rel = posixRel(src, p);
289
+ if (skipRel && skipRel(rel)) continue;
290
+ copyFile(p, path.join(dest, path.relative(src, p)));
291
+ n += 1;
292
+ }
293
+ return n;
294
+ }
295
+
296
+ function posixRel(from, to) {
297
+ return path.relative(from, to).split(path.sep).join("/");
298
+ }
299
+
300
+ function bmadPresent(target) {
301
+ const markers = [
302
+ path.join(target, ".claude", "skills", "bmad-help", "SKILL.md"),
303
+ path.join(target, "_bmad", "core", "config.yaml"),
304
+ path.join(target, "_bmad", "_config", "manifest.yaml"),
305
+ ];
306
+ return markers.some((p) => fs.existsSync(p));
307
+ }
308
+
309
+ function wdiPresent(target) {
310
+ return (
311
+ fs.existsSync(path.join(target, ".control", "wdi-method.yaml")) ||
312
+ fs.existsSync(path.join(target, ".constitution", "method", "README.md"))
313
+ );
314
+ }
315
+
316
+ function dirNonEmpty(target) {
317
+ if (!fs.existsSync(target)) return false;
318
+ return fs.readdirSync(target).some((n) => n !== ".git" && n !== ".gitignore");
319
+ }
320
+
321
+ function readBmadVersion(target) {
322
+ const manifest = path.join(target, "_bmad", "_config", "manifest.yaml");
323
+ if (!fs.existsSync(manifest)) return "";
324
+ const text = fs.readFileSync(manifest, "utf8");
325
+ const m = text.match(/installation:\s*\n\s*version:\s*(\S+)/);
326
+ return m ? m[1] : "";
327
+ }
328
+
329
+ function gitHead(repo) {
330
+ const r = spawnSync("git", ["-C", repo, "rev-parse", "--short", "HEAD"], {
331
+ encoding: "utf8",
332
+ });
333
+ if (r.status !== 0) return "unknown";
334
+ return r.stdout.trim();
335
+ }
336
+
337
+ function today() {
338
+ return new Date().toISOString().slice(0, 10);
339
+ }
340
+
341
+ function requireKit() {
342
+ if (!fs.existsSync(path.join(KIT, ".constitution"))) {
343
+ die(`kit missing at ${KIT}`);
344
+ }
345
+ }
346
+
347
+ function requireTarget(dir) {
348
+ const target = path.resolve(dir || process.cwd());
349
+ if (!fs.existsSync(target) || !fs.statSync(target).isDirectory()) {
350
+ die(`target is not a directory: ${target}`);
351
+ }
352
+ return target;
353
+ }
354
+
355
+ /** Skill files in the repo, keyed by name, de-duplicated by the file each one REALLY is.
356
+ *
357
+ * The de-duplication is the point. `npx skills add` offers "symlink — single source of truth" when
358
+ * more than one agent is selected, so one SKILL.md is reachable through `.claude/skills/` and
359
+ * `.agents/skills/` at once. Walking directories would patch it twice — and where the link points
360
+ * into `node_modules`, patching it at all would edit a dependency.
361
+ */
362
+ function repoSkillFiles(target, names) {
363
+ const out = new Map();
364
+ for (const home of SKILL_HOMES) {
365
+ for (const name of names) {
366
+ const file = path.join(target, home, "skills", name, "SKILL.md");
367
+ if (!fs.existsSync(file)) continue;
368
+ let real = file;
369
+ try {
370
+ real = fs.realpathSync(file);
371
+ } catch {}
372
+ if (!out.has(name)) out.set(name, new Map());
373
+ out.get(name).set(real, file);
374
+ }
375
+ }
376
+ return out;
377
+ }
378
+
379
+ /** The six engines, in the REPO. A user-level plugin is not an answer here — see ENGINE_SKILLS. */
380
+ function enginesReport(target) {
381
+ const files = repoSkillFiles(target, ENGINE_SKILLS);
382
+ const missing = ENGINE_SKILLS.filter((n) => !files.has(n));
383
+ return { files, missing, present: missing.length === 0 };
384
+ }
385
+
386
+ function enginesPresent(target) {
387
+ return enginesReport(target).present;
388
+ }
389
+
390
+ /** Only ever a WARNING. The plugin's copies are namespaced and still flagged, so they can neither be
391
+ * invoked nor shadow the repo's — but `/to-spec` in the UI becomes ambiguous, and `npx skills
392
+ * update` run against a plugin-shaped install is one way the flag comes back. */
393
+ function pluginEnginesRegistered() {
394
+ const cfg = process.env.CLAUDE_CONFIG_DIR || path.join(os.homedir(), ".claude");
395
+ const registry = path.join(cfg, "plugins", "installed_plugins.json");
396
+ if (!fs.existsSync(registry)) return false;
397
+ try {
398
+ const plugins = JSON.parse(fs.readFileSync(registry, "utf8")).plugins || {};
399
+ return Object.keys(plugins).some((k) => k.startsWith("mattpocock-skills@"));
400
+ } catch {
401
+ return false;
402
+ }
403
+ }
404
+
405
+ /** Strip the author's flag, and write one guard line where it stood.
406
+ *
407
+ * The flag was the only thing stopping a stray session from publishing tickets. Removing it without
408
+ * naming who may drive the engine trades a hard gate for nothing, so the two arrive together.
409
+ * Idempotent by construction: no flag and a guard already present means the file is returned as-is,
410
+ * which is what keeps a second `update` from stacking a second line.
411
+ */
412
+ function withInvocationEnabled(text) {
413
+ let out = text;
414
+ if (/^disable-model-invocation\s*:.*$/m.test(out)) {
415
+ out = out.replace(/^disable-model-invocation\s*:.*\r?\n/m, "");
416
+ }
417
+ if (!out.includes(GUARD_MARK)) {
418
+ out = out.replace(/^(---\r?\n[\s\S]*?\r?\n---\r?\n)/, `$1\n${GUARD_LINE}\n`);
419
+ }
420
+ return out;
421
+ }
422
+
423
+ function enableEngineInvocation(target) {
424
+ const { files } = enginesReport(target);
425
+ const patched = new Set();
426
+ for (const name of ENGINE_FLAGGED) {
427
+ const copies = files.get(name);
428
+ if (!copies) continue;
429
+ for (const real of copies.keys()) {
430
+ const before = fs.readFileSync(real, "utf8");
431
+ const after = withInvocationEnabled(before);
432
+ if (after === before) continue;
433
+ fs.writeFileSync(real, after, "utf8");
434
+ patched.add(name);
435
+ }
436
+ }
437
+ if (patched.size) {
438
+ note(`engines invocable: ${[...patched].join(" · ")} — author's flag removed, guard line written`);
439
+ }
440
+ return [...patched];
441
+ }
442
+
443
+ /** The mirror image, pointed at BMad's G5 wrappers: the flag ADDED rather than removed.
444
+ *
445
+ * A rule in a document lost this argument for three releases. `bmad-build` sits in the repo's own
446
+ * skill folder claiming it "implements any user intent, requirement, story, bug fix or change
447
+ * request", model-invocable, while the sanctioned engines sat in a plugin the model could not call.
448
+ * The harness rewarded the forbidden path. This is what stops rewarding it — and it leaves the
449
+ * human route open, because the gate only refuses the Skill tool: `/bmad-build` typed by a person
450
+ * still runs.
451
+ */
452
+ function withModelInvocationDisabled(text) {
453
+ if (/^disable-model-invocation\s*:\s*true/m.test(text)) return text;
454
+ if (!/^---\r?\n/.test(text)) return text; // no frontmatter of its own: not ours to invent one
455
+ if (/^disable-model-invocation\s*:/m.test(text)) {
456
+ return text.replace(/^disable-model-invocation\s*:.*$/m, "disable-model-invocation: true");
457
+ }
458
+ return text.replace(/^---\r?\n/, "---\ndisable-model-invocation: true\n");
459
+ }
460
+
461
+ function retireBmadG5(target) {
462
+ const files = repoSkillFiles(target, BMAD_RETIRED_G5);
463
+ const patched = new Set();
464
+ for (const [name, copies] of files) {
465
+ for (const real of copies.keys()) {
466
+ const before = fs.readFileSync(real, "utf8");
467
+ const after = withModelInvocationDisabled(before);
468
+ if (after === before) continue;
469
+ fs.writeFileSync(real, after, "utf8");
470
+ patched.add(name);
471
+ }
472
+ }
473
+ if (patched.size) {
474
+ note(`retired at G5: ${patched.size} BMad skill${patched.size === 1 ? "" : "s"} can no longer be `
475
+ + `model-invoked (a person typing the slash command still can)`);
476
+ }
477
+ return [...patched];
478
+ }
479
+
480
+ /** Second layer, and the only one that survives BMad reinstalling its own wrappers mid-week.
481
+ *
482
+ * Merged, never replaced: a product's own permissions are its own. Invalid JSON is reported rather
483
+ * than repaired — rewriting a settings file nobody can parse is how a repo loses its allowlist.
484
+ */
485
+ function writeDenyRules(target) {
486
+ const file = path.join(target, ".claude", "settings.json");
487
+ let settings = {};
488
+ if (fs.existsSync(file)) {
489
+ try {
490
+ settings = JSON.parse(fs.readFileSync(file, "utf8"));
491
+ } catch {
492
+ note(".claude/settings.json is not valid JSON — deny rules NOT written; fix it and re-run");
493
+ return 0;
494
+ }
495
+ if (!settings || typeof settings !== "object" || Array.isArray(settings)) return 0;
496
+ }
497
+ const perms = settings.permissions && typeof settings.permissions === "object"
498
+ && !Array.isArray(settings.permissions) ? settings.permissions : {};
499
+ const deny = Array.isArray(perms.deny) ? perms.deny : [];
500
+ const want = BMAD_RETIRED_G5.map((n) => `Skill(${n})`);
501
+ const added = want.filter((rule) => !deny.includes(rule));
502
+ if (!added.length) return 0;
503
+ perms.deny = [...deny, ...added];
504
+ settings.permissions = perms;
505
+ fs.mkdirSync(path.dirname(file), { recursive: true });
506
+ fs.writeFileSync(file, `${JSON.stringify(settings, null, 2)}\n`, "utf8");
507
+ note(`deny rules for ${added.length} retired BMad skill${added.length === 1 ? "" : "s"} `
508
+ + `→ .claude/settings.json`);
509
+ return added.length;
510
+ }
511
+
512
+ /** A trace of what the engines were when the method last looked — not a lockfile.
513
+ *
514
+ * `npx skills add` writes its own `skills-lock.json` with a folder hash per skill, and that hash
515
+ * stops matching the moment the flag is stripped. So the register records the hash of the file the
516
+ * method actually reads, AFTER the patch. It is informational: `engines-invocable` decides by
517
+ * looking for the flag, not by comparing hashes, because a legitimate content change MUST NOT read
518
+ * as a defect.
519
+ */
520
+ function engineFingerprints(target) {
521
+ const { files } = enginesReport(target);
522
+ const out = {};
523
+ for (const name of ENGINE_SKILLS) {
524
+ const copies = files.get(name);
525
+ if (!copies) continue;
526
+ const [real] = [...copies.keys()].sort();
527
+ out[name] = createHash("sha256").update(fs.readFileSync(real)).digest("hex").slice(0, 12);
528
+ }
529
+ return out;
530
+ }
531
+
532
+ function engineInvocationState(target) {
533
+ const { files } = enginesReport(target);
534
+ const blocked = [];
535
+ for (const name of ENGINE_FLAGGED) {
536
+ const copies = files.get(name);
537
+ if (!copies) continue;
538
+ for (const real of copies.keys()) {
539
+ if (/^disable-model-invocation\s*:\s*true/m.test(fs.readFileSync(real, "utf8"))) {
540
+ blocked.push(name);
541
+ break;
542
+ }
543
+ }
544
+ }
545
+ return { blocked };
546
+ }
547
+
548
+ function bmadMissingMessage() {
549
+ return [
550
+ "BMad Method is not installed in this repo. Install it first, then run this installer again.",
551
+ "",
552
+ ` ${BMAD_INSTALL}`,
553
+ "",
554
+ `Source: ${BMAD_REPO}`,
555
+ "In the BMad installer, pick the same agents (Claude Code, Cursor, …).",
556
+ ].join("\n");
557
+ }
558
+
559
+ // The engines used to WARN and let the install through, on the reasoning that G1-G4 run without them and
560
+ // a first install has no G5 yet. Both halves are still true, and the reasoning stopped being enough:
561
+ // `wdi-autopilot` needs all three from its first iteration, and a warning inside a forty-line summary is
562
+ // read exactly as often as it is skipped. The failure it was meant to prevent — learning they are missing
563
+ // inside `wdi-build`, with a spec already open — kept happening anyway.
564
+ //
565
+ // So it blocks, and `--skip-engines-check` is the escape, exactly as `--skip-bmad-check` is for BMad. The
566
+ // escape matters: CI installs into a bare checkout, and a repo that will never reach G5 is a real case.
567
+ function enginesMissingMessage(missing) {
568
+ const names = (missing && missing.length ? missing : ENGINE_SKILLS).join(" · ");
569
+ return [
570
+ `The engines are not in this repo. Missing: ${names}`,
571
+ "",
572
+ "They MUST be installed INTO the repo, not as a user-level plugin — the method strips",
573
+ "`disable-model-invocation` from its own copies so `wdi-build` and `wdi-autopilot` can drive",
574
+ "them, and a plugin's files are not the repo's to edit.",
575
+ "",
576
+ ` ${ENGINES_INSTALL_ANY}`,
577
+ "",
578
+ `Take all six: ${ENGINE_SKILLS.join(" · ")}. Choose "copy" or "symlink" — either is read.`,
579
+ "",
580
+ "You do NOT need to run the setup skill after this — the installer seeds docs/agents/ already",
581
+ `answered for this method. Run ${ENGINES_SETUP} only to change tracker.`,
582
+ `Source: ${ENGINES_REPO}`,
583
+ "",
584
+ "G1-G4 run without them. To install anyway and add them later: --skip-engines-check",
585
+ ].join("\n");
586
+ }
587
+
588
+ /** `docs/agents/` is the engines' config, and its PATH is the author's: `to-spec`, `to-tickets`,
589
+ * `implement` and `triage` read `docs/agents/issue-tracker.md` and `docs/agents/domain.md` and
590
+ * nowhere else. What the files SAY is this method's, and that is the half that kept going wrong:
591
+ * a repo that ran `/setup-matt-pocock-skills` carries upstream's answer, which sends every engine to
592
+ * `.scratch/` with no registry behind it and never mentions `specs.yaml`. Two of four live repos
593
+ * still had it.
594
+ *
595
+ * The installer does not touch a product-owned file, and that rule stays. This is the repair, run
596
+ * from `wdi-method engines --fix` — by `wdi-init` or `wdi-upgrade`, knowingly — and it keeps the
597
+ * previous text beside it as `.bak` rather than deleting an answer somebody may have meant.
598
+ */
599
+ function repairAgentDocs(target) {
600
+ const dir = path.join(target, "docs", "agents");
601
+ const fixed = [];
602
+ for (const name of ["issue-tracker.md", "domain.md"]) {
603
+ const seed = path.join(ROOT, "scaffold", "docs", "agents", name);
604
+ if (!fs.existsSync(seed)) continue;
605
+ const to = path.join(dir, name);
606
+ if (!fs.existsSync(to)) {
607
+ copyFile(seed, to);
608
+ fixed.push(`${name} (seeded)`);
609
+ continue;
610
+ }
611
+ const text = fs.readFileSync(to, "utf8");
612
+ if (text.includes("seeded by `wdi-method`")) continue;
613
+ fs.writeFileSync(`${to}.bak`, text, "utf8");
614
+ copyFile(seed, to);
615
+ fixed.push(`${name} (was upstream's — previous text kept as ${name}.bak)`);
616
+ }
617
+ for (const line of fixed) note(`repaired docs/agents/${line}`);
618
+ return fixed;
619
+ }
620
+
621
+ // The product's custom room. Three properties, and all three MUST hold together:
622
+ // install/update seeds its content ONLY when absent — never written again after that
623
+ // promote SKIPS it entirely, so a product's own rules can never reach the public repo
624
+ // agent loads it like any other guide, so it BINDS
625
+ // The deliberate consequence: this room's README is authored in the package and never comes home
626
+ // through promote.
627
+ const PROJECT_ROOM = "project/";
628
+
629
+ // 0.5.0 moved `.constitution/` to exactly two folders: `method/` is the method's and is overwritten,
630
+ // `project/` is the product's and is never touched. Before it, generic and product-owned files sat
631
+ // side by side at the root, `codebase/` was a third product-owned room nobody had written down, and
632
+ // `constitution.md` was ONE file holding both — which is why `update` had to keep the whole thing and
633
+ // the product never received a fixed generic Article.
634
+ //
635
+ // Without this migration an installed repo would end up carrying BOTH layouts: the kit writes the new
636
+ // paths while the old files stay behind, and an agent reading `AGENTS.md` routing would find two
637
+ // copies of most guides and no way to tell which binds.
638
+ const OLD_ROOT_GUIDES = ["README", "language-guide", "method-glossary", "repo-guide", "structure-guide"];
639
+ const OLD_WHY = ["README", "artifact-map", "portability", "rationale"];
640
+ const OLD_CODEBASE = ["stack", "conventions", "brownfield"];
641
+
642
+ function mv(from, to) {
643
+ fs.mkdirSync(path.dirname(to), { recursive: true });
644
+ fs.renameSync(from, to);
645
+ }
646
+
647
+ /** Article numbers that belong to the method half. The product keeps 1, 2, and 5. */
648
+ const METHOD_ARTICLES = [3, 4, 6, 7];
649
+
650
+ /**
651
+ * Cut the method's articles out of a product's constitution.md, and repoint its relative links.
652
+ *
653
+ * Returns {cut, kept, relinked}, or null when the file does not look like a constitution at all —
654
+ * in which case it is left ALONE rather than guessed at.
655
+ *
656
+ * 0.5.0 moved the file whole and printed "delete Articles 3, 4, 6, 7 yourself", on the grounds that
657
+ * no script can tell an edited copy from the original. That reasoning was wrong in the way that
658
+ * matters: the split does not need to know whether a section was edited, only which article numbers
659
+ * are the method's — and the file states them in its own headings. Leaving it whole left every
660
+ * migrated repo carrying those articles in TWO files, one of them frozen and drifting, plus relative
661
+ * links that no longer resolve one level down. It is all in git, so cutting is reversible; not
662
+ * cutting is what nobody notices.
663
+ */
664
+ function splitProductConstitution(file) {
665
+ if (!fs.existsSync(file)) return null;
666
+ const raw = fs.readFileSync(file, "utf8");
667
+ const crlf = raw.includes("\r\n");
668
+ const text = crlf ? raw.replaceAll("\r\n", "\n") : raw;
669
+ const marks = [...text.matchAll(/^## Article (\d+)\b.*$/gm)];
670
+ if (marks.length < 2) return null; // not the shape we know; do not touch it
671
+
672
+ const kept = [];
673
+ const cut = [];
674
+ let out = text.slice(0, marks[0].index);
675
+ for (let i = 0; i < marks.length; i += 1) {
676
+ const n = Number(marks[i][1]);
677
+ const end = i + 1 < marks.length ? marks[i + 1].index : text.length;
678
+ if (METHOD_ARTICLES.includes(n)) cut.push(n);
679
+ else {
680
+ kept.push(n);
681
+ out += text.slice(marks[i].index, end);
682
+ }
683
+ }
684
+ if (!cut.length) return { cut, kept, relinked: 0 };
685
+
686
+ // The file sits one level deeper than it did, and its former siblings moved into method/. A link
687
+ // left as `repo-guide.md` now resolves to .constitution/project/repo-guide.md, which does not exist.
688
+ let relinked = 0;
689
+ const bump = (re, to) => {
690
+ out = out.replace(re, (m, ...rest) => {
691
+ relinked += 1;
692
+ return typeof to === "function" ? to(m, ...rest) : to + m;
693
+ });
694
+ };
695
+ for (const name of ["repo-guide.md", "structure-guide.md", "language-guide.md",
696
+ "method-glossary.md"]) {
697
+ bump(new RegExp(`(?<![\\w./-])${name.replace(".", "\\.")}`, "g"), "../method/");
698
+ }
699
+ bump(/(?<![\w./-])document\//g, "../method/");
700
+ bump(/(?<![\w./-])codebase\/([a-z]+)-guide\.md/g, (_m, kind) => `codebase-${kind}-guide.md`);
701
+ out = out.replaceAll("../method/../method/", "../method/");
702
+
703
+ const banner = [
704
+ "",
705
+ `> **Articles ${cut.join(", ")} were removed from this file on migration to the two-folder layout.**`,
706
+ "> They are the method's and live in [`../method/constitution.md`](../method/constitution.md), which",
707
+ `> \`update\` replaces. Only Articles ${kept.join(", ")} are yours. The removed text is in git.`,
708
+ "",
709
+ ].join("\n");
710
+ const firstArticle = out.search(/^## Article /m);
711
+ out = firstArticle === -1
712
+ ? out + banner
713
+ : out.slice(0, firstArticle) + banner.trimStart() + "\n" + out.slice(firstArticle);
714
+
715
+ fs.writeFileSync(file, crlf ? out.replaceAll("\n", "\r\n") : out, "utf8");
716
+ return { cut, kept, relinked };
717
+ }
718
+
719
+ // `waves.yaml` holds the PRODUCT's plan, not the package's. When the method retired `wave` for
720
+ // `spec` the registry had to follow, and a rename is the only part of that a tool can safely do:
721
+ // the file MOVES, its content is left exactly as written. Rewriting the rows — `W1` to `SPEC-1`,
722
+ // `epics`/`stories` to `tickets` — is the product's own migration, run by `wdi-build` where a human
723
+ // can see it, because a guess there silently rewrites months of real work.
724
+ //
725
+ // Two refusals matter more than the move. It never writes over an existing `specs.yaml`, and it
726
+ // never deletes a `waves.yaml` whose content has nowhere to go: a half-finished hand migration
727
+ // leaves BOTH files present, and which one is real is not something an installer can know.
728
+ // `wdi-autopilot` named its ledger for the DAY before 0.6.2 — `autopilot-<YYYY-MM-DD>.md`. The mandate
729
+ // it belongs to is named for the MANDATE now — `autopilot-<DEC-id>.md` — because two mandates opened
730
+ // on the same day would otherwise append to one file and destroy both as a record, and because
731
+ // `mandate-accept` (the validator introduced alongside the rename) looks for the file at that path and
732
+ // nowhere else. This is a pure rename, like `waves.yaml` → `specs.yaml`: the ledger's own content is
733
+ // never touched, only found and moved. Renaming it is what a script can safely do; restructuring its
734
+ // CONTENT into the `## Resume` / `## Decisions` split is not — that has to read git and the registry to
735
+ // know where the run actually stands, so it is the skill's own job on the next iteration it runs, not
736
+ // this installer's.
737
+ // `/setup-matt-pocock-skills` interviews the owner and writes `docs/agents/`. Two of its answers are
738
+ // wrong for a WDI repo, and BOTH repos that ran it had to hand-correct the SAME file afterwards:
739
+ //
740
+ // - `domain.md` tells agents to read and lazily create a root `CONTEXT.md` and `docs/adr/`. Article 3
741
+ // says this method has no `docs/` layer for corpus or rules, and `wdi-reconcile` reports both as
742
+ // findings. The homes already exist: `.control/product-glossary.md`, `.what/`, `.how/`, `DEC-`.
743
+ // - `issue-tracker.md`'s local-markdown default puts every ticket under `.scratch/<feature>/`, while
744
+ // `wdi-build` owns tickets at `{spec_folder}/issues/`. Two homes for one ticket set.
745
+ //
746
+ // Seeding them removes the interview for the answers WDI Method actually has a requirement on. Seeded
747
+ // ONCE and never overwritten — after the first install they are the product's, like every other file
748
+ // under a path the product owns. An owner who wants a different tracker re-runs the setup skill; the
749
+ // seeded file says which three invariants have to survive that.
750
+ function seedAgentDocs(target) {
751
+ const dir = path.join(target, "docs", "agents");
752
+ let wrote = 0;
753
+ for (const name of ["domain.md", "issue-tracker.md"]) {
754
+ const to = path.join(dir, name);
755
+ if (fs.existsSync(to)) continue;
756
+ const seed = path.join(ROOT, "scaffold", "docs", "agents", name);
757
+ if (!fs.existsSync(seed)) continue;
758
+ copyFile(seed, to);
759
+ wrote += 1;
760
+ }
761
+ if (wrote) {
762
+ note(`seeded docs/agents/ (${wrote} file${wrote === 1 ? "" : "s"}) — the engines' config, pre-answered`);
763
+ note(" do NOT run /setup-matt-pocock-skills to redo these; re-run it only to change tracker");
764
+ }
765
+ return wrote > 0;
766
+ }
767
+
768
+ // A repo that ran the setup skill BEFORE installing this package still carries the default `domain.md`,
769
+ // and it is actively misleading: it sends every engineering skill looking for a root `CONTEXT.md` and
770
+ // `docs/adr/`, and tells them to create both lazily. Seeding cannot fix it, because the file already
771
+ // exists and a file under a product-owned path is never overwritten. So it is named instead.
772
+ function warnStaleAgentDocs(target) {
773
+ const file = path.join(target, "docs", "agents", "domain.md");
774
+ if (!fs.existsSync(file)) return;
775
+ const text = fs.readFileSync(file, "utf8");
776
+ if (!/CONTEXT\.md|docs\/adr/.test(text)) return;
777
+ // An override note is what both real repos added by hand. Recognising it is what stops this warning
778
+ // from firing forever on a file somebody already fixed.
779
+ if (/does not use|MUST NOT be created|no `docs\/` layer/i.test(text)) return;
780
+ note("docs/agents/domain.md still points agents at a root CONTEXT.md and docs/adr/");
781
+ note(" Article 3: this method has no `docs/` layer for corpus or rules, and wdi-reconcile");
782
+ note(" reports both as findings. Say so at the top of that file — the glossary is at");
783
+ note(" .control/product-glossary.md and a decision is a DEC-, never an ADR");
784
+ }
785
+
786
+ function migrateAutopilotLedgers(target) {
787
+ const dir = path.join(target, ".control", "memlog");
788
+ if (!fs.existsSync(dir)) return;
789
+ const OLD = /^autopilot-(\d{4}-\d{2}-\d{2})\.md$/;
790
+ for (const name of fs.readdirSync(dir)) {
791
+ const m = OLD.exec(name);
792
+ if (!m) continue;
793
+ const from = path.join(dir, name);
794
+ const text = fs.readFileSync(from, "utf8");
795
+ const artifact = /^artifact:\s*(\S.*)$/m.exec(text)?.[1]?.trim();
796
+ const id = artifact && /(DEC-\d+)/.exec(artifact)?.[1];
797
+ if (!id) {
798
+ note(`.control/memlog/${name} looks like a pre-0.6.2 autopilot ledger, but its \`artifact:\` does`);
799
+ note(` not resolve to a DEC- id — rename it to autopilot-<the mandate's DEC- id>.md yourself`);
800
+ continue;
801
+ }
802
+ const to = path.join(dir, `autopilot-${id}.md`);
803
+ if (fs.existsSync(to)) {
804
+ note(`BOTH .control/memlog/${name} and autopilot-${id}.md exist — neither was touched`);
805
+ note(` the run's ledger is in one of them and I cannot tell which. Merge them, then delete the other`);
806
+ continue;
807
+ }
808
+ mv(from, to);
809
+ note(`renamed .control/memlog/${name} → autopilot-${id}.md (content unchanged)`);
810
+ note(` \`mandate-accept\` looks for a mandate's ledger at this exact path`);
811
+ }
812
+ }
813
+
814
+ // A mandate opened before 0.6.2 recorded `parked: []` under the OLD default — full authority, AD-N
815
+ // contradictions included. 0.6.2 changed the DEFAULT for a NEW mandate to park `ad-n`, because
816
+ // decision-guide.md says narrowing an invariant MUST NOT be softened further. A default only applies
817
+ // at the moment a mandate is written, so an EXISTING accepted mandate keeps whatever it already says —
818
+ // silently adding `ad-n` to it would be overwriting a value the owner already chose, which `update`
819
+ // MUST NOT do to anything in the product's own registry. So this only ever WARNS, naming the mandate
820
+ // and the one line that would close the gap, and leaves the decision to whoever reads the summary.
821
+ function warnStaleMandates(target) {
822
+ const file = path.join(target, ".control", "registry", "decisions.yaml");
823
+ if (!fs.existsSync(file)) return;
824
+ const text = fs.readFileSync(file, "utf8");
825
+ const blocks = text.split(/\n(?=\s*-\s*id:\s*DEC-)/);
826
+ for (const block of blocks) {
827
+ if (!/type:\s*mandate/.test(block)) continue;
828
+ if (!/status:\s*accepted/.test(block)) continue;
829
+ const id = /id:\s*(DEC-\d+)/.exec(block)?.[1];
830
+ const parkedLine = /parked:\s*(\[[^\]]*\]|.*)$/m.exec(block)?.[0] || "";
831
+ const parkedBlockList = /parked:\s*\n((?:\s+-\s*\S.*\n?)*)/.exec(block)?.[1] || "";
832
+ if (/ad-n/.test(parkedLine) || /ad-n/.test(parkedBlockList)) continue;
833
+ note(`${id || "a mandate"} predates the \`ad-n\`-parked-by-default protection (0.6.2) — its \`parked\``);
834
+ note(` list does not name it, so it still decides an AD-N contradiction on its own`);
835
+ note(` add \`ad-n\` to its \`parked\` list in decisions.yaml yourself if you want the new default`);
836
+ }
837
+ }
838
+
839
+ function migrateRegistryNames(target) {
840
+ const reg = path.join(target, ".control", "registry");
841
+ const from = path.join(reg, "waves.yaml");
842
+ const to = path.join(reg, "specs.yaml");
843
+ if (!fs.existsSync(from)) return false;
844
+ if (fs.existsSync(to)) {
845
+ note("BOTH .control/registry/waves.yaml and specs.yaml exist — neither was touched");
846
+ note(" the plan is in one of them and I cannot tell which. Merge them yourself, then delete waves.yaml");
847
+ return false;
848
+ }
849
+ mv(from, to);
850
+ note("renamed .control/registry/waves.yaml → specs.yaml (content unchanged)");
851
+ note(" the rows still say `W<N>` and `epics`/`stories`. Re-cut them through the wdi-build skill");
852
+ return true;
853
+ }
854
+
855
+ // The requirement registry split into `goals.yaml` (the product's `BG`, written by `wdi-problem` at
856
+ // G1) plus one `requirements-<slug>.yaml` per PRD (`CAP`, `FR`, `NFR`, `UJ`, written by
857
+ // `wdi-product` at G2). One file, one writer, one gate. What a tool can do here is SEED `goals.yaml`;
858
+ // what it MUST NOT do is move the rows.
859
+ //
860
+ // Splitting the rows needs one fact the registry has never recorded: which PRD an `FR` belongs to.
861
+ // Before the split nothing wrote it down, and deriving it — FR → UC → ticket → spec → `prd:` — only
862
+ // works for FRs that already have tickets. A guess would file a promise under the wrong initiative,
863
+ // which is worse than leaving it where it is. So `requirements.yaml` is left ALONE and still read:
864
+ // `validate.py` unions every requirement file it finds, so a half-split corpus stays green while its
865
+ // owner cuts the rows through the skill that owns each one.
866
+ function seedRequirementSplit(target) {
867
+ const reg = path.join(target, ".control", "registry");
868
+ if (!fs.existsSync(reg)) return false;
869
+ const product = path.join(reg, "goals.yaml");
870
+ if (fs.existsSync(product)) return false;
871
+ const seed = path.join(SCAFFOLD, "registry", "goals.yaml");
872
+ if (!fs.existsSync(seed)) return false;
873
+ copyFile(seed, product);
874
+ note("seeded .control/registry/goals.yaml");
875
+ if (fs.existsSync(path.join(reg, "requirements.yaml"))) {
876
+ note(" requirements.yaml was left exactly as it is, and is still read — nothing broke");
877
+ note(" the wdi-upgrade skill moves `goals:` into goals.yaml and cuts `capabilities:`,");
878
+ note(" `functional:`, `nonfunctional:`, and `journeys:` into requirements-<slug>.yaml per PRD.");
879
+ note(" <slug> is the PRD's folder name under .what/_prd/");
880
+ }
881
+ return true;
882
+ }
883
+
884
+ function ensureGitignoreCustomDispatch(target) {
885
+ const gitignorePath = path.join(target, ".gitignore");
886
+ const rule = ".control/custom-dispatch.yaml";
887
+ if (fs.existsSync(gitignorePath)) {
888
+ const content = fs.readFileSync(gitignorePath, "utf8");
889
+ const lines = content.split(/\r?\n/).map((l) => l.trim());
890
+ if (lines.includes(rule) || lines.includes(`/${rule}`) || lines.includes(".control/*.yaml")) {
891
+ return;
892
+ }
893
+ const separator = content.endsWith("\n") ? "" : "\n";
894
+ fs.writeFileSync(gitignorePath, `${content}${separator}# Local runner configuration (never commit personal runners/credentials)\n${rule}\n`, "utf8");
895
+ note("added .control/custom-dispatch.yaml to .gitignore");
896
+ } else {
897
+ fs.writeFileSync(gitignorePath, `# Local runner configuration (never commit personal runners/credentials)\n${rule}\n`, "utf8");
898
+ note("created .gitignore with .control/custom-dispatch.yaml");
899
+ }
900
+ }
901
+
902
+ function ensureGitignoreSmoke(target) {
903
+ const gitignorePath = path.join(target, ".gitignore");
904
+ const rule = ".work/smoke/";
905
+ if (fs.existsSync(gitignorePath)) {
906
+ const content = fs.readFileSync(gitignorePath, "utf8");
907
+ const lines = content.split(/\r?\n/).map((l) => l.trim());
908
+ if (lines.includes(rule) || lines.includes(`/${rule}`) || lines.includes(".work/smoke") || lines.includes(".work/**")) {
909
+ return;
910
+ }
911
+ const separator = content.endsWith("\n") ? "" : "\n";
912
+ fs.writeFileSync(gitignorePath, `${content}${separator}# Ephemeral smoke test artifacts\n${rule}\n`, "utf8");
913
+ note("added .work/smoke/ to .gitignore");
914
+ } else {
915
+ fs.writeFileSync(gitignorePath, `# Ephemeral smoke test artifacts\n${rule}\n`, "utf8");
916
+ note("created .gitignore with .work/smoke/");
917
+ }
918
+ }
919
+
920
+ function seedDailyTierScaffold(target) {
921
+ const control = path.join(target, ".control");
922
+ if (!fs.existsSync(control)) return;
923
+
924
+ const exampleDest = path.join(control, "custom-dispatch.yaml.example");
925
+ const exampleSrc = path.join(SCAFFOLD, "custom-dispatch.yaml.example");
926
+ if (fs.existsSync(exampleSrc)) {
927
+ const isNew = !fs.existsSync(exampleDest);
928
+ copyFile(exampleSrc, exampleDest);
929
+ note(isNew ? "seeded .control/custom-dispatch.yaml.example" : "refreshed .control/custom-dispatch.yaml.example");
930
+ }
931
+
932
+ const targetsDest = path.join(control, "test-targets");
933
+ const targetsSrc = path.join(SCAFFOLD, "test-targets");
934
+ if (fs.existsSync(targetsSrc)) {
935
+ fs.mkdirSync(targetsDest, { recursive: true });
936
+ let seeded = 0;
937
+ for (const file of fs.readdirSync(targetsSrc)) {
938
+ const srcFile = path.join(targetsSrc, file);
939
+ const dstFile = path.join(targetsDest, file);
940
+ if (!fs.existsSync(dstFile)) {
941
+ copyFile(srcFile, dstFile);
942
+ seeded += 1;
943
+ }
944
+ }
945
+ if (seeded > 0) {
946
+ note(`seeded ${seeded} template(s) in .control/test-targets/`);
947
+ }
948
+ }
949
+
950
+ ensureGitignoreCustomDispatch(target);
951
+ ensureGitignoreSmoke(target);
952
+
953
+ const localDispatchDest = path.join(control, "custom-dispatch.yaml");
954
+ if (!fs.existsSync(localDispatchDest) && fs.existsSync(exampleSrc)) {
955
+ const raw = fs.readFileSync(exampleSrc, "utf8");
956
+ const content = raw.replace(/^# \.control\/custom-dispatch\.yaml\.example/m, "# .control/custom-dispatch.yaml");
957
+ fs.writeFileSync(localDispatchDest, content, "utf8");
958
+ note("seeded default .control/custom-dispatch.yaml (gitignored)");
959
+ }
960
+ }
961
+
962
+ function migrateToTwoFolders(target) {
963
+ const c = path.join(target, ".constitution");
964
+ if (!fs.existsSync(c)) return false; // a first install has nothing to migrate
965
+ const at = (...p) => path.join(c, ...p);
966
+ // The old layout is identified by `document/` at the ROOT — in the new layout that folder only ever
967
+ // exists under `method/`. Checking a loose guide instead would misfire on a repo that added one.
968
+ if (!fs.existsSync(at("document")) && !fs.existsSync(at("codebase"))
969
+ && !fs.existsSync(at("constitution.md")) && !fs.existsSync(at("scripts"))) {
970
+ return false;
971
+ }
972
+ note("pre-0.5.0 .constitution/ found — migrating to method/ + project/");
973
+
974
+ // 1. The four Reference files go one level deeper. This MUST run before the kit is written, or the
975
+ // kit's own why/ files land while the old copies still sit at method/ root.
976
+ for (const name of OLD_WHY) {
977
+ const from = at("method", `${name}.md`);
978
+ if (fs.existsSync(from)) {
979
+ mv(from, at("method", "why", `${name}.md`));
980
+ note(` moved method/${name}.md → method/why/${name}.md`);
981
+ }
982
+ }
983
+ // 2. and 3. whole folders
984
+ for (const dir of ["document", "scripts"]) {
985
+ if (fs.existsSync(at(dir)) && !fs.existsSync(at("method", dir))) {
986
+ mv(at(dir), at("method", dir));
987
+ note(` moved ${dir}/ → method/${dir}/`);
988
+ }
989
+ }
990
+ // 4. the loose generic guides
991
+ for (const name of OLD_ROOT_GUIDES) {
992
+ const from = at(`${name}.md`);
993
+ if (fs.existsSync(from)) {
994
+ mv(from, at("method", `${name}.md`));
995
+ note(` moved ${name}.md → method/${name}.md`);
996
+ }
997
+ }
998
+ // 5. codebase/ was a product-owned room all along — it becomes flat files in the room that says so
999
+ for (const name of OLD_CODEBASE) {
1000
+ const from = at("codebase", `${name}-guide.md`);
1001
+ if (fs.existsSync(from)) {
1002
+ mv(from, at("project", `codebase-${name}-guide.md`));
1003
+ note(` moved codebase/${name}-guide.md → project/codebase-${name}-guide.md`);
1004
+ }
1005
+ }
1006
+ if (fs.existsSync(at("codebase"))) {
1007
+ const left = fs.readdirSync(at("codebase"));
1008
+ if (!left.length) fs.rmdirSync(at("codebase"));
1009
+ else note(` codebase/ still holds ${left.join(", ")} — left in place, move them yourself`);
1010
+ }
1011
+ // 6. The product's constitution.md moves WHOLE into the room, so its Articles 1, 2, and 5 survive
1012
+ // exactly as written. The generic half then arrives fresh at method/constitution.md.
1013
+ let split = null;
1014
+ if (fs.existsSync(at("constitution.md")) && !fs.existsSync(at("project", "constitution.md"))) {
1015
+ mv(at("constitution.md"), at("project", "constitution.md"));
1016
+ note(" moved constitution.md → project/constitution.md");
1017
+ split = splitProductConstitution(at("project", "constitution.md"));
1018
+ if (split && split.cut.length) {
1019
+ note(` kept Articles ${split.kept.join(", ")}, removed ${split.cut.join(", ")} `
1020
+ + "(the method's — they arrive in method/constitution.md)");
1021
+ if (split.relinked) note(` repointed ${split.relinked} relative links one level up`);
1022
+ } else if (split === null) {
1023
+ note(" it does not carry `## Article N` headings, so it was moved but NOT split — yours to check");
1024
+ }
1025
+ }
1026
+ // Anything else loose at the root is a file this product ADDED. It is NOT moved: it may be routed
1027
+ // from AGENTS.md by its current path, and guessing a destination would break that silently.
1028
+ const stray = fs.existsSync(c)
1029
+ ? fs.readdirSync(c, { withFileTypes: true })
1030
+ .filter((e) => e.isFile() && e.name.endsWith(".md"))
1031
+ .map((e) => e.name)
1032
+ : [];
1033
+ if (stray.length) {
1034
+ note(` left at .constitution/ root, yours to place: ${stray.join(", ")}`);
1035
+ note(" a file you added belongs in project/ — but moving it would break any pointer that");
1036
+ note(" names its current path, so the choice is yours. repo-guide.md states the rule.");
1037
+ }
1038
+ return split;
1039
+ }
1040
+
1041
+ function syncConstitution(target) {
1042
+ const kitConst = path.join(KIT, ".constitution");
1043
+ const destConst = path.join(target, ".constitution");
1044
+ fs.mkdirSync(destConst, { recursive: true });
1045
+ let written = 0;
1046
+ let skipped = 0;
1047
+ for (const file of walkFiles(kitConst)) {
1048
+ const rel = posixRel(kitConst, file);
1049
+ const dest = path.join(destConst, rel);
1050
+ // ONE rule for everything the product owns, because 0.5.0 put all of it in one folder. Before
1051
+ // that this loop had three branches — the mixed constitution.md kept whole, `codebase/` gated on
1052
+ // `status: Accepted` (which is what silently destroyed a half-written guide), and the room — and
1053
+ // the three disagreed about when a file was the product's. Seeded when absent, never written
1054
+ // again: the same rule as the language policy.
1055
+ // ONE file in the room is the package's and is refreshed like any method file: the room's own
1056
+ // README. It explains what the room is FOR and carries no product decision, so a stale copy does
1057
+ // not preserve anybody's work — it just misinforms. acme-billing-portal proved that: its copy
1058
+ // still pointed at `.constitution/codebase/*-guide.md`, a folder 0.5.0 deleted, and no update
1059
+ // would ever have corrected it while the file claimed in its own text to be "authored in the
1060
+ // package". Either the package writes it or it stops claiming authorship; this is the first.
1061
+ if (rel === `${PROJECT_ROOM}README.md`) {
1062
+ copyFile(file, dest);
1063
+ written += 1;
1064
+ continue;
1065
+ }
1066
+ if (rel.startsWith(PROJECT_ROOM) && fs.existsSync(dest)) {
1067
+ skipped += 1;
1068
+ note(`keep ${rel} (yours — the project room)`);
1069
+ continue;
1070
+ }
1071
+ copyFile(file, dest);
1072
+ written += 1;
1073
+ }
1074
+ return { written, skipped };
1075
+ }
1076
+
1077
+ function syncSkills(target, agents) {
1078
+ let n = 0;
1079
+ const dests = skillDestinations(target, agents);
1080
+ if (dests.length === 0) {
1081
+ note("no skill destinations for selected platforms — AGENTS.md still applies");
1082
+ return { files: 0, removed: 0 };
1083
+ }
1084
+ for (const name of WDI_SKILLS) {
1085
+ const src = path.join(KIT, "skills", name);
1086
+ if (!fs.existsSync(src)) die(`kit missing skill ${name}`);
1087
+ for (const root of dests) {
1088
+ const dest = path.join(root, name);
1089
+ fs.rmSync(dest, { recursive: true, force: true });
1090
+ n += copyTree(src, dest);
1091
+ }
1092
+ }
1093
+ const removed = pruneRetiredSkills(dests);
1094
+ return { files: n, removed };
1095
+ }
1096
+
1097
+ // A wrapper the method RETIRED is worse than a wrapper missing: the folder is still there, its
1098
+ // SKILL.md still reads like an instruction, and an agent will invoke it — while the guide it points
1099
+ // at is gone. Renaming five wrappers (wdi-apply, wdi-analysis, wdi-structure, …) left exactly that
1100
+ // in every repo installed before the rename, because update only ever touched the names it knows.
1101
+ //
1102
+ // `wdi-` is the method's namespace, so a `wdi-*` folder carrying a SKILL.md and not in WDI_SKILLS is
1103
+ // ours and retired. Each removal is PRINTED: silent deletion in someone else's repo is not a fix.
1104
+ function pruneRetiredSkills(dests) {
1105
+ let removed = 0;
1106
+ const keep = new Set(WDI_SKILLS);
1107
+ for (const root of dests) {
1108
+ if (!fs.existsSync(root)) continue;
1109
+ for (const entry of fs.readdirSync(root, { withFileTypes: true })) {
1110
+ if (!entry.isDirectory() || !entry.name.startsWith("wdi-") || keep.has(entry.name)) continue;
1111
+ const dir = path.join(root, entry.name);
1112
+ if (!fs.existsSync(path.join(dir, "SKILL.md"))) {
1113
+ note(`kept ${entry.name} (no SKILL.md — not one of ours)`);
1114
+ continue;
1115
+ }
1116
+ fs.rmSync(dir, { recursive: true, force: true });
1117
+ note(`removed retired skill ${entry.name}`);
1118
+ removed += 1;
1119
+ }
1120
+ }
1121
+ return removed;
1122
+ }
1123
+
1124
+ // `promote` scrubs a product's initiative slug out of bmad-prd.toml before publishing, which is right.
1125
+ // Writing the scrubbed PLACEHOLDER back into a product repo is not: the first real install replaced a
1126
+ // live `run_folder_pattern = "some-real-slug"` with `FILL-initiative-slug`, and nothing said so. A value
1127
+ // the product already chose is not the installer's to overwrite — same rule as the custom room and the
1128
+ // language policy.
1129
+ const PLACEHOLDER_SLUG = "FILL-initiative-slug";
1130
+ const RUN_FOLDER_LINE = /^(\s*run_folder_pattern\s*=\s*)(".*?"|'.*?')/m;
1131
+
1132
+ // The slug appears MORE THAN ONCE — bmad-prd.toml carries it in `run_folder_pattern` and again inside a
1133
+ // memlog path, and the file itself says the two lines MUST change together. The first version of this
1134
+ // function restored only the first line and so produced exactly the inconsistency that file forbids.
1135
+ // So: read the product's slug once, then put it back everywhere the placeholder appears.
1136
+ function keepProductSlug(incoming, existing) {
1137
+ const mineNow = existing.match(RUN_FOLDER_LINE);
1138
+ if (!mineNow) return null;
1139
+ const slug = mineNow[2].slice(1, -1);
1140
+ if (!slug || slug === PLACEHOLDER_SLUG) return null;
1141
+ if (!incoming.includes(PLACEHOLDER_SLUG)) return null;
1142
+ // Only where the slug is a VALUE: the quoted setting, and the memlog path built from it. A bare
1143
+ // mention inside a comment stays the placeholder — that sentence explains the pattern, and rewriting
1144
+ // it would turn a generic explanation into a statement about one initiative.
1145
+ return incoming
1146
+ .replaceAll(`"${PLACEHOLDER_SLUG}"`, `"${slug}"`)
1147
+ .replaceAll(`prd-${PLACEHOLDER_SLUG}`, `prd-${slug}`);
1148
+ }
1149
+
1150
+ function syncTomls(target) {
1151
+ const src = path.join(KIT, "assets", "bmad-custom");
1152
+ const dest = path.join(target, "_bmad", "custom");
1153
+ fs.mkdirSync(dest, { recursive: true });
1154
+ let n = 0;
1155
+ let slugsKept = 0;
1156
+ for (const file of walkFiles(src)) {
1157
+ if (!file.endsWith(".toml") || file.endsWith(".user.toml")) continue;
1158
+ const to = path.join(dest, path.basename(file));
1159
+ if (fs.existsSync(to)) {
1160
+ const merged = keepProductSlug(fs.readFileSync(file, "utf8"), fs.readFileSync(to, "utf8"));
1161
+ if (merged !== null) {
1162
+ fs.writeFileSync(to, merged);
1163
+ note(`kept run_folder_pattern in ${path.basename(file)}`);
1164
+ slugsKept += 1;
1165
+ n += 1;
1166
+ continue;
1167
+ }
1168
+ }
1169
+ copyFile(file, to);
1170
+ n += 1;
1171
+ }
1172
+ return { files: n, slugsKept };
1173
+ }
1174
+
1175
+ // The same argument pruneRetiredSkills makes, one folder over — with one difference that changes
1176
+ // the rule. `wdi-` is this method's namespace, so "a wdi-* folder not in WDI_SKILLS" is safely ours.
1177
+ // `_bmad/custom/` is NOT: a product may put its own override there, and `.user.toml` is the
1178
+ // product's half of every override by convention. So removal here is by an EXPLICIT list of files
1179
+ // this package once shipped and has now withdrawn — never by "absent from the kit".
1180
+ //
1181
+ // Why remove them at all: an override for a retired engine is worse than no override. It is still
1182
+ // installed and still read, and bmad-retrospective.toml instructs an agent to archive an `RTR-`
1183
+ // against a validator, V19, that no longer exists.
1184
+ const RETIRED_TOMLS = [
1185
+ "bmad-spec.toml", "bmad-build.toml", "bmad-build-auto.toml",
1186
+ "bmad-code-review.toml", "bmad-retrospective.toml",
1187
+ ];
1188
+
1189
+ function pruneRetiredTomls(target) {
1190
+ const dir = path.join(target, "_bmad", "custom");
1191
+ if (!fs.existsSync(dir)) return 0;
1192
+ let removed = 0;
1193
+ for (const name of RETIRED_TOMLS) {
1194
+ const file = path.join(dir, name);
1195
+ if (!fs.existsSync(file)) continue;
1196
+ fs.rmSync(file);
1197
+ note(`removed retired override ${name}`);
1198
+ removed += 1;
1199
+ }
1200
+ return removed;
1201
+ }
1202
+
1203
+ function seedControlIfMissing(target) {
1204
+ const control = path.join(target, ".control");
1205
+ if (fs.existsSync(control)) {
1206
+ note(".control/ already present — left untouched");
1207
+ return;
1208
+ }
1209
+ if (!fs.existsSync(SCAFFOLD)) die(`scaffold missing: ${SCAFFOLD}`);
1210
+ const n = copyTree(SCAFFOLD, control);
1211
+ ok(`seeded empty .control/ (${n} files)`);
1212
+ }
1213
+
1214
+ // On a FIRST install these folders are the corpus taking shape. On an UPDATE their absence means
1215
+ // somebody removed them on purpose — `.work/` and `_bmad-output/prior-knowledge/` are exactly the two a
1216
+ // product retires once its migration is done, and one repo retired them through an applied decision.
1217
+ // Recreating them then is an installer overruling a decision it cannot read. Seed once, never resurrect.
1218
+ function seedEmptyLayers(target, { first }) {
1219
+ const always = [".what", path.join(".how", "_platform")];
1220
+ const firstOnly = [".work", path.join("_bmad-output", "prior-knowledge")];
1221
+ for (const rel of first ? [...always, ...firstOnly] : always) {
1222
+ const dest = path.join(target, rel);
1223
+ if (!fs.existsSync(dest)) {
1224
+ fs.mkdirSync(dest, { recursive: true });
1225
+ // Git tracks files, not directories: an empty folder does not reach the next clone. The
1226
+ // scaffold already puts a `.gitkeep` in each of its empty rooms, and these four were the
1227
+ // exception — `.work/` invisible from birth is half the reason a bootstrap read it as
1228
+ // ignorable and wrote it into `.gitignore`, which corpus-in-git now reports.
1229
+ fs.writeFileSync(path.join(dest, ".gitkeep"), "");
1230
+ note(`created ${rel.replaceAll(path.sep, "/")}/`);
1231
+ }
1232
+ }
1233
+ if (!first) {
1234
+ for (const rel of firstOnly) {
1235
+ if (!fs.existsSync(path.join(target, rel))) {
1236
+ note(`left ${rel.replaceAll(path.sep, "/")}/ absent — a product retires it, not the installer`);
1237
+ }
1238
+ }
1239
+ }
1240
+ }
1241
+
1242
+ function writeStamp(target) {
1243
+ const control = path.join(target, ".control");
1244
+ if (!fs.existsSync(control)) return;
1245
+ const eng = enginesReport(target);
1246
+ const fp = engineFingerprints(target);
1247
+ const names = Object.keys(fp);
1248
+ const lines = [
1249
+ "# Written by wdi-method install/update. A trace, not a lockfile.",
1250
+ `wdi_method: ${PKG.version}`,
1251
+ `bmad_method: ${readBmadVersion(target) || '""'}`,
1252
+ ];
1253
+ if (names.length) {
1254
+ const blocked = engineInvocationState(target).blocked;
1255
+ lines.push("engines:");
1256
+ lines.push(" source: local");
1257
+ lines.push(" package: mattpocock/skills");
1258
+ lines.push(` lock: ${fs.existsSync(path.join(target, ENGINE_LOCK)) ? ENGINE_LOCK : '""'}`);
1259
+ lines.push(` model_invocation: ${blocked.length ? "blocked" : "enabled"}`);
1260
+ if (eng.missing.length) lines.push(` missing: [${eng.missing.join(", ")}]`);
1261
+ lines.push(" # sha256 of each SKILL.md AFTER the flag was stripped, first 12. Informational:");
1262
+ lines.push(" # engines-invocable decides by looking for the flag, not by comparing these.");
1263
+ lines.push(" skills:");
1264
+ for (const name of names) lines.push(` ${name}: ${fp[name]}`);
1265
+ } else {
1266
+ lines.push("engines:");
1267
+ lines.push(" source: none # none in this repo — G5 cannot run until they are installed");
1268
+ }
1269
+ lines.push(`installed_at: ${today()}`);
1270
+ lines.push("");
1271
+ const stamp = lines.join("\n");
1272
+ fs.writeFileSync(path.join(control, "wdi-method.yaml"), stamp, "utf8");
1273
+ note("stamped .control/wdi-method.yaml");
1274
+ }
1275
+
1276
+ function setProductIdentity(target, { name, client }) {
1277
+ if (!name || identityIsPlaceholder(name)) return;
1278
+ const file = path.join(target, ".control", "registry", "index.yaml");
1279
+ if (!fs.existsSync(file)) return;
1280
+ const next = writeProductIdentity(fs.readFileSync(file, "utf8"), {
1281
+ name,
1282
+ client: client ?? "",
1283
+ });
1284
+ fs.writeFileSync(file, next.endsWith("\n") ? next : `${next}\n`);
1285
+ note(`product.name = ${name}`);
1286
+ }
1287
+
1288
+ // The document language belongs to the PRODUCT, so update MUST NOT overwrite it. It is written only
1289
+ // when absent — same as the custom room, and for the same reason: a setting somebody already chose
1290
+ // is not the installer's to change behind their back.
1291
+ function setLanguagePolicy(target, { docLanguage, docFilenameLanguage, chosen }) {
1292
+ const file = path.join(target, ".control", "registry", "index.yaml");
1293
+ if (!fs.existsSync(file)) return;
1294
+ const text = fs.readFileSync(file, "utf8");
1295
+ const existing = readLanguagePolicy(text);
1296
+ // `chosen` means somebody actually answered — in the TUI, or through an explicit flag. Only then
1297
+ // does the answer take effect. Without it the incoming value is just a default, and a default
1298
+ // MUST NOT overwrite a choice somebody already made.
1299
+ if (!chosen && existing.docLanguage && existing.docFilenameLanguage) {
1300
+ note(`kept policy.doc_language = ${existing.docLanguage}, ` +
1301
+ `doc_filename_language = ${existing.docFilenameLanguage}`);
1302
+ return;
1303
+ }
1304
+ const next = writeLanguagePolicy(text, {
1305
+ docLanguage: docLanguage || existing.docLanguage || DEFAULT_DOC_LANGUAGE,
1306
+ docFilenameLanguage:
1307
+ docFilenameLanguage || existing.docFilenameLanguage || DEFAULT_DOC_LANGUAGE,
1308
+ });
1309
+ fs.writeFileSync(file, next.endsWith("\n") ? next : `${next}\n`);
1310
+ const after = readLanguagePolicy(next);
1311
+ note(`policy.doc_language = ${after.docLanguage}, ` +
1312
+ `doc_filename_language = ${after.docFilenameLanguage}`);
1313
+ }
1314
+
1315
+ // After `update`, some of the corpus can still be in the OLD shape — content the installer MUST NOT
1316
+ // move, because moving it takes a decision about meaning: which PRD an `FR` belongs to, whether a
1317
+ // sentence was an assumption or a constraint. The `wdi-upgrade` skill does that half. This only
1318
+ // DETECTS it, cheaply, so the summary can say how much is waiting and where.
1319
+ /** Specs whose folder is not where the convention puts it — and that are still WORK.
1320
+ *
1321
+ * A closed spec is exempt, and one measured repo is why: ten closed specs, none open. Reporting all
1322
+ * ten would ask somebody to move ten folders of finished work and repoint every cite into them, for
1323
+ * nothing — `spec_folder` still resolves, and a closed spec's ticket file is already allowed to be
1324
+ * gone. The same exemption `ticket-status-one-home` grants, for the same reason: the convention binds
1325
+ * work, not the record of work that is done.
1326
+ *
1327
+ * Scanned line by line rather than parsed: this installer has no YAML reader, and both the flat
1328
+ * `specs:` shape and the pre-rename `waves:` one open a row the same way.
1329
+ */
1330
+ function specsOutsideScratch(text) {
1331
+ const out = [];
1332
+ let id = "";
1333
+ let status = "";
1334
+ let folder = "";
1335
+ const flush = () => {
1336
+ if (id && folder && status !== "closed" && !folder.startsWith(".scratch/")) out.push(id);
1337
+ id = "";
1338
+ status = "";
1339
+ folder = "";
1340
+ };
1341
+ for (const line of text.split(/\r?\n/)) {
1342
+ const row = /^\s{2}-\s+id:\s*(\S+)/.exec(line);
1343
+ if (row) {
1344
+ flush();
1345
+ id = row[1].replace(/['"]/g, "");
1346
+ continue;
1347
+ }
1348
+ if (!id) continue;
1349
+ const st = /^\s+status:\s*(\S+)/.exec(line);
1350
+ if (st && !status) status = st[1].replace(/['"]/g, "");
1351
+ const sf = /^\s+spec_folder:\s*(\S+)/.exec(line);
1352
+ if (sf && !folder) folder = sf[1].replace(/['"]/g, "");
1353
+ }
1354
+ flush();
1355
+ return out;
1356
+ }
1357
+
1358
+ /** Specs still in the pre-rename plan shape that are NOT closed — the ones with work left in them.
1359
+ *
1360
+ * Same scanner shape as `specsOutsideScratch`, and the same exemption for the same reason: the
1361
+ * convention binds work, not the record of work that is done.
1362
+ */
1363
+ function specsInLegacyShape(text) {
1364
+ const out = [];
1365
+ let id = "";
1366
+ let status = "";
1367
+ let legacy = false;
1368
+ const flush = () => {
1369
+ if (id && legacy && status !== "closed") out.push(id);
1370
+ id = "";
1371
+ status = "";
1372
+ legacy = false;
1373
+ };
1374
+ for (const line of text.split(/\r?\n/)) {
1375
+ const row = /^\s{2}-\s+id:\s*(\S+)/.exec(line);
1376
+ if (row) {
1377
+ flush();
1378
+ id = row[1].replace(/['"]/g, "");
1379
+ if (/^W\d+$/.test(id)) legacy = true;
1380
+ continue;
1381
+ }
1382
+ if (!id) continue;
1383
+ const st = /^\s+status:\s*(\S+)/.exec(line);
1384
+ if (st && !status) status = st[1].replace(/['"]/g, "");
1385
+ if (/^\s+(epics|stories):/.test(line)) legacy = true;
1386
+ }
1387
+ flush();
1388
+ return out;
1389
+ }
1390
+
1391
+ function pendingUpgrades(target) {
1392
+ const has = (...p) => fs.existsSync(path.join(target, ...p));
1393
+ const read = (...p) => (has(...p) ? fs.readFileSync(path.join(target, ...p), "utf8") : "");
1394
+ const anyIn = (dir, glob, re) => {
1395
+ const d = path.join(target, dir);
1396
+ if (!fs.existsSync(d)) return false;
1397
+ return fs.readdirSync(d).some((n) => {
1398
+ const f = path.join(d, n, glob);
1399
+ return fs.existsSync(f) && re.test(fs.readFileSync(f, "utf8"));
1400
+ });
1401
+ };
1402
+ const items = [];
1403
+ if (has(".control", "registry", "requirements.yaml")) items.push("requirements.yaml → goals.yaml + requirements-<slug>.yaml");
1404
+ // The file the engines actually read. `/setup-matt-pocock-skills` writes its own answer here — no
1405
+ // `specs.yaml`, no predefined path — and `seedAgentDocs` will not overwrite a file the product owns,
1406
+ // so without this probe the repo never learns why its tickets scatter.
1407
+ if (has("docs", "agents", "issue-tracker.md")
1408
+ && !read("docs", "agents", "issue-tracker.md").includes("seeded by `wdi-method`")) {
1409
+ items.push("docs/agents/issue-tracker.md is not the method's answer (npx wdi-method engines --fix)");
1410
+ }
1411
+ const strays = specsOutsideScratch(read(".control", "registry", "specs.yaml"));
1412
+ if (strays.length) {
1413
+ items.push(`spec_folder outside .scratch/<spec-id>-<slug>/ on ${strays.join(", ")} `
1414
+ + `(the folder moves, then its cites)`);
1415
+ }
1416
+ // Reported only where it is still WORK. A closed pre-rename wave is read correctly (0.6.7 taught
1417
+ // `Corpus.tickets()` to flatten `epics`/`stories` in memory), its `W<n>` id is a retired alias by
1418
+ // design, and its ticket files are already allowed to be gone. Nothing there is waiting to move.
1419
+ //
1420
+ // Until 0.6.11 this fired on every legacy row and pointed at `wdi-build` to "re-cut" it. That
1421
+ // instruction outlived the design it came from: `wdi-build` Phase 2 invokes `to-spec`/`to-tickets`
1422
+ // to write a NEW contract and publish new tickets, and has no mode that converts an old wave.
1423
+ // Three repos carrying twenty, forty-five and ten closed legacy rows were each told to run a skill
1424
+ // that would answer "not mine" and stop.
1425
+ const legacyOpen = specsInLegacyShape(read(".control", "registry", "specs.yaml"));
1426
+ if (legacyOpen.length) {
1427
+ items.push(`${legacyOpen.join(", ")} still in the W<n>/epics/stories shape and not closed `
1428
+ + `(flattened into tickets, id kept as its retired alias)`);
1429
+ }
1430
+ if (/^## (Executive Summary|Vision|Assumptions|Prerequisites)\s*$/m.test(read(".what", "_product-brief", "brief.md"))) items.push("brief.md in the 14-section shape");
1431
+ // Sections by NAME: the numbers moved between kits (Non-Goals was §7 in one, §5 in the next).
1432
+ if (anyIn(".what/_prd", "prd.md", /^## (\d+\.\s*)?(Document Purpose|Glossary|Non-Goals|Open Questions|Assumptions Index)\b|\*\*Proof of done:\*\*/m)) items.push("a prd.md in the 12-section shape, or with FR blocks");
1433
+ const whatDir = path.join(target, ".what");
1434
+ if (fs.existsSync(whatDir)) {
1435
+ for (const pc of fs.readdirSync(whatDir)) {
1436
+ if (pc.startsWith("_")) continue;
1437
+ const srs = read(".what", pc, `SRS-${pc}.md`);
1438
+ if (/^\|\s*UC-\d+\s*\|/m.test(srs)) { items.push("an SRS with a UC Catalogue table (now a pointer)"); break; }
1439
+ }
1440
+ }
1441
+ const howDir = path.join(target, ".how");
1442
+ if (fs.existsSync(howDir)) {
1443
+ for (const pc of fs.readdirSync(howDir)) {
1444
+ if (pc.startsWith("_")) continue;
1445
+ if (/\|\s*Quoted rule\s*\||Quoted verbatim from/.test(read(".how", pc, `SDD-${pc}.md`))) { items.push("an SDD quoting AD-N text (now ids only)"); break; }
1446
+ }
1447
+ }
1448
+ if (/\|\s*Container\s*\|\s*Product Components living in it\s*\|/.test(read(".how", "_platform", "c4-l2-containers.md"))) items.push("c4-l2 with a PC x container table (now a pointer)");
1449
+ if (has(".control", "generated", "brief.md") || has(".control", "generated", "blueprint.md")) items.push("human pages still in .control/generated/ (render clears them)");
1450
+ if (has(".what", "_product-brief", "brief.md") && !has(".what-rendered")) items.push("no .what-rendered/ yet (render creates it)");
1451
+ // Skipped: what the validator never reads (kit copies, rendered output, dependencies) and what it
1452
+ // treats as a record of the PAST — memlog, decisions, reports, _bmad-output. A stale path in a log
1453
+ // is history, not a finding, and repointing it would falsify the record.
1454
+ const SKIP = new Set([".git", "node_modules", "target", ".constitution", ".claude", ".agents", ".agent",
1455
+ ".what-rendered", ".how-rendered", "dist", "build", "memlog", "decisions", "reports", "meetings", "_bmad-output", ".work"]);
1456
+ const OLD_PAGE = /\.control\/generated\/(brief|blueprint|prd-[a-z0-9-]+)\.md/;
1457
+ const citesOldPage = (dir, depth) => {
1458
+ if (depth > 8) return false;
1459
+ for (const e of fs.readdirSync(dir, { withFileTypes: true })) {
1460
+ if (e.isDirectory()) { if (!SKIP.has(e.name) && citesOldPage(path.join(dir, e.name), depth + 1)) return true; continue; }
1461
+ if (e.name === "answered.md") continue;
1462
+ if (e.name.endsWith(".md") && OLD_PAGE.test(fs.readFileSync(path.join(dir, e.name), "utf8"))) return true;
1463
+ }
1464
+ return false;
1465
+ };
1466
+ if (citesOldPage(target, 0)) items.push("a document cites .control/generated/brief|blueprint|prd-*.md (pages moved to the rendered trees)");
1467
+ return items;
1468
+ }
1469
+
1470
+ // Read BEFORE writeStamp overwrites it. Without this there is no version transition to print, and
1471
+ // an "updated" with no from-to tells the reader nothing they can use.
1472
+ function readStampVersion(target) {
1473
+ const file = path.join(target, ".control", "wdi-method.yaml");
1474
+ if (!fs.existsSync(file)) return "";
1475
+ const m = fs.readFileSync(file, "utf8").match(/^wdi_method:\s*"?([^"\s]+)"?/m);
1476
+ return m ? m[1] : "";
1477
+ }
1478
+
1479
+ function readIndexPolicy(target) {
1480
+ const file = path.join(target, ".control", "registry", "index.yaml");
1481
+ if (!fs.existsSync(file)) return { docLanguage: "", docFilenameLanguage: "" };
1482
+ return readLanguagePolicy(fs.readFileSync(file, "utf8"));
1483
+ }
1484
+
1485
+ function readIndexIdentity(target) {
1486
+ const file = path.join(target, ".control", "registry", "index.yaml");
1487
+ if (!fs.existsSync(file)) return { name: "", client: "" };
1488
+ return readProductIdentity(fs.readFileSync(file, "utf8"));
1489
+ }
1490
+
1491
+ function upsertAgentFiles(target, platforms, productName) {
1492
+ const template = fs.readFileSync(path.join(OVERLAY, "AGENTS.md"), "utf8");
1493
+ const agentsFile = path.join(target, "AGENTS.md");
1494
+ let next;
1495
+ if (!fs.existsSync(agentsFile)) {
1496
+ next = fillProductTitle(template, productName || "{product}");
1497
+ ok("AGENTS.md created — rewrite ## Code for this product");
1498
+ } else {
1499
+ next = upsertMethodBlock(fs.readFileSync(agentsFile, "utf8"), template);
1500
+ note("AGENTS.md method block refreshed; product sections kept");
1501
+ }
1502
+ if (!next.endsWith("\n")) next += "\n";
1503
+ fs.writeFileSync(agentsFile, next);
1504
+
1505
+ const mirrors = [];
1506
+ if (platformUsesHook(platforms, "cursorrules")) {
1507
+ mirrors.push(path.join(target, ".cursorrules"));
1508
+ }
1509
+ if (platformUsesHook(platforms, "agents-mirror")) {
1510
+ mirrors.push(path.join(target, ".agents", "AGENTS.md"));
1511
+ }
1512
+ for (const mirror of mirrors) {
1513
+ fs.mkdirSync(path.dirname(mirror), { recursive: true });
1514
+ if (fs.existsSync(mirror)) {
1515
+ const patched = upsertMethodBlock(fs.readFileSync(mirror, "utf8"), template);
1516
+ fs.writeFileSync(mirror, patched.endsWith("\n") ? patched : `${patched}\n`);
1517
+ note(`method block refreshed in ${posixRel(target, mirror)}`);
1518
+ } else {
1519
+ fs.writeFileSync(mirror, next);
1520
+ note(`created ${posixRel(target, mirror)}`);
1521
+ }
1522
+ }
1523
+
1524
+ if (platformUsesHook(platforms, "claude-md")) {
1525
+ const claude = path.join(target, "CLAUDE.md");
1526
+ if (!fs.existsSync(claude) || fs.readFileSync(claude, "utf8").trim() === "@AGENTS.md") {
1527
+ fs.writeFileSync(claude, next);
1528
+ note("CLAUDE.md synchronized from AGENTS.md");
1529
+ } else {
1530
+ const patched = upsertMethodBlock(fs.readFileSync(claude, "utf8"), template);
1531
+ fs.writeFileSync(claude, patched.endsWith("\n") ? patched : `${patched}\n`);
1532
+ note("method block refreshed in CLAUDE.md");
1533
+ }
1534
+ }
1535
+ }
1536
+
1537
+ // What a run MUST leave a reader able to answer: which version replaced which, what was written, what
1538
+ // was KEPT, and what to do next. The third is the one usually missing, and it is the one that decides
1539
+ // whether somebody trusts running this over a repo they have already put work into.
1540
+ function summaryLine(label, value) {
1541
+ console.log(` ${DIM}${label.padEnd(11)}${RESET}${value}`);
1542
+ }
1543
+
1544
+ function printSummary(target, agents, { first, was, written, skipped, skills, tomls, opencodeCmds }) {
1545
+ const now = PKG.version;
1546
+ const version = first
1547
+ ? `${now} — first install`
1548
+ : was && was !== now
1549
+ ? `${was} ${DIM}→${RESET} ${now}`
1550
+ : `${now} ${DIM}(unchanged)${RESET}`;
1551
+ const bmad = readBmadVersion(target);
1552
+
1553
+ const kept = [];
1554
+ if (skipped) kept.push(`${skipped} constitution file${skipped === 1 ? "" : "s"}`);
1555
+ if (tomls.slugsKept) kept.push(`${tomls.slugsKept} initiative slug${tomls.slugsKept === 1 ? "" : "s"}`);
1556
+ // On a first install the language was just CHOSEN, not kept — saying "kept" there reads as if the
1557
+ // installer had found something it decided to leave alone, which is the opposite of what happened.
1558
+ const policy = readIndexPolicy(target);
1559
+ if (policy.docLanguage && !first) kept.push(`language (${policy.docLanguage})`);
1560
+ if (fs.existsSync(path.join(target, ".constitution", "project"))) kept.push(".constitution/project/");
1561
+
1562
+ console.log("");
1563
+ console.log(`${DIM}────${RESET} WDI Method ${DIM}${"─".repeat(46)}${RESET}`);
1564
+ summaryLine("version", version);
1565
+ if (bmad) summaryLine("bmad", bmad);
1566
+ summaryLine("target", target);
1567
+ console.log("");
1568
+ summaryLine("written", `${written} constitution · ${skills.files} skill files · ${tomls.files} bmad overrides`
1569
+ + (opencodeCmds?.written ? ` · ${opencodeCmds.written} opencode commands` : ""));
1570
+ if (kept.length) summaryLine("kept", kept.join(" · "));
1571
+ const gone = [];
1572
+ if (skills.removed) gone.push(`${skills.removed} retired wrapper${skills.removed === 1 ? "" : "s"}`);
1573
+ if (tomls.removed) gone.push(`${tomls.removed} retired override${tomls.removed === 1 ? "" : "s"}`);
1574
+ if (gone.length) summaryLine("removed", gone.join(" · "));
1575
+ if (first && policy.docLanguage) {
1576
+ summaryLine("language", `${policy.docLanguage} · filenames ${policy.docFilenameLanguage}`);
1577
+ }
1578
+ summaryLine("platforms", agents.join(", ") || "none");
1579
+ console.log("");
1580
+ // The readers are the one seeded file that does nothing until somebody writes it, and its
1581
+ // silence is expensive: inventory.py refuses to run and the reason is a folder deep. One line
1582
+ // here, only while it is still the skeleton, so it stops appearing once it is done.
1583
+ if (readersAreSkeleton(target)) {
1584
+ summaryLine("todo", `${DIM}.constitution/project/inventory-readers.py${RESET} is a skeleton — ` +
1585
+ `run the ${INIT_SKILL} skill, intent ${DIM}readers${RESET}, ` +
1586
+ `to write it for this repo's stack`);
1587
+ }
1588
+ const engReport = enginesReport(target);
1589
+ summaryLine("engines", engReport.present
1590
+ ? `${ENGINE_SKILLS.join(" · ")} — found (in this repo)`
1591
+ : `NOT found: ${engReport.missing.join(" · ")}. G5 (wdi-build) and the Fast Path need them; G1–G4 run without them`);
1592
+ if (!engReport.present) {
1593
+ summaryLine("", `${DIM}·${RESET} into THIS repo: ${DIM}${ENGINES_INSTALL_ANY}${RESET} — a user-level plugin does not count`);
1594
+ summaryLine("", `${DIM}·${RESET} docs/agents/ is already seeded, so ${DIM}${ENGINES_SETUP}${RESET} is not needed · ${ENGINES_REPO}`);
1595
+ } else {
1596
+ const blocked = engineInvocationState(target).blocked;
1597
+ if (blocked.length) {
1598
+ summaryLine("", `${DIM}·${RESET} still flagged, so no skill can invoke ${blocked.join(" · ")} — run ${DIM}npx wdi-method engines --fix${RESET}`);
1599
+ }
1600
+ }
1601
+ // Upstream's own warning: "installing both leaves you with every skill twice." It is survivable —
1602
+ // the plugin's copies are namespaced and still flagged, so they can neither be invoked nor shadow
1603
+ // the repo's — but `/to-spec` in the UI stops being one thing, so it is said out loud.
1604
+ if (pluginEnginesRegistered()) {
1605
+ summaryLine("", `${DIM}·${RESET} the ${ENGINES_PLUGIN} plugin is ALSO installed for this user — the repo's copies are what run;`);
1606
+ summaryLine("", `${DIM}·${RESET} remove the plugin to keep ${DIM}/to-spec${RESET} unambiguous`);
1607
+ }
1608
+ const pending = first ? [] : pendingUpgrades(target);
1609
+ if (pending.length) {
1610
+ summaryLine("upgrade", `${pending.length} item${pending.length === 1 ? "" : "s"} still in the OLD shape — ` +
1611
+ `run the ${DIM}wdi-upgrade${RESET} skill; it moves content, never invents it`);
1612
+ for (const item of pending) summaryLine("", `${DIM}·${RESET} ${item}`);
1613
+ }
1614
+ summaryLine("next", pending.length
1615
+ ? `run the ${DIM}wdi-upgrade${RESET} skill first, then ${HELP_SKILL}`
1616
+ : `invoke the ${HELP_SKILL} skill and ask what to do`);
1617
+ summaryLine("", REPO_URL);
1618
+ console.log(`${DIM}${"─".repeat(62)}${RESET}`);
1619
+ }
1620
+
1621
+ function printNextSteps({ first, productSet, upgradePending }) {
1622
+ console.log("");
1623
+ console.log(first ? "After install:" : "After update:");
1624
+ if (first) {
1625
+ if (!productSet) {
1626
+ console.log(" 1. Fill product.name (and product.client if there is one) in .control/registry/index.yaml.");
1627
+ } else {
1628
+ console.log(" 1. product.name is set. G1 confirms it in the brief.");
1629
+ }
1630
+ console.log(" 2. Rewrite .constitution/constitution.md Articles 2 and 5 for this product.");
1631
+ console.log(" Article 1 cites index.yaml — do not become a second source for the name.");
1632
+ console.log(" 3. Write ## Code in AGENTS.md (where the app lives). Leave the BEGIN:wdi-method block alone.");
1633
+ console.log(" 4. Run the wdi-init skill, intent setup.");
1634
+ console.log(" 5. Sort the documents you already have. Do not move any of them in this step.");
1635
+ console.log("");
1636
+ console.log("Next update:");
1637
+ console.log(" npx wdi-method");
1638
+ console.log(" (the TUI offers the update) or: npx wdi-method update --yes");
1639
+ } else {
1640
+ console.log(" 1. The <!-- BEGIN:wdi-method --> block in AGENTS.md was replaced. Read the diff.");
1641
+ console.log(" 2. constitution.md Articles 1-2-5, ## Code, and *.user.toml were not overwritten.");
1642
+ console.log(" 3. If BMad has new skills, install those first, then run this update again.");
1643
+ if (upgradePending) {
1644
+ console.log(" 4. The summary listed an `upgrade` line: run the wdi-upgrade skill before any other skill.");
1645
+ console.log(" It moves content into the new shape and never invents any; one commit.");
1646
+ }
1647
+ }
1648
+ }
1649
+
1650
+ function apply(target, agents,
1651
+ { first, product, client, docLanguage, docFilenameLanguage, languageChosen }) {
1652
+ requireKit();
1653
+ const was = readStampVersion(target);
1654
+ // MUST run before the kit is written: it moves the product's files out of the way of paths the kit
1655
+ // is about to occupy. Running it after would leave two copies of most guides.
1656
+ const migrated = migrateToTwoFolders(target);
1657
+ migrateRegistryNames(target);
1658
+ migrateAutopilotLedgers(target);
1659
+ warnStaleMandates(target);
1660
+ seedAgentDocs(target);
1661
+ warnStaleAgentDocs(target);
1662
+ seedRequirementSplit(target);
1663
+ seedDailyTierScaffold(target);
1664
+ // The split MUST also be reachable without a migration. 0.5.2 only ran it from inside
1665
+ // migrateToTwoFolders, which returns early when the old layout is absent — so a repo that took
1666
+ // 0.5.0 or 0.5.1, whose project/constitution.md was moved WHOLE and never split, could never be
1667
+ // fixed by any later update. That is precisely the repo that needs it. Running it here on every
1668
+ // update closes that, and it is idempotent: after a split there are no method articles left to cut.
1669
+ const lateSplit = splitProductConstitution(path.join(target, ".constitution", "project",
1670
+ "constitution.md"));
1671
+ if (!migrated && lateSplit && lateSplit.cut.length) {
1672
+ note(`project/constitution.md still carried Articles ${lateSplit.cut.join(", ")} — removed`);
1673
+ note(` they are the method's and live in method/constitution.md; kept ${lateSplit.kept.join(", ")}`);
1674
+ if (lateSplit.relinked) note(` repointed ${lateSplit.relinked} relative links`);
1675
+ }
1676
+ const splitConstitution = migrated;
1677
+ const { written, skipped } = syncConstitution(target);
1678
+ note(`constitution wrote ${written}, kept ${skipped}`);
1679
+ // A migrated repo also carries derived output stamped against the OLD layout: .control/generated/*
1680
+ // still names the pre-0.5.0 script path, and the two structure maps still draw the old tree. The
1681
+ // installer MUST NOT write either — one is generated, the other is re-derived by a skill — so it
1682
+ // says so instead of leaving them to be found by whoever trusts them next.
1683
+ if (splitConstitution) {
1684
+ note(" derived output still describes the OLD layout, and neither is mine to write:");
1685
+ note(" uv run .constitution/method/scripts/validate.py --generate → .control/generated/");
1686
+ note(" then the wdi-init skill, intent `structure` → the two structure maps");
1687
+ }
1688
+ const skills = syncSkills(target, agents);
1689
+ note(`skills ${skills.files} files`);
1690
+ let opencodeCmds = { written: 0, removed: 0 };
1691
+ if (platformUsesHook(agents, "opencode-commands")) {
1692
+ opencodeCmds = syncOpencodeCommands(target, WDI_SKILLS, path.join(KIT, "skills"));
1693
+ note(`opencode commands ${opencodeCmds.written} files → ${opencodeCommandsDir()}/`);
1694
+ if (opencodeCmds.removed) {
1695
+ note(`removed ${opencodeCmds.removed} retired opencode command${opencodeCmds.removed === 1 ? "" : "s"}`);
1696
+ }
1697
+ }
1698
+ const tomls = syncTomls(target);
1699
+ tomls.removed = pruneRetiredTomls(target);
1700
+ note(`bmad custom ${tomls.files} toml → _bmad/custom/`);
1701
+ if (first) seedControlIfMissing(target);
1702
+ seedEmptyLayers(target, { first });
1703
+ setProductIdentity(target, { name: product, client });
1704
+ setLanguagePolicy(target, { docLanguage, docFilenameLanguage, chosen: languageChosen });
1705
+ upsertAgentFiles(target, agents, product);
1706
+ // Mechanical, idempotent, and re-run on EVERY update because both sides are restored behind our
1707
+ // back: `npx skills update` puts the author's flag back, and BMad's installer rewrites its own
1708
+ // wrappers. A one-time fix would hold for about a week.
1709
+ enableEngineInvocation(target);
1710
+ retireBmadG5(target);
1711
+ writeDenyRules(target);
1712
+ writeStamp(target);
1713
+ printSummary(target, agents, { first, was, written, skipped, skills, tomls, opencodeCmds });
1714
+ printNextSteps({
1715
+ first,
1716
+ productSet: Boolean(product) && !identityIsPlaceholder(product),
1717
+ upgradePending: !first && pendingUpgrades(target).length > 0,
1718
+ });
1719
+ }
1720
+
1721
+ function enginesCommand(target, { fix }) {
1722
+ const before = enginesReport(target);
1723
+ console.log("");
1724
+ console.log(` engines ${before.present ? "all present" : `MISSING ${before.missing.join(" · ")}`}`);
1725
+ for (const name of ENGINE_SKILLS) {
1726
+ const copies = before.files.get(name);
1727
+ if (!copies) continue;
1728
+ const flagged = [...copies.keys()].some((f) =>
1729
+ /^disable-model-invocation\s*:\s*true/m.test(fs.readFileSync(f, "utf8")));
1730
+ const where = [...copies.values()].map((f) => posixRel(target, f)).join(", ");
1731
+ console.log(` ${name.padEnd(17)}${flagged ? "flagged — no skill can invoke it" : "invocable"} ${DIM}${where}${RESET}`);
1732
+ }
1733
+ const banned = repoSkillFiles(target, BMAD_RETIRED_G5);
1734
+ const open = [];
1735
+ for (const [name, copies] of banned) {
1736
+ const shut = [...copies.keys()].every((f) =>
1737
+ /^disable-model-invocation\s*:\s*true/m.test(fs.readFileSync(f, "utf8")));
1738
+ if (!shut) open.push(name);
1739
+ }
1740
+ console.log(` bmad G5 ${banned.size} installed, ${open.length ? `STILL model-invocable: ${open.join(" · ")}` : "all retired"}`);
1741
+ const tracker = path.join(target, "docs", "agents", "issue-tracker.md");
1742
+ const trackerOwn = fs.existsSync(tracker)
1743
+ && fs.readFileSync(tracker, "utf8").includes("seeded by `wdi-method`");
1744
+ console.log(` config docs/agents/issue-tracker.md ${trackerOwn ? "is the method's" : "is NOT the method's — upstream's answer sends the engines to the wrong place"}`);
1745
+ console.log("");
1746
+
1747
+ if (!fix) {
1748
+ if (!before.present || open.length || !trackerOwn
1749
+ || engineInvocationState(target).blocked.length) {
1750
+ console.log(` ${DIM}to repair what can be repaired:${RESET} npx wdi-method engines --fix`);
1751
+ console.log("");
1752
+ }
1753
+ return;
1754
+ }
1755
+ repairAgentDocs(target);
1756
+ enableEngineInvocation(target);
1757
+ retireBmadG5(target);
1758
+ writeDenyRules(target);
1759
+ writeStamp(target);
1760
+ ok("engines aligned");
1761
+ if (!before.present) {
1762
+ console.log("");
1763
+ console.log(enginesMissingMessage(before.missing));
1764
+ }
1765
+ }
1766
+
1767
+ function verify(target, agents) {
1768
+ requireKit();
1769
+ const missing = [];
1770
+ const kitConst = path.join(KIT, ".constitution");
1771
+ for (const file of walkFiles(kitConst)) {
1772
+ const rel = posixRel(kitConst, file);
1773
+ const dest = path.join(target, ".constitution", rel);
1774
+ if (!fs.existsSync(dest)) missing.push(`.constitution/${rel}`);
1775
+ }
1776
+ for (const name of WDI_SKILLS) {
1777
+ for (const root of skillDestinations(target, agents)) {
1778
+ const dest = path.join(root, name, "SKILL.md");
1779
+ if (!fs.existsSync(dest)) missing.push(posixRel(target, dest));
1780
+ }
1781
+ }
1782
+ if (platformUsesHook(agents, "opencode-commands")) {
1783
+ for (const name of WDI_SKILLS) {
1784
+ const dest = path.join(target, opencodeCommandsDir(), `${name}.md`);
1785
+ if (!fs.existsSync(dest)) missing.push(posixRel(target, dest));
1786
+ }
1787
+ }
1788
+ const custom = path.join(KIT, "assets", "bmad-custom");
1789
+ for (const file of walkFiles(custom)) {
1790
+ if (!file.endsWith(".toml")) continue;
1791
+ const dest = path.join(target, "_bmad", "custom", path.basename(file));
1792
+ if (!fs.existsSync(dest)) missing.push(`_bmad/custom/${path.basename(file)}`);
1793
+ }
1794
+ if (fs.existsSync(path.join(target, ".control"))) {
1795
+ for (const file of walkFiles(SCAFFOLD)) {
1796
+ const rel = posixRel(SCAFFOLD, file);
1797
+ const dest = path.join(target, ".control", rel);
1798
+ if (!fs.existsSync(dest)) missing.push(`.control/${rel}`);
1799
+ }
1800
+ } else {
1801
+ missing.push(".control/ (folder missing — first install should have seeded it)");
1802
+ }
1803
+ // `.constitution/constitution.md` was the pre-0.5.0 path. Demanding it here made `verify` report a
1804
+ // file MISSING that the split deliberately removed — a check telling the truth about the wrong world.
1805
+ for (const required of ["AGENTS.md", path.join(".constitution", "project", "constitution.md")]) {
1806
+ if (!fs.existsSync(path.join(target, required))) missing.push(required.replaceAll(path.sep, "/"));
1807
+ }
1808
+ if (missing.length) {
1809
+ console.error(`${RED}missing ${missing.length}${RESET}`);
1810
+ for (const m of missing) console.error(` ${m}`);
1811
+ process.exit(1);
1812
+ }
1813
+ ok(`method files present in ${target}`);
1814
+
1815
+ // Present-and-correct is not the same as consistent. These three are states `update` cannot fix on
1816
+ // its own — it MUST NOT write over the room, and it cannot know what a product meant — so `verify`
1817
+ // is where they get said out loud instead of waiting to be tripped over.
1818
+ const judgement = [];
1819
+ const room = path.join(target, ".constitution", "project", "constitution.md");
1820
+ if (fs.existsSync(room)) {
1821
+ const carried = [...fs.readFileSync(room, "utf8").matchAll(/^## Article (\d+)\b/gm)]
1822
+ .map((m) => Number(m[1])).filter((n) => METHOD_ARTICLES.includes(n));
1823
+ if (carried.length) {
1824
+ judgement.push(`project/constitution.md still carries Articles ${carried.join(", ")} — the `
1825
+ + "method's. They are duplicated in method/constitution.md and will drift. Run update again.");
1826
+ }
1827
+ }
1828
+ const constRoot = path.join(target, ".constitution");
1829
+ const loose = fs.existsSync(constRoot)
1830
+ ? fs.readdirSync(constRoot, { withFileTypes: true })
1831
+ .filter((e) => e.isFile() && e.name.endsWith(".md")).map((e) => e.name)
1832
+ : [];
1833
+ if (loose.length) {
1834
+ judgement.push(`loose at .constitution/ root: ${loose.join(", ")} — .constitution/ holds two `
1835
+ + "folders and nothing else the method knows about. Move it into project/, or name it from "
1836
+ + "Article 2 so the next reader knows why it is there. repo-guide.md states the rule.");
1837
+ }
1838
+ if (judgement.length) {
1839
+ console.log("");
1840
+ for (const j of judgement) note(j);
1841
+ }
1842
+ note("extra product files are expected and were not checked");
1843
+ }
1844
+
1845
+ function scrubPrdToml(file) {
1846
+ const raw = fs.readFileSync(file, "utf8");
1847
+ const m = raw.match(/run_folder_pattern\s*=\s*"([^"]+)"/);
1848
+ if (!m) return;
1849
+ const slug = m[1];
1850
+ if (GENERIC_FOLDER_PATTERNS.has(slug)) return;
1851
+ fs.writeFileSync(file, raw.split(slug).join(PRD_SLUG_PLACEHOLDER), "utf8");
1852
+ note("bmad-prd.toml initiative slug scrubbed to placeholder");
1853
+ }
1854
+
1855
+ function promote(live) {
1856
+ live = path.resolve(live);
1857
+ if (!fs.existsSync(path.join(live, ".constitution"))) {
1858
+ die(`${live} has no .constitution/ — is this a method-carrying repo?`);
1859
+ }
1860
+ // EVERY file in the room is authored in the package and MUST survive the rmSync below — the room's
1861
+ // README, the generic Articles 1-2-5, and the three empty codebase templates. Read here, not
1862
+ // after: the first version of this preserved only README.md and read it AFTER the kit was deleted,
1863
+ // so it was always null and the file vanished on every promote. Two tests cover it now.
1864
+ const roomKit = path.join(KIT, ".constitution", PROJECT_ROOM);
1865
+ const roomKept = fs.existsSync(roomKit)
1866
+ ? Object.fromEntries(walkFiles(roomKit).map((f) => [posixRel(roomKit, f), fs.readFileSync(f, "utf8")]))
1867
+ : {};
1868
+
1869
+ fs.rmSync(KIT, { recursive: true, force: true });
1870
+ fs.mkdirSync(KIT, { recursive: true });
1871
+
1872
+ // ONE skip, because 0.5.0 put everything the product owns in one folder. It covers the codebase
1873
+ // guides too, which used to need a rule of their own: promoting a filled-in stack guide would leak
1874
+ // one product's conventions — possibly written in its own `doc_language` — into a public package.
1875
+ const nConst = copyTree(path.join(live, ".constitution"), path.join(KIT, ".constitution"),
1876
+ (rel) => rel.startsWith(PROJECT_ROOM));
1877
+ note(`constitution ${nConst} files (${PROJECT_ROOM} skipped — it is the product's)`);
1878
+ for (const [rel, text] of Object.entries(roomKept)) {
1879
+ const dest = path.join(roomKit, rel);
1880
+ fs.mkdirSync(path.dirname(dest), { recursive: true });
1881
+ fs.writeFileSync(dest, text, "utf8");
1882
+ }
1883
+ if (Object.keys(roomKept).length) {
1884
+ note(`${PROJECT_ROOM} restored from the package (${Object.keys(roomKept).length} files) — `
1885
+ + "promote never carries the room home");
1886
+ }
1887
+
1888
+ let copiedSkills = 0;
1889
+ const skillsSrc = path.join(live, ".claude", "skills");
1890
+ for (const name of WDI_SKILLS) {
1891
+ const src = path.join(skillsSrc, name);
1892
+ if (!fs.existsSync(src)) die(`skill missing in live repo: ${src}`);
1893
+ copiedSkills += copyTree(src, path.join(KIT, "skills", name));
1894
+ }
1895
+ note(`skills ${copiedSkills} files (${WDI_SKILLS.length} wrappers)`);
1896
+
1897
+ const customSrc = path.join(live, "_bmad", "custom");
1898
+ const customDst = path.join(KIT, "assets", "bmad-custom");
1899
+ fs.mkdirSync(customDst, { recursive: true });
1900
+ let tomls = 0;
1901
+ if (fs.existsSync(customSrc)) {
1902
+ for (const file of walkFiles(customSrc)) {
1903
+ if (!file.endsWith(".toml") || file.endsWith(".user.toml")) continue;
1904
+ copyFile(file, path.join(customDst, path.basename(file)));
1905
+ tomls += 1;
1906
+ }
1907
+ }
1908
+ const prd = path.join(customDst, "bmad-prd.toml");
1909
+ if (fs.existsSync(prd)) scrubPrdToml(prd);
1910
+ note(`bmad custom ${tomls} toml`);
1911
+
1912
+ const replacements = {
1913
+ "constitution.md": path.join(KIT, ".constitution", "method", "constitution.md"),
1914
+ "portability.md": path.join(KIT, ".constitution", "method", "why", "portability.md"),
1915
+ "repo-guide.md": path.join(KIT, ".constitution", "method", "repo-guide.md"),
1916
+ "README.md": path.join(KIT, ".constitution", "method", "README.md"),
1917
+ };
1918
+ for (const [name, dest] of Object.entries(replacements)) {
1919
+ const src = path.join(OVERLAY, name);
1920
+ if (fs.existsSync(src)) {
1921
+ copyFile(src, dest);
1922
+ note(`${name} replaced with kit overlay`);
1923
+ }
1924
+ }
1925
+
1926
+ const source = [
1927
+ `date: ${today()}`,
1928
+ `commit: ${gitHead(live)}`,
1929
+ "kind: working copy that currently carries a newer method",
1930
+ "note: the repo path and product name MUST NOT be recorded here",
1931
+ "",
1932
+ ].join("\n");
1933
+ fs.writeFileSync(path.join(ROOT, "SOURCE"), source, "utf8");
1934
+ ok(`SOURCE stamped ${today()} @ ${gitHead(live)}`);
1935
+ ok(`promoted into ${KIT}`);
1936
+ }
1937
+
1938
+ function cancelIf(value) {
1939
+ if (p.isCancel(value)) {
1940
+ p.cancel("Cancelled.");
1941
+ process.exit(0);
1942
+ }
1943
+ return value;
1944
+ }
1945
+
1946
+ async function runWizard(pre) {
1947
+ p.intro(`WDI Method ${PKG.version}`);
1948
+
1949
+ const dirValue = cancelIf(
1950
+ await p.text({
1951
+ message: "Target repo (the product folder)",
1952
+ placeholder: process.cwd(),
1953
+ defaultValue: pre.dir || process.cwd(),
1954
+ }),
1955
+ );
1956
+ const target = path.resolve(String(dirValue).trim() || process.cwd());
1957
+
1958
+ if (!fs.existsSync(target)) {
1959
+ const create = cancelIf(
1960
+ await p.confirm({ message: `${target} does not exist. Create it?`, initialValue: true }),
1961
+ );
1962
+ if (!create) {
1963
+ p.cancel("No target folder.");
1964
+ process.exit(1);
1965
+ }
1966
+ fs.mkdirSync(target, { recursive: true });
1967
+ }
1968
+
1969
+ const hasBmad = bmadPresent(target);
1970
+ const hasWdi = wdiPresent(target);
1971
+ const nonempty = dirNonEmpty(target);
1972
+
1973
+ const facts = [
1974
+ hasBmad
1975
+ ? `BMad Method: installed${readBmadVersion(target) ? ` (${readBmadVersion(target)})` : ""}`
1976
+ : "BMad Method: not installed",
1977
+ hasWdi ? "WDI Method: already present — the installer will offer an update" : "WDI Method: not present",
1978
+ enginesPresent(target)
1979
+ ? `Engines (mattpocock/skills, in this repo): all ${ENGINE_SKILLS.length} present`
1980
+ : `Engines: MISSING ${enginesReport(target).missing.join(" · ")} — ${ENGINES_INSTALL_ANY} (${ENGINES_REPO})`,
1981
+ engineInvocationState(target).blocked.length
1982
+ ? `Engine invocation: BLOCKED for ${engineInvocationState(target).blocked.join(" · ")} — npx wdi-method engines --fix`
1983
+ : "Engine invocation: enabled (the author's disable-model-invocation is stripped from the repo's copies)",
1984
+ nonempty ? "Folder is not empty (normal for a product repo already under way)" : "Folder is empty",
1985
+ ].join("\n");
1986
+ p.note(facts, "Detected");
1987
+
1988
+ if (!hasBmad && !pre.skipBmad) {
1989
+ p.note(bmadMissingMessage(), "BMad first");
1990
+ p.outro("Install BMad, then run this again: npx wdi-method");
1991
+ process.exit(1);
1992
+ }
1993
+
1994
+ // Step 2, refused in step 2's place. This used to be a line in the Detected note and nothing more,
1995
+ // so an interactive install or update sailed past a repo with no engines in it — the same repo the
1996
+ // `--yes` path refuses. The order matters as much as the stop: BMad is step 1, so a repo missing
1997
+ // both is told about BMad first rather than sent to install the second thing.
1998
+ const engineGate = enginesReport(target);
1999
+ if (!engineGate.present && !pre.skipEngines) {
2000
+ p.note(enginesMissingMessage(engineGate.missing), "Engines next");
2001
+ p.outro("Install them into this repo, then run this again: npx wdi-method");
2002
+ process.exit(1);
2003
+ }
2004
+
2005
+ let first = !hasWdi;
2006
+ if (hasWdi) {
2007
+ const update = cancelIf(
2008
+ await p.confirm({
2009
+ message: "WDI Method is already installed. Update it now?",
2010
+ initialValue: true,
2011
+ }),
2012
+ );
2013
+ first = !update;
2014
+ if (first) {
2015
+ p.cancel("Update declined.");
2016
+ process.exit(0);
2017
+ }
2018
+ } else {
2019
+ const go = cancelIf(
2020
+ await p.confirm({
2021
+ message: `Install WDI Method into ${target}?`,
2022
+ initialValue: true,
2023
+ }),
2024
+ );
2025
+ if (!go) {
2026
+ p.cancel("Install declined.");
2027
+ process.exit(0);
2028
+ }
2029
+ }
2030
+
2031
+ // Every field arrives with an answer already in it, and Enter accepts it. On an update that answer is
2032
+ // what the repo already says; on a first install it is the folder name made readable. Nothing here is
2033
+ // validated as required: a prompt that refuses an empty submission when it already holds a sensible
2034
+ // default is asking the owner to retype something the installer knows.
2035
+ const existing = readIndexIdentity(target);
2036
+ const suggestedName = identityIsPlaceholder(existing.name)
2037
+ ? humaniseFolderName(path.basename(target))
2038
+ : existing.name;
2039
+ const product = cancelIf(
2040
+ await p.text({
2041
+ message: "Product name (one room: index.yaml product.name)",
2042
+ placeholder: suggestedName,
2043
+ defaultValue: suggestedName,
2044
+ }),
2045
+ ).trim() || suggestedName;
2046
+ const client = cancelIf(
2047
+ await p.text({
2048
+ message: "Client name (Enter to leave it as it is)",
2049
+ placeholder: existing.client || "(none)",
2050
+ defaultValue: existing.client || "",
2051
+ }),
2052
+ ).trim();
2053
+
2054
+ // Two questions, and only two. Method terminology, document code prefixes, machine-facing
2055
+ // markers, and code identifiers are always English — MUST NOT be asked about.
2056
+ const policy = readIndexPolicy(target);
2057
+ // Free text, not a list. Write whatever a model understands — "English", "Bahasa Indonesia",
2058
+ // "id". The only value refused is empty.
2059
+ const askLanguage = async (message, current) =>
2060
+ (cancelIf(
2061
+ await p.text({
2062
+ message,
2063
+ placeholder: current || DEFAULT_DOC_LANGUAGE,
2064
+ defaultValue: current || DEFAULT_DOC_LANGUAGE,
2065
+ }),
2066
+ ) || DEFAULT_DOC_LANGUAGE).trim();
2067
+ const docLanguage = await askLanguage(
2068
+ "Language of working-document prose (.what/ .how/ .control/) — free text",
2069
+ policy.docLanguage || pre.docLanguage);
2070
+ const docFilenameLanguage = await askLanguage(
2071
+ "Language of document filename slugs — the `UC-` `DEC-` codes stay English",
2072
+ policy.docFilenameLanguage || pre.docFilenameLanguage || docLanguage);
2073
+
2074
+ const detected = pre.agents
2075
+ ? normalizePlatformIds(pre.agents)
2076
+ : detectPlatforms(target, fs);
2077
+ const selected = cancelIf(
2078
+ await p.autocompleteMultiselect({
2079
+ message: "Which tools get the wdi-* skills? (⭐ = recommended)",
2080
+ options: platformSelectOptions(detected),
2081
+ initialValues: detected,
2082
+ required: true,
2083
+ maxItems: 8,
2084
+ placeholder: "Type to search…",
2085
+ }),
2086
+ );
2087
+
2088
+ p.note(
2089
+ [
2090
+ "The corpus folder names are fixed — they are not an install option:",
2091
+ " .constitution .control .what .how .work _bmad-output",
2092
+ "",
2093
+ "What gets written for the platforms you picked:",
2094
+ " AGENTS.md (the BEGIN:wdi-method block — always)",
2095
+ platformUsesHook(selected, "claude-md") ? " CLAUDE.md (method block mirror)" : "",
2096
+ platformUsesHook(selected, "cursorrules") ? " .cursorrules (method block mirror)" : "",
2097
+ platformUsesHook(selected, "agents-mirror") ? " .agents/AGENTS.md (method block mirror)" : "",
2098
+ platformUsesHook(selected, "opencode-commands")
2099
+ ? ` ${opencodeCommandsDir()}/wdi-*.md (slash commands → skills)`
2100
+ : "",
2101
+ ` wdi-* skills → ${skillDestinations(target, selected).map((d) => posixRel(target, d)).join(", ") || "(none)"}`,
2102
+ ]
2103
+ .filter(Boolean)
2104
+ .join("\n"),
2105
+ "Write targets",
2106
+ );
2107
+
2108
+ const okGo = cancelIf(await p.confirm({ message: first ? "Run the install?" : "Run the update?", initialValue: true }));
2109
+ if (!okGo) {
2110
+ p.cancel("Dibatalkan.");
2111
+ process.exit(0);
2112
+ }
2113
+
2114
+ const spinner = p.spinner();
2115
+ spinner.start(first ? "Memasang…" : "Meng-update…");
2116
+ apply(target, selected, {
2117
+ docLanguage,
2118
+ docFilenameLanguage,
2119
+ languageChosen: true,
2120
+ first,
2121
+ product: String(product).trim(),
2122
+ client: String(client).trim(),
2123
+ });
2124
+ spinner.stop(first ? "Terpasang" : "Ter-update");
2125
+ p.outro(first ? "Done. Take the after-install steps above." : "Done. Read the method-block diff in AGENTS.md.");
2126
+ }
2127
+
2128
+ function runNonInteractive(args) {
2129
+ const target = requireTarget(args.dir);
2130
+ const agents = args.agents || detectPlatforms(target, fs) || PREFERRED_PLATFORM_IDS.slice();
2131
+ if (args.cmd === "verify") {
2132
+ verify(target, agents);
2133
+ return;
2134
+ }
2135
+ if (!args.skipBmad && !bmadPresent(target)) {
2136
+ die(bmadMissingMessage());
2137
+ }
2138
+ if (!args.skipEngines && !enginesPresent(target)) {
2139
+ die(enginesMissingMessage(enginesReport(target).missing));
2140
+ }
2141
+ const existing = readIndexIdentity(target);
2142
+ const product = args.product || existing.name;
2143
+ const client = args.client ?? existing.client;
2144
+ const first = args.cmd === "install" || (args.cmd === "wizard" && !wdiPresent(target));
2145
+ apply(target, agents, {
2146
+ first: args.cmd === "update" ? false : first,
2147
+ product,
2148
+ client,
2149
+ docLanguage: args.docLanguage,
2150
+ docFilenameLanguage: args.docFilenameLanguage,
2151
+ languageChosen: Boolean(args.docLanguage || args.docFilenameLanguage),
2152
+ });
2153
+ }
2154
+
2155
+ async function main() {
2156
+ const args = parseArgs(process.argv);
2157
+ if (!["wizard", "install", "update", "verify", "promote", "engines"].includes(args.cmd)) {
2158
+ usage();
2159
+ process.exit(2);
2160
+ }
2161
+ if (args.cmd === "promote") {
2162
+ if (!args.dir) die("promote needs a path to the working copy");
2163
+ // `promote` used to BE the workflow: author a rule in a product repo, run it, carry it here.
2164
+ // It is now a rescue tool, and the flag is what makes that structural rather than a paragraph
2165
+ // nobody rereads. Running it by habit overwrites the whole kit with one consumer's copy —
2166
+ // silently reverting every change made here since that repo last updated.
2167
+ if (!args.rescue) {
2168
+ die([
2169
+ "promote overwrites the whole kit from a consumer's copy, and this package is now where a",
2170
+ " method change is authored — see CONTRIBUTING.md. If a change really was made in a",
2171
+ " product repo by mistake and needs rescuing, say so:",
2172
+ "",
2173
+ " npx wdi-method promote <dir> --rescue",
2174
+ ].join("\n"));
2175
+ }
2176
+ note("--rescue: pulling the method back out of a consumer. Read the diff before committing.");
2177
+ promote(args.dir);
2178
+ return;
2179
+ }
2180
+ if (args.cmd === "engines") {
2181
+ enginesCommand(requireTarget(args.dir), { fix: Boolean(args.fix) });
2182
+ return;
2183
+ }
2184
+ const wantTui = !args.yes && args.cmd !== "verify" && process.stdin.isTTY && process.stdout.isTTY;
2185
+ if (wantTui) {
2186
+ await runWizard(args);
2187
+ return;
2188
+ }
2189
+ if (args.cmd === "wizard" && !args.yes) {
2190
+ die("not a TTY. Use `install --yes` / `update --yes`, or run this in a terminal.");
2191
+ }
2192
+ if (args.cmd === "wizard") args.cmd = wdiPresent(requireTarget(args.dir)) ? "update" : "install";
2193
+ runNonInteractive(args);
2194
+ }
2195
+
2196
+ main().catch((err) => {
2197
+ console.error(err);
2198
+ process.exit(1);
2199
+ });