diff --git a/README.md b/README.md index c483a1d..3805ccb 100644 --- a/README.md +++ b/README.md @@ -176,8 +176,8 @@ Review the evidence in `fleet-proposal.json`, edit its `review.status` to `approved`, and identify the reviewer. Then inspect and apply the installation: ```bash -node bin/torch.mjs install --proposal fleet-proposal.json --dry-run --json -node bin/torch.mjs install --proposal fleet-proposal.json --yes --json +node bin/torch.mjs install --proposal fleet-proposal.json --runtime claude --dry-run --json +node bin/torch.mjs install --proposal fleet-proposal.json --runtime claude --yes --json node bin/torch.mjs doctor --json node bin/torch.mjs worktrees --dry-run --json node bin/torch.mjs worktrees --yes --json @@ -189,10 +189,22 @@ step: ```bash node bin/torch.mjs up --fresh --yes --json +node bin/torch.mjs up --only backend,qa --dry-run --json +node bin/torch.mjs list --json +node bin/torch.mjs brief --area backend --json +node bin/torch.mjs capture --json node bin/torch.mjs down --dry-run --json node bin/torch.mjs down --yes --json +node bin/torch.mjs detach --dry-run --json +node bin/torch.mjs detach --yes --json ``` +`capture` externalizes the current stable identities, runtime IDs, presence, +and worktree commits without stopping sessions. `detach` first enforces normal +wind-down safety, then marks runtime integration detached while preserving the +tracked organization, local state, branches, and worktrees. A later explicit +`up` reattaches the selected runtime sessions. + The Fleet can evolve when recurring work develops a coherent new boundary. The Session Manager may propose the domain through MCP or the CLI, but cannot approve or activate it. Owner approval and activation are deliberately @@ -213,6 +225,19 @@ rejects silent ownership overlap, unknown checks/runtimes, worker-created roster changes, stale activation commits, and activation without matching owner approval. +The manager can also submit evidence-backed boundary changes: + +```bash +torch fleet propose-merge --from session-manager --proposal merge.json --json +torch fleet propose-split --from session-manager --proposal split.json --json +torch fleet reject --change --by --reason "Not yet" --yes --json +``` + +Every existing owned-path pattern must be assigned exactly once. Approval +records the owner's decision but does not activate a merge or split: TORCH +returns a migration-required blocker until backlog, prompts, worktrees, +branches, neighbours, and ownership transfer have a separately proven plan. + The CLI fallback exposes `agents`, `who-owns`, `message`, `inbox`, `ack`, `status`, `complete`, `blocked`, `coordinate`, `handoff`, and the tracked `backlog` lifecycle. MCP hosts launch @@ -280,9 +305,16 @@ torch schedules plan --id fleet-hygiene --actor session-manager --json torch schedules run --id fleet-hygiene --actor session-manager --json ``` -Mutating schedules additionally require `--yes`. Cron definitions are exposed -for an external system launcher; TORCH does not silently install persistent -timers. +Mutating schedules additionally require `--yes`. TORCH can preview, install, +inspect, and exactly remove its user-systemd launcher, but never installs the +persistent timer implicitly: + +```bash +torch schedules launcher plan --json +torch schedules launcher install --yes --json +torch schedules launcher status --json +torch schedules launcher remove --yes --json +``` ## Product principles diff --git a/reports/test-audit/test-integrity-note-2026-09-27-portable-kernel.yaml b/reports/test-audit/test-integrity-note-2026-09-27-portable-kernel.yaml index 6dd28e5..ff45580 100644 --- a/reports/test-audit/test-integrity-note-2026-09-27-portable-kernel.yaml +++ b/reports/test-audit/test-integrity-note-2026-09-27-portable-kernel.yaml @@ -182,6 +182,14 @@ test_integrity_note: given: an installed Fleet and a file-backed proposed domain when: the manager proposes it and the owner separately approves, plans, and activates it through the public CLI then: missing approval is refused and the activated identity is queryable through the ordinary Fleet CLI + - id: SCN-cli-lifecycle-surface + given: an approved installed Fleet with isolated worktrees + when: an operator selects runtimes, previews selected startup, reads list and brief output, captures state, and detaches + then: the documented lifecycle commands share durable state, preserve the organization and worktrees, and require approval before stopping integrations + - id: SCN-install-runtime-selection + given: an approved Fleet proposal with declared runtime assignments + when: installation selects its enabled runtime adapters + then: unsupported or incomplete selections fail closed and the installed configuration contains the reviewed adapters - id: SCN-product-site given: the redesigned TORCH public surface when: its product story and local-console boundary are inspected diff --git a/src/cli.mjs b/src/cli.mjs index 9df72b6..39b53e1 100644 --- a/src/cli.mjs +++ b/src/cli.mjs @@ -19,7 +19,10 @@ import { readFileSync } from 'node:fs'; import { resolve } from 'node:path'; import { writeNewFile } from './kernel/files.mjs'; import { createWorktrees, planWorktrees } from './kernel/worktrees.mjs'; -import { planAreaUp, planFleetDown, planFleetUp, startFleet, stopFleet } from './runtime/lifecycle.mjs'; +import { + captureFleet, createFleetBrief, detachFleet, planAreaUp, planFleetDetach, + planFleetDown, planFleetUp, startFleet, stopFleet, +} from './runtime/lifecycle.mjs'; import { ResourceService } from './resources/service.mjs'; import { CANDIDATE_ACCEPTANCE_SCENARIOS, createVersionService } from './self-host/service.mjs'; import { observeProject } from './observability/snapshot.mjs'; @@ -44,10 +47,14 @@ Usage: torch architect validate --brief --response [--json] torch architect run --brief --provider --output [--model ] [--max-budget-usd ] --yes [--json] torch domains [--repo ] [--spec ] [--output ] [--json] - torch install --proposal [--dry-run] [--yes] [--json] + torch install --proposal [--runtime ] [--dry-run] [--yes] [--json] torch worktrees [--parent ] [--dry-run] [--yes] [--json] - torch up [--fresh] [--dry-run] [--yes] [--json] + torch up [--fresh] [--only ] [--dry-run] [--yes] [--json] torch down [--dry-run] [--yes] [--json] + torch capture [--json] + torch detach [--dry-run] [--yes] [--json] + torch list [--json] + torch brief [--area ] [--json] torch identity --area [--json] torch agents [--json] torch agent --area [--json] @@ -181,6 +188,25 @@ function runtimeAdapters() { ]); } +function runtimeStoppers(spawn) { + return new Map([ + ['claude', createClaudeAdapter({ + runner: (executable, args) => spawn(executable, args, { + encoding: 'utf8', stdio: ['ignore', 'pipe', 'pipe'], + }), + })], + ['codex', createCodexAdapter()], + ]); +} + +function stopThrough(adapters, action) { + const adapter = adapters.get(action.runtime); + if (!adapter) throw new TorchError(`No runtime stopper for ${action.runtime}`, { + code: 'RUNTIME_ADAPTER_MISSING', details: { runtime: action.runtime }, + }); + return adapter.stopSession({ runtimeSessionId: action.runtimeSessionId }); +} + function runCandidateAcceptance(candidateRoot, spawn) { const checks = [ { id: 'test', args: ['test'] }, @@ -204,6 +230,7 @@ function runCandidateAcceptance(candidateRoot, spawn) { ['ai-fleet-planning', ['SCN-ai-fleet-planning']], ['fleet-evolution', ['SCN-fleet-evolution', 'SCN-cli-fleet-evolution']], ['fleet-boundary-evolution', ['SCN-fleet-boundary-evolution']], + ['cli-lifecycle-surface', ['SCN-cli-lifecycle-surface']], ['review-install-roster', ['SCN-cli-domain-review']], ['branches-worktrees', ['SCN-worktree-bootstrap']], ['runtime-identities', ['SCN-mixed-runtime']], @@ -439,9 +466,11 @@ export async function runCli(argv = process.argv.slice(2), { } if (command === 'install') { const proposalPath = optionValue(argv, '--proposal'); + const runtimeOption = optionValue(argv, '--runtime'); + const runtimes = runtimeOption === undefined ? undefined : commaList(runtimeOption); if (argv.includes('--dry-run')) { const proposal = loadProposal(cwd, proposalPath); - print({ ...planInstall({ repository, proposal, env }), proposal: resolve(cwd, proposalPath) }, { json }); + print({ ...planInstall({ repository, proposal, env, runtimes }), proposal: resolve(cwd, proposalPath) }, { json }); return 0; } if (!argv.includes('--yes')) { @@ -450,7 +479,7 @@ export async function runCli(argv = process.argv.slice(2), { }); } const proposal = loadProposal(cwd, proposalPath); - print(installProject({ repository, proposal, env }), { json }); + print(installProject({ repository, proposal, env, runtimes }), { json }); return 0; } if (command === 'doctor') { @@ -479,6 +508,7 @@ export async function runCli(argv = process.argv.slice(2), { const adapters = runtimeAdapters(); const plan = planFleetUp({ repositoryRoot: repository.root, controlPlane: control, adapters, fresh: argv.includes('--fresh'), + only: optionValue(argv, '--only') === undefined ? undefined : commaList(optionValue(argv, '--only')), }); if (argv.includes('--dry-run')) { print(plan, { json }); @@ -514,18 +544,40 @@ export async function runCli(argv = process.argv.slice(2), { code: 'APPROVAL_REQUIRED', }); } - const runtimeStoppers = new Map([ - ['claude', createClaudeAdapter({ - runner: (executable, args) => spawn(executable, args, { - encoding: 'utf8', stdio: ['ignore', 'pipe', 'pipe'], - }), - })], - ['codex', createCodexAdapter()], - ]); + const stoppers = runtimeStoppers(spawn); print(stopFleet({ plan, controlPlane: control, - stopRuntime: (action) => runtimeStoppers.get(action.runtime) - .stopSession({ runtimeSessionId: action.runtimeSessionId }), + stopRuntime: (action) => stopThrough(stoppers, action), + }), { json }); + return 0; + } finally { + control.close(); + } + } + if (command === 'capture') { + print(withControlPlane(repository, env, (control) => captureFleet({ + repositoryRoot: repository.root, controlPlane: control, + })), { json }); + return 0; + } + if (command === 'detach') { + const control = openControlPlane({ repositoryRoot: repository.root, env }); + try { + const stoppers = runtimeStoppers(spawn); + const plan = planFleetDetach({ + repositoryRoot: repository.root, controlPlane: control, adapters: stoppers, + }); + if (argv.includes('--dry-run')) { + print(plan, { json }); + return plan.canProceed ? 0 : 1; + } + if (!argv.includes('--yes')) { + throw new TorchError('Fleet detach stops runtime sessions while preserving organization and worktrees. Review --dry-run, then use --yes.', { + code: 'APPROVAL_REQUIRED', + }); + } + print(detachFleet({ + plan, controlPlane: control, stopRuntime: (action) => stopThrough(stoppers, action), }), { json }); return 0; } finally { @@ -536,10 +588,16 @@ export async function runCli(argv = process.argv.slice(2), { print(withControlPlane(repository, env, (control) => control.identity(optionValue(argv, '--area'))), { json }); return 0; } - if (command === 'agents') { + if (command === 'agents' || command === 'list') { print(withControlPlane(repository, env, (control) => ({ agents: control.listAgents() })), { json }); return 0; } + if (command === 'brief') { + print(withControlPlane(repository, env, (control) => createFleetBrief({ + repositoryRoot: repository.root, controlPlane: control, areaId: optionValue(argv, '--area'), + })), { json }); + return 0; + } if (command === 'agent') { print(withControlPlane(repository, env, (control) => control.getAgent(optionValue(argv, '--area'))), { json }); return 0; diff --git a/src/kernel/doctor.mjs b/src/kernel/doctor.mjs index eb5f42d..83dd837 100644 --- a/src/kernel/doctor.mjs +++ b/src/kernel/doctor.mjs @@ -48,6 +48,18 @@ export function diagnoseProject({ repository, env = process.env }) { } const stateRoot = expectedStateRoot; if (!existsSync(stateRoot)) findings.push({ severity: 'error', code: 'LOCAL_STATE_MISSING', path: stateRoot }); + const projectMetadataPath = join(stateRoot, 'project.json'); + if (existsSync(projectMetadataPath)) { + try { + const metadata = parseJsonYaml(projectMetadataPath); + if (metadata.detachedAt) findings.push({ + severity: 'info', code: 'FLEET_DETACHED', detachedAt: metadata.detachedAt, + recommendation: 'Run torch up after reviewing its dry-run plan to attach runtime integrations again.', + }); + } catch (error) { + findings.push({ severity: 'error', code: 'LOCAL_STATE_METADATA_INVALID', message: error.message }); + } + } const databasePath = join(stateRoot, 'state.db'); if (existsSync(databasePath)) { try { diff --git a/src/kernel/install.mjs b/src/kernel/install.mjs index 431597b..3f3b082 100644 --- a/src/kernel/install.mjs +++ b/src/kernel/install.mjs @@ -16,7 +16,34 @@ function jsonYaml(value) { return `${JSON.stringify(value, null, 2)}\n`; } -function initialFiles({ repository, projectId, createdAt, proposal }) { +const RUNTIME_DEFINITIONS = Object.freeze({ + claude: { model: 'opus', background: true }, + codex: { sandbox: 'workspace-write', approval: 'approve-for-me' }, +}); + +function resolveInstallRuntimes(proposal, requested) { + const selected = requested === undefined + ? Object.keys(RUNTIME_DEFINITIONS) + : [...new Set(requested)]; + if (!selected.length || selected.some((runtime) => typeof runtime !== 'string' || !RUNTIME_DEFINITIONS[runtime])) { + throw new TorchError('Install runtimes must be one or more supported adapters', { + code: 'INSTALL_RUNTIME_INVALID', details: { requested: selected, supported: Object.keys(RUNTIME_DEFINITIONS) }, + }); + } + const required = new Set([ + proposal.session_manager?.runtime ?? 'claude', + ...proposal.domains.map((domain) => domain.runtime ?? 'claude'), + ]); + const missing = [...required].filter((runtime) => !selected.includes(runtime)); + if (missing.length) { + throw new TorchError('Selected install runtimes do not cover the approved Fleet', { + code: 'INSTALL_RUNTIME_MISSING', details: { selected, required: [...required], missing }, + }); + } + return selected; +} + +function initialFiles({ repository, projectId, createdAt, proposal, runtimes }) { const promptContent = (source, fallback) => source ? readFileSync(safeProjectPath(repository.root, source), 'utf8') : fallback; @@ -40,11 +67,10 @@ function initialFiles({ repository, projectId, createdAt, proposal }) { allow_force_push: false, allow_bare_stash: false, }, synchronization: { strategy: 'dispatcher-managed', auto_merge_worktrees: false }, - runtimes: { - default: 'claude', - claude: { model: 'opus', background: true }, - codex: { sandbox: 'workspace-write', approval: 'approve-for-me' }, - }, + runtimes: Object.fromEntries([ + ['default', proposal.session_manager?.runtime ?? 'claude'], + ...runtimes.map((runtime) => [runtime, RUNTIME_DEFINITIONS[runtime]]), + ]), checks: proposal.checks ?? [], resources: proposal.resources ?? [], schedules: proposal.schedules ?? [], @@ -174,8 +200,9 @@ function safeProjectPath(root, relativePath) { return target; } -export function planInstall({ repository, proposal, env = process.env }) { +export function planInstall({ repository, proposal, env = process.env, runtimes: requestedRuntimes } = {}) { validateApprovedProposal({ proposal, repository }); + const runtimes = resolveInstallRuntimes(proposal, requestedRuntimes); const trackedRoot = join(repository.root, TRACKED_DIR); return { action: 'install', @@ -184,6 +211,7 @@ export function planInstall({ repository, proposal, env = process.env }) { stateParent: join(projectStatePath('', env), '..'), wouldCreateTrackedState: !existsSync(trackedRoot), approvedDomainCount: proposal.domains.length, + runtimes, mutationPerformed: false, }; } @@ -194,8 +222,10 @@ export function installProject({ env = process.env, projectId = randomUUID(), now = () => new Date(), + runtimes: requestedRuntimes, } = {}) { validateApprovedProposal({ proposal, repository }); + const runtimes = resolveInstallRuntimes(proposal, requestedRuntimes); const trackedRoot = join(repository.root, TRACKED_DIR); if (existsSync(trackedRoot)) { throw new TorchError(`Refusing to replace existing ${trackedRoot}`, { @@ -213,7 +243,7 @@ export function installProject({ const created = []; try { - for (const [path, content] of initialFiles({ repository, projectId, createdAt, proposal })) { + for (const [path, content] of initialFiles({ repository, projectId, createdAt, proposal, runtimes })) { const absolute = join(trackedRoot, path); const record = writeNewFile(absolute, content); created.push({ path: relative(repository.root, record.path), sha256: record.sha256 }); diff --git a/src/runtime/lifecycle.mjs b/src/runtime/lifecycle.mjs index 1429860..70ec28e 100644 --- a/src/runtime/lifecycle.mjs +++ b/src/runtime/lifecycle.mjs @@ -1,5 +1,5 @@ import { execFileSync } from 'node:child_process'; -import { mkdirSync, readFileSync, renameSync, writeFileSync } from 'node:fs'; +import { existsSync, mkdirSync, readFileSync, renameSync, writeFileSync } from 'node:fs'; import { dirname, join } from 'node:path'; import { TorchError } from '../kernel/errors.mjs'; import { readInstallManifest } from '../kernel/install.mjs'; @@ -23,6 +23,24 @@ function orderedAreas(roster) { ...roster.areas.filter((area) => area.id === 'session-manager')]; } +function selectedAreas(roster, only, blockers) { + const ordered = orderedAreas(roster); + if (only === undefined) return ordered; + const selected = Array.isArray(only) + ? [...new Set(only.map((id) => typeof id === 'string' ? id.trim() : '').filter(Boolean))] + : []; + if (!selected.length) { + blockers.push({ code: 'FLEET_SELECTION_EMPTY' }); + return []; + } + const known = new Set(ordered.map((area) => area.id)); + for (const areaId of selected.filter((id) => !known.has(id))) { + blockers.push({ areaId, code: 'FLEET_IDENTITY_MISSING' }); + } + const wanted = new Set(selected); + return ordered.filter((area) => wanted.has(area.id)); +} + function adapterMap({ adapter, adapters } = {}) { if (adapters instanceof Map) return adapters; if (adapters && typeof adapters === 'object') return new Map(Object.entries(adapters)); @@ -64,6 +82,24 @@ function atomicJson(path, value) { renameSync(temporary, path); } +function projectMetadata(stateRoot) { + const path = join(stateRoot, 'project.json'); + if (!existsSync(path)) throw new TorchError('TORCH local project metadata is missing', { code: 'LOCAL_STATE_MISSING' }); + try { return { path, value: JSON.parse(readFileSync(path, 'utf8')) }; } catch (error) { + throw new TorchError('TORCH local project metadata is invalid', { code: 'LOCAL_STATE_INVALID', details: error.message }); + } +} + +function captureSnapshot({ plan, controlPlane, now = () => new Date() }) { + const snapshotPath = join(plan.stateRoot, 'sessions', 'resume.json'); + atomicJson(snapshotPath, { + schema: 'torch.dev/runtime-snapshot/v1alpha1', projectId: plan.projectId, + capturedAt: now().toISOString(), agents: controlPlane.listAgents(), + worktrees: plan.actions.map((action) => action.worktree).filter(Boolean), + }); + return snapshotPath; +} + function inspectWorktree(worktree) { const git = (args) => execFileSync('git', ['-C', worktree.path, ...args], { encoding: 'utf8' }).trim(); return { @@ -75,16 +111,16 @@ function inspectWorktree(worktree) { }; } -export function planFleetUp({ repositoryRoot, controlPlane, adapter, adapters, fresh = false } = {}) { +export function planFleetUp({ repositoryRoot, controlPlane, adapter, adapters, fresh = false, only } = {}) { const { config, roster } = loadFleetDefinition(repositoryRoot); const manifest = readInstallManifest(repositoryRoot); const worktrees = worktreesByArea(manifest); const stateRoot = localStateRoot(manifest); const runtimes = adapterMap({ adapter, adapters }); - const actions = []; const blockers = []; + const actions = []; - for (const area of orderedAreas(roster)) { + for (const area of selectedAreas(roster, only, blockers)) { const worktree = worktrees.get(area.id); if (!worktree) { blockers.push({ areaId: area.id, code: 'WORKTREE_MISSING' }); @@ -129,7 +165,8 @@ export function planFleetUp({ repositoryRoot, controlPlane, adapter, adapters, f } return { - action: 'fleet-up', projectId: manifest.projectId, fresh, actions, blockers, + action: 'fleet-up', projectId: manifest.projectId, stateRoot, fresh, + only: only ?? null, actions, blockers, canProceed: blockers.length === 0, mutationPerformed: false, }; } @@ -258,6 +295,12 @@ export function startFleet({ plan, controlPlane, executor, adapters } = {}) { }); started.push({ areaId: action.areaId, runtimeSessionId: identity.runtimeSessionId, mode: action.mode }); } + if (plan.stateRoot) { + const metadata = projectMetadata(plan.stateRoot); + if (metadata.value.detachedAt) { + atomicJson(metadata.path, { ...metadata.value, detachedAt: null, attachedAt: new Date().toISOString() }); + } + } return { projectId: plan.projectId, started, mutationPerformed: true }; } @@ -328,11 +371,59 @@ export function stopFleet({ plan, controlPlane, stopRuntime, now = () => new Dat controlPlane.reportStatus({ areaId: action.areaId, state: 'offline', summary: 'Runtime stopped cleanly.' }); stopped.push({ areaId: action.areaId, runtimeSessionId: action.runtimeSessionId, alreadyOffline: false }); } - const snapshotPath = join(plan.stateRoot, 'sessions', 'resume.json'); - atomicJson(snapshotPath, { - schema: 'torch.dev/runtime-snapshot/v1alpha1', projectId: plan.projectId, - capturedAt: now().toISOString(), agents: controlPlane.listAgents(), - worktrees: plan.actions.map((action) => action.worktree).filter(Boolean), - }); + const snapshotPath = captureSnapshot({ plan, controlPlane, now }); return { projectId: plan.projectId, stopped, snapshotPath, mutationPerformed: true }; } + +export function captureFleet({ repositoryRoot, controlPlane, now = () => new Date() } = {}) { + const plan = planFleetDown({ repositoryRoot, controlPlane }); + const snapshotPath = captureSnapshot({ plan, controlPlane, now }); + return { + action: 'capture', projectId: plan.projectId, snapshotPath, + agents: controlPlane.listAgents(), mutationPerformed: true, + }; +} + +export function createFleetBrief({ repositoryRoot, controlPlane, areaId } = {}) { + const { roster } = loadFleetDefinition(repositoryRoot); + const selected = areaId + ? roster.areas.filter((area) => area.id === areaId) + : orderedAreas(roster); + if (!selected.length) { + throw new TorchError(`Unknown Fleet identity: ${areaId}`, { code: 'UNKNOWN_FLEET_IDENTITY' }); + } + const commonPath = join(repositoryRoot, '.torch', 'prompts', 'COMMON.md'); + const common = readFileSync(commonPath, 'utf8').trimEnd(); + return { + schema: 'torch.dev/fleet-brief/v1alpha1', mutationPerformed: false, + areas: selected.map((area) => { + const promptPath = join(repositoryRoot, '.torch', 'prompts', `${area.id}.md`); + return { + areaId: area.id, title: area.title, identity: controlPlane.identity(area.id), + promptSources: [commonPath, promptPath], + prompt: `${common}\n\n${readFileSync(promptPath, 'utf8').trimEnd()}\n`, + }; + }), + }; +} + +export function planFleetDetach({ repositoryRoot, controlPlane, adapters } = {}) { + const plan = planFleetDown({ repositoryRoot, controlPlane, adapters }); + return { ...plan, action: 'fleet-detach' }; +} + +export function detachFleet({ plan, controlPlane, stopRuntime, now = () => new Date() } = {}) { + if (plan?.action !== 'fleet-detach') { + throw new TorchError('Fleet detach requires a reviewed detach plan', { code: 'FLEET_DETACH_PLAN_REQUIRED' }); + } + const stopped = stopFleet({ plan, controlPlane, stopRuntime, now }); + const metadata = projectMetadata(plan.stateRoot); + const detachedAt = now().toISOString(); + atomicJson(metadata.path, { ...metadata.value, detachedAt }); + return { + action: 'fleet-detach', projectId: plan.projectId, detachedAt, + stopped: stopped.stopped, snapshotPath: stopped.snapshotPath, + preserved: ['tracked organization', 'worktrees', 'branches', 'local state'], + mutationPerformed: true, + }; +} diff --git a/src/self-host/service.mjs b/src/self-host/service.mjs index 6720346..8d10fa7 100644 --- a/src/self-host/service.mjs +++ b/src/self-host/service.mjs @@ -14,6 +14,7 @@ export const CANDIDATE_ACCEPTANCE_SCENARIOS = Object.freeze([ 'ai-fleet-planning', 'fleet-evolution', 'fleet-boundary-evolution', + 'cli-lifecycle-surface', 'review-install-roster', 'branches-worktrees', 'runtime-identities', diff --git a/test/fleet/cli-scenarios.test.mjs b/test/fleet/cli-scenarios.test.mjs index 52c9d52..53796f5 100644 --- a/test/fleet/cli-scenarios.test.mjs +++ b/test/fleet/cli-scenarios.test.mjs @@ -37,6 +37,7 @@ function releaseCandidate(root, version) { 'SCN-ai-fleet-planning', 'SCN-fleet-evolution', 'SCN-cli-fleet-evolution', 'SCN-fleet-boundary-evolution', + 'SCN-cli-lifecycle-surface', 'SCN-durable-message', 'SCN-ownership-handoff', 'SCN-worktree-purge-safety', 'SCN-backlog-lifecycle', 'SCN-native-integration', 'SCN-resource-fifo', 'SCN-fleet-fresh-resume', 'SCN-install-doctor-purge', @@ -100,6 +101,60 @@ test('SCN-cli-domain-review: a generated proposal must be approved before instal assert.equal(existsSync(join(root, '.torch', 'domain-proposal.approved.json')), true); }); +test('SCN-cli-lifecycle-surface: runtime selection, selective startup, capture, brief, list, and detach are public', () => { + const root = repo(); + const proposalPath = join(root, 'fleet-proposal.json'); + assert.equal(run(root, ['domains', '--output', proposalPath, '--json']).status, 0); + const proposal = JSON.parse(readFileSync(proposalPath, 'utf8')); + proposal.review = { + status: 'approved', reviewedAt: '2026-09-27T00:00:00Z', reviewedBy: 'fixture-owner', notes: [], + }; + writeFileSync(proposalPath, `${JSON.stringify(proposal, null, 2)}\n`); + const preview = run(root, [ + 'install', '--proposal', proposalPath, '--runtime', 'claude', '--dry-run', '--json', + ]); + assert.equal(preview.status, 0, preview.stderr || preview.stdout); + assert.deepEqual(JSON.parse(preview.stdout).runtimes, ['claude']); + const installed = run(root, [ + 'install', '--proposal', proposalPath, '--runtime', 'claude', '--yes', '--json', + ]); + assert.equal(installed.status, 0, installed.stderr || installed.stdout); + const config = JSON.parse(readFileSync(join(root, '.torch', 'torch.yaml'), 'utf8')); + assert.deepEqual(Object.keys(config.runtimes).sort(), ['claude', 'default']); + execFileSync('git', ['-C', root, 'add', '.torch', 'fleet-proposal.json']); + execFileSync('git', ['-C', root, 'commit', '-m', 'install torch']); + const worktreeParent = join(tmpdir(), `${basename(root)}-cli-worktrees`); + const worktrees = run(root, ['worktrees', '--parent', worktreeParent, '--yes', '--json']); + assert.equal(worktrees.status, 0, worktrees.stderr || worktrees.stdout); + const worker = proposal.domains[0].id; + + const listed = run(root, ['list', '--json']); + assert.equal(listed.status, 0, listed.stderr || listed.stdout); + assert.equal(JSON.parse(listed.stdout).agents.some((agent) => agent.areaId === worker), true); + const brief = run(root, ['brief', '--area', worker, '--json']); + assert.equal(brief.status, 0, brief.stderr || brief.stdout); + assert.equal(JSON.parse(brief.stdout).areas[0].areaId, worker); + assert.match(JSON.parse(brief.stdout).areas[0].prompt, /Common fleet rules/); + const selective = run(root, ['up', '--only', worker, '--dry-run', '--json']); + assert.equal(selective.status, 0, selective.stderr || selective.stdout); + assert.deepEqual(JSON.parse(selective.stdout).actions.map((action) => action.areaId), [worker]); + const unknown = run(root, ['up', '--only', 'not-a-domain', '--dry-run', '--json']); + assert.equal(unknown.status, 1); + assert.equal(JSON.parse(unknown.stdout).blockers[0].code, 'FLEET_IDENTITY_MISSING'); + const captured = run(root, ['capture', '--json']); + assert.equal(captured.status, 0, captured.stderr || captured.stdout); + assert.equal(existsSync(JSON.parse(captured.stdout).snapshotPath), true); + assert.equal(JSON.parse(run(root, ['detach', '--json']).stdout).error, 'APPROVAL_REQUIRED'); + const detached = run(root, ['detach', '--yes', '--json']); + assert.equal(detached.status, 0, detached.stderr || detached.stdout); + assert.deepEqual(JSON.parse(detached.stdout).preserved, [ + 'tracked organization', 'worktrees', 'branches', 'local state', + ]); + const doctor = run(root, ['doctor', '--json']); + assert.equal(doctor.status, 0, doctor.stderr || doctor.stdout); + assert.equal(JSON.parse(doctor.stdout).findings.some((finding) => finding.code === 'FLEET_DETACHED'), true); +}); + test('SCN-cli-control-plane: CLI messages, acknowledgements, ownership, and status share durable state', () => { const root = repo(); const proposalPath = join(root, 'fleet-proposal.json'); diff --git a/test/fleet/kernel-lifecycle.test.mjs b/test/fleet/kernel-lifecycle.test.mjs index bab4de5..ae53844 100644 --- a/test/fleet/kernel-lifecycle.test.mjs +++ b/test/fleet/kernel-lifecycle.test.mjs @@ -109,6 +109,28 @@ test('SCN-domain-approval: installation rejects stale or unapproved organization assert.equal(existsSync(join(root, '.torch')), false); }); +test('SCN-install-runtime-selection: approved runtime choices are explicit and cover every identity', () => { + const { root, env } = fixture(); + const repository = inspectRepository(root); + const proposal = approvedProposal(repository); + assert.deepEqual(planInstall({ repository, proposal, env, runtimes: ['claude'] }).runtimes, ['claude']); + proposal.domains[0].runtime = 'codex'; + assert.throws( + () => planInstall({ repository, proposal, env, runtimes: ['claude'] }), + (error) => error.code === 'INSTALL_RUNTIME_MISSING' && error.details.missing.includes('codex'), + ); + assert.throws( + () => planInstall({ repository, proposal, env, runtimes: ['unknown'] }), + (error) => error.code === 'INSTALL_RUNTIME_INVALID', + ); + const installed = installProject({ + repository, proposal, env, projectId: 'runtime-selection', runtimes: ['claude', 'codex'], + }); + assert.equal(installed.mutationPerformed, true); + const config = JSON.parse(readFileSync(join(root, '.torch', 'torch.yaml'), 'utf8')); + assert.deepEqual(Object.keys(config.runtimes).sort(), ['claude', 'codex', 'default']); +}); + test('SCN-config-schema: installed configuration is strict, versioned, and migration-aware', () => { const { root, env } = fixture(); const repository = inspectRepository(root); diff --git a/test/fleet/lifecycle.test.mjs b/test/fleet/lifecycle.test.mjs index 125db6d..b773cc1 100644 --- a/test/fleet/lifecycle.test.mjs +++ b/test/fleet/lifecycle.test.mjs @@ -12,7 +12,9 @@ import { proposeDomains } from '../../src/kernel/domains.mjs'; import { inspectRepository } from '../../src/kernel/git.mjs'; import { installProject } from '../../src/kernel/install.mjs'; import { createWorktrees } from '../../src/kernel/worktrees.mjs'; -import { planFleetDown, planFleetUp, startFleet, stopFleet } from '../../src/runtime/lifecycle.mjs'; +import { + detachFleet, planFleetDetach, planFleetDown, planFleetUp, startFleet, stopFleet, +} from '../../src/runtime/lifecycle.mjs'; function fleetFixture({ workerRuntime = 'claude' } = {}) { const root = mkdtempSync(join(tmpdir(), 'torch-lifecycle-')); @@ -70,12 +72,25 @@ test('SCN-fleet-fresh-resume: workers start before manager and stable identities assert.equal(stoppedIds.length, fresh.actions.length); assert.equal(existsSync(down.snapshotPath), true); + const detached = detachFleet({ + plan: planFleetDetach({ repositoryRoot: fixture.root, controlPlane: control }), + controlPlane: control, stopRuntime: () => ({ stopped: true }), + now: () => new Date('2026-09-27T13:00:00Z'), + }); + assert.equal(detached.detachedAt, '2026-09-27T13:00:00.000Z'); + const metadataPath = join(planFleetDown({ + repositoryRoot: fixture.root, controlPlane: control, + }).stateRoot, 'project.json'); + assert.equal(JSON.parse(readFileSync(metadataPath, 'utf8')).detachedAt, detached.detachedAt); + const resumed = planFleetUp({ repositoryRoot: fixture.root, controlPlane: control, adapter, fresh: false }); assert.equal(resumed.actions.every((action) => action.mode === 'resume'), true); assert.deepEqual( resumed.actions.map((action) => action.runtimeSessionId), fresh.actions.map((action) => action.runtimeSessionId), ); + startFleet({ plan: resumed, controlPlane: control, executor: () => ({ status: 0 }) }); + assert.equal(JSON.parse(readFileSync(metadataPath, 'utf8')).detachedAt, null); control.close(); });