@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
@@ -4,15 +4,81 @@
4
4
  // 背景(红队评估实证):旧实现正则硬编码(`## 2. To-Be`、`/api/` 前缀、反引号表名)
5
5
  // 在 LLM 文档标题漂移/格式变化时静默失败(match 失配返回空串、流程继续、返回 merged:true)。
6
6
  // 本模块用通用表格解析(按 `|` split + 去反引号)与格式容错匹配,抽取失配由调用方 abort。
7
+ //
8
+ // v0.53.0 形态健壮化(设计增强方案 v0.25 §105 / §106 / §115):
9
+ // - **共享常量**(§105.3):路径字符集与方法词表原在「提取侧(本文件)」与「校验侧
10
+ // (arch-merge.conflictCheck)」各自硬编码 → 改一处即口径分裂(提取到、校验认不出 →
11
+ // 冲突逃逸)。本文件为唯一来源,`arch-merge` 的手写解析器一律改调 `parseTableRow`。
12
+ // - **extractEndpoints 六种形态**(§105.2):合并式单元格 / 强调包裹 / 逗号枚举 /
13
+ // 括号后缀 / 章节 kind 归属 / 无方法即跳过。并新增 `extractEndpointsDetailed`
14
+ // 承载产出量对账(§104.2.1)。
15
+ // - **extractAggregates 表头定位**(§106.2):原实现用第二列的**值**是否命中
16
+ // `新增|修改|New|Update` 判断表布局;C1 实际写的是 `**extend**`(DDD 术语 + 加粗)
17
+ // → 两重失配 → 列语义整体错位 → 全局台账被写入 `上下文=extend`、
18
+ // `根实体=SysFrontSystem(不变)`。**这是污染而非缺失**。改判据为表头定位。
19
+
20
+ /**
21
+ * HTTP 方法词表——**三个方法正则的唯一事实源**。
22
+ *
23
+ * 单一来源是刻意的:本文件早期版本把词表在 3 个正则里各写了一遍字面量
24
+ * (`HTTP_METHOD_RE` / `MULTI_METHOD_RE` / `MERGED_CELL_RE`),而 `arch-merge` 的
25
+ * 校验侧又自建第 4 份——这正是根因 III「同一跨文件契约在多处独立实现 → 判据漂移」
26
+ * (§101.5)。新增方法(如 HEAD / OPTIONS)时凡漏改一处即产生口径分裂。
27
+ */
28
+ export const HTTP_METHODS = ['GET', 'POST', 'PUT', 'DELETE', 'PATCH'];
29
+ const METHODS_ALT = HTTP_METHODS.join('|');
7
30
 
8
- /** 解析一行 markdown 表格为单元格数组(去反引号/去 code 包裹/trim)。非表格行返回 null。 */
31
+ export const HTTP_METHOD_RE = new RegExp(`^(${METHODS_ALT})$`, 'i');
32
+ /**
33
+ * 多方法单元格:官方根模板 `templates/api.md` 授权 `POST / PUT / DELETE` 写法
34
+ * (§115.3 D-3)。若只认单方法,未来该类行会被静默丢弃。
35
+ */
36
+ export const MULTI_METHOD_RE = new RegExp(`^(${METHODS_ALT})(\\s*/\\s*(?:${METHODS_ALT}))+$`, 'i');
37
+ /** 路径单元格(含逗号枚举,如 `/check/{add,update}/{a,b}/unique`)。 */
38
+ export const PATH_CELL_RE = /^\/[\w\-/{},.]+$/;
39
+ /** 合并式单元格:方法与路径同格(捕获组 1=方法,2=路径)。 */
40
+ export const MERGED_CELL_RE = new RegExp(`^(${METHODS_ALT})\\s+(\\S+)`, 'i');
41
+ /** 路径合法字符前缀——用于合并式捕获后的**截断 + 复验**,防全角括号/尾部描述混入路径。 */
42
+ const PATH_PREFIX_RE = /^\/[\w\-/{},.]*/;
43
+
44
+ /**
45
+ * 端点章节的**表级**判定:首列表头**精确等于**其中之一。
46
+ *
47
+ * ⚠ **禁止 `includes` / `startsWith`**(§115.3 A-1,全量死锁风险):
48
+ * `API 端点` 是引用/对齐表的固定表头,来自**两个官方模板**——
49
+ * `templates/api.md`(`| API 端点 | 支撑的数据实体 | 一致性 |`)与
50
+ * `skills/architecture-design/templates/api.md`(`| API 端点 | 对应数据实体 | 对齐状态 |`)。
51
+ * **每一份按模板产出的 api.md 都带此表**,且模板行本身是无斜杠简写(candidates>0 / endpoints=0)。
52
+ * 若用 contains 匹配,该表被判为端点章节 → 触发 FAIL 判据 → 配合 arch-merged 阻断语义
53
+ * → **全量 change 死锁**(严重度高于零聚合 abort:后者只影响纯 DB change)。
54
+ */
55
+ export const ENDPOINT_HEADERS = new Set(['端点', 'Endpoint', 'API', '接口']);
56
+
57
+ /** 强调标记剥离:`**x**` / `__x__` / `*x*` / `_x_` → `x`(首尾成对才剥离;长的优先)。 */
58
+ function stripEmphasis(s) {
59
+ let out = s;
60
+ for (const [open, close] of [['**', '**'], ['__', '__'], ['*', '*'], ['_', '_']]) {
61
+ if (out.length > open.length + close.length && out.startsWith(open) && out.endsWith(close)) {
62
+ out = out.slice(open.length, -close.length).trim();
63
+ }
64
+ }
65
+ return out;
66
+ }
67
+
68
+ /**
69
+ * 解析一行 markdown 表格为单元格数组(去反引号 + 剥离强调标记 + trim)。非表格行返回 null。
70
+ *
71
+ * v0.53.0 §105.2.2:新增强调标记剥离。原实现只去反引号,`**\`/path\`**` 清理后残留 `**`
72
+ * → 首字符非 `/` → 整行漏提取。实测 C1 的 `update/status`、`reset/secret` 两条**新增**
73
+ * 端点因此丢失——即**最重要的增量端点最容易被漏掉**。
74
+ */
9
75
  export function parseTableRow(line) {
10
76
  const trimmed = line.trim();
11
77
  if (!trimmed.startsWith('|') || !trimmed.endsWith('|')) return null;
12
78
  return trimmed
13
79
  .slice(1, -1)
14
80
  .split('|')
15
- .map(cell => cell.trim().replace(/`/g, '').trim());
81
+ .map(cell => stripEmphasis(cell.trim().replace(/`/g, '').trim()).trim());
16
82
  }
17
83
 
18
84
  /**
@@ -43,46 +109,259 @@ export function parseTableAfter(content, headingRe) {
43
109
  }
44
110
 
45
111
  /**
46
- * api.md 提取端点(Command/Read/Query 三段表格)。
47
- * 容错:匹配路径单元格(`/xxx/{id}`)与方法单元格(GET/POST/PUT/DELETE/PATCH),
48
- * 不依赖 `/api/` 前缀硬编码(企业系统可能是 /v1/、/gateway/oms/ 等)。
112
+ * 章节标题分类。**未识别的标题返回 `'unknown'` 而非 `null`——即显式重置**
113
+ * (§105.2.6):原实现只在匹配 Command/Read/Query 时更新 currentKind 且**从不重置**,
114
+ * 「前端路由契约」类标题不匹配任何模式 沿用更早章节(如 `### 1.3 现有 Query API`)
115
+ * 留下的 `Query` → C2/C3 的前端路由误报被标注为 Query 分流(**误报不仅内容错、分类也错**)。
116
+ * 非标题行返回 null(不动状态)。
49
117
  */
50
- export function extractEndpoints(apiMd) {
118
+ function classifyHeading(line) {
119
+ if (!/^#{2,3}\s/.test(line)) return null;
120
+ if (/(Command|命令)/i.test(line)) return 'Command';
121
+ if (/(Read|读取)/i.test(line)) return 'Read';
122
+ if (/(Query|查询)/i.test(line)) return 'Query';
123
+ return 'unknown';
124
+ }
125
+
126
+ /**
127
+ * 端点提取 + 产出量对账(§104.2.1)。
128
+ *
129
+ * 返回 `{ endpoints, candidates, unmatched, sections }`;`extractEndpoints` 为兼容包装。
130
+ *
131
+ * 形态支持(§105.2 六种):
132
+ * 1. 合并式单元格 `GET /path`(含尾部描述/全角括号 → **截断 + PATH_CELL_RE 复验**)
133
+ * 2. 强调包裹 `**\`/path\`**`(由 `parseTableRow` 统一剥离)
134
+ * 3. 逗号枚举 `/x/{a,b}`(`PATH_CELL_RE` 含逗号)
135
+ * 4. 括号后缀 `/x(svc)`(截断)
136
+ * 5. 章节 kind 归属(含未识别标题的显式重置)
137
+ * 6. 无方法即跳过 + 按 `method+path` 去重
138
+ */
139
+ export function extractEndpointsDetailed(apiMd) {
51
140
  const endpoints = [];
141
+ const unmatched = [];
142
+ const sections = new Map();
143
+ const seen = new Set();
52
144
  const lines = apiMd.split('\n');
145
+
53
146
  let currentKind = 'unknown';
54
- for (const line of lines) {
55
- if (/^#{2,3}\s*.*(Command|命令)/i.test(line)) currentKind = 'Command';
56
- else if (/^#{2,3}\s*.*(Read|读取)/i.test(line)) currentKind = 'Read';
57
- else if (/^#{2,3}\s*.*(Query|查询)/i.test(line)) currentKind = 'Query';
58
- if (!line.trim().startsWith('|')) continue;
147
+ let headingText = '(top)';
148
+ let currentIsAsIs = false;
149
+ let inTable = false;
150
+ let tableHeader = null;
151
+ let sectionIsEndpoint = false;
152
+ let sectionCandidates = 0;
153
+ let sectionEndpoints = 0;
154
+
155
+ const flushSection = () => {
156
+ const key = `${headingText}::${sectionIsEndpoint ? 'EP' : 'ref'}`;
157
+ const prev = sections.get(key) || { heading: headingText, isEndpointSection: sectionIsEndpoint, candidates: 0, endpoints: 0 };
158
+ prev.candidates += sectionCandidates;
159
+ prev.endpoints += sectionEndpoints;
160
+ sections.set(key, prev);
161
+ sectionCandidates = 0;
162
+ sectionEndpoints = 0;
163
+ };
164
+
165
+ for (let i = 0; i < lines.length; i++) {
166
+ const line = lines[i];
167
+
168
+ // ---- 标题:切换 kind 与章节,并**显式重置**章节级统计 ----
169
+ const headingClass = classifyHeading(line);
170
+ if (headingClass !== null) {
171
+ if (inTable) flushSection();
172
+ currentKind = headingClass;
173
+ headingText = line.trim().slice(0, 60);
174
+ // As-Is 段(`## 1. As-Is 基线(冻结复制)` / `### 1.x 现有 …`):这些表是
175
+ // **冻结复制的基线记录**,不代表本 change 的端点所有权。`conflictCheck` 必须
176
+ // 排除它们,否则多个 change 各自复制同一批 As-Is 端点会被误判为所有权冲突
177
+ // (emp-auth 实测:C2/C3 的 As-Is 段共享 5 条 `/auth/*` 端点 → C2 被阻断)。
178
+ currentIsAsIs = /as-?is|现有/i.test(line);
179
+ sectionIsEndpoint = headingClass !== 'unknown';
180
+ inTable = false;
181
+ tableHeader = null;
182
+ continue;
183
+ }
184
+
185
+ const isTableLine = line.trim().startsWith('|');
186
+ if (!isTableLine) {
187
+ if (inTable) { flushSection(); inTable = false; tableHeader = null; }
188
+ continue;
189
+ }
190
+
191
+ // ---- 表格首行 = 表头;表头行不参与提取,但参与「端点章节」的表级判定 ----
192
+ if (!inTable) {
193
+ inTable = true;
194
+ tableHeader = parseTableRow(line);
195
+ // 表级端点章节判定:首列表头**精确等于**(禁 includes/startsWith,§115.3 A-1)
196
+ if (tableHeader && ENDPOINT_HEADERS.has((tableHeader[0] || '').trim())) sectionIsEndpoint = true;
197
+ continue;
198
+ }
199
+ if (/^\s*\|[\s:|-]+\|\s*$/.test(line)) continue; // 分隔行
200
+
59
201
  const cells = parseTableRow(line);
60
202
  if (!cells || cells.length === 0) continue;
61
- const pathCell = cells.find(c => /^\/[\w\-/{}.]+$/.test(c));
62
- if (!pathCell) continue;
63
- const methodCell = cells.find(c => /^(GET|POST|PUT|DELETE|PATCH)$/i.test(c));
64
- // kind:章节标题优先;若无章节标题(如 API-INDEX 生成格式),从表格分流列取
203
+
204
+ // ---- candidates 判定(§104.2.1)----
205
+ const mergedCell = cells.map(c => c.match(MERGED_CELL_RE)).find(Boolean);
206
+ const hasMethodWord = cells.some(c => HTTP_METHOD_RE.test(c) || MULTI_METHOD_RE.test(c)) || !!mergedCell;
207
+ const hasPathWord = cells.some(c => PATH_CELL_RE.test(c));
208
+ if (!hasMethodWord && !hasPathWord) continue;
209
+ sectionCandidates++;
210
+
211
+ // ---- 取 path / method(合并式优先,统一走「截断 + PATH_CELL_RE 复验」收口)----
212
+ // MERGED_CELL_RE 捕获组:1=方法,2=路径(`GET /auth/token/flush` → ['GET', '/auth/token/flush'])
213
+ let path = '';
214
+ let method = '';
215
+ if (mergedCell) {
216
+ const truncated = (mergedCell[2].match(PATH_PREFIX_RE) || [''])[0];
217
+ if (PATH_CELL_RE.test(truncated)) { path = truncated; method = mergedCell[1].toUpperCase(); }
218
+ }
219
+ if (!path) {
220
+ const pathCell = cells.find(c => PATH_CELL_RE.test(c));
221
+ if (pathCell) path = pathCell;
222
+ }
223
+ if (!path) {
224
+ if (hasMethodWord) {
225
+ unmatched.push({ line: i + 1, text: line.trim().slice(0, 120), section: headingText, isEndpointSection: sectionIsEndpoint, kind: 'method-no-path' });
226
+ }
227
+ continue;
228
+ }
229
+ if (!method) {
230
+ const methodCell = cells.find(c => HTTP_METHOD_RE.test(c) || MULTI_METHOD_RE.test(c));
231
+ if (methodCell) method = methodCell.split('/')[0].trim().toUpperCase();
232
+ }
233
+ // 无方法即跳过(§105.2.4)——消除前端路由误报与「半成品端点」两类噪音
234
+ if (!method) {
235
+ unmatched.push({ line: i + 1, text: line.trim().slice(0, 120), section: headingText, isEndpointSection: sectionIsEndpoint, kind: 'path-no-method' });
236
+ continue;
237
+ }
238
+
239
+ const key = `${method} ${path}`;
240
+ if (seen.has(key)) continue; // 去重(§105.2.6)
241
+ seen.add(key);
242
+
65
243
  const kindCell = cells.find(c => /^(Command|Read|Query)$/i.test(c));
66
244
  const kind = currentKind !== 'unknown' ? currentKind : (kindCell || 'unknown');
67
- endpoints.push({ path: pathCell, method: methodCell ? methodCell.toUpperCase() : '', kind });
245
+ endpoints.push({
246
+ path, method, kind,
247
+ section: headingText,
248
+ isEndpointSection: sectionIsEndpoint,
249
+ isAsIs: currentIsAsIs,
250
+ });
251
+ sectionEndpoints++;
68
252
  }
69
- return endpoints;
253
+ if (inTable) flushSection();
254
+
255
+ return {
256
+ endpoints,
257
+ candidates: [...sections.values()].reduce((n, s) => n + s.candidates, 0),
258
+ unmatched,
259
+ sections: [...sections.values()],
260
+ };
261
+ }
262
+
263
+ /** 兼容包装:沿用原签名(返回数组),三个既有消费方零改动。 */
264
+ export function extractEndpoints(apiMd) {
265
+ return extractEndpointsDetailed(apiMd).endpoints;
266
+ }
267
+
268
+ /**
269
+ * 聚合表表头关键词 → 字段名。**精确匹配**(normalize 后),长的语义优先靠 Map 顺序保证。
270
+ * 见 §106.2:由表头决定列语义,不再由「第二列的值」决定。
271
+ */
272
+ const AGG_HEADER_MAP = [
273
+ [['聚合id', 'aggregateid', 'aggregate', '聚合名称', '聚合'], 'id'],
274
+ [['聚合根', '根实体', 'root', '聚合根实体'], 'root'],
275
+ [['所属bc', '所属限界上下文', '所属上下文', '上下文', 'context', 'bc'], 'context'],
276
+ [['不变量', '关键不变量', 'invariant', 'invariants'], 'invariants'],
277
+ [['来源', 'source'], 'source'],
278
+ ];
279
+
280
+ /** 表头归一:去空白/下划线/反引号/强调标记,转小写(中文不受影响)。 */
281
+ function normalizeHeader(h) {
282
+ return (h || '').trim().replace(/[\s_*`]/g, '').toLowerCase();
283
+ }
284
+
285
+ /** 表头 → 列索引映射。未识别的字段不写入(调用方按 undefined 处理)。 */
286
+ function mapAggregateColumns(header) {
287
+ const col = {};
288
+ header.forEach((h, i) => {
289
+ const n = normalizeHeader(h);
290
+ for (const [keys, field] of AGG_HEADER_MAP) {
291
+ if (col[field] !== undefined) continue;
292
+ if (keys.includes(n)) { col[field] = i; break; }
293
+ }
294
+ });
295
+ return col;
296
+ }
297
+
298
+ /**
299
+ * 定位聚合表(三处容错,见 §106.1 的 R2 修正)。
300
+ *
301
+ * v0.53.0 修正:R2 原为 `/^#{3,4}\s*\d*\.?\s*(聚合变更|…)/`,其中 `\d*\.?` **无法消费
302
+ * 子编号 `2.1` 中的 `.1`** → 实测对真实标题 `### 2.1 聚合变更` 返回 false →
303
+ * **R2 从未被任何实际标题命中**,所有 change 级聚合表一直靠 R3(To-Be)兜底命中。
304
+ * 而 R3 取的是「To-Be」标题下的**第一个表格**,若该标题下首个表不是聚合表即取错表。
305
+ * 修正为 `\d+(?:\.\d+)*\.?` 支持任意层级子编号。
306
+ */
307
+ function locateAggregateTable(archMd) {
308
+ let rows = parseTableAfter(archMd, /^#{2,3}\s*\d*\.?\s*(聚合注册表|Aggregate Registry)/i);
309
+ if (rows.length) return rows;
310
+ rows = parseTableAfter(archMd, /^#{2,4}\s*(?:\d+(?:\.\d+)*\.?\s*)?(聚合变更|Aggregate Changes)/i);
311
+ if (rows.length) return rows;
312
+ return parseTableAfter(archMd, /^#{2,3}\s*\d*\.?\s*To-Be/i);
70
313
  }
71
314
 
72
315
  /**
73
316
  * 从 architecture.md 提取聚合清单。聚合 id 格式 `context:Aggregate`。
74
- * 兼容两种模板:
75
- * - 产品级「聚合注册表」:`| 聚合ID | 上下文 | 根实体 | ... |`(列1=上下文,列2=根,列5=不变量)
76
- * - change 级「To-Be 增量设计 §2.1 聚合变更」:`| 聚合名称 | 操作 | 聚合根 | ... | 所属 BC |`(列1=操作,列5=所属 BC)
317
+ *
318
+ * v0.53.0 §106.2:判据由「值判布局」改为「表头定位」。
319
+ *
320
+ * 原实现用**第二列的值**是否命中 `新增|修改|New|Update` 推断表布局,再据此决定后续列语义。
321
+ * C1 的 §2.1 写的是 `` `**extend**` ``(DDD 标准术语 + **加粗**)——**两重失配**
322
+ * (① 首字符 `*` 使 `^` 锚失败;② `extend` 不在词表)→ `isChange=false` → 按产品级布局
323
+ * 解读 → 全局台账被写入:
324
+ * ```
325
+ * | permission:FrontSystem | **extend** | SysFrontSystem(不变) | change:v1-C1 | 已落地 | emp-auth(不变) |
326
+ * ↑ 上下文取了「操作」列 ↑ 根实体取了聚合根列 ↑ 不变量取了「所属 BC」列
327
+ * ```
328
+ * **这是污染而非缺失**——错误数据进入台账比缺数据更难察觉。
329
+ *
330
+ * 表头定位后**布局判据自然消失**:change 级的 `所属 BC` 与产品级的 `上下文` 各自映射到
331
+ * `context`,无需推断表属于哪一类。
77
332
  */
78
333
  export function extractAggregates(archMd) {
79
- let rows = parseTableAfter(archMd, /^#{2,3}\s*\d*\.?\s*(聚合注册表|Aggregate Registry)/i);
80
- if (rows.length === 0) {
81
- rows = parseTableAfter(archMd, /^#{3,4}\s*\d*\.?\s*(聚合变更|Aggregate Changes)/i);
334
+ const rows = locateAggregateTable(archMd);
335
+ if (rows.length === 0) return [];
336
+ const header = rows[0] || [];
337
+ const dataRows = rows.slice(1);
338
+ const col = mapAggregateColumns(header);
339
+
340
+ // 兜底:表头映射基本失败(< 2 个字段)→ 退回位置逻辑,但**必须告警**(不再静默猜列)
341
+ if (Object.keys(col).length < 2) {
342
+ console.warn(` [WARN] extractAggregates 表头未识别(${header.join(' | ')})——回退位置逻辑,可能列错位`);
343
+ return legacyPositionalAggregates(dataRows);
82
344
  }
83
- if (rows.length === 0) {
84
- rows = parseTableAfter(archMd, /^#{2,3}\s*\d*\.?\s*To-Be/i);
345
+
346
+ const aggregates = [];
347
+ for (const row of dataRows) {
348
+ const pick = f => (col[f] !== undefined ? (row[col[f]] || '').trim() : '');
349
+ const id = pick('id');
350
+ if (!id || !/^[\w]+:[\w]+$/.test(id)) continue;
351
+ const src = pick('source');
352
+ aggregates.push({
353
+ id,
354
+ context: pick('context'),
355
+ root: pick('root'),
356
+ invariants: pick('invariants'),
357
+ source: /^change:/.test(src) ? src : '',
358
+ });
85
359
  }
360
+ return aggregates;
361
+ }
362
+
363
+ /** 位置兜底(表头未识别时):保持 v0.52.0 及以前的行为,仅作降级路径。 */
364
+ function legacyPositionalAggregates(rows) {
86
365
  const aggregates = [];
87
366
  for (const row of rows) {
88
367
  const id = row[0]?.trim();
@@ -90,7 +369,6 @@ export function extractAggregates(archMd) {
90
369
  if (!/^[\w]+:[\w]+$/.test(id)) continue;
91
370
  const col1 = row[1]?.trim() || '';
92
371
  const isChange = /^(新增|修改|New|Update)/i.test(col1);
93
- // source:产品级全局格式(buildCurrentStateSection 生成)列3=来源 changeName;change 级无来源列 → ''
94
372
  const source = /^change:/.test(row[3]?.trim() || '') ? row[3].trim() : '';
95
373
  aggregates.push({
96
374
  id,
@@ -7,6 +7,7 @@ import fs from 'node:fs';
7
7
  import path from 'node:path';
8
8
  import { parseArgs } from 'node:util';
9
9
  import * as archPrecheck from './arch-precheck.mjs';
10
+ import { scaffoldGlobalLedger } from './arch-merge.mjs';
10
11
 
11
12
  const ARCH_STATE_FILE = '.team-flow/arch-state.json';
12
13
 
@@ -25,12 +26,39 @@ export async function run(args) {
25
26
  const sub = positionals[0];
26
27
  if (sub === 'init') return init(values);
27
28
  if (sub === 'show') return show(values);
29
+ // v0.53.0 §102.2.1 R1:全局台账脚手架(B2 Step 0 的替代,产出目标格式而非复制模板)
30
+ if (sub === 'scaffold') return scaffold(values);
28
31
  // v0.22 §88.3.2:架构门判据的确定性证据工具(证据 only,退出码恒 0)
29
32
  if (sub === 'precheck') return archPrecheck.run(positionals.slice(1), values);
30
- console.error('Usage: tf arch init [--mode reconstruction|design] [--baseline-ref <prd/vN/>] | tf arch show | tf arch precheck <change-dir> [--json]');
33
+ console.error('Usage: tf arch init [--mode reconstruction|design] [--baseline-ref <prd/vN/>] | tf arch show | tf arch scaffold | tf arch precheck <change-dir> [--json]');
31
34
  process.exit(2);
32
35
  }
33
36
 
37
+ /**
38
+ * v0.53.0 §102.2.1 R1:全局台账脚手架。
39
+ *
40
+ * **背景(§101.3 根因 I)**:`workflow-bootstrap` B2 Step 0 原从
41
+ * `skills/architecture-design/templates/` 复制 **change 级模板** 到**全局台账路径**
42
+ * (`architecture.md→ARCHITECTURE.md`、`api.md→API-INDEX.md` 等 5 处)。两类写入方
43
+ * 语义互斥且互不知情——其中 DATABASE / PHYSICAL-MODEL / INDEX 因「无条件重建」被覆盖,
44
+ * 而 ARCHITECTURE / API-INDEX 永久卡在模板态。更隐蔽的是:`API-INDEX.md` 模板自带的
45
+ * 4 条 `/api/xxx` 占位行使扫描结果非空,**连"空结果拒绝覆盖"保护都不触发**,
46
+ * `updateIndex` 还把这 4 行统计成 `端点数量: 4` 写进 INDEX.md。
47
+ *
48
+ * **本命令产出目标格式的空基线**(与生成器同源),而非复制模板:
49
+ * 脚手架产出什么格式,生成器就维护什么格式——构造上消除格式漂移。
50
+ *
51
+ * **幂等**:已存在的文件不覆盖(只补缺失)。
52
+ */
53
+ function scaffold(values) {
54
+ const root = findRoot(values['project-root']);
55
+ const result = scaffoldGlobalLedger(root);
56
+ console.log(`全局台账脚手架:${path.join(root, 'docs', 'architecture')}`);
57
+ console.log(` 新建 ${result.created.length} 个:${result.created.join(', ') || '(无)'}`);
58
+ console.log(` 已存在跳过 ${result.skipped.length} 个:${result.skipped.join(', ') || '(无)'}`);
59
+ return result;
60
+ }
61
+
34
62
  function findRoot(rootOpt) {
35
63
  if (rootOpt) return path.resolve(rootOpt);
36
64
  let dir = process.cwd();
@@ -54,9 +54,19 @@ function parseArgv(argv) {
54
54
  return parsed;
55
55
  }
56
56
 
57
- /** git 执行封装(统一 encoding + 错误降级)。 */
57
+ /**
58
+ * git 执行封装(统一 encoding)。
59
+ *
60
+ * v0.24 §98.3.1:**只裁首尾换行,不用 `.trim()`**。`status --porcelain` 是固定宽度
61
+ * 格式,首行可能是 ` M path`(X 位空格 = 未暂存修改)——`.trim()` 会吃掉行首空格,
62
+ * 令 parsePorcelainPaths 的 `slice(3)` 切掉路径首字符(`changes/…` → `hanges/…`),
63
+ * 路径损坏 → 白名单失配 → 误报「白名单之外存在未提交文件」(而 git add 用的白名单
64
+ * 未损坏,文件实际被提交,形成"告警与行为不一致")。
65
+ * 对 `branch --show-current` / `rev-parse` 等单行输出,只裁尾换行与 trim 等效。
66
+ */
58
67
  function git(cwd, ...args) {
59
- return execFileSync('git', args, { encoding: 'utf-8', cwd, stdio: ['ignore', 'pipe', 'pipe'] }).trim();
68
+ return execFileSync('git', args, { encoding: 'utf-8', cwd, stdio: ['ignore', 'pipe', 'pipe'] })
69
+ .replace(/^[\r\n]+|[\r\n]+$/g, '');
60
70
  }
61
71
 
62
72
  function isDir(p) {
@@ -114,15 +124,46 @@ function collectWhitelist(projectRoot, targets, changeDir) {
114
124
  return paths;
115
125
  }
116
126
 
127
+ /**
128
+ * 白名单条目 → 仓库相对路径(**唯一实现**,v0.53.0 §115.10)。
129
+ *
130
+ * P4 复核 Minor #6:`collectWhitelist` 允许 `isAbsolute(changeDir)` 原样入列,而原先
131
+ * 散落在 **3 处**的 `p.replace(projectRoot + '/', '')` 对**工程外**绝对路径是**无操作**
132
+ * (前缀不匹配)——"相对路径"仍是绝对路径。后果:
133
+ * ① `git add <abs>` / `git commit -- <abs>` → git 报 `pathspec ... is outside repository`,
134
+ * 再被 commit 的 catch 改写成 `git commit failed: <原消息>`(**归因错误**:真因是白名单
135
+ * 条目越界,不是 commit 失败);
136
+ * ② `detectOutsideDirty` 的比对集中混入绝对路径,而 `git status --porcelain` 输出恒为
137
+ * 仓库相对路径 → 该条目对所有文件都不匹配,覆盖比对静默失效。
138
+ * 三处收敛为单一实现(同属本轮根因 Ⅲ「同一契约多处各自实现」)。
139
+ *
140
+ * @returns {string|null} 相对路径;条目不在 projectRoot 之下时返回 null(越界)
141
+ */
142
+ function toRelPath(p, projectRoot) {
143
+ const prefix = projectRoot.replace(/\/+$/, '') + '/';
144
+ return p.startsWith(prefix) ? p.slice(prefix.length) : null;
145
+ }
146
+
147
+ /** 严格版:越界即抛。用于会**写盘**的路径——越界属调用方/配置错误,不得静默降级为误导性报错。 */
148
+ function toRelPathStrict(p, projectRoot) {
149
+ const rel = toRelPath(p, projectRoot);
150
+ if (rel === null) {
151
+ throw new Error(`白名单条目不在工程根之下,无法构造 git pathspec:${p}(projectRoot=${projectRoot})`);
152
+ }
153
+ return rel;
154
+ }
155
+
117
156
  /**
118
157
  * 检测白名单之外的未提交/未跟踪文件(团队协作安全底线,仿 arch-merge detectUntouchedDirtyFiles)。
119
158
  * 返回值:外部脏文件列表(含 staged + unstaged + untracked)。
120
159
  */
121
160
  function detectOutsideDirty(projectRoot, whitelist) {
122
- const relWhitelist = new Set(whitelist.map(p => {
123
- const rel = p.replace(projectRoot + '/', '');
124
- return rel.replace(/\/+$/, '');
125
- }));
161
+ // 越界条目(toRelPath 返回 null)对仓库相对路径的比对无贡献,直接剔除;
162
+ // 写盘侧的越界由 toRelPathStrict 提前拦下,不会走到这里。
163
+ const relWhitelist = new Set(whitelist
164
+ .map(p => toRelPath(p, projectRoot))
165
+ .filter(r => r !== null)
166
+ .map(r => r.replace(/\/+$/, '')));
126
167
  try {
127
168
  const status = git(projectRoot, 'status', '--porcelain');
128
169
  if (!status) return [];
@@ -147,7 +188,10 @@ function detectOutsideDirty(projectRoot, whitelist) {
147
188
  }
148
189
  return true;
149
190
  });
150
- } catch {
191
+ } catch (e) {
192
+ // v0.24 §98.3.2:解析失败不得静默(旧实现静默返回 [] = 漏报)。提交主流程不受影响,
193
+ // 但必须让使用者知道"本次白名单外检测未生效",改以 git status 原始输出人工核对。
194
+ console.warn(`[WARN] 白名单外脏文件检测失败(${e.message})——本次不输出该告警,提交范围仍以白名单为准。`);
151
195
  return [];
152
196
  }
153
197
  }
@@ -209,7 +253,9 @@ export async function run(args = []) {
209
253
  // 脏文件检测(target 之外)→ 告警不阻断
210
254
  const outsideDirty = detectOutsideDirty(projectRoot, whitelist);
211
255
  if (outsideDirty.length > 0) {
212
- console.warn(`[WARN] 白名单之外存在 ${outsideDirty.length} 个未提交文件(不提交):`);
256
+ // v0.24 §98:文案中性化(与 v0.51.0 arch-merge 同类处理对齐)——「不提交」易被误读为
257
+ // 永久不提交(这些文件可能由其他流程提交),准确表述为「本次不提交 + 范围以白名单为准」。
258
+ console.warn(`[WARN] 白名单之外存在 ${outsideDirty.length} 个未提交文件(本次不提交,提交范围以白名单为准):`);
213
259
  for (const f of outsideDirty.slice(0, 5)) console.warn(` - ${f}`);
214
260
  if (outsideDirty.length > 5) console.warn(` ... 等 ${outsideDirty.length} 个`);
215
261
  }
@@ -232,13 +278,29 @@ export async function run(args = []) {
232
278
  try {
233
279
  // 白名单 git add(只 add 目标目录)
234
280
  for (const p of whitelist) {
235
- const rel = p.replace(projectRoot + '/', '');
281
+ const rel = toRelPathStrict(p, projectRoot);
236
282
  execFileSync('git', ['add', rel], { cwd: projectRoot, stdio: 'pipe' });
237
283
  }
238
284
  // commit(nothing to commit 可接受)
285
+ const relPaths = whitelist.map(p => toRelPathStrict(p, projectRoot));
239
286
  try {
240
- execFileSync('git', ['commit', '-m', commitMsg], { cwd: projectRoot, stdio: 'pipe' });
287
+ // v0.53.0 §109.2 R9:commit 必须带 pathspec。
288
+ // 裸 `git commit` 提交索引中**全部已暂存内容**——索引里若有他人/前序工具留下的
289
+ // staged 文件会被一并卷走。arch-merge 已在 v0.23 §93.3.2 修复同类问题,
290
+ // 而 cmd-publish 只复用了白名单的 **add** 侧、**未复用 commit 侧**(本文件头部
291
+ // 注释自称「复用 arch-merge 协作安全模式」,实际只复用了半套)。
292
+ execFileSync('git', ['commit', '-m', commitMsg, '--', ...relPaths], { cwd: projectRoot, stdio: 'pipe' });
241
293
  console.log(`✅ committed: ${commitMsg}`);
294
+
295
+ // 第二道防线(仿 arch-merge v0.23 §93.3.3):回读实际提交清单与白名单比对。
296
+ // 不阻断(提交已完成),但白名单外文件必须可见。
297
+ const committed = execFileSync('git', ['show', '--name-only', '--format=', 'HEAD'],
298
+ { cwd: projectRoot, encoding: 'utf8', stdio: ['ignore', 'pipe', 'ignore'] }).split('\n').filter(Boolean);
299
+ const wl = relPaths.map(r => r.replace(/\/+$/, ''));
300
+ const unexpected = committed.filter(p => !wl.some(w => p === w || p.startsWith(w + '/')));
301
+ if (unexpected.length > 0) {
302
+ console.warn(`[WARN] 本次 commit 含白名单外的文件(${unexpected.length} 个):${unexpected.slice(0, 5).join(', ')}${unexpected.length > 5 ? ' …' : ''}`);
303
+ }
242
304
  } catch (e) {
243
305
  // git 空提交消息在 stdout(execFileSync 失败时 stderr 常为空 Buffer,需先排除空值)
244
306
  const msg = (e.stderr && e.stderr.toString()) || (e.stdout && e.stdout.toString()) || '';
@@ -40,6 +40,8 @@ const SETTABLE_FIELDS = [
40
40
  'test_matrix_skipped', 'test_matrix_skip_reason',
41
41
  // Tasks gate (v0.22 §85:hotfix/tweak 显式跳过 tasks.md,须附理由)
42
42
  'tasks_skipped', 'tasks_skip_reason',
43
+ // Arch merge gate (v0.53.0 §110.2:arch-merged guard 维度的显式跳过键,须附理由)
44
+ 'arch_merge_skipped', 'arch_merge_skip_reason',
43
45
  ];
44
46
 
45
47
  export async function run(args) {