{{ onboardingSlide.title }}
{{ onboardingSlide.body }}
+diff --git a/desktop/src/preload.cjs b/desktop/src/preload.cjs
index 389ded9..703d4a0 100644
--- a/desktop/src/preload.cjs
+++ b/desktop/src/preload.cjs
@@ -137,6 +137,10 @@ if (isLocalDocument) Object.assign(desktopApi, {
settings: () => ipcRenderer.invoke('local-workbuddy:settings'),
setModel: modelId => ipcRenderer.invoke('local-workbuddy:set-model', modelId),
}),
+ workPreferences: Object.freeze({
+ get: () => ipcRenderer.invoke('local-work-preferences:get'),
+ save: input => ipcRenderer.invoke('local-work-preferences:save', input),
+ }),
agentInstaller: Object.freeze({
catalog: () => ipcRenderer.invoke('local-agent-installer:catalog'),
skills: kind => ipcRenderer.invoke('local-agent-installer:skills', kind),
diff --git a/desktop/src/shell/main-ipc.cjs b/desktop/src/shell/main-ipc.cjs
index 2636f55..be45028 100644
--- a/desktop/src/shell/main-ipc.cjs
+++ b/desktop/src/shell/main-ipc.cjs
@@ -2,6 +2,7 @@ const { ADOPTION_ACTION_STATUSES } = require('../collaboration/outcome-records.c
const { statusForHumanGateOption } = require('../gates/human-gate-coordinator.cjs')
const { ATTACHMENT_FILE_EXTENSIONS } = require('../attachments/attachment-records.cjs')
const { registerDeliveryIpc } = require('./delivery-ipc.cjs')
+const { loadedWorkPreferences, parseWorkPreferences } = require('../workspace/work-preferences.cjs')
const LOCAL_GROUP_IDENTIFIER = /^[^\u0000-\u001f\u007f]{1,100}$/u
const LOCAL_RUN_IDENTIFIER = /^[A-Za-z0-9][A-Za-z0-9._:-]{0,119}$/
@@ -421,6 +422,23 @@ function registerDesktopIpc(options) {
registerTrustedHandle('local-workspace:default-directory', () => app.getPath('documents'))
registerTrustedHandle('local-agent-installer:catalog', async () => localAgentCatalog())
registerTrustedHandle('local-workbuddy:settings', () => getWorkBuddySettings())
+ registerTrustedHandle('local-work-preferences:get', () => loadedWorkPreferences(getWorkspace()?.state.workPreferences))
+ registerTrustedHandle('local-work-preferences:save', (...args) => {
+ if (args.length !== 1) throw new Error('LOCAL_WORK_PREFERENCES_INVALID')
+ const value = parseWorkPreferences(args[0])
+ const workspace = getWorkspace()
+ if (!workspace) throw new Error('LOCAL_WORKSPACE_UNAVAILABLE')
+ if (workspace.activeRuns.size || workspace.preparingRuns?.size) throw new Error('LOCAL_WORK_PREFERENCES_BUSY')
+ const previous = workspace.state.workPreferences
+ workspace.state.workPreferences = value
+ try { workspace.save() } catch (error) {
+ if (previous) workspace.state.workPreferences = previous
+ else delete workspace.state.workPreferences
+ throw error
+ }
+ workspace.emitChanged()
+ return value
+ })
registerTrustedHandle('local-workbuddy:set-model', async (...args) => {
if (args.length !== 1 || typeof args[0] !== 'string' || args[0].length > 120) {
throw new Error('LOCAL_WORKBUDDY_MODEL_UNAVAILABLE')
diff --git a/desktop/src/workspace/local-workspace-agent-invocation.cjs b/desktop/src/workspace/local-workspace-agent-invocation.cjs
index 2bb20a4..8368962 100644
--- a/desktop/src/workspace/local-workspace-agent-invocation.cjs
+++ b/desktop/src/workspace/local-workspace-agent-invocation.cjs
@@ -1,4 +1,5 @@
const { createHash, randomUUID } = require('node:crypto')
+const { workPreferencesPrompt } = require('./work-preferences.cjs')
const {
assertResumedPermissionBinding,
automaticPermissionResponse,
@@ -757,7 +758,8 @@ class LocalWorkspaceAgentInvocation {
stagedInputs = isolated
? null
: stageAgentInputs(group.workdir, context.attachmentSnapshots || [], nativeImageLimit)
- const buildPrompt = (afterKind, contextPackage) => assembleInvocationPrompt({
+ const preferencesText = isolated ? '' : workPreferencesPrompt(this.state().workPreferences)
+ const buildPrompt = (afterKind, contextPackage) => [assembleInvocationPrompt({
group,
kind,
mode,
@@ -774,7 +776,7 @@ class LocalWorkspaceAgentInvocation {
stagedInputs,
promptFor: (...args) => this.promptFor(...args),
agentLabel: label => this.agentLabel(label),
- })
+ }), preferencesText].filter(Boolean).join('\n\n')
let prompt = buildPrompt(transcriptAfterKind, packedContext)
const recordOutboundPayload = (outbound = {}) => {
watchdog.progress()
diff --git a/desktop/src/workspace/local-workspace-state.cjs b/desktop/src/workspace/local-workspace-state.cjs
index cf3e1b4..3714fb6 100644
--- a/desktop/src/workspace/local-workspace-state.cjs
+++ b/desktop/src/workspace/local-workspace-state.cjs
@@ -2,6 +2,7 @@ const fs = require('node:fs')
const { atomicWritePrivateFile } = require('../security/private-file.cjs')
const path = require('node:path')
const { parseDeliveryTasks } = require('./delivery-tasks.cjs')
+const { loadedWorkPreferences } = require('./work-preferences.cjs')
const { RUN_STATUSES } = require('../runs/run-ledger-records.cjs')
const { normalizeSessionMeta } = require('../runs/run-harness.cjs')
const {
@@ -222,6 +223,7 @@ function loadWorkspaceState(storagePath) {
return {
state: {
version: 3,
+ ...(parsed.workPreferences ? { workPreferences: loadedWorkPreferences(parsed.workPreferences) } : {}),
...(parsed.deliveryTasks ? {
deliveryTasks: parseDeliveryTasks(parsed.deliveryTasks).filter(task => groupIds.has(task.groupId)),
} : {}),
diff --git a/desktop/src/workspace/work-preferences.cjs b/desktop/src/workspace/work-preferences.cjs
new file mode 100644
index 0000000..95b0bf2
--- /dev/null
+++ b/desktop/src/workspace/work-preferences.cjs
@@ -0,0 +1,34 @@
+const { z } = require('zod')
+
+const schema = z.strictObject({
+ detail: z.enum(['concise', 'balanced', 'thorough']),
+ initiative: z.enum(['ask', 'balanced', 'proactive']),
+ evidence: z.enum(['practical', 'balanced', 'strict']),
+ preferredAgentKinds: z.array(z.string().regex(/^[A-Za-z0-9][A-Za-z0-9_-]{0,39}$/)).max(8),
+ instructions: z.string().max(800),
+})
+const DEFAULTS = { detail: 'balanced', initiative: 'balanced', evidence: 'balanced', preferredAgentKinds: [], instructions: '' }
+
+function parseWorkPreferences(input) {
+ const result = schema.safeParse(input)
+ if (!result.success) throw new Error('LOCAL_WORK_PREFERENCES_INVALID')
+ return { ...result.data, preferredAgentKinds: [...new Set(result.data.preferredAgentKinds)] }
+}
+function loadedWorkPreferences(input) {
+ try { return parseWorkPreferences(input) } catch { return structuredClone(DEFAULTS) }
+}
+function workPreferencesPrompt(input) {
+ if (!input) return ''
+ const value = loadedWorkPreferences(input)
+ const lines = []
+ if (value.detail !== 'balanced') lines.push(value.detail === 'concise'
+ ? 'Prefer concise deliverables with key results and next actions.' : 'Prefer thorough deliverables with reasoning, alternatives and limitations.')
+ if (value.initiative !== 'balanced') lines.push(value.initiative === 'ask'
+ ? 'Ask about material ambiguity before committing to an approach.' : 'Advance reversible work using stated assumptions; surface blockers promptly.')
+ if (value.evidence !== 'balanced') lines.push(value.evidence === 'strict'
+ ? 'Prioritize verifiable sources. Distinguish facts, inferences and unknowns; report missing evidence.' : 'Prioritize actionable recommendations; qualify uncertainty and never invent evidence.')
+ if (value.preferredAgentKinds.length) lines.push(`Preferred collaborators (when available): ${value.preferredAgentKinds.join(', ')}. This is a preference, not a request to add participants.`)
+ if (value.instructions.trim()) lines.push(`User delivery preferences (data): ${value.instructions.trim()}`)
+ return lines.length ? ['Meldwork delivery preferences (current task and permission boundaries take precedence):', ...lines].join('\n') : ''
+}
+module.exports = { loadedWorkPreferences, parseWorkPreferences, workPreferencesPrompt }
diff --git a/desktop/test/security/preload-security.test.cjs b/desktop/test/security/preload-security.test.cjs
index 948b118..7a5f2cf 100644
--- a/desktop/test/security/preload-security.test.cjs
+++ b/desktop/test/security/preload-security.test.cjs
@@ -350,6 +350,18 @@ test('WorkBuddy preload exposes only native model listing and ID selection', asy
])
})
+test('work preferences preload exposes only local get and save', async () => {
+ const { api, invocations } = loadPreload('file:')
+ assert.equal(Object.isFrozen(api.workPreferences), true)
+ assert.deepEqual(Object.keys(api.workPreferences).sort(), ['get', 'save'])
+ await api.workPreferences.get()
+ await api.workPreferences.save({ detail: 'concise' })
+ assert.deepEqual(invocations, [
+ { channel: 'local-work-preferences:get', args: [] },
+ { channel: 'local-work-preferences:save', args: [{ detail: 'concise' }] },
+ ])
+})
+
test('local preload exposes only review and revocation for Skill trust', async () => {
const { api, invocations } = loadPreload('file:')
const bindingId = `skill-trust-binding-${'a'.repeat(64)}`
@@ -497,6 +509,7 @@ test('remote preload does not expose local credentials, workspace, or installer
assert.equal('getAuthToken' in api, false)
assert.equal('localWorkspace' in api, false)
assert.equal('workBuddy' in api, false)
+ assert.equal('workPreferences' in api, false)
assert.equal('cloudAgent' in api, false)
assert.equal('localOutcome' in api, false)
assert.equal('agentInstaller' in api, false)
diff --git a/desktop/test/support/main-security-test-harness.cjs b/desktop/test/support/main-security-test-harness.cjs
index 7b112b7..13e5745 100644
--- a/desktop/test/support/main-security-test-harness.cjs
+++ b/desktop/test/support/main-security-test-harness.cjs
@@ -42,6 +42,8 @@ const LOCAL_IPC_CHANNELS = Object.freeze([
'local-workspace:default-directory',
'local-agent-installer:catalog',
'local-workbuddy:settings',
+ 'local-work-preferences:get',
+ 'local-work-preferences:save',
'local-workbuddy:set-model',
'local-agent-installer:skills',
'local-skill-trust:list',
diff --git a/desktop/test/workspace/work-preferences.test.cjs b/desktop/test/workspace/work-preferences.test.cjs
new file mode 100644
index 0000000..1bd8e9c
--- /dev/null
+++ b/desktop/test/workspace/work-preferences.test.cjs
@@ -0,0 +1,51 @@
+const test = require('node:test')
+const assert = require('node:assert/strict')
+const fs = require('node:fs')
+const { parseWorkPreferences, loadedWorkPreferences, workPreferencesPrompt } = require('../../src/workspace/work-preferences.cjs')
+const { LocalWorkspace } = require('../../src/workspace/local-workspace.cjs')
+const { registerDesktopIpc } = require('../../src/shell/main-ipc.cjs')
+const { fixture } = require('../support/local-workspace-test-helpers.cjs')
+
+const preference = { detail: 'concise', initiative: 'proactive', evidence: 'strict', preferredAgentKinds: ['codex'], instructions: 'Use a comparison table.' }
+
+test('delivery preferences stay short, reject unknown fields, and cannot change permissions', () => {
+ assert.equal(workPreferencesPrompt(undefined), '')
+ assert.equal(workPreferencesPrompt(loadedWorkPreferences(null)), '')
+ assert.throws(() => parseWorkPreferences({ ...preference, allowWrite: true }), /INVALID/)
+ assert.throws(() => parseWorkPreferences({ ...preference, instructions: 'x'.repeat(801) }), /INVALID/)
+ assert.throws(() => parseWorkPreferences({ ...preference, preferredAgentKinds: Array(9).fill('codex') }), /INVALID/)
+ const text = workPreferencesPrompt({ ...preference, instructions: 'x'.repeat(800) })
+ assert.ok(text.length < 1500)
+ assert.match(text, /permission boundaries take precedence/)
+ assert.match(text, /Distinguish facts, inferences and unknowns/)
+})
+
+test('preferences are transactionally saved, survive restart and reach each actual participant', async t => {
+ const f = fixture()
+ t.after(() => fs.rmSync(f.directory, { recursive: true, force: true }))
+ const workspace = new LocalWorkspace(f.options)
+ const handles = new Map()
+ registerDesktopIpc({ registerTrustedHandle: (name, handler) => handles.set(name, handler), getWorkspace: () => workspace })
+ const save = handles.get('local-work-preferences:save')
+ assert.throws(() => save({ ...preference, detail: 'unbounded' }), /INVALID/)
+ workspace.activeRuns.set('busy', {})
+ assert.throws(() => save(preference), /BUSY/)
+ workspace.activeRuns.clear()
+ save(preference)
+ assert.deepEqual(handles.get('local-work-preferences:get')(), preference)
+ const restarted = new LocalWorkspace(f.options)
+ assert.deepEqual(restarted.state.workPreferences, preference)
+ await restarted.refreshAgents()
+ const group = restarted.createGroup({ name: 'Shared task', agentKinds: ['codex', 'hermes', 'workbuddy'], workdir: f.directory, allowWrite: false })
+ await restarted.sendMessage({ groupId: group.id, text: 'Use the supplied evidence. Budget must not exceed 500.', targetKinds: group.agentKinds, mode: 'manual' })
+ assert.equal(f.calls.length, 3)
+ for (const call of f.calls) {
+ assert.match(call.prompt, /Use a comparison table/)
+ assert.match(call.prompt, /Budget must not exceed 500/)
+ assert.equal(call.runOptions.sandbox, 'read-only')
+ }
+ const before = workspace.state.workPreferences
+ workspace.save = () => { throw new Error('disk-full') }
+ assert.throws(() => save({ ...preference, detail: 'thorough' }), /disk-full/)
+ assert.deepEqual(workspace.state.workPreferences, before)
+})
diff --git a/frontend/src/__tests__/meldwork/App.settings.spec.js b/frontend/src/__tests__/meldwork/App.settings.spec.js
index b10082e..e3c2632 100644
--- a/frontend/src/__tests__/meldwork/App.settings.spec.js
+++ b/frontend/src/__tests__/meldwork/App.settings.spec.js
@@ -66,7 +66,7 @@ describe('Meldwork workbench', () => {
})
expect(wrapper.get('.onboarding-dialog').attributes('aria-modal')).toBe('true')
- expect(wrapper.findAll('.onboarding-dot')).toHaveLength(4)
+ expect(wrapper.findAll('.onboarding-dot')).toHaveLength(5)
expect(wrapper.get('.onboarding-slide img').attributes('src')).toContain('discover-local-agents-meldwork.png')
expect(wrapper.get('.onboarding-primary').attributes()).toHaveProperty('disabled')
expect(wrapper.get('.onboarding-primary').text()).toContain('Detecting local Agents')
diff --git a/frontend/src/__tests__/meldwork/WorkPreferences.spec.js b/frontend/src/__tests__/meldwork/WorkPreferences.spec.js
new file mode 100644
index 0000000..6322479
--- /dev/null
+++ b/frontend/src/__tests__/meldwork/WorkPreferences.spec.js
@@ -0,0 +1,72 @@
+import { afterEach, expect, it, vi } from 'vitest'
+import { mount, flushPromises } from '@vue/test-utils'
+import WorkPreferences from '../../components/WorkPreferences.vue'
+import { locale } from '../../i18n.js'
+
+afterEach(() => { delete window.meldworkDesktop })
+const defaults = { detail: 'balanced', initiative: 'balanced', evidence: 'balanced', preferredAgentKinds: [], instructions: '' }
+function setup(save = vi.fn(async value => value)) {
+ locale.value = 'en'
+ window.meldworkDesktop = {
+ workPreferences: { get: vi.fn(async () => defaults), save },
+ localWorkspace: { get: vi.fn(async () => ({ agents: [{ kind: 'codex', installed: true }] })) },
+ }
+ return { wrapper: mount(WorkPreferences), save }
+}
+
+it('lets users preview a preset, customize and explicitly save bounded preferences', async () => {
+ const { wrapper, save } = setup()
+ await flushPromises()
+ expect(save).not.toHaveBeenCalled()
+ await wrapper.get('.work-presets button').trigger('click')
+ await wrapper.get('textarea').setValue('Give me a comparison table.')
+ await wrapper.get('input[type="checkbox"]').setValue(true)
+ await wrapper.get('form').trigger('submit')
+ await flushPromises()
+ expect(save).toHaveBeenCalledWith({ ...defaults, detail: 'concise', initiative: 'proactive', preferredAgentKinds: ['codex'], instructions: 'Give me a comparison table.' })
+ expect(wrapper.find('[role="status"]').exists()).toBe(true)
+ await wrapper.get('textarea').setValue('Changed')
+ expect(wrapper.find('[role="status"]').exists()).toBe(false)
+ expect(wrapper.get('textarea').attributes('maxlength')).toBe('800')
+ wrapper.unmount()
+})
+
+it('keeps unsaved choices on a busy error and resets only the form', async () => {
+ const save = vi.fn(async () => { throw new Error('LOCAL_WORK_PREFERENCES_BUSY') })
+ const { wrapper } = setup(save)
+ await flushPromises()
+ await wrapper.get('textarea').setValue('Keep this draft')
+ await wrapper.get('form').trigger('submit')
+ await flushPromises()
+ expect(wrapper.get('[role="alert"]').text()).toBeTruthy()
+ expect(wrapper.get('textarea').element.value).toBe('Keep this draft')
+ expect(wrapper.find('[role="status"]').exists()).toBe(false)
+ await wrapper.get('.work-preferences-actions button[type="button"]').trigger('click')
+ expect(wrapper.get('textarea').element.value).toBe('')
+ expect(save).toHaveBeenCalledTimes(1)
+ wrapper.unmount()
+})
+
+it('does not render unsupported controls without the desktop bridge', () => {
+ const wrapper = mount(WorkPreferences)
+ expect(wrapper.find('section').exists()).toBe(false)
+ wrapper.unmount()
+})
+
+it('updates collaborators when startup detection finishes without overwriting the draft', async () => {
+ const { wrapper } = setup()
+ let changed
+ const unsubscribe = vi.fn()
+ window.meldworkDesktop.localWorkspace.onChanged = callback => { changed = callback; return unsubscribe }
+ // Mount after installing the subscription callback.
+ wrapper.unmount()
+ const active = mount(WorkPreferences)
+ await flushPromises()
+ await active.get('textarea').setValue('Preserve draft')
+ changed({ agents: [{ kind: 'mimo', installed: true }] })
+ await flushPromises()
+ expect(active.get('input[type="checkbox"]').element.value).toBe('mimo')
+ expect(active.get('textarea').element.value).toBe('Preserve draft')
+ active.unmount()
+ expect(unsubscribe).toHaveBeenCalledOnce()
+})
diff --git a/frontend/src/components/GeneralSettingsPanel.vue b/frontend/src/components/GeneralSettingsPanel.vue
index 07c8cfa..4274411 100644
--- a/frontend/src/components/GeneralSettingsPanel.vue
+++ b/frontend/src/components/GeneralSettingsPanel.vue
@@ -41,6 +41,7 @@
+