wdi-method 0.6.7 → 0.6.15

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/bin/wdi-method.js CHANGED
@@ -3,6 +3,7 @@ import fs from "node:fs";
3
3
  import os from "node:os";
4
4
  import path from "node:path";
5
5
  import { spawnSync } from "node:child_process";
6
+ import { createHash } from "node:crypto";
6
7
  import { fileURLToPath } from "node:url";
7
8
  import * as p from "@clack/prompts";
8
9
  import {
@@ -66,14 +67,65 @@ const GENERIC_FOLDER_PATTERNS = new Set([
66
67
  ]);
67
68
 
68
69
  const BMAD_INSTALL = `npx bmad-method install`;
69
- // The ticket engines G5 runs. BMad writes the documents; these cut the work. They are a Claude Code
70
- // plugin installed per USER, not per repo, so the check reads the plugin registry — and the check
71
- // warns instead of blocking, because G1–G4 run without them and a first install has no G5 yet.
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".
72
81
  const ENGINES_REPO = "https://github.com/mattpocock/skills";
73
82
  const ENGINES_PLUGIN = "mattpocock-skills";
74
83
  const ENGINES_INSTALL = `/plugin install ${ENGINES_PLUGIN}`;
75
84
  const ENGINES_INSTALL_ANY = "npx skills@latest add mattpocock/skills";
76
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
+ ];
77
129
  const REPO_URL = "https://github.com/wiradigitalid/wdi-method";
78
130
  const HELP_SKILL = "wdi-help";
79
131
  const INIT_SKILL = "wdi-init";
@@ -113,6 +165,7 @@ function usage() {
113
165
  install [dir] first install (TUI unless --yes)
114
166
  update [dir] update (TUI unless --yes)
115
167
  verify [dir]
168
+ engines [dir] [--fix] report the six engines, their invocation state, and the BMad G5 ban
116
169
  promote <live-dir> --rescue pull a method change back out of a consumer (not the normal flow)
117
170
 
118
171
  --yes non-interactive
@@ -136,6 +189,7 @@ function parseArgs(argv) {
136
189
  agents: null,
137
190
  skipBmad: false,
138
191
  rescue: false,
192
+ fix: false,
139
193
  yes: false,
140
194
  product: null,
141
195
  client: null,
@@ -156,7 +210,7 @@ function parseArgs(argv) {
156
210
  return args;
157
211
  }
158
212
  const first = rest[0];
159
- if (["install", "update", "verify", "promote"].includes(first)) {
213
+ if (["install", "update", "verify", "promote", "engines"].includes(first)) {
160
214
  args.cmd = rest.shift();
161
215
  } else if (first.startsWith("-")) {
162
216
  args.cmd = "wizard";
@@ -167,6 +221,7 @@ function parseArgs(argv) {
167
221
  while (rest.length) {
168
222
  const t = rest.shift();
169
223
  if (t === "--skip-bmad-check") args.skipBmad = true;
224
+ else if (t === "--fix") args.fix = true;
170
225
  else if (t === "--skip-engines-check") args.skipEngines = true;
171
226
  else if (t === "--rescue") args.rescue = true;
172
227
  else if (t === "--yes" || t === "-y") args.yes = true;
@@ -293,11 +348,45 @@ function requireTarget(dir) {
293
348
  return target;
294
349
  }
295
350
 
296
- /** `to-spec` · `to-tickets` · `implement` present as a user-level plugin, or copied into the repo. */
297
- function enginesPresent(target) {
298
- for (const dir of [".claude", ".agents", ".agent", ".cursor", ".codex"]) {
299
- if (fs.existsSync(path.join(target, dir, "skills", "to-tickets", "SKILL.md"))) return true;
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
+ }
300
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() {
301
390
  const cfg = process.env.CLAUDE_CONFIG_DIR || path.join(os.homedir(), ".claude");
302
391
  const registry = path.join(cfg, "plugins", "installed_plugins.json");
303
392
  if (!fs.existsSync(registry)) return false;
@@ -309,6 +398,149 @@ function enginesPresent(target) {
309
398
  }
310
399
  }
311
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
+
312
544
  function bmadMissingMessage() {
313
545
  return [
314
546
  "BMad Method is not installed in this repo. Install it first, then run this installer again.",
@@ -328,12 +560,18 @@ function bmadMissingMessage() {
328
560
  //
329
561
  // So it blocks, and `--skip-engines-check` is the escape, exactly as `--skip-bmad-check` is for BMad. The
330
562
  // escape matters: CI installs into a bare checkout, and a repo that will never reach G5 is a real case.
331
- function enginesMissingMessage() {
563
+ function enginesMissingMessage(missing) {
564
+ const names = (missing && missing.length ? missing : ENGINE_SKILLS).join(" · ");
332
565
  return [
333
- "The ticket engines are not installed. G5 (wdi-build) and wdi-autopilot need all three.",
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.",
334
571
  "",
335
- ` Claude Code: ${ENGINES_INSTALL}`,
336
- ` Other agents: ${ENGINES_INSTALL_ANY}`,
572
+ ` ${ENGINES_INSTALL_ANY}`,
573
+ "",
574
+ `Take all six: ${ENGINE_SKILLS.join(" · ")}. Choose "copy" or "symlink" — either is read.`,
337
575
  "",
338
576
  "You do NOT need to run the setup skill after this — the installer seeds docs/agents/ already",
339
577
  `answered for this method. Run ${ENGINES_SETUP} only to change tracker.`,
@@ -343,6 +581,39 @@ function enginesMissingMessage() {
343
581
  ].join("\n");
344
582
  }
345
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
+
346
617
  // The product's custom room. Three properties, and all three MUST hold together:
347
618
  // install/update seeds its content ONLY when absent — never written again after that
348
619
  // promote SKIPS it entirely, so a product's own rules can never reach the public repo
@@ -889,13 +1160,33 @@ function seedEmptyLayers(target, { first }) {
889
1160
  function writeStamp(target) {
890
1161
  const control = path.join(target, ".control");
891
1162
  if (!fs.existsSync(control)) return;
892
- const stamp = [
1163
+ const eng = enginesReport(target);
1164
+ const fp = engineFingerprints(target);
1165
+ const names = Object.keys(fp);
1166
+ const lines = [
893
1167
  "# Written by wdi-method install/update. A trace, not a lockfile.",
894
1168
  `wdi_method: ${PKG.version}`,
895
1169
  `bmad_method: ${readBmadVersion(target) || '""'}`,
896
- `installed_at: ${today()}`,
897
- "",
898
- ].join("\n");
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");
899
1190
  fs.writeFileSync(path.join(control, "wdi-method.yaml"), stamp, "utf8");
900
1191
  note("stamped .control/wdi-method.yaml");
901
1192
  }
@@ -943,6 +1234,78 @@ function setLanguagePolicy(target, { docLanguage, docFilenameLanguage, chosen })
943
1234
  // move, because moving it takes a decision about meaning: which PRD an `FR` belongs to, whether a
944
1235
  // sentence was an assumption or a constraint. The `wdi-upgrade` skill does that half. This only
945
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
+
946
1309
  function pendingUpgrades(target) {
947
1310
  const has = (...p) => fs.existsSync(path.join(target, ...p));
948
1311
  const read = (...p) => (has(...p) ? fs.readFileSync(path.join(target, ...p), "utf8") : "");
@@ -956,7 +1319,32 @@ function pendingUpgrades(target) {
956
1319
  };
957
1320
  const items = [];
958
1321
  if (has(".control", "registry", "requirements.yaml")) items.push("requirements.yaml → goals.yaml + requirements-<slug>.yaml");
959
- if (/^\s*-\s*id:\s*W\d+|^\s*(epics|stories):/m.test(read(".control", "registry", "specs.yaml"))) items.push("specs.yaml rows still W<n>/epics/stories (wdi-build re-cuts)");
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
+ }
960
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");
961
1349
  // Sections by NAME: the numbers moved between kits (Non-Goals was §7 in one, §5 in the next).
962
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");
@@ -1111,12 +1499,25 @@ function printSummary(target, agents, { first, was, written, skipped, skills, to
1111
1499
  `run the ${INIT_SKILL} skill, intent ${DIM}readers${RESET}, ` +
1112
1500
  `to write it for this repo's stack`);
1113
1501
  }
1114
- summaryLine("engines", enginesPresent(target)
1115
- ? `to-spec · to-tickets · implement — found (${ENGINES_PLUGIN})`
1116
- : `to-spec · to-tickets · implement NOT found. G5 (wdi-build) and the Fast Path need them; G1–G4 run without them`);
1117
- if (!enginesPresent(target)) {
1118
- summaryLine("", `${DIM}·${RESET} Claude Code: ${DIM}${ENGINES_INSTALL}${RESET} — other agents: ${DIM}${ENGINES_INSTALL_ANY}${RESET}`);
1119
- summaryLine("", `${DIM}·${RESET} then ${DIM}${ENGINES_SETUP}${RESET} once, to name the tracker · ${ENGINES_REPO}`);
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`);
1120
1521
  }
1121
1522
  const pending = first ? [] : pendingUpgrades(target);
1122
1523
  if (pending.length) {
@@ -1215,6 +1616,12 @@ function apply(target, agents,
1215
1616
  setProductIdentity(target, { name: product, client });
1216
1617
  setLanguagePolicy(target, { docLanguage, docFilenameLanguage, chosen: languageChosen });
1217
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);
1218
1625
  writeStamp(target);
1219
1626
  printSummary(target, agents, { first, was, written, skipped, skills, tomls, opencodeCmds });
1220
1627
  printNextSteps({
@@ -1224,6 +1631,52 @@ function apply(target, agents,
1224
1631
  });
1225
1632
  }
1226
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
+
1227
1680
  function verify(target, agents) {
1228
1681
  requireKit();
1229
1682
  const missing = [];
@@ -1436,8 +1889,11 @@ async function runWizard(pre) {
1436
1889
  : "BMad Method: not installed",
1437
1890
  hasWdi ? "WDI Method: already present — the installer will offer an update" : "WDI Method: not present",
1438
1891
  enginesPresent(target)
1439
- ? "Ticket engines (mattpocock-skills): installed"
1440
- : `Ticket engines (mattpocock-skills): not foundneeded at G5 only; ${ENGINES_INSTALL} (${ENGINES_REPO})`,
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)",
1441
1897
  nonempty ? "Folder is not empty (normal for a product repo already under way)" : "Folder is empty",
1442
1898
  ].join("\n");
1443
1899
  p.note(facts, "Detected");
@@ -1448,6 +1904,17 @@ async function runWizard(pre) {
1448
1904
  process.exit(1);
1449
1905
  }
1450
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
+
1451
1918
  let first = !hasWdi;
1452
1919
  if (hasWdi) {
1453
1920
  const update = cancelIf(
@@ -1582,7 +2049,7 @@ function runNonInteractive(args) {
1582
2049
  die(bmadMissingMessage());
1583
2050
  }
1584
2051
  if (!args.skipEngines && !enginesPresent(target)) {
1585
- die(enginesMissingMessage());
2052
+ die(enginesMissingMessage(enginesReport(target).missing));
1586
2053
  }
1587
2054
  const existing = readIndexIdentity(target);
1588
2055
  const product = args.product || existing.name;
@@ -1600,7 +2067,7 @@ function runNonInteractive(args) {
1600
2067
 
1601
2068
  async function main() {
1602
2069
  const args = parseArgs(process.argv);
1603
- if (!["wizard", "install", "update", "verify", "promote"].includes(args.cmd)) {
2070
+ if (!["wizard", "install", "update", "verify", "promote", "engines"].includes(args.cmd)) {
1604
2071
  usage();
1605
2072
  process.exit(2);
1606
2073
  }
@@ -1623,6 +2090,10 @@ async function main() {
1623
2090
  promote(args.dir);
1624
2091
  return;
1625
2092
  }
2093
+ if (args.cmd === "engines") {
2094
+ enginesCommand(requireTarget(args.dir), { fix: Boolean(args.fix) });
2095
+ return;
2096
+ }
1626
2097
  const wantTui = !args.yes && args.cmd !== "verify" && process.stdin.isTTY && process.stdout.isTTY;
1627
2098
  if (wantTui) {
1628
2099
  await runWizard(args);