diff --git a/README.md b/README.md index 0b1557f..d8fbefe 100644 --- a/README.md +++ b/README.md @@ -250,6 +250,24 @@ Local verification and convergence use `torch checks`, `torch resources`, and `torch integrate`. A project without a forge can preview and create its bare canonical store with `torch canonical plan` and `torch canonical create --yes`. +An equivalent Git remote can later become the optional canonical forge without +changing TORCH identity or coordination semantics: + +```bash +torch forge plan --remote origin --json +torch forge attach --remote origin --provider generic-git --yes --json +torch forge status --json +torch forge detach --dry-run --json +torch forge detach --yes --json +``` + +Attach requires the selected remote's main ref to equal local canonical main +exactly and never pushes or fetches implicitly. During a forge outage, status +and doctor expose pending synchronization while local identities, messages, +checks, backlog, worktrees, and policy remain operational. Detach works during +that outage and restores the prior local canonical configuration; it does not +delete a user-owned remote. + TORCH source checkouts can stage a self-host candidate without changing the active version. Candidate build runs the complete local acceptance, lint, and syntax gates before writing its receipt: diff --git a/package.json b/package.json index ec99560..d60a9ef 100644 --- a/package.json +++ b/package.json @@ -16,6 +16,7 @@ "./design/brief": "./src/design/brief.mjs", "./design/architect": "./src/design/architect.mjs", "./evolution": "./src/evolution/service.mjs", + "./forge": "./src/forge/service.mjs", "./control-plane": "./src/control-plane/service.mjs", "./kernel/analyze": "./src/kernel/analyze.mjs", "./kernel/config": "./src/kernel/config.mjs", @@ -66,6 +67,7 @@ "src/control-plane/", "src/design/", "src/evolution/", + "src/forge/", "src/kernel/", "src/integration/", "src/importers/", @@ -88,8 +90,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/convergence 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/convergence 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" + "format": "eslint src/adapters src/backlog src/canonical src/checks src/console src/convergence src/control-plane src/design src/evolution src/forge 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/convergence src/control-plane src/design src/evolution src/forge src/importers src/integration src/kernel src/mcp src/observability src/resources src/runtime src/schedules src/self-host src/telemetry src/cli.mjs bin/torch.mjs site/*.js test/fleet" }, "dependencies": { "@modelcontextprotocol/server": "^2.1.0", diff --git a/reports/test-audit/test-integrity-note-2026-09-27-portable-kernel.yaml b/reports/test-audit/test-integrity-note-2026-09-27-portable-kernel.yaml index 7ada218..9dc4d5a 100644 --- a/reports/test-audit/test-integrity-note-2026-09-27-portable-kernel.yaml +++ b/reports/test-audit/test-integrity-note-2026-09-27-portable-kernel.yaml @@ -142,6 +142,10 @@ test_integrity_note: given: an installed project with no forge or remote when: the owner creates a local canonical Git store and later makes and pushes another commit then: TORCH distinguishes one-disk from local-remote recoverability without claiming off-machine backup + - id: SCN-forge-migration + given: a healthy local-only Fleet and an equivalent optional Git remote + when: the owner attaches the forge, the remote becomes unavailable, and the owner detaches it + then: ref mismatch fails closed while identities, worktrees, backlog, messages, and local operation survive every mode - id: SCN-canonical-reversal given: a TORCH-owned local canonical remote and bare repository when: purge is planned before and after a remote-only commit becomes recoverable locally diff --git a/src/cli.mjs b/src/cli.mjs index 39b53e1..005b042 100644 --- a/src/cli.mjs +++ b/src/cli.mjs @@ -35,6 +35,9 @@ import { } from './design/architect.mjs'; import { FleetEvolutionService } from './evolution/service.mjs'; import { ConvergenceService } from './convergence/service.mjs'; +import { + attachForge, detachForge, forgeStatus, planForgeAttach, planForgeDetach, +} from './forge/service.mjs'; const HELP = `TORCH — portable agent fleet @@ -96,6 +99,10 @@ Usage: torch canonical plan [--json] torch canonical create --yes [--json] torch recoverability [--commit ] [--json] + torch forge plan --remote [--provider ] [--json] + torch forge attach --remote [--provider ] --yes [--json] + torch forge status [--json] + torch forge detach [--dry-run] --yes [--json] torch candidate plan --source [--version ] [--json] torch candidate build --source [--version ] --yes [--json] torch candidate status [--json] @@ -231,6 +238,7 @@ function runCandidateAcceptance(candidateRoot, spawn) { ['fleet-evolution', ['SCN-fleet-evolution', 'SCN-cli-fleet-evolution']], ['fleet-boundary-evolution', ['SCN-fleet-boundary-evolution']], ['cli-lifecycle-surface', ['SCN-cli-lifecycle-surface']], + ['forge-migration', ['SCN-forge-migration']], ['review-install-roster', ['SCN-cli-domain-review']], ['branches-worktrees', ['SCN-worktree-bootstrap']], ['runtime-identities', ['SCN-mixed-runtime']], @@ -1027,6 +1035,49 @@ export async function runCli(argv = process.argv.slice(2), { }), { json }); return 0; } + if (command === 'forge') { + const operation = argv[1] ?? 'status'; + if (operation === 'status') { + const status = forgeStatus({ repositoryRoot: repository.root }); + print(status, { json }); + return status.available === false ? 1 : 0; + } + if (operation === 'plan') { + const plan = planForgeAttach({ + repositoryRoot: repository.root, remote: optionValue(argv, '--remote'), + provider: optionValue(argv, '--provider') ?? 'generic-git', + }); + print(plan, { json }); + return plan.canProceed ? 0 : 1; + } + if (operation === 'attach') { + if (!argv.includes('--yes')) { + throw new TorchError('Forge attach changes tracked canonical-repository policy. Review forge plan, then use --yes.', { + code: 'APPROVAL_REQUIRED', + }); + } + print(attachForge({ + repositoryRoot: repository.root, remote: optionValue(argv, '--remote'), + provider: optionValue(argv, '--provider') ?? 'generic-git', + }), { json }); + return 0; + } + if (operation === 'detach') { + if (argv.includes('--dry-run')) { + const plan = planForgeDetach({ repositoryRoot: repository.root }); + print(plan, { json }); + return plan.canProceed ? 0 : 1; + } + if (!argv.includes('--yes')) { + throw new TorchError('Forge detach changes tracked canonical-repository policy. Review the current status, then use --yes.', { + code: 'APPROVAL_REQUIRED', + }); + } + print(detachForge({ repositoryRoot: repository.root }), { json }); + return 0; + } + throw new TorchError(`Unknown forge operation: ${operation}`, { code: 'UNKNOWN_COMMAND' }); + } if (command === 'uninstall') { const result = uninstallProject({ repository, diff --git a/src/forge/service.mjs b/src/forge/service.mjs new file mode 100644 index 0000000..4482f3b --- /dev/null +++ b/src/forge/service.mjs @@ -0,0 +1,161 @@ +import { spawnSync } from 'node:child_process'; +import { randomUUID } from 'node:crypto'; +import { renameSync, writeFileSync } from 'node:fs'; +import { join } from 'node:path'; +import { TorchError } from '../kernel/errors.mjs'; +import { fileHash } from '../kernel/files.mjs'; +import { loadProjectConfig } from '../kernel/config.mjs'; +import { readInstallManifest, writeInstallManifest } from '../kernel/install.mjs'; + +function text(value, name) { + if (typeof value !== 'string' || !value.trim()) { + throw new TorchError(`${name} must be a non-empty string`, { + code: 'FORGE_INPUT_INVALID', details: { field: name }, + }); + } + return value.trim(); +} + +function git(root, args, { timeout = 5_000 } = {}) { + const result = spawnSync('git', ['-C', root, ...args], { + encoding: 'utf8', stdio: ['ignore', 'pipe', 'pipe'], timeout, + }); + return { + status: result.status ?? 1, stdout: result.stdout?.trim() ?? '', stderr: result.stderr?.trim() ?? '', + error: result.error?.message ?? null, + }; +} + +function configPaths(repositoryRoot) { + return { + config: join(repositoryRoot, '.torch', 'torch.yaml'), + manifest: join(repositoryRoot, '.torch', 'install-manifest.json'), + }; +} + +function configurationOwned(repositoryRoot, manifest) { + const record = manifest.created.find((entry) => entry.path === '.torch/torch.yaml'); + return Boolean(record && fileHash(configPaths(repositoryRoot).config) === record.sha256); +} + +function remoteHead(repositoryRoot, remote, branch) { + const result = git(repositoryRoot, ['ls-remote', '--heads', remote, `refs/heads/${branch}`]); + if (result.status !== 0) return { available: false, commit: null, error: result.error ?? result.stderr }; + const commit = result.stdout.split(/\s+/)[0] || null; + return { available: true, commit, error: null }; +} + +function writeConfiguration(repositoryRoot, config, manifest) { + const paths = configPaths(repositoryRoot); + const temporary = `${paths.config}.tmp-${process.pid}-${randomUUID()}`; + writeFileSync(temporary, `${JSON.stringify(config, null, 2)}\n`, { + encoding: 'utf8', mode: 0o600, flag: 'wx', + }); + renameSync(temporary, paths.config); + const record = manifest.created.find((entry) => entry.path === '.torch/torch.yaml'); + if (!record) throw new TorchError('Installation manifest does not own project configuration', { code: 'MANIFEST_INVALID' }); + record.sha256 = fileHash(paths.config); + writeInstallManifest(repositoryRoot, manifest); +} + +export function forgeStatus({ repositoryRoot } = {}) { + const config = loadProjectConfig(repositoryRoot); + const provider = config.forge?.provider ?? 'none'; + if (provider === 'none') { + return { + mode: 'local-only', provider: 'none', remote: null, available: null, + synchronized: null, pendingSynchronization: false, localOperational: true, + mutationPerformed: false, + }; + } + const remote = config.forge.remote; + const localCommit = git(repositoryRoot, ['rev-parse', config.project.main_branch]).stdout; + const observed = remoteHead(repositoryRoot, remote, config.project.main_branch); + return { + mode: observed.available ? 'forge-backed' : 'forge-degraded', provider, remote, + available: observed.available, localCommit, remoteCommit: observed.commit, + synchronized: observed.available && observed.commit === localCommit, + pendingSynchronization: !observed.available || observed.commit !== localCommit, + error: observed.error, localOperational: true, mutationPerformed: false, + }; +} + +export function planForgeAttach({ repositoryRoot, remote, provider = 'generic-git' } = {}) { + const selectedRemote = text(remote, 'remote'); + const selectedProvider = text(provider, 'provider'); + if (selectedProvider === 'none') throw new TorchError('Forge provider cannot be none during attach', { code: 'FORGE_INPUT_INVALID' }); + const config = loadProjectConfig(repositoryRoot); + const manifest = readInstallManifest(repositoryRoot); + const blockers = []; + const remotes = git(repositoryRoot, ['remote']).stdout.split('\n').filter(Boolean); + if (!remotes.includes(selectedRemote)) blockers.push({ code: 'FORGE_REMOTE_MISSING', remote: selectedRemote }); + if (config.forge.provider !== 'none') blockers.push({ code: 'FORGE_ALREADY_ATTACHED', provider: config.forge.provider }); + if (!configurationOwned(repositoryRoot, manifest)) blockers.push({ code: 'FORGE_CONFIGURATION_MODIFIED' }); + const localCommit = git(repositoryRoot, ['rev-parse', config.project.main_branch]).stdout; + const observed = remotes.includes(selectedRemote) + ? remoteHead(repositoryRoot, selectedRemote, config.project.main_branch) + : { available: false, commit: null, error: 'remote missing' }; + if (!observed.available) blockers.push({ code: 'FORGE_UNAVAILABLE', remote: selectedRemote, error: observed.error }); + else if (!observed.commit) blockers.push({ code: 'FORGE_BRANCH_MISSING', remote: selectedRemote, branch: config.project.main_branch }); + else if (observed.commit !== localCommit) blockers.push({ + code: 'FORGE_REF_MISMATCH', remote: selectedRemote, branch: config.project.main_branch, + localCommit, remoteCommit: observed.commit, + }); + return { + action: 'forge-attach', provider: selectedProvider, remote: selectedRemote, + branch: config.project.main_branch, localCommit, remoteCommit: observed.commit, + previousCanonical: config.repository.canonical, + blockers, canProceed: blockers.length === 0, mutationPerformed: false, + }; +} + +export function attachForge(input = {}) { + const plan = planForgeAttach(input); + if (!plan.canProceed) throw new TorchError('Forge attach is blocked', { code: 'FORGE_ATTACH_BLOCKED', details: plan.blockers }); + const config = loadProjectConfig(input.repositoryRoot); + const manifest = readInstallManifest(input.repositoryRoot); + const attachedAt = new Date().toISOString(); + config.forge = { + provider: plan.provider, remote: plan.remote, attached_at: attachedAt, + previous_canonical: plan.previousCanonical, + }; + config.repository.canonical = { type: 'remote', remote: plan.remote }; + writeConfiguration(input.repositoryRoot, config, manifest); + return { ...plan, attachedAt, mutationPerformed: true }; +} + +export function planForgeDetach({ repositoryRoot } = {}) { + const config = loadProjectConfig(repositoryRoot); + const manifest = readInstallManifest(repositoryRoot); + const blockers = []; + if (config.forge.provider === 'none') blockers.push({ code: 'FORGE_NOT_ATTACHED' }); + if (!configurationOwned(repositoryRoot, manifest)) blockers.push({ code: 'FORGE_CONFIGURATION_MODIFIED' }); + if (config.repository.canonical.type !== 'remote' + || config.repository.canonical.remote !== config.forge.remote) { + blockers.push({ code: 'FORGE_CANONICAL_CHANGED', canonical: config.repository.canonical }); + } + const restoreCanonical = config.forge.previous_canonical ?? { type: 'unconfigured' }; + return { + action: 'forge-detach', provider: config.forge.provider, remote: config.forge.remote ?? null, + restoreCanonical, blockers, canProceed: blockers.length === 0, mutationPerformed: false, + }; +} + +export function detachForge({ repositoryRoot } = {}) { + const plan = planForgeDetach({ repositoryRoot }); + if (!plan.canProceed) throw new TorchError('Forge detach is blocked', { code: 'FORGE_DETACH_BLOCKED', details: plan.blockers }); + const config = loadProjectConfig(repositoryRoot); + const manifest = readInstallManifest(repositoryRoot); + const detachedAt = new Date().toISOString(); + config.repository.canonical = plan.restoreCanonical; + config.forge = { + provider: 'none', last_provider: plan.provider, last_remote: plan.remote, + detached_at: detachedAt, + }; + writeConfiguration(repositoryRoot, config, manifest); + return { + ...plan, detachedAt, localOperational: true, + preserved: ['identities', 'worktrees', 'backlog', 'decisions', 'messages', 'local state'], + mutationPerformed: true, + }; +} diff --git a/src/kernel/doctor.mjs b/src/kernel/doctor.mjs index 83dd837..fb87c79 100644 --- a/src/kernel/doctor.mjs +++ b/src/kernel/doctor.mjs @@ -5,6 +5,7 @@ import { fileHash } from './files.mjs'; import { projectStatePath } from './paths.mjs'; import { inspectManagedWorktree } from './worktree-state.mjs'; import { validateProjectConfig } from './config.mjs'; +import { forgeStatus } from '../forge/service.mjs'; function parseJsonYaml(path) { return JSON.parse(readFileSync(path, 'utf8')); @@ -168,6 +169,21 @@ export function diagnoseProject({ repository, env = process.env }) { if (repository.dirtyEntries.length) { findings.push({ severity: 'info', code: 'WORKTREE_DIRTY', count: repository.dirtyEntries.length }); } + if (config?.forge?.provider && config.forge.provider !== 'none') { + try { + const forge = forgeStatus({ repositoryRoot: repository.root }); + if (!forge.available) findings.push({ + severity: 'warning', code: 'FORGE_UNAVAILABLE', remote: forge.remote, + pendingSynchronization: true, localOperational: true, message: forge.error, + }); + else if (!forge.synchronized) findings.push({ + severity: 'info', code: 'FORGE_SYNCHRONIZATION_PENDING', remote: forge.remote, + localCommit: forge.localCommit, remoteCommit: forge.remoteCommit, + }); + } catch (error) { + findings.push({ severity: 'error', code: 'FORGE_STATUS_INVALID', message: error.message }); + } + } return { status: findings.some((finding) => finding.severity === 'error') ? 'broken' : 'ok', diff --git a/src/self-host/service.mjs b/src/self-host/service.mjs index 8d10fa7..29515a3 100644 --- a/src/self-host/service.mjs +++ b/src/self-host/service.mjs @@ -15,6 +15,7 @@ export const CANDIDATE_ACCEPTANCE_SCENARIOS = Object.freeze([ 'fleet-evolution', 'fleet-boundary-evolution', 'cli-lifecycle-surface', + 'forge-migration', 'review-install-roster', 'branches-worktrees', 'runtime-identities', diff --git a/test/fleet/cli-scenarios.test.mjs b/test/fleet/cli-scenarios.test.mjs index 53796f5..203cd3e 100644 --- a/test/fleet/cli-scenarios.test.mjs +++ b/test/fleet/cli-scenarios.test.mjs @@ -38,6 +38,7 @@ function releaseCandidate(root, version) { 'SCN-fleet-evolution', 'SCN-cli-fleet-evolution', 'SCN-fleet-boundary-evolution', 'SCN-cli-lifecycle-surface', + 'SCN-forge-migration', 'SCN-durable-message', 'SCN-ownership-handoff', 'SCN-worktree-purge-safety', 'SCN-backlog-lifecycle', 'SCN-native-integration', 'SCN-resource-fifo', 'SCN-fleet-fresh-resume', 'SCN-install-doctor-purge', diff --git a/test/fleet/forge-migration.test.mjs b/test/fleet/forge-migration.test.mjs new file mode 100644 index 0000000..ba5c849 --- /dev/null +++ b/test/fleet/forge-migration.test.mjs @@ -0,0 +1,100 @@ +import assert from 'node:assert/strict'; +import { execFileSync, spawnSync } from 'node:child_process'; +import { mkdtempSync, readFileSync, renameSync, writeFileSync } from 'node:fs'; +import { tmpdir } from 'node:os'; +import { join } from 'node:path'; +import test from 'node:test'; +import { createLocalCanonical } from '../../src/canonical/local.mjs'; +import { openControlPlane } from '../../src/control-plane/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 { createWorktrees } from '../../src/kernel/worktrees.mjs'; + +const CLI = new URL('../../bin/torch.mjs', import.meta.url).pathname; + +function run(root, env, args) { + return spawnSync(process.execPath, [CLI, ...args], { cwd: root, env, encoding: 'utf8' }); +} + +test('SCN-forge-migration: attach, outage, and detach preserve the local Fleet', () => { + const root = mkdtempSync(join(tmpdir(), 'torch-forge-migration-')); + const stateHome = mkdtempSync(join(tmpdir(), 'torch-forge-state-')); + const worktreeParent = mkdtempSync(join(tmpdir(), 'torch-forge-worktrees-')); + const forgePath = mkdtempSync(join(tmpdir(), 'torch-forge-remote-')); + const offlinePath = `${forgePath}-offline`; + const env = { ...process.env, XDG_DATA_HOME: stateHome }; + execFileSync('git', ['init', '-b', 'main', root]); + execFileSync('git', ['-C', root, 'config', 'user.email', 'torch-test@example.invalid']); + execFileSync('git', ['-C', root, 'config', 'user.name', 'TORCH Test']); + writeFileSync(join(root, 'app.js'), 'export const app = true;\n'); + execFileSync('git', ['-C', root, 'add', '.']); + execFileSync('git', ['-C', root, 'commit', '-m', 'fixture']); + let repository = inspectRepository(root); + const proposal = proposeDomains({ repository, analysis: analyzeRepository(repository) }); + proposal.paths = { worktree_parent: worktreeParent }; + proposal.review = { + status: 'approved', reviewedAt: '2026-09-27T00:00:00Z', reviewedBy: 'fixture-owner', notes: [], + }; + installProject({ repository, proposal, env, projectId: 'forge-migration' }); + execFileSync('git', ['-C', root, 'add', '.torch']); + execFileSync('git', ['-C', root, 'commit', '-m', 'install torch']); + repository = inspectRepository(root); + createWorktrees({ repository, parentOverride: worktreeParent }); + execFileSync('git', ['-C', root, 'add', '.torch']); + execFileSync('git', ['-C', root, 'commit', '-m', 'record worktrees']); + const localCanonical = createLocalCanonical({ repositoryRoot: root }); + execFileSync('git', ['-C', root, 'add', '.torch']); + execFileSync('git', ['-C', root, 'commit', '-m', 'record local canonical']); + execFileSync('git', ['-C', root, 'push', 'torch-canonical', 'main']); + + execFileSync('git', ['init', '--bare', forgePath]); + execFileSync('git', ['-C', root, 'remote', 'add', 'origin', forgePath]); + execFileSync('git', ['-C', root, 'push', 'origin', 'main']); + const manifestBefore = JSON.parse(readFileSync(join(root, '.torch', 'install-manifest.json'), 'utf8')); + const worktreesBefore = manifestBefore.external.filter((entry) => entry.type === 'worktree'); + const control = openControlPlane({ repositoryRoot: root, env }); + const worker = proposal.domains[0].id; + const message = control.sendMessage({ sender: 'session-manager', recipient: worker, body: 'Survive forge migration.' }); + const agentsBefore = control.listAgents().map((agent) => agent.areaId); + control.close(); + + const planned = run(root, env, ['forge', 'plan', '--remote', 'origin', '--json']); + assert.equal(planned.status, 0, planned.stderr || planned.stdout); + assert.equal(JSON.parse(planned.stdout).previousCanonical.path, localCanonical.path); + assert.equal(JSON.parse(run(root, env, ['forge', 'attach', '--remote', 'origin', '--json']).stdout).error, 'APPROVAL_REQUIRED'); + const attached = run(root, env, ['forge', 'attach', '--remote', 'origin', '--yes', '--json']); + assert.equal(attached.status, 0, attached.stderr || attached.stdout); + const attachedConfig = JSON.parse(readFileSync(join(root, '.torch', 'torch.yaml'), 'utf8')); + assert.equal(attachedConfig.forge.provider, 'generic-git'); + assert.deepEqual(attachedConfig.repository.canonical, { type: 'remote', remote: 'origin' }); + assert.equal(JSON.parse(run(root, env, ['forge', 'status', '--json']).stdout).synchronized, true); + + renameSync(forgePath, offlinePath); + const degraded = run(root, env, ['forge', 'status', '--json']); + assert.equal(degraded.status, 1); + assert.equal(JSON.parse(degraded.stdout).mode, 'forge-degraded'); + assert.equal(JSON.parse(degraded.stdout).localOperational, true); + const diagnosis = run(root, env, ['doctor', '--json']); + assert.equal(diagnosis.status, 0, diagnosis.stderr || diagnosis.stdout); + assert.equal(JSON.parse(diagnosis.stdout).findings.some((finding) => + finding.code === 'FORGE_UNAVAILABLE' && finding.localOperational === true), true); + + const detachPlan = run(root, env, ['forge', 'detach', '--dry-run', '--json']); + assert.equal(detachPlan.status, 0, detachPlan.stderr || detachPlan.stdout); + const detached = run(root, env, ['forge', 'detach', '--yes', '--json']); + assert.equal(detached.status, 0, detached.stderr || detached.stdout); + const detachedConfig = JSON.parse(readFileSync(join(root, '.torch', 'torch.yaml'), 'utf8')); + assert.equal(detachedConfig.forge.provider, 'none'); + assert.deepEqual(detachedConfig.repository.canonical, { + type: 'local', remote: 'torch-canonical', path: localCanonical.path, + }); + const manifestAfter = JSON.parse(readFileSync(join(root, '.torch', 'install-manifest.json'), 'utf8')); + assert.deepEqual(manifestAfter.external.filter((entry) => entry.type === 'worktree'), worktreesBefore); + const reopened = openControlPlane({ repositoryRoot: root, env }); + assert.deepEqual(reopened.listAgents().map((agent) => agent.areaId), agentsBefore); + assert.equal(reopened.readMessages({ recipient: worker }).some((entry) => entry.id === message.id), true); + reopened.close(); + renameSync(offlinePath, forgePath); +});