@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.
- package/.claude-plugin/plugin.json +10 -0
- package/.codex-plugin/plugin.json +28 -0
- package/CHANGELOG.md +591 -0
- package/README.md +107 -0
- package/docs/ARCHITECTURE.md +716 -0
- package/docs/MIGRATION.md +419 -0
- package/docs/PRINCIPLES.md +464 -0
- package/docs/ROADMAP.md +95 -0
- package/docs/SPEC.md +855 -0
- package/docs/VISION.md +106 -0
- package/extensions/agile-workflow.test.ts +419 -0
- package/extensions/agile-workflow.ts +467 -0
- package/hooks/hooks.json +52 -0
- package/hooks/scripts/prompt-context.py +628 -0
- package/hooks/scripts/substrate-maintainer.py +359 -0
- package/hooks/scripts/test_prompt_context.py +757 -0
- package/package.json +30 -0
- package/scripts/install-work-view.sh +165 -0
- package/scripts/tests/agent-metadata.test.sh +75 -0
- package/scripts/tests/bump-version.test.sh +552 -0
- package/scripts/tests/channel-parity.test.sh +117 -0
- package/scripts/tests/convert-content-integrity.test.sh +246 -0
- package/scripts/tests/convert-install-routing.test.sh +159 -0
- package/scripts/tests/install-work-view.test.sh +411 -0
- package/scripts/tests/pi-package-metadata.test.sh +119 -0
- package/scripts/tests/work-board-shim.test.sh +239 -0
- package/scripts/tests/work-view-dist-version.test.sh +105 -0
- package/scripts/work-board.sh +64 -0
- package/scripts/work-view.sh +423 -0
- package/skills/autopilot/SKILL.md +330 -0
- package/skills/autopilot/agents/openai.yaml +6 -0
- package/skills/board/SKILL.md +62 -0
- package/skills/board/agents/openai.yaml +6 -0
- package/skills/bold-refactor/SKILL.md +244 -0
- package/skills/bold-refactor/agents/openai.yaml +6 -0
- package/skills/bug-scan/SKILL.md +378 -0
- package/skills/bug-scan/agents/openai.yaml +6 -0
- package/skills/bug-scan/references/async-promises.md +119 -0
- package/skills/bug-scan/references/concurrency-races.md +148 -0
- package/skills/bug-scan/references/data-layer.md +167 -0
- package/skills/bug-scan/references/error-handling.md +197 -0
- package/skills/bug-scan/references/gate-item-template.md +94 -0
- package/skills/bug-scan/references/language-footguns.md +219 -0
- package/skills/bug-scan/references/parked-item-template.md +103 -0
- package/skills/bug-scan/references/report-template.md +115 -0
- package/skills/bug-scan/references/resource-leaks.md +199 -0
- package/skills/bug-scan/references/state-closures.md +148 -0
- package/skills/bug-scan/references/time-numbers.md +158 -0
- package/skills/convert/SKILL.md +1463 -0
- package/skills/convert/agents/openai.yaml +6 -0
- package/skills/convert/references/legacy-overlap-migration.md +179 -0
- package/skills/deep-code-scan/SKILL.md +378 -0
- package/skills/deep-code-scan/agents/openai.yaml +6 -0
- package/skills/deep-code-scan/references/artifact-ledger.md +148 -0
- package/skills/deep-code-scan/references/consolidation.md +91 -0
- package/skills/deep-code-scan/references/decomposition.md +127 -0
- package/skills/deep-code-scan/references/item-templates.md +263 -0
- package/skills/deep-code-scan/references/lane-catalog.md +129 -0
- package/skills/deep-code-scan/references/review-gauntlet.md +108 -0
- package/skills/deep-code-scan/references/scanner-brief.md +152 -0
- package/skills/e2e-test-design/SKILL.md +517 -0
- package/skills/e2e-test-design/references/anti-tautology.md +261 -0
- package/skills/e2e-test-design/references/service-mocks.md +234 -0
- package/skills/e2e-test-design/references/test-taxonomy.md +211 -0
- package/skills/epic-design/SKILL.md +476 -0
- package/skills/epicize/SKILL.md +210 -0
- package/skills/epicize/agents/openai.yaml +6 -0
- package/skills/feature-design/SKILL.md +472 -0
- package/skills/fix/SKILL.md +173 -0
- package/skills/gate-cruft/SKILL.md +258 -0
- package/skills/gate-docs/SKILL.md +278 -0
- package/skills/gate-patterns/SKILL.md +389 -0
- package/skills/gate-refactor/SKILL.md +408 -0
- package/skills/gate-security/SKILL.md +275 -0
- package/skills/gate-tests/SKILL.md +367 -0
- package/skills/groom/SKILL.md +162 -0
- package/skills/groom/agents/openai.yaml +6 -0
- package/skills/ideate/SKILL.md +185 -0
- package/skills/ideate/agents/openai.yaml +6 -0
- package/skills/implement/SKILL.md +288 -0
- package/skills/implement-orchestrator/SKILL.md +604 -0
- package/skills/park/SKILL.md +111 -0
- package/skills/perf-design/SKILL.md +427 -0
- package/skills/perf-scout/SKILL.md +404 -0
- package/skills/perf-scout/agents/openai.yaml +6 -0
- package/skills/perf-scout/references/algorithmic-and-data-structures.md +126 -0
- package/skills/perf-scout/references/approximation-and-precomputation.md +136 -0
- package/skills/perf-scout/references/caching-and-memoization.md +123 -0
- package/skills/perf-scout/references/compiler-runtime-and-language.md +136 -0
- package/skills/perf-scout/references/database-and-storage-internals.md +144 -0
- package/skills/perf-scout/references/distributed-systems.md +150 -0
- package/skills/perf-scout/references/game-engine-and-realtime.md +136 -0
- package/skills/perf-scout/references/gpu-and-accelerators.md +130 -0
- package/skills/perf-scout/references/idea-ranking.md +75 -0
- package/skills/perf-scout/references/io-and-batching.md +135 -0
- package/skills/perf-scout/references/memory-and-data-locality.md +137 -0
- package/skills/perf-scout/references/parallelism-and-vectorization.md +126 -0
- package/skills/perf-scout/references/parked-item-template.md +117 -0
- package/skills/perf-scout/references/peer-review-pass.md +150 -0
- package/skills/perf-scout/references/report-template.md +148 -0
- package/skills/principles/SKILL.md +678 -0
- package/skills/principles/references/models.md +191 -0
- package/skills/principles/references/subagents.md +158 -0
- package/skills/prose-author/SKILL.md +185 -0
- package/skills/refactor-conventions-creator/SKILL.md +359 -0
- package/skills/refactor-conventions-creator/agents/openai.yaml +6 -0
- package/skills/refactor-conventions-creator/references/common-structures.md +91 -0
- package/skills/refactor-conventions-creator/references/common-styles.md +90 -0
- package/skills/refactor-design/SKILL.md +384 -0
- package/skills/release-deploy/SKILL.md +667 -0
- package/skills/release-deploy/agents/openai.yaml +6 -0
- package/skills/research/SKILL.md +166 -0
- package/skills/research/agents/openai.yaml +6 -0
- package/skills/review/SKILL.md +267 -0
- package/skills/review/references/deep-review.md +98 -0
- package/skills/review/references/review-lenses.md +68 -0
- package/skills/review/references/substrate-side-effects.md +161 -0
- package/skills/review/references/target-resolution.md +52 -0
- package/skills/scope/SKILL.md +486 -0
- package/work-view/Cargo.lock +486 -0
- package/work-view/Cargo.toml +9 -0
- package/work-view/crates/cli/.work-view-version +1 -0
- package/work-view/crates/cli/Cargo.toml +18 -0
- package/work-view/crates/cli/src/actionable.rs +500 -0
- package/work-view/crates/cli/src/args.rs +792 -0
- package/work-view/crates/cli/src/board/assets/board.css +855 -0
- package/work-view/crates/cli/src/board/assets/board.js +259 -0
- package/work-view/crates/cli/src/board/assets/card.js +139 -0
- package/work-view/crates/cli/src/board/assets/components.css +290 -0
- package/work-view/crates/cli/src/board/assets/dependency.js +1193 -0
- package/work-view/crates/cli/src/board/assets/detail.js +256 -0
- package/work-view/crates/cli/src/board/assets/filters.js +389 -0
- package/work-view/crates/cli/src/board/assets/index.html +77 -0
- package/work-view/crates/cli/src/board/assets/kanban.js +231 -0
- package/work-view/crates/cli/src/board/assets/markdown.js +260 -0
- package/work-view/crates/cli/src/board/assets/motion.css +131 -0
- package/work-view/crates/cli/src/board/assets/state.js +245 -0
- package/work-view/crates/cli/src/board/assets/table.js +304 -0
- package/work-view/crates/cli/src/board/assets/tokens.css +602 -0
- package/work-view/crates/cli/src/board/assets/views.js +41 -0
- package/work-view/crates/cli/src/board/assets.rs +162 -0
- package/work-view/crates/cli/src/board/feed.rs +204 -0
- package/work-view/crates/cli/src/board/mod.rs +246 -0
- package/work-view/crates/cli/src/board/open.rs +144 -0
- package/work-view/crates/cli/src/board/server.rs +407 -0
- package/work-view/crates/cli/src/main.rs +195 -0
- package/work-view/crates/cli/src/render.rs +354 -0
- package/work-view/crates/cli/src/scope.rs +157 -0
- package/work-view/crates/cli/src/stale.rs +581 -0
- package/work-view/crates/cli/tests/board-js/dependency-table.test.mjs +64 -0
- package/work-view/crates/cli/tests/board-js/expanded-browsing.test.mjs +79 -0
- package/work-view/crates/cli/tests/board-js/harness-smoke.test.mjs +11 -0
- package/work-view/crates/cli/tests/board-js/harness.mjs +437 -0
- package/work-view/crates/cli/tests/board-js/kanban-detail.test.mjs +75 -0
- package/work-view/crates/cli/tests/board-js/markdown-filter.test.mjs +60 -0
- package/work-view/crates/cli/tests/fixtures/golden/.work/CONVENTIONS.md +3 -0
- package/work-view/crates/cli/tests/fixtures/golden/.work/active/epics/epic-alpha.md +16 -0
- package/work-view/crates/cli/tests/fixtures/golden/.work/active/features/feat-a.md +16 -0
- package/work-view/crates/cli/tests/fixtures/golden/.work/active/features/feat-b.md +16 -0
- package/work-view/crates/cli/tests/fixtures/golden/.work/active/stories/story-alpha-1.md +16 -0
- package/work-view/crates/cli/tests/fixtures/golden/.work/active/stories/story-research-1.md +19 -0
- package/work-view/crates/cli/tests/fixtures/golden/.work/archive/feat-done.md +16 -0
- package/work-view/crates/cli/tests/fixtures/golden/.work/archive/feat-shipped.md +18 -0
- package/work-view/crates/cli/tests/fixtures/golden/.work/backlog/idea-backlog.md +7 -0
- package/work-view/crates/cli/tests/fixtures/golden/.work/releases/v1.0/v1.0.md +16 -0
- package/work-view/crates/cli/tests/fixtures/ready-drafting/.work/CONVENTIONS.md +6 -0
- package/work-view/crates/cli/tests/fixtures/ready-drafting/.work/active/features/feat-dep-done.md +17 -0
- package/work-view/crates/cli/tests/fixtures/ready-drafting/.work/active/features/feat-design-ready.md +17 -0
- package/work-view/crates/cli/tests/fixtures/ready-drafting/.work/active/features/feat-impl-ready.md +17 -0
- package/work-view/crates/cli/tests/fixtures/ready-drafting/.work/active/stories/story-review-ready.md +17 -0
- package/work-view/crates/cli/tests/fixtures/with-malformed/.work/CONVENTIONS.md +4 -0
- package/work-view/crates/cli/tests/fixtures/with-malformed/.work/active/epics/malformed-no-closing-fence.md +8 -0
- package/work-view/crates/cli/tests/fixtures/with-malformed/.work/active/features/good-item.md +16 -0
- package/work-view/crates/cli/tests/integration.rs +2833 -0
- package/work-view/crates/core/Cargo.toml +13 -0
- package/work-view/crates/core/src/error.rs +61 -0
- package/work-view/crates/core/src/filter.rs +804 -0
- package/work-view/crates/core/src/graph.rs +324 -0
- package/work-view/crates/core/src/index.rs +590 -0
- package/work-view/crates/core/src/lib.rs +54 -0
- package/work-view/crates/core/src/model.rs +202 -0
- package/work-view/crates/core/src/parse.rs +494 -0
- package/work-view/crates/core/tests/fixtures/golden/.work/CONVENTIONS.md +15 -0
- package/work-view/crates/core/tests/fixtures/golden/.work/active/epics/epic-core.md +16 -0
- package/work-view/crates/core/tests/fixtures/golden/.work/active/features/feat-parser.md +16 -0
- package/work-view/crates/core/tests/fixtures/golden/.work/active/features/feat-query.md +16 -0
- package/work-view/crates/core/tests/fixtures/golden/.work/active/stories/malformed-no-id.md +15 -0
- package/work-view/crates/core/tests/fixtures/golden/.work/active/stories/story-parse-unit.md +16 -0
- package/work-view/crates/core/tests/fixtures/golden/.work/active/stories/story-security-gate.md +16 -0
- package/work-view/crates/core/tests/fixtures/golden/.work/archive/feat-old.md +16 -0
- package/work-view/crates/core/tests/fixtures/golden/.work/archive/feat-parser.md +17 -0
- package/work-view/crates/core/tests/fixtures/golden/.work/backlog/idea-future-board.md +7 -0
- package/work-view/crates/core/tests/fixtures/golden/.work/releases/v1.0/v1.0.md +16 -0
- package/work-view/crates/core/tests/fixtures/precedence/.work/CONVENTIONS.md +10 -0
- package/work-view/crates/core/tests/fixtures/precedence/.work/archive/dup-item.md +18 -0
- package/work-view/crates/core/tests/fixtures/precedence/.work/releases/v2.0/dup-item.md +18 -0
- package/work-view/crates/core/tests/integration.rs +541 -0
- package/work-view/dist/.gitattributes +1 -0
- package/work-view/dist/README.md +42 -0
- package/work-view/dist/aarch64-apple-darwin/work-view +0 -0
- package/work-view/dist/aarch64-unknown-linux-musl/work-view +0 -0
- package/work-view/dist/x86_64-apple-darwin/work-view +0 -0
- 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())
|