bmad-method-test-architecture-enterprise 3.1.0 → 3.3.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 (36) hide show
  1. package/.claude-plugin/marketplace.json +1 -1
  2. package/cli/automate.js +7 -0
  3. package/cli/evaluate.js +20 -0
  4. package/cli/examples/gitlab-ci.yaml +1 -1
  5. package/cli/examples/pr-test-review.yml +3 -3
  6. package/cli/lib/agent-adapters.js +46 -0
  7. package/cli/lib/automate-inventory.js +258 -25
  8. package/cli/lib/automate-prompt.js +34 -2
  9. package/cli/lib/automate-result.js +74 -2
  10. package/cli/lib/evaluate/configuration-experiment.js +3401 -0
  11. package/cli/lib/evaluate/experiments.js +1299 -0
  12. package/cli/lib/evaluate/retain-skill-artifacts.js +78 -0
  13. package/cli/lib/evaluate/test-design-corpus/clean/docs/epics/epic-9-last-sync-indicator.md +47 -0
  14. package/cli/lib/evaluate/test-design-corpus/ground-truth.json +222 -0
  15. package/cli/lib/evaluate/test-design-corpus/native-identity.json +4 -0
  16. package/cli/lib/evaluate/test-design-corpus/seeded/docs/epics/epic-7-offline-order-capture.md +75 -0
  17. package/cli/lib/evaluate/test-design-replay.js +452 -0
  18. package/cli/lib/run-agent.js +18 -2
  19. package/cli/lib/test-design-staged-config.js +60 -0
  20. package/cli/lib/test-design-staging.js +229 -0
  21. package/cli/lib/unit-runners.js +219 -0
  22. package/cli/lib/vitest-attempts-reporter.mjs +30 -0
  23. package/cli/test-design-runner.js +245 -11
  24. package/cli/unit-check.js +595 -0
  25. package/package.json +13 -3
  26. package/skills/bmad-tea-automate/red/checklist.md +1 -1
  27. package/skills/bmad-tea-automate/resources/run-and-heal.md +3 -1
  28. package/skills/bmad-tea-evaluate/SKILL.md +11 -0
  29. package/skills/bmad-tea-evaluate/references/configuration-experiments.md +37 -0
  30. package/skills/bmad-tea-evaluate/references/evaluator.md +2 -2
  31. package/skills/bmad-tea-evaluate/references/gaps.md +1 -1
  32. package/skills/bmad-tea-test-review/steps-c/step-03f-aggregate-scores.md +3 -3
  33. package/skills/bmod-tea/bmod.toml +1 -1
  34. package/skills/bmod-tea/help/help.md +1 -1
  35. package/skills/bmod-tea/knowledge/auth-session.md +25 -20
  36. package/skills/bmod-tea/knowledge/test-healing-patterns.md +2 -0
@@ -52,9 +52,10 @@
52
52
 
53
53
  const fs = require('node:fs');
54
54
  const path = require('node:path');
55
+ const { createHash, randomUUID } = require('node:crypto');
55
56
  const { Command } = require('commander');
56
57
 
57
- const { AGENT_ADAPTERS } = require('./lib/agent-adapters');
58
+ const { AGENT_ADAPTERS, resolveModel, agentCaptureTelemetry } = require('./lib/agent-adapters');
58
59
  const { runAgent } = require('./lib/run-agent');
59
60
  const { EXIT_CODES, classOfAgentError, failureClassForExit, vendorEnvironmentNames } = require('./lib/runner-exit-codes');
60
61
  const { readDesign, scoredRiskProjection } = require('./lib/test-design-parser');
@@ -81,7 +82,8 @@ const TEST_DESIGN_REQUEST_KEYS = {
81
82
  argument: { required: [], permitted: [] },
82
83
  option: {
83
84
  required: ['agent', 'design-path'],
84
- permitted: ['agent', 'agent-cmd', 'agent-arg', 'design-path', 'env-pass', 'model', 'timeout-ms'],
85
+ permitted: ['agent', 'agent-cmd', 'agent-arg', 'design-path', 'env-pass', 'model', 'timeout-ms', 'capture-path', 'capture-test-double'],
86
+ types: { 'capture-test-double': 'boolean' },
85
87
  },
86
88
  environment: { required: [], permitted: vendorEnvironmentNames() },
87
89
  stdin: { required: ['prompt'], permitted: ['prompt'] },
@@ -107,7 +109,7 @@ function readPrompt() {
107
109
  }
108
110
 
109
111
  /** Derive a projection only after the agent has finished its document. */
110
- function projectScoredRisks(cwd, relativeDesignPath) {
112
+ function designText(cwd, relativeDesignPath) {
111
113
  const relative = path.normalize(relativeDesignPath);
112
114
  if (path.isAbsolute(relative) || relative.startsWith(`..${path.sep}`) || relative === '..' || !relative.endsWith('.md')) {
113
115
  throw new Error(`--design-path must name a markdown file inside the working directory: ${JSON.stringify(relativeDesignPath)}`);
@@ -120,8 +122,13 @@ function projectScoredRisks(cwd, relativeDesignPath) {
120
122
  if (fromRoot === '..' || fromRoot.startsWith(`..${path.sep}`) || path.isAbsolute(fromRoot) || fs.lstatSync(designPath).isSymbolicLink()) {
121
123
  throw new Error(`design path escapes the working directory: ${JSON.stringify(relativeDesignPath)}`);
122
124
  }
123
- const text = fs.readFileSync(designPath, 'utf8');
125
+ return fs.readFileSync(designPath, 'utf8');
126
+ }
127
+ function projectScoredRisks(cwd, relativeDesignPath, { requireDeliverable = false } = {}) {
128
+ const text = designText(cwd, relativeDesignPath);
129
+ if (text === null) return null;
124
130
  const read = readDesign({ kind: 'text', value: text });
131
+ if (!read.ok && requireDeliverable) throw Object.assign(new Error(read.reason), { failureClass: read.failureClass });
125
132
  return scoredRiskProjection(read.ok ? read.design : { risks: [], text });
126
133
  }
127
134
 
@@ -136,6 +143,8 @@ function main(argv) {
136
143
  .option('--env-pass <NAME>', 'environment variable name allowed through to the agent (repeatable)', collect, [])
137
144
  .option('--model <name>', 'model to pin for this run; defaults to the adapter default')
138
145
  .option('--design-path <path>', 'staged design document path for a deterministic scored-risk projection')
146
+ .option('--capture-path <path>', 'opt-in source receipt and telemetry inside the workspace')
147
+ .option('--capture-test-double', 'label custom telemetry as a deterministic test double')
139
148
  .option('--timeout-ms <n>', 'agent wall-clock timeout in milliseconds', String(DEFAULT_TIMEOUT_MS));
140
149
 
141
150
  program.exitOverride();
@@ -151,6 +160,9 @@ function main(argv) {
151
160
  if (!Object.hasOwn(AGENT_ADAPTERS, options.agent)) {
152
161
  fail('environment-configuration', `unknown agent "${options.agent}"; expected one of ${Object.keys(AGENT_ADAPTERS).join(', ')}`);
153
162
  }
163
+ if (options.captureTestDouble && options.agent !== 'custom') {
164
+ fail('usage', '--capture-test-double requires --agent custom');
165
+ }
154
166
  // Tested on the raw string before the parse. Number.parseInt truncates, so
155
167
  // `--timeout-ms 20abc` became 20 and every run then timed out at twenty
156
168
  // milliseconds, and `--timeout-ms 1e9` became 1.
@@ -168,29 +180,251 @@ function main(argv) {
168
180
  }
169
181
 
170
182
  let stdout = '';
183
+ let source = null;
184
+ let captureFile = null;
185
+ let captureFd = null;
186
+ let captureIdentity = null;
187
+ let captureBytes = null;
188
+ let captureResult = null;
189
+ const reportCapture = () => {
190
+ if (captureResult) process.stdout.write(`${JSON.stringify({ sourceCapture: captureResult })}\n`);
191
+ };
192
+ const saveCapture = () => {
193
+ if (captureFd === null) return;
194
+ let integrityFailure = null;
195
+ try {
196
+ const held = fs.fstatSync(captureFd);
197
+ const named = fs.lstatSync(captureFile);
198
+ if (
199
+ !held.isFile() ||
200
+ held.nlink !== 1 ||
201
+ !named.isFile() ||
202
+ named.nlink !== 1 ||
203
+ held.dev !== captureIdentity.dev ||
204
+ held.ino !== captureIdentity.ino ||
205
+ named.dev !== held.dev ||
206
+ named.ino !== held.ino
207
+ )
208
+ integrityFailure = 'source capture path or link identity changed';
209
+ if (!integrityFailure && captureBytes !== null) {
210
+ if (held.size === captureBytes.length) {
211
+ const current = Buffer.alloc(held.size);
212
+ fs.readSync(captureFd, current, 0, current.length, 0);
213
+ if (!current.equals(captureBytes)) integrityFailure = 'source capture bytes changed';
214
+ } else integrityFailure = 'source capture bytes changed';
215
+ }
216
+ } catch (error) {
217
+ integrityFailure = `source capture identity could not be checked: ${error.message}`;
218
+ }
219
+ if (integrityFailure) {
220
+ const recovery = path.join(process.cwd(), `source-capture-recovery-${randomUUID()}.json`);
221
+ source.captureIntegrityViolation = { reason: integrityFailure, originalPath: captureFile, agentStatus: source.status };
222
+ source.status = 'failed';
223
+ const recoveryBytes = Buffer.from(`${JSON.stringify(source, null, 2)}\n`);
224
+ let recoveryFd = null;
225
+ let recoveryFailure = null;
226
+ try {
227
+ recoveryFd = fs.openSync(recovery, 'wx', 0o600);
228
+ fs.writeFileSync(recoveryFd, recoveryBytes);
229
+ fs.fsyncSync(recoveryFd);
230
+ } catch (error) {
231
+ recoveryFailure = error;
232
+ } finally {
233
+ for (const fd of [recoveryFd, captureFd]) {
234
+ if (fd === null) continue;
235
+ try {
236
+ fs.closeSync(fd);
237
+ } catch (error) {
238
+ recoveryFailure ??= error;
239
+ }
240
+ }
241
+ captureFd = null;
242
+ }
243
+ if (recoveryFailure) {
244
+ captureResult = {
245
+ status: 'unverified',
246
+ reason: integrityFailure,
247
+ recoveryError: recoveryFailure.message,
248
+ path: path.basename(recovery),
249
+ };
250
+ reportCapture();
251
+ fail(
252
+ 'environment-configuration',
253
+ `${integrityFailure}; recovery receipt could not be preserved at ${recovery}: ${recoveryFailure.message}`,
254
+ );
255
+ }
256
+ captureResult = {
257
+ status: 'rejected',
258
+ path: path.basename(recovery),
259
+ sha256: `sha256:${createHash('sha256').update(recoveryBytes).digest('hex')}`,
260
+ };
261
+ reportCapture();
262
+ fail('environment-configuration', `${integrityFailure}; source evidence preserved at ${recovery}`);
263
+ }
264
+ if (options.designPath) {
265
+ try {
266
+ const text = designText(process.cwd(), options.designPath);
267
+ source.artifactSha256 = text === null ? null : `sha256:${createHash('sha256').update(text).digest('hex')}`;
268
+ } catch {
269
+ source.artifactSha256 = null;
270
+ }
271
+ }
272
+ captureBytes = Buffer.from(`${JSON.stringify(source, null, 2)}\n`);
273
+ fs.ftruncateSync(captureFd, 0);
274
+ fs.writeSync(captureFd, captureBytes, 0, captureBytes.length, 0);
275
+ fs.fsyncSync(captureFd);
276
+ captureResult = {
277
+ status: 'verified',
278
+ path: path.basename(captureFile),
279
+ sha256: `sha256:${createHash('sha256').update(captureBytes).digest('hex')}`,
280
+ };
281
+ };
282
+ const captureDoubleTelemetry = (stderr) => {
283
+ if (!source || !options.captureTestDouble || options.agent !== 'custom') return;
284
+ const line = stderr.split('\n').find((line) => line.startsWith('TEA_EXPERIMENT_TEST_TELEMETRY:'));
285
+ if (line) {
286
+ try {
287
+ const telemetry = JSON.parse(line.slice('TEA_EXPERIMENT_TEST_TELEMETRY:'.length));
288
+ source.observed = telemetry.observed ?? source.observed;
289
+ source.usage = telemetry.usage ?? null;
290
+ source.cost = telemetry.cost ?? null;
291
+ } catch (error) {
292
+ (source.telemetryErrors ??= []).push(`test-double telemetry unavailable: ${error.message}`);
293
+ source.observed = { resolvedModel: null, appliedEffort: null };
294
+ source.usage = null;
295
+ source.cost = null;
296
+ }
297
+ }
298
+ };
299
+ if (options.capturePath) {
300
+ const relative = path.normalize(options.capturePath);
301
+ if (path.isAbsolute(relative) || relative === '..' || relative.startsWith(`..${path.sep}`))
302
+ fail('usage', '--capture-path must stay inside the workspace');
303
+ captureFile = path.join(process.cwd(), relative);
304
+ if (fs.existsSync(captureFile)) fail('usage', '--capture-path already exists');
305
+ let captureParent;
306
+ let workspaceRoot;
307
+ try {
308
+ captureParent = fs.realpathSync(path.dirname(captureFile));
309
+ workspaceRoot = fs.realpathSync(process.cwd());
310
+ } catch (error) {
311
+ fail('usage', `--capture-path parent cannot be resolved: ${error.message}`);
312
+ }
313
+ if (captureParent !== workspaceRoot) fail('usage', '--capture-path must be directly inside the workspace');
314
+ let requestedModel;
315
+ try {
316
+ requestedModel = options.captureTestDouble ? (options.model ?? null) : resolveModel(options.agent, options.model, options.agentArg);
317
+ } catch (error) {
318
+ fail(classOfAgentError(error), error.message);
319
+ }
320
+ try {
321
+ captureFd = fs.openSync(captureFile, 'wx+', 0o600);
322
+ captureIdentity = fs.fstatSync(captureFd);
323
+ source = {
324
+ kind: 'test-design-source-capture',
325
+ label: options.captureTestDouble ? 'deterministic-test-double' : 'native',
326
+ workspaceId: randomUUID(),
327
+ workspace: process.cwd(),
328
+ requested: {
329
+ agent: options.agent,
330
+ model: requestedModel,
331
+ },
332
+ inputSha256: `sha256:${createHash('sha256').update(prompt).digest('hex')}`,
333
+ startedAt: new Date().toISOString(),
334
+ endedAt: null,
335
+ status: 'dispatched',
336
+ observed: { resolvedModel: null, appliedEffort: null },
337
+ usage: null,
338
+ cost: null,
339
+ };
340
+ saveCapture();
341
+ } catch (error) {
342
+ let message = error.message;
343
+ if (captureFd !== null) {
344
+ try {
345
+ fs.closeSync(captureFd);
346
+ } catch (closeError) {
347
+ message += `; capture close failed: ${closeError.message}`;
348
+ }
349
+ captureFd = null;
350
+ }
351
+ fail('environment-configuration', `could not initialize source capture: ${message}`);
352
+ }
353
+ }
171
354
  try {
172
- ({ stdout } = runAgent(prompt, {
355
+ const answer = runAgent(prompt, {
173
356
  agent: options.agent,
174
357
  agentCommand: options.agentCmd,
175
358
  agentArgs: options.agentArg,
176
359
  envPass: options.envPass,
177
- model: options.model,
360
+ model: options.captureTestDouble && options.agent === 'custom' ? undefined : options.model,
178
361
  timeout,
179
362
  cwd: process.cwd(),
180
363
  capabilities: RUNNER_CAPABILITIES,
181
- }));
364
+ usageReport: Boolean(source) && options.agent !== 'custom',
365
+ captureTelemetry: Boolean(source) && options.agent !== 'custom',
366
+ });
367
+ stdout = answer.stdout;
368
+ if (source) {
369
+ source.observed = { resolvedModel: answer.telemetry?.resolvedModel ?? null, appliedEffort: answer.telemetry?.appliedEffort ?? null };
370
+ source.providerStdout = answer.sourceStdout ?? answer.stdout;
371
+ source.providerStderr = answer.sourceStderr ?? answer.stderr;
372
+ source.usage = answer.telemetry?.usage ?? null;
373
+ source.telemetryErrors = answer.telemetry?.errors ?? [];
374
+ const usd = source.usage?.costUsd;
375
+ if (usd !== undefined) source.cost = { currency: 'USD', components: { target: Number(usd), tools: null } };
376
+ captureDoubleTelemetry(answer.stderr);
377
+ source.status = 'completed';
378
+ source.processOutcome = { status: 'completed', exitCode: 0 };
379
+ source.endedAt = new Date().toISOString();
380
+ saveCapture();
381
+ }
182
382
  } catch (error) {
383
+ if (source) {
384
+ source.providerStdout = error.stdout ?? '';
385
+ source.providerStderr = error.stderr ?? '';
386
+ captureDoubleTelemetry(error.stderr ?? '');
387
+ if (options.agent !== 'custom') {
388
+ const parsed = agentCaptureTelemetry(options.agent, error.stdout ?? '', error.stderr ?? '');
389
+ source.usage = parsed.usage;
390
+ source.observed.resolvedModel = parsed.resolvedModel;
391
+ source.telemetryErrors = parsed.errors;
392
+ source.providerStdout = error.stdout ?? '';
393
+ source.providerStderr = error.stderr ?? '';
394
+ if (parsed.usage?.costUsd !== undefined)
395
+ source.cost = { currency: 'USD', components: { target: Number(parsed.usage.costUsd), tools: null } };
396
+ }
397
+ source.status = /timed out/.test(error.message) ? 'timeout' : /signal SIG(?:INT|TERM)/.test(error.message) ? 'canceled' : 'failed';
398
+ source.processOutcome = { status: source.status, exitCode: null };
399
+ source.endedAt = new Date().toISOString();
400
+ source.error = { code: error.code ?? null, message: error.message };
401
+ source.stdout = error.stdout ?? '';
402
+ source.stderr = error.stderr ?? '';
403
+ saveCapture();
404
+ }
405
+ reportCapture();
183
406
  fail(classOfAgentError(error), error.message);
184
407
  }
185
-
186
408
  if (options.designPath) {
187
409
  try {
188
- const projection = projectScoredRisks(process.cwd(), options.designPath);
189
- if (projection) process.stdout.write(`${JSON.stringify(projection)}\n`);
410
+ const projection = projectScoredRisks(process.cwd(), options.designPath, { requireDeliverable: Boolean(source) });
411
+ if (projection)
412
+ process.stdout.write(`${JSON.stringify(captureResult ? { ...projection, sourceCapture: captureResult } : projection)}\n`);
413
+ else if (source)
414
+ throw Object.assign(new Error('the native design deliverable is absent'), { failureClass: 'environment-missing-artifact' });
190
415
  } catch (error) {
416
+ if (source) {
417
+ source.agentStatus = source.status;
418
+ source.status = 'failed';
419
+ source.failureClass = error.failureClass ?? 'environment-parser';
420
+ source.error = { code: 'RUNNER_PROJECTION_FAILED', message: error.message };
421
+ saveCapture();
422
+ }
423
+ reportCapture();
191
424
  fail('environment-parser', `could not project scored risks: ${error.message}`);
192
425
  }
193
- }
426
+ } else reportCapture();
427
+ if (captureFd !== null) fs.closeSync(captureFd);
194
428
 
195
429
  if (stdout.length > 0) process.stderr.write(stdout.endsWith('\n') ? stdout : `${stdout}\n`);
196
430
  }