@ikon85/agent-workflow-kit 0.36.4 → 0.37.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.
Files changed (43) hide show
  1. package/.agents/skills/orchestrate-wave/SKILL.md +15 -7
  2. package/.agents/skills/setup-workflow/SKILL.md +16 -2
  3. package/.agents/skills/setup-workflow/orchestrate-wave-seed.md +3 -2
  4. package/.agents/skills/setup-workflow/worktree-lifecycle.md +11 -0
  5. package/.agents/skills/wrapup/SKILL.md +20 -9
  6. package/.claude/hooks/migration-snapshot-reminder.py +1 -1
  7. package/.claude/skills/orchestrate-wave/SKILL.md +6 -6
  8. package/.claude/skills/setup-workflow/SKILL.md +16 -2
  9. package/.claude/skills/setup-workflow/orchestrate-wave-seed.md +3 -2
  10. package/.claude/skills/setup-workflow/worktree-lifecycle.md +11 -0
  11. package/.claude/skills/skill-manifest.json +1 -1
  12. package/.claude/skills/wrapup/SKILL.md +11 -10
  13. package/README.md +59 -1
  14. package/agent-workflow-kit.package.json +26 -26
  15. package/docs/agents/workflow-capabilities.json +1 -0
  16. package/package.json +1 -1
  17. package/scripts/anchor_table.py +14 -8
  18. package/scripts/project-skill-extension.mjs +21 -2
  19. package/scripts/readiness.mjs +32 -4
  20. package/scripts/release-state.mjs +19 -9
  21. package/scripts/release-state.test.mjs +71 -8
  22. package/scripts/test_anchor_table.py +69 -0
  23. package/scripts/test_board_sync_create_idempotency.py +15 -2
  24. package/scripts/test_board_sync_wave_title.py +14 -2
  25. package/scripts/test_census_backstop.py +33 -0
  26. package/scripts/test_orchestrate_wave_contract.py +35 -0
  27. package/scripts/test_retro_wrapup_contract.py +19 -2
  28. package/scripts/test_wrapup_land.py +428 -0
  29. package/scripts/workflow-advisories/core.py +44 -2
  30. package/scripts/worktree-lifecycle/README.md +18 -4
  31. package/scripts/worktree-lifecycle/cleanup.py +173 -6
  32. package/scripts/worktree-lifecycle/core.py +331 -18
  33. package/scripts/worktree-lifecycle/profile.py +2 -0
  34. package/scripts/wrapup-land.py +336 -15
  35. package/src/cli.mjs +60 -27
  36. package/src/lib/manifest.mjs +173 -3
  37. package/src/lib/projectSkillExtension.mjs +78 -1
  38. package/src/lib/updateCandidate.mjs +11 -1
  39. package/src/lib/updateDecisions.mjs +2 -2
  40. package/src/lib/updateReconcile.mjs +6 -0
  41. package/src/lib/verifyUpdateCandidate.mjs +6 -3
  42. package/src/lib/verifyUpdateCandidateProtocol.mjs +15 -0
  43. package/src/lib/verifyUpdateCandidateTransaction.mjs +23 -1
@@ -34,6 +34,7 @@ PROFILE = {
34
34
 
35
35
 
36
36
  def load_board_sync():
37
+ previous_profile = os.environ.get("BOARD_SYNC_PROFILE")
37
38
  with tempfile.NamedTemporaryFile("w", suffix=".md", delete=False) as profile:
38
39
  profile.write("<!-- board-sync:profile -->\n```json\n")
39
40
  json.dump(PROFILE, profile)
@@ -45,11 +46,18 @@ def load_board_sync():
45
46
  )
46
47
  module = importlib.util.module_from_spec(spec)
47
48
  assert spec.loader is not None
48
- spec.loader.exec_module(module)
49
- Path(profile_path).unlink()
49
+ try:
50
+ spec.loader.exec_module(module)
51
+ finally:
52
+ Path(profile_path).unlink()
53
+ if previous_profile is None:
54
+ os.environ.pop("BOARD_SYNC_PROFILE", None)
55
+ else:
56
+ os.environ["BOARD_SYNC_PROFILE"] = previous_profile
50
57
  return module
51
58
 
52
59
 
60
+ PROFILE_BEFORE_LOAD = os.environ.get("BOARD_SYNC_PROFILE")
53
61
  bs = load_board_sync()
54
62
 
55
63
 
@@ -80,6 +88,11 @@ class FakeGh:
80
88
  return ""
81
89
 
82
90
 
91
+ class BoardSyncImportIsolationTest(unittest.TestCase):
92
+ def test_loading_board_sync_restores_the_caller_profile(self):
93
+ self.assertEqual(os.environ.get("BOARD_SYNC_PROFILE"), PROFILE_BEFORE_LOAD)
94
+
95
+
83
96
  class MarkerAwareCreateTest(unittest.TestCase):
84
97
  def test_identical_program_leaf_retry_reuses_existing_issue(self):
85
98
  marker = "<!-- program-leaf-source: program-x/1a -->"
@@ -37,6 +37,7 @@ BASE_PROFILE = {
37
37
 
38
38
 
39
39
  def load_board_sync(profile_dict: dict, module_name: str):
40
+ previous_profile = os.environ.get("BOARD_SYNC_PROFILE")
40
41
  with tempfile.NamedTemporaryFile("w", suffix=".md", delete=False) as profile:
41
42
  profile.write("<!-- board-sync:profile -->\n```json\n")
42
43
  json.dump(profile_dict, profile)
@@ -48,8 +49,14 @@ def load_board_sync(profile_dict: dict, module_name: str):
48
49
  )
49
50
  module = importlib.util.module_from_spec(spec)
50
51
  assert spec.loader is not None
51
- spec.loader.exec_module(module)
52
- Path(profile_path).unlink()
52
+ try:
53
+ spec.loader.exec_module(module)
54
+ finally:
55
+ Path(profile_path).unlink()
56
+ if previous_profile is None:
57
+ os.environ.pop("BOARD_SYNC_PROFILE", None)
58
+ else:
59
+ os.environ["BOARD_SYNC_PROFILE"] = previous_profile
53
60
  return module
54
61
 
55
62
 
@@ -75,6 +82,11 @@ class WaveTitleDefaultPrefix(unittest.TestCase):
75
82
  "Welle 29 — Auth hardening",
76
83
  )
77
84
 
85
+ def test_loading_board_sync_restores_the_caller_profile(self):
86
+ before = os.environ.get("BOARD_SYNC_PROFILE")
87
+ load_board_sync(copy.deepcopy(BASE_PROFILE), "board_sync_wave_isolation")
88
+ self.assertEqual(os.environ.get("BOARD_SYNC_PROFILE"), before)
89
+
78
90
 
79
91
  class WaveTitleProfilePrefix(unittest.TestCase):
80
92
  """A profile with `titles.wavePrefix` drives the anchor title language."""
@@ -40,6 +40,39 @@ class CensusBackstopTest(unittest.TestCase):
40
40
  "overrides": overrides or [],
41
41
  }) + "\n", encoding="utf-8")
42
42
 
43
+ def test_execute_ready_missing_profile_failure_is_captured_and_exact(self):
44
+ missing_profile = ROOT / "missing-board-profile.md"
45
+ environment = os.environ.copy()
46
+ environment["BOARD_SYNC_PROFILE"] = str(missing_profile)
47
+
48
+ completed = subprocess.run(
49
+ [
50
+ sys.executable,
51
+ str(ROOT / "scripts" / "execute-ready-check.py"),
52
+ "--issue",
53
+ "52",
54
+ "--mode",
55
+ "handoff",
56
+ ],
57
+ cwd=ROOT,
58
+ env=environment,
59
+ capture_output=True,
60
+ text=True,
61
+ )
62
+
63
+ expected_stderr = (
64
+ "[FAIL] execute-ready-check: Board-Profil nicht verfügbar — "
65
+ f"board profile not found at {missing_profile} — run /setup-workflow "
66
+ "to seed docs/agents/board-sync.md.\n"
67
+ )
68
+ diagnostics = (
69
+ f"stdout:\n{completed.stdout}\n"
70
+ f"stderr:\n{completed.stderr}"
71
+ )
72
+ self.assertEqual(completed.returncode, 1, diagnostics)
73
+ self.assertEqual(completed.stdout, "", diagnostics)
74
+ self.assertEqual(completed.stderr, expected_stderr, diagnostics)
75
+
43
76
  def activate_current(self, root):
44
77
  fresh = DRIFT_GUARD.scan_census_status(root)["fresh"]
45
78
  (root / ".census" / "active.json").write_text(
@@ -210,6 +210,41 @@ class OrchestrateWaveContract(unittest.TestCase):
210
210
  ):
211
211
  self.assertIn(fragment, done)
212
212
 
213
+ def test_phase_one_orders_producers_before_surface_measurers(self):
214
+ prose = " ".join(self.skill.split())
215
+ phase_one = prose.split("## Phase 1", 1)[1].split("## Phase 2", 1)[0]
216
+ for fragment in (
217
+ "Producer/measurer coupling",
218
+ "semantic dependency",
219
+ "measurer strictly after the producer",
220
+ "baseline reconciliation",
221
+ "drop producer-resolved entries",
222
+ ):
223
+ self.assertIn(fragment, phase_one)
224
+
225
+ done = phase_one.split("**Done when:**", 1)[1]
226
+ for fragment in (
227
+ "producer/measurer pair",
228
+ "explicit edge/order",
229
+ "reconciliation owner",
230
+ ):
231
+ self.assertIn(fragment, done)
232
+
233
+ def test_phase_four_centrally_runs_builder_browser_specs_before_ci(self):
234
+ prose = " ".join(self.skill.split())
235
+ phase_four = prose.split("## Phase 4", 1)[1].split("## Phase 5", 1)[0]
236
+ browser_contract = "builders authored or changed browser specs"
237
+ central_gate = "Re-run your project's full CI/verify gate CENTRALLY yourself"
238
+ for fragment in (
239
+ browser_contract,
240
+ "unverified artifact",
241
+ "ONE central green run",
242
+ "if `§Verify Recipe` names a local e2e runner",
243
+ "Never invent a command under generic fallback",
244
+ ):
245
+ self.assertIn(fragment, phase_four)
246
+ self.assertLess(phase_four.index(browser_contract), phase_four.index(central_gate))
247
+
213
248
  def test_phase_two_keeps_per_slice_routing_decisions(self):
214
249
  prose = " ".join(self.skill.split())
215
250
  self.assertIn("(a) inline vs delegate", prose)
@@ -88,7 +88,7 @@ class WrapupChainingContract(unittest.TestCase):
88
88
  required = (
89
89
  "invoke the `retro` skill immediately in this run",
90
90
  "land nothing in this run",
91
- "fresh explicit `/wrapup` invocation",
91
+ "fresh explicit `$wrapup` or `/wrapup` invocation",
92
92
  )
93
93
  for surface in SURFACES:
94
94
  with self.subTest(surface=surface):
@@ -120,10 +120,27 @@ class WrapupChainingContract(unittest.TestCase):
120
120
  with self.subTest(surface=surface):
121
121
  text = skill(surface, "wrapup")
122
122
  self.assertIn("disable-model-invocation: true", text)
123
- self.assertIn("user's `/wrapup` input IS the explicit", text)
123
+ self.assertIn(
124
+ "user's direct `$wrapup` or `/wrapup` input IS the explicit",
125
+ text,
126
+ )
124
127
  self.assertIn("program-sync", text)
125
128
  self.assertIn("Phasen-Gates", text)
126
129
 
130
+ def test_wrapup_accepts_only_direct_user_dollar_or_slash_invocations(self):
131
+ required = (
132
+ "direct `$wrapup` or `/wrapup`",
133
+ "Natural-language requests",
134
+ "indirect skill chaining",
135
+ "autonomous invocation",
136
+ "fresh explicit `$wrapup` or `/wrapup` invocation",
137
+ )
138
+ for surface in SURFACES:
139
+ with self.subTest(surface=surface):
140
+ text = contract_text(surface, "wrapup")
141
+ for phrase in required:
142
+ self.assertIn(phrase, text)
143
+
127
144
  def test_new_contract_is_reference_free(self):
128
145
  for surface in SURFACES:
129
146
  with self.subTest(surface=surface):
@@ -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 migration_reminder_decision(profile: dict, payload: dict) -> Decision:
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."