@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
@@ -11,13 +11,21 @@ import type {
11
11
  BrowserPromptRequestMessage,
12
12
  BrowserPromptDismissMessage,
13
13
  BrowserPromptCancelMessage,
14
+ BrowserExtUiDecoratorMessage,
14
15
  } from "../browser-protocol.js";
16
+ import type { ExtensionToServerMessage, ExtUiDecoratorMessage } from "../protocol.js";
17
+ import type { DecoratorDescriptor } from "../types.js";
15
18
 
16
19
  // Type-level assertion: if these types are NOT in the union, this will fail to compile.
17
20
  type AssertExtends<T, U> = T extends U ? true : never;
18
21
  type _PromptRequestInUnion = AssertExtends<BrowserPromptRequestMessage, ServerToBrowserMessage>;
19
22
  type _PromptDismissInUnion = AssertExtends<BrowserPromptDismissMessage, ServerToBrowserMessage>;
20
23
  type _PromptCancelInUnion = AssertExtends<BrowserPromptCancelMessage, ServerToBrowserMessage>;
24
+ // Phase-2 (add-extension-ui-decorations): ext_ui_decorator must be a member of
25
+ // BOTH the extension→server union and the server→browser union, otherwise
26
+ // esbuild strips the switch arms in production builds.
27
+ type _ExtUiDecoratorInExtensionUnion = AssertExtends<ExtUiDecoratorMessage, ExtensionToServerMessage>;
28
+ type _ExtUiDecoratorInBrowserUnion = AssertExtends<BrowserExtUiDecoratorMessage, ServerToBrowserMessage>;
21
29
 
22
30
  // Runtime verification that the type discriminants are reachable in a switch
23
31
  function extractPromptType(msg: ServerToBrowserMessage): string | null {
@@ -60,3 +68,54 @@ describe("ServerToBrowserMessage includes PromptBus messages", () => {
60
68
  expect(extractPromptType(msg)).toBe("p1");
61
69
  });
62
70
  });
71
+
72
+ // Phase-2: ext_ui_decorator switch-arm reachability.
73
+ function extractDecoratorKey(msg: ServerToBrowserMessage): string | null {
74
+ switch (msg.type) {
75
+ case "ext_ui_decorator":
76
+ return `${msg.descriptor.kind}:${msg.descriptor.namespace}:${msg.descriptor.id}`;
77
+ default:
78
+ return null;
79
+ }
80
+ }
81
+
82
+ describe("ext_ui_decorator is a member of both protocol unions", () => {
83
+ const sample: DecoratorDescriptor = {
84
+ kind: "footer-segment",
85
+ namespace: "judo",
86
+ id: "model-state",
87
+ payload: { text: "3 mut" },
88
+ };
89
+
90
+ it("server→browser ext_ui_decorator is a valid discriminant", () => {
91
+ const msg: BrowserExtUiDecoratorMessage = {
92
+ type: "ext_ui_decorator",
93
+ sessionId: "s1",
94
+ descriptor: sample,
95
+ };
96
+ expect(extractDecoratorKey(msg)).toBe("footer-segment:judo:model-state");
97
+ });
98
+
99
+ it("removed flag round-trips through the union", () => {
100
+ const msg: BrowserExtUiDecoratorMessage = {
101
+ type: "ext_ui_decorator",
102
+ sessionId: "s1",
103
+ descriptor: sample,
104
+ removed: true,
105
+ };
106
+ expect(extractDecoratorKey(msg)).toBe("footer-segment:judo:model-state");
107
+ // Round-trip via JSON to confirm `removed` survives serialization.
108
+ const parsed = JSON.parse(JSON.stringify(msg)) as BrowserExtUiDecoratorMessage;
109
+ expect(parsed.removed).toBe(true);
110
+ });
111
+
112
+ it("extension→server ext_ui_decorator carries the same shape", () => {
113
+ const msg: ExtUiDecoratorMessage = {
114
+ type: "ext_ui_decorator",
115
+ sessionId: "s1",
116
+ descriptor: sample,
117
+ };
118
+ expect(msg.type).toBe("ext_ui_decorator");
119
+ expect(msg.descriptor.kind).toBe("footer-segment");
120
+ });
121
+ });
@@ -0,0 +1,68 @@
1
+ /**
2
+ * Tests for plugins config namespace in DashboardConfig.
3
+ * Verifies round-trip preservation of all keys.
4
+ */
5
+ import { describe, it, expect, beforeEach, afterEach } from "vitest";
6
+ import fs from "node:fs";
7
+ import os from "node:os";
8
+ import path from "node:path";
9
+ import { loadConfig, getPluginsConfig, getPluginConfig } from "../config.js";
10
+
11
+ let tmpDir: string;
12
+ let origHome: string | undefined;
13
+
14
+ beforeEach(() => {
15
+ tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), "config-plugins-test-"));
16
+ origHome = process.env.HOME;
17
+ process.env.HOME = tmpDir;
18
+ });
19
+
20
+ afterEach(() => {
21
+ process.env.HOME = origHome;
22
+ fs.rmSync(tmpDir, { recursive: true, force: true });
23
+ });
24
+
25
+ function writeConfig(data: Record<string, unknown>) {
26
+ const dir = path.join(tmpDir, ".pi", "dashboard");
27
+ fs.mkdirSync(dir, { recursive: true });
28
+ fs.writeFileSync(path.join(dir, "config.json"), JSON.stringify(data));
29
+ }
30
+
31
+ describe("plugins config round-trip", () => {
32
+ it("preserves all top-level keys including plugins namespace", () => {
33
+ writeConfig({
34
+ port: 9000,
35
+ auth: undefined,
36
+ openspec: { pollIntervalSeconds: 30 },
37
+ plugins: { demo: { foo: 1 } },
38
+ });
39
+
40
+ const config = loadConfig();
41
+ expect(config.port).toBe(9000);
42
+ // loadConfig normalizes openspec to full object with defaults
43
+ expect(config.openspec.pollIntervalSeconds).toBe(30);
44
+ expect(getPluginConfig(config, "demo")).toEqual({ foo: 1 });
45
+ });
46
+
47
+ it("returns empty object for unknown plugin id", () => {
48
+ writeConfig({ plugins: { demo: { foo: 1 } } });
49
+ const config = loadConfig();
50
+ expect(getPluginConfig(config, "nonexistent")).toEqual({});
51
+ });
52
+
53
+ it("returns empty plugins config when plugins key is absent", () => {
54
+ writeConfig({ port: 8000 });
55
+ const config = loadConfig();
56
+ expect(getPluginsConfig(config)).toEqual({});
57
+ });
58
+
59
+ it("getPluginsConfig returns all plugin namespaces", () => {
60
+ writeConfig({
61
+ plugins: { demo: { foo: 1 }, openspec: { pollIntervalSeconds: 60 } },
62
+ });
63
+ const config = loadConfig();
64
+ const plugins = getPluginsConfig(config);
65
+ expect(plugins.demo).toEqual({ foo: 1 });
66
+ expect(plugins.openspec).toEqual({ pollIntervalSeconds: 60 });
67
+ });
68
+ });
@@ -0,0 +1,265 @@
1
+ /**
2
+ * Type-shape and union-membership tests for the Extension UI System Phase-1
3
+ * schema and protocol additions. See change: add-extension-ui-modal.
4
+ *
5
+ * The point of these tests is twofold:
6
+ *
7
+ * 1. **Compile-time shape validation.** Constructing concrete
8
+ * `ExtensionUiModule` instances with the canonical view kinds (`table`,
9
+ * `grid`, `form`) and `UiAction` confirm-polish flag forces the type
10
+ * system to enforce the field shapes from the design.
11
+ *
12
+ * 2. **Union membership.** The new wire-protocol messages
13
+ * (`ui_modules_list`, `ui_data_list`, `ui_management`) are dropped by
14
+ * esbuild in production if they are not members of the
15
+ * `ServerToBrowserMessage` / `BrowserToServerMessage` /
16
+ * `ExtensionToServerMessage` / `ServerToExtensionMessage` unions. The
17
+ * `AssertExtends` guards below fail at compile time if any union loses
18
+ * its new member.
19
+ */
20
+ import { describe, it, expect } from "vitest";
21
+ import type {
22
+ ExtensionUiModule,
23
+ UiAction,
24
+ UiField,
25
+ UiSection,
26
+ UiView,
27
+ } from "../types.js";
28
+ import type {
29
+ ExtensionToServerMessage,
30
+ ServerToExtensionMessage,
31
+ UiModulesListMessage,
32
+ UiDataListMessage,
33
+ UiManagementMessage,
34
+ } from "../protocol.js";
35
+ import type {
36
+ ServerToBrowserMessage,
37
+ BrowserToServerMessage,
38
+ BrowserUiModulesListMessage,
39
+ BrowserUiDataListMessage,
40
+ UiManagementBrowserMessage,
41
+ } from "../browser-protocol.js";
42
+
43
+ // ── Compile-time union-membership assertions ───────────────────────
44
+ type AssertExtends<T, U> = T extends U ? true : never;
45
+
46
+ // Bridge ↔ Server leg
47
+ type _UiModulesListInExt = AssertExtends<UiModulesListMessage, ExtensionToServerMessage>;
48
+ type _UiDataListInExt = AssertExtends<UiDataListMessage, ExtensionToServerMessage>;
49
+ type _UiManagementInServer = AssertExtends<UiManagementMessage, ServerToExtensionMessage>;
50
+
51
+ // Server ↔ Browser leg
52
+ type _BrowserUiModulesInUnion = AssertExtends<BrowserUiModulesListMessage, ServerToBrowserMessage>;
53
+ type _BrowserUiDataInUnion = AssertExtends<BrowserUiDataListMessage, ServerToBrowserMessage>;
54
+ type _UiManagementInBrowser = AssertExtends<UiManagementBrowserMessage, BrowserToServerMessage>;
55
+
56
+ // Sentinel — referenced so tsc keeps the assertions live.
57
+ const _typeSentinel: Array<true> = [
58
+ true as _UiModulesListInExt,
59
+ true as _UiDataListInExt,
60
+ true as _UiManagementInServer,
61
+ true as _BrowserUiModulesInUnion,
62
+ true as _BrowserUiDataInUnion,
63
+ true as _UiManagementInBrowser,
64
+ ];
65
+
66
+ describe("ExtensionUiModule shape", () => {
67
+ it("accepts a table view with dataEvent and rowActions", () => {
68
+ const tableModule: ExtensionUiModule = {
69
+ kind: "management-modal",
70
+ id: "judo-status",
71
+ command: "/judo:status",
72
+ title: "Judo Status",
73
+ description: "Show pending status rows",
74
+ icon: "mdiTableLarge",
75
+ category: "judo",
76
+ view: {
77
+ kind: "table",
78
+ dataEvent: "judo:status-rows",
79
+ rowKey: "id",
80
+ fields: [
81
+ { key: "id", label: "ID", kind: "text", width: 80 },
82
+ { key: "name", label: "Name", kind: "text" },
83
+ { key: "score", label: "Score", kind: "number" },
84
+ ],
85
+ rowActions: [
86
+ {
87
+ id: "delete",
88
+ label: "Delete",
89
+ icon: "mdiDelete",
90
+ variant: "danger",
91
+ event: "judo:delete-row",
92
+ confirm: "Delete this entry?",
93
+ },
94
+ ],
95
+ emptyState: "No rows yet.",
96
+ actions: [
97
+ {
98
+ id: "refresh",
99
+ label: "Refresh",
100
+ icon: "mdiRefresh",
101
+ variant: "secondary",
102
+ event: "judo:refresh",
103
+ },
104
+ ],
105
+ },
106
+ };
107
+
108
+ expect(tableModule.kind).toBe("management-modal");
109
+ expect(tableModule.view.kind).toBe("table");
110
+ expect(tableModule.view.dataEvent).toBe("judo:status-rows");
111
+ expect(tableModule.view.rowActions?.[0]?.confirm).toBe("Delete this entry?");
112
+ });
113
+
114
+ it("accepts a form view with sections grouping fields", () => {
115
+ const sections: UiSection[] = [
116
+ {
117
+ id: "general",
118
+ title: "General",
119
+ fields: [
120
+ { key: "name", label: "Name", kind: "text", required: true },
121
+ { key: "enabled", label: "Enabled", kind: "boolean" },
122
+ ],
123
+ },
124
+ {
125
+ id: "advanced",
126
+ title: "Advanced",
127
+ description: "Optional knobs.",
128
+ fields: [
129
+ { key: "code", label: "Hook", kind: "code", language: "javascript" },
130
+ { key: "notes", label: "Notes", kind: "textarea" },
131
+ ],
132
+ },
133
+ ];
134
+
135
+ const formModule: ExtensionUiModule = {
136
+ kind: "management-modal",
137
+ id: "judo-config",
138
+ command: "/judo:config",
139
+ title: "Judo Config",
140
+ view: {
141
+ kind: "form",
142
+ sections,
143
+ actions: [
144
+ {
145
+ id: "save",
146
+ label: "Save",
147
+ icon: "mdiContentSave",
148
+ variant: "primary",
149
+ event: "judo:save-config",
150
+ },
151
+ ],
152
+ },
153
+ };
154
+
155
+ expect(formModule.view.kind).toBe("form");
156
+ expect(formModule.view.sections).toHaveLength(2);
157
+ expect(formModule.view.sections?.[1]?.fields[0]?.kind).toBe("code");
158
+ expect(formModule.view.sections?.[1]?.fields[0]?.language).toBe("javascript");
159
+ });
160
+
161
+ it("accepts a grid view (same lifecycle as table) and select-kind fields", () => {
162
+ const fields: UiField[] = [
163
+ { key: "id", label: "ID", kind: "text" },
164
+ { key: "tier", label: "Tier", kind: "select", options: ["bronze", "silver", "gold"] },
165
+ { key: "joinedAt", label: "Joined", kind: "datetime" },
166
+ ];
167
+
168
+ const gridModule: ExtensionUiModule = {
169
+ kind: "management-modal",
170
+ id: "judo-members",
171
+ command: "/judo:members",
172
+ title: "Members",
173
+ view: {
174
+ kind: "grid",
175
+ dataEvent: "judo:members-list",
176
+ fields,
177
+ },
178
+ };
179
+
180
+ expect(gridModule.view.kind).toBe("grid");
181
+ expect(gridModule.view.fields?.[1]?.options).toEqual(["bronze", "silver", "gold"]);
182
+ });
183
+
184
+ it("UiAction supports confirm polish for destructive actions", () => {
185
+ const dangerAction: UiAction = {
186
+ id: "wipe",
187
+ label: "Wipe All",
188
+ variant: "danger",
189
+ event: "judo:wipe",
190
+ confirm: "Wipe all members? This cannot be undone.",
191
+ };
192
+
193
+ expect(dangerAction.confirm).toContain("cannot be undone");
194
+ expect(dangerAction.variant).toBe("danger");
195
+ });
196
+
197
+ it("UiView with table kind requires neither sections nor actions", () => {
198
+ const minimal: UiView = {
199
+ kind: "table",
200
+ dataEvent: "x:list",
201
+ fields: [{ key: "id", label: "ID", kind: "text" }],
202
+ };
203
+ expect(minimal.kind).toBe("table");
204
+ expect(minimal.actions).toBeUndefined();
205
+ expect(minimal.sections).toBeUndefined();
206
+ });
207
+ });
208
+
209
+ describe("Phase-1 wire protocol", () => {
210
+ it("constructs each message with discriminated literal types", () => {
211
+ const modulesList: UiModulesListMessage = {
212
+ type: "ui_modules_list",
213
+ sessionId: "s1",
214
+ modules: [],
215
+ };
216
+ const dataList: UiDataListMessage = {
217
+ type: "ui_data_list",
218
+ sessionId: "s1",
219
+ event: "judo:status-rows",
220
+ items: [{ id: 1 }, { id: 2 }],
221
+ };
222
+ const browserModules: BrowserUiModulesListMessage = {
223
+ type: "ui_modules_list",
224
+ sessionId: "s1",
225
+ modules: [],
226
+ };
227
+ const browserData: BrowserUiDataListMessage = {
228
+ type: "ui_data_list",
229
+ sessionId: "s1",
230
+ event: "judo:status-rows",
231
+ items: [],
232
+ };
233
+ const mgmt: UiManagementBrowserMessage = {
234
+ type: "ui_management",
235
+ sessionId: "s1",
236
+ action: "list",
237
+ event: "judo:status-rows",
238
+ params: { since: 0 },
239
+ };
240
+
241
+ expect(modulesList.type).toBe("ui_modules_list");
242
+ expect(dataList.type).toBe("ui_data_list");
243
+ expect(browserModules.type).toBe("ui_modules_list");
244
+ expect(browserData.type).toBe("ui_data_list");
245
+ expect(mgmt.type).toBe("ui_management");
246
+ });
247
+
248
+ it("type discriminants are reachable in a switch (esbuild safety)", () => {
249
+ function classify(msg: ServerToBrowserMessage): string | null {
250
+ switch (msg.type) {
251
+ case "ui_modules_list":
252
+ return `modules:${msg.modules.length}`;
253
+ case "ui_data_list":
254
+ return `data:${msg.event}:${msg.items.length}`;
255
+ default:
256
+ return null;
257
+ }
258
+ }
259
+
260
+ expect(classify({ type: "ui_modules_list", sessionId: "s", modules: [] })).toBe("modules:0");
261
+ expect(
262
+ classify({ type: "ui_data_list", sessionId: "s", event: "x", items: [1, 2, 3] }),
263
+ ).toBe("data:x:3");
264
+ });
265
+ });
@@ -0,0 +1,81 @@
1
+ /**
2
+ * Repo-level invariant: OpenSpec workflow skills (.pi/skills/openspec-*)
3
+ * MUST invoke `.pi/skills/openspec-shared/scripts/effective-status.sh`
4
+ * instead of calling `openspec status --json` directly. The wrapper
5
+ * applies the dashboard's local-design-evidence override (R1/R2/R3) so
6
+ * skill-driven prompts and dashboard session-card buttons cannot
7
+ * disagree about a change's next-ready artifact.
8
+ *
9
+ * If this test fails: replace the offending `openspec status ... --json`
10
+ * line with:
11
+ *
12
+ * .pi/skills/openspec-shared/scripts/effective-status.sh "<name>"
13
+ *
14
+ * See change: fix-openspec-design-detection.
15
+ */
16
+ import { describe, it, expect } from "vitest";
17
+ import fs from "node:fs/promises";
18
+ import path from "node:path";
19
+ import url from "node:url";
20
+
21
+ /**
22
+ * Regex catches `openspec status ... --json` invocations. We deliberately
23
+ * accept whitespace flexibility but reject only the `--json` flavor (the
24
+ * human-readable `openspec status --change "<name>"` form is allowed
25
+ * because it doesn't drive logic).
26
+ */
27
+ const RAW_STATUS_RE = /\bopenspec\s+status\b[^\n]*--json\b/;
28
+
29
+ /** Per-line opt-out marker. */
30
+ const OPT_OUT_MARKER = "ban:openspec-status-ok";
31
+
32
+ /** Skills the wrapper exists to serve — these MUST go through it. */
33
+ const GOVERNED_SKILLS = [
34
+ "openspec-continue-change",
35
+ "openspec-ff-change",
36
+ "openspec-apply-change",
37
+ "openspec-verify-change",
38
+ ];
39
+
40
+ describe("OpenSpec workflow skills must use effective-status.sh", () => {
41
+ it("no raw `openspec status --json` outside the wrapper script", async () => {
42
+ const here = path.dirname(url.fileURLToPath(import.meta.url));
43
+ const repoRoot = path.resolve(here, "..", "..", "..", "..");
44
+ const skillsRoot = path.resolve(repoRoot, ".pi", "skills");
45
+
46
+ const violations: Array<{ file: string; line: number; text: string }> = [];
47
+
48
+ for (const skill of GOVERNED_SKILLS) {
49
+ const skillFile = path.join(skillsRoot, skill, "SKILL.md");
50
+ let content: string;
51
+ try {
52
+ content = await fs.readFile(skillFile, "utf-8");
53
+ } catch {
54
+ // Skill not present in this checkout — fine, skip.
55
+ continue;
56
+ }
57
+ const lines = content.split(/\r?\n/);
58
+ lines.forEach((line, idx) => {
59
+ if (!RAW_STATUS_RE.test(line)) return;
60
+ if (line.includes(OPT_OUT_MARKER)) return;
61
+ violations.push({
62
+ file: path.relative(repoRoot, skillFile),
63
+ line: idx + 1,
64
+ text: line.trim(),
65
+ });
66
+ });
67
+ }
68
+
69
+ if (violations.length > 0) {
70
+ const msg =
71
+ `Raw \`openspec status --json\` invocations found in OpenSpec skills.\n` +
72
+ `Replace each with the wrapper that applies the dashboard's design override:\n` +
73
+ ` .pi/skills/openspec-shared/scripts/effective-status.sh "<name>"\n\n` +
74
+ `Offenders (${violations.length}):\n` +
75
+ violations
76
+ .map((v) => ` ${v.file}:${v.line} ${v.text}`)
77
+ .join("\n");
78
+ expect(violations, msg).toEqual([]);
79
+ }
80
+ });
81
+ });