@hunter-harness/workflow-harness 0.2.11 → 0.2.13
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.
- package/harness/bundles/general/claude-code/.harness-build.json +1 -1
- package/harness/bundles/general/claude-code/contracts/ci-metrics.schema.json +36 -0
- package/harness/bundles/general/claude-code/contracts/workflow-policy.json +30 -0
- package/harness/bundles/general/claude-code/harness-archive/SKILL.md +1 -1
- package/harness/bundles/general/claude-code/harness-codebase-map/SKILL.md +1 -1
- package/harness/bundles/general/claude-code/harness-knowledge-ingest/SKILL.md +1 -1
- package/harness/bundles/general/claude-code/harness-knowledge-query/SKILL.md +1 -1
- package/harness/bundles/general/claude-code/harness-plan/SKILL.md +4 -4
- package/harness/bundles/general/claude-code/harness-plan/checklist.md +6 -0
- package/harness/bundles/general/claude-code/harness-plan/reference.md +7 -2
- package/harness/bundles/general/claude-code/harness-review/SKILL.md +1 -1
- package/harness/bundles/general/claude-code/harness-run/SKILL.md +1 -1
- package/harness/bundles/general/claude-code/harness-run/checklist.md +3 -2
- package/harness/bundles/general/claude-code/harness-run/reference.md +6 -4
- package/harness/bundles/general/claude-code/harness-submit/SKILL.md +1 -1
- package/harness/bundles/general/claude-code/harness-sync/SKILL.md +2 -2
- package/harness/bundles/general/claude-code/harness-sync/reference.md +5 -4
- package/harness/bundles/general/claude-code/harness-test/SKILL.md +1 -1
- package/harness/bundles/general/claude-code/scripts/harness_archive.py +282 -120
- package/harness/bundles/general/claude-code/scripts/harness_events.py +400 -24
- package/harness/bundles/general/claude-code/scripts/harness_gate.py +585 -21
- package/harness/bundles/general/claude-code/scripts/harness_integration.py +403 -18
- package/harness/bundles/general/claude-code/scripts/harness_ledger.py +225 -29
- package/harness/bundles/general/claude-code/scripts/harness_phase.py +926 -0
- package/harness/bundles/general/claude-code/scripts/harness_plan_finalize.py +361 -0
- package/harness/bundles/general/claude-code/scripts/harness_profile.py +50 -0
- package/harness/bundles/general/claude-code/scripts/harness_runtime.py +248 -0
- package/harness/bundles/general/claude-code/scripts/harness_state.py +15 -2
- package/harness/bundles/general/claude-code/scripts/harness_test_guard.py +188 -0
- package/harness/bundles/general/claude-code/scripts/harness_workflow_policy.py +19 -0
- package/harness/bundles/general/codebuddy/.harness-build.json +1 -1
- package/harness/bundles/general/codebuddy/contracts/ci-metrics.schema.json +36 -0
- package/harness/bundles/general/codebuddy/contracts/workflow-policy.json +30 -0
- package/harness/bundles/general/codebuddy/harness-archive/SKILL.md +1 -1
- package/harness/bundles/general/codebuddy/harness-codebase-map/SKILL.md +1 -1
- package/harness/bundles/general/codebuddy/harness-knowledge-ingest/SKILL.md +1 -1
- package/harness/bundles/general/codebuddy/harness-knowledge-query/SKILL.md +1 -1
- package/harness/bundles/general/codebuddy/harness-plan/SKILL.md +4 -4
- package/harness/bundles/general/codebuddy/harness-plan/checklist.md +6 -0
- package/harness/bundles/general/codebuddy/harness-plan/reference.md +7 -2
- package/harness/bundles/general/codebuddy/harness-review/SKILL.md +1 -1
- package/harness/bundles/general/codebuddy/harness-run/SKILL.md +1 -1
- package/harness/bundles/general/codebuddy/harness-run/checklist.md +3 -2
- package/harness/bundles/general/codebuddy/harness-run/reference.md +6 -4
- package/harness/bundles/general/codebuddy/harness-submit/SKILL.md +1 -1
- package/harness/bundles/general/codebuddy/harness-sync/SKILL.md +2 -2
- package/harness/bundles/general/codebuddy/harness-sync/reference.md +5 -4
- package/harness/bundles/general/codebuddy/harness-test/SKILL.md +1 -1
- package/harness/bundles/general/codebuddy/scripts/harness_archive.py +282 -120
- package/harness/bundles/general/codebuddy/scripts/harness_events.py +400 -24
- package/harness/bundles/general/codebuddy/scripts/harness_gate.py +585 -21
- package/harness/bundles/general/codebuddy/scripts/harness_integration.py +403 -18
- package/harness/bundles/general/codebuddy/scripts/harness_ledger.py +225 -29
- package/harness/bundles/general/codebuddy/scripts/harness_phase.py +926 -0
- package/harness/bundles/general/codebuddy/scripts/harness_plan_finalize.py +361 -0
- package/harness/bundles/general/codebuddy/scripts/harness_profile.py +50 -0
- package/harness/bundles/general/codebuddy/scripts/harness_runtime.py +248 -0
- package/harness/bundles/general/codebuddy/scripts/harness_state.py +15 -2
- package/harness/bundles/general/codebuddy/scripts/harness_test_guard.py +188 -0
- package/harness/bundles/general/codebuddy/scripts/harness_workflow_policy.py +19 -0
- package/harness/bundles/general/codex/.harness-build.json +1 -1
- package/harness/bundles/general/codex/contracts/ci-metrics.schema.json +36 -0
- package/harness/bundles/general/codex/contracts/workflow-policy.json +30 -0
- package/harness/bundles/general/codex/harness-archive/SKILL.md +1 -1
- package/harness/bundles/general/codex/harness-codebase-map/SKILL.md +1 -1
- package/harness/bundles/general/codex/harness-knowledge-ingest/SKILL.md +1 -1
- package/harness/bundles/general/codex/harness-knowledge-query/SKILL.md +1 -1
- package/harness/bundles/general/codex/harness-plan/SKILL.md +4 -4
- package/harness/bundles/general/codex/harness-plan/checklist.md +6 -0
- package/harness/bundles/general/codex/harness-plan/reference.md +7 -2
- package/harness/bundles/general/codex/harness-review/SKILL.md +1 -1
- package/harness/bundles/general/codex/harness-run/SKILL.md +1 -1
- package/harness/bundles/general/codex/harness-run/checklist.md +3 -2
- package/harness/bundles/general/codex/harness-run/reference.md +6 -4
- package/harness/bundles/general/codex/harness-submit/SKILL.md +1 -1
- package/harness/bundles/general/codex/harness-sync/SKILL.md +2 -2
- package/harness/bundles/general/codex/harness-sync/reference.md +5 -4
- package/harness/bundles/general/codex/harness-test/SKILL.md +1 -1
- package/harness/bundles/general/codex/scripts/harness_archive.py +282 -120
- package/harness/bundles/general/codex/scripts/harness_events.py +400 -24
- package/harness/bundles/general/codex/scripts/harness_gate.py +585 -21
- package/harness/bundles/general/codex/scripts/harness_integration.py +403 -18
- package/harness/bundles/general/codex/scripts/harness_ledger.py +225 -29
- package/harness/bundles/general/codex/scripts/harness_phase.py +926 -0
- package/harness/bundles/general/codex/scripts/harness_plan_finalize.py +361 -0
- package/harness/bundles/general/codex/scripts/harness_profile.py +50 -0
- package/harness/bundles/general/codex/scripts/harness_runtime.py +248 -0
- package/harness/bundles/general/codex/scripts/harness_state.py +15 -2
- package/harness/bundles/general/codex/scripts/harness_test_guard.py +188 -0
- package/harness/bundles/general/codex/scripts/harness_workflow_policy.py +19 -0
- package/harness/bundles/general/cursor/.harness-build.json +1 -1
- package/harness/bundles/general/cursor/contracts/ci-metrics.schema.json +36 -0
- package/harness/bundles/general/cursor/contracts/workflow-policy.json +30 -0
- package/harness/bundles/general/cursor/harness-archive/SKILL.md +1 -1
- package/harness/bundles/general/cursor/harness-codebase-map/SKILL.md +1 -1
- package/harness/bundles/general/cursor/harness-knowledge-ingest/SKILL.md +1 -1
- package/harness/bundles/general/cursor/harness-knowledge-query/SKILL.md +1 -1
- package/harness/bundles/general/cursor/harness-plan/SKILL.md +4 -4
- package/harness/bundles/general/cursor/harness-plan/checklist.md +6 -0
- package/harness/bundles/general/cursor/harness-plan/reference.md +7 -2
- package/harness/bundles/general/cursor/harness-review/SKILL.md +1 -1
- package/harness/bundles/general/cursor/harness-run/SKILL.md +1 -1
- package/harness/bundles/general/cursor/harness-run/checklist.md +3 -2
- package/harness/bundles/general/cursor/harness-run/reference.md +6 -4
- package/harness/bundles/general/cursor/harness-submit/SKILL.md +1 -1
- package/harness/bundles/general/cursor/harness-sync/SKILL.md +2 -2
- package/harness/bundles/general/cursor/harness-sync/reference.md +5 -4
- package/harness/bundles/general/cursor/harness-test/SKILL.md +1 -1
- package/harness/bundles/general/cursor/scripts/harness_archive.py +282 -120
- package/harness/bundles/general/cursor/scripts/harness_events.py +400 -24
- package/harness/bundles/general/cursor/scripts/harness_gate.py +585 -21
- package/harness/bundles/general/cursor/scripts/harness_integration.py +403 -18
- package/harness/bundles/general/cursor/scripts/harness_ledger.py +225 -29
- package/harness/bundles/general/cursor/scripts/harness_phase.py +926 -0
- package/harness/bundles/general/cursor/scripts/harness_plan_finalize.py +361 -0
- package/harness/bundles/general/cursor/scripts/harness_profile.py +50 -0
- package/harness/bundles/general/cursor/scripts/harness_runtime.py +248 -0
- package/harness/bundles/general/cursor/scripts/harness_state.py +15 -2
- package/harness/bundles/general/cursor/scripts/harness_test_guard.py +188 -0
- package/harness/bundles/general/cursor/scripts/harness_workflow_policy.py +19 -0
- package/harness/bundles/java/claude-code/.harness-build.json +1 -1
- package/harness/bundles/java/claude-code/contracts/ci-metrics.schema.json +36 -0
- package/harness/bundles/java/claude-code/contracts/workflow-policy.json +30 -0
- package/harness/bundles/java/claude-code/harness-apidoc/SKILL.md +1 -1
- package/harness/bundles/java/claude-code/harness-archive/SKILL.md +1 -1
- package/harness/bundles/java/claude-code/harness-codebase-map/SKILL.md +1 -1
- package/harness/bundles/java/claude-code/harness-knowledge-ingest/SKILL.md +1 -1
- package/harness/bundles/java/claude-code/harness-knowledge-query/SKILL.md +1 -1
- package/harness/bundles/java/claude-code/harness-package/SKILL.md +1 -1
- package/harness/bundles/java/claude-code/harness-plan/SKILL.md +4 -4
- package/harness/bundles/java/claude-code/harness-plan/checklist.md +6 -0
- package/harness/bundles/java/claude-code/harness-plan/reference.md +7 -2
- package/harness/bundles/java/claude-code/harness-review/SKILL.md +1 -1
- package/harness/bundles/java/claude-code/harness-run/SKILL.md +1 -1
- package/harness/bundles/java/claude-code/harness-submit/SKILL.md +1 -1
- package/harness/bundles/java/claude-code/harness-sync/SKILL.md +2 -2
- package/harness/bundles/java/claude-code/harness-sync/reference.md +5 -4
- package/harness/bundles/java/claude-code/harness-test/SKILL.md +1 -1
- package/harness/bundles/java/claude-code/scripts/harness_archive.py +282 -120
- package/harness/bundles/java/claude-code/scripts/harness_events.py +400 -24
- package/harness/bundles/java/claude-code/scripts/harness_gate.py +585 -21
- package/harness/bundles/java/claude-code/scripts/harness_integration.py +403 -18
- package/harness/bundles/java/claude-code/scripts/harness_ledger.py +225 -29
- package/harness/bundles/java/claude-code/scripts/harness_phase.py +926 -0
- package/harness/bundles/java/claude-code/scripts/harness_plan_finalize.py +361 -0
- package/harness/bundles/java/claude-code/scripts/harness_profile.py +50 -0
- package/harness/bundles/java/claude-code/scripts/harness_runtime.py +248 -0
- package/harness/bundles/java/claude-code/scripts/harness_state.py +15 -2
- package/harness/bundles/java/claude-code/scripts/harness_test_guard.py +188 -0
- package/harness/bundles/java/claude-code/scripts/harness_workflow_policy.py +19 -0
- package/harness/bundles/java/codebuddy/.harness-build.json +1 -1
- package/harness/bundles/java/codebuddy/contracts/ci-metrics.schema.json +36 -0
- package/harness/bundles/java/codebuddy/contracts/workflow-policy.json +30 -0
- package/harness/bundles/java/codebuddy/harness-apidoc/SKILL.md +1 -1
- package/harness/bundles/java/codebuddy/harness-archive/SKILL.md +1 -1
- package/harness/bundles/java/codebuddy/harness-codebase-map/SKILL.md +1 -1
- package/harness/bundles/java/codebuddy/harness-knowledge-ingest/SKILL.md +1 -1
- package/harness/bundles/java/codebuddy/harness-knowledge-query/SKILL.md +1 -1
- package/harness/bundles/java/codebuddy/harness-package/SKILL.md +1 -1
- package/harness/bundles/java/codebuddy/harness-plan/SKILL.md +4 -4
- package/harness/bundles/java/codebuddy/harness-plan/checklist.md +6 -0
- package/harness/bundles/java/codebuddy/harness-plan/reference.md +7 -2
- package/harness/bundles/java/codebuddy/harness-review/SKILL.md +1 -1
- package/harness/bundles/java/codebuddy/harness-run/SKILL.md +1 -1
- package/harness/bundles/java/codebuddy/harness-submit/SKILL.md +1 -1
- package/harness/bundles/java/codebuddy/harness-sync/SKILL.md +2 -2
- package/harness/bundles/java/codebuddy/harness-sync/reference.md +5 -4
- package/harness/bundles/java/codebuddy/harness-test/SKILL.md +1 -1
- package/harness/bundles/java/codebuddy/scripts/harness_archive.py +282 -120
- package/harness/bundles/java/codebuddy/scripts/harness_events.py +400 -24
- package/harness/bundles/java/codebuddy/scripts/harness_gate.py +585 -21
- package/harness/bundles/java/codebuddy/scripts/harness_integration.py +403 -18
- package/harness/bundles/java/codebuddy/scripts/harness_ledger.py +225 -29
- package/harness/bundles/java/codebuddy/scripts/harness_phase.py +926 -0
- package/harness/bundles/java/codebuddy/scripts/harness_plan_finalize.py +361 -0
- package/harness/bundles/java/codebuddy/scripts/harness_profile.py +50 -0
- package/harness/bundles/java/codebuddy/scripts/harness_runtime.py +248 -0
- package/harness/bundles/java/codebuddy/scripts/harness_state.py +15 -2
- package/harness/bundles/java/codebuddy/scripts/harness_test_guard.py +188 -0
- package/harness/bundles/java/codebuddy/scripts/harness_workflow_policy.py +19 -0
- package/harness/bundles/java/codex/.harness-build.json +1 -1
- package/harness/bundles/java/codex/contracts/ci-metrics.schema.json +36 -0
- package/harness/bundles/java/codex/contracts/workflow-policy.json +30 -0
- package/harness/bundles/java/codex/harness-apidoc/SKILL.md +1 -1
- package/harness/bundles/java/codex/harness-archive/SKILL.md +1 -1
- package/harness/bundles/java/codex/harness-codebase-map/SKILL.md +1 -1
- package/harness/bundles/java/codex/harness-knowledge-ingest/SKILL.md +1 -1
- package/harness/bundles/java/codex/harness-knowledge-query/SKILL.md +1 -1
- package/harness/bundles/java/codex/harness-package/SKILL.md +1 -1
- package/harness/bundles/java/codex/harness-plan/SKILL.md +4 -4
- package/harness/bundles/java/codex/harness-plan/checklist.md +6 -0
- package/harness/bundles/java/codex/harness-plan/reference.md +7 -2
- package/harness/bundles/java/codex/harness-review/SKILL.md +1 -1
- package/harness/bundles/java/codex/harness-run/SKILL.md +1 -1
- package/harness/bundles/java/codex/harness-submit/SKILL.md +1 -1
- package/harness/bundles/java/codex/harness-sync/SKILL.md +2 -2
- package/harness/bundles/java/codex/harness-sync/reference.md +5 -4
- package/harness/bundles/java/codex/harness-test/SKILL.md +1 -1
- package/harness/bundles/java/codex/scripts/harness_archive.py +282 -120
- package/harness/bundles/java/codex/scripts/harness_events.py +400 -24
- package/harness/bundles/java/codex/scripts/harness_gate.py +585 -21
- package/harness/bundles/java/codex/scripts/harness_integration.py +403 -18
- package/harness/bundles/java/codex/scripts/harness_ledger.py +225 -29
- package/harness/bundles/java/codex/scripts/harness_phase.py +926 -0
- package/harness/bundles/java/codex/scripts/harness_plan_finalize.py +361 -0
- package/harness/bundles/java/codex/scripts/harness_profile.py +50 -0
- package/harness/bundles/java/codex/scripts/harness_runtime.py +248 -0
- package/harness/bundles/java/codex/scripts/harness_state.py +15 -2
- package/harness/bundles/java/codex/scripts/harness_test_guard.py +188 -0
- package/harness/bundles/java/codex/scripts/harness_workflow_policy.py +19 -0
- package/harness/bundles/java/cursor/.harness-build.json +1 -1
- package/harness/bundles/java/cursor/contracts/ci-metrics.schema.json +36 -0
- package/harness/bundles/java/cursor/contracts/workflow-policy.json +30 -0
- package/harness/bundles/java/cursor/harness-apidoc/SKILL.md +1 -1
- package/harness/bundles/java/cursor/harness-archive/SKILL.md +1 -1
- package/harness/bundles/java/cursor/harness-codebase-map/SKILL.md +1 -1
- package/harness/bundles/java/cursor/harness-knowledge-ingest/SKILL.md +1 -1
- package/harness/bundles/java/cursor/harness-knowledge-query/SKILL.md +1 -1
- package/harness/bundles/java/cursor/harness-package/SKILL.md +1 -1
- package/harness/bundles/java/cursor/harness-plan/SKILL.md +4 -4
- package/harness/bundles/java/cursor/harness-plan/checklist.md +6 -0
- package/harness/bundles/java/cursor/harness-plan/reference.md +7 -2
- package/harness/bundles/java/cursor/harness-review/SKILL.md +1 -1
- package/harness/bundles/java/cursor/harness-run/SKILL.md +1 -1
- package/harness/bundles/java/cursor/harness-submit/SKILL.md +1 -1
- package/harness/bundles/java/cursor/harness-sync/SKILL.md +2 -2
- package/harness/bundles/java/cursor/harness-sync/reference.md +5 -4
- package/harness/bundles/java/cursor/harness-test/SKILL.md +1 -1
- package/harness/bundles/java/cursor/scripts/harness_archive.py +282 -120
- package/harness/bundles/java/cursor/scripts/harness_events.py +400 -24
- package/harness/bundles/java/cursor/scripts/harness_gate.py +585 -21
- package/harness/bundles/java/cursor/scripts/harness_integration.py +403 -18
- package/harness/bundles/java/cursor/scripts/harness_ledger.py +225 -29
- package/harness/bundles/java/cursor/scripts/harness_phase.py +926 -0
- package/harness/bundles/java/cursor/scripts/harness_plan_finalize.py +361 -0
- package/harness/bundles/java/cursor/scripts/harness_profile.py +50 -0
- package/harness/bundles/java/cursor/scripts/harness_runtime.py +248 -0
- package/harness/bundles/java/cursor/scripts/harness_state.py +15 -2
- package/harness/bundles/java/cursor/scripts/harness_test_guard.py +188 -0
- package/harness/bundles/java/cursor/scripts/harness_workflow_policy.py +19 -0
- package/harness/manifests/general/claude-code.json +43 -27
- package/harness/manifests/general/codebuddy.json +43 -27
- package/harness/manifests/general/codex.json +43 -27
- package/harness/manifests/general/cursor.json +43 -27
- package/harness/manifests/java/claude-code.json +43 -27
- package/harness/manifests/java/codebuddy.json +43 -27
- package/harness/manifests/java/codex.json +43 -27
- package/harness/manifests/java/cursor.json +43 -27
- package/hunter-workflow-family.json +2 -2
- package/package.json +1 -1
|
@@ -70,6 +70,16 @@ CAPABILITY_MARKERS: dict[str, tuple[str, ...]] = {
|
|
|
70
70
|
"integration-lock": ("harness_change.py integration-lock",),
|
|
71
71
|
}
|
|
72
72
|
|
|
73
|
+
DESIGN_GATE_CAPABILITIES = frozenset({"deployment", "container", "api", "database"})
|
|
74
|
+
VALIDATION_DEPENDENCIES: dict[str, tuple[str, ...]] = {
|
|
75
|
+
"compile": (),
|
|
76
|
+
"unitTest": ("compile",),
|
|
77
|
+
"unitTestFull": ("unitTest",),
|
|
78
|
+
"apiTest": ("unitTest",),
|
|
79
|
+
"dbCompatibility": ("unitTest",),
|
|
80
|
+
"package": ("unitTestFull", "apiTest", "dbCompatibility"),
|
|
81
|
+
}
|
|
82
|
+
|
|
73
83
|
|
|
74
84
|
def _load_workflow_policy(
|
|
75
85
|
*, project: Path | None = None, skills_root: Path | None = None
|
|
@@ -124,6 +134,133 @@ def _write_json(path: Path, data: Any) -> None:
|
|
|
124
134
|
raise
|
|
125
135
|
|
|
126
136
|
|
|
137
|
+
def _git_text(cwd: Path, *args: str) -> str | None:
|
|
138
|
+
proc = subprocess.run(
|
|
139
|
+
["git", *args],
|
|
140
|
+
cwd=str(cwd),
|
|
141
|
+
capture_output=True,
|
|
142
|
+
text=True,
|
|
143
|
+
encoding="utf-8",
|
|
144
|
+
errors="replace",
|
|
145
|
+
check=False,
|
|
146
|
+
)
|
|
147
|
+
return proc.stdout.strip() if proc.returncode == 0 else None
|
|
148
|
+
|
|
149
|
+
|
|
150
|
+
def _git_is_ancestor(cwd: Path, ancestor: str, descendant: str) -> bool:
|
|
151
|
+
proc = subprocess.run(
|
|
152
|
+
["git", "merge-base", "--is-ancestor", ancestor, descendant],
|
|
153
|
+
cwd=str(cwd),
|
|
154
|
+
capture_output=True,
|
|
155
|
+
text=True,
|
|
156
|
+
encoding="utf-8",
|
|
157
|
+
errors="replace",
|
|
158
|
+
check=False,
|
|
159
|
+
)
|
|
160
|
+
return proc.returncode == 0
|
|
161
|
+
|
|
162
|
+
|
|
163
|
+
def resolve_execution_root(main_project: Path, raw: str | None) -> Path:
|
|
164
|
+
candidate = Path(raw).expanduser().resolve() if raw else main_project.resolve()
|
|
165
|
+
if not candidate.is_dir():
|
|
166
|
+
raise ValueError(f"execution root not found: {candidate}")
|
|
167
|
+
top = _git_text(candidate, "rev-parse", "--show-toplevel")
|
|
168
|
+
if not top:
|
|
169
|
+
raise ValueError(f"execution root is not a git worktree: {candidate}")
|
|
170
|
+
root = Path(top).resolve()
|
|
171
|
+
if hp.repository_identity(root) != hp.repository_identity(main_project):
|
|
172
|
+
raise ValueError("execution root belongs to a different repository")
|
|
173
|
+
return root
|
|
174
|
+
|
|
175
|
+
|
|
176
|
+
def _phase_capsule_path(change_dir: Path, phase: str, run_id: str) -> Path:
|
|
177
|
+
key = hashlib.sha256(f"{phase}\0{run_id}".encode("utf-8")).hexdigest()[:20]
|
|
178
|
+
return (
|
|
179
|
+
Path(hp.resolve_state_dir_for_contract(change_dir))
|
|
180
|
+
/ "runtime"
|
|
181
|
+
/ "phase-context"
|
|
182
|
+
/ f"{phase}-{key}.json"
|
|
183
|
+
)
|
|
184
|
+
|
|
185
|
+
|
|
186
|
+
def load_phase_capsule(
|
|
187
|
+
change_dir: Path, phase: str, run_id: str
|
|
188
|
+
) -> dict[str, Any] | None:
|
|
189
|
+
path = _phase_capsule_path(change_dir, phase, run_id)
|
|
190
|
+
if not path.is_file():
|
|
191
|
+
return None
|
|
192
|
+
try:
|
|
193
|
+
data = _read_json(path)
|
|
194
|
+
except (OSError, json.JSONDecodeError) as exc:
|
|
195
|
+
raise ValueError(f"phase capsule is unreadable: {path}: {exc}") from exc
|
|
196
|
+
if not isinstance(data, dict) or data.get("schemaVersion") != 1:
|
|
197
|
+
raise ValueError(f"phase capsule has an unsupported schema: {path}")
|
|
198
|
+
return data
|
|
199
|
+
|
|
200
|
+
|
|
201
|
+
def write_phase_capsule(
|
|
202
|
+
change_dir: Path, phase: str, run_id: str, capsule: dict[str, Any]
|
|
203
|
+
) -> Path:
|
|
204
|
+
path = _phase_capsule_path(change_dir, phase, run_id)
|
|
205
|
+
_write_json(path, capsule)
|
|
206
|
+
return path
|
|
207
|
+
|
|
208
|
+
|
|
209
|
+
def validate_phase_capsule(
|
|
210
|
+
capsule: dict[str, Any],
|
|
211
|
+
*,
|
|
212
|
+
change_dir: Path,
|
|
213
|
+
change_id: str,
|
|
214
|
+
phase: str,
|
|
215
|
+
run_id: str,
|
|
216
|
+
project: Path,
|
|
217
|
+
execution_root: Path,
|
|
218
|
+
skills_root: Path | None = None,
|
|
219
|
+
allow_head_advance: bool = False,
|
|
220
|
+
) -> None:
|
|
221
|
+
"""Fail closed when a resume/close capsule no longer identifies this run."""
|
|
222
|
+
required = (
|
|
223
|
+
"changeId", "phase", "runId", "projectRoot", "stateRoot",
|
|
224
|
+
"executionRoot", "skillsRoot", "repositoryId", "baseCommit", "currentHead",
|
|
225
|
+
)
|
|
226
|
+
missing = [
|
|
227
|
+
field for field in required
|
|
228
|
+
if not isinstance(capsule.get(field), str) or not str(capsule[field]).strip()
|
|
229
|
+
]
|
|
230
|
+
if missing:
|
|
231
|
+
raise ValueError("phase capsule missing: " + ", ".join(missing))
|
|
232
|
+
expected = {
|
|
233
|
+
"changeId": change_id,
|
|
234
|
+
"phase": phase,
|
|
235
|
+
"runId": run_id,
|
|
236
|
+
"projectRoot": str(project.resolve()),
|
|
237
|
+
"stateRoot": str(Path(hp.resolve_state_dir_for_contract(change_dir)).resolve()),
|
|
238
|
+
"executionRoot": str(execution_root.resolve()),
|
|
239
|
+
}
|
|
240
|
+
if skills_root is not None:
|
|
241
|
+
expected["skillsRoot"] = str(skills_root.resolve())
|
|
242
|
+
for field, value in expected.items():
|
|
243
|
+
if capsule.get(field) != value:
|
|
244
|
+
raise ValueError(
|
|
245
|
+
f"phase capsule {field} mismatch: expected {value}, found {capsule.get(field)}"
|
|
246
|
+
)
|
|
247
|
+
current_repository = hp.repository_identity(execution_root)
|
|
248
|
+
if capsule.get("repositoryId") != current_repository:
|
|
249
|
+
raise ValueError("phase capsule repositoryId mismatch")
|
|
250
|
+
current_head = _git_text(execution_root, "rev-parse", "--verify", "HEAD")
|
|
251
|
+
capsule_head = str(capsule["currentHead"])
|
|
252
|
+
head_advanced = (
|
|
253
|
+
allow_head_advance
|
|
254
|
+
and isinstance(current_head, str)
|
|
255
|
+
and _git_is_ancestor(execution_root, capsule_head, current_head)
|
|
256
|
+
)
|
|
257
|
+
if capsule_head != current_head and not head_advanced:
|
|
258
|
+
raise ValueError(
|
|
259
|
+
f"phase capsule currentHead mismatch: expected {capsule_head}, "
|
|
260
|
+
f"found {current_head}"
|
|
261
|
+
)
|
|
262
|
+
|
|
263
|
+
|
|
127
264
|
def load_checkpoints(change_dir: Path) -> dict[str, Any] | None:
|
|
128
265
|
path = change_dir / CHECKPOINTS_REL
|
|
129
266
|
if not path.is_file():
|
|
@@ -218,6 +355,8 @@ def validate_ledger_for_phase_close(
|
|
|
218
355
|
change_dir: Path,
|
|
219
356
|
phase: str,
|
|
220
357
|
policy: dict[str, Any],
|
|
358
|
+
*,
|
|
359
|
+
execution_root: Path | None = None,
|
|
221
360
|
) -> dict[str, Any]:
|
|
222
361
|
"""Validate ledger v2 fields required for phase close (UT-026)."""
|
|
223
362
|
required = policy.get("requiredValidations", {}).get(phase, [])
|
|
@@ -243,6 +382,65 @@ def validate_ledger_for_phase_close(
|
|
|
243
382
|
"ledgerPath": str(ledger_path) if ledger_path else None,
|
|
244
383
|
}
|
|
245
384
|
|
|
385
|
+
try:
|
|
386
|
+
contract = hp.load_change_contract(change_dir)
|
|
387
|
+
except (OSError, ValueError, json.JSONDecodeError):
|
|
388
|
+
contract = {}
|
|
389
|
+
contract_version = contract.get("schemaVersion")
|
|
390
|
+
contract_is_v2 = hp.contract_layout_kind(contract) == "split-v1" or (
|
|
391
|
+
isinstance(contract_version, int) and contract_version >= 2
|
|
392
|
+
)
|
|
393
|
+
if contract_is_v2:
|
|
394
|
+
missing_identity = hl.validate_ledger_identity(ledger)
|
|
395
|
+
if missing_identity:
|
|
396
|
+
return {
|
|
397
|
+
"ok": False,
|
|
398
|
+
"code": "LEDGER_IDENTITY_INVALID",
|
|
399
|
+
"message": "ledger identity is incomplete",
|
|
400
|
+
"phase": phase,
|
|
401
|
+
"missing": missing_identity,
|
|
402
|
+
"ledgerPath": str(ledger_path) if ledger_path else None,
|
|
403
|
+
}
|
|
404
|
+
root = Path(execution_root or hp.resolve_worktree_root(change_dir)).resolve()
|
|
405
|
+
try:
|
|
406
|
+
current_repository = hp.repository_identity(root)
|
|
407
|
+
current_ownership = hl.ownership_hash(contract)
|
|
408
|
+
current_diff = hl.compute_ownership_diff(
|
|
409
|
+
root,
|
|
410
|
+
base=str(ledger["baseCommit"]),
|
|
411
|
+
change_dir=change_dir,
|
|
412
|
+
)["diffHash"]
|
|
413
|
+
current_head = _git_text(root, "rev-parse", "--verify", "HEAD")
|
|
414
|
+
except (OSError, ValueError, RuntimeError) as exc:
|
|
415
|
+
return {
|
|
416
|
+
"ok": False,
|
|
417
|
+
"code": "LEDGER_IDENTITY_INVALID",
|
|
418
|
+
"message": f"cannot resolve current ledger identity: {exc}",
|
|
419
|
+
"phase": phase,
|
|
420
|
+
"ledgerPath": str(ledger_path) if ledger_path else None,
|
|
421
|
+
}
|
|
422
|
+
identity_mismatch = (
|
|
423
|
+
ledger.get("repositoryId") != current_repository
|
|
424
|
+
or ledger.get("ownershipHash") != current_ownership
|
|
425
|
+
or ledger.get("diffHash") != current_diff
|
|
426
|
+
)
|
|
427
|
+
if identity_mismatch:
|
|
428
|
+
return {
|
|
429
|
+
"ok": False,
|
|
430
|
+
"code": "LEDGER_IDENTITY_MISMATCH",
|
|
431
|
+
"message": "verification ledger does not match the current change",
|
|
432
|
+
"phase": phase,
|
|
433
|
+
"storedRepositoryId": ledger.get("repositoryId"),
|
|
434
|
+
"currentRepositoryId": current_repository,
|
|
435
|
+
"storedOwnershipHash": ledger.get("ownershipHash"),
|
|
436
|
+
"currentOwnershipHash": current_ownership,
|
|
437
|
+
"storedDiffHash": ledger.get("diffHash"),
|
|
438
|
+
"currentDiffHash": current_diff,
|
|
439
|
+
"storedHead": ledger.get("currentHead"),
|
|
440
|
+
"currentHead": current_head,
|
|
441
|
+
"ledgerPath": str(ledger_path) if ledger_path else None,
|
|
442
|
+
}
|
|
443
|
+
|
|
246
444
|
problems: list[dict[str, Any]] = []
|
|
247
445
|
degraded: list[str] = []
|
|
248
446
|
for verification in required:
|
|
@@ -309,6 +507,34 @@ def validate_ledger_for_phase_close(
|
|
|
309
507
|
}
|
|
310
508
|
|
|
311
509
|
|
|
510
|
+
def effective_workflow_policy(
|
|
511
|
+
workflow: dict[str, Any], change_dir: Path
|
|
512
|
+
) -> dict[str, Any]:
|
|
513
|
+
"""Overlay a classified change's per-phase gate requirements."""
|
|
514
|
+
path = change_dir / "meta" / "gate-policy.json"
|
|
515
|
+
if not path.is_file():
|
|
516
|
+
return workflow
|
|
517
|
+
document = json.loads(path.read_text(encoding="utf-8"))
|
|
518
|
+
if not isinstance(document, dict) or document.get("schemaVersion") != 1:
|
|
519
|
+
raise ValueError("gate-policy.json must be a schemaVersion 1 object")
|
|
520
|
+
by_phase = document.get("requiredValidationsByPhase")
|
|
521
|
+
if by_phase is None:
|
|
522
|
+
return workflow
|
|
523
|
+
if not isinstance(by_phase, dict):
|
|
524
|
+
raise ValueError("gate-policy.requiredValidationsByPhase must be an object")
|
|
525
|
+
known = set(workflow.get("validationPhases") or {})
|
|
526
|
+
required = dict(workflow.get("requiredValidations") or {})
|
|
527
|
+
for phase, validations in by_phase.items():
|
|
528
|
+
if not isinstance(phase, str) or not isinstance(validations, list):
|
|
529
|
+
raise ValueError("gate-policy phase requirements must be string arrays")
|
|
530
|
+
if any(not isinstance(item, str) or item not in known for item in validations):
|
|
531
|
+
raise ValueError(f"gate-policy contains unknown validation for phase {phase}")
|
|
532
|
+
required[phase] = _ordered_unique(validations)
|
|
533
|
+
effective = dict(workflow)
|
|
534
|
+
effective["requiredValidations"] = required
|
|
535
|
+
return effective
|
|
536
|
+
|
|
537
|
+
|
|
312
538
|
def _sha256_file(path: Path) -> str:
|
|
313
539
|
digest = hashlib.sha256()
|
|
314
540
|
with path.open("rb") as handle:
|
|
@@ -448,6 +674,145 @@ def change_code_root(change_dir: Path) -> Path:
|
|
|
448
674
|
return project
|
|
449
675
|
|
|
450
676
|
|
|
677
|
+
def _design_capabilities(change_dir: Path) -> list[str]:
|
|
678
|
+
"""Read explicit capability tags from design/plan YAML frontmatter."""
|
|
679
|
+
capabilities: set[str] = set()
|
|
680
|
+
candidates: list[Path] = []
|
|
681
|
+
for directory in (change_dir / "spec", change_dir / "plans"):
|
|
682
|
+
if directory.is_dir():
|
|
683
|
+
candidates.extend(sorted(directory.glob("*.md")))
|
|
684
|
+
for candidate in candidates:
|
|
685
|
+
text = candidate.read_text(encoding="utf-8", errors="replace")
|
|
686
|
+
if not text.startswith("---"):
|
|
687
|
+
continue
|
|
688
|
+
end = text.find("\n---", 3)
|
|
689
|
+
if end < 0:
|
|
690
|
+
continue
|
|
691
|
+
frontmatter = text[3:end]
|
|
692
|
+
inline = re.search(r"^capabilities\s*:\s*\[([^\]]*)\]\s*$", frontmatter, re.MULTILINE)
|
|
693
|
+
if inline:
|
|
694
|
+
values = (item.strip().strip("'\"") for item in inline.group(1).split(","))
|
|
695
|
+
capabilities.update(item for item in values if item in DESIGN_GATE_CAPABILITIES)
|
|
696
|
+
continue
|
|
697
|
+
block = re.search(
|
|
698
|
+
r"^capabilities\s*:\s*$((?:\n\s*-\s*[^\n]+)+)",
|
|
699
|
+
frontmatter,
|
|
700
|
+
re.MULTILINE,
|
|
701
|
+
)
|
|
702
|
+
if block:
|
|
703
|
+
for item in re.findall(r"^\s*-\s*([^\n]+)$", block.group(1), re.MULTILINE):
|
|
704
|
+
value = item.strip().strip("'\"")
|
|
705
|
+
if value in DESIGN_GATE_CAPABILITIES:
|
|
706
|
+
capabilities.add(value)
|
|
707
|
+
return sorted(capabilities)
|
|
708
|
+
|
|
709
|
+
|
|
710
|
+
def _diff_capabilities(changed: list[str]) -> list[str]:
|
|
711
|
+
"""Conservatively infer capabilities from the owned post-run diff."""
|
|
712
|
+
lowered = "\n".join(path.lower().replace("\\", "/") for path in changed)
|
|
713
|
+
found: set[str] = set()
|
|
714
|
+
if any(marker in lowered for marker in ("deploy", "helm", "k8s", "kubernetes")):
|
|
715
|
+
found.add("deployment")
|
|
716
|
+
if any(marker in lowered for marker in ("dockerfile", "container", "compose.y")):
|
|
717
|
+
found.add("container")
|
|
718
|
+
if any(marker in lowered for marker in ("/api/", "openapi", "swagger", "controller")):
|
|
719
|
+
found.add("api")
|
|
720
|
+
if any(marker in lowered for marker in ("migration", "/sql/", ".sql", "schema")):
|
|
721
|
+
found.add("database")
|
|
722
|
+
return sorted(found)
|
|
723
|
+
|
|
724
|
+
|
|
725
|
+
def _ordered_unique(items: list[str]) -> list[str]:
|
|
726
|
+
return list(dict.fromkeys(items))
|
|
727
|
+
|
|
728
|
+
|
|
729
|
+
def _apply_required_gate_contract(
|
|
730
|
+
payload: dict[str, Any],
|
|
731
|
+
workflow: dict[str, Any],
|
|
732
|
+
capabilities: list[str],
|
|
733
|
+
) -> dict[str, Any]:
|
|
734
|
+
"""Expand tier + capability facts into the persisted required-gate DAG."""
|
|
735
|
+
result = dict(payload)
|
|
736
|
+
tier = str(result["tier"])
|
|
737
|
+
tier_policy = workflow["riskTiers"][tier]
|
|
738
|
+
selected = sorted(set(capabilities) & set(workflow["capabilityGates"]))
|
|
739
|
+
signals = list(result.get("signals") or [])
|
|
740
|
+
required = list(tier_policy["requiredValidations"])
|
|
741
|
+
required_stages: set[str] = set()
|
|
742
|
+
for capability in selected:
|
|
743
|
+
contract = workflow["capabilityGates"][capability]
|
|
744
|
+
signals.extend(contract["signals"])
|
|
745
|
+
required.extend(contract["requiredValidations"])
|
|
746
|
+
required_stages.update(contract["requiredStages"])
|
|
747
|
+
signals = sorted(set(signals))
|
|
748
|
+
required = _ordered_unique(required)
|
|
749
|
+
|
|
750
|
+
stage_decisions = _stage_decisions_for_tier(workflow, tier, signals)
|
|
751
|
+
for stage_name in required_stages:
|
|
752
|
+
decision = stage_decisions.setdefault(
|
|
753
|
+
stage_name,
|
|
754
|
+
{"required": False, "reason": "not-triggered", "matchedSignals": []},
|
|
755
|
+
)
|
|
756
|
+
decision["required"] = True
|
|
757
|
+
if decision["reason"] == "not-triggered":
|
|
758
|
+
decision["reason"] = "capability"
|
|
759
|
+
|
|
760
|
+
validation_phases = workflow["validationPhases"]
|
|
761
|
+
by_phase: dict[str, list[str]] = {}
|
|
762
|
+
for verification in required:
|
|
763
|
+
phase = validation_phases.get(verification)
|
|
764
|
+
if isinstance(phase, str) and phase:
|
|
765
|
+
by_phase.setdefault(phase, []).append(verification)
|
|
766
|
+
|
|
767
|
+
nodes: list[dict[str, Any]] = []
|
|
768
|
+
edges: list[dict[str, str]] = []
|
|
769
|
+
required_set = set(required)
|
|
770
|
+
for verification in required:
|
|
771
|
+
dependencies = [
|
|
772
|
+
item for item in VALIDATION_DEPENDENCIES.get(verification, ())
|
|
773
|
+
if item in required_set
|
|
774
|
+
]
|
|
775
|
+
node_id = f"validation:{verification}"
|
|
776
|
+
nodes.append({
|
|
777
|
+
"id": node_id,
|
|
778
|
+
"kind": "validation",
|
|
779
|
+
"phase": validation_phases.get(verification),
|
|
780
|
+
"dependsOn": [f"validation:{item}" for item in dependencies],
|
|
781
|
+
})
|
|
782
|
+
edges.extend(
|
|
783
|
+
{"from": f"validation:{item}", "to": node_id}
|
|
784
|
+
for item in dependencies
|
|
785
|
+
)
|
|
786
|
+
for stage_name, decision in sorted(stage_decisions.items()):
|
|
787
|
+
if not decision.get("required"):
|
|
788
|
+
continue
|
|
789
|
+
if stage_name == "package":
|
|
790
|
+
dependencies = [
|
|
791
|
+
f"validation:{item}" for item in required
|
|
792
|
+
if validation_phases.get(item) in {"run", "test"}
|
|
793
|
+
]
|
|
794
|
+
elif stage_name == "apidoc" and "apiTest" in required_set:
|
|
795
|
+
dependencies = ["validation:apiTest"]
|
|
796
|
+
else:
|
|
797
|
+
dependencies = []
|
|
798
|
+
node_id = f"stage:{stage_name}"
|
|
799
|
+
nodes.append({
|
|
800
|
+
"id": node_id,
|
|
801
|
+
"kind": "stage",
|
|
802
|
+
"phase": stage_name,
|
|
803
|
+
"dependsOn": dependencies,
|
|
804
|
+
})
|
|
805
|
+
edges.extend({"from": item, "to": node_id} for item in dependencies)
|
|
806
|
+
|
|
807
|
+
result["capabilities"] = selected
|
|
808
|
+
result["signals"] = signals
|
|
809
|
+
result["requiredValidations"] = required
|
|
810
|
+
result["requiredValidationsByPhase"] = by_phase
|
|
811
|
+
result["stageDecisions"] = stage_decisions
|
|
812
|
+
result["requiredGateDag"] = {"schemaVersion": 1, "nodes": nodes, "edges": edges}
|
|
813
|
+
return result
|
|
814
|
+
|
|
815
|
+
|
|
451
816
|
def classify_risk(
|
|
452
817
|
change_dir: Path,
|
|
453
818
|
stage: str,
|
|
@@ -455,6 +820,7 @@ def classify_risk(
|
|
|
455
820
|
) -> dict[str, Any]:
|
|
456
821
|
tier = "full"
|
|
457
822
|
source = "default-full"
|
|
823
|
+
capabilities = _design_capabilities(change_dir)
|
|
458
824
|
plan_path = change_dir / "plans"
|
|
459
825
|
for candidate in sorted(plan_path.glob("*.md")) if plan_path.is_dir() else []:
|
|
460
826
|
text = candidate.read_text(encoding="utf-8", errors="replace")
|
|
@@ -486,6 +852,7 @@ def classify_risk(
|
|
|
486
852
|
if not raw or raw.startswith(".harness/"):
|
|
487
853
|
continue
|
|
488
854
|
changed.append(raw)
|
|
855
|
+
capabilities = sorted(set(capabilities) | set(_diff_capabilities(changed)))
|
|
489
856
|
lowered = "\n".join(changed).lower()
|
|
490
857
|
full_markers = {
|
|
491
858
|
"auth": ("auth", "token", "credential", "permission"),
|
|
@@ -538,7 +905,7 @@ def classify_risk(
|
|
|
538
905
|
}
|
|
539
906
|
if stage == "post-run":
|
|
540
907
|
_write_json(change_dir / "meta" / "risk-classification.json", payload)
|
|
541
|
-
return payload
|
|
908
|
+
return _apply_required_gate_contract(payload, workflow, capabilities)
|
|
542
909
|
|
|
543
910
|
|
|
544
911
|
def _stage_decisions_for_tier(
|
|
@@ -582,7 +949,9 @@ def apply_tier_override(
|
|
|
582
949
|
payload["conditionalStages"] = list(tier_policy["conditionalStages"])
|
|
583
950
|
payload["stageDecisions"] = _stage_decisions_for_tier(workflow, tier, signals)
|
|
584
951
|
payload["tierOverride"] = {"tier": tier, "by": override_by, "at": now}
|
|
585
|
-
return
|
|
952
|
+
return _apply_required_gate_contract(
|
|
953
|
+
payload, workflow, list(payload.get("capabilities") or [])
|
|
954
|
+
)
|
|
586
955
|
|
|
587
956
|
|
|
588
957
|
def gate_policy_document(payload: dict[str, Any]) -> dict[str, Any]:
|
|
@@ -593,6 +962,13 @@ def gate_policy_document(payload: dict[str, Any]) -> dict[str, Any]:
|
|
|
593
962
|
"source": payload["source"],
|
|
594
963
|
"defaultPhases": list(payload.get("defaultPhases") or []),
|
|
595
964
|
"requiredValidations": list(payload.get("requiredValidations") or []),
|
|
965
|
+
"requiredValidationsByPhase": dict(
|
|
966
|
+
payload.get("requiredValidationsByPhase") or {}
|
|
967
|
+
),
|
|
968
|
+
"capabilities": list(payload.get("capabilities") or []),
|
|
969
|
+
"signals": list(payload.get("signals") or []),
|
|
970
|
+
"stageDecisions": dict(payload.get("stageDecisions") or {}),
|
|
971
|
+
"requiredGateDag": dict(payload.get("requiredGateDag") or {}),
|
|
596
972
|
"classifiedAt": payload.get("classifiedAt") or hc.now_iso(),
|
|
597
973
|
"tierOverride": payload.get("tierOverride"),
|
|
598
974
|
}
|
|
@@ -607,7 +983,7 @@ def classify_defaults(
|
|
|
607
983
|
tier = "full"
|
|
608
984
|
source = "default-full"
|
|
609
985
|
tier_policy = workflow["riskTiers"][tier]
|
|
610
|
-
|
|
986
|
+
payload = {
|
|
611
987
|
"ok": True,
|
|
612
988
|
"code": "CLASSIFIED",
|
|
613
989
|
"stage": stage,
|
|
@@ -620,6 +996,7 @@ def classify_defaults(
|
|
|
620
996
|
"conditionalStages": list(tier_policy["conditionalStages"]),
|
|
621
997
|
"stageDecisions": _stage_decisions_for_tier(workflow, tier, []),
|
|
622
998
|
}
|
|
999
|
+
return _apply_required_gate_contract(payload, workflow, [])
|
|
623
1000
|
|
|
624
1001
|
|
|
625
1002
|
def lint_skill_tree(skills_root: Path) -> dict[str, Any]:
|
|
@@ -803,6 +1180,7 @@ def cmd_begin(args: argparse.Namespace) -> int:
|
|
|
803
1180
|
|
|
804
1181
|
try:
|
|
805
1182
|
policy = _load_workflow_policy(project=project)
|
|
1183
|
+
policy = effective_workflow_policy(policy, change_dir)
|
|
806
1184
|
except (OSError, ValueError, hwp.PolicyValidationError) as exc:
|
|
807
1185
|
return emit_error("POLICY_LOAD_FAILED", str(exc), as_json=as_json)
|
|
808
1186
|
|
|
@@ -827,6 +1205,31 @@ def cmd_begin(args: argparse.Namespace) -> int:
|
|
|
827
1205
|
if isinstance(current_lease, dict) and current_lease.get("phase") == args.phase
|
|
828
1206
|
else "run-" + uuid.uuid4().hex
|
|
829
1207
|
)
|
|
1208
|
+
try:
|
|
1209
|
+
capsule = load_phase_capsule(change_dir, args.phase, run_id)
|
|
1210
|
+
except ValueError as exc:
|
|
1211
|
+
return emit_error("PHASE_CAPSULE_INVALID", str(exc), as_json=as_json)
|
|
1212
|
+
execution_hint = args.project
|
|
1213
|
+
if not execution_hint and capsule is not None:
|
|
1214
|
+
execution_hint = str(capsule.get("executionRoot") or "")
|
|
1215
|
+
try:
|
|
1216
|
+
execution_root = resolve_execution_root(project, execution_hint)
|
|
1217
|
+
except ValueError as exc:
|
|
1218
|
+
return emit_error("EXECUTION_ROOT_INVALID", str(exc), as_json=as_json)
|
|
1219
|
+
if capsule is not None:
|
|
1220
|
+
try:
|
|
1221
|
+
validate_phase_capsule(
|
|
1222
|
+
capsule,
|
|
1223
|
+
change_dir=change_dir,
|
|
1224
|
+
change_id=str(resolved["changeId"]),
|
|
1225
|
+
phase=args.phase,
|
|
1226
|
+
run_id=run_id,
|
|
1227
|
+
project=project,
|
|
1228
|
+
execution_root=execution_root,
|
|
1229
|
+
skills_root=Path(args.skills_root).expanduser(),
|
|
1230
|
+
)
|
|
1231
|
+
except ValueError as exc:
|
|
1232
|
+
return emit_error("PHASE_CAPSULE_MISMATCH", str(exc), as_json=as_json)
|
|
830
1233
|
claim = hc.claim_lease(
|
|
831
1234
|
project,
|
|
832
1235
|
change_id=resolved["changeId"],
|
|
@@ -842,7 +1245,45 @@ def cmd_begin(args: argparse.Namespace) -> int:
|
|
|
842
1245
|
extra={k: v for k, v in claim.items() if k not in {"ok", "message", "code"}},
|
|
843
1246
|
)
|
|
844
1247
|
|
|
1248
|
+
guard_result: dict[str, Any] | None = None
|
|
845
1249
|
try:
|
|
1250
|
+
if capsule is not None:
|
|
1251
|
+
guard_result = {"ok": True, "code": "SNAPSHOT_REUSED"}
|
|
1252
|
+
else:
|
|
1253
|
+
if args.phase in {"run", "test"}:
|
|
1254
|
+
guard_result = htg.begin(execution_root, change_dir)
|
|
1255
|
+
if not guard_result.get("ok"):
|
|
1256
|
+
hc.release_lease(
|
|
1257
|
+
project,
|
|
1258
|
+
change_id=resolved["changeId"],
|
|
1259
|
+
phase=args.phase,
|
|
1260
|
+
run_id=run_id,
|
|
1261
|
+
)
|
|
1262
|
+
return emit_error(
|
|
1263
|
+
str(guard_result.get("code", "TEST_GUARD_BEGIN_FAILED")),
|
|
1264
|
+
"test guard begin failed",
|
|
1265
|
+
as_json=as_json,
|
|
1266
|
+
extra=guard_result,
|
|
1267
|
+
)
|
|
1268
|
+
state_root = Path(hp.resolve_state_dir_for_contract(change_dir)).resolve()
|
|
1269
|
+
current_head = _git_text(execution_root, "rev-parse", "--verify", "HEAD")
|
|
1270
|
+
ledger, _ = hl.load_ledger(change_dir)
|
|
1271
|
+
base_commit = ledger.get("baseCommit") if isinstance(ledger, dict) else None
|
|
1272
|
+
capsule = {
|
|
1273
|
+
"schemaVersion": 1,
|
|
1274
|
+
"changeId": resolved["changeId"],
|
|
1275
|
+
"phase": args.phase,
|
|
1276
|
+
"runId": run_id,
|
|
1277
|
+
"projectRoot": str(project.resolve()),
|
|
1278
|
+
"stateRoot": str(state_root),
|
|
1279
|
+
"executionRoot": str(execution_root),
|
|
1280
|
+
"skillsRoot": str(Path(args.skills_root).expanduser().resolve()),
|
|
1281
|
+
"repositoryId": hp.repository_identity(execution_root),
|
|
1282
|
+
"baseCommit": base_commit or current_head,
|
|
1283
|
+
"currentHead": current_head,
|
|
1284
|
+
"createdAt": he.now_iso(),
|
|
1285
|
+
}
|
|
1286
|
+
write_phase_capsule(change_dir, args.phase, run_id, capsule)
|
|
846
1287
|
event_result = {"ok": True, "skipped": True, "reason": "already-recorded"} \
|
|
847
1288
|
if _phase_event_exists(change_dir, args.phase, "phase.start", run_id) \
|
|
848
1289
|
else append_phase_event(
|
|
@@ -869,9 +1310,13 @@ def cmd_begin(args: argparse.Namespace) -> int:
|
|
|
869
1310
|
"changeId": resolved["changeId"],
|
|
870
1311
|
"changeDir": str(change_dir),
|
|
871
1312
|
"projectRoot": str(project),
|
|
1313
|
+
"stateRoot": capsule.get("stateRoot") if capsule else None,
|
|
1314
|
+
"executionRoot": capsule.get("executionRoot") if capsule else str(execution_root),
|
|
1315
|
+
"skillsRoot": capsule.get("skillsRoot") if capsule else str(Path(args.skills_root).resolve()),
|
|
872
1316
|
"lease": claim.get("lease"),
|
|
873
1317
|
"identity": identity,
|
|
874
1318
|
"event": event_result,
|
|
1319
|
+
"testGuard": guard_result,
|
|
875
1320
|
"policySchemaVersion": policy.get("schemaVersion"),
|
|
876
1321
|
}
|
|
877
1322
|
emit(payload, as_json=as_json)
|
|
@@ -901,6 +1346,7 @@ def cmd_close(args: argparse.Namespace) -> int:
|
|
|
901
1346
|
|
|
902
1347
|
try:
|
|
903
1348
|
policy = _load_workflow_policy(project=project)
|
|
1349
|
+
policy = effective_workflow_policy(policy, change_dir)
|
|
904
1350
|
except (OSError, ValueError, hwp.PolicyValidationError) as exc:
|
|
905
1351
|
return emit_error("POLICY_LOAD_FAILED", str(exc), as_json=as_json)
|
|
906
1352
|
|
|
@@ -917,7 +1363,52 @@ def cmd_close(args: argparse.Namespace) -> int:
|
|
|
917
1363
|
extra={"holder": current_lease},
|
|
918
1364
|
)
|
|
919
1365
|
|
|
920
|
-
|
|
1366
|
+
try:
|
|
1367
|
+
capsule = load_phase_capsule(change_dir, args.phase, run_id)
|
|
1368
|
+
except ValueError as exc:
|
|
1369
|
+
return emit_error("PHASE_CAPSULE_INVALID", str(exc), as_json=as_json)
|
|
1370
|
+
if capsule is not None:
|
|
1371
|
+
try:
|
|
1372
|
+
execution_root = resolve_execution_root(
|
|
1373
|
+
project, str(capsule.get("executionRoot") or "")
|
|
1374
|
+
)
|
|
1375
|
+
if args.project:
|
|
1376
|
+
requested_root = resolve_execution_root(project, args.project)
|
|
1377
|
+
if requested_root != execution_root:
|
|
1378
|
+
return emit_error(
|
|
1379
|
+
"EXECUTION_ROOT_MISMATCH",
|
|
1380
|
+
"close must use the execution root captured at begin",
|
|
1381
|
+
as_json=as_json,
|
|
1382
|
+
extra={
|
|
1383
|
+
"storedExecutionRoot": str(execution_root),
|
|
1384
|
+
"requestedExecutionRoot": str(requested_root),
|
|
1385
|
+
},
|
|
1386
|
+
)
|
|
1387
|
+
except ValueError as exc:
|
|
1388
|
+
return emit_error("EXECUTION_ROOT_INVALID", str(exc), as_json=as_json)
|
|
1389
|
+
else:
|
|
1390
|
+
try:
|
|
1391
|
+
execution_root = resolve_execution_root(project, args.project)
|
|
1392
|
+
except ValueError as exc:
|
|
1393
|
+
return emit_error("EXECUTION_ROOT_INVALID", str(exc), as_json=as_json)
|
|
1394
|
+
if capsule is not None:
|
|
1395
|
+
try:
|
|
1396
|
+
validate_phase_capsule(
|
|
1397
|
+
capsule,
|
|
1398
|
+
change_dir=change_dir,
|
|
1399
|
+
change_id=str(resolved["changeId"]),
|
|
1400
|
+
phase=args.phase,
|
|
1401
|
+
run_id=run_id,
|
|
1402
|
+
project=project,
|
|
1403
|
+
execution_root=execution_root,
|
|
1404
|
+
allow_head_advance=args.phase in {"run", "submit", "merge"},
|
|
1405
|
+
)
|
|
1406
|
+
except ValueError as exc:
|
|
1407
|
+
return emit_error("PHASE_CAPSULE_MISMATCH", str(exc), as_json=as_json)
|
|
1408
|
+
|
|
1409
|
+
ledger_result = validate_ledger_for_phase_close(
|
|
1410
|
+
change_dir, args.phase, policy, execution_root=execution_root
|
|
1411
|
+
)
|
|
921
1412
|
if not ledger_result.get("ok") and args.phase in {"run", "test", "package"}:
|
|
922
1413
|
return emit_error(
|
|
923
1414
|
str(ledger_result.get("code", "LEDGER_INVALID")),
|
|
@@ -934,28 +1425,79 @@ def cmd_close(args: argparse.Namespace) -> int:
|
|
|
934
1425
|
if close_status == "OK":
|
|
935
1426
|
close_status = "WARN"
|
|
936
1427
|
|
|
1428
|
+
close_transaction: dict[str, Any] = {}
|
|
1429
|
+
if capsule is not None:
|
|
1430
|
+
existing_transaction = capsule.get("closeTransaction")
|
|
1431
|
+
if isinstance(existing_transaction, dict):
|
|
1432
|
+
close_transaction.update(existing_transaction)
|
|
1433
|
+
close_transaction.update({
|
|
1434
|
+
"status": "CLOSING",
|
|
1435
|
+
"retryable": True,
|
|
1436
|
+
"guardClosed": bool(close_transaction.get("guardClosed")),
|
|
1437
|
+
"phaseEndRecorded": bool(close_transaction.get("phaseEndRecorded")),
|
|
1438
|
+
"leaseReleased": False,
|
|
1439
|
+
"updatedAt": he.now_iso(),
|
|
1440
|
+
})
|
|
1441
|
+
capsule["closeTransaction"] = close_transaction
|
|
1442
|
+
write_phase_capsule(change_dir, args.phase, run_id, capsule)
|
|
1443
|
+
|
|
937
1444
|
guard_result = None
|
|
938
1445
|
if args.phase in {"run", "test"}:
|
|
939
|
-
|
|
940
|
-
|
|
941
|
-
|
|
1446
|
+
if close_transaction.get("guardClosed"):
|
|
1447
|
+
guard_result = {"ok": True, "code": "ALREADY_CLOSED", "reused": True}
|
|
1448
|
+
else:
|
|
1449
|
+
guard_result = htg.close(execution_root, change_dir)
|
|
1450
|
+
if not guard_result.get("ok"):
|
|
1451
|
+
if capsule is not None:
|
|
1452
|
+
close_transaction.update({
|
|
1453
|
+
"status": "GUARD_CLOSE_FAILED",
|
|
1454
|
+
"lastError": guard_result,
|
|
1455
|
+
"updatedAt": he.now_iso(),
|
|
1456
|
+
})
|
|
1457
|
+
write_phase_capsule(change_dir, args.phase, run_id, capsule)
|
|
1458
|
+
return emit_error(
|
|
1459
|
+
str(guard_result.get("code", "TEST_GUARD_CLOSE_FAILED")),
|
|
1460
|
+
"test guard close failed",
|
|
1461
|
+
as_json=as_json,
|
|
1462
|
+
extra=guard_result,
|
|
1463
|
+
)
|
|
1464
|
+
if capsule is not None:
|
|
1465
|
+
close_transaction["guardClosed"] = True
|
|
1466
|
+
close_transaction["updatedAt"] = he.now_iso()
|
|
1467
|
+
write_phase_capsule(change_dir, args.phase, run_id, capsule)
|
|
1468
|
+
|
|
1469
|
+
if close_transaction.get("phaseEndRecorded") or _phase_event_exists(
|
|
1470
|
+
change_dir, args.phase, "phase.end", run_id
|
|
1471
|
+
):
|
|
1472
|
+
event_result = {"ok": True, "skipped": True, "reason": "already-recorded"}
|
|
1473
|
+
else:
|
|
1474
|
+
try:
|
|
1475
|
+
event_result = append_phase_event(
|
|
1476
|
+
change_dir,
|
|
1477
|
+
phase=args.phase,
|
|
1478
|
+
type_="phase.end",
|
|
1479
|
+
status=close_status,
|
|
1480
|
+
note=args.note or "",
|
|
1481
|
+
run_id=run_id,
|
|
1482
|
+
)
|
|
1483
|
+
except BaseException as exc:
|
|
1484
|
+
if capsule is not None:
|
|
1485
|
+
close_transaction.update({
|
|
1486
|
+
"status": "PHASE_END_FAILED",
|
|
1487
|
+
"lastError": {"type": type(exc).__name__, "message": str(exc)},
|
|
1488
|
+
"updatedAt": he.now_iso(),
|
|
1489
|
+
})
|
|
1490
|
+
write_phase_capsule(change_dir, args.phase, run_id, capsule)
|
|
942
1491
|
return emit_error(
|
|
943
|
-
|
|
944
|
-
|
|
1492
|
+
"PHASE_END_FAILED",
|
|
1493
|
+
str(exc),
|
|
945
1494
|
as_json=as_json,
|
|
946
|
-
extra=
|
|
1495
|
+
extra={"retryable": True},
|
|
947
1496
|
)
|
|
948
|
-
|
|
949
|
-
|
|
950
|
-
|
|
951
|
-
|
|
952
|
-
type_="phase.end",
|
|
953
|
-
status=close_status,
|
|
954
|
-
note=args.note or "",
|
|
955
|
-
run_id=run_id,
|
|
956
|
-
) if not _phase_event_exists(change_dir, args.phase, "phase.end", run_id) else {
|
|
957
|
-
"ok": True, "skipped": True, "reason": "already-recorded"
|
|
958
|
-
}
|
|
1497
|
+
if capsule is not None:
|
|
1498
|
+
close_transaction["phaseEndRecorded"] = True
|
|
1499
|
+
close_transaction["updatedAt"] = he.now_iso()
|
|
1500
|
+
write_phase_capsule(change_dir, args.phase, run_id, capsule)
|
|
959
1501
|
|
|
960
1502
|
release = hc.release_lease(
|
|
961
1503
|
project,
|
|
@@ -964,6 +1506,14 @@ def cmd_close(args: argparse.Namespace) -> int:
|
|
|
964
1506
|
run_id=run_id,
|
|
965
1507
|
)
|
|
966
1508
|
if not release.get("ok"):
|
|
1509
|
+
if capsule is not None:
|
|
1510
|
+
close_transaction.update({
|
|
1511
|
+
"status": "RELEASE_PENDING",
|
|
1512
|
+
"retryable": True,
|
|
1513
|
+
"lastError": release,
|
|
1514
|
+
"updatedAt": he.now_iso(),
|
|
1515
|
+
})
|
|
1516
|
+
write_phase_capsule(change_dir, args.phase, run_id, capsule)
|
|
967
1517
|
return emit_error(
|
|
968
1518
|
str(release.get("code", "LEASE_RELEASE_FAILED")),
|
|
969
1519
|
str(release.get("message", "lease release failed")),
|
|
@@ -971,12 +1521,26 @@ def cmd_close(args: argparse.Namespace) -> int:
|
|
|
971
1521
|
extra={k: v for k, v in release.items() if k not in {"ok", "message", "code"}},
|
|
972
1522
|
)
|
|
973
1523
|
|
|
1524
|
+
if capsule is not None:
|
|
1525
|
+
close_transaction.update({
|
|
1526
|
+
"status": "CLOSED",
|
|
1527
|
+
"retryable": False,
|
|
1528
|
+
"leaseReleased": True,
|
|
1529
|
+
"updatedAt": he.now_iso(),
|
|
1530
|
+
})
|
|
1531
|
+
capsule["closedAt"] = he.now_iso()
|
|
1532
|
+
capsule["closeStatus"] = close_status
|
|
1533
|
+
write_phase_capsule(change_dir, args.phase, run_id, capsule)
|
|
1534
|
+
|
|
974
1535
|
payload = {
|
|
975
1536
|
"ok": True,
|
|
976
1537
|
"code": close_code,
|
|
977
1538
|
"phase": args.phase,
|
|
978
1539
|
"status": close_status,
|
|
979
1540
|
"changeId": resolved["changeId"],
|
|
1541
|
+
"stateRoot": capsule.get("stateRoot") if capsule else str(Path(hp.resolve_state_dir_for_contract(change_dir)).resolve()),
|
|
1542
|
+
"executionRoot": str(execution_root),
|
|
1543
|
+
"skillsRoot": capsule.get("skillsRoot") if capsule else None,
|
|
980
1544
|
"ledger": ledger_result,
|
|
981
1545
|
"testGuard": guard_result,
|
|
982
1546
|
"event": event_result,
|