mirror of
https://github.com/PR0M3TH3AN/TORCH.git
synced 2026-11-01 10:21:53 +00:00
Add spec-aware fleet design startup
This commit is contained in:
1 parent
c2af3917f5
commit
87001555ea
10 files changed
+362
-9
No files matched your search
@@ -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:
|
||||
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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
@@ -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) {
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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>'}`);
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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',
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -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' }), '');
|
||||
});
|
||||
Reference in new issue
Block a user