wdi-method 0.6.30 → 0.6.32

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 (43) hide show
  1. package/CHANGELOG.md +129 -0
  2. package/NOTICE +7 -2
  3. package/README.md +16 -11
  4. package/bin/wdi-method.js +396 -87
  5. package/kit/.constitution/method/document/architecture-guide.md +217 -209
  6. package/kit/.constitution/method/document/bmad-skill-register.md +107 -104
  7. package/kit/.constitution/method/document/corpus-guide.md +522 -517
  8. package/kit/.constitution/method/document/decision-guide.md +236 -216
  9. package/kit/.constitution/method/document/delivery-flow-guide.md +20 -0
  10. package/kit/.constitution/method/document/prd-guide.md +245 -245
  11. package/kit/.constitution/method/document/templates/design-system.md +96 -66
  12. package/kit/.constitution/method/document/templates/experience.md +62 -0
  13. package/kit/.constitution/method/document/templates/structure-codebase.md +131 -129
  14. package/kit/.constitution/method/document/templates/ux.md +78 -76
  15. package/kit/.constitution/method/document/ux-guide.md +161 -115
  16. package/kit/.constitution/method/method-glossary.md +3 -0
  17. package/kit/.constitution/method/scripts/validate.py +3375 -3200
  18. package/kit/.constitution/method/structure-guide.md +204 -202
  19. package/kit/.constitution/method/why/README.md +1 -1
  20. package/kit/.constitution/method/why/artifact-map.md +158 -157
  21. package/kit/.constitution/method/why/portability.md +19 -2
  22. package/kit/skills/wdi-autopilot/SKILL.md +32 -19
  23. package/kit/skills/wdi-blueprint/SKILL.md +271 -264
  24. package/kit/skills/wdi-build/SKILL.md +28 -19
  25. package/kit/skills/wdi-component/SKILL.md +179 -174
  26. package/kit/skills/wdi-daily-autopilot/SKILL.md +24 -13
  27. package/kit/skills/wdi-daily-what-to-build/SKILL.md +9 -6
  28. package/kit/skills/wdi-daily-what-to-test/SKILL.md +2 -0
  29. package/kit/skills/wdi-decision/SKILL.md +206 -203
  30. package/kit/skills/wdi-explain-to-me/SKILL.md +2 -0
  31. package/kit/skills/wdi-help/SKILL.md +130 -125
  32. package/kit/skills/wdi-init/SKILL.md +10 -5
  33. package/kit/skills/wdi-problem/SKILL.md +114 -108
  34. package/kit/skills/wdi-product/SKILL.md +167 -162
  35. package/kit/skills/wdi-prune-or-archive/SKILL.md +2 -0
  36. package/kit/skills/wdi-reconcile/SKILL.md +170 -169
  37. package/kit/skills/wdi-upgrade/SKILL.md +234 -215
  38. package/kit/skills/wdi-ux/SKILL.md +187 -169
  39. package/kit-overlay/AGENTS.md +15 -2
  40. package/kit-overlay/portability.md +19 -2
  41. package/lib/platforms.mjs +420 -248
  42. package/package.json +1 -1
  43. package/scaffold/.control/registry/index.yaml +2 -1
package/bin/wdi-method.js CHANGED
@@ -20,13 +20,23 @@ import {
20
20
  writeProductIdentity,
21
21
  } from "../lib/identity.mjs";
22
22
  import {
23
+ declaredPlatforms,
23
24
  detectPlatforms,
24
25
  formatPlatformList,
26
+ getPlatform,
27
+ hostsYaml,
25
28
  isKnownPlatform,
29
+ isRetiredPlatform,
30
+ normalizePlatformId,
26
31
  normalizePlatformIds,
32
+ PLATFORMS,
27
33
  platformSelectOptions,
34
+ platformsWithManualOnly,
28
35
  platformUsesHook,
29
36
  PREFERRED_PLATFORM_IDS,
37
+ RETIRED_PLATFORMS,
38
+ retiredPlatformsIn,
39
+ ruleFiles,
30
40
  skillDestinations,
31
41
  } from "../lib/platforms.mjs";
32
42
  import { opencodeCommandsDir, syncOpencodeCommands } from "../lib/opencode-commands.mjs";
@@ -101,9 +111,36 @@ const GUARD_LINE = `> **${GUARD_MARK}.** \`wdi-method\` unlocked model invocatio
101
111
  + "session, a subagent that thought this looked relevant — stop and say so: this engine publishes "
102
112
  + "to the tracker and writes code.";
103
113
 
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"];
114
+ // The method's own skills a person MUST type. Each carries `disable-model-invocation: true` (the hosts
115
+ // that honour it hold it there), a guard line at the top of its body (every host reads that), and a
116
+ // line in the AGENTS.md block (every host reads that too).
117
+ const WDI_MANUAL_ONLY = [
118
+ "wdi-daily-what-to-build",
119
+ "wdi-daily-autopilot",
120
+ "wdi-daily-what-to-test",
121
+ "wdi-prune-or-archive",
122
+ "wdi-explain-to-me",
123
+ ];
124
+
125
+ /** Every folder in a repo that may hold skills.
126
+ *
127
+ * NOT a hand-kept list of hosts. The one this replaced named five folders while the installer
128
+ * offered forty hosts, so an engine installed for Kiro, Cline, or Trae was reported missing and never
129
+ * unlocked. Every `.<host>/skills` at the root counts, plus every directory a supported host is
130
+ * documented to read (`skills/` for OpenClaw, nested ones) — a repo installs the engines wherever
131
+ * `npx skills add` was pointed, and that installer symlinks across several of them.
132
+ */
133
+ function skillRoots(target) {
134
+ const roots = new Set(PLATFORMS.flatMap((p) => p.reads));
135
+ if (fs.existsSync(target)) {
136
+ for (const e of fs.readdirSync(target, { withFileTypes: true })) {
137
+ if (!e.name.startsWith(".") || e.name === ".git") continue;
138
+ if (!e.isDirectory() && !e.isSymbolicLink()) continue;
139
+ if (fs.existsSync(path.join(target, e.name, "skills"))) roots.add(`${e.name}/skills`);
140
+ }
141
+ }
142
+ return [...roots];
143
+ }
107
144
 
108
145
  // BMad skills RETIRED at G5. This array is the single home of that list: `bmad-skill-register.md`
109
146
  // carries the same names for a reader, and a test fails when the two disagree.
@@ -169,7 +206,10 @@ function usage() {
169
206
  install [dir] first install (TUI unless --yes)
170
207
  update [dir] update (TUI unless --yes)
171
208
  verify [dir]
172
- engines [dir] [--fix] report the six engines, their invocation state, and the BMad G5 ban
209
+ engines [dir] [--fix] [--copy]
210
+ report the six engines, their invocation state, and the BMad G5 ban;
211
+ --copy puts the repo's own engine copies where each selected host reads
212
+ upgrade-check [dir] re-probe what wdi-upgrade still owes; rewrites upgrade_pending (exit 1 if any)
173
213
  promote <live-dir> --rescue pull a method change back out of a consumer (not the normal flow)
174
214
 
175
215
  --yes non-interactive
@@ -181,6 +221,7 @@ function usage() {
181
221
  --doc-filename-language <text> slug part of document filenames; free text, default English
182
222
  --skip-bmad-check
183
223
  --skip-engines-check install without to-spec / to-tickets / implement
224
+ --copy-engines engines are in the repo but a selected host cannot read them: copy them there
184
225
 
185
226
  BMad first, then this package. ${WDI_REPO}
186
227
  `);
@@ -214,7 +255,7 @@ function parseArgs(argv) {
214
255
  return args;
215
256
  }
216
257
  const first = rest[0];
217
- if (["install", "update", "verify", "promote", "engines"].includes(first)) {
258
+ if (["install", "update", "verify", "promote", "engines", "upgrade-check"].includes(first)) {
218
259
  args.cmd = rest.shift();
219
260
  } else if (first.startsWith("-")) {
220
261
  args.cmd = "wizard";
@@ -226,15 +267,21 @@ function parseArgs(argv) {
226
267
  const t = rest.shift();
227
268
  if (t === "--skip-bmad-check") args.skipBmad = true;
228
269
  else if (t === "--fix") args.fix = true;
270
+ else if (t === "--copy" || t === "--copy-engines") args.copyEngines = true;
229
271
  else if (t === "--skip-engines-check") args.skipEngines = true;
230
272
  else if (t === "--rescue") args.rescue = true;
231
273
  else if (t === "--yes" || t === "-y") args.yes = true;
232
274
  else if (t === "--agents") {
233
275
  const raw = rest.shift();
234
276
  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));
277
+ const asked = raw.split(",").map((s) => s.trim()).filter(Boolean);
278
+ const retired = asked.filter((a) => isRetiredPlatform(a));
279
+ if (retired.length) {
280
+ die(retired.map((a) => `${a} is no longer supported — ${RETIRED_PLATFORMS[normalizePlatformId(a)]}`)
281
+ .join("\n "));
282
+ }
283
+ args.agents = normalizePlatformIds(asked);
284
+ const unknown = asked.filter((a) => !isKnownPlatform(a));
238
285
  if (unknown.length) die(`unknown platform: ${unknown.join(", ")} (run --list-agents)`);
239
286
  if (!args.agents.length) die("--agents needs at least one known platform");
240
287
  } else if (t === "--product") args.product = rest.shift();
@@ -361,30 +408,73 @@ function requireTarget(dir) {
361
408
  */
362
409
  function repoSkillFiles(target, names) {
363
410
  const out = new Map();
364
- for (const home of SKILL_HOMES) {
411
+ for (const root of skillRoots(target)) {
365
412
  for (const name of names) {
366
- const file = path.join(target, home, "skills", name, "SKILL.md");
413
+ const file = path.join(target, root, name, "SKILL.md");
367
414
  if (!fs.existsSync(file)) continue;
368
415
  let real = file;
369
416
  try {
370
417
  real = fs.realpathSync(file);
371
418
  } catch {}
372
419
  if (!out.has(name)) out.set(name, new Map());
373
- out.get(name).set(real, file);
420
+ const seen = out.get(name);
421
+ if (!seen.has(real)) seen.set(real, []);
422
+ seen.get(real).push(file);
374
423
  }
375
424
  }
376
425
  return out;
377
426
  }
378
427
 
379
- /** The six engines, in the REPO. A user-level plugin is not an answer here — see ENGINE_SKILLS. */
380
- function enginesReport(target) {
428
+ /** The six engines, in the REPO — and visible to EVERY selected host.
429
+ *
430
+ * Present somewhere is not enough. Kiro reads `.kiro/skills` and nothing else, so engines in
431
+ * `.agents/skills` are invisible to it and `wdi-build` fails there at G5. `missingByHost` names, per
432
+ * selected host, the engines none of its `reads` directories holds. With no hosts given it falls
433
+ * back to "anywhere in the repo", which is what the unlock and the lock work on.
434
+ * A user-level plugin is not an answer here — see ENGINE_SKILLS.
435
+ */
436
+ function enginesReport(target, agents = []) {
381
437
  const files = repoSkillFiles(target, ENGINE_SKILLS);
382
438
  const missing = ENGINE_SKILLS.filter((n) => !files.has(n));
383
- return { files, missing, present: missing.length === 0 };
439
+ const missingByHost = new Map();
440
+ for (const id of normalizePlatformIds(agents)) {
441
+ const reads = getPlatform(id).reads.map((d) => path.join(target, d) + path.sep);
442
+ const visible = (name) => [...(files.get(name)?.values() || [])].flat()
443
+ .some((f) => reads.some((r) => f.startsWith(r)));
444
+ const lacking = ENGINE_SKILLS.filter((n) => !visible(n));
445
+ if (lacking.length) missingByHost.set(id, lacking);
446
+ }
447
+ const present = agents.length ? missingByHost.size === 0 : missing.length === 0;
448
+ return { files, missing, missingByHost, present };
449
+ }
450
+
451
+ function enginesPresent(target, agents = []) {
452
+ return enginesReport(target, agents).present;
384
453
  }
385
454
 
386
- function enginesPresent(target) {
387
- return enginesReport(target).present;
455
+ /** Engines that ARE in the repo, copied into the skill folder of each selected host that cannot see
456
+ * them. The source is the repo's own copy — no network, no third-party installer — which is what
457
+ * `npx skills add` in "copy" mode would have produced. An existing folder is never overwritten.
458
+ * Returns the number of engine folders written; engines missing from the repo entirely are left to
459
+ * `npx skills add`, because there is nothing here to copy from.
460
+ */
461
+ function copyEnginesToHosts(target, agents) {
462
+ const report = enginesReport(target, agents);
463
+ let written = 0;
464
+ for (const [id, names] of report.missingByHost) {
465
+ const host = getPlatform(id);
466
+ for (const name of names) {
467
+ const copies = report.files.get(name);
468
+ if (!copies) continue;
469
+ const source = path.dirname([...copies.keys()].sort()[0]);
470
+ const dest = path.join(target, ...host.skillDir.split("/"), name);
471
+ if (fs.existsSync(dest)) continue;
472
+ copyTree(source, dest);
473
+ written += 1;
474
+ }
475
+ note(`copied engines for ${host.name} → ${host.skillDir}/ (from the repo's own copy)`);
476
+ }
477
+ return written;
388
478
  }
389
479
 
390
480
  /** Only ever a WARNING. The plugin's copies are namespaced and still flagged, so they can neither be
@@ -482,7 +572,10 @@ function retireBmadG5(target) {
482
572
  * Merged, never replaced: a product's own permissions are its own. Invalid JSON is reported rather
483
573
  * than repaired — rewriting a settings file nobody can parse is how a repo loses its allowlist.
484
574
  */
485
- function writeDenyRules(target) {
575
+ function writeDenyRules(target, agents) {
576
+ // Claude Code's own settings file, for Claude Code only. Writing it for a Kiro-only repo would
577
+ // create a `.claude/` folder nothing reads.
578
+ if (!platformsWithManualOnly(agents, "claude").length) return 0;
486
579
  const file = path.join(target, ".claude", "settings.json");
487
580
  let settings = {};
488
581
  if (fs.existsSync(file)) {
@@ -509,6 +602,46 @@ function writeDenyRules(target) {
509
602
  return added.length;
510
603
  }
511
604
 
605
+ /** OpenCode's equivalent: `permission.skill: ask` in `opencode.json`. The model may still reach for
606
+ * the skill, and OpenCode then asks the person before loading it — a gate the model cannot pass
607
+ * alone, with the human route left open. `deny` would hide it from the person too.
608
+ *
609
+ * Merged, never replaced, under the same rules as `.claude/settings.json`: a value the product
610
+ * already set for a name is kept, and a file nobody can parse (`opencode.json` may carry comments) is
611
+ * reported rather than rewritten.
612
+ */
613
+ function writeOpencodePermissions(target, agents) {
614
+ if (!platformsWithManualOnly(agents, "opencode").length) return 0;
615
+ const file = path.join(target, "opencode.json");
616
+ let cfg = {};
617
+ if (fs.existsSync(file)) {
618
+ try {
619
+ cfg = JSON.parse(fs.readFileSync(file, "utf8"));
620
+ } catch {
621
+ note("opencode.json is not plain JSON — skill permissions NOT written; add them by hand: "
622
+ + `permission.skill "${WDI_MANUAL_ONLY[0]}": "ask" (and the rest in bmad-skill-register.md)`);
623
+ return 0;
624
+ }
625
+ if (!cfg || typeof cfg !== "object" || Array.isArray(cfg)) return 0;
626
+ }
627
+ const perm = cfg.permission && typeof cfg.permission === "object" && !Array.isArray(cfg.permission)
628
+ ? cfg.permission : {};
629
+ if (typeof perm.skill === "string") {
630
+ note(`opencode.json sets permission.skill to "${perm.skill}" for every skill — left as the product wrote it`);
631
+ return 0;
632
+ }
633
+ const skill = perm.skill && typeof perm.skill === "object" ? perm.skill : {};
634
+ const added = [...WDI_MANUAL_ONLY, ...BMAD_RETIRED_G5].filter((n) => !(n in skill));
635
+ if (!added.length) return 0;
636
+ for (const n of added) skill[n] = "ask";
637
+ perm.skill = skill;
638
+ cfg.permission = perm;
639
+ if (!cfg.$schema) cfg = { $schema: "https://opencode.ai/config.json", ...cfg };
640
+ fs.writeFileSync(file, `${JSON.stringify(cfg, null, 2)}\n`, "utf8");
641
+ note(`opencode.json: ${added.length} manual-only skill${added.length === 1 ? "" : "s"} set to "ask"`);
642
+ return added.length;
643
+ }
644
+
512
645
  /** A trace of what the engines were when the method last looked — not a lockfile.
513
646
  *
514
647
  * `npx skills add` writes its own `skills-lock.json` with a folder hash per skill, and that hash
@@ -564,18 +697,37 @@ function bmadMissingMessage() {
564
697
  //
565
698
  // So it blocks, and `--skip-engines-check` is the escape, exactly as `--skip-bmad-check` is for BMad. The
566
699
  // 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(" · ");
700
+ function enginesMissingMessage(report) {
701
+ const missing = report?.missing?.length ? report.missing
702
+ : report?.missingByHost?.size ? [] : ENGINE_SKILLS;
703
+ const byHost = report?.missingByHost?.size ? [...report.missingByHost] : [];
704
+ const head = [];
705
+ if (missing.length) {
706
+ head.push(`The engines are not in this repo. Missing: ${missing.join(" · ")}`, "",
707
+ ` ${ENGINES_INSTALL_ANY}`);
708
+ }
709
+ if (byHost.length) {
710
+ if (head.length) head.push("");
711
+ head.push("Not where every selected host can read them — install for each host named here:", "");
712
+ for (const [id, names] of byHost) {
713
+ const p = getPlatform(id);
714
+ head.push(` ${p.name} reads ${p.reads.join(", ")} — cannot see ${names.join(" · ")}`,
715
+ ` ${ENGINES_INSTALL_ANY} --agent ${p.skillsCli} (lands them where ${p.name} reads)`);
716
+ }
717
+ head.push("",
718
+ "Or, without the network — the engines are already in this repo, just not where those hosts read:",
719
+ " npx wdi-method engines --copy (or add --copy-engines to install / update)",
720
+ "Or leave those hosts out if this repo does not use them: --agents <the hosts you use>");
721
+ }
569
722
  return [
570
- `The engines are not in this repo. Missing: ${names}`,
723
+ ...head,
571
724
  "",
572
725
  "They MUST be installed INTO the repo, not as a user-level plugin — the method strips",
573
726
  "`disable-model-invocation` from its own copies so `wdi-build` and `wdi-autopilot` can drive",
574
727
  "them, and a plugin's files are not the repo's to edit.",
575
728
  "",
576
- ` ${ENGINES_INSTALL_ANY}`,
577
- "",
578
- `Take all six: ${ENGINE_SKILLS.join(" · ")}. Choose "copy" or "symlink" — either is read.`,
729
+ `Take all six: ${ENGINE_SKILLS.join(" · ")}. Choose "copy" or "symlink" — either is read; on Windows`,
730
+ "without Developer Mode a symlink fails and the installer falls back to copy, which is fine.",
579
731
  "",
580
732
  "You do NOT need to run the setup skill after this — the installer seeds docs/agents/ already",
581
733
  `answered for this method. Run ${ENGINES_SETUP} only to change tracker.`,
@@ -1266,9 +1418,47 @@ function seedEmptyLayers(target, { first }) {
1266
1418
  }
1267
1419
  }
1268
1420
 
1269
- function writeStamp(target) {
1421
+ // What `wdi-upgrade` still owes, written where it is committed. The summary line alone was the only
1422
+ // record, so a repo whose owner missed that one screen could not find out again — and `wdi-help` can
1423
+ // only route to `wdi-upgrade` from something it can read. Absent when nothing is pending: an empty
1424
+ // list would read as a question nobody has to answer.
1425
+ const PENDING_HEADER = "# What wdi-upgrade still has to move. Re-probed by `npx wdi-method upgrade-check`; absent = nothing.";
1426
+
1427
+ function pendingBlock(items) {
1428
+ if (!items.length) return "";
1429
+ return `${PENDING_HEADER}\nupgrade_pending:\n${items.map((i) => ` - ${JSON.stringify(i)}\n`).join("")}`;
1430
+ }
1431
+
1432
+ // Re-probe and rewrite ONLY the pending block, leaving the rest of the stamp as `update` wrote it.
1433
+ // This is how `wdi-upgrade` closes: the field disappears when the probes come back clean.
1434
+ function upgradeCheck(target) {
1435
+ const file = path.join(target, ".control", "wdi-method.yaml");
1436
+ if (!fs.existsSync(file)) die("no .control/wdi-method.yaml — the method is not installed here");
1437
+ const items = pendingUpgrades(target);
1438
+ let text = fs.readFileSync(file, "utf8")
1439
+ .replace(/^# What wdi-upgrade still has to move\..*\r?\n/m, "")
1440
+ .replace(/^upgrade_pending:\r?\n(?:[ \t]+- .*\r?\n)*/m, "");
1441
+ const block = pendingBlock(items);
1442
+ if (block) {
1443
+ text = /^installed_at:/m.test(text)
1444
+ ? text.replace(/^installed_at:/m, `${block}installed_at:`)
1445
+ : `${text.replace(/\s*$/, "\n")}${block}`;
1446
+ }
1447
+ fs.writeFileSync(file, text, "utf8");
1448
+ console.log("");
1449
+ if (!items.length) {
1450
+ ok("nothing pending — wdi-upgrade owes nothing here");
1451
+ return 0;
1452
+ }
1453
+ console.log(` upgrade ${items.length} item${items.length === 1 ? "" : "s"} still in the OLD shape — run the wdi-upgrade skill`);
1454
+ for (const item of items) console.log(` ${DIM}·${RESET} ${item}`);
1455
+ return 1;
1456
+ }
1457
+
1458
+ function writeStamp(target, agents) {
1270
1459
  const control = path.join(target, ".control");
1271
1460
  if (!fs.existsSync(control)) return;
1461
+ const hosts = normalizePlatformIds(agents && agents.length ? agents : declaredPlatforms(target, fs));
1272
1462
  const eng = enginesReport(target);
1273
1463
  const fp = engineFingerprints(target);
1274
1464
  const names = Object.keys(fp);
@@ -1276,6 +1466,12 @@ function writeStamp(target) {
1276
1466
  "# Written by wdi-method install/update. A trace, not a lockfile.",
1277
1467
  `wdi_method: ${PKG.version}`,
1278
1468
  `bmad_method: ${readBmadVersion(target) || '""'}`,
1469
+ "# The hosts this repo was installed for. `update` reuses this list; the skills and validate.py",
1470
+ "# read `hosts:` to learn where each host reads skills, how a person types one, and whether the",
1471
+ "# host has its own scheduler.",
1472
+ "platforms:",
1473
+ ...hosts.map((id) => ` - ${id}`),
1474
+ hostsYaml(hosts),
1279
1475
  ];
1280
1476
  if (names.length) {
1281
1477
  const blocked = engineInvocationState(target).blocked;
@@ -1293,6 +1489,8 @@ function writeStamp(target) {
1293
1489
  lines.push("engines:");
1294
1490
  lines.push(" source: none # none in this repo — G5 cannot run until they are installed");
1295
1491
  }
1492
+ const pending = pendingBlock(pendingUpgrades(target));
1493
+ if (pending) lines.push(pending.trimEnd());
1296
1494
  lines.push(`installed_at: ${today()}`);
1297
1495
  lines.push("");
1298
1496
  const stamp = lines.join("\n");
@@ -1427,17 +1625,17 @@ function pendingUpgrades(target) {
1427
1625
  });
1428
1626
  };
1429
1627
  const items = [];
1430
- if (has(".control", "registry", "requirements.yaml")) items.push("requirements.yaml → goals.yaml + requirements-<slug>.yaml");
1628
+ if (has(".control", "registry", "requirements.yaml")) items.push("#1 requirements.yaml → goals.yaml + requirements-<slug>.yaml");
1431
1629
  // The file the engines actually read. `/setup-matt-pocock-skills` writes its own answer here — no
1432
1630
  // `specs.yaml`, no predefined path — and `seedAgentDocs` will not overwrite a file the product owns,
1433
1631
  // so without this probe the repo never learns why its tickets scatter.
1434
1632
  if (has("docs", "agents", "issue-tracker.md")
1435
1633
  && !read("docs", "agents", "issue-tracker.md").includes("seeded by `wdi-method`")) {
1436
- items.push("docs/agents/issue-tracker.md is not the method's answer (npx wdi-method engines --fix)");
1634
+ items.push("#11 docs/agents/issue-tracker.md is not the method's answer (npx wdi-method engines --fix)");
1437
1635
  }
1438
1636
  const strays = specsOutsideScratch(read(".control", "registry", "specs.yaml"));
1439
1637
  if (strays.length) {
1440
- items.push(`spec_folder outside .scratch/<spec-id>-<slug>/ on ${strays.join(", ")} `
1638
+ items.push(`#12 spec_folder outside .scratch/<spec-id>-<slug>/ on ${strays.join(", ")} `
1441
1639
  + `(the folder moves, then its cites)`);
1442
1640
  }
1443
1641
  // Reported only where it is still WORK. A closed pre-rename wave is read correctly (0.6.7 taught
@@ -1451,46 +1649,109 @@ function pendingUpgrades(target) {
1451
1649
  // that would answer "not mine" and stop.
1452
1650
  const legacyOpen = specsInLegacyShape(read(".control", "registry", "specs.yaml"));
1453
1651
  if (legacyOpen.length) {
1454
- items.push(`${legacyOpen.join(", ")} still in the W<n>/epics/stories shape and not closed `
1652
+ items.push(`#2 ${legacyOpen.join(", ")} still in the W<n>/epics/stories shape and not closed `
1455
1653
  + `(flattened into tickets, id kept as its retired alias)`);
1456
1654
  }
1457
- if (/^## (Executive Summary|Vision|Assumptions|Prerequisites)\s*$/m.test(read(".what", "_product-brief", "brief.md"))) items.push("brief.md in the 14-section shape");
1655
+ if (/^## (Executive Summary|Vision|Assumptions|Prerequisites)\s*$/m.test(read(".what", "_product-brief", "brief.md"))) items.push("#3 brief.md in the 14-section shape");
1458
1656
  // Sections by NAME: the numbers moved between kits (Non-Goals was §7 in one, §5 in the next).
1459
- 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");
1657
+ if (anyIn(".what/_prd", "prd.md", /^## (\d+\.\s*)?(Document Purpose|Glossary|Non-Goals|Open Questions|Assumptions Index)\b|\*\*Proof of done:\*\*/m)) items.push("#4 a prd.md in the 12-section shape, or with FR blocks");
1460
1658
  const whatDir = path.join(target, ".what");
1461
1659
  if (fs.existsSync(whatDir)) {
1462
1660
  for (const pc of fs.readdirSync(whatDir)) {
1463
1661
  if (pc.startsWith("_")) continue;
1464
1662
  const srs = read(".what", pc, `SRS-${pc}.md`);
1465
- if (/^\|\s*UC-\d+\s*\|/m.test(srs)) { items.push("an SRS with a UC Catalogue table (now a pointer)"); break; }
1663
+ if (/^\|\s*UC-\d+\s*\|/m.test(srs)) { items.push("#5 an SRS with a UC Catalogue table (now a pointer)"); break; }
1466
1664
  }
1467
1665
  }
1468
1666
  const howDir = path.join(target, ".how");
1469
1667
  if (fs.existsSync(howDir)) {
1470
1668
  for (const pc of fs.readdirSync(howDir)) {
1471
1669
  if (pc.startsWith("_")) continue;
1472
- 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; }
1670
+ if (/\|\s*Quoted rule\s*\||Quoted verbatim from/.test(read(".how", pc, `SDD-${pc}.md`))) { items.push("#6 an SDD quoting AD-N text (now ids only)"); break; }
1473
1671
  }
1474
1672
  }
1475
- 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)");
1476
- if (has(".control", "generated", "brief.md") || has(".control", "generated", "blueprint.md")) items.push("human pages still in .control/generated/ (render clears them)");
1477
- if (has(".what", "_product-brief", "brief.md") && !has(".what-rendered")) items.push("no .what-rendered/ yet (render creates it)");
1673
+ if (/\|\s*Container\s*\|\s*Product Components living in it\s*\|/.test(read(".how", "_platform", "c4-l2-containers.md"))) items.push("#7 c4-l2 with a PC x container table (now a pointer)");
1674
+ if (has(".control", "generated", "brief.md") || has(".control", "generated", "blueprint.md")) items.push("#8 human pages still in .control/generated/ (render clears them)");
1675
+ if (has(".what", "_product-brief", "brief.md") && !has(".what-rendered")) items.push("#9 no .what-rendered/ yet (render creates it)");
1478
1676
  // Skipped: what the validator never reads (kit copies, rendered output, dependencies) and what it
1479
1677
  // treats as a record of the PAST — memlog, decisions, reports, _bmad-output. A stale path in a log
1480
1678
  // is history, not a finding, and repointing it would falsify the record.
1481
1679
  const SKIP = new Set([".git", "node_modules", "target", ".constitution", ".claude", ".agents", ".agent",
1482
1680
  ".what-rendered", ".how-rendered", "dist", "build", "memlog", "decisions", "reports", "meetings", "_bmad-output", ".work"]);
1483
1681
  const OLD_PAGE = /\.control\/generated\/(brief|blueprint|prd-[a-z0-9-]+)\.md/;
1682
+ // At the root, every dot-folder except the corpus layers is a tool's: an agent host's skill copies
1683
+ // (`.kiro/skills/wdi-upgrade` names the OLD paths on purpose, to probe for them), editor state, CI.
1684
+ // Listing hosts by name missed every one outside the first three, and each such repo was told to run
1685
+ // `wdi-upgrade` on every update with nothing to move.
1686
+ const CORPUS_DOT = new Set([".control", ".what", ".how"]);
1484
1687
  const citesOldPage = (dir, depth) => {
1485
1688
  if (depth > 8) return false;
1486
1689
  for (const e of fs.readdirSync(dir, { withFileTypes: true })) {
1690
+ if (e.isDirectory() && depth === 0 && e.name.startsWith(".") && !CORPUS_DOT.has(e.name)) continue;
1487
1691
  if (e.isDirectory()) { if (!SKIP.has(e.name) && citesOldPage(path.join(dir, e.name), depth + 1)) return true; continue; }
1488
1692
  if (e.name === "answered.md") continue;
1489
1693
  if (e.name.endsWith(".md") && OLD_PAGE.test(fs.readFileSync(path.join(dir, e.name), "utf8"))) return true;
1490
1694
  }
1491
1695
  return false;
1492
1696
  };
1493
- if (citesOldPage(target, 0)) items.push("a document cites .control/generated/brief|blueprint|prd-*.md (pages moved to the rendered trees)");
1697
+ if (citesOldPage(target, 0)) items.push("#10 a document cites .control/generated/brief|blueprint|prd-*.md (pages moved to the rendered trees)");
1698
+ // Cross-component EXPERIENCE parked in the product's design system. Before `.what/experience.md`
1699
+ // existed it had no home, so a repo put it in the one product-level UX file there was — a promise
1700
+ // in the build layer. The section names are bmad-ux's own, which is where the content comes from.
1701
+ // The names are bmad-ux's own and the product-level template's, in either spelling — a probe narrower
1702
+ // than the template it migrates into reports "nothing owed" while the content is still in the wrong layer.
1703
+ if (/^## (Foundation|Information architecture|Voice and tone|Flow map|Onboarding|Journeys|Shared edge cases|Promises every surface keeps|Cross-component (behaviou?r|journeys?))\b/mi
1704
+ .test(read(".how", "_platform", "design-system.md"))) {
1705
+ items.push("#15 cross-component experience in design-system.md (moves to .what/experience.md)");
1706
+ }
1707
+ // #16 and #18 follow `ux-landed`: nothing about a UX run is owed before a Product Component exists.
1708
+ const components = read(".control", "registry", "components.yaml");
1709
+ const hasPcs = /^product_components:[ \t]*\r?\n[ \t]+-/m.test(components);
1710
+ const LANDED_FROM = /^landed_from:.*(?:\r?\n[ \t]+-.*)*/m; // provenance, not a citation
1711
+ const UX_RUN = /_bmad-output\/ux\/[A-Za-z0-9_./-]*?(DESIGN|EXPERIENCE|design-system)\.md/;
1712
+ const citesUxRun = (dir, depth) => {
1713
+ if (depth > 8 || !fs.existsSync(dir)) return false;
1714
+ for (const e of fs.readdirSync(dir, { withFileTypes: true })) {
1715
+ const full = path.join(dir, e.name);
1716
+ if (e.isDirectory()) { if (citesUxRun(full, depth + 1)) return true; continue; }
1717
+ if (/\.(md|ya?ml)$/.test(e.name) && UX_RUN.test(fs.readFileSync(full, "utf8").replace(LANDED_FROM, ""))) return true;
1718
+ }
1719
+ return false;
1720
+ };
1721
+ if (hasPcs && (citesUxRun(path.join(target, ".what"), 0) || citesUxRun(path.join(target, ".how"), 0))) {
1722
+ items.push("#16 the corpus cites a UX run in _bmad-output/ux/ (repoint at what landed; provenance to landed_from)");
1723
+ }
1724
+ // #17 — a container whose `repo:` sends its code elsewhere but still has a heading here, or a `repo:` on
1725
+ // a container nobody writes. The self-naming case needs the origin remote and is left to `container-built`.
1726
+ const ctrSection = (components.match(/^containers:[^\n]*\n((?:[ \t].*(?:\r?\n|$)|\r?\n)*)/m) || [, ""])[1];
1727
+ const headings = new Set([...(read(".control", "structure-codebase.md").split(/^## Containers\s*$/m)[1] || "")
1728
+ .split(/^## (?!#)/m)[0].matchAll(/^###\s+(.+?)\s*$/gm)].map((m) => m[1]));
1729
+ const repoDebt = ctrSection.split(/^[ \t]*-[ \t]+id:[ \t]*/m).slice(1).some((block) => {
1730
+ const id = block.split(/\r?\n/)[0].trim().replace(/^["']|["']$/g, "");
1731
+ const repo = /^[ \t]+repo:[ \t]*["']?([^"'\r\n]+)/m.exec(block)?.[1].trim();
1732
+ if (!repo) return false;
1733
+ return /^[ \t]+built:[ \t]*false\b/m.test(block) || headings.has(id);
1734
+ });
1735
+ if (repoDebt) {
1736
+ items.push("#17 a container's repo: disagrees with the code map (heading for code kept elsewhere, or repo: on built: false)");
1737
+ }
1738
+ // #18 — landed UX documents written before `landed_from` existed. Without it `ux-landed` cannot tell
1739
+ // which run a landing discharged, so every run in `_bmad-output/ux/` reads as still owed.
1740
+ const uxRoot = path.join(target, "_bmad-output", "ux");
1741
+ const hasRun = fs.existsSync(uxRoot) && fs.readdirSync(uxRoot, { recursive: true })
1742
+ .some((f) => /(^|[\\/])(DESIGN|EXPERIENCE)\.md$/.test(String(f)));
1743
+ const landedDocs = [];
1744
+ for (const [layer, slot, name] of [[".how", "01-ux", "DESIGN.md"], [".what", "04-usecases", "EXPERIENCE.md"]]) {
1745
+ const base = path.join(target, layer);
1746
+ if (!fs.existsSync(base)) continue;
1747
+ for (const pc of fs.readdirSync(base)) {
1748
+ const f = path.join(base, pc, slot, name);
1749
+ if (fs.existsSync(f)) landedDocs.push(f);
1750
+ }
1751
+ }
1752
+ if (hasPcs && hasRun && landedDocs.some((f) => !/^landed_from:/m.test(fs.readFileSync(f, "utf8")))) {
1753
+ items.push("#18 a landed UX document has no landed_from (name the run file(s) it came from)");
1754
+ }
1494
1755
  return items;
1495
1756
  }
1496
1757
 
@@ -1529,34 +1790,20 @@ function upsertAgentFiles(target, platforms, productName) {
1529
1790
  if (!next.endsWith("\n")) next += "\n";
1530
1791
  fs.writeFileSync(agentsFile, next);
1531
1792
 
1532
- const mirrors = [];
1533
- if (platformUsesHook(platforms, "cursorrules")) {
1534
- mirrors.push(path.join(target, ".cursorrules"));
1535
- }
1536
- if (platformUsesHook(platforms, "agents-mirror")) {
1537
- mirrors.push(path.join(target, ".agents", "AGENTS.md"));
1538
- }
1539
- for (const mirror of mirrors) {
1793
+ // Every rule file a selected host reads in place of, or beside, AGENTS.md — CLAUDE.md, GEMINI.md,
1794
+ // .junie/guidelines.md, … — carries the same block. A host that never sees the block never sees
1795
+ // the method policy, and on a host with no manual-only gate that policy is the lock.
1796
+ for (const rel of ruleFiles(platforms).slice(1)) {
1797
+ const mirror = path.join(target, ...rel.split("/"));
1540
1798
  fs.mkdirSync(path.dirname(mirror), { recursive: true });
1541
- if (fs.existsSync(mirror)) {
1542
- const patched = upsertMethodBlock(fs.readFileSync(mirror, "utf8"), template);
1543
- fs.writeFileSync(mirror, patched.endsWith("\n") ? patched : `${patched}\n`);
1544
- note(`method block refreshed in ${posixRel(target, mirror)}`);
1545
- } else {
1799
+ const current = fs.existsSync(mirror) ? fs.readFileSync(mirror, "utf8") : "";
1800
+ if (!current.trim() || current.trim() === "@AGENTS.md") {
1546
1801
  fs.writeFileSync(mirror, next);
1547
- note(`created ${posixRel(target, mirror)}`);
1548
- }
1549
- }
1550
-
1551
- if (platformUsesHook(platforms, "claude-md")) {
1552
- const claude = path.join(target, "CLAUDE.md");
1553
- if (!fs.existsSync(claude) || fs.readFileSync(claude, "utf8").trim() === "@AGENTS.md") {
1554
- fs.writeFileSync(claude, next);
1555
- note("CLAUDE.md synchronized from AGENTS.md");
1802
+ note(current.trim() ? `${rel} synchronized from AGENTS.md` : `created ${rel}`);
1556
1803
  } else {
1557
- const patched = upsertMethodBlock(fs.readFileSync(claude, "utf8"), template);
1558
- fs.writeFileSync(claude, patched.endsWith("\n") ? patched : `${patched}\n`);
1559
- note("method block refreshed in CLAUDE.md");
1804
+ const patched = upsertMethodBlock(current, template);
1805
+ fs.writeFileSync(mirror, patched.endsWith("\n") ? patched : `${patched}\n`);
1806
+ note(`method block refreshed in ${rel}`);
1560
1807
  }
1561
1808
  }
1562
1809
  }
@@ -1612,12 +1859,17 @@ function printSummary(target, agents, { first, was, written, skipped, skills, to
1612
1859
  `run the ${INIT_SKILL} skill, intent ${DIM}readers${RESET}, ` +
1613
1860
  `to write it for this repo's stack`);
1614
1861
  }
1615
- const engReport = enginesReport(target);
1862
+ const engReport = enginesReport(target, agents);
1616
1863
  summaryLine("engines", engReport.present
1617
- ? `${ENGINE_SKILLS.join(" · ")} — found (in this repo)`
1618
- : `NOT found: ${engReport.missing.join(" · ")}. G5 (wdi-build) and the Fast Path need them; G1–G4 run without them`);
1864
+ ? `${ENGINE_SKILLS.join(" · ")} — found (in this repo), readable by every selected host`
1865
+ : engReport.missing.length
1866
+ ? `NOT found: ${engReport.missing.join(" · ")}. G5 (wdi-build) and the Fast Path need them; G1–G4 run without them`
1867
+ : `NOT readable by ${[...engReport.missingByHost.keys()].join(", ")}. G5 (wdi-build) and the Fast Path need them; G1–G4 run without them`);
1619
1868
  if (!engReport.present) {
1620
- summaryLine("", `${DIM}·${RESET} into THIS repo: ${DIM}${ENGINES_INSTALL_ANY}${RESET} — a user-level plugin does not count`);
1869
+ for (const [id, names] of engReport.missingByHost) {
1870
+ summaryLine("", `${DIM}·${RESET} ${id}: ${names.join(" · ")} — ${DIM}${ENGINES_INSTALL_ANY} --agent ${getPlatform(id).skillsCli}${RESET}`);
1871
+ }
1872
+ summaryLine("", `${DIM}·${RESET} into THIS repo — a user-level plugin does not count`);
1621
1873
  summaryLine("", `${DIM}·${RESET} docs/agents/ is already seeded, so ${DIM}${ENGINES_SETUP}${RESET} is not needed · ${ENGINES_REPO}`);
1622
1874
  } else {
1623
1875
  const blocked = engineInvocationState(target).blocked;
@@ -1735,8 +1987,10 @@ function apply(target, agents,
1735
1987
  // wrappers. A one-time fix would hold for about a week.
1736
1988
  enableEngineInvocation(target);
1737
1989
  retireBmadG5(target);
1738
- writeDenyRules(target);
1739
- writeStamp(target);
1990
+ writeDenyRules(target, agents);
1991
+ writeOpencodePermissions(target, agents);
1992
+ noteRetiredPlatforms(target);
1993
+ writeStamp(target, agents);
1740
1994
  printSummary(target, agents, { first, was, written, skipped, skills, tomls, opencodeCmds });
1741
1995
  printNextSteps({
1742
1996
  first,
@@ -1745,16 +1999,34 @@ function apply(target, agents,
1745
1999
  });
1746
2000
  }
1747
2001
 
1748
- function enginesCommand(target, { fix }) {
1749
- const before = enginesReport(target);
2002
+ /** A host the repo still names that the method dropped. Said, never acted on: its folder holds
2003
+ * whatever the product put there too, and on update absence is a decision the owner makes. */
2004
+ function noteRetiredPlatforms(target) {
2005
+ for (const id of retiredPlatformsIn(target, fs)) {
2006
+ note(`${id} is no longer supported — ${RETIRED_PLATFORMS[id]}. Its wdi-* skill folders were left `
2007
+ + "in place; remove them by hand when you are ready");
2008
+ }
2009
+ }
2010
+
2011
+ function enginesCommand(target, { fix, copy }) {
2012
+ const agents = declaredPlatforms(target, fs);
2013
+ if (copy) {
2014
+ const n = copyEnginesToHosts(target, agents);
2015
+ if (n) enableEngineInvocation(target);
2016
+ ok(n ? `copied ${n} engine folder${n === 1 ? "" : "s"}` : "nothing to copy — every selected host already reads the engines, or none are in the repo");
2017
+ }
2018
+ const before = enginesReport(target, agents);
1750
2019
  console.log("");
1751
- console.log(` engines ${before.present ? "all present" : `MISSING ${before.missing.join(" · ")}`}`);
2020
+ console.log(` engines ${before.present ? "all present" : before.missing.length ? `MISSING ${before.missing.join(" · ")}` : "present, but not where every host reads"}`);
2021
+ for (const [id, names] of before.missingByHost) {
2022
+ console.log(` ${id.padEnd(17)}cannot read ${names.join(" · ")} ${DIM}${ENGINES_INSTALL_ANY} --agent ${getPlatform(id).skillsCli}${RESET}`);
2023
+ }
1752
2024
  for (const name of ENGINE_SKILLS) {
1753
2025
  const copies = before.files.get(name);
1754
2026
  if (!copies) continue;
1755
2027
  const flagged = [...copies.keys()].some((f) =>
1756
2028
  /^disable-model-invocation\s*:\s*true/m.test(fs.readFileSync(f, "utf8")));
1757
- const where = [...copies.values()].map((f) => posixRel(target, f)).join(", ");
2029
+ const where = [...copies.values()].flat().map((f) => posixRel(target, f)).join(", ");
1758
2030
  console.log(` ${name.padEnd(17)}${flagged ? "flagged — no skill can invoke it" : "invocable"} ${DIM}${where}${RESET}`);
1759
2031
  }
1760
2032
  const banned = repoSkillFiles(target, BMAD_RETIRED_G5);
@@ -1782,12 +2054,13 @@ function enginesCommand(target, { fix }) {
1782
2054
  repairAgentDocs(target);
1783
2055
  enableEngineInvocation(target);
1784
2056
  retireBmadG5(target);
1785
- writeDenyRules(target);
1786
- writeStamp(target);
2057
+ writeDenyRules(target, agents);
2058
+ writeOpencodePermissions(target, agents);
2059
+ writeStamp(target, agents);
1787
2060
  ok("engines aligned");
1788
2061
  if (!before.present) {
1789
2062
  console.log("");
1790
- console.log(enginesMissingMessage(before.missing));
2063
+ console.log(enginesMissingMessage(before));
1791
2064
  }
1792
2065
  }
1793
2066
 
@@ -2024,7 +2297,7 @@ async function runWizard(pre) {
2024
2297
  // both is told about BMad first rather than sent to install the second thing.
2025
2298
  const engineGate = enginesReport(target);
2026
2299
  if (!engineGate.present && !pre.skipEngines) {
2027
- p.note(enginesMissingMessage(engineGate.missing), "Engines next");
2300
+ p.note(enginesMissingMessage(engineGate), "Engines next");
2028
2301
  p.outro("Install them into this repo, then run this again: npx wdi-method");
2029
2302
  process.exit(1);
2030
2303
  }
@@ -2101,17 +2374,48 @@ async function runWizard(pre) {
2101
2374
  const detected = pre.agents
2102
2375
  ? normalizePlatformIds(pre.agents)
2103
2376
  : detectPlatforms(target, fs);
2104
- const selected = cancelIf(
2377
+ let selected = cancelIf(
2105
2378
  await p.autocompleteMultiselect({
2106
2379
  message: "Which tools get the wdi-* skills? (⭐ = recommended)",
2107
2380
  options: platformSelectOptions(detected),
2108
2381
  initialValues: detected,
2109
2382
  required: true,
2110
2383
  maxItems: 8,
2111
- placeholder: "Type to search…",
2384
+ placeholder: `Type to search all ${PLATFORMS.length} hosts — Space selects, Enter confirms`,
2112
2385
  }),
2113
2386
  );
2114
2387
 
2388
+ // Step 2 again, now that the hosts are known. Present somewhere is not enough: Kiro reads
2389
+ // `.kiro/skills` only, so engines in `.agents/skills` leave it unable to run G5.
2390
+ let hostGate = enginesReport(target, selected);
2391
+ if (!hostGate.present && !pre.skipEngines && !hostGate.missing.length) {
2392
+ // The engines are in the repo, only not where these hosts read. That is fixable right here.
2393
+ const blind = [...hostGate.missingByHost.keys()];
2394
+ const names = blind.map((id) => getPlatform(id).name).join(", ");
2395
+ const choice = cancelIf(await p.select({
2396
+ message: `The engines are in this repo, but ${names} cannot read them. What now?`,
2397
+ options: [
2398
+ { value: "copy", label: `Copy them where ${names} read`, hint: "from this repo's own copy, no network" },
2399
+ { value: "drop", label: `Leave ${names} out of this install`, hint: "if this repo does not use them" },
2400
+ { value: "stop", label: "Stop — I will run npx skills add myself" },
2401
+ ],
2402
+ initialValue: "copy",
2403
+ }));
2404
+ if (choice === "copy") copyEnginesToHosts(target, selected);
2405
+ if (choice === "drop") selected = selected.filter((id) => !blind.includes(id));
2406
+ if (choice === "stop" || !selected.length) {
2407
+ p.note(enginesMissingMessage(hostGate), "Engines for the hosts you picked");
2408
+ p.outro("Install them where each host reads, then run this again: npx wdi-method");
2409
+ process.exit(1);
2410
+ }
2411
+ hostGate = enginesReport(target, selected);
2412
+ }
2413
+ if (!hostGate.present && !pre.skipEngines) {
2414
+ p.note(enginesMissingMessage(hostGate), "Engines for the hosts you picked");
2415
+ p.outro("Install them where each host reads, then run this again: npx wdi-method");
2416
+ process.exit(1);
2417
+ }
2418
+
2115
2419
  p.note(
2116
2420
  [
2117
2421
  "The corpus folder names are fixed — they are not an install option:",
@@ -2119,9 +2423,9 @@ async function runWizard(pre) {
2119
2423
  "",
2120
2424
  "What gets written for the platforms you picked:",
2121
2425
  " AGENTS.md (the BEGIN:wdi-method block — always)",
2122
- platformUsesHook(selected, "claude-md") ? " CLAUDE.md (method block mirror)" : "",
2123
- platformUsesHook(selected, "cursorrules") ? " .cursorrules (method block mirror)" : "",
2124
- platformUsesHook(selected, "agents-mirror") ? " .agents/AGENTS.md (method block mirror)" : "",
2426
+ ...ruleFiles(selected).slice(1).map((f) => ` ${f} (method block mirror)`),
2427
+ platformsWithManualOnly(selected, "claude").length ? " .claude/settings.json (deny rules, merged)" : "",
2428
+ platformsWithManualOnly(selected, "opencode").length ? " opencode.json (permission.skill: ask, merged)" : "",
2125
2429
  platformUsesHook(selected, "opencode-commands")
2126
2430
  ? ` ${opencodeCommandsDir()}/wdi-*.md (slash commands → skills)`
2127
2431
  : "",
@@ -2134,12 +2438,12 @@ async function runWizard(pre) {
2134
2438
 
2135
2439
  const okGo = cancelIf(await p.confirm({ message: first ? "Run the install?" : "Run the update?", initialValue: true }));
2136
2440
  if (!okGo) {
2137
- p.cancel("Dibatalkan.");
2441
+ p.cancel("Cancelled.");
2138
2442
  process.exit(0);
2139
2443
  }
2140
2444
 
2141
2445
  const spinner = p.spinner();
2142
- spinner.start(first ? "Memasang…" : "Meng-update…");
2446
+ spinner.start(first ? "Installing…" : "Updating…");
2143
2447
  apply(target, selected, {
2144
2448
  docLanguage,
2145
2449
  docFilenameLanguage,
@@ -2148,7 +2452,7 @@ async function runWizard(pre) {
2148
2452
  product: String(product).trim(),
2149
2453
  client: String(client).trim(),
2150
2454
  });
2151
- spinner.stop(first ? "Terpasang" : "Ter-update");
2455
+ spinner.stop(first ? "Installed" : "Updated");
2152
2456
  p.outro(first ? "Done. Take the after-install steps above." : "Done. Read the method-block diff in AGENTS.md.");
2153
2457
  }
2154
2458
 
@@ -2162,8 +2466,9 @@ function runNonInteractive(args) {
2162
2466
  if (!args.skipBmad && !bmadPresent(target)) {
2163
2467
  die(bmadMissingMessage());
2164
2468
  }
2165
- if (!args.skipEngines && !enginesPresent(target)) {
2166
- die(enginesMissingMessage(enginesReport(target).missing));
2469
+ if (args.copyEngines) copyEnginesToHosts(target, agents);
2470
+ if (!args.skipEngines && !enginesPresent(target, agents)) {
2471
+ die(enginesMissingMessage(enginesReport(target, agents)));
2167
2472
  }
2168
2473
  const existing = readIndexIdentity(target);
2169
2474
  const product = args.product || existing.name;
@@ -2181,7 +2486,7 @@ function runNonInteractive(args) {
2181
2486
 
2182
2487
  async function main() {
2183
2488
  const args = parseArgs(process.argv);
2184
- if (!["wizard", "install", "update", "verify", "promote", "engines"].includes(args.cmd)) {
2489
+ if (!["wizard", "install", "update", "verify", "promote", "engines", "upgrade-check"].includes(args.cmd)) {
2185
2490
  usage();
2186
2491
  process.exit(2);
2187
2492
  }
@@ -2205,7 +2510,11 @@ async function main() {
2205
2510
  return;
2206
2511
  }
2207
2512
  if (args.cmd === "engines") {
2208
- enginesCommand(requireTarget(args.dir), { fix: Boolean(args.fix) });
2513
+ enginesCommand(requireTarget(args.dir), { fix: Boolean(args.fix), copy: Boolean(args.copyEngines) });
2514
+ return;
2515
+ }
2516
+ if (args.cmd === "upgrade-check") {
2517
+ process.exitCode = upgradeCheck(requireTarget(args.dir));
2209
2518
  return;
2210
2519
  }
2211
2520
  const wantTui = !args.yes && args.cmd !== "verify" && process.stdin.isTTY && process.stdout.isTTY;