From 4955f40e353b72731074dc57fd6e7be3a7a2d0b6 Mon Sep 17 00:00:00 2001 From: Adrian Webb Date: Sat, 19 Sep 2026 14:11:31 -0400 Subject: [PATCH 1/9] Route host lifecycle over local manager and test CLI envelopes --- src/cli/commands/host.ts | 1 + .../command-boundary/canonical-client.test.ts | 23 +++++++++++++++++++ 2 files changed, 24 insertions(+) diff --git a/src/cli/commands/host.ts b/src/cli/commands/host.ts index c2fadd7..336c9e4 100644 --- a/src/cli/commands/host.ts +++ b/src/cli/commands/host.ts @@ -228,6 +228,7 @@ async function input(invocation: ParsedInvocation, context: CommandContext) { export function hostUsesProtectedLocalTransport(invocation: Pick) { return invocation.command.name === 'host initialize' || invocation.command.name === 'host config adopt' || invocation.command.name === 'host bootstrap enroll' + || invocation.command.name === 'host start' || invocation.command.name === 'host stop' || invocation.command.name === 'host config stage' || invocation.command.name === 'host reset' || invocation.command.name === 'host uninstall' || invocation.command.name.startsWith('host storage ') || invocation.command.name.startsWith('host security ') || invocation.command.name.startsWith('host sandbox ') || invocation.command.name.startsWith('host postgres ') diff --git a/tests/unit/command-boundary/canonical-client.test.ts b/tests/unit/command-boundary/canonical-client.test.ts index c178ee2..9d31396 100644 --- a/tests/unit/command-boundary/canonical-client.test.ts +++ b/tests/unit/command-boundary/canonical-client.test.ts @@ -64,6 +64,19 @@ test('host commands preserve the SDK handler boundary and stable envelope', asyn assert.deepEqual(JSON.parse(output[0]!).result, { componentId: 'agent', healthy: true }); }); +test('host lifecycle commands preserve plan/noop/result envelopes and never need a remote server', async () => { + const calls: unknown[] = []; const output: string[] = []; + const invoke = async (input: unknown) => { calls.push(input); return { state: 'stopped', changed: false }; }; + for (const action of ['stop', 'start'] as const) { + for (const plan of [true, false]) { + const args = ['host', action, ...(plan ? ['--plan'] : ['--yes']), '--json']; + assert.equal(await runCommandLine(args, { interactiveUi: false, hostInvoke: invoke, write: (value) => output.push(value) }), 0); + assert.deepEqual(calls.at(-1), { handlerId: `local.host.${action}`, arguments: [], options: plan ? { plan: true } : {} }); + assert.deepEqual(JSON.parse(output.at(-1)!).result, { state: 'stopped', changed: false }); + } + } +}); + test('AI mode commands use the same bounded host-manager authority', async () => { const calls: unknown[] = []; const output: string[] = []; const exit = await runCommandLine(['ai', 'mode', 'set', 'sleep', '--idempotency-key', 'cycle-1', '--drain-timeout', '120', '--yes', '--json'], { @@ -88,6 +101,13 @@ test('host configuration adoption sends validated content and requires explicit assert.equal(calls[0]?.options.confirm, true); assert.equal(calls[0]?.configuration.configurationId, 'development-workstation'); assert.deepEqual(calls[0]?.arguments, []); + const staged = await runCommandLine(['host', 'config', 'stage', file, '--yes', '--json'], { + interactiveUi: false, hostInvoke: async (value) => { calls.push(value); return { staged: true, lifecycle: 'stopped' }; }, write() {}, + }); + assert.equal(staged, 0); + assert.equal(calls.at(-1)?.handlerId, 'local.host.config.stage'); + assert.equal(calls.at(-1)?.configuration.configurationId, 'development-workstation'); + assert.equal(calls.at(-1)?.configuration.generation, 1); } finally { rmSync(root, { recursive: true, force: true }); } }); @@ -96,6 +116,9 @@ test('host identity adoption is permanently bound to the protected local socket' assert.equal(hostUsesProtectedLocalTransport({ command: { name: 'host config adopt' } as any }), true); assert.equal(hostUsesProtectedLocalTransport({ command: { name: 'host reset' } as any }), true); assert.equal(hostUsesProtectedLocalTransport({ command: { name: 'host config apply' } as any }), false); + for (const name of ['host start', 'host stop', 'host config stage']) { + assert.equal(hostUsesProtectedLocalTransport({ command: { name } as any }), true, name); + } }); test('host storage connect derives the active team and keeps bootstrap authority out of output', async () => { From 2a9d48fc66ff99800631bf1afbdc24ae642b2d23 Mon Sep 17 00:00:00 2001 From: Adrian Webb Date: Sat, 19 Sep 2026 14:21:38 -0400 Subject: [PATCH 2/9] Coordinate host stop and start with development session custody --- src/cli/commands/development.ts | 61 +++++++++++-------- src/cli/commands/host.ts | 21 +++++++ .../command-boundary/canonical-client.test.ts | 53 +++++++++++++++- .../development/lifecycle-entrypoints.test.ts | 24 +++++++- 4 files changed, 127 insertions(+), 32 deletions(-) diff --git a/src/cli/commands/development.ts b/src/cli/commands/development.ts index f620e95..aaeb8a0 100644 --- a/src/cli/commands/development.ts +++ b/src/cli/commands/development.ts @@ -397,6 +397,10 @@ export async function resumeDevelopmentSession(sessionId: string, context: Comma return withDevelopmentLifecycle(context.env, () => resumeDevelopmentUnlocked(sessionId, context), { waitForOwner: true }); } +export async function suspendDevelopmentSession(sessionId: string, context: CommandContext) { + return withDevelopmentLifecycle(context.env, () => closeDevelopmentSession(loadState(context.env, sessionId), sessionId, context, false)); +} + async function resumeDevelopmentUnlocked(sessionId: string, context: CommandContext) { if (!/^dev-[a-z0-9-]{1,64}$/.test(sessionId)) throw new Error('An exact development session is required.'); loadState(context.env, sessionId); @@ -411,6 +415,35 @@ async function resumeDevelopmentUnlocked(sessionId: string, context: CommandCont } } +async function closeDevelopmentSession(state: LocalSessionState, sessionId: string, context: CommandContext, permanent: boolean) { + const isSelected = JSON.parse(readFileSync(statePath(context.env), 'utf8')).sessionId === sessionId; + const record = await invoke(context, 'local.dev.status', { sessionId, all: false }) as DevelopmentStatusRecord; + const running = await stopProcesses(state); + const active = new Set(running.map((entry) => `${entry.projectId}.${entry.targetId}`)); + for (const selected of record.session.targets) { + const { runtime, target } = selectedTarget(record, selected.projectId, selected.targetId); + const repository = record.session.repositories.find((entry) => entry.projectId === selected.projectId); + if (usesManagedContainer(target)) { + let registered = true; + try { + const status = await containerOperation(context, sessionId, runtime, target, 'status') as { registered?: unknown }; + registered = status.registered === true; + } catch { + // An unhealthy registered application may reject status; stopping it is the recovery path. + } + if (registered) { + try { await containerOperation(context, sessionId, runtime, target, 'stop'); } + catch { /* Session closure must remain available when an unhealthy container cannot stop itself. */ } + } + } else if (repository && active.has(`${runtime.project.id}.${target.id}`) && target.operations.cleanup) + runOneShotOperation(state, target.operations.cleanup, repository.worktree, 'released', context.env, { TREESEED_DEVELOPMENT_CLEANUP_SCOPE: 'session' }); + } + restoreOverlays(state); + if (isSelected) selectDevelopmentCli(context.env, null); + saveState(state, context.env, isSelected); + return invoke(context, permanent ? 'local.dev.session.stop' : 'local.dev.session.suspend', { sessionId }); +} + export async function runDevelopment(invocation: ParsedInvocation, context: CommandContext) { if (invocation.options.plan === true || ['dev status', 'dev logs', 'dev plan', 'dev host status'].includes(invocation.command.name)) return runDevelopmentUnlocked(invocation, context); return withDevelopmentLifecycle(context.env, () => runDevelopmentUnlocked(invocation, context)); @@ -439,33 +472,7 @@ async function runDevelopmentUnlocked(invocation: ParsedInvocation, context: Com const state = loadState(context.env,invocation.options.session), sessionId = String(invocation.options.session ?? state.sessionId); if (invocation.command.name === 'dev session stop') { if (invocation.options.plan === true) return { sessionId, restore: true, mutation: false }; - const isSelected = JSON.parse(readFileSync(statePath(context.env), 'utf8')).sessionId === sessionId; - const record = await invoke(context, 'local.dev.status', { sessionId, all: false }) as DevelopmentStatusRecord; - const running = await stopProcesses(state); - const active = new Set(running.map((entry) => `${entry.projectId}.${entry.targetId}`)); - for (const selected of record.session.targets) { - const { runtime, target } = selectedTarget(record, selected.projectId, selected.targetId); - const repository = record.session.repositories.find((entry) => entry.projectId === selected.projectId); - if (usesManagedContainer(target)) { - let registered = true; - try { - const status = await containerOperation(context, sessionId, runtime, target, 'status') as { registered?: unknown }; - registered = status.registered === true; - } catch { - // An unhealthy registered application may reject status; stopping it is the recovery path. - } - if (registered) { - try { - await containerOperation(context, sessionId, runtime, target, 'stop'); - } catch { - // Session closure is authoritative and must remain available when an unhealthy - // application cannot complete its own stop operation. - } - } - } - else if (repository && active.has(`${runtime.project.id}.${target.id}`) && target.operations.cleanup) runOneShotOperation(state, target.operations.cleanup, repository.worktree, 'released', context.env, { TREESEED_DEVELOPMENT_CLEANUP_SCOPE: 'session' }); - } - restoreOverlays(state); if (isSelected) selectDevelopmentCli(context.env, null); saveState(state, context.env, isSelected); return invoke(context, 'local.dev.session.stop', { sessionId }); + return closeDevelopmentSession(state, sessionId, context, true); } if (invocation.command.name === 'dev status') return invoke(context, 'local.dev.status', { ...(invocation.options.session ? { sessionId } : {}), all: invocation.options.all === true }); if (invocation.command.name === 'dev plan') return invoke(context, 'local.dev.plan', { sessionId, selected: [] }); diff --git a/src/cli/commands/host.ts b/src/cli/commands/host.ts index 336c9e4..2174bd6 100644 --- a/src/cli/commands/host.ts +++ b/src/cli/commands/host.ts @@ -9,6 +9,8 @@ import { existsSync, readFileSync } from 'node:fs'; import { resolve } from 'node:path'; import { hostConfigurationSchema } from '@treeseed/sdk/deployment'; import { readPostgresTransferSelection } from './host/postgres-transfer.js'; +import { developmentStateRoot } from './development-cli-selection.js'; +import { invokeDevelopmentManager } from './development-support/manager/invoke.js'; const cloudflareSetupGuide = `Cloudflare R2 setup @@ -248,6 +250,25 @@ export async function runHost(invocation: ParsedInvocation, context: CommandCont const invoke = () => context.hostInvoke ? context.hostInvoke(command) : hostUsesProtectedLocalTransport(invocation) ? invokeLocalHostManager(command) : invokeHostManager(command, typeof invocation.options.server === 'string' ? invocation.options.server : undefined, context.env); + if (invocation.options.plan !== true && (invocation.command.name === 'host stop' || invocation.command.name === 'host start')) { + const listed = await invokeDevelopmentManager(context, 'local.dev.status', { all: true }) as { sessions?: Array<{ session: { sessionId: string; status: string; targets: Array<{ mode: string }> } }> }; + if (!Array.isArray(listed.sessions)) throw new Error('Manager did not return an authoritative development session inventory.'); + const selected = listed.sessions.filter(record => record.session.status !== 'stopped' + && (invocation.command.name === 'host stop' || record.session.status === 'suspended') + && record.session.targets.some(target => target.mode !== 'released')); + const snapshots = selected.map(record => ({ sessionId: record.session.sessionId, status: record.session.status })); + for (const { sessionId } of snapshots) if (!/^dev-[a-z0-9-]{1,64}$/u.test(sessionId) + || !existsSync(resolve(developmentStateRoot(context.env), sessionId, 'session.json'))) + throw new Error(`Development session ${sessionId} requires its original owner to manage the host lifecycle; no workloads were changed.`); + const { suspendDevelopmentSession, resumeDevelopmentSession } = await import('./development.js'); + if (invocation.command.name === 'host stop') { + for (const { sessionId, status } of snapshots) if (status !== 'suspended') await suspendDevelopmentSession(sessionId, context); + return invoke(); + } + const result = await invoke(); + for (const { sessionId, status } of snapshots) if (status === 'suspended') await resumeDevelopmentSession(sessionId, context); + return result; + } const progressLabel = context.outputFormat === 'human' && invocation.options.plan !== true ? ({ 'host initialize': 'Initializing the selected TreeSeed host profile', 'host storage connect': 'Connecting Cloudflare R2. Provisioning storage, securing credentials, and reconciling the host', diff --git a/tests/unit/command-boundary/canonical-client.test.ts b/tests/unit/command-boundary/canonical-client.test.ts index 9d31396..1619efe 100644 --- a/tests/unit/command-boundary/canonical-client.test.ts +++ b/tests/unit/command-boundary/canonical-client.test.ts @@ -1,5 +1,5 @@ import assert from 'node:assert/strict'; -import { existsSync, mkdtempSync, readFileSync, rmSync, writeFileSync } from 'node:fs'; +import { chmodSync, existsSync, mkdirSync, mkdtempSync, readFileSync, rmSync, writeFileSync } from 'node:fs'; import { createServer } from 'node:http'; import { tmpdir } from 'node:os'; import { resolve } from 'node:path'; @@ -66,17 +66,62 @@ test('host commands preserve the SDK handler boundary and stable envelope', asyn test('host lifecycle commands preserve plan/noop/result envelopes and never need a remote server', async () => { const calls: unknown[] = []; const output: string[] = []; - const invoke = async (input: unknown) => { calls.push(input); return { state: 'stopped', changed: false }; }; + const invoke = async (input: unknown) => { calls.push(input); return (input as { handlerId: string }).handlerId === 'local.dev.status' + ? { sessions: [] } : { state: 'stopped', changed: false }; }; for (const action of ['stop', 'start'] as const) { for (const plan of [true, false]) { const args = ['host', action, ...(plan ? ['--plan'] : ['--yes']), '--json']; assert.equal(await runCommandLine(args, { interactiveUi: false, hostInvoke: invoke, write: (value) => output.push(value) }), 0); assert.deepEqual(calls.at(-1), { handlerId: `local.host.${action}`, arguments: [], options: plan ? { plan: true } : {} }); + if (!plan) assert.deepEqual(calls.at(-2), { handlerId: 'local.dev.status', arguments: [], options: { payload: '{"all":true}' } }); assert.deepEqual(JSON.parse(output.at(-1)!).result, { state: 'stopped', changed: false }); } } }); +test('host stop suspends a selected development session before stopping released workloads', async () => { + const root = mkdtempSync(resolve(tmpdir(), 'treeseed-cli-host-stop-')); + const env = { XDG_STATE_HOME: root }; + const stateRoot = resolve(root, 'treeseed', 'development'), sessionId = 'dev-test-session'; + const local = { sessionId, manifest: '/fixture/manifest.yaml', processes: {}, overlays: [], candidates: [] }; + const record = { session: { sessionId, status: 'active', targets: [{ projectId: 'admin', targetId: 'web', mode: 'live' }], repositories: [] }, + runtimes: [{ project: { id: 'admin' }, targets: [{ id: 'web', kind: 'source-check', operations: {}, endpoints: [] }] }] }; + mkdirSync(resolve(stateRoot, sessionId), { recursive: true }); + for (const path of [resolve(root, 'treeseed'), stateRoot, resolve(stateRoot, sessionId)]) chmodSync(path, 0o700); + writeFileSync(resolve(stateRoot, 'current.json'), JSON.stringify(local), { mode: 0o600 }); + writeFileSync(resolve(stateRoot, sessionId, 'session.json'), JSON.stringify(local), { mode: 0o600 }); + const calls: string[] = []; + const output: string[] = []; + try { + const exit = await runCommandLine(['host', 'stop', '--yes', '--json'], { env, interactiveUi: false, write: value => output.push(value), + hostInvoke: async (request: any) => { + calls.push(request.handlerId); + if (request.handlerId === 'local.dev.status') return JSON.parse(request.options.payload).all ? { sessions: [record] } : record; + if (request.handlerId === 'local.dev.session.suspend') return { ...record, session: { ...record.session, status: 'suspended' } }; + return { state: 'stopped', changed: true }; + }, + }); + assert.equal(exit, 0, output.join('')); + assert.deepEqual(calls, ['local.dev.status', 'local.dev.status', 'local.dev.session.suspend', 'local.host.stop']); + assert.equal(JSON.parse(readFileSync(resolve(stateRoot, sessionId, 'session.json'), 'utf8')).sessionId, sessionId); + } finally { rmSync(root, { recursive: true, force: true }); } +}); + +test('host stop fails before mutation when another owner holds a live development selection', async () => { + const root = mkdtempSync(resolve(tmpdir(), 'treeseed-cli-host-owner-')); + const calls: string[] = []; const output: string[] = []; + try { + const exit = await runCommandLine(['host', 'stop', '--yes', '--json'], { env: { XDG_STATE_HOME: root }, interactiveUi: false, + write: value => output.push(value), hostInvoke: async (request: any) => { + calls.push(request.handlerId); + return { sessions: [{ session: { sessionId: 'dev-other-owner', status: 'active', targets: [{ mode: 'live' }] } }] }; + } }); + assert.equal(exit, 1); + assert.deepEqual(calls, ['local.dev.status']); + assert.match(output.join(''), /original owner/u); + } finally { rmSync(root, { recursive: true, force: true }); } +}); + test('AI mode commands use the same bounded host-manager authority', async () => { const calls: unknown[] = []; const output: string[] = []; const exit = await runCommandLine(['ai', 'mode', 'set', 'sleep', '--idempotency-key', 'cycle-1', '--drain-timeout', '120', '--yes', '--json'], { @@ -108,6 +153,10 @@ test('host configuration adoption sends validated content and requires explicit assert.equal(calls.at(-1)?.handlerId, 'local.host.config.stage'); assert.equal(calls.at(-1)?.configuration.configurationId, 'development-workstation'); assert.equal(calls.at(-1)?.configuration.generation, 1); + assert.equal(await runCommandLine(['host', 'config', 'stage', resolve(root, 'missing.json'), '--yes', '--json'], { + interactiveUi: false, hostInvoke: async (value) => { calls.push(value); return {}; }, write() {}, + }), 1); + assert.equal(calls.length, 2); } finally { rmSync(root, { recursive: true, force: true }); } }); diff --git a/tests/unit/command-boundary/development/lifecycle-entrypoints.test.ts b/tests/unit/command-boundary/development/lifecycle-entrypoints.test.ts index 672f5f3..fe6d08b 100644 --- a/tests/unit/command-boundary/development/lifecycle-entrypoints.test.ts +++ b/tests/unit/command-boundary/development/lifecycle-entrypoints.test.ts @@ -4,6 +4,7 @@ import { mkdirSync, mkdtempSync, rmSync, writeFileSync } from 'node:fs'; import { tmpdir } from 'node:os'; import { resolve } from 'node:path'; import { resumeDevelopmentSession, runDevelopment } from '../../../../src/cli/commands/development.ts'; +import { runCommandLine } from '../../../../src/cli/runtime.ts'; import type { CommandContext, ParsedInvocation } from '../../../../src/cli/types.ts'; test('boot resume and manual use re-read state under the same lifecycle lock', { skip: process.platform !== 'linux' }, async () => { @@ -22,7 +23,10 @@ test('boot resume and manual use re-read state under the same lifecycle lock', { statePolicy: 'stateless', migrationPolicy: 'none', secretRefs: {}, shutdown: { graceSeconds: 1, activeWorkPolicy: 'block' }, resources: {}, logs: [], forbiddenOperations: [], promotion: { liveAdmissible: false, candidateRequiresVerification: true } }], } })); - writeFileSync(resolve(directory, 'current.json'), JSON.stringify({ sessionId, manifest, processes: {}, overlays: [], candidates: [] })); + const local = { sessionId, manifest, processes: {}, overlays: [], candidates: [] }; + writeFileSync(resolve(directory, 'current.json'), JSON.stringify(local), { mode: 0o600 }); + mkdirSync(resolve(directory, sessionId), { mode: 0o700 }); + writeFileSync(resolve(directory, sessionId, 'session.json'), JSON.stringify(local), { mode: 0o600 }); const record = { session: { sessionId, status: 'active', repositories: [{ projectId: 'api', worktree: root }], targets: [{ projectId: 'api', targetId: 'operations-runner', mode: 'candidate', generation: 0, health: 'pending' }] }, @@ -33,11 +37,12 @@ test('boot resume and manual use re-read state under the same lifecycle lock', { const context = { cwd: root, env, hostInvoke: async (request: { handlerId: string; options: { payload?: unknown } }) => { - const payload = JSON.parse(String(request.options.payload)); - if (request.handlerId === 'local.dev.status') return record; + const payload = request.options.payload ? JSON.parse(String(request.options.payload)) : {}; + if (request.handlerId === 'local.dev.status') return payload.all ? { sessions: [record] } : record; if (request.handlerId === 'local.dev.session.refresh') return record; if (request.handlerId === 'local.dev.use') { assert.equal(payload.port, undefined, 'Manager-custody targets must not receive redundant host-port readiness probes'); + record.session.status = 'active'; return record; } if (request.handlerId === 'local.dev.environment') return { environment: {} }; @@ -47,6 +52,10 @@ test('boot resume and manual use re-read state under the same lifecycle lock', { if (request.handlerId === 'local.dev.container' && payload.action === 'start') { starts++; await new Promise(resolve => setTimeout(resolve, 30)); started = true; return { started: true }; } + if (request.handlerId === 'local.dev.container' && payload.action === 'stop') { started = false; return { stopped: true }; } + if (request.handlerId === 'local.dev.session.suspend') { record.session.status = 'suspended'; return record; } + if (request.handlerId === 'local.host.stop') return { state: 'stopped', changed: true }; + if (request.handlerId === 'local.host.start') return { state: 'running', changed: true }; throw new Error(`Unexpected operation ${request.handlerId}`); }, } as CommandContext; @@ -56,5 +65,14 @@ test('boot resume and manual use re-read state under the same lifecycle lock', { assert.equal(starts, 1); await resumeDevelopmentSession(sessionId, context); assert.equal(starts, 1); + const lifecycleContext = { ...context, interactiveUi: false, write() {} }; + assert.equal(await runCommandLine(['host', 'stop', '--yes', '--json'], lifecycleContext), 0); + assert.equal(record.session.status, 'suspended'); + assert.equal(started, false); + assert.equal(await runCommandLine(['host', 'start', '--yes', '--json'], lifecycleContext), 0); + assert.equal(starts, 2, 'Host start must restore the exact suspended live target'); + assert.equal(record.session.status, 'active'); + await resumeDevelopmentSession(sessionId, context); + assert.equal(starts, 2, 'Repeating resume must not duplicate the managed container'); } finally { rmSync(root, { recursive: true, force: true }); } }); From ba84273bccda6a5c141542003d33cbe2307dce1e Mon Sep 17 00:00:00 2001 From: Adrian Webb Date: Sun, 20 Sep 2026 21:55:33 -0400 Subject: [PATCH 3/9] Align agent execution with living graph and allocation contracts --- completions/trsd.bash | 2 +- docs/command-reference.md | 86 +++- schemas/command-tree.json | 470 ++++++++++++++++-- src/cli/application/data.ts | 15 +- src/cli/commands/operator.ts | 9 +- tests/unit/application/workspaces.test.ts | 15 +- .../command-boundary/canonical-client.test.ts | 53 ++ 7 files changed, 567 insertions(+), 83 deletions(-) diff --git a/completions/trsd.bash b/completions/trsd.bash index 9891d2e..f9fd464 100644 --- a/completions/trsd.bash +++ b/completions/trsd.bash @@ -1,6 +1,6 @@ # Generated from treeseed.command-tree/v1. _trsd_complete() { - local paths="inbox\nsend\ntopics list\ntopics show\ntopics subscribe\ntopics unsubscribe\ncapabilities list\ncapabilities show\nauth login\nauth logout\nauth status\nusers create\nteams list\nteams current\nteams use\nproposals list\nproposals show\nproposals create\nproposals update\nproposals open\nproposals feedback resolve\nproposals voting start\nproposals vote\nproposals evaluate\ndecisions list\ndecisions show\nsecrets list\nsecrets status\nsecrets unlock\nsecrets lock\nservices credentials show\nservices credentials put\nservices credentials delete\nservices credentials validate\nplatform verify\nplatform workset\nplatform project create\nplatform topology plan\nplatform topology apply\nplatform topology status\nplatform topology rollback\ndev host activate\ndev host status\ndev host deactivate\ndev host guest image import\ndev session start\ndev session stop\ndev session recover\ndev use\ndev rebuild\ndev migrate\ndev restart\ndev status\ndev logs\ndev plan\ndev freeze\ndev verify\nhost initialize\nhost status\nhost doctor\nhost plan\nhost apply\nhost reconcile\nhost events\nhost config show\nhost config plan\nhost config apply\nhost config adopt\nhost postgres transfer prepare\nhost postgres transfer status\nhost topology\nhost connections\nhost provider status\nhost provider credentials list\nhost provider credentials status\nhost provider credentials initialize\nhost provider environment list\nhost provider environment show\nhost provider environment status\nhost provider environment set\nhost provider environment import\nhost provider environment unset\nhost provider environment rotate\nhost provider environment verify\nhost storage status\nhost storage connect\nhost storage reconcile\nhost storage rotate\nhost storage reset\nhost security plan\nhost security initialize\nhost security status\nhost security verify\nhost security rotate\nhost security recovery verify\nhost sandbox status\nhost sandbox doctor\nhost fleet status\nhost update status\nhost update check\nhost update apply\nhost update channel\nhost update pause\nhost update resume\nhost component list\nhost component status\nhost component enable\nhost component disable\nhost aliases list\nhost recovery status\nhost recovery retry\nhost recovery restore\nhost bootstrap status\nhost bootstrap enroll\nhost reset\nhost uninstall\nagents list\nagents show\nagents team clone plan\nagents team clone apply\nagents handlers list\nagents handlers show\nagents profiles show\nagents profiles validate\nagents classes list\nagents classes show\nproviders list\nproviders show\nproviders status\nproviders diagnose\nproviders connect\nproviders disconnect\nproviders registration code status\nproviders registration code reveal\nproviders registration code rotate\nproviders environments list\nproviders environments show\nproviders environments grant\nproviders environments revoke\nproviders requests list\nproviders requests show\nproviders requests approve\nproviders requests reject\nproviders credentials status\nproviders credentials rotate\nproviders credentials revoke\nproviders offers show\nproviders offers validate\nproviders offers plan\nproviders offers apply\nseeds validate\nseeds plan\nseeds apply\nseeds show\nseeds verify\ncapacity status\ncapacity explain\ncapacity usage\ncapacity ledger\ncapacity audit\nworkdays profiles list\nworkdays profiles show\nworkdays profiles reconcile\nworkdays profiles validate\nworkdays plan\nworkdays start\nworkdays list\nworkdays show\nworkdays watch\nworkdays stop\nworkdays schedules list\nworkdays schedules show\nworkdays schedules plan\nworkdays schedules start\nworkdays schedules pause\nworkdays schedules resume\nworkdays schedules retire\nassignments list\nassignments show\nassignments explain\nassignments watch\nassignments retry\nassignments cancel\nassignments artifacts\nexecution graph show\nexecution graph watch\nexecution node show\nexecution node explain\nexecution reconcile\nexecution assignments list\nprojects treedx show\nprojects treedx bind\nprojects treedx status\nprojects treedx diagnose\nprojects treedx capabilities\nprojects treedx workspaces list\nprojects treedx workspaces show\nprojects treedx workspaces abandon\nai status\nai mode show\nai mode set\nai inference models\nai inference jobs\nai inference rollback\nai training libraries\nai training jobs\nai training runs\nai lab status\nai lab agents\nai lab libraries\nai storage show\nai storage connect\nai storage disconnect\nai storage verify\nlibrary show\nlibrary status\nlibrary paths\nlibrary read\nlibrary search\nlibrary query\nlibrary context\nlibrary workspace create\nlibrary workspace show\nlibrary workspace read\nlibrary workspace diff\nlibrary workspace write\nlibrary workspace submit\nlibrary workspace abandon\nlibrary reviews list\nlibrary reviews decide\nlibrary reviews publish\nsave\nstage\nrelease\nstatus\ndiagnose" + local paths="inbox\nsend\ntopics list\ntopics show\ntopics subscribe\ntopics unsubscribe\ncapabilities list\ncapabilities show\nauth login\nauth logout\nauth status\nusers create\nteams list\nteams current\nteams use\nproposals list\nproposals show\nproposals create\nproposals update\nproposals open\nproposals feedback resolve\nproposals voting start\nproposals vote\nproposals evaluate\ndecisions list\ndecisions show\nsecrets list\nsecrets status\nsecrets unlock\nsecrets lock\nservices credentials show\nservices credentials put\nservices credentials delete\nservices credentials validate\nplatform verify\nplatform workset\nplatform project create\nplatform topology plan\nplatform topology apply\nplatform topology status\nplatform topology rollback\ndev host activate\ndev host status\ndev host deactivate\ndev host guest image import\ndev session start\ndev session stop\ndev session recover\ndev use\ndev rebuild\ndev migrate\ndev restart\ndev status\ndev logs\ndev plan\ndev freeze\ndev verify\nhost initialize\nhost status\nhost doctor\nhost plan\nhost apply\nhost reconcile\nhost start\nhost stop\nhost events\nhost config show\nhost config plan\nhost config apply\nhost config stage\nhost config adopt\nhost postgres transfer prepare\nhost postgres transfer status\nhost topology\nhost connections\nhost provider status\nhost provider credentials list\nhost provider credentials status\nhost provider credentials initialize\nhost provider environment list\nhost provider environment show\nhost provider environment status\nhost provider environment set\nhost provider environment import\nhost provider environment unset\nhost provider environment rotate\nhost provider environment verify\nhost storage status\nhost storage connect\nhost storage reconcile\nhost storage rotate\nhost storage reset\nhost security plan\nhost security initialize\nhost security status\nhost security verify\nhost security rotate\nhost security recovery verify\nhost sandbox status\nhost sandbox doctor\nhost fleet status\nhost update status\nhost update check\nhost update apply\nhost update channel\nhost update pause\nhost update resume\nhost component list\nhost component status\nhost component enable\nhost component disable\nhost aliases list\nhost recovery status\nhost recovery retry\nhost recovery restore\nhost bootstrap status\nhost bootstrap enroll\nhost reset\nhost uninstall\nagents list\nagents show\nagents team clone plan\nagents team clone apply\nagents handlers list\nagents handlers show\nagents profiles show\nagents profiles validate\nagents classes list\nagents classes show\nproviders list\nproviders show\nproviders status\nproviders diagnose\nproviders connect\nproviders disconnect\nproviders registration code status\nproviders registration code reveal\nproviders registration code rotate\nproviders environments list\nproviders environments show\nproviders environments grant\nproviders environments revoke\nproviders requests list\nproviders requests show\nproviders requests approve\nproviders requests reject\nproviders credentials status\nproviders credentials rotate\nproviders credentials revoke\nproviders offers show\nproviders offers validate\nproviders offers plan\nproviders offers apply\nseeds validate\nseeds plan\nseeds apply\nseeds show\nseeds verify\ncapacity status\ncapacity explain\ncapacity usage\ncapacity ledger\ncapacity audit\nworkdays profiles list\nworkdays profiles show\nworkdays profiles update\nworkdays plan\nworkdays start\nworkdays list\nworkdays show\nworkdays watch\nworkdays stop\nworkdays schedules list\nworkdays schedules show\nworkdays schedules plan\nworkdays schedules start\nworkdays schedules pause\nworkdays schedules resume\nworkdays schedules retire\nassignments list\nassignments show\nassignments explain\nassignments watch\nassignments retry\nassignments cancel\nassignments artifacts\nexecution graph show\nexecution graph watch\nexecution node show\nexecution node explain\nexecution reconcile\nexecution assignments list\nprojects treedx show\nprojects treedx bind\nprojects treedx status\nprojects treedx diagnose\nprojects treedx capabilities\nprojects treedx workspaces list\nprojects treedx workspaces show\nprojects treedx workspaces abandon\nai status\nai mode show\nai mode set\nai inference models\nai inference jobs\nai inference rollback\nai training libraries\nai training jobs\nai training runs\nai lab status\nai lab agents\nai lab libraries\nai storage show\nai storage connect\nai storage disconnect\nai storage verify\nlibrary show\nlibrary status\nlibrary paths\nlibrary read\nlibrary search\nlibrary query\nlibrary context\nlibrary workspace create\nlibrary workspace show\nlibrary workspace read\nlibrary workspace diff\nlibrary workspace write\nlibrary workspace submit\nlibrary workspace abandon\nlibrary reviews list\nlibrary reviews decide\nlibrary reviews publish\nsave\nstage\nrelease\nstatus\ndiagnose" COMPREPLY=( $(compgen -W "$paths" -- "${COMP_WORDS[*]:1}") ) } complete -F _trsd_complete trsd diff --git a/docs/command-reference.md b/docs/command-reference.md index 88ebe1a..4c52c0f 100644 --- a/docs/command-reference.md +++ b/docs/command-reference.md @@ -26,6 +26,8 @@ Control-plane operation: `communications.send`. - `--json`: Emit the stable JSON envelope. - `--idempotency-key `: Reuse the same request identity when retrying this mutation. - `--plan`: Return the exact proposed outcome without mutation. +- `--proposal `: Open project proposal to bind as exact discussion context. +- `--workday `: Attach addressed communication to this active workday and its allocation. - `--to `: Deprecated validation-only address list. - `--timeout `: Optional maximum seconds to listen for the complete response chain. - `--no-wait`: Return immediately after durable admission. @@ -840,6 +842,30 @@ Execution: `local.host.reconcile`. - `--json`: Emit the stable JSON envelope. - `--plan`: Return the exact proposed outcome without mutation. +### trsd host start + +Start the selected resource. + +Operation: mutation. Result schema: `treeseed.command.start/v1`. +Execution: `local.host.start`. + +- `--server `: Control-plane server profile or URL. +- `--yes`: Confirm authorized automation. +- `--json`: Emit the stable JSON envelope. +- `--plan`: Return the exact proposed outcome without mutation. + +### trsd host stop + +Stop the selected resource. + +Operation: mutation. Result schema: `treeseed.command.stop/v1`. +Execution: `local.host.stop`. + +- `--server `: Control-plane server profile or URL. +- `--yes`: Confirm authorized automation. +- `--json`: Emit the stable JSON envelope. +- `--plan`: Return the exact proposed outcome without mutation. + ### trsd host events Events the selected resource. @@ -886,6 +912,18 @@ Execution: `local.host.config.apply`. - `--json`: Emit the stable JSON envelope. - `--plan`: Return the exact proposed outcome without mutation. +### trsd host config stage + +Stage the selected resource. + +Operation: mutation. Result schema: `treeseed.command.stage/v1`. +Execution: `local.host.config.stage`. + +- `--server `: Control-plane server profile or URL. +- `--yes`: Confirm authorized automation. +- `--json`: Emit the stable JSON envelope. +- `--plan`: Return the exact proposed outcome without mutation. + ### trsd host config adopt Adopt the selected resource. @@ -1940,8 +1978,10 @@ Offers operations. Show the selected resource. Operation: read. Result schema: `treeseed.command.show/v1`. -Availability: fail-closed (`standards_migration_not_enabled`). This capability is not enabled until its control-plane operation is accepted. +Control-plane operation: `providers.offers.show`. +- `--server `: Control-plane server profile or URL. +- `--team `: Team id or slug. - `--json`: Emit the stable JSON envelope. ### trsd providers offers validate @@ -2119,7 +2159,6 @@ Control-plane operation: `workdays.profiles.list`. - `--server `: Control-plane server profile or URL. - `--team `: Team id or slug. -- `--status `: Status filter. - `--limit `: Page size. - `--cursor `: Opaque page cursor. - `--json`: Emit the stable JSON envelope. @@ -2135,28 +2174,21 @@ Control-plane operation: `workdays.profiles.show`. - `--team `: Team id or slug. - `--json`: Emit the stable JSON envelope. -### trsd workdays profiles reconcile +### trsd workdays profiles update -Reconcile the selected resource. +Update the selected resource. -Operation: mutation. Result schema: `treeseed.command.reconcile/v1`. -Control-plane operation: `workdays.profiles.reconcile`. +Operation: mutation. Result schema: `treeseed.command.update/v1`. +Control-plane operation: `workdays.profiles.update`. - `--server `: Control-plane server profile or URL. - `--team `: Team id or slug. - `--yes`: Confirm authorized automation. - `--json`: Emit the stable JSON envelope. +- `--if-match `: Exact current resource version, or new when unconfigured. - `--idempotency-key `: Reuse the same request identity when retrying this mutation. - `--plan`: Return the exact proposed outcome without mutation. - -### trsd workdays profiles validate - -Validate the selected resource. - -Operation: read. Result schema: `treeseed.command.validate/v1`. -Availability: fail-closed (`standards_migration_not_enabled`). This capability is not enabled until its control-plane operation is accepted. - -- `--json`: Emit the stable JSON envelope. +- `--input `: YAML or JSON workday policy document. ### trsd workdays plan @@ -2176,7 +2208,15 @@ Control-plane operation: `workdays.plan`. - `--objective `: Objective filter. - `--json`: Emit the stable JSON envelope. - `--idempotency-key `: Reuse the same request identity when retrying this mutation. +- `--planning-percent `: Planning share of workday time and capacity (default 20%). +- `--allocation-weight `: Relative share among eligible concurrent workdays (default 1). +- `--planning-turn-maximum-seconds `: Maximum active seconds per planning turn (default 180). +- `--project-percentages `: JSON project allocation targets; normalized among selected projects. +- `--agent-class-percentages `: JSON class allocation targets keyed by project. - `--plan`: Return the request without creating a preflight. +- `--planning-only`: Run cooperative planning profiles without admitting accepted acting work. +- `--execution-mode `: Select simulation or production custody; both consume real capacity. +- `--proposal `: Governed proposal id for cooperative planning; repeat or comma-separate. - `--agent `: Planning agent slug; repeat or comma-separate. Intersects with class/activity selectors. - `--activity `: Planning activity: planning, estimating, reviewing, reporting, or chat; repeat or comma-separate. - `--class `: Planning class slug; repeat or comma-separate. Acting remains governed by accepted decisions. @@ -2289,12 +2329,28 @@ Control-plane operation: `workdays.schedules.create`. - `--server `: Control-plane server profile or URL. - `--team `: Team id or slug. - `--profile `: Workday profile identity. +- `--decision `: Accepted decision id; repeat or comma-separate. The API derives and verifies acting authority. - `--projects `: Project scope or comma-separated projects. +- `--start `: ISO start time. +- `--end `: ISO end time. - `--duration `: Duration in seconds. +- `--objective `: Objective filter. - `--yes`: Confirm authorized automation. - `--json`: Emit the stable JSON envelope. - `--idempotency-key `: Reuse the same request identity when retrying this mutation. - `--plan`: Return the exact proposed outcome without mutation. +- `--planning-percent `: Planning share of workday time and capacity (default 20%). +- `--allocation-weight `: Relative share among eligible concurrent workdays (default 1). +- `--planning-turn-maximum-seconds `: Maximum active seconds per planning turn (default 180). +- `--project-percentages `: JSON project allocation targets; normalized among selected projects. +- `--agent-class-percentages `: JSON class allocation targets keyed by project. +- `--planning-only`: Run cooperative planning profiles without admitting accepted acting work. +- `--execution-mode `: Select simulation or production custody; both consume real capacity. +- `--proposal `: Governed proposal id for cooperative planning; repeat or comma-separate. +- `--agent `: Planning agent slug; repeat or comma-separate. Intersects with class/activity selectors. +- `--activity `: Planning activity: planning, estimating, reviewing, reporting, or chat; repeat or comma-separate. +- `--class `: Planning class slug; repeat or comma-separate. Acting remains governed by accepted decisions. +- `--cadence-seconds `: Seconds between recurring workday starts. ### trsd workdays schedules pause diff --git a/schemas/command-tree.json b/schemas/command-tree.json index 110f591..d9ddeb7 100644 --- a/schemas/command-tree.json +++ b/schemas/command-tree.json @@ -58,6 +58,16 @@ "description": "One-command team override.", "type": "string" }, + { + "name": "--proposal", + "description": "Open project proposal to bind as exact discussion context.", + "type": "string" + }, + { + "name": "--workday", + "description": "Attach addressed communication to this active workday and its allocation.", + "type": "string" + }, { "name": "--to", "description": "Deprecated validation-only address list.", @@ -130,6 +140,22 @@ "required": false, "transform": "identity" }, + { + "target": "body", + "field": "proposalId", + "source": "option", + "name": "proposal", + "required": false, + "transform": "identity" + }, + { + "target": "body", + "field": "parentWorkdayId", + "source": "option", + "name": "workday", + "required": false, + "transform": "identity" + }, { "target": "body", "field": "timeoutSeconds", @@ -2452,6 +2478,50 @@ "handlerId": "local.host.reconcile" } }, + { + "nodeType": "leaf", + "segment": "start", + "description": "Start the selected resource.", + "kind": "mutation", + "options": [ + { + "name": "--plan", + "description": "Return the exact proposed outcome without mutation.", + "type": "boolean" + } + ], + "authorization": { + "capability": "command.start", + "confirmation": "authority" + }, + "resultSchemaId": "treeseed.command.start/v1", + "execution": { + "kind": "local", + "handlerId": "local.host.start" + } + }, + { + "nodeType": "leaf", + "segment": "stop", + "description": "Stop the selected resource.", + "kind": "mutation", + "options": [ + { + "name": "--plan", + "description": "Return the exact proposed outcome without mutation.", + "type": "boolean" + } + ], + "authorization": { + "capability": "command.stop", + "confirmation": "authority" + }, + "resultSchemaId": "treeseed.command.stop/v1", + "execution": { + "kind": "local", + "handlerId": "local.host.stop" + } + }, { "nodeType": "leaf", "segment": "events", @@ -2526,6 +2596,35 @@ "handlerId": "local.host.config.apply" } }, + { + "nodeType": "leaf", + "segment": "stage", + "description": "Stage the selected resource.", + "kind": "mutation", + "arguments": [ + { + "name": "file", + "description": "file identity or path.", + "required": true + } + ], + "options": [ + { + "name": "--plan", + "description": "Return the exact proposed outcome without mutation.", + "type": "boolean" + } + ], + "authorization": { + "capability": "command.stage", + "confirmation": "authority" + }, + "resultSchemaId": "treeseed.command.stage/v1", + "execution": { + "kind": "local", + "handlerId": "local.host.config.stage" + } + }, { "nodeType": "leaf", "segment": "adopt", @@ -5015,9 +5114,26 @@ ], "resultSchemaId": "treeseed.command.show/v1", "execution": { - "kind": "unavailable", - "code": "standards_migration_not_enabled", - "reason": "This capability is not enabled until its control-plane operation is accepted." + "kind": "operation", + "operationId": "providers.offers.show", + "input": [ + { + "target": "path", + "field": "teamId", + "source": "context", + "name": "team", + "required": true, + "transform": "identity" + }, + { + "target": "path", + "field": "providerId", + "source": "argument", + "name": "connection", + "required": true, + "transform": "identity" + } + ] } }, { @@ -5537,13 +5653,6 @@ "source": "option", "name": "cursor", "transform": "identity" - }, - { - "target": "query", - "field": "status", - "source": "option", - "name": "status", - "transform": "identity" } ] } @@ -5586,13 +5695,13 @@ }, { "nodeType": "leaf", - "segment": "reconcile", - "description": "Reconcile the selected resource.", + "segment": "update", + "description": "Update the selected resource.", "kind": "mutation", "arguments": [ { - "name": "project", - "description": "project identity or path.", + "name": "profile", + "description": "profile identity or path.", "required": true } ], @@ -5601,16 +5710,22 @@ "name": "--plan", "description": "Return the exact proposed outcome without mutation.", "type": "boolean" + }, + { + "name": "--input", + "description": "YAML or JSON workday policy document.", + "type": "string", + "required": true } ], "authorization": { - "capability": "command.reconcile", + "capability": "command.update", "confirmation": "authority" }, - "resultSchemaId": "treeseed.command.reconcile/v1", + "resultSchemaId": "treeseed.command.update/v1", "execution": { "kind": "operation", - "operationId": "workdays.profiles.reconcile", + "operationId": "workdays.profiles.update", "input": [ { "target": "path", @@ -5622,33 +5737,22 @@ }, { "target": "path", - "field": "projectId", + "field": "profileId", "source": "argument", - "name": "project", + "name": "profile", + "required": true, + "transform": "identity" + }, + { + "target": "body", + "field": "file", + "source": "option", + "name": "input", "required": true, "transform": "identity" } ] } - }, - { - "nodeType": "leaf", - "segment": "validate", - "description": "Validate the selected resource.", - "kind": "read", - "arguments": [ - { - "name": "file", - "description": "file identity or path.", - "required": true - } - ], - "resultSchemaId": "treeseed.command.validate/v1", - "execution": { - "kind": "unavailable", - "code": "standards_migration_not_enabled", - "reason": "This capability is not enabled until its control-plane operation is accepted." - } } ] }, @@ -5658,11 +5762,51 @@ "description": "Plan a workday with optional targeted cooperative planning; acting stays decision-governed.", "kind": "mutation", "options": [ + { + "name": "--planning-percent", + "description": "Planning share of workday time and capacity (default 20%).", + "type": "number" + }, + { + "name": "--allocation-weight", + "description": "Relative share among eligible concurrent workdays (default 1).", + "type": "number" + }, + { + "name": "--planning-turn-maximum-seconds", + "description": "Maximum active seconds per planning turn (default 180).", + "type": "number" + }, + { + "name": "--project-percentages", + "description": "JSON project allocation targets; normalized among selected projects.", + "type": "string" + }, + { + "name": "--agent-class-percentages", + "description": "JSON class allocation targets keyed by project.", + "type": "string" + }, { "name": "--plan", "description": "Return the request without creating a preflight.", "type": "boolean" }, + { + "name": "--planning-only", + "description": "Run cooperative planning profiles without admitting accepted acting work.", + "type": "boolean" + }, + { + "name": "--execution-mode", + "description": "Select simulation or production custody; both consume real capacity.", + "type": "string" + }, + { + "name": "--proposal", + "description": "Governed proposal id for cooperative planning; repeat or comma-separate.", + "type": "string[]" + }, { "name": "--decision", "description": "Accepted decision id; repeat or comma-separate. The API derives and verifies acting authority.", @@ -5717,6 +5861,14 @@ "required": false, "transform": "csv" }, + { + "target": "body", + "field": "executionMode", + "source": "option", + "name": "executionMode", + "required": false, + "transform": "identity" + }, { "target": "body", "field": "startsAt", @@ -5749,6 +5901,22 @@ "required": false, "transform": "csv" }, + { + "target": "body", + "field": "planningOnly", + "source": "option", + "name": "planningOnly", + "required": false, + "transform": "identity" + }, + { + "target": "body", + "field": "proposalIds", + "source": "option", + "name": "proposal", + "required": false, + "transform": "csv" + }, { "target": "body", "field": "decisionIds", @@ -5757,6 +5925,41 @@ "required": false, "transform": "csv" }, + { + "target": "body", + "field": "allocation.planningPercent", + "source": "option", + "name": "planningPercent", + "transform": "number" + }, + { + "target": "body", + "field": "allocation.allocationWeight", + "source": "option", + "name": "allocationWeight", + "transform": "number" + }, + { + "target": "body", + "field": "allocation.planningTurnMaximumSeconds", + "source": "option", + "name": "planningTurnMaximumSeconds", + "transform": "integer" + }, + { + "target": "body", + "field": "allocation.projectPercentages", + "source": "option", + "name": "projectPercentages", + "transform": "json" + }, + { + "target": "body", + "field": "allocation.agentClassPercentages", + "source": "option", + "name": "agentClassPercentages", + "transform": "json" + }, { "target": "body", "field": "agentSelection.agentSlugs", @@ -6051,6 +6254,71 @@ "name": "--plan", "description": "Return the exact proposed outcome without mutation.", "type": "boolean" + }, + { + "name": "--planning-percent", + "description": "Planning share of workday time and capacity (default 20%).", + "type": "number" + }, + { + "name": "--allocation-weight", + "description": "Relative share among eligible concurrent workdays (default 1).", + "type": "number" + }, + { + "name": "--planning-turn-maximum-seconds", + "description": "Maximum active seconds per planning turn (default 180).", + "type": "number" + }, + { + "name": "--project-percentages", + "description": "JSON project allocation targets; normalized among selected projects.", + "type": "string" + }, + { + "name": "--agent-class-percentages", + "description": "JSON class allocation targets keyed by project.", + "type": "string" + }, + { + "name": "--planning-only", + "description": "Run cooperative planning profiles without admitting accepted acting work.", + "type": "boolean" + }, + { + "name": "--execution-mode", + "description": "Select simulation or production custody; both consume real capacity.", + "type": "string" + }, + { + "name": "--proposal", + "description": "Governed proposal id for cooperative planning; repeat or comma-separate.", + "type": "string[]" + }, + { + "name": "--decision", + "description": "Accepted decision id; repeat or comma-separate. The API derives and verifies acting authority.", + "type": "string[]" + }, + { + "name": "--agent", + "description": "Planning agent slug; repeat or comma-separate. Intersects with class/activity selectors.", + "type": "string[]" + }, + { + "name": "--activity", + "description": "Planning activity: planning, estimating, reviewing, reporting, or chat; repeat or comma-separate.", + "type": "string[]" + }, + { + "name": "--class", + "description": "Planning class slug; repeat or comma-separate. Acting remains governed by accepted decisions.", + "type": "string[]" + }, + { + "name": "--cadence-seconds", + "description": "Seconds between recurring workday starts.", + "type": "number" } ], "authorization": { @@ -6072,7 +6340,7 @@ }, { "target": "body", - "field": "profile", + "field": "intent.profileId", "source": "option", "name": "profile", "required": false, @@ -6080,7 +6348,7 @@ }, { "target": "body", - "field": "projects", + "field": "intent.projects", "source": "option", "name": "projects", "required": false, @@ -6088,11 +6356,131 @@ }, { "target": "body", - "field": "duration", + "field": "intent.executionMode", + "source": "option", + "name": "executionMode", + "required": false, + "transform": "identity" + }, + { + "target": "body", + "field": "intent.startsAt", + "source": "option", + "name": "start", + "required": false, + "transform": "identity" + }, + { + "target": "body", + "field": "intent.endsAt", + "source": "option", + "name": "end", + "required": false, + "transform": "identity" + }, + { + "target": "body", + "field": "intent.durationSeconds", "source": "option", "name": "duration", "required": false, "transform": "integer" + }, + { + "target": "body", + "field": "intent.objectiveFilters", + "source": "option", + "name": "objective", + "required": false, + "transform": "csv" + }, + { + "target": "body", + "field": "intent.planningOnly", + "source": "option", + "name": "planningOnly", + "required": false, + "transform": "identity" + }, + { + "target": "body", + "field": "intent.proposalIds", + "source": "option", + "name": "proposal", + "required": false, + "transform": "csv" + }, + { + "target": "body", + "field": "intent.decisionIds", + "source": "option", + "name": "decision", + "required": false, + "transform": "csv" + }, + { + "target": "body", + "field": "intent.allocation.planningPercent", + "source": "option", + "name": "planningPercent", + "transform": "number" + }, + { + "target": "body", + "field": "intent.allocation.allocationWeight", + "source": "option", + "name": "allocationWeight", + "transform": "number" + }, + { + "target": "body", + "field": "intent.allocation.planningTurnMaximumSeconds", + "source": "option", + "name": "planningTurnMaximumSeconds", + "transform": "integer" + }, + { + "target": "body", + "field": "intent.allocation.projectPercentages", + "source": "option", + "name": "projectPercentages", + "transform": "json" + }, + { + "target": "body", + "field": "intent.allocation.agentClassPercentages", + "source": "option", + "name": "agentClassPercentages", + "transform": "json" + }, + { + "target": "body", + "field": "intent.agentSelection.agentSlugs", + "source": "option", + "name": "agent", + "transform": "csv" + }, + { + "target": "body", + "field": "intent.agentSelection.activityTypes", + "source": "option", + "name": "activity", + "transform": "csv" + }, + { + "target": "body", + "field": "intent.agentSelection.classSlugs", + "source": "option", + "name": "class", + "transform": "csv" + }, + { + "target": "body", + "field": "cadenceSeconds", + "source": "option", + "name": "cadenceSeconds", + "required": false, + "transform": "integer" } ] } diff --git a/src/cli/application/data.ts b/src/cli/application/data.ts index 6c22c66..6a30cd9 100644 --- a/src/cli/application/data.ts +++ b/src/cli/application/data.ts @@ -1,6 +1,4 @@ import type { InkRow as Row, InkSurfaceCollection as SurfaceCollection, InkSurfaceItem as SurfaceItem, InkWorkspaceDataSource } from '@treeseed/ui/ink'; -import { validateCapacityAllocationSetV2, type CapacityAllocationSetV2 } from '@treeseed/sdk/agent-capacity'; -import { parse as parseYaml } from 'yaml'; export type { InkRow as Row, InkSurfaceCollection as SurfaceCollection, InkSurfaceItem as SurfaceItem } from '@treeseed/ui/ink'; export type Invoke = (operationId: string, input: { path: Row; query: Row; body: unknown }, options?: Row) => Promise; @@ -87,7 +85,7 @@ export async function loadSurfaceCollection(invoke: Invoke, teamId: string, surf } export function canExecuteSurfaceAction(actionId: string, selected?: SurfaceItem) { - if (['project.create', 'service.connect', 'capacity.configure', 'allocation.save', 'agent.create'].includes(actionId)) return true; + if (['project.create', 'service.connect', 'capacity.configure', 'agent.create'].includes(actionId)) return true; if (['service.configure', 'service.remove', 'capacity.revoke', 'agent.save', 'content.edit', 'release.promote-production'].includes(actionId)) return Boolean(selected); if (actionId === 'release.cut') return !selected || ['approved', 'ready', 'staging'].includes(text(selected.raw.status)); if (actionId === 'question.answer') return selected?.raw.kind === 'question' && selected.raw.status === 'outstanding'; @@ -132,7 +130,7 @@ export async function executeSurfaceAction(invoke: Invoke, teamId: string, actio if (!selected) throw new Error('Revoke capacity requires a selected provider connection.'); return invoke('providers.disconnect', { path: { teamId, connectionId: text(selected.raw.connectionId) || selected.id }, query: {}, body: undefined }, { idempotencyKey: globalThis.crypto.randomUUID() }); } - if (['content.edit', 'agent.create', 'agent.save', 'allocation.save'].includes(actionId)) return executeTreeDxAuthoring(invoke, actionId, values, selected); + if (['content.edit', 'agent.create', 'agent.save'].includes(actionId)) return executeTreeDxAuthoring(invoke, actionId, values, selected); if (actionId === 'release.cut' || actionId === 'release.promote-production') { const reviewId = text(values.reviewId) || selected?.id; if (!reviewId) throw new Error(`${actionId} requires a review.`); @@ -146,13 +144,6 @@ export async function executeSurfaceAction(invoke: Invoke, teamId: string, actio async function executeTreeDxAuthoring(invoke: Invoke, actionId: string, values: Row, selected?: SurfaceItem) { const projectId = text(values.projectId || selected?.raw.projectId); if (!projectId) throw new Error(`${actionId} requires a project ID.`); - if (actionId === 'allocation.save') { - let allocation: unknown; - try { allocation = parseYaml(text(values.content)); } - catch (error) { throw new Error(`Allocation profile is not valid JSON or YAML: ${error instanceof Error ? error.message : String(error)}`); } - const validation = validateCapacityAllocationSetV2(allocation as CapacityAllocationSetV2); - if (!validation.ok) throw new Error(`Allocation profile is invalid: ${validation.diagnostics.map((entry) => `${entry.path}: ${entry.message}`).join(' ')}`); - } let workspaceId = text(values.workspaceId), version = Number(values.version || 0); if (!workspaceId) { const created = payload(await invoke('knowledge.workspaces.create', { path: { projectId }, query: {}, body: { requestId: globalThis.crypto.randomUUID() } }, { idempotencyKey: globalThis.crypto.randomUUID() })); @@ -161,7 +152,7 @@ async function executeTreeDxAuthoring(invoke: Invoke, actionId: string, values: if (!workspaceId || version < 1) throw new Error('A valid TreeDX workspace and version are required.'); const sourcePath = text(values.sourcePath || selected?.raw.path); const body = actionId === 'content.edit' ? { kind: 'page', version, sourcePath: sourcePath || undefined, bookId: text(values.bookId), slug: text(values.slug), title: text(values.title), summary: text(values.summary), body: text(values.body) } - : { kind: actionId === 'agent.save' || actionId === 'agent.create' ? 'agent-profile' : 'operational-content', version, sourcePath, expectedSha: text(selected?.raw.sha ?? values.expectedSha) || undefined, content: text(values.content), ...(actionId === 'agent.create' || !sourcePath ? { create: true } : {}) }; + : { kind: 'agent-profile', version, sourcePath, expectedSha: text(selected?.raw.sha ?? values.expectedSha) || undefined, content: text(values.content), ...(actionId === 'agent.create' || !sourcePath ? { create: true } : {}) }; const updated = payload(await invoke('knowledge.workspaces.content.update', { path: { workspaceId }, query: {}, body }, {})); const nextWorkspace = record(updated.workspace), nextVersion = Number(nextWorkspace.version ?? version + 1); return invoke('knowledge.workspaces.submit', { path: { workspaceId }, query: {}, body: { version: nextVersion, message: text(values.message) } }, { idempotencyKey: globalThis.crypto.randomUUID() }); diff --git a/src/cli/commands/operator.ts b/src/cli/commands/operator.ts index d0115d6..14cd885 100644 --- a/src/cli/commands/operator.ts +++ b/src/cli/commands/operator.ts @@ -4,7 +4,7 @@ import { resolve } from 'node:path'; import { parse as parseYaml } from 'yaml'; import { controlPlaneOperation, encodeConfirmationState, parseCommunicationAddresses, validateWorkdayIntentSelection, normalizeWorkdayAgentSelection, type CommandInputBinding } from '@treeseed/sdk/operator-contracts'; import { ControlPlaneClientError, resolveControlPlaneServer } from '@treeseed/sdk/control-plane-client'; -import { workdayAllocationOverridesSchema } from '@treeseed/sdk/agent-capacity'; +import { workdayAllocationOverridesSchema, workdayPolicySchema } from '@treeseed/sdk/agent-capacity'; import type { CommandContext, ParsedInvocation } from '../types.js'; import { launchApplication } from '../application/launch.js'; import { runInteractiveChat } from '../communication/interactive-chat.js'; @@ -121,7 +121,12 @@ async function operationInput(invocation: ParsedInvocation, context: CommandCont const parsed = parseYaml(await inputDocument(input.body.file, context)); if (!parsed || typeof parsed !== 'object' || Array.isArray(parsed)) throw Object.assign(new Error('Input file must contain one YAML or JSON object.'), { category: 'invalid_input', code: 'command_input_file_invalid' }); delete input.body.file; - Object.assign(input.body, parsed); + if (operation.descriptor.operationId === 'workdays.profiles.update') { + const policy = workdayPolicySchema.safeParse(parsed); + if (!policy.success) throw Object.assign(new Error(policy.error.issues.map((issue) => `${issue.path.join('.')}: ${issue.message}`).join('; ')), + { category: 'invalid_input', code: 'workday_policy_file_invalid' }); + input.body.policy = policy.data; + } else Object.assign(input.body, parsed); } for (const binding of deferred) if (getOperationInputField(input[binding.target], binding.field) === undefined) { throw Object.assign(new Error(`Missing required ${binding.source}: ${binding.name}`), { category: 'ambiguous_context', code: `${binding.name}_required` }); diff --git a/tests/unit/application/workspaces.test.ts b/tests/unit/application/workspaces.test.ts index 5cd908a..c351d9c 100644 --- a/tests/unit/application/workspaces.test.ts +++ b/tests/unit/application/workspaces.test.ts @@ -134,18 +134,9 @@ test('TreeDX authoring creates, writes, and submits one recoverable workspace', assert.deepEqual(calls[2]?.input.body, { version: 2, message: 'Add welcome page' }); }); -test('allocation authoring validates normalized hierarchy percentages before writing TreeDX content', async () => { - const calls: string[] = []; - const invoke = async (operationId: string) => { - calls.push(operationId); - if (operationId === 'knowledge.workspaces.create') return { data: { id: 'workspace-a', version: 1 } }; - if (operationId === 'knowledge.workspaces.content.update') return { data: { workspace: { version: 2 } } }; - return { data: { review: { id: 'review-a' } } }; - }; - const allocation = { schemaVersion: 2, id: 'allocation-a', teamId: 'team-a', version: 1, status: 'draft', effectiveFrom: '2026-09-02T12:00:00.000Z', reservePolicy: { percent: 0, overflow: 'deny' }, slices: [{ id: 'project-a', scope: 'project', targetId: 'project-a', policy: { minPercent: 0, targetPercent: 100, maxPercent: 100, hardCapPercent: 100 } }], borrowingRules: [] }; - await executeSurfaceAction(invoke, 'team-a', 'allocation.save', { projectId: 'project-a', sourcePath: 'capacity/allocations/team.yaml', content: JSON.stringify(allocation), message: 'Allocate project capacity' }); - assert.deepEqual(calls, ['knowledge.workspaces.create', 'knowledge.workspaces.content.update', 'knowledge.workspaces.submit']); - await assert.rejects(() => executeSurfaceAction(invoke, 'team-a', 'allocation.save', { projectId: 'project-a', sourcePath: 'capacity/allocations/team.yaml', content: JSON.stringify({ ...allocation, slices: [{ ...allocation.slices[0], policy: { ...allocation.slices[0].policy, targetPercent: 90 } }] }), message: 'Invalid allocation' }), /Sibling target percentages must total 100/u); +test('retired allocation-set authoring cannot create a TreeDX workspace', async () => { + const invoke = async () => { throw new Error('unexpected operation'); }; + await assert.rejects(() => executeSurfaceAction(invoke, 'team-a', 'allocation.save', {}, undefined), /not implemented/u); }); test('release workflows publish staging and leave production fail-closed to control-plane authority', async () => { diff --git a/tests/unit/command-boundary/canonical-client.test.ts b/tests/unit/command-boundary/canonical-client.test.ts index 1619efe..824011b 100644 --- a/tests/unit/command-boundary/canonical-client.test.ts +++ b/tests/unit/command-boundary/canonical-client.test.ts @@ -54,6 +54,59 @@ test('leaf commands expose only catalog-derived high-level options', () => { assert.equal(commandSpecs.some((command) => command.options.some((option) => option.flag === '--execute' || option.flag === '--market')), false); }); +test('assignment explain preserves allocator evidence without inventing a CLI budget', async () => { + const calls: Array<{ operationId: string; input: any }> = []; + const output: string[] = []; + const allocation = { allocatedSeconds: 60, limitingConstraint: 'task-duration', + calibration: { multiplier: 2, measurementIds: [] }, opportunity: { shareSeconds: 600 } }; + const exit = await runCommandLine(['assignments', 'explain', 'assignment-1', + '--team', '11111111-1111-4111-8111-111111111111', '--json'], { + interactiveUi: false, write: (value) => output.push(value), + operationInvoke: async (operationId, input) => { + calls.push({ operationId, input }); return { data: { metadata: { allocation } } }; + }, + }); + assert.equal(exit, 0, output.join('')); + assert.equal(calls[0]?.operationId, 'assignments.explain'); + assert.deepEqual(calls[0]?.input.path, { teamId: '11111111-1111-4111-8111-111111111111', assignmentId: 'assignment-1' }); + assert.deepEqual(JSON.parse(output.at(-1)!).result.metadata.allocation, allocation); +}); + +test('workday profile update sends a validated policy document under the API policy field', async () => { + const root = mkdtempSync(resolve(tmpdir(), 'treeseed-workday-policy-')); + const file = resolve(root, 'policy.json'); + const policy = { durationSeconds: 28_800, maximumConcurrency: 1, communicationConcurrency: 1, + planningPercent: 20, projectPercentages: { sdk: 100 }, agentClassPercentages: { sdk: { engineer: 100 } } }; + const calls: Array<{ operationId: string; input: any }> = []; + const output: string[] = []; + try { + writeFileSync(file, JSON.stringify(policy)); + const exit = await runCommandLine(['workdays', 'profiles', 'update', 'default', '--team', '11111111-1111-4111-8111-111111111111', '--input', file, + '--if-match', '1', '--yes', '--json'], { interactiveUi: false, write: value => output.push(value), + operationInvoke: async (operationId, input) => { calls.push({ operationId, input }); return { data: { id: 'default', teamId: '11111111-1111-4111-8111-111111111111', revision: 2, policy } }; } }); + assert.equal(exit, 0, output.join('')); + assert.equal(calls[0]?.operationId, 'workdays.profiles.update'); + assert.deepEqual(calls[0]?.input.body.policy, { ...policy, allocationWeight: 1, planningTurnMaximumSeconds: 180 }); + assert.equal(calls[0]?.input.body.file, undefined); + } finally { rmSync(root, { recursive: true, force: true }); } +}); + +test('workday profile update rejects an invalid policy file before mutation', async () => { + const root = mkdtempSync(resolve(tmpdir(), 'treeseed-workday-policy-invalid-')); + const file = resolve(root, 'policy.json'); + const calls: string[] = []; + const output: string[] = []; + try { + writeFileSync(file, JSON.stringify({ durationSeconds: -1, maximumConcurrency: 1, communicationConcurrency: 1 })); + const exit = await runCommandLine(['workdays', 'profiles', 'update', 'default', '--team', '11111111-1111-4111-8111-111111111111', + '--input', file, '--if-match', '1', '--yes', '--json'], { interactiveUi: false, write: value => output.push(value), + operationInvoke: async (operationId) => { calls.push(operationId); return { data: {} }; } }); + assert.equal(exit, 1); + assert.deepEqual(calls, []); + assert.equal(JSON.parse(output.at(-1)!).error.code, 'workday_policy_file_invalid'); + } finally { rmSync(root, { recursive: true, force: true }); } +}); + test('host commands preserve the SDK handler boundary and stable envelope', async () => { const calls: unknown[] = []; const output: string[] = []; const exit = await runCommandLine(['host', 'component', 'status', 'agent', '--server', 'lab', '--json'], { From 562d18ba6bf6a1187c030aa08d8faaf48e9164ed Mon Sep 17 00:00:00 2001 From: Adrian Webb Date: Sat, 26 Sep 2026 05:53:46 -0400 Subject: [PATCH 4/9] Preserve live selection on refused managed rebuild and bind regression scene --- .../component-boundaries.guarantee.yaml | 12 +++++ .../scenes/component-boundaries.scene.yaml | 28 +++++++++++ guarantees/verifiers/golden.verifiers.yaml | 48 +++++++++++++++++++ src/cli/commands/development.ts | 8 +++- .../managed-development-container.test.ts | 17 +++++-- 5 files changed, 108 insertions(+), 5 deletions(-) create mode 100644 guarantees/agent/golden/component-boundaries.guarantee.yaml create mode 100644 guarantees/agent/golden/scenes/component-boundaries.scene.yaml create mode 100644 guarantees/verifiers/golden.verifiers.yaml diff --git a/guarantees/agent/golden/component-boundaries.guarantee.yaml b/guarantees/agent/golden/component-boundaries.guarantee.yaml new file mode 100644 index 0000000..1e93378 --- /dev/null +++ b/guarantees/agent/golden/component-boundaries.guarantee.yaml @@ -0,0 +1,12 @@ +schemaVersion: treeseed.guarantee/v1 +id: guarantee.cli.golden.component-boundaries +journey: Verify golden operator component boundaries +ownerPackage: "@treeseed/cli" +type: agent +subtype: operator +status: planned +gates: [core] +summary: Coded operator component scenarios; not proof of a live SDK golden workday. +scene: + required: true + manifest: guarantees/agent/golden/scenes/component-boundaries.scene.yaml diff --git a/guarantees/agent/golden/scenes/component-boundaries.scene.yaml b/guarantees/agent/golden/scenes/component-boundaries.scene.yaml new file mode 100644 index 0000000..dcee1ae --- /dev/null +++ b/guarantees/agent/golden/scenes/component-boundaries.scene.yaml @@ -0,0 +1,28 @@ +workflow: + - id: refused-rebuild + action: { verifier: cli.golden.refused-rebuild } + expect: { status: passed } + - id: allocations + action: { verifier: cli.golden.allocations } + expect: { status: passed } + - id: selection + action: { verifier: cli.golden.selection } + expect: { status: passed } + - id: decisions + action: { verifier: cli.golden.decisions } + expect: { status: passed } + - id: decision-denial + action: { verifier: cli.golden.decision-denial } + expect: { status: passed } + - id: input-isolation + action: { verifier: cli.golden.input-isolation } + expect: { status: passed } + - id: diagnostics + action: { verifier: cli.golden.diagnostics } + expect: { status: passed } + - id: runtime-readback + action: { verifier: cli.golden.runtime-readback } + expect: { status: passed } + - id: lifecycle-lock + action: { verifier: cli.golden.lifecycle-lock } + expect: { status: passed } diff --git a/guarantees/verifiers/golden.verifiers.yaml b/guarantees/verifiers/golden.verifiers.yaml new file mode 100644 index 0000000..99fd184 --- /dev/null +++ b/guarantees/verifiers/golden.verifiers.yaml @@ -0,0 +1,48 @@ +schemaVersion: treeseed.guarantee-verifiers/v1 +ownerPackage: "@treeseed/cli" +verifiers: + cli.golden.refused-rebuild: + kind: nodeTestCase + ownerPackage: "@treeseed/cli" + testFile: tests/unit/command-boundary/host/managed-development-container.test.ts + testName: container startup and cleanup only invoke the protected manager, including failed-start cleanup + cli.golden.allocations: + kind: nodeTestCase + ownerPackage: "@treeseed/cli" + testFile: tests/unit/command-boundary/workdays/selection.test.ts + testName: high-level allocation options use one nested policy input + cli.golden.selection: + kind: nodeTestCase + ownerPackage: "@treeseed/cli" + testFile: tests/unit/command-boundary/workdays/selection.test.ts + testName: repeated and CSV selectors become a normalized intersecting nested intent + cli.golden.decisions: + kind: nodeTestCase + ownerPackage: "@treeseed/cli" + testFile: tests/unit/command-boundary/workdays/selection.test.ts + testName: repeated and CSV accepted decisions become one normalized selection + cli.golden.decision-denial: + kind: nodeTestCase + ownerPackage: "@treeseed/cli" + testFile: tests/unit/command-boundary/workdays/selection.test.ts + testName: empty decision selection never invokes the API + cli.golden.input-isolation: + kind: nodeTestCase + ownerPackage: "@treeseed/cli" + testFile: tests/unit/command-boundary/workdays/selection.test.ts + testName: nested bindings reject prototype traversal, collisions, and excessive depth + cli.golden.diagnostics: + kind: nodeTestCase + ownerPackage: "@treeseed/cli" + testFile: tests/unit/command-boundary/communications/send-outcome.test.ts + testName: streamed human sends retain failure reason and IDs without duplicating prior responses + cli.golden.runtime-readback: + kind: nodeTestCase + ownerPackage: "@treeseed/cli" + testFile: tests/unit/command-boundary/development/lifecycle.test.ts + testName: managed Agent sandbox status recognizes exact active guest-image custody + cli.golden.lifecycle-lock: + kind: nodeTestCase + ownerPackage: "@treeseed/cli" + testFile: tests/unit/command-boundary/development/lifecycle.test.ts + testName: lifecycle lock releases after a failed operation diff --git a/src/cli/commands/development.ts b/src/cli/commands/development.ts index aaeb8a0..57a9fe1 100644 --- a/src/cli/commands/development.ts +++ b/src/cli/commands/development.ts @@ -293,9 +293,13 @@ async function rebuildPackage(input: { state: LocalSessionState; record: { sessi async function restartConsumer(input: { state: LocalSessionState; runtime: DevelopmentRuntime; target: DevelopmentTarget; worktree: string; mode: 'candidate' | 'live'; context: CommandContext; recordGeneration?: boolean }) { const { state, runtime, target, worktree, mode, context } = input, key = `${runtime.project.id}.${target.id}`; - if (usesManagedContainer(target)) await invoke(context, 'local.dev.use', {sessionId:state.sessionId,projectId:runtime.project.id,targetId:target.id,mode:'released'}); await stopProcess(state, key); - if (usesManagedContainer(target)) await containerOperation(context, state.sessionId, runtime, target, 'stop'); + if (usesManagedContainer(target)) { + // A manager refusal (for example, an active Kata claim) leaves the current + // runtime in place. Do not retire its selection before that custody check. + await containerOperation(context, state.sessionId, runtime, target, 'stop'); + await invoke(context, 'local.dev.use', {sessionId:state.sessionId,projectId:runtime.project.id,targetId:target.id,mode:'released'}); + } else if (target.operations.cleanup) runOneShotOperation(state, target.operations.cleanup, worktree, mode, context.env, { TREESEED_DEVELOPMENT_CLEANUP_SCOPE: 'runtime' }); const resolved = await invoke(context, 'local.dev.environment', { sessionId: state.sessionId, projectId: runtime.project.id, targetId: target.id }) as { environment?: NodeJS.ProcessEnv }; if (target.operations.setup) runOneShotOperation(state, target.operations.setup, worktree, mode, context.env, resolved.environment ?? {}); diff --git a/tests/unit/command-boundary/host/managed-development-container.test.ts b/tests/unit/command-boundary/host/managed-development-container.test.ts index 639e8a5..d92305e 100644 --- a/tests/unit/command-boundary/host/managed-development-container.test.ts +++ b/tests/unit/command-boundary/host/managed-development-container.test.ts @@ -16,13 +16,17 @@ test('container startup and cleanup only invoke the protected manager, including const file=resolve(root,'treeseed.package.yaml');writeFileSync(file,JSON.stringify({development:runtime})); execFileSync('git',['init','-b','staging'],{cwd:root});execFileSync('git',['add','.'],{cwd:root}); execFileSync('git',['-c','user.name=Test','-c','user.email=test@example.invalid','commit','-m','fixture'],{cwd:root}); - let record:any;const actions:string[]=[],records=new Map();let selected=''; + let record:any;const actions:string[]=[],records=new Map();let selected='', rejectStop=false; const context={cwd:root,env:{XDG_STATE_HOME:resolve(root,'state'),USER:'tester'},interactiveUi:false,write:()=>{},hostInvoke:async(request:any)=>{ const payload=JSON.parse(request.options.payload); if(request.handlerId==='local.dev.session.start'){record={session:payload.session,runtimes:payload.runtimes};records.set(payload.session.sessionId,record);return record;} record=records.get(payload.sessionId)??record; if(request.handlerId==='local.dev.environment')return {environment:{}}; - if(request.handlerId==='local.dev.container'){assert.equal(payload.sessionId,selected);actions.push(payload.action);return payload.action==='status'?{registered:false,state:null}:{};} + if(request.handlerId==='local.dev.container'){ + assert.equal(payload.sessionId,selected);actions.push(payload.action); + if(payload.action==='stop' && rejectStop)throw new Error('Active assignment prevents guest trust replacement.'); + return payload.action==='status'?{registered:false,state:null}:{}; + } if(request.handlerId==='local.dev.use'){ if(payload.mode!=='released')assert.equal(payload.port,3000,'Every activation, including restart, must reattach the canonical route'); record.session.targets[0].mode=payload.mode; @@ -39,9 +43,16 @@ test('container startup and cleanup only invoke the protected manager, including assert.deepEqual(actions,beforePlan,'Plan must not stop or start a container'); assert.equal(record.session.targets[0].mode,'live','Plan must not switch routes'); assert.equal(JSON.parse(planned[0]!).result.mutation,false); + rejectStop=true; + for(const command of ['restart','rebuild']) { + assert.equal(await runCommandLine(['dev',command,'api.service','--session',selected,'--json'],context),1); + assert.equal(record.session.targets[0].mode,'live','A refused stop must not switch the selected runtime to released'); + assert.equal(actions.at(-1),'stop'); + } + rejectStop=false; assert.equal(await runCommandLine(['dev','restart','api.service','--session',selected,'--json'],context),0); assert.equal(await runCommandLine(['dev','use','api.service=released','--session',selected,'--json'],context),0); assert.equal(await runCommandLine(['dev','session','stop','--session',selected,'--json'],context),0); - assert.deepEqual(actions,['status','start','stop','start','stop','status']); + assert.deepEqual(actions,['status','start','stop','stop','stop','start','stop','status']); } finally {rmSync(root,{recursive:true,force:true});} }); From df2f369b9c95b42e672530b58ddc35ae6d293f3e Mon Sep 17 00:00:00 2001 From: Adrian Webb Date: Sat, 26 Sep 2026 05:56:13 -0400 Subject: [PATCH 5/9] Integrate current lifecycle contracts and preserve focused allocation regressions --- completions/trsd.bash | 2 +- schemas/command-tree.json | 120 ++++++++++++++++++ .../development-support/host-runtime.ts | 21 ++- src/cli/commands/development.ts | 6 +- .../command-boundary/canonical-client.test.ts | 66 ---------- .../host/lifecycle-envelopes.test.ts | 18 +++ .../workdays/allocation-policy.test.ts | 59 +++++++++ 7 files changed, 208 insertions(+), 84 deletions(-) create mode 100644 tests/unit/command-boundary/host/lifecycle-envelopes.test.ts create mode 100644 tests/unit/command-boundary/workdays/allocation-policy.test.ts diff --git a/completions/trsd.bash b/completions/trsd.bash index f9fd464..9ffdd9d 100644 --- a/completions/trsd.bash +++ b/completions/trsd.bash @@ -1,6 +1,6 @@ # Generated from treeseed.command-tree/v1. _trsd_complete() { - local paths="inbox\nsend\ntopics list\ntopics show\ntopics subscribe\ntopics unsubscribe\ncapabilities list\ncapabilities show\nauth login\nauth logout\nauth status\nusers create\nteams list\nteams current\nteams use\nproposals list\nproposals show\nproposals create\nproposals update\nproposals open\nproposals feedback resolve\nproposals voting start\nproposals vote\nproposals evaluate\ndecisions list\ndecisions show\nsecrets list\nsecrets status\nsecrets unlock\nsecrets lock\nservices credentials show\nservices credentials put\nservices credentials delete\nservices credentials validate\nplatform verify\nplatform workset\nplatform project create\nplatform topology plan\nplatform topology apply\nplatform topology status\nplatform topology rollback\ndev host activate\ndev host status\ndev host deactivate\ndev host guest image import\ndev session start\ndev session stop\ndev session recover\ndev use\ndev rebuild\ndev migrate\ndev restart\ndev status\ndev logs\ndev plan\ndev freeze\ndev verify\nhost initialize\nhost status\nhost doctor\nhost plan\nhost apply\nhost reconcile\nhost start\nhost stop\nhost events\nhost config show\nhost config plan\nhost config apply\nhost config stage\nhost config adopt\nhost postgres transfer prepare\nhost postgres transfer status\nhost topology\nhost connections\nhost provider status\nhost provider credentials list\nhost provider credentials status\nhost provider credentials initialize\nhost provider environment list\nhost provider environment show\nhost provider environment status\nhost provider environment set\nhost provider environment import\nhost provider environment unset\nhost provider environment rotate\nhost provider environment verify\nhost storage status\nhost storage connect\nhost storage reconcile\nhost storage rotate\nhost storage reset\nhost security plan\nhost security initialize\nhost security status\nhost security verify\nhost security rotate\nhost security recovery verify\nhost sandbox status\nhost sandbox doctor\nhost fleet status\nhost update status\nhost update check\nhost update apply\nhost update channel\nhost update pause\nhost update resume\nhost component list\nhost component status\nhost component enable\nhost component disable\nhost aliases list\nhost recovery status\nhost recovery retry\nhost recovery restore\nhost bootstrap status\nhost bootstrap enroll\nhost reset\nhost uninstall\nagents list\nagents show\nagents team clone plan\nagents team clone apply\nagents handlers list\nagents handlers show\nagents profiles show\nagents profiles validate\nagents classes list\nagents classes show\nproviders list\nproviders show\nproviders status\nproviders diagnose\nproviders connect\nproviders disconnect\nproviders registration code status\nproviders registration code reveal\nproviders registration code rotate\nproviders environments list\nproviders environments show\nproviders environments grant\nproviders environments revoke\nproviders requests list\nproviders requests show\nproviders requests approve\nproviders requests reject\nproviders credentials status\nproviders credentials rotate\nproviders credentials revoke\nproviders offers show\nproviders offers validate\nproviders offers plan\nproviders offers apply\nseeds validate\nseeds plan\nseeds apply\nseeds show\nseeds verify\ncapacity status\ncapacity explain\ncapacity usage\ncapacity ledger\ncapacity audit\nworkdays profiles list\nworkdays profiles show\nworkdays profiles update\nworkdays plan\nworkdays start\nworkdays list\nworkdays show\nworkdays watch\nworkdays stop\nworkdays schedules list\nworkdays schedules show\nworkdays schedules plan\nworkdays schedules start\nworkdays schedules pause\nworkdays schedules resume\nworkdays schedules retire\nassignments list\nassignments show\nassignments explain\nassignments watch\nassignments retry\nassignments cancel\nassignments artifacts\nexecution graph show\nexecution graph watch\nexecution node show\nexecution node explain\nexecution reconcile\nexecution assignments list\nprojects treedx show\nprojects treedx bind\nprojects treedx status\nprojects treedx diagnose\nprojects treedx capabilities\nprojects treedx workspaces list\nprojects treedx workspaces show\nprojects treedx workspaces abandon\nai status\nai mode show\nai mode set\nai inference models\nai inference jobs\nai inference rollback\nai training libraries\nai training jobs\nai training runs\nai lab status\nai lab agents\nai lab libraries\nai storage show\nai storage connect\nai storage disconnect\nai storage verify\nlibrary show\nlibrary status\nlibrary paths\nlibrary read\nlibrary search\nlibrary query\nlibrary context\nlibrary workspace create\nlibrary workspace show\nlibrary workspace read\nlibrary workspace diff\nlibrary workspace write\nlibrary workspace submit\nlibrary workspace abandon\nlibrary reviews list\nlibrary reviews decide\nlibrary reviews publish\nsave\nstage\nrelease\nstatus\ndiagnose" + local paths="inbox\nsend\ntopics list\ntopics show\ntopics subscribe\ntopics unsubscribe\ncapabilities list\ncapabilities show\nauth login\nauth logout\nauth status\nusers create\nteams list\nteams current\nteams use\nproposals list\nproposals show\nproposals create\nproposals update\nproposals open\nproposals feedback resolve\nproposals voting start\nproposals vote\nproposals evaluate\nproposals withdraw\nproposals supersede\ndecisions list\ndecisions show\nsecrets list\nsecrets status\nsecrets unlock\nsecrets lock\nservices credentials show\nservices credentials put\nservices credentials delete\nservices credentials validate\nplatform verify\nplatform workset\nplatform project create\nplatform topology plan\nplatform topology apply\nplatform topology status\nplatform topology rollback\ndev host activate\ndev host status\ndev host deactivate\ndev host guest image import\ndev session start\ndev session stop\ndev session recover\ndev use\ndev rebuild\ndev migrate\ndev restart\ndev status\ndev logs\ndev plan\ndev freeze\ndev verify\nhost initialize\nhost status\nhost doctor\nhost plan\nhost apply\nhost reconcile\nhost start\nhost stop\nhost events\nhost config show\nhost config plan\nhost config apply\nhost config stage\nhost config adopt\nhost postgres transfer prepare\nhost postgres transfer status\nhost topology\nhost connections\nhost provider status\nhost provider credentials list\nhost provider credentials status\nhost provider credentials initialize\nhost provider environment list\nhost provider environment show\nhost provider environment status\nhost provider environment set\nhost provider environment import\nhost provider environment unset\nhost provider environment rotate\nhost provider environment verify\nhost storage status\nhost storage connect\nhost storage reconcile\nhost storage rotate\nhost storage reset\nhost security plan\nhost security initialize\nhost security status\nhost security verify\nhost security rotate\nhost security recovery verify\nhost sandbox status\nhost sandbox doctor\nhost fleet status\nhost update status\nhost update check\nhost update apply\nhost update channel\nhost update pause\nhost update resume\nhost component list\nhost component status\nhost component enable\nhost component disable\nhost aliases list\nhost recovery status\nhost recovery retry\nhost recovery restore\nhost bootstrap status\nhost bootstrap enroll\nhost reset\nhost uninstall\nagents list\nagents show\nagents team clone plan\nagents team clone apply\nagents handlers list\nagents handlers show\nagents profiles show\nagents profiles validate\nagents classes list\nagents classes show\nproviders list\nproviders show\nproviders status\nproviders diagnose\nproviders connect\nproviders disconnect\nproviders registration code status\nproviders registration code reveal\nproviders registration code rotate\nproviders environments list\nproviders environments show\nproviders environments grant\nproviders environments revoke\nproviders requests list\nproviders requests show\nproviders requests approve\nproviders requests reject\nproviders credentials status\nproviders credentials rotate\nproviders credentials revoke\nproviders offers show\nproviders offers validate\nproviders offers plan\nproviders offers apply\nseeds validate\nseeds plan\nseeds apply\nseeds show\nseeds verify\ncapacity status\ncapacity explain\ncapacity usage\ncapacity ledger\ncapacity audit\nworkdays profiles list\nworkdays profiles show\nworkdays profiles update\nworkdays plan\nworkdays start\nworkdays list\nworkdays show\nworkdays watch\nworkdays stop\nworkdays schedules list\nworkdays schedules show\nworkdays schedules plan\nworkdays schedules start\nworkdays schedules pause\nworkdays schedules resume\nworkdays schedules retire\nassignments list\nassignments show\nassignments explain\nassignments watch\nassignments retry\nassignments cancel\nassignments artifacts\nexecution graph show\nexecution graph watch\nexecution node show\nexecution node explain\nexecution reconcile\nexecution assignments list\nprojects treedx show\nprojects treedx bind\nprojects treedx status\nprojects treedx diagnose\nprojects treedx capabilities\nprojects treedx workspaces list\nprojects treedx workspaces show\nprojects treedx workspaces abandon\nai status\nai mode show\nai mode set\nai inference models\nai inference jobs\nai inference rollback\nai training libraries\nai training jobs\nai training runs\nai lab status\nai lab agents\nai lab libraries\nai storage show\nai storage connect\nai storage disconnect\nai storage verify\nlibrary show\nlibrary status\nlibrary paths\nlibrary read\nlibrary search\nlibrary query\nlibrary context\nlibrary workspace create\nlibrary workspace show\nlibrary workspace read\nlibrary workspace diff\nlibrary workspace write\nlibrary workspace submit\nlibrary workspace abandon\nlibrary reviews list\nlibrary reviews decide\nlibrary reviews publish\nsave\nstage\nrelease\nstatus\ndiagnose" COMPREPLY=( $(compgen -W "$paths" -- "${COMP_WORDS[*]:1}") ) } complete -F _trsd_complete trsd diff --git a/schemas/command-tree.json b/schemas/command-tree.json index d9ddeb7..495471e 100644 --- a/schemas/command-tree.json +++ b/schemas/command-tree.json @@ -1235,6 +1235,126 @@ } ] } + }, + { + "nodeType": "leaf", + "segment": "withdraw", + "description": "Withdraw the selected resource.", + "kind": "mutation", + "arguments": [ + { + "name": "proposal", + "description": "proposal identity or path.", + "required": true + } + ], + "options": [ + { + "name": "--plan", + "description": "Return the exact proposed outcome without mutation.", + "type": "boolean" + }, + { + "name": "--input", + "description": "Optional YAML or JSON withdrawal reason and evidence.", + "type": "string" + } + ], + "authorization": { + "capability": "command.withdraw", + "confirmation": "never" + }, + "resultSchemaId": "treeseed.command.withdraw/v1", + "execution": { + "kind": "operation", + "operationId": "governance.proposals.withdraw", + "input": [ + { + "target": "path", + "field": "projectId", + "source": "context", + "name": "project", + "required": true, + "transform": "identity" + }, + { + "target": "path", + "field": "proposalId", + "source": "argument", + "name": "proposal", + "required": true, + "transform": "identity" + }, + { + "target": "body", + "field": "file", + "source": "option", + "name": "input", + "required": false, + "transform": "identity" + } + ] + } + }, + { + "nodeType": "leaf", + "segment": "supersede", + "description": "Supersede the selected resource.", + "kind": "mutation", + "arguments": [ + { + "name": "proposal", + "description": "proposal identity or path.", + "required": true + } + ], + "options": [ + { + "name": "--plan", + "description": "Return the exact proposed outcome without mutation.", + "type": "boolean" + }, + { + "name": "--input", + "description": "Optional YAML or JSON successor, reason, and evidence.", + "type": "string" + } + ], + "authorization": { + "capability": "command.supersede", + "confirmation": "never" + }, + "resultSchemaId": "treeseed.command.supersede/v1", + "execution": { + "kind": "operation", + "operationId": "governance.proposals.supersede", + "input": [ + { + "target": "path", + "field": "projectId", + "source": "context", + "name": "project", + "required": true, + "transform": "identity" + }, + { + "target": "path", + "field": "proposalId", + "source": "argument", + "name": "proposal", + "required": true, + "transform": "identity" + }, + { + "target": "body", + "field": "file", + "source": "option", + "name": "input", + "required": false, + "transform": "identity" + } + ] + } } ] }, diff --git a/src/cli/commands/development-support/host-runtime.ts b/src/cli/commands/development-support/host-runtime.ts index 2760638..f03f740 100644 --- a/src/cli/commands/development-support/host-runtime.ts +++ b/src/cli/commands/development-support/host-runtime.ts @@ -1,6 +1,6 @@ import { createHash, randomUUID } from 'node:crypto'; import { execFileSync } from 'node:child_process'; -import { existsSync, mkdirSync, readdirSync, readFileSync, renameSync, rmSync, writeFileSync } from 'node:fs'; +import { existsSync, mkdirSync, readdirSync, readFileSync, renameSync, writeFileSync } from 'node:fs'; import { basename, relative, resolve } from 'node:path'; import type { CommandContext, ParsedInvocation } from '../../types.js'; import { invokeLocalHostManager } from '../../support/host-client.js'; @@ -83,18 +83,15 @@ export async function runHostDevelopment(invocation: ParsedInvocation, context: if (invocation.options.plan === true) return { action: 'guest-image-import', image, mutation: false }; const stateBase = context.env.XDG_STATE_HOME ?? (context.env.HOME ? resolve(context.env.HOME, '.local', 'state') : null); if (!stateBase) throw new Error('HOME or XDG_STATE_HOME is required for development guest-image custody.'); - const directory = resolve(stateBase, 'treeseed', 'development', 'images'), archivePath = resolve(directory, `sandbox-${randomUUID()}.tar`); + if (!json) context.write(`Importing ${image} through the local manager…`, 'stdout'); + const result = await invoke(context, 'local.dev.host.guest-image.import', { image }) as { digest?: unknown; architecture?: unknown }; + if (typeof result.digest !== 'string' || !/^sha256:[a-f0-9]{64}$/u.test(result.digest)) throw new Error('Host guest-image import omitted its immutable digest.'); + const directory = resolve(stateBase, 'treeseed', 'development'); mkdirSync(directory, { recursive: true, mode: 0o700 }); - try { - if (!json) context.write(`Exporting ${image} for the local Kata runtime…`, 'stdout'); - execFileSync('docker', ['image', 'save', '--output', archivePath, image], { cwd: context.cwd, env: context.env, stdio: json ? 'pipe' : 'inherit' }); - const result = await invoke(context, 'local.dev.host.guest-image.import', { archivePath, image }) as { digest?: unknown; architecture?: unknown }; - if (typeof result.digest !== 'string' || !/^sha256:[a-f0-9]{64}$/u.test(result.digest)) throw new Error('Host guest-image import omitted its immutable digest.'); - const receipt = resolve(stateBase, 'treeseed', 'development', 'sandbox-guest.json'), temporary = `${receipt}.${process.pid}.tmp`; - writeFileSync(temporary, `${JSON.stringify({ schemaVersion: 'treeseed.development-sandbox-guest/v1', image, digest: result.digest, architecture: result.architecture, importedAt: new Date().toISOString() }, null, 2)}\n`, { mode: 0o600 }); - renameSync(temporary, receipt); - return result; - } finally { rmSync(archivePath, { force: true }); } + const receipt = resolve(directory, 'sandbox-guest.json'), temporary = `${receipt}.${process.pid}.tmp`; + writeFileSync(temporary, `${JSON.stringify({ schemaVersion: 'treeseed.development-sandbox-guest/v1', image, digest: result.digest, architecture: result.architecture, importedAt: new Date().toISOString() }, null, 2)}\n`, { mode: 0o600 }); + renameSync(temporary, receipt); + return result; } if (invocation.command.name !== 'dev host activate') throw new Error(`Unsupported host development command ${invocation.command.name}.`); const worktree = resolve(String(invocation.arguments[0] ?? defaultWorktree(context.cwd))); diff --git a/src/cli/commands/development.ts b/src/cli/commands/development.ts index 55c0b33..fd5ea38 100644 --- a/src/cli/commands/development.ts +++ b/src/cli/commands/development.ts @@ -290,13 +290,11 @@ async function rebuildPackage(input: { state: LocalSessionState; record: { sessi installPackageOverlay(state, record, runtime, target, worktree, overlayRoot); await markRebuilt(context, state.sessionId, runtime.project.id, target.id, mode, target); } - async function restartConsumer(input: { state: LocalSessionState; runtime: DevelopmentRuntime; target: DevelopmentTarget; worktree: string; mode: 'candidate' | 'live'; context: CommandContext; recordGeneration?: boolean }) { const { state, runtime, target, worktree, mode, context } = input, key = `${runtime.project.id}.${target.id}`; await stopProcess(state, key); if (usesManagedContainer(target)) { - // A manager refusal (for example, an active Kata claim) leaves the current - // runtime in place. Do not retire its selection before that custody check. + // Preserve the live selection if manager custody refuses an active claim. await containerOperation(context, state.sessionId, runtime, target, 'stop'); await invoke(context, 'local.dev.use', {sessionId:state.sessionId,projectId:runtime.project.id,targetId:target.id,mode:'released'}); } @@ -316,7 +314,6 @@ async function restartConsumer(input: { state: LocalSessionState; runtime: Devel } if (input.recordGeneration !== false) await markRebuilt(context, state.sessionId, runtime.project.id, target.id, mode, target); } - async function restart(invocation: ParsedInvocation, context: CommandContext, state: LocalSessionState, sessionId: string) { const selection = parseSelection(`${invocation.arguments[0]}=candidate`); const status = await invoke(context, 'local.dev.status', { sessionId, all: false }) as DevelopmentStatusRecord; @@ -339,7 +336,6 @@ async function restart(invocation: ParsedInvocation, context: CommandContext, st saveState(state, context.env); return { sessionId, target: `${selection.projectId}.${selection.targetId}`, restarted: true, record: await invoke(context, 'local.dev.status', { sessionId, all: false }) }; } - async function rebuild(invocation: ParsedInvocation, context: CommandContext, state: LocalSessionState, sessionId: string) { const selection = parseSelection(`${invocation.arguments[0]}=candidate`); const runtimes = (await loadDevelopmentRuntimes(state.manifest)).map(({ runtime }) => runtime); diff --git a/tests/unit/command-boundary/canonical-client.test.ts b/tests/unit/command-boundary/canonical-client.test.ts index 824011b..68fbbd8 100644 --- a/tests/unit/command-boundary/canonical-client.test.ts +++ b/tests/unit/command-boundary/canonical-client.test.ts @@ -54,58 +54,6 @@ test('leaf commands expose only catalog-derived high-level options', () => { assert.equal(commandSpecs.some((command) => command.options.some((option) => option.flag === '--execute' || option.flag === '--market')), false); }); -test('assignment explain preserves allocator evidence without inventing a CLI budget', async () => { - const calls: Array<{ operationId: string; input: any }> = []; - const output: string[] = []; - const allocation = { allocatedSeconds: 60, limitingConstraint: 'task-duration', - calibration: { multiplier: 2, measurementIds: [] }, opportunity: { shareSeconds: 600 } }; - const exit = await runCommandLine(['assignments', 'explain', 'assignment-1', - '--team', '11111111-1111-4111-8111-111111111111', '--json'], { - interactiveUi: false, write: (value) => output.push(value), - operationInvoke: async (operationId, input) => { - calls.push({ operationId, input }); return { data: { metadata: { allocation } } }; - }, - }); - assert.equal(exit, 0, output.join('')); - assert.equal(calls[0]?.operationId, 'assignments.explain'); - assert.deepEqual(calls[0]?.input.path, { teamId: '11111111-1111-4111-8111-111111111111', assignmentId: 'assignment-1' }); - assert.deepEqual(JSON.parse(output.at(-1)!).result.metadata.allocation, allocation); -}); - -test('workday profile update sends a validated policy document under the API policy field', async () => { - const root = mkdtempSync(resolve(tmpdir(), 'treeseed-workday-policy-')); - const file = resolve(root, 'policy.json'); - const policy = { durationSeconds: 28_800, maximumConcurrency: 1, communicationConcurrency: 1, - planningPercent: 20, projectPercentages: { sdk: 100 }, agentClassPercentages: { sdk: { engineer: 100 } } }; - const calls: Array<{ operationId: string; input: any }> = []; - const output: string[] = []; - try { - writeFileSync(file, JSON.stringify(policy)); - const exit = await runCommandLine(['workdays', 'profiles', 'update', 'default', '--team', '11111111-1111-4111-8111-111111111111', '--input', file, - '--if-match', '1', '--yes', '--json'], { interactiveUi: false, write: value => output.push(value), - operationInvoke: async (operationId, input) => { calls.push({ operationId, input }); return { data: { id: 'default', teamId: '11111111-1111-4111-8111-111111111111', revision: 2, policy } }; } }); - assert.equal(exit, 0, output.join('')); - assert.equal(calls[0]?.operationId, 'workdays.profiles.update'); - assert.deepEqual(calls[0]?.input.body.policy, { ...policy, allocationWeight: 1, planningTurnMaximumSeconds: 180 }); - assert.equal(calls[0]?.input.body.file, undefined); - } finally { rmSync(root, { recursive: true, force: true }); } -}); - -test('workday profile update rejects an invalid policy file before mutation', async () => { - const root = mkdtempSync(resolve(tmpdir(), 'treeseed-workday-policy-invalid-')); - const file = resolve(root, 'policy.json'); - const calls: string[] = []; - const output: string[] = []; - try { - writeFileSync(file, JSON.stringify({ durationSeconds: -1, maximumConcurrency: 1, communicationConcurrency: 1 })); - const exit = await runCommandLine(['workdays', 'profiles', 'update', 'default', '--team', '11111111-1111-4111-8111-111111111111', - '--input', file, '--if-match', '1', '--yes', '--json'], { interactiveUi: false, write: value => output.push(value), - operationInvoke: async (operationId) => { calls.push(operationId); return { data: {} }; } }); - assert.equal(exit, 1); - assert.deepEqual(calls, []); - assert.equal(JSON.parse(output.at(-1)!).error.code, 'workday_policy_file_invalid'); - } finally { rmSync(root, { recursive: true, force: true }); } -}); test('host commands preserve the SDK handler boundary and stable envelope', async () => { const calls: unknown[] = []; const output: string[] = []; @@ -117,20 +65,6 @@ test('host commands preserve the SDK handler boundary and stable envelope', asyn assert.deepEqual(JSON.parse(output[0]!).result, { componentId: 'agent', healthy: true }); }); -test('host lifecycle commands preserve plan/noop/result envelopes and never need a remote server', async () => { - const calls: unknown[] = []; const output: string[] = []; - const invoke = async (input: unknown) => { calls.push(input); return (input as { handlerId: string }).handlerId === 'local.dev.status' - ? { sessions: [] } : { state: 'stopped', changed: false }; }; - for (const action of ['stop', 'start'] as const) { - for (const plan of [true, false]) { - const args = ['host', action, ...(plan ? ['--plan'] : ['--yes']), '--json']; - assert.equal(await runCommandLine(args, { interactiveUi: false, hostInvoke: invoke, write: (value) => output.push(value) }), 0); - assert.deepEqual(calls.at(-1), { handlerId: `local.host.${action}`, arguments: [], options: plan ? { plan: true } : {} }); - if (!plan) assert.deepEqual(calls.at(-2), { handlerId: 'local.dev.status', arguments: [], options: { payload: '{"all":true}' } }); - assert.deepEqual(JSON.parse(output.at(-1)!).result, { state: 'stopped', changed: false }); - } - } -}); test('host stop suspends a selected development session before stopping released workloads', async () => { const root = mkdtempSync(resolve(tmpdir(), 'treeseed-cli-host-stop-')); diff --git a/tests/unit/command-boundary/host/lifecycle-envelopes.test.ts b/tests/unit/command-boundary/host/lifecycle-envelopes.test.ts new file mode 100644 index 0000000..9466d43 --- /dev/null +++ b/tests/unit/command-boundary/host/lifecycle-envelopes.test.ts @@ -0,0 +1,18 @@ +import assert from 'node:assert/strict'; +import test from 'node:test'; +import { runCommandLine } from '../../../../src/cli/runtime.ts'; + +test('host lifecycle commands preserve plan/noop/result envelopes and never need a remote server', async () => { + const calls: unknown[] = []; const output: string[] = []; + const invoke = async (input: unknown) => { calls.push(input); return (input as { handlerId: string }).handlerId === 'local.dev.status' + ? { sessions: [] } : { state: 'stopped', changed: false }; }; + for (const action of ['stop', 'start'] as const) { + for (const plan of [true, false]) { + const args = ['host', action, ...(plan ? ['--plan'] : ['--yes']), '--json']; + assert.equal(await runCommandLine(args, { interactiveUi: false, hostInvoke: invoke, write: (value) => output.push(value) }), 0); + assert.deepEqual(calls.at(-1), { handlerId: `local.host.${action}`, arguments: [], options: plan ? { plan: true } : {} }); + if (!plan) assert.deepEqual(calls.at(-2), { handlerId: 'local.dev.status', arguments: [], options: { payload: '{"all":true}' } }); + assert.deepEqual(JSON.parse(output.at(-1)!).result, { state: 'stopped', changed: false }); + } + } +}); diff --git a/tests/unit/command-boundary/workdays/allocation-policy.test.ts b/tests/unit/command-boundary/workdays/allocation-policy.test.ts new file mode 100644 index 0000000..239f83b --- /dev/null +++ b/tests/unit/command-boundary/workdays/allocation-policy.test.ts @@ -0,0 +1,59 @@ +import assert from 'node:assert/strict'; +import { mkdtempSync, rmSync, writeFileSync } from 'node:fs'; +import { tmpdir } from 'node:os'; +import { resolve } from 'node:path'; +import test from 'node:test'; +import { runCommandLine } from '../../../../src/cli/runtime.ts'; + +test('assignment explain preserves allocator evidence without inventing a CLI budget', async () => { + const calls: Array<{ operationId: string; input: any }> = []; + const output: string[] = []; + const allocation = { allocatedSeconds: 60, limitingConstraint: 'task-duration', + calibration: { multiplier: 2, measurementIds: [] }, opportunity: { shareSeconds: 600 } }; + const exit = await runCommandLine(['assignments', 'explain', 'assignment-1', + '--team', '11111111-1111-4111-8111-111111111111', '--json'], { + interactiveUi: false, write: (value) => output.push(value), + operationInvoke: async (operationId, input) => { + calls.push({ operationId, input }); return { data: { metadata: { allocation } } }; + }, + }); + assert.equal(exit, 0, output.join('')); + assert.equal(calls[0]?.operationId, 'assignments.explain'); + assert.deepEqual(calls[0]?.input.path, { teamId: '11111111-1111-4111-8111-111111111111', assignmentId: 'assignment-1' }); + assert.deepEqual(JSON.parse(output.at(-1)!).result.metadata.allocation, allocation); +}); + +test('workday profile update sends a validated policy document under the API policy field', async () => { + const root = mkdtempSync(resolve(tmpdir(), 'treeseed-workday-policy-')); + const file = resolve(root, 'policy.json'); + const policy = { durationSeconds: 28_800, maximumConcurrency: 1, communicationConcurrency: 1, + planningPercent: 20, projectPercentages: { sdk: 100 }, agentClassPercentages: { sdk: { engineer: 100 } } }; + const calls: Array<{ operationId: string; input: any }> = []; + const output: string[] = []; + try { + writeFileSync(file, JSON.stringify(policy)); + const exit = await runCommandLine(['workdays', 'profiles', 'update', 'default', '--team', '11111111-1111-4111-8111-111111111111', '--input', file, + '--if-match', '1', '--yes', '--json'], { interactiveUi: false, write: value => output.push(value), + operationInvoke: async (operationId, input) => { calls.push({ operationId, input }); return { data: { id: 'default', teamId: '11111111-1111-4111-8111-111111111111', revision: 2, policy } }; } }); + assert.equal(exit, 0, output.join('')); + assert.equal(calls[0]?.operationId, 'workdays.profiles.update'); + assert.deepEqual(calls[0]?.input.body.policy, { ...policy, allocationWeight: 1, planningTurnMaximumSeconds: 180 }); + assert.equal(calls[0]?.input.body.file, undefined); + } finally { rmSync(root, { recursive: true, force: true }); } +}); + +test('workday profile update rejects an invalid policy file before mutation', async () => { + const root = mkdtempSync(resolve(tmpdir(), 'treeseed-workday-policy-invalid-')); + const file = resolve(root, 'policy.json'); + const calls: string[] = []; + const output: string[] = []; + try { + writeFileSync(file, JSON.stringify({ durationSeconds: -1, maximumConcurrency: 1, communicationConcurrency: 1 })); + const exit = await runCommandLine(['workdays', 'profiles', 'update', 'default', '--team', '11111111-1111-4111-8111-111111111111', + '--input', file, '--if-match', '1', '--yes', '--json'], { interactiveUi: false, write: value => output.push(value), + operationInvoke: async (operationId) => { calls.push(operationId); return { data: {} }; } }); + assert.equal(exit, 1); + assert.deepEqual(calls, []); + assert.equal(JSON.parse(output.at(-1)!).error.code, 'workday_policy_file_invalid'); + } finally { rmSync(root, { recursive: true, force: true }); } +}); From 86f2e6e004984263c6f0d5ad2e1923a2daf02fab Mon Sep 17 00:00:00 2001 From: Adrian Webb Date: Sat, 26 Sep 2026 05:59:39 -0400 Subject: [PATCH 6/9] Pin checked SDK contract and preserve typed dependent rebuild operations --- .github/workflows/verify.yml | 2 +- src/cli/commands/development-support/overlays.ts | 2 +- src/cli/commands/development.ts | 6 +++--- 3 files changed, 5 insertions(+), 5 deletions(-) diff --git a/.github/workflows/verify.yml b/.github/workflows/verify.yml index ef69b30..9e120c9 100644 --- a/.github/workflows/verify.yml +++ b/.github/workflows/verify.yml @@ -31,7 +31,7 @@ jobs: run: npm ci --no-audit --no-fund - name: Install exact integrated SDK - uses: treeseed-ai/sdk/.github/actions/install-exact-sdk@0bc542a8c2919bd58ac16f5a2b12eace961ef755 + uses: treeseed-ai/sdk/.github/actions/install-exact-sdk@affa0623b8af92cde577f3b8ab685c1b61f2c8cb with: github-token: ${{ github.token }} diff --git a/src/cli/commands/development-support/overlays.ts b/src/cli/commands/development-support/overlays.ts index 02feea3..40e70cc 100644 --- a/src/cli/commands/development-support/overlays.ts +++ b/src/cli/commands/development-support/overlays.ts @@ -171,7 +171,7 @@ export async function stopProcess(state: OverlaySessionState, key: string) { } export function dependentReactions(runtimes: DevelopmentRuntime[], projectId: string, targetId: string) { - const result: Array<{ runtime: DevelopmentRuntime; target: DevelopmentTarget; reaction: string }> = [], queued = [`${projectId}.${targetId}`], seen = new Set(queued); + const result: Array<{ runtime: DevelopmentRuntime; target: DevelopmentTarget; reaction: DevelopmentTarget['dependencies'][number]['reaction'] }> = [], queued = [`${projectId}.${targetId}`], seen = new Set(queued); while (queued.length) { const selected = queued.shift()!; for (const runtime of runtimes) for (const target of runtime.targets) for (const dependency of target.dependencies) { diff --git a/src/cli/commands/development.ts b/src/cli/commands/development.ts index fd5ea38..83ee398 100644 --- a/src/cli/commands/development.ts +++ b/src/cli/commands/development.ts @@ -118,7 +118,6 @@ function operationIsRunning(state: LocalSessionState, key: string) { if (ownsDevelopmentProcess(existing, state.sessionId)) return true; delete state.processes[key]; return false; } - async function waitForDirectReadiness(target: DevelopmentTarget, timeoutSeconds: number, state?: LocalSessionState, key?: string) { if (target.ready.kind === 'process') { if (!state || !key) throw new Error(`Process readiness for ${target.id} requires tracked process state.`); @@ -141,7 +140,6 @@ async function waitForDirectReadiness(target: DevelopmentTarget, timeoutSeconds: } throw new Error(`Readiness timed out for ${target.id}.`); } - async function startSession(invocation: ParsedInvocation, context: CommandContext) { const manifest = resolve(context.cwd, invocation.arguments[0]!); if (invocation.options.plan !== true) assertNoSelectedDevelopmentCustody(context.env); @@ -384,7 +382,9 @@ async function rebuild(invocation: ParsedInvocation, context: CommandContext, st if (action === 'package-rebuild') await rebuildPackage(dependentInput); else if (action === 'rebuild-restart') await restartConsumer(dependentInput); else if (action === 'build-only') { - runOneShotOperation(state, dependent.target.operations.build, dependentRepository.worktree, dependentSelection.mode, context.env); + const build = dependent.target.operations.build; + if (!build) throw new Error('Build-only dependent has no build operation.'); + runOneShotOperation(state, build, dependentRepository.worktree, dependentSelection.mode, context.env); await markRebuilt(context, sessionId, dependent.runtime.project.id, dependent.target.id, dependentSelection.mode, dependent.target); } else await restartConsumer(dependentInput); } From 5dff89a7243ecbf540ac1cb9a2919245094d5a2d Mon Sep 17 00:00:00 2001 From: Adrian Webb Date: Sat, 26 Sep 2026 06:00:22 -0400 Subject: [PATCH 7/9] Use canonical dependency reaction type across development selection --- src/cli/commands/development-support/selection.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/cli/commands/development-support/selection.ts b/src/cli/commands/development-support/selection.ts index 6ac45e8..2e3ae1d 100644 --- a/src/cli/commands/development-support/selection.ts +++ b/src/cli/commands/development-support/selection.ts @@ -13,7 +13,7 @@ export function selectedDevelopmentTarget(record: unknown, projectId: string, ta return { runtime, target }; } -export function dependentDevelopmentAction(reaction: 'none' | 'restart' | 'rebuild' | 'manual', target: Pick) { +export function dependentDevelopmentAction(reaction: DevelopmentTarget['dependencies'][number]['reaction'], target: Pick) { if (reaction === 'manual') return 'manual' as const; if (reaction !== 'rebuild') return 'restart' as const; if (target.kind === 'package-watch') return 'package-rebuild' as const; From 22ec9de24e19784ba1c786e033026a97a6079467 Mon Sep 17 00:00:00 2001 From: Adrian Webb Date: Sat, 26 Sep 2026 06:02:35 -0400 Subject: [PATCH 8/9] Keep CLI thin while verifying declarative owner guarantee metadata --- tests/contract/package/thin-package.test.ts | 19 ++++++++++++++++++- 1 file changed, 18 insertions(+), 1 deletion(-) diff --git a/tests/contract/package/thin-package.test.ts b/tests/contract/package/thin-package.test.ts index c2c9160..e22fd14 100644 --- a/tests/contract/package/thin-package.test.ts +++ b/tests/contract/package/thin-package.test.ts @@ -1,6 +1,7 @@ import assert from 'node:assert/strict'; import { existsSync, readFileSync, readdirSync } from 'node:fs'; import test from 'node:test'; +import { parse } from 'yaml'; test('package has one executable and only its declared CLI runtime dependencies', () => { const pkg = JSON.parse(readFileSync('package.json', 'utf8')); @@ -67,5 +68,21 @@ test('source contains no legacy implementation residue', () => { for (const forbidden of ['MarketClient', 'marketId', '--market', 'operator/commands', 'workflow-support']) assert.equal(source.includes(forbidden), false, forbidden); const nonHostTransport = sourceFiles.filter((file) => !file.endsWith('/host-client.ts')).map((file) => readFileSync(file, 'utf8')).join('\n'); assert.equal(nonHostTransport.includes('/v1/'), false, '/v1/ outside the fixed host-manager transport'); - for (const removed of ['docs/src', 'guarantees', '.gitmodules']) assert.equal(existsSync(removed), false, removed); + for (const removed of ['docs/src', '.gitmodules']) assert.equal(existsSync(removed), false, removed); +}); + +test('guarantee metadata binds owner tests without adding a second CLI implementation', () => { + const files = readdirSync('guarantees', { recursive: true, withFileTypes: true }).filter(entry => entry.isFile()); + assert.equal(files.length, 3); + for (const entry of files) { + assert.match(entry.name, /\.yaml$/u); + const document = parse(readFileSync(`${entry.parentPath}/${entry.name}`, 'utf8')); + assert.match(document.schemaVersion, /^treeseed\.(guarantee|scene|guarantee-verifiers)\/v1$/u); + if (document.verifiers) for (const verifier of Object.values(document.verifiers) as Array<{kind: string; ownerPackage: string; testFile: string}>) { + assert.equal(verifier.kind, 'nodeTestCase'); + assert.equal(verifier.ownerPackage, '@treeseed/cli'); + assert.match(verifier.testFile, /^tests\/unit\/command-boundary\//u); + assert.equal(existsSync(verifier.testFile), true); + } + } }); From c88bc96123702d46663c13d5071d183ebe488631 Mon Sep 17 00:00:00 2001 From: Adrian Webb Date: Sat, 26 Sep 2026 06:02:54 -0400 Subject: [PATCH 9/9] Declare canonical CLI scene identity and component scope --- .../agent/golden/scenes/component-boundaries.scene.yaml | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/guarantees/agent/golden/scenes/component-boundaries.scene.yaml b/guarantees/agent/golden/scenes/component-boundaries.scene.yaml index dcee1ae..70dd996 100644 --- a/guarantees/agent/golden/scenes/component-boundaries.scene.yaml +++ b/guarantees/agent/golden/scenes/component-boundaries.scene.yaml @@ -1,3 +1,7 @@ +schemaVersion: treeseed.scene/v1 +id: cli.golden.component-boundaries +title: Golden operator component boundaries +scope: local-component-tests workflow: - id: refused-rebuild action: { verifier: cli.golden.refused-rebuild }