@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,359 @@
1
+ #!/usr/bin/env python3
2
+ """Maintain and validate agile-workflow substrate items after file edits."""
3
+
4
+ from __future__ import annotations
5
+
6
+ import json
7
+ import os
8
+ import re
9
+ import sys
10
+ from datetime import datetime
11
+ from pathlib import Path
12
+ from typing import Any
13
+
14
+ ACTIVE_REQUIRED = {
15
+ "id",
16
+ "kind",
17
+ "stage",
18
+ "tags",
19
+ "parent",
20
+ "depends_on",
21
+ "release_binding",
22
+ "gate_origin",
23
+ "created",
24
+ "updated",
25
+ }
26
+ BACKLOG_REQUIRED = {"id", "created", "tags"}
27
+ VALID_KIND = {"epic", "feature", "story", "release"}
28
+ VALID_STAGE = {
29
+ "epic": {"drafting", "implementing", "review", "done"},
30
+ "feature": {"drafting", "implementing", "review", "done"},
31
+ "story": {"drafting", "review", "implementing", "done"},
32
+ "release": {"planned", "quality-gate", "released"},
33
+ }
34
+ DATE_RE = re.compile(r"^\d{4}-\d{2}-\d{2}$")
35
+
36
+
37
+ def load_payload() -> dict[str, Any]:
38
+ raw = sys.stdin.read()
39
+ if not raw.strip():
40
+ return {}
41
+ try:
42
+ value = json.loads(raw)
43
+ except json.JSONDecodeError:
44
+ return {}
45
+ return value if isinstance(value, dict) else {}
46
+
47
+
48
+ def find_substrate_root(cwd: str | None) -> Path | None:
49
+ start = Path(cwd or os.environ.get("CLAUDE_PROJECT_DIR") or os.getcwd()).resolve()
50
+ for candidate in (start, *start.parents):
51
+ if (candidate / ".work" / "CONVENTIONS.md").is_file():
52
+ return candidate
53
+ return None
54
+
55
+
56
+ def parse_patch_paths(text: str) -> list[str]:
57
+ paths: list[str] = []
58
+ for line in text.splitlines():
59
+ match = re.match(r"^\*\*\* (?:Add|Update|Delete) File: (.+)$", line)
60
+ if match:
61
+ paths.append(match.group(1).strip())
62
+ continue
63
+ match = re.match(r"^\*\*\* Move to: (.+)$", line)
64
+ if match:
65
+ paths.append(match.group(1).strip())
66
+ return paths
67
+
68
+
69
+ def extract_paths(payload: dict[str, Any]) -> list[Path]:
70
+ cwd = Path(str(payload.get("cwd") or os.getcwd())).resolve()
71
+ tool_input = payload.get("tool_input")
72
+ if not isinstance(tool_input, dict):
73
+ tool_input = {}
74
+
75
+ raw_paths: list[str] = []
76
+ for key in ("file_path", "path"):
77
+ value = tool_input.get(key)
78
+ if isinstance(value, str):
79
+ raw_paths.append(value)
80
+ for key in ("file_paths", "paths"):
81
+ value = tool_input.get(key)
82
+ if isinstance(value, list):
83
+ raw_paths.extend(str(item) for item in value if isinstance(item, str))
84
+ for key in ("command", "patch"):
85
+ value = tool_input.get(key)
86
+ if isinstance(value, str):
87
+ raw_paths.extend(parse_patch_paths(value))
88
+
89
+ paths: list[Path] = []
90
+ seen: set[Path] = set()
91
+ for raw in raw_paths:
92
+ path = Path(raw)
93
+ if not path.is_absolute():
94
+ path = cwd / path
95
+ try:
96
+ resolved = path.resolve()
97
+ except OSError:
98
+ resolved = path.absolute()
99
+ if resolved not in seen:
100
+ paths.append(resolved)
101
+ seen.add(resolved)
102
+ return paths
103
+
104
+
105
+ def is_item_path(root: Path, path: Path) -> bool:
106
+ try:
107
+ rel = path.relative_to(root)
108
+ except ValueError:
109
+ return False
110
+ parts = rel.parts
111
+ if len(parts) < 3 or parts[0] != ".work" or path.suffix != ".md":
112
+ return False
113
+ return parts[1] in {"active", "backlog", "releases", "archive"}
114
+
115
+
116
+ def bumpable_path(root: Path, path: Path) -> bool:
117
+ try:
118
+ rel = path.relative_to(root)
119
+ except ValueError:
120
+ return False
121
+ parts = rel.parts
122
+ return len(parts) >= 3 and parts[0] == ".work" and parts[1] in {"active", "backlog"} and path.suffix == ".md"
123
+
124
+
125
+ def bump_updated(path: Path) -> None:
126
+ if not path.is_file():
127
+ return
128
+ try:
129
+ with path.open("r", encoding="utf-8", newline="") as fh:
130
+ lines = fh.read().splitlines(keepends=True)
131
+ except OSError:
132
+ return
133
+ if not lines or lines[0].strip() != "---":
134
+ return
135
+ today = datetime.now().strftime("%Y-%m-%d")
136
+ changed = False
137
+ for index in range(1, len(lines)):
138
+ if lines[index].strip() == "---":
139
+ break
140
+ if lines[index].startswith("updated:"):
141
+ line_ending = "\r\n" if lines[index].endswith("\r\n") else ("\n" if lines[index].endswith("\n") else "")
142
+ replacement = f"updated: {today}{line_ending}"
143
+ if lines[index] != replacement:
144
+ lines[index] = replacement
145
+ changed = True
146
+ break
147
+ if changed:
148
+ with path.open("w", encoding="utf-8", newline="") as fh:
149
+ fh.write("".join(lines))
150
+
151
+
152
+ def item_files(root: Path) -> list[Path]:
153
+ work = root / ".work"
154
+ files: list[Path] = []
155
+ for rel in ("active", "backlog", "releases", "archive"):
156
+ base = work / rel
157
+ if base.exists():
158
+ files.extend(sorted(base.rglob("*.md")))
159
+ return files
160
+
161
+
162
+ def parse_frontmatter(path: Path) -> dict[str, str]:
163
+ try:
164
+ lines = path.read_text(encoding="utf-8").splitlines()
165
+ except OSError:
166
+ return {}
167
+ if not lines or lines[0].strip() != "---":
168
+ return {}
169
+ fields: dict[str, str] = {}
170
+ for line in lines[1:]:
171
+ if line.strip() == "---":
172
+ break
173
+ if ":" not in line or line.startswith((" ", "\t", "-")):
174
+ continue
175
+ key, value = line.split(":", 1)
176
+ fields[key.strip()] = value.strip()
177
+ return fields
178
+
179
+
180
+ def clean_scalar(value: str | None) -> str:
181
+ if value is None:
182
+ return ""
183
+ value = value.strip()
184
+ if (value.startswith('"') and value.endswith('"')) or (value.startswith("'") and value.endswith("'")):
185
+ value = value[1:-1]
186
+ return value.strip()
187
+
188
+
189
+ def parse_array(value: str | None) -> list[str]:
190
+ raw = clean_scalar(value)
191
+ if raw in {"", "[]", "null"}:
192
+ return []
193
+ if raw.startswith("[") and raw.endswith("]"):
194
+ raw = raw[1:-1]
195
+ return [clean_scalar(item) for item in raw.split(",") if clean_scalar(item)]
196
+
197
+
198
+ def tier(root: Path, path: Path) -> str:
199
+ try:
200
+ rel = path.relative_to(root)
201
+ except ValueError:
202
+ return ""
203
+ return rel.parts[1] if len(rel.parts) > 1 and rel.parts[0] == ".work" else ""
204
+
205
+
206
+ def display_path(root: Path, path: Path) -> str:
207
+ try:
208
+ return str(path.relative_to(root))
209
+ except ValueError:
210
+ return str(path)
211
+
212
+
213
+ def validate(root: Path, changed: list[Path]) -> list[str]:
214
+ issues: list[str] = []
215
+ files = item_files(root)
216
+ by_id: dict[str, Path] = {}
217
+ ids_by_path: dict[Path, str] = {}
218
+ depends: dict[str, list[str]] = {}
219
+
220
+ for path in files:
221
+ fields = parse_frontmatter(path)
222
+ item_id = clean_scalar(fields.get("id"))
223
+ if not fields or not item_id:
224
+ continue
225
+ ids_by_path[path] = item_id
226
+ if item_id:
227
+ if item_id in by_id:
228
+ if path in changed or by_id[item_id] in changed:
229
+ issues.append(
230
+ f"{display_path(root, path)}: duplicate id {item_id} "
231
+ f"also used by {display_path(root, by_id[item_id])}"
232
+ )
233
+ by_id[item_id] = path
234
+
235
+ changed_existing = [path for path in changed if path.is_file()]
236
+ for path in changed_existing:
237
+ fields = parse_frontmatter(path)
238
+ rel = display_path(root, path)
239
+ item_id = clean_scalar(fields.get("id"))
240
+ item_tier = tier(root, path)
241
+ if not fields:
242
+ issues.append(f"{rel}: missing YAML frontmatter")
243
+ continue
244
+ required = BACKLOG_REQUIRED if item_tier == "backlog" else ACTIVE_REQUIRED
245
+ missing = sorted(field for field in required if field not in fields)
246
+ if missing:
247
+ issues.append(f"{rel}: missing required frontmatter fields: {', '.join(missing)}")
248
+ if item_id and item_id != path.stem:
249
+ issues.append(f"{rel}: id {item_id} does not match filename {path.stem}.md")
250
+ if "tags" in fields and not clean_scalar(fields.get("tags")).startswith("["):
251
+ issues.append(f"{rel}: tags must be a flow array, e.g. tags: [security]")
252
+ for date_field in ("created", "updated"):
253
+ if date_field in fields and not DATE_RE.match(clean_scalar(fields.get(date_field))):
254
+ issues.append(f"{rel}: {date_field} must be YYYY-MM-DD")
255
+
256
+ if item_tier != "backlog":
257
+ kind = clean_scalar(fields.get("kind"))
258
+ stage = clean_scalar(fields.get("stage"))
259
+ if kind and kind not in VALID_KIND:
260
+ issues.append(f"{rel}: invalid kind {kind}")
261
+ if kind in VALID_STAGE and stage and stage not in VALID_STAGE[kind]:
262
+ issues.append(f"{rel}: invalid stage {stage} for kind {kind}")
263
+ parent = clean_scalar(fields.get("parent"))
264
+ if parent and parent != "null" and parent not in by_id:
265
+ issues.append(f"{rel}: parent {parent} does not exist")
266
+ deps = parse_array(fields.get("depends_on"))
267
+ depends[item_id] = deps
268
+ for dep in deps:
269
+ if dep not in by_id:
270
+ issues.append(f"{rel}: depends_on {dep} does not exist")
271
+
272
+ for path in files:
273
+ item_id = ids_by_path.get(path)
274
+ if item_id and item_id not in depends:
275
+ depends[item_id] = parse_array(parse_frontmatter(path).get("depends_on"))
276
+
277
+ changed_ids = {ids_by_path[path] for path in changed_existing if path in ids_by_path}
278
+ issues.extend(detect_cycles(depends, changed_ids))
279
+ return issues
280
+
281
+
282
+ def detect_cycles(depends: dict[str, list[str]], roots: set[str]) -> list[str]:
283
+ issues: list[str] = []
284
+ seen: set[tuple[str, ...]] = set()
285
+
286
+ def normalized_cycle(cycle: list[str]) -> tuple[str, ...]:
287
+ body = cycle[:-1]
288
+ rotations = [tuple(body[index:] + body[:index]) for index in range(len(body))]
289
+ return min(rotations)
290
+
291
+ def record_cycle(cycle: list[str]) -> None:
292
+ key = normalized_cycle(cycle)
293
+ if key in seen:
294
+ return
295
+ seen.add(key)
296
+ canonical = list(key) + [key[0]]
297
+ issues.append(f"depends_on cycle detected: {' -> '.join(canonical)}")
298
+
299
+ def walk(item_id: str, stack: list[str], visited: set[str]) -> None:
300
+ if item_id in stack:
301
+ start = stack.index(item_id)
302
+ record_cycle(stack[start:] + [item_id])
303
+ return
304
+ if item_id in visited:
305
+ return
306
+ stack.append(item_id)
307
+ for dep in depends.get(item_id, []):
308
+ if dep in depends:
309
+ walk(dep, stack, visited)
310
+ stack.pop()
311
+ visited.add(item_id)
312
+
313
+ for item_id in sorted(roots):
314
+ walk(item_id, [], set())
315
+ return issues
316
+
317
+
318
+ def output_context(event_name: str, issues: list[str]) -> None:
319
+ if not issues:
320
+ return
321
+ shown = issues[:12]
322
+ hidden = len(issues) - len(shown)
323
+ lines = ["Agile Workflow substrate validation found issues:"]
324
+ lines.extend(f"- {issue}" for issue in shown)
325
+ if hidden:
326
+ lines.append(f"- ... {hidden} more")
327
+ print(
328
+ json.dumps(
329
+ {
330
+ "hookSpecificOutput": {
331
+ "hookEventName": event_name,
332
+ "additionalContext": "\n".join(lines),
333
+ }
334
+ }
335
+ )
336
+ )
337
+
338
+
339
+ def main() -> int:
340
+ payload = load_payload()
341
+ root = find_substrate_root(payload.get("cwd"))
342
+ if root is None:
343
+ return 0
344
+
345
+ changed = [path for path in extract_paths(payload) if is_item_path(root, path)]
346
+ if not changed:
347
+ return 0
348
+
349
+ for path in changed:
350
+ if bumpable_path(root, path):
351
+ bump_updated(path)
352
+
353
+ issues = validate(root, changed)
354
+ output_context(str(payload.get("hook_event_name") or "PostToolUse"), issues)
355
+ return 0
356
+
357
+
358
+ if __name__ == "__main__":
359
+ raise SystemExit(main())