diff --git a/README.md b/README.md index 8dfd34e..a8dbeca 100644 --- a/README.md +++ b/README.md @@ -17,6 +17,8 @@ and the first Claude reference-fleet path: - read-only Git repository inspection; - repository inventory and check discovery; +- first-class repository and external-specification startup inputs; +- spec-fingerprinted, evidence-linked fleet design with stale-spec refusal; - evidence-linked architecture graphs and domain-collision proposals; - owner-reviewed organizational proposals required before installation; - explicit install approval; @@ -81,6 +83,29 @@ node bin/torch.mjs analyze --json node bin/torch.mjs domains --output fleet-proposal.json --json ``` +Design a fleet for any selected Git project from its code, one or more project +specifications, or both: + +```bash +node bin/torch.mjs analyze \ + --repo /path/to/project \ + --spec /path/to/product-spec.md \ + --json + +node bin/torch.mjs design \ + --repo /path/to/project \ + --spec /path/to/product-spec.md \ + --output fleet-proposal.json \ + --json +``` + +`--spec` is repeatable. TORCH reads specifications as untrusted project data, +extracts responsibilities and evidence references without executing their +contents, and records their SHA-256 digests. A prose-only project can receive a +responsibility-oriented fleet proposal before source exists, but unresolved +path ownership remains visibly blocked for owner review. Installation refuses +a proposal if a selected specification has changed since design. + Import an existing COMBATRIG fleet into the same pending-review proposal format without changing the source repository: diff --git a/docs/PORTABLE_AGENT_FLEET_SPEC.md b/docs/PORTABLE_AGENT_FLEET_SPEC.md index db017bc..4bdff2d 100644 --- a/docs/PORTABLE_AGENT_FLEET_SPEC.md +++ b/docs/PORTABLE_AGENT_FLEET_SPEC.md @@ -430,6 +430,13 @@ Uninstall MUST stop before destructive cleanup and report exact remediation. - scarce resources such as GPUs, browsers, ports, databases, devices, API quotas, and deployment accounts. +Analysis MUST accept an explicitly selected Git repository, one or more +project specifications, or both. Specifications may live outside the +repository. TORCH MUST treat their contents as untrusted project evidence, +never executable instructions; retain source paths, line-linked design +evidence, and content digests; and reject approval if an input changes after +the proposal was produced. + The analysis phase MUST be read-only. ### 10.2 Architecture graph @@ -499,6 +506,11 @@ authority; or reject the analysis. Permanent identities, branches, worktrees, and sessions are created only from an approved proposal. +For a specification-first project, TORCH MAY propose responsibilities before +source paths exist, but it MUST label unresolved ownership and block +installation until the owner supplies defensible path boundaries. It MUST NOT +invent ownership merely to make a proposal installable. + ### 10.6 Generated configuration and prompts For each approved domain TORCH generates: @@ -1331,6 +1343,15 @@ without modifying user code. Exit gate: two structurally different fixture repositories receive defensible, reviewable organizations. +Implementation checkpoint (2026-09-27): `torch analyze` and `torch design` +accept `--repo` plus repeatable `--spec` inputs. Markdown specifications are +read without execution, reduced to evidence-linked responsibilities and +domain signals, fingerprinted, and checked again at approval. A spec-first +fixture demonstrates project-specific responsibilities with intentionally +unresolved path ownership; a separate repository fixture continues to prove +code-derived architecture and collision boundaries. Both paths remain +read-only until an approved install. + ### Stage 3: Claude reference fleet - control-plane service and MCP server; diff --git a/package.json b/package.json index 57557aa..a7c0008 100644 --- a/package.json +++ b/package.json @@ -15,6 +15,7 @@ "./kernel/analyze": "./src/kernel/analyze.mjs", "./kernel/git": "./src/kernel/git.mjs", "./kernel/install": "./src/kernel/install.mjs", + "./kernel/specifications": "./src/kernel/specifications.mjs", "./kernel/worktrees": "./src/kernel/worktrees.mjs", "./integration": "./src/integration/service.mjs", "./importers/combatrig": "./src/importers/combatrig.mjs", diff --git a/src/cli.mjs b/src/cli.mjs index f08e7f3..7938dba 100644 --- a/src/cli.mjs +++ b/src/cli.mjs @@ -9,6 +9,7 @@ import { spawnSync } from 'node:child_process'; import { diagnoseProject } from './kernel/doctor.mjs'; import { asErrorRecord, TorchError } from './kernel/errors.mjs'; import { inspectRepository } from './kernel/git.mjs'; +import { inspectSpecifications } from './kernel/specifications.mjs'; import { installProject, planInstall, uninstallProject } from './kernel/install.mjs'; import { IntegrationService } from './integration/service.mjs'; import { analyzeCombatrigFleet } from './importers/combatrig.mjs'; @@ -24,9 +25,10 @@ import { CANDIDATE_ACCEPTANCE_SCENARIOS, createVersionService } from './self-hos const HELP = `TORCH — portable agent fleet Usage: - torch init [--json] - torch analyze [--json] - torch domains [--output ] [--json] + torch init [--repo ] [--spec ] [--json] + torch analyze [--repo ] [--spec ] [--json] + torch design [--repo ] [--spec ] [--output ] [--json] + torch domains [--repo ] [--spec ] [--output ] [--json] torch install --proposal [--dry-run] [--yes] [--json] torch worktrees [--parent ] [--dry-run] [--yes] [--json] torch up [--fresh] [--dry-run] [--yes] [--json] @@ -90,6 +92,15 @@ function optionValue(argv, name) { return index >= 0 ? argv[index + 1] : undefined; } +function optionValues(argv, name) { + const values = []; + for (let index = 0; index < argv.length; index += 1) { + if (argv[index].startsWith(`${name}=`)) values.push(argv[index].slice(name.length + 1)); + else if (argv[index] === name && argv[index + 1] !== undefined) values.push(argv[index + 1]); + } + return values; +} + function commaList(value) { return (value ?? '').split(',').map((item) => item.trim()).filter(Boolean); } @@ -141,6 +152,7 @@ function runCandidateAcceptance(candidateRoot, spawn) { const testOutput = checks.find((check) => check.id === 'test')?.stdout ?? ''; const evidenceByScenario = new Map([ ['init-analyze', ['SCN-init-read-only']], + ['spec-aware-design', ['SCN-spec-fleet-design', 'SCN-cli-spec-design']], ['review-install-roster', ['SCN-cli-domain-review']], ['branches-worktrees', ['SCN-worktree-bootstrap']], ['runtime-identities', ['SCN-mixed-runtime']], @@ -273,14 +285,25 @@ export async function runCli(argv = process.argv.slice(2), { } else print({ proposal, mutationPerformed: false }, { json }); return proposal.compatibility.readyForApproval ? 0 : 1; } - const repository = inspectRepository(cwd); + const designCommand = ['init', 'analyze', 'domains', 'design'].includes(command); + const repositoryRoot = designCommand ? resolve(cwd, optionValue(argv, '--repo') ?? '.') : cwd; + const repository = inspectRepository(repositoryRoot); if (command === 'init' || command === 'analyze') { - const analysis = analyzeRepository(repository); - print({ command, repository, analysis, next: 'Review analysis, then run torch install --dry-run.' }, { json }); + const specifications = inspectSpecifications(optionValues(argv, '--spec'), { + cwd, repositoryRoot: repository.root, + }); + const analysis = analyzeRepository(repository, { specifications }); + print({ + command, repository, analysis, + next: 'Run torch design with the same repository/specification inputs, review its proposal, then install.', + }, { json }); return 0; } - if (command === 'domains') { - const analysis = analyzeRepository(repository); + if (command === 'domains' || command === 'design') { + const specifications = inspectSpecifications(optionValues(argv, '--spec'), { + cwd, repositoryRoot: repository.root, + }); + const analysis = analyzeRepository(repository, { specifications }); const proposal = proposeDomains({ repository, analysis }); const output = optionValue(argv, '--output'); if (output) { diff --git a/src/kernel/analyze.mjs b/src/kernel/analyze.mjs index 1fab55e..36d65b9 100644 --- a/src/kernel/analyze.mjs +++ b/src/kernel/analyze.mjs @@ -19,7 +19,7 @@ const MANIFESTS = [ 'pom.xml', 'build.gradle', 'Makefile', 'CMakeLists.txt', 'Dockerfile', ]; -export function analyzeRepository(repository) { +export function analyzeRepository(repository, { specifications = [] } = {}) { const files = trackedFiles(repository.root); const languageCounts = new Map(); const topLevel = new Set(); @@ -84,6 +84,7 @@ export function analyzeRepository(repository) { scarceResources, }, mutationPerformed: false, + specifications, }; result.architecture = buildArchitectureGraph({ repository, files }); return result; diff --git a/src/kernel/domains.mjs b/src/kernel/domains.mjs index b8b49d9..ab27ced 100644 --- a/src/kernel/domains.mjs +++ b/src/kernel/domains.mjs @@ -1,6 +1,7 @@ import { existsSync, readFileSync } from 'node:fs'; import { posix } from 'node:path'; import { TorchError } from './errors.mjs'; +import { validateSpecificationEvidence } from './specifications.mjs'; const SOURCE_ROOTS = new Set(['src', 'app', 'apps', 'server', 'client', 'lib', 'packages', 'services']); const IGNORED_ROOTS = new Set(['node_modules', 'dist', 'build', 'coverage', 'vendor', 'artifacts', 'reports']); @@ -141,12 +142,59 @@ function domainFromComponent(component) { }; } +function words(value) { + return new Set(value.toLowerCase().match(/[a-z0-9]{3,}/g) ?? []); +} + +function overlap(left, right) { + const a = words(left); + const b = words(right); + return [...a].filter((word) => b.has(word)).length; +} + +function specDomain(signal, specification) { + return { + id: signal.id, + title: signal.title, + kind: 'planning', + scope: signal.responsibilities.length + ? signal.responsibilities + : [`responsibilities described by ${specification.path}:${signal.line}`], + not_scope: [], + owned_paths: signal.pathReferences, + shared_paths: [], neighbours: [], required_checks: [], runtime: 'claude', + evidence: [`${specification.path}:${signal.line}`], + design_status: signal.pathReferences.length ? 'evidence-backed' : 'needs-owner-path-review', + }; +} + export function proposeDomains({ repository, analysis }) { const graph = analysis.architecture; const sourceComponents = graph.components.filter((component) => !SHARED_PATTERN.test(component.key)); + const specFirst = sourceComponents.length === 0; const selected = sourceComponents.filter((component) => component.fileCount >= 2); const basis = selected.length ? selected : sourceComponents.slice(0, 1); const domains = basis.map(domainFromComponent); + const specifications = analysis.specifications ?? []; + const signals = specifications.flatMap((specification) => + specification.domainSignals.map((signal) => ({ signal, specification }))); + + for (const { signal, specification } of signals) { + const candidate = domains + .map((domain) => ({ domain, score: overlap(`${domain.id} ${domain.title} ${domain.owned_paths.join(' ')}`, `${signal.title} ${signal.pathReferences.join(' ')}`) })) + .sort((a, b) => b.score - a.score)[0]; + if (candidate?.score > 0) { + for (const responsibility of signal.responsibilities) { + if (!candidate.domain.scope.includes(responsibility)) candidate.domain.scope.push(responsibility); + } + candidate.domain.evidence.push(`${specification.path}:${signal.line}`); + continue; + } + if (signal.level <= 3 && (signal.pathReferences.length || specFirst)) { + const proposed = specDomain(signal, specification); + if (!domains.some((domain) => domain.id === proposed.id)) domains.push(proposed); + } + } if (!domains.length) { domains.push({ id: 'core', title: 'Core project', kind: 'development', @@ -251,6 +299,12 @@ export function proposeDomains({ repository, analysis }) { generatedAt: new Date().toISOString(), repository: { root: repository.root, initialCommit: repository.initialCommit, head: repository.head }, review: { status: 'pending', reviewedAt: null, reviewedBy: null, notes: [] }, + specifications: specifications.map((specification) => ({ + path: specification.path, + sha256: specification.sha256, + bytes: specification.bytes, + title: specification.title, + })), domains, collisions: collisions.sort((a, b) => b.score - a.score || a.domains.join(':').localeCompare(b.domains.join(':'))), checks: analysis.inventory.checks.map((check) => ({ @@ -271,6 +325,7 @@ export function validateApprovedProposal({ proposal, repository }) { if (proposal?.repository?.initialCommit !== repository.initialCommit) problems.push('proposal belongs to a different Git history'); if (proposal?.repository?.head !== repository.head) problems.push('proposal is stale because HEAD changed'); if (!Array.isArray(proposal?.domains) || proposal.domains.length === 0) problems.push('proposal has no domains'); + problems.push(...validateSpecificationEvidence(proposal?.specifications ?? [], repository.root)); const ids = new Set(); for (const domain of proposal?.domains ?? []) { if (!domain.id || ids.has(domain.id)) problems.push(`invalid or duplicate domain id: ${domain.id ?? ''}`); diff --git a/src/kernel/specifications.mjs b/src/kernel/specifications.mjs new file mode 100644 index 0000000..bcb0fac --- /dev/null +++ b/src/kernel/specifications.mjs @@ -0,0 +1,129 @@ +import { createHash } from 'node:crypto'; +import { existsSync, readFileSync, statSync } from 'node:fs'; +import { isAbsolute, relative, resolve, sep } from 'node:path'; +import { TorchError } from './errors.mjs'; + +const MAX_SPEC_BYTES = 2 * 1024 * 1024; +const GENERIC_HEADINGS = new Set([ + 'abstract', 'appendix', 'background', 'conclusion', 'contents', 'introduction', + 'overview', 'references', 'summary', 'table of contents', +]); +const DOMAIN_TERMS = /\b(?:agent|api|architecture|art|audio|backend|cli|client|content|data|database|design|documentation|domain|frontend|gameplay|integration|migration|mobile|operations|performance|qa|release|runtime|security|service|session|testing|tooling|ui|web|worker)\b/i; +const REQUIREMENT = /\b(?:must|should|shall|required|requires|need(?:s)? to|will)\b/i; + +function digest(content) { + return createHash('sha256').update(content).digest('hex'); +} + +function slug(value) { + return value.toLowerCase().replace(/[^a-z0-9]+/g, '-').replace(/^-|-$/g, '').slice(0, 64) || 'project'; +} + +function plain(value) { + return value + .replace(//g, ' ') + .replace(/```[^]*?```/g, ' ') + .replace(/!\[[^\]]*\]\([^)]*\)/g, ' ') + .replace(/\[([^\]]+)\]\([^)]*\)/g, '$1') + .replace(/[`*_>#]/g, ' ') + .replace(/\s+/g, ' ') + .trim(); +} + +function pathReferences(content) { + const found = new Set(); + for (const match of content.matchAll(/`([^`\n]+)`/g)) { + const value = match[1].trim().replaceAll('\\', '/'); + if (value.length <= 180 && !value.includes(' ') && (value.includes('/') || /\.[a-z0-9]{1,8}$/i.test(value))) { + found.add(value.replace(/^\.\//, '')); + } + } + return [...found].slice(0, 40); +} + +function responsibilities(content) { + const lines = content.split('\n').map((line) => line.trim()).filter(Boolean); + const selected = []; + for (const line of lines) { + const bullet = line.match(/^(?:[-*+] |\d+[.)] )(.+)/)?.[1]; + const candidate = plain(bullet ?? line); + if (candidate.length < 12 || candidate.length > 300) continue; + if (bullet || REQUIREMENT.test(candidate)) selected.push(candidate); + if (selected.length === 8) break; + } + if (!selected.length) { + const first = plain(content).split(/(?<=[.!?])\s+/).find((sentence) => sentence.length >= 12); + if (first) selected.push(first.slice(0, 300)); + } + return selected; +} + +function sections(content) { + const headings = [...content.matchAll(/^(#{1,4})\s+(.+?)\s*$/gm)]; + return headings.map((match, index) => { + const start = match.index + match[0].length; + const end = headings[index + 1]?.index ?? content.length; + const body = content.slice(start, end).trim(); + const title = plain(match[2]); + const line = content.slice(0, match.index).split('\n').length; + const paths = pathReferences(body); + const explicitDomain = DOMAIN_TERMS.test(title); + return { + id: slug(title), title, level: match[1].length, line, + responsibilities: responsibilities(body), pathReferences: paths, + explicitDomain, + }; + }).filter((section) => section.title + && !GENERIC_HEADINGS.has(section.title.toLowerCase()) + && (section.responsibilities.length || section.pathReferences.length)); +} + +function displayPath(path, repositoryRoot) { + if (!repositoryRoot) return path; + const name = relative(repositoryRoot, path); + return name && !name.startsWith(`..${sep}`) && name !== '..' ? name : path; +} + +export function inspectSpecifications(paths = [], { cwd = process.cwd(), repositoryRoot = null } = {}) { + const unique = [...new Set(paths.map((path) => resolve(cwd, path)))]; + return unique.map((path) => { + if (!existsSync(path) || !statSync(path).isFile()) { + throw new TorchError(`Specification is not a readable file: ${path}`, { + code: 'SPECIFICATION_NOT_FOUND', details: { path }, + }); + } + const bytes = statSync(path).size; + if (bytes > MAX_SPEC_BYTES) { + throw new TorchError(`Specification exceeds ${MAX_SPEC_BYTES} bytes: ${path}`, { + code: 'SPECIFICATION_TOO_LARGE', details: { path, bytes, maximum: MAX_SPEC_BYTES }, + }); + } + const content = readFileSync(path, 'utf8'); + const analyzedSections = sections(content); + const requirements = responsibilities(content).slice(0, 20); + return { + path: displayPath(path, repositoryRoot), + absolutePath: path, + sha256: digest(content), bytes, + title: analyzedSections[0]?.level === 1 ? analyzedSections[0].title : null, + requirements, + sections: analyzedSections.slice(0, 80), + domainSignals: analyzedSections.filter((section) => section.explicitDomain).slice(0, 40), + }; + }); +} + +export function validateSpecificationEvidence(specifications = [], repositoryRoot) { + const problems = []; + for (const specification of specifications) { + const path = specification.absolutePath + ?? (isAbsolute(specification.path) ? specification.path : resolve(repositoryRoot, specification.path)); + if (!existsSync(path)) { + problems.push(`specification is missing: ${specification.path}`); + continue; + } + const actual = digest(readFileSync(path)); + if (actual !== specification.sha256) problems.push(`specification changed after proposal: ${specification.path}`); + } + return problems; +} diff --git a/src/self-host/service.mjs b/src/self-host/service.mjs index 6501414..ae11ebb 100644 --- a/src/self-host/service.mjs +++ b/src/self-host/service.mjs @@ -9,6 +9,7 @@ import { torchDataHome } from '../kernel/paths.mjs'; export const CANDIDATE_ACCEPTANCE_SCENARIOS = Object.freeze([ 'init-analyze', + 'spec-aware-design', 'review-install-roster', 'branches-worktrees', 'runtime-identities', diff --git a/test/fleet/cli-scenarios.test.mjs b/test/fleet/cli-scenarios.test.mjs index 8fbc4e7..22bfdd9 100644 --- a/test/fleet/cli-scenarios.test.mjs +++ b/test/fleet/cli-scenarios.test.mjs @@ -32,6 +32,7 @@ function releaseCandidate(root, version) { writeFileSync(join(candidateRoot, 'bin', 'torch.mjs'), '#!/usr/bin/env node\n'); writeFileSync(join(candidateRoot, 'acceptance.mjs'), `console.log(${JSON.stringify([ 'SCN-init-read-only', 'SCN-cli-domain-review', 'SCN-worktree-bootstrap', 'SCN-mixed-runtime', + 'SCN-spec-fleet-design', 'SCN-cli-spec-design', '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/spec-design.test.mjs b/test/fleet/spec-design.test.mjs new file mode 100644 index 0000000..9cdc5eb --- /dev/null +++ b/test/fleet/spec-design.test.mjs @@ -0,0 +1,96 @@ +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 { analyzeRepository } from '../../src/kernel/analyze.mjs'; +import { proposeDomains, validateApprovedProposal } from '../../src/kernel/domains.mjs'; +import { inspectRepository } from '../../src/kernel/git.mjs'; +import { inspectSpecifications } from '../../src/kernel/specifications.mjs'; + +const CLI = new URL('../../bin/torch.mjs', import.meta.url).pathname; + +function repositoryFixture() { + const root = mkdtempSync(join(tmpdir(), 'torch-spec-project-')); + 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, 'README.md'), '# New project\n'); + execFileSync('git', ['-C', root, 'add', '.']); + execFileSync('git', ['-C', root, 'commit', '-m', 'empty project shell']); + return root; +} + +function specificationFixture() { + const directory = mkdtempSync(join(tmpdir(), 'torch-spec-input-')); + const path = join(directory, 'garden-product.md'); + writeFileSync(path, `# Garden Planner Product Specification + +## Client experience + +- The client must let a household plan beds and seasonal planting. +- The interface should remain useful offline. + +## Plant data service + +- The service must maintain the crop catalog and planting rules. +- Data changes require provenance. + +## Quality and release + +- Testing must cover saved plans and offline recovery. +- Release operations require owner approval. +`); + return path; +} + +test('SCN-spec-fleet-design: a prose-only project spec produces reviewable responsibilities without invented paths', () => { + const root = repositoryFixture(); + const specPath = specificationFixture(); + const repository = inspectRepository(root); + const before = execFileSync('git', ['-C', root, 'status', '--porcelain'], { encoding: 'utf8' }); + const specifications = inspectSpecifications([specPath], { repositoryRoot: root }); + const proposal = proposeDomains({ + repository, + analysis: analyzeRepository(repository, { specifications }), + }); + assert.deepEqual(proposal.domains.map((domain) => domain.id), [ + 'client-experience', 'plant-data-service', 'quality-and-release', + ]); + assert.equal(proposal.domains.every((domain) => domain.scope.length > 0), true); + assert.equal(proposal.domains.every((domain) => domain.owned_paths.length === 0), true); + assert.equal(proposal.domains.every((domain) => domain.design_status === 'needs-owner-path-review'), true); + assert.equal(proposal.specifications[0].sha256.length, 64); + assert.equal(execFileSync('git', ['-C', root, 'status', '--porcelain'], { encoding: 'utf8' }), before); + + proposal.review = { + status: 'approved', reviewedAt: '2026-09-27T00:00:00Z', reviewedBy: 'fixture-owner', notes: [], + }; + proposal.domains.forEach((domain) => { domain.owned_paths = [`planned/${domain.id}/**`]; }); + validateApprovedProposal({ proposal, repository }); + writeFileSync(specPath, `${readFileSync(specPath, 'utf8')}\n## Security\n\n- Security must protect household data.\n`); + assert.throws( + () => validateApprovedProposal({ proposal, repository }), + (error) => error.code === 'PROPOSAL_NOT_APPROVED' + && error.details.some((detail) => detail.startsWith('specification changed after proposal:')), + ); +}); + +test('SCN-cli-spec-design: startup can inspect a selected repository and external spec from another directory', () => { + const root = repositoryFixture(); + const specPath = specificationFixture(); + const launchDirectory = mkdtempSync(join(tmpdir(), 'torch-spec-launch-')); + const output = join(launchDirectory, 'fleet-proposal.json'); + const result = spawnSync(process.execPath, [ + CLI, 'design', '--repo', root, '--spec', specPath, '--output', output, '--json', + ], { cwd: launchDirectory, encoding: 'utf8' }); + assert.equal(result.status, 0, result.stderr || result.stdout); + const response = JSON.parse(result.stdout); + assert.equal(response.proposal.review.status, 'pending'); + assert.equal(response.proposal.repository.root, root); + assert.equal(response.proposal.specifications[0].path, specPath); + assert.equal(response.proposal.domains.length, 3); + assert.equal(existsSync(join(root, '.torch')), false); + assert.equal(execFileSync('git', ['-C', root, 'status', '--porcelain'], { encoding: 'utf8' }), ''); +});