@ikon85/agent-workflow-kit 0.36.5 → 0.38.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/orchestrate-wave/SKILL.md +20 -12
- package/.agents/skills/setup-workflow/SKILL.md +24 -3
- package/.agents/skills/setup-workflow/orchestrate-wave-seed.md +3 -2
- package/.agents/skills/setup-workflow/worktree-lifecycle.md +29 -1
- package/.agents/skills/wrapup/SKILL.md +42 -9
- package/.claude/hooks/migration-snapshot-reminder.py +1 -1
- package/.claude/skills/orchestrate-wave/SKILL.md +11 -11
- package/.claude/skills/setup-workflow/SKILL.md +24 -3
- package/.claude/skills/setup-workflow/orchestrate-wave-seed.md +3 -2
- package/.claude/skills/setup-workflow/worktree-lifecycle.md +29 -1
- package/.claude/skills/skill-manifest.json +1 -1
- package/.claude/skills/wrapup/SKILL.md +33 -10
- package/README.md +88 -1
- package/agent-workflow-kit.package.json +36 -28
- package/docs/adr/0007-session-teardown-requires-provenance-bound-ownership.md +88 -0
- package/docs/agents/workflow-capabilities.json +11 -0
- package/package.json +1 -1
- package/scripts/anchor_table.py +14 -8
- package/scripts/project-skill-extension.mjs +21 -2
- package/scripts/readiness.mjs +32 -4
- package/scripts/release-state.mjs +19 -9
- package/scripts/release-state.test.mjs +71 -8
- package/scripts/test_anchor_table.py +69 -0
- package/scripts/test_board_sync_create_idempotency.py +15 -2
- package/scripts/test_board_sync_wave_title.py +14 -2
- package/scripts/test_census_backstop.py +33 -0
- package/scripts/test_orchestrate_wave_contract.py +44 -0
- package/scripts/test_retro_wrapup_contract.py +19 -2
- package/scripts/test_worktree_wrapup_contract.py +1004 -3
- package/scripts/test_wrapup_land.py +428 -0
- package/scripts/workflow-advisories/core.py +44 -2
- package/scripts/worktree-lifecycle/README.md +126 -4
- package/scripts/worktree-lifecycle/capabilities.json +9 -1
- package/scripts/worktree-lifecycle/cleanup.py +143 -5
- package/scripts/worktree-lifecycle/core.py +1383 -20
- package/scripts/worktree-lifecycle/profile.py +40 -3
- package/scripts/worktree-lifecycle/session.py +1857 -0
- package/scripts/worktree-lifecycle/setup.py +15 -0
- package/scripts/wrapup-land.py +650 -27
- package/src/cli.mjs +60 -27
- package/src/lib/bundle.mjs +1 -0
- package/src/lib/manifest.mjs +173 -3
- package/src/lib/projectSkillExtension.mjs +78 -1
- package/src/lib/updateCandidate.mjs +3 -1
- package/src/lib/updateDecisions.mjs +2 -2
- package/src/lib/verifyUpdateCandidateProtocol.mjs +15 -0
|
@@ -0,0 +1,428 @@
|
|
|
1
|
+
#!/usr/bin/env python3
|
|
2
|
+
"""Focused contracts for wrapup's bounded PR-check merge gate."""
|
|
3
|
+
|
|
4
|
+
import importlib.util
|
|
5
|
+
import io
|
|
6
|
+
import json
|
|
7
|
+
import subprocess
|
|
8
|
+
import unittest
|
|
9
|
+
from pathlib import Path
|
|
10
|
+
|
|
11
|
+
REPO = Path(__file__).resolve().parent.parent
|
|
12
|
+
WRAPUP = REPO / "scripts/wrapup-land.py"
|
|
13
|
+
|
|
14
|
+
|
|
15
|
+
def load_wrapup():
|
|
16
|
+
spec = importlib.util.spec_from_file_location("wrapup_land_check_gate", WRAPUP)
|
|
17
|
+
module = importlib.util.module_from_spec(spec)
|
|
18
|
+
spec.loader.exec_module(module)
|
|
19
|
+
return module
|
|
20
|
+
|
|
21
|
+
|
|
22
|
+
def completed(payload, returncode=0, stderr=""):
|
|
23
|
+
return subprocess.CompletedProcess([], returncode, json.dumps(payload), stderr)
|
|
24
|
+
|
|
25
|
+
|
|
26
|
+
def pr_snapshot(**overrides):
|
|
27
|
+
snapshot = {
|
|
28
|
+
"state": "OPEN",
|
|
29
|
+
"mergeable": "MERGEABLE",
|
|
30
|
+
"mergeStateStatus": "CLEAN",
|
|
31
|
+
"statusCheckRollup": [],
|
|
32
|
+
"baseRefName": "main",
|
|
33
|
+
}
|
|
34
|
+
snapshot.update(overrides)
|
|
35
|
+
return snapshot
|
|
36
|
+
|
|
37
|
+
|
|
38
|
+
def gate_runner(snapshots, required_checks):
|
|
39
|
+
snapshots = iter(snapshots)
|
|
40
|
+
required_checks = iter(required_checks)
|
|
41
|
+
|
|
42
|
+
def runner(cmd, **_kwargs):
|
|
43
|
+
if cmd[:3] == ["gh", "pr", "view"]:
|
|
44
|
+
return completed(next(snapshots))
|
|
45
|
+
if cmd[:3] == ["gh", "pr", "checks"]:
|
|
46
|
+
checks = next(required_checks)
|
|
47
|
+
return completed(checks, returncode=1 if any(
|
|
48
|
+
check.get("state") == "FAILURE" for check in checks
|
|
49
|
+
) else 0)
|
|
50
|
+
raise AssertionError(f"unexpected command: {cmd}")
|
|
51
|
+
|
|
52
|
+
return runner
|
|
53
|
+
|
|
54
|
+
|
|
55
|
+
class FakeClock:
|
|
56
|
+
def __init__(self):
|
|
57
|
+
self.now = 0.0
|
|
58
|
+
|
|
59
|
+
def monotonic(self):
|
|
60
|
+
return self.now
|
|
61
|
+
|
|
62
|
+
def sleep(self, seconds):
|
|
63
|
+
self.now += seconds
|
|
64
|
+
|
|
65
|
+
|
|
66
|
+
class WrapupCheckGateContract(unittest.TestCase):
|
|
67
|
+
def setUp(self):
|
|
68
|
+
self.wrapup = load_wrapup()
|
|
69
|
+
|
|
70
|
+
def test_required_check_names_come_from_active_base_branch_rules(self):
|
|
71
|
+
commands = []
|
|
72
|
+
|
|
73
|
+
def runner(cmd, **_kwargs):
|
|
74
|
+
commands.append(cmd)
|
|
75
|
+
if cmd[:3] == ["gh", "repo", "view"]:
|
|
76
|
+
return completed({"nameWithOwner": "acme/repo"})
|
|
77
|
+
if cmd[:2] == ["gh", "api"]:
|
|
78
|
+
return completed([
|
|
79
|
+
{"type": "pull_request"},
|
|
80
|
+
{
|
|
81
|
+
"type": "required_status_checks",
|
|
82
|
+
"parameters": {
|
|
83
|
+
"required_status_checks": [
|
|
84
|
+
{"context": "test", "integration_id": 1},
|
|
85
|
+
{"context": "lint", "integration_id": 1},
|
|
86
|
+
],
|
|
87
|
+
},
|
|
88
|
+
},
|
|
89
|
+
])
|
|
90
|
+
raise AssertionError(f"unexpected command: {cmd}")
|
|
91
|
+
|
|
92
|
+
self.assertEqual(
|
|
93
|
+
self.wrapup._configured_required_check_names(
|
|
94
|
+
pr_snapshot(baseRefName="release/next"), runner
|
|
95
|
+
),
|
|
96
|
+
{"test", "lint"},
|
|
97
|
+
)
|
|
98
|
+
self.assertIn(
|
|
99
|
+
["gh", "api", "repos/acme/repo/rules/branches/release%2Fnext"],
|
|
100
|
+
commands,
|
|
101
|
+
)
|
|
102
|
+
|
|
103
|
+
def test_pending_checks_are_visible_then_green_checks_proceed(self):
|
|
104
|
+
snapshots = iter([
|
|
105
|
+
pr_snapshot(mergeable="UNKNOWN", mergeStateStatus="BLOCKED"),
|
|
106
|
+
pr_snapshot(),
|
|
107
|
+
])
|
|
108
|
+
checks = iter([
|
|
109
|
+
[{"name": "test", "state": "PENDING", "link": "https://example.test"}],
|
|
110
|
+
[{"name": "test", "state": "SUCCESS", "link": "https://example.test"}],
|
|
111
|
+
])
|
|
112
|
+
calls = []
|
|
113
|
+
|
|
114
|
+
def runner(cmd, **_kwargs):
|
|
115
|
+
calls.append(cmd)
|
|
116
|
+
if cmd[:3] == ["gh", "pr", "view"]:
|
|
117
|
+
return completed(next(snapshots))
|
|
118
|
+
if cmd[:3] == ["gh", "pr", "checks"]:
|
|
119
|
+
return completed(next(checks))
|
|
120
|
+
raise AssertionError(f"unexpected command: {cmd}")
|
|
121
|
+
|
|
122
|
+
clock = FakeClock()
|
|
123
|
+
progress = io.StringIO()
|
|
124
|
+
already_merged = self.wrapup.wait_for_merge_gate(
|
|
125
|
+
"42",
|
|
126
|
+
timeout_seconds=30,
|
|
127
|
+
poll_interval=5,
|
|
128
|
+
command_runner=runner,
|
|
129
|
+
clock=clock.monotonic,
|
|
130
|
+
sleeper=clock.sleep,
|
|
131
|
+
progress_stream=progress,
|
|
132
|
+
configured_required_names={"test"},
|
|
133
|
+
)
|
|
134
|
+
|
|
135
|
+
self.assertFalse(already_merged)
|
|
136
|
+
self.assertEqual(len(calls), 4)
|
|
137
|
+
self.assertIn("waiting for PR #42 checks", progress.getvalue())
|
|
138
|
+
self.assertIn("test", progress.getvalue())
|
|
139
|
+
|
|
140
|
+
def test_explicit_null_conclusion_waits_even_with_green_legacy_state(self):
|
|
141
|
+
checks = [{
|
|
142
|
+
"name": "test",
|
|
143
|
+
"status": "COMPLETED",
|
|
144
|
+
"conclusion": None,
|
|
145
|
+
"state": "SUCCESS",
|
|
146
|
+
}]
|
|
147
|
+
|
|
148
|
+
self.assertEqual(self.wrapup.pending_checks(checks), checks)
|
|
149
|
+
|
|
150
|
+
def test_terminal_red_check_stops_before_merge_and_names_check(self):
|
|
151
|
+
snapshot = pr_snapshot(mergeStateStatus="BLOCKED")
|
|
152
|
+
checks = [[{
|
|
153
|
+
"name": "test",
|
|
154
|
+
"state": "FAILURE",
|
|
155
|
+
"link": "https://example.test",
|
|
156
|
+
}]]
|
|
157
|
+
|
|
158
|
+
with self.assertRaises(self.wrapup.Stop) as stopped:
|
|
159
|
+
self.wrapup.wait_for_merge_gate(
|
|
160
|
+
"42",
|
|
161
|
+
command_runner=gate_runner([snapshot], checks),
|
|
162
|
+
configured_required_names={"test"},
|
|
163
|
+
)
|
|
164
|
+
|
|
165
|
+
self.assertEqual(stopped.exception.step, "0c merge-gate")
|
|
166
|
+
self.assertIn("test", stopped.exception.detail)
|
|
167
|
+
self.assertNotIn("infrastructure failure", stopped.exception.detail)
|
|
168
|
+
|
|
169
|
+
def test_timeout_names_pending_checks_and_elapsed_time(self):
|
|
170
|
+
snapshot = pr_snapshot(mergeable="UNKNOWN", mergeStateStatus="BLOCKED")
|
|
171
|
+
checks = [
|
|
172
|
+
{"name": "test", "state": "QUEUED"},
|
|
173
|
+
{"name": "lint", "state": "PENDING"},
|
|
174
|
+
]
|
|
175
|
+
clock = FakeClock()
|
|
176
|
+
|
|
177
|
+
with self.assertRaises(self.wrapup.Stop) as stopped:
|
|
178
|
+
self.wrapup.wait_for_merge_gate(
|
|
179
|
+
"42",
|
|
180
|
+
timeout_seconds=10,
|
|
181
|
+
poll_interval=5,
|
|
182
|
+
command_runner=gate_runner(
|
|
183
|
+
[snapshot, snapshot, snapshot],
|
|
184
|
+
[checks, checks, checks],
|
|
185
|
+
),
|
|
186
|
+
clock=clock.monotonic,
|
|
187
|
+
sleeper=clock.sleep,
|
|
188
|
+
progress_stream=io.StringIO(),
|
|
189
|
+
configured_required_names={"test", "lint"},
|
|
190
|
+
)
|
|
191
|
+
|
|
192
|
+
self.assertIn("wait budget exceeded", stopped.exception.reason)
|
|
193
|
+
self.assertIn("elapsed=10.0s", stopped.exception.detail)
|
|
194
|
+
self.assertIn("test", stopped.exception.detail)
|
|
195
|
+
self.assertIn("lint", stopped.exception.detail)
|
|
196
|
+
|
|
197
|
+
def test_already_merged_pr_bypasses_check_wait(self):
|
|
198
|
+
snapshot = pr_snapshot(
|
|
199
|
+
state="MERGED", mergeable="UNKNOWN", mergeStateStatus="UNKNOWN"
|
|
200
|
+
)
|
|
201
|
+
clock = FakeClock()
|
|
202
|
+
commands = []
|
|
203
|
+
|
|
204
|
+
def runner(cmd, **_kwargs):
|
|
205
|
+
commands.append(cmd)
|
|
206
|
+
return completed(snapshot)
|
|
207
|
+
|
|
208
|
+
already_merged = self.wrapup.wait_for_merge_gate(
|
|
209
|
+
"42",
|
|
210
|
+
command_runner=runner,
|
|
211
|
+
clock=clock.monotonic,
|
|
212
|
+
sleeper=clock.sleep,
|
|
213
|
+
progress_stream=io.StringIO(),
|
|
214
|
+
configured_required_names={"test"},
|
|
215
|
+
)
|
|
216
|
+
|
|
217
|
+
self.assertTrue(already_merged)
|
|
218
|
+
self.assertEqual(clock.now, 0)
|
|
219
|
+
self.assertEqual(len(commands), 1)
|
|
220
|
+
|
|
221
|
+
def test_optional_red_check_does_not_block_when_required_check_is_green(self):
|
|
222
|
+
snapshot = pr_snapshot(
|
|
223
|
+
mergeStateStatus="BLOCKED",
|
|
224
|
+
statusCheckRollup=[{
|
|
225
|
+
"name": "advisory-browser",
|
|
226
|
+
"status": "COMPLETED",
|
|
227
|
+
"conclusion": "FAILURE",
|
|
228
|
+
}],
|
|
229
|
+
)
|
|
230
|
+
runner = gate_runner([snapshot], [[{
|
|
231
|
+
"name": "test",
|
|
232
|
+
"state": "SUCCESS",
|
|
233
|
+
"link": "https://example.test",
|
|
234
|
+
}]])
|
|
235
|
+
|
|
236
|
+
self.assertFalse(self.wrapup.wait_for_merge_gate(
|
|
237
|
+
"42", command_runner=runner, configured_required_names={"test"}
|
|
238
|
+
))
|
|
239
|
+
|
|
240
|
+
def test_fresh_pr_waits_until_required_checks_become_visible(self):
|
|
241
|
+
snapshots = [
|
|
242
|
+
pr_snapshot(mergeable="UNKNOWN", mergeStateStatus="BLOCKED"),
|
|
243
|
+
pr_snapshot(mergeable="UNKNOWN", mergeStateStatus="BLOCKED"),
|
|
244
|
+
pr_snapshot(),
|
|
245
|
+
]
|
|
246
|
+
required = [
|
|
247
|
+
[],
|
|
248
|
+
[{"name": "test", "state": "PENDING"}],
|
|
249
|
+
[{"name": "test", "state": "SUCCESS"}],
|
|
250
|
+
]
|
|
251
|
+
clock = FakeClock()
|
|
252
|
+
progress = io.StringIO()
|
|
253
|
+
|
|
254
|
+
self.assertFalse(self.wrapup.wait_for_merge_gate(
|
|
255
|
+
"42",
|
|
256
|
+
timeout_seconds=30,
|
|
257
|
+
poll_interval=5,
|
|
258
|
+
command_runner=gate_runner(snapshots, required),
|
|
259
|
+
clock=clock.monotonic,
|
|
260
|
+
sleeper=clock.sleep,
|
|
261
|
+
progress_stream=progress,
|
|
262
|
+
configured_required_names={"test"},
|
|
263
|
+
))
|
|
264
|
+
self.assertIn("test (awaiting discovery)", progress.getvalue())
|
|
265
|
+
self.assertIn("test", progress.getvalue())
|
|
266
|
+
|
|
267
|
+
def test_visible_optional_check_cannot_mask_required_discovery(self):
|
|
268
|
+
snapshot = pr_snapshot(
|
|
269
|
+
mergeStateStatus="BLOCKED",
|
|
270
|
+
statusCheckRollup=[{
|
|
271
|
+
"name": "advisory-browser",
|
|
272
|
+
"status": "COMPLETED",
|
|
273
|
+
"conclusion": "FAILURE",
|
|
274
|
+
}],
|
|
275
|
+
)
|
|
276
|
+
|
|
277
|
+
clock = FakeClock()
|
|
278
|
+
with self.assertRaises(self.wrapup.Stop) as stopped:
|
|
279
|
+
self.wrapup.wait_for_merge_gate(
|
|
280
|
+
"42",
|
|
281
|
+
timeout_seconds=0,
|
|
282
|
+
command_runner=gate_runner([snapshot], [[]]),
|
|
283
|
+
clock=clock.monotonic,
|
|
284
|
+
sleeper=clock.sleep,
|
|
285
|
+
progress_stream=io.StringIO(),
|
|
286
|
+
configured_required_names={"test"},
|
|
287
|
+
)
|
|
288
|
+
self.assertIn("test (awaiting discovery)", stopped.exception.detail)
|
|
289
|
+
|
|
290
|
+
def test_zero_step_failed_job_is_named_as_infrastructure_failure(self):
|
|
291
|
+
snapshot = pr_snapshot(mergeStateStatus="BLOCKED")
|
|
292
|
+
check = {
|
|
293
|
+
"name": "test",
|
|
294
|
+
"state": "FAILURE",
|
|
295
|
+
"link": "https://github.com/acme/repo/actions/runs/123/job/456",
|
|
296
|
+
}
|
|
297
|
+
commands = []
|
|
298
|
+
|
|
299
|
+
def runner(cmd, **_kwargs):
|
|
300
|
+
commands.append(cmd)
|
|
301
|
+
if cmd[:3] == ["gh", "pr", "view"]:
|
|
302
|
+
return completed(snapshot)
|
|
303
|
+
if cmd[:3] == ["gh", "pr", "checks"]:
|
|
304
|
+
return completed([check], returncode=1)
|
|
305
|
+
if "--json" in cmd:
|
|
306
|
+
return completed({
|
|
307
|
+
"jobs": [{
|
|
308
|
+
"databaseId": 456,
|
|
309
|
+
"name": "test",
|
|
310
|
+
"conclusion": "failure",
|
|
311
|
+
"steps": [],
|
|
312
|
+
}],
|
|
313
|
+
})
|
|
314
|
+
return subprocess.CompletedProcess(
|
|
315
|
+
cmd, 1, "", "log unavailable"
|
|
316
|
+
)
|
|
317
|
+
|
|
318
|
+
with self.assertRaises(self.wrapup.Stop) as stopped:
|
|
319
|
+
self.wrapup.wait_for_merge_gate(
|
|
320
|
+
"42", command_runner=runner, configured_required_names={"test"}
|
|
321
|
+
)
|
|
322
|
+
|
|
323
|
+
self.assertIn("test", stopped.exception.detail)
|
|
324
|
+
self.assertIn("infrastructure failure", stopped.exception.detail)
|
|
325
|
+
self.assertIn(["gh", "run", "view", "123", "--json", "jobs"], commands)
|
|
326
|
+
self.assertEqual(
|
|
327
|
+
[cmd[:3] for cmd in commands].count(["gh", "run", "view"]), 1
|
|
328
|
+
)
|
|
329
|
+
|
|
330
|
+
def test_billing_annotation_is_sanitized_and_bounded(self):
|
|
331
|
+
noisy = (
|
|
332
|
+
"The job was not started because recent account payments have failed. "
|
|
333
|
+
+ "TOKEN=secret "
|
|
334
|
+
+ "x" * 1000
|
|
335
|
+
)
|
|
336
|
+
check = {
|
|
337
|
+
"name": "test",
|
|
338
|
+
"state": "FAILURE",
|
|
339
|
+
"link": "https://github.com/acme/repo/actions/runs/123/job/456",
|
|
340
|
+
}
|
|
341
|
+
|
|
342
|
+
def runner(cmd, **_kwargs):
|
|
343
|
+
if "--json" in cmd:
|
|
344
|
+
return completed({"jobs": [{
|
|
345
|
+
"databaseId": 456,
|
|
346
|
+
"name": "test",
|
|
347
|
+
"conclusion": "failure",
|
|
348
|
+
"steps": [{"name": "x"}],
|
|
349
|
+
}]})
|
|
350
|
+
self.assertIn("--job", cmd)
|
|
351
|
+
self.assertIn("456", cmd)
|
|
352
|
+
return subprocess.CompletedProcess(cmd, 0, noisy, "")
|
|
353
|
+
|
|
354
|
+
diagnosis = self.wrapup.infrastructure_failure_diagnosis(
|
|
355
|
+
check, command_runner=runner
|
|
356
|
+
)
|
|
357
|
+
|
|
358
|
+
self.assertIn("infrastructure failure", diagnosis)
|
|
359
|
+
self.assertLessEqual(len(diagnosis), self.wrapup.MAX_EXTERNAL_DETAIL)
|
|
360
|
+
self.assertNotIn("\n", diagnosis)
|
|
361
|
+
|
|
362
|
+
def test_mixed_run_does_not_misclassify_real_test_failure_as_infra(self):
|
|
363
|
+
check = {
|
|
364
|
+
"name": "test",
|
|
365
|
+
"state": "FAILURE",
|
|
366
|
+
"link": "https://github.com/acme/repo/actions/runs/123/job/456",
|
|
367
|
+
}
|
|
368
|
+
commands = []
|
|
369
|
+
|
|
370
|
+
def runner(cmd, **_kwargs):
|
|
371
|
+
commands.append(cmd)
|
|
372
|
+
if "--json" in cmd:
|
|
373
|
+
return completed({"jobs": [
|
|
374
|
+
{
|
|
375
|
+
"databaseId": 111,
|
|
376
|
+
"name": "setup",
|
|
377
|
+
"conclusion": "startup_failure",
|
|
378
|
+
"steps": [],
|
|
379
|
+
},
|
|
380
|
+
{
|
|
381
|
+
"databaseId": 456,
|
|
382
|
+
"name": "test",
|
|
383
|
+
"conclusion": "failure",
|
|
384
|
+
"steps": [{"name": "Run tests", "conclusion": "failure"}],
|
|
385
|
+
},
|
|
386
|
+
]})
|
|
387
|
+
return subprocess.CompletedProcess(
|
|
388
|
+
cmd, 0, "AssertionError: expected green", ""
|
|
389
|
+
)
|
|
390
|
+
|
|
391
|
+
diagnosis = self.wrapup.infrastructure_failure_diagnosis(
|
|
392
|
+
check, command_runner=runner
|
|
393
|
+
)
|
|
394
|
+
|
|
395
|
+
self.assertEqual(diagnosis, "")
|
|
396
|
+
self.assertIn(
|
|
397
|
+
["gh", "run", "view", "123", "--job", "456", "--log-failed"],
|
|
398
|
+
commands,
|
|
399
|
+
)
|
|
400
|
+
|
|
401
|
+
def test_check_without_unique_job_match_is_not_classified_from_run_logs(self):
|
|
402
|
+
check = {
|
|
403
|
+
"name": "test",
|
|
404
|
+
"state": "FAILURE",
|
|
405
|
+
"link": "https://github.com/acme/repo/actions/runs/123",
|
|
406
|
+
}
|
|
407
|
+
commands = []
|
|
408
|
+
|
|
409
|
+
def runner(cmd, **_kwargs):
|
|
410
|
+
commands.append(cmd)
|
|
411
|
+
if "--json" in cmd:
|
|
412
|
+
return completed({"jobs": [
|
|
413
|
+
{"name": "test", "conclusion": "failure", "steps": []},
|
|
414
|
+
{"name": "test", "conclusion": "failure", "steps": []},
|
|
415
|
+
]})
|
|
416
|
+
raise AssertionError("aggregate run logs must not be inspected")
|
|
417
|
+
|
|
418
|
+
self.assertEqual(
|
|
419
|
+
self.wrapup.infrastructure_failure_diagnosis(
|
|
420
|
+
check, command_runner=runner
|
|
421
|
+
),
|
|
422
|
+
"",
|
|
423
|
+
)
|
|
424
|
+
self.assertEqual(len(commands), 1)
|
|
425
|
+
|
|
426
|
+
|
|
427
|
+
if __name__ == "__main__":
|
|
428
|
+
unittest.main()
|
|
@@ -261,11 +261,47 @@ def convention_freshness_decision(profile: dict, root: Path) -> Decision:
|
|
|
261
261
|
)
|
|
262
262
|
|
|
263
263
|
|
|
264
|
-
def
|
|
264
|
+
def _migration_session_marker(profile: dict, payload: dict, root: Path) -> Path | None:
|
|
265
|
+
session_id = payload.get("session_id")
|
|
266
|
+
if not isinstance(session_id, str) or not session_id:
|
|
267
|
+
return None
|
|
268
|
+
state_dir = root / profile.get("baseline", {}).get(
|
|
269
|
+
"stateDir", ".claude/logs/advisory-state",
|
|
270
|
+
)
|
|
271
|
+
safe_session = re.sub(r"[^A-Za-z0-9._-]", "-", session_id)
|
|
272
|
+
return state_dir / f"{safe_session}.migration.hinted"
|
|
273
|
+
|
|
274
|
+
|
|
275
|
+
def _unquoted_shell_text(command: str) -> str:
|
|
276
|
+
visible: list[str] = []
|
|
277
|
+
quote: str | None = None
|
|
278
|
+
escaped = False
|
|
279
|
+
for character in command:
|
|
280
|
+
if escaped:
|
|
281
|
+
visible.append(" " if quote else character)
|
|
282
|
+
escaped = False
|
|
283
|
+
elif character == "\\" and quote != "'":
|
|
284
|
+
visible.append(" ")
|
|
285
|
+
escaped = True
|
|
286
|
+
elif quote:
|
|
287
|
+
visible.append(" ")
|
|
288
|
+
if character == quote:
|
|
289
|
+
quote = None
|
|
290
|
+
elif character in {"'", '"', "`"}:
|
|
291
|
+
visible.append(" ")
|
|
292
|
+
quote = character
|
|
293
|
+
else:
|
|
294
|
+
visible.append(character)
|
|
295
|
+
return "".join(visible)
|
|
296
|
+
|
|
297
|
+
|
|
298
|
+
def migration_reminder_decision(
|
|
299
|
+
profile: dict, payload: dict, root: Path,
|
|
300
|
+
) -> Decision:
|
|
265
301
|
config = profile.get("migration", {})
|
|
266
302
|
if payload.get("tool_name") != "Bash":
|
|
267
303
|
return Decision(None, "PostToolUse")
|
|
268
|
-
command = payload.get("tool_input", {}).get("command", "")
|
|
304
|
+
command = _unquoted_shell_text(payload.get("tool_input", {}).get("command", ""))
|
|
269
305
|
if not any(
|
|
270
306
|
re.search(pattern, command)
|
|
271
307
|
for pattern in config.get("commandMatchers", [])
|
|
@@ -275,6 +311,12 @@ def migration_reminder_decision(profile: dict, payload: dict) -> Decision:
|
|
|
275
311
|
refresh = config.get("refreshCommand", [])
|
|
276
312
|
if not artifact or not refresh:
|
|
277
313
|
return Decision(None, "PostToolUse")
|
|
314
|
+
marker = _migration_session_marker(profile, payload, root)
|
|
315
|
+
if marker and marker.exists():
|
|
316
|
+
return Decision(None, "PostToolUse")
|
|
317
|
+
if marker:
|
|
318
|
+
marker.parent.mkdir(parents=True, exist_ok=True)
|
|
319
|
+
marker.write_text(f"{payload['session_id']}\n", encoding="utf-8")
|
|
278
320
|
message = (
|
|
279
321
|
f"Migration advisory: refresh {artifact} with `{' '.join(refresh)}` "
|
|
280
322
|
"before treating the migration result as complete."
|
|
@@ -17,6 +17,17 @@ they do not carry a second branch regex, worktree traversal, or failure policy.
|
|
|
17
17
|
- `setupEntry` and ordered `setupSteps`: the portable setup command and project
|
|
18
18
|
setup sequence.
|
|
19
19
|
- `riskyCommandPatterns`: commands that must target the active linked worktree.
|
|
20
|
+
- `scratchPatterns`: consumer-owned glob patterns for untracked disposable
|
|
21
|
+
planning artefacts. No filename is assumed by Core.
|
|
22
|
+
- `wrapup.landingGeneratedArtifactPatterns`: an explicitly reviewed
|
|
23
|
+
consumer-owned allowlist for outputs created by the landing commands. It is
|
|
24
|
+
deletion authority, so setup never derives it from `.gitignore` alone and
|
|
25
|
+
update never installs a universal default.
|
|
26
|
+
|
|
27
|
+
Profile globs use repository-relative POSIX semantics. `*` stays inside one
|
|
28
|
+
path segment; `**` crosses `/`, and leading `**/` also matches the repository
|
|
29
|
+
root. For example, `**/__pycache__/**` matches both root and nested caches,
|
|
30
|
+
while `dist-kit/*` does not match `dist-kit/a/b`.
|
|
20
31
|
|
|
21
32
|
Unknown or malformed events fail open without changing repository state.
|
|
22
33
|
Security-sensitive, profile-matched edits and commands fail closed only when
|
|
@@ -35,10 +46,121 @@ the core proves the target is unsafe.
|
|
|
35
46
|
|
|
36
47
|
## Cleanup
|
|
37
48
|
|
|
38
|
-
`cleanup.py` previews by default. Removal refuses protected, dirty,
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
49
|
+
`cleanup.py` previews by default. Removal refuses protected, tracked-dirty,
|
|
50
|
+
non-scratch-dirty, open-PR, or unmerged worktrees. Profile-declared untracked
|
|
51
|
+
scratch is named in the report but does not block removal. The assessment reads
|
|
52
|
+
`ANNAHMEN.md` before removal and returns its contents for propagation.
|
|
53
|
+
`wrapup-land.py` invokes this same assessment after a merge and before killing
|
|
54
|
+
processes or removing the worktree.
|
|
55
|
+
|
|
56
|
+
Explicit removal re-collects facts immediately before mutation, requires the
|
|
57
|
+
same removable inventory, deletes only the exact contained regular scratch
|
|
58
|
+
files from that inventory, and uses ordinary `git worktree remove`. It never
|
|
59
|
+
bypasses Git's final concurrent-change check with force removal.
|
|
60
|
+
|
|
61
|
+
The generic setup route atomically records its ignored and complete
|
|
62
|
+
untracked-file inventories in the linked worktree's Git metadata after its
|
|
63
|
+
configured setup steps. The record is bound to the worktree path, branch, root
|
|
64
|
+
device/inode, and setup HEAD, and carries a canonical digest. Existing
|
|
65
|
+
worktrees can receive a conservative baseline only when they are the exact
|
|
66
|
+
registered no-follow directory on an attached branch with a clean tracked
|
|
67
|
+
worktree and index. Setup reuse defers that backfill, without blocking the
|
|
68
|
+
existing worktree, while tracked work is dirty or landing-generated blockers
|
|
69
|
+
are present. Landing classifies those blockers before writing any baseline;
|
|
70
|
+
after they are moved, every remaining current ignored and untracked path is
|
|
71
|
+
recorded as pre-existing and therefore protected. A corrupt baseline or an
|
|
72
|
+
active landing attempt is never overwritten. The claim-bound session route
|
|
73
|
+
below captures its stricter baseline before project setup so a failed setup has
|
|
74
|
+
an exact recovery boundary.
|
|
75
|
+
|
|
76
|
+
Before merge, the committed worktree profile may only nominate exact,
|
|
77
|
+
identity-bound landing candidates; it authorizes no deletion. After merge and
|
|
78
|
+
`fetch origin/main`, cleanup reloads the profile directly from canonical
|
|
79
|
+
`origin/main`, requires its scratch and generator policies to equal the
|
|
80
|
+
worktree candidate and the policy digest frozen at attempt start, and only then
|
|
81
|
+
authorizes mutation. Every supplied generator-evidence path is independently
|
|
82
|
+
checked against that canonical generator policy. A missing policy is distinct
|
|
83
|
+
from an explicit empty policy. An unmerged or transient branch policy therefore
|
|
84
|
+
cannot grant itself broader cleanup authority.
|
|
85
|
+
|
|
86
|
+
The landing adapter may carry exact scratch evidence only for current ignored
|
|
87
|
+
files that match the consumer-owned
|
|
88
|
+
`wrapup.landingGeneratedArtifactPatterns` profile and were absent from that
|
|
89
|
+
creation baseline. Missing, changed, or incoherent provenance stops landing
|
|
90
|
+
cleanup. Initial/profile-matched files, unmatched files, symlinks, and writes
|
|
91
|
+
after the landing evidence snapshot remain cleanup stops; deletion still uses
|
|
92
|
+
the same descriptor-bound regular-file primitive and a second inventory check.
|
|
93
|
+
Mutable session logs belong in explicit `scratchPatterns`, not in the landing
|
|
94
|
+
generator allowlist: their identity is frozen at final cleanup assessment, so
|
|
95
|
+
normal logging before teardown remains live while a later append or replacement
|
|
96
|
+
still stops deletion.
|
|
97
|
+
Landing-start blockers are classified before a journal is written, so moving a
|
|
98
|
+
protected blocker permits a clean next attempt. An explicit relinquish archives
|
|
99
|
+
either a started or frozen attempt, including drifted evidence, without
|
|
100
|
+
deleting or claiming any file; the next preflight treats every current file as
|
|
101
|
+
pre-existing. Exact unchanged frozen evidence remains directly resumable.
|
|
102
|
+
|
|
103
|
+
`cleanup.py sweep` is the read-only inventory entrypoint. It accounts once for
|
|
104
|
+
every linked worktree and local branch, reports issue/PR/merge/age/removal
|
|
105
|
+
facts, and counts merged remote branches separately. It never removes a
|
|
106
|
+
worktree or branch.
|
|
107
|
+
|
|
108
|
+
## Session-owned teardown
|
|
109
|
+
|
|
110
|
+
`session.py` is the narrower orchestration route. It binds one receipt in the
|
|
111
|
+
Git common directory to the active `wave-active/<anchor>` annotated claim:
|
|
112
|
+
|
|
113
|
+
```sh
|
|
114
|
+
python3 scripts/worktree-lifecycle/session.py begin --anchor <n> --owner <run> --base <wave-head>
|
|
115
|
+
python3 scripts/worktree-lifecycle/session.py create --anchor <n> --owner <run> --base <current-wave-head> <issue> <slug> <type>
|
|
116
|
+
python3 scripts/worktree-lifecycle/session.py recover --anchor <n> --owner <run> --branch <exact-branch>
|
|
117
|
+
python3 scripts/worktree-lifecycle/session.py seal --anchor <n> --owner <run>
|
|
118
|
+
python3 scripts/worktree-lifecycle/session.py inspect --anchor <n> --owner <run> --main origin/main
|
|
119
|
+
python3 scripts/worktree-lifecycle/session.py teardown --anchor <n> --owner <run> --main origin/main
|
|
120
|
+
```
|
|
121
|
+
|
|
122
|
+
Only `create` can add an inventory row, and it refuses any branch or path that
|
|
123
|
+
already existed. It journals a provisional exact row before `git worktree add`,
|
|
124
|
+
then captures the shared artifact baseline before project setup and promotes
|
|
125
|
+
the row only after setup succeeds. Session inspection
|
|
126
|
+
therefore accepts generated scratch only when the exact path matches the
|
|
127
|
+
profile and is absent from the creation baseline; missing, changed, or
|
|
128
|
+
incoherent provenance stops. `seal` records the final exact branch OIDs.
|
|
129
|
+
Inspection reports ancestry, one-to-one stable patch equivalence, unique
|
|
130
|
+
content, and ambiguity separately. Empty commits, non-ancestry merge commits,
|
|
131
|
+
duplicate patch IDs, open or unreadable PR evidence, dirt, protected names,
|
|
132
|
+
stale registrations, recreated removed targets, and identity drift stop the
|
|
133
|
+
whole teardown before mutation.
|
|
134
|
+
|
|
135
|
+
If setup fails, the provisional row becomes `recovery-pending`. Automatic and
|
|
136
|
+
explicit `recover` use the same bounded route: active claim and receipt,
|
|
137
|
+
protected name, PR state, exact ref OID, registration/root identity, and the
|
|
138
|
+
creation baseline are revalidated. A branch-only remainder is compare-deleted
|
|
139
|
+
only at its provisional OID. A registered worktree whose add succeeded before
|
|
140
|
+
root journaling is adopted only when its Git registration/backlink, branch,
|
|
141
|
+
OID, and exact-clean inventory all match.
|
|
142
|
+
|
|
143
|
+
For failed project setup, exact index/worktree diff hashes and paths are frozen
|
|
144
|
+
without storing diff content. Exact regular-file or no-follow symlink identity
|
|
145
|
+
is frozen for every setup-created untracked path. Recovery revalidates that
|
|
146
|
+
evidence, restores only the frozen tracked paths to the creation OID, and
|
|
147
|
+
unlinks only matching untracked identities; later foreign files,
|
|
148
|
+
modifications, or replacements stop without losing receipt ownership. If
|
|
149
|
+
identity capture cannot finish, the receipt retains only a bounded failure
|
|
150
|
+
class and can retry from the unchanged inventory or an exact-clean worktree.
|
|
151
|
+
Raw command, exception, stdout, and stderr text is never retained or archived.
|
|
152
|
+
Recovery uses ordinary worktree removal and compare-deletes the exact ref.
|
|
153
|
+
|
|
154
|
+
Teardown re-runs that complete assessment, archives every recovery OID in the
|
|
155
|
+
receipt before its first mutation, and revalidates canonical main, the active
|
|
156
|
+
claim and receipt, profile protection, PR state, branch OID, worktree
|
|
157
|
+
registration/root identity, dirt, and the exact scratch inventory immediately
|
|
158
|
+
before each target mutation. After ordinary worktree removal it rechecks the
|
|
159
|
+
ref-side gates, then compare-deletes only the recorded local refs with
|
|
160
|
+
`git update-ref -d <ref> <expected-oid>`. A concurrent ref move therefore
|
|
161
|
+
survives, while a partial run keeps its recovery OIDs and can resume. Generic
|
|
162
|
+
cleanup and sweep never consume this ownership route and never infer authority
|
|
163
|
+
over a foreign branch.
|
|
42
164
|
|
|
43
165
|
Claude hook wiring and any Codex adaptation consume this same profile and core.
|
|
44
166
|
An adapter may change only the surface event envelope; it must preserve the
|
|
@@ -39,7 +39,15 @@
|
|
|
39
39
|
{
|
|
40
40
|
"historicalPath": "scripts/cleanup-worktrees.sh",
|
|
41
41
|
"artifact": "scripts/worktree-lifecycle/cleanup.py",
|
|
42
|
-
"
|
|
42
|
+
"supportingArtifacts": ["scripts/worktree-lifecycle/session.py"],
|
|
43
|
+
"primitives": [
|
|
44
|
+
"profile",
|
|
45
|
+
"git-facts",
|
|
46
|
+
"cleanup-safety",
|
|
47
|
+
"claim-bound-session-receipt",
|
|
48
|
+
"patch-equivalence",
|
|
49
|
+
"compare-and-delete"
|
|
50
|
+
]
|
|
43
51
|
}
|
|
44
52
|
]
|
|
45
53
|
}
|