specfuse-loop 0.3.15__tar.gz → 0.3.16__tar.gz

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 (162) hide show
  1. {specfuse_loop-0.3.15/specfuse_loop.egg-info → specfuse_loop-0.3.16}/PKG-INFO +1 -1
  2. {specfuse_loop-0.3.15 → specfuse_loop-0.3.16}/pyproject.toml +1 -1
  3. specfuse_loop-0.3.16/specfuse/loop/data/VERSION +1 -0
  4. {specfuse_loop-0.3.15 → specfuse_loop-0.3.16}/specfuse/loop/loop.py +80 -12
  5. {specfuse_loop-0.3.15 → specfuse_loop-0.3.16/specfuse_loop.egg-info}/PKG-INFO +1 -1
  6. specfuse_loop-0.3.16/tests/test_loop_reset_preserving_events.py +294 -0
  7. specfuse_loop-0.3.15/specfuse/loop/data/VERSION +0 -1
  8. specfuse_loop-0.3.15/tests/test_loop_reset_preserving_events.py +0 -142
  9. {specfuse_loop-0.3.15 → specfuse_loop-0.3.16}/LICENSE +0 -0
  10. {specfuse_loop-0.3.15 → specfuse_loop-0.3.16}/NOTICE +0 -0
  11. {specfuse_loop-0.3.15 → specfuse_loop-0.3.16}/README.md +0 -0
  12. {specfuse_loop-0.3.15 → specfuse_loop-0.3.16}/setup.cfg +0 -0
  13. {specfuse_loop-0.3.15 → specfuse_loop-0.3.16}/specfuse/loop/__init__.py +0 -0
  14. {specfuse_loop-0.3.15 → specfuse_loop-0.3.16}/specfuse/loop/_filelock.py +0 -0
  15. {specfuse_loop-0.3.15 → specfuse_loop-0.3.16}/specfuse/loop/_miniyaml.py +0 -0
  16. {specfuse_loop-0.3.15 → specfuse_loop-0.3.16}/specfuse/loop/adopt_feature.py +0 -0
  17. {specfuse_loop-0.3.15 → specfuse_loop-0.3.16}/specfuse/loop/data/LEARNINGS.template.md +0 -0
  18. {specfuse_loop-0.3.15 → specfuse_loop-0.3.16}/specfuse/loop/data/docs/concepts/architecture-addendum-gates-and-iterative-planning.md +0 -0
  19. {specfuse_loop-0.3.15 → specfuse_loop-0.3.16}/specfuse/loop/data/docs/concepts/ralph-lineage.md +0 -0
  20. {specfuse_loop-0.3.15 → specfuse_loop-0.3.16}/specfuse/loop/data/docs/getting-started.md +0 -0
  21. {specfuse_loop-0.3.15 → specfuse_loop-0.3.16}/specfuse/loop/data/docs/methodology.md +0 -0
  22. {specfuse_loop-0.3.15 → specfuse_loop-0.3.16}/specfuse/loop/data/docs/skills.md +0 -0
  23. {specfuse_loop-0.3.15 → specfuse_loop-0.3.16}/specfuse/loop/data/gitignore.snippet +0 -0
  24. {specfuse_loop-0.3.15 → specfuse_loop-0.3.16}/specfuse/loop/data/roadmap.template.md +0 -0
  25. {specfuse_loop-0.3.15 → specfuse_loop-0.3.16}/specfuse/loop/data/rules/correlation-ids.md +0 -0
  26. {specfuse_loop-0.3.15 → specfuse_loop-0.3.16}/specfuse/loop/data/rules/never-touch.md +0 -0
  27. {specfuse_loop-0.3.15 → specfuse_loop-0.3.16}/specfuse/loop/data/rules/result-contract.md +0 -0
  28. {specfuse_loop-0.3.15 → specfuse_loop-0.3.16}/specfuse/loop/data/rules/security-boundaries.md +0 -0
  29. {specfuse_loop-0.3.15 → specfuse_loop-0.3.16}/specfuse/loop/data/rules/verification-discipline.md +0 -0
  30. {specfuse_loop-0.3.15 → specfuse_loop-0.3.16}/specfuse/loop/data/schemas/event.schema.json +0 -0
  31. {specfuse_loop-0.3.15 → specfuse_loop-0.3.16}/specfuse/loop/data/schemas/events/initiative_created.schema.json +0 -0
  32. {specfuse_loop-0.3.15 → specfuse_loop-0.3.16}/specfuse/loop/data/schemas/events/spec_issue_resolved.schema.json +0 -0
  33. {specfuse_loop-0.3.15 → specfuse_loop-0.3.16}/specfuse/loop/data/schemas/events/spec_issue_routed.schema.json +0 -0
  34. {specfuse_loop-0.3.15 → specfuse_loop-0.3.16}/specfuse/loop/data/schemas/events/spec_validated.schema.json +0 -0
  35. {specfuse_loop-0.3.15 → specfuse_loop-0.3.16}/specfuse/loop/data/templates/GATE.template.md +0 -0
  36. {specfuse_loop-0.3.15 → specfuse_loop-0.3.16}/specfuse/loop/data/templates/PLAN.template.md +0 -0
  37. {specfuse_loop-0.3.15 → specfuse_loop-0.3.16}/specfuse/loop/data/templates/WU.template.md +0 -0
  38. {specfuse_loop-0.3.15 → specfuse_loop-0.3.16}/specfuse/loop/data/verification.yml.example +0 -0
  39. {specfuse_loop-0.3.15 → specfuse_loop-0.3.16}/specfuse/loop/gate_eval.py +0 -0
  40. {specfuse_loop-0.3.15 → specfuse_loop-0.3.16}/specfuse/loop/gh_backend.py +0 -0
  41. {specfuse_loop-0.3.15 → specfuse_loop-0.3.16}/specfuse/loop/gh_features.py +0 -0
  42. {specfuse_loop-0.3.15 → specfuse_loop-0.3.16}/specfuse/loop/lint_plan.py +0 -0
  43. {specfuse_loop-0.3.15 → specfuse_loop-0.3.16}/specfuse/loop/scaffold.py +0 -0
  44. {specfuse_loop-0.3.15 → specfuse_loop-0.3.16}/specfuse/loop/validate_event.py +0 -0
  45. {specfuse_loop-0.3.15 → specfuse_loop-0.3.16}/specfuse_loop.egg-info/SOURCES.txt +0 -0
  46. {specfuse_loop-0.3.15 → specfuse_loop-0.3.16}/specfuse_loop.egg-info/dependency_links.txt +0 -0
  47. {specfuse_loop-0.3.15 → specfuse_loop-0.3.16}/specfuse_loop.egg-info/entry_points.txt +0 -0
  48. {specfuse_loop-0.3.15 → specfuse_loop-0.3.16}/specfuse_loop.egg-info/requires.txt +0 -0
  49. {specfuse_loop-0.3.15 → specfuse_loop-0.3.16}/specfuse_loop.egg-info/top_level.txt +0 -0
  50. {specfuse_loop-0.3.15 → specfuse_loop-0.3.16}/tests/test_adopt_feature.py +0 -0
  51. {specfuse_loop-0.3.15 → specfuse_loop-0.3.16}/tests/test_arm_gate_edits_uncommitted.py +0 -0
  52. {specfuse_loop-0.3.15 → specfuse_loop-0.3.16}/tests/test_attempt_outcome_emission.py +0 -0
  53. {specfuse_loop-0.3.15 → specfuse_loop-0.3.16}/tests/test_autoclose_deferral_visibility.py +0 -0
  54. {specfuse_loop-0.3.15 → specfuse_loop-0.3.16}/tests/test_autosync.py +0 -0
  55. {specfuse_loop-0.3.15 → specfuse_loop-0.3.16}/tests/test_autosync_consent.py +0 -0
  56. {specfuse_loop-0.3.15 → specfuse_loop-0.3.16}/tests/test_autosync_firstrun.py +0 -0
  57. {specfuse_loop-0.3.15 → specfuse_loop-0.3.16}/tests/test_autosync_plugin.py +0 -0
  58. {specfuse_loop-0.3.15 → specfuse_loop-0.3.16}/tests/test_backend.py +0 -0
  59. {specfuse_loop-0.3.15 → specfuse_loop-0.3.16}/tests/test_bash_routing.py +0 -0
  60. {specfuse_loop-0.3.15 → specfuse_loop-0.3.16}/tests/test_bookkeeping_commit_crash_run.py +0 -0
  61. {specfuse_loop-0.3.15 → specfuse_loop-0.3.16}/tests/test_bookkeeping_commit_hook_crash.py +0 -0
  62. {specfuse_loop-0.3.15 → specfuse_loop-0.3.16}/tests/test_bookkeeping_no_verify.py +0 -0
  63. {specfuse_loop-0.3.15 → specfuse_loop-0.3.16}/tests/test_claude_resolution.py +0 -0
  64. {specfuse_loop-0.3.15 → specfuse_loop-0.3.16}/tests/test_close_wu_self_reference.py +0 -0
  65. {specfuse_loop-0.3.15 → specfuse_loop-0.3.16}/tests/test_closing_deliverable_guard.py +0 -0
  66. {specfuse_loop-0.3.15 → specfuse_loop-0.3.16}/tests/test_console_encoding.py +0 -0
  67. {specfuse_loop-0.3.15 → specfuse_loop-0.3.16}/tests/test_cost_tracking.py +0 -0
  68. {specfuse_loop-0.3.15 → specfuse_loop-0.3.16}/tests/test_degraded_oracle.py +0 -0
  69. {specfuse_loop-0.3.15 → specfuse_loop-0.3.16}/tests/test_deliverable_presence_gate.py +0 -0
  70. {specfuse_loop-0.3.15 → specfuse_loop-0.3.16}/tests/test_doctor.py +0 -0
  71. {specfuse_loop-0.3.15 → specfuse_loop-0.3.16}/tests/test_driver_integration.py +0 -0
  72. {specfuse_loop-0.3.15 → specfuse_loop-0.3.16}/tests/test_driver_lock.py +0 -0
  73. {specfuse_loop-0.3.15 → specfuse_loop-0.3.16}/tests/test_duration_tracking.py +0 -0
  74. {specfuse_loop-0.3.15 → specfuse_loop-0.3.16}/tests/test_empty_files_escalation.py +0 -0
  75. {specfuse_loop-0.3.15 → specfuse_loop-0.3.16}/tests/test_ensure_feature_branch.py +0 -0
  76. {specfuse_loop-0.3.15 → specfuse_loop-0.3.16}/tests/test_ensure_feature_branch_base.py +0 -0
  77. {specfuse_loop-0.3.15 → specfuse_loop-0.3.16}/tests/test_events_redaction.py +0 -0
  78. {specfuse_loop-0.3.15 → specfuse_loop-0.3.16}/tests/test_extra_gates.py +0 -0
  79. {specfuse_loop-0.3.15 → specfuse_loop-0.3.16}/tests/test_filelock_portable.py +0 -0
  80. {specfuse_loop-0.3.15 → specfuse_loop-0.3.16}/tests/test_force_full_close.py +0 -0
  81. {specfuse_loop-0.3.15 → specfuse_loop-0.3.16}/tests/test_gate_eval.py +0 -0
  82. {specfuse_loop-0.3.15 → specfuse_loop-0.3.16}/tests/test_gate_eval_calibration.py +0 -0
  83. {specfuse_loop-0.3.15 → specfuse_loop-0.3.16}/tests/test_gate_eval_intermediate_wiring.py +0 -0
  84. {specfuse_loop-0.3.15 → specfuse_loop-0.3.16}/tests/test_gate_eval_terminal_wiring.py +0 -0
  85. {specfuse_loop-0.3.15 → specfuse_loop-0.3.16}/tests/test_gh_backend.py +0 -0
  86. {specfuse_loop-0.3.15 → specfuse_loop-0.3.16}/tests/test_gh_features.py +0 -0
  87. {specfuse_loop-0.3.15 → specfuse_loop-0.3.16}/tests/test_git_env_isolation.py +0 -0
  88. {specfuse_loop-0.3.15 → specfuse_loop-0.3.16}/tests/test_hashed_denylist.py +0 -0
  89. {specfuse_loop-0.3.15 → specfuse_loop-0.3.16}/tests/test_hashed_denylist_ci.py +0 -0
  90. {specfuse_loop-0.3.15 → specfuse_loop-0.3.16}/tests/test_init_integration.py +0 -0
  91. {specfuse_loop-0.3.15 → specfuse_loop-0.3.16}/tests/test_interpreter_normalization.py +0 -0
  92. {specfuse_loop-0.3.15 → specfuse_loop-0.3.16}/tests/test_leak_findings_redaction.py +0 -0
  93. {specfuse_loop-0.3.15 → specfuse_loop-0.3.16}/tests/test_leak_scan.py +0 -0
  94. {specfuse_loop-0.3.15 → specfuse_loop-0.3.16}/tests/test_leak_scan_content.py +0 -0
  95. {specfuse_loop-0.3.15 → specfuse_loop-0.3.16}/tests/test_learnings_query.py +0 -0
  96. {specfuse_loop-0.3.15 → specfuse_loop-0.3.16}/tests/test_legacy_4wu_terminal_flips.py +0 -0
  97. {specfuse_loop-0.3.15 → specfuse_loop-0.3.16}/tests/test_lifecycle_integration.py +0 -0
  98. {specfuse_loop-0.3.15 → specfuse_loop-0.3.16}/tests/test_lint_bare_produces_path.py +0 -0
  99. {specfuse_loop-0.3.15 → specfuse_loop-0.3.16}/tests/test_lint_close_intermediate.py +0 -0
  100. {specfuse_loop-0.3.15 → specfuse_loop-0.3.16}/tests/test_lint_close_wu.py +0 -0
  101. {specfuse_loop-0.3.15 → specfuse_loop-0.3.16}/tests/test_lint_correlation_id.py +0 -0
  102. {specfuse_loop-0.3.15 → specfuse_loop-0.3.16}/tests/test_lint_correlation_id_close_intermediate.py +0 -0
  103. {specfuse_loop-0.3.15 → specfuse_loop-0.3.16}/tests/test_lint_oracle_env.py +0 -0
  104. {specfuse_loop-0.3.15 → specfuse_loop-0.3.16}/tests/test_lint_plan_errors.py +0 -0
  105. {specfuse_loop-0.3.15 → specfuse_loop-0.3.16}/tests/test_lint_plan_next_draft.py +0 -0
  106. {specfuse_loop-0.3.15 → specfuse_loop-0.3.16}/tests/test_lint_plan_relative_import.py +0 -0
  107. {specfuse_loop-0.3.15 → specfuse_loop-0.3.16}/tests/test_lint_produces_driver_helper.py +0 -0
  108. {specfuse_loop-0.3.15 → specfuse_loop-0.3.16}/tests/test_lint_sections.py +0 -0
  109. {specfuse_loop-0.3.15 → specfuse_loop-0.3.16}/tests/test_lint_task_graph_yaml_selection.py +0 -0
  110. {specfuse_loop-0.3.15 → specfuse_loop-0.3.16}/tests/test_loop_auto_archive.py +0 -0
  111. {specfuse_loop-0.3.15 → specfuse_loop-0.3.16}/tests/test_loop_caveman_preamble.py +0 -0
  112. {specfuse_loop-0.3.15 → specfuse_loop-0.3.16}/tests/test_loop_close_intermediate.py +0 -0
  113. {specfuse_loop-0.3.15 → specfuse_loop-0.3.16}/tests/test_loop_defaults_by_type.py +0 -0
  114. {specfuse_loop-0.3.15 → specfuse_loop-0.3.16}/tests/test_loop_effort.py +0 -0
  115. {specfuse_loop-0.3.15 → specfuse_loop-0.3.16}/tests/test_loop_failure_note_cap.py +0 -0
  116. {specfuse_loop-0.3.15 → specfuse_loop-0.3.16}/tests/test_loop_files_changed_guard.py +0 -0
  117. {specfuse_loop-0.3.15 → specfuse_loop-0.3.16}/tests/test_loop_gate_budget.py +0 -0
  118. {specfuse_loop-0.3.15 → specfuse_loop-0.3.16}/tests/test_loop_model_alias.py +0 -0
  119. {specfuse_loop-0.3.15 → specfuse_loop-0.3.16}/tests/test_loop_orchestration.py +0 -0
  120. {specfuse_loop-0.3.15 → specfuse_loop-0.3.16}/tests/test_loop_post_pass_invariant.py +0 -0
  121. {specfuse_loop-0.3.15 → specfuse_loop-0.3.16}/tests/test_loop_smoke_runner.py +0 -0
  122. {specfuse_loop-0.3.15 → specfuse_loop-0.3.16}/tests/test_loop_unsandboxed.py +0 -0
  123. {specfuse_loop-0.3.15 → specfuse_loop-0.3.16}/tests/test_loop_zero_token_guard.py +0 -0
  124. {specfuse_loop-0.3.15 → specfuse_loop-0.3.16}/tests/test_migrate_legacy.py +0 -0
  125. {specfuse_loop-0.3.15 → specfuse_loop-0.3.16}/tests/test_miniyaml_equivalence.py +0 -0
  126. {specfuse_loop-0.3.15 → specfuse_loop-0.3.16}/tests/test_miniyaml_negative.py +0 -0
  127. {specfuse_loop-0.3.15 → specfuse_loop-0.3.16}/tests/test_planned_cost_lint.py +0 -0
  128. {specfuse_loop-0.3.15 → specfuse_loop-0.3.16}/tests/test_prepare_feature.py +0 -0
  129. {specfuse_loop-0.3.15 → specfuse_loop-0.3.16}/tests/test_prepare_scaffold_sync.py +0 -0
  130. {specfuse_loop-0.3.15 → specfuse_loop-0.3.16}/tests/test_produces_field.py +0 -0
  131. {specfuse_loop-0.3.15 → specfuse_loop-0.3.16}/tests/test_redaction_windows_home.py +0 -0
  132. {specfuse_loop-0.3.15 → specfuse_loop-0.3.16}/tests/test_resolve_base.py +0 -0
  133. {specfuse_loop-0.3.15 → specfuse_loop-0.3.16}/tests/test_result_block.py +0 -0
  134. {specfuse_loop-0.3.15 → specfuse_loop-0.3.16}/tests/test_roadmap_add_skill.py +0 -0
  135. {specfuse_loop-0.3.15 → specfuse_loop-0.3.16}/tests/test_roadmap_archive_skill.py +0 -0
  136. {specfuse_loop-0.3.15 → specfuse_loop-0.3.16}/tests/test_roadmap_row_parser.py +0 -0
  137. {specfuse_loop-0.3.15 → specfuse_loop-0.3.16}/tests/test_scaffold_data_in_sync.py +0 -0
  138. {specfuse_loop-0.3.15 → specfuse_loop-0.3.16}/tests/test_scaffold_doc_hygiene.py +0 -0
  139. {specfuse_loop-0.3.15 → specfuse_loop-0.3.16}/tests/test_scaffold_docs.py +0 -0
  140. {specfuse_loop-0.3.15 → specfuse_loop-0.3.16}/tests/test_scaffold_init.py +0 -0
  141. {specfuse_loop-0.3.15 → specfuse_loop-0.3.16}/tests/test_scaffold_manifest.py +0 -0
  142. {specfuse_loop-0.3.15 → specfuse_loop-0.3.16}/tests/test_scaffold_resources.py +0 -0
  143. {specfuse_loop-0.3.15 → specfuse_loop-0.3.16}/tests/test_scaffold_seed_sanity.py +0 -0
  144. {specfuse_loop-0.3.15 → specfuse_loop-0.3.16}/tests/test_scaffold_upgrade.py +0 -0
  145. {specfuse_loop-0.3.15 → specfuse_loop-0.3.16}/tests/test_scaffold_wiring.py +0 -0
  146. {specfuse_loop-0.3.15 → specfuse_loop-0.3.16}/tests/test_skills_vendored_in_sync.py +0 -0
  147. {specfuse_loop-0.3.15 → specfuse_loop-0.3.16}/tests/test_squash_commit_hook_crash.py +0 -0
  148. {specfuse_loop-0.3.15 → specfuse_loop-0.3.16}/tests/test_template_closing_shapes.py +0 -0
  149. {specfuse_loop-0.3.15 → specfuse_loop-0.3.16}/tests/test_terminal_flip_ownership.py +0 -0
  150. {specfuse_loop-0.3.15 → specfuse_loop-0.3.16}/tests/test_terminal_flips.py +0 -0
  151. {specfuse_loop-0.3.15 → specfuse_loop-0.3.16}/tests/test_timeout_kill_portable.py +0 -0
  152. {specfuse_loop-0.3.15 → specfuse_loop-0.3.16}/tests/test_untracked_feature_folder.py +0 -0
  153. {specfuse_loop-0.3.15 → specfuse_loop-0.3.16}/tests/test_untracked_file_absorption.py +0 -0
  154. {specfuse_loop-0.3.15 → specfuse_loop-0.3.16}/tests/test_upgrade_integration.py +0 -0
  155. {specfuse_loop-0.3.15 → specfuse_loop-0.3.16}/tests/test_upgrade_merge_gate.py +0 -0
  156. {specfuse_loop-0.3.15 → specfuse_loop-0.3.16}/tests/test_validate_event.py +0 -0
  157. {specfuse_loop-0.3.15 → specfuse_loop-0.3.16}/tests/test_verdict_coupling.py +0 -0
  158. {specfuse_loop-0.3.15 → specfuse_loop-0.3.16}/tests/test_verify_empty_gate_set.py +0 -0
  159. {specfuse_loop-0.3.15 → specfuse_loop-0.3.16}/tests/test_version_consistency.py +0 -0
  160. {specfuse_loop-0.3.15 → specfuse_loop-0.3.16}/tests/test_version_skew.py +0 -0
  161. {specfuse_loop-0.3.15 → specfuse_loop-0.3.16}/tests/test_windows_gate_exec.py +0 -0
  162. {specfuse_loop-0.3.15 → specfuse_loop-0.3.16}/tests/test_wu_execution_metadata.py +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: specfuse-loop
3
- Version: 0.3.15
3
+ Version: 0.3.16
4
4
  Summary: Local-first executor for the Specfuse Plan + Work Unit gate-cycle methodology.
5
5
  Author: Specfuse contributors
6
6
  License: Apache-2.0
@@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta"
4
4
 
5
5
  [project]
6
6
  name = "specfuse-loop"
7
- version = "0.3.15"
7
+ version = "0.3.16"
8
8
  description = "Local-first executor for the Specfuse Plan + Work Unit gate-cycle methodology."
9
9
  readme = "README.md"
10
10
  requires-python = ">=3.10"
@@ -0,0 +1 @@
1
+ 0.3.16
@@ -63,7 +63,7 @@ SPECFUSE_DIR = Path(".specfuse")
63
63
  REPO_ROOT = SPECFUSE_DIR.parent
64
64
  FEATURES_DIR = SPECFUSE_DIR / "features"
65
65
  VERIFICATION_PATH = SPECFUSE_DIR / "verification.yml"
66
- DRIVER_VERSION = "0.3.15"
66
+ DRIVER_VERSION = "0.3.16"
67
67
  # Oldest scaffold layout this driver can drive. init.sh stamps the scaffold's own
68
68
  # version into `.specfuse/VERSION`; check_scaffold_version() fails loud at startup if
69
69
  # the consumer's scaffold is older than this, pointing at `specfuse upgrade`. Bump
@@ -1410,7 +1410,49 @@ def commit_bookkeeping(paths: list, message: str) -> str | None:
1410
1410
  return git("rev-parse", "HEAD")
1411
1411
 
1412
1412
 
1413
- def reset_preserving_events(head_before: str, events_path: Path) -> None:
1413
+ def _clean_attempt_untracked(
1414
+ untracked_before: "set[str] | frozenset[str]", events_path: Path,
1415
+ ) -> None:
1416
+ """Delete untracked files that appeared since the *untracked_before* snapshot.
1417
+
1418
+ Helper for `reset_preserving_events`; see its docstring for why. Deletes
1419
+ FILES only — empty directories are left behind, which is harmless (git
1420
+ does not track directories) and avoids racing the driver's own mid-run
1421
+ scratch dirs.
1422
+
1423
+ Never deletes: paths in *untracked_before* (the operator's, per #150),
1424
+ *events_path* (driver-managed, and untracked on a fresh branch), or
1425
+ gitignored paths (`untracked_paths` honors .gitignore, so a feature's
1426
+ `work/` scratch dir never enters the delete set).
1427
+
1428
+ Best-effort: any git or filesystem error is swallowed. A failure to clean
1429
+ reverts to the old leftover behavior, which must not crash the run.
1430
+ """
1431
+ try:
1432
+ root = Path(git("rev-parse", "--show-toplevel"))
1433
+ candidates = untracked_paths() - set(untracked_before)
1434
+ except (subprocess.CalledProcessError, OSError):
1435
+ return
1436
+ try:
1437
+ keep = events_path.resolve()
1438
+ except OSError:
1439
+ keep = None
1440
+ for rel in sorted(candidates):
1441
+ target = root / rel
1442
+ try:
1443
+ if keep is not None and target.resolve() == keep:
1444
+ continue
1445
+ if target.is_file() or target.is_symlink():
1446
+ target.unlink()
1447
+ except OSError:
1448
+ continue
1449
+
1450
+
1451
+ def reset_preserving_events(
1452
+ head_before: str,
1453
+ events_path: Path,
1454
+ untracked_before: "set[str] | frozenset[str] | None" = None,
1455
+ ) -> None:
1414
1456
  """`git reset --hard <head_before>` without losing events.jsonl content.
1415
1457
 
1416
1458
  The hard-reset is the methodology's "wipe agent's edits before we write our
@@ -1433,11 +1475,27 @@ def reset_preserving_events(head_before: str, events_path: Path) -> None:
1433
1475
 
1434
1476
  Subsequent `flush_events` calls then append to the preserved content;
1435
1477
  `commit_bookkeeping` captures the full history.
1478
+
1479
+ `untracked_before` (#162) is the per-WU untracked snapshot taken at
1480
+ dispatch. When supplied, untracked files that appeared SINCE it are
1481
+ deleted after the reset. A hard reset rolls back tracked content but
1482
+ leaves untracked files on disk, and the snapshot is taken once per WU
1483
+ (outside the attempt loop) — so without this, attempt 1's new files
1484
+ survive into attempt 2, where they are indistinguishable from attempt 2's
1485
+ own work: `verify_files_changed` passes them and `squash_commit` commits
1486
+ them, letting an attempt pass on its predecessor's leftovers.
1487
+
1488
+ Absence is inert BY DESIGN. An empty set would read as "the operator had
1489
+ nothing untracked" and delete the entire untracked working tree; `None`
1490
+ means "no snapshot available, do not clean" and preserves the pre-#162
1491
+ behavior for callers that cannot supply one.
1436
1492
  """
1437
1493
  saved = events_path.read_text() if events_path.is_file() else None
1438
1494
  git("reset", "--hard", head_before)
1439
1495
  if saved is not None:
1440
1496
  events_path.write_text(saved)
1497
+ if untracked_before is not None:
1498
+ _clean_attempt_untracked(untracked_before, events_path)
1441
1499
 
1442
1500
 
1443
1501
  class SquashCommitError(RuntimeError):
@@ -3801,7 +3859,8 @@ def run(
3801
3859
  wu, attempt, "zero_token_skip",
3802
3860
  attempts_usage[-1],
3803
3861
  ))
3804
- reset_preserving_events(head_before, events_path)
3862
+ reset_preserving_events(head_before, events_path,
3863
+ untracked_before=untracked_before)
3805
3864
  print(f" ZERO-TOKEN attempt {attempt}/{MAX_ATTEMPTS} "
3806
3865
  f"— no agent output, skipping")
3807
3866
  continue
@@ -3812,7 +3871,8 @@ def run(
3812
3871
  # reset wipe the flip — the silent-state-loss bug.
3813
3872
  # Use reset_preserving_events to keep prior WU's
3814
3873
  # flushed-but-uncommitted events.jsonl entries.
3815
- reset_preserving_events(head_before, events_path)
3874
+ reset_preserving_events(head_before, events_path,
3875
+ untracked_before=untracked_before)
3816
3876
  backend.set_wu(wu, "status", "blocked_human")
3817
3877
  write_cost_to_wu(backend, wu, cum_usage)
3818
3878
  wu_events.append(emit_attempt_outcome(
@@ -3856,7 +3916,8 @@ def run(
3856
3916
  # written just above — record the failure with git's
3857
3917
  # stderr, and retry within budget (MAX_ATTEMPTS
3858
3918
  # exhaustion escalates to blocked_human).
3859
- reset_preserving_events(head_before, events_path)
3919
+ reset_preserving_events(head_before, events_path,
3920
+ untracked_before=untracked_before)
3860
3921
  # Redact any quoted leak-scan match before it lands
3861
3922
  # in events.jsonl / the attempt note, so the captured
3862
3923
  # FINDINGS text can't re-trip the next bookkeeping
@@ -3887,7 +3948,8 @@ def run(
3887
3948
  smoke_cmds, Path("."),
3888
3949
  )
3889
3950
  if not smoke_ok:
3890
- reset_preserving_events(head_before, events_path)
3951
+ reset_preserving_events(head_before, events_path,
3952
+ untracked_before=untracked_before)
3891
3953
  wu_events.append(emit_attempt_outcome(
3892
3954
  wu, attempt, "smoke_import_failed",
3893
3955
  attempts_usage[-1],
@@ -3904,7 +3966,8 @@ def run(
3904
3966
  wu, feature_dir, REPO_ROOT, head_before,
3905
3967
  )
3906
3968
  if not closing_ok:
3907
- reset_preserving_events(head_before, events_path)
3969
+ reset_preserving_events(head_before, events_path,
3970
+ untracked_before=untracked_before)
3908
3971
  # `assertion` names the primary (first) failing
3909
3972
  # assertion. With aggregation (#72) closing_summary
3910
3973
  # may list several; the first `assert_*` token is the
@@ -3940,7 +4003,8 @@ def run(
3940
4003
  # every current WU is unchanged.
3941
4004
  deliv_ok, deliv_summary = assert_declared_deliverables(wu)
3942
4005
  if not deliv_ok:
3943
- reset_preserving_events(head_before, events_path)
4006
+ reset_preserving_events(head_before, events_path,
4007
+ untracked_before=untracked_before)
3944
4008
  missing = deliv_summary.split(": ", 1)[-1]
3945
4009
  wu_events.append(emit_attempt_outcome(
3946
4010
  wu, attempt, "deliverable_missing",
@@ -3966,7 +4030,8 @@ def run(
3966
4030
  wu, touched,
3967
4031
  )
3968
4032
  if not impl_ok:
3969
- reset_preserving_events(head_before, events_path)
4033
+ reset_preserving_events(head_before, events_path,
4034
+ untracked_before=untracked_before)
3970
4035
  wu_events.append(emit_attempt_outcome(
3971
4036
  wu, attempt, "no_deliverable_files",
3972
4037
  attempts_usage[-1],
@@ -4068,7 +4133,8 @@ def run(
4068
4133
  ))
4069
4134
  attempt_notes.append((attempt, note))
4070
4135
  failure_note = note
4071
- reset_preserving_events(head_before, events_path)
4136
+ reset_preserving_events(head_before, events_path,
4137
+ untracked_before=untracked_before)
4072
4138
  print(f" FILES_CHANGED MISMATCH attempt "
4073
4139
  f"{attempt}/{MAX_ATTEMPTS} — {len(payload)} path(s) "
4074
4140
  f"unchanged")
@@ -4101,7 +4167,8 @@ def run(
4101
4167
  "attempts": attempt,
4102
4168
  "attempts_usage": attempts_usage,
4103
4169
  }))
4104
- reset_preserving_events(head_before, events_path)
4170
+ reset_preserving_events(head_before, events_path,
4171
+ untracked_before=untracked_before)
4105
4172
  backend.set_wu(wu, "status", "blocked_human")
4106
4173
  write_cost_to_wu(backend, wu, cum_usage)
4107
4174
  flush_events(events_path, wu_events)
@@ -4119,7 +4186,8 @@ def run(
4119
4186
  prior_failure_signature = (_fc, _fs)
4120
4187
  flush_events(events_path, wu_events)
4121
4188
  wu_events.clear()
4122
- reset_preserving_events(head_before, events_path)
4189
+ reset_preserving_events(head_before, events_path,
4190
+ untracked_before=untracked_before)
4123
4191
  print(f" FAIL attempt {attempt}/{MAX_ATTEMPTS}")
4124
4192
  else:
4125
4193
  # for-else: ran out of attempts without break = spinning.
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: specfuse-loop
3
- Version: 0.3.15
3
+ Version: 0.3.16
4
4
  Summary: Local-first executor for the Specfuse Plan + Work Unit gate-cycle methodology.
5
5
  Author: Specfuse contributors
6
6
  License: Apache-2.0
@@ -0,0 +1,294 @@
1
+ #
2
+ # Copyright 2026 Specfuse contributors
3
+ # Licensed under the Apache License, Version 2.0. See LICENSE.
4
+ #
5
+ """Unit tests for `reset_preserving_events` — FEAT-2026-0015 bug fix.
6
+
7
+ Bug context: `git reset --hard head_before` between WUs rolled events.jsonl
8
+ back to its last-committed state, silently dropping the prior WU's lifecycle
9
+ events that had been flushed-but-not-yet-committed. Surfaced
10
+ FEAT-2026-0015/T02: ran clean, events flushed post-squash, then T03 blocked
11
+ → bare hard-reset wiped T02's events.
12
+
13
+ Fix: helper captures events.jsonl content, performs hard-reset, restores
14
+ content. Subsequent flush_events appends; subsequent commit_bookkeeping
15
+ captures the full history.
16
+ """
17
+
18
+ from __future__ import annotations
19
+
20
+ import os
21
+ import subprocess
22
+ import tempfile
23
+ import unittest
24
+ from contextlib import contextmanager
25
+ from pathlib import Path
26
+
27
+ from tests._loop_loader import load_loop
28
+
29
+ loop = load_loop()
30
+
31
+
32
+ @contextmanager
33
+ def _git_repo_no_sign():
34
+ """Minimal git repo with signing disabled (operator config-independent)."""
35
+ with tempfile.TemporaryDirectory(ignore_cleanup_errors=True) as tmp:
36
+ root = Path(tmp)
37
+ run = lambda *a: subprocess.run( # noqa: E731
38
+ ["git", "-C", str(root), *a], check=True, capture_output=True,
39
+ )
40
+ run("init", "-q", "-b", "main")
41
+ run("config", "commit.gpgSign", "false")
42
+ run("config", "gc.auto", "0")
43
+ run("config", "user.email", "test@example.com")
44
+ run("config", "user.name", "Test")
45
+ (root / "README.md").write_text("# fixture\n")
46
+ run("add", ".")
47
+ run("commit", "-q", "-m", "init")
48
+ # Sync barrier
49
+ run("rev-parse", "HEAD")
50
+ # cwd swap so loop's git() helper (which runs without -C) operates
51
+ # on this tree
52
+ prev_cwd = os.getcwd()
53
+ try:
54
+ os.chdir(root)
55
+ yield root
56
+ finally:
57
+ os.chdir(prev_cwd)
58
+
59
+
60
+ class TestResetPreservingEventsHelper(unittest.TestCase):
61
+ """Direct unit-level tests on reset_preserving_events()."""
62
+
63
+ def test_preserves_flushed_but_uncommitted_events_content(self):
64
+ """events.jsonl modifications since head_before survive the reset."""
65
+ with _git_repo_no_sign() as root:
66
+ # Setup: commit a baseline events.jsonl + README
67
+ events_path = root / "events.jsonl"
68
+ events_path.write_text("# committed baseline\n")
69
+ subprocess.run(
70
+ ["git", "-C", str(root), "add", "events.jsonl"],
71
+ check=True, capture_output=True,
72
+ )
73
+ subprocess.run(
74
+ ["git", "-C", str(root), "commit", "-q", "-m", "baseline"],
75
+ check=True, capture_output=True,
76
+ )
77
+ head_before = loop.git("rev-parse", "HEAD")
78
+
79
+ # Simulate prior-WU events flushed post-squash, not yet committed:
80
+ # events.jsonl on disk has new content vs head_before's commit.
81
+ events_path.write_text(
82
+ "# committed baseline\n"
83
+ '{"correlation_id":"FEAT-PRIOR/T01","event_type":"task_completed"}\n'
84
+ )
85
+ # Simulate an agent's tracked-file edit that reset SHOULD wipe.
86
+ readme = root / "README.md"
87
+ readme.write_text("# AGENT-VANDALIZED\n")
88
+
89
+ loop.reset_preserving_events(head_before, events_path)
90
+
91
+ # Reset reverted the agent's tracked-file edit
92
+ self.assertEqual(readme.read_text(), "# fixture\n")
93
+ # But events.jsonl content survived (NOT rolled back to baseline)
94
+ self.assertIn("FEAT-PRIOR/T01", events_path.read_text())
95
+
96
+ def test_missing_events_file_does_not_crash(self):
97
+ """If events.jsonl doesn't exist, helper still resets cleanly."""
98
+ with _git_repo_no_sign() as root:
99
+ head_before = loop.git("rev-parse", "HEAD")
100
+ readme = root / "README.md"
101
+ readme.write_text("# agent garbage\n")
102
+ events_path = root / "events.jsonl"
103
+ self.assertFalse(events_path.is_file())
104
+
105
+ loop.reset_preserving_events(head_before, events_path)
106
+
107
+ self.assertEqual(readme.read_text(), "# fixture\n")
108
+ # events.jsonl still absent — nothing to preserve
109
+ self.assertFalse(events_path.is_file())
110
+
111
+ def test_preserves_multi_line_events_content(self):
112
+ """Multi-line events.jsonl content fully preserved."""
113
+ with _git_repo_no_sign() as root:
114
+ events_path = root / "events.jsonl"
115
+ events_path.write_text("# baseline\n")
116
+ subprocess.run(
117
+ ["git", "-C", str(root), "add", "events.jsonl"],
118
+ check=True, capture_output=True,
119
+ )
120
+ subprocess.run(
121
+ ["git", "-C", str(root), "commit", "-q", "-m", "baseline"],
122
+ check=True, capture_output=True,
123
+ )
124
+ head_before = loop.git("rev-parse", "HEAD")
125
+
126
+ events_content = (
127
+ "# baseline\n"
128
+ '{"correlation_id":"X/T01","event_type":"task_started"}\n'
129
+ '{"correlation_id":"X/T01","event_type":"task_completed"}\n'
130
+ '{"correlation_id":"X/T02","event_type":"task_started"}\n'
131
+ )
132
+ events_path.write_text(events_content)
133
+ (root / "README.md").write_text("# vandalized\n")
134
+
135
+ loop.reset_preserving_events(head_before, events_path)
136
+
137
+ self.assertEqual((root / "README.md").read_text(), "# fixture\n")
138
+ self.assertEqual(events_path.read_text(), events_content)
139
+
140
+
141
+ class TestResetClearsAttemptUntracked(unittest.TestCase):
142
+ """Issue #162: the reset must clear the failed attempt's untracked output.
143
+
144
+ `git reset --hard` rolls back tracked content but leaves untracked files
145
+ on disk. Because `untracked_before` is snapshotted once per WU (loop.py
146
+ :3709) and the attempt loop runs beneath it (:3769), attempt 1's new
147
+ files survive into attempt 2 and are indistinguishable from attempt 2's
148
+ own work — `verify_files_changed` passes them and `squash_commit` commits
149
+ them, so an attempt can pass on its predecessor's leftovers.
150
+
151
+ The snapshot is the exclusion set: untracked paths present at dispatch
152
+ are the operator's and must survive; anything else appeared during the
153
+ attempt and must go.
154
+ """
155
+
156
+ def _snapshot(self):
157
+ return loop.untracked_paths()
158
+
159
+ def test_untracked_file_created_during_attempt_is_removed(self):
160
+ with _git_repo_no_sign() as root:
161
+ head_before = loop.git("rev-parse", "HEAD")
162
+ before = self._snapshot()
163
+
164
+ # The attempt creates a new deliverable, then fails.
165
+ (root / "new.sh").write_text("#!/bin/sh\necho hi\n")
166
+
167
+ loop.reset_preserving_events(
168
+ head_before, root / "events.jsonl", untracked_before=before,
169
+ )
170
+
171
+ self.assertFalse(
172
+ (root / "new.sh").exists(),
173
+ "attempt's untracked output must not survive into the next "
174
+ "attempt — it is what lets a later attempt hollow-pass",
175
+ )
176
+
177
+ def test_operator_untracked_file_survives(self):
178
+ with _git_repo_no_sign() as root:
179
+ # Operator WIP predates dispatch (#150's concern).
180
+ (root / "scratch.md").write_text("my notes\n")
181
+ head_before = loop.git("rev-parse", "HEAD")
182
+ before = self._snapshot()
183
+ self.assertIn("scratch.md", before)
184
+
185
+ (root / "new.sh").write_text("#!/bin/sh\n")
186
+
187
+ loop.reset_preserving_events(
188
+ head_before, root / "events.jsonl", untracked_before=before,
189
+ )
190
+
191
+ self.assertTrue((root / "scratch.md").exists())
192
+ self.assertEqual((root / "scratch.md").read_text(), "my notes\n")
193
+ self.assertFalse((root / "new.sh").exists())
194
+
195
+ def test_untracked_file_in_subdir_is_removed(self):
196
+ with _git_repo_no_sign() as root:
197
+ head_before = loop.git("rev-parse", "HEAD")
198
+ before = self._snapshot()
199
+
200
+ (root / "scripts").mkdir()
201
+ (root / "scripts" / "check.sh").write_text("#!/bin/sh\n")
202
+
203
+ loop.reset_preserving_events(
204
+ head_before, root / "events.jsonl", untracked_before=before,
205
+ )
206
+
207
+ self.assertFalse((root / "scripts" / "check.sh").exists())
208
+
209
+ def test_events_file_survives_even_when_untracked(self):
210
+ """events.jsonl is driver-managed; the cleanup must never eat it.
211
+
212
+ It can be untracked (first run on a fresh branch) and is by
213
+ definition not in the pre-dispatch snapshot when the driver just
214
+ created it — exactly the shape the cleanup deletes.
215
+ """
216
+ with _git_repo_no_sign() as root:
217
+ head_before = loop.git("rev-parse", "HEAD")
218
+ before = self._snapshot()
219
+
220
+ events_path = root / "events.jsonl"
221
+ events_path.write_text(
222
+ '{"correlation_id":"X/T01","event_type":"task_started"}\n'
223
+ )
224
+
225
+ loop.reset_preserving_events(
226
+ head_before, events_path, untracked_before=before,
227
+ )
228
+
229
+ self.assertTrue(events_path.is_file())
230
+ self.assertIn("X/T01", events_path.read_text())
231
+
232
+ def test_gitignored_file_survives(self):
233
+ """Gitignored paths (e.g. the feature's `work/` dir) are out of scope.
234
+
235
+ `untracked_paths` honors .gitignore, so ignored files never enter the
236
+ delete set. Asserted explicitly because deleting them would destroy
237
+ agent scratch space mid-run.
238
+ """
239
+ with _git_repo_no_sign() as root:
240
+ (root / ".gitignore").write_text("work/\n")
241
+ subprocess.run(
242
+ ["git", "-C", str(root), "add", ".gitignore"],
243
+ check=True, capture_output=True,
244
+ )
245
+ subprocess.run(
246
+ ["git", "-C", str(root), "commit", "-q", "-m", "ignore"],
247
+ check=True, capture_output=True,
248
+ )
249
+ head_before = loop.git("rev-parse", "HEAD")
250
+ before = self._snapshot()
251
+
252
+ (root / "work").mkdir()
253
+ (root / "work" / "scratch.txt").write_text("agent scratch\n")
254
+
255
+ loop.reset_preserving_events(
256
+ head_before, root / "events.jsonl", untracked_before=before,
257
+ )
258
+
259
+ self.assertTrue((root / "work" / "scratch.txt").exists())
260
+
261
+ def test_omitting_snapshot_preserves_untracked(self):
262
+ """Back-compat: no snapshot means no cleanup.
263
+
264
+ An empty set is NOT the right default — it reads as "the operator had
265
+ nothing untracked", which would make every existing call site delete
266
+ the whole untracked working tree. Absence must stay inert.
267
+ """
268
+ with _git_repo_no_sign() as root:
269
+ head_before = loop.git("rev-parse", "HEAD")
270
+ (root / "new.sh").write_text("#!/bin/sh\n")
271
+
272
+ loop.reset_preserving_events(head_before, root / "events.jsonl")
273
+
274
+ self.assertTrue((root / "new.sh").exists())
275
+
276
+ def test_tracked_rollback_still_happens_with_cleanup(self):
277
+ """The cleanup must not displace the reset's original job."""
278
+ with _git_repo_no_sign() as root:
279
+ head_before = loop.git("rev-parse", "HEAD")
280
+ before = self._snapshot()
281
+
282
+ (root / "README.md").write_text("# AGENT-VANDALIZED\n")
283
+ (root / "new.sh").write_text("#!/bin/sh\n")
284
+
285
+ loop.reset_preserving_events(
286
+ head_before, root / "events.jsonl", untracked_before=before,
287
+ )
288
+
289
+ self.assertEqual((root / "README.md").read_text(), "# fixture\n")
290
+ self.assertFalse((root / "new.sh").exists())
291
+
292
+
293
+ if __name__ == "__main__":
294
+ unittest.main()
@@ -1 +0,0 @@
1
- 0.3.15
@@ -1,142 +0,0 @@
1
- #
2
- # Copyright 2026 Specfuse contributors
3
- # Licensed under the Apache License, Version 2.0. See LICENSE.
4
- #
5
- """Unit tests for `reset_preserving_events` — FEAT-2026-0015 bug fix.
6
-
7
- Bug context: `git reset --hard head_before` between WUs rolled events.jsonl
8
- back to its last-committed state, silently dropping the prior WU's lifecycle
9
- events that had been flushed-but-not-yet-committed. Surfaced
10
- FEAT-2026-0015/T02: ran clean, events flushed post-squash, then T03 blocked
11
- → bare hard-reset wiped T02's events.
12
-
13
- Fix: helper captures events.jsonl content, performs hard-reset, restores
14
- content. Subsequent flush_events appends; subsequent commit_bookkeeping
15
- captures the full history.
16
- """
17
-
18
- from __future__ import annotations
19
-
20
- import os
21
- import subprocess
22
- import tempfile
23
- import unittest
24
- from contextlib import contextmanager
25
- from pathlib import Path
26
-
27
- from tests._loop_loader import load_loop
28
-
29
- loop = load_loop()
30
-
31
-
32
- @contextmanager
33
- def _git_repo_no_sign():
34
- """Minimal git repo with signing disabled (operator config-independent)."""
35
- with tempfile.TemporaryDirectory(ignore_cleanup_errors=True) as tmp:
36
- root = Path(tmp)
37
- run = lambda *a: subprocess.run( # noqa: E731
38
- ["git", "-C", str(root), *a], check=True, capture_output=True,
39
- )
40
- run("init", "-q", "-b", "main")
41
- run("config", "commit.gpgSign", "false")
42
- run("config", "gc.auto", "0")
43
- run("config", "user.email", "test@example.com")
44
- run("config", "user.name", "Test")
45
- (root / "README.md").write_text("# fixture\n")
46
- run("add", ".")
47
- run("commit", "-q", "-m", "init")
48
- # Sync barrier
49
- run("rev-parse", "HEAD")
50
- # cwd swap so loop's git() helper (which runs without -C) operates
51
- # on this tree
52
- prev_cwd = os.getcwd()
53
- try:
54
- os.chdir(root)
55
- yield root
56
- finally:
57
- os.chdir(prev_cwd)
58
-
59
-
60
- class TestResetPreservingEventsHelper(unittest.TestCase):
61
- """Direct unit-level tests on reset_preserving_events()."""
62
-
63
- def test_preserves_flushed_but_uncommitted_events_content(self):
64
- """events.jsonl modifications since head_before survive the reset."""
65
- with _git_repo_no_sign() as root:
66
- # Setup: commit a baseline events.jsonl + README
67
- events_path = root / "events.jsonl"
68
- events_path.write_text("# committed baseline\n")
69
- subprocess.run(
70
- ["git", "-C", str(root), "add", "events.jsonl"],
71
- check=True, capture_output=True,
72
- )
73
- subprocess.run(
74
- ["git", "-C", str(root), "commit", "-q", "-m", "baseline"],
75
- check=True, capture_output=True,
76
- )
77
- head_before = loop.git("rev-parse", "HEAD")
78
-
79
- # Simulate prior-WU events flushed post-squash, not yet committed:
80
- # events.jsonl on disk has new content vs head_before's commit.
81
- events_path.write_text(
82
- "# committed baseline\n"
83
- '{"correlation_id":"FEAT-PRIOR/T01","event_type":"task_completed"}\n'
84
- )
85
- # Simulate an agent's tracked-file edit that reset SHOULD wipe.
86
- readme = root / "README.md"
87
- readme.write_text("# AGENT-VANDALIZED\n")
88
-
89
- loop.reset_preserving_events(head_before, events_path)
90
-
91
- # Reset reverted the agent's tracked-file edit
92
- self.assertEqual(readme.read_text(), "# fixture\n")
93
- # But events.jsonl content survived (NOT rolled back to baseline)
94
- self.assertIn("FEAT-PRIOR/T01", events_path.read_text())
95
-
96
- def test_missing_events_file_does_not_crash(self):
97
- """If events.jsonl doesn't exist, helper still resets cleanly."""
98
- with _git_repo_no_sign() as root:
99
- head_before = loop.git("rev-parse", "HEAD")
100
- readme = root / "README.md"
101
- readme.write_text("# agent garbage\n")
102
- events_path = root / "events.jsonl"
103
- self.assertFalse(events_path.is_file())
104
-
105
- loop.reset_preserving_events(head_before, events_path)
106
-
107
- self.assertEqual(readme.read_text(), "# fixture\n")
108
- # events.jsonl still absent — nothing to preserve
109
- self.assertFalse(events_path.is_file())
110
-
111
- def test_preserves_multi_line_events_content(self):
112
- """Multi-line events.jsonl content fully preserved."""
113
- with _git_repo_no_sign() as root:
114
- events_path = root / "events.jsonl"
115
- events_path.write_text("# baseline\n")
116
- subprocess.run(
117
- ["git", "-C", str(root), "add", "events.jsonl"],
118
- check=True, capture_output=True,
119
- )
120
- subprocess.run(
121
- ["git", "-C", str(root), "commit", "-q", "-m", "baseline"],
122
- check=True, capture_output=True,
123
- )
124
- head_before = loop.git("rev-parse", "HEAD")
125
-
126
- events_content = (
127
- "# baseline\n"
128
- '{"correlation_id":"X/T01","event_type":"task_started"}\n'
129
- '{"correlation_id":"X/T01","event_type":"task_completed"}\n'
130
- '{"correlation_id":"X/T02","event_type":"task_started"}\n'
131
- )
132
- events_path.write_text(events_content)
133
- (root / "README.md").write_text("# vandalized\n")
134
-
135
- loop.reset_preserving_events(head_before, events_path)
136
-
137
- self.assertEqual((root / "README.md").read_text(), "# fixture\n")
138
- self.assertEqual(events_path.read_text(), events_content)
139
-
140
-
141
- if __name__ == "__main__":
142
- unittest.main()
File without changes
File without changes
File without changes
File without changes