@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
|
@@ -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
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
198
|
+
path_glob_matches(path, pattern)
|
|
174
199
|
for path in changed
|
|
175
200
|
for pattern in surface.get("globs", [])
|
|
176
201
|
):
|