sillyspec 3.26.13 → 3.26.15

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 (56) hide show
  1. package/.claude/skills/sillyspec-archive/SKILL.md +2 -0
  2. package/.claude/skills/sillyspec-brainstorm/SKILL.md +1 -1
  3. package/.claude/skills/sillyspec-commit/SKILL.md +18 -60
  4. package/.claude/skills/sillyspec-continue/SKILL.md +2 -0
  5. package/.claude/skills/sillyspec-execute/SKILL.md +10 -4
  6. package/.claude/skills/sillyspec-plan/SKILL.md +10 -2
  7. package/.claude/skills/sillyspec-quick/SKILL.md +2 -2
  8. package/.claude/skills/sillyspec-resume/SKILL.md +2 -0
  9. package/.claude/skills/sillyspec-verify/SKILL.md +2 -0
  10. package/.claude/skills/sillyspec-workspace/SKILL.md +7 -1
  11. package/package.json +1 -1
  12. package/src/commit-suggest.js +202 -0
  13. package/src/config-cat.js +88 -0
  14. package/src/docs-check.js +48 -12
  15. package/src/doctor-diagnostics.js +49 -0
  16. package/src/hooks/worktree-guard.js +20 -1
  17. package/src/index.js +547 -21
  18. package/src/init.js +1 -1
  19. package/src/local-register.js +98 -0
  20. package/src/module-impact.js +146 -0
  21. package/src/progress/change-registry.js +43 -4
  22. package/src/progress/stage-machine.js +10 -5
  23. package/src/progress.js +18 -3
  24. package/src/quicklog.js +106 -1
  25. package/src/run/command.js +151 -38
  26. package/src/run/complete-handlers.js +62 -4
  27. package/src/run/complete.js +10 -8
  28. package/src/run/concurrent-detect.js +3 -1
  29. package/src/run/gates.js +86 -4
  30. package/src/run/multi-repo-context.js +3 -1
  31. package/src/run/next.js +128 -0
  32. package/src/run/prompt.js +28 -6
  33. package/src/run/scan-profile.js +35 -23
  34. package/src/run/shared.js +28 -7
  35. package/src/scan-postcheck.js +142 -2
  36. package/src/stage-contract-spec.js +1 -1
  37. package/src/stage-review.js +55 -7
  38. package/src/stages/archive.js +1 -1
  39. package/src/stages/brainstorm.js +26 -12
  40. package/src/stages/doctor.js +2 -4
  41. package/src/stages/execute.js +110 -26
  42. package/src/stages/knowledge.js +21 -5
  43. package/src/stages/plan-postcheck.js +24 -10
  44. package/src/stages/plan.js +14 -9
  45. package/src/stages/quick.js +14 -6
  46. package/src/stages/scan.js +1 -0
  47. package/src/stages/verify.js +7 -49
  48. package/src/sync.js +19 -7
  49. package/src/task-review.js +256 -10
  50. package/src/verify-probes.js +264 -0
  51. package/src/workspace.js +125 -0
  52. package/src/worktree-apply.js +15 -1
  53. package/src/worktree.js +58 -8
  54. package/templates/claude-instruction.md +1 -1
  55. package/templates/prompts/taskcard-rules.md +1 -1
  56. package/templates/prompts/verify-probes.md +13 -47
package/src/sync.js CHANGED
@@ -9,6 +9,7 @@
9
9
  */
10
10
 
11
11
  import { existsSync, mkdirSync, readFileSync, readdirSync, writeFileSync, unlinkSync, chmodSync } from 'fs';
12
+ import { writeAtomicSync } from './fs-atomic.js';
12
13
  import { join } from 'path';
13
14
  import { resolvePlatformSpecDir } from './progress.js';
14
15
  import { safeGit } from './git-helper.js';
@@ -131,12 +132,14 @@ function replaceTopLevelSection(text, name, body) {
131
132
  return `${stripped}\n\n${name}:\n${body}\n`;
132
133
  }
133
134
 
134
- /** 文本级写 local.yaml(保留传入 text 的注释/结构);确保 .sillyspec 目录存在。 */
135
+ /** 文本级写 local.yaml(保留传入 text 的注释/结构);确保 .sillyspec 目录存在。
136
+ * 原子写(fs-atomic 契约:local.yaml 被 hook/probe 等其他进程并发读,裸 writeFileSync 的
137
+ * 写入窗口可读到半截文件 → mcp 段解析失败回退 env → 误判 no-config 降级)。 */
135
138
  function writeLocalYamlRaw(cwd, text) {
136
139
  const dir = join(cwd, '.sillyspec');
137
140
  if (!existsSync(dir)) mkdirSync(dir, { recursive: true });
138
141
  const yamlPath = join(cwd, LOCAL_YAML);
139
- writeFileSync(yamlPath, text, 'utf8');
142
+ writeAtomicSync(yamlPath, text, 'utf8');
140
143
  // 体检 SEC-04:local.yaml 含明文 token,收紧为仅属主可读写。POSIX chmod 0600 生效;
141
144
  // Windows 上 Node chmod 只映射只读位(近似 no-op),属可接受 best-effort
142
145
  try { chmodSync(yamlPath, 0o600); } catch { /* chmod 失败不阻断连接流程 */ }
@@ -364,7 +367,7 @@ export class SyncManager {
364
367
  // 删段后纯空白(无任何段也无注释)→ 删除整个文件;注释算内容,有注释则保留
365
368
  try { unlinkSync(p); } catch { /* best effort */ }
366
369
  } else {
367
- writeFileSync(p, newText, 'utf8');
370
+ writeAtomicSync(p, newText, 'utf8');
368
371
  }
369
372
  }
370
373
  }
@@ -609,7 +612,10 @@ export class SyncManager {
609
612
  return { synced: 0, errors: ['未指定变更名称'] };
610
613
  }
611
614
 
612
- const changeDir = join(this.cwd, CHANGES_DIR, changeName);
615
+ // 树根与进度/spec 树同源(safePlatformSpecDir,BUG-01 同族):此前硬编码 cwd/.sillyspec/changes,
616
+ // 平台模式(specRoot 指向外部)下路径不存在 → syncDocuments 恒报「变更不存在」且调用方不查
617
+ // 返回值 → 文档在平台模式下静默永不同步。
618
+ const changeDir = join(safePlatformSpecDir(this.cwd) || join(this.cwd, '.sillyspec'), 'changes', changeName);
613
619
  if (!existsSync(changeDir)) {
614
620
  console.warn(`[sync] 变更不存在: ${changeName}`);
615
621
  return { synced: 0, errors: [`变更不存在: ${changeName}`] };
@@ -997,7 +1003,10 @@ export class SyncManager {
997
1003
  const { ProgressManager } = await import('./progress.js');
998
1004
  const pm = new ProgressManager({ specDir: safePlatformSpecDir(this.cwd) });
999
1005
  const db = pm._ensureDB(this.cwd).getDb();
1000
- db.prepare('UPDATE changes SET last_synced_platform_ts = MAX(?, COALESCE(last_synced_platform_ts, ?)) WHERE name = ?')
1006
+ // 参数侧同样 COALESCE(坑 keep-local-base-ts-null-param):SQLite 标量 MAX() 任一参数
1007
+ // 为 NULL 即返回 NULL——冲突文件 platform_last_pushed_at 为 null 时 MAX(NULL, 旧值)
1008
+ // 会把已回填的 base_ts 清空,恰好违背本注释的单调防回退意图。
1009
+ db.prepare('UPDATE changes SET last_synced_platform_ts = MAX(COALESCE(?, last_synced_platform_ts), COALESCE(last_synced_platform_ts, ?)) WHERE name = ?')
1001
1010
  .run(platformPushedAt, platformPushedAt, changeName);
1002
1011
  } catch (err) {
1003
1012
  return { ok: false, resolved: false, reason: `keep-local 更新 base_ts 失败: ${err.message}` };
@@ -1024,9 +1033,12 @@ export class SyncManager {
1024
1033
  }
1025
1034
 
1026
1035
  if (mode === 'take-platform' && cf) {
1027
- // 用冲突文件的 platform_progress 调 import 覆盖本地(保隔离:import 不覆盖 isolation_*)
1036
+ // 用冲突文件的 platform_progress 调 import 覆盖本地(保隔离:import 不覆盖 isolation_*)。
1037
+ // fail-closed(坑 take-platform-empty-import):缺 platform_progress 时必须直接返回——
1038
+ // { ...null } 展开为 {},import 会照常跑完整事务(DELETE stages 后按空 JSON 重建 =
1039
+ // 清空本地进度),返回值却报 ok:false「未执行」,用户以为无事发生实则数据已丢。
1028
1040
  if (!cf.platform_progress) {
1029
- progressOutcome = { ok: false, resolved: false, reason: '冲突文件缺 platform_progress,无法 take-platform(建议先 platform pull)' };
1041
+ return { ok: false, resolved: false, reason: '冲突文件缺 platform_progress,无法 take-platform(建议先 platform pull)' };
1030
1042
  }
1031
1043
  try {
1032
1044
  const { ProgressManager } = await import('./progress.js');
@@ -525,12 +525,15 @@ export function validateTaskReviews(opts) {
525
525
 
526
526
  // QUAL-01 收口:原为本地 execFileSync('git')(无 safe.directory、与 git-helper 口径分裂),
527
527
  // 现走统一入口;抛错/trim 语义不变,timeout 15000 保留(rev-parse/diff 大仓余量)
528
- function runGit(gitDir, args) {
529
- return git(gitDir, args, { timeout: 15000 })
528
+ function runGit(gitDir, args, opts = {}) {
529
+ return git(gitDir, args, { timeout: 15000, ...opts })
530
530
  }
531
531
 
532
532
  // porcelain 状态行 → 变更文件路径(对齐 shared.js:343 parsePorcelainPath:`XY path`、引号包裹、
533
533
  // rename `R old -> new` 取箭头后)。未 commit 的 working-tree 改动对账用。
534
+ // ⚠️ 入参必须是【未 trim】的原始输出(git(..., {trim:false}))——porcelain 行首空格是状态码
535
+ // 一部分,git() 默认 trim 会把 ' M feature.js' 削成 'M feature.js',slice(3) 再咬掉路径首字符
536
+ // (实测 'eature.js')→ 未提交文件对账全错位(verifyReviewGitEvidence 相交比对误判「完全不相交」)。
534
537
  function parsePorcelainFiles(statusOut) {
535
538
  const files = []
536
539
  for (const line of String(statusOut).split('\n')) {
@@ -639,7 +642,7 @@ export function verifyReviewGitEvidence(review, gitDir, cache = null) {
639
642
  try {
640
643
  let wtStatus = c.wtStatus.get(gitDir)
641
644
  if (wtStatus === undefined) {
642
- wtStatus = runGit(gitDir, ['status', '--porcelain'])
645
+ wtStatus = runGit(gitDir, ['status', '--porcelain'], { trim: false })
643
646
  c.wtStatus.set(gitDir, wtStatus)
644
647
  }
645
648
  if (wtStatus && wtStatus.trim().length > 0) {
@@ -983,7 +986,30 @@ export async function generateTaskReviewDrafts({ changeName, cwd, platformOpts =
983
986
  }
984
987
 
985
988
  // base..head diff 文件集(worktree-aware;null=git 不可用,[]=无 commit diff)
986
- const diffFiles = resolveVerifyChangedFiles(cwd, changeName)
989
+ let diffFiles = resolveVerifyChangedFiles(cwd, changeName)
990
+ // ⚠️ 并入 worktree 未提交改动(坑 draft-attribution-uncommitted-worktree,2026-08-21 实证):
991
+ // 子代理默认不 commit(execute 复盘 a 同源事实),真实改动全在 worktree working-tree——只看
992
+ // base..HEAD commit diff 时归属恒空,9/9 草稿全成「无归属」靠主代理手写升级。与
993
+ // verifyReviewGitEvidence 的 working-tree 并入口径同源:status --porcelain 文件(排除
994
+ // .sillyspec/ 运行时产物)并入归属集,按 allowed_paths 正常路径归属;review.head 仍为 HEAD
995
+ // commit——evidence 校验对「commit diff 空 + working-tree 有改动」明示不判伪造,语义一致。
996
+ try {
997
+ const wm = new WorktreeManager({ cwd })
998
+ const meta = wm.getMeta(changeName)
999
+ if (meta) {
1000
+ const wtGitDir = (meta.worktreePath && meta.mode !== 'in-place-fallback' && existsSync(meta.worktreePath))
1001
+ ? meta.worktreePath
1002
+ : cwd
1003
+ const wtStatus = runGit(wtGitDir, ['status', '--porcelain'], { trim: false })
1004
+ const wtFiles = parsePorcelainFiles(wtStatus)
1005
+ .map(p => String(p).replace(/\\/g, '/'))
1006
+ .filter(p => p !== '.sillyspec' && !p.startsWith('.sillyspec/'))
1007
+ if (wtFiles.length > 0) {
1008
+ diffFiles = [...new Set([...(Array.isArray(diffFiles) ? diffFiles : []), ...wtFiles])]
1009
+ console.log(`[sillyspec] 草稿归属并入 ${wtFiles.length} 个 worktree 未提交文件(子代理未 commit 的改动按 allowed_paths 归属)`)
1010
+ }
1011
+ }
1012
+ } catch { /* working-tree 并入失败退回 commit diff 口径(fail-open,不阻断草稿) */ }
987
1013
  // 单仓模式(无 ctx):主仓无 diff 即无任何 task 可生成 → 提前返回(原逻辑零回归)。
988
1014
  // 有 ctx:主仓无 diff 不阻断——跨仓 task 的 diff 在跨仓仓根独立取(per-task),主仓 task 自然跳过(空 changedFiles)。
989
1015
  if (!ctx && (!diffFiles || diffFiles.length === 0)) {
@@ -1023,6 +1049,7 @@ export async function generateTaskReviewDrafts({ changeName, cwd, platformOpts =
1023
1049
  const taskFiles = readdirSync(tasksDir).filter(f => /^task-\d+\.md$/.test(f)).sort()
1024
1050
  let generated = 0
1025
1051
  let skipped = 0
1052
+ let noAttributionCount = 0
1026
1053
  const attributed = new Set()
1027
1054
 
1028
1055
  for (const tf of taskFiles) {
@@ -1081,11 +1108,6 @@ export async function generateTaskReviewDrafts({ changeName, cwd, platformOpts =
1081
1108
  : []
1082
1109
  }
1083
1110
 
1084
- // 空 changedFiles 的 task 不生成(verifyReviewGitEvidence 判空 diff 伪造,留给 agent 手补)
1085
- if (taskChangedFiles.length === 0) {
1086
- skipped++
1087
- continue
1088
- }
1089
1111
  // 主仓 task 缺 base/head(meta 缺失 / HEAD 解析失败,见上方 :968-981 注释承诺的"后续跳过"):
1090
1112
  // 跳过留给 agent 手补。此前 null.slice(0,8) 直接 TypeError 崩掉整个草稿生成(体检 BUG-06)
1091
1113
  if (!crossEntry && (!taskBase || !taskHead)) {
@@ -1097,6 +1119,42 @@ export async function generateTaskReviewDrafts({ changeName, cwd, platformOpts =
1097
1119
  taskChangedFiles.forEach(f => attributed.add(f))
1098
1120
  }
1099
1121
 
1122
+ // 空 changedFiles 的 task 也生成「无归属」草稿(坑 task-review-draft-skip-leak,2026-08-21
1123
+ // 实证:task-08/task-10 两次都因 allowed_paths 未命中 diff 被静默跳过,--done gate 拦截后
1124
+ // agent 只能从零手写 review.json)。cannot_verify 草稿本就待复核(verify 阶段兑现
1125
+ // requiredEvidence),空 changedFiles 不触发 emptyDiff 伪造误判(该判定看真实 git diff 而非
1126
+ // review.changedFiles),shouldAutoCheckTask 零 diff 守卫也保证不会被自动勾选——草稿从
1127
+ // 「漏了让 agent 从零写」变成「给了骨架让 agent 升级 verdict」。
1128
+ if (taskChangedFiles.length === 0) {
1129
+ const draft = {
1130
+ schemaVersion: REVIEW_SCHEMA_VERSION,
1131
+ task: taskId,
1132
+ base: taskBase,
1133
+ head: taskHead,
1134
+ changedFiles: [],
1135
+ specVerdict: 'cannot_verify',
1136
+ qualityVerdict: 'cannot_verify',
1137
+ requiredEvidence: ['auto-generated draft (no-attributed-diff): ' + taskId +
1138
+ ' 的 allowed_paths 未命中本次 diff 任何文件(task 未实现 / 改动与他人同文件 / 路径不匹配)——' +
1139
+ '需人工确认实际改动后升级 verdict,确属未实现则回 fail'],
1140
+ reviewerNotes: 'auto-generated draft from git diff ' + taskBase.slice(0, 8) + '..' + taskHead.slice(0, 8) +
1141
+ ';verdict=未评审(无归属 diff 兜底草稿,坑 task-review-draft-skip-leak)',
1142
+ }
1143
+ if (draftRepo) draft.repo = draftRepo
1144
+ // fail-open(对齐函数头契约 + execute-run-dir-fail-loud 测试锁定的「写失败返回统计不抛」):
1145
+ // 无归属草稿写入失败(如 tasks/ 被普通文件占位 ENOTDIR)降级 skipped,不崩整个草稿生成
1146
+ try {
1147
+ mkdirSync(reviewDir, { recursive: true })
1148
+ writeFileSync(reviewPath, JSON.stringify(draft, null, 2) + '\n')
1149
+ generated++
1150
+ noAttributionCount++
1151
+ } catch (e) {
1152
+ console.error(`[sillyspec] task ${taskId} 无归属草稿落盘失败(降级留给 agent 手补): ${e.message}`)
1153
+ skipped++
1154
+ }
1155
+ continue
1156
+ }
1157
+
1100
1158
  const draft = {
1101
1159
  schemaVersion: REVIEW_SCHEMA_VERSION,
1102
1160
  task: taskId,
@@ -1115,7 +1173,195 @@ export async function generateTaskReviewDrafts({ changeName, cwd, platformOpts =
1115
1173
  }
1116
1174
 
1117
1175
  const unattributed = (Array.isArray(diffFiles) ? diffFiles : []).filter(f => !attributed.has(f))
1118
- return { generated, skipped, unattributed, executeRunId }
1176
+ return { generated, skipped, noAttribution: noAttributionCount, unattributed, executeRunId }
1177
+ }
1178
+
1179
+ /**
1180
+ * adoptTaskReviewMechanics —— 已存在 review.json 的 mechanics 字段一键重算代填(保 verdict)。
1181
+ *
1182
+ * 背景(2026-08-21 agent-手工产出审计项①):review.json 里 schemaVersion/task/base/head/
1183
+ * changedFiles(/repo) 全部可由 CLI 从 git + task 卡机械推导(generateTaskReviewDrafts 已证明),
1184
+ * agent/子代理手算 hash、手抄 diff 清单是纯错误面——而 gate(verifyReviewGitEvidence)校验的
1185
+ * 恰是这些字段。与草稿生成互补:草稿只补「缺失」,adopt 修「已存在但 mechanics 错/缺」——
1186
+ * 保留人工语义字段(specVerdict/qualityVerdict/requiredEvidence/reviewerNotes),只重写 mechanics。
1187
+ *
1188
+ * verdict 保护策略(不猜 agent 本意):
1189
+ * - 合法 verdict(VALID_VERDICTS)原样保留;
1190
+ * - 非法/缺失 verdict 降级 cannot_verify,reviewerNotes 追加标记,requiredEvidence 缺失补占位
1191
+ * (schema 要求 cannot_verify 必带非空 requiredEvidence);
1192
+ * - JSON 解析失败的 review 不救(无法可信保留任何字段),报出来留给 agent 修 JSON 后重跑。
1193
+ *
1194
+ * fail-open:与草稿生成同契约——缺数据返回统计与 reasons,不抛。
1195
+ *
1196
+ * @param {{ changeName: string, cwd: string, platformOpts?: object, ctx?: object|null }} opts
1197
+ * @returns {Promise<{ adopted: number, unchanged: number, skipped: number, missing: number,
1198
+ * downgraded: string[], reasons: string[], executeRunId?: string }>}
1199
+ */
1200
+ export async function adoptTaskReviewMechanics({ changeName, cwd, platformOpts = {}, ctx = null }) {
1201
+ const specBase = platformOpts.specRoot || join(cwd, '.sillyspec')
1202
+ const runtimeRoot = resolveRuntimeRoot(platformOpts, specBase)
1203
+ const tasksDir = join(specBase, 'changes', changeName, 'tasks')
1204
+ const reasons = []
1205
+
1206
+ if (!changeName) {
1207
+ return { adopted: 0, unchanged: 0, skipped: 0, missing: 0, downgraded: [], reasons: ['无 changeName'] }
1208
+ }
1209
+ if (!existsSync(tasksDir)) {
1210
+ return { adopted: 0, unchanged: 0, skipped: 0, missing: 0, downgraded: [], reasons: ['无 tasks/ 目录'] }
1211
+ }
1212
+
1213
+ // executeRunId:与草稿/gate 同源 marker。缺 marker 不新建 run——adopt 修的是当前 run 里
1214
+ // 已存在的 review,marker 丢失说明无「当前 run」语义,无对象可修(gate 对 marker 缺失放行,
1215
+ // 下游 Task Review Gate 兜底,这里同口径不造 run)。
1216
+ const runIdFile = join(runtimeRoot, 'current-execute-run-id-' + changeName)
1217
+ let executeRunId = ''
1218
+ try {
1219
+ if (existsSync(runIdFile)) {
1220
+ const c = readFileSync(runIdFile, 'utf8').trim()
1221
+ if (isValidExecuteRunId(c)) executeRunId = c
1222
+ }
1223
+ } catch {}
1224
+ if (!executeRunId) {
1225
+ return { adopted: 0, unchanged: 0, skipped: 0, missing: 0, downgraded: [], reasons: ['无 execute run marker——无当前 run,adopt 无对象'] }
1226
+ }
1227
+
1228
+ // base/head + diff:与 generateTaskReviewDrafts 同源口径(meta 锡点 + worktree rev-parse +
1229
+ // resolveVerifyChangedFiles),保证 adopt 产物与 gate 校验输入一致。
1230
+ const diffFiles = resolveVerifyChangedFiles(cwd, changeName)
1231
+ let base = null
1232
+ let head = null
1233
+ let reviewGitDir = cwd
1234
+ try {
1235
+ const wm = new WorktreeManager({ cwd })
1236
+ const meta = wm.getMeta(changeName)
1237
+ base = (meta && (meta.baselineCommit || meta.baseHash)) || null
1238
+ if (meta && meta.worktreePath && meta.mode !== 'in-place-fallback' && existsSync(meta.worktreePath)) {
1239
+ reviewGitDir = meta.worktreePath
1240
+ }
1241
+ } catch {}
1242
+ if (base) {
1243
+ try {
1244
+ head = runGit(reviewGitDir, ['rev-parse', 'HEAD'])
1245
+ } catch {
1246
+ head = null
1247
+ }
1248
+ }
1249
+
1250
+ const taskFiles = readdirSync(tasksDir).filter(f => /^task-\d+\.md$/.test(f)).sort()
1251
+ let adopted = 0
1252
+ let unchanged = 0
1253
+ let skipped = 0
1254
+ let missing = 0
1255
+ const downgraded = []
1256
+
1257
+ for (const tf of taskFiles) {
1258
+ const taskId = tf.replace(/\.md$/, '')
1259
+ const reviewPath = join(runtimeRoot, 'execute-runs', executeRunId, 'tasks', taskId, 'review.json')
1260
+ if (!existsSync(reviewPath)) {
1261
+ missing++
1262
+ continue
1263
+ }
1264
+
1265
+ const existing = readReview(reviewPath)
1266
+ if (existing.parseError) {
1267
+ skipped++
1268
+ reasons.push(`${taskId}: JSON 解析失败,adopt 不猜字段——先修 JSON 再重跑`)
1269
+ continue
1270
+ }
1271
+ const prev = existing.review || {}
1272
+
1273
+ const content = readFileSync(join(tasksDir, tf), 'utf8')
1274
+ const allowedPaths = parseAllowedPaths(content)
1275
+ const taskRepo = normalizeRepoKey(parseRepo(content))
1276
+ const crossEntry = (ctx && taskRepo !== DEFAULT_REPO_KEY) ? ctx.resolve(taskRepo) : null
1277
+ let taskBase = base
1278
+ let taskHead = head
1279
+ let taskChangedFiles
1280
+ let draftRepo
1281
+
1282
+ if (crossEntry) {
1283
+ const baseCommit = parseBaseCommit(content)
1284
+ const headCommit = parseHeadCommit(content)
1285
+ if (!baseCommit || !headCommit) {
1286
+ skipped++
1287
+ reasons.push(`${taskId}: 跨仓 task 卡缺 base/head 锡点,无法 adopt`)
1288
+ continue
1289
+ }
1290
+ taskBase = baseCommit
1291
+ taskHead = headCommit
1292
+ draftRepo = taskRepo
1293
+ let crossDiffFiles = []
1294
+ try {
1295
+ const out = runGit(crossEntry.gitDir, ['diff', '--name-only', `${baseCommit}..${headCommit}`])
1296
+ crossDiffFiles = out ? out.split('\n').filter(Boolean).map(p => p.replace(/\\/g, '/')) : []
1297
+ } catch {
1298
+ skipped++
1299
+ reasons.push(`${taskId}: 跨仓 git diff 失败(${crossEntry.gitDir})`)
1300
+ continue
1301
+ }
1302
+ taskChangedFiles = allowedPaths.length > 0
1303
+ ? crossDiffFiles.filter(f => allowedPaths.some(p => pathMatches(f, p)))
1304
+ : crossDiffFiles
1305
+ } else {
1306
+ const mainDiff = Array.isArray(diffFiles) ? diffFiles : []
1307
+ taskChangedFiles = allowedPaths.length > 0
1308
+ ? mainDiff.filter(f => allowedPaths.some(p => pathMatches(f, p)))
1309
+ : []
1310
+ }
1311
+
1312
+ if (!crossEntry && (!taskBase || !taskHead)) {
1313
+ skipped++
1314
+ reasons.push(`${taskId}: 主仓缺 worktree meta base/head,无法 adopt`)
1315
+ continue
1316
+ }
1317
+
1318
+ const merged = { ...prev }
1319
+ merged.schemaVersion = REVIEW_SCHEMA_VERSION
1320
+ merged.task = taskId
1321
+ merged.base = taskBase
1322
+ merged.head = taskHead
1323
+ merged.changedFiles = taskChangedFiles
1324
+ if (draftRepo) merged.repo = draftRepo
1325
+
1326
+ let verdictTouched = false
1327
+ for (const v of ['specVerdict', 'qualityVerdict']) {
1328
+ if (!VALID_VERDICTS.includes(merged[v])) {
1329
+ merged[v] = 'cannot_verify'
1330
+ verdictTouched = true
1331
+ }
1332
+ }
1333
+ if (verdictTouched) downgraded.push(taskId)
1334
+ let evidenceFilled = false
1335
+ if ((merged.specVerdict === 'cannot_verify' || merged.qualityVerdict === 'cannot_verify')
1336
+ && (!Array.isArray(merged.requiredEvidence) || merged.requiredEvidence.length === 0)) {
1337
+ merged.requiredEvidence = [`adopt 代填:原 review 缺 requiredEvidence(verdict=${merged.specVerdict}/${merged.qualityVerdict}),待人工复核补证据`]
1338
+ evidenceFilled = true
1339
+ }
1340
+ if (verdictTouched) {
1341
+ merged.reviewerNotes = String(merged.reviewerNotes || '') + (merged.reviewerNotes ? '; ' : '')
1342
+ + 'adopt: 原 verdict 非法/缺失已降级 cannot_verify(不猜本意)'
1343
+ }
1344
+
1345
+ const mechanicsChanged = prev.schemaVersion !== merged.schemaVersion
1346
+ || prev.base !== merged.base
1347
+ || prev.head !== merged.head
1348
+ || JSON.stringify(prev.changedFiles ?? null) !== JSON.stringify(merged.changedFiles)
1349
+ || prev.repo !== merged.repo
1350
+
1351
+ try {
1352
+ if (mechanicsChanged || verdictTouched || evidenceFilled) {
1353
+ writeFileSync(reviewPath, JSON.stringify(merged, null, 2) + '\n')
1354
+ adopted++
1355
+ } else {
1356
+ unchanged++
1357
+ }
1358
+ } catch (e) {
1359
+ skipped++
1360
+ reasons.push(`${taskId}: 写回失败 ${e.message}`)
1361
+ }
1362
+ }
1363
+
1364
+ return { adopted, unchanged, skipped, missing, downgraded, reasons, executeRunId }
1119
1365
  }
1120
1366
 
1121
1367
  /**
@@ -0,0 +1,264 @@
1
+ /**
2
+ * verify-probes.js — `sillyspec verify-probes` 的机械探针实现 + verify-result.md 骨架生成
3
+ * (2026-08-21 agent-手工产出审计第三批 H1/H3/H5/H7/F9)。
4
+ *
5
+ * verify-probes.md 模板定义六个探针,agent 此前逐条手跑 grep/递归查找/git 对账再手工拼表格。
6
+ * 本模块把纯机械的四个探针命令化(语义判断的留 agent,输出里显式标注):
7
+ * 探针1 未实现标记扫描:design §6 清单的具体文件逐行 grep TODO/FIXME/尚未实现 等
8
+ * 探针3 测试覆盖:逐 task 按 allowed_paths 定位模块目录,递归找测试文件(co-located tests/ 陷阱)
9
+ * 探针5 API 契约对账:复用 contract-matrix.verifyApiParity(endpoints.json × 前端调用)+ 表格渲染
10
+ * 探针6 删除对账:git diff --name-status HEAD 的 D/R × design 声明操作三态判定
11
+ * 探针2(关键词提取半语义)/探针3.4 集成盲区/3.5 断言抽查/探针4(决策追踪语义)留 agent。
12
+ *
13
+ * verify-result.md 骨架:七章节固定结构 + 探针结果机械预填 + 其余章节 <!--TODO--> 占位。
14
+ * 结论章节留「待填」——extractVerifyConclusion 找不到 PASS/FAIL 关键词即判不过,骨架不能
15
+ * 直接过门(与 symbol-impact 骨架同款防偷懒语义)。
16
+ */
17
+ import { existsSync, readFileSync, readdirSync, writeFileSync } from 'fs'
18
+ import { join, dirname, basename } from 'path'
19
+ import { gitQuiet } from './git-helper.js'
20
+ import { parseFileChangeListDetailed } from './change-list.js'
21
+ import { parseAllowedPaths } from './stages/plan-postcheck.js'
22
+ import { verifyApiParity } from './contract-matrix.js'
23
+ import { resolveSpecDir, resolveRuntimeRoot } from './run/shared.js'
24
+
25
+ const TODO_MARKER_RE = /尚未实现|TODO|FIXME|HACK|XXX/
26
+ const TEST_FILE_RE = /test|spec/i
27
+ const PROBE1_MAX_MATCHES = 200
28
+
29
+ function listTaskIds(tasksPath) {
30
+ if (!existsSync(tasksPath)) return []
31
+ const ids = []
32
+ for (const line of readFileSync(tasksPath, 'utf8').split('\n')) {
33
+ const m = line.match(/^[-*]\s*\[[ xX]\]\s*(task-\d+)\b/)
34
+ if (m) ids.push(m[1])
35
+ }
36
+ return ids
37
+ }
38
+
39
+ /** 递归找测试文件(排除 node_modules/.git/dist 等;返回 posix 相对 cwd 路径,封顶展示) */
40
+ function findTestFiles(rootDir, cwd, cap = 10) {
41
+ const found = []
42
+ const skip = new Set(['node_modules', '.git', '.gradle', '__pycache__', '.venv', 'dist', 'build', 'target', 'out'])
43
+ const walk = (d) => {
44
+ let entries
45
+ try {
46
+ entries = readdirSync(d, { withFileTypes: true })
47
+ } catch {
48
+ return
49
+ }
50
+ for (const e of entries) {
51
+ const full = join(d, e.name)
52
+ if (e.isDirectory()) {
53
+ if (!skip.has(e.name)) walk(full)
54
+ } else if (e.isFile() && TEST_FILE_RE.test(basename(e.name))) {
55
+ found.push(full.split('\\').join('/').replace(cwd.split('\\').join('/').replace(/\/$/, '') + '/', ''))
56
+ if (found.length >= cap) return
57
+ }
58
+ }
59
+ }
60
+ if (existsSync(rootDir)) walk(rootDir)
61
+ return found
62
+ }
63
+
64
+ /**
65
+ * 跑四个机械探针。
66
+ * @param {{ cwd: string, changeName: string, specDir?: string|null }} opts
67
+ * @returns {{ probe1: object, probe3: object, probe5: object, probe6: object }}
68
+ */
69
+ export function runVerifyProbes({ cwd, changeName, specDir = null }) {
70
+ const specBase = resolveSpecDir(cwd, { specDir })
71
+ const changeDir = join(specBase, 'changes', changeName)
72
+ const designPath = join(changeDir, 'design.md')
73
+ const detailed = existsSync(designPath) ? parseFileChangeListDetailed(designPath) : []
74
+ const designOps = new Map(detailed.map(e => [e.path, e.operation]))
75
+
76
+ // ── 探针 1:未实现标记扫描(design 清单具体文件;glob 项列出让 agent 展开)──
77
+ const probe1 = { matches: [], globEntries: [], skippedFiles: [] }
78
+ for (const e of detailed) {
79
+ if (e.path.startsWith('.sillyspec/')) continue
80
+ if (/[*?[\]]/.test(e.path)) {
81
+ probe1.globEntries.push(e.path)
82
+ continue
83
+ }
84
+ const abs = join(cwd, e.path)
85
+ if (!existsSync(abs)) {
86
+ probe1.skippedFiles.push(e.path)
87
+ continue
88
+ }
89
+ try {
90
+ const lines = readFileSync(abs, 'utf8').split('\n')
91
+ lines.forEach((line, i) => {
92
+ if (probe1.matches.length >= PROBE1_MAX_MATCHES) return
93
+ if (TODO_MARKER_RE.test(line)) probe1.matches.push({ file: e.path, line: i + 1, content: line.trim().slice(0, 160) })
94
+ })
95
+ } catch {
96
+ probe1.skippedFiles.push(e.path)
97
+ }
98
+ }
99
+
100
+ // ── 探针 3:验收标准测试覆盖(task 卡 allowed_paths → 模块目录递归找测试文件)──
101
+ const tasksPath = join(changeDir, 'tasks.md')
102
+ const probe3 = { tasks: [], note: '集成盲区(路由/跨模块装配)与断言有效性抽查是语义判断,留给 agent 逐 task 标注 ⚠️' }
103
+ for (const taskId of listTaskIds(tasksPath)) {
104
+ const cardPath = join(changeDir, 'tasks', `${taskId}.md`)
105
+ let allowed = []
106
+ if (existsSync(cardPath)) allowed = parseAllowedPaths(readFileSync(cardPath, 'utf8'))
107
+ const moduleDirs = [...new Set(allowed.map(p => dirname(p.split('\\').join('/'))).filter(d => d && d !== '.'))]
108
+ const testFiles = []
109
+ for (const d of moduleDirs) {
110
+ for (const f of findTestFiles(join(cwd, d), cwd)) {
111
+ if (!testFiles.includes(f)) testFiles.push(f)
112
+ }
113
+ }
114
+ probe3.tasks.push({
115
+ task: taskId,
116
+ moduleDirs,
117
+ testFiles: testFiles.slice(0, 10),
118
+ testFileCount: testFiles.length,
119
+ hasTest: testFiles.length > 0,
120
+ located: moduleDirs.length > 0,
121
+ })
122
+ }
123
+
124
+ // ── 探针 5:API 契约对账(复用 verifyApiParity:endpoints.json × 前端调用)──
125
+ const runtimeRoot = resolveRuntimeRoot({ specRoot: specDir }, specBase)
126
+ const probe5 = verifyApiParity(specBase, cwd, runtimeRoot, changeName)
127
+
128
+ // ── 探针 6:代码删除对账(git diff --name-status HEAD 的 D/R × design 声明三态)──
129
+ const probe6 = { deletions: [], note: '以 git 事实为准(真实 > 声明);是否 FAIL blocker 由 agent 诚实判定' }
130
+ const nsOut = gitQuiet(cwd, ['diff', '--name-status', 'HEAD']) || ''
131
+ for (const row of nsOut.split('\n').filter(Boolean)) {
132
+ const [status, ...paths] = row.split('\t')
133
+ const st = (status || '').trim().toUpperCase()
134
+ if (!/^[DRC]/.test(st)) continue
135
+ // R/C 的旧路径等价删除(paths[0]);D 的唯一路径
136
+ const target = (st.startsWith('D') ? paths[0] : paths[0]) || ''
137
+ const posix = target.split('\\').join('/')
138
+ if (!posix || posix.startsWith('.sillyspec/') || posix === 'meta.json' || basename(posix) === 'meta.json') continue
139
+ const op = designOps.get(posix)
140
+ let verdict
141
+ if (op && /删除|delete|del/i.test(op)) verdict = '✅ 合规(design 声明删除)'
142
+ else if (op) verdict = `❌ 高风险(design 声明「${op}」却整文件删除)`
143
+ else verdict = '⚠️ 未声明删除(design 清单未列出)'
144
+ probe6.deletions.push({ path: posix, status: st, designOp: op || null, verdict })
145
+ }
146
+
147
+ return { probe1, probe3, probe5, probe6 }
148
+ }
149
+
150
+ /**
151
+ * 渲染探针结果为 markdown(可直接粘进 verify-result.md「探针结果」章节)。
152
+ */
153
+ export function renderVerifyProbesReport(result) {
154
+ const L = []
155
+ const { probe1, probe3, probe5, probe6 } = result
156
+
157
+ L.push('#### 探针 1:未实现标记扫描(design 清单文件)')
158
+ if (probe1.matches.length === 0) {
159
+ L.push('- ✅ 无 TODO/FIXME/尚未实现 标记命中')
160
+ } else {
161
+ for (const m of probe1.matches) L.push(`- ⚠️ \`${m.file}:${m.line}\` ${m.content}`)
162
+ }
163
+ if (probe1.globEntries.length > 0) L.push(`- ℹ️ glob 项未展开(agent 手动展开扫描):${probe1.globEntries.join('、')}`)
164
+ if (probe1.skippedFiles.length > 0) L.push(`- ℹ️ 清单文件不存在(跳过):${probe1.skippedFiles.join('、')}`)
165
+ L.push('')
166
+
167
+ L.push('#### 探针 2:设计关键词覆盖')
168
+ L.push('<!--TODO: 半语义探针——从 design 提取能力关键词逐个 grep 确认实现(agent 执行)-->')
169
+ L.push('')
170
+
171
+ L.push('#### 探针 3:验收标准测试覆盖')
172
+ if (probe3.tasks.length === 0) {
173
+ L.push('- ℹ️ tasks.md 无 checkbox 任务')
174
+ } else {
175
+ for (const t of probe3.tasks) {
176
+ if (!t.located) {
177
+ L.push(`- ⚠️ ${t.task}: 无 task 卡/allowed_paths,无法定位模块目录(agent 手查)`)
178
+ } else if (t.hasTest) {
179
+ L.push(`- ✅ ${t.task}: 模块目录(${t.moduleDirs.join('、')})找到 ${t.testFileCount} 个测试文件(${t.testFiles.slice(0, 5).join('、')}${t.testFileCount > 5 ? ' …' : ''})`)
180
+ } else {
181
+ L.push(`- ⚠️ ${t.task}: 模块目录(${t.moduleDirs.join('、')})递归未找到测试文件(含 co-located tests/)`)
182
+ }
183
+ }
184
+ }
185
+ L.push(`- ℹ️ ${probe3.note}`)
186
+ L.push('')
187
+
188
+ L.push('#### 探针 4:决策追踪覆盖')
189
+ L.push('<!--TODO: 语义探针——D-xxx@vN → FR-xxx → plan/task 引用 → 证据回指闭环(agent 执行)-->')
190
+ L.push('')
191
+
192
+ L.push('#### 探针 5:API Contract Parity')
193
+ L.push(`- ${probe5.summary || `backend ${probe5.backendCount ?? 0} 端点 / frontend ${probe5.frontendCount ?? 0} 调用`}`)
194
+ if ((probe5.missingBackend || []).length > 0) {
195
+ L.push('')
196
+ L.push('| 状态 | 前端调用 | 后端端点 | 文件 |')
197
+ L.push('|---|---|---|---|')
198
+ for (const m of probe5.missingBackend) {
199
+ L.push(`| ❌ missing | ${m.method} ${m.path} | — | ${m.consumerFile || '?'}${m.consumerLine ? ':' + m.consumerLine : ''} |`)
200
+ }
201
+ L.push('')
202
+ L.push('- ❌ contract gap 是真实集成缺陷——诚实判 FAIL 并回 execute 补端点(CLI 仅 advisory 不硬阻断)')
203
+ }
204
+ if ((probe5.unusedBackend || []).length > 0) {
205
+ L.push(`- ⚠️ ${probe5.unusedBackend.length} 个后端端点前端未调用(warning 不阻断):${probe5.unusedBackend.slice(0, 5).map(u => `${u.method} ${u.path}`).join('、')}${probe5.unusedBackend.length > 5 ? ' …' : ''}`)
206
+ }
207
+ L.push('')
208
+
209
+ L.push('#### 探针 6:代码删除对账')
210
+ if (probe6.deletions.length === 0) {
211
+ L.push('- ✅ git diff 无整文件删除(D/R/C)记录')
212
+ } else {
213
+ for (const d of probe6.deletions) {
214
+ L.push(`- ${d.verdict} \`${d.path}\`(git 状态 ${d.status})`)
215
+ }
216
+ }
217
+ L.push(`- ℹ️ ${probe6.note}`)
218
+ return L.join('\n')
219
+ }
220
+
221
+ /**
222
+ * 生成 verify-result.md 骨架(七章节;探针结果机械预填,语义章节 <!--TODO--> 占位)。
223
+ * 结论章节留「待填」——extractVerifyConclusion 无 PASS/FAIL 关键词即判不过,骨架不能直接过门。
224
+ * @returns {string|null} 骨架全文;无 design.md/tasks.md(非完整流程变更)→ null
225
+ */
226
+ export function generateVerifyResultSkeleton(result) {
227
+ const L = [
228
+ '# 验证报告(骨架由 `sillyspec verify-probes --change <变更名> --init` 生成)',
229
+ '',
230
+ '> 探针结果已机械预填;其余章节把 `<!--TODO-->` 替换为真实内容。**结论必须写明 PASS / FAIL**——',
231
+ '> 留「待填」会被 gate 判不过(fail-closed)。',
232
+ '',
233
+ '## 结论:<待填:PASS 或 FAIL(+一句话理由)>',
234
+ '',
235
+ '## 任务完成度',
236
+ '<!--TODO: 逐 task 对照 tasks.md 勾选与验收标准,完成/未完成/存疑三态-->',
237
+ '',
238
+ '## 设计一致性',
239
+ '<!--TODO: 实现与 design.md 的偏差(无偏差也显式写「一致」)-->',
240
+ '',
241
+ '## 探针结果(CLI 机械预填)',
242
+ renderVerifyProbesReport(result),
243
+ '',
244
+ '## 测试结果',
245
+ '<!--TODO: 测试命令 + 结果(通过数/失败数;known_failures 豁免逐条注明)-->',
246
+ '',
247
+ '## 决策追踪矩阵(如存在 decisions.md;无则删本节)',
248
+ '<!--TODO: | 决策 ID | FR | Task | Evidence | 状态 |(D-xxx@vN → FR-xxx → task → 证据回指闭环)-->',
249
+ '',
250
+ '## 技术债务',
251
+ '<!--TODO: TODO/FIXME/HACK 统计(探针 1 的命中已预填在上方探针结果)-->',
252
+ '',
253
+ '## 变更风险等级',
254
+ '<!--TODO: doc-only / unit-sufficient / contract-required / integration-critical / deployment-critical;若 design.md frontmatter 有 risk_level 显式声明,写明「显式声明 = <等级>」+ 理由(豁免可审计)-->',
255
+ '',
256
+ '## Runtime Evidence',
257
+ '<!--TODO: 关键命令输出/时间戳/commit hash 证据链;integration/deployment-critical 必填,按实际触碰的运行时组件写(启动命令/端点/请求响应/日志片段/生命周期终态断言/失败模式排除),未涉及的行写「不涉及」-->',
258
+ '',
259
+ '## 代码审查',
260
+ '<!--TODO: 问题列表 + 总体评价-->',
261
+ '',
262
+ ]
263
+ return L.join('\n')
264
+ }