Add spec-aware fleet design startup

This commit is contained in:
thePR0M3TH3AN committed 2026-09-27 16:56:39 -04:00
1 parent c2af3917f5
commit 87001555ea
10 files changed
+362 -9

No files matched your search

+25
View File
@@ -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:
+21
View File
@@ -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;
+1
View File
@@ -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",
+31 -8
View File
@@ -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 <path>] [--json]
torch init [--repo <path>] [--spec <path>] [--json]
torch analyze [--repo <path>] [--spec <path>] [--json]
torch design [--repo <path>] [--spec <path>] [--output <path>] [--json]
torch domains [--repo <path>] [--spec <path>] [--output <path>] [--json]
torch install --proposal <path> [--dry-run] [--yes] [--json]
torch worktrees [--parent <path>] [--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) {
+2 -1
View File
@@ -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;
+55
View File
@@ -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 ?? '<missing>'}`);
+129
View File
@@ -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;
}
+1
View File
@@ -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',
+1
View File
@@ -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',
+96
View File
@@ -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', '[email protected]']);
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' }), '');
});