@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,590 @@
1
+ //! Substrate root detection and item index.
2
+ //!
3
+ //! `find_substrate_root` walks up from a given directory looking for
4
+ //! `.work/CONVENTIONS.md`.
5
+ //!
6
+ //! `Substrate::load` scans the four tier directories (active, backlog,
7
+ //! releases, archive), sorts all `.md` paths by raw bytes (matching
8
+ //! `LC_ALL=C sort`), parses each file, and builds an in-memory index.
9
+ //!
10
+ //! Key invariants:
11
+ //! - Load order = global byte-sort over ALL four dirs combined.
12
+ //! - `by_id` resolves duplicates by tier precedence: active > releases > archive > backlog.
13
+ //! - Per-item read/parse failures → `ParseError` (non-fatal; item is skipped).
14
+ //! - Root-level traversal I/O failure → `LoadError::Io` (fatal).
15
+ //! - Validation is narrow + tier-aware: only active/releases items warn on
16
+ //! missing SPEC-required fields.
17
+
18
+ use std::{
19
+ collections::HashMap,
20
+ path::{Path, PathBuf},
21
+ };
22
+
23
+ use crate::{
24
+ error::{LoadError, ParseError},
25
+ model::{Item, Tier},
26
+ parse::parse_item,
27
+ };
28
+
29
+ // ── Public diagnostic types ───────────────────────────────────────────────────
30
+
31
+ /// An actionable load-time warning attached to a specific file and tier.
32
+ #[derive(Debug)]
33
+ pub struct Diagnostic {
34
+ /// Absolute path to the offending file.
35
+ pub path: PathBuf,
36
+ /// Tier the file was loaded from.
37
+ pub tier: Tier,
38
+ /// Item id, if successfully parsed (None when parsing failed before id extraction).
39
+ pub id: Option<String>,
40
+ /// The field that triggered the diagnostic, if applicable.
41
+ pub field: Option<String>,
42
+ /// Human-readable explanation.
43
+ pub reason: String,
44
+ }
45
+
46
+ /// Summary of non-fatal issues discovered during `Substrate::load`.
47
+ #[derive(Debug, Default)]
48
+ pub struct LoadReport {
49
+ /// Per-item files that failed to parse (no id, bad YAML, or read failure).
50
+ /// These items are excluded from `Substrate::items()`.
51
+ pub parse_errors: Vec<ParseError>,
52
+
53
+ /// Active or releases items that are present in `items()` but are missing
54
+ /// a SPEC-required field. These items are surfaced, NOT dropped.
55
+ pub validation_warnings: Vec<Diagnostic>,
56
+
57
+ /// Duplicate id occurrences (one `Diagnostic` per *extra* location).
58
+ /// `by_id` resolves the canonical item by tier precedence.
59
+ pub duplicate_ids: Vec<Diagnostic>,
60
+ }
61
+
62
+ // ── Substrate ─────────────────────────────────────────────────────────────────
63
+
64
+ /// The in-memory index of a substrate's `.work/` directory.
65
+ pub struct Substrate {
66
+ /// Absolute path to the substrate root (contains `.work/CONVENTIONS.md`).
67
+ pub root: PathBuf,
68
+ /// All successfully parsed items in stable byte-sorted order.
69
+ items: Vec<Item>,
70
+ /// Map from item id → index into `items` for the canonical item.
71
+ /// Resolved by tier precedence: active > releases > archive > backlog.
72
+ by_id: HashMap<String, usize>,
73
+ }
74
+
75
+ impl Substrate {
76
+ /// Return all items in stable byte-sorted load order.
77
+ pub fn items(&self) -> &[Item] {
78
+ &self.items
79
+ }
80
+
81
+ /// Look up an item by id using tier-precedence resolution.
82
+ ///
83
+ /// If the same id appears in multiple tiers, the highest-precedence copy
84
+ /// is returned (active > releases > archive > backlog).
85
+ pub fn by_id(&self, id: &str) -> Option<&Item> {
86
+ self.by_id.get(id).map(|&idx| &self.items[idx])
87
+ }
88
+
89
+ /// Scan the substrate and load all items.
90
+ ///
91
+ /// # Errors
92
+ /// Returns `LoadError::Io` only if a root-level directory cannot be read.
93
+ /// Per-item read/parse failures are collected in `LoadReport.parse_errors`.
94
+ pub fn load(root: &Path) -> Result<(Substrate, LoadReport), LoadError> {
95
+ let mut report = LoadReport::default();
96
+
97
+ // Collect and byte-sort all *.md paths across the four tier dirs.
98
+ let paths = collect_sorted_paths(root)?;
99
+
100
+ // Parse every file in sorted order, collecting errors non-fatally.
101
+ let mut items: Vec<Item> = Vec::with_capacity(paths.len());
102
+ for (abs_path, tier) in &paths {
103
+ let text = match std::fs::read_to_string(abs_path) {
104
+ Ok(t) => t,
105
+ Err(e) => {
106
+ report.parse_errors.push(ParseError {
107
+ path: abs_path.clone(),
108
+ reason: format!("could not read file: {e}"),
109
+ });
110
+ continue;
111
+ }
112
+ };
113
+
114
+ let rel = abs_path
115
+ .strip_prefix(root)
116
+ .unwrap_or(abs_path.as_path())
117
+ .to_owned();
118
+
119
+ match parse_item(abs_path, &rel, *tier, &text) {
120
+ Ok(item) => items.push(item),
121
+ Err(e) => report.parse_errors.push(e),
122
+ }
123
+ }
124
+
125
+ // Build by_id with tier-precedence resolution; collect duplicate diagnostics.
126
+ // We need to store the position of each item as we process them.
127
+ // First pass: build a map from id → best_index (by precedence).
128
+ // We also need to track "all indices for an id" to emit duplicate diagnostics.
129
+ let mut id_candidates: HashMap<String, Vec<usize>> = HashMap::new();
130
+ for (idx, item) in items.iter().enumerate() {
131
+ id_candidates.entry(item.id.clone()).or_default().push(idx);
132
+ }
133
+
134
+ let mut by_id: HashMap<String, usize> = HashMap::with_capacity(id_candidates.len());
135
+
136
+ for (id, indices) in &id_candidates {
137
+ // Pick the best candidate by tier precedence (lower precedence value wins).
138
+ let best_idx = indices
139
+ .iter()
140
+ .copied()
141
+ .min_by_key(|&i| items[i].tier.precedence())
142
+ .expect("indices is non-empty");
143
+
144
+ by_id.insert(id.clone(), best_idx);
145
+
146
+ // Emit a diagnostic for every *non-winning* location.
147
+ if indices.len() > 1 {
148
+ for &idx in indices {
149
+ if idx == best_idx {
150
+ continue;
151
+ }
152
+ let item = &items[idx];
153
+ report.duplicate_ids.push(Diagnostic {
154
+ path: item.path.clone(),
155
+ tier: item.tier,
156
+ id: Some(item.id.clone()),
157
+ field: None,
158
+ reason: format!(
159
+ "duplicate id `{}` (canonical copy is in {:?} tier)",
160
+ item.id, items[best_idx].tier
161
+ ),
162
+ });
163
+ }
164
+ }
165
+ }
166
+
167
+ // Tier-aware validation: warn on active/releases items missing required fields.
168
+ for item in &items {
169
+ if !matches!(item.tier, Tier::Active | Tier::Releases) {
170
+ continue;
171
+ }
172
+ let required_fields: &[(&str, bool)] = &[
173
+ ("kind", item.kind.is_none()),
174
+ ("stage", item.stage.is_none()),
175
+ // parent, depends_on, release_binding, gate_origin are required per SPEC
176
+ // but may legitimately be null/[]. We only warn when the field is
177
+ // completely absent — the parse step would have set these to None/[] for
178
+ // missing fields, which is indistinguishable from explicitly `null`.
179
+ // Per spec: "required (may be [])" or "required (null for top-level)".
180
+ // So we cannot warn on None for parent/release_binding/gate_origin —
181
+ // they are valid as null. We warn only on kind/stage which have no
182
+ // "may be null" carve-out for active items.
183
+ ];
184
+
185
+ for (field_name, is_missing) in required_fields {
186
+ if *is_missing {
187
+ report.validation_warnings.push(Diagnostic {
188
+ path: item.path.clone(),
189
+ tier: item.tier,
190
+ id: Some(item.id.clone()),
191
+ field: Some(field_name.to_string()),
192
+ reason: format!(
193
+ "active/releases item `{}` is missing required field `{}`",
194
+ item.id, field_name
195
+ ),
196
+ });
197
+ }
198
+ }
199
+ }
200
+
201
+ Ok((
202
+ Substrate {
203
+ root: root.to_owned(),
204
+ items,
205
+ by_id,
206
+ },
207
+ report,
208
+ ))
209
+ }
210
+ }
211
+
212
+ // ── Internal helpers ──────────────────────────────────────────────────────────
213
+
214
+ /// The four tier directories in traversal order (matches bash `find` argument order).
215
+ const TIER_DIRS: [(&str, Tier); 4] = [
216
+ ("active", Tier::Active),
217
+ ("backlog", Tier::Backlog),
218
+ ("releases", Tier::Releases),
219
+ ("archive", Tier::Archive),
220
+ ];
221
+
222
+ /// Collect all `*.md` files from the four tier dirs, byte-sorted globally.
223
+ ///
224
+ /// Matches `find <active> <backlog> <releases> <archive> -name '*.md' | LC_ALL=C sort`.
225
+ ///
226
+ /// Returns `(absolute_path, tier)` pairs. Returns `LoadError` if a tier
227
+ /// directory exists but cannot be read (its read-dir call fails).
228
+ fn collect_sorted_paths(root: &Path) -> Result<Vec<(PathBuf, Tier)>, LoadError> {
229
+ let work = root.join(".work");
230
+
231
+ let mut all: Vec<(PathBuf, Tier)> = Vec::new();
232
+
233
+ for (dir_name, tier) in &TIER_DIRS {
234
+ let dir = work.join(dir_name);
235
+ if !dir.exists() {
236
+ // Missing tier dir is not an error — substrate may not have all dirs.
237
+ continue;
238
+ }
239
+ collect_md_recursive(&dir, *tier, &mut all).map_err(LoadError::Io)?;
240
+ }
241
+
242
+ // Sort by raw path bytes — matches `LC_ALL=C sort` on ASCII paths.
243
+ use std::os::unix::ffi::OsStrExt;
244
+ all.sort_by(|(a, _), (b, _)| a.as_os_str().as_bytes().cmp(b.as_os_str().as_bytes()));
245
+
246
+ Ok(all)
247
+ }
248
+
249
+ /// Recursively collect all `*.md` files under `dir`.
250
+ fn collect_md_recursive(
251
+ dir: &Path,
252
+ tier: Tier,
253
+ out: &mut Vec<(PathBuf, Tier)>,
254
+ ) -> std::io::Result<()> {
255
+ for entry in std::fs::read_dir(dir)? {
256
+ let entry = entry?;
257
+ let ft = entry.file_type()?;
258
+ let path = entry.path();
259
+ if ft.is_dir() {
260
+ collect_md_recursive(&path, tier, out)?;
261
+ } else if ft.is_file() && path.extension().and_then(|e| e.to_str()) == Some("md") {
262
+ out.push((path, tier));
263
+ }
264
+ }
265
+ Ok(())
266
+ }
267
+
268
+ // ── Substrate-root detection ──────────────────────────────────────────────────
269
+
270
+ /// Walk up the directory tree from `start`, looking for `.work/CONVENTIONS.md`.
271
+ ///
272
+ /// Returns the first ancestor directory that contains it, or `None` if not found.
273
+ pub fn find_substrate_root(start: &Path) -> Option<PathBuf> {
274
+ let mut dir = start.to_path_buf();
275
+ // Canonicalize to avoid symlink/relative issues if possible; ignore error.
276
+ if let Ok(canon) = start.canonicalize() {
277
+ dir = canon;
278
+ }
279
+ loop {
280
+ if dir.join(".work").join("CONVENTIONS.md").is_file() {
281
+ return Some(dir);
282
+ }
283
+ match dir.parent() {
284
+ Some(p) if p != dir => dir = p.to_path_buf(),
285
+ _ => return None,
286
+ }
287
+ }
288
+ }
289
+
290
+ #[cfg(test)]
291
+ mod tests {
292
+ use super::*;
293
+ use std::fs;
294
+ use tempfile::TempDir;
295
+
296
+ fn write_item(dir: &Path, filename: &str, content: &str) {
297
+ let path = dir.join(filename);
298
+ if let Some(parent) = path.parent() {
299
+ fs::create_dir_all(parent).unwrap();
300
+ }
301
+ fs::write(path, content).unwrap();
302
+ }
303
+
304
+ fn create_substrate(tmp: &TempDir) -> PathBuf {
305
+ let root = tmp.path().to_path_buf();
306
+ fs::create_dir_all(root.join(".work")).unwrap();
307
+ fs::write(root.join(".work/CONVENTIONS.md"), "# Conventions\n").unwrap();
308
+ root
309
+ }
310
+
311
+ // ── find_substrate_root ────────────────────────────────────────────────
312
+
313
+ #[test]
314
+ fn find_root_returns_none_when_absent() {
315
+ // Robust to $TMPDIR placement: TempDir honors $TMPDIR, which can itself
316
+ // sit inside a substrate tree (e.g. a repo-local tmp dir). There,
317
+ // find_substrate_root *correctly* walks up and finds that ancestor, so a
318
+ // bare `.is_none()` would spuriously fail. Detect a real substrate
319
+ // ancestor with an independent walk (std `ancestors()`, distinct from the
320
+ // production `.parent()` loop) and assert the true contract either way.
321
+ let tmp = TempDir::new().unwrap();
322
+ let canon = tmp.path().canonicalize().unwrap();
323
+ let ancestor_with_substrate = canon
324
+ .ancestors()
325
+ .find(|d| d.join(".work").join("CONVENTIONS.md").is_file());
326
+ match ancestor_with_substrate {
327
+ None => assert!(find_substrate_root(tmp.path()).is_none()),
328
+ Some(expected) => assert_eq!(
329
+ find_substrate_root(tmp.path()).unwrap().canonicalize().unwrap(),
330
+ expected.canonicalize().unwrap(),
331
+ ),
332
+ }
333
+ }
334
+
335
+ #[test]
336
+ fn find_root_finds_root_at_start() {
337
+ let tmp = TempDir::new().unwrap();
338
+ let root = create_substrate(&tmp);
339
+ let found = find_substrate_root(&root).unwrap();
340
+ assert_eq!(found.canonicalize().unwrap(), root.canonicalize().unwrap());
341
+ }
342
+
343
+ #[test]
344
+ fn find_root_walks_up_from_subdirectory() {
345
+ let tmp = TempDir::new().unwrap();
346
+ let root = create_substrate(&tmp);
347
+ let subdir = root.join("deep/nested/dir");
348
+ fs::create_dir_all(&subdir).unwrap();
349
+ let found = find_substrate_root(&subdir).unwrap();
350
+ assert_eq!(found.canonicalize().unwrap(), root.canonicalize().unwrap());
351
+ }
352
+
353
+ // ── Substrate::load ────────────────────────────────────────────────────
354
+
355
+ #[test]
356
+ fn load_empty_substrate_succeeds() {
357
+ let tmp = TempDir::new().unwrap();
358
+ let root = create_substrate(&tmp);
359
+ let (sub, report) = Substrate::load(&root).unwrap();
360
+ assert!(sub.items().is_empty());
361
+ assert!(report.parse_errors.is_empty());
362
+ }
363
+
364
+ #[test]
365
+ fn load_unreadable_tier_dir_returns_io_error() {
366
+ // A root-level traversal I/O failure (an unreadable tier dir) is the only
367
+ // fatal load condition: Substrate::load must return Err(LoadError::Io).
368
+ //
369
+ // Skipped when running as root, since root bypasses permission bits and
370
+ // the dir stays readable — detected by reading after chmod.
371
+ use std::os::unix::fs::PermissionsExt;
372
+
373
+ let tmp = TempDir::new().unwrap();
374
+ let root = create_substrate(&tmp);
375
+ let active = root.join(".work/active");
376
+ fs::create_dir_all(&active).unwrap();
377
+
378
+ // Make the active tier dir unreadable.
379
+ fs::set_permissions(&active, fs::Permissions::from_mode(0o000)).unwrap();
380
+
381
+ // Skip guard: if still readable (root / perms bypassed), restore and bail.
382
+ if fs::read_dir(&active).is_ok() {
383
+ let _ = fs::set_permissions(&active, fs::Permissions::from_mode(0o755));
384
+ eprintln!(
385
+ "skipping load_unreadable_tier_dir_returns_io_error: chmod 000 was bypassed \
386
+ (likely running as root); cannot make the tier dir unreadable"
387
+ );
388
+ return;
389
+ }
390
+
391
+ let result = Substrate::load(&root);
392
+
393
+ // Restore perms BEFORE the TempDir drops so cleanup does not fail.
394
+ fs::set_permissions(&active, fs::Permissions::from_mode(0o755)).unwrap();
395
+
396
+ assert!(
397
+ matches!(result, Err(LoadError::Io(_))),
398
+ "unreadable tier dir should yield Err(LoadError::Io), got: {:?}",
399
+ result.map(|(s, _)| s.items().len())
400
+ );
401
+ }
402
+
403
+ #[test]
404
+ fn load_items_from_four_tiers() {
405
+ let tmp = TempDir::new().unwrap();
406
+ let root = create_substrate(&tmp);
407
+ let work = root.join(".work");
408
+
409
+ let active_dir = work.join("active/features");
410
+ let backlog_dir = work.join("backlog");
411
+ let releases_dir = work.join("releases/v1.0");
412
+ let archive_dir = work.join("archive");
413
+
414
+ write_item(
415
+ &active_dir,
416
+ "feat-a.md",
417
+ "---\nid: feat-a\nkind: feature\nstage: implementing\ntags: []\nparent: null\ndepends_on: []\nrelease_binding: null\ngate_origin: null\ncreated: 2026-01-01\nupdated: 2026-01-01\n---\n\n# Feat A\n",
418
+ );
419
+ write_item(
420
+ &backlog_dir,
421
+ "idea-x.md",
422
+ "---\nid: idea-x\ncreated: 2026-01-02\ntags: []\n---\nAn idea.\n",
423
+ );
424
+ write_item(
425
+ &releases_dir,
426
+ "v1.0.md",
427
+ "---\nid: v1.0\nkind: release\nstage: released\ntags: []\nparent: null\ndepends_on: []\nrelease_binding: null\ngate_origin: null\ncreated: 2026-01-03\nupdated: 2026-01-03\n---\n\n# Release v1.0\n",
428
+ );
429
+ write_item(
430
+ &archive_dir,
431
+ "old-feat.md",
432
+ "---\nid: old-feat\nkind: feature\nstage: done\ntags: []\nparent: null\ndepends_on: []\nrelease_binding: null\ngate_origin: null\ncreated: 2025-01-01\nupdated: 2025-01-01\n---\n\n# Old Feature\n",
433
+ );
434
+
435
+ let (sub, _report) = Substrate::load(&root).unwrap();
436
+ assert_eq!(sub.items().len(), 4);
437
+
438
+ // Check tiers
439
+ let by_id = |id: &str| sub.by_id(id).unwrap();
440
+ assert_eq!(by_id("feat-a").tier, Tier::Active);
441
+ assert_eq!(by_id("idea-x").tier, Tier::Backlog);
442
+ assert_eq!(by_id("v1.0").tier, Tier::Releases);
443
+ assert_eq!(by_id("old-feat").tier, Tier::Archive);
444
+ }
445
+
446
+ #[test]
447
+ fn load_order_is_byte_sorted() {
448
+ let tmp = TempDir::new().unwrap();
449
+ let root = create_substrate(&tmp);
450
+ let active = root.join(".work/active/stories");
451
+
452
+ // Create files in reverse alphabetical order
453
+ write_item(&active, "z-story.md", "---\nid: z-story\nkind: story\nstage: implementing\ntags: []\nparent: null\ndepends_on: []\nrelease_binding: null\ngate_origin: null\ncreated: 2026-01-01\nupdated: 2026-01-01\n---\n");
454
+ write_item(&active, "a-story.md", "---\nid: a-story\nkind: story\nstage: implementing\ntags: []\nparent: null\ndepends_on: []\nrelease_binding: null\ngate_origin: null\ncreated: 2026-01-01\nupdated: 2026-01-01\n---\n");
455
+ write_item(&active, "m-story.md", "---\nid: m-story\nkind: story\nstage: implementing\ntags: []\nparent: null\ndepends_on: []\nrelease_binding: null\ngate_origin: null\ncreated: 2026-01-01\nupdated: 2026-01-01\n---\n");
456
+
457
+ let (sub, _) = Substrate::load(&root).unwrap();
458
+ let ids: Vec<&str> = sub.items().iter().map(|i| i.id.as_str()).collect();
459
+ assert_eq!(ids, vec!["a-story", "m-story", "z-story"]);
460
+ }
461
+
462
+ #[test]
463
+ fn malformed_file_excluded_from_items_but_valid_siblings_load() {
464
+ let tmp = TempDir::new().unwrap();
465
+ let root = create_substrate(&tmp);
466
+ let active = root.join(".work/active/stories");
467
+
468
+ // A valid item
469
+ write_item(
470
+ &active,
471
+ "good.md",
472
+ "---\nid: good\nkind: story\nstage: implementing\ntags: []\nparent: null\ndepends_on: []\nrelease_binding: null\ngate_origin: null\ncreated: 2026-01-01\nupdated: 2026-01-01\n---\n\n# Good\n",
473
+ );
474
+ // A malformed item (no id)
475
+ write_item(
476
+ &active,
477
+ "bad.md",
478
+ "---\nkind: story\nstage: implementing\n---\n\n# Bad\n",
479
+ );
480
+
481
+ let (sub, report) = Substrate::load(&root).unwrap();
482
+ assert_eq!(sub.items().len(), 1);
483
+ assert_eq!(sub.items()[0].id, "good");
484
+ assert_eq!(report.parse_errors.len(), 1);
485
+ assert!(report.parse_errors[0].path.ends_with("bad.md"));
486
+ }
487
+
488
+ #[test]
489
+ fn duplicate_ids_resolved_by_tier_precedence() {
490
+ let tmp = TempDir::new().unwrap();
491
+ let root = create_substrate(&tmp);
492
+
493
+ let item_content = |id: &str| {
494
+ format!("---\nid: {id}\nkind: feature\nstage: done\ntags: []\nparent: null\ndepends_on: []\nrelease_binding: null\ngate_origin: null\ncreated: 2026-01-01\nupdated: 2026-01-01\n---\n\n# {id}\n")
495
+ };
496
+
497
+ // Same id in archive and active tiers
498
+ write_item(
499
+ &root.join(".work/archive"),
500
+ "dup-feat.md",
501
+ &item_content("dup-feat"),
502
+ );
503
+ write_item(
504
+ &root.join(".work/active/features"),
505
+ "dup-feat.md",
506
+ &item_content("dup-feat"),
507
+ );
508
+
509
+ let (sub, report) = Substrate::load(&root).unwrap();
510
+
511
+ // Should report 1 duplicate diagnostic
512
+ assert_eq!(report.duplicate_ids.len(), 1);
513
+
514
+ // by_id should return the Active tier copy (higher precedence)
515
+ let canonical = sub.by_id("dup-feat").unwrap();
516
+ assert_eq!(canonical.tier, Tier::Active);
517
+ }
518
+
519
+ #[test]
520
+ fn validation_warns_on_active_item_missing_required_field() {
521
+ let tmp = TempDir::new().unwrap();
522
+ let root = create_substrate(&tmp);
523
+
524
+ // Active item missing `kind` and `stage`
525
+ write_item(
526
+ &root.join(".work/active/features"),
527
+ "incomplete.md",
528
+ "---\nid: incomplete\ntags: []\nparent: null\ndepends_on: []\nrelease_binding: null\ngate_origin: null\ncreated: 2026-01-01\nupdated: 2026-01-01\n---\n\n# Incomplete\n",
529
+ );
530
+
531
+ let (sub, report) = Substrate::load(&root).unwrap();
532
+
533
+ // Item stays in items (not dropped)
534
+ assert!(sub.by_id("incomplete").is_some());
535
+
536
+ // Validation warnings for kind and stage
537
+ let fields: Vec<&str> = report
538
+ .validation_warnings
539
+ .iter()
540
+ .filter_map(|d| d.field.as_deref())
541
+ .collect();
542
+ assert!(fields.contains(&"kind"), "should warn on missing kind");
543
+ assert!(fields.contains(&"stage"), "should warn on missing stage");
544
+ }
545
+
546
+ #[test]
547
+ fn backlog_item_missing_kind_stage_no_validation_warning() {
548
+ let tmp = TempDir::new().unwrap();
549
+ let root = create_substrate(&tmp);
550
+
551
+ write_item(
552
+ &root.join(".work/backlog"),
553
+ "idea.md",
554
+ "---\nid: backlog-idea\ncreated: 2026-01-01\ntags: []\n---\nAn idea.\n",
555
+ );
556
+
557
+ let (sub, report) = Substrate::load(&root).unwrap();
558
+
559
+ // Item loads fine
560
+ assert!(sub.by_id("backlog-idea").is_some());
561
+
562
+ // No validation warnings for backlog item
563
+ let backlog_warnings: Vec<_> = report
564
+ .validation_warnings
565
+ .iter()
566
+ .filter(|d| d.id.as_deref() == Some("backlog-idea"))
567
+ .collect();
568
+ assert!(backlog_warnings.is_empty());
569
+ }
570
+
571
+ #[test]
572
+ fn rel_path_is_relative_to_root() {
573
+ let tmp = TempDir::new().unwrap();
574
+ let root = create_substrate(&tmp);
575
+ let active = root.join(".work/active/features");
576
+
577
+ write_item(
578
+ &active,
579
+ "my-feat.md",
580
+ "---\nid: my-feat\nkind: feature\nstage: implementing\ntags: []\nparent: null\ndepends_on: []\nrelease_binding: null\ngate_origin: null\ncreated: 2026-01-01\nupdated: 2026-01-01\n---\n",
581
+ );
582
+
583
+ let (sub, _) = Substrate::load(&root).unwrap();
584
+ let item = sub.by_id("my-feat").unwrap();
585
+
586
+ // rel_path should be relative (not absolute)
587
+ assert!(item.rel_path.is_relative());
588
+ assert!(item.rel_path.starts_with(".work/active/features"));
589
+ }
590
+ }
@@ -0,0 +1,54 @@
1
+ //! # work-view-core
2
+ //!
3
+ //! The shared `.work/` query and domain core for the agile-workflow substrate.
4
+ //!
5
+ //! This is the Single-Source-of-Truth layer (Ports & Adapters) that both the
6
+ //! CLI adapter (`epic-substrate-cli-adapter`) and the future web board
7
+ //! (`epic-substrate-board`) import. It does NOT contain CLI flag parsing,
8
+ //! output formatting, or process exit code logic.
9
+ //!
10
+ //! ## Public API
11
+ //!
12
+ //! ```text
13
+ //! work_view_core::
14
+ //! model::{Item, Tier}
15
+ //! parse::parse_item
16
+ //! index::{Substrate, LoadReport, Diagnostic, find_substrate_root}
17
+ //! filter::{Filter, Match}
18
+ //! error::{ParseError, LoadError}
19
+ //! ```
20
+ //!
21
+ //! ## Entry points
22
+ //!
23
+ //! 1. Detect the substrate root:
24
+ //! ```rust,no_run
25
+ //! use std::path::Path;
26
+ //! use work_view_core::index::find_substrate_root;
27
+ //! let root = find_substrate_root(Path::new("."));
28
+ //! ```
29
+ //!
30
+ //! 2. Load the substrate:
31
+ //! ```rust,no_run
32
+ //! use work_view_core::index::Substrate;
33
+ //! # let root = std::path::Path::new(".");
34
+ //! let (substrate, report) = Substrate::load(root).unwrap();
35
+ //! ```
36
+ //!
37
+ //! 3. Query items:
38
+ //! ```rust,no_run
39
+ //! use work_view_core::filter::{Filter, Match};
40
+ //! # use work_view_core::index::Substrate;
41
+ //! # let (substrate, _) = Substrate::load(std::path::Path::new(".")).unwrap();
42
+ //! let f = Filter {
43
+ //! stage: Match::Equals("implementing".into()),
44
+ //! ..Filter::default()
45
+ //! };
46
+ //! let items = substrate.query(&f);
47
+ //! ```
48
+
49
+ pub mod error;
50
+ pub mod filter;
51
+ pub mod graph;
52
+ pub mod index;
53
+ pub mod model;
54
+ pub mod parse;