@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,256 @@
1
+ import { renderMarkdown } from "/assets/markdown.js";
2
+
3
+ let activeDetail = null;
4
+ let returnFocus = null;
5
+
6
+ function textElement(tag, className, text) {
7
+ const element = document.createElement(tag);
8
+ if (className) {
9
+ element.className = className;
10
+ }
11
+ element.textContent = text;
12
+ return element;
13
+ }
14
+
15
+ function valueOrNone(value) {
16
+ return value == null || value === "" ? "—" : String(value);
17
+ }
18
+
19
+ function bodyLength(item) {
20
+ return String(item?.body || "").length;
21
+ }
22
+
23
+ export function detectDetailPresentation(item, viewportWidth = window.innerWidth) {
24
+ if (viewportWidth < 760) {
25
+ return "modal";
26
+ }
27
+ const length = bodyLength(item);
28
+ if (length < 900) {
29
+ return "drawer-narrow";
30
+ }
31
+ if (length < 2400) {
32
+ return "drawer-wide";
33
+ }
34
+ return "modal";
35
+ }
36
+
37
+ function frontmatter(item) {
38
+ return [
39
+ ["kind", item.kind],
40
+ ["stage", item.stage],
41
+ ["parent", item.parent],
42
+ ["tags", Array.isArray(item.tags) && item.tags.length > 0 ? item.tags.join(", ") : null],
43
+ ["depends_on", Array.isArray(item.depends_on) ? item.depends_on.length : 0],
44
+ ["unmet", Array.isArray(item.unmet_deps) ? item.unmet_deps.length : 0],
45
+ ["unblocks", Array.isArray(item.dependents) ? item.dependents.length : 0],
46
+ ];
47
+ }
48
+
49
+ function frontmatterList(item) {
50
+ const list = document.createElement("dl");
51
+ list.className = "dd-frontmatter";
52
+ for (const [label, value] of frontmatter(item)) {
53
+ list.append(
54
+ textElement("dt", "", label),
55
+ textElement("dd", "", valueOrNone(value)),
56
+ );
57
+ }
58
+ return list;
59
+ }
60
+
61
+ function closeButton(ctx) {
62
+ const button = document.createElement("button");
63
+ button.className = "btn btn-ghost btn--sm dd-close";
64
+ button.type = "button";
65
+ button.textContent = "Close";
66
+ button.addEventListener("click", () => closeDetail(ctx));
67
+ return button;
68
+ }
69
+
70
+ function detailTitle(item) {
71
+ const title = textElement("span", "dd-id", valueOrNone(item.id));
72
+ title.id = "item-detail-title";
73
+ return title;
74
+ }
75
+
76
+ function drawer(item, ctx, presentation) {
77
+ const article = document.createElement("article");
78
+ article.className = [
79
+ "detail-drawer",
80
+ "as-drawer",
81
+ presentation === "drawer-wide" ? "w-wide" : "w-narrow",
82
+ ].join(" ");
83
+ article.tabIndex = -1;
84
+ article.setAttribute("role", "dialog");
85
+ article.setAttribute("aria-modal", "true");
86
+ article.setAttribute("aria-labelledby", "item-detail-title");
87
+ const head = document.createElement("div");
88
+ head.className = "dd-head";
89
+ head.append(
90
+ textElement("span", "chip", valueOrNone(item.kind)),
91
+ detailTitle(item),
92
+ closeButton(ctx),
93
+ );
94
+ const body = document.createElement("div");
95
+ body.className = "dd-md";
96
+ body.append(renderMarkdown(item.body || "", { mode: "detail" }));
97
+ article.append(head, frontmatterList(item), body);
98
+ return article;
99
+ }
100
+
101
+ function modal(item, ctx) {
102
+ const scrim = document.createElement("div");
103
+ scrim.className = "detail-scrim";
104
+ const article = document.createElement("article");
105
+ article.className = "item-modal";
106
+ article.tabIndex = -1;
107
+ article.setAttribute("role", "dialog");
108
+ article.setAttribute("aria-modal", "true");
109
+ article.setAttribute("aria-labelledby", "item-detail-title");
110
+ const head = document.createElement("div");
111
+ head.className = "m-head";
112
+ const title = detailTitle(item);
113
+ title.className = "m-id";
114
+ head.append(
115
+ textElement("span", "chip", valueOrNone(item.kind)),
116
+ title,
117
+ closeButton(ctx),
118
+ );
119
+ const scroll = document.createElement("div");
120
+ scroll.className = "m-scroll";
121
+ scroll.append(frontmatterList(item), renderMarkdown(item.body || "", { mode: "detail" }));
122
+ article.append(head, scroll);
123
+ scrim.append(article);
124
+ scrim.addEventListener("click", (event) => {
125
+ if (event.target === scrim) {
126
+ closeDetail(ctx);
127
+ }
128
+ });
129
+ return scrim;
130
+ }
131
+
132
+ function setBackgroundInert(active) {
133
+ const shell = document.querySelector(".app-shell");
134
+ if (!shell) {
135
+ return;
136
+ }
137
+ if (active) {
138
+ shell.setAttribute("aria-hidden", "true");
139
+ shell.inert = true;
140
+ } else {
141
+ shell.removeAttribute("aria-hidden");
142
+ shell.inert = false;
143
+ }
144
+ }
145
+
146
+ function rememberReturnFocus() {
147
+ const element = document.activeElement;
148
+ if (element && element !== document.body && typeof element.focus === "function") {
149
+ returnFocus = element;
150
+ }
151
+ }
152
+
153
+ function restoreReturnFocus() {
154
+ const element = returnFocus;
155
+ returnFocus = null;
156
+ if (element?.isConnected && typeof element.focus === "function") {
157
+ element.focus({ preventScroll: true });
158
+ }
159
+ }
160
+
161
+ function focusDetail(node) {
162
+ const target = node.querySelector(".dd-close") || node.querySelector("[role='dialog']") || node;
163
+ target.focus({ preventScroll: true });
164
+ }
165
+
166
+ function focusableNodes() {
167
+ if (!activeDetail) {
168
+ return [];
169
+ }
170
+ return Array.from(activeDetail.node.querySelectorAll(
171
+ "button, [href], input, select, textarea, [tabindex]:not([tabindex='-1'])",
172
+ )).filter((node) => !node.disabled && node.getAttribute("aria-hidden") !== "true");
173
+ }
174
+
175
+ function trapFocus(event) {
176
+ const focusable = focusableNodes();
177
+ if (focusable.length === 0) {
178
+ event.preventDefault();
179
+ activeDetail?.node.focus?.({ preventScroll: true });
180
+ return;
181
+ }
182
+ const first = focusable[0];
183
+ const last = focusable[focusable.length - 1];
184
+ if (event.shiftKey && document.activeElement === first) {
185
+ event.preventDefault();
186
+ last.focus({ preventScroll: true });
187
+ } else if (!event.shiftKey && document.activeElement === last) {
188
+ event.preventDefault();
189
+ first.focus({ preventScroll: true });
190
+ }
191
+ }
192
+
193
+ function renderDetail(item, ctx) {
194
+ const presentation = detectDetailPresentation(item, window.innerWidth);
195
+ if (
196
+ activeDetail?.id === item.id
197
+ && activeDetail?.item === item
198
+ && activeDetail?.presentation === presentation
199
+ ) {
200
+ return;
201
+ }
202
+ const node = presentation === "modal" ? modal(item, ctx) : drawer(item, ctx, presentation);
203
+ activeDetail?.node.remove();
204
+ setBackgroundInert(true);
205
+ document.body.append(node);
206
+ activeDetail = { id: item.id, item, presentation, node, ctx };
207
+ focusDetail(node);
208
+ }
209
+
210
+ function closeActive() {
211
+ activeDetail?.node.remove();
212
+ activeDetail = null;
213
+ setBackgroundInert(false);
214
+ restoreReturnFocus();
215
+ }
216
+
217
+ document.addEventListener("keydown", (event) => {
218
+ if (!activeDetail) {
219
+ return;
220
+ }
221
+ if (event.key === "Escape") {
222
+ closeDetail(activeDetail.ctx);
223
+ } else if (event.key === "Tab") {
224
+ trapFocus(event);
225
+ }
226
+ });
227
+
228
+ export function openDetail(id, ctx) {
229
+ const item = ctx.getItemById(id);
230
+ if (!item) {
231
+ closeDetail(ctx);
232
+ return;
233
+ }
234
+ rememberReturnFocus();
235
+ ctx.setSelectedItem?.(item.id);
236
+ renderDetail(item, ctx);
237
+ }
238
+
239
+ export function closeDetail(ctx = null) {
240
+ closeActive();
241
+ ctx?.setSelectedItem?.(null);
242
+ }
243
+
244
+ export function syncDetail(ctx) {
245
+ const selectedItemId = ctx.getState().selectedItemId;
246
+ if (!selectedItemId) {
247
+ closeActive();
248
+ return;
249
+ }
250
+ const item = ctx.getItemById(selectedItemId);
251
+ if (!item) {
252
+ closeDetail(ctx);
253
+ return;
254
+ }
255
+ renderDetail(item, ctx);
256
+ }
@@ -0,0 +1,389 @@
1
+ const SET_FILTERS = new Set(["kinds", "stages", "parents", "tags", "epics"]);
2
+ const FILTER_KEYS = new Set(["search", ...SET_FILTERS, "autoHideReleased"]);
3
+ const PREFERRED_KINDS = ["epic", "feature", "story", "release", "backlog"];
4
+ const PREFERRED_STAGES = ["drafting", "implementing", "review", "done", "released"];
5
+ const EXPAND_THRESHOLD = 12;
6
+ const expandedGroups = new Set();
7
+
8
+ export function createDefaultFilters() {
9
+ return {
10
+ search: "",
11
+ kinds: new Set(),
12
+ stages: new Set(),
13
+ parents: new Set(),
14
+ tags: new Set(),
15
+ epics: new Set(),
16
+ autoHideReleased: true,
17
+ };
18
+ }
19
+
20
+ export function cloneFilters(filters) {
21
+ return {
22
+ search: filters.search,
23
+ kinds: new Set(filters.kinds),
24
+ stages: new Set(filters.stages),
25
+ parents: new Set(filters.parents),
26
+ tags: new Set(filters.tags),
27
+ epics: new Set(filters.epics),
28
+ autoHideReleased: filters.autoHideReleased,
29
+ };
30
+ }
31
+
32
+ export function serializeFilters(filters) {
33
+ return {
34
+ search: filters.search,
35
+ kinds: Array.from(filters.kinds),
36
+ stages: Array.from(filters.stages),
37
+ parents: Array.from(filters.parents),
38
+ tags: Array.from(filters.tags),
39
+ epics: Array.from(filters.epics),
40
+ autoHideReleased: filters.autoHideReleased,
41
+ };
42
+ }
43
+
44
+ export function normalizeSet(value) {
45
+ if (value instanceof Set) {
46
+ return new Set(Array.from(value).map(String).filter(Boolean));
47
+ }
48
+ if (Array.isArray(value)) {
49
+ return new Set(value.map(String).filter(Boolean));
50
+ }
51
+ if (typeof value === "string" && value !== "") {
52
+ return new Set([value]);
53
+ }
54
+ return new Set();
55
+ }
56
+
57
+ export function normalizeFilterValue(key, value) {
58
+ if (!FILTER_KEYS.has(key)) {
59
+ throw new Error(`Unknown board filter: ${key}`);
60
+ }
61
+ if (SET_FILTERS.has(key)) {
62
+ return normalizeSet(value);
63
+ }
64
+ if (key === "search") {
65
+ return typeof value === "string" ? value : "";
66
+ }
67
+ return Boolean(value);
68
+ }
69
+
70
+ export function normalizeFilters(raw) {
71
+ const filters = createDefaultFilters();
72
+ if (!raw || typeof raw !== "object") {
73
+ return filters;
74
+ }
75
+ for (const key of FILTER_KEYS) {
76
+ if (Object.prototype.hasOwnProperty.call(raw, key)) {
77
+ filters[key] = normalizeFilterValue(key, raw[key]);
78
+ }
79
+ }
80
+ return filters;
81
+ }
82
+
83
+ function filterValue(value) {
84
+ return value == null || value === "" ? "" : String(value);
85
+ }
86
+
87
+ function searchText(item) {
88
+ return [
89
+ item?.id,
90
+ item?.kind,
91
+ item?.stage,
92
+ item?.parent,
93
+ item?.gate_origin,
94
+ item?.rel_path,
95
+ item?.body,
96
+ ...(Array.isArray(item?.tags) ? item.tags : []),
97
+ ].filter(Boolean).join(" ").toLowerCase();
98
+ }
99
+
100
+ function filterSetAllows(values, actual) {
101
+ return values.size === 0 || values.has(actual);
102
+ }
103
+
104
+ function tagsAllow(selectedTags, itemTags) {
105
+ if (selectedTags.size === 0) {
106
+ return true;
107
+ }
108
+ const tags = new Set(Array.isArray(itemTags) ? itemTags.map(String) : []);
109
+ for (const tag of selectedTags) {
110
+ if (!tags.has(tag)) {
111
+ return false;
112
+ }
113
+ }
114
+ return true;
115
+ }
116
+
117
+ function itemsByIdFrom(snapshot) {
118
+ const items = Array.isArray(snapshot) ? snapshot : Array.isArray(snapshot?.items) ? snapshot.items : [];
119
+ return new Map(items.filter((item) => item?.id).map((item) => [String(item.id), item]));
120
+ }
121
+
122
+ export function epicIdForItem(item, itemsById = new Map()) {
123
+ if (!item?.id) {
124
+ return "";
125
+ }
126
+ let current = item;
127
+ const seen = new Set();
128
+ while (current?.id && !seen.has(String(current.id))) {
129
+ const currentId = String(current.id);
130
+ seen.add(currentId);
131
+ if (current.kind === "epic") {
132
+ return currentId;
133
+ }
134
+ const parentId = filterValue(current.parent);
135
+ if (!parentId) {
136
+ return "";
137
+ }
138
+ current = itemsById.get(parentId);
139
+ }
140
+ return "";
141
+ }
142
+
143
+ function epicsAllow(selectedEpics, item, snapshot) {
144
+ if (selectedEpics.size === 0) {
145
+ return true;
146
+ }
147
+ return selectedEpics.has(epicIdForItem(item, itemsByIdFrom(snapshot)));
148
+ }
149
+
150
+ export function matchesFilters(item, filters, snapshot = null) {
151
+ if (!item || typeof item !== "object") {
152
+ return false;
153
+ }
154
+ if (filters.autoHideReleased && (item.tier === "releases" || item.tier === "archive")) {
155
+ return false;
156
+ }
157
+ const needle = filters.search.trim().toLowerCase();
158
+ return filterSetAllows(filters.kinds, filterValue(item.kind))
159
+ && filterSetAllows(filters.stages, filterValue(item.stage))
160
+ && filterSetAllows(filters.parents, filterValue(item.parent))
161
+ && tagsAllow(filters.tags, item.tags)
162
+ && epicsAllow(filters.epics, item, snapshot)
163
+ && (needle === "" || searchText(item).includes(needle));
164
+ }
165
+
166
+ function orderedValues(values, preferred = []) {
167
+ const unique = new Set(values);
168
+ const ordered = [];
169
+ for (const value of preferred) {
170
+ if (unique.delete(value)) {
171
+ ordered.push(value);
172
+ }
173
+ }
174
+ return ordered.concat(Array.from(unique).sort((a, b) => a.localeCompare(b)));
175
+ }
176
+
177
+ function optionSet(items, getter) {
178
+ const values = new Set();
179
+ for (const item of items) {
180
+ const value = filterValue(getter(item));
181
+ if (value !== "") {
182
+ values.add(value);
183
+ }
184
+ }
185
+ return values;
186
+ }
187
+
188
+ export function deriveFilterOptions(snapshot) {
189
+ const items = Array.isArray(snapshot?.items) ? snapshot.items : [];
190
+ const tags = new Set();
191
+ const epics = new Set();
192
+ const itemsById = itemsByIdFrom(items);
193
+ for (const item of items) {
194
+ for (const tag of Array.isArray(item?.tags) ? item.tags : []) {
195
+ tags.add(String(tag));
196
+ }
197
+ const epicId = epicIdForItem(item, itemsById);
198
+ if (epicId) {
199
+ epics.add(epicId);
200
+ }
201
+ }
202
+ return {
203
+ kinds: orderedValues(
204
+ new Set([...PREFERRED_KINDS, ...optionSet(items, (item) => item.kind)]),
205
+ PREFERRED_KINDS,
206
+ ),
207
+ stages: orderedValues(
208
+ new Set([...PREFERRED_STAGES, ...optionSet(items, (item) => item.stage)]),
209
+ PREFERRED_STAGES,
210
+ ),
211
+ parents: orderedValues(optionSet(items, (item) => item.parent)),
212
+ tags: orderedValues(tags),
213
+ epics: orderedValues(epics),
214
+ };
215
+ }
216
+
217
+ function textElement(tag, className, text) {
218
+ const element = document.createElement(tag);
219
+ if (className) {
220
+ element.className = className;
221
+ }
222
+ element.textContent = text;
223
+ return element;
224
+ }
225
+
226
+ function filterGroup(title, child, options = {}) {
227
+ const group = document.createElement("div");
228
+ group.className = "filter-group";
229
+ const header = document.createElement("div");
230
+ header.className = "filter-group__header";
231
+ header.append(textElement("h2", "", title));
232
+ if (options.expandKey) {
233
+ const button = document.createElement("button");
234
+ button.className = "filter-expand-toggle";
235
+ button.type = "button";
236
+ button.dataset.expandKey = options.expandKey;
237
+ button.setAttribute("data-expand-key", options.expandKey);
238
+ button.addEventListener("click", () => {
239
+ if (expandedGroups.has(options.expandKey)) {
240
+ expandedGroups.delete(options.expandKey);
241
+ } else {
242
+ expandedGroups.add(options.expandKey);
243
+ }
244
+ syncExpandableGroup(child, button, options.expandKey, Number(button.dataset.optionCount || "0"));
245
+ });
246
+ header.append(button);
247
+ }
248
+ group.append(header, child);
249
+ return group;
250
+ }
251
+
252
+ function syncExpandableGroup(optionsElement, button, key, count) {
253
+ if (!button) {
254
+ return;
255
+ }
256
+ button.dataset.optionCount = String(count);
257
+ const expandable = count > EXPAND_THRESHOLD;
258
+ const expanded = expandable && expandedGroups.has(key);
259
+ button.hidden = !expandable;
260
+ button.textContent = expanded ? "Less" : `All ${count}`;
261
+ button.setAttribute("aria-expanded", String(expanded));
262
+ optionsElement.dataset.expanded = String(expanded);
263
+ optionsElement.classList.remove("filter-options--expanded", "filter-options--compact");
264
+ optionsElement.classList.add(expanded ? "filter-options--expanded" : "filter-options--compact");
265
+ }
266
+
267
+ function buttonFor(value, onClick) {
268
+ const button = document.createElement("button");
269
+ button.className = "filter-chip";
270
+ button.type = "button";
271
+ button.textContent = value;
272
+ button.dataset.filterValue = value;
273
+ button.addEventListener("click", onClick);
274
+ return button;
275
+ }
276
+
277
+ function syncChipGroup(group, key, values, selected, ctx) {
278
+ const signature = values.join("\u0000");
279
+ group.dataset.filterKey = key;
280
+ if (group.dataset.values !== signature) {
281
+ group.dataset.values = signature;
282
+ if (values.length === 0) {
283
+ group.replaceChildren(textElement("span", "filter-empty", "No values"));
284
+ } else {
285
+ const buttons = values.map((value) => buttonFor(value, () => {
286
+ const next = new Set(ctx.getState().filters[key]);
287
+ if (next.has(value)) {
288
+ next.delete(value);
289
+ } else {
290
+ next.add(value);
291
+ }
292
+ ctx.setFilter(key, next);
293
+ }));
294
+ group.replaceChildren(...buttons);
295
+ }
296
+ }
297
+ for (const button of group.querySelectorAll(".filter-chip")) {
298
+ button.setAttribute("aria-pressed", String(selected.has(button.dataset.filterValue)));
299
+ }
300
+ }
301
+
302
+ export function renderFilterBar(root, ctx) {
303
+ const search = document.createElement("input");
304
+ search.className = "input";
305
+ search.id = "filter-search";
306
+ search.type = "search";
307
+ search.placeholder = "id, body, metadata";
308
+ search.addEventListener("input", (event) => {
309
+ ctx.setFilter("search", event.target.value);
310
+ });
311
+
312
+ const searchWrap = document.createElement("span");
313
+ searchWrap.className = "input-search";
314
+ searchWrap.append(search);
315
+
316
+ const kindOptions = document.createElement("div");
317
+ kindOptions.className = "filter-options";
318
+ kindOptions.setAttribute("aria-label", "Kind filters");
319
+
320
+ const stageOptions = document.createElement("div");
321
+ stageOptions.className = "filter-options";
322
+ stageOptions.setAttribute("aria-label", "Stage filters");
323
+
324
+ const parentOptions = document.createElement("div");
325
+ parentOptions.className = "filter-options filter-options--scroll";
326
+ parentOptions.setAttribute("aria-label", "Parent filters");
327
+
328
+ const epicOptions = document.createElement("div");
329
+ epicOptions.className = "filter-options filter-options--scroll";
330
+ epicOptions.setAttribute("aria-label", "Epic filters");
331
+
332
+ const tagOptions = document.createElement("div");
333
+ tagOptions.className = "filter-options filter-options--scroll";
334
+ tagOptions.setAttribute("aria-label", "Tag filters");
335
+
336
+ const autoHide = document.createElement("button");
337
+ autoHide.className = "toggle";
338
+ autoHide.type = "button";
339
+ autoHide.id = "auto-hide-released";
340
+ autoHide.append(
341
+ textElement("span", "track", ""),
342
+ document.createTextNode("hide released/archived"),
343
+ );
344
+ autoHide.querySelector(".track")?.setAttribute("aria-hidden", "true");
345
+ autoHide.addEventListener("click", () => {
346
+ ctx.setFilter("autoHideReleased", !ctx.getState().filters.autoHideReleased);
347
+ });
348
+
349
+ root.replaceChildren(
350
+ filterGroup("Search", searchWrap),
351
+ filterGroup("Kind", kindOptions),
352
+ filterGroup("Stage", stageOptions),
353
+ filterGroup("Epic", epicOptions, { expandKey: "epics" }),
354
+ filterGroup("Parent", parentOptions),
355
+ filterGroup("Tags", tagOptions, { expandKey: "tags" }),
356
+ autoHide,
357
+ );
358
+ const epicExpand = root.querySelector("[data-expand-key='epics']");
359
+ const tagExpand = root.querySelector("[data-expand-key='tags']");
360
+
361
+ let previousSnapshot = null;
362
+ let previousOptions = deriveFilterOptions(null);
363
+
364
+ function optionsFor(snapshot) {
365
+ if (snapshot !== previousSnapshot) {
366
+ previousSnapshot = snapshot;
367
+ previousOptions = deriveFilterOptions(snapshot);
368
+ }
369
+ return previousOptions;
370
+ }
371
+
372
+ function sync(state) {
373
+ if (document.activeElement !== search && search.value !== state.filters.search) {
374
+ search.value = state.filters.search;
375
+ }
376
+ const options = optionsFor(state.snapshot);
377
+ syncChipGroup(kindOptions, "kinds", options.kinds, state.filters.kinds, ctx);
378
+ syncChipGroup(stageOptions, "stages", options.stages, state.filters.stages, ctx);
379
+ syncChipGroup(epicOptions, "epics", options.epics, state.filters.epics, ctx);
380
+ syncChipGroup(parentOptions, "parents", options.parents, state.filters.parents, ctx);
381
+ syncChipGroup(tagOptions, "tags", options.tags, state.filters.tags, ctx);
382
+ syncExpandableGroup(epicOptions, epicExpand, "epics", options.epics.length);
383
+ syncExpandableGroup(tagOptions, tagExpand, "tags", options.tags.length);
384
+ autoHide.setAttribute("aria-pressed", String(state.filters.autoHideReleased));
385
+ }
386
+
387
+ sync(ctx.getState());
388
+ return { sync };
389
+ }