sillyspec 3.32.8 → 3.32.10

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/skills/sillyspec-brainstorm/SKILL.md +2 -2
  2. package/.claude/skills/sillyspec-execute/SKILL.md +2 -2
  3. package/.claude/skills/sillyspec-flow/SKILL.md +1 -1
  4. package/.claude/skills/sillyspec-plan/SKILL.md +2 -2
  5. package/AGENTS.md +5 -4
  6. package/assets/command-cards/flow.md +9 -4
  7. package/package.json +1 -1
  8. package/src/change-list.js +13 -0
  9. package/src/commit-guard.js +70 -2
  10. package/src/commit-suggest.js +45 -4
  11. package/src/config-schema.js +14 -0
  12. package/src/cross-repo-reconcile.js +54 -1
  13. package/src/decision-distill.js +77 -21
  14. package/src/decision-renumber.js +157 -0
  15. package/src/deleted-ledger.js +75 -0
  16. package/src/design-facts.js +5 -3
  17. package/src/dispatch/backends/local-agent.js +1 -1
  18. package/src/dispatch/backends/sillyhub-mcp.js +1 -1
  19. package/src/docs-check.js +30 -15
  20. package/src/docs-debt.js +3 -2
  21. package/src/doctor-diagnostics.js +3 -1
  22. package/src/flow-draft.js +24 -2
  23. package/src/flow-parity.js +32 -2
  24. package/src/flow.js +427 -69
  25. package/src/fr-index.js +15 -1
  26. package/src/hooks/worktree-guard.js +3 -3
  27. package/src/index.js +22 -10
  28. package/src/knowledge-digest.js +23 -6
  29. package/src/knowledge-graph.js +92 -14
  30. package/src/knowledge-match.js +34 -9
  31. package/src/knowledge-stats.js +12 -13
  32. package/src/knowledge-vector.js +98 -7
  33. package/src/machine-interface.js +26 -0
  34. package/src/progress/change-registry.js +7 -0
  35. package/src/progress.js +67 -0
  36. package/src/quicklog.js +6 -1
  37. package/src/redomain.js +27 -0
  38. package/src/route-hindsight.js +34 -2
  39. package/src/run/archive-distill.js +3 -0
  40. package/src/run/command.js +40 -44
  41. package/src/run/complete-handlers.js +8 -3
  42. package/src/run/complete.js +129 -63
  43. package/src/run/gates.js +94 -90
  44. package/src/run/prompt.js +13 -3
  45. package/src/run/shared.js +19 -0
  46. package/src/run/stage.js +1 -1
  47. package/src/scope-audit.js +2 -2
  48. package/src/sentinel-assertions.js +43 -1
  49. package/src/stage-contract.js +26 -3
  50. package/src/stage-review.js +44 -11
  51. package/src/stage-templates.js +2 -0
  52. package/src/stages/brainstorm.js +24 -12
  53. package/src/stages/execute.js +1 -1
  54. package/src/stages/explore.js +3 -5
  55. package/src/stages/knowledge.js +56 -5
  56. package/src/sync.js +98 -2
  57. package/src/task-tick.js +12 -0
  58. package/src/timeline.js +60 -19
  59. package/src/verify-postcheck.js +83 -6
  60. package/src/verify-probes.js +61 -18
  61. package/templates/agents-instruction.md +5 -4
  62. package/templates/prompts/testcase-design.md +5 -0
  63. package/src/stages/brainstorm-auto.js +0 -286
package/src/timeline.js CHANGED
@@ -24,6 +24,33 @@ export function parseTaskLines(tasksMd) {
24
24
  return out;
25
25
  }
26
26
 
27
+ /** task id 归一:task-2 → task-02(与 task-tick normalizeTaskId 同口径,非 task-N 形态返 null)。 */
28
+ function normTaskId(s) {
29
+ const m = /^task-(\d+)$/.exec(String(s || '').trim());
30
+ return m ? `task-${String(Number(m[1])).padStart(2, '0')}` : null;
31
+ }
32
+
33
+ /**
34
+ * 侧车勾选留痕解析(2026-10-14-timeline-ticktime-anchor-scope):task-ticks.jsonl 行集
35
+ * ({ts,task,source})→ Array<{ts,task}>。坏行跳过;id 归一;同任务取最早(时钟回拨/重复
36
+ * 留痕容错——勾选时刻语义=首次翻格时刻)。文件缺失/不可读由调用方 catch 成空数组。
37
+ */
38
+ export function parseTickLog(raw) {
39
+ const byTask = new Map();
40
+ for (const line of String(raw || '').split(/\r?\n/)) {
41
+ if (!line.trim()) continue;
42
+ try {
43
+ const o = JSON.parse(line);
44
+ const id = normTaskId(o && o.task);
45
+ const ts = Number(o && o.ts);
46
+ if (!id || !Number.isFinite(ts)) continue;
47
+ const p = byTask.get(id);
48
+ if (!p || ts < p.ts) byTask.set(id, { ts, task: id });
49
+ } catch { /* 坏行跳过 */ }
50
+ }
51
+ return [...byTask.values()];
52
+ }
53
+
27
54
  /**
28
55
  * 翻格顺序推断(FR-04):task-done(stage='tasks')事件序列 → 每任务序号的勾选时刻。
29
56
  * 计数链衔接(from === 游标)才赋值;**中段**断裂(from ≠ 游标)标 broken 并停止赋值。
@@ -60,9 +87,12 @@ function tokensOf(text) {
60
87
  /**
61
88
  * 提交锚解析:事件流 kind=commit 的短 hash → gitLook 解析出主题/时间/token。
62
89
  * @param {Function} gitLook (hash) => null | {hash, dateISO, message}(注入面,接线层包 gitQuiet)
63
- * @returns {Array<{hash, ts, subject, tokens, resolvable}>} ts 取事件观测时刻(git 日期仅存证)。
90
+ * @param {string|null} change 变更名(2026-10-14-timeline-ticktime-anchor-scope):在场时逐 anchor
91
+ * 算 sameChange=提交全消息(%B 含正文)includes(change)——锚定面据此收敛,杀跨变更 token 撞车
92
+ * (litellm-retire 实证:他变更提交消息含 task-01/04/05 被误锚);缺省恒 true 向后兼容。
93
+ * @returns {Array<{hash, ts, subject, tokens, resolvable, sameChange}>} ts 取事件观测时刻(git 日期仅存证)。
64
94
  */
65
- export function resolveCommitAnchors(events, gitLook) {
95
+ export function resolveCommitAnchors(events, gitLook, change = null) {
66
96
  const out = [];
67
97
  for (const e of events || []) {
68
98
  if (!e || e.kind !== 'commit' || !e.detail) continue;
@@ -75,6 +105,7 @@ export function resolveCommitAnchors(events, gitLook) {
75
105
  subject: info ? String(info.message || '').split(/\r?\n/)[0] : null,
76
106
  tokens: info ? tokensOf(info.message) : [],
77
107
  resolvable: Boolean(info),
108
+ sameChange: !change || (info ? String(info.message || '').includes(change) : false),
78
109
  });
79
110
  }
80
111
  return out;
@@ -125,11 +156,13 @@ function fmtDur(ms) {
125
156
  * @param {string} args.change 变更名
126
157
  * @param {Array} args.events 事件流(readWatcherEvents.events)
127
158
  * @param {Array|null} args.tasks parseTaskLines 产物(null=任务面缺失)
128
- * @param {Array} args.anchors resolveCommitAnchors 产物
159
+ * @param {Array} args.anchors resolveCommitAnchors 产物(锚定只取 sameChange!==false 的行)
129
160
  * @param {number|null} args.birthTs 诞生锚(工件 frontmatter created_at 毫秒;null=缺)
130
161
  * @param {string|null} args.tier flow-state tier(null=缺/未知)
162
+ * @param {Array|null} args.tickLog parseTickLog 产物(2026-10-14-timeline-ticktime-anchor-scope:
163
+ * 侧车勾选留痕——任务行时刻三级降级:侧车精确(无 ≈)> 事件流推断(≈)> ?)
131
164
  */
132
- export function renderTimeline({ change, events, tasks, anchors, birthTs, tier }) {
165
+ export function renderTimeline({ change, events, tasks, anchors, birthTs, tier, tickLog = null }) {
133
166
  const L = [];
134
167
  L.push(`📅 ${change} — 合成时间线(${tier || 'tier 未知'}|事件流 × tasks.md × git 提交锚)`);
135
168
  L.push('');
@@ -140,16 +173,22 @@ export function renderTimeline({ change, events, tasks, anchors, birthTs, tier }
140
173
 
141
174
  if (tasks) {
142
175
  L.push('');
143
- L.push('── 任务面(勾选时刻 ≈ 顺序推断|tasks.md 描述行 × 提交锚)──');
176
+ L.push('── 任务面(勾选时刻:精确=task tick 侧车留痕|≈=事件流顺序推断|tasks.md 描述行 × 提交锚)──');
144
177
  const { times, broken } = inferFlipTimes(events, tasks.length);
145
- if (broken) L.push('(计数链中段断裂——断裂点后勾选时刻推断不可用,标 ?)');
146
- if (tasks.some((t, i) => t.checked && times[i] == null)) L.push('(已勾任务缺推断时刻——观测盲窗/水位丢失,标 ?)');
178
+ const tickMap = new Map((tickLog || []).map((r) => [String(r && r.task), Number(r && r.ts)]));
179
+ // 断裂注按需打(2026-10-14-timeline-ticktime-anchor-scope):侧车已覆盖全部已勾位时推断层
180
+ // 与展示无关——watcher 重复采样(0→1 双拍)也会撞断游标,此时打断裂注是误导噪音。
181
+ const inferenceMatters = tasks.some((t) => t.checked && !tickMap.has(t.id));
182
+ if (broken && inferenceMatters) L.push('(计数链中段断裂——断裂点后勾选时刻推断不可用,标 ?)');
183
+ if (tasks.some((t, i) => t.checked && times[i] == null && !tickMap.has(t.id))) L.push('(已勾任务缺推断时刻——观测盲窗/水位丢失,标 ?)');
147
184
  for (let i = 0; i < tasks.length; i++) {
148
185
  const t = tasks[i];
149
- const when = t.checked
150
- ? (times[i] != null ? `≈${fmtHMS(times[i])}` : '?')
151
- : '未勾';
152
- const anchor = anchors.filter((a) => a.tokens.includes(t.id)).sort((a, b) => a.ts - b.ts)[0];
186
+ const when = !t.checked
187
+ ? '未勾'
188
+ : tickMap.has(t.id) ? fmtHMS(tickMap.get(t.id))
189
+ : times[i] != null ? `≈${fmtHMS(times[i])}`
190
+ : '?';
191
+ const anchor = anchors.filter((a) => a && a.sameChange !== false && a.tokens.includes(t.id)).sort((a, b) => a.ts - b.ts)[0];
153
192
  const ev = anchor ? anchor.hash : (t.checked ? '无提交锚⚠️' : '—');
154
193
  L.push(`${t.id} ${when.padEnd(10)} ${clip(t.desc, 44).padEnd(46)}${ev}`);
155
194
  }
@@ -169,7 +208,7 @@ export function renderTimeline({ change, events, tasks, anchors, birthTs, tier }
169
208
  if (stages.length > 0) {
170
209
  L.push(`阶段墙钟:${stages.map((s) => `${s.stage} ${fmtDur(s.durationMs)}`).join('|')}`);
171
210
  }
172
- L.push('注:观测起点≠诞生时刻(watcher 后拉起/单飞锁盲窗);勾选时刻为顺序推断(事件不记任务 id);描述行含机器稿截断。');
211
+ L.push('注:观测起点≠诞生时刻(watcher 后拉起/单飞锁盲窗);勾选时刻精确值=task tick 侧车留痕(task-ticks.jsonl),≈=事件流顺序推断(事件不记任务 id);描述行含机器稿截断。');
173
212
  return L.join('\n');
174
213
  }
175
214
 
@@ -179,27 +218,29 @@ export function renderTimeline({ change, events, tasks, anchors, birthTs, tier }
179
218
  * 烤制落盘——诚实注记而非数据错误。eventsCopySkipped=副本未随包形态(尺寸超帽或读源失败,
180
219
  * 评审 P3-1:注记必须与实际产出一致,不虚报副本在场)。
181
220
  */
182
- export function renderBakedTimeline({ change, events, tasks, anchors, birthTs, tier, bakedAtIso, eventsCopySkipped = false }) {
221
+ export function renderBakedTimeline({ change, events, tasks, anchors, birthTs, tier, bakedAtIso, eventsCopySkipped = false, tickLog = null }) {
183
222
  const head = [
184
223
  `# 合成时间线快照 — ${change}`,
185
224
  '',
186
225
  `> 烤制于归档链(${bakedAtIso}):事件流机本位(.runtime gitignore),本快照随归档包进 git 跨机可读。`,
187
- '> 末尾事件(含「目录已移入 archive」终拍)可能晚于烤制未入快照;勾选时刻为顺序推断(≈)。',
226
+ '> 末尾事件(含「目录已移入 archive」终拍)可能晚于烤制未入快照;勾选时刻:精确=task tick 侧车留痕,≈=事件流顺序推断。',
188
227
  eventsCopySkipped
189
228
  ? '> 原始事件副本未随包(尺寸超帽或读源失败)——细粒度复核回本机 .runtime 或平台 observation 事件面。'
190
229
  : '> 原始事件副本:本目录 watcher-events.jsonl(时点快照)。',
191
230
  '',
192
231
  ]
193
- return head.join('\n') + renderTimeline({ change, events, tasks, anchors, birthTs, tier })
232
+ return head.join('\n') + renderTimeline({ change, events, tasks, anchors, birthTs, tier, tickLog })
194
233
  }
195
234
 
196
- /** change 目录双路径探测(活跃 > 归档)+ tasks.md 读取 → {changeDir, tasksMd}(缺 tasksMd=null)。 */
235
+ /** change 目录双路径探测(活跃 > 归档)+ tasks.md / task-ticks.jsonl 读取 → {changeDir, tasksMd, tickLog}(缺 tasksMd=null、tickLog=空数组)。 */
197
236
  export function loadChangeTasks(specBase, change) {
198
237
  const active = join(specBase, 'changes', change);
199
238
  const archived = join(specBase, 'changes', 'archive', change);
200
239
  const dir = [active, archived].find((d) => { try { return readFileSync(join(d, 'tasks.md'), 'utf8') != null } catch { return false } });
201
- if (!dir) return { changeDir: null, tasksMd: null };
202
- return { changeDir: dir, tasksMd: readFileSync(join(dir, 'tasks.md'), 'utf8') };
240
+ if (!dir) return { changeDir: null, tasksMd: null, tickLog: [] };
241
+ let tickLog = [];
242
+ try { tickLog = parseTickLog(readFileSync(join(dir, 'task-ticks.jsonl'), 'utf8')); } catch { /* 侧车缺失/不可读按空 */ }
243
+ return { changeDir: dir, tasksMd: readFileSync(join(dir, 'tasks.md'), 'utf8'), tickLog };
203
244
  }
204
245
 
205
246
  /** 诞生锚:change 目录工件 frontmatter created_at(requirements > proposal > design > tasks)。 */
@@ -215,4 +256,4 @@ export function readBirthTs(changeDir) {
215
256
  return null;
216
257
  }
217
258
 
218
- export default { parseTaskLines, inferFlipTimes, resolveCommitAnchors, renderTimeline, renderBakedTimeline, loadChangeTasks, readBirthTs };
259
+ export default { parseTaskLines, parseTickLog, inferFlipTimes, resolveCommitAnchors, renderTimeline, renderBakedTimeline, loadChangeTasks, readBirthTs };
@@ -2867,17 +2867,34 @@ function buildDepsBatches({ deps, changedFiles = [], hits = [], cwd = null, prio
2867
2867
  // cd 前缀保留(2026-09-25-deps-cwd-prefix,R15/R16 实证):剥前缀后从 worktree 根跑 uv,根上无
2868
2868
  // pyproject.toml → uv 解析到无 dev extras 的错环境 → aiobotocore 假红(两轮 known_failures 顶着)。
2869
2869
  // 前缀在则批次文件路径按该 dir 重定基(模块命令的文件参即 dir 相对口径,如 app/modules/...)。
2870
+ // py 按包根分组(坑 flow-dynamic-subset-root-scripts-path,2026-10-13 实证:主仓根
2871
+ // scripts/test_x.py 被拼进 backend 的 cd 批——相对路径在 backend cwd 下必然 not found,
2872
+ // 整批 exit 2 假败)。每个 .py 文件按最近 pyproject/uv.lock 祖先归组(与 runner 推断
2873
+ // 同款就近原则——monorepo 子项目各自独立环境);组内共用该包根的 runner 命令与重定基。
2874
+ // 显式命中命令串(hits)优先且整批共用(用户显式配置语义优先,不拆组)。
2870
2875
  let pyRunner = null
2871
2876
  for (const h of hits || []) {
2872
2877
  const cmd = String(h.test || '')
2873
2878
  const m = /^((?:cd\s+[^&|;]+&&\s*)*[^&|;]*?pytest)/.exec(cmd) || /(?:^|&&|;|\|\|)\s*([^&|;]*pytest)/.exec(cmd)
2874
2879
  if (m) { pyRunner = m[1].trim(); break }
2875
2880
  }
2876
- if (!pyRunner && cwd && pyRun.length > 0) {
2877
- const inferred = inferPyRunner(cwd, pyRun[0])
2878
- pyRunner = inferred.command
2881
+ // pyGroups: Map<dirKey, { files, command }>;dirKey null = 主仓根(无 cd 前缀)
2882
+ const pyGroups = new Map()
2883
+ const pyGroupOf = (f) => {
2884
+ if (pyRunner) return null // 显式命中命令:整批一组(既有语义)
2885
+ if (!cwd) return null
2886
+ const inferred = inferPyRunner(cwd, f)
2887
+ return inferred.dir || null
2888
+ }
2889
+ for (const f of pyRun) {
2890
+ const key = pyGroupOf(f)
2891
+ if (!pyGroups.has(key)) {
2892
+ const command = pyRunner || (cwd ? inferPyRunner(cwd, f).command : 'python -m pytest')
2893
+ pyGroups.set(key, { files: [], command })
2894
+ }
2895
+ pyGroups.get(key).files.push(f)
2879
2896
  }
2880
- if (!pyRunner) pyRunner = 'python -m pytest'
2897
+ if (!pyRunner && pyGroups.size > 0) pyRunner = pyGroups.values().next().value.command
2881
2898
  // JSX 运行器推断(2026-09-26-residual-runner-parity 起 + 结构推断源):.tsx/.jsx 是 node
2882
2899
  // 原生 type stripping 跑不了的(node --test 直跑恒败——R18 verify 段 ~11 次尝试的坑),
2883
2900
  // ① 命中命令串提取 vitest/jest(旧路径)② 无命中命令时最近含 vitest/jest 的 package.json;
@@ -2928,7 +2945,19 @@ function buildDepsBatches({ deps, changedFiles = [], hits = [], cwd = null, prio
2928
2945
  // 批计数分列(2026-10-06-fr-regress-cap-drop):count=实跑总数、prioCount=实跑中优先面数、
2929
2946
  // dropped 只计普通依赖弃置、prioDropped=防御路径计数(构造上恒 0——quotaFill 保证优先面零弃置,
2930
2947
  // >0 即组卷逻辑被破坏,消费面 loud 披露)。js 批经 jsNative/jsProject 内容分流后计数随分流面。
2931
- if (pyRun.length > 0) batches.push({ name: 'deps(auto-py)', short: 'py', command: `${pyRunner} ${pyRun.map(rebase).join(' ')}`, count: pyRun.length, prioCount: pyQ.prioCount, dropped: pyQ.dropped, prioDropped: pyQ.prioDropped, files: pyRun })
2948
+ // py 多批(坑 flow-dynamic-subset-root-scripts-path):按包根分组逐批 push——每组
2949
+ // 重定基按本组 dir(跨组文件不再共享单一 cdDir);组序稳定(Map 插入序=pyRun 排序序)。
2950
+ {
2951
+ let gi = 0
2952
+ for (const [dirKey, grp] of pyGroups) {
2953
+ // hits 命中形态 dirKey=null——从本组命令的 cd 前缀提取重定基目录(坑 deps-cwd-prefix
2954
+ // ① 用例:显式配置 'cd backend && uv run pytest …' 时路径须按 backend/ 重定基)
2955
+ const cmdDir = dirKey || /(?:^|\s)cd\s+(\S+)\s*&&/.exec(grp.command)?.[1] || null
2956
+ const rebasePy = (f) => (cmdDir && f.startsWith(cmdDir + '/')) ? f.slice(cmdDir.length + 1) : f
2957
+ batches.push({ name: pyGroups.size > 1 ? `deps(auto-py:${dirKey || 'root'})` : 'deps(auto-py)', short: 'py', command: `${grp.command} ${grp.files.map(rebasePy).join(' ')}`, count: grp.files.length, prioCount: grp.files.filter(f => prio(f) === 0).length, dropped: pyQ.dropped, prioDropped: pyQ.prioDropped, tap: true, files: grp.files, pyRoot: dirKey || null })
2958
+ gi++
2959
+ }
2960
+ }
2932
2961
  if (jsNative.length > 0) batches.push({ name: 'deps(auto-js)', short: 'js', command: `node --test --test-reporter=spec --test-reporter-destination=stderr --test-reporter=tap --test-reporter-destination=stdout ${jsNative.join(' ')}`, count: jsNative.length, prioCount: jsNative.filter(f => prio(f) === 0).length, dropped: jsTest.length - jsRun.length, prioDropped: jsQ.prioDropped, tap: true, files: jsNative })
2933
2962
  if (jsMeta.length > 0) batches.push({ name: 'deps(auto-js-meta-skip)', short: 'js-meta-skip', command: null, count: jsMeta.length, dropped: 0, skip: true, files: jsMeta, reason: '套件编排器(run-tests.mjs=npm test 组卷入口)非单测文件——内嵌执行=递归全量套件(嵌套 runner 污染假败);全量门由收口实测/CI 的 npm test 承担' })
2934
2963
 
@@ -4194,6 +4223,51 @@ export function resolveReconcileActualFiles({ cwd, specBase, runtimeRoot, change
4194
4223
  }
4195
4224
  }
4196
4225
 
4226
+ // —— EOL-only 噪声过滤(坑 verify-close-gate-staggered-exposure 建议④,2026-10-15 消费仓实证:
4227
+ // gen:types 幂等重生成纯行尾 diff 进 actual 面 → ③类 undeclared 假红,agent 被迫为零内容
4228
+ // 改动补声明或回退)。手法与 run/shared.js quick 审计的 eolOnly 先例同源:同一 git 范围
4229
+ // 跑 plain 名单与 --ignore-cr-at-eol 名单,差集 = 纯行尾差异 → 剔出对账面,eolOnlyFiles
4230
+ // 软留痕。两条硬边界:① 必须正向出现在 plain diff 且内容零变更才剔——absence-based
4231
+ // (「不在真实集即剔」)会误伤 B3 pathspec/救援窗口来源的已交付文件(它们不在任何
4232
+ // git diff 里);② --name-only 不吃 --ignore-cr-at-eol(git 实测怪癖),内容过滤必须走
4233
+ // --numstat。探测失败 fail-open 不过滤;untracked 天然不在 plain diff 不受影响;删除/
4234
+ // 重命名是内容变更(numstat 在场)不受影响。——
4235
+ const eolOnlyFiles = []
4236
+ try {
4237
+ const eolOnlySet = new Set()
4238
+ const probePair = (dir, refs) => {
4239
+ const plainOut = gitQuiet(dir, ['diff', '--name-only', ...refs], { timeout: 30 * 1000, trim: false })
4240
+ if (plainOut === null) return
4241
+ const realOut = gitQuiet(dir, ['diff', '--numstat', '--ignore-cr-at-eol', ...refs], { timeout: 30 * 1000, trim: false })
4242
+ if (realOut === null) return
4243
+ const real = new Set()
4244
+ for (const l of realOut.split('\n')) {
4245
+ const i = l.lastIndexOf('\t')
4246
+ if (i > 0) real.add(normalizeReconcilePath(l.slice(i + 1)))
4247
+ }
4248
+ for (const f of plainOut.split('\n').map(x => x.trim()).filter(Boolean)) {
4249
+ const n = normalizeReconcilePath(f)
4250
+ if (!real.has(n)) eolOnlySet.add(n)
4251
+ }
4252
+ }
4253
+ // 工作树(HEAD 对照):主仓 + worktree(form A 双仓各自 HEAD)
4254
+ const probeDirs = [cwd]
4255
+ if (form === 'worktree' && wtMeta && wtMeta.gitDir && wtMeta.gitDir !== cwd) probeDirs.push(wtMeta.gitDir)
4256
+ for (const dir of probeDirs) probePair(dir, ['HEAD'])
4257
+ // 已提交侧:form B B1 同基点(mergeBase 对照工作树);form A worktree 已提交段(meta 锚点..HEAD)
4258
+ if (form === 'post-apply' && baseAnchor) {
4259
+ probePair(cwd, [baseAnchor])
4260
+ } else if (form === 'worktree' && wtMeta) {
4261
+ const mb = wtMeta.meta && (wtMeta.meta.baselineCommit || wtMeta.meta.actualBaseHash || wtMeta.meta.baseHash)
4262
+ if (mb) probePair(wtMeta.gitDir, [mb, 'HEAD'])
4263
+ }
4264
+ for (const f of eolOnlySet) {
4265
+ if (union.has(f)) {
4266
+ union.delete(f)
4267
+ eolOnlyFiles.push(f)
4268
+ }
4269
+ }
4270
+ } catch { /* EOL 探测异常 → 不过滤(fail-open,行为与修复前一致) */ }
4197
4271
  const files = [...new Set(filterDeliverableFiles([...union]).filter(Boolean))].sort()
4198
4272
  // 多会话推进死锁诊断(reconcile-source-isolation,R18-SF-full 实证):post-apply 形态下
4199
4273
  // 实际面全空(B1 无 ref + porcelain 无未提交面 + 无 apply-pathspec 兜底)= 典型「主仓被
@@ -4203,7 +4277,7 @@ export function resolveReconcileActualFiles({ cwd, specBase, runtimeRoot, change
4203
4277
  const parallelAdvanceHint = (form === 'post-apply' && files.length === 0 && union.size === 0 && !sources.some((s) => s.startsWith('main:diff-merge-base') || s.startsWith('apply-pathspec')))
4204
4278
  ? '⚠️ actual 源全空(无分支锚点 diff + 主仓无未提交面 + 无 apply-pathspec 兜底)——典型「主仓被并行会话推进」形态(本变更的提交/暂存可能已被他侧裸提交扫走)。切勿用「暂存物化」自救(touch 文件进共享暂存区——会被再次扫走且污染他侧,R18-SF-full 实证 matched=0 死循环);出路:① 恢复/登记本变更分支(worktree meta.json 的 branch 字段)让锚定 diff 生效后重跑 ② 确属全量已在他侧提交:与该会话按 AGENTS 规则 18 对账后以该提交为锚核销'
4205
4279
  : null
4206
- return { ok: true, form, files, sources, foreignExcluded, foreignExcludedFiles, degradedReason: null, baseAnchor, commitWindowFiles, parallelAdvanceHint }
4280
+ return { ok: true, form, files, sources, foreignExcluded, foreignExcludedFiles, degradedReason: null, baseAnchor, commitWindowFiles, parallelAdvanceHint, eolOnlyFiles }
4207
4281
  }
4208
4282
 
4209
4283
  /**
@@ -4433,6 +4507,9 @@ export function reconcileTargetFiles({ cwd, specBase = null, changeName = null,
4433
4507
  notes.push(`③类另有 ${scaffoldTotal} 个工具/平台脚手架文件(platform ${scaffoldBuckets.platform} / tool ${scaffoldBuckets.tool},如 ${scaffoldSamples.join('、')}${scaffoldTotal > scaffoldSamples.length ? ' 等' : ''})已聚合不逐条列——非交付物(scope-audit 设施桶同口径)`)
4434
4508
  }
4435
4509
 
4510
+ if (Array.isArray(actual.eolOnlyFiles) && actual.eolOnlyFiles.length > 0) {
4511
+ notes.push(`EOL-only 剔除:${actual.eolOnlyFiles.length} 个文件仅行尾差异(--ignore-cr-at-eol 对照零内容变更,如 gen 脚本幂等重生成)已剔出对账面——${actual.eolOnlyFiles.slice(0, 5).join('、')}${actual.eolOnlyFiles.length > 5 ? ' 等' : ''}(.gitattributes 统一行尾可根治)`)
4512
+ }
4436
4513
  // 状态聚合:②在场即 ERROR 态优先(gates 阻断语义靠它兑现);仅③ → WARNING 态
4437
4514
  const status = missing.length > 0 ? 'missing_declared' : (undeclared.length > 0 ? 'undeclared' : 'ok')
4438
4515
  return { status, matched, missing, undeclared, undeclaredScaffold: scaffoldTotal, skipReason: null, notes, form: actual.form, sources: actual.sources, crossRepo }
@@ -1952,7 +1952,7 @@ function prefillMatrixCells(item, testFiles, hint) {
1952
1952
  * @param {{ applicable: boolean, tasks: Array<{task: string, acceptance: string[], testFiles: string[], hints: Record<number, {terms: string[], files: string[], anchors?: Array<{term: string, file: string, line: number}>}>}> }} p7
1953
1953
  * @returns {string[]} 行数组(含段标题;调用方自理前后空行)
1954
1954
  */
1955
- function renderProbe7Lines(p7) {
1955
+ export function renderProbe7Lines(p7) {
1956
1956
  const L = [PROBE7_HEADING]
1957
1957
  if (!p7 || !p7.applicable) {
1958
1958
  L.push('- 不适用(无 TaskCard)')
@@ -1986,8 +1986,12 @@ function renderProbe7Lines(p7) {
1986
1986
  L.push('| acceptance 条目 | 归属测试文件 | 关键词命中(提示,命中≠判定) | 判定 | 证据 |')
1987
1987
  L.push('|---|---|---|---|---|')
1988
1988
  t.acceptance.forEach((item, i) => {
1989
+ // 展示帽(坑 probe-noise-and-commit-suggest-quality 坑A,2026-10-15 消费仓实证:宽目录
1990
+ // allowed_paths 递归把 82 个测试文件全量塞进单格(单行数千字符 ×3 行),信号全淹——照
1991
+ // 探针 3 既有 slice 截断先例收敛为前 5 + 「共 N 个」折叠;路径经 mdEscapeCell 防竖线破表)
1989
1992
  const attribCell = (t.testFiles && t.testFiles.length > 0)
1990
- ? t.testFiles.map(f => `\`${f}\``).join('<br>')
1993
+ ? t.testFiles.slice(0, 5).map(f => `\`${mdEscapeCell(f, 120)}\``).join('<br>')
1994
+ + (t.testFiles.length > 5 ? `<br>…共 ${t.testFiles.length} 个(目录级候选全量在 facts/trace,归属按关键词命中与证据锚点收窄)` : '')
1991
1995
  : '无归属测试——判定大概率 uncovered'
1992
1996
  const h = (t.hints && t.hints[i]) || null
1993
1997
  const hintCell = h
@@ -2251,7 +2255,9 @@ export function runVerifyProbes({ cwd, changeName, specDir = null }) {
2251
2255
  if (t && isProbe7TestPath(t) && !fromDependents.includes(t)) fromDependents.push(t)
2252
2256
  }
2253
2257
  }
2254
- const testFiles = [...new Set([...fromAllowed, ...fromReview, ...fromDependents])]
2258
+ // 收集帽(坑 probe-noise-and-commit-suggest-quality 坑A):目录级 allowed_paths 递归可卷
2259
+ // 入整仓测试树(实证 82 个)——去重后截前 20(展示层另有 5 个硬帽;hints/trace 消费同集)
2260
+ const testFiles = [...new Set([...fromAllowed, ...fromReview, ...fromDependents])].slice(0, 20)
2255
2261
  // 无归属卡注入 FR 关联回归既有用例候选(task-02;有归属卡 existingTests 恒空——零噪音)
2256
2262
  const existingTests = []
2257
2263
  if (frLinkedExisting && testFiles.length === 0) {
@@ -2259,7 +2265,7 @@ export function runVerifyProbes({ cwd, changeName, specDir = null }) {
2259
2265
  if (isProbe7TestPath(f) && !existingTests.includes(f)) existingTests.push(f)
2260
2266
  }
2261
2267
  }
2262
- const finalTestFiles = existingTests.length > 0 ? [...new Set([...testFiles, ...existingTests])] : testFiles
2268
+ const finalTestFiles = existingTests.length > 0 ? [...new Set([...testFiles, ...existingTests])].slice(0, 20) : testFiles
2263
2269
  probe7.tasks.push({
2264
2270
  task: card.task,
2265
2271
  acceptance: card.acceptance,
@@ -2663,6 +2669,33 @@ const API_MATRIX_HEADING = '## 接口验证覆盖矩阵'
2663
2669
  // 权限矩阵段有对应行或显式豁免(「无权限约束」)——warning 计算归 task-05,此处只定集合。
2664
2670
  const API_MATRIX_WRITE_METHODS = new Set(['POST', 'PUT', 'DELETE', 'PATCH'])
2665
2671
 
2672
+ /**
2673
+ * design.md 接口段实况解析面(gate/derive 预检注入用,2026-10-14-gate-verify-apiface-preflight):
2674
+ * 与 backfillFactsFromMdAndTests 落盘的 facts.apiFace 同形同源(同一 parseDesignApiTable、同一
2675
+ * writeEndpoints 过滤——单一产物源,禁第二套解析文法),零落盘。背景:facts.apiFace 唯一生产者
2676
+ * 是 run verify --done 收尾前置 backfill(run/gates.js),gate 路径不回填且 --init 刻意不落键 →
2677
+ * 预检 parseSet 空/旧 → design接口表# 锚点恒空指(平台 6 轮 gate 实证)。gate 侧经本 helper
2678
+ * 实况解析后经 context.apiFaceLive 注入 validator(stage-contract 零 import 纪律:注入不做
2679
+ * 函数调用),「预检过=必过」对锚点核对成立;design.md 缺失/不可读返回 null(调用方不注入,
2680
+ * 退回 facts 口径——2026-10-14 评审 P1:空面注入会覆写有效 facts.apiFace 反致误阻断)、解析
2681
+ * 异常由调用方 fail-soft 承接(不注入退回 facts)。
2682
+ * @param {string} designPath changeDir/design.md 路径(缺失/不可读 → null=不注入信号)
2683
+ * @returns {{ endpoints: Array<{method: string, path: string, rowIdx: number}>, declared: number|null, writeEndpoints: Array }|null}
2684
+ */
2685
+ export function computeApiFaceFromDesign(designPath) {
2686
+ let designText = null
2687
+ if (existsSync(designPath)) {
2688
+ try { designText = readFileSync(designPath, 'utf8') } catch { designText = null }
2689
+ }
2690
+ if (designText === null) return null
2691
+ const face = parseDesignApiTable(designText)
2692
+ return {
2693
+ endpoints: face.endpoints,
2694
+ declared: face.declared,
2695
+ writeEndpoints: face.endpoints.filter(e => e && API_MATRIX_WRITE_METHODS.has(e.method)),
2696
+ }
2697
+ }
2698
+
2666
2699
  /**
2667
2700
  * 渲染「## 接口验证覆盖矩阵」段(task-04)。
2668
2701
  * 三态:解析有端点 → 逐端点预填行(判定列 `<待填:五选一>` 占位,同 probe7 预填口径供 agent
@@ -2731,7 +2764,12 @@ export function buildVerifyFacts(result, { changeName, now } = {}) {
2731
2764
  // v2(2026-09-08-ir-verify-facts):probes 机器段原样;conclusion/tests/requiredEvidence/
2732
2765
  // runtimeEvidence/factsConsistency 五段是 slot-backfill/实测回填段(D-001@v2),--init 快照
2733
2766
  // 不落键(writeVerifyFacts 分段合并时保留既有固化段),由 backfillFactsFromMdAndTests 填。
2734
- return {
2767
+ // apiFace 例外(坑 verify-close-gate-staggered-exposure 建议③连带,2026-10-15 消费仓实证):
2768
+ // --init 即落键——runVerifyProbes 的 apiFace 与 --done backfill 同源同形(computeApiFaceFromDesign),
2769
+ // 原「刻意不落」使「重跑 --init 刷新 facts」救不了空指预检(报错文案也没提这一点),且空指
2770
+ // 诊断方向被误导去改 design 文档。键只在值在场时出现(undefined 值键会在 writeVerifyFacts
2771
+ // 合并 spread 时覆盖 prev 固化段)。
2772
+ const factsSkeleton = {
2735
2773
  schemaVersion: 2,
2736
2774
  change: changeName,
2737
2775
  generatedAt: now || new Date().toISOString(),
@@ -2796,6 +2834,8 @@ export function buildVerifyFacts(result, { changeName, now } = {}) {
2796
2834
  },
2797
2835
  },
2798
2836
  }
2837
+ if (result && result.apiFace && typeof result.apiFace === 'object') factsSkeleton.apiFace = result.apiFace
2838
+ return factsSkeleton
2799
2839
  }
2800
2840
 
2801
2841
  /**
@@ -2832,7 +2872,16 @@ export function writeVerifyFacts(changeDir, result, changeName, opts = {}) {
2832
2872
  try {
2833
2873
  const prev = JSON.parse(readFileSync(factsPath, 'utf8'))
2834
2874
  if (prev && prev.schemaVersion === FACTS_SCHEMA_VERSION) {
2875
+ // backfill 固化段保留(坑 verify-close-gate-staggered-exposure,2026-10-15):旧白名单只保
2876
+ // 五段,re-init 会把 --done 回填的 apiFace/consumerHints/handover/dbScriptDeclarations 等
2877
+ // 整个抹掉(--init 后预检又空指)。此处先铺 prev 固化段再被 fresh 覆盖:apiFace 由 fresh
2878
+ // 侧重算(同源 computeApiFaceFromDesign,值应等价),fresh 未带的键原样保留。
2879
+ const preservedBackfill = {}
2880
+ for (const k of ['apiFace', 'consumerHints', 'handover', 'dbScriptDeclarations', 'matrixPartialRows', 'smokeRan']) {
2881
+ if (prev[k] !== undefined) preservedBackfill[k] = prev[k]
2882
+ }
2835
2883
  facts = {
2884
+ ...preservedBackfill,
2836
2885
  ...fresh,
2837
2886
  conclusion: prev.conclusion,
2838
2887
  tests: prev.tests,
@@ -3140,20 +3189,14 @@ export function backfillFactsFromMdAndTests(factsPath, { verifyMd, testCheckResu
3140
3189
  // validator 判 N=0 走零行为注记);与 runVerifyProbes 同一解析器(单一产物源)。
3141
3190
  {
3142
3191
  const changeDir = dirname(factsPath)
3143
- const designPath = join(changeDir, 'design.md')
3144
- let designText = null
3145
- if (existsSync(designPath)) {
3146
- try { designText = readFileSync(designPath, 'utf8') } catch { designText = null }
3147
- }
3148
- const face = parseDesignApiTable(designText || '')
3149
- facts.apiFace = {
3150
- endpoints: face.endpoints,
3151
- declared: face.declared,
3152
- writeEndpoints: face.endpoints.filter(e => e && API_MATRIX_WRITE_METHODS.has(e.method)),
3153
- }
3192
+ // backfill 契约=恒落对象面(缺失/不可读 → 空面,基线口径不变):helper 的 null 是 gate
3193
+ // 注入通道的「不注入」信号,非 backfill 落盘形态(2026-10-14 评审 P3——null 落盘会让
3194
+ // 「design 蒸发×矩阵段缺失×严格档」窄角从基线 error 降 warning,重构不得夹带行为变化)。
3195
+ facts.apiFace = computeApiFaceFromDesign(join(changeDir, 'design.md'))
3196
+ || { endpoints: [], declared: null, writeEndpoints: [] }
3154
3197
  let designPaths = []
3155
- if (designText !== null) {
3156
- try { designPaths = parseFileChangeListDetailed(designPath).map(e => String(e.path)) } catch { designPaths = [] }
3198
+ if (existsSync(join(changeDir, 'design.md'))) {
3199
+ try { designPaths = parseFileChangeListDetailed(join(changeDir, 'design.md')).map(e => String(e.path)) } catch { designPaths = [] }
3157
3200
  }
3158
3201
  facts.consumerHints = classifyConsumerHints(designPaths)
3159
3202
  }
@@ -6,15 +6,16 @@
6
6
  ## 选道(动手前先对号入座)
7
7
  | 需求形态 | 走法 |
8
8
  |---|---|
9
- | 需求已含决策(改什么、成功标准说得清) | **轻量变更(默认快道)**:`flow start` → 直接干活 → `flow done`(2 次协议调用;操作细节见 `/sillyspec:flow` skill 与 flow start 输出) |
10
- | 需求不清晰 / 需要方案探索 | 头脑风暴预段:`run brainstorm --change <名>` → 完成后 `flow start --change <名>` 收编续跑(design 以头脑风暴版为准) |
9
+ | 需求已含决策(改什么、成功标准说得清——成功标准须来自**用户原话或既有文档转写**;要靠你推断拟出的标准=需求不清晰) | **轻量变更(默认快道)**:`flow start` → 直接干活 → `flow done`(2 次协议调用;操作细节见 `/sillyspec:flow` skill 与 flow start 输出) |
10
+ | 用户显式要求极速(「极速/别验证/只要 spec 和代码」类原话)且需求已含决策 | **轻量变更极速版**:同上行加 `--turbo`(`flow start --change <名> --input "<...>" --turbo`)——**仅用户显式触发,禁止你自行启用**;收口跳过实测与重仪式门(hunk/dirty/ui 探针/评审),spec 结构校验/tasks 全勾/ledger/patch 冻结/distill/归档照常(跳的是验证不是证据);升厚即失效恢复全量仪式;收口输出显式标注「不含测试客观核验」。预段组合极速:头脑风暴预段走轻量跑判定+单轮合并问询+burst 一次 --done,收编时按用户要求带 --turbo |
11
+ | 需求不清晰 / 需要方案探索(含「成功标准要你替用户拟」的形态——自己拟标准再自答是把默认共识伪装成共识) | 头脑风暴预段:`run brainstorm --change <名>` → 完成后 `flow start --change <名>` 收编续跑(design 以头脑风暴版为准)。上个轻量变更被 route-hindsight 标记时全新 start 会被同意门拦下要求显式选道:确认已含决策带 `--no-prestage` 声明留痕,否则先走本行预段 |
11
12
  | 大改动:跨模块取舍 / Wave 计划编排 / 多阶段治理 / 设计期人机对抗 | 完整流程五阶段:`run brainstorm → plan → execute → verify → archive`(每阶段一次渲染 + 一次 --done 收口;细节见各阶段 skill 与 CLI 输出) |
12
13
  | 代码已先写好(倒推收尾) | 不回头补流程装样子:`flow start --change <YYYY-MM-DD-名> --input` 按下方过门格式写(动机=已做改动的描述,成功标准=已验证达标的行为)→ `flow done` 一步收口 |
13
14
 
14
15
  `--input` 过门格式(轻量道首次调用即过,勿靠报错学;引号内换行合法,下例可照抄):
15
16
 
16
17
  ```bash
17
- sillyspec flow start --change <YYYY-MM-DD-名> --input "<动机与背景>
18
+ sillyspec flow start --change <YYYY-MM-DD-名> --input "<一句中文概括(首行即变更标题);随后动机与背景
18
19
 
19
20
  成功标准:
20
21
  - <可验证标准>"
@@ -30,7 +31,7 @@ sillyspec flow start --change <YYYY-MM-DD-名> --input "<动机与背景>
30
31
  ## 核心规则
31
32
  1. **禁止绕过本文件规则和 SillySpec 流程**。所有变更走 sillyspec 流程,不裸改裸提交。
32
33
  2. **改代码前必须先说明依据**——依据的文档路径(design.md / 模块文档 / file-lifecycle.md(如有))或现有代码依据,无依据不改。
33
- 3. **执行顺序**:文档 → 读代码 → 写测试 → 写实现 → 跑测试 → 验收 → 更新文档。
34
+ 3. **执行顺序**:文档 → 读代码 → 写测试 → 写实现 → 跑测试 → 验收 → 更新文档。测试按层适用:纯逻辑先写测试,行为契约断言用户可见行为,视觉样式与纯配置/文档不写测试(记「不适用:理由」,验收靠规范与人工)。
34
35
  4. **实证核验再收口**:触及 `src`/`test` 的改动,CLI 会在收口时亲自实测(实测失败阻断收口;纯 doc/配置自动跳过)。测试面按变更动态推断(本变更测试 ∪ FR 关联回归 ∪ import 依赖,runner 自项目结构推断——无需配置);lint 走 `.sillyspec/local.yaml` 的 `commands.lint`。以落盘文件与测试结果为准,不信口头"已完成"。
35
36
  5. **非测试逻辑本身有误时,禁止改测试来"通过"**——修逻辑,不修测试。
36
37
  6. **git hook 拦截提交时禁止跳过**(如 `.husky/pre-push`),修复问题后再提交。
@@ -3,6 +3,11 @@
3
3
  写测试是为了拦住回归,不是"有测试文件"。
4
4
  **基础习惯照做、不展开**:FIRST(快/独立/可重复/自校验/及时)、金字塔分层(单元为主/集成少量/E2E 只冒烟)、AAA 结构、测试名描述场景、测试数据轻量构造(Object Mother/Builder)、覆盖率不追数字。
5
5
 
6
+ **先判该不该写:按层适用**(一句话判据——能写成「当 X 发生时,用户应看到/发生 Y」→ 写测试;只能描述成「看起来更精致/更美观」→ 不写,靠规范与人工验收):
7
+ - **纯逻辑**(校验规则/数据变换/状态规则/重试策略)→ 严格 test-first:先写测试再实现。
8
+ - **行为契约**(用户做 X 后应看到/发生 Y 的关键交互,组件/API 皆然)→ 断言用户可见行为;主干断言可先行,mock 细节允许实现后补;不为样式类/className 写断言。
9
+ - **视觉样式与纯配置**(颜色/布局/间距/动效;配置/文档/CI)→ 不写测试——断言样式是负资产,改一次样式碎一片;测试绑定记「不适用:<理由>」。
10
+
6
11
  逐条对照检查:
7
12
  1. **覆盖**:正常路径 + 边界 + 异常/非法输入,不只写业务正例;单测全绿 ≠ 集成正确。
8
13
  2. **断言**:断言真实输出/副作用,禁止只"不抛错"的空断言;不 mock 被测方法自身。