@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,628 @@
1
+ #!/usr/bin/env python3
2
+ """Prompt-gated agile-workflow context for Codex and Claude plugin hooks."""
3
+
4
+ from __future__ import annotations
5
+
6
+ import contextlib
7
+ import hashlib
8
+ import json
9
+ import os
10
+ import re
11
+ import subprocess
12
+ import sys
13
+ import tempfile
14
+ import time
15
+ from pathlib import Path
16
+ from typing import Any, Iterator
17
+
18
+ try: # POSIX advisory file locking; absent on some platforms (e.g. Windows).
19
+ import fcntl
20
+ except ImportError: # pragma: no cover - exercised via monkeypatch in tests.
21
+ fcntl = None # type: ignore[assignment]
22
+
23
+ MAX_SESSIONS = 20
24
+
25
+ STRONG_ACTION_RE = re.compile(
26
+ r"\b("
27
+ r"agile-workflow|autopilot|drain|epicize|depends_on|work item"
28
+ r")\b",
29
+ re.IGNORECASE,
30
+ )
31
+ POLY_WORKFLOW_ACTION_RE = re.compile(r"\b(ready|blocked|release|deploy|gate|queue|done|convert|ideate)\b", re.IGNORECASE)
32
+ POLY_WORKFLOW_CONTEXT_RE = re.compile(r"\b(epic|feature|story|item|items|stage|workflow|agile-workflow|depends_on)\b", re.IGNORECASE)
33
+ ITEM_VERB_RE = re.compile(
34
+ r"\b(implement|fix|patch|design|scope|park|refactor|perf|optimi[sz]e|review|verdict|done)\b",
35
+ re.IGNORECASE,
36
+ )
37
+ # A one-word imperative like "implement" is a high-intent workflow command in
38
+ # an agile-workflow-enabled repo. Earlier gating required an explicit noun
39
+ # ("implement item"), which was too conservative: the hook is already gated by
40
+ # `.work/CONVENTIONS.md`, and principles capsules are small + per-session deduped.
41
+ SHORT_WORKFLOW_COMMAND_RE = re.compile(
42
+ r"^\s*(implement|fix|patch|design|scope|park|refactor|review|done|release|deploy|gate|convert|ideate|epicize|autopilot|perf|optimi[sz]e)\s*[.!?]*\s*$",
43
+ re.IGNORECASE,
44
+ )
45
+ WORKFLOW_NOUN_RE = re.compile(r"\b(epic|feature|story|item|items|backlog|ready|queue|stage|workflow)\b", re.IGNORECASE)
46
+ ITEM_REF_SHAPE_RE = re.compile(r"\b[a-z0-9]+-[a-z0-9][a-z0-9-]*\b", re.IGNORECASE)
47
+ REVIEW_STAGE_RE = re.compile(r"\b(at|in)\s+review\b|\breview\s+(queue|everything|all)\b", re.IGNORECASE)
48
+ SLASH_RE = re.compile(
49
+ r"(^|\s)/("
50
+ r"agile-workflow:[a-z0-9-]+|"
51
+ r"autopilot|review|implement|implement-orchestrator|fix|scope|park|"
52
+ r"release-deploy|gate-[a-z0-9-]+|epic-design|feature-design|"
53
+ r"refactor-design|perf-design|convert|ideate|epicize"
54
+ r")\b",
55
+ re.IGNORECASE,
56
+ )
57
+ SKILL_MENTION_RE = re.compile(r"(^|\s)\$agile-workflow(:[a-z0-9-]+)?\b", re.IGNORECASE)
58
+ EXPLAIN_RE = re.compile(
59
+ r"^\s*(explain|describe|what is|what are|how does|why|eli5|simplify)\b",
60
+ re.IGNORECASE,
61
+ )
62
+ QUEUE_QUERY_RE = re.compile(
63
+ r"\b(what'?s|what is|show|list|next)\s+(ready|blocked|in review|at review)\b|"
64
+ r"\b(what'?s|what is|which|show|list|next)\b.*\b(backlog|queue|item|items|story|feature|epic)\b",
65
+ re.IGNORECASE,
66
+ )
67
+
68
+ DEFAULT_RULES_MAX_BYTES = 12000
69
+
70
+ CAPSULES = {
71
+ "code_design": {
72
+ "title": "Code-design capsule",
73
+ "triggers": re.compile(
74
+ r"\b(design|implement|fix|patch|scope|refactor|perf|optimi[sz]e|story|feature|bug)\b",
75
+ re.IGNORECASE,
76
+ ),
77
+ "text": [
78
+ "Ports & Adapters: domain logic stays independent of DB/filesystem/HTTP/time/randomness.",
79
+ "Single Source of Truth: growing variant sets have one registry; types, validation, routing, and display derive from it.",
80
+ "Generated Contracts: boundary types come from schema/router/DB inference or generation instead of hand copies.",
81
+ "Fail Fast: unknown input is validated at system boundaries and internal preconditions are asserted early.",
82
+ ],
83
+ },
84
+ "dispatch_economy": {
85
+ "title": "Dispatch-economy capsule",
86
+ "triggers": re.compile(
87
+ r"\b(autopilot|drain|implement-orchestrator|parallel|wave|explore|audit|gate|bundle|fan-?out)\b",
88
+ re.IGNORECASE,
89
+ ),
90
+ "text": [
91
+ "Local probes with rg/read/work-view come before read-only agents.",
92
+ "Agents are for breadth, isolation, fresh judgment, or independent write ownership.",
93
+ "Parallelism follows ownership and dependency layers rather than item count.",
94
+ "Dispatch rationale belongs in run notes or the item body when it affects bundling or wave width.",
95
+ ],
96
+ },
97
+ "advisory_review": {
98
+ "title": "Advisory-review capsule",
99
+ "triggers": re.compile(
100
+ r"\b(review|verdict|complete|done|risky|architecture|epic|feature|autopilot|final review)\b",
101
+ re.IGNORECASE,
102
+ ),
103
+ "text": [
104
+ "Cross-model peer review applies only when a different model class is available.",
105
+ "Same-model review uses a fresh-context sub-agent rather than inline self-review.",
106
+ "Stories fast-advance on verification; features and epics get deeper review.",
107
+ "Advisory review is non-blocking during design, but final autopilot completion needs a successful review path.",
108
+ ],
109
+ },
110
+ }
111
+
112
+
113
+ def load_payload() -> dict[str, Any]:
114
+ raw = sys.stdin.read()
115
+ if not raw.strip():
116
+ return {}
117
+ try:
118
+ value = json.loads(raw)
119
+ except json.JSONDecodeError:
120
+ return {}
121
+ return value if isinstance(value, dict) else {}
122
+
123
+
124
+ def find_substrate_root(cwd: str | None) -> Path | None:
125
+ start = Path(cwd or os.environ.get("CLAUDE_PROJECT_DIR") or os.getcwd()).resolve()
126
+ for candidate in (start, *start.parents):
127
+ if (candidate / ".work" / "CONVENTIONS.md").is_file():
128
+ return candidate
129
+ return None
130
+
131
+
132
+ def state_path(root: Path) -> Path:
133
+ plugin_data = os.environ.get("PLUGIN_DATA") or os.environ.get("CLAUDE_PLUGIN_DATA")
134
+ digest = hashlib.sha256(str(root).encode("utf-8")).hexdigest()[:16]
135
+ name = re.sub(r"[^a-zA-Z0-9_.-]+", "-", root.name).strip("-") or "workspace"
136
+ if plugin_data:
137
+ return Path(plugin_data) / "agile-workflow" / f"{name}-{digest}" / "principles-hook-state.json"
138
+ state_home = os.environ.get("XDG_STATE_HOME")
139
+ if state_home:
140
+ return Path(state_home) / "agile-workflow" / f"{name}-{digest}" / "principles-hook-state.json"
141
+ home = Path.home()
142
+ if str(home) and str(home) != "/":
143
+ return home / ".local" / "state" / "agile-workflow" / f"{name}-{digest}" / "principles-hook-state.json"
144
+ return Path(tempfile.gettempdir()) / "agile-workflow" / f"{name}-{digest}" / "principles-hook-state.json"
145
+
146
+
147
+ def load_state(root: Path) -> dict[str, Any]:
148
+ path = state_path(root)
149
+ try:
150
+ with path.open("r", encoding="utf-8") as fh:
151
+ value = json.load(fh)
152
+ except (OSError, json.JSONDecodeError):
153
+ return {"version": 1, "sessions": {}}
154
+ if not isinstance(value, dict):
155
+ return {"version": 1, "sessions": {}}
156
+ value.setdefault("version", 1)
157
+ value.setdefault("sessions", {})
158
+ return value
159
+
160
+
161
+ def save_state(root: Path, state: dict[str, Any]) -> None:
162
+ path = state_path(root)
163
+ path.parent.mkdir(parents=True, exist_ok=True)
164
+ # Per-writer unique temp path (pid + random token) so overlapping writers do
165
+ # not clobber each other's temp before the atomic os.replace. A fixed
166
+ # ".json.tmp" would race: two processes writing it concurrently could rename
167
+ # a half-written or interleaved file into place.
168
+ token = os.urandom(8).hex()
169
+ tmp = path.with_suffix(f".{os.getpid()}.{token}.tmp")
170
+ try:
171
+ with tmp.open("w", encoding="utf-8") as fh:
172
+ json.dump(state, fh, indent=2, sort_keys=True)
173
+ fh.write("\n")
174
+ os.replace(tmp, path)
175
+ except OSError:
176
+ # Fail open: a failed save must never crash the hook. Best-effort cleanup
177
+ # of our uniquely-named temp so we don't leave debris behind.
178
+ with contextlib.suppress(OSError):
179
+ tmp.unlink()
180
+
181
+
182
+ @contextlib.contextmanager
183
+ def _state_lock(root: Path) -> Iterator[None]:
184
+ """Serialize the state-file read-modify-write across concurrent hook procs.
185
+
186
+ Holds an advisory ``fcntl.flock`` on a sibling ``.lock`` file for the whole
187
+ load -> mutate -> save cycle so two processes cannot lose each other's
188
+ session entries (whole-file last-writer-wins). Degrades gracefully:
189
+
190
+ - Where ``fcntl`` is unavailable (import-guarded above), this is a no-op and
191
+ we fall back to the prior best-effort behavior.
192
+ - Any locking failure (cannot open/create the lock file, flock errors) is
193
+ swallowed — the hook must stay fail-open and still exit 0. We never block
194
+ the hook on lock acquisition errors.
195
+ """
196
+ if fcntl is None:
197
+ yield
198
+ return
199
+ lock_path = state_path(root).with_suffix(".lock")
200
+ handle = None
201
+ try:
202
+ lock_path.parent.mkdir(parents=True, exist_ok=True)
203
+ handle = lock_path.open("a+")
204
+ fcntl.flock(handle.fileno(), fcntl.LOCK_EX)
205
+ except OSError:
206
+ # Could not acquire the lock — proceed unlocked rather than block/crash.
207
+ if handle is not None:
208
+ with contextlib.suppress(OSError):
209
+ handle.close()
210
+ yield
211
+ return
212
+ try:
213
+ yield
214
+ finally:
215
+ with contextlib.suppress(OSError):
216
+ fcntl.flock(handle.fileno(), fcntl.LOCK_UN)
217
+ with contextlib.suppress(OSError):
218
+ handle.close()
219
+
220
+
221
+ def session_entry(state: dict[str, Any], session_id: str) -> dict[str, Any]:
222
+ sessions = state.setdefault("sessions", {})
223
+ entry = sessions.setdefault(session_id, {})
224
+ entry.setdefault("epoch", 0)
225
+ entry.setdefault("seen", {})
226
+ entry["last_seen"] = time.time()
227
+ if len(sessions) > MAX_SESSIONS:
228
+ ordered = sorted(sessions.items(), key=lambda item: int(item[1].get("last_seen") or 0))
229
+ for old_session, _ in ordered[: len(sessions) - MAX_SESSIONS]:
230
+ if old_session != session_id:
231
+ sessions.pop(old_session, None)
232
+ return entry
233
+
234
+
235
+ def bump_epoch(root: Path, payload: dict[str, Any]) -> None:
236
+ session_id = str(payload.get("session_id") or "unknown")
237
+ event = str(payload.get("hook_event_name") or "")
238
+ source = str(payload.get("source") or payload.get("trigger") or "")
239
+ with _state_lock(root):
240
+ state = load_state(root)
241
+ entry = session_entry(state, session_id)
242
+ if event == "SessionStart" and source in {"startup", "clear", ""}:
243
+ entry["epoch"] = 0
244
+ entry["seen"] = {}
245
+ elif event == "PostCompact" or source in {"resume", "compact"}:
246
+ entry["epoch"] = int(entry.get("epoch") or 0) + 1
247
+ entry["seen"] = {}
248
+ save_state(root, state)
249
+
250
+
251
+ def output_context(event_name: str, text: str) -> None:
252
+ if not text.strip():
253
+ return
254
+ print(
255
+ json.dumps(
256
+ {
257
+ "hookSpecificOutput": {
258
+ "hookEventName": event_name,
259
+ "additionalContext": text,
260
+ }
261
+ }
262
+ )
263
+ )
264
+
265
+
266
+ def frontmatter(path: Path) -> dict[str, str]:
267
+ try:
268
+ lines = path.read_text(encoding="utf-8").splitlines()
269
+ except OSError:
270
+ return {}
271
+ if not lines or lines[0].strip() != "---":
272
+ return {}
273
+ fields: dict[str, str] = {}
274
+ for line in lines[1:]:
275
+ if line.strip() == "---":
276
+ break
277
+ if ":" not in line or line.startswith((" ", "\t", "-")):
278
+ continue
279
+ key, value = line.split(":", 1)
280
+ fields[key.strip()] = value.strip().strip('"').strip("'")
281
+ return fields
282
+
283
+
284
+ def item_files(root: Path) -> list[Path]:
285
+ work = root / ".work"
286
+ files: list[Path] = []
287
+ for rel in ("active", "backlog", "releases", "archive"):
288
+ base = work / rel
289
+ if base.exists():
290
+ files.extend(sorted(base.rglob("*.md")))
291
+ return files
292
+
293
+
294
+ def item_index(root: Path) -> dict[str, Path]:
295
+ index: dict[str, Path] = {}
296
+ for path in item_files(root):
297
+ fields = frontmatter(path)
298
+ item_id = fields.get("id") or path.stem
299
+ if item_id:
300
+ index[item_id] = path
301
+ return index
302
+
303
+
304
+ def matched_item_ids(prompt: str, ids: set[str]) -> set[str]:
305
+ found: set[str] = set()
306
+ for item_id in ids:
307
+ pattern = re.compile(rf"(?<![a-z0-9-]){re.escape(item_id.lower())}(?![a-z0-9-])")
308
+ if pattern.search(prompt.lower()):
309
+ found.add(item_id)
310
+ return found
311
+
312
+
313
+ def cheap_action_candidate(prompt: str) -> bool:
314
+ if EXPLAIN_RE.search(prompt) and not (QUEUE_QUERY_RE.search(prompt) or ITEM_REF_SHAPE_RE.search(prompt)):
315
+ return False
316
+ if SLASH_RE.search(prompt) or SKILL_MENTION_RE.search(prompt):
317
+ return True
318
+ if SHORT_WORKFLOW_COMMAND_RE.search(prompt):
319
+ return True
320
+ if QUEUE_QUERY_RE.search(prompt):
321
+ return True
322
+ if STRONG_ACTION_RE.search(prompt):
323
+ return True
324
+ if ITEM_REF_SHAPE_RE.search(prompt):
325
+ return True
326
+ if POLY_WORKFLOW_ACTION_RE.search(prompt) and POLY_WORKFLOW_CONTEXT_RE.search(prompt):
327
+ return True
328
+ return bool(
329
+ ITEM_VERB_RE.search(prompt)
330
+ and (WORKFLOW_NOUN_RE.search(prompt) or ITEM_REF_SHAPE_RE.search(prompt) or REVIEW_STAGE_RE.search(prompt))
331
+ )
332
+
333
+
334
+ def is_actionable(prompt: str, matched_ids: set[str]) -> bool:
335
+ if matched_ids:
336
+ return True
337
+ if SLASH_RE.search(prompt) or SKILL_MENTION_RE.search(prompt):
338
+ return True
339
+ if SHORT_WORKFLOW_COMMAND_RE.search(prompt):
340
+ return True
341
+ if QUEUE_QUERY_RE.search(prompt) or STRONG_ACTION_RE.search(prompt):
342
+ return True
343
+ if POLY_WORKFLOW_ACTION_RE.search(prompt) and POLY_WORKFLOW_CONTEXT_RE.search(prompt):
344
+ return True
345
+ return bool(
346
+ ITEM_VERB_RE.search(prompt)
347
+ and (WORKFLOW_NOUN_RE.search(prompt) or REVIEW_STAGE_RE.search(prompt))
348
+ )
349
+
350
+
351
+ def plugin_root() -> Path | None:
352
+ pr = os.environ.get("PLUGIN_ROOT") or os.environ.get("CLAUDE_PLUGIN_ROOT")
353
+ return Path(pr) if pr else None
354
+
355
+
356
+ def is_codex_hook_environment() -> bool:
357
+ return bool(os.environ.get("PLUGIN_ROOT") or os.environ.get("PLUGIN_DATA"))
358
+
359
+
360
+ def plugin_version(pr: Path) -> str | None:
361
+ try:
362
+ data = json.loads((pr / ".claude-plugin" / "plugin.json").read_text("utf-8"))
363
+ except (OSError, json.JSONDecodeError):
364
+ return None
365
+ if not isinstance(data, dict):
366
+ return None
367
+ version = data.get("version")
368
+ return version if isinstance(version, str) and version else None
369
+
370
+
371
+ def installed_version(root: Path) -> str | None:
372
+ work_view = root / ".work" / "bin" / "work-view"
373
+ if not work_view.is_file() or not os.access(work_view, os.X_OK):
374
+ return None
375
+ try:
376
+ result = subprocess.run(
377
+ [str(work_view), "--version"],
378
+ cwd=str(root),
379
+ text=True,
380
+ stdout=subprocess.PIPE,
381
+ stderr=subprocess.DEVNULL,
382
+ timeout=5,
383
+ check=False,
384
+ )
385
+ except (OSError, subprocess.TimeoutExpired):
386
+ return None
387
+ if result.returncode != 0:
388
+ return None
389
+ out = result.stdout.strip()
390
+ return out.split()[-1] if out.startswith("work-view ") else None
391
+
392
+
393
+ def run_installer(root: Path, pr: Path) -> None:
394
+ installer = pr / "scripts" / "install-work-view.sh"
395
+ if not installer.is_file():
396
+ return
397
+ with contextlib.suppress(OSError, subprocess.TimeoutExpired):
398
+ subprocess.run(
399
+ ["bash", str(installer)],
400
+ cwd=str(root),
401
+ env={**os.environ, "PLUGIN_ROOT": str(pr)},
402
+ stdout=subprocess.DEVNULL,
403
+ stderr=subprocess.DEVNULL,
404
+ timeout=20,
405
+ check=False,
406
+ )
407
+
408
+
409
+ def self_heal_work_view(root: Path, event: str) -> None:
410
+ try:
411
+ pr = plugin_root()
412
+ if pr is None:
413
+ return
414
+ work_view = root / ".work" / "bin" / "work-view"
415
+ present = work_view.is_file() and os.access(work_view, os.X_OK)
416
+ if event == "UserPromptSubmit":
417
+ if not present:
418
+ run_installer(root, pr)
419
+ return
420
+
421
+ if not present:
422
+ run_installer(root, pr)
423
+ return
424
+ want = plugin_version(pr)
425
+ if want is None:
426
+ return
427
+ if installed_version(root) != want:
428
+ run_installer(root, pr)
429
+ except Exception:
430
+ return
431
+
432
+
433
+ def capsule_keys(prompt: str, matched_ids_set: set[str], index: dict[str, Path]) -> list[str]:
434
+ keys = [key for key, spec in CAPSULES.items() if spec["triggers"].search(prompt)]
435
+ if matched_ids_set:
436
+ for item_id in matched_ids_set:
437
+ fields = frontmatter(index.get(item_id, Path()))
438
+ kind = fields.get("kind")
439
+ stage = fields.get("stage")
440
+ if stage in {"drafting", "implementing"} and "code_design" not in keys:
441
+ keys.append("code_design")
442
+ if stage == "review" and "advisory_review" not in keys:
443
+ keys.append("advisory_review")
444
+ if kind in {"feature", "epic"} and "advisory_review" not in keys:
445
+ keys.append("advisory_review")
446
+ return keys
447
+
448
+
449
+ def unseen_capsules(root: Path, payload: dict[str, Any], keys: list[str]) -> list[str]:
450
+ if not keys:
451
+ return []
452
+ session_id = str(payload.get("session_id") or "unknown")
453
+ with _state_lock(root):
454
+ state = load_state(root)
455
+ entry = session_entry(state, session_id)
456
+ epoch = int(entry.get("epoch") or 0)
457
+ seen = entry.setdefault("seen", {})
458
+ unseen = [key for key in keys if seen.get(key) != epoch]
459
+ for key in unseen:
460
+ seen[key] = epoch
461
+ save_state(root, state)
462
+ return unseen
463
+
464
+
465
+ def format_capsules(keys: list[str]) -> str:
466
+ if not keys:
467
+ return ""
468
+ lines = ["## Agile Workflow Principles"]
469
+ for key in keys:
470
+ spec = CAPSULES[key]
471
+ lines.append(f"{spec['title']}:")
472
+ lines.extend(f"- {line}" for line in spec["text"])
473
+ return "\n".join(lines)
474
+
475
+
476
+ def rules_config(root: Path) -> tuple[bool, int]:
477
+ """Parse `.work/CONVENTIONS.md` for the rules-loader knobs.
478
+
479
+ `rules_context: on|off` (default on) and `rules_context_max_bytes: <int>`
480
+ (default DEFAULT_RULES_MAX_BYTES). Tolerant: any read/parse failure returns
481
+ the enabled defaults so a malformed CONVENTIONS never silences rules.
482
+ """
483
+ enabled = True
484
+ max_bytes = DEFAULT_RULES_MAX_BYTES
485
+ try:
486
+ text = (root / ".work" / "CONVENTIONS.md").read_text(encoding="utf-8")
487
+ except (OSError, UnicodeDecodeError):
488
+ return enabled, max_bytes
489
+ for raw in text.splitlines():
490
+ line = raw.strip().lstrip("-").strip()
491
+ # Anchor the value so prose like "rules_context: off disables injection"
492
+ # in a CONVENTIONS example does not silently flip the flag.
493
+ flag = re.match(
494
+ r"rules_context\s*:\s*(on|off|true|false|yes|no|0|1)\s*$", line, re.IGNORECASE
495
+ )
496
+ if flag:
497
+ enabled = flag.group(1).lower() not in {"off", "false", "no", "0"}
498
+ continue
499
+ cap = re.match(r"rules_context_max_bytes\s*:\s*(\d+)\s*$", line, re.IGNORECASE)
500
+ if cap:
501
+ value = int(cap.group(1))
502
+ if value > 0: # 0/invalid keeps the default; use `rules_context: off` to disable
503
+ max_bytes = value
504
+ return enabled, max_bytes
505
+
506
+
507
+ def read_rules_dir(root: Path, max_bytes: int) -> tuple[str, str]:
508
+ """Read `<root>/.agents/rules/*.md` (sorted) into one injectable block.
509
+
510
+ Returns ``(text, sha256)`` where the hash is over the UNtruncated
511
+ concatenation so any edit re-injects. Truncates the emitted text at
512
+ ``max_bytes`` with a notice. Returns ``("", "")`` when the dir is absent or
513
+ has no readable markdown content.
514
+ """
515
+ rules_dir = root / ".agents" / "rules"
516
+ if not rules_dir.is_dir():
517
+ return "", ""
518
+ chunks: list[str] = []
519
+ for path in sorted(rules_dir.glob("*.md")):
520
+ try:
521
+ body = path.read_text(encoding="utf-8").strip()
522
+ except OSError:
523
+ continue
524
+ if body:
525
+ chunks.append(body)
526
+ if not chunks:
527
+ return "", ""
528
+ body = "\n\n".join(chunks)
529
+ digest = hashlib.sha256(body.encode("utf-8")).hexdigest()
530
+ if max_bytes and len(body.encode("utf-8")) > max_bytes:
531
+ clipped = body.encode("utf-8")[:max_bytes].decode("utf-8", "ignore").rstrip()
532
+ body = clipped + "\n\n(.agents/rules truncated — read the files for full content)"
533
+ return "## Project Rules (.agents/rules/)\n" + body, digest
534
+
535
+
536
+ def rules_unseen(root: Path, payload: dict[str, Any], content_hash: str) -> bool:
537
+ """True iff (epoch, content_hash) has not yet been injected this session.
538
+
539
+ Reuses the epoch state; stores ``seen['rules'] = f'{epoch}:{hash}'`` so rules
540
+ re-inject after a PostCompact epoch bump or when `.agents/rules/` content
541
+ changes, but only once per (epoch, content) otherwise.
542
+ """
543
+ session_id = str(payload.get("session_id") or "unknown")
544
+ with _state_lock(root):
545
+ state = load_state(root)
546
+ entry = session_entry(state, session_id)
547
+ marker = f"{int(entry.get('epoch') or 0)}:{content_hash}"
548
+ seen = entry.setdefault("seen", {})
549
+ if seen.get("rules") == marker:
550
+ return False
551
+ seen["rules"] = marker
552
+ save_state(root, state)
553
+ return True
554
+
555
+
556
+ def emit_rules(root: Path, payload: dict[str, Any], *, force: bool = False) -> str:
557
+ """Return `.agents/rules/` context to inject, or "".
558
+
559
+ Claude/Codex SessionStart/PostCompact hooks dedup once per epoch via
560
+ ``rules_unseen`` because hook output is host-managed context. Pi rebuilds the
561
+ system prompt every turn and has no hooks.json package surface, so its native
562
+ extension calls the synthetic ``PiBeforeAgentStart`` path with ``force=True``
563
+ to append the same rules block every turn without mutating hook state.
564
+ """
565
+ enabled, max_bytes = rules_config(root)
566
+ if not enabled:
567
+ return ""
568
+ text, digest = read_rules_dir(root, max_bytes)
569
+ if not text:
570
+ return ""
571
+ if not force and not rules_unseen(root, payload, digest):
572
+ return ""
573
+ return text
574
+
575
+
576
+ def main() -> int:
577
+ payload = load_payload()
578
+ event = str(payload.get("hook_event_name") or "")
579
+ root = find_substrate_root(payload.get("cwd"))
580
+ if root is None:
581
+ return 0
582
+
583
+ # Pi package parity adapter: Pi has native extension events rather than a
584
+ # hooks.json surface. The extension calls this synthetic path from
585
+ # before_agent_start so it can append the exact same .agents/rules block to
586
+ # Pi's rebuilt-per-turn system prompt without disturbing Claude/Codex epoch
587
+ # dedup state.
588
+ if event == "PiBeforeAgentStart":
589
+ output_context("SessionStart", emit_rules(root, payload, force=True))
590
+ return 0
591
+
592
+ # Primary rules firing: SessionStart / PostCompact emit `.agents/rules/`
593
+ # directly where the host supports hook-specific context. Codex accepts
594
+ # context on SessionStart but not PostCompact, so Codex PostCompact only
595
+ # bumps the epoch and leaves context injection to SessionStart compact.
596
+ if event in {"SessionStart", "PostCompact"}:
597
+ bump_epoch(root, payload)
598
+ self_heal_work_view(root, event)
599
+ if event == "PostCompact" and is_codex_hook_environment():
600
+ return 0
601
+ output_context(event, emit_rules(root, payload))
602
+ return 0
603
+
604
+ if event != "UserPromptSubmit":
605
+ return 0
606
+
607
+ prompt = str(payload.get("prompt") or "")
608
+ self_heal_work_view(root, event)
609
+
610
+ parts: list[str] = []
611
+ # Prompt-time output is limited to principles capsules behind the workflow
612
+ # gate. Queue state is available through explicit work-view/board commands.
613
+ if cheap_action_candidate(prompt):
614
+ index = item_index(root)
615
+ matched = matched_item_ids(prompt, set(index))
616
+ if is_actionable(prompt, matched):
617
+ capsule_text = format_capsules(
618
+ unseen_capsules(root, payload, capsule_keys(prompt, matched, index))
619
+ )
620
+ if capsule_text:
621
+ parts.append(capsule_text)
622
+
623
+ output_context(event, "\n\n".join(p for p in parts if p))
624
+ return 0
625
+
626
+
627
+ if __name__ == "__main__":
628
+ raise SystemExit(main())