@xulthekl/team-flow 0.54.0 → 0.56.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 (63) 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 +2 -2
  9. package/CHANGELOG.md +92 -1
  10. package/GEMINI.md +1 -1
  11. package/INSTALL.md +1 -1
  12. package/README.md +1 -1
  13. package/agents/prototype-env-scout.md +4 -4
  14. package/dist/parsing/requirement-blocks.d.ts +26 -0
  15. package/dist/parsing/requirement-blocks.js +33 -5
  16. package/dist/validation/validator.js +8 -1
  17. package/docs/README_en.md +1 -1
  18. package/gemini-extension.json +1 -1
  19. package/hooks/session-start +19 -2
  20. package/llms.txt +1 -1
  21. package/package.json +1 -1
  22. package/plugin.json +1 -1
  23. package/scripts/check-project-config.mjs +84 -0
  24. package/scripts/design-system-clone.mjs +150 -0
  25. package/scripts/design-system-import.mjs +102 -14
  26. package/scripts/gen-primer.mjs +65 -13
  27. package/scripts/guard/checks/tasks-complete.mjs +9 -4
  28. package/scripts/guard/design-token-guard.mjs +136 -117
  29. package/scripts/infer-workflow.mjs +10 -1
  30. package/scripts/lib/arch-merge.mjs +20 -6
  31. package/scripts/lib/arch-parse.mjs +5 -11
  32. package/scripts/lib/ds-inputs.mjs +125 -0
  33. package/scripts/lib/ds-parse.mjs +124 -12
  34. package/scripts/lib/execution-recommendation.mjs +10 -1
  35. package/scripts/lib/glaf4-delegation.mjs +14 -3
  36. package/scripts/lib/hash.mjs +18 -2
  37. package/scripts/lib/md-normalize.mjs +108 -0
  38. package/scripts/lib/prototype-sync.mjs +19 -1
  39. package/scripts/lib/sdd-overlay.mjs +17 -6
  40. package/scripts/lib/slug.mjs +68 -0
  41. package/scripts/lib/solutions-capture.mjs +6 -12
  42. package/scripts/lib/solutions-index-gen.mjs +2 -2
  43. package/scripts/lib/solutions-phases.mjs +32 -0
  44. package/scripts/lib/solutions-promote.mjs +16 -5
  45. package/scripts/lib/spec-merge.mjs +46 -11
  46. package/scripts/lib/state-loader.mjs +4 -1
  47. package/scripts/lib/test-merge.mjs +4 -1
  48. package/scripts/token-extract.mjs +101 -9
  49. package/skills/ce-compound/references/three-tier-index.md +6 -2
  50. package/skills/design-system/SKILL.md +46 -23
  51. package/skills/design-system/references/agents/design-system-architect.md +25 -12
  52. package/skills/design-system/references/creation-flow.md +17 -0
  53. package/skills/design-system/references/creation-modes.md +171 -0
  54. package/skills/design-system/references/showcase-board-b-end.md +50 -36
  55. package/skills/design-system/references/showcase-board-c-end.md +59 -36
  56. package/skills/design-system/references/variant-schema.md +21 -1
  57. package/skills/prototype/SKILL.md +4 -0
  58. package/skills/prototype/references/builder-methodology.md +11 -4
  59. package/skills/prototype/references/layouts.md +10 -0
  60. package/skills/prototype/references/orchestration-flow.md +8 -0
  61. package/skills/workflow-bootstrap/SKILL.md +15 -5
  62. package/src/parsing/requirement-blocks.ts +34 -5
  63. package/src/validation/validator.ts +8 -1
@@ -1,7 +1,15 @@
1
- // ds-parse.mjs — 设计系统文档解析(公共模块,v0.54.0)
1
+ // ds-parse.mjs — 设计系统文档解析(公共模块,v0.55.0)
2
2
  //
3
- // 供 design-token-guard.mjs / gen-primer.mjs 复用(避免多份重复解析)。
3
+ // 供 design-token-guard.mjs / gen-primer.mjs / design-system-import.mjs 复用。
4
4
  // 纯函数,无副作用。
5
+ //
6
+ // v0.55.0(FB-4 修复):全部解析点接入 md-normalize 共享层。
7
+ // 原实现对 Markdown 强调语法脆弱且**失败静默**——`- **contract**: v1` 解析不出值,
8
+ // 被判 unset → guard 降级 + primer 头部错报,无任何提示。根因与同类复发史见
9
+ // md-normalize.mjs 头注。本文件另提供 *Diag 函数,把"段存在但解析不出"变成**显式告警**
10
+ // (纯函数不打印,由调用方决定输出通道)。
11
+
12
+ import { normalizeInline, stripInlineEmphasis, matchKeyValue } from './md-normalize.mjs';
5
13
 
6
14
  // 组件契约表分组规则(§4.1.1):类型 → states 下限(null = 豁免)
7
15
  export const COMPONENT_TYPE_MIN_STATES = { '交互': 3, '轻量': 2, '豁免': null };
@@ -40,16 +48,50 @@ export function sectionBodyRaw(markdown, section) {
40
48
  return body.join('\n');
41
49
  }
42
50
 
51
+ /**
52
+ * Return the raw body of an **H3 subsection** inside a section (v0.55.0 新增).
53
+ *
54
+ * 动机:`layout` 段同时承载 栅格/断点 + 容器骨架 + 页面范式 三块(设计 §8.2.2)。
55
+ * L4b 若要按"页面类型表行数"判定,必须**只在 `### 页面范式` 子块内计数** ——
56
+ * 按整段计数会把容器骨架表/断点表的行也数进去,产生**假 PASS**
57
+ * (实测:现场 b-end.md 的 layout 段已有 2 张表 8 行,8 ≥ 3 直接误判达标)。
58
+ *
59
+ * 切片语义:从 `### <sub>` 起,到下一个 `###` 或 `##` 或 EOF 止;
60
+ * `#### ` 不截断(`/^###?\s+/` 不匹配 `####`——回溯后 `\s+` 无法匹配 `#`)。
61
+ */
62
+ export function subsectionBodyRaw(markdown, section, sub) {
63
+ const sectionRaw = sectionBodyRaw(markdown, section);
64
+ if (sectionRaw === null) return null;
65
+ const lines = sectionRaw.split('\n');
66
+ const startIdx = lines.findIndex(line => {
67
+ const m = /^###\s+(.*)$/.exec(line);
68
+ return m && m[1].trim().toLowerCase().includes(sub);
69
+ });
70
+ if (startIdx === -1) return null;
71
+ const body = [];
72
+ for (let i = startIdx + 1; i < lines.length; i++) {
73
+ if (/^###?\s+/.test(lines[i]) && !/^####/.test(lines[i])) break;
74
+ body.push(lines[i]);
75
+ }
76
+ return body.join('\n');
77
+ }
78
+
43
79
  // Lower-cased section body.
44
80
  export function sectionBody(markdown, section) {
45
81
  const raw = sectionBodyRaw(markdown, section);
46
82
  return raw === null ? null : raw.toLowerCase();
47
83
  }
48
84
 
49
- // Parse a markdown table row into trimmed cells; null when not a data row.
85
+ /**
86
+ * Parse a markdown table row into trimmed cells; null when not a data row.
87
+ *
88
+ * v0.55.0:接入共享归一化层(去反引号 + 剥强调)——与 arch-parse.mjs 的
89
+ * 同型实现统一。此前本函数不剥强调,导致 `| **交互** |` 这类单元格无法匹配类型表,
90
+ * **整行静默漏计**(FB-4 实测复现)。
91
+ */
50
92
  export function parseTableRow(line) {
51
93
  if (!/^\s*\|/.test(line)) return null;
52
- const cells = line.split('|').slice(1, -1).map(c => c.trim());
94
+ const cells = line.split('|').slice(1, -1).map(c => normalizeInline(c.trim()));
53
95
  if (cells.length === 0) return null;
54
96
  if (cells.every(c => /^:?-{2,}:?$/.test(c))) return null;
55
97
  return cells;
@@ -90,35 +132,105 @@ export function parseComponentsTable(markdown) {
90
132
  return rows.length > 0 ? { rows } : null;
91
133
  }
92
134
 
135
+ /** contract 取值的接受形态(v0.55.0 放宽):`v1` / `v2` / … / `legacy`。 */
136
+ const CONTRACT_VALUE_RE = /^(v\d+|legacy)$/i;
137
+
93
138
  // Parse governance section for the `contract` marker (§4.1.3).
94
- // Returns 'v1' | 'legacy' | null.
139
+ // Returns 形如 'v1' | 'legacy' | 'v2' | null(**未知值不再静默归 null**)。
140
+ //
141
+ // v0.55.0:容忍 `**contract**:` / `` `contract`: `` / 全角冒号 / 表格形态;
142
+ // 值域由 `v1|legacy` 放宽为任意 `v\d+`,未知值交由 guard 按最严档处理 + WARN。
95
143
  export function parseContract(markdown) {
96
144
  const body = sectionBodyRaw(markdown, 'governance');
97
145
  if (!body) return null;
98
- const m = body.match(/contract\s*[::]\s*(v1|legacy)/i);
99
- return m ? m[1].toLowerCase() : null;
146
+ // 形态 1:key: value(含各种格式变体)
147
+ const kv = matchKeyValue(body, 'contract');
148
+ if (kv) {
149
+ const v = kv.match(CONTRACT_VALUE_RE);
150
+ if (v) return v[1].toLowerCase();
151
+ }
152
+ // 形态 2:表格 `| contract | v1 |`
153
+ for (const line of body.split('\n')) {
154
+ const cells = parseTableRow(line);
155
+ if (!cells || cells.length < 2) continue;
156
+ if (!/^contract$/i.test(cells[0])) continue;
157
+ const v = cells[1].match(CONTRACT_VALUE_RE);
158
+ if (v) return v[1].toLowerCase();
159
+ }
160
+ return null;
161
+ }
162
+
163
+ /**
164
+ * 诊断:governance 段**存在且提到 contract**,但解析不出取值。
165
+ * 返回警告文本或 null(纯函数——由调用方决定输出到 stderr/stdout)。
166
+ *
167
+ * 动机:FB-4 的核心痛点是**静默**——用户"已知自己写的是 v1 却显示 unset",
168
+ * 靠对照才发现是格式问题。本函数把这种情形变成显式信号。
169
+ */
170
+ export function contractFormatWarning(markdown) {
171
+ const body = sectionBodyRaw(markdown, 'governance');
172
+ if (!body) return null; // 段缺失 → 另一类问题,不在此报
173
+ if (parseContract(markdown)) return null; // 解析成功 → 无警告
174
+ if (!/contract/i.test(body)) return null; // 未提及 → 无警告
175
+ return '检测到 governance 段含 contract 字样但未能解析出取值,请检查格式(应为 `contract: v1`;'
176
+ + '容忍 `**contract**:` / 反引号 / 全角冒号)';
100
177
  }
101
178
 
102
179
  // Parse the A1 identity token block from a token/spec section (for primer 速查).
103
180
  // Looks for `--token: value` pairs anywhere in the markdown.
181
+ // v0.55.0:接入共享层(容忍 `**--accent**:` / 反引号 等写法)。
104
182
  export function parseA1Tokens(markdown) {
105
183
  const names = ['--bg', '--surface', '--fg', '--muted', '--border', '--accent', '--font-display', '--font-body'];
106
184
  const out = {};
107
185
  for (const name of names) {
108
- const re = new RegExp(`${name.replace(/-/g, '\\-')}\\s*[::]\\s*([^;\\n]+)`, 'i');
109
- const m = markdown.match(re);
110
- if (m) out[name] = m[1].trim().replace(/\s+$/, '');
186
+ const v = matchKeyValue(markdown, name);
187
+ if (v) out[name] = v.trim().replace(/\s+$/, '');
111
188
  }
112
189
  return out;
113
190
  }
114
191
 
115
- // Extract the anti-patterns section as a list of bullet/numbered items.
192
+ /**
193
+ * 解析**页面范式声明**(v0.55.0,设计 §8.2.2 / §8.2.5)。
194
+ *
195
+ * 承载点:`layout` 段内的 `### 页面范式` 子块 —— 选 `layout` 是因为它是**必填段**,
196
+ * 单文件模式(转换器产物)也必然含它,不依赖变体合并(原方案新增独立段在单文件模式下
197
+ * 永远不可见且静默)。
198
+ *
199
+ * ### 页面范式
200
+ * **页面范式来源**:引用内置 | 项目自有 | 同 <端>
201
+ * | 页面类型 | 骨架/容器组合 | 参考 |
202
+ *
203
+ * **只在子块内计数表行**(表行数 = 页面类型数):按 layout 段整段计数会把
204
+ * 容器骨架表 / 断点表的行也数进去——现场实测该段已有 2 表 8 行,8 ≥ 3 会**假 PASS**。
205
+ *
206
+ * @returns {{declared: boolean, source: string|null, tableRows: number, subPresent: boolean}}
207
+ */
208
+ export function parsePageArchetype(markdown) {
209
+ const sub = subsectionBodyRaw(markdown, 'layout', '页面范式');
210
+ if (sub === null) return { declared: false, source: null, tableRows: 0, subPresent: false };
211
+ const raw = matchKeyValue(sub, '页面范式来源');
212
+ // 表格行数 = **数据行**(排除表头)。parseTableRow 已滤掉分隔行(`|---|`),
213
+ // 但表头行仍会被匹配 → 子块内**首个表格行视为表头**跳过。
214
+ const tableLines = sub.split('\n').map(parseTableRow).filter(Boolean);
215
+ const tableRows = Math.max(0, tableLines.length - (tableLines.length > 0 ? 1 : 0));
216
+ return {
217
+ declared: Boolean(raw),
218
+ source: raw ? raw.replace(/\s+/g, '') : null,
219
+ tableRows,
220
+ subPresent: true,
221
+ };
222
+ }
223
+
224
+ /**
225
+ * Extract the anti-patterns section as a list of bullet/numbered items.
226
+ * v0.55.0:容忍整行被强调包裹(`**- item**`)——先剥行内强调再匹配列表符。
227
+ */
116
228
  export function parseAntiPatterns(markdown) {
117
229
  const body = sectionBodyRaw(markdown, 'anti-patterns');
118
230
  if (!body) return [];
119
231
  return body
120
232
  .split('\n')
121
- .map(l => l.trim())
233
+ .map(l => stripInlineEmphasis(l.trim()))
122
234
  .filter(l => /^([-*]|\d+[.、)])\s+\S/.test(l))
123
235
  .map(l => l.replace(/^([-*]|\d+[.、)])\s+/, ''));
124
236
  }
@@ -8,6 +8,7 @@ import { computeArtifactsHash, computeContractHash, hashObject, stableJson } fro
8
8
  import { getOverlayPaths } from './sdd-overlay.mjs';
9
9
  import { readState } from './state-loader.mjs';
10
10
  import { EXECUTION_MODES } from './execution-plan.mjs';
11
+ import { parseTaskLine } from './md-normalize.mjs';
11
12
 
12
13
  // v2.1 §6.6(二轮评审 HIGH):recommend 链补全
13
14
  // - GLAF4 change 的 recommend 恒为"推荐 inline + available_modes 含 glaf4-delegation"
@@ -151,10 +152,18 @@ export function validateRecommendationReceipt(changeDir, receipt, waves = [], ex
151
152
  return failures;
152
153
  }
153
154
 
155
+ /**
156
+ * tasks.md 中已记录的 task 数(DP-4 推荐的事实输入之一)。
157
+ *
158
+ * v0.55.0 §8.4.3 横展(FB-4):识别走共享原语 `parseTaskLine`——原正则要求
159
+ * `- [x] `(`]` 后必须跟空格)且不容缩进,`*` bullet / 缩进行 / `- [x]正文` 全部漏计。
160
+ * **计数语义**(约束③:会改变 inline / batch-inline / sdd 的推荐结果)。
161
+ */
154
162
  function countDocumentedTasks(changeDir) {
155
163
  const tasksPath = join(changeDir, 'tasks.md');
156
164
  if (!existsSync(tasksPath)) return null;
157
- return (readFileSync(tasksPath, 'utf8').match(/^- \[[ xX]\] /gm) || []).length;
165
+ return readFileSync(tasksPath, 'utf8').split('\n')
166
+ .map(line => parseTaskLine(line)).filter(Boolean).length;
158
167
  }
159
168
 
160
169
  function result(availableModes, mode, reasons, facts) {
@@ -33,6 +33,7 @@ import { getOverlayPaths } from './sdd-overlay.mjs';
33
33
  import { detectGlaf4Delegation, detectTechStack, writeGlaf4DevConfig } from './conventions-generator.mjs';
34
34
  import { detectWorkspaceRoot, getGitRoot, resolveCodeRepos } from './git-utils.mjs';
35
35
  import { loadConfig } from './config-loader.mjs';
36
+ import { parseTaskLine, stripInlineEmphasis } from './md-normalize.mjs';
36
37
 
37
38
  // DP-4 委托模式合法枚举(对应 glaf4-dev 七模式中可委托 change 的六种)
38
39
  export const GLAF4_DELEGATION_MODES = [
@@ -372,8 +373,18 @@ export function recordPartialWrites(changeDir, runDir) {
372
373
  return { ok: true, partialWrites, count: partialWrites.length };
373
374
  }
374
375
 
376
+ /**
377
+ * `· targets:` 注解判定(`·` 是结构标记,不放宽;容忍 `**targets**` 加粗与全角冒号)。
378
+ */
379
+ function hasTargetsAnnotation(text) {
380
+ return /·\s*targets\s*[::]/.test(stripInlineEmphasis(text));
381
+ }
382
+
375
383
  /**
376
384
  * task 完成度对账(审计输出):tasks.md targets 注解统计
385
+ *
386
+ * v0.55.0 §8.4.3 横展(FB-4):识别走共享原语 `parseTaskLine`——容忍 `*` bullet、
387
+ * 制表符分隔与缩进(原本 `^- \[` 不容缩进)。**计数语义**(约束③:派生数字会变)。
377
388
  * @param {string} changeDir change 目录
378
389
  * @returns {object} { ok, totalTasks, doneTasks, annotatedTasks, missingAnnotation }
379
390
  */
@@ -383,10 +394,10 @@ export function verifyTasks(changeDir) {
383
394
  return { ok: false, totalTasks: 0, doneTasks: 0, annotatedTasks: 0, missingAnnotation: 0 };
384
395
  }
385
396
  const content = readFileSync(tasksPath, 'utf-8');
386
- const taskLines = content.split('\n').filter(l => /^- \[[ xX]\] /.test(l));
397
+ const taskLines = content.split('\n').map(line => parseTaskLine(line)).filter(Boolean);
387
398
  const totalTasks = taskLines.length;
388
- const doneTasks = taskLines.filter(l => /^- \[[xX]\] /.test(l)).length;
389
- const annotatedTasks = taskLines.filter(l => /·\s*targets:\s*/.test(l)).length;
399
+ const doneTasks = taskLines.filter(task => task.checked).length;
400
+ const annotatedTasks = taskLines.filter(task => hasTargetsAnnotation(task.text)).length;
390
401
 
391
402
  return {
392
403
  ok: doneTasks === totalTasks && totalTasks > 0,
@@ -3,6 +3,7 @@ import crypto from 'node:crypto';
3
3
  import fs from 'node:fs';
4
4
  import path from 'node:path';
5
5
  import { findCanonicalSpecFiles } from './spec-paths.mjs';
6
+ import { parseTaskLine } from './md-normalize.mjs';
6
7
 
7
8
  /**
8
9
  * Normalize tasks.md checkbox state: `- [x]` / `- [X]` → `- [ ]`.
@@ -12,9 +13,20 @@ import { findCanonicalSpecFiles } from './spec-paths.mjs';
12
13
  * 勾选状态属执行进度、不属规划内容 —— 归一化后入 hash,使"执行期勾选"不再使 plan
13
14
  * 过期(否则勾选后 recordReview 因 plan stale 抛错,形成 closing 死锁)。
14
15
  * 任务文本、数量与结构的变更仍会改变 hash。
16
+ *
17
+ * v0.55.0 §8.4.3 横展(FB-4):识别改用共享原语 `parseTaskLine`(容忍 `*` bullet /
18
+ * 缩进 / 制表符等纯格式变体),但**语义保持"替换式"**——只把复选框标记改回 `[ ]`,
19
+ * 行的其余字符(缩进、bullet 符号、分隔空白、正文、行尾 `\r`)**逐字节保留**,
20
+ * 因此该函数**不是**计数函数、不重建行。
15
21
  */
16
22
  export function normalizeCheckboxes(content) {
17
- return content.replace(/^([ \t]*- )\[[xX]\]/gm, '$1[ ]');
23
+ return content.split('\n').map((line) => {
24
+ const task = parseTaskLine(line);
25
+ if (!task || !task.checked) return line;
26
+ const at = line.indexOf('[');
27
+ if (at === -1) return line;
28
+ return `${line.slice(0, at + 1)} ${line.slice(at + 2)}`;
29
+ }).join('\n');
18
30
  }
19
31
 
20
32
  /**
@@ -130,9 +142,13 @@ export function isContractFresh(changeDir, stateLoader) {
130
142
  return storedHash === currentHash;
131
143
  }
132
144
 
145
+ // v0.55.0 §8.4.3 横展(FB-4):容忍全角冒号(`artifacts_hash:sha256:…`)。
146
+ // 这是 `isContractFresh` 的判据——解析不出即判 stale,静默且后果重(closing 死锁)。
147
+ // 仅放宽冒号形态(与 `md-normalize.matchKeyValue` 的全角冒号约定一致),
148
+ // 不引入缩进等结构语义容忍(约束①)。
133
149
  function extractYamlField(content, field) {
134
150
  for (const line of content.split('\n')) {
135
- const match = line.match(new RegExp(`^${field}:\\s*(.*)`));
151
+ const match = line.match(new RegExp(`^${field}[::]\\s*(.*)`));
136
152
  if (match) {
137
153
  const val = match[1].trim();
138
154
  return val === 'null' || val === '' ? null : val;
@@ -0,0 +1,108 @@
1
+ // md-normalize.mjs — 手写 Markdown 的解析容错共享层(v0.55.0)
2
+ //
3
+ // ── 为什么存在(根因,workflow-feedback 2026-09-11 FB-4)──────────────────────
4
+ // 解析层默认"用户会按规范格式书写",对最常见的 Markdown 强调语法(`**x**`、
5
+ // 反引号、表格)脆弱,且**失败静默**。实测:`- **contract**: v1` 与 `` `contract`: v1 ``
6
+ // 都解析不出值,被判 unset → guard 降级、primer 头部错报,全程无任何提示。
7
+ //
8
+ // 该缺陷是**复发型**,不是孤立疏漏——同型 bug 已修过两次,且两次都未横展:
9
+ // - arch-parse.mjs:58-66(v0.53.0 §105.2.2):表格单元格缺 stripEmphasis
10
+ // → 新增端点 `update/status` / `reset/secret` 整行漏提取
11
+ // - test-merge.mjs:79-89(feedback 2026-08-05):正则未容忍 `\*{0,2}`
12
+ // → 手写加粗致 Total cases 计数归零
13
+ //
14
+ // 本模块是**全仓库唯一的 Markdown 归一化约定**(单一真相源)。新解析点一律复用,
15
+ // 不再各写各的正则——避免第三次复发。
16
+ //
17
+ // ── 三条安全约束(设计 §8.4.3,D-16「全修」决策的配套)───────────────────────
18
+ // ① 宽容 ≠ 放宽语义:只容忍**纯格式变体**(`**`/反引号/全角冒号/冒号前空格),
19
+ // **不改变结构语义**。例:YAML 缩进是语义,不得在此层容忍。
20
+ // ② 宽容 + 校验配对,但缺字段一律 WARN(不报错)——避免打断被测试断言的既有契约。
21
+ // ③ 派生数字变更须先出对照表(计数型解析点的宽容会改变判定,见 parseTaskLine 注)。
22
+
23
+ /**
24
+ * 强调标记剥离:`**x**` / `__x__` / `*x*` / `_x_` → `x`(**首尾成对**才剥离;长的优先)。
25
+ *
26
+ * 实现自 arch-parse.mjs(v0.53.0 §105.2.2),此处提为共享层。
27
+ * 适用于**整段/整格**的首尾包裹场景(如 `**\`/path\`**`)。
28
+ */
29
+ export function stripEmphasis(s) {
30
+ let out = s;
31
+ for (const [open, close] of [['**', '**'], ['__', '__'], ['*', '*'], ['_', '_']]) {
32
+ if (out.length > open.length + close.length && out.startsWith(open) && out.endsWith(close)) {
33
+ out = out.slice(open.length, -close.length).trim();
34
+ }
35
+ }
36
+ return out;
37
+ }
38
+
39
+ /**
40
+ * 行内强调剥离(**全局**,非首尾):`- **contract**: v1` → `- contract: v1`。
41
+ *
42
+ * 与 {@link stripEmphasis} 的分工:后者只处理"整段被一对标记包裹",本函数处理
43
+ * "一行内嵌着一对标记"——这是 key/value 行的常见写法,也是 FB-4 的实际触发形态。
44
+ *
45
+ * **只剥离双符号对(`**` / `__`)**,单符号(`*` / `_`)刻意不动:
46
+ * `*` 可能是列表符、`_` 可能是 snake_case 标识符——剥离它们是"改语义"而非"容格式",
47
+ * 违反安全约束 ①。
48
+ */
49
+ export function stripInlineEmphasis(text) {
50
+ if (typeof text !== 'string') return '';
51
+ return text.replace(/\*\*(.+?)\*\*/g, '$1').replace(/__(.+?)__/g, '$1');
52
+ }
53
+
54
+ /**
55
+ * 行内归一化:去反引号 + 剥强调 + trim。用于单元格与 key/value 两侧。
56
+ */
57
+ export function normalizeInline(s) {
58
+ if (typeof s !== 'string') return '';
59
+ return stripEmphasis(stripInlineEmphasis(s.replace(/`/g, '')).trim()).trim();
60
+ }
61
+
62
+ /**
63
+ * 宽容匹配 `key: value`,返回首个捕获值或 null。
64
+ *
65
+ * 容忍的写法(FB-4 的 5 种格式变体):
66
+ * a. `**key**: value` —— 加粗(**本函数的主要动机**)
67
+ * b. `key:value` —— 全角冒号
68
+ * c. `` `key`: value `` —— 反引号
69
+ * d. `key : value` —— 冒号前空格
70
+ * (e. 表格形态 `| key | value |` 不在此处理——用 ds-parse 的表格路径)
71
+ *
72
+ * @param {string} text 待搜索文本(整段或整行均可)
73
+ * @param {string} key 键名(可含 `-`,如 `--bg`)
74
+ */
75
+ export function matchKeyValue(text, key) {
76
+ if (typeof text !== 'string' || !key) return null;
77
+ const normalized = stripInlineEmphasis(text).replace(/`/g, '');
78
+ const escaped = key.replace(/[-/\\^$*+?.()|[\]{}]/g, '\\$&');
79
+ const m = normalized.match(new RegExp(`${escaped}\\s*[::]\\s*([^;\\n|]+)`, 'i'));
80
+ return m ? m[1].trim() : null;
81
+ }
82
+
83
+ /**
84
+ * 任务行解析**原语**(复选框):`- [ ] x` / `* [x] x` / 缩进子项 → `{ indent, checked, text }`;
85
+ * 非任务行返回 null。
86
+ *
87
+ * **本函数不做语义判定** —— 全仓库 5 处复选框解析语义并不同(原方案误判为"同语义"):
88
+ * - `hash.mjs` 的 `normalizeCheckboxes` 是**替换式归一化**(保证勾选不改变 artifacts_hash)
89
+ * - `tasks-complete.mjs` / `glaf4-delegation.mjs` / `execution-recommendation.mjs` /
90
+ * `infer-workflow.mjs` 是**计数/存在性判定**
91
+ * 故只共享"识别",语义由调用方决定。
92
+ *
93
+ * **安全约束 ③ 提醒**:把本函数接入计数型解析点会**改变派生数字** →
94
+ * `infer-workflow.mjs` 的 `taskCount` 直接决定 hotfix(≤2) / tweak(≤4) / full 档位。
95
+ * 接入前须先出**新旧计数对照表**(构造的格式变体集,见 tests/lib/checkbox-consistency.test.mjs)。
96
+ *
97
+ * **CRLF 容错(`\r?$`,v0.55.0 接入期实测补充)**:接入前,各处用逐行正则计数时
98
+ * `\r` 被当作普通字符(`[ \t]*- \[ \]` 无 `$` 锚)→ CRLF 的 tasks.md 正常计数;
99
+ * 若本函数不容忍行尾 `\r`,接入后这些行会**全部解析失败** → 计数归零/guard 静默放行
100
+ * (比现状更差)。故此处容忍行尾 `\r`(纯格式变体),返回的 `text` 不含 `\r`。
101
+ * 调用方若需**逐字节保留原行**(如 hash 归一化),请自行持有原行、不要用本返回值重建。
102
+ */
103
+ export function parseTaskLine(line) {
104
+ if (typeof line !== 'string') return null;
105
+ const m = /^([ \t]*)([-*])\s+\[([ xX])\]\s?(.*)\r?$/.exec(line);
106
+ if (!m) return null;
107
+ return { indent: m[1].length, checked: m[3].toLowerCase() === 'x', text: m[4] };
108
+ }
@@ -19,6 +19,19 @@ import { readFileSync, writeFileSync, existsSync, cpSync, mkdirSync } from 'node
19
19
  import { join, basename, relative, sep, resolve } from 'node:path';
20
20
  import { pathToFileURL } from 'node:url';
21
21
 
22
+ /**
23
+ * UX 增量标题(v0.55.0 §8.4.3 横展 FB-4):在 `## UX 增量` 基础上容忍 `**加粗**`
24
+ * 与行尾空白。**不锚行首是既有行为**——`### UX 增量` / `#### **UX 增量**` 修复前
25
+ * 也能命中且正文提取正确,故此处不收紧(收紧会让既有契约文本失同步)。
26
+ */
27
+ const UX_HEADING_RE = /##[ \t]+\**[ \t]*(?:UX 增量|Prototype Changes|UX Delta)\**[ \t]*\r?\n([\s\S]*?)(?=\n## |\n---|\n$)/;
28
+
29
+ /**
30
+ * 形似 UX 增量标题但上面未命中(实测为 H1 `# UX 增量`,正则要求两个 `#` 起)——
31
+ * 用于给出不静默的 WARN,避免"整段 UX 增量丢失且只说 nothing to sync"。
32
+ */
33
+ const UX_HEADING_LIKE_RE = /^#{1,6}[ \t]*\**[ \t]*(?:UX 增量|Prototype Changes|UX Delta)/m;
34
+
22
35
  /**
23
36
  * 解析 CLI 参数数组为结构化对象
24
37
  * @param {string[]} argv - 原始参数数组,如 ['changes/my-change/', '--source', 'delta.md']
@@ -110,10 +123,15 @@ export function run(args = {}) {
110
123
  if (existsSync(contractPath)) {
111
124
  const contract = readFileSync(contractPath, 'utf-8');
112
125
  // 提取 UX 相关章节(## UX 增量 或 ## Prototype Changes)
113
- const uxMatch = contract.match(/## (?:UX 增量|Prototype Changes|UX Delta)\n([\s\S]*?)(?=\n## |\n---|\n$)/);
126
+ // v0.55.0 §8.4.3 横展(FB-4):修复前 `## **UX 增量**`(加粗)静默降级成
127
+ // "No UX delta found, nothing to sync",整段 UX 增量丢失且无任何提示。
128
+ const uxMatch = contract.match(UX_HEADING_RE);
114
129
  if (uxMatch) {
115
130
  uxDeltaContent = uxMatch[1].trim();
116
131
  uxDeltaSource = contractPath;
132
+ } else if (UX_HEADING_LIKE_RE.test(contract)) {
133
+ // 含关键词但正则未命中(如 H1 `# UX 增量`)→ 显式提示,不再静默
134
+ console.warn(' [WARN] prototype-sync: execution-contract.md 含 UX 增量字样但标题形态不匹配(期望 `## UX 增量`),本次未同步');
117
135
  }
118
136
  }
119
137
  }
@@ -4,7 +4,9 @@ import {
4
4
  } from 'node:fs';
5
5
  import { dirname, join } from 'node:path';
6
6
  import { computeArtifactsHash, normalizeCheckboxes } from './hash.mjs';
7
+ import { parseTaskLine } from './md-normalize.mjs';
7
8
  import { readState } from './state-loader.mjs';
9
+ import { safeName } from './slug.mjs';
8
10
 
9
11
  export const HANDOFF_TYPES = new Set(['prototype', 'research', 'experiment']);
10
12
  export const HANDOFF_DECISIONS = new Set(['accept', 'reject', 'defer']);
@@ -28,10 +30,21 @@ export function getOverlayPaths(changeDir) {
28
30
  export function computeTaskHash(changeDir, taskId) {
29
31
  const tasks = readFileSync(join(changeDir, 'tasks.md'), 'utf8');
30
32
  const escaped = taskId.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
31
- const match = tasks.match(new RegExp(`^- \\[([ xX])\\] ${escaped}\\s+.+$`, 'm'));
32
- if (!match) throw new Error(`Task '${taskId}' was not found in tasks.md`);
33
+ // 任务正文须以 id 开头且后面还有内容(与原 `^...\\s+.+$` 同判据)
34
+ const idRe = new RegExp(`^${escaped}\\s+[\\s\\S]`);
35
+ // v0.55.0 §8.4.3 横展(FB-4):行定位走共享原语 `parseTaskLine`(容忍 `*` bullet /
36
+ // 制表符分隔 / 缩进等纯格式变体)。**本函数保持"找不到即抛错"的硬失败语义**——
37
+ // 定位失败会让 checkpoint 无法落盘,静默放行比抛错危险。
38
+ // hash 仍取**原始行**(仅去行尾 `\\r`,与旧 `match[0]` 一致)→ 既有 checkpoint 不失效。
39
+ const matched = tasks.split('\n')
40
+ .map(line => line.replace(/\r$/, ''))
41
+ .find((line) => {
42
+ const task = parseTaskLine(line);
43
+ return task ? idRe.test(task.text) : false;
44
+ });
45
+ if (matched === undefined) throw new Error(`Task '${taskId}' was not found in tasks.md`);
33
46
  // v0.49.0 §83.3.1:勾选状态归一化 —— 勾选是执行进度,不应使 checkpoint 变 stale
34
- return `sha256:${createHash('sha256').update(normalizeCheckboxes(match[0])).digest('hex')}`;
47
+ return `sha256:${createHash('sha256').update(normalizeCheckboxes(matched)).digest('hex')}`;
35
48
  }
36
49
 
37
50
  export function saveCheckpoint(changeDir, input) {
@@ -258,6 +271,4 @@ function requireText(value, field) {
258
271
  if (typeof value !== 'string' || !value.trim()) throw new Error(`${field} is required`);
259
272
  }
260
273
 
261
- function safeName(value) {
262
- return String(value).replace(/[^A-Za-z0-9._-]/g, '_');
263
- }
274
+ // safeName 已收敛到 slug.mjs(保留 CJK;`. _ -` 语义不变,如 taskId `1.1` 原样保留)
@@ -0,0 +1,68 @@
1
+ /**
2
+ * slug — 文件名 / 路径段安全化的唯一真相源(2026-09-12,emp-auth 复利膨胀调查)
3
+ *
4
+ * 背景:同一插件内曾存在 3 套文件名安全化规则,其中两套**未保留 CJK 且逐字符替换**,
5
+ * 导致中文标题退化为纯短横线序列。实证(emp-auth v1 迭代):
6
+ * - `docs/test-ledger/baselines/--------------------.md`(原名「执行结果(三仓实测,命令 + 实际输出)」)
7
+ * - 同目录 17 个文件名受影响,其中多份已无法从文件名辨识内容
8
+ * - 叠加第二个因素:baselines 的 moduleName 取自 test-matrix 三级标题原文(每轮由 LLM
9
+ * 自由命名,如 `DemoHome 当前应用(demo-ui,medium)` vs `DemoHome(demo-ui,medium,revision 3)`)
10
+ * ⇒ 文件名每轮变化 ⇒ `existsSync` 不命中 ⇒ **新建而非增量合并**(demohome 三次 merge
11
+ * 产出 3 个文件,git 记录全为 A)
12
+ *
13
+ * 约定:
14
+ * - 字符集统一为 **ASCII 字母数字 + CJK 统一表意文字(U+4E00–U+9FFF)**;其余字符折叠为分隔符
15
+ * - 一律使用 `+` 量词折叠连续非法字符——**禁止逐字符替换**(一个中文 = 一个分隔符是退化根因)
16
+ * - 两类用途语义不同,**不要互串**:
17
+ * · slugify() 标题 → 文件名(小写、`.`/`_`/`-` 均折叠为分隔符、可截断)
18
+ * · safeName() ID → 路径段(保留大小写与 `.` `_` `-`,不截断)——ID 自身以这些字符作分隔,
19
+ * 例如 taskId `1.1` MUST 保持为 `1.1`(折叠成 `1_1` 会失配既有 checkpoint 文件)
20
+ *
21
+ * @module slug
22
+ */
23
+
24
+ /**
25
+ * CJK 统一表意文字区间字符类(U+4E00–U+9FFF)。
26
+ *
27
+ * 用 `\u` 转义写法而非字面「一-鿿」:字面写法拼进字符类时,**前一个字符若为 `-`
28
+ * 会被解析成范围**——`[^..._-一-鿿]` 里的 `_-一` 是 U+005F–U+4E00 范围,
29
+ * 结果 U+4E00 以上的汉字(如「任务」U+4EFB)仍被替换。转义写法无此风险。
30
+ */
31
+ export const CJK_CLASS = '\\u4e00-\\u9fff';
32
+
33
+ /** 转义正则元字符(separator 由调用方传入,可能是 `-` / `_` 等) */
34
+ function escapeRe(s) {
35
+ return s.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
36
+ }
37
+
38
+ /**
39
+ * 标题 → 文件名 slug。
40
+ *
41
+ * 保留 ASCII 小写字母数字与 CJK;其余连续字符折叠为一个 separator;去掉首尾 separator。
42
+ *
43
+ * @param {string} value 原始文本(通常是条目标题 / 模块名)
44
+ * @param {{separator?: string, maxLength?: number}} [options]
45
+ * separator 默认 `-`;maxLength > 0 时才截断(默认不截断,由调用方按场景显式传入)
46
+ * @returns {string}
47
+ */
48
+ export function slugify(value, { separator = '-', maxLength = 0 } = {}) {
49
+ const esc = escapeRe(separator);
50
+ let s = String(value ?? '').toLowerCase();
51
+ s = s.replace(new RegExp(`[^a-z0-9${CJK_CLASS}]+`, 'g'), separator);
52
+ s = s.replace(new RegExp(`^(?:${esc})+|(?:${esc})+$`, 'g'), '');
53
+ return maxLength > 0 ? s.slice(0, maxLength) : s;
54
+ }
55
+
56
+ /**
57
+ * ID → 路径段安全名。
58
+ *
59
+ * 保留 ASCII 字母数字(含大小写)与 `.` `_` `-`,以及 CJK;其余连续字符折叠为一个 `_`。
60
+ * 不截断、不改大小写——ID 的既有语义(如 `1.1`、`W4-1`)MUST 原样保留。
61
+ *
62
+ * @param {string} value
63
+ * @returns {string}
64
+ */
65
+ export function safeName(value) {
66
+ // `-` MUST 置于字符类末尾(否则与相邻字符构成范围)
67
+ return String(value ?? '').replace(new RegExp(`[^A-Za-z0-9._${CJK_CLASS}-]+`, 'g'), '_');
68
+ }
@@ -16,17 +16,11 @@ import { readFileSync, writeFileSync, existsSync, mkdirSync, appendFileSync } fr
16
16
  import { join } from 'node:path';
17
17
  import { pathToFileURL } from 'node:url';
18
18
  import { SEVERITY_VALUES, isSeverity } from './severity.mjs';
19
+ import { SOLUTION_PHASES } from './solutions-phases.mjs';
20
+ import { slugify } from './slug.mjs';
19
21
 
20
- const PHASES = ['prd', 'plan', 'architecture', 'prototype', 'spec', 'build', 'review', 'cross-phase'];
21
22
  const MAX_INDEX_LINES = 150;
22
-
23
- function slugify(text) {
24
- return text
25
- .toLowerCase()
26
- .replace(/[^a-z0-9一-鿿]+/g, '-')
27
- .replace(/^-|-$/g, '')
28
- .slice(0, 40);
29
- }
23
+ const SLUG_MAX_LENGTH = 40;
30
24
 
31
25
  export function run(args = {}) {
32
26
  const phase = args.phase || 'cross-phase';
@@ -42,8 +36,8 @@ export function run(args = {}) {
42
36
 
43
37
  // 注:process.exit 后补 return——真实 CLI 下 exit 即终止;测试环境 mock exit 时
44
38
  // 不得继续执行(否则校验失败仍会写入文件,v0.23 §91.3.4 横展)。
45
- if (!PHASES.includes(phase)) {
46
- console.error(`Invalid phase: ${phase}. Valid: ${PHASES.join(', ')}`);
39
+ if (!SOLUTION_PHASES.includes(phase)) {
40
+ console.error(`Invalid phase: ${phase}. Valid: ${SOLUTION_PHASES.join(', ')}`);
47
41
  process.exit(1);
48
42
  return;
49
43
  }
@@ -61,7 +55,7 @@ export function run(args = {}) {
61
55
 
62
56
  // 生成文件名
63
57
  const date = new Date().toISOString().slice(0, 10);
64
- const slug = slugify(summary);
58
+ const slug = slugify(summary, { maxLength: SLUG_MAX_LENGTH });
65
59
  const fileName = `${date}-${slug}.md`;
66
60
  const filePath = join(phaseDir, fileName);
67
61
 
@@ -17,8 +17,8 @@ import { readFileSync, writeFileSync, readdirSync, statSync, existsSync } from '
17
17
  import { join } from 'node:path';
18
18
  import { pathToFileURL } from 'node:url';
19
19
  import { severityRank } from './severity.mjs';
20
+ import { SOLUTION_PHASES } from './solutions-phases.mjs';
20
21
 
21
- const PHASES = ['prd', 'plan', 'prototype', 'spec', 'build', 'review', 'cross-phase'];
22
22
  const MAX_INDEX_LINES = 150;
23
23
 
24
24
  function parseFrontmatter(content) {
@@ -54,7 +54,7 @@ export function run(args = {}) {
54
54
 
55
55
  const entries = [];
56
56
 
57
- for (const phase of PHASES) {
57
+ for (const phase of SOLUTION_PHASES) {
58
58
  const phaseDir = join(dir, phase);
59
59
  if (!existsSync(phaseDir) || !statSync(phaseDir).isDirectory()) continue;
60
60
 
@@ -0,0 +1,32 @@
1
+ /**
2
+ * solutions-phases — 复利条目 phase 目录的唯一真相源(2026-09-12)
3
+ *
4
+ * 背景:PHASES 枚举曾在 3 处各自定义且互相矛盾——
5
+ * - `solutions-capture.mjs` 8 项(含 `architecture`,v0.36.3 加入)
6
+ * - `solutions-index-gen.mjs` 7 项(**缺 `architecture`**)
7
+ * - 文档树(three-tier-index.md / AGENTS.md)写 `requirement`,而 frontmatter 示例写 `prd`
8
+ *
9
+ * 后果(实证,emp-auth):`tf solutions capture --phase architecture` 写入的条目在
10
+ * `tf solutions index-gen` 重建后会**从 INDEX.md 消失**——文件仍在磁盘,但对所有
11
+ * 注入 / 检索通道不可见。任何新增 phase 只改本文件。
12
+ *
13
+ * 注意:`docs/solutions/` 下另有 ce-compound 通道写入的 **category 目录**
14
+ * (`workflow-issues/` 等,见 skills/ce-compound/references/yaml-schema.md),
15
+ * 那套词表与本 phase 词表**互不兼容**,本模块不覆盖。
16
+ *
17
+ * @module solutions-phases
18
+ */
19
+
20
+ /** 合法 phase 取值(同时是 docs/solutions/ 下的子目录名) */
21
+ export const SOLUTION_PHASES = Object.freeze([
22
+ 'prd', 'plan', 'architecture', 'prototype', 'spec', 'build', 'review', 'cross-phase',
23
+ ]);
24
+
25
+ /**
26
+ * 是否为合法 phase 取值。
27
+ * @param {string} phase
28
+ * @returns {boolean}
29
+ */
30
+ export function isSolutionPhase(phase) {
31
+ return SOLUTION_PHASES.includes(phase);
32
+ }