wdi-method 0.6.7 → 0.6.8

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/README.md CHANGED
@@ -42,16 +42,26 @@ cd /path/to/your/product-repo
42
42
  npx bmad-method install
43
43
  ```
44
44
 
45
- **2. mattpocock/skills** — one of the two paths, not both (both leaves every skill twice):
45
+ **2. mattpocock/skills** — **into this repo**, on every agent:
46
46
 
47
47
  ```bash
48
- # Claude Code — a managed bundle that updates when its author ships
49
- claude plugins install mattpocock-skills # or, inside a session: /plugin install mattpocock-skills
50
-
51
- # Codex, Cursor, OpenCode, other agents — copies the skills into this repo; keep setup-matt-pocock-skills selected
52
48
  npx skills@latest add mattpocock/skills
53
49
  ```
54
50
 
51
+ Take all six the method drives: `to-spec`, `to-tickets`, `implement`, `tdd`, `code-review`, and
52
+ `domain-modeling`. Either install mode works — "copy" or "symlink".
53
+
54
+ **The Claude Code plugin is not an alternative here, and the reason is mechanical.** `to-spec`,
55
+ `to-tickets` and `implement` ship with `disable-model-invocation: true`, so no skill can invoke them;
56
+ nothing outside the file lifts that flag, and a plugin's files are not this repo's to edit. WDI Method
57
+ strips it from the copies the repo owns — which is what lets `wdi-build` invoke an engine and
58
+ `wdi-autopilot` run an iteration with nobody watching — and re-applies that on every update, because
59
+ `npx skills update` restores the author's file. The installer refuses without the six, and
60
+ `--skip-engines-check` is the escape for CI and for a repo that will never reach G5.
61
+
62
+ If you also have the plugin installed for your user, the repo's copies are what run; removing the
63
+ plugin keeps `/to-spec` unambiguous.
64
+
55
65
  **You do not need to run `/setup-matt-pocock-skills` to get started.** Step 3 seeds `docs/agents/` with
56
66
  the two answers WDI Method actually has a requirement on, so the engines are aligned from the first
57
67
  install. Run the setup skill only to *change* something — to point at GitHub or Jira instead of local
@@ -450,6 +460,24 @@ English, whatever the settings say — it travels to every repo through this pac
450
460
 
451
461
  It prints the version it replaced, what it wrote, what it kept, and what to do next.
452
462
 
463
+ ### Moving a repo from 0.6.7 or earlier to 0.6.8
464
+
465
+ Four things change for a repo already running the method. The first is the only one that can stop an
466
+ update, and all four are mechanical.
467
+
468
+ | What changed | What it means for your repo |
469
+ |---|---|
470
+ | **The engines must be in the repo** | `install` and `update` refuse until `to-spec`, `to-tickets`, `implement`, `tdd`, `code-review` and `domain-modeling` are here — `npx skills@latest add mattpocock/skills`. The Claude Code plugin no longer counts: three of the six ship locked against skill invocation, nothing outside the file unlocks them, and a plugin's files are not yours to edit. `--skip-engines-check` still installs without them |
471
+ | **The engines are invoked, not handed to you** | `wdi-build` calls `to-spec`, `to-tickets`, `implement`, `tdd` and `code-review` itself, so `wdi-autopilot` can finish a spec with nobody watching. Every `update` re-unlocks the repo's copies, because `npx skills update` puts the author's lock back — and `engines-invocable` in `validate.py` goes red when it has |
472
+ | **Thirteen BMad skills are retired at G5, and now enforced** | `bmad-spec`, `bmad-build`, `bmad-build-auto`, `bmad-code-review`, `bmad-retrospective`, `bmad-agent-dev`, `bmad-create-epics-and-stories`, `bmad-create-story`, `bmad-dev-story`, `bmad-dev-auto`, `bmad-quick-dev`, `bmad-sprint-planning`, `bmad-sprint-status`. Each is locked out of model invocation and denied in `.claude/settings.json`; typing the slash command yourself still works. `bmad-skill-register.md` carries the list and the criterion — retired only where this method has a named replacement, which is why `bmad-qa-generate-e2e-tests` and `bmad-checkpoint-preview` are not on it |
473
+ | **A spec has one predefined home** | `.scratch/<spec-id>-<slug>/`, with `SPEC.md` and `issues/<NN>-<slug>.md` inside it. Left free, that folder name gets written a different way in every repo and traces back to nothing. A row in `specs.yaml` is now what makes an effort a spec rather than ad hoc work — the path no longer says |
474
+
475
+ Run the `wdi-upgrade` skill after updating: it names what is still in the old shape, including a
476
+ `docs/agents/issue-tracker.md` that still carries `/setup-matt-pocock-skills`' own answer, and the spec
477
+ folders that need moving. `npx wdi-method engines` reports the engine state on its own, and
478
+ `npx wdi-method engines --fix` repairs what can be repaired without touching anything you wrote — the
479
+ previous config is kept as `.bak`.
480
+
453
481
  ---
454
482
 
455
483
  ## Changing the method
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.",
571
+ "",
572
+ ` ${ENGINES_INSTALL_ANY}`,
334
573
  "",
335
- ` Claude Code: ${ENGINES_INSTALL}`,
336
- ` Other agents: ${ENGINES_INSTALL_ANY}`,
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
  }
@@ -1111,12 +1402,25 @@ function printSummary(target, agents, { first, was, written, skipped, skills, to
1111
1402
  `run the ${INIT_SKILL} skill, intent ${DIM}readers${RESET}, ` +
1112
1403
  `to write it for this repo's stack`);
1113
1404
  }
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}`);
1405
+ const engReport = enginesReport(target);
1406
+ summaryLine("engines", engReport.present
1407
+ ? `${ENGINE_SKILLS.join(" · ")} — found (in this repo)`
1408
+ : `NOT found: ${engReport.missing.join(" · ")}. G5 (wdi-build) and the Fast Path need them; G1–G4 run without them`);
1409
+ if (!engReport.present) {
1410
+ summaryLine("", `${DIM}·${RESET} into THIS repo: ${DIM}${ENGINES_INSTALL_ANY}${RESET} a user-level plugin does not count`);
1411
+ summaryLine("", `${DIM}·${RESET} docs/agents/ is already seeded, so ${DIM}${ENGINES_SETUP}${RESET} is not needed · ${ENGINES_REPO}`);
1412
+ } else {
1413
+ const blocked = engineInvocationState(target).blocked;
1414
+ if (blocked.length) {
1415
+ summaryLine("", `${DIM}·${RESET} still flagged, so no skill can invoke ${blocked.join(" · ")} — run ${DIM}npx wdi-method engines --fix${RESET}`);
1416
+ }
1417
+ }
1418
+ // Upstream's own warning: "installing both leaves you with every skill twice." It is survivable —
1419
+ // the plugin's copies are namespaced and still flagged, so they can neither be invoked nor shadow
1420
+ // the repo's — but `/to-spec` in the UI stops being one thing, so it is said out loud.
1421
+ if (pluginEnginesRegistered()) {
1422
+ summaryLine("", `${DIM}·${RESET} the ${ENGINES_PLUGIN} plugin is ALSO installed for this user — the repo's copies are what run;`);
1423
+ summaryLine("", `${DIM}·${RESET} remove the plugin to keep ${DIM}/to-spec${RESET} unambiguous`);
1120
1424
  }
1121
1425
  const pending = first ? [] : pendingUpgrades(target);
1122
1426
  if (pending.length) {
@@ -1215,6 +1519,12 @@ function apply(target, agents,
1215
1519
  setProductIdentity(target, { name: product, client });
1216
1520
  setLanguagePolicy(target, { docLanguage, docFilenameLanguage, chosen: languageChosen });
1217
1521
  upsertAgentFiles(target, agents, product);
1522
+ // Mechanical, idempotent, and re-run on EVERY update because both sides are restored behind our
1523
+ // back: `npx skills update` puts the author's flag back, and BMad's installer rewrites its own
1524
+ // wrappers. A one-time fix would hold for about a week.
1525
+ enableEngineInvocation(target);
1526
+ retireBmadG5(target);
1527
+ writeDenyRules(target);
1218
1528
  writeStamp(target);
1219
1529
  printSummary(target, agents, { first, was, written, skipped, skills, tomls, opencodeCmds });
1220
1530
  printNextSteps({
@@ -1224,6 +1534,52 @@ function apply(target, agents,
1224
1534
  });
1225
1535
  }
1226
1536
 
1537
+ function enginesCommand(target, { fix }) {
1538
+ const before = enginesReport(target);
1539
+ console.log("");
1540
+ console.log(` engines ${before.present ? "all present" : `MISSING ${before.missing.join(" · ")}`}`);
1541
+ for (const name of ENGINE_SKILLS) {
1542
+ const copies = before.files.get(name);
1543
+ if (!copies) continue;
1544
+ const flagged = [...copies.keys()].some((f) =>
1545
+ /^disable-model-invocation\s*:\s*true/m.test(fs.readFileSync(f, "utf8")));
1546
+ const where = [...copies.values()].map((f) => posixRel(target, f)).join(", ");
1547
+ console.log(` ${name.padEnd(17)}${flagged ? "flagged — no skill can invoke it" : "invocable"} ${DIM}${where}${RESET}`);
1548
+ }
1549
+ const banned = repoSkillFiles(target, BMAD_RETIRED_G5);
1550
+ const open = [];
1551
+ for (const [name, copies] of banned) {
1552
+ const shut = [...copies.keys()].every((f) =>
1553
+ /^disable-model-invocation\s*:\s*true/m.test(fs.readFileSync(f, "utf8")));
1554
+ if (!shut) open.push(name);
1555
+ }
1556
+ console.log(` bmad G5 ${banned.size} installed, ${open.length ? `STILL model-invocable: ${open.join(" · ")}` : "all retired"}`);
1557
+ const tracker = path.join(target, "docs", "agents", "issue-tracker.md");
1558
+ const trackerOwn = fs.existsSync(tracker)
1559
+ && fs.readFileSync(tracker, "utf8").includes("seeded by `wdi-method`");
1560
+ 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"}`);
1561
+ console.log("");
1562
+
1563
+ if (!fix) {
1564
+ if (!before.present || open.length || !trackerOwn
1565
+ || engineInvocationState(target).blocked.length) {
1566
+ console.log(` ${DIM}to repair what can be repaired:${RESET} npx wdi-method engines --fix`);
1567
+ console.log("");
1568
+ }
1569
+ return;
1570
+ }
1571
+ repairAgentDocs(target);
1572
+ enableEngineInvocation(target);
1573
+ retireBmadG5(target);
1574
+ writeDenyRules(target);
1575
+ writeStamp(target);
1576
+ ok("engines aligned");
1577
+ if (!before.present) {
1578
+ console.log("");
1579
+ console.log(enginesMissingMessage(before.missing));
1580
+ }
1581
+ }
1582
+
1227
1583
  function verify(target, agents) {
1228
1584
  requireKit();
1229
1585
  const missing = [];
@@ -1436,8 +1792,11 @@ async function runWizard(pre) {
1436
1792
  : "BMad Method: not installed",
1437
1793
  hasWdi ? "WDI Method: already present — the installer will offer an update" : "WDI Method: not present",
1438
1794
  enginesPresent(target)
1439
- ? "Ticket engines (mattpocock-skills): installed"
1440
- : `Ticket engines (mattpocock-skills): not foundneeded at G5 only; ${ENGINES_INSTALL} (${ENGINES_REPO})`,
1795
+ ? `Engines (mattpocock/skills, in this repo): all ${ENGINE_SKILLS.length} present`
1796
+ : `Engines: MISSING ${enginesReport(target).missing.join(" · ")} — ${ENGINES_INSTALL_ANY} (${ENGINES_REPO})`,
1797
+ engineInvocationState(target).blocked.length
1798
+ ? `Engine invocation: BLOCKED for ${engineInvocationState(target).blocked.join(" · ")} — npx wdi-method engines --fix`
1799
+ : "Engine invocation: enabled (the author's disable-model-invocation is stripped from the repo's copies)",
1441
1800
  nonempty ? "Folder is not empty (normal for a product repo already under way)" : "Folder is empty",
1442
1801
  ].join("\n");
1443
1802
  p.note(facts, "Detected");
@@ -1582,7 +1941,7 @@ function runNonInteractive(args) {
1582
1941
  die(bmadMissingMessage());
1583
1942
  }
1584
1943
  if (!args.skipEngines && !enginesPresent(target)) {
1585
- die(enginesMissingMessage());
1944
+ die(enginesMissingMessage(enginesReport(target).missing));
1586
1945
  }
1587
1946
  const existing = readIndexIdentity(target);
1588
1947
  const product = args.product || existing.name;
@@ -1600,7 +1959,7 @@ function runNonInteractive(args) {
1600
1959
 
1601
1960
  async function main() {
1602
1961
  const args = parseArgs(process.argv);
1603
- if (!["wizard", "install", "update", "verify", "promote"].includes(args.cmd)) {
1962
+ if (!["wizard", "install", "update", "verify", "promote", "engines"].includes(args.cmd)) {
1604
1963
  usage();
1605
1964
  process.exit(2);
1606
1965
  }
@@ -1623,6 +1982,10 @@ async function main() {
1623
1982
  promote(args.dir);
1624
1983
  return;
1625
1984
  }
1985
+ if (args.cmd === "engines") {
1986
+ enginesCommand(requireTarget(args.dir), { fix: Boolean(args.fix) });
1987
+ return;
1988
+ }
1626
1989
  const wantTui = !args.yes && args.cmd !== "verify" && process.stdin.isTTY && process.stdout.isTTY;
1627
1990
  if (wantTui) {
1628
1991
  await runWizard(args);
@@ -25,10 +25,13 @@ The repo layout is governed by `corpus-guide.md` and mapped by
25
25
  | `.how/` | How it is built |
26
26
  | `.what-rendered/` · `.how-rendered/` | The two above, assembled for a human to read — one page per gate, at the mirror path. Regenerated by `validate.py`, never edited, never read by a skill |
27
27
  | `_bmad-output/` | Run workspace; MUST be in git, not curated |
28
+ | `.scratch/` | One directory per effort: a spec's `SPEC.md` and its ticket files, and ad hoc work that has no `FR` yet. MUST be in git — the corpus cites into it by path |
28
29
  | `.work/` | Scratch; MUST be in git, emptied when a task closes |
29
30
  | *(application roots)* | Application code — named and mapped in `.control/structure-codebase.md` |
30
31
 
31
- The method does not use a `docs/` layer for corpus or rules. A leftover `docs/`
32
+ **One exception, and it is not corpus.** `docs/agents/issue-tracker.md` and `docs/agents/domain.md` are the ENGINES' config, and their path belongs to the engines' author — `to-spec`, `to-tickets`, `implement` and `triage` read those two files and nowhere else. What they SAY is this method's, and `wdi-method` seeds both already answered. Neither is corpus, neither is a rule, and nothing else MAY be added beside them.
33
+
34
+ Otherwise the method does not use a `docs/` layer for corpus or rules. A leftover `docs/`
32
35
  folder is inventory to sort, not a second home.
33
36
 
34
37
  ## Article 4 — Lifecycle
@@ -47,12 +47,16 @@ inventory, and putting somebody else's skill in it makes the register a lie.
47
47
 
48
48
  No → **absorb the discipline and invoke nothing.** Prose we could have written is not an engine.
49
49
 
50
- An engine whose output this corpus keeps is invoked even when it is a **plugin rather than part of this
51
- package's install** — `mattpocock-skills:domain-modeling` is the case, and `wdi-blueprint` invokes it. Two
52
- rules make that safe in a repo that does not have it:
53
-
54
- - **A missing plugin is a state, not a defect.** Report it once, name the standard the work is still held
55
- to, and do the work. You MUST NOT block a gate on it, and you MUST NOT report its absence as a finding.
50
+ An engine whose output this corpus keeps is invoked even though it is **not part of this package's
51
+ install** — `domain-modeling` is the case, and `wdi-blueprint` invokes it. It is one of the six the
52
+ installer requires IN the repo, so it is named without a plugin prefix: a repo may hold the engines as
53
+ its own files or not at all, and `mattpocock-skills:domain-modeling` resolves to nothing in the first
54
+ case. Two rules make the reference safe in a repo that does not have it:
55
+
56
+ - **A missing engine is a state, not a defect — at G1–G4.** Report it once, name the standard the work
57
+ is still held to, and do the work. You MUST NOT block one of those gates on it, and you MUST NOT
58
+ report its absence as a finding. G5 is the exception and always was: `wdi-build` cannot run without
59
+ the engines, and the installer refuses rather than let that be discovered with a spec already open.
56
60
  - **The wrapper carries the standard, never the plugin.** What the engine is invoked *for* MUST be written
57
61
  in the wrapping skill as behaviours to verify — so the same bar is met either way, and a run that
58
62
  produced none of them is reported as a transcription rather than landed.