prizmkit 1.1.160 → 1.1.161

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 (25) hide show
  1. package/bundled/VERSION.json +3 -3
  2. package/bundled/dev-pipeline/prizmkit_runtime/heartbeat.py +9 -6
  3. package/bundled/dev-pipeline/prizmkit_runtime/runtime_commit.py +145 -110
  4. package/bundled/dev-pipeline/templates/bootstrap-prompt.md +1 -1
  5. package/bundled/dev-pipeline/templates/bootstrap-tier3.md +2 -2
  6. package/bundled/dev-pipeline/templates/bugfix-bootstrap-prompt.md +1 -1
  7. package/bundled/dev-pipeline/templates/refactor-bootstrap-prompt.md +1 -1
  8. package/bundled/dev-pipeline/templates/sections/runtime-commit-handoff.md +9 -15
  9. package/bundled/dev-pipeline/tests/test_generate_bootstrap_prompt.py +8 -5
  10. package/bundled/dev-pipeline/tests/test_generate_bugfix_prompt.py +4 -2
  11. package/bundled/dev-pipeline/tests/test_generate_refactor_prompt.py +4 -2
  12. package/bundled/dev-pipeline/tests/test_runtime_commit.py +170 -66
  13. package/bundled/dev-pipeline/tests/test_unified_cli.py +172 -4
  14. package/bundled/rules/prizm/prizm-commit-workflow.md +12 -5
  15. package/bundled/skills/_metadata.json +1 -1
  16. package/bundled/skills/prizmkit-code-review/SKILL.md +5 -5
  17. package/bundled/skills/prizmkit-code-review/references/independent-code-review.md +4 -5
  18. package/bundled/skills/prizmkit-code-review/references/review-report-template.md +4 -6
  19. package/bundled/skills/prizmkit-code-review/scripts/render_review_report.py +1 -4
  20. package/bundled/skills/prizmkit-committer/SKILL.md +47 -63
  21. package/bundled/skills/prizmkit-test/SKILL.md +2 -2
  22. package/bundled/skills/prizmkit-test/references/test-coverage-model.md +1 -1
  23. package/bundled/skills/prizmkit-workflow/SKILL.md +4 -6
  24. package/bundled/skills/prizmkit-workflow/references/workflow-state-protocol.md +10 -8
  25. package/package.json +1 -1
@@ -171,16 +171,15 @@ def _write_commit_ready_checkpoint(root: Path) -> tuple[Path, Path]:
171
171
  return artifact_dir, checkpoint_path
172
172
 
173
173
 
174
- def _write_request(root: Path, artifact_dir: Path, base_head: str, paths: list[str]) -> Path:
174
+ def _write_request(root: Path, artifact_dir: Path, base_head: str) -> Path:
175
175
  request_path = artifact_dir / "runtime-commit-request.json"
176
176
  request_path.write_text(
177
177
  json.dumps(
178
178
  {
179
- "schema_version": 1,
179
+ "schema_version": 2,
180
180
  "artifact_dir": artifact_dir.relative_to(root).as_posix(),
181
181
  "base_head": base_head,
182
182
  "commit_message": "fix(runtime): finalize prepared task",
183
- "intended_paths": paths,
184
183
  }
185
184
  ),
186
185
  encoding="utf-8",
@@ -188,7 +187,7 @@ def _write_request(root: Path, artifact_dir: Path, base_head: str, paths: list[s
188
187
  return request_path
189
188
 
190
189
 
191
- def test_runtime_commit_executes_exact_request_and_finalizes_only_checkpoint(tmp_path):
190
+ def test_runtime_commit_executes_complete_workspace_and_finalizes_only_checkpoint(tmp_path):
192
191
  base_head = _init_repo(tmp_path)
193
192
  artifact_dir, checkpoint_path = _write_commit_ready_checkpoint(tmp_path)
194
193
  caller_state = tmp_path / ".prizmkit" / "state" / "workflows" / "caller.json"
@@ -196,7 +195,7 @@ def test_runtime_commit_executes_exact_request_and_finalizes_only_checkpoint(tmp
196
195
  caller_state.write_text('{"owner":"external-coordinator"}\n', encoding="utf-8")
197
196
  expected_caller_state = caller_state.read_bytes()
198
197
  (tmp_path / "tracked.txt").write_text("after\n", encoding="utf-8")
199
- _write_request(tmp_path, artifact_dir, base_head, ["tracked.txt"])
198
+ _write_request(tmp_path, artifact_dir, base_head)
200
199
 
201
200
  result = finalize_runtime_commit(
202
201
  tmp_path,
@@ -229,7 +228,7 @@ def test_runtime_commit_executes_exact_request_and_finalizes_only_checkpoint(tmp
229
228
  ".prizmkit/prizm-docs/src/counter.prizm",
230
229
  ],
231
230
  )
232
- def test_runtime_commit_admits_tracked_prizmkit_path_in_exact_request(tmp_path, durable_path):
231
+ def test_runtime_commit_includes_tracked_prizmkit_path_from_workspace(tmp_path, durable_path):
233
232
  _init_repo(tmp_path)
234
233
  durable = tmp_path / durable_path
235
234
  durable.parent.mkdir(parents=True, exist_ok=True)
@@ -240,7 +239,7 @@ def test_runtime_commit_admits_tracked_prizmkit_path_in_exact_request(tmp_path,
240
239
  artifact_dir, checkpoint_path = _write_commit_ready_checkpoint(tmp_path)
241
240
  (tmp_path / "tracked.txt").write_text("after\n", encoding="utf-8")
242
241
  durable.write_text("after\n", encoding="utf-8")
243
- _write_request(tmp_path, artifact_dir, base_head, ["tracked.txt", durable_path])
242
+ _write_request(tmp_path, artifact_dir, base_head)
244
243
 
245
244
  result = finalize_runtime_commit(
246
245
  tmp_path,
@@ -261,7 +260,7 @@ def test_runtime_commit_admits_tracked_prizmkit_path_in_exact_request(tmp_path,
261
260
  assert durable.read_text(encoding="utf-8") == "after\n"
262
261
 
263
262
 
264
- def test_runtime_commit_ignores_runner_support_paths_but_leaves_them_unmodified(tmp_path):
263
+ def test_runtime_commit_includes_valid_host_support_paths_from_workspace(tmp_path):
265
264
  base_head = _init_repo(tmp_path)
266
265
  artifact_dir, checkpoint_path = _write_commit_ready_checkpoint(tmp_path)
267
266
  (tmp_path / "tracked.txt").write_text("after\n", encoding="utf-8")
@@ -269,7 +268,7 @@ def test_runtime_commit_ignores_runner_support_paths_but_leaves_them_unmodified(
269
268
  codex_config = tmp_path / ".codex" / "config.toml"
270
269
  codex_config.parent.mkdir()
271
270
  codex_config.write_text("[project]\ntrusted = true\n", encoding="utf-8")
272
- _write_request(tmp_path, artifact_dir, base_head, ["tracked.txt"])
271
+ _write_request(tmp_path, artifact_dir, base_head)
273
272
 
274
273
  result = finalize_runtime_commit(
275
274
  tmp_path,
@@ -278,11 +277,11 @@ def test_runtime_commit_ignores_runner_support_paths_but_leaves_them_unmodified(
278
277
  )
279
278
 
280
279
  assert result.status == "committed"
281
- assert _git(tmp_path, "diff-tree", "--no-commit-id", "--name-only", "-r", "HEAD") == "tracked.txt"
282
- assert (tmp_path / "AGENTS.md").read_text(encoding="utf-8") == "@./AGENTS.private.md\n"
283
- assert codex_config.read_text(encoding="utf-8") == "[project]\ntrusted = true\n"
284
- untracked = set(_git(tmp_path, "ls-files", "--others", "--exclude-standard").splitlines())
285
- assert {"AGENTS.md", ".codex/config.toml"}.issubset(untracked)
280
+ changed = set(
281
+ _git(tmp_path, "diff-tree", "--no-commit-id", "--name-only", "-r", "HEAD").splitlines()
282
+ )
283
+ assert changed == {"tracked.txt", "AGENTS.md", ".codex/config.toml"}
284
+ assert _git(tmp_path, "status", "--porcelain") == ""
286
285
 
287
286
 
288
287
  def test_runtime_commit_allows_task_path_adjacent_to_internal_namespace(tmp_path):
@@ -291,7 +290,7 @@ def test_runtime_commit_allows_task_path_adjacent_to_internal_namespace(tmp_path
291
290
  adjacent_path = tmp_path / ".prizmkitx" / "task.txt"
292
291
  adjacent_path.parent.mkdir()
293
292
  adjacent_path.write_text("task-owned\n", encoding="utf-8")
294
- _write_request(tmp_path, artifact_dir, base_head, [".prizmkitx/task.txt"])
293
+ _write_request(tmp_path, artifact_dir, base_head)
295
294
 
296
295
  result = finalize_runtime_commit(
297
296
  tmp_path,
@@ -315,7 +314,7 @@ def test_runtime_commit_ignores_project_gitignored_transient_data(tmp_path):
315
314
  transient = tmp_path / "tmp" / "tool-cache.json"
316
315
  transient.parent.mkdir()
317
316
  transient.write_text('{"cache":true}\n', encoding="utf-8")
318
- _write_request(tmp_path, artifact_dir, base_head, ["tracked.txt"])
317
+ _write_request(tmp_path, artifact_dir, base_head)
319
318
 
320
319
  result = finalize_runtime_commit(
321
320
  tmp_path,
@@ -328,12 +327,12 @@ def test_runtime_commit_ignores_project_gitignored_transient_data(tmp_path):
328
327
  assert transient.read_text(encoding="utf-8") == '{"cache":true}\n'
329
328
 
330
329
 
331
- def test_runtime_commit_rejects_unlisted_git_visible_change(tmp_path):
330
+ def test_runtime_commit_includes_preexisting_git_visible_change_without_manifest(tmp_path):
332
331
  base_head = _init_repo(tmp_path)
333
332
  artifact_dir, checkpoint_path = _write_commit_ready_checkpoint(tmp_path)
334
333
  (tmp_path / "tracked.txt").write_text("after\n", encoding="utf-8")
335
- (tmp_path / "unexpected.txt").write_text("unexpected\n", encoding="utf-8")
336
- _write_request(tmp_path, artifact_dir, base_head, ["tracked.txt"])
334
+ (tmp_path / "preexisting.txt").write_text("validated existing work\n", encoding="utf-8")
335
+ _write_request(tmp_path, artifact_dir, base_head)
337
336
 
338
337
  result = finalize_runtime_commit(
339
338
  tmp_path,
@@ -341,16 +340,18 @@ def test_runtime_commit_rejects_unlisted_git_visible_change(tmp_path):
341
340
  checkpoint_path=checkpoint_path,
342
341
  )
343
342
 
344
- assert result.status == "failed"
345
- assert result.reason == "intended_paths_mismatch"
346
- assert _git(tmp_path, "rev-parse", "HEAD") == base_head
343
+ assert result.status == "committed"
344
+ changed = set(
345
+ _git(tmp_path, "diff-tree", "--no-commit-id", "--name-only", "-r", "HEAD").splitlines()
346
+ )
347
+ assert changed == {"tracked.txt", "preexisting.txt"}
347
348
 
348
349
 
349
350
  def test_runtime_commit_recovers_commit_created_before_state_finalization(tmp_path):
350
351
  base_head = _init_repo(tmp_path)
351
352
  artifact_dir, checkpoint_path = _write_commit_ready_checkpoint(tmp_path)
352
353
  (tmp_path / "tracked.txt").write_text("after\n", encoding="utf-8")
353
- _write_request(tmp_path, artifact_dir, base_head, ["tracked.txt"])
354
+ _write_request(tmp_path, artifact_dir, base_head)
354
355
  _git(tmp_path, "add", "tracked.txt")
355
356
  _git(tmp_path, "commit", "-m", "fix(runtime): finalize prepared task")
356
357
  committed_head = _git(tmp_path, "rev-parse", "HEAD")
@@ -367,15 +368,38 @@ def test_runtime_commit_recovers_commit_created_before_state_finalization(tmp_pa
367
368
  assert _git(tmp_path, "rev-list", "--count", f"{base_head}..HEAD") == "1"
368
369
 
369
370
 
371
+ def test_runtime_commit_rejects_recovery_commit_containing_causal_bookkeeping(tmp_path):
372
+ base_head = _init_repo(tmp_path)
373
+ artifact_dir, checkpoint_path = _write_commit_ready_checkpoint(tmp_path)
374
+ (tmp_path / "tracked.txt").write_text("after\n", encoding="utf-8")
375
+ request_path = _write_request(tmp_path, artifact_dir, base_head)
376
+ _git(
377
+ tmp_path,
378
+ "add",
379
+ "-f",
380
+ "tracked.txt",
381
+ checkpoint_path.relative_to(tmp_path).as_posix(),
382
+ request_path.relative_to(tmp_path).as_posix(),
383
+ )
384
+ _git(tmp_path, "commit", "-m", "fix(runtime): finalize prepared task")
385
+
386
+ result = finalize_runtime_commit(
387
+ tmp_path,
388
+ artifact_dir=artifact_dir,
389
+ checkpoint_path=checkpoint_path,
390
+ )
391
+
392
+ assert result.status == "failed"
393
+ assert result.reason == "base_head_mismatch"
394
+
395
+
370
396
  def test_runtime_commit_recovers_after_checkpoint_finalization_failure(tmp_path, monkeypatch):
371
397
  import prizmkit_runtime.runtime_commit as runtime_commit
372
398
 
373
399
  base_head = _init_repo(tmp_path)
374
400
  artifact_dir, checkpoint_path = _write_commit_ready_checkpoint(tmp_path)
375
401
  (tmp_path / "tracked.txt").write_text("after\n", encoding="utf-8")
376
- request_path = _write_request(
377
- tmp_path, artifact_dir, base_head, ["tracked.txt"]
378
- )
402
+ request_path = _write_request(tmp_path, artifact_dir, base_head)
379
403
 
380
404
  original_finalize = runtime_commit._finalize_checkpoint
381
405
  monkeypatch.setattr(
@@ -412,9 +436,7 @@ def test_runtime_commit_recovers_cleanup_after_completed_checkpoint(tmp_path, mo
412
436
  base_head = _init_repo(tmp_path)
413
437
  artifact_dir, checkpoint_path = _write_commit_ready_checkpoint(tmp_path)
414
438
  (tmp_path / "tracked.txt").write_text("after\n", encoding="utf-8")
415
- request_path = _write_request(
416
- tmp_path, artifact_dir, base_head, ["tracked.txt"]
417
- )
439
+ request_path = _write_request(tmp_path, artifact_dir, base_head)
418
440
 
419
441
  original_unlink = Path.unlink
420
442
 
@@ -457,7 +479,7 @@ def test_runtime_commit_disables_repository_hooks(tmp_path):
457
479
  )
458
480
  hook.chmod(0o755)
459
481
  (tmp_path / "tracked.txt").write_text("after\n", encoding="utf-8")
460
- _write_request(tmp_path, artifact_dir, base_head, ["tracked.txt"])
482
+ _write_request(tmp_path, artifact_dir, base_head)
461
483
 
462
484
  result = finalize_runtime_commit(
463
485
  tmp_path,
@@ -479,12 +501,7 @@ def test_runtime_commit_handles_rename_as_exact_delete_and_add(tmp_path):
479
501
  base_head = _git(tmp_path, "rev-parse", "HEAD")
480
502
  artifact_dir, checkpoint_path = _write_commit_ready_checkpoint(tmp_path)
481
503
  old_path.rename(tmp_path / "new-name.txt")
482
- _write_request(
483
- tmp_path,
484
- artifact_dir,
485
- base_head,
486
- ["old-name.txt", "new-name.txt"],
487
- )
504
+ _write_request(tmp_path, artifact_dir, base_head)
488
505
 
489
506
  result = finalize_runtime_commit(
490
507
  tmp_path,
@@ -508,27 +525,116 @@ def test_runtime_commit_handles_rename_as_exact_delete_and_add(tmp_path):
508
525
  @pytest.mark.parametrize(
509
526
  "unsafe_path",
510
527
  [
511
- "../escape.txt",
512
- ".git/config",
513
528
  ".env",
514
- "AGENTS.md",
515
- "skills-lock.json",
516
- ".codex/config.toml",
517
- ".CLAUDE/settings.local.json",
518
- ".prizmkit/prizm-docs/.env",
519
- ".prizmkit/state/runtime.json",
520
- ".prizmkit/dev-pipeline/cli.py",
521
- ".prizmkit/scripts/helper.py",
522
- ".prizmkit/manifest.json",
523
- ".prizmkit/specs/001-runtime-commit/workflow-checkpoint.json",
524
- ".prizmkit/specs/001-runtime-commit/runtime-commit-request.json",
529
+ "credentials.json",
530
+ "secrets.json",
531
+ "private.pem",
532
+ ".codex/settings.local.json",
525
533
  ],
526
534
  )
527
- def test_runtime_commit_rejects_unsafe_intended_paths(tmp_path, unsafe_path):
535
+ def test_runtime_commit_rejects_sensitive_git_visible_workspace_path(tmp_path, unsafe_path):
536
+ base_head = _init_repo(tmp_path)
537
+ artifact_dir, checkpoint_path = _write_commit_ready_checkpoint(tmp_path)
538
+ unsafe = tmp_path / unsafe_path
539
+ unsafe.parent.mkdir(parents=True, exist_ok=True)
540
+ unsafe.write_text("sensitive\n", encoding="utf-8")
541
+ _write_request(tmp_path, artifact_dir, base_head)
542
+
543
+ result = finalize_runtime_commit(
544
+ tmp_path,
545
+ artifact_dir=artifact_dir,
546
+ checkpoint_path=checkpoint_path,
547
+ )
548
+
549
+ assert result.status == "failed"
550
+ assert result.reason == "sensitive_workspace_path"
551
+ assert _git(tmp_path, "rev-parse", "HEAD") == base_head
552
+
553
+
554
+ def test_runtime_commit_rejects_empty_workspace(tmp_path):
555
+ base_head = _init_repo(tmp_path)
556
+ artifact_dir, checkpoint_path = _write_commit_ready_checkpoint(tmp_path)
557
+ _write_request(tmp_path, artifact_dir, base_head)
558
+
559
+ result = finalize_runtime_commit(
560
+ tmp_path,
561
+ artifact_dir=artifact_dir,
562
+ checkpoint_path=checkpoint_path,
563
+ )
564
+
565
+ assert result.status == "failed"
566
+ assert result.reason == "empty_workspace_change"
567
+ assert _git(tmp_path, "rev-parse", "HEAD") == base_head
568
+
569
+
570
+ def test_runtime_commit_rejects_unresolved_merge_state(tmp_path):
571
+ _init_repo(tmp_path)
572
+ initial_branch = _git(tmp_path, "branch", "--show-current")
573
+ _git(tmp_path, "checkout", "-b", "other")
574
+ (tmp_path / "tracked.txt").write_text("other\n", encoding="utf-8")
575
+ _git(tmp_path, "add", "tracked.txt")
576
+ _git(tmp_path, "commit", "-m", "other change")
577
+ _git(tmp_path, "checkout", initial_branch)
578
+ (tmp_path / "tracked.txt").write_text("master\n", encoding="utf-8")
579
+ _git(tmp_path, "add", "tracked.txt")
580
+ _git(tmp_path, "commit", "-m", "master change")
581
+ base_head = _git(tmp_path, "rev-parse", "HEAD")
582
+ merged = subprocess.run(
583
+ ["git", "merge", "other"],
584
+ cwd=tmp_path,
585
+ stdout=subprocess.PIPE,
586
+ stderr=subprocess.PIPE,
587
+ text=True,
588
+ check=False,
589
+ )
590
+ assert merged.returncode != 0
591
+ artifact_dir, checkpoint_path = _write_commit_ready_checkpoint(tmp_path)
592
+ _write_request(tmp_path, artifact_dir, base_head)
593
+
594
+ result = finalize_runtime_commit(
595
+ tmp_path,
596
+ artifact_dir=artifact_dir,
597
+ checkpoint_path=checkpoint_path,
598
+ )
599
+
600
+ assert result.status == "failed"
601
+ assert result.reason == "unresolved_merge_state"
602
+ assert _git(tmp_path, "rev-parse", "HEAD") == base_head
603
+
604
+
605
+ def test_runtime_commit_rejects_already_staged_runtime_bookkeeping(tmp_path):
528
606
  base_head = _init_repo(tmp_path)
529
607
  artifact_dir, checkpoint_path = _write_commit_ready_checkpoint(tmp_path)
530
608
  (tmp_path / "tracked.txt").write_text("after\n", encoding="utf-8")
531
- _write_request(tmp_path, artifact_dir, base_head, [unsafe_path])
609
+ request_path = _write_request(tmp_path, artifact_dir, base_head)
610
+ _git(
611
+ tmp_path,
612
+ "add",
613
+ "-f",
614
+ checkpoint_path.relative_to(tmp_path).as_posix(),
615
+ request_path.relative_to(tmp_path).as_posix(),
616
+ )
617
+
618
+ result = finalize_runtime_commit(
619
+ tmp_path,
620
+ artifact_dir=artifact_dir,
621
+ checkpoint_path=checkpoint_path,
622
+ )
623
+
624
+ assert result.status == "failed"
625
+ assert result.reason == "runtime_bookkeeping_staged"
626
+ assert _git(tmp_path, "rev-parse", "HEAD") == base_head
627
+
628
+
629
+ def test_runtime_commit_rejects_schema_v1_path_manifest(tmp_path):
630
+ base_head = _init_repo(tmp_path)
631
+ artifact_dir, checkpoint_path = _write_commit_ready_checkpoint(tmp_path)
632
+ (tmp_path / "tracked.txt").write_text("after\n", encoding="utf-8")
633
+ request_path = _write_request(tmp_path, artifact_dir, base_head)
634
+ request = json.loads(request_path.read_text(encoding="utf-8"))
635
+ request["schema_version"] = 1
636
+ request["intended_paths"] = ["tracked.txt"]
637
+ request_path.write_text(json.dumps(request), encoding="utf-8")
532
638
 
533
639
  result = finalize_runtime_commit(
534
640
  tmp_path,
@@ -537,11 +643,11 @@ def test_runtime_commit_rejects_unsafe_intended_paths(tmp_path, unsafe_path):
537
643
  )
538
644
 
539
645
  assert result.status == "failed"
540
- assert result.reason == "commit_request_path_unsafe"
646
+ assert result.reason == "commit_request_invalid_schema"
541
647
  assert _git(tmp_path, "rev-parse", "HEAD") == base_head
542
648
 
543
649
 
544
- def test_runtime_commit_verifies_staged_set_before_commit(tmp_path):
650
+ def test_runtime_commit_includes_already_staged_and_unstaged_workspace(tmp_path):
545
651
  _init_repo(tmp_path)
546
652
  support = tmp_path / "AGENTS.md"
547
653
  support.write_text("before\n", encoding="utf-8")
@@ -552,7 +658,7 @@ def test_runtime_commit_verifies_staged_set_before_commit(tmp_path):
552
658
  (tmp_path / "tracked.txt").write_text("after\n", encoding="utf-8")
553
659
  support.write_text("after\n", encoding="utf-8")
554
660
  _git(tmp_path, "add", "AGENTS.md")
555
- _write_request(tmp_path, artifact_dir, base_head, ["tracked.txt"])
661
+ _write_request(tmp_path, artifact_dir, base_head)
556
662
 
557
663
  result = finalize_runtime_commit(
558
664
  tmp_path,
@@ -560,9 +666,11 @@ def test_runtime_commit_verifies_staged_set_before_commit(tmp_path):
560
666
  checkpoint_path=checkpoint_path,
561
667
  )
562
668
 
563
- assert result.status == "failed"
564
- assert result.reason == "staged_paths_mismatch"
565
- assert _git(tmp_path, "rev-parse", "HEAD") == base_head
669
+ assert result.status == "committed"
670
+ changed = set(
671
+ _git(tmp_path, "diff-tree", "--no-commit-id", "--name-only", "-r", "HEAD").splitlines()
672
+ )
673
+ assert changed == {"AGENTS.md", "tracked.txt"}
566
674
 
567
675
 
568
676
  def test_runtime_commit_keeps_tracked_post_commit_bookkeeping_out_of_snapshot(tmp_path):
@@ -570,13 +678,13 @@ def test_runtime_commit_keeps_tracked_post_commit_bookkeeping_out_of_snapshot(tm
570
678
 
571
679
  initial_head = _init_repo(tmp_path)
572
680
  artifact_dir, checkpoint_path = _write_commit_ready_checkpoint(tmp_path)
573
- request_path = _write_request(tmp_path, artifact_dir, initial_head, ["tracked.txt"])
681
+ request_path = _write_request(tmp_path, artifact_dir, initial_head)
574
682
  checkpoint_rel = checkpoint_path.relative_to(tmp_path).as_posix()
575
683
  request_rel = request_path.relative_to(tmp_path).as_posix()
576
684
  _git(tmp_path, "add", "-f", checkpoint_rel, request_rel)
577
685
  _git(tmp_path, "commit", "-m", "track runtime bookkeeping by project policy")
578
686
  base_head = _git(tmp_path, "rev-parse", "HEAD")
579
- _write_request(tmp_path, artifact_dir, base_head, ["tracked.txt"])
687
+ _write_request(tmp_path, artifact_dir, base_head)
580
688
  (tmp_path / "tracked.txt").write_text("after\n", encoding="utf-8")
581
689
 
582
690
  result = finalize_runtime_commit(
@@ -606,9 +714,7 @@ def test_runtime_commit_rejects_caller_orchestration_fields(tmp_path):
606
714
  base_head = _init_repo(tmp_path)
607
715
  artifact_dir, checkpoint_path = _write_commit_ready_checkpoint(tmp_path)
608
716
  (tmp_path / "tracked.txt").write_text("after\n", encoding="utf-8")
609
- request_path = _write_request(
610
- tmp_path, artifact_dir, base_head, ["tracked.txt"]
611
- )
717
+ request_path = _write_request(tmp_path, artifact_dir, base_head)
612
718
  request = json.loads(request_path.read_text(encoding="utf-8"))
613
719
  request["caller_state_path"] = ".runtime/caller-state.json"
614
720
  request_path.write_text(json.dumps(request), encoding="utf-8")
@@ -628,9 +734,7 @@ def test_runtime_commit_rejects_ai_supplied_premature_runtime_hash(tmp_path):
628
734
  base_head = _init_repo(tmp_path)
629
735
  artifact_dir, checkpoint_path = _write_commit_ready_checkpoint(tmp_path)
630
736
  (tmp_path / "tracked.txt").write_text("after\n", encoding="utf-8")
631
- request_path = _write_request(
632
- tmp_path, artifact_dir, base_head, ["tracked.txt"]
633
- )
737
+ request_path = _write_request(tmp_path, artifact_dir, base_head)
634
738
  request = json.loads(request_path.read_text(encoding="utf-8"))
635
739
  request["runtime_commit_hash"] = "a" * 40
636
740
  request_path.write_text(json.dumps(request), encoding="utf-8")
@@ -650,7 +754,7 @@ def test_runtime_commit_rejects_stale_base_head(tmp_path):
650
754
  base_head = _init_repo(tmp_path)
651
755
  artifact_dir, checkpoint_path = _write_commit_ready_checkpoint(tmp_path)
652
756
  (tmp_path / "tracked.txt").write_text("after\n", encoding="utf-8")
653
- _write_request(tmp_path, artifact_dir, "a" * 40, ["tracked.txt"])
757
+ _write_request(tmp_path, artifact_dir, "a" * 40)
654
758
 
655
759
  result = finalize_runtime_commit(
656
760
  tmp_path,
@@ -1313,8 +1313,9 @@ def test_headless_review_guidance_maps_stage_local_skill_result():
1313
1313
 
1314
1314
  skill_text = skill_path.read_text(encoding="utf-8")
1315
1315
  assert "the staged diff, and the unstaged diff" in skill_text
1316
- assert "untracked, deleted, and renamed paths" in skill_text
1317
- assert "default-excluded changed paths" in skill_text
1316
+ assert "non-ignored untracked, deleted, renamed, and copied paths" in skill_text
1317
+ assert "Do not maintain excluded-path metadata" in skill_text
1318
+ assert "complete current Git-visible workspace" in skill_text
1318
1319
  assert "accepted = 0" in skill_text
1319
1320
  assert "ten completed review rounds" in skill_text
1320
1321
  assert "## Phase 3: Independent Code Review" in skill_text
@@ -2391,7 +2392,7 @@ def test_heartbeat_observation_stale_accumulates_by_elapsed_time_and_resets_on_p
2391
2392
  session_log = tmp_path / "session.log"
2392
2393
  progress = tmp_path / "progress.json"
2393
2394
  session_log.write_text("initial\n", encoding="utf-8")
2394
- progress.write_text('{"current_phase":"plan","current_tool":"Read","message_count":1,"total_tool_calls":1}\n', encoding="utf-8")
2395
+ progress.write_text('{"current_phase":"plan","current_tool":null,"message_count":1,"total_tool_calls":1}\n', encoding="utf-8")
2395
2396
  config = HeartbeatConfig(
2396
2397
  heartbeat_file=tmp_path / "heartbeat.json",
2397
2398
  session_log=session_log,
@@ -2419,6 +2420,72 @@ def test_heartbeat_observation_stale_accumulates_by_elapsed_time_and_resets_on_p
2419
2420
  assert reset.last_seen_epoch == 1095.0
2420
2421
 
2421
2422
 
2423
+ def test_heartbeat_observation_active_tool_resets_stale_until_tool_clears(tmp_path, monkeypatch):
2424
+ import prizmkit_runtime.heartbeat as heartbeat
2425
+ from prizmkit_runtime.heartbeat import HeartbeatConfig, observe_heartbeat_state
2426
+
2427
+ progress = tmp_path / "progress.json"
2428
+ progress.write_text(
2429
+ '{"current_tool":"bash","message_count":1,"total_tool_calls":1}\n',
2430
+ encoding="utf-8",
2431
+ )
2432
+ config = HeartbeatConfig(
2433
+ heartbeat_file=tmp_path / "heartbeat.json",
2434
+ progress_file=progress,
2435
+ stale_after_seconds=60,
2436
+ )
2437
+ ticks = iter([1000.0, 1100.0, 1200.0, 1205.0, 1230.0])
2438
+ monkeypatch.setattr(heartbeat.time, "time", lambda: next(ticks))
2439
+
2440
+ first = observe_heartbeat_state(config)
2441
+ active = observe_heartbeat_state(config, first)
2442
+ still_active = observe_heartbeat_state(config, active)
2443
+ progress.write_text(
2444
+ '{"current_tool":null,"message_count":1,"total_tool_calls":1}\n',
2445
+ encoding="utf-8",
2446
+ )
2447
+ tool_finished = observe_heartbeat_state(config, still_active)
2448
+ idle = observe_heartbeat_state(config, tool_finished)
2449
+
2450
+ assert active.details == "progress"
2451
+ assert active.stale_seconds == 0
2452
+ assert active.last_seen_epoch == 1100.0
2453
+ assert still_active.details == "progress"
2454
+ assert still_active.stale_seconds == 0
2455
+ assert still_active.last_seen_epoch == 1200.0
2456
+ assert tool_finished.details == "progress"
2457
+ assert tool_finished.stale_seconds == 0
2458
+ assert tool_finished.last_seen_epoch == 1205.0
2459
+ assert idle.details == "no progress"
2460
+ assert idle.stale_seconds == 25.0
2461
+
2462
+
2463
+ @pytest.mark.parametrize("current_tool", [None, "", " ", 0, ["bash"]])
2464
+ def test_heartbeat_observation_rejects_inactive_or_malformed_tool_values(
2465
+ tmp_path,
2466
+ monkeypatch,
2467
+ current_tool,
2468
+ ):
2469
+ import prizmkit_runtime.heartbeat as heartbeat
2470
+ from prizmkit_runtime.heartbeat import HeartbeatConfig, observe_heartbeat_state
2471
+
2472
+ progress = tmp_path / "progress.json"
2473
+ progress.write_text(json.dumps({"current_tool": current_tool}), encoding="utf-8")
2474
+ config = HeartbeatConfig(
2475
+ heartbeat_file=tmp_path / "heartbeat.json",
2476
+ progress_file=progress,
2477
+ stale_after_seconds=60,
2478
+ )
2479
+ ticks = iter([1000.0, 1070.0])
2480
+ monkeypatch.setattr(heartbeat.time, "time", lambda: next(ticks))
2481
+
2482
+ first = observe_heartbeat_state(config)
2483
+ stale = observe_heartbeat_state(config, first)
2484
+
2485
+ assert stale.details == "no progress"
2486
+ assert stale.stale is True
2487
+ assert stale.stale_seconds == 70.0
2488
+
2422
2489
 
2423
2490
  def test_heartbeat_state_tracks_progress_json_child_activity(tmp_path, monkeypatch):
2424
2491
  import prizmkit_runtime.heartbeat as heartbeat
@@ -2495,6 +2562,104 @@ def test_heartbeat_monitor_stale_kill_uses_elapsed_no_progress_threshold(tmp_pat
2495
2562
  assert marker_data["stale_seconds"] >= 0
2496
2563
 
2497
2564
 
2565
+ def test_heartbeat_monitor_active_tool_defers_stale_kill_until_tool_clears(tmp_path):
2566
+ from prizmkit_runtime.heartbeat import HeartbeatConfig, HeartbeatMonitor
2567
+ from prizmkit_runtime.processes import ProcessSpec, is_process_running, launch_process
2568
+
2569
+ sleeper = tmp_path / "active_tool_sleep.py"
2570
+ sleeper.write_text("import time\ntime.sleep(5)\n", encoding="utf-8")
2571
+ progress = tmp_path / "progress.json"
2572
+ progress.write_text('{"current_tool":"bash"}\n', encoding="utf-8")
2573
+ handle = launch_process(ProcessSpec(command=(sys.executable, str(sleeper)), cwd=tmp_path))
2574
+ monitor = HeartbeatMonitor(
2575
+ handle,
2576
+ HeartbeatConfig(
2577
+ heartbeat_file=tmp_path / "heartbeat.json",
2578
+ session_log=tmp_path / "session.log",
2579
+ progress_file=progress,
2580
+ marker_dir=tmp_path,
2581
+ interval_seconds=0.05,
2582
+ stale_after_seconds=0.1,
2583
+ kill_after_seconds=0.2,
2584
+ grace_seconds=0.1,
2585
+ ),
2586
+ )
2587
+ monitor.start()
2588
+
2589
+ time.sleep(0.5)
2590
+ assert is_process_running(handle)
2591
+ assert not (tmp_path / "stale-kill.json").exists()
2592
+
2593
+ progress.write_text('{"current_tool":null}\n', encoding="utf-8")
2594
+ deadline = time.time() + 3
2595
+ while time.time() < deadline and not (tmp_path / "stale-kill.json").exists():
2596
+ time.sleep(0.05)
2597
+ result = monitor.stop()
2598
+
2599
+ marker = json.loads((tmp_path / "stale-kill.json").read_text(encoding="utf-8"))
2600
+ assert result.killed is True
2601
+ assert result.reason == "stale_session"
2602
+ assert marker["reason"] == "stale_session"
2603
+
2604
+
2605
+ def test_pi_session_active_tool_stream_survives_stale_threshold(tmp_path, monkeypatch):
2606
+ import prizmkit_runtime.sessions as sessions
2607
+ from prizmkit_runtime.sessions import AISessionConfig, AISessionLauncher
2608
+
2609
+ parser_classes = sessions._load_stream_parser_classes()
2610
+ monkeypatch.setattr(sessions, "_load_stream_parser_classes", lambda: parser_classes)
2611
+
2612
+ fake_cli = tmp_path / "pi_active_tool_cli.py"
2613
+ fake_cli.write_text(
2614
+ "#!/usr/bin/env python3\n"
2615
+ "import json, sys, time\n"
2616
+ "_prompt = sys.stdin.read(5)\n"
2617
+ "for event in [\n"
2618
+ " {'type':'session','id':'active-tool-session'},\n"
2619
+ " {'type':'agent_start'},\n"
2620
+ " {'type':'turn_start'},\n"
2621
+ " {'type':'tool_execution_start','toolCallId':'call-1','toolName':'long-running-tool','args':{}},\n"
2622
+ "]:\n"
2623
+ " print(json.dumps(event), flush=True)\n"
2624
+ "time.sleep(3.0)\n"
2625
+ "for event in [\n"
2626
+ " {'type':'tool_execution_end','toolCallId':'call-1','toolName':'long-running-tool','result':{'content':[]},'isError':False},\n"
2627
+ " {'type':'message_end','message':{'role':'assistant','content':[{'type':'text','text':'done'}],'stopReason':'stop'}},\n"
2628
+ " {'type':'agent_end','willRetry':False,'messages':[{'role':'assistant','content':[{'type':'text','text':'done'}],'stopReason':'stop'}]},\n"
2629
+ "]:\n"
2630
+ " print(json.dumps(event), flush=True)\n",
2631
+ encoding="utf-8",
2632
+ )
2633
+ fake_cli.chmod(0o755)
2634
+ prompt = tmp_path / "prompt.md"
2635
+ prompt.write_text("hello", encoding="utf-8")
2636
+ session_log = tmp_path / "state" / "sessions" / "active-tool" / "logs" / "session.log"
2637
+ progress = session_log.with_name("progress.json")
2638
+
2639
+ result = AISessionLauncher(
2640
+ AISessionConfig(
2641
+ cli_command=str(fake_cli),
2642
+ platform="pi",
2643
+ model=None,
2644
+ prompt_path=prompt,
2645
+ cwd=tmp_path,
2646
+ log_path=session_log,
2647
+ progress_path=progress,
2648
+ heartbeat_path=session_log.with_name("heartbeat.json"),
2649
+ stale_kill_threshold_seconds=1.5,
2650
+ heartbeat_stale_threshold_seconds=1.0,
2651
+ stale_kill_grace_seconds=0.1,
2652
+ live_progress_interval_seconds=0.05,
2653
+ )
2654
+ ).run()
2655
+
2656
+ final_progress = json.loads(progress.read_text(encoding="utf-8"))
2657
+ assert result.exit_code == 0
2658
+ assert result.termination_reason is None
2659
+ assert final_progress["current_tool"] is None
2660
+ assert not (session_log.parent / "stale-kill.json").exists()
2661
+
2662
+
2498
2663
  def test_stale_marker_preserves_subsecond_threshold(tmp_path):
2499
2664
  from prizmkit_runtime.heartbeat import write_stale_marker
2500
2665
 
@@ -2913,7 +3078,10 @@ def test_heartbeat_fatal_error_writes_shell_compatible_markers(tmp_path):
2913
3078
  sleeper = tmp_path / "fatal_sleep.py"
2914
3079
  sleeper.write_text("import time\ntime.sleep(5)\n", encoding="utf-8")
2915
3080
  progress = tmp_path / "progress.json"
2916
- progress.write_text('{"fatal_error_code":"context_overflow"}\n', encoding="utf-8")
3081
+ progress.write_text(
3082
+ '{"current_tool":"bash","fatal_error_code":"context_overflow"}\n',
3083
+ encoding="utf-8",
3084
+ )
2917
3085
  handle = launch_process(ProcessSpec(command=(sys.executable, str(sleeper)), cwd=tmp_path))
2918
3086
  monitor = HeartbeatMonitor(
2919
3087
  handle,