wdi-method 0.6.4 → 0.6.6
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 +495 -487
- package/bin/wdi-method.js +1641 -1636
- package/kit/.constitution/method/constitution.md +2 -2
- package/kit/.constitution/method/repo-guide.md +5 -0
- package/kit/.constitution/method/scripts/validate.py +2627 -2561
- package/kit-overlay/constitution.md +2 -2
- package/kit-overlay/repo-guide.md +5 -0
- package/package.json +1 -1
package/bin/wdi-method.js
CHANGED
|
@@ -1,1636 +1,1641 @@
|
|
|
1
|
-
#!/usr/bin/env node
|
|
2
|
-
import fs from "node:fs";
|
|
3
|
-
import os from "node:os";
|
|
4
|
-
import path from "node:path";
|
|
5
|
-
import { spawnSync } from "node:child_process";
|
|
6
|
-
import { fileURLToPath } from "node:url";
|
|
7
|
-
import * as p from "@clack/prompts";
|
|
8
|
-
import {
|
|
9
|
-
fillProductTitle,
|
|
10
|
-
upsertMethodBlock,
|
|
11
|
-
} from "../lib/agents-block.mjs";
|
|
12
|
-
import {
|
|
13
|
-
identityIsPlaceholder,
|
|
14
|
-
humaniseFolderName,
|
|
15
|
-
readLanguagePolicy,
|
|
16
|
-
writeLanguagePolicy,
|
|
17
|
-
DEFAULT_DOC_LANGUAGE,
|
|
18
|
-
readProductIdentity,
|
|
19
|
-
writeProductIdentity,
|
|
20
|
-
} from "../lib/identity.mjs";
|
|
21
|
-
import {
|
|
22
|
-
detectPlatforms,
|
|
23
|
-
formatPlatformList,
|
|
24
|
-
isKnownPlatform,
|
|
25
|
-
normalizePlatformIds,
|
|
26
|
-
platformSelectOptions,
|
|
27
|
-
platformUsesHook,
|
|
28
|
-
PREFERRED_PLATFORM_IDS,
|
|
29
|
-
skillDestinations,
|
|
30
|
-
} from "../lib/platforms.mjs";
|
|
31
|
-
import { opencodeCommandsDir, syncOpencodeCommands } from "../lib/opencode-commands.mjs";
|
|
32
|
-
|
|
33
|
-
const ROOT = path.resolve(path.dirname(fileURLToPath(import.meta.url)), "..");
|
|
34
|
-
const KIT = path.join(ROOT, "kit");
|
|
35
|
-
const OVERLAY = path.join(ROOT, "kit-overlay");
|
|
36
|
-
const SCAFFOLD = path.join(ROOT, "scaffold", ".control");
|
|
37
|
-
const PKG = JSON.parse(fs.readFileSync(path.join(ROOT, "package.json"), "utf8"));
|
|
38
|
-
|
|
39
|
-
const WDI_SKILLS = [
|
|
40
|
-
"wdi-init",
|
|
41
|
-
"wdi-problem",
|
|
42
|
-
"wdi-product",
|
|
43
|
-
"wdi-ux",
|
|
44
|
-
"wdi-blueprint",
|
|
45
|
-
"wdi-component",
|
|
46
|
-
"wdi-build",
|
|
47
|
-
"wdi-decision",
|
|
48
|
-
"wdi-question",
|
|
49
|
-
"wdi-log",
|
|
50
|
-
"wdi-help",
|
|
51
|
-
"wdi-explain-to-me",
|
|
52
|
-
"wdi-autopilot",
|
|
53
|
-
"wdi-reconcile",
|
|
54
|
-
"wdi-review",
|
|
55
|
-
"wdi-report",
|
|
56
|
-
"wdi-systematic-debugging",
|
|
57
|
-
"wdi-upgrade",
|
|
58
|
-
];
|
|
59
|
-
|
|
60
|
-
const PRD_SLUG_PLACEHOLDER = "FILL-initiative-slug";
|
|
61
|
-
const GENERIC_FOLDER_PATTERNS = new Set([
|
|
62
|
-
"_product-brief",
|
|
63
|
-
"ux",
|
|
64
|
-
"architecture",
|
|
65
|
-
PRD_SLUG_PLACEHOLDER,
|
|
66
|
-
]);
|
|
67
|
-
|
|
68
|
-
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.
|
|
72
|
-
const ENGINES_REPO = "https://github.com/mattpocock/skills";
|
|
73
|
-
const ENGINES_PLUGIN = "mattpocock-skills";
|
|
74
|
-
const ENGINES_INSTALL = `/plugin install ${ENGINES_PLUGIN}`;
|
|
75
|
-
const ENGINES_INSTALL_ANY = "npx skills@latest add mattpocock/skills";
|
|
76
|
-
const ENGINES_SETUP = "/setup-matt-pocock-skills";
|
|
77
|
-
const REPO_URL = "https://github.com/wiradigitalid/wdi-method";
|
|
78
|
-
const HELP_SKILL = "wdi-help";
|
|
79
|
-
const INIT_SKILL = "wdi-init";
|
|
80
|
-
// The room's readers file is seeded as a skeleton and is useless until a product writes it. The
|
|
81
|
-
// flag is the skeleton's own declaration, so this reads the same thing the engine does rather than
|
|
82
|
-
// guessing from the file's size or its age.
|
|
83
|
-
function readersAreSkeleton(target) {
|
|
84
|
-
const file = path.join(target, ".constitution", "project", "inventory-readers.py");
|
|
85
|
-
if (!fs.existsSync(file)) return false;
|
|
86
|
-
return /^SKELETON\s*=\s*True\b/m.test(fs.readFileSync(file, "utf8"));
|
|
87
|
-
}
|
|
88
|
-
const BMAD_REPO = "https://github.com/bmad-code-org/BMAD-METHOD";
|
|
89
|
-
const WDI_REPO = "https://github.com/wiradigitalid/wdi-method";
|
|
90
|
-
|
|
91
|
-
const RED = "\x1b[31m";
|
|
92
|
-
const GREEN = "\x1b[32m";
|
|
93
|
-
const DIM = "\x1b[2m";
|
|
94
|
-
const RESET = "\x1b[0m";
|
|
95
|
-
|
|
96
|
-
function die(msg) {
|
|
97
|
-
console.error(`${RED}error:${RESET} ${msg}`);
|
|
98
|
-
process.exit(1);
|
|
99
|
-
}
|
|
100
|
-
|
|
101
|
-
function ok(msg) {
|
|
102
|
-
console.log(`${GREEN}ok${RESET} ${msg}`);
|
|
103
|
-
}
|
|
104
|
-
|
|
105
|
-
function note(msg) {
|
|
106
|
-
console.log(`${DIM}·${RESET} ${msg}`);
|
|
107
|
-
}
|
|
108
|
-
|
|
109
|
-
function usage() {
|
|
110
|
-
console.log(`wdi-method ${PKG.version}
|
|
111
|
-
|
|
112
|
-
(no command) interactive TUI — detects install vs update
|
|
113
|
-
install [dir] first install (TUI unless --yes)
|
|
114
|
-
update [dir] update (TUI unless --yes)
|
|
115
|
-
verify [dir]
|
|
116
|
-
promote <live-dir> --rescue pull a method change back out of a consumer (not the normal flow)
|
|
117
|
-
|
|
118
|
-
--yes non-interactive
|
|
119
|
-
--agents a,b platform IDs (same as BMad --tools; legacy: claude = claude-code)
|
|
120
|
-
--list-agents print supported platform IDs
|
|
121
|
-
--product NAME written to index.yaml product.name
|
|
122
|
-
--client NAME written to index.yaml product.client (optional)
|
|
123
|
-
--doc-language <text> prose of working documents; free text, default English
|
|
124
|
-
--doc-filename-language <text> slug part of document filenames; free text, default English
|
|
125
|
-
--skip-bmad-check
|
|
126
|
-
--skip-engines-check install without to-spec / to-tickets / implement
|
|
127
|
-
|
|
128
|
-
BMad first, then this package. ${WDI_REPO}
|
|
129
|
-
`);
|
|
130
|
-
}
|
|
131
|
-
|
|
132
|
-
function parseArgs(argv) {
|
|
133
|
-
const args = {
|
|
134
|
-
cmd: null,
|
|
135
|
-
dir: null,
|
|
136
|
-
agents: null,
|
|
137
|
-
skipBmad: false,
|
|
138
|
-
rescue: false,
|
|
139
|
-
yes: false,
|
|
140
|
-
product: null,
|
|
141
|
-
client: null,
|
|
142
|
-
docLanguage: null,
|
|
143
|
-
docFilenameLanguage: null,
|
|
144
|
-
};
|
|
145
|
-
const rest = argv.slice(2);
|
|
146
|
-
if (rest[0] === "-h" || rest[0] === "--help") {
|
|
147
|
-
usage();
|
|
148
|
-
process.exit(0);
|
|
149
|
-
}
|
|
150
|
-
if (rest[0] === "--list-agents") {
|
|
151
|
-
console.log(formatPlatformList());
|
|
152
|
-
process.exit(0);
|
|
153
|
-
}
|
|
154
|
-
if (rest.length === 0) {
|
|
155
|
-
args.cmd = "wizard";
|
|
156
|
-
return args;
|
|
157
|
-
}
|
|
158
|
-
const first = rest[0];
|
|
159
|
-
if (["install", "update", "verify", "promote"].includes(first)) {
|
|
160
|
-
args.cmd = rest.shift();
|
|
161
|
-
} else if (first.startsWith("-")) {
|
|
162
|
-
args.cmd = "wizard";
|
|
163
|
-
} else {
|
|
164
|
-
args.cmd = "wizard";
|
|
165
|
-
args.dir = rest.shift();
|
|
166
|
-
}
|
|
167
|
-
while (rest.length) {
|
|
168
|
-
const t = rest.shift();
|
|
169
|
-
if (t === "--skip-bmad-check") args.skipBmad = true;
|
|
170
|
-
else if (t === "--skip-engines-check") args.skipEngines = true;
|
|
171
|
-
else if (t === "--rescue") args.rescue = true;
|
|
172
|
-
else if (t === "--yes" || t === "-y") args.yes = true;
|
|
173
|
-
else if (t === "--agents") {
|
|
174
|
-
const raw = rest.shift();
|
|
175
|
-
if (!raw) die("--agents needs a comma-separated list");
|
|
176
|
-
args.agents = normalizePlatformIds(raw.split(",").map((s) => s.trim()).filter(Boolean));
|
|
177
|
-
const unknown = raw.split(",").map((s) => s.trim()).filter(Boolean)
|
|
178
|
-
.filter((a) => !isKnownPlatform(a));
|
|
179
|
-
if (unknown.length) die(`unknown platform: ${unknown.join(", ")} (run --list-agents)`);
|
|
180
|
-
if (!args.agents.length) die("--agents needs at least one known platform");
|
|
181
|
-
} else if (t === "--product") args.product = rest.shift();
|
|
182
|
-
else if (t === "--client") args.client = rest.shift();
|
|
183
|
-
else if (t === "--doc-language" || t === "--doc-filename-language") {
|
|
184
|
-
// Free text: "English", "Bahasa Indonesia", "id" — a model reads it, so no list to match.
|
|
185
|
-
const raw = (rest.shift() || "").trim();
|
|
186
|
-
if (!raw) die(`${t} needs a value, for example: English`);
|
|
187
|
-
if (t === "--doc-language") args.docLanguage = raw;
|
|
188
|
-
else args.docFilenameLanguage = raw;
|
|
189
|
-
}
|
|
190
|
-
else if (t.startsWith("-")) die(`unknown flag: ${t}`);
|
|
191
|
-
else if (!args.dir) args.dir = t;
|
|
192
|
-
else die(`unexpected argument: ${t}`);
|
|
193
|
-
}
|
|
194
|
-
return args;
|
|
195
|
-
}
|
|
196
|
-
|
|
197
|
-
// Build output and editor droppings MUST NOT reach the kit. This repository is public, and a
|
|
198
|
-
// __pycache__/*.pyc carries the ABSOLUTE PATH of the source it was compiled from — which means a
|
|
199
|
-
// product name and a client folder leak into a public package through a file nobody wrote.
|
|
200
|
-
// Found 2026-08-18 on the first real promote: inventory.cpython-314.pyc embedded the live repo path.
|
|
201
|
-
const SKIP_DIRS = new Set(["__pycache__", "node_modules", ".git", ".pytest_cache", ".ruff_cache",
|
|
202
|
-
".mypy_cache", ".venv", "venv", "dist", "build", ".idea", ".vscode"]);
|
|
203
|
-
const SKIP_FILE = /(\.pyc|\.pyo|\.pyd|\.log|\.tmp|\.swp|\.orig|\.rej|\.bak)$|^\.DS_Store$|^Thumbs\.db$/i;
|
|
204
|
-
|
|
205
|
-
function walkFiles(dir) {
|
|
206
|
-
const out = [];
|
|
207
|
-
if (!fs.existsSync(dir)) return out;
|
|
208
|
-
for (const entry of fs.readdirSync(dir, { withFileTypes: true })) {
|
|
209
|
-
const p = path.join(dir, entry.name);
|
|
210
|
-
if (entry.isDirectory()) {
|
|
211
|
-
if (SKIP_DIRS.has(entry.name)) continue;
|
|
212
|
-
out.push(...walkFiles(p));
|
|
213
|
-
} else if (entry.isFile()) {
|
|
214
|
-
if (SKIP_FILE.test(entry.name)) continue;
|
|
215
|
-
out.push(p);
|
|
216
|
-
}
|
|
217
|
-
}
|
|
218
|
-
return out;
|
|
219
|
-
}
|
|
220
|
-
|
|
221
|
-
function copyFile(src, dest) {
|
|
222
|
-
fs.mkdirSync(path.dirname(dest), { recursive: true });
|
|
223
|
-
fs.copyFileSync(src, dest);
|
|
224
|
-
}
|
|
225
|
-
|
|
226
|
-
function copyTree(src, dest, skipRel) {
|
|
227
|
-
let n = 0;
|
|
228
|
-
for (const p of walkFiles(src)) {
|
|
229
|
-
const rel = posixRel(src, p);
|
|
230
|
-
if (skipRel && skipRel(rel)) continue;
|
|
231
|
-
copyFile(p, path.join(dest, path.relative(src, p)));
|
|
232
|
-
n += 1;
|
|
233
|
-
}
|
|
234
|
-
return n;
|
|
235
|
-
}
|
|
236
|
-
|
|
237
|
-
function posixRel(from, to) {
|
|
238
|
-
return path.relative(from, to).split(path.sep).join("/");
|
|
239
|
-
}
|
|
240
|
-
|
|
241
|
-
function bmadPresent(target) {
|
|
242
|
-
const markers = [
|
|
243
|
-
path.join(target, ".claude", "skills", "bmad-help", "SKILL.md"),
|
|
244
|
-
path.join(target, "_bmad", "core", "config.yaml"),
|
|
245
|
-
path.join(target, "_bmad", "_config", "manifest.yaml"),
|
|
246
|
-
];
|
|
247
|
-
return markers.some((p) => fs.existsSync(p));
|
|
248
|
-
}
|
|
249
|
-
|
|
250
|
-
function wdiPresent(target) {
|
|
251
|
-
return (
|
|
252
|
-
fs.existsSync(path.join(target, ".control", "wdi-method.yaml")) ||
|
|
253
|
-
fs.existsSync(path.join(target, ".constitution", "method", "README.md"))
|
|
254
|
-
);
|
|
255
|
-
}
|
|
256
|
-
|
|
257
|
-
function dirNonEmpty(target) {
|
|
258
|
-
if (!fs.existsSync(target)) return false;
|
|
259
|
-
return fs.readdirSync(target).some((n) => n !== ".git" && n !== ".gitignore");
|
|
260
|
-
}
|
|
261
|
-
|
|
262
|
-
function readBmadVersion(target) {
|
|
263
|
-
const manifest = path.join(target, "_bmad", "_config", "manifest.yaml");
|
|
264
|
-
if (!fs.existsSync(manifest)) return "";
|
|
265
|
-
const text = fs.readFileSync(manifest, "utf8");
|
|
266
|
-
const m = text.match(/installation:\s*\n\s*version:\s*(\S+)/);
|
|
267
|
-
return m ? m[1] : "";
|
|
268
|
-
}
|
|
269
|
-
|
|
270
|
-
function gitHead(repo) {
|
|
271
|
-
const r = spawnSync("git", ["-C", repo, "rev-parse", "--short", "HEAD"], {
|
|
272
|
-
encoding: "utf8",
|
|
273
|
-
});
|
|
274
|
-
if (r.status !== 0) return "unknown";
|
|
275
|
-
return r.stdout.trim();
|
|
276
|
-
}
|
|
277
|
-
|
|
278
|
-
function today() {
|
|
279
|
-
return new Date().toISOString().slice(0, 10);
|
|
280
|
-
}
|
|
281
|
-
|
|
282
|
-
function requireKit() {
|
|
283
|
-
if (!fs.existsSync(path.join(KIT, ".constitution"))) {
|
|
284
|
-
die(`kit missing at ${KIT}`);
|
|
285
|
-
}
|
|
286
|
-
}
|
|
287
|
-
|
|
288
|
-
function requireTarget(dir) {
|
|
289
|
-
const target = path.resolve(dir || process.cwd());
|
|
290
|
-
if (!fs.existsSync(target) || !fs.statSync(target).isDirectory()) {
|
|
291
|
-
die(`target is not a directory: ${target}`);
|
|
292
|
-
}
|
|
293
|
-
return target;
|
|
294
|
-
}
|
|
295
|
-
|
|
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;
|
|
300
|
-
}
|
|
301
|
-
const cfg = process.env.CLAUDE_CONFIG_DIR || path.join(os.homedir(), ".claude");
|
|
302
|
-
const registry = path.join(cfg, "plugins", "installed_plugins.json");
|
|
303
|
-
if (!fs.existsSync(registry)) return false;
|
|
304
|
-
try {
|
|
305
|
-
const plugins = JSON.parse(fs.readFileSync(registry, "utf8")).plugins || {};
|
|
306
|
-
return Object.keys(plugins).some((k) => k.startsWith("mattpocock-skills@"));
|
|
307
|
-
} catch {
|
|
308
|
-
return false;
|
|
309
|
-
}
|
|
310
|
-
}
|
|
311
|
-
|
|
312
|
-
function bmadMissingMessage() {
|
|
313
|
-
return [
|
|
314
|
-
"BMad Method is not installed in this repo. Install it first, then run this installer again.",
|
|
315
|
-
"",
|
|
316
|
-
` ${BMAD_INSTALL}`,
|
|
317
|
-
"",
|
|
318
|
-
`Source: ${BMAD_REPO}`,
|
|
319
|
-
"In the BMad installer, pick the same agents (Claude Code, Cursor, …).",
|
|
320
|
-
].join("\n");
|
|
321
|
-
}
|
|
322
|
-
|
|
323
|
-
// The engines used to WARN and let the install through, on the reasoning that G1-G4 run without them and
|
|
324
|
-
// a first install has no G5 yet. Both halves are still true, and the reasoning stopped being enough:
|
|
325
|
-
// `wdi-autopilot` needs all three from its first iteration, and a warning inside a forty-line summary is
|
|
326
|
-
// read exactly as often as it is skipped. The failure it was meant to prevent — learning they are missing
|
|
327
|
-
// inside `wdi-build`, with a spec already open — kept happening anyway.
|
|
328
|
-
//
|
|
329
|
-
// So it blocks, and `--skip-engines-check` is the escape, exactly as `--skip-bmad-check` is for BMad. The
|
|
330
|
-
// escape matters: CI installs into a bare checkout, and a repo that will never reach G5 is a real case.
|
|
331
|
-
function enginesMissingMessage() {
|
|
332
|
-
return [
|
|
333
|
-
"The ticket engines are not installed. G5 (wdi-build) and wdi-autopilot need all three.",
|
|
334
|
-
"",
|
|
335
|
-
` Claude Code: ${ENGINES_INSTALL}`,
|
|
336
|
-
` Other agents: ${ENGINES_INSTALL_ANY}`,
|
|
337
|
-
"",
|
|
338
|
-
"You do NOT need to run the setup skill after this — the installer seeds docs/agents/ already",
|
|
339
|
-
`answered for this method. Run ${ENGINES_SETUP} only to change tracker.`,
|
|
340
|
-
`Source: ${ENGINES_REPO}`,
|
|
341
|
-
"",
|
|
342
|
-
"G1-G4 run without them. To install anyway and add them later: --skip-engines-check",
|
|
343
|
-
].join("\n");
|
|
344
|
-
}
|
|
345
|
-
|
|
346
|
-
// The product's custom room. Three properties, and all three MUST hold together:
|
|
347
|
-
// install/update seeds its content ONLY when absent — never written again after that
|
|
348
|
-
// promote SKIPS it entirely, so a product's own rules can never reach the public repo
|
|
349
|
-
// agent loads it like any other guide, so it BINDS
|
|
350
|
-
// The deliberate consequence: this room's README is authored in the package and never comes home
|
|
351
|
-
// through promote.
|
|
352
|
-
const PROJECT_ROOM = "project/";
|
|
353
|
-
|
|
354
|
-
// 0.5.0 moved `.constitution/` to exactly two folders: `method/` is the method's and is overwritten,
|
|
355
|
-
// `project/` is the product's and is never touched. Before it, generic and product-owned files sat
|
|
356
|
-
// side by side at the root, `codebase/` was a third product-owned room nobody had written down, and
|
|
357
|
-
// `constitution.md` was ONE file holding both — which is why `update` had to keep the whole thing and
|
|
358
|
-
// the product never received a fixed generic Article.
|
|
359
|
-
//
|
|
360
|
-
// Without this migration an installed repo would end up carrying BOTH layouts: the kit writes the new
|
|
361
|
-
// paths while the old files stay behind, and an agent reading `AGENTS.md` routing would find two
|
|
362
|
-
// copies of most guides and no way to tell which binds.
|
|
363
|
-
const OLD_ROOT_GUIDES = ["README", "language-guide", "method-glossary", "repo-guide", "structure-guide"];
|
|
364
|
-
const OLD_WHY = ["README", "artifact-map", "portability", "rationale"];
|
|
365
|
-
const OLD_CODEBASE = ["stack", "conventions", "brownfield"];
|
|
366
|
-
|
|
367
|
-
function mv(from, to) {
|
|
368
|
-
fs.mkdirSync(path.dirname(to), { recursive: true });
|
|
369
|
-
fs.renameSync(from, to);
|
|
370
|
-
}
|
|
371
|
-
|
|
372
|
-
/** Article numbers that belong to the method half. The product keeps 1, 2, and 5. */
|
|
373
|
-
const METHOD_ARTICLES = [3, 4, 6, 7];
|
|
374
|
-
|
|
375
|
-
/**
|
|
376
|
-
* Cut the method's articles out of a product's constitution.md, and repoint its relative links.
|
|
377
|
-
*
|
|
378
|
-
* Returns {cut, kept, relinked}, or null when the file does not look like a constitution at all —
|
|
379
|
-
* in which case it is left ALONE rather than guessed at.
|
|
380
|
-
*
|
|
381
|
-
* 0.5.0 moved the file whole and printed "delete Articles 3, 4, 6, 7 yourself", on the grounds that
|
|
382
|
-
* no script can tell an edited copy from the original. That reasoning was wrong in the way that
|
|
383
|
-
* matters: the split does not need to know whether a section was edited, only which article numbers
|
|
384
|
-
* are the method's — and the file states them in its own headings. Leaving it whole left every
|
|
385
|
-
* migrated repo carrying those articles in TWO files, one of them frozen and drifting, plus relative
|
|
386
|
-
* links that no longer resolve one level down. It is all in git, so cutting is reversible; not
|
|
387
|
-
* cutting is what nobody notices.
|
|
388
|
-
*/
|
|
389
|
-
function splitProductConstitution(file) {
|
|
390
|
-
if (!fs.existsSync(file)) return null;
|
|
391
|
-
const raw = fs.readFileSync(file, "utf8");
|
|
392
|
-
const crlf = raw.includes("\r\n");
|
|
393
|
-
const text = crlf ? raw.replaceAll("\r\n", "\n") : raw;
|
|
394
|
-
const marks = [...text.matchAll(/^## Article (\d+)\b.*$/gm)];
|
|
395
|
-
if (marks.length < 2) return null; // not the shape we know; do not touch it
|
|
396
|
-
|
|
397
|
-
const kept = [];
|
|
398
|
-
const cut = [];
|
|
399
|
-
let out = text.slice(0, marks[0].index);
|
|
400
|
-
for (let i = 0; i < marks.length; i += 1) {
|
|
401
|
-
const n = Number(marks[i][1]);
|
|
402
|
-
const end = i + 1 < marks.length ? marks[i + 1].index : text.length;
|
|
403
|
-
if (METHOD_ARTICLES.includes(n)) cut.push(n);
|
|
404
|
-
else {
|
|
405
|
-
kept.push(n);
|
|
406
|
-
out += text.slice(marks[i].index, end);
|
|
407
|
-
}
|
|
408
|
-
}
|
|
409
|
-
if (!cut.length) return { cut, kept, relinked: 0 };
|
|
410
|
-
|
|
411
|
-
// The file sits one level deeper than it did, and its former siblings moved into method/. A link
|
|
412
|
-
// left as `repo-guide.md` now resolves to .constitution/project/repo-guide.md, which does not exist.
|
|
413
|
-
let relinked = 0;
|
|
414
|
-
const bump = (re, to) => {
|
|
415
|
-
out = out.replace(re, (m, ...rest) => {
|
|
416
|
-
relinked += 1;
|
|
417
|
-
return typeof to === "function" ? to(m, ...rest) : to + m;
|
|
418
|
-
});
|
|
419
|
-
};
|
|
420
|
-
for (const name of ["repo-guide.md", "structure-guide.md", "language-guide.md",
|
|
421
|
-
"method-glossary.md"]) {
|
|
422
|
-
bump(new RegExp(`(?<![\\w./-])${name.replace(".", "\\.")}`, "g"), "../method/");
|
|
423
|
-
}
|
|
424
|
-
bump(/(?<![\w./-])document\//g, "../method/");
|
|
425
|
-
bump(/(?<![\w./-])codebase\/([a-z]+)-guide\.md/g, (_m, kind) => `codebase-${kind}-guide.md`);
|
|
426
|
-
out = out.replaceAll("../method/../method/", "../method/");
|
|
427
|
-
|
|
428
|
-
const banner = [
|
|
429
|
-
"",
|
|
430
|
-
`> **Articles ${cut.join(", ")} were removed from this file on migration to the two-folder layout.**`,
|
|
431
|
-
"> They are the method's and live in [`../method/constitution.md`](../method/constitution.md), which",
|
|
432
|
-
`> \`update\` replaces. Only Articles ${kept.join(", ")} are yours. The removed text is in git.`,
|
|
433
|
-
"",
|
|
434
|
-
].join("\n");
|
|
435
|
-
const firstArticle = out.search(/^## Article /m);
|
|
436
|
-
out = firstArticle === -1
|
|
437
|
-
? out + banner
|
|
438
|
-
: out.slice(0, firstArticle) + banner.trimStart() + "\n" + out.slice(firstArticle);
|
|
439
|
-
|
|
440
|
-
fs.writeFileSync(file, crlf ? out.replaceAll("\n", "\r\n") : out, "utf8");
|
|
441
|
-
return { cut, kept, relinked };
|
|
442
|
-
}
|
|
443
|
-
|
|
444
|
-
// `waves.yaml` holds the PRODUCT's plan, not the package's. When the method retired `wave` for
|
|
445
|
-
// `spec` the registry had to follow, and a rename is the only part of that a tool can safely do:
|
|
446
|
-
// the file MOVES, its content is left exactly as written. Rewriting the rows — `W1` to `SPEC-1`,
|
|
447
|
-
// `epics`/`stories` to `tickets` — is the product's own migration, run by `wdi-build` where a human
|
|
448
|
-
// can see it, because a guess there silently rewrites months of real work.
|
|
449
|
-
//
|
|
450
|
-
// Two refusals matter more than the move. It never writes over an existing `specs.yaml`, and it
|
|
451
|
-
// never deletes a `waves.yaml` whose content has nowhere to go: a half-finished hand migration
|
|
452
|
-
// leaves BOTH files present, and which one is real is not something an installer can know.
|
|
453
|
-
// `wdi-autopilot` named its ledger for the DAY before 0.6.2 — `autopilot-<YYYY-MM-DD>.md`. The mandate
|
|
454
|
-
// it belongs to is named for the MANDATE now — `autopilot-<DEC-id>.md` — because two mandates opened
|
|
455
|
-
// on the same day would otherwise append to one file and destroy both as a record, and because
|
|
456
|
-
// `mandate-accept` (the validator introduced alongside the rename) looks for the file at that path and
|
|
457
|
-
// nowhere else. This is a pure rename, like `waves.yaml` → `specs.yaml`: the ledger's own content is
|
|
458
|
-
// never touched, only found and moved. Renaming it is what a script can safely do; restructuring its
|
|
459
|
-
// CONTENT into the `## Resume` / `## Decisions` split is not — that has to read git and the registry to
|
|
460
|
-
// know where the run actually stands, so it is the skill's own job on the next iteration it runs, not
|
|
461
|
-
// this installer's.
|
|
462
|
-
// `/setup-matt-pocock-skills` interviews the owner and writes `docs/agents/`. Two of its answers are
|
|
463
|
-
// wrong for a WDI repo, and BOTH repos that ran it had to hand-correct the SAME file afterwards:
|
|
464
|
-
//
|
|
465
|
-
// - `domain.md` tells agents to read and lazily create a root `CONTEXT.md` and `docs/adr/`. Article 3
|
|
466
|
-
// says this method has no `docs/` layer for corpus or rules, and `wdi-reconcile` reports both as
|
|
467
|
-
// findings. The homes already exist: `.control/product-glossary.md`, `.what/`, `.how/`, `DEC-`.
|
|
468
|
-
// - `issue-tracker.md`'s local-markdown default puts every ticket under `.scratch/<feature>/`, while
|
|
469
|
-
// `wdi-build` owns tickets at `{spec_folder}/issues/`. Two homes for one ticket set.
|
|
470
|
-
//
|
|
471
|
-
// Seeding them removes the interview for the answers WDI Method actually has a requirement on. Seeded
|
|
472
|
-
// ONCE and never overwritten — after the first install they are the product's, like every other file
|
|
473
|
-
// under a path the product owns. An owner who wants a different tracker re-runs the setup skill; the
|
|
474
|
-
// seeded file says which three invariants have to survive that.
|
|
475
|
-
function seedAgentDocs(target) {
|
|
476
|
-
const dir = path.join(target, "docs", "agents");
|
|
477
|
-
let wrote = 0;
|
|
478
|
-
for (const name of ["domain.md", "issue-tracker.md"]) {
|
|
479
|
-
const to = path.join(dir, name);
|
|
480
|
-
if (fs.existsSync(to)) continue;
|
|
481
|
-
const seed = path.join(ROOT, "scaffold", "docs", "agents", name);
|
|
482
|
-
if (!fs.existsSync(seed)) continue;
|
|
483
|
-
copyFile(seed, to);
|
|
484
|
-
wrote += 1;
|
|
485
|
-
}
|
|
486
|
-
if (wrote) {
|
|
487
|
-
note(`seeded docs/agents/ (${wrote} file${wrote === 1 ? "" : "s"}) — the engines' config, pre-answered`);
|
|
488
|
-
note(" do NOT run /setup-matt-pocock-skills to redo these; re-run it only to change tracker");
|
|
489
|
-
}
|
|
490
|
-
return wrote > 0;
|
|
491
|
-
}
|
|
492
|
-
|
|
493
|
-
// A repo that ran the setup skill BEFORE installing this package still carries the default `domain.md`,
|
|
494
|
-
// and it is actively misleading: it sends every engineering skill looking for a root `CONTEXT.md` and
|
|
495
|
-
// `docs/adr/`, and tells them to create both lazily. Seeding cannot fix it, because the file already
|
|
496
|
-
// exists and a file under a product-owned path is never overwritten. So it is named instead.
|
|
497
|
-
function warnStaleAgentDocs(target) {
|
|
498
|
-
const file = path.join(target, "docs", "agents", "domain.md");
|
|
499
|
-
if (!fs.existsSync(file)) return;
|
|
500
|
-
const text = fs.readFileSync(file, "utf8");
|
|
501
|
-
if (!/CONTEXT\.md|docs\/adr/.test(text)) return;
|
|
502
|
-
// An override note is what both real repos added by hand. Recognising it is what stops this warning
|
|
503
|
-
// from firing forever on a file somebody already fixed.
|
|
504
|
-
if (/does not use|MUST NOT be created|no `docs\/` layer/i.test(text)) return;
|
|
505
|
-
note("docs/agents/domain.md still points agents at a root CONTEXT.md and docs/adr/");
|
|
506
|
-
note(" Article 3: this method has no `docs/` layer for corpus or rules, and wdi-reconcile");
|
|
507
|
-
note(" reports both as findings. Say so at the top of that file — the glossary is at");
|
|
508
|
-
note(" .control/product-glossary.md and a decision is a DEC-, never an ADR");
|
|
509
|
-
}
|
|
510
|
-
|
|
511
|
-
function migrateAutopilotLedgers(target) {
|
|
512
|
-
const dir = path.join(target, ".control", "memlog");
|
|
513
|
-
if (!fs.existsSync(dir)) return;
|
|
514
|
-
const OLD = /^autopilot-(\d{4}-\d{2}-\d{2})\.md$/;
|
|
515
|
-
for (const name of fs.readdirSync(dir)) {
|
|
516
|
-
const m = OLD.exec(name);
|
|
517
|
-
if (!m) continue;
|
|
518
|
-
const from = path.join(dir, name);
|
|
519
|
-
const text = fs.readFileSync(from, "utf8");
|
|
520
|
-
const artifact = /^artifact:\s*(\S.*)$/m.exec(text)?.[1]?.trim();
|
|
521
|
-
const id = artifact && /(DEC-\d+)/.exec(artifact)?.[1];
|
|
522
|
-
if (!id) {
|
|
523
|
-
note(`.control/memlog/${name} looks like a pre-0.6.2 autopilot ledger, but its \`artifact:\` does`);
|
|
524
|
-
note(` not resolve to a DEC- id — rename it to autopilot-<the mandate's DEC- id>.md yourself`);
|
|
525
|
-
continue;
|
|
526
|
-
}
|
|
527
|
-
const to = path.join(dir, `autopilot-${id}.md`);
|
|
528
|
-
if (fs.existsSync(to)) {
|
|
529
|
-
note(`BOTH .control/memlog/${name} and autopilot-${id}.md exist — neither was touched`);
|
|
530
|
-
note(` the run's ledger is in one of them and I cannot tell which. Merge them, then delete the other`);
|
|
531
|
-
continue;
|
|
532
|
-
}
|
|
533
|
-
mv(from, to);
|
|
534
|
-
note(`renamed .control/memlog/${name} → autopilot-${id}.md (content unchanged)`);
|
|
535
|
-
note(` \`mandate-accept\` looks for a mandate's ledger at this exact path`);
|
|
536
|
-
}
|
|
537
|
-
}
|
|
538
|
-
|
|
539
|
-
// A mandate opened before 0.6.2 recorded `parked: []` under the OLD default — full authority, AD-N
|
|
540
|
-
// contradictions included. 0.6.2 changed the DEFAULT for a NEW mandate to park `ad-n`, because
|
|
541
|
-
// decision-guide.md says narrowing an invariant MUST NOT be softened further. A default only applies
|
|
542
|
-
// at the moment a mandate is written, so an EXISTING accepted mandate keeps whatever it already says —
|
|
543
|
-
// silently adding `ad-n` to it would be overwriting a value the owner already chose, which `update`
|
|
544
|
-
// MUST NOT do to anything in the product's own registry. So this only ever WARNS, naming the mandate
|
|
545
|
-
// and the one line that would close the gap, and leaves the decision to whoever reads the summary.
|
|
546
|
-
function warnStaleMandates(target) {
|
|
547
|
-
const file = path.join(target, ".control", "registry", "decisions.yaml");
|
|
548
|
-
if (!fs.existsSync(file)) return;
|
|
549
|
-
const text = fs.readFileSync(file, "utf8");
|
|
550
|
-
const blocks = text.split(/\n(?=\s*-\s*id:\s*DEC-)/);
|
|
551
|
-
for (const block of blocks) {
|
|
552
|
-
if (!/type:\s*mandate/.test(block)) continue;
|
|
553
|
-
if (!/status:\s*accepted/.test(block)) continue;
|
|
554
|
-
const id = /id:\s*(DEC-\d+)/.exec(block)?.[1];
|
|
555
|
-
const parkedLine = /parked:\s*(\[[^\]]*\]|.*)$/m.exec(block)?.[0] || "";
|
|
556
|
-
const parkedBlockList = /parked:\s*\n((?:\s+-\s*\S.*\n?)*)/.exec(block)?.[1] || "";
|
|
557
|
-
if (/ad-n/.test(parkedLine) || /ad-n/.test(parkedBlockList)) continue;
|
|
558
|
-
note(`${id || "a mandate"} predates the \`ad-n\`-parked-by-default protection (0.6.2) — its \`parked\``);
|
|
559
|
-
note(` list does not name it, so it still decides an AD-N contradiction on its own`);
|
|
560
|
-
note(` add \`ad-n\` to its \`parked\` list in decisions.yaml yourself if you want the new default`);
|
|
561
|
-
}
|
|
562
|
-
}
|
|
563
|
-
|
|
564
|
-
function migrateRegistryNames(target) {
|
|
565
|
-
const reg = path.join(target, ".control", "registry");
|
|
566
|
-
const from = path.join(reg, "waves.yaml");
|
|
567
|
-
const to = path.join(reg, "specs.yaml");
|
|
568
|
-
if (!fs.existsSync(from)) return false;
|
|
569
|
-
if (fs.existsSync(to)) {
|
|
570
|
-
note("BOTH .control/registry/waves.yaml and specs.yaml exist — neither was touched");
|
|
571
|
-
note(" the plan is in one of them and I cannot tell which. Merge them yourself, then delete waves.yaml");
|
|
572
|
-
return false;
|
|
573
|
-
}
|
|
574
|
-
mv(from, to);
|
|
575
|
-
note("renamed .control/registry/waves.yaml → specs.yaml (content unchanged)");
|
|
576
|
-
note(" the rows still say `W<N>` and `epics`/`stories`. Re-cut them through the wdi-build skill");
|
|
577
|
-
return true;
|
|
578
|
-
}
|
|
579
|
-
|
|
580
|
-
// The requirement registry split into `goals.yaml` (the product's `BG`, written by `wdi-problem` at
|
|
581
|
-
// G1) plus one `requirements-<slug>.yaml` per PRD (`CAP`, `FR`, `NFR`, `UJ`, written by
|
|
582
|
-
// `wdi-product` at G2). One file, one writer, one gate. What a tool can do here is SEED `goals.yaml`;
|
|
583
|
-
// what it MUST NOT do is move the rows.
|
|
584
|
-
//
|
|
585
|
-
// Splitting the rows needs one fact the registry has never recorded: which PRD an `FR` belongs to.
|
|
586
|
-
// Before the split nothing wrote it down, and deriving it — FR → UC → ticket → spec → `prd:` — only
|
|
587
|
-
// works for FRs that already have tickets. A guess would file a promise under the wrong initiative,
|
|
588
|
-
// which is worse than leaving it where it is. So `requirements.yaml` is left ALONE and still read:
|
|
589
|
-
// `validate.py` unions every requirement file it finds, so a half-split corpus stays green while its
|
|
590
|
-
// owner cuts the rows through the skill that owns each one.
|
|
591
|
-
function seedRequirementSplit(target) {
|
|
592
|
-
const reg = path.join(target, ".control", "registry");
|
|
593
|
-
if (!fs.existsSync(reg)) return false;
|
|
594
|
-
const product = path.join(reg, "goals.yaml");
|
|
595
|
-
if (fs.existsSync(product)) return false;
|
|
596
|
-
const seed = path.join(SCAFFOLD, "registry", "goals.yaml");
|
|
597
|
-
if (!fs.existsSync(seed)) return false;
|
|
598
|
-
copyFile(seed, product);
|
|
599
|
-
note("seeded .control/registry/goals.yaml");
|
|
600
|
-
if (fs.existsSync(path.join(reg, "requirements.yaml"))) {
|
|
601
|
-
note(" requirements.yaml was left exactly as it is, and is still read — nothing broke");
|
|
602
|
-
note(" the wdi-upgrade skill moves `goals:` into goals.yaml and cuts `capabilities:`,");
|
|
603
|
-
note(" `functional:`, `nonfunctional:`, and `journeys:` into requirements-<slug>.yaml per PRD.");
|
|
604
|
-
note(" <slug> is the PRD's folder name under .what/_prd/");
|
|
605
|
-
}
|
|
606
|
-
return true;
|
|
607
|
-
}
|
|
608
|
-
|
|
609
|
-
function migrateToTwoFolders(target) {
|
|
610
|
-
const c = path.join(target, ".constitution");
|
|
611
|
-
if (!fs.existsSync(c)) return false; // a first install has nothing to migrate
|
|
612
|
-
const at = (...p) => path.join(c, ...p);
|
|
613
|
-
// The old layout is identified by `document/` at the ROOT — in the new layout that folder only ever
|
|
614
|
-
// exists under `method/`. Checking a loose guide instead would misfire on a repo that added one.
|
|
615
|
-
if (!fs.existsSync(at("document")) && !fs.existsSync(at("codebase"))
|
|
616
|
-
&& !fs.existsSync(at("constitution.md")) && !fs.existsSync(at("scripts"))) {
|
|
617
|
-
return false;
|
|
618
|
-
}
|
|
619
|
-
note("pre-0.5.0 .constitution/ found — migrating to method/ + project/");
|
|
620
|
-
|
|
621
|
-
// 1. The four Reference files go one level deeper. This MUST run before the kit is written, or the
|
|
622
|
-
// kit's own why/ files land while the old copies still sit at method/ root.
|
|
623
|
-
for (const name of OLD_WHY) {
|
|
624
|
-
const from = at("method", `${name}.md`);
|
|
625
|
-
if (fs.existsSync(from)) {
|
|
626
|
-
mv(from, at("method", "why", `${name}.md`));
|
|
627
|
-
note(` moved method/${name}.md → method/why/${name}.md`);
|
|
628
|
-
}
|
|
629
|
-
}
|
|
630
|
-
// 2. and 3. whole folders
|
|
631
|
-
for (const dir of ["document", "scripts"]) {
|
|
632
|
-
if (fs.existsSync(at(dir)) && !fs.existsSync(at("method", dir))) {
|
|
633
|
-
mv(at(dir), at("method", dir));
|
|
634
|
-
note(` moved ${dir}/ → method/${dir}/`);
|
|
635
|
-
}
|
|
636
|
-
}
|
|
637
|
-
// 4. the loose generic guides
|
|
638
|
-
for (const name of OLD_ROOT_GUIDES) {
|
|
639
|
-
const from = at(`${name}.md`);
|
|
640
|
-
if (fs.existsSync(from)) {
|
|
641
|
-
mv(from, at("method", `${name}.md`));
|
|
642
|
-
note(` moved ${name}.md → method/${name}.md`);
|
|
643
|
-
}
|
|
644
|
-
}
|
|
645
|
-
// 5. codebase/ was a product-owned room all along — it becomes flat files in the room that says so
|
|
646
|
-
for (const name of OLD_CODEBASE) {
|
|
647
|
-
const from = at("codebase", `${name}-guide.md`);
|
|
648
|
-
if (fs.existsSync(from)) {
|
|
649
|
-
mv(from, at("project", `codebase-${name}-guide.md`));
|
|
650
|
-
note(` moved codebase/${name}-guide.md → project/codebase-${name}-guide.md`);
|
|
651
|
-
}
|
|
652
|
-
}
|
|
653
|
-
if (fs.existsSync(at("codebase"))) {
|
|
654
|
-
const left = fs.readdirSync(at("codebase"));
|
|
655
|
-
if (!left.length) fs.rmdirSync(at("codebase"));
|
|
656
|
-
else note(` codebase/ still holds ${left.join(", ")} — left in place, move them yourself`);
|
|
657
|
-
}
|
|
658
|
-
// 6. The product's constitution.md moves WHOLE into the room, so its Articles 1, 2, and 5 survive
|
|
659
|
-
// exactly as written. The generic half then arrives fresh at method/constitution.md.
|
|
660
|
-
let split = null;
|
|
661
|
-
if (fs.existsSync(at("constitution.md")) && !fs.existsSync(at("project", "constitution.md"))) {
|
|
662
|
-
mv(at("constitution.md"), at("project", "constitution.md"));
|
|
663
|
-
note(" moved constitution.md → project/constitution.md");
|
|
664
|
-
split = splitProductConstitution(at("project", "constitution.md"));
|
|
665
|
-
if (split && split.cut.length) {
|
|
666
|
-
note(` kept Articles ${split.kept.join(", ")}, removed ${split.cut.join(", ")} `
|
|
667
|
-
+ "(the method's — they arrive in method/constitution.md)");
|
|
668
|
-
if (split.relinked) note(` repointed ${split.relinked} relative links one level up`);
|
|
669
|
-
} else if (split === null) {
|
|
670
|
-
note(" it does not carry `## Article N` headings, so it was moved but NOT split — yours to check");
|
|
671
|
-
}
|
|
672
|
-
}
|
|
673
|
-
// Anything else loose at the root is a file this product ADDED. It is NOT moved: it may be routed
|
|
674
|
-
// from AGENTS.md by its current path, and guessing a destination would break that silently.
|
|
675
|
-
const stray = fs.existsSync(c)
|
|
676
|
-
? fs.readdirSync(c, { withFileTypes: true })
|
|
677
|
-
.filter((e) => e.isFile() && e.name.endsWith(".md"))
|
|
678
|
-
.map((e) => e.name)
|
|
679
|
-
: [];
|
|
680
|
-
if (stray.length) {
|
|
681
|
-
note(` left at .constitution/ root, yours to place: ${stray.join(", ")}`);
|
|
682
|
-
note(" a file you added belongs in project/ — but moving it would break any pointer that");
|
|
683
|
-
note(" names its current path, so the choice is yours. repo-guide.md states the rule.");
|
|
684
|
-
}
|
|
685
|
-
return split;
|
|
686
|
-
}
|
|
687
|
-
|
|
688
|
-
function syncConstitution(target) {
|
|
689
|
-
const kitConst = path.join(KIT, ".constitution");
|
|
690
|
-
const destConst = path.join(target, ".constitution");
|
|
691
|
-
fs.mkdirSync(destConst, { recursive: true });
|
|
692
|
-
let written = 0;
|
|
693
|
-
let skipped = 0;
|
|
694
|
-
for (const file of walkFiles(kitConst)) {
|
|
695
|
-
const rel = posixRel(kitConst, file);
|
|
696
|
-
const dest = path.join(destConst, rel);
|
|
697
|
-
// ONE rule for everything the product owns, because 0.5.0 put all of it in one folder. Before
|
|
698
|
-
// that this loop had three branches — the mixed constitution.md kept whole, `codebase/` gated on
|
|
699
|
-
// `status: Accepted` (which is what silently destroyed a half-written guide), and the room — and
|
|
700
|
-
// the three disagreed about when a file was the product's. Seeded when absent, never written
|
|
701
|
-
// again: the same rule as the language policy.
|
|
702
|
-
// ONE file in the room is the package's and is refreshed like any method file: the room's own
|
|
703
|
-
// README. It explains what the room is FOR and carries no product decision, so a stale copy does
|
|
704
|
-
// not preserve anybody's work — it just misinforms. worship-presenter-web proved that: its copy
|
|
705
|
-
// still pointed at `.constitution/codebase/*-guide.md`, a folder 0.5.0 deleted, and no update
|
|
706
|
-
// would ever have corrected it while the file claimed in its own text to be "authored in the
|
|
707
|
-
// package". Either the package writes it or it stops claiming authorship; this is the first.
|
|
708
|
-
if (rel === `${PROJECT_ROOM}README.md`) {
|
|
709
|
-
copyFile(file, dest);
|
|
710
|
-
written += 1;
|
|
711
|
-
continue;
|
|
712
|
-
}
|
|
713
|
-
if (rel.startsWith(PROJECT_ROOM) && fs.existsSync(dest)) {
|
|
714
|
-
skipped += 1;
|
|
715
|
-
note(`keep ${rel} (yours — the project room)`);
|
|
716
|
-
continue;
|
|
717
|
-
}
|
|
718
|
-
copyFile(file, dest);
|
|
719
|
-
written += 1;
|
|
720
|
-
}
|
|
721
|
-
return { written, skipped };
|
|
722
|
-
}
|
|
723
|
-
|
|
724
|
-
function syncSkills(target, agents) {
|
|
725
|
-
let n = 0;
|
|
726
|
-
const dests = skillDestinations(target, agents);
|
|
727
|
-
if (dests.length === 0) {
|
|
728
|
-
note("no skill destinations for selected platforms — AGENTS.md still applies");
|
|
729
|
-
return { files: 0, removed: 0 };
|
|
730
|
-
}
|
|
731
|
-
for (const name of WDI_SKILLS) {
|
|
732
|
-
const src = path.join(KIT, "skills", name);
|
|
733
|
-
if (!fs.existsSync(src)) die(`kit missing skill ${name}`);
|
|
734
|
-
for (const root of dests) {
|
|
735
|
-
const dest = path.join(root, name);
|
|
736
|
-
fs.rmSync(dest, { recursive: true, force: true });
|
|
737
|
-
n += copyTree(src, dest);
|
|
738
|
-
}
|
|
739
|
-
}
|
|
740
|
-
const removed = pruneRetiredSkills(dests);
|
|
741
|
-
return { files: n, removed };
|
|
742
|
-
}
|
|
743
|
-
|
|
744
|
-
// A wrapper the method RETIRED is worse than a wrapper missing: the folder is still there, its
|
|
745
|
-
// SKILL.md still reads like an instruction, and an agent will invoke it — while the guide it points
|
|
746
|
-
// at is gone. Renaming five wrappers (wdi-apply, wdi-analysis, wdi-structure, …) left exactly that
|
|
747
|
-
// in every repo installed before the rename, because update only ever touched the names it knows.
|
|
748
|
-
//
|
|
749
|
-
// `wdi-` is the method's namespace, so a `wdi-*` folder carrying a SKILL.md and not in WDI_SKILLS is
|
|
750
|
-
// ours and retired. Each removal is PRINTED: silent deletion in someone else's repo is not a fix.
|
|
751
|
-
function pruneRetiredSkills(dests) {
|
|
752
|
-
let removed = 0;
|
|
753
|
-
const keep = new Set(WDI_SKILLS);
|
|
754
|
-
for (const root of dests) {
|
|
755
|
-
if (!fs.existsSync(root)) continue;
|
|
756
|
-
for (const entry of fs.readdirSync(root, { withFileTypes: true })) {
|
|
757
|
-
if (!entry.isDirectory() || !entry.name.startsWith("wdi-") || keep.has(entry.name)) continue;
|
|
758
|
-
const dir = path.join(root, entry.name);
|
|
759
|
-
if (!fs.existsSync(path.join(dir, "SKILL.md"))) {
|
|
760
|
-
note(`kept ${entry.name} (no SKILL.md — not one of ours)`);
|
|
761
|
-
continue;
|
|
762
|
-
}
|
|
763
|
-
fs.rmSync(dir, { recursive: true, force: true });
|
|
764
|
-
note(`removed retired skill ${entry.name}`);
|
|
765
|
-
removed += 1;
|
|
766
|
-
}
|
|
767
|
-
}
|
|
768
|
-
return removed;
|
|
769
|
-
}
|
|
770
|
-
|
|
771
|
-
// `promote` scrubs a product's initiative slug out of bmad-prd.toml before publishing, which is right.
|
|
772
|
-
// Writing the scrubbed PLACEHOLDER back into a product repo is not: the first real install replaced a
|
|
773
|
-
// live `run_folder_pattern = "some-real-slug"` with `FILL-initiative-slug`, and nothing said so. A value
|
|
774
|
-
// the product already chose is not the installer's to overwrite — same rule as the custom room and the
|
|
775
|
-
// language policy.
|
|
776
|
-
const PLACEHOLDER_SLUG = "FILL-initiative-slug";
|
|
777
|
-
const RUN_FOLDER_LINE = /^(\s*run_folder_pattern\s*=\s*)(".*?"|'.*?')/m;
|
|
778
|
-
|
|
779
|
-
// The slug appears MORE THAN ONCE — bmad-prd.toml carries it in `run_folder_pattern` and again inside a
|
|
780
|
-
// memlog path, and the file itself says the two lines MUST change together. The first version of this
|
|
781
|
-
// function restored only the first line and so produced exactly the inconsistency that file forbids.
|
|
782
|
-
// So: read the product's slug once, then put it back everywhere the placeholder appears.
|
|
783
|
-
function keepProductSlug(incoming, existing) {
|
|
784
|
-
const mineNow = existing.match(RUN_FOLDER_LINE);
|
|
785
|
-
if (!mineNow) return null;
|
|
786
|
-
const slug = mineNow[2].slice(1, -1);
|
|
787
|
-
if (!slug || slug === PLACEHOLDER_SLUG) return null;
|
|
788
|
-
if (!incoming.includes(PLACEHOLDER_SLUG)) return null;
|
|
789
|
-
// Only where the slug is a VALUE: the quoted setting, and the memlog path built from it. A bare
|
|
790
|
-
// mention inside a comment stays the placeholder — that sentence explains the pattern, and rewriting
|
|
791
|
-
// it would turn a generic explanation into a statement about one initiative.
|
|
792
|
-
return incoming
|
|
793
|
-
.replaceAll(`"${PLACEHOLDER_SLUG}"`, `"${slug}"`)
|
|
794
|
-
.replaceAll(`prd-${PLACEHOLDER_SLUG}`, `prd-${slug}`);
|
|
795
|
-
}
|
|
796
|
-
|
|
797
|
-
function syncTomls(target) {
|
|
798
|
-
const src = path.join(KIT, "assets", "bmad-custom");
|
|
799
|
-
const dest = path.join(target, "_bmad", "custom");
|
|
800
|
-
fs.mkdirSync(dest, { recursive: true });
|
|
801
|
-
let n = 0;
|
|
802
|
-
let slugsKept = 0;
|
|
803
|
-
for (const file of walkFiles(src)) {
|
|
804
|
-
if (!file.endsWith(".toml") || file.endsWith(".user.toml")) continue;
|
|
805
|
-
const to = path.join(dest, path.basename(file));
|
|
806
|
-
if (fs.existsSync(to)) {
|
|
807
|
-
const merged = keepProductSlug(fs.readFileSync(file, "utf8"), fs.readFileSync(to, "utf8"));
|
|
808
|
-
if (merged !== null) {
|
|
809
|
-
fs.writeFileSync(to, merged);
|
|
810
|
-
note(`kept run_folder_pattern in ${path.basename(file)}`);
|
|
811
|
-
slugsKept += 1;
|
|
812
|
-
n += 1;
|
|
813
|
-
continue;
|
|
814
|
-
}
|
|
815
|
-
}
|
|
816
|
-
copyFile(file, to);
|
|
817
|
-
n += 1;
|
|
818
|
-
}
|
|
819
|
-
return { files: n, slugsKept };
|
|
820
|
-
}
|
|
821
|
-
|
|
822
|
-
// The same argument pruneRetiredSkills makes, one folder over — with one difference that changes
|
|
823
|
-
// the rule. `wdi-` is this method's namespace, so "a wdi-* folder not in WDI_SKILLS" is safely ours.
|
|
824
|
-
// `_bmad/custom/` is NOT: a product may put its own override there, and `.user.toml` is the
|
|
825
|
-
// product's half of every override by convention. So removal here is by an EXPLICIT list of files
|
|
826
|
-
// this package once shipped and has now withdrawn — never by "absent from the kit".
|
|
827
|
-
//
|
|
828
|
-
// Why remove them at all: an override for a retired engine is worse than no override. It is still
|
|
829
|
-
// installed and still read, and bmad-retrospective.toml instructs an agent to archive an `RTR-`
|
|
830
|
-
// against a validator, V19, that no longer exists.
|
|
831
|
-
const RETIRED_TOMLS = [
|
|
832
|
-
"bmad-spec.toml", "bmad-build.toml", "bmad-build-auto.toml",
|
|
833
|
-
"bmad-code-review.toml", "bmad-retrospective.toml",
|
|
834
|
-
];
|
|
835
|
-
|
|
836
|
-
function pruneRetiredTomls(target) {
|
|
837
|
-
const dir = path.join(target, "_bmad", "custom");
|
|
838
|
-
if (!fs.existsSync(dir)) return 0;
|
|
839
|
-
let removed = 0;
|
|
840
|
-
for (const name of RETIRED_TOMLS) {
|
|
841
|
-
const file = path.join(dir, name);
|
|
842
|
-
if (!fs.existsSync(file)) continue;
|
|
843
|
-
fs.rmSync(file);
|
|
844
|
-
note(`removed retired override ${name}`);
|
|
845
|
-
removed += 1;
|
|
846
|
-
}
|
|
847
|
-
return removed;
|
|
848
|
-
}
|
|
849
|
-
|
|
850
|
-
function seedControlIfMissing(target) {
|
|
851
|
-
const control = path.join(target, ".control");
|
|
852
|
-
if (fs.existsSync(control)) {
|
|
853
|
-
note(".control/ already present — left untouched");
|
|
854
|
-
return;
|
|
855
|
-
}
|
|
856
|
-
if (!fs.existsSync(SCAFFOLD)) die(`scaffold missing: ${SCAFFOLD}`);
|
|
857
|
-
const n = copyTree(SCAFFOLD, control);
|
|
858
|
-
ok(`seeded empty .control/ (${n} files)`);
|
|
859
|
-
}
|
|
860
|
-
|
|
861
|
-
// On a FIRST install these folders are the corpus taking shape. On an UPDATE their absence means
|
|
862
|
-
// somebody removed them on purpose — `.work/` and `_bmad-output/prior-knowledge/` are exactly the two a
|
|
863
|
-
// product retires once its migration is done, and one repo retired them through an applied decision.
|
|
864
|
-
// Recreating them then is an installer overruling a decision it cannot read. Seed once, never resurrect.
|
|
865
|
-
function seedEmptyLayers(target, { first }) {
|
|
866
|
-
const always = [".what", path.join(".how", "_platform")];
|
|
867
|
-
const firstOnly = [".work", path.join("_bmad-output", "prior-knowledge")];
|
|
868
|
-
for (const rel of first ? [...always, ...firstOnly] : always) {
|
|
869
|
-
const dest = path.join(target, rel);
|
|
870
|
-
if (!fs.existsSync(dest)) {
|
|
871
|
-
fs.mkdirSync(dest, { recursive: true });
|
|
872
|
-
|
|
873
|
-
|
|
874
|
-
|
|
875
|
-
|
|
876
|
-
|
|
877
|
-
|
|
878
|
-
|
|
879
|
-
|
|
880
|
-
|
|
881
|
-
|
|
882
|
-
|
|
883
|
-
|
|
884
|
-
|
|
885
|
-
|
|
886
|
-
|
|
887
|
-
|
|
888
|
-
|
|
889
|
-
|
|
890
|
-
|
|
891
|
-
|
|
892
|
-
|
|
893
|
-
|
|
894
|
-
|
|
895
|
-
|
|
896
|
-
}
|
|
897
|
-
|
|
898
|
-
|
|
899
|
-
|
|
900
|
-
|
|
901
|
-
|
|
902
|
-
|
|
903
|
-
|
|
904
|
-
|
|
905
|
-
|
|
906
|
-
fs.
|
|
907
|
-
|
|
908
|
-
|
|
909
|
-
|
|
910
|
-
|
|
911
|
-
|
|
912
|
-
|
|
913
|
-
|
|
914
|
-
|
|
915
|
-
|
|
916
|
-
|
|
917
|
-
|
|
918
|
-
|
|
919
|
-
|
|
920
|
-
|
|
921
|
-
|
|
922
|
-
|
|
923
|
-
|
|
924
|
-
|
|
925
|
-
|
|
926
|
-
|
|
927
|
-
|
|
928
|
-
|
|
929
|
-
|
|
930
|
-
}
|
|
931
|
-
|
|
932
|
-
|
|
933
|
-
|
|
934
|
-
|
|
935
|
-
}
|
|
936
|
-
|
|
937
|
-
|
|
938
|
-
|
|
939
|
-
|
|
940
|
-
|
|
941
|
-
|
|
942
|
-
|
|
943
|
-
|
|
944
|
-
|
|
945
|
-
|
|
946
|
-
|
|
947
|
-
|
|
948
|
-
|
|
949
|
-
|
|
950
|
-
|
|
951
|
-
|
|
952
|
-
|
|
953
|
-
|
|
954
|
-
|
|
955
|
-
|
|
956
|
-
|
|
957
|
-
|
|
958
|
-
|
|
959
|
-
if (
|
|
960
|
-
|
|
961
|
-
|
|
962
|
-
|
|
963
|
-
|
|
964
|
-
|
|
965
|
-
|
|
966
|
-
|
|
967
|
-
|
|
968
|
-
|
|
969
|
-
|
|
970
|
-
|
|
971
|
-
|
|
972
|
-
|
|
973
|
-
|
|
974
|
-
|
|
975
|
-
|
|
976
|
-
|
|
977
|
-
|
|
978
|
-
|
|
979
|
-
|
|
980
|
-
|
|
981
|
-
|
|
982
|
-
|
|
983
|
-
|
|
984
|
-
|
|
985
|
-
|
|
986
|
-
|
|
987
|
-
|
|
988
|
-
|
|
989
|
-
|
|
990
|
-
|
|
991
|
-
|
|
992
|
-
|
|
993
|
-
}
|
|
994
|
-
|
|
995
|
-
|
|
996
|
-
|
|
997
|
-
|
|
998
|
-
|
|
999
|
-
|
|
1000
|
-
|
|
1001
|
-
|
|
1002
|
-
|
|
1003
|
-
|
|
1004
|
-
|
|
1005
|
-
const
|
|
1006
|
-
|
|
1007
|
-
|
|
1008
|
-
|
|
1009
|
-
|
|
1010
|
-
|
|
1011
|
-
|
|
1012
|
-
|
|
1013
|
-
|
|
1014
|
-
|
|
1015
|
-
|
|
1016
|
-
|
|
1017
|
-
|
|
1018
|
-
|
|
1019
|
-
|
|
1020
|
-
|
|
1021
|
-
|
|
1022
|
-
|
|
1023
|
-
|
|
1024
|
-
|
|
1025
|
-
|
|
1026
|
-
|
|
1027
|
-
|
|
1028
|
-
|
|
1029
|
-
|
|
1030
|
-
|
|
1031
|
-
|
|
1032
|
-
|
|
1033
|
-
|
|
1034
|
-
|
|
1035
|
-
|
|
1036
|
-
|
|
1037
|
-
|
|
1038
|
-
|
|
1039
|
-
|
|
1040
|
-
|
|
1041
|
-
|
|
1042
|
-
|
|
1043
|
-
|
|
1044
|
-
|
|
1045
|
-
|
|
1046
|
-
|
|
1047
|
-
|
|
1048
|
-
|
|
1049
|
-
|
|
1050
|
-
|
|
1051
|
-
|
|
1052
|
-
|
|
1053
|
-
|
|
1054
|
-
|
|
1055
|
-
|
|
1056
|
-
|
|
1057
|
-
|
|
1058
|
-
|
|
1059
|
-
|
|
1060
|
-
|
|
1061
|
-
|
|
1062
|
-
|
|
1063
|
-
|
|
1064
|
-
|
|
1065
|
-
|
|
1066
|
-
|
|
1067
|
-
|
|
1068
|
-
|
|
1069
|
-
|
|
1070
|
-
|
|
1071
|
-
|
|
1072
|
-
const
|
|
1073
|
-
|
|
1074
|
-
|
|
1075
|
-
|
|
1076
|
-
|
|
1077
|
-
|
|
1078
|
-
|
|
1079
|
-
const
|
|
1080
|
-
if (
|
|
1081
|
-
if (
|
|
1082
|
-
|
|
1083
|
-
|
|
1084
|
-
|
|
1085
|
-
|
|
1086
|
-
if (
|
|
1087
|
-
|
|
1088
|
-
console.log("");
|
|
1089
|
-
|
|
1090
|
-
|
|
1091
|
-
if (
|
|
1092
|
-
|
|
1093
|
-
|
|
1094
|
-
|
|
1095
|
-
|
|
1096
|
-
if (
|
|
1097
|
-
|
|
1098
|
-
}
|
|
1099
|
-
|
|
1100
|
-
|
|
1101
|
-
|
|
1102
|
-
|
|
1103
|
-
|
|
1104
|
-
|
|
1105
|
-
|
|
1106
|
-
|
|
1107
|
-
|
|
1108
|
-
|
|
1109
|
-
|
|
1110
|
-
|
|
1111
|
-
|
|
1112
|
-
|
|
1113
|
-
|
|
1114
|
-
|
|
1115
|
-
|
|
1116
|
-
|
|
1117
|
-
if (
|
|
1118
|
-
summaryLine("
|
|
1119
|
-
|
|
1120
|
-
|
|
1121
|
-
|
|
1122
|
-
|
|
1123
|
-
|
|
1124
|
-
|
|
1125
|
-
|
|
1126
|
-
|
|
1127
|
-
|
|
1128
|
-
|
|
1129
|
-
|
|
1130
|
-
|
|
1131
|
-
console.log(
|
|
1132
|
-
|
|
1133
|
-
|
|
1134
|
-
|
|
1135
|
-
|
|
1136
|
-
|
|
1137
|
-
|
|
1138
|
-
|
|
1139
|
-
|
|
1140
|
-
|
|
1141
|
-
|
|
1142
|
-
|
|
1143
|
-
console.log("");
|
|
1144
|
-
console.log("
|
|
1145
|
-
console.log("
|
|
1146
|
-
console.log("
|
|
1147
|
-
|
|
1148
|
-
console.log("
|
|
1149
|
-
console.log("
|
|
1150
|
-
console.log("
|
|
1151
|
-
|
|
1152
|
-
|
|
1153
|
-
|
|
1154
|
-
|
|
1155
|
-
|
|
1156
|
-
|
|
1157
|
-
|
|
1158
|
-
|
|
1159
|
-
|
|
1160
|
-
|
|
1161
|
-
|
|
1162
|
-
|
|
1163
|
-
|
|
1164
|
-
|
|
1165
|
-
|
|
1166
|
-
|
|
1167
|
-
|
|
1168
|
-
|
|
1169
|
-
|
|
1170
|
-
|
|
1171
|
-
|
|
1172
|
-
|
|
1173
|
-
|
|
1174
|
-
|
|
1175
|
-
|
|
1176
|
-
|
|
1177
|
-
|
|
1178
|
-
|
|
1179
|
-
|
|
1180
|
-
|
|
1181
|
-
|
|
1182
|
-
|
|
1183
|
-
|
|
1184
|
-
|
|
1185
|
-
|
|
1186
|
-
|
|
1187
|
-
|
|
1188
|
-
|
|
1189
|
-
|
|
1190
|
-
|
|
1191
|
-
|
|
1192
|
-
|
|
1193
|
-
|
|
1194
|
-
|
|
1195
|
-
|
|
1196
|
-
|
|
1197
|
-
|
|
1198
|
-
|
|
1199
|
-
|
|
1200
|
-
|
|
1201
|
-
|
|
1202
|
-
|
|
1203
|
-
|
|
1204
|
-
|
|
1205
|
-
|
|
1206
|
-
|
|
1207
|
-
|
|
1208
|
-
|
|
1209
|
-
|
|
1210
|
-
|
|
1211
|
-
|
|
1212
|
-
|
|
1213
|
-
|
|
1214
|
-
|
|
1215
|
-
|
|
1216
|
-
|
|
1217
|
-
|
|
1218
|
-
|
|
1219
|
-
});
|
|
1220
|
-
|
|
1221
|
-
|
|
1222
|
-
|
|
1223
|
-
|
|
1224
|
-
|
|
1225
|
-
|
|
1226
|
-
|
|
1227
|
-
|
|
1228
|
-
|
|
1229
|
-
|
|
1230
|
-
|
|
1231
|
-
for (const
|
|
1232
|
-
|
|
1233
|
-
|
|
1234
|
-
|
|
1235
|
-
|
|
1236
|
-
|
|
1237
|
-
|
|
1238
|
-
|
|
1239
|
-
|
|
1240
|
-
|
|
1241
|
-
|
|
1242
|
-
|
|
1243
|
-
|
|
1244
|
-
|
|
1245
|
-
|
|
1246
|
-
|
|
1247
|
-
|
|
1248
|
-
|
|
1249
|
-
|
|
1250
|
-
|
|
1251
|
-
|
|
1252
|
-
|
|
1253
|
-
|
|
1254
|
-
|
|
1255
|
-
|
|
1256
|
-
|
|
1257
|
-
|
|
1258
|
-
|
|
1259
|
-
|
|
1260
|
-
|
|
1261
|
-
|
|
1262
|
-
}
|
|
1263
|
-
|
|
1264
|
-
|
|
1265
|
-
|
|
1266
|
-
|
|
1267
|
-
}
|
|
1268
|
-
|
|
1269
|
-
|
|
1270
|
-
|
|
1271
|
-
|
|
1272
|
-
|
|
1273
|
-
|
|
1274
|
-
|
|
1275
|
-
|
|
1276
|
-
|
|
1277
|
-
|
|
1278
|
-
|
|
1279
|
-
|
|
1280
|
-
|
|
1281
|
-
|
|
1282
|
-
|
|
1283
|
-
|
|
1284
|
-
|
|
1285
|
-
|
|
1286
|
-
|
|
1287
|
-
|
|
1288
|
-
|
|
1289
|
-
|
|
1290
|
-
|
|
1291
|
-
|
|
1292
|
-
|
|
1293
|
-
if (
|
|
1294
|
-
|
|
1295
|
-
|
|
1296
|
-
|
|
1297
|
-
|
|
1298
|
-
|
|
1299
|
-
|
|
1300
|
-
|
|
1301
|
-
|
|
1302
|
-
|
|
1303
|
-
|
|
1304
|
-
|
|
1305
|
-
|
|
1306
|
-
fs.
|
|
1307
|
-
|
|
1308
|
-
|
|
1309
|
-
|
|
1310
|
-
|
|
1311
|
-
|
|
1312
|
-
|
|
1313
|
-
|
|
1314
|
-
|
|
1315
|
-
|
|
1316
|
-
|
|
1317
|
-
|
|
1318
|
-
|
|
1319
|
-
|
|
1320
|
-
|
|
1321
|
-
|
|
1322
|
-
|
|
1323
|
-
|
|
1324
|
-
|
|
1325
|
-
fs.
|
|
1326
|
-
|
|
1327
|
-
|
|
1328
|
-
|
|
1329
|
-
|
|
1330
|
-
|
|
1331
|
-
|
|
1332
|
-
|
|
1333
|
-
|
|
1334
|
-
|
|
1335
|
-
|
|
1336
|
-
|
|
1337
|
-
}
|
|
1338
|
-
|
|
1339
|
-
|
|
1340
|
-
|
|
1341
|
-
|
|
1342
|
-
|
|
1343
|
-
|
|
1344
|
-
|
|
1345
|
-
|
|
1346
|
-
|
|
1347
|
-
|
|
1348
|
-
|
|
1349
|
-
|
|
1350
|
-
|
|
1351
|
-
|
|
1352
|
-
|
|
1353
|
-
|
|
1354
|
-
|
|
1355
|
-
|
|
1356
|
-
|
|
1357
|
-
|
|
1358
|
-
|
|
1359
|
-
|
|
1360
|
-
|
|
1361
|
-
|
|
1362
|
-
|
|
1363
|
-
|
|
1364
|
-
|
|
1365
|
-
|
|
1366
|
-
|
|
1367
|
-
|
|
1368
|
-
|
|
1369
|
-
|
|
1370
|
-
|
|
1371
|
-
|
|
1372
|
-
|
|
1373
|
-
|
|
1374
|
-
|
|
1375
|
-
|
|
1376
|
-
|
|
1377
|
-
|
|
1378
|
-
|
|
1379
|
-
|
|
1380
|
-
|
|
1381
|
-
|
|
1382
|
-
|
|
1383
|
-
|
|
1384
|
-
|
|
1385
|
-
|
|
1386
|
-
|
|
1387
|
-
|
|
1388
|
-
|
|
1389
|
-
|
|
1390
|
-
|
|
1391
|
-
|
|
1392
|
-
|
|
1393
|
-
|
|
1394
|
-
|
|
1395
|
-
|
|
1396
|
-
|
|
1397
|
-
|
|
1398
|
-
|
|
1399
|
-
|
|
1400
|
-
|
|
1401
|
-
|
|
1402
|
-
|
|
1403
|
-
|
|
1404
|
-
|
|
1405
|
-
|
|
1406
|
-
|
|
1407
|
-
|
|
1408
|
-
|
|
1409
|
-
|
|
1410
|
-
|
|
1411
|
-
|
|
1412
|
-
|
|
1413
|
-
|
|
1414
|
-
|
|
1415
|
-
|
|
1416
|
-
|
|
1417
|
-
|
|
1418
|
-
|
|
1419
|
-
|
|
1420
|
-
|
|
1421
|
-
|
|
1422
|
-
|
|
1423
|
-
|
|
1424
|
-
|
|
1425
|
-
|
|
1426
|
-
|
|
1427
|
-
|
|
1428
|
-
|
|
1429
|
-
|
|
1430
|
-
|
|
1431
|
-
|
|
1432
|
-
|
|
1433
|
-
|
|
1434
|
-
|
|
1435
|
-
|
|
1436
|
-
|
|
1437
|
-
|
|
1438
|
-
|
|
1439
|
-
|
|
1440
|
-
|
|
1441
|
-
|
|
1442
|
-
|
|
1443
|
-
|
|
1444
|
-
|
|
1445
|
-
|
|
1446
|
-
|
|
1447
|
-
|
|
1448
|
-
|
|
1449
|
-
|
|
1450
|
-
|
|
1451
|
-
|
|
1452
|
-
|
|
1453
|
-
|
|
1454
|
-
|
|
1455
|
-
|
|
1456
|
-
|
|
1457
|
-
|
|
1458
|
-
|
|
1459
|
-
|
|
1460
|
-
|
|
1461
|
-
|
|
1462
|
-
|
|
1463
|
-
|
|
1464
|
-
|
|
1465
|
-
|
|
1466
|
-
|
|
1467
|
-
|
|
1468
|
-
|
|
1469
|
-
|
|
1470
|
-
|
|
1471
|
-
|
|
1472
|
-
|
|
1473
|
-
|
|
1474
|
-
|
|
1475
|
-
|
|
1476
|
-
|
|
1477
|
-
|
|
1478
|
-
|
|
1479
|
-
|
|
1480
|
-
|
|
1481
|
-
|
|
1482
|
-
|
|
1483
|
-
|
|
1484
|
-
|
|
1485
|
-
|
|
1486
|
-
|
|
1487
|
-
|
|
1488
|
-
|
|
1489
|
-
|
|
1490
|
-
|
|
1491
|
-
|
|
1492
|
-
|
|
1493
|
-
|
|
1494
|
-
|
|
1495
|
-
|
|
1496
|
-
|
|
1497
|
-
|
|
1498
|
-
|
|
1499
|
-
|
|
1500
|
-
|
|
1501
|
-
|
|
1502
|
-
|
|
1503
|
-
|
|
1504
|
-
|
|
1505
|
-
|
|
1506
|
-
|
|
1507
|
-
|
|
1508
|
-
|
|
1509
|
-
|
|
1510
|
-
|
|
1511
|
-
|
|
1512
|
-
|
|
1513
|
-
|
|
1514
|
-
|
|
1515
|
-
|
|
1516
|
-
|
|
1517
|
-
|
|
1518
|
-
|
|
1519
|
-
|
|
1520
|
-
|
|
1521
|
-
|
|
1522
|
-
|
|
1523
|
-
|
|
1524
|
-
|
|
1525
|
-
|
|
1526
|
-
|
|
1527
|
-
|
|
1528
|
-
|
|
1529
|
-
|
|
1530
|
-
|
|
1531
|
-
|
|
1532
|
-
|
|
1533
|
-
|
|
1534
|
-
|
|
1535
|
-
|
|
1536
|
-
|
|
1537
|
-
|
|
1538
|
-
|
|
1539
|
-
|
|
1540
|
-
|
|
1541
|
-
|
|
1542
|
-
|
|
1543
|
-
|
|
1544
|
-
|
|
1545
|
-
|
|
1546
|
-
|
|
1547
|
-
)
|
|
1548
|
-
|
|
1549
|
-
|
|
1550
|
-
|
|
1551
|
-
|
|
1552
|
-
|
|
1553
|
-
|
|
1554
|
-
|
|
1555
|
-
|
|
1556
|
-
|
|
1557
|
-
|
|
1558
|
-
|
|
1559
|
-
|
|
1560
|
-
|
|
1561
|
-
|
|
1562
|
-
|
|
1563
|
-
|
|
1564
|
-
|
|
1565
|
-
|
|
1566
|
-
|
|
1567
|
-
|
|
1568
|
-
|
|
1569
|
-
|
|
1570
|
-
|
|
1571
|
-
|
|
1572
|
-
|
|
1573
|
-
|
|
1574
|
-
|
|
1575
|
-
|
|
1576
|
-
|
|
1577
|
-
|
|
1578
|
-
|
|
1579
|
-
|
|
1580
|
-
|
|
1581
|
-
|
|
1582
|
-
|
|
1583
|
-
|
|
1584
|
-
|
|
1585
|
-
|
|
1586
|
-
|
|
1587
|
-
|
|
1588
|
-
|
|
1589
|
-
|
|
1590
|
-
|
|
1591
|
-
|
|
1592
|
-
|
|
1593
|
-
|
|
1594
|
-
|
|
1595
|
-
|
|
1596
|
-
|
|
1597
|
-
|
|
1598
|
-
|
|
1599
|
-
|
|
1600
|
-
|
|
1601
|
-
|
|
1602
|
-
|
|
1603
|
-
|
|
1604
|
-
|
|
1605
|
-
|
|
1606
|
-
|
|
1607
|
-
|
|
1608
|
-
if (!args.
|
|
1609
|
-
|
|
1610
|
-
|
|
1611
|
-
|
|
1612
|
-
|
|
1613
|
-
|
|
1614
|
-
|
|
1615
|
-
|
|
1616
|
-
|
|
1617
|
-
|
|
1618
|
-
|
|
1619
|
-
|
|
1620
|
-
|
|
1621
|
-
|
|
1622
|
-
|
|
1623
|
-
|
|
1624
|
-
return;
|
|
1625
|
-
}
|
|
1626
|
-
|
|
1627
|
-
|
|
1628
|
-
|
|
1629
|
-
|
|
1630
|
-
|
|
1631
|
-
|
|
1632
|
-
|
|
1633
|
-
|
|
1634
|
-
|
|
1635
|
-
|
|
1636
|
-
}
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
import fs from "node:fs";
|
|
3
|
+
import os from "node:os";
|
|
4
|
+
import path from "node:path";
|
|
5
|
+
import { spawnSync } from "node:child_process";
|
|
6
|
+
import { fileURLToPath } from "node:url";
|
|
7
|
+
import * as p from "@clack/prompts";
|
|
8
|
+
import {
|
|
9
|
+
fillProductTitle,
|
|
10
|
+
upsertMethodBlock,
|
|
11
|
+
} from "../lib/agents-block.mjs";
|
|
12
|
+
import {
|
|
13
|
+
identityIsPlaceholder,
|
|
14
|
+
humaniseFolderName,
|
|
15
|
+
readLanguagePolicy,
|
|
16
|
+
writeLanguagePolicy,
|
|
17
|
+
DEFAULT_DOC_LANGUAGE,
|
|
18
|
+
readProductIdentity,
|
|
19
|
+
writeProductIdentity,
|
|
20
|
+
} from "../lib/identity.mjs";
|
|
21
|
+
import {
|
|
22
|
+
detectPlatforms,
|
|
23
|
+
formatPlatformList,
|
|
24
|
+
isKnownPlatform,
|
|
25
|
+
normalizePlatformIds,
|
|
26
|
+
platformSelectOptions,
|
|
27
|
+
platformUsesHook,
|
|
28
|
+
PREFERRED_PLATFORM_IDS,
|
|
29
|
+
skillDestinations,
|
|
30
|
+
} from "../lib/platforms.mjs";
|
|
31
|
+
import { opencodeCommandsDir, syncOpencodeCommands } from "../lib/opencode-commands.mjs";
|
|
32
|
+
|
|
33
|
+
const ROOT = path.resolve(path.dirname(fileURLToPath(import.meta.url)), "..");
|
|
34
|
+
const KIT = path.join(ROOT, "kit");
|
|
35
|
+
const OVERLAY = path.join(ROOT, "kit-overlay");
|
|
36
|
+
const SCAFFOLD = path.join(ROOT, "scaffold", ".control");
|
|
37
|
+
const PKG = JSON.parse(fs.readFileSync(path.join(ROOT, "package.json"), "utf8"));
|
|
38
|
+
|
|
39
|
+
const WDI_SKILLS = [
|
|
40
|
+
"wdi-init",
|
|
41
|
+
"wdi-problem",
|
|
42
|
+
"wdi-product",
|
|
43
|
+
"wdi-ux",
|
|
44
|
+
"wdi-blueprint",
|
|
45
|
+
"wdi-component",
|
|
46
|
+
"wdi-build",
|
|
47
|
+
"wdi-decision",
|
|
48
|
+
"wdi-question",
|
|
49
|
+
"wdi-log",
|
|
50
|
+
"wdi-help",
|
|
51
|
+
"wdi-explain-to-me",
|
|
52
|
+
"wdi-autopilot",
|
|
53
|
+
"wdi-reconcile",
|
|
54
|
+
"wdi-review",
|
|
55
|
+
"wdi-report",
|
|
56
|
+
"wdi-systematic-debugging",
|
|
57
|
+
"wdi-upgrade",
|
|
58
|
+
];
|
|
59
|
+
|
|
60
|
+
const PRD_SLUG_PLACEHOLDER = "FILL-initiative-slug";
|
|
61
|
+
const GENERIC_FOLDER_PATTERNS = new Set([
|
|
62
|
+
"_product-brief",
|
|
63
|
+
"ux",
|
|
64
|
+
"architecture",
|
|
65
|
+
PRD_SLUG_PLACEHOLDER,
|
|
66
|
+
]);
|
|
67
|
+
|
|
68
|
+
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.
|
|
72
|
+
const ENGINES_REPO = "https://github.com/mattpocock/skills";
|
|
73
|
+
const ENGINES_PLUGIN = "mattpocock-skills";
|
|
74
|
+
const ENGINES_INSTALL = `/plugin install ${ENGINES_PLUGIN}`;
|
|
75
|
+
const ENGINES_INSTALL_ANY = "npx skills@latest add mattpocock/skills";
|
|
76
|
+
const ENGINES_SETUP = "/setup-matt-pocock-skills";
|
|
77
|
+
const REPO_URL = "https://github.com/wiradigitalid/wdi-method";
|
|
78
|
+
const HELP_SKILL = "wdi-help";
|
|
79
|
+
const INIT_SKILL = "wdi-init";
|
|
80
|
+
// The room's readers file is seeded as a skeleton and is useless until a product writes it. The
|
|
81
|
+
// flag is the skeleton's own declaration, so this reads the same thing the engine does rather than
|
|
82
|
+
// guessing from the file's size or its age.
|
|
83
|
+
function readersAreSkeleton(target) {
|
|
84
|
+
const file = path.join(target, ".constitution", "project", "inventory-readers.py");
|
|
85
|
+
if (!fs.existsSync(file)) return false;
|
|
86
|
+
return /^SKELETON\s*=\s*True\b/m.test(fs.readFileSync(file, "utf8"));
|
|
87
|
+
}
|
|
88
|
+
const BMAD_REPO = "https://github.com/bmad-code-org/BMAD-METHOD";
|
|
89
|
+
const WDI_REPO = "https://github.com/wiradigitalid/wdi-method";
|
|
90
|
+
|
|
91
|
+
const RED = "\x1b[31m";
|
|
92
|
+
const GREEN = "\x1b[32m";
|
|
93
|
+
const DIM = "\x1b[2m";
|
|
94
|
+
const RESET = "\x1b[0m";
|
|
95
|
+
|
|
96
|
+
function die(msg) {
|
|
97
|
+
console.error(`${RED}error:${RESET} ${msg}`);
|
|
98
|
+
process.exit(1);
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
function ok(msg) {
|
|
102
|
+
console.log(`${GREEN}ok${RESET} ${msg}`);
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
function note(msg) {
|
|
106
|
+
console.log(`${DIM}·${RESET} ${msg}`);
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
function usage() {
|
|
110
|
+
console.log(`wdi-method ${PKG.version}
|
|
111
|
+
|
|
112
|
+
(no command) interactive TUI — detects install vs update
|
|
113
|
+
install [dir] first install (TUI unless --yes)
|
|
114
|
+
update [dir] update (TUI unless --yes)
|
|
115
|
+
verify [dir]
|
|
116
|
+
promote <live-dir> --rescue pull a method change back out of a consumer (not the normal flow)
|
|
117
|
+
|
|
118
|
+
--yes non-interactive
|
|
119
|
+
--agents a,b platform IDs (same as BMad --tools; legacy: claude = claude-code)
|
|
120
|
+
--list-agents print supported platform IDs
|
|
121
|
+
--product NAME written to index.yaml product.name
|
|
122
|
+
--client NAME written to index.yaml product.client (optional)
|
|
123
|
+
--doc-language <text> prose of working documents; free text, default English
|
|
124
|
+
--doc-filename-language <text> slug part of document filenames; free text, default English
|
|
125
|
+
--skip-bmad-check
|
|
126
|
+
--skip-engines-check install without to-spec / to-tickets / implement
|
|
127
|
+
|
|
128
|
+
BMad first, then this package. ${WDI_REPO}
|
|
129
|
+
`);
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
function parseArgs(argv) {
|
|
133
|
+
const args = {
|
|
134
|
+
cmd: null,
|
|
135
|
+
dir: null,
|
|
136
|
+
agents: null,
|
|
137
|
+
skipBmad: false,
|
|
138
|
+
rescue: false,
|
|
139
|
+
yes: false,
|
|
140
|
+
product: null,
|
|
141
|
+
client: null,
|
|
142
|
+
docLanguage: null,
|
|
143
|
+
docFilenameLanguage: null,
|
|
144
|
+
};
|
|
145
|
+
const rest = argv.slice(2);
|
|
146
|
+
if (rest[0] === "-h" || rest[0] === "--help") {
|
|
147
|
+
usage();
|
|
148
|
+
process.exit(0);
|
|
149
|
+
}
|
|
150
|
+
if (rest[0] === "--list-agents") {
|
|
151
|
+
console.log(formatPlatformList());
|
|
152
|
+
process.exit(0);
|
|
153
|
+
}
|
|
154
|
+
if (rest.length === 0) {
|
|
155
|
+
args.cmd = "wizard";
|
|
156
|
+
return args;
|
|
157
|
+
}
|
|
158
|
+
const first = rest[0];
|
|
159
|
+
if (["install", "update", "verify", "promote"].includes(first)) {
|
|
160
|
+
args.cmd = rest.shift();
|
|
161
|
+
} else if (first.startsWith("-")) {
|
|
162
|
+
args.cmd = "wizard";
|
|
163
|
+
} else {
|
|
164
|
+
args.cmd = "wizard";
|
|
165
|
+
args.dir = rest.shift();
|
|
166
|
+
}
|
|
167
|
+
while (rest.length) {
|
|
168
|
+
const t = rest.shift();
|
|
169
|
+
if (t === "--skip-bmad-check") args.skipBmad = true;
|
|
170
|
+
else if (t === "--skip-engines-check") args.skipEngines = true;
|
|
171
|
+
else if (t === "--rescue") args.rescue = true;
|
|
172
|
+
else if (t === "--yes" || t === "-y") args.yes = true;
|
|
173
|
+
else if (t === "--agents") {
|
|
174
|
+
const raw = rest.shift();
|
|
175
|
+
if (!raw) die("--agents needs a comma-separated list");
|
|
176
|
+
args.agents = normalizePlatformIds(raw.split(",").map((s) => s.trim()).filter(Boolean));
|
|
177
|
+
const unknown = raw.split(",").map((s) => s.trim()).filter(Boolean)
|
|
178
|
+
.filter((a) => !isKnownPlatform(a));
|
|
179
|
+
if (unknown.length) die(`unknown platform: ${unknown.join(", ")} (run --list-agents)`);
|
|
180
|
+
if (!args.agents.length) die("--agents needs at least one known platform");
|
|
181
|
+
} else if (t === "--product") args.product = rest.shift();
|
|
182
|
+
else if (t === "--client") args.client = rest.shift();
|
|
183
|
+
else if (t === "--doc-language" || t === "--doc-filename-language") {
|
|
184
|
+
// Free text: "English", "Bahasa Indonesia", "id" — a model reads it, so no list to match.
|
|
185
|
+
const raw = (rest.shift() || "").trim();
|
|
186
|
+
if (!raw) die(`${t} needs a value, for example: English`);
|
|
187
|
+
if (t === "--doc-language") args.docLanguage = raw;
|
|
188
|
+
else args.docFilenameLanguage = raw;
|
|
189
|
+
}
|
|
190
|
+
else if (t.startsWith("-")) die(`unknown flag: ${t}`);
|
|
191
|
+
else if (!args.dir) args.dir = t;
|
|
192
|
+
else die(`unexpected argument: ${t}`);
|
|
193
|
+
}
|
|
194
|
+
return args;
|
|
195
|
+
}
|
|
196
|
+
|
|
197
|
+
// Build output and editor droppings MUST NOT reach the kit. This repository is public, and a
|
|
198
|
+
// __pycache__/*.pyc carries the ABSOLUTE PATH of the source it was compiled from — which means a
|
|
199
|
+
// product name and a client folder leak into a public package through a file nobody wrote.
|
|
200
|
+
// Found 2026-08-18 on the first real promote: inventory.cpython-314.pyc embedded the live repo path.
|
|
201
|
+
const SKIP_DIRS = new Set(["__pycache__", "node_modules", ".git", ".pytest_cache", ".ruff_cache",
|
|
202
|
+
".mypy_cache", ".venv", "venv", "dist", "build", ".idea", ".vscode"]);
|
|
203
|
+
const SKIP_FILE = /(\.pyc|\.pyo|\.pyd|\.log|\.tmp|\.swp|\.orig|\.rej|\.bak)$|^\.DS_Store$|^Thumbs\.db$/i;
|
|
204
|
+
|
|
205
|
+
function walkFiles(dir) {
|
|
206
|
+
const out = [];
|
|
207
|
+
if (!fs.existsSync(dir)) return out;
|
|
208
|
+
for (const entry of fs.readdirSync(dir, { withFileTypes: true })) {
|
|
209
|
+
const p = path.join(dir, entry.name);
|
|
210
|
+
if (entry.isDirectory()) {
|
|
211
|
+
if (SKIP_DIRS.has(entry.name)) continue;
|
|
212
|
+
out.push(...walkFiles(p));
|
|
213
|
+
} else if (entry.isFile()) {
|
|
214
|
+
if (SKIP_FILE.test(entry.name)) continue;
|
|
215
|
+
out.push(p);
|
|
216
|
+
}
|
|
217
|
+
}
|
|
218
|
+
return out;
|
|
219
|
+
}
|
|
220
|
+
|
|
221
|
+
function copyFile(src, dest) {
|
|
222
|
+
fs.mkdirSync(path.dirname(dest), { recursive: true });
|
|
223
|
+
fs.copyFileSync(src, dest);
|
|
224
|
+
}
|
|
225
|
+
|
|
226
|
+
function copyTree(src, dest, skipRel) {
|
|
227
|
+
let n = 0;
|
|
228
|
+
for (const p of walkFiles(src)) {
|
|
229
|
+
const rel = posixRel(src, p);
|
|
230
|
+
if (skipRel && skipRel(rel)) continue;
|
|
231
|
+
copyFile(p, path.join(dest, path.relative(src, p)));
|
|
232
|
+
n += 1;
|
|
233
|
+
}
|
|
234
|
+
return n;
|
|
235
|
+
}
|
|
236
|
+
|
|
237
|
+
function posixRel(from, to) {
|
|
238
|
+
return path.relative(from, to).split(path.sep).join("/");
|
|
239
|
+
}
|
|
240
|
+
|
|
241
|
+
function bmadPresent(target) {
|
|
242
|
+
const markers = [
|
|
243
|
+
path.join(target, ".claude", "skills", "bmad-help", "SKILL.md"),
|
|
244
|
+
path.join(target, "_bmad", "core", "config.yaml"),
|
|
245
|
+
path.join(target, "_bmad", "_config", "manifest.yaml"),
|
|
246
|
+
];
|
|
247
|
+
return markers.some((p) => fs.existsSync(p));
|
|
248
|
+
}
|
|
249
|
+
|
|
250
|
+
function wdiPresent(target) {
|
|
251
|
+
return (
|
|
252
|
+
fs.existsSync(path.join(target, ".control", "wdi-method.yaml")) ||
|
|
253
|
+
fs.existsSync(path.join(target, ".constitution", "method", "README.md"))
|
|
254
|
+
);
|
|
255
|
+
}
|
|
256
|
+
|
|
257
|
+
function dirNonEmpty(target) {
|
|
258
|
+
if (!fs.existsSync(target)) return false;
|
|
259
|
+
return fs.readdirSync(target).some((n) => n !== ".git" && n !== ".gitignore");
|
|
260
|
+
}
|
|
261
|
+
|
|
262
|
+
function readBmadVersion(target) {
|
|
263
|
+
const manifest = path.join(target, "_bmad", "_config", "manifest.yaml");
|
|
264
|
+
if (!fs.existsSync(manifest)) return "";
|
|
265
|
+
const text = fs.readFileSync(manifest, "utf8");
|
|
266
|
+
const m = text.match(/installation:\s*\n\s*version:\s*(\S+)/);
|
|
267
|
+
return m ? m[1] : "";
|
|
268
|
+
}
|
|
269
|
+
|
|
270
|
+
function gitHead(repo) {
|
|
271
|
+
const r = spawnSync("git", ["-C", repo, "rev-parse", "--short", "HEAD"], {
|
|
272
|
+
encoding: "utf8",
|
|
273
|
+
});
|
|
274
|
+
if (r.status !== 0) return "unknown";
|
|
275
|
+
return r.stdout.trim();
|
|
276
|
+
}
|
|
277
|
+
|
|
278
|
+
function today() {
|
|
279
|
+
return new Date().toISOString().slice(0, 10);
|
|
280
|
+
}
|
|
281
|
+
|
|
282
|
+
function requireKit() {
|
|
283
|
+
if (!fs.existsSync(path.join(KIT, ".constitution"))) {
|
|
284
|
+
die(`kit missing at ${KIT}`);
|
|
285
|
+
}
|
|
286
|
+
}
|
|
287
|
+
|
|
288
|
+
function requireTarget(dir) {
|
|
289
|
+
const target = path.resolve(dir || process.cwd());
|
|
290
|
+
if (!fs.existsSync(target) || !fs.statSync(target).isDirectory()) {
|
|
291
|
+
die(`target is not a directory: ${target}`);
|
|
292
|
+
}
|
|
293
|
+
return target;
|
|
294
|
+
}
|
|
295
|
+
|
|
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;
|
|
300
|
+
}
|
|
301
|
+
const cfg = process.env.CLAUDE_CONFIG_DIR || path.join(os.homedir(), ".claude");
|
|
302
|
+
const registry = path.join(cfg, "plugins", "installed_plugins.json");
|
|
303
|
+
if (!fs.existsSync(registry)) return false;
|
|
304
|
+
try {
|
|
305
|
+
const plugins = JSON.parse(fs.readFileSync(registry, "utf8")).plugins || {};
|
|
306
|
+
return Object.keys(plugins).some((k) => k.startsWith("mattpocock-skills@"));
|
|
307
|
+
} catch {
|
|
308
|
+
return false;
|
|
309
|
+
}
|
|
310
|
+
}
|
|
311
|
+
|
|
312
|
+
function bmadMissingMessage() {
|
|
313
|
+
return [
|
|
314
|
+
"BMad Method is not installed in this repo. Install it first, then run this installer again.",
|
|
315
|
+
"",
|
|
316
|
+
` ${BMAD_INSTALL}`,
|
|
317
|
+
"",
|
|
318
|
+
`Source: ${BMAD_REPO}`,
|
|
319
|
+
"In the BMad installer, pick the same agents (Claude Code, Cursor, …).",
|
|
320
|
+
].join("\n");
|
|
321
|
+
}
|
|
322
|
+
|
|
323
|
+
// The engines used to WARN and let the install through, on the reasoning that G1-G4 run without them and
|
|
324
|
+
// a first install has no G5 yet. Both halves are still true, and the reasoning stopped being enough:
|
|
325
|
+
// `wdi-autopilot` needs all three from its first iteration, and a warning inside a forty-line summary is
|
|
326
|
+
// read exactly as often as it is skipped. The failure it was meant to prevent — learning they are missing
|
|
327
|
+
// inside `wdi-build`, with a spec already open — kept happening anyway.
|
|
328
|
+
//
|
|
329
|
+
// So it blocks, and `--skip-engines-check` is the escape, exactly as `--skip-bmad-check` is for BMad. The
|
|
330
|
+
// escape matters: CI installs into a bare checkout, and a repo that will never reach G5 is a real case.
|
|
331
|
+
function enginesMissingMessage() {
|
|
332
|
+
return [
|
|
333
|
+
"The ticket engines are not installed. G5 (wdi-build) and wdi-autopilot need all three.",
|
|
334
|
+
"",
|
|
335
|
+
` Claude Code: ${ENGINES_INSTALL}`,
|
|
336
|
+
` Other agents: ${ENGINES_INSTALL_ANY}`,
|
|
337
|
+
"",
|
|
338
|
+
"You do NOT need to run the setup skill after this — the installer seeds docs/agents/ already",
|
|
339
|
+
`answered for this method. Run ${ENGINES_SETUP} only to change tracker.`,
|
|
340
|
+
`Source: ${ENGINES_REPO}`,
|
|
341
|
+
"",
|
|
342
|
+
"G1-G4 run without them. To install anyway and add them later: --skip-engines-check",
|
|
343
|
+
].join("\n");
|
|
344
|
+
}
|
|
345
|
+
|
|
346
|
+
// The product's custom room. Three properties, and all three MUST hold together:
|
|
347
|
+
// install/update seeds its content ONLY when absent — never written again after that
|
|
348
|
+
// promote SKIPS it entirely, so a product's own rules can never reach the public repo
|
|
349
|
+
// agent loads it like any other guide, so it BINDS
|
|
350
|
+
// The deliberate consequence: this room's README is authored in the package and never comes home
|
|
351
|
+
// through promote.
|
|
352
|
+
const PROJECT_ROOM = "project/";
|
|
353
|
+
|
|
354
|
+
// 0.5.0 moved `.constitution/` to exactly two folders: `method/` is the method's and is overwritten,
|
|
355
|
+
// `project/` is the product's and is never touched. Before it, generic and product-owned files sat
|
|
356
|
+
// side by side at the root, `codebase/` was a third product-owned room nobody had written down, and
|
|
357
|
+
// `constitution.md` was ONE file holding both — which is why `update` had to keep the whole thing and
|
|
358
|
+
// the product never received a fixed generic Article.
|
|
359
|
+
//
|
|
360
|
+
// Without this migration an installed repo would end up carrying BOTH layouts: the kit writes the new
|
|
361
|
+
// paths while the old files stay behind, and an agent reading `AGENTS.md` routing would find two
|
|
362
|
+
// copies of most guides and no way to tell which binds.
|
|
363
|
+
const OLD_ROOT_GUIDES = ["README", "language-guide", "method-glossary", "repo-guide", "structure-guide"];
|
|
364
|
+
const OLD_WHY = ["README", "artifact-map", "portability", "rationale"];
|
|
365
|
+
const OLD_CODEBASE = ["stack", "conventions", "brownfield"];
|
|
366
|
+
|
|
367
|
+
function mv(from, to) {
|
|
368
|
+
fs.mkdirSync(path.dirname(to), { recursive: true });
|
|
369
|
+
fs.renameSync(from, to);
|
|
370
|
+
}
|
|
371
|
+
|
|
372
|
+
/** Article numbers that belong to the method half. The product keeps 1, 2, and 5. */
|
|
373
|
+
const METHOD_ARTICLES = [3, 4, 6, 7];
|
|
374
|
+
|
|
375
|
+
/**
|
|
376
|
+
* Cut the method's articles out of a product's constitution.md, and repoint its relative links.
|
|
377
|
+
*
|
|
378
|
+
* Returns {cut, kept, relinked}, or null when the file does not look like a constitution at all —
|
|
379
|
+
* in which case it is left ALONE rather than guessed at.
|
|
380
|
+
*
|
|
381
|
+
* 0.5.0 moved the file whole and printed "delete Articles 3, 4, 6, 7 yourself", on the grounds that
|
|
382
|
+
* no script can tell an edited copy from the original. That reasoning was wrong in the way that
|
|
383
|
+
* matters: the split does not need to know whether a section was edited, only which article numbers
|
|
384
|
+
* are the method's — and the file states them in its own headings. Leaving it whole left every
|
|
385
|
+
* migrated repo carrying those articles in TWO files, one of them frozen and drifting, plus relative
|
|
386
|
+
* links that no longer resolve one level down. It is all in git, so cutting is reversible; not
|
|
387
|
+
* cutting is what nobody notices.
|
|
388
|
+
*/
|
|
389
|
+
function splitProductConstitution(file) {
|
|
390
|
+
if (!fs.existsSync(file)) return null;
|
|
391
|
+
const raw = fs.readFileSync(file, "utf8");
|
|
392
|
+
const crlf = raw.includes("\r\n");
|
|
393
|
+
const text = crlf ? raw.replaceAll("\r\n", "\n") : raw;
|
|
394
|
+
const marks = [...text.matchAll(/^## Article (\d+)\b.*$/gm)];
|
|
395
|
+
if (marks.length < 2) return null; // not the shape we know; do not touch it
|
|
396
|
+
|
|
397
|
+
const kept = [];
|
|
398
|
+
const cut = [];
|
|
399
|
+
let out = text.slice(0, marks[0].index);
|
|
400
|
+
for (let i = 0; i < marks.length; i += 1) {
|
|
401
|
+
const n = Number(marks[i][1]);
|
|
402
|
+
const end = i + 1 < marks.length ? marks[i + 1].index : text.length;
|
|
403
|
+
if (METHOD_ARTICLES.includes(n)) cut.push(n);
|
|
404
|
+
else {
|
|
405
|
+
kept.push(n);
|
|
406
|
+
out += text.slice(marks[i].index, end);
|
|
407
|
+
}
|
|
408
|
+
}
|
|
409
|
+
if (!cut.length) return { cut, kept, relinked: 0 };
|
|
410
|
+
|
|
411
|
+
// The file sits one level deeper than it did, and its former siblings moved into method/. A link
|
|
412
|
+
// left as `repo-guide.md` now resolves to .constitution/project/repo-guide.md, which does not exist.
|
|
413
|
+
let relinked = 0;
|
|
414
|
+
const bump = (re, to) => {
|
|
415
|
+
out = out.replace(re, (m, ...rest) => {
|
|
416
|
+
relinked += 1;
|
|
417
|
+
return typeof to === "function" ? to(m, ...rest) : to + m;
|
|
418
|
+
});
|
|
419
|
+
};
|
|
420
|
+
for (const name of ["repo-guide.md", "structure-guide.md", "language-guide.md",
|
|
421
|
+
"method-glossary.md"]) {
|
|
422
|
+
bump(new RegExp(`(?<![\\w./-])${name.replace(".", "\\.")}`, "g"), "../method/");
|
|
423
|
+
}
|
|
424
|
+
bump(/(?<![\w./-])document\//g, "../method/");
|
|
425
|
+
bump(/(?<![\w./-])codebase\/([a-z]+)-guide\.md/g, (_m, kind) => `codebase-${kind}-guide.md`);
|
|
426
|
+
out = out.replaceAll("../method/../method/", "../method/");
|
|
427
|
+
|
|
428
|
+
const banner = [
|
|
429
|
+
"",
|
|
430
|
+
`> **Articles ${cut.join(", ")} were removed from this file on migration to the two-folder layout.**`,
|
|
431
|
+
"> They are the method's and live in [`../method/constitution.md`](../method/constitution.md), which",
|
|
432
|
+
`> \`update\` replaces. Only Articles ${kept.join(", ")} are yours. The removed text is in git.`,
|
|
433
|
+
"",
|
|
434
|
+
].join("\n");
|
|
435
|
+
const firstArticle = out.search(/^## Article /m);
|
|
436
|
+
out = firstArticle === -1
|
|
437
|
+
? out + banner
|
|
438
|
+
: out.slice(0, firstArticle) + banner.trimStart() + "\n" + out.slice(firstArticle);
|
|
439
|
+
|
|
440
|
+
fs.writeFileSync(file, crlf ? out.replaceAll("\n", "\r\n") : out, "utf8");
|
|
441
|
+
return { cut, kept, relinked };
|
|
442
|
+
}
|
|
443
|
+
|
|
444
|
+
// `waves.yaml` holds the PRODUCT's plan, not the package's. When the method retired `wave` for
|
|
445
|
+
// `spec` the registry had to follow, and a rename is the only part of that a tool can safely do:
|
|
446
|
+
// the file MOVES, its content is left exactly as written. Rewriting the rows — `W1` to `SPEC-1`,
|
|
447
|
+
// `epics`/`stories` to `tickets` — is the product's own migration, run by `wdi-build` where a human
|
|
448
|
+
// can see it, because a guess there silently rewrites months of real work.
|
|
449
|
+
//
|
|
450
|
+
// Two refusals matter more than the move. It never writes over an existing `specs.yaml`, and it
|
|
451
|
+
// never deletes a `waves.yaml` whose content has nowhere to go: a half-finished hand migration
|
|
452
|
+
// leaves BOTH files present, and which one is real is not something an installer can know.
|
|
453
|
+
// `wdi-autopilot` named its ledger for the DAY before 0.6.2 — `autopilot-<YYYY-MM-DD>.md`. The mandate
|
|
454
|
+
// it belongs to is named for the MANDATE now — `autopilot-<DEC-id>.md` — because two mandates opened
|
|
455
|
+
// on the same day would otherwise append to one file and destroy both as a record, and because
|
|
456
|
+
// `mandate-accept` (the validator introduced alongside the rename) looks for the file at that path and
|
|
457
|
+
// nowhere else. This is a pure rename, like `waves.yaml` → `specs.yaml`: the ledger's own content is
|
|
458
|
+
// never touched, only found and moved. Renaming it is what a script can safely do; restructuring its
|
|
459
|
+
// CONTENT into the `## Resume` / `## Decisions` split is not — that has to read git and the registry to
|
|
460
|
+
// know where the run actually stands, so it is the skill's own job on the next iteration it runs, not
|
|
461
|
+
// this installer's.
|
|
462
|
+
// `/setup-matt-pocock-skills` interviews the owner and writes `docs/agents/`. Two of its answers are
|
|
463
|
+
// wrong for a WDI repo, and BOTH repos that ran it had to hand-correct the SAME file afterwards:
|
|
464
|
+
//
|
|
465
|
+
// - `domain.md` tells agents to read and lazily create a root `CONTEXT.md` and `docs/adr/`. Article 3
|
|
466
|
+
// says this method has no `docs/` layer for corpus or rules, and `wdi-reconcile` reports both as
|
|
467
|
+
// findings. The homes already exist: `.control/product-glossary.md`, `.what/`, `.how/`, `DEC-`.
|
|
468
|
+
// - `issue-tracker.md`'s local-markdown default puts every ticket under `.scratch/<feature>/`, while
|
|
469
|
+
// `wdi-build` owns tickets at `{spec_folder}/issues/`. Two homes for one ticket set.
|
|
470
|
+
//
|
|
471
|
+
// Seeding them removes the interview for the answers WDI Method actually has a requirement on. Seeded
|
|
472
|
+
// ONCE and never overwritten — after the first install they are the product's, like every other file
|
|
473
|
+
// under a path the product owns. An owner who wants a different tracker re-runs the setup skill; the
|
|
474
|
+
// seeded file says which three invariants have to survive that.
|
|
475
|
+
function seedAgentDocs(target) {
|
|
476
|
+
const dir = path.join(target, "docs", "agents");
|
|
477
|
+
let wrote = 0;
|
|
478
|
+
for (const name of ["domain.md", "issue-tracker.md"]) {
|
|
479
|
+
const to = path.join(dir, name);
|
|
480
|
+
if (fs.existsSync(to)) continue;
|
|
481
|
+
const seed = path.join(ROOT, "scaffold", "docs", "agents", name);
|
|
482
|
+
if (!fs.existsSync(seed)) continue;
|
|
483
|
+
copyFile(seed, to);
|
|
484
|
+
wrote += 1;
|
|
485
|
+
}
|
|
486
|
+
if (wrote) {
|
|
487
|
+
note(`seeded docs/agents/ (${wrote} file${wrote === 1 ? "" : "s"}) — the engines' config, pre-answered`);
|
|
488
|
+
note(" do NOT run /setup-matt-pocock-skills to redo these; re-run it only to change tracker");
|
|
489
|
+
}
|
|
490
|
+
return wrote > 0;
|
|
491
|
+
}
|
|
492
|
+
|
|
493
|
+
// A repo that ran the setup skill BEFORE installing this package still carries the default `domain.md`,
|
|
494
|
+
// and it is actively misleading: it sends every engineering skill looking for a root `CONTEXT.md` and
|
|
495
|
+
// `docs/adr/`, and tells them to create both lazily. Seeding cannot fix it, because the file already
|
|
496
|
+
// exists and a file under a product-owned path is never overwritten. So it is named instead.
|
|
497
|
+
function warnStaleAgentDocs(target) {
|
|
498
|
+
const file = path.join(target, "docs", "agents", "domain.md");
|
|
499
|
+
if (!fs.existsSync(file)) return;
|
|
500
|
+
const text = fs.readFileSync(file, "utf8");
|
|
501
|
+
if (!/CONTEXT\.md|docs\/adr/.test(text)) return;
|
|
502
|
+
// An override note is what both real repos added by hand. Recognising it is what stops this warning
|
|
503
|
+
// from firing forever on a file somebody already fixed.
|
|
504
|
+
if (/does not use|MUST NOT be created|no `docs\/` layer/i.test(text)) return;
|
|
505
|
+
note("docs/agents/domain.md still points agents at a root CONTEXT.md and docs/adr/");
|
|
506
|
+
note(" Article 3: this method has no `docs/` layer for corpus or rules, and wdi-reconcile");
|
|
507
|
+
note(" reports both as findings. Say so at the top of that file — the glossary is at");
|
|
508
|
+
note(" .control/product-glossary.md and a decision is a DEC-, never an ADR");
|
|
509
|
+
}
|
|
510
|
+
|
|
511
|
+
function migrateAutopilotLedgers(target) {
|
|
512
|
+
const dir = path.join(target, ".control", "memlog");
|
|
513
|
+
if (!fs.existsSync(dir)) return;
|
|
514
|
+
const OLD = /^autopilot-(\d{4}-\d{2}-\d{2})\.md$/;
|
|
515
|
+
for (const name of fs.readdirSync(dir)) {
|
|
516
|
+
const m = OLD.exec(name);
|
|
517
|
+
if (!m) continue;
|
|
518
|
+
const from = path.join(dir, name);
|
|
519
|
+
const text = fs.readFileSync(from, "utf8");
|
|
520
|
+
const artifact = /^artifact:\s*(\S.*)$/m.exec(text)?.[1]?.trim();
|
|
521
|
+
const id = artifact && /(DEC-\d+)/.exec(artifact)?.[1];
|
|
522
|
+
if (!id) {
|
|
523
|
+
note(`.control/memlog/${name} looks like a pre-0.6.2 autopilot ledger, but its \`artifact:\` does`);
|
|
524
|
+
note(` not resolve to a DEC- id — rename it to autopilot-<the mandate's DEC- id>.md yourself`);
|
|
525
|
+
continue;
|
|
526
|
+
}
|
|
527
|
+
const to = path.join(dir, `autopilot-${id}.md`);
|
|
528
|
+
if (fs.existsSync(to)) {
|
|
529
|
+
note(`BOTH .control/memlog/${name} and autopilot-${id}.md exist — neither was touched`);
|
|
530
|
+
note(` the run's ledger is in one of them and I cannot tell which. Merge them, then delete the other`);
|
|
531
|
+
continue;
|
|
532
|
+
}
|
|
533
|
+
mv(from, to);
|
|
534
|
+
note(`renamed .control/memlog/${name} → autopilot-${id}.md (content unchanged)`);
|
|
535
|
+
note(` \`mandate-accept\` looks for a mandate's ledger at this exact path`);
|
|
536
|
+
}
|
|
537
|
+
}
|
|
538
|
+
|
|
539
|
+
// A mandate opened before 0.6.2 recorded `parked: []` under the OLD default — full authority, AD-N
|
|
540
|
+
// contradictions included. 0.6.2 changed the DEFAULT for a NEW mandate to park `ad-n`, because
|
|
541
|
+
// decision-guide.md says narrowing an invariant MUST NOT be softened further. A default only applies
|
|
542
|
+
// at the moment a mandate is written, so an EXISTING accepted mandate keeps whatever it already says —
|
|
543
|
+
// silently adding `ad-n` to it would be overwriting a value the owner already chose, which `update`
|
|
544
|
+
// MUST NOT do to anything in the product's own registry. So this only ever WARNS, naming the mandate
|
|
545
|
+
// and the one line that would close the gap, and leaves the decision to whoever reads the summary.
|
|
546
|
+
function warnStaleMandates(target) {
|
|
547
|
+
const file = path.join(target, ".control", "registry", "decisions.yaml");
|
|
548
|
+
if (!fs.existsSync(file)) return;
|
|
549
|
+
const text = fs.readFileSync(file, "utf8");
|
|
550
|
+
const blocks = text.split(/\n(?=\s*-\s*id:\s*DEC-)/);
|
|
551
|
+
for (const block of blocks) {
|
|
552
|
+
if (!/type:\s*mandate/.test(block)) continue;
|
|
553
|
+
if (!/status:\s*accepted/.test(block)) continue;
|
|
554
|
+
const id = /id:\s*(DEC-\d+)/.exec(block)?.[1];
|
|
555
|
+
const parkedLine = /parked:\s*(\[[^\]]*\]|.*)$/m.exec(block)?.[0] || "";
|
|
556
|
+
const parkedBlockList = /parked:\s*\n((?:\s+-\s*\S.*\n?)*)/.exec(block)?.[1] || "";
|
|
557
|
+
if (/ad-n/.test(parkedLine) || /ad-n/.test(parkedBlockList)) continue;
|
|
558
|
+
note(`${id || "a mandate"} predates the \`ad-n\`-parked-by-default protection (0.6.2) — its \`parked\``);
|
|
559
|
+
note(` list does not name it, so it still decides an AD-N contradiction on its own`);
|
|
560
|
+
note(` add \`ad-n\` to its \`parked\` list in decisions.yaml yourself if you want the new default`);
|
|
561
|
+
}
|
|
562
|
+
}
|
|
563
|
+
|
|
564
|
+
function migrateRegistryNames(target) {
|
|
565
|
+
const reg = path.join(target, ".control", "registry");
|
|
566
|
+
const from = path.join(reg, "waves.yaml");
|
|
567
|
+
const to = path.join(reg, "specs.yaml");
|
|
568
|
+
if (!fs.existsSync(from)) return false;
|
|
569
|
+
if (fs.existsSync(to)) {
|
|
570
|
+
note("BOTH .control/registry/waves.yaml and specs.yaml exist — neither was touched");
|
|
571
|
+
note(" the plan is in one of them and I cannot tell which. Merge them yourself, then delete waves.yaml");
|
|
572
|
+
return false;
|
|
573
|
+
}
|
|
574
|
+
mv(from, to);
|
|
575
|
+
note("renamed .control/registry/waves.yaml → specs.yaml (content unchanged)");
|
|
576
|
+
note(" the rows still say `W<N>` and `epics`/`stories`. Re-cut them through the wdi-build skill");
|
|
577
|
+
return true;
|
|
578
|
+
}
|
|
579
|
+
|
|
580
|
+
// The requirement registry split into `goals.yaml` (the product's `BG`, written by `wdi-problem` at
|
|
581
|
+
// G1) plus one `requirements-<slug>.yaml` per PRD (`CAP`, `FR`, `NFR`, `UJ`, written by
|
|
582
|
+
// `wdi-product` at G2). One file, one writer, one gate. What a tool can do here is SEED `goals.yaml`;
|
|
583
|
+
// what it MUST NOT do is move the rows.
|
|
584
|
+
//
|
|
585
|
+
// Splitting the rows needs one fact the registry has never recorded: which PRD an `FR` belongs to.
|
|
586
|
+
// Before the split nothing wrote it down, and deriving it — FR → UC → ticket → spec → `prd:` — only
|
|
587
|
+
// works for FRs that already have tickets. A guess would file a promise under the wrong initiative,
|
|
588
|
+
// which is worse than leaving it where it is. So `requirements.yaml` is left ALONE and still read:
|
|
589
|
+
// `validate.py` unions every requirement file it finds, so a half-split corpus stays green while its
|
|
590
|
+
// owner cuts the rows through the skill that owns each one.
|
|
591
|
+
function seedRequirementSplit(target) {
|
|
592
|
+
const reg = path.join(target, ".control", "registry");
|
|
593
|
+
if (!fs.existsSync(reg)) return false;
|
|
594
|
+
const product = path.join(reg, "goals.yaml");
|
|
595
|
+
if (fs.existsSync(product)) return false;
|
|
596
|
+
const seed = path.join(SCAFFOLD, "registry", "goals.yaml");
|
|
597
|
+
if (!fs.existsSync(seed)) return false;
|
|
598
|
+
copyFile(seed, product);
|
|
599
|
+
note("seeded .control/registry/goals.yaml");
|
|
600
|
+
if (fs.existsSync(path.join(reg, "requirements.yaml"))) {
|
|
601
|
+
note(" requirements.yaml was left exactly as it is, and is still read — nothing broke");
|
|
602
|
+
note(" the wdi-upgrade skill moves `goals:` into goals.yaml and cuts `capabilities:`,");
|
|
603
|
+
note(" `functional:`, `nonfunctional:`, and `journeys:` into requirements-<slug>.yaml per PRD.");
|
|
604
|
+
note(" <slug> is the PRD's folder name under .what/_prd/");
|
|
605
|
+
}
|
|
606
|
+
return true;
|
|
607
|
+
}
|
|
608
|
+
|
|
609
|
+
function migrateToTwoFolders(target) {
|
|
610
|
+
const c = path.join(target, ".constitution");
|
|
611
|
+
if (!fs.existsSync(c)) return false; // a first install has nothing to migrate
|
|
612
|
+
const at = (...p) => path.join(c, ...p);
|
|
613
|
+
// The old layout is identified by `document/` at the ROOT — in the new layout that folder only ever
|
|
614
|
+
// exists under `method/`. Checking a loose guide instead would misfire on a repo that added one.
|
|
615
|
+
if (!fs.existsSync(at("document")) && !fs.existsSync(at("codebase"))
|
|
616
|
+
&& !fs.existsSync(at("constitution.md")) && !fs.existsSync(at("scripts"))) {
|
|
617
|
+
return false;
|
|
618
|
+
}
|
|
619
|
+
note("pre-0.5.0 .constitution/ found — migrating to method/ + project/");
|
|
620
|
+
|
|
621
|
+
// 1. The four Reference files go one level deeper. This MUST run before the kit is written, or the
|
|
622
|
+
// kit's own why/ files land while the old copies still sit at method/ root.
|
|
623
|
+
for (const name of OLD_WHY) {
|
|
624
|
+
const from = at("method", `${name}.md`);
|
|
625
|
+
if (fs.existsSync(from)) {
|
|
626
|
+
mv(from, at("method", "why", `${name}.md`));
|
|
627
|
+
note(` moved method/${name}.md → method/why/${name}.md`);
|
|
628
|
+
}
|
|
629
|
+
}
|
|
630
|
+
// 2. and 3. whole folders
|
|
631
|
+
for (const dir of ["document", "scripts"]) {
|
|
632
|
+
if (fs.existsSync(at(dir)) && !fs.existsSync(at("method", dir))) {
|
|
633
|
+
mv(at(dir), at("method", dir));
|
|
634
|
+
note(` moved ${dir}/ → method/${dir}/`);
|
|
635
|
+
}
|
|
636
|
+
}
|
|
637
|
+
// 4. the loose generic guides
|
|
638
|
+
for (const name of OLD_ROOT_GUIDES) {
|
|
639
|
+
const from = at(`${name}.md`);
|
|
640
|
+
if (fs.existsSync(from)) {
|
|
641
|
+
mv(from, at("method", `${name}.md`));
|
|
642
|
+
note(` moved ${name}.md → method/${name}.md`);
|
|
643
|
+
}
|
|
644
|
+
}
|
|
645
|
+
// 5. codebase/ was a product-owned room all along — it becomes flat files in the room that says so
|
|
646
|
+
for (const name of OLD_CODEBASE) {
|
|
647
|
+
const from = at("codebase", `${name}-guide.md`);
|
|
648
|
+
if (fs.existsSync(from)) {
|
|
649
|
+
mv(from, at("project", `codebase-${name}-guide.md`));
|
|
650
|
+
note(` moved codebase/${name}-guide.md → project/codebase-${name}-guide.md`);
|
|
651
|
+
}
|
|
652
|
+
}
|
|
653
|
+
if (fs.existsSync(at("codebase"))) {
|
|
654
|
+
const left = fs.readdirSync(at("codebase"));
|
|
655
|
+
if (!left.length) fs.rmdirSync(at("codebase"));
|
|
656
|
+
else note(` codebase/ still holds ${left.join(", ")} — left in place, move them yourself`);
|
|
657
|
+
}
|
|
658
|
+
// 6. The product's constitution.md moves WHOLE into the room, so its Articles 1, 2, and 5 survive
|
|
659
|
+
// exactly as written. The generic half then arrives fresh at method/constitution.md.
|
|
660
|
+
let split = null;
|
|
661
|
+
if (fs.existsSync(at("constitution.md")) && !fs.existsSync(at("project", "constitution.md"))) {
|
|
662
|
+
mv(at("constitution.md"), at("project", "constitution.md"));
|
|
663
|
+
note(" moved constitution.md → project/constitution.md");
|
|
664
|
+
split = splitProductConstitution(at("project", "constitution.md"));
|
|
665
|
+
if (split && split.cut.length) {
|
|
666
|
+
note(` kept Articles ${split.kept.join(", ")}, removed ${split.cut.join(", ")} `
|
|
667
|
+
+ "(the method's — they arrive in method/constitution.md)");
|
|
668
|
+
if (split.relinked) note(` repointed ${split.relinked} relative links one level up`);
|
|
669
|
+
} else if (split === null) {
|
|
670
|
+
note(" it does not carry `## Article N` headings, so it was moved but NOT split — yours to check");
|
|
671
|
+
}
|
|
672
|
+
}
|
|
673
|
+
// Anything else loose at the root is a file this product ADDED. It is NOT moved: it may be routed
|
|
674
|
+
// from AGENTS.md by its current path, and guessing a destination would break that silently.
|
|
675
|
+
const stray = fs.existsSync(c)
|
|
676
|
+
? fs.readdirSync(c, { withFileTypes: true })
|
|
677
|
+
.filter((e) => e.isFile() && e.name.endsWith(".md"))
|
|
678
|
+
.map((e) => e.name)
|
|
679
|
+
: [];
|
|
680
|
+
if (stray.length) {
|
|
681
|
+
note(` left at .constitution/ root, yours to place: ${stray.join(", ")}`);
|
|
682
|
+
note(" a file you added belongs in project/ — but moving it would break any pointer that");
|
|
683
|
+
note(" names its current path, so the choice is yours. repo-guide.md states the rule.");
|
|
684
|
+
}
|
|
685
|
+
return split;
|
|
686
|
+
}
|
|
687
|
+
|
|
688
|
+
function syncConstitution(target) {
|
|
689
|
+
const kitConst = path.join(KIT, ".constitution");
|
|
690
|
+
const destConst = path.join(target, ".constitution");
|
|
691
|
+
fs.mkdirSync(destConst, { recursive: true });
|
|
692
|
+
let written = 0;
|
|
693
|
+
let skipped = 0;
|
|
694
|
+
for (const file of walkFiles(kitConst)) {
|
|
695
|
+
const rel = posixRel(kitConst, file);
|
|
696
|
+
const dest = path.join(destConst, rel);
|
|
697
|
+
// ONE rule for everything the product owns, because 0.5.0 put all of it in one folder. Before
|
|
698
|
+
// that this loop had three branches — the mixed constitution.md kept whole, `codebase/` gated on
|
|
699
|
+
// `status: Accepted` (which is what silently destroyed a half-written guide), and the room — and
|
|
700
|
+
// the three disagreed about when a file was the product's. Seeded when absent, never written
|
|
701
|
+
// again: the same rule as the language policy.
|
|
702
|
+
// ONE file in the room is the package's and is refreshed like any method file: the room's own
|
|
703
|
+
// README. It explains what the room is FOR and carries no product decision, so a stale copy does
|
|
704
|
+
// not preserve anybody's work — it just misinforms. worship-presenter-web proved that: its copy
|
|
705
|
+
// still pointed at `.constitution/codebase/*-guide.md`, a folder 0.5.0 deleted, and no update
|
|
706
|
+
// would ever have corrected it while the file claimed in its own text to be "authored in the
|
|
707
|
+
// package". Either the package writes it or it stops claiming authorship; this is the first.
|
|
708
|
+
if (rel === `${PROJECT_ROOM}README.md`) {
|
|
709
|
+
copyFile(file, dest);
|
|
710
|
+
written += 1;
|
|
711
|
+
continue;
|
|
712
|
+
}
|
|
713
|
+
if (rel.startsWith(PROJECT_ROOM) && fs.existsSync(dest)) {
|
|
714
|
+
skipped += 1;
|
|
715
|
+
note(`keep ${rel} (yours — the project room)`);
|
|
716
|
+
continue;
|
|
717
|
+
}
|
|
718
|
+
copyFile(file, dest);
|
|
719
|
+
written += 1;
|
|
720
|
+
}
|
|
721
|
+
return { written, skipped };
|
|
722
|
+
}
|
|
723
|
+
|
|
724
|
+
function syncSkills(target, agents) {
|
|
725
|
+
let n = 0;
|
|
726
|
+
const dests = skillDestinations(target, agents);
|
|
727
|
+
if (dests.length === 0) {
|
|
728
|
+
note("no skill destinations for selected platforms — AGENTS.md still applies");
|
|
729
|
+
return { files: 0, removed: 0 };
|
|
730
|
+
}
|
|
731
|
+
for (const name of WDI_SKILLS) {
|
|
732
|
+
const src = path.join(KIT, "skills", name);
|
|
733
|
+
if (!fs.existsSync(src)) die(`kit missing skill ${name}`);
|
|
734
|
+
for (const root of dests) {
|
|
735
|
+
const dest = path.join(root, name);
|
|
736
|
+
fs.rmSync(dest, { recursive: true, force: true });
|
|
737
|
+
n += copyTree(src, dest);
|
|
738
|
+
}
|
|
739
|
+
}
|
|
740
|
+
const removed = pruneRetiredSkills(dests);
|
|
741
|
+
return { files: n, removed };
|
|
742
|
+
}
|
|
743
|
+
|
|
744
|
+
// A wrapper the method RETIRED is worse than a wrapper missing: the folder is still there, its
|
|
745
|
+
// SKILL.md still reads like an instruction, and an agent will invoke it — while the guide it points
|
|
746
|
+
// at is gone. Renaming five wrappers (wdi-apply, wdi-analysis, wdi-structure, …) left exactly that
|
|
747
|
+
// in every repo installed before the rename, because update only ever touched the names it knows.
|
|
748
|
+
//
|
|
749
|
+
// `wdi-` is the method's namespace, so a `wdi-*` folder carrying a SKILL.md and not in WDI_SKILLS is
|
|
750
|
+
// ours and retired. Each removal is PRINTED: silent deletion in someone else's repo is not a fix.
|
|
751
|
+
function pruneRetiredSkills(dests) {
|
|
752
|
+
let removed = 0;
|
|
753
|
+
const keep = new Set(WDI_SKILLS);
|
|
754
|
+
for (const root of dests) {
|
|
755
|
+
if (!fs.existsSync(root)) continue;
|
|
756
|
+
for (const entry of fs.readdirSync(root, { withFileTypes: true })) {
|
|
757
|
+
if (!entry.isDirectory() || !entry.name.startsWith("wdi-") || keep.has(entry.name)) continue;
|
|
758
|
+
const dir = path.join(root, entry.name);
|
|
759
|
+
if (!fs.existsSync(path.join(dir, "SKILL.md"))) {
|
|
760
|
+
note(`kept ${entry.name} (no SKILL.md — not one of ours)`);
|
|
761
|
+
continue;
|
|
762
|
+
}
|
|
763
|
+
fs.rmSync(dir, { recursive: true, force: true });
|
|
764
|
+
note(`removed retired skill ${entry.name}`);
|
|
765
|
+
removed += 1;
|
|
766
|
+
}
|
|
767
|
+
}
|
|
768
|
+
return removed;
|
|
769
|
+
}
|
|
770
|
+
|
|
771
|
+
// `promote` scrubs a product's initiative slug out of bmad-prd.toml before publishing, which is right.
|
|
772
|
+
// Writing the scrubbed PLACEHOLDER back into a product repo is not: the first real install replaced a
|
|
773
|
+
// live `run_folder_pattern = "some-real-slug"` with `FILL-initiative-slug`, and nothing said so. A value
|
|
774
|
+
// the product already chose is not the installer's to overwrite — same rule as the custom room and the
|
|
775
|
+
// language policy.
|
|
776
|
+
const PLACEHOLDER_SLUG = "FILL-initiative-slug";
|
|
777
|
+
const RUN_FOLDER_LINE = /^(\s*run_folder_pattern\s*=\s*)(".*?"|'.*?')/m;
|
|
778
|
+
|
|
779
|
+
// The slug appears MORE THAN ONCE — bmad-prd.toml carries it in `run_folder_pattern` and again inside a
|
|
780
|
+
// memlog path, and the file itself says the two lines MUST change together. The first version of this
|
|
781
|
+
// function restored only the first line and so produced exactly the inconsistency that file forbids.
|
|
782
|
+
// So: read the product's slug once, then put it back everywhere the placeholder appears.
|
|
783
|
+
function keepProductSlug(incoming, existing) {
|
|
784
|
+
const mineNow = existing.match(RUN_FOLDER_LINE);
|
|
785
|
+
if (!mineNow) return null;
|
|
786
|
+
const slug = mineNow[2].slice(1, -1);
|
|
787
|
+
if (!slug || slug === PLACEHOLDER_SLUG) return null;
|
|
788
|
+
if (!incoming.includes(PLACEHOLDER_SLUG)) return null;
|
|
789
|
+
// Only where the slug is a VALUE: the quoted setting, and the memlog path built from it. A bare
|
|
790
|
+
// mention inside a comment stays the placeholder — that sentence explains the pattern, and rewriting
|
|
791
|
+
// it would turn a generic explanation into a statement about one initiative.
|
|
792
|
+
return incoming
|
|
793
|
+
.replaceAll(`"${PLACEHOLDER_SLUG}"`, `"${slug}"`)
|
|
794
|
+
.replaceAll(`prd-${PLACEHOLDER_SLUG}`, `prd-${slug}`);
|
|
795
|
+
}
|
|
796
|
+
|
|
797
|
+
function syncTomls(target) {
|
|
798
|
+
const src = path.join(KIT, "assets", "bmad-custom");
|
|
799
|
+
const dest = path.join(target, "_bmad", "custom");
|
|
800
|
+
fs.mkdirSync(dest, { recursive: true });
|
|
801
|
+
let n = 0;
|
|
802
|
+
let slugsKept = 0;
|
|
803
|
+
for (const file of walkFiles(src)) {
|
|
804
|
+
if (!file.endsWith(".toml") || file.endsWith(".user.toml")) continue;
|
|
805
|
+
const to = path.join(dest, path.basename(file));
|
|
806
|
+
if (fs.existsSync(to)) {
|
|
807
|
+
const merged = keepProductSlug(fs.readFileSync(file, "utf8"), fs.readFileSync(to, "utf8"));
|
|
808
|
+
if (merged !== null) {
|
|
809
|
+
fs.writeFileSync(to, merged);
|
|
810
|
+
note(`kept run_folder_pattern in ${path.basename(file)}`);
|
|
811
|
+
slugsKept += 1;
|
|
812
|
+
n += 1;
|
|
813
|
+
continue;
|
|
814
|
+
}
|
|
815
|
+
}
|
|
816
|
+
copyFile(file, to);
|
|
817
|
+
n += 1;
|
|
818
|
+
}
|
|
819
|
+
return { files: n, slugsKept };
|
|
820
|
+
}
|
|
821
|
+
|
|
822
|
+
// The same argument pruneRetiredSkills makes, one folder over — with one difference that changes
|
|
823
|
+
// the rule. `wdi-` is this method's namespace, so "a wdi-* folder not in WDI_SKILLS" is safely ours.
|
|
824
|
+
// `_bmad/custom/` is NOT: a product may put its own override there, and `.user.toml` is the
|
|
825
|
+
// product's half of every override by convention. So removal here is by an EXPLICIT list of files
|
|
826
|
+
// this package once shipped and has now withdrawn — never by "absent from the kit".
|
|
827
|
+
//
|
|
828
|
+
// Why remove them at all: an override for a retired engine is worse than no override. It is still
|
|
829
|
+
// installed and still read, and bmad-retrospective.toml instructs an agent to archive an `RTR-`
|
|
830
|
+
// against a validator, V19, that no longer exists.
|
|
831
|
+
const RETIRED_TOMLS = [
|
|
832
|
+
"bmad-spec.toml", "bmad-build.toml", "bmad-build-auto.toml",
|
|
833
|
+
"bmad-code-review.toml", "bmad-retrospective.toml",
|
|
834
|
+
];
|
|
835
|
+
|
|
836
|
+
function pruneRetiredTomls(target) {
|
|
837
|
+
const dir = path.join(target, "_bmad", "custom");
|
|
838
|
+
if (!fs.existsSync(dir)) return 0;
|
|
839
|
+
let removed = 0;
|
|
840
|
+
for (const name of RETIRED_TOMLS) {
|
|
841
|
+
const file = path.join(dir, name);
|
|
842
|
+
if (!fs.existsSync(file)) continue;
|
|
843
|
+
fs.rmSync(file);
|
|
844
|
+
note(`removed retired override ${name}`);
|
|
845
|
+
removed += 1;
|
|
846
|
+
}
|
|
847
|
+
return removed;
|
|
848
|
+
}
|
|
849
|
+
|
|
850
|
+
function seedControlIfMissing(target) {
|
|
851
|
+
const control = path.join(target, ".control");
|
|
852
|
+
if (fs.existsSync(control)) {
|
|
853
|
+
note(".control/ already present — left untouched");
|
|
854
|
+
return;
|
|
855
|
+
}
|
|
856
|
+
if (!fs.existsSync(SCAFFOLD)) die(`scaffold missing: ${SCAFFOLD}`);
|
|
857
|
+
const n = copyTree(SCAFFOLD, control);
|
|
858
|
+
ok(`seeded empty .control/ (${n} files)`);
|
|
859
|
+
}
|
|
860
|
+
|
|
861
|
+
// On a FIRST install these folders are the corpus taking shape. On an UPDATE their absence means
|
|
862
|
+
// somebody removed them on purpose — `.work/` and `_bmad-output/prior-knowledge/` are exactly the two a
|
|
863
|
+
// product retires once its migration is done, and one repo retired them through an applied decision.
|
|
864
|
+
// Recreating them then is an installer overruling a decision it cannot read. Seed once, never resurrect.
|
|
865
|
+
function seedEmptyLayers(target, { first }) {
|
|
866
|
+
const always = [".what", path.join(".how", "_platform")];
|
|
867
|
+
const firstOnly = [".work", path.join("_bmad-output", "prior-knowledge")];
|
|
868
|
+
for (const rel of first ? [...always, ...firstOnly] : always) {
|
|
869
|
+
const dest = path.join(target, rel);
|
|
870
|
+
if (!fs.existsSync(dest)) {
|
|
871
|
+
fs.mkdirSync(dest, { recursive: true });
|
|
872
|
+
// Git tracks files, not directories: an empty folder does not reach the next clone. The
|
|
873
|
+
// scaffold already puts a `.gitkeep` in each of its empty rooms, and these four were the
|
|
874
|
+
// exception — `.work/` invisible from birth is half the reason a bootstrap read it as
|
|
875
|
+
// ignorable and wrote it into `.gitignore`, which corpus-in-git now reports.
|
|
876
|
+
fs.writeFileSync(path.join(dest, ".gitkeep"), "");
|
|
877
|
+
note(`created ${rel.replaceAll(path.sep, "/")}/`);
|
|
878
|
+
}
|
|
879
|
+
}
|
|
880
|
+
if (!first) {
|
|
881
|
+
for (const rel of firstOnly) {
|
|
882
|
+
if (!fs.existsSync(path.join(target, rel))) {
|
|
883
|
+
note(`left ${rel.replaceAll(path.sep, "/")}/ absent — a product retires it, not the installer`);
|
|
884
|
+
}
|
|
885
|
+
}
|
|
886
|
+
}
|
|
887
|
+
}
|
|
888
|
+
|
|
889
|
+
function writeStamp(target) {
|
|
890
|
+
const control = path.join(target, ".control");
|
|
891
|
+
if (!fs.existsSync(control)) return;
|
|
892
|
+
const stamp = [
|
|
893
|
+
"# Written by wdi-method install/update. A trace, not a lockfile.",
|
|
894
|
+
`wdi_method: ${PKG.version}`,
|
|
895
|
+
`bmad_method: ${readBmadVersion(target) || '""'}`,
|
|
896
|
+
`installed_at: ${today()}`,
|
|
897
|
+
"",
|
|
898
|
+
].join("\n");
|
|
899
|
+
fs.writeFileSync(path.join(control, "wdi-method.yaml"), stamp, "utf8");
|
|
900
|
+
note("stamped .control/wdi-method.yaml");
|
|
901
|
+
}
|
|
902
|
+
|
|
903
|
+
function setProductIdentity(target, { name, client }) {
|
|
904
|
+
if (!name || identityIsPlaceholder(name)) return;
|
|
905
|
+
const file = path.join(target, ".control", "registry", "index.yaml");
|
|
906
|
+
if (!fs.existsSync(file)) return;
|
|
907
|
+
const next = writeProductIdentity(fs.readFileSync(file, "utf8"), {
|
|
908
|
+
name,
|
|
909
|
+
client: client ?? "",
|
|
910
|
+
});
|
|
911
|
+
fs.writeFileSync(file, next.endsWith("\n") ? next : `${next}\n`);
|
|
912
|
+
note(`product.name = ${name}`);
|
|
913
|
+
}
|
|
914
|
+
|
|
915
|
+
// The document language belongs to the PRODUCT, so update MUST NOT overwrite it. It is written only
|
|
916
|
+
// when absent — same as the custom room, and for the same reason: a setting somebody already chose
|
|
917
|
+
// is not the installer's to change behind their back.
|
|
918
|
+
function setLanguagePolicy(target, { docLanguage, docFilenameLanguage, chosen }) {
|
|
919
|
+
const file = path.join(target, ".control", "registry", "index.yaml");
|
|
920
|
+
if (!fs.existsSync(file)) return;
|
|
921
|
+
const text = fs.readFileSync(file, "utf8");
|
|
922
|
+
const existing = readLanguagePolicy(text);
|
|
923
|
+
// `chosen` means somebody actually answered — in the TUI, or through an explicit flag. Only then
|
|
924
|
+
// does the answer take effect. Without it the incoming value is just a default, and a default
|
|
925
|
+
// MUST NOT overwrite a choice somebody already made.
|
|
926
|
+
if (!chosen && existing.docLanguage && existing.docFilenameLanguage) {
|
|
927
|
+
note(`kept policy.doc_language = ${existing.docLanguage}, ` +
|
|
928
|
+
`doc_filename_language = ${existing.docFilenameLanguage}`);
|
|
929
|
+
return;
|
|
930
|
+
}
|
|
931
|
+
const next = writeLanguagePolicy(text, {
|
|
932
|
+
docLanguage: docLanguage || existing.docLanguage || DEFAULT_DOC_LANGUAGE,
|
|
933
|
+
docFilenameLanguage:
|
|
934
|
+
docFilenameLanguage || existing.docFilenameLanguage || DEFAULT_DOC_LANGUAGE,
|
|
935
|
+
});
|
|
936
|
+
fs.writeFileSync(file, next.endsWith("\n") ? next : `${next}\n`);
|
|
937
|
+
const after = readLanguagePolicy(next);
|
|
938
|
+
note(`policy.doc_language = ${after.docLanguage}, ` +
|
|
939
|
+
`doc_filename_language = ${after.docFilenameLanguage}`);
|
|
940
|
+
}
|
|
941
|
+
|
|
942
|
+
// After `update`, some of the corpus can still be in the OLD shape — content the installer MUST NOT
|
|
943
|
+
// move, because moving it takes a decision about meaning: which PRD an `FR` belongs to, whether a
|
|
944
|
+
// sentence was an assumption or a constraint. The `wdi-upgrade` skill does that half. This only
|
|
945
|
+
// DETECTS it, cheaply, so the summary can say how much is waiting and where.
|
|
946
|
+
function pendingUpgrades(target) {
|
|
947
|
+
const has = (...p) => fs.existsSync(path.join(target, ...p));
|
|
948
|
+
const read = (...p) => (has(...p) ? fs.readFileSync(path.join(target, ...p), "utf8") : "");
|
|
949
|
+
const anyIn = (dir, glob, re) => {
|
|
950
|
+
const d = path.join(target, dir);
|
|
951
|
+
if (!fs.existsSync(d)) return false;
|
|
952
|
+
return fs.readdirSync(d).some((n) => {
|
|
953
|
+
const f = path.join(d, n, glob);
|
|
954
|
+
return fs.existsSync(f) && re.test(fs.readFileSync(f, "utf8"));
|
|
955
|
+
});
|
|
956
|
+
};
|
|
957
|
+
const items = [];
|
|
958
|
+
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)");
|
|
960
|
+
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
|
+
// Sections by NAME: the numbers moved between kits (Non-Goals was §7 in one, §5 in the next).
|
|
962
|
+
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");
|
|
963
|
+
const whatDir = path.join(target, ".what");
|
|
964
|
+
if (fs.existsSync(whatDir)) {
|
|
965
|
+
for (const pc of fs.readdirSync(whatDir)) {
|
|
966
|
+
if (pc.startsWith("_")) continue;
|
|
967
|
+
const srs = read(".what", pc, `SRS-${pc}.md`);
|
|
968
|
+
if (/^\|\s*UC-\d+\s*\|/m.test(srs)) { items.push("an SRS with a UC Catalogue table (now a pointer)"); break; }
|
|
969
|
+
}
|
|
970
|
+
}
|
|
971
|
+
const howDir = path.join(target, ".how");
|
|
972
|
+
if (fs.existsSync(howDir)) {
|
|
973
|
+
for (const pc of fs.readdirSync(howDir)) {
|
|
974
|
+
if (pc.startsWith("_")) continue;
|
|
975
|
+
if (/\|\s*Quoted rule\s*\||Quoted verbatim from/.test(read(".how", pc, `SDD-${pc}.md`))) { items.push("an SDD quoting AD-N text (now ids only)"); break; }
|
|
976
|
+
}
|
|
977
|
+
}
|
|
978
|
+
if (/\|\s*Container\s*\|\s*Product Components living in it\s*\|/.test(read(".how", "_platform", "c4-l2-containers.md"))) items.push("c4-l2 with a PC x container table (now a pointer)");
|
|
979
|
+
if (has(".control", "generated", "brief.md") || has(".control", "generated", "blueprint.md")) items.push("human pages still in .control/generated/ (render clears them)");
|
|
980
|
+
if (has(".what", "_product-brief", "brief.md") && !has(".what-rendered")) items.push("no .what-rendered/ yet (render creates it)");
|
|
981
|
+
// Skipped: what the validator never reads (kit copies, rendered output, dependencies) and what it
|
|
982
|
+
// treats as a record of the PAST — memlog, decisions, reports, _bmad-output. A stale path in a log
|
|
983
|
+
// is history, not a finding, and repointing it would falsify the record.
|
|
984
|
+
const SKIP = new Set([".git", "node_modules", "target", ".constitution", ".claude", ".agents", ".agent",
|
|
985
|
+
".what-rendered", ".how-rendered", "dist", "build", "memlog", "decisions", "reports", "meetings", "_bmad-output", ".work"]);
|
|
986
|
+
const OLD_PAGE = /\.control\/generated\/(brief|blueprint|prd-[a-z0-9-]+)\.md/;
|
|
987
|
+
const citesOldPage = (dir, depth) => {
|
|
988
|
+
if (depth > 8) return false;
|
|
989
|
+
for (const e of fs.readdirSync(dir, { withFileTypes: true })) {
|
|
990
|
+
if (e.isDirectory()) { if (!SKIP.has(e.name) && citesOldPage(path.join(dir, e.name), depth + 1)) return true; continue; }
|
|
991
|
+
if (e.name === "answered.md") continue;
|
|
992
|
+
if (e.name.endsWith(".md") && OLD_PAGE.test(fs.readFileSync(path.join(dir, e.name), "utf8"))) return true;
|
|
993
|
+
}
|
|
994
|
+
return false;
|
|
995
|
+
};
|
|
996
|
+
if (citesOldPage(target, 0)) items.push("a document cites .control/generated/brief|blueprint|prd-*.md (pages moved to the rendered trees)");
|
|
997
|
+
return items;
|
|
998
|
+
}
|
|
999
|
+
|
|
1000
|
+
// Read BEFORE writeStamp overwrites it. Without this there is no version transition to print, and
|
|
1001
|
+
// an "updated" with no from-to tells the reader nothing they can use.
|
|
1002
|
+
function readStampVersion(target) {
|
|
1003
|
+
const file = path.join(target, ".control", "wdi-method.yaml");
|
|
1004
|
+
if (!fs.existsSync(file)) return "";
|
|
1005
|
+
const m = fs.readFileSync(file, "utf8").match(/^wdi_method:\s*"?([^"\s]+)"?/m);
|
|
1006
|
+
return m ? m[1] : "";
|
|
1007
|
+
}
|
|
1008
|
+
|
|
1009
|
+
function readIndexPolicy(target) {
|
|
1010
|
+
const file = path.join(target, ".control", "registry", "index.yaml");
|
|
1011
|
+
if (!fs.existsSync(file)) return { docLanguage: "", docFilenameLanguage: "" };
|
|
1012
|
+
return readLanguagePolicy(fs.readFileSync(file, "utf8"));
|
|
1013
|
+
}
|
|
1014
|
+
|
|
1015
|
+
function readIndexIdentity(target) {
|
|
1016
|
+
const file = path.join(target, ".control", "registry", "index.yaml");
|
|
1017
|
+
if (!fs.existsSync(file)) return { name: "", client: "" };
|
|
1018
|
+
return readProductIdentity(fs.readFileSync(file, "utf8"));
|
|
1019
|
+
}
|
|
1020
|
+
|
|
1021
|
+
function upsertAgentFiles(target, platforms, productName) {
|
|
1022
|
+
const template = fs.readFileSync(path.join(OVERLAY, "AGENTS.md"), "utf8");
|
|
1023
|
+
const agentsFile = path.join(target, "AGENTS.md");
|
|
1024
|
+
let next;
|
|
1025
|
+
if (!fs.existsSync(agentsFile)) {
|
|
1026
|
+
next = fillProductTitle(template, productName || "{product}");
|
|
1027
|
+
ok("AGENTS.md created — rewrite ## Code for this product");
|
|
1028
|
+
} else {
|
|
1029
|
+
next = upsertMethodBlock(fs.readFileSync(agentsFile, "utf8"), template);
|
|
1030
|
+
note("AGENTS.md method block refreshed; product sections kept");
|
|
1031
|
+
}
|
|
1032
|
+
if (!next.endsWith("\n")) next += "\n";
|
|
1033
|
+
fs.writeFileSync(agentsFile, next);
|
|
1034
|
+
|
|
1035
|
+
const mirrors = [];
|
|
1036
|
+
if (platformUsesHook(platforms, "cursorrules")) {
|
|
1037
|
+
mirrors.push(path.join(target, ".cursorrules"));
|
|
1038
|
+
}
|
|
1039
|
+
if (platformUsesHook(platforms, "agents-mirror")) {
|
|
1040
|
+
mirrors.push(path.join(target, ".agents", "AGENTS.md"));
|
|
1041
|
+
}
|
|
1042
|
+
for (const mirror of mirrors) {
|
|
1043
|
+
fs.mkdirSync(path.dirname(mirror), { recursive: true });
|
|
1044
|
+
if (fs.existsSync(mirror)) {
|
|
1045
|
+
const patched = upsertMethodBlock(fs.readFileSync(mirror, "utf8"), template);
|
|
1046
|
+
fs.writeFileSync(mirror, patched.endsWith("\n") ? patched : `${patched}\n`);
|
|
1047
|
+
note(`method block refreshed in ${posixRel(target, mirror)}`);
|
|
1048
|
+
} else {
|
|
1049
|
+
fs.writeFileSync(mirror, next);
|
|
1050
|
+
note(`created ${posixRel(target, mirror)}`);
|
|
1051
|
+
}
|
|
1052
|
+
}
|
|
1053
|
+
|
|
1054
|
+
if (platformUsesHook(platforms, "claude-md")) {
|
|
1055
|
+
const claude = path.join(target, "CLAUDE.md");
|
|
1056
|
+
if (!fs.existsSync(claude)) {
|
|
1057
|
+
fs.writeFileSync(claude, "@AGENTS.md\n");
|
|
1058
|
+
note("CLAUDE.md created as @AGENTS.md");
|
|
1059
|
+
}
|
|
1060
|
+
}
|
|
1061
|
+
}
|
|
1062
|
+
|
|
1063
|
+
// What a run MUST leave a reader able to answer: which version replaced which, what was written, what
|
|
1064
|
+
// was KEPT, and what to do next. The third is the one usually missing, and it is the one that decides
|
|
1065
|
+
// whether somebody trusts running this over a repo they have already put work into.
|
|
1066
|
+
function summaryLine(label, value) {
|
|
1067
|
+
console.log(` ${DIM}${label.padEnd(11)}${RESET}${value}`);
|
|
1068
|
+
}
|
|
1069
|
+
|
|
1070
|
+
function printSummary(target, agents, { first, was, written, skipped, skills, tomls, opencodeCmds }) {
|
|
1071
|
+
const now = PKG.version;
|
|
1072
|
+
const version = first
|
|
1073
|
+
? `${now} — first install`
|
|
1074
|
+
: was && was !== now
|
|
1075
|
+
? `${was} ${DIM}→${RESET} ${now}`
|
|
1076
|
+
: `${now} ${DIM}(unchanged)${RESET}`;
|
|
1077
|
+
const bmad = readBmadVersion(target);
|
|
1078
|
+
|
|
1079
|
+
const kept = [];
|
|
1080
|
+
if (skipped) kept.push(`${skipped} constitution file${skipped === 1 ? "" : "s"}`);
|
|
1081
|
+
if (tomls.slugsKept) kept.push(`${tomls.slugsKept} initiative slug${tomls.slugsKept === 1 ? "" : "s"}`);
|
|
1082
|
+
// On a first install the language was just CHOSEN, not kept — saying "kept" there reads as if the
|
|
1083
|
+
// installer had found something it decided to leave alone, which is the opposite of what happened.
|
|
1084
|
+
const policy = readIndexPolicy(target);
|
|
1085
|
+
if (policy.docLanguage && !first) kept.push(`language (${policy.docLanguage})`);
|
|
1086
|
+
if (fs.existsSync(path.join(target, ".constitution", "project"))) kept.push(".constitution/project/");
|
|
1087
|
+
|
|
1088
|
+
console.log("");
|
|
1089
|
+
console.log(`${DIM}────${RESET} WDI Method ${DIM}${"─".repeat(46)}${RESET}`);
|
|
1090
|
+
summaryLine("version", version);
|
|
1091
|
+
if (bmad) summaryLine("bmad", bmad);
|
|
1092
|
+
summaryLine("target", target);
|
|
1093
|
+
console.log("");
|
|
1094
|
+
summaryLine("written", `${written} constitution · ${skills.files} skill files · ${tomls.files} bmad overrides`
|
|
1095
|
+
+ (opencodeCmds?.written ? ` · ${opencodeCmds.written} opencode commands` : ""));
|
|
1096
|
+
if (kept.length) summaryLine("kept", kept.join(" · "));
|
|
1097
|
+
const gone = [];
|
|
1098
|
+
if (skills.removed) gone.push(`${skills.removed} retired wrapper${skills.removed === 1 ? "" : "s"}`);
|
|
1099
|
+
if (tomls.removed) gone.push(`${tomls.removed} retired override${tomls.removed === 1 ? "" : "s"}`);
|
|
1100
|
+
if (gone.length) summaryLine("removed", gone.join(" · "));
|
|
1101
|
+
if (first && policy.docLanguage) {
|
|
1102
|
+
summaryLine("language", `${policy.docLanguage} · filenames ${policy.docFilenameLanguage}`);
|
|
1103
|
+
}
|
|
1104
|
+
summaryLine("platforms", agents.join(", ") || "none");
|
|
1105
|
+
console.log("");
|
|
1106
|
+
// The readers are the one seeded file that does nothing until somebody writes it, and its
|
|
1107
|
+
// silence is expensive: inventory.py refuses to run and the reason is a folder deep. One line
|
|
1108
|
+
// here, only while it is still the skeleton, so it stops appearing once it is done.
|
|
1109
|
+
if (readersAreSkeleton(target)) {
|
|
1110
|
+
summaryLine("todo", `${DIM}.constitution/project/inventory-readers.py${RESET} is a skeleton — ` +
|
|
1111
|
+
`run the ${INIT_SKILL} skill, intent ${DIM}readers${RESET}, ` +
|
|
1112
|
+
`to write it for this repo's stack`);
|
|
1113
|
+
}
|
|
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}`);
|
|
1120
|
+
}
|
|
1121
|
+
const pending = first ? [] : pendingUpgrades(target);
|
|
1122
|
+
if (pending.length) {
|
|
1123
|
+
summaryLine("upgrade", `${pending.length} item${pending.length === 1 ? "" : "s"} still in the OLD shape — ` +
|
|
1124
|
+
`run the ${DIM}wdi-upgrade${RESET} skill; it moves content, never invents it`);
|
|
1125
|
+
for (const item of pending) summaryLine("", `${DIM}·${RESET} ${item}`);
|
|
1126
|
+
}
|
|
1127
|
+
summaryLine("next", pending.length
|
|
1128
|
+
? `run the ${DIM}wdi-upgrade${RESET} skill first, then ${HELP_SKILL}`
|
|
1129
|
+
: `invoke the ${HELP_SKILL} skill and ask what to do`);
|
|
1130
|
+
summaryLine("", REPO_URL);
|
|
1131
|
+
console.log(`${DIM}${"─".repeat(62)}${RESET}`);
|
|
1132
|
+
}
|
|
1133
|
+
|
|
1134
|
+
function printNextSteps({ first, productSet, upgradePending }) {
|
|
1135
|
+
console.log("");
|
|
1136
|
+
console.log(first ? "After install:" : "After update:");
|
|
1137
|
+
if (first) {
|
|
1138
|
+
if (!productSet) {
|
|
1139
|
+
console.log(" 1. Fill product.name (and product.client if there is one) in .control/registry/index.yaml.");
|
|
1140
|
+
} else {
|
|
1141
|
+
console.log(" 1. product.name is set. G1 confirms it in the brief.");
|
|
1142
|
+
}
|
|
1143
|
+
console.log(" 2. Rewrite .constitution/constitution.md Articles 2 and 5 for this product.");
|
|
1144
|
+
console.log(" Article 1 cites index.yaml — do not become a second source for the name.");
|
|
1145
|
+
console.log(" 3. Write ## Code in AGENTS.md (where the app lives). Leave the BEGIN:wdi-method block alone.");
|
|
1146
|
+
console.log(" 4. Run the wdi-init skill, intent setup.");
|
|
1147
|
+
console.log(" 5. Sort the documents you already have. Do not move any of them in this step.");
|
|
1148
|
+
console.log("");
|
|
1149
|
+
console.log("Next update:");
|
|
1150
|
+
console.log(" npx wdi-method");
|
|
1151
|
+
console.log(" (the TUI offers the update) or: npx wdi-method update --yes");
|
|
1152
|
+
} else {
|
|
1153
|
+
console.log(" 1. The <!-- BEGIN:wdi-method --> block in AGENTS.md was replaced. Read the diff.");
|
|
1154
|
+
console.log(" 2. constitution.md Articles 1-2-5, ## Code, and *.user.toml were not overwritten.");
|
|
1155
|
+
console.log(" 3. If BMad has new skills, install those first, then run this update again.");
|
|
1156
|
+
if (upgradePending) {
|
|
1157
|
+
console.log(" 4. The summary listed an `upgrade` line: run the wdi-upgrade skill before any other skill.");
|
|
1158
|
+
console.log(" It moves content into the new shape and never invents any; one commit.");
|
|
1159
|
+
}
|
|
1160
|
+
}
|
|
1161
|
+
}
|
|
1162
|
+
|
|
1163
|
+
function apply(target, agents,
|
|
1164
|
+
{ first, product, client, docLanguage, docFilenameLanguage, languageChosen }) {
|
|
1165
|
+
requireKit();
|
|
1166
|
+
const was = readStampVersion(target);
|
|
1167
|
+
// MUST run before the kit is written: it moves the product's files out of the way of paths the kit
|
|
1168
|
+
// is about to occupy. Running it after would leave two copies of most guides.
|
|
1169
|
+
const migrated = migrateToTwoFolders(target);
|
|
1170
|
+
migrateRegistryNames(target);
|
|
1171
|
+
migrateAutopilotLedgers(target);
|
|
1172
|
+
warnStaleMandates(target);
|
|
1173
|
+
seedAgentDocs(target);
|
|
1174
|
+
warnStaleAgentDocs(target);
|
|
1175
|
+
seedRequirementSplit(target);
|
|
1176
|
+
// The split MUST also be reachable without a migration. 0.5.2 only ran it from inside
|
|
1177
|
+
// migrateToTwoFolders, which returns early when the old layout is absent — so a repo that took
|
|
1178
|
+
// 0.5.0 or 0.5.1, whose project/constitution.md was moved WHOLE and never split, could never be
|
|
1179
|
+
// fixed by any later update. That is precisely the repo that needs it. Running it here on every
|
|
1180
|
+
// update closes that, and it is idempotent: after a split there are no method articles left to cut.
|
|
1181
|
+
const lateSplit = splitProductConstitution(path.join(target, ".constitution", "project",
|
|
1182
|
+
"constitution.md"));
|
|
1183
|
+
if (!migrated && lateSplit && lateSplit.cut.length) {
|
|
1184
|
+
note(`project/constitution.md still carried Articles ${lateSplit.cut.join(", ")} — removed`);
|
|
1185
|
+
note(` they are the method's and live in method/constitution.md; kept ${lateSplit.kept.join(", ")}`);
|
|
1186
|
+
if (lateSplit.relinked) note(` repointed ${lateSplit.relinked} relative links`);
|
|
1187
|
+
}
|
|
1188
|
+
const splitConstitution = migrated;
|
|
1189
|
+
const { written, skipped } = syncConstitution(target);
|
|
1190
|
+
note(`constitution wrote ${written}, kept ${skipped}`);
|
|
1191
|
+
// A migrated repo also carries derived output stamped against the OLD layout: .control/generated/*
|
|
1192
|
+
// still names the pre-0.5.0 script path, and the two structure maps still draw the old tree. The
|
|
1193
|
+
// installer MUST NOT write either — one is generated, the other is re-derived by a skill — so it
|
|
1194
|
+
// says so instead of leaving them to be found by whoever trusts them next.
|
|
1195
|
+
if (splitConstitution) {
|
|
1196
|
+
note(" derived output still describes the OLD layout, and neither is mine to write:");
|
|
1197
|
+
note(" uv run .constitution/method/scripts/validate.py --generate → .control/generated/");
|
|
1198
|
+
note(" then the wdi-init skill, intent `structure` → the two structure maps");
|
|
1199
|
+
}
|
|
1200
|
+
const skills = syncSkills(target, agents);
|
|
1201
|
+
note(`skills ${skills.files} files`);
|
|
1202
|
+
let opencodeCmds = { written: 0, removed: 0 };
|
|
1203
|
+
if (platformUsesHook(agents, "opencode-commands")) {
|
|
1204
|
+
opencodeCmds = syncOpencodeCommands(target, WDI_SKILLS, path.join(KIT, "skills"));
|
|
1205
|
+
note(`opencode commands ${opencodeCmds.written} files → ${opencodeCommandsDir()}/`);
|
|
1206
|
+
if (opencodeCmds.removed) {
|
|
1207
|
+
note(`removed ${opencodeCmds.removed} retired opencode command${opencodeCmds.removed === 1 ? "" : "s"}`);
|
|
1208
|
+
}
|
|
1209
|
+
}
|
|
1210
|
+
const tomls = syncTomls(target);
|
|
1211
|
+
tomls.removed = pruneRetiredTomls(target);
|
|
1212
|
+
note(`bmad custom ${tomls.files} toml → _bmad/custom/`);
|
|
1213
|
+
if (first) seedControlIfMissing(target);
|
|
1214
|
+
seedEmptyLayers(target, { first });
|
|
1215
|
+
setProductIdentity(target, { name: product, client });
|
|
1216
|
+
setLanguagePolicy(target, { docLanguage, docFilenameLanguage, chosen: languageChosen });
|
|
1217
|
+
upsertAgentFiles(target, agents, product);
|
|
1218
|
+
writeStamp(target);
|
|
1219
|
+
printSummary(target, agents, { first, was, written, skipped, skills, tomls, opencodeCmds });
|
|
1220
|
+
printNextSteps({
|
|
1221
|
+
first,
|
|
1222
|
+
productSet: Boolean(product) && !identityIsPlaceholder(product),
|
|
1223
|
+
upgradePending: !first && pendingUpgrades(target).length > 0,
|
|
1224
|
+
});
|
|
1225
|
+
}
|
|
1226
|
+
|
|
1227
|
+
function verify(target, agents) {
|
|
1228
|
+
requireKit();
|
|
1229
|
+
const missing = [];
|
|
1230
|
+
const kitConst = path.join(KIT, ".constitution");
|
|
1231
|
+
for (const file of walkFiles(kitConst)) {
|
|
1232
|
+
const rel = posixRel(kitConst, file);
|
|
1233
|
+
const dest = path.join(target, ".constitution", rel);
|
|
1234
|
+
if (!fs.existsSync(dest)) missing.push(`.constitution/${rel}`);
|
|
1235
|
+
}
|
|
1236
|
+
for (const name of WDI_SKILLS) {
|
|
1237
|
+
for (const root of skillDestinations(target, agents)) {
|
|
1238
|
+
const dest = path.join(root, name, "SKILL.md");
|
|
1239
|
+
if (!fs.existsSync(dest)) missing.push(posixRel(target, dest));
|
|
1240
|
+
}
|
|
1241
|
+
}
|
|
1242
|
+
if (platformUsesHook(agents, "opencode-commands")) {
|
|
1243
|
+
for (const name of WDI_SKILLS) {
|
|
1244
|
+
const dest = path.join(target, opencodeCommandsDir(), `${name}.md`);
|
|
1245
|
+
if (!fs.existsSync(dest)) missing.push(posixRel(target, dest));
|
|
1246
|
+
}
|
|
1247
|
+
}
|
|
1248
|
+
const custom = path.join(KIT, "assets", "bmad-custom");
|
|
1249
|
+
for (const file of walkFiles(custom)) {
|
|
1250
|
+
if (!file.endsWith(".toml")) continue;
|
|
1251
|
+
const dest = path.join(target, "_bmad", "custom", path.basename(file));
|
|
1252
|
+
if (!fs.existsSync(dest)) missing.push(`_bmad/custom/${path.basename(file)}`);
|
|
1253
|
+
}
|
|
1254
|
+
if (fs.existsSync(path.join(target, ".control"))) {
|
|
1255
|
+
for (const file of walkFiles(SCAFFOLD)) {
|
|
1256
|
+
const rel = posixRel(SCAFFOLD, file);
|
|
1257
|
+
const dest = path.join(target, ".control", rel);
|
|
1258
|
+
if (!fs.existsSync(dest)) missing.push(`.control/${rel}`);
|
|
1259
|
+
}
|
|
1260
|
+
} else {
|
|
1261
|
+
missing.push(".control/ (folder missing — first install should have seeded it)");
|
|
1262
|
+
}
|
|
1263
|
+
// `.constitution/constitution.md` was the pre-0.5.0 path. Demanding it here made `verify` report a
|
|
1264
|
+
// file MISSING that the split deliberately removed — a check telling the truth about the wrong world.
|
|
1265
|
+
for (const required of ["AGENTS.md", path.join(".constitution", "project", "constitution.md")]) {
|
|
1266
|
+
if (!fs.existsSync(path.join(target, required))) missing.push(required.replaceAll(path.sep, "/"));
|
|
1267
|
+
}
|
|
1268
|
+
if (missing.length) {
|
|
1269
|
+
console.error(`${RED}missing ${missing.length}${RESET}`);
|
|
1270
|
+
for (const m of missing) console.error(` ${m}`);
|
|
1271
|
+
process.exit(1);
|
|
1272
|
+
}
|
|
1273
|
+
ok(`method files present in ${target}`);
|
|
1274
|
+
|
|
1275
|
+
// Present-and-correct is not the same as consistent. These three are states `update` cannot fix on
|
|
1276
|
+
// its own — it MUST NOT write over the room, and it cannot know what a product meant — so `verify`
|
|
1277
|
+
// is where they get said out loud instead of waiting to be tripped over.
|
|
1278
|
+
const judgement = [];
|
|
1279
|
+
const room = path.join(target, ".constitution", "project", "constitution.md");
|
|
1280
|
+
if (fs.existsSync(room)) {
|
|
1281
|
+
const carried = [...fs.readFileSync(room, "utf8").matchAll(/^## Article (\d+)\b/gm)]
|
|
1282
|
+
.map((m) => Number(m[1])).filter((n) => METHOD_ARTICLES.includes(n));
|
|
1283
|
+
if (carried.length) {
|
|
1284
|
+
judgement.push(`project/constitution.md still carries Articles ${carried.join(", ")} — the `
|
|
1285
|
+
+ "method's. They are duplicated in method/constitution.md and will drift. Run update again.");
|
|
1286
|
+
}
|
|
1287
|
+
}
|
|
1288
|
+
const constRoot = path.join(target, ".constitution");
|
|
1289
|
+
const loose = fs.existsSync(constRoot)
|
|
1290
|
+
? fs.readdirSync(constRoot, { withFileTypes: true })
|
|
1291
|
+
.filter((e) => e.isFile() && e.name.endsWith(".md")).map((e) => e.name)
|
|
1292
|
+
: [];
|
|
1293
|
+
if (loose.length) {
|
|
1294
|
+
judgement.push(`loose at .constitution/ root: ${loose.join(", ")} — .constitution/ holds two `
|
|
1295
|
+
+ "folders and nothing else the method knows about. Move it into project/, or name it from "
|
|
1296
|
+
+ "Article 2 so the next reader knows why it is there. repo-guide.md states the rule.");
|
|
1297
|
+
}
|
|
1298
|
+
if (judgement.length) {
|
|
1299
|
+
console.log("");
|
|
1300
|
+
for (const j of judgement) note(j);
|
|
1301
|
+
}
|
|
1302
|
+
note("extra product files are expected and were not checked");
|
|
1303
|
+
}
|
|
1304
|
+
|
|
1305
|
+
function scrubPrdToml(file) {
|
|
1306
|
+
const raw = fs.readFileSync(file, "utf8");
|
|
1307
|
+
const m = raw.match(/run_folder_pattern\s*=\s*"([^"]+)"/);
|
|
1308
|
+
if (!m) return;
|
|
1309
|
+
const slug = m[1];
|
|
1310
|
+
if (GENERIC_FOLDER_PATTERNS.has(slug)) return;
|
|
1311
|
+
fs.writeFileSync(file, raw.split(slug).join(PRD_SLUG_PLACEHOLDER), "utf8");
|
|
1312
|
+
note("bmad-prd.toml initiative slug scrubbed to placeholder");
|
|
1313
|
+
}
|
|
1314
|
+
|
|
1315
|
+
function promote(live) {
|
|
1316
|
+
live = path.resolve(live);
|
|
1317
|
+
if (!fs.existsSync(path.join(live, ".constitution"))) {
|
|
1318
|
+
die(`${live} has no .constitution/ — is this a method-carrying repo?`);
|
|
1319
|
+
}
|
|
1320
|
+
// EVERY file in the room is authored in the package and MUST survive the rmSync below — the room's
|
|
1321
|
+
// README, the generic Articles 1-2-5, and the three empty codebase templates. Read here, not
|
|
1322
|
+
// after: the first version of this preserved only README.md and read it AFTER the kit was deleted,
|
|
1323
|
+
// so it was always null and the file vanished on every promote. Two tests cover it now.
|
|
1324
|
+
const roomKit = path.join(KIT, ".constitution", PROJECT_ROOM);
|
|
1325
|
+
const roomKept = fs.existsSync(roomKit)
|
|
1326
|
+
? Object.fromEntries(walkFiles(roomKit).map((f) => [posixRel(roomKit, f), fs.readFileSync(f, "utf8")]))
|
|
1327
|
+
: {};
|
|
1328
|
+
|
|
1329
|
+
fs.rmSync(KIT, { recursive: true, force: true });
|
|
1330
|
+
fs.mkdirSync(KIT, { recursive: true });
|
|
1331
|
+
|
|
1332
|
+
// ONE skip, because 0.5.0 put everything the product owns in one folder. It covers the codebase
|
|
1333
|
+
// guides too, which used to need a rule of their own: promoting a filled-in stack guide would leak
|
|
1334
|
+
// one product's conventions — possibly written in its own `doc_language` — into a public package.
|
|
1335
|
+
const nConst = copyTree(path.join(live, ".constitution"), path.join(KIT, ".constitution"),
|
|
1336
|
+
(rel) => rel.startsWith(PROJECT_ROOM));
|
|
1337
|
+
note(`constitution ${nConst} files (${PROJECT_ROOM} skipped — it is the product's)`);
|
|
1338
|
+
for (const [rel, text] of Object.entries(roomKept)) {
|
|
1339
|
+
const dest = path.join(roomKit, rel);
|
|
1340
|
+
fs.mkdirSync(path.dirname(dest), { recursive: true });
|
|
1341
|
+
fs.writeFileSync(dest, text, "utf8");
|
|
1342
|
+
}
|
|
1343
|
+
if (Object.keys(roomKept).length) {
|
|
1344
|
+
note(`${PROJECT_ROOM} restored from the package (${Object.keys(roomKept).length} files) — `
|
|
1345
|
+
+ "promote never carries the room home");
|
|
1346
|
+
}
|
|
1347
|
+
|
|
1348
|
+
let copiedSkills = 0;
|
|
1349
|
+
const skillsSrc = path.join(live, ".claude", "skills");
|
|
1350
|
+
for (const name of WDI_SKILLS) {
|
|
1351
|
+
const src = path.join(skillsSrc, name);
|
|
1352
|
+
if (!fs.existsSync(src)) die(`skill missing in live repo: ${src}`);
|
|
1353
|
+
copiedSkills += copyTree(src, path.join(KIT, "skills", name));
|
|
1354
|
+
}
|
|
1355
|
+
note(`skills ${copiedSkills} files (${WDI_SKILLS.length} wrappers)`);
|
|
1356
|
+
|
|
1357
|
+
const customSrc = path.join(live, "_bmad", "custom");
|
|
1358
|
+
const customDst = path.join(KIT, "assets", "bmad-custom");
|
|
1359
|
+
fs.mkdirSync(customDst, { recursive: true });
|
|
1360
|
+
let tomls = 0;
|
|
1361
|
+
if (fs.existsSync(customSrc)) {
|
|
1362
|
+
for (const file of walkFiles(customSrc)) {
|
|
1363
|
+
if (!file.endsWith(".toml") || file.endsWith(".user.toml")) continue;
|
|
1364
|
+
copyFile(file, path.join(customDst, path.basename(file)));
|
|
1365
|
+
tomls += 1;
|
|
1366
|
+
}
|
|
1367
|
+
}
|
|
1368
|
+
const prd = path.join(customDst, "bmad-prd.toml");
|
|
1369
|
+
if (fs.existsSync(prd)) scrubPrdToml(prd);
|
|
1370
|
+
note(`bmad custom ${tomls} toml`);
|
|
1371
|
+
|
|
1372
|
+
const replacements = {
|
|
1373
|
+
"constitution.md": path.join(KIT, ".constitution", "method", "constitution.md"),
|
|
1374
|
+
"portability.md": path.join(KIT, ".constitution", "method", "why", "portability.md"),
|
|
1375
|
+
"repo-guide.md": path.join(KIT, ".constitution", "method", "repo-guide.md"),
|
|
1376
|
+
"README.md": path.join(KIT, ".constitution", "method", "README.md"),
|
|
1377
|
+
};
|
|
1378
|
+
for (const [name, dest] of Object.entries(replacements)) {
|
|
1379
|
+
const src = path.join(OVERLAY, name);
|
|
1380
|
+
if (fs.existsSync(src)) {
|
|
1381
|
+
copyFile(src, dest);
|
|
1382
|
+
note(`${name} replaced with kit overlay`);
|
|
1383
|
+
}
|
|
1384
|
+
}
|
|
1385
|
+
|
|
1386
|
+
const source = [
|
|
1387
|
+
`date: ${today()}`,
|
|
1388
|
+
`commit: ${gitHead(live)}`,
|
|
1389
|
+
"kind: working copy that currently carries a newer method",
|
|
1390
|
+
"note: the repo path and product name MUST NOT be recorded here",
|
|
1391
|
+
"",
|
|
1392
|
+
].join("\n");
|
|
1393
|
+
fs.writeFileSync(path.join(ROOT, "SOURCE"), source, "utf8");
|
|
1394
|
+
ok(`SOURCE stamped ${today()} @ ${gitHead(live)}`);
|
|
1395
|
+
ok(`promoted into ${KIT}`);
|
|
1396
|
+
}
|
|
1397
|
+
|
|
1398
|
+
function cancelIf(value) {
|
|
1399
|
+
if (p.isCancel(value)) {
|
|
1400
|
+
p.cancel("Cancelled.");
|
|
1401
|
+
process.exit(0);
|
|
1402
|
+
}
|
|
1403
|
+
return value;
|
|
1404
|
+
}
|
|
1405
|
+
|
|
1406
|
+
async function runWizard(pre) {
|
|
1407
|
+
p.intro(`WDI Method ${PKG.version}`);
|
|
1408
|
+
|
|
1409
|
+
const dirValue = cancelIf(
|
|
1410
|
+
await p.text({
|
|
1411
|
+
message: "Target repo (the product folder)",
|
|
1412
|
+
placeholder: process.cwd(),
|
|
1413
|
+
defaultValue: pre.dir || process.cwd(),
|
|
1414
|
+
}),
|
|
1415
|
+
);
|
|
1416
|
+
const target = path.resolve(String(dirValue).trim() || process.cwd());
|
|
1417
|
+
|
|
1418
|
+
if (!fs.existsSync(target)) {
|
|
1419
|
+
const create = cancelIf(
|
|
1420
|
+
await p.confirm({ message: `${target} does not exist. Create it?`, initialValue: true }),
|
|
1421
|
+
);
|
|
1422
|
+
if (!create) {
|
|
1423
|
+
p.cancel("No target folder.");
|
|
1424
|
+
process.exit(1);
|
|
1425
|
+
}
|
|
1426
|
+
fs.mkdirSync(target, { recursive: true });
|
|
1427
|
+
}
|
|
1428
|
+
|
|
1429
|
+
const hasBmad = bmadPresent(target);
|
|
1430
|
+
const hasWdi = wdiPresent(target);
|
|
1431
|
+
const nonempty = dirNonEmpty(target);
|
|
1432
|
+
|
|
1433
|
+
const facts = [
|
|
1434
|
+
hasBmad
|
|
1435
|
+
? `BMad Method: installed${readBmadVersion(target) ? ` (${readBmadVersion(target)})` : ""}`
|
|
1436
|
+
: "BMad Method: not installed",
|
|
1437
|
+
hasWdi ? "WDI Method: already present — the installer will offer an update" : "WDI Method: not present",
|
|
1438
|
+
enginesPresent(target)
|
|
1439
|
+
? "Ticket engines (mattpocock-skills): installed"
|
|
1440
|
+
: `Ticket engines (mattpocock-skills): not found — needed at G5 only; ${ENGINES_INSTALL} (${ENGINES_REPO})`,
|
|
1441
|
+
nonempty ? "Folder is not empty (normal for a product repo already under way)" : "Folder is empty",
|
|
1442
|
+
].join("\n");
|
|
1443
|
+
p.note(facts, "Detected");
|
|
1444
|
+
|
|
1445
|
+
if (!hasBmad && !pre.skipBmad) {
|
|
1446
|
+
p.note(bmadMissingMessage(), "BMad first");
|
|
1447
|
+
p.outro("Install BMad, then run this again: npx wdi-method");
|
|
1448
|
+
process.exit(1);
|
|
1449
|
+
}
|
|
1450
|
+
|
|
1451
|
+
let first = !hasWdi;
|
|
1452
|
+
if (hasWdi) {
|
|
1453
|
+
const update = cancelIf(
|
|
1454
|
+
await p.confirm({
|
|
1455
|
+
message: "WDI Method is already installed. Update it now?",
|
|
1456
|
+
initialValue: true,
|
|
1457
|
+
}),
|
|
1458
|
+
);
|
|
1459
|
+
first = !update;
|
|
1460
|
+
if (first) {
|
|
1461
|
+
p.cancel("Update declined.");
|
|
1462
|
+
process.exit(0);
|
|
1463
|
+
}
|
|
1464
|
+
} else {
|
|
1465
|
+
const go = cancelIf(
|
|
1466
|
+
await p.confirm({
|
|
1467
|
+
message: `Install WDI Method into ${target}?`,
|
|
1468
|
+
initialValue: true,
|
|
1469
|
+
}),
|
|
1470
|
+
);
|
|
1471
|
+
if (!go) {
|
|
1472
|
+
p.cancel("Install declined.");
|
|
1473
|
+
process.exit(0);
|
|
1474
|
+
}
|
|
1475
|
+
}
|
|
1476
|
+
|
|
1477
|
+
// Every field arrives with an answer already in it, and Enter accepts it. On an update that answer is
|
|
1478
|
+
// what the repo already says; on a first install it is the folder name made readable. Nothing here is
|
|
1479
|
+
// validated as required: a prompt that refuses an empty submission when it already holds a sensible
|
|
1480
|
+
// default is asking the owner to retype something the installer knows.
|
|
1481
|
+
const existing = readIndexIdentity(target);
|
|
1482
|
+
const suggestedName = identityIsPlaceholder(existing.name)
|
|
1483
|
+
? humaniseFolderName(path.basename(target))
|
|
1484
|
+
: existing.name;
|
|
1485
|
+
const product = cancelIf(
|
|
1486
|
+
await p.text({
|
|
1487
|
+
message: "Product name (one room: index.yaml product.name)",
|
|
1488
|
+
placeholder: suggestedName,
|
|
1489
|
+
defaultValue: suggestedName,
|
|
1490
|
+
}),
|
|
1491
|
+
).trim() || suggestedName;
|
|
1492
|
+
const client = cancelIf(
|
|
1493
|
+
await p.text({
|
|
1494
|
+
message: "Client name (Enter to leave it as it is)",
|
|
1495
|
+
placeholder: existing.client || "(none)",
|
|
1496
|
+
defaultValue: existing.client || "",
|
|
1497
|
+
}),
|
|
1498
|
+
).trim();
|
|
1499
|
+
|
|
1500
|
+
// Two questions, and only two. Method terminology, document code prefixes, machine-facing
|
|
1501
|
+
// markers, and code identifiers are always English — MUST NOT be asked about.
|
|
1502
|
+
const policy = readIndexPolicy(target);
|
|
1503
|
+
// Free text, not a list. Write whatever a model understands — "English", "Bahasa Indonesia",
|
|
1504
|
+
// "id". The only value refused is empty.
|
|
1505
|
+
const askLanguage = async (message, current) =>
|
|
1506
|
+
(cancelIf(
|
|
1507
|
+
await p.text({
|
|
1508
|
+
message,
|
|
1509
|
+
placeholder: current || DEFAULT_DOC_LANGUAGE,
|
|
1510
|
+
defaultValue: current || DEFAULT_DOC_LANGUAGE,
|
|
1511
|
+
}),
|
|
1512
|
+
) || DEFAULT_DOC_LANGUAGE).trim();
|
|
1513
|
+
const docLanguage = await askLanguage(
|
|
1514
|
+
"Language of working-document prose (.what/ .how/ .control/) — free text",
|
|
1515
|
+
policy.docLanguage || pre.docLanguage);
|
|
1516
|
+
const docFilenameLanguage = await askLanguage(
|
|
1517
|
+
"Language of document filename slugs — the `UC-` `DEC-` codes stay English",
|
|
1518
|
+
policy.docFilenameLanguage || pre.docFilenameLanguage || docLanguage);
|
|
1519
|
+
|
|
1520
|
+
const detected = pre.agents
|
|
1521
|
+
? normalizePlatformIds(pre.agents)
|
|
1522
|
+
: detectPlatforms(target, fs);
|
|
1523
|
+
const selected = cancelIf(
|
|
1524
|
+
await p.autocompleteMultiselect({
|
|
1525
|
+
message: "Which tools get the wdi-* skills? (⭐ = recommended)",
|
|
1526
|
+
options: platformSelectOptions(detected),
|
|
1527
|
+
initialValues: detected,
|
|
1528
|
+
required: true,
|
|
1529
|
+
maxItems: 8,
|
|
1530
|
+
placeholder: "Type to search…",
|
|
1531
|
+
}),
|
|
1532
|
+
);
|
|
1533
|
+
|
|
1534
|
+
p.note(
|
|
1535
|
+
[
|
|
1536
|
+
"The corpus folder names are fixed — they are not an install option:",
|
|
1537
|
+
" .constitution .control .what .how .work _bmad-output",
|
|
1538
|
+
"",
|
|
1539
|
+
"What gets written for the platforms you picked:",
|
|
1540
|
+
" AGENTS.md (the BEGIN:wdi-method block — always)",
|
|
1541
|
+
platformUsesHook(selected, "claude-md") ? " CLAUDE.md → @AGENTS.md" : "",
|
|
1542
|
+
platformUsesHook(selected, "cursorrules") ? " .cursorrules (method block mirror)" : "",
|
|
1543
|
+
platformUsesHook(selected, "agents-mirror") ? " .agents/AGENTS.md (method block mirror)" : "",
|
|
1544
|
+
platformUsesHook(selected, "opencode-commands")
|
|
1545
|
+
? ` ${opencodeCommandsDir()}/wdi-*.md (slash commands → skills)`
|
|
1546
|
+
: "",
|
|
1547
|
+
` wdi-* skills → ${skillDestinations(target, selected).map((d) => posixRel(target, d)).join(", ") || "(none)"}`,
|
|
1548
|
+
]
|
|
1549
|
+
.filter(Boolean)
|
|
1550
|
+
.join("\n"),
|
|
1551
|
+
"Write targets",
|
|
1552
|
+
);
|
|
1553
|
+
|
|
1554
|
+
const okGo = cancelIf(await p.confirm({ message: first ? "Run the install?" : "Run the update?", initialValue: true }));
|
|
1555
|
+
if (!okGo) {
|
|
1556
|
+
p.cancel("Dibatalkan.");
|
|
1557
|
+
process.exit(0);
|
|
1558
|
+
}
|
|
1559
|
+
|
|
1560
|
+
const spinner = p.spinner();
|
|
1561
|
+
spinner.start(first ? "Memasang…" : "Meng-update…");
|
|
1562
|
+
apply(target, selected, {
|
|
1563
|
+
docLanguage,
|
|
1564
|
+
docFilenameLanguage,
|
|
1565
|
+
languageChosen: true,
|
|
1566
|
+
first,
|
|
1567
|
+
product: String(product).trim(),
|
|
1568
|
+
client: String(client).trim(),
|
|
1569
|
+
});
|
|
1570
|
+
spinner.stop(first ? "Terpasang" : "Ter-update");
|
|
1571
|
+
p.outro(first ? "Done. Take the after-install steps above." : "Done. Read the method-block diff in AGENTS.md.");
|
|
1572
|
+
}
|
|
1573
|
+
|
|
1574
|
+
function runNonInteractive(args) {
|
|
1575
|
+
const target = requireTarget(args.dir);
|
|
1576
|
+
const agents = args.agents || detectPlatforms(target, fs) || PREFERRED_PLATFORM_IDS.slice();
|
|
1577
|
+
if (args.cmd === "verify") {
|
|
1578
|
+
verify(target, agents);
|
|
1579
|
+
return;
|
|
1580
|
+
}
|
|
1581
|
+
if (!args.skipBmad && !bmadPresent(target)) {
|
|
1582
|
+
die(bmadMissingMessage());
|
|
1583
|
+
}
|
|
1584
|
+
if (!args.skipEngines && !enginesPresent(target)) {
|
|
1585
|
+
die(enginesMissingMessage());
|
|
1586
|
+
}
|
|
1587
|
+
const existing = readIndexIdentity(target);
|
|
1588
|
+
const product = args.product || existing.name;
|
|
1589
|
+
const client = args.client ?? existing.client;
|
|
1590
|
+
const first = args.cmd === "install" || (args.cmd === "wizard" && !wdiPresent(target));
|
|
1591
|
+
apply(target, agents, {
|
|
1592
|
+
first: args.cmd === "update" ? false : first,
|
|
1593
|
+
product,
|
|
1594
|
+
client,
|
|
1595
|
+
docLanguage: args.docLanguage,
|
|
1596
|
+
docFilenameLanguage: args.docFilenameLanguage,
|
|
1597
|
+
languageChosen: Boolean(args.docLanguage || args.docFilenameLanguage),
|
|
1598
|
+
});
|
|
1599
|
+
}
|
|
1600
|
+
|
|
1601
|
+
async function main() {
|
|
1602
|
+
const args = parseArgs(process.argv);
|
|
1603
|
+
if (!["wizard", "install", "update", "verify", "promote"].includes(args.cmd)) {
|
|
1604
|
+
usage();
|
|
1605
|
+
process.exit(2);
|
|
1606
|
+
}
|
|
1607
|
+
if (args.cmd === "promote") {
|
|
1608
|
+
if (!args.dir) die("promote needs a path to the working copy");
|
|
1609
|
+
// `promote` used to BE the workflow: author a rule in a product repo, run it, carry it here.
|
|
1610
|
+
// It is now a rescue tool, and the flag is what makes that structural rather than a paragraph
|
|
1611
|
+
// nobody rereads. Running it by habit overwrites the whole kit with one consumer's copy —
|
|
1612
|
+
// silently reverting every change made here since that repo last updated.
|
|
1613
|
+
if (!args.rescue) {
|
|
1614
|
+
die([
|
|
1615
|
+
"promote overwrites the whole kit from a consumer's copy, and this package is now where a",
|
|
1616
|
+
" method change is authored — see CONTRIBUTING.md. If a change really was made in a",
|
|
1617
|
+
" product repo by mistake and needs rescuing, say so:",
|
|
1618
|
+
"",
|
|
1619
|
+
" npx wdi-method promote <dir> --rescue",
|
|
1620
|
+
].join("\n"));
|
|
1621
|
+
}
|
|
1622
|
+
note("--rescue: pulling the method back out of a consumer. Read the diff before committing.");
|
|
1623
|
+
promote(args.dir);
|
|
1624
|
+
return;
|
|
1625
|
+
}
|
|
1626
|
+
const wantTui = !args.yes && args.cmd !== "verify" && process.stdin.isTTY && process.stdout.isTTY;
|
|
1627
|
+
if (wantTui) {
|
|
1628
|
+
await runWizard(args);
|
|
1629
|
+
return;
|
|
1630
|
+
}
|
|
1631
|
+
if (args.cmd === "wizard" && !args.yes) {
|
|
1632
|
+
die("not a TTY. Use `install --yes` / `update --yes`, or run this in a terminal.");
|
|
1633
|
+
}
|
|
1634
|
+
if (args.cmd === "wizard") args.cmd = wdiPresent(requireTarget(args.dir)) ? "update" : "install";
|
|
1635
|
+
runNonInteractive(args);
|
|
1636
|
+
}
|
|
1637
|
+
|
|
1638
|
+
main().catch((err) => {
|
|
1639
|
+
console.error(err);
|
|
1640
|
+
process.exit(1);
|
|
1641
|
+
});
|