@nklisch/pi-agile-workflow 0.15.3

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 (203) hide show
  1. package/.claude-plugin/plugin.json +10 -0
  2. package/.codex-plugin/plugin.json +28 -0
  3. package/CHANGELOG.md +591 -0
  4. package/README.md +107 -0
  5. package/docs/ARCHITECTURE.md +716 -0
  6. package/docs/MIGRATION.md +419 -0
  7. package/docs/PRINCIPLES.md +464 -0
  8. package/docs/ROADMAP.md +95 -0
  9. package/docs/SPEC.md +855 -0
  10. package/docs/VISION.md +106 -0
  11. package/extensions/agile-workflow.test.ts +419 -0
  12. package/extensions/agile-workflow.ts +467 -0
  13. package/hooks/hooks.json +52 -0
  14. package/hooks/scripts/prompt-context.py +628 -0
  15. package/hooks/scripts/substrate-maintainer.py +359 -0
  16. package/hooks/scripts/test_prompt_context.py +757 -0
  17. package/package.json +30 -0
  18. package/scripts/install-work-view.sh +165 -0
  19. package/scripts/tests/agent-metadata.test.sh +75 -0
  20. package/scripts/tests/bump-version.test.sh +552 -0
  21. package/scripts/tests/channel-parity.test.sh +117 -0
  22. package/scripts/tests/convert-content-integrity.test.sh +246 -0
  23. package/scripts/tests/convert-install-routing.test.sh +159 -0
  24. package/scripts/tests/install-work-view.test.sh +411 -0
  25. package/scripts/tests/pi-package-metadata.test.sh +119 -0
  26. package/scripts/tests/work-board-shim.test.sh +239 -0
  27. package/scripts/tests/work-view-dist-version.test.sh +105 -0
  28. package/scripts/work-board.sh +64 -0
  29. package/scripts/work-view.sh +423 -0
  30. package/skills/autopilot/SKILL.md +330 -0
  31. package/skills/autopilot/agents/openai.yaml +6 -0
  32. package/skills/board/SKILL.md +62 -0
  33. package/skills/board/agents/openai.yaml +6 -0
  34. package/skills/bold-refactor/SKILL.md +244 -0
  35. package/skills/bold-refactor/agents/openai.yaml +6 -0
  36. package/skills/bug-scan/SKILL.md +378 -0
  37. package/skills/bug-scan/agents/openai.yaml +6 -0
  38. package/skills/bug-scan/references/async-promises.md +119 -0
  39. package/skills/bug-scan/references/concurrency-races.md +148 -0
  40. package/skills/bug-scan/references/data-layer.md +167 -0
  41. package/skills/bug-scan/references/error-handling.md +197 -0
  42. package/skills/bug-scan/references/gate-item-template.md +94 -0
  43. package/skills/bug-scan/references/language-footguns.md +219 -0
  44. package/skills/bug-scan/references/parked-item-template.md +103 -0
  45. package/skills/bug-scan/references/report-template.md +115 -0
  46. package/skills/bug-scan/references/resource-leaks.md +199 -0
  47. package/skills/bug-scan/references/state-closures.md +148 -0
  48. package/skills/bug-scan/references/time-numbers.md +158 -0
  49. package/skills/convert/SKILL.md +1463 -0
  50. package/skills/convert/agents/openai.yaml +6 -0
  51. package/skills/convert/references/legacy-overlap-migration.md +179 -0
  52. package/skills/deep-code-scan/SKILL.md +378 -0
  53. package/skills/deep-code-scan/agents/openai.yaml +6 -0
  54. package/skills/deep-code-scan/references/artifact-ledger.md +148 -0
  55. package/skills/deep-code-scan/references/consolidation.md +91 -0
  56. package/skills/deep-code-scan/references/decomposition.md +127 -0
  57. package/skills/deep-code-scan/references/item-templates.md +263 -0
  58. package/skills/deep-code-scan/references/lane-catalog.md +129 -0
  59. package/skills/deep-code-scan/references/review-gauntlet.md +108 -0
  60. package/skills/deep-code-scan/references/scanner-brief.md +152 -0
  61. package/skills/e2e-test-design/SKILL.md +517 -0
  62. package/skills/e2e-test-design/references/anti-tautology.md +261 -0
  63. package/skills/e2e-test-design/references/service-mocks.md +234 -0
  64. package/skills/e2e-test-design/references/test-taxonomy.md +211 -0
  65. package/skills/epic-design/SKILL.md +476 -0
  66. package/skills/epicize/SKILL.md +210 -0
  67. package/skills/epicize/agents/openai.yaml +6 -0
  68. package/skills/feature-design/SKILL.md +472 -0
  69. package/skills/fix/SKILL.md +173 -0
  70. package/skills/gate-cruft/SKILL.md +258 -0
  71. package/skills/gate-docs/SKILL.md +278 -0
  72. package/skills/gate-patterns/SKILL.md +389 -0
  73. package/skills/gate-refactor/SKILL.md +408 -0
  74. package/skills/gate-security/SKILL.md +275 -0
  75. package/skills/gate-tests/SKILL.md +367 -0
  76. package/skills/groom/SKILL.md +162 -0
  77. package/skills/groom/agents/openai.yaml +6 -0
  78. package/skills/ideate/SKILL.md +185 -0
  79. package/skills/ideate/agents/openai.yaml +6 -0
  80. package/skills/implement/SKILL.md +288 -0
  81. package/skills/implement-orchestrator/SKILL.md +604 -0
  82. package/skills/park/SKILL.md +111 -0
  83. package/skills/perf-design/SKILL.md +427 -0
  84. package/skills/perf-scout/SKILL.md +404 -0
  85. package/skills/perf-scout/agents/openai.yaml +6 -0
  86. package/skills/perf-scout/references/algorithmic-and-data-structures.md +126 -0
  87. package/skills/perf-scout/references/approximation-and-precomputation.md +136 -0
  88. package/skills/perf-scout/references/caching-and-memoization.md +123 -0
  89. package/skills/perf-scout/references/compiler-runtime-and-language.md +136 -0
  90. package/skills/perf-scout/references/database-and-storage-internals.md +144 -0
  91. package/skills/perf-scout/references/distributed-systems.md +150 -0
  92. package/skills/perf-scout/references/game-engine-and-realtime.md +136 -0
  93. package/skills/perf-scout/references/gpu-and-accelerators.md +130 -0
  94. package/skills/perf-scout/references/idea-ranking.md +75 -0
  95. package/skills/perf-scout/references/io-and-batching.md +135 -0
  96. package/skills/perf-scout/references/memory-and-data-locality.md +137 -0
  97. package/skills/perf-scout/references/parallelism-and-vectorization.md +126 -0
  98. package/skills/perf-scout/references/parked-item-template.md +117 -0
  99. package/skills/perf-scout/references/peer-review-pass.md +150 -0
  100. package/skills/perf-scout/references/report-template.md +148 -0
  101. package/skills/principles/SKILL.md +678 -0
  102. package/skills/principles/references/models.md +191 -0
  103. package/skills/principles/references/subagents.md +158 -0
  104. package/skills/prose-author/SKILL.md +185 -0
  105. package/skills/refactor-conventions-creator/SKILL.md +359 -0
  106. package/skills/refactor-conventions-creator/agents/openai.yaml +6 -0
  107. package/skills/refactor-conventions-creator/references/common-structures.md +91 -0
  108. package/skills/refactor-conventions-creator/references/common-styles.md +90 -0
  109. package/skills/refactor-design/SKILL.md +384 -0
  110. package/skills/release-deploy/SKILL.md +667 -0
  111. package/skills/release-deploy/agents/openai.yaml +6 -0
  112. package/skills/research/SKILL.md +166 -0
  113. package/skills/research/agents/openai.yaml +6 -0
  114. package/skills/review/SKILL.md +267 -0
  115. package/skills/review/references/deep-review.md +98 -0
  116. package/skills/review/references/review-lenses.md +68 -0
  117. package/skills/review/references/substrate-side-effects.md +161 -0
  118. package/skills/review/references/target-resolution.md +52 -0
  119. package/skills/scope/SKILL.md +486 -0
  120. package/work-view/Cargo.lock +486 -0
  121. package/work-view/Cargo.toml +9 -0
  122. package/work-view/crates/cli/.work-view-version +1 -0
  123. package/work-view/crates/cli/Cargo.toml +18 -0
  124. package/work-view/crates/cli/src/actionable.rs +500 -0
  125. package/work-view/crates/cli/src/args.rs +792 -0
  126. package/work-view/crates/cli/src/board/assets/board.css +855 -0
  127. package/work-view/crates/cli/src/board/assets/board.js +259 -0
  128. package/work-view/crates/cli/src/board/assets/card.js +139 -0
  129. package/work-view/crates/cli/src/board/assets/components.css +290 -0
  130. package/work-view/crates/cli/src/board/assets/dependency.js +1193 -0
  131. package/work-view/crates/cli/src/board/assets/detail.js +256 -0
  132. package/work-view/crates/cli/src/board/assets/filters.js +389 -0
  133. package/work-view/crates/cli/src/board/assets/index.html +77 -0
  134. package/work-view/crates/cli/src/board/assets/kanban.js +231 -0
  135. package/work-view/crates/cli/src/board/assets/markdown.js +260 -0
  136. package/work-view/crates/cli/src/board/assets/motion.css +131 -0
  137. package/work-view/crates/cli/src/board/assets/state.js +245 -0
  138. package/work-view/crates/cli/src/board/assets/table.js +304 -0
  139. package/work-view/crates/cli/src/board/assets/tokens.css +602 -0
  140. package/work-view/crates/cli/src/board/assets/views.js +41 -0
  141. package/work-view/crates/cli/src/board/assets.rs +162 -0
  142. package/work-view/crates/cli/src/board/feed.rs +204 -0
  143. package/work-view/crates/cli/src/board/mod.rs +246 -0
  144. package/work-view/crates/cli/src/board/open.rs +144 -0
  145. package/work-view/crates/cli/src/board/server.rs +407 -0
  146. package/work-view/crates/cli/src/main.rs +195 -0
  147. package/work-view/crates/cli/src/render.rs +354 -0
  148. package/work-view/crates/cli/src/scope.rs +157 -0
  149. package/work-view/crates/cli/src/stale.rs +581 -0
  150. package/work-view/crates/cli/tests/board-js/dependency-table.test.mjs +64 -0
  151. package/work-view/crates/cli/tests/board-js/expanded-browsing.test.mjs +79 -0
  152. package/work-view/crates/cli/tests/board-js/harness-smoke.test.mjs +11 -0
  153. package/work-view/crates/cli/tests/board-js/harness.mjs +437 -0
  154. package/work-view/crates/cli/tests/board-js/kanban-detail.test.mjs +75 -0
  155. package/work-view/crates/cli/tests/board-js/markdown-filter.test.mjs +60 -0
  156. package/work-view/crates/cli/tests/fixtures/golden/.work/CONVENTIONS.md +3 -0
  157. package/work-view/crates/cli/tests/fixtures/golden/.work/active/epics/epic-alpha.md +16 -0
  158. package/work-view/crates/cli/tests/fixtures/golden/.work/active/features/feat-a.md +16 -0
  159. package/work-view/crates/cli/tests/fixtures/golden/.work/active/features/feat-b.md +16 -0
  160. package/work-view/crates/cli/tests/fixtures/golden/.work/active/stories/story-alpha-1.md +16 -0
  161. package/work-view/crates/cli/tests/fixtures/golden/.work/active/stories/story-research-1.md +19 -0
  162. package/work-view/crates/cli/tests/fixtures/golden/.work/archive/feat-done.md +16 -0
  163. package/work-view/crates/cli/tests/fixtures/golden/.work/archive/feat-shipped.md +18 -0
  164. package/work-view/crates/cli/tests/fixtures/golden/.work/backlog/idea-backlog.md +7 -0
  165. package/work-view/crates/cli/tests/fixtures/golden/.work/releases/v1.0/v1.0.md +16 -0
  166. package/work-view/crates/cli/tests/fixtures/ready-drafting/.work/CONVENTIONS.md +6 -0
  167. package/work-view/crates/cli/tests/fixtures/ready-drafting/.work/active/features/feat-dep-done.md +17 -0
  168. package/work-view/crates/cli/tests/fixtures/ready-drafting/.work/active/features/feat-design-ready.md +17 -0
  169. package/work-view/crates/cli/tests/fixtures/ready-drafting/.work/active/features/feat-impl-ready.md +17 -0
  170. package/work-view/crates/cli/tests/fixtures/ready-drafting/.work/active/stories/story-review-ready.md +17 -0
  171. package/work-view/crates/cli/tests/fixtures/with-malformed/.work/CONVENTIONS.md +4 -0
  172. package/work-view/crates/cli/tests/fixtures/with-malformed/.work/active/epics/malformed-no-closing-fence.md +8 -0
  173. package/work-view/crates/cli/tests/fixtures/with-malformed/.work/active/features/good-item.md +16 -0
  174. package/work-view/crates/cli/tests/integration.rs +2833 -0
  175. package/work-view/crates/core/Cargo.toml +13 -0
  176. package/work-view/crates/core/src/error.rs +61 -0
  177. package/work-view/crates/core/src/filter.rs +804 -0
  178. package/work-view/crates/core/src/graph.rs +324 -0
  179. package/work-view/crates/core/src/index.rs +590 -0
  180. package/work-view/crates/core/src/lib.rs +54 -0
  181. package/work-view/crates/core/src/model.rs +202 -0
  182. package/work-view/crates/core/src/parse.rs +494 -0
  183. package/work-view/crates/core/tests/fixtures/golden/.work/CONVENTIONS.md +15 -0
  184. package/work-view/crates/core/tests/fixtures/golden/.work/active/epics/epic-core.md +16 -0
  185. package/work-view/crates/core/tests/fixtures/golden/.work/active/features/feat-parser.md +16 -0
  186. package/work-view/crates/core/tests/fixtures/golden/.work/active/features/feat-query.md +16 -0
  187. package/work-view/crates/core/tests/fixtures/golden/.work/active/stories/malformed-no-id.md +15 -0
  188. package/work-view/crates/core/tests/fixtures/golden/.work/active/stories/story-parse-unit.md +16 -0
  189. package/work-view/crates/core/tests/fixtures/golden/.work/active/stories/story-security-gate.md +16 -0
  190. package/work-view/crates/core/tests/fixtures/golden/.work/archive/feat-old.md +16 -0
  191. package/work-view/crates/core/tests/fixtures/golden/.work/archive/feat-parser.md +17 -0
  192. package/work-view/crates/core/tests/fixtures/golden/.work/backlog/idea-future-board.md +7 -0
  193. package/work-view/crates/core/tests/fixtures/golden/.work/releases/v1.0/v1.0.md +16 -0
  194. package/work-view/crates/core/tests/fixtures/precedence/.work/CONVENTIONS.md +10 -0
  195. package/work-view/crates/core/tests/fixtures/precedence/.work/archive/dup-item.md +18 -0
  196. package/work-view/crates/core/tests/fixtures/precedence/.work/releases/v2.0/dup-item.md +18 -0
  197. package/work-view/crates/core/tests/integration.rs +541 -0
  198. package/work-view/dist/.gitattributes +1 -0
  199. package/work-view/dist/README.md +42 -0
  200. package/work-view/dist/aarch64-apple-darwin/work-view +0 -0
  201. package/work-view/dist/aarch64-unknown-linux-musl/work-view +0 -0
  202. package/work-view/dist/x86_64-apple-darwin/work-view +0 -0
  203. package/work-view/dist/x86_64-unknown-linux-musl/work-view +0 -0
@@ -0,0 +1,757 @@
1
+ #!/usr/bin/env python3
2
+ """Stdlib unittest suite for prompt-context.py.
3
+
4
+ Pure stdlib (unittest + unittest.mock) — does NOT depend on pytest. Run with:
5
+
6
+ cd plugins/agile-workflow/hooks/scripts
7
+ python3 -m unittest test_prompt_context -v
8
+ """
9
+
10
+ from __future__ import annotations
11
+
12
+ import importlib.util
13
+ import io
14
+ import json
15
+ import shutil
16
+ import subprocess
17
+ import sys
18
+ import tempfile
19
+ import time
20
+ import unittest
21
+ from pathlib import Path
22
+ from unittest import mock
23
+
24
+ # ---------------------------------------------------------------------------
25
+ # Import prompt-context.py by path (filename has a hyphen, so it is not a
26
+ # normal importable module name).
27
+ # ---------------------------------------------------------------------------
28
+ _MODULE_PATH = Path(__file__).resolve().parent / "prompt-context.py"
29
+ _spec = importlib.util.spec_from_file_location("prompt_context", _MODULE_PATH)
30
+ assert _spec is not None and _spec.loader is not None
31
+ prompt_context = importlib.util.module_from_spec(_spec)
32
+ _spec.loader.exec_module(prompt_context)
33
+
34
+
35
+ class WorkViewSelfHealTest(unittest.TestCase):
36
+ """Guards the hook's fail-open work-view installer self-heal step."""
37
+
38
+ def setUp(self) -> None:
39
+ self._tmp = tempfile.TemporaryDirectory()
40
+ self.root = Path(self._tmp.name) / "workspace"
41
+ self.root.mkdir()
42
+ (self.root / ".work").mkdir()
43
+ (self.root / ".work" / "CONVENTIONS.md").write_text("# Conventions\n", encoding="utf-8")
44
+ self.plugin = Path(self._tmp.name) / "plugin"
45
+ (self.plugin / ".claude-plugin").mkdir(parents=True)
46
+ (self.plugin / ".claude-plugin" / "plugin.json").write_text(
47
+ '{"version": "0.8.7"}\n', encoding="utf-8"
48
+ )
49
+ (self.plugin / "scripts").mkdir()
50
+ (self.plugin / "scripts" / "install-work-view.sh").write_text(
51
+ "#!/usr/bin/env bash\nexit 0\n", encoding="utf-8"
52
+ )
53
+
54
+ def tearDown(self) -> None:
55
+ self._tmp.cleanup()
56
+
57
+ def _write_work_view(self) -> Path:
58
+ work_view = self.root / ".work" / "bin" / "work-view"
59
+ work_view.parent.mkdir(parents=True, exist_ok=True)
60
+ work_view.write_text("#!/usr/bin/env bash\n", encoding="utf-8")
61
+ work_view.chmod(0o755)
62
+ return work_view
63
+
64
+ def _env(self):
65
+ return mock.patch.dict(
66
+ prompt_context.os.environ,
67
+ {"PLUGIN_ROOT": str(self.plugin)},
68
+ clear=True,
69
+ )
70
+
71
+ def test_sessionstart_stale_copy_runs_installer(self) -> None:
72
+ self._write_work_view()
73
+ with self._env(), mock.patch.object(
74
+ prompt_context, "installed_version", return_value="0.8.6"
75
+ ), mock.patch.object(prompt_context, "run_installer") as run_installer:
76
+ prompt_context.self_heal_work_view(self.root, "SessionStart")
77
+
78
+ run_installer.assert_called_once_with(self.root, self.plugin)
79
+
80
+ def test_sessionstart_unrecognized_copy_runs_installer(self) -> None:
81
+ self._write_work_view()
82
+ with self._env(), mock.patch.object(
83
+ prompt_context, "installed_version", return_value=None
84
+ ), mock.patch.object(prompt_context, "run_installer") as run_installer:
85
+ prompt_context.self_heal_work_view(self.root, "SessionStart")
86
+
87
+ run_installer.assert_called_once_with(self.root, self.plugin)
88
+
89
+ def test_sessionstart_current_copy_does_not_install(self) -> None:
90
+ self._write_work_view()
91
+ with self._env(), mock.patch.object(
92
+ prompt_context, "installed_version", return_value="0.8.7"
93
+ ), mock.patch.object(prompt_context, "run_installer") as run_installer:
94
+ prompt_context.self_heal_work_view(self.root, "SessionStart")
95
+
96
+ run_installer.assert_not_called()
97
+
98
+ def test_sessionstart_missing_copy_installs_without_version_probe(self) -> None:
99
+ with self._env(), mock.patch.object(
100
+ prompt_context, "installed_version", side_effect=AssertionError("no probe")
101
+ ), mock.patch.object(prompt_context, "run_installer") as run_installer:
102
+ prompt_context.self_heal_work_view(self.root, "SessionStart")
103
+
104
+ run_installer.assert_called_once_with(self.root, self.plugin)
105
+
106
+ def test_userpromptsubmit_missing_installs(self) -> None:
107
+ with self._env(), mock.patch.object(
108
+ prompt_context, "installed_version", side_effect=AssertionError("no probe")
109
+ ), mock.patch.object(prompt_context, "run_installer") as run_installer:
110
+ prompt_context.self_heal_work_view(self.root, "UserPromptSubmit")
111
+
112
+ run_installer.assert_called_once_with(self.root, self.plugin)
113
+
114
+ def test_userpromptsubmit_present_does_not_install_or_probe_version(self) -> None:
115
+ self._write_work_view()
116
+ with self._env(), mock.patch.object(
117
+ prompt_context, "installed_version", side_effect=AssertionError("no version probe")
118
+ ) as installed_version, mock.patch.object(
119
+ prompt_context.subprocess, "run", side_effect=AssertionError("no subprocess")
120
+ ), mock.patch.object(prompt_context, "run_installer") as run_installer:
121
+ prompt_context.self_heal_work_view(self.root, "UserPromptSubmit")
122
+
123
+ installed_version.assert_not_called()
124
+ run_installer.assert_not_called()
125
+
126
+ def test_no_plugin_root_env_is_noop(self) -> None:
127
+ self._write_work_view()
128
+ with mock.patch.dict(prompt_context.os.environ, {}, clear=True), mock.patch.object(
129
+ prompt_context, "run_installer"
130
+ ) as run_installer:
131
+ prompt_context.self_heal_work_view(self.root, "SessionStart")
132
+
133
+ run_installer.assert_not_called()
134
+
135
+ def test_installed_version_uses_last_token(self) -> None:
136
+ self._write_work_view()
137
+ completed = mock.Mock(returncode=0, stdout="work-view 0.8.7\n")
138
+ with mock.patch.object(prompt_context.subprocess, "run", return_value=completed):
139
+ self.assertEqual(prompt_context.installed_version(self.root), "0.8.7")
140
+
141
+ def test_installed_version_rejects_unrecognized_output(self) -> None:
142
+ self._write_work_view()
143
+ completed = mock.Mock(returncode=0, stdout="version 0.8.7\n")
144
+ with mock.patch.object(prompt_context.subprocess, "run", return_value=completed):
145
+ self.assertIsNone(prompt_context.installed_version(self.root))
146
+
147
+ def test_installed_version_returns_none_on_probe_timeout(self) -> None:
148
+ """A --version probe that times out yields an unknown (None) version.
149
+
150
+ Guards the distinct failure from a broken installer: the probe itself
151
+ runs a subprocess on a possibly-broken work-view (corrupt binary, bash
152
+ entrypoint stuck on a prompt). If that subprocess raises TimeoutExpired,
153
+ installed_version must swallow it and report None so the copy is treated
154
+ as stale/unknown rather than crashing the hook.
155
+ """
156
+ self._write_work_view()
157
+ timeout = prompt_context.subprocess.TimeoutExpired(cmd="work-view --version", timeout=5)
158
+ with mock.patch.object(prompt_context.subprocess, "run", side_effect=timeout):
159
+ self.assertIsNone(prompt_context.installed_version(self.root))
160
+
161
+ def test_sessionstart_version_probe_timeout_installs(self) -> None:
162
+ """SessionStart + a hung/timed-out --version probe -> treat as stale.
163
+
164
+ The probe subprocess raises TimeoutExpired. self_heal_work_view must not
165
+ raise, and because the version is now unknown (None != want) it must run
166
+ the installer to repair the broken copy.
167
+ """
168
+ self._write_work_view()
169
+ timeout = prompt_context.subprocess.TimeoutExpired(cmd="work-view --version", timeout=5)
170
+ with self._env(), mock.patch.object(
171
+ prompt_context.subprocess, "run", side_effect=timeout
172
+ ), mock.patch.object(prompt_context, "run_installer") as run_installer:
173
+ # Must complete without raising even though the probe times out.
174
+ prompt_context.self_heal_work_view(self.root, "SessionStart")
175
+
176
+ run_installer.assert_called_once_with(self.root, self.plugin)
177
+
178
+ def test_main_failopens_when_version_probe_times_out(self) -> None:
179
+ """SessionStart hook stays fail-open when the --version probe times out.
180
+
181
+ End-to-end through main(): the real installed_version runs but its
182
+ subprocess.run raises TimeoutExpired. The hook must still exit 0 and emit
183
+ its normal .agents/rules/ context. run_installer is stubbed (the copy is
184
+ treated as stale, so it would be invoked) to keep the test hermetic.
185
+ """
186
+ rules_dir = self.root / ".agents" / "rules"
187
+ rules_dir.mkdir(parents=True)
188
+ (rules_dir / "a.md").write_text("Rule A body", encoding="utf-8")
189
+ state_file = self.root / "hook-state.json"
190
+ self._write_work_view()
191
+ payload = {
192
+ "session_id": "s1",
193
+ "hook_event_name": "SessionStart",
194
+ "source": "startup",
195
+ "cwd": str(self.root),
196
+ }
197
+ timeout = prompt_context.subprocess.TimeoutExpired(cmd="work-view --version", timeout=5)
198
+ out = io.StringIO()
199
+ with self._env(), mock.patch.object(
200
+ prompt_context, "state_path", return_value=state_file
201
+ ), mock.patch.object(
202
+ prompt_context.subprocess, "run", side_effect=timeout
203
+ ), mock.patch.object(prompt_context, "run_installer") as run_installer, mock.patch.object(
204
+ prompt_context.sys, "stdin", io.StringIO(json.dumps(payload))
205
+ ), mock.patch.object(prompt_context.sys, "stdout", out):
206
+ rc = prompt_context.main()
207
+
208
+ self.assertEqual(rc, 0)
209
+ printed = out.getvalue()
210
+ self.assertIn("additionalContext", printed)
211
+ self.assertIn("Rule A body", printed)
212
+ # Probe timed out -> version unknown -> copy treated as stale -> installer
213
+ # invoked. The hook never raised.
214
+ run_installer.assert_called_once_with(self.root, self.plugin)
215
+
216
+ def test_run_installer_suppresses_output(self) -> None:
217
+ with self._env(), mock.patch.object(prompt_context.subprocess, "run") as run:
218
+ prompt_context.run_installer(self.root, self.plugin)
219
+
220
+ kwargs = run.call_args.kwargs
221
+ self.assertEqual(kwargs["stdout"], prompt_context.subprocess.DEVNULL)
222
+ self.assertEqual(kwargs["stderr"], prompt_context.subprocess.DEVNULL)
223
+ self.assertEqual(kwargs["env"]["PLUGIN_ROOT"], str(self.plugin))
224
+
225
+ def test_main_failopens_when_installer_path_raises(self) -> None:
226
+ rules_dir = self.root / ".agents" / "rules"
227
+ rules_dir.mkdir(parents=True)
228
+ (rules_dir / "a.md").write_text("Rule A body", encoding="utf-8")
229
+ state_file = self.root / "hook-state.json"
230
+ payload = {
231
+ "session_id": "s1",
232
+ "hook_event_name": "SessionStart",
233
+ "source": "startup",
234
+ "cwd": str(self.root),
235
+ }
236
+ out = io.StringIO()
237
+ with self._env(), mock.patch.object(
238
+ prompt_context, "state_path", return_value=state_file
239
+ ), mock.patch.object(
240
+ prompt_context, "run_installer", side_effect=RuntimeError("installer failed")
241
+ ), mock.patch.object(
242
+ prompt_context.sys, "stdin", io.StringIO(json.dumps(payload))
243
+ ), mock.patch.object(prompt_context.sys, "stdout", out):
244
+ rc = prompt_context.main()
245
+
246
+ self.assertEqual(rc, 0)
247
+ printed = out.getvalue()
248
+ self.assertIn("additionalContext", printed)
249
+ self.assertIn("Rule A body", printed)
250
+
251
+
252
+ class RulesLoaderTest(unittest.TestCase):
253
+ """Guards the generic .agents/rules/ hook loader.
254
+
255
+ Covers the reader + hash, the CONVENTIONS flag/byte-cap, per-epoch +
256
+ content-hash dedup, and the SessionStart/PostCompact wiring. State is
257
+ isolated to a temp file so dedup does not leak across tests.
258
+ """
259
+
260
+ def setUp(self) -> None:
261
+ self._tmp = tempfile.TemporaryDirectory()
262
+ self.root = Path(self._tmp.name)
263
+ (self.root / ".work").mkdir()
264
+ self._write_conventions("# Conventions\n")
265
+ self.rules_dir = self.root / ".agents" / "rules"
266
+ self.rules_dir.mkdir(parents=True)
267
+ self.state_file = self.root / "hook-state.json"
268
+ self._state_patch = mock.patch.object(
269
+ prompt_context, "state_path", return_value=self.state_file
270
+ )
271
+ self._state_patch.start()
272
+
273
+ def tearDown(self) -> None:
274
+ self._state_patch.stop()
275
+ self._tmp.cleanup()
276
+
277
+ def _write_conventions(self, text: str) -> None:
278
+ (self.root / ".work" / "CONVENTIONS.md").write_text(text, encoding="utf-8")
279
+
280
+ def _payload(self, **kw):
281
+ base = {"session_id": "s1", "hook_event_name": "UserPromptSubmit"}
282
+ base.update(kw)
283
+ return base
284
+
285
+ def test_reads_and_hashes(self) -> None:
286
+ (self.rules_dir / "a.md").write_text("Rule A body", encoding="utf-8")
287
+ (self.rules_dir / "b.md").write_text("Rule B body", encoding="utf-8")
288
+ text, digest = prompt_context.read_rules_dir(self.root, 12000)
289
+ self.assertIn("## Project Rules", text)
290
+ self.assertIn("Rule A body", text)
291
+ self.assertIn("Rule B body", text)
292
+ self.assertTrue(digest)
293
+
294
+ def test_absent_dir_returns_empty(self) -> None:
295
+ shutil.rmtree(self.rules_dir)
296
+ self.assertEqual(prompt_context.read_rules_dir(self.root, 12000), ("", ""))
297
+
298
+ def test_empty_dir_returns_empty(self) -> None:
299
+ self.assertEqual(prompt_context.read_rules_dir(self.root, 12000), ("", ""))
300
+
301
+ def test_byte_cap_truncates(self) -> None:
302
+ (self.rules_dir / "big.md").write_text("x" * 5000, encoding="utf-8")
303
+ text, _ = prompt_context.read_rules_dir(self.root, 1000)
304
+ self.assertIn("truncated", text)
305
+ self.assertLess(len(text.encode("utf-8")), 2000)
306
+
307
+ def test_flag_off(self) -> None:
308
+ self._write_conventions("# Conventions\nrules_context: off\n")
309
+ enabled, _ = prompt_context.rules_config(self.root)
310
+ self.assertFalse(enabled)
311
+
312
+ def test_flag_default_on_and_max_bytes(self) -> None:
313
+ self._write_conventions("rules_context_max_bytes: 2048\n")
314
+ enabled, max_bytes = prompt_context.rules_config(self.root)
315
+ self.assertTrue(enabled)
316
+ self.assertEqual(max_bytes, 2048)
317
+
318
+ def test_emit_dedup_per_epoch(self) -> None:
319
+ (self.rules_dir / "a.md").write_text("Rule A", encoding="utf-8")
320
+ payload = self._payload()
321
+ first = prompt_context.emit_rules(self.root, payload)
322
+ self.assertIn("Rule A", first)
323
+ second = prompt_context.emit_rules(self.root, payload)
324
+ self.assertEqual(second, "")
325
+
326
+ def test_emit_force_bypasses_dedup_for_pi_rebuilt_prompt(self) -> None:
327
+ (self.rules_dir / "a.md").write_text("Rule A", encoding="utf-8")
328
+ payload = self._payload()
329
+ self.assertIn("Rule A", prompt_context.emit_rules(self.root, payload, force=True))
330
+ self.assertIn("Rule A", prompt_context.emit_rules(self.root, payload, force=True))
331
+
332
+ def test_emit_reinject_on_content_change(self) -> None:
333
+ (self.rules_dir / "a.md").write_text("Rule A", encoding="utf-8")
334
+ payload = self._payload()
335
+ self.assertIn("Rule A", prompt_context.emit_rules(self.root, payload))
336
+ (self.rules_dir / "a.md").write_text("Rule A v2", encoding="utf-8")
337
+ self.assertIn(
338
+ "Rule A v2",
339
+ prompt_context.emit_rules(self.root, payload),
340
+ )
341
+
342
+ def test_emit_off_when_flag_off(self) -> None:
343
+ (self.rules_dir / "a.md").write_text("Rule A", encoding="utf-8")
344
+ self._write_conventions("rules_context: off\n")
345
+ self.assertEqual(
346
+ prompt_context.emit_rules(self.root, self._payload()),
347
+ "",
348
+ )
349
+
350
+ def test_user_prompt_submit_omits_rules_and_snapshot_on_actionable_prompt(self) -> None:
351
+ (self.rules_dir / "a.md").write_text("Rule A", encoding="utf-8")
352
+ payload = self._payload(cwd=str(self.root), prompt="drain the queue")
353
+ out = io.StringIO()
354
+ with mock.patch.object(
355
+ prompt_context.sys, "stdin", io.StringIO(json.dumps(payload))
356
+ ), mock.patch.object(prompt_context.sys, "stdout", out):
357
+ rc = prompt_context.main()
358
+ self.assertEqual(rc, 0)
359
+ printed = out.getvalue()
360
+ self.assertIn("Agile Workflow Principles", printed)
361
+ self.assertNotIn("Rule A", printed)
362
+ self.assertNotIn("Agile Workflow Snapshot", printed)
363
+
364
+ def test_short_workflow_command_emits_principles(self) -> None:
365
+ payload = self._payload(cwd=str(self.root), prompt="implement")
366
+ out = io.StringIO()
367
+ with mock.patch.object(
368
+ prompt_context.sys, "stdin", io.StringIO(json.dumps(payload))
369
+ ), mock.patch.object(prompt_context.sys, "stdout", out):
370
+ rc = prompt_context.main()
371
+ self.assertEqual(rc, 0)
372
+ printed = out.getvalue()
373
+ self.assertIn("Agile Workflow Principles", printed)
374
+ self.assertIn("Code-design capsule", printed)
375
+
376
+ def test_main_sessionstart_emits_rules(self) -> None:
377
+ (self.rules_dir / "a.md").write_text("Rule A body", encoding="utf-8")
378
+ payload = {
379
+ "session_id": "s2",
380
+ "hook_event_name": "SessionStart",
381
+ "source": "startup",
382
+ "cwd": str(self.root),
383
+ }
384
+ out = io.StringIO()
385
+ with mock.patch.object(
386
+ prompt_context.sys, "stdin", io.StringIO(json.dumps(payload))
387
+ ), mock.patch.object(prompt_context.sys, "stdout", out):
388
+ rc = prompt_context.main()
389
+ self.assertEqual(rc, 0)
390
+ printed = out.getvalue()
391
+ self.assertIn("additionalContext", printed)
392
+ self.assertIn("Rule A body", printed)
393
+
394
+ def test_config_prose_does_not_disable(self) -> None:
395
+ self._write_conventions(
396
+ "# Conventions\n- rules_context: off disables injection\n"
397
+ )
398
+ enabled, _ = prompt_context.rules_config(self.root)
399
+ self.assertTrue(enabled, "prose example must not flip the flag")
400
+
401
+ def test_config_invalid_utf8_defaults(self) -> None:
402
+ (self.root / ".work" / "CONVENTIONS.md").write_bytes(
403
+ b"\xff\xfe rules_context: off\n"
404
+ )
405
+ enabled, max_bytes = prompt_context.rules_config(self.root)
406
+ self.assertTrue(enabled)
407
+ self.assertEqual(max_bytes, prompt_context.DEFAULT_RULES_MAX_BYTES)
408
+
409
+ def test_max_bytes_zero_ignored(self) -> None:
410
+ self._write_conventions("rules_context_max_bytes: 0\n")
411
+ _, max_bytes = prompt_context.rules_config(self.root)
412
+ self.assertEqual(max_bytes, prompt_context.DEFAULT_RULES_MAX_BYTES)
413
+ (self.rules_dir / "a.md").write_text("Rule A body", encoding="utf-8")
414
+ text, _ = prompt_context.read_rules_dir(self.root, max_bytes)
415
+ self.assertIn("Rule A body", text)
416
+ self.assertNotIn("truncated", text)
417
+
418
+ def test_sessionstart_dedup_suppresses_second_emit(self) -> None:
419
+ (self.rules_dir / "a.md").write_text("Rule A", encoding="utf-8")
420
+ prompt_context.bump_epoch(
421
+ self.root,
422
+ {"session_id": "s1", "hook_event_name": "SessionStart", "source": "startup"},
423
+ )
424
+ first = prompt_context.emit_rules(self.root, {"session_id": "s1"})
425
+ self.assertIn("Rule A", first)
426
+ second = prompt_context.emit_rules(self.root, {"session_id": "s1"})
427
+ self.assertEqual(second, "")
428
+
429
+ def test_main_postcompact_emits(self) -> None:
430
+ (self.rules_dir / "a.md").write_text("Rule A body", encoding="utf-8")
431
+ payload = {
432
+ "session_id": "s3",
433
+ "hook_event_name": "PostCompact",
434
+ "source": "auto",
435
+ "cwd": str(self.root),
436
+ }
437
+ out = io.StringIO()
438
+ with mock.patch.object(
439
+ prompt_context.sys, "stdin", io.StringIO(json.dumps(payload))
440
+ ), mock.patch.object(prompt_context.sys, "stdout", out):
441
+ rc = prompt_context.main()
442
+ self.assertEqual(rc, 0)
443
+ self.assertIn("Rule A body", out.getvalue())
444
+
445
+ def test_main_codex_postcompact_suppresses_unsupported_context_output(self) -> None:
446
+ (self.rules_dir / "a.md").write_text("Rule A body", encoding="utf-8")
447
+ payload = {
448
+ "session_id": "codex-s1",
449
+ "hook_event_name": "PostCompact",
450
+ "trigger": "auto",
451
+ "cwd": str(self.root),
452
+ }
453
+ out = io.StringIO()
454
+ with mock.patch.dict(
455
+ prompt_context.os.environ,
456
+ {"PLUGIN_ROOT": "/tmp/agile-workflow-plugin"},
457
+ clear=True,
458
+ ), mock.patch.object(
459
+ prompt_context.sys, "stdin", io.StringIO(json.dumps(payload))
460
+ ), mock.patch.object(prompt_context.sys, "stdout", out):
461
+ rc = prompt_context.main()
462
+
463
+ self.assertEqual(rc, 0)
464
+ self.assertEqual(out.getvalue(), "")
465
+ state = prompt_context.load_state(self.root)
466
+ session = state["sessions"]["codex-s1"]
467
+ self.assertEqual(session["epoch"], 1)
468
+ self.assertNotIn("rules", session["seen"])
469
+
470
+ def test_main_codex_sessionstart_compact_emits_rules(self) -> None:
471
+ (self.rules_dir / "a.md").write_text("Rule A body", encoding="utf-8")
472
+ payload = {
473
+ "session_id": "codex-s2",
474
+ "hook_event_name": "SessionStart",
475
+ "source": "compact",
476
+ "cwd": str(self.root),
477
+ }
478
+ out = io.StringIO()
479
+ with mock.patch.dict(
480
+ prompt_context.os.environ,
481
+ {"PLUGIN_ROOT": "/tmp/agile-workflow-plugin"},
482
+ clear=True,
483
+ ), mock.patch.object(
484
+ prompt_context.sys, "stdin", io.StringIO(json.dumps(payload))
485
+ ), mock.patch.object(prompt_context.sys, "stdout", out):
486
+ rc = prompt_context.main()
487
+
488
+ self.assertEqual(rc, 0)
489
+ printed = out.getvalue()
490
+ self.assertIn("additionalContext", printed)
491
+ self.assertIn("Rule A body", printed)
492
+
493
+
494
+ class StateConcurrencyTest(unittest.TestCase):
495
+ """Guards the state-file atomicity + concurrency hardening.
496
+
497
+ Covers: (a) save_state uses a per-writer unique temp path, not a fixed
498
+ ".json.tmp"; (b) the _state_lock context manager acquires/releases an flock
499
+ and degrades gracefully when fcntl is unavailable; (c) the locked
500
+ read-modify-write does not lose another session's entry across an
501
+ interleaved load/save simulated by hand.
502
+
503
+ State is isolated to a temp file so it does not leak across tests.
504
+ """
505
+
506
+ def setUp(self) -> None:
507
+ self._tmp = tempfile.TemporaryDirectory()
508
+ self.root = Path(self._tmp.name)
509
+ self.state_file = self.root / "state" / "hook-state.json"
510
+ self._state_patch = mock.patch.object(
511
+ prompt_context, "state_path", return_value=self.state_file
512
+ )
513
+ self._state_patch.start()
514
+
515
+ def tearDown(self) -> None:
516
+ self._state_patch.stop()
517
+ self._tmp.cleanup()
518
+
519
+ def test_save_state_uses_unique_temp_path(self) -> None:
520
+ """save_state must NOT write to a fixed ".json.tmp"; two saves must not
521
+ collide on the same temp name."""
522
+ captured: list[str] = []
523
+ real_open = Path.open
524
+
525
+ def spy_open(self_path, *args, **kwargs): # type: ignore[no-untyped-def]
526
+ if str(self_path).endswith(".tmp"):
527
+ captured.append(str(self_path))
528
+ return real_open(self_path, *args, **kwargs)
529
+
530
+ with mock.patch.object(Path, "open", spy_open):
531
+ prompt_context.save_state(self.root, {"version": 1, "sessions": {"a": {}}})
532
+ prompt_context.save_state(self.root, {"version": 1, "sessions": {"b": {}}})
533
+
534
+ self.assertEqual(len(captured), 2, "each save should open exactly one temp")
535
+ # The fixed legacy name would be "<state>.json.tmp"; ensure we moved off it.
536
+ legacy = str(self.state_file.with_suffix(".json.tmp"))
537
+ self.assertNotIn(legacy, captured, "must not use the fixed .json.tmp path")
538
+ # Two saves must use distinct temp names (no clobber).
539
+ self.assertNotEqual(captured[0], captured[1], "temp names must be unique per save")
540
+ # The pid should be embedded so concurrent processes do not collide.
541
+ for name in captured:
542
+ self.assertIn(str(prompt_context.os.getpid()), name)
543
+ # No temp debris remains, and the real file landed.
544
+ leftovers = list(self.state_file.parent.glob("*.tmp"))
545
+ self.assertEqual(leftovers, [], f"temp debris left behind: {leftovers}")
546
+ self.assertTrue(self.state_file.exists())
547
+
548
+ def test_save_state_failure_is_fail_open(self) -> None:
549
+ """An OSError during the write must not propagate (fail-open)."""
550
+
551
+ def boom(self_path, *args, **kwargs): # type: ignore[no-untyped-def]
552
+ raise OSError("disk full")
553
+
554
+ with mock.patch.object(Path, "open", boom):
555
+ # Must not raise.
556
+ prompt_context.save_state(self.root, {"version": 1, "sessions": {}})
557
+
558
+ def test_state_lock_acquires_and_releases(self) -> None:
559
+ """When fcntl is present, _state_lock locks then unlocks the lock file."""
560
+ self.assertIsNotNone(
561
+ prompt_context.fcntl, "this platform has fcntl; test the real lock path"
562
+ )
563
+ calls: list[int] = []
564
+ real_flock = prompt_context.fcntl.flock
565
+
566
+ def spy_flock(fd, op): # type: ignore[no-untyped-def]
567
+ calls.append(op)
568
+ return real_flock(fd, op)
569
+
570
+ with mock.patch.object(prompt_context.fcntl, "flock", spy_flock):
571
+ with prompt_context._state_lock(self.root):
572
+ pass
573
+
574
+ self.assertIn(prompt_context.fcntl.LOCK_EX, calls, "should take an exclusive lock")
575
+ self.assertIn(prompt_context.fcntl.LOCK_UN, calls, "should release the lock")
576
+ # The lock file was created next to the state file.
577
+ self.assertTrue(self.state_file.with_suffix(".lock").exists())
578
+
579
+ def test_state_lock_degrades_without_fcntl(self) -> None:
580
+ """With fcntl monkeypatched out, _state_lock is a graceful no-op."""
581
+ with mock.patch.object(prompt_context, "fcntl", None):
582
+ entered = False
583
+ with prompt_context._state_lock(self.root):
584
+ entered = True
585
+ self.assertTrue(entered, "context body must still run without fcntl")
586
+ # No lock file should be created in the fcntl-less path.
587
+ self.assertFalse(self.state_file.with_suffix(".lock").exists())
588
+
589
+ def test_state_lock_failopen_on_flock_error(self) -> None:
590
+ """A flock failure must not block/crash — body still runs (unlocked)."""
591
+
592
+ def boom(fd, op): # type: ignore[no-untyped-def]
593
+ raise OSError("flock unavailable")
594
+
595
+ with mock.patch.object(prompt_context.fcntl, "flock", boom):
596
+ entered = False
597
+ with prompt_context._state_lock(self.root):
598
+ entered = True
599
+ self.assertTrue(entered, "must proceed unlocked on flock error")
600
+
601
+ def test_bump_epoch_works_without_fcntl(self) -> None:
602
+ """The full read-modify-write still works when fcntl is unavailable."""
603
+ with mock.patch.object(prompt_context, "fcntl", None):
604
+ prompt_context.bump_epoch(
605
+ self.root,
606
+ {"session_id": "s1", "hook_event_name": "PostCompact", "source": "auto"},
607
+ )
608
+ state = prompt_context.load_state(self.root)
609
+ self.assertEqual(state["sessions"]["s1"]["epoch"], 1)
610
+
611
+ def test_concurrent_sessions_do_not_lose_entries(self) -> None:
612
+ """Two sessions writing under the lock must both survive.
613
+
614
+ Simulates the interleave by hand: each call is a full locked
615
+ load -> mutate -> save. Because each acquires the lock and re-loads,
616
+ neither clobbers the other's session entry (the whole-file
617
+ last-writer-wins race the lock removes).
618
+ """
619
+ prompt_context.bump_epoch(
620
+ self.root,
621
+ {"session_id": "alpha", "hook_event_name": "SessionStart", "source": "startup"},
622
+ )
623
+ prompt_context.bump_epoch(
624
+ self.root,
625
+ {"session_id": "beta", "hook_event_name": "PostCompact", "source": "auto"},
626
+ )
627
+ state = prompt_context.load_state(self.root)
628
+ self.assertIn("alpha", state["sessions"])
629
+ self.assertIn("beta", state["sessions"])
630
+ self.assertEqual(state["sessions"]["beta"]["epoch"], 1)
631
+
632
+ def test_process_interleave_does_not_clobber_postcompact_epoch(self) -> None:
633
+ """A stale rules writer cannot overwrite a PostCompact bump.
634
+
635
+ This uses two real Python processes so the fcntl lock is exercised as an
636
+ inter-process lock. P1 enters the rules read-modify-write and pauses
637
+ immediately after `load_state` while still holding the lock. P2 attempts
638
+ the PostCompact epoch bump and must wait. When P1 is released, P2 loads
639
+ the post-P1 state and bumps the epoch, so the final state keeps the
640
+ PostCompact epoch instead of being clobbered by P1's stale save.
641
+ """
642
+ if prompt_context.fcntl is None:
643
+ self.skipTest("requires fcntl for an inter-process lock")
644
+
645
+ conventions = self.root / ".work" / "CONVENTIONS.md"
646
+ conventions.parent.mkdir(parents=True)
647
+ conventions.write_text("# Conventions\n", encoding="utf-8")
648
+ rules_dir = self.root / ".agents" / "rules"
649
+ rules_dir.mkdir(parents=True)
650
+ (rules_dir / "a.md").write_text("Rule A body", encoding="utf-8")
651
+
652
+ loaded_signal = self.root / "loaded.signal"
653
+ release_signal = self.root / "release.signal"
654
+ module_path = str(_MODULE_PATH)
655
+ root_path = str(self.root)
656
+ state_path = str(self.state_file)
657
+
658
+ rules_worker = f"""
659
+ import importlib.util
660
+ import time
661
+ from pathlib import Path
662
+
663
+ module_path = Path({module_path!r})
664
+ root = Path({root_path!r})
665
+ state_file = Path({state_path!r})
666
+ loaded_signal = Path({str(loaded_signal)!r})
667
+ release_signal = Path({str(release_signal)!r})
668
+
669
+ spec = importlib.util.spec_from_file_location("prompt_context_worker", module_path)
670
+ prompt_context = importlib.util.module_from_spec(spec)
671
+ spec.loader.exec_module(prompt_context)
672
+ prompt_context.state_path = lambda _root: state_file
673
+ real_load_state = prompt_context.load_state
674
+
675
+ def paused_load_state(load_root):
676
+ state = real_load_state(load_root)
677
+ loaded_signal.write_text("loaded", encoding="utf-8")
678
+ deadline = time.monotonic() + 5
679
+ while not release_signal.exists():
680
+ if time.monotonic() > deadline:
681
+ raise TimeoutError("release signal was not written")
682
+ time.sleep(0.02)
683
+ return state
684
+
685
+ prompt_context.load_state = paused_load_state
686
+ prompt_context.emit_rules(root, {{"session_id": "race"}})
687
+ """
688
+
689
+ postcompact_worker = f"""
690
+ import importlib.util
691
+ from pathlib import Path
692
+
693
+ module_path = Path({module_path!r})
694
+ root = Path({root_path!r})
695
+ state_file = Path({state_path!r})
696
+
697
+ spec = importlib.util.spec_from_file_location("prompt_context_worker", module_path)
698
+ prompt_context = importlib.util.module_from_spec(spec)
699
+ spec.loader.exec_module(prompt_context)
700
+ prompt_context.state_path = lambda _root: state_file
701
+ prompt_context.bump_epoch(root, {{"session_id": "race", "hook_event_name": "PostCompact", "source": "auto"}})
702
+ """
703
+
704
+ p1 = subprocess.Popen(
705
+ [sys.executable, "-c", rules_worker],
706
+ stdout=subprocess.DEVNULL,
707
+ stderr=subprocess.DEVNULL,
708
+ )
709
+ try:
710
+ deadline = time.monotonic() + 5
711
+ while not loaded_signal.exists():
712
+ if p1.poll() is not None:
713
+ self.fail(f"rules worker exited early with {p1.returncode}")
714
+ if time.monotonic() > deadline:
715
+ self.fail("rules worker did not reach the load barrier")
716
+ time.sleep(0.02)
717
+
718
+ p2 = subprocess.Popen(
719
+ [sys.executable, "-c", postcompact_worker],
720
+ stdout=subprocess.DEVNULL,
721
+ stderr=subprocess.DEVNULL,
722
+ )
723
+ try:
724
+ time.sleep(0.15)
725
+ self.assertIsNone(
726
+ p2.poll(),
727
+ "PostCompact worker should be blocked on the state lock until P1 saves",
728
+ )
729
+ release_signal.write_text("release", encoding="utf-8")
730
+ self.assertEqual(p1.wait(timeout=5), 0)
731
+ self.assertEqual(p2.wait(timeout=5), 0)
732
+ finally:
733
+ if p2.poll() is None:
734
+ p2.kill()
735
+ p2.wait()
736
+ finally:
737
+ if p1.poll() is None:
738
+ release_signal.write_text("release", encoding="utf-8")
739
+ p1.kill()
740
+ p1.wait()
741
+
742
+ state = prompt_context.load_state(self.root)
743
+ session = state["sessions"]["race"]
744
+ self.assertEqual(session["epoch"], 1)
745
+ self.assertEqual(session["seen"], {})
746
+
747
+ def test_save_state_roundtrip(self) -> None:
748
+ """save_state -> load_state preserves data (atomic replace lands)."""
749
+ prompt_context.save_state(
750
+ self.root, {"version": 1, "sessions": {"x": {"epoch": 3, "seen": {}}}}
751
+ )
752
+ loaded = prompt_context.load_state(self.root)
753
+ self.assertEqual(loaded["sessions"]["x"]["epoch"], 3)
754
+
755
+
756
+ if __name__ == "__main__":
757
+ unittest.main()