bmad-plus 0.14.0 → 0.16.0

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 (60) hide show
  1. package/CHANGELOG.md +52 -0
  2. package/README.md +34 -15
  3. package/SECURITY.md +71 -0
  4. package/THIRD-PARTY-LICENSES.md +349 -0
  5. package/osint-agent-package/README.md +1 -1
  6. package/package.json +9 -3
  7. package/readme-international/README.de.md +14 -8
  8. package/readme-international/README.es.md +15 -9
  9. package/readme-international/README.fr.md +14 -8
  10. package/src/bmad-plus/agents/agent-architect-dev/SKILL.md +11 -13
  11. package/src/bmad-plus/agents/agent-orchestrator/SKILL.md +147 -8
  12. package/src/bmad-plus/agents/agent-quality/SKILL.md +41 -11
  13. package/src/bmad-plus/data/role-triggers.yaml +19 -0
  14. package/src/bmad-plus/module-help.csv +1 -0
  15. package/src/bmad-plus/module.yaml +1 -0
  16. package/src/bmad-plus/packs/pack-dev-studio/categories/implementation/create-story.md +3 -1
  17. package/src/bmad-plus/packs/pack-dev-studio/categories/implementation/dev-story-checklist.md +2 -0
  18. package/src/bmad-plus/packs/pack-dev-studio/categories/implementation/dev-story.md +5 -1
  19. package/src/bmad-plus/packs/pack-memory/README.md +29 -4
  20. package/src/bmad-plus/packs/pack-memory/memory-orchestrator.md +21 -1
  21. package/src/bmad-plus/packs/pack-memory/shared/karpathy-guardrails.md +3 -3
  22. package/src/bmad-plus/packs/pack-memory/shared/memory-protocol.md +27 -3
  23. package/src/bmad-plus/packs/pack-memory/zecher-agent.md +18 -2
  24. package/src/bmad-plus/skills/bmad-plus-autopilot/SKILL.md +47 -10
  25. package/src/bmad-plus/skills/bmad-plus-parallel/SKILL.md +17 -3
  26. package/src/bmad-plus/skills/bmad-plus-sync/SKILL.md +76 -67
  27. package/src/bmad-plus/skills/bmad-plus-uat/SKILL.md +144 -0
  28. package/src/bmad-plus/skills/bmad-plus-uat/ref/uat-results.schema.json +60 -0
  29. package/src/bmad-plus/skills/bmad-plus-uat/ref/uat-spec.schema.json +121 -0
  30. package/src/bmad-plus/skills/bmad-plus-uat/ref/uat-triage.schema.json +60 -0
  31. package/src/bmad-plus/skills/bmad-plus-uat/template/page.html +552 -0
  32. package/src/bmad-plus/skills/bmad-plus-uat/template/strings.json +362 -0
  33. package/tools/build/check-install-contract.js +202 -4
  34. package/tools/build/generate.js +16 -0
  35. package/tools/build/generated-adapters/.codex/AGENTS.md +1 -1
  36. package/tools/build/generated-adapters/.cursor/rules/bmad-plus.mdc +1 -1
  37. package/tools/build/generated-adapters/.opencode/AGENTS.md +1 -1
  38. package/tools/build/generated-adapters/AGENTS.md +1 -1
  39. package/tools/build/generated-adapters/CLAUDE.md +1 -1
  40. package/tools/build/generated-adapters/CONVENTIONS.md +1 -1
  41. package/tools/build/generated-adapters/GEMINI.md +1 -1
  42. package/tools/cli/bmad-plus-cli.js +15 -12
  43. package/tools/cli/commands/doctor.js +1 -0
  44. package/tools/cli/commands/install.js +21 -2
  45. package/tools/cli/commands/memory-journal-cmd.js +119 -19
  46. package/tools/cli/commands/nexus.js +111 -0
  47. package/tools/cli/commands/uat.js +389 -0
  48. package/tools/cli/lib/README-memory-journal.md +19 -8
  49. package/tools/cli/lib/installation-health.js +6 -0
  50. package/tools/cli/lib/memory-journal.js +0 -0
  51. package/tools/cli/lib/memory-outcomes.js +293 -0
  52. package/tools/cli/lib/memory-store.js +139 -0
  53. package/tools/cli/lib/nexus-process.js +377 -0
  54. package/tools/cli/lib/nexus.js +1532 -0
  55. package/tools/cli/lib/pack-copy.js +39 -11
  56. package/tools/cli/lib/packs.js +17 -3
  57. package/tools/cli/lib/uat.js +869 -0
  58. package/tools/maintain/upstream-candidate.js +456 -0
  59. package/tools/release/publication-content.js +3 -1
  60. package/tools/release/supply-chain.js +282 -0
@@ -0,0 +1,293 @@
1
+ 'use strict';
2
+
3
+ const crypto = require('node:crypto');
4
+ const nexus = require('./nexus');
5
+ const store = require('./memory-store');
6
+
7
+ const OUTCOMES = '.bmad/memory/outcomes.ndjson';
8
+ const BOOST = 0.25;
9
+ const digest = (value) =>
10
+ crypto
11
+ .createHash('sha256')
12
+ .update(typeof value === 'string' ? value : JSON.stringify(value))
13
+ .digest('hex');
14
+ const equal = (a, b) => JSON.stringify(a) === JSON.stringify(b);
15
+
16
+ function requiredText(value, label, maximum = 1000) {
17
+ if (typeof value !== 'string' || !value.trim() || value.length > maximum)
18
+ throw new Error(label + ' must be non-empty bounded text.');
19
+ return value;
20
+ }
21
+ function scopePath(value) {
22
+ if (
23
+ typeof value !== 'string' ||
24
+ !value ||
25
+ value.includes('\\') ||
26
+ value.startsWith('/') ||
27
+ value.includes(':') ||
28
+ value.split('/').some((p) => !p || p === '.' || p === '..')
29
+ ) {
30
+ throw new Error('Outcome scope must be a normalized relative path.');
31
+ }
32
+ return value;
33
+ }
34
+ function within(child, parent) {
35
+ return child === parent || child.startsWith(parent + '/');
36
+ }
37
+ function overlaps(a, b) {
38
+ return a.some((left) => b.some((right) => within(left, right) || within(right, left)));
39
+ }
40
+ function scopes(values) {
41
+ if (!Array.isArray(values) || !values.length || values.length > 30)
42
+ throw new Error('An explicit non-empty project scope is required.');
43
+ return [...new Set(values.map(scopePath))].sort();
44
+ }
45
+ function sourceSection(baseDir, input) {
46
+ if (
47
+ !input ||
48
+ !/^(?:\.agents|\.bmad)\/memory\/(?:decisions|lessons|patterns)\.md$/.test(input.file)
49
+ )
50
+ throw new Error(
51
+ 'Outcome memory source must be a project decisions, lessons or patterns Markdown file.'
52
+ );
53
+ const heading = requiredText(input.heading, 'Memory heading', 300);
54
+ const sections = require('./memory-journal')._internal.splitSections(
55
+ store.readText(baseDir, input.file),
56
+ input.file
57
+ );
58
+ const matches = sections.filter((section) => section.heading === heading);
59
+ if (matches.length !== 1) throw new Error('Memory heading must identify exactly one section.');
60
+ const text = (matches[0].heading + '\n' + matches[0].text).trim();
61
+ return { file: input.file, heading, textSha256: digest(text) };
62
+ }
63
+
64
+ /** Read-only adapter: Nexus owns the actual verification and freshness policy. */
65
+ function acceptedSource(baseDir, runId, taskId) {
66
+ const run = nexus.inspectRun(baseDir, runId);
67
+ if (run.lock || run.recoveryLock)
68
+ throw new Error(
69
+ 'Nexus state is being written or recovered; retry observation after its owner finishes.'
70
+ );
71
+ const task = run.tasks.find((item) => item.id === taskId);
72
+ const observation = run.observations.find((item) => item.taskId === taskId);
73
+ const attempt = task?.attempts.at(-1);
74
+ if (
75
+ !task ||
76
+ task.integration !== 'accepted' ||
77
+ !observation?.evidenceEligible ||
78
+ observation.stale ||
79
+ attempt?.execution !== 'completed' ||
80
+ attempt.verification.status !== 'passed'
81
+ ) {
82
+ throw new Error(
83
+ 'Outcome source is not an accepted task with current independently verified evidence.'
84
+ );
85
+ }
86
+ if (
87
+ task.integrationReceipt?.attemptId !== attempt.id ||
88
+ !equal(task.integrationReceipt.artifactHashes, attempt.verification.artifactHashes)
89
+ ) {
90
+ throw new Error('Nexus acceptance does not bind the current attempt and artifacts.');
91
+ }
92
+ const definition = run.plan.tasks.find((item) => item.id === taskId);
93
+ return {
94
+ kind: 'nexus',
95
+ runId,
96
+ taskId,
97
+ attemptId: attempt.id,
98
+ verificationId: attempt.verification.id,
99
+ verificationSha256: digest(attempt.verification),
100
+ acceptedAt: task.acceptedAt,
101
+ artifactHashes: task.integrationReceipt.artifactHashes,
102
+ resourceHashes: definition.resourceHashes,
103
+ scope: definition.scope,
104
+ objective: definition.objective,
105
+ baseline: attempt.baseline || null,
106
+ };
107
+ }
108
+
109
+ function readOutcomes(baseDir) {
110
+ const text = store.readText(baseDir, OUTCOMES, { missing: true });
111
+ if (text === null) return [];
112
+ if (text && !text.endsWith('\n'))
113
+ throw new Error('Outcome store has an incomplete final record.');
114
+ const records = text
115
+ .split('\n')
116
+ .filter(Boolean)
117
+ .map((line) => JSON.parse(line));
118
+ if (records.length > 1000) throw new Error('Outcome store exceeds 1000 records.');
119
+ const seen = new Set();
120
+ const attempts = new Set();
121
+ for (const record of records) {
122
+ const { id, ...body } = record;
123
+ if (
124
+ body.schemaVersion !== 1 ||
125
+ body.kind !== 'accepted-outcome' ||
126
+ id !== digest(body) ||
127
+ seen.has(id)
128
+ )
129
+ throw new Error('Outcome receipt integrity or uniqueness check failed.');
130
+ if (
131
+ !Array.isArray(record.supersedes) ||
132
+ !Array.isArray(record.contradicts) ||
133
+ [...record.supersedes, ...record.contradicts].some((ref) => !seen.has(ref))
134
+ )
135
+ throw new Error('Outcome relationships must identify earlier receipts.');
136
+ const attemptKey = [record.source?.runId, record.source?.taskId, record.source?.attemptId].join(
137
+ '\0'
138
+ );
139
+ if (!record.source?.attemptId || attempts.has(attemptKey))
140
+ throw new Error('Duplicate or missing outcome attempt identity.');
141
+ attempts.add(attemptKey);
142
+ seen.add(id);
143
+ }
144
+ return records;
145
+ }
146
+
147
+ function observeOutcome(baseDir, input, { now } = {}) {
148
+ if (!input || typeof input !== 'object') throw new Error('Outcome input is required.');
149
+ if (typeof now !== 'string' || !Number.isFinite(Date.parse(now)))
150
+ throw new Error('Outcome timestamp must be injected as an ISO string.');
151
+ return store.withMemoryLock(baseDir, () => {
152
+ const records = readOutcomes(baseDir);
153
+ const source = acceptedSource(
154
+ baseDir,
155
+ requiredText(input.runId, 'Run ID'),
156
+ requiredText(input.taskId, 'Task ID')
157
+ );
158
+ if (
159
+ records.some(
160
+ (record) =>
161
+ record.source.runId === source.runId &&
162
+ record.source.taskId === source.taskId &&
163
+ record.source.attemptId === source.attemptId
164
+ )
165
+ )
166
+ throw new Error('Duplicate outcome: this task attempt already supplied a receipt.');
167
+ const memory = sourceSection(baseDir, input.memory);
168
+ const scope = scopes(input.scope);
169
+ if (scope.some((item) => !source.scope.some((parent) => within(item, parent))))
170
+ throw new Error('Outcome scope exceeds the accepted task scope.');
171
+ const relationships = {};
172
+ for (const key of ['supersedes', 'contradicts']) {
173
+ const values = input[key] || [];
174
+ if (!Array.isArray(values) || values.length > 30 || new Set(values).size !== values.length)
175
+ throw new Error('Outcome relationships must be a unique bounded receipt-ID list.');
176
+ for (const id of values) {
177
+ const prior = records.find((record) => record.id === id);
178
+ if (!prior || !overlaps(scope, prior.scope))
179
+ throw new Error('Related outcome must exist in the same applicable project scope.');
180
+ }
181
+ relationships[key] = [...values].sort();
182
+ }
183
+ if (relationships.supersedes.some((id) => relationships.contradicts.includes(id)))
184
+ throw new Error('A receipt cannot be both superseded and contradicted.');
185
+ const body = {
186
+ schemaVersion: 1,
187
+ kind: 'accepted-outcome',
188
+ ts: now,
189
+ memory,
190
+ scope,
191
+ source,
192
+ interpretation: requiredText(input.interpretation, 'Lesson interpretation', 2000),
193
+ verification: { status: 'passed', acceptance: 'accepted', lessonCausality: 'unmeasured' },
194
+ ...relationships,
195
+ };
196
+ // Re-read after loading relationships/Markdown; do not persist a torn source observation.
197
+ if (
198
+ !equal(source, acceptedSource(baseDir, source.runId, source.taskId)) ||
199
+ !equal(memory, sourceSection(baseDir, input.memory))
200
+ )
201
+ throw new Error('Outcome sources changed while being observed.');
202
+ if (records.length >= 1000) throw new Error('Outcome store is full; archive it explicitly.');
203
+ return store.appendRecord(baseDir, OUTCOMES, { id: digest(body), ...body });
204
+ });
205
+ }
206
+
207
+ function inspectOutcomes(baseDir, { contextScope } = {}) {
208
+ const selectedScope = contextScope === undefined ? null : scopes(contextScope);
209
+ const records = readOutcomes(baseDir);
210
+ const superseded = new Set(records.flatMap((record) => record.supersedes));
211
+ const contradictory = new Set();
212
+ for (const record of records.filter((record) => !superseded.has(record.id))) {
213
+ for (const other of record.contradicts) {
214
+ if (!superseded.has(other)) {
215
+ contradictory.add(record.id);
216
+ contradictory.add(other);
217
+ }
218
+ }
219
+ }
220
+ const conflictingAnchors = new Set(
221
+ records
222
+ .filter((record) => contradictory.has(record.id))
223
+ .map((record) => record.memory.file + '\0' + record.memory.heading)
224
+ );
225
+ return records.map((record) => {
226
+ let reason = null;
227
+ if (superseded.has(record.id)) reason = 'superseded';
228
+ else if (conflictingAnchors.has(record.memory.file + '\0' + record.memory.heading))
229
+ reason = 'contradictory';
230
+ else if (selectedScope && !overlaps(record.scope, selectedScope))
231
+ reason = 'outside-query-scope';
232
+ else {
233
+ try {
234
+ if (!equal(sourceSection(baseDir, record.memory), record.memory))
235
+ reason = 'stale-memory-section';
236
+ else if (
237
+ !equal(acceptedSource(baseDir, record.source.runId, record.source.taskId), record.source)
238
+ )
239
+ reason = 'stale-outcome-source';
240
+ } catch (error) {
241
+ reason = 'unavailable-or-ineligible-source: ' + error.message;
242
+ }
243
+ }
244
+ return { ...record, eligible: reason === null, reason };
245
+ });
246
+ }
247
+
248
+ function rankWithEvidence(baseDir, candidates, { contextScope } = {}) {
249
+ const status = inspectOutcomes(baseDir, { contextScope: scopes(contextScope) });
250
+ const key = (value) => value.file + '\0' + value.heading;
251
+ const results = [];
252
+ for (const entry of candidates) {
253
+ const matching =
254
+ entry.kind === 'note'
255
+ ? status.filter(
256
+ (record) => key(record.memory) === key({ file: entry.sourceFile, heading: entry.ref })
257
+ )
258
+ : [];
259
+ if (!matching.length) {
260
+ results.push({ ...entry, evidence: { status: 'unverified', receiptIds: [], boost: 0 } });
261
+ continue;
262
+ }
263
+ // A stale, contradicted or superseded anchor must not silently fall back to an authoritative note.
264
+ const eligible = matching.filter(
265
+ (record) => record.eligible && record.memory.textSha256 === digest(entry.text)
266
+ );
267
+ if (!eligible.length) continue;
268
+ const reward = require('./memory-journal').computeReward({ acceptance: true });
269
+ results.push({
270
+ ...entry,
271
+ lexicalScore: entry.score,
272
+ score: entry.score * (1 + BOOST * reward),
273
+ evidence: {
274
+ status: 'accepted-current',
275
+ receiptIds: eligible.map((record) => record.id),
276
+ reward,
277
+ boost: BOOST,
278
+ lessonCausality: 'unmeasured',
279
+ },
280
+ });
281
+ }
282
+ return results;
283
+ }
284
+
285
+ module.exports = {
286
+ observeOutcome,
287
+ inspectOutcomes,
288
+ readOutcomes,
289
+ rankWithEvidence,
290
+ acceptedSource,
291
+ OUTCOMES,
292
+ BOOST,
293
+ };
@@ -0,0 +1,139 @@
1
+ 'use strict';
2
+
3
+ const fs = require('node:fs');
4
+ const path = require('node:path');
5
+ const crypto = require('node:crypto');
6
+ const os = require('node:os');
7
+
8
+ const DIRECTORY = '.bmad/memory';
9
+ const LOCK = DIRECTORY + '/writer.lock';
10
+ const MAX_BYTES = 2 * 1024 * 1024;
11
+ const held = new Set();
12
+
13
+ function safeFile(baseDir, relative) {
14
+ const root = path.resolve(baseDir);
15
+ if (
16
+ typeof relative !== 'string' ||
17
+ !relative ||
18
+ relative.includes(':') ||
19
+ path.isAbsolute(relative) ||
20
+ relative.split(/[\\/]/).some((p) => !p || p === '..' || p === '.')
21
+ ) {
22
+ throw new Error('Memory path must be a normalized project-relative path.');
23
+ }
24
+ const target = path.resolve(root, relative);
25
+ if (!target.startsWith(root + path.sep)) throw new Error('Memory path escapes project.');
26
+ let current = path.parse(target).root;
27
+ for (const part of target.slice(current.length).split(path.sep)) {
28
+ current = path.join(current, part);
29
+ let stat;
30
+ try {
31
+ stat = fs.lstatSync(current);
32
+ } catch (error) {
33
+ if (error.code === 'ENOENT') continue;
34
+ throw error;
35
+ }
36
+ if (stat.isSymbolicLink())
37
+ throw new Error('Memory paths cannot follow symbolic links or junctions.');
38
+ if (current !== target && !stat.isDirectory())
39
+ throw new Error('Memory parent is not a directory.');
40
+ }
41
+ return target;
42
+ }
43
+
44
+ function readText(baseDir, relative, { missing = false } = {}) {
45
+ const file = safeFile(baseDir, relative);
46
+ let stat;
47
+ try {
48
+ stat = fs.statSync(file);
49
+ } catch (error) {
50
+ if (missing && error.code === 'ENOENT') return null;
51
+ throw error;
52
+ }
53
+ if (!stat.isFile() || stat.size > MAX_BYTES)
54
+ throw new Error('Memory source must be a regular file of at most 2 MiB.');
55
+ const buffer = fs.readFileSync(file);
56
+ if (buffer.length > MAX_BYTES) throw new Error('Memory source exceeded 2 MiB while reading.');
57
+ const text = buffer.toString('utf8');
58
+ if (!Buffer.from(text).equals(buffer)) throw new Error('Memory source must contain valid UTF-8.');
59
+ return text;
60
+ }
61
+
62
+ /** Synchronous, process-shared mutex; stale owners require explicit operator inspection. */
63
+ function withMemoryLock(baseDir, operation) {
64
+ const root = path.resolve(baseDir);
65
+ if (held.has(root)) return operation(); // Nested synchronous legacy score/promotion writes.
66
+ const file = safeFile(root, LOCK);
67
+ fs.mkdirSync(path.dirname(file), { recursive: true });
68
+ const owner = JSON.stringify({
69
+ pid: process.pid,
70
+ hostname: os.hostname(),
71
+ token: crypto.randomUUID(),
72
+ });
73
+ let descriptor;
74
+ for (let attempt = 0; attempt < 200; attempt++) {
75
+ try {
76
+ descriptor = fs.openSync(safeFile(root, LOCK), 'wx');
77
+ break;
78
+ } catch (error) {
79
+ if (error.code !== 'EEXIST') throw error;
80
+ Atomics.wait(new Int32Array(new SharedArrayBuffer(4)), 0, 0, 10);
81
+ }
82
+ }
83
+ if (descriptor === undefined)
84
+ throw new Error(
85
+ 'Memory writer lock is occupied. Inspect writer.lock and its owner; do not remove a live lock.'
86
+ );
87
+ try {
88
+ fs.writeFileSync(descriptor, owner);
89
+ fs.fsyncSync(descriptor);
90
+ held.add(root);
91
+ const result = operation();
92
+ if (result && typeof result.then === 'function')
93
+ throw new Error('Memory lock operations must be synchronous.');
94
+ return result;
95
+ } finally {
96
+ held.delete(root);
97
+ fs.closeSync(descriptor);
98
+ if (readText(root, LOCK, { missing: true }) === owner) fs.unlinkSync(safeFile(root, LOCK));
99
+ }
100
+ }
101
+
102
+ function appendRecord(baseDir, relative, record) {
103
+ return withMemoryLock(baseDir, () => {
104
+ const file = safeFile(baseDir, relative);
105
+ const prior = readText(baseDir, relative, { missing: true }) || '';
106
+ const line = JSON.stringify(record) + '\n';
107
+ if (Buffer.byteLength(prior + line) > MAX_BYTES)
108
+ throw new Error('Memory store is full; archive it explicitly before adding records.');
109
+ if (prior && !prior.endsWith('\n'))
110
+ throw new Error('Memory store has an incomplete final line; inspect it before writing.');
111
+ const descriptor = fs.openSync(file, 'a');
112
+ try {
113
+ fs.writeFileSync(descriptor, line);
114
+ fs.fsyncSync(descriptor);
115
+ } finally {
116
+ fs.closeSync(descriptor);
117
+ }
118
+ return record;
119
+ });
120
+ }
121
+
122
+ function writeJson(baseDir, relative, value) {
123
+ return withMemoryLock(baseDir, () => {
124
+ const file = safeFile(baseDir, relative);
125
+ const text = JSON.stringify(value, null, 2) + '\n';
126
+ if (Buffer.byteLength(text) > MAX_BYTES) throw new Error('Memory store exceeds 2 MiB.');
127
+ const temporary = safeFile(baseDir, relative + '.' + crypto.randomUUID() + '.tmp');
128
+ const descriptor = fs.openSync(temporary, 'wx');
129
+ try {
130
+ fs.writeFileSync(descriptor, text);
131
+ fs.fsyncSync(descriptor);
132
+ } finally {
133
+ fs.closeSync(descriptor);
134
+ }
135
+ fs.renameSync(temporary, file);
136
+ });
137
+ }
138
+
139
+ module.exports = { withMemoryLock, appendRecord, writeJson, readText, safeFile, LOCK, MAX_BYTES };