@blackbelt-technology/pi-agent-dashboard 0.4.1 → 0.4.2

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 (108) hide show
  1. package/AGENTS.md +79 -32
  2. package/README.md +7 -3
  3. package/docs/architecture.md +361 -12
  4. package/package.json +7 -7
  5. package/packages/extension/package.json +7 -2
  6. package/packages/extension/src/__tests__/ask-user-schema-discriminator.test.ts +141 -0
  7. package/packages/extension/src/__tests__/ask-user-tool.test.ts +51 -7
  8. package/packages/extension/src/__tests__/multiselect-dashboard-routing.test.ts +203 -0
  9. package/packages/extension/src/__tests__/multiselect-polyfill.test.ts +92 -0
  10. package/packages/extension/src/__tests__/no-tui-multiselect-arm-regression.test.ts +81 -0
  11. package/packages/extension/src/__tests__/openspec-activity-detector.test.ts +37 -0
  12. package/packages/extension/src/__tests__/ui-decorators.test.ts +309 -0
  13. package/packages/extension/src/__tests__/ui-modules.test.ts +293 -0
  14. package/packages/extension/src/ask-user-tool.ts +165 -57
  15. package/packages/extension/src/bridge.ts +97 -4
  16. package/packages/extension/src/multiselect-decode.ts +40 -0
  17. package/packages/extension/src/multiselect-polyfill.ts +38 -8
  18. package/packages/extension/src/ui-modules.ts +272 -0
  19. package/packages/server/package.json +9 -3
  20. package/packages/server/src/__tests__/auto-attach.test.ts +61 -8
  21. package/packages/server/src/__tests__/browse-endpoint.test.ts +295 -19
  22. package/packages/server/src/__tests__/cli-bootstrap.test.ts +36 -0
  23. package/packages/server/src/__tests__/directory-service-refresh-force.test.ts +163 -0
  24. package/packages/server/src/__tests__/directory-service-specs-mtime.test.ts +315 -0
  25. package/packages/server/src/__tests__/directory-service-toctou.test.ts +303 -0
  26. package/packages/server/src/__tests__/directory-service.test.ts +174 -0
  27. package/packages/server/src/__tests__/installed-package-enricher.test.ts +225 -0
  28. package/packages/server/src/__tests__/package-manager-wrapper-move.test.ts +414 -0
  29. package/packages/server/src/__tests__/package-routes.test.ts +136 -3
  30. package/packages/server/src/__tests__/package-source-helpers.test.ts +101 -0
  31. package/packages/server/src/__tests__/pending-attach-registry.test.ts +123 -0
  32. package/packages/server/src/__tests__/pending-resume-intent-registry.test.ts +138 -0
  33. package/packages/server/src/__tests__/pi-core-checker.test.ts +73 -30
  34. package/packages/server/src/__tests__/pi-gateway-consume-pending-attach.test.ts +112 -0
  35. package/packages/server/src/__tests__/post-install-openspec-refresh.test.ts +180 -0
  36. package/packages/server/src/__tests__/post-install-rescan.test.ts +134 -0
  37. package/packages/server/src/__tests__/proposal-attach-naming.test.ts +79 -0
  38. package/packages/server/src/__tests__/session-action-handler-spawn-with-attach.test.ts +108 -0
  39. package/packages/server/src/__tests__/session-order-manager.test.ts +55 -0
  40. package/packages/server/src/__tests__/session-order-reboot.test.ts +242 -0
  41. package/packages/server/src/__tests__/session-scanner.test.ts +44 -0
  42. package/packages/server/src/__tests__/subscription-handler.test.ts +40 -0
  43. package/packages/server/src/__tests__/translate-path-source.test.ts +77 -0
  44. package/packages/server/src/__tests__/ui-decorators-replay.test.ts +209 -0
  45. package/packages/server/src/__tests__/ui-modules-replay.test.ts +221 -0
  46. package/packages/server/src/browse.ts +118 -13
  47. package/packages/server/src/browser-gateway.ts +19 -0
  48. package/packages/server/src/browser-handlers/__tests__/session-meta-handler.test.ts +183 -0
  49. package/packages/server/src/browser-handlers/directory-handler.ts +7 -1
  50. package/packages/server/src/browser-handlers/handler-context.ts +15 -0
  51. package/packages/server/src/browser-handlers/session-action-handler.ts +29 -3
  52. package/packages/server/src/browser-handlers/session-meta-handler.ts +46 -12
  53. package/packages/server/src/browser-handlers/subscription-handler.ts +46 -1
  54. package/packages/server/src/cli.ts +5 -6
  55. package/packages/server/src/directory-service.ts +156 -15
  56. package/packages/server/src/event-wiring.ts +111 -10
  57. package/packages/server/src/installed-package-enricher.ts +143 -0
  58. package/packages/server/src/package-manager-wrapper.ts +305 -8
  59. package/packages/server/src/package-source-helpers.ts +104 -0
  60. package/packages/server/src/pending-attach-registry.ts +112 -0
  61. package/packages/server/src/pending-resume-intent-registry.ts +107 -0
  62. package/packages/server/src/pi-core-checker.ts +9 -14
  63. package/packages/server/src/pi-gateway.ts +14 -0
  64. package/packages/server/src/proposal-attach-naming.ts +47 -0
  65. package/packages/server/src/routes/file-routes.ts +29 -3
  66. package/packages/server/src/routes/package-routes.ts +72 -3
  67. package/packages/server/src/routes/plugin-config-routes.ts +129 -0
  68. package/packages/server/src/routes/system-routes.ts +2 -0
  69. package/packages/server/src/server.ts +339 -10
  70. package/packages/server/src/session-api.ts +30 -5
  71. package/packages/server/src/session-order-manager.ts +22 -0
  72. package/packages/server/src/session-scanner.ts +10 -1
  73. package/packages/shared/package.json +9 -2
  74. package/packages/shared/src/__tests__/browser-protocol-types.test.ts +59 -0
  75. package/packages/shared/src/__tests__/config-plugins.test.ts +68 -0
  76. package/packages/shared/src/__tests__/extension-ui-module-shape.test.ts +265 -0
  77. package/packages/shared/src/__tests__/no-raw-openspec-status-in-skills.test.ts +81 -0
  78. package/packages/shared/src/__tests__/openspec-design-evidence.test.ts +288 -0
  79. package/packages/shared/src/__tests__/openspec-effective-status-script.test.ts +174 -0
  80. package/packages/shared/src/__tests__/openspec-poller-design-override.test.ts +225 -0
  81. package/packages/shared/src/__tests__/openspec-poller-specs-override.test.ts +284 -0
  82. package/packages/shared/src/__tests__/openspec-specs-evidence.test.ts +144 -0
  83. package/packages/shared/src/__tests__/platform/is-appimage-self-hit.test.ts +164 -0
  84. package/packages/shared/src/__tests__/plugin-bridge-register-extended.test.ts +72 -0
  85. package/packages/shared/src/__tests__/plugin-bridge-register.test.ts +113 -0
  86. package/packages/shared/src/__tests__/plugin-config-update-protocol.test.ts +41 -0
  87. package/packages/shared/src/__tests__/recommended-extensions.test.ts +5 -1
  88. package/packages/shared/src/__tests__/spawn-session-attach-proposal.test.ts +47 -0
  89. package/packages/shared/src/__tests__/tool-registry-strategies-appimage.test.ts +118 -0
  90. package/packages/shared/src/browser-protocol.ts +110 -4
  91. package/packages/shared/src/config.ts +45 -0
  92. package/packages/shared/src/dashboard-plugin/index.ts +11 -0
  93. package/packages/shared/src/dashboard-plugin/manifest-types.ts +58 -0
  94. package/packages/shared/src/dashboard-plugin/plugin-status.ts +26 -0
  95. package/packages/shared/src/dashboard-plugin/slot-props.ts +92 -0
  96. package/packages/shared/src/dashboard-plugin/slot-types.ts +151 -0
  97. package/packages/shared/src/openspec-activity-detector.ts +18 -22
  98. package/packages/shared/src/openspec-design-evidence.ts +109 -0
  99. package/packages/shared/src/openspec-poller.ts +117 -3
  100. package/packages/shared/src/openspec-specs-evidence.ts +79 -0
  101. package/packages/shared/src/platform/binary-lookup.ts +96 -1
  102. package/packages/shared/src/plugin-bridge-register.ts +139 -0
  103. package/packages/shared/src/protocol.ts +56 -2
  104. package/packages/shared/src/recommended-extensions.ts +7 -1
  105. package/packages/shared/src/rest-api.ts +68 -3
  106. package/packages/shared/src/state-replay.ts +11 -1
  107. package/packages/shared/src/tool-registry/strategies.ts +17 -3
  108. package/packages/shared/src/types.ts +160 -0
@@ -0,0 +1,288 @@
1
+ /**
2
+ * Tests for the local-design-evidence override that fixes session-card
3
+ * button rendering for split-design (Case B) and no-design (Case A) changes.
4
+ * See change: fix-openspec-design-detection.
5
+ */
6
+ import { describe, expect, it } from "vitest";
7
+ import {
8
+ evaluateLocalDesignSatisfaction,
9
+ type DesignEvidenceProbe,
10
+ } from "../openspec-design-evidence.js";
11
+
12
+ /** In-memory probe stub. */
13
+ function probe(opts: {
14
+ hasDesignFile?: boolean;
15
+ hasDesignDirWithMd?: boolean;
16
+ tasksHasCheckboxes?: boolean;
17
+ }): DesignEvidenceProbe {
18
+ return {
19
+ hasDesignFile: () => opts.hasDesignFile === true,
20
+ hasDesignDirWithMd: () => opts.hasDesignDirWithMd === true,
21
+ tasksHasCheckboxes: () => opts.tasksHasCheckboxes === true,
22
+ };
23
+ }
24
+
25
+ describe("evaluateLocalDesignSatisfaction", () => {
26
+ it("R1: design.md (or design-*.md) present → satisfied", () => {
27
+ expect(evaluateLocalDesignSatisfaction("/c", probe({ hasDesignFile: true }))).toBe(true);
28
+ });
29
+
30
+ it("R2: design/ folder with .md → satisfied", () => {
31
+ expect(evaluateLocalDesignSatisfaction("/c", probe({ hasDesignDirWithMd: true }))).toBe(true);
32
+ });
33
+
34
+ it("R3: tasks.md with checkboxes → satisfied", () => {
35
+ expect(evaluateLocalDesignSatisfaction("/c", probe({ tasksHasCheckboxes: true }))).toBe(true);
36
+ });
37
+
38
+ it("no evidence → not satisfied", () => {
39
+ expect(evaluateLocalDesignSatisfaction("/c", probe({}))).toBe(false);
40
+ });
41
+
42
+ it("R1 short-circuits before R2/R3", () => {
43
+ let r2 = 0;
44
+ let r3 = 0;
45
+ const p: DesignEvidenceProbe = {
46
+ hasDesignFile: () => true,
47
+ hasDesignDirWithMd: () => {
48
+ r2++;
49
+ return false;
50
+ },
51
+ tasksHasCheckboxes: () => {
52
+ r3++;
53
+ return false;
54
+ },
55
+ };
56
+ expect(evaluateLocalDesignSatisfaction("/c", p)).toBe(true);
57
+ expect(r2).toBe(0);
58
+ expect(r3).toBe(0);
59
+ });
60
+
61
+ it("R2 short-circuits before R3", () => {
62
+ let r3 = 0;
63
+ const p: DesignEvidenceProbe = {
64
+ hasDesignFile: () => false,
65
+ hasDesignDirWithMd: () => true,
66
+ tasksHasCheckboxes: () => {
67
+ r3++;
68
+ return false;
69
+ },
70
+ };
71
+ expect(evaluateLocalDesignSatisfaction("/c", p)).toBe(true);
72
+ expect(r3).toBe(0);
73
+ });
74
+
75
+ it("passes the changeDir through to every probe call", () => {
76
+ const seen: string[] = [];
77
+ const p: DesignEvidenceProbe = {
78
+ hasDesignFile: (d) => {
79
+ seen.push(d);
80
+ return false;
81
+ },
82
+ hasDesignDirWithMd: (d) => {
83
+ seen.push(d);
84
+ return false;
85
+ },
86
+ tasksHasCheckboxes: (d) => {
87
+ seen.push(d);
88
+ return false;
89
+ },
90
+ };
91
+ evaluateLocalDesignSatisfaction("/abs/path/to/change", p);
92
+ expect(seen).toEqual([
93
+ "/abs/path/to/change",
94
+ "/abs/path/to/change",
95
+ "/abs/path/to/change",
96
+ ]);
97
+ });
98
+ });
99
+
100
+ // ── Real-fs probe tests (createFsDesignEvidenceProbe) ──────────────────────
101
+
102
+ import { mkdtempSync, mkdirSync, writeFileSync, rmSync } from "node:fs";
103
+ import { tmpdir } from "node:os";
104
+ import path from "node:path";
105
+ import { createFsDesignEvidenceProbe } from "../openspec-design-evidence.js";
106
+
107
+ function tmpChangeDir(): string {
108
+ return mkdtempSync(path.join(tmpdir(), "openspec-design-evidence-"));
109
+ }
110
+
111
+ describe("createFsDesignEvidenceProbe — R1", () => {
112
+ it("matches design.md", () => {
113
+ const d = tmpChangeDir();
114
+ try {
115
+ writeFileSync(path.join(d, "design.md"), "");
116
+ const probe = createFsDesignEvidenceProbe();
117
+ expect(probe.hasDesignFile(d)).toBe(true);
118
+ } finally {
119
+ rmSync(d, { recursive: true, force: true });
120
+ }
121
+ });
122
+
123
+ it("matches design-rendering.md (split design)", () => {
124
+ const d = tmpChangeDir();
125
+ try {
126
+ writeFileSync(path.join(d, "design-rendering.md"), "");
127
+ writeFileSync(path.join(d, "design-state.md"), "");
128
+ const probe = createFsDesignEvidenceProbe();
129
+ expect(probe.hasDesignFile(d)).toBe(true);
130
+ } finally {
131
+ rmSync(d, { recursive: true, force: true });
132
+ }
133
+ });
134
+
135
+ it("does NOT match designate.md (must start with 'design')", () => {
136
+ const d = tmpChangeDir();
137
+ try {
138
+ // 'designate' starts with 'design' — accepted by ^design.*\.md$.
139
+ // We're testing a non-matching name instead:
140
+ writeFileSync(path.join(d, "redesign.md"), "");
141
+ const probe = createFsDesignEvidenceProbe();
142
+ expect(probe.hasDesignFile(d)).toBe(false);
143
+ } finally {
144
+ rmSync(d, { recursive: true, force: true });
145
+ }
146
+ });
147
+
148
+ it("does NOT match design.txt (must end with .md)", () => {
149
+ const d = tmpChangeDir();
150
+ try {
151
+ writeFileSync(path.join(d, "design.txt"), "");
152
+ const probe = createFsDesignEvidenceProbe();
153
+ expect(probe.hasDesignFile(d)).toBe(false);
154
+ } finally {
155
+ rmSync(d, { recursive: true, force: true });
156
+ }
157
+ });
158
+
159
+ it("returns false on missing directory", () => {
160
+ const probe = createFsDesignEvidenceProbe();
161
+ expect(probe.hasDesignFile("/nonexistent/path/xyz123")).toBe(false);
162
+ });
163
+ });
164
+
165
+ describe("createFsDesignEvidenceProbe — R2", () => {
166
+ it("matches design/ folder with at least one .md", () => {
167
+ const d = tmpChangeDir();
168
+ try {
169
+ mkdirSync(path.join(d, "design"));
170
+ writeFileSync(path.join(d, "design", "architecture.md"), "");
171
+ const probe = createFsDesignEvidenceProbe();
172
+ expect(probe.hasDesignDirWithMd(d)).toBe(true);
173
+ } finally {
174
+ rmSync(d, { recursive: true, force: true });
175
+ }
176
+ });
177
+
178
+ it("does NOT match empty design/ folder", () => {
179
+ const d = tmpChangeDir();
180
+ try {
181
+ mkdirSync(path.join(d, "design"));
182
+ const probe = createFsDesignEvidenceProbe();
183
+ expect(probe.hasDesignDirWithMd(d)).toBe(false);
184
+ } finally {
185
+ rmSync(d, { recursive: true, force: true });
186
+ }
187
+ });
188
+
189
+ it("does NOT match design/ with only non-md files", () => {
190
+ const d = tmpChangeDir();
191
+ try {
192
+ mkdirSync(path.join(d, "design"));
193
+ writeFileSync(path.join(d, "design", "notes.txt"), "");
194
+ const probe = createFsDesignEvidenceProbe();
195
+ expect(probe.hasDesignDirWithMd(d)).toBe(false);
196
+ } finally {
197
+ rmSync(d, { recursive: true, force: true });
198
+ }
199
+ });
200
+
201
+ it("returns false when design/ does not exist", () => {
202
+ const d = tmpChangeDir();
203
+ try {
204
+ const probe = createFsDesignEvidenceProbe();
205
+ expect(probe.hasDesignDirWithMd(d)).toBe(false);
206
+ } finally {
207
+ rmSync(d, { recursive: true, force: true });
208
+ }
209
+ });
210
+ });
211
+
212
+ describe("createFsDesignEvidenceProbe — R3", () => {
213
+ it("matches tasks.md with `- [ ]` checkbox", () => {
214
+ const d = tmpChangeDir();
215
+ try {
216
+ writeFileSync(path.join(d, "tasks.md"), "## 1. Setup\n\n- [ ] 1.1 Do thing\n");
217
+ const probe = createFsDesignEvidenceProbe();
218
+ expect(probe.tasksHasCheckboxes(d)).toBe(true);
219
+ } finally {
220
+ rmSync(d, { recursive: true, force: true });
221
+ }
222
+ });
223
+
224
+ it("matches tasks.md with `- [x]` checkbox (already complete)", () => {
225
+ const d = tmpChangeDir();
226
+ try {
227
+ writeFileSync(path.join(d, "tasks.md"), "- [x] done\n");
228
+ const probe = createFsDesignEvidenceProbe();
229
+ expect(probe.tasksHasCheckboxes(d)).toBe(true);
230
+ } finally {
231
+ rmSync(d, { recursive: true, force: true });
232
+ }
233
+ });
234
+
235
+ it("matches tasks.md with `- [X]` (uppercase X)", () => {
236
+ const d = tmpChangeDir();
237
+ try {
238
+ writeFileSync(path.join(d, "tasks.md"), "- [X] done\n");
239
+ const probe = createFsDesignEvidenceProbe();
240
+ expect(probe.tasksHasCheckboxes(d)).toBe(true);
241
+ } finally {
242
+ rmSync(d, { recursive: true, force: true });
243
+ }
244
+ });
245
+
246
+ it("does NOT match empty tasks.md", () => {
247
+ const d = tmpChangeDir();
248
+ try {
249
+ writeFileSync(path.join(d, "tasks.md"), "");
250
+ const probe = createFsDesignEvidenceProbe();
251
+ expect(probe.tasksHasCheckboxes(d)).toBe(false);
252
+ } finally {
253
+ rmSync(d, { recursive: true, force: true });
254
+ }
255
+ });
256
+
257
+ it("does NOT match tasks.md with only headings (no checkboxes)", () => {
258
+ const d = tmpChangeDir();
259
+ try {
260
+ writeFileSync(path.join(d, "tasks.md"), "## 1. Setup\n\nSome prose.\n");
261
+ const probe = createFsDesignEvidenceProbe();
262
+ expect(probe.tasksHasCheckboxes(d)).toBe(false);
263
+ } finally {
264
+ rmSync(d, { recursive: true, force: true });
265
+ }
266
+ });
267
+
268
+ it("does NOT match a `[ ]` not preceded by `- `", () => {
269
+ const d = tmpChangeDir();
270
+ try {
271
+ writeFileSync(path.join(d, "tasks.md"), "this [ ] is inline text\n");
272
+ const probe = createFsDesignEvidenceProbe();
273
+ expect(probe.tasksHasCheckboxes(d)).toBe(false);
274
+ } finally {
275
+ rmSync(d, { recursive: true, force: true });
276
+ }
277
+ });
278
+
279
+ it("returns false when tasks.md does not exist", () => {
280
+ const d = tmpChangeDir();
281
+ try {
282
+ const probe = createFsDesignEvidenceProbe();
283
+ expect(probe.tasksHasCheckboxes(d)).toBe(false);
284
+ } finally {
285
+ rmSync(d, { recursive: true, force: true });
286
+ }
287
+ });
288
+ });
@@ -0,0 +1,174 @@
1
+ /**
2
+ * Parity test: the bash wrapper at
3
+ * `.pi/skills/openspec-shared/scripts/effective-status.sh` must apply the
4
+ * SAME R1/R2/R3 promotion as the TS `evaluateLocalDesignSatisfaction`,
5
+ * so skill-driven prompts and dashboard buttons cannot disagree.
6
+ *
7
+ * Strategy: the wrapper calls the real `openspec` CLI. We can't invoke it
8
+ * here without the binary on PATH and an in-tree change. Instead we
9
+ * verify the wrapper's RULE EVALUATION against the same fixtures we
10
+ * use for `evaluateLocalDesignSatisfaction`, by piping a synthetic
11
+ * `openspec status --json`-shaped JSON into the override section of the
12
+ * script. Since the script's CLI invocation is at the very top, we
13
+ * exercise it with a stubbed `openspec` shim on PATH.
14
+ *
15
+ * See change: fix-openspec-design-detection.
16
+ */
17
+ import { describe, expect, it } from "vitest";
18
+ import { mkdtempSync, mkdirSync, writeFileSync, chmodSync, rmSync } from "node:fs";
19
+ import { tmpdir } from "node:os";
20
+ import path from "node:path";
21
+ import { execFileSync } from "node:child_process";
22
+
23
+ const SCRIPT_PATH = path.resolve(
24
+ __dirname,
25
+ "../../../../.pi/skills/openspec-shared/scripts/effective-status.sh",
26
+ );
27
+
28
+ function setupHarness(): { root: string; changeName: string; changeDir: string; binDir: string } {
29
+ const root = mkdtempSync(path.join(tmpdir(), "effective-status-test-"));
30
+ const changeName = "demo-change";
31
+ const changesDir = path.join(root, "openspec", "changes", changeName);
32
+ mkdirSync(changesDir, { recursive: true });
33
+ const binDir = path.join(root, "bin");
34
+ mkdirSync(binDir, { recursive: true });
35
+ return { root, changeName, changeDir: changesDir, binDir };
36
+ }
37
+
38
+ function writeOpenspecStub(binDir: string, jsonOutput: string): void {
39
+ const stub = path.join(binDir, "openspec");
40
+ writeFileSync(
41
+ stub,
42
+ `#!/usr/bin/env bash\ncat <<'JSON_EOF'\n${jsonOutput}\nJSON_EOF\n`,
43
+ { mode: 0o755 },
44
+ );
45
+ chmodSync(stub, 0o755);
46
+ }
47
+
48
+ function runWrapper(root: string, binDir: string, changeName: string): unknown {
49
+ const out = execFileSync("bash", [SCRIPT_PATH, changeName], {
50
+ cwd: root,
51
+ env: { ...process.env, PATH: `${binDir}:${process.env.PATH ?? ""}` },
52
+ encoding: "utf8",
53
+ });
54
+ return JSON.parse(out);
55
+ }
56
+
57
+ const BASE_STATUS = JSON.stringify({
58
+ changeName: "demo-change",
59
+ schemaName: "spec-driven",
60
+ isComplete: false,
61
+ applyRequires: ["tasks"],
62
+ artifacts: [
63
+ { id: "proposal", outputPath: "proposal.md", status: "done" },
64
+ { id: "specs", outputPath: "specs/**/*.md", status: "done" },
65
+ { id: "design", outputPath: "design.md", status: "ready" },
66
+ { id: "tasks", outputPath: "tasks.md", status: "done" },
67
+ ],
68
+ });
69
+
70
+ describe("effective-status.sh — parity with evaluateLocalDesignSatisfaction", () => {
71
+ it("R1: design.md present → promotes design to done", () => {
72
+ const h = setupHarness();
73
+ try {
74
+ writeFileSync(path.join(h.changeDir, "design.md"), "");
75
+ writeOpenspecStub(h.binDir, BASE_STATUS);
76
+ const out = runWrapper(h.root, h.binDir, h.changeName) as any;
77
+ const design = out.artifacts.find((a: any) => a.id === "design");
78
+ expect(design.status).toBe("done");
79
+ expect(out.isComplete).toBe(true); // all artifacts done after promotion
80
+ } finally {
81
+ rmSync(h.root, { recursive: true, force: true });
82
+ }
83
+ });
84
+
85
+ it("R1: split design (design-A.md + design-B.md, no design.md) → promoted", () => {
86
+ const h = setupHarness();
87
+ try {
88
+ writeFileSync(path.join(h.changeDir, "design-rendering.md"), "");
89
+ writeFileSync(path.join(h.changeDir, "design-state.md"), "");
90
+ writeOpenspecStub(h.binDir, BASE_STATUS);
91
+ const out = runWrapper(h.root, h.binDir, h.changeName) as any;
92
+ expect(out.artifacts.find((a: any) => a.id === "design").status).toBe("done");
93
+ } finally {
94
+ rmSync(h.root, { recursive: true, force: true });
95
+ }
96
+ });
97
+
98
+ it("R2: design/ folder with .md → promoted", () => {
99
+ const h = setupHarness();
100
+ try {
101
+ mkdirSync(path.join(h.changeDir, "design"));
102
+ writeFileSync(path.join(h.changeDir, "design", "architecture.md"), "");
103
+ writeOpenspecStub(h.binDir, BASE_STATUS);
104
+ const out = runWrapper(h.root, h.binDir, h.changeName) as any;
105
+ expect(out.artifacts.find((a: any) => a.id === "design").status).toBe("done");
106
+ } finally {
107
+ rmSync(h.root, { recursive: true, force: true });
108
+ }
109
+ });
110
+
111
+ it("R3: tasks.md with `- [ ]` → promoted", () => {
112
+ const h = setupHarness();
113
+ try {
114
+ writeFileSync(path.join(h.changeDir, "tasks.md"), "## 1. x\n\n- [ ] 1.1 do\n");
115
+ writeOpenspecStub(h.binDir, BASE_STATUS);
116
+ const out = runWrapper(h.root, h.binDir, h.changeName) as any;
117
+ expect(out.artifacts.find((a: any) => a.id === "design").status).toBe("done");
118
+ } finally {
119
+ rmSync(h.root, { recursive: true, force: true });
120
+ }
121
+ });
122
+
123
+ it("no evidence → design stays ready", () => {
124
+ const h = setupHarness();
125
+ try {
126
+ writeFileSync(path.join(h.changeDir, "proposal.md"), "");
127
+ writeOpenspecStub(h.binDir, BASE_STATUS);
128
+ const out = runWrapper(h.root, h.binDir, h.changeName) as any;
129
+ expect(out.artifacts.find((a: any) => a.id === "design").status).toBe("ready");
130
+ expect(out.isComplete).toBe(false);
131
+ } finally {
132
+ rmSync(h.root, { recursive: true, force: true });
133
+ }
134
+ });
135
+
136
+ it("never demotes done → ready", () => {
137
+ const h = setupHarness();
138
+ try {
139
+ // No evidence in fs, but CLI says design done.
140
+ const cliDone = JSON.stringify({
141
+ ...JSON.parse(BASE_STATUS),
142
+ artifacts: [
143
+ { id: "proposal", outputPath: "proposal.md", status: "done" },
144
+ { id: "design", outputPath: "design.md", status: "done" },
145
+ ],
146
+ isComplete: true,
147
+ });
148
+ writeOpenspecStub(h.binDir, cliDone);
149
+ const out = runWrapper(h.root, h.binDir, h.changeName) as any;
150
+ expect(out.artifacts.find((a: any) => a.id === "design").status).toBe("done");
151
+ expect(out.isComplete).toBe(true);
152
+ } finally {
153
+ rmSync(h.root, { recursive: true, force: true });
154
+ }
155
+ });
156
+
157
+ it("never promotes blocked → done even with evidence", () => {
158
+ const h = setupHarness();
159
+ try {
160
+ writeFileSync(path.join(h.changeDir, "design.md"), "");
161
+ const blocked = JSON.stringify({
162
+ ...JSON.parse(BASE_STATUS),
163
+ artifacts: [
164
+ { id: "design", outputPath: "design.md", status: "blocked" },
165
+ ],
166
+ });
167
+ writeOpenspecStub(h.binDir, blocked);
168
+ const out = runWrapper(h.root, h.binDir, h.changeName) as any;
169
+ expect(out.artifacts.find((a: any) => a.id === "design").status).toBe("blocked");
170
+ } finally {
171
+ rmSync(h.root, { recursive: true, force: true });
172
+ }
173
+ });
174
+ });
@@ -0,0 +1,225 @@
1
+ /**
2
+ * Verifies `buildOpenSpecData`'s design-artifact override behavior.
3
+ * Invariants: promote-only, design-only, never demote, isComplete only
4
+ * promoted to true (never demoted from CLI true).
5
+ *
6
+ * See change: fix-openspec-design-detection.
7
+ */
8
+ import { describe, expect, it } from "vitest";
9
+ import { buildOpenSpecData } from "../openspec-poller.js";
10
+ import type { DesignEvidenceProbe } from "../openspec-design-evidence.js";
11
+
12
+ function probe(satisfied: boolean, calls?: { count: number }): DesignEvidenceProbe {
13
+ return {
14
+ hasDesignFile: () => {
15
+ if (calls) calls.count++;
16
+ return satisfied;
17
+ },
18
+ hasDesignDirWithMd: () => false,
19
+ tasksHasCheckboxes: () => false,
20
+ };
21
+ }
22
+
23
+ const listResult = {
24
+ changes: [
25
+ { name: "x", status: "in-progress", completedTasks: 1, totalTasks: 3 },
26
+ ],
27
+ };
28
+
29
+ describe("buildOpenSpecData design override", () => {
30
+ it("promotes design ready→done when probe satisfies", () => {
31
+ const statusResults = new Map<string, any>([
32
+ [
33
+ "x",
34
+ {
35
+ artifacts: [
36
+ { id: "proposal", status: "done" },
37
+ { id: "specs", status: "done" },
38
+ { id: "design", status: "ready" },
39
+ { id: "tasks", status: "ready" },
40
+ ],
41
+ isComplete: false,
42
+ },
43
+ ],
44
+ ]);
45
+ const data = buildOpenSpecData(listResult, statusResults, () => probe(true));
46
+ const x = data.changes[0];
47
+ expect(x.artifacts.find((a) => a.id === "design")!.status).toBe("done");
48
+ });
49
+
50
+ it("does NOT promote when probe says not satisfied", () => {
51
+ const statusResults = new Map<string, any>([
52
+ [
53
+ "x",
54
+ {
55
+ artifacts: [
56
+ { id: "design", status: "ready" },
57
+ ],
58
+ },
59
+ ],
60
+ ]);
61
+ const data = buildOpenSpecData(listResult, statusResults, () => probe(false));
62
+ expect(data.changes[0].artifacts.find((a) => a.id === "design")!.status).toBe("ready");
63
+ });
64
+
65
+ it("never promotes blocked → done", () => {
66
+ const statusResults = new Map<string, any>([
67
+ [
68
+ "x",
69
+ {
70
+ artifacts: [{ id: "design", status: "blocked" }],
71
+ },
72
+ ],
73
+ ]);
74
+ const data = buildOpenSpecData(listResult, statusResults, () => probe(true));
75
+ expect(data.changes[0].artifacts.find((a) => a.id === "design")!.status).toBe("blocked");
76
+ });
77
+
78
+ it("never demotes done → ready (CLI says done; we trust it)", () => {
79
+ const statusResults = new Map<string, any>([
80
+ [
81
+ "x",
82
+ {
83
+ artifacts: [{ id: "design", status: "done" }],
84
+ },
85
+ ],
86
+ ]);
87
+ const data = buildOpenSpecData(listResult, statusResults, () => probe(false));
88
+ expect(data.changes[0].artifacts.find((a) => a.id === "design")!.status).toBe("done");
89
+ });
90
+
91
+ it("only mutates design — other artifact statuses pass through", () => {
92
+ const statusResults = new Map<string, any>([
93
+ [
94
+ "x",
95
+ {
96
+ artifacts: [
97
+ { id: "proposal", status: "ready" },
98
+ { id: "specs", status: "blocked" },
99
+ { id: "design", status: "ready" },
100
+ { id: "tasks", status: "ready" },
101
+ ],
102
+ },
103
+ ],
104
+ ]);
105
+ const data = buildOpenSpecData(listResult, statusResults, () => probe(true));
106
+ const arts = data.changes[0].artifacts;
107
+ expect(arts.find((a) => a.id === "proposal")!.status).toBe("ready");
108
+ expect(arts.find((a) => a.id === "specs")!.status).toBe("blocked");
109
+ expect(arts.find((a) => a.id === "design")!.status).toBe("done");
110
+ expect(arts.find((a) => a.id === "tasks")!.status).toBe("ready");
111
+ });
112
+
113
+ it("re-derives isComplete=true when all artifacts done after override", () => {
114
+ const statusResults = new Map<string, any>([
115
+ [
116
+ "x",
117
+ {
118
+ artifacts: [
119
+ { id: "proposal", status: "done" },
120
+ { id: "specs", status: "done" },
121
+ { id: "design", status: "ready" },
122
+ { id: "tasks", status: "done" },
123
+ ],
124
+ isComplete: false,
125
+ },
126
+ ],
127
+ ]);
128
+ const data = buildOpenSpecData(listResult, statusResults, () => probe(true));
129
+ expect(data.changes[0].isComplete).toBe(true);
130
+ });
131
+
132
+ it("does NOT promote isComplete when any non-design artifact is not done", () => {
133
+ const statusResults = new Map<string, any>([
134
+ [
135
+ "x",
136
+ {
137
+ artifacts: [
138
+ { id: "proposal", status: "done" },
139
+ { id: "specs", status: "ready" },
140
+ { id: "design", status: "ready" },
141
+ { id: "tasks", status: "blocked" },
142
+ ],
143
+ isComplete: false,
144
+ },
145
+ ],
146
+ ]);
147
+ const data = buildOpenSpecData(listResult, statusResults, () => probe(true));
148
+ // design becomes done, but specs/tasks are still not — isComplete passes through CLI value (false)
149
+ expect(data.changes[0].isComplete).toBe(false);
150
+ });
151
+
152
+ it("never demotes CLI isComplete=true to false", () => {
153
+ const statusResults = new Map<string, any>([
154
+ [
155
+ "x",
156
+ {
157
+ artifacts: [{ id: "proposal", status: "done" }],
158
+ isComplete: true,
159
+ },
160
+ ],
161
+ ]);
162
+ // Probe says NOT satisfied, single artifact is done so override re-derive would also be true.
163
+ // Use a more adversarial setup: probe NOT satisfied, single artifact is "ready", CLI says complete.
164
+ const adversarial = new Map<string, any>([
165
+ [
166
+ "x",
167
+ {
168
+ artifacts: [{ id: "proposal", status: "ready" }],
169
+ isComplete: true,
170
+ },
171
+ ],
172
+ ]);
173
+ const data = buildOpenSpecData(listResult, adversarial, () => probe(false));
174
+ expect(data.changes[0].isComplete).toBe(true);
175
+ });
176
+
177
+ it("no-probe call site preserves today's behavior verbatim", () => {
178
+ const statusResults = new Map<string, any>([
179
+ [
180
+ "x",
181
+ {
182
+ artifacts: [
183
+ { id: "design", status: "ready" },
184
+ { id: "tasks", status: "blocked" },
185
+ ],
186
+ isComplete: false,
187
+ },
188
+ ],
189
+ ]);
190
+ const data = buildOpenSpecData(listResult, statusResults);
191
+ expect(data.changes[0].artifacts.find((a) => a.id === "design")!.status).toBe("ready");
192
+ expect(data.changes[0].isComplete).toBe(false);
193
+ });
194
+
195
+ it("probe factory receives the change name", () => {
196
+ const seen: string[] = [];
197
+ const statusResults = new Map<string, any>([
198
+ [
199
+ "x",
200
+ {
201
+ artifacts: [{ id: "design", status: "ready" }],
202
+ },
203
+ ],
204
+ ]);
205
+ buildOpenSpecData(listResult, statusResults, (changeName) => {
206
+ seen.push(changeName);
207
+ return probe(false);
208
+ });
209
+ expect(seen).toContain("x");
210
+ });
211
+
212
+ it("probe is NOT consulted when CLI says design is already done", () => {
213
+ const calls = { count: 0 };
214
+ const statusResults = new Map<string, any>([
215
+ [
216
+ "x",
217
+ {
218
+ artifacts: [{ id: "design", status: "done" }],
219
+ },
220
+ ],
221
+ ]);
222
+ buildOpenSpecData(listResult, statusResults, () => probe(true, calls));
223
+ expect(calls.count).toBe(0);
224
+ });
225
+ });