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
@@ -0,0 +1,196 @@
1
+ // src/sidecar/tool-part.js
2
+ 'use strict';
3
+
4
+ /**
5
+ * @module sidecar/tool-part
6
+ * OpenCode's TOOL-PART shape and its status vocabulary (v4.4 B4 part 1).
7
+ *
8
+ * WHY THIS EXISTS. The mirror used to model a tool call as an Anthropic-style
9
+ * `tool_use` part cleared by a matching `tool_result` part carrying
10
+ * `tool_use_id`. **OpenCode has no such part type.** Established empirically
11
+ * before designing against it (see tests/sidecar/tool-part-status.test.js for
12
+ * the full derivation):
13
+ *
14
+ * - 5,129 persisted parts in this machine's OpenCode database resolve to
15
+ * exactly six `type` values: text, step-start, reasoning, step-finish,
16
+ * tool, patch. No `tool_result`. No `tool_use`.
17
+ * - The 35 recorded legs of `council-wsgate01/`..`council-wsgate04/` wrote
18
+ * **36 `tool_use` records and ZERO `tool_result` records**. So the
19
+ * diagnosis's proposed `pendingToolCalls.length === 0` gate, keyed on a
20
+ * `tool_result` that never arrives, would have hung every tool-using leg
21
+ * to its full `--timeout`.
22
+ *
23
+ * THE REAL SHAPE (@opencode-ai/sdk `ToolPart`):
24
+ * `{id, sessionID, messageID, type:'tool', callID, tool:<name>, state}`
25
+ * where `state` is the `ToolState` union.
26
+ *
27
+ * STATUS VOCABULARY:
28
+ * - DECLARED by the SDK's `ToolState` union: 'pending' | 'running' |
29
+ * 'completed' | 'error'.
30
+ * - OBSERVED across all 1,307 persisted tool parts: completed 1148,
31
+ * error 150, running 9. 'pending' is declared but never persisted — it is
32
+ * the pre-execution transient (`{input, raw}`, no `time` at all).
33
+ * - TERMINAL is 'completed' | 'error', verified structurally: all 1,298
34
+ * terminal parts carry `state.time.end`; all 9 non-terminal parts carry
35
+ * `state.time.start` and no `end`. Disjoint and exhaustive.
36
+ *
37
+ * The 9 `running` parts are LEFTOVERS — `time_updated` within milliseconds of
38
+ * `time_created`, all from three killed sessions that never wrote a terminal
39
+ * status. A stale non-terminal status can therefore persist forever, which is
40
+ * why any wait keyed on this vocabulary MUST be bounded (headless.js's
41
+ * TOOL_SETTLE_GRACE_MS).
42
+ */
43
+
44
+ /**
45
+ * The statuses that mean "this tool call is finished and its session is no
46
+ * longer working on it". Anything else — including an unrecognised or absent
47
+ * status — is treated as still live: terminality is never INVENTED, it must be
48
+ * positively observed.
49
+ * @type {Set<string>}
50
+ */
51
+ const TERMINAL_TOOL_STATUSES = new Set(['completed', 'error']);
52
+
53
+ /**
54
+ * The statuses that POSITIVELY tell us OpenCode is still working on a tool call.
55
+ *
56
+ * This is deliberately NOT `!TERMINAL` — the difference is the anti-hang
57
+ * guarantee. A tool part carrying no `state` at all (the legacy `tool_use`
58
+ * shape, which the repo's own fixtures still emit) gives us no evidence in
59
+ * either direction, and deferring a leg's completion on an ABSENCE of evidence
60
+ * is precisely how a completion gate hangs. So:
61
+ *
62
+ * - `getPendingToolCalls` = not-yet-terminal, INCLUDING unknown shape.
63
+ * Feeds B53's wedge detector, whose whole job is the no-evidence case, and
64
+ * whose semantics are therefore unchanged.
65
+ * - `getLiveToolCalls` = positively observed 'pending' or 'running'.
66
+ * Feeds the v4.4 completion gate: it will only ever hold a leg open on
67
+ * evidence that the session really is still working.
68
+ * @type {Set<string>}
69
+ */
70
+ const LIVE_TOOL_STATUSES = new Set(['pending', 'running']);
71
+
72
+ /**
73
+ * Is this part a tool call at all? Accepts OpenCode's real `'tool'` type and
74
+ * the legacy `'tool_use'` shape that older fixtures/providers emit.
75
+ * @param {object} part
76
+ * @returns {boolean}
77
+ */
78
+ function isToolPart(part) {
79
+ return !!part && (part.type === 'tool' || part.type === 'tool_use');
80
+ }
81
+
82
+ /**
83
+ * The tool's name. Reads OpenCode's real field (`part.tool`) and falls back to
84
+ * the legacy `part.name`. The mirror previously read ONLY `part.name`, which
85
+ * the real shape never has — which is why all 36 recorded tool records carry an
86
+ * id and nothing else, and why headless.js's `Task`-subagent summary log was
87
+ * permanently empty.
88
+ * @param {object} part
89
+ * @returns {string|undefined}
90
+ */
91
+ function toolPartName(part) {
92
+ if (!part) { return undefined; }
93
+ return part.tool !== undefined ? part.tool : part.name;
94
+ }
95
+
96
+ /**
97
+ * The tool's input. Real shape carries it on `state.input`; the legacy shape on
98
+ * `part.input`.
99
+ * @param {object} part
100
+ * @returns {object|undefined}
101
+ */
102
+ function toolPartInput(part) {
103
+ if (!part) { return undefined; }
104
+ if (part.state && part.state.input !== undefined) { return part.state.input; }
105
+ return part.input;
106
+ }
107
+
108
+ /**
109
+ * Has this tool call reached a TERMINAL status? A part with no `state` (the
110
+ * legacy shape) is NOT settled — unknown status must never read as "finished",
111
+ * which is what keeps B53's wedge detection working on legacy fixtures.
112
+ * @param {object} part
113
+ * @returns {boolean}
114
+ */
115
+ function isToolPartSettled(part) {
116
+ const status = toolPartStatus(part);
117
+ return typeof status === 'string' && TERMINAL_TOOL_STATUSES.has(status);
118
+ }
119
+
120
+ /**
121
+ * The observed `state.status`, or undefined when the part carries no state
122
+ * (legacy shape) — "unknown", which is NOT the same as either terminal or live.
123
+ * @param {object} part
124
+ * @returns {string|undefined}
125
+ */
126
+ function toolPartStatus(part) {
127
+ const status = part && part.state && part.state.status;
128
+ return typeof status === 'string' && status !== '' ? status : undefined;
129
+ }
130
+
131
+ /**
132
+ * Is this tool call POSITIVELY observed as still executing? See
133
+ * LIVE_TOOL_STATUSES for why this is not simply `!isToolPartSettled`.
134
+ * @param {object} part
135
+ * @returns {boolean}
136
+ */
137
+ function isToolPartLive(part) {
138
+ const status = toolPartStatus(part);
139
+ return status !== undefined && LIVE_TOOL_STATUSES.has(status);
140
+ }
141
+
142
+ /**
143
+ * Is this a SUBAGENT (child-session) tool call? OpenCode names it `task`
144
+ * (lowercase — the pre-existing `t.name === 'Task'` comparison in headless.js
145
+ * could never match, a second bug on top of the missing `part.tool` read).
146
+ *
147
+ * This is the signal for B4: a `task` call spawns a CHILD OpenCode session
148
+ * whose spend is billed separately and is NOT rolled into the parent session's
149
+ * cost, so a leg that made one has cost we cannot claim to know. Verified 1:1
150
+ * on the recorded corpus — exactly 2 `task` calls across 37 sessions, and
151
+ * exactly 2 child sessions, each parented by the calling leg's session
152
+ * (wsgate01-s1-2 → $0.021460, wsgate02-s1-3 → $0.471046).
153
+ * @param {{name?: string}} toolCall a recorded {id,name,input} entry
154
+ * @returns {boolean}
155
+ */
156
+ function isSubagentToolCall(toolCall) {
157
+ const name = toolCall && toolCall.name;
158
+ return typeof name === 'string' && name.toLowerCase() === 'task';
159
+ }
160
+
161
+ /**
162
+ * Tool calls that have NOT reached a terminal `state.status` — as far as we can
163
+ * observe, OpenCode may still be working on them and the session may still be
164
+ * billing. INCLUDES the unknown-shape case (no `state` at all). Feeds the
165
+ * headless poll loop's B53 wedge detector, whose whole purpose is that case.
166
+ *
167
+ * v4.4 B4 part 1: this used to clear only on a `tool_result` part carrying
168
+ * `tool_use_id` — a part type OpenCode never emits (0 of 36 recorded tool
169
+ * records) — so it never cleared for any real leg. It is now driven by the real
170
+ * `state.status` vocabulary, with the legacy `tool_result` path kept for
171
+ * back-compat. Returns a fresh array; `state.pendingToolCalls` is the live
172
+ * source of truth.
173
+ * @param {{pendingToolCalls: Map}} state from createMirrorState()
174
+ * @returns {Array<{id:string,name:string,status:string|undefined,firstSeenAt:string}>}
175
+ */
176
+ function getPendingToolCalls(state) {
177
+ return Array.from(state.pendingToolCalls.values());
178
+ }
179
+
180
+ /**
181
+ * Tool calls POSITIVELY observed as still executing. A strict SUBSET of
182
+ * getPendingToolCalls — see LIVE_TOOL_STATUSES for why the difference is the
183
+ * anti-hang guarantee. This is what the v4.4 completion gate reads.
184
+ * @param {{pendingToolCalls: Map}} state from createMirrorState()
185
+ * @returns {Array<{id:string,name:string,status:string,firstSeenAt:string}>}
186
+ */
187
+ function getLiveToolCalls(state) {
188
+ return getPendingToolCalls(state)
189
+ .filter((t) => t.status !== undefined && LIVE_TOOL_STATUSES.has(t.status));
190
+ }
191
+
192
+ module.exports = {
193
+ TERMINAL_TOOL_STATUSES, LIVE_TOOL_STATUSES, isToolPart, toolPartName, toolPartInput,
194
+ toolPartStatus, isToolPartSettled, isToolPartLive, isSubagentToolCall,
195
+ getPendingToolCalls, getLiveToolCalls,
196
+ };
@@ -0,0 +1,62 @@
1
+ /**
2
+ * Council Workspace launcher (v4.4 §4.3/§4.4) — setup-window.js pattern:
3
+ * ensureElectron() (the one place provisioning is allowed, #55) → spawn
4
+ * electron/main.js in council-workspace mode. Unlike setup (which buffers),
5
+ * this RELAYS child stdout live — the nonced fold block must reach the
6
+ * launching terminal's command output verbatim. Exit code propagates
7
+ * (0 on fold-then-close and on plain close).
8
+ */
9
+ 'use strict';
10
+
11
+ const { spawn } = require('child_process');
12
+ const path = require('path');
13
+ const { logger } = require('../utils/logger');
14
+ const { getElectronPath } = require('./interactive-process');
15
+ const { ensureElectron } = require('./electron-ensure');
16
+ const { generateFoldNonce } = require('../utils/fold-marker');
17
+
18
+ /**
19
+ * @param {{project: string, runId?: string}} opts
20
+ * @param {{ensureElectron?: Function, spawn?: Function, nonce?: string}} [deps] test injection
21
+ * @returns {Promise<{code: number, error?: string}>}
22
+ */
23
+ async function launchWorkspaceWindow({ project, runId = '' }, deps = {}) {
24
+ const ensure = deps.ensureElectron || ensureElectron;
25
+ const spawnFn = deps.spawn || spawn;
26
+ const ensured = await ensure();
27
+ if (!ensured.ok) {
28
+ return { code: 1, error: ensured.reason || 'Electron not installed' };
29
+ }
30
+ return new Promise((resolve) => {
31
+ const electronPath = ensured.path || getElectronPath();
32
+ const mainPath = path.join(__dirname, '..', '..', 'electron', 'main.js');
33
+ const env = {
34
+ ...process.env,
35
+ AMICUS_MODE: 'council-workspace',
36
+ AMICUS_PROJECT: project,
37
+ AMICUS_RUN_ID: runId || '',
38
+ AMICUS_FOLD_NONCE: deps.nonce || generateFoldNonce(),
39
+ };
40
+ const debugPort = process.env.AMICUS_DEBUG_PORT;
41
+ const args = debugPort ? [`--remote-debugging-port=${debugPort}`, mainPath] : [mainPath];
42
+ logger.info('Launching council workspace', { runId: runId || '(run list)', debugPort: debugPort || 'disabled' });
43
+
44
+ const proc = spawnFn(electronPath, args, { env, stdio: ['ignore', 'pipe', 'pipe'] });
45
+
46
+ proc.stdout.setEncoding('utf-8');
47
+ proc.stdout.on('data', (chunk) => { process.stdout.write(chunk); }); // fold relay — live, verbatim
48
+ proc.stderr.setEncoding('utf-8');
49
+ proc.stderr.on('data', (chunk) => { logger.debug('Workspace stderr', { data: String(chunk).trim() }); });
50
+
51
+ proc.on('error', (err) => {
52
+ logger.error('Workspace failed to spawn', { error: err.message });
53
+ resolve({ code: 1, error: `Failed to start workspace: ${err.message}` });
54
+ });
55
+ proc.on('close', (code) => {
56
+ logger.info('Workspace closed', { code });
57
+ resolve({ code: code === null ? 1 : code });
58
+ });
59
+ });
60
+ }
61
+
62
+ module.exports = { launchWorkspaceWindow };
@@ -59,17 +59,33 @@ function rowKey(row, dimension) {
59
59
  }
60
60
  }
61
61
 
62
- /** Group rows into {key, amount, tokens, runs, sourceMix}, most-expensive first. */
62
+ /**
63
+ * Group rows into {key, amount, tokens, runs, unpricedRows,
64
+ * unattributedSubtreeRows, sourceMix}, most-expensive first.
65
+ *
66
+ * v4.4: `amount` deliberately stays a plain number — the published
67
+ * spend.schema.json pins `groups[].amount` to `type: "number"` — so
68
+ * `unpricedRows` is how a group says "this figure omits N rows we cannot
69
+ * price". Without it, a group of entirely unpriced rows was indistinguishable
70
+ * from a group that genuinely cost $0 (diagnosis §8).
71
+ *
72
+ * v4.4.1 CA-2: `unattributedSubtreeRows` is the SECOND way a figure can be a
73
+ * floor, and `unpricedRows` structurally cannot see it — such a row IS priced,
74
+ * lands in the `r` source bucket, and contributes its own cost to `amount`; what
75
+ * is missing is the child session it spawned. The two counters are incremented
76
+ * BESIDE each other, never instead of: a row can be both.
77
+ */
63
78
  function groupRows(rows, dimension) {
64
79
  const map = new Map();
65
80
  for (const r of rows) {
66
81
  const key = rowKey(r, dimension);
67
- if (!map.has(key)) { map.set(key, { key, amount: 0, tokens: emptyTokens(), runs: 0, sourceMix: { reported: 0, estimated: 0, unknown: 0 } }); }
82
+ if (!map.has(key)) { map.set(key, { key, amount: 0, tokens: emptyTokens(), runs: 0, unpricedRows: 0, unattributedSubtreeRows: 0, sourceMix: { reported: 0, estimated: 0, unknown: 0 } }); }
68
83
  const b = map.get(key);
69
84
  b.runs += 1;
70
85
  addTokens(b.tokens, r.tokens);
71
86
  const cost = r.cost || {};
72
- if (typeof cost.amount === 'number') { b.amount += cost.amount; }
87
+ if (typeof cost.amount === 'number') { b.amount += cost.amount; } else { b.unpricedRows += 1; }
88
+ if (r.subtreeUnknown) { b.unattributedSubtreeRows += 1; }
73
89
  const src = (cost.source === 'reported' || cost.source === 'estimated') ? cost.source : 'unknown';
74
90
  b.sourceMix[src] += 1;
75
91
  }
@@ -87,16 +103,27 @@ function groupRows(rows, dimension) {
87
103
  * a row); computeWasted intentionally drops it instead.
88
104
  */
89
105
  function computeWasted(rows) {
90
- const out = { amount: 0, tokens: emptyTokens(), runs: 0, byStatus: {} };
106
+ const out = { amount: 0, tokens: emptyTokens(), runs: 0, unpricedRows: 0, unattributedSubtreeRows: 0, byStatus: {} };
91
107
  for (const r of rows) {
92
108
  if (r.status === 'complete' || !r.status) { continue; }
93
109
  out.runs += 1;
94
110
  addTokens(out.tokens, r.tokens);
95
- const amt = (r.cost && typeof r.cost.amount === 'number') ? r.cost.amount : 0;
111
+ // v4.4: null→0 here is arithmetic, not a claim. `unpricedRows` records how
112
+ // many failed rows we could not price so "wasted $X" is never mistaken for
113
+ // the whole loss (see groupRows for why `amount` stays a number).
114
+ // v4.4.1 CA-2: `unattributedSubtreeRows` is the other half of the same
115
+ // understatement — a failed leg that DID resolve its own cost but left a
116
+ // child session unpriced. Counted beside `unpricedRows`, never instead of.
117
+ const priced = r.cost && typeof r.cost.amount === 'number';
118
+ const amt = priced ? r.cost.amount : 0;
119
+ if (!priced) { out.unpricedRows += 1; }
120
+ if (r.subtreeUnknown) { out.unattributedSubtreeRows += 1; }
96
121
  out.amount += amt;
97
- if (!out.byStatus[r.status]) { out.byStatus[r.status] = { amount: 0, runs: 0 }; }
122
+ if (!out.byStatus[r.status]) { out.byStatus[r.status] = { amount: 0, runs: 0, unpricedRows: 0, unattributedSubtreeRows: 0 }; }
98
123
  out.byStatus[r.status].amount += amt;
99
124
  out.byStatus[r.status].runs += 1;
125
+ if (!priced) { out.byStatus[r.status].unpricedRows += 1; }
126
+ if (r.subtreeUnknown) { out.byStatus[r.status].unattributedSubtreeRows += 1; }
100
127
  }
101
128
  return out;
102
129
  }
@@ -0,0 +1,42 @@
1
+ // src/utils/env-num.js
2
+ 'use strict';
3
+
4
+ /**
5
+ * @module utils/env-num
6
+ * Numeric environment override that HONORS an explicit `0`.
7
+ *
8
+ * WHY THIS EXISTS. The idiom `Number(process.env.X) || DEFAULT` is wrong for any
9
+ * knob whose "off" value is `0`, because `0` is falsy and is therefore rewritten
10
+ * back into `DEFAULT`. v4.4 shipped four such knobs — `AMICUS_USAGE_SETTLE_POLLS`,
11
+ * `AMICUS_USAGE_SETTLE_INTERVAL_MS`, `AMICUS_USAGE_SETTLE_CALL_TIMEOUT_MS` and
12
+ * `AMICUS_TOOL_SETTLE_GRACE_MS` — each documenting `0` as the disable switch in
13
+ * its docblock and (for the last one) in the CHANGELOG, while making that switch
14
+ * unreachable from the environment. The operator could read the escape hatch and
15
+ * not use it.
16
+ *
17
+ * SEMANTICS. An explicit, finite numeric value always wins, `0` included. Unset,
18
+ * blank/whitespace-only, and non-finite values fall back to the default:
19
+ * - blank matters because `Number('') === 0`, so a bare `export AMICUS_X=` would
20
+ * otherwise read as an intentional disable rather than the accident it is;
21
+ * - non-finite matters because `Number('Infinity')` would otherwise be fed
22
+ * straight into `setTimeout`/comparison arithmetic.
23
+ *
24
+ * NOT A BLANKET REPLACEMENT. Several older knobs (`AMICUS_POLL_INTERVAL_MS`,
25
+ * `AMICUS_STABLE_*_POLLS`, `AMICUS_TOOL_CALL_STALL_MS`, `AMICUS_MAX_SESSIONS`, …)
26
+ * deliberately keep `||`: `0` is not a documented escape hatch for any of them and
27
+ * honoring it would busy-loop a poller or silently disable a stall guard. Migrate a
28
+ * knob to this helper only when `0` is a value its call site actually understands.
29
+ *
30
+ * @param {string} name environment variable name
31
+ * @param {number} dflt value used when unset / blank / non-finite
32
+ * @param {object} [env] environment object (test seam; defaults to process.env)
33
+ * @returns {number}
34
+ */
35
+ function envNumber(name, dflt, env) {
36
+ const raw = (env || process.env)[name];
37
+ if (raw === undefined || raw === null || String(raw).trim() === '') { return dflt; }
38
+ const n = Number(raw);
39
+ return Number.isFinite(n) ? n : dflt;
40
+ }
41
+
42
+ module.exports = { envNumber };
@@ -41,4 +41,40 @@ function armExitWatchdog(code = 0, ms = 1500, deps = {}) {
41
41
  return t;
42
42
  }
43
43
 
44
- module.exports = { isOneShotCommand, armExitWatchdog, ONE_SHOT_COMMANDS };
44
+ /**
45
+ * An `exit` hook for armExitWatchdog that REAPS an OpenCode server this process
46
+ * is using but does not own, on its way out (v4.4.1 fix wave, finding F3).
47
+ *
48
+ * The force-exit path is the one place where "not ours to close" stops being the
49
+ * safe answer. A wave running on an injected server deliberately never closes it
50
+ * — the owner does, once, in its own finalize. But if the watchdog fires first
51
+ * the parent dies anyway, and the Go server survives it: an orphan still holding
52
+ * the OpenCode SQLite lock that the per-run shared server exists to stop
53
+ * contending on. Before the external-server seam, fanout's own close() covered
54
+ * this by accident; this restores it deliberately.
55
+ *
56
+ * SIGTERM only, and synchronous: process.exit cannot await server.close()'s
57
+ * escalation, so this sends the one signal that fits in the window and gets out
58
+ * of the way. Never signals this process, and never lets a dead/absent pid throw
59
+ * — the exit must happen regardless.
60
+ *
61
+ * @param {{goPid?: number|null}|null} server the injected server handle
62
+ * @param {{kill?: Function, exit?: Function}} [deps] test seams
63
+ * @returns {(code: number) => void}
64
+ */
65
+ function exitReaping(server, deps = {}) {
66
+ return (code) => {
67
+ // Resolved at CALL time, not creation time: this hook is built the instant a
68
+ // signal lands and invoked up to 10s later, so binding process.kill/exit
69
+ // early would freeze whatever was installed at signal time.
70
+ const kill = deps.kill || ((p, sig) => process.kill(p, sig));
71
+ const exit = deps.exit || ((c) => process.exit(c));
72
+ const pid = server && server.goPid;
73
+ if (pid && pid !== process.pid) {
74
+ try { kill(pid, 'SIGTERM'); } catch { /* already gone: nothing to reap */ }
75
+ }
76
+ exit(code);
77
+ };
78
+ }
79
+
80
+ module.exports = { isOneShotCommand, armExitWatchdog, exitReaping, ONE_SHOT_COMMANDS };
@@ -0,0 +1,120 @@
1
+ /**
2
+ * Shared realpath-containment fence.
3
+ *
4
+ * Single source of truth for "is this resolved path inside that resolved
5
+ * directory" — the primitive that defeats symlink escapes AND tampered/stale
6
+ * pointer files (a `council-<id>.json` pointer's `runDir` is validated only
7
+ * for truthiness by src/council/run-state.js's readPointer, so nothing
8
+ * upstream of this check guarantees it stays inside the project).
9
+ *
10
+ * It is a LEAF: `fs` + `path` at load time and nothing else, no require cycle
11
+ * possible. (SEC-3's debug-mode diagnostic requires src/utils/logger.js lazily,
12
+ * inside the flag branch — logger.js has zero requires of its own, so even that
13
+ * cannot introduce a cycle, and nothing is loaded at all with the flag unset.)
14
+ * That was first needed inside the v4.4 workspace layer —
15
+ * src/workspace/artifact-guard.js requires src/workspace/run-scan.js for
16
+ * readPointer, so if run-scan.js also required artifact-guard.js for this
17
+ * helper, the two would require each other and one side's destructured import
18
+ * would silently resolve to undefined depending on load order.
19
+ *
20
+ * It lives in src/utils/ rather than src/workspace/ because its consumers are
21
+ * no longer all workspace modules: the shipped v4.3 surfaces
22
+ * (src/mcp-council-awareness.js behind amicus_status / amicus_abort /
23
+ * amicus_list, src/cli-handlers-watch.js and src/observe/watch-render.js behind
24
+ * `amicus watch`) fence the same pointer with the same check. Keeping it under
25
+ * src/workspace/ would have made three stable shipped surfaces depend on a
26
+ * feature directory added in v4.4 — the only inverted require in the tree, and
27
+ * one that would turn any future reorganisation of that layer into a breaking
28
+ * change for those tools. src/utils/ is the neutral layer src/workspace/
29
+ * already depends on (formatCost, fold-marker), so the arrow now points one way.
30
+ */
31
+ 'use strict';
32
+
33
+ const fs = require('fs');
34
+ const path = require('path');
35
+
36
+ /**
37
+ * ⚠️ SEC-3: isRealpathContained is a pure string-prefix comparison, and it is only SOUND
38
+ * if both arguments were already resolved through realpathSync. Nothing enforced that, so
39
+ * a caller who forgot got a silently WEAKER check rather than an error — a raw
40
+ * (unresolved) path containing a symlink can prefix-match a directory it does not
41
+ * physically live under.
42
+ *
43
+ * This is a DIAGNOSTIC, opt-in via `AMICUS_DEBUG_FENCE=1`, and it MUST NEVER THROW: the
44
+ * fence must never become the failure it exists to prevent. Every step is wrapped, and a
45
+ * non-existent path is a legitimate argument here (callers fence paths before probing
46
+ * them), so it is not reported. The logger is required lazily so this module keeps its
47
+ * `fs` + `path` load-time surface (see the header) for the 99.99% of calls that run with
48
+ * the flag unset.
49
+ * @param {string} dir already-stringified dirRealPath
50
+ * @param {string} target already-stringified targetRealPath
51
+ */
52
+ function assertResolvedArgs(dir, target) {
53
+ try {
54
+ const { logger } = require('./logger');
55
+ for (const [name, p] of [['dirRealPath', dir], ['targetRealPath', target]]) {
56
+ if (!path.isAbsolute(p)) {
57
+ logger.warn('path-fence: argument is not absolute', { arg: name, value: p });
58
+ continue;
59
+ }
60
+ try {
61
+ if (fs.existsSync(p) && fs.realpathSync(p) !== p) {
62
+ logger.warn('path-fence: argument is not realpath-resolved', { arg: name, value: p });
63
+ }
64
+ } catch { /* unreadable / dangling: not this diagnostic's business */ }
65
+ }
66
+ } catch { /* a broken logger must never break the fence */ }
67
+ }
68
+
69
+ /**
70
+ * True when `targetRealPath` is exactly `dirRealPath` or a proper descendant
71
+ * of it. Both arguments MUST already be resolved through realpathSync — this
72
+ * is a pure string-prefix check.
73
+ * @param {string} dirRealPath
74
+ * @param {string} targetRealPath
75
+ * @returns {boolean}
76
+ */
77
+ function isRealpathContained(dirRealPath, targetRealPath) {
78
+ const dir = String(dirRealPath);
79
+ const target = String(targetRealPath);
80
+ // SEC-3: debug-mode contract check only — never a gate, never a throw (see above).
81
+ if (process.env.AMICUS_DEBUG_FENCE === '1') { assertResolvedArgs(dir, target); }
82
+ if (target === dir) { return true; }
83
+ // ⚠️ COUNCIL REVIEW R2 (A6): when dirRealPath IS a filesystem root, it already
84
+ // ends in a separator ('/' on POSIX, 'C:\\' on Windows) — blindly appending
85
+ // another (the old `dirRealPath + path.sep`) doubles it ('//' / 'C:\\\\'), and
86
+ // no real path ever starts with that, so containment silently returned false
87
+ // for every path under a root dirRealPath. Only append the separator when it
88
+ // isn't already there.
89
+ const base = dir.endsWith(path.sep) ? dir : dir + path.sep;
90
+ // The separator-qualified prefix (not a bare `startsWith(dir)`) is what defeats
91
+ // the sibling-prefix trap: '/foobar' must not be considered inside '/foo'.
92
+ return target.startsWith(base);
93
+ }
94
+
95
+ /**
96
+ * Fail-closed, disk-resolving form of isRealpathContained: resolves BOTH
97
+ * arguments through realpathSync and applies the same containment test.
98
+ * Returns false when either side cannot be resolved — a missing directory, a
99
+ * dangling symlink, a permission error, or a non-string `targetPath` straight
100
+ * out of a hand-edited pointer file — so an unresolvable path is REFUSED
101
+ * rather than trusted.
102
+ *
103
+ * The v4.4 workspace consumers keep calling isRealpathContained directly
104
+ * because each has to tell "unreadable" apart from "escapes" in the error row
105
+ * it renders. The v4.3 CLI/MCP consumers (src/mcp-council-awareness.js,
106
+ * src/cli-handlers-watch.js, src/observe/watch-render.js) collapse every
107
+ * failure into one outcome — no payload / skip the row / kind 'unknown' — so
108
+ * they take this boolean form instead of repeating the two try/catch blocks at
109
+ * four more call sites.
110
+ * @param {string} dirPath
111
+ * @param {string} targetPath
112
+ * @returns {boolean}
113
+ */
114
+ function containsOnDisk(dirPath, targetPath) {
115
+ try {
116
+ return isRealpathContained(fs.realpathSync(dirPath), fs.realpathSync(targetPath));
117
+ } catch { return false; }
118
+ }
119
+
120
+ module.exports = { isRealpathContained, containsOnDisk };