specfuse-loop 0.3.16__tar.gz → 0.3.17__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.16/specfuse_loop.egg-info → specfuse_loop-0.3.17}/PKG-INFO +1 -1
  2. {specfuse_loop-0.3.16 → specfuse_loop-0.3.17}/pyproject.toml +1 -1
  3. specfuse_loop-0.3.17/specfuse/loop/data/VERSION +1 -0
  4. {specfuse_loop-0.3.16 → specfuse_loop-0.3.17}/specfuse/loop/loop.py +111 -13
  5. {specfuse_loop-0.3.16 → specfuse_loop-0.3.17}/specfuse/loop/scaffold.py +37 -0
  6. {specfuse_loop-0.3.16 → specfuse_loop-0.3.17/specfuse_loop.egg-info}/PKG-INFO +1 -1
  7. {specfuse_loop-0.3.16 → specfuse_loop-0.3.17}/specfuse_loop.egg-info/SOURCES.txt +1 -0
  8. {specfuse_loop-0.3.16 → specfuse_loop-0.3.17}/tests/test_attempt_outcome_emission.py +82 -0
  9. specfuse_loop-0.3.17/tests/test_loop_attempt_evidence.py +116 -0
  10. {specfuse_loop-0.3.16 → specfuse_loop-0.3.17}/tests/test_scaffold_upgrade.py +66 -0
  11. {specfuse_loop-0.3.16 → specfuse_loop-0.3.17}/tests/test_untracked_file_absorption.py +5 -0
  12. specfuse_loop-0.3.16/specfuse/loop/data/VERSION +0 -1
  13. {specfuse_loop-0.3.16 → specfuse_loop-0.3.17}/LICENSE +0 -0
  14. {specfuse_loop-0.3.16 → specfuse_loop-0.3.17}/NOTICE +0 -0
  15. {specfuse_loop-0.3.16 → specfuse_loop-0.3.17}/README.md +0 -0
  16. {specfuse_loop-0.3.16 → specfuse_loop-0.3.17}/setup.cfg +0 -0
  17. {specfuse_loop-0.3.16 → specfuse_loop-0.3.17}/specfuse/loop/__init__.py +0 -0
  18. {specfuse_loop-0.3.16 → specfuse_loop-0.3.17}/specfuse/loop/_filelock.py +0 -0
  19. {specfuse_loop-0.3.16 → specfuse_loop-0.3.17}/specfuse/loop/_miniyaml.py +0 -0
  20. {specfuse_loop-0.3.16 → specfuse_loop-0.3.17}/specfuse/loop/adopt_feature.py +0 -0
  21. {specfuse_loop-0.3.16 → specfuse_loop-0.3.17}/specfuse/loop/data/LEARNINGS.template.md +0 -0
  22. {specfuse_loop-0.3.16 → specfuse_loop-0.3.17}/specfuse/loop/data/docs/concepts/architecture-addendum-gates-and-iterative-planning.md +0 -0
  23. {specfuse_loop-0.3.16 → specfuse_loop-0.3.17}/specfuse/loop/data/docs/concepts/ralph-lineage.md +0 -0
  24. {specfuse_loop-0.3.16 → specfuse_loop-0.3.17}/specfuse/loop/data/docs/getting-started.md +0 -0
  25. {specfuse_loop-0.3.16 → specfuse_loop-0.3.17}/specfuse/loop/data/docs/methodology.md +0 -0
  26. {specfuse_loop-0.3.16 → specfuse_loop-0.3.17}/specfuse/loop/data/docs/skills.md +0 -0
  27. {specfuse_loop-0.3.16 → specfuse_loop-0.3.17}/specfuse/loop/data/gitignore.snippet +0 -0
  28. {specfuse_loop-0.3.16 → specfuse_loop-0.3.17}/specfuse/loop/data/roadmap.template.md +0 -0
  29. {specfuse_loop-0.3.16 → specfuse_loop-0.3.17}/specfuse/loop/data/rules/correlation-ids.md +0 -0
  30. {specfuse_loop-0.3.16 → specfuse_loop-0.3.17}/specfuse/loop/data/rules/never-touch.md +0 -0
  31. {specfuse_loop-0.3.16 → specfuse_loop-0.3.17}/specfuse/loop/data/rules/result-contract.md +0 -0
  32. {specfuse_loop-0.3.16 → specfuse_loop-0.3.17}/specfuse/loop/data/rules/security-boundaries.md +0 -0
  33. {specfuse_loop-0.3.16 → specfuse_loop-0.3.17}/specfuse/loop/data/rules/verification-discipline.md +0 -0
  34. {specfuse_loop-0.3.16 → specfuse_loop-0.3.17}/specfuse/loop/data/schemas/event.schema.json +0 -0
  35. {specfuse_loop-0.3.16 → specfuse_loop-0.3.17}/specfuse/loop/data/schemas/events/initiative_created.schema.json +0 -0
  36. {specfuse_loop-0.3.16 → specfuse_loop-0.3.17}/specfuse/loop/data/schemas/events/spec_issue_resolved.schema.json +0 -0
  37. {specfuse_loop-0.3.16 → specfuse_loop-0.3.17}/specfuse/loop/data/schemas/events/spec_issue_routed.schema.json +0 -0
  38. {specfuse_loop-0.3.16 → specfuse_loop-0.3.17}/specfuse/loop/data/schemas/events/spec_validated.schema.json +0 -0
  39. {specfuse_loop-0.3.16 → specfuse_loop-0.3.17}/specfuse/loop/data/templates/GATE.template.md +0 -0
  40. {specfuse_loop-0.3.16 → specfuse_loop-0.3.17}/specfuse/loop/data/templates/PLAN.template.md +0 -0
  41. {specfuse_loop-0.3.16 → specfuse_loop-0.3.17}/specfuse/loop/data/templates/WU.template.md +0 -0
  42. {specfuse_loop-0.3.16 → specfuse_loop-0.3.17}/specfuse/loop/data/verification.yml.example +0 -0
  43. {specfuse_loop-0.3.16 → specfuse_loop-0.3.17}/specfuse/loop/gate_eval.py +0 -0
  44. {specfuse_loop-0.3.16 → specfuse_loop-0.3.17}/specfuse/loop/gh_backend.py +0 -0
  45. {specfuse_loop-0.3.16 → specfuse_loop-0.3.17}/specfuse/loop/gh_features.py +0 -0
  46. {specfuse_loop-0.3.16 → specfuse_loop-0.3.17}/specfuse/loop/lint_plan.py +0 -0
  47. {specfuse_loop-0.3.16 → specfuse_loop-0.3.17}/specfuse/loop/validate_event.py +0 -0
  48. {specfuse_loop-0.3.16 → specfuse_loop-0.3.17}/specfuse_loop.egg-info/dependency_links.txt +0 -0
  49. {specfuse_loop-0.3.16 → specfuse_loop-0.3.17}/specfuse_loop.egg-info/entry_points.txt +0 -0
  50. {specfuse_loop-0.3.16 → specfuse_loop-0.3.17}/specfuse_loop.egg-info/requires.txt +0 -0
  51. {specfuse_loop-0.3.16 → specfuse_loop-0.3.17}/specfuse_loop.egg-info/top_level.txt +0 -0
  52. {specfuse_loop-0.3.16 → specfuse_loop-0.3.17}/tests/test_adopt_feature.py +0 -0
  53. {specfuse_loop-0.3.16 → specfuse_loop-0.3.17}/tests/test_arm_gate_edits_uncommitted.py +0 -0
  54. {specfuse_loop-0.3.16 → specfuse_loop-0.3.17}/tests/test_autoclose_deferral_visibility.py +0 -0
  55. {specfuse_loop-0.3.16 → specfuse_loop-0.3.17}/tests/test_autosync.py +0 -0
  56. {specfuse_loop-0.3.16 → specfuse_loop-0.3.17}/tests/test_autosync_consent.py +0 -0
  57. {specfuse_loop-0.3.16 → specfuse_loop-0.3.17}/tests/test_autosync_firstrun.py +0 -0
  58. {specfuse_loop-0.3.16 → specfuse_loop-0.3.17}/tests/test_autosync_plugin.py +0 -0
  59. {specfuse_loop-0.3.16 → specfuse_loop-0.3.17}/tests/test_backend.py +0 -0
  60. {specfuse_loop-0.3.16 → specfuse_loop-0.3.17}/tests/test_bash_routing.py +0 -0
  61. {specfuse_loop-0.3.16 → specfuse_loop-0.3.17}/tests/test_bookkeeping_commit_crash_run.py +0 -0
  62. {specfuse_loop-0.3.16 → specfuse_loop-0.3.17}/tests/test_bookkeeping_commit_hook_crash.py +0 -0
  63. {specfuse_loop-0.3.16 → specfuse_loop-0.3.17}/tests/test_bookkeeping_no_verify.py +0 -0
  64. {specfuse_loop-0.3.16 → specfuse_loop-0.3.17}/tests/test_claude_resolution.py +0 -0
  65. {specfuse_loop-0.3.16 → specfuse_loop-0.3.17}/tests/test_close_wu_self_reference.py +0 -0
  66. {specfuse_loop-0.3.16 → specfuse_loop-0.3.17}/tests/test_closing_deliverable_guard.py +0 -0
  67. {specfuse_loop-0.3.16 → specfuse_loop-0.3.17}/tests/test_console_encoding.py +0 -0
  68. {specfuse_loop-0.3.16 → specfuse_loop-0.3.17}/tests/test_cost_tracking.py +0 -0
  69. {specfuse_loop-0.3.16 → specfuse_loop-0.3.17}/tests/test_degraded_oracle.py +0 -0
  70. {specfuse_loop-0.3.16 → specfuse_loop-0.3.17}/tests/test_deliverable_presence_gate.py +0 -0
  71. {specfuse_loop-0.3.16 → specfuse_loop-0.3.17}/tests/test_doctor.py +0 -0
  72. {specfuse_loop-0.3.16 → specfuse_loop-0.3.17}/tests/test_driver_integration.py +0 -0
  73. {specfuse_loop-0.3.16 → specfuse_loop-0.3.17}/tests/test_driver_lock.py +0 -0
  74. {specfuse_loop-0.3.16 → specfuse_loop-0.3.17}/tests/test_duration_tracking.py +0 -0
  75. {specfuse_loop-0.3.16 → specfuse_loop-0.3.17}/tests/test_empty_files_escalation.py +0 -0
  76. {specfuse_loop-0.3.16 → specfuse_loop-0.3.17}/tests/test_ensure_feature_branch.py +0 -0
  77. {specfuse_loop-0.3.16 → specfuse_loop-0.3.17}/tests/test_ensure_feature_branch_base.py +0 -0
  78. {specfuse_loop-0.3.16 → specfuse_loop-0.3.17}/tests/test_events_redaction.py +0 -0
  79. {specfuse_loop-0.3.16 → specfuse_loop-0.3.17}/tests/test_extra_gates.py +0 -0
  80. {specfuse_loop-0.3.16 → specfuse_loop-0.3.17}/tests/test_filelock_portable.py +0 -0
  81. {specfuse_loop-0.3.16 → specfuse_loop-0.3.17}/tests/test_force_full_close.py +0 -0
  82. {specfuse_loop-0.3.16 → specfuse_loop-0.3.17}/tests/test_gate_eval.py +0 -0
  83. {specfuse_loop-0.3.16 → specfuse_loop-0.3.17}/tests/test_gate_eval_calibration.py +0 -0
  84. {specfuse_loop-0.3.16 → specfuse_loop-0.3.17}/tests/test_gate_eval_intermediate_wiring.py +0 -0
  85. {specfuse_loop-0.3.16 → specfuse_loop-0.3.17}/tests/test_gate_eval_terminal_wiring.py +0 -0
  86. {specfuse_loop-0.3.16 → specfuse_loop-0.3.17}/tests/test_gh_backend.py +0 -0
  87. {specfuse_loop-0.3.16 → specfuse_loop-0.3.17}/tests/test_gh_features.py +0 -0
  88. {specfuse_loop-0.3.16 → specfuse_loop-0.3.17}/tests/test_git_env_isolation.py +0 -0
  89. {specfuse_loop-0.3.16 → specfuse_loop-0.3.17}/tests/test_hashed_denylist.py +0 -0
  90. {specfuse_loop-0.3.16 → specfuse_loop-0.3.17}/tests/test_hashed_denylist_ci.py +0 -0
  91. {specfuse_loop-0.3.16 → specfuse_loop-0.3.17}/tests/test_init_integration.py +0 -0
  92. {specfuse_loop-0.3.16 → specfuse_loop-0.3.17}/tests/test_interpreter_normalization.py +0 -0
  93. {specfuse_loop-0.3.16 → specfuse_loop-0.3.17}/tests/test_leak_findings_redaction.py +0 -0
  94. {specfuse_loop-0.3.16 → specfuse_loop-0.3.17}/tests/test_leak_scan.py +0 -0
  95. {specfuse_loop-0.3.16 → specfuse_loop-0.3.17}/tests/test_leak_scan_content.py +0 -0
  96. {specfuse_loop-0.3.16 → specfuse_loop-0.3.17}/tests/test_learnings_query.py +0 -0
  97. {specfuse_loop-0.3.16 → specfuse_loop-0.3.17}/tests/test_legacy_4wu_terminal_flips.py +0 -0
  98. {specfuse_loop-0.3.16 → specfuse_loop-0.3.17}/tests/test_lifecycle_integration.py +0 -0
  99. {specfuse_loop-0.3.16 → specfuse_loop-0.3.17}/tests/test_lint_bare_produces_path.py +0 -0
  100. {specfuse_loop-0.3.16 → specfuse_loop-0.3.17}/tests/test_lint_close_intermediate.py +0 -0
  101. {specfuse_loop-0.3.16 → specfuse_loop-0.3.17}/tests/test_lint_close_wu.py +0 -0
  102. {specfuse_loop-0.3.16 → specfuse_loop-0.3.17}/tests/test_lint_correlation_id.py +0 -0
  103. {specfuse_loop-0.3.16 → specfuse_loop-0.3.17}/tests/test_lint_correlation_id_close_intermediate.py +0 -0
  104. {specfuse_loop-0.3.16 → specfuse_loop-0.3.17}/tests/test_lint_oracle_env.py +0 -0
  105. {specfuse_loop-0.3.16 → specfuse_loop-0.3.17}/tests/test_lint_plan_errors.py +0 -0
  106. {specfuse_loop-0.3.16 → specfuse_loop-0.3.17}/tests/test_lint_plan_next_draft.py +0 -0
  107. {specfuse_loop-0.3.16 → specfuse_loop-0.3.17}/tests/test_lint_plan_relative_import.py +0 -0
  108. {specfuse_loop-0.3.16 → specfuse_loop-0.3.17}/tests/test_lint_produces_driver_helper.py +0 -0
  109. {specfuse_loop-0.3.16 → specfuse_loop-0.3.17}/tests/test_lint_sections.py +0 -0
  110. {specfuse_loop-0.3.16 → specfuse_loop-0.3.17}/tests/test_lint_task_graph_yaml_selection.py +0 -0
  111. {specfuse_loop-0.3.16 → specfuse_loop-0.3.17}/tests/test_loop_auto_archive.py +0 -0
  112. {specfuse_loop-0.3.16 → specfuse_loop-0.3.17}/tests/test_loop_caveman_preamble.py +0 -0
  113. {specfuse_loop-0.3.16 → specfuse_loop-0.3.17}/tests/test_loop_close_intermediate.py +0 -0
  114. {specfuse_loop-0.3.16 → specfuse_loop-0.3.17}/tests/test_loop_defaults_by_type.py +0 -0
  115. {specfuse_loop-0.3.16 → specfuse_loop-0.3.17}/tests/test_loop_effort.py +0 -0
  116. {specfuse_loop-0.3.16 → specfuse_loop-0.3.17}/tests/test_loop_failure_note_cap.py +0 -0
  117. {specfuse_loop-0.3.16 → specfuse_loop-0.3.17}/tests/test_loop_files_changed_guard.py +0 -0
  118. {specfuse_loop-0.3.16 → specfuse_loop-0.3.17}/tests/test_loop_gate_budget.py +0 -0
  119. {specfuse_loop-0.3.16 → specfuse_loop-0.3.17}/tests/test_loop_model_alias.py +0 -0
  120. {specfuse_loop-0.3.16 → specfuse_loop-0.3.17}/tests/test_loop_orchestration.py +0 -0
  121. {specfuse_loop-0.3.16 → specfuse_loop-0.3.17}/tests/test_loop_post_pass_invariant.py +0 -0
  122. {specfuse_loop-0.3.16 → specfuse_loop-0.3.17}/tests/test_loop_reset_preserving_events.py +0 -0
  123. {specfuse_loop-0.3.16 → specfuse_loop-0.3.17}/tests/test_loop_smoke_runner.py +0 -0
  124. {specfuse_loop-0.3.16 → specfuse_loop-0.3.17}/tests/test_loop_unsandboxed.py +0 -0
  125. {specfuse_loop-0.3.16 → specfuse_loop-0.3.17}/tests/test_loop_zero_token_guard.py +0 -0
  126. {specfuse_loop-0.3.16 → specfuse_loop-0.3.17}/tests/test_migrate_legacy.py +0 -0
  127. {specfuse_loop-0.3.16 → specfuse_loop-0.3.17}/tests/test_miniyaml_equivalence.py +0 -0
  128. {specfuse_loop-0.3.16 → specfuse_loop-0.3.17}/tests/test_miniyaml_negative.py +0 -0
  129. {specfuse_loop-0.3.16 → specfuse_loop-0.3.17}/tests/test_planned_cost_lint.py +0 -0
  130. {specfuse_loop-0.3.16 → specfuse_loop-0.3.17}/tests/test_prepare_feature.py +0 -0
  131. {specfuse_loop-0.3.16 → specfuse_loop-0.3.17}/tests/test_prepare_scaffold_sync.py +0 -0
  132. {specfuse_loop-0.3.16 → specfuse_loop-0.3.17}/tests/test_produces_field.py +0 -0
  133. {specfuse_loop-0.3.16 → specfuse_loop-0.3.17}/tests/test_redaction_windows_home.py +0 -0
  134. {specfuse_loop-0.3.16 → specfuse_loop-0.3.17}/tests/test_resolve_base.py +0 -0
  135. {specfuse_loop-0.3.16 → specfuse_loop-0.3.17}/tests/test_result_block.py +0 -0
  136. {specfuse_loop-0.3.16 → specfuse_loop-0.3.17}/tests/test_roadmap_add_skill.py +0 -0
  137. {specfuse_loop-0.3.16 → specfuse_loop-0.3.17}/tests/test_roadmap_archive_skill.py +0 -0
  138. {specfuse_loop-0.3.16 → specfuse_loop-0.3.17}/tests/test_roadmap_row_parser.py +0 -0
  139. {specfuse_loop-0.3.16 → specfuse_loop-0.3.17}/tests/test_scaffold_data_in_sync.py +0 -0
  140. {specfuse_loop-0.3.16 → specfuse_loop-0.3.17}/tests/test_scaffold_doc_hygiene.py +0 -0
  141. {specfuse_loop-0.3.16 → specfuse_loop-0.3.17}/tests/test_scaffold_docs.py +0 -0
  142. {specfuse_loop-0.3.16 → specfuse_loop-0.3.17}/tests/test_scaffold_init.py +0 -0
  143. {specfuse_loop-0.3.16 → specfuse_loop-0.3.17}/tests/test_scaffold_manifest.py +0 -0
  144. {specfuse_loop-0.3.16 → specfuse_loop-0.3.17}/tests/test_scaffold_resources.py +0 -0
  145. {specfuse_loop-0.3.16 → specfuse_loop-0.3.17}/tests/test_scaffold_seed_sanity.py +0 -0
  146. {specfuse_loop-0.3.16 → specfuse_loop-0.3.17}/tests/test_scaffold_wiring.py +0 -0
  147. {specfuse_loop-0.3.16 → specfuse_loop-0.3.17}/tests/test_skills_vendored_in_sync.py +0 -0
  148. {specfuse_loop-0.3.16 → specfuse_loop-0.3.17}/tests/test_squash_commit_hook_crash.py +0 -0
  149. {specfuse_loop-0.3.16 → specfuse_loop-0.3.17}/tests/test_template_closing_shapes.py +0 -0
  150. {specfuse_loop-0.3.16 → specfuse_loop-0.3.17}/tests/test_terminal_flip_ownership.py +0 -0
  151. {specfuse_loop-0.3.16 → specfuse_loop-0.3.17}/tests/test_terminal_flips.py +0 -0
  152. {specfuse_loop-0.3.16 → specfuse_loop-0.3.17}/tests/test_timeout_kill_portable.py +0 -0
  153. {specfuse_loop-0.3.16 → specfuse_loop-0.3.17}/tests/test_untracked_feature_folder.py +0 -0
  154. {specfuse_loop-0.3.16 → specfuse_loop-0.3.17}/tests/test_upgrade_integration.py +0 -0
  155. {specfuse_loop-0.3.16 → specfuse_loop-0.3.17}/tests/test_upgrade_merge_gate.py +0 -0
  156. {specfuse_loop-0.3.16 → specfuse_loop-0.3.17}/tests/test_validate_event.py +0 -0
  157. {specfuse_loop-0.3.16 → specfuse_loop-0.3.17}/tests/test_verdict_coupling.py +0 -0
  158. {specfuse_loop-0.3.16 → specfuse_loop-0.3.17}/tests/test_verify_empty_gate_set.py +0 -0
  159. {specfuse_loop-0.3.16 → specfuse_loop-0.3.17}/tests/test_version_consistency.py +0 -0
  160. {specfuse_loop-0.3.16 → specfuse_loop-0.3.17}/tests/test_version_skew.py +0 -0
  161. {specfuse_loop-0.3.16 → specfuse_loop-0.3.17}/tests/test_windows_gate_exec.py +0 -0
  162. {specfuse_loop-0.3.16 → specfuse_loop-0.3.17}/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.16
3
+ Version: 0.3.17
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.16"
7
+ version = "0.3.17"
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.17
@@ -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.16"
66
+ DRIVER_VERSION = "0.3.17"
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
@@ -542,6 +542,36 @@ def detect_degraded_oracle(stdout: str) -> str | None:
542
542
  return None
543
543
 
544
544
 
545
+ # A failure signature carries no distinguishing content when it is a bare
546
+ # code/markdown fence, whitespace-only, or pure ANSI. Non-Python gate output
547
+ # (e.g. a TypeScript `tests` gate) matches none of the Python-specific
548
+ # _SIG_PATTERNS below and used to fall through to the first line after the
549
+ # marker — often a ``` fence — collapsing genuinely different failures to one
550
+ # value and false-firing spinning_signature_repeat (#167).
551
+ _FENCE_RE = re.compile(r"^[`~]{3,}[\w.+-]*$")
552
+ _ANSI_RE = re.compile(r"\x1b\[[0-9;]*[A-Za-z]")
553
+ # Sentinel returned when every line after the FAIL marker is non-informative.
554
+ # Distinct from `no_gate_marker` (a marker WAS found) and treated as
555
+ # non-informative by `_is_noninformative_signature`.
556
+ NO_SIGNATURE = "no_signature"
557
+
558
+
559
+ def _is_noninformative_signature(sig: "str | None") -> bool:
560
+ """True when *sig* cannot distinguish one failure from another.
561
+
562
+ Covers None, whitespace-only, bare fences (``` / ~~~, optionally with a
563
+ language tag), pure-ANSI strings, and the NO_SIGNATURE sentinel. The spin
564
+ detector and the extraction fallback both consult this so a content-free
565
+ signature neither becomes a WU's identity nor trips escalation (#167).
566
+ """
567
+ if sig is None or sig == NO_SIGNATURE:
568
+ return True
569
+ stripped = _ANSI_RE.sub("", sig).strip()
570
+ if not stripped:
571
+ return True
572
+ return bool(_FENCE_RE.match(stripped))
573
+
574
+
545
575
  def parse_gate_failure_signature(stdout: str) -> tuple[str, str]:
546
576
  """Extract (failure_class, failure_signature) from gate runner stdout.
547
577
 
@@ -577,9 +607,12 @@ def parse_gate_failure_signature(stdout: str) -> tuple[str, str]:
577
607
  return failure_class, sig if sig else "unknown"
578
608
  for line in after_lines:
579
609
  stripped = line.strip()
580
- if stripped:
581
- return failure_class, stripped[:100]
582
- return failure_class, "unknown"
610
+ # Skip non-informative lines (bare fences, whitespace, pure ANSI):
611
+ # keying the signature off one would collapse distinct failures (#167).
612
+ if _is_noninformative_signature(stripped):
613
+ continue
614
+ return failure_class, stripped[:100]
615
+ return failure_class, NO_SIGNATURE
583
616
 
584
617
 
585
618
  def detect_spinning_signature_repeat(
@@ -592,6 +625,9 @@ def detect_spinning_signature_repeat(
592
625
  Returns False when either element of current is None.
593
626
  Returns False when current or prior is the no_gate_marker sentinel to
594
627
  avoid false-positive halts on parser-opaque failures (AC4).
628
+ Returns False when either signature is non-informative — a bare fence,
629
+ whitespace, pure ANSI, or the NO_SIGNATURE sentinel — since such values
630
+ collapse distinct failures and would false-fire the spin halt (#167).
595
631
  """
596
632
  _SENTINEL = ("other", "no_gate_marker")
597
633
  if prior is None:
@@ -600,6 +636,9 @@ def detect_spinning_signature_repeat(
600
636
  return False
601
637
  if current == _SENTINEL or prior == _SENTINEL:
602
638
  return False
639
+ if (_is_noninformative_signature(current[1])
640
+ or _is_noninformative_signature(prior[1])):
641
+ return False
603
642
  return current == prior
604
643
 
605
644
 
@@ -1498,6 +1537,51 @@ def reset_preserving_events(
1498
1537
  _clean_attempt_untracked(untracked_before, events_path)
1499
1538
 
1500
1539
 
1540
+ def capture_working_tree_diff(head_before: str, max_chars: int = 20000) -> str:
1541
+ """Return `git diff <head_before>` (working tree vs pre-attempt base), capped.
1542
+
1543
+ Folded into a failing attempt's evidence note BEFORE the per-attempt
1544
+ `git reset --hard` discards the rejected work, so a blocked WU stays
1545
+ diagnosable — a human (or /gate-status) can see the diff that was rolled
1546
+ back, not just a truncated failure excerpt (#168).
1547
+
1548
+ Best-effort: returns "" on any git error or a clean tree. Truncated to
1549
+ max_chars so a large refactor's diff cannot bloat the committed note.
1550
+ """
1551
+ try:
1552
+ out = subprocess.run(
1553
+ ["git", "diff", head_before],
1554
+ capture_output=True, text=True, check=True,
1555
+ ).stdout
1556
+ except (subprocess.CalledProcessError, OSError):
1557
+ return ""
1558
+ if len(out) > max_chars:
1559
+ out = out[:max_chars] + "\n… (diff truncated)\n"
1560
+ return out
1561
+
1562
+
1563
+ def persist_attempt_notes(
1564
+ work_dir: Path, wu_id: str, attempt_notes: "list[tuple[int, str]]",
1565
+ ) -> "list[Path]":
1566
+ """Write buffered per-attempt evidence to work/<wu>/attempt-N.md.
1567
+
1568
+ Extracted so both escalation paths persist evidence the same way: the
1569
+ exhausted-attempts `for-else` and the early `spinning_signature_repeat`
1570
+ halt. Previously only the former wrote notes, so a signature-repeat block
1571
+ left nothing on disk to diagnose (#168). Returns the written paths for
1572
+ inclusion in the escalation commit. `work/` is gitignored, so the explicit
1573
+ `git add` in commit_bookkeeping is what tracks these.
1574
+ """
1575
+ wu_key = wu_id.replace("/", "_")
1576
+ paths: list[Path] = []
1577
+ for atmpt, evidence in attempt_notes:
1578
+ p = work_dir / wu_key / f"attempt-{atmpt}.md"
1579
+ p.parent.mkdir(parents=True, exist_ok=True)
1580
+ p.write_text(evidence)
1581
+ paths.append(p)
1582
+ return paths
1583
+
1584
+
1501
1585
  class SquashCommitError(RuntimeError):
1502
1586
  """Raised when squash_commit's `git commit` is rejected (non-zero exit).
1503
1587
 
@@ -4144,7 +4228,19 @@ def run(
4144
4228
  # Per-attempt notes are buffered (not written to disk) so they
4145
4229
  # ride with the spinning-escalation commit if we exhaust
4146
4230
  # attempts; on eventual PASS they're discarded as scratch.
4147
- attempt_notes.append((attempt, payload))
4231
+ # Fold the rejected working-tree diff into the buffered
4232
+ # evidence NOW, before the reset below discards it (#168).
4233
+ # Signature/excerpt parsing stays on the raw payload.
4234
+ _diff = capture_working_tree_diff(head_before)
4235
+ _evidence = payload
4236
+ if _diff:
4237
+ _evidence = (
4238
+ payload
4239
+ + "\n\n## Rejected working-tree diff "
4240
+ "(discarded by git reset on block)\n\n"
4241
+ + "```diff\n" + _diff + "\n```\n"
4242
+ )
4243
+ attempt_notes.append((attempt, _evidence))
4148
4244
  failure_note = payload
4149
4245
  _fc, _fs = parse_gate_failure_signature(payload)
4150
4246
  _ex = extract_failure_excerpt(payload)
@@ -4169,11 +4265,18 @@ def run(
4169
4265
  }))
4170
4266
  reset_preserving_events(head_before, events_path,
4171
4267
  untracked_before=untracked_before)
4268
+ # Persist buffered evidence (verify output + rejected
4269
+ # diff) AFTER the reset — work/ is gitignored so the
4270
+ # reset leaves it untouched, and this makes a
4271
+ # signature-repeat block diagnosable like the
4272
+ # exhausted-attempts path (#168).
4273
+ note_paths = persist_attempt_notes(
4274
+ work_dir, wu.wu_id, attempt_notes)
4172
4275
  backend.set_wu(wu, "status", "blocked_human")
4173
4276
  write_cost_to_wu(backend, wu, cum_usage)
4174
4277
  flush_events(events_path, wu_events)
4175
4278
  commit_bookkeeping(
4176
- [wu.file, events_path],
4279
+ [wu.file, events_path, *note_paths],
4177
4280
  f"chore(loop): {wu.wu_id} blocked_human "
4178
4281
  f"(spinning_signature_repeat, attempt {attempt})"
4179
4282
  f"\n\nFeature: {wu.wu_id}",
@@ -4206,13 +4309,8 @@ def run(
4206
4309
  o == "zero_token" for o in attempt_outcomes)
4207
4310
  reason = ("all_attempts_zero_token" if all_zero
4208
4311
  else "spinning_detected")
4209
- wu_key = wu.wu_id.replace("/", "_")
4210
- note_paths = []
4211
- for atmpt, evidence in attempt_notes:
4212
- p = work_dir / wu_key / f"attempt-{atmpt}.md"
4213
- p.parent.mkdir(parents=True, exist_ok=True)
4214
- p.write_text(evidence)
4215
- note_paths.append(p)
4312
+ note_paths = persist_attempt_notes(
4313
+ work_dir, wu.wu_id, attempt_notes)
4216
4314
  backend.set_wu(wu, "status", "blocked_human")
4217
4315
  write_cost_to_wu(backend, wu, cum_usage)
4218
4316
  wu_events.append(build_event("human_escalation", wu.wu_id, {
@@ -8,6 +8,7 @@ import importlib.resources
8
8
  import json
9
9
  import os
10
10
  import re
11
+ import sys
11
12
  from pathlib import Path
12
13
 
13
14
  from . import _miniyaml
@@ -389,9 +390,45 @@ def upgrade_specfuse(
389
390
  wire_claude(target_path)
390
391
  _write_manifest(specfuse_dir, manifest_entries)
391
392
 
393
+ _warn_unmanaged_legacy_dirs(specfuse_dir)
394
+
392
395
  return sorted(written)
393
396
 
394
397
 
398
+ def _warn_unmanaged_legacy_dirs(specfuse_dir: Path) -> None:
399
+ """Warn (stderr) when legacy scripts/ or skills/ copies exist post-upgrade.
400
+
401
+ Current scaffold data ships neither directory: scripts come from the
402
+ specfuse-loop package, skills from the specfuse@specfuse plugin. Only
403
+ pre-PyPI (init.sh-era) repos carry full copies, and `upgrade` overlays
404
+ none of them while still advancing VERSION — so a VERSION that outpaces
405
+ the vendored scripts/skills is silent drift (#165). New projects have
406
+ neither dir, so this is a no-op for them.
407
+
408
+ A warning, not a prune: reconciling those copies (see `migrate_legacy`)
409
+ is destructive and left to an explicit operator step.
410
+ """
411
+ drift: list[str] = []
412
+ for name in ("scripts", "skills"):
413
+ d = specfuse_dir / name
414
+ if d.is_dir():
415
+ n = sum(1 for p in d.rglob("*") if p.is_file())
416
+ if n:
417
+ drift.append(f" .specfuse/{name}/ ({n} file(s))")
418
+ if not drift:
419
+ return
420
+ print(
421
+ f"specfuse upgrade: WARNING — VERSION advanced to {scaffold_version()}, "
422
+ "but these directories are NOT managed by upgrade and may be stale:\n"
423
+ + "\n".join(drift)
424
+ + "\n Scripts are provided by the specfuse-loop package and skills by "
425
+ "the specfuse@specfuse plugin; these are leftover full copies from an "
426
+ "older (pre-package) install. Remove them once nothing local depends "
427
+ "on them.",
428
+ file=sys.stderr,
429
+ )
430
+
431
+
395
432
  def init(target: str | Path, *, ci_check: str | None = None) -> list[str]:
396
433
  """Bootstrap a Specfuse-enabled repo: write .specfuse/ then wire .claude/.
397
434
 
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: specfuse-loop
3
- Version: 0.3.16
3
+ Version: 0.3.17
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
@@ -102,6 +102,7 @@ tests/test_lint_plan_relative_import.py
102
102
  tests/test_lint_produces_driver_helper.py
103
103
  tests/test_lint_sections.py
104
104
  tests/test_lint_task_graph_yaml_selection.py
105
+ tests/test_loop_attempt_evidence.py
105
106
  tests/test_loop_auto_archive.py
106
107
  tests/test_loop_caveman_preamble.py
107
108
  tests/test_loop_close_intermediate.py
@@ -231,6 +231,45 @@ class TestParseGateFailureSignature(unittest.TestCase):
231
231
  self.assertEqual(fc, "other")
232
232
  self.assertEqual(sig, "no_gate_marker")
233
233
 
234
+ # -- #167: non-Python gate output must not collapse to a bare fence -- #
235
+
236
+ def test_fallback_skips_bare_code_fence(self):
237
+ # A non-Python tests gate (e.g. TypeScript) matches none of the
238
+ # Python _SIG_PATTERNS and falls through. The first line after the
239
+ # marker is a Markdown fence; the signature must key off the real
240
+ # failure line beneath it, not the fence.
241
+ stdout = (
242
+ "### tests: FAIL\n"
243
+ "```\n"
244
+ " ✕ company builds a valid config (expected 3, got 4)\n"
245
+ "```\n"
246
+ )
247
+ fc, sig = loop.parse_gate_failure_signature(stdout)
248
+ self.assertEqual(fc, "tests")
249
+ self.assertNotEqual(sig, "```")
250
+ self.assertIn("company builds a valid config", sig)
251
+
252
+ def test_fallback_skips_whitespace_and_tilde_fence(self):
253
+ stdout = "### tests: FAIL\n \n~~~\n\nReal failure: assertion X\n"
254
+ fc, sig = loop.parse_gate_failure_signature(stdout)
255
+ self.assertEqual(fc, "tests")
256
+ self.assertIn("Real failure", sig)
257
+
258
+ def test_all_noninformative_returns_no_signature(self):
259
+ stdout = "### tests: FAIL\n```\n \n~~~\n"
260
+ fc, sig = loop.parse_gate_failure_signature(stdout)
261
+ self.assertEqual(fc, "tests")
262
+ self.assertEqual(sig, "no_signature")
263
+
264
+ def test_two_distinct_ts_failures_yield_distinct_signatures(self):
265
+ # The #167 false-spin: two genuinely different failures must NOT
266
+ # reduce to the same signature (both previously became "```").
267
+ a = "### tests: FAIL\n```\n ✕ builds config (expected 3, got 4)\n```\n"
268
+ b = "### tests: FAIL\n```\n ✕ parses tokens (unexpected EOF)\n```\n"
269
+ _, sig_a = loop.parse_gate_failure_signature(a)
270
+ _, sig_b = loop.parse_gate_failure_signature(b)
271
+ self.assertNotEqual(sig_a, sig_b)
272
+
234
273
 
235
274
  # --------------------------------------------------------------------------- #
236
275
  # TestExtractFailureExcerpt #
@@ -443,6 +482,49 @@ class TestDetectSpinningSignatureRepeat(unittest.TestCase):
443
482
  )
444
483
  self.assertFalse(result)
445
484
 
485
+ # -- #167: non-informative signatures must not trip the spin detector -- #
486
+
487
+ def test_false_on_bare_fence_signature(self):
488
+ # The exact #167 shape: both attempts reduced to "```". Even though
489
+ # current == prior, a bare fence carries no failure-distinguishing
490
+ # content and must not escalate.
491
+ self.assertFalse(
492
+ loop.detect_spinning_signature_repeat(
493
+ ("tests", "```"), ("tests", "```"),
494
+ )
495
+ )
496
+
497
+ def test_false_on_whitespace_signature(self):
498
+ self.assertFalse(
499
+ loop.detect_spinning_signature_repeat(
500
+ ("tests", " "), ("tests", " "),
501
+ )
502
+ )
503
+
504
+ def test_false_on_pure_ansi_signature(self):
505
+ ansi = "\x1b[31m\x1b[0m"
506
+ self.assertFalse(
507
+ loop.detect_spinning_signature_repeat(
508
+ ("tests", ansi), ("tests", ansi),
509
+ )
510
+ )
511
+
512
+ def test_false_on_no_signature_sentinel(self):
513
+ self.assertFalse(
514
+ loop.detect_spinning_signature_repeat(
515
+ ("tests", "no_signature"), ("tests", "no_signature"),
516
+ )
517
+ )
518
+
519
+ def test_still_true_on_real_repeated_signature(self):
520
+ # Guard against over-correction: a genuine repeat still escalates.
521
+ self.assertTrue(
522
+ loop.detect_spinning_signature_repeat(
523
+ ("tests", "builds config (expected 3, got 4)"),
524
+ ("tests", "builds config (expected 3, got 4)"),
525
+ )
526
+ )
527
+
446
528
 
447
529
  # --------------------------------------------------------------------------- #
448
530
  # TestSummarizeAttemptFailureClasses #
@@ -0,0 +1,116 @@
1
+ #
2
+ # Copyright 2026 Specfuse contributors
3
+ # Licensed under the Apache License, Version 2.0. See LICENSE.
4
+ #
5
+ """Unit tests for #168 — persist rejected-attempt evidence before git reset.
6
+
7
+ Two helpers keep a blocked WU diagnosable:
8
+
9
+ - `capture_working_tree_diff(head_before)` folds the rejected diff into a
10
+ failing attempt's note BEFORE the per-attempt `git reset` discards it.
11
+ - `persist_attempt_notes(work_dir, wu_id, notes)` writes the buffered
12
+ evidence to `work/<wu>/attempt-N.md`. It is now called on BOTH escalation
13
+ paths (early spinning_signature_repeat and exhausted-attempts); previously
14
+ only the latter wrote notes, so a signature-repeat block lost all evidence.
15
+ """
16
+
17
+ from __future__ import annotations
18
+
19
+ import os
20
+ import subprocess
21
+ import tempfile
22
+ import unittest
23
+ from contextlib import contextmanager
24
+ from pathlib import Path
25
+
26
+ from tests._loop_loader import load_loop
27
+
28
+ loop = load_loop()
29
+
30
+
31
+ @contextmanager
32
+ def _git_repo_no_sign():
33
+ """Minimal git repo with signing disabled (operator config-independent)."""
34
+ with tempfile.TemporaryDirectory(ignore_cleanup_errors=True) as tmp:
35
+ root = Path(tmp)
36
+ run = lambda *a: subprocess.run( # noqa: E731
37
+ ["git", "-C", str(root), *a], check=True, capture_output=True,
38
+ )
39
+ run("init", "-q", "-b", "main")
40
+ run("config", "commit.gpgSign", "false")
41
+ run("config", "gc.auto", "0")
42
+ run("config", "user.email", "test@example.com")
43
+ run("config", "user.name", "Test")
44
+ (root / "README.md").write_text("# fixture\n")
45
+ run("add", ".")
46
+ run("commit", "-q", "-m", "init")
47
+ run("rev-parse", "HEAD")
48
+ prev_cwd = os.getcwd()
49
+ try:
50
+ os.chdir(root)
51
+ yield root
52
+ finally:
53
+ os.chdir(prev_cwd)
54
+
55
+
56
+ class TestCaptureWorkingTreeDiff(unittest.TestCase):
57
+
58
+ def test_captures_tracked_modification(self):
59
+ with _git_repo_no_sign() as root:
60
+ head = loop.git("rev-parse", "HEAD")
61
+ (root / "README.md").write_text("# fixture\nnew line\n")
62
+ diff = loop.capture_working_tree_diff(head)
63
+ self.assertIn("new line", diff)
64
+ self.assertIn("README.md", diff)
65
+
66
+ def test_empty_on_clean_tree(self):
67
+ with _git_repo_no_sign():
68
+ head = loop.git("rev-parse", "HEAD")
69
+ self.assertEqual(loop.capture_working_tree_diff(head), "")
70
+
71
+ def test_truncates_large_diff(self):
72
+ with _git_repo_no_sign() as root:
73
+ head = loop.git("rev-parse", "HEAD")
74
+ (root / "big.txt").write_text("x\n" * 100000)
75
+ subprocess.run(["git", "-C", str(root), "add", "big.txt"],
76
+ check=True, capture_output=True)
77
+ diff = loop.capture_working_tree_diff(head, max_chars=2000)
78
+ self.assertLessEqual(len(diff), 2000 + 64)
79
+ self.assertIn("truncated", diff)
80
+
81
+ def test_empty_on_bad_ref(self):
82
+ with _git_repo_no_sign():
83
+ # A non-existent ref makes git diff exit non-zero → "" (best-effort).
84
+ self.assertEqual(
85
+ loop.capture_working_tree_diff("deadbeefdeadbeef"), "")
86
+
87
+
88
+ class TestPersistAttemptNotes(unittest.TestCase):
89
+
90
+ def test_writes_one_file_per_attempt(self):
91
+ with tempfile.TemporaryDirectory() as tmp:
92
+ work = Path(tmp) / "work"
93
+ notes = [(1, "first failure"), (2, "second failure")]
94
+ paths = loop.persist_attempt_notes(work, "FEAT-2026-0016/T03", notes)
95
+ self.assertEqual(len(paths), 2)
96
+ self.assertTrue((work / "FEAT-2026-0016_T03" / "attempt-1.md").exists())
97
+ self.assertEqual(
98
+ (work / "FEAT-2026-0016_T03" / "attempt-2.md").read_text(),
99
+ "second failure")
100
+
101
+ def test_wu_id_slashes_flattened(self):
102
+ with tempfile.TemporaryDirectory() as tmp:
103
+ work = Path(tmp) / "work"
104
+ paths = loop.persist_attempt_notes(work, "A/B/C", [(1, "x")])
105
+ self.assertIn("A_B_C", str(paths[0]))
106
+
107
+ def test_empty_notes_writes_nothing(self):
108
+ with tempfile.TemporaryDirectory() as tmp:
109
+ work = Path(tmp) / "work"
110
+ paths = loop.persist_attempt_notes(work, "X/T01", [])
111
+ self.assertEqual(paths, [])
112
+ self.assertFalse(work.exists())
113
+
114
+
115
+ if __name__ == "__main__":
116
+ unittest.main()
@@ -1,6 +1,8 @@
1
1
  # Copyright 2026 Specfuse Contributors
2
2
  # Licensed under the Apache License, Version 2.0. See LICENSE.
3
3
 
4
+ import contextlib
5
+ import io
4
6
  import pathlib
5
7
  import shutil
6
8
  import tempfile
@@ -87,6 +89,70 @@ class TestUpgradeDowngradeRefusal(unittest.TestCase):
87
89
  shutil.rmtree(tmpdir)
88
90
 
89
91
 
92
+ class TestUnmanagedLegacyWarning(unittest.TestCase):
93
+ """#165: upgrade advances VERSION but never manages scripts/ or skills/.
94
+
95
+ Only pre-PyPI (init.sh-era) repos carry full copies; upgrade leaves them
96
+ stale while VERSION moves on. A warning must surface that drift; a clean
97
+ (post-migration) project must stay silent.
98
+ """
99
+
100
+ def _upgrade_capturing_stderr(self, target) -> str:
101
+ err = io.StringIO()
102
+ with contextlib.redirect_stderr(err):
103
+ upgrade_specfuse(target)
104
+ return err.getvalue()
105
+
106
+ def test_warns_when_legacy_scripts_and_skills_present(self):
107
+ target, specfuse, tmpdir = _make_target_with_version("0.0.1")
108
+ try:
109
+ (specfuse / "scripts").mkdir()
110
+ (specfuse / "scripts" / "loop.py").write_text("# legacy 0.2.0\n")
111
+ (specfuse / "skills" / "draft-feature").mkdir(parents=True)
112
+ (specfuse / "skills" / "draft-feature" / "SKILL.md").write_text("x")
113
+ out = self._upgrade_capturing_stderr(target)
114
+ self.assertIn("scripts/", out)
115
+ self.assertIn("skills/", out)
116
+ # Names the version it advanced to, so the drift is legible.
117
+ self.assertIn(scaffold_version(), out)
118
+ finally:
119
+ shutil.rmtree(tmpdir)
120
+
121
+ def test_warns_when_only_scripts_present(self):
122
+ target, specfuse, tmpdir = _make_target_with_version("0.0.1")
123
+ try:
124
+ (specfuse / "scripts").mkdir()
125
+ (specfuse / "scripts" / "loop.py").write_text("# legacy\n")
126
+ out = self._upgrade_capturing_stderr(target)
127
+ self.assertIn("scripts/", out)
128
+ self.assertNotIn("skills/", out)
129
+ finally:
130
+ shutil.rmtree(tmpdir)
131
+
132
+ def test_silent_on_clean_project(self):
133
+ # A post-migration project (init_specfuse ships neither dir).
134
+ tmpdir = tempfile.mkdtemp()
135
+ target = pathlib.Path(tmpdir)
136
+ try:
137
+ init_specfuse(target)
138
+ self.assertFalse((target / ".specfuse" / "scripts").exists())
139
+ self.assertFalse((target / ".specfuse" / "skills").exists())
140
+ out = self._upgrade_capturing_stderr(target)
141
+ self.assertNotIn("not managed", out.lower())
142
+ finally:
143
+ shutil.rmtree(tmpdir)
144
+
145
+ def test_empty_legacy_dir_does_not_warn(self):
146
+ # An empty scripts/ dir (no files) is not drift.
147
+ target, specfuse, tmpdir = _make_target_with_version("0.0.1")
148
+ try:
149
+ (specfuse / "scripts").mkdir()
150
+ out = self._upgrade_capturing_stderr(target)
151
+ self.assertNotIn("not managed", out.lower())
152
+ finally:
153
+ shutil.rmtree(tmpdir)
154
+
155
+
90
156
  class TestUpgradeOverlayAndPreserve(unittest.TestCase):
91
157
  def setUp(self):
92
158
  self._tmpdir = tempfile.TemporaryDirectory()
@@ -186,6 +186,11 @@ class TestUntrackedPathsHelper(unittest.TestCase):
186
186
  root = Path(self._tmp.name)
187
187
  subprocess.run(["git", "init", "-q", "-b", "main", str(root)],
188
188
  check=True)
189
+ # Disable commit signing so the fixture is independent of operator
190
+ # global config: with SSH signing on and no agent socket (e.g. a
191
+ # sandboxed session) `git commit` fails "Couldn't get agent socket"
192
+ # (#166). Mirrors the _git_repo_no_sign convention used elsewhere.
193
+ _git(root, "config", "commit.gpgSign", "false")
189
194
  _git(root, "config", "user.email", "test@example.com")
190
195
  _git(root, "config", "user.name", "Test")
191
196
  (root / "a.py").write_text("a\n")
@@ -1 +0,0 @@
1
- 0.3.16
File without changes
File without changes
File without changes
File without changes