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 @@ + @@ -48,6 +49,7 @@ import { computed, unref } from 'vue' import { DesktopOutline, MoonOutline, SunnyOutline } from '@vicons/ionicons5' import { locale as currentLocale, setLocale, t } from '../i18n.js' +import WorkPreferences from './WorkPreferences.vue' const props = defineProps({ setThemePreference: { type: Function, required: true }, diff --git a/frontend/src/components/OnboardingDialog.vue b/frontend/src/components/OnboardingDialog.vue index 60adc45..ca8b991 100644 --- a/frontend/src/components/OnboardingDialog.vue +++ b/frontend/src/components/OnboardingDialog.vue @@ -12,11 +12,12 @@ >
-
- +
+

{{ onboardingSlide.title }}

{{ onboardingSlide.body }}

+
@@ -58,6 +59,7 @@ + + diff --git a/frontend/src/components/WorkPreferences.vue b/frontend/src/components/WorkPreferences.vue new file mode 100644 index 0000000..82f54c5 --- /dev/null +++ b/frontend/src/components/WorkPreferences.vue @@ -0,0 +1,123 @@ +