@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,581 @@
1
+ //! Backlog staleness filter for `--stale`.
2
+ //!
3
+ //! Reads `backlog_staleness_days` from `.work/CONVENTIONS.md`, then filters
4
+ //! the loaded items to those in the backlog tier whose last-touched date is
5
+ //! more than that many days before today (local time).
6
+ //!
7
+ //! Design decisions:
8
+ //! - Last-touched date: `updated` if `Some`, else `created`; if both `None`,
9
+ //! the item is treated as infinitely stale and surfaces unconditionally. A
10
+ //! dateless backlog item has no evidence of recency — surfacing it is
11
+ //! correct and conservative.
12
+ //! - Absent `backlog_staleness_days` key → `StalenessThreshold::NotConfigured`
13
+ //! (caller prints a one-line notice and exits 0 — politely inert, same
14
+ //! pattern as opt-in gates).
15
+ //! - Date comparison is done in local time, matching the PostToolUse hook that
16
+ //! writes `datetime.now().strftime("%Y-%m-%d")`.
17
+ //! - No `chrono` or other time dependency is added — `std::time::SystemTime`
18
+ //! suffices for local-date arithmetic (see `today_local_days`). The crate is
19
+ //! intentionally dependency-light; confirm Cargo.toml before adding anything.
20
+ //!
21
+ //! NOTE: `--stale` is Rust-only; do NOT back-port to `work-view.sh` (the bash
22
+ //! script is a frozen degraded fallback; `--scope` is also Rust-only, same
23
+ //! rationale).
24
+
25
+ use std::path::Path;
26
+
27
+ use work_view_core::model::{Item, Tier};
28
+
29
+ // ── Public types ──────────────────────────────────────────────────────────────
30
+
31
+ /// The result of reading `backlog_staleness_days` from CONVENTIONS.md.
32
+ pub enum StalenessThreshold {
33
+ /// The key `backlog_staleness_days: N` was found and parsed.
34
+ Days(u64),
35
+ /// The key is absent from CONVENTIONS.md. `--stale` should be inert.
36
+ NotConfigured,
37
+ }
38
+
39
+ // ── Public API ────────────────────────────────────────────────────────────────
40
+
41
+ /// Read `backlog_staleness_days` from `<root>/.work/CONVENTIONS.md`.
42
+ ///
43
+ /// The file is scanned line by line; the first line matching
44
+ /// `backlog_staleness_days: <integer>` wins. Lines with leading whitespace,
45
+ /// YAML comments, or surrounding blank values are skipped.
46
+ ///
47
+ /// Returns `StalenessThreshold::NotConfigured` when:
48
+ /// - the file cannot be read (non-fatal; same graceful-skip contract as the
49
+ /// absent-key case), or
50
+ /// - no matching line is found.
51
+ ///
52
+ /// Returns `StalenessThreshold::Days(0)` if the value parses as zero (every
53
+ /// backlog item would be stale — unusual but valid).
54
+ pub fn read_staleness_threshold(root: &Path) -> StalenessThreshold {
55
+ let conventions_path = root.join(".work").join("CONVENTIONS.md");
56
+ let text = match std::fs::read_to_string(&conventions_path) {
57
+ Ok(t) => t,
58
+ Err(_) => return StalenessThreshold::NotConfigured,
59
+ };
60
+ parse_staleness_days(&text)
61
+ }
62
+
63
+ /// Apply the staleness filter to a slice of items.
64
+ ///
65
+ /// Keeps only `Tier::Backlog` items whose last-touched date (see module doc)
66
+ /// is more than `threshold_days` days before `today_days`.
67
+ ///
68
+ /// `today_days` should be produced by `today_as_days()`.
69
+ pub fn apply_stale(
70
+ items: Vec<&Item>,
71
+ threshold_days: u64,
72
+ today_days: u64,
73
+ ) -> Vec<&Item> {
74
+ items
75
+ .into_iter()
76
+ .filter(|item| {
77
+ if item.tier != Tier::Backlog {
78
+ return false;
79
+ }
80
+ is_stale(item, threshold_days, today_days)
81
+ })
82
+ .collect()
83
+ }
84
+
85
+ /// Return today's date as a count of days since the Unix epoch, in local time.
86
+ ///
87
+ /// Local-time date: matches `datetime.now().strftime("%Y-%m-%d")` from Python,
88
+ /// which the PostToolUse hook uses to stamp `updated` / `created` fields.
89
+ ///
90
+ /// Uses `SystemTime::now()` + UTC offset detection via `/etc/localtime` parsing
91
+ /// is complex and not worth the complexity for a "days ago" comparison, so we
92
+ /// use a UTC-adjusted approximation: read the UTC timestamp and add the local
93
+ /// UTC offset in seconds obtained via `time(2)` libc call.
94
+ ///
95
+ /// # Simpler approach used here
96
+ ///
97
+ /// We read `SystemTime::now()` (UTC epoch seconds), then apply the local-time
98
+ /// offset by calling the C `localtime_r` (POSIX) to get the local calendar
99
+ /// date — using `std::time` primitives only. On platforms without `libc`, we
100
+ /// fall back to UTC, which differs by at most one calendar day; the staleness
101
+ /// threshold is days, not hours, so a one-day error on the boundary is
102
+ /// acceptable for this use case.
103
+ ///
104
+ /// Since this is not a cryptographic primitive and the crate forbids adding
105
+ /// new dependencies, we use `std::time::SystemTime` and compute the local date
106
+ /// via the POSIX `time_t` + `localtime` approach using `extern "C"` declarations
107
+ /// (available on all POSIX targets where the crate runs: Linux + macOS).
108
+ pub fn today_as_days() -> u64 {
109
+ // SAFETY: localtime_r is thread-safe (re-entrant) and widely available on
110
+ // all Linux/macOS targets. We call it with a valid stack-allocated tm.
111
+ #[cfg(unix)]
112
+ {
113
+ use std::time::{SystemTime, UNIX_EPOCH};
114
+
115
+ #[repr(C)]
116
+ #[allow(non_camel_case_types)]
117
+ struct tm {
118
+ tm_sec: i32,
119
+ tm_min: i32,
120
+ tm_hour: i32,
121
+ tm_mday: i32,
122
+ tm_mon: i32,
123
+ tm_year: i32,
124
+ tm_wday: i32,
125
+ tm_yday: i32,
126
+ tm_isdst: i32,
127
+ // glibc adds two more longs for timezone info; include them so the
128
+ // struct is large enough on any libc without ABI mismatch.
129
+ _tm_gmtoff: i64,
130
+ _tm_zone: *const u8,
131
+ }
132
+
133
+ extern "C" {
134
+ fn localtime_r(timep: *const i64, result: *mut tm) -> *mut tm;
135
+ }
136
+
137
+ let now = SystemTime::now()
138
+ .duration_since(UNIX_EPOCH)
139
+ .unwrap_or_default()
140
+ .as_secs() as i64;
141
+
142
+ let mut local_tm: tm = unsafe { std::mem::zeroed() };
143
+ unsafe { localtime_r(&now, &mut local_tm) };
144
+
145
+ let year = local_tm.tm_year + 1900;
146
+ let month = local_tm.tm_mon + 1; // 1..=12
147
+ let day = local_tm.tm_mday; // 1..=31
148
+
149
+ date_to_days(year, month as u32, day as u32)
150
+ }
151
+
152
+ #[cfg(not(unix))]
153
+ {
154
+ // Non-POSIX fallback: use UTC. Off by at most one calendar day.
155
+ use std::time::{SystemTime, UNIX_EPOCH};
156
+ let secs = SystemTime::now()
157
+ .duration_since(UNIX_EPOCH)
158
+ .unwrap_or_default()
159
+ .as_secs();
160
+ // days since epoch (UTC)
161
+ secs / 86400
162
+ }
163
+ }
164
+
165
+ // ── Internal helpers ──────────────────────────────────────────────────────────
166
+
167
+ /// Parse `backlog_staleness_days` from CONVENTIONS.md text.
168
+ fn parse_staleness_days(text: &str) -> StalenessThreshold {
169
+ for line in text.lines() {
170
+ let line = line.trim();
171
+ if let Some(rest) = line.strip_prefix("backlog_staleness_days:") {
172
+ let value = rest.trim();
173
+ if let Ok(n) = value.parse::<u64>() {
174
+ return StalenessThreshold::Days(n);
175
+ }
176
+ }
177
+ }
178
+ StalenessThreshold::NotConfigured
179
+ }
180
+
181
+ /// Return `true` if `item` is stale: its last-touched date is more than
182
+ /// `threshold_days` before `today_days`.
183
+ ///
184
+ /// Last-touched: `updated` if `Some`, else `created`; if both `None`, the
185
+ /// item is treated as infinitely stale (always surfaces). See module doc.
186
+ fn is_stale(item: &Item, threshold_days: u64, today_days: u64) -> bool {
187
+ let date_str = item
188
+ .updated
189
+ .as_deref()
190
+ .or(item.created.as_deref());
191
+
192
+ match date_str {
193
+ None => {
194
+ // No date at all: treat as infinitely stale — surface it.
195
+ // A dateless backlog item is at least as suspect as an old one.
196
+ true
197
+ }
198
+ Some(date) => match parse_date(date) {
199
+ Some(item_days) => {
200
+ // Stale if: today_days - item_days > threshold_days
201
+ // Guard against underflow: if item_days > today_days (clock
202
+ // skew or future date), the item is not stale.
203
+ today_days
204
+ .saturating_sub(item_days)
205
+ .gt(&threshold_days)
206
+ }
207
+ None => {
208
+ // Unparseable date: treat as stale (conservative).
209
+ true
210
+ }
211
+ },
212
+ }
213
+ }
214
+
215
+ /// Parse a `YYYY-MM-DD` string into a day count (days since the proleptic
216
+ /// Gregorian epoch 0000-01-01, a stable internal unit).
217
+ ///
218
+ /// Returns `None` if the string is not in `YYYY-MM-DD` format or if any
219
+ /// component is out of range.
220
+ fn parse_date(s: &str) -> Option<u64> {
221
+ // Expect exactly "YYYY-MM-DD": 10 chars, '-' at positions 4 and 7.
222
+ if s.len() != 10 {
223
+ return None;
224
+ }
225
+ let bytes = s.as_bytes();
226
+ if bytes[4] != b'-' || bytes[7] != b'-' {
227
+ return None;
228
+ }
229
+ let year: i32 = s[..4].parse().ok()?;
230
+ let month: u32 = s[5..7].parse().ok()?;
231
+ let day: u32 = s[8..10].parse().ok()?;
232
+ if !(1..=12).contains(&month) {
233
+ return None;
234
+ }
235
+ // Validate the day against the actual length of the month (leap-aware), so
236
+ // impossible calendar dates like 2026-02-31 or non-leap 2026-02-29 fail
237
+ // closed (None) rather than being fed into staleness comparisons as if real.
238
+ if day < 1 || day > days_in_month(year, month) {
239
+ return None;
240
+ }
241
+ Some(date_to_days(year, month, day))
242
+ }
243
+
244
+ /// Number of days in a given month, accounting for Gregorian leap years.
245
+ fn days_in_month(year: i32, month: u32) -> u32 {
246
+ match month {
247
+ 1 | 3 | 5 | 7 | 8 | 10 | 12 => 31,
248
+ 4 | 6 | 9 | 11 => 30,
249
+ 2 if is_leap_year(year) => 29,
250
+ 2 => 28,
251
+ _ => 0, // unreachable: callers guard 1..=12
252
+ }
253
+ }
254
+
255
+ /// Proleptic Gregorian leap-year rule.
256
+ fn is_leap_year(year: i32) -> bool {
257
+ (year % 4 == 0 && year % 100 != 0) || year % 400 == 0
258
+ }
259
+
260
+ /// Convert a proleptic Gregorian calendar date to a day number.
261
+ ///
262
+ /// Uses the algorithm from the C Standard (ISO 8601 / RFC 3339) for Gregorian
263
+ /// date arithmetic. The epoch (day 0) is 0000-03-01 (an internal reference
264
+ /// point chosen so the leap-day boundary falls at month start).
265
+ ///
266
+ /// This is a pure integer calculation with no dependencies.
267
+ fn date_to_days(year: i32, month: u32, day: u32) -> u64 {
268
+ // Shift the epoch so March = month 1; this puts the leap day at end of year.
269
+ let (y, m) = if month <= 2 {
270
+ (year - 1, month + 9)
271
+ } else {
272
+ (year, month - 3)
273
+ };
274
+
275
+ let era = y.div_euclid(400);
276
+ let yoe = y.rem_euclid(400) as u64; // year-of-era [0, 399]
277
+ let doy = (153 * m as u64 + 2) / 5 + day as u64 - 1; // day-of-year [0, 365]
278
+ let doe = yoe * 365 + yoe / 4 - yoe / 100 + doy; // day-of-era [0, 146096]
279
+
280
+ // Add a large offset so the result fits in u64 even for years near 0000.
281
+ const EPOCH_OFFSET: u64 = 146097 * 10; // 10 full 400-year cycles
282
+ (era as u64)
283
+ .wrapping_add(10) // same 10-cycle offset
284
+ .wrapping_mul(146097)
285
+ .wrapping_add(doe)
286
+ .wrapping_add(EPOCH_OFFSET)
287
+ .wrapping_sub((10u64).wrapping_mul(146097))
288
+ }
289
+
290
+ // ── Unit tests ────────────────────────────────────────────────────────────────
291
+
292
+ #[cfg(test)]
293
+ mod tests {
294
+ use super::*;
295
+ use std::path::PathBuf;
296
+ use work_view_core::model::Tier;
297
+
298
+ fn make_backlog_item(id: &str, created: Option<&str>, updated: Option<&str>) -> Item {
299
+ Item {
300
+ id: id.to_string(),
301
+ kind: None,
302
+ stage: None,
303
+ tags: vec![],
304
+ parent: None,
305
+ depends_on: vec![],
306
+ release_binding: None,
307
+ gate_origin: None,
308
+ research_refs: vec![],
309
+ research_origin: None,
310
+ scan_origin: None,
311
+ created: created.map(str::to_string),
312
+ updated: updated.map(str::to_string),
313
+ tier: Tier::Backlog,
314
+ path: PathBuf::from("/fake/backlog/item.md"),
315
+ rel_path: PathBuf::from(".work/backlog/item.md"),
316
+ raw_text: String::new(),
317
+ body: String::new(),
318
+ }
319
+ }
320
+
321
+ fn make_active_item(id: &str, created: Option<&str>) -> Item {
322
+ Item {
323
+ id: id.to_string(),
324
+ kind: Some("feature".to_string()),
325
+ stage: Some("implementing".to_string()),
326
+ tags: vec![],
327
+ parent: None,
328
+ depends_on: vec![],
329
+ release_binding: None,
330
+ gate_origin: None,
331
+ research_refs: vec![],
332
+ research_origin: None,
333
+ scan_origin: None,
334
+ created: created.map(str::to_string),
335
+ updated: None,
336
+ tier: Tier::Active,
337
+ path: PathBuf::from("/fake/active/item.md"),
338
+ rel_path: PathBuf::from(".work/active/features/item.md"),
339
+ raw_text: String::new(),
340
+ body: String::new(),
341
+ }
342
+ }
343
+
344
+ // ── parse_staleness_days ──────────────────────────────────────────────────
345
+
346
+ #[test]
347
+ fn parse_key_present_returns_days() {
348
+ let text = "# Conventions\nbacklog_staleness_days: 90\n";
349
+ assert!(matches!(
350
+ parse_staleness_days(text),
351
+ StalenessThreshold::Days(90)
352
+ ));
353
+ }
354
+
355
+ #[test]
356
+ fn parse_key_absent_returns_not_configured() {
357
+ let text = "# Conventions\nsome_other_key: 30\n";
358
+ assert!(matches!(
359
+ parse_staleness_days(text),
360
+ StalenessThreshold::NotConfigured
361
+ ));
362
+ }
363
+
364
+ #[test]
365
+ fn parse_key_with_leading_whitespace_on_line_is_found() {
366
+ // The key may appear under a heading with indentation stripped.
367
+ let text = " backlog_staleness_days: 45\n";
368
+ assert!(matches!(
369
+ parse_staleness_days(text),
370
+ StalenessThreshold::Days(45)
371
+ ));
372
+ }
373
+
374
+ #[test]
375
+ fn parse_key_zero_returns_days_zero() {
376
+ let text = "backlog_staleness_days: 0\n";
377
+ assert!(matches!(
378
+ parse_staleness_days(text),
379
+ StalenessThreshold::Days(0)
380
+ ));
381
+ }
382
+
383
+ #[test]
384
+ fn parse_key_non_integer_value_skips() {
385
+ let text = "backlog_staleness_days: auto\n";
386
+ assert!(matches!(
387
+ parse_staleness_days(text),
388
+ StalenessThreshold::NotConfigured
389
+ ));
390
+ }
391
+
392
+ // ── parse_date ────────────────────────────────────────────────────────────
393
+
394
+ #[test]
395
+ fn parse_date_valid_returns_some() {
396
+ assert!(parse_date("2026-01-01").is_some());
397
+ assert!(parse_date("2000-02-29").is_some()); // valid leap year
398
+ assert!(parse_date("1970-01-01").is_some());
399
+ }
400
+
401
+ #[test]
402
+ fn parse_date_invalid_format_returns_none() {
403
+ assert!(parse_date("26-01-01").is_none()); // too short
404
+ assert!(parse_date("2026/01/01").is_none()); // wrong separator
405
+ assert!(parse_date("2026-1-1").is_none()); // no zero-padding
406
+ assert!(parse_date("").is_none());
407
+ }
408
+
409
+ #[test]
410
+ fn parse_date_impossible_calendar_dates_return_none() {
411
+ // Day exceeds the month's real length — must fail closed, not be fed
412
+ // into staleness math as if it were a real date.
413
+ assert!(parse_date("2026-02-31").is_none()); // Feb never has 31
414
+ assert!(parse_date("2026-02-29").is_none()); // 2026 is not a leap year
415
+ assert!(parse_date("2026-04-31").is_none()); // April has 30
416
+ assert!(parse_date("2026-06-31").is_none()); // June has 30
417
+ assert!(parse_date("2026-01-32").is_none()); // no month has 32
418
+ assert!(parse_date("2026-01-00").is_none()); // day 0 invalid
419
+ assert!(parse_date("2026-00-10").is_none()); // month 0 invalid
420
+ assert!(parse_date("2026-13-10").is_none()); // month 13 invalid
421
+ }
422
+
423
+ #[test]
424
+ fn parse_date_leap_year_boundaries() {
425
+ assert!(parse_date("2000-02-29").is_some()); // divisible by 400 → leap
426
+ assert!(parse_date("2024-02-29").is_some()); // divisible by 4 → leap
427
+ assert!(parse_date("1900-02-29").is_none()); // divisible by 100, not 400 → not leap
428
+ assert!(parse_date("2026-02-28").is_some()); // last valid non-leap Feb day
429
+ }
430
+
431
+ #[test]
432
+ fn days_in_month_is_leap_aware() {
433
+ assert_eq!(days_in_month(2026, 2), 28);
434
+ assert_eq!(days_in_month(2024, 2), 29);
435
+ assert_eq!(days_in_month(1900, 2), 28);
436
+ assert_eq!(days_in_month(2000, 2), 29);
437
+ assert_eq!(days_in_month(2026, 4), 30);
438
+ assert_eq!(days_in_month(2026, 12), 31);
439
+ }
440
+
441
+ #[test]
442
+ fn parse_date_monotonically_increasing() {
443
+ let d1 = parse_date("2026-01-01").unwrap();
444
+ let d2 = parse_date("2026-01-02").unwrap();
445
+ let d3 = parse_date("2026-02-01").unwrap();
446
+ let d4 = parse_date("2027-01-01").unwrap();
447
+ assert!(d1 < d2, "next day should be later");
448
+ assert!(d2 < d3, "next month should be later");
449
+ assert!(d3 < d4, "next year should be later");
450
+ }
451
+
452
+ #[test]
453
+ fn parse_date_exactly_365_day_difference() {
454
+ // 2026 is not a leap year; 2025-01-01 to 2026-01-01 = 365 days.
455
+ let d_start = parse_date("2025-01-01").unwrap();
456
+ let d_end = parse_date("2026-01-01").unwrap();
457
+ assert_eq!(d_end - d_start, 365, "365 days in a non-leap year");
458
+ }
459
+
460
+ // ── is_stale ─────────────────────────────────────────────────────────────
461
+
462
+ #[test]
463
+ fn item_older_than_threshold_is_stale() {
464
+ // today = 2026-06-15 (day N), item created 2025-01-01 (> 90 days ago)
465
+ let today = parse_date("2026-06-15").unwrap();
466
+ let item = make_backlog_item("old", Some("2025-01-01"), None);
467
+ assert!(is_stale(&item, 90, today));
468
+ }
469
+
470
+ #[test]
471
+ fn item_newer_than_threshold_is_not_stale() {
472
+ let today = parse_date("2026-06-15").unwrap();
473
+ // created yesterday
474
+ let item = make_backlog_item("fresh", Some("2026-06-14"), None);
475
+ assert!(!is_stale(&item, 90, today));
476
+ }
477
+
478
+ #[test]
479
+ fn item_exactly_at_threshold_is_not_stale() {
480
+ // "more than N days" — exact boundary is not stale.
481
+ let today = parse_date("2026-04-15").unwrap();
482
+ // 90 days before 2026-04-15 is 2026-01-15
483
+ let item = make_backlog_item("boundary", Some("2026-01-15"), None);
484
+ let diff = today - parse_date("2026-01-15").unwrap();
485
+ // diff must equal exactly the threshold for this to be the boundary case.
486
+ assert_eq!(diff, 90);
487
+ // Not stale: "more than" 90, not "90 or more".
488
+ assert!(!is_stale(&item, 90, today));
489
+ }
490
+
491
+ #[test]
492
+ fn item_one_day_past_threshold_is_stale() {
493
+ let today = parse_date("2026-04-16").unwrap();
494
+ // 91 days before 2026-04-16 is 2026-01-15
495
+ let item = make_backlog_item("just-over", Some("2026-01-15"), None);
496
+ let diff = today - parse_date("2026-01-15").unwrap();
497
+ assert_eq!(diff, 91);
498
+ assert!(is_stale(&item, 90, today));
499
+ }
500
+
501
+ #[test]
502
+ fn updated_takes_precedence_over_created() {
503
+ // created is old, updated is recent → not stale
504
+ let today = parse_date("2026-06-15").unwrap();
505
+ let item = make_backlog_item("updated-recently", Some("2020-01-01"), Some("2026-06-14"));
506
+ assert!(!is_stale(&item, 90, today));
507
+ }
508
+
509
+ #[test]
510
+ fn updated_old_created_recent_is_judged_by_updated() {
511
+ // updated is the deciding date; if updated is old, stale regardless of created.
512
+ let today = parse_date("2026-06-15").unwrap();
513
+ let item = make_backlog_item("stale-updated", Some("2026-06-14"), Some("2020-01-01"));
514
+ assert!(is_stale(&item, 90, today));
515
+ }
516
+
517
+ #[test]
518
+ fn dateless_item_is_stale() {
519
+ // Both created and updated are None → treated as infinitely stale.
520
+ let today = parse_date("2026-06-15").unwrap();
521
+ let item = make_backlog_item("dateless", None, None);
522
+ assert!(is_stale(&item, 90, today));
523
+ }
524
+
525
+ // ── apply_stale ───────────────────────────────────────────────────────────
526
+
527
+ #[test]
528
+ fn apply_stale_excludes_non_backlog_items() {
529
+ let today = parse_date("2026-06-15").unwrap();
530
+ let active = make_active_item("active-old", Some("2020-01-01"));
531
+ let backlog_old = make_backlog_item("backlog-old", Some("2020-01-01"), None);
532
+
533
+ let items: Vec<&Item> = vec![&active, &backlog_old];
534
+ let stale = apply_stale(items, 90, today);
535
+
536
+ let ids: Vec<&str> = stale.iter().map(|i| i.id.as_str()).collect();
537
+ assert!(!ids.contains(&"active-old"), "active items must be excluded");
538
+ assert!(ids.contains(&"backlog-old"), "old backlog item must be included");
539
+ }
540
+
541
+ #[test]
542
+ fn apply_stale_excludes_fresh_backlog_items() {
543
+ let today = parse_date("2026-06-15").unwrap();
544
+ let fresh = make_backlog_item("fresh", Some("2026-06-14"), None);
545
+ let old = make_backlog_item("old", Some("2020-01-01"), None);
546
+
547
+ let items: Vec<&Item> = vec![&fresh, &old];
548
+ let stale = apply_stale(items, 90, today);
549
+
550
+ let ids: Vec<&str> = stale.iter().map(|i| i.id.as_str()).collect();
551
+ assert!(ids.contains(&"old"));
552
+ assert!(!ids.contains(&"fresh"));
553
+ }
554
+
555
+ #[test]
556
+ fn apply_stale_includes_dateless_backlog_item() {
557
+ let today = parse_date("2026-06-15").unwrap();
558
+ let dateless = make_backlog_item("dateless", None, None);
559
+ let items: Vec<&Item> = vec![&dateless];
560
+ let stale = apply_stale(items, 90, today);
561
+ assert_eq!(stale.len(), 1);
562
+ }
563
+
564
+ // ── today_as_days ─────────────────────────────────────────────────────────
565
+
566
+ #[test]
567
+ fn today_as_days_returns_plausible_value() {
568
+ // Sanity check: today should be after 2020-01-01 and before 2100-01-01.
569
+ let today = today_as_days();
570
+ let lower = parse_date("2020-01-01").unwrap();
571
+ let upper = parse_date("2100-01-01").unwrap();
572
+ assert!(
573
+ today >= lower,
574
+ "today ({today}) should be after 2020-01-01 ({lower})"
575
+ );
576
+ assert!(
577
+ today < upper,
578
+ "today ({today}) should be before 2100-01-01 ({upper})"
579
+ );
580
+ }
581
+ }
@@ -0,0 +1,64 @@
1
+ import test from "node:test";
2
+ import assert from "node:assert/strict";
3
+
4
+ import { installDomGlobals, loadBoardModule, makeItem } from "./harness.mjs";
5
+
6
+ test("dependency model reports cycles and missing dependency stubs", async () => {
7
+ installDomGlobals();
8
+ const { buildDependencyModel } = await loadBoardModule("dependency.js");
9
+ const model = buildDependencyModel([
10
+ makeItem({ id: "story-a", depends_on: ["story-c"] }),
11
+ makeItem({ id: "story-b", depends_on: ["story-a"] }),
12
+ makeItem({ id: "story-c", depends_on: ["story-b", "missing-dep"], unmet_deps: ["missing-dep"] }),
13
+ ]);
14
+
15
+ assert.deepEqual(new Set(model.cycleIds), new Set(["story-a", "story-b", "story-c"]));
16
+ assert.equal(model.nodes.has("external:missing-dep"), true);
17
+ assert.equal(model.nodes.get("external:missing-dep").external, true);
18
+ assert.equal(
19
+ model.edges.some((edge) => edge.from === "external:missing-dep" && edge.to === "story-c" && edge.unmet),
20
+ true,
21
+ );
22
+ });
23
+
24
+ test("dependency model represents filtered dependencies as unmet external stubs", async () => {
25
+ installDomGlobals();
26
+ const { buildDependencyModel } = await loadBoardModule("dependency.js");
27
+ const model = buildDependencyModel([
28
+ makeItem({ id: "visible-story", depends_on: ["filtered-story"], unmet_deps: ["filtered-story"] }),
29
+ ]);
30
+
31
+ assert.equal(model.nodes.has("external:filtered-story"), true);
32
+ assert.equal(model.nodes.get("external:filtered-story").external, true);
33
+ assert.equal(
34
+ model.edges.some((edge) => (
35
+ edge.from === "external:filtered-story"
36
+ && edge.to === "visible-story"
37
+ && edge.external
38
+ && edge.unmet
39
+ )),
40
+ true,
41
+ );
42
+ });
43
+
44
+ test("table comparators are deterministic, stage-aware, and tolerate missing updated", async () => {
45
+ installDomGlobals();
46
+ const { compareRows, sortedItems } = await loadBoardModule("table.js");
47
+ const snapshot = {
48
+ items: [
49
+ makeItem({ id: "draft", stage: "drafting" }),
50
+ makeItem({ id: "done", stage: "done" }),
51
+ makeItem({ id: "empty-updated", updated: "" }),
52
+ makeItem({ id: "dated", updated: "2026-06-01" }),
53
+ ],
54
+ };
55
+
56
+ assert.equal(compareRows(snapshot.items[0], snapshot.items[1], "stage", snapshot) < 0, true);
57
+ assert.equal(compareRows(snapshot.items[2], snapshot.items[3], "updated", snapshot) < 0, true);
58
+
59
+ const duplicateA = makeItem({ id: "same", stage: "done", updated: "2026-06-01" });
60
+ const duplicateB = makeItem({ id: "same", stage: "done", updated: "2026-06-01" });
61
+ const sorted = sortedItems([duplicateA, duplicateB], { items: [duplicateA, duplicateB] });
62
+ assert.equal(sorted[0], duplicateA);
63
+ assert.equal(sorted[1], duplicateB);
64
+ });