sillyspec 3.32.9 → 3.33.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 (176) hide show
  1. package/.claude/skills/sillyspec-brainstorm/SKILL.md +114 -114
  2. package/.claude/skills/sillyspec-execute/SKILL.md +222 -222
  3. package/.claude/skills/sillyspec-explore/SKILL.md +107 -107
  4. package/.claude/skills/sillyspec-flow/SKILL.md +1 -1
  5. package/.claude/skills/sillyspec-plan/SKILL.md +156 -156
  6. package/.github/workflows/ci.yml +58 -0
  7. package/.husky/pre-push +7 -6
  8. package/AGENTS.md +1 -1
  9. package/assets/command-cards/flow.md +1 -1
  10. package/eval/eval.config.json +3 -3
  11. package/eval/lib/config.mjs +6 -3
  12. package/eval/lib/util.mjs +16 -3
  13. package/eval/lib/workdir.mjs +4 -1
  14. package/eval/results/eval.db +0 -0
  15. package/eval/results/logs/2026-10-15T12-37-22-986Z-004-phase-stats-armA/prompt.txt +10 -0
  16. package/eval/results/logs/2026-10-15T12-37-22-986Z-004-phase-stats-armA/stderr.txt +0 -0
  17. package/eval/results/logs/2026-10-15T12-37-22-986Z-004-phase-stats-armA/stdout.txt +1 -0
  18. package/eval/results/logs/2026-10-15T12-45-37-124Z-004-phase-stats-armB/prompt.txt +10 -0
  19. package/eval/results/logs/2026-10-15T12-45-37-124Z-004-phase-stats-armB/stderr.txt +0 -0
  20. package/eval/results/logs/2026-10-15T12-45-37-124Z-004-phase-stats-armB/stdout.txt +1 -0
  21. package/eval/results/logs/2026-10-15T12-56-19-259Z-004-phase-stats-armA/prompt.txt +10 -0
  22. package/eval/results/logs/2026-10-15T12-56-19-259Z-004-phase-stats-armA/stderr.txt +0 -0
  23. package/eval/results/logs/2026-10-15T12-56-19-259Z-004-phase-stats-armA/stdout.txt +1 -0
  24. package/eval/results/logs/2026-10-15T13-00-05-625Z-004-phase-stats-armB/prompt.txt +10 -0
  25. package/eval/results/logs/2026-10-15T13-04-51-003Z-004-phase-stats-armA/prompt.txt +10 -0
  26. package/eval/results/logs/2026-10-15T13-04-51-003Z-004-phase-stats-armA/stderr.txt +0 -0
  27. package/eval/results/logs/2026-10-15T13-04-51-003Z-004-phase-stats-armA/stdout.txt +1 -0
  28. package/eval/results/logs/2026-10-15T13-06-25-271Z-004-phase-stats-armB/prompt.txt +10 -0
  29. package/eval/results/logs/2026-10-15T13-06-25-271Z-004-phase-stats-armB/stderr.txt +0 -0
  30. package/eval/results/logs/2026-10-15T13-06-25-271Z-004-phase-stats-armB/stdout.txt +1 -0
  31. package/eval/results/logs/2026-10-15T13-24-38-308Z-004-phase-stats-armB/prompt.txt +10 -0
  32. package/eval/results/logs/2026-10-15T13-24-38-308Z-004-phase-stats-armB/stderr.txt +0 -0
  33. package/eval/results/logs/2026-10-15T13-24-38-308Z-004-phase-stats-armB/stdout.txt +1 -0
  34. package/eval/results/logs/2026-10-15T14-50-01-950Z-005-orders-export-armA/prompt.txt +8 -0
  35. package/eval/results/logs/2026-10-15T14-50-01-950Z-005-orders-export-armA/stderr.txt +0 -0
  36. package/eval/results/logs/2026-10-15T14-50-01-950Z-005-orders-export-armA/stdout.txt +1 -0
  37. package/eval/results/logs/2026-10-15T14-51-56-316Z-005-orders-export-armB/prompt.txt +8 -0
  38. package/eval/results/logs/2026-10-15T14-51-56-316Z-005-orders-export-armB/stderr.txt +0 -0
  39. package/eval/results/logs/2026-10-15T14-51-56-316Z-005-orders-export-armB/stdout.txt +1 -0
  40. package/eval/results/workdirs/001-slugify__armA/package.json +6 -0
  41. package/eval/results/workdirs/001-slugify__armA/src/slugify.mjs +26 -0
  42. package/eval/results/workdirs/001-slugify__armB/package.json +6 -0
  43. package/eval/results/workdirs/001-slugify__armB/src/slugify.mjs +26 -0
  44. package/eval/results/workdirs/002-each-day__armA/package.json +6 -0
  45. package/eval/results/workdirs/002-each-day__armA/src/dates.mjs +30 -0
  46. package/eval/results/workdirs/002-each-day__armB/DRY_RUN_WRONG_ANSWER.mjs +1 -0
  47. package/eval/results/workdirs/002-each-day__armB/package.json +6 -0
  48. package/eval/results/workdirs/002-each-day__armB/src/dates.mjs +4 -0
  49. package/eval/results/workdirs/003-word-report__armA/bin/report.mjs +36 -0
  50. package/eval/results/workdirs/003-word-report__armA/data/notes.txt +5 -0
  51. package/eval/results/workdirs/003-word-report__armA/package.json +6 -0
  52. package/eval/results/workdirs/003-word-report__armB/bin/report.mjs +36 -0
  53. package/eval/results/workdirs/003-word-report__armB/data/notes.txt +5 -0
  54. package/eval/results/workdirs/003-word-report__armB/package.json +6 -0
  55. package/eval/results/workdirs/004-phase-stats__armA/data/events.log +16 -0
  56. package/eval/results/workdirs/004-phase-stats__armB/.claude/commands/sillyspec/flow.md +44 -0
  57. package/eval/results/workdirs/004-phase-stats__armB/.claude/commands/sillyspec/run-archive.md +27 -0
  58. package/eval/results/workdirs/004-phase-stats__armB/.claude/commands/sillyspec/run-brainstorm.md +34 -0
  59. package/eval/results/workdirs/004-phase-stats__armB/.claude/commands/sillyspec/run-execute.md +30 -0
  60. package/eval/results/workdirs/004-phase-stats__armB/.claude/commands/sillyspec/run-plan.md +28 -0
  61. package/eval/results/workdirs/004-phase-stats__armB/.claude/commands/sillyspec/run-verify.md +30 -0
  62. package/eval/results/workdirs/004-phase-stats__armB/.claude/commands/sillyspec/status.md +28 -0
  63. package/eval/results/workdirs/004-phase-stats__armB/.claude/skills/sillyspec-archive/SKILL.md +87 -0
  64. package/eval/results/workdirs/004-phase-stats__armB/.claude/skills/sillyspec-auto/SKILL.md +54 -0
  65. package/eval/results/workdirs/004-phase-stats__armB/.claude/skills/sillyspec-brainstorm/SKILL.md +114 -0
  66. package/eval/results/workdirs/004-phase-stats__armB/.claude/skills/sillyspec-commit/SKILL.md +67 -0
  67. package/eval/results/workdirs/004-phase-stats__armB/.claude/skills/sillyspec-continue/SKILL.md +17 -0
  68. package/eval/results/workdirs/004-phase-stats__armB/.claude/skills/sillyspec-doctor/SKILL.md +76 -0
  69. package/eval/results/workdirs/004-phase-stats__armB/.claude/skills/sillyspec-execute/SKILL.md +222 -0
  70. package/eval/results/workdirs/004-phase-stats__armB/.claude/skills/sillyspec-explore/SKILL.md +107 -0
  71. package/eval/results/workdirs/004-phase-stats__armB/.claude/skills/sillyspec-export/SKILL.md +57 -0
  72. package/eval/results/workdirs/004-phase-stats__armB/.claude/skills/sillyspec-flow/SKILL.md +62 -0
  73. package/eval/results/workdirs/004-phase-stats__armB/.claude/skills/sillyspec-init/SKILL.md +177 -0
  74. package/eval/results/workdirs/004-phase-stats__armB/.claude/skills/sillyspec-knowledge/SKILL.md +40 -0
  75. package/eval/results/workdirs/004-phase-stats__armB/.claude/skills/sillyspec-plan/SKILL.md +156 -0
  76. package/eval/results/workdirs/004-phase-stats__armB/.claude/skills/sillyspec-propose/SKILL.md +16 -0
  77. package/eval/results/workdirs/004-phase-stats__armB/.claude/skills/sillyspec-quick/SKILL.md +116 -0
  78. package/eval/results/workdirs/004-phase-stats__armB/.claude/skills/sillyspec-resume/SKILL.md +21 -0
  79. package/eval/results/workdirs/004-phase-stats__armB/.claude/skills/sillyspec-scan/SKILL.md +79 -0
  80. package/eval/results/workdirs/004-phase-stats__armB/.claude/skills/sillyspec-state/SKILL.md +25 -0
  81. package/eval/results/workdirs/004-phase-stats__armB/.claude/skills/sillyspec-status/SKILL.md +54 -0
  82. package/eval/results/workdirs/004-phase-stats__armB/.claude/skills/sillyspec-verify/SKILL.md +87 -0
  83. package/eval/results/workdirs/004-phase-stats__armB/.claude/skills/sillyspec-workspace/SKILL.md +33 -0
  84. package/eval/results/workdirs/004-phase-stats__armB/AGENTS.md +45 -0
  85. package/eval/results/workdirs/004-phase-stats__armB/CLAUDE.md +3 -0
  86. package/eval/results/workdirs/004-phase-stats__armB/data/events.log +16 -0
  87. package/eval/results/workdirs/run-001-slugify__armA/package.json +6 -0
  88. package/eval/results/workdirs/run-001-slugify__armA/src/slugify.mjs +26 -0
  89. package/eval/results/workdirs/run-001-slugify__armB/package.json +6 -0
  90. package/eval/results/workdirs/run-001-slugify__armB/src/slugify.mjs +26 -0
  91. package/eval/results/workdirs/run-002-each-day__armA/package.json +6 -0
  92. package/eval/results/workdirs/run-002-each-day__armA/src/dates.mjs +30 -0
  93. package/eval/results/workdirs/run-002-each-day__armB/DRY_RUN_WRONG_ANSWER.mjs +1 -0
  94. package/eval/results/workdirs/run-002-each-day__armB/package.json +6 -0
  95. package/eval/results/workdirs/run-002-each-day__armB/src/dates.mjs +4 -0
  96. package/eval/results/workdirs/run-003-word-report__armA/bin/report.mjs +36 -0
  97. package/eval/results/workdirs/run-003-word-report__armA/data/notes.txt +5 -0
  98. package/eval/results/workdirs/run-003-word-report__armA/package.json +6 -0
  99. package/eval/results/workdirs/run-003-word-report__armB/bin/report.mjs +36 -0
  100. package/eval/results/workdirs/run-003-word-report__armB/data/notes.txt +5 -0
  101. package/eval/results/workdirs/run-003-word-report__armB/package.json +6 -0
  102. package/eval/results/workdirs/run-004-phase-stats__armA/bin/phase-stats.mjs +37 -0
  103. package/eval/results/workdirs/run-004-phase-stats__armA/data/events.log +16 -0
  104. package/eval/results/workdirs/run-004-phase-stats__armB/DRY_RUN_WRONG_ANSWER.mjs +1 -0
  105. package/eval/results/workdirs/run-004-phase-stats__armB/data/events.log +16 -0
  106. package/eval/results/workdirs/run-005-orders-export__armA/bin/export-orders.mjs +57 -0
  107. package/eval/results/workdirs/run-005-orders-export__armA/data/.gitattributes +1 -0
  108. package/eval/results/workdirs/run-005-orders-export__armA/data/orders.csv +9 -0
  109. package/eval/results/workdirs/run-005-orders-export__armA/out/summary.json +1 -0
  110. package/eval/results/workdirs/run-005-orders-export__armB/bin/export-orders.mjs +57 -0
  111. package/eval/results/workdirs/run-005-orders-export__armB/data/.gitattributes +1 -0
  112. package/eval/results/workdirs/run-005-orders-export__armB/data/orders.csv +9 -0
  113. package/eval/results/workdirs/run-005-orders-export__armB/out/summary.json +1 -0
  114. package/eval/tasks/004-phase-stats/files/data/events.log +16 -0
  115. package/eval/tasks/004-phase-stats/solution/bin/phase-stats.mjs +37 -0
  116. package/eval/tasks/004-phase-stats/task.json +6 -0
  117. package/eval/tasks/004-phase-stats/verify.mjs +38 -0
  118. package/eval/tasks/005-orders-export/files/data/.gitattributes +1 -0
  119. package/eval/tasks/005-orders-export/files/data/orders.csv +9 -0
  120. package/eval/tasks/005-orders-export/solution/bin/export-orders.mjs +57 -0
  121. package/eval/tasks/005-orders-export/task.json +6 -0
  122. package/eval/tasks/005-orders-export/verify.mjs +28 -0
  123. package/package.json +1 -1
  124. package/quicklog/tmp-fix-review.mjs +21 -0
  125. package/src/ceremony-tier.js +2 -1
  126. package/src/commit-suggest.js +45 -4
  127. package/src/config-schema.js +14 -0
  128. package/src/decision-distill.js +106 -24
  129. package/src/decision-redomain.js +148 -0
  130. package/src/decision-renumber.js +157 -0
  131. package/src/design-facts.js +3 -3
  132. package/src/docs-check.js +30 -15
  133. package/src/docs-debt.js +6 -5
  134. package/src/doctor-diagnostics.js +3 -1
  135. package/src/flow-parity.js +28 -4
  136. package/src/flow.js +76 -13
  137. package/src/forensics.js +236 -0
  138. package/src/fr-index.js +17 -2
  139. package/src/hooks/worktree-guard.js +3 -3
  140. package/src/index.js +41 -6
  141. package/src/knowledge-digest.js +23 -6
  142. package/src/knowledge-graph.js +95 -18
  143. package/src/knowledge-match.js +34 -9
  144. package/src/knowledge-stats.js +12 -13
  145. package/src/knowledge-vector.js +98 -7
  146. package/src/machine-interface.js +26 -0
  147. package/src/module-impact.js +2 -5
  148. package/src/modules.js +31 -0
  149. package/src/quicklog.js +6 -1
  150. package/src/run/archive-distill.js +3 -0
  151. package/src/run/command.js +3 -3
  152. package/src/run/complete-handlers.js +8 -3
  153. package/src/run/complete.js +113 -44
  154. package/src/run/gates.js +100 -103
  155. package/src/run/lint-ownership.js +37 -0
  156. package/src/run/prompt.js +43 -28
  157. package/src/run/stage.js +1 -1
  158. package/src/run/verify-quality-scan.js +10 -1
  159. package/src/sentinel-assertions.js +43 -1
  160. package/src/spec-sync.js +81 -2
  161. package/src/stage-contract.js +26 -3
  162. package/src/stage-review.js +46 -12
  163. package/src/stage-templates.js +2 -0
  164. package/src/stages/brainstorm.js +76 -21
  165. package/src/stages/knowledge.js +56 -5
  166. package/src/stages/plan-postcheck.js +13 -2
  167. package/src/stages/plan.js +19 -0
  168. package/src/sync.js +28 -2
  169. package/src/task-tick.js +12 -0
  170. package/src/test-bindings.js +32 -3
  171. package/src/timeline.js +60 -19
  172. package/src/ui-visual.js +119 -4
  173. package/src/verify-postcheck.js +166 -18
  174. package/src/verify-probes.js +61 -18
  175. package/src/worktree-apply.js +26 -2
  176. package/templates/agents-instruction.md +1 -1
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 };
package/src/ui-visual.js CHANGED
@@ -17,6 +17,8 @@
17
17
  */
18
18
  import { existsSync, readFileSync } from 'node:fs'
19
19
  import { join } from 'node:path'
20
+ import jsYaml from 'js-yaml'
21
+ import { parseRepoRegistry } from './stages/plan-postcheck.js'
20
22
 
21
23
  export const UI_VISUAL_PROBE_HEADING = '#### 探针 12:UI 视觉证据(分级门)'
22
24
  export const UI_EVIDENCE_FILENAME = 'visual-evidence.md'
@@ -48,8 +50,114 @@ export function detectUiTouchInPaths(paths) {
48
50
 
49
51
  /* ── flow start 须知(仓中立;证据约定与探针同源)── */
50
52
 
51
- export function buildUiGuidanceLines() {
52
- return [
53
+ /**
54
+ * local.yaml ui_prototype 段解析(2026-10-15-ui-proto-form-config,FR-01)。
55
+ * 每仓原型形态契约:form: real-code | single-html;豁免(single-html)须带 ruling{by,at,reason}
56
+ * 全三字段(用户裁决留痕——缺任一=登记不完整,消费方须警告);repos.<子仓键> 同构 override。
57
+ * fail-soft:段缺席/文件不可读/YAML 坏/值非法 → registered:false 零抛出(与 readUiVisualGate
58
+ * 同根同哲学)。诚实边界:工具只消费登记与注入指引,不校验原型质量。
59
+ */
60
+ export function readUiPrototypeForm(specBase) {
61
+ const empty = { registered: false, form: null, ruling: null, rulingComplete: false, repos: {} }
62
+ if (!specBase) return empty
63
+ let raw
64
+ try {
65
+ raw = jsYaml.load(readFileSync(join(specBase, 'local.yaml'), 'utf8'))
66
+ } catch { return empty }
67
+ const section = raw && typeof raw === 'object' ? raw.ui_prototype : null
68
+ if (!section || typeof section !== 'object') return empty
69
+ const parseEntry = (e) => {
70
+ if (!e || typeof e !== 'object') return null
71
+ const form = e.form === 'real-code' || e.form === 'single-html' ? e.form : null
72
+ if (!form) return null
73
+ const r = e.ruling && typeof e.ruling === 'object' ? e.ruling : null
74
+ const rulingComplete = !!(r && r.by && r.at && r.reason)
75
+ return { form, ruling: r, rulingComplete }
76
+ }
77
+ const top = parseEntry(section)
78
+ const repos = {}
79
+ if (section.repos && typeof section.repos === 'object') {
80
+ for (const [key, val] of Object.entries(section.repos)) {
81
+ const entry = parseEntry(val)
82
+ if (entry) repos[key] = entry
83
+ }
84
+ }
85
+ if (!top) return { registered: false, form: null, ruling: null, rulingComplete: false, repos }
86
+ return { registered: true, ...top, repos }
87
+ }
88
+
89
+ /**
90
+ * 多仓 override 解析(FR-02):触达路径与 local.yaml repos 注册匹配——子仓若在
91
+ * ui_prototype.repos 有 override 则该形态优先;未命中走仓级兜底。返回形态条目数组(去重):
92
+ * 每项 { scope: '仓级'|'<子仓键>', form, ruling, rulingComplete, registered }。
93
+ * 匹配三通道(评审 P2 清偿:注入语料常为相对路径,纯绝对前缀会让 override 静默失效):
94
+ * ① 绝对前缀:归一(反斜杠→斜杠/去尾斜杠/大小写不敏感)后 startsWith 注册路径;
95
+ * ② 相对键前缀:路径以 `<repoKey>/` 开头(语料常见 `spdemo/src/...` 形态);
96
+ * ③ 相对基名前缀:路径以注册路径 basename + `/` 开头(键与目录名不同名时兜底)。
97
+ */
98
+ export function resolveUiPrototypeFor(specBase, paths = []) {
99
+ const proto = readUiPrototypeForm(specBase)
100
+ const out = []
101
+ const seen = new Set()
102
+ const push = (entry) => {
103
+ const key = `${entry.form}|${entry.rulingComplete}`
104
+ if (seen.has(key)) return
105
+ seen.add(key)
106
+ out.push(entry)
107
+ }
108
+ if (paths.length > 0) {
109
+ let repoPaths = {}
110
+ try {
111
+ // 复用既有注册解析(plan-postcheck _parseRepoEntries 同语义出口:字符串条目=路径、
112
+ // 对象条目取 path 子键)——不走 js-yaml 裸读,防对象形态静默漏配。
113
+ repoPaths = Object.fromEntries(parseRepoRegistry(readFileSync(join(specBase, 'local.yaml'), 'utf8')))
114
+ } catch { /* 解析失败=零子仓命中,仓级兜底 */ }
115
+ const norm = (p) => String(p).replace(/\\/g, '/').replace(/\/+$/, '').toLowerCase()
116
+ for (const [key, registeredPath] of Object.entries(repoPaths)) {
117
+ if (typeof registeredPath !== 'string' || !registeredPath) continue
118
+ const reg = norm(registeredPath)
119
+ const regBase = reg.split('/').pop()
120
+ const hit = paths.some((p) => {
121
+ const np = norm(p)
122
+ return np.startsWith(reg + '/') || np === reg
123
+ || np.startsWith(key.toLowerCase() + '/')
124
+ || np.startsWith(regBase + '/')
125
+ })
126
+ const override = proto.repos[key]
127
+ if (hit && override) push({ scope: key, registered: true, ...override })
128
+ }
129
+ }
130
+ push(proto.registered
131
+ ? { scope: '仓级', registered: true, form: proto.form, ruling: proto.ruling, rulingComplete: proto.rulingComplete }
132
+ : { scope: '仓级', registered: false, form: null, ruling: null, rulingComplete: false })
133
+ return out
134
+ }
135
+
136
+ /** 须知第 5 节:按形态条目组合「本仓原型形态」行(FR-03 四态;生态命令词禁入——neutrality 契约)。 */
137
+ function uiPrototypeFormLines(entries) {
138
+ const lines = [`📐 本仓原型形态(local.yaml ui_prototype 登记;工具只查声明在场不验质量):`]
139
+ let anyContent = false
140
+ for (const e of entries) {
141
+ const scope = e.scope === '仓级' ? '' : `【子仓 ${e.scope}】`
142
+ if (!e.registered) {
143
+ anyContent = true
144
+ lines.push(` ⚠️ ${scope}未登记原型形态——本次按阶梯执行:有真实前端工具链→定稿原型必须真码;查无真码管线→先单开一个轻量变更搭最小入口(管线建设与业务变更分离),或登记豁免(form: single-html + ruling 用户裁决留痕)。收口前把 local.yaml ui_prototype 登记补上(强提醒,不阻断)。`)
145
+ } else if (e.form === 'single-html' && !e.rulingComplete) {
146
+ anyContent = true
147
+ lines.push(` ⚠️ ${scope}豁免登记不完整(single-html 缺 ruling{by,at,reason} 某字段)——这是静默漂移入口:补齐用户裁决留痕(by/at/reason)后再依赖豁免;本次仍按阶梯执行。`)
148
+ } else if (e.form === 'single-html') {
149
+ anyContent = true
150
+ lines.push(` 📋 ${scope}豁免生效(single-html;裁决 by=${e.ruling.by} at=${e.ruling.at}):定稿原型=自包含 HTML + 真值 token(来源须声明:token 源文件/组件参照),反向翻译税为已裁决接受项。`)
151
+ } else {
152
+ anyContent = true
153
+ lines.push(` 🔒 ${scope}真码契约(real-code):定稿原型必须与实现同方言真码;查无管线先单开轻量变更搭最小入口(bootstrap 与业务变更分离)。`)
154
+ }
155
+ }
156
+ return anyContent ? lines : []
157
+ }
158
+
159
+ export function buildUiGuidanceLines(specBase = null, paths = []) {
160
+ const lines = [
53
161
  `🎨 UI 变更执行须知(本变更触及前端页面或 UI——advisory 引导非门禁):`,
54
162
  ` 1. 定稿原型必须是可复跑的真码产物(与实现同方言、能被本项目工具链构建)——手绘单文件`,
55
163
  ` 仅限一次性粗选对比,不作为交付原型(2026-09-28 frontend-apple-style 实证:手绘定稿后每条意见都要反向翻译回真实代码);`,
@@ -62,6 +170,13 @@ export function buildUiGuidanceLines() {
62
170
  ` 必须停下找用户签字,裁决记录写进 ${UI_EVIDENCE_FILENAME}(含「用户裁决」字样段)——`,
63
171
  ` 静默降级会在收口被「UI 视觉证据」探针硬拦(error 门)。`,
64
172
  ]
173
+ if (specBase) {
174
+ try {
175
+ const entries = resolveUiPrototypeFor(specBase, paths)
176
+ lines.push('', ...uiPrototypeFormLines(entries))
177
+ } catch { /* 登记解析失败零打扰(fail-soft 同 readUiVisualGate) */ }
178
+ }
179
+ return lines
65
180
  }
66
181
 
67
182
  /* ── 分级探针 ── */
@@ -173,8 +288,8 @@ export function uiEvidenceExecuteAdvisory({ changeDir, specBase = null }) {
173
288
  }
174
289
  }
175
290
 
176
- /** design 文件变更清单行的路径粗提(反引号/裸路径行——探针侧仅作 UI 扩展名兜底命中,不追求全解)。 */
177
- function extractQuotedPaths(designText) {
291
+ /** design 文件变更清单行的路径粗提(反引号/裸路径行——探针侧仅作 UI 扩展名兜底命中,不追求全解;导出供 prompt.js 组子仓 override 匹配路径用)。 */
292
+ export function extractQuotedPaths(designText) {
178
293
  if (!designText) return []
179
294
  const paths = []
180
295
  for (const m of designText.matchAll(/`([^`]+)`/g)) {
@@ -2821,6 +2821,31 @@ function inferFullTestCommand(projectRoot) {
2821
2821
  * 相关回归)。组卷改配额制:每组优先面整跑,普通 import 依赖只填剩余席位(可为 0)——帽
2822
2822
  * 只界普通依赖尾。批尺寸上界护栏由 TEST_TIMEOUT_MS 兜底(超时=失败 fail-closed 不变)。
2823
2823
  */
2824
+ /**
2825
+ * 批命令长度分块(2026-10-15 平台侧实证,跨仓事故驱动最小修复):Windows cmd 执行器
2826
+ * 命令行上限 ~8191 字符——大依赖面(广被引用的前端文件变更 → 依赖测试 100+ 个)拼单条
2827
+ * vitest/node --test 命令必炸「命令行太长」exit 1,且失败批进增量账本自我循环恒败
2828
+ * (实测:137 文件批反复重跑复败,verify 门禁无法收口)。按「命令前缀+文件参数拼接
2829
+ * 总长」分块:单块保持原名(零回归),多块命名 <base>:N(N 从 1 起);每块独立计账。
2830
+ */
2831
+ function chunkByCommandLength(prefix, files, maxLen = 6000) {
2832
+ const chunks = []
2833
+ let cur = []
2834
+ let curLen = prefix.length
2835
+ for (const f of files) {
2836
+ const add = f.length + 1
2837
+ if (cur.length > 0 && curLen + add > maxLen) {
2838
+ chunks.push(cur)
2839
+ cur = []
2840
+ curLen = prefix.length
2841
+ }
2842
+ cur.push(f)
2843
+ curLen += add
2844
+ }
2845
+ if (cur.length > 0) chunks.push(cur)
2846
+ return chunks
2847
+ }
2848
+
2824
2849
  function buildDepsBatches({ deps, changedFiles = [], hits = [], cwd = null, priorityFiles = [] }) {
2825
2850
  const norm = (p2) => String(p2).replace(/\\/g, '/')
2826
2851
  const changedSet = new Set((changedFiles || []).map(norm))
@@ -2930,10 +2955,11 @@ function buildDepsBatches({ deps, changedFiles = [], hits = [], cwd = null, prio
2930
2955
  const jsProjectRun = jsProject.filter(f => !E2E_DIR_RE.test(_norm(f)))
2931
2956
  const e2eDroppedFiles = jsProject.filter(f => E2E_DIR_RE.test(_norm(f)))
2932
2957
  let jsxRunner = null
2958
+ let jsxRunnerFromHits = false
2933
2959
  for (const h of hits || []) {
2934
2960
  const cmd = String(h.test || '')
2935
2961
  const m = /^((?:cd\s+[^&|;]+&&\s*)*[^&|;]*?(?:vitest|jest))(?=\s|$)/.exec(cmd) || /(?:^|&&|;|\|\|)\s*([^&|;]*(?:vitest|jest)(?:\s|$))/.exec(cmd)
2936
- if (m) { jsxRunner = m[1].trim(); break }
2962
+ if (m) { jsxRunner = m[1].trim(); jsxRunnerFromHits = true; break }
2937
2963
  }
2938
2964
  if (!jsxRunner && cwd && jsProjectRun.length > 0) {
2939
2965
  const inferred = inferJsxRunner(cwd, jsProjectRun[0])
@@ -2958,24 +2984,59 @@ function buildDepsBatches({ deps, changedFiles = [], hits = [], cwd = null, prio
2958
2984
  gi++
2959
2985
  }
2960
2986
  }
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 })
2987
+ if (jsNative.length > 0) {
2988
+ const jsPrefix = 'node --test --test-reporter=spec --test-reporter-destination=stderr --test-reporter=tap --test-reporter-destination=stdout '
2989
+ const jsChunks = chunkByCommandLength(jsPrefix, jsNative)
2990
+ jsChunks.forEach((chunk, i) => {
2991
+ const isMulti = jsChunks.length > 1
2992
+ batches.push({ name: isMulti ? `deps(auto-js:${i + 1}/${jsChunks.length})` : 'deps(auto-js)', short: 'js', command: jsPrefix + chunk.join(' '), count: chunk.length, prioCount: chunk.filter(f => prio(f) === 0).length, dropped: i === 0 ? (jsTest.length - jsRun.length) : 0, prioDropped: i === 0 ? jsQ.prioDropped : 0, tap: true, files: chunk })
2993
+ })
2994
+ }
2962
2995
  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 承担' })
2963
2996
 
2964
2997
  if (nonTestFiles.length > 0) batches.push({ name: 'deps(auto-nontest-skip)', short: 'nontest-skip', command: null, count: nonTestFiles.length, dropped: 0, skip: true, files: nonTestFiles, reason: `非测试形态文件 ${nonTestFiles.length} 个不进单测执行批(node --test 语义「文件即测试」/ vitest No test suite found / pytest 0 collected exit 5——直跑必假败;坑 verify-dynamic-subset-node-test-ts-source):${nonTestFiles.slice(0, 3).join('、')}${nonTestFiles.length > 3 ? ' 等' : ''}——FR 绑定面脏数据可 sillyspec tests --unbind --tests <路径> 清行` })
2965
2998
  if (jsProjectRun.length > 0) {
2966
- if (jsxRunner) {
2967
- const isVitest = /vitest/.test(jsxRunner)
2968
- // jsx 批的 cd 重定基(评审 MEDIUM 清偿):cdDir 只源 pyRunner——jsxRunner 自带 cd 前缀
2969
- // (cd frontend && pnpm exec vitest)时文件未重定基产双前缀错路径;独立提取 jsxDir。
2970
- const jsxDir = /(?:^|\s)cd\s+(\S+)\s*&&/.exec(jsxRunner)?.[1]
2971
- const rebaseJsx = (f) => (jsxDir && f.startsWith(jsxDir + '/')) ? f.slice(jsxDir.length + 1) : f
2972
- // R20(2026-09-27-thin-display-fix 实证,用户授权修复):Windows cmd 执行器对含
2973
- // 括号/方括号/空格的路径(src/app/(dashboard)/workspaces/[id]/...)不加引号会以
2974
- // 「此时不应有 <。」语法错误炸整批——文件参数含特殊字符时逐个 wrap 双引号。
2975
- const quoteWinPath = (f) => (/[()\[\]\s&^%,;!]/.test(f) ? `"${f}"` : f)
2976
- batches.push({ name: 'deps(auto-jsx)', short: 'jsx', command: `${jsxRunner} ${isVitest ? 'run ' : ''}${jsProjectRun.map(rebaseJsx).map(quoteWinPath).join(' ')}`, count: jsProjectRun.length, dropped: 0, files: jsProjectRun })
2977
- } else {
2978
- batches.push({ name: 'deps(auto-jsx-skip)', short: 'jsx-skip', command: null, count: jsProjectRun.length, dropped: 0, skip: true, files: jsProjectRun, reason: `JSX 测试文件(tsx/jsx,${jsProjectRun.length} 个)node 原生不可跑且项目结构无 vitest/jest 运行器可推断——转项目运行器执行并如实披露,不制造恒败段(R18-SF-full 残差段 11 连败的坑)` })
2999
+ // R20(2026-09-27-thin-display-fix 实证,用户授权修复):Windows cmd 执行器对含
3000
+ // 括号/方括号/空格的路径(src/app/(dashboard)/workspaces/[id]/...)不加引号会以
3001
+ // 「此时不应有 <。」语法错误炸整批——文件参数含特殊字符时逐个 wrap 双引号。
3002
+ const quoteWinPath = (f) => (/[()\[\]\s&^%,;!]/.test(f) ? `"${f}"` : f)
3003
+ // jsx 批按包根分组(2026-10-15-zcode-engine-appserver verify 实证,跨仓事故驱动修复):
3004
+ // 混合包根(frontend/ 的 tsx 测试 + sillyhub-daemon/ 的 ts 测试同批)共享单一 runner
3005
+ // 前缀时,非本根路径在 runner 的 cwd 下「No test files found」exit 1 恒败(分块修复
3006
+ // 后暴露:第二块全 daemon 路径仍跑 cd frontend)。与 pyGroups 同款:每文件按最近
3007
+ // package.json 推断自身 runner/根;显式命中命令(hits)保持整批单组旧语义。
3008
+ const jsxGroups = new Map() // key: 组命令前缀
3009
+ const jsxUngrouped = []
3010
+ for (const f of jsProjectRun) {
3011
+ // 命中命令(hits)→ 整批单组旧语义;全局推断(jsProjectRun[0])仅兜底,逐文件按自身包根推断优先。
3012
+ let command = null
3013
+ let dirKey = null
3014
+ if (!jsxRunnerFromHits && cwd) {
3015
+ const inferred = inferJsxRunner(cwd, f)
3016
+ if (inferred) { command = inferred.command; dirKey = inferred.dir || null }
3017
+ }
3018
+ if (!command) command = jsxRunner
3019
+ if (!command) { jsxUngrouped.push(f); continue }
3020
+ if (!jsxGroups.has(command)) jsxGroups.set(command, { files: [], dirKey })
3021
+ jsxGroups.get(command).files.push(f)
3022
+ }
3023
+ const multiGroup = jsxGroups.size > 1
3024
+ for (const [command, grp] of jsxGroups) {
3025
+ const isVitest = /vitest/.test(command)
3026
+ const jsxPrefix = `${command} ${isVitest ? 'run ' : ''}`
3027
+ const grpDir = grp.dirKey || /(?:^|\s)cd\s+(\S+)\s*&&/.exec(command)?.[1] || null
3028
+ const rebaseJsx = (f) => (grpDir && f.startsWith(grpDir + '/')) ? f.slice(grpDir.length + 1) : f
3029
+ const jsxArgs = grp.files.map(rebaseJsx).map(quoteWinPath)
3030
+ const jsxChunks = chunkByCommandLength(jsxPrefix, jsxArgs)
3031
+ const baseName = multiGroup ? `deps(auto-jsx:${grp.dirKey || 'root'})` : 'deps(auto-jsx)'
3032
+ jsxChunks.forEach((chunk, i) => {
3033
+ const isMulti = jsxChunks.length > 1
3034
+ batches.push({ name: isMulti ? `${baseName}:${i + 1}/${jsxChunks.length}` : baseName, short: 'jsx', command: jsxPrefix + chunk.join(' '), count: chunk.length, dropped: 0, files: chunk })
3035
+ })
3036
+ }
3037
+ if (jsxUngrouped.length > 0) {
3038
+ // 无运行器可推断的文件(含 !jsxRunner && !cwd 全量形态):skip 批如实披露(原整批 skip 语义按文件收窄)。
3039
+ batches.push({ name: multiGroup || jsxGroups.size > 0 ? 'deps(auto-jsx-skip:unresolved)' : 'deps(auto-jsx-skip)', short: 'jsx-skip', command: null, count: jsxUngrouped.length, dropped: 0, skip: true, files: jsxUngrouped, reason: `JSX 测试文件(tsx/jsx,${jsxUngrouped.length} 个)node 原生不可跑且各自最近 package.json 无 vitest/jest 运行器可推断——转项目运行器执行并如实披露,不制造恒败段(R18-SF-full 残差段 11 连败的坑)` })
2979
3040
  }
2980
3041
  }
2981
3042
  if (e2eDroppedFiles.length > 0) {
@@ -3042,6 +3103,26 @@ function traceRepoRoots({ specBase, cwd }) {
3042
3103
  }
3043
3104
 
3044
3105
  /** 残差解析:active·非 superseded·锚命中的 trace 行 → { anchors, rows, files, dangling, perFile } */
3106
+ /**
3107
+ * 主仓侧候选解析根(worktree 感知;2026-10-15-zcode-engine-appserver verify 实证,跨仓
3108
+ * 事故驱动最小修复):变更在隔离 worktree 内实现时,新增测试文件在主仓根不可见——
3109
+ * 悬空判定对主仓文件按「主仓根 ∪ 变更 worktree 根」多根解析(任一存在即非悬空);
3110
+ * 残差执行根同款(文件在 worktree 存在则从 worktree 根跑)。meta 缺失/不可读 → 仅主仓。
3111
+ */
3112
+ function traceMainRoots({ specBase, changeName, cwd }) {
3113
+ const roots = [cwd]
3114
+ try {
3115
+ const metaPath = join(specBase, '.runtime', 'worktrees', changeName, 'meta.json')
3116
+ if (existsSync(metaPath)) {
3117
+ const meta = JSON.parse(readFileSync(metaPath, 'utf8'))
3118
+ if (meta && typeof meta.worktreePath === 'string' && meta.worktreePath && existsSync(meta.worktreePath) && meta.worktreePath !== cwd) {
3119
+ roots.push(meta.worktreePath)
3120
+ }
3121
+ }
3122
+ } catch { /* meta 不可读 → 仅主仓(零回归) */ }
3123
+ return roots
3124
+ }
3125
+
3045
3126
  export function resolveTraceResidual({ specBase, changeName, cwd }) {
3046
3127
  const anchors = resolveVerifyAnchorSet({ specBase, changeName })
3047
3128
  if (anchors.length === 0) return { anchors, rows: [], files: [], dangling: [], perFile: [] }
@@ -3050,6 +3131,7 @@ export function resolveTraceResidual({ specBase, changeName, cwd }) {
3050
3131
  // tests 条目可携带用例锚(2026-09-26-binding-anchor-fidelity)——文件面/悬空判定走剥锚。
3051
3132
  // FR-06(2026-10-09):文件按行 repo 归属配对(perFile),悬空判定/残差执行按行根解析。
3052
3133
  const repoRoots = traceRepoRoots({ specBase, cwd })
3134
+ const mainRoots = traceMainRoots({ specBase, changeName, cwd })
3053
3135
  const seen = new Set()
3054
3136
  const perFile = []
3055
3137
  for (const r of rows) {
@@ -3061,8 +3143,13 @@ export function resolveTraceResidual({ specBase, changeName, cwd }) {
3061
3143
  }
3062
3144
  }
3063
3145
  const files = [...new Set(perFile.map(x => x.file))].sort()
3146
+ // 主仓文件多根解析:解析到 worktree 根则记录 resolvedRoot(残差执行同款消费)。
3147
+ for (const x of perFile) {
3148
+ if (x.repo) { x.resolvedRoot = repoRoots.get(x.repo) || cwd; continue }
3149
+ x.resolvedRoot = mainRoots.find(root => existsSync(resolve(root, x.file))) || cwd
3150
+ }
3064
3151
  const dangling = perFile
3065
- .filter(x => !existsSync(resolve((x.repo && repoRoots.get(x.repo)) || cwd, x.file)))
3152
+ .filter(x => !existsSync(resolve(x.resolvedRoot, x.file)))
3066
3153
  .map(x => x.repo ? `${x.repo}:${x.file}` : x.file)
3067
3154
  return { anchors, rows, files, dangling, perFile }
3068
3155
  }
@@ -3141,7 +3228,8 @@ export function applyTraceResidual({ mainResult, action, cwd, specBase, changeNa
3141
3228
  for (const x of (tr.perFile || [])) {
3142
3229
  if (provable.has(String(x.file).replace(/\\/g, '/'))) continue
3143
3230
  const _rk = x.repo || null
3144
- const _root = (_rk && _repoRoots.get(_rk)) || cwd
3231
+ // worktree 感知(同悬空判定口径):主仓行按 resolvedRoot(主仓或变更 worktree 根)取执行根。
3232
+ const _root = (_rk && _repoRoots.get(_rk)) || x.resolvedRoot || cwd
3145
3233
  const _gk = _rk + '' + _root
3146
3234
  if (!_gmap.has(_gk)) _gmap.set(_gk, { repo: _rk, root: _root, files: [] })
3147
3235
  _gmap.get(_gk).files.push(x.file)
@@ -3284,8 +3372,20 @@ export function runModuleSubset({ cwd, specBase, changeName, hits, knownFailures
3284
3372
  // not ok 以测试文件为套件名(not ok N - test/b.test.mjs),用判账行集匹配批内文件名把
3285
3373
  // 「批失败」收窄到「文件失败」;归因不出(非 TAP/行集缺文件名)保守全记批文件。
3286
3374
  failedFiles: (() => {
3375
+ // 基建失败甄别(坑 verify-incremental-rerun-infra-batch-growth,2026-10-15 两变更实证:
3376
+ // Windows argv 超限/runner 启动失败类批失败无任何测试级失败行,重跑不可能转绿——计入
3377
+ // 前轮失败批只会让增量面随轮膨胀(141→159 自锁)。判据:输出无 not ok/FAILED/ERROR/
3378
+ // AssertionError 任何测试级痕迹,且 reason 命中基建关键词(命令行太长/E2BIG/spawn/
3379
+ // Cannot find module/No test files found)——此类批不进失败批账本,loud 留痕供人工。
3380
+ const INFRA_REASON_RE = /命令行太长|E2BIG|spawn|Cannot find module|No test files found|ENAMETOOLONG/i
3381
+ const TEST_FAILURE_RE = /not ok \d|FAILED|ERROR|AssertionError|assert/i
3287
3382
  const out = new Set()
3288
3383
  for (const r of perModule.filter(x => x.status === 'failed')) {
3384
+ const text = String(r.outputFull || r.outputTail || '')
3385
+ if (!TEST_FAILURE_RE.test(text) && INFRA_REASON_RE.test(String(r.reason || '') + text)) {
3386
+ console.warn(`⚠️ 批 ${r.name} 判定基建失败(${String(r.reason || 'runner 层失败').slice(0, 80)})——不进增量失败批账本(重跑不可能转绿),修复基建后整轮重测`)
3387
+ continue
3388
+ }
3289
3389
  for (const f of attributeFailedFiles(r)) out.add(f)
3290
3390
  }
3291
3391
  return [...out].sort()
@@ -4223,6 +4323,51 @@ export function resolveReconcileActualFiles({ cwd, specBase, runtimeRoot, change
4223
4323
  }
4224
4324
  }
4225
4325
 
4326
+ // —— EOL-only 噪声过滤(坑 verify-close-gate-staggered-exposure 建议④,2026-10-15 消费仓实证:
4327
+ // gen:types 幂等重生成纯行尾 diff 进 actual 面 → ③类 undeclared 假红,agent 被迫为零内容
4328
+ // 改动补声明或回退)。手法与 run/shared.js quick 审计的 eolOnly 先例同源:同一 git 范围
4329
+ // 跑 plain 名单与 --ignore-cr-at-eol 名单,差集 = 纯行尾差异 → 剔出对账面,eolOnlyFiles
4330
+ // 软留痕。两条硬边界:① 必须正向出现在 plain diff 且内容零变更才剔——absence-based
4331
+ // (「不在真实集即剔」)会误伤 B3 pathspec/救援窗口来源的已交付文件(它们不在任何
4332
+ // git diff 里);② --name-only 不吃 --ignore-cr-at-eol(git 实测怪癖),内容过滤必须走
4333
+ // --numstat。探测失败 fail-open 不过滤;untracked 天然不在 plain diff 不受影响;删除/
4334
+ // 重命名是内容变更(numstat 在场)不受影响。——
4335
+ const eolOnlyFiles = []
4336
+ try {
4337
+ const eolOnlySet = new Set()
4338
+ const probePair = (dir, refs) => {
4339
+ const plainOut = gitQuiet(dir, ['diff', '--name-only', ...refs], { timeout: 30 * 1000, trim: false })
4340
+ if (plainOut === null) return
4341
+ const realOut = gitQuiet(dir, ['diff', '--numstat', '--ignore-cr-at-eol', ...refs], { timeout: 30 * 1000, trim: false })
4342
+ if (realOut === null) return
4343
+ const real = new Set()
4344
+ for (const l of realOut.split('\n')) {
4345
+ const i = l.lastIndexOf('\t')
4346
+ if (i > 0) real.add(normalizeReconcilePath(l.slice(i + 1)))
4347
+ }
4348
+ for (const f of plainOut.split('\n').map(x => x.trim()).filter(Boolean)) {
4349
+ const n = normalizeReconcilePath(f)
4350
+ if (!real.has(n)) eolOnlySet.add(n)
4351
+ }
4352
+ }
4353
+ // 工作树(HEAD 对照):主仓 + worktree(form A 双仓各自 HEAD)
4354
+ const probeDirs = [cwd]
4355
+ if (form === 'worktree' && wtMeta && wtMeta.gitDir && wtMeta.gitDir !== cwd) probeDirs.push(wtMeta.gitDir)
4356
+ for (const dir of probeDirs) probePair(dir, ['HEAD'])
4357
+ // 已提交侧:form B B1 同基点(mergeBase 对照工作树);form A worktree 已提交段(meta 锚点..HEAD)
4358
+ if (form === 'post-apply' && baseAnchor) {
4359
+ probePair(cwd, [baseAnchor])
4360
+ } else if (form === 'worktree' && wtMeta) {
4361
+ const mb = wtMeta.meta && (wtMeta.meta.baselineCommit || wtMeta.meta.actualBaseHash || wtMeta.meta.baseHash)
4362
+ if (mb) probePair(wtMeta.gitDir, [mb, 'HEAD'])
4363
+ }
4364
+ for (const f of eolOnlySet) {
4365
+ if (union.has(f)) {
4366
+ union.delete(f)
4367
+ eolOnlyFiles.push(f)
4368
+ }
4369
+ }
4370
+ } catch { /* EOL 探测异常 → 不过滤(fail-open,行为与修复前一致) */ }
4226
4371
  const files = [...new Set(filterDeliverableFiles([...union]).filter(Boolean))].sort()
4227
4372
  // 多会话推进死锁诊断(reconcile-source-isolation,R18-SF-full 实证):post-apply 形态下
4228
4373
  // 实际面全空(B1 无 ref + porcelain 无未提交面 + 无 apply-pathspec 兜底)= 典型「主仓被
@@ -4232,7 +4377,7 @@ export function resolveReconcileActualFiles({ cwd, specBase, runtimeRoot, change
4232
4377
  const parallelAdvanceHint = (form === 'post-apply' && files.length === 0 && union.size === 0 && !sources.some((s) => s.startsWith('main:diff-merge-base') || s.startsWith('apply-pathspec')))
4233
4378
  ? '⚠️ actual 源全空(无分支锚点 diff + 主仓无未提交面 + 无 apply-pathspec 兜底)——典型「主仓被并行会话推进」形态(本变更的提交/暂存可能已被他侧裸提交扫走)。切勿用「暂存物化」自救(touch 文件进共享暂存区——会被再次扫走且污染他侧,R18-SF-full 实证 matched=0 死循环);出路:① 恢复/登记本变更分支(worktree meta.json 的 branch 字段)让锚定 diff 生效后重跑 ② 确属全量已在他侧提交:与该会话按 AGENTS 规则 18 对账后以该提交为锚核销'
4234
4379
  : null
4235
- return { ok: true, form, files, sources, foreignExcluded, foreignExcludedFiles, degradedReason: null, baseAnchor, commitWindowFiles, parallelAdvanceHint }
4380
+ return { ok: true, form, files, sources, foreignExcluded, foreignExcludedFiles, degradedReason: null, baseAnchor, commitWindowFiles, parallelAdvanceHint, eolOnlyFiles }
4236
4381
  }
4237
4382
 
4238
4383
  /**
@@ -4462,6 +4607,9 @@ export function reconcileTargetFiles({ cwd, specBase = null, changeName = null,
4462
4607
  notes.push(`③类另有 ${scaffoldTotal} 个工具/平台脚手架文件(platform ${scaffoldBuckets.platform} / tool ${scaffoldBuckets.tool},如 ${scaffoldSamples.join('、')}${scaffoldTotal > scaffoldSamples.length ? ' 等' : ''})已聚合不逐条列——非交付物(scope-audit 设施桶同口径)`)
4463
4608
  }
4464
4609
 
4610
+ if (Array.isArray(actual.eolOnlyFiles) && actual.eolOnlyFiles.length > 0) {
4611
+ notes.push(`EOL-only 剔除:${actual.eolOnlyFiles.length} 个文件仅行尾差异(--ignore-cr-at-eol 对照零内容变更,如 gen 脚本幂等重生成)已剔出对账面——${actual.eolOnlyFiles.slice(0, 5).join('、')}${actual.eolOnlyFiles.length > 5 ? ' 等' : ''}(.gitattributes 统一行尾可根治)`)
4612
+ }
4465
4613
  // 状态聚合:②在场即 ERROR 态优先(gates 阻断语义靠它兑现);仅③ → WARNING 态
4466
4614
  const status = missing.length > 0 ? 'missing_declared' : (undeclared.length > 0 ? 'undeclared' : 'ok')
4467
4615
  return { status, matched, missing, undeclared, undeclaredScaffold: scaffoldTotal, skipReason: null, notes, form: actual.form, sources: actual.sources, crossRepo }