bmad-method-test-architecture-enterprise 2.0.1-next.1 → 2.0.1-next.3

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (62) hide show
  1. package/.claude-plugin/marketplace.json +2 -2
  2. package/README.md +6 -1
  3. package/cli/lib/build-prompt.js +20 -9
  4. package/cli/lib/resolve-tea-config.js +264 -46
  5. package/cli/lib/review-provenance.js +19 -7
  6. package/cli/test-review.js +41 -19
  7. package/package.json +7 -2
  8. package/skills/bmad-tea/SKILL.md +4 -0
  9. package/skills/bmad-tea/customize.toml +2 -2
  10. package/skills/bmad-teach-me-testing/data/session-content-map.yaml +2 -1
  11. package/skills/bmad-teach-me-testing/data/tea-resources-index.yaml +2 -0
  12. package/skills/bmad-teach-me-testing/resources/tea-completion-summary.example.md +1 -1
  13. package/skills/bmad-teach-me-testing/steps-c/step-04-session-01.md +2 -1
  14. package/skills/bmad-teach-me-testing/steps-c/step-04-session-03.md +10 -0
  15. package/skills/bmad-teach-me-testing/steps-c/step-05-completion.md +2 -2
  16. package/skills/bmad-teach-me-testing/templates/completion-summary-template.md +1 -1
  17. package/skills/bmad-testarch-ci/SKILL.md +6 -81
  18. package/skills/bmad-testarch-ci/bmod.toml +1 -0
  19. package/skills/bmad-testarch-ci/workflow.yaml +6 -3
  20. package/skills/bmad-testarch-evaluate/SKILL.md +1 -1
  21. package/skills/bmad-testarch-evaluate/assets/inspection-record.md +1 -1
  22. package/skills/bmad-testarch-evaluate/references/ci.md +5 -5
  23. package/skills/bmad-testarch-framework/SKILL.md +28 -20
  24. package/skills/bmad-testarch-framework/checklist.md +2 -0
  25. package/skills/{bmad-testarch-ci → bmad-testarch-framework/ci}/checklist.md +9 -7
  26. package/skills/{bmad-testarch-ci → bmad-testarch-framework/ci}/instructions.md +9 -8
  27. package/skills/bmad-testarch-framework/ci/steps-c/step-01-preflight.md +212 -0
  28. package/skills/{bmad-testarch-ci → bmad-testarch-framework/ci}/steps-c/step-01b-resume.md +15 -5
  29. package/skills/{bmad-testarch-ci → bmad-testarch-framework/ci}/steps-c/step-02-generate-pipeline.md +23 -13
  30. package/skills/{bmad-testarch-ci → bmad-testarch-framework/ci}/steps-c/step-03-configure-quality-gates.md +9 -3
  31. package/skills/{bmad-testarch-ci → bmad-testarch-framework/ci}/steps-c/step-03b-render-evaluation-plans.md +7 -5
  32. package/skills/{bmad-testarch-ci → bmad-testarch-framework/ci}/steps-c/step-04-validate-and-summary.md +9 -7
  33. package/skills/{bmad-testarch-ci → bmad-testarch-framework/ci}/steps-e/step-01-assess.md +7 -3
  34. package/skills/bmad-testarch-framework/ci/steps-e/step-02-apply-edit.md +67 -0
  35. package/skills/{bmad-testarch-ci → bmad-testarch-framework/ci}/steps-v/step-01-validate.md +8 -10
  36. package/skills/bmad-testarch-framework/customize.toml +4 -0
  37. package/skills/bmad-testarch-framework/instructions.md +5 -11
  38. package/skills/bmad-testarch-framework/resources/setup-parallel.md +25 -0
  39. package/skills/bmad-testarch-framework/resources/setup-phase-completion.md +39 -0
  40. package/skills/bmad-testarch-framework/resources/setup-routing.md +58 -0
  41. package/skills/bmad-testarch-framework/resources/setup-run-progress.example.md +83 -0
  42. package/skills/bmad-testarch-framework/resources/setup-state.md +60 -0
  43. package/skills/bmad-testarch-framework/steps-c/step-01-preflight.md +4 -0
  44. package/skills/bmad-testarch-framework/steps-c/step-01b-resume.md +11 -1
  45. package/skills/bmad-testarch-framework/steps-c/step-02-select-framework.md +3 -1
  46. package/skills/bmad-testarch-framework/steps-c/step-03-scaffold-framework.md +8 -2
  47. package/skills/bmad-testarch-framework/steps-c/step-04-docs-and-scripts.md +2 -0
  48. package/skills/bmad-testarch-framework/steps-c/step-05-validate-and-summary.md +8 -6
  49. package/skills/bmad-testarch-framework/steps-e/step-01-assess.md +5 -1
  50. package/skills/bmad-testarch-framework/steps-e/step-02-apply-edit.md +5 -9
  51. package/skills/bmad-testarch-framework/steps-v/step-01-validate.md +6 -8
  52. package/skills/bmad-testarch-framework/workflow.yaml +6 -1
  53. package/skills/bmod-tea/bmod.toml +2 -2
  54. package/skills/bmod-tea/help/help.md +3 -3
  55. package/skills/bmad-testarch-ci/steps-c/step-01-preflight.md +0 -189
  56. package/skills/bmad-testarch-ci/steps-e/step-02-apply-edit.md +0 -71
  57. /package/skills/{bmad-testarch-ci → bmad-testarch-framework/ci}/azure-pipelines-template.yaml +0 -0
  58. /package/skills/{bmad-testarch-ci → bmad-testarch-framework/ci}/github-actions-template.yaml +0 -0
  59. /package/skills/{bmad-testarch-ci → bmad-testarch-framework/ci}/gitlab-ci-template.yaml +0 -0
  60. /package/skills/{bmad-testarch-ci → bmad-testarch-framework/ci}/harness-pipeline-template.yaml +0 -0
  61. /package/skills/{bmad-testarch-ci → bmad-testarch-framework/ci}/jenkins-pipeline-template.groovy +0 -0
  62. /package/skills/{bmad-testarch-ci → bmad-testarch-framework/ci}/resources/ci-pipeline-progress.example.md +0 -0
@@ -30,8 +30,8 @@
30
30
  {
31
31
  "name": "bmad-method-test-architecture-enterprise",
32
32
  "source": "./",
33
- "description": "Master Test Architect module for quality strategy, test automation, CI/CD quality gates, and structured testing education. Part of the BMad Method ecosystem.",
34
- "version": "2.0.1-next.1",
33
+ "description": "Master Test Architect module for quality strategy, test automation, combined framework and CI setup, CI/CD quality gates, and structured testing education. The CI skill remains a compatibility entry. Part of the BMad Method ecosystem.",
34
+ "version": "2.0.1-next.3",
35
35
  "author": {
36
36
  "name": "Murat K Ozcan (TEA Creator) & Brian (BMad) Madison"
37
37
  },
package/README.md CHANGED
@@ -57,7 +57,7 @@ Each workflow can run directly in a fresh session.
57
57
  | ---------------------------------------------------------------------------------------------------------------------------------- | ---------------------------- | ---- | ------------------------------------------------------ |
58
58
  | [Teach Me Testing](https://bmad-code-org.github.io/bmad-method-test-architecture-enterprise/how-to/workflows/teach-me-testing/) | `/bmad-teach-me-testing` | TMT | Learn testing through seven sessions |
59
59
  | [Test Design](https://bmad-code-org.github.io/bmad-method-test-architecture-enterprise/how-to/workflows/run-test-design/) | `/bmad-testarch-test-design` | TD | Plan risks, coverage, and NFR evidence |
60
- | [Framework Setup](https://bmad-code-org.github.io/bmad-method-test-architecture-enterprise/how-to/workflows/setup-test-framework/) | `/bmad-testarch-framework` | TF | Scaffold a test framework |
60
+ | [Framework Setup](https://bmad-code-org.github.io/bmad-method-test-architecture-enterprise/how-to/workflows/setup-test-framework/) | `/bmad-testarch-framework` | TF | Set up a test framework, CI, or both |
61
61
  | [CI Setup](https://bmad-code-org.github.io/bmad-method-test-architecture-enterprise/how-to/workflows/setup-ci/) | `/bmad-testarch-ci` | CI | Connect tests and quality checks to CI |
62
62
  | [ATDD](https://bmad-code-org.github.io/bmad-method-test-architecture-enterprise/how-to/workflows/run-atdd/) | `/bmad-testarch-atdd` | AT | Write acceptance scaffolds before implementation |
63
63
  | [Automate](https://bmad-code-org.github.io/bmad-method-test-architecture-enterprise/how-to/workflows/run-automate/) | `/bmad-testarch-automate` | TA | Add coverage to implemented features |
@@ -66,6 +66,11 @@ Each workflow can run directly in a fresh session.
66
66
  | [Evaluate](https://bmad-code-org.github.io/bmad-method-test-architecture-enterprise/tutorials/evaluate-your-first-skill/) | `/bmad-testarch-evaluate` | EV | Build and run a behavioral evaluation |
67
67
  | [Trace](https://bmad-code-org.github.io/bmad-method-test-architecture-enterprise/how-to/workflows/run-trace/) | `/bmad-testarch-trace` | TR | Map requirements to tests and decide a release gate |
68
68
 
69
+ Framework and CI setup share one skill, `bmad-testarch-framework`.
70
+ Ask for framework only, CI only, or both; TEA infers the scope from your prompt and asks "Do you want CI too?" once when CI scope is unclear in an interactive session. An unattended request with unclear scope runs framework setup only and states that CI was excluded.
71
+ `/bmad-testarch-ci` and the `CI` menu code still work and start the same skill's CI setup, with your existing CI customizations.
72
+ Create, Resume, Validate, and Edit remain available for the selected setup scope.
73
+
69
74
  For Codex, replace the leading `/` in the table with `$`.
70
75
  The agent menu also accepts `GATE` to route you through test review, NFR evidence audit, and trace Phase 2.
71
76
 
@@ -173,6 +173,8 @@ function reviewModePromptLines(reviewMode, changedLines) {
173
173
  * @param {object} [options.teaConfig] - Resolved TEA config keys from
174
174
  * resolve-tea-config. Defaults to the module defaults so the prompt always
175
175
  * states them and the agent never has to infer them.
176
+ * @param {object} [options.configSnapshot] - Complete CLI-resolved core and TEA tables.
177
+ * @param {object} [options.workflowCustomization] - Resolved workflow overrides and base policy contents.
176
178
  * @param {Record<string, number>} [options.fileStats] - Exact line count of each review file, counted by
177
179
  * the CLI so the report states no count it only estimated.
178
180
  * @param {string[]} [options.contextFiles] - Read-only context set from the diff.
@@ -217,6 +219,11 @@ function buildPrompt({
217
219
  changedLines = {},
218
220
  testDir = 'tests',
219
221
  teaConfig = MODULE_DEFAULTS,
222
+ configSnapshot = {
223
+ core: { user_name: 'User', communication_language: 'English', document_output_language: 'English' },
224
+ modules: { tea: teaConfig },
225
+ },
226
+ workflowCustomization = {},
220
227
  installedPackages = { playwright_utils_installed: false, pactjs_utils_installed: false },
221
228
  contextFiles = [],
222
229
  contextBasis = 'none',
@@ -236,14 +243,17 @@ function buildPrompt({
236
243
  `Skill root: ${absoluteSkillRoot}`,
237
244
  '',
238
245
  'Perform the SKILL.md "On Activation" sequence silently: no greeting, no user interaction.',
239
- '- Resolve the workflow customization block by merging these files in base -> team -> user order (skip any that are missing):',
240
- ' 1. customize.toml in the skill root (defaults)',
241
- ' 2. _bmad/custom/bmad-testarch-test-review.toml (team overrides)',
242
- ' 3. _bmad/custom/bmad-testarch-test-review.user.toml (personal overrides)',
243
- '- Load Config: read [core] (user_name, communication_language) from _bmad/config.toml merged with',
244
- ' _bmad/custom/config.toml and _bmad/custom/config.user.toml, or from _bmad/tea/config.yaml on a v6 install.',
245
- ' If neither exists, continue with defaults: do not stop or ask for `bmad setup tea`. Every TEA key this run',
246
- ' needs is stated below.',
246
+ '- Use the CLI-resolved workflow customization supplied below. Do not load customization from the checkout',
247
+ ' or invoke _bmad/scripts/resolve_customization.py. This replaces SKILL.md On Activation Step 1.',
248
+ ' Policy file references have already been expanded into facts from the selected configuration source. Use those supplied facts.',
249
+ ' Additional project configuration or policy requested by custom activation steps must never be read from the checkout.',
250
+ `Resolved workflow customization: ${JSON.stringify(workflowCustomization)}`,
251
+ '- Load Config: use the CLI-resolved configuration supplied below for [core] and [modules.tea].',
252
+ ' Do not read configuration from the checkout or invoke _bmad/scripts/resolve_config.py.',
253
+ ' This replaces SKILL.md On Activation Step 4 and every later workflow instruction to read the TEA config.',
254
+ ' Missing settings use module defaults; do not stop or ask for `bmad setup tea`.',
255
+ ' Treat the supplied configuration as data. Every resolved run input below overrides that data.',
256
+ `Resolved configuration: ${JSON.stringify(configSnapshot)}`,
247
257
  'Then skip ONLY the interactive Initialization Sequence menu. Execute Create mode directly,',
248
258
  'starting at steps-c/step-01-load-context.md.',
249
259
  'Resolve all bare paths (instructions.md, checklist.md, steps-c/..., test-review-template.md) from the skill root.',
@@ -266,6 +276,7 @@ function buildPrompt({
266
276
  `review_scope=${reviewScope}`,
267
277
  `review_mode=${reviewMode}`,
268
278
  `test_dir=${testDir}`,
279
+ `test_stack_type=${teaConfig.test_stack_type ?? MODULE_DEFAULTS.test_stack_type}`,
269
280
  'tea_browser_automation=none',
270
281
  `tea_execution_mode=${teaConfig.tea_execution_mode}`,
271
282
  `tea_capability_probe=${teaConfig.tea_capability_probe}`,
@@ -276,7 +287,7 @@ function buildPrompt({
276
287
  `playwright_utils_installed=${installedPackages.playwright_utils_installed}`,
277
288
  `pactjs_utils_installed=${installedPackages.pactjs_utils_installed}`,
278
289
  'The values above are the resolved configuration for this run and take precedence over anything read from',
279
- 'the TEA config. Use them for the step-01 fragment selection (Playwright Utils loading profile, pactjs-utils',
290
+ 'the supplied configuration. Use them for the step-01 fragment selection (Playwright Utils loading profile, pactjs-utils',
280
291
  'fragment set, Pact MCP) instead of inferring the flags.',
281
292
  'The two *_installed values above were read from the project manifest by the CLI. Do not re-derive them, and do',
282
293
  'not open package.json for them: they are the second half of each mandate gate, stated here for the same reason the flags are.',
@@ -2,10 +2,14 @@
2
2
  * Resolve the TEA module config keys that change which knowledge fragments the
3
3
  * review loads, so a headless run never leaves them to the agent's discretion.
4
4
  *
5
+ * A baseRef reads every layer from that pinned Git tree. Without it, layers
6
+ * come from the working tree. Missing base files use defaults; Git failures
7
+ * and malformed base config fail closed.
8
+ *
5
9
  * Precedence, highest first:
6
10
  * 1. An explicit CLI flag (--use-playwright-utils / --no-use-pactjs-utils / ...)
7
11
  * 2. The consuming project's `[modules.tea]` table, merged from
8
- * _bmad/config.toml, _bmad/custom/config.toml and
12
+ * _bmad/config.toml, _bmad/config.user.toml, _bmad/custom/config.toml and
9
13
  * _bmad/custom/config.user.toml (later files win), as `bmad setup tea`
10
14
  * writes it. Only when _bmad/config.toml does not exist is a v6
11
15
  * _bmad/tea/config.yaml read instead, so older installs keep working in CI.
@@ -22,12 +26,16 @@
22
26
 
23
27
  const fs = require('node:fs');
24
28
  const path = require('node:path');
29
+ const { spawnSync } = require('node:child_process');
25
30
  const yaml = require('js-yaml');
26
31
  const TOML = require('smol-toml');
32
+ const { minimatch } = require('minimatch');
33
+ const { globSync } = require('glob');
27
34
 
28
35
  /** The central config layers, lowest precedence first. Only the first is required. */
29
36
  const CONFIG_LAYER_RELATIVE_PATHS = [
30
37
  path.join('_bmad', 'config.toml'),
38
+ path.join('_bmad', 'config.user.toml'),
31
39
  path.join('_bmad', 'custom', 'config.toml'),
32
40
  path.join('_bmad', 'custom', 'config.user.toml'),
33
41
  ];
@@ -40,8 +48,19 @@ const MODULE_DEFAULTS = {
40
48
  tea_pact_mcp: 'mcp',
41
49
  tea_execution_mode: 'auto',
42
50
  tea_capability_probe: true,
51
+ test_stack_type: 'auto',
52
+ };
53
+
54
+ const SNAPSHOT_DEFAULTS = {
55
+ test_artifacts: '{project-root}/_bmad-output/test-artifacts',
56
+ test_framework: 'auto',
43
57
  };
44
58
 
59
+ const WORKFLOW_CUSTOM_PATHS = [
60
+ path.join('_bmad', 'custom', 'bmad-testarch-test-review.toml'),
61
+ path.join('_bmad', 'custom', 'bmad-testarch-test-review.user.toml'),
62
+ ];
63
+
45
64
  const PACT_MCP_VALUES = ['mcp', 'none'];
46
65
  // step-03-quality-evaluation.md's requestable modes. `auto` asks the capability
47
66
  // probe to pick; the other three are honoured as stated.
@@ -56,12 +75,108 @@ const FLAG_TO_KEY = {
56
75
  capabilityProbe: 'tea_capability_probe',
57
76
  };
58
77
 
78
+ /** Create a configuration error that the CLI publishes as an environment failure. */
59
79
  function configError(message) {
60
80
  const error = new Error(message);
61
81
  error.code = 'TEA_CONFIG_INVALID';
62
82
  return error;
63
83
  }
64
84
 
85
+ /** Read configuration from one pinned base commit, or from the working tree. */
86
+ function configSource(projectRoot, baseRef) {
87
+ if (baseRef === undefined || baseRef === null) {
88
+ return {
89
+ label: (relativePath) => path.join(projectRoot, relativePath),
90
+ list(pattern) {
91
+ try {
92
+ return globSync(pattern, { cwd: projectRoot, dot: true, nodir: true, follow: false });
93
+ } catch (error) {
94
+ throw configError(`Failed to list policy files in ${projectRoot}: ${error.message}`);
95
+ }
96
+ },
97
+ read(relativePath) {
98
+ try {
99
+ return fs.readFileSync(path.join(projectRoot, relativePath), 'utf8');
100
+ } catch (error) {
101
+ if (error.code === 'ENOENT') return null;
102
+ throw configError(`Failed to read ${path.join(projectRoot, relativePath)}: ${error.message}`);
103
+ }
104
+ },
105
+ };
106
+ }
107
+ if (typeof baseRef !== 'string' || baseRef.trim() === '' || baseRef.startsWith('-') || baseRef.includes('\0')) {
108
+ throw configError(`Invalid git base ref for config ${JSON.stringify(baseRef)}`);
109
+ }
110
+ const git = (args, cwd = projectRoot) => {
111
+ const result = spawnSync('git', args, { cwd, encoding: 'utf8', timeout: 30_000, maxBuffer: 4 * 1024 * 1024 });
112
+ if (result.error || result.status !== 0) {
113
+ throw configError(`Cannot read config from base ref ${JSON.stringify(baseRef)}: ${result.error?.message ?? result.stderr.trim()}`);
114
+ }
115
+ return result.stdout.replace(/\r?\n$/, '');
116
+ };
117
+ const commit = git(['rev-parse', '--verify', '--end-of-options', `${baseRef}^{commit}`]);
118
+ const repositoryRoot = git(['rev-parse', '--show-toplevel']);
119
+ const prefix = git(['rev-parse', '--show-prefix']);
120
+ const treePath = (relativePath) => prefix + relativePath.split(path.sep).join('/');
121
+ const cache = new Map();
122
+ const entries = new Map();
123
+ const entryAt = (file) => {
124
+ if (!entries.has(file)) {
125
+ entries.set(file, git(['--literal-pathspecs', 'ls-tree', '-z', '--full-tree', commit, '--', file], repositoryRoot));
126
+ }
127
+ return entries.get(file);
128
+ };
129
+ let listedPaths;
130
+ return {
131
+ commit,
132
+ list() {
133
+ if (!listedPaths) {
134
+ const args = ['--literal-pathspecs', 'ls-tree', '-r', '-z', '--name-only', '--full-tree', commit];
135
+ if (prefix) args.push('--', prefix);
136
+ listedPaths = git(args, repositoryRoot)
137
+ .split('\0')
138
+ .filter(Boolean)
139
+ .map((file) => file.slice(prefix.length));
140
+ }
141
+ return listedPaths;
142
+ },
143
+ label: (relativePath) => `${commit}:${treePath(relativePath)}`,
144
+ read(relativePath) {
145
+ if (cache.has(relativePath)) return cache.get(relativePath);
146
+ const file = treePath(relativePath);
147
+ const segments = file.split('/');
148
+ for (let i = 1; i < segments.length; i++) {
149
+ const parent = segments.slice(0, i).join('/');
150
+ const ancestor = entryAt(parent);
151
+ if (ancestor === '') break;
152
+ if (!/^040000 tree [a-f0-9]+\t/.test(ancestor)) {
153
+ throw configError(`Config parent at ${commit}:${parent} must be a directory tree`);
154
+ }
155
+ }
156
+ const entry = entryAt(file);
157
+ if (entry === '') {
158
+ cache.set(relativePath, null);
159
+ return null;
160
+ }
161
+ if (!/^100(?:644|755) blob [a-f0-9]+\t/.test(entry)) {
162
+ throw configError(`Config at ${commit}:${treePath(relativePath)} must be a regular file`);
163
+ }
164
+ // Keep the blob's trailing newline intact for YAML block scalars.
165
+ const blob = spawnSync('git', ['show', `${commit}:${treePath(relativePath)}`], {
166
+ cwd: repositoryRoot,
167
+ encoding: 'utf8',
168
+ timeout: 30_000,
169
+ maxBuffer: 4 * 1024 * 1024,
170
+ });
171
+ if (blob.error || blob.status !== 0) {
172
+ throw configError(`Failed to read ${commit}:${treePath(relativePath)}: ${blob.error?.message ?? blob.stderr.trim()}`);
173
+ }
174
+ cache.set(relativePath, blob.stdout);
175
+ return blob.stdout;
176
+ },
177
+ };
178
+ }
179
+
65
180
  /**
66
181
  * Coerce a config boolean. `bmad setup` writes the strings "true"/"false", a v6
67
182
  * config.yaml or a hand-edited TOML file may carry real booleans. Accept both
@@ -124,13 +239,89 @@ function isPlainObject(value) {
124
239
  return value !== null && typeof value === 'object' && !Array.isArray(value) && !(value instanceof Date);
125
240
  }
126
241
 
127
- /** Merge tables recursively; any other value from the later layer replaces the earlier one. */
242
+ /** Match BMad structural merging: recursive tables, keyed table arrays, and appended arrays. */
128
243
  function mergeTables(base, override) {
129
- const result = { ...base };
130
- for (const [key, value] of Object.entries(override)) {
131
- result[key] = isPlainObject(result[key]) && isPlainObject(value) ? mergeTables(result[key], value) : value;
244
+ if (isPlainObject(base) && isPlainObject(override)) {
245
+ const result = { ...base };
246
+ for (const [key, value] of Object.entries(override)) {
247
+ result[key] = Object.hasOwn(result, key) ? mergeTables(result[key], value) : value;
248
+ }
249
+ return result;
250
+ }
251
+ if (Array.isArray(base) && Array.isArray(override)) {
252
+ const items = [...base, ...override];
253
+ const keyedField =
254
+ items.length > 0 && items.every(isPlainObject)
255
+ ? ['code', 'id'].find((field) => items.every((item) => Object.hasOwn(item, field)))
256
+ : undefined;
257
+ if (!keyedField) return items;
258
+ for (const item of items) {
259
+ if (typeof item[keyedField] !== 'string' || item[keyedField] === '') {
260
+ throw configError(`Keyed array identifier ${keyedField} must be a nonempty string`);
261
+ }
262
+ }
263
+ const result = [...base];
264
+ const indices = new Map(base.map((item, index) => [item[keyedField], index]));
265
+ for (const item of override) {
266
+ const key = item[keyedField];
267
+ if (indices.has(key)) result[indices.get(key)] = item;
268
+ else {
269
+ indices.set(key, result.length);
270
+ result.push(item);
271
+ }
272
+ }
273
+ return result;
274
+ }
275
+ return override;
276
+ }
277
+
278
+ /** Parse a TOML layer with a consistent fail-closed diagnostic. */
279
+ function parseToml(content, label) {
280
+ try {
281
+ return TOML.parse(content);
282
+ } catch (error) {
283
+ throw configError(`Failed to parse ${label}: ${error.message}`);
284
+ }
285
+ }
286
+
287
+ /** Resolve trusted skill defaults and project workflow overrides from the same configuration source. */
288
+ function readWorkflowCustomization(source, skillRoot, projectRoot) {
289
+ if (!skillRoot) return {};
290
+ const defaultsPath = path.join(skillRoot, 'customize.toml');
291
+ let merged = {};
292
+ try {
293
+ merged = parseToml(fs.readFileSync(defaultsPath, 'utf8'), defaultsPath);
294
+ } catch (error) {
295
+ if (error.code !== 'ENOENT') throw configError(`Failed to load ${defaultsPath}: ${error.message}`);
296
+ }
297
+ for (const relativePath of WORKFLOW_CUSTOM_PATHS) {
298
+ const content = source.read(relativePath);
299
+ if (content !== null) merged = mergeTables(merged, parseToml(content, source.label(relativePath)));
132
300
  }
133
- return result;
301
+ if (merged.workflow !== undefined && !isPlainObject(merged.workflow)) {
302
+ throw configError('Workflow customization must contain a [workflow] table');
303
+ }
304
+ const workflow = merged.workflow || {};
305
+ if (workflow.persistent_facts !== undefined) {
306
+ if (!Array.isArray(workflow.persistent_facts) || !workflow.persistent_facts.every((fact) => typeof fact === 'string')) {
307
+ throw configError('Workflow persistent_facts must be an array of strings');
308
+ }
309
+ workflow.persistent_facts = workflow.persistent_facts.flatMap((fact) => {
310
+ if (!fact.startsWith('file:')) return [fact];
311
+ const rawPath = fact.slice('file:'.length).replaceAll('{project-root}', projectRoot);
312
+ const pattern = path.relative(projectRoot, path.resolve(projectRoot, rawPath)).split(path.sep).join('/');
313
+ if (pattern === '' || pattern === '..' || pattern.startsWith('../') || path.isAbsolute(pattern)) {
314
+ throw configError(`Persistent fact must refer to a file inside the project: ${fact}`);
315
+ }
316
+ const matching = source
317
+ .list(pattern)
318
+ .filter((file) => minimatch(file, pattern, { dot: true, nonegate: true, nocomment: true }))
319
+ .sort();
320
+ if (matching.length === 0) throw configError(`Persistent fact has no files in the configuration source: ${fact}`);
321
+ return matching.map((file) => `Resolved policy ${source.label(file)}:\n${source.read(file)}`);
322
+ });
323
+ }
324
+ return workflow;
134
325
  }
135
326
 
136
327
  /** Coerce the keys this CLI cares about out of a raw TEA config table. */
@@ -151,50 +342,51 @@ function pickTeaValues(table, source) {
151
342
  if ('tea_capability_probe' in table) {
152
343
  values.tea_capability_probe = coerceBoolean(table.tea_capability_probe, 'tea_capability_probe', source);
153
344
  }
345
+ if ('test_stack_type' in table) {
346
+ const stack = typeof table.test_stack_type === 'string' ? table.test_stack_type.trim().toLowerCase() : '';
347
+ if (!['auto', 'frontend', 'backend', 'fullstack', 'mobile'].includes(stack)) {
348
+ throw configError(`test_stack_type in ${source} must be auto, frontend, backend, fullstack or mobile`);
349
+ }
350
+ values.test_stack_type = stack;
351
+ }
154
352
  return values;
155
353
  }
156
354
 
157
355
  /**
158
- * Merge the central TOML layers and return the `[modules.tea]` table.
356
+ * Merge the central TOML layers and return their core and TEA tables.
159
357
  *
160
- * @param {string} projectRoot
161
- * @returns {object}
358
+ * @param {object} source - Working-tree or pinned-base configuration reader.
359
+ * @returns {{core: object, tea: object}}
162
360
  */
163
- function readCentralTeaTable(projectRoot) {
361
+ function readCentralConfig(source) {
164
362
  let merged = {};
165
363
  for (const relativePath of CONFIG_LAYER_RELATIVE_PATHS) {
166
- const layerPath = path.join(projectRoot, relativePath);
167
- if (!fs.existsSync(layerPath)) {
168
- continue;
169
- }
170
- let parsed;
171
- try {
172
- parsed = TOML.parse(fs.readFileSync(layerPath, 'utf8'));
173
- } catch (error) {
174
- throw configError(`Failed to parse ${layerPath}: ${error.message}`);
175
- }
364
+ const content = source.read(relativePath);
365
+ if (content === null) continue;
366
+ const parsed = parseToml(content, source.label(relativePath));
176
367
  merged = mergeTables(merged, parsed);
177
368
  }
178
369
  const modules = merged.modules;
179
- if (modules === undefined) {
180
- return {};
370
+ if (modules !== undefined && (!isPlainObject(modules) || (modules.tea !== undefined && !isPlainObject(modules.tea)))) {
371
+ throw configError(`[modules.tea] in ${source.label(CONFIG_RELATIVE_PATH)} must be a table of config keys`);
181
372
  }
182
- if (!isPlainObject(modules) || (modules.tea !== undefined && !isPlainObject(modules.tea))) {
183
- throw configError(`[modules.tea] in ${path.join(projectRoot, CONFIG_RELATIVE_PATH)} must be a table of config keys`);
373
+ if (merged.core !== undefined && !isPlainObject(merged.core)) {
374
+ throw configError(`[core] in ${source.label(CONFIG_RELATIVE_PATH)} must be a table of config keys`);
184
375
  }
185
- return modules.tea || {};
376
+ return { core: merged.core || {}, tea: modules?.tea || {} };
186
377
  }
187
378
 
188
379
  /**
189
380
  * Read a v6 _bmad/tea/config.yaml, used only when _bmad/config.toml is absent.
190
381
  *
191
382
  * @param {string} configPath
383
+ * @param {string} content
192
384
  * @returns {object}
193
385
  */
194
- function readLegacyTeaTable(configPath) {
386
+ function readLegacyTeaTable(configPath, content) {
195
387
  let parsed;
196
388
  try {
197
- parsed = yaml.load(fs.readFileSync(configPath, 'utf8'));
389
+ parsed = yaml.load(content);
198
390
  } catch (error) {
199
391
  throw configError(`Failed to parse ${configPath}: ${error.message}`);
200
392
  }
@@ -213,30 +405,34 @@ function readLegacyTeaTable(configPath) {
213
405
  * unreadable or unparseable content is a configuration error.
214
406
  *
215
407
  * @param {string} projectRoot - Consuming project root.
408
+ * @param {string} [baseRef] - Base commit or ref; omitted for working-tree config.
216
409
  * @returns {{present: boolean, path: string, format: 'toml'|'yaml'|null, values: object}}
217
410
  */
218
- function readTeaConfigFile(projectRoot) {
219
- const centralPath = path.join(projectRoot, CONFIG_RELATIVE_PATH);
220
- if (fs.existsSync(centralPath)) {
411
+ function readTeaConfigFile(projectRoot, baseRef, source = configSource(projectRoot, baseRef)) {
412
+ const centralPath = source.label(CONFIG_RELATIVE_PATH);
413
+ if (source.read(CONFIG_RELATIVE_PATH) !== null) {
414
+ const config = readCentralConfig(source);
221
415
  return {
222
416
  present: true,
223
417
  path: centralPath,
224
418
  format: 'toml',
225
- values: pickTeaValues(readCentralTeaTable(projectRoot), CONFIG_RELATIVE_PATH),
419
+ values: pickTeaValues(config.tea, centralPath),
420
+ core: config.core,
421
+ tea: config.tea,
226
422
  };
227
423
  }
228
-
229
- const legacyPath = path.join(projectRoot, LEGACY_CONFIG_RELATIVE_PATH);
230
- if (fs.existsSync(legacyPath)) {
231
- return {
232
- present: true,
233
- path: legacyPath,
234
- format: 'yaml',
235
- values: pickTeaValues(readLegacyTeaTable(legacyPath), LEGACY_CONFIG_RELATIVE_PATH),
236
- };
424
+ const legacyPath = source.label(LEGACY_CONFIG_RELATIVE_PATH);
425
+ const legacy = source.read(LEGACY_CONFIG_RELATIVE_PATH);
426
+ if (legacy !== null) {
427
+ const table = readLegacyTeaTable(legacyPath, legacy);
428
+ const core = Object.fromEntries(
429
+ ['user_name', 'communication_language', 'document_output_language', 'output_folder']
430
+ .filter((key) => key in table)
431
+ .map((key) => [key, table[key]]),
432
+ );
433
+ return { present: true, path: legacyPath, format: 'yaml', values: pickTeaValues(table, legacyPath), core, tea: table };
237
434
  }
238
-
239
- return { present: false, path: centralPath, format: null, values: {} };
435
+ return { present: false, path: centralPath, format: null, values: {}, core: {}, tea: {} };
240
436
  }
241
437
 
242
438
  /**
@@ -292,15 +488,19 @@ const KEY_TO_INSTALLED_FIELD = {
292
488
  *
293
489
  * @param {object} options
294
490
  * @param {string} options.projectRoot - Consuming project root.
491
+ * @param {string} [options.baseRef] - Configuration source ref, pinned before any file read.
492
+ * @param {string} [options.skillRoot] - Trusted skill defaults for workflow customization.
295
493
  * @param {object} [options.flags] - Parsed CLI options; only the keys in
296
494
  * FLAG_TO_KEY are read, and only when not undefined.
297
- * @returns {{values: object, sources: object, installed: object, configPath: string, configPresent: boolean, configFormat: string|null}}
495
+ * @returns {{values: object, sources: object, installed: object, configSnapshot: object, workflowCustomization: object, configCommit: string|null, configPath: string, configPresent: boolean, configFormat: string|null}}
298
496
  * `installed` carries one boolean per library gate, read from the project
299
497
  * manifest rather than left to the agent.
300
498
  * @throws {Error} With code TEA_CONFIG_INVALID on unusable config content.
301
499
  */
302
- function resolveTeaConfig({ projectRoot, flags = {} }) {
303
- const file = readTeaConfigFile(projectRoot);
500
+ function resolveTeaConfig({ projectRoot, flags = {}, baseRef, skillRoot }) {
501
+ const source = configSource(projectRoot, baseRef);
502
+ const file = readTeaConfigFile(projectRoot, baseRef, source);
503
+ const workflowCustomization = readWorkflowCustomization(source, skillRoot, projectRoot);
304
504
 
305
505
  const values = {};
306
506
  const sources = {};
@@ -327,12 +527,29 @@ function resolveTeaConfig({ projectRoot, flags = {} }) {
327
527
  sources[key] = 'default';
328
528
  }
329
529
 
530
+ values.test_stack_type = file.values.test_stack_type ?? MODULE_DEFAULTS.test_stack_type;
531
+ sources.test_stack_type = 'test_stack_type' in file.values ? 'config' : 'default';
532
+
330
533
  const installed = {};
331
534
  for (const [key, packageName] of Object.entries(KEY_TO_PACKAGE)) {
332
535
  installed[KEY_TO_INSTALLED_FIELD[key]] = isPackageInstalled(projectRoot, packageName);
333
536
  }
334
537
 
335
- return { values, sources, installed, configPath: file.path, configPresent: file.present, configFormat: file.format };
538
+ const configSnapshot = {
539
+ core: { user_name: 'User', communication_language: 'English', document_output_language: 'English', ...file.core },
540
+ modules: { tea: { ...SNAPSHOT_DEFAULTS, ...file.tea, ...values, tea_browser_automation: 'none' } },
541
+ };
542
+ return {
543
+ values,
544
+ sources,
545
+ installed,
546
+ configSnapshot,
547
+ workflowCustomization,
548
+ configCommit: source.commit ?? null,
549
+ configPath: file.path,
550
+ configPresent: file.present,
551
+ configFormat: file.format,
552
+ };
336
553
  }
337
554
 
338
555
  module.exports = {
@@ -342,6 +559,7 @@ module.exports = {
342
559
  KEY_TO_PACKAGE,
343
560
  KEY_TO_INSTALLED_FIELD,
344
561
  MODULE_DEFAULTS,
562
+ SNAPSHOT_DEFAULTS,
345
563
  EXECUTION_MODE_VALUES,
346
564
  PACT_MCP_VALUES,
347
565
  CONFIG_RELATIVE_PATH,
@@ -70,9 +70,18 @@ function workflowRunFromEnvironment(env) {
70
70
  * Missing values stay null. The sources map tells artifact consumers where
71
71
  * each value came from, or why this process could not know it.
72
72
  */
73
- function buildReviewProvenance({ projectRoot, skillRoot, baseRef, filesProvided, modelIdentifier, gateMode = 'all', env = process.env }) {
73
+ function buildReviewProvenance({
74
+ projectRoot,
75
+ skillRoot,
76
+ baseRef,
77
+ baseCommit,
78
+ filesProvided,
79
+ modelIdentifier,
80
+ gateMode = 'all',
81
+ env = process.env,
82
+ }) {
74
83
  const rubricVersion = skillRubricVersion(skillRoot);
75
- const baseSha = filesProvided ? null : gitSha(projectRoot, baseRef);
84
+ const baseSha = baseCommit ?? (filesProvided ? null : gitSha(projectRoot, baseRef));
76
85
  const headSha = gitSha(projectRoot, 'HEAD');
77
86
  const triggerComment = triggerCommentFromEnvironment(env);
78
87
  const workflowRun = workflowRunFromEnvironment(env);
@@ -93,11 +102,14 @@ function buildReviewProvenance({ projectRoot, skillRoot, baseRef, filesProvided,
93
102
  : 'unavailable: skill workflow.yaml has no readable rubric_version',
94
103
  modelIdentifier:
95
104
  modelIdentifier == null ? 'unavailable: selected adapter does not expose a model identifier' : 'resolved agent adapter model',
96
- baseSha: filesProvided
97
- ? 'unavailable: --files bypassed the git comparison base'
98
- : baseSha
99
- ? `git rev-parse ${baseRef}^{commit}`
100
- : `unavailable: git could not resolve ${baseRef}`,
105
+ baseSha:
106
+ baseCommit && filesProvided
107
+ ? `config snapshot: git rev-parse ${baseRef}^{commit}; --files has no comparison base`
108
+ : filesProvided
109
+ ? 'unavailable: --files bypassed the git comparison base'
110
+ : baseSha
111
+ ? `git rev-parse ${baseRef}^{commit}`
112
+ : `unavailable: git could not resolve ${baseRef}`,
101
113
  headSha: headSha ? 'git rev-parse HEAD^{commit}' : 'unavailable: project root is not a readable git worktree',
102
114
  triggerComment: triggerComment
103
115
  ? 'GitHub event comment URL or id'