@hunter-harness/workflow-harness 0.2.72 → 0.2.74

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 (206) 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 +3 -3
  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 +14 -10
  7. package/harness/bundles/general/claude-code/harness-plan/checklist.md +49 -9
  8. package/harness/bundles/general/claude-code/harness-plan/reference.md +83 -33
  9. package/harness/bundles/general/claude-code/harness-pull/SKILL.md +1 -1
  10. package/harness/bundles/general/claude-code/harness-push/SKILL.md +1 -1
  11. package/harness/bundles/general/claude-code/harness-review/SKILL.md +3 -3
  12. package/harness/bundles/general/claude-code/harness-run/SKILL.md +6 -6
  13. package/harness/bundles/general/claude-code/harness-run/checklist.md +4 -1
  14. package/harness/bundles/general/claude-code/harness-run/reference.md +3 -3
  15. package/harness/bundles/general/claude-code/harness-submit/SKILL.md +5 -4
  16. package/harness/bundles/general/claude-code/harness-sync/SKILL.md +1 -1
  17. package/harness/bundles/general/claude-code/harness-test/SKILL.md +4 -4
  18. package/harness/bundles/general/claude-code/harness-test/checklist.md +2 -0
  19. package/harness/bundles/general/claude-code/scripts/harness_archive.py +82 -1
  20. package/harness/bundles/general/claude-code/scripts/harness_context.py +270 -0
  21. package/harness/bundles/general/claude-code/scripts/harness_gate.py +71 -2
  22. package/harness/bundles/general/claude-code/scripts/harness_ledger.py +25 -4
  23. package/harness/bundles/general/claude-code/scripts/harness_profile.py +45 -0
  24. package/harness/bundles/general/claude-code/scripts/harness_test_guard.py +8 -5
  25. package/harness/bundles/general/codebuddy/.harness-build.json +1 -1
  26. package/harness/bundles/general/codebuddy/harness-archive/SKILL.md +3 -3
  27. package/harness/bundles/general/codebuddy/harness-codebase-map/SKILL.md +1 -1
  28. package/harness/bundles/general/codebuddy/harness-knowledge-ingest/SKILL.md +1 -1
  29. package/harness/bundles/general/codebuddy/harness-knowledge-query/SKILL.md +1 -1
  30. package/harness/bundles/general/codebuddy/harness-plan/SKILL.md +14 -10
  31. package/harness/bundles/general/codebuddy/harness-plan/checklist.md +49 -9
  32. package/harness/bundles/general/codebuddy/harness-plan/reference.md +83 -33
  33. package/harness/bundles/general/codebuddy/harness-pull/SKILL.md +1 -1
  34. package/harness/bundles/general/codebuddy/harness-push/SKILL.md +1 -1
  35. package/harness/bundles/general/codebuddy/harness-review/SKILL.md +3 -3
  36. package/harness/bundles/general/codebuddy/harness-run/SKILL.md +6 -6
  37. package/harness/bundles/general/codebuddy/harness-run/checklist.md +4 -1
  38. package/harness/bundles/general/codebuddy/harness-run/reference.md +3 -3
  39. package/harness/bundles/general/codebuddy/harness-submit/SKILL.md +5 -4
  40. package/harness/bundles/general/codebuddy/harness-sync/SKILL.md +1 -1
  41. package/harness/bundles/general/codebuddy/harness-test/SKILL.md +4 -4
  42. package/harness/bundles/general/codebuddy/harness-test/checklist.md +2 -0
  43. package/harness/bundles/general/codebuddy/scripts/harness_archive.py +82 -1
  44. package/harness/bundles/general/codebuddy/scripts/harness_context.py +270 -0
  45. package/harness/bundles/general/codebuddy/scripts/harness_gate.py +71 -2
  46. package/harness/bundles/general/codebuddy/scripts/harness_ledger.py +25 -4
  47. package/harness/bundles/general/codebuddy/scripts/harness_profile.py +45 -0
  48. package/harness/bundles/general/codebuddy/scripts/harness_test_guard.py +8 -5
  49. package/harness/bundles/general/codex/.harness-build.json +1 -1
  50. package/harness/bundles/general/codex/harness-archive/SKILL.md +3 -3
  51. package/harness/bundles/general/codex/harness-codebase-map/SKILL.md +1 -1
  52. package/harness/bundles/general/codex/harness-knowledge-ingest/SKILL.md +1 -1
  53. package/harness/bundles/general/codex/harness-knowledge-query/SKILL.md +1 -1
  54. package/harness/bundles/general/codex/harness-plan/SKILL.md +14 -10
  55. package/harness/bundles/general/codex/harness-plan/checklist.md +49 -9
  56. package/harness/bundles/general/codex/harness-plan/reference.md +83 -33
  57. package/harness/bundles/general/codex/harness-pull/SKILL.md +1 -1
  58. package/harness/bundles/general/codex/harness-push/SKILL.md +1 -1
  59. package/harness/bundles/general/codex/harness-review/SKILL.md +3 -3
  60. package/harness/bundles/general/codex/harness-run/SKILL.md +6 -6
  61. package/harness/bundles/general/codex/harness-run/checklist.md +4 -1
  62. package/harness/bundles/general/codex/harness-run/reference.md +3 -3
  63. package/harness/bundles/general/codex/harness-submit/SKILL.md +5 -4
  64. package/harness/bundles/general/codex/harness-sync/SKILL.md +1 -1
  65. package/harness/bundles/general/codex/harness-test/SKILL.md +4 -4
  66. package/harness/bundles/general/codex/harness-test/checklist.md +2 -0
  67. package/harness/bundles/general/codex/scripts/harness_archive.py +82 -1
  68. package/harness/bundles/general/codex/scripts/harness_context.py +270 -0
  69. package/harness/bundles/general/codex/scripts/harness_gate.py +71 -2
  70. package/harness/bundles/general/codex/scripts/harness_ledger.py +25 -4
  71. package/harness/bundles/general/codex/scripts/harness_profile.py +45 -0
  72. package/harness/bundles/general/codex/scripts/harness_test_guard.py +8 -5
  73. package/harness/bundles/general/cursor/.harness-build.json +1 -1
  74. package/harness/bundles/general/cursor/harness-archive/SKILL.md +3 -3
  75. package/harness/bundles/general/cursor/harness-codebase-map/SKILL.md +1 -1
  76. package/harness/bundles/general/cursor/harness-knowledge-ingest/SKILL.md +1 -1
  77. package/harness/bundles/general/cursor/harness-knowledge-query/SKILL.md +1 -1
  78. package/harness/bundles/general/cursor/harness-plan/SKILL.md +14 -10
  79. package/harness/bundles/general/cursor/harness-plan/checklist.md +49 -9
  80. package/harness/bundles/general/cursor/harness-plan/reference.md +83 -33
  81. package/harness/bundles/general/cursor/harness-pull/SKILL.md +1 -1
  82. package/harness/bundles/general/cursor/harness-push/SKILL.md +1 -1
  83. package/harness/bundles/general/cursor/harness-review/SKILL.md +3 -3
  84. package/harness/bundles/general/cursor/harness-run/SKILL.md +6 -6
  85. package/harness/bundles/general/cursor/harness-run/checklist.md +4 -1
  86. package/harness/bundles/general/cursor/harness-run/reference.md +3 -3
  87. package/harness/bundles/general/cursor/harness-submit/SKILL.md +5 -4
  88. package/harness/bundles/general/cursor/harness-sync/SKILL.md +1 -1
  89. package/harness/bundles/general/cursor/harness-test/SKILL.md +4 -4
  90. package/harness/bundles/general/cursor/harness-test/checklist.md +2 -0
  91. package/harness/bundles/general/cursor/scripts/harness_archive.py +82 -1
  92. package/harness/bundles/general/cursor/scripts/harness_context.py +270 -0
  93. package/harness/bundles/general/cursor/scripts/harness_gate.py +71 -2
  94. package/harness/bundles/general/cursor/scripts/harness_ledger.py +25 -4
  95. package/harness/bundles/general/cursor/scripts/harness_profile.py +45 -0
  96. package/harness/bundles/general/cursor/scripts/harness_test_guard.py +8 -5
  97. package/harness/bundles/java/claude-code/.harness-build.json +1 -1
  98. package/harness/bundles/java/claude-code/harness-apidoc/SKILL.md +3 -3
  99. package/harness/bundles/java/claude-code/harness-archive/SKILL.md +3 -3
  100. package/harness/bundles/java/claude-code/harness-codebase-map/SKILL.md +1 -1
  101. package/harness/bundles/java/claude-code/harness-knowledge-ingest/SKILL.md +1 -1
  102. package/harness/bundles/java/claude-code/harness-knowledge-query/SKILL.md +1 -1
  103. package/harness/bundles/java/claude-code/harness-package/SKILL.md +3 -3
  104. package/harness/bundles/java/claude-code/harness-plan/SKILL.md +14 -10
  105. package/harness/bundles/java/claude-code/harness-plan/checklist.md +49 -9
  106. package/harness/bundles/java/claude-code/harness-plan/reference.md +83 -33
  107. package/harness/bundles/java/claude-code/harness-pull/SKILL.md +1 -1
  108. package/harness/bundles/java/claude-code/harness-push/SKILL.md +1 -1
  109. package/harness/bundles/java/claude-code/harness-review/SKILL.md +3 -3
  110. package/harness/bundles/java/claude-code/harness-run/SKILL.md +6 -6
  111. package/harness/bundles/java/claude-code/harness-run/checklist.md +1 -1
  112. package/harness/bundles/java/claude-code/harness-run/reference.md +348 -348
  113. package/harness/bundles/java/claude-code/harness-submit/SKILL.md +5 -4
  114. package/harness/bundles/java/claude-code/harness-sync/SKILL.md +1 -1
  115. package/harness/bundles/java/claude-code/harness-test/SKILL.md +4 -4
  116. package/harness/bundles/java/claude-code/scripts/harness_archive.py +82 -1
  117. package/harness/bundles/java/claude-code/scripts/harness_context.py +270 -0
  118. package/harness/bundles/java/claude-code/scripts/harness_gate.py +71 -2
  119. package/harness/bundles/java/claude-code/scripts/harness_ledger.py +25 -4
  120. package/harness/bundles/java/claude-code/scripts/harness_profile.py +45 -0
  121. package/harness/bundles/java/claude-code/scripts/harness_test_guard.py +8 -5
  122. package/harness/bundles/java/codebuddy/.harness-build.json +1 -1
  123. package/harness/bundles/java/codebuddy/harness-apidoc/SKILL.md +3 -3
  124. package/harness/bundles/java/codebuddy/harness-archive/SKILL.md +3 -3
  125. package/harness/bundles/java/codebuddy/harness-codebase-map/SKILL.md +1 -1
  126. package/harness/bundles/java/codebuddy/harness-knowledge-ingest/SKILL.md +1 -1
  127. package/harness/bundles/java/codebuddy/harness-knowledge-query/SKILL.md +1 -1
  128. package/harness/bundles/java/codebuddy/harness-package/SKILL.md +3 -3
  129. package/harness/bundles/java/codebuddy/harness-plan/SKILL.md +14 -10
  130. package/harness/bundles/java/codebuddy/harness-plan/checklist.md +49 -9
  131. package/harness/bundles/java/codebuddy/harness-plan/reference.md +83 -33
  132. package/harness/bundles/java/codebuddy/harness-pull/SKILL.md +1 -1
  133. package/harness/bundles/java/codebuddy/harness-push/SKILL.md +1 -1
  134. package/harness/bundles/java/codebuddy/harness-review/SKILL.md +3 -3
  135. package/harness/bundles/java/codebuddy/harness-run/SKILL.md +6 -6
  136. package/harness/bundles/java/codebuddy/harness-run/checklist.md +1 -1
  137. package/harness/bundles/java/codebuddy/harness-run/reference.md +348 -348
  138. package/harness/bundles/java/codebuddy/harness-submit/SKILL.md +5 -4
  139. package/harness/bundles/java/codebuddy/harness-sync/SKILL.md +1 -1
  140. package/harness/bundles/java/codebuddy/harness-test/SKILL.md +4 -4
  141. package/harness/bundles/java/codebuddy/scripts/harness_archive.py +82 -1
  142. package/harness/bundles/java/codebuddy/scripts/harness_context.py +270 -0
  143. package/harness/bundles/java/codebuddy/scripts/harness_gate.py +71 -2
  144. package/harness/bundles/java/codebuddy/scripts/harness_ledger.py +25 -4
  145. package/harness/bundles/java/codebuddy/scripts/harness_profile.py +45 -0
  146. package/harness/bundles/java/codebuddy/scripts/harness_test_guard.py +8 -5
  147. package/harness/bundles/java/codex/.harness-build.json +1 -1
  148. package/harness/bundles/java/codex/harness-apidoc/SKILL.md +3 -3
  149. package/harness/bundles/java/codex/harness-archive/SKILL.md +3 -3
  150. package/harness/bundles/java/codex/harness-codebase-map/SKILL.md +1 -1
  151. package/harness/bundles/java/codex/harness-knowledge-ingest/SKILL.md +1 -1
  152. package/harness/bundles/java/codex/harness-knowledge-query/SKILL.md +1 -1
  153. package/harness/bundles/java/codex/harness-package/SKILL.md +3 -3
  154. package/harness/bundles/java/codex/harness-plan/SKILL.md +14 -10
  155. package/harness/bundles/java/codex/harness-plan/checklist.md +49 -9
  156. package/harness/bundles/java/codex/harness-plan/reference.md +83 -33
  157. package/harness/bundles/java/codex/harness-pull/SKILL.md +1 -1
  158. package/harness/bundles/java/codex/harness-push/SKILL.md +1 -1
  159. package/harness/bundles/java/codex/harness-review/SKILL.md +3 -3
  160. package/harness/bundles/java/codex/harness-run/SKILL.md +6 -6
  161. package/harness/bundles/java/codex/harness-run/checklist.md +1 -1
  162. package/harness/bundles/java/codex/harness-run/reference.md +348 -348
  163. package/harness/bundles/java/codex/harness-submit/SKILL.md +5 -4
  164. package/harness/bundles/java/codex/harness-sync/SKILL.md +1 -1
  165. package/harness/bundles/java/codex/harness-test/SKILL.md +4 -4
  166. package/harness/bundles/java/codex/scripts/harness_archive.py +82 -1
  167. package/harness/bundles/java/codex/scripts/harness_context.py +270 -0
  168. package/harness/bundles/java/codex/scripts/harness_gate.py +71 -2
  169. package/harness/bundles/java/codex/scripts/harness_ledger.py +25 -4
  170. package/harness/bundles/java/codex/scripts/harness_profile.py +45 -0
  171. package/harness/bundles/java/codex/scripts/harness_test_guard.py +8 -5
  172. package/harness/bundles/java/cursor/.harness-build.json +1 -1
  173. package/harness/bundles/java/cursor/harness-apidoc/SKILL.md +3 -3
  174. package/harness/bundles/java/cursor/harness-archive/SKILL.md +3 -3
  175. package/harness/bundles/java/cursor/harness-codebase-map/SKILL.md +1 -1
  176. package/harness/bundles/java/cursor/harness-knowledge-ingest/SKILL.md +1 -1
  177. package/harness/bundles/java/cursor/harness-knowledge-query/SKILL.md +1 -1
  178. package/harness/bundles/java/cursor/harness-package/SKILL.md +3 -3
  179. package/harness/bundles/java/cursor/harness-plan/SKILL.md +14 -10
  180. package/harness/bundles/java/cursor/harness-plan/checklist.md +49 -9
  181. package/harness/bundles/java/cursor/harness-plan/reference.md +83 -33
  182. package/harness/bundles/java/cursor/harness-pull/SKILL.md +1 -1
  183. package/harness/bundles/java/cursor/harness-push/SKILL.md +1 -1
  184. package/harness/bundles/java/cursor/harness-review/SKILL.md +3 -3
  185. package/harness/bundles/java/cursor/harness-run/SKILL.md +6 -6
  186. package/harness/bundles/java/cursor/harness-run/checklist.md +1 -1
  187. package/harness/bundles/java/cursor/harness-run/reference.md +348 -348
  188. package/harness/bundles/java/cursor/harness-submit/SKILL.md +5 -4
  189. package/harness/bundles/java/cursor/harness-sync/SKILL.md +1 -1
  190. package/harness/bundles/java/cursor/harness-test/SKILL.md +4 -4
  191. package/harness/bundles/java/cursor/scripts/harness_archive.py +82 -1
  192. package/harness/bundles/java/cursor/scripts/harness_context.py +270 -0
  193. package/harness/bundles/java/cursor/scripts/harness_gate.py +71 -2
  194. package/harness/bundles/java/cursor/scripts/harness_ledger.py +25 -4
  195. package/harness/bundles/java/cursor/scripts/harness_profile.py +45 -0
  196. package/harness/bundles/java/cursor/scripts/harness_test_guard.py +8 -5
  197. package/harness/manifests/general/claude-code.json +26 -26
  198. package/harness/manifests/general/codebuddy.json +26 -26
  199. package/harness/manifests/general/codex.json +26 -26
  200. package/harness/manifests/general/cursor.json +26 -26
  201. package/harness/manifests/java/claude-code.json +27 -27
  202. package/harness/manifests/java/codebuddy.json +27 -27
  203. package/harness/manifests/java/codex.json +27 -27
  204. package/harness/manifests/java/cursor.json +27 -27
  205. package/hunter-workflow-family.json +5 -5
  206. package/package.json +1 -1
@@ -273,6 +273,8 @@ powershell.exe -NoProfile -Command "try { (Invoke-WebRequest -Uri 'http://127.0.
273
273
  - [ ] 复用 → 跳过重跑,标记"✅ 复用 harness-run 单元测试结果"
274
274
  - [ ] 不复用 → 按 profile key resolve 重跑测试命令(`harness_profile.py resolve --key unitTest`,不复制示例 `-pl` 命令),结果写回 ledger 的 `unitTest` 项
275
275
  - [ ] HTTP/API 契约结果写入 `apiTest`;真实浏览器/真实栈 Playwright 结果写入 `browserTest`,两者不得互相覆盖
276
+ - [ ] **本变更没有该维度场景时**(如全部为单元场景 → 无 API 场景):用 `harness_ledger.py record --verification apiTest --status NOT_RUN --applicability NOT_APPLICABLE --applicability-reason "<为什么不适用>"` 登记,**不传 `--files`**。门禁 close 要求 requiredValidations 每项都有 entry,但**绝不能拿无关文件(比如单元测试文件)凑 `--files`**——那会让 ledger 声称该维度的输入是那些文件,是假证据
277
+ - [ ] `can-reuse` 返回 `reuse:false` 时直接读默认输出里的 `reason`/`executionNeed`/`detail` 定位原因,不必再补跑 `--verbose`
276
278
  - [ ] 复用判断前以 `harness_ledger.py diff-hash --repo . --base <baseCommit> --change-dir ".harness/changes/<change-name>" --json` 重算指纹;test-tracking manifest 无效或 hash 漂移即停止
277
279
  - [ ] 测试失败若明确为陈旧测试,仅在当前代码/批准计划/可验证历史唯一确定新契约且只改测试时自动修复;否则记录 `BLOCKED_PREEXISTING`
278
280
  - [ ] 自动修复后立即重跑该测试与目标测试,并以 `harness_test_guard.py record ... --reason stale-test-repair` 记录精确路径
@@ -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:
@@ -732,6 +732,16 @@ def prepare_context(
732
732
  )
733
733
  if not reselection.get("ok"):
734
734
  return reselection
735
+ # v2 计划的交接凭证由此补录:finalize 不写 context 事务,缺它 run 阶段进不去
736
+ if phase != "plan":
737
+ _bootstrap_v2_plan_transition(
738
+ project,
739
+ change,
740
+ contract_root=contract_root,
741
+ state_root=state_root,
742
+ to_phase=phase,
743
+ executor=executor,
744
+ )
735
745
  claim = _claim_prepared_context(
736
746
  project,
737
747
  change,
@@ -779,6 +789,150 @@ def prepare_context(
779
789
  }
780
790
 
781
791
 
792
+ def _plan_run_identity(change_dir: Path) -> tuple[str, int] | None:
793
+ """已存在的 plan `phase.start` 身份(run-id, attempt)。
794
+
795
+ finalize 按生命周期身份 fail-closed:重跑引导时换一个 run-id,整条链路就对不上。
796
+ 因此引导必须复用已有身份,而不是每次新生成。
797
+ """
798
+ for item in reversed(_read_ndjson(change_dir / "events.ndjson")):
799
+ if item.get("phase") != "plan" or item.get("type") != "phase.start":
800
+ continue
801
+ run_id = item.get("run_id")
802
+ if isinstance(run_id, str) and run_id:
803
+ attempt = item.get("attempt")
804
+ return run_id, attempt if isinstance(attempt, int) and attempt > 0 else 1
805
+ return None
806
+
807
+
808
+ def bootstrap_plan(
809
+ project: Path,
810
+ *,
811
+ change: str,
812
+ executor: str,
813
+ display_title: str | None = None,
814
+ stage: str = "plan",
815
+ ttl_seconds: int = 3600,
816
+ ) -> dict[str, Any]:
817
+ """阶段 0 一次性引导:doctor + prepare + capture + classify + phase.start。
818
+
819
+ 拆成五条子进程调用时,每条的完整 argv 都要调用方记住(少 `--project` 或
820
+ `--change-dir` 就白跑一轮),run-id 还要调用方自己保证小写字母开头。这些都是
821
+ 确定性工作,交给脚本做;调用方只需要一次调用和一份紧凑摘要。
822
+
823
+ 重跑安全:已有 plan `phase.start` 时复用同一 run-id / attempt,不追加第二条。
824
+ """
825
+ import uuid
826
+
827
+ import harness_change as hchg
828
+ import harness_events as he
829
+ import harness_gate as hg
830
+ import harness_runtime as hr
831
+ import harness_state as hs
832
+
833
+ project = Path(project).resolve()
834
+ changes_root = project / ".harness" / "changes"
835
+ if not changes_root.is_dir():
836
+ return {
837
+ "ok": False,
838
+ "code": "PROJECT_ROOT_INVALID",
839
+ "error": f"{changes_root} 不存在——先在该项目运行 hunter-harness init",
840
+ }
841
+
842
+ # 新 change 的目录此前没有任何脚本负责创建,靠的是后续 Write 的副作用;
843
+ # 引导阶段显式建立骨架,prepare 才有 contract 可读
844
+ change_dir = changes_root / change
845
+ created_change = not change_dir.is_dir()
846
+ if created_change:
847
+ (change_dir / "meta").mkdir(parents=True, exist_ok=True)
848
+ hchg.migrate_change(project, change)
849
+
850
+ prepared = prepare_context(
851
+ project,
852
+ phase="plan",
853
+ executor=executor,
854
+ change=change,
855
+ display_title=display_title,
856
+ ttl_seconds=ttl_seconds,
857
+ )
858
+ if not prepared.get("ok"):
859
+ return prepared
860
+
861
+ change_name = str(prepared["changeName"])
862
+ # executionRoot 是代码执行根(无 worktree 时等于项目根),change_dir 才是状态目录。
863
+ # 计划产物一律落 change_dir,混用会把 gate-policy/events 写到项目根去。
864
+ change_dir = (changes_root / change_name).resolve()
865
+ execution_root = Path(prepared["executionRoot"])
866
+
867
+ # doctor 失败不阻断引导:它是环境体检,结论随摘要返回给调用方判断
868
+ try:
869
+ health = hr.doctor(project, change_dir, agent=executor)
870
+ except Exception as exc: # noqa: BLE001 - 体检失败要报告而不是中断引导
871
+ health = {"ok": False, "code": "DOCTOR_FAILED", "error": str(exc)}
872
+
873
+ # 首次 capture 把当时的 HEAD 固定为不可变 changeBase(design §3.6)
874
+ snapshot, changed_segments = hs.capture_current_state(
875
+ project=project,
876
+ change_dir=change_dir,
877
+ change_name=change_name,
878
+ worktree_root=project,
879
+ )
880
+ git_state = snapshot.get("git") or {}
881
+
882
+ workflow = hg._load_workflow_policy(project=project)
883
+ classification = hg.classify_risk(change_dir, stage, workflow=workflow)
884
+ classification.setdefault("tierOverride", None)
885
+ classification["classifiedAt"] = _now().isoformat().replace("+00:00", "Z")
886
+ policy_path = change_dir / "meta" / "gate-policy.json"
887
+ hg._write_json(policy_path, hg.gate_policy_document(classification))
888
+
889
+ identity = _plan_run_identity(change_dir)
890
+ reused = identity is not None
891
+ if identity is None:
892
+ # v2 identity:必须小写字母开头,裸 UUID 有 10/16 概率数字开头被拒
893
+ run_id = f"plan_{uuid.uuid4()}"
894
+ attempt = 1
895
+ appended = he.append_event(
896
+ change_dir,
897
+ phase="plan",
898
+ type_="phase.start",
899
+ run_id=run_id,
900
+ attempt=attempt,
901
+ executor_tool=executor,
902
+ note=f"/harness-plan 引导:{change_name}",
903
+ )
904
+ if not appended.get("ok", True):
905
+ return appended
906
+ else:
907
+ run_id, attempt = identity
908
+
909
+ return {
910
+ "ok": True,
911
+ "code": "PLAN_BOOTSTRAPPED",
912
+ "changeName": change_name,
913
+ "displayTitle": prepared.get("displayTitle"),
914
+ "changeCreated": created_change,
915
+ "changeDir": str(change_dir),
916
+ "executionRoot": str(execution_root),
917
+ "runId": run_id,
918
+ "attempt": attempt,
919
+ "reused": reused,
920
+ "tier": classification.get("tier"),
921
+ "tierSource": classification.get("source"),
922
+ "defaultPhases": list(classification.get("defaultPhases") or []),
923
+ "conditionalPhases": list(classification.get("conditionalPhases") or []),
924
+ "requiredValidations": list(classification.get("requiredValidations") or []),
925
+ "gatePolicyPath": str(policy_path),
926
+ "plannedPhases": prepared.get("plannedPhases"),
927
+ "changeBase": git_state.get("base"),
928
+ "head": git_state.get("head"),
929
+ "changedSegments": sorted(changed_segments),
930
+ "legacyBootstrap": prepared.get("legacyBootstrap"),
931
+ "doctorOk": bool(health.get("ok", True)),
932
+ "doctor": health,
933
+ }
934
+
935
+
782
936
  def _invalidate_for_fixback(
783
937
  state_root: Path,
784
938
  *,
@@ -808,6 +962,90 @@ def _invalidate_for_fixback(
808
962
  return record
809
963
 
810
964
 
965
+ def _committed_publication_journal(contract_root: Path) -> Path | None:
966
+ """已 committed 的 v2 发布 journal —— plan 确实完成的机器证据。"""
967
+ journal_dir = contract_root / "meta" / "publication-journals"
968
+ if not journal_dir.is_dir():
969
+ return None
970
+ for path in sorted(journal_dir.glob("*.json")):
971
+ try:
972
+ payload = _read_json(path)
973
+ except (OSError, ValueError, json.JSONDecodeError):
974
+ continue
975
+ if isinstance(payload, dict) and payload.get("state") == "committed":
976
+ return path
977
+ return None
978
+
979
+
980
+ def _published_plan_artifacts(project: Path, contract_root: Path, change: str) -> list[dict[str, Any]]:
981
+ """交接携带的 v2 发布产物(存在者入账,缺失者跳过)。"""
982
+ entries: list[dict[str, Any]] = []
983
+ for rel in (
984
+ f"plans/{change}-design.md",
985
+ f"plans/{change}-plan.md",
986
+ f"plans/{change}-implementation-detail.md",
987
+ f"plans/{change}-test-scenarios.md",
988
+ "meta/scenario-manifest.json",
989
+ ):
990
+ candidate = contract_root / rel
991
+ if candidate.is_file():
992
+ entries.append(
993
+ {
994
+ "path": candidate.resolve().relative_to(project).as_posix(),
995
+ "sha256": _sha256(candidate),
996
+ }
997
+ )
998
+ return entries
999
+
1000
+
1001
+ def _bootstrap_v2_plan_transition(
1002
+ project: Path,
1003
+ change: str,
1004
+ *,
1005
+ contract_root: Path,
1006
+ state_root: Path,
1007
+ to_phase: str,
1008
+ executor: str,
1009
+ ) -> dict[str, Any] | None:
1010
+ """v2 发布已 committed 却没有交接凭证时,补录 plan → to_phase 凭证。
1011
+
1012
+ v2 plan finalize 只写 plan-events.ndjson 与发布 journal,不碰 context 事务
1013
+ 存储,于是 run 阶段 prepare 必报 HANDOFF_REQUIRED、begin 必报
1014
+ LEGACY_BOOTSTRAP_REQUIRED——两个错误都不给恢复路径,调用方只能读脚本源码
1015
+ 自己拼出 classify + configure-plan + close,参数全靠现编,可审计性更差。
1016
+
1017
+ committed 的发布 journal 是比人工 close 更强的完成证据:据此补录,并在凭证上
1018
+ 留 bootstrapSource/bootstrapEvidence,事后能与人工 close 区分。没有该证据时
1019
+ 返回 None,交接继续 fail-closed。
1020
+ """
1021
+ paths = _paths(state_root)
1022
+ if _read_ndjson(paths["transitions"]):
1023
+ return None
1024
+ if to_phase not in _allowed_next_phases(contract_root, "plan"):
1025
+ return None
1026
+ journal = _committed_publication_journal(contract_root)
1027
+ if journal is None:
1028
+ return None
1029
+ receipt: dict[str, Any] = {
1030
+ "schemaVersion": 1,
1031
+ "changeName": change,
1032
+ "fromPhase": "plan",
1033
+ "toPhase": to_phase,
1034
+ "status": "OK",
1035
+ "executor": executor,
1036
+ "productCommit": _head(project),
1037
+ "artifacts": _published_plan_artifacts(project, contract_root, change),
1038
+ "attempt": 1,
1039
+ "closedAt": _now().isoformat(),
1040
+ "previousReceiptHash": None,
1041
+ "bootstrapSource": "plan_publication_journal",
1042
+ "bootstrapEvidence": journal.resolve().relative_to(project).as_posix(),
1043
+ }
1044
+ receipt["receiptHash"] = _payload_hash(receipt)
1045
+ _append_ndjson(paths["transitions"], receipt)
1046
+ return receipt
1047
+
1048
+
811
1049
  def close_transition(
812
1050
  project: Path,
813
1051
  change: str,
@@ -1007,11 +1245,26 @@ def _begin_transition_unlocked(
1007
1245
  "message": "本次修复准备凭证与当前上下文不一致,未启动新阶段。",
1008
1246
  }
1009
1247
  transitions = _read_ndjson(paths["transitions"])
1248
+ if not transitions and phase != "plan":
1249
+ # prepare 之外的入口(或跳过了 prepare)同样要能从发布证据恢复
1250
+ _bootstrap_v2_plan_transition(
1251
+ project,
1252
+ change,
1253
+ contract_root=contract_root,
1254
+ state_root=state_root,
1255
+ to_phase=phase,
1256
+ executor=executor,
1257
+ )
1258
+ transitions = _read_ndjson(paths["transitions"])
1010
1259
  if not transitions:
1011
1260
  return {
1012
1261
  "ok": False,
1013
1262
  "code": "LEGACY_BOOTSTRAP_REQUIRED",
1014
1263
  "legacyBootstrap": True,
1264
+ "message": (
1265
+ "没有交接凭证。v2 计划应有 committed 的 meta/publication-journals/*.json "
1266
+ "作为补录证据;legacy 计划需先完成 plan 阶段的 close"
1267
+ ),
1015
1268
  }
1016
1269
  receipt = transitions[-1]
1017
1270
  if receipt.get("toPhase") != phase:
@@ -1256,6 +1509,14 @@ def build_parser() -> argparse.ArgumentParser:
1256
1509
  prepare.add_argument("--title")
1257
1510
  prepare.add_argument("--ttl-seconds", type=int, default=3600)
1258
1511
  prepare.add_argument("--trigger", choices=["review-fixback"])
1512
+ bootstrap = sub.add_parser("bootstrap-plan")
1513
+ bootstrap.add_argument("--json", action="store_true")
1514
+ bootstrap.add_argument("--project", required=True, type=Path)
1515
+ bootstrap.add_argument("--change", required=True)
1516
+ bootstrap.add_argument("--executor", required=True)
1517
+ bootstrap.add_argument("--title")
1518
+ bootstrap.add_argument("--stage", default="plan", choices=["plan", "post-run"])
1519
+ bootstrap.add_argument("--ttl-seconds", type=int, default=3600)
1259
1520
  close = sub.add_parser("close")
1260
1521
  close.add_argument("--json", action="store_true")
1261
1522
  close.add_argument("--project", required=True, type=Path)
@@ -1297,6 +1558,15 @@ def main(argv: list[str] | None = None) -> int:
1297
1558
  ttl_seconds=args.ttl_seconds,
1298
1559
  trigger=args.trigger,
1299
1560
  )
1561
+ elif args.command == "bootstrap-plan":
1562
+ result = bootstrap_plan(
1563
+ args.project,
1564
+ change=args.change,
1565
+ executor=args.executor,
1566
+ display_title=args.title,
1567
+ stage=args.stage,
1568
+ ttl_seconds=args.ttl_seconds,
1569
+ )
1300
1570
  elif args.command == "close":
1301
1571
  result = close_transition(
1302
1572
  args.project,
@@ -2024,6 +2024,70 @@ def _deferred_hint(deferred_ids: list[str], phase: str | None) -> str:
2024
2024
  )
2025
2025
 
2026
2026
 
2027
+ _PROJECT_ROOT_HELP = (
2028
+ "project root PATH (defaults to resolving from CWD). Takes a path, not a project name."
2029
+ )
2030
+
2031
+
2032
+ def _resolve_project(args: argparse.Namespace) -> Path:
2033
+ """--project 优先,未给时按 CWD 解析主项目根。
2034
+
2035
+ 其他 harness 脚本都把 --project 当项目根且列为必填,只有 gate 例外——调用方
2036
+ 按惯例传过来会被 argparse 拒掉。注意 begin/close 的 --project 是另一层语义
2037
+ (本阶段执行根/worktree),本函数只服务 classify/checkpoint。
2038
+ """
2039
+ raw = getattr(args, "project", None)
2040
+ if raw:
2041
+ return Path(raw).expanduser().resolve()
2042
+ return hc.resolve_main_project_root()
2043
+
2044
+
2045
+ # 门禁消费的场景字段。v2 plan finalize 派生的 scenario_manifest 一个都没有:
2046
+ # 它只带 scenario_id/coverage_dimension/execution_level/evidence_requirements/
2047
+ # risk_level/task_refs/requirement_refs。
2048
+ _V2_MANIFEST_REQUIRED_FIELDS = (
2049
+ "id",
2050
+ "priority",
2051
+ "requiredEvidenceKind",
2052
+ "ownerPhase",
2053
+ "executableTestId",
2054
+ "testFile",
2055
+ "testTitle",
2056
+ )
2057
+
2058
+
2059
+ def _v2_artifact_manifest_gap(manifest: Any) -> dict[str, Any] | None:
2060
+ """v2 plan artifact 包装的 scenario-manifest → 明确的不可消费错误。
2061
+
2062
+ 不能"解包 + 字段改名"了事:包装体里既没有 priority 也没有
2063
+ requiredEvidenceKind,`required_ids` 会算成空集,C9 随即返回
2064
+ NO_LEDGER_REQUIRED_SCENARIOS——那是把证据门禁对所有 v2 计划静默关掉,
2065
+ 比直接失败危险得多。所以这里点名缺口,让调用方回规划阶段补齐后重新发布。
2066
+
2067
+ v2 场景契约补齐这些字段后,解包逻辑加在这里。
2068
+ """
2069
+ if not isinstance(manifest, dict) or manifest.get("artifact_type") != "scenario_manifest":
2070
+ return None
2071
+ content = manifest.get("content")
2072
+ present: set[str] = set()
2073
+ if isinstance(content, dict) and isinstance(content.get("scenarios"), list):
2074
+ for scenario in content["scenarios"]:
2075
+ if isinstance(scenario, dict):
2076
+ present.update(scenario.keys())
2077
+ return {
2078
+ "ok": False,
2079
+ "code": "SCENARIO_MANIFEST_V2_UNSUPPORTED",
2080
+ "message": (
2081
+ "meta/scenario-manifest.json 是 v2 plan artifact 包装体,缺少门禁消费的场景字段;"
2082
+ "请在规划阶段补齐后重新发布,不要手改派生产物"
2083
+ ),
2084
+ "missingFields": [
2085
+ field for field in _V2_MANIFEST_REQUIRED_FIELDS if field not in present
2086
+ ],
2087
+ "artifactType": "scenario_manifest",
2088
+ }
2089
+
2090
+
2027
2091
  def _validate_scenario_coverage(
2028
2092
  change_dir: Path, phase: str | None = None
2029
2093
  ) -> dict[str, Any]:
@@ -2050,6 +2114,9 @@ def _validate_scenario_coverage(
2050
2114
  "code": "SCENARIO_MANIFEST_INVALID",
2051
2115
  "message": f"scenario-manifest.json unreadable: {exc}",
2052
2116
  }
2117
+ v2_gap = _v2_artifact_manifest_gap(manifest)
2118
+ if v2_gap is not None:
2119
+ return v2_gap
2053
2120
  scenarios = manifest.get("scenarios") if isinstance(manifest, dict) else None
2054
2121
  if not isinstance(scenarios, list):
2055
2122
  return {
@@ -3218,7 +3285,7 @@ def cmd_close(args: argparse.Namespace) -> int:
3218
3285
 
3219
3286
  def cmd_classify(args: argparse.Namespace) -> int:
3220
3287
  as_json = bool(args.json)
3221
- project = hc.resolve_main_project_root()
3288
+ project = _resolve_project(args)
3222
3289
  try:
3223
3290
  workflow = _load_workflow_policy(project=project)
3224
3291
  except (OSError, ValueError, hwp.PolicyValidationError) as exc:
@@ -3271,7 +3338,7 @@ def cmd_classify(args: argparse.Namespace) -> int:
3271
3338
 
3272
3339
  def cmd_checkpoint(args: argparse.Namespace) -> int:
3273
3340
  as_json = bool(args.json)
3274
- project = hc.resolve_main_project_root()
3341
+ project = _resolve_project(args)
3275
3342
  resolved = hc.resolve_change(project, args.change)
3276
3343
  if not resolved.get("ok"):
3277
3344
  return emit_error(
@@ -3425,6 +3492,7 @@ def build_parser() -> argparse.ArgumentParser:
3425
3492
  p_close.set_defaults(func=cmd_close)
3426
3493
 
3427
3494
  p_classify = sub.add_parser("classify", parents=[shared])
3495
+ p_classify.add_argument("--project", default=None, type=Path, help=_PROJECT_ROOT_HELP)
3428
3496
  p_classify.add_argument("--change", default=None)
3429
3497
  p_classify.add_argument("--stage", required=True, choices=["plan", "post-run"])
3430
3498
  p_classify.add_argument(
@@ -3436,6 +3504,7 @@ def build_parser() -> argparse.ArgumentParser:
3436
3504
  p_classify.set_defaults(func=cmd_classify)
3437
3505
 
3438
3506
  p_checkpoint = sub.add_parser("checkpoint", parents=[shared])
3507
+ p_checkpoint.add_argument("--project", default=None, type=Path, help=_PROJECT_ROOT_HELP)
3439
3508
  p_checkpoint.add_argument("checkpoint_action", choices=["status", "approve"])
3440
3509
  p_checkpoint.add_argument("--id", required=True)
3441
3510
  p_checkpoint.add_argument("--change", default=None)
@@ -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 {