mirror of
https://github.com/PR0M3TH3AN/TORCH.git
synced 2026-10-31 18:01:55 +00:00
421 lines
18 KiB
JavaScript
421 lines
18 KiB
JavaScript
import { randomUUID } from 'node:crypto';
|
|
import { execFileSync } from 'node:child_process';
|
|
import {
|
|
existsSync, mkdirSync, readFileSync, readdirSync, renameSync, rmSync, writeFileSync,
|
|
} from 'node:fs';
|
|
import { dirname, join, relative, resolve, sep } from 'node:path';
|
|
import { TorchError } from './errors.mjs';
|
|
import { fileHash, writeNewFile } from './files.mjs';
|
|
import { projectStatePath } from './paths.mjs';
|
|
import { validateApprovedProposal } from './domains.mjs';
|
|
import { inspectManagedWorktree } from './worktree-state.mjs';
|
|
|
|
const TRACKED_DIR = '.torch';
|
|
|
|
function jsonYaml(value) {
|
|
return `${JSON.stringify(value, null, 2)}\n`;
|
|
}
|
|
|
|
const RUNTIME_DEFINITIONS = Object.freeze({
|
|
claude: { model: 'opus', background: true },
|
|
codex: { sandbox: 'workspace-write', approval: 'approve-for-me' },
|
|
});
|
|
|
|
function resolveInstallRuntimes(proposal, requested) {
|
|
const selected = requested === undefined
|
|
? Object.keys(RUNTIME_DEFINITIONS)
|
|
: [...new Set(requested)];
|
|
if (!selected.length || selected.some((runtime) => typeof runtime !== 'string' || !RUNTIME_DEFINITIONS[runtime])) {
|
|
throw new TorchError('Install runtimes must be one or more supported adapters', {
|
|
code: 'INSTALL_RUNTIME_INVALID', details: { requested: selected, supported: Object.keys(RUNTIME_DEFINITIONS) },
|
|
});
|
|
}
|
|
const required = new Set([
|
|
proposal.session_manager?.runtime ?? 'claude',
|
|
...proposal.domains.map((domain) => domain.runtime ?? 'claude'),
|
|
]);
|
|
const missing = [...required].filter((runtime) => !selected.includes(runtime));
|
|
if (missing.length) {
|
|
throw new TorchError('Selected install runtimes do not cover the approved Fleet', {
|
|
code: 'INSTALL_RUNTIME_MISSING', details: { selected, required: [...required], missing },
|
|
});
|
|
}
|
|
return selected;
|
|
}
|
|
|
|
function initialFiles({ repository, projectId, createdAt, proposal, runtimes }) {
|
|
const promptContent = (source, fallback) => source
|
|
? readFileSync(safeProjectPath(repository.root, source), 'utf8')
|
|
: fallback;
|
|
const config = {
|
|
schema: 'torch.dev/v1alpha1',
|
|
project: {
|
|
id: projectId,
|
|
name: repository.name,
|
|
main_branch: repository.branch || 'main',
|
|
},
|
|
paths: {
|
|
tracked_state: TRACKED_DIR,
|
|
worktree_parent: proposal.paths?.worktree_parent ?? '~/TORCHWorktrees',
|
|
},
|
|
repository: repository.canonicalRemote
|
|
? { canonical: { type: 'remote', remote: repository.canonicalRemote.name } }
|
|
: { canonical: { type: 'unconfigured' } },
|
|
forge: { provider: 'none' },
|
|
git: {
|
|
branch_prefix: 'torch/', convergence: 'merge', allow_rebase: false,
|
|
allow_force_push: false, allow_bare_stash: false,
|
|
},
|
|
synchronization: { strategy: 'dispatcher-managed', auto_merge_worktrees: false },
|
|
runtimes: Object.fromEntries([
|
|
['default', proposal.session_manager?.runtime ?? 'claude'],
|
|
...runtimes.map((runtime) => [runtime, RUNTIME_DEFINITIONS[runtime]]),
|
|
]),
|
|
checks: proposal.checks ?? [],
|
|
resources: proposal.resources ?? [],
|
|
schedules: proposal.schedules ?? [],
|
|
integration: {
|
|
provider: 'torch', target: repository.branch || 'main', require_current_main: true,
|
|
required_checks: (proposal.checks ?? []).map((check) => check.id),
|
|
landing_authority: ['session-manager'],
|
|
},
|
|
delivery: {
|
|
authority: {
|
|
implemented: ['$source'],
|
|
verified: ['$source', 'session-manager'],
|
|
integrated: ['session-manager'],
|
|
release_ready: ['session-manager'],
|
|
released: ['owner'],
|
|
deployed: ['owner'],
|
|
live_verified: ['owner'],
|
|
},
|
|
adapters: {
|
|
release: { provider: 'none' },
|
|
deployment: { provider: 'none' },
|
|
},
|
|
},
|
|
session_manager: {
|
|
id: 'session-manager', runtime: proposal.session_manager?.runtime ?? 'claude', start_last: true,
|
|
branch: proposal.session_manager?.branch ?? null,
|
|
worktree_name: proposal.session_manager?.worktree_name ?? null,
|
|
},
|
|
domains: proposal.domains.map((domain) => ({
|
|
id: domain.id,
|
|
title: domain.title,
|
|
scope: domain.scope,
|
|
not_scope: domain.not_scope,
|
|
owned_paths: domain.owned_paths,
|
|
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,
|
|
})),
|
|
};
|
|
const roster = {
|
|
schema: 'torch.dev/roster/v1alpha1',
|
|
areas: [{
|
|
id: 'session-manager', title: 'TORCH Session Manager',
|
|
scope: ['routing', 'priority', 'ownership rulings', 'fleet health'],
|
|
not_scope: ['project feature implementation by default'],
|
|
neighbours: ['all'],
|
|
runtime: config.session_manager.runtime,
|
|
branch: config.session_manager.branch,
|
|
worktree_name: config.session_manager.worktree_name,
|
|
}, ...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. When a specialist no longer earns its coordination cost, use \`torch_propose_domain_retirement\`; retirement preserves its branch and refuses active or unrecoverable work. Use \`torch_list_fleet_changes\` to follow change state. Never create, retire, approve, or activate a persistent identity yourself; owner approval and CLI activation are separate. Recommend a merge or split for owner review when boundaries should change but do not silently rewrite ownership.\n`;
|
|
const files = new Map([
|
|
['torch.yaml', jsonYaml(config)],
|
|
['roster.yaml', jsonYaml(roster)],
|
|
['decisions.md', '# TORCH decisions\n\nNo project decisions recorded yet.\n'],
|
|
['RESUME-BRIEF.md', '# TORCH resume brief\n\nFleet not started yet.\n'],
|
|
['prompts/COMMON.md', promptContent(
|
|
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', managerPrompt],
|
|
['backlog/.gitkeep', ''],
|
|
['INSTALLATION.md', `# TORCH installation\n\nInstalled ${createdAt}. Run \`torch doctor\` before starting the fleet.\n`],
|
|
]);
|
|
for (const domain of config.domains) {
|
|
const generatedPrompt = [
|
|
`# ${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');
|
|
const proposalDomain = proposal.domains.find((candidate) => candidate.id === domain.id);
|
|
files.set(`prompts/${domain.id}.md`, promptContent(proposalDomain?.prompt_source, generatedPrompt));
|
|
}
|
|
files.set('domain-proposal.approved.json', jsonYaml(proposal));
|
|
return files;
|
|
}
|
|
|
|
export function manifestPath(root) {
|
|
return join(root, TRACKED_DIR, 'install-manifest.json');
|
|
}
|
|
|
|
export function readInstallManifest(root) {
|
|
const path = manifestPath(root);
|
|
if (!existsSync(path)) {
|
|
throw new TorchError(`No TORCH ownership manifest at ${path}`, { code: 'NOT_INSTALLED' });
|
|
}
|
|
try {
|
|
return JSON.parse(readFileSync(path, 'utf8'));
|
|
} catch (error) {
|
|
throw new TorchError(`Invalid TORCH ownership manifest: ${path}`, {
|
|
code: 'INVALID_INSTALL_MANIFEST', details: error.message,
|
|
});
|
|
}
|
|
}
|
|
|
|
export function writeInstallManifest(root, manifest) {
|
|
const path = manifestPath(root);
|
|
const temporary = `${path}.tmp-${process.pid}`;
|
|
writeFileSync(temporary, jsonYaml(manifest), { encoding: 'utf8', mode: 0o600, flag: 'wx' });
|
|
renameSync(temporary, path);
|
|
}
|
|
|
|
function safeProjectPath(root, relativePath) {
|
|
const target = resolve(root, relativePath);
|
|
const prefix = `${resolve(root)}${sep}`;
|
|
if (!target.startsWith(prefix)) {
|
|
throw new TorchError(`Manifest path escapes project root: ${relativePath}`, {
|
|
code: 'UNSAFE_MANIFEST_PATH', details: { relativePath },
|
|
});
|
|
}
|
|
return target;
|
|
}
|
|
|
|
export function planInstall({ repository, proposal, env = process.env, runtimes: requestedRuntimes } = {}) {
|
|
validateApprovedProposal({ proposal, repository });
|
|
const runtimes = resolveInstallRuntimes(proposal, requestedRuntimes);
|
|
const trackedRoot = join(repository.root, TRACKED_DIR);
|
|
return {
|
|
action: 'install',
|
|
projectRoot: repository.root,
|
|
trackedRoot,
|
|
stateParent: join(projectStatePath('<new-project-id>', env), '..'),
|
|
wouldCreateTrackedState: !existsSync(trackedRoot),
|
|
approvedDomainCount: proposal.domains.length,
|
|
runtimes,
|
|
mutationPerformed: false,
|
|
};
|
|
}
|
|
|
|
export function installProject({
|
|
repository,
|
|
proposal,
|
|
env = process.env,
|
|
projectId = randomUUID(),
|
|
now = () => new Date(),
|
|
runtimes: requestedRuntimes,
|
|
} = {}) {
|
|
validateApprovedProposal({ proposal, repository });
|
|
const runtimes = resolveInstallRuntimes(proposal, requestedRuntimes);
|
|
const trackedRoot = join(repository.root, TRACKED_DIR);
|
|
if (existsSync(trackedRoot)) {
|
|
throw new TorchError(`Refusing to replace existing ${trackedRoot}`, {
|
|
code: 'ALREADY_INSTALLED', details: { trackedRoot },
|
|
});
|
|
}
|
|
|
|
const createdAt = now().toISOString();
|
|
const stateRoot = projectStatePath(projectId, env);
|
|
if (existsSync(stateRoot)) {
|
|
throw new TorchError(`Refusing to replace existing TORCH state: ${stateRoot}`, {
|
|
code: 'STATE_ALREADY_EXISTS', details: { stateRoot },
|
|
});
|
|
}
|
|
|
|
const created = [];
|
|
try {
|
|
for (const [path, content] of initialFiles({ repository, projectId, createdAt, proposal, runtimes })) {
|
|
const absolute = join(trackedRoot, path);
|
|
const record = writeNewFile(absolute, content);
|
|
created.push({ path: relative(repository.root, record.path), sha256: record.sha256 });
|
|
}
|
|
|
|
mkdirSync(dirname(stateRoot), { recursive: true });
|
|
mkdirSync(stateRoot, { recursive: false });
|
|
for (const dir of ['sessions', 'messages', 'locks', 'leases', 'checks', 'integration', 'logs']) {
|
|
mkdirSync(join(stateRoot, dir));
|
|
}
|
|
writeFileSync(join(stateRoot, 'project.json'), jsonYaml({
|
|
schema: 'torch.dev/local-project/v1alpha1', projectId,
|
|
root: repository.root, createdAt, detachedAt: null,
|
|
}), { encoding: 'utf8', mode: 0o600, flag: 'wx' });
|
|
|
|
const manifest = {
|
|
schema: 'torch.dev/install-manifest/v1alpha1',
|
|
installationId: randomUUID(),
|
|
projectId,
|
|
createdAt,
|
|
created,
|
|
patched: [],
|
|
external: [{ type: 'local-state', path: stateRoot }],
|
|
};
|
|
writeNewFile(manifestPath(repository.root), jsonYaml(manifest));
|
|
return { projectId, trackedRoot, stateRoot, manifest, mutationPerformed: true };
|
|
} catch (error) {
|
|
if (existsSync(trackedRoot)) rmSync(trackedRoot, { recursive: true, force: true });
|
|
if (existsSync(stateRoot)) rmSync(stateRoot, { recursive: true, force: true });
|
|
throw error;
|
|
}
|
|
}
|
|
|
|
function nonEmptyEntries(path, ignoredRoots = []) {
|
|
if (!existsSync(path)) return [];
|
|
return readdirSync(path, { recursive: true, withFileTypes: true })
|
|
.filter((entry) => entry.isFile())
|
|
.map((entry) => resolve(entry.parentPath, entry.name))
|
|
.filter((entry) => entry !== join(resolve(path), 'project.json'))
|
|
.filter((entry) => !ignoredRoots.some((root) =>
|
|
entry === resolve(root) || entry.startsWith(`${resolve(root)}${sep}`)))
|
|
.map((entry) => relative(path, entry));
|
|
}
|
|
|
|
function gitOptional(root, args) {
|
|
try {
|
|
return execFileSync('git', ['-C', root, ...args], {
|
|
encoding: 'utf8', stdio: ['ignore', 'pipe', 'pipe'],
|
|
}).trim();
|
|
} catch { return null; }
|
|
}
|
|
|
|
function inspectCanonicalRemoval(repositoryRoot, entry, localStatePath) {
|
|
const problems = [];
|
|
const expectedRoot = `${resolve(localStatePath)}${sep}`;
|
|
if (!resolve(entry.path).startsWith(expectedRoot)) {
|
|
return [{ type: 'unsafe-canonical-path', path: entry.path, stateRoot: localStatePath }];
|
|
}
|
|
const remoteUrl = gitOptional(repositoryRoot, ['remote', 'get-url', entry.remote]);
|
|
if (remoteUrl === null) problems.push({ type: 'canonical-remote-missing', remote: entry.remote });
|
|
else if (resolve(repositoryRoot, remoteUrl) !== resolve(entry.path)) {
|
|
problems.push({ type: 'canonical-remote-changed', remote: entry.remote, expected: entry.path, actual: remoteUrl });
|
|
}
|
|
if (!existsSync(entry.path)) problems.push({ type: 'canonical-path-missing', path: entry.path });
|
|
else {
|
|
const heads = gitOptional(entry.path, ['for-each-ref', '--format=%(objectname)', 'refs/heads']);
|
|
for (const commit of (heads ?? '').split('\n').filter(Boolean)) {
|
|
if (!gitOptional(repositoryRoot, ['for-each-ref', '--format=%(refname)', '--contains', commit])) {
|
|
problems.push({ type: 'canonical-unique-commit', path: entry.path, commit });
|
|
}
|
|
}
|
|
}
|
|
return problems;
|
|
}
|
|
|
|
export function planUninstall({ repository, purge = false }) {
|
|
const manifest = readInstallManifest(repository.root);
|
|
const problems = [];
|
|
let mainBranch = repository.branch || 'main';
|
|
try {
|
|
const config = JSON.parse(readFileSync(join(repository.root, TRACKED_DIR, 'torch.yaml'), 'utf8'));
|
|
mainBranch = config.project?.main_branch ?? mainBranch;
|
|
} catch { /* created-file validation below reports the invalid config */ }
|
|
const localState = (manifest.external ?? []).find((entry) => entry.type === 'local-state');
|
|
if (localState && existsSync(join(localState.path, 'project.json'))) {
|
|
try {
|
|
const metadata = JSON.parse(readFileSync(join(localState.path, 'project.json'), 'utf8'));
|
|
if (resolve(metadata.root) !== resolve(repository.root)) {
|
|
problems.push({ type: 'not-installation-root', expected: metadata.root, actual: repository.root });
|
|
}
|
|
} catch {
|
|
problems.push({ type: 'local-state-metadata-invalid', path: localState.path });
|
|
}
|
|
}
|
|
for (const record of manifest.created) {
|
|
const path = safeProjectPath(repository.root, record.path);
|
|
if (!existsSync(path)) problems.push({ type: 'missing', path: record.path });
|
|
else if (fileHash(path) !== record.sha256) problems.push({ type: 'modified', path: record.path });
|
|
}
|
|
for (const entry of manifest.external ?? []) {
|
|
if (entry.type === 'local-state') {
|
|
const canonicalRoots = (manifest.external ?? [])
|
|
.filter((candidate) => candidate.type === 'canonical-remote')
|
|
.map((candidate) => candidate.path);
|
|
const extras = nonEmptyEntries(entry.path, canonicalRoots);
|
|
if (extras.length) problems.push({ type: 'local-state-not-empty', path: entry.path, entries: extras });
|
|
}
|
|
if (entry.type === 'canonical-remote') {
|
|
if (!localState?.path) problems.push({ type: 'local-state-missing-for-canonical', path: entry.path });
|
|
else problems.push(...inspectCanonicalRemoval(repository.root, entry, localState.path));
|
|
}
|
|
if (entry.type === 'worktree') {
|
|
const state = inspectManagedWorktree(repository.root, entry, mainBranch);
|
|
if (!state.safeToRemove) problems.push({ type: 'unsafe-worktree', path: entry.path, area: entry.area, problems: state.problems });
|
|
}
|
|
if (entry.type === 'systemd-user-unit') {
|
|
problems.push({ type: 'persistent-integration-installed', path: entry.path, name: entry.name });
|
|
}
|
|
}
|
|
return {
|
|
action: purge ? 'purge' : 'detach', manifest, problems,
|
|
canProceed: !purge || problems.length === 0,
|
|
mutationPerformed: false,
|
|
};
|
|
}
|
|
|
|
export function uninstallProject({ repository, purge = false, dryRun = false }) {
|
|
const plan = planUninstall({ repository, purge });
|
|
if (dryRun || !purge) return plan;
|
|
if (!plan.canProceed) {
|
|
throw new TorchError('TORCH purge stopped because managed state changed', {
|
|
code: 'UNSAFE_TO_PURGE', details: plan.problems,
|
|
});
|
|
}
|
|
|
|
for (const entry of (plan.manifest.external ?? []).filter((item) => item.type === 'worktree').reverse()) {
|
|
execFileSync('git', ['-C', repository.root, 'worktree', 'remove', entry.path], { stdio: 'ignore' });
|
|
execFileSync('git', ['-C', repository.root, 'branch', '-d', entry.branch], { stdio: 'ignore' });
|
|
}
|
|
for (const patch of plan.manifest.patched ?? []) {
|
|
if (patch.type !== 'git-info-exclude-line' || !existsSync(patch.path)) continue;
|
|
const marker = `# TORCH ${patch.installationId}\n${patch.line}\n`;
|
|
const content = readFileSync(patch.path, 'utf8');
|
|
if (content.includes(marker)) writeFileSync(patch.path, content.replace(marker, ''), 'utf8');
|
|
}
|
|
for (const entry of (plan.manifest.external ?? []).filter((item) => item.type === 'canonical-remote')) {
|
|
execFileSync('git', ['-C', repository.root, 'remote', 'remove', entry.remote], { stdio: 'ignore' });
|
|
}
|
|
|
|
for (const record of [...plan.manifest.created].reverse()) {
|
|
rmSync(safeProjectPath(repository.root, record.path));
|
|
}
|
|
rmSync(manifestPath(repository.root));
|
|
rmSync(join(repository.root, TRACKED_DIR), { recursive: true });
|
|
for (const entry of plan.manifest.external ?? []) {
|
|
if (entry.type === 'local-state' && existsSync(entry.path)) rmSync(entry.path, { recursive: true });
|
|
}
|
|
return { ...plan, mutationPerformed: true };
|
|
}
|