@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.
Files changed (44) hide show
  1. package/.agents/skills/grill-me/SKILL.md +1 -1
  2. package/.agents/skills/grill-with-docs/SKILL.md +1 -1
  3. package/.agents/skills/kit-update/SKILL.md +33 -1
  4. package/.agents/skills/orchestrate-wave/SKILL.md +4 -4
  5. package/.agents/skills/setup-workflow/SKILL.md +84 -3
  6. package/.agents/skills/setup-workflow/board-sync.md +6 -2
  7. package/.agents/skills/setup-workflow/workflow-advisories.md +34 -0
  8. package/.agents/skills/setup-workflow/worktree-lifecycle.md +54 -3
  9. package/.agents/skills/wrapup/SKILL.md +24 -0
  10. package/.claude/hooks/drift-guard.py +212 -21
  11. package/.claude/skills/grill-me/SKILL.md +1 -1
  12. package/.claude/skills/grill-with-docs/SKILL.md +1 -1
  13. package/.claude/skills/kit-update/SKILL.md +33 -1
  14. package/.claude/skills/orchestrate-wave/SKILL.md +4 -4
  15. package/.claude/skills/setup-workflow/SKILL.md +84 -3
  16. package/.claude/skills/setup-workflow/board-sync.md +6 -2
  17. package/.claude/skills/setup-workflow/workflow-advisories.md +34 -0
  18. package/.claude/skills/setup-workflow/worktree-lifecycle.md +54 -3
  19. package/.claude/skills/wrapup/SKILL.md +24 -0
  20. package/README.md +62 -0
  21. package/agent-workflow-kit.package.json +57 -25
  22. package/docs/adr/0008-planning-ignore-rules-are-offered-never-installed.md +84 -0
  23. package/package.json +1 -1
  24. package/scripts/board_bootstrap.py +367 -0
  25. package/scripts/kit-update-pr.mjs +20 -7
  26. package/scripts/kit-update-pr.test.mjs +29 -0
  27. package/scripts/profile_globs.py +347 -0
  28. package/scripts/test_board_bootstrap.py +348 -0
  29. package/scripts/test_drift_guard_diagnostics.py +295 -0
  30. package/scripts/test_profile_globs.py +280 -0
  31. package/scripts/test_skill_setup_workflow_seeds.py +87 -0
  32. package/scripts/test_worktree_ignore_seed.py +320 -0
  33. package/scripts/test_worktree_wrapup_contract.py +588 -0
  34. package/scripts/workflow-advisories/core.py +29 -4
  35. package/scripts/worktree-lifecycle/README.md +53 -4
  36. package/scripts/worktree-lifecycle/core.py +211 -60
  37. package/scripts/worktree-lifecycle/ignore_seed.py +226 -0
  38. package/scripts/worktree-lifecycle/plan-artifacts.json +37 -0
  39. package/scripts/wrapup-land.py +179 -34
  40. package/src/cli.mjs +32 -1
  41. package/src/commands/update.mjs +30 -21
  42. package/src/consumer-migrations.json +19 -0
  43. package/src/lib/bundle.mjs +16 -0
  44. package/src/lib/consumerMigrations.mjs +161 -0
@@ -173,6 +173,83 @@ def create_real_kit_merged_worktree(root: Path) -> tuple[Path, Path]:
173
173
  return main, worktree
174
174
 
175
175
 
176
+ def merged_landing_runner(wrapup):
177
+ """Stub the external gh/board calls of an already-MERGED land run."""
178
+ real_run = wrapup.run
179
+
180
+ def landing_run(args, cwd=None, check=False):
181
+ if args[:3] == ["gh", "pr", "view"]:
182
+ fields = args[-1]
183
+ payload = (
184
+ {"number": 42, "state": "MERGED", "body": "**Retro:** n/a"}
185
+ if "number,state,body" in fields
186
+ else {"state": "MERGED"}
187
+ )
188
+ return subprocess.CompletedProcess(args, 0, json.dumps(payload), "")
189
+ if args[:3] == ["gh", "issue", "view"]:
190
+ return subprocess.CompletedProcess(
191
+ args, 0, json.dumps({"state": "CLOSED"}), ""
192
+ )
193
+ if args[:3] == ["gh", "pr", "list"]:
194
+ return subprocess.CompletedProcess(args, 0, "", "")
195
+ if args and args[0] == os.sys.executable:
196
+ return subprocess.CompletedProcess(args, 0, "", "")
197
+ return real_run(args, cwd=cwd, check=check)
198
+
199
+ return landing_run
200
+
201
+
202
+ def land_args(branch="fix/268-cleanup", **overrides):
203
+ values = {
204
+ "branch": branch,
205
+ "body_file": None,
206
+ "title": None,
207
+ "anchor": None,
208
+ "skip_malformed_drift": False,
209
+ "abandon_unfinished_attempt": False,
210
+ "recover_canonical_cleanup": False,
211
+ }
212
+ values.update(overrides)
213
+ return SimpleNamespace(**values)
214
+
215
+
216
+ def run_land(wrapup, main: Path, args) -> dict:
217
+ """Run `land` from the main tree against an already-merged PR."""
218
+ previous = Path.cwd()
219
+ try:
220
+ os.chdir(main)
221
+ with (
222
+ patch.object(wrapup, "run", side_effect=merged_landing_runner(wrapup)),
223
+ patch.object(wrapup, "wait_for_merge_gate", return_value=True),
224
+ patch.object(wrapup, "kill_worktree_processes", return_value=[]),
225
+ ):
226
+ return wrapup.cmd_land(args)
227
+ finally:
228
+ os.chdir(previous)
229
+
230
+
231
+ def rewrite_landing_attempt(core, worktree: Path, mutate) -> Path:
232
+ """Rewrite the attempt journal with a coherent digest for a forged payload."""
233
+ path = core.artifact_baseline_path(worktree).with_name(core.LANDING_ATTEMPT_FILE)
234
+ document = json.loads(path.read_text(encoding="utf-8"))
235
+ payload = {key: value for key, value in document.items() if key != "sha256"}
236
+ mutate(payload)
237
+ path.write_text(
238
+ json.dumps({**payload, "sha256": core._baseline_digest(payload)}),
239
+ encoding="utf-8",
240
+ )
241
+ return path
242
+
243
+
244
+ def downgrade_landing_attempt_to_v1(core, worktree: Path) -> Path:
245
+ """Produce the coherent v1 journal shape a pre-upgrade landing left behind."""
246
+ def mutate(payload):
247
+ payload.pop("policyDigest", None)
248
+ payload["contractVersion"] = 1
249
+
250
+ return rewrite_landing_attempt(core, worktree, mutate)
251
+
252
+
176
253
  class WorktreeCleanupContract(unittest.TestCase):
177
254
  def test_profile_scratch_and_generated_evidence_share_safe_removal_contract(self):
178
255
  wrapup = load_wrapup()
@@ -1049,5 +1126,516 @@ class WorktreeCleanupContract(unittest.TestCase):
1049
1126
  self.assertIn("consumer-owned", stopped.exception.reason)
1050
1127
 
1051
1128
 
1129
+ class LandingAttemptJournalContract(unittest.TestCase):
1130
+ """#274 — one nofollow-safe journal classification and no dead v1 surface."""
1131
+
1132
+ def test_symlinked_attempt_journal_is_refused_without_following_it(self):
1133
+ wrapup = load_wrapup()
1134
+ with tempfile.TemporaryDirectory() as tmp:
1135
+ main, worktree = create_merged_worktree(Path(tmp))
1136
+ wrapup.landing_start_artifact_inventory(str(worktree), str(main))
1137
+ core = wrapup.load_worktree_cleanup_core()
1138
+ attempt_path = core.landing_attempt_path(worktree)
1139
+ moved = attempt_path.with_name("relocated-attempt.json")
1140
+ os.replace(attempt_path, moved)
1141
+ attempt_path.symlink_to(moved)
1142
+ frozen_bytes = moved.read_text(encoding="utf-8")
1143
+
1144
+ with self.assertRaises(wrapup.Stop) as stopped:
1145
+ wrapup.landing_start_artifact_inventory(str(worktree), str(main))
1146
+
1147
+ self.assertIn("not a regular file", stopped.exception.reason)
1148
+ self.assertTrue(attempt_path.is_symlink())
1149
+ self.assertEqual(moved.read_text(encoding="utf-8"), frozen_bytes)
1150
+
1151
+ def test_dangling_attempt_symlink_is_never_silently_replaced(self):
1152
+ wrapup = load_wrapup()
1153
+ with tempfile.TemporaryDirectory() as tmp:
1154
+ main, worktree = create_merged_worktree(Path(tmp))
1155
+ wrapup.landing_start_artifact_inventory(str(worktree), str(main))
1156
+ core = wrapup.load_worktree_cleanup_core()
1157
+ attempt_path = core.landing_attempt_path(worktree)
1158
+ absent = attempt_path.with_name("absent-attempt.json")
1159
+ attempt_path.unlink()
1160
+ attempt_path.symlink_to(absent)
1161
+
1162
+ with self.assertRaises(wrapup.Stop) as stopped:
1163
+ wrapup.landing_start_artifact_inventory(str(worktree), str(main))
1164
+
1165
+ self.assertIn("not a regular file", stopped.exception.reason)
1166
+ self.assertTrue(attempt_path.is_symlink())
1167
+ self.assertFalse(os.path.lexists(absent))
1168
+
1169
+ def test_attempt_presence_uses_one_nofollow_classification(self):
1170
+ wrapup = load_wrapup()
1171
+ with tempfile.TemporaryDirectory() as tmp:
1172
+ main, worktree = create_merged_worktree(Path(tmp))
1173
+ core = wrapup.load_worktree_cleanup_core()
1174
+ attempt_path = core.landing_attempt_path(worktree)
1175
+
1176
+ self.assertFalse(core.landing_attempt_exists(attempt_path))
1177
+ wrapup.landing_start_artifact_inventory(str(worktree), str(main))
1178
+ self.assertTrue(core.landing_attempt_exists(attempt_path))
1179
+ attempt_path.unlink()
1180
+ attempt_path.symlink_to(attempt_path.with_name("absent-attempt.json"))
1181
+ self.assertTrue(core.landing_attempt_exists(attempt_path))
1182
+
1183
+ def test_archived_v2_receipt_name_is_not_stamped_v1(self):
1184
+ wrapup = load_wrapup()
1185
+ with tempfile.TemporaryDirectory() as tmp:
1186
+ main, worktree = create_merged_worktree(Path(tmp))
1187
+ wrapup.landing_start_artifact_inventory(str(worktree), str(main))
1188
+
1189
+ archive = Path(
1190
+ wrapup.abandon_unfinished_landing_attempt(str(worktree), str(main))
1191
+ )
1192
+
1193
+ self.assertTrue(archive.is_file())
1194
+ self.assertNotIn("-v1", archive.name)
1195
+ self.assertIn(".v2.abandoned-", archive.name)
1196
+ self.assertTrue(
1197
+ archive.name.startswith(core_archive_stem(wrapup)),
1198
+ archive.name,
1199
+ )
1200
+
1201
+ def test_archived_v1_receipt_name_reports_its_own_contract_version(self):
1202
+ wrapup = load_wrapup()
1203
+ with tempfile.TemporaryDirectory() as tmp:
1204
+ main, worktree = create_merged_worktree(Path(tmp))
1205
+ wrapup.landing_start_artifact_inventory(str(worktree), str(main))
1206
+ core = wrapup.load_worktree_cleanup_core()
1207
+ downgrade_landing_attempt_to_v1(core, worktree)
1208
+
1209
+ archive = Path(
1210
+ wrapup.abandon_unfinished_landing_attempt(str(worktree), str(main))
1211
+ )
1212
+
1213
+ self.assertTrue(archive.is_file())
1214
+ self.assertIn(".v1.abandoned-", archive.name)
1215
+
1216
+ def test_cleanup_authorization_always_returns_an_assessment_or_stops(self):
1217
+ """Coverage for the removed `cleanup is not None` dead branches."""
1218
+ wrapup = load_wrapup()
1219
+ with tempfile.TemporaryDirectory() as tmp:
1220
+ main, worktree = create_merged_worktree(Path(tmp))
1221
+ wrapup.landing_start_artifact_inventory(str(worktree), str(main))
1222
+
1223
+ assessment = wrapup.ensure_worktree_removable(str(worktree), str(main))
1224
+ self.assertIsNotNone(assessment)
1225
+ self.assertEqual(assessment.reasons, ())
1226
+
1227
+ blocker = worktree / "consumer/blocker.txt"
1228
+ blocker.parent.mkdir(parents=True)
1229
+ blocker.write_text("keep", encoding="utf-8")
1230
+ with self.assertRaises(wrapup.Stop):
1231
+ wrapup.ensure_worktree_removable(str(worktree), str(main))
1232
+ self.assertEqual(blocker.read_text(encoding="utf-8"), "keep")
1233
+
1234
+ def test_land_reports_generated_files_without_a_dead_guard_flag(self):
1235
+ wrapup = load_wrapup()
1236
+ with tempfile.TemporaryDirectory() as tmp:
1237
+ main, worktree = create_merged_worktree(Path(tmp))
1238
+ wrapup.landing_start_artifact_inventory(str(worktree), str(main))
1239
+ generated = worktree / "dist-kit/package.tgz"
1240
+ generated.parent.mkdir(parents=True)
1241
+ generated.write_text("generated\n", encoding="utf-8")
1242
+ wrapup.freeze_landing_artifact_evidence(
1243
+ str(worktree), str(main), push_succeeded=True
1244
+ )
1245
+
1246
+ report = run_land(wrapup, main, land_args())
1247
+
1248
+ self.assertEqual(
1249
+ report["cleanup_guard"],
1250
+ {
1251
+ "assumptions_read": False,
1252
+ "landing_generated_files": ["dist-kit/package.tgz"],
1253
+ },
1254
+ )
1255
+ self.assertEqual(report["worktree_removed"], str(worktree))
1256
+ self.assertFalse(worktree.exists())
1257
+
1258
+ def test_tampered_v2_journal_claiming_generated_files_still_stops_land(self):
1259
+ wrapup = load_wrapup()
1260
+ with tempfile.TemporaryDirectory() as tmp:
1261
+ main, worktree = create_merged_worktree(Path(tmp))
1262
+ wrapup.landing_start_artifact_inventory(str(worktree), str(main))
1263
+ core = wrapup.load_worktree_cleanup_core()
1264
+ generated = worktree / "dist-kit/package.tgz"
1265
+ generated.parent.mkdir(parents=True)
1266
+ generated.write_text("generated\n", encoding="utf-8")
1267
+ wrapup.freeze_landing_artifact_evidence(
1268
+ str(worktree), str(main), push_succeeded=True
1269
+ )
1270
+ victim = worktree / "dist-kit/claimed.tgz"
1271
+ victim.write_text("consumer bytes", encoding="utf-8")
1272
+ rewrite_landing_attempt(
1273
+ core,
1274
+ worktree,
1275
+ lambda payload: payload.__setitem__(
1276
+ "generatedFiles", ["dist-kit/claimed.tgz"]
1277
+ ),
1278
+ )
1279
+
1280
+ with self.assertRaises(wrapup.Stop) as stopped:
1281
+ run_land(wrapup, main, land_args())
1282
+
1283
+ self.assertIn("consumer-owned", stopped.exception.reason)
1284
+ self.assertIn("dist-kit/claimed.tgz", stopped.exception.reason)
1285
+ self.assertEqual(victim.read_text(encoding="utf-8"), "consumer bytes")
1286
+ self.assertTrue(worktree.is_dir())
1287
+
1288
+
1289
+ class LegacyLandingAttemptContract(unittest.TestCase):
1290
+ """#275 — a coherent v1 journal is legacy, not corruption, and has a route out."""
1291
+
1292
+ def assert_legacy_stop(self, stop):
1293
+ self.assertIn("superseded v1 journal contract", stop.reason)
1294
+ self.assertIn("--abandon-unfinished-attempt", stop.reason)
1295
+ self.assertNotIn("incoherent", stop.reason)
1296
+
1297
+ def test_coherent_v1_started_journal_is_classified_as_legacy(self):
1298
+ wrapup = load_wrapup()
1299
+ with tempfile.TemporaryDirectory() as tmp:
1300
+ main, worktree = create_merged_worktree(Path(tmp))
1301
+ wrapup.landing_start_artifact_inventory(str(worktree), str(main))
1302
+ core = wrapup.load_worktree_cleanup_core()
1303
+ downgrade_landing_attempt_to_v1(core, worktree)
1304
+
1305
+ with self.assertRaises(wrapup.Stop) as stopped:
1306
+ wrapup.landing_start_artifact_inventory(str(worktree), str(main))
1307
+
1308
+ self.assert_legacy_stop(stopped.exception)
1309
+
1310
+ def test_coherent_v1_frozen_journal_is_classified_as_legacy(self):
1311
+ wrapup = load_wrapup()
1312
+ with tempfile.TemporaryDirectory() as tmp:
1313
+ main, worktree = create_merged_worktree(Path(tmp))
1314
+ wrapup.landing_start_artifact_inventory(str(worktree), str(main))
1315
+ generated = worktree / "dist-kit/package.tgz"
1316
+ generated.parent.mkdir(parents=True)
1317
+ generated.write_text("generated\n", encoding="utf-8")
1318
+ wrapup.freeze_landing_artifact_evidence(
1319
+ str(worktree), str(main), push_succeeded=True
1320
+ )
1321
+ core = wrapup.load_worktree_cleanup_core()
1322
+ downgrade_landing_attempt_to_v1(core, worktree)
1323
+
1324
+ with self.assertRaises(wrapup.Stop) as stopped:
1325
+ wrapup.landing_start_artifact_inventory(str(worktree), str(main))
1326
+
1327
+ self.assert_legacy_stop(stopped.exception)
1328
+ self.assertEqual(generated.read_text(encoding="utf-8"), "generated\n")
1329
+
1330
+ def test_incoherent_v1_journal_stays_classified_as_corruption(self):
1331
+ wrapup = load_wrapup()
1332
+ with tempfile.TemporaryDirectory() as tmp:
1333
+ main, worktree = create_merged_worktree(Path(tmp))
1334
+ wrapup.landing_start_artifact_inventory(str(worktree), str(main))
1335
+ core = wrapup.load_worktree_cleanup_core()
1336
+
1337
+ def mutate(payload):
1338
+ payload.pop("policyDigest", None)
1339
+ payload["contractVersion"] = 1
1340
+ payload["branch"] = "fix/999-foreign"
1341
+
1342
+ rewrite_landing_attempt(core, worktree, mutate)
1343
+
1344
+ with self.assertRaises(wrapup.Stop) as stopped:
1345
+ wrapup.landing_start_artifact_inventory(str(worktree), str(main))
1346
+
1347
+ self.assertIn("incoherent", stopped.exception.reason)
1348
+
1349
+ def test_land_names_the_safe_abandon_command_for_a_legacy_attempt(self):
1350
+ wrapup = load_wrapup()
1351
+ with tempfile.TemporaryDirectory() as tmp:
1352
+ main, worktree = create_merged_worktree(Path(tmp))
1353
+ wrapup.landing_start_artifact_inventory(str(worktree), str(main))
1354
+ core = wrapup.load_worktree_cleanup_core()
1355
+ downgrade_landing_attempt_to_v1(core, worktree)
1356
+ consumer = worktree / "consumer/keep.txt"
1357
+ consumer.parent.mkdir(parents=True)
1358
+ consumer.write_text("mine", encoding="utf-8")
1359
+
1360
+ with self.assertRaises(wrapup.Stop) as stopped:
1361
+ run_land(wrapup, main, land_args())
1362
+
1363
+ self.assert_legacy_stop(stopped.exception)
1364
+ self.assertTrue(worktree.is_dir())
1365
+ self.assertEqual(consumer.read_text(encoding="utf-8"), "mine")
1366
+
1367
+ def test_v2_archival_stays_valid_without_baseline_or_local_main_profile(self):
1368
+ wrapup = load_wrapup()
1369
+ with tempfile.TemporaryDirectory() as tmp:
1370
+ main, worktree = create_merged_worktree(Path(tmp))
1371
+ wrapup.landing_start_artifact_inventory(str(worktree), str(main))
1372
+ core = wrapup.load_worktree_cleanup_core()
1373
+ core.artifact_baseline_path(worktree).unlink()
1374
+ (main / "docs/agents/workflow-capabilities.json").unlink()
1375
+ ambiguous = worktree / "dist-kit/ambiguous.tgz"
1376
+ ambiguous.parent.mkdir(parents=True)
1377
+ ambiguous.write_text("unknown owner", encoding="utf-8")
1378
+
1379
+ archive = Path(
1380
+ wrapup.abandon_unfinished_landing_attempt(str(worktree), str(main))
1381
+ )
1382
+
1383
+ self.assertTrue(archive.is_file())
1384
+ self.assertIn(".v2.abandoned-", archive.name)
1385
+ self.assertEqual(ambiguous.read_text(encoding="utf-8"), "unknown owner")
1386
+
1387
+ def test_legacy_abandon_claims_no_generated_or_consumer_file(self):
1388
+ wrapup = load_wrapup()
1389
+ with tempfile.TemporaryDirectory() as tmp:
1390
+ main, worktree = create_merged_worktree(Path(tmp))
1391
+ wrapup.landing_start_artifact_inventory(str(worktree), str(main))
1392
+ generated = worktree / "dist-kit/package.tgz"
1393
+ generated.parent.mkdir(parents=True)
1394
+ generated.write_text("generated\n", encoding="utf-8")
1395
+ wrapup.freeze_landing_artifact_evidence(
1396
+ str(worktree), str(main), push_succeeded=True
1397
+ )
1398
+ consumer = worktree / "consumer/keep.txt"
1399
+ consumer.parent.mkdir(parents=True)
1400
+ consumer.write_text("mine", encoding="utf-8")
1401
+ core = wrapup.load_worktree_cleanup_core()
1402
+ downgrade_landing_attempt_to_v1(core, worktree)
1403
+
1404
+ archive = Path(
1405
+ wrapup.abandon_unfinished_landing_attempt(str(worktree), str(main))
1406
+ )
1407
+
1408
+ self.assertTrue(archive.is_file())
1409
+ self.assertIn(".v1.abandoned-", archive.name)
1410
+ self.assertEqual(generated.read_text(encoding="utf-8"), "generated\n")
1411
+ self.assertEqual(consumer.read_text(encoding="utf-8"), "mine")
1412
+ self.assertTrue(worktree.is_dir())
1413
+
1414
+
1415
+ class CanonicalCleanupDriftRecovery(unittest.TestCase):
1416
+ """#272 — canonical policy drift stays fail-closed but has a supported route out."""
1417
+
1418
+ def drifted_merged_worktree(self, root: Path, wrapup, mutate) -> tuple[Path, Path]:
1419
+ """Freeze a landing attempt, then drift canonical policy after the merge."""
1420
+ main, worktree = create_merged_worktree(
1421
+ root, scratch_patterns=[".claude/logs/**"]
1422
+ )
1423
+ wrapup.landing_start_artifact_inventory(str(worktree), str(main))
1424
+ generated = worktree / "dist-kit/package.tgz"
1425
+ generated.parent.mkdir(parents=True)
1426
+ generated.write_text("generated\n", encoding="utf-8")
1427
+ wrapup.freeze_landing_artifact_evidence(
1428
+ str(worktree), str(main), push_succeeded=True
1429
+ )
1430
+ profile_path = main / "docs/agents/workflow-capabilities.json"
1431
+ document = json.loads(profile_path.read_text(encoding="utf-8"))
1432
+ mutate(document)
1433
+ profile_path.write_text(json.dumps(document), encoding="utf-8")
1434
+ command(["git", "add", "docs/agents/workflow-capabilities.json"], main)
1435
+ command(["git", "commit", "-m", "drift canonical cleanup policy"], main)
1436
+ command(["git", "push", "origin", "main"], main)
1437
+ return main, worktree
1438
+
1439
+ @staticmethod
1440
+ def widen_scratch(document):
1441
+ document["worktreeLifecycle"]["scratchPatterns"] = [
1442
+ ".claude/logs/**", "**/.cache/**",
1443
+ ]
1444
+
1445
+ @staticmethod
1446
+ def drop_generator_pattern(document):
1447
+ document["wrapup"]["landingGeneratedArtifactPatterns"] = [
1448
+ "**/__pycache__/**",
1449
+ ]
1450
+
1451
+ def test_first_cleanup_stops_before_every_mutation_and_names_recovery(self):
1452
+ wrapup = load_wrapup()
1453
+ with tempfile.TemporaryDirectory() as tmp:
1454
+ main, worktree = self.drifted_merged_worktree(
1455
+ Path(tmp), wrapup, self.widen_scratch
1456
+ )
1457
+ generated = worktree / "dist-kit/package.tgz"
1458
+
1459
+ with self.assertRaises(wrapup.Stop) as stopped:
1460
+ run_land(wrapup, main, land_args())
1461
+
1462
+ self.assertIn(
1463
+ "worktree cleanup policy differs from merged canonical origin/main",
1464
+ stopped.exception.reason,
1465
+ )
1466
+ self.assertIn("--recover-canonical-cleanup", stopped.exception.reason)
1467
+ self.assertTrue(worktree.is_dir())
1468
+ self.assertEqual(generated.read_text(encoding="utf-8"), "generated\n")
1469
+ self.assertIn(
1470
+ "fix/268-cleanup",
1471
+ command(["git", "branch", "--list", "fix/268-cleanup"], main).stdout,
1472
+ )
1473
+
1474
+ def test_recovery_retires_the_merged_worktree_and_branch_idempotently(self):
1475
+ wrapup = load_wrapup()
1476
+ with tempfile.TemporaryDirectory() as tmp:
1477
+ main, worktree = self.drifted_merged_worktree(
1478
+ Path(tmp), wrapup, self.widen_scratch
1479
+ )
1480
+ log = worktree / ".claude/logs/session.log"
1481
+ log.parent.mkdir(parents=True)
1482
+ log.write_text("session\n", encoding="utf-8")
1483
+
1484
+ first = run_land(
1485
+ wrapup, main, land_args(recover_canonical_cleanup=True)
1486
+ )
1487
+ second = run_land(
1488
+ wrapup, main, land_args(recover_canonical_cleanup=True)
1489
+ )
1490
+
1491
+ self.assertEqual(first["worktree_removed"], str(worktree))
1492
+ self.assertEqual(
1493
+ first["cleanup_guard"]["landing_generated_files"],
1494
+ ["dist-kit/package.tgz"],
1495
+ )
1496
+ self.assertFalse(worktree.exists())
1497
+ self.assertEqual(first["branch_retired"], True)
1498
+ self.assertEqual(second["worktree_removed"], None)
1499
+ self.assertEqual(second["branch_retired"], "already absent")
1500
+ self.assertEqual(
1501
+ command(["git", "branch", "--list", "fix/268-cleanup"], main).stdout,
1502
+ "",
1503
+ )
1504
+
1505
+ def test_recovery_refuses_evidence_outside_canonical_policy(self):
1506
+ wrapup = load_wrapup()
1507
+ with tempfile.TemporaryDirectory() as tmp:
1508
+ main, worktree = self.drifted_merged_worktree(
1509
+ Path(tmp), wrapup, self.drop_generator_pattern
1510
+ )
1511
+ generated = worktree / "dist-kit/package.tgz"
1512
+
1513
+ with self.assertRaises(wrapup.Stop) as stopped:
1514
+ run_land(wrapup, main, land_args(recover_canonical_cleanup=True))
1515
+
1516
+ self.assertIn(
1517
+ "outside canonical cleanup policy", stopped.exception.reason
1518
+ )
1519
+ self.assertIn("dist-kit/package.tgz", stopped.exception.reason)
1520
+ self.assertEqual(generated.read_text(encoding="utf-8"), "generated\n")
1521
+ self.assertTrue(worktree.is_dir())
1522
+
1523
+ def test_recovery_refuses_a_changed_frozen_identity(self):
1524
+ wrapup = load_wrapup()
1525
+ with tempfile.TemporaryDirectory() as tmp:
1526
+ main, worktree = self.drifted_merged_worktree(
1527
+ Path(tmp), wrapup, self.widen_scratch
1528
+ )
1529
+ generated = worktree / "dist-kit/package.tgz"
1530
+ generated.write_text("replaced by a user\n", encoding="utf-8")
1531
+
1532
+ with self.assertRaises(wrapup.Stop) as stopped:
1533
+ run_land(wrapup, main, land_args(recover_canonical_cleanup=True))
1534
+
1535
+ self.assertIn("evidence changed", stopped.exception.reason)
1536
+ self.assertEqual(
1537
+ generated.read_text(encoding="utf-8"), "replaced by a user\n"
1538
+ )
1539
+ self.assertTrue(worktree.is_dir())
1540
+
1541
+ def test_recovery_preserves_pre_existing_and_foreign_files(self):
1542
+ wrapup = load_wrapup()
1543
+ with tempfile.TemporaryDirectory() as tmp:
1544
+ root = Path(tmp)
1545
+ main, worktree = self.drifted_merged_worktree(
1546
+ root, wrapup, self.widen_scratch
1547
+ )
1548
+ consumer = worktree / "consumer/private.cache"
1549
+ consumer.parent.mkdir(parents=True)
1550
+ consumer.write_text("mine", encoding="utf-8")
1551
+ foreign = root / "foreign.txt"
1552
+ foreign.write_text("keep", encoding="utf-8")
1553
+
1554
+ with self.assertRaises(wrapup.Stop) as stopped:
1555
+ run_land(wrapup, main, land_args(recover_canonical_cleanup=True))
1556
+
1557
+ self.assertIn("consumer/private.cache", stopped.exception.detail)
1558
+ self.assertEqual(consumer.read_text(encoding="utf-8"), "mine")
1559
+ self.assertEqual(foreign.read_text(encoding="utf-8"), "keep")
1560
+ self.assertTrue((worktree / "dist-kit/package.tgz").exists())
1561
+ self.assertTrue(worktree.is_dir())
1562
+
1563
+ def test_recovery_refuses_an_unmerged_branch(self):
1564
+ wrapup = load_wrapup()
1565
+ with tempfile.TemporaryDirectory() as tmp:
1566
+ main, worktree = create_merged_worktree(Path(tmp))
1567
+ wrapup.landing_start_artifact_inventory(str(worktree), str(main))
1568
+ wrapup.freeze_landing_artifact_evidence(
1569
+ str(worktree), str(main), push_succeeded=True
1570
+ )
1571
+ (worktree / "later.txt").write_text("unmerged\n", encoding="utf-8")
1572
+ command(["git", "add", "later.txt"], worktree)
1573
+ command(["git", "commit", "-m", "unmerged work"], worktree)
1574
+
1575
+ with self.assertRaises(wrapup.Stop) as stopped:
1576
+ run_land(wrapup, main, land_args(recover_canonical_cleanup=True))
1577
+
1578
+ self.assertIn("not merged into canonical origin/main",
1579
+ stopped.exception.reason)
1580
+ self.assertTrue(worktree.is_dir())
1581
+
1582
+ def test_recovery_refuses_an_unfrozen_attempt(self):
1583
+ wrapup = load_wrapup()
1584
+ with tempfile.TemporaryDirectory() as tmp:
1585
+ main, worktree = self.drifted_merged_worktree(
1586
+ Path(tmp), wrapup, self.widen_scratch
1587
+ )
1588
+ core = wrapup.load_worktree_cleanup_core()
1589
+ rewrite_landing_attempt(
1590
+ core,
1591
+ worktree,
1592
+ lambda payload: payload.update(
1593
+ {"state": "started", "authorizedEvidence": [],
1594
+ "pushSucceeded": False},
1595
+ ),
1596
+ )
1597
+
1598
+ with self.assertRaises(wrapup.Stop) as stopped:
1599
+ run_land(wrapup, main, land_args(recover_canonical_cleanup=True))
1600
+
1601
+ self.assertIn("frozen landing attempt", stopped.exception.reason)
1602
+ self.assertTrue((worktree / "dist-kit/package.tgz").exists())
1603
+ self.assertTrue(worktree.is_dir())
1604
+
1605
+ def test_recovery_never_adopts_a_legacy_journal(self):
1606
+ wrapup = load_wrapup()
1607
+ with tempfile.TemporaryDirectory() as tmp:
1608
+ main, worktree = self.drifted_merged_worktree(
1609
+ Path(tmp), wrapup, self.widen_scratch
1610
+ )
1611
+ core = wrapup.load_worktree_cleanup_core()
1612
+ downgrade_landing_attempt_to_v1(core, worktree)
1613
+
1614
+ with self.assertRaises(wrapup.Stop) as stopped:
1615
+ run_land(wrapup, main, land_args(recover_canonical_cleanup=True))
1616
+
1617
+ self.assertIn("incoherent", stopped.exception.reason)
1618
+ self.assertTrue((worktree / "dist-kit/package.tgz").exists())
1619
+ self.assertTrue(worktree.is_dir())
1620
+
1621
+ def test_abandon_and_recovery_flags_are_mutually_exclusive(self):
1622
+ result = subprocess.run(
1623
+ [
1624
+ os.sys.executable, str(WRAPUP), "land", "--branch", "fix/1-x",
1625
+ "--abandon-unfinished-attempt", "--recover-canonical-cleanup",
1626
+ ],
1627
+ cwd=REPO,
1628
+ capture_output=True,
1629
+ text=True,
1630
+ )
1631
+
1632
+ self.assertEqual(result.returncode, 2)
1633
+ self.assertIn("not allowed with argument", result.stderr)
1634
+
1635
+
1636
+ def core_archive_stem(wrapup) -> str:
1637
+ return wrapup.load_worktree_cleanup_core().LANDING_ATTEMPT_ARCHIVE_STEM
1638
+
1639
+
1052
1640
  if __name__ == "__main__":
1053
1641
  unittest.main()
@@ -1,13 +1,37 @@
1
1
  """Profile-driven decision core for non-blocking Workflow Advisories."""
2
2
  from __future__ import annotations
3
3
 
4
- import fnmatch
4
+ import importlib.util
5
5
  import json
6
6
  import re
7
7
  import subprocess
8
+ import sys
8
9
  from dataclasses import dataclass
9
10
  from pathlib import Path
10
11
 
12
+ PROFILE_GLOBS_MODULE = "_agent_workflow_kit_profile_globs"
13
+
14
+
15
+ def load_profile_globs():
16
+ """Load the one shared repository-relative glob dialect exactly once."""
17
+ module = sys.modules.get(PROFILE_GLOBS_MODULE)
18
+ if module is not None:
19
+ return module
20
+ path = Path(__file__).resolve().parents[1] / "profile_globs.py"
21
+ spec = importlib.util.spec_from_file_location(PROFILE_GLOBS_MODULE, path)
22
+ if spec is None or spec.loader is None:
23
+ raise ImportError(f"cannot load the shared profile glob dialect from {path}")
24
+ module = importlib.util.module_from_spec(spec)
25
+ sys.modules[PROFILE_GLOBS_MODULE] = module
26
+ spec.loader.exec_module(module)
27
+ return module
28
+
29
+
30
+ # Consumer profile globs are matched here exactly as Worktree Lifecycle matches
31
+ # its own: one dialect, so an advisory and a deletion decision can never
32
+ # disagree about which repository-relative paths a pattern selects.
33
+ path_glob_matches = load_profile_globs().path_glob_matches
34
+
11
35
 
12
36
  @dataclass(frozen=True)
13
37
  class Decision:
@@ -87,7 +111,8 @@ def baseline_decision(profile: dict, payload: dict, root: Path, branch: str) ->
87
111
  raw_path = payload.get("tool_input", {}).get("file_path")
88
112
  relative = _repo_relative(root, raw_path) if raw_path else None
89
113
  if not relative or not any(
90
- fnmatch.fnmatch(relative, pattern) for pattern in config.get("sourceGlobs", [])
114
+ path_glob_matches(relative, pattern)
115
+ for pattern in config.get("sourceGlobs", [])
91
116
  ):
92
117
  return Decision(None, "PreToolUse")
93
118
  manifest = root / config.get("manifestPath", ".agent/baseline.json")
@@ -119,7 +144,7 @@ def pre_refactor_decision(profile: dict, payload: dict, root: Path) -> Decision:
119
144
  seen: set[tuple[str, ...]] = set()
120
145
  for surface in config.get("surfaces", []):
121
146
  if not any(
122
- fnmatch.fnmatch(path, pattern)
147
+ path_glob_matches(path, pattern)
123
148
  for path in changed
124
149
  for pattern in surface.get("globs", [])
125
150
  ):
@@ -170,7 +195,7 @@ def stop_check_decision(profile: dict, payload: dict, root: Path) -> Decision:
170
195
  seen: set[tuple[str, ...]] = set()
171
196
  for surface in config.get("surfaces", []):
172
197
  if not any(
173
- fnmatch.fnmatch(path, pattern)
198
+ path_glob_matches(path, pattern)
174
199
  for path in changed
175
200
  for pattern in surface.get("globs", [])
176
201
  ):