@opengsd/gsd-path 1.6.2 → 1.7.0
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/CHANGELOG.md +21 -0
- package/DOCS.md +27 -1
- package/FULL.md +2 -1
- package/HOOKS.md +38 -4
- package/QUICK.md +1 -1
- package/README.md +19 -11
- package/RUNTIME.md +5 -3
- package/WORKFLOW.md +9 -2
- package/package.json +1 -1
- package/scripts/_common.py +76 -2
- package/scripts/archive_milestone.py +129 -7
- package/scripts/build_recovery.py +25 -2
- package/scripts/build_state.py +333 -8
- package/scripts/check_handoffs.py +437 -30
- package/scripts/check_task_briefs.py +135 -11
- package/scripts/discussion_validate.py +46 -10
- package/scripts/dispatch_driver.py +28 -3
- package/scripts/git_guard.py +44 -4
- package/scripts/guard_hook.py +456 -9
- package/scripts/install.py +34 -4
- package/scripts/integration.py +196 -39
- package/scripts/isolation.py +996 -38
- package/scripts/lean_verification.py +28 -13
- package/scripts/loop_run.py +9 -4
- package/scripts/members.py +17 -3
- package/scripts/pipeline_diagnose.py +104 -1
- package/scripts/pipeline_state.py +10 -2
- package/scripts/runtime_store.py +88 -3
- package/scripts/state_checkpoint.py +15 -0
- package/scripts/status_runtime.py +73 -2
- package/scripts/workflow_run.py +10 -3
- package/scripts/worktree_paths.py +45 -0
- package/skills/gsd-path/BUILD.md +3 -1
- package/skills/gsd-path/DEFINE.md +2 -1
- package/skills/gsd-path/PLAN.md +10 -0
- package/skills/gsd-path/SHIP.md +41 -8
- package/skills/gsd-path/SKILL.md +16 -1
- package/skills/gsd-path/references/build-native.md +20 -3
- package/skills/gsd-path/references/coder.md +6 -3
- package/skills/gsd-path/references/reviewer-final.md +10 -0
- package/skills/gsd-path/references/reviewer-wave.md +61 -22
- package/skills/gsd-path/references/reviewer.md +8 -6
- package/skills/gsd-path/references/runtime-selection.md +36 -0
- package/skills/gsd-path/references/spec-probes.md +19 -1
- package/skills/gsd-path/scripts/_common.py +76 -2
- package/skills/gsd-path/scripts/archive_milestone.py +129 -7
- package/skills/gsd-path/scripts/build_recovery.py +25 -2
- package/skills/gsd-path/scripts/build_state.py +333 -8
- package/skills/gsd-path/scripts/check_handoffs.py +437 -30
- package/skills/gsd-path/scripts/check_task_briefs.py +135 -11
- package/skills/gsd-path/scripts/discussion_validate.py +46 -10
- package/skills/gsd-path/scripts/dispatch_driver.py +28 -3
- package/skills/gsd-path/scripts/git_guard.py +44 -4
- package/skills/gsd-path/scripts/integration.py +196 -39
- package/skills/gsd-path/scripts/isolation.py +996 -38
- package/skills/gsd-path/scripts/lean_verification.py +28 -13
- package/skills/gsd-path/scripts/loop_run.py +9 -4
- package/skills/gsd-path/scripts/members.py +17 -3
- package/skills/gsd-path/scripts/pipeline_state.py +10 -2
- package/skills/gsd-path/scripts/state_checkpoint.py +15 -0
- package/skills/gsd-path/scripts/status_runtime.py +73 -2
- package/skills/gsd-path/scripts/workflow_run.py +10 -3
- package/skills/gsd-path/scripts/worktree_paths.py +45 -0
- package/skills/gsd-path/templates/intent.md +5 -3
- package/skills/gsd-path/templates/task.md +8 -1
- package/skills/gsd-path/templates/wave-review.md +24 -8
- package/skills/gsd-path-build/SKILL.md +3 -1
- package/skills/gsd-path-build/references/build-native.md +20 -3
- package/skills/gsd-path-build/references/coder.md +6 -3
- package/skills/gsd-path-build/references/reviewer-final.md +10 -0
- package/skills/gsd-path-build/references/reviewer-wave.md +61 -22
- package/skills/gsd-path-build/references/reviewer.md +8 -6
- package/skills/gsd-path-build/references/runtime-selection.md +36 -0
- package/skills/gsd-path-build/references/spec-probes.md +19 -1
- package/skills/gsd-path-build/scripts/_common.py +76 -2
- package/skills/gsd-path-build/scripts/archive_milestone.py +129 -7
- package/skills/gsd-path-build/scripts/build_recovery.py +25 -2
- package/skills/gsd-path-build/scripts/build_state.py +333 -8
- package/skills/gsd-path-build/scripts/check_handoffs.py +437 -30
- package/skills/gsd-path-build/scripts/check_task_briefs.py +135 -11
- package/skills/gsd-path-build/scripts/discussion_validate.py +46 -10
- package/skills/gsd-path-build/scripts/dispatch_driver.py +28 -3
- package/skills/gsd-path-build/scripts/integration.py +196 -39
- package/skills/gsd-path-build/scripts/isolation.py +996 -38
- package/skills/gsd-path-build/scripts/loop_run.py +9 -4
- package/skills/gsd-path-build/scripts/members.py +17 -3
- package/skills/gsd-path-build/scripts/pipeline_state.py +10 -2
- package/skills/gsd-path-build/scripts/state_checkpoint.py +15 -0
- package/skills/gsd-path-build/scripts/workflow_run.py +10 -3
- package/skills/gsd-path-build/scripts/worktree_paths.py +45 -0
- package/skills/gsd-path-build/templates/task.md +8 -1
- package/skills/gsd-path-build/templates/wave-review.md +24 -8
- package/skills/gsd-path-decide/references/runtime-selection.md +36 -0
- package/skills/gsd-path-decide/scripts/_common.py +76 -2
- package/skills/gsd-path-decide/scripts/archive_milestone.py +129 -7
- package/skills/gsd-path-decide/scripts/build_recovery.py +25 -2
- package/skills/gsd-path-decide/scripts/check_handoffs.py +437 -30
- package/skills/gsd-path-decide/scripts/check_task_briefs.py +135 -11
- package/skills/gsd-path-decide/scripts/discussion_validate.py +46 -10
- package/skills/gsd-path-decide/scripts/integration.py +196 -39
- package/skills/gsd-path-decide/scripts/isolation.py +996 -38
- package/skills/gsd-path-decide/scripts/loop_run.py +9 -4
- package/skills/gsd-path-decide/scripts/members.py +17 -3
- package/skills/gsd-path-decide/scripts/pipeline_state.py +10 -2
- package/skills/gsd-path-decide/scripts/state_checkpoint.py +15 -0
- package/skills/gsd-path-decide/scripts/worktree_paths.py +45 -0
- package/skills/gsd-path-define/SKILL.md +2 -1
- package/skills/gsd-path-define/references/runtime-selection.md +36 -0
- package/skills/gsd-path-define/references/spec-probes.md +19 -1
- package/skills/gsd-path-define/scripts/_common.py +76 -2
- package/skills/gsd-path-define/scripts/archive_milestone.py +129 -7
- package/skills/gsd-path-define/scripts/build_recovery.py +25 -2
- package/skills/gsd-path-define/scripts/check_handoffs.py +437 -30
- package/skills/gsd-path-define/scripts/check_task_briefs.py +135 -11
- package/skills/gsd-path-define/scripts/discussion_validate.py +46 -10
- package/skills/gsd-path-define/scripts/integration.py +196 -39
- package/skills/gsd-path-define/scripts/isolation.py +996 -38
- package/skills/gsd-path-define/scripts/loop_run.py +9 -4
- package/skills/gsd-path-define/scripts/members.py +17 -3
- package/skills/gsd-path-define/scripts/pipeline_state.py +10 -2
- package/skills/gsd-path-define/scripts/state_checkpoint.py +15 -0
- package/skills/gsd-path-define/scripts/worktree_paths.py +45 -0
- package/skills/gsd-path-define/templates/intent.md +5 -3
- package/skills/gsd-path-discuss/references/runtime-selection.md +36 -0
- package/skills/gsd-path-discuss/scripts/_common.py +76 -2
- package/skills/gsd-path-discuss/scripts/loop_run.py +9 -4
- package/skills/gsd-path-discuss/scripts/worktree_paths.py +45 -0
- package/skills/gsd-path-docs-audit/references/runtime-selection.md +36 -0
- package/skills/gsd-path-docs-audit/scripts/_common.py +76 -2
- package/skills/gsd-path-docs-audit/scripts/archive_milestone.py +129 -7
- package/skills/gsd-path-docs-audit/scripts/build_recovery.py +25 -2
- package/skills/gsd-path-docs-audit/scripts/check_handoffs.py +437 -30
- package/skills/gsd-path-docs-audit/scripts/check_task_briefs.py +135 -11
- package/skills/gsd-path-docs-audit/scripts/discussion_validate.py +46 -10
- package/skills/gsd-path-docs-audit/scripts/integration.py +196 -39
- package/skills/gsd-path-docs-audit/scripts/isolation.py +996 -38
- package/skills/gsd-path-docs-audit/scripts/loop_run.py +9 -4
- package/skills/gsd-path-docs-audit/scripts/members.py +17 -3
- package/skills/gsd-path-docs-audit/scripts/pipeline_state.py +10 -2
- package/skills/gsd-path-docs-audit/scripts/state_checkpoint.py +15 -0
- package/skills/gsd-path-docs-audit/scripts/worktree_paths.py +45 -0
- package/skills/gsd-path-forensics/references/runtime-selection.md +36 -0
- package/skills/gsd-path-forensics/scripts/_common.py +76 -2
- package/skills/gsd-path-forensics/scripts/loop_run.py +9 -4
- package/skills/gsd-path-forensics/scripts/pipeline_diagnose.py +104 -1
- package/skills/gsd-path-forensics/scripts/worktree_paths.py +45 -0
- package/skills/gsd-path-inspect/references/runtime-selection.md +36 -0
- package/skills/gsd-path-inspect/scripts/_common.py +76 -2
- package/skills/gsd-path-inspect/scripts/archive_milestone.py +129 -7
- package/skills/gsd-path-inspect/scripts/build_recovery.py +25 -2
- package/skills/gsd-path-inspect/scripts/check_handoffs.py +437 -30
- package/skills/gsd-path-inspect/scripts/check_task_briefs.py +135 -11
- package/skills/gsd-path-inspect/scripts/discussion_validate.py +46 -10
- package/skills/gsd-path-inspect/scripts/integration.py +196 -39
- package/skills/gsd-path-inspect/scripts/isolation.py +996 -38
- package/skills/gsd-path-inspect/scripts/loop_run.py +9 -4
- package/skills/gsd-path-inspect/scripts/members.py +17 -3
- package/skills/gsd-path-inspect/scripts/pipeline_state.py +10 -2
- package/skills/gsd-path-inspect/scripts/state_checkpoint.py +15 -0
- package/skills/gsd-path-inspect/scripts/workflow_run.py +10 -3
- package/skills/gsd-path-inspect/scripts/worktree_paths.py +45 -0
- package/skills/gsd-path-loop/references/runtime-selection.md +36 -0
- package/skills/gsd-path-loop/scripts/_common.py +76 -2
- package/skills/gsd-path-loop/scripts/loop_run.py +9 -4
- package/skills/gsd-path-loop/scripts/worktree_paths.py +45 -0
- package/skills/gsd-path-migrate/references/runtime-selection.md +36 -0
- package/skills/gsd-path-migrate/scripts/_common.py +76 -2
- package/skills/gsd-path-migrate/scripts/loop_run.py +9 -4
- package/skills/gsd-path-plan/SKILL.md +10 -0
- package/skills/gsd-path-plan/references/runtime-selection.md +36 -0
- package/skills/gsd-path-plan/references/spec-probes.md +19 -1
- package/skills/gsd-path-plan/scripts/_common.py +76 -2
- package/skills/gsd-path-plan/scripts/archive_milestone.py +129 -7
- package/skills/gsd-path-plan/scripts/build_recovery.py +25 -2
- package/skills/gsd-path-plan/scripts/check_handoffs.py +437 -30
- package/skills/gsd-path-plan/scripts/check_task_briefs.py +135 -11
- package/skills/gsd-path-plan/scripts/discussion_validate.py +46 -10
- package/skills/gsd-path-plan/scripts/integration.py +196 -39
- package/skills/gsd-path-plan/scripts/isolation.py +996 -38
- package/skills/gsd-path-plan/scripts/loop_run.py +9 -4
- package/skills/gsd-path-plan/scripts/members.py +17 -3
- package/skills/gsd-path-plan/scripts/pipeline_state.py +10 -2
- package/skills/gsd-path-plan/scripts/state_checkpoint.py +15 -0
- package/skills/gsd-path-plan/scripts/workflow_run.py +10 -3
- package/skills/gsd-path-plan/scripts/worktree_paths.py +45 -0
- package/skills/gsd-path-plan/templates/task.md +8 -1
- package/skills/gsd-path-research/references/runtime-selection.md +36 -0
- package/skills/gsd-path-research/scripts/_common.py +76 -2
- package/skills/gsd-path-research/scripts/archive_milestone.py +129 -7
- package/skills/gsd-path-research/scripts/build_recovery.py +25 -2
- package/skills/gsd-path-research/scripts/check_handoffs.py +437 -30
- package/skills/gsd-path-research/scripts/check_task_briefs.py +135 -11
- package/skills/gsd-path-research/scripts/discussion_validate.py +46 -10
- package/skills/gsd-path-research/scripts/integration.py +196 -39
- package/skills/gsd-path-research/scripts/isolation.py +996 -38
- package/skills/gsd-path-research/scripts/loop_run.py +9 -4
- package/skills/gsd-path-research/scripts/members.py +17 -3
- package/skills/gsd-path-research/scripts/pipeline_state.py +10 -2
- package/skills/gsd-path-research/scripts/state_checkpoint.py +15 -0
- package/skills/gsd-path-research/scripts/worktree_paths.py +45 -0
- package/skills/gsd-path-roadmap/references/runtime-selection.md +36 -0
- package/skills/gsd-path-roadmap/scripts/_common.py +76 -2
- package/skills/gsd-path-roadmap/scripts/archive_milestone.py +129 -7
- package/skills/gsd-path-roadmap/scripts/build_recovery.py +25 -2
- package/skills/gsd-path-roadmap/scripts/check_handoffs.py +437 -30
- package/skills/gsd-path-roadmap/scripts/check_task_briefs.py +135 -11
- package/skills/gsd-path-roadmap/scripts/discussion_validate.py +46 -10
- package/skills/gsd-path-roadmap/scripts/integration.py +196 -39
- package/skills/gsd-path-roadmap/scripts/isolation.py +996 -38
- package/skills/gsd-path-roadmap/scripts/loop_run.py +9 -4
- package/skills/gsd-path-roadmap/scripts/members.py +17 -3
- package/skills/gsd-path-roadmap/scripts/pipeline_state.py +10 -2
- package/skills/gsd-path-roadmap/scripts/state_checkpoint.py +15 -0
- package/skills/gsd-path-roadmap/scripts/worktree_paths.py +45 -0
- package/skills/gsd-path-ship/SKILL.md +41 -8
- package/skills/gsd-path-ship/references/reviewer-final.md +10 -0
- package/skills/gsd-path-ship/references/reviewer-wave.md +61 -22
- package/skills/gsd-path-ship/references/reviewer.md +8 -6
- package/skills/gsd-path-ship/references/runtime-selection.md +36 -0
- package/skills/gsd-path-ship/references/spec-probes.md +19 -1
- package/skills/gsd-path-ship/scripts/_common.py +76 -2
- package/skills/gsd-path-ship/scripts/archive_milestone.py +129 -7
- package/skills/gsd-path-ship/scripts/build_recovery.py +25 -2
- package/skills/gsd-path-ship/scripts/build_state.py +333 -8
- package/skills/gsd-path-ship/scripts/check_handoffs.py +437 -30
- package/skills/gsd-path-ship/scripts/check_task_briefs.py +135 -11
- package/skills/gsd-path-ship/scripts/discussion_validate.py +46 -10
- package/skills/gsd-path-ship/scripts/integration.py +196 -39
- package/skills/gsd-path-ship/scripts/isolation.py +996 -38
- package/skills/gsd-path-ship/scripts/lean_verification.py +28 -13
- package/skills/gsd-path-ship/scripts/loop_run.py +9 -4
- package/skills/gsd-path-ship/scripts/members.py +17 -3
- package/skills/gsd-path-ship/scripts/pipeline_state.py +10 -2
- package/skills/gsd-path-ship/scripts/state_checkpoint.py +15 -0
- package/skills/gsd-path-ship/scripts/workflow_run.py +10 -3
- package/skills/gsd-path-ship/scripts/worktree_paths.py +45 -0
- package/skills/gsd-path-ship/templates/wave-review.md +24 -8
- package/skills/gsd-path-undo/references/runtime-selection.md +36 -0
- package/skills/gsd-path-undo/scripts/_common.py +76 -2
- package/skills/gsd-path-undo/scripts/loop_run.py +9 -4
- package/skills/gsd-path-undo/scripts/worktree_paths.py +45 -0
- package/skills/path/BUILD.md +3 -1
- package/skills/path/DEFINE.md +2 -1
- package/skills/path/PLAN.md +10 -0
- package/skills/path/SHIP.md +41 -8
- package/skills/path/SKILL.md +16 -1
- package/skills/path/references/build-native.md +20 -3
- package/skills/path/references/coder.md +6 -3
- package/skills/path/references/reviewer-final.md +10 -0
- package/skills/path/references/reviewer-wave.md +61 -22
- package/skills/path/references/reviewer.md +8 -6
- package/skills/path/references/runtime-selection.md +36 -0
- package/skills/path/references/spec-probes.md +19 -1
- package/skills/path/scripts/_common.py +76 -2
- package/skills/path/scripts/archive_milestone.py +129 -7
- package/skills/path/scripts/build_recovery.py +25 -2
- package/skills/path/scripts/build_state.py +333 -8
- package/skills/path/scripts/check_handoffs.py +437 -30
- package/skills/path/scripts/check_task_briefs.py +135 -11
- package/skills/path/scripts/discussion_validate.py +46 -10
- package/skills/path/scripts/dispatch_driver.py +28 -3
- package/skills/path/scripts/git_guard.py +44 -4
- package/skills/path/scripts/integration.py +196 -39
- package/skills/path/scripts/isolation.py +996 -38
- package/skills/path/scripts/lean_verification.py +28 -13
- package/skills/path/scripts/loop_run.py +9 -4
- package/skills/path/scripts/members.py +17 -3
- package/skills/path/scripts/pipeline_state.py +10 -2
- package/skills/path/scripts/state_checkpoint.py +15 -0
- package/skills/path/scripts/status_runtime.py +73 -2
- package/skills/path/scripts/workflow_run.py +10 -3
- package/skills/path/scripts/worktree_paths.py +45 -0
- package/skills/path/templates/intent.md +5 -3
- package/skills/path/templates/task.md +8 -1
- package/skills/path/templates/wave-review.md +24 -8
|
@@ -231,8 +231,14 @@ def _retire_execution(repo, head, execution):
|
|
|
231
231
|
isolation.retire(repo, worktree, execution["branch"], force=False)
|
|
232
232
|
|
|
233
233
|
|
|
234
|
-
def verify_project(repo, expected_head):
|
|
235
|
-
"""Execute project Verify in isolation, retaining output for retry reuse.
|
|
234
|
+
def verify_project(repo, expected_head, *, rerun: bool = False):
|
|
235
|
+
"""Execute project Verify in isolation, retaining output for retry reuse.
|
|
236
|
+
|
|
237
|
+
With ``rerun`` (the owner's explicit ``prepare-final --rerun`` after a
|
|
238
|
+
host-caused failure diagnosed through the forensics contract), a recorded
|
|
239
|
+
execution at the same command and commit is retired and the command runs
|
|
240
|
+
again, appending a second ledger entry; the failed entry stays as history.
|
|
241
|
+
"""
|
|
236
242
|
repo = Path(repo).resolve()
|
|
237
243
|
_require_ship_inputs(repo, expected_head)
|
|
238
244
|
plan = contracts._read(repo, ".project/plan/PLAN.md")
|
|
@@ -247,7 +253,6 @@ def verify_project(repo, expected_head):
|
|
|
247
253
|
destination = repo / relative
|
|
248
254
|
if destination.is_symlink():
|
|
249
255
|
raise contracts.HandoffError("project verification evidence must be a real file")
|
|
250
|
-
expected_destination = hashlib.sha256(destination.read_bytes()).hexdigest() if destination.exists() else None
|
|
251
256
|
execution = entry.get("execution") if entry else None
|
|
252
257
|
if entry and execution is None:
|
|
253
258
|
raise contracts.HandoffError("recorded verification lacks output; preserve and reconcile its evidence")
|
|
@@ -263,11 +268,16 @@ def verify_project(repo, expected_head):
|
|
|
263
268
|
relative, relative, journal["expected_destination"],
|
|
264
269
|
)
|
|
265
270
|
_retire_execution(repo, expected_head, execution)
|
|
266
|
-
|
|
267
|
-
|
|
268
|
-
|
|
269
|
-
|
|
270
|
-
|
|
271
|
+
if not rerun:
|
|
272
|
+
view = _gap_view(command, expected_head, execution, waves)
|
|
273
|
+
if not destination.exists() or destination.read_text(encoding="utf-8") != view:
|
|
274
|
+
_common.atomic_write(destination, view)
|
|
275
|
+
return {"passed": entry["result"] == "pass", "reused": True,
|
|
276
|
+
"path": str(destination), "execution": execution}
|
|
277
|
+
# Hash after any prior execution's recovery: a rerun's old-collection
|
|
278
|
+
# recovery can rewrite the gap view, and the fresh collection must expect
|
|
279
|
+
# the file as it stands now, not before the recovery (#454).
|
|
280
|
+
expected_destination = hashlib.sha256(destination.read_bytes()).hexdigest() if destination.exists() else None
|
|
271
281
|
sidecar = isolation.isolate_verify(repo, expected_head, "project-verify")
|
|
272
282
|
worktree = Path(sidecar["worktree"])
|
|
273
283
|
try:
|
|
@@ -295,18 +305,20 @@ def verify_project(repo, expected_head):
|
|
|
295
305
|
execution["members"] = {name: record["tip"] for name, record in sidecars.items()}
|
|
296
306
|
else:
|
|
297
307
|
build_state.verify_record(str(repo), command, expected_head,
|
|
298
|
-
"pass" if passed else "fail", execution=execution
|
|
308
|
+
"pass" if passed else "fail", execution=execution,
|
|
309
|
+
rerun=rerun)
|
|
299
310
|
isolation.clean_verify(repo, worktree, expected_head, sidecar["branch"])
|
|
300
311
|
_common.atomic_write(worktree / relative, _gap_view(command, expected_head, execution, waves))
|
|
301
312
|
collection = isolation.collect_artifact(repo, worktree, expected_head, sidecar["branch"],
|
|
302
313
|
relative, relative, expected_destination)
|
|
303
314
|
isolation.retire(repo, worktree, sidecar["branch"], force=False)
|
|
304
315
|
return {"passed": passed, "reused": False, "path": str(destination),
|
|
305
|
-
"execution": execution, "collection": collection
|
|
316
|
+
"execution": execution, "collection": collection,
|
|
317
|
+
**({"rerun": True} if rerun else {})}
|
|
306
318
|
|
|
307
319
|
|
|
308
|
-
def prepare_final(repo, expected_head):
|
|
309
|
-
verification = verify_project(repo, expected_head)
|
|
320
|
+
def prepare_final(repo, expected_head, *, rerun: bool = False):
|
|
321
|
+
verification = verify_project(repo, expected_head, rerun=rerun)
|
|
310
322
|
if not verification["passed"]:
|
|
311
323
|
return {"status": "blocked", "verification": verification,
|
|
312
324
|
"reason": "project Verify failed; use its recorded output"}
|
|
@@ -327,9 +339,12 @@ def main(argv=None):
|
|
|
327
339
|
parser = argparse.ArgumentParser(description=__doc__)
|
|
328
340
|
parser.add_argument("--repo", type=Path, required=True)
|
|
329
341
|
parser.add_argument("--expected-head", required=True)
|
|
342
|
+
parser.add_argument("--rerun", action="store_true",
|
|
343
|
+
help="re-execute project Verify at the same head after a host-caused "
|
|
344
|
+
"failure, appending a new ledger entry")
|
|
330
345
|
args = parser.parse_args(argv)
|
|
331
346
|
try:
|
|
332
|
-
result = prepare_final(args.repo.resolve(), args.expected_head)
|
|
347
|
+
result = prepare_final(args.repo.resolve(), args.expected_head, rerun=args.rerun)
|
|
333
348
|
except (contracts.HandoffError, isolation.IsolationError, build_state.BuildStateError,
|
|
334
349
|
OSError, subprocess.CalledProcessError) as error:
|
|
335
350
|
print(str(error), file=sys.stderr)
|
package/scripts/loop_run.py
CHANGED
|
@@ -530,10 +530,15 @@ def run_shell(command: str, timeout: float) -> tuple[Optional[int], str]:
|
|
|
530
530
|
except subprocess.TimeoutExpired as drained:
|
|
531
531
|
stdout = drained.stdout or expired.stdout or b""
|
|
532
532
|
stderr = drained.stderr or expired.stderr or b""
|
|
533
|
-
|
|
534
|
-
|
|
535
|
-
|
|
536
|
-
|
|
533
|
+
# Windows communicate() reads in threads that hold each pipe's lock,
|
|
534
|
+
# so close() would block until an orphaned grandchild (bash forks
|
|
535
|
+
# commands it runs from a script file) exits; leave them to the
|
|
536
|
+
# daemon reader threads there.
|
|
537
|
+
if os.name != "nt":
|
|
538
|
+
if process.stdout:
|
|
539
|
+
process.stdout.close()
|
|
540
|
+
if process.stderr:
|
|
541
|
+
process.stderr.close()
|
|
537
542
|
process.kill()
|
|
538
543
|
process.wait(timeout=5)
|
|
539
544
|
output = stdout.decode("utf-8", errors="replace") + stderr.decode("utf-8", errors="replace")
|
package/scripts/members.py
CHANGED
|
@@ -34,9 +34,17 @@ MEMBERS_FILE = "MEMBERS.md"
|
|
|
34
34
|
MARKER_SCHEMA = "gsd-path/member/v1"
|
|
35
35
|
MARKER_KEYS = {"schema", "coordinator", "project", "name"}
|
|
36
36
|
NAME_RE = re.compile(r"^[a-z0-9][a-z0-9-]*$")
|
|
37
|
-
FIELDS = ("Checkout", "Remote", "Integration")
|
|
37
|
+
FIELDS = ("Checkout", "Remote", "Integration", "Prepare")
|
|
38
|
+
# The first three are required; Prepare (isolate provisioning, #463) and
|
|
39
|
+
# Default branch are optional.
|
|
40
|
+
REQUIRED_FIELDS = ("Checkout", "Remote", "Integration")
|
|
38
41
|
# Optional: `add` records it one time; a row without it means main.
|
|
39
42
|
DEFAULT_BRANCH_FIELD = "Default branch"
|
|
43
|
+
# Optional: a shell command run inside each fresh member isolate before any
|
|
44
|
+
# Verify (#463). It provisions what a bare worktree lacks — submodules from
|
|
45
|
+
# the primary checkout's object store, gitignored build inputs — and must
|
|
46
|
+
# copy, never link, outside the isolate (#462).
|
|
47
|
+
PREPARE_FIELD = "Prepare"
|
|
40
48
|
INTEGRATIONS = ("default", "direct", "pull-request")
|
|
41
49
|
GITHUB_REMOTE_RE = re.compile(
|
|
42
50
|
r"(?:https://github\.com/|git@github\.com:|ssh://git@github\.com/)([^/\s]+)/([^/\s]+)"
|
|
@@ -206,10 +214,15 @@ def read_members(coordinator: Path) -> list[dict[str, str]]:
|
|
|
206
214
|
return []
|
|
207
215
|
if path.is_symlink() or not path.is_file():
|
|
208
216
|
raise MembersError("MEMBERS.md must be a regular file")
|
|
217
|
+
return parse_members(path.read_text(encoding="utf-8"))
|
|
218
|
+
|
|
219
|
+
|
|
220
|
+
def parse_members(text: str) -> list[dict[str, str]]:
|
|
221
|
+
"""Parse MEMBERS.md text."""
|
|
209
222
|
members: list[dict[str, str]] = []
|
|
210
223
|
current: Optional[dict[str, str]] = None
|
|
211
224
|
preamble: list[str] = []
|
|
212
|
-
for number, line in enumerate(
|
|
225
|
+
for number, line in enumerate(text.splitlines(), 1):
|
|
213
226
|
if line.startswith("## "):
|
|
214
227
|
name = line[3:].strip()
|
|
215
228
|
if not NAME_RE.fullmatch(name):
|
|
@@ -241,7 +254,7 @@ def read_members(coordinator: Path) -> list[dict[str, str]]:
|
|
|
241
254
|
if len(set(names)) != len(names):
|
|
242
255
|
raise MembersError("MEMBERS.md repeats a member name")
|
|
243
256
|
for member in members:
|
|
244
|
-
missing = [field for field in
|
|
257
|
+
missing = [field for field in REQUIRED_FIELDS if not member.get(field.lower())]
|
|
245
258
|
if missing:
|
|
246
259
|
raise MembersError(f"MEMBERS.md member {member['name']} lacks {', '.join(missing)}")
|
|
247
260
|
if member["integration"] not in INTEGRATIONS:
|
|
@@ -510,6 +523,7 @@ def render(members: Sequence[dict[str, str]]) -> str:
|
|
|
510
523
|
f"Checkout: {member['checkout']}\n"
|
|
511
524
|
f"Remote: {member['remote']}\n"
|
|
512
525
|
f"Integration: {member['integration']}\n"
|
|
526
|
+
+ (f"{PREPARE_FIELD}: {member['prepare']}\n" if "prepare" in member else "")
|
|
513
527
|
# A row that never had the field keeps none, so old rows are not rewritten.
|
|
514
528
|
+ (f"{DEFAULT_BRANCH_FIELD}: {member['default_branch']}\n" if "default_branch" in member else "")
|
|
515
529
|
for member in members
|
|
@@ -24,16 +24,20 @@ from typing import Callable, Optional, Sequence
|
|
|
24
24
|
def _load_diagnose_modules():
|
|
25
25
|
if __package__:
|
|
26
26
|
from . import archive_milestone
|
|
27
|
+
from . import build_state
|
|
27
28
|
from . import detect_project
|
|
28
29
|
from . import discussion_records
|
|
30
|
+
from . import discussion_validate
|
|
29
31
|
from . import integration
|
|
30
32
|
from . import isolation
|
|
31
33
|
from . import pipeline_git
|
|
32
34
|
from . import pipeline_state
|
|
33
35
|
return (
|
|
34
36
|
archive_milestone,
|
|
37
|
+
build_state,
|
|
35
38
|
detect_project,
|
|
36
39
|
discussion_records,
|
|
40
|
+
discussion_validate,
|
|
37
41
|
integration,
|
|
38
42
|
isolation,
|
|
39
43
|
pipeline_git,
|
|
@@ -42,16 +46,20 @@ def _load_diagnose_modules():
|
|
|
42
46
|
else:
|
|
43
47
|
try:
|
|
44
48
|
import archive_milestone
|
|
49
|
+
import build_state
|
|
45
50
|
import detect_project
|
|
46
51
|
import discussion_records
|
|
52
|
+
import discussion_validate
|
|
47
53
|
import integration
|
|
48
54
|
import isolation
|
|
49
55
|
import pipeline_git
|
|
50
56
|
import pipeline_state
|
|
51
57
|
return (
|
|
52
58
|
archive_milestone,
|
|
59
|
+
build_state,
|
|
53
60
|
detect_project,
|
|
54
61
|
discussion_records,
|
|
62
|
+
discussion_validate,
|
|
55
63
|
integration,
|
|
56
64
|
isolation,
|
|
57
65
|
pipeline_git,
|
|
@@ -60,8 +68,10 @@ def _load_diagnose_modules():
|
|
|
60
68
|
except ModuleNotFoundError as error:
|
|
61
69
|
if error.name not in {
|
|
62
70
|
"archive_milestone",
|
|
71
|
+
"build_state",
|
|
63
72
|
"detect_project",
|
|
64
73
|
"discussion_records",
|
|
74
|
+
"discussion_validate",
|
|
65
75
|
"integration",
|
|
66
76
|
"isolation",
|
|
67
77
|
"pipeline_git",
|
|
@@ -71,8 +81,10 @@ def _load_diagnose_modules():
|
|
|
71
81
|
try:
|
|
72
82
|
from scripts import (
|
|
73
83
|
archive_milestone,
|
|
84
|
+
build_state,
|
|
74
85
|
detect_project,
|
|
75
86
|
discussion_records,
|
|
87
|
+
discussion_validate,
|
|
76
88
|
integration,
|
|
77
89
|
isolation,
|
|
78
90
|
pipeline_git,
|
|
@@ -80,8 +92,10 @@ def _load_diagnose_modules():
|
|
|
80
92
|
)
|
|
81
93
|
return (
|
|
82
94
|
archive_milestone,
|
|
95
|
+
build_state,
|
|
83
96
|
detect_project,
|
|
84
97
|
discussion_records,
|
|
98
|
+
discussion_validate,
|
|
85
99
|
integration,
|
|
86
100
|
isolation,
|
|
87
101
|
pipeline_git,
|
|
@@ -91,8 +105,10 @@ def _load_diagnose_modules():
|
|
|
91
105
|
if error.name not in {
|
|
92
106
|
"scripts",
|
|
93
107
|
"scripts.archive_milestone",
|
|
108
|
+
"scripts.build_state",
|
|
94
109
|
"scripts.detect_project",
|
|
95
110
|
"scripts.discussion_records",
|
|
111
|
+
"scripts.discussion_validate",
|
|
96
112
|
"scripts.integration",
|
|
97
113
|
"scripts.isolation",
|
|
98
114
|
"scripts.pipeline_git",
|
|
@@ -109,16 +125,20 @@ def _load_diagnose_modules():
|
|
|
109
125
|
shared = Path(__file__).resolve().parents[2] / "gsd-path" / "scripts"
|
|
110
126
|
sys.path.insert(0, str(shared))
|
|
111
127
|
import archive_milestone
|
|
128
|
+
import build_state
|
|
112
129
|
import detect_project
|
|
113
130
|
import discussion_records
|
|
131
|
+
import discussion_validate
|
|
114
132
|
import integration
|
|
115
133
|
import isolation
|
|
116
134
|
import pipeline_git
|
|
117
135
|
import pipeline_state
|
|
118
136
|
return (
|
|
119
137
|
archive_milestone,
|
|
138
|
+
build_state,
|
|
120
139
|
detect_project,
|
|
121
140
|
discussion_records,
|
|
141
|
+
discussion_validate,
|
|
122
142
|
integration,
|
|
123
143
|
isolation,
|
|
124
144
|
pipeline_git,
|
|
@@ -128,8 +148,10 @@ def _load_diagnose_modules():
|
|
|
128
148
|
|
|
129
149
|
(
|
|
130
150
|
archive_milestone,
|
|
151
|
+
build_state,
|
|
131
152
|
detect_project,
|
|
132
153
|
discussion_records,
|
|
154
|
+
discussion_validate,
|
|
133
155
|
integration,
|
|
134
156
|
isolation,
|
|
135
157
|
pipeline_git,
|
|
@@ -137,6 +159,9 @@ def _load_diagnose_modules():
|
|
|
137
159
|
) = _load_diagnose_modules()
|
|
138
160
|
DetectError = detect_project.DetectError
|
|
139
161
|
classify = detect_project.classify
|
|
162
|
+
BuildStateError = build_state.BuildStateError
|
|
163
|
+
build_ready = build_state.ready
|
|
164
|
+
review_cycle_counts = discussion_validate.review_cycle_counts
|
|
140
165
|
IsolationError = isolation.IsolationError
|
|
141
166
|
_registered_worktrees = isolation._registered_worktrees
|
|
142
167
|
_worktree_report = isolation._worktree_report
|
|
@@ -231,7 +256,15 @@ def _state_template() -> Path:
|
|
|
231
256
|
|
|
232
257
|
|
|
233
258
|
def _command(module: object, *arguments: object) -> str:
|
|
234
|
-
|
|
259
|
+
return _script_command(Path(str(getattr(module, "__file__"))), *arguments)
|
|
260
|
+
|
|
261
|
+
|
|
262
|
+
def _dispatch_command(*arguments: object) -> str:
|
|
263
|
+
return _script_command(Path(str(build_state.__file__)).with_name("dispatch_driver.py"), *arguments)
|
|
264
|
+
|
|
265
|
+
|
|
266
|
+
def _script_command(path: Path, *arguments: object) -> str:
|
|
267
|
+
script = path.resolve()
|
|
235
268
|
def text(value: object) -> str:
|
|
236
269
|
# Agents paste this into Git Bash on Windows, where unquoted
|
|
237
270
|
# backslashes are escapes; forward slashes need no quoting.
|
|
@@ -354,6 +387,74 @@ def _leftover_worktrees(repo: Path) -> list[dict[str, object]]:
|
|
|
354
387
|
return leftovers
|
|
355
388
|
|
|
356
389
|
|
|
390
|
+
def _build_completion_findings(repo: Path) -> list[dict[str, str]]:
|
|
391
|
+
"""Mirror dispatch complete's build gates so a completion refusal is diagnosable.
|
|
392
|
+
|
|
393
|
+
Runs the same checks `dispatch_driver.py complete` runs, under the same
|
|
394
|
+
precondition (every wave's tasks done, no blocked review holding them), and
|
|
395
|
+
names the review set or typed build state that complete would refuse on.
|
|
396
|
+
"""
|
|
397
|
+
try:
|
|
398
|
+
ready_result = build_ready(str(repo), allow_done=True)
|
|
399
|
+
except BuildStateError as error:
|
|
400
|
+
return [
|
|
401
|
+
_finding(
|
|
402
|
+
"review-cycles",
|
|
403
|
+
"stuck",
|
|
404
|
+
f"{error.code}: {error}",
|
|
405
|
+
_dispatch_command("complete", "--repo", repo)
|
|
406
|
+
+ " # reports the same typed blocker; follow its recovery (reconcile "
|
|
407
|
+
"failed or blocked tasks, resolve a dependency deadlock before new dispatch)",
|
|
408
|
+
)
|
|
409
|
+
]
|
|
410
|
+
blocked = ready_result.get("blocked_review")
|
|
411
|
+
if blocked:
|
|
412
|
+
wave, cycle = blocked["wave"], blocked["cycle"]
|
|
413
|
+
waiting = ", ".join(
|
|
414
|
+
str(item) for item in blocked.get("waiting_waves") or []
|
|
415
|
+
)
|
|
416
|
+
if any((repo / ".project" / "tasks").glob(f"*-fix-wave-{wave}-cycle-{cycle}.md")):
|
|
417
|
+
retry = (
|
|
418
|
+
_dispatch_command("review", "--repo", repo, "--wave", wave, "--cycle", cycle + 1)
|
|
419
|
+
+ " # add the owner's --child-command; the source wave's next review cycle "
|
|
420
|
+
"in full at the current HEAD; "
|
|
421
|
+
"pass --repair-evidence when review_findings.py repair-evidence accepts a landed repair"
|
|
422
|
+
)
|
|
423
|
+
else:
|
|
424
|
+
retry = (
|
|
425
|
+
_dispatch_command("fix-tasks", "--repo", repo, "--wave", wave, "--cycle", cycle)
|
|
426
|
+
+ " # then land the repair wave; the source wave's next review cycle judges it"
|
|
427
|
+
)
|
|
428
|
+
return [
|
|
429
|
+
_finding(
|
|
430
|
+
"review-cycles",
|
|
431
|
+
"stuck",
|
|
432
|
+
f"wave {wave} review cycle {cycle} is blocked "
|
|
433
|
+
+ (f"and holds waiting waves {waiting}" if waiting else "and holds completion"),
|
|
434
|
+
retry,
|
|
435
|
+
)
|
|
436
|
+
]
|
|
437
|
+
if ready_result.get("current_wave") is not None:
|
|
438
|
+
return []
|
|
439
|
+
try:
|
|
440
|
+
review_cycle_counts(repo / ".project")
|
|
441
|
+
except archive_milestone.ArchiveError as error:
|
|
442
|
+
return [
|
|
443
|
+
_finding(
|
|
444
|
+
"review-cycles",
|
|
445
|
+
"stuck",
|
|
446
|
+
str(error),
|
|
447
|
+
"compare each PLAN.md wave with the .project/review/wave-N.cycleC.md "
|
|
448
|
+
"files, then run the missing cycle's review: "
|
|
449
|
+
+ _dispatch_command("review", "--repo", repo)
|
|
450
|
+
+ " with that wave's --wave and --cycle and the owner's --child-command; "
|
|
451
|
+
"a repair wave is judged by "
|
|
452
|
+
"its source wave's next cycle",
|
|
453
|
+
)
|
|
454
|
+
]
|
|
455
|
+
return []
|
|
456
|
+
|
|
457
|
+
|
|
357
458
|
def diagnose(repo: Path) -> dict[str, object]:
|
|
358
459
|
resolved = repo.resolve()
|
|
359
460
|
if not resolved.is_dir():
|
|
@@ -567,6 +668,8 @@ def diagnose(repo: Path) -> dict[str, object]:
|
|
|
567
668
|
"$gsd-path-ship",
|
|
568
669
|
)
|
|
569
670
|
)
|
|
671
|
+
elif state.get("phase") == "build" and state.get("status") in {"active", "done"}:
|
|
672
|
+
findings.extend(_build_completion_findings(resolved))
|
|
570
673
|
|
|
571
674
|
undo_transaction = by_name["undo-transaction"]
|
|
572
675
|
route_action = (
|
|
@@ -1407,6 +1407,12 @@ def _route_state(repo: Path, project_dir: str = ".project") -> dict[str, object]
|
|
|
1407
1407
|
mode="milestone" if status == "done" else "normal",
|
|
1408
1408
|
reason=f"state is {phase}/{status}",
|
|
1409
1409
|
)
|
|
1410
|
+
if phase == "plan" and status == "blocked":
|
|
1411
|
+
return _route_result(
|
|
1412
|
+
state,
|
|
1413
|
+
"block",
|
|
1414
|
+
reason="plan is blocked; resolve the correction or user decision named by the plan gate before resuming",
|
|
1415
|
+
)
|
|
1410
1416
|
if phase == "plan" and status != "done":
|
|
1411
1417
|
return _route_result(state, "run-phase", phase="plan", reason=f"state is {phase}/{status}")
|
|
1412
1418
|
if phase == "plan":
|
|
@@ -2373,9 +2379,9 @@ def main(argv: Optional[Sequence[str]] = None) -> int:
|
|
|
2373
2379
|
args.project_dir,
|
|
2374
2380
|
)
|
|
2375
2381
|
elif args.command == "approve" and (args.defer_checkpoint or args.patch):
|
|
2376
|
-
if args.expected_head is not None:
|
|
2382
|
+
if args.defer_checkpoint and args.expected_head is not None:
|
|
2377
2383
|
raise PipelineStateError(
|
|
2378
|
-
"--expected-head is not accepted with --defer-checkpoint
|
|
2384
|
+
"--expected-head is not accepted with --defer-checkpoint"
|
|
2379
2385
|
)
|
|
2380
2386
|
result = state_checkpoint.defer_approval(
|
|
2381
2387
|
args.repo,
|
|
@@ -2384,6 +2390,8 @@ def main(argv: Optional[Sequence[str]] = None) -> int:
|
|
|
2384
2390
|
args.milestone,
|
|
2385
2391
|
args.patch,
|
|
2386
2392
|
)
|
|
2393
|
+
if args.expected_head is not None:
|
|
2394
|
+
result["expected_head_accepted"] = args.expected_head
|
|
2387
2395
|
elif args.command == "approve":
|
|
2388
2396
|
if args.expected_head is None:
|
|
2389
2397
|
raise PipelineStateError(
|
package/scripts/runtime_store.py
CHANGED
|
@@ -7,6 +7,7 @@ import os
|
|
|
7
7
|
from pathlib import Path
|
|
8
8
|
import shutil
|
|
9
9
|
import shlex
|
|
10
|
+
import sys
|
|
10
11
|
import tempfile
|
|
11
12
|
import time
|
|
12
13
|
from contextlib import contextmanager
|
|
@@ -74,7 +75,7 @@ def guard_launcher(name):
|
|
|
74
75
|
f" raise SystemExit({2 if name == 'guard_hook.py' else 1})\n")
|
|
75
76
|
|
|
76
77
|
|
|
77
|
-
def source_manifest(source):
|
|
78
|
+
def source_manifest(source, successors=()):
|
|
78
79
|
version = json.loads((source / "package.json").read_text(encoding="utf-8"))["version"]
|
|
79
80
|
files = {}
|
|
80
81
|
for name in (*RUNTIME_FILES, *GUARDS, "status_runtime.py"):
|
|
@@ -83,6 +84,13 @@ def source_manifest(source):
|
|
|
83
84
|
raise ValueError(f"symlinked runtime source: {path}")
|
|
84
85
|
files[name] = hashlib.sha256(path.read_bytes()).hexdigest()
|
|
85
86
|
manifest = {"version": version, "files": files}
|
|
87
|
+
if successors:
|
|
88
|
+
# #468 option (i): the successors list joins the manifest BEFORE the
|
|
89
|
+
# digest is taken, so the published runtime's own bytes bless exactly
|
|
90
|
+
# these successor digests. An unnamed publish keeps the stock
|
|
91
|
+
# manifest — identical sources still digest identically.
|
|
92
|
+
manifest["successors"] = list(successors)
|
|
93
|
+
status_runtime.manifest_successors(manifest)
|
|
86
94
|
pin = {"schema": status_runtime.RUNTIME_SCHEMA, "version": version,
|
|
87
95
|
"digest": status_runtime.manifest_digest(manifest)}
|
|
88
96
|
return manifest, pin
|
|
@@ -114,9 +122,33 @@ def publication_lock(parent, digest):
|
|
|
114
122
|
os.close(descriptor)
|
|
115
123
|
|
|
116
124
|
|
|
117
|
-
def
|
|
118
|
-
|
|
125
|
+
def _blessed_store_entry(expected, source_manifest_dict) -> bool:
|
|
126
|
+
"""True when the expected runtime is in the store, hash-valid, stamped with
|
|
127
|
+
successors, and built from the SAME version and files as the supplied
|
|
128
|
+
source — only the stamp may differ (#468). A wrong package never restores,
|
|
129
|
+
even for a blessed pin."""
|
|
130
|
+
try:
|
|
131
|
+
root = status_runtime.validate_runtime(expected)
|
|
132
|
+
manifest = json.loads((root / "manifest.json").read_text(encoding="utf-8"))
|
|
133
|
+
return bool(
|
|
134
|
+
status_runtime.manifest_successors(manifest)
|
|
135
|
+
and manifest.get("version") == source_manifest_dict.get("version")
|
|
136
|
+
and manifest.get("files") == source_manifest_dict.get("files")
|
|
137
|
+
)
|
|
138
|
+
except (OSError, ValueError, TypeError):
|
|
139
|
+
return False
|
|
140
|
+
|
|
141
|
+
|
|
142
|
+
def publish(source, *, expected=None, dry_run=False, repair=False, successors=()):
|
|
143
|
+
manifest, pin = source_manifest(source, successors)
|
|
119
144
|
if expected is not None and any(pin[key] != expected.get(key) for key in pin):
|
|
145
|
+
# A blessed pin's digest covers its successors stamp, so an unstamped
|
|
146
|
+
# source cannot reproduce it. When the blessed entry itself is in the
|
|
147
|
+
# store, hash-valid, stamped, and built from the same version and
|
|
148
|
+
# files as this source, restore returns those exact bytes; every
|
|
149
|
+
# other mismatch stays the caller's error.
|
|
150
|
+
if _blessed_store_entry(expected, manifest):
|
|
151
|
+
return expected
|
|
120
152
|
raise ValueError(f"source does not match declared runtime {expected['version']} ({expected['digest']}); use its matching package")
|
|
121
153
|
if dry_run:
|
|
122
154
|
return pin
|
|
@@ -336,3 +368,56 @@ def operate(source, project, action, *, dry_run=False, provenance=None):
|
|
|
336
368
|
raise
|
|
337
369
|
journal.unlink()
|
|
338
370
|
return {**pin, "backup": str(backup)} if backup else pin
|
|
371
|
+
|
|
372
|
+
|
|
373
|
+
def main(argv=None) -> int:
|
|
374
|
+
import argparse
|
|
375
|
+
|
|
376
|
+
parser = argparse.ArgumentParser(
|
|
377
|
+
description="Content-addressed runtime store operations. Publication only adds a "
|
|
378
|
+
"store entry: it never selects a runtime for, or writes to, any project.")
|
|
379
|
+
subcommands = parser.add_subparsers(dest="command", required=True)
|
|
380
|
+
publish_parser = subcommands.add_parser(
|
|
381
|
+
"publish", help="publish and verify a runtime without touching any project pin")
|
|
382
|
+
publish_parser.add_argument("--source", required=True,
|
|
383
|
+
help="runtime source root (holds package.json and scripts/)")
|
|
384
|
+
publish_parser.add_argument("--expected-version",
|
|
385
|
+
help="with --expected-digest: refuse a source that differs")
|
|
386
|
+
publish_parser.add_argument("--expected-digest",
|
|
387
|
+
help="with --expected-version: refuse a source that differs")
|
|
388
|
+
publish_parser.add_argument("--provenance", default=None,
|
|
389
|
+
help="provenance note echoed in the output; the store never keeps it")
|
|
390
|
+
publish_parser.add_argument("--dry-run", action="store_true",
|
|
391
|
+
help="compute the identity without writing to the store")
|
|
392
|
+
publish_parser.add_argument("--repair", action="store_true",
|
|
393
|
+
help="replace a store entry that fails verification")
|
|
394
|
+
publish_parser.add_argument("--name-successor", action="append", default=[], metavar="DIGEST",
|
|
395
|
+
help="record a successor runtime digest in this runtime's hashed "
|
|
396
|
+
"manifest (#468 option (i)); publish the successor first, then "
|
|
397
|
+
"name its digest here — repeatable for several successors")
|
|
398
|
+
arguments = parser.parse_args(argv)
|
|
399
|
+
source = Path(arguments.source).resolve()
|
|
400
|
+
if (arguments.expected_version is None) != (arguments.expected_digest is None):
|
|
401
|
+
parser.error("--expected-version and --expected-digest are given together or not at all")
|
|
402
|
+
expected = None
|
|
403
|
+
if arguments.expected_version is not None:
|
|
404
|
+
if not arguments.expected_version or not arguments.expected_digest:
|
|
405
|
+
parser.error("--expected-version and --expected-digest must be non-empty")
|
|
406
|
+
expected = {"schema": status_runtime.RUNTIME_SCHEMA,
|
|
407
|
+
"version": arguments.expected_version,
|
|
408
|
+
"digest": arguments.expected_digest}
|
|
409
|
+
try:
|
|
410
|
+
pin = publish(source, expected=expected,
|
|
411
|
+
dry_run=arguments.dry_run, repair=arguments.repair,
|
|
412
|
+
successors=arguments.name_successor)
|
|
413
|
+
path = None if arguments.dry_run else str(status_runtime.validate_runtime(pin))
|
|
414
|
+
except (ValueError, OSError) as error:
|
|
415
|
+
print(f"publish failed: {error}", file=sys.stderr)
|
|
416
|
+
return 1
|
|
417
|
+
result = {**pin, "path": path, "provenance": arguments.provenance}
|
|
418
|
+
print(json.dumps(result, indent=2, sort_keys=True))
|
|
419
|
+
return 0
|
|
420
|
+
|
|
421
|
+
|
|
422
|
+
if __name__ == "__main__":
|
|
423
|
+
sys.exit(main())
|
|
@@ -458,6 +458,21 @@ def _validate_plan_briefs(repo: Path, kind: str, project_dir: str) -> None:
|
|
|
458
458
|
if head.returncode != 0:
|
|
459
459
|
# Pre-Git approval defers base-dependent checks to build.
|
|
460
460
|
return
|
|
461
|
+
if project_dir == ".project":
|
|
462
|
+
# Unsupported .project entries must surface at approval, before build,
|
|
463
|
+
# where removing them is cheapest (#466). Like the checkpoint, judge
|
|
464
|
+
# each git-visible entry by whether it still exists in the worktree.
|
|
465
|
+
project = repo / ".project"
|
|
466
|
+
unsupported = sorted(
|
|
467
|
+
name
|
|
468
|
+
for name in pipeline_state._common.git_visible_entries(project) - PROJECT_ENTRIES
|
|
469
|
+
if (project / name).exists() or (project / name).is_symlink()
|
|
470
|
+
)
|
|
471
|
+
if unsupported:
|
|
472
|
+
raise PipelineStateError(
|
|
473
|
+
"unsupported .project artifacts: " + ", ".join(unsupported)
|
|
474
|
+
+ "; remove or relocate them before approving the plan"
|
|
475
|
+
)
|
|
461
476
|
if __package__:
|
|
462
477
|
from .check_task_briefs import BriefError, _member_bases, plan_validation_bases, validate_task_briefs
|
|
463
478
|
else:
|
|
@@ -128,7 +128,76 @@ def manifest_digest(manifest: dict) -> str:
|
|
|
128
128
|
return hashlib.sha256(json.dumps(manifest, sort_keys=True, separators=(",", ":")).encode()).hexdigest()
|
|
129
129
|
|
|
130
130
|
|
|
131
|
+
def manifest_successors(manifest: dict) -> tuple:
|
|
132
|
+
"""The validated successor digests a runtime's hashed manifest names (#468).
|
|
133
|
+
|
|
134
|
+
`successors` is an optional list of unique 64-hex lowercase store digests.
|
|
135
|
+
It lives inside the manifest, so the runtime's own digest covers it: a
|
|
136
|
+
runtime can only bless successors its hashed bytes name. A malformed
|
|
137
|
+
field makes the whole runtime invalid — acceptance never widens on a
|
|
138
|
+
shape the store cannot prove.
|
|
139
|
+
"""
|
|
140
|
+
successors = manifest.get("successors", [])
|
|
141
|
+
if (not isinstance(successors, list)
|
|
142
|
+
or not all(
|
|
143
|
+
isinstance(item, str) and len(item) == 64
|
|
144
|
+
and all(char in "0123456789abcdef" for char in item)
|
|
145
|
+
for item in successors
|
|
146
|
+
)
|
|
147
|
+
or len(set(successors)) != len(successors)):
|
|
148
|
+
raise ValueError("invalid manifest successors")
|
|
149
|
+
return tuple(successors)
|
|
150
|
+
|
|
151
|
+
|
|
152
|
+
def accepted_runtime_dirs(repo: Path) -> set:
|
|
153
|
+
"""The pinned runtime's directory plus every validated successor (#468).
|
|
154
|
+
|
|
155
|
+
Owner-ruled option (i) on #468: acceptance after ship is anchored in the
|
|
156
|
+
pinned runtime's own hashed manifest. It names successor digests; each
|
|
157
|
+
hop is validated exactly like the pin and may name further successors.
|
|
158
|
+
A named entry that is missing or fails validation simply does not widen
|
|
159
|
+
the set — the pinned runtime keeps working — and a visited set ends
|
|
160
|
+
blessed cycles. Nothing outside the pin's hashed reach is ever accepted.
|
|
161
|
+
"""
|
|
162
|
+
pin = declaration(repo)
|
|
163
|
+
pin_root, pin_manifest = _validated_manifest(pin)
|
|
164
|
+
accepted = {pin_root.resolve()}
|
|
165
|
+
seen = {pin["digest"]}
|
|
166
|
+
pending = list(manifest_successors(pin_manifest))
|
|
167
|
+
while pending:
|
|
168
|
+
digest = pending.pop()
|
|
169
|
+
if digest in seen:
|
|
170
|
+
continue
|
|
171
|
+
seen.add(digest)
|
|
172
|
+
try:
|
|
173
|
+
# version None: the hop's trust anchor is the digest the pinned
|
|
174
|
+
# bytes named, never a version claim. The successors followed
|
|
175
|
+
# come from the same manifest read whose digest was proven —
|
|
176
|
+
# a mid-walk entry swap cannot smuggle a different list.
|
|
177
|
+
root, manifest = _validated_manifest(
|
|
178
|
+
{"schema": RUNTIME_SCHEMA, "version": None, "digest": digest}
|
|
179
|
+
)
|
|
180
|
+
named = manifest_successors(manifest)
|
|
181
|
+
except (OSError, ValueError, TypeError):
|
|
182
|
+
continue
|
|
183
|
+
pending.extend(named)
|
|
184
|
+
accepted.add(root.resolve())
|
|
185
|
+
return accepted
|
|
186
|
+
|
|
187
|
+
|
|
131
188
|
def validate_runtime(data: dict) -> Path:
|
|
189
|
+
return _validated_manifest(data)[0]
|
|
190
|
+
|
|
191
|
+
|
|
192
|
+
def _validated_manifest(data: dict) -> tuple:
|
|
193
|
+
"""validate_runtime, returning the exact manifest whose digest was proven.
|
|
194
|
+
|
|
195
|
+
Callers that act on manifest content (the #468 successor walk) must use
|
|
196
|
+
this returned object, never a second read of manifest.json: a concurrent
|
|
197
|
+
swap between reads could otherwise validate one manifest while following
|
|
198
|
+
successors from another. `version` None anchors on the digest alone —
|
|
199
|
+
used by the walk for hops whose version no declaration supplies.
|
|
200
|
+
"""
|
|
132
201
|
root = runtime_home() / data["digest"]
|
|
133
202
|
hint = (f"Runtime {data['version']} ({data['digest']}) is unavailable or invalid at {root}. "
|
|
134
203
|
"Run gsd-path --runtime-restore --project <project-root> "
|
|
@@ -141,7 +210,8 @@ def validate_runtime(data: dict) -> Path:
|
|
|
141
210
|
raise ValueError("symlinked manifest")
|
|
142
211
|
manifest = json.loads(manifest_path.read_text(encoding="utf-8"))
|
|
143
212
|
if (not isinstance(manifest, dict) or manifest_digest(manifest) != data["digest"]
|
|
144
|
-
or
|
|
213
|
+
or (data["version"] is not None
|
|
214
|
+
and manifest.get("version") != data["version"])):
|
|
145
215
|
raise ValueError("manifest identity mismatch")
|
|
146
216
|
files = manifest.get("files")
|
|
147
217
|
if not isinstance(files, dict) or "pipeline_state.py" not in files:
|
|
@@ -164,9 +234,10 @@ def validate_runtime(data: dict) -> Path:
|
|
|
164
234
|
runtime_entries.add(path.name)
|
|
165
235
|
if runtime_entries != set(files) | {"manifest.json"}:
|
|
166
236
|
raise ValueError("unexpected runtime files")
|
|
237
|
+
manifest_successors(manifest)
|
|
167
238
|
except (OSError, ValueError, TypeError) as error:
|
|
168
239
|
raise ValueError(hint + f" ({error})") from error
|
|
169
|
-
return root
|
|
240
|
+
return root, manifest
|
|
170
241
|
|
|
171
242
|
|
|
172
243
|
def resolve_runtime(repo: Path) -> Path:
|