@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,202 @@
1
+ //! Domain model for a single substrate item.
2
+ //!
3
+ //! `Item` is the central type in this crate. Every other module either
4
+ //! produces `Item`s (parse, index) or operates on them (graph, filter).
5
+
6
+ use std::path::PathBuf;
7
+
8
+ /// The substrate directory tier an item was loaded from.
9
+ ///
10
+ /// Derived from the load directory, NOT from frontmatter. Immutable once set.
11
+ ///
12
+ /// Used by:
13
+ /// - `Item::is_terminal` (Releases | Archive → terminal regardless of stage)
14
+ /// - Tier-aware validation (active/releases items require full frontmatter)
15
+ /// - Duplicate-id precedence resolution (active > releases > archive > backlog)
16
+ /// - Board bucketing (`tier` maps directly to the board's bucket field)
17
+ #[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord)]
18
+ pub enum Tier {
19
+ /// `.work/active/**` — in-flight work (epics, features, stories)
20
+ Active,
21
+ /// `.work/releases/**` — release items; terminal by tier
22
+ Releases,
23
+ /// `.work/archive/**` — completed/abandoned items; terminal by tier
24
+ Archive,
25
+ /// `.work/backlog/**` — leaner items (only `id`/`created`/`tags` required)
26
+ Backlog,
27
+ }
28
+
29
+ impl Tier {
30
+ /// Precedence value for `by_id` duplicate resolution.
31
+ /// Lower is higher precedence: Active=0, Releases=1, Archive=2, Backlog=3.
32
+ pub fn precedence(self) -> u8 {
33
+ match self {
34
+ Tier::Active => 0,
35
+ Tier::Releases => 1,
36
+ Tier::Archive => 2,
37
+ Tier::Backlog => 3,
38
+ }
39
+ }
40
+ }
41
+
42
+ /// A single parsed item from the substrate.
43
+ ///
44
+ /// All optional scalar fields are normalized: missing / YAML `null` / literal
45
+ /// `"null"` string / empty string → `None`. This applies to `kind`, `stage`,
46
+ /// `parent`, `release_binding`, `gate_origin`, `research_origin`, and
47
+ /// `scan_origin`.
48
+ ///
49
+ /// `tags`, `depends_on`, and `research_refs` are always a `Vec<String>`;
50
+ /// missing/empty frontmatter yields an empty vec (never `None`).
51
+ #[derive(Debug, Clone)]
52
+ pub struct Item {
53
+ /// Item identifier. Unique within the active tier; may repeat across tiers.
54
+ pub id: String,
55
+
56
+ /// Item kind (`epic`, `feature`, `story`, `release`).
57
+ /// `None` for backlog items (not yet scoped) or if missing from frontmatter.
58
+ pub kind: Option<String>,
59
+
60
+ /// Workflow stage (e.g. `drafting`, `implementing`, `review`, `done`).
61
+ /// `None` for backlog items or if missing from frontmatter.
62
+ pub stage: Option<String>,
63
+
64
+ /// Routing/classification tags (AND semantics in filters).
65
+ pub tags: Vec<String>,
66
+
67
+ /// Parent item id, if any.
68
+ pub parent: Option<String>,
69
+
70
+ /// Ids of items this item depends on (must be terminal before this starts).
71
+ pub depends_on: Vec<String>,
72
+
73
+ /// Release version this item is bound to (late-binding).
74
+ pub release_binding: Option<String>,
75
+
76
+ /// Gate that produced this item (`security`, `tests`, etc.), if any.
77
+ pub gate_origin: Option<String>,
78
+
79
+ /// Research artifact(s) this work item tracks/consumes (Arrow 1, coordination).
80
+ pub research_refs: Vec<String>,
81
+ /// Research artifact that spawned this work item (Arrow 2, grounding).
82
+ pub research_origin: Option<String>,
83
+
84
+ /// Scan artifact that originated/spawned this work item (mirror of research_origin).
85
+ pub scan_origin: Option<String>,
86
+
87
+ /// ISO date when this item was created (`YYYY-MM-DD`).
88
+ pub created: Option<String>,
89
+
90
+ /// ISO date when this item was last updated (`YYYY-MM-DD`).
91
+ pub updated: Option<String>,
92
+
93
+ /// Substrate directory tier (derived from the load directory).
94
+ pub tier: Tier,
95
+
96
+ /// Absolute path to the item file on disk.
97
+ pub path: PathBuf,
98
+
99
+ /// Path relative to the substrate root (for board rendering and compact display).
100
+ pub rel_path: PathBuf,
101
+
102
+ /// Full raw file contents including the frontmatter block.
103
+ ///
104
+ /// Used by the CLI adapter's `--cat` mode (matches `cat "$f"` exactly).
105
+ pub raw_text: String,
106
+
107
+ /// Markdown body text AFTER the closing frontmatter `---` delimiter.
108
+ ///
109
+ /// Used by board rendering. Does NOT include the frontmatter block.
110
+ pub body: String,
111
+ }
112
+
113
+ impl Item {
114
+ /// Returns `true` if this item is considered "terminal" (done/released).
115
+ ///
116
+ /// Terminal conditions:
117
+ /// - Tier is `Releases` or `Archive` (irrespective of stage), OR
118
+ /// - `stage` is `"done"` or `"released"`.
119
+ ///
120
+ /// Path string-matching is intentionally NOT used — `tier` is the
121
+ /// authoritative signal.
122
+ pub fn is_terminal(&self) -> bool {
123
+ match self.tier {
124
+ Tier::Releases | Tier::Archive => true,
125
+ Tier::Active | Tier::Backlog => {
126
+ matches!(self.stage.as_deref(), Some("done") | Some("released"))
127
+ }
128
+ }
129
+ }
130
+ }
131
+
132
+ #[cfg(test)]
133
+ mod tests {
134
+ use super::*;
135
+ use std::path::PathBuf;
136
+
137
+ fn make_item(tier: Tier, stage: Option<&str>) -> Item {
138
+ Item {
139
+ id: "test-id".into(),
140
+ kind: None,
141
+ stage: stage.map(str::to_owned),
142
+ tags: vec![],
143
+ parent: None,
144
+ depends_on: vec![],
145
+ release_binding: None,
146
+ gate_origin: None,
147
+ research_refs: vec![],
148
+ research_origin: None,
149
+ scan_origin: None,
150
+ created: None,
151
+ updated: None,
152
+ tier,
153
+ path: PathBuf::from("/fake/path.md"),
154
+ rel_path: PathBuf::from(".work/active/stories/test-id.md"),
155
+ raw_text: String::new(),
156
+ body: String::new(),
157
+ }
158
+ }
159
+
160
+ #[test]
161
+ fn is_terminal_releases_tier_always_terminal() {
162
+ assert!(make_item(Tier::Releases, None).is_terminal());
163
+ assert!(make_item(Tier::Releases, Some("planned")).is_terminal());
164
+ assert!(make_item(Tier::Releases, Some("released")).is_terminal());
165
+ }
166
+
167
+ #[test]
168
+ fn is_terminal_archive_tier_always_terminal() {
169
+ assert!(make_item(Tier::Archive, None).is_terminal());
170
+ assert!(make_item(Tier::Archive, Some("implementing")).is_terminal());
171
+ }
172
+
173
+ #[test]
174
+ fn is_terminal_active_done_stage() {
175
+ assert!(make_item(Tier::Active, Some("done")).is_terminal());
176
+ assert!(make_item(Tier::Active, Some("released")).is_terminal());
177
+ }
178
+
179
+ #[test]
180
+ fn is_terminal_active_non_done_not_terminal() {
181
+ assert!(!make_item(Tier::Active, Some("implementing")).is_terminal());
182
+ assert!(!make_item(Tier::Active, Some("review")).is_terminal());
183
+ assert!(!make_item(Tier::Active, Some("drafting")).is_terminal());
184
+ assert!(!make_item(Tier::Active, None).is_terminal());
185
+ }
186
+
187
+ #[test]
188
+ fn is_terminal_backlog_tier_non_terminal_by_default() {
189
+ // Backlog items are not terminal unless their stage is done/released
190
+ // (which is unusual, but the logic is consistent)
191
+ assert!(!make_item(Tier::Backlog, None).is_terminal());
192
+ assert!(!make_item(Tier::Backlog, Some("implementing")).is_terminal());
193
+ assert!(make_item(Tier::Backlog, Some("done")).is_terminal());
194
+ }
195
+
196
+ #[test]
197
+ fn tier_precedence_order() {
198
+ assert!(Tier::Active.precedence() < Tier::Releases.precedence());
199
+ assert!(Tier::Releases.precedence() < Tier::Archive.precedence());
200
+ assert!(Tier::Archive.precedence() < Tier::Backlog.precedence());
201
+ }
202
+ }
@@ -0,0 +1,494 @@
1
+ //! Frontmatter parsing: split `---`/`---` block, deserialize YAML, build `Item`.
2
+ //!
3
+ //! Key invariants:
4
+ //! - Only `id` is required; all other frontmatter fields are optional.
5
+ //! - Optional scalars are normalized: missing / YAML null / `"null"` / `""` → `None`.
6
+ //! - Arrays (`tags`, `depends_on`) handle both flow style (`[a, b]`) and block
7
+ //! style (`- a\n - b`) natively via serde.
8
+ //! - `raw_text` is the FULL file content (for `--cat` parity with `cat "$f"`).
9
+ //! - `body` is the markdown AFTER the closing `---` (for board rendering).
10
+ //! - Missing `id` or invalid YAML → `ParseError` (non-fatal).
11
+
12
+ use std::path::Path;
13
+
14
+ use serde::Deserialize;
15
+
16
+ use crate::{
17
+ error::ParseError,
18
+ model::{Item, Tier},
19
+ };
20
+
21
+ /// Raw deserialization target. Only `id` is required; everything else has a
22
+ /// serde default so backlog items (which omit `kind`/`stage`) parse cleanly.
23
+ #[derive(Debug, Deserialize)]
24
+ struct RawFrontmatter {
25
+ id: String,
26
+ #[serde(default)]
27
+ kind: Option<String>,
28
+ #[serde(default)]
29
+ stage: Option<String>,
30
+ #[serde(default)]
31
+ tags: Vec<String>,
32
+ #[serde(default)]
33
+ parent: Option<String>,
34
+ #[serde(default)]
35
+ depends_on: Vec<String>,
36
+ #[serde(default)]
37
+ release_binding: Option<String>,
38
+ #[serde(default)]
39
+ gate_origin: Option<String>,
40
+ #[serde(default)]
41
+ research_refs: Vec<String>,
42
+ #[serde(default)]
43
+ research_origin: Option<String>,
44
+ #[serde(default)]
45
+ scan_origin: Option<String>,
46
+ #[serde(default)]
47
+ created: Option<String>,
48
+ #[serde(default)]
49
+ updated: Option<String>,
50
+ }
51
+
52
+ /// Normalize an optional scalar: missing / YAML null / literal `"null"` /
53
+ /// empty-or-whitespace → `None`.
54
+ fn normalize_optional(v: Option<String>) -> Option<String> {
55
+ match v {
56
+ None => None,
57
+ Some(s) => {
58
+ let trimmed = s.trim();
59
+ if trimmed.is_empty() || trimmed == "null" {
60
+ None
61
+ } else {
62
+ Some(trimmed.to_owned())
63
+ }
64
+ }
65
+ }
66
+ }
67
+
68
+ /// Normalize a `Vec<String>` by trimming each element and removing any
69
+ /// entry that normalizes to empty/null (defensive, in case YAML gives us
70
+ /// odd entries).
71
+ fn normalize_vec(v: Vec<String>) -> Vec<String> {
72
+ v.into_iter()
73
+ .filter_map(|s| {
74
+ let t = s.trim().to_owned();
75
+ if t.is_empty() || t == "null" {
76
+ None
77
+ } else {
78
+ Some(t)
79
+ }
80
+ })
81
+ .collect()
82
+ }
83
+
84
+ /// Split a file's text into (frontmatter_yaml, body_after_frontmatter).
85
+ ///
86
+ /// Returns `None` if no valid `---`/`---` frontmatter block is found.
87
+ /// `body` is everything after the closing `---\n` (or `---` at EOF).
88
+ fn split_frontmatter(text: &str) -> Option<(&str, &str)> {
89
+ // The file must start with `---` (optionally followed by spaces/nothing)
90
+ let after_open = text.strip_prefix("---")?;
91
+ // After the opening `---` there must be a newline (or just `---\n`)
92
+ let after_open = if let Some(rest) = after_open.strip_prefix('\n') {
93
+ rest
94
+ } else if let Some(rest) = after_open.strip_prefix("\r\n") {
95
+ rest
96
+ } else {
97
+ // `---` must be alone on the first line
98
+ return None;
99
+ };
100
+
101
+ // Find the closing `---` on its own line
102
+ // We search for `\n---` to anchor to a line start.
103
+ let close_marker = "\n---";
104
+ let close_pos = after_open.find(close_marker)?;
105
+ let fm_yaml = &after_open[..close_pos];
106
+
107
+ // Body is everything after the closing `---` line (skip optional trailing
108
+ // whitespace on that line and the newline that follows).
109
+ let rest = &after_open[close_pos + close_marker.len()..];
110
+ let body = if let Some(stripped) = rest.strip_prefix('\n') {
111
+ stripped
112
+ } else if let Some(stripped) = rest.strip_prefix("\r\n") {
113
+ stripped
114
+ } else if rest.starts_with([' ', '\t', '\r']) {
115
+ // `--- \n` — skip trailing spaces then newline
116
+ let trimmed = rest.trim_start_matches([' ', '\t', '\r']);
117
+ if let Some(stripped) = trimmed.strip_prefix('\n') {
118
+ stripped
119
+ } else {
120
+ trimmed
121
+ }
122
+ } else {
123
+ rest
124
+ };
125
+
126
+ Some((fm_yaml, body))
127
+ }
128
+
129
+ /// Parse a single `.work/` item file.
130
+ ///
131
+ /// # Arguments
132
+ /// - `path` — absolute path to the file (stored on the `Item`)
133
+ /// - `rel` — path relative to the substrate root (stored on the `Item`)
134
+ /// - `tier` — which tier directory this file came from
135
+ /// - `text` — full raw file contents (stored as `raw_text`)
136
+ ///
137
+ /// # Errors
138
+ /// Returns `ParseError` if:
139
+ /// - The file contains no valid frontmatter block.
140
+ /// - The frontmatter YAML is syntactically invalid.
141
+ /// - The frontmatter lacks an `id` field.
142
+ pub fn parse_item(path: &Path, rel: &Path, tier: Tier, text: &str) -> Result<Item, ParseError> {
143
+ let (fm_yaml, body) = split_frontmatter(text).ok_or_else(|| ParseError {
144
+ path: path.to_owned(),
145
+ reason: "no valid frontmatter block (expected `---` / `---` delimiters)".into(),
146
+ })?;
147
+
148
+ let raw: RawFrontmatter = serde_yaml_ng::from_str(fm_yaml).map_err(|e| ParseError {
149
+ path: path.to_owned(),
150
+ reason: format!("invalid YAML frontmatter: {e}"),
151
+ })?;
152
+
153
+ // Validate id is non-empty after trimming
154
+ let id = raw.id.trim().to_owned();
155
+ if id.is_empty() {
156
+ return Err(ParseError {
157
+ path: path.to_owned(),
158
+ reason: "frontmatter `id` field is empty".into(),
159
+ });
160
+ }
161
+
162
+ Ok(Item {
163
+ id,
164
+ kind: normalize_optional(raw.kind),
165
+ stage: normalize_optional(raw.stage),
166
+ tags: normalize_vec(raw.tags),
167
+ parent: normalize_optional(raw.parent),
168
+ depends_on: normalize_vec(raw.depends_on),
169
+ release_binding: normalize_optional(raw.release_binding),
170
+ gate_origin: normalize_optional(raw.gate_origin),
171
+ research_refs: normalize_vec(raw.research_refs),
172
+ research_origin: normalize_optional(raw.research_origin),
173
+ scan_origin: normalize_optional(raw.scan_origin),
174
+ created: normalize_optional(raw.created),
175
+ updated: normalize_optional(raw.updated),
176
+ tier,
177
+ path: path.to_owned(),
178
+ rel_path: rel.to_owned(),
179
+ raw_text: text.to_owned(),
180
+ body: body.to_owned(),
181
+ })
182
+ }
183
+
184
+ #[cfg(test)]
185
+ mod tests {
186
+ use super::*;
187
+ use std::path::PathBuf;
188
+
189
+ fn p(s: &str) -> PathBuf {
190
+ PathBuf::from(s)
191
+ }
192
+
193
+ /// Helper: parse with Active tier and dummy paths.
194
+ fn parse(text: &str) -> Result<Item, ParseError> {
195
+ parse_item(
196
+ &p("/repo/.work/active/features/test.md"),
197
+ &p(".work/active/features/test.md"),
198
+ Tier::Active,
199
+ text,
200
+ )
201
+ }
202
+
203
+ #[test]
204
+ fn full_item_parses_correctly() {
205
+ let text = "\
206
+ ---
207
+ id: my-feature
208
+ kind: feature
209
+ stage: implementing
210
+ tags: [tooling, perf]
211
+ parent: my-epic
212
+ depends_on: [dep-a, dep-b]
213
+ release_binding: v1.0.0
214
+ gate_origin: null
215
+ created: 2026-01-01
216
+ updated: 2026-05-30
217
+ ---
218
+
219
+ # My Feature
220
+
221
+ Some body text.
222
+ ";
223
+ let item = parse(text).unwrap();
224
+ assert_eq!(item.id, "my-feature");
225
+ assert_eq!(item.kind.as_deref(), Some("feature"));
226
+ assert_eq!(item.stage.as_deref(), Some("implementing"));
227
+ assert_eq!(item.tags, vec!["tooling", "perf"]);
228
+ assert_eq!(item.parent.as_deref(), Some("my-epic"));
229
+ assert_eq!(item.depends_on, vec!["dep-a", "dep-b"]);
230
+ assert_eq!(item.release_binding.as_deref(), Some("v1.0.0"));
231
+ assert!(item.gate_origin.is_none());
232
+ assert!(item.research_refs.is_empty());
233
+ assert!(item.research_origin.is_none());
234
+ assert_eq!(item.created.as_deref(), Some("2026-01-01"));
235
+ assert_eq!(item.updated.as_deref(), Some("2026-05-30"));
236
+ }
237
+
238
+ #[test]
239
+ fn raw_text_is_full_file_body_excludes_frontmatter() {
240
+ let text = "\
241
+ ---
242
+ id: abc
243
+ ---
244
+
245
+ # Title
246
+
247
+ Body paragraph.
248
+ ";
249
+ let item = parse(text).unwrap();
250
+ // raw_text is the full file
251
+ assert_eq!(item.raw_text, text);
252
+ // body is after the closing `---`
253
+ assert!(item.body.contains("# Title"));
254
+ assert!(item.body.contains("Body paragraph."));
255
+ // body must NOT contain the frontmatter
256
+ assert!(!item.body.contains("id: abc"));
257
+ }
258
+
259
+ #[test]
260
+ fn backlog_minimal_item_parses_with_none_kind_stage() {
261
+ let text = "\
262
+ ---
263
+ id: my-backlog-idea
264
+ created: 2026-01-15
265
+ tags: [tooling]
266
+ ---
267
+
268
+ An unscoped idea capture.
269
+ ";
270
+ let item = parse_item(
271
+ &p("/repo/.work/backlog/my-backlog-idea.md"),
272
+ &p(".work/backlog/my-backlog-idea.md"),
273
+ Tier::Backlog,
274
+ text,
275
+ )
276
+ .unwrap();
277
+ assert_eq!(item.id, "my-backlog-idea");
278
+ assert!(item.kind.is_none(), "backlog item should have None kind");
279
+ assert!(item.stage.is_none(), "backlog item should have None stage");
280
+ assert_eq!(item.tags, vec!["tooling"]);
281
+ assert!(item.depends_on.is_empty());
282
+ }
283
+
284
+ #[test]
285
+ fn flow_array_tags_parse() {
286
+ let text = "---\nid: x\ntags: [alpha, beta, gamma]\n---\n";
287
+ let item = parse(text).unwrap();
288
+ assert_eq!(item.tags, vec!["alpha", "beta", "gamma"]);
289
+ }
290
+
291
+ #[test]
292
+ fn block_array_tags_parse_identically() {
293
+ let text = "---\nid: x\ntags:\n - alpha\n - beta\n - gamma\n---\n";
294
+ let item = parse(text).unwrap();
295
+ assert_eq!(item.tags, vec!["alpha", "beta", "gamma"]);
296
+ }
297
+
298
+ #[test]
299
+ fn flow_and_block_arrays_identical() {
300
+ let flow = "---\nid: x\ntags: [alpha, beta]\ndepends_on: [dep-1, dep-2]\n---\n";
301
+ let block =
302
+ "---\nid: x\ntags:\n - alpha\n - beta\ndepends_on:\n - dep-1\n - dep-2\n---\n";
303
+ let flow_item = parse(flow).unwrap();
304
+ let block_item = parse(block).unwrap();
305
+ assert_eq!(flow_item.tags, block_item.tags);
306
+ assert_eq!(flow_item.depends_on, block_item.depends_on);
307
+ }
308
+
309
+ #[test]
310
+ fn null_string_literal_normalizes_to_none() {
311
+ let text = "---\nid: x\nparent: \"null\"\nrelease_binding: null\ngate_origin: null\n---\n";
312
+ let item = parse(text).unwrap();
313
+ assert!(
314
+ item.parent.is_none(),
315
+ "literal `\"null\"` string should be None"
316
+ );
317
+ assert!(item.release_binding.is_none());
318
+ assert!(item.gate_origin.is_none());
319
+ }
320
+
321
+ #[test]
322
+ fn empty_string_optional_normalizes_to_none() {
323
+ let text = "---\nid: x\nparent: \"\"\nrelease_binding: \"\"\n---\n";
324
+ let item = parse(text).unwrap();
325
+ assert!(item.parent.is_none());
326
+ assert!(item.release_binding.is_none());
327
+ }
328
+
329
+ #[test]
330
+ fn missing_optional_fields_are_none() {
331
+ let text = "---\nid: x\n---\n";
332
+ let item = parse(text).unwrap();
333
+ assert!(item.kind.is_none());
334
+ assert!(item.stage.is_none());
335
+ assert!(item.parent.is_none());
336
+ assert!(item.release_binding.is_none());
337
+ assert!(item.gate_origin.is_none());
338
+ assert!(item.research_refs.is_empty());
339
+ assert!(item.research_origin.is_none());
340
+ assert!(item.created.is_none());
341
+ assert!(item.updated.is_none());
342
+ assert!(item.tags.is_empty());
343
+ assert!(item.depends_on.is_empty());
344
+ }
345
+
346
+ #[test]
347
+ fn missing_id_returns_parse_error() {
348
+ let text = "---\nkind: feature\nstage: implementing\n---\n";
349
+ let err = parse(text).unwrap_err();
350
+ assert!(
351
+ err.reason.contains("id") || err.reason.contains("missing"),
352
+ "error should mention id, got: {}",
353
+ err.reason
354
+ );
355
+ }
356
+
357
+ #[test]
358
+ fn invalid_yaml_returns_parse_error() {
359
+ let text = "---\nid: [not valid: yaml: {\n---\n";
360
+ assert!(parse(text).is_err());
361
+ }
362
+
363
+ #[test]
364
+ fn no_frontmatter_block_returns_parse_error() {
365
+ let text = "# Just markdown\nNo frontmatter here.\n";
366
+ assert!(parse(text).is_err());
367
+ }
368
+
369
+ #[test]
370
+ fn empty_id_field_returns_parse_error() {
371
+ let text = "---\nid: \"\"\n---\n";
372
+ let err = parse(text).unwrap_err();
373
+ assert!(err.reason.contains("empty"));
374
+ }
375
+
376
+ #[test]
377
+ fn whitespace_scalar_strips() {
378
+ // YAML should strip surrounding quotes; the scalar value is the content
379
+ let text = "---\nid: my-id\nparent: \" my-parent \"\n---\n";
380
+ let item = parse(text).unwrap();
381
+ // serde_yaml_ng should give us " my-parent " (with spaces); we normalize trim
382
+ // Actually YAML quoted strings preserve interior whitespace but strip the quotes.
383
+ // After our normalize_optional, leading/trailing spaces are trimmed.
384
+ assert_eq!(item.parent.as_deref(), Some("my-parent"));
385
+ }
386
+
387
+ #[test]
388
+ fn tier_and_paths_stored_correctly() {
389
+ let text = "---\nid: x\n---\n";
390
+ let abs = p("/repo/.work/archive/old.md");
391
+ let rel = p(".work/archive/old.md");
392
+ let item = parse_item(&abs, &rel, Tier::Archive, text).unwrap();
393
+ assert_eq!(item.tier, Tier::Archive);
394
+ assert_eq!(item.path, abs);
395
+ assert_eq!(item.rel_path, rel);
396
+ }
397
+
398
+ #[test]
399
+ fn research_refs_flow_array_parses() {
400
+ let text = "---\nid: x\nresearch_refs: [slug-a, slug-b]\n---\n";
401
+ let item = parse(text).unwrap();
402
+ assert_eq!(item.research_refs, vec!["slug-a", "slug-b"]);
403
+ }
404
+
405
+ #[test]
406
+ fn research_refs_block_array_parses_identically() {
407
+ let text = "---\nid: x\nresearch_refs:\n - slug-a\n - slug-b\n---\n";
408
+ let item = parse(text).unwrap();
409
+ assert_eq!(item.research_refs, vec!["slug-a", "slug-b"]);
410
+ }
411
+
412
+ #[test]
413
+ fn research_refs_missing_yields_empty_vec() {
414
+ let text = "---\nid: x\n---\n";
415
+ let item = parse(text).unwrap();
416
+ assert!(item.research_refs.is_empty());
417
+ }
418
+
419
+ #[test]
420
+ fn research_origin_null_literal_normalizes_to_none() {
421
+ let text = "---\nid: x\nresearch_origin: \"null\"\n---\n";
422
+ let item = parse(text).unwrap();
423
+ assert!(
424
+ item.research_origin.is_none(),
425
+ "literal `\"null\"` should normalize to None"
426
+ );
427
+ }
428
+
429
+ #[test]
430
+ fn research_origin_yaml_null_normalizes_to_none() {
431
+ let text = "---\nid: x\nresearch_origin: null\n---\n";
432
+ let item = parse(text).unwrap();
433
+ assert!(item.research_origin.is_none());
434
+ }
435
+
436
+ #[test]
437
+ fn research_origin_empty_string_normalizes_to_none() {
438
+ let text = "---\nid: x\nresearch_origin: \"\"\n---\n";
439
+ let item = parse(text).unwrap();
440
+ assert!(item.research_origin.is_none());
441
+ }
442
+
443
+ #[test]
444
+ fn research_origin_missing_normalizes_to_none() {
445
+ let text = "---\nid: x\n---\n";
446
+ let item = parse(text).unwrap();
447
+ assert!(item.research_origin.is_none());
448
+ }
449
+
450
+ #[test]
451
+ fn research_origin_value_preserved() {
452
+ let text = "---\nid: x\nresearch_origin: pos-x\n---\n";
453
+ let item = parse(text).unwrap();
454
+ assert_eq!(item.research_origin.as_deref(), Some("pos-x"));
455
+ }
456
+
457
+ #[test]
458
+ fn scan_origin_null_literal_normalizes_to_none() {
459
+ let text = "---\nid: x\nscan_origin: \"null\"\n---\n";
460
+ let item = parse(text).unwrap();
461
+ assert!(
462
+ item.scan_origin.is_none(),
463
+ "literal `\"null\"` should normalize to None"
464
+ );
465
+ }
466
+
467
+ #[test]
468
+ fn scan_origin_yaml_null_normalizes_to_none() {
469
+ let text = "---\nid: x\nscan_origin: null\n---\n";
470
+ let item = parse(text).unwrap();
471
+ assert!(item.scan_origin.is_none());
472
+ }
473
+
474
+ #[test]
475
+ fn scan_origin_empty_string_normalizes_to_none() {
476
+ let text = "---\nid: x\nscan_origin: \"\"\n---\n";
477
+ let item = parse(text).unwrap();
478
+ assert!(item.scan_origin.is_none());
479
+ }
480
+
481
+ #[test]
482
+ fn scan_origin_missing_normalizes_to_none() {
483
+ let text = "---\nid: x\n---\n";
484
+ let item = parse(text).unwrap();
485
+ assert!(item.scan_origin.is_none());
486
+ }
487
+
488
+ #[test]
489
+ fn scan_origin_value_preserved() {
490
+ let text = "---\nid: x\nscan_origin: scan-x\n---\n";
491
+ let item = parse(text).unwrap();
492
+ assert_eq!(item.scan_origin.as_deref(), Some("scan-x"));
493
+ }
494
+ }