@hunter-harness/workflow-harness 0.2.73 → 0.2.75

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 (170) hide show
  1. package/harness/bundles/general/claude-code/.harness-build.json +1 -1
  2. package/harness/bundles/general/claude-code/harness-archive/SKILL.md +26 -1
  3. package/harness/bundles/general/claude-code/harness-codebase-map/SKILL.md +1 -1
  4. package/harness/bundles/general/claude-code/harness-knowledge-ingest/SKILL.md +1 -1
  5. package/harness/bundles/general/claude-code/harness-knowledge-query/SKILL.md +1 -1
  6. package/harness/bundles/general/claude-code/harness-plan/SKILL.md +2 -1
  7. package/harness/bundles/general/claude-code/harness-pull/SKILL.md +1 -1
  8. package/harness/bundles/general/claude-code/harness-push/SKILL.md +1 -1
  9. package/harness/bundles/general/claude-code/harness-review/SKILL.md +2 -1
  10. package/harness/bundles/general/claude-code/harness-run/SKILL.md +3 -2
  11. package/harness/bundles/general/claude-code/harness-submit/SKILL.md +4 -2
  12. package/harness/bundles/general/claude-code/harness-sync/SKILL.md +1 -1
  13. package/harness/bundles/general/claude-code/harness-test/SKILL.md +3 -2
  14. package/harness/bundles/general/claude-code/harness-test/checklist.md +2 -0
  15. package/harness/bundles/general/claude-code/scripts/harness_archive.py +149 -6
  16. package/harness/bundles/general/claude-code/scripts/harness_change.py +95 -0
  17. package/harness/bundles/general/claude-code/scripts/harness_ledger.py +25 -4
  18. package/harness/bundles/general/claude-code/scripts/harness_profile.py +45 -0
  19. package/harness/bundles/general/claude-code/scripts/harness_runtime.py +101 -1
  20. package/harness/bundles/general/codebuddy/.harness-build.json +1 -1
  21. package/harness/bundles/general/codebuddy/harness-archive/SKILL.md +26 -1
  22. package/harness/bundles/general/codebuddy/harness-codebase-map/SKILL.md +1 -1
  23. package/harness/bundles/general/codebuddy/harness-knowledge-ingest/SKILL.md +1 -1
  24. package/harness/bundles/general/codebuddy/harness-knowledge-query/SKILL.md +1 -1
  25. package/harness/bundles/general/codebuddy/harness-plan/SKILL.md +2 -1
  26. package/harness/bundles/general/codebuddy/harness-pull/SKILL.md +1 -1
  27. package/harness/bundles/general/codebuddy/harness-push/SKILL.md +1 -1
  28. package/harness/bundles/general/codebuddy/harness-review/SKILL.md +2 -1
  29. package/harness/bundles/general/codebuddy/harness-run/SKILL.md +3 -2
  30. package/harness/bundles/general/codebuddy/harness-submit/SKILL.md +4 -2
  31. package/harness/bundles/general/codebuddy/harness-sync/SKILL.md +1 -1
  32. package/harness/bundles/general/codebuddy/harness-test/SKILL.md +3 -2
  33. package/harness/bundles/general/codebuddy/harness-test/checklist.md +2 -0
  34. package/harness/bundles/general/codebuddy/scripts/harness_archive.py +149 -6
  35. package/harness/bundles/general/codebuddy/scripts/harness_change.py +95 -0
  36. package/harness/bundles/general/codebuddy/scripts/harness_ledger.py +25 -4
  37. package/harness/bundles/general/codebuddy/scripts/harness_profile.py +45 -0
  38. package/harness/bundles/general/codebuddy/scripts/harness_runtime.py +101 -1
  39. package/harness/bundles/general/codex/.harness-build.json +1 -1
  40. package/harness/bundles/general/codex/harness-archive/SKILL.md +26 -1
  41. package/harness/bundles/general/codex/harness-codebase-map/SKILL.md +1 -1
  42. package/harness/bundles/general/codex/harness-knowledge-ingest/SKILL.md +1 -1
  43. package/harness/bundles/general/codex/harness-knowledge-query/SKILL.md +1 -1
  44. package/harness/bundles/general/codex/harness-plan/SKILL.md +2 -1
  45. package/harness/bundles/general/codex/harness-pull/SKILL.md +1 -1
  46. package/harness/bundles/general/codex/harness-push/SKILL.md +1 -1
  47. package/harness/bundles/general/codex/harness-review/SKILL.md +2 -1
  48. package/harness/bundles/general/codex/harness-run/SKILL.md +3 -2
  49. package/harness/bundles/general/codex/harness-submit/SKILL.md +4 -2
  50. package/harness/bundles/general/codex/harness-sync/SKILL.md +1 -1
  51. package/harness/bundles/general/codex/harness-test/SKILL.md +3 -2
  52. package/harness/bundles/general/codex/harness-test/checklist.md +2 -0
  53. package/harness/bundles/general/codex/scripts/harness_archive.py +149 -6
  54. package/harness/bundles/general/codex/scripts/harness_change.py +95 -0
  55. package/harness/bundles/general/codex/scripts/harness_ledger.py +25 -4
  56. package/harness/bundles/general/codex/scripts/harness_profile.py +45 -0
  57. package/harness/bundles/general/codex/scripts/harness_runtime.py +101 -1
  58. package/harness/bundles/general/cursor/.harness-build.json +1 -1
  59. package/harness/bundles/general/cursor/harness-archive/SKILL.md +26 -1
  60. package/harness/bundles/general/cursor/harness-codebase-map/SKILL.md +1 -1
  61. package/harness/bundles/general/cursor/harness-knowledge-ingest/SKILL.md +1 -1
  62. package/harness/bundles/general/cursor/harness-knowledge-query/SKILL.md +1 -1
  63. package/harness/bundles/general/cursor/harness-plan/SKILL.md +2 -1
  64. package/harness/bundles/general/cursor/harness-pull/SKILL.md +1 -1
  65. package/harness/bundles/general/cursor/harness-push/SKILL.md +1 -1
  66. package/harness/bundles/general/cursor/harness-review/SKILL.md +2 -1
  67. package/harness/bundles/general/cursor/harness-run/SKILL.md +3 -2
  68. package/harness/bundles/general/cursor/harness-submit/SKILL.md +4 -2
  69. package/harness/bundles/general/cursor/harness-sync/SKILL.md +1 -1
  70. package/harness/bundles/general/cursor/harness-test/SKILL.md +3 -2
  71. package/harness/bundles/general/cursor/harness-test/checklist.md +2 -0
  72. package/harness/bundles/general/cursor/scripts/harness_archive.py +149 -6
  73. package/harness/bundles/general/cursor/scripts/harness_change.py +95 -0
  74. package/harness/bundles/general/cursor/scripts/harness_ledger.py +25 -4
  75. package/harness/bundles/general/cursor/scripts/harness_profile.py +45 -0
  76. package/harness/bundles/general/cursor/scripts/harness_runtime.py +101 -1
  77. package/harness/bundles/java/claude-code/.harness-build.json +1 -1
  78. package/harness/bundles/java/claude-code/harness-apidoc/SKILL.md +2 -1
  79. package/harness/bundles/java/claude-code/harness-archive/SKILL.md +26 -1
  80. package/harness/bundles/java/claude-code/harness-codebase-map/SKILL.md +1 -1
  81. package/harness/bundles/java/claude-code/harness-knowledge-ingest/SKILL.md +1 -1
  82. package/harness/bundles/java/claude-code/harness-knowledge-query/SKILL.md +1 -1
  83. package/harness/bundles/java/claude-code/harness-package/SKILL.md +2 -1
  84. package/harness/bundles/java/claude-code/harness-plan/SKILL.md +2 -1
  85. package/harness/bundles/java/claude-code/harness-pull/SKILL.md +1 -1
  86. package/harness/bundles/java/claude-code/harness-push/SKILL.md +1 -1
  87. package/harness/bundles/java/claude-code/harness-review/SKILL.md +2 -1
  88. package/harness/bundles/java/claude-code/harness-run/SKILL.md +3 -2
  89. package/harness/bundles/java/claude-code/harness-run/reference.md +348 -348
  90. package/harness/bundles/java/claude-code/harness-submit/SKILL.md +4 -2
  91. package/harness/bundles/java/claude-code/harness-sync/SKILL.md +1 -1
  92. package/harness/bundles/java/claude-code/harness-test/SKILL.md +3 -2
  93. package/harness/bundles/java/claude-code/scripts/harness_archive.py +149 -6
  94. package/harness/bundles/java/claude-code/scripts/harness_change.py +95 -0
  95. package/harness/bundles/java/claude-code/scripts/harness_ledger.py +25 -4
  96. package/harness/bundles/java/claude-code/scripts/harness_profile.py +45 -0
  97. package/harness/bundles/java/claude-code/scripts/harness_runtime.py +101 -1
  98. package/harness/bundles/java/codebuddy/.harness-build.json +1 -1
  99. package/harness/bundles/java/codebuddy/harness-apidoc/SKILL.md +2 -1
  100. package/harness/bundles/java/codebuddy/harness-archive/SKILL.md +26 -1
  101. package/harness/bundles/java/codebuddy/harness-codebase-map/SKILL.md +1 -1
  102. package/harness/bundles/java/codebuddy/harness-knowledge-ingest/SKILL.md +1 -1
  103. package/harness/bundles/java/codebuddy/harness-knowledge-query/SKILL.md +1 -1
  104. package/harness/bundles/java/codebuddy/harness-package/SKILL.md +2 -1
  105. package/harness/bundles/java/codebuddy/harness-plan/SKILL.md +2 -1
  106. package/harness/bundles/java/codebuddy/harness-pull/SKILL.md +1 -1
  107. package/harness/bundles/java/codebuddy/harness-push/SKILL.md +1 -1
  108. package/harness/bundles/java/codebuddy/harness-review/SKILL.md +2 -1
  109. package/harness/bundles/java/codebuddy/harness-run/SKILL.md +3 -2
  110. package/harness/bundles/java/codebuddy/harness-run/reference.md +348 -348
  111. package/harness/bundles/java/codebuddy/harness-submit/SKILL.md +4 -2
  112. package/harness/bundles/java/codebuddy/harness-sync/SKILL.md +1 -1
  113. package/harness/bundles/java/codebuddy/harness-test/SKILL.md +3 -2
  114. package/harness/bundles/java/codebuddy/scripts/harness_archive.py +149 -6
  115. package/harness/bundles/java/codebuddy/scripts/harness_change.py +95 -0
  116. package/harness/bundles/java/codebuddy/scripts/harness_ledger.py +25 -4
  117. package/harness/bundles/java/codebuddy/scripts/harness_profile.py +45 -0
  118. package/harness/bundles/java/codebuddy/scripts/harness_runtime.py +101 -1
  119. package/harness/bundles/java/codex/.harness-build.json +1 -1
  120. package/harness/bundles/java/codex/harness-apidoc/SKILL.md +2 -1
  121. package/harness/bundles/java/codex/harness-archive/SKILL.md +26 -1
  122. package/harness/bundles/java/codex/harness-codebase-map/SKILL.md +1 -1
  123. package/harness/bundles/java/codex/harness-knowledge-ingest/SKILL.md +1 -1
  124. package/harness/bundles/java/codex/harness-knowledge-query/SKILL.md +1 -1
  125. package/harness/bundles/java/codex/harness-package/SKILL.md +2 -1
  126. package/harness/bundles/java/codex/harness-plan/SKILL.md +2 -1
  127. package/harness/bundles/java/codex/harness-pull/SKILL.md +1 -1
  128. package/harness/bundles/java/codex/harness-push/SKILL.md +1 -1
  129. package/harness/bundles/java/codex/harness-review/SKILL.md +2 -1
  130. package/harness/bundles/java/codex/harness-run/SKILL.md +3 -2
  131. package/harness/bundles/java/codex/harness-run/reference.md +348 -348
  132. package/harness/bundles/java/codex/harness-submit/SKILL.md +4 -2
  133. package/harness/bundles/java/codex/harness-sync/SKILL.md +1 -1
  134. package/harness/bundles/java/codex/harness-test/SKILL.md +3 -2
  135. package/harness/bundles/java/codex/scripts/harness_archive.py +149 -6
  136. package/harness/bundles/java/codex/scripts/harness_change.py +95 -0
  137. package/harness/bundles/java/codex/scripts/harness_ledger.py +25 -4
  138. package/harness/bundles/java/codex/scripts/harness_profile.py +45 -0
  139. package/harness/bundles/java/codex/scripts/harness_runtime.py +101 -1
  140. package/harness/bundles/java/cursor/.harness-build.json +1 -1
  141. package/harness/bundles/java/cursor/harness-apidoc/SKILL.md +2 -1
  142. package/harness/bundles/java/cursor/harness-archive/SKILL.md +26 -1
  143. package/harness/bundles/java/cursor/harness-codebase-map/SKILL.md +1 -1
  144. package/harness/bundles/java/cursor/harness-knowledge-ingest/SKILL.md +1 -1
  145. package/harness/bundles/java/cursor/harness-knowledge-query/SKILL.md +1 -1
  146. package/harness/bundles/java/cursor/harness-package/SKILL.md +2 -1
  147. package/harness/bundles/java/cursor/harness-plan/SKILL.md +2 -1
  148. package/harness/bundles/java/cursor/harness-pull/SKILL.md +1 -1
  149. package/harness/bundles/java/cursor/harness-push/SKILL.md +1 -1
  150. package/harness/bundles/java/cursor/harness-review/SKILL.md +2 -1
  151. package/harness/bundles/java/cursor/harness-run/SKILL.md +3 -2
  152. package/harness/bundles/java/cursor/harness-run/reference.md +348 -348
  153. package/harness/bundles/java/cursor/harness-submit/SKILL.md +4 -2
  154. package/harness/bundles/java/cursor/harness-sync/SKILL.md +1 -1
  155. package/harness/bundles/java/cursor/harness-test/SKILL.md +3 -2
  156. package/harness/bundles/java/cursor/scripts/harness_archive.py +149 -6
  157. package/harness/bundles/java/cursor/scripts/harness_change.py +95 -0
  158. package/harness/bundles/java/cursor/scripts/harness_ledger.py +25 -4
  159. package/harness/bundles/java/cursor/scripts/harness_profile.py +45 -0
  160. package/harness/bundles/java/cursor/scripts/harness_runtime.py +101 -1
  161. package/harness/manifests/general/claude-code.json +20 -20
  162. package/harness/manifests/general/codebuddy.json +20 -20
  163. package/harness/manifests/general/codex.json +20 -20
  164. package/harness/manifests/general/cursor.json +20 -20
  165. package/harness/manifests/java/claude-code.json +22 -22
  166. package/harness/manifests/java/codebuddy.json +22 -22
  167. package/harness/manifests/java/codex.json +22 -22
  168. package/harness/manifests/java/cursor.json +22 -22
  169. package/hunter-workflow-family.json +3 -3
  170. package/package.json +1 -1
@@ -21,7 +21,7 @@ disallowed-tools:
21
21
  - Bash(awk *)
22
22
  - Bash(curl *)
23
23
  ---
24
- <!-- generated by harness_deploy.py; core=984aee382a36595c; overlay=java; agent=claude-code; do not edit -->
24
+ <!-- generated by harness_deploy.py; core=4f3a290efefe6a68; overlay=java; agent=claude-code; do not edit -->
25
25
  # harness-submit — 最终提交(含 worktree 合并)
26
26
 
27
27
  ## Purpose
@@ -47,6 +47,7 @@ disallowed-tools:
47
47
 
48
48
  ## 统一读取协议
49
49
 
50
+ 0. **脚本在 `<skills-root>/scripts/` 共享,不在每个 skill 子目录下** — 实际形态是 `.codebuddy/skills/scripts/harness_*.py`(`.claude`/`.cursor`/`.codex` 同理),**没有** `.../skills/harness-<phase>/scripts/`。plan、run/test、archive 三份执行日志里都先猜成后者、报 `No such file` 再靠 Search 找回来;照第一种写法直接用
50
51
  1. **`.harness/changes/<change-name>/` 是唯一真相源** — 所有输入从该目录读取,产物写入对应子目录
51
52
  2. **change-name 优先从 frontmatter 读取** — `plans/*-design.md`、`spec/*-design.md`、`plans/*-plan.md` 的 YAML `change-name`
52
53
  3. **frontmatter 缺失时兼容旧格式** — 从路径推断,标记 `🟡 legacy-plan`,不失败
@@ -72,10 +73,11 @@ worktree 合并前必须运行 `harness_change.py integration-lock acquire --run
72
73
 
73
74
  ### 提交流程(步骤 0–7)
74
75
 
75
- 0. **启动准备** — `harness_context.py prepare --phase submit --executor <tool> [--change <id>] --json` 确定唯一变更与 executionRoot;`harness_context.py begin --phase submit --change <id> --executor <tool> --json` 校验 review→submit receipt;**`harness_gate.py begin --phase submit --change <id>`**;读 ledger,以 `harness_ledger.py diff-hash --repo <executionRoot> --base <baseCommit> --change-dir ".harness/changes/<change-name>" --json` 计算 diffHash + post-test 7 类分类(`executionRoot` 必须直接取准备回执;禁止手写 ledger / 手工 phase.end)
76
+ 0. **启动准备** — `harness_context.py prepare --project . --change <id> --phase submit --executor <tool> --json`(`--project` 必填)确定唯一变更与 executionRoot;`harness_context.py begin --project . --change <id> --phase submit --executor <tool> --json` 校验 review→submit receipt;**`harness_gate.py begin --phase submit --change <id>`**;读 ledger,以 `harness_ledger.py diff-hash --repo <executionRoot> --base <baseCommit> --change-dir ".harness/changes/<change-name>" --json` 计算 diffHash + post-test 7 类分类(`executionRoot` 必须直接取准备回执;禁止手写 ledger / 手工 phase.end)
76
77
  1. **合并最新代码** — 主目录与 worktree 均**不在业务工作区 stash/pull**;远端同步由合并段 integration transaction 在隔离 integration worktree 内完成(见「worktree 合并流程」);**正常路径禁止 `git stash` / `stash pop`**
77
78
  2. **最终验证** — ledger 复用优先;提交前只调用 `can-reuse --project . --profile-input unitTestFull --command <profile 规范命令>`。不得传入另一套 `--files`,不得把 runner 包装说明写进 `command`。`reuse=true` 时禁止重跑;只有真实输入、依赖、工具链或环境身份变化时才执行一次同一 profile 验证,并只登记一条结果。不要读取 ledger/archive 实现源码或临时编写散列脚本排查参数。
78
79
  - 无远端 CI 且 gate-policy 未强制 remote provider 时,验证通过/复用后运行 `harness_archive.py certify-local --change-dir ... --project . --json`,从同一 ledger 生成 `local-reproducible` 产品候选收据;该命令不执行测试。
80
+ - **被与本变更无关的预存失败卡住时的正规出路**:用 `harness_preflight.py record-quirk --project . --action skip-not-block --pattern "<具体错误签名,≥8 字符>" --reason "<为什么与本变更无关>"` 把它声明进 build-profile 的 `knownPreexistingErrors`,certify-local 会在**该失败的 ledger 证据里确实出现该签名**时放行,并把 `{validation, pattern, reason}` 写进收据的 `verification.preexistingExemptions` 留痕。**不得**改 gate-policy 的 `candidateVerification.requiredValidations` 来绕过——那是把门禁本身拆掉。声明了但证据对不上、或签名短到能匹配一切,仍然阻断。
79
81
  - gate-policy 要求 `remote-attested` 时不得降级成本地收据,等待远端 attestation。
80
82
  3. **.gitignore + 精确暂存** ⚠️ — 检查 `.harness/` 在 `.gitignore`;**禁止 `git add -A`**。若存在 `evidence/test-tracking.json`,先执行 `python <skills-root>/scripts/harness_test_guard.py stage --project . --change-dir ".harness/changes/<change-name>" --json`;失败即硬停止。无 manifest 时不使用 `-f`。manifest 之外的文件按精确业务路径正常暂存,**禁止全局 force-add**。
81
83
  4. **提交方式** — 主目录:blocking user confirmation 三选项(commit+push / 仅本地 / 取消);**worktree:固定仅本地 commit**
@@ -19,7 +19,7 @@ disallowed-tools:
19
19
  - Bash(awk *)
20
20
  - Bash(curl *)
21
21
  ---
22
- <!-- generated by harness_deploy.py; core=984aee382a36595c; overlay=java; agent=claude-code; do not edit -->
22
+ <!-- generated by harness_deploy.py; core=4f3a290efefe6a68; overlay=java; agent=claude-code; do not edit -->
23
23
  # harness-sync
24
24
 
25
25
  ## Purpose
@@ -23,7 +23,7 @@ disallowed-tools:
23
23
  - Bash(node *)
24
24
  - Bash(codegraph *)
25
25
  ---
26
- <!-- generated by harness_deploy.py; core=984aee382a36595c; overlay=java; agent=claude-code; do not edit -->
26
+ <!-- generated by harness_deploy.py; core=4f3a290efefe6a68; overlay=java; agent=claude-code; do not edit -->
27
27
  # harness-test — 测试执行
28
28
 
29
29
  ## Purpose
@@ -51,6 +51,7 @@ disallowed-tools:
51
51
 
52
52
  ## 统一读取协议
53
53
 
54
+ 0. **脚本在 `<skills-root>/scripts/` 共享,不在每个 skill 子目录下** — 实际形态是 `.codebuddy/skills/scripts/harness_*.py`(`.claude`/`.cursor`/`.codex` 同理),**没有** `.../skills/harness-<phase>/scripts/`。plan、run/test、archive 三份执行日志里都先猜成后者、报 `No such file` 再靠 Search 找回来;照第一种写法直接用
54
55
  1. **`.harness/changes/<change-name>/` 是唯一真相源** — 所有输入从该目录读取,产物写入对应子目录
55
56
  2. **change-name 优先从 frontmatter 读取** — `plans/*-design.md`、`spec/*-design.md`、`plans/*-plan.md` 的 YAML `change-name`
56
57
  3. **frontmatter 缺失时兼容旧格式** — 从路径推断,标记 `🟡 legacy-plan`,不失败
@@ -105,7 +106,7 @@ Runner 强制同项目单实例、低调度优先级、逐命令超时、正常
105
106
 
106
107
  ### Phase 0:环境准备(主会话执行,需要交互确认)
107
108
 
108
- 先 `harness_context.py prepare --phase test --executor <tool> [--change <id>] --json`,再 `harness_context.py begin --phase test --change <id> --executor <tool> --json` 校验最新 run→test receipt 的 artifact/hash/HEAD;然后 **`harness_gate.py begin --phase test --change <id>`**(禁止手工 phase.start / 手写 ledger)。执行各项强制环境检查 + **命令执行模式 preflight (0.1)**;只有首选执行器不可用时,才执行 fallback 执行器探测。
109
+ 先 `harness_context.py prepare --project . --change <id> --phase test --executor <tool> --json`(`--project` 必填,漏了直接 argparse 报错),再 `harness_context.py begin --project . --change <id> --phase test --executor <tool> --json` 校验最新 run→test receipt 的 artifact/hash/HEAD;然后 **`harness_gate.py begin --phase test --change <id>`**(禁止手工 phase.start / 手写 ledger)。执行各项强制环境检查 + **命令执行模式 preflight (0.1)**;只有首选执行器不可用时,才执行 fallback 执行器探测。
109
110
 
110
111
  验证写入**仅**允许 `harness_ledger.py record` / `can-reuse`;禁止 Write/Edit `verification-ledger.json`。测试跟踪:gate begin → 执行(可选 `harness_test_guard.py mark stale-test-repair`)→ 单次 `harness_gate.py close`。`--to-phase` 取实际阶段计划的后继;Fixback 返回 run,普通流程可直接进入 Review、Submit 或 Archive。Fixback 只失效与改动文件相交的验证目标。
111
112
 
@@ -991,6 +991,62 @@ def migrate_legacy_candidate_evidence(
991
991
  return receipt
992
992
 
993
993
 
994
+ # 声明必须是具体的错误签名,不能是能匹配一切的短串。这个下限不是"安全边界"
995
+ # ——审计线索才是——但它挡住最省事的滥用写法。
996
+ _PREEXISTING_MIN_PATTERN = 8
997
+
998
+
999
+ def _known_preexisting_patterns(
1000
+ project_root: Path,
1001
+ ) -> tuple[list[dict[str, str]], list[dict[str, str]]]:
1002
+ """build-profile 里声明的预存失败签名,按"是否足够具体"分成两组。
1003
+
1004
+ `knownPreexistingErrors` 由 `harness_preflight.py record-quirk --action
1005
+ skip-not-block` 写入,此前没有任何消费方——于是预存失败在 certify-local 处
1006
+ 成为死结:要么去 gate-policy 降门禁,要么顺手改范围外的产品 bug。
1007
+ """
1008
+ path = project_root / ".harness" / "config" / "build-profile.json"
1009
+ if not path.is_file():
1010
+ return [], []
1011
+ try:
1012
+ profile = json.loads(path.read_text(encoding="utf-8-sig"))
1013
+ except (OSError, ValueError):
1014
+ return [], []
1015
+ declared = profile.get("knownPreexistingErrors") if isinstance(profile, dict) else None
1016
+ if not isinstance(declared, list):
1017
+ return [], []
1018
+ usable: list[dict[str, str]] = []
1019
+ vague: list[dict[str, str]] = []
1020
+ for item in declared:
1021
+ if not isinstance(item, dict) or item.get("action") != "skip-not-block":
1022
+ continue
1023
+ pattern = str(item.get("pattern") or "").strip()
1024
+ reason = str(item.get("reason") or "").strip()
1025
+ if not pattern or not reason:
1026
+ continue
1027
+ record = {"pattern": pattern, "reason": reason}
1028
+ (usable if len(pattern) >= _PREEXISTING_MIN_PATTERN else vague).append(record)
1029
+ return usable, vague
1030
+
1031
+
1032
+ def _match_preexisting(
1033
+ entry: dict[str, Any], patterns: list[dict[str, str]]
1034
+ ) -> dict[str, str] | None:
1035
+ """失败证据里是否确实出现了某个已声明签名。
1036
+
1037
+ 只看"声明过"是不够的——那等于声明一次豁免一切。必须是这一条失败的证据里
1038
+ 真的带着该签名,声明与现场才对得上。
1039
+ """
1040
+ evidence = entry.get("evidence")
1041
+ haystack = evidence if isinstance(evidence, str) else json.dumps(
1042
+ evidence, ensure_ascii=False, sort_keys=True
1043
+ )
1044
+ for record in patterns:
1045
+ if record["pattern"] in haystack:
1046
+ return record
1047
+ return None
1048
+
1049
+
994
1050
  def certify_local_candidate(
995
1051
  change_dir: Path,
996
1052
  *,
@@ -1029,14 +1085,28 @@ def certify_local_candidate(
1029
1085
  required = ["unitTestFull"]
1030
1086
  required = [str(item).strip() for item in required if str(item).strip()]
1031
1087
 
1088
+ usable_patterns, vague_patterns = _known_preexisting_patterns(project_root)
1089
+
1032
1090
  selected: list[dict[str, Any]] = []
1091
+ exemptions: list[dict[str, Any]] = []
1033
1092
  for name in required:
1034
1093
  entry = validations.get(name)
1035
1094
  if not isinstance(entry, dict):
1036
1095
  raise ValueError(f"required validation missing: {name}")
1037
1096
  missing: list[str] = []
1097
+ exempted = None
1038
1098
  if str(entry.get("status") or "").upper() != "OK":
1039
- missing.append("status=OK")
1099
+ exempted = _match_preexisting(entry, usable_patterns)
1100
+ if exempted is None:
1101
+ vague = _match_preexisting(entry, vague_patterns)
1102
+ if vague is not None:
1103
+ raise ValueError(
1104
+ f"required validation {name} failed and the declared "
1105
+ f"knownPreexistingErrors pattern {vague['pattern']!r} is too "
1106
+ "generic to identify it; declare the specific error signature "
1107
+ f"(at least {_PREEXISTING_MIN_PATTERN} characters)"
1108
+ )
1109
+ missing.append("status=OK")
1040
1110
  for field in (
1041
1111
  "command",
1042
1112
  "evidence",
@@ -1048,6 +1118,15 @@ def certify_local_candidate(
1048
1118
  raise ValueError(
1049
1119
  f"required validation {name} is incomplete: {', '.join(missing)}"
1050
1120
  )
1121
+ if exempted is not None:
1122
+ exemptions.append(
1123
+ {
1124
+ "validation": name,
1125
+ "status": str(entry.get("status") or "").upper(),
1126
+ "pattern": exempted["pattern"],
1127
+ "reason": exempted["reason"],
1128
+ }
1129
+ )
1051
1130
  selected.append(
1052
1131
  {
1053
1132
  "name": name,
@@ -1160,6 +1239,8 @@ def certify_local_candidate(
1160
1239
  {str(item["inputsHash"]) for item in selected}
1161
1240
  ),
1162
1241
  "logHashes": sorted({str(item["logHash"]) for item in selected}),
1242
+ # 豁免必须随收据一起留痕,否则事后无法把它与干净通过区分开
1243
+ "preexistingExemptions": exemptions,
1163
1244
  },
1164
1245
  }
1165
1246
  if rebound_from_commit is not None:
@@ -8471,8 +8552,17 @@ def cmd_finalize(
8471
8552
  payload["knowledgeMaintenance"] = _knowledge_maintenance_from_archive_push(
8472
8553
  push_result
8473
8554
  )
8474
- if push_result.get("archiveStatus") == "durable":
8475
- payload["archiveDurability"] = "ARCHIVED_REMOTE_DURABLE"
8555
+ remote_durability = remote_durable_archive_durability(
8556
+ payload.get("archiveDurability"), push_result
8557
+ )
8558
+ if remote_durability is not None:
8559
+ payload["archiveDurability"] = remote_durability
8560
+ # 报告必须跟着改口:上传发生在 summary 落盘之后,不回写就会出现
8561
+ # "回执 durable / 报告 local-only" 的自相矛盾
8562
+ if not persist_archive_durability(summary_path, remote_durability):
8563
+ warnings.append(
8564
+ "远端归档已持久化,但 summary-data.json 的 archiveDurability 回写失败"
8565
+ )
8476
8566
  if push_result.get("warning"):
8477
8567
  warnings.append(str(push_result["warning"]))
8478
8568
 
@@ -9040,6 +9130,50 @@ def build_archive_package(
9040
9130
  }
9041
9131
 
9042
9132
 
9133
+ def remote_durable_archive_durability(
9134
+ current: Any, push_result: Mapping[str, Any]
9135
+ ) -> dict[str, Any] | None:
9136
+ """远端上传成功后的耐久性投影;未 durable 时返回 None。
9137
+
9138
+ summary-data.json 在归档流程早期就以 ARCHIVED_LOCAL_ONLY 写盘,而 ZIP 上传在
9139
+ 那之后才发生。此前上传成功只改内存里的 payload、还把对象覆盖成裸字符串,落盘
9140
+ 报告从没跟着改——于是同一次归档,回执说 durable、报告说 local-only,平台和用户
9141
+ 读到的是后者。这里保持对象形状并带上回执标识,供调用方同时更新 payload 与文件。
9142
+ """
9143
+ if str(push_result.get("archiveStatus") or "") != "durable":
9144
+ return None
9145
+ projected = dict(current) if isinstance(current, dict) else {}
9146
+ projected.update({
9147
+ "status": "ARCHIVED_REMOTE_DURABLE",
9148
+ # 已远端持久化:不再声称"只存在于本地、可能随工作区丢失"
9149
+ "risk": None,
9150
+ "archiveId": push_result.get("archiveId"),
9151
+ "uploadStatus": push_result.get("uploadStatus"),
9152
+ "knowledgeStatus": push_result.get("knowledgeStatus"),
9153
+ })
9154
+ return projected
9155
+
9156
+
9157
+ def persist_archive_durability(summary_path: Path, durability: dict[str, Any]) -> bool:
9158
+ """把耐久性结论回写到已落盘的 summary,其余字段原样保留。
9159
+
9160
+ summary-data.json 本就被排除在归档校验和覆盖之外(它在覆盖快照之后才写),
9161
+ 所以这次回写不会破坏 manifest 对账。
9162
+ """
9163
+ try:
9164
+ summary = json.loads(Path(summary_path).read_text(encoding="utf-8-sig"))
9165
+ except (OSError, json.JSONDecodeError):
9166
+ return False
9167
+ if not isinstance(summary, dict):
9168
+ return False
9169
+ summary["archiveDurability"] = durability
9170
+ try:
9171
+ write_json(Path(summary_path), summary)
9172
+ except OSError:
9173
+ return False
9174
+ return True
9175
+
9176
+
9043
9177
  def auto_push_managed_snapshot(project_root: Path) -> dict[str, Any]:
9044
9178
  """Best-effort upload of rules and Codebase Map through the normal push protocol."""
9045
9179
  project_root = project_root.resolve()
@@ -9095,16 +9229,25 @@ def auto_push_managed_snapshot(project_root: Path) -> dict[str, Any]:
9095
9229
  except (json.JSONDecodeError, TypeError):
9096
9230
  output = None
9097
9231
  if completed.returncode != 0 or not isinstance(output, dict):
9098
- return {
9232
+ # 失败时丢掉 stdout/stderr 等于把线索一起丢了:plan/spec 与规则、架构地图
9233
+ # 全靠这条推送上平台,用户在平台上只会看到"东西没上去",却无从查为什么。
9234
+ # 保留有界的诊断片段(push 本身拒绝上传 secret,这里再截断兜底)。
9235
+ detail = (completed.stderr or completed.stdout or "").strip()
9236
+ failure: dict[str, Any] = {
9099
9237
  "ok": False,
9100
9238
  "skipped": False,
9101
9239
  "exitCode": completed.returncode,
9102
9240
  "reasonCode": "MANAGED_SNAPSHOT_UPLOAD_FAILED",
9241
+ "detail": detail[-2_048:],
9103
9242
  "warning": (
9104
- "项目规则与架构地图未能同步到平台;归档不受影响,"
9105
- "稍后可运行 npx hunter-harness push --yes --non-interactive 重试。"
9243
+ "项目规则与架构地图未能同步到平台(plan/spec 也随这条推送上传);"
9244
+ "归档不受影响,稍后可运行 npx hunter-harness push --yes --non-interactive 重试。"
9106
9245
  ),
9107
9246
  }
9247
+ cli_code = output.get("code") or output.get("reason_code") if isinstance(output, dict) else None
9248
+ if isinstance(cli_code, str) and cli_code:
9249
+ failure["cliCode"] = cli_code
9250
+ return failure
9108
9251
  summary = output.get("summary")
9109
9252
  submitted = summary.get("submitted", 0) if isinstance(summary, dict) else 0
9110
9253
  return {
@@ -1081,6 +1081,90 @@ def cmd_resolve(args: argparse.Namespace) -> int:
1081
1081
  )
1082
1082
 
1083
1083
 
1084
+ def declare_product_ownership(
1085
+ project_root: Path, change_id: str, *, product_paths: list[str]
1086
+ ) -> dict[str, Any]:
1087
+ """把 `ownership.productPaths` 写进 change 契约。
1088
+
1089
+ 这个字段此前没有任何写入方:plan 的 validate_product_ownership 只校验、缺失时
1090
+ 软放行,而归档的 compute_ownership_diff 会把全部改动判成 foreignPaths,
1091
+ filesChanged=0 直接触发 DIFF_ZERO_WITH_NONEMPTY_COMMIT。两端口径不一致,中间
1092
+ 没工具能补——只能手改契约。这里给出正规入口。
1093
+
1094
+ 规则与 plan 校验一致:只收精确文件或目录前缀,不接受通配。
1095
+ """
1096
+ resolved = resolve_change(project_root, change_id)
1097
+ if not resolved.get("ok"):
1098
+ return resolved
1099
+ change_dir = Path(resolved["changeDir"])
1100
+ normalized = sorted({
1101
+ str(item).strip().replace("\\", "/").removeprefix("./")
1102
+ for item in product_paths
1103
+ if isinstance(item, str) and item.strip()
1104
+ })
1105
+ if not normalized:
1106
+ return {
1107
+ "ok": False,
1108
+ "code": "PLAN_PRODUCT_PATHS_REQUIRED",
1109
+ "message": "至少声明一条 productPaths(精确文件或目录前缀)",
1110
+ }
1111
+ unsupported = sorted(p for p in normalized if any(ch in p for ch in "*?[]"))
1112
+ if unsupported:
1113
+ return {
1114
+ "ok": False,
1115
+ "code": "PLAN_PRODUCT_PATHS_GLOB_UNSUPPORTED",
1116
+ "message": "productPaths 只接受精确文件或目录前缀,不支持通配:" + ", ".join(unsupported),
1117
+ "unsupportedPaths": unsupported,
1118
+ }
1119
+
1120
+ context_path = change_dir / CHANGE_CONTEXT_REL
1121
+ try:
1122
+ context = _read_json(context_path)
1123
+ except (OSError, json.JSONDecodeError) as exc:
1124
+ return {"ok": False, "code": "CHANGE_CONTEXT_INVALID", "message": str(exc)}
1125
+ if not isinstance(context, dict):
1126
+ return {"ok": False, "code": "CHANGE_CONTEXT_INVALID", "message": "change-context.json 不是对象"}
1127
+
1128
+ ownership = context.get("ownership")
1129
+ ownership = dict(ownership) if isinstance(ownership, dict) else {}
1130
+ if ownership.get("productPaths") == normalized:
1131
+ return {
1132
+ "ok": True,
1133
+ "code": "PRODUCT_OWNERSHIP_DECLARED",
1134
+ "idempotent": True,
1135
+ "changeId": change_id,
1136
+ "productPaths": normalized,
1137
+ "path": str(context_path),
1138
+ }
1139
+ ownership["productPaths"] = normalized
1140
+ context["ownership"] = ownership
1141
+ _write_json(context_path, context)
1142
+ return {
1143
+ "ok": True,
1144
+ "code": "PRODUCT_OWNERSHIP_DECLARED",
1145
+ "idempotent": False,
1146
+ "changeId": change_id,
1147
+ "productPaths": normalized,
1148
+ "path": str(context_path),
1149
+ }
1150
+
1151
+
1152
+ def cmd_declare_ownership(args: argparse.Namespace) -> int:
1153
+ project = resolve_main_project_root()
1154
+ payload = declare_product_ownership(
1155
+ project, args.change, product_paths=list(args.product_path)
1156
+ )
1157
+ if payload.get("ok"):
1158
+ emit(payload, as_json=bool(args.json))
1159
+ return 0
1160
+ return emit_error(
1161
+ str(payload.get("code", "PRODUCT_OWNERSHIP_FAILED")),
1162
+ str(payload.get("message", "declare ownership failed")),
1163
+ as_json=bool(args.json),
1164
+ extra={k: v for k, v in payload.items() if k not in {"ok", "message"}},
1165
+ )
1166
+
1167
+
1084
1168
  def cmd_migrate(args: argparse.Namespace) -> int:
1085
1169
  project = resolve_main_project_root()
1086
1170
  if not args.change:
@@ -1451,6 +1535,17 @@ def build_parser() -> argparse.ArgumentParser:
1451
1535
  p_migrate.add_argument("--change", required=True)
1452
1536
  p_migrate.set_defaults(func=cmd_migrate)
1453
1537
 
1538
+ p_ownership = sub.add_parser(
1539
+ "declare-ownership", parents=[shared],
1540
+ help="declare ownership.productPaths so archive can project the real diff",
1541
+ )
1542
+ p_ownership.add_argument("--change", required=True)
1543
+ p_ownership.add_argument(
1544
+ "--product-path", required=True, action="append",
1545
+ help="exact file or directory prefix (no globs); repeatable",
1546
+ )
1547
+ p_ownership.set_defaults(func=cmd_declare_ownership)
1548
+
1454
1549
  p_claim = sub.add_parser("claim", parents=[shared])
1455
1550
  p_claim.add_argument("--change", required=True)
1456
1551
  p_claim.add_argument("--phase", required=True)
@@ -304,12 +304,23 @@ def _compact_record_payload(payload: dict[str, Any]) -> dict[str, Any]:
304
304
 
305
305
 
306
306
  def _compact_can_reuse_payload(payload: dict[str, Any]) -> dict[str, Any]:
307
- """C5: can-reuse compact — ok/reuse/code only."""
308
- return {
307
+ """C5: can-reuse compact — ok/reuse/code;拒绝复用时附带可执行的原因。
308
+
309
+ 只回 ok/reuse/code 时,调用方拿到裸 `{"reuse": false}` 无从判断该怎么办,
310
+ 只能再跑一次 `--verbose`——原因(profile 缺 verificationInputs、证据不完整等)
311
+ 是单行短文本,值得留在默认输出里。允许复用时保持原样精简。
312
+ """
313
+ compact = {
309
314
  "ok": payload.get("ok", True),
310
315
  "reuse": payload.get("reuse"),
311
316
  "code": payload.get("code"),
312
317
  }
318
+ if payload.get("reuse") is not True:
319
+ for key in ("reason", "executionNeed", "detail"):
320
+ value = payload.get(key)
321
+ if value not in (None, "", [], {}):
322
+ compact[key] = value
323
+ return compact
313
324
 
314
325
 
315
326
  def emit_compact_or_verbose(
@@ -2581,9 +2592,19 @@ def cmd_record(args: argparse.Namespace) -> int:
2581
2592
  error_code="PROFILE_INPUT_FILES_CONFLICT",
2582
2593
  )
2583
2594
  files = profile_files
2584
- if not files:
2595
+ # NOT_APPLICABLE 的 validation 没有输入文件可言。门禁 close 又要求
2596
+ # requiredValidations 每项都有 entry——两条规则叠加会逼调用方拿无关文件凑数,
2597
+ # ledger 从此声称该 validation 的输入是那些文件,是假证据。
2598
+ not_applicable = str(getattr(args, "applicability", "") or "") == "NOT_APPLICABLE"
2599
+ if not files and not not_applicable:
2600
+ return emit_error(
2601
+ "record requires --files or a non-empty --profile-input file set; "
2602
+ "unless --applicability NOT_APPLICABLE (then --applicability-reason is required)",
2603
+ as_json=as_json,
2604
+ )
2605
+ if not_applicable and not _nonempty_str(getattr(args, "applicability_reason", None)):
2585
2606
  return emit_error(
2586
- "record requires --files or a non-empty --profile-input file set",
2607
+ "NOT_APPLICABLE requires --applicability-reason",
2587
2608
  as_json=as_json,
2588
2609
  )
2589
2610
 
@@ -1101,6 +1101,43 @@ def _derive_verification_graph(profile: dict[str, Any]) -> None:
1101
1101
  }
1102
1102
 
1103
1103
 
1104
+ def manual_profile_template(
1105
+ excluded: tuple[str, ...] | list[str], components: list[str]
1106
+ ) -> dict[str, Any]:
1107
+ """歧义时交给调用方的可填骨架。
1108
+
1109
+ 骨架本身就是 `empty_profile_skeleton`——defaultsFingerprint、excludedRoots
1110
+ 默认集、commands/verificationInputs/verificationGraph 的键都已就位;这里只是
1111
+ 补上 commands 的条目形状与 requiredCoverage 取值,省掉调用方去源码里翻。
1112
+ 占位值用 `<...>` 包裹,便于替换后 grep 自检。
1113
+ """
1114
+ template = empty_profile_skeleton(excluded)
1115
+ template["projectType"] = (
1116
+ f"<从 detectedComponents 选定:{'|'.join(components)}>" if components else "<项目类型>"
1117
+ )
1118
+ template["commands"] = {
1119
+ "unitTest": {
1120
+ "command": "<在项目根可直接执行的增量测试命令>",
1121
+ "scope": "incremental",
1122
+ "inputs": ["<相对项目根的 glob,如 <component>/test/**>"],
1123
+ "coverage": "unitTest",
1124
+ "requiredCoverage": "incremental",
1125
+ "dependsOn": [],
1126
+ "source": "user",
1127
+ },
1128
+ "unitTestFull": {
1129
+ "command": "<在项目根可直接执行的全量测试命令>",
1130
+ "scope": "full",
1131
+ "inputs": ["<相对项目根的 glob,如 <component>/**>"],
1132
+ "coverage": "unitTestFull",
1133
+ "requiredCoverage": "module",
1134
+ "dependsOn": [],
1135
+ "source": "user",
1136
+ },
1137
+ }
1138
+ return template
1139
+
1140
+
1104
1141
  def detect(project: Path) -> dict[str, Any]:
1105
1142
  project = project.resolve()
1106
1143
  existing = load_profile(project)
@@ -1120,6 +1157,14 @@ def detect(project: Path) -> dict[str, Any]:
1120
1157
  "applied": False,
1121
1158
  "detectedComponents": components,
1122
1159
  "message": "multiple or nested project components require an explicit profile",
1160
+ # 拒绝生成可以,但不能连结构都不给:调用方否则只能反读本文件源码去凑
1161
+ # v3 形状。骨架不落盘——一份占位 profile 看起来"已配好"比没有更糟。
1162
+ "profileTemplate": manual_profile_template(excluded, components),
1163
+ "hint": (
1164
+ "手工声明一份 profile:以 profileTemplate 为骨架,按 detectedComponents "
1165
+ "选定产品包,commands 里的 inputs 用相对项目根的 glob(如 "
1166
+ f"'<component>/test/**'),填好后写入 {project / PROFILE_REL}"
1167
+ ),
1123
1168
  }
1124
1169
  if project_type == "unknown" and existing:
1125
1170
  return {
@@ -157,6 +157,35 @@ def _private_evidence_root(explicit: Path | None = None) -> Path:
157
157
  Path.home() / ".harness" / "private-evidence").expanduser().resolve()
158
158
 
159
159
 
160
+ def private_evidence_root_for(source: Path, explicit: Path | None = None) -> Path:
161
+ """私有隔离根:必须与源同一驱动器。
162
+
163
+ `os.replace` 跨驱动器会直接失败(Windows 上是 WinError 17)。默认根
164
+ `~/.harness/private-evidence` 在 Windows 落 C 盘,项目在别的盘时隔离必然失败
165
+ ——而隔离是归档的硬前置,等于整条归档路被堵死。同盘时沿用配置/默认值;不同盘
166
+ 时退到源所在驱动器根下的 `.harness-private-evidence`(天然在任何项目根之外)。
167
+ POSIX 上 drive 恒为空串,行为不变。
168
+ """
169
+ source = source.expanduser().resolve()
170
+ preferred = _private_evidence_root(explicit)
171
+ if preferred.drive.lower() == source.drive.lower():
172
+ return preferred
173
+ return (Path(source.anchor) / ".harness-private-evidence").resolve()
174
+
175
+
176
+ def _infer_project_root(change_root: Path) -> Path | None:
177
+ """change 目录形如 `<project>/.harness/changes/<name>`:取路径里 `.harness` 段的父目录。
178
+
179
+ 不做"向上找存在 .harness 的目录"式搜索——用户 HOME 下通常也有 `.harness`,
180
+ 那样会把 HOME 误判成项目根,把合法的临时隔离位置一并拒掉。
181
+ """
182
+ parts = change_root.parts
183
+ for index in range(len(parts) - 1, 0, -1):
184
+ if parts[index] == ".harness":
185
+ return Path(*parts[:index])
186
+ return None
187
+
188
+
160
189
  def _secure_private_path(path: Path, *, directory: bool) -> str:
161
190
  """Best-effort owner-only permissions, with an explicit ACL result on Windows."""
162
191
  mode = 0o700 if directory else 0o600
@@ -288,11 +317,12 @@ def quarantine_sensitive_evidence(
288
317
  change_root: Path,
289
318
  reason: str = "sensitive evidence",
290
319
  private_root: Path | None = None,
320
+ project_root: Path | None = None,
291
321
  ) -> dict[str, Any]:
292
322
  """Atomically move legacy plaintext evidence outside any publishable tree."""
293
323
  source = source.expanduser().resolve()
294
324
  change_root = change_root.expanduser().resolve()
295
- private = _private_evidence_root(private_root)
325
+ private = private_evidence_root_for(source, private_root)
296
326
  if not source.is_file():
297
327
  return {"ok": False, "reasonCode": "SENSITIVE_EVIDENCE_SOURCE_MISSING", "sourcePath": str(source)}
298
328
  if private == change_root or private.is_relative_to(change_root):
@@ -301,6 +331,25 @@ def quarantine_sensitive_evidence(
301
331
  "reasonCode": "SENSITIVE_EVIDENCE_QUARANTINE_FAILED",
302
332
  "error": "private quarantine root must be outside the publishable change root",
303
333
  }
334
+ # 归档的密钥扫描门禁按**项目根**判定(SECRET_SCAN_PRIVATE_PATH_IN_COPY_ROOT)。
335
+ # 这里此前只看 change_root,比门禁宽——于是隔离"成功"了,门禁再拒一次,调用方
336
+ # 得多试一轮才找到合法位置。对齐到同一条边界,错就错在当场。
337
+ resolved_project = (
338
+ project_root.expanduser().resolve()
339
+ if project_root is not None
340
+ else _infer_project_root(change_root)
341
+ )
342
+ if resolved_project is not None and (
343
+ private == resolved_project or private.is_relative_to(resolved_project)
344
+ ):
345
+ return {
346
+ "ok": False,
347
+ "reasonCode": "SENSITIVE_EVIDENCE_QUARANTINE_FAILED",
348
+ "error": (
349
+ "private quarantine root must be outside the project root "
350
+ f"({resolved_project}); archive's secret scan rejects paths inside it"
351
+ ),
352
+ }
304
353
  try:
305
354
  raw_digest = "sha256:" + hashlib.sha256(source.read_bytes()).hexdigest()
306
355
  quarantine_dir = private / uuid.uuid4().hex
@@ -1850,6 +1899,37 @@ def doctor(project: Path, change_dir: Path, *, agent: str) -> dict[str, Any]:
1850
1899
  return {"ok": True, "action": "doctor", **payload}
1851
1900
 
1852
1901
 
1902
+ def cmd_quarantine_evidence(args: argparse.Namespace) -> dict[str, Any]:
1903
+ """隔离命令:把明文敏感证据原子移出可发布树,逐条返回结果。
1904
+
1905
+ 单条失败不吞掉其余条目——归档阻断项常常一次报多个文件,逐条报告才能一轮修完。
1906
+ """
1907
+ project = Path(args.project).expanduser().resolve()
1908
+ change_root = Path(args.change_dir).expanduser().resolve()
1909
+ private_root = Path(args.private_root) if args.private_root else None
1910
+ entries: list[dict[str, Any]] = []
1911
+ for raw in args.file:
1912
+ candidate = Path(raw)
1913
+ source = candidate if candidate.is_absolute() else change_root / candidate
1914
+ outcome = quarantine_sensitive_evidence(
1915
+ source,
1916
+ change_root=change_root,
1917
+ reason=args.reason,
1918
+ private_root=private_root,
1919
+ project_root=project,
1920
+ )
1921
+ entries.append({"file": raw, **outcome})
1922
+ failed = [item for item in entries if not item.get("ok")]
1923
+ return {
1924
+ "ok": not failed,
1925
+ "action": "quarantine-evidence",
1926
+ "code": "SENSITIVE_EVIDENCE_QUARANTINED" if not failed else "SENSITIVE_EVIDENCE_QUARANTINE_FAILED",
1927
+ "quarantined": len(entries) - len(failed),
1928
+ "failed": len(failed),
1929
+ "entries": entries,
1930
+ }
1931
+
1932
+
1853
1933
  def build_parser() -> argparse.ArgumentParser:
1854
1934
  parser = argparse.ArgumentParser(prog="harness_runtime.py")
1855
1935
  sub = parser.add_subparsers(dest="command", required=True)
@@ -1858,6 +1938,24 @@ def build_parser() -> argparse.ArgumentParser:
1858
1938
  p_doctor.add_argument("--change-dir", required=True)
1859
1939
  p_doctor.add_argument("--agent", choices=sorted(_ADAPTERS), required=True)
1860
1940
  p_doctor.add_argument("--json", action="store_true")
1941
+ # 归档的 SENSITIVE_EVIDENCE_UNQUARANTINED 是硬阻断,此前却没有任何命令行入口
1942
+ # ——调用方只能写 python -c + sys.path hack 去调内部函数。
1943
+ p_quarantine = sub.add_parser(
1944
+ "quarantine-evidence",
1945
+ help="move plaintext sensitive evidence outside the publishable tree",
1946
+ )
1947
+ p_quarantine.add_argument("--project", required=True)
1948
+ p_quarantine.add_argument("--change-dir", required=True)
1949
+ p_quarantine.add_argument(
1950
+ "--file", required=True, action="append",
1951
+ help="path to quarantine, relative to --change-dir or absolute; repeatable",
1952
+ )
1953
+ p_quarantine.add_argument("--reason", default="sensitive evidence")
1954
+ p_quarantine.add_argument(
1955
+ "--private-root", default=None,
1956
+ help="override the private root; must be outside the project root and on its drive",
1957
+ )
1958
+ p_quarantine.add_argument("--json", action="store_true")
1861
1959
  p_adapter = sub.add_parser("adapter")
1862
1960
  p_adapter.add_argument("--agent", choices=sorted(_ADAPTERS), required=True)
1863
1961
  p_adapter.add_argument("--change", required=True)
@@ -1899,6 +1997,8 @@ def main(argv: list[str] | None = None) -> int:
1899
1997
  try:
1900
1998
  if args.command == "doctor":
1901
1999
  result = doctor(Path(args.project), Path(args.change_dir), agent=args.agent)
2000
+ elif args.command == "quarantine-evidence":
2001
+ result = cmd_quarantine_evidence(args)
1902
2002
  elif args.command == "adapter":
1903
2003
  result = {"ok": True, "action": "adapter", **adapter_worktree(args.agent, args.change)}
1904
2004
  elif args.command == "_worker":
@@ -2,5 +2,5 @@
2
2
  "schemaVersion": 1,
3
3
  "overlay": "java",
4
4
  "agent": "codebuddy",
5
- "coreHash": "984aee382a36595c"
5
+ "coreHash": "4f3a290efefe6a68"
6
6
  }