@hunter-harness/workflow-harness 0.2.57 → 0.2.59

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 (182) 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 +1 -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 +1 -1
  7. package/harness/bundles/general/claude-code/harness-review/SKILL.md +5 -3
  8. package/harness/bundles/general/claude-code/harness-review/protocols.md +2 -1
  9. package/harness/bundles/general/claude-code/harness-run/SKILL.md +4 -4
  10. package/harness/bundles/general/claude-code/harness-submit/SKILL.md +1 -1
  11. package/harness/bundles/general/claude-code/harness-sync/SKILL.md +1 -1
  12. package/harness/bundles/general/claude-code/harness-test/SKILL.md +5 -3
  13. package/harness/bundles/general/claude-code/harness-test/reference.md +2 -0
  14. package/harness/bundles/general/claude-code/protocols/report-pipeline-protocol.md +2 -0
  15. package/harness/bundles/general/claude-code/scripts/harness_context.py +423 -77
  16. package/harness/bundles/general/claude-code/scripts/harness_events.py +102 -12
  17. package/harness/bundles/general/claude-code/scripts/harness_fixback.py +529 -1
  18. package/harness/bundles/general/claude-code/scripts/harness_gate.py +161 -6
  19. package/harness/bundles/general/claude-code/scripts/harness_ledger.py +81 -8
  20. package/harness/bundles/general/claude-code/scripts/harness_review.py +25 -2
  21. package/harness/bundles/general/claude-code/scripts/harness_test_runner.py +155 -1
  22. package/harness/bundles/general/codebuddy/.harness-build.json +1 -1
  23. package/harness/bundles/general/codebuddy/harness-archive/SKILL.md +1 -1
  24. package/harness/bundles/general/codebuddy/harness-codebase-map/SKILL.md +1 -1
  25. package/harness/bundles/general/codebuddy/harness-knowledge-ingest/SKILL.md +1 -1
  26. package/harness/bundles/general/codebuddy/harness-knowledge-query/SKILL.md +1 -1
  27. package/harness/bundles/general/codebuddy/harness-plan/SKILL.md +1 -1
  28. package/harness/bundles/general/codebuddy/harness-review/SKILL.md +5 -3
  29. package/harness/bundles/general/codebuddy/harness-review/protocols.md +2 -1
  30. package/harness/bundles/general/codebuddy/harness-run/SKILL.md +4 -4
  31. package/harness/bundles/general/codebuddy/harness-submit/SKILL.md +1 -1
  32. package/harness/bundles/general/codebuddy/harness-sync/SKILL.md +1 -1
  33. package/harness/bundles/general/codebuddy/harness-test/SKILL.md +5 -3
  34. package/harness/bundles/general/codebuddy/harness-test/reference.md +2 -0
  35. package/harness/bundles/general/codebuddy/protocols/report-pipeline-protocol.md +2 -0
  36. package/harness/bundles/general/codebuddy/scripts/harness_context.py +423 -77
  37. package/harness/bundles/general/codebuddy/scripts/harness_events.py +102 -12
  38. package/harness/bundles/general/codebuddy/scripts/harness_fixback.py +529 -1
  39. package/harness/bundles/general/codebuddy/scripts/harness_gate.py +161 -6
  40. package/harness/bundles/general/codebuddy/scripts/harness_ledger.py +81 -8
  41. package/harness/bundles/general/codebuddy/scripts/harness_review.py +25 -2
  42. package/harness/bundles/general/codebuddy/scripts/harness_test_runner.py +155 -1
  43. package/harness/bundles/general/codex/.harness-build.json +1 -1
  44. package/harness/bundles/general/codex/harness-archive/SKILL.md +1 -1
  45. package/harness/bundles/general/codex/harness-codebase-map/SKILL.md +1 -1
  46. package/harness/bundles/general/codex/harness-knowledge-ingest/SKILL.md +1 -1
  47. package/harness/bundles/general/codex/harness-knowledge-query/SKILL.md +1 -1
  48. package/harness/bundles/general/codex/harness-plan/SKILL.md +1 -1
  49. package/harness/bundles/general/codex/harness-review/SKILL.md +3 -1
  50. package/harness/bundles/general/codex/harness-review/protocols.md +2 -1
  51. package/harness/bundles/general/codex/harness-run/SKILL.md +4 -4
  52. package/harness/bundles/general/codex/harness-submit/SKILL.md +1 -1
  53. package/harness/bundles/general/codex/harness-sync/SKILL.md +1 -1
  54. package/harness/bundles/general/codex/harness-test/SKILL.md +5 -3
  55. package/harness/bundles/general/codex/harness-test/reference.md +2 -0
  56. package/harness/bundles/general/codex/protocols/report-pipeline-protocol.md +2 -0
  57. package/harness/bundles/general/codex/scripts/harness_context.py +423 -77
  58. package/harness/bundles/general/codex/scripts/harness_events.py +102 -12
  59. package/harness/bundles/general/codex/scripts/harness_fixback.py +529 -1
  60. package/harness/bundles/general/codex/scripts/harness_gate.py +161 -6
  61. package/harness/bundles/general/codex/scripts/harness_ledger.py +81 -8
  62. package/harness/bundles/general/codex/scripts/harness_review.py +25 -2
  63. package/harness/bundles/general/codex/scripts/harness_test_runner.py +155 -1
  64. package/harness/bundles/general/cursor/.harness-build.json +1 -1
  65. package/harness/bundles/general/cursor/harness-archive/SKILL.md +1 -1
  66. package/harness/bundles/general/cursor/harness-codebase-map/SKILL.md +1 -1
  67. package/harness/bundles/general/cursor/harness-knowledge-ingest/SKILL.md +1 -1
  68. package/harness/bundles/general/cursor/harness-knowledge-query/SKILL.md +1 -1
  69. package/harness/bundles/general/cursor/harness-plan/SKILL.md +1 -1
  70. package/harness/bundles/general/cursor/harness-review/SKILL.md +3 -1
  71. package/harness/bundles/general/cursor/harness-review/protocols.md +2 -1
  72. package/harness/bundles/general/cursor/harness-run/SKILL.md +4 -4
  73. package/harness/bundles/general/cursor/harness-submit/SKILL.md +1 -1
  74. package/harness/bundles/general/cursor/harness-sync/SKILL.md +1 -1
  75. package/harness/bundles/general/cursor/harness-test/SKILL.md +5 -3
  76. package/harness/bundles/general/cursor/harness-test/reference.md +2 -0
  77. package/harness/bundles/general/cursor/protocols/report-pipeline-protocol.md +2 -0
  78. package/harness/bundles/general/cursor/scripts/harness_context.py +423 -77
  79. package/harness/bundles/general/cursor/scripts/harness_events.py +102 -12
  80. package/harness/bundles/general/cursor/scripts/harness_fixback.py +529 -1
  81. package/harness/bundles/general/cursor/scripts/harness_gate.py +161 -6
  82. package/harness/bundles/general/cursor/scripts/harness_ledger.py +81 -8
  83. package/harness/bundles/general/cursor/scripts/harness_review.py +25 -2
  84. package/harness/bundles/general/cursor/scripts/harness_test_runner.py +155 -1
  85. package/harness/bundles/java/claude-code/.harness-build.json +1 -1
  86. package/harness/bundles/java/claude-code/harness-apidoc/SKILL.md +1 -1
  87. package/harness/bundles/java/claude-code/harness-archive/SKILL.md +1 -1
  88. package/harness/bundles/java/claude-code/harness-codebase-map/SKILL.md +1 -1
  89. package/harness/bundles/java/claude-code/harness-knowledge-ingest/SKILL.md +1 -1
  90. package/harness/bundles/java/claude-code/harness-knowledge-query/SKILL.md +1 -1
  91. package/harness/bundles/java/claude-code/harness-package/SKILL.md +1 -1
  92. package/harness/bundles/java/claude-code/harness-plan/SKILL.md +1 -1
  93. package/harness/bundles/java/claude-code/harness-review/SKILL.md +5 -3
  94. package/harness/bundles/java/claude-code/harness-review/protocols.md +2 -1
  95. package/harness/bundles/java/claude-code/harness-run/SKILL.md +4 -4
  96. package/harness/bundles/java/claude-code/harness-submit/SKILL.md +1 -1
  97. package/harness/bundles/java/claude-code/harness-sync/SKILL.md +1 -1
  98. package/harness/bundles/java/claude-code/harness-test/SKILL.md +5 -3
  99. package/harness/bundles/java/claude-code/protocols/report-pipeline-protocol.md +2 -0
  100. package/harness/bundles/java/claude-code/scripts/harness_context.py +423 -77
  101. package/harness/bundles/java/claude-code/scripts/harness_events.py +102 -12
  102. package/harness/bundles/java/claude-code/scripts/harness_fixback.py +529 -1
  103. package/harness/bundles/java/claude-code/scripts/harness_gate.py +161 -6
  104. package/harness/bundles/java/claude-code/scripts/harness_ledger.py +81 -8
  105. package/harness/bundles/java/claude-code/scripts/harness_review.py +25 -2
  106. package/harness/bundles/java/claude-code/scripts/harness_test_runner.py +155 -1
  107. package/harness/bundles/java/codebuddy/.harness-build.json +1 -1
  108. package/harness/bundles/java/codebuddy/harness-apidoc/SKILL.md +1 -1
  109. package/harness/bundles/java/codebuddy/harness-archive/SKILL.md +1 -1
  110. package/harness/bundles/java/codebuddy/harness-codebase-map/SKILL.md +1 -1
  111. package/harness/bundles/java/codebuddy/harness-knowledge-ingest/SKILL.md +1 -1
  112. package/harness/bundles/java/codebuddy/harness-knowledge-query/SKILL.md +1 -1
  113. package/harness/bundles/java/codebuddy/harness-package/SKILL.md +1 -1
  114. package/harness/bundles/java/codebuddy/harness-plan/SKILL.md +1 -1
  115. package/harness/bundles/java/codebuddy/harness-review/SKILL.md +5 -3
  116. package/harness/bundles/java/codebuddy/harness-review/protocols.md +2 -1
  117. package/harness/bundles/java/codebuddy/harness-run/SKILL.md +4 -4
  118. package/harness/bundles/java/codebuddy/harness-submit/SKILL.md +1 -1
  119. package/harness/bundles/java/codebuddy/harness-sync/SKILL.md +1 -1
  120. package/harness/bundles/java/codebuddy/harness-test/SKILL.md +5 -3
  121. package/harness/bundles/java/codebuddy/protocols/report-pipeline-protocol.md +2 -0
  122. package/harness/bundles/java/codebuddy/scripts/harness_context.py +423 -77
  123. package/harness/bundles/java/codebuddy/scripts/harness_events.py +102 -12
  124. package/harness/bundles/java/codebuddy/scripts/harness_fixback.py +529 -1
  125. package/harness/bundles/java/codebuddy/scripts/harness_gate.py +161 -6
  126. package/harness/bundles/java/codebuddy/scripts/harness_ledger.py +81 -8
  127. package/harness/bundles/java/codebuddy/scripts/harness_review.py +25 -2
  128. package/harness/bundles/java/codebuddy/scripts/harness_test_runner.py +155 -1
  129. package/harness/bundles/java/codex/.harness-build.json +1 -1
  130. package/harness/bundles/java/codex/harness-apidoc/SKILL.md +1 -1
  131. package/harness/bundles/java/codex/harness-archive/SKILL.md +1 -1
  132. package/harness/bundles/java/codex/harness-codebase-map/SKILL.md +1 -1
  133. package/harness/bundles/java/codex/harness-knowledge-ingest/SKILL.md +1 -1
  134. package/harness/bundles/java/codex/harness-knowledge-query/SKILL.md +1 -1
  135. package/harness/bundles/java/codex/harness-package/SKILL.md +1 -1
  136. package/harness/bundles/java/codex/harness-plan/SKILL.md +1 -1
  137. package/harness/bundles/java/codex/harness-review/SKILL.md +3 -1
  138. package/harness/bundles/java/codex/harness-review/protocols.md +2 -1
  139. package/harness/bundles/java/codex/harness-run/SKILL.md +4 -4
  140. package/harness/bundles/java/codex/harness-submit/SKILL.md +1 -1
  141. package/harness/bundles/java/codex/harness-sync/SKILL.md +1 -1
  142. package/harness/bundles/java/codex/harness-test/SKILL.md +5 -3
  143. package/harness/bundles/java/codex/protocols/report-pipeline-protocol.md +2 -0
  144. package/harness/bundles/java/codex/scripts/harness_context.py +423 -77
  145. package/harness/bundles/java/codex/scripts/harness_events.py +102 -12
  146. package/harness/bundles/java/codex/scripts/harness_fixback.py +529 -1
  147. package/harness/bundles/java/codex/scripts/harness_gate.py +161 -6
  148. package/harness/bundles/java/codex/scripts/harness_ledger.py +81 -8
  149. package/harness/bundles/java/codex/scripts/harness_review.py +25 -2
  150. package/harness/bundles/java/codex/scripts/harness_test_runner.py +155 -1
  151. package/harness/bundles/java/cursor/.harness-build.json +1 -1
  152. package/harness/bundles/java/cursor/harness-apidoc/SKILL.md +1 -1
  153. package/harness/bundles/java/cursor/harness-archive/SKILL.md +1 -1
  154. package/harness/bundles/java/cursor/harness-codebase-map/SKILL.md +1 -1
  155. package/harness/bundles/java/cursor/harness-knowledge-ingest/SKILL.md +1 -1
  156. package/harness/bundles/java/cursor/harness-knowledge-query/SKILL.md +1 -1
  157. package/harness/bundles/java/cursor/harness-package/SKILL.md +1 -1
  158. package/harness/bundles/java/cursor/harness-plan/SKILL.md +1 -1
  159. package/harness/bundles/java/cursor/harness-review/SKILL.md +3 -1
  160. package/harness/bundles/java/cursor/harness-review/protocols.md +2 -1
  161. package/harness/bundles/java/cursor/harness-run/SKILL.md +4 -4
  162. package/harness/bundles/java/cursor/harness-submit/SKILL.md +1 -1
  163. package/harness/bundles/java/cursor/harness-sync/SKILL.md +1 -1
  164. package/harness/bundles/java/cursor/harness-test/SKILL.md +5 -3
  165. package/harness/bundles/java/cursor/protocols/report-pipeline-protocol.md +2 -0
  166. package/harness/bundles/java/cursor/scripts/harness_context.py +423 -77
  167. package/harness/bundles/java/cursor/scripts/harness_events.py +102 -12
  168. package/harness/bundles/java/cursor/scripts/harness_fixback.py +529 -1
  169. package/harness/bundles/java/cursor/scripts/harness_gate.py +161 -6
  170. package/harness/bundles/java/cursor/scripts/harness_ledger.py +81 -8
  171. package/harness/bundles/java/cursor/scripts/harness_review.py +25 -2
  172. package/harness/bundles/java/cursor/scripts/harness_test_runner.py +155 -1
  173. package/harness/manifests/general/claude-code.json +23 -23
  174. package/harness/manifests/general/codebuddy.json +23 -23
  175. package/harness/manifests/general/codex.json +23 -23
  176. package/harness/manifests/general/cursor.json +23 -23
  177. package/harness/manifests/java/claude-code.json +24 -24
  178. package/harness/manifests/java/codebuddy.json +24 -24
  179. package/harness/manifests/java/codex.json +24 -24
  180. package/harness/manifests/java/cursor.json +24 -24
  181. package/hunter-workflow-family.json +5 -5
  182. package/package.json +1 -1
@@ -36,6 +36,7 @@ import harness_events_sync as hes # noqa: E402
36
36
  import harness_ledger as hl # noqa: E402
37
37
  import harness_paths as hp # noqa: E402
38
38
  import harness_plan_finalize as hpf # noqa: E402
39
+ import harness_review as hr # noqa: E402
39
40
  import harness_workflow_policy as hwp # noqa: E402
40
41
  import harness_test_guard as htg # noqa: E402
41
42
 
@@ -1319,11 +1320,62 @@ def classify_risk(
1319
1320
  changed: list[str] = []
1320
1321
  for line in proc.stdout.splitlines():
1321
1322
  raw = line[3:].strip().strip('"').replace("\\", "/")
1322
- if not raw or raw.startswith(".harness/"):
1323
+ if not raw:
1323
1324
  continue
1325
+ if " -> " in raw:
1326
+ raw = raw.rsplit(" -> ", 1)[-1].strip().strip('"')
1324
1327
  changed.append(raw)
1325
- capabilities = sorted(set(capabilities) | set(_diff_capabilities(changed)))
1326
- lowered = "\n".join(changed).lower()
1328
+ try:
1329
+ contract = hp.load_change_contract(change_dir)
1330
+ except (OSError, ValueError, json.JSONDecodeError):
1331
+ contract = {}
1332
+ ownership = contract.get("ownership") if isinstance(contract, dict) else None
1333
+ modern_scope = isinstance(ownership, dict) and bool(
1334
+ ownership.get("productPaths") or ownership.get("staticEvidencePaths")
1335
+ )
1336
+ product_paths: list[str] = []
1337
+ static_paths: list[str] = []
1338
+ excluded_paths: list[str] = []
1339
+ maintenance_paths: list[str] = []
1340
+ foreign_paths: list[str] = []
1341
+
1342
+ def is_harness_maintenance(path: str) -> bool:
1343
+ normalized = path.replace("\\", "/")
1344
+ return normalized.startswith(
1345
+ (
1346
+ ".cursor/skills/",
1347
+ ".claude/skills/",
1348
+ ".agents/skills/",
1349
+ ".codebuddy/skills/",
1350
+ )
1351
+ ) or normalized in {
1352
+ ".harness/context-index.json",
1353
+ ".harness/config/build-profile.json",
1354
+ }
1355
+
1356
+ for path in changed:
1357
+ if modern_scope:
1358
+ category = hl._classify_ownership_path(
1359
+ path, change_dir.name, ownership
1360
+ )
1361
+ if category == "owned":
1362
+ product_paths.append(path)
1363
+ elif category == "staticEvidence":
1364
+ static_paths.append(path)
1365
+ elif category == "excludedRuntime":
1366
+ excluded_paths.append(path)
1367
+ elif is_harness_maintenance(path):
1368
+ maintenance_paths.append(path)
1369
+ else:
1370
+ foreign_paths.append(path)
1371
+ elif path.startswith(".harness/") or is_harness_maintenance(path):
1372
+ maintenance_paths.append(path)
1373
+ else:
1374
+ product_paths.append(path)
1375
+ capabilities = sorted(
1376
+ set(capabilities) | set(_diff_capabilities(product_paths))
1377
+ )
1378
+ lowered = "\n".join(product_paths).lower()
1327
1379
  full_markers = {
1328
1380
  "auth": ("auth", "token", "credential", "permission"),
1329
1381
  "security": ("security", "secret", "crypto"),
@@ -1338,14 +1390,14 @@ def classify_risk(
1338
1390
  signals.append(signal)
1339
1391
  if signals:
1340
1392
  observed = "full"
1341
- elif changed and all(
1393
+ elif product_paths and all(
1342
1394
  path.lower().endswith((".md", ".txt", ".rst"))
1343
1395
  or path.lower().startswith("docs/")
1344
- for path in changed
1396
+ for path in product_paths
1345
1397
  ):
1346
1398
  observed = "fast"
1347
1399
  signals.append("docs-only")
1348
- elif changed:
1400
+ elif product_paths:
1349
1401
  observed = "standard"
1350
1402
  signals.append("production-code")
1351
1403
  else:
@@ -1373,6 +1425,14 @@ def classify_risk(
1373
1425
  "conditionalStages": list(tier_policy["conditionalStages"]),
1374
1426
  "stageDecisions": _stage_decisions_for_tier(workflow, tier, unique_signals),
1375
1427
  }
1428
+ if stage == "post-run":
1429
+ payload["workspaceBreakdown"] = {
1430
+ "productPaths": sorted(set(product_paths)),
1431
+ "staticEvidencePaths": sorted(set(static_paths)),
1432
+ "excludedRuntimePaths": sorted(set(excluded_paths)),
1433
+ "harnessMaintenancePaths": sorted(set(maintenance_paths)),
1434
+ "foreignPaths": sorted(set(foreign_paths)),
1435
+ }
1376
1436
  if stage == "post-run":
1377
1437
  _write_json(change_dir / "meta" / "risk-classification.json", payload)
1378
1438
  result = _apply_required_gate_contract(payload, workflow, capabilities)
@@ -2158,6 +2218,72 @@ def validate_plan_handoff(change_dir: Path) -> dict[str, Any]:
2158
2218
  return hpf.verify_plan(change_dir)
2159
2219
 
2160
2220
 
2221
+ def validate_review_outputs_for_close(
2222
+ change_dir: Path,
2223
+ run_id: str,
2224
+ ) -> dict[str, Any]:
2225
+ """Require complete, run-bound structured Review sidecars before close."""
2226
+
2227
+ findings_path = hr.findings_path(change_dir)
2228
+ dispositions_path = hr.dispositions_path(change_dir)
2229
+ missing = [
2230
+ str(path)
2231
+ for path in (findings_path, dispositions_path)
2232
+ if not path.is_file()
2233
+ ]
2234
+ if missing:
2235
+ return {
2236
+ "ok": False,
2237
+ "code": "REVIEW_OUTPUTS_INCOMPLETE",
2238
+ "message": "评审的结构化发现或处置记录缺失,尚不能结束评审阶段。",
2239
+ "missing": missing,
2240
+ "recoveryAction": "补写 review-findings.json 与 fixback-dispositions.json 后重试关门",
2241
+ }
2242
+ try:
2243
+ findings = json.loads(findings_path.read_text(encoding="utf-8-sig"))
2244
+ dispositions = json.loads(
2245
+ dispositions_path.read_text(encoding="utf-8-sig")
2246
+ )
2247
+ except (OSError, json.JSONDecodeError) as exc:
2248
+ return {
2249
+ "ok": False,
2250
+ "code": "REVIEW_OUTPUTS_INVALID",
2251
+ "message": f"评审结构化记录无法读取:{exc}",
2252
+ }
2253
+ finding_problems = hr.validate_findings(findings, require_ids=True)
2254
+ known_ids = {
2255
+ str(item.get("id"))
2256
+ for item in findings.get("findings", [])
2257
+ if isinstance(item, dict) and item.get("id")
2258
+ }
2259
+ disposition_problems = hr.validate_dispositions(dispositions, known_ids)
2260
+ disposition_ids = {
2261
+ str(item.get("findingId"))
2262
+ for item in dispositions.get("dispositions", [])
2263
+ if isinstance(item, dict) and item.get("findingId")
2264
+ }
2265
+ problems = [*finding_problems, *disposition_problems]
2266
+ if findings.get("runId") != run_id or dispositions.get("runId") != run_id:
2267
+ problems.append("review sidecars must match the active review runId")
2268
+ missing_dispositions = sorted(known_ids - disposition_ids)
2269
+ if missing_dispositions:
2270
+ problems.append(
2271
+ "findings without dispositions: " + ",".join(missing_dispositions)
2272
+ )
2273
+ if problems:
2274
+ return {
2275
+ "ok": False,
2276
+ "code": "REVIEW_OUTPUTS_INVALID",
2277
+ "message": "评审结构化记录不完整或与当前轮次不一致。",
2278
+ "problems": problems,
2279
+ }
2280
+ return {
2281
+ "ok": True,
2282
+ "code": "REVIEW_OUTPUTS_READY",
2283
+ "findingCount": len(known_ids),
2284
+ }
2285
+
2286
+
2161
2287
  def cmd_begin(args: argparse.Namespace) -> int:
2162
2288
  as_json = bool(args.json)
2163
2289
  project = hc.resolve_main_project_root()
@@ -2738,6 +2864,35 @@ def cmd_close(args: argparse.Namespace) -> int:
2738
2864
  extra={k: v for k, v in ledger_result.items() if k not in {"ok", "message", "code"}},
2739
2865
  )
2740
2866
 
2867
+ if args.phase == "review":
2868
+ review_outputs = validate_review_outputs_for_close(change_dir, run_id)
2869
+ if not review_outputs.get("ok"):
2870
+ persist_close_failure(
2871
+ change_dir,
2872
+ args.phase,
2873
+ run_id,
2874
+ capsule,
2875
+ status="REVIEW_OUTPUTS_INVALID",
2876
+ error=review_outputs,
2877
+ )
2878
+ record_gate_blocked(
2879
+ change_dir,
2880
+ phase=args.phase,
2881
+ code=str(review_outputs.get("code") or "REVIEW_OUTPUTS_INVALID"),
2882
+ message=str(review_outputs.get("message") or "评审记录不完整。"),
2883
+ run_id=run_id,
2884
+ )
2885
+ return emit_error(
2886
+ str(review_outputs.get("code") or "REVIEW_OUTPUTS_INVALID"),
2887
+ str(review_outputs.get("message") or "评审记录不完整。"),
2888
+ as_json=as_json,
2889
+ extra={
2890
+ key: value
2891
+ for key, value in review_outputs.items()
2892
+ if key not in {"ok", "code", "message"}
2893
+ },
2894
+ )
2895
+
2741
2896
  # C9: scenario coverage check — all P0 scenarios must have a ledger entry.
2742
2897
  if args.phase in {"run", "test"}:
2743
2898
  coverage = _validate_scenario_coverage(change_dir)
@@ -808,6 +808,50 @@ def expand_profile_input_files(
808
808
  return sorted(seen), None
809
809
 
810
810
 
811
+ def ensure_profile_input_target(
812
+ project: Path,
813
+ profile_input: str,
814
+ ) -> tuple[dict[str, Any] | None, str | None]:
815
+ """Refresh a missing/stale detected profile before recording evidence."""
816
+
817
+ project_root = Path(project).resolve()
818
+ profile = harness_profile.load_profile(project_root)
819
+ inputs = profile.get("verificationInputs") if isinstance(profile, dict) else None
820
+ graph = profile.get("verificationGraph") if isinstance(profile, dict) else None
821
+ targets = graph.get("targets") if isinstance(graph, dict) else None
822
+ if (
823
+ isinstance(inputs, dict)
824
+ and profile_input in inputs
825
+ and isinstance(targets, dict)
826
+ and isinstance(targets.get(profile_input), dict)
827
+ ):
828
+ return profile, None
829
+
830
+ # An unreadable profile needs an explicit repair; silently replacing it can
831
+ # discard user-owned overrides.
832
+ common = harness_paths.common_root(project_root)
833
+ for root in dict.fromkeys((common, project_root)):
834
+ candidate = root / ".harness" / "config" / "build-profile.json"
835
+ if not candidate.is_file():
836
+ continue
837
+ try:
838
+ json.loads(candidate.read_text(encoding="utf-8-sig"))
839
+ except (OSError, json.JSONDecodeError) as exc:
840
+ return None, f"build-profile.json unreadable: {exc}"
841
+
842
+ detected = harness_profile.detect(project_root)
843
+ if not detected.get("ok"):
844
+ return None, str(
845
+ detected.get("message")
846
+ or detected.get("code")
847
+ or "build profile detection failed"
848
+ )
849
+ refreshed = harness_profile.load_profile(project_root)
850
+ if not isinstance(refreshed, dict):
851
+ return None, "build-profile.json missing after detection"
852
+ return refreshed, None
853
+
854
+
811
855
  def _state_dir(change_dir: Path) -> Path:
812
856
  return Path(harness_paths.resolve_state_dir_for_contract(change_dir))
813
857
 
@@ -1728,6 +1772,7 @@ def decide_can_reuse(
1728
1772
  "reuse": False,
1729
1773
  "reason": "insufficient-evidence",
1730
1774
  "code": "LEDGER_MISSING",
1775
+ "executionNeed": "first-run",
1731
1776
  "verification": verification,
1732
1777
  "detail": "ledger missing",
1733
1778
  }
@@ -1787,6 +1832,7 @@ def decide_can_reuse(
1787
1832
  "reuse": False,
1788
1833
  "reason": "insufficient-evidence",
1789
1834
  "code": "VALIDATIONS_MISSING",
1835
+ "executionNeed": "first-run",
1790
1836
  "verification": verification,
1791
1837
  "detail": "validations missing",
1792
1838
  "ledger_path": str(ledger_path) if ledger_path else None,
@@ -1800,6 +1846,7 @@ def decide_can_reuse(
1800
1846
  "reuse": False,
1801
1847
  "reason": "insufficient-evidence",
1802
1848
  "code": "VALIDATION_MISSING",
1849
+ "executionNeed": "first-run",
1803
1850
  "verification": verification,
1804
1851
  "detail": f"validation '{verification}' missing",
1805
1852
  "ledger_path": str(ledger_path) if ledger_path else None,
@@ -1821,6 +1868,7 @@ def decide_can_reuse(
1821
1868
  "reuse": False,
1822
1869
  "reason": "rerun",
1823
1870
  "code": "EVIDENCE_INVALIDATED",
1871
+ "executionNeed": "rerun",
1824
1872
  "verification": verification,
1825
1873
  "detail": "recorded target was invalidated by workflow fixback",
1826
1874
  "invalidation": entry.get("invalidation"),
@@ -1868,6 +1916,7 @@ def decide_can_reuse(
1868
1916
  "reuse": False,
1869
1917
  "reason": "insufficient-evidence",
1870
1918
  "code": "MISSING_V2_FIELDS" if v2_missing else "MISSING_FIELDS",
1919
+ "executionNeed": "evidence-incomplete",
1871
1920
  "verification": verification,
1872
1921
  "detail": "missing or invalid: " + ", ".join(all_missing),
1873
1922
  "ledger_path": str(ledger_path) if ledger_path else None,
@@ -2017,6 +2066,7 @@ def decide_can_reuse(
2017
2066
  "reuse": True,
2018
2067
  "reason": "reuse",
2019
2068
  "code": "REUSED",
2069
+ "executionNeed": "reuse",
2020
2070
  "invalidationCode": None,
2021
2071
  "verification": verification,
2022
2072
  "ledger_path": str(ledger_path) if ledger_path else None,
@@ -2171,7 +2221,6 @@ def cmd_can_reuse(args: argparse.Namespace) -> int:
2171
2221
  )
2172
2222
  except (OSError, ValueError) as exc:
2173
2223
  return emit_error(f"can-reuse failed: {exc}", as_json=as_json)
2174
- explicit_files = list(files)
2175
2224
  target = resolve_verification_target(verification, project_root)
2176
2225
  if target is None:
2177
2226
  return emit_error(
@@ -2180,6 +2229,7 @@ def cmd_can_reuse(args: argparse.Namespace) -> int:
2180
2229
  "verificationGraph.targets",
2181
2230
  as_json=as_json,
2182
2231
  )
2232
+ explicit_files = list(files)
2183
2233
  profile_input = getattr(args, "profile_input", None)
2184
2234
  project_raw = getattr(args, "project", None)
2185
2235
  if profile_input:
@@ -2297,6 +2347,20 @@ def cmd_record(args: argparse.Namespace) -> int:
2297
2347
  except (OSError, ValueError) as exc:
2298
2348
  return emit_error(f"record failed: {exc}", as_json=as_json)
2299
2349
  explicit_files = list(files)
2350
+ profile_input = getattr(args, "profile_input", None)
2351
+ project_raw = getattr(args, "project", None)
2352
+ if profile_input:
2353
+ if not project_raw:
2354
+ return emit_error("--profile-input requires --project", as_json=as_json)
2355
+ _profile, profile_error = ensure_profile_input_target(
2356
+ project_root, profile_input
2357
+ )
2358
+ if profile_error:
2359
+ return emit_error(
2360
+ f"record failed: {profile_error}",
2361
+ as_json=as_json,
2362
+ error_code="BUILD_PROFILE_REFRESH_FAILED",
2363
+ )
2300
2364
  target = resolve_verification_target(verification, project_root)
2301
2365
  if target is None:
2302
2366
  return emit_error(
@@ -2305,11 +2369,7 @@ def cmd_record(args: argparse.Namespace) -> int:
2305
2369
  "verificationGraph.targets",
2306
2370
  as_json=as_json,
2307
2371
  )
2308
- profile_input = getattr(args, "profile_input", None)
2309
- project_raw = getattr(args, "project", None)
2310
2372
  if profile_input:
2311
- if not project_raw:
2312
- return emit_error("--profile-input requires --project", as_json=as_json)
2313
2373
  resolved_files, err = expand_profile_input_files(project_root, profile_input)
2314
2374
  if err:
2315
2375
  return emit_error(f"record failed: {err}", as_json=as_json)
@@ -2455,8 +2515,21 @@ def cmd_record(args: argparse.Namespace) -> int:
2455
2515
  )
2456
2516
  except ValueError as exc:
2457
2517
  return emit_error(str(exc), as_json=as_json)
2458
- if args.scope is not None and str(args.scope).strip():
2459
- entry["scope"] = str(args.scope).strip()
2518
+ effective_scope = (
2519
+ str(args.scope).strip()
2520
+ if args.scope is not None and str(args.scope).strip()
2521
+ else (
2522
+ str(
2523
+ target.get("coverageLevel")
2524
+ or target.get("requiredCoverage")
2525
+ or ""
2526
+ ).strip()
2527
+ if profile_input
2528
+ else ""
2529
+ )
2530
+ )
2531
+ if effective_scope:
2532
+ entry["scope"] = effective_scope
2460
2533
  # No default scope: recording an incremental run as broad "module" scope
2461
2534
  # would let can-reuse wrongly approve untested classes (D13 guardrail).
2462
2535
  # Missing scope → can-reuse treats unitTest as insufficient-evidence.
@@ -2470,7 +2543,7 @@ def cmd_record(args: argparse.Namespace) -> int:
2470
2543
  )
2471
2544
  coverage = getattr(args, "coverage", None)
2472
2545
  if not (_nonempty_str(coverage) and str(coverage).strip() in COVERAGE_RANK):
2473
- coverage = derive_coverage(verification, args.scope)
2546
+ coverage = derive_coverage(verification, effective_scope or None)
2474
2547
  entry["coverage"] = coverage
2475
2548
  for field, attr in (
2476
2549
  ("toolchainHash", "toolchain_hash"),
@@ -37,6 +37,7 @@ if hasattr(sys.stderr, "reconfigure"):
37
37
  sys.stderr.reconfigure(encoding="utf-8")
38
38
 
39
39
  SEVERITIES = {"RED", "YELLOW", "OK"}
40
+ FIXBACK_ACTIONS = {"code", "manual", "workflow"}
40
41
  DISPOSITIONS = {
41
42
  "OPEN",
42
43
  "FIXED",
@@ -48,7 +49,14 @@ DISPOSITIONS = {
48
49
  CURRENT_RISK_DISPOSITIONS = {"OPEN", "ACCEPTED_RISK", "DEFERRED", "UNKNOWN"}
49
50
  FINDINGS_REL = Path("reports") / "review" / "review-findings.json"
50
51
  DISPOSITIONS_REL = Path("reports") / "review" / "fixback-dispositions.json"
51
- _REQUIRED_FINDING_FIELDS = ("dimension", "severity", "path", "line", "title")
52
+ _REQUIRED_FINDING_FIELDS = (
53
+ "dimension",
54
+ "severity",
55
+ "path",
56
+ "line",
57
+ "title",
58
+ "fixbackAction",
59
+ )
52
60
 
53
61
 
54
62
  def _write_json_atomic(path: Path, data: Any) -> None:
@@ -95,7 +103,7 @@ def stable_finding_id(
95
103
  return "f-" + hashlib.sha256(basis.encode("utf-8")).hexdigest()[:16]
96
104
 
97
105
 
98
- def validate_findings(doc: Any) -> list[str]:
106
+ def validate_findings(doc: Any, *, require_ids: bool = False) -> list[str]:
99
107
  problems: list[str] = []
100
108
  if not isinstance(doc, dict):
101
109
  return ["findings document must be an object"]
@@ -105,10 +113,19 @@ def validate_findings(doc: Any) -> list[str]:
105
113
  if not isinstance(findings, list):
106
114
  problems.append("findings must be a list")
107
115
  return problems
116
+ seen_ids: set[str] = set()
108
117
  for index, finding in enumerate(findings):
109
118
  if not isinstance(finding, dict):
110
119
  problems.append(f"findings[{index}] must be an object")
111
120
  continue
121
+ if require_ids:
122
+ finding_id = finding.get("id")
123
+ if not isinstance(finding_id, str) or not finding_id.strip():
124
+ problems.append(f"findings[{index}].id is required")
125
+ elif finding_id in seen_ids:
126
+ problems.append(f"findings[{index}].id must be unique")
127
+ else:
128
+ seen_ids.add(finding_id)
112
129
  for field in _REQUIRED_FINDING_FIELDS:
113
130
  if field not in finding:
114
131
  problems.append(f"findings[{index}].{field} is required")
@@ -120,6 +137,12 @@ def validate_findings(doc: Any) -> list[str]:
120
137
  line = finding.get("line")
121
138
  if line is not None and (not isinstance(line, int) or line < 0):
122
139
  problems.append(f"findings[{index}].line must be a non-negative int")
140
+ action = finding.get("fixbackAction")
141
+ if action is not None and action not in FIXBACK_ACTIONS:
142
+ problems.append(
143
+ f"findings[{index}].fixbackAction must be one of "
144
+ f"{sorted(FIXBACK_ACTIONS)}"
145
+ )
123
146
  return problems
124
147
 
125
148
 
@@ -14,6 +14,8 @@ import ctypes
14
14
  import hashlib
15
15
  import json
16
16
  import os
17
+ import re
18
+ import shutil
17
19
  import signal
18
20
  import subprocess
19
21
  import sys
@@ -68,6 +70,104 @@ class TestRunAlreadyActive(RuntimeError):
68
70
  """Raised when another resource-safe runner owns the project lock."""
69
71
 
70
72
 
73
+ class ManagedCommandNotFound(FileNotFoundError):
74
+ """Raised when a structured command cannot be resolved without a shell."""
75
+
76
+
77
+ class ManagedCommandUnsafe(ValueError):
78
+ """Raised when a Windows batch argument could be interpreted by cmd.exe."""
79
+
80
+
81
+ _WINDOWS_BATCH_SUFFIXES = {".bat", ".cmd"}
82
+ _WINDOWS_CMD_META = re.compile(r'[\r\n&|<>^%!\"]')
83
+
84
+
85
+ def validate_managed_argv(
86
+ argv: Sequence[str], *, platform_name: str | None = None
87
+ ) -> None:
88
+ """Fail closed when Windows would reinterpret a batch argv boundary."""
89
+
90
+ platform = platform_name or os.name
91
+ if (
92
+ platform != "nt"
93
+ or not argv
94
+ or Path(argv[0]).suffix.casefold() not in _WINDOWS_BATCH_SUFFIXES
95
+ ):
96
+ return
97
+ for index, argument in enumerate(argv):
98
+ if _WINDOWS_CMD_META.search(str(argument)):
99
+ raise ManagedCommandUnsafe(
100
+ "MANAGED_BATCH_ARGUMENT_UNSAFE: "
101
+ f"Windows 批处理命令的第 {index} 个参数包含命令解释符;"
102
+ "请改用原生可执行文件或不含命令解释符的参数"
103
+ )
104
+
105
+
106
+ def resolve_managed_argv(
107
+ argv: Sequence[str],
108
+ *,
109
+ environ: Mapping[str, str],
110
+ platform_name: str | None = None,
111
+ ) -> list[str]:
112
+ """Resolve argv[0] through PATH/PATHEXT while preserving argv boundaries."""
113
+
114
+ if not argv or not isinstance(argv[0], str) or not argv[0].strip():
115
+ raise ManagedCommandNotFound("MANAGED_COMMAND_NOT_FOUND: 命令不能为空")
116
+ command = argv[0].strip()
117
+ platform = platform_name or os.name
118
+ windows_extensions = [
119
+ item.strip() if item.strip().startswith(".") else f".{item.strip()}"
120
+ for item in str(
121
+ environ.get("PATHEXT") or ".COM;.EXE;.BAT;.CMD"
122
+ ).split(";")
123
+ if item.strip()
124
+ ] if platform == "nt" else []
125
+ windows_extension_set = {item.casefold() for item in windows_extensions}
126
+ has_path = Path(command).is_absolute() or "/" in command or "\\" in command
127
+ if has_path:
128
+ requested = Path(command).expanduser()
129
+ suffix = requested.suffix.casefold()
130
+ candidates = (
131
+ [Path(str(requested) + extension) for extension in windows_extensions]
132
+ + [requested]
133
+ if platform == "nt" and suffix not in windows_extension_set
134
+ else [requested]
135
+ )
136
+ for candidate in candidates:
137
+ if candidate.is_file():
138
+ return [str(candidate.resolve()), *list(argv[1:])]
139
+ raise ManagedCommandNotFound(
140
+ f"MANAGED_COMMAND_NOT_FOUND: 找不到可执行命令 {command}"
141
+ )
142
+
143
+ path_value = str(environ.get("PATH") or "")
144
+ resolved: str | None = None
145
+ if platform == "nt":
146
+ suffix = Path(command).suffix.casefold()
147
+ names = (
148
+ [command]
149
+ if suffix and suffix in windows_extension_set
150
+ else [*(command + item for item in windows_extensions), command]
151
+ )
152
+ for raw_directory in path_value.split(";"):
153
+ directory = raw_directory.strip().strip('"') or "."
154
+ for name in names:
155
+ candidate = Path(directory) / name
156
+ if candidate.is_file():
157
+ resolved = str(candidate.resolve())
158
+ break
159
+ if resolved is not None:
160
+ break
161
+ else:
162
+ resolved = shutil.which(command, path=path_value or None)
163
+
164
+ if resolved is None:
165
+ raise ManagedCommandNotFound(
166
+ f"MANAGED_COMMAND_NOT_FOUND: 找不到可执行命令 {command}"
167
+ )
168
+ return [resolved, *list(argv[1:])]
169
+
170
+
71
171
  def _truthy(value: object) -> bool:
72
172
  return str(value or "").strip().lower() in TRUTHY_VALUES
73
173
 
@@ -819,6 +919,8 @@ def run_managed_command(
819
919
  if environ:
820
920
  child_env.update({str(key): str(value) for key, value in environ.items()})
821
921
  child_env.setdefault("PYTHONUNBUFFERED", "1")
922
+ resolved_argv = resolve_managed_argv(argv, environ=child_env)
923
+ validate_managed_argv(resolved_argv)
822
924
  lower_process_priority()
823
925
 
824
926
  output_stream = tempfile.TemporaryFile(mode="w+b") if capture_output else None
@@ -828,7 +930,7 @@ def run_managed_command(
828
930
  cleanup_complete = False
829
931
  try:
830
932
  spawned = hprocess.spawn_structured_argv(
831
- list(argv),
933
+ resolved_argv,
832
934
  cwd=Path(cwd).resolve(),
833
935
  environment=child_env,
834
936
  owner_token=f"test-runner:{uuid.uuid4().hex}",
@@ -1133,6 +1235,32 @@ def _run_unittest(args: argparse.Namespace) -> int:
1133
1235
  except TestRunAlreadyActive as exc:
1134
1236
  print(str(exc), file=sys.stderr)
1135
1237
  return 3
1238
+ except ManagedCommandNotFound as exc:
1239
+ print(
1240
+ json.dumps(
1241
+ {
1242
+ "ok": False,
1243
+ "code": "MANAGED_COMMAND_NOT_FOUND",
1244
+ "message": str(exc).split(":", 1)[-1].strip(),
1245
+ },
1246
+ ensure_ascii=False,
1247
+ ),
1248
+ file=sys.stderr,
1249
+ )
1250
+ return 127
1251
+ except OSError as exc:
1252
+ print(
1253
+ json.dumps(
1254
+ {
1255
+ "ok": False,
1256
+ "code": "MANAGED_COMMAND_START_FAILED",
1257
+ "message": f"命令无法启动:{exc}",
1258
+ },
1259
+ ensure_ascii=False,
1260
+ ),
1261
+ file=sys.stderr,
1262
+ )
1263
+ return 126
1136
1264
 
1137
1265
  duration = time.monotonic() - started
1138
1266
  status = "PASS" if not failures else "FAIL"
@@ -1264,6 +1392,32 @@ def _run_exec(args: argparse.Namespace) -> int:
1264
1392
  except TestRunAlreadyActive as exc:
1265
1393
  print(str(exc), file=sys.stderr)
1266
1394
  return 3
1395
+ except ManagedCommandNotFound as exc:
1396
+ print(
1397
+ json.dumps(
1398
+ {
1399
+ "ok": False,
1400
+ "code": "MANAGED_COMMAND_NOT_FOUND",
1401
+ "message": str(exc).split(":", 1)[-1].strip(),
1402
+ },
1403
+ ensure_ascii=False,
1404
+ ),
1405
+ file=sys.stderr,
1406
+ )
1407
+ return 127
1408
+ except OSError as exc:
1409
+ print(
1410
+ json.dumps(
1411
+ {
1412
+ "ok": False,
1413
+ "code": "MANAGED_COMMAND_START_FAILED",
1414
+ "message": f"命令无法启动:{exc}",
1415
+ },
1416
+ ensure_ascii=False,
1417
+ ),
1418
+ file=sys.stderr,
1419
+ )
1420
+ return 126
1267
1421
  if not result.process_tree_isolated:
1268
1422
  print("PROCESS_TREE_ISOLATION_UNAVAILABLE", file=sys.stderr)
1269
1423
  return 4