bmad-method-test-architecture-enterprise 2.0.4-next.1 → 2.0.4-next.2

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.4-next.1",
34
+ "version": "2.0.4-next.2",
35
35
  "author": {
36
36
  "name": "Murat K Ozcan (TEA Creator) & Brian (BMad) Madison"
37
37
  },
package/README.md CHANGED
@@ -114,6 +114,7 @@ npm install --save-dev bmad-method-test-architecture-enterprise
114
114
  ```
115
115
 
116
116
  - [`tea-test-review`](https://bmad-code-org.github.io/bmad-method-test-architecture-enterprise/reference/tea-test-review-cli/) runs the review skill against changed tests and returns a gate verdict.
117
+ - [`tea-framework`](https://bmad-code-org.github.io/bmad-method-test-architecture-enterprise/reference/tea-framework-cli/) creates, resumes, validates, and edits test framework and CI setup with retained run evidence.
117
118
  - [`tea-trace`](https://bmad-code-org.github.io/bmad-method-test-architecture-enterprise/reference/tea-trace-cli/) maps requirements to existing evidence and returns a coverage gate.
118
119
  - [`tea-evaluate`](https://bmad-code-org.github.io/bmad-method-test-architecture-enterprise/reference/tea-evaluate-cli/) checks, runs, scores, and compares behavioral evaluations.
119
120
 
@@ -0,0 +1,190 @@
1
+ #!/usr/bin/env node
2
+ /** tea-framework drives the packaged framework and CI skill with durable run evidence. */
3
+ 'use strict';
4
+
5
+ const fs = require('node:fs');
6
+ const path = require('node:path');
7
+ const { Command } = require('commander');
8
+ const { resolveModel } = require('./lib/agent-adapters');
9
+ const { agentOptions, WorkflowError } = require('./lib/workflow-cli');
10
+ const { resolveTeaConfig } = require('./lib/resolve-tea-config');
11
+ const { addAgentOptions, resolveWorkflowSkill, runWithEvidence } = require('./lib/workflow-cli');
12
+ const {
13
+ SKILL_NAME,
14
+ SCOPES,
15
+ OPERATIONS,
16
+ projectPath,
17
+ readJournal,
18
+ prepareSetup,
19
+ setupPrompt,
20
+ inspectCompletion,
21
+ protectResultPath,
22
+ saveRequestSnapshot,
23
+ } = require('./lib/framework-setup');
24
+
25
+ function collect(value, previous) {
26
+ return [...previous, value];
27
+ }
28
+
29
+ function resultReport(result) {
30
+ return [
31
+ '# Framework and CI setup result',
32
+ '',
33
+ `Operation: ${result.operation}. Scope: ${result.scope}.`,
34
+ `Completed: ${result.completed ? 'yes' : 'no'}.`,
35
+ `Agent: ${result.agent}. Model: ${result.model ?? 'unspecified'}.`,
36
+ '',
37
+ '## Completion evidence',
38
+ '',
39
+ ...result.artifacts.map((file) => `- ${file}`),
40
+ '',
41
+ ...(result.issues.length > 0 ? ['## Unresolved work', '', ...result.issues.map((issue) => `- ${issue}`), ''] : []),
42
+ `Retained agent evidence: ${result.runDirectory}.`,
43
+ '',
44
+ 'Create completion verifies the skill journal and referenced outputs, then reruns the frozen test commands. Native verification logs and agent transcripts are retained. Validate findings remain report outcomes.',
45
+ '',
46
+ ].join('\n');
47
+ }
48
+
49
+ function main(argv = process.argv) {
50
+ const program = new Command();
51
+ program
52
+ .name('tea-framework')
53
+ .description('Create, resume, validate or edit test framework and CI setup using the packaged TEA skill.')
54
+ .option('--project-root <dir>', 'project whose framework and CI are being set up', process.cwd())
55
+ .option('--scope <scope>', `requested phases (${SCOPES.join('|')})`, 'framework')
56
+ .option('--operation <operation>', `requested operation (${OPERATIONS.join('|')})`, 'create')
57
+ .option('--input <path>', 'exact project artifact to edit or validate (repeatable)', collect, [])
58
+ .option('--instructions <path>', 'project file containing the setup request or requested edit')
59
+ .option('--skill-root <dir>', 'explicit skill copy; defaults to the packaged framework skill')
60
+ .option('--output <dir>', 'project directory for retained CLI reports and agent evidence')
61
+ .option('--json <path>', 'additional project-relative machine-readable result path')
62
+ .option('--allow-network', 'allow unrestricted outbound network access in the Codex workspace sandbox')
63
+ .option('--use-playwright-utils', 'enable Playwright utilities when applicable')
64
+ .option('--no-use-playwright-utils', 'disable Playwright utilities')
65
+ .option('--use-pactjs-utils', 'enable Pact utilities when applicable')
66
+ .option('--no-use-pactjs-utils', 'disable Pact utilities')
67
+ .exitOverride()
68
+ .configureOutput({ writeErr: () => {} });
69
+ addAgentOptions(program);
70
+ program.setOptionValueWithSource('retries', '0', 'default');
71
+ try {
72
+ program.parse(argv);
73
+ } catch (error) {
74
+ if (error.exitCode === 0) return 0;
75
+ process.stderr.write(`tea-framework: ${error.message}\n`);
76
+ return 2;
77
+ }
78
+ const options = program.opts();
79
+ let request;
80
+ let jsonPath;
81
+ try {
82
+ if (options.allowNetwork && options.agent !== 'codex') throw new Error('--allow-network requires --agent codex');
83
+ if (options.allowNetwork) options.agentArg = ['-c', 'sandbox_workspace_write.network_access=true', ...options.agentArg];
84
+ const projectRoot = fs.realpathSync(path.resolve(options.projectRoot));
85
+ agentOptions(options, projectRoot);
86
+ if (!fs.statSync(projectRoot).isDirectory()) throw new Error('--project-root must be a directory');
87
+ const skillRoot = resolveWorkflowSkill({ projectRoot, skillName: SKILL_NAME, skillRoot: options.skillRoot });
88
+ const config = resolveTeaConfig({ projectRoot, skillRoot, skillName: SKILL_NAME, flags: options });
89
+ request = prepareSetup({ projectRoot, skillRoot, config, options });
90
+ const evidenceRoot = projectPath(projectRoot, options.output ?? path.join(request.artifactsRoot, 'framework', 'cli-runs'));
91
+ jsonPath = options.json ? projectPath(projectRoot, options.json) : null;
92
+ if (
93
+ jsonPath &&
94
+ (path.extname(jsonPath) !== '.json' ||
95
+ jsonPath === request.journalPath ||
96
+ request.inputs.includes(jsonPath) ||
97
+ jsonPath === request.instructionsPath)
98
+ ) {
99
+ throw new Error('--json must name a .json result file separate from setup inputs and journal');
100
+ }
101
+ protectResultPath(request, jsonPath);
102
+ if (options.agent === 'none') {
103
+ process.stdout.write(`${setupPrompt(request)}\n`);
104
+ return 0;
105
+ }
106
+ const execution = runWithEvidence({
107
+ name: 'tea-framework',
108
+ projectRoot,
109
+ evidenceRoot,
110
+ options,
111
+ capabilities: ['command-execution'],
112
+ prepare: ({ attempt }) => ({ prompt: setupPrompt(request, { retry: attempt > 1 }), request }),
113
+ validate: ({ attemptDir }) => {
114
+ saveRequestSnapshot(request);
115
+ try {
116
+ protectResultPath(request, jsonPath, readJournal(projectPath(projectRoot, request.journalPath))?.data);
117
+ } catch (error) {
118
+ jsonPath = null;
119
+ throw new WorkflowError('usage', error.message, { cause: error });
120
+ }
121
+ return inspectCompletion(request, { evidenceDir: attemptDir, timeoutMs: Number(options.timeoutMs) });
122
+ },
123
+ });
124
+ const outcome = execution.value;
125
+ const result = {
126
+ schema_version: '0.1.0',
127
+ completed: outcome.completed,
128
+ scope: request.scope,
129
+ operation: request.savedOperation,
130
+ agent: options.agent,
131
+ model: resolveModel(options.agent, options.model, options.agentArg),
132
+ runDirectory: execution.runDir,
133
+ journal: request.journalPath,
134
+ artifacts: outcome.artifacts,
135
+ issues: outcome.issues,
136
+ verification: outcome.executions,
137
+ };
138
+ const serialized = `${JSON.stringify(result, null, 2)}\n`;
139
+ fs.writeFileSync(path.join(execution.runDir, 'result.json'), serialized);
140
+ fs.writeFileSync(path.join(execution.runDir, 'report.md'), resultReport(result));
141
+ if (jsonPath) {
142
+ protectResultPath(request, jsonPath, readJournal(projectPath(projectRoot, request.journalPath))?.data);
143
+ fs.mkdirSync(path.dirname(jsonPath), { recursive: true });
144
+ fs.writeFileSync(jsonPath, serialized);
145
+ }
146
+ process.stdout.write(serialized);
147
+ return result.completed ? 0 : 1;
148
+ } catch (error) {
149
+ if (request) {
150
+ try {
151
+ saveRequestSnapshot(request);
152
+ } catch (snapshotError) {
153
+ process.stderr.write(`tea-framework: could not retain setup request: ${snapshotError.message}\n`);
154
+ }
155
+ }
156
+ if (error.runDir) {
157
+ const failureResult = {
158
+ schema_version: '0.1.0',
159
+ completed: false,
160
+ scope: request?.scope ?? options.scope,
161
+ operation: request?.savedOperation ?? options.operation,
162
+ agent: options.agent,
163
+ model: null,
164
+ runDirectory: error.runDir,
165
+ journal: request?.journalPath ?? null,
166
+ artifacts: [],
167
+ issues: [error.message],
168
+ failureClass: error.failureClass,
169
+ };
170
+ const serialized = `${JSON.stringify(failureResult, null, 2)}\n`;
171
+ fs.writeFileSync(path.join(error.runDir, 'result.json'), serialized);
172
+ fs.writeFileSync(path.join(error.runDir, 'report.md'), resultReport(failureResult));
173
+ if (jsonPath) {
174
+ try {
175
+ protectResultPath(request, jsonPath, readJournal(projectPath(request.projectRoot, request.journalPath))?.data);
176
+ fs.mkdirSync(path.dirname(jsonPath), { recursive: true });
177
+ fs.writeFileSync(jsonPath, serialized);
178
+ } catch (publicationError) {
179
+ process.stderr.write(`tea-framework: result publication refused: ${publicationError.message}\n`);
180
+ }
181
+ }
182
+ process.stdout.write(serialized);
183
+ }
184
+ process.stderr.write(`tea-framework: ${error.message}${error.runDir ? `\nRetained evidence: ${error.runDir}` : ''}\n`);
185
+ return ['environment-transport', 'environment-timeout', 'environment-parser'].includes(error.failureClass) ? 3 : 2;
186
+ }
187
+ }
188
+
189
+ if (require.main === module) process.exitCode = main();
190
+ module.exports = { main, resultReport };
@@ -0,0 +1,896 @@
1
+ /** Framework-specific request routing and completion checks for tea-framework. */
2
+ 'use strict';
3
+
4
+ const fs = require('node:fs');
5
+ const path = require('node:path');
6
+ const yaml = require('js-yaml');
7
+ const { createHash } = require('node:crypto');
8
+ // eslint-disable-next-line no-control-regex
9
+ const ANSI_REGEX = /[\u001B\u009B][[()#;?]*(?:[0-9]{1,4}(?:;[0-9]{0,4})*)?[0-9A-ORZcf-nqry=><]/g;
10
+ const stripVTControlCharacters = (str) => (typeof str === 'string' ? str.replaceAll(ANSI_REGEX, '') : str);
11
+ const { spawnSync } = require('node:child_process');
12
+ const { runAgent } = require('./run-agent');
13
+ const { resolveTeaConfig } = require('./resolve-tea-config');
14
+
15
+ const SKILL_NAME = 'bmad-testarch-framework';
16
+ const SCOPES = ['framework', 'ci', 'both'];
17
+ const OPERATIONS = ['create', 'resume', 'validate', 'edit'];
18
+
19
+ function problem(message) {
20
+ const error = new Error(message);
21
+ error.code = 'TEA_CONFIG_INVALID';
22
+ return error;
23
+ }
24
+
25
+ /** Resolve paths and existing parents through symlinks before allowing project artifacts. */
26
+ function projectPath(root, value) {
27
+ if (typeof value !== 'string' || !value.trim() || /[\r\n\0]/.test(value))
28
+ throw problem('Artifact path must be a nonempty single-line path');
29
+ const target = path.resolve(root, value.replaceAll('{project-root}', root));
30
+ let ancestor = target;
31
+ while (!fs.existsSync(ancestor)) {
32
+ try {
33
+ if (fs.lstatSync(ancestor).isSymbolicLink()) throw problem(`Artifact path is a dangling symbolic link: ${value}`);
34
+ } catch (error) {
35
+ if (error.code !== 'ENOENT') throw error;
36
+ }
37
+ const parent = path.dirname(ancestor);
38
+ if (parent === ancestor) throw problem(`Cannot resolve artifact path: ${value}`);
39
+ ancestor = parent;
40
+ }
41
+ const resolved = path.resolve(fs.realpathSync(ancestor), path.relative(ancestor, target));
42
+ const relative = path.relative(fs.realpathSync(root), resolved);
43
+ if (relative === '..' || relative.startsWith(`..${path.sep}`) || path.isAbsolute(relative)) {
44
+ throw problem(`Artifact path is outside project root: ${value}`);
45
+ }
46
+ return resolved;
47
+ }
48
+
49
+ /** Result publication must preserve configuration, inputs and skill-owned outputs, including inode aliases. */
50
+ function protectResultPath(request, resultPath, state = request.before?.data) {
51
+ if (!resultPath) return;
52
+ const protectedPaths = [
53
+ request.journalPath,
54
+ request.instructionsPath,
55
+ ...request.inputs,
56
+ 'package.json',
57
+ 'package-lock.json',
58
+ 'npm-shrinkwrap.json',
59
+ 'tsconfig.json',
60
+ 'jsconfig.json',
61
+ request.config.configPath,
62
+ '_bmad/config.toml',
63
+ '_bmad/config.user.toml',
64
+ '_bmad/custom/config.toml',
65
+ '_bmad/custom/config.user.toml',
66
+ ];
67
+ for (const phase of ['framework', 'ci']) {
68
+ protectedPaths.push(...(state?.phase_targets?.[phase] ?? []), state?.phase_checkpoints?.[phase]);
69
+ const report = state?.validation_reports?.[phase];
70
+ protectedPaths.push(...(Array.isArray(report) ? report : typeof report === 'string' ? [report] : [report?.path]));
71
+ }
72
+ protectedPaths.push(
73
+ ...(state?.contract?.config_paths ?? []),
74
+ ...(state?.contract?.test_directories ?? []),
75
+ state?.contract?.test_dir,
76
+ state?.contract?.pipeline_target,
77
+ state?.contract?.lockfile,
78
+ state?.pipeline_target,
79
+ );
80
+ if (typeof state?.run_id === 'string' && state.run_id) protectedPaths.push(requestSnapshotPath(request, state.run_id));
81
+ const target = projectPath(request.projectRoot, resultPath);
82
+ const targetStat = fs.existsSync(target) ? fs.statSync(target) : null;
83
+ for (const raw of protectedPaths.filter(Boolean)) {
84
+ const file = projectPath(request.projectRoot, raw.replaceAll('{test_artifacts}', request.artifactsRoot));
85
+ const stat = fs.existsSync(file) ? fs.statSync(file) : null;
86
+ const relative = path.relative(file, target);
87
+ if (
88
+ target === file ||
89
+ (stat && targetStat && stat.dev === targetStat.dev && stat.ino === targetStat.ino) ||
90
+ (stat?.isDirectory() && relative !== '..' && !relative.startsWith(`..${path.sep}`) && !path.isAbsolute(relative))
91
+ )
92
+ throw problem('--json conflicts with protected setup configuration, input or output');
93
+ }
94
+ }
95
+
96
+ function journalArtifactPaths(state) {
97
+ const references = [];
98
+ const add = (value) => {
99
+ if (typeof value === 'string') references.push(value);
100
+ else if (Array.isArray(value)) {
101
+ for (const item of value) add(item);
102
+ } else if (value?.path) add(value.path);
103
+ };
104
+ for (const phase of ['framework', 'ci']) {
105
+ add(state?.phase_targets?.[phase]);
106
+ add(state?.phase_checkpoints?.[phase]);
107
+ add(state?.validation_reports?.[phase]);
108
+ add(state?.parallel_workers?.[phase]?.checkpoint);
109
+ }
110
+ for (const key of ['config_paths', 'test_directories', 'test_dir', 'pipeline_target', 'lockfile', 'artifact_paths'])
111
+ add(state?.contract?.[key]);
112
+ add(state?.pipeline_target);
113
+ return references;
114
+ }
115
+
116
+ function validateJournalPaths(request, state) {
117
+ for (const file of journalArtifactPaths(state))
118
+ projectPath(request.projectRoot, file.replaceAll('{test_artifacts}', request.artifactsRoot));
119
+ }
120
+
121
+ /** Read simple shell invocations while preserving quoted and escaped argument boundaries. */
122
+ function shellInvocations(command) {
123
+ const commands = [];
124
+ let args = [];
125
+ let word = '';
126
+ let active = false;
127
+ let quote = null;
128
+ let escaped = false;
129
+ let comment = false;
130
+ const token = () => {
131
+ if (active) args.push(word);
132
+ word = '';
133
+ active = false;
134
+ };
135
+ const finish = () => {
136
+ token();
137
+ if (args.length > 0) commands.push(args);
138
+ args = [];
139
+ };
140
+ for (const character of command) {
141
+ if (comment) {
142
+ if (character === '\n') {
143
+ comment = false;
144
+ finish();
145
+ }
146
+ continue;
147
+ }
148
+ if (escaped) {
149
+ if (character !== '\n') word += character;
150
+ escaped = false;
151
+ continue;
152
+ }
153
+ if (character === '\\' && quote !== "'" && process.platform !== 'win32') {
154
+ escaped = true;
155
+ active = true;
156
+ continue;
157
+ }
158
+ if (quote) {
159
+ if (character === quote) quote = null;
160
+ else word += character;
161
+ continue;
162
+ }
163
+ if (["'", '"', '`'].includes(character)) {
164
+ quote = character;
165
+ active = true;
166
+ continue;
167
+ }
168
+ if (character === '#' && !active) {
169
+ comment = true;
170
+ continue;
171
+ }
172
+ if ([';', '&', '|', '\n'].includes(character)) {
173
+ finish();
174
+ continue;
175
+ }
176
+ if (/\s/.test(character)) {
177
+ token();
178
+ continue;
179
+ }
180
+ word += character;
181
+ active = true;
182
+ }
183
+ if (quote || escaped) return [];
184
+ finish();
185
+ return commands.map((parts) => {
186
+ while (/^[A-Z_]\w*=/.test(parts[0] ?? '')) parts.shift();
187
+ if (parts[0] === 'npx') {
188
+ parts.shift();
189
+ while (parts[0]?.startsWith('--')) parts.shift();
190
+ } else if (['npm', 'pnpm'].includes(parts[0]) && parts[1] === 'exec') {
191
+ parts.splice(0, 2);
192
+ if (parts[0] === '--') parts.shift();
193
+ }
194
+ if (parts[0]) parts[0] = path.basename(parts[0]);
195
+ return parts;
196
+ });
197
+ }
198
+
199
+ /** Expand package scripts from their actual directory, including monorepo prefix flags. */
200
+ function commandInvocations(root, command, cwd = root, seen = new Set()) {
201
+ const invocations = [];
202
+ for (const parts of shellInvocations(command)) {
203
+ if (parts[0] === 'cd' && parts[1]) {
204
+ cwd = projectPath(root, path.resolve(cwd, parts[1]));
205
+ continue;
206
+ }
207
+ invocations.push(parts.join(' '));
208
+ if (['npm', 'pnpm', 'yarn'].includes(parts[0])) {
209
+ let directory = cwd;
210
+ const words = [];
211
+ for (let index = 1; index < parts.length; index++) {
212
+ const part = parts[index];
213
+ if (['--prefix', '--cwd', '-C', '--dir'].includes(part)) {
214
+ if (!parts[index + 1]) break;
215
+ directory = projectPath(root, path.resolve(cwd, parts[++index]));
216
+ } else if (/^--(?:prefix|cwd|dir)=/.test(part)) {
217
+ directory = projectPath(root, path.resolve(cwd, part.slice(part.indexOf('=') + 1)));
218
+ } else if (part === '--') break;
219
+ else if (!part.startsWith('-')) words.push(part);
220
+ }
221
+ const name = words[0] === 'run' ? words[1] : words[0];
222
+ if (!name || ['exec', 'dlx', 'install', 'ci', 'add'].includes(name)) continue;
223
+ const manifest = path.join(directory, 'package.json');
224
+ const identity = `${manifest}:${name}`;
225
+ if (seen.has(identity) || seen.size >= 16 || !fs.existsSync(manifest)) continue;
226
+ seen.add(identity);
227
+ const script = JSON.parse(fs.readFileSync(manifest, 'utf8')).scripts?.[name];
228
+ if (typeof script === 'string') invocations.push(...commandInvocations(root, script, directory, seen));
229
+ } else if (['bash', 'sh'].includes(parts[0]) && parts[1] && !parts[1].startsWith('-')) {
230
+ const file = projectPath(root, path.resolve(cwd, parts[1]));
231
+ if (!seen.has(file) && seen.size < 16 && fs.existsSync(file)) {
232
+ seen.add(file);
233
+ invocations.push(...commandInvocations(root, fs.readFileSync(file, 'utf8'), cwd, seen));
234
+ }
235
+ }
236
+ }
237
+ return invocations;
238
+ }
239
+
240
+ /** Recognize every native summary, so shell success cannot mask a later suite failure. */
241
+ function testExecutionEvidence(root, command, output) {
242
+ const invocations = commandInvocations(root, command);
243
+ const patterns = [
244
+ ['node-test', /^node(?:\.exe)?\s+(?:--[\w-]+(?:=\S+)?\s+)*--test(?:[=\s]|$)/, /^(?:#|ℹ) pass (\d+)\s*$/],
245
+ ['unittest', /^python[\d.]*\s+(?:-[A-Za-z]+\s+)*-m\s+unittest\b/, /Ran (\d+) tests?\b/],
246
+ ['pytest', /^(?:pytest|python[\d.]*\s+(?:-[A-Za-z]+\s+)*-m\s+pytest)\b/, /(?:^|\s)(\d+) passed\b/],
247
+ ['playwright', /^playwright\s+test\b/, /(?:^|\s)(\d+) passed\b/],
248
+ ['vitest', /^vitest\b/, /Tests\s+(\d+) passed\b/],
249
+ ['jest', /^jest\b/, /Tests:\s+(\d+) passed\b/],
250
+ ['cypress', /^cypress\s+run\b/, /Passing:\s*(\d+)/],
251
+ ['go', /^go\s+test\b/, /--- PASS:/],
252
+ ['cargo', /^cargo\s+test\b/, /test result: ok\. (\d+) passed/],
253
+ ['dotnet', /^dotnet\s+test\b/, /Passed:\s*(\d+)/],
254
+ ['phpunit', /^phpunit\b/, /OK \((\d+) tests?/],
255
+ ];
256
+ for (const [runner, detector, summary] of patterns) {
257
+ if (!invocations.some((invocation) => detector.test(invocation))) continue;
258
+ const plain = stripVTControlCharacters(output);
259
+ const numeric = (pattern) => [...plain.matchAll(pattern)].reduce((total, item) => total + Number(item[1]), 0);
260
+ const matches = [...plain.matchAll(new RegExp(summary.source, 'gm'))];
261
+ let passedTests = runner === 'go' ? matches.length : matches.reduce((total, match) => total + Number(match[1]), 0);
262
+ if (runner === 'unittest') passedTests -= numeric(/skipped=(\d+)/g);
263
+ let failedTests =
264
+ numeric(/(?:\b(\d+) (?:failed|failing)\b)/g) +
265
+ numeric(/\b(?:Failed|Failing|Failures):\s*(\d+)/g) +
266
+ numeric(/failures=(\d+)/g) +
267
+ numeric(/^(?:#|ℹ) fail (\d+)\s*$/gm);
268
+ let errors =
269
+ numeric(/\b(\d+) (?:errors?|interrupted|timed out|cancelled)\b/g) +
270
+ numeric(/\bErrors:\s*(\d+)/g) +
271
+ numeric(/errors=(\d+)/g) +
272
+ numeric(/^(?:#|ℹ) cancelled (\d+)\s*$/gm);
273
+ let summaryComplete = matches.length > 0;
274
+ if (runner === 'node-test') {
275
+ const summaries = plain.split(/(?=^(?:#|ℹ) tests \d+\s*$)/m).slice(1);
276
+ const required = ['tests', 'pass', 'fail', 'cancelled', 'skipped'];
277
+ summaryComplete =
278
+ summaries.length > 0 &&
279
+ summaries.every((block) => {
280
+ const count = (key) => block.match(new RegExp(`^(?:#|ℹ) ${key} (\\d+)\\s*$`, 'm'))?.[1];
281
+ return (
282
+ required.every((key) => count(key) !== undefined) &&
283
+ Number(count('tests')) === Number(count('pass')) + Number(count('fail')) + Number(count('cancelled')) + Number(count('skipped'))
284
+ );
285
+ });
286
+ }
287
+ if (runner === 'go' && /(?:--- FAIL:|^FAIL\s)/m.test(plain)) failedTests++;
288
+ if (runner === 'unittest' && (plain.match(/^OK(?:\s|$)/gm)?.length ?? 0) !== matches.length) summaryComplete = false;
289
+ if (runner === 'unittest') passedTests = Math.max(0, passedTests - failedTests - errors);
290
+ return { runner, passedTests, failedTests, errors, summaryComplete };
291
+ }
292
+ return { runner: null, passedTests: 0, failedTests: 0, errors: 0, summaryComplete: false };
293
+ }
294
+
295
+ function readJournal(file) {
296
+ let text;
297
+ try {
298
+ text = fs.readFileSync(file, 'utf8');
299
+ } catch (error) {
300
+ if (error.code === 'ENOENT') return null;
301
+ throw error;
302
+ }
303
+ const match = text.match(/^---\r?\n([\s\S]*?)\r?\n---(?:\r?\n|$)/);
304
+ if (!match) throw problem(`Setup journal has no YAML frontmatter: ${file}`);
305
+ const data = yaml.load(match[1]);
306
+ if (!data || typeof data !== 'object' || Array.isArray(data)) throw problem(`Invalid setup journal: ${file}`);
307
+ return { data, digest: createHash('sha256').update(text).digest('hex'), text };
308
+ }
309
+
310
+ /** Keep the original headless request with its journal run, so a later Resume can restore it. */
311
+ function requestSnapshotPath(request, runId) {
312
+ const digest = createHash('sha256').update(runId).digest('hex');
313
+ return projectPath(request.projectRoot, path.join(request.artifactsRoot, 'framework', `cli-request-${digest}.json`));
314
+ }
315
+
316
+ function saveRequestSnapshot(request) {
317
+ if (request.operation === 'resume') return;
318
+ const journal = readJournal(request.journalPath);
319
+ const state = journal?.data;
320
+ if (
321
+ typeof state?.run_id !== 'string' ||
322
+ !state.run_id ||
323
+ state.run_id === request.before?.data.run_id ||
324
+ state.setup_scope !== request.scope ||
325
+ state.setup_operation !== request.savedOperation
326
+ )
327
+ return;
328
+ const snapshot = {
329
+ schema_version: 1,
330
+ run_id: state.run_id,
331
+ scope: request.scope,
332
+ operation: request.savedOperation,
333
+ instructions_path: request.instructionsPath,
334
+ instructions: request.instructions,
335
+ input_digests: Object.fromEntries(request.inputDigests),
336
+ };
337
+ const target = requestSnapshotPath(request, state.run_id);
338
+ const bytes = `${JSON.stringify(snapshot, null, 2)}\n`;
339
+ fs.mkdirSync(path.dirname(target), { recursive: true });
340
+ if (fs.existsSync(target)) {
341
+ if (fs.readFileSync(target, 'utf8') !== bytes) throw problem(`Saved CLI request conflicts with ${target}`);
342
+ return;
343
+ }
344
+ fs.writeFileSync(target, bytes, { flag: 'wx' });
345
+ }
346
+
347
+ function readRequestSnapshot(request, state) {
348
+ const target = requestSnapshotPath(request, state.run_id);
349
+ if (!fs.existsSync(target)) return null; // Journals created before this CLI feature retain their own context.
350
+ const snapshot = JSON.parse(fs.readFileSync(target, 'utf8'));
351
+ if (
352
+ snapshot.schema_version !== 1 ||
353
+ snapshot.run_id !== state.run_id ||
354
+ snapshot.scope !== state.setup_scope ||
355
+ snapshot.operation !== state.setup_operation ||
356
+ typeof snapshot.instructions !== 'string' ||
357
+ !snapshot.input_digests ||
358
+ typeof snapshot.input_digests !== 'object' ||
359
+ Array.isArray(snapshot.input_digests)
360
+ )
361
+ throw problem(`Saved CLI request does not match setup journal: ${target}`);
362
+ return snapshot;
363
+ }
364
+
365
+ /** Snapshot exact selected artifacts, including directory inputs, without following escaping links. */
366
+ function artifactDigest(root, file, ancestors = new Set()) {
367
+ const resolved = projectPath(root, file);
368
+ const stat = fs.statSync(resolved);
369
+ const hash = createHash('sha256').update(String(stat.mode));
370
+ if (stat.isFile()) return hash.update(fs.readFileSync(resolved)).digest('hex');
371
+ if (!stat.isDirectory()) throw problem(`Input artifact must be a regular file or directory: ${file}`);
372
+ if (ancestors.has(resolved)) throw problem(`Input artifact contains a directory-link cycle: ${file}`);
373
+ const next = new Set([...ancestors, resolved]);
374
+ for (const name of fs.readdirSync(resolved).sort()) hash.update(name).update(artifactDigest(root, path.join(resolved, name), next));
375
+ return hash.digest('hex');
376
+ }
377
+
378
+ /** Inspect platform-owned executable jobs and their relationship to the frozen test command. */
379
+ function inspectPipeline(root, file, state) {
380
+ if (!fs.statSync(file).isFile()) return 'Pipeline must be a regular file';
381
+ const text = fs.readFileSync(file, 'utf8');
382
+ if (!text.trim()) return 'Pipeline is empty';
383
+ const platform =
384
+ state.contract?.ci_platform ??
385
+ state.ci_platform ??
386
+ (file.includes(`${path.sep}.github${path.sep}`)
387
+ ? 'github-actions'
388
+ : path.basename(file).startsWith('.gitlab-ci')
389
+ ? 'gitlab-ci'
390
+ : path.basename(file) === 'Jenkinsfile'
391
+ ? 'jenkins'
392
+ : file.includes(`${path.sep}.circleci${path.sep}`)
393
+ ? 'circle-ci'
394
+ : file.includes(`${path.sep}.harness${path.sep}`)
395
+ ? 'harness'
396
+ : 'azure-devops');
397
+ const commands = [];
398
+ const add = (value) => {
399
+ if (typeof value === 'string' && value.trim()) commands.push(value);
400
+ };
401
+ const walkSteps = (steps) => {
402
+ for (const step of steps ?? []) {
403
+ if (!step || typeof step !== 'object' || step.if === false || step.condition === false) continue;
404
+ for (const key of ['run', 'script', 'bash', 'pwsh', 'powershell'])
405
+ add(typeof step[key] === 'object' ? step[key]?.command : step[key]);
406
+ if (step.step?.type === 'Run') add(step.step.spec?.command);
407
+ }
408
+ };
409
+ try {
410
+ if (platform === 'jenkins') {
411
+ if (!/\bpipeline\s*\{/.test(text) || !/\bstages\s*\{/.test(text)) return 'Jenkins pipeline has no executable stages';
412
+ for (const match of text.matchAll(/\b(?:sh|bat|powershell)\s*(?:\(\s*)?(['"])([\s\S]*?)\1/g)) add(match[2]);
413
+ } else {
414
+ const config = yaml.load(text);
415
+ if (!config || typeof config !== 'object' || Array.isArray(config)) return 'Pipeline YAML must contain a mapping';
416
+ switch (platform) {
417
+ case 'github-actions': {
418
+ if (!config.on || !config.jobs || typeof config.jobs !== 'object' || Array.isArray(config.jobs))
419
+ return 'GitHub workflow has no triggers or jobs';
420
+ for (const job of Object.values(config.jobs)) {
421
+ if (job?.if === false) continue;
422
+ if (job?.uses) continue;
423
+ if (!job?.['runs-on'] || !Array.isArray(job.steps)) continue;
424
+ walkSteps(job.steps);
425
+ }
426
+
427
+ break;
428
+ }
429
+ case 'gitlab-ci': {
430
+ const reserved = new Set([
431
+ 'stages',
432
+ 'workflow',
433
+ 'default',
434
+ 'variables',
435
+ 'include',
436
+ 'image',
437
+ 'services',
438
+ 'before_script',
439
+ 'after_script',
440
+ 'cache',
441
+ ]);
442
+ for (const [name, job] of Object.entries(config)) {
443
+ if (reserved.has(name) || name.startsWith('.') || !job || typeof job !== 'object') continue;
444
+ for (const command of Array.isArray(job.script) ? job.script : [job.script]) add(command);
445
+ }
446
+
447
+ break;
448
+ }
449
+ case 'circle-ci': {
450
+ const jobs = Object.values(config.workflows ?? {}).flatMap((workflow) => workflow?.jobs ?? []);
451
+ for (const selected of jobs) {
452
+ const name = typeof selected === 'string' ? selected : Object.keys(selected ?? {})[0];
453
+ walkSteps(config.jobs?.[name]?.steps);
454
+ }
455
+
456
+ break;
457
+ }
458
+ case 'azure-devops': {
459
+ walkSteps(config.steps);
460
+ for (const job of [...(config.jobs ?? []), ...(config.stages ?? []).flatMap((stage) => stage.jobs ?? [])]) walkSteps(job.steps);
461
+
462
+ break;
463
+ }
464
+ case 'harness': {
465
+ for (const stage of config.pipeline?.stages ?? []) walkSteps(stage.stage?.spec?.execution?.steps);
466
+
467
+ break;
468
+ }
469
+ default: {
470
+ return `Unsupported pipeline platform: ${platform}`;
471
+ }
472
+ }
473
+ }
474
+ } catch (error) {
475
+ return `Pipeline syntax or job structure is invalid: ${error.message}`;
476
+ }
477
+ if (commands.length === 0) return 'Pipeline has no runnable test steps';
478
+ const frozen =
479
+ state.contract?.ci_test_commands ??
480
+ state.contract?.test_commands ??
481
+ state.contract?.local_test_commands ??
482
+ state.contract?.local_commands ??
483
+ [];
484
+ const actual = commands.flatMap((command) => commandInvocations(root, command));
485
+ const matches = (candidate, target) => candidate === target || candidate.startsWith(`${target} `);
486
+ if (
487
+ frozen.length === 0 ||
488
+ !frozen.every((command) => commandInvocations(root, command).some((target) => actual.some((candidate) => matches(candidate, target))))
489
+ )
490
+ return 'Pipeline has no runnable step for a frozen test command';
491
+ return null;
492
+ }
493
+
494
+ /** Completion must account for the caller-selected Edit and Validate scope. */
495
+ function inspectSelectedOperation(request, state, issues) {
496
+ if (!['edit', 'validate'].includes(request.savedOperation)) return;
497
+ const phases = request.scope === 'both' ? ['framework', 'ci'] : [request.scope];
498
+ const resolve = (value) => projectPath(request.projectRoot, value.replaceAll('{test_artifacts}', request.artifactsRoot));
499
+ const targets = phases.flatMap((phase) => state.phase_targets?.[phase] ?? []).map(resolve);
500
+ const expected =
501
+ request.operation === 'resume'
502
+ ? phases.flatMap((phase) => request.resumeState.phase_targets?.[phase] ?? []).map(resolve)
503
+ : request.inputs;
504
+ const same = (left, right) => JSON.stringify([...new Set(left)].sort()) === JSON.stringify([...new Set(right)].sort());
505
+ if (expected.length === 0 || !same(targets, expected))
506
+ issues.push(`${request.savedOperation} did not preserve the exact selected artifacts`);
507
+ if (request.savedOperation === 'validate') {
508
+ const validated = [];
509
+ for (const phase of phases) {
510
+ const references = state.validation_reports?.[phase];
511
+ const paths =
512
+ typeof references === 'string' ? [references] : Array.isArray(references) ? references : [references?.path].filter(Boolean);
513
+ for (const reference of paths) {
514
+ const report = resolve(reference);
515
+ if (!fs.existsSync(report)) continue;
516
+ const stat = fs.statSync(report);
517
+ if (!stat.isFile() || stat.size === 0) {
518
+ issues.push(`Validation report is not a nonempty regular file: ${reference}`);
519
+ continue;
520
+ }
521
+ let owned;
522
+ try {
523
+ owned = readJournal(report);
524
+ } catch (error) {
525
+ issues.push(`Validation report is malformed: ${reference} (${error.message})`);
526
+ continue;
527
+ }
528
+ if (
529
+ !owned ||
530
+ owned.data.run_id !== state.run_id ||
531
+ !['PASS', 'WARN', 'FAIL'].includes(owned.data.status) ||
532
+ !owned.text.replace(/^---\r?\n[\s\S]*?\r?\n---/, '').trim() ||
533
+ !Array.isArray(owned.data.validated_artifacts)
534
+ ) {
535
+ issues.push(`Validation report lacks completed same-run ownership and selected artifact metadata: ${reference}`);
536
+ continue;
537
+ }
538
+ if (request.operation !== 'resume' && stat.mtimeMs < request.startedAtMs - 1)
539
+ issues.push(`Validation report predates this invocation: ${reference}`);
540
+ const selections = owned.data.validated_artifacts.map(resolve);
541
+ if (!same(selections, (state.phase_targets?.[phase] ?? []).map(resolve)))
542
+ issues.push(`Validation report covers a different selected scope: ${reference}`);
543
+ validated.push(...selections);
544
+ }
545
+ }
546
+ if (!same(validated, expected)) issues.push('Validation reports do not account for every selected artifact');
547
+ for (const [file, digest] of request.inputDigests) {
548
+ if (artifactDigest(request.projectRoot, file) !== digest) issues.push(`Validate changed a selected input: ${file}`);
549
+ }
550
+ } else {
551
+ const outcomes = phases.flatMap((phase) => state.edit_applied?.[phase] ?? []);
552
+ const savedOutcomes = phases.flatMap((phase) => request.resumeState?.edit_applied?.[phase] ?? []);
553
+ for (const file of expected) {
554
+ const outcome = outcomes.find((entry) => entry && typeof entry.path === 'string' && resolve(entry.path) === file);
555
+ const savedOutcome = savedOutcomes.find((entry) => entry && typeof entry.path === 'string' && resolve(entry.path) === file);
556
+ const before = request.inputDigests.get(file) ?? savedOutcome?.before_sha256 ?? outcome?.before_sha256;
557
+ const after = artifactDigest(request.projectRoot, file);
558
+ if (
559
+ savedOutcome &&
560
+ ['applied', 'noop'].includes(savedOutcome.status) &&
561
+ ['status', 'before_sha256', 'after_sha256', 'reason'].some((key) => savedOutcome[key] !== outcome?.[key])
562
+ )
563
+ issues.push(`Resume changed a completed Edit outcome: ${file}`);
564
+ if (
565
+ !outcome ||
566
+ !/^[a-f0-9]{64}$/.test(before ?? '') ||
567
+ outcome.before_sha256 !== before ||
568
+ outcome.after_sha256 !== after ||
569
+ !(
570
+ (outcome.status === 'applied' && before !== after) ||
571
+ (outcome.status === 'noop' && before === after && typeof outcome.reason === 'string' && outcome.reason.trim())
572
+ )
573
+ )
574
+ issues.push(`Edit has no verified applied change or explicit no-op outcome: ${file}`);
575
+ }
576
+ }
577
+ }
578
+
579
+ function prepareSetup({ projectRoot, skillRoot, config, options }) {
580
+ if (!SCOPES.includes(options.scope)) throw problem(`--scope must be ${SCOPES.join('|')}`);
581
+ if (!OPERATIONS.includes(options.operation)) throw problem(`--operation must be ${OPERATIONS.join('|')}`);
582
+ const artifactsRoot = projectPath(projectRoot, config.configSnapshot.modules.tea.test_artifacts);
583
+ const journalPath = projectPath(projectRoot, path.join(artifactsRoot, 'framework', 'setup-run-progress.md'));
584
+ const before = readJournal(journalPath);
585
+ const inputs = [...new Set((options.input ?? []).map((file) => projectPath(projectRoot, file)))];
586
+ for (const file of inputs) if (!fs.existsSync(file)) throw problem(`Input artifact does not exist: ${file}`);
587
+ let instructions = '';
588
+ const instructionsPath = options.instructions ? projectPath(projectRoot, options.instructions) : null;
589
+ if (instructionsPath) instructions = fs.readFileSync(instructionsPath, 'utf8');
590
+ if (options.operation === 'resume' && (inputs.length > 0 || instructionsPath))
591
+ throw problem('Resume restores saved inputs and instructions; omit --input and --instructions');
592
+ if (['edit', 'validate'].includes(options.operation) && inputs.length === 0)
593
+ throw problem(`${options.operation} requires --input <artifact> (repeatable)`);
594
+ if (options.operation === 'edit' && !instructions.trim())
595
+ throw problem('edit requires --instructions <file> containing requested changes');
596
+ if (options.operation === 'resume' && !before) throw problem(`Resume requires the setup journal at ${journalPath}`);
597
+ if (
598
+ options.operation === 'resume' &&
599
+ (typeof before.data.run_id !== 'string' ||
600
+ !before.data.run_id ||
601
+ !SCOPES.includes(before.data.setup_scope) ||
602
+ !['create', 'edit', 'validate'].includes(before.data.setup_operation))
603
+ ) {
604
+ throw problem('Setup journal has no recognized saved scope and operation');
605
+ }
606
+ if (options.operation === 'resume' && before.data.hooks_started?.some((key) => !before.data.hooks_completed?.includes(key)))
607
+ throw problem('Resume has an uncertain started hook. Record explicit operator recovery in the journal before headless execution.');
608
+ const scope = options.operation === 'resume' ? before.data.setup_scope : options.scope;
609
+ const ciConfig = ['ci', 'both'].includes(scope)
610
+ ? resolveTeaConfig({ projectRoot, skillRoot: path.join(skillRoot, '..', 'bmad-testarch-ci'), skillName: 'bmad-testarch-ci' })
611
+ : null;
612
+ const requiredHooks = {};
613
+ for (const [phase, settings] of [
614
+ ['framework', config.workflowCustomization],
615
+ ['ci', ciConfig?.workflowCustomization],
616
+ ]) {
617
+ if (scope !== 'both' && scope !== phase) continue;
618
+ for (const kind of ['activation_steps_prepend', 'activation_steps_append']) {
619
+ const steps = settings?.[kind] ?? [];
620
+ if (!Array.isArray(steps) || steps.some((step) => typeof step !== 'string')) throw problem(`Invalid ${phase}.${kind}`);
621
+ for (const [index, instruction] of steps.entries()) {
622
+ if (instruction.trim()) requiredHooks[`${phase}.${kind}.${index}`] = instruction;
623
+ }
624
+ }
625
+ const instruction = settings?.on_complete ?? '';
626
+ if (typeof instruction !== 'string') throw problem(`Invalid ${phase}.on_complete`);
627
+ if (instruction.trim()) requiredHooks[`${phase}.on_complete`] = instruction;
628
+ }
629
+ if (options.operation === 'create' && ['ci', 'both'].includes(options.scope)) {
630
+ const git = spawnSync('git', ['rev-parse', '--is-inside-work-tree'], { cwd: projectRoot, encoding: 'utf8', timeout: 10_000 });
631
+ if (git.status !== 0 || git.stdout.trim() !== 'true')
632
+ throw problem('Git repository required for CI/CD setup. Initialize the repository before Create.');
633
+ }
634
+ const request = {
635
+ projectRoot,
636
+ skillRoot,
637
+ config,
638
+ scope,
639
+ operation: options.operation,
640
+ savedOperation: options.operation === 'resume' ? before.data.setup_operation : options.operation,
641
+ artifactsRoot,
642
+ journalPath,
643
+ before,
644
+ inputs,
645
+ instructions,
646
+ instructionsPath,
647
+ requiredHooks,
648
+ ciWorkflowCustomization: ciConfig?.workflowCustomization ?? {},
649
+ resumeState: options.operation === 'resume' ? before.data : null,
650
+ startedAtMs: Date.now(),
651
+ inputDigests: new Map(inputs.map((file) => [file, artifactDigest(projectRoot, file)])),
652
+ };
653
+ if (before) validateJournalPaths(request, before.data);
654
+ if (options.operation === 'resume') {
655
+ const snapshot = readRequestSnapshot(request, before.data);
656
+ if (snapshot) {
657
+ request.instructions = snapshot.instructions;
658
+ request.instructionsPath = snapshot.instructions_path;
659
+ request.inputDigests = new Map(Object.entries(snapshot.input_digests));
660
+ request.inputs = [...request.inputDigests.keys()];
661
+ }
662
+ }
663
+ return request;
664
+ }
665
+
666
+ function setupPrompt(request, { retry = false } = {}) {
667
+ let operation = request.operation;
668
+ if (retry) {
669
+ const progress = readJournal(projectPath(request.projectRoot, request.journalPath));
670
+ if (
671
+ !progress ||
672
+ progress.digest === request.before?.digest ||
673
+ progress.data.workflowStatus === 'completed' ||
674
+ !progress.data.run_id ||
675
+ (progress.data.run_id === request.before?.data.run_id && request.operation !== 'resume') ||
676
+ progress.data.setup_scope !== request.scope ||
677
+ progress.data.setup_operation !== request.savedOperation
678
+ )
679
+ throw problem('Retry has no recoverable progress from this invocation; refusing to adopt prior history.');
680
+ if (progress.data.hooks_started?.some((key) => !progress.data.hooks_completed?.includes(key)))
681
+ throw problem('Retry has an uncertain started hook; explicit operator recovery is required.');
682
+ request.resumeState ??= progress.data;
683
+ if (request.resumeState.run_id !== progress.data.run_id) throw problem('Retry replaced the saved run_id');
684
+ operation = 'resume';
685
+ }
686
+ const pythonProbe = spawnSync('python3', ['--version'], { cwd: request.projectRoot, encoding: 'utf8', timeout: 3000 });
687
+ const pythonVersion = pythonProbe.status === 0 ? (pythonProbe.stdout || pythonProbe.stderr).trim() : 'unavailable';
688
+ return [
689
+ `Run the framework and CI setup skill at ${JSON.stringify(request.skillRoot)}. Read its SKILL.md, setup-routing.md, setup-state.md and applicable step files completely.`,
690
+ `Project root: ${JSON.stringify(request.projectRoot)}. Knowledge root: ${JSON.stringify(path.join(request.skillRoot, '..', 'bmod-tea', 'knowledge'))}.`,
691
+ `This is a headless ${operation} request. Explicit setup_scope: ${request.scope}. Preserve the original operation and ledger when resuming.`,
692
+ 'Configuration has been resolved by the CLI. This snapshot fulfills activation configuration loading, including package defaults when no project config exists. Treat it as authoritative for core and modules.tea values. Apply applicable framework and CI workflow customizations using the skill rules; no configuration file needs to be created.',
693
+ JSON.stringify(request.config.configSnapshot, null, 2),
694
+ `The CLI's native verifier executes frozen local test commands using ${process.platform === 'win32' ? 'cmd.exe' : '/bin/sh -c'} with PATH ${JSON.stringify(process.env.PATH ?? '')}. In this verifier environment, python3 --version is ${JSON.stringify(pythonVersion)}. Your own tool shell may resolve another interpreter. Freeze a local command that succeeds in the verifier environment, using an explicit interpreter path or versioned command when needed. CI may use its own frozen command.`,
695
+ `Canonical workflow customization: ${JSON.stringify(request.config.workflowCustomization ?? {})}. CI workflow customization: ${JSON.stringify(request.ciWorkflowCustomization)}. Required hook instructions and stable ledger keys: ${JSON.stringify(request.requiredHooks)}.`,
696
+ request.operation === 'resume'
697
+ ? 'Restore the exact phase targets, selected reports and requested edits from the saved journal. New artifact selection is not part of Resume.'
698
+ : `Exact input artifacts: ${JSON.stringify(request.inputs)}.`,
699
+ request.instructions
700
+ ? `Requested work:\n${request.instructions}`
701
+ : 'Create the setup requested by this scope, or continue the saved operation.',
702
+ 'Proceed through authorized ordinary checkpoints using the observed project and configured preferences. Use sequential execution; do not launch paid agents.',
703
+ 'A missing framework during CI-only Create requires consent: record the unresolved offer and halt with an incomplete result. Explicit both scope authorizes framework setup.',
704
+ 'Preserve existing application files. Follow the skill policy for existing framework and pipeline reuse. Do not replace an existing pipeline without explicit instructions.',
705
+ 'Execute the actual frozen-contract test commands before Create completion. Failed or unavailable execution keeps Create incomplete. Validate records FAIL/WARN as report outcomes and completes its report without repairs.',
706
+ 'Create CI completion requires a valid pipeline with runnable jobs that execute its frozen test command.',
707
+ 'For Edit and Validate preserve exact selected artifacts in phase_targets. Validate reports are nonempty regular Markdown files with YAML frontmatter containing run_id, status PASS/WARN/FAIL and validated_artifacts listing the exact phase targets; fresh Validate writes a new same-run report and preserves its selected inputs.',
708
+ `For Edit record edit_applied.<phase> as an array of {path,status,before_sha256,after_sha256,reason}. status is applied for a verified change or noop for an unchanged artifact with an explicit reason. Use these CLI-observed input digests (including permissions): ${JSON.stringify(Object.fromEntries(request.inputDigests))}. The digest is SHA-256 of the file's numeric stat.mode followed by its raw bytes; directories hash numeric mode followed by each sorted child name and child digest.`,
709
+ `Persist the shared journal at ${JSON.stringify(request.journalPath)} and all required per-phase artifacts. Complete applicable hooks through their durable ledger.`,
710
+ 'Print a concise summary of files and actual checks. Agent exit success alone does not establish setup completion.',
711
+ ].join('\n\n');
712
+ }
713
+
714
+ /** Check the skill-owned journal and artifacts. Completion is an operation outcome, separate from Validate quality. */
715
+ function inspectCompletion(request, { evidenceDir, timeoutMs = 1_200_000 } = {}) {
716
+ const journal = readJournal(projectPath(request.projectRoot, request.journalPath));
717
+ const issues = [];
718
+ if (!journal) return { completed: false, issues: ['The setup journal was not written'], journal: null, artifacts: [] };
719
+ const state = journal.data;
720
+ validateJournalPaths(request, state);
721
+ if (state.workflowStatus !== 'completed') issues.push(`Setup journal is ${state.workflowStatus ?? 'missing workflowStatus'}`);
722
+ if (state.setup_scope !== request.scope) issues.push(`Journal scope ${state.setup_scope} does not match ${request.scope}`);
723
+ if (state.setup_operation !== request.savedOperation)
724
+ issues.push(`Journal operation ${state.setup_operation} does not match ${request.savedOperation}`);
725
+ if (!state.run_id || typeof state.run_id !== 'string') issues.push('Setup journal has no run_id');
726
+ if (request.operation !== 'resume' && request.before && state.run_id === request.before.data.run_id)
727
+ issues.push('A fresh operation reused the preceding run_id');
728
+ if (request.resumeState) {
729
+ if (state.run_id !== request.resumeState.run_id) issues.push('Resume replaced the frozen run_id');
730
+ const preserves = (saved, current) => {
731
+ if (Array.isArray(saved)) return JSON.stringify(saved) === JSON.stringify(current);
732
+ if (saved && typeof saved === 'object') return current && Object.keys(saved).every((key) => preserves(saved[key], current[key]));
733
+ return saved === current;
734
+ };
735
+ if (!preserves(request.resumeState.contract ?? {}, state.contract)) issues.push('Resume changed the frozen contract');
736
+ for (const field of ['phase_targets', 'phase_checkpoints', 'validation_reports', 'edit_requests']) {
737
+ for (const phase of ['framework', 'ci']) {
738
+ const saved = request.resumeState[field]?.[phase];
739
+ if (saved && (!Array.isArray(saved) || saved.length > 0) && !preserves(saved, state[field]?.[phase]))
740
+ issues.push(`Resume changed saved ${field}.${phase}`);
741
+ }
742
+ }
743
+ for (const key of request.resumeState.hooks_started ?? [])
744
+ if (
745
+ !state.hooks_started?.includes(key) ||
746
+ !state.hooks_completed?.includes(key) ||
747
+ state.hook_instructions?.[key] !== request.resumeState.hook_instructions?.[key]
748
+ )
749
+ issues.push(`Resume discarded or changed saved hook ${key}`);
750
+ }
751
+ if (!Array.isArray(state.hooks_started) || !Array.isArray(state.hooks_completed)) issues.push('Setup journal is missing its hook ledger');
752
+ else if (state.hooks_started.some((key) => !state.hooks_completed.includes(key))) issues.push('Setup journal contains unfinished hooks');
753
+ for (const [key, instruction] of Object.entries(request.requiredHooks)) {
754
+ if (!state.hooks_started?.includes(key) || !state.hooks_completed?.includes(key) || state.hook_instructions?.[key] !== instruction)
755
+ issues.push(`Configured hook ${key} lacks its exact instruction and completed lifecycle`);
756
+ }
757
+ const phases = request.scope === 'both' ? ['framework', 'ci'] : [request.scope];
758
+ const artifacts = [request.journalPath];
759
+ for (const phase of phases) {
760
+ if (state.phase_status?.[phase] !== 'completed') issues.push(`${phase} phase is incomplete`);
761
+ const createTargets =
762
+ phase === 'framework'
763
+ ? [
764
+ ...(state.contract?.config_paths ?? []),
765
+ ...(state.contract?.test_directories ?? []),
766
+ ...(state.contract?.test_dir ? [state.contract.test_dir] : []),
767
+ ]
768
+ : [state.contract?.pipeline_target ?? state.pipeline_target].filter(Boolean);
769
+ const references =
770
+ request.savedOperation === 'validate'
771
+ ? state.validation_reports?.[phase]
772
+ : request.savedOperation === 'create'
773
+ ? [...new Set([...createTargets, ...(state.phase_targets?.[phase] ?? [])])]
774
+ : state.phase_targets?.[phase];
775
+ const paths =
776
+ typeof references === 'string' ? [references] : Array.isArray(references) ? references : references?.path ? [references.path] : [];
777
+ if (request.savedOperation === 'create' && state.phase_checkpoints?.[phase]) paths.push(state.phase_checkpoints[phase]);
778
+ if (paths.length === 0)
779
+ issues.push(`${phase} phase names no ${request.savedOperation === 'validate' ? 'validation report' : 'target artifacts'}`);
780
+ for (const file of paths) {
781
+ const resolved = projectPath(request.projectRoot, file.replaceAll('{test_artifacts}', request.artifactsRoot));
782
+ if (fs.existsSync(resolved)) {
783
+ artifacts.push(resolved);
784
+ } else {
785
+ issues.push(`Missing ${phase} artifact: ${file}`);
786
+ }
787
+ }
788
+ }
789
+ if (request.savedOperation === 'create') {
790
+ const testCommands = state.contract?.test_commands ?? state.contract?.local_test_commands ?? state.contract?.local_commands;
791
+ if (
792
+ !Array.isArray(testCommands) ||
793
+ testCommands.length === 0 ||
794
+ testCommands.some((command) => typeof command !== 'string' || !command.trim())
795
+ )
796
+ issues.push('Create journal has no valid frozen test commands');
797
+ if (phases.includes('ci')) {
798
+ const pipeline = state.contract?.pipeline_target ?? state.pipeline_target;
799
+ if (typeof pipeline === 'string') {
800
+ const resolved = projectPath(request.projectRoot, pipeline);
801
+ if (fs.existsSync(resolved)) {
802
+ artifacts.push(resolved);
803
+ const pipelineIssue = inspectPipeline(request.projectRoot, resolved, state);
804
+ if (pipelineIssue) issues.push(pipelineIssue);
805
+ } else {
806
+ issues.push(`Missing pipeline: ${pipeline}`);
807
+ }
808
+ } else {
809
+ issues.push('Create journal has no CI pipeline target');
810
+ }
811
+ }
812
+ }
813
+ inspectSelectedOperation(request, state, issues);
814
+ if (request.savedOperation === 'create' && phases.includes('framework') && issues.length === 0) {
815
+ const directories = [...(state.contract?.test_directories ?? []), ...(state.contract?.test_dir ? [state.contract.test_dir] : [])];
816
+ const roots =
817
+ directories.length > 0
818
+ ? directories
819
+ : (state.phase_targets?.framework ?? []).filter((file) => !(state.contract?.config_paths ?? []).includes(file));
820
+ const seen = new Set();
821
+ const hasCode = (file) => {
822
+ const resolved = projectPath(request.projectRoot, file);
823
+ if (seen.has(resolved)) return false;
824
+ seen.add(resolved);
825
+ const stat = fs.statSync(resolved);
826
+ if (stat.isFile())
827
+ return (
828
+ stat.size > 0 &&
829
+ /\.(?:[cm]?[jt]sx?|py|go|rs|rb|java|cs|kt|php|sh|feature|swift|cpp|c|ya?ml|dart|fsx?|vb|groovy|scala|exs?|clj[sc]?|lua|pl|robot)$/i.test(
830
+ resolved,
831
+ )
832
+ );
833
+ return stat.isDirectory() && fs.readdirSync(resolved).some((entry) => hasCode(path.join(resolved, entry)));
834
+ };
835
+ if (!roots.some(hasCode)) issues.push('Create journal references no nonempty test source files');
836
+ }
837
+ const executions = [];
838
+ if (request.savedOperation === 'create' && issues.length === 0) {
839
+ const commands = state.contract.test_commands ?? state.contract.local_test_commands ?? state.contract.local_commands;
840
+ for (const [index, command] of commands.entries()) {
841
+ const shell = process.platform === 'win32' ? process.env.ComSpec || 'cmd.exe' : '/bin/sh';
842
+ const args = process.platform === 'win32' ? ['/d', '/s', '/c', command] : ['-c', command];
843
+ let result;
844
+ let failure = null;
845
+ try {
846
+ result = runAgent('', {
847
+ agent: 'custom',
848
+ agentCommand: shell,
849
+ agentArgs: args,
850
+ cwd: request.projectRoot,
851
+ timeout: timeoutMs,
852
+ envPass: Object.keys(process.env).filter((key) => key !== 'NODE_TEST_CONTEXT'),
853
+ });
854
+ } catch (error) {
855
+ failure = error;
856
+ result = error;
857
+ }
858
+ const execution = {
859
+ command,
860
+ timeoutMs,
861
+ exitCode: failure ? Number(failure.message.match(/exited with code (\d+)/)?.[1] ?? NaN) : 0,
862
+ error: failure?.message ?? null,
863
+ ...testExecutionEvidence(request.projectRoot, command, `${result.stdout ?? ''}\n${result.stderr ?? ''}`),
864
+ };
865
+ if (!Number.isFinite(execution.exitCode)) execution.exitCode = null;
866
+ if (evidenceDir) {
867
+ const prefix = path.join(evidenceDir, `verification-${index + 1}`);
868
+ fs.writeFileSync(`${prefix}.stdout.txt`, result.stdout ?? '');
869
+ fs.writeFileSync(`${prefix}.stderr.txt`, result.stderr ?? '');
870
+ execution.stdout = `${prefix}.stdout.txt`;
871
+ execution.stderr = `${prefix}.stderr.txt`;
872
+ }
873
+ executions.push(execution);
874
+ if (failure) issues.push(`Frozen test command failed: ${command} (${failure.message})`);
875
+ else if (!execution.runner || !execution.summaryComplete || execution.passedTests < 1)
876
+ issues.push(`Frozen command has no positive supported test execution evidence: ${command}`);
877
+ else if (execution.failedTests > 0 || execution.errors > 0)
878
+ issues.push(`Frozen native runner reports failed or cancelled tests: ${command}`);
879
+ }
880
+ if (evidenceDir) fs.writeFileSync(path.join(evidenceDir, 'verification.json'), `${JSON.stringify(executions, null, 2)}\n`);
881
+ }
882
+ return { completed: issues.length === 0, issues, journal: state, artifacts: [...new Set(artifacts)], executions };
883
+ }
884
+
885
+ module.exports = {
886
+ SKILL_NAME,
887
+ SCOPES,
888
+ OPERATIONS,
889
+ projectPath,
890
+ readJournal,
891
+ prepareSetup,
892
+ setupPrompt,
893
+ inspectCompletion,
894
+ protectResultPath,
895
+ saveRequestSnapshot,
896
+ };
package/package.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "$schema": "https://json.schemastore.org/package.json",
3
3
  "name": "bmad-method-test-architecture-enterprise",
4
- "version": "2.0.4-next.1",
4
+ "version": "2.0.4-next.2",
5
5
  "description": "Master Test Architect for quality strategy, test automation, and release gates",
6
6
  "keywords": [
7
7
  "bmad",
@@ -37,6 +37,7 @@
37
37
  "tea-ci-runner": "cli/ci-runner.js",
38
38
  "tea-evaluate": "cli/evaluate.js",
39
39
  "tea-fragment-selection-runner": "cli/fragment-selection-runner.js",
40
+ "tea-framework": "cli/framework.js",
40
41
  "tea-nfr-runner": "cli/nfr-runner.js",
41
42
  "tea-routing-runner": "cli/routing-runner.js",
42
43
  "tea-skill-runner": "cli/skill-runner.js",
@@ -91,7 +92,7 @@
91
92
  "release:minor": "gh workflow run publish.yaml -f channel=latest -f bump=minor",
92
93
  "release:next": "gh workflow run publish.yaml -f channel=next",
93
94
  "release:patch": "gh workflow run publish.yaml -f channel=latest -f bump=patch",
94
- "test": "npm run test:conflict-markers && npm run test:schemas && npm run test:install && npm run test:evaluate-guidance && npm run test:evaluate-authoring && npm run test:evaluate-gap-loop && npm run test:evaluate-dogfood && npm run test:evaluate-check && npm run test:evaluate-compare && npm run test:evaluate-before-state && npm run test:evaluate-ci-gates && npm run test:evaluate-ci-tiers && npm run test:evaluate-ci-repositories:tagged-release-pr && npm run test:evaluate-ci-repositories:tagged-release-merge && npm run test:evaluate-ci-repositories:tagged-release-release && npm run test:evaluate-ci-repositories:nightly-deploy-pr && npm run test:evaluate-ci-repositories:nightly-deploy-merge && npm run test:evaluate-ci-repositories:nightly-deploy-scheduled && npm run test:evaluate-ci-repositories:nightly-deploy-release && npm run test:evaluate-ci-render && npm run test:evaluate-pr-mcp && npm run test:evaluate-pr-api && npm run test:evaluate-pr-suite && npm run test:evaluate-pr-workflow && npm run test:evaluate-pr-tool-use && npm run test:evaluate-pr-promptfoo && npm run test:evaluate-pr-ai-feature && npm run test:evaluate-pr-test-review && npm run test:evaluate-pr-gap-loop && npm run test:evaluate-pr-learn && npm run test:evaluate-pr-tutorial && npm run test:evaluate-partitions && npm run test:evaluate-partition-plans-views-and-check && npm run test:evaluate-partition-plans-preflight-and-run && npm run test:evaluate-partition-plans-score && npm run test:evaluate-partition-plans-attempts-seal-and-mappings && npm run test:evaluate-partition-plans-attempts-records && npm run test:evaluate-partition-plans-attempts-gameability && npm run test:evaluate-interpret && npm run test:evaluate-calibration && npm run test:evaluate-boundaries && npm run test:evaluate-preflight && npm run test:evaluate-isolation-conformance && npm run test:evaluate-mutation:qualification-basics && npm run test:evaluate-mutation:qualification-guards && npm run test:evaluate-mutation:recovery && npm run test:evaluate-mutation:journals && npm run test:evaluate-run && npm run test:evaluate-progress && npm run test:evaluate-aggregate && npm run test:evaluate-confinement && npm run test:evaluate-confinement-audit && npm run test:evaluate-confinement-history && npm run test:evaluate-confinement-git-state && npm run test:evaluate-confinement-network && npm run test:evaluate-held-inputs && npm run test:evaluate-arms-historical && npm run test:evaluate-arms-deployments && npm run test:evaluate-evaluators && npm run test:evaluate-evaluators-agent-version && npm run test:evaluate-agents && npm run test:evaluate-agents-importers && npm run test:evaluate-agents-sealed-brief && npm run test:evaluate-held-attempts && npm run test:evaluate-private && npm run test:evaluate-records && npm run test:evaluate-mcp && npm run test:evaluate-api && npm run test:evaluate-workflow && npm run test:evaluate-tool-use && npm run test:evaluate-promptfoo && npm run test:evaluate-learned-framework && npm run test:docs-tutorial && npm run test:knowledge && npm run test:criteria-fragments && npm run test:doc-count-sources && npm run test:planning-doc-sources && npm run test:doc-counts && npm run test:doc-claim-sources && npm run test:doc-claims && npm run test:doc-invocation-entry && npm run test:doc-invocations && npm run test:doc-link-anchors && npm run test:docs-build-names && npm run test:docs-evaluate-sidebar && npm run test:docs-gap-reader && npm run test:enforce-hook && npm run test:framework-scaffold && npm run test:isolation-primitives && npm run test:atdd-isolation && npm run test:atdd-net-guard && npm run test:atdd-scoring && npm run test:atdd-workflow-guidance && npm run test:generation-healing && npm run test:framework-scaffold-install-isolation && npm run test:transcript-harness && npm run test:suite-manifest && npm run test:teach-me-testing-scoring && npm run test:eval-data && npm run test:eval-atdd-data && npm run test:eval-ci-data && npm run test:eval-framework-scaffold-data && npm run test:eval-nfr-data && npm run test:nfr-validate-corpus && npm run test:nfr-evidence-guidance && npm run test:eval-test-design-data && npm run test:eval-trace-data && npm run test:eval-routing-data && npm run test:eval-routing-boundaries && npm run test:eval-routing-evidence && npm run test:eval-transcript-data && npm run test:eval-teach-me-testing-data && npm run test:eval-schemas && npm run test:eval-diagnostics && npm run test:eval-replay && npm run test:eval-quality-corpus && npm run test:schema-versions && npm run test:contract-sources && npm run test:contracts && npm run test:contract-oracles && npm run test:probe-sources && npm run test:test-design-qualification && npm run test:test-review-qualification && npm run test:trace-qualification && npm run test:nfr-qualification && npm run test:ci-qualification && npm run test:probe-corpus && npm run test:compare-dominance && npm run test:compare-eval-runs && npm run test:trial-set-scoring && npm run test:automate-cli && npm run test:automate-generation-evidence && npm run test:automate-eval-fixture && npm run test:eval-automate-data && npm run test:clock-port && npm run test:probe-conformance && npm run test:corpus-conformance && npm run test:file-system-conformance && npm run test:file-system-port && npm run test:probe-targets && npm run test:port-totality && npm run test:ci-coverage && npm run test:ci-coverage-filters && npm run test:install-actionlint && npm run test:workflows-lint && npm run test:install-linux-sandbox && npm run test:shards && npm run test:groups && npm run test:cli && npm run test:workflow-cli && npm run test:test-review-pr-scope && npm run test:test-review-report && npm run test:test-review-rubric && npm run test:bmad-output-gated && npm run test:release-metadata && npm run test:release-notes && npm run test:lockfile-age && npm run test:lockfile-age-cache && npm run test:licences && npm run test:supply-chain && npm run test:boundary && npm run test:direction && npm run test:lineage && npm run test:guard-publish && npm run test:layering-boundary-lineage && npm run test:changelog && npm run test:tea-workflow-descriptions && npm run test:docs-mermaid && npm run test:llms-headroom && npm run validate:schemas && npm run lint && npm run lint:md && npm run format:check",
95
+ "test": "npm run test:conflict-markers && npm run test:schemas && npm run test:install && npm run test:evaluate-guidance && npm run test:evaluate-authoring && npm run test:evaluate-gap-loop && npm run test:evaluate-dogfood && npm run test:evaluate-check && npm run test:evaluate-compare && npm run test:evaluate-before-state && npm run test:evaluate-ci-gates && npm run test:evaluate-ci-tiers && npm run test:evaluate-ci-repositories:tagged-release-pr && npm run test:evaluate-ci-repositories:tagged-release-merge && npm run test:evaluate-ci-repositories:tagged-release-release && npm run test:evaluate-ci-repositories:nightly-deploy-pr && npm run test:evaluate-ci-repositories:nightly-deploy-merge && npm run test:evaluate-ci-repositories:nightly-deploy-scheduled && npm run test:evaluate-ci-repositories:nightly-deploy-release && npm run test:evaluate-ci-render && npm run test:evaluate-pr-mcp && npm run test:evaluate-pr-api && npm run test:evaluate-pr-suite && npm run test:evaluate-pr-workflow && npm run test:evaluate-pr-tool-use && npm run test:evaluate-pr-promptfoo && npm run test:evaluate-pr-ai-feature && npm run test:evaluate-pr-test-review && npm run test:evaluate-pr-gap-loop && npm run test:evaluate-pr-learn && npm run test:evaluate-pr-tutorial && npm run test:evaluate-partitions && npm run test:evaluate-partition-plans-views-and-check && npm run test:evaluate-partition-plans-preflight-and-run && npm run test:evaluate-partition-plans-score && npm run test:evaluate-partition-plans-attempts-seal-and-mappings && npm run test:evaluate-partition-plans-attempts-records && npm run test:evaluate-partition-plans-attempts-gameability && npm run test:evaluate-interpret && npm run test:evaluate-calibration && npm run test:evaluate-boundaries && npm run test:evaluate-preflight && npm run test:evaluate-isolation-conformance && npm run test:evaluate-mutation:qualification-basics && npm run test:evaluate-mutation:qualification-guards && npm run test:evaluate-mutation:recovery && npm run test:evaluate-mutation:journals && npm run test:evaluate-run && npm run test:evaluate-progress && npm run test:evaluate-aggregate && npm run test:evaluate-confinement && npm run test:evaluate-confinement-audit && npm run test:evaluate-confinement-history && npm run test:evaluate-confinement-git-state && npm run test:evaluate-confinement-network && npm run test:evaluate-held-inputs && npm run test:evaluate-arms-historical && npm run test:evaluate-arms-deployments && npm run test:evaluate-evaluators && npm run test:evaluate-evaluators-agent-version && npm run test:evaluate-agents && npm run test:evaluate-agents-importers && npm run test:evaluate-agents-sealed-brief && npm run test:evaluate-held-attempts && npm run test:evaluate-private && npm run test:evaluate-records && npm run test:evaluate-mcp && npm run test:evaluate-api && npm run test:evaluate-workflow && npm run test:evaluate-tool-use && npm run test:evaluate-promptfoo && npm run test:evaluate-learned-framework && npm run test:docs-tutorial && npm run test:knowledge && npm run test:criteria-fragments && npm run test:doc-count-sources && npm run test:planning-doc-sources && npm run test:doc-counts && npm run test:doc-claim-sources && npm run test:doc-claims && npm run test:doc-invocation-entry && npm run test:doc-invocations && npm run test:doc-link-anchors && npm run test:docs-build-names && npm run test:docs-evaluate-sidebar && npm run test:docs-gap-reader && npm run test:enforce-hook && npm run test:framework-scaffold && npm run test:isolation-primitives && npm run test:atdd-isolation && npm run test:atdd-net-guard && npm run test:atdd-scoring && npm run test:atdd-workflow-guidance && npm run test:generation-healing && npm run test:framework-scaffold-install-isolation && npm run test:transcript-harness && npm run test:suite-manifest && npm run test:teach-me-testing-scoring && npm run test:eval-data && npm run test:eval-atdd-data && npm run test:eval-ci-data && npm run test:eval-framework-scaffold-data && npm run test:eval-nfr-data && npm run test:nfr-validate-corpus && npm run test:nfr-evidence-guidance && npm run test:eval-test-design-data && npm run test:eval-trace-data && npm run test:eval-routing-data && npm run test:eval-routing-boundaries && npm run test:eval-routing-evidence && npm run test:eval-transcript-data && npm run test:eval-teach-me-testing-data && npm run test:eval-schemas && npm run test:eval-diagnostics && npm run test:eval-replay && npm run test:eval-quality-corpus && npm run test:schema-versions && npm run test:contract-sources && npm run test:contracts && npm run test:contract-oracles && npm run test:probe-sources && npm run test:test-design-qualification && npm run test:test-review-qualification && npm run test:trace-qualification && npm run test:nfr-qualification && npm run test:ci-qualification && npm run test:probe-corpus && npm run test:compare-dominance && npm run test:compare-eval-runs && npm run test:trial-set-scoring && npm run test:automate-cli && npm run test:automate-generation-evidence && npm run test:automate-eval-fixture && npm run test:eval-automate-data && npm run test:clock-port && npm run test:probe-conformance && npm run test:corpus-conformance && npm run test:file-system-conformance && npm run test:file-system-port && npm run test:probe-targets && npm run test:port-totality && npm run test:ci-coverage && npm run test:ci-coverage-filters && npm run test:install-actionlint && npm run test:workflows-lint && npm run test:install-linux-sandbox && npm run test:shards && npm run test:groups && npm run test:cli && npm run test:workflow-cli && npm run test:framework-cli && npm run test:test-review-pr-scope && npm run test:test-review-report && npm run test:test-review-rubric && npm run test:bmad-output-gated && npm run test:release-metadata && npm run test:release-notes && npm run test:lockfile-age && npm run test:lockfile-age-cache && npm run test:licences && npm run test:supply-chain && npm run test:boundary && npm run test:direction && npm run test:lineage && npm run test:guard-publish && npm run test:layering-boundary-lineage && npm run test:changelog && npm run test:tea-workflow-descriptions && npm run test:docs-mermaid && npm run test:llms-headroom && npm run validate:schemas && npm run lint && npm run lint:md && npm run format:check",
95
96
  "test:atdd-isolation": "node test/test-atdd-isolation.js",
96
97
  "test:atdd-net-guard": "node test/test-atdd-net-guard.js",
97
98
  "test:atdd-scoring": "node test/test-atdd-scoring.js",
@@ -221,6 +222,7 @@
221
222
  "test:evaluate-workflow": "node test/test-evaluate-workflow.js",
222
223
  "test:file-system-conformance": "node test/test-file-system-conformance.js",
223
224
  "test:file-system-port": "node test/test-file-system-port.js",
225
+ "test:framework-cli": "node --test test/test-framework-setup.js test/test-framework-cli.js",
224
226
  "test:framework-scaffold": "node test/test-framework-scaffold.js",
225
227
  "test:framework-scaffold-install-isolation": "node test/test-framework-scaffold-install-isolation.js",
226
228
  "test:generation-healing": "node test/test-generation-healing.js",
@@ -15,6 +15,8 @@ Resolve `setup_scope` as `framework`, `ci`, or `both`:
15
15
  - Framework-only wording or a supplied framework artifact selects `framework`.
16
16
  - For setup requests whose CI intent remains unclear, ask exactly once: **“Do you want CI too?”** A yes selects `both`; a no selects `framework`. Record `ci_scope_answered = true`. Wait for the answer before any project writes, including custom activation hooks. An unattended/headless run with no scope answer defaults to `framework`, records `ci_scope_defaulted = true`, and says in its completion summary that CI was not included. Explicit or inferred CI intent still selects `ci` or `both` in unattended runs.
17
17
 
18
+ For fresh Create whose scope includes CI, verify a Git worktree before activation, hooks, checkpoints, dependency installation or framework generation. Run `git rev-parse --is-inside-work-tree` from the project root and require a successful `true` result. A worktree marker file is valid. If missing, halt with "Git repository required for CI/CD setup." Keep the project untouched and ask the caller to initialize its repository separately. A headless run cannot silently skip this prerequisite or initialize `.git` as part of setup. Resume retains its journal and checks this prerequisite before continuing CI preflight.
19
+
18
20
  For CI Create without an existing framework, make a read-only inventory before any hook or checkpoint writes. Search root and configured test directories, scripts and their config paths, language manifests, and mobile test directories. A config can live under `tests/`; a Go suite needs test files. If the framework is missing, ask once: **“There is no test framework yet. Set it up now and continue CI in this run?”** Acceptance sets `setup_scope = both`, `framework_first_accepted = true`, and returns to framework selection before continuing CI. Declining stops the run and leaves the project untouched. This choice occurs before shared or CI checkpoints are created. An explicit request for both already authorizes framework setup. Resume uses its existing consent and saved current position.
19
21
 
20
22
  For both Create with a framework already present, set `framework_reused = true`. Inventory its stack, package manager, commands and config paths; validate and reuse it. Preserve its files and checkpoint. Skip framework Create preflight's no-existing-framework conflict and continue CI under its actual existing contract.
@@ -55,6 +55,15 @@ For Create, use the journal's saved next-step position when available. Before di
55
55
 
56
56
  For Edit, persist each phase's confirmed exact targets, requested changes, evaluation-plan paths/digests, current step/subsection and completed edits. Save target pre-edit digests before writes and actual resulting digests after each applied change. After an interrupted edit, read the actual files and reconcile the requested change against the saved before state before reapplying. A fully applied change advances without rewriting it; an uncertain partial edit halts for the missing decision. Resume uses these saved targets and confirmed requests, skips completed edits, and continues at the saved edit subsection. It never enters a Create resume loader.
57
57
 
58
+ For a CLI Edit, keep the exact selected artifacts in `phase_targets` and store `edit_applied.<phase>` as an array of `{path, status, before_sha256, after_sha256, reason}` records.
59
+ Use `status: applied` only when the selected artifact changed; use `status: noop` for an unchanged artifact with an explicit reason.
60
+ The CLI supplies its observed before digests in the prompt and the digest algorithm, including permissions and directory inputs.
61
+ Compute the after digest from the actual selected artifact, then save its outcome before phase completion.
62
+
58
63
  For Validate, save exact selected artifacts, `validation_scope`, `run_timestamp`, the reserved report path, reservation `run_id`, completed criteria/results and the current subsection. Journal the chosen report path before its exclusive-create reservation. After reservation succeeds, save that fact before further validation. If interrupted between those writes, recover the report only when its `run_id` proves this run owns it. Resume continues the same owned report and unfinished checks. It never reserves another report or truncates another run's report. If a saved reservation is missing or its ownership differs, stop and report the inconsistency.
59
64
 
65
+ CLI validation reports are nonempty regular Markdown files with YAML frontmatter containing the journal's `run_id`, completed `status: PASS`, `WARN`, or `FAIL`, and `validated_artifacts` naming the exact phase targets.
66
+ Preserve the selected inputs during Validate.
67
+ A fresh operation writes a fresh report; Resume keeps its same-run reserved report.
68
+
60
69
  Dispatch the saved next file/subsection directly for journal-backed Resume. This is a one-way dispatch: do not route back into a phase resume loader or call the shared router recursively. A completed framework checkpoint with pending CI continues to CI under the saved original operation; completed phases keep their checkpoints and hooks. A phase becomes completed after its saved operation's checks and applicable phase hook succeed; a Validate report can complete with a FAIL verdict. Canonical completion is a distinct final journal position.
@@ -2,7 +2,7 @@
2
2
 
3
3
  [bmod]
4
4
  code = "tea"
5
- version = "2.0.4-next.1"
5
+ version = "2.0.4-next.2"
6
6
  update_source = "github:bmad-code-org/bmad-method-test-architecture-enterprise/skills"
7
7
  skills = [
8
8
  "bmad-tea",