mirror of
https://github.com/PR0M3TH3AN/TORCH.git
synced 2026-10-30 09:21:52 +00:00
Add owner-gated fleet evolution
This commit is contained in:
1 parent
acb4dc401d
commit
d1cd79cccb
22 files changed
+888
-11
No files matched your search
@@ -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.
|
||||
|
||||
@@ -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
@@ -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
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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
@@ -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
@@ -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 });
|
||||
|
||||
@@ -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}`, {
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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
@@ -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.',
|
||||
|
||||
@@ -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');
|
||||
|
||||
@@ -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}`),
|
||||
});
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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 });
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
@@ -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);
|
||||
|
||||
|
||||
Reference in new issue
Block a user