@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,195 @@
1
+ //! work-view CLI entrypoint.
2
+ //!
3
+ //! Pipeline:
4
+ //! ```text
5
+ //! parse_args(argv)
6
+ //! -> Help: print HELP to stdout, exit 0
7
+ //! -> Version: print `work-view <semver>` to stdout, exit 0
8
+ //! -> UsageError: print to stderr, exit 1
9
+ //! find_substrate_root(cwd)
10
+ //! -> None: print to stderr, exit 2
11
+ //! Substrate::load(root)
12
+ //! -> Err(Io): print to stderr, exit 3
13
+ //! emit parse_errors / validation_warnings / duplicate_ids to stderr
14
+ //! items = substrate.query(&opts.filter)
15
+ //! items = apply_scope(items, opts.scope) // default: non-terminal tiers
16
+ //! items = apply_dependency_view(&sub, items, opts.dependency_view)
17
+ //! render(items, opts.output, stdout)
18
+ //! -> Ok: exit 0
19
+ //! -> Err(BrokenPipe): exit 0 (clean — e.g. `work-view | head`)
20
+ //! -> Err(other): print to stderr, exit 3
21
+ //! ```
22
+ //!
23
+ //! Exit codes:
24
+ //! 0 success (or `--help`, or BrokenPipe)
25
+ //! 1 usage error (bad flags, conflicting flags)
26
+ //! 2 no substrate found
27
+ //! 3 I/O error (load or render)
28
+
29
+ mod actionable;
30
+ mod args;
31
+ mod board;
32
+ mod render;
33
+ mod scope;
34
+ mod stale;
35
+
36
+ use std::env;
37
+ use std::io::{self, ErrorKind};
38
+
39
+ use work_view_core::error::LoadError;
40
+ use work_view_core::index::{find_substrate_root, Substrate};
41
+
42
+ use actionable::apply_dependency_view;
43
+ use args::{parse_args, ParseOutcome, HELP};
44
+ use board::{parse_board_args, run_board, BoardParseOutcome, BOARD_HELP};
45
+ use render::render;
46
+ use scope::apply_scope;
47
+ use stale::{apply_stale, read_staleness_threshold, today_as_days, StalenessThreshold};
48
+
49
+ fn run() -> u8 {
50
+ // ── 1. Parse argv ────────────────────────────────────────────────────────
51
+ let argv: Vec<String> = env::args().skip(1).collect();
52
+ if matches!(argv.first().map(String::as_str), Some("board" | "serve")) {
53
+ let outcome = match parse_board_args(argv.into_iter().skip(1)) {
54
+ Ok(o) => o,
55
+ Err(e) => {
56
+ eprintln!("{e}");
57
+ return 1;
58
+ }
59
+ };
60
+
61
+ return match outcome {
62
+ BoardParseOutcome::Help => {
63
+ println!("{BOARD_HELP}");
64
+ 0
65
+ }
66
+ BoardParseOutcome::Run(opts) => run_board(opts),
67
+ };
68
+ }
69
+
70
+ let outcome = match parse_args(argv.into_iter()) {
71
+ Ok(o) => o,
72
+ Err(e) => {
73
+ eprintln!("{e}");
74
+ return 1;
75
+ }
76
+ };
77
+
78
+ let opts = match outcome {
79
+ ParseOutcome::Help => {
80
+ println!("{HELP}");
81
+ return 0;
82
+ }
83
+ ParseOutcome::Version => {
84
+ println!("work-view {}", args::WORK_VIEW_VERSION);
85
+ return 0;
86
+ }
87
+ ParseOutcome::Run(o) => o,
88
+ };
89
+
90
+ // ── 2. Locate substrate root ─────────────────────────────────────────────
91
+ let cwd = match env::current_dir() {
92
+ Ok(d) => d,
93
+ Err(e) => {
94
+ eprintln!("work-view: cannot determine current directory: {e}");
95
+ return 3;
96
+ }
97
+ };
98
+
99
+ let root = match find_substrate_root(&cwd) {
100
+ Some(r) => r,
101
+ None => {
102
+ eprintln!("work-view: no substrate found (no .work/CONVENTIONS.md in CWD or ancestor)");
103
+ return 2;
104
+ }
105
+ };
106
+
107
+ // ── 3. Load substrate ────────────────────────────────────────────────────
108
+ let (sub, report) = match Substrate::load(&root) {
109
+ Ok(pair) => pair,
110
+ Err(LoadError::Io(e)) => {
111
+ eprintln!("work-view: I/O error loading substrate: {e}");
112
+ return 3;
113
+ }
114
+ };
115
+
116
+ // ── 4. Emit non-fatal diagnostics to stderr (exit unaffected) ────────────
117
+ for pe in &report.parse_errors {
118
+ eprintln!(
119
+ "work-view: parse error in {}: {}",
120
+ pe.path.display(),
121
+ pe.reason
122
+ );
123
+ }
124
+ for vw in &report.validation_warnings {
125
+ eprintln!(
126
+ "work-view: validation warning for item {:?} in {}: {}",
127
+ vw.id.as_deref().unwrap_or("<unknown>"),
128
+ vw.path.display(),
129
+ vw.reason
130
+ );
131
+ }
132
+ for dup in &report.duplicate_ids {
133
+ eprintln!(
134
+ "work-view: duplicate id {:?} at {}: {}",
135
+ dup.id.as_deref().unwrap_or("<unknown>"),
136
+ dup.path.display(),
137
+ dup.reason
138
+ );
139
+ }
140
+
141
+ // ── 5. Query + post-filters ──────────────────────────────────────────────
142
+
143
+ // --stale: a dedicated backlog-staleness query. It bypasses the normal
144
+ // scope / dependency-view pipeline and operates only on backlog items.
145
+ // When backlog_staleness_days is absent from CONVENTIONS.md, print a
146
+ // polite notice to stdout and exit 0 (inert, not an error — mirrors the
147
+ // graceful-skip pattern of opt-in gates).
148
+ let items = if opts.stale {
149
+ match read_staleness_threshold(&root) {
150
+ StalenessThreshold::NotConfigured => {
151
+ // Write the inert notice through the same BrokenPipe-safe path as
152
+ // render (a bare println! panics on a closed pipe, e.g.
153
+ // `work-view --stale | head -n 0`). A broken pipe here is a clean
154
+ // exit 0, matching the documented CLI contract.
155
+ use std::io::Write;
156
+ let stdout = io::stdout();
157
+ let mut out = stdout.lock();
158
+ return match writeln!(out, "work-view: no backlog_staleness_days configured") {
159
+ Ok(()) => 0,
160
+ Err(e) if e.kind() == ErrorKind::BrokenPipe => 0,
161
+ Err(e) => {
162
+ eprintln!("work-view: render error: {e}");
163
+ 3
164
+ }
165
+ };
166
+ }
167
+ StalenessThreshold::Days(threshold) => {
168
+ let today = today_as_days();
169
+ let all_items = sub.query(&opts.filter);
170
+ apply_stale(all_items, threshold, today)
171
+ }
172
+ }
173
+ } else {
174
+ let items = sub.query(&opts.filter);
175
+ let items = apply_scope(items, opts.scope);
176
+ apply_dependency_view(&sub, items, opts.dependency_view)
177
+ };
178
+
179
+ // ── 6. Render ────────────────────────────────────────────────────────────
180
+ let stdout = io::stdout();
181
+ let mut out = stdout.lock();
182
+
183
+ match render(&items, opts.output, &mut out) {
184
+ Ok(()) => 0,
185
+ Err(e) if e.kind() == ErrorKind::BrokenPipe => 0,
186
+ Err(e) => {
187
+ eprintln!("work-view: render error: {e}");
188
+ 3
189
+ }
190
+ }
191
+ }
192
+
193
+ fn main() {
194
+ std::process::exit(run() as i32);
195
+ }
@@ -0,0 +1,354 @@
1
+ //! Output rendering for the work-view CLI.
2
+ //!
3
+ //! All modes write to a generic `impl Write` so the caller can provide stdout
4
+ //! or a buffer for testing. Returns `io::Result` so `main` can distinguish
5
+ //! `BrokenPipe` (clean exit 0) from other write errors (exit 3).
6
+ //!
7
+ //! ## Byte-parity notes (vs work-view.sh)
8
+ //!
9
+ //! - **Table**: `printf '%-40s %-8s %-14s %-30s %s\n'` — two spaces
10
+ //! between each column. Empty result → print nothing (no header).
11
+ //! `kind`/`stage` `None` → `""`; `parent` `None` → `"-"`;
12
+ //! `tags` comma-joined (bash: `${IDX_TAGS[$f]// /,}`).
13
+ //!
14
+ //! - **Paths**: `item.path` (absolute), one per line.
15
+ //!
16
+ //! - **Cat**: `item.raw_text` per item; between items emit blank / `---` /
17
+ //! blank (bash: `echo ""; echo "---"; echo ""`).
18
+ //!
19
+ //! - **Count**: `items.len()` followed by newline.
20
+
21
+ use std::io::{self, Write};
22
+
23
+ use work_view_core::model::Item;
24
+
25
+ use crate::args::OutputMode;
26
+
27
+ // ── Column widths (matching bash printf format) ───────────────────────────────
28
+
29
+ const W_ID: usize = 40;
30
+ const W_KIND: usize = 8;
31
+ const W_STAGE: usize = 14;
32
+ const W_TAGS: usize = 30;
33
+ // PARENT is the last column (no width limit).
34
+
35
+ // ── Public entry point ────────────────────────────────────────────────────────
36
+
37
+ /// Render `items` in the given `mode` to `out`.
38
+ ///
39
+ /// Returns `Err` only on I/O failures. Callers should map
40
+ /// `ErrorKind::BrokenPipe` to exit 0 and other errors to exit 3.
41
+ pub fn render(items: &[&Item], mode: OutputMode, out: &mut impl Write) -> io::Result<()> {
42
+ match mode {
43
+ OutputMode::Table => render_table(items, out),
44
+ OutputMode::Paths => render_paths(items, out),
45
+ OutputMode::Cat => render_cat(items, out),
46
+ OutputMode::Count => render_count(items, out),
47
+ }
48
+ }
49
+
50
+ // ── Table ─────────────────────────────────────────────────────────────────────
51
+
52
+ fn render_table(items: &[&Item], out: &mut impl Write) -> io::Result<()> {
53
+ if items.is_empty() {
54
+ return Ok(());
55
+ }
56
+
57
+ // Header
58
+ writeln!(
59
+ out,
60
+ "{:<w_id$} {:<w_kind$} {:<w_stage$} {:<w_tags$} PARENT",
61
+ "ID",
62
+ "KIND",
63
+ "STAGE",
64
+ "TAGS",
65
+ w_id = W_ID,
66
+ w_kind = W_KIND,
67
+ w_stage = W_STAGE,
68
+ w_tags = W_TAGS,
69
+ )?;
70
+
71
+ // Separator — fixed-length dash strings matching bash.
72
+ writeln!(
73
+ out,
74
+ "{:<w_id$} {:<w_kind$} {:<w_stage$} {:<w_tags$} ----------------",
75
+ "----------------------------------------",
76
+ "--------",
77
+ "--------------",
78
+ "------------------------------",
79
+ w_id = W_ID,
80
+ w_kind = W_KIND,
81
+ w_stage = W_STAGE,
82
+ w_tags = W_TAGS,
83
+ )?;
84
+
85
+ // Rows
86
+ for item in items {
87
+ let id = &item.id;
88
+ let kind = item.kind.as_deref().unwrap_or("");
89
+ let stage = item.stage.as_deref().unwrap_or("");
90
+ let tags = item.tags.join(",");
91
+ let parent = item.parent.as_deref().unwrap_or("-");
92
+
93
+ writeln!(
94
+ out,
95
+ "{:<w_id$} {:<w_kind$} {:<w_stage$} {:<w_tags$} {}",
96
+ id,
97
+ kind,
98
+ stage,
99
+ tags,
100
+ parent,
101
+ w_id = W_ID,
102
+ w_kind = W_KIND,
103
+ w_stage = W_STAGE,
104
+ w_tags = W_TAGS,
105
+ )?;
106
+ }
107
+
108
+ Ok(())
109
+ }
110
+
111
+ // ── Paths ─────────────────────────────────────────────────────────────────────
112
+
113
+ fn render_paths(items: &[&Item], out: &mut impl Write) -> io::Result<()> {
114
+ for item in items {
115
+ writeln!(out, "{}", item.path.display())?;
116
+ }
117
+ Ok(())
118
+ }
119
+
120
+ // ── Cat ───────────────────────────────────────────────────────────────────────
121
+
122
+ fn render_cat(items: &[&Item], out: &mut impl Write) -> io::Result<()> {
123
+ let mut first = true;
124
+ for item in items {
125
+ if !first {
126
+ // Separator matching bash: echo ""; echo "---"; echo ""
127
+ out.write_all(b"\n---\n\n")?;
128
+ }
129
+ out.write_all(item.raw_text.as_bytes())?;
130
+ first = false;
131
+ }
132
+ Ok(())
133
+ }
134
+
135
+ // ── Count ─────────────────────────────────────────────────────────────────────
136
+
137
+ fn render_count(items: &[&Item], out: &mut impl Write) -> io::Result<()> {
138
+ writeln!(out, "{}", items.len())
139
+ }
140
+
141
+ // ── Tests ─────────────────────────────────────────────────────────────────────
142
+
143
+ #[cfg(test)]
144
+ mod tests {
145
+ use super::*;
146
+ use std::path::PathBuf;
147
+ use work_view_core::model::Tier;
148
+
149
+ fn make_item(
150
+ id: &str,
151
+ kind: Option<&str>,
152
+ stage: Option<&str>,
153
+ tags: &[&str],
154
+ parent: Option<&str>,
155
+ path: &str,
156
+ raw_text: &str,
157
+ ) -> Item {
158
+ Item {
159
+ id: id.to_string(),
160
+ kind: kind.map(str::to_string),
161
+ stage: stage.map(str::to_string),
162
+ tags: tags.iter().map(|t| t.to_string()).collect(),
163
+ parent: parent.map(str::to_string),
164
+ depends_on: vec![],
165
+ release_binding: None,
166
+ gate_origin: None,
167
+ research_refs: vec![],
168
+ research_origin: None,
169
+ scan_origin: None,
170
+ created: None,
171
+ updated: None,
172
+ tier: Tier::Active,
173
+ path: PathBuf::from(path),
174
+ rel_path: PathBuf::from(".work/active/features/test.md"),
175
+ raw_text: raw_text.to_string(),
176
+ body: String::new(),
177
+ }
178
+ }
179
+
180
+ fn render_to_string(items: &[&Item], mode: OutputMode) -> String {
181
+ let mut buf = Vec::new();
182
+ render(items, mode, &mut buf).unwrap();
183
+ String::from_utf8(buf).unwrap()
184
+ }
185
+
186
+ // ── Table ─────────────────────────────────────────────────────────────────
187
+
188
+ #[test]
189
+ fn table_empty_items_prints_nothing() {
190
+ let output = render_to_string(&[], OutputMode::Table);
191
+ assert_eq!(output, "");
192
+ }
193
+
194
+ #[test]
195
+ fn table_single_item_has_header_separator_row() {
196
+ let item = make_item(
197
+ "feat-parser",
198
+ Some("feature"),
199
+ Some("implementing"),
200
+ &["tooling"],
201
+ Some("epic-core"),
202
+ "/root/.work/active/features/feat-parser.md",
203
+ "---\nid: feat-parser\n---\n",
204
+ );
205
+ let output = render_to_string(&[&item], OutputMode::Table);
206
+ let lines: Vec<&str> = output.lines().collect();
207
+ assert_eq!(lines.len(), 3, "header + separator + 1 row");
208
+ assert!(lines[0].starts_with("ID"), "header starts with ID");
209
+ assert!(lines[1].starts_with("---"), "separator starts with dashes");
210
+ assert!(lines[2].contains("feat-parser"), "row contains id");
211
+ assert!(lines[2].contains("feature"), "row contains kind");
212
+ assert!(lines[2].contains("implementing"), "row contains stage");
213
+ assert!(lines[2].contains("tooling"), "row contains tags");
214
+ assert!(lines[2].contains("epic-core"), "row contains parent");
215
+ }
216
+
217
+ #[test]
218
+ fn table_none_kind_and_stage_render_empty_string() {
219
+ let item = make_item(
220
+ "idea-future",
221
+ None,
222
+ None,
223
+ &[],
224
+ None,
225
+ "/root/.work/backlog/idea-future.md",
226
+ "---\nid: idea-future\n---\n",
227
+ );
228
+ let output = render_to_string(&[&item], OutputMode::Table);
229
+ let row = output.lines().nth(2).unwrap();
230
+ // kind and stage columns should be blank (just spaces), not "None"
231
+ assert!(!row.contains("None"), "should not contain 'None'");
232
+ }
233
+
234
+ #[test]
235
+ fn table_none_parent_renders_dash() {
236
+ let item = make_item(
237
+ "epic-core",
238
+ Some("epic"),
239
+ Some("implementing"),
240
+ &[],
241
+ None,
242
+ "/root/.work/active/epics/epic-core.md",
243
+ "---\nid: epic-core\n---\n",
244
+ );
245
+ let output = render_to_string(&[&item], OutputMode::Table);
246
+ let row = output.lines().nth(2).unwrap();
247
+ // parent column should be "-" for None
248
+ assert!(
249
+ row.ends_with('-') || row.contains(" -"),
250
+ "parent should be '-', got: {row}"
251
+ );
252
+ }
253
+
254
+ #[test]
255
+ fn table_tags_comma_joined() {
256
+ let item = make_item(
257
+ "feat",
258
+ Some("feature"),
259
+ Some("implementing"),
260
+ &["tooling", "perf"],
261
+ None,
262
+ "/root/.work/active/features/feat.md",
263
+ "",
264
+ );
265
+ let output = render_to_string(&[&item], OutputMode::Table);
266
+ let row = output.lines().nth(2).unwrap();
267
+ assert!(
268
+ row.contains("tooling,perf"),
269
+ "tags should be comma-joined: {row}"
270
+ );
271
+ }
272
+
273
+ #[test]
274
+ fn table_format_matches_bash_widths() {
275
+ // Verify the two-space separator between columns by checking the
276
+ // header line byte positions.
277
+ let item = make_item("a", Some("b"), Some("c"), &[], None, "/p", "");
278
+ let output = render_to_string(&[&item], OutputMode::Table);
279
+ let header = output.lines().next().unwrap();
280
+ // "ID" padded to 40 chars, then two spaces, then "KIND"
281
+ assert!(
282
+ header.starts_with(&format!(
283
+ "{:<40} {:<8} {:<14} {:<30} {}",
284
+ "ID", "KIND", "STAGE", "TAGS", "PARENT"
285
+ )),
286
+ "header format mismatch: {header:?}"
287
+ );
288
+ }
289
+
290
+ // ── Paths ─────────────────────────────────────────────────────────────────
291
+
292
+ #[test]
293
+ fn paths_prints_absolute_paths_one_per_line() {
294
+ let item1 = make_item("a", None, None, &[], None, "/root/.work/active/a.md", "");
295
+ let item2 = make_item("b", None, None, &[], None, "/root/.work/active/b.md", "");
296
+ let output = render_to_string(&[&item1, &item2], OutputMode::Paths);
297
+ let lines: Vec<&str> = output.lines().collect();
298
+ assert_eq!(
299
+ lines,
300
+ vec!["/root/.work/active/a.md", "/root/.work/active/b.md",]
301
+ );
302
+ }
303
+
304
+ #[test]
305
+ fn paths_empty_items_prints_nothing() {
306
+ let output = render_to_string(&[], OutputMode::Paths);
307
+ assert_eq!(output, "");
308
+ }
309
+
310
+ // ── Cat ───────────────────────────────────────────────────────────────────
311
+
312
+ #[test]
313
+ fn cat_single_item_no_separator() {
314
+ let raw = "---\nid: feat\n---\n\n# Feat\n";
315
+ let item = make_item("feat", None, None, &[], None, "/p.md", raw);
316
+ let output = render_to_string(&[&item], OutputMode::Cat);
317
+ assert_eq!(output, raw);
318
+ }
319
+
320
+ #[test]
321
+ fn cat_two_items_separator_between() {
322
+ let raw1 = "---\nid: a\n---\n# A\n";
323
+ let raw2 = "---\nid: b\n---\n# B\n";
324
+ let item1 = make_item("a", None, None, &[], None, "/a.md", raw1);
325
+ let item2 = make_item("b", None, None, &[], None, "/b.md", raw2);
326
+ let output = render_to_string(&[&item1, &item2], OutputMode::Cat);
327
+ let expected = format!("{raw1}\n---\n\n{raw2}");
328
+ assert_eq!(output, expected);
329
+ }
330
+
331
+ #[test]
332
+ fn cat_empty_items_prints_nothing() {
333
+ let output = render_to_string(&[], OutputMode::Cat);
334
+ assert_eq!(output, "");
335
+ }
336
+
337
+ // ── Count ─────────────────────────────────────────────────────────────────
338
+
339
+ #[test]
340
+ fn count_zero_prints_zero() {
341
+ let output = render_to_string(&[], OutputMode::Count);
342
+ assert_eq!(output.trim(), "0");
343
+ }
344
+
345
+ #[test]
346
+ fn count_three_items_prints_three() {
347
+ let items: Vec<Item> = (0..3)
348
+ .map(|i| make_item(&format!("item-{i}"), None, None, &[], None, "/p.md", ""))
349
+ .collect();
350
+ let refs: Vec<&Item> = items.iter().collect();
351
+ let output = render_to_string(&refs, OutputMode::Count);
352
+ assert_eq!(output.trim(), "3");
353
+ }
354
+ }