@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
|
@@ -0,0 +1,226 @@
|
|
|
1
|
+
#!/usr/bin/env python3
|
|
2
|
+
"""Offer the planning-artifact ignore rules for a consumer `.gitignore`.
|
|
3
|
+
|
|
4
|
+
The shipped skills write `PLAN.md`, `PLAN-REVIEW-LOG.md`, and `ANNAHMEN.md`
|
|
5
|
+
into a session worktree, but `.gitignore` is a consumer file the kit does not
|
|
6
|
+
own: `init` and `update` never touch it. This helper is the one place that
|
|
7
|
+
closes that gap, and it closes it only when a user explicitly approves the
|
|
8
|
+
offer inside `/setup-workflow` (ADR 0008).
|
|
9
|
+
|
|
10
|
+
Contract:
|
|
11
|
+
|
|
12
|
+
- `preview` reads and reports; it never writes.
|
|
13
|
+
- `apply` appends one marker block and nothing else. It never rewrites,
|
|
14
|
+
reorders, or removes an existing line, so a second run is a byte-identical
|
|
15
|
+
no-op once the rules are in place.
|
|
16
|
+
- Rules a consumer already has — by any pattern, including a wildcard — are
|
|
17
|
+
reported as covered and never duplicated.
|
|
18
|
+
- A marker block that the consumer has since edited is theirs: the helper
|
|
19
|
+
blocks instead of repairing it.
|
|
20
|
+
- Nothing here runs during `init`/`update` reconciliation.
|
|
21
|
+
|
|
22
|
+
Usage:
|
|
23
|
+
python3 scripts/worktree-lifecycle/ignore_seed.py preview [--repo PATH] [--json]
|
|
24
|
+
python3 scripts/worktree-lifecycle/ignore_seed.py apply [--repo PATH] [--json]
|
|
25
|
+
|
|
26
|
+
Exit codes: 0 previewed/appended/nothing-to-do · 2 blocked · 1 error.
|
|
27
|
+
"""
|
|
28
|
+
|
|
29
|
+
from __future__ import annotations
|
|
30
|
+
|
|
31
|
+
import argparse
|
|
32
|
+
import json
|
|
33
|
+
import subprocess
|
|
34
|
+
import sys
|
|
35
|
+
from dataclasses import dataclass
|
|
36
|
+
from pathlib import Path
|
|
37
|
+
|
|
38
|
+
MANIFEST = Path(__file__).resolve().parent / "plan-artifacts.json"
|
|
39
|
+
|
|
40
|
+
BLOCK_START = "# >>> agent-workflow-kit: planning-artifacts/v1 >>>"
|
|
41
|
+
BLOCK_END = "# <<< agent-workflow-kit: planning-artifacts/v1 <<<"
|
|
42
|
+
BLOCK_NOTE = (
|
|
43
|
+
"# Planning artifacts the agent workflow skills write into a session\n"
|
|
44
|
+
"# worktree. They are session scratch, never part of the change. Added by\n"
|
|
45
|
+
"# setup-workflow on request; this block is yours to edit or delete.\n"
|
|
46
|
+
)
|
|
47
|
+
|
|
48
|
+
|
|
49
|
+
class IgnoreSeedError(RuntimeError):
|
|
50
|
+
"""A safe, user-visible refusal."""
|
|
51
|
+
|
|
52
|
+
|
|
53
|
+
@dataclass(frozen=True)
|
|
54
|
+
class IgnorePlan:
|
|
55
|
+
"""What an approval would append — and nothing more."""
|
|
56
|
+
|
|
57
|
+
repo: Path
|
|
58
|
+
gitignore_exists: bool
|
|
59
|
+
already_ignored: tuple[str, ...]
|
|
60
|
+
pending: tuple[str, ...]
|
|
61
|
+
tracked: tuple[str, ...]
|
|
62
|
+
block: str | None
|
|
63
|
+
status: str
|
|
64
|
+
detail: str
|
|
65
|
+
|
|
66
|
+
def as_dict(self) -> dict:
|
|
67
|
+
return {
|
|
68
|
+
"status": self.status,
|
|
69
|
+
"detail": self.detail,
|
|
70
|
+
"gitignoreExists": self.gitignore_exists,
|
|
71
|
+
"alreadyIgnored": list(self.already_ignored),
|
|
72
|
+
"pending": list(self.pending),
|
|
73
|
+
"tracked": list(self.tracked),
|
|
74
|
+
"block": self.block,
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
|
|
78
|
+
def load_artifacts(manifest: Path = MANIFEST) -> tuple[str, ...]:
|
|
79
|
+
"""Return the kit-declared planning artifacts, in declaration order."""
|
|
80
|
+
try:
|
|
81
|
+
document = json.loads(manifest.read_text(encoding="utf-8"))
|
|
82
|
+
return tuple(entry["path"] for entry in document["artifacts"])
|
|
83
|
+
except (OSError, json.JSONDecodeError, KeyError, TypeError) as error:
|
|
84
|
+
raise IgnoreSeedError(f"cannot read planning-artifact manifest: {error}") from error
|
|
85
|
+
|
|
86
|
+
|
|
87
|
+
def _git(repo: Path, *args: str) -> subprocess.CompletedProcess:
|
|
88
|
+
try:
|
|
89
|
+
return subprocess.run(
|
|
90
|
+
["git", *args], cwd=repo, capture_output=True, text=True,
|
|
91
|
+
)
|
|
92
|
+
except OSError as error: # pragma: no cover - git absent
|
|
93
|
+
raise IgnoreSeedError(f"cannot run git: {error}") from error
|
|
94
|
+
|
|
95
|
+
|
|
96
|
+
def is_ignored(repo: Path, path: str) -> bool:
|
|
97
|
+
"""True when any ignore rule already matches `path`.
|
|
98
|
+
|
|
99
|
+
`--no-index` asks the pattern question, not the index question, so an
|
|
100
|
+
artifact that a consumer once committed still reports its real rule state.
|
|
101
|
+
"""
|
|
102
|
+
return _git(repo, "check-ignore", "-q", "--no-index", "--", path).returncode == 0
|
|
103
|
+
|
|
104
|
+
|
|
105
|
+
def is_tracked(repo: Path, path: str) -> bool:
|
|
106
|
+
"""True when the artifact is committed — an ignore rule cannot untrack it."""
|
|
107
|
+
result = _git(repo, "ls-files", "--error-unmatch", "--", path)
|
|
108
|
+
return result.returncode == 0
|
|
109
|
+
|
|
110
|
+
|
|
111
|
+
def render_block(paths: tuple[str, ...]) -> str:
|
|
112
|
+
"""The exact text an approval appends."""
|
|
113
|
+
listed = "".join(f"{path}\n" for path in paths)
|
|
114
|
+
return f"{BLOCK_START}\n{BLOCK_NOTE}{listed}{BLOCK_END}\n"
|
|
115
|
+
|
|
116
|
+
|
|
117
|
+
def _read_gitignore(repo: Path) -> tuple[bool, str]:
|
|
118
|
+
target = repo / ".gitignore"
|
|
119
|
+
try:
|
|
120
|
+
return True, target.read_text(encoding="utf-8")
|
|
121
|
+
except FileNotFoundError:
|
|
122
|
+
return False, ""
|
|
123
|
+
except OSError as error:
|
|
124
|
+
raise IgnoreSeedError(f"cannot read {target}: {error}") from error
|
|
125
|
+
|
|
126
|
+
|
|
127
|
+
def plan(repo: Path, manifest: Path = MANIFEST) -> IgnorePlan:
|
|
128
|
+
"""Report what an approval would change. Reads only."""
|
|
129
|
+
repo = Path(repo)
|
|
130
|
+
if not (repo / ".git").exists():
|
|
131
|
+
raise IgnoreSeedError(f"not a git repository: {repo}")
|
|
132
|
+
artifacts = load_artifacts(manifest)
|
|
133
|
+
exists, text = _read_gitignore(repo)
|
|
134
|
+
covered = tuple(path for path in artifacts if is_ignored(repo, path))
|
|
135
|
+
pending = tuple(path for path in artifacts if path not in covered)
|
|
136
|
+
tracked = tuple(path for path in artifacts if is_tracked(repo, path))
|
|
137
|
+
has_block = BLOCK_START in text
|
|
138
|
+
|
|
139
|
+
if not pending:
|
|
140
|
+
return IgnorePlan(
|
|
141
|
+
repo=repo, gitignore_exists=exists, already_ignored=covered,
|
|
142
|
+
pending=(), tracked=tracked, block=None, status="nothing-to-do",
|
|
143
|
+
detail="every planning artifact is already ignored",
|
|
144
|
+
)
|
|
145
|
+
if has_block:
|
|
146
|
+
return IgnorePlan(
|
|
147
|
+
repo=repo, gitignore_exists=exists, already_ignored=covered,
|
|
148
|
+
pending=pending, tracked=tracked, block=None, status="blocked",
|
|
149
|
+
detail=(
|
|
150
|
+
"the marker block exists but no longer covers "
|
|
151
|
+
f"{', '.join(pending)} — that block is consumer-owned; "
|
|
152
|
+
"edit .gitignore yourself"
|
|
153
|
+
),
|
|
154
|
+
)
|
|
155
|
+
return IgnorePlan(
|
|
156
|
+
repo=repo, gitignore_exists=exists, already_ignored=covered,
|
|
157
|
+
pending=pending, tracked=tracked, block=render_block(pending),
|
|
158
|
+
status="append",
|
|
159
|
+
detail=f"would append {len(pending)} rule(s) in one marker block",
|
|
160
|
+
)
|
|
161
|
+
|
|
162
|
+
|
|
163
|
+
def apply(repo: Path, manifest: Path = MANIFEST) -> IgnorePlan:
|
|
164
|
+
"""Append the previewed block. Never rewrites an existing line."""
|
|
165
|
+
decision = plan(repo, manifest)
|
|
166
|
+
if decision.status != "append":
|
|
167
|
+
return decision
|
|
168
|
+
target = Path(repo) / ".gitignore"
|
|
169
|
+
_, text = _read_gitignore(Path(repo))
|
|
170
|
+
prefix = text
|
|
171
|
+
if prefix and not prefix.endswith("\n"):
|
|
172
|
+
prefix += "\n"
|
|
173
|
+
if prefix and not prefix.endswith("\n\n"):
|
|
174
|
+
prefix += "\n"
|
|
175
|
+
try:
|
|
176
|
+
target.write_text(prefix + decision.block, encoding="utf-8")
|
|
177
|
+
except OSError as error:
|
|
178
|
+
raise IgnoreSeedError(f"cannot write {target}: {error}") from error
|
|
179
|
+
return IgnorePlan(
|
|
180
|
+
repo=decision.repo, gitignore_exists=True,
|
|
181
|
+
already_ignored=decision.already_ignored, pending=decision.pending,
|
|
182
|
+
tracked=decision.tracked, block=decision.block, status="appended",
|
|
183
|
+
detail=f"appended {len(decision.pending)} rule(s) to .gitignore",
|
|
184
|
+
)
|
|
185
|
+
|
|
186
|
+
|
|
187
|
+
def _report(decision: IgnorePlan, *, as_json: bool) -> None:
|
|
188
|
+
if as_json:
|
|
189
|
+
print(json.dumps(decision.as_dict(), indent=2))
|
|
190
|
+
return
|
|
191
|
+
print(f"{decision.status} — {decision.detail}")
|
|
192
|
+
if decision.already_ignored:
|
|
193
|
+
print(f" already ignored: {', '.join(decision.already_ignored)}")
|
|
194
|
+
if decision.tracked:
|
|
195
|
+
print(
|
|
196
|
+
" tracked in git (an ignore rule cannot untrack these): "
|
|
197
|
+
f"{', '.join(decision.tracked)}"
|
|
198
|
+
)
|
|
199
|
+
if decision.block:
|
|
200
|
+
label = "appended" if decision.status == "appended" else "would append"
|
|
201
|
+
print(f" {label}:")
|
|
202
|
+
for line in decision.block.rstrip("\n").split("\n"):
|
|
203
|
+
print(f" {line}")
|
|
204
|
+
|
|
205
|
+
|
|
206
|
+
def parse_args(argv: list[str]) -> argparse.Namespace:
|
|
207
|
+
parser = argparse.ArgumentParser(prog="ignore_seed.py")
|
|
208
|
+
parser.add_argument("command", choices=("preview", "apply"))
|
|
209
|
+
parser.add_argument("--repo", default=".")
|
|
210
|
+
parser.add_argument("--json", action="store_true")
|
|
211
|
+
return parser.parse_args(argv)
|
|
212
|
+
|
|
213
|
+
|
|
214
|
+
def main(argv: list[str]) -> int:
|
|
215
|
+
args = parse_args(argv)
|
|
216
|
+
try:
|
|
217
|
+
decision = plan(args.repo) if args.command == "preview" else apply(args.repo)
|
|
218
|
+
except IgnoreSeedError as error:
|
|
219
|
+
print(str(error), file=sys.stderr)
|
|
220
|
+
return 1
|
|
221
|
+
_report(decision, as_json=args.json)
|
|
222
|
+
return 2 if decision.status == "blocked" else 0
|
|
223
|
+
|
|
224
|
+
|
|
225
|
+
if __name__ == "__main__":
|
|
226
|
+
sys.exit(main(sys.argv[1:]))
|
|
@@ -0,0 +1,37 @@
|
|
|
1
|
+
{
|
|
2
|
+
"version": 1,
|
|
3
|
+
"artifacts": [
|
|
4
|
+
{
|
|
5
|
+
"path": "PLAN.md",
|
|
6
|
+
"writtenBy": [
|
|
7
|
+
"grill-me",
|
|
8
|
+
"grill-with-docs",
|
|
9
|
+
"codex-review",
|
|
10
|
+
"grill-me-codex",
|
|
11
|
+
"grill-with-docs-codex",
|
|
12
|
+
"codex-build",
|
|
13
|
+
"orchestrate-wave"
|
|
14
|
+
],
|
|
15
|
+
"why": "the locked plan a planning session hands to the implementing session"
|
|
16
|
+
},
|
|
17
|
+
{
|
|
18
|
+
"path": "PLAN-REVIEW-LOG.md",
|
|
19
|
+
"writtenBy": [
|
|
20
|
+
"codex-review",
|
|
21
|
+
"grill-me-codex",
|
|
22
|
+
"grill-with-docs-codex",
|
|
23
|
+
"codex-build"
|
|
24
|
+
],
|
|
25
|
+
"why": "the append-only cross-model review transcript of that plan"
|
|
26
|
+
},
|
|
27
|
+
{
|
|
28
|
+
"path": "ANNAHMEN.md",
|
|
29
|
+
"writtenBy": [
|
|
30
|
+
"tdd",
|
|
31
|
+
"orchestrate-wave",
|
|
32
|
+
"wrapup"
|
|
33
|
+
],
|
|
34
|
+
"why": "the build-time assumption-drift log wrapup propagates before teardown"
|
|
35
|
+
}
|
|
36
|
+
]
|
|
37
|
+
}
|
package/scripts/wrapup-land.py
CHANGED
|
@@ -71,6 +71,14 @@ INFRA_FAILURE_RE = re.compile(
|
|
|
71
71
|
)
|
|
72
72
|
|
|
73
73
|
|
|
74
|
+
ABANDON_ATTEMPT_FLAG = "--abandon-unfinished-attempt"
|
|
75
|
+
RECOVER_CANONICAL_CLEANUP_FLAG = "--recover-canonical-cleanup"
|
|
76
|
+
CANONICAL_DRIFT_RECOVERY_HINT = (
|
|
77
|
+
f"rerun land with {RECOVER_CANONICAL_CLEANUP_FLAG} to revalidate the frozen "
|
|
78
|
+
"landing evidence against canonical policy and resume teardown"
|
|
79
|
+
)
|
|
80
|
+
|
|
81
|
+
|
|
74
82
|
class Stop(Exception):
|
|
75
83
|
def __init__(self, step: str, reason: str, detail: str = ""):
|
|
76
84
|
super().__init__(reason)
|
|
@@ -461,9 +469,8 @@ def load_candidate_landing_profile(core, wt: str):
|
|
|
461
469
|
return candidate
|
|
462
470
|
|
|
463
471
|
|
|
464
|
-
def
|
|
465
|
-
"""
|
|
466
|
-
candidate = load_candidate_landing_profile(core, wt)
|
|
472
|
+
def load_merged_canonical_profile(core, main_tree: str):
|
|
473
|
+
"""Read the merged canonical cleanup policy from origin/main."""
|
|
467
474
|
result = core.run(
|
|
468
475
|
[
|
|
469
476
|
"git", "show",
|
|
@@ -482,6 +489,13 @@ def load_canonical_landing_profile(core, wt: str, main_tree: str):
|
|
|
482
489
|
raise core.LifecycleError(
|
|
483
490
|
"merged canonical landing artifact policy is not configured"
|
|
484
491
|
)
|
|
492
|
+
return canonical
|
|
493
|
+
|
|
494
|
+
|
|
495
|
+
def load_canonical_landing_profile(core, wt: str, main_tree: str):
|
|
496
|
+
"""Authorize deletion only after the candidate policy is canonical."""
|
|
497
|
+
candidate = load_candidate_landing_profile(core, wt)
|
|
498
|
+
canonical = load_merged_canonical_profile(core, main_tree)
|
|
485
499
|
if (
|
|
486
500
|
candidate.landing_generated_artifact_patterns
|
|
487
501
|
!= canonical.landing_generated_artifact_patterns
|
|
@@ -489,11 +503,9 @@ def load_canonical_landing_profile(core, wt: str, main_tree: str):
|
|
|
489
503
|
):
|
|
490
504
|
raise core.LifecycleError(
|
|
491
505
|
"worktree cleanup policy differs from merged canonical origin/main"
|
|
506
|
+
f"; {CANONICAL_DRIFT_RECOVERY_HINT}"
|
|
492
507
|
)
|
|
493
|
-
|
|
494
|
-
core.LANDING_ATTEMPT_FILE
|
|
495
|
-
)
|
|
496
|
-
if not os.path.lexists(attempt_path):
|
|
508
|
+
if not core.landing_attempt_exists(core.landing_attempt_path(Path(wt))):
|
|
497
509
|
raise core.LifecycleError(
|
|
498
510
|
"landing attempt is missing before canonical cleanup authorization"
|
|
499
511
|
)
|
|
@@ -504,17 +516,22 @@ def load_canonical_landing_profile(core, wt: str, main_tree: str):
|
|
|
504
516
|
):
|
|
505
517
|
raise core.LifecycleError(
|
|
506
518
|
"landing attempt cleanup policy differs from merged canonical origin/main"
|
|
519
|
+
f"; {CANONICAL_DRIFT_RECOVERY_HINT}"
|
|
507
520
|
)
|
|
508
521
|
return canonical
|
|
509
522
|
|
|
510
523
|
|
|
511
|
-
def
|
|
512
|
-
|
|
513
|
-
|
|
514
|
-
|
|
515
|
-
|
|
516
|
-
|
|
517
|
-
|
|
524
|
+
def load_canonical_recovery_profile(core, wt: str, main_tree: str):
|
|
525
|
+
"""Authorize post-merge cleanup from canonical policy alone after drift.
|
|
526
|
+
|
|
527
|
+
The committed worktree candidate is deliberately never consulted here: after
|
|
528
|
+
canonical drift it is stale, and trusting it could only widen what may be
|
|
529
|
+
deleted. Every authority boundary re-validates the frozen evidence against
|
|
530
|
+
the merged canonical policy instead.
|
|
531
|
+
"""
|
|
532
|
+
canonical = load_merged_canonical_profile(core, main_tree)
|
|
533
|
+
core.canonical_recovery_evidence(canonical, Path(wt))
|
|
534
|
+
return canonical
|
|
518
535
|
|
|
519
536
|
|
|
520
537
|
def landing_start_artifact_inventory(wt: str, main_tree: str) -> dict:
|
|
@@ -522,6 +539,10 @@ def landing_start_artifact_inventory(wt: str, main_tree: str) -> dict:
|
|
|
522
539
|
try:
|
|
523
540
|
profile = load_candidate_landing_profile(core, wt)
|
|
524
541
|
return core.landing_start_artifact_inventory(profile, Path(wt))
|
|
542
|
+
except core.LegacyLandingAttempt as error:
|
|
543
|
+
# Legacy, not damage: report the classification and the safe route out
|
|
544
|
+
# verbatim, without the corruption framing of a guard failure.
|
|
545
|
+
raise Stop("cleanup", str(error)) from error
|
|
525
546
|
except core.LifecycleError as error:
|
|
526
547
|
raise Stop("cleanup", f"shared cleanup guard failed: {error}") from error
|
|
527
548
|
|
|
@@ -581,6 +602,16 @@ def abandon_unfinished_landing_attempt(wt: str, main_tree: str) -> str:
|
|
|
581
602
|
raise Stop("cleanup", f"shared cleanup guard failed: {error}") from error
|
|
582
603
|
|
|
583
604
|
|
|
605
|
+
def canonical_recovery_evidence(wt: str, main_tree: str) -> tuple[dict, ...]:
|
|
606
|
+
"""Revalidate the frozen landing evidence against canonical policy alone."""
|
|
607
|
+
core = load_worktree_cleanup_core()
|
|
608
|
+
try:
|
|
609
|
+
canonical = load_merged_canonical_profile(core, main_tree)
|
|
610
|
+
return core.canonical_recovery_evidence(canonical, Path(wt))
|
|
611
|
+
except core.LifecycleError as error:
|
|
612
|
+
raise Stop("cleanup", f"shared cleanup guard failed: {error}") from error
|
|
613
|
+
|
|
614
|
+
|
|
584
615
|
def landing_verified_scratch_files(
|
|
585
616
|
wt: str,
|
|
586
617
|
main_tree: str,
|
|
@@ -605,10 +636,13 @@ def ensure_worktree_removable(
|
|
|
605
636
|
*,
|
|
606
637
|
verified_scratch_files: tuple[str, ...] = (),
|
|
607
638
|
verified_scratch_evidence: tuple[dict, ...] = (),
|
|
639
|
+
profile_loader=None,
|
|
608
640
|
):
|
|
609
641
|
core = load_worktree_cleanup_core()
|
|
642
|
+
# Resolved at call time so the module attribute stays the single seam.
|
|
643
|
+
loader = profile_loader or load_canonical_landing_profile
|
|
610
644
|
try:
|
|
611
|
-
profile =
|
|
645
|
+
profile = loader(core, wt, main_tree)
|
|
612
646
|
kwargs = {"merge_target": "origin/main"}
|
|
613
647
|
paths = (
|
|
614
648
|
tuple(item["path"] for item in verified_scratch_evidence)
|
|
@@ -639,11 +673,13 @@ def remove_verified_worktree_scratch(
|
|
|
639
673
|
*,
|
|
640
674
|
verified_scratch_files: tuple[str, ...] = (),
|
|
641
675
|
verified_scratch_evidence: tuple[dict, ...] = (),
|
|
676
|
+
profile_loader=None,
|
|
642
677
|
):
|
|
643
678
|
"""Re-verify and delete only assessed regular scratch files."""
|
|
644
679
|
core = load_worktree_cleanup_core()
|
|
680
|
+
loader = profile_loader or load_canonical_landing_profile
|
|
645
681
|
try:
|
|
646
|
-
profile =
|
|
682
|
+
profile = loader(core, wt, main_tree)
|
|
647
683
|
evidence = verified_scratch_evidence
|
|
648
684
|
paths = tuple(item["path"] for item in evidence) or verified_scratch_files
|
|
649
685
|
latest = core.cleanup_assessment(
|
|
@@ -920,6 +956,105 @@ def cmd_commit(args) -> dict:
|
|
|
920
956
|
return {"committed": True, "sha": sha, "allowed_matches": bool(hits)}
|
|
921
957
|
|
|
922
958
|
|
|
959
|
+
def retire_local_branch(branch: str, main_tree: str, report: dict) -> None:
|
|
960
|
+
"""Fast-forward main, then retire the merged local branch with `-d` only."""
|
|
961
|
+
git(["fetch", "origin", "--prune"], cwd=main_tree)
|
|
962
|
+
git(["checkout", "main"], cwd=main_tree)
|
|
963
|
+
p = git(["pull", "--ff-only"], cwd=main_tree)
|
|
964
|
+
if p.returncode != 0:
|
|
965
|
+
raise Stop("5 main-ff", "no fast-forward possible — diverged main is an anomaly",
|
|
966
|
+
(p.stderr or p.stdout).strip()[-1000:])
|
|
967
|
+
if git(["show-ref", "--verify", "--quiet", f"refs/heads/{branch}"],
|
|
968
|
+
cwd=main_tree).returncode != 0:
|
|
969
|
+
report["branch_retired"] = "already absent"
|
|
970
|
+
return
|
|
971
|
+
if branch in worktree_map(main_tree)[1]:
|
|
972
|
+
report["branch_retired"] = "refused: still checked out"
|
|
973
|
+
report["warnings"].append(
|
|
974
|
+
f"branch -d {branch} refused (still checked out?) — never -D"
|
|
975
|
+
)
|
|
976
|
+
return
|
|
977
|
+
p = git(["branch", "-d", branch], cwd=main_tree)
|
|
978
|
+
report["branch_retired"] = p.returncode == 0
|
|
979
|
+
if p.returncode != 0:
|
|
980
|
+
report["warnings"].append(
|
|
981
|
+
f"branch -d {branch} refused: {(p.stderr or '').strip()[:200]}"
|
|
982
|
+
)
|
|
983
|
+
|
|
984
|
+
|
|
985
|
+
def recover_canonical_cleanup(
|
|
986
|
+
branch: str,
|
|
987
|
+
wt: str | None,
|
|
988
|
+
wt_exists: bool,
|
|
989
|
+
main_tree: str,
|
|
990
|
+
) -> dict:
|
|
991
|
+
"""Resume post-merge teardown after canonical cleanup-policy drift.
|
|
992
|
+
|
|
993
|
+
Canonical drift keeps the normal path fail-closed: the attempt is bound to
|
|
994
|
+
the policy that nominated its evidence, and that binding is never relaxed.
|
|
995
|
+
This route is the supported way out, not a bypass — it re-reads the merged
|
|
996
|
+
canonical policy, requires the branch to already be an ancestor of canonical
|
|
997
|
+
`origin/main`, and revalidates every frozen identity against that canonical
|
|
998
|
+
policy before the same shared assessment and removal primitives run. Nothing
|
|
999
|
+
outside the revalidated evidence can be deleted, and rerunning it after a
|
|
1000
|
+
successful teardown is a no-op.
|
|
1001
|
+
"""
|
|
1002
|
+
report: dict = {
|
|
1003
|
+
"recovery": "canonical-cleanup",
|
|
1004
|
+
"branch": branch,
|
|
1005
|
+
"warnings": [],
|
|
1006
|
+
}
|
|
1007
|
+
git(["fetch", "origin", "main"], cwd=main_tree, check=True)
|
|
1008
|
+
if not wt_exists:
|
|
1009
|
+
report["worktree_removed"] = None
|
|
1010
|
+
report["cleanup_guard"] = None
|
|
1011
|
+
retire_local_branch(branch, main_tree, report)
|
|
1012
|
+
report["main_sha"] = git(["log", "--oneline", "-1"], cwd=main_tree,
|
|
1013
|
+
check=True).stdout.strip()
|
|
1014
|
+
return report
|
|
1015
|
+
assert wt is not None
|
|
1016
|
+
if git(["status", "--porcelain"], cwd=wt, check=True).stdout.strip():
|
|
1017
|
+
raise Stop("recover-cleanup", "worktree dirty — run `commit` first", wt)
|
|
1018
|
+
if git(["merge-base", "--is-ancestor", branch, "origin/main"],
|
|
1019
|
+
cwd=main_tree).returncode != 0:
|
|
1020
|
+
raise Stop(
|
|
1021
|
+
"recover-cleanup",
|
|
1022
|
+
"branch is not merged into canonical origin/main — canonical cleanup "
|
|
1023
|
+
"recovery only resumes an already-merged teardown",
|
|
1024
|
+
branch,
|
|
1025
|
+
)
|
|
1026
|
+
evidence = canonical_recovery_evidence(wt, main_tree)
|
|
1027
|
+
assessment = ensure_worktree_removable(
|
|
1028
|
+
wt,
|
|
1029
|
+
main_tree,
|
|
1030
|
+
verified_scratch_evidence=evidence,
|
|
1031
|
+
profile_loader=load_canonical_recovery_profile,
|
|
1032
|
+
)
|
|
1033
|
+
report["cleanup_guard"] = {
|
|
1034
|
+
"assumptions_read": bool(assessment.assumptions),
|
|
1035
|
+
"landing_generated_files": [item["path"] for item in evidence],
|
|
1036
|
+
}
|
|
1037
|
+
report["killed_processes"] = kill_worktree_processes(wt)
|
|
1038
|
+
remove_verified_worktree_scratch(
|
|
1039
|
+
wt,
|
|
1040
|
+
main_tree,
|
|
1041
|
+
assessment,
|
|
1042
|
+
verified_scratch_evidence=evidence,
|
|
1043
|
+
profile_loader=load_canonical_recovery_profile,
|
|
1044
|
+
)
|
|
1045
|
+
p = git(["worktree", "remove", wt], cwd=main_tree)
|
|
1046
|
+
if p.returncode != 0:
|
|
1047
|
+
raise Stop("recover-cleanup", "git worktree remove refused — no --force; "
|
|
1048
|
+
"check for surviving processes (lsof/pgrep)",
|
|
1049
|
+
(p.stderr or p.stdout).strip()[-1000:])
|
|
1050
|
+
git(["worktree", "prune"], cwd=main_tree)
|
|
1051
|
+
report["worktree_removed"] = wt
|
|
1052
|
+
retire_local_branch(branch, main_tree, report)
|
|
1053
|
+
report["main_sha"] = git(["log", "--oneline", "-1"], cwd=main_tree,
|
|
1054
|
+
check=True).stdout.strip()
|
|
1055
|
+
return report
|
|
1056
|
+
|
|
1057
|
+
|
|
923
1058
|
def cmd_land(args) -> dict:
|
|
924
1059
|
report: dict = {"stops": [], "warnings": []}
|
|
925
1060
|
main_tree, branches = worktree_map()
|
|
@@ -931,13 +1066,13 @@ def cmd_land(args) -> dict:
|
|
|
931
1066
|
branch = args.branch
|
|
932
1067
|
wt = branches.get(branch)
|
|
933
1068
|
wt_exists = wt is not None and Path(wt).is_dir()
|
|
1069
|
+
if getattr(args, "recover_canonical_cleanup", False):
|
|
1070
|
+
return recover_canonical_cleanup(branch, wt, wt_exists, main_tree)
|
|
934
1071
|
profile = load_profile()
|
|
935
1072
|
default_section = profile.get("headings", {}).get("vorBau", "Vor Bau zu klären")
|
|
936
1073
|
|
|
937
1074
|
# drift markers from the build-time log — mechanical, no gate
|
|
938
1075
|
markers: list[dict] = []
|
|
939
|
-
artifact_baseline_digest: str | None = None
|
|
940
|
-
landing_start_files: tuple[str, ...] = ()
|
|
941
1076
|
landing_attempt: dict | None = None
|
|
942
1077
|
generated_evidence: tuple[dict, ...] = ()
|
|
943
1078
|
if wt_exists:
|
|
@@ -959,16 +1094,18 @@ def cmd_land(args) -> dict:
|
|
|
959
1094
|
raise Stop(
|
|
960
1095
|
"cleanup",
|
|
961
1096
|
"unfinished landing generator attempt has no frozen output evidence; "
|
|
962
|
-
"classify its files, then rerun with
|
|
1097
|
+
f"classify its files, then rerun with {ABANDON_ATTEMPT_FLAG}",
|
|
963
1098
|
)
|
|
964
|
-
|
|
965
|
-
|
|
1099
|
+
# Retained structural backstop: a journal on the active contract always
|
|
1100
|
+
# carries an empty `generatedFiles`, because a landing-start blocker is
|
|
1101
|
+
# refused before the journal is written. A non-empty list therefore means
|
|
1102
|
+
# tampered or migrated evidence — never a path this run may claim.
|
|
1103
|
+
if landing_attempt["generatedFiles"]:
|
|
966
1104
|
raise Stop(
|
|
967
1105
|
"cleanup",
|
|
968
1106
|
"landing-start generated paths are consumer-owned and protected: "
|
|
969
|
-
+ ", ".join(
|
|
1107
|
+
+ ", ".join(landing_attempt["generatedFiles"]),
|
|
970
1108
|
)
|
|
971
|
-
artifact_baseline_digest = landing_attempt["baselineDigest"]
|
|
972
1109
|
annahmen = Path(wt) / "ANNAHMEN.md"
|
|
973
1110
|
if annahmen.is_file():
|
|
974
1111
|
markers, malformed = parse_annahmen(annahmen.read_text(), default_section)
|
|
@@ -1077,18 +1214,16 @@ def cmd_land(args) -> dict:
|
|
|
1077
1214
|
verified_scratch_evidence=generated_evidence,
|
|
1078
1215
|
)
|
|
1079
1216
|
report["cleanup_guard"] = {
|
|
1080
|
-
"
|
|
1081
|
-
"assumptions_read": bool(cleanup and cleanup.assumptions),
|
|
1217
|
+
"assumptions_read": bool(cleanup.assumptions),
|
|
1082
1218
|
"landing_generated_files": list(generated),
|
|
1083
1219
|
}
|
|
1084
1220
|
report["killed_processes"] = kill_worktree_processes(wt)
|
|
1085
|
-
|
|
1086
|
-
|
|
1087
|
-
|
|
1088
|
-
|
|
1089
|
-
|
|
1090
|
-
|
|
1091
|
-
)
|
|
1221
|
+
remove_verified_worktree_scratch(
|
|
1222
|
+
wt,
|
|
1223
|
+
main_tree,
|
|
1224
|
+
cleanup,
|
|
1225
|
+
verified_scratch_evidence=generated_evidence,
|
|
1226
|
+
)
|
|
1092
1227
|
p = git(["worktree", "remove", wt], cwd=main_tree)
|
|
1093
1228
|
if p.returncode != 0:
|
|
1094
1229
|
raise Stop("4 worktree-remove", "git worktree remove refused — no --force; "
|
|
@@ -1216,14 +1351,24 @@ def main() -> int:
|
|
|
1216
1351
|
l.add_argument("--body-file", help="final PR body (create or overwrite)")
|
|
1217
1352
|
l.add_argument("--anchor", help="wave-anchor issue # (derived via parent-of when omitted)")
|
|
1218
1353
|
l.add_argument("--skip-malformed-drift", action="store_true")
|
|
1219
|
-
l.
|
|
1220
|
-
|
|
1354
|
+
recovery = l.add_mutually_exclusive_group()
|
|
1355
|
+
recovery.add_argument(
|
|
1356
|
+
ABANDON_ATTEMPT_FLAG,
|
|
1221
1357
|
action="store_true",
|
|
1222
1358
|
help=(
|
|
1223
1359
|
"archive an interrupted pre-freeze landing attempt without deleting "
|
|
1224
1360
|
"or claiming its ambiguous files"
|
|
1225
1361
|
),
|
|
1226
1362
|
)
|
|
1363
|
+
recovery.add_argument(
|
|
1364
|
+
RECOVER_CANONICAL_CLEANUP_FLAG,
|
|
1365
|
+
action="store_true",
|
|
1366
|
+
help=(
|
|
1367
|
+
"after canonical cleanup-policy drift, revalidate the frozen landing "
|
|
1368
|
+
"evidence against canonical origin/main and resume the merged "
|
|
1369
|
+
"worktree's teardown idempotently"
|
|
1370
|
+
),
|
|
1371
|
+
)
|
|
1227
1372
|
args = ap.parse_args()
|
|
1228
1373
|
|
|
1229
1374
|
try:
|
package/src/cli.mjs
CHANGED
|
@@ -16,6 +16,7 @@ import {
|
|
|
16
16
|
} from './lib/contributionRouting.mjs';
|
|
17
17
|
import { CONSUMER_ORIGIN, KIT_ORIGIN } from './lib/manifest.mjs';
|
|
18
18
|
import { nonInteractiveUpdateDecision } from './lib/updateDecisions.mjs';
|
|
19
|
+
import { renderRequiredMigration } from './lib/consumerMigrations.mjs';
|
|
19
20
|
import { currentAgentSurface } from './lib/agentSurfaceRegistry.mjs';
|
|
20
21
|
import { createCommandAdapter } from '../scripts/release-state.mjs';
|
|
21
22
|
import { installedIdentityFromDir } from '../scripts/release-parity.mjs';
|
|
@@ -43,6 +44,8 @@ export async function runCli({
|
|
|
43
44
|
const keepDeleted = args.includes('--keep-deleted');
|
|
44
45
|
const restoreDeleted = args.includes('--restore-deleted');
|
|
45
46
|
const ownershipState = args.find((arg) => arg.startsWith('--as='))?.slice('--as='.length);
|
|
47
|
+
// Machine-readable rendering of the same update record the interactive run prints.
|
|
48
|
+
const jsonReport = cmd === 'update' && args.includes('--json');
|
|
46
49
|
let exitCode = 0;
|
|
47
50
|
|
|
48
51
|
if (cmd === 'update' && keepDeleted && restoreDeleted) {
|
|
@@ -57,7 +60,7 @@ export async function runCli({
|
|
|
57
60
|
return 1;
|
|
58
61
|
}
|
|
59
62
|
|
|
60
|
-
p.intro('agent-workflow-kit');
|
|
63
|
+
if (!jsonReport) p.intro('agent-workflow-kit');
|
|
61
64
|
|
|
62
65
|
try {
|
|
63
66
|
if (cmd === 'init') {
|
|
@@ -94,6 +97,11 @@ export async function runCli({
|
|
|
94
97
|
releaseIdentities,
|
|
95
98
|
routingProfile: routingProfileOptions(yes),
|
|
96
99
|
});
|
|
100
|
+
if (jsonReport) {
|
|
101
|
+
process.stdout.write(`${JSON.stringify(updateDocument(r), null, 2)}\n`);
|
|
102
|
+
if (r.state === 'failed') return 1;
|
|
103
|
+
return r.state === 'conflicted' ? 2 : 0;
|
|
104
|
+
}
|
|
97
105
|
printPlan(r);
|
|
98
106
|
printRoutingProfile(r.routingProfile);
|
|
99
107
|
for (const c of r.conflicts) p.note(c.diff || '(binary/!text)', `conflict (not applied): ${c.path}`);
|
|
@@ -183,12 +191,31 @@ export async function runCli({
|
|
|
183
191
|
p.outro('');
|
|
184
192
|
}
|
|
185
193
|
} catch (err) {
|
|
194
|
+
if (jsonReport) {
|
|
195
|
+
process.stdout.write(`${JSON.stringify({
|
|
196
|
+
schemaVersion: UPDATE_DOCUMENT_SCHEMA_VERSION, state: 'failed', error: err.message,
|
|
197
|
+
}, null, 2)}\n`);
|
|
198
|
+
return 1;
|
|
199
|
+
}
|
|
186
200
|
p.cancel(`Error: ${err.message}`);
|
|
187
201
|
return 1;
|
|
188
202
|
}
|
|
189
203
|
return exitCode;
|
|
190
204
|
}
|
|
191
205
|
|
|
206
|
+
const UPDATE_DOCUMENT_SCHEMA_VERSION = 1;
|
|
207
|
+
|
|
208
|
+
/** The single structured update record; `printPlan` renders the same source. */
|
|
209
|
+
function updateDocument(r) {
|
|
210
|
+
return {
|
|
211
|
+
schemaVersion: UPDATE_DOCUMENT_SCHEMA_VERSION,
|
|
212
|
+
state: r.state,
|
|
213
|
+
status: r.status ?? null,
|
|
214
|
+
report: r.report,
|
|
215
|
+
...(r.error ? { error: renderUpdateFailure(r) } : {}),
|
|
216
|
+
};
|
|
217
|
+
}
|
|
218
|
+
|
|
192
219
|
const invokedPath = process.argv[1] ? realpathSync(process.argv[1]) : '';
|
|
193
220
|
if (invokedPath === fileURLToPath(import.meta.url)) {
|
|
194
221
|
process.exitCode = await runCli();
|
|
@@ -211,6 +238,10 @@ function printPlan(r) {
|
|
|
211
238
|
lines.push(`${label}: ${r.availability[key].join(', ') || 'none'}`);
|
|
212
239
|
}
|
|
213
240
|
}
|
|
241
|
+
for (const action of r.requiredMigrations ?? []) {
|
|
242
|
+
lines.push(renderRequiredMigration(action));
|
|
243
|
+
lines.push(` ${action.consequence} ${action.remediation}`);
|
|
244
|
+
}
|
|
214
245
|
for (const owned of r.ownedDiffs ?? []) {
|
|
215
246
|
lines.push(`${owned.state} ${owned.path}`);
|
|
216
247
|
if (owned.binary) {
|