@hunter-harness/workflow-harness 0.2.31 → 0.2.32

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 (26) hide show
  1. package/harness/bundles/general/claude-code/scripts/harness_gate.py +8 -19
  2. package/harness/bundles/general/claude-code/scripts/harness_plan_finalize.py +138 -49
  3. package/harness/bundles/general/codebuddy/scripts/harness_gate.py +8 -19
  4. package/harness/bundles/general/codebuddy/scripts/harness_plan_finalize.py +138 -49
  5. package/harness/bundles/general/codex/scripts/harness_gate.py +8 -19
  6. package/harness/bundles/general/codex/scripts/harness_plan_finalize.py +138 -49
  7. package/harness/bundles/general/cursor/scripts/harness_gate.py +8 -19
  8. package/harness/bundles/general/cursor/scripts/harness_plan_finalize.py +138 -49
  9. package/harness/bundles/java/claude-code/scripts/harness_gate.py +8 -19
  10. package/harness/bundles/java/claude-code/scripts/harness_plan_finalize.py +138 -49
  11. package/harness/bundles/java/codebuddy/scripts/harness_gate.py +8 -19
  12. package/harness/bundles/java/codebuddy/scripts/harness_plan_finalize.py +138 -49
  13. package/harness/bundles/java/codex/scripts/harness_gate.py +8 -19
  14. package/harness/bundles/java/codex/scripts/harness_plan_finalize.py +138 -49
  15. package/harness/bundles/java/cursor/scripts/harness_gate.py +8 -19
  16. package/harness/bundles/java/cursor/scripts/harness_plan_finalize.py +138 -49
  17. package/harness/manifests/general/claude-code.json +4 -4
  18. package/harness/manifests/general/codebuddy.json +4 -4
  19. package/harness/manifests/general/codex.json +4 -4
  20. package/harness/manifests/general/cursor.json +4 -4
  21. package/harness/manifests/java/claude-code.json +4 -4
  22. package/harness/manifests/java/codebuddy.json +4 -4
  23. package/harness/manifests/java/codex.json +4 -4
  24. package/harness/manifests/java/cursor.json +4 -4
  25. package/hunter-workflow-family.json +4 -4
  26. package/package.json +1 -1
@@ -1635,31 +1635,27 @@ def _validate_scenario_coverage(change_dir: Path) -> dict[str, Any]:
1635
1635
  for s in scenarios
1636
1636
  if isinstance(s, dict)
1637
1637
  and (
1638
- s.get("requiredEvidenceKind") == "ledger"
1639
- or (
1640
- not s.get("requiredEvidenceKind")
1641
- and str(s.get("priority", "")).upper() in {"P0", "P1"}
1642
- )
1638
+ str(s.get("priority", "")).upper() in {"P0", "P1"}
1639
+ or s.get("requiredEvidenceKind") == "ledger"
1643
1640
  )
1644
1641
  }
1645
1642
  if not required_ids:
1646
1643
  return {"ok": True, "code": "NO_LEDGER_REQUIRED_SCENARIOS"}
1647
1644
 
1648
- ledger_path = change_dir / "evidence" / "verification-ledger.json"
1649
- if not ledger_path.is_file():
1645
+ try:
1646
+ ledger, ledger_path = hl.load_ledger(change_dir)
1647
+ except (OSError, ValueError, json.JSONDecodeError) as exc:
1650
1648
  return {
1651
1649
  "ok": False,
1652
1650
  "code": "SCENARIO_COVERAGE_FAILED",
1653
- "message": "ledger missing; cannot verify required scenario coverage",
1651
+ "message": f"ledger unreadable: {exc}",
1654
1652
  "missing": sorted(required_ids),
1655
1653
  }
1656
- try:
1657
- ledger = json.loads(ledger_path.read_text(encoding="utf-8-sig"))
1658
- except (OSError, json.JSONDecodeError) as exc:
1654
+ if ledger is None or ledger_path is None:
1659
1655
  return {
1660
1656
  "ok": False,
1661
1657
  "code": "SCENARIO_COVERAGE_FAILED",
1662
- "message": f"ledger unreadable: {exc}",
1658
+ "message": "ledger missing; cannot verify required scenario coverage",
1663
1659
  "missing": sorted(required_ids),
1664
1660
  }
1665
1661
  covered: set[str] = set()
@@ -1728,13 +1724,6 @@ def cmd_begin(args: argparse.Namespace) -> int:
1728
1724
  or plan_verification.get("message")
1729
1725
  or "finalized plan verification failed"
1730
1726
  )
1731
- record_blocked_attempt(
1732
- change_dir,
1733
- phase=args.phase,
1734
- code=code,
1735
- message=message,
1736
- run_id=args.run_id or os.environ.get("HUNTER_HARNESS_RUN_ID"),
1737
- )
1738
1727
  return emit_error(
1739
1728
  code,
1740
1729
  message,
@@ -13,7 +13,7 @@ import re
13
13
  import shutil
14
14
  import sys
15
15
  import tempfile
16
- from pathlib import Path
16
+ from pathlib import Path, PurePosixPath
17
17
  from typing import Any
18
18
 
19
19
  SCRIPTS_DIR = Path(__file__).resolve().parent
@@ -51,6 +51,12 @@ def _result_error(code: str, message: str) -> dict[str, Any]:
51
51
  return {"ok": False, "code": code, "error": message}
52
52
 
53
53
 
54
+ class PlanParseError(ValueError):
55
+ def __init__(self, code: str, message: str) -> None:
56
+ super().__init__(message)
57
+ self.code = code
58
+
59
+
54
60
  def _normalize_header(value: str) -> str:
55
61
  return re.sub(r"[\s_-]+", "", value).lower()
56
62
 
@@ -127,13 +133,23 @@ def parse_test_scenarios(scenarios_path: Path) -> list[dict[str, str]]:
127
133
  break
128
134
  cells = _table_cells(row)
129
135
  if max(id_index, scenario_index) >= len(cells):
130
- row_index += 1
131
- continue
136
+ raise PlanParseError(
137
+ "PLAN_SCENARIO_ROW_INVALID",
138
+ f"{scenarios_path.name}: line {row_index + 1} is missing scenario ID or description",
139
+ )
132
140
  complete_row = len(cells) == len(headers)
133
141
  scenario_id = cells[id_index].strip()
134
142
  if not scenario_id or set(scenario_id) <= {"-", ":"}:
135
- row_index += 1
136
- continue
143
+ raise PlanParseError(
144
+ "PLAN_SCENARIO_ROW_INVALID",
145
+ f"{scenarios_path.name}: line {row_index + 1} has an empty scenario ID",
146
+ )
147
+ scenario_text = cells[scenario_index].strip()
148
+ if not scenario_text or set(scenario_text) <= {"-", ":"}:
149
+ raise PlanParseError(
150
+ "PLAN_SCENARIO_ROW_INVALID",
151
+ f"{scenarios_path.name}: line {row_index + 1} has an empty scenario description",
152
+ )
137
153
  priority = (
138
154
  cells[priority_index].strip().upper()
139
155
  if priority_index is not None
@@ -144,7 +160,7 @@ def parse_test_scenarios(scenarios_path: Path) -> list[dict[str, str]]:
144
160
  scenario = {
145
161
  "id": scenario_id,
146
162
  "priority": priority,
147
- "scenario": cells[scenario_index].strip(),
163
+ "scenario": scenario_text,
148
164
  "ownerPhase": (
149
165
  cells[owner_phase_index].strip()
150
166
  if owner_phase_index is not None
@@ -219,19 +235,34 @@ def parse_plan_tasks(plan_path: Path) -> list[dict[str, str]]:
219
235
  if not row.startswith("|"):
220
236
  break
221
237
  cells = _table_cells(row)
222
- if len(cells) < len(headers):
223
- row_index += 1
224
- continue
238
+ if max(number_index, task_index) >= len(cells):
239
+ raise PlanParseError(
240
+ "PLAN_TASK_ROW_INVALID",
241
+ f"{plan_path.name}: line {row_index + 1} is missing task ID or description",
242
+ )
243
+ complete_row = len(cells) == len(headers)
225
244
  number = cells[number_index].strip()
226
245
  task_text = cells[task_index].strip()
227
- if not number or not task_text or set(number) <= {"-", ":"}:
228
- row_index += 1
229
- continue
246
+ if (
247
+ not number
248
+ or not task_text
249
+ or set(number) <= {"-", ":"}
250
+ or set(task_text) <= {"-", ":"}
251
+ ):
252
+ raise PlanParseError(
253
+ "PLAN_TASK_ROW_INVALID",
254
+ f"{plan_path.name}: line {row_index + 1} has an empty task ID or description",
255
+ )
230
256
  task: dict[str, str] = {"num": number, "task": task_text}
231
257
  if cluster_index is not None and cells[cluster_index].strip():
232
258
  task["cluster"] = cells[cluster_index].strip()
233
259
  for name, index in optional_indices.items():
234
- if index is not None and cells[index].strip():
260
+ if (
261
+ complete_row
262
+ and index is not None
263
+ and index < len(cells)
264
+ and cells[index].strip()
265
+ ):
235
266
  task[name] = cells[index].strip()
236
267
  tasks.append(task)
237
268
  row_index += 1
@@ -416,7 +447,10 @@ def validate_staging(staging: Path, change_name: str) -> dict[str, Any]:
416
447
 
417
448
  # C8: validate ownerPhase values in plan.md task table.
418
449
  plan_path = staging / "plans" / f"{change_name}-plan.md"
419
- tasks = parse_plan_tasks(plan_path)
450
+ try:
451
+ tasks = parse_plan_tasks(plan_path)
452
+ except PlanParseError as exc:
453
+ return _result_error(exc.code, str(exc))
420
454
  if not tasks:
421
455
  return _result_error(
422
456
  "PLAN_TASKS_EMPTY",
@@ -444,7 +478,10 @@ def validate_staging(staging: Path, change_name: str) -> dict[str, Any]:
444
478
 
445
479
  # C9: parse test-scenarios.md for scenario manifest.
446
480
  scenarios_path = staging / "plans" / f"{change_name}-test-scenarios.md"
447
- scenarios = parse_test_scenarios(scenarios_path)
481
+ try:
482
+ scenarios = parse_test_scenarios(scenarios_path)
483
+ except PlanParseError as exc:
484
+ return _result_error(exc.code, str(exc))
448
485
  if not scenarios:
449
486
  return _result_error(
450
487
  "PLAN_SCENARIOS_EMPTY",
@@ -542,6 +579,58 @@ def _validate_plan_start(
542
579
  return {"ok": True, "phaseStartCount": 1}
543
580
 
544
581
 
582
+ def _receipt_artifact_targets(
583
+ change_dir: Path,
584
+ files_list: list[Any],
585
+ ) -> tuple[list[tuple[str, Path]] | None, dict[str, Any] | None]:
586
+ targets: list[tuple[str, Path]] = []
587
+ seen: set[str] = set()
588
+ for index, value in enumerate(files_list):
589
+ if not isinstance(value, str):
590
+ return None, _result_error(
591
+ "RECEIPT_FILE_PATH_INVALID",
592
+ f"receipt files[{index}] must be a string",
593
+ )
594
+ raw = value
595
+ segments = raw.split("/")
596
+ rel = PurePosixPath(raw)
597
+ invalid = (
598
+ not raw
599
+ or raw != raw.strip()
600
+ or "\\" in raw
601
+ or ":" in raw
602
+ or rel.is_absolute()
603
+ or any(segment in {"", ".", ".."} for segment in segments)
604
+ or any(segment.endswith((".", " ")) for segment in segments)
605
+ or not rel.parts
606
+ or rel.parts[0] not in {"spec", "plans", "meta"}
607
+ )
608
+ if invalid:
609
+ return None, _result_error(
610
+ "RECEIPT_FILE_PATH_INVALID",
611
+ f"receipt files[{index}] is not a safe artifact-relative path: {raw!r}",
612
+ )
613
+ normalized = rel.as_posix()
614
+ if normalized in seen:
615
+ return None, _result_error(
616
+ "RECEIPT_FILE_PATH_INVALID",
617
+ f"receipt contains duplicate artifact path: {normalized}",
618
+ )
619
+ seen.add(normalized)
620
+ target = change_dir.joinpath(*rel.parts)
621
+ cursor = change_dir
622
+ for part in rel.parts:
623
+ cursor = cursor / part
624
+ is_junction = getattr(cursor, "is_junction", lambda: False)
625
+ if cursor.is_symlink() or is_junction():
626
+ return None, _result_error(
627
+ "RECEIPT_FILE_PATH_INVALID",
628
+ f"receipt artifact path traverses a link: {normalized}",
629
+ )
630
+ targets.append((normalized, target))
631
+ return targets, None
632
+
633
+
545
634
  def verify_plan(change_dir: Path) -> dict[str, Any]:
546
635
  """Read-only verification of a finalized plan (retro §5.8).
547
636
 
@@ -565,6 +654,11 @@ def verify_plan(change_dir: Path) -> dict[str, Any]:
565
654
  change_name = str(receipt.get("changeName") or "").strip()
566
655
  if not change_name:
567
656
  return _result_error("RECEIPT_INVALID", "receipt missing changeName")
657
+ if change_name != change_dir.name:
658
+ return _result_error(
659
+ "RECEIPT_CHANGE_NAME_INVALID",
660
+ f"receipt changeName {change_name!r} does not match {change_dir.name!r}",
661
+ )
568
662
 
569
663
  expected_hash = str(receipt.get("artifactsHash") or "").strip()
570
664
  if not expected_hash.startswith("sha256:"):
@@ -573,6 +667,10 @@ def verify_plan(change_dir: Path) -> dict[str, Any]:
573
667
  files_list = receipt.get("files")
574
668
  if not isinstance(files_list, list) or not files_list:
575
669
  return _result_error("RECEIPT_INVALID", "receipt files list missing or empty")
670
+ artifact_targets, path_error = _receipt_artifact_targets(change_dir, files_list)
671
+ if path_error is not None:
672
+ return path_error
673
+ assert artifact_targets is not None
576
674
  if receipt.get("status") != "finalized":
577
675
  return _result_error(
578
676
  "RECEIPT_NOT_FINALIZED",
@@ -589,9 +687,7 @@ def verify_plan(change_dir: Path) -> dict[str, Any]:
589
687
  # Recompute artifacts hash from published files.
590
688
  digest = hashlib.sha256()
591
689
  artifact_names: list[str] = []
592
- for rel_text in files_list:
593
- rel = rel_text.as_posix() if hasattr(rel_text, "as_posix") else str(rel_text)
594
- target = change_dir / rel
690
+ for rel, target in artifact_targets:
595
691
  if not target.is_file():
596
692
  return _result_error(
597
693
  "ARTIFACT_MISSING", f"published artifact missing: {rel}"
@@ -634,7 +730,10 @@ def verify_plan(change_dir: Path) -> dict[str, Any]:
634
730
  }
635
731
 
636
732
  plan_path = change_dir / "plans" / f"{change_name}-plan.md"
637
- expected_tasks = parse_plan_tasks(plan_path)
733
+ try:
734
+ expected_tasks = parse_plan_tasks(plan_path)
735
+ except PlanParseError as exc:
736
+ return _result_error(exc.code, str(exc))
638
737
  if not expected_tasks:
639
738
  return _result_error(
640
739
  "PLAN_TASKS_EMPTY",
@@ -652,24 +751,25 @@ def verify_plan(change_dir: Path) -> dict[str, Any]:
652
751
  )
653
752
  except (OSError, json.JSONDecodeError) as exc:
654
753
  return _result_error("IMPLEMENTATION_CHECKPOINTS_INVALID", str(exc))
655
- actual_tasks = checkpoints.get("tasks") if isinstance(checkpoints, dict) else None
656
- expected_task_ids = [str(item.get("num") or "") for item in expected_tasks]
657
- actual_task_ids = (
658
- [str(item.get("num") or "") for item in actual_tasks if isinstance(item, dict)]
659
- if isinstance(actual_tasks, list)
660
- else []
661
- )
662
- if expected_task_ids != actual_task_ids:
754
+ expected_checkpoints = {
755
+ "schemaVersion": 1,
756
+ "changeName": change_name,
757
+ "tasks": expected_tasks,
758
+ "foundationGate": "approved",
759
+ }
760
+ if checkpoints != expected_checkpoints:
663
761
  return _result_error(
664
762
  "IMPLEMENTATION_CHECKPOINTS_DRIFT",
665
- "derived task IDs do not match the finalized plan: "
666
- f"expected={expected_task_ids}, actual={actual_task_ids}",
763
+ "derived implementation checkpoints do not match the finalized plan",
667
764
  )
668
765
 
669
766
  scenarios_path = (
670
767
  change_dir / "plans" / f"{change_name}-test-scenarios.md"
671
768
  )
672
- expected_scenarios = parse_test_scenarios(scenarios_path)
769
+ try:
770
+ expected_scenarios = parse_test_scenarios(scenarios_path)
771
+ except PlanParseError as exc:
772
+ return _result_error(exc.code, str(exc))
673
773
  if not expected_scenarios:
674
774
  return _result_error(
675
775
  "PLAN_SCENARIOS_EMPTY",
@@ -694,26 +794,15 @@ def verify_plan(change_dir: Path) -> dict[str, Any]:
694
794
  "scenario-manifest.json must contain at least one scenario",
695
795
  )
696
796
 
697
- def scenario_identity(item: dict[str, Any]) -> tuple[str, str, str, str]:
698
- return (
699
- str(item.get("id") or ""),
700
- str(item.get("priority") or ""),
701
- str(item.get("ownerPhase") or ""),
702
- str(item.get("requiredEvidenceKind") or ""),
703
- )
704
-
705
- expected_scenario_identities = [
706
- scenario_identity(item) for item in expected_scenarios
707
- ]
708
- actual_scenario_identities = [
709
- scenario_identity(item)
710
- for item in actual_scenarios
711
- if isinstance(item, dict)
712
- ]
713
- if expected_scenario_identities != actual_scenario_identities:
797
+ expected_manifest = {
798
+ "schemaVersion": 1,
799
+ "changeName": change_name,
800
+ "scenarios": expected_scenarios,
801
+ }
802
+ if manifest != expected_manifest:
714
803
  return _result_error(
715
804
  "SCENARIO_MANIFEST_DRIFT",
716
- "derived scenario identities do not match the finalized scenario table",
805
+ "derived scenario manifest does not match the finalized scenario table",
717
806
  )
718
807
 
719
808
  # Validate gate-policy.json is parseable JSON.
@@ -730,7 +819,7 @@ def verify_plan(change_dir: Path) -> dict[str, Any]:
730
819
  gate_policy_consistent = True
731
820
 
732
821
  # Validate events.ndjson: require one matching start/end lifecycle.
733
- events_path = change_dir / "events.ndjson"
822
+ events_path = harness_events.events_path(change_dir)
734
823
  phase_start_count = 0
735
824
  phase_end_count = 0
736
825
  phase_end_status: str | None = None
@@ -1635,31 +1635,27 @@ def _validate_scenario_coverage(change_dir: Path) -> dict[str, Any]:
1635
1635
  for s in scenarios
1636
1636
  if isinstance(s, dict)
1637
1637
  and (
1638
- s.get("requiredEvidenceKind") == "ledger"
1639
- or (
1640
- not s.get("requiredEvidenceKind")
1641
- and str(s.get("priority", "")).upper() in {"P0", "P1"}
1642
- )
1638
+ str(s.get("priority", "")).upper() in {"P0", "P1"}
1639
+ or s.get("requiredEvidenceKind") == "ledger"
1643
1640
  )
1644
1641
  }
1645
1642
  if not required_ids:
1646
1643
  return {"ok": True, "code": "NO_LEDGER_REQUIRED_SCENARIOS"}
1647
1644
 
1648
- ledger_path = change_dir / "evidence" / "verification-ledger.json"
1649
- if not ledger_path.is_file():
1645
+ try:
1646
+ ledger, ledger_path = hl.load_ledger(change_dir)
1647
+ except (OSError, ValueError, json.JSONDecodeError) as exc:
1650
1648
  return {
1651
1649
  "ok": False,
1652
1650
  "code": "SCENARIO_COVERAGE_FAILED",
1653
- "message": "ledger missing; cannot verify required scenario coverage",
1651
+ "message": f"ledger unreadable: {exc}",
1654
1652
  "missing": sorted(required_ids),
1655
1653
  }
1656
- try:
1657
- ledger = json.loads(ledger_path.read_text(encoding="utf-8-sig"))
1658
- except (OSError, json.JSONDecodeError) as exc:
1654
+ if ledger is None or ledger_path is None:
1659
1655
  return {
1660
1656
  "ok": False,
1661
1657
  "code": "SCENARIO_COVERAGE_FAILED",
1662
- "message": f"ledger unreadable: {exc}",
1658
+ "message": "ledger missing; cannot verify required scenario coverage",
1663
1659
  "missing": sorted(required_ids),
1664
1660
  }
1665
1661
  covered: set[str] = set()
@@ -1728,13 +1724,6 @@ def cmd_begin(args: argparse.Namespace) -> int:
1728
1724
  or plan_verification.get("message")
1729
1725
  or "finalized plan verification failed"
1730
1726
  )
1731
- record_blocked_attempt(
1732
- change_dir,
1733
- phase=args.phase,
1734
- code=code,
1735
- message=message,
1736
- run_id=args.run_id or os.environ.get("HUNTER_HARNESS_RUN_ID"),
1737
- )
1738
1727
  return emit_error(
1739
1728
  code,
1740
1729
  message,