@ikon85/agent-workflow-kit 0.38.0 → 0.40.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/.agents/skills/grill-me/SKILL.md +1 -1
- package/.agents/skills/grill-with-docs/SKILL.md +1 -1
- package/.agents/skills/kit-update/SKILL.md +33 -1
- package/.agents/skills/orchestrate-wave/SKILL.md +4 -4
- package/.agents/skills/setup-workflow/SKILL.md +84 -3
- package/.agents/skills/setup-workflow/board-sync.md +6 -2
- package/.agents/skills/setup-workflow/workflow-advisories.md +34 -0
- package/.agents/skills/setup-workflow/worktree-lifecycle.md +54 -3
- package/.agents/skills/wrapup/SKILL.md +24 -0
- package/.claude/hooks/drift-guard.py +212 -21
- package/.claude/skills/grill-me/SKILL.md +1 -1
- package/.claude/skills/grill-with-docs/SKILL.md +1 -1
- package/.claude/skills/kit-update/SKILL.md +33 -1
- package/.claude/skills/orchestrate-wave/SKILL.md +4 -4
- package/.claude/skills/setup-workflow/SKILL.md +84 -3
- package/.claude/skills/setup-workflow/board-sync.md +6 -2
- package/.claude/skills/setup-workflow/workflow-advisories.md +34 -0
- package/.claude/skills/setup-workflow/worktree-lifecycle.md +54 -3
- package/.claude/skills/wrapup/SKILL.md +24 -0
- package/README.md +62 -0
- package/agent-workflow-kit.package.json +57 -25
- package/docs/adr/0008-planning-ignore-rules-are-offered-never-installed.md +84 -0
- package/package.json +1 -1
- package/scripts/board_bootstrap.py +367 -0
- package/scripts/kit-update-pr.mjs +20 -7
- package/scripts/kit-update-pr.test.mjs +29 -0
- package/scripts/profile_globs.py +347 -0
- package/scripts/test_board_bootstrap.py +348 -0
- package/scripts/test_drift_guard_diagnostics.py +295 -0
- package/scripts/test_profile_globs.py +280 -0
- package/scripts/test_skill_setup_workflow_seeds.py +87 -0
- package/scripts/test_worktree_ignore_seed.py +320 -0
- package/scripts/test_worktree_wrapup_contract.py +588 -0
- package/scripts/workflow-advisories/core.py +29 -4
- package/scripts/worktree-lifecycle/README.md +53 -4
- package/scripts/worktree-lifecycle/core.py +211 -60
- package/scripts/worktree-lifecycle/ignore_seed.py +226 -0
- package/scripts/worktree-lifecycle/plan-artifacts.json +37 -0
- package/scripts/wrapup-land.py +179 -34
- package/src/cli.mjs +32 -1
- package/src/commands/update.mjs +30 -21
- package/src/consumer-migrations.json +19 -0
- package/src/lib/bundle.mjs +16 -0
- package/src/lib/consumerMigrations.mjs +161 -0
|
@@ -24,11 +24,25 @@ they do not carry a second branch regex, worktree traversal, or failure policy.
|
|
|
24
24
|
deletion authority, so setup never derives it from `.gitignore` alone and
|
|
25
25
|
update never installs a universal default.
|
|
26
26
|
|
|
27
|
-
Profile globs use repository-relative POSIX
|
|
28
|
-
|
|
29
|
-
|
|
27
|
+
Profile globs use one repository-relative POSIX dialect, implemented once in
|
|
28
|
+
`scripts/profile_globs.py` and loaded by this core and by
|
|
29
|
+
`scripts/workflow-advisories/core.py`. `*` and `?` stay inside one path
|
|
30
|
+
segment; `[seq]`/`[!seq]` are per-segment character classes; `**` as a whole
|
|
31
|
+
segment matches zero or more segments, so a leading `**/` also matches the
|
|
32
|
+
repository root and `dir/**` also matches `dir` itself; matching is always
|
|
33
|
+
case-sensitive on every host filesystem; and a pattern must match the whole
|
|
34
|
+
path. For example, `**/__pycache__/**` matches both root and nested caches,
|
|
30
35
|
while `dist-kit/*` does not match `dist-kit/a/b`.
|
|
31
36
|
|
|
37
|
+
Because both capabilities load the same matcher, a pattern can never select one
|
|
38
|
+
set of paths for an advisory and a different set for a deletion decision. Run
|
|
39
|
+
`python3 scripts/profile_globs.py <profile.json>` to review an installed
|
|
40
|
+
profile: it names every pattern whose match set narrows or widens against that
|
|
41
|
+
key's legacy matcher, prints the witness path proving it, marks the keys that
|
|
42
|
+
carry deletion authority, and exits 1 when anything needs review. It reads the
|
|
43
|
+
profile and never rewrites a pattern, so a migration cannot silently expand
|
|
44
|
+
cleanup authority.
|
|
45
|
+
|
|
32
46
|
Unknown or malformed events fail open without changing repository state.
|
|
33
47
|
Security-sensitive, profile-matched edits and commands fail closed only when
|
|
34
48
|
the core proves the target is unsafe.
|
|
@@ -44,6 +58,24 @@ the core proves the target is unsafe.
|
|
|
44
58
|
| `enforce-worktree-discipline.py` | PreToolUse | routes issue-branch creation through the configured setup entry |
|
|
45
59
|
| `slice-handoff-hint.py` | UserPromptSubmit | names the configured setup entry for a defined slice |
|
|
46
60
|
|
|
61
|
+
## Planning-artifact ignore rules
|
|
62
|
+
|
|
63
|
+
`plan-artifacts.json` is the kit-owned declaration of the planning artefacts the
|
|
64
|
+
shipped skills write (`PLAN.md`, `PLAN-REVIEW-LOG.md`, `ANNAHMEN.md`).
|
|
65
|
+
`ignore_seed.py` turns that declaration into an offer, never an installation:
|
|
66
|
+
`preview` reports what an approval would append and writes nothing, and `apply`
|
|
67
|
+
appends exactly one marker block. `.gitignore` is a consumer file the kit does
|
|
68
|
+
not own, so only an explicit approved `/setup-workflow` step may run `apply`;
|
|
69
|
+
`init` and `update` reconciliation never reach it (ADR 0008).
|
|
70
|
+
|
|
71
|
+
The append never rewrites, reorders, or removes an existing line. A repository
|
|
72
|
+
that already ignores every artefact reports `nothing-to-do`, a re-run after an
|
|
73
|
+
approval is a byte-identical no-op, and a marker block the consumer has since
|
|
74
|
+
edited reports `blocked` rather than being repaired. An artefact already tracked
|
|
75
|
+
in git is named separately — an ignore rule cannot untrack it, and the helper
|
|
76
|
+
never runs `git rm`. Only after such an approval does the `scratchPatterns`
|
|
77
|
+
derivation have real ignored planning artefacts to read.
|
|
78
|
+
|
|
47
79
|
## Cleanup
|
|
48
80
|
|
|
49
81
|
`cleanup.py` previews by default. Removal refuses protected, tracked-dirty,
|
|
@@ -83,6 +115,16 @@ checked against that canonical generator policy. A missing policy is distinct
|
|
|
83
115
|
from an explicit empty policy. An unmerged or transient branch policy therefore
|
|
84
116
|
cannot grant itself broader cleanup authority.
|
|
85
117
|
|
|
118
|
+
Canonical policy that drifts between attempt start and post-merge cleanup keeps
|
|
119
|
+
that refusal, and the refusal names its supported recovery. Recovery re-derives
|
|
120
|
+
authority from the merged canonical policy alone: it never consults the stale
|
|
121
|
+
worktree candidate, never re-scans for new candidates, requires the branch to
|
|
122
|
+
already be an ancestor of canonical main, and requires each frozen identity to
|
|
123
|
+
be both named by canonical policy and unchanged on disk. Evidence outside
|
|
124
|
+
canonical policy, a changed identity, and pre-existing or foreign state stop the
|
|
125
|
+
recovery instead of being deleted, and a repeated run after a completed teardown
|
|
126
|
+
is a no-op.
|
|
127
|
+
|
|
86
128
|
The landing adapter may carry exact scratch evidence only for current ignored
|
|
87
129
|
files that match the consumer-owned
|
|
88
130
|
`wrapup.landingGeneratedArtifactPatterns` profile and were absent from that
|
|
@@ -98,7 +140,14 @@ Landing-start blockers are classified before a journal is written, so moving a
|
|
|
98
140
|
protected blocker permits a clean next attempt. An explicit relinquish archives
|
|
99
141
|
either a started or frozen attempt, including drifted evidence, without
|
|
100
142
|
deleting or claiming any file; the next preflight treats every current file as
|
|
101
|
-
pre-existing. Exact unchanged frozen evidence remains directly resumable.
|
|
143
|
+
pre-existing. Exact unchanged frozen evidence remains directly resumable. The
|
|
144
|
+
attempt journal name is classified without following a symlink, so a symlinked
|
|
145
|
+
or dangling journal entry stops instead of being read or replaced, and each
|
|
146
|
+
archived receipt is filed under a contract-version-neutral stem plus its own
|
|
147
|
+
recorded contract version. A journal written under a superseded contract is
|
|
148
|
+
classified as legacy rather than corrupt when it still satisfies its own
|
|
149
|
+
recorded contract; that refusal names the archive route explicitly, while a
|
|
150
|
+
journal that fails its own contract is still reported as incoherent evidence.
|
|
102
151
|
|
|
103
152
|
`cleanup.py sweep` is the read-only inventory entrypoint. It accounts once for
|
|
104
153
|
every linked worktree and local branch, reports issue/PR/merge/age/removal
|
|
@@ -2,13 +2,14 @@
|
|
|
2
2
|
|
|
3
3
|
from __future__ import annotations
|
|
4
4
|
|
|
5
|
+
import importlib.util
|
|
5
6
|
import json
|
|
6
7
|
import os
|
|
7
8
|
import re
|
|
8
9
|
import stat
|
|
10
|
+
import sys
|
|
9
11
|
from contextlib import contextmanager
|
|
10
12
|
from dataclasses import dataclass, replace
|
|
11
|
-
from fnmatch import fnmatchcase
|
|
12
13
|
from hashlib import sha256
|
|
13
14
|
from pathlib import Path, PurePosixPath
|
|
14
15
|
from time import time
|
|
@@ -29,12 +30,50 @@ _BRANCH_CHANGE_RE = re.compile(r"\b(?:git\s+(?:checkout|switch)|gh\s+pr\s+(?:mer
|
|
|
29
30
|
_BRANCH_CREATE_RE = re.compile(r"\bgit\s+(?:checkout|switch)\s+-[bc]\s+(\S+)")
|
|
30
31
|
ARTIFACT_BASELINE_FILE = "awkit-artifact-baseline-v1.json"
|
|
31
32
|
LANDING_ATTEMPT_FILE = "awkit-landing-attempt-v1.json"
|
|
33
|
+
PROFILE_GLOBS_MODULE = "_agent_workflow_kit_profile_globs"
|
|
34
|
+
|
|
35
|
+
|
|
36
|
+
def load_profile_globs():
|
|
37
|
+
"""Load the one shared repository-relative glob dialect exactly once."""
|
|
38
|
+
module = sys.modules.get(PROFILE_GLOBS_MODULE)
|
|
39
|
+
if module is not None:
|
|
40
|
+
return module
|
|
41
|
+
path = Path(__file__).resolve().parents[1] / "profile_globs.py"
|
|
42
|
+
spec = importlib.util.spec_from_file_location(PROFILE_GLOBS_MODULE, path)
|
|
43
|
+
if spec is None or spec.loader is None:
|
|
44
|
+
raise ImportError(f"cannot load the shared profile glob dialect from {path}")
|
|
45
|
+
module = importlib.util.module_from_spec(spec)
|
|
46
|
+
sys.modules[PROFILE_GLOBS_MODULE] = module
|
|
47
|
+
spec.loader.exec_module(module)
|
|
48
|
+
return module
|
|
49
|
+
|
|
50
|
+
|
|
51
|
+
# Consumer profile globs are matched here exactly as Workflow Advisories
|
|
52
|
+
# matches its own: one dialect, so an advisory and a deletion decision can
|
|
53
|
+
# never disagree about which repository-relative paths a pattern selects.
|
|
54
|
+
path_glob_matches = load_profile_globs().path_glob_matches
|
|
55
|
+
|
|
56
|
+
# Archived receipts are named from a contract-version-neutral stem plus the
|
|
57
|
+
# archived receipt's own contractVersion, so a v2 receipt is never filed as v1.
|
|
58
|
+
LANDING_ATTEMPT_ARCHIVE_STEM = "awkit-landing-attempt"
|
|
59
|
+
LANDING_ATTEMPT_CONTRACT_VERSION = 2
|
|
60
|
+
LANDING_ATTEMPT_KEYS = (
|
|
61
|
+
"contractVersion", "worktree", "branch", "rootDevice",
|
|
62
|
+
"rootInode", "baselineDigest", "generatedFiles",
|
|
63
|
+
"generatedEvidence", "state", "authorizedEvidence",
|
|
64
|
+
"pushSucceeded",
|
|
65
|
+
)
|
|
66
|
+
ABANDON_ATTEMPT_FLAG = "--abandon-unfinished-attempt"
|
|
32
67
|
|
|
33
68
|
|
|
34
69
|
class BaselineBackfillDeferred(LifecycleError):
|
|
35
70
|
"""A safe legacy baseline cannot be captured until consumer state changes."""
|
|
36
71
|
|
|
37
72
|
|
|
73
|
+
class LegacyLandingAttempt(LifecycleError):
|
|
74
|
+
"""A coherent attempt journal predates the active contract and is not adoptable."""
|
|
75
|
+
|
|
76
|
+
|
|
38
77
|
def durable_atomic_json(
|
|
39
78
|
path: Path,
|
|
40
79
|
document: dict[str, Any],
|
|
@@ -88,34 +127,6 @@ def durable_replace(source: Path, destination: Path, *, label: str) -> None:
|
|
|
88
127
|
raise LifecycleError(f"cannot {label}: {error}") from error
|
|
89
128
|
|
|
90
129
|
|
|
91
|
-
def path_glob_matches(path: str, pattern: str) -> bool:
|
|
92
|
-
"""Match POSIX path segments while retaining fnmatch bracket compatibility."""
|
|
93
|
-
path_parts = PurePosixPath(path).parts
|
|
94
|
-
pattern_parts = PurePosixPath(pattern).parts
|
|
95
|
-
memo: dict[tuple[int, int], bool] = {}
|
|
96
|
-
|
|
97
|
-
def matches(path_index: int, pattern_index: int) -> bool:
|
|
98
|
-
key = (path_index, pattern_index)
|
|
99
|
-
if key in memo:
|
|
100
|
-
return memo[key]
|
|
101
|
-
if pattern_index == len(pattern_parts):
|
|
102
|
-
result = path_index == len(path_parts)
|
|
103
|
-
elif pattern_parts[pattern_index] == "**":
|
|
104
|
-
result = matches(path_index, pattern_index + 1) or (
|
|
105
|
-
path_index < len(path_parts)
|
|
106
|
-
and matches(path_index + 1, pattern_index)
|
|
107
|
-
)
|
|
108
|
-
else:
|
|
109
|
-
result = (
|
|
110
|
-
path_index < len(path_parts)
|
|
111
|
-
and fnmatchcase(path_parts[path_index], pattern_parts[pattern_index])
|
|
112
|
-
and matches(path_index + 1, pattern_index + 1)
|
|
113
|
-
)
|
|
114
|
-
memo[key] = result
|
|
115
|
-
return result
|
|
116
|
-
|
|
117
|
-
return matches(0, 0)
|
|
118
|
-
|
|
119
130
|
@dataclass(frozen=True)
|
|
120
131
|
class RepoFacts:
|
|
121
132
|
root: Path
|
|
@@ -209,6 +220,30 @@ def artifact_baseline_path(worktree: Path) -> Path:
|
|
|
209
220
|
return git_dir / ARTIFACT_BASELINE_FILE
|
|
210
221
|
|
|
211
222
|
|
|
223
|
+
def landing_attempt_path(worktree: Path) -> Path:
|
|
224
|
+
"""Return the one landing-attempt journal path beside the artifact baseline."""
|
|
225
|
+
return artifact_baseline_path(worktree).with_name(LANDING_ATTEMPT_FILE)
|
|
226
|
+
|
|
227
|
+
|
|
228
|
+
def landing_attempt_exists(path: Path) -> bool:
|
|
229
|
+
"""Classify journal presence without following a symlink at that name."""
|
|
230
|
+
return os.path.lexists(path)
|
|
231
|
+
|
|
232
|
+
|
|
233
|
+
def require_regular_landing_attempt(path: Path) -> None:
|
|
234
|
+
"""Refuse a journal name occupied by a symlink or any non-regular entry."""
|
|
235
|
+
if path.is_symlink() or not path.is_file():
|
|
236
|
+
raise LifecycleError("landing-attempt provenance is not a regular file")
|
|
237
|
+
|
|
238
|
+
|
|
239
|
+
def landing_attempt_keys(contract_version: Any) -> list[str]:
|
|
240
|
+
"""Return the exact journal key set recorded by one contract version."""
|
|
241
|
+
keys = list(LANDING_ATTEMPT_KEYS)
|
|
242
|
+
if contract_version == LANDING_ATTEMPT_CONTRACT_VERSION:
|
|
243
|
+
keys.append("policyDigest")
|
|
244
|
+
return keys
|
|
245
|
+
|
|
246
|
+
|
|
212
247
|
def _baseline_payload(
|
|
213
248
|
*,
|
|
214
249
|
worktree: Path,
|
|
@@ -395,8 +430,7 @@ def ensure_artifact_baseline(
|
|
|
395
430
|
path = artifact_baseline_path(worktree)
|
|
396
431
|
if os.path.lexists(path):
|
|
397
432
|
return load_artifact_baseline(worktree)
|
|
398
|
-
|
|
399
|
-
if os.path.lexists(attempt_path):
|
|
433
|
+
if landing_attempt_exists(landing_attempt_path(worktree)):
|
|
400
434
|
raise LifecycleError(
|
|
401
435
|
"artifact provenance baseline is missing while a landing attempt exists"
|
|
402
436
|
)
|
|
@@ -447,6 +481,36 @@ def verified_landing_scratch_files(
|
|
|
447
481
|
)
|
|
448
482
|
|
|
449
483
|
|
|
484
|
+
def _classify_superseded_landing_attempt(
|
|
485
|
+
payload: dict[str, Any],
|
|
486
|
+
digest: Any,
|
|
487
|
+
baseline: ArtifactBaseline,
|
|
488
|
+
worktree: Path,
|
|
489
|
+
) -> None:
|
|
490
|
+
"""Separate a coherent pre-upgrade journal from genuinely corrupt evidence.
|
|
491
|
+
|
|
492
|
+
A journal written before the active contract can never be adopted, but it is
|
|
493
|
+
not damage: it has an exact, non-deleting route out. Only evidence that also
|
|
494
|
+
fails its own recorded contract is reported as corruption.
|
|
495
|
+
"""
|
|
496
|
+
if (
|
|
497
|
+
payload["contractVersion"] != 1
|
|
498
|
+
or payload["worktree"] != str(worktree.resolve())
|
|
499
|
+
or payload["branch"] != baseline.branch
|
|
500
|
+
or (payload["rootDevice"], payload["rootInode"])
|
|
501
|
+
!= (baseline.root_device, baseline.root_inode)
|
|
502
|
+
or payload["state"] not in {"started", "frozen"}
|
|
503
|
+
or digest != _baseline_digest(payload)
|
|
504
|
+
):
|
|
505
|
+
raise LifecycleError("landing-attempt provenance is incoherent")
|
|
506
|
+
raise LegacyLandingAttempt(
|
|
507
|
+
"landing attempt was started under the superseded v1 journal contract "
|
|
508
|
+
"and cannot be adopted; archive it with "
|
|
509
|
+
f"`land {ABANDON_ATTEMPT_FLAG}` — that deletes and claims no file — "
|
|
510
|
+
"then rerun land"
|
|
511
|
+
)
|
|
512
|
+
|
|
513
|
+
|
|
450
514
|
def landing_start_artifact_inventory(
|
|
451
515
|
profile: WorktreeProfile,
|
|
452
516
|
worktree: Path,
|
|
@@ -456,31 +520,32 @@ def landing_start_artifact_inventory(
|
|
|
456
520
|
worktree,
|
|
457
521
|
reject_ignored_patterns=profile.landing_generated_artifact_patterns,
|
|
458
522
|
)
|
|
459
|
-
|
|
460
|
-
if
|
|
523
|
+
attempt_path = landing_attempt_path(worktree)
|
|
524
|
+
if landing_attempt_exists(attempt_path):
|
|
525
|
+
require_regular_landing_attempt(attempt_path)
|
|
461
526
|
try:
|
|
462
|
-
document = json.loads(
|
|
527
|
+
document = json.loads(attempt_path.read_text(encoding="utf-8"))
|
|
528
|
+
contract_version = document["contractVersion"]
|
|
463
529
|
payload = {
|
|
464
530
|
key: document[key]
|
|
465
|
-
for key in (
|
|
466
|
-
"contractVersion", "worktree", "branch", "rootDevice",
|
|
467
|
-
"rootInode", "baselineDigest", "generatedFiles",
|
|
468
|
-
"generatedEvidence", "state", "authorizedEvidence",
|
|
469
|
-
"pushSucceeded", "policyDigest",
|
|
470
|
-
)
|
|
531
|
+
for key in landing_attempt_keys(contract_version)
|
|
471
532
|
}
|
|
472
533
|
digest = document["sha256"]
|
|
473
534
|
except (OSError, json.JSONDecodeError, KeyError, TypeError) as error:
|
|
474
535
|
raise LifecycleError(
|
|
475
536
|
f"landing-attempt provenance is incoherent: {error}"
|
|
476
537
|
) from error
|
|
538
|
+
if contract_version != LANDING_ATTEMPT_CONTRACT_VERSION:
|
|
539
|
+
_classify_superseded_landing_attempt(
|
|
540
|
+
payload, digest, baseline, worktree
|
|
541
|
+
)
|
|
477
542
|
if payload["policyDigest"] != landing_cleanup_policy_digest(profile):
|
|
478
543
|
raise LifecycleError(
|
|
479
544
|
"landing cleanup policy changed after attempt start; "
|
|
480
545
|
"abandon the unfinished attempt before retrying"
|
|
481
546
|
)
|
|
482
547
|
if (
|
|
483
|
-
payload["contractVersion"] !=
|
|
548
|
+
payload["contractVersion"] != LANDING_ATTEMPT_CONTRACT_VERSION
|
|
484
549
|
or payload["worktree"] != str(worktree.resolve())
|
|
485
550
|
or payload["branch"] != baseline.branch
|
|
486
551
|
or (payload["rootDevice"], payload["rootInode"])
|
|
@@ -528,7 +593,7 @@ def landing_start_artifact_inventory(
|
|
|
528
593
|
"policyDigest": landing_cleanup_policy_digest(profile),
|
|
529
594
|
}
|
|
530
595
|
payload = {
|
|
531
|
-
"contractVersion":
|
|
596
|
+
"contractVersion": LANDING_ATTEMPT_CONTRACT_VERSION,
|
|
532
597
|
"worktree": str(worktree.resolve()),
|
|
533
598
|
"branch": baseline.branch,
|
|
534
599
|
"rootDevice": baseline.root_device,
|
|
@@ -537,7 +602,7 @@ def landing_start_artifact_inventory(
|
|
|
537
602
|
}
|
|
538
603
|
digest = _baseline_digest(payload)
|
|
539
604
|
durable_atomic_json(
|
|
540
|
-
|
|
605
|
+
attempt_path,
|
|
541
606
|
{**payload, "sha256": digest},
|
|
542
607
|
label="persist landing-attempt provenance",
|
|
543
608
|
)
|
|
@@ -620,7 +685,7 @@ def freeze_landing_artifact_evidence(
|
|
|
620
685
|
)
|
|
621
686
|
if attempt["state"] == "frozen":
|
|
622
687
|
return frozen
|
|
623
|
-
path =
|
|
688
|
+
path = landing_attempt_path(worktree)
|
|
624
689
|
document = json.loads(path.read_text(encoding="utf-8"))
|
|
625
690
|
payload = {
|
|
626
691
|
key: document[key]
|
|
@@ -652,7 +717,7 @@ def reopen_frozen_landing_attempt(
|
|
|
652
717
|
frozen = freeze_landing_artifact_evidence(
|
|
653
718
|
profile, worktree, push_succeeded=False
|
|
654
719
|
)
|
|
655
|
-
path =
|
|
720
|
+
path = landing_attempt_path(worktree)
|
|
656
721
|
document = json.loads(path.read_text(encoding="utf-8"))
|
|
657
722
|
payload = {
|
|
658
723
|
key: document[key]
|
|
@@ -680,25 +745,15 @@ def abandon_unfinished_landing_attempt(
|
|
|
680
745
|
worktree: Path,
|
|
681
746
|
) -> Path:
|
|
682
747
|
"""Archive an ambiguous started attempt without claiming or deleting files."""
|
|
683
|
-
path =
|
|
684
|
-
if not
|
|
748
|
+
path = landing_attempt_path(worktree)
|
|
749
|
+
if not landing_attempt_exists(path):
|
|
685
750
|
raise LifecycleError("no pre-existing unfinished landing attempt to abandon")
|
|
686
751
|
try:
|
|
687
|
-
|
|
688
|
-
raise LifecycleError("landing-attempt provenance is not a regular file")
|
|
752
|
+
require_regular_landing_attempt(path)
|
|
689
753
|
document = json.loads(path.read_text(encoding="utf-8"))
|
|
690
|
-
contract_version = document["contractVersion"]
|
|
691
|
-
keys = [
|
|
692
|
-
"contractVersion", "worktree", "branch", "rootDevice",
|
|
693
|
-
"rootInode", "baselineDigest", "generatedFiles",
|
|
694
|
-
"generatedEvidence", "state", "authorizedEvidence",
|
|
695
|
-
"pushSucceeded",
|
|
696
|
-
]
|
|
697
|
-
if contract_version == 2:
|
|
698
|
-
keys.append("policyDigest")
|
|
699
754
|
payload = {
|
|
700
755
|
key: document[key]
|
|
701
|
-
for key in
|
|
756
|
+
for key in landing_attempt_keys(document["contractVersion"])
|
|
702
757
|
}
|
|
703
758
|
digest = document["sha256"]
|
|
704
759
|
metadata = os.lstat(worktree)
|
|
@@ -719,12 +774,108 @@ def abandon_unfinished_landing_attempt(
|
|
|
719
774
|
):
|
|
720
775
|
raise LifecycleError("landing-attempt provenance is incoherent")
|
|
721
776
|
archive = path.with_name(
|
|
722
|
-
f"{
|
|
777
|
+
f"{LANDING_ATTEMPT_ARCHIVE_STEM}.v{payload['contractVersion']}"
|
|
778
|
+
f".abandoned-{int(time() * 1_000_000)}.json"
|
|
723
779
|
)
|
|
724
780
|
durable_replace(path, archive, label="archive landing attempt")
|
|
725
781
|
return archive
|
|
726
782
|
|
|
727
783
|
|
|
784
|
+
def canonical_recovery_evidence(
|
|
785
|
+
canonical: WorktreeProfile,
|
|
786
|
+
worktree: Path,
|
|
787
|
+
) -> tuple[dict[str, Any], ...]:
|
|
788
|
+
"""Revalidate one frozen landing attempt against canonical policy alone.
|
|
789
|
+
|
|
790
|
+
Canonical cleanup-policy drift between attempt start and post-merge cleanup
|
|
791
|
+
strands an already-merged worktree, because the attempt is bound to the
|
|
792
|
+
policy that nominated it. This route re-derives deletion authority from the
|
|
793
|
+
merged canonical policy only. It never re-scans the worktree for new
|
|
794
|
+
candidates, so broader stale candidate evidence cannot enter; every frozen
|
|
795
|
+
identity must additionally still be named by canonical policy and still
|
|
796
|
+
match byte-for-byte on disk, so a narrowed canonical policy, a changed file,
|
|
797
|
+
and any pre-existing state stop the recovery instead of being deleted.
|
|
798
|
+
"""
|
|
799
|
+
path = landing_attempt_path(worktree)
|
|
800
|
+
if not landing_attempt_exists(path):
|
|
801
|
+
raise LifecycleError(
|
|
802
|
+
"canonical cleanup recovery requires the landing attempt that froze "
|
|
803
|
+
"this teardown's evidence"
|
|
804
|
+
)
|
|
805
|
+
try:
|
|
806
|
+
require_regular_landing_attempt(path)
|
|
807
|
+
document = json.loads(path.read_text(encoding="utf-8"))
|
|
808
|
+
contract_version = document["contractVersion"]
|
|
809
|
+
payload = {
|
|
810
|
+
key: document[key]
|
|
811
|
+
for key in landing_attempt_keys(contract_version)
|
|
812
|
+
}
|
|
813
|
+
digest = document["sha256"]
|
|
814
|
+
metadata = os.lstat(worktree)
|
|
815
|
+
branch = run(["git", "branch", "--show-current"], cwd=worktree).stdout.strip()
|
|
816
|
+
except LifecycleError:
|
|
817
|
+
raise
|
|
818
|
+
except (OSError, json.JSONDecodeError, KeyError, TypeError) as error:
|
|
819
|
+
raise LifecycleError(f"landing-attempt provenance is incoherent: {error}") from error
|
|
820
|
+
if (
|
|
821
|
+
contract_version != LANDING_ATTEMPT_CONTRACT_VERSION
|
|
822
|
+
or payload["worktree"] != str(worktree.resolve())
|
|
823
|
+
or payload["branch"] != branch
|
|
824
|
+
or not stat.S_ISDIR(metadata.st_mode)
|
|
825
|
+
or (payload["rootDevice"], payload["rootInode"])
|
|
826
|
+
!= (metadata.st_dev, metadata.st_ino)
|
|
827
|
+
or not isinstance(payload["generatedFiles"], list)
|
|
828
|
+
or not isinstance(payload["authorizedEvidence"], list)
|
|
829
|
+
or type(payload["pushSucceeded"]) is not bool
|
|
830
|
+
or digest != _baseline_digest(payload)
|
|
831
|
+
):
|
|
832
|
+
raise LifecycleError("landing-attempt provenance is incoherent")
|
|
833
|
+
if payload["state"] != "frozen" or not payload["pushSucceeded"]:
|
|
834
|
+
raise LifecycleError(
|
|
835
|
+
"canonical cleanup recovery requires a frozen landing attempt from a "
|
|
836
|
+
f"completed push; archive an unfinished attempt with `{ABANDON_ATTEMPT_FLAG}`"
|
|
837
|
+
)
|
|
838
|
+
if payload["generatedFiles"]:
|
|
839
|
+
raise LifecycleError(
|
|
840
|
+
"landing-start generated paths are consumer-owned and protected: "
|
|
841
|
+
+ ", ".join(payload["generatedFiles"])
|
|
842
|
+
)
|
|
843
|
+
baseline = load_artifact_baseline(worktree)
|
|
844
|
+
if payload["baselineDigest"] != baseline.digest:
|
|
845
|
+
raise LifecycleError("artifact provenance baseline changed during landing")
|
|
846
|
+
frozen = tuple(payload["authorizedEvidence"])
|
|
847
|
+
relatives: list[str] = []
|
|
848
|
+
for item in frozen:
|
|
849
|
+
relative = item.get("path") if isinstance(item, dict) else None
|
|
850
|
+
if not isinstance(relative, str) or relative in relatives:
|
|
851
|
+
raise LifecycleError("scratch evidence is incoherent")
|
|
852
|
+
relatives.append(relative)
|
|
853
|
+
outside = sorted(
|
|
854
|
+
relative for relative in relatives
|
|
855
|
+
if not any(
|
|
856
|
+
path_glob_matches(relative, pattern)
|
|
857
|
+
for pattern in canonical.landing_generated_artifact_patterns
|
|
858
|
+
)
|
|
859
|
+
)
|
|
860
|
+
if outside:
|
|
861
|
+
raise LifecycleError(
|
|
862
|
+
"landing-attempt evidence is outside canonical cleanup policy: "
|
|
863
|
+
+ ", ".join(outside)
|
|
864
|
+
)
|
|
865
|
+
with verified_worktree_root(
|
|
866
|
+
worktree,
|
|
867
|
+
baseline.root_device,
|
|
868
|
+
baseline.root_inode,
|
|
869
|
+
) as descriptor:
|
|
870
|
+
current = tuple(
|
|
871
|
+
contained_regular_identity(descriptor, relative)
|
|
872
|
+
for relative in relatives
|
|
873
|
+
)
|
|
874
|
+
if current != frozen:
|
|
875
|
+
raise LifecycleError("landing-generated evidence changed after it was frozen")
|
|
876
|
+
return current
|
|
877
|
+
|
|
878
|
+
|
|
728
879
|
def collect_facts(cwd: Path) -> RepoFacts:
|
|
729
880
|
root = Path(run(["git", "rev-parse", "--show-toplevel"], cwd=cwd).stdout.strip()).resolve()
|
|
730
881
|
main = main_worktree(root)
|