diff --git a/.torch/prompts/COMMON.md b/.torch/prompts/COMMON.md index 886536b..3fb0de4 100644 --- a/.torch/prompts/COMMON.md +++ b/.torch/prompts/COMMON.md @@ -6,7 +6,10 @@ Repository state outranks conversation memory. Query ownership before crossing a This initial launch is a bounded installation and communications smoke test. Use the installed `torch` launcher and identity-bound TORCH MCP tools. Query -identity, ownership, inbox and next backlog item. Read current canonical +identity, ownership and inbox. Specialists query their next backlog item; +the Session Manager inspects fleet backlog rather than claiming specialist work. +Run `torch brief --area --json` at startup and before each new item. +Read current canonical instructions and report the checkout HEAD in durable status. Do not implement code, create or assign development tasks, enable timers, diff --git a/README.md b/README.md index 7c580df..ae8641f 100644 --- a/README.md +++ b/README.md @@ -292,14 +292,28 @@ Review the evidence in `fleet-proposal.json`, edit its `review.status` to node bin/torch.mjs install --proposal fleet-proposal.json --runtime codex --dry-run --json node bin/torch.mjs install --proposal fleet-proposal.json --runtime codex --yes --json node bin/torch.mjs doctor --json -node bin/torch.mjs worktrees --dry-run --json -node bin/torch.mjs worktrees --yes --json +node bin/torch.mjs setup --dry-run --json +node bin/torch.mjs setup --yes --json node bin/torch.mjs up --fresh --dry-run --json ``` Starting or stopping configured runtime sessions is always a separate explicit step: +`setup` commits only installation-owned configuration, provisions worktrees, +and checkpoints their registration. An existing staging area is a blocker; +unrelated files are never staged. Alternatively, append `--setup` to the +approved install command. Neither path launches agents. + +Normal uninstall retains configuration and history. Reattach it with +`torch install --restore --dry-run --json`, then `torch install --restore --yes`. +Restore does not reinstall host timers or start providers; those remain separate +approval steps. A purge is a different, destructive operation. + +From a registered agent worktree, `torch brief --area ` reads the latest +canonical project instructions. Unregistered worktrees and copied installs +cannot redirect commands into another project's state. + ```bash node bin/torch.mjs up --fresh --yes --json node bin/torch.mjs up --only backend,qa --dry-run --json diff --git a/docs/PORTABLE_AGENT_FLEET_SPEC.md b/docs/PORTABLE_AGENT_FLEET_SPEC.md index e8d325d..1279547 100644 --- a/docs/PORTABLE_AGENT_FLEET_SPEC.md +++ b/docs/PORTABLE_AGENT_FLEET_SPEC.md @@ -451,6 +451,18 @@ or installed persistent launchers. Historical messages and receipts therefore do not make reversal permanently impossible, while active or unowned state still blocks deletion. +Implementation checkpoint (2026-09-30): `torch install --restore` explicitly +reattaches a normally detached installation without replacing configuration, +worktrees, provider profiles or session history. It never launches a provider +or recreates host timers. `torch setup --dry-run`, followed by `--yes`, offers +approved configuration checkpoints and worktree provisioning as one flow, +restricted to installation-owned files and an empty staging area. Install may +opt into the same flow with `--setup`. Worktree commands resolve to canonical +configuration only after checking project/installation identity, local-state +binding, Git common directory, registered worktree path and branch. Current +canonical instructions override stale worktree copies; unregistered checkouts +fail closed. + Ordinary CLI uninstall is also review-first and explicitly confirmed. It uses the same safe Fleet wind-down contract as detach, removes only manifest-owned persistent schedule launchers after verifying their content, and preserves the diff --git a/docs/TEST_INTEGRITY.md b/docs/TEST_INTEGRITY.md index f10b4f2..e6e5832 100644 --- a/docs/TEST_INTEGRITY.md +++ b/docs/TEST_INTEGRITY.md @@ -2237,3 +2237,34 @@ test_integrity_note: did_relax_any_assertion: false if_true_explain_spec_basis: "" ``` +# Setup and canonical worktree briefs (2026-09-30) + +```yaml +test_integrity_note: + change_type: new_tests + scenarios: + - id: SCN-guided-setup + given: "An approved installed project with uncommitted TORCH files" + when: "Owner reviews and authorizes setup" + then: "Only owned files are committed; registered worktrees exist; no providers start; staged user work blocks setup" + - id: SCN-worktree-canonical-brief + given: "A registered worktree has older instructions" + when: "Its installation root is resolved and a brief requested" + then: "Canonical instructions appear; unregistered worktrees, copied installs and stale installation IDs fail closed" + - id: SCN-install-restore + given: "A normally detached project retains configuration and worktree commits" + when: "Owner explicitly restores the install" + then: "Binding is reattached without changing profiles, worktree history or launching providers" + observable_outcomes: + - "Git commits, clean canonical checkout, worktree registrations, brief text and local detach metadata" + determinism_controls: + - "Isolated temporary Git repositories and XDG state; no provider or network calls" + anti_cheat_rationale: + prevents: + - "Hard-coded success without Git worktrees or commits" + - "Accepting copied or unregistered installation state" + - "Staging unrelated owner work" + relaxation: + did_relax_any_assertion: false + if_true_explain_spec_basis: "" +``` diff --git a/docs/TODO.md b/docs/TODO.md index 7ec4258..a357380 100644 --- a/docs/TODO.md +++ b/docs/TODO.md @@ -37,7 +37,7 @@ updates, retained previous installs, offline previews and fail-closed errors. Four deterministic scenarios added; live download qualification recorded in the first-run audit, not implied by mocked installer tests. -- [ ] Fix `torch brief` invoked from a managed worktree: both live sessions hit +- [x] Fix `torch brief` invoked from a managed worktree: both live sessions hit `LOCAL_STATE_METADATA_MISMATCH`. Resolve canonical project binding with checked worktree membership, never weaken root/installation identity checks. - [ ] Surface Codex JSON stdout failure events: first Sol launch hid the actual @@ -45,7 +45,7 @@ - [ ] Add exact executable/version/model/auth preflight to owner Console and setup flow. PATH selected CLI 0.158.0 while the current session used 0.159.2; the older child rejected Sol 6.1 and required manual investigation/retry. -- [ ] Make configuration checkpoint/worktree/manifest handoffs explicit or +- [x] Make configuration checkpoint/worktree/manifest handoffs explicit or guided: generated tracked files and provisioning manifest updates require deliberate commits; a healthy doctor does not prove startup readiness. - [ ] Isolate irrelevant inherited global MCP servers without removing user diff --git a/package-lock.json b/package-lock.json index b4afe31..13acd7f 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,12 +1,12 @@ { "name": "torch-agent-fleet", - "version": "0.1.0-alpha.1", + "version": "0.1.0-alpha.2", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "torch-agent-fleet", - "version": "0.1.0-alpha.1", + "version": "0.1.0-alpha.2", "bundleDependencies": [ "@modelcontextprotocol/server", "typebox", diff --git a/package.json b/package.json index f2b1ad6..b3352f1 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "torch-agent-fleet", - "version": "0.1.0-alpha.1", + "version": "0.1.0-alpha.2", "type": "module", "description": "TORCH — a portable, provider-independent AI development fleet for Git repositories", "main": "src/cli.mjs", diff --git a/reports/audit/setup-reinstall-2026-09-30.md b/reports/audit/setup-reinstall-2026-09-30.md new file mode 100644 index 0000000..eecd18a --- /dev/null +++ b/reports/audit/setup-reinstall-2026-09-30.md @@ -0,0 +1,29 @@ +# Guided setup and reinstall verification + +## Changes + +- `torch brief` and normal installed-project CLI commands resolve registered + worktrees to the canonical project. The resolver checks project and installation + IDs, XDG state binding, Git common directory, worktree membership and branch. + Current canonical instructions win over an old worktree copy. +- `torch setup --dry-run`, then `--yes`, checkpoints owned configuration, + provisions worktrees and checkpoints registration. Existing staged work is + rejected; unrelated files are not staged. Install can opt in with `--setup`. +- `torch install --restore` reattaches the retained state from normal uninstall. + It does not overwrite profiles, erase history, recreate timers or start agents. +- The self-host smoke brief distinguishes manager backlog inspection from + specialist next-task lookup and explicitly requires current briefs. + +## Evidence before installation + +All 39 source acceptance groups, lint and syntax: PASS. +Receipt: `/tmp/torch-alpha2-source-acceptance.json`. +New scenarios: guided setup, canonical worktree briefs, safe detach/restore. +The actual Owner Console worktree returned its current brief successfully +through the patched source CLI, including its preserved offline runtime ID. + +## Installation trial + +Pending accepted alpha.2 build and normal uninstall/restore verification. +This is a retained-state reinstall, not a purge or deletion of the tool's +historical versions. Provider launches remain deliberately disabled. diff --git a/src/cli.mjs b/src/cli.mjs index 361ea7d..8c8cc6b 100644 --- a/src/cli.mjs +++ b/src/cli.mjs @@ -24,6 +24,8 @@ import { diagnoseProject } from './kernel/doctor.mjs'; import { loadProjectConfig, validateProjectConfig } from './kernel/config.mjs'; import { asErrorRecord, TorchError } from './kernel/errors.mjs'; import { inspectRepository } from './kernel/git.mjs'; +import { resolveInstalledProjectRoot } from './kernel/project-root.mjs'; +import { setupProject, restoreProject } from './kernel/setup.mjs'; import { inspectSpecifications } from './kernel/specifications.mjs'; import { installProject, planInstall, uninstallProject } from './kernel/install.mjs'; import { IntegrationService } from './integration/service.mjs'; @@ -72,6 +74,8 @@ Usage: torch domains [--repo ] [--spec ] [--output ] [--json] torch install --proposal [--runtime ] [--default-runtime ] [--update-runtimes] [--dry-run] [--yes] [--json] torch worktrees [--parent ] [--dry-run] [--yes] [--json] + torch install --restore [--dry-run] [--yes] [--json] + torch setup [--parent ] [--dry-run] [--yes] [--json] torch up [--fresh] [--only ] [--dry-run] [--yes] [--json] torch down [--dry-run] [--yes] [--json] torch capture [--json] @@ -414,7 +418,7 @@ export const CANDIDATE_ACCEPTANCE_EVIDENCE = Object.freeze({ 'forge-migration': ['SCN-forge-migration'], 'delivery-lifecycle': ['SCN-delivery-lifecycle', 'SCN-delivery-attempts', 'SCN-delivery-retry-boundaries', 'SCN-delivery-succeeded-recovery', 'SCN-owner-digest', 'SCN-owner-digest-schedule', 'SCN-owner-digest-receipts'], - 'review-install-roster': ['SCN-cli-domain-review', 'SCN-cli-install-provider-default', 'SCN-install-provider-inheritance'], + 'review-install-roster': ['SCN-cli-domain-review', 'SCN-cli-install-provider-default', 'SCN-install-provider-inheritance', 'SCN-guided-setup'], 'branches-worktrees': ['SCN-worktree-bootstrap'], 'runtime-identities': ['SCN-mixed-runtime'], 'runtime-profiles': ['SCN-runtime-profiles', 'SCN-runtime-cost-ceiling', 'SCN-runtime-profile-cli'], @@ -471,7 +475,7 @@ export const CANDIDATE_ACCEPTANCE_EVIDENCE = Object.freeze({ 'capture-stop-resume': [ 'SCN-fleet-fresh-resume', 'SCN-hierarchy-aware-start-stop', 'SCN-hierarchy-startup-cycle', ], - 'detach-uninstall': ['SCN-install-doctor-purge'], + 'detach-uninstall': ['SCN-install-doctor-purge', 'SCN-install-restore', 'SCN-worktree-canonical-brief'], 'combatrig-compatibility': ['SCN-combatrig-import', 'SCN-cli-combatrig-import'], 'product-surface': ['SCN-product-site', 'SCN-dashboard-demo', 'SCN-console-readonly', 'SCN-console-lifecycle-blockers', 'SCN-console-owner-digest', 'SCN-demo-owner-digest', 'SCN-console-check-evidence', 'SCN-console-task-activity', 'SCN-console-operation-outcomes', 'SCN-console-live-refresh'], @@ -796,7 +800,9 @@ export async function runCli(argv = process.argv.slice(2), { } const designCommand = ['init', 'analyze', 'domains', 'design', 'bootstrap'].includes(command); const repositoryRoot = designCommand ? resolve(cwd, optionValue(argv, '--repo') ?? '.') : cwd; - const repository = inspectRepository(repositoryRoot); + const inspectedRepository = inspectRepository(repositoryRoot); + const repository = designCommand ? inspectedRepository + : inspectRepository(resolveInstalledProjectRoot(inspectedRepository.root, env)); if (command === 'profile') { const operation = argv[1] ?? 'show'; const areaId = optionValue(argv, '--area'); @@ -997,6 +1003,11 @@ export async function runCli(argv = process.argv.slice(2), { return 0; } if (command === 'install') { + if (argv.includes('--restore')) { + const result = restoreProject({ repository, env, dryRun: argv.includes('--dry-run'), authorized: argv.includes('--yes') }); + print(result, { json }); + return 0; + } const proposalPath = optionValue(argv, '--proposal'); const runtimeOption = optionValue(argv, '--runtime'); const defaultRuntime = optionValue(argv, '--default-runtime'); @@ -1038,7 +1049,11 @@ export async function runCli(argv = process.argv.slice(2), { } persistProfileConfig(repository.root, config); } - print(installed, { json }); + print(argv.includes('--setup') ? { ...installed, setup: setupProject({ repository: inspectRepository(repository.root), env, authorized: true, parentOverride: optionValue(argv, '--parent') }) } : installed, { json }); + return 0; + } + if (command === 'setup') { + print(setupProject({ repository, env, authorized: argv.includes('--yes'), dryRun: argv.includes('--dry-run'), parentOverride: optionValue(argv, '--parent') }), { json }); return 0; } if (command === 'doctor') { diff --git a/src/kernel/project-root.mjs b/src/kernel/project-root.mjs new file mode 100644 index 0000000..eeea65b --- /dev/null +++ b/src/kernel/project-root.mjs @@ -0,0 +1,38 @@ +import { execFileSync } from 'node:child_process'; +import { existsSync, readFileSync, realpathSync } from 'node:fs'; +import { join, resolve } from 'node:path'; +import { projectStatePath } from './paths.mjs'; +import { TorchError } from './errors.mjs'; + +const read = path => JSON.parse(readFileSync(path, 'utf8')); +const git = (root, args) => execFileSync('git', ['-C', root, ...args], { encoding: 'utf8', stdio: ['ignore', 'pipe', 'pipe'] }).trim(); + +// A worktree's copied configuration is not authoritative. Only registered, +// same-installation worktrees may resolve to the current canonical definition. +export function resolveInstalledProjectRoot(root, env = process.env) { + root = realpathSync(root); + const path = join(root, '.torch', 'install-manifest.json'); + if (!existsSync(path)) return root; + const manifest = read(path); + const stateRoot = projectStatePath(manifest.projectId, env); + const local = manifest.external?.find(entry => entry.type === 'local-state'); + if (!local || resolve(local.path) !== resolve(stateRoot)) { + throw new TorchError('Installation state path does not match this project', { code: 'LOCAL_STATE_PATH_MISMATCH' }); + } + const metadata = read(join(stateRoot, 'project.json')); + if (metadata.projectId !== manifest.projectId) { + throw new TorchError('Installation metadata belongs to another project', { code: 'LOCAL_STATE_METADATA_MISMATCH' }); + } + const canonical = realpathSync(metadata.root); + if (canonical === root) return root; + const current = read(join(canonical, '.torch', 'install-manifest.json')); + const registered = current.external?.find(entry => entry.type === 'worktree' && existsSync(entry.path) && realpathSync(entry.path) === root); + const common = directory => realpathSync(resolve(directory, git(directory, ['rev-parse', '--git-common-dir']))); + const members = git(canonical, ['worktree', 'list', '--porcelain']).split('\n').filter(line => line.startsWith('worktree ')).map(line => realpathSync(line.slice(9))); + if (current.projectId !== manifest.projectId || current.installationId !== manifest.installationId + || !registered || common(root) !== common(canonical) || !members.includes(root) + || git(root, ['branch', '--show-current']) !== registered.branch) { + throw new TorchError('This checkout is not a registered worktree of the installation', { code: 'UNREGISTERED_PROJECT_WORKTREE' }); + } + return canonical; +} diff --git a/src/kernel/setup.mjs b/src/kernel/setup.mjs new file mode 100644 index 0000000..b926a56 --- /dev/null +++ b/src/kernel/setup.mjs @@ -0,0 +1,55 @@ +import { execFileSync } from 'node:child_process'; +import { readFileSync, renameSync, writeFileSync } from 'node:fs'; +import { join } from 'node:path'; +import { inspectRepository } from './git.mjs'; +import { readInstallManifest } from './install.mjs'; +import { projectStatePath } from './paths.mjs'; +import { resolveInstalledProjectRoot } from './project-root.mjs'; +import { createWorktrees, planWorktrees } from './worktrees.mjs'; +import { TorchError } from './errors.mjs'; + +const git = (root, args) => execFileSync('git', ['-C', root, ...args], { encoding: 'utf8', stdio: ['ignore', 'pipe', 'pipe'] }).trim(); + +export function setupProject({ repository, env = process.env, authorized = false, dryRun = false, parentOverride }) { + const root = resolveInstalledProjectRoot(repository.root, env); + repository = inspectRepository(root); + const manifest = readInstallManifest(root); + const owned = [...new Set([...manifest.created.map(record => record.path), '.torch/install-manifest.json'])]; + const changed = owned.filter(path => git(root, ['status', '--porcelain=v1', '--', path])); + const staged = git(root, ['diff', '--cached', '--name-only']); + if (staged) throw new TorchError('Setup requires an empty staging area; preserve and finish your existing staged work first', { code: 'SETUP_STAGED_WORK', details: staged }); + const unrelated = repository.dirtyEntries.filter(entry => !owned.includes(entry.slice(3))); + const worktrees = planWorktrees({ repository: { ...repository, dirtyEntries: unrelated }, parentOverride }); + const plan = { action: 'setup', projectId: manifest.projectId, checkpointPaths: changed, worktrees, canProceed: worktrees.canProceed, sessionsStarted: false, mutationPerformed: false }; + if (dryRun) return plan; + if (!authorized) throw new TorchError('Setup commits TORCH-owned configuration and provisions worktrees; review --dry-run then use --yes', { code: 'APPROVAL_REQUIRED' }); + if (!plan.canProceed) throw new TorchError('Setup has blockers', { code: 'SETUP_BLOCKED', details: worktrees.conflicts }); + const checkpoint = (paths, message) => { + const pending = paths.filter(path => git(root, ['status', '--porcelain=v1', '--', path])); + if (!pending.length) return null; + git(root, ['add', '--', ...pending]); + git(root, ['commit', '--only', '-m', message, '--', ...pending]); + return git(root, ['rev-parse', 'HEAD']); + }; + const configurationCommit = checkpoint(changed, 'Checkpoint approved TORCH configuration'); + const provisioned = createWorktrees({ repository: inspectRepository(root), parentOverride }); + const registrationCommit = checkpoint(['.torch/install-manifest.json'], 'Checkpoint TORCH worktree registration'); + return { ...plan, configurationCommit, registrationCommit, provisioned, mutationPerformed: Boolean(configurationCommit || registrationCommit || provisioned.mutationPerformed) }; +} + +export function restoreProject({ repository, env = process.env, authorized = false, dryRun = false }) { + const root = resolveInstalledProjectRoot(repository.root, env); + const manifest = readInstallManifest(root); + const path = join(projectStatePath(manifest.projectId, env), 'project.json'); + const metadata = JSON.parse(readFileSync(path, 'utf8')); + const result = { action: 'restore-installation', projectId: manifest.projectId, installationId: manifest.installationId, wasDetached: Boolean(metadata.detachedAt), preserved: ['configuration', 'worktrees', 'branches', 'session history', 'provider profiles'], sessionsStarted: false, mutationPerformed: false }; + if (dryRun) return result; + if (!authorized) throw new TorchError('Restoring a retained installation requires --yes', { code: 'APPROVAL_REQUIRED' }); + if (metadata.detachedAt) { + const temporary = `${path}.restore-${process.pid}`; + writeFileSync(temporary, `${JSON.stringify({ ...metadata, detachedAt: null, attachedAt: new Date().toISOString() }, null, 2)}\n`, { flag: 'wx', mode: 0o600 }); + renameSync(temporary, path); + result.mutationPerformed = true; + } + return result; +} diff --git a/test/fleet/setup.test.mjs b/test/fleet/setup.test.mjs new file mode 100644 index 0000000..b051f36 --- /dev/null +++ b/test/fleet/setup.test.mjs @@ -0,0 +1,101 @@ +import assert from 'node:assert/strict'; +import { execFileSync } from 'node:child_process'; +import { mkdtempSync, readFileSync, writeFileSync, cpSync } from 'node:fs'; +import { tmpdir } from 'node:os'; +import { join } from 'node:path'; +import test from 'node:test'; +import { analyzeRepository } from '../../src/kernel/analyze.mjs'; +import { proposeDomains } from '../../src/kernel/domains.mjs'; +import { inspectRepository } from '../../src/kernel/git.mjs'; +import { installProject, readInstallManifest } from '../../src/kernel/install.mjs'; +import { resolveInstalledProjectRoot } from '../../src/kernel/project-root.mjs'; +import { setupProject, restoreProject } from '../../src/kernel/setup.mjs'; +import { openControlPlane } from '../../src/control-plane/service.mjs'; +import { createFleetBrief, detachFleet, planFleetDetach } from '../../src/runtime/lifecycle.mjs'; + +const git = (root, args) => execFileSync('git', ['-C', root, ...args], { encoding: 'utf8', stdio: ['ignore', 'pipe', 'pipe'] }).trim(); +function fixture() { + const root = mkdtempSync(join(tmpdir(), 'torch-setup-')); + const env = { ...process.env, XDG_DATA_HOME: join(root, 'external-state') }; + git(root, ['init', '-b', 'main']); + git(root, ['config', 'user.name', 'Setup Test']); + git(root, ['config', 'user.email', 'test@example.invalid']); + writeFileSync(join(root, '.gitignore'), 'external-state/\nworktrees/\n'); + writeFileSync(join(root, 'app.js'), 'export const app = true;\n'); + git(root, ['add', '.']); + git(root, ['commit', '-m', 'fixture']); + const repository = inspectRepository(root); + const proposal = proposeDomains({ repository, analysis: analyzeRepository(repository) }); + proposal.review = { status: 'approved', reviewedAt: '2026-09-30T00:00:00Z', reviewedBy: 'owner', notes: [] }; + installProject({ repository, proposal, env }); + return { root, env, parentOverride: join(root, 'worktrees') }; +} + +test('SCN-guided-setup: approved setup checkpoints owned files and provisions without agents', () => { + const f = fixture(); + const before = git(f.root, ['rev-parse', 'HEAD']); + assert.equal(setupProject({ ...f, repository: inspectRepository(f.root), dryRun: true }).canProceed, true); + assert.equal(git(f.root, ['rev-parse', 'HEAD']), before); + assert.throws(() => setupProject({ ...f, repository: inspectRepository(f.root) }), { code: 'APPROVAL_REQUIRED' }); + writeFileSync(join(f.root, 'app.js'), 'export const app = false;\n'); + git(f.root, ['add', 'app.js']); + assert.throws(() => setupProject({ ...f, repository: inspectRepository(f.root), authorized: true }), { code: 'SETUP_STAGED_WORK' }); + assert.equal(git(f.root, ['diff', '--cached', '--name-only']), 'app.js'); + git(f.root, ['restore', '--staged', 'app.js']); + writeFileSync(join(f.root, 'app.js'), 'export const app = true;\n'); + const result = setupProject({ ...f, repository: inspectRepository(f.root), authorized: true }); + assert.equal(result.sessionsStarted, false); + assert.ok(result.configurationCommit); + assert.ok(result.registrationCommit); + assert.equal(git(f.root, ['status', '--porcelain']), ''); + assert.ok(git(f.root, ['diff', '--name-only', before, 'HEAD']).split('\n').every(path => path.startsWith('.torch/'))); + assert.ok(readInstallManifest(f.root).external.some(entry => entry.type === 'worktree')); + assert.equal(setupProject({ ...f, repository: inspectRepository(f.root), authorized: true }).mutationPerformed, false); +}); + +test('SCN-worktree-canonical-brief: current instructions win and unregistered or copied worktrees fail closed', () => { + const f = fixture(); + setupProject({ ...f, repository: inspectRepository(f.root), authorized: true }); + const entry = readInstallManifest(f.root).external.find(item => item.type === 'worktree'); + const marker = 'Current canonical instructions, not the old worktree copy.'; + writeFileSync(join(f.root, '.torch/prompts/COMMON.md'), marker); + const canonical = resolveInstalledProjectRoot(entry.path, f.env); + assert.equal(canonical, f.root); + const control = openControlPlane({ repositoryRoot: canonical, env: f.env }); + try { + const brief = createFleetBrief({ repositoryRoot: canonical, controlPlane: control, areaId: entry.area }); + assert.ok(brief.areas[0].prompt.includes(marker)); + } finally { control.close(); } + const stray = join(f.root, 'worktrees', 'stray'); + git(f.root, ['worktree', 'add', '-b', 'stray', stray]); + assert.throws(() => resolveInstalledProjectRoot(stray, f.env), { code: 'UNREGISTERED_PROJECT_WORKTREE' }); + const copied = mkdtempSync(join(tmpdir(), 'torch-copy-')); + cpSync(join(entry.path, '.torch'), join(copied, '.torch'), { recursive: true }); + git(copied, ['init', '-b', entry.branch]); + assert.throws(() => resolveInstalledProjectRoot(copied, f.env), { code: 'UNREGISTERED_PROJECT_WORKTREE' }); + const localManifest = JSON.parse(readFileSync(join(entry.path, '.torch/install-manifest.json'))); + localManifest.installationId = 'stale-installation'; + writeFileSync(join(entry.path, '.torch/install-manifest.json'), JSON.stringify(localManifest)); + assert.throws(() => resolveInstalledProjectRoot(entry.path, f.env), { code: 'UNREGISTERED_PROJECT_WORKTREE' }); +}); + +test('SCN-install-restore: detach and explicit restore preserve configuration and worktree history without starting sessions', () => { + const f = fixture(); + setupProject({ ...f, repository: inspectRepository(f.root), authorized: true }); + const manifest = readInstallManifest(f.root); + const metadataPath = join(manifest.external.find(entry => entry.type === 'local-state').path, 'project.json'); + const config = readFileSync(join(f.root, '.torch/torch.yaml'), 'utf8'); + const heads = manifest.external.filter(entry => entry.type === 'worktree').map(entry => git(entry.path, ['rev-parse', 'HEAD'])); + const control = openControlPlane({ repositoryRoot: f.root, env: f.env }); + try { + detachFleet({ plan: planFleetDetach({ repositoryRoot: f.root, controlPlane: control, adapters: {} }), controlPlane: control, stopRuntime: () => { throw new Error('Offline setup must not call a provider'); } }); + } finally { control.close(); } + const before = readFileSync(metadataPath, 'utf8'); + assert.equal(restoreProject({ repository: inspectRepository(f.root), env: f.env, dryRun: true }).wasDetached, true); + assert.equal(readFileSync(metadataPath, 'utf8'), before); + assert.throws(() => restoreProject({ repository: inspectRepository(f.root), env: f.env }), { code: 'APPROVAL_REQUIRED' }); + assert.equal(restoreProject({ repository: inspectRepository(f.root), env: f.env, authorized: true }).sessionsStarted, false); + assert.equal(JSON.parse(readFileSync(metadataPath)).detachedAt, null); + assert.equal(readFileSync(join(f.root, '.torch/torch.yaml'), 'utf8'), config); + assert.deepEqual(manifest.external.filter(entry => entry.type === 'worktree').map(entry => git(entry.path, ['rev-parse', 'HEAD'])), heads); +}); diff --git a/torch-release.json b/torch-release.json index 00b9ad6..98595d5 100644 --- a/torch-release.json +++ b/torch-release.json @@ -1,6 +1,6 @@ { "schema": "torch.dev/release/v1alpha1", - "version": "0.1.0-alpha.1", + "version": "0.1.0-alpha.2", "state": { "reads": ["torch.dev/state/v1alpha1"], "writes": "torch.dev/state/v1alpha1",