bmad-plus 0.16.0 → 0.17.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (66) hide show
  1. package/CHANGELOG.md +114 -29
  2. package/README.md +45 -27
  3. package/SECURITY.md +9 -1
  4. package/osint-agent-package/README.md +13 -0
  5. package/osint-agent-package/SETUP_KEYS.md +13 -0
  6. package/package.json +32 -8
  7. package/readme-international/README.de.md +134 -537
  8. package/readme-international/README.es.md +136 -556
  9. package/readme-international/README.fr.md +135 -553
  10. package/src/bmad-plus/agents/agent-shadow/SKILL.md +6 -0
  11. package/src/bmad-plus/packs/pack-memory/README.md +1 -1
  12. package/src/bmad-plus/packs/pack-memory/memory-orchestrator.md +1 -1
  13. package/src/bmad-plus/packs/pack-memory/shared/memory-protocol.md +1 -1
  14. package/src/bmad-plus/packs/pack-shield/README.md +2 -0
  15. package/src/bmad-plus/packs/pack-shield/SKILL.md +2 -0
  16. package/src/bmad-plus/packs/pack-shield/shared/audit-report-template.md +6 -0
  17. package/src/bmad-plus/packs/pack-shield/shield-orchestrator.md +4 -0
  18. package/src/bmad-plus/skills/bmad-plus-uat/SKILL.md +18 -4
  19. package/src/bmad-plus/skills/bmad-plus-uat/template/page.html +95 -19
  20. package/src/bmad-plus/skills/bmad-plus-uat/template/strings.json +90 -10
  21. package/src/bmad-plus/skills/bmad-plus-uat/templates/README.md +63 -0
  22. package/src/bmad-plus/skills/bmad-plus-uat/templates/example-uat-spec.json +95 -0
  23. package/src/bmad-plus/skills/bmad-plus-uat/templates/uat-release-gate.mjs +127 -0
  24. package/src/bmad-plus/skills/bmad-plus-uat/templates/uat-spec-present.mjs +77 -0
  25. package/tools/build/adapters.config.js +1 -1
  26. package/tools/build/generate-adapters.js +186 -60
  27. package/tools/build/generate.js +49 -13
  28. package/tools/cli/bmad-plus-cli.js +3 -6
  29. package/tools/cli/commands/autoconfig.js +66 -24
  30. package/tools/cli/commands/install.js +620 -356
  31. package/tools/cli/commands/memory.js +40 -13
  32. package/tools/cli/commands/scan.js +94 -30
  33. package/tools/cli/commands/uat.js +21 -5
  34. package/tools/cli/commands/uninstall.js +65 -16
  35. package/tools/cli/commands/update-check.js +8 -3
  36. package/tools/cli/commands/update-policy.js +14 -5
  37. package/tools/cli/commands/update.js +89 -20
  38. package/tools/cli/i18n.js +128 -66
  39. package/tools/cli/lib/ide-config.js +3 -1
  40. package/tools/cli/lib/install-manifest.js +13 -6
  41. package/tools/cli/lib/installation-health.js +33 -1
  42. package/tools/cli/lib/installed-adapters.js +29 -6
  43. package/tools/cli/lib/memory-init.js +19 -4
  44. package/tools/cli/lib/packs.js +17 -28
  45. package/tools/cli/lib/path-hash.js +5 -1
  46. package/tools/cli/lib/python-provision.js +51 -12
  47. package/tools/cli/lib/stack-detect.js +51 -25
  48. package/tools/cli/lib/uat.js +18 -0
  49. package/tools/cli/lib/update-check.js +132 -34
  50. package/tools/cli/lib/update-dispatch.js +2 -2
  51. package/tools/cli/lib/update-policy.js +57 -19
  52. package/tools/cli/lib/update-transaction.js +439 -85
  53. package/tools/build/README.md +0 -78
  54. package/tools/build/check-counts.js +0 -626
  55. package/tools/build/check-install-contract.js +0 -648
  56. package/tools/build/generated-adapters/.codex/AGENTS.md +0 -134
  57. package/tools/build/generated-adapters/.cursor/rules/bmad-plus.mdc +0 -139
  58. package/tools/build/generated-adapters/.opencode/AGENTS.md +0 -134
  59. package/tools/build/generated-adapters/AGENTS.md +0 -132
  60. package/tools/build/generated-adapters/CLAUDE.md +0 -135
  61. package/tools/build/generated-adapters/CONVENTIONS.md +0 -134
  62. package/tools/build/generated-adapters/GEMINI.md +0 -139
  63. package/tools/build/generated-adapters/README.md +0 -79
  64. package/tools/maintain/upstream-candidate.js +0 -456
  65. package/tools/release/publication-content.js +0 -903
  66. package/tools/release/supply-chain.js +0 -282
@@ -2,16 +2,26 @@
2
2
  const fs = require('node:fs');
3
3
  const path = require('node:path');
4
4
  const crypto = require('node:crypto');
5
+ const os = require('node:os');
5
6
  const semver = require('semver');
6
7
  const { PACKS, DERIVED } = require('./packs');
7
8
  const { listPackFiles, fileHash } = require('./pack-copy');
8
9
  const { contentHash, classifyAdapter, INSTALLER_MARKER } = require('./installed-adapters');
9
- const { generateUserFiles, IDE_CONFIGS, GENERATED_MARKER, USER_CONFIG_MARKER, safeTarget } = require('../../build/generate-adapters');
10
+ const {
11
+ generateUserFiles,
12
+ IDE_CONFIGS,
13
+ GENERATED_MARKER,
14
+ USER_CONFIG_MARKER,
15
+ safeTarget,
16
+ } = require('../../build/generate-adapters');
10
17
  const { readInstallManifest } = require('./install-manifest');
11
18
  const { isExactVersion } = require('./update-policy');
12
19
 
13
20
  const MANIFEST = '_bmad/.bmad-plus-install.json';
14
21
  const LOCK = '.bmad/update.lock';
22
+ // No install or update holds the lock this long; older locks are reported as stale.
23
+ const STALE_LOCK_MS = 60 * 60 * 1000;
24
+ const UNREADABLE_LOCK_GRACE_MS = 60 * 1000;
15
25
  const MODULE_FILES = ['_bmad/module.yaml', '_bmad/module-help.csv'];
16
26
  const DEFAULT_PACKAGE_ROOT = path.resolve(__dirname, '../../..');
17
27
  const relativeFile = (root, target) => path.relative(root, target).split(path.sep).join('/');
@@ -23,14 +33,24 @@ const currentHash = (root, file) => {
23
33
  const content = currentBytes(root, file);
24
34
  return content === null ? null : fileHash(content);
25
35
  };
26
- const isManagedFile = file => typeof file === 'string' && !file.includes('\\') &&
27
- path.posix.normalize(file) === file && (MODULE_FILES.includes(file) ||
28
- file.startsWith('.agents/skills/') || file.startsWith('.agents/data/'));
36
+ const isManagedFile = (file) =>
37
+ typeof file === 'string' &&
38
+ !file.includes('\\') &&
39
+ path.posix.normalize(file) === file &&
40
+ (MODULE_FILES.includes(file) ||
41
+ file.startsWith('.agents/skills/') ||
42
+ file.startsWith('.agents/data/'));
29
43
 
30
44
  function inventoryOf(manifest) {
31
45
  const inventory = manifest.fileInventory;
32
- if (!inventory || inventory.schemaVersion !== 1 || !inventory.files ||
33
- typeof inventory.files !== 'object' || Array.isArray(inventory.files)) return null;
46
+ if (
47
+ !inventory ||
48
+ inventory.schemaVersion !== 1 ||
49
+ !inventory.files ||
50
+ typeof inventory.files !== 'object' ||
51
+ Array.isArray(inventory.files)
52
+ )
53
+ return null;
34
54
  for (const [file, hash] of Object.entries(inventory.files)) {
35
55
  if (!isManagedFile(file) || !/^[a-f0-9]{64}$/.test(hash)) return null;
36
56
  }
@@ -38,7 +58,11 @@ function inventoryOf(manifest) {
38
58
  }
39
59
 
40
60
  function hasCompleteInventory(manifest, inventory) {
41
- return Boolean(inventory && manifest.fileInventory.complete === true && MODULE_FILES.every(file => typeof inventory[file] === 'string'));
61
+ return Boolean(
62
+ inventory &&
63
+ manifest.fileInventory.complete === true &&
64
+ MODULE_FILES.every((file) => typeof inventory[file] === 'string')
65
+ );
42
66
  }
43
67
 
44
68
  function collectManagedFiles({ projectDir, packageRoot = DEFAULT_PACKAGE_ROOT, packs }) {
@@ -46,10 +70,15 @@ function collectManagedFiles({ projectDir, packageRoot = DEFAULT_PACKAGE_ROOT, p
46
70
  const bmadSrc = path.join(packageRoot, 'src', 'bmad-plus');
47
71
  for (const id of packs) {
48
72
  if (!Object.hasOwn(PACKS, id)) throw new Error('Unknown installed pack: ' + id);
49
- const entries = listPackFiles({ bmadSrc, projectRoot: packageRoot,
73
+ const entries = listPackFiles({
74
+ bmadSrc,
75
+ projectRoot: packageRoot,
50
76
  targetAgentsDir: path.join(projectDir, '.agents', 'skills'),
51
- targetDataDir: path.join(projectDir, '.agents', 'data'), pack: PACKS[id] });
52
- for (const entry of entries.files) files.set(relativeFile(projectDir, entry.target), entry.source);
77
+ targetDataDir: path.join(projectDir, '.agents', 'data'),
78
+ pack: PACKS[id],
79
+ });
80
+ for (const entry of entries.files)
81
+ files.set(relativeFile(projectDir, entry.target), entry.source);
53
82
  }
54
83
  for (const file of MODULE_FILES) files.set(file, path.join(bmadSrc, path.basename(file)));
55
84
  return [...files].map(([file, source]) => {
@@ -59,10 +88,18 @@ function collectManagedFiles({ projectDir, packageRoot = DEFAULT_PACKAGE_ROOT, p
59
88
  }
60
89
 
61
90
  function installedTools(projectDir, manifest) {
62
- if (Array.isArray(manifest.ides)) return manifest.ides.filter(id => Object.hasOwn(IDE_CONFIGS, id));
63
- const tools = Object.keys(IDE_CONFIGS).filter(id => fs.existsSync(safeTarget(projectDir, IDE_CONFIGS[id].configFile)));
91
+ if (Array.isArray(manifest.ides))
92
+ return manifest.ides.filter((id) => Object.hasOwn(IDE_CONFIGS, id));
93
+ const tools = Object.keys(IDE_CONFIGS).filter((id) =>
94
+ fs.existsSync(safeTarget(projectDir, IDE_CONFIGS[id].configFile))
95
+ );
64
96
  const spine = currentBytes(projectDir, DERIVED.targets.spine)?.toString('utf8');
65
- if (spine?.includes(INSTALLER_MARKER) && !spine.includes(GENERATED_MARKER) && !tools.includes('codex-cli')) tools.push('codex-cli');
97
+ if (
98
+ spine?.includes(INSTALLER_MARKER) &&
99
+ !spine.includes(GENERATED_MARKER) &&
100
+ !tools.includes('codex-cli')
101
+ )
102
+ tools.push('codex-cli');
66
103
  return tools;
67
104
  }
68
105
 
@@ -70,56 +107,99 @@ function evaluateUpdateReadiness({ projectDir, manifest }) {
70
107
  try {
71
108
  manifest ||= readInstallManifest(safeTarget(projectDir, MANIFEST));
72
109
  const inventory = inventoryOf(manifest);
73
- if (!hasCompleteInventory(manifest, inventory)) return { ready: false, legacy: true, conflicts: [], reason: 'Complete file ownership inventory is required.' };
110
+ if (!hasCompleteInventory(manifest, inventory))
111
+ return {
112
+ ready: false,
113
+ legacy: true,
114
+ conflicts: [],
115
+ reason: 'Complete file ownership inventory is required.',
116
+ };
74
117
  const conflicts = [];
75
- if (fs.existsSync(safeTarget(projectDir, LOCK))) conflicts.push({ file: LOCK, reason: 'update-in-progress' });
118
+ if (fs.existsSync(safeTarget(projectDir, LOCK)))
119
+ conflicts.push({ file: LOCK, reason: 'update-in-progress' });
76
120
  for (const [file, hash] of Object.entries(inventory)) {
77
121
  const current = currentHash(projectDir, file);
78
- if (current !== null && current !== hash) conflicts.push({ file, reason: 'modified-managed-file' });
122
+ if (current !== null && current !== hash)
123
+ conflicts.push({ file, reason: 'modified-managed-file' });
79
124
  }
80
125
  const tools = installedTools(projectDir, manifest);
81
- const expected = tools.length ? generateUserFiles(DERIVED, { packs: manifest.packs, tools }) : [];
126
+ const expected = tools.length
127
+ ? generateUserFiles(DERIVED, { packs: manifest.packs, tools })
128
+ : [];
82
129
  for (const { file } of expected) {
83
130
  const bytes = currentBytes(projectDir, file);
84
- if (bytes !== null && (!bytes.toString('utf8').includes(USER_CONFIG_MARKER) ||
85
- contentHash(bytes.toString('utf8')) !== manifest.adapterHashes?.[file])) {
131
+ if (
132
+ bytes !== null &&
133
+ (!bytes.toString('utf8').includes(USER_CONFIG_MARKER) ||
134
+ contentHash(bytes.toString('utf8')) !== manifest.adapterHashes?.[file])
135
+ ) {
86
136
  conflicts.push({ file, reason: 'unowned-or-modified-adapter' });
87
137
  }
88
138
  }
89
- return { ready: conflicts.length === 0, legacy: false, conflicts,
90
- reason: conflicts.length ? 'Local changes or an active update require manual review.' : 'Managed files are unchanged.' };
139
+ return {
140
+ ready: conflicts.length === 0,
141
+ legacy: false,
142
+ conflicts,
143
+ reason: conflicts.length
144
+ ? 'Local changes or an active update require manual review.'
145
+ : 'Managed files are unchanged.',
146
+ };
91
147
  } catch (error) {
92
148
  return { ready: false, legacy: false, conflicts: [], reason: error.message };
93
149
  }
94
150
  }
95
151
 
96
152
  /** Read-only plan: every destination is checked before the first mutation. */
97
- function planUpdate({ projectDir, packageRoot = DEFAULT_PACKAGE_ROOT, manifest, version, auto = false }) {
153
+ function planUpdate({
154
+ projectDir,
155
+ packageRoot = DEFAULT_PACKAGE_ROOT,
156
+ manifest,
157
+ version,
158
+ auto = false,
159
+ }) {
98
160
  projectDir = path.resolve(projectDir);
99
161
  const snapshot = currentBytes(projectDir, MANIFEST);
100
162
  const live = readInstallManifest(safeTarget(projectDir, MANIFEST));
101
- if (manifest && JSON.stringify(live) !== JSON.stringify(manifest)) throw new Error('Installation manifest changed; check again.');
163
+ if (manifest && JSON.stringify(live) !== JSON.stringify(manifest))
164
+ throw new Error('Installation manifest changed; check again.');
102
165
  manifest = live;
103
- if (!isExactVersion(version) || !isExactVersion(manifest.version)) throw new Error('Update versions must be strict semantic versions.');
104
- if (semver.lt(version, manifest.version)) throw new Error('Downgrade refused: ' + manifest.version + ' -> ' + version);
166
+ if (!isExactVersion(version) || !isExactVersion(manifest.version))
167
+ throw new Error('Update versions must be strict semantic versions.');
168
+ if (semver.lt(version, manifest.version))
169
+ throw new Error('Downgrade refused: ' + manifest.version + ' -> ' + version);
105
170
  const owned = inventoryOf(manifest);
106
171
  let legacy = !hasCompleteInventory(manifest, owned);
107
172
  const nextHashes = { ...(owned || {}) };
108
173
  const nextAdapterHashes = { ...(manifest.adapterHashes || {}) };
109
174
  const tools = installedTools(projectDir, manifest);
110
175
  const expected = collectManagedFiles({ projectDir, packageRoot, packs: manifest.packs });
111
- if (tools.length) expected.push(...generateUserFiles(DERIVED, {
112
- packs: manifest.packs, tools, userName: manifest.user || 'Developer', language: manifest.language || 'English',
113
- }).map(entry => ({ file: entry.file, content: Buffer.from(entry.content), kind: 'adapter' })));
176
+ if (tools.length)
177
+ expected.push(
178
+ ...generateUserFiles(DERIVED, {
179
+ packs: manifest.packs,
180
+ tools,
181
+ userName: manifest.user || 'Developer',
182
+ language: manifest.language || 'English',
183
+ }).map((entry) => ({
184
+ file: entry.file,
185
+ content: Buffer.from(entry.content),
186
+ kind: 'adapter',
187
+ }))
188
+ );
114
189
  const observed = new Map([[MANIFEST, fileHash(snapshot)]]);
115
190
  const files = [];
116
191
  const conflicts = [];
192
+ const expectedFiles = new Set(expected.map((entry) => entry.file));
193
+ // Owned files the new version no longer ships: remove them when unchanged
194
+ // (backed up in the receipt); a locally edited one is kept and handed over.
117
195
  for (const [file, hash] of Object.entries(owned || {})) {
118
196
  const current = currentHash(projectDir, file);
119
197
  observed.set(file, current);
120
- if (!expected.some(entry => entry.file === file) && current !== null && current !== hash) {
121
- conflicts.push({ file, reason: 'modified-managed-file' });
122
- }
198
+ if (expectedFiles.has(file)) continue;
199
+ delete nextHashes[file];
200
+ if (current === hash)
201
+ files.push({ file, kind: 'delete', content: null, beforeHash: current, afterHash: null });
202
+ else if (current !== null) conflicts.push({ file, reason: 'modified-orphaned-file' });
123
203
  }
124
204
  for (const entry of expected) {
125
205
  const before = currentBytes(projectDir, entry.file);
@@ -128,8 +208,12 @@ function planUpdate({ projectDir, packageRoot = DEFAULT_PACKAGE_ROOT, manifest,
128
208
  const afterHash = fileHash(entry.content);
129
209
  let backupLegacy = false;
130
210
  if (entry.kind === 'adapter') {
131
- const decision = classifyAdapter({ existing: before?.toString('utf8'), content: entry.content.toString('utf8'),
132
- previousHash: manifest.adapterHashes?.[entry.file], update: true });
211
+ const decision = classifyAdapter({
212
+ existing: before?.toString('utf8'),
213
+ content: entry.content.toString('utf8'),
214
+ previousHash: manifest.adapterHashes?.[entry.file],
215
+ update: true,
216
+ });
133
217
  if (decision.action === 'preserve') {
134
218
  conflicts.push({ file: entry.file, reason: decision.reason });
135
219
  continue;
@@ -153,16 +237,113 @@ function planUpdate({ projectDir, packageRoot = DEFAULT_PACKAGE_ROOT, manifest,
153
237
  }
154
238
  if (beforeHash !== afterHash) files.push({ ...entry, beforeHash, afterHash, backupLegacy });
155
239
  }
156
- const nextManifest = { ...manifest, version, lastUpdated: new Date().toISOString(), ides: tools,
157
- adapterHashes: nextAdapterHashes, fileInventory: { schemaVersion: 1,
158
- complete: !conflicts.some(entry => entry.reason === 'unowned-file'), files: nextHashes } };
240
+ const nextManifest = {
241
+ ...manifest,
242
+ version,
243
+ lastUpdated: new Date().toISOString(),
244
+ ides: tools,
245
+ adapterHashes: nextAdapterHashes,
246
+ fileInventory: {
247
+ schemaVersion: 1,
248
+ complete: !conflicts.some((entry) => entry.reason === 'unowned-file'),
249
+ files: nextHashes,
250
+ },
251
+ };
159
252
  const manifestContent = Buffer.from(JSON.stringify(nextManifest, null, 2));
160
- files.push({ file: MANIFEST, kind: 'manifest', content: manifestContent,
161
- beforeHash: fileHash(snapshot), afterHash: fileHash(manifestContent) });
253
+ files.push({
254
+ file: MANIFEST,
255
+ kind: 'manifest',
256
+ content: manifestContent,
257
+ beforeHash: fileHash(snapshot),
258
+ afterHash: fileHash(manifestContent),
259
+ });
162
260
  for (const { file } of files) safeTarget(projectDir, file);
163
- if (auto && (legacy || conflicts.length)) throw new Error('Automatic update refused: ' + (legacy ? 'legacy ownership inventory' : 'local file conflicts'));
164
- return { projectDir, fromVersion: manifest.version, toVersion: version, files, conflicts, legacy,
165
- inventoryComplete: !legacy, nextManifest, observed: [...observed], auto };
261
+ if (auto && (legacy || conflicts.length))
262
+ throw new Error(
263
+ 'Automatic update refused: ' +
264
+ (legacy ? 'legacy ownership inventory' : 'local file conflicts')
265
+ );
266
+ return {
267
+ projectDir,
268
+ fromVersion: manifest.version,
269
+ toVersion: version,
270
+ files,
271
+ conflicts,
272
+ legacy,
273
+ inventoryComplete: !legacy,
274
+ nextManifest,
275
+ observed: [...observed],
276
+ auto,
277
+ };
278
+ }
279
+
280
+ function processAlive(pid) {
281
+ try {
282
+ process.kill(pid, 0);
283
+ return true;
284
+ } catch (error) {
285
+ return error.code === 'EPERM';
286
+ }
287
+ }
288
+
289
+ /** Read-only lock diagnosis: owner, age, and whether it can be proven stale. */
290
+ function inspectProjectLock(projectDir, { now = Date.now() } = {}) {
291
+ const target = safeTarget(projectDir, LOCK);
292
+ let stat;
293
+ try {
294
+ stat = fs.statSync(target);
295
+ } catch (error) {
296
+ if (error.code === 'ENOENT') return null;
297
+ throw error;
298
+ }
299
+ let lock = null;
300
+ try {
301
+ lock = JSON.parse(fs.readFileSync(target, 'utf8'));
302
+ } catch {
303
+ /* reported as unreadable */
304
+ }
305
+ if (!lock || typeof lock !== 'object' || Array.isArray(lock)) lock = null;
306
+ const createdAt = Date.parse(lock?.createdAt) || stat.mtimeMs;
307
+ const ageMs = Math.max(0, now - createdAt);
308
+ const pid = Number.isInteger(lock?.pid) && lock.pid > 0 ? lock.pid : null;
309
+ const sameHost = !lock?.hostname || lock.hostname === os.hostname();
310
+ const pidAlive = pid && sameHost ? processAlive(pid) : null;
311
+ let stale = false;
312
+ let reason = 'An install or update is running (pid ' + (pid ?? 'unknown') + ').';
313
+ if (!lock) {
314
+ stale = ageMs > UNREADABLE_LOCK_GRACE_MS;
315
+ reason = 'The lock file is unreadable.';
316
+ } else if (pidAlive === false) {
317
+ stale = true;
318
+ reason = 'The recorded process ' + pid + ' is no longer running.';
319
+ } else if (ageMs > STALE_LOCK_MS) {
320
+ stale = true;
321
+ reason = 'The lock is older than ' + STALE_LOCK_MS / 60000 + ' minutes.';
322
+ }
323
+ return {
324
+ path: target,
325
+ id: lock?.id ?? null,
326
+ pid,
327
+ hostname: lock?.hostname || null,
328
+ receiptId: lock?.receiptId || null,
329
+ createdAt: new Date(createdAt).toISOString(),
330
+ ageMs,
331
+ pidAlive,
332
+ stale,
333
+ reason,
334
+ // Only a dead process on this machine is proof enough to take the lock over.
335
+ recoverable: pidAlive === false,
336
+ };
337
+ }
338
+
339
+ function lockRemedy(info) {
340
+ return info.stale
341
+ ? ' ' +
342
+ info.reason +
343
+ ' If no BMAD+ install or update is running, delete ' +
344
+ info.path +
345
+ ' and retry.'
346
+ : ' Wait for it to finish; if it was interrupted, `bmad-plus doctor` reports when the lock becomes stale.';
166
347
  }
167
348
 
168
349
  function acquireProjectLock(projectDir, { receiptId = null } = {}) {
@@ -170,18 +351,68 @@ function acquireProjectLock(projectDir, { receiptId = null } = {}) {
170
351
  fs.mkdirSync(path.dirname(target), { recursive: true });
171
352
  const id = crypto.randomUUID();
172
353
  let fd;
173
- try { fd = fs.openSync(target, 'wx'); } catch (error) {
174
- if (error.code === 'EEXIST') throw new Error('Another update is in progress: ' + target, { cause: error });
175
- throw error;
354
+ for (let attempt = 0; fd === undefined; attempt++) {
355
+ try {
356
+ fd = fs.openSync(target, 'wx');
357
+ } catch (error) {
358
+ if (error.code !== 'EEXIST') throw error;
359
+ const info = inspectProjectLock(projectDir);
360
+ // Move a provably dead lock aside once (rename is atomic: one contender wins).
361
+ if (attempt === 0 && info?.recoverable) {
362
+ const aside = target + '.stale-' + id;
363
+ try {
364
+ fs.renameSync(target, aside);
365
+ } catch (renameError) {
366
+ if (renameError.code !== 'ENOENT') throw renameError;
367
+ continue;
368
+ }
369
+ let moved = null;
370
+ try {
371
+ moved = JSON.parse(fs.readFileSync(aside, 'utf8'));
372
+ } catch {
373
+ /* unreadable: not a live lock */
374
+ }
375
+ if ((moved?.id ?? null) !== info.id || moved?.pid !== info.pid) {
376
+ // Another contender took the lock between inspection and rename: give it back.
377
+ try {
378
+ fs.linkSync(aside, target);
379
+ fs.unlinkSync(aside);
380
+ } catch {
381
+ /* keep evidence */
382
+ }
383
+ throw new Error('Another update is in progress: ' + target + '.', { cause: error });
384
+ }
385
+ continue;
386
+ }
387
+ throw new Error(
388
+ 'Another update is in progress: ' + target + '.' + (info ? lockRemedy(info) : ''),
389
+ { cause: error }
390
+ );
391
+ }
176
392
  }
177
- try { fs.writeFileSync(fd, JSON.stringify({ id, pid: process.pid, receiptId })); } catch (error) {
393
+ try {
394
+ fs.writeFileSync(
395
+ fd,
396
+ JSON.stringify({
397
+ id,
398
+ pid: process.pid,
399
+ hostname: os.hostname(),
400
+ createdAt: new Date().toISOString(),
401
+ receiptId,
402
+ })
403
+ );
404
+ } catch (error) {
178
405
  fs.closeSync(fd);
179
406
  fs.unlinkSync(target);
180
407
  throw error;
181
408
  }
182
409
  fs.closeSync(fd);
183
410
  return () => {
184
- if (fs.existsSync(safeTarget(projectDir, LOCK)) && JSON.parse(fs.readFileSync(target, 'utf8')).id === id) fs.unlinkSync(target);
411
+ if (
412
+ fs.existsSync(safeTarget(projectDir, LOCK)) &&
413
+ JSON.parse(fs.readFileSync(target, 'utf8')).id === id
414
+ )
415
+ fs.unlinkSync(target);
185
416
  };
186
417
  }
187
418
 
@@ -202,26 +433,49 @@ function atomicWrite(projectDir, file, content) {
202
433
  function rollback(projectDir, receipt) {
203
434
  const failures = [];
204
435
  // Restore the manifest last, after restoring the content it describes.
205
- const entries = [...receipt.entries.filter(entry => entry.file !== MANIFEST).reverse(),
206
- ...receipt.entries.filter(entry => entry.file === MANIFEST)];
436
+ const entries = [
437
+ ...receipt.entries.filter((entry) => entry.file !== MANIFEST).reverse(),
438
+ ...receipt.entries.filter((entry) => entry.file === MANIFEST),
439
+ ];
207
440
  for (const entry of entries) {
208
441
  try {
209
442
  const current = currentHash(projectDir, entry.file);
210
443
  if (current === entry.beforeHash) continue;
211
- if (current !== entry.afterHash && current !== null) throw new Error('File changed since the update; retained');
444
+ if (current !== entry.afterHash && current !== null)
445
+ throw new Error('File changed since the update; retained');
212
446
  if (entry.beforeHash === null) {
213
447
  if (current !== null) fs.unlinkSync(safeTarget(projectDir, entry.file));
214
448
  } else {
215
- if (entry.file === MANIFEST && failures.length) throw new Error('Content recovery is incomplete; manifest retained');
449
+ if (entry.file === MANIFEST && failures.length)
450
+ throw new Error('Content recovery is incomplete; manifest retained');
216
451
  const backup = currentBytes(projectDir, entry.backup);
217
- if (!backup || fileHash(backup) !== entry.beforeHash) throw new Error('Backup is missing or does not match its recorded hash');
452
+ if (!backup || fileHash(backup) !== entry.beforeHash)
453
+ throw new Error('Backup is missing or does not match its recorded hash');
218
454
  atomicWrite(projectDir, entry.file, backup);
219
455
  }
220
- } catch (error) { failures.push({ file: entry.file, reason: error.message }); }
456
+ } catch (error) {
457
+ failures.push({ file: entry.file, reason: error.message });
458
+ }
221
459
  }
222
460
  return failures;
223
461
  }
224
462
 
463
+ /** Remove directories emptied by a deletion, never above the managed roots. */
464
+ function pruneEmptyParents(projectDir, file) {
465
+ let directory = path.posix.dirname(file);
466
+ while (directory.startsWith('.agents/skills/') || directory.startsWith('.agents/data/')) {
467
+ try {
468
+ // safeTarget validates files; checking a child path rejects linked parents.
469
+ const target = path.dirname(safeTarget(projectDir, directory + '/.bmad-preflight'));
470
+ if (fs.readdirSync(target).length) return;
471
+ fs.rmdirSync(target);
472
+ } catch {
473
+ return;
474
+ }
475
+ directory = path.posix.dirname(directory);
476
+ }
477
+ }
478
+
225
479
  function applyPlan(plan, { reauthorize } = {}) {
226
480
  const { projectDir } = plan;
227
481
  const id = crypto.randomUUID();
@@ -230,14 +484,24 @@ function applyPlan(plan, { reauthorize } = {}) {
230
484
  let receipt;
231
485
  try {
232
486
  if (plan.auto) {
233
- if (typeof reauthorize !== 'function') throw new Error('Automatic update requires policy reauthorization under the project lock.');
487
+ if (typeof reauthorize !== 'function')
488
+ throw new Error('Automatic update requires policy reauthorization under the project lock.');
234
489
  reauthorize();
235
490
  }
236
491
  for (const [file, hash] of plan.observed) {
237
- if (currentHash(projectDir, file) !== hash) throw new Error('File changed after preflight: ' + file);
492
+ if (currentHash(projectDir, file) !== hash)
493
+ throw new Error('File changed after preflight: ' + file);
238
494
  }
239
- receipt = { schemaVersion: 1, id, status: 'prepared', fromVersion: plan.fromVersion, toVersion: plan.toVersion,
240
- createdAt: new Date().toISOString(), conflicts: plan.conflicts, entries: [] };
495
+ receipt = {
496
+ schemaVersion: 1,
497
+ id,
498
+ status: 'prepared',
499
+ fromVersion: plan.fromVersion,
500
+ toVersion: plan.toVersion,
501
+ createdAt: new Date().toISOString(),
502
+ conflicts: plan.conflicts,
503
+ entries: [],
504
+ };
241
505
  for (const [index, entry] of plan.files.entries()) {
242
506
  let backup = null;
243
507
  if (entry.beforeHash !== null) {
@@ -245,63 +509,130 @@ function applyPlan(plan, { reauthorize } = {}) {
245
509
  const target = safeTarget(projectDir, backup);
246
510
  fs.mkdirSync(path.dirname(target), { recursive: true });
247
511
  fs.copyFileSync(safeTarget(projectDir, entry.file), target, fs.constants.COPYFILE_EXCL);
248
- if (fileHash(fs.readFileSync(target)) !== entry.beforeHash) throw new Error('File changed while backing up: ' + entry.file);
512
+ if (fileHash(fs.readFileSync(target)) !== entry.beforeHash)
513
+ throw new Error('File changed while backing up: ' + entry.file);
249
514
  }
250
515
  if (entry.backupLegacy) {
251
516
  let legacyBackup = entry.file + '.bak';
252
517
  let index = 1;
253
- while (fs.existsSync(safeTarget(projectDir, legacyBackup))) legacyBackup = entry.file + '.bak.' + index++;
254
- fs.copyFileSync(safeTarget(projectDir, entry.file), safeTarget(projectDir, legacyBackup), fs.constants.COPYFILE_EXCL);
518
+ while (fs.existsSync(safeTarget(projectDir, legacyBackup)))
519
+ legacyBackup = entry.file + '.bak.' + index++;
520
+ fs.copyFileSync(
521
+ safeTarget(projectDir, entry.file),
522
+ safeTarget(projectDir, legacyBackup),
523
+ fs.constants.COPYFILE_EXCL
524
+ );
255
525
  }
256
- receipt.entries.push({ file: entry.file, beforeHash: entry.beforeHash, afterHash: entry.afterHash, backup });
526
+ receipt.entries.push({
527
+ file: entry.file,
528
+ beforeHash: entry.beforeHash,
529
+ afterHash: entry.afterHash,
530
+ backup,
531
+ });
257
532
  }
258
533
  atomicWrite(projectDir, receiptFile, JSON.stringify(receipt, null, 2));
259
534
  for (const entry of plan.files) {
260
- if (currentHash(projectDir, entry.file) !== entry.beforeHash) throw new Error('File changed during update: ' + entry.file);
261
- atomicWrite(projectDir, entry.file, entry.content);
262
- if (currentHash(projectDir, entry.file) !== entry.afterHash) throw new Error('Written file verification failed: ' + entry.file);
535
+ if (currentHash(projectDir, entry.file) !== entry.beforeHash)
536
+ throw new Error('File changed during update: ' + entry.file);
537
+ if (entry.afterHash === null) fs.unlinkSync(safeTarget(projectDir, entry.file));
538
+ else atomicWrite(projectDir, entry.file, entry.content);
539
+ if (currentHash(projectDir, entry.file) !== entry.afterHash)
540
+ throw new Error('Written file verification failed: ' + entry.file);
263
541
  }
264
542
  receipt.status = 'committed';
265
543
  atomicWrite(projectDir, receiptFile, JSON.stringify(receipt, null, 2));
544
+ const removedFiles = plan.files
545
+ .filter((entry) => entry.afterHash === null)
546
+ .map((entry) => entry.file);
547
+ for (const file of removedFiles) pruneEmptyParents(projectDir, file);
266
548
  const manifest = readInstallManifest(safeTarget(projectDir, MANIFEST));
267
- return { manifest, receiptPath: path.join(projectDir, receiptFile), receiptId: id,
268
- changedFiles: plan.files.filter(entry => entry.kind !== 'manifest').map(entry => entry.file), conflicts: plan.conflicts };
549
+ return {
550
+ manifest,
551
+ receiptPath: path.join(projectDir, receiptFile),
552
+ receiptId: id,
553
+ changedFiles: plan.files
554
+ .filter((entry) => entry.kind !== 'manifest')
555
+ .map((entry) => entry.file),
556
+ removedFiles,
557
+ conflicts: plan.conflicts,
558
+ };
269
559
  } catch (error) {
270
560
  if (receipt) {
271
561
  const failures = rollback(projectDir, receipt);
272
562
  receipt.status = failures.length ? 'recovery-required' : 'rolled-back';
273
563
  receipt.failures = failures;
274
- try { atomicWrite(projectDir, receiptFile, JSON.stringify(receipt, null, 2)); } catch (writeError) {
564
+ try {
565
+ atomicWrite(projectDir, receiptFile, JSON.stringify(receipt, null, 2));
566
+ } catch (writeError) {
275
567
  error.message += '; recovery receipt could not be saved: ' + writeError.message;
276
568
  }
277
- error.message += '; ' + receipt.status + '. Recovery: bmad-plus update --restore ' + id + ' --directory "' + projectDir + '" --yes';
569
+ error.message +=
570
+ '; ' +
571
+ receipt.status +
572
+ '. Recovery: bmad-plus update --restore ' +
573
+ id +
574
+ ' --directory "' +
575
+ projectDir +
576
+ '" --yes';
278
577
  }
279
578
  throw error;
280
- } finally { release(); }
579
+ } finally {
580
+ release();
581
+ }
281
582
  }
282
583
 
283
584
  function restoreUpdate({ projectDir, receiptId }) {
284
585
  if (!/^[a-f0-9-]{36}$/.test(receiptId)) throw new Error('Invalid update receipt ID');
285
586
  const receiptFile = '.bmad/updates/' + receiptId + '/receipt.json';
286
587
  const receipt = JSON.parse(currentBytes(projectDir, receiptFile)?.toString('utf8') || 'null');
287
- if (!receipt || receipt.schemaVersion !== 1 || receipt.id !== receiptId || !Array.isArray(receipt.entries)) throw new Error('Invalid update receipt');
288
- const adapterPaths = new Set([DERIVED.targets.spine, ...DERIVED.targets.adapters.map(entry => entry.file)]);
588
+ if (
589
+ !receipt ||
590
+ receipt.schemaVersion !== 1 ||
591
+ receipt.id !== receiptId ||
592
+ !Array.isArray(receipt.entries)
593
+ )
594
+ throw new Error('Invalid update receipt');
595
+ const adapterPaths = new Set([
596
+ DERIVED.targets.spine,
597
+ ...DERIVED.targets.adapters.map((entry) => entry.file),
598
+ ]);
599
+ const isHash = (value) => typeof value === 'string' && /^[a-f0-9]{64}$/.test(value);
289
600
  for (const entry of receipt.entries) {
290
- if (typeof entry.file !== 'string' || !(entry.file === MANIFEST || isManagedFile(entry.file) || adapterPaths.has(entry.file)) ||
291
- !/^[a-f0-9]{64}$/.test(entry.afterHash) || (entry.beforeHash !== null && !/^[a-f0-9]{64}$/.test(entry.beforeHash))) throw new Error('Invalid recovery file entry');
601
+ // A null afterHash records a deleted file, which always has a backup.
602
+ if (
603
+ typeof entry.file !== 'string' ||
604
+ !(entry.file === MANIFEST || isManagedFile(entry.file) || adapterPaths.has(entry.file)) ||
605
+ !(
606
+ isHash(entry.afterHash) ||
607
+ (entry.afterHash === null && entry.file !== MANIFEST && isHash(entry.beforeHash))
608
+ ) ||
609
+ (entry.beforeHash !== null && !isHash(entry.beforeHash))
610
+ )
611
+ throw new Error('Invalid recovery file entry');
292
612
  safeTarget(projectDir, entry.file);
293
- if (entry.beforeHash === null ? entry.backup !== null :
294
- typeof entry.backup !== 'string' || !entry.backup.startsWith('.bmad/updates/' + receiptId + '/backups/') ||
295
- path.posix.normalize(entry.backup) !== entry.backup || entry.backup.includes('\\')) throw new Error('Invalid backup entry');
613
+ if (
614
+ entry.beforeHash === null
615
+ ? entry.backup !== null
616
+ : typeof entry.backup !== 'string' ||
617
+ !entry.backup.startsWith('.bmad/updates/' + receiptId + '/backups/') ||
618
+ path.posix.normalize(entry.backup) !== entry.backup ||
619
+ entry.backup.includes('\\')
620
+ )
621
+ throw new Error('Invalid backup entry');
296
622
  if (entry.backup) safeTarget(projectDir, entry.backup);
297
623
  }
298
- const manifestEntry = receipt.entries.find(entry => entry.file === MANIFEST);
624
+ const manifestEntry = receipt.entries.find((entry) => entry.file === MANIFEST);
299
625
  const installedHash = currentHash(projectDir, MANIFEST);
300
- if (!manifestEntry || (installedHash !== manifestEntry.beforeHash && installedHash !== manifestEntry.afterHash)) throw new Error('Installation changed since this receipt; recovery refused.');
626
+ if (
627
+ !manifestEntry ||
628
+ (installedHash !== manifestEntry.beforeHash && installedHash !== manifestEntry.afterHash)
629
+ )
630
+ throw new Error('Installation changed since this receipt; recovery refused.');
301
631
  const lockPath = safeTarget(projectDir, LOCK);
302
632
  if (fs.existsSync(lockPath)) {
303
633
  const lock = JSON.parse(fs.readFileSync(lockPath, 'utf8'));
304
- if (lock.receiptId !== receiptId || !Number.isInteger(lock.pid) || lock.pid <= 0) throw new Error('Another update owns the project lock.');
634
+ if (lock.receiptId !== receiptId || !Number.isInteger(lock.pid) || lock.pid <= 0)
635
+ throw new Error('Another update owns the project lock.');
305
636
  try {
306
637
  process.kill(lock.pid, 0);
307
638
  throw new Error('The recorded update process is still running.');
@@ -315,20 +646,43 @@ function restoreUpdate({ projectDir, receiptId }) {
315
646
  // Validate every required byte before starting a manual recovery.
316
647
  for (const entry of receipt.entries) {
317
648
  const current = currentHash(projectDir, entry.file);
318
- if (current !== entry.beforeHash && current !== entry.afterHash && current !== null) throw new Error('File changed since the update: ' + entry.file);
649
+ if (current !== entry.beforeHash && current !== entry.afterHash && current !== null)
650
+ throw new Error('File changed since the update: ' + entry.file);
319
651
  if (entry.beforeHash !== null) {
320
652
  const backup = currentBytes(projectDir, entry.backup);
321
- if (backup === null || fileHash(backup) !== entry.beforeHash) throw new Error('Backup is missing or corrupt: ' + entry.file);
653
+ if (backup === null || fileHash(backup) !== entry.beforeHash)
654
+ throw new Error('Backup is missing or corrupt: ' + entry.file);
322
655
  }
323
656
  }
324
657
  const failures = rollback(projectDir, receipt);
325
658
  receipt.status = failures.length ? 'recovery-required' : 'restored';
326
659
  receipt.failures = failures;
327
660
  atomicWrite(projectDir, receiptFile, JSON.stringify(receipt, null, 2));
328
- if (failures.length) throw new Error('Recovery incomplete: ' + failures.map(entry => entry.file + ': ' + entry.reason).join('; '));
329
- return { receiptPath: path.join(projectDir, receiptFile), restoredVersion: readInstallManifest(safeTarget(projectDir, MANIFEST)).version };
330
- } finally { release(); }
661
+ if (failures.length)
662
+ throw new Error(
663
+ 'Recovery incomplete: ' +
664
+ failures.map((entry) => entry.file + ': ' + entry.reason).join('; ')
665
+ );
666
+ return {
667
+ receiptPath: path.join(projectDir, receiptFile),
668
+ restoredVersion: readInstallManifest(safeTarget(projectDir, MANIFEST)).version,
669
+ };
670
+ } finally {
671
+ release();
672
+ }
331
673
  }
332
674
 
333
- module.exports = { planUpdate, applyPlan, restoreUpdate, acquireProjectLock, evaluateUpdateReadiness,
334
- collectManagedFiles, inventoryOf, MANIFEST, LOCK };
675
+ module.exports = {
676
+ planUpdate,
677
+ applyPlan,
678
+ restoreUpdate,
679
+ acquireProjectLock,
680
+ inspectProjectLock,
681
+ atomicWrite,
682
+ evaluateUpdateReadiness,
683
+ collectManagedFiles,
684
+ inventoryOf,
685
+ MANIFEST,
686
+ LOCK,
687
+ STALE_LOCK_MS,
688
+ };