Add owner-gated fleet evolution

This commit is contained in:
thePR0M3TH3AN committed 2026-09-27 17:39:12 -04:00
1 parent acb4dc401d
commit d1cd79cccb
22 files changed
+888 -11

No files matched your search

+25
View File
@@ -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 <change-id> --by <owner> --yes --json
torch fleet plan --change <change-id> --json
torch fleet activate --change <change-id> --by <owner> --yes --json
torch fleet start --change <change-id> --fresh --dry-run --json
torch fleet start --change <change-id> --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.
+16
View File
@@ -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.
+4 -2
View File
@@ -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",
@@ -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
+1
View File
@@ -26,6 +26,7 @@
<section class="console-grid">
<article class="console-panel agents-panel"><div class="panel-heading"><h1>Fleet</h1><span id="agent-count">0 identities</span></div><div id="agent-list" class="agent-list"></div></article>
<article class="console-panel"><div class="panel-heading"><h2>Backlog</h2><span id="task-count">0 active</span></div><div id="task-list" class="console-list"></div></article>
<article class="console-panel"><div class="panel-heading"><h2>Fleet changes</h2><span id="change-count">0 pending</span></div><div id="change-list" class="console-list"></div></article>
<article class="console-panel"><div class="panel-heading"><h2>Integration</h2><span>Exact commits</span></div><div id="integration-list" class="console-list"></div></article>
<article class="console-panel"><div class="panel-heading"><h2>Resources</h2><span>FIFO leases</span></div><div id="resource-list" class="console-list"></div></article>
<article class="console-panel messages-panel"><div class="panel-heading"><h2>Messages</h2><span id="message-count">0 queued</span></div><div id="message-list" class="console-list"></div></article>
+7
View File
@@ -30,6 +30,13 @@ function render(snapshot) {
<div class="list-row"><div><strong>${safe(task.title ?? task.id)}</strong><small>${safe(task.owner ?? 'unassigned')}</small></div><span>${safe(task.state)}</span></div>
`).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) => `
<div class="list-row"><div><strong>${safe(change.title ?? change.domainId)}</strong><small>${safe(change.expectedBenefit ?? 'No expected benefit recorded')}</small></div><span>${safe(change.state)}</span></div>
`).join('') : empty('No Fleet changes proposed. The current roster remains in force.');
const integrations = snapshot.integration ?? [];
$('#integration-list').innerHTML = integrations.length ? integrations.map((item) => `
<div class="list-row"><div><strong>${safe(item.sourceArea)}</strong><small>${safe(item.sourceCommit?.slice(0, 9))}</small></div><span>${safe(item.state)}</span></div>
+1 -1
View File
@@ -71,7 +71,7 @@
<article>
<span class="blueprint-index">Operate</span>
<h3>Persistent specialists</h3>
<p>Stable identities survive runtime sessions. Durable messages and project state outlive model context.</p>
<p>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.</p>
</article>
</div>
</section>
+78 -1
View File
@@ -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 <schedule>] [--json]
torch schedules plan --id <schedule> --actor <id|owner> [--json]
torch schedules run --id <schedule> --actor <id|owner> [--yes] [--json]
torch fleet changes [--state <state>] [--json]
torch fleet get --change <id> [--json]
torch fleet propose --from session-manager --proposal <path> [--json]
torch fleet approve --change <id> --by <owner> --yes [--json]
torch fleet plan --change <id> [--json]
torch fleet activate --change <id> --by <owner> --yes [--json]
torch fleet start --change <id> [--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 });
+18
View File
@@ -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}`, {
+1
View File
@@ -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',
+411
View File
@@ -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);
}
}
+6 -1
View File
@@ -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`);
+11 -4
View File
@@ -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.',
+2 -2
View File
@@ -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');
+3
View File
@@ -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}`),
});
+48
View File
@@ -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 {
+8
View File
@@ -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
+57
View File
@@ -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 });
+1
View File
@@ -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',
+1
View File
@@ -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',
+176
View File
@@ -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', '[email protected]']);
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);
});
+2
View File
@@ -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);