prizmkit 1.1.148 → 1.1.150

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 (24) hide show
  1. package/bundled/VERSION.json +3 -3
  2. package/bundled/dev-pipeline/README.md +20 -17
  3. package/bundled/dev-pipeline/prizmkit_runtime/checkpoint_state.py +77 -0
  4. package/bundled/dev-pipeline/prizmkit_runtime/gitops.py +234 -39
  5. package/bundled/dev-pipeline/prizmkit_runtime/reset.py +57 -30
  6. package/bundled/dev-pipeline/prizmkit_runtime/reset_preserve.py +822 -0
  7. package/bundled/dev-pipeline/prizmkit_runtime/runner_bookkeeping.py +28 -11
  8. package/bundled/dev-pipeline/prizmkit_runtime/runners.py +71 -28
  9. package/bundled/dev-pipeline/prizmkit_runtime/task_checkout.py +18 -0
  10. package/bundled/dev-pipeline/tests/test_python_runner_parity.py +67 -13
  11. package/bundled/dev-pipeline/tests/test_reset_preserve.py +959 -0
  12. package/bundled/dev-pipeline/tests/test_unified_cli.py +325 -5
  13. package/bundled/skills/_metadata.json +1 -1
  14. package/bundled/skills/app-planner/SKILL.md +27 -27
  15. package/bundled/skills/app-planner/references/architecture-decisions.md +5 -11
  16. package/bundled/skills/app-planner/references/frontend-design-guide.md +1 -1
  17. package/bundled/skills/app-planner/references/generated-plan-review.md +2 -2
  18. package/bundled/skills/app-planner/references/infrastructure-convention-discovery.md +2 -2
  19. package/bundled/skills/app-planner/references/project-conventions-discovery.md +1 -1
  20. package/bundled/skills/bugfix-pipeline-launcher/SKILL.md +21 -9
  21. package/bundled/skills/feature-pipeline-launcher/SKILL.md +21 -9
  22. package/bundled/skills/refactor-pipeline-launcher/SKILL.md +21 -9
  23. package/bundled/templates/project-memory-template.md +38 -0
  24. package/package.json +1 -1
@@ -0,0 +1,959 @@
1
+ """Regression tests for non-destructive failed-chain reset behavior."""
2
+
3
+ import json
4
+ import shlex
5
+ import subprocess
6
+ import sys
7
+ from pathlib import Path
8
+
9
+ import pytest
10
+
11
+ REPO_ROOT = Path(__file__).resolve().parents[2]
12
+ PIPELINE_ROOT = REPO_ROOT / "dev-pipeline"
13
+ CLI_PATH = PIPELINE_ROOT / "cli.py"
14
+ if str(PIPELINE_ROOT) not in sys.path:
15
+ sys.path.insert(0, str(PIPELINE_ROOT))
16
+
17
+
18
+ def _make_paths(tmp_path):
19
+ from prizmkit_runtime.paths import resolve_runtime_paths
20
+
21
+ project = tmp_path / "project"
22
+ (project / ".prizmkit" / "plans").mkdir(parents=True)
23
+ return resolve_runtime_paths(pipeline_root=PIPELINE_ROOT, project_root=project)
24
+
25
+
26
+ def _write_family_plan(paths, kind, items):
27
+ plan_config = {
28
+ "feature": (paths.feature_plan, "features", "dev-pipeline-feature-list-v1"),
29
+ "bugfix": (paths.bugfix_plan, "bugs", "dev-pipeline-bug-fix-list-v1"),
30
+ "refactor": (paths.refactor_plan, "refactors", "dev-pipeline-refactor-list-v1"),
31
+ }
32
+ plan_path, list_key, schema = plan_config[kind]
33
+ plan_path.write_text(
34
+ json.dumps({"$schema": schema, "project_name": "Fake", list_key: items}),
35
+ encoding="utf-8",
36
+ )
37
+ return plan_path
38
+
39
+
40
+ def _write_semantic_completion_checkpoint(project_root, artifact_dir, workflow_type="feature-pipeline"):
41
+ contracts = {
42
+ "feature-pipeline": (
43
+ "feature_state",
44
+ [
45
+ "prizmkit-implement", "prizmkit-code-review", "prizmkit-test",
46
+ "prizmkit-retrospective", "prizmkit-committer", "completion-summary",
47
+ ],
48
+ ),
49
+ "bugfix-pipeline": (
50
+ "bugfix_state",
51
+ [
52
+ "prizmkit-implement", "prizmkit-code-review", "prizmkit-test",
53
+ "prizmkit-retrospective", "prizmkit-committer", "bug-report",
54
+ ],
55
+ ),
56
+ "refactor-pipeline": (
57
+ "refactor_state",
58
+ [
59
+ "prizmkit-implement", "prizmkit-code-review", "prizmkit-test",
60
+ "prizmkit-retrospective", "prizmkit-committer", "refactor-report",
61
+ "completion-summary",
62
+ ],
63
+ ),
64
+ }
65
+ state_key, skills = contracts[workflow_type]
66
+ steps = []
67
+ previous = None
68
+ for index, skill in enumerate(skills, start=1):
69
+ step = {
70
+ "id": f"S{index:02d}",
71
+ "skill": skill,
72
+ "name": skill,
73
+ "status": "completed",
74
+ "required_artifacts": [],
75
+ "depends_on": previous,
76
+ }
77
+ if skill == "prizmkit-implement":
78
+ step["stage_result"] = "IMPLEMENTED"
79
+ if skill == "prizmkit-code-review":
80
+ step["stage_result"] = "REVIEW_PASS"
81
+ if skill == "prizmkit-test":
82
+ step["stage_result"] = "TEST_PASS"
83
+ relative_artifact = artifact_dir.relative_to(project_root)
84
+ step["required_artifacts"] = [
85
+ str(relative_artifact / "test-report.md"),
86
+ str(relative_artifact / "test-result.json"),
87
+ ]
88
+ if skill == "prizmkit-retrospective":
89
+ step["stage_result"] = "RETRO_COMPLETE"
90
+ if skill == "prizmkit-committer":
91
+ step["stage_result"] = "COMMITTED"
92
+ steps.append(step)
93
+ previous = step["id"]
94
+ artifact_dir.mkdir(parents=True, exist_ok=True)
95
+ (artifact_dir / "test-report.md").write_text(
96
+ "# Test Report\n\n## Final Result\n\n- Result: TEST_PASS\n",
97
+ encoding="utf-8",
98
+ )
99
+ (artifact_dir / "test-result.json").write_text(
100
+ json.dumps({
101
+ "schema_version": 1,
102
+ "result": "TEST_PASS",
103
+ "report": "test-report.md",
104
+ "main_review_rounds": 1,
105
+ "independent_review": {
106
+ "status": "downgraded",
107
+ "responses": 0,
108
+ "downgrade_reason": "read-only capability unavailable",
109
+ "final_state_rechecked": False,
110
+ },
111
+ "repair_rounds": 0,
112
+ "production_changed": False,
113
+ "review_required": False,
114
+ "review_scope": None,
115
+ "unresolved_items": [],
116
+ }),
117
+ encoding="utf-8",
118
+ )
119
+ checkpoint = artifact_dir / "workflow-checkpoint.json"
120
+ checkpoint.write_text(
121
+ json.dumps({
122
+ "version": 1,
123
+ "workflow_type": workflow_type,
124
+ "steps": steps,
125
+ state_key: {
126
+ "repair_round": 0,
127
+ "terminal_status": "WORKFLOW_COMPLETED",
128
+ },
129
+ }),
130
+ encoding="utf-8",
131
+ )
132
+ return checkpoint
133
+
134
+
135
+ def _init_preserve_runtime_repo(paths):
136
+ subprocess.run(["git", "init", "-b", "main"], cwd=paths.project_root, check=True, stdout=subprocess.DEVNULL)
137
+ subprocess.run(["git", "config", "user.email", "test@example.com"], cwd=paths.project_root, check=True)
138
+ subprocess.run(["git", "config", "user.name", "Test"], cwd=paths.project_root, check=True)
139
+ (paths.project_root / "base.txt").write_text("base\n", encoding="utf-8")
140
+ subprocess.run(["git", "add", "base.txt"], cwd=paths.project_root, check=True)
141
+ subprocess.run(["git", "commit", "-m", "base"], cwd=paths.project_root, check=True, stdout=subprocess.DEVNULL)
142
+
143
+
144
+ def _preserve_family_contract(kind):
145
+ return {
146
+ "feature": ("F-", "dev", "feature-pipeline", "specs"),
147
+ "bugfix": ("B-", "bugfix", "bugfix-pipeline", "bugfix"),
148
+ "refactor": ("R-", "refactor", "refactor-pipeline", "refactor"),
149
+ }[kind]
150
+
151
+
152
+ def _preserve_item_id(prefix, number):
153
+ return f"{prefix}{number:03d}"
154
+
155
+
156
+ def _preserve_items(kind):
157
+ prefix, _branch_prefix, _workflow_type, _artifact_root = _preserve_family_contract(kind)
158
+ statuses = [
159
+ (1, "failed", []),
160
+ (2, "auto_skipped", [_preserve_item_id(prefix, 1)]),
161
+ (3, "auto_skipped", [_preserve_item_id(prefix, 2)]),
162
+ (4, "completed", []),
163
+ (5, "skipped", []),
164
+ (6, "auto_skipped", [_preserve_item_id(prefix, 5)]),
165
+ (7, "needs_info", []),
166
+ (8, "pending", []),
167
+ (9, "split", []),
168
+ ]
169
+ return [
170
+ {
171
+ "id": _preserve_item_id(prefix, number),
172
+ "title": "Root" if number == 1 else f"Item {number}",
173
+ "description": "preserve runtime",
174
+ "status": status,
175
+ "priority": "medium",
176
+ "dependencies": dependencies,
177
+ }
178
+ for number, status, dependencies in statuses
179
+ ]
180
+
181
+
182
+ def _write_preserve_runtime_state(paths, kind, item_id, branch, *, base_branch="main"):
183
+ from prizmkit_runtime.runner_models import family_for
184
+ from prizmkit_runtime.task_checkout import TaskCheckout, persist_task_checkout
185
+
186
+ family = family_for(kind, paths)
187
+ family.state_dir.mkdir(parents=True, exist_ok=True)
188
+ (family.state_dir / "pipeline.json").write_text(json.dumps({"status": "failed"}), encoding="utf-8")
189
+ item_state = family.state_dir / item_id
190
+ sessions = item_state / "sessions" / "old-session" / "logs"
191
+ sessions.mkdir(parents=True, exist_ok=True)
192
+ (sessions / "session.log").write_text("historical log\n", encoding="utf-8")
193
+ status = {
194
+ "status": "failed",
195
+ "retry_count": 7,
196
+ "infra_error_count": 4,
197
+ "sessions": ["old-session"],
198
+ "last_session_id": "old-session",
199
+ "previous_session_id": "old-session",
200
+ "last_infra_error_session_id": "old-session",
201
+ "continuation_pending": False,
202
+ "continuation_reason": "code_error",
203
+ "continuation_summary_path": "keep-this-path",
204
+ "last_progress_fingerprint": {"head": "abc", "checkpoint": "S03"},
205
+ "no_progress_count": 2,
206
+ "fatal_error_code": "provider_error",
207
+ "active_dev_branch": branch,
208
+ "base_branch": base_branch,
209
+ "custom_diagnostic": {"reason": "preserve me"},
210
+ }
211
+ status_path = item_state / "status.json"
212
+ status_path.write_text(json.dumps(status, indent=2) + "\n", encoding="utf-8")
213
+ checkout = TaskCheckout.active(kind, item_id, branch, base_branch)
214
+ persist_task_checkout(family.state_dir, checkout)
215
+ return family, status_path, status, checkout
216
+
217
+
218
+ @pytest.mark.parametrize("kind", ["feature", "bugfix", "refactor"])
219
+ def test_preserve_runtime_restores_failed_chain_and_preserves_existing_checkout_history(tmp_path, kind):
220
+ from prizmkit_runtime.reset import run_reset_command
221
+ from prizmkit_runtime.task_checkout import load_task_checkout
222
+
223
+ paths = _make_paths(tmp_path / kind)
224
+ _init_preserve_runtime_repo(paths)
225
+ prefix, branch_prefix, workflow_type, artifact_root = _preserve_family_contract(kind)
226
+ root_id = _preserve_item_id(prefix, 1)
227
+ plan = _write_family_plan(paths, kind, _preserve_items(kind))
228
+ branch = f"{branch_prefix}/{root_id}-preserved"
229
+ subprocess.run(["git", "branch", branch, "main"], cwd=paths.project_root, check=True)
230
+ family, status_path, before_status, checkout = _write_preserve_runtime_state(paths, kind, root_id, branch)
231
+ descendant_status = family.state_dir / _preserve_item_id(prefix, 2) / "status.json"
232
+ descendant_status.parent.mkdir(parents=True, exist_ok=True)
233
+ descendant_status.write_text(
234
+ json.dumps({"status": "auto_skipped", "caused_by": root_id, "sessions": ["child-history"]}, indent=2) + "\n",
235
+ encoding="utf-8",
236
+ )
237
+ descendant_status_before = descendant_status.read_bytes()
238
+ artifact_name = "001-root" if kind == "feature" else root_id
239
+ artifact = paths.project_root / ".prizmkit" / artifact_root / artifact_name
240
+ checkpoint = _write_semantic_completion_checkpoint(paths.project_root, artifact, workflow_type)
241
+ checkpoint_before = checkpoint.read_bytes()
242
+ session_log = family.state_dir / root_id / "sessions" / "old-session" / "logs" / "session.log"
243
+ session_before = session_log.read_bytes()
244
+ branch_head_before = subprocess.run(
245
+ ["git", "rev-parse", branch], cwd=paths.project_root, check=True, stdout=subprocess.PIPE, text=True
246
+ ).stdout.strip()
247
+
248
+ result = run_reset_command(kind, ("--failed", "--preserve-runtime"), paths)
249
+
250
+ assert result.exit_code == 0, result.render()
251
+ updated_plan = json.loads(plan.read_text(encoding="utf-8"))
252
+ statuses = {item["id"]: item["status"] for item in updated_plan[family.list_key]}
253
+ assert statuses[root_id] == "pending"
254
+ assert statuses[_preserve_item_id(prefix, 2)] == "pending"
255
+ assert statuses[_preserve_item_id(prefix, 3)] == "pending"
256
+ assert statuses[_preserve_item_id(prefix, 4)] == "completed"
257
+ assert statuses[_preserve_item_id(prefix, 5)] == "skipped"
258
+ assert statuses[_preserve_item_id(prefix, 6)] == "auto_skipped"
259
+ assert statuses[_preserve_item_id(prefix, 7)] == "needs_info"
260
+ assert statuses[_preserve_item_id(prefix, 8)] == "pending"
261
+ assert statuses[_preserve_item_id(prefix, 9)] == "split"
262
+ expected_status = dict(before_status)
263
+ expected_status.update(retry_count=0, infra_error_count=0, continuation_pending=True)
264
+ assert json.loads(status_path.read_text(encoding="utf-8")) == expected_status
265
+ assert load_task_checkout(family.state_dir, kind, root_id) == checkout
266
+ assert descendant_status.read_bytes() == descendant_status_before
267
+ assert checkpoint.read_bytes() == checkpoint_before
268
+ assert session_log.read_bytes() == session_before
269
+ assert subprocess.run(
270
+ ["git", "rev-parse", branch], cwd=paths.project_root, check=True, stdout=subprocess.PIPE, text=True
271
+ ).stdout.strip() == branch_head_before
272
+ assert "Active checkouts reused" in result.render()
273
+ assert "Reset did not start a runner" in result.render()
274
+
275
+
276
+ @pytest.mark.parametrize("kind", ["feature", "bugfix", "refactor"])
277
+ def test_preserve_runtime_no_failed_root_is_a_noop_without_checkout_state(tmp_path, kind):
278
+ from prizmkit_runtime.reset import run_reset_command
279
+ from prizmkit_runtime.runner_models import family_for
280
+
281
+ paths = _make_paths(tmp_path / kind)
282
+ items = _preserve_items(kind)
283
+ items[0]["status"] = "completed"
284
+ plan = _write_family_plan(paths, kind, items)
285
+ family = family_for(kind, paths)
286
+ before = plan.read_bytes()
287
+
288
+ result = run_reset_command(kind, ("--failed", "--preserve-runtime"), paths)
289
+
290
+ assert result.exit_code == 0
291
+ assert "No failed" in result.render()
292
+ assert plan.read_bytes() == before
293
+ assert not any((family.state_dir / item["id"] / "checkout.json").exists() for item in items)
294
+
295
+
296
+ def test_preserve_runtime_option_boundaries_and_removed_reset_run_do_not_mutate_state(tmp_path):
297
+ from prizmkit_runtime.reset import ResetOptions, run_reset_command
298
+ from prizmkit_runtime.runner_models import family_for, parse_invocation
299
+
300
+ paths = _make_paths(tmp_path)
301
+ plan = _write_family_plan(paths, "feature", _preserve_items("feature"))
302
+ family = family_for("feature", paths)
303
+ family.state_dir.mkdir(parents=True, exist_ok=True)
304
+ (family.state_dir / "pipeline.json").write_text("{}\n", encoding="utf-8")
305
+ before = plan.read_bytes()
306
+ invalid_cases = [
307
+ (("F-001", "--run"), "no longer accepts --run"),
308
+ (("--preserve-runtime",), "requires --failed"),
309
+ (("--failed", "--preserve-runtime", "--clean"), "mutually exclusive"),
310
+ (("F-001", "--failed", "--preserve-runtime"), "cannot target an ID or range"),
311
+ (("F-001:F-003", "--failed", "--preserve-runtime"), "cannot target an ID or range"),
312
+ (("--failed", "--preserve-runtime", "--stalled"), "Unsupported reset arguments"),
313
+ (("--failed", "--preserve-runtime", "--unknown"), "Unsupported reset arguments"),
314
+ ]
315
+
316
+ for args, message in invalid_cases:
317
+ result = run_reset_command("feature", args, paths)
318
+ assert result.exit_code != 0
319
+ assert message in result.render()
320
+ assert plan.read_bytes() == before
321
+ assert "run_after" not in ResetOptions.__dataclass_fields__
322
+ invocation = parse_invocation(
323
+ family,
324
+ "run",
325
+ ("F-001", "--max-retries", "6", "--max-infra-retries", "7"),
326
+ )
327
+ assert invocation.action == "run"
328
+ assert invocation.item_id == "F-001"
329
+ assert invocation.max_retries == 6
330
+ assert invocation.max_infra_retries == 7
331
+
332
+
333
+ def test_preserve_runtime_restores_every_failed_root_in_one_successful_pass(tmp_path):
334
+ from prizmkit_runtime.reset import run_reset_command
335
+
336
+ paths = _make_paths(tmp_path)
337
+ _init_preserve_runtime_repo(paths)
338
+ items = [
339
+ {"id": "F-001", "title": "Root One", "status": "failed", "dependencies": []},
340
+ {"id": "F-002", "title": "Child One", "status": "auto_skipped", "dependencies": ["F-001"]},
341
+ {"id": "F-003", "title": "Root Two", "status": "failed", "dependencies": []},
342
+ {"id": "F-004", "title": "Child Two", "status": "auto_skipped", "dependencies": ["F-003"]},
343
+ {"id": "F-005", "title": "Done", "status": "completed", "dependencies": []},
344
+ ]
345
+ plan = _write_family_plan(paths, "feature", items)
346
+ before_statuses = {}
347
+ for item_id in ("F-001", "F-003"):
348
+ branch = f"dev/{item_id}-existing"
349
+ subprocess.run(["git", "branch", branch, "main"], cwd=paths.project_root, check=True)
350
+ _family, status_path, status_data, _checkout = _write_preserve_runtime_state(
351
+ paths, "feature", item_id, branch
352
+ )
353
+ before_statuses[item_id] = (status_path, status_data)
354
+
355
+ result = run_reset_command("feature", ("--failed", "--preserve-runtime"), paths)
356
+
357
+ assert result.exit_code == 0, result.render()
358
+ statuses = {
359
+ item["id"]: item["status"]
360
+ for item in json.loads(plan.read_text(encoding="utf-8"))["features"]
361
+ }
362
+ assert statuses == {
363
+ "F-001": "pending",
364
+ "F-002": "pending",
365
+ "F-003": "pending",
366
+ "F-004": "pending",
367
+ "F-005": "completed",
368
+ }
369
+ for status_path, before in before_statuses.values():
370
+ expected = dict(before)
371
+ expected.update(retry_count=0, infra_error_count=0, continuation_pending=True)
372
+ assert json.loads(status_path.read_text(encoding="utf-8")) == expected
373
+ assert "Failed roots restored: F-001, F-003" in result.render()
374
+
375
+ plan_after = plan.read_bytes()
376
+ runtime_after = {
377
+ item_id: status_path.read_bytes()
378
+ for item_id, (status_path, _before) in before_statuses.items()
379
+ }
380
+ second = run_reset_command("feature", ("--failed", "--preserve-runtime"), paths)
381
+ assert second.exit_code == 0
382
+ assert "No failed" in second.render()
383
+ assert plan.read_bytes() == plan_after
384
+ assert {
385
+ item_id: status_path.read_bytes()
386
+ for item_id, (status_path, _before) in before_statuses.items()
387
+ } == runtime_after
388
+
389
+
390
+ def test_preserve_runtime_respects_existing_task_guard_without_mutation(tmp_path):
391
+ from prizmkit_runtime.reset import run_reset_command
392
+ from prizmkit_runtime.task_checkout import task_checkout_guard
393
+
394
+ paths = _make_paths(tmp_path)
395
+ _init_preserve_runtime_repo(paths)
396
+ plan = _write_family_plan(
397
+ paths,
398
+ "feature",
399
+ [{"id": "F-001", "title": "Root", "status": "failed", "dependencies": []}],
400
+ )
401
+ subprocess.run(["git", "branch", "dev/F-001-existing", "main"], cwd=paths.project_root, check=True)
402
+ family, status_path, _status, _checkout = _write_preserve_runtime_state(
403
+ paths, "feature", "F-001", "dev/F-001-existing"
404
+ )
405
+ plan_before = plan.read_bytes()
406
+ status_before = status_path.read_bytes()
407
+
408
+ with task_checkout_guard(family.state_dir, "F-001"):
409
+ result = run_reset_command("feature", ("--failed", "--preserve-runtime"), paths)
410
+
411
+ assert result.exit_code == 1
412
+ assert "checkout lifecycle is busy" in result.render()
413
+ assert plan.read_bytes() == plan_before
414
+ assert status_path.read_bytes() == status_before
415
+
416
+
417
+ @pytest.mark.parametrize(
418
+ ("kind", "item_id"),
419
+ [("feature", "F-001"), ("bugfix", "B-001"), ("refactor", "R-001")],
420
+ )
421
+ def test_public_reset_cli_hard_rejects_run_for_every_family_without_state_change(tmp_path, kind, item_id):
422
+ project = tmp_path / kind
423
+ project.mkdir()
424
+
425
+ completed = subprocess.run(
426
+ [
427
+ sys.executable,
428
+ str(CLI_PATH),
429
+ "--project-root",
430
+ str(project),
431
+ "reset",
432
+ kind,
433
+ item_id,
434
+ "--run",
435
+ ],
436
+ cwd=REPO_ROOT,
437
+ stdout=subprocess.PIPE,
438
+ stderr=subprocess.PIPE,
439
+ text=True,
440
+ check=False,
441
+ )
442
+
443
+ assert completed.returncode == 2
444
+ assert "Reset no longer accepts --run" in completed.stdout + completed.stderr
445
+ assert not (project / ".prizmkit").exists()
446
+
447
+
448
+ @pytest.mark.parametrize(
449
+ ("items", "expected_error"),
450
+ [
451
+ (
452
+ [{"id": "F-001", "title": "Root", "status": "failed", "dependencies": ["F-999"]}],
453
+ "unknown dependency F-999",
454
+ ),
455
+ (
456
+ [
457
+ {"id": "F-001", "title": "One", "status": "failed", "dependencies": ["F-002"]},
458
+ {"id": "F-002", "title": "Two", "status": "auto_skipped", "dependencies": ["F-001"]},
459
+ ],
460
+ "dependency cycle",
461
+ ),
462
+ (
463
+ [
464
+ {"id": "F-001", "title": "One", "status": "failed", "dependencies": []},
465
+ {"id": "F-001", "title": "Duplicate", "status": "failed", "dependencies": []},
466
+ ],
467
+ "duplicate item id: F-001",
468
+ ),
469
+ ],
470
+ )
471
+ def test_preserve_runtime_rejects_invalid_dependency_graph_without_state_mutation(
472
+ tmp_path,
473
+ items,
474
+ expected_error,
475
+ ):
476
+ from prizmkit_runtime.reset import run_reset_command
477
+
478
+ paths = _make_paths(tmp_path)
479
+ plan = _write_family_plan(paths, "feature", items)
480
+ before = plan.read_bytes()
481
+
482
+ result = run_reset_command("feature", ("--failed", "--preserve-runtime"), paths)
483
+
484
+ assert result.exit_code == 1
485
+ assert expected_error in result.render()
486
+ assert plan.read_bytes() == before
487
+ assert not paths.feature_state_dir.exists()
488
+
489
+
490
+ def test_preserve_runtime_success_quotes_list_path_in_separate_run_instruction(tmp_path):
491
+ from prizmkit_runtime.reset import run_reset_command
492
+
493
+ paths = _make_paths(tmp_path / "parent with spaces")
494
+ _init_preserve_runtime_repo(paths)
495
+ plan = _write_family_plan(
496
+ paths,
497
+ "feature",
498
+ [{"id": "F-001", "title": "Root", "status": "failed", "dependencies": []}],
499
+ )
500
+ subprocess.run(["git", "branch", "dev/F-001-existing", "main"], cwd=paths.project_root, check=True)
501
+ _write_preserve_runtime_state(paths, "feature", "F-001", "dev/F-001-existing")
502
+
503
+ result = run_reset_command("feature", ("--failed", "--preserve-runtime"), paths)
504
+
505
+ assert result.exit_code == 0
506
+ assert f"feature run {shlex.quote(str(plan.resolve()))}" in result.render()
507
+
508
+
509
+ def test_preserve_runtime_requires_recorded_base_to_be_a_local_branch(tmp_path):
510
+ from prizmkit_runtime.reset import run_reset_command
511
+
512
+ paths = _make_paths(tmp_path)
513
+ _init_preserve_runtime_repo(paths)
514
+ subprocess.run(["git", "tag", "base-tag", "main"], cwd=paths.project_root, check=True)
515
+ plan = _write_family_plan(
516
+ paths,
517
+ "feature",
518
+ [{"id": "F-001", "title": "Root", "status": "failed", "dependencies": []}],
519
+ )
520
+ _family, status_path, _before_status, _checkout = _write_preserve_runtime_state(
521
+ paths,
522
+ "feature",
523
+ "F-001",
524
+ "dev/F-001-missing",
525
+ base_branch="base-tag",
526
+ )
527
+ plan_before = plan.read_bytes()
528
+ status_before = status_path.read_bytes()
529
+
530
+ result = run_reset_command("feature", ("--failed", "--preserve-runtime"), paths)
531
+
532
+ assert result.exit_code == 1
533
+ assert "recorded base branch is missing: base-tag" in result.render()
534
+ assert plan.read_bytes() == plan_before
535
+ assert status_path.read_bytes() == status_before
536
+
537
+
538
+ def test_preserve_runtime_preflights_all_failed_roots_before_any_state_change(tmp_path):
539
+ from prizmkit_runtime.reset import run_reset_command
540
+
541
+ paths = _make_paths(tmp_path)
542
+ _init_preserve_runtime_repo(paths)
543
+ items = [
544
+ {"id": "F-001", "title": "One", "status": "failed", "dependencies": []},
545
+ {"id": "F-002", "title": "Two", "status": "failed", "dependencies": []},
546
+ {"id": "F-003", "title": "Three", "status": "failed", "dependencies": []},
547
+ ]
548
+ plan = _write_family_plan(paths, "feature", items)
549
+ subprocess.run(["git", "branch", "dev/F-001-existing", "main"], cwd=paths.project_root, check=True)
550
+ family, status_one, _data_one, _checkout_one = _write_preserve_runtime_state(
551
+ paths, "feature", "F-001", "dev/F-001-existing"
552
+ )
553
+ _family, status_two, _data_two, _checkout_two = _write_preserve_runtime_state(
554
+ paths, "feature", "F-002", "dev/F-002-missing", base_branch="missing-base-two"
555
+ )
556
+ _family, status_three, _data_three, _checkout_three = _write_preserve_runtime_state(
557
+ paths, "feature", "F-003", "dev/F-003-missing", base_branch="missing-base-three"
558
+ )
559
+ plan_before = plan.read_bytes()
560
+ status_one_before = status_one.read_bytes()
561
+ status_two_before = status_two.read_bytes()
562
+ status_three_before = status_three.read_bytes()
563
+ checkout_one_before = (family.state_dir / "F-001" / "checkout.json").read_bytes()
564
+ branches_before = subprocess.run(
565
+ ["git", "branch", "--format=%(refname:short)"],
566
+ cwd=paths.project_root,
567
+ check=True,
568
+ stdout=subprocess.PIPE,
569
+ text=True,
570
+ ).stdout
571
+
572
+ result = run_reset_command("feature", ("--failed", "--preserve-runtime"), paths)
573
+
574
+ assert result.exit_code == 1
575
+ assert "recorded base branch is missing: missing-base-two" in result.render()
576
+ assert "recorded base branch is missing: missing-base-three" in result.render()
577
+ assert plan.read_bytes() == plan_before
578
+ assert status_one.read_bytes() == status_one_before
579
+ assert status_two.read_bytes() == status_two_before
580
+ assert status_three.read_bytes() == status_three_before
581
+ assert (family.state_dir / "F-001" / "checkout.json").read_bytes() == checkout_one_before
582
+ assert subprocess.run(
583
+ ["git", "branch", "--format=%(refname:short)"],
584
+ cwd=paths.project_root,
585
+ check=True,
586
+ stdout=subprocess.PIPE,
587
+ text=True,
588
+ ).stdout == branches_before
589
+
590
+
591
+ def test_preserve_runtime_invalid_existing_checkpoint_blocks_before_mutation(tmp_path):
592
+ from prizmkit_runtime.reset import run_reset_command
593
+
594
+ paths = _make_paths(tmp_path)
595
+ _init_preserve_runtime_repo(paths)
596
+ plan = _write_family_plan(
597
+ paths,
598
+ "feature",
599
+ [{"id": "F-001", "title": "Root", "status": "failed", "dependencies": []}],
600
+ )
601
+ subprocess.run(["git", "branch", "dev/F-001-existing", "main"], cwd=paths.project_root, check=True)
602
+ _family, status_path, _before_status, _checkout = _write_preserve_runtime_state(
603
+ paths, "feature", "F-001", "dev/F-001-existing"
604
+ )
605
+ artifact = paths.project_root / ".prizmkit" / "specs" / "001-root"
606
+ artifact.mkdir(parents=True)
607
+ checkpoint = artifact / "workflow-checkpoint.json"
608
+ checkpoint.write_text("{\"steps\": \"invalid\"}\n", encoding="utf-8")
609
+ plan_before = plan.read_bytes()
610
+ status_before = status_path.read_bytes()
611
+ checkout_before = (
612
+ paths.feature_state_dir / "F-001" / "checkout.json"
613
+ ).read_bytes()
614
+
615
+ result = run_reset_command("feature", ("--failed", "--preserve-runtime"), paths)
616
+
617
+ assert result.exit_code == 1
618
+ assert "invalid checkpoint" in result.render()
619
+ assert plan.read_bytes() == plan_before
620
+ assert status_path.read_bytes() == status_before
621
+ assert (paths.feature_state_dir / "F-001" / "checkout.json").read_bytes() == checkout_before
622
+ assert checkpoint.read_text(encoding="utf-8") == "{\"steps\": \"invalid\"}\n"
623
+
624
+
625
+ def _add_completed_plan_step(checkpoint):
626
+ payload = json.loads(checkpoint.read_text(encoding="utf-8"))
627
+ payload["steps"].insert(
628
+ 0,
629
+ {
630
+ "id": "S00",
631
+ "skill": "prizmkit-plan",
632
+ "name": "plan",
633
+ "status": "completed",
634
+ "stage_result": "PLAN_READY",
635
+ "depends_on": None,
636
+ "required_artifacts": [],
637
+ },
638
+ )
639
+ payload["steps"][1]["depends_on"] = "S00"
640
+ state_key = {
641
+ "feature-pipeline": "feature_state",
642
+ "bugfix-pipeline": "bugfix_state",
643
+ "refactor-pipeline": "refactor_state",
644
+ }[payload["workflow_type"]]
645
+ payload[state_key]["completed_stages"] = ["plan", "implement", "code-review", "test"]
646
+ checkpoint.write_text(json.dumps(payload, indent=2) + "\n", encoding="utf-8")
647
+
648
+
649
+ def test_preserve_runtime_missing_branch_creates_replacement_and_rewinds_checkpoint(tmp_path):
650
+ from prizmkit_runtime.gitops import local_branch_exists
651
+ from prizmkit_runtime.reset import run_reset_command
652
+ from prizmkit_runtime.runner_models import family_for
653
+ from prizmkit_runtime.task_checkout import load_task_checkout
654
+
655
+ paths = _make_paths(tmp_path)
656
+ _init_preserve_runtime_repo(paths)
657
+ plan = _write_family_plan(
658
+ paths,
659
+ "feature",
660
+ [{"id": "F-001", "title": "Root", "status": "failed", "dependencies": []}],
661
+ )
662
+ family, status_path, before_status, old_checkout = _write_preserve_runtime_state(
663
+ paths, "feature", "F-001", "dev/F-001-deleted"
664
+ )
665
+ artifact = paths.project_root / ".prizmkit" / "specs" / "001-root"
666
+ checkpoint = _write_semantic_completion_checkpoint(paths.project_root, artifact)
667
+ _add_completed_plan_step(checkpoint)
668
+ (artifact / "implementation-notes.md").write_text("keep context\n", encoding="utf-8")
669
+ session_log = family.state_dir / "F-001" / "sessions" / "old-session" / "logs" / "session.log"
670
+ session_before = session_log.read_bytes()
671
+ base_head = subprocess.run(
672
+ ["git", "rev-parse", "main"], cwd=paths.project_root, check=True, stdout=subprocess.PIPE, text=True
673
+ ).stdout.strip()
674
+
675
+ result = run_reset_command("feature", ("--failed", "--preserve-runtime"), paths)
676
+
677
+ assert result.exit_code == 0, result.render()
678
+ checkout = load_task_checkout(family.state_dir, "feature", "F-001")
679
+ assert checkout is not None and checkout.is_active and not checkout.is_reset
680
+ assert checkout.active_dev_branch != old_checkout.active_dev_branch
681
+ assert checkout.active_dev_branch.startswith("dev/F-001-resume-")
682
+ assert checkout.base_branch == "main"
683
+ assert local_branch_exists(paths.project_root, checkout.active_dev_branch)
684
+ assert subprocess.run(
685
+ ["git", "rev-parse", checkout.active_dev_branch],
686
+ cwd=paths.project_root,
687
+ check=True,
688
+ stdout=subprocess.PIPE,
689
+ text=True,
690
+ ).stdout.strip() == base_head
691
+ persisted = json.loads(checkpoint.read_text(encoding="utf-8"))
692
+ assert persisted["steps"][0]["status"] == "completed"
693
+ assert persisted["steps"][0]["stage_result"] == "PLAN_READY"
694
+ assert all(step["status"] == "pending" for step in persisted["steps"][1:])
695
+ assert all("stage_result" not in step for step in persisted["steps"][1:])
696
+ assert persisted["feature_state"]["stage"] == "implement"
697
+ assert persisted["feature_state"]["current_stage"] == "prizmkit-implement"
698
+ assert persisted["feature_state"]["completed_stages"] == ["plan"]
699
+ assert (artifact / "implementation-notes.md").read_text(encoding="utf-8") == "keep context\n"
700
+ expected_status = dict(before_status)
701
+ expected_status.update(
702
+ retry_count=0,
703
+ infra_error_count=0,
704
+ continuation_pending=True,
705
+ active_dev_branch=checkout.active_dev_branch,
706
+ base_branch="main",
707
+ )
708
+ assert json.loads(status_path.read_text(encoding="utf-8")) == expected_status
709
+ assert session_log.read_bytes() == session_before
710
+ assert json.loads(plan.read_text(encoding="utf-8"))["features"][0]["status"] == "pending"
711
+
712
+
713
+ @pytest.mark.parametrize(
714
+ ("kind", "item_id", "branch_prefix", "workflow_type", "artifact_root", "state_key"),
715
+ [
716
+ ("bugfix", "B-001", "bugfix", "bugfix-pipeline", "bugfix", "bugfix_state"),
717
+ ("refactor", "R-001", "refactor", "refactor-pipeline", "refactor", "refactor_state"),
718
+ ],
719
+ )
720
+ def test_missing_branch_checkpoint_rewind_has_cross_family_parity(
721
+ tmp_path,
722
+ kind,
723
+ item_id,
724
+ branch_prefix,
725
+ workflow_type,
726
+ artifact_root,
727
+ state_key,
728
+ ):
729
+ from prizmkit_runtime.gitops import local_branch_exists
730
+ from prizmkit_runtime.reset import run_reset_command
731
+ from prizmkit_runtime.runner_models import family_for
732
+ from prizmkit_runtime.task_checkout import load_task_checkout
733
+
734
+ paths = _make_paths(tmp_path / kind)
735
+ _init_preserve_runtime_repo(paths)
736
+ plan = _write_family_plan(
737
+ paths,
738
+ kind,
739
+ [{"id": item_id, "title": "Root", "status": "failed", "dependencies": []}],
740
+ )
741
+ family, _status_path, _before_status, old_checkout = _write_preserve_runtime_state(
742
+ paths, kind, item_id, f"{branch_prefix}/{item_id}-deleted"
743
+ )
744
+ artifact = paths.project_root / ".prizmkit" / artifact_root / item_id
745
+ checkpoint = _write_semantic_completion_checkpoint(
746
+ paths.project_root,
747
+ artifact,
748
+ workflow_type,
749
+ )
750
+ _add_completed_plan_step(checkpoint)
751
+
752
+ result = run_reset_command(kind, ("--failed", "--preserve-runtime"), paths)
753
+
754
+ assert result.exit_code == 0, result.render()
755
+ checkout = load_task_checkout(family.state_dir, kind, item_id)
756
+ assert checkout is not None and checkout.is_active
757
+ assert checkout.active_dev_branch != old_checkout.active_dev_branch
758
+ assert checkout.active_dev_branch.startswith(f"{branch_prefix}/{item_id}-resume-")
759
+ assert local_branch_exists(paths.project_root, checkout.active_dev_branch)
760
+ persisted = json.loads(checkpoint.read_text(encoding="utf-8"))
761
+ assert persisted["steps"][0]["status"] == "completed"
762
+ assert all(step["status"] == "pending" for step in persisted["steps"][1:])
763
+ assert persisted[state_key]["stage"] == "implement"
764
+ assert persisted[state_key]["current_stage"] == "prizmkit-implement"
765
+ collection = {"bugfix": "bugs", "refactor": "refactors"}[kind]
766
+ assert json.loads(plan.read_text(encoding="utf-8"))[collection][0]["status"] == "pending"
767
+
768
+
769
+ def test_preserve_runtime_reuses_registered_worktree_without_cleanup(tmp_path):
770
+ from prizmkit_runtime.gitops import (
771
+ BranchContext,
772
+ WorktreePolicy,
773
+ ensure_linked_worktree,
774
+ registered_worktrees,
775
+ worktree_runtime_context,
776
+ )
777
+ from prizmkit_runtime.reset import run_reset_command
778
+ from prizmkit_runtime.runner_models import family_for
779
+ from prizmkit_runtime.task_checkout import (
780
+ CHECKOUT_MODE_WORKTREE,
781
+ TaskCheckout,
782
+ load_task_checkout,
783
+ persist_task_checkout,
784
+ )
785
+
786
+ paths = _make_paths(tmp_path)
787
+ _init_preserve_runtime_repo(paths)
788
+ plan = _write_family_plan(
789
+ paths,
790
+ "feature",
791
+ [{"id": "F-001", "title": "Root", "status": "failed", "dependencies": []}],
792
+ )
793
+ family, status_path, _before_status, _old_checkout = _write_preserve_runtime_state(
794
+ paths, "feature", "F-001", "dev/F-001-worktree"
795
+ )
796
+ runtime = worktree_runtime_context(
797
+ BranchContext("main", "dev/F-001-worktree", paths.project_root),
798
+ WorktreePolicy(True, True, True, tmp_path / "worktrees"),
799
+ )
800
+ assert ensure_linked_worktree(runtime).ok
801
+ worktree_checkout = TaskCheckout.active(
802
+ "feature",
803
+ "F-001",
804
+ "dev/F-001-worktree",
805
+ "main",
806
+ checkout_mode=CHECKOUT_MODE_WORKTREE,
807
+ worktree_path=str(runtime.worktree_path),
808
+ )
809
+ persist_task_checkout(family.state_dir, worktree_checkout)
810
+ artifact = runtime.worktree_path / ".prizmkit" / "specs" / "001-root"
811
+ artifact.mkdir(parents=True)
812
+ marker = artifact / "partial.md"
813
+ marker.write_text("worktree WIP\n", encoding="utf-8")
814
+ registrations_before = registered_worktrees(paths.project_root)
815
+
816
+ result = run_reset_command("feature", ("--failed", "--preserve-runtime"), paths)
817
+
818
+ assert result.exit_code == 0, result.render()
819
+ assert load_task_checkout(family.state_dir, "feature", "F-001") == worktree_checkout
820
+ assert runtime.worktree_path.is_dir()
821
+ assert marker.read_text(encoding="utf-8") == "worktree WIP\n"
822
+ assert registered_worktrees(paths.project_root) == registrations_before
823
+ assert json.loads(status_path.read_text(encoding="utf-8"))["continuation_pending"] is True
824
+ assert json.loads(plan.read_text(encoding="utf-8"))["features"][0]["status"] == "pending"
825
+
826
+
827
+ def test_replacement_worktree_skips_registered_path_collision_even_when_path_is_absent(monkeypatch, tmp_path):
828
+ from prizmkit_runtime import reset_preserve
829
+ from prizmkit_runtime.gitops import (
830
+ BranchContext,
831
+ RegisteredWorktree,
832
+ WorktreePolicy,
833
+ worktree_runtime_context,
834
+ )
835
+ from prizmkit_runtime.runner_models import family_for
836
+ from prizmkit_runtime.task_checkout import CHECKOUT_MODE_WORKTREE, TaskCheckout
837
+
838
+ paths = _make_paths(tmp_path)
839
+ _init_preserve_runtime_repo(paths)
840
+ family = family_for("feature", paths)
841
+ worktree_root = tmp_path / "worktrees"
842
+ old_runtime = worktree_runtime_context(
843
+ BranchContext("main", "dev/F-001-old", paths.project_root),
844
+ WorktreePolicy(True, True, True, worktree_root),
845
+ )
846
+ checkout = TaskCheckout.active(
847
+ "feature",
848
+ "F-001",
849
+ "dev/F-001-old",
850
+ "main",
851
+ checkout_mode=CHECKOUT_MODE_WORKTREE,
852
+ worktree_path=str(old_runtime.worktree_path),
853
+ )
854
+ item = reset_preserve.PreservePlanItem(
855
+ "F-001", "Root", "001-root", "failed", (), 0
856
+ )
857
+ monkeypatch.setattr(reset_preserve.time, "strftime", lambda *_args: "20260725120000")
858
+ colliding_runtime = worktree_runtime_context(
859
+ BranchContext(
860
+ "main",
861
+ "dev/F-001-resume-20260725120000",
862
+ paths.project_root,
863
+ ),
864
+ WorktreePolicy(True, True, True, worktree_root),
865
+ )
866
+ registration = RegisteredWorktree(
867
+ colliding_runtime.worktree_path,
868
+ "different/branch-with-same-normalized-path",
869
+ )
870
+
871
+ branch, runtime = reset_preserve._replacement_identity(
872
+ family,
873
+ item,
874
+ checkout,
875
+ paths.project_root,
876
+ (registration,),
877
+ old_runtime,
878
+ )
879
+
880
+ assert branch == "dev/F-001-resume-20260725120000-1"
881
+ assert runtime is not None
882
+ assert runtime.worktree_path != colliding_runtime.worktree_path
883
+
884
+
885
+ def test_preserve_runtime_missing_worktree_copies_reference_artifacts_without_deleting_old_site(tmp_path):
886
+ from prizmkit_runtime.gitops import BranchContext, WorktreePolicy, worktree_runtime_context
887
+ from prizmkit_runtime.reset import run_reset_command
888
+ from prizmkit_runtime.runner_models import family_for
889
+ from prizmkit_runtime.task_checkout import (
890
+ CHECKOUT_MODE_WORKTREE,
891
+ TaskCheckout,
892
+ load_task_checkout,
893
+ persist_task_checkout,
894
+ )
895
+
896
+ paths = _make_paths(tmp_path)
897
+ _init_preserve_runtime_repo(paths)
898
+ plan = _write_family_plan(
899
+ paths,
900
+ "feature",
901
+ [{"id": "F-001", "title": "Root", "status": "failed", "dependencies": []}],
902
+ )
903
+ family, status_path, before_status, _branch_checkout = _write_preserve_runtime_state(
904
+ paths, "feature", "F-001", "dev/F-001-deleted-worktree"
905
+ )
906
+ old_runtime = worktree_runtime_context(
907
+ BranchContext("main", "dev/F-001-deleted-worktree", paths.project_root),
908
+ WorktreePolicy(True, True, True, tmp_path / "worktrees"),
909
+ )
910
+ old_runtime.worktree_path.mkdir(parents=True)
911
+ old_checkout = TaskCheckout.active(
912
+ "feature",
913
+ "F-001",
914
+ "dev/F-001-deleted-worktree",
915
+ "main",
916
+ checkout_mode=CHECKOUT_MODE_WORKTREE,
917
+ worktree_path=str(old_runtime.worktree_path),
918
+ )
919
+ persist_task_checkout(family.state_dir, old_checkout)
920
+ old_artifact = old_runtime.worktree_path / ".prizmkit" / "specs" / "001-root"
921
+ old_checkpoint = _write_semantic_completion_checkpoint(old_runtime.worktree_path, old_artifact)
922
+ _add_completed_plan_step(old_checkpoint)
923
+ old_marker = old_artifact / "partial.md"
924
+ old_marker.write_text("old reference\n", encoding="utf-8")
925
+ external_reference = tmp_path / "external-reference.txt"
926
+ external_reference.write_text("do not copy through symlink\n", encoding="utf-8")
927
+ old_link = old_artifact / "external-reference"
928
+ old_link.symlink_to(external_reference)
929
+ old_checkpoint_before = old_checkpoint.read_bytes()
930
+
931
+ result = run_reset_command("feature", ("--failed", "--preserve-runtime"), paths)
932
+
933
+ assert result.exit_code == 0, result.render()
934
+ checkout = load_task_checkout(family.state_dir, "feature", "F-001")
935
+ assert checkout is not None and checkout.is_active and checkout.uses_worktree
936
+ assert checkout.active_dev_branch.startswith("dev/F-001-resume-")
937
+ assert Path(checkout.worktree_path) != old_runtime.worktree_path
938
+ assert Path(checkout.worktree_path).is_dir()
939
+ assert old_runtime.worktree_path.is_dir()
940
+ assert old_checkpoint.read_bytes() == old_checkpoint_before
941
+ assert old_marker.read_text(encoding="utf-8") == "old reference\n"
942
+ copied_artifact = Path(checkout.worktree_path) / ".prizmkit" / "specs" / "001-root"
943
+ assert (copied_artifact / "partial.md").read_text(encoding="utf-8") == "old reference\n"
944
+ copied_link = copied_artifact / "external-reference"
945
+ assert copied_link.is_symlink()
946
+ assert copied_link.resolve() == external_reference.resolve()
947
+ copied_checkpoint = json.loads((copied_artifact / "workflow-checkpoint.json").read_text(encoding="utf-8"))
948
+ assert copied_checkpoint["steps"][0]["status"] == "completed"
949
+ assert all(step["status"] == "pending" for step in copied_checkpoint["steps"][1:])
950
+ expected_status = dict(before_status)
951
+ expected_status.update(
952
+ retry_count=0,
953
+ infra_error_count=0,
954
+ continuation_pending=True,
955
+ active_dev_branch=checkout.active_dev_branch,
956
+ base_branch="main",
957
+ )
958
+ assert json.loads(status_path.read_text(encoding="utf-8")) == expected_status
959
+ assert json.loads(plan.read_text(encoding="utf-8"))["features"][0]["status"] == "pending"