diff --git a/README.md b/README.md index a4afc85..75fc8d9 100644 --- a/README.md +++ b/README.md @@ -62,7 +62,9 @@ and the first Claude reference-fleet path: retries, durable run evidence, and explicit approval for mutation. - durable Fleet evolution: the Session Manager can propose a justified new persistent domain, while owner approval gates the configuration commit, - identity, prompt, branch, and worktree activation. + identity, prompt, branch, and worktree activation; it can also propose safe + retirement when coordination cost exceeds continuing value, preserving the + branch and refusing live, dirty, guarded, leased, or active work. Live mixed-provider qualification, stable self-host bootstrapping, COMBATRIG cutover, persistent system-schedule launcher integration, and public deployment 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 fe1f763..038a03a 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 @@ -162,6 +162,10 @@ test_integrity_note: given: an installed Fleet whose Session Manager identifies recurring work without a coherent owner when: it proposes a durable domain and the owner explicitly approves activation then: TORCH commits the roster change, provisions its branch and clean worktree, exposes the identity to existing and restarted control-plane clients, and can start only that identity through a virtualized adapter + - id: SCN-fleet-retirement + given: a persistent specialist whose recurring work no longer justifies its coordination cost + when: the Session Manager proposes retirement and the owner approves it + then: live, dirty, guarded, leased, uniquely committed, or actively assigned work blocks retirement; safe activation removes the worktree and identity while preserving its branch and durable decision - id: SCN-cli-fleet-evolution 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 diff --git a/src/cli.mjs b/src/cli.mjs index 2df5312..2fa2115 100644 --- a/src/cli.mjs +++ b/src/cli.mjs @@ -106,6 +106,7 @@ Usage: torch fleet changes [--state ] [--json] torch fleet get --change [--json] torch fleet propose --from session-manager --proposal [--json] + torch fleet propose-retirement --from session-manager --proposal [--json] torch fleet approve --change --by --yes [--json] torch fleet plan --change [--json] torch fleet activate --change --by --yes [--json] @@ -626,6 +627,14 @@ export async function runCli(argv = process.argv.slice(2), { print(evolution.proposeDomain({ ...input, proposer: optionValue(argv, '--from') ?? input.proposer, }), { json }); + } else if (operation === 'propose-retirement') { + const input = loadJsonFile(cwd, optionValue(argv, '--proposal'), { + label: 'Fleet domain retirement proposal', code: 'FLEET_PROPOSAL_INVALID', + }); + print(evolution.proposeRetirement({ + ...input, areaId: input.area_id ?? input.areaId, + proposer: optionValue(argv, '--from') ?? input.proposer, + }), { json }); } else if (operation === 'approve') { if (!argv.includes('--yes')) { throw new TorchError('Owner approval of a Fleet change requires explicit --yes.', { code: 'APPROVAL_REQUIRED' }); @@ -634,14 +643,14 @@ export async function runCli(argv = process.argv.slice(2), { } else if (operation === 'plan') print(evolution.planActivation(changeId), { json }); else if (operation === 'activate') { if (!argv.includes('--yes')) { - throw new TorchError('Fleet activation commits configuration and creates a worktree. Review fleet plan, then use --yes.', { + throw new TorchError('Fleet activation changes tracked organization state and may create or remove a worktree. Review fleet plan, then use --yes.', { code: 'APPROVAL_REQUIRED', }); } print(evolution.activate({ changeId, approvedBy: optionValue(argv, '--by') }), { json }); } else if (operation === 'start') { const change = evolution.get(changeId); - if (change.state !== 'active') { + if (change.type !== 'add-domain' || change.state !== 'active') { throw new TorchError('Only an active Fleet domain can start a runtime session', { code: 'FLEET_CHANGE_STATE_CONFLICT', details: { state: change.state }, }); diff --git a/src/evolution/service.mjs b/src/evolution/service.mjs index 820af07..212ae3a 100644 --- a/src/evolution/service.mjs +++ b/src/evolution/service.mjs @@ -1,14 +1,15 @@ import { execFileSync } from 'node:child_process'; import { createHash, randomUUID } from 'node:crypto'; -import { existsSync, mkdirSync, readFileSync, renameSync, writeFileSync } from 'node:fs'; +import { existsSync, mkdirSync, readFileSync, readdirSync, renameSync, writeFileSync } from 'node:fs'; import { homedir } from 'node:os'; import { dirname, isAbsolute, join, resolve } from 'node:path'; import { TorchError } from '../kernel/errors.mjs'; import { readInstallManifest } from '../kernel/install.mjs'; import { ensureTaskIgnored, removeTaskIgnore } from '../kernel/worktrees.mjs'; import { loadProjectConfig } from '../kernel/config.mjs'; +import { inspectManagedWorktree } from '../kernel/worktree-state.mjs'; -const CHANGE_STATES = Object.freeze(['proposed', 'approved', 'provisioning', 'active', 'rejected']); +const CHANGE_STATES = Object.freeze(['proposed', 'approved', 'provisioning', 'active', 'retired', 'rejected']); function requiredText(value, name) { if (typeof value !== 'string' || !value.trim()) { @@ -111,7 +112,7 @@ function normalizeDomain(input) { function rowToChange(row) { if (!row) return null; return { - id: row.id, state: row.state, proposer: row.proposer, + id: row.id, type: row.change_type ?? 'add-domain', state: row.state, proposer: row.proposer, createdAt: row.created_at, updatedAt: row.updated_at, baseCommit: row.base_commit, approvedBy: row.approved_by, approvedAt: row.approved_at, activationCommit: row.activation_commit, domain: JSON.parse(row.domain_json), @@ -139,6 +140,7 @@ function initialize(database) { database.exec(` CREATE TABLE IF NOT EXISTS fleet_changes ( id TEXT PRIMARY KEY, + change_type TEXT NOT NULL DEFAULT 'add-domain', state TEXT NOT NULL, proposer TEXT NOT NULL, created_at TEXT NOT NULL, @@ -155,6 +157,23 @@ function initialize(database) { CREATE INDEX IF NOT EXISTS fleet_changes_state_created ON fleet_changes(state, created_at, id); `); + const columns = new Set(database.prepare('PRAGMA table_info(fleet_changes)').all().map((column) => column.name)); + if (!columns.has('change_type')) { + database.exec("ALTER TABLE fleet_changes ADD COLUMN change_type TEXT NOT NULL DEFAULT 'add-domain'"); + } +} + +function expectedBenefit(input) { + return { + summary: requiredText(input?.summary, 'expectedBenefit.summary'), + recurringWork: requiredText(input?.recurringWork, 'expectedBenefit.recurringWork'), + contextLocality: requiredText(input?.contextLocality, 'expectedBenefit.contextLocality'), + coordinationCost: requiredText(input?.coordinationCost, 'expectedBenefit.coordinationCost'), + }; +} + +function hasTable(database, name) { + return Boolean(database.prepare("SELECT name FROM sqlite_master WHERE type = 'table' AND name = ?").get(name)); } export class FleetEvolutionService { @@ -185,7 +204,7 @@ export class FleetEvolutionService { return rows.map(rowToChange); } - proposeDomain({ proposer, domain: input, rationale, expectedBenefit, evidence } = {}) { + proposeDomain({ proposer, domain: input, rationale, expectedBenefit: benefitInput, evidence } = {}) { const actor = this.controlPlane.assertIdentity(proposer); if (actor !== 'session-manager') { throw new TorchError('Only the Session Manager may propose a persistent Fleet domain', { @@ -198,12 +217,7 @@ export class FleetEvolutionService { throw new TorchError(`Fleet identity already exists: ${domain.id}`, { code: 'FLEET_IDENTITY_EXISTS' }); } for (const neighbour of domain.neighbours) this.controlPlane.assertIdentity(neighbour); - const benefit = { - summary: requiredText(expectedBenefit?.summary, 'expectedBenefit.summary'), - recurringWork: requiredText(expectedBenefit?.recurringWork, 'expectedBenefit.recurringWork'), - contextLocality: requiredText(expectedBenefit?.contextLocality, 'expectedBenefit.contextLocality'), - coordinationCost: requiredText(expectedBenefit?.coordinationCost, 'expectedBenefit.coordinationCost'), - }; + const benefit = expectedBenefit(benefitInput); const evidenceList = textList(evidence, 'evidence', { required: true }); const collisions = agents.flatMap((agent) => domain.owned_paths.flatMap((proposedPath) => agent.ownedPaths.filter((existingPath) => pathsOverlap(proposedPath, existingPath)) @@ -242,10 +256,10 @@ export class FleetEvolutionService { }; this.database.prepare(` INSERT INTO fleet_changes ( - id, state, proposer, created_at, updated_at, base_commit, approved_by, + id, change_type, state, proposer, created_at, updated_at, base_commit, approved_by, approved_at, activation_commit, domain_json, rationale, expected_benefit_json, evidence_json - ) VALUES (?, ?, ?, ?, ?, ?, NULL, NULL, NULL, ?, ?, ?, ?) + ) VALUES (?, 'add-domain', ?, ?, ?, ?, ?, NULL, NULL, NULL, ?, ?, ?, ?) `).run( record.id, record.state, record.proposer, record.createdAt, record.updatedAt, record.baseCommit, JSON.stringify(record.domain), record.rationale, @@ -258,6 +272,44 @@ export class FleetEvolutionService { return record; } + proposeRetirement({ proposer, areaId, rationale, expectedBenefit: benefitInput, evidence } = {}) { + const actor = this.controlPlane.assertIdentity(proposer); + if (actor !== 'session-manager') { + throw new TorchError('Only the Session Manager may propose retiring a persistent Fleet domain', { + code: 'FLEET_CHANGE_AUTHORITY_REQUIRED', details: { actor }, + }); + } + const target = this.controlPlane.identity(areaId); + if (target.areaId === 'session-manager') { + throw new TorchError('The primary Session Manager cannot be retired as a domain', { code: 'FLEET_CHANGE_INVALID' }); + } + const domain = loadProjectConfig(this.repositoryRoot).domains.find((entry) => entry.id === target.areaId); + if (!domain) throw new TorchError(`Configured domain not found: ${target.areaId}`, { code: 'FLEET_CHANGE_INVALID' }); + const now = this.clock().toISOString(); + const record = { + id: this.idFactory(), type: 'retire-domain', state: 'proposed', proposer: actor, + createdAt: now, updatedAt: now, baseCommit: git(this.repositoryRoot, ['rev-parse', 'HEAD']), + approvedBy: null, approvedAt: null, activationCommit: null, domain, + rationale: requiredText(rationale, 'rationale'), expectedBenefit: expectedBenefit(benefitInput), + evidence: textList(evidence, 'evidence', { required: true }), + }; + this.database.prepare(` + INSERT INTO fleet_changes ( + id, change_type, state, proposer, created_at, updated_at, base_commit, approved_by, + approved_at, activation_commit, domain_json, rationale, expected_benefit_json, evidence_json + ) VALUES (?, 'retire-domain', ?, ?, ?, ?, ?, NULL, NULL, NULL, ?, ?, ?, ?) + `).run( + record.id, record.state, record.proposer, record.createdAt, record.updatedAt, + record.baseCommit, JSON.stringify(record.domain), record.rationale, + JSON.stringify(record.expectedBenefit), JSON.stringify(record.evidence), + ); + this.controlPlane.audit({ + actorId: actor, operation: 'fleet-change.propose-retirement', entityType: 'fleet-change', entityId: record.id, + details: { domainId: domain.id, baseCommit: record.baseCommit }, + }); + return record; + } + approve({ changeId, approvedBy } = {}) { const change = this.get(changeId); if (change.state !== 'proposed') { @@ -278,6 +330,7 @@ export class FleetEvolutionService { planActivation(changeId) { const change = this.get(changeId); + if (change.type === 'retire-domain') return this.planRetirement(change); const config = loadProjectConfig(this.repositoryRoot); const roster = readJson(join(this.repositoryRoot, '.torch', 'roster.yaml'), 'ROSTER_INVALID'); const currentHead = git(this.repositoryRoot, ['rev-parse', 'HEAD']); @@ -321,6 +374,7 @@ export class FleetEvolutionService { code: 'FLEET_CHANGE_AUTHORITY_REQUIRED', details: { approvedBy: change.approvedBy }, }); } + if (change.type === 'retire-domain') return this.retire(change, approvedBy); const plan = this.planActivation(change.id); if (!plan.canProceed) { throw new TorchError('Fleet domain activation is blocked', { @@ -357,6 +411,128 @@ export class FleetEvolutionService { }; } + planRetirement(changeOrId) { + const change = typeof changeOrId === 'string' ? this.get(changeOrId) : changeOrId; + const config = loadProjectConfig(this.repositoryRoot); + const roster = readJson(join(this.repositoryRoot, '.torch', 'roster.yaml'), 'ROSTER_INVALID'); + const manifest = readInstallManifest(this.repositoryRoot); + const currentHead = git(this.repositoryRoot, ['rev-parse', 'HEAD']); + const dirtyEntries = git(this.repositoryRoot, ['status', '--porcelain']).split('\n').filter(Boolean); + const worktree = (manifest.external ?? []).find((entry) => entry.type === 'worktree' && entry.area === change.domain.id); + const blockers = []; + if (change.state !== 'approved' && change.state !== 'provisioning') blockers.push({ type: 'state', actual: change.state }); + if (change.state === 'approved' && currentHead !== change.baseCommit) blockers.push({ type: 'stale-head', expected: change.baseCommit, actual: currentHead }); + if (change.state === 'approved' && dirtyEntries.length) blockers.push({ type: 'dirty-main', entries: dirtyEntries }); + if (!config.domains.some((entry) => entry.id === change.domain.id)) blockers.push({ type: 'domain-not-configured', domainId: change.domain.id }); + if (config.domains.length <= 1) blockers.push({ type: 'last-development-domain' }); + if (!roster.areas.some((entry) => entry.id === change.domain.id)) blockers.push({ type: 'identity-not-rostered', domainId: change.domain.id }); + if (!worktree) blockers.push({ type: 'worktree-not-managed', domainId: change.domain.id }); + if (worktree) { + const state = inspectManagedWorktree(this.repositoryRoot, worktree, config.project.main_branch); + for (const problem of state.problems) blockers.push({ type: 'worktree-unsafe', problem, path: worktree.path }); + } + const identity = this.controlPlane.identity(change.domain.id); + if (identity.state !== 'offline') blockers.push({ type: 'runtime-not-offline', state: identity.state }); + if (hasTable(this.database, 'worktree_guards')) { + const guards = this.database.prepare(`SELECT guard_type AS type, reason FROM worktree_guards + WHERE area_id = ? AND released_at IS NULL`).all(change.domain.id); + if (guards.length) blockers.push({ type: 'worktree-guards-active', guards }); + } + if (hasTable(this.database, 'resource_leases')) { + const leases = this.database.prepare(`SELECT resource_id AS resourceId FROM resource_leases + WHERE area_id = ? AND released_at IS NULL`).all(change.domain.id); + if (leases.length) blockers.push({ type: 'resource-leases-active', leases }); + } + if (hasTable(this.database, 'integration_requests')) { + const requests = this.database.prepare(`SELECT id, state FROM integration_requests + WHERE source_area = ? AND state NOT IN ('landed', 'superseded')`).all(change.domain.id); + if (requests.length) blockers.push({ type: 'integration-active', requests }); + } + const manager = (manifest.external ?? []).find((entry) => entry.type === 'worktree' && entry.area === 'session-manager'); + const backlogRoot = join(manager?.path && existsSync(manager.path) ? manager.path : this.repositoryRoot, '.torch', 'backlog'); + if (existsSync(backlogRoot)) { + const tasks = readdirSync(backlogRoot).filter((name) => name.endsWith('.json')).flatMap((name) => { + try { + const task = readJson(join(backlogRoot, name), 'BACKLOG_INVALID'); + const active = !['completed', 'cancelled'].includes(task.state) + && (task.owner === change.domain.id || task.affectedDomains?.includes(change.domain.id)); + return active ? [{ id: task.id, state: task.state }] : []; + } catch { return [{ id: name, state: 'invalid' }]; } + }); + if (tasks.length) blockers.push({ type: 'backlog-active', tasks }); + } + return { + action: 'retire-domain', changeId: change.id, domainId: change.domain.id, + state: change.state, branch: worktree?.branch ?? change.domain.branch, + worktree: worktree?.path ?? null, currentHead, baseCommit: change.baseCommit, + blockers, canProceed: blockers.length === 0, mutationPerformed: false, + }; + } + + retire(change, approvedBy) { + const plan = this.planRetirement(change); + if (!plan.canProceed) throw new TorchError('Fleet domain retirement is blocked', { code: 'FLEET_CHANGE_BLOCKED', details: plan.blockers }); + const owner = requiredText(approvedBy, 'approvedBy'); + git(this.repositoryRoot, ['worktree', 'remove', plan.worktree]); + try { + this.commitRetirement(change, plan, owner); + } catch (error) { + git(this.repositoryRoot, ['worktree', 'add', plan.worktree, plan.branch], { optional: true }); + throw error; + } + this.controlPlane.refreshRoster(); + this.controlPlane.audit({ + actorId: 'session-manager', operation: 'fleet-change.retire-domain', entityType: 'fleet-change', entityId: change.id, + details: { domainId: change.domain.id, branch: plan.branch }, + }); + return { ...this.get(change.id), branch: plan.branch, worktree: plan.worktree, mutationPerformed: true }; + } + + commitRetirement(change, plan, approvedBy) { + const config = loadProjectConfig(this.repositoryRoot); + const rosterPath = join(this.repositoryRoot, '.torch', 'roster.yaml'); + const roster = readJson(rosterPath, 'ROSTER_INVALID'); + const manifest = readInstallManifest(this.repositoryRoot); + config.domains = config.domains.filter((entry) => entry.id !== change.domain.id); + config.retired_domains ??= []; + config.retired_domains.push({ + id: change.domain.id, title: change.domain.title, branch: plan.branch, + change_id: change.id, retired_at: this.clock().toISOString(), + }); + roster.areas = roster.areas.filter((entry) => entry.id !== change.domain.id) + .map((entry) => ({ ...entry, neighbours: (entry.neighbours ?? []).filter((id) => id !== change.domain.id) })); + manifest.external = (manifest.external ?? []).filter((entry) => !(entry.type === 'worktree' && entry.area === change.domain.id)); + const changeRelative = `.torch/fleet-changes/${change.id}.retired.json`; + const record = { + schema: 'torch.dev/fleet-change/v1alpha1', ...change, state: 'retired', approvedBy, + retirement: { branch: plan.branch, worktreeRemoved: plan.worktree, branchPreserved: true }, + }; + const serialized = new Map([ + ['.torch/torch.yaml', `${JSON.stringify(config, null, 2)}\n`], + ['.torch/roster.yaml', `${JSON.stringify(roster, null, 2)}\n`], + [changeRelative, `${JSON.stringify(record, null, 2)}\n`], + ]); + for (const [relativePath, content] of serialized) { + const existing = manifest.created.find((entry) => entry.path === relativePath); + if (existing) existing.sha256 = sha256(content); + else manifest.created.push({ path: relativePath, sha256: sha256(content) }); + } + serialized.set('.torch/install-manifest.json', `${JSON.stringify(manifest, null, 2)}\n`); + for (const [relativePath, content] of serialized) { + const path = join(this.repositoryRoot, relativePath); + mkdirSync(dirname(path), { recursive: true }); + const temporary = `${path}.tmp-${process.pid}-${randomUUID()}`; + writeFileSync(temporary, content, { encoding: 'utf8', mode: 0o600, flag: 'wx' }); + renameSync(temporary, path); + } + git(this.repositoryRoot, ['add', ...serialized.keys()]); + git(this.repositoryRoot, ['commit', '-m', `chore(torch): retire ${change.domain.id} domain`]); + const activationCommit = git(this.repositoryRoot, ['rev-parse', 'HEAD']); + const now = this.clock().toISOString(); + this.database.prepare(`UPDATE fleet_changes SET state = 'retired', activation_commit = ?, updated_at = ? WHERE id = ?`) + .run(activationCommit, now, change.id); + } + commitConfiguration(change, plan, ignore) { const rosterPath = join(this.repositoryRoot, '.torch', 'roster.yaml'); const config = loadProjectConfig(this.repositoryRoot); diff --git a/src/kernel/config.mjs b/src/kernel/config.mjs index 9106635..292feee 100644 --- a/src/kernel/config.mjs +++ b/src/kernel/config.mjs @@ -106,6 +106,13 @@ export const projectConfigSchema = z.object({ worktree_name: text.regex(/^[A-Za-z0-9][A-Za-z0-9._-]*$/).nullable().optional(), }).strict(), domains: z.array(domain).min(1), + retired_domains: z.array(z.object({ + id, + title: text, + branch: text, + change_id: text, + retired_at: text, + }).strict()).optional(), }).strict().superRefine((config, context) => { const runtimeNames = new Set(Object.keys(config.runtimes).filter((name) => name !== 'default')); if (!runtimeNames.has(config.runtimes.default)) { diff --git a/src/kernel/install.mjs b/src/kernel/install.mjs index 990d081..f5e0cd3 100644 --- a/src/kernel/install.mjs +++ b/src/kernel/install.mjs @@ -90,7 +90,7 @@ function initialFiles({ repository, projectId, createdAt, proposal }) { const managerPrompt = `${promptContent( proposal.session_manager?.prompt_source, '# TORCH Session Manager\n\nRoute owner requests, establish ownership and priority, and keep routine coordination inside the fleet.\n', - ).trimEnd()}\n\n## Fleet evolution\n\nWhen recurring work has no coherent owner, or a durable specialist would materially improve context locality, ownership clarity, or verification, use \`torch_propose_domain\` to submit an evidence-backed Fleet change. Use \`torch_list_fleet_changes\` to follow its state. Never create, approve, or activate a persistent identity yourself; owner approval and CLI activation are separate. Also recommend merge or retirement when coordination cost exceeds the value of a persistent session.\n`; + ).trimEnd()}\n\n## Fleet evolution\n\nWhen recurring work has no coherent owner, or a durable specialist would materially improve context locality, ownership clarity, or verification, use \`torch_propose_domain\` to submit an evidence-backed Fleet change. When a specialist no longer earns its coordination cost, use \`torch_propose_domain_retirement\`; retirement preserves its branch and refuses active or unrecoverable work. Use \`torch_list_fleet_changes\` to follow change state. Never create, retire, approve, or activate a persistent identity yourself; owner approval and CLI activation are separate. Recommend a merge or split for owner review when boundaries should change but do not silently rewrite ownership.\n`; const files = new Map([ ['torch.yaml', jsonYaml(config)], ['roster.yaml', jsonYaml(roster)], diff --git a/src/mcp/tools.mjs b/src/mcp/tools.mjs index b85cd00..e60de5b 100644 --- a/src/mcp/tools.mjs +++ b/src/mcp/tools.mjs @@ -56,6 +56,7 @@ export const TORCH_MCP_TOOL_NAMES = Object.freeze([ 'torch_list_fleet_changes', 'torch_get_fleet_change', 'torch_propose_domain', + 'torch_propose_domain_retirement', ]); function claimedIdentity(controlPlane, actorId, claim, field) { @@ -398,7 +399,7 @@ export function createTorchToolset(controlPlane, { if (evolutionService) { tools.set('torch_list_fleet_changes', { description: 'List durable proposed, approved, provisioning, or active Fleet organization changes.', - schema: { state: z.enum(['proposed', 'approved', 'provisioning', 'active', 'rejected']).optional() }, + schema: { state: z.enum(['proposed', 'approved', 'provisioning', 'active', 'retired', 'rejected']).optional() }, invoke: ({ state }) => ({ changes: evolutionService.list({ state }) }), }); tools.set('torch_get_fleet_change', { @@ -436,6 +437,25 @@ export function createTorchToolset(controlPlane, { }, }), }); + tools.set('torch_propose_domain_retirement', { + description: 'Propose retiring a persistent domain when its coordination cost exceeds its continuing benefit. This never approves or activates retirement.', + schema: { + proposer: z.string().min(1).optional(), area_id: z.string().min(1), rationale: z.string().min(1), + expected_benefit: z.object({ + summary: z.string().min(1), recurring_work: z.string().min(1), + context_locality: z.string().min(1), coordination_cost: z.string().min(1), + }), + evidence: z.array(z.string().min(1)).min(1), + }, + invoke: ({ proposer, area_id: areaId, expected_benefit: benefit, ...input }) => evolutionService.proposeRetirement({ + ...input, areaId, + proposer: claimedIdentity(controlPlane, actorId, proposer, 'proposer'), + expectedBenefit: { + summary: benefit.summary, recurringWork: benefit.recurring_work, + contextLocality: benefit.context_locality, coordinationCost: benefit.coordination_cost, + }, + }), + }); } return tools; } diff --git a/test/fleet/evolution.test.mjs b/test/fleet/evolution.test.mjs index 42e74f4..d542d5f 100644 --- a/test/fleet/evolution.test.mjs +++ b/test/fleet/evolution.test.mjs @@ -174,3 +174,65 @@ test('SCN-cli-fleet-evolution: public CLI separates manager proposal, owner appr assert.deepEqual(JSON.parse(startPlan.stdout).actions.map((action) => action.areaId), ['security']); assert.equal(run(['agent', '--area', 'security', '--json']).status, 0); }); + +test('SCN-fleet-retirement: owner-approved retirement shrinks the roster without deleting branch history', () => { + const context = fixture(); + const control = openControlPlane({ repositoryRoot: context.root, env: context.env }); + let sequence = 0; + const evolution = new FleetEvolutionService({ + repositoryRoot: context.root, controlPlane: control, idFactory: () => `CHANGE-${++sequence}`, + }); + const existing = control.listAgents().find((agent) => agent.areaId !== 'session-manager').areaId; + const addition = evolution.proposeDomain({ + proposer: 'session-manager', + domain: { + id: 'review', title: 'Review', scope: ['cross-boundary review'], not_scope: ['feature implementation'], + owned_paths: ['review/**'], shared_paths: [], neighbours: [existing], required_checks: [], resources: [], runtime: 'claude', + }, + rationale: 'Milestone reviews recur without a coherent owner.', + expectedBenefit: { + summary: 'Keep a recurring review boundary focused.', recurringWork: 'Review work recurs each milestone.', + contextLocality: 'Retain the review assumptions in one session.', coordinationCost: 'One explicit handoff.', + }, + evidence: ['TASK-9'], + }); + evolution.approve({ changeId: addition.id, approvedBy: 'fixture-owner' }); + const activated = evolution.activate({ changeId: addition.id, approvedBy: 'fixture-owner' }); + assert.equal(existsSync(activated.worktree), true); + + const retirement = callTorchTool(control, 'torch_propose_domain_retirement', { + area_id: 'review', + rationale: 'The review stream ended and a permanent session now costs more coordination than it saves.', + expected_benefit: { + summary: 'Shrink routine coordination while preserving review history.', + recurring_work: 'No recurring review work remains after the milestone.', + context_locality: 'Future isolated reviews can use bounded temporary help.', + coordination_cost: 'Removes an idle persistent handoff boundary.', + }, + evidence: ['TASK-9 completed', 'decision: review milestone closed'], + }, { actorId: 'session-manager', evolutionService: evolution }); + assert.equal(retirement.type, 'retire-domain'); + evolution.approve({ changeId: retirement.id, approvedBy: 'fixture-owner' }); + + control.reportStatus({ areaId: 'review', state: 'idle', summary: 'Awaiting retirement.' }); + assert.equal(evolution.planActivation(retirement.id).blockers + .some((blocker) => blocker.type === 'runtime-not-offline'), true); + control.reportStatus({ areaId: 'review', state: 'offline', summary: 'Stopped.' }); + writeFileSync(join(activated.worktree, 'dirty.txt'), 'preserve me\n'); + assert.equal(evolution.planActivation(retirement.id).blockers + .some((blocker) => blocker.type === 'worktree-unsafe' && blocker.problem === 'worktree-dirty'), true); + execFileSync('git', ['-C', activated.worktree, 'clean', '-f', '--', 'dirty.txt']); + + const plan = evolution.planActivation(retirement.id); + assert.equal(plan.canProceed, true, JSON.stringify(plan.blockers)); + const retired = evolution.activate({ changeId: retirement.id, approvedBy: 'fixture-owner' }); + assert.equal(retired.state, 'retired'); + assert.equal(existsSync(activated.worktree), false); + assert.notEqual(execFileSync('git', ['-C', context.root, 'branch', '--list', activated.branch], { encoding: 'utf8' }).trim(), ''); + assert.equal(control.listAgents().some((agent) => agent.areaId === 'review'), false); + const config = JSON.parse(readFileSync(join(context.root, '.torch', 'torch.yaml'), 'utf8')); + assert.equal(config.domains.some((domain) => domain.id === 'review'), false); + assert.equal(config.retired_domains.some((domain) => domain.id === 'review' && domain.branch === activated.branch), true); + assert.match(execFileSync('git', ['-C', context.root, 'log', '-1', '--pretty=%s'], { encoding: 'utf8' }), /retire review domain/); + control.close(); +});