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/README.md +37 -10
- package/bin/wdi-method.js +498 -27
- package/kit/.constitution/method/constitution.md +4 -1
- package/kit/.constitution/method/document/bmad-guide.md +10 -6
- package/kit/.constitution/method/document/bmad-skill-register.md +36 -6
- package/kit/.constitution/method/document/corpus-guide.md +35 -0
- package/kit/.constitution/method/method-glossary.md +184 -183
- package/kit/.constitution/method/scripts/validate.py +179 -11
- package/kit/.constitution/method/why/README.md +192 -186
- package/kit/.constitution/method/why/artifact-map.md +1 -1
- package/kit/skills/wdi-autopilot/SKILL.md +383 -375
- package/kit/skills/wdi-blueprint/SKILL.md +3 -3
- package/kit/skills/wdi-build/SKILL.md +393 -371
- package/kit/skills/wdi-help/SKILL.md +111 -110
- package/kit/skills/wdi-init/SKILL.md +19 -1
- package/kit/skills/wdi-product/SKILL.md +22 -0
- package/kit/skills/wdi-upgrade/SKILL.md +214 -187
- package/kit-overlay/constitution.md +4 -1
- package/package.json +1 -1
- package/scaffold/docs/agents/issue-tracker.md +56 -43
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
|
|
70
|
-
//
|
|
71
|
-
//
|
|
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
|
-
/**
|
|
297
|
-
|
|
298
|
-
|
|
299
|
-
|
|
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
|
-
|
|
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
|
-
`
|
|
336
|
-
|
|
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
|
|
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
|
-
|
|
897
|
-
|
|
898
|
-
|
|
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
|
-
|
|
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
|
-
|
|
1115
|
-
|
|
1116
|
-
|
|
1117
|
-
|
|
1118
|
-
|
|
1119
|
-
summaryLine("", `${DIM}·${RESET}
|
|
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
|
-
?
|
|
1440
|
-
: `
|
|
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);
|