From d1cd79cccbf6cdf296874f0fc74ec2a96b124033 Mon Sep 17 00:00:00 2001 From: thePR0M3TH3AN Date: Sun, 27 Sep 2026 17:39:12 -0400 Subject: [PATCH] Add owner-gated fleet evolution --- README.md | 25 ++ docs/PORTABLE_AGENT_FLEET_SPEC.md | 16 + package.json | 6 +- ...grity-note-2026-09-27-portable-kernel.yaml | 11 + site/console.html | 1 + site/console.js | 7 + site/index.html | 2 +- src/cli.mjs | 79 +++- src/control-plane/service.mjs | 18 + src/design/brief.mjs | 1 + src/evolution/service.mjs | 411 ++++++++++++++++++ src/kernel/domains.mjs | 7 +- src/kernel/install.mjs | 15 +- src/kernel/worktrees.mjs | 4 +- src/mcp/server.mjs | 3 + src/mcp/tools.mjs | 48 ++ src/observability/snapshot.mjs | 8 + src/runtime/lifecycle.mjs | 57 +++ src/self-host/service.mjs | 1 + test/fleet/cli-scenarios.test.mjs | 1 + test/fleet/evolution.test.mjs | 176 ++++++++ test/fleet/product-surface.test.mjs | 2 + 22 files changed, 888 insertions(+), 11 deletions(-) create mode 100644 src/evolution/service.mjs create mode 100644 test/fleet/evolution.test.mjs diff --git a/README.md b/README.md index 57dc4a7..735f795 100644 --- a/README.md +++ b/README.md @@ -58,6 +58,9 @@ and the first Claude reference-fleet path: estimates and relates cost/cache behavior to verified work; - validated system/session schedules with shell-free actions, authority, 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. Live mixed-provider qualification, stable self-host bootstrapping, COMBATRIG cutover, persistent system-schedule launcher integration, and public deployment @@ -160,6 +163,26 @@ node bin/torch.mjs down --dry-run --json node bin/torch.mjs down --yes --json ``` +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 +separate, and runtime startup remains another explicit step: + +```bash +torch fleet propose --from session-manager --proposal new-domain.json --json +torch fleet approve --change --by --yes --json +torch fleet plan --change --json +torch fleet activate --change --by --yes --json +torch fleet start --change --fresh --dry-run --json +torch fleet start --change --fresh --yes --json +``` + +The proposal records recurring-work evidence, anticipated context-locality +benefit, coordination cost, ownership, neighbours, checks, and runtime. TORCH +rejects silent ownership overlap, unknown checks/runtimes, worker-created +roster changes, stale activation commits, and activation without matching +owner approval. + The CLI fallback exposes `agents`, `who-owns`, `message`, `inbox`, `ack`, `status`, `complete`, `blocked`, `coordinate`, `handoff`, and the tracked `backlog` lifecycle. MCP hosts launch @@ -239,6 +262,8 @@ timers. - Every domain has explicit scope and explicit exclusions. - Peers coordinate directly; the Session Manager controls priority and ownership. +- The Session Manager may propose Fleet growth; only the owner can approve and + activate persistent roster changes. - Monitoring may be automatic; active worktrees are changed intentionally. - Verification belongs to an exact commit. - The TORCH engine runs outside the repository it manages. diff --git a/docs/PORTABLE_AGENT_FLEET_SPEC.md b/docs/PORTABLE_AGENT_FLEET_SPEC.md index 9e5ee72..2628c1d 100644 --- a/docs/PORTABLE_AGENT_FLEET_SPEC.md +++ b/docs/PORTABLE_AGENT_FLEET_SPEC.md @@ -695,6 +695,22 @@ The Session Manager normally does not implement product features. It: 9. manages convergence and landing according to policy; 10. reports decisions, risks, blockers, milestones, and review-ready outcomes to the owner. +11. detects recurring or unowned work that may justify a new persistent domain; +12. proposes evidence-backed domain additions, merges, splits, or retirements, + including expected context-locality benefit and coordination cost. + +The Session Manager MUST NOT grow the Fleet merely because a new task appears. +A persistent domain change requires evidence of recurring work, a coherent +ownership boundary, or a material verification or context-locality benefit. +The owner MUST approve the change before TORCH changes the roster. An approved +addition is wired into the same provider-independent control plane with its +identity, prompt, ownership, neighbours, branch, worktree, checks, resources, +and runtime policy. Runtime launch remains a separate explicit action because +it may consume provider quota; TORCH MUST be able to plan and launch only the +newly activated identity rather than restarting the Fleet. Existing +control-plane processes MUST discover the new identity without discarding +durable state. Fleet evolution MUST also support future merge and retirement +proposals so session count cannot grow without review. The owner SHOULD not need to operate many independent agent inboxes. Routine peer coordination stays inside the Fleet. diff --git a/package.json b/package.json index 33964ff..f4e6273 100644 --- a/package.json +++ b/package.json @@ -13,6 +13,7 @@ "./checks": "./src/checks/service.mjs", "./console": "./src/console/server.mjs", "./design/brief": "./src/design/brief.mjs", + "./evolution": "./src/evolution/service.mjs", "./control-plane": "./src/control-plane/service.mjs", "./kernel/analyze": "./src/kernel/analyze.mjs", "./kernel/git": "./src/kernel/git.mjs", @@ -59,6 +60,7 @@ "src/console/", "src/control-plane/", "src/design/", + "src/evolution/", "src/kernel/", "src/integration/", "src/importers/", @@ -80,8 +82,8 @@ "torch": "node bin/torch.mjs", "check": "node --check bin/torch.mjs && node --check src/cli.mjs && node --check src/mcp/server.mjs && node --check src/console/server.mjs && node --check site/app.js && node --check site/console.js", "site:serve": "node bin/torch.mjs console serve", - "format": "eslint src/adapters src/backlog src/canonical src/checks src/console src/control-plane src/design src/importers src/integration src/kernel src/mcp src/observability src/resources src/runtime src/schedules src/self-host src/telemetry src/cli.mjs bin/torch.mjs site/*.js test/fleet --fix", - "lint": "eslint src/adapters src/backlog src/canonical src/checks src/console src/control-plane src/design src/importers src/integration src/kernel src/mcp src/observability src/resources src/runtime src/schedules src/self-host src/telemetry src/cli.mjs bin/torch.mjs site/*.js test/fleet" + "format": "eslint src/adapters src/backlog src/canonical src/checks src/console src/control-plane src/design src/evolution src/importers src/integration src/kernel src/mcp src/observability src/resources src/runtime src/schedules src/self-host src/telemetry src/cli.mjs bin/torch.mjs site/*.js test/fleet --fix", + "lint": "eslint src/adapters src/backlog src/canonical src/checks src/console src/control-plane src/design src/evolution src/importers src/integration src/kernel src/mcp src/observability src/resources src/runtime src/schedules src/self-host src/telemetry src/cli.mjs bin/torch.mjs site/*.js test/fleet" }, "dependencies": { "@modelcontextprotocol/server": "^2.1.0", 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 b985ac3..7909b3e 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 @@ -146,6 +146,14 @@ test_integrity_note: given: a mixed repository and an external prose specification when: torch bootstrap prepares the provider-independent Session Architect input then: repository evidence, omitted components, unmatched specification signals, trust boundaries, and the pending output contract remain explicit without mutating the project + - id: SCN-fleet-evolution + 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-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 + then: missing approval is refused and the activated identity is queryable through the ordinary Fleet CLI - id: SCN-product-site given: the redesigned TORCH public surface when: its product story and local-console boundary are inspected @@ -174,6 +182,7 @@ test_integrity_note: - proposal review status, repository commit binding, and generated domain count - specification digest, evidence-linked responsibility, and stale-spec refusal - bootstrap trust boundary, unassigned components, unmatched specification signals, and pending output contract + - durable Fleet-change state, owner approval, configuration commit, clean worktree, prompt, live identity discovery, and isolated runtime start plan - compatibility report counts and unchanged COMBATRIG Git status - HTTP status, content-security policy, fixed routes, and traversal refusal - unchanged Git status and absent database before and after console observation @@ -237,6 +246,8 @@ test_integrity_note: - generic fleet templates replacing project-derived responsibilities - a code-layout heuristic being presented as AI architectural judgment - bootstrap silently calling a paid model or copying COMBATRIG domains + - a worker expanding the roster or the Session Manager activating a domain without owner approval + - transient one-off work causing silent unbounded Fleet growth - COMBATRIG compatibility inventing path ownership to force a green migration - a disconnected dashboard inventing state or creating it during observation - public copy claiming live qualification that only virtualized tests prove diff --git a/site/console.html b/site/console.html index c8f1d10..210d407 100644 --- a/site/console.html +++ b/site/console.html @@ -26,6 +26,7 @@

Fleet

0 identities

Backlog

0 active
+

Fleet changes

0 pending

Integration

Exact commits

Resources

FIFO leases

Messages

0 queued
diff --git a/site/console.js b/site/console.js index 838df44..a8950b9 100644 --- a/site/console.js +++ b/site/console.js @@ -30,6 +30,13 @@ function render(snapshot) {
${safe(task.title ?? task.id)}${safe(task.owner ?? 'unassigned')}
${safe(task.state)}
`).join('') : empty('No active tracked work.'); + const changes = snapshot.fleetChanges ?? []; + const pendingChanges = changes.filter((change) => !['active', 'rejected'].includes(change.state)); + $('#change-count').textContent = `${pendingChanges.length} pending`; + $('#change-list').innerHTML = changes.length ? changes.map((change) => ` +
${safe(change.title ?? change.domainId)}${safe(change.expectedBenefit ?? 'No expected benefit recorded')}
${safe(change.state)}
+ `).join('') : empty('No Fleet changes proposed. The current roster remains in force.'); + const integrations = snapshot.integration ?? []; $('#integration-list').innerHTML = integrations.length ? integrations.map((item) => `
${safe(item.sourceArea)}${safe(item.sourceCommit?.slice(0, 9))}
${safe(item.state)}
diff --git a/site/index.html b/site/index.html index 01817c7..c60d2a1 100644 --- a/site/index.html +++ b/site/index.html @@ -71,7 +71,7 @@
Operate

Persistent specialists

-

Stable identities survive runtime sessions. Durable messages and project state outlive model context.

+

Stable identities survive runtime sessions. When a durable new boundary emerges, the manager can propose another specialist—but only owner approval wires it into the Fleet.

diff --git a/src/cli.mjs b/src/cli.mjs index 2546f5d..156a70c 100644 --- a/src/cli.mjs +++ b/src/cli.mjs @@ -19,13 +19,14 @@ import { readFileSync } from 'node:fs'; import { resolve } from 'node:path'; import { writeNewFile } from './kernel/files.mjs'; import { createWorktrees, planWorktrees } from './kernel/worktrees.mjs'; -import { planFleetDown, planFleetUp, startFleet, stopFleet } from './runtime/lifecycle.mjs'; +import { planAreaUp, 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'; import { ContextTelemetryService } from './telemetry/context.mjs'; import { ScheduleService } from './schedules/service.mjs'; import { createFleetDesignBrief } from './design/brief.mjs'; +import { FleetEvolutionService } from './evolution/service.mjs'; const HELP = `TORCH — portable agent fleet @@ -90,6 +91,13 @@ Usage: torch schedules runs [--id ] [--json] torch schedules plan --id --actor [--json] torch schedules run --id --actor [--yes] [--json] + torch fleet changes [--state ] [--json] + torch fleet get --change [--json] + torch fleet propose --from session-manager --proposal [--json] + torch fleet approve --change --by --yes [--json] + torch fleet plan --change [--json] + torch fleet activate --change --by --yes [--json] + torch fleet start --change [--fresh] [--dry-run] --yes [--json] torch doctor [--json] torch uninstall [--dry-run] [--purge] [--json] `; @@ -132,6 +140,13 @@ function loadProposal(cwd, proposalPath) { } } +function loadJsonFile(cwd, inputPath, { label = 'JSON input', code = 'INPUT_INVALID' } = {}) { + if (!inputPath) throw new TorchError(`${label} path is required`, { code }); + try { return JSON.parse(readFileSync(resolve(cwd, inputPath), 'utf8')); } catch (error) { + throw new TorchError(`Cannot read ${label}: ${inputPath}`, { code, details: error.message }); + } +} + function withControlPlane(repository, env, callback) { const controlPlane = openControlPlane({ repositoryRoot: repository.root, env }); try { @@ -168,6 +183,7 @@ function runCandidateAcceptance(candidateRoot, spawn) { ['init-analyze', ['SCN-init-read-only']], ['spec-aware-design', ['SCN-spec-fleet-design', 'SCN-cli-spec-design']], ['ai-fleet-bootstrap', ['SCN-ai-fleet-bootstrap']], + ['fleet-evolution', ['SCN-fleet-evolution', 'SCN-cli-fleet-evolution']], ['review-install-roster', ['SCN-cli-domain-review']], ['branches-worktrees', ['SCN-worktree-bootstrap']], ['runtime-identities', ['SCN-mixed-runtime']], @@ -534,6 +550,67 @@ export async function runCli(argv = process.argv.slice(2), { })), { json }); return 0; } + if (command === 'fleet') { + const operation = argv[1] ?? 'changes'; + const control = openControlPlane({ repositoryRoot: repository.root, env }); + try { + const evolution = new FleetEvolutionService({ repositoryRoot: repository.root, controlPlane: control }); + const changeId = optionValue(argv, '--change'); + if (operation === 'changes') print({ changes: evolution.list({ state: optionValue(argv, '--state') }) }, { json }); + else if (operation === 'get') print(evolution.get(changeId), { json }); + else if (operation === 'propose') { + const input = loadJsonFile(cwd, optionValue(argv, '--proposal'), { + label: 'Fleet domain proposal', code: 'FLEET_PROPOSAL_INVALID', + }); + print(evolution.proposeDomain({ + ...input, 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' }); + } + print(evolution.approve({ changeId, approvedBy: optionValue(argv, '--by') }), { json }); + } 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.', { + 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') { + throw new TorchError('Only an active Fleet domain can start a runtime session', { + code: 'FLEET_CHANGE_STATE_CONFLICT', details: { state: change.state }, + }); + } + const adapters = runtimeAdapters(); + const plan = planAreaUp({ + repositoryRoot: repository.root, controlPlane: control, areaId: change.domain.id, + adapters, fresh: argv.includes('--fresh'), + }); + if (argv.includes('--dry-run')) { + print(plan, { json }); + return plan.canProceed ? 0 : 1; + } + if (!argv.includes('--yes')) { + throw new TorchError('Starting the new domain invokes its configured AI runtime. Review --dry-run, then use --yes.', { + code: 'APPROVAL_REQUIRED', + }); + } + print(startFleet({ + plan, controlPlane: control, adapters, + executor: (launch) => spawn(launch.command, launch.args, { + cwd: launch.cwd, encoding: 'utf8', stdio: ['ignore', 'pipe', 'pipe'], + }), + }), { json }); + } else throw new TorchError(`Unknown fleet operation: ${operation}`, { code: 'UNKNOWN_COMMAND' }); + return 0; + } finally { + control.close(); + } + } if (command === 'backlog') { const operation = argv[1] ?? 'list'; const control = openControlPlane({ repositoryRoot: repository.root, env }); diff --git a/src/control-plane/service.mjs b/src/control-plane/service.mjs index 8cdc5d5..9c0afe6 100644 --- a/src/control-plane/service.mjs +++ b/src/control-plane/service.mjs @@ -78,7 +78,9 @@ function rowToAgent(row, rosterAgent) { sharedPaths: rosterAgent.shared_paths ?? [], neighbours: rosterAgent.neighbours ?? [], requiredChecks: rosterAgent.required_checks ?? [], + resources: rosterAgent.resources ?? [], runtime: row?.runtime ?? null, + model: rosterAgent.model ?? null, runtimeSessionId: row?.runtime_session_id ?? null, state: row?.state ?? 'offline', summary: row?.summary ?? null, @@ -224,11 +226,27 @@ export class ControlPlane { for (const areaId of this.agents.keys()) insert.run(areaId, 'offline'); } + refreshRoster() { + const roster = readJson(join(this.repositoryRoot, '.torch', 'roster.yaml'), 'INVALID_TORCH_ROSTER'); + const agents = new Map((roster.areas ?? []).map((area) => [area.id, area])); + if (!agents.has('session-manager')) { + throw new TorchError('Roster does not contain the required session-manager identity', { + code: 'INVALID_TORCH_ROSTER', + }); + } + const insert = this.database.prepare('INSERT OR IGNORE INTO identities (area_id, state) VALUES (?, ?)'); + for (const areaId of agents.keys()) insert.run(areaId, 'offline'); + this.roster = roster; + this.agents = agents; + return { schema: roster.schema, areaIds: [...agents.keys()] }; + } + close() { this.database.close(); } assertIdentity(areaId) { + this.refreshRoster(); const normalized = requiredText(areaId, 'areaId'); if (!this.agents.has(normalized)) { throw new TorchError(`Unknown Fleet identity: ${normalized}`, { diff --git a/src/design/brief.mjs b/src/design/brief.mjs index 9a6d8dc..9a89619 100644 --- a/src/design/brief.mjs +++ b/src/design/brief.mjs @@ -77,6 +77,7 @@ export function createFleetDesignBrief({ repository, analysis, baseline } = {}) domainFields: [ 'id', 'title', 'kind', 'scope', 'not_scope', 'owned_paths', 'shared_paths', 'neighbours', 'required_checks', 'runtime', 'evidence', + 'resources', ], topLevelFields: [ 'schema', 'generatedAt', 'repository', 'review', 'specifications', 'domains', diff --git a/src/evolution/service.mjs b/src/evolution/service.mjs new file mode 100644 index 0000000..1b73b8c --- /dev/null +++ b/src/evolution/service.mjs @@ -0,0 +1,411 @@ +import { execFileSync } from 'node:child_process'; +import { createHash, randomUUID } from 'node:crypto'; +import { existsSync, mkdirSync, readFileSync, 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'; + +const CHANGE_STATES = Object.freeze(['proposed', 'approved', 'provisioning', 'active', 'rejected']); + +function requiredText(value, name) { + if (typeof value !== 'string' || !value.trim()) { + throw new TorchError(`${name} must be a non-empty string`, { + code: 'INVALID_FLEET_CHANGE', details: { field: name }, + }); + } + return value.trim(); +} + +function textList(value, name, { required = false } = {}) { + if (!Array.isArray(value) || (required && value.length === 0) + || value.some((entry) => typeof entry !== 'string' || !entry.trim())) { + throw new TorchError(`${name} must be ${required ? 'a non-empty' : 'an'} array of non-empty strings`, { + code: 'INVALID_FLEET_CHANGE', details: { field: name }, + }); + } + return [...new Set(value.map((entry) => entry.trim()))]; +} + +function readJson(path, code) { + try { return JSON.parse(readFileSync(path, 'utf8')); } catch (error) { + throw new TorchError(`Cannot read ${path}`, { code, details: error.message }); + } +} + +function sha256(content) { + return createHash('sha256').update(content).digest('hex'); +} + +function git(root, args, { optional = false } = {}) { + try { + return execFileSync('git', ['-C', root, ...args], { + encoding: 'utf8', stdio: ['ignore', 'pipe', 'pipe'], + }).trim(); + } catch (error) { + if (optional) return ''; + throw new TorchError(`Git command failed: git ${args.join(' ')}`, { + code: 'GIT_COMMAND_FAILED', details: error.stderr?.toString().trim() || error.message, + }); + } +} + +function expandPath(value, root) { + if (value === '~') return homedir(); + if (value.startsWith('~/')) return join(homedir(), value.slice(2)); + return isAbsolute(value) ? value : resolve(root, value); +} + +function ownershipPrefixes(pattern) { + const normalized = pattern.replaceAll('\\', '/'); + const wildcard = normalized.search(/[?*[]/); + return (wildcard === -1 ? normalized : normalized.slice(0, wildcard)).replace(/\/$/, ''); +} + +function pathsOverlap(left, right) { + if (left === right) return true; + const a = ownershipPrefixes(left); + const b = ownershipPrefixes(right); + return Boolean(a && b && (a.startsWith(`${b}/`) || b.startsWith(`${a}/`))); +} + +function normalizeDomain(input) { + const id = requiredText(input?.id, 'domain.id'); + if (!/^[a-z0-9][a-z0-9-]*$/.test(id) || id === 'session-manager') { + throw new TorchError(`Invalid proposed domain ID: ${id}`, { + code: 'INVALID_FLEET_CHANGE', details: { field: 'domain.id' }, + }); + } + const runtime = requiredText(input.runtime ?? 'claude', 'domain.runtime'); + const model = input.model === undefined || input.model === null + ? null : requiredText(input.model, 'domain.model'); + const branch = input.branch === undefined || input.branch === null + ? null : requiredText(input.branch, 'domain.branch'); + const worktreeName = input.worktree_name === undefined || input.worktree_name === null + ? null : requiredText(input.worktree_name, 'domain.worktree_name'); + if (worktreeName && !/^[A-Za-z0-9][A-Za-z0-9._-]*$/.test(worktreeName)) { + throw new TorchError(`Invalid worktree name: ${worktreeName}`, { + code: 'INVALID_FLEET_CHANGE', details: { field: 'domain.worktree_name' }, + }); + } + return { + id, + title: requiredText(input.title, 'domain.title'), + kind: requiredText(input.kind ?? 'development', 'domain.kind'), + scope: textList(input.scope, 'domain.scope', { required: true }), + not_scope: textList(input.not_scope ?? [], 'domain.not_scope'), + owned_paths: textList(input.owned_paths, 'domain.owned_paths', { required: true }), + shared_paths: textList(input.shared_paths ?? [], 'domain.shared_paths'), + neighbours: textList(input.neighbours ?? [], 'domain.neighbours'), + required_checks: textList(input.required_checks ?? [], 'domain.required_checks'), + resources: textList(input.resources ?? [], 'domain.resources'), + runtime, + model, + branch, + worktree_name: worktreeName, + }; +} + +function rowToChange(row) { + if (!row) return null; + return { + id: row.id, 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), + rationale: row.rationale, expectedBenefit: JSON.parse(row.expected_benefit_json), + evidence: JSON.parse(row.evidence_json), + }; +} + +function promptFor(domain) { + return [ + `# ${domain.title}`, '', `Area ID: ${domain.id}`, '', '## Owns', '', + ...domain.scope.map((item) => `- ${item}`), '', '## Does not own', '', + ...(domain.not_scope.length ? domain.not_scope : ['No exclusions recorded.']).map((item) => `- ${item}`), + '', '## Neighbours', '', + ...(domain.neighbours.length ? domain.neighbours : ['None identified.']).map((item) => `- ${item}`), + '', '## Resources', '', + ...(domain.resources.length ? domain.resources : ['No scarce resources declared.']).map((item) => `- ${item}`), + '', '## First move', '', + 'Query live ownership, read the assigned backlog item, and report current repository evidence to the Session Manager.', + '', + ].join('\n'); +} + +function initialize(database) { + database.exec(` + CREATE TABLE IF NOT EXISTS fleet_changes ( + id TEXT PRIMARY KEY, + state TEXT NOT NULL, + proposer TEXT NOT NULL, + created_at TEXT NOT NULL, + updated_at TEXT NOT NULL, + base_commit TEXT NOT NULL, + approved_by TEXT, + approved_at TEXT, + activation_commit TEXT, + domain_json TEXT NOT NULL, + rationale TEXT NOT NULL, + expected_benefit_json TEXT NOT NULL, + evidence_json TEXT NOT NULL + ); + CREATE INDEX IF NOT EXISTS fleet_changes_state_created + ON fleet_changes(state, created_at, id); + `); +} + +export class FleetEvolutionService { + constructor({ repositoryRoot, controlPlane, clock = () => new Date(), idFactory = randomUUID } = {}) { + if (!controlPlane) throw new TorchError('Fleet evolution requires the TORCH control plane', { code: 'CONTROL_PLANE_REQUIRED' }); + this.repositoryRoot = resolve(repositoryRoot ?? controlPlane.repositoryRoot); + this.controlPlane = controlPlane; + this.database = controlPlane.database; + this.clock = clock; + this.idFactory = idFactory; + initialize(this.database); + } + + get(changeId) { + const id = requiredText(changeId, 'changeId'); + const change = rowToChange(this.database.prepare('SELECT * FROM fleet_changes WHERE id = ?').get(id)); + if (!change) throw new TorchError(`Unknown Fleet change: ${id}`, { code: 'FLEET_CHANGE_NOT_FOUND' }); + return change; + } + + list({ state } = {}) { + if (state && !CHANGE_STATES.includes(state)) { + throw new TorchError(`Invalid Fleet change state: ${state}`, { code: 'INVALID_FLEET_CHANGE_STATE' }); + } + const rows = state + ? this.database.prepare('SELECT * FROM fleet_changes WHERE state = ? ORDER BY created_at, id').all(state) + : this.database.prepare('SELECT * FROM fleet_changes ORDER BY created_at, id').all(); + return rows.map(rowToChange); + } + + proposeDomain({ proposer, domain: input, rationale, expectedBenefit, evidence } = {}) { + const actor = this.controlPlane.assertIdentity(proposer); + if (actor !== 'session-manager') { + throw new TorchError('Only the Session Manager may propose a persistent Fleet domain', { + code: 'FLEET_CHANGE_AUTHORITY_REQUIRED', details: { actor }, + }); + } + const domain = normalizeDomain(input); + const agents = this.controlPlane.listAgents(); + if (agents.some((agent) => agent.areaId === domain.id)) { + 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 evidenceList = textList(evidence, 'evidence', { required: true }); + const collisions = agents.flatMap((agent) => domain.owned_paths.flatMap((proposedPath) => + agent.ownedPaths.filter((existingPath) => pathsOverlap(proposedPath, existingPath)) + .map((existingPath) => ({ areaId: agent.areaId, proposedPath, existingPath })))); + if (collisions.length) { + throw new TorchError('A new domain cannot silently overlap existing owned paths; propose a split or ownership change instead', { + code: 'FLEET_OWNERSHIP_COLLISION', details: collisions, + }); + } + const config = readJson(join(this.repositoryRoot, '.torch', 'torch.yaml'), 'CONFIG_INVALID'); + if (!config.runtimes?.[domain.runtime]) { + throw new TorchError(`Runtime is not configured for the Fleet: ${domain.runtime}`, { + code: 'FLEET_RUNTIME_NOT_CONFIGURED', details: { runtime: domain.runtime }, + }); + } + const knownChecks = new Set((config.checks ?? []).map((check) => check.id)); + const unknownChecks = domain.required_checks.filter((check) => !knownChecks.has(check)); + if (unknownChecks.length) { + throw new TorchError('Proposed domain references unknown checks', { + code: 'FLEET_CHECK_NOT_CONFIGURED', details: { checks: unknownChecks }, + }); + } + const knownResources = new Set((config.resources ?? []).map((resource) => resource.id)); + const unknownResources = domain.resources.filter((resource) => !knownResources.has(resource)); + if (unknownResources.length) { + throw new TorchError('Proposed domain references unknown resources', { + code: 'FLEET_RESOURCE_NOT_CONFIGURED', details: { resources: unknownResources }, + }); + } + const now = this.clock().toISOString(); + const record = { + id: this.idFactory(), 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: benefit, evidence: evidenceList, + }; + this.database.prepare(` + INSERT INTO fleet_changes ( + id, 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, ?, ?, ?, ?) + `).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-domain', 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') { + throw new TorchError(`Fleet change ${change.id} is ${change.state}, not proposed`, { code: 'FLEET_CHANGE_STATE_CONFLICT' }); + } + const owner = requiredText(approvedBy, 'approvedBy'); + const now = this.clock().toISOString(); + this.database.prepare(` + UPDATE fleet_changes SET state = 'approved', approved_by = ?, approved_at = ?, updated_at = ? + WHERE id = ? AND state = 'proposed' + `).run(owner, now, now, change.id); + this.controlPlane.audit({ + actorId: 'session-manager', operation: 'fleet-change.owner-approved', entityType: 'fleet-change', entityId: change.id, + details: { approvedBy: owner }, + }); + return this.get(change.id); + } + + planActivation(changeId) { + const change = this.get(changeId); + const config = readJson(join(this.repositoryRoot, '.torch', 'torch.yaml'), 'CONFIG_INVALID'); + const roster = readJson(join(this.repositoryRoot, '.torch', 'roster.yaml'), 'ROSTER_INVALID'); + const currentHead = git(this.repositoryRoot, ['rev-parse', 'HEAD']); + const dirtyEntries = git(this.repositoryRoot, ['status', '--porcelain']).split('\n').filter(Boolean); + const trackedConfig = ['.torch/torch.yaml', '.torch/roster.yaml'].every((path) => + Boolean(git(this.repositoryRoot, ['ls-files', '--error-unmatch', path], { optional: true }))); + const prefix = config.git?.branch_prefix ?? 'torch/'; + const branch = change.domain.branch ?? `${prefix}${change.domain.id}`; + const parent = expandPath(config.paths.worktree_parent, this.repositoryRoot); + const worktreeName = change.domain.worktree_name ?? change.domain.id; + const worktree = change.domain.worktree_name + ? join(parent, worktreeName) : join(parent, config.project.id, worktreeName); + 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 (!trackedConfig) blockers.push({ type: 'configuration-not-committed' }); + if (config.domains.some((domain) => domain.id === change.domain.id) && change.state !== 'provisioning') { + blockers.push({ type: 'domain-already-configured', domainId: change.domain.id }); + } + if (roster.areas.some((area) => area.id === change.domain.id) && change.state !== 'provisioning') { + blockers.push({ type: 'identity-already-rostered', domainId: change.domain.id }); + } + if (git(this.repositoryRoot, ['branch', '--list', branch], { optional: true })) { + blockers.push({ type: 'branch-exists', branch }); + } + if (existsSync(worktree)) blockers.push({ type: 'worktree-path-exists', worktree }); + return { + action: 'activate-domain', changeId: change.id, domainId: change.domain.id, + state: change.state, branch, worktree, currentHead, baseCommit: change.baseCommit, + blockers, canProceed: blockers.length === 0, mutationPerformed: false, + }; + } + + activate({ changeId, approvedBy } = {}) { + const change = this.get(changeId); + if (change.approvedBy !== requiredText(approvedBy, 'approvedBy')) { + throw new TorchError('Fleet activation must name the same owner who approved the change', { + code: 'FLEET_CHANGE_AUTHORITY_REQUIRED', details: { approvedBy: change.approvedBy }, + }); + } + const plan = this.planActivation(change.id); + if (!plan.canProceed) { + throw new TorchError('Fleet domain activation is blocked', { + code: 'FLEET_CHANGE_BLOCKED', details: plan.blockers, + }); + } + if (change.state === 'approved') { + const manifest = readInstallManifest(this.repositoryRoot); + const ignore = ensureTaskIgnored(this.repositoryRoot, manifest.installationId); + try { + this.commitConfiguration(change, plan, ignore); + } catch (error) { + removeTaskIgnore(ignore, manifest.installationId); + throw error; + } + } + const current = this.get(change.id); + mkdirSync(dirname(plan.worktree), { recursive: true }); + git(this.repositoryRoot, ['worktree', 'add', '-b', plan.branch, plan.worktree, current.activationCommit]); + writeFileSync(join(plan.worktree, '.task'), `Unassigned · ${change.domain.id}\n`, { encoding: 'utf8', flag: 'wx' }); + const now = this.clock().toISOString(); + this.database.prepare("UPDATE fleet_changes SET state = 'active', updated_at = ? WHERE id = ?") + .run(now, change.id); + this.controlPlane.refreshRoster(); + this.controlPlane.audit({ + actorId: 'session-manager', operation: 'fleet-change.activate-domain', entityType: 'fleet-change', entityId: change.id, + details: { domainId: change.domain.id, branch: plan.branch, worktree: plan.worktree }, + }); + return { + ...this.get(change.id), branch: plan.branch, worktree: plan.worktree, + identity: this.controlPlane.identity(change.domain.id), + runtime: { state: 'offline', next: `Start ${change.domain.id} through the approved ${change.domain.runtime} adapter.` }, + mutationPerformed: true, + }; + } + + commitConfiguration(change, plan, ignore) { + const configPath = join(this.repositoryRoot, '.torch', 'torch.yaml'); + const rosterPath = join(this.repositoryRoot, '.torch', 'roster.yaml'); + const config = readJson(configPath, 'CONFIG_INVALID'); + const roster = readJson(rosterPath, 'ROSTER_INVALID'); + const manifest = readInstallManifest(this.repositoryRoot); + const domain = change.domain; + config.domains.push(domain); + roster.areas.push(domain); + for (const neighbourId of domain.neighbours) { + const configured = config.domains.find((candidate) => candidate.id === neighbourId); + const rostered = roster.areas.find((candidate) => candidate.id === neighbourId); + if (configured && !configured.neighbours.includes(domain.id)) configured.neighbours.push(domain.id); + if (rostered && !rostered.neighbours.includes(domain.id)) rostered.neighbours.push(domain.id); + } + const promptRelative = `.torch/prompts/${domain.id}.md`; + const changeRelative = `.torch/fleet-changes/${change.id}.approved.json`; + const approvedRecord = { + schema: 'torch.dev/fleet-change/v1alpha1', ...change, state: 'approved', + activation: { branch: plan.branch, worktree: plan.worktree }, + }; + const serialized = new Map([ + ['.torch/torch.yaml', `${JSON.stringify(config, null, 2)}\n`], + ['.torch/roster.yaml', `${JSON.stringify(roster, null, 2)}\n`], + [promptRelative, promptFor(domain)], + [changeRelative, `${JSON.stringify(approvedRecord, null, 2)}\n`], + ]); + manifest.external.push({ type: 'worktree', area: domain.id, path: plan.worktree, branch: plan.branch }); + if (ignore?.added) manifest.patched.push({ + type: 'git-info-exclude-line', path: ignore.path, line: '.task', installationId: manifest.installationId, + }); + for (const [relativePath, content] of serialized) { + const existing = manifest.created.find((record) => record.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): add ${domain.id} domain`]); + const activationCommit = git(this.repositoryRoot, ['rev-parse', 'HEAD']); + const now = this.clock().toISOString(); + this.database.prepare(` + UPDATE fleet_changes SET state = 'provisioning', activation_commit = ?, updated_at = ? WHERE id = ? + `).run(activationCommit, now, change.id); + } +} diff --git a/src/kernel/domains.mjs b/src/kernel/domains.mjs index fe2f543..3b9cf21 100644 --- a/src/kernel/domains.mjs +++ b/src/kernel/domains.mjs @@ -137,6 +137,7 @@ function domainFromComponent(component) { shared_paths: [], neighbours: [], required_checks: [], + resources: [], runtime: 'claude', evidence: component.evidence, }; @@ -162,7 +163,7 @@ function specDomain(signal, specification) { : [`responsibilities described by ${specification.path}:${signal.line}`], not_scope: [], owned_paths: signal.pathReferences, - shared_paths: [], neighbours: [], required_checks: [], runtime: 'claude', + shared_paths: [], neighbours: [], required_checks: [], resources: [], runtime: 'claude', evidence: [`${specification.path}:${signal.line}`], design_status: signal.pathReferences.length ? 'evidence-backed' : 'needs-owner-path-review', }; @@ -200,6 +201,7 @@ export function proposeDomains({ repository, analysis }) { id: 'core', title: 'Core project', kind: 'development', scope: ['initial project implementation and architecture'], not_scope: [], owned_paths: ['**'], shared_paths: [], neighbours: [], required_checks: [], + resources: [], runtime: 'claude', evidence: analysis.inventory.manifests, }); @@ -212,6 +214,7 @@ export function proposeDomains({ repository, analysis }) { not_scope: ['implementing domain features solely to make checks pass'], owned_paths: ['test/**', 'tests/**', 'e2e/**'], shared_paths: [], neighbours: [], required_checks: [], evidence: graph.verificationSurfaces.slice(0, 12), + resources: [], runtime: 'claude', }); } @@ -222,6 +225,7 @@ export function proposeDomains({ repository, analysis }) { not_scope: ['feature implementation', 'deployment without configured authority'], owned_paths: graph.operationalSurfaces.filter((path) => /deploy|release|workflow/i.test(path)).slice(0, 20), shared_paths: [], neighbours: [], required_checks: [], + resources: [], runtime: 'claude', evidence: graph.operationalSurfaces.slice(0, 12), }); @@ -333,6 +337,7 @@ export function validateApprovedProposal({ proposal, repository }) { ids.add(domain.id); if (!domain.scope?.length) problems.push(`domain ${domain.id} has no scope`); if (!Array.isArray(domain.not_scope)) problems.push(`domain ${domain.id} has no not_scope list`); + if (domain.resources !== undefined && !Array.isArray(domain.resources)) problems.push(`domain ${domain.id} has an invalid resources list`); if (!domain.owned_paths?.length) problems.push(`domain ${domain.id} has no owned paths`); if (domain.runtime !== undefined && (typeof domain.runtime !== 'string' || !domain.runtime.trim())) { problems.push(`domain ${domain.id} has an invalid runtime`); diff --git a/src/kernel/install.mjs b/src/kernel/install.mjs index 6f172b0..990d081 100644 --- a/src/kernel/install.mjs +++ b/src/kernel/install.mjs @@ -67,7 +67,9 @@ function initialFiles({ repository, projectId, createdAt, proposal }) { shared_paths: domain.shared_paths ?? [], neighbours: domain.neighbours ?? [], required_checks: domain.required_checks ?? [], + resources: domain.resources ?? [], runtime: domain.runtime ?? 'claude', + model: domain.model ?? null, branch: domain.branch ?? null, worktree_name: domain.worktree_name ?? null, })), @@ -85,6 +87,10 @@ function initialFiles({ repository, projectId, createdAt, proposal }) { }, ...config.domains], }; + 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`; const files = new Map([ ['torch.yaml', jsonYaml(config)], ['roster.yaml', jsonYaml(roster)], @@ -94,10 +100,7 @@ function initialFiles({ repository, projectId, createdAt, proposal }) { proposal.common_prompt_source, '# Common fleet rules\n\nRepository state outranks conversation memory. Query ownership before crossing a domain boundary.\n', )], - ['prompts/session-manager.md', 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', - )], + ['prompts/session-manager.md', managerPrompt], ['backlog/.gitkeep', ''], ['INSTALLATION.md', `# TORCH installation\n\nInstalled ${createdAt}. Run \`torch doctor\` before starting the fleet.\n`], ]); @@ -119,6 +122,10 @@ function initialFiles({ repository, projectId, createdAt, proposal }) { '', ...(domain.neighbours.length ? domain.neighbours : ['None identified.']).map((item) => `- ${item}`), '', + '## Resources', + '', + ...(domain.resources.length ? domain.resources : ['No scarce resources declared.']).map((item) => `- ${item}`), + '', '## First move', '', 'Query live ownership, read the assigned backlog item, and report current repository evidence to the Session Manager.', diff --git a/src/kernel/worktrees.mjs b/src/kernel/worktrees.mjs index 8b752e8..a4f0611 100644 --- a/src/kernel/worktrees.mjs +++ b/src/kernel/worktrees.mjs @@ -116,7 +116,7 @@ export function planWorktrees({ repository, parentOverride } = {}) { }; } -function ensureTaskIgnored(root, installationId) { +export function ensureTaskIgnored(root, installationId) { const common = git(root, ['rev-parse', '--git-common-dir']); const commonPath = isAbsolute(common) ? common : resolve(root, common); const excludePath = join(commonPath, 'info', 'exclude'); @@ -127,7 +127,7 @@ function ensureTaskIgnored(root, installationId) { return { path: excludePath, added: true }; } -function removeTaskIgnore(record, installationId) { +export function removeTaskIgnore(record, installationId) { if (!record.added || !existsSync(record.path)) return; const marker = `# TORCH ${installationId}\n.task\n`; const content = readFileSync(record.path, 'utf8'); diff --git a/src/mcp/server.mjs b/src/mcp/server.mjs index b261d59..4bd16d6 100755 --- a/src/mcp/server.mjs +++ b/src/mcp/server.mjs @@ -9,6 +9,7 @@ import { ResourceService } from '../resources/service.mjs'; import { createTorchMcpServer } from './tools.mjs'; import { ContextTelemetryService } from '../telemetry/context.mjs'; import { ScheduleService } from '../schedules/service.mjs'; +import { FleetEvolutionService } from '../evolution/service.mjs'; function optionValue(argv, name) { const direct = argv.find((argument) => argument.startsWith(`${name}=`)); @@ -29,8 +30,10 @@ const backlogService = new BacklogService({ }); const contextTelemetryService = new ContextTelemetryService({ controlPlane }); const scheduleService = new ScheduleService({ repositoryRoot, controlPlane }); +const evolutionService = new FleetEvolutionService({ repositoryRoot, controlPlane }); const handle = serveStdio(() => createTorchMcpServer(controlPlane, { actorId, backlogService, checkService, resourceService, integrationService, contextTelemetryService, scheduleService, + evolutionService, }), { onerror: (error) => console.error(`TORCH MCP: ${error.message}`), }); diff --git a/src/mcp/tools.mjs b/src/mcp/tools.mjs index fae4433..b85cd00 100644 --- a/src/mcp/tools.mjs +++ b/src/mcp/tools.mjs @@ -53,6 +53,9 @@ export const TORCH_MCP_TOOL_NAMES = Object.freeze([ 'torch_schedule_runs', 'torch_plan_schedule', 'torch_run_schedule', + 'torch_list_fleet_changes', + 'torch_get_fleet_change', + 'torch_propose_domain', ]); function claimedIdentity(controlPlane, actorId, claim, field) { @@ -72,6 +75,7 @@ function claimedIdentity(controlPlane, actorId, claim, field) { export function createTorchToolset(controlPlane, { actorId, backlogService, checkService, resourceService, integrationService, contextTelemetryService, scheduleService, + evolutionService, } = {}) { const tools = new Map([ ['torch_identity', { @@ -391,6 +395,48 @@ export function createTorchToolset(controlPlane, { invoke: ({ schedule_id: scheduleId }) => scheduleService.run({ scheduleId, actorId, approved: false }), }); } + 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() }, + invoke: ({ state }) => ({ changes: evolutionService.list({ state }) }), + }); + tools.set('torch_get_fleet_change', { + description: 'Read one durable Fleet organization change and its owner-approval state.', + schema: { change_id: z.string().min(1) }, + invoke: ({ change_id: changeId }) => evolutionService.get(changeId), + }); + tools.set('torch_propose_domain', { + description: 'Propose an evidence-backed persistent domain as the bound Session Manager. This never approves or activates it.', + schema: { + proposer: z.string().min(1).optional(), + domain: z.object({ + id: z.string().min(1), title: z.string().min(1), kind: z.string().min(1).optional(), + scope: z.array(z.string().min(1)).min(1), not_scope: z.array(z.string().min(1)).optional(), + owned_paths: z.array(z.string().min(1)).min(1), shared_paths: z.array(z.string().min(1)).optional(), + neighbours: z.array(z.string().min(1)).optional(), required_checks: z.array(z.string().min(1)).optional(), + resources: z.array(z.string().min(1)).optional(), + runtime: z.string().min(1).optional(), branch: z.string().min(1).optional(), + model: z.string().min(1).optional(), + worktree_name: z.string().min(1).optional(), + }), + 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, expected_benefit: benefit, ...input }) => evolutionService.proposeDomain({ + ...input, + proposer: claimedIdentity(controlPlane, actorId, proposer, 'proposer'), + expectedBenefit: { + summary: benefit.summary, recurringWork: benefit.recurring_work, + contextLocality: benefit.context_locality, coordinationCost: benefit.coordination_cost, + }, + }), + }); + } return tools; } @@ -403,6 +449,7 @@ export function callTorchTool(controlPlane, name, input = {}, options = {}) { export function createTorchMcpServer(controlPlane, { actorId, backlogService, checkService, resourceService, integrationService, contextTelemetryService, scheduleService, + evolutionService, } = {}) { controlPlane.assertIdentity(actorId); const server = new McpServer( @@ -413,6 +460,7 @@ export function createTorchMcpServer(controlPlane, { ); for (const [name, tool] of createTorchToolset(controlPlane, { actorId, backlogService, checkService, resourceService, integrationService, contextTelemetryService, scheduleService, + evolutionService, })) { server.registerTool(name, { description: tool.description, inputSchema: tool.schema }, async (input) => { try { diff --git a/src/observability/snapshot.mjs b/src/observability/snapshot.mjs index 2f7bf67..c509637 100644 --- a/src/observability/snapshot.mjs +++ b/src/observability/snapshot.mjs @@ -78,6 +78,7 @@ export function observeProject({ repositoryRoot, env = process.env, now = () => checks: { definitions: config.checks ?? [], receipts: [] }, resources: { definitions: config.resources ?? [], holders: [], waiters: [] }, integration: [], + fleetChanges: [], schedules: config.schedules ?? [], audit: [], contextLocality: { @@ -135,6 +136,13 @@ export function observeProject({ repositoryRoot, env = process.env, now = () => SELECT id, source_area AS sourceArea, source_commit AS sourceCommit, target_branch AS targetBranch, state, reason, updated_at AS updatedAt FROM integration_requests ORDER BY updated_at DESC LIMIT 40 `); + result.fleetChanges = rows(database, 'fleet_changes', ` + SELECT id, state, proposer, json_extract(domain_json, '$.id') AS domainId, + json_extract(domain_json, '$.title') AS title, + json_extract(expected_benefit_json, '$.summary') AS expectedBenefit, + approved_by AS approvedBy, updated_at AS updatedAt + FROM fleet_changes ORDER BY updated_at DESC, id LIMIT 40 + `); result.audit = rows(database, 'audit_events', ` SELECT id, actor_id AS actorId, operation, entity_type AS entityType, entity_id AS entityId, created_at AS createdAt FROM audit_events ORDER BY created_at DESC LIMIT 30 diff --git a/src/runtime/lifecycle.mjs b/src/runtime/lifecycle.mjs index 9692149..1429860 100644 --- a/src/runtime/lifecycle.mjs +++ b/src/runtime/lifecycle.mjs @@ -134,6 +134,63 @@ export function planFleetUp({ repositoryRoot, controlPlane, adapter, adapters, f }; } +export function planAreaUp({ repositoryRoot, controlPlane, areaId, adapter, adapters, fresh = false } = {}) { + const { config, roster } = loadFleetDefinition(repositoryRoot); + const manifest = readInstallManifest(repositoryRoot); + const worktree = worktreesByArea(manifest).get(areaId); + const area = roster.areas.find((candidate) => candidate.id === areaId); + const blockers = []; + if (!area) blockers.push({ areaId, code: 'FLEET_IDENTITY_MISSING' }); + if (!worktree) blockers.push({ areaId, code: 'WORKTREE_MISSING' }); + if (blockers.length) { + return { + action: 'area-up', projectId: manifest.projectId, areaId, fresh, + actions: [], blockers, canProceed: false, mutationPerformed: false, + }; + } + const runtimes = adapterMap({ adapter, adapters }); + const runtime = configuredRuntime(area, config); + const runtimeAdapter = runtimes.get(runtime); + if (!runtimeAdapter) blockers.push({ areaId, code: 'RUNTIME_ADAPTER_MISSING', runtime }); + else if (runtimeAdapter.name !== runtime) { + blockers.push({ areaId, code: 'RUNTIME_ADAPTER_MISMATCH', runtime, adapter: runtimeAdapter.name }); + } + if (blockers.length) { + return { + action: 'area-up', projectId: manifest.projectId, areaId, fresh, + actions: [], blockers, canProceed: false, mutationPerformed: false, + }; + } + validateRuntimeAdapter(runtimeAdapter); + const identity = controlPlane.identity(area.id); + const prompts = promptPaths(localStateRoot(manifest), worktree, area.id); + const runtimeConfig = config.runtimes?.[runtime] ?? {}; + const model = area.model ?? runtimeConfig.model; + const background = runtimeConfig.background ?? true; + const shouldResume = !fresh && identity.runtime === runtime && Boolean(identity.runtimeSessionId); + const runtimePlan = shouldResume + ? runtimeAdapter.resumeSession({ + areaId: area.id, runtimeSessionId: identity.runtimeSessionId, worktree: worktree.path, + model, background, message: startupMessage(area.id, false), + }) + : runtimeAdapter.createSession({ + areaId: area.id, title: area.title, worktree: worktree.path, promptFile: prompts.combined, + firstMessage: startupMessage(area.id, true), model, background, + }); + const action = { + areaId: area.id, title: area.title, mode: shouldResume ? 'resume' : 'create', + runtime: runtimeAdapter.name, runtimeSessionId: runtimePlan.runtimeSessionId, + requiresRuntimeIdCapture: runtimePlan.requiresRuntimeIdCapture ?? false, + completionState: runtimePlan.completionState ?? 'starting', + worktree: worktree.path, branch: worktree.branch, promptFile: prompts.combined, + promptSources: [prompts.common, prompts.area], launch: runtimePlan.launch, + }; + return { + action: 'area-up', projectId: manifest.projectId, areaId, fresh, + actions: [action], blockers, canProceed: true, mutationPerformed: false, + }; +} + function writeCombinedPrompt(action) { const content = action.promptSources.map((path) => readFileSync(path, 'utf8').trimEnd()).join('\n\n'); mkdirSync(dirname(action.promptFile), { recursive: true }); diff --git a/src/self-host/service.mjs b/src/self-host/service.mjs index e0c5ae7..62ac123 100644 --- a/src/self-host/service.mjs +++ b/src/self-host/service.mjs @@ -11,6 +11,7 @@ export const CANDIDATE_ACCEPTANCE_SCENARIOS = Object.freeze([ 'init-analyze', 'spec-aware-design', 'ai-fleet-bootstrap', + 'fleet-evolution', 'review-install-roster', 'branches-worktrees', 'runtime-identities', diff --git a/test/fleet/cli-scenarios.test.mjs b/test/fleet/cli-scenarios.test.mjs index dabb156..9276de8 100644 --- a/test/fleet/cli-scenarios.test.mjs +++ b/test/fleet/cli-scenarios.test.mjs @@ -34,6 +34,7 @@ function releaseCandidate(root, version) { 'SCN-init-read-only', 'SCN-cli-domain-review', 'SCN-worktree-bootstrap', 'SCN-mixed-runtime', 'SCN-spec-fleet-design', 'SCN-cli-spec-design', 'SCN-ai-fleet-bootstrap', + 'SCN-fleet-evolution', 'SCN-cli-fleet-evolution', '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', diff --git a/test/fleet/evolution.test.mjs b/test/fleet/evolution.test.mjs new file mode 100644 index 0000000..42e74f4 --- /dev/null +++ b/test/fleet/evolution.test.mjs @@ -0,0 +1,176 @@ +import assert from 'node:assert/strict'; +import { execFileSync, spawnSync } from 'node:child_process'; +import { existsSync, mkdtempSync, readFileSync, writeFileSync } from 'node:fs'; +import { tmpdir } from 'node:os'; +import { join } from 'node:path'; +import test from 'node:test'; +import { openControlPlane } from '../../src/control-plane/service.mjs'; +import { FleetEvolutionService } from '../../src/evolution/service.mjs'; +import { analyzeRepository } from '../../src/kernel/analyze.mjs'; +import { proposeDomains } from '../../src/kernel/domains.mjs'; +import { inspectRepository } from '../../src/kernel/git.mjs'; +import { installProject } from '../../src/kernel/install.mjs'; +import { callTorchTool } from '../../src/mcp/tools.mjs'; +import { createCodexAdapter } from '../../src/adapters/codex.mjs'; +import { planAreaUp, startFleet } from '../../src/runtime/lifecycle.mjs'; +import { observeProject } from '../../src/observability/snapshot.mjs'; + +const CLI = new URL('../../bin/torch.mjs', import.meta.url).pathname; + +function fixture() { + const root = mkdtempSync(join(tmpdir(), 'torch-evolution-')); + const worktreeParent = mkdtempSync(join(tmpdir(), 'torch-evolution-worktrees-')); + const env = { ...process.env, XDG_DATA_HOME: mkdtempSync(join(tmpdir(), 'torch-evolution-state-')) }; + execFileSync('git', ['init', '-b', 'main', root]); + execFileSync('git', ['-C', root, 'config', 'user.email', 'torch-test@example.invalid']); + execFileSync('git', ['-C', root, 'config', 'user.name', 'TORCH Test']); + writeFileSync(join(root, 'app.js'), 'export const app = true;\n'); + execFileSync('git', ['-C', root, 'add', '.']); + execFileSync('git', ['-C', root, 'commit', '-m', 'fixture']); + let repository = inspectRepository(root); + const proposal = proposeDomains({ repository, analysis: analyzeRepository(repository) }); + proposal.paths = { worktree_parent: worktreeParent }; + proposal.review = { + status: 'approved', reviewedAt: '2026-09-27T00:00:00Z', reviewedBy: 'fixture-owner', notes: [], + }; + installProject({ repository, proposal, env, projectId: 'evolution-fixture' }); + execFileSync('git', ['-C', root, 'add', '.torch']); + execFileSync('git', ['-C', root, 'commit', '-m', 'install torch']); + repository = inspectRepository(root); + return { root, env, repository, worktreeParent }; +} + +test('SCN-fleet-evolution: the manager proposes and owner activates a newly justified persistent domain', () => { + const context = fixture(); + const control = openControlPlane({ repositoryRoot: context.root, env: context.env }); + const evolution = new FleetEvolutionService({ + repositoryRoot: context.root, + controlPlane: control, + clock: () => new Date('2026-09-27T12:00:00Z'), + idFactory: () => 'CHANGE-1', + }); + const worker = control.listAgents().find((agent) => agent.areaId !== 'session-manager').areaId; + const request = { + domain: { + id: 'payments', title: 'Payment systems', kind: 'development', + scope: ['payment intent lifecycle and settlement verification'], + not_scope: ['checkout presentation'], owned_paths: ['src/payments/**'], + shared_paths: ['src/contracts/payment-schema.js'], neighbours: [worker], + required_checks: [], resources: [], runtime: 'codex', model: 'gpt-test', + }, + rationale: 'Repeated payment work has no coherent owner in the current Fleet.', + expectedBenefit: { + summary: 'Retain payment-domain context across recurring settlement work.', + recurringWork: 'Three queued milestones and ongoing incident ownership.', + contextLocality: 'Avoid reloading payment invariants into unrelated sessions.', + coordinationCost: 'One explicit API boundary with the existing core domain.', + }, + evidence: ['docs/product-spec.md:42', 'TASK-17', 'TASK-23'], + }; + assert.throws( + () => evolution.proposeDomain({ ...request, proposer: worker }), + (error) => error.code === 'FLEET_CHANGE_AUTHORITY_REQUIRED', + ); + const proposed = callTorchTool(control, 'torch_propose_domain', { + proposer: 'session-manager', domain: request.domain, rationale: request.rationale, + expected_benefit: { + summary: request.expectedBenefit.summary, + recurring_work: request.expectedBenefit.recurringWork, + context_locality: request.expectedBenefit.contextLocality, + coordination_cost: request.expectedBenefit.coordinationCost, + }, + evidence: request.evidence, + }, { actorId: 'session-manager', evolutionService: evolution }); + assert.equal(proposed.state, 'proposed'); + assert.equal(control.listAgents().some((agent) => agent.areaId === 'payments'), false); + assert.throws( + () => evolution.activate({ changeId: proposed.id, approvedBy: 'fixture-owner' }), + (error) => error.code === 'FLEET_CHANGE_AUTHORITY_REQUIRED', + ); + const approved = evolution.approve({ changeId: proposed.id, approvedBy: 'fixture-owner' }); + assert.equal(approved.state, 'approved'); + const observed = observeProject({ repositoryRoot: context.root, env: context.env }); + assert.equal(observed.fleetChanges[0].domainId, 'payments'); + assert.equal(observed.fleetChanges[0].state, 'approved'); + const plan = evolution.planActivation(proposed.id); + assert.equal(plan.canProceed, true, JSON.stringify(plan.blockers)); + assert.equal(plan.mutationPerformed, false); + + const activated = evolution.activate({ changeId: proposed.id, approvedBy: 'fixture-owner' }); + assert.equal(activated.state, 'active'); + assert.equal(activated.identity.areaId, 'payments'); + assert.equal(activated.identity.state, 'offline'); + assert.equal(activated.identity.model, 'gpt-test'); + assert.equal(existsSync(activated.worktree), true); + assert.equal(existsSync(join(activated.worktree, '.torch', 'prompts', 'payments.md')), true); + assert.equal(execFileSync('git', ['-C', activated.worktree, 'status', '--porcelain'], { encoding: 'utf8' }), ''); + assert.equal(execFileSync('git', ['-C', context.root, 'status', '--porcelain'], { encoding: 'utf8' }), ''); + const roster = JSON.parse(readFileSync(join(context.root, '.torch', 'roster.yaml'), 'utf8')); + assert.equal(roster.areas.find((area) => area.id === worker).neighbours.includes('payments'), true); + assert.equal(roster.areas.find((area) => area.id === 'payments').neighbours.includes(worker), true); + assert.match(execFileSync('git', ['-C', context.root, 'log', '-1', '--pretty=%s'], { encoding: 'utf8' }), /add payments domain/); + const adapter = createCodexAdapter({ executable: '/opt/codex/bin/codex' }); + const adapters = new Map([['codex', adapter]]); + const startPlan = planAreaUp({ + repositoryRoot: context.root, controlPlane: control, areaId: 'payments', adapters, fresh: true, + }); + assert.equal(startPlan.canProceed, true, JSON.stringify(startPlan.blockers)); + assert.deepEqual(startPlan.actions.map((action) => action.areaId), ['payments']); + const started = startFleet({ + plan: startPlan, controlPlane: control, adapters, + executor: () => ({ + status: 0, stdout: '{"type":"thread.started","thread_id":"payments-thread"}\n{"type":"turn.completed"}\n', + }), + }); + assert.equal(started.started[0].runtimeSessionId, 'payments-thread'); + assert.equal(control.identity('payments').state, 'idle'); + control.close(); + + const reopened = openControlPlane({ repositoryRoot: context.root, env: context.env }); + assert.equal(reopened.identity('payments').areaId, 'payments'); + const persisted = new FleetEvolutionService({ repositoryRoot: context.root, controlPlane: reopened }).get(proposed.id); + assert.equal(persisted.activationCommit, activated.activationCommit); + reopened.close(); +}); + +test('SCN-cli-fleet-evolution: public CLI separates manager proposal, owner approval, plan, and activation', () => { + const context = fixture(); + const inputPath = join(context.root, 'domain-change.json'); + writeFileSync(inputPath, `${JSON.stringify({ + domain: { + id: 'security', title: 'Security engineering', scope: ['threat modeling and security checks'], + not_scope: ['general feature implementation'], owned_paths: ['security/**'], + shared_paths: [], neighbours: [], required_checks: [], runtime: 'claude', + }, + rationale: 'Recurring threat-model and audit work lacks a durable owner.', + expectedBenefit: { + summary: 'Keep security assumptions focused and reviewable.', + recurringWork: 'Security review recurs at each release.', + contextLocality: 'Retain threat models instead of reloading them into feature sessions.', + coordinationCost: 'The Session Manager coordinates explicit review requests.', + }, + evidence: ['docs/security-model.md:1', 'release policy'], + }, null, 2)}\n`); + execFileSync('git', ['-C', context.root, 'add', 'domain-change.json']); + execFileSync('git', ['-C', context.root, 'commit', '-m', 'add domain proposal input']); + const run = (args) => spawnSync(process.execPath, [CLI, ...args], { + cwd: context.root, env: context.env, encoding: 'utf8', + }); + const proposedResult = run(['fleet', 'propose', '--from', 'session-manager', '--proposal', inputPath, '--json']); + assert.equal(proposedResult.status, 0, proposedResult.stderr || proposedResult.stdout); + const proposed = JSON.parse(proposedResult.stdout); + const refused = run(['fleet', 'approve', '--change', proposed.id, '--by', 'fixture-owner', '--json']); + assert.equal(refused.status, 2); + assert.equal(JSON.parse(refused.stdout).error, 'APPROVAL_REQUIRED'); + assert.equal(run(['fleet', 'approve', '--change', proposed.id, '--by', 'fixture-owner', '--yes', '--json']).status, 0); + const plan = run(['fleet', 'plan', '--change', proposed.id, '--json']); + assert.equal(plan.status, 0, plan.stderr || plan.stdout); + assert.equal(JSON.parse(plan.stdout).canProceed, true); + const activated = run(['fleet', 'activate', '--change', proposed.id, '--by', 'fixture-owner', '--yes', '--json']); + assert.equal(activated.status, 0, activated.stderr || activated.stdout); + assert.equal(JSON.parse(activated.stdout).identity.areaId, 'security'); + const startPlan = run(['fleet', 'start', '--change', proposed.id, '--fresh', '--dry-run', '--json']); + assert.equal(startPlan.status, 0, startPlan.stderr || startPlan.stdout); + assert.deepEqual(JSON.parse(startPlan.stdout).actions.map((action) => action.areaId), ['security']); + assert.equal(run(['agent', '--area', 'security', '--json']).status, 0); +}); diff --git a/test/fleet/product-surface.test.mjs b/test/fleet/product-surface.test.mjs index f56c672..eb2975a 100644 --- a/test/fleet/product-surface.test.mjs +++ b/test/fleet/product-surface.test.mjs @@ -31,6 +31,7 @@ test('SCN-product-site: public surface explains the portable fleet and separates assert.match(html, /Live gate open/); assert.match(html, /Context savings are a measured hypothesis/); assert.match(html, /torch\.mjs bootstrap/); + assert.match(html, /manager can propose another specialist[\s\S]*owner approval/i); assert.doesNotMatch(html, /nostr|relay coordination|task lock/i); assert.match(consoleHtml, /Local Fleet Console/); assert.match(consoleHtml, /noindex,nofollow/); @@ -53,6 +54,7 @@ test('SCN-console-readonly: HTTP console serves fixed assets and observes an ins assert.equal(observed.mutationPerformed, false); assert.equal(observed.agents.length, proposal.domains.length + 1); assert.equal(observed.contextLocality.measured, false); + assert.deepEqual(observed.fleetChanges, []); assert.equal(existsSync(databasePath), false); assert.equal(execFileSync('git', ['-C', root, 'status', '--porcelain'], { encoding: 'utf8' }), before);