amicus 4.3.0 → 4.4.1

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 (94) hide show
  1. package/.claude-plugin/plugin.json +1 -1
  2. package/CHANGELOG.md +64 -0
  3. package/README.md +6 -3
  4. package/docs/DISTRIBUTION.md +234 -0
  5. package/docs/ROADMAP.md +200 -0
  6. package/docs/SHIMS.md +62 -0
  7. package/docs/architecture.md +104 -0
  8. package/docs/configuration.md +371 -0
  9. package/docs/council.md +911 -0
  10. package/docs/doc-system.md +92 -0
  11. package/docs/electron-testing.md +471 -0
  12. package/docs/jsdoc-setup.md +75 -0
  13. package/docs/opencode-integration.md +114 -0
  14. package/docs/publishing.md +60 -0
  15. package/docs/schemas.md +55 -0
  16. package/docs/testing.md +589 -0
  17. package/docs/troubleshooting.md +298 -0
  18. package/docs/usage.md +699 -0
  19. package/electron/fold.js +1 -1
  20. package/electron/ipc-workspace.js +283 -0
  21. package/electron/main.js +31 -1
  22. package/electron/preload-workspace.js +40 -0
  23. package/electron/setup-ui-aliases.js +6 -6
  24. package/electron/workspace-shell.js +85 -0
  25. package/electron/workspace-ui/index.html +111 -0
  26. package/electron/workspace-ui/live-model.js +112 -0
  27. package/electron/workspace-ui/md-lite.js +163 -0
  28. package/electron/workspace-ui/workspace-app.js +240 -0
  29. package/electron/workspace-ui/workspace-matrix.js +249 -0
  30. package/electron/workspace-ui/workspace-panels.js +237 -0
  31. package/electron/workspace-ui/workspace-render.js +277 -0
  32. package/electron/workspace-ui/workspace-verbs.js +293 -0
  33. package/electron/workspace-ui/workspace.css +172 -0
  34. package/package.json +8 -3
  35. package/schemas/council-run-live.schema.json +25 -1
  36. package/schemas/council-run.schema.json +34 -0
  37. package/schemas/progress.schema.json +26 -1
  38. package/schemas/spend.schema.json +52 -4
  39. package/skills/second-opinion/MODEL-NOTES.md +53 -5
  40. package/src/cli-handlers-council-run.js +25 -3
  41. package/src/cli-handlers-spend.js +50 -5
  42. package/src/cli-handlers-watch.js +48 -10
  43. package/src/cli.js +4 -2
  44. package/src/council/briefings-debate.js +27 -7
  45. package/src/council/briefings-stage2.js +155 -25
  46. package/src/council/briefings.js +59 -3
  47. package/src/council/findings.js +236 -9
  48. package/src/council/parse-stage2.js +10 -2
  49. package/src/council/report.js +19 -8
  50. package/src/council/run-assemble.js +42 -1
  51. package/src/council/run-budget.js +277 -0
  52. package/src/council/run-chair.js +4 -1
  53. package/src/council/run-debate.js +4 -2
  54. package/src/council/run-finalize.js +102 -0
  55. package/src/council/run-launch.js +73 -7
  56. package/src/council/run-server.js +248 -0
  57. package/src/council/run-stage2.js +118 -0
  58. package/src/council/run-stages.js +148 -113
  59. package/src/council/run-state.js +23 -1
  60. package/src/council/run.js +52 -53
  61. package/src/council/tally.js +10 -0
  62. package/src/headless.js +519 -17
  63. package/src/mcp-council-awareness.js +53 -3
  64. package/src/observe/council-legs.js +240 -0
  65. package/src/observe/live-doc.js +39 -4
  66. package/src/observe/watch-render.js +23 -1
  67. package/src/opencode-client.js +15 -3
  68. package/src/sidecar/child-sessions.js +197 -0
  69. package/src/sidecar/conversation-mirror.js +111 -37
  70. package/src/sidecar/fanout-budget.js +71 -0
  71. package/src/sidecar/fanout-leg-fallback.js +69 -21
  72. package/src/sidecar/fanout-leg.js +29 -1
  73. package/src/sidecar/fanout-signals.js +61 -0
  74. package/src/sidecar/fanout-wave-io.js +75 -0
  75. package/src/sidecar/fanout.js +65 -81
  76. package/src/sidecar/progress-fields.js +26 -4
  77. package/src/sidecar/progress.js +8 -1
  78. package/src/sidecar/session-utils.js +23 -14
  79. package/src/sidecar/tool-part.js +196 -0
  80. package/src/sidecar/workspace-window.js +62 -0
  81. package/src/spend-query.js +33 -6
  82. package/src/utils/env-num.js +42 -0
  83. package/src/utils/lifecycle.js +37 -1
  84. package/src/utils/path-fence.js +120 -0
  85. package/src/utils/pricing.js +114 -9
  86. package/src/utils/server-setup.js +79 -1
  87. package/src/utils/spend-ledger.js +24 -3
  88. package/src/workspace/artifact-guard.js +208 -0
  89. package/src/workspace/blind-mode.js +32 -0
  90. package/src/workspace/fold-format.js +124 -0
  91. package/src/workspace/live-normalize.js +169 -0
  92. package/src/workspace/matrix-model.js +94 -0
  93. package/src/workspace/run-detail.js +229 -0
  94. package/src/workspace/run-scan.js +148 -0
package/electron/fold.js CHANGED
@@ -66,7 +66,7 @@ function createFoldHandler(state) {
66
66
  `Session: ${state.sessionId || state.taskId}`,
67
67
  `Client: ${state.client}`,
68
68
  `CWD: ${state.cwd}`,
69
- `Mode: interactive`,
69
+ 'Mode: interactive',
70
70
  '---',
71
71
  summary || 'Session ended without summary.'
72
72
  ].join('\n');
@@ -0,0 +1,283 @@
1
+ /**
2
+ * Council Workspace IPC (v4.4 §4.5) — all seven workspace: channels.
3
+ *
4
+ * ipc-setup.js registration pattern + DI for tests. Every handler validates
5
+ * the sender against the workspace window via isPrivilegedSender (M9
6
+ * belt-and-suspenders, even though this window has one webContents). All
7
+ * reads are main-process-side; the renderer never sees a filesystem path
8
+ * except for display. Read-only against runDir (spec §6.2): fold writes to
9
+ * OUR stdout; abort delegates to the engine's own council-aware path.
10
+ *
11
+ * The in-process v4.3/v4.0 seam: require('../src/mcp-server').handlers —
12
+ * requiring mcp-server does NOT start the server (cli-handlers-status.js:3).
13
+ */
14
+ 'use strict';
15
+
16
+ const path = require('path');
17
+ const { pathToFileURL } = require('url');
18
+ const { logger } = require('../src/utils/logger');
19
+ const { isPrivilegedSender } = require('./ipc-guard');
20
+ const { isRealpathContained } = require('../src/workspace/artifact-guard');
21
+
22
+ function defaultDeps() {
23
+ return {
24
+ scanCouncilRuns: (...a) => require('../src/workspace/run-scan').scanCouncilRuns(...a),
25
+ getRunDetail: (...a) => require('../src/workspace/run-detail').getRunDetail(...a),
26
+ readPointer: (...a) => require('../src/workspace/run-scan').readPointer(...a),
27
+ readRunArtifact: (...a) => require('../src/workspace/artifact-guard').readRunArtifact(...a),
28
+ buildFoldText: (...a) => require('../src/workspace/fold-format').buildFoldText(...a),
29
+ normalizeLive: (...a) => require('../src/workspace/live-normalize').normalizeLive(...a),
30
+ handlers: () => require('../src/mcp-server').handlers,
31
+ // Node passes an Error to this callback when the chunk fails to flush
32
+ // (e.g. a dead parent pipe / closed stdout) — reject on it rather than
33
+ // resolving unconditionally, or a failed write reports {ok:true} to the
34
+ // renderer and permanently latches gate.hasCompleted() (code review fix).
35
+ stdoutWrite: (text) => new Promise((resolve, reject) => {
36
+ process.stdout.write(text, (err) => (err ? reject(err) : resolve()));
37
+ }),
38
+ openExternal: (url) => require('electron').shell.openExternal(url),
39
+ existsSync: (p) => require('fs').existsSync(p),
40
+ realpathSync: (p) => require('fs').realpathSync(p),
41
+ };
42
+ }
43
+
44
+ /**
45
+ * Two-flag fold latch (fold.js folded/completed pattern WITHOUT the
46
+ * close-guard fallback-destroy machinery — a workspace fold does no async
47
+ * model work, so that failure surface does not apply; spec §7).
48
+ *
49
+ * ⚠️ COUNCIL REVIEW R2 (A3): `completed` used to be a single unkeyed boolean —
50
+ * one successful fold latched the gate for the ENTIRE life of the window, for
51
+ * every run, not just the one that was actually folded. The renderer supports
52
+ * navigating to a different run within one open window (openRun with a
53
+ * dynamic runId; a bare `--ui` launch opens the whole project run list, spec
54
+ * §4.3/§4.4), so: open run A, Fold (succeeds) -> switch to run B, Fold ->
55
+ * the old code returned {ok:true, already:true} WITHOUT ever writing
56
+ * anything for B. Silent wrong-result, not cosmetic: the UI reads "Folded ✓"
57
+ * for a run that was never written to the launching terminal.
58
+ *
59
+ * Completion is now keyed per runId (a Set of runIds that have been
60
+ * successfully folded), so each DISTINCT run gets its own honest
61
+ * begin()/hasCompleted() pair. `writing` (in flight) stays a single GLOBAL
62
+ * flag on purpose: at most one fold write can be in progress at any instant
63
+ * regardless of which run it is for (there is exactly one stdout stream to
64
+ * write to), so a second concurrent call — for the SAME run or a different
65
+ * one — still correctly gets 'fold-in-flight' rather than interleaving two
66
+ * writes.
67
+ *
68
+ * Why per-run, not "reset the whole gate when the active run changes" or
69
+ * "keep one global one-shot latch and just report it more honestly": the
70
+ * plan's spec §7 line ("the fold nonce is per-window-launch... one fold
71
+ * write per launch... a second fold of the SAME run legitimately returns
72
+ * already:true") was written before multi-run navigation existed and reads
73
+ * naturally as "don't spam-refold what you already folded" rather than "the
74
+ * whole window may only ever fold one run, period." Concretely, allowing one
75
+ * successful fold per DISTINCT run within a single launch is safe: the
76
+ * workspace's fold-write path (src/sidecar/workspace-window.js) just relays
77
+ * accumulated stdout live and returns the exit code once the window closes —
78
+ * it does no first/last-marker disambiguation the way the OLDER headless
79
+ * model-session flow's findTrailingFoldMarker does (src/headless.js) — and
80
+ * every fold block is self-identifying (`Session: <runId>`,
81
+ * src/workspace/fold-format.js), so two distinct runs folded into the same
82
+ * launch's stdout are unambiguous to whatever reads it. A global
83
+ * one-shot-per-window latch would instead be a real product regression: it
84
+ * would make "browse several runs and fold the ones you care about" — the
85
+ * whole point of the window NOT auto-closing after a fold — impossible
86
+ * without reopening the workspace for every run after the first.
87
+ */
88
+ function createFoldGate() {
89
+ let writing = false;
90
+ let writingRunId = null;
91
+ const completedRuns = new Set();
92
+ let pendingClose = false;
93
+ return {
94
+ /** @returns {boolean} true when a write may start for runId */
95
+ begin(runId) {
96
+ if (writing || completedRuns.has(runId)) { return false; }
97
+ writing = true;
98
+ writingRunId = runId;
99
+ return true;
100
+ },
101
+ /** @returns {boolean} true when a close was blocked during the write */
102
+ settle({ ok }) {
103
+ writing = false;
104
+ if (ok) { completedRuns.add(writingRunId); }
105
+ writingRunId = null;
106
+ const p = pendingClose;
107
+ pendingClose = false;
108
+ return p;
109
+ },
110
+ isWriting() { return writing; },
111
+ hasCompleted(runId) { return completedRuns.has(runId); },
112
+ noteBlockedClose() { pendingClose = true; },
113
+ };
114
+ }
115
+
116
+ /**
117
+ * @param {() => object|null} getWindow returns the workspace BrowserWindow
118
+ * @param {{project: string, nonce: string, ipc?: object, gate?: object, deps?: object}} ctx
119
+ * @returns {{gate: object}}
120
+ */
121
+ function registerWorkspaceHandlers(getWindow, ctx) {
122
+ const { project, nonce } = ctx;
123
+ const deps = { ...defaultDeps(), ...(ctx.deps || {}) };
124
+ const ipc = ctx.ipc || require('electron').ipcMain;
125
+ const gate = ctx.gate || createFoldGate();
126
+ const fromWorkspace = (event) => isPrivilegedSender(event, getWindow);
127
+ // ⚠️ CODE REVIEW (task-9 post-approval): settle()'s pendingClose signal used to be
128
+ // read only on the success path below, so a close blocked while a fold was writing
129
+ // silently dropped on the two failure paths (settle({ok:false}) at the detail-error
130
+ // early return and in the outer catch) — the window stayed open with no further
131
+ // close handler poised to retry until the user clicked X again. All three settle()
132
+ // call sites must honor a pending close identically.
133
+ const closeIfPending = (shouldClose) => {
134
+ if (!shouldClose) { return; }
135
+ const win = getWindow();
136
+ if (win && !win.isDestroyed()) { win.close(); }
137
+ };
138
+
139
+ ipc.handle('workspace:list-runs', (event) => {
140
+ if (!fromWorkspace(event)) { return []; }
141
+ try { return deps.scanCouncilRuns(project); }
142
+ catch (err) { logger.error('workspace list-runs failed', { error: err.message }); return []; }
143
+ });
144
+
145
+ ipc.handle('workspace:get-run', (event, runId) => {
146
+ if (!fromWorkspace(event)) { return { error: 'unauthorized' }; }
147
+ try { return deps.getRunDetail(project, String(runId)); }
148
+ catch (err) { return { runId: String(runId), error: err.message }; }
149
+ });
150
+
151
+ ipc.handle('workspace:get-live', async (event, runId) => {
152
+ if (!fromWorkspace(event)) { return { ok: false, error: 'unauthorized' }; }
153
+ try {
154
+ const result = await deps.handlers().amicus_status({ taskId: String(runId) }, project);
155
+ const text = result && result.content && result.content[0] ? result.content[0].text : '';
156
+ if (result && result.isError) { return { ok: false, error: String(text).slice(0, 500) }; }
157
+ let doc;
158
+ try { doc = JSON.parse(text); } catch { return { ok: false, error: 'unparseable status doc' }; }
159
+ return deps.normalizeLive(doc);
160
+ } catch (err) {
161
+ return { ok: false, error: err.message };
162
+ }
163
+ });
164
+
165
+ ipc.handle('workspace:read-artifact', (event, runId, name) => {
166
+ if (!fromWorkspace(event)) { return { error: 'unauthorized' }; }
167
+ try { return deps.readRunArtifact(project, String(runId), String(name)); }
168
+ catch (err) { return { error: err.message }; }
169
+ });
170
+
171
+ ipc.handle('workspace:abort-run', async (event, runId) => {
172
+ if (!fromWorkspace(event)) { return { ok: false, error: 'unauthorized' }; }
173
+ try {
174
+ const result = await deps.handlers().amicus_abort({ taskId: String(runId) }, project);
175
+ const text = result && result.content && result.content[0] ? result.content[0].text : '';
176
+ return { ok: !(result && result.isError), detail: String(text).slice(0, 2000) };
177
+ } catch (err) {
178
+ return { ok: false, error: err.message };
179
+ }
180
+ });
181
+
182
+ ipc.handle('workspace:fold', async (event, runId) => {
183
+ if (!fromWorkspace(event)) { return { ok: false, error: 'unauthorized' }; }
184
+ const runKey = String(runId);
185
+ // Gate keyed on runKey (council review R2, A3 — see createFoldGate's
186
+ // docblock): completing run A must not latch a DIFFERENT run B.
187
+ if (gate.hasCompleted(runKey)) { return { ok: true, already: true }; }
188
+ if (!gate.begin(runKey)) { return { ok: false, error: 'fold-in-flight' }; }
189
+ try {
190
+ const detail = deps.getRunDetail(project, runKey);
191
+ if (detail.error || !detail.run || detail.run.parseError) {
192
+ closeIfPending(gate.settle({ ok: false }));
193
+ return { ok: false, error: detail.error || 'run.json unavailable' };
194
+ }
195
+ const chairRead = deps.readRunArtifact(project, runKey, 'chair-output.md');
196
+ // Distinguish a security fence firing (e.g. the realpath-escape check)
197
+ // from a benign absence (not written yet) — both fall through to the
198
+ // same safe fallback body below, but only one of them is worth an
199
+ // operator's attention (code review fix).
200
+ if (chairRead && chairRead.error) {
201
+ logger.warn('Workspace fold: chair-output.md unavailable', { runId: runKey, reason: chairRead.error });
202
+ }
203
+ const chairText = chairRead && chairRead.text ? chairRead.text : null;
204
+ const text = deps.buildFoldText({
205
+ nonce, project, run: detail.run, tally: detail.tally, verdict: detail.verdict, chairText,
206
+ });
207
+ await deps.stdoutWrite(text + '\n');
208
+ logger.info('Workspace fold completed', { runId: runKey });
209
+ closeIfPending(gate.settle({ ok: true }));
210
+ return { ok: true };
211
+ } catch (err) {
212
+ closeIfPending(gate.settle({ ok: false }));
213
+ logger.error('Workspace fold failed', { error: err.message });
214
+ return { ok: false, error: err.message };
215
+ }
216
+ });
217
+
218
+ ipc.handle('workspace:open-report', (event, runId) => {
219
+ if (!fromWorkspace(event)) { return { ok: false, error: 'unauthorized' }; }
220
+ // ⚠️ DE-ROT (F59): the SHIPPED readPointer (src/council/run-state.js:133-139,
221
+ // which Task 2's dedupe makes this deps entry resolve to) returns **null** for a
222
+ // missing/corrupt pointer — it never returns {error}. A bare `ptr.error` throws a
223
+ // TypeError straight out of the handler, and this was the only one of the seven
224
+ // with no try/catch. Guard for null AND wrap, like the other six.
225
+ try {
226
+ const ptr = deps.readPointer(project, String(runId));
227
+ if (!ptr || ptr.error) { return { ok: false, error: (ptr && ptr.error) || 'run pointer not found' }; }
228
+
229
+ // Containment fence FIRST (council review C1, MAJOR, unanimous; ROUND 4 ORDERING
230
+ // FIX, third live paid council, blocker-adjacent — this handler is the direct
231
+ // analogue of the same bug fixed in src/workspace/artifact-guard.js's
232
+ // readRunArtifact): this handler was the only filesystem-reaching workspace:
233
+ // channel with no containment check, and the only one that hands a disk-derived
234
+ // path to a shell verb (openExternal) — ptr.runDir is charset-blind and never
235
+ // validated beyond truthiness (src/council/run-state.js:133-139). The
236
+ // existsSync(report) probe used to run BEFORE this fence: read-only and
237
+ // Boolean, so it never leaked file content, but it let a tampered/stale pointer
238
+ // use file-EXISTENCE as an out-of-project oracle ahead of any containment check
239
+ // — the same "resolve+fence before touching the filesystem at all" invariant
240
+ // every other channel in this file now honours. Resolve+fence runDir first;
241
+ // only then probe/read anything derived from it.
242
+ let realProject, realRunDir;
243
+ try {
244
+ realProject = deps.realpathSync(project);
245
+ realRunDir = deps.realpathSync(ptr.runDir);
246
+ } catch {
247
+ return { ok: false, error: 'run directory unreadable' };
248
+ }
249
+ if (!isRealpathContained(realProject, realRunDir)) {
250
+ return { ok: false, error: 'run directory escapes project' };
251
+ }
252
+
253
+ const report = path.join(ptr.runDir, 'report.html');
254
+ if (!deps.existsSync(report)) { return { ok: false, error: 'report.html not written' }; }
255
+
256
+ // Inner fence (independent of the one above): report.html must resolve inside
257
+ // runDir too — a symlinked report.html can't escape the run dir either. Fail
258
+ // closed on any realpath error (permission, dangling symlink) rather than let
259
+ // it fall through to openExternal. Generic message (round 4): a readFileSync/
260
+ // realpathSync failure's err.message embeds the full resolved path it tried to
261
+ // open — interpolating it here would hand the renderer an internal filesystem
262
+ // path via the IPC response.
263
+ let realReport;
264
+ try {
265
+ realReport = deps.realpathSync(report);
266
+ } catch {
267
+ return { ok: false, error: 'report path unreadable' };
268
+ }
269
+ if (!isRealpathContained(realRunDir, realReport)) {
270
+ return { ok: false, error: 'report escapes run directory' };
271
+ }
272
+
273
+ deps.openExternal(pathToFileURL(realReport).href);
274
+ return { ok: true };
275
+ } catch (err) {
276
+ return { ok: false, error: err.message };
277
+ }
278
+ });
279
+
280
+ return { gate };
281
+ }
282
+
283
+ module.exports = { registerWorkspaceHandlers, createFoldGate };
package/electron/main.js CHANGED
@@ -79,6 +79,10 @@ const WINDOW_POSITION = process.env.AMICUS_WINDOW_POSITION || 'right';
79
79
  // marker. That's safe here because the GUI fold write is exit-code driven, not
80
80
  // marker-detected, so an un-advertised fallback nonce can't be exploited.
81
81
  const FOLD_NONCE = process.env.AMICUS_FOLD_NONCE;
82
+ // v4.4 Council Workspace mode (AMICUS_MODE=council-workspace). Set by
83
+ // src/sidecar/workspace-window.js; PROJECT defaults to cwd for direct launches.
84
+ const PROJECT = process.env.AMICUS_PROJECT || process.cwd();
85
+ const RUN_ID = process.env.AMICUS_RUN_ID || '';
82
86
 
83
87
  const OPENCODE_URL = `http://localhost:${OPENCODE_PORT}`;
84
88
 
@@ -138,7 +142,10 @@ function createAmicusWindow() {
138
142
  let updateInfo = null;
139
143
  const updateInfoRaw = process.env.AMICUS_UPDATE_INFO;
140
144
  if (updateInfoRaw) {
141
- try { updateInfo = JSON.parse(updateInfoRaw); } catch (_) {}
145
+ try { updateInfo = JSON.parse(updateInfoRaw); } catch (_) {
146
+ // A malformed AMICUS_UPDATE_INFO is not worth failing launch over:
147
+ // updateInfo stays null and the direct check below takes over.
148
+ }
142
149
  }
143
150
  if (!updateInfo) {
144
151
  const { getUpdateInfo, initUpdateCheck } = require('../src/utils/updater');
@@ -510,6 +517,27 @@ function createSettingsChildWindow() {
510
517
  settingsWin.webContents.on('page-title-updated', (e) => e.preventDefault());
511
518
  }
512
519
 
520
+ // ============================================================================
521
+ // Council Workspace (v4.4) — third mode; window body lives in workspace-shell.js
522
+ // ============================================================================
523
+
524
+ function createCouncilWorkspace() {
525
+ const { createWorkspaceWindow } = require('./workspace-shell');
526
+ const { registerWorkspaceHandlers, createFoldGate } = require('./ipc-workspace');
527
+ const { generateFoldNonce } = require('../src/utils/fold-marker');
528
+ const gate = createFoldGate();
529
+ registerWorkspaceHandlers(() => mainWindow, {
530
+ project: PROJECT,
531
+ nonce: FOLD_NONCE || generateFoldNonce(),
532
+ gate,
533
+ });
534
+ mainWindow = createWorkspaceWindow({ runId: RUN_ID, gate, headless: !!process.env.AMICUS_HEADLESS_TEST });
535
+ mainWindow.on('closed', () => {
536
+ mainWindow = null;
537
+ app.quit();
538
+ });
539
+ }
540
+
513
541
  // ============================================================================
514
542
  // App Lifecycle
515
543
  // ============================================================================
@@ -523,6 +551,8 @@ app.whenReady().then(() => {
523
551
 
524
552
  if (MODE === 'setup') {
525
553
  createSetupWindow().catch((err) => { logger.error('createSetupWindow failed', err); });
554
+ } else if (MODE === 'council-workspace') {
555
+ createCouncilWorkspace();
526
556
  } else {
527
557
  createAmicusWindow();
528
558
  }
@@ -0,0 +1,40 @@
1
+ /**
2
+ * Council Workspace Preload — minimal typed IPC bridge (v4.4 §4.2/§4.5).
3
+ *
4
+ * The workspace page loads via loadFile (a real file:// origin), so
5
+ * contextBridge works under FULL sandbox — no data:-URL workaround needed
6
+ * (docs/electron-testing.md Known Limitations; the setup wizard only works
7
+ * because it runs sandbox:false, a posture this page must not inherit: it
8
+ * renders untrusted model prose, the H9 surface).
9
+ *
10
+ * Exposes exactly one function. No openExternal bridge, no listeners.
11
+ * Unknown channel → throw (preload-setup.js pattern).
12
+ *
13
+ * ⚠️ DE-ROT (F30): this header used to name the banned electron module (the
14
+ * one preload-setup.js:9 imports for openExternal). Step 1 asserts
15
+ * expect(SRC).not.toContain(...) against the RAW file source — comments
16
+ * included — so that word here made Task 7 fail its own test. Never let the
17
+ * literal token appear anywhere in this file. Step 4's "PASS (3 tests)" is
18
+ * correct only with the reworded line above.
19
+ */
20
+ const { contextBridge, ipcRenderer } = require('electron');
21
+
22
+ const ALLOWED_CHANNELS = [
23
+ 'workspace:list-runs',
24
+ 'workspace:get-run',
25
+ 'workspace:get-live',
26
+ 'workspace:read-artifact',
27
+ 'workspace:abort-run',
28
+ 'workspace:fold',
29
+ 'workspace:open-report',
30
+ ];
31
+
32
+ contextBridge.exposeInMainWorld('amicusWorkspace', {
33
+ /** Invoke an allowlisted workspace IPC channel. */
34
+ invoke: (channel, ...args) => {
35
+ if (!ALLOWED_CHANNELS.includes(channel)) {
36
+ throw new Error(`IPC channel not allowed: ${channel}`);
37
+ }
38
+ return ipcRenderer.invoke(channel, ...args);
39
+ },
40
+ });
@@ -22,7 +22,7 @@ const ALIAS_GROUPS = [
22
22
  * @returns {string} HTML fragment with search, groups, rows, and add button
23
23
  */
24
24
  function buildAliasEditorHTML(aliases) {
25
- const searchInput = `<input type="text" id="alias-search" class="alias-search" placeholder="Search aliases..." autocomplete="off" spellcheck="false">`;
25
+ const searchInput = '<input type="text" id="alias-search" class="alias-search" placeholder="Search aliases..." autocomplete="off" spellcheck="false">';
26
26
 
27
27
  const groups = ALIAS_GROUPS.map(group => {
28
28
  const rows = group.keys
@@ -31,10 +31,10 @@ function buildAliasEditorHTML(aliases) {
31
31
  const model = aliases[key];
32
32
  return `<div class="alias-row" data-alias="${key}">` +
33
33
  `<span class="alias-name">${key}</span>` +
34
- `<span class="alias-arrow">\u2192</span>` +
34
+ '<span class="alias-arrow">\u2192</span>' +
35
35
  `<span class="alias-model">${model}</span>` +
36
36
  `<button class="alias-delete" data-alias="${key}">\u00d7</button>` +
37
- `</div>`;
37
+ '</div>';
38
38
  }).join('\n ');
39
39
 
40
40
  const count = group.keys.filter(key => aliases[key] !== undefined).length;
@@ -51,9 +51,9 @@ function buildAliasEditorHTML(aliases) {
51
51
  const exampleModel = aliases[exampleAlias] || 'openrouter/google/gemini-3.1-flash-lite-preview';
52
52
 
53
53
  // SVG icons for the example box
54
- const terminalIcon = `<svg class="alias-icon-accent" width="16" height="16" viewBox="0 0 16 16" fill="none"><rect x="1" y="2" width="14" height="12" rx="2" stroke-width="1.5"/><path d="M4 6l2.5 2L4 10" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"/><path d="M8.5 10H11" class="alias-icon-faint-path" stroke-width="1.5" stroke-linecap="round"/></svg>`;
55
- const arrowIcon = `<svg class="alias-icon-accent" width="20" height="12" viewBox="0 0 20 12" fill="none"><path d="M2 6h14" stroke-width="1.5" stroke-linecap="round"/><path d="M13 2l4 4-4 4" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"/></svg>`;
56
- const modelIcon = `<svg class="alias-icon-ok" width="16" height="16" viewBox="0 0 16 16" fill="none"><circle cx="8" cy="5" r="3" stroke-width="1.5"/><path d="M8 8v3" stroke-width="1.5" stroke-linecap="round"/><circle cx="4" cy="13" r="1.5" stroke-width="1.2"/><circle cx="8" cy="13" r="1.5" stroke-width="1.2"/><circle cx="12" cy="13" r="1.5" stroke-width="1.2"/><path d="M4 11.5L8 11M8 11l4 .5" stroke-width="1" stroke-linecap="round"/></svg>`;
54
+ const terminalIcon = '<svg class="alias-icon-accent" width="16" height="16" viewBox="0 0 16 16" fill="none"><rect x="1" y="2" width="14" height="12" rx="2" stroke-width="1.5"/><path d="M4 6l2.5 2L4 10" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"/><path d="M8.5 10H11" class="alias-icon-faint-path" stroke-width="1.5" stroke-linecap="round"/></svg>';
55
+ const arrowIcon = '<svg class="alias-icon-accent" width="20" height="12" viewBox="0 0 20 12" fill="none"><path d="M2 6h14" stroke-width="1.5" stroke-linecap="round"/><path d="M13 2l4 4-4 4" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"/></svg>';
56
+ const modelIcon = '<svg class="alias-icon-ok" width="16" height="16" viewBox="0 0 16 16" fill="none"><circle cx="8" cy="5" r="3" stroke-width="1.5"/><path d="M8 8v3" stroke-width="1.5" stroke-linecap="round"/><circle cx="4" cy="13" r="1.5" stroke-width="1.2"/><circle cx="8" cy="13" r="1.5" stroke-width="1.2"/><circle cx="12" cy="13" r="1.5" stroke-width="1.2"/><path d="M4 11.5L8 11M8 11l4 .5" stroke-width="1" stroke-linecap="round"/></svg>';
57
57
 
58
58
  const exampleBox = `<div class="routing-example">
59
59
  <div class="example-label">How it works</div>
@@ -0,0 +1,85 @@
1
+ /**
2
+ * Council Workspace window (v4.4 §4.1/§4.2) — the third Electron mode's
3
+ * BrowserWindow. One window, no WebContentsView split: the workspace is a
4
+ * single first-party page. Extracted from main.js (grandfathered size) so
5
+ * the posture is source-testable.
6
+ *
7
+ * H9 posture: this page renders untrusted model prose, so it is the MOST
8
+ * locked-down page in the app — full sandbox, minimal preload, CSP in the
9
+ * page itself, and it never navigates or opens windows.
10
+ *
11
+ * Close semantics (spec §7 resolved Q3): NO auto-fold on close — closing a
12
+ * viewer discards nothing (all artifacts are on disk). The only guard is the
13
+ * in-flight fold-write latch supplied by ipc-workspace's gate.
14
+ */
15
+ 'use strict';
16
+
17
+ const path = require('path');
18
+ const { BrowserWindow } = require('electron');
19
+ const { TOKENS } = require('../src/design/tokens');
20
+ // ⚠️ DE-ROT (F31): shipped guard for exactly this show:false hazard — reuse, don't
21
+ // re-invent (electron/load-failsafe.js:27, paired with show:false at main.js:210-232).
22
+ const { attachLoadFailsafe } = require('./load-failsafe');
23
+ const { logger } = require('../src/utils/logger');
24
+
25
+ const ICON_PATH = path.join(__dirname, 'assets', 'icon.png');
26
+
27
+ /**
28
+ * @param {{runId?: string, gate: {isWriting: () => boolean, noteBlockedClose: () => void}, headless?: boolean}} opts
29
+ * @returns {BrowserWindow}
30
+ */
31
+ function createWorkspaceWindow({ runId, gate, headless }) {
32
+ const win = new BrowserWindow({
33
+ width: 1100, height: 800, minWidth: 860, minHeight: 600,
34
+ show: false,
35
+ frame: true, backgroundColor: TOKENS.bg,
36
+ title: 'Amicus Council Workspace',
37
+ icon: ICON_PATH,
38
+ webPreferences: {
39
+ preload: path.join(__dirname, 'preload-workspace.js'),
40
+ contextIsolation: true, nodeIntegration: false, sandbox: true,
41
+ },
42
+ });
43
+
44
+ win.webContents.on('will-navigate', (event) => { event.preventDefault(); });
45
+ win.webContents.setWindowOpenHandler(() => ({ action: 'deny' }));
46
+ win.webContents.on('page-title-updated', (e) => e.preventDefault());
47
+
48
+ // ⚠️ DE-ROT (F31): attach BEFORE loadFile. onFail shows the window anyway (so the
49
+ // user sees an empty-but-present shell instead of nothing) and logs the reason;
50
+ // default timeout is 15s (load-failsafe.js:19). No buildLoadErrorHTML here — the
51
+ // workspace page is first-party and CSP-locked, so just surface + log.
52
+ const failsafe = attachLoadFailsafe({
53
+ webContents: win.webContents,
54
+ onFail: ({ reason, errorCode, errorDescription, validatedURL }) => {
55
+ logger.error('Council Workspace page failed to load', {
56
+ reason, errorCode, errorDescription, validatedURL, runId: runId || ''
57
+ });
58
+ if (!headless && !win.isDestroyed()) { win.show(); }
59
+ },
60
+ });
61
+
62
+ // ⚠️ CODE REVIEW: loadFile() rejects on load failure (and, until Task 11 lands
63
+ // workspace-ui/index.html, on EVERY launch). The failsafe above already surfaces
64
+ // and logs the failure, so swallow the rejection itself rather than leaving an
65
+ // unhandled rejection in the main process (precedent: main.js's own
66
+ // .loadURL(...).catch(() => {})).
67
+ win.loadFile(path.join(__dirname, 'workspace-ui', 'index.html'), { query: { runId: runId || '' } })
68
+ .catch(() => {});
69
+
70
+ win.once('ready-to-show', () => {
71
+ failsafe.cancel(); // disarm on the success path
72
+ if (!headless) { win.show(); }
73
+ });
74
+
75
+ win.on('close', (event) => {
76
+ if (gate && gate.isWriting()) {
77
+ event.preventDefault();
78
+ gate.noteBlockedClose();
79
+ }
80
+ });
81
+
82
+ return win;
83
+ }
84
+
85
+ module.exports = { createWorkspaceWindow };
@@ -0,0 +1,111 @@
1
+ <!DOCTYPE html>
2
+ <html lang="en">
3
+ <head>
4
+ <meta charset="utf-8">
5
+ <meta http-equiv="Content-Security-Policy"
6
+ content="default-src 'none'; script-src file:; style-src file: 'unsafe-inline'; font-src file:; img-src file: data:">
7
+ <title>Amicus Council Workspace</title>
8
+ <link rel="stylesheet" href="../../src/design/tokens.css">
9
+ <link rel="stylesheet" href="./workspace.css">
10
+ </head>
11
+ <body>
12
+ <div id="app">
13
+ <aside id="rail" aria-label="Council runs">
14
+ <header class="rail-head">
15
+ <span class="rail-title">Council runs</span>
16
+ <span id="rail-project" class="rail-project mono"></span>
17
+ </header>
18
+ <ul id="run-list" tabindex="0" aria-label="Run list"></ul>
19
+ </aside>
20
+
21
+ <main id="detail">
22
+ <div id="empty-state" class="empty-state">Select a council run</div>
23
+
24
+ <div id="run-view" hidden>
25
+ <header id="run-header">
26
+ <div class="run-head-row">
27
+ <h1 id="run-title" class="mono"></h1>
28
+ <div id="run-chips" class="chips"></div>
29
+ </div>
30
+ <div class="run-head-row">
31
+ <div id="cost-gauge" class="gauge" role="meter" aria-label="Run cost vs ceiling">
32
+ <div id="cost-gauge-fill" class="gauge-fill"></div>
33
+ <span id="cost-gauge-text" class="gauge-text mono"></span>
34
+ </div>
35
+ <label class="blind-label">
36
+ <input type="checkbox" id="blind-toggle"> Blind mode
37
+ </label>
38
+ <button id="abort-btn" class="btn danger" hidden>Abort run…</button>
39
+ </div>
40
+ </header>
41
+
42
+ <div id="banner" class="banner" hidden></div>
43
+
44
+ <nav id="stage-rail" class="stage-rail" aria-label="Stages"></nav>
45
+
46
+ <section id="seats-panel" class="panel" aria-label="Seats">
47
+ <h2 class="panel-title">Seats</h2>
48
+ <table class="table">
49
+ <thead>
50
+ <tr><th>Seat</th><th>Role</th><th>Status</th><th>Stage</th><th>Msgs</th>
51
+ <th>Tokens in/out</th><th>Cost</th><th>Last activity</th><th></th></tr>
52
+ </thead>
53
+ <tbody id="seats-body"></tbody>
54
+ </table>
55
+ </section>
56
+
57
+ <details id="reviews-panel" class="panel">
58
+ <summary class="panel-title">Stage-1 reviews</summary>
59
+ <div id="reviews-body" class="prose-host"></div>
60
+ </details>
61
+
62
+ <details id="bundle-panel" class="panel">
63
+ <summary class="panel-title">Peer-review packet (verbatim)</summary>
64
+ <div id="bundle-body" class="prose-host"></div>
65
+ </details>
66
+
67
+ <details id="judges-panel" class="panel">
68
+ <summary class="panel-title">Judge outputs</summary>
69
+ <div id="judges-body" class="prose-host"></div>
70
+ </details>
71
+
72
+ <section id="matrix-panel" class="panel" aria-label="Adjudication matrix">
73
+ <h2 class="panel-title">Adjudication matrix</h2>
74
+ <div id="matrix-body"></div>
75
+ </section>
76
+
77
+ <section id="verdict-panel" class="panel" aria-label="Verdict">
78
+ <h2 class="panel-title">Verdict</h2>
79
+ <div id="verdict-body"></div>
80
+ </section>
81
+
82
+ <section id="cost-panel" class="panel" aria-label="Cost">
83
+ <h2 class="panel-title">Cost by seat</h2>
84
+ <div id="cost-body"></div>
85
+ </section>
86
+ </div>
87
+ </main>
88
+ </div>
89
+
90
+ <div id="dialog-abort" class="dialog-backdrop" hidden>
91
+ <div class="dialog" role="dialog" aria-modal="true" aria-labelledby="dialog-abort-title">
92
+ <h2 id="dialog-abort-title">Abort this council run?</h2>
93
+ <p>The active wave and solo legs will be aborted via the engine's abort path. Artifacts already on disk stay browsable.</p>
94
+ <div class="dialog-actions">
95
+ <button id="dialog-abort-cancel" class="btn">Cancel</button>
96
+ <button id="dialog-abort-confirm" class="btn danger">Abort run</button>
97
+ </div>
98
+ </div>
99
+ </div>
100
+
101
+ <script src="./md-lite.js"></script>
102
+ <script src="./live-model.js"></script>
103
+ <script src="./workspace-render.js"></script>
104
+ <script src="./workspace-matrix.js"></script>
105
+ <!-- ⚠️ DE-ROT (F05): panels + verbs are the Task 13 split of workspace-app.js.
106
+ Load order matters: workspace-app.js boots last and owns `state`. -->
107
+ <script src="./workspace-panels.js"></script>
108
+ <script src="./workspace-verbs.js"></script>
109
+ <script src="./workspace-app.js"></script>
110
+ </body>
111
+ </html>