bmad-method-test-architecture-enterprise 2.0.3 → 2.0.4-next.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.
@@ -31,7 +31,7 @@
31
31
  "name": "bmad-method-test-architecture-enterprise",
32
32
  "source": "./",
33
33
  "description": "Master Test Architect module for quality strategy, test generation in red and expand modes with automatic run-and-heal, combined framework and CI setup, CI/CD quality gates, and structured testing education. The CI and ATDD commands remain available with existing customizations. Part of the BMad Method ecosystem.",
34
- "version": "2.0.3",
34
+ "version": "2.0.4-next.1",
35
35
  "author": {
36
36
  "name": "Murat K Ozcan (TEA Creator) & Brian (BMad) Madison"
37
37
  },
@@ -0,0 +1,272 @@
1
+ #!/usr/bin/env node
2
+ 'use strict';
3
+
4
+ const fs = require('node:fs');
5
+ const path = require('node:path');
6
+ const { randomUUID } = require('node:crypto');
7
+ const { Command } = require('commander');
8
+ const { addAgentOptions, agentOptions, projectPath, resolveWorkflowSkill, headlessPrompt, runWithEvidence } = require('./lib/workflow-cli');
9
+ const { artifactPath, parseResult, frontmatter } = require('./lib/automate-result');
10
+ const { resolveGenerationConfig, generationRequest } = require('./lib/automate-prompt');
11
+
12
+ const NAME = 'tea-automate';
13
+ const MODES = ['red', 'expand'];
14
+ const OPERATIONS = ['create', 'resume', 'validate', 'edit'];
15
+
16
+ function collect(value, previous) {
17
+ return [...previous, value];
18
+ }
19
+
20
+ function safeTarget(root, value) {
21
+ const realRoot = fs.realpathSync(root);
22
+ const target = fs.realpathSync(path.resolve(realRoot, value));
23
+ const relative = path.relative(realRoot, target);
24
+ if (relative === '..' || relative.startsWith(`..${path.sep}`) || path.isAbsolute(relative))
25
+ throw new Error(`target is outside the project: ${value}`);
26
+ return relative.split(path.sep).join('/') || '.';
27
+ }
28
+
29
+ function resultDestination(root, value, protectedFiles) {
30
+ const output = projectPath(root, value, '--json');
31
+ if (path.extname(output) !== '.json') throw new Error('--json must name a .json result separate from inputs');
32
+ const existing = fs.existsSync(output) ? fs.statSync(output, { bigint: true }) : null;
33
+ if (existing && !existing.isFile()) throw new Error('--json must name a regular file');
34
+ const canonical = existing ? fs.realpathSync(output) : output;
35
+ for (const file of protectedFiles.filter(Boolean)) {
36
+ if (typeof file === 'object') {
37
+ if (canonical === file.path || (existing && `${existing.dev}:${existing.ino}` === file.identity))
38
+ throw new Error('--json collides with a protected input or artifact');
39
+ continue;
40
+ }
41
+ const input = path.resolve(root, file);
42
+ if (!fs.existsSync(input)) continue;
43
+ const stat = fs.statSync(input, { bigint: true });
44
+ if (canonical === fs.realpathSync(input) || (existing && existing.dev === stat.dev && existing.ino === stat.ino))
45
+ throw new Error('--json collides with a protected input or artifact');
46
+ }
47
+ return output;
48
+ }
49
+
50
+ function freezeInputs(root, files) {
51
+ const protectedFiles = [];
52
+ const visited = new Set();
53
+ const visit = (file) => {
54
+ if (!fs.existsSync(file)) return;
55
+ const real = fs.realpathSync(file);
56
+ const stat = fs.statSync(real, { bigint: true });
57
+ const identity = `${stat.dev}:${stat.ino}`;
58
+ if (stat.isFile()) protectedFiles.push({ path: real, identity });
59
+ if (!stat.isDirectory() || visited.has(identity)) return;
60
+ visited.add(identity);
61
+ for (const child of fs.readdirSync(real)) visit(path.join(real, child));
62
+ };
63
+ for (const file of files.filter(Boolean)) visit(path.resolve(root, file));
64
+ return protectedFiles;
65
+ }
66
+
67
+ function selectMode(explicit, request, savedMode) {
68
+ if (savedMode) {
69
+ if (explicit && explicit !== savedMode) throw new Error(`checkpoint belongs to ${savedMode} mode; pass --mode ${savedMode}`);
70
+ return { mode: savedMode, selection: 'checkpoint' };
71
+ }
72
+ if (explicit) return { mode: explicit, selection: 'explicit' };
73
+ const red = /\b(?:red mode|ATDD|TDD red|write acceptance tests|acceptance test scaffolds|acceptance tests before implementation)\b/i.test(
74
+ request,
75
+ );
76
+ const expand =
77
+ /\b(?:expand mode|coverage expansion|expand (?:test )?coverage|tests for (?:existing|implemented) code|automation)\b/i.test(request);
78
+ if (red !== expand) return { mode: red ? 'red' : 'expand', selection: 'request' };
79
+ return { mode: 'expand', selection: 'entry-default' };
80
+ }
81
+
82
+ async function main(argv) {
83
+ const program = new Command();
84
+ program
85
+ .name(NAME)
86
+ .description(
87
+ 'Generate red acceptance scaffolds or expand coverage with the packaged merged Automate skill, including default run-and-heal.',
88
+ )
89
+ .argument('[request]', 'generation request; otherwise read UTF-8 text from standard input')
90
+ .option('--mode <mode>', 'generation mode (red|expand); infer from request or checkpoint, default expand')
91
+ .option('--operation <operation>', 'workflow operation (create|resume|validate|edit)', 'create')
92
+ .option('--project-root <dir>', 'consuming project directory', process.cwd())
93
+ .option('--story <path>', 'story with acceptance criteria; required for red Create')
94
+ .option('--target <path>', 'target file or directory inside the project (repeatable)', collect, [])
95
+ .option('--checkpoint <path>', 'exact saved artifact for Resume, Validate or Edit')
96
+ .option('--skill-root <dir>', 'explicit canonical Automate skill directory')
97
+ .option('--project-skill', 'use the project-installed canonical skill')
98
+ .option('--evidence-dir <dir>', 'directory for fresh per-attempt prompts, streams and manifests')
99
+ .option('--json <path>', 'write validated result JSON to this file')
100
+ .option('--allow-network', 'allow unrestricted outbound network access in the Codex workspace sandbox')
101
+ .exitOverride()
102
+ .configureOutput({ writeErr: () => {} });
103
+ addAgentOptions(program);
104
+ program.setOptionValueWithSource('retries', '0', 'default');
105
+ try {
106
+ program.parse(argv);
107
+ } catch (error) {
108
+ if (error.exitCode === 0) return 0;
109
+ process.stderr.write(`${NAME}: ${error.message}\n`);
110
+ return 2;
111
+ }
112
+ const options = program.opts();
113
+ let root;
114
+ let skillRoot;
115
+ let request;
116
+ let story;
117
+ let checkpoint;
118
+ let targets;
119
+ let resolvedConfig;
120
+ let modeSelection;
121
+ let checkpointBefore;
122
+ let protectedFiles;
123
+ try {
124
+ if (options.mode !== undefined && !MODES.includes(options.mode)) throw new Error(`--mode must be ${MODES.join(' or ')}`);
125
+ if (!OPERATIONS.includes(options.operation)) throw new Error(`--operation must be ${OPERATIONS.join(', ')}`);
126
+ if (options.allowNetwork && options.agent !== 'codex') throw new Error('--allow-network requires --agent codex');
127
+ root = fs.realpathSync(path.resolve(options.projectRoot));
128
+ agentOptions(options, root);
129
+ if (Number(options.retries) > 0 && options.operation !== 'validate')
130
+ throw new Error('generation operations require --retries 0; resume a retained checkpoint explicitly after interruption');
131
+ if (options.evidenceDir) projectPath(root, options.evidenceDir, '--evidence-dir');
132
+ if (options.json) projectPath(root, options.json, '--json');
133
+ if (!fs.statSync(root).isDirectory()) throw new Error('--project-root must be a directory');
134
+ if (options.skillRoot && options.projectSkill) throw new Error('--skill-root and --project-skill are mutually exclusive');
135
+ if (options.projectSkill) {
136
+ const candidates = ['.claude/skills', '.agents/skills', 'skills', '_bmad/tea/workflows/testarch'];
137
+ options.skillRoot = candidates
138
+ .map((directory) => path.join(root, directory, 'bmad-testarch-automate'))
139
+ .find((candidate) => fs.existsSync(path.join(candidate, 'SKILL.md')));
140
+ if (!options.skillRoot) throw new Error('project-installed bmad-testarch-automate skill is missing');
141
+ }
142
+ skillRoot = resolveWorkflowSkill({ projectRoot: root, skillName: 'bmad-testarch-automate', skillRoot: options.skillRoot });
143
+ if (!fs.existsSync(path.join(skillRoot, '..', 'bmod-tea', 'knowledge', 'tea-index.csv')))
144
+ throw new Error('the selected skill has no sibling bmod-tea knowledge base');
145
+ if (options.story) story = artifactPath(root, options.story, '--story');
146
+ if (options.checkpoint) checkpoint = artifactPath(root, options.checkpoint, '--checkpoint');
147
+ targets = options.target.map((target) => safeTarget(root, target));
148
+ protectedFiles = freezeInputs(root, [story, checkpoint, 'package.json', ...targets]);
149
+ if (options.json) resultDestination(root, options.json, protectedFiles);
150
+ if (options.operation !== 'create' && !checkpoint) throw new Error(`--checkpoint is required for ${options.operation}`);
151
+ if (options.operation === 'create' && checkpoint) throw new Error('--checkpoint requires Resume, Validate or Edit');
152
+ let savedMode;
153
+ if (checkpoint) {
154
+ checkpointBefore = frontmatter(path.join(root, checkpoint));
155
+ const saved = checkpointBefore.state;
156
+ if (options.operation === 'resume') {
157
+ if (story && (saved.cli_story === undefined ? !(saved.inputDocuments ?? []).includes(story) : saved.cli_story !== story))
158
+ throw new Error('Resume story differs from its saved scope');
159
+ if (targets.length > 0 && JSON.stringify(saved.cli_targets) !== JSON.stringify(targets))
160
+ throw new Error('Resume targets differ from its saved scope');
161
+ protectedFiles.push(...freezeInputs(root, [saved.cli_story, ...(saved.cli_targets ?? [])]));
162
+ if (options.json) resultDestination(root, options.json, protectedFiles);
163
+ }
164
+ savedMode = saved?.testMode ?? saved?.test_mode;
165
+ if (savedMode && !MODES.includes(savedMode)) throw new Error('checkpoint has an invalid generation mode');
166
+ if (!savedMode) savedMode = /(?:^|\/)atdd(?:\/|$)|(?:^|\/)atdd-checklist-/.test(checkpoint) ? 'red' : 'expand';
167
+ }
168
+ request = program.args[0] ?? (process.stdin.isTTY ? '' : new TextDecoder('utf-8', { fatal: true }).decode(fs.readFileSync(0)));
169
+ const selected = selectMode(options.mode, request, savedMode);
170
+ options.mode = selected.mode;
171
+ modeSelection = selected.selection;
172
+ if (options.mode === 'red' && options.operation === 'create' && !story) throw new Error('--story is required for red Create');
173
+ if (!request.trim() && !story && targets.length === 0 && !checkpoint)
174
+ throw new Error('supply a request, --story, --target or --checkpoint');
175
+ if (options.operation === 'edit' && !request.trim()) throw new Error('Edit requires instructions in the request or standard input');
176
+ resolvedConfig = resolveGenerationConfig(
177
+ root,
178
+ checkpoint ? path.join(root, checkpoint) : undefined,
179
+ options.operation,
180
+ skillRoot,
181
+ options.mode,
182
+ );
183
+ } catch (error) {
184
+ process.stderr.write(`${NAME}: ${error.message}\n`);
185
+ return 2;
186
+ }
187
+
188
+ const evidenceRoot = path.resolve(root, options.evidenceDir ?? path.join('_bmad-output', 'test-artifacts', 'automate-cli', randomUUID()));
189
+ const prepare = ({ attemptDir }) => {
190
+ const manifestPath = path.join(attemptDir, 'generation.json');
191
+ const requestId = randomUUID();
192
+ const prompt = headlessPrompt({
193
+ skillRoot,
194
+ projectRoot: root,
195
+ resolvedConfig,
196
+ operation: options.operation,
197
+ requestLines: generationRequest({
198
+ mode: options.mode,
199
+ operation: options.operation,
200
+ request,
201
+ story,
202
+ targets,
203
+ checkpoint,
204
+ manifestPath,
205
+ requestId,
206
+ settings: resolvedConfig.settings,
207
+ modeSelection,
208
+ }),
209
+ });
210
+ return { prompt, manifestPath, requestId, startedAtMs: Date.now() };
211
+ };
212
+ if (options.agent === 'none') {
213
+ process.stdout.write(`${prepare({ attemptDir: path.join(evidenceRoot, 'attempt-1') }).prompt}\n`);
214
+ return 0;
215
+ }
216
+ if (options.allowNetwork) options.agentArg = ['-c', 'sandbox_workspace_write.network_access=true', ...options.agentArg];
217
+ try {
218
+ const result = await runWithEvidence({
219
+ name: NAME,
220
+ projectRoot: root,
221
+ evidenceRoot,
222
+ options,
223
+ prepare,
224
+ capabilities: ['command-execution'],
225
+ validate: ({ manifestPath, startedAtMs, requestId, attemptDir }) =>
226
+ parseResult({
227
+ manifestPath,
228
+ projectRoot: root,
229
+ mode: options.mode,
230
+ operation: options.operation,
231
+ settings: resolvedConfig.settings,
232
+ startedAtMs,
233
+ requestId,
234
+ story,
235
+ targets,
236
+ selectedCheckpoint: checkpoint,
237
+ checkpointBefore,
238
+ evidenceDir: attemptDir,
239
+ }),
240
+ });
241
+ const payload = { ...result.value, modeSelection, evidenceDir: result.runDir, agent: options.agent };
242
+ const text = `${JSON.stringify(payload, null, 2)}\n`;
243
+ if (options.json) {
244
+ const output = resultDestination(root, options.json, [
245
+ ...protectedFiles,
246
+ payload.summaryPath,
247
+ ...payload.generatedFiles,
248
+ ...payload.executionReports,
249
+ payload.validationReportPath,
250
+ ]);
251
+ fs.mkdirSync(path.dirname(output), { recursive: true });
252
+ fs.writeFileSync(output, text);
253
+ }
254
+ process.stdout.write(text);
255
+ return ['failed', 'could not measure'].includes(payload.executionStatus) || payload.remainingFailures.length > 0 ? 1 : 0;
256
+ } catch (error) {
257
+ process.stderr.write(`${NAME}: ${error.message}\nEvidence: ${evidenceRoot}\n`);
258
+ return 3;
259
+ }
260
+ }
261
+
262
+ if (require.main === module)
263
+ main(process.argv)
264
+ .then((code) => {
265
+ process.exitCode = code;
266
+ })
267
+ .catch((error) => {
268
+ process.stderr.write(`${NAME}: ${error.message}\n`);
269
+ process.exitCode = 3;
270
+ });
271
+
272
+ module.exports = { main, MODES, OPERATIONS, selectMode };
@@ -35,7 +35,7 @@
35
35
  * here makes the local run and the CI run the same run. --model overrides it.
36
36
  *
37
37
  * The pinned values are aliases, not immutable snapshots: "sonnet" follows
38
- * Anthropic's current Sonnet and "gpt-5.6-sol" is a family slug. They hold the
38
+ * Anthropic's current Sonnet and "gpt-6.1-sol" is a family slug. They hold the
39
39
  * tier steady, not the exact weights. Pass a fully-qualified slug to --model
40
40
  * when a run has to be reproducible across model generations.
41
41
  *
@@ -343,7 +343,7 @@ const AGENT_ADAPTERS = {
343
343
  },
344
344
  codex: {
345
345
  command: 'codex',
346
- defaultModel: 'gpt-5.6-sol',
346
+ defaultModel: 'gpt-6.1-sol',
347
347
  modelFlags: ['-m', '--model'],
348
348
  // `codex exec` reads the prompt from stdin when no PROMPT arg is given.
349
349
  // --sandbox workspace-write grants read/write/exec inside cwd without
@@ -351,7 +351,9 @@ const AGENT_ADAPTERS = {
351
351
  // a workspace-write file write completes with no approval prompt and no
352
352
  // TTY, because approval is only for escalating past the sandbox. A caller
353
353
  // declaring read-only gets --sandbox read-only instead, which is the
354
- // vendor's own enforcement of that tier.
354
+ // vendor's own enforcement of that tier. Command execution does not
355
+ // grant network access by itself. A caller can opt in through `extra`;
356
+ // Codex grants unrestricted outbound access when that setting is true.
355
357
  // --skip-git-repo-check matters under --isolate, where the agent's cwd
356
358
  // is a fresh tmpdir with no .git.
357
359
  //