sillyspec 3.26.14 → 3.27.1

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 (70) 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-explore/SKILL.md +10 -3
  7. package/.claude/skills/sillyspec-plan/SKILL.md +10 -2
  8. package/.claude/skills/sillyspec-quick/SKILL.md +2 -2
  9. package/.claude/skills/sillyspec-resume/SKILL.md +2 -0
  10. package/.claude/skills/sillyspec-verify/SKILL.md +4 -0
  11. package/.claude/skills/sillyspec-workspace/SKILL.md +7 -1
  12. package/README.md +1 -1
  13. package/package.json +1 -1
  14. package/packages/dashboard/server/index.js +22 -6
  15. package/src/change-risk-profile.js +21 -6
  16. package/src/commit-suggest.js +202 -0
  17. package/src/config-cat.js +88 -0
  18. package/src/db.js +14 -3
  19. package/src/docs-check.js +598 -534
  20. package/src/doctor-diagnostics.js +102 -1
  21. package/src/endpoint-extractor.js +21 -2
  22. package/src/fs-atomic.js +4 -2
  23. package/src/git-helper.js +73 -5
  24. package/src/hooks/claude-pre-tool-use.cjs +10 -5
  25. package/src/hooks/worktree-guard.js +42 -4
  26. package/src/index.js +557 -12
  27. package/src/init.js +1 -1
  28. package/src/local-register.js +98 -0
  29. package/src/module-impact.js +166 -0
  30. package/src/module-resolve.js +211 -0
  31. package/src/modules.js +82 -2
  32. package/src/progress/change-registry.js +43 -4
  33. package/src/progress/stage-machine.js +10 -5
  34. package/src/progress/step-store.js +12 -8
  35. package/src/progress.js +19 -3
  36. package/src/quicklog.js +106 -1
  37. package/src/run/command.js +95 -1
  38. package/src/run/complete-handlers.js +73 -5
  39. package/src/run/complete.js +47 -14
  40. package/src/run/gates.js +144 -17
  41. package/src/run/multi-repo-context.js +3 -1
  42. package/src/run/next.js +128 -0
  43. package/src/run/prompt.js +36 -4
  44. package/src/run/quick-audit.js +5 -2
  45. package/src/run/shared.js +48 -20
  46. package/src/run/stage.js +37 -3
  47. package/src/scan-postcheck.js +142 -2
  48. package/src/stage-contract-spec.js +1 -1
  49. package/src/stage-contract.js +14 -1
  50. package/src/stage-review.js +15 -6
  51. package/src/stages/archive.js +4 -2
  52. package/src/stages/brainstorm.js +1 -0
  53. package/src/stages/execute.js +275 -44
  54. package/src/stages/explore.js +11 -2
  55. package/src/stages/knowledge.js +3 -2
  56. package/src/stages/quick.js +18 -9
  57. package/src/stages/scan.js +1 -0
  58. package/src/stages/verify.js +16 -51
  59. package/src/sync.js +44 -3
  60. package/src/task-review.js +273 -11
  61. package/src/taskcard.js +52 -22
  62. package/src/verify-postcheck.js +223 -6
  63. package/src/verify-probes.js +264 -0
  64. package/src/workspace.js +125 -0
  65. package/src/worktree-apply.js +43 -11
  66. package/src/worktree-deps.js +59 -5
  67. package/src/worktree.js +128 -14
  68. package/templates/claude-instruction.md +1 -1
  69. package/templates/prompts/taskcard-rules.md +1 -1
  70. package/templates/prompts/verify-probes.md +13 -47
@@ -172,11 +172,11 @@ export const definition = {
172
172
  - Gradle:\`./gradlew :<模块>:test\`
173
173
  - npm/pnpm:\`pnpm test --filter=<包名>\` 或 \`npm test -- --testPathPattern=<相关文件>\`
174
174
  - Python:\`pytest <变更模块路径>/\`
175
- 3. 如果 local.yaml 有 lint 命令,运行 lint 检查
175
+ 3. 如果 local.yaml 有 lint 命令,运行 lint 检查并修复报出的问题(--done 时 CLI 会亲自实测 commands.lint 对账,实测失败会明示)
176
176
  4. 搜索技术债务:grep TODO/FIXME/HACK/XXX(仅限变更文件)
177
177
 
178
178
  ### 注意
179
- - **CLI 对账机制**:verify 阶段最终 --done 时,CLI 会亲自执行 local.yaml 的 commands.test(同步,耗时可能较长);实测失败会直接阻断 verify 完成,谎报测试结果没有意义
179
+ - **CLI 对账机制**:verify 阶段最终 --done 时,CLI 会亲自执行 local.yaml 的 commands.test(同步,耗时可能较长);实测失败会直接阻断 verify 完成,谎报测试结果没有意义。commands.lint 同样会被 CLI 实测对账(advisory)
180
180
  - 冒烟测试非必需:全量/模块实测结果以 CLI 对账为准,本步跑的结果仅供你提前发现问题并写入验证报告
181
181
 
182
182
  ### 输出
@@ -197,57 +197,22 @@ export const definition = {
197
197
  你只需:在 verify-result.md 的「变更风险等级」section 如实记录变更性质;若变更涉及 daemon/backend 跨进程、session/lease/lifecycle 状态机、或部署启动路径,在「Runtime Evidence」section 提供真实集成证据(启动命令、daemon↔backend 调用与日志关键片段、终态断言)。
198
198
  - **集成证据是自报告、CLI 不独立运行时核验**:「Runtime Evidence」由你如实填写,CLI 只校验其**字面存在**(是否含关键词),**不会替你启动 daemon、打真实请求或跑迁移**——它是否名副其实取决于你是否实跑过。务必实跑后据实填写,不得凭堆关键词通过门控。(测试套件对账另算:commands.test 由 CLI 真实执行,那块谎报无效。)
199
199
 
200
- 3. 生成 verify-result.md 文件,保存到 \`.sillyspec/changes/<change-name>/verify-result.md\`
201
- 4. 给出结论:PASS / PASS WITH NOTES / FAIL(受风险门控约束)
200
+ 3. **生成 verify-result.md 骨架(勿从零手写)**:先跑 \`sillyspec verify-probes --change <change-name> --init\`——一条命令生成九章节骨架(已存在不覆盖),其中**探针结果章节已机械预填**(探针 1 的 TODO/FIXME 命中清单、探针 3 的测试覆盖、探针 5 的 API 契约对账表、探针 6 的删除对账三态判定),文件落 \`.sillyspec/changes/<change-name>/verify-result.md\`。你只需把各 \`<!--TODO-->\` 占位替换为语义结论;半语义探针(2 关键词覆盖 / 4 决策追踪)与断言抽查、集成盲区标注由你补在对应 TODO 处
201
+ 4. 给出结论:PASS / PASS WITH NOTES / FAIL(受风险门控约束)——**结论必须写明 PASS/FAIL 字样,留「待填」会被 gate 判不过**
202
202
  5. **核对 module-impact.md**(若 changes/<change>/module-impact.md 存在):对照本次实际代码变更(git diff)与 module-impact.md 的模块影响矩阵,发现不一致(漏标受影响模块 / 影响类型错误 / 实际未触碰的模块被误标)则在 verify-result.md 标注。module-impact 由 plan 首版生成、execute 各 Wave 更新,verify 是最后一次核对机会(archive 仅终审不再生成)。这是 advisory 核对(不阻断 verify 完成),但 module-impact 与实际严重背离应记为风险。
203
203
 
204
- ### verify-result.md 格式
205
- \`\`\`markdown
206
- # 验证报告(Verify Result)
207
-
208
- ## 结论
209
- PASS / PASS WITH NOTES / FAIL
210
-
211
- ## 任务完成度
212
- (逐项检查任务的结果)
213
-
214
- ## 设计一致性
215
- (对照 design.md 的检查结果)
216
-
217
- ## 探针结果
218
- - 未实现标记扫描:...
219
- - 关键词覆盖:...
220
- - 测试覆盖(含断言有效性抽查):...
221
- - 决策追踪覆盖:...
222
- - API 契约对账:...
223
- - 代码删除对账:声明修改/新增却被整文件删除 = 高风险;清单未列出的删除 = 未声明(CLI 会用 git diff --name-status HEAD advisory 复核,仅 warn 不阻断——是否 FAIL blocker 由你诚实判定)
224
-
225
- ## 决策追踪矩阵(如存在 decisions.md)
226
- | 决策 ID | FR | Task | Evidence | 状态 |
227
- |---|---|---|---|---|
228
- | D-001@v1 | FR-01 | task-01 | test/file/path | PASS/PARTIAL/MISSING |
229
-
230
- ## 测试结果
231
- (测试套件执行结果)
232
-
233
- ## 技术债务
234
- (TODO/FIXME/HACK 统计)
235
-
236
- ## 变更风险等级
237
- (自动检测的 change_risk_profile: doc-only / unit-sufficient / contract-required / integration-critical / deployment-critical;若 design.md frontmatter 有 risk_level 显式声明,以声明为准并在此注明「显式声明 = <等级>」+ 理由)
238
-
239
- ## Runtime Evidence(integration-critical / deployment-critical 必填;自报告,CLI 仅校验字面存在、不独立核验,须真实执行过)
240
- 按本次变更实际触碰的运行时组件填写(模板是通用形态——长驻进程 / 服务端点 / 生命周期状态机的证据结构;未涉及的行写「不涉及」并注明原因,勿堆砌无关关键词):
241
- - 长驻进程/服务 启动命令:
242
- - 服务地址(本变更触碰的端点):
243
- - 触发核心路径的请求/命令(附关键响应):
244
- - 进程日志关键片段(证明走了新路径、无降级/回退分支):
245
- - 生命周期终态断言(本变更涉及的状态机:初始态 → 运行态 → 终态,逐个确认到达):
246
- - 失败模式排除(针对本变更声明的风险,逐条说明为何未触发):
247
-
248
- ## 代码审查
249
- (问题列表 + 总体评价)
250
- \`\`\`
204
+ ### verify-result.md 章节结构(骨架已含,占位替换即可)
205
+
206
+ 结论(PASS / PASS WITH NOTES / FAIL)→ 任务完成度 → 设计一致性 → 探针结果(已预填)→ 测试结果 → 决策追踪矩阵(\`| 决策 ID | FR | Task | Evidence | 状态 |\`,存在 decisions.md 才留)→ 技术债务 → 变更风险等级 → Runtime Evidence → 代码审查。
207
+
208
+ **Runtime Evidence 行结构**(integration/deployment-critical 必填;按实际触碰的运行时组件写,未涉及的行写「不涉及」勿堆关键词):长驻进程启动命令 / 触碰的服务端点 / 触发核心路径的请求(附关键响应)/ 进程日志关键片段(证明走了新路径)/ 生命周期终态断言(初始态→运行态→终态)/ 失败模式排除(逐条说明为何未触发)。
209
+
210
+ ### 🧹 服务进程登记与自动回收(真实启动验证必读,坑 verify-service-process-leak)
211
+ 「真实启动一次」起的长驻服务(uvicorn/node server 等)**必须登记 PID,CLI 在 verify 收尾自动回收**——不登记的进程会挂死机器(实测 uvicorn 漏挂一天多):
212
+ 1. 后台启动服务并取 PID(PowerShell:\`Start-Process -PassThru\` 取 \`.Id\`;bash:\`cmd & echo $!\`)
213
+ 2. 每行一个 PID 追加到 \`\${SPEC_ROOT}/.runtime/verify-services.pids\`(\`Add-Content\` / \`echo >>\`)
214
+ 3. verify \`--done\` 时 CLI 读该文件逐个 kill 并清空——你在 verify-result.md 的「生命周期终态断言」里写「PID 已登记,CLI 收尾回收」即可
215
+ 登记是硬要求:没有 PID 登记的「真实启动」证据在 deployment-critical 门控下视为不完整(进程无法证明被回收)。
251
216
 
252
217
  ### 输出
253
218
  verify-result.md 路径 + 验证报告摘要 + 下一步命令
package/src/sync.js CHANGED
@@ -13,6 +13,7 @@ import { writeAtomicSync } from './fs-atomic.js';
13
13
  import { join } from 'path';
14
14
  import { resolvePlatformSpecDir } from './progress.js';
15
15
  import { safeGit } from './git-helper.js';
16
+ import { openDatabase } from './db-engine.js';
16
17
  import { PLATFORM_MANAGED_FILENAME } from './run/shared.js';
17
18
  import { syncSpecTree } from './spec-sync.js';
18
19
 
@@ -267,6 +268,9 @@ async function fetchJsonWithStatus(url, options = {}) {
267
268
  export class SyncManager {
268
269
  constructor(cwd) {
269
270
  this.cwd = cwd;
271
+ // 归档尾声静默化旗标(坑 post-archive-sync-noise):sync() 探测到已归档时置位,
272
+ // 链内 syncDocuments 的「变更不存在」降 debug(每次 sync 调用重置)
273
+ this._suppressDocsMissingWarn = false;
270
274
  }
271
275
 
272
276
  /**
@@ -411,12 +415,24 @@ export class SyncManager {
411
415
  return { synced: 0, errors: ['未指定变更名称'] };
412
416
  }
413
417
 
414
- // 检查变更目录是否存在(warn 不拦:归档后目录已移走但 DB 仍有最终状态需推平台,
415
- // serializeForSync 从 DB 读不依赖文件系统目录;目录存在时 warn 也无害,仅辅助排查)
418
+ // 检查变更目录是否存在(归档后目录已移走但 DB 仍有最终状态需推平台,serializeForSync 从
419
+ // DB 读不依赖文件系统目录)。归档终态探测(坑 post-archive-sync-noise,2026-08-21 实证:
420
+ // 归档尾声连打「目录不存在/变更不存在」warn,观感像出错):确认已归档(changes/archive/
421
+ // 有实体 或 DB status='archived')时降为一行 info 措辞——正常时序不是告警。
416
422
  const changeDir = join(this.cwd, CHANGES_DIR, changeName);
423
+ let archivedQuietly = false;
417
424
  if (!existsSync(changeDir)) {
418
- console.warn(`[sync] 变更目录不存在(可能是已归档,继续从 DB 同步最终状态): ${changeName}`);
425
+ archivedQuietly = existsSync(join(this.cwd, CHANGES_DIR, 'archive', changeName))
426
+ || this._isChangeArchivedInDb(changeName);
427
+ if (archivedQuietly) {
428
+ console.log(`[sync] 变更已归档(目录在 archive/),继续从 DB 推送最终状态: ${changeName}`);
429
+ } else {
430
+ console.warn(`[sync] 变更目录不存在(可能是已归档,继续从 DB 同步最终状态): ${changeName}`);
431
+ }
419
432
  }
433
+ // 透传给本次 sync 链内的 syncDocuments(best-effort 四件套直推:归档后目录已移走,
434
+ // 其「变更不存在」warn 属正常时序,静默化)
435
+ this._suppressDocsMissingWarn = archivedQuietly;
420
436
 
421
437
  const MAX_PUSH_ATTEMPTS = 2;
422
438
  for (let attempt = 1; attempt <= MAX_PUSH_ATTEMPTS; attempt++) {
@@ -617,6 +633,12 @@ export class SyncManager {
617
633
  // 返回值 → 文档在平台模式下静默永不同步。
618
634
  const changeDir = join(safePlatformSpecDir(this.cwd) || join(this.cwd, '.sillyspec'), 'changes', changeName);
619
635
  if (!existsSync(changeDir)) {
636
+ // 归档尾声静默化(坑 post-archive-sync-noise):sync() 链内调用(_suppressDocsMissingWarn
637
+ // 置位,目录已移 archive/ 的正常时序)降为 debug;独立调用(手动 sync-docs)保留 warn
638
+ if (this._suppressDocsMissingWarn) {
639
+ debugLog(`[sync] 四件套目录不存在(变更已归档,跳过文档直推): ${changeName}`);
640
+ return { synced: 0, errors: [] };
641
+ }
620
642
  console.warn(`[sync] 变更不存在: ${changeName}`);
621
643
  return { synced: 0, errors: [`变更不存在: ${changeName}`] };
622
644
  }
@@ -734,6 +756,25 @@ export class SyncManager {
734
756
  return config.platform || null;
735
757
  }
736
758
 
759
+ /**
760
+ * DB 侧归档态探测(坑 post-archive-sync-noise):changes 表 status='archived' 即真。
761
+ * 只读直查(node:sqlite,不经 ProgressManager——sync 是短进程,惰性 import 无收益且
762
+ * 本调用点在同步代码段);DB 不存在/无行/读失败 → false(保守退回普通 warn 措辞)。
763
+ * @param {string} changeName
764
+ * @returns {boolean}
765
+ */
766
+ _isChangeArchivedInDb(changeName) {
767
+ try {
768
+ const dbPath = join(safePlatformSpecDir(this.cwd) || join(this.cwd, '.sillyspec'), '.runtime', 'sillyspec.db');
769
+ if (!existsSync(dbPath)) return false;
770
+ const db = openDatabase(dbPath, { readOnly: true });
771
+ try {
772
+ const row = db.prepare("SELECT status FROM changes WHERE name = ?").get(changeName);
773
+ return row?.status === 'archived';
774
+ } finally { try { db.close() } catch {} }
775
+ } catch { return false }
776
+ }
777
+
737
778
  /**
738
779
  * 写冲突文件 .runtime/sync-conflict-<change>.json(task-12 / D-002 / D-008 / D-010 / FR-05)。
739
780
  * push 409 与 pull 本地脏度双向冲突命中时调用,强制提示用户走 platform resolve 三选一。
@@ -10,7 +10,7 @@
10
10
 
11
11
  import { existsSync, readFileSync, mkdirSync, writeFileSync, readdirSync, statSync } from 'fs'
12
12
  import { join, resolve } from 'path'
13
- import { git } from './git-helper.js'
13
+ import { git, unquoteGitPath } from './git-helper.js'
14
14
  import { pathMatches } from './change-list.js'
15
15
  import { parseAllowedPaths, parseRepo, parseBaseCommit, parseHeadCommit } from './stages/plan-postcheck.js'
16
16
  import { resolveVerifyChangedFiles } from './verify-postcheck.js'
@@ -378,6 +378,30 @@ function isTaskLowRisk(changeDir, taskId) {
378
378
  }
379
379
  }
380
380
 
381
+ /**
382
+ * 纯验证 task 判定(坑 verification-task-zero-diff,2026-08-21 实证):task-10/11 类
383
+ * 「跑测试/部署验证/观测确认」型任务无代码 diff 是本质属性而非伪造——emptyDiff 判伪造
384
+ * 对它们必误杀,此前只能引用验证区间并手工披露归属绕过。task 卡 frontmatter 显式声明
385
+ * `task_type: verification`(或 `verification_only: true`)即走合法零 diff 通道:
386
+ * emptyDiff 不判伪造(转 warning),但 review 必须带非空 requiredEvidence(披露验证
387
+ * 区间/结论出处)——不能拿声明逃掉披露义务。
388
+ * @returns {boolean}
389
+ */
390
+ function isTaskVerification(changeDir, taskId) {
391
+ if (!changeDir) return false
392
+ const taskFile = join(changeDir, 'tasks', `${taskId}.md`)
393
+ if (!existsSync(taskFile)) return false
394
+ try {
395
+ const content = readFileSync(taskFile, 'utf8')
396
+ const fm = content.match(/^---\r?\n([\s\S]*?)\r?\n---/)
397
+ if (!fm) return false
398
+ return /^task_type:\s*['"]?verification['"]?\s*$/im.test(fm[1])
399
+ || /^verification_only:\s*true\s*$/im.test(fm[1])
400
+ } catch {
401
+ return false
402
+ }
403
+ }
404
+
381
405
  export function validateTaskReviews(opts) {
382
406
  const { planContent, runtimeRoot, executeRunId, allowCannotVerify = true, changeDir = null, gitDir = null, ctx = null } = opts
383
407
 
@@ -463,6 +487,15 @@ export function validateTaskReviews(opts) {
463
487
  if (evidence.emptyDiff) {
464
488
  if (isTaskLowRisk(changeDir, taskId)) {
465
489
  warnings.push(`${taskId}: base..head 无代码变更(task 声明 low_risk: true,不阻断)`)
490
+ } else if (isTaskVerification(changeDir, taskId)) {
491
+ // 纯验证 task 零 diff 通道(坑 verification-task-zero-diff):声明 task_type: verification
492
+ // 的「跑测试/部署验证/观测」型任务无代码 diff 是本质属性——不判伪造,但披露义务不豁免:
493
+ // requiredEvidence 为空 → error(声明不能替代证据)
494
+ if (Array.isArray(review.requiredEvidence) && review.requiredEvidence.length > 0) {
495
+ warnings.push(`${taskId}: base..head 无代码变更 — task 声明 task_type: verification(纯验证任务),零 diff 合法;requiredEvidence 已披露验证区间/结论出处`)
496
+ } else {
497
+ errors.push(`${taskId}: 纯验证 task(task_type: verification)零 diff 合法,但 review 缺 requiredEvidence(须披露验证区间/命令/结论出处)——声明不能替代披露`)
498
+ }
466
499
  } else {
467
500
  errors.push(`${taskId}: base..head(${review.base.slice(0, 8)}..${review.head.slice(0, 8)})无任何代码变更 — 评审了一个零改动的任务,review 疑似伪造`)
468
501
  continue
@@ -525,18 +558,21 @@ export function validateTaskReviews(opts) {
525
558
 
526
559
  // QUAL-01 收口:原为本地 execFileSync('git')(无 safe.directory、与 git-helper 口径分裂),
527
560
  // 现走统一入口;抛错/trim 语义不变,timeout 15000 保留(rev-parse/diff 大仓余量)
528
- function runGit(gitDir, args) {
529
- return git(gitDir, args, { timeout: 15000 })
561
+ function runGit(gitDir, args, opts = {}) {
562
+ return git(gitDir, args, { timeout: 15000, ...opts })
530
563
  }
531
564
 
532
565
  // porcelain 状态行 → 变更文件路径(对齐 shared.js:343 parsePorcelainPath:`XY path`、引号包裹、
533
566
  // rename `R old -> new` 取箭头后)。未 commit 的 working-tree 改动对账用。
567
+ // ⚠️ 入参必须是【未 trim】的原始输出(git(..., {trim:false}))——porcelain 行首空格是状态码
568
+ // 一部分,git() 默认 trim 会把 ' M feature.js' 削成 'M feature.js',slice(3) 再咬掉路径首字符
569
+ // (实测 'eature.js')→ 未提交文件对账全错位(verifyReviewGitEvidence 相交比对误判「完全不相交」)。
534
570
  function parsePorcelainFiles(statusOut) {
535
571
  const files = []
536
572
  for (const line of String(statusOut).split('\n')) {
537
573
  if (line.trim() === '') continue
538
574
  let p = line.slice(3).trim()
539
- if (p.length >= 2 && p.startsWith('"') && p.endsWith('"')) p = p.slice(1, -1).replace(/\\(.)/g, (_, c) => c)
575
+ if (p.length >= 2 && p.startsWith('"') && p.endsWith('"')) p = unquoteGitPath(p.slice(1, -1))
540
576
  const arrow = p.indexOf(' -> ')
541
577
  if (arrow !== -1) p = p.slice(arrow + 4)
542
578
  if (p) files.push(p.replace(/\\/g, '/'))
@@ -608,7 +644,11 @@ export function verifyReviewGitEvidence(review, gitDir, cache = null) {
608
644
  runGit(gitDir, ['rev-parse', '--verify', '--quiet', `${hash}^{commit}`])
609
645
  c.verified.add(vkey)
610
646
  } catch {
611
- errors.push(`${field} "${hash}" 不是仓库中的真实 commit — review.json 疑似伪造`)
647
+ // 可执行指引(坑 review-head-real-commit-late,2026-08-22 实证:head 必须真实 commit 的
648
+ // 要求撞门才知道——报错直接给操作序列:worktree 改动先 commit,取 HEAD 作 head)
649
+ errors.push(`${field} "${hash}" 不是仓库中的真实 commit — review.json 疑似伪造。` +
650
+ `head/base 必须是 git rev-parse 可解析的 commit hash:worktree 内先 git add -A && git commit -m "<task>",` +
651
+ `再取 git rev-parse HEAD 作 head(base 用 task 卡锡点或基线 commit);不要填分支名/伪 hash/working-tree 描述`)
612
652
  }
613
653
  }
614
654
  if (errors.length > 0) {
@@ -639,7 +679,7 @@ export function verifyReviewGitEvidence(review, gitDir, cache = null) {
639
679
  try {
640
680
  let wtStatus = c.wtStatus.get(gitDir)
641
681
  if (wtStatus === undefined) {
642
- wtStatus = runGit(gitDir, ['status', '--porcelain'])
682
+ wtStatus = runGit(gitDir, ['status', '--porcelain'], { trim: false })
643
683
  c.wtStatus.set(gitDir, wtStatus)
644
684
  }
645
685
  if (wtStatus && wtStatus.trim().length > 0) {
@@ -656,15 +696,25 @@ export function verifyReviewGitEvidence(review, gitDir, cache = null) {
656
696
 
657
697
  // changedFiles 交叉比对:完全不相交 = review 描述的改动与实际 diff 无关(diffFiles 已含 working-tree)
658
698
  if (!emptyDiff && Array.isArray(review.changedFiles) && review.changedFiles.length > 0) {
659
- const normalize = (p) => String(p).replace(/^\.\//, '')
699
+ // 注记剥离(坑 changedfiles-annotation-suffix-mismatch,2026-08-22 实证:changedFiles 带
700
+ // 「src/a.js(新增)」类注记后缀被判完全不相交,agent 用正则修还贪婪吞了路径段两轮才对)。
701
+ // 结构化剥法:只剥「路径尾部」的注记形态——全角/半角括号注记、// 与 # 行内注释、空白后的
702
+ // 说明文字;注记本身不参与匹配(想写说明放 reviewerNotes/requiredEvidence)。
703
+ const normalize = (p) => String(p)
704
+ .replace(/^\.\//, '')
705
+ .replace(/[((][^(())]*[))]\s*$/, '') // 尾部(注记)/(note)——中段括号(路径段)不动
706
+ .replace(/\s+(\/\/|#).*$/, '') // 空格后的 // 或 # 注释
707
+ .trim()
660
708
  const diffSet = new Set(diffFiles.map(normalize))
661
709
  const hasOverlap = review.changedFiles.some(f => {
662
710
  const nf = normalize(f)
711
+ if (!nf) return false
663
712
  // 兼容 review 写相对/部分路径:前后缀匹配即可
664
713
  return diffSet.has(nf) || diffFiles.some(d => d.endsWith(nf) || nf.endsWith(d))
665
714
  })
666
715
  if (!hasOverlap) {
667
- errors.push(`changedFiles 与实际 git diff(${review.base.slice(0, 8)}..${review.head.slice(0, 8)},${diffFiles.length} 个文件)完全不相交 — review 内容与代码变更无关`)
716
+ errors.push(`changedFiles 与实际 git diff(${review.base.slice(0, 8)}..${review.head.slice(0, 8)},${diffFiles.length} 个文件)完全不相交 — review 内容与代码变更无关。` +
717
+ `检查:changedFiles 必须是纯文件路径(注记/说明写 reviewerNotes,不拼进路径数组);路径拼写与 diff 对照`)
668
718
  }
669
719
  }
670
720
 
@@ -983,7 +1033,30 @@ export async function generateTaskReviewDrafts({ changeName, cwd, platformOpts =
983
1033
  }
984
1034
 
985
1035
  // base..head diff 文件集(worktree-aware;null=git 不可用,[]=无 commit diff)
986
- const diffFiles = resolveVerifyChangedFiles(cwd, changeName)
1036
+ let diffFiles = resolveVerifyChangedFiles(cwd, changeName)
1037
+ // ⚠️ 并入 worktree 未提交改动(坑 draft-attribution-uncommitted-worktree,2026-08-21 实证):
1038
+ // 子代理默认不 commit(execute 复盘 a 同源事实),真实改动全在 worktree working-tree——只看
1039
+ // base..HEAD commit diff 时归属恒空,9/9 草稿全成「无归属」靠主代理手写升级。与
1040
+ // verifyReviewGitEvidence 的 working-tree 并入口径同源:status --porcelain 文件(排除
1041
+ // .sillyspec/ 运行时产物)并入归属集,按 allowed_paths 正常路径归属;review.head 仍为 HEAD
1042
+ // commit——evidence 校验对「commit diff 空 + working-tree 有改动」明示不判伪造,语义一致。
1043
+ try {
1044
+ const wm = new WorktreeManager({ cwd })
1045
+ const meta = wm.getMeta(changeName)
1046
+ if (meta) {
1047
+ const wtGitDir = (meta.worktreePath && meta.mode !== 'in-place-fallback' && existsSync(meta.worktreePath))
1048
+ ? meta.worktreePath
1049
+ : cwd
1050
+ const wtStatus = runGit(wtGitDir, ['status', '--porcelain'], { trim: false })
1051
+ const wtFiles = parsePorcelainFiles(wtStatus)
1052
+ .map(p => String(p).replace(/\\/g, '/'))
1053
+ .filter(p => p !== '.sillyspec' && !p.startsWith('.sillyspec/'))
1054
+ if (wtFiles.length > 0) {
1055
+ diffFiles = [...new Set([...(Array.isArray(diffFiles) ? diffFiles : []), ...wtFiles])]
1056
+ console.log(`[sillyspec] 草稿归属并入 ${wtFiles.length} 个 worktree 未提交文件(子代理未 commit 的改动按 allowed_paths 归属)`)
1057
+ }
1058
+ }
1059
+ } catch { /* working-tree 并入失败退回 commit diff 口径(fail-open,不阻断草稿) */ }
987
1060
  // 单仓模式(无 ctx):主仓无 diff 即无任何 task 可生成 → 提前返回(原逻辑零回归)。
988
1061
  // 有 ctx:主仓无 diff 不阻断——跨仓 task 的 diff 在跨仓仓根独立取(per-task),主仓 task 自然跳过(空 changedFiles)。
989
1062
  if (!ctx && (!diffFiles || diffFiles.length === 0)) {
@@ -1109,8 +1182,9 @@ export async function generateTaskReviewDrafts({ changeName, cwd, platformOpts =
1109
1182
  specVerdict: 'cannot_verify',
1110
1183
  qualityVerdict: 'cannot_verify',
1111
1184
  requiredEvidence: ['auto-generated draft (no-attributed-diff): ' + taskId +
1112
- ' 的 allowed_paths 未命中本次 diff 任何文件(task 未实现 / 改动与他人同文件 / 路径不匹配)——' +
1113
- '需人工确认实际改动后升级 verdict,确属未实现则回 fail'],
1185
+ ' 的 allowed_paths 未命中本次 diff 任何文件(task 未实现 / 改动与他人同文件 / 路径不匹配 / 纯验证任务)——' +
1186
+ '需人工确认实际改动后升级 verdict,确属未实现则回 fail;若是纯验证任务(跑测试/部署验证/观测,无代码 diff 是本质属性),' +
1187
+ 'task 卡 frontmatter 声明 task_type: verification + review 披露验证区间即走合法零 diff 通道'],
1114
1188
  reviewerNotes: 'auto-generated draft from git diff ' + taskBase.slice(0, 8) + '..' + taskHead.slice(0, 8) +
1115
1189
  ';verdict=未评审(无归属 diff 兜底草稿,坑 task-review-draft-skip-leak)',
1116
1190
  }
@@ -1150,6 +1224,194 @@ export async function generateTaskReviewDrafts({ changeName, cwd, platformOpts =
1150
1224
  return { generated, skipped, noAttribution: noAttributionCount, unattributed, executeRunId }
1151
1225
  }
1152
1226
 
1227
+ /**
1228
+ * adoptTaskReviewMechanics —— 已存在 review.json 的 mechanics 字段一键重算代填(保 verdict)。
1229
+ *
1230
+ * 背景(2026-08-21 agent-手工产出审计项①):review.json 里 schemaVersion/task/base/head/
1231
+ * changedFiles(/repo) 全部可由 CLI 从 git + task 卡机械推导(generateTaskReviewDrafts 已证明),
1232
+ * agent/子代理手算 hash、手抄 diff 清单是纯错误面——而 gate(verifyReviewGitEvidence)校验的
1233
+ * 恰是这些字段。与草稿生成互补:草稿只补「缺失」,adopt 修「已存在但 mechanics 错/缺」——
1234
+ * 保留人工语义字段(specVerdict/qualityVerdict/requiredEvidence/reviewerNotes),只重写 mechanics。
1235
+ *
1236
+ * verdict 保护策略(不猜 agent 本意):
1237
+ * - 合法 verdict(VALID_VERDICTS)原样保留;
1238
+ * - 非法/缺失 verdict 降级 cannot_verify,reviewerNotes 追加标记,requiredEvidence 缺失补占位
1239
+ * (schema 要求 cannot_verify 必带非空 requiredEvidence);
1240
+ * - JSON 解析失败的 review 不救(无法可信保留任何字段),报出来留给 agent 修 JSON 后重跑。
1241
+ *
1242
+ * fail-open:与草稿生成同契约——缺数据返回统计与 reasons,不抛。
1243
+ *
1244
+ * @param {{ changeName: string, cwd: string, platformOpts?: object, ctx?: object|null }} opts
1245
+ * @returns {Promise<{ adopted: number, unchanged: number, skipped: number, missing: number,
1246
+ * downgraded: string[], reasons: string[], executeRunId?: string }>}
1247
+ */
1248
+ export async function adoptTaskReviewMechanics({ changeName, cwd, platformOpts = {}, ctx = null }) {
1249
+ const specBase = platformOpts.specRoot || join(cwd, '.sillyspec')
1250
+ const runtimeRoot = resolveRuntimeRoot(platformOpts, specBase)
1251
+ const tasksDir = join(specBase, 'changes', changeName, 'tasks')
1252
+ const reasons = []
1253
+
1254
+ if (!changeName) {
1255
+ return { adopted: 0, unchanged: 0, skipped: 0, missing: 0, downgraded: [], reasons: ['无 changeName'] }
1256
+ }
1257
+ if (!existsSync(tasksDir)) {
1258
+ return { adopted: 0, unchanged: 0, skipped: 0, missing: 0, downgraded: [], reasons: ['无 tasks/ 目录'] }
1259
+ }
1260
+
1261
+ // executeRunId:与草稿/gate 同源 marker。缺 marker 不新建 run——adopt 修的是当前 run 里
1262
+ // 已存在的 review,marker 丢失说明无「当前 run」语义,无对象可修(gate 对 marker 缺失放行,
1263
+ // 下游 Task Review Gate 兜底,这里同口径不造 run)。
1264
+ const runIdFile = join(runtimeRoot, 'current-execute-run-id-' + changeName)
1265
+ let executeRunId = ''
1266
+ try {
1267
+ if (existsSync(runIdFile)) {
1268
+ const c = readFileSync(runIdFile, 'utf8').trim()
1269
+ if (isValidExecuteRunId(c)) executeRunId = c
1270
+ }
1271
+ } catch {}
1272
+ if (!executeRunId) {
1273
+ return { adopted: 0, unchanged: 0, skipped: 0, missing: 0, downgraded: [], reasons: ['无 execute run marker——无当前 run,adopt 无对象'] }
1274
+ }
1275
+
1276
+ // base/head + diff:与 generateTaskReviewDrafts 同源口径(meta 锡点 + worktree rev-parse +
1277
+ // resolveVerifyChangedFiles),保证 adopt 产物与 gate 校验输入一致。
1278
+ const diffFiles = resolveVerifyChangedFiles(cwd, changeName)
1279
+ let base = null
1280
+ let head = null
1281
+ let reviewGitDir = cwd
1282
+ try {
1283
+ const wm = new WorktreeManager({ cwd })
1284
+ const meta = wm.getMeta(changeName)
1285
+ base = (meta && (meta.baselineCommit || meta.baseHash)) || null
1286
+ if (meta && meta.worktreePath && meta.mode !== 'in-place-fallback' && existsSync(meta.worktreePath)) {
1287
+ reviewGitDir = meta.worktreePath
1288
+ }
1289
+ } catch {}
1290
+ if (base) {
1291
+ try {
1292
+ head = runGit(reviewGitDir, ['rev-parse', 'HEAD'])
1293
+ } catch {
1294
+ head = null
1295
+ }
1296
+ }
1297
+
1298
+ const taskFiles = readdirSync(tasksDir).filter(f => /^task-\d+\.md$/.test(f)).sort()
1299
+ let adopted = 0
1300
+ let unchanged = 0
1301
+ let skipped = 0
1302
+ let missing = 0
1303
+ const downgraded = []
1304
+
1305
+ for (const tf of taskFiles) {
1306
+ const taskId = tf.replace(/\.md$/, '')
1307
+ const reviewPath = join(runtimeRoot, 'execute-runs', executeRunId, 'tasks', taskId, 'review.json')
1308
+ if (!existsSync(reviewPath)) {
1309
+ missing++
1310
+ continue
1311
+ }
1312
+
1313
+ const existing = readReview(reviewPath)
1314
+ if (existing.parseError) {
1315
+ skipped++
1316
+ reasons.push(`${taskId}: JSON 解析失败,adopt 不猜字段——先修 JSON 再重跑`)
1317
+ continue
1318
+ }
1319
+ const prev = existing.review || {}
1320
+
1321
+ const content = readFileSync(join(tasksDir, tf), 'utf8')
1322
+ const allowedPaths = parseAllowedPaths(content)
1323
+ const taskRepo = normalizeRepoKey(parseRepo(content))
1324
+ const crossEntry = (ctx && taskRepo !== DEFAULT_REPO_KEY) ? ctx.resolve(taskRepo) : null
1325
+ let taskBase = base
1326
+ let taskHead = head
1327
+ let taskChangedFiles
1328
+ let draftRepo
1329
+
1330
+ if (crossEntry) {
1331
+ const baseCommit = parseBaseCommit(content)
1332
+ const headCommit = parseHeadCommit(content)
1333
+ if (!baseCommit || !headCommit) {
1334
+ skipped++
1335
+ reasons.push(`${taskId}: 跨仓 task 卡缺 base/head 锡点,无法 adopt`)
1336
+ continue
1337
+ }
1338
+ taskBase = baseCommit
1339
+ taskHead = headCommit
1340
+ draftRepo = taskRepo
1341
+ let crossDiffFiles = []
1342
+ try {
1343
+ const out = runGit(crossEntry.gitDir, ['diff', '--name-only', `${baseCommit}..${headCommit}`])
1344
+ crossDiffFiles = out ? out.split('\n').filter(Boolean).map(p => p.replace(/\\/g, '/')) : []
1345
+ } catch {
1346
+ skipped++
1347
+ reasons.push(`${taskId}: 跨仓 git diff 失败(${crossEntry.gitDir})`)
1348
+ continue
1349
+ }
1350
+ taskChangedFiles = allowedPaths.length > 0
1351
+ ? crossDiffFiles.filter(f => allowedPaths.some(p => pathMatches(f, p)))
1352
+ : crossDiffFiles
1353
+ } else {
1354
+ const mainDiff = Array.isArray(diffFiles) ? diffFiles : []
1355
+ taskChangedFiles = allowedPaths.length > 0
1356
+ ? mainDiff.filter(f => allowedPaths.some(p => pathMatches(f, p)))
1357
+ : []
1358
+ }
1359
+
1360
+ if (!crossEntry && (!taskBase || !taskHead)) {
1361
+ skipped++
1362
+ reasons.push(`${taskId}: 主仓缺 worktree meta base/head,无法 adopt`)
1363
+ continue
1364
+ }
1365
+
1366
+ const merged = { ...prev }
1367
+ merged.schemaVersion = REVIEW_SCHEMA_VERSION
1368
+ merged.task = taskId
1369
+ merged.base = taskBase
1370
+ merged.head = taskHead
1371
+ merged.changedFiles = taskChangedFiles
1372
+ if (draftRepo) merged.repo = draftRepo
1373
+
1374
+ let verdictTouched = false
1375
+ for (const v of ['specVerdict', 'qualityVerdict']) {
1376
+ if (!VALID_VERDICTS.includes(merged[v])) {
1377
+ merged[v] = 'cannot_verify'
1378
+ verdictTouched = true
1379
+ }
1380
+ }
1381
+ if (verdictTouched) downgraded.push(taskId)
1382
+ let evidenceFilled = false
1383
+ if ((merged.specVerdict === 'cannot_verify' || merged.qualityVerdict === 'cannot_verify')
1384
+ && (!Array.isArray(merged.requiredEvidence) || merged.requiredEvidence.length === 0)) {
1385
+ merged.requiredEvidence = [`adopt 代填:原 review 缺 requiredEvidence(verdict=${merged.specVerdict}/${merged.qualityVerdict}),待人工复核补证据`]
1386
+ evidenceFilled = true
1387
+ }
1388
+ if (verdictTouched) {
1389
+ merged.reviewerNotes = String(merged.reviewerNotes || '') + (merged.reviewerNotes ? '; ' : '')
1390
+ + 'adopt: 原 verdict 非法/缺失已降级 cannot_verify(不猜本意)'
1391
+ }
1392
+
1393
+ const mechanicsChanged = prev.schemaVersion !== merged.schemaVersion
1394
+ || prev.base !== merged.base
1395
+ || prev.head !== merged.head
1396
+ || JSON.stringify(prev.changedFiles ?? null) !== JSON.stringify(merged.changedFiles)
1397
+ || prev.repo !== merged.repo
1398
+
1399
+ try {
1400
+ if (mechanicsChanged || verdictTouched || evidenceFilled) {
1401
+ writeFileSync(reviewPath, JSON.stringify(merged, null, 2) + '\n')
1402
+ adopted++
1403
+ } else {
1404
+ unchanged++
1405
+ }
1406
+ } catch (e) {
1407
+ skipped++
1408
+ reasons.push(`${taskId}: 写回失败 ${e.message}`)
1409
+ }
1410
+ }
1411
+
1412
+ return { adopted, unchanged, skipped, missing, downgraded, reasons, executeRunId }
1413
+ }
1414
+
1153
1415
  /**
1154
1416
  * 获取当前(或最新)execute run id
1155
1417
  * 从 runtime 目录下查找 execute-runs/ 子目录