@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,792 @@
1
+ //! Arg parsing for the work-view CLI.
2
+ //!
3
+ //! Hand-rolled to preserve binary-size goals and full exit-code control.
4
+ //! Originally mirrored `work-view.sh`'s arg loop, with one intentional
5
+ //! tightening: a missing flag value (e.g. `--stage` at end of args, or
6
+ //! `--stage --other`) is a `UsageError`, where bash's blind `shift 2` would
7
+ //! silently consume the next flag as the value. The Rust binary is now the
8
+ //! canonical work-view — bash<->Rust byte-parity is no longer enforced, and
9
+ //! `--scope` is Rust-only (the bash script is a frozen degraded fallback).
10
+
11
+ use work_view_core::filter::{Filter, Match};
12
+ use work_view_core::model::Tier;
13
+
14
+ // ── Version stamp ───────────────────────────────────────────────────────────────
15
+
16
+ /// The agile-workflow **plugin** version, compiled in from a committed generated
17
+ /// file. `bump-version.sh` projects `plugin.json`'s version into
18
+ /// `crates/cli/.work-view-version` (and the bash fallback's literal) in lockstep,
19
+ /// so every build path — CI cross-build and local `cargo test` — bakes the right
20
+ /// value with no env wiring.
21
+ ///
22
+ /// The file is written with NO trailing newline (`printf '%s'`), so the raw
23
+ /// `include_str!` yields the bare semver; no `.trim()` is needed (and a `const`
24
+ /// can't call `.trim()` on a non-const-fn path cleanly). The relative path steps
25
+ /// up from `src/args.rs` to the crate root where the file lives.
26
+ pub const WORK_VIEW_VERSION: &str = include_str!("../.work-view-version");
27
+
28
+ // ── Output mode ───────────────────────────────────────────────────────────────
29
+
30
+ /// How to present matching items on stdout.
31
+ #[derive(Debug, PartialEq, Default)]
32
+ pub enum OutputMode {
33
+ /// Default: formatted table with ID/KIND/STAGE/TAGS/PARENT columns.
34
+ #[default]
35
+ Table,
36
+ /// One absolute file path per line.
37
+ Paths,
38
+ /// Full raw item text, separated by blank/---/blank between items.
39
+ Cat,
40
+ /// Match count as a single integer.
41
+ Count,
42
+ }
43
+
44
+ // ── Dependency view ───────────────────────────────────────────────────────────
45
+
46
+ /// Post-filter applied after `query()`.
47
+ ///
48
+ /// `All` is the only variant produced by item 1 (adapter); `next-actionable`
49
+ /// adds `Ready`/`Blocked`.
50
+ #[derive(Debug, PartialEq, Default)]
51
+ pub enum DependencyView {
52
+ /// Return all query results unchanged (default).
53
+ #[default]
54
+ All,
55
+ /// Active-tier items at a movable stage whose deps are satisfied.
56
+ Ready,
57
+ /// Active-tier items at a movable stage whose deps are NOT satisfied.
58
+ Blocked,
59
+ }
60
+
61
+ // ── Tier scope ──────────────────────────────────────────────────────────────
62
+
63
+ /// Which substrate tiers a query surfaces.
64
+ ///
65
+ /// Applied as a CLI-layer post-filter (see `scope.rs`), parallel to
66
+ /// `DependencyView`. The core `query()` and the board stay scope-agnostic — the
67
+ /// board must keep rendering every tier, so the default lives here, not in core.
68
+ ///
69
+ /// The default (`NonTerminal`) hides the terminal tiers (`Releases` + `Archive`),
70
+ /// which grow without bound, while keeping the live working set (`Active` +
71
+ /// `Backlog`). This reuses the `is_terminal`-by-tier line from the model.
72
+ #[derive(Debug, PartialEq, Eq, Default, Clone, Copy)]
73
+ pub enum TierScope {
74
+ /// Default: non-terminal tiers only (`Active` + `Backlog`). There is no
75
+ /// user-facing token for this set — it is the absence of `--scope`.
76
+ #[default]
77
+ NonTerminal,
78
+ /// `Active` tier only.
79
+ Active,
80
+ /// `Backlog` tier only.
81
+ Backlog,
82
+ /// `Releases` tier only.
83
+ Releases,
84
+ /// `Archive` tier only.
85
+ Archive,
86
+ /// Every tier — reproduces the pre-`--scope` all-tier behavior.
87
+ All,
88
+ }
89
+
90
+ impl TierScope {
91
+ /// Returns `true` if `tier` is included in this scope.
92
+ pub fn includes(self, tier: Tier) -> bool {
93
+ match self {
94
+ TierScope::NonTerminal => matches!(tier, Tier::Active | Tier::Backlog),
95
+ TierScope::Active => tier == Tier::Active,
96
+ TierScope::Backlog => tier == Tier::Backlog,
97
+ TierScope::Releases => tier == Tier::Releases,
98
+ TierScope::Archive => tier == Tier::Archive,
99
+ TierScope::All => true,
100
+ }
101
+ }
102
+ }
103
+
104
+ // ── Options ───────────────────────────────────────────────────────────────────
105
+
106
+ /// Fully-parsed options for a single work-view invocation.
107
+ #[derive(Debug, Default)]
108
+ pub struct CliOptions {
109
+ /// Filter to apply against the substrate.
110
+ pub filter: Filter,
111
+ /// Output representation.
112
+ pub output: OutputMode,
113
+ /// Dependency-view post-filter (extended by `next-actionable`).
114
+ pub dependency_view: DependencyView,
115
+ /// Tier-scope post-filter (default: non-terminal tiers).
116
+ pub scope: TierScope,
117
+ /// When `true`, restrict output to stale backlog items (those whose
118
+ /// last-touched date exceeds `backlog_staleness_days` in CONVENTIONS.md).
119
+ pub stale: bool,
120
+ }
121
+
122
+ // ── Outcome ───────────────────────────────────────────────────────────────────
123
+
124
+ /// The result of a successful parse.
125
+ #[derive(Debug)]
126
+ pub enum ParseOutcome {
127
+ /// `--help` / `-h` was passed; caller should print `HELP` and exit 0.
128
+ Help,
129
+ /// `--version` / `-V` was passed; caller should print the version line
130
+ /// (`work-view <semver>`) and exit 0.
131
+ Version,
132
+ /// A valid `CliOptions` was produced; caller should run the query.
133
+ Run(CliOptions),
134
+ }
135
+
136
+ // ── Error ─────────────────────────────────────────────────────────────────────
137
+
138
+ /// A parse error that should be printed to stderr; main maps to exit 1.
139
+ #[derive(Debug)]
140
+ pub struct UsageError(pub String);
141
+
142
+ impl std::fmt::Display for UsageError {
143
+ fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
144
+ write!(f, "work-view: {}", self.0)
145
+ }
146
+ }
147
+
148
+ // ── Help text ─────────────────────────────────────────────────────────────────
149
+
150
+ pub const HELP: &str = "\
151
+ work-view — query items in the agile-workflow substrate
152
+
153
+ Usage: work-view [FILTERS...] [OUTPUT]
154
+ work-view board [OPTIONS]
155
+ work-view serve [OPTIONS]
156
+
157
+ Subcommands:
158
+ board, serve Serve the live substrate board (see `work-view board --help`)
159
+
160
+ Filters (compose with AND semantics):
161
+ --stage <stage> Items at the given stage
162
+ --tag <tag> Items with the given tag (repeatable, AND)
163
+ --kind <kind> Items of the given kind (epic|feature|story|release)
164
+ --parent <id> Direct children of the given item
165
+ --release <version> Items with release_binding: <version>
166
+ --gate <name> Items with gate_origin: <name>
167
+ --research-origin <s> Items with research_origin: <s>
168
+ --scan-origin <s> Items with scan_origin: <s>
169
+ --research-refs <s> Items whose research_refs contains <s>
170
+ --ready Active items at drafting/implementing/review with all depends_on done
171
+ --blocked Active items at drafting/implementing/review with unmet dependencies
172
+ --blocking <id> Items that depend on <id>
173
+ --stale Backlog items whose last-touched date exceeds backlog_staleness_days
174
+ configured in .work/CONVENTIONS.md (absent key → notice + exit 0)
175
+
176
+ Scope (default: active + backlog; terminal tiers hidden):
177
+ --scope <scope> Tiers to include: active|backlog|releases|archive|all
178
+ (--release/--gate widen to all unless --scope is set)
179
+
180
+ Output (default tabular):
181
+ --paths One file path per line
182
+ --cat Full item bodies (separated by ---)
183
+ --count Match count only
184
+
185
+ Other:
186
+ --version Print the work-view version and exit
187
+ --help Show this help and exit\
188
+ ";
189
+
190
+ // ── Internal helpers ──────────────────────────────────────────────────────────
191
+
192
+ /// Map the literal string `"null"` to `Match::IsNull`; anything else to
193
+ /// `Match::Equals`.
194
+ ///
195
+ /// This is necessary because the Rust core normalises YAML `null` to `None`,
196
+ /// so `Equals("null")` would never match. The bash `work-view.sh` stores the
197
+ /// raw YAML string `"null"` in its associative arrays and tests against it
198
+ /// directly. Mapping the CLI argument `"null"` to `IsNull` preserves
199
+ /// equivalent behaviour.
200
+ fn nullable_match(value: String) -> Match {
201
+ if value == "null" {
202
+ Match::IsNull
203
+ } else {
204
+ Match::Equals(value)
205
+ }
206
+ }
207
+
208
+ /// Pull the next argument as the value for a flag that requires one.
209
+ ///
210
+ /// Returns `UsageError` if:
211
+ /// - the iterator is exhausted, or
212
+ /// - the next token starts with `-` (looks like another flag).
213
+ ///
214
+ /// This is the intentional tightening over bash's blind `shift 2`.
215
+ fn next_value<I: Iterator<Item = String>>(
216
+ flag: &str,
217
+ iter: &mut std::iter::Peekable<I>,
218
+ ) -> Result<String, UsageError> {
219
+ match iter.peek() {
220
+ None => Err(UsageError(format!("missing value for {flag}"))),
221
+ Some(next) if next.starts_with('-') => Err(UsageError(format!("missing value for {flag}"))),
222
+ Some(_) => Ok(iter.next().expect("peeked Some")),
223
+ }
224
+ }
225
+
226
+ // ── Public parser ─────────────────────────────────────────────────────────────
227
+
228
+ /// Parse a whitespace-free iterator of argument strings into a
229
+ /// [`ParseOutcome`] (or [`UsageError`]).
230
+ ///
231
+ /// Contract:
232
+ /// - `--stage|--kind <v>` → `Match::Equals(v)` on the matching filter field
233
+ /// - `--parent|--release|--gate <v>` → `Equals(v)`, but literal `"null"` → `IsNull`
234
+ /// - `--research-origin <v>` → `filter.research` via `nullable_match` (like `--gate`)
235
+ /// - `--scan-origin <v>` → `filter.scan` via `nullable_match` (like `--research-origin`)
236
+ /// - `--tag <v>` (repeatable) → appended to `filter.tags` (AND semantics)
237
+ /// - `--blocking <id>` → `filter.blocking = Some(id)`
238
+ /// - `--research-refs <slug>` → `filter.research_refs = Some(slug)` (like `--blocking`)
239
+ /// - `--paths|--cat|--count` → set output mode (last-wins)
240
+ /// - `--ready` → `dependency_view = DependencyView::Ready`
241
+ /// - `--blocked` → `dependency_view = DependencyView::Blocked`
242
+ /// - `--ready` AND `--blocked` both present → `UsageError` (mutually exclusive)
243
+ /// - `--scope <active|backlog|releases|archive|all>` → `scope`; unknown → `UsageError`
244
+ /// - `--release`/`--gate` set without explicit `--scope` → `scope = All` (implicit-widen)
245
+ /// - `--help|-h` → `ParseOutcome::Help`
246
+ /// - `--version|-V` → `ParseOutcome::Version`
247
+ /// - `--` → stop flag parsing; subsequent positionals → `UsageError`
248
+ /// - unknown flag / positional / missing value → `UsageError`
249
+ pub fn parse_args<I: Iterator<Item = String>>(args: I) -> Result<ParseOutcome, UsageError> {
250
+ let mut opts = CliOptions::default();
251
+ let mut iter = args.peekable();
252
+ let mut flags_done = false;
253
+ let mut saw_ready = false;
254
+ let mut saw_blocked = false;
255
+ let mut saw_scope = false;
256
+
257
+ while let Some(arg) = iter.next() {
258
+ if flags_done {
259
+ return Err(UsageError(format!("unexpected argument: {arg}")));
260
+ }
261
+
262
+ match arg.as_str() {
263
+ "--" => {
264
+ flags_done = true;
265
+ }
266
+ "--help" | "-h" => {
267
+ return Ok(ParseOutcome::Help);
268
+ }
269
+ // `-V` (uppercase) is the conventional short form for --version.
270
+ // Lowercase `-v` is deliberately NOT used — reserve it for a
271
+ // possible future `--verbose`. Placed before the generic
272
+ // `flag.starts_with('-')` arm so --version is not "unknown flag".
273
+ "--version" | "-V" => {
274
+ return Ok(ParseOutcome::Version);
275
+ }
276
+ "--stage" => {
277
+ let v = next_value("--stage", &mut iter)?;
278
+ opts.filter.stage = Match::Equals(v);
279
+ }
280
+ "--kind" => {
281
+ let v = next_value("--kind", &mut iter)?;
282
+ opts.filter.kind = Match::Equals(v);
283
+ }
284
+ "--parent" => {
285
+ let v = next_value("--parent", &mut iter)?;
286
+ opts.filter.parent = nullable_match(v);
287
+ }
288
+ "--release" => {
289
+ let v = next_value("--release", &mut iter)?;
290
+ opts.filter.release = nullable_match(v);
291
+ }
292
+ "--gate" => {
293
+ let v = next_value("--gate", &mut iter)?;
294
+ opts.filter.gate = nullable_match(v);
295
+ }
296
+ "--research-origin" => {
297
+ let v = next_value("--research-origin", &mut iter)?;
298
+ opts.filter.research = nullable_match(v);
299
+ }
300
+ "--scan-origin" => {
301
+ let v = next_value("--scan-origin", &mut iter)?;
302
+ opts.filter.scan = nullable_match(v);
303
+ }
304
+ "--research-refs" => {
305
+ let v = next_value("--research-refs", &mut iter)?;
306
+ opts.filter.research_refs = Some(v);
307
+ }
308
+ "--tag" => {
309
+ let v = next_value("--tag", &mut iter)?;
310
+ opts.filter.tags.push(v);
311
+ }
312
+ "--blocking" => {
313
+ let v = next_value("--blocking", &mut iter)?;
314
+ opts.filter.blocking = Some(v);
315
+ }
316
+ "--stale" => {
317
+ opts.stale = true;
318
+ }
319
+ "--ready" => {
320
+ saw_ready = true;
321
+ opts.dependency_view = DependencyView::Ready;
322
+ }
323
+ "--blocked" => {
324
+ saw_blocked = true;
325
+ opts.dependency_view = DependencyView::Blocked;
326
+ }
327
+ "--scope" => {
328
+ let v = next_value("--scope", &mut iter)?;
329
+ opts.scope = match v.as_str() {
330
+ "active" => TierScope::Active,
331
+ "backlog" => TierScope::Backlog,
332
+ "releases" => TierScope::Releases,
333
+ "archive" => TierScope::Archive,
334
+ "all" => TierScope::All,
335
+ other => {
336
+ return Err(UsageError(format!(
337
+ "invalid --scope value: {other} \
338
+ (expected active|backlog|releases|archive|all)"
339
+ )));
340
+ }
341
+ };
342
+ saw_scope = true;
343
+ }
344
+ "--paths" => {
345
+ opts.output = OutputMode::Paths;
346
+ }
347
+ "--cat" => {
348
+ opts.output = OutputMode::Cat;
349
+ }
350
+ "--count" => {
351
+ opts.output = OutputMode::Count;
352
+ }
353
+ flag if flag.starts_with('-') => {
354
+ return Err(UsageError(format!("unknown flag: {flag}")));
355
+ }
356
+ positional => {
357
+ return Err(UsageError(format!("unexpected argument: {positional}")));
358
+ }
359
+ }
360
+ }
361
+
362
+ if saw_ready && saw_blocked {
363
+ return Err(UsageError(
364
+ "--ready and --blocked are mutually exclusive".to_string(),
365
+ ));
366
+ }
367
+
368
+ // Implicit-widen: `--release` / `--gate` are lifecycle-spanning selectors.
369
+ // A query keyed on `release_binding` or `gate_origin` is inherently a
370
+ // lifetime query — release-deploy `git mv`s bound items into the terminal
371
+ // `releases/` tier on ship — so widen scope to `All` unless the user set
372
+ // `--scope` explicitly. Explicit `--scope` always wins.
373
+ if !saw_scope && (opts.filter.release != Match::Any || opts.filter.gate != Match::Any) {
374
+ opts.scope = TierScope::All;
375
+ }
376
+
377
+ Ok(ParseOutcome::Run(opts))
378
+ }
379
+
380
+ // ── Tests ─────────────────────────────────────────────────────────────────────
381
+
382
+ #[cfg(test)]
383
+ mod tests {
384
+ use super::*;
385
+ use work_view_core::filter::Match;
386
+
387
+ fn parse(args: &[&str]) -> Result<ParseOutcome, UsageError> {
388
+ parse_args(args.iter().map(|s| s.to_string()))
389
+ }
390
+
391
+ fn run(args: &[&str]) -> CliOptions {
392
+ match parse(args).expect("expected Ok") {
393
+ ParseOutcome::Run(opts) => opts,
394
+ ParseOutcome::Help => panic!("expected Run, got Help"),
395
+ ParseOutcome::Version => panic!("expected Run, got Version"),
396
+ }
397
+ }
398
+
399
+ fn err(args: &[&str]) -> String {
400
+ match parse(args).expect_err("expected UsageError") {
401
+ UsageError(msg) => msg,
402
+ }
403
+ }
404
+
405
+ // ── Happy-path flag mapping ────────────────────────────────────────────
406
+
407
+ #[test]
408
+ fn no_args_produces_default_options() {
409
+ let opts = run(&[]);
410
+ assert_eq!(opts.filter.stage, Match::Any);
411
+ assert_eq!(opts.filter.kind, Match::Any);
412
+ assert_eq!(opts.output, OutputMode::Table);
413
+ assert_eq!(opts.dependency_view, DependencyView::All);
414
+ assert_eq!(opts.scope, TierScope::NonTerminal);
415
+ }
416
+
417
+ // ── --scope flag + implicit-widen ──────────────────────────────────────────
418
+
419
+ #[test]
420
+ fn scope_active_sets_active() {
421
+ assert_eq!(run(&["--scope", "active"]).scope, TierScope::Active);
422
+ }
423
+
424
+ #[test]
425
+ fn scope_all_sets_all() {
426
+ assert_eq!(run(&["--scope", "all"]).scope, TierScope::All);
427
+ }
428
+
429
+ #[test]
430
+ fn scope_each_tier_token_maps() {
431
+ assert_eq!(run(&["--scope", "backlog"]).scope, TierScope::Backlog);
432
+ assert_eq!(run(&["--scope", "releases"]).scope, TierScope::Releases);
433
+ assert_eq!(run(&["--scope", "archive"]).scope, TierScope::Archive);
434
+ }
435
+
436
+ #[test]
437
+ fn scope_invalid_value_is_usage_error() {
438
+ let msg = err(&["--scope", "bogus"]);
439
+ assert!(msg.contains("invalid --scope value"), "got: {msg}");
440
+ assert!(msg.contains("bogus"), "got: {msg}");
441
+ }
442
+
443
+ #[test]
444
+ fn scope_missing_value_is_usage_error() {
445
+ let msg = err(&["--scope"]);
446
+ assert!(msg.contains("missing value"), "got: {msg}");
447
+ assert!(msg.contains("--scope"), "got: {msg}");
448
+ }
449
+
450
+ #[test]
451
+ fn scope_last_wins() {
452
+ assert_eq!(
453
+ run(&["--scope", "archive", "--scope", "all"]).scope,
454
+ TierScope::All
455
+ );
456
+ }
457
+
458
+ #[test]
459
+ fn release_without_scope_implicit_widens_to_all() {
460
+ let opts = run(&["--release", "v1.0.0"]);
461
+ assert_eq!(opts.scope, TierScope::All);
462
+ }
463
+
464
+ #[test]
465
+ fn gate_without_scope_implicit_widens_to_all() {
466
+ let opts = run(&["--gate", "security"]);
467
+ assert_eq!(opts.scope, TierScope::All);
468
+ }
469
+
470
+ #[test]
471
+ fn gate_null_implicit_widens_to_all() {
472
+ // `--gate null` sets Match::IsNull (still "given") → widens.
473
+ let opts = run(&["--gate", "null"]);
474
+ assert_eq!(opts.scope, TierScope::All);
475
+ }
476
+
477
+ #[test]
478
+ fn explicit_scope_overrides_implicit_widen() {
479
+ // Explicit --scope always wins over the --release/--gate widen.
480
+ let opts = run(&["--release", "v1.0.0", "--scope", "active"]);
481
+ assert_eq!(opts.scope, TierScope::Active);
482
+ // Order-independent.
483
+ let opts = run(&["--scope", "active", "--gate", "tests"]);
484
+ assert_eq!(opts.scope, TierScope::Active);
485
+ }
486
+
487
+ #[test]
488
+ fn plain_filters_do_not_widen() {
489
+ // Only --release/--gate widen; --stage/--kind/--tag/--parent do not.
490
+ assert_eq!(run(&["--stage", "done"]).scope, TierScope::NonTerminal);
491
+ assert_eq!(run(&["--kind", "feature"]).scope, TierScope::NonTerminal);
492
+ assert_eq!(run(&["--tag", "tooling"]).scope, TierScope::NonTerminal);
493
+ assert_eq!(run(&["--parent", "epic-x"]).scope, TierScope::NonTerminal);
494
+ }
495
+
496
+ #[test]
497
+ fn stage_flag_sets_equals_match() {
498
+ let opts = run(&["--stage", "implementing"]);
499
+ assert_eq!(opts.filter.stage, Match::Equals("implementing".into()));
500
+ }
501
+
502
+ #[test]
503
+ fn kind_flag_sets_equals_match() {
504
+ let opts = run(&["--kind", "feature"]);
505
+ assert_eq!(opts.filter.kind, Match::Equals("feature".into()));
506
+ }
507
+
508
+ #[test]
509
+ fn parent_non_null_sets_equals_match() {
510
+ let opts = run(&["--parent", "epic-core"]);
511
+ assert_eq!(opts.filter.parent, Match::Equals("epic-core".into()));
512
+ }
513
+
514
+ #[test]
515
+ fn parent_null_sets_is_null_match() {
516
+ let opts = run(&["--parent", "null"]);
517
+ assert_eq!(opts.filter.parent, Match::IsNull);
518
+ }
519
+
520
+ #[test]
521
+ fn release_null_sets_is_null_match() {
522
+ let opts = run(&["--release", "null"]);
523
+ assert_eq!(opts.filter.release, Match::IsNull);
524
+ }
525
+
526
+ #[test]
527
+ fn gate_null_sets_is_null_match() {
528
+ let opts = run(&["--gate", "null"]);
529
+ assert_eq!(opts.filter.gate, Match::IsNull);
530
+ }
531
+
532
+ #[test]
533
+ fn release_non_null_sets_equals() {
534
+ let opts = run(&["--release", "v1.0.0"]);
535
+ assert_eq!(opts.filter.release, Match::Equals("v1.0.0".into()));
536
+ }
537
+
538
+ #[test]
539
+ fn tag_single_accumulates() {
540
+ let opts = run(&["--tag", "tooling"]);
541
+ assert_eq!(opts.filter.tags, vec!["tooling"]);
542
+ }
543
+
544
+ #[test]
545
+ fn tag_multiple_accumulates_and_semantics() {
546
+ let opts = run(&["--tag", "tooling", "--tag", "perf"]);
547
+ assert_eq!(opts.filter.tags, vec!["tooling", "perf"]);
548
+ }
549
+
550
+ #[test]
551
+ fn blocking_flag_sets_blocking() {
552
+ let opts = run(&["--blocking", "epic-core"]);
553
+ assert_eq!(opts.filter.blocking, Some("epic-core".into()));
554
+ }
555
+
556
+ #[test]
557
+ fn output_paths() {
558
+ let opts = run(&["--paths"]);
559
+ assert_eq!(opts.output, OutputMode::Paths);
560
+ }
561
+
562
+ #[test]
563
+ fn output_cat() {
564
+ let opts = run(&["--cat"]);
565
+ assert_eq!(opts.output, OutputMode::Cat);
566
+ }
567
+
568
+ #[test]
569
+ fn output_count() {
570
+ let opts = run(&["--count"]);
571
+ assert_eq!(opts.output, OutputMode::Count);
572
+ }
573
+
574
+ #[test]
575
+ fn output_last_wins() {
576
+ let opts = run(&["--paths", "--count"]);
577
+ assert_eq!(opts.output, OutputMode::Count);
578
+ let opts = run(&["--count", "--cat"]);
579
+ assert_eq!(opts.output, OutputMode::Cat);
580
+ }
581
+
582
+ #[test]
583
+ fn scalar_filter_last_wins() {
584
+ let opts = run(&["--stage", "drafting", "--stage", "review"]);
585
+ assert_eq!(opts.filter.stage, Match::Equals("review".into()));
586
+ }
587
+
588
+ #[test]
589
+ fn ready_flag_sets_dependency_view() {
590
+ let opts = run(&["--ready"]);
591
+ assert_eq!(opts.dependency_view, DependencyView::Ready);
592
+ }
593
+
594
+ #[test]
595
+ fn blocked_flag_sets_dependency_view() {
596
+ let opts = run(&["--blocked"]);
597
+ assert_eq!(opts.dependency_view, DependencyView::Blocked);
598
+ }
599
+
600
+ #[test]
601
+ fn dash_dash_terminates_flag_parsing() {
602
+ let opts = run(&["--stage", "drafting", "--"]);
603
+ assert_eq!(opts.filter.stage, Match::Equals("drafting".into()));
604
+ }
605
+
606
+ #[test]
607
+ fn help_long_returns_help_outcome() {
608
+ assert!(matches!(parse(&["--help"]).unwrap(), ParseOutcome::Help));
609
+ }
610
+
611
+ #[test]
612
+ fn help_short_returns_help_outcome() {
613
+ assert!(matches!(parse(&["-h"]).unwrap(), ParseOutcome::Help));
614
+ }
615
+
616
+ // ── --version flag ─────────────────────────────────────────────────────────
617
+
618
+ #[test]
619
+ fn version_long_returns_version_outcome() {
620
+ assert!(matches!(
621
+ parse(&["--version"]).unwrap(),
622
+ ParseOutcome::Version
623
+ ));
624
+ }
625
+
626
+ #[test]
627
+ fn version_short_returns_version_outcome() {
628
+ assert!(matches!(parse(&["-V"]).unwrap(), ParseOutcome::Version));
629
+ }
630
+
631
+ #[test]
632
+ fn version_is_not_a_usage_error() {
633
+ // Regression guard: --version used to fall through to the generic
634
+ // "unknown flag" arm and produce a UsageError (exit 1).
635
+ assert!(parse(&["--version"]).is_ok(), "--version must parse Ok");
636
+ assert!(parse(&["-V"]).is_ok(), "-V must parse Ok");
637
+ }
638
+
639
+ #[test]
640
+ fn version_stamp_has_no_trailing_newline() {
641
+ // Pins the single approach: .work-view-version is written with no
642
+ // trailing newline and consumed via raw include_str! (no .trim()).
643
+ // A stray newline would print a blank line after the semver and break
644
+ // byte-parity with the bash fallback.
645
+ assert!(
646
+ !WORK_VIEW_VERSION.ends_with('\n'),
647
+ "WORK_VIEW_VERSION must not end with a newline; got {WORK_VIEW_VERSION:?}"
648
+ );
649
+ }
650
+
651
+ #[test]
652
+ fn combined_filters_compose() {
653
+ let opts = run(&[
654
+ "--stage",
655
+ "implementing",
656
+ "--kind",
657
+ "feature",
658
+ "--tag",
659
+ "tooling",
660
+ ]);
661
+ assert_eq!(opts.filter.stage, Match::Equals("implementing".into()));
662
+ assert_eq!(opts.filter.kind, Match::Equals("feature".into()));
663
+ assert_eq!(opts.filter.tags, vec!["tooling"]);
664
+ }
665
+
666
+ // ── Error cases ───────────────────────────────────────────────────────────
667
+
668
+ #[test]
669
+ fn unknown_flag_is_usage_error() {
670
+ let msg = err(&["--frobulate"]);
671
+ assert!(msg.contains("unknown flag"), "got: {msg}");
672
+ assert!(msg.contains("--frobulate"), "got: {msg}");
673
+ }
674
+
675
+ #[test]
676
+ fn positional_arg_is_usage_error() {
677
+ let msg = err(&["somefile.md"]);
678
+ assert!(msg.contains("unexpected argument"), "got: {msg}");
679
+ }
680
+
681
+ #[test]
682
+ fn positional_after_dash_dash_is_usage_error() {
683
+ let msg = err(&["--", "something"]);
684
+ assert!(msg.contains("unexpected argument"), "got: {msg}");
685
+ }
686
+
687
+ #[test]
688
+ fn missing_value_for_stage_is_usage_error() {
689
+ let msg = err(&["--stage"]);
690
+ assert!(msg.contains("missing value"), "got: {msg}");
691
+ assert!(msg.contains("--stage"), "got: {msg}");
692
+ }
693
+
694
+ #[test]
695
+ fn missing_value_stage_followed_by_flag_is_usage_error() {
696
+ let msg = err(&["--stage", "--kind"]);
697
+ assert!(msg.contains("missing value"), "got: {msg}");
698
+ assert!(msg.contains("--stage"), "got: {msg}");
699
+ }
700
+
701
+ #[test]
702
+ fn missing_value_for_kind_is_usage_error() {
703
+ let msg = err(&["--kind"]);
704
+ assert!(msg.contains("missing value"), "got: {msg}");
705
+ }
706
+
707
+ #[test]
708
+ fn missing_value_for_parent_is_usage_error() {
709
+ let msg = err(&["--parent"]);
710
+ assert!(msg.contains("missing value"), "got: {msg}");
711
+ }
712
+
713
+ #[test]
714
+ fn missing_value_for_tag_is_usage_error() {
715
+ let msg = err(&["--tag"]);
716
+ assert!(msg.contains("missing value"), "got: {msg}");
717
+ }
718
+
719
+ #[test]
720
+ fn missing_value_for_blocking_is_usage_error() {
721
+ let msg = err(&["--blocking"]);
722
+ assert!(msg.contains("missing value"), "got: {msg}");
723
+ }
724
+
725
+ #[test]
726
+ fn ready_and_blocked_together_is_usage_error() {
727
+ let msg = err(&["--ready", "--blocked"]);
728
+ assert!(msg.contains("mutually exclusive"), "got: {msg}");
729
+ }
730
+
731
+ #[test]
732
+ fn blocked_and_ready_together_is_usage_error() {
733
+ let msg = err(&["--blocked", "--ready"]);
734
+ assert!(msg.contains("mutually exclusive"), "got: {msg}");
735
+ }
736
+
737
+ // ── --research-origin / --research-refs ───────────────────────────────────
738
+
739
+ #[test]
740
+ fn research_origin_non_null_sets_equals() {
741
+ let opts = run(&["--research-origin", "my-research"]);
742
+ assert_eq!(
743
+ opts.filter.research,
744
+ Match::Equals("my-research".into())
745
+ );
746
+ }
747
+
748
+ #[test]
749
+ fn research_origin_null_sets_is_null_match() {
750
+ let opts = run(&["--research-origin", "null"]);
751
+ assert_eq!(opts.filter.research, Match::IsNull);
752
+ }
753
+
754
+ #[test]
755
+ fn research_refs_flag_sets_research_refs() {
756
+ let opts = run(&["--research-refs", "slug-abc"]);
757
+ assert_eq!(opts.filter.research_refs, Some("slug-abc".into()));
758
+ }
759
+
760
+ #[test]
761
+ fn missing_value_for_research_origin_is_usage_error() {
762
+ let msg = err(&["--research-origin"]);
763
+ assert!(msg.contains("missing value"), "got: {msg}");
764
+ assert!(msg.contains("--research-origin"), "got: {msg}");
765
+ }
766
+
767
+ #[test]
768
+ fn scan_origin_non_null_sets_equals() {
769
+ let opts = run(&["--scan-origin", "my-scan"]);
770
+ assert_eq!(opts.filter.scan, Match::Equals("my-scan".into()));
771
+ }
772
+
773
+ #[test]
774
+ fn scan_origin_null_sets_is_null_match() {
775
+ let opts = run(&["--scan-origin", "null"]);
776
+ assert_eq!(opts.filter.scan, Match::IsNull);
777
+ }
778
+
779
+ #[test]
780
+ fn missing_value_for_scan_origin_is_usage_error() {
781
+ let msg = err(&["--scan-origin"]);
782
+ assert!(msg.contains("missing value"), "got: {msg}");
783
+ assert!(msg.contains("--scan-origin"), "got: {msg}");
784
+ }
785
+
786
+ #[test]
787
+ fn missing_value_for_research_refs_is_usage_error() {
788
+ let msg = err(&["--research-refs"]);
789
+ assert!(msg.contains("missing value"), "got: {msg}");
790
+ assert!(msg.contains("--research-refs"), "got: {msg}");
791
+ }
792
+ }