sillyspec 3.28.10 → 3.28.12

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 (52) hide show
  1. package/.claude/skills/sillyspec-execute/SKILL.md +223 -222
  2. package/.claude/skills/sillyspec-plan/SKILL.md +156 -156
  3. package/.claude/skills/sillyspec-quick/SKILL.md +8 -10
  4. package/.claude/skills/sillyspec-verify/SKILL.md +91 -87
  5. package/.husky/pre-commit +4 -0
  6. package/.husky/pre-push +3 -0
  7. package/AGENTS.md +1 -1
  8. package/package.json +1 -1
  9. package/src/change-risk-profile.js +76 -6
  10. package/src/commit-guard.js +119 -0
  11. package/src/config-schema.js +23 -0
  12. package/src/design-facts.js +46 -9
  13. package/src/diagnostic-codes.js +95 -0
  14. package/src/docs-check.js +14 -10
  15. package/src/docs-debt.js +7 -2
  16. package/src/docs-gate.js +42 -15
  17. package/src/doctor-diagnostics.js +141 -1
  18. package/src/endpoint-extractor.js +50 -2
  19. package/src/foreign-declared.js +25 -16
  20. package/src/hooks/worktree-guard.js +75 -2
  21. package/src/index.js +129 -1
  22. package/src/machine-interface.js +63 -0
  23. package/src/probe7-anchor-check.js +20 -2
  24. package/src/progress/stage-machine.js +34 -5
  25. package/src/progress.js +1 -1
  26. package/src/quicklog.js +231 -1
  27. package/src/run/complete-handlers.js +172 -1
  28. package/src/run/complete.js +73 -3
  29. package/src/run/gate-snapshot.js +262 -0
  30. package/src/run/gates.js +41 -7
  31. package/src/run/prompt.js +79 -1
  32. package/src/run/shared.js +10 -0
  33. package/src/run/verify-quality-scan.js +151 -5
  34. package/src/stage-contract-spec.js +14 -0
  35. package/src/stage-contract.js +738 -8
  36. package/src/stage-review-checklist.js +16 -2
  37. package/src/stage-review.js +137 -2
  38. package/src/stages/archive.js +9 -5
  39. package/src/stages/brainstorm.js +7 -1
  40. package/src/stages/execute.js +62 -8
  41. package/src/stages/plan-postcheck.js +42 -5
  42. package/src/stages/plan.js +8 -1
  43. package/src/stages/quick.js +5 -5
  44. package/src/stages/verify.js +8 -3
  45. package/src/sync.js +69 -0
  46. package/src/task-review.js +9 -3
  47. package/src/verify-facts-schema.js +95 -10
  48. package/src/verify-postcheck.js +346 -28
  49. package/src/verify-probes.js +1221 -46
  50. package/src/worktree-apply.js +175 -3
  51. package/src/write-audit.js +55 -0
  52. package/.claude/full.json +0 -1
package/src/docs-gate.js CHANGED
@@ -16,8 +16,16 @@
16
16
  * 远端 origin/main 已合入的失效消化/新增不会回流本地基线——出现「基线 371 < origin/main
17
17
  * 实测 379、本地 current 379」时 ratchet 拦 379>371,但本次推送零增量(不劣于远端)——
18
18
  * ratchet 本质=拦增量,被陈旧基线破坏成拦存量。修复:current > baseline 分支先实测
19
- * origin/main 树(临时 detach worktree 跑 runDocsCheck),current ≤ 实测值即放行 + 提示
20
- * 重锚;实测失败/无远端 ref fail-open 回原拦。快路径(current ≤ baseline)零成本零变化。
19
+ * origin/main 树(临时 detach worktree 跑 runDocsCheck),current ≤ 实测值即放行;实测
20
+ * 失败/无远端 ref fail-open 回原拦。快路径(current ≤ baseline)零成本零变化。
21
+ *
22
+ * 陈旧分支自动重锚(2026-09-17-docs-bracket-reanchor,D-002@v1):走到该分支即已实付
23
+ * 远端实测成本验证「本次不劣于远端」——已验证事实自动回流基线:口径守卫通过即
24
+ * writeBaseline(current) + 消息披露(陈旧提示出现一次即消失,棘轮只紧不松:新基线 =
25
+ * current ≤ 远端实测,每一分增量都有实测背书)。守卫只拦 checkOpts 一次性覆盖(paths/
26
+ * skip/keywordAssert/crossRepoRoots 四键——异口径计数写盘会错调基线),不拦 local.yaml
27
+ * 持久口径(measure 与 current 恒同读该配置,读写同源自洽);首次立线 fail-closed 不变
28
+ * (无基线仍须显式 --init-baseline,自动重锚仅限基线已存在的陈旧分支)。
21
29
  */
22
30
  import { readFileSync, writeFileSync, existsSync, mkdtempSync, rmSync } from 'node:fs';
23
31
  import { join } from 'node:path';
@@ -138,12 +146,19 @@ export async function measureRemoteBaselineCount(projectRoot, remoteRefs = ['ori
138
146
  * IO 入口:跑一次 gate。
139
147
  * @param {{ projectRoot: string, specBase: string, initBaseline?: boolean }} opts
140
148
  * projectRoot 源码仓根(docs check 锚);specBase .sillyspec 根(基线文件所在)
141
- * @param {object} checkOpts 透传 runDocsCheck(paths/skip/keywordAssert 覆盖;缺省读 local.yaml)
149
+ * @param {object} checkOpts 透传 runDocsCheck(paths/skip/keywordAssert/crossRepoRoots 覆盖;缺省
150
+ * 读 local.yaml)——四键任一显式传入即触发陈旧分支口径守卫(不自动重锚,见头注)
142
151
  * @returns {Promise<{ exitCode: 0|1|2, ok: boolean, current: number, baseline: number|null,
143
- * delta: number|null, message: string, inited: boolean }>}
152
+ * delta: number|null, message: string, inited: boolean,
153
+ * reanchored: boolean }>} reanchored:陈旧分支自动重锚 true(baseline 返
154
+ * 新值 = current),其余分支缺省 false
144
155
  */
145
156
  export async function runDocsGate(opts = {}, checkOpts = {}) {
146
157
  const { projectRoot, specBase, initBaseline = false } = opts;
158
+ // 口径守卫:checkOpts 一次性子集/异口径覆盖时计数口径 ≠ 持久口径,写盘会错调基线——
159
+ // 陈旧分支不自动重锚(against 不算守卫键:与 --init-baseline --against 同写提交树
160
+ // 实测值语义一致,不新增口径)。不拦 local.yaml 持久口径(measure 与 current 恒同读)
161
+ const scopeGuarded = !!(checkOpts.paths || checkOpts.skip || checkOpts.keywordAssert != null || checkOpts.crossRepoRoots);
147
162
  let result;
148
163
  try {
149
164
  const cfg = readDocsCheckConfig(projectRoot);
@@ -159,7 +174,7 @@ export async function runDocsGate(opts = {}, checkOpts = {}) {
159
174
  });
160
175
  } catch (e) {
161
176
  if (e instanceof DocsCheckConfigError) {
162
- return { exitCode: 2, ok: false, current: null, baseline: null, delta: null, originCount: null, message: `docs gate 配置错误:${e.message}`, inited: false };
177
+ return { exitCode: 2, ok: false, current: null, baseline: null, delta: null, originCount: null, message: `docs gate 配置错误:${e.message}`, inited: false, reanchored: false };
163
178
  }
164
179
  throw e;
165
180
  }
@@ -167,7 +182,7 @@ export async function runDocsGate(opts = {}, checkOpts = {}) {
167
182
 
168
183
  if (initBaseline) {
169
184
  writeBaseline(specBase, current);
170
- return { exitCode: 0, ok: true, current, baseline: current, delta: 0, originCount: null, message: `📌 docs gate: 基线已初始化为当前实测 ${current} 处失效(${baselineDisplay(specBase)})`, inited: true };
185
+ return { exitCode: 0, ok: true, current, baseline: current, delta: 0, originCount: null, message: `📌 docs gate: 基线已初始化为当前实测 ${current} 处失效(${baselineDisplay(specBase)})`, inited: true, reanchored: false };
171
186
  }
172
187
 
173
188
  const baseline = readBaseline(specBase);
@@ -175,27 +190,39 @@ export async function runDocsGate(opts = {}, checkOpts = {}) {
175
190
  return {
176
191
  exitCode: 2, ok: false, current, baseline: null, delta: null, originCount: null,
177
192
  message: `❌ docs gate: 无基线文件(${baselineDisplay(specBase)})。首次使用先跑 sillyspec docs gate --init-baseline(以当前实测数立基线,存量既往不咎只拦增量)`,
178
- inited: false,
193
+ inited: false, reanchored: false,
179
194
  };
180
195
  }
181
196
  if (Number.isNaN(baseline)) {
182
- return { exitCode: 2, ok: false, current, baseline: null, delta: null, originCount: null, message: `❌ docs gate: 基线文件损坏(非非负整数),手工修正或 --init-baseline 重置`, inited: false };
197
+ return { exitCode: 2, ok: false, current, baseline: null, delta: null, originCount: null, message: `❌ docs gate: 基线文件损坏(非非负整数),手工修正或 --init-baseline 重置`, inited: false, reanchored: false };
183
198
  }
184
199
  const v = evaluateRatchet({ current, baseline });
185
200
  if (v.ok) {
186
201
  // 快路径(current ≤ baseline):原路零变化——不触远端实测(零 git 成本零行为漂移)
187
- return { exitCode: 0, ok: true, current, baseline, delta: v.delta, originCount: null, message: v.message, inited: false };
202
+ return { exitCode: 0, ok: true, current, baseline, delta: v.delta, originCount: null, message: v.message, inited: false, reanchored: false };
188
203
  }
189
204
  // 坑 docs-gate-stale-baseline(ql-20260915-004):current > baseline 时 origin 实测兜底——
190
205
  // ratchet 本质=拦增量;基线是静态快照,远端已合入的失效增长不回流基线会造成「未劣于远端
191
- // 也被拦」的假拦。实测 origin/main 树:current ≤ 实测值 = 本次不劣于远端 → 放行 + 提示
192
- // 重锚;无远端 ref / 实测失败 fail-open 回原拦;current > 实测值 = 真增量劣于远端 → 拦。
206
+ // 也被拦」的假拦。实测 origin/main 树:current ≤ 实测值 = 本次不劣于远端 → 放行 + 自动
207
+ // 重锚落盘(D-002@v1;口径守卫命中时不写盘、维持手动重锚建议);无远端 ref / 实测失败
208
+ // fail-open 回原拦;current > 实测值 = 真增量劣于远端 → 拦。
193
209
  const measured = await measureRemoteBaselineCount(projectRoot);
194
210
  if (measured.originCount !== null && current <= measured.originCount) {
211
+ if (scopeGuarded) {
212
+ // 守卫:checkOpts 一次性覆盖(异口径计数)——基线文件不动,维持手动 --init-baseline 建议文案
213
+ return {
214
+ exitCode: 0, ok: true, current, baseline, delta: v.delta, originCount: measured.originCount,
215
+ message: `⚠️ docs gate: 基线陈旧:基线 ${baseline} < ${measured.ref} 实测 ${measured.originCount},本次 ${current} 处失效未劣于远端不拦——建议 sillyspec docs gate --init-baseline 重锚锁定(以当前实测 ${current} 立线,存量既往不咎只拦增量)`,
216
+ inited: false, reanchored: false,
217
+ };
218
+ }
219
+ // 自动重锚:已实付实测成本验证「本次不劣于远端」——事实回流基线,同态第二次跑走快路径
220
+ //(陈旧提示与远端实测各只发生一次);新基线 = current ≤ 远端实测,棘轮只紧不松
221
+ writeBaseline(specBase, current);
195
222
  return {
196
- exitCode: 0, ok: true, current, baseline, delta: v.delta, originCount: measured.originCount,
197
- message: `⚠️ docs gate: 基线陈旧:基线 ${baseline} < ${measured.ref} 实测 ${measured.originCount},本次 ${current} 处失效未劣于远端不拦——建议 sillyspec docs gate --init-baseline 重锚锁定(以当前实测 ${current} 立线,存量既往不咎只拦增量)`,
198
- inited: false,
223
+ exitCode: 0, ok: true, current, baseline: current, delta: v.delta, originCount: measured.originCount,
224
+ message: `⚠️ docs gate: 基线陈旧:基线 ${baseline} < ${measured.ref} 实测 ${measured.originCount},本次 ${current} 处失效未劣于远端不拦——📌 已自动重锚 基线 ${baseline}→${current}(实测不劣于远端,已落盘锁定;棘轮只紧不松)`,
225
+ inited: false, reanchored: true,
199
226
  };
200
227
  }
201
228
  const remoteNote = measured.originCount !== null
@@ -204,6 +231,6 @@ export async function runDocsGate(opts = {}, checkOpts = {}) {
204
231
  return {
205
232
  exitCode: 1, ok: false, current, baseline, delta: v.delta, originCount: measured.originCount,
206
233
  message: `❌ docs gate: ${current} 处失效 > 基线 ${baseline}(新增 ${v.delta} 处)${remoteNote},拦截。修掉新增引用或显式 --init-baseline 重置基线(需你确认存量合法)`,
207
- inited: false,
234
+ inited: false, reanchored: false,
208
235
  };
209
236
  }
@@ -1125,6 +1125,144 @@ export function detectSelfMaintenanceTax(cwd, authoritySpecDir) {
1125
1125
  }
1126
1126
  }
1127
1127
 
1128
+ /**
1129
+ * D14 归档完整性重扫(archive_integrity,2026-09-17 OpenSpec validate --archived 对标)。
1130
+ *
1131
+ * 为什么需要:归档前的完成门禁很严(review.json 驱动勾选 / Wave 断言 / verify 对账),
1132
+ * 但目录搬进 changes/archive/ 后**没有任何机制回头看**——手工挪目录绕流程的自愈只在
1133
+ * 下次 CLI 触碰时生效,归档后手改 tasks.md / 删 plan.md 无人发现。本维度补事后重扫:
1134
+ * ① 任务 checkbox 全勾(tasks.md 优先,回退 plan.md——读侧兼容同 D5/progress 口径;
1135
+ * 归档后 runtime 工件已 prune,review.json 不可用,checkbox 是归档目录里唯一持久的完成面)
1136
+ * ② plan.md 在场(archive 自愈 findAlreadyArchivedDir 的基准文件)
1137
+ * ③ 注册表文件存在但读不了 → finding(OpenSpec #205 教训:读不了 ≠ 没任务,
1138
+ * 检查不许在什么都没看的情况下静默通过)
1139
+ * 语义:WARNING advisory 不阻断、只读无修复(钩子/门禁挂载是后续独立策略裁决);
1140
+ * 无 archive 目录 / 无 task 行 → pass(无任务视为完备,同 OpenSpec "nothing to complete")。
1141
+ */
1142
+ function detectArchiveIntegrity(cwd, authoritySpecDir) {
1143
+ const base = { name: 'archive_integrity', label: '归档完整性重扫', safe_actions: [] };
1144
+ if (!authoritySpecDir) {
1145
+ return { ...base, pass: true, severity: null, findings: ['无权威 specDir,跳过'] };
1146
+ }
1147
+ const archiveDir = join(authoritySpecDir, 'changes', 'archive');
1148
+ if (!existsSync(archiveDir)) {
1149
+ return { ...base, pass: true, severity: null, findings: ['无 changes/archive 目录(未归档/新仓),跳过'] };
1150
+ }
1151
+ let entries;
1152
+ try {
1153
+ entries = readdirSync(archiveDir, { withFileTypes: true });
1154
+ } catch (e) {
1155
+ return { ...base, pass: false, severity: CHECK_SEVERITY.WARNING, findings: [`changes/archive 目录不可读(${e?.message || e})——人工排查,不静默跳过`] };
1156
+ }
1157
+ const names = entries
1158
+ .filter((d) => d.isDirectory() && !d.name.startsWith('.'))
1159
+ .map((d) => d.name)
1160
+ .sort();
1161
+ if (names.length === 0) {
1162
+ return { ...base, pass: true, severity: null, findings: ['changes/archive 为空(无已归档变更)'], archive_count: 0, offenders: [] };
1163
+ }
1164
+ const offenders = [];
1165
+ for (const name of names) {
1166
+ const dir = join(archiveDir, name);
1167
+ const reasons = [];
1168
+ // ② plan.md 在场性
1169
+ const planPath = join(dir, 'plan.md');
1170
+ const hasPlan = existsSync(planPath);
1171
+ if (!hasPlan) reasons.push('plan.md 缺失(归档自愈基准文件)');
1172
+ // ①③ 任务注册表:tasks.md 优先回退 plan.md;存在但不可读必须显式报(区分「缺席」与「读不了」)。
1173
+ // 2026-08-20-task-truth-unify 之前的旧归档完成态勾在 plan.md(tasks.md 是留给后人的未勾任务清单)——
1174
+ // tasks.md 有 checkbox 行但 0 勾、而 plan.md 有 ≥1 勾时,完成证据在 plan.md,切 plan.md 为完成源
1175
+ // (真实仓首扫实证:2026-05-31-sqlite-migration tasks 0/15 + plan 15/15,43 份 offenders 大半为此误报)。
1176
+ const tasksPath = join(dir, 'tasks.md');
1177
+ const readRegistry = (p) => {
1178
+ const content = readFileSync(p, 'utf8');
1179
+ // 与 progress.js readPlanCheckboxStatus 同正则(task- 前缀锚定,x/X 计勾)
1180
+ const re = /^\s*[-*]\s+\[([ xX])\]\s+task-\d+/gm;
1181
+ let t = 0;
1182
+ let c = 0;
1183
+ let m;
1184
+ while ((m = re.exec(content)) !== null) {
1185
+ t++;
1186
+ if (m[1] !== ' ') c++;
1187
+ }
1188
+ return { total: t, checked: c };
1189
+ };
1190
+ const regPath = existsSync(tasksPath) ? tasksPath : hasPlan ? planPath : null;
1191
+ if (regPath) {
1192
+ try {
1193
+ let stat = readRegistry(regPath);
1194
+ if (regPath === tasksPath && stat.total > 0 && stat.checked === 0 && hasPlan) {
1195
+ try {
1196
+ const planStat = readRegistry(planPath);
1197
+ if (planStat.total > 0 && planStat.checked > 0) stat = planStat;
1198
+ } catch { /* plan.md 不可读 → 维持 tasks.md 0 勾口径(本就报未勾) */ }
1199
+ }
1200
+ if (stat.total > 0 && stat.checked < stat.total) {
1201
+ reasons.push(`${stat.total - stat.checked} 个任务未勾(${stat.checked}/${stat.total})`);
1202
+ }
1203
+ } catch (e) {
1204
+ reasons.push(`任务注册表不可读(${relative(archiveDir, regPath)}:${e?.code || e?.message || e})`);
1205
+ }
1206
+ }
1207
+ if (reasons.length > 0) offenders.push({ name, reasons });
1208
+ }
1209
+
1210
+ // 豁免账本(2026-09-17 收尾裁决):老归档历史形态(早于 plan.md 流程的手工归档/远古命名)逐份豁免——
1211
+ // 照 docs-check skip 清单先例(39 处历史快照冻结),不伪造 plan.md 篡改历史;账本外继续亮红;
1212
+ // 纪律:新欠账禁止入账(入账须变更流程裁决);账本解析失败按无豁免 fail-safe。
1213
+ const exemptPath = join(authoritySpecDir, 'archive-integrity-exempt.yaml');
1214
+ let exemptNames = new Set();
1215
+ const exemptNotes = [];
1216
+ if (existsSync(exemptPath)) {
1217
+ try {
1218
+ const doc = jsYaml.load(readFileSync(exemptPath, 'utf8'));
1219
+ const entries = Array.isArray(doc && doc.entries) ? doc.entries : [];
1220
+ exemptNames = new Set(entries.filter((e) => e && typeof e.name === 'string').map((e) => e.name));
1221
+ } catch (e) {
1222
+ exemptNotes.push(`豁免账本解析失败(${e?.message || e})——按无豁免处理,红灯保持`);
1223
+ }
1224
+ }
1225
+ const exempted = offenders.filter((o) => exemptNames.has(o.name));
1226
+ const effective = offenders.filter((o) => !exemptNames.has(o.name));
1227
+ const staleExemptions = [...exemptNames].filter((n) => !offenders.some((o) => o.name === n));
1228
+ if (exempted.length > 0) {
1229
+ exemptNotes.push(`${exempted.length} 份豁免在案(历史形态,账本 archive-integrity-exempt.yaml)`);
1230
+ }
1231
+ if (staleExemptions.length > 0) {
1232
+ exemptNotes.push(`${staleExemptions.length} 条豁免已失效(对应归档不再欠账)可清理:${staleExemptions.slice(0, 5).join(', ')}${staleExemptions.length > 5 ? ' …' : ''}`);
1233
+ }
1234
+
1235
+ if (effective.length === 0) {
1236
+ const findings = [`${names.length} 份归档完整(任务全勾 + plan.md 在场${exempted.length > 0 ? `,另有 ${exempted.length} 份豁免在案` : ''})`];
1237
+ findings.push(...exemptNotes);
1238
+ return {
1239
+ ...base,
1240
+ pass: true,
1241
+ severity: null,
1242
+ findings,
1243
+ archive_count: names.length,
1244
+ offenders: [],
1245
+ exempted_count: exempted.length,
1246
+ stale_exemptions: staleExemptions,
1247
+ };
1248
+ }
1249
+ const findings = effective.slice(0, 10).map((o) => `${o.name}:${o.reasons.join(';')}`);
1250
+ if (effective.length > 10) findings.push(`…还有 ${effective.length - 10} 份`);
1251
+ findings.push(...exemptNotes);
1252
+ findings.push('语义:归档=全勾终态;未勾/缺文件多为归档后手改或手工搬目录绕流程——git log 定位改动来源,doctor 只读不自动修');
1253
+ return {
1254
+ ...base,
1255
+ pass: false,
1256
+ severity: CHECK_SEVERITY.WARNING,
1257
+ findings,
1258
+ safe_actions: [{ dimension: 'archive_integrity', action: 'manual_inspect', risk: 'manual_edit', rationale: '归档完整性破损', next_step: '逐份核对 offenders:git log --follow .sillyspec/changes/archive/<name>/tasks.md 定位改动者与意图,确属历史遗留走变更流程裁决入豁免账本(新欠账禁止入账),勿改语义' }],
1259
+ archive_count: names.length,
1260
+ offenders: effective,
1261
+ exempted_count: exempted.length,
1262
+ stale_exemptions: staleExemptions,
1263
+ };
1264
+ }
1265
+
1128
1266
  /**
1129
1267
  * renderDoctorSummary(FR-01,全新输出契约——2026-09-09-doctor-noai):逐维
1130
1268
  * ✅/⚠️/❌ + label + findings 首行 + safe_actions 提示行。顶层非 --json 命令与
@@ -1177,7 +1315,9 @@ export async function runDoctorDiagnostics({ cwd }) {
1177
1315
  const applyManifestDrift = detectApplyManifestDrift(cwd, authoritySpecDir)
1178
1316
  // task-02(2026-09-15-tax-governance):自维护税面(活跃 tally 列示 + 台账聚合 + 税重阈值,只读)
1179
1317
  const selfMaintenanceTax = detectSelfMaintenanceTax(cwd, authoritySpecDir)
1180
- const dimensions = [multiDb, pointerHealth, changesSplit, changeDb, executeMismatch, docBloat, repoNativeChain, lifecycleDoc, worktreeHealth, buildEnv, mcpEndpoints, applyManifestDrift, selfMaintenanceTax];
1318
+ // D14(2026-09-17 archive-rescan,OpenSpec validate --archived 对标):归档完整性事后重扫(只读 WARNING)
1319
+ const archiveIntegrity = detectArchiveIntegrity(cwd, authoritySpecDir)
1320
+ const dimensions = [multiDb, pointerHealth, changesSplit, changeDb, executeMismatch, docBloat, repoNativeChain, lifecycleDoc, worktreeHealth, buildEnv, mcpEndpoints, applyManifestDrift, selfMaintenanceTax, archiveIntegrity];
1181
1321
 
1182
1322
  return {
1183
1323
  dimensions,
@@ -24,6 +24,46 @@ function lineOfIndex(content, index) {
24
24
  return content.slice(0, index).split('\n').length
25
25
  }
26
26
 
27
+ /**
28
+ * 注释掩码(坑 endpoint-extractor-selfdoc-noise,2026-09-16 E 变更 verify 实证):JS/Java 的
29
+ * // 与 /* *\/ 注释区替换为等长空格(换行保留)——注释里的 `app.use("/api", router)`、
30
+ * `router.get("/api/xxx")`、`@GetMapping("/x")` 文档示例不再被全文正则当真路由(探针5 曾报
31
+ * 「GET /api/path、GET /api unused」假 advisory)。字符串字面量(' " `)原样保留(真路由在
32
+ * 字符串里);正则字面量按字符串近似处理(端点正则里出现路由字符串形态极罕见,误掩概率低)。
33
+ * 保长度 = 索引/行号零漂移,lineOfIndex 与全部既有正则兼容。
34
+ * @param {string} content
35
+ * @returns {string} 等长掩码后内容
36
+ */
37
+ function stripCommentsKeepLength(content) {
38
+ const src = String(content || '')
39
+ const out = src.split('')
40
+ let st = 'normal' // normal | block | sq | dq | tick
41
+ for (let i = 0; i < src.length; i++) {
42
+ const c = src[i], n = src[i + 1]
43
+ if (st === 'normal') {
44
+ if (c === '/' && n === '/') {
45
+ // 行注释:掩到行尾(保留换行符本身)
46
+ let j = i
47
+ while (j < src.length && src[j] !== '\n') { out[j] = ' '; j++ }
48
+ i = j - 1
49
+ } else if (c === '/' && n === '*') {
50
+ out[i] = ' '; out[i + 1] = ' '
51
+ st = 'block'; i++
52
+ } else if (c === "'") st = 'sq'
53
+ else if (c === '"') st = 'dq'
54
+ else if (c === '`') st = 'tick'
55
+ } else if (st === 'block') {
56
+ if (c === '*' && n === '/') { out[i] = ' '; out[i + 1] = ' '; st = 'normal'; i++ }
57
+ else if (c !== '\n') out[i] = ' '
58
+ } else {
59
+ // 字符串内:转义跳过;闭合引号回 normal(内容原样保留)
60
+ if (c === '\\') i++
61
+ else if ((st === 'sq' && c === "'") || (st === 'dq' && c === '"') || (st === 'tick' && c === '`')) st = 'normal'
62
+ }
63
+ }
64
+ return out.join('')
65
+ }
66
+
27
67
  /**
28
68
  * 从单个文件提取 FastAPI router 端点
29
69
  * 支持 APIRouter(prefix=...) 和 @router.get/post/put/delete/patch("/path")
@@ -73,7 +113,13 @@ export function extractFastApiEndpoints(filePath) {
73
113
  * @returns {Array<{ method: string, path: string, source: string, line: number }>}
74
114
  */
75
115
  export function extractExpressEndpoints(filePath) {
76
- const content = readFileSync(filePath, 'utf8')
116
+ const rawContent = readFileSync(filePath, 'utf8')
117
+ // 注释掩码(坑 endpoint-extractor-selfdoc-noise,2026-09-16 E 变更 verify 实证:本模块自身
118
+ // JSDoc 示例 `app.use("/api", router)` / `router.get("/api/xxx")` 被全文正则当真路由——
119
+ // 探针5 报「GET /api/path、GET /api unused」假 advisory,agent 逐条裁定纯耗损)。掩码保长度
120
+ // 替换注释区为空格(换行保留):字符串字面量不动(真路由在字符串里),索引/行号零漂移,
121
+ // 下游 lineOfIndex/正则全兼容。
122
+ const content = stripCommentsKeepLength(rawContent)
77
123
  const lines = content.split('\n')
78
124
  const endpoints = []
79
125
 
@@ -116,7 +162,9 @@ export function extractExpressEndpoints(filePath) {
116
162
  * @returns {Array<{ method: string, path: string, source: string, line: number }>}
117
163
  */
118
164
  export function extractSpringEndpoints(filePath) {
119
- const content = readFileSync(filePath, 'utf8')
165
+ // 注释掩码同 Express(坑 endpoint-extractor-selfdoc-noise;Java 与 JS 注释语法同族 // /* */
166
+ // ——掩码器共用。注解本体不在注释内,掩码零影响;注释里的 @GetMapping 示例不再入面)
167
+ const content = stripCommentsKeepLength(readFileSync(filePath, 'utf8'))
120
168
  const lines = content.split('\n')
121
169
  const endpoints = []
122
170
 
@@ -45,23 +45,32 @@ function getMainDirtySet(cwd) {
45
45
  * 变更 worktree 活性速查(.runtime/worktrees/<change>/meta.json)。
46
46
  * isolated=true:有存活隔离 worktree(meta 在、非 in-place、worktreePath 目录在)——
47
47
  * 其 WIP 在 worktree 内主仓 porcelain 不可见,整份 design 声明按在途处理。
48
- * @returns {Map<string, { isolated: boolean }>|null} null = 读取失败(fail-closed)
48
+ * 双根扫描(坑 platform-dual-root-fourth-consumer,2026-09-16 hunt 实证):平台模式
49
+ * specBase=specRoot 下只挂跨仓 worktree,主仓 worktree 在项目侧 <cwd>/.sillyspec——单扫
50
+ * specBase 会把主仓 worktree 变更误判 stale → 其声明失效 → 文件被误归属当前变更(③类
51
+ * 噪音/误扣)。specBase 与 cwd 项目侧两目录都扫,按变更名合并(specBase 先入)。
52
+ * @returns {Map<string, { isolated: boolean }>|null} null = 两目录均读取失败(fail-closed)
49
53
  */
50
- function loadWorktreeLiveness(specBase) {
54
+ export function loadWorktreeLiveness(specBase, cwd) {
51
55
  const map = new Map()
52
- try {
53
- const dir = join(specBase, '.runtime', 'worktrees')
54
- for (const e of readdirSync(dir, { withFileTypes: true })) {
55
- if (!e.isDirectory()) continue
56
- try {
57
- const meta = JSON.parse(readFileSync(join(dir, e.name, 'meta.json'), 'utf8'))
58
- const isolated = Boolean(meta && meta.mode !== 'in-place-fallback'
59
- && meta.worktreePath && existsSync(meta.worktreePath))
60
- map.set(meta.changeName || e.name, { isolated })
61
- } catch { /* 单条损坏跳过(该变更走主仓 dirty 判定) */ }
62
- }
63
- } catch { return null }
64
- return map
56
+ const roots = [...new Set([specBase, join(cwd || process.cwd(), '.sillyspec')].filter(Boolean))]
57
+ let anyDirOk = false
58
+ for (const base of roots) {
59
+ try {
60
+ const dir = join(base, '.runtime', 'worktrees')
61
+ for (const e of readdirSync(dir, { withFileTypes: true })) {
62
+ if (!e.isDirectory()) continue
63
+ try {
64
+ const meta = JSON.parse(readFileSync(join(dir, e.name, 'meta.json'), 'utf8'))
65
+ const isolated = Boolean(meta && meta.mode !== 'in-place-fallback'
66
+ && meta.worktreePath && existsSync(meta.worktreePath))
67
+ map.set(meta.changeName || e.name, { isolated })
68
+ } catch { /* 单条损坏跳过(该变更走主仓 dirty 判定) */ }
69
+ }
70
+ anyDirOk = true
71
+ } catch { /* 该根不存在/不可读 → 试下一根 */ }
72
+ }
73
+ return anyDirOk ? map : null
65
74
  }
66
75
 
67
76
  /**
@@ -74,7 +83,7 @@ function loadWorktreeLiveness(specBase) {
74
83
  function filterStaleForeignDeclarations(foreign, cwd, specBase) {
75
84
  if (foreign.size === 0) return
76
85
  const dirty = getMainDirtySet(cwd)
77
- const worktrees = loadWorktreeLiveness(specBase)
86
+ const worktrees = loadWorktreeLiveness(specBase, cwd)
78
87
  if (dirty === null && worktrees === null) return // 两个事实源全失败 → 原样保留
79
88
  for (const [file, owners] of [...foreign.entries()]) {
80
89
  const live = owners.filter(owner => {
@@ -308,6 +308,8 @@ export {
308
308
  matchReadonlyWhitelist as _matchReadonlyWhitelistForTest,
309
309
  matchDangerBlacklist as _matchDangerBlacklistForTest,
310
310
  isSingleCommandReadonly as _isSingleCommandReadonlyForTest,
311
+ analyzeCrossRepoCd as _analyzeCrossRepoCdForTest,
312
+ CROSS_REPO_TEST_RE as _CROSS_REPO_TEST_REForTest,
311
313
  };
312
314
  /**
313
315
  * 从 sillyspec.db 读取 currentStage(直读 DB,task-10 废 gate-status.json 后为唯一来源)
@@ -834,6 +836,56 @@ export function shouldBlockWrite(filePath, cwd) {
834
836
  }
835
837
  }
836
838
 
839
+ /**
840
+ * 跨仓命令锚点感知(known-issues 2026-09-16 登记「worktree 隔离期跨仓命令锚定错位」兑现):
841
+ * EHS 三仓形态(主仓 + repos 注册兄弟仓)两处摩擦——①worktree 内 `cd ../<注册仓>` 解析到
842
+ * `.sillyspec/.runtime/worktrees/` 存储目录而非真实兄弟仓(锚点错位,静默跑错地方);②
843
+ * verify 等非 execute/quick 阶段从主仓跑 `cd ../注册仓 && npx eslint` 属合法只读跨仓操作,
844
+ * 但 cd 不在只读白名单 → 整条进 stage 门禁拦截。
845
+ * 处置:①worktree cwd 本就全放行——不拦,只 stderr 纠偏留痕(真实兄弟仓路径);②从严放行
846
+ * (cd 目标命中 repos 注册根 + 其余片段全部测试/lint 类 + 危险黑名单不沾),写类/混合维持
847
+ * 原判 fail-closed。
848
+ */
849
+ const CROSS_REPO_TEST_RE = /^(?:npm (?:test|run test[\w:.-]*)|npx (?:eslint|tsc|jest|vitest|mocha|stylelint|prettier)(?:\s|$)|node --test)/i
850
+
851
+ /**
852
+ * 解析复合命令中的 cd 目标并比对 repos 注册仓根。
853
+ * @param {string} command 原始命令
854
+ * @param {string} callerCwd 调用方 cwd(相对 cd 目标的解析基准之一)
855
+ * @param {string} projectRoot 主仓根(worktree 形态下的正确锚点基准)
856
+ * @param {boolean} inWorktree 调用方是否在 worktree 内(决定 ../ 的解析基准)
857
+ * @returns {{hits: Array<{key:string,repoRoot:string,resolved:string,target:string}>, segments: string[]}|null}
858
+ * 无 cd 段 / 无 repos 注册 / 无命中 → null
859
+ */
860
+ function analyzeCrossRepoCd(command, callerCwd, projectRoot, inWorktree) {
861
+ const segments = splitCommandParts(command)
862
+ const cdTargets = []
863
+ for (const seg of segments) {
864
+ const m = seg.match(/^cd\s+([^\s&|;]+)/i)
865
+ if (m) cdTargets.push(m[1])
866
+ }
867
+ if (cdTargets.length === 0) return null
868
+ const repos = loadLocalConfig(projectRoot).repos
869
+ if (!repos || typeof repos !== 'object') return null
870
+ const hits = []
871
+ for (const t of cdTargets) {
872
+ // 双基准:shell 实际解析(callerCwd——worktree 内 ../ 即存储目录错位形态)与意图解析
873
+ // (worktree 形态按主仓根、主仓 cwd 即 shell 本身)。注册命中按意图基准;纠偏提示按
874
+ // 「shell 解析落在 worktree 存储目录」判定。
875
+ const shellResolved = path.isAbsolute(t) ? t : path.resolve(callerCwd, t)
876
+ const intended = path.isAbsolute(t) ? t : path.resolve(inWorktree ? projectRoot : callerCwd, t)
877
+ for (const [key, raw] of Object.entries(repos)) {
878
+ if (typeof raw !== 'string' || !raw) continue
879
+ const repoRoot = path.isAbsolute(raw) ? raw : path.resolve(projectRoot, raw)
880
+ if (intended === repoRoot || isPathInside(intended, repoRoot)) {
881
+ hits.push({ key, repoRoot, resolved: intended, shellResolved, target: t })
882
+ break
883
+ }
884
+ }
885
+ }
886
+ return hits.length > 0 ? { hits, segments } : null
887
+ }
888
+
837
889
  /**
838
890
  * 判断 Bash 命令是否应被拦截
839
891
  * @param {string} command - Bash 命令字符串
@@ -846,8 +898,19 @@ function shouldBlockBash(command, cwd) {
846
898
  const callerCwd = cwd || process.cwd()
847
899
  const projectRoot = findProjectRoot(callerCwd)
848
900
 
849
- // cwd 在 worktree 内 → 全部放行
850
- if (isInsideRegisteredWorktree(callerCwd, projectRoot)) return { blocked: false }
901
+ // cwd 在 worktree 内 → 全部放行(跨仓锚点纠偏留痕:../注册仓 在 worktree 内解析到
902
+ // worktrees 存储目录——不拦只纠正,known-issues③)
903
+ if (isInsideRegisteredWorktree(callerCwd, projectRoot)) {
904
+ try {
905
+ const xr = analyzeCrossRepoCd(command, callerCwd, projectRoot, true)
906
+ for (const h of (xr && xr.hits) || []) {
907
+ if (isInsideWorktreeStorage(h.shellResolved, projectRoot)) {
908
+ console.error(`[sillyspec hook] ℹ️ 跨仓锚点纠偏:cd ${h.target} 在 worktree 内实际会解析到 ${h.shellResolved}(worktrees 存储目录,非真实兄弟仓)。注册仓 ${h.key} 的真实路径:${h.repoRoot}——跨仓测试/构建请改用真实路径。`)
909
+ }
910
+ }
911
+ } catch { /* 纠偏 fail-soft:解析异常不影响放行 */ }
912
+ return { blocked: false }
913
+ }
851
914
 
852
915
  // 阶段门禁(直读 sillyspec.db)
853
916
  const stage = readCurrentStage(projectRoot) || '(none)'
@@ -857,6 +920,16 @@ function shouldBlockBash(command, cwd) {
857
920
  const localConfig = loadLocalConfig(projectRoot)
858
921
  const extraReadonly = localConfig.worktreeHook?.readonlyCommands || localConfig['worktree-hook']?.readonlyCommands || []
859
922
  if (matchReadonlyWhitelist(command, extraReadonly)) return { blocked: false }
923
+ // 跨仓注册仓测试类放行(known-issues③):verify 等阶段从主仓跑兄弟仓测试/lint 是合法
924
+ // 只读操作。从严三条件:cd 目标命中 repos 注册根 + 其余片段全部测试/lint 类 + 危险
925
+ // 黑名单不沾——写类/混合命令维持 stage 门禁原判(fail-closed)。
926
+ if (!matchDangerBlacklist(command)) {
927
+ const xr = analyzeCrossRepoCd(command, callerCwd, projectRoot, false)
928
+ if (xr && xr.segments.every(seg => /^cd\s+/i.test(seg) || CROSS_REPO_TEST_RE.test(seg))) {
929
+ console.error(`[sillyspec hook] 放行依据 = 跨仓注册仓(${xr.hits.map(h => h.key).join('、')})测试/lint 类命令(known-issues③ 锚点感知,其余片段全测试类+黑名单不沾)`)
930
+ return { blocked: false }
931
+ }
932
+ }
860
933
  return {
861
934
  blocked: true,
862
935
  reason: buildStageHint(stage)