@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,79 @@
1
+ import test from "node:test";
2
+ import assert from "node:assert/strict";
3
+
4
+ import { TestEvent, installDomGlobals, loadBoardModule, makeItem } from "./harness.mjs";
5
+
6
+ test("epic filters match an epic and all descendants through parent chains", async () => {
7
+ installDomGlobals();
8
+ const { createDefaultFilters, deriveFilterOptions, epicIdForItem, matchesFilters } = await loadBoardModule("filters.js");
9
+ const items = [
10
+ makeItem({ id: "epic-alpha", kind: "epic", parent: null }),
11
+ makeItem({ id: "feature-alpha", kind: "feature", parent: "epic-alpha" }),
12
+ makeItem({ id: "story-alpha", kind: "story", parent: "feature-alpha" }),
13
+ makeItem({ id: "epic-beta", kind: "epic", parent: null }),
14
+ makeItem({ id: "story-beta", kind: "story", parent: "epic-beta" }),
15
+ makeItem({ id: "cycle-a", kind: "story", parent: "cycle-b" }),
16
+ makeItem({ id: "cycle-b", kind: "feature", parent: "cycle-a" }),
17
+ ];
18
+ const snapshot = { items };
19
+ const options = deriveFilterOptions(snapshot);
20
+ assert.deepEqual(options.epics, ["epic-alpha", "epic-beta"]);
21
+ assert.equal(epicIdForItem(items[2], new Map(items.map((item) => [item.id, item]))), "epic-alpha");
22
+ assert.equal(epicIdForItem(items[5], new Map(items.map((item) => [item.id, item]))), "");
23
+
24
+ const filters = createDefaultFilters();
25
+ filters.epics = new Set(["epic-alpha"]);
26
+ assert.equal(matchesFilters(items[0], filters, snapshot), true);
27
+ assert.equal(matchesFilters(items[1], filters, snapshot), true);
28
+ assert.equal(matchesFilters(items[2], filters, snapshot), true);
29
+ assert.equal(matchesFilters(items[3], filters, snapshot), false);
30
+ assert.equal(matchesFilters(items[4], filters, snapshot), false);
31
+ });
32
+
33
+ test("tag and epic groups expand in the sidebar without changing filter semantics", async () => {
34
+ const { document } = installDomGlobals();
35
+ const { createDefaultFilters, renderFilterBar } = await loadBoardModule("filters.js");
36
+ const tags = Array.from({ length: 16 }, (_, index) => `tag-${index + 1}`);
37
+ const items = [
38
+ makeItem({ id: "epic-alpha", kind: "epic", tags: ["tag-1"] }),
39
+ ...tags.map((tag, index) => makeItem({
40
+ id: `story-${index}`,
41
+ kind: "story",
42
+ parent: "epic-alpha",
43
+ tags: [tag],
44
+ })),
45
+ ];
46
+ const filters = createDefaultFilters();
47
+ let setFilterCalls = 0;
48
+ const ctx = {
49
+ getState: () => ({ snapshot: { items }, filters }),
50
+ setFilter(key, value) {
51
+ setFilterCalls += 1;
52
+ filters[key] = value;
53
+ },
54
+ };
55
+ const root = document.createElement("aside");
56
+ const ui = renderFilterBar(root, ctx);
57
+ ui.sync(ctx.getState());
58
+
59
+ const tagOptions = root.querySelectorAll(".filter-options")
60
+ .find((node) => node.getAttribute("aria-label") === "Tag filters");
61
+ const tagToggle = root.querySelectorAll(".filter-expand-toggle")
62
+ .find((button) => button.dataset.expandKey === "tags");
63
+ assert.ok(tagOptions);
64
+ assert.ok(tagToggle);
65
+ assert.equal(tagToggle.tagName, "BUTTON");
66
+ assert.notEqual(tagToggle.getAttribute("tabindex"), "-1");
67
+ assert.equal(tagOptions.dataset.expanded, "false");
68
+ assert.equal(tagToggle.getAttribute("aria-expanded"), "false");
69
+
70
+ tagToggle.dispatchEvent(new TestEvent("keydown", { key: "Enter", cancelable: true }));
71
+ assert.equal(tagOptions.dataset.expanded, "true");
72
+ assert.equal(tagToggle.getAttribute("aria-expanded"), "true");
73
+
74
+ const tagButton = tagOptions.querySelectorAll(".filter-chip")
75
+ .find((button) => button.dataset.filterValue === "tag-3");
76
+ tagButton.dispatchEvent(new TestEvent("click", { cancelable: true }));
77
+ assert.equal(setFilterCalls, 1);
78
+ assert.deepEqual(Array.from(filters.tags), ["tag-3"]);
79
+ });
@@ -0,0 +1,11 @@
1
+ import test from "node:test";
2
+ import assert from "node:assert/strict";
3
+
4
+ import { installDomGlobals, loadBoardModule } from "./harness.mjs";
5
+
6
+ test("board asset modules load through the no-build harness", async () => {
7
+ installDomGlobals();
8
+ const filters = await loadBoardModule("filters.js");
9
+ assert.equal(typeof filters.createDefaultFilters, "function");
10
+ assert.equal(typeof filters.matchesFilters, "function");
11
+ });
@@ -0,0 +1,437 @@
1
+ import { mkdtemp, readFile, readdir, writeFile } from "node:fs/promises";
2
+ import { tmpdir } from "node:os";
3
+ import { dirname, join, resolve } from "node:path";
4
+ import { fileURLToPath, pathToFileURL } from "node:url";
5
+
6
+ const here = dirname(fileURLToPath(import.meta.url));
7
+ const assetRoot = resolve(here, "../../src/board/assets");
8
+ let moduleGraphDir = null;
9
+
10
+ class StyleDeclaration {
11
+ setProperty(name, value) {
12
+ this[name] = String(value);
13
+ }
14
+ }
15
+
16
+ class ClassList {
17
+ constructor(element) {
18
+ this.element = element;
19
+ }
20
+
21
+ values() {
22
+ return this.element.className.split(/\s+/).filter(Boolean);
23
+ }
24
+
25
+ add(...names) {
26
+ const values = new Set(this.values());
27
+ for (const name of names) {
28
+ if (name) {
29
+ values.add(String(name));
30
+ }
31
+ }
32
+ this.element.className = Array.from(values).join(" ");
33
+ }
34
+
35
+ remove(...names) {
36
+ const remove = new Set(names.map(String));
37
+ this.element.className = this.values().filter((name) => !remove.has(name)).join(" ");
38
+ }
39
+
40
+ toggle(name, force = undefined) {
41
+ const shouldAdd = force === undefined ? !this.contains(name) : Boolean(force);
42
+ if (shouldAdd) {
43
+ this.add(name);
44
+ } else {
45
+ this.remove(name);
46
+ }
47
+ return shouldAdd;
48
+ }
49
+
50
+ contains(name) {
51
+ return this.values().includes(String(name));
52
+ }
53
+ }
54
+
55
+ class TestNode {
56
+ constructor(ownerDocument, nodeType, nodeName) {
57
+ this.ownerDocument = ownerDocument;
58
+ this.nodeType = nodeType;
59
+ this.nodeName = nodeName;
60
+ this.parentNode = null;
61
+ this.childNodes = [];
62
+ this.isConnected = false;
63
+ }
64
+
65
+ append(...children) {
66
+ for (const child of children.flat()) {
67
+ const node = typeof child === "string"
68
+ ? this.ownerDocument.createTextNode(child)
69
+ : child;
70
+ if (!node) {
71
+ continue;
72
+ }
73
+ if (node.parentNode) {
74
+ node.remove();
75
+ }
76
+ node.parentNode = this;
77
+ node.isConnected = this.isConnected;
78
+ this.childNodes.push(node);
79
+ markConnected(node, node.isConnected);
80
+ }
81
+ }
82
+
83
+ replaceChildren(...children) {
84
+ for (const child of this.childNodes) {
85
+ child.parentNode = null;
86
+ markConnected(child, false);
87
+ }
88
+ this.childNodes = [];
89
+ this.append(...children);
90
+ }
91
+
92
+ remove() {
93
+ if (!this.parentNode) {
94
+ return;
95
+ }
96
+ const siblings = this.parentNode.childNodes;
97
+ const index = siblings.indexOf(this);
98
+ if (index !== -1) {
99
+ siblings.splice(index, 1);
100
+ }
101
+ this.parentNode = null;
102
+ markConnected(this, false);
103
+ }
104
+
105
+ hasChildNodes() {
106
+ return this.childNodes.length > 0;
107
+ }
108
+
109
+ get children() {
110
+ return this.childNodes.filter((child) => child.nodeType === 1);
111
+ }
112
+
113
+ get textContent() {
114
+ if (this.nodeType === 3) {
115
+ return this.data;
116
+ }
117
+ return this.childNodes.map((child) => child.textContent).join("");
118
+ }
119
+
120
+ set textContent(value) {
121
+ if (this.nodeType === 3) {
122
+ this.data = String(value);
123
+ return;
124
+ }
125
+ this.replaceChildren(this.ownerDocument.createTextNode(String(value)));
126
+ }
127
+ }
128
+
129
+ class TestText extends TestNode {
130
+ constructor(ownerDocument, text) {
131
+ super(ownerDocument, 3, "#text");
132
+ this.data = String(text);
133
+ }
134
+ }
135
+
136
+ class TestElement extends TestNode {
137
+ constructor(ownerDocument, tagName) {
138
+ super(ownerDocument, 1, String(tagName).toUpperCase());
139
+ this.tagName = String(tagName).toUpperCase();
140
+ this.attributes = new Map();
141
+ this.dataset = {};
142
+ this.style = new StyleDeclaration();
143
+ this.className = "";
144
+ this.classList = new ClassList(this);
145
+ this.eventListeners = new Map();
146
+ this.disabled = false;
147
+ this.hidden = false;
148
+ this.value = "";
149
+ this.type = "";
150
+ this.id = "";
151
+ this.tabIndex = undefined;
152
+ }
153
+
154
+ setAttribute(name, value) {
155
+ const text = String(value);
156
+ this.attributes.set(name, text);
157
+ if (name === "class") {
158
+ this.className = text;
159
+ } else if (name === "id") {
160
+ this.id = text;
161
+ } else if (name === "tabindex") {
162
+ this.tabIndex = Number.parseInt(text, 10);
163
+ } else if (name.startsWith("data-")) {
164
+ this.dataset[dataKey(name.slice(5))] = text;
165
+ } else {
166
+ this[name] = text;
167
+ }
168
+ }
169
+
170
+ getAttribute(name) {
171
+ if (name === "class") {
172
+ return this.className || null;
173
+ }
174
+ if (name === "id") {
175
+ return this.id || null;
176
+ }
177
+ if (name === "tabindex" && this.tabIndex !== undefined) {
178
+ return String(this.tabIndex);
179
+ }
180
+ return this.attributes.has(name) ? this.attributes.get(name) : null;
181
+ }
182
+
183
+ removeAttribute(name) {
184
+ this.attributes.delete(name);
185
+ if (name === "class") {
186
+ this.className = "";
187
+ } else if (name === "id") {
188
+ this.id = "";
189
+ } else if (name.startsWith("data-")) {
190
+ delete this.dataset[dataKey(name.slice(5))];
191
+ } else {
192
+ delete this[name];
193
+ }
194
+ }
195
+
196
+ addEventListener(type, listener) {
197
+ const listeners = this.eventListeners.get(type) || [];
198
+ listeners.push(listener);
199
+ this.eventListeners.set(type, listeners);
200
+ }
201
+
202
+ removeEventListener(type, listener) {
203
+ const listeners = this.eventListeners.get(type) || [];
204
+ this.eventListeners.set(type, listeners.filter((entry) => entry !== listener));
205
+ }
206
+
207
+ dispatchEvent(event) {
208
+ event.target ||= this;
209
+ event.currentTarget = this;
210
+ for (const listener of this.eventListeners.get(event.type) || []) {
211
+ listener.call(this, event);
212
+ if (event.immediatePropagationStopped) {
213
+ break;
214
+ }
215
+ }
216
+ if (
217
+ event.type === "keydown"
218
+ && this.tagName === "BUTTON"
219
+ && !event.defaultPrevented
220
+ && (event.key === "Enter" || event.key === " " || event.key === "Space")
221
+ ) {
222
+ const click = new TestEvent("click", { cancelable: true });
223
+ this.dispatchEvent(click);
224
+ if (click.defaultPrevented && event.cancelable) {
225
+ event.preventDefault();
226
+ }
227
+ }
228
+ return !event.defaultPrevented;
229
+ }
230
+
231
+ focus() {
232
+ this.ownerDocument.activeElement = this;
233
+ }
234
+
235
+ querySelector(selector) {
236
+ return this.querySelectorAll(selector)[0] || null;
237
+ }
238
+
239
+ querySelectorAll(selector) {
240
+ const selectors = selector.split(",").map((part) => part.trim()).filter(Boolean);
241
+ const matches = [];
242
+ walkElements(this, (element) => {
243
+ if (element !== this && selectors.some((part) => matchesSelector(element, part))) {
244
+ matches.push(element);
245
+ }
246
+ });
247
+ return matches;
248
+ }
249
+
250
+ closest(selector) {
251
+ let current = this;
252
+ while (current) {
253
+ if (current.nodeType === 1 && matchesSelector(current, selector)) {
254
+ return current;
255
+ }
256
+ current = current.parentNode;
257
+ }
258
+ return null;
259
+ }
260
+ }
261
+
262
+ class TestDocument extends TestElement {
263
+ constructor() {
264
+ super(null, "#document");
265
+ this.ownerDocument = this;
266
+ this.nodeType = 9;
267
+ this.nodeName = "#document";
268
+ this.documentElement = new TestElement(this, "html");
269
+ this.body = new TestElement(this, "body");
270
+ this.activeElement = this.body;
271
+ this.isConnected = true;
272
+ this.documentElement.isConnected = true;
273
+ this.body.isConnected = true;
274
+ this.documentElement.append(this.body);
275
+ this.childNodes = [this.documentElement];
276
+ }
277
+
278
+ createElement(tagName) {
279
+ return new TestElement(this, tagName);
280
+ }
281
+
282
+ createElementNS(_namespace, tagName) {
283
+ return new TestElement(this, tagName);
284
+ }
285
+
286
+ createTextNode(text) {
287
+ return new TestText(this, text);
288
+ }
289
+
290
+ createDocumentFragment() {
291
+ const fragment = new TestElement(this, "#fragment");
292
+ fragment.nodeType = 11;
293
+ fragment.nodeName = "#document-fragment";
294
+ return fragment;
295
+ }
296
+ }
297
+
298
+ export class TestEvent {
299
+ constructor(type, options = {}) {
300
+ this.type = type;
301
+ this.bubbles = Boolean(options.bubbles);
302
+ this.cancelable = Boolean(options.cancelable);
303
+ this.defaultPrevented = false;
304
+ this.immediatePropagationStopped = false;
305
+ this.key = options.key || "";
306
+ this.shiftKey = Boolean(options.shiftKey);
307
+ this.target = options.target || null;
308
+ this.currentTarget = null;
309
+ }
310
+
311
+ preventDefault() {
312
+ if (this.cancelable) {
313
+ this.defaultPrevented = true;
314
+ }
315
+ }
316
+
317
+ stopImmediatePropagation() {
318
+ this.immediatePropagationStopped = true;
319
+ }
320
+ }
321
+
322
+ export function installDomGlobals({ width = 1024 } = {}) {
323
+ const document = new TestDocument();
324
+ const listeners = new Map();
325
+ const window = {
326
+ document,
327
+ innerWidth: width,
328
+ localStorage: null,
329
+ addEventListener(type, listener) {
330
+ const values = listeners.get(type) || [];
331
+ values.push(listener);
332
+ listeners.set(type, values);
333
+ },
334
+ removeEventListener(type, listener) {
335
+ const values = listeners.get(type) || [];
336
+ listeners.set(type, values.filter((entry) => entry !== listener));
337
+ },
338
+ dispatchEvent(event) {
339
+ for (const listener of listeners.get(event.type) || []) {
340
+ listener.call(window, event);
341
+ }
342
+ return !event.defaultPrevented;
343
+ },
344
+ setTimeout,
345
+ clearTimeout,
346
+ };
347
+
348
+ globalThis.window = window;
349
+ globalThis.document = document;
350
+ globalThis.Event = TestEvent;
351
+ globalThis.KeyboardEvent = TestEvent;
352
+ globalThis.URL = URL;
353
+ return { document, window };
354
+ }
355
+
356
+ export async function loadBoardModule(name) {
357
+ const dir = await ensureModuleGraph();
358
+ return import(`${pathToFileURL(join(dir, name)).href}?v=${Date.now()}-${Math.random()}`);
359
+ }
360
+
361
+ export function makeItem(overrides = {}) {
362
+ return {
363
+ id: "story-alpha",
364
+ kind: "story",
365
+ stage: "implementing",
366
+ parent: null,
367
+ tags: [],
368
+ depends_on: [],
369
+ unmet_deps: [],
370
+ dependents: [],
371
+ children: [],
372
+ body: "Story body",
373
+ updated: "2026-06-01",
374
+ ready: false,
375
+ blocked: false,
376
+ is_terminal: false,
377
+ tier: "active",
378
+ ...overrides,
379
+ };
380
+ }
381
+
382
+ async function ensureModuleGraph() {
383
+ if (moduleGraphDir) {
384
+ return moduleGraphDir;
385
+ }
386
+ moduleGraphDir = await mkdtemp(join(tmpdir(), "board-js-assets-"));
387
+ const files = (await readdir(assetRoot)).filter((file) => file.endsWith(".js"));
388
+ await Promise.all(files.map(async (file) => {
389
+ const source = await readFile(join(assetRoot, file), "utf8");
390
+ const rewritten = source.replaceAll(/from\s+["']\/assets\/([^"']+)["']/g, 'from "./$1"');
391
+ await writeFile(join(moduleGraphDir, file), rewritten, "utf8");
392
+ }));
393
+ return moduleGraphDir;
394
+ }
395
+
396
+ function dataKey(name) {
397
+ return name.replace(/-([a-z])/g, (_, letter) => letter.toUpperCase());
398
+ }
399
+
400
+ function markConnected(node, connected) {
401
+ node.isConnected = connected;
402
+ for (const child of node.childNodes || []) {
403
+ markConnected(child, connected);
404
+ }
405
+ }
406
+
407
+ function walkElements(root, visit) {
408
+ for (const child of root.childNodes || []) {
409
+ if (child.nodeType === 1) {
410
+ visit(child);
411
+ walkElements(child, visit);
412
+ }
413
+ }
414
+ }
415
+
416
+ function matchesSelector(element, selector) {
417
+ const attrMatch = selector.match(/^(.+)(\[.+\])$/);
418
+ if (attrMatch) {
419
+ return matchesSelector(element, attrMatch[1]) && matchesSelector(element, attrMatch[2]);
420
+ }
421
+ const notTabIndex = selector.match(/^(.*):not\(\[tabindex=['"]?-1['"]?\]\)$/);
422
+ if (notTabIndex) {
423
+ return matchesSelector(element, notTabIndex[1]) && element.getAttribute("tabindex") !== "-1";
424
+ }
425
+ if (selector.startsWith(".")) {
426
+ return element.classList.contains(selector.slice(1));
427
+ }
428
+ if (selector.startsWith("#")) {
429
+ return element.id === selector.slice(1);
430
+ }
431
+ const attr = selector.match(/^\[([^=\]]+)(?:=['"]?([^'"\]]+)['"]?)?\]$/);
432
+ if (attr) {
433
+ const value = element.getAttribute(attr[1]);
434
+ return attr[2] === undefined ? value !== null : value === attr[2];
435
+ }
436
+ return element.tagName.toLowerCase() === selector.toLowerCase();
437
+ }
@@ -0,0 +1,75 @@
1
+ import test from "node:test";
2
+ import assert from "node:assert/strict";
3
+
4
+ import { TestEvent, installDomGlobals, loadBoardModule, makeItem } from "./harness.mjs";
5
+
6
+ test("kanban lanes group by parent and lane focus does not mutate global filters", async () => {
7
+ const { document } = installDomGlobals();
8
+ const { createDefaultFilters } = await loadBoardModule("filters.js");
9
+ const { groupItemsByLane, laneProgress, kanbanView } = await loadBoardModule("kanban.js");
10
+ const items = [
11
+ makeItem({ id: "epic-alpha", kind: "epic", parent: null, stage: "done", is_terminal: true }),
12
+ makeItem({ id: "feature-alpha", kind: "feature", parent: "epic-alpha", stage: "review" }),
13
+ makeItem({ id: "story-alpha", kind: "story", parent: "feature-alpha", stage: "implementing" }),
14
+ makeItem({ id: "orphan", kind: "story", parent: null, stage: "drafting" }),
15
+ ];
16
+
17
+ const lanes = groupItemsByLane(items);
18
+ assert.deepEqual(Array.from(lanes.keys()).sort(), ["(no parent)", "epic-alpha", "feature-alpha"]);
19
+ assert.deepEqual(laneProgress(lanes.get("epic-alpha")), { done: 1, total: 2 });
20
+
21
+ const filters = createDefaultFilters();
22
+ let setFilterCalls = 0;
23
+ const ctx = {
24
+ visibleItems: () => items,
25
+ getState: () => ({ snapshot: { items }, filters }),
26
+ setFilter: () => { setFilterCalls += 1; },
27
+ renderCard: (item) => {
28
+ const node = document.createElement("article");
29
+ node.dataset.id = item.id;
30
+ return node;
31
+ },
32
+ };
33
+ const root = document.createElement("main");
34
+ document.body.append(root);
35
+ kanbanView.mount(root, ctx);
36
+
37
+ const laneButton = root.querySelectorAll(".kanban-lane-chip")
38
+ .find((button) => button.dataset.lane === "epic-alpha");
39
+ assert.ok(laneButton);
40
+ laneButton.dispatchEvent(new TestEvent("click", { cancelable: true }));
41
+
42
+ assert.equal(setFilterCalls, 0);
43
+ assert.deepEqual(Array.from(filters.parents), []);
44
+ assert.deepEqual(Array.from(filters.tags), []);
45
+ });
46
+
47
+ test("detail presentation and selected id refresh behavior are deterministic", async () => {
48
+ installDomGlobals();
49
+ const { detectDetailPresentation } = await loadBoardModule("detail.js");
50
+ const { createBoardStore } = await loadBoardModule("state.js");
51
+
52
+ assert.equal(detectDetailPresentation(makeItem({ body: "short" }), 500), "modal");
53
+ assert.equal(detectDetailPresentation(makeItem({ body: "short" }), 1024), "drawer-narrow");
54
+ assert.equal(detectDetailPresentation(makeItem({ body: "x".repeat(1200) }), 1024), "drawer-wide");
55
+ assert.equal(detectDetailPresentation(makeItem({ body: "x".repeat(2600) }), 1024), "modal");
56
+
57
+ const snapshots = [
58
+ { items: [makeItem({ id: "story-alpha" })] },
59
+ { items: [makeItem({ id: "story-alpha" })] },
60
+ { items: [makeItem({ id: "story-beta" })] },
61
+ ];
62
+ const store = createBoardStore({
63
+ storage: null,
64
+ root: { addEventListener() {}, visibilityState: "visible" },
65
+ fetchJson: async () => snapshots.shift(),
66
+ });
67
+
68
+ await store.refresh();
69
+ store.setSelectedItem("story-alpha");
70
+ await store.refresh();
71
+ assert.equal(store.getState().selectedItemId, "story-alpha");
72
+
73
+ await store.refresh();
74
+ assert.equal(store.getState().selectedItemId, null);
75
+ });
@@ -0,0 +1,60 @@
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("markdown renderer keeps adversarial HTML inert", async () => {
7
+ installDomGlobals();
8
+ const { renderMarkdown } = await loadBoardModule("markdown.js");
9
+ const root = renderMarkdown(`
10
+ # Unsafe
11
+ <script>alert("x")</script>
12
+ <img src=x onerror=alert(1)>
13
+ [bad](javascript:alert(1))
14
+ [ok](https://example.com/docs)
15
+ `);
16
+
17
+ assert.equal(root.querySelectorAll("script").length, 0);
18
+ assert.equal(root.querySelectorAll("img").length, 0);
19
+ assert.match(root.textContent, /<script>alert\("x"\)<\/script>/);
20
+ assert.match(root.textContent, /\[bad\]\(javascript:alert\(1\)\)/);
21
+
22
+ const anchors = root.querySelectorAll("a");
23
+ assert.equal(anchors.length, 1);
24
+ assert.equal(anchors[0].getAttribute("href"), "https://example.com/docs");
25
+ assert.equal(anchors[0].getAttribute("rel"), "noreferrer");
26
+ });
27
+
28
+ test("filters compose scalar ORs, group ANDs, tag ANDs, search, null parent, and tier hide", async () => {
29
+ installDomGlobals();
30
+ const { createDefaultFilters, matchesFilters } = await loadBoardModule("filters.js");
31
+ const item = makeItem({
32
+ kind: "story",
33
+ stage: "review",
34
+ parent: null,
35
+ tags: ["tooling", "testing"],
36
+ body: "Dependency board behavior",
37
+ });
38
+
39
+ const filters = createDefaultFilters();
40
+ filters.kinds = new Set(["feature", "story"]);
41
+ filters.stages = new Set(["done", "review"]);
42
+ filters.parents = new Set([""]);
43
+ filters.tags = new Set(["tooling", "testing"]);
44
+ filters.search = "dependency";
45
+ assert.equal(matchesFilters(item, filters), true);
46
+
47
+ filters.tags = new Set(["tooling", "missing"]);
48
+ assert.equal(matchesFilters(item, filters), false);
49
+
50
+ filters.tags = new Set(["tooling"]);
51
+ filters.kinds = new Set(["feature"]);
52
+ assert.equal(matchesFilters(item, filters), false);
53
+
54
+ filters.kinds = new Set(["story"]);
55
+ filters.autoHideReleased = true;
56
+ assert.equal(matchesFilters({ ...item, tier: "releases" }, filters), false);
57
+
58
+ filters.autoHideReleased = false;
59
+ assert.equal(matchesFilters({ ...item, tier: "releases" }, filters), true);
60
+ });
@@ -0,0 +1,3 @@
1
+ # Conventions
2
+
3
+ Test fixture substrate for the work-view CLI integration tests.
@@ -0,0 +1,16 @@
1
+ ---
2
+ id: epic-alpha
3
+ kind: epic
4
+ stage: implementing
5
+ tags: [tooling]
6
+ parent: null
7
+ depends_on: []
8
+ release_binding: null
9
+ gate_origin: null
10
+ created: 2026-01-01
11
+ updated: 2026-05-30
12
+ ---
13
+
14
+ # Alpha epic
15
+
16
+ Top-level epic for testing the CLI adapter.
@@ -0,0 +1,16 @@
1
+ ---
2
+ id: feat-a
3
+ kind: feature
4
+ stage: implementing
5
+ tags: [tooling]
6
+ parent: epic-alpha
7
+ depends_on: []
8
+ release_binding: v1.0.0
9
+ gate_origin: null
10
+ created: 2026-01-02
11
+ updated: 2026-05-30
12
+ ---
13
+
14
+ # Feature A
15
+
16
+ First feature under the alpha epic.