@venturewild/workspace 0.3.5 → 0.3.7

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 (52) hide show
  1. package/LICENSE +21 -21
  2. package/README.md +112 -112
  3. package/package.json +83 -83
  4. package/server/bin/wild-workspace.mjs +995 -995
  5. package/server/src/account.mjs +114 -114
  6. package/server/src/agent-login.mjs +146 -146
  7. package/server/src/agent-readiness.mjs +200 -200
  8. package/server/src/agent.mjs +468 -453
  9. package/server/src/bazaar/core.mjs +579 -579
  10. package/server/src/bazaar/index.mjs +75 -75
  11. package/server/src/bazaar/mcp-server.mjs +328 -328
  12. package/server/src/bazaar/mock-tickup.mjs +97 -97
  13. package/server/src/bazaar/preview-server.mjs +95 -95
  14. package/server/src/bazaar/seed-recipes/customer-feedback-form/know-how.md +23 -23
  15. package/server/src/bazaar/seed-recipes/customer-feedback-form/recipe.json +24 -24
  16. package/server/src/bazaar/seed-recipes/landing-page-launch/know-how.md +29 -29
  17. package/server/src/bazaar/seed-recipes/landing-page-launch/recipe.json +25 -25
  18. package/server/src/bazaar/seed-recipes/personal-portfolio/know-how.md +21 -21
  19. package/server/src/bazaar/seed-recipes/personal-portfolio/recipe.json +24 -24
  20. package/server/src/bazaar/seed-recipes/receipt-sorter/know-how.md +31 -31
  21. package/server/src/bazaar/seed-recipes/receipt-sorter/recipe.json +25 -25
  22. package/server/src/bazaar/seed-recipes/tickup-hr-matching/know-how.md +79 -79
  23. package/server/src/bazaar/seed-recipes/tickup-hr-matching/recipe.json +32 -32
  24. package/server/src/canvas/core.mjs +421 -324
  25. package/server/src/canvas/index.mjs +42 -42
  26. package/server/src/canvas/mcp-server.mjs +253 -253
  27. package/server/src/config.mjs +404 -404
  28. package/server/src/daemon-bin.mjs +110 -110
  29. package/server/src/daemon-supervisor.mjs +285 -285
  30. package/server/src/doctor.mjs +375 -375
  31. package/server/src/inbox.mjs +86 -86
  32. package/server/src/index.mjs +2475 -2349
  33. package/server/src/logpaths.mjs +98 -98
  34. package/server/src/observability.mjs +45 -45
  35. package/server/src/operator.mjs +92 -92
  36. package/server/src/pairing.mjs +137 -137
  37. package/server/src/service.mjs +515 -515
  38. package/server/src/session-reporter.mjs +201 -201
  39. package/server/src/settings.mjs +145 -0
  40. package/server/src/share.mjs +182 -182
  41. package/server/src/skills.mjs +213 -0
  42. package/server/src/supervisor.mjs +647 -647
  43. package/server/src/support-consent.mjs +133 -133
  44. package/server/src/sync.mjs +248 -248
  45. package/server/src/transcript.mjs +121 -121
  46. package/server/src/turn-mcp.mjs +46 -46
  47. package/server/src/usage.mjs +405 -0
  48. package/web/dist/assets/index-BxRx8EsD.js +91 -0
  49. package/web/dist/assets/index-DoOPBr3s.css +1 -0
  50. package/web/dist/index.html +2 -2
  51. package/web/dist/assets/index-DatlFPkm.js +0 -91
  52. package/web/dist/assets/index-Dl0VT5e6.css +0 -1
@@ -1,201 +1,201 @@
1
- // SessionReporter — the proactive, consented "is this user okay?" feed.
2
- //
3
- // WHY: the get-in path is self-serve, but if a real user gets stuck or their
4
- // install breaks, we were blind unless they ran `operator enable` and messaged
5
- // us — which makes us a bottleneck (docs/user-experience.md §5). This forwards a
6
- // LIVE, REDACTED stream of session events + install health to bmo-sync, keyed by
7
- // account, established at first load — so a stuck/broken user is never invisible
8
- // and never has to ask.
9
- //
10
- // PRIVACY — the load-bearing boundary: this feed carries WHAT happened (a turn
11
- // ran, tool X fired, an error, token/cost), NEVER the words. Chat text, tool
12
- // inputs, file contents, and paths are reduced to lengths/counts before anything
13
- // leaves the machine. Conversation *content* is a separate, separately-consented
14
- // channel (transcript.mjs). `redactEvent` is an ALLOWLIST projection — any event
15
- // type we don't explicitly model forwards only `{type}`, so a new event can
16
- // never leak by default. The matching test asserts a secret typed into a chat
17
- // turn never appears in the payload.
18
- //
19
- // Modeled on error-reporter.mjs (fire-and-forget, rate-limited, disable-able).
20
- // Gated by BOTH consent (user toggle) AND the shared WILD_WORKSPACE_NO_TELEMETRY
21
- // kill switch; inert without an accountToken (can't key it) or on a localhost
22
- // bmo-sync URL (dev).
23
-
24
- import os from 'node:os';
25
- import { APP_VERSION } from './config.mjs';
26
-
27
- function sanitizeUsage(u) {
28
- if (!u || typeof u !== 'object') return null;
29
- const out = {
30
- input_tokens: Number(u.input_tokens) || 0,
31
- output_tokens: Number(u.output_tokens) || 0,
32
- };
33
- if (typeof u.cost_usd === 'number') out.cost_usd = u.cost_usd;
34
- return out;
35
- }
36
-
37
- /**
38
- * Project one ActivityBus event to a SAFE shape. Allowlist by type; anything not
39
- * listed forwards only {type, ts, id}. NEVER returns chat text, tool inputs,
40
- * file paths, or file contents.
41
- */
42
- export function redactEvent(ev) {
43
- if (!ev || typeof ev !== 'object') return null;
44
- const base = { type: ev.type, ts: ev.ts, id: ev.id };
45
- if (ev.messageId) base.messageId = ev.messageId;
46
- switch (ev.type) {
47
- case 'chat-user':
48
- // The user's prompt — length only, never the words.
49
- return { ...base, textLen: typeof ev.text === 'string' ? ev.text.length : 0 };
50
- case 'chat-stream': {
51
- const c = ev.chunk || {};
52
- const safe = { ...base, chunkType: c.type };
53
- if (c.type === 'text' && typeof c.text === 'string') safe.textLen = c.text.length;
54
- // tool NAME is safe ("Edit", "Bash"); the tool INPUT is not — never forward it.
55
- if (c.type === 'tool-use') safe.tool = typeof c.name === 'string' ? c.name : c.tool || null;
56
- if (c.type === 'usage' && c.usage) safe.usage = sanitizeUsage(c.usage);
57
- if (c.type === 'error') safe.hasError = true; // the flag, not the message
58
- return safe;
59
- }
60
- case 'usage':
61
- return { ...base, usage: sanitizeUsage(ev.usage) };
62
- case 'chat-end':
63
- return { ...base, code: ev.code };
64
- case 'identity-changed':
65
- return { ...base, tone: ev.tone || null }; // tone only; drop the agent's name
66
- case 'onboarded':
67
- return { ...base, at: ev.at || null };
68
- case 'agent-changed':
69
- return { ...base, agentId: ev.agentId || null };
70
- case 'daemon-status':
71
- return { ...base, status: ev.status || null };
72
- case 'operator-action':
73
- return { ...base, action: ev.action || null }; // action verb, not its detail
74
- case 'inbox-change':
75
- return { ...base, count: Array.isArray(ev.snapshot) ? ev.snapshot.length : undefined };
76
- case 'presence-join':
77
- case 'presence-leave':
78
- case 'presence-focus':
79
- // sessionId + role are safe; `focus` can be a file path → dropped.
80
- return { ...base, sessionId: ev.sessionId, role: ev.role };
81
- default:
82
- // Unknown event → minimal envelope only. Privacy fails CLOSED.
83
- return { type: ev.type, ts: ev.ts, id: ev.id };
84
- }
85
- }
86
-
87
- const FLUSH_INTERVAL_MS = 15_000; // batch window
88
- const MAX_BATCH = 50; // flush early once this many buffer
89
- const MAX_BUFFER = 500; // hard cap — drop oldest beyond this
90
-
91
- export class SessionReporter {
92
- constructor({
93
- bmoSyncUrl,
94
- accountToken,
95
- slug = null,
96
- workspaceId = 'workspace',
97
- sessionId = null,
98
- enabled = true,
99
- endpointPath = '/api/telemetry',
100
- flushIntervalMs = FLUSH_INTERVAL_MS,
101
- maxBatch = MAX_BATCH,
102
- fetchImpl = (...a) => globalThis.fetch(...a),
103
- nowImpl = () => Date.now(),
104
- } = {}) {
105
- this.bmoSyncUrl = bmoSyncUrl ? bmoSyncUrl.replace(/\/$/, '') : null;
106
- this.accountToken = accountToken || null;
107
- this.slug = slug;
108
- this.workspaceId = workspaceId;
109
- this.sessionId = sessionId;
110
- this.endpointPath = endpointPath;
111
- this.flushIntervalMs = flushIntervalMs;
112
- this.maxBatch = maxBatch;
113
- this.fetchImpl = fetchImpl;
114
- this.nowImpl = nowImpl;
115
- this.buffer = [];
116
- this.timer = null;
117
- // Inert without a token, without a server, or on localhost (dev).
118
- this._capable =
119
- Boolean(this.accountToken) &&
120
- Boolean(this.bmoSyncUrl) &&
121
- !this.bmoSyncUrl.startsWith('http://127') &&
122
- !this.bmoSyncUrl.startsWith('http://localhost');
123
- this.enabled = enabled !== false && this._capable;
124
- }
125
-
126
- /** Live consent toggle — no restart needed when the user flips observability. */
127
- setEnabled(on) {
128
- this.enabled = Boolean(on) && this._capable;
129
- if (!this.enabled) this.buffer = [];
130
- }
131
-
132
- /** Feed one ActivityBus event. Redacts + buffers; flushes on size. No-op when off. */
133
- ingest(ev) {
134
- if (!this.enabled) return;
135
- const safe = redactEvent(ev);
136
- if (!safe) return;
137
- this.buffer.push(safe);
138
- if (this.buffer.length > MAX_BUFFER) this.buffer.splice(0, this.buffer.length - MAX_BUFFER);
139
- if (this.buffer.length >= this.maxBatch) this.flush();
140
- }
141
-
142
- /** POST the install-health snapshot alongside events (called by the supervisor path too). */
143
- envelope(events, extra = {}) {
144
- return {
145
- account_token: this.accountToken,
146
- slug: this.slug,
147
- workspace_id: this.workspaceId,
148
- session_id: this.sessionId,
149
- app_version: APP_VERSION,
150
- os: `${os.platform()}-${os.arch()}`,
151
- sent_at: Math.floor(this.nowImpl() / 1000),
152
- events,
153
- ...extra,
154
- };
155
- }
156
-
157
- /** Fire-and-forget flush of the current buffer. Never throws. */
158
- flush() {
159
- if (!this.enabled || !this.buffer.length) return;
160
- const events = this.buffer;
161
- this.buffer = [];
162
- this._post(this.envelope(events));
163
- }
164
-
165
- _post(body) {
166
- const url = `${this.bmoSyncUrl}${this.endpointPath}`;
167
- const ctrl = new AbortController();
168
- const timer = setTimeout(() => ctrl.abort(), 5000);
169
- if (timer.unref) timer.unref();
170
- let p;
171
- try {
172
- // Call synchronously so the request is observable without awaiting a tick.
173
- p = this.fetchImpl(url, {
174
- method: 'POST',
175
- headers: { 'content-type': 'application/json' },
176
- body: JSON.stringify(body),
177
- signal: ctrl.signal,
178
- });
179
- } catch {
180
- clearTimeout(timer);
181
- return; // telemetry must never break the user's path
182
- }
183
- Promise.resolve(p)
184
- .catch(() => {})
185
- .finally(() => clearTimeout(timer));
186
- }
187
-
188
- start() {
189
- if (this.timer || !this._capable) return;
190
- this.timer = setInterval(() => this.flush(), this.flushIntervalMs);
191
- if (this.timer.unref) this.timer.unref(); // never keep the process alive
192
- }
193
-
194
- stop() {
195
- if (this.timer) {
196
- clearInterval(this.timer);
197
- this.timer = null;
198
- }
199
- this.flush();
200
- }
201
- }
1
+ // SessionReporter — the proactive, consented "is this user okay?" feed.
2
+ //
3
+ // WHY: the get-in path is self-serve, but if a real user gets stuck or their
4
+ // install breaks, we were blind unless they ran `operator enable` and messaged
5
+ // us — which makes us a bottleneck (docs/user-experience.md §5). This forwards a
6
+ // LIVE, REDACTED stream of session events + install health to bmo-sync, keyed by
7
+ // account, established at first load — so a stuck/broken user is never invisible
8
+ // and never has to ask.
9
+ //
10
+ // PRIVACY — the load-bearing boundary: this feed carries WHAT happened (a turn
11
+ // ran, tool X fired, an error, token/cost), NEVER the words. Chat text, tool
12
+ // inputs, file contents, and paths are reduced to lengths/counts before anything
13
+ // leaves the machine. Conversation *content* is a separate, separately-consented
14
+ // channel (transcript.mjs). `redactEvent` is an ALLOWLIST projection — any event
15
+ // type we don't explicitly model forwards only `{type}`, so a new event can
16
+ // never leak by default. The matching test asserts a secret typed into a chat
17
+ // turn never appears in the payload.
18
+ //
19
+ // Modeled on error-reporter.mjs (fire-and-forget, rate-limited, disable-able).
20
+ // Gated by BOTH consent (user toggle) AND the shared WILD_WORKSPACE_NO_TELEMETRY
21
+ // kill switch; inert without an accountToken (can't key it) or on a localhost
22
+ // bmo-sync URL (dev).
23
+
24
+ import os from 'node:os';
25
+ import { APP_VERSION } from './config.mjs';
26
+
27
+ function sanitizeUsage(u) {
28
+ if (!u || typeof u !== 'object') return null;
29
+ const out = {
30
+ input_tokens: Number(u.input_tokens) || 0,
31
+ output_tokens: Number(u.output_tokens) || 0,
32
+ };
33
+ if (typeof u.cost_usd === 'number') out.cost_usd = u.cost_usd;
34
+ return out;
35
+ }
36
+
37
+ /**
38
+ * Project one ActivityBus event to a SAFE shape. Allowlist by type; anything not
39
+ * listed forwards only {type, ts, id}. NEVER returns chat text, tool inputs,
40
+ * file paths, or file contents.
41
+ */
42
+ export function redactEvent(ev) {
43
+ if (!ev || typeof ev !== 'object') return null;
44
+ const base = { type: ev.type, ts: ev.ts, id: ev.id };
45
+ if (ev.messageId) base.messageId = ev.messageId;
46
+ switch (ev.type) {
47
+ case 'chat-user':
48
+ // The user's prompt — length only, never the words.
49
+ return { ...base, textLen: typeof ev.text === 'string' ? ev.text.length : 0 };
50
+ case 'chat-stream': {
51
+ const c = ev.chunk || {};
52
+ const safe = { ...base, chunkType: c.type };
53
+ if (c.type === 'text' && typeof c.text === 'string') safe.textLen = c.text.length;
54
+ // tool NAME is safe ("Edit", "Bash"); the tool INPUT is not — never forward it.
55
+ if (c.type === 'tool-use') safe.tool = typeof c.name === 'string' ? c.name : c.tool || null;
56
+ if (c.type === 'usage' && c.usage) safe.usage = sanitizeUsage(c.usage);
57
+ if (c.type === 'error') safe.hasError = true; // the flag, not the message
58
+ return safe;
59
+ }
60
+ case 'usage':
61
+ return { ...base, usage: sanitizeUsage(ev.usage) };
62
+ case 'chat-end':
63
+ return { ...base, code: ev.code };
64
+ case 'identity-changed':
65
+ return { ...base, tone: ev.tone || null }; // tone only; drop the agent's name
66
+ case 'onboarded':
67
+ return { ...base, at: ev.at || null };
68
+ case 'agent-changed':
69
+ return { ...base, agentId: ev.agentId || null };
70
+ case 'daemon-status':
71
+ return { ...base, status: ev.status || null };
72
+ case 'operator-action':
73
+ return { ...base, action: ev.action || null }; // action verb, not its detail
74
+ case 'inbox-change':
75
+ return { ...base, count: Array.isArray(ev.snapshot) ? ev.snapshot.length : undefined };
76
+ case 'presence-join':
77
+ case 'presence-leave':
78
+ case 'presence-focus':
79
+ // sessionId + role are safe; `focus` can be a file path → dropped.
80
+ return { ...base, sessionId: ev.sessionId, role: ev.role };
81
+ default:
82
+ // Unknown event → minimal envelope only. Privacy fails CLOSED.
83
+ return { type: ev.type, ts: ev.ts, id: ev.id };
84
+ }
85
+ }
86
+
87
+ const FLUSH_INTERVAL_MS = 15_000; // batch window
88
+ const MAX_BATCH = 50; // flush early once this many buffer
89
+ const MAX_BUFFER = 500; // hard cap — drop oldest beyond this
90
+
91
+ export class SessionReporter {
92
+ constructor({
93
+ bmoSyncUrl,
94
+ accountToken,
95
+ slug = null,
96
+ workspaceId = 'workspace',
97
+ sessionId = null,
98
+ enabled = true,
99
+ endpointPath = '/api/telemetry',
100
+ flushIntervalMs = FLUSH_INTERVAL_MS,
101
+ maxBatch = MAX_BATCH,
102
+ fetchImpl = (...a) => globalThis.fetch(...a),
103
+ nowImpl = () => Date.now(),
104
+ } = {}) {
105
+ this.bmoSyncUrl = bmoSyncUrl ? bmoSyncUrl.replace(/\/$/, '') : null;
106
+ this.accountToken = accountToken || null;
107
+ this.slug = slug;
108
+ this.workspaceId = workspaceId;
109
+ this.sessionId = sessionId;
110
+ this.endpointPath = endpointPath;
111
+ this.flushIntervalMs = flushIntervalMs;
112
+ this.maxBatch = maxBatch;
113
+ this.fetchImpl = fetchImpl;
114
+ this.nowImpl = nowImpl;
115
+ this.buffer = [];
116
+ this.timer = null;
117
+ // Inert without a token, without a server, or on localhost (dev).
118
+ this._capable =
119
+ Boolean(this.accountToken) &&
120
+ Boolean(this.bmoSyncUrl) &&
121
+ !this.bmoSyncUrl.startsWith('http://127') &&
122
+ !this.bmoSyncUrl.startsWith('http://localhost');
123
+ this.enabled = enabled !== false && this._capable;
124
+ }
125
+
126
+ /** Live consent toggle — no restart needed when the user flips observability. */
127
+ setEnabled(on) {
128
+ this.enabled = Boolean(on) && this._capable;
129
+ if (!this.enabled) this.buffer = [];
130
+ }
131
+
132
+ /** Feed one ActivityBus event. Redacts + buffers; flushes on size. No-op when off. */
133
+ ingest(ev) {
134
+ if (!this.enabled) return;
135
+ const safe = redactEvent(ev);
136
+ if (!safe) return;
137
+ this.buffer.push(safe);
138
+ if (this.buffer.length > MAX_BUFFER) this.buffer.splice(0, this.buffer.length - MAX_BUFFER);
139
+ if (this.buffer.length >= this.maxBatch) this.flush();
140
+ }
141
+
142
+ /** POST the install-health snapshot alongside events (called by the supervisor path too). */
143
+ envelope(events, extra = {}) {
144
+ return {
145
+ account_token: this.accountToken,
146
+ slug: this.slug,
147
+ workspace_id: this.workspaceId,
148
+ session_id: this.sessionId,
149
+ app_version: APP_VERSION,
150
+ os: `${os.platform()}-${os.arch()}`,
151
+ sent_at: Math.floor(this.nowImpl() / 1000),
152
+ events,
153
+ ...extra,
154
+ };
155
+ }
156
+
157
+ /** Fire-and-forget flush of the current buffer. Never throws. */
158
+ flush() {
159
+ if (!this.enabled || !this.buffer.length) return;
160
+ const events = this.buffer;
161
+ this.buffer = [];
162
+ this._post(this.envelope(events));
163
+ }
164
+
165
+ _post(body) {
166
+ const url = `${this.bmoSyncUrl}${this.endpointPath}`;
167
+ const ctrl = new AbortController();
168
+ const timer = setTimeout(() => ctrl.abort(), 5000);
169
+ if (timer.unref) timer.unref();
170
+ let p;
171
+ try {
172
+ // Call synchronously so the request is observable without awaiting a tick.
173
+ p = this.fetchImpl(url, {
174
+ method: 'POST',
175
+ headers: { 'content-type': 'application/json' },
176
+ body: JSON.stringify(body),
177
+ signal: ctrl.signal,
178
+ });
179
+ } catch {
180
+ clearTimeout(timer);
181
+ return; // telemetry must never break the user's path
182
+ }
183
+ Promise.resolve(p)
184
+ .catch(() => {})
185
+ .finally(() => clearTimeout(timer));
186
+ }
187
+
188
+ start() {
189
+ if (this.timer || !this._capable) return;
190
+ this.timer = setInterval(() => this.flush(), this.flushIntervalMs);
191
+ if (this.timer.unref) this.timer.unref(); // never keep the process alive
192
+ }
193
+
194
+ stop() {
195
+ if (this.timer) {
196
+ clearInterval(this.timer);
197
+ this.timer = null;
198
+ }
199
+ this.flush();
200
+ }
201
+ }
@@ -0,0 +1,145 @@
1
+ // Settings store + the autonomy-dial resolver (§8 "Surface B"). Two things live here:
2
+ // 1. resolveAutonomyMode() — the SAFETY-CRITICAL mapping from the user-facing
3
+ // autonomy level to the agent's permission mode. This is the guardrail.
4
+ // 2. createSettings() — a file-backed store under ~/.wild-workspace/settings/ for
5
+ // the autonomy level + the conversation title (/rename override of the derived
6
+ // presence label). CLAUDE.md #1: never the synced repo, never localStorage.
7
+ //
8
+ // THE AUTONOMY DIAL — and why the middle tier is "coming soon", not built (read this):
9
+ //
10
+ // The §8 build-risk is explicit: "Work, then show me" is NOT one native flag. It is
11
+ // acceptEdits PLUS our own confirm-gate on dangerous non-edit ops (deploy/delete/
12
+ // send/spend). A TRUE inline confirm-gate needs to intercept a tool call mid-turn,
13
+ // round-trip a confirmation to the browser, and resume — which our architecture
14
+ // can't do cleanly tonight: we wrap `claude -p` per turn (AR-17: no Agent SDK, so no
15
+ // `canUseTool` callback), and headless `-p` has no interactive permission prompt, so
16
+ // `default`/`acceptEdits` modes can't "ask" — they just deny. Building that
17
+ // round-trip is a real piece of infra, not a flag.
18
+ //
19
+ // So per the pre-approved fallback we ship only the TWO UNAMBIGUOUS tiers and leave
20
+ // the middle DISABLED ("coming soon"):
21
+ // • 'check' (Check with me) → 'plan' — read-only; the agent proposes, you approve.
22
+ // • 'full' (Full autonomy) → honors the Build/Plan toggle (Build → bypass).
23
+ // The middle ('work') and ANY unknown value SAFELY fall back to 'plan' — NEVER
24
+ // bypassPermissions. That is the guardrail the build-risk warns about: an unbuilt or
25
+ // unrecognized autonomy level must never silently behave like Full autonomy.
26
+ //
27
+ // DEFAULT = UNSET. An install with no chosen level keeps today's behavior exactly
28
+ // (the per-message Build/Plan toggle drives the mode — Build is already bypass). The
29
+ // dial is purely ADDITIVE: it lets a nervous user LOCK to read-only, which they
30
+ // couldn't before. We did NOT change the product's existing default, and we did NOT
31
+ // map the middle tier to Full. (Whether the default SHOULD become a guarded
32
+ // auto-build once the confirm-gate ships is a product call flagged for Tuan.)
33
+
34
+ import fs from 'node:fs';
35
+ import path from 'node:path';
36
+ import os from 'node:os';
37
+
38
+ // The three user-facing tiers. 'work' is DEFINED (so the UI can render it) but NOT
39
+ // BUILD-BACKED — see resolveAutonomyMode (it falls back to the safe 'plan').
40
+ export const AUTONOMY_LEVELS = ['check', 'work', 'full'];
41
+ // Which tiers a user may actually SELECT today. 'work' is excluded — "coming soon".
42
+ export const SELECTABLE_AUTONOMY = ['check', 'full'];
43
+
44
+ /**
45
+ * Map an autonomy level + the per-message Build/Plan toggle to the agent permission
46
+ * mode buildClaudeArgs understands ('plan' | 'build'). THE GUARDRAIL lives here:
47
+ * - unknown / 'work' (middle, not built) → 'plan' (read-only — NEVER bypass)
48
+ * - 'check' → 'plan' (hard read-only lock)
49
+ * - 'full' → honors the toggle (Build → bypass)
50
+ * - null/undefined (unset) → honors the toggle (today's behavior)
51
+ * The invariant tested in settings.test.mjs: NOTHING except 'full'/unset+Build ever
52
+ * yields 'build' (which is the only path to bypassPermissions).
53
+ */
54
+ export function resolveAutonomyMode(level, requestedMode) {
55
+ const toggle = requestedMode === 'plan' ? 'plan' : 'build';
56
+ switch (level) {
57
+ case 'full':
58
+ return toggle; // Full autonomy → honor the toggle (Build = bypass)
59
+ case 'check':
60
+ return 'plan'; // Check with me → always read-only propose
61
+ case null:
62
+ case undefined:
63
+ case '':
64
+ return toggle; // unset → unchanged product behavior
65
+ default:
66
+ // 'work' (middle, not built) AND any unrecognized value → SAFE read-only.
67
+ // This is the anti-regression: an unbuilt guardrail never becomes Full.
68
+ return 'plan';
69
+ }
70
+ }
71
+
72
+ function defaultSettingsDir(env = process.env, home = os.homedir()) {
73
+ const base = env.WILD_WORKSPACE_GLOBAL_DIR || path.join(home, '.wild-workspace');
74
+ return path.join(base, 'settings');
75
+ }
76
+
77
+ function readJsonSafe(file, fallback) {
78
+ try { return JSON.parse(fs.readFileSync(file, 'utf8')); } catch { return fallback; }
79
+ }
80
+
81
+ const TITLE_CAP = 120;
82
+
83
+ export function createSettings({ baseDir, env = process.env, home = os.homedir() } = {}) {
84
+ const dir = baseDir || defaultSettingsDir(env, home);
85
+ const file = path.join(dir, 'settings.json');
86
+
87
+ function read() {
88
+ const v = readJsonSafe(file, null);
89
+ return v && typeof v === 'object' ? v : {};
90
+ }
91
+
92
+ function write(patch) {
93
+ const next = { ...read(), ...patch, ts: Date.now() };
94
+ try {
95
+ fs.mkdirSync(dir, { recursive: true });
96
+ const tmp = `${file}.${process.pid}.tmp`;
97
+ fs.writeFileSync(tmp, JSON.stringify(next, null, 2));
98
+ fs.renameSync(tmp, file);
99
+ } catch { /* read-only fs — degrade to not-persisted */ }
100
+ return next;
101
+ }
102
+
103
+ // autonomyLevel: 'check' | 'full' | null. We REFUSE to persist 'work' (not
104
+ // selectable) or any unknown value — storing it would be a latent foot-gun. null
105
+ // clears back to the unset/default behavior.
106
+ function getAutonomyLevel() {
107
+ const lvl = read().autonomyLevel;
108
+ return SELECTABLE_AUTONOMY.includes(lvl) ? lvl : null;
109
+ }
110
+ function setAutonomyLevel(level) {
111
+ const clean = SELECTABLE_AUTONOMY.includes(level) ? level : null;
112
+ write({ autonomyLevel: clean });
113
+ return clean;
114
+ }
115
+
116
+ function getConversationTitle() {
117
+ const t = read().conversationTitle;
118
+ return typeof t === 'string' && t.trim() ? t.trim() : null;
119
+ }
120
+ function setConversationTitle(title) {
121
+ const clean = typeof title === 'string' ? title.trim().slice(0, TITLE_CAP) : '';
122
+ write({ conversationTitle: clean || null });
123
+ return clean || null;
124
+ }
125
+
126
+ function getAll() {
127
+ return {
128
+ autonomyLevel: getAutonomyLevel(),
129
+ conversationTitle: getConversationTitle(),
130
+ // Surface what's selectable + which is build-backed, so the UI doesn't hardcode it.
131
+ autonomy: {
132
+ levels: AUTONOMY_LEVELS,
133
+ selectable: SELECTABLE_AUTONOMY,
134
+ comingSoon: AUTONOMY_LEVELS.filter((l) => !SELECTABLE_AUTONOMY.includes(l)),
135
+ },
136
+ };
137
+ }
138
+
139
+ return {
140
+ dir, file,
141
+ getAutonomyLevel, setAutonomyLevel,
142
+ getConversationTitle, setConversationTitle,
143
+ getAll,
144
+ };
145
+ }