@xulthekl/team-flow 0.29.2 → 0.31.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (53) hide show
  1. package/.claude/always/phase-guard.md +1 -1
  2. package/.claude-plugin/marketplace.json +3 -3
  3. package/.claude-plugin/plugin.json +2 -2
  4. package/.codex-plugin/plugin.json +1 -1
  5. package/.cursor-plugin/marketplace.json +1 -1
  6. package/.cursor-plugin/plugin.json +2 -2
  7. package/.github/plugin/marketplace.json +2 -2
  8. package/AGENTS.md +10 -4
  9. package/CHANGELOG.md +88 -0
  10. package/GEMINI.md +1 -1
  11. package/HANDOFF.md +4 -4
  12. package/INSTALL.md +1 -1
  13. package/README.md +5 -5
  14. package/agents/architecture-design.md +1 -0
  15. package/agents/build-executor.md +81 -0
  16. package/agents/contract-builder.md +78 -0
  17. package/agents/cross-change-consistency-checker.md +1 -1
  18. package/agents/need-explorer.md +67 -0
  19. package/agents/release-archivist.md +82 -0
  20. package/agents/spec-writer.md +83 -0
  21. package/docs/README_en.md +1 -1
  22. package/gemini-extension.json +1 -1
  23. package/hooks/session-start +2 -2
  24. package/llms.txt +1 -1
  25. package/package.json +2 -2
  26. package/plugin.json +2 -2
  27. package/scripts/ensure-branch.mjs +200 -42
  28. package/scripts/guard/checks/test-matrix-complete.mjs +67 -0
  29. package/scripts/guard/guard.mjs +5 -1
  30. package/scripts/lib/cmd-doctor.mjs +40 -1
  31. package/scripts/lib/cmd-state.mjs +33 -13
  32. package/scripts/lib/hash.mjs +11 -0
  33. package/scripts/lib/state-loader.mjs +34 -1
  34. package/scripts/lib/test-matrix-export.mjs +231 -0
  35. package/scripts/lib/test-merge.mjs +540 -0
  36. package/scripts/team-flow.mjs +6 -0
  37. package/skills/build-executor/SKILL.md +14 -4
  38. package/skills/build-executor/implementer-prompt.md +38 -3
  39. package/skills/code-reviewer/SKILL.md +28 -1
  40. package/skills/code-reviewer/code-reviewer-prompt.md +10 -0
  41. package/skills/contract-builder/SKILL.md +78 -0
  42. package/skills/need-explorer/SKILL.md +2 -0
  43. package/skills/release-archivist/SKILL.md +37 -2
  44. package/skills/spec-writer/SKILL.md +5 -1
  45. package/skills/test-strategy/SKILL.md +70 -0
  46. package/skills/test-strategy/references/adversarial-patterns.md +0 -0
  47. package/skills/test-strategy/references/complexity-grading.md +137 -0
  48. package/skills/test-strategy/references/design-methods-detail.md +183 -0
  49. package/skills/workflow-orchestrator/references/s1-path-router.md +4 -0
  50. package/skills/workflow-start/SKILL.md +29 -15
  51. package/skills/workflow-start/references/routing-rules.md +29 -6
  52. package/tests/lib/cmd-install-workbuddy.test.mjs +1 -1
  53. package/tests/lib/ensure-branch.test.mjs +52 -1
@@ -3,6 +3,8 @@ import { readFileSync, readdirSync, existsSync, statSync } from 'node:fs';
3
3
  import { join } from 'node:path';
4
4
  import { loadConfig } from './config-loader.mjs';
5
5
  import { PLATFORM_RUNTIME_INVENTORY } from './platform-runtime-inventory.mjs';
6
+ // 非法 state 巡检所需的共享常量与读取器(来源:workflow-feedback 2026-08-01,#100004)。
7
+ import { readState, VALID_STATES } from './state-loader.mjs';
6
8
 
7
9
  const RUNTIME_SKILLS = new Set([
8
10
  'workflow-start', 'need-explorer', 'spec-writer', 'contract-builder',
@@ -220,6 +222,42 @@ function checkDocs(root) {
220
222
  return { pass: false, message: warnings.join('; ') };
221
223
  }
222
224
 
225
+ // 非法 state 巡检(来源:workflow-feedback 2026-08-01,#100004):
226
+ // 扫描 changes/<name>/.team-flow.yaml,若 state 字段不在 VALID_STATES 则报 FAIL。
227
+ // 非法值通常由子代理绕过 CLI 直接 Edit .team-flow.yaml 导致,提示修复为合法值。
228
+ // 沿用 checkSkills 的目录扫描 + 计数报告风格。
229
+ function checkChangeStates(root) {
230
+ const changesDir = join(root, 'changes');
231
+ if (!existsSync(changesDir)) {
232
+ return { pass: true, message: 'no changes/ directory (skipped)' };
233
+ }
234
+ const dirs = readdirSync(changesDir).filter(f => {
235
+ try { return statSync(join(changesDir, f)).isDirectory(); } catch { return false; }
236
+ });
237
+ const invalid = [];
238
+ let checked = 0;
239
+ for (const d of dirs) {
240
+ if (!existsSync(join(changesDir, d, '.team-flow.yaml'))) continue;
241
+ checked += 1;
242
+ const current = readState(join(changesDir, d)).state;
243
+ if (!VALID_STATES.includes(current)) {
244
+ invalid.push(`${d}='${current}'`);
245
+ }
246
+ }
247
+ if (checked === 0) {
248
+ return { pass: true, message: 'no state files under changes/ (skipped)' };
249
+ }
250
+ if (invalid.length > 0) {
251
+ return {
252
+ pass: false,
253
+ message:
254
+ `state 值非法(可能是子代理绕过 CLI 直接 Edit .team-flow.yaml 所致):${invalid.join('; ')}。` +
255
+ `请修复为合法值:${VALID_STATES.join('/')}`,
256
+ };
257
+ }
258
+ return { pass: true, message: `${checked} change(s) have legal state values` };
259
+ }
260
+
223
261
  export async function run(args) {
224
262
  const root = process.cwd();
225
263
  const config = loadConfig(root);
@@ -236,6 +274,7 @@ export async function run(args) {
236
274
  ['dist/', checkDist(root)],
237
275
  ['Node.js', checkNodeVersion()],
238
276
  ['Docs', checkDocs(root)],
277
+ ['Change states', checkChangeStates(root)],
239
278
  ];
240
279
 
241
280
  // Config check
@@ -264,4 +303,4 @@ export async function run(args) {
264
303
  }
265
304
  }
266
305
 
267
- export { checkVersionConsistency, checkHooks, checkCodexManifest, checkSkills, checkRuntimeDistribution, checkDist, checkRootPluginAuthor, checkNodeVersion, checkDocs };
306
+ export { checkVersionConsistency, checkHooks, checkCodexManifest, checkSkills, checkRuntimeDistribution, checkDist, checkRootPluginAuthor, checkNodeVersion, checkDocs, checkChangeStates };
@@ -2,18 +2,15 @@
2
2
  import { parseArgs } from 'node:util';
3
3
  import { spawnSync } from 'node:child_process';
4
4
  import { existsSync, mkdirSync } from 'node:fs';
5
- import { dirname, join } from 'node:path';
5
+ import path, { dirname, join } from 'node:path';
6
6
  import { fileURLToPath } from 'node:url';
7
- import { readState, writeState, updateField, rebuildState } from './state-loader.mjs';
8
- import { computeArtifactsHash, computeContractHash } from './hash.mjs';
7
+ // VALID_STATES 统一从 state-loader.mjs 引入(状态机合法值唯一真相源),不再本地硬编码。
8
+ // 来源:workflow-feedback 2026-08-01,#100005。
9
+ import { readState, writeState, updateField, rebuildState, VALID_STATES } from './state-loader.mjs';
10
+ import { computeArtifactsHash, computeContractHash, computeTestMatrixHash } from './hash.mjs';
9
11
 
10
12
  const __dirname = dirname(fileURLToPath(import.meta.url));
11
13
 
12
- const VALID_STATES = [
13
- 'exploring', 'specifying', 'bridging', 'approved-for-build',
14
- 'executing', 'debugging', 'closing', 'abandoned',
15
- ];
16
-
17
14
  const SETTABLE_FIELDS = [
18
15
  'workflow', 'test_result', 'batches_completed', 'spec_merged',
19
16
  'dp_0_decisions', 'dp_0_confirmed', 'dp_0_timestamp', 'dp_0_result',
@@ -32,6 +29,8 @@ const SETTABLE_FIELDS = [
32
29
  'dp_a_result', 'dp_a_timestamp', 'dp_a_adjustments',
33
30
  // Compound engineering capture gate (v0.24.0 复利贯穿强制化)
34
31
  'compound_skipped',
32
+ // Test matrix gate (v0.12 §45.4)
33
+ 'test_matrix_skipped',
35
34
  ];
36
35
 
37
36
  export async function run(args) {
@@ -44,7 +43,7 @@ export async function run(args) {
44
43
  });
45
44
 
46
45
  const sub = positionals[0]; // init | check | transition | get | rebuild | set
47
- const changeDir = positionals[1];
46
+ let changeDir = positionals[1];
48
47
  const arg = positionals[2]; // <to-state> for transition, <field> for get
49
48
 
50
49
  if (!changeDir) {
@@ -53,6 +52,13 @@ export async function run(args) {
53
52
  process.exit(2);
54
53
  }
55
54
 
55
+ // 修复相对路径 bug(来源:workflow-feedback 2026-08-01,#100005)。
56
+ // 根因:readState 用 path.join(changeDir,...) 按用户 cwd 解析,而 transition 分支 spawn guard
57
+ // 子进程时 cwd 设为包根(join(__dirname,'..','..'))+ 原样 changeDir,两处基准不一致——用户传
58
+ // 相对路径时 guard 在包根下找不到产物。统一在入口 resolve 为绝对路径,使后续 readState /
59
+ // guard spawn / existsSync 全部基于同一绝对基准,消除 cwd 歧义。
60
+ changeDir = path.resolve(changeDir);
61
+
56
62
  // Unknown subcommand: report a usage error (exit 2) BEFORE the BUG-B
57
63
  // state-file existence check, so a bad subcommand is not masked by the
58
64
  // "No state file" error (which would return exit 1 instead of exit 2).
@@ -80,15 +86,18 @@ export async function run(args) {
80
86
  }
81
87
  const hash = computeArtifactsHash(changeDir);
82
88
  const ch = computeContractHash(changeDir);
89
+ const tmh = computeTestMatrixHash(changeDir);
83
90
  const state = readState(changeDir);
84
91
  state.artifacts_hash = hash;
85
92
  state.contract_hash = ch;
93
+ // v0.12 §42.5: test_matrix_hash is independent from artifacts_hash
94
+ state.test_matrix_hash = tmh;
86
95
  state.last_transition = new Date().toISOString();
87
96
  writeState(changeDir, state);
88
97
  if (values.json) {
89
- console.log(JSON.stringify({ ok: true, artifacts_hash: hash, contract_hash: ch }));
98
+ console.log(JSON.stringify({ ok: true, artifacts_hash: hash, contract_hash: ch, test_matrix_hash: tmh }));
90
99
  } else {
91
- console.log(`State initialized. artifacts_hash: ${hash}`);
100
+ console.log(`State initialized. artifacts_hash: ${hash}` + (tmh ? `, test_matrix_hash: ${tmh}` : ''));
92
101
  }
93
102
  break;
94
103
  }
@@ -205,7 +214,7 @@ export async function run(args) {
205
214
  break;
206
215
  }
207
216
  case 'rebuild': {
208
- const state = rebuildState(changeDir, { computeArtifactsHash, computeContractHash });
217
+ const state = rebuildState(changeDir, { computeArtifactsHash, computeContractHash, computeTestMatrixHash });
209
218
  if (values.json) {
210
219
  console.log(JSON.stringify({ ok: true, state: state.state }));
211
220
  } else {
@@ -222,7 +231,18 @@ export async function run(args) {
222
231
  process.exit(2);
223
232
  }
224
233
  if (!SETTABLE_FIELDS.includes(field)) {
225
- console.error(`⛔ Field '${field}' is not settable (use 'transition' for state, or check SETTABLE_FIELDS)`);
234
+ // 错误提示升级(来源:workflow-feedback 2026-08-01,#100005):
235
+ // 'state' 是最常被误用 set 的字段,专门给出完整 transition 语法示例;合法状态清单
236
+ // 引用 VALID_STATES(唯一真相源),不硬编码。保留 'not settable' 措辞以兼容既有回归测试。
237
+ if (field === 'state') {
238
+ console.error(
239
+ `⛔ 'state' 不是可 set 的字段(not settable)。状态转换请用完整语法:\n` +
240
+ ` tf state transition <change-dir 绝对路径> <目标状态>\n` +
241
+ ` 合法状态:${VALID_STATES.join('/')}`
242
+ );
243
+ } else {
244
+ console.error(`⛔ Field '${field}' is not settable (use 'transition' for state, or check SETTABLE_FIELDS)`);
245
+ }
226
246
  process.exit(1);
227
247
  }
228
248
  updateField(changeDir, field, value);
@@ -67,6 +67,17 @@ export function computeContractHash(changeDir) {
67
67
  return `sha256:${hash.digest('hex')}`;
68
68
  }
69
69
 
70
+ // Compute SHA256 hash of test-matrix.md alone (v0.12 §42.5).
71
+ // Independent from artifacts_hash to avoid self-circulation:
72
+ // modify matrix → contract stale → rollback bridging → regenerate matrix.
73
+ export function computeTestMatrixHash(changeDir) {
74
+ const matrix = path.join(changeDir, 'test-matrix.md');
75
+ if (!fs.existsSync(matrix)) return null;
76
+ const hash = crypto.createHash('sha256');
77
+ hash.update(fs.readFileSync(matrix, 'utf-8'));
78
+ return `sha256:${hash.digest('hex')}`;
79
+ }
80
+
70
81
  // Fast staleness check: compare stored artifacts_hash against current.
71
82
  export function isContractFresh(changeDir, stateLoader) {
72
83
  const stateFile = path.join(changeDir, '.team-flow.yaml');
@@ -4,6 +4,14 @@ import path from 'node:path';
4
4
 
5
5
  const STATE_FILE = '.team-flow.yaml';
6
6
 
7
+ // 状态机合法值唯一真相源 (来源:workflow-feedback 2026-08-01,#100005)。
8
+ // 所有状态枚举校验统一引用此常量,禁止在各处硬编码状态字符串数组,避免多处定义漂移。
9
+ // cmd-state.mjs(transition 校验 / set 报错)、cmd-doctor.mjs(非法 state 巡检)均从这里 import。
10
+ export const VALID_STATES = [
11
+ 'exploring', 'specifying', 'bridging', 'approved-for-build',
12
+ 'executing', 'debugging', 'closing', 'abandoned',
13
+ ];
14
+
7
15
  const BUILTIN_DEFAULTS = {
8
16
  state: 'exploring',
9
17
  workflow: 'auto',
@@ -53,6 +61,9 @@ const BUILTIN_DEFAULTS = {
53
61
  dp_a_adjustments: null,
54
62
  // Compound engineering capture gate (v0.24.0 复利贯穿强制化)
55
63
  compound_skipped: null,
64
+ // Test matrix gate (v0.12 §42.5 + §45.4)
65
+ test_matrix_hash: null,
66
+ test_matrix_skipped: null,
56
67
  };
57
68
 
58
69
  /**
@@ -74,6 +85,20 @@ export function readState(changeDir) {
74
85
  * Write state object to .team-flow.yaml.
75
86
  */
76
87
  export function writeState(changeDir, state) {
88
+ // Defense-in-depth 状态合法性校验 (来源:workflow-feedback 2026-08-01,#100004)。
89
+ // 主修复是子代理 prompt 禁令(禁止绕过 CLI 直接 Edit .team-flow.yaml);此处是最后一道防线,
90
+ // 拦截任何非法 state 落盘。init/transition/rebuild/updateField 等 CLI 路径产生的 state 均合法
91
+ // (缺失时回退 'exploring'),不会误伤;仅当出现非法值时抛错,提示用 tf doctor 排查。
92
+ const effectiveState = state.state || 'exploring';
93
+ if (!VALID_STATES.includes(effectiveState)) {
94
+ throw new Error(
95
+ `Refusing to write illegal state '${effectiveState}' to ${path.join(changeDir, STATE_FILE)}. ` +
96
+ `Legal states: ${VALID_STATES.join(', ')}. ` +
97
+ `This is usually caused by a subagent bypassing the CLI and editing .team-flow.yaml directly — ` +
98
+ `restore a legal value or run 'tf doctor' to diagnose.`
99
+ );
100
+ }
101
+
77
102
  const filePath = path.join(changeDir, STATE_FILE);
78
103
  const lines = [];
79
104
  lines.push('# .team-flow.yaml — lightweight state machine');
@@ -140,6 +165,10 @@ export function writeState(changeDir, state) {
140
165
  lines.push('');
141
166
  lines.push('# === Compound engineering capture gate (v0.24.0) ===');
142
167
  lines.push(`compound_skipped: ${state.compound_skipped ?? 'null'}`);
168
+ lines.push('');
169
+ lines.push('# === Test matrix gate (v0.12 §42.5) ===');
170
+ lines.push(`test_matrix_hash: ${state.test_matrix_hash ?? 'null'}`);
171
+ lines.push(`test_matrix_skipped: ${state.test_matrix_skipped ?? 'null'}`);
143
172
 
144
173
  fs.writeFileSync(filePath, lines.join('\n') + '\n', 'utf-8');
145
174
  }
@@ -157,10 +186,14 @@ export function updateField(changeDir, field, value) {
157
186
  * Rebuild state file from artifacts — recomputes hashes.
158
187
  * Requires hash functions to be passed in (avoids circular dependency).
159
188
  */
160
- export function rebuildState(changeDir, { computeArtifactsHash, computeContractHash }) {
189
+ export function rebuildState(changeDir, { computeArtifactsHash, computeContractHash, computeTestMatrixHash }) {
161
190
  const state = readState(changeDir);
162
191
  state.artifacts_hash = computeArtifactsHash(changeDir);
163
192
  state.contract_hash = computeContractHash(changeDir);
193
+ // v0.12 §42.5: test_matrix_hash is independent from artifacts_hash
194
+ if (computeTestMatrixHash) {
195
+ state.test_matrix_hash = computeTestMatrixHash(changeDir);
196
+ }
164
197
  writeState(changeDir, state);
165
198
  return state;
166
199
  }
@@ -0,0 +1,231 @@
1
+ #!/usr/bin/env node
2
+ /**
3
+ * test-matrix-export — glaf4 test-matrix.json → team-flow test-matrix.md 格式转换
4
+ *
5
+ * v0.12 §46.1 定义,v0.31.0 实现为 CLI 子命令
6
+ * 用法:tf test-matrix-export <input.json> <output.md> [--change-id <id>] [--batch-id <id>]
7
+ *
8
+ * 功能:
9
+ * - 读取 glaf4 的 test-matrix.json
10
+ * - 保留全部 14 字段 + 包装 team_flow_context
11
+ * - 从 test_kind 派生 test_tier(unit/integration)
12
+ * - 输出 team-flow 格式的 test-matrix.md
13
+ */
14
+
15
+ import { readFileSync, writeFileSync } from 'node:fs';
16
+ import { resolve, basename } from 'node:path';
17
+
18
+ /**
19
+ * 解析 CLI 参数
20
+ */
21
+ function parseArgv(argv) {
22
+ const parsed = { _: [] };
23
+ for (let i = 0; i < argv.length; i++) {
24
+ if (argv[i] === '--change-id' && argv[i + 1]) {
25
+ parsed.changeId = argv[++i];
26
+ } else if (argv[i] === '--batch-id' && argv[i + 1]) {
27
+ parsed.batchId = argv[++i];
28
+ } else if (argv[i] === '--ledger-ref' && argv[i + 1]) {
29
+ parsed.ledgerRef = argv[++i];
30
+ } else if (!argv[i].startsWith('--')) {
31
+ parsed._.push(argv[i]);
32
+ }
33
+ }
34
+ return parsed;
35
+ }
36
+
37
+ /**
38
+ * test_kind → test_tier 映射(v0.12 §42.4)
39
+ */
40
+ const TEST_KIND_TO_TIER = {
41
+ // unit tier
42
+ pure_unit: 'unit',
43
+ mockito_unit: 'unit',
44
+ spring_assisted_unit: 'unit',
45
+ vitest_unit: 'unit',
46
+ vue_test_utils_mount: 'unit',
47
+ // integration tier
48
+ api_mockmvc_standalone: 'integration',
49
+ api_mockmvc_slice: 'integration',
50
+ api_mockmvc_boot: 'integration',
51
+ service_social: 'integration',
52
+ repository_h2: 'integration',
53
+ rabbitmq_social: 'integration',
54
+ redis_social: 'integration',
55
+ external_api_stub: 'integration',
56
+ test_infrastructure: 'integration',
57
+ };
58
+
59
+ /**
60
+ * 从 glaf4 case 派生 test_tier
61
+ */
62
+ function deriveTestTier(testKind) {
63
+ return TEST_KIND_TO_TIER[testKind] || 'unit';
64
+ }
65
+
66
+ /**
67
+ * 转义 markdown 表格单元格
68
+ */
69
+ function escapeCell(str) {
70
+ if (typeof str !== 'string') return String(str ?? '');
71
+ return str.replace(/\|/g, '\\|').replace(/\n/g, ' ');
72
+ }
73
+
74
+ /**
75
+ * 转换单个 case 为 markdown 行
76
+ */
77
+ function caseToRow(c) {
78
+ const tier = deriveTestTier(c.test_kind);
79
+ return [
80
+ c.case_id || '',
81
+ escapeCell(c.behavior || c.display_name || ''),
82
+ c.design_method || '',
83
+ escapeCell(typeof c.input === 'object' ? JSON.stringify(c.input) : (c.input || '')),
84
+ escapeCell(typeof c.expected === 'object' ? JSON.stringify(c.expected) : (c.expected || '')),
85
+ c.test_kind || '',
86
+ tier,
87
+ escapeCell(Array.isArray(c.required_stubs) ? c.required_stubs.join(', ') : (c.mock || '')),
88
+ c.work_mode || 'TDD',
89
+ c.test_file || '',
90
+ c.test_method_name || '',
91
+ c.run_command || '',
92
+ ].join(' | ');
93
+ }
94
+
95
+ /**
96
+ * 提取模块名(从 target 或 case_id 前缀)
97
+ */
98
+ function extractModuleName(matrix) {
99
+ if (matrix.target) {
100
+ // e.g., "com.example.service.OrderService" → "OrderService"
101
+ const parts = matrix.target.split('.');
102
+ return parts[parts.length - 1];
103
+ }
104
+ // fallback: from first case_id
105
+ if (matrix.cases?.[0]?.case_id) {
106
+ const id = matrix.cases[0].case_id;
107
+ const match = id.match(/^([A-Z][a-zA-Z]+)/);
108
+ if (match) return match[1];
109
+ }
110
+ return 'Unknown';
111
+ }
112
+
113
+ /**
114
+ * 转换 glaf4 matrix → team-flow markdown
115
+ */
116
+ export function convert(matrix, options = {}) {
117
+ const moduleName = extractModuleName(matrix);
118
+ const cases = matrix.cases || [];
119
+
120
+ // 统计
121
+ const unitCases = cases.filter(c => deriveTestTier(c.test_kind) === 'unit');
122
+ const integrationCases = cases.filter(c => deriveTestTier(c.test_kind) === 'integration');
123
+
124
+ // 提取 complexity
125
+ const complexity = matrix.target_complexity || {};
126
+ let complexityTier = 'medium';
127
+ if (complexity.is_trivial) complexityTier = 'trivial';
128
+ else if ((complexity.public_methods || 0) > 15 || (complexity.lines || 0) > 800 || (complexity.param_count || 0) > 6) {
129
+ complexityTier = 'complex';
130
+ }
131
+
132
+ // 提取 candidate ledger
133
+ const candidateLedger = matrix.source_candidate_ledger || [];
134
+
135
+ // 构建 markdown
136
+ const lines = [];
137
+ lines.push(`# Test Matrix — ${options.changeId || 'unknown'}`);
138
+ lines.push('');
139
+ lines.push('## Summary');
140
+ lines.push(`- Total cases: ${cases.length}`);
141
+ lines.push(`- Modules covered: 1 (${moduleName}, complexity: ${complexityTier})`);
142
+ lines.push(`- Unit cases: ${unitCases.length} (${Math.round(unitCases.length / cases.length * 100) || 0}%)`);
143
+ lines.push(`- Integration cases: ${integrationCases.length} (${Math.round(integrationCases.length / cases.length * 100) || 0}%)`);
144
+ lines.push(`- Deferred items: ${candidateLedger.filter(c => c.decision === 'deferred').length}`);
145
+ lines.push(`- Matrix revision: 1`);
146
+ lines.push('');
147
+
148
+ // Candidate Coverage Ledger
149
+ if (candidateLedger.length > 0) {
150
+ lines.push('## Candidate Coverage Ledger');
151
+ lines.push('');
152
+ lines.push('| candidate | category | decision | case_ids | reason |');
153
+ lines.push('|---|---|---|---|---|');
154
+ for (const c of candidateLedger) {
155
+ lines.push(`| ${escapeCell(c.candidate)} | ${c.category || ''} | ${c.decision} | ${escapeCell(Array.isArray(c.case_ids) ? c.case_ids.join(', ') : (c.case_ids || ''))} | ${escapeCell(c.reason || '')} |`);
156
+ }
157
+ lines.push('');
158
+ }
159
+
160
+ // Cases
161
+ lines.push('## Cases');
162
+ lines.push('');
163
+ lines.push(`### ${moduleName}`);
164
+ lines.push('');
165
+ lines.push('| case_id | behavior | design_method | input | expected | test_kind | test_tier | mock | work_mode | test_file | test_method_name | run_command |');
166
+ lines.push('|---|---|---|---|---|---|---|---|---|---|---|---|');
167
+ for (const c of cases) {
168
+ lines.push(`| ${caseToRow(c)} |`);
169
+ }
170
+ lines.push('');
171
+
172
+ // Deferred Items
173
+ const deferred = candidateLedger.filter(c => c.decision === 'deferred');
174
+ if (deferred.length > 0) {
175
+ lines.push('## Deferred Items');
176
+ lines.push('');
177
+ lines.push('| case_id | behavior | design_method | reason | deferred_since |');
178
+ lines.push('|---|---|---|---|---|');
179
+ for (const d of deferred) {
180
+ lines.push(`| ${escapeCell(Array.isArray(d.case_ids) ? d.case_ids[0] : (d.case_ids || ''))} | ${escapeCell(d.candidate)} | — | ${escapeCell(d.reason || '')} | ${new Date().toISOString().slice(0, 10)} |`);
181
+ }
182
+ lines.push('');
183
+ }
184
+
185
+ // team_flow_context footer
186
+ lines.push('---');
187
+ lines.push('');
188
+ lines.push('<!-- team_flow_context:');
189
+ lines.push(` change_id: ${options.changeId || 'unknown'}`);
190
+ if (options.batchId) lines.push(` batch_id: ${options.batchId}`);
191
+ lines.push(` closing_stage: test-merge`);
192
+ if (options.ledgerRef) lines.push(` ledger_ref: ${options.ledgerRef}`);
193
+ lines.push(` source: glaf4-tests-export`);
194
+ lines.push('-->');
195
+
196
+ return lines.join('\n') + '\n';
197
+ }
198
+
199
+ /**
200
+ * CLI entry point
201
+ */
202
+ export async function run(args) {
203
+ const argv = parseArgv(args || process.argv.slice(2));
204
+ const inputFile = argv._[0];
205
+ const outputFile = argv._[1];
206
+
207
+ if (!inputFile) {
208
+ console.error('Usage: tf test-matrix-export <input.json> <output.md> [--change-id <id>] [--batch-id <id>]');
209
+ process.exit(2);
210
+ }
211
+
212
+ if (!outputFile) {
213
+ console.error('Usage: tf test-matrix-export <input.json> <output.md>');
214
+ process.exit(2);
215
+ }
216
+
217
+ try {
218
+ const raw = readFileSync(resolve(inputFile), 'utf-8');
219
+ const matrix = JSON.parse(raw);
220
+ const md = convert(matrix, {
221
+ changeId: argv.changeId || basename(inputFile, '.json'),
222
+ batchId: argv.batchId,
223
+ ledgerRef: argv.ledgerRef,
224
+ });
225
+ writeFileSync(resolve(outputFile), md, 'utf-8');
226
+ console.log(`✅ test-matrix-export: ${inputFile} → ${outputFile}`);
227
+ } catch (err) {
228
+ console.error(`❌ test-matrix-export error: ${err.message}`);
229
+ process.exit(1);
230
+ }
231
+ }