@xulthekl/team-flow 0.51.1 → 0.53.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 (44) hide show
  1. package/.claude/always/phase-guard.md +1 -1
  2. package/.claude-plugin/marketplace.json +1 -1
  3. package/.claude-plugin/plugin.json +1 -1
  4. package/.codex-plugin/plugin.json +1 -1
  5. package/.cursor-plugin/marketplace.json +1 -1
  6. package/.cursor-plugin/plugin.json +1 -1
  7. package/.github/plugin/marketplace.json +2 -2
  8. package/AGENTS.md +3 -3
  9. package/CHANGELOG.md +100 -0
  10. package/GEMINI.md +1 -1
  11. package/HANDOFF.md +11 -0
  12. package/INSTALL.md +1 -1
  13. package/README.md +2 -2
  14. package/agents/release-archivist.md +4 -4
  15. package/docs/README_en.md +1 -1
  16. package/docs/usage-guide.md +5 -2
  17. package/gemini-extension.json +1 -1
  18. package/hooks/session-start +2 -2
  19. package/llms.txt +1 -1
  20. package/package.json +1 -1
  21. package/plugin.json +1 -1
  22. package/scripts/guard/checks/arch-merged.mjs +101 -0
  23. package/scripts/guard/checks/arch-snapshot.mjs +16 -3
  24. package/scripts/guard/guard.mjs +9 -1
  25. package/scripts/lib/arch-merge.mjs +411 -48
  26. package/scripts/lib/arch-parse.mjs +304 -26
  27. package/scripts/lib/cmd-arch.mjs +29 -1
  28. package/scripts/lib/cmd-publish.mjs +72 -10
  29. package/scripts/lib/cmd-state.mjs +2 -0
  30. package/scripts/lib/cmd-sync.mjs +73 -17
  31. package/scripts/lib/git-utils.mjs +18 -1
  32. package/scripts/lib/prototype-sync.mjs +2 -1
  33. package/scripts/lib/spec-merge.mjs +315 -0
  34. package/scripts/lib/state-loader.mjs +10 -0
  35. package/scripts/lib/test-merge.mjs +1 -1
  36. package/scripts/lib/test-record.mjs +34 -4
  37. package/scripts/team-flow.mjs +14 -1
  38. package/skills/architecture-design/templates/api.md +10 -4
  39. package/skills/prototype/SKILL.md +2 -2
  40. package/skills/release-archivist/SKILL.md +46 -36
  41. package/skills/release-archivist/references/closing-procedures.md +14 -4
  42. package/skills/spec-merger/SKILL.md +39 -34
  43. package/skills/workflow-bootstrap/SKILL.md +27 -18
  44. package/skills/workflow-orchestrator/references/state-model.md +3 -0
@@ -1,7 +1,20 @@
1
1
  // tf sync <change-dir> — merge delta specs into main specs with conflict detection
2
+ //
3
+ // v0.24 §96(触发来源:workflow-feedback 20260910-093040,P1):修复前为纯文件拷贝——
4
+ // 对「主基已存在」的 capability 整体覆盖,静默删除前序 change 交付的 Requirement
5
+ // (C3 覆盖 C2 主基,git diff --numstat = 168/60)。现在按 spec-merger SKILL.md 定义的
6
+ // 语义合并执行:
7
+ // - 主基不存在(capability 首次交付)→ 拷贝(原行为)
8
+ // - 主基存在 → spec-merge.mergeMainSpec(ADDED/MODIFIED/REMOVED/RENAMED)
9
+ // - delta 无操作段落 → clean skip(不写、不报错)
10
+ // 多 capability 原子性:全部在内存完成,全部成功才写盘;任一失败不写任何文件、退出非 0。
11
+ // 全部成功后写 spec_merged: true(并入 workflow-feedback 20260910-002000:此前唯一写入
12
+ // 路径是人工 `tf state set`,导致 specs-merged guard 恒 FAIL)。
2
13
  import { readFileSync, readdirSync, writeFileSync, existsSync, statSync, mkdirSync } from 'node:fs';
3
- import path, { join, basename } from 'node:path';
14
+ import path, { join, basename, dirname } from 'node:path';
4
15
  import { validateSpecPathLayout } from './spec-paths.mjs';
16
+ import { mergeMainSpec, deltaHasOperations } from './spec-merge.mjs';
17
+ import { updateField, readState } from './state-loader.mjs';
5
18
 
6
19
  function toPosix(value) {
7
20
  return value.replace(/\\/g, '/');
@@ -29,6 +42,13 @@ export async function run(args) {
29
42
  process.exit(2);
30
43
  }
31
44
 
45
+ // v0.24 §101.3:abandoned 检查上浮到命令层(原仅 spec-merger SKILL.md 的 Guard 约束)。
46
+ // 技能层约束无法机械保证;命令是唯一合并通道后,废弃 change 的 delta 不得合并进主基。
47
+ if (readState(changeDir).state === 'abandoned') {
48
+ console.error('❌ Abandoned change cannot be synced — delta specs are preserved for reference but must not be merged.');
49
+ process.exit(1);
50
+ }
51
+
32
52
  const { Validator } = await import('../../dist/index.js');
33
53
  const validator = new Validator();
34
54
 
@@ -61,14 +81,13 @@ export async function run(args) {
61
81
  console.log('⚠️ Sync conflicts detected:\n');
62
82
  for (const conflict of conflictReport.conflicts) {
63
83
  console.log(` Requirement: "${conflict.requirement}"`);
64
- console.log(` Modified by: ${conflict.changes.join(', ')}\n`);
84
+ console.log(` Modified by: ${conflict.changes.join(', ')}\n`);
65
85
  }
66
86
  console.log('Resolve conflicts before syncing. Consider syncing changes one at a time.');
67
87
  process.exit(1);
68
88
  }
69
89
  }
70
90
 
71
- // Perform sync: copy delta specs to main specs/
72
91
  const changeSpecsDir = join(changeDir, 'specs');
73
92
  const mainSpecsDir = join(process.cwd(), 'specs');
74
93
  const changeName = basename(changeDir);
@@ -78,25 +97,62 @@ export async function run(args) {
78
97
  process.exit(1);
79
98
  }
80
99
 
81
- if (!existsSync(mainSpecsDir)) {
82
- mkdirSync(mainSpecsDir, { recursive: true });
100
+ // 计算阶段(内存):任一 capability 失败即整体中止,不写任何文件(fail-closed)
101
+ const plan = [];
102
+ try {
103
+ for (const specFile of layout.specFiles) {
104
+ const capabilityDir = deriveCapabilityDir(changeSpecsDir, specFile);
105
+ const targetPath = join(mainSpecsDir, capabilityDir, 'spec.md');
106
+ const deltaContent = readFileSync(specFile, 'utf-8');
107
+
108
+ if (!existsSync(targetPath)) {
109
+ plan.push({ capabilityDir, targetPath, content: deltaContent, mode: 'created' });
110
+ continue;
111
+ }
112
+ if (!(await deltaHasOperations(deltaContent))) {
113
+ plan.push({ capabilityDir, targetPath, content: null, mode: 'no-ops' });
114
+ continue;
115
+ }
116
+ const mainContent = readFileSync(targetPath, 'utf-8');
117
+ const { content, report } = await mergeMainSpec(mainContent, deltaContent, changeName);
118
+ plan.push({ capabilityDir, targetPath, content, mode: 'merged', report });
119
+ }
120
+ } catch (e) {
121
+ console.error(`\n❌ Sync aborted(未写入任何文件):${e.message}`);
122
+ process.exit(1);
83
123
  }
84
124
 
85
- let synced = 0;
86
-
87
- for (const specFile of layout.specFiles) {
88
- const capabilityDir = deriveCapabilityDir(changeSpecsDir, specFile);
89
- const targetDir = join(mainSpecsDir, capabilityDir);
125
+ // 应用阶段:全部成功后才写盘
126
+ for (const item of plan) {
127
+ if (item.content === null) continue;
128
+ mkdirSync(dirname(item.targetPath), { recursive: true });
129
+ writeFileSync(item.targetPath, item.content);
130
+ }
90
131
 
91
- if (!existsSync(targetDir)) {
92
- mkdirSync(targetDir, { recursive: true });
132
+ for (const item of plan) {
133
+ const rel = `specs/${item.capabilityDir}/spec.md`;
134
+ if (item.mode === 'created') {
135
+ console.log(` 📋 Created: ${rel}`);
136
+ } else if (item.mode === 'no-ops') {
137
+ // 用告警而非普通信息:无操作段落也可能是 delta 段落标题拼写错误(畸形输入),
138
+ // 静默跳过会掩盖真实缺口(v0.24 §101.3)。
139
+ console.warn(` ⚠️ No delta operations found: ${rel} — 若该 capability 本应有变更,请检查 delta 的段落标题(## ADDED/MODIFIED/REMOVED/RENAMED Requirements)是否拼写正确`);
140
+ } else {
141
+ const r = item.report;
142
+ console.log(
143
+ ` 📋 Merged: ${rel} (ADDED ${r.added} / MODIFIED ${r.modified} / REMOVED ${r.removed}`
144
+ + ` / RENAMED ${r.renamed} / skipped ${r.skipped})`,
145
+ );
93
146
  }
147
+ }
94
148
 
95
- const content = readFileSync(specFile, 'utf-8');
96
- writeFileSync(join(targetDir, 'spec.md'), content);
97
- console.log(` 📋 Synced: specs/${capabilityDir}/spec.md`);
98
- synced++;
149
+ // spec_merged 状态位:closing specs-merged guard 依此判定(v0.24 §96.3.3)
150
+ if (existsSync(join(changeDir, '.team-flow.yaml'))) {
151
+ updateField(changeDir, 'spec_merged', true);
152
+ } else {
153
+ console.warn(' [WARN] 未找到 .team-flow.yaml,spec_merged 未记录(guard 仍会要求该状态位,请手工设置)');
99
154
  }
100
155
 
101
- console.log(`\n✅ Synced ${synced} spec(s) from ${changeName} to specs/`);
156
+ const written = plan.filter(i => i.content !== null).length;
157
+ console.log(`\n✅ Synced ${written} spec(s) from ${changeName} to specs/`);
102
158
  }
@@ -230,6 +230,9 @@ function resolveDeclaredRepo(workspaceRoot, key, value) {
230
230
  return null;
231
231
  }
232
232
 
233
+ /** porcelain v1 行首格式:XY + 一个空格(X/Y ∈ [ MADRCUT?!];未暂存修改时 X 位是空格)。 */
234
+ const PORCELAIN_LINE_RE = /^[ MADRCUT?!]{2} /;
235
+
233
236
  /**
234
237
  * 解析 `git status --porcelain` 输出为路径数组(v0.23 §93.3.1)。
235
238
  *
@@ -237,12 +240,26 @@ function resolveDeclaredRepo(workspaceRoot, key, value) {
237
240
  * `^\S+\s+` 剥离(该正则会漏掉 ` M path`,正是 v0.51.0 前 arch-merge 误报的根因)。
238
241
  * 以 `line.slice(3)` 按固定宽度切片,并还原引号包裹与转义空格。
239
242
  *
243
+ * v0.24 §98.3.2(第二道防线):porcelain 是**固定宽度**格式,行首空格是格式的一部分。
244
+ * 若上游对输出做过 trim 或其它清洗(如 v0.52.0 前 cmd-publish 的 `.trim()`),
245
+ * `slice(3)` 会切掉路径首字符(`changes/…` → `hanges/…`)并**静默产出错误路径**。
246
+ * 因此对每行做形态断言,不匹配即抛错——损坏的输入无法产出可信路径,fail-loud 优于静默误报。
247
+ *
240
248
  * @param {string} output git status --porcelain 的原始输出
241
249
  * @returns {string[]} 仓库相对路径
250
+ * @throws {Error} 输入行不符合 porcelain 形态(提示上游可能清洗过输出)
242
251
  */
243
252
  export function parsePorcelainPaths(output) {
244
253
  return String(output || '')
245
254
  .split('\n')
246
255
  .filter(Boolean)
247
- .map(line => line.slice(3).replace(/^"|"$/g, '').replace(/\\ /g, ' '));
256
+ .map(line => {
257
+ if (!PORCELAIN_LINE_RE.test(line)) {
258
+ throw new Error(
259
+ `parsePorcelainPaths: malformed porcelain line (input may have been trimmed/normalized `
260
+ + `upstream — porcelain's fixed-width prefix must be preserved): ${JSON.stringify(line)}`,
261
+ );
262
+ }
263
+ return line.slice(3).replace(/^"|"$/g, '').replace(/\\ /g, ' ');
264
+ });
248
265
  }
@@ -11,7 +11,8 @@
11
11
  * 3. 若涉及设计系统迭代(新组件/token/anti-pattern),合并进 prototype/design-system.md
12
12
  * 4. 输出合并报告
13
13
  *
14
- * 回写顺序:arch-merge → prototype-sync(同一 change closing 内,顺序提交)
14
+ * 回写顺序:arch-merge → state transition closing → prototype-sync test-merge → compound promotion
15
+ * (同一 change closing 内顺序执行;状态转换位次由 v0.53.0 §110 B' 时序前移确定)
15
16
  */
16
17
 
17
18
  import { readFileSync, writeFileSync, existsSync, cpSync, mkdirSync } from 'node:fs';
@@ -0,0 +1,315 @@
1
+ // scripts/lib/spec-merge.mjs — `tf sync` 的 delta → 主基语义合并器(v0.24 §96)
2
+ //
3
+ // 设计权威:设计增强方案 v0.24 §96.3(工作区级文档,插件包内不含)
4
+ // 触发来源:workflow-feedback 20260910-093040(P1)——修复前 `tf sync` 是纯文件拷贝,
5
+ // 对「主基已存在」的 capability 会整体覆盖,静默删除前序 change 交付的 Requirement。
6
+ //
7
+ // 设计要点:
8
+ // 1. **纯文本合并**(无 I/O 副作用):输入主基 + delta 内容,输出合并结果与操作报告;
9
+ // 写盘由调用方(cmd-sync)统一执行,保证多 capability 的原子性。
10
+ // 2. delta 解析复用 dist 导出的 `parseDeltaSpec`(与 Validator 同一解析器,单一真相源)。
11
+ // 3. 操作顺序 RENAMED → MODIFIED → ADDED → REMOVED(改名先行,避免后续按名匹配失效)。
12
+ // 4. **幂等**:同名同内容 → 跳过(重复 sync 安全);**fail-closed**:目标缺失/命名冲突/
13
+ // 已合并后主基被改动 → 抛错(调用方不写盘,整体退出非 0)。
14
+ // 5. 主基格式不做规范化:按 `### Requirement:` 为块边界解析,兼容既有主基的
15
+ // `## ADDED Requirements` 容器形态(不改写既有段落结构)。
16
+
17
+ const REQ_RE = /^###\s*Requirement:\s*(.+?)\s*$/;
18
+ const H2_RE = /^##\s+/;
19
+
20
+ /** dist 解析器惰性加载(与 cmd-sync 导入 Validator 同源)。 */
21
+ let _parseDeltaSpec = null;
22
+ async function loadParseDeltaSpec() {
23
+ if (!_parseDeltaSpec) {
24
+ const mod = await import('../../dist/index.js');
25
+ _parseDeltaSpec = mod.parseDeltaSpec;
26
+ }
27
+ return _parseDeltaSpec;
28
+ }
29
+
30
+ /** 归一化文本用于幂等比对:去行尾空白、去空行。 */
31
+ function normalize(text) {
32
+ return text.split('\n').map(l => l.trimEnd()).filter(l => l.trim() !== '').join('\n').trim();
33
+ }
34
+
35
+ /** 取 requirement 块正文(去掉标题行与首尾空行)。 */
36
+ function blockBody(blockLines) {
37
+ return blockLines.slice(1).join('\n').replace(/^\n+/, '').replace(/\s+$/, '');
38
+ }
39
+
40
+ /** 切掉 `#### Previous version` 子节(及其后内容)——用于幂等比对与旧内容提取。 */
41
+ function stripPreviousVersion(body) {
42
+ const idx = body.search(/^####\s+Previous version\b/m);
43
+ return idx === -1 ? body : body.slice(0, idx).replace(/\s+$/, '');
44
+ }
45
+
46
+ function escapeRe(s) {
47
+ return s.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
48
+ }
49
+
50
+ /**
51
+ * 主基是否已带「本 change 合并过」的 Previous version 注记(重跑判定)。
52
+ * 词边界(`[\w-]` 两侧)避免 change 名互为前缀时误判(如 `v1-C3` 不应命中
53
+ * `v1-C3-session-governance` 的注记)。
54
+ */
55
+ function hasMergedNote(body, changeName) {
56
+ const re = new RegExp(`^####\\s+Previous version\\b.*(?<![\\w-])${escapeRe(changeName)}(?![\\w-])`, 'm');
57
+ return re.test(body);
58
+ }
59
+
60
+ function renameNote(from, changeName) {
61
+ return `_Renamed from ${from} in ${changeName}._`;
62
+ }
63
+
64
+ function isRemovedSection(section) {
65
+ return typeof section === 'string' && /^Removed\b/i.test(section.trim());
66
+ }
67
+
68
+ /**
69
+ * 解析主基:以 `### Requirement:` 为块边界(块止于下一个 requirement / 下一个二级标题 / 文件末尾)。
70
+ * 记录每个块所属的二级段落名(section),用于区分正常区与 `## Removed` 段。
71
+ */
72
+ function parseMain(content) {
73
+ const lines = content.split('\n');
74
+ const blocks = [];
75
+ let section = null;
76
+ let i = 0;
77
+ while (i < lines.length) {
78
+ const h2 = lines[i].match(/^##\s+(.+?)\s*$/);
79
+ if (h2) {
80
+ section = h2[1];
81
+ i++;
82
+ continue;
83
+ }
84
+ const m = lines[i].match(REQ_RE);
85
+ if (!m) {
86
+ i++;
87
+ continue;
88
+ }
89
+ let end = lines.length;
90
+ for (let j = i + 1; j < lines.length; j++) {
91
+ if (REQ_RE.test(lines[j]) || H2_RE.test(lines[j])) {
92
+ end = j;
93
+ break;
94
+ }
95
+ }
96
+ blocks.push({ name: m[1].trim(), start: i, end, section });
97
+ i = end;
98
+ }
99
+ return { lines, blocks };
100
+ }
101
+
102
+ /** 在正常区(非 `## Removed` 段)按名查找 requirement 块。 */
103
+ function findBlock(parsed, name) {
104
+ return parsed.blocks.find(b => b.name === name && !isRemovedSection(b.section)) || null;
105
+ }
106
+
107
+ /**
108
+ * ADDED 插入点:**正常区**(非 `## Removed` 段)最后一个 requirement 块之后
109
+ * (即下一个二级标题之前,或文件末尾)。
110
+ *
111
+ * 注意不能直接用最后一个块——若主基以 `## Removed` 段收尾,其内的条目也是块,
112
+ * 会把新 requirement 追加进已移除区。正常区无块时退化为「Removed 段之前 / 文件末尾」。
113
+ */
114
+ function findAppendIndex(parsed) {
115
+ const normal = parsed.blocks.filter(b => !isRemovedSection(b.section));
116
+ if (normal.length === 0) {
117
+ const range = findRemovedSectionRange(parsed);
118
+ return range.start === -1 ? parsed.lines.length : range.start;
119
+ }
120
+ return normal[normal.length - 1].end;
121
+ }
122
+
123
+ /** 定位 `## Removed` 段的 [start, end)(end = 下一个二级标题或文件末尾);无则 -1。 */
124
+ function findRemovedSectionRange(parsed) {
125
+ const start = parsed.lines.findIndex(l => /^##\s+Removed\s*$/i.test(l));
126
+ if (start === -1) return { start: -1, end: -1 };
127
+ let end = parsed.lines.length;
128
+ for (let j = start + 1; j < parsed.lines.length; j++) {
129
+ if (H2_RE.test(parsed.lines[j])) {
130
+ end = j;
131
+ break;
132
+ }
133
+ }
134
+ return { start, end };
135
+ }
136
+
137
+ /** 段内尾部插入(自动补前导空行,条目自带尾随空行)。 */
138
+ function appendAtSectionEnd(lines, idx, block) {
139
+ const pre = idx > 0 && lines[idx - 1].trim() !== '' ? [''] : [];
140
+ lines.splice(idx, 0, ...pre, ...block, '');
141
+ }
142
+
143
+ // ── 各操作的 apply(每次操作前重新解析,规避行号失效)────────────────────────
144
+
145
+ function applyRenamed(content, plan, changeName, report) {
146
+ for (const { from, to } of plan.renamed) {
147
+ const parsed = parseMain(content);
148
+ const fromBlock = findBlock(parsed, from);
149
+ const toBlock = findBlock(parsed, to);
150
+
151
+ if (!fromBlock) {
152
+ // 幂等:源名已不存在,且目标名带本 change 的 rename 注记
153
+ const toText = toBlock ? parsed.lines.slice(toBlock.start, toBlock.end).join('\n') : '';
154
+ if (toBlock && toText.includes(renameNote(from, changeName))) {
155
+ report.skipped++;
156
+ continue;
157
+ }
158
+ throw new Error(`RENAMED 的源 requirement 不存在于主基:「${from}」(目标「${to}」)`);
159
+ }
160
+ if (toBlock) {
161
+ throw new Error(`RENAMED 的新名与主基既有 requirement 冲突:「${to}」(源「${from}」)`);
162
+ }
163
+ const lines = parsed.lines;
164
+ lines[fromBlock.start] = `### Requirement: ${to}`;
165
+ lines.splice(fromBlock.start + 1, 0, '', renameNote(from, changeName));
166
+ content = lines.join('\n');
167
+ report.renamed++;
168
+ }
169
+ return content;
170
+ }
171
+
172
+ function applyModified(content, plan, changeName, today, report) {
173
+ for (const block of plan.modified) {
174
+ const parsed = parseMain(content);
175
+ const target = findBlock(parsed, block.name);
176
+ if (!target) {
177
+ throw new Error(`MODIFIED 的目标 requirement 不存在于主基:「${block.name}」`);
178
+ }
179
+ const oldBody = blockBody(parsed.lines.slice(target.start, target.end));
180
+ const oldCore = stripPreviousVersion(oldBody);
181
+ const newBody = blockBody(block.raw.split('\n'));
182
+
183
+ if (normalize(oldCore) === normalize(newBody)) {
184
+ report.skipped++; // 幂等:内容一致(重复 sync 的安全路径)
185
+ continue;
186
+ }
187
+ if (hasMergedNote(oldBody, changeName)) {
188
+ throw new Error(
189
+ `MODIFIED 的目标「${block.name}」已合并且被后续改动(主基内容与 delta 不一致)——`
190
+ + `拒绝覆盖,请人工确认后用 git diff 核对主基与 delta`,
191
+ );
192
+ }
193
+
194
+ const replacement = [
195
+ block.raw.trimEnd(),
196
+ '',
197
+ `#### Previous version(${changeName} 更新于 ${today})`,
198
+ '',
199
+ oldCore.trimEnd(),
200
+ ].join('\n').split('\n');
201
+
202
+ const lines = parsed.lines;
203
+ lines.splice(target.start, target.end - target.start, ...replacement);
204
+ content = lines.join('\n');
205
+ report.modified++;
206
+ }
207
+ return content;
208
+ }
209
+
210
+ function applyAdded(content, plan, report) {
211
+ for (const block of plan.added) {
212
+ const parsed = parseMain(content);
213
+ const existing = findBlock(parsed, block.name);
214
+ if (existing) {
215
+ const existingCore = stripPreviousVersion(blockBody(parsed.lines.slice(existing.start, existing.end)));
216
+ const newCore = blockBody(block.raw.split('\n'));
217
+ if (normalize(existingCore) === normalize(newCore)) {
218
+ report.skipped++; // 幂等:同名同内容
219
+ continue;
220
+ }
221
+ throw new Error(`ADDED 的名称与主基既有 requirement 冲突:「${block.name}」(内容不同)`);
222
+ }
223
+ const lines = parsed.lines;
224
+ appendAtSectionEnd(lines, findAppendIndex(parsed), block.raw.trimEnd().split('\n'));
225
+ content = lines.join('\n');
226
+ report.added++;
227
+ }
228
+ return content;
229
+ }
230
+
231
+ function applyRemoved(content, plan, changeName, today, report) {
232
+ for (const name of plan.removed) {
233
+ const parsed = parseMain(content);
234
+ const target = findBlock(parsed, name);
235
+
236
+ if (!target) {
237
+ // 幂等:该名已出现在 `## Removed` 段
238
+ const inRemoved = parsed.blocks.some(b => b.name === name && isRemovedSection(b.section));
239
+ if (inRemoved) {
240
+ report.skipped++;
241
+ continue;
242
+ }
243
+ throw new Error(`REMOVED 的目标 requirement 不存在于主基:「${name}」`);
244
+ }
245
+
246
+ const removedBody = blockBody(parsed.lines.slice(target.start, target.end));
247
+ const lines = parsed.lines;
248
+ lines.splice(target.start, target.end - target.start);
249
+ let text = lines.join('\n');
250
+
251
+ const entry = [
252
+ `### Requirement: ${name}`,
253
+ '',
254
+ `_Removed in ${changeName} on ${today}._`,
255
+ '',
256
+ removedBody.trimEnd(),
257
+ ];
258
+
259
+ const reparsed = parseMain(text);
260
+ const range = findRemovedSectionRange(reparsed);
261
+ if (range.start === -1) {
262
+ text = `${text.replace(/\s+$/, '')}\n\n## Removed\n\n${entry.join('\n')}\n`;
263
+ } else {
264
+ appendAtSectionEnd(reparsed.lines, range.end, entry);
265
+ text = reparsed.lines.join('\n');
266
+ }
267
+ content = text;
268
+ report.removed++;
269
+ }
270
+ return content;
271
+ }
272
+
273
+ // ── 公开入口 ────────────────────────────────────────────────────────────────
274
+
275
+ function hasOperations(plan) {
276
+ return plan.added.length > 0 || plan.modified.length > 0
277
+ || plan.removed.length > 0 || plan.renamed.length > 0;
278
+ }
279
+
280
+ /**
281
+ * 将 delta spec 语义合并进主基。
282
+ *
283
+ * @param {string} mainContent 主基 spec 全文
284
+ * @param {string} deltaContent change 的 delta spec 全文(含 `## ADDED/MODIFIED/REMOVED/RENAMED Requirements`)
285
+ * @param {string} changeName change 名(写入 Previous version / Removed 注记)
286
+ * @param {{today?: string}} [options] `today` 可注入(测试确定性),默认取本地日期 YYYY-MM-DD
287
+ * @returns {Promise<{content: string, report: {renamed:number, modified:number, added:number, removed:number, skipped:number, noOps:boolean}}>}
288
+ * @throws {Error} 语义校验失败(fail-closed,调用方不得写盘)
289
+ */
290
+ export async function mergeMainSpec(mainContent, deltaContent, changeName, options = {}) {
291
+ const parseDeltaSpec = await loadParseDeltaSpec();
292
+ const plan = parseDeltaSpec(deltaContent);
293
+ const today = options.today || new Date().toISOString().slice(0, 10);
294
+
295
+ if (!hasOperations(plan)) {
296
+ return {
297
+ content: mainContent,
298
+ report: { renamed: 0, modified: 0, added: 0, removed: 0, skipped: 0, noOps: true },
299
+ };
300
+ }
301
+
302
+ const report = { renamed: 0, modified: 0, added: 0, removed: 0, skipped: 0, noOps: false };
303
+ let content = mainContent;
304
+ content = applyRenamed(content, plan, changeName, report);
305
+ content = applyModified(content, plan, changeName, today, report);
306
+ content = applyAdded(content, plan, report);
307
+ content = applyRemoved(content, plan, changeName, today, report);
308
+ return { content, report };
309
+ }
310
+
311
+ /** delta 是否含任何可合并的操作段落(供 cmd-sync 判定 clean skip)。 */
312
+ export async function deltaHasOperations(deltaContent) {
313
+ const parseDeltaSpec = await loadParseDeltaSpec();
314
+ return hasOperations(parseDeltaSpec(deltaContent));
315
+ }
@@ -77,6 +77,12 @@ const BUILTIN_DEFAULTS = {
77
77
  // Tasks gate (v0.22 §85:hotfix/tweak 跳过 spec-writer 时显式跳过 tasks.md)
78
78
  tasks_skipped: null,
79
79
  tasks_skip_reason: null,
80
+ // Arch merge gate (v0.53.0 §110.2 加固 iii:arch-merged guard 维度的显式跳过键)
81
+ // 与 tasks_skipped / test_matrix_skipped 同一模式。用于 arch-merge 确为 no-op 的场景
82
+ // (如 change 的 architecture.md 无演进日志段且无聚合增量 → 全局台账不会出现
83
+ // `change:<name>`,若不给跳过键则 guard 永久 FAIL 无出路)。
84
+ arch_merge_skipped: null,
85
+ arch_merge_skip_reason: null,
80
86
  // 注意:schema_version 故意不在 BUILTIN_DEFAULTS 中(v0.13 §48.1)——
81
87
  // 它只由 `tf state init` 在 change 创建时打戳,字段缺失本身就是"存量 change"信号。
82
88
  };
@@ -203,6 +209,10 @@ export function writeState(changeDir, state) {
203
209
  lines.push('# === Tasks gate (v0.22 §85) ===');
204
210
  lines.push(`tasks_skipped: ${state.tasks_skipped ?? 'null'}`);
205
211
  lines.push(`tasks_skip_reason: ${state.tasks_skip_reason ?? 'null'}`);
212
+ lines.push('');
213
+ lines.push('# === Arch merge gate (v0.53.0 §110.2) ===');
214
+ lines.push(`arch_merge_skipped: ${state.arch_merge_skipped ?? 'null'}`);
215
+ lines.push(`arch_merge_skip_reason: ${state.arch_merge_skip_reason ?? 'null'}`);
206
216
 
207
217
  fs.writeFileSync(filePath, lines.join('\n') + '\n', 'utf-8');
208
218
  }
@@ -13,7 +13,7 @@
13
13
  * 5. rewriteIndex — 统计模块数/case 数/deferred 数,重写 INDEX.md
14
14
  * 6. gitCommit — 单次原子提交
15
15
  *
16
- * 回写顺序:arch-merge → prototype-sync → test-merge → compound promotion
16
+ * 回写顺序:arch-merge → state transition closing → prototype-sync → test-merge → compound promotion
17
17
  *
18
18
  * v0.38.0(feedback 2026-08-05 修复 + E2E 层级):
19
19
  * - resolveDeferred 只删 Deferred Items 段内被覆盖行(原全文件 regex 误删 Current Cases)
@@ -16,9 +16,20 @@ export const SUPPORTED_RUNNERS = ['maven-surefire', 'jest', 'pytest'];
16
16
 
17
17
  // ── 解析器(全部返回 { total, passed, failed, skipped } 或 null)────────────
18
18
 
19
- /** maven surefire 控制台汇总行(多模块累加):"Tests run: 42, Failures: 0, Errors: 0, Skipped: 2" */
19
+ /**
20
+ * maven surefire 控制台解析:只累加**模块汇总结行**(多模块累加),排除类级明细行。
21
+ *
22
+ * v0.24 §97.3.1:两类行的判据是 `Skipped: N` 之后的内容——
23
+ * - 类级行:`Tests run: 7, Failures: 0, Errors: 0, Skipped: 0, Time elapsed: 0.5 s -- in com.FooTest`
24
+ * (surefire 2.x 为 `sec - in`、3.x 为 `s -- in`,`Time elapsed:` 前缀为共同判据);
25
+ * - 汇总行:`Tests run: 134, Failures: 0, Errors: 0, Skipped: 0`(其后即行尾,或仅余 ANSI 尾码)。
26
+ *
27
+ * 旧实现无差别累加全部匹配行:带 ANSI 时类级行的计数数字被颜色码包裹
28
+ * (`Tests run: \x1b[0;1;32m7\x1b[m, …`)而"侥幸"不匹配,剥离 ANSI 后类级行 + 汇总行
29
+ * 同时命中 → 双计(实测 134 → 268)。负向前瞻令 ANSI 剥离前后行为一致。
30
+ */
20
31
  export function parseMavenSurefire(text) {
21
- const re = /Tests run:\s*(\d+),\s*Failures:\s*(\d+),\s*Errors:\s*(\d+),\s*Skipped:\s*(\d+)/g;
32
+ const re = /Tests run:\s*(\d+),\s*Failures:\s*(\d+),\s*Errors:\s*(\d+),\s*Skipped:\s*(\d+)(?!,\s*Time elapsed)/g;
22
33
  let m;
23
34
  let total = 0; let failures = 0; let errors = 0; let skipped = 0;
24
35
  let found = false;
@@ -121,8 +132,24 @@ export function parsePytest(text) {
121
132
  return { total, passed, failed, skipped };
122
133
  }
123
134
 
135
+ /**
136
+ * maven-surefire 文件输入入口(v0.24 §97.3.2):junit XML 优先,与 pytest 对称。
137
+ *
138
+ * 目录输入走 parseSurefireReportDir;文件输入经此入口——内容含 `<testsuites>` 时
139
+ * 结构化解析(权威口径,免疫 ANSI / 类级行 / 日志截断),否则按控制台文本解析。
140
+ * 修复前:detectRunner 嗅探 XML 后归入 maven-surefire,但该 runner 的解析器只认控制台
141
+ * 文本 → 单个 surefire XML 文件解析失败(目录可用是因其走了专用分支)。
142
+ */
143
+ function parseMavenSurefireInput(text) {
144
+ if (looksLikeJunitXml(text)) {
145
+ const xml = parseJunitXml(text);
146
+ if (xml) return xml;
147
+ }
148
+ return parseMavenSurefire(text);
149
+ }
150
+
124
151
  const PARSERS = {
125
- 'maven-surefire': parseMavenSurefire,
152
+ 'maven-surefire': parseMavenSurefireInput,
126
153
  jest: parseJest,
127
154
  pytest: parsePytest,
128
155
  };
@@ -265,9 +292,12 @@ export async function run(args) {
265
292
  }
266
293
  if (!stats) {
267
294
  // v0.49.0 §83.3.6:错误信息附输入形态指引(原提示对"传了 XML"的用户无帮助)
295
+ // v0.24 §97.3.3:maven-surefire 补 XML 形态与"仅类级明细行"说明(类级行已不再计入)
268
296
  const hint = runner === 'pytest'
269
297
  ? 'pytest accepts a terminal summary or a junit XML report (pytest --junitxml=<path>).'
270
- : `Supported runners: ${SUPPORTED_RUNNERS.join(', ')} (or pass --runner explicitly).`;
298
+ : runner === 'maven-surefire'
299
+ ? 'maven-surefire accepts a console summary (module summary lines, not per-class detail lines), a single surefire XML report, or a target/surefire-reports directory.'
300
+ : `Supported runners: ${SUPPORTED_RUNNERS.join(', ')} (or pass --runner explicitly).`;
271
301
  console.error(`Could not parse ${runner} output in ${fromPath} — no recognizable test summary found.\n${hint}`);
272
302
  process.exit(1);
273
303
  }
@@ -67,6 +67,7 @@ Commands:
67
67
  arch init [--mode reconstruction|design] [--baseline-ref <prd/vN/>]
68
68
  Stamp project-level arch_baseline into .team-flow/arch-state.json (v0.35.0 §59.4)
69
69
  arch show Show current project architecture baseline state
70
+ arch scaffold Scaffold global docs/architecture/ ledger in generator format (v0.53.0 §102)
70
71
  arch precheck <change-dir> [--json]
71
72
  Emit deterministic architecture-gate evidence (v0.22 §88; evidence only, exit 0)
72
73
  arch-merge <change-dir> [--project-root <path>] [--dry-run]
@@ -198,7 +199,19 @@ async function main() {
198
199
  }
199
200
 
200
201
  const mod = await COMMANDS[command]();
201
- await mod.run(commandArgs);
202
+ const result = await mod.run(commandArgs);
203
+ // v0.53.0 §104.2.3:命令以结构化失败结果结束 → 传播非零退出码。
204
+ //
205
+ // 背景:`arch-merge` 的 `[FAIL]` 汇总与 `arch-merge FAILED: N failure(s).` 消息
206
+ // 原本只在 `import.meta.url === process.argv[1]` 的**直调块**里设 `process.exitCode`,
207
+ // 而经 `tf` 调用走的是本 dispatcher(不消费返回值)→ **`tf arch-merge` 实测恒 exit 0**,
208
+ // 与 SKILL.md 承诺的"可能以非零退出码结束"不符,`tf arch-merge <dir> && tf state
209
+ // transition closing` 这类链式写法拿不到失败信号。
210
+ //
211
+ // 放在 dispatcher 而非 `run()` 内:`run()` 被 import 时不应设全局 exitCode
212
+ // (既有测试守护 "does not set process.exitCode when imported")。
213
+ // 守卫 `Array.isArray(result.failures)`:仅对返回该结构的命令生效,其余命令不受影响。
214
+ if (result && Array.isArray(result.failures) && result.failures.length > 0) process.exitCode = 1;
202
215
  }
203
216
 
204
217
  main().catch(err => {
@@ -30,23 +30,29 @@ api_contract_manager: swagger
30
30
 
31
31
  ## 2. To-Be 增量设计
32
32
 
33
+ > ⚠ **占位符必须替换**:示例行的路径写作 `<endpoint>` 形式而非 `/api/xxx` 这类**看起来像真实路径**的字符串。
34
+ > 原因(v0.53.0 §115.6):`/api/xxx` 是**合法的路径形状**,arch-merge 的端点提取器会**正常提取**它,
35
+ > 且新加的"候选>0 且提取=0 → 失败"与"覆盖率<0.5 → 告警"两条对账判据**都不会触发**
36
+ > (占位行被当作真实端点)。后果是占位符被计入全局 `API-INDEX.md` 与 `INDEX.md` 的「端点数量」
37
+ > ——与 v0.25 §101.3 记录的症状同源。`<endpoint>` 不以 `/` 开头,提取器天然拒绝。
38
+
33
39
  ### 2.1 Command API(改状态)
34
40
 
35
41
  | 端点 | 方法 | 聚合 | 事务边界 | 说明 |
36
42
  |------|------|------|---------|------|
37
- | /api/xxx | POST | XxxAggregate | t_xxx 事务 | 简要说明 |
43
+ | `<endpoint>` | POST | XxxAggregate | t_xxx 事务 | 简要说明 |
38
44
 
39
45
  ### 2.2 Read API(有逻辑不改状态)
40
46
 
41
47
  | 端点 | 方法 | 聚合 | 数据来源 | 说明 |
42
48
  |------|------|------|---------|------|
43
- | /api/xxx/{id}/detail | GET | XxxAggregate | t_xxx + JOIN | 简要说明 |
49
+ | `<endpoint>/{id}` | GET | XxxAggregate | t_xxx + JOIN | 简要说明 |
44
50
 
45
51
  ### 2.3 Query API(纯查询)
46
52
 
47
53
  | 端点 | 方法 | 查询模型 | 阻断测试 | 说明 |
48
54
  |------|------|---------|---------|------|
49
- | /api/dashboard/xxx-stats | GET | XxxStatsView | 阻断=继续 → 数据服务 | 简要说明 |
55
+ | `<endpoint>` | GET | XxxStatsView | 阻断=继续 → 数据服务 | 简要说明 |
50
56
 
51
57
  > **阻断测试说明**:将服务阻断 1h,下游不能继续 → 业务服务;能继续 → 数据服务
52
58
 
@@ -56,7 +62,7 @@ api_contract_manager: swagger
56
62
 
57
63
  | API 端点 | 对应数据实体 | 对齐状态 | 不一致说明 |
58
64
  |---------|------------|---------|-----------|
59
- | /api/xxx | t_xxx | ✅ 对齐 | — |
65
+ | `<endpoint>` | t_xxx | ✅ 对齐 | — |
60
66
 
61
67
  ### 3.1 术语命名统一性
62
68