@xulthekl/team-flow 0.52.0 → 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 (39) 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 +69 -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 +4 -1
  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 +407 -47
  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 +53 -6
  29. package/scripts/lib/cmd-state.mjs +2 -0
  30. package/scripts/lib/prototype-sync.mjs +2 -1
  31. package/scripts/lib/state-loader.mjs +10 -0
  32. package/scripts/lib/test-merge.mjs +1 -1
  33. package/scripts/team-flow.mjs +14 -1
  34. package/skills/architecture-design/templates/api.md +10 -4
  35. package/skills/prototype/SKILL.md +2 -2
  36. package/skills/release-archivist/SKILL.md +46 -36
  37. package/skills/release-archivist/references/closing-procedures.md +14 -4
  38. package/skills/workflow-bootstrap/SKILL.md +27 -18
  39. package/skills/workflow-orchestrator/references/state-model.md +3 -0
@@ -22,8 +22,8 @@ import { join, basename, dirname, relative, resolve } from 'node:path';
22
22
  import { execFileSync } from 'node:child_process';
23
23
  import { pathToFileURL } from 'node:url';
24
24
  import {
25
- parseTableAfter, extractEndpoints, extractAggregates,
26
- extractTablesFromSql, extractTablesFromDatabaseMd, readFrontmatter,
25
+ parseTableRow, extractEndpoints, extractEndpointsDetailed, extractAggregates,
26
+ extractTablesFromSql, readFrontmatter, PATH_CELL_RE,
27
27
  } from './arch-parse.mjs';
28
28
  import { detectWorkspaceRoot, getGitRoot, parsePorcelainPaths } from './git-utils.mjs';
29
29
 
@@ -65,7 +65,7 @@ function releaseLock(lockPath) {
65
65
  * 全局文档维护 marker 区(当前态:BC/聚合注册表)+ 演进日志段。
66
66
  * marker 区重建 = 所有已合并 change 增量的投影(按聚合 id upsert,非原地编辑)。
67
67
  */
68
- function mergeArchitecture(changeDir, archDir, globalArchDir, changeName) {
68
+ function mergeArchitecture(changeDir, archDir, globalArchDir, changeName, projectRoot) {
69
69
  const srcPath = join(archDir, 'architecture.md');
70
70
  const dstPath = join(globalArchDir, 'ARCHITECTURE.md');
71
71
  if (!existsSync(srcPath)) return { merged: false, reason: 'no-source' };
@@ -75,13 +75,26 @@ function mergeArchitecture(changeDir, archDir, globalArchDir, changeName) {
75
75
 
76
76
  // 结构化提取聚合清单(唯一事实源,聚合注册表)
77
77
  const aggregates = extractAggregates(srcContent);
78
- // 关键门禁:声明 required 但解析到 0 聚合 → abort(禁止静默)
79
- if (fm.arch_design_decision === 'required' && aggregates.length === 0) {
80
- abort('architecture.md 聚合注册表解析为空(arch_design_decision=required)——检查模板 §2 是否输出聚合行');
78
+
79
+ // v0.53.0 §107 R7:零聚合增量合法化。
80
+ // arch_design_decision=required 但零聚合」是**完全合法且常见**的形态(纯 API 增量 /
81
+ // 纯读写模型增量 / 瞬态凭证值对象设计)——C2/C3 连续两个 change 复现,且在识别读写模型
82
+ // (CQRS)增量时必然触发。原实现一律 abort,且无合法绕过路径(塞聚合=伪造/冲突,
83
+ // 改决策=伪造判定),归档永久缺一块。
84
+ //
85
+ // **但零聚合有两种成因,必须可区分**:① 声明为无(合法)② 解析失配(缺陷)。
86
+ // 故只放宽前者——须显式声明 `aggregate_action: none`(或 `aggregate_id: null`),
87
+ // 未声明仍 abort,以保留对解析失配的守卫(否则 §106 那类列错位缺陷会被一并静默)。
88
+ const declaredNone = fm.aggregate_action === 'none' || fm.aggregate_id === 'null';
89
+ if (fm.arch_design_decision === 'required' && aggregates.length === 0 && !declaredNone) {
90
+ abort('architecture.md 聚合注册表解析为空且未声明零增量(arch_design_decision=required)——'
91
+ + '检查模板 §2 是否输出聚合行;若本 change 确无聚合增量,请显式声明 aggregate_action: none');
81
92
  }
82
93
 
83
94
  const existing = existsSync(dstPath) ? readFileSync(dstPath, 'utf-8') : '';
84
- const currentStateSection = buildCurrentStateSection(aggregates, changeName);
95
+ // v0.53.0 §108 R8:marker 区 = **已合并 change 的聚合全集投影**(非仅当前 change)
96
+ const allAggregates = collectAllAggregates(projectRoot, changeName, aggregates);
97
+ const currentStateSection = buildCurrentStateSection(allAggregates);
85
98
 
86
99
  // marker 区替换(代码独占写):存在则替换,缺失则插入
87
100
  let updated;
@@ -104,15 +117,59 @@ function mergeArchitecture(changeDir, archDir, globalArchDir, changeName) {
104
117
  updated = upsertEvolutionLog(updated, srcContent, changeName);
105
118
 
106
119
  writeFile(dstPath, updated, 'utf-8');
107
- return { merged: true, file: 'ARCHITECTURE.md', aggregates: aggregates.map(a => a.id) };
120
+ return {
121
+ merged: true,
122
+ file: 'ARCHITECTURE.md',
123
+ aggregates: aggregates.map(a => a.id),
124
+ projected: allAggregates.length,
125
+ ...(aggregates.length === 0 ? { reason: 'no-aggregate-delta' } : {}),
126
+ };
127
+ }
128
+
129
+ /**
130
+ * v0.53.0 §108 R8:收集「已合并 change 的聚合全集」用于 marker 区投影。
131
+ *
132
+ * **原实现只用当前 change 的聚合重建 marker 区** → 多 change 场景下互相抹除:
133
+ * ```
134
+ * C1 merge → marker 区 = {C1 聚合}
135
+ * C2 merge → marker 区 = {C2 聚合} ← C1 的行被抹掉
136
+ * ```
137
+ * emp-auth 现状之所以未暴露该风险,是因为 C2/C3 被零聚合 abort 挡在门外——
138
+ * **一旦 R7 修好门禁,抹除会立即发生**(`20260910-000823` 的「根因 4」已论证:
139
+ * abort 在此客观上起了保护作用)。
140
+ *
141
+ * 纳入判据与 R2 一致:**当前 change 无条件纳入**;其余仅 `closed|closing`
142
+ * (防在途半成品污染,沿用 v0.36.3 判据)。同一 id 被多个 change 声明时
143
+ * **以当前 change 优先**(本次合并是权威——而"其他 change 声明同一聚合"已由
144
+ * `conflictCheck` 在更早阶段阻断,此处仅为确定性兜底)。
145
+ */
146
+ function collectAllAggregates(projectRoot, changeName, currentAggregates) {
147
+ const collected = new Map(); // id → { ...aggregate, source }
148
+ const changesDir = join(projectRoot, 'changes');
149
+ if (existsSync(changesDir)) {
150
+ for (const name of readdirSync(changesDir)) {
151
+ if (name === changeName) continue;
152
+ const changeRoot = join(changesDir, name);
153
+ if (!isChangeClosed(changeRoot)) continue;
154
+ const p = join(changeRoot, 'architecture', 'architecture.md');
155
+ if (!existsSync(p)) continue;
156
+ for (const a of extractAggregates(readFileSync(p, 'utf-8'))) {
157
+ if (!collected.has(a.id)) collected.set(a.id, { ...a, source: `change:${name}` });
158
+ }
159
+ }
160
+ }
161
+ // 当前 change 覆盖同 id 项(本次合并权威)
162
+ for (const a of currentAggregates) collected.set(a.id, { ...a, source: `change:${changeName}` });
163
+ return [...collected.values()];
108
164
  }
109
165
 
110
- /** 构建当前态 marker 区内容(BC 表 + 聚合注册表,厚锚点一句话级;聚合注册表带来源列)。 */
111
- function buildCurrentStateSection(aggregates, changeName) {
166
+ /** 构建当前态 marker 区内容(BC 表 + 聚合注册表,厚锚点一句话级;来源列标注真实来源 change)。 */
167
+ function buildCurrentStateSection(aggregates) {
112
168
  const bcMap = new Map();
113
169
  for (const a of aggregates) {
114
- if (!bcMap.has(a.context)) bcMap.set(a.context, []);
115
- bcMap.get(a.context).push(a);
170
+ const bc = a.context || '(未标注)';
171
+ if (!bcMap.has(bc)) bcMap.set(bc, []);
172
+ bcMap.get(bc).push(a);
116
173
  }
117
174
  const lines = ['## 当前态', ''];
118
175
  lines.push('### 限界上下文');
@@ -121,31 +178,66 @@ function buildCurrentStateSection(aggregates, changeName) {
121
178
  lines.push(`| ${bc} | ${aggs.length} |`);
122
179
  }
123
180
  lines.push('', '### 聚合注册表');
124
- // v0.36.3 §62.1 首轮特例:全局聚合全部来自已合并 change(=已落地态);状态列标注
125
181
  lines.push('| 聚合ID | 上下文 | 根实体 | 来源 | 状态 | 关键不变量 |', '|--------|--------|--------|------|------|-----------|');
126
182
  for (const a of aggregates) {
127
- lines.push(`| ${a.id} | ${a.context} | ${a.root} | change:${changeName} | 已落地 | ${a.invariants || '—'} |`);
183
+ lines.push(`| ${a.id} | ${a.context || '—'} | ${a.root || '—'} | ${a.source || '—'} | 已落地 | ${a.invariants || '—'} |`);
128
184
  }
129
185
  return lines.join('\n');
130
186
  }
131
187
 
132
- /** 演进日志 upsert:按 changeName 锚定位既有块,存在则替换、否则追加。 */
188
+ /**
189
+ * 演进日志 upsert:按 changeName 锚定位既有块,存在则替换、否则追加。
190
+ *
191
+ * v0.53.0 §109.4 R11 修复(幂等性):原替换正则前瞻 `(?=^### change:|^## |^\n$|$)` 中,
192
+ * `$` 在 `/m` 标志下匹配的是**行尾**而非字符串末尾 —— 惰性量词 `[\s\S]*?` 在锚行行尾
193
+ * 处即刻满足前瞻 → 匹配范围退化为零宽 → **只替换掉锚行本身,旧表体一行不删**。
194
+ * 后果:arch-merge 每重复执行一次就追加一份演进日志(实测 3 次运行表体 1→2→3 份),
195
+ * 全局 ARCHITECTURE.md 无限膨胀。该缺陷在 B' 方案(arch-merge 前移至门禁前,重试成为
196
+ * 常态)下会被放大,故必须先修。
197
+ *
198
+ * 修复要点:
199
+ * 1. 前瞻末尾 `$` → `$(?![\s\S])`:`/m` 下中间行尾因后面仍有字符而不成立,只有字符串
200
+ * 末尾才命中(等价"真·文件末尾")。
201
+ * 2. 移除 `^\n$`:`/m` 下几乎不匹配,且一旦匹配会造成块被提前截断(截断 = 替换不完整
202
+ * = 残留累积,与本缺陷同型)。
203
+ * 3. **新增 `^<!-- arch:` 边界(关键)**:marker 区(当前态)紧跟在 `### change:` 块之后,
204
+ * 而 `<!-- arch:current-state:begin -->` 不匹配 `^## `。若边界漏掉它,替换会一路吞到
205
+ * 文件末尾,**把 marker 区一并删除** → marker 不成对 → 下次 `mergeArchitecture` 直接
206
+ * abort。原实现之所以未暴露该风险,恰恰是因为零宽 bug 让它"什么都没删"。
207
+ * 4. 块范围 = 锚行 → 下一个块起点,块起点 ∈ {`^### change:`、`^## ` / `^# `、`^<!-- arch:`、
208
+ * 文件末尾}。边界之外的内容(含 change 级模板残留表)由本 change 块独占,随替换清理。
209
+ * 5. 删除死变量 `anchorRe`(`/^#{3,4}\s*.*change[:\s].*$/mi`,从未被引用)。
210
+ */
133
211
  function upsertEvolutionLog(globalContent, srcContent, changeName) {
134
212
  // 从 change 源提取演进日志条目(标题 + 正文)
135
213
  const logStart = srcContent.search(/^#{2,3}\s*\d*\.?\s*(演进日志|Evolution Log)/m);
136
214
  if (logStart < 0) return globalContent;
137
215
  const logSection = srcContent.slice(logStart).trim();
138
- const anchorRe = /^#{3,4}\s*.*change[:\s].*$/mi;
139
216
  const anchor = `### change:${changeName}`;
140
217
  const logBlock = `${anchor}\n${logSection.replace(/^#{2,3}\s*\d*\.?\s*(演进日志|Evolution Log)/m, '').trim()}`;
141
218
 
142
- // 已存在 → 替换;否则追加到演进日志段末尾
219
+ // 已存在 → 替换整块;否则追加到演进日志段末尾
143
220
  const logHeadingRe = /^#{2,3}\s*\d*\.?\s*(演进日志|Evolution Log)/m;
144
- if (new RegExp(`^### change:${escapeRegExp(changeName)}\\s*$`, 'm').test(globalContent)) {
145
- return globalContent.replace(
146
- new RegExp(`^### change:${escapeRegExp(changeName)}[\\s\\S]*?(?=^### change:|^## |^\\n$|$)`, 'm'),
147
- logBlock
221
+ const anchorRe = new RegExp(`^### change:${escapeRegExp(changeName)}\\s*$`, 'm');
222
+ if (anchorRe.test(globalContent)) {
223
+ // 块起点:下一个 change / 下一个一二级标题 / arch marker / 文件末尾
224
+ const blockRe = new RegExp(
225
+ `^### change:${escapeRegExp(changeName)}\\s*$[\\s\\S]*?`
226
+ + `(?=^### change:|^#{1,2}\\s|^<!-- arch:|$(?![\\s\\S]))`,
227
+ 'm'
148
228
  );
229
+ const matched = globalContent.match(blockRe);
230
+ if (matched) {
231
+ // 被替换段内若含疑似人工内容(非标题/非表格行/非空行/非注释)→ 告警不静默
232
+ const suspicious = matched[0].split('\n').slice(1)
233
+ .filter(l => l.trim() && !l.trim().startsWith('|') && !/^#{1,6}\s/.test(l.trim()));
234
+ if (suspicious.length > 0) {
235
+ console.warn(` [WARN] 演进日志块 change:${changeName} 内含 ${suspicious.length} 行非表格内容,将被替换:${suspicious[0].slice(0, 60)}`);
236
+ }
237
+ }
238
+ // 替换值恒为 `${logBlock}\n`:blockRe 的惰性匹配天然包含块尾换行,故重建结果
239
+ // 与首次写入(第三分支 `+ logBlock + '\n'`)逐字节一致 → 逐字节幂等。
240
+ return globalContent.replace(blockRe, `${logBlock}\n`);
149
241
  }
150
242
  if (logHeadingRe.test(globalContent)) {
151
243
  return globalContent.replace(logHeadingRe, `$&\n${logBlock}`);
@@ -232,13 +324,24 @@ function isChangeClosed(changeRoot) {
232
324
  if (!existsSync(stateFile)) return false;
233
325
  const m = readFileSync(stateFile, 'utf-8').match(/^state:\s*(.*)$/m);
234
326
  const state = m ? m[1].trim() : '';
327
+ // 注:`closed` 在 `VALID_STATES`(state-loader.mjs)中**不存在**,`closing→closed`
328
+ // 转换也不在状态机上——此处的 `closed` 分支**仅为前向兼容**(若未来新增终态则自动
329
+ // 生效),当前活跃路径只有 `closing`。**勿将其当作可依赖的活跃分支。**
235
330
  return state === 'closed' || state === 'closing';
236
331
  }
237
332
 
238
333
  /**
239
- * Step 6: API-INDEX.md 生成式(api-scan 核心)。
240
- * 扫描项目根下所有已关闭 change 的 architecture/api.md + domains/,归一化端点重建。
241
- * 保护:①重建结果为空 拒绝覆盖(防历史手工端点被抹);②只扫已关闭 change(v0.36.3,在途半成品端点不得入全局)。
334
+ * Step 6: API-INDEX.md 生成式(api-scan 核心)+ 产出量对账(v0.53.0 §103/§104)。
335
+ *
336
+ * 扫描项目根下所有(已关闭 **或本次正在合并** 的)change 的 architecture/api.md,重建端点。
337
+ *
338
+ * 保护与对账:
339
+ * ① **当前 change 无条件纳入**(§103 R2)——原实现对自身也施加 `isChangeClosed` 判据,
340
+ * 而执行时刻自身状态仍为 `executing`/`approved-for-build` → 自我排除 → 扫描集空 →
341
+ * 命中空结果保护 → **报成功但零产出**。此修复使行为与执行时序**彻底解耦**。
342
+ * ② 重建结果为空 → 拒绝覆盖(防历史手工端点被抹;保护本身正确,**但必须可见**,见 §104 R4)。
343
+ * ③ 产出量对账(§104.2.2)——生成器原只判「源文件在不在」,从不判「吃进去多少 / 原文多少」,
344
+ * 形态失配因此长期隐性。
242
345
  */
243
346
  function generateApiIndex(projectRoot, globalArchDir, changeName) {
244
347
  const changesDir = join(projectRoot, 'changes');
@@ -246,29 +349,85 @@ function generateApiIndex(projectRoot, globalArchDir, changeName) {
246
349
  const header = '# API Index(generated by arch-merge, do not edit)\n\n';
247
350
 
248
351
  const endpoints = [];
352
+ const details = [];
249
353
  if (existsSync(changesDir)) {
250
354
  for (const changeDirName of readdirSync(changesDir)) {
251
355
  const changeRoot = join(changesDir, changeDirName);
252
- if (!isChangeClosed(changeRoot)) continue; // v0.36.3:只扫已关闭 change
356
+ if (changeDirName !== changeName && !isChangeClosed(changeRoot)) continue; // §103 R2
253
357
  const apiPath = join(changeRoot, 'architecture', 'api.md');
254
358
  if (!existsSync(apiPath)) continue;
255
- const ep = extractEndpoints(readFileSync(apiPath, 'utf-8'));
256
- for (const e of ep) endpoints.push({ ...e, source: changeDirName });
359
+ const det = extractEndpointsDetailed(readFileSync(apiPath, 'utf-8'));
360
+ details.push({ change: changeDirName, ...det });
361
+ for (const e of det.endpoints) endpoints.push({ ...e, source: changeDirName });
362
+ }
363
+ }
364
+
365
+ // ---- 产出量对账(§104.2.2 / §115.3)----
366
+ const warnings = [];
367
+ const failures = [];
368
+ for (const d of details) {
369
+ const epSections = d.sections.filter(s => s.isEndpointSection);
370
+ const epTotal = epSections.reduce((n, s) => n + s.endpoints, 0);
371
+ const candTotal = epSections.reduce((n, s) => n + s.candidates, 0);
372
+
373
+ // FAIL 前提(§115.3 A-2):仅「端点章节」参与判定,且要求 every(全零) && some(有候选)。
374
+ // 这样纯 DB 变更 / 纯重构 change(api.md 存在但无端点)不会误杀——它们没有端点章节候选。
375
+ //
376
+ // v0.53.0 §115.8(P4 复核发现,**严重**):**失败必须归属到 owner**。
377
+ // 本函数扫描「当前 change + 全部已关闭 change」。若把扫描集中**任一** change 的解析
378
+ // 失败都算作本次失败,则**一个历史脏数据会死锁所有后续 change 的 closing**——
379
+ // 实测:无关的已关闭 change 畸形 → `tf arch-merge <当前 change>` 退出码 1,
380
+ // 而当前 change 自己的回写已落地、`arch-merged` 门禁也 PASS;且 `arch_merge_skipped`
381
+ // 只豁免 guard 维度、**不清除命令级 failure** → 逃生通道完全不通。
382
+ //
383
+ // 判据:只有**本 change** 的解析失配才是本次 merge 的 failure(意味着本 change 的
384
+ // 端点未能入索引,回写不完整);其他 change 的问题降级为 WARN,由各自 owner 修复。
385
+ if (epSections.length > 0 && epTotal === 0 && candTotal > 0) {
386
+ const msg = `${d.change}:端点章节表有 ${candTotal} 行候选但 0 条提取——api.md 表格形态失配`;
387
+ if (d.change === changeName) failures.push(msg);
388
+ else warnings.push(`[历史 change 解析异常] ${msg}(不影响本次回写,请单独修复该 change 的 api.md)`);
389
+ }
390
+ // ratio:仅端点章节内(§104.2.2 ⑤)——实测修复前 C1/C2/C3 为 0.163/0.083/0.077
391
+ // 三个文件全部触发,故该规则**必须与 §105 同批上线**才有判别力。
392
+ if (candTotal > 0 && epTotal / candTotal < 0.5) {
393
+ warnings.push(`${d.change}:端点章节覆盖率偏低 ${epTotal}/${candTotal}`);
257
394
  }
395
+ // 分层展示(§104.2.2 ②):真信号层(端点章节内)逐条;噪音层折叠为计数。
396
+ // 实测端点章节内 unmatched 恒为 0 → **平时静默、出问题才响**。
397
+ for (const u of d.unmatched.filter(x => x.isEndpointSection)) {
398
+ warnings.push(`${d.change}:端点章节内未识别(${u.kind})第 ${u.line} 行: ${u.text}`);
399
+ }
400
+ const noiseCount = d.unmatched.length - d.unmatched.filter(x => x.isEndpointSection).length;
401
+ if (noiseCount > 0) {
402
+ warnings.push(`${d.change}:引用/映射/约定章节未识别 ${noiseCount} 行(噪音层,已折叠)`);
403
+ }
404
+ }
405
+
406
+ // 解析失配 → fail-closed 不写盘(与聚合链 abort 标准对齐)
407
+ if (failures.length > 0) {
408
+ return { generated: false, reason: 'endpoint-parse-mismatch', failures, warnings, file: 'API-INDEX.md', endpoints: endpoints.length };
258
409
  }
259
410
 
260
- // 空结果拒绝覆盖(数据丢失保护)
411
+ // 空结果拒绝覆盖(数据丢失保护,**保留**)+ 可见化(§104 R4:不得静默)
261
412
  if (endpoints.length === 0 && existsSync(dstPath)) {
262
- return { generated: false, reason: 'empty-scan-skip-overwrite', file: 'API-INDEX.md', endpoints: 0 };
413
+ return {
414
+ generated: false,
415
+ reason: 'empty-scan-skip-overwrite',
416
+ warnings: [...warnings, '扫描结果为空,已保留既有 API-INDEX.md 未被覆盖(防历史手工端点被抹);若确应清空请手工处理'],
417
+ file: 'API-INDEX.md',
418
+ endpoints: 0,
419
+ };
263
420
  }
264
421
 
265
422
  const lines = [header, '> 扫描 changes/**/api.md + domains/* 归一化端点重建(v0.14 §62.4)。\n'];
266
423
  lines.push('| 方法 | 路径 | 分流 | 来源 |', '|------|------|------|------|');
267
424
  for (const e of endpoints) {
268
- lines.push(`| ${e.method || '—'} | ${e.path} | ${e.kind} | ${e.source} |`);
425
+ // method 恒非空(§105.2.4「无方法即跳过」保证),故不再写 `—` 占位——
426
+ // 否则往返解析(updateIndex 会重新 extractEndpoints)时该行会被判为无方法而丢失。
427
+ lines.push(`| ${e.method} | ${e.path} | ${e.kind} | ${e.source} |`);
269
428
  }
270
429
  writeFile(dstPath, lines.join('\n'), 'utf-8');
271
- return { generated: true, file: 'API-INDEX.md', endpoints: endpoints.length };
430
+ return { generated: true, file: 'API-INDEX.md', endpoints: endpoints.length, warnings, failures: [] };
272
431
  }
273
432
 
274
433
  /* ============ 冲突预检 ============ */
@@ -280,27 +439,63 @@ function generateApiIndex(projectRoot, globalArchDir, changeName) {
280
439
  */
281
440
  function conflictCheck(changeDir, archDir, globalArchDir, changeName) {
282
441
  const conflicts = [];
442
+ const warnings = [];
283
443
  const apiPath = join(archDir, 'api.md');
284
444
  const archPath = join(archDir, 'architecture.md');
285
445
 
286
446
  if (existsSync(apiPath)) {
287
- const newEndpoints = extractEndpoints(readFileSync(apiPath, 'utf-8'));
447
+ // v0.53.0 §115.5:**排除 As-Is 段端点**。
448
+ //
449
+ // As-Is 段(`## 1. As-Is 基线(冻结复制)` / `### 1.x 现有 …`)是**冻结复制的基线记录**,
450
+ // 不是本 change 的端点所有权声明。原实现比较两个 change 的**全部**端点 → 多个 change
451
+ // 各自复制同一批 As-Is 端点即被误判为所有权冲突。
452
+ //
453
+ // emp-auth 实测:C2 与 C3 的 As-Is 段共享 5 条 `/auth/*` 端点(C2 明确标注「F5 归 C3」
454
+ // 即这些归 C3 实现)→ C2 的 arch-merge 被阻断,其演进日志与 marker 来源列永不写入
455
+ // → 连带 `arch-merged` 门禁对该 change 无出路。
456
+ //
457
+ // **该缺陷是 R5 修复暴露出来的**:修复前合并式单元格提取不到这些端点,冲突检查
458
+ // 是"假通过"(同时真冲突也检不出);修复后端点提取完整,误判才显现。
459
+ const newEndpoints = extractEndpointsDetailed(readFileSync(apiPath, 'utf-8'))
460
+ .endpoints.filter(e => !e.isAsIs);
288
461
  const indexPath = join(globalArchDir, 'API-INDEX.md');
289
462
  if (existsSync(indexPath)) {
290
463
  // 全局端点带来源(API-INDEX 生成格式列3=来源);排除本 change 自身的(幂等重复不冲突)
464
+ //
465
+ // v0.53.0 §105.3 R12:**改用 `parseTableRow` + 共享常量**。
466
+ // 原实现是**手写的第二套表格解析器**——`slice(1,-1).split('|')` 自建拆分 + 自建
467
+ // 反引号剥离 + 硬编码路径字符集。它与生成侧的 `parseTableRow` **清洗规则不同**:
468
+ // 若生成侧未来输出 `**GET**`,`parseTableRow`(已支持强调剥离)认得出、此处认不出
469
+ // → 校验静默漏检。**仅把字符集换成共享常量不够,必须统一拆分与清洗入口**——
470
+ // 否则只统一了字符集,口径分裂仍在。
291
471
  const existingByKey = new Map();
292
472
  for (const line of readFileSync(indexPath, 'utf-8').split('\n')) {
293
- if (!line.trim().startsWith('|')) continue;
294
- const cells = line.trim().slice(1, -1).split('|').map(c => c.trim().replace(/`/g, ''));
295
- if (cells.length < 4) continue;
473
+ const cells = parseTableRow(line);
474
+ if (!cells || cells.length < 4) continue;
296
475
  const method = cells[0], path = cells[1], source = cells[3];
297
- if (!/^\/[\w\-/{}.]+$/.test(path)) continue;
476
+ if (!PATH_CELL_RE.test(path)) continue;
298
477
  existingByKey.set(`${method}:${path}`, source);
299
478
  }
479
+ // v0.53.0 §115.5:**端点重复声明由阻断降级为告警**。
480
+ //
481
+ // 原实现把"两个 change 涉及同一端点"当作所有权冲突硬阻断。但实测(emp-auth):
482
+ // C2 与 C3 的 §2.1 To-Be 段都列了 `/auth/logout`、`/auth/token/flush`、
483
+ // `/auth/token/disable`(C2 明确标注「F5 归 C3」——即它知道这些归 C3,只是
484
+ // 在自己的 To-Be 表里记录了变更影响)。**两个 change 涉及同一端点是协作常态**
485
+ // (登录链路的 change 必然碰到会话治理的 change 要改的端点)。
486
+ //
487
+ // 硬阻断的后果与本方案的**核心目标直接冲突**:C2 因此无法完成 arch-merge →
488
+ // 演进日志与 marker 来源列永不写入 → 连带 `arch-merged` 门禁对该 change 无出路
489
+ // →「架构变更必须合并到台账」这一原则被破坏。
490
+ //
491
+ // **判据修正**:端点归属无法由解析判定(无法区分"重复实现"与"协作共享"),
492
+ // 故降级为 WARN 提示,交由 `cross-change-consistency-checker` agent 做语义判断。
493
+ // **聚合冲突保持阻断**——聚合所有权是明确的(一个聚合一个 owner,由 §2.1 聚合变更
494
+ // 表显式声明),不存在"协作共享聚合"的合法形态。
300
495
  for (const e of newEndpoints) {
301
496
  const key = `${e.method}:${e.path}`;
302
497
  if (existingByKey.has(key) && existingByKey.get(key) !== changeName) {
303
- conflicts.push(`端点 ${e.method} ${e.path} 已被 change ${existingByKey.get(key)} 占用 → 阻断`);
498
+ warnings.push(`端点 ${e.method} ${e.path} 亦被 change ${existingByKey.get(key)} 声明(协作共享,非阻断)`);
304
499
  }
305
500
  }
306
501
  }
@@ -321,7 +516,7 @@ function conflictCheck(changeDir, archDir, globalArchDir, changeName) {
321
516
  }
322
517
  }
323
518
 
324
- return { conflicts };
519
+ return { conflicts, warnings };
325
520
  }
326
521
 
327
522
  /* ============ INDEX 确定性统计 ============ */
@@ -444,7 +639,17 @@ function gitCommit(projectRoot, globalArchDir, touchedFiles, changeName, dryRun)
444
639
  unexpected,
445
640
  };
446
641
  } catch (e) {
447
- return { committed: false, error: e.message };
642
+ const msg = (e.stderr && e.stderr.toString()) || (e.stdout && e.stdout.toString()) || e.message || '';
643
+ // v0.53.0 §104.2.4(B' 方案的硬前提):无变更重跑是**幂等重试的常态**。
644
+ // B' 让 arch-merge 前移到门禁之前,「guard FAIL → 修复 → 重试」成为常态路径,
645
+ // arch-merge 会被反复执行;第二次 merge 内容无变化 → `git commit` 报 nothing to commit
646
+ // → 原实现落入 catch 返回 `{committed:false, error}`。一旦 §104 引入 failure 升级,
647
+ // 这会被计为 failure → 退出码非 0 → **重试永远无法通过门禁**(路径自我阻断)。
648
+ // 故必须归为 **no-op**,不得计为 failure。判据与 `cmd-publish` 同源。
649
+ if (msg.includes('nothing to commit') || msg.includes('nothing added to commit')) {
650
+ return { committed: false, noop: true, message: commitMsg };
651
+ }
652
+ return { committed: false, error: msg };
448
653
  }
449
654
  }
450
655
 
@@ -457,8 +662,19 @@ function detectUntouchedDirtyFiles(projectRoot, globalArchDir, touchedFiles) {
457
662
  // v0.23 §93.3.1:改用共享解析器。原 `^\S+\s+` 无法剥离 ` M path`(行首空格 = 未暂存修改,
458
663
  // 最常见形态)→ 路径残留状态码 → 本次 touch 的文件被误报为「非本次 touch 的脏文件」。
459
664
  // 路径口径统一为「相对 projectRoot 的绝对路径」后再与白名单比对,不依赖 process.cwd()。
460
- const touched = new Set(touchedFiles.map(p => resolve(projectRoot, p)));
461
- return parsePorcelainPaths(status).filter(p => p && !touched.has(resolve(projectRoot, p)));
665
+ //
666
+ // v0.53.0 §109.3 R10:比对判据改为**双向往返匹配**(与 `cmd-publish.detectOutsideDirty`
667
+ // 同源)。原实现是**精确匹配** `touched.has(resolve(p))`,而 git 对**全新未跟踪目录**
668
+ // 输出的是**目录级**路径 `?? docs/architecture/`(白名单是文件级)→ 永不命中 →
669
+ // **全新项目首次 arch-merge 必然误报**。该坑 `cmd-publish` 已踩过并修复(其注释明确
670
+ // 记录了「未跟踪内容显示目录级」场景),只是**未横展到 arch-merge**。
671
+ const norm = s => s.replace(/\/+$/, '');
672
+ const touchedDirs = touchedFiles.map(p => norm(resolve(projectRoot, p)));
673
+ return parsePorcelainPaths(status).filter(p => {
674
+ if (!p) return false;
675
+ const abs = norm(resolve(projectRoot, p));
676
+ return !touchedDirs.some(t => abs === t || abs.startsWith(t + '/') || t.startsWith(abs + '/'));
677
+ });
462
678
  } catch (e) {
463
679
  // v0.24 §98.3.2:解析失败不得静默(旧实现静默返回 [] = 漏报)。commit 侧仍有
464
680
  // `git commit -- <白名单>` 与提交后回读比对两道防线,故此处仅告警不阻断。
@@ -514,7 +730,16 @@ export function run(args = {}) {
514
730
  if (!preCheckResult.pass) {
515
731
  console.error(' Pre-check FAILED:');
516
732
  for (const c of preCheckResult.conflicts) console.error(` - ${c}`);
517
- return { merged: false, reason: 'pre-check-failed', conflicts: preCheckResult.conflicts };
733
+ // 返回形态统一(v0.53.0 §104.2.3):**所有路径都必须带 warnings/failures 双数组**。
734
+ // 早退路径原先只返 {merged, reason, conflicts},调用方写 `result.failures.length`
735
+ // 会抛 TypeError——同一契约面两种形状是漂移源。
736
+ return {
737
+ merged: false,
738
+ reason: 'pre-check-failed',
739
+ conflicts: preCheckResult.conflicts,
740
+ warnings: [],
741
+ failures: preCheckResult.conflicts,
742
+ };
518
743
  }
519
744
 
520
745
  // Step 1b: 冲突预检
@@ -522,11 +747,17 @@ export function run(args = {}) {
522
747
  if (conflictResult.conflicts.length > 0) {
523
748
  console.error(' Conflict check FAILED:');
524
749
  for (const c of conflictResult.conflicts) console.error(` - ${c}`);
525
- return { merged: false, reason: 'conflict', conflicts: conflictResult.conflicts };
750
+ return {
751
+ merged: false,
752
+ reason: 'conflict',
753
+ conflicts: conflictResult.conflicts,
754
+ warnings: conflictResult.warnings || [],
755
+ failures: conflictResult.conflicts,
756
+ };
526
757
  }
527
758
 
528
759
  // Step 2: ARCHITECTURE.md(当前态 upsert)
529
- const archResult = mergeArchitecture(changeDir, archDir, globalArchDir, changeName);
760
+ const archResult = mergeArchitecture(changeDir, archDir, globalArchDir, changeName, projectRoot);
530
761
 
531
762
  // Step 4: schema-baseline.sql(幂等追加)——先同步 DDL,供 PHYSICAL-MODEL/DATABASE 生成消费
532
763
  const schemaResult = syncSchemaBaseline(changeDir, archDir, globalArchDir, changeName);
@@ -563,10 +794,45 @@ export function run(args = {}) {
563
794
  }
564
795
  const commitResult = gitCommit(projectRoot, globalArchDir, touchedFiles, changeName, dryRun);
565
796
 
566
- console.log('\narch-merge complete.');
797
+ // ---- v0.53.0 §104.2.3 失败升级:子步骤结果不再被吞成 merged:true ----
798
+ // 原实现无视全部子步骤结果,一律 `return { merged: true }` + `arch-merge complete.`
799
+ // → `empty-scan-skip-overwrite` 等降级被静默,调用方(release-archivist)在 closing
800
+ // 归档报告里看到的是"全绿"。而**聚合链是有 abort() 的**——两条链标准不对称。
801
+ const warnings = [
802
+ ...(conflictResult.warnings || []),
803
+ ...(apiResult.warnings || []),
804
+ ...(archResult.reason === 'no-aggregate-delta'
805
+ ? [`${changeName}:本 change 无聚合增量(aggregate_action: none),marker 区按已合并 change 全集投影,未新增聚合行`]
806
+ : []),
807
+ ...(schemaResult.synced === false && schemaResult.reason && schemaResult.reason !== 'no-ddl-dir'
808
+ ? [`${changeName}:schema-baseline 未同步(${schemaResult.reason})`]
809
+ : []),
810
+ ...(commitResult.noop ? [`${changeName}:无内容变更,未产生新提交(幂等重跑,非失败)`] : []),
811
+ // commit 失败归 **warning 而非 failure**:arch-merge 的核心职责是**回写制品**(已成功),
812
+ // commit 是协作便利层。非 git 环境(如单测 fixture)不应阻断 merge——但必须可见。
813
+ ...(commitResult.error ? [`${changeName}:git commit 失败(制品已写盘)——${commitResult.error}`] : []),
814
+ ...(dirty.length > 0 ? [`${changeName}:docs/architecture/ 有 ${dirty.length} 个非本次 touch 的改动,未纳入本次提交`] : []),
815
+ ];
816
+ const failures = [
817
+ // 只收「设计层面判定为解析/生成失败」的项。schema 未同步(无 DDL 目录/无 .sql 文件)
818
+ // 属**正常降级**(change 可能本就无 DB 变更),归 warning。
819
+ ...(archResult.merged === false && archResult.reason !== 'no-source'
820
+ ? [`architecture.md 未合并(${archResult.reason})`] : []),
821
+ ...(apiResult.failures || []),
822
+ ];
823
+
824
+ for (const w of warnings) console.warn(` [WARN] ${w}`);
825
+ for (const f of failures) console.error(` [FAIL] ${f}`);
826
+
827
+ const merged = failures.length === 0;
828
+ console.log(merged
829
+ ? (warnings.length ? `\narch-merge complete (with ${warnings.length} warning(s)).` : '\narch-merge complete.')
830
+ : `\narch-merge FAILED: ${failures.length} failure(s).`);
567
831
  return {
568
- merged: true,
832
+ merged,
569
833
  changeName,
834
+ warnings,
835
+ failures,
570
836
  steps: {
571
837
  architecture: archResult, physicalModel: pmResult, database: dbResult,
572
838
  schema: schemaResult, archive: archiveResult, apiIndex: apiResult,
@@ -578,6 +844,96 @@ export function run(args = {}) {
578
844
  }
579
845
  }
580
846
 
847
+ /* ============ 生成器导出(供 tf arch scaffold 复用,§102.2.1 / §115.1 P2-4) ============ */
848
+
849
+ /**
850
+ * 全局台账初始化脚手架:产出**目标格式**的空基线(而非复制 change 级模板)。
851
+ *
852
+ * 背景(§101.3 根因 I):`workflow-bootstrap` B2 Step 0 原从
853
+ * `skills/architecture-design/templates/` 复制 **change 级模板** 到全局台账路径
854
+ * (`api.md → API-INDEX.md` 等 5 处),与 arch-merge 的生成式产物语义互斥。
855
+ * 其中 `DATABASE/PHYSICAL-MODEL/INDEX` 因无条件重建而被覆盖,`ARCHITECTURE/API-INDEX`
856
+ * 则永久卡在模板态——且 `API-INDEX.md` 模板自带的 4 条 `/api/xxx` 占位行使扫描非空,
857
+ * **连"空结果拒绝覆盖"保护都不触发**,`updateIndex` 还把这 4 行统计成 `端点数量: 4`。
858
+ *
859
+ * 本函数复用 arch-merge 的**同一批生成器**,构造上消除格式漂移:脚手架产出什么格式,
860
+ * 生成器就维护什么格式。
861
+ *
862
+ * @returns {{created: string[], skipped: string[]}}
863
+ */
864
+ export function scaffoldGlobalLedger(projectRoot) {
865
+ const globalArchDir = join(projectRoot, 'docs', 'architecture');
866
+ const created = [];
867
+ const skipped = [];
868
+ const schemaPath = join(globalArchDir, 'schema-baseline.sql');
869
+
870
+ if (!existsSync(globalArchDir)) mkdirSync(globalArchDir, { recursive: true });
871
+
872
+ const writeIfAbsent = (name, produce) => {
873
+ const p = join(globalArchDir, name);
874
+ if (existsSync(p)) { skipped.push(name); return; }
875
+ fsWriteFileSync(p, produce(), 'utf-8');
876
+ created.push(name);
877
+ };
878
+
879
+ // 空 marker 区骨架(与 buildCurrentStateSection 同源)。
880
+ // 带 H1 与内容归属说明(v0.53.0 §115.6,P4 反馈 m2 / 问题8):同批其他制品均有 H1,
881
+ // 且需澄清「As-Is 叙述写哪」——arch-merge 只重写 marker 区**之间**,区外内容保留。
882
+ writeIfAbsent('ARCHITECTURE.md', () => [
883
+ '# Architecture',
884
+ '',
885
+ '> **As-Is 架构叙述写在本文件 marker 区之外**(分层 / 模块边界 / 技术选型等)。',
886
+ // ⚠ 此处**不得原样写出 marker 字符串**——那会让文件里出现两对 begin/end,
887
+ // 触发 mergeArchitecture 的 marker 成对校验 → 首次 arch-merge 直接 abort。
888
+ // (本行曾是 `${MARKER_BEGIN}` 插值,被 R1 的"scaffold 产物可被接管"测试抓住。)
889
+ '> 两个 `arch:current-state` HTML 注释标记**之间**为全局当前态(聚合注册表),',
890
+ '> 由 `tf arch-merge` 独占维护(generated — 请勿手工编辑该区)。',
891
+ '',
892
+ MARKER_BEGIN,
893
+ buildCurrentStateSection([]),
894
+ MARKER_END,
895
+ '',
896
+ ].join('\n'));
897
+ // 生成器产物(无 schema 时输出"暂无")
898
+ writeIfAbsent('PHYSICAL-MODEL.md', () => {
899
+ const header = '# Physical Model(generated by arch-merge, do not edit)\n\n';
900
+ return header + '> schema-baseline.sql 不存在,暂无物理模型。\n';
901
+ });
902
+ writeIfAbsent('DATABASE.md', () => {
903
+ const header = '# Database(generated by arch-merge, do not edit)\n\n';
904
+ return header + '> schema-baseline.sql 不存在,暂无库表说明。\n';
905
+ });
906
+ writeIfAbsent('API-INDEX.md', () => [
907
+ '# API Index(generated by arch-merge, do not edit)',
908
+ '',
909
+ '> 扫描 changes/**/api.md 归一化端点重建(v0.14 §62.4)。',
910
+ '',
911
+ '| 方法 | 路径 | 分流 | 来源 |',
912
+ '|------|------|------|------|',
913
+ '',
914
+ ].join('\n'));
915
+ writeIfAbsent('INDEX.md', () => [
916
+ '# Architecture Index',
917
+ '',
918
+ '> 本文件始终加载,各产物按需 Read。',
919
+ '',
920
+ '## 统计(确定性源)',
921
+ '- 限界上下文: 0',
922
+ '- 表数量: 0',
923
+ '- 端点数量: 0',
924
+ '',
925
+ ].join('\n'));
926
+
927
+ if (!existsSync(schemaPath)) {
928
+ fsWriteFileSync(schemaPath, '-- Schema Baseline\n-- Auto-maintained by arch-merge (generated, do not edit)\n\n', 'utf-8');
929
+ created.push('schema-baseline.sql');
930
+ } else {
931
+ skipped.push('schema-baseline.sql');
932
+ }
933
+
934
+ return { created, skipped };
935
+ }
936
+
581
937
  // 支持直接执行(仅当被 node 直接运行,非被 import——node --test 加载时 process.argv[1] 可能含文件名片段)
582
938
  if (process.argv[1] && import.meta.url === pathToFileURL(process.argv[1]).href) {
583
939
  const args = process.argv.slice(2);
@@ -587,5 +943,9 @@ if (process.argv[1] && import.meta.url === pathToFileURL(process.argv[1]).href)
587
943
  else if (args[i] === '--dry-run') parsed.dryRun = true;
588
944
  else if (!args[i].startsWith('--')) parsed._.push(args[i]);
589
945
  }
590
- run(parsed);
946
+ const result = run(parsed);
947
+ // v0.53.0 §104.2.3:有 failure → 非零退出码。
948
+ // 原实现一律 exit 0 并以 `arch-merge complete.` 结束,降级(如空扫描保护命中)对
949
+ // 调用方完全不可见。
950
+ if (result && Array.isArray(result.failures) && result.failures.length > 0) process.exitCode = 1;
591
951
  }