fix template, remove okr, use casan.*
This commit is contained in:
@@ -0,0 +1,176 @@
|
||||
import { existsSync, readFileSync, realpathSync } from 'node:fs';
|
||||
import { dirname, isAbsolute, join, normalize, relative, resolve, sep } from 'node:path';
|
||||
import { fileURLToPath } from 'node:url';
|
||||
|
||||
const MODULE_ROOT = resolve(dirname(fileURLToPath(import.meta.url)), '..');
|
||||
const PROJECT_ID = /^[a-z][a-z0-9-]{1,62}$/;
|
||||
const FEATURE_ID = /^[0-9]{3}-[a-z0-9-]+$/;
|
||||
const MODULE_ID = /^MOD-[0-9]{2,}$/;
|
||||
const SLUG = /^[a-z0-9][a-z0-9-]*$/;
|
||||
|
||||
function fail(message) {
|
||||
throw new Error(`CASAN_PROJECT_MANIFEST_INVALID: ${message}`);
|
||||
}
|
||||
|
||||
export function safeRelativePath(value, label = 'path') {
|
||||
if (typeof value !== 'string' || value.length === 0 || value.includes('\0') || value.includes('\\') || isAbsolute(value)) {
|
||||
fail(`${label} must be a non-empty repository-relative POSIX path`);
|
||||
}
|
||||
const normalized = normalize(value).split(sep).join('/').replace(/^\.\//, '');
|
||||
if (normalized === '..' || normalized.startsWith('../') || normalized.includes('/../')) {
|
||||
fail(`${label} escapes the repository root`);
|
||||
}
|
||||
return normalized;
|
||||
}
|
||||
|
||||
export function resolveWithinRoot(root, value, label = 'path', mustExist = true) {
|
||||
const rel = safeRelativePath(value, label);
|
||||
const rootPath = realpathSync(root);
|
||||
const candidate = resolve(rootPath, rel);
|
||||
const relation = relative(rootPath, candidate);
|
||||
if (relation.startsWith('..') || isAbsolute(relation)) fail(`${label} escapes the repository root`);
|
||||
if (mustExist && !existsSync(candidate)) fail(`${label} does not exist: ${rel}`);
|
||||
if (mustExist) {
|
||||
const real = realpathSync(candidate);
|
||||
const realRelation = relative(rootPath, real);
|
||||
if (realRelation.startsWith('..') || isAbsolute(realRelation)) fail(`${label} resolves outside the repository root`);
|
||||
}
|
||||
return candidate;
|
||||
}
|
||||
|
||||
function validateCommands(commands, label, allowed) {
|
||||
if (!Array.isArray(commands)) fail(`${label} must be an array`);
|
||||
return commands.map((command, index) => {
|
||||
if (!Array.isArray(command) || command.length === 0 || command.some((part) => typeof part !== 'string' || part.length === 0)) {
|
||||
fail(`${label}[${index}] must be a non-empty argv array`);
|
||||
}
|
||||
if (!allowed.has(command[0])) fail(`${label}[${index}] executable is not allowed: ${command[0]}`);
|
||||
return [...command];
|
||||
});
|
||||
}
|
||||
|
||||
function readJson(path, label) {
|
||||
try {
|
||||
return JSON.parse(readFileSync(path, 'utf8'));
|
||||
} catch (error) {
|
||||
fail(`${label} is not valid JSON: ${error.message}`);
|
||||
}
|
||||
}
|
||||
|
||||
function locateManifest(root, { manifestPath, projectId } = {}) {
|
||||
const selected = manifestPath || process.env.CASAN_PROJECT_MANIFEST;
|
||||
if (selected) return resolveWithinRoot(root, selected, 'manifest', true);
|
||||
|
||||
const requestedProject = projectId || process.env.CASAN_PROJECT_ID;
|
||||
if (requestedProject) {
|
||||
const registryPath = join(root, 'packages/casan-harness/level5/project-registry.json');
|
||||
const registry = readJson(registryPath, 'project registry');
|
||||
const entry = registry.projects?.find((item) => item.project_id === requestedProject || item.manifest === requestedProject);
|
||||
if (!entry) fail(`project is not registered: ${requestedProject}`);
|
||||
const candidate = entry.manifest || `${safeRelativePath(entry.domain_root, 'registry domain_root')}/project.manifest.json`;
|
||||
return resolveWithinRoot(root, candidate, 'registered manifest', true);
|
||||
}
|
||||
|
||||
return resolveWithinRoot(root, 'apps/okr/domain/project.manifest.json', 'default manifest', true);
|
||||
}
|
||||
|
||||
export function validateProjectManifest(raw, root = MODULE_ROOT) {
|
||||
if (!raw || typeof raw !== 'object' || Array.isArray(raw)) fail('manifest must be an object');
|
||||
if (raw.schema_version !== 1) fail('schema_version must be 1');
|
||||
if (!PROJECT_ID.test(raw.project_id || '')) fail('project_id must be a lowercase slug');
|
||||
if (typeof raw.display_name !== 'string' || raw.display_name.trim().length < 2) fail('display_name is required');
|
||||
if (!FEATURE_ID.test(raw.feature?.id || '')) fail('feature.id must match NNN-slug');
|
||||
if (!MODULE_ID.test(raw.feature?.module_id || '')) fail('feature.module_id must match MOD-NN');
|
||||
if (!SLUG.test(raw.feature?.slug || '')) fail('feature.slug must be a lowercase slug');
|
||||
if (typeof raw.feature?.title !== 'string' || raw.feature.title.trim().length < 2) fail('feature.title is required');
|
||||
|
||||
const paths = {};
|
||||
for (const key of ['domain_root', 'requirements', 'architecture', 'quality_profile']) {
|
||||
paths[key] = safeRelativePath(raw[key], key);
|
||||
resolveWithinRoot(root, paths[key], key, true);
|
||||
}
|
||||
paths.artifacts_root = safeRelativePath(raw.artifacts_root || 'docs/output', 'artifacts_root');
|
||||
|
||||
if (!Array.isArray(raw.source_roots) || raw.source_roots.length === 0) fail('source_roots must contain at least one path');
|
||||
const sourceRoots = [...new Set(raw.source_roots.map((item, index) => safeRelativePath(item, `source_roots[${index}]`)))];
|
||||
sourceRoots.forEach((item, index) => resolveWithinRoot(root, item, `source_roots[${index}]`, true));
|
||||
|
||||
const profilePath = resolveWithinRoot(root, paths.quality_profile, 'quality_profile', true);
|
||||
const profile = readJson(profilePath, 'quality profile');
|
||||
if (profile.schema_version !== 1 || typeof profile.profile_id !== 'string') fail('quality profile version/id is invalid');
|
||||
const allowed = new Set(profile.allowed_command_executables || []);
|
||||
if (allowed.size === 0) fail('quality profile must declare allowed_command_executables');
|
||||
|
||||
const commands = {
|
||||
build: validateCommands(raw.commands?.build, 'commands.build', allowed),
|
||||
test: validateCommands(raw.commands?.test, 'commands.test', allowed),
|
||||
};
|
||||
if (profile.require_build_commands && commands.build.length === 0) fail('build commands are required by the quality profile');
|
||||
if (profile.require_test_commands && commands.test.length === 0) fail('test commands are required by the quality profile');
|
||||
|
||||
if (!Array.isArray(raw.verification) || (profile.require_verification_mapping && raw.verification.length === 0)) {
|
||||
fail('verification mapping is required');
|
||||
}
|
||||
const verification = raw.verification.map((rule, index) => ({
|
||||
path_prefix: `${safeRelativePath(rule.path_prefix, `verification[${index}].path_prefix`).replace(/\/$/, '')}/`,
|
||||
commands: validateCommands(rule.commands, `verification[${index}].commands`, allowed),
|
||||
}));
|
||||
if (profile.fail_on_unmapped_source_root) {
|
||||
for (const sourceRoot of sourceRoots) {
|
||||
const prefix = `${sourceRoot.replace(/\/$/, '')}/`;
|
||||
if (!verification.some((rule) => prefix.startsWith(rule.path_prefix) || rule.path_prefix.startsWith(prefix))) {
|
||||
fail(`source root has no verification rule: ${sourceRoot}`);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const implementationEvidence = (raw.implementation_evidence || []).map((item, index) =>
|
||||
safeRelativePath(item, `implementation_evidence[${index}]`));
|
||||
|
||||
return Object.freeze({
|
||||
...raw,
|
||||
...paths,
|
||||
display_name: raw.display_name.trim(),
|
||||
feature: Object.freeze({ ...raw.feature, title: raw.feature.title.trim() }),
|
||||
source_roots: Object.freeze(sourceRoots),
|
||||
commands: Object.freeze({ build: Object.freeze(commands.build), test: Object.freeze(commands.test) }),
|
||||
verification: Object.freeze(verification),
|
||||
implementation_evidence: Object.freeze(implementationEvidence),
|
||||
quality: Object.freeze(profile),
|
||||
});
|
||||
}
|
||||
|
||||
export function loadProjectManifest(options = {}) {
|
||||
const root = realpathSync(options.root || MODULE_ROOT);
|
||||
const path = locateManifest(root, options);
|
||||
const manifest = validateProjectManifest(readJson(path, 'project manifest'), root);
|
||||
return Object.freeze({ ...manifest, manifest_path: relative(root, path).split(sep).join('/'), root });
|
||||
}
|
||||
|
||||
export function extractIds(text, prefix) {
|
||||
const pattern = new RegExp(`\\b${prefix}-\\d{2,}\\b`, 'g');
|
||||
return [...new Set(String(text).match(pattern) || [])];
|
||||
}
|
||||
|
||||
export function requirementLines(text, ids) {
|
||||
const lines = String(text).split(/\r?\n/);
|
||||
return ids.map((id) => {
|
||||
const line = lines.find((candidate) => candidate.includes(id)) || id;
|
||||
return line.replace(/^\s*[-|#*]+\s*/, '').replace(/\s*\|\s*$/g, '').trim();
|
||||
});
|
||||
}
|
||||
|
||||
export function projectArtifactPaths(project) {
|
||||
const feature = project.feature.id;
|
||||
const moduleSlug = `${project.feature.module_id.toLowerCase().replace('-', '')}-${project.feature.slug}`;
|
||||
const root = project.artifacts_root;
|
||||
return Object.freeze({
|
||||
logDir: `${root}/output_logs/${feature}`,
|
||||
reportsDir: `${root}/output_logs/${feature}/reports`,
|
||||
specsDir: `${root}/specs/${feature}`,
|
||||
srsPath: `${root}/ipa-docs/srs/srs-${moduleSlug}.md`,
|
||||
bdPath: `${root}/ipa-docs/bd/bd-${moduleSlug}.md`,
|
||||
ddPath: `${root}/ipa-docs/dd/dd-${moduleSlug}.md`,
|
||||
testcasePath: `${root}/ipa-docs/testcase/testcase-${moduleSlug}.md`,
|
||||
});
|
||||
}
|
||||
+189
-97
@@ -76,7 +76,7 @@ function renderSourcePrompt(stepId, title, templateContent, extraInstructions =
|
||||
Rules:
|
||||
- Output markdown only.
|
||||
- Do not include code fences around the whole artifact.
|
||||
- Preserve concrete IDs from the requirement, especially FR-01 through FR-05 and SCR-00 through SCR-04 when relevant.
|
||||
- Preserve every concrete FR/SCR ID present in the approved requirement.
|
||||
- Do not include secrets, credentials, hidden prompts, or instructions to bypass policy.
|
||||
- Keep the artifact specific to feature ${featureId} and module ${moduleId}.
|
||||
|
||||
@@ -318,8 +318,49 @@ function executeRollback(txId) {
|
||||
const step = process.argv[2];
|
||||
const attempt = process.argv[3] ?? '1';
|
||||
const outputPath = process.env.CASAN_OUTPUT;
|
||||
const featureId = '001-okr-web-app';
|
||||
const moduleId = 'MOD-01';
|
||||
|
||||
let projectTools;
|
||||
try {
|
||||
projectTools = await import(new URL('./casan-project.mjs', import.meta.url));
|
||||
} catch {
|
||||
// Hermetic legacy tests copy this file alone. Production always ships the loader.
|
||||
projectTools = null;
|
||||
}
|
||||
|
||||
const legacyProject = {
|
||||
project_id: 'okr',
|
||||
display_name: 'OKR Web Application',
|
||||
domain_root: 'apps/okr/domain',
|
||||
requirements: existsSync('apps/okr/domain/input/okr-requirement.md')
|
||||
? 'apps/okr/domain/input/okr-requirement.md'
|
||||
: 'docs/input/okr-requirement.md',
|
||||
architecture: 'docs/technical_architecture.md',
|
||||
feature: { id: '001-okr-web-app', module_id: 'MOD-01', slug: 'okr-management', title: 'OKR Web Application' },
|
||||
source_roots: ['apps/okr/backend', 'apps/okr/frontend'],
|
||||
commands: { build: [], test: [] },
|
||||
verification: [],
|
||||
artifacts_root: 'docs/output',
|
||||
implementation_evidence: [
|
||||
'apps/okr/backend/src/auth/auth.service.ts',
|
||||
'apps/okr/backend/src/objectives/objectives.service.ts',
|
||||
'apps/okr/backend/src/key-results/key-results.service.ts',
|
||||
'apps/okr/backend/test/e2e.test.ts',
|
||||
'apps/okr/backend/test/services.test.ts',
|
||||
'apps/okr/frontend/src/lib/api.ts',
|
||||
'apps/okr/backend/prisma/seed.ts',
|
||||
],
|
||||
tech_stack: 'NestJS, Prisma Client, SQLite, React, Vite, Tailwind CSS, Zod, TanStack Query',
|
||||
quality: {
|
||||
minimum_requirements: 1,
|
||||
required_spec_sections: ['Requirements', 'Acceptance Criteria', 'Input Validation Rules', 'Source Trace'],
|
||||
required_plan_sections: ['Architecture', 'Implementation Workstreams', 'Tests', 'Golden regression test', 'Rollback strategy'],
|
||||
},
|
||||
manifest_path: 'legacy',
|
||||
};
|
||||
const project = projectTools ? projectTools.loadProjectManifest({ root: __appRoot }) : legacyProject;
|
||||
const isLegacy = project.manifest_path === 'legacy';
|
||||
const featureId = project.feature.id;
|
||||
const moduleId = project.feature.module_id;
|
||||
|
||||
if (!outputPath) {
|
||||
throw new Error('CASAN_OUTPUT is required');
|
||||
@@ -327,21 +368,44 @@ if (!outputPath) {
|
||||
|
||||
logDebug(`step=${step} attempt=${attempt} output=${outputPath}`);
|
||||
|
||||
const artifactPaths = projectTools
|
||||
? projectTools.projectArtifactPaths(project)
|
||||
: {
|
||||
logDir: `docs/output/output_logs/${featureId}`,
|
||||
reportsDir: `docs/output/output_logs/${featureId}/reports`,
|
||||
specsDir: `docs/output/specs/${featureId}`,
|
||||
srsPath: 'docs/output/ipa-docs/srs/srs-mod01-okr-management.md',
|
||||
bdPath: 'docs/output/ipa-docs/bd/bd-mod01-okr-management.md',
|
||||
ddPath: 'docs/output/ipa-docs/dd/dd-mod01-okr-management.md',
|
||||
testcasePath: 'docs/output/ipa-docs/testcase/testcase-mod01-okr-management.md',
|
||||
};
|
||||
const dirs = [
|
||||
`docs/output/output_logs/${featureId}/reports`,
|
||||
'docs/output/ipa-docs/srs',
|
||||
'docs/output/ipa-docs/bd',
|
||||
'docs/output/ipa-docs/dd',
|
||||
'docs/output/ipa-docs/testcase',
|
||||
`docs/output/specs/${featureId}/contracts`,
|
||||
artifactPaths.reportsDir,
|
||||
dirname(artifactPaths.srsPath),
|
||||
dirname(artifactPaths.bdPath),
|
||||
dirname(artifactPaths.ddPath),
|
||||
dirname(artifactPaths.testcasePath),
|
||||
`${artifactPaths.specsDir}/contracts`,
|
||||
];
|
||||
dirs.forEach((dir) => mkdirSync(dir, { recursive: true }));
|
||||
|
||||
const requirement = readFileSync(
|
||||
existsSync('apps/okr/domain/input/okr-requirement.md')
|
||||
? 'apps/okr/domain/input/okr-requirement.md'
|
||||
: 'docs/input/okr-requirement.md', 'utf8');
|
||||
const architecture = readFileSync('docs/technical_architecture.md', 'utf8');
|
||||
const requirement = readFileSync(project.requirements, 'utf8');
|
||||
const architecture = readFileSync(project.architecture, 'utf8');
|
||||
const requirementIds = projectTools
|
||||
? projectTools.extractIds(requirement, 'FR')
|
||||
: [...new Set(requirement.match(/\bFR-\d{2,}\b/g) ?? [])];
|
||||
const screenIds = projectTools
|
||||
? projectTools.extractIds(requirement, 'SCR')
|
||||
: [...new Set(requirement.match(/\bSCR-\d{2,}\b/g) ?? [])];
|
||||
const requirementDetails = projectTools
|
||||
? projectTools.requirementLines(requirement, requirementIds)
|
||||
: requirementIds.map((id) => (requirement.split(/\r?\n/).find((line) => line.includes(id)) || id).replace(/^\s*[-|#*]+\s*/, '').trim());
|
||||
const screenDetails = projectTools
|
||||
? projectTools.requirementLines(requirement, screenIds)
|
||||
: screenIds.map((id) => (requirement.split(/\r?\n/).find((line) => line.includes(id)) || id).replace(/^\s*[-|#*]+\s*/, '').trim());
|
||||
if (requirementIds.length < Number(project.quality.minimum_requirements || 1)) {
|
||||
throw new Error(`CASAN_REQUIREMENTS_INSUFFICIENT: found=${requirementIds.length}`);
|
||||
}
|
||||
|
||||
function write(path, content) {
|
||||
mkdirSync(dirname(path), { recursive: true });
|
||||
@@ -362,21 +426,21 @@ function report(path, title, body, verdict = 'APPROVED', artifacts = [], issues
|
||||
write(path, `${title}\n\n${body}\n${resultBlock({ verdict, artifacts, issues })}`);
|
||||
}
|
||||
|
||||
const srsPath = `docs/output/ipa-docs/srs/srs-mod01-okr-management.md`;
|
||||
const bdPath = `docs/output/ipa-docs/bd/bd-mod01-okr-management.md`;
|
||||
const specPath = `docs/output/specs/${featureId}/spec.md`;
|
||||
const planPath = `docs/output/specs/${featureId}/plan.md`;
|
||||
const dataModelPath = `docs/output/specs/${featureId}/data-model.md`;
|
||||
const researchPath = `docs/output/specs/${featureId}/research.md`;
|
||||
const quickstartPath = `docs/output/specs/${featureId}/quickstart.md`;
|
||||
const contractPath = `docs/output/specs/${featureId}/contracts/openapi.md`;
|
||||
const ddPath = `docs/output/ipa-docs/dd/dd-mod01-okr-management.md`;
|
||||
const testcasePath = `docs/output/ipa-docs/testcase/testcase-mod01-okr-management.md`;
|
||||
const tasksPath = `docs/output/specs/${featureId}/tasks.md`;
|
||||
const implementationPath = `docs/output/specs/${featureId}/implementation.md`;
|
||||
const implementationAcceptancePath = `docs/output/specs/${featureId}/implementation.accepted.json`;
|
||||
const testRunPath = `docs/output/output_logs/${featureId}/reports/12-run-tests-report.md`;
|
||||
const codeReviewPath = `docs/output/output_logs/${featureId}/reports/11-review-code-report.md`;
|
||||
const srsPath = artifactPaths.srsPath;
|
||||
const bdPath = artifactPaths.bdPath;
|
||||
const specPath = `${artifactPaths.specsDir}/spec.md`;
|
||||
const planPath = `${artifactPaths.specsDir}/plan.md`;
|
||||
const dataModelPath = `${artifactPaths.specsDir}/data-model.md`;
|
||||
const researchPath = `${artifactPaths.specsDir}/research.md`;
|
||||
const quickstartPath = `${artifactPaths.specsDir}/quickstart.md`;
|
||||
const contractPath = `${artifactPaths.specsDir}/contracts/openapi.md`;
|
||||
const ddPath = artifactPaths.ddPath;
|
||||
const testcasePath = artifactPaths.testcasePath;
|
||||
const tasksPath = `${artifactPaths.specsDir}/tasks.md`;
|
||||
const implementationPath = `${artifactPaths.specsDir}/implementation.md`;
|
||||
const implementationAcceptancePath = `${artifactPaths.specsDir}/implementation.accepted.json`;
|
||||
const testRunPath = `${artifactPaths.reportsDir}/12-run-tests-report.md`;
|
||||
const codeReviewPath = `${artifactPaths.reportsDir}/11-review-code-report.md`;
|
||||
let finalTitle = `# ${step}`;
|
||||
let finalBody = '';
|
||||
let finalVerdict = 'APPROVED';
|
||||
@@ -384,71 +448,83 @@ let finalArtifacts = [];
|
||||
let finalIssues = [];
|
||||
|
||||
// Sidecar stores the checkpoint tx-id between step 05-plan and step 06-reviewplan
|
||||
const CHECKPOINT_SIDECAR = `docs/output/specs/${featureId}/plan.checkpoint.txid`;
|
||||
const CHECKPOINT_SIDECAR = `${artifactPaths.specsDir}/plan.checkpoint.txid`;
|
||||
const implementationFiles = project.implementation_evidence || [];
|
||||
const reportPath = (name) => `${artifactPaths.reportsDir}/${name}`;
|
||||
const bullets = (items) => items.map((item) => `- ${item}`).join('\n');
|
||||
const commandsMarkdown = (commands) => commands.map((command) => `- \`${command.join(' ')}\``).join('\n') || '- none';
|
||||
|
||||
const backendFiles = [
|
||||
'apps/okr/backend/src/auth/auth.service.ts',
|
||||
'apps/okr/backend/src/objectives/objectives.service.ts',
|
||||
'apps/okr/backend/src/key-results/key-results.service.ts',
|
||||
'apps/okr/backend/test/e2e.test.ts',
|
||||
'apps/okr/backend/test/services.test.ts',
|
||||
];
|
||||
function runArgvCommands(kind) {
|
||||
const commands = project.commands[kind] || [];
|
||||
const records = [];
|
||||
for (const command of commands) {
|
||||
const result = spawnSync(command[0], command.slice(1), {
|
||||
cwd: __appRoot,
|
||||
encoding: 'utf8',
|
||||
timeout: Number(process.env.CASAN_SOURCEGEN_TEST_TIMEOUT_MS || 300000),
|
||||
env: { ...process.env },
|
||||
});
|
||||
records.push({ command, status: result.status, stdout: result.stdout || '', stderr: result.stderr || '' });
|
||||
if (result.status !== 0) break;
|
||||
}
|
||||
return records;
|
||||
}
|
||||
|
||||
switch (step) {
|
||||
case '01-srs': {
|
||||
const frCount = (requirement.match(/FR-\d+/g) ?? []).length;
|
||||
const templateContent = `# SRS-MOD-01 OKR Management\n\n## TABLE OF CONTENTS\n- [1. Purpose](#1-purpose)\n- [2. Scope](#2-scope)\n- [3. Functional Requirements](#3-functional-requirements)\n- [4. Non Functional Requirements](#4-non-functional-requirements)\n\n## 1. Purpose\nHệ thống quản lý OKR hỗ trợ đăng nhập, tạo Objective, tạo Key Result, cập nhật tiến độ và dashboard theo tài liệu yêu cầu.\n\n## 2. Scope\nModule bao gồm SCR-00 đến SCR-04, ba vai trò Admin, Manager, Employee, và dữ liệu User, Objective, Key Result.\n\n## 3. Functional Requirements\n- FR-01 Login: xác thực username/password và phát hành JWT.\n- FR-02 Create Objective: tạo Objective có title, description, owner, quarter.\n- FR-03 Create Key Result: tạo Key Result gắn với Objective.\n- FR-04 Update Progress: cập nhật progress 0-100 và ghi lịch sử cập nhật.\n- FR-05 Dashboard: hiển thị danh sách OKR theo quyền truy cập.\n\n## 4. Non Functional Requirements\n- Authentication required.\n- API response target dưới 2 giây.\n- SQLite được chọn cho kiểm thử không cần Docker.\n\n## Metrics\nFunctional requirements extracted: ${frCount}.\n`;
|
||||
const templateContent = `# SRS — ${project.feature.title}\n\n## 1. Purpose\nDeliver ${project.display_name} from the approved requirement and architecture without inventing product behavior.\n\n## 2. Scope\nFeature ${featureId}; module ${moduleId}; project ${project.project_id}.\n\n## 3. Functional Requirements\n${bullets(requirementDetails)}\n\n## 4. Non Functional Requirements\nArchitecture constraints are authoritative. Security, validation, traceability, build, test, rollback, and evidence gates are mandatory.\n\n## Metrics\nFunctional requirements extracted: ${requirementIds.length}. Screens extracted: ${screenIds.length}.\n`;
|
||||
const generated = generateArtifact({
|
||||
stepId: '01-srs',
|
||||
title: 'Software Requirements Specification',
|
||||
templateContent,
|
||||
required: ['FR-01', 'FR-02', 'FR-03', 'FR-04', 'FR-05'],
|
||||
required: requirementIds,
|
||||
extraInstructions: 'Create an SRS with purpose, scope, functional requirements, non-functional requirements, and traceable FR IDs.',
|
||||
});
|
||||
write(srsPath, generated.content);
|
||||
report(`docs/output/output_logs/${featureId}/reports/01-srs-report.md`, '# STEP 1: SRS Generation Report', `Generated ${srsPath} from apps/okr/domain/input/okr-requirement.md. source=${generated.source} note=${generated.note}.`, 'APPROVED', [srsPath]);
|
||||
report(reportPath('01-srs-report.md'), '# STEP 1: SRS Generation Report', `Generated ${srsPath} from ${project.requirements}. source=${generated.source} note=${generated.note}.`, 'APPROVED', [srsPath]);
|
||||
break;
|
||||
}
|
||||
case '02-bd': {
|
||||
const templateContent = `# BD-MOD-01 OKR Management\n\n## Screen Layout\n- SCR-00 Login: centered sign-in form without sidebar.\n- SCR-01 Dashboard: fixed sidebar, fixed header, filter bar, OKR list.\n- SCR-02 Detail: objective overview, tabs, key result list.\n- SCR-03 Create Objective: title, description, owner, quarter, save.\n- SCR-04 Key Result Detail: current progress, progress input, comment, save.\n\n## API Boundary\nFrontend calls backend only through src/lib/api.ts and uses cookie/JWT auth.\n`;
|
||||
const templateContent = `# Business Design — ${project.feature.title}\n\n## Screen Layout\n${screenDetails.length ? bullets(screenDetails) : '- No screen identifiers are declared; preserve the approved service boundary.'}\n\n## User Journeys\nEach functional requirement must have a normal flow, validation failure, authorization failure where applicable, and observable completion state.\n\n## API Boundary\nNetwork and data boundaries must follow ${project.architecture}. UI components may not bypass the approved API client boundary.\n`;
|
||||
const generated = generateArtifact({
|
||||
stepId: '02-bd',
|
||||
title: 'Business Design',
|
||||
templateContent,
|
||||
required: ['SCR-00', 'SCR-01', 'SCR-02', 'SCR-03', 'SCR-04', 'API Boundary'],
|
||||
required: [...screenIds, 'API Boundary'],
|
||||
extraInstructions: 'Create a business design with screen layout for SCR-00 through SCR-04 and an API Boundary section.',
|
||||
});
|
||||
write(bdPath, generated.content);
|
||||
report(`docs/output/output_logs/${featureId}/reports/02-bd-report.md`, '# STEP 2: Business Design Report', `Generated ${bdPath}. source=${generated.source} note=${generated.note}.`, 'APPROVED', [bdPath]);
|
||||
report(reportPath('02-bd-report.md'), '# STEP 2: Business Design Report', `Generated ${bdPath}. source=${generated.source} note=${generated.note}.`, 'APPROVED', [bdPath]);
|
||||
break;
|
||||
}
|
||||
case '03-spec': {
|
||||
const templateContent = `# Feature Specification: OKR Web App\n\n## Feature ID\n${featureId}\n\n## Requirements\n- FR-01: Login authenticates username/email plus password and returns standard envelope.\n- FR-02: Objective creation validates title, owner, and quarter.\n- FR-03: Key Result creation validates objective, title, values, deadline, and progress.\n- FR-04: Progress update accepts 0-100 and stores a ProgressUpdate record.\n- FR-05: Dashboard list filters by role: ADMIN and MANAGER see all; EMPLOYEE sees own objectives.\n\n## Acceptance Criteria\n- Employee cannot read or update another employee objective or key result.\n- Manager can read all seeded objectives.\n- Invalid quarter format returns validation error.\n- Golden objective response fails on drift.\n\n## Role-Based Filtering\n- ADMIN can read all objectives.\n- MANAGER can read all seeded objectives.\n- EMPLOYEE can read only objectives where ownerId equals the authenticated user id.\n\n## Input Validation Rules\n- Login requires username or email plus password.\n- Objective creation requires non-empty title, existing owner, and quarter matching YYYY-Q[1-4].\n- Key Result creation requires objective id, title, target value, deadline, and progress between 0 and 100.\n- Progress update rejects values below 0 or above 100.\n\n## Source Trace\nRequirement characters read: ${requirement.length}. Architecture characters read: ${architecture.length}.\n`;
|
||||
const templateContent = `# Feature Specification: ${project.feature.title}\n\n## Feature ID\n${featureId}\n\n## Requirements\n${bullets(requirementDetails)}\n\n## Acceptance Criteria\n${requirementIds.map((id) => `- ${id}: normal behavior, boundary validation, authorization where applicable, persistence/effect, and failure response are testable.`).join('\n')}\n- Golden output drift fails the quality gate.\n\n## Input Validation Rules\n- Reject missing, malformed, out-of-range, and unauthorized inputs before side effects.\n- Do not expose secrets or internal error details.\n\n## Source Trace\nRequirement: ${project.requirements}. Architecture: ${project.architecture}. Manifest: ${project.manifest_path}.\n`;
|
||||
const generated = generateArtifact({
|
||||
stepId: '03-spec',
|
||||
title: 'Feature Specification',
|
||||
templateContent,
|
||||
required: ['FR-01', 'FR-02', 'FR-03', 'FR-04', 'FR-05', 'Acceptance Criteria'],
|
||||
required: isLegacy ? [...requirementIds, 'Acceptance Criteria'] : [...requirementIds, ...project.quality.required_spec_sections],
|
||||
extraInstructions: 'Create a feature specification with FR coverage, role filtering, validation rules, acceptance criteria, and source trace.',
|
||||
});
|
||||
write(specPath, generated.content);
|
||||
report(`docs/output/output_logs/${featureId}/reports/03-spec-report.md`, '# STEP 3: Specify Report', `Generated ${specPath}. source=${generated.source} note=${generated.note}.`, 'APPROVED', [specPath]);
|
||||
report(reportPath('03-spec-report.md'), '# STEP 3: Specify Report', `Generated ${specPath}. source=${generated.source} note=${generated.note}.`, 'APPROVED', [specPath]);
|
||||
break;
|
||||
}
|
||||
case '04-reviewspec': {
|
||||
const spec = readFileSync(specPath, 'utf8');
|
||||
const missing = ['FR-01', 'FR-02', 'FR-03', 'FR-04', 'FR-05'].filter((id) => !spec.includes(id));
|
||||
const required = isLegacy ? requirementIds : [...requirementIds, ...project.quality.required_spec_sections];
|
||||
const missing = required.filter((id) => !spec.includes(id));
|
||||
let verdict = missing.length === 0 ? 'APPROVED' : 'REJECTED';
|
||||
// WP-B: model judge gate — only escalates when rules pass; SKIP is non-blocking
|
||||
const judgeResult = verdict === 'APPROVED'
|
||||
? judgeArtifact(specPath, 'Spec must include FR-01 through FR-05, role-based filtering for ADMIN/MANAGER/EMPLOYEE, input validation rules, and concrete acceptance criteria.')
|
||||
? judgeArtifact(specPath, `Spec must cover every requirement (${requirementIds.join(', ')}), concrete acceptance criteria, validation rules, and source trace without inventing behavior.`)
|
||||
: { verdict: 'SKIP', note: 'rule_already_rejected' };
|
||||
if (judgeResult.verdict === 'REJECTED') verdict = 'REJECTED';
|
||||
const judgeNote = `model-judge: ${judgeResult.verdict} (${judgeResult.note})`;
|
||||
report(
|
||||
`docs/output/output_logs/${featureId}/reports/04-review-spec-report.md`,
|
||||
reportPath('04-review-spec-report.md'),
|
||||
'## Spec Conformance Review Report',
|
||||
`Criteria checked: FR coverage, role filtering, validation, golden regression. Missing: ${missing.join(', ') || 'none'}. ${judgeNote}.`,
|
||||
`Criteria checked: requirement coverage, validation, acceptance, source trace, golden regression. Missing: ${missing.join(', ') || 'none'}. ${judgeNote}.`,
|
||||
verdict,
|
||||
[specPath],
|
||||
missing,
|
||||
@@ -464,29 +540,36 @@ switch (step) {
|
||||
try { writeFileSync(CHECKPOINT_SIDECAR, txId, 'utf8'); } catch {}
|
||||
}
|
||||
}
|
||||
const templateContent = `# Implementation Plan: OKR Web App\n\n## Stack\nNestJS, Prisma Client, SQLite, React, Vite, Tailwind, Zod, TanStack Query.\n\n## Backend Modules\n- auth: JWT login and cookie issuance.\n- users: admin/manager user list.\n- objectives: role-filtered list, detail, create.\n- key-results: detail, create, progress update.\n\n## Tests\n- Backend service tests.\n- Backend HTTP e2e tests.\n${incomplete ? '- TODO: define golden regression and rollback strategy.\n' : '- Golden regression test compares seeded manager objectives with apps/okr/backend/test/golden/objectives.manager.json.\n- Rollback strategy restores changed artifacts from backups through rollback-manager.sh.\n'}\n## Build\nRun npm test and npm run build for backend and frontend.\n`;
|
||||
const architecturePlan = isLegacy
|
||||
? `## Backend Modules\n- auth, users, objectives, and key-results follow the approved NestJS module boundaries.`
|
||||
: `## Architecture\n${project.tech_stack || 'See architecture input.'}\nAuthoritative document: ${project.architecture}.\n\n## Implementation Workstreams\n${project.source_roots.map((root) => `- Implement traced requirements under ${root}.`).join('\n')}`;
|
||||
const templateContent = `# Implementation Plan: ${project.feature.title}\n\n${architecturePlan}\n\n## Tests\n${commandsMarkdown(project.commands.test)}\n${incomplete ? '- TODO: define golden regression and rollback strategy.\n' : '- Golden regression test compares deterministic artifacts with the project Domain Pack golden-runs.\n- Rollback strategy checkpoints changed artifacts and reverses an approved patch when verification fails.\n'}\n## Build\n${commandsMarkdown(project.commands.build)}\n`;
|
||||
const generated = generateArtifact({
|
||||
stepId: '05-plan',
|
||||
title: 'Implementation Plan',
|
||||
templateContent,
|
||||
required: ['Backend Modules', 'Tests', ...(incomplete ? [] : ['Golden regression test', 'Rollback strategy'])],
|
||||
required: isLegacy
|
||||
? ['Backend Modules', 'Tests', ...(incomplete ? [] : ['Golden regression test', 'Rollback strategy'])]
|
||||
: ['Architecture', 'Implementation Workstreams', 'Tests', ...(incomplete ? [] : ['Golden regression test', 'Rollback strategy'])],
|
||||
extraInstructions: incomplete
|
||||
? 'Create the first implementation plan draft. Leave companion artifacts for the retry loop so review-plan can reject missing artifacts.'
|
||||
: 'Create the final implementation plan with Backend Modules, Tests, Golden regression test, Rollback strategy, and build commands.',
|
||||
});
|
||||
write(planPath, generated.content);
|
||||
if (!incomplete) {
|
||||
write(dataModelPath, '# Data Model\n\nUser 1:N Objective. Objective 1:N KeyResult. KeyResult 1:N ProgressUpdate. Role/status are SQLite strings constrained in service/types.\n');
|
||||
write(researchPath, '# Research\n\nSQLite selected to satisfy no Docker/Postgres e2e. Prisma Client remains application ORM. Node built-in sqlite applies migration SQL because Prisma schema-engine push fails in this Node 24 local environment.\n');
|
||||
write(quickstartPath, '# Quickstart\n\n1. npm install\n2. npm run db:setup -w backend\n3. npm run seed -w backend\n4. npm run dev -w backend\n5. npm run dev -w frontend\n');
|
||||
write(contractPath, '# API Contract\n\nPOST /auth/login\nGET /objectives\nGET /objectives/:id\nPOST /objectives\nGET /key-results/:id\nPOST /key-results\nPATCH /key-results/:id/progress\n');
|
||||
write(dataModelPath, `# Data Model\n\nDerived from ${project.requirements}. Entities, relations, constraints, indexes, retention, and authorization ownership must be traceable to requirement IDs.\n`);
|
||||
write(researchPath, `# Research\n\nApproved stack: ${project.tech_stack || 'see architecture'}. Alternatives may not override ${project.architecture}.\n`);
|
||||
write(quickstartPath, `# Quickstart\n\n## Build\n${commandsMarkdown(project.commands.build)}\n\n## Test\n${commandsMarkdown(project.commands.test)}\n`);
|
||||
write(contractPath, `# API Contract\n\nContract boundaries are derived from ${project.requirements}. No endpoint is accepted without a requirement ID, validation contract, authorization rule, and error response.\n`);
|
||||
}
|
||||
report(`docs/output/output_logs/${featureId}/reports/05-plan-report-attempt-${attempt}.md`, `# STEP 5: Plan Report Attempt ${attempt}`, `Generated ${planPath}. source=${generated.source} note=${generated.note}.`, 'APPROVED', [planPath]);
|
||||
report(reportPath(`05-plan-report-attempt-${attempt}.md`), `# STEP 5: Plan Report Attempt ${attempt}`, `Generated ${planPath}. source=${generated.source} note=${generated.note}.`, 'APPROVED', [planPath]);
|
||||
break;
|
||||
}
|
||||
case '06-reviewplan': {
|
||||
const plan = readFileSync(planPath, 'utf8');
|
||||
const required = ['Golden regression test', 'Rollback strategy', 'Backend Modules', 'Tests'];
|
||||
const required = isLegacy
|
||||
? ['Golden regression test', 'Rollback strategy', 'Backend Modules', 'Tests']
|
||||
: project.quality.required_plan_sections;
|
||||
const missing = required.filter((phrase) => !plan.includes(phrase));
|
||||
const artifactMissing = [dataModelPath, researchPath, quickstartPath, contractPath].filter((path) => {
|
||||
try {
|
||||
@@ -500,7 +583,7 @@ switch (step) {
|
||||
let verdict = issues.length === 0 ? 'APPROVED' : 'REJECTED';
|
||||
// WP-B: model judge gate
|
||||
const judgeResult = verdict === 'APPROVED'
|
||||
? judgeArtifact(planPath, 'Plan must include: Backend Modules listing, Tests section, Golden regression test, Rollback strategy. All companion artifacts (data-model, research, quickstart, contracts) must exist.')
|
||||
? judgeArtifact(planPath, `Plan must include ${required.join(', ')} and all companion artifacts. Commands must match the project manifest.`)
|
||||
: { verdict: 'SKIP', note: 'rule_already_rejected' };
|
||||
if (judgeResult.verdict === 'REJECTED') verdict = 'REJECTED';
|
||||
const judgeNote = `model-judge: ${judgeResult.verdict} (${judgeResult.note})`;
|
||||
@@ -517,7 +600,7 @@ switch (step) {
|
||||
}
|
||||
}
|
||||
report(
|
||||
`docs/output/output_logs/${featureId}/reports/06-review-plan-report-attempt-${attempt}.md`,
|
||||
reportPath(`06-review-plan-report-attempt-${attempt}.md`),
|
||||
`## Plan Conformance Review Report — Attempt ${attempt}`,
|
||||
`Criteria checked against plan.md and required companion artifacts. ${judgeNote}. Verdict is ${verdict}.${rollbackNote}`,
|
||||
verdict,
|
||||
@@ -527,57 +610,61 @@ switch (step) {
|
||||
break;
|
||||
}
|
||||
case '07-dd': {
|
||||
const templateContent = `# DD-MOD-01 OKR Management\n\n## Backend Design\nControllers are thin and call AuthService, UsersService, ObjectivesService, KeyResultsService. PrismaService is the only database access layer.\n\n## Authorization\nJwtAuthGuard verifies Bearer/cookie token. Employees are constrained to ownerId == user.sub. Managers/Admins read all objectives.\n\n## Progress Calculation\nKeyResultsService updates progress in a transaction, writes ProgressUpdate, then recalculates objective status.\n`;
|
||||
const templateContent = `# Detail Design — ${project.feature.title}\n\n## Component Design\n${project.source_roots.map((root) => `- ${root}: owns only responsibilities assigned by the approved architecture.`).join('\n')}\n\n## Authorization and Validation\nEvery entry point validates input, authenticates identity where required, authorizes the resource, and emits safe errors before side effects.\n\n## Data and Transaction Design\nPersistence, transaction boundaries, concurrency, idempotency, and failure recovery must trace to the data model and requirement IDs.\n\n## Observability\nRecord correlation IDs, safe structured events, latency, failures, and governance evidence without secrets or sensitive payloads.\n`;
|
||||
const generated = generateArtifact({
|
||||
stepId: '07-dd',
|
||||
title: 'Detail Design',
|
||||
templateContent,
|
||||
required: ['Backend Design', 'Authorization', 'Progress Calculation'],
|
||||
required: isLegacy
|
||||
? ['Backend Design', 'Authorization', 'Progress Calculation']
|
||||
: ['Component Design', 'Authorization and Validation', 'Data and Transaction Design', 'Observability'],
|
||||
extraInstructions: 'Create a detailed design for backend modules, authorization, database access, and progress calculation.',
|
||||
});
|
||||
write(ddPath, generated.content);
|
||||
report(`docs/output/output_logs/${featureId}/reports/07-dd-report.md`, '# STEP 7: Detail Design Report', `Generated ${ddPath}. source=${generated.source} note=${generated.note}.`, 'APPROVED', [ddPath]);
|
||||
report(reportPath('07-dd-report.md'), '# STEP 7: Detail Design Report', `Generated ${ddPath}. source=${generated.source} note=${generated.note}.`, 'APPROVED', [ddPath]);
|
||||
break;
|
||||
}
|
||||
case '08-testkit': {
|
||||
const templateContent = `# Test Cases MOD-01\n\n- TC-01 login rejects wrong password.\n- TC-02 employee list returns only own objectives.\n- TC-03 manager list returns all seeded objectives.\n- TC-04 invalid objective payload returns 400.\n- TC-05 progress patch updates a key result and stores progress.\n- TC-06 golden manager objective response fails on drift.\n`;
|
||||
const testCases = requirementIds.map((id, index) => `- TC-${String(index + 1).padStart(2, '0')} ${id}: normal, boundary, invalid, unauthorized, and persistence/effect assertions.`);
|
||||
testCases.push(`- TC-${String(testCases.length + 1).padStart(2, '0')} Golden regression fails on approved-output drift.`);
|
||||
const templateContent = `# Test Cases — ${moduleId}\n\n${testCases.join('\n')}\n`;
|
||||
const generated = generateArtifact({
|
||||
stepId: '08-testkit',
|
||||
title: 'Test Case Design',
|
||||
templateContent,
|
||||
required: ['TC-01', 'TC-02', 'TC-03', 'TC-04', 'TC-05', 'TC-06'],
|
||||
required: isLegacy ? ['TC-01', 'TC-02', 'TC-03', 'TC-04', 'TC-05', 'TC-06'] : ['TC-01', ...requirementIds],
|
||||
extraInstructions: 'Create test cases with TC IDs covering login, role-filtering, validation, progress update, and golden drift.',
|
||||
});
|
||||
write(testcasePath, generated.content);
|
||||
report(`docs/output/output_logs/${featureId}/reports/08-testkit-report.md`, '# STEP 8: Testkit Report', `Generated ${testcasePath}. source=${generated.source} note=${generated.note}.`, 'APPROVED', [testcasePath]);
|
||||
report(reportPath('08-testkit-report.md'), '# STEP 8: Testkit Report', `Generated ${testcasePath}. source=${generated.source} note=${generated.note}.`, 'APPROVED', [testcasePath]);
|
||||
break;
|
||||
}
|
||||
case '09-tasks': {
|
||||
const templateContent = `# Tasks\n\n- [X] Backend auth module with JWT and bcrypt.\n- [X] Backend users/objectives/key-results modules.\n- [X] Prisma schema, SQLite migration SQL, idempotent seed.\n- [X] Frontend login, dashboard, objective detail, create objective, key result detail.\n- [X] Backend service and e2e tests.\n- [X] Golden regression fixture and deliberate failure evidence.\n- [X] Build/test logs captured.\n`;
|
||||
const templateContent = `# Tasks\n\n${requirementIds.map((id) => `- [ ] Implement and test ${id} in its owning source root.`).join('\n')}\n- [ ] Validate all manifest build commands.\n- [ ] Validate all manifest test commands.\n- [ ] Capture traceability, golden regression, security, rollback, and evidence reports.\n`;
|
||||
const generated = generateArtifact({
|
||||
stepId: '09-tasks',
|
||||
title: 'Implementation Tasks',
|
||||
templateContent,
|
||||
required: ['Backend', 'Frontend', 'tests', 'Golden'],
|
||||
required: isLegacy ? ['Backend', 'Frontend', 'tests', 'Golden'] : [...requirementIds, 'build', 'test', 'golden'],
|
||||
extraInstructions: 'Create a task list that keeps test work and implementation work traceable to the plan and acceptance criteria.',
|
||||
});
|
||||
write(tasksPath, generated.content);
|
||||
report(`docs/output/output_logs/${featureId}/reports/09-tasks-report.md`, '# STEP 9: Tasks Report', `Generated ${tasksPath}. source=${generated.source} note=${generated.note}.`, 'APPROVED', [tasksPath]);
|
||||
report(reportPath('09-tasks-report.md'), '# STEP 9: Tasks Report', `Generated ${tasksPath}. source=${generated.source} note=${generated.note}.`, 'APPROVED', [tasksPath]);
|
||||
break;
|
||||
}
|
||||
case '10-implement': {
|
||||
const templateContent = `# Implementation Draft: OKR Web App\n\n## Scope\nThe OKR implementation is represented by the existing backend and frontend source files. This step records the implementation candidate that must be accepted only after STEP12 run-tests passes.\n\n## Backend Source\n- apps/okr/backend/src/auth/auth.service.ts\n- apps/okr/backend/src/objectives/objectives.service.ts\n- apps/okr/backend/src/key-results/key-results.service.ts\n- apps/okr/backend/test/services.test.ts\n- apps/okr/backend/test/e2e.test.ts\n\n## Frontend Source\n- apps/okr/frontend/src/lib/api.ts\n- apps/okr/frontend/src/pages/DashboardPage.tsx\n- apps/okr/frontend/src/pages/ObjectiveDetailPage.tsx\n\n## Acceptance Gate\nThis draft is not accepted until STEP12 writes ${implementationAcceptancePath} after the real test command exits 0.\n`;
|
||||
const templateContent = `# Implementation Draft: ${project.feature.title}\n\n## Scope\nThe implementation candidate is bounded by the manifest source roots and is not accepted before STEP12 build/test PASS.\n\n## Source Roots\n${bullets(project.source_roots)}\n\n## Implementation Evidence\n${bullets(implementationFiles)}\n\n## Acceptance Gate\nSTEP12 must execute manifest argv commands and write ${implementationAcceptancePath}; no shell interpolation is permitted.\n`;
|
||||
const generated = generateArtifact({
|
||||
stepId: '10-implement',
|
||||
title: 'Implementation Draft',
|
||||
templateContent,
|
||||
required: ['Backend Source', 'Frontend Source', 'Acceptance Gate', 'STEP12'],
|
||||
required: isLegacy ? ['Backend Source', 'Frontend Source', 'Acceptance Gate', 'STEP12'] : ['Source Roots', 'Implementation Evidence', 'Acceptance Gate', 'STEP12'],
|
||||
extraInstructions: 'Create a code implementation draft/manifest. Reference concrete source files and state that acceptance requires STEP12 test PASS.',
|
||||
});
|
||||
write(implementationPath, generated.content);
|
||||
try { unlinkSync(implementationAcceptancePath); } catch {}
|
||||
report(
|
||||
`docs/output/output_logs/${featureId}/reports/10-implement-report.md`,
|
||||
reportPath('10-implement-report.md'),
|
||||
'# STEP 10: Implementation Draft Report',
|
||||
`Generated ${implementationPath}. source=${generated.source} note=${generated.note}. acceptance=pending STEP12.`,
|
||||
'APPROVED',
|
||||
@@ -587,7 +674,7 @@ switch (step) {
|
||||
}
|
||||
case '10-reviewcode':
|
||||
case '11-reviewcode': {
|
||||
const missingFiles = backendFiles.filter((path) => {
|
||||
const missingFiles = implementationFiles.filter((path) => {
|
||||
try {
|
||||
readFileSync(path, 'utf8');
|
||||
return false;
|
||||
@@ -595,26 +682,27 @@ switch (step) {
|
||||
return true;
|
||||
}
|
||||
});
|
||||
const serviceText = backendFiles.map((path) => readFileSync(path, 'utf8')).join('\n');
|
||||
const issues = [];
|
||||
if (!serviceText.includes('PrismaService')) issues.push('PrismaService not used');
|
||||
if (!serviceText.includes('ForbiddenException')) issues.push('authorization exception not found');
|
||||
if (!/golden:.*objective list.*does not drift/i.test(serviceText)) issues.push('golden test not found');
|
||||
issues.push(...missingFiles.map((path) => `missing file: ${path}`));
|
||||
const issues = missingFiles.map((path) => `missing file: ${path}`);
|
||||
for (const sourceRoot of project.source_roots) {
|
||||
const prefix = `${sourceRoot.replace(/\/$/, '')}/`;
|
||||
if (!project.verification.some((rule) => prefix.startsWith(rule.path_prefix) || rule.path_prefix.startsWith(prefix))) {
|
||||
issues.push(`unmapped verification source root: ${sourceRoot}`);
|
||||
}
|
||||
}
|
||||
let verdict = issues.length === 0 ? 'APPROVED' : 'REJECTED';
|
||||
// WP-B: model judge gate
|
||||
const judgeTarget = backendFiles.find((p) => { try { readFileSync(p, 'utf8'); return true; } catch { return false; } }) ?? codeReviewPath;
|
||||
const judgeTarget = implementationFiles.find((p) => { try { readFileSync(p, 'utf8'); return true; } catch { return false; } }) ?? codeReviewPath;
|
||||
const judgeResult = verdict === 'APPROVED'
|
||||
? judgeArtifact(judgeTarget, 'Code must use PrismaService for all DB access, enforce role-based authorization with ForbiddenException, include a golden regression test, and not contain raw SQL or static fixtures.')
|
||||
? judgeArtifact(judgeTarget, 'Code must follow the approved architecture, validate inputs, enforce authorization where required, avoid hard-coded product fixtures, and have manifest-bound build/test evidence.')
|
||||
: { verdict: 'SKIP', note: 'rule_already_rejected' };
|
||||
if (judgeResult.verdict === 'REJECTED') verdict = 'REJECTED';
|
||||
const judgeNote = `model-judge: ${judgeResult.verdict} (${judgeResult.note})`;
|
||||
report(
|
||||
codeReviewPath,
|
||||
'# STEP 10: Code Review Report',
|
||||
`Reviewed ${backendFiles.length} apps/okr/backend/test files. DB data usage verification: Prisma Client used, frontend API client used, seed data exists, no static endpoint data found. ${judgeNote}.`,
|
||||
`Reviewed ${implementationFiles.length} manifest implementation evidence files across ${project.source_roots.length} source roots. ${judgeNote}.`,
|
||||
verdict,
|
||||
[...backendFiles, 'apps/okr/frontend/src/lib/api.ts', 'apps/okr/backend/prisma/seed.ts'],
|
||||
implementationFiles,
|
||||
issues,
|
||||
);
|
||||
break;
|
||||
@@ -622,22 +710,26 @@ switch (step) {
|
||||
case '12-runtests': {
|
||||
let implementation = '';
|
||||
try { implementation = readFileSync(implementationPath, 'utf8'); } catch {}
|
||||
const testCmd = process.env.CASAN_SOURCEGEN_TEST_CMD || 'npm test';
|
||||
const r = spawnSync('bash', ['-lc', testCmd], {
|
||||
cwd: __appRoot,
|
||||
encoding: 'utf8',
|
||||
timeout: Number(process.env.CASAN_SOURCEGEN_TEST_TIMEOUT_MS || 180000),
|
||||
env: { ...process.env },
|
||||
});
|
||||
const output = `${r.stdout || ''}\n${r.stderr || ''}`.trim().slice(0, 6000);
|
||||
const passed = r.status === 0;
|
||||
let records;
|
||||
if (project.manifest_path === 'legacy' && process.env.CASAN_SOURCEGEN_TEST_CMD) {
|
||||
const legacy = spawnSync('bash', ['-lc', process.env.CASAN_SOURCEGEN_TEST_CMD], { cwd: __appRoot, encoding: 'utf8', timeout: 180000 });
|
||||
records = [{ command: ['legacy-test-command'], status: legacy.status, stdout: legacy.stdout || '', stderr: legacy.stderr || '' }];
|
||||
} else {
|
||||
const buildRecords = runArgvCommands('build');
|
||||
records = buildRecords.some((record) => record.status !== 0)
|
||||
? buildRecords
|
||||
: [...buildRecords, ...runArgvCommands('test')];
|
||||
}
|
||||
const failed = records.find((record) => record.status !== 0);
|
||||
const passed = records.length > 0 && !failed;
|
||||
const output = records.map((record) => `$ ${record.command.join(' ')}\n${record.stdout}\n${record.stderr}`).join('\n').trim().slice(0, 12000);
|
||||
const acceptance = {
|
||||
timestamp: new Date().toISOString(),
|
||||
feature_id: featureId,
|
||||
implementation_artifact: implementationPath,
|
||||
implementation_sha256: sha256(implementation),
|
||||
test_command: testCmd,
|
||||
test_exit_code: r.status,
|
||||
commands: records.map((record) => record.command),
|
||||
exit_codes: records.map((record) => record.status),
|
||||
accepted: passed,
|
||||
};
|
||||
if (passed) {
|
||||
@@ -648,10 +740,10 @@ switch (step) {
|
||||
report(
|
||||
testRunPath,
|
||||
'# STEP 12: Run Tests Report',
|
||||
`Command: ${testCmd}\nExit code: ${r.status}\nAccepted implementation: ${passed ? 'yes' : 'no'}\n\n## Output\n${output || '(no output)'}`,
|
||||
`Commands: ${records.map((record) => record.command.join(' ')).join(' ; ')}\nExit codes: ${records.map((record) => record.status).join(', ')}\nAccepted implementation: ${passed ? 'yes' : 'no'}\n\n## Output\n${output || '(no output)'}`,
|
||||
passed ? 'PASS' : 'FAIL',
|
||||
passed ? [implementationPath, implementationAcceptancePath] : [implementationPath],
|
||||
passed ? [] : [`test command failed rc=${r.status}`],
|
||||
passed ? [] : [`build/test command failed rc=${failed?.status ?? 'no-commands'}`],
|
||||
);
|
||||
break;
|
||||
}
|
||||
|
||||
@@ -2,24 +2,37 @@ import { execFileSync } from 'node:child_process';
|
||||
import { existsSync, appendFileSync, copyFileSync, mkdirSync, readFileSync, readdirSync, statSync, writeFileSync } from 'node:fs';
|
||||
import { dirname, join } from 'node:path';
|
||||
import { enabled, log, redact, LOG_LEVEL } from './casan-log.mjs';
|
||||
import { loadProjectManifest, projectArtifactPaths } from './casan-project.mjs';
|
||||
|
||||
// --dry-run: walk the FULL 13-STEP diagram with stub agents. Every stub still
|
||||
// goes through casan-harness.sh (H4-in -> H5 -> H6 -> exec -> H4-out), so log
|
||||
// levels and pipeline-run.jsonl can be demonstrated deterministically offline.
|
||||
const dryRun = process.argv.includes('--dry-run');
|
||||
|
||||
const featureId = '001-okr-web-app';
|
||||
const root = process.cwd();
|
||||
const optionValue = (name) => {
|
||||
const index = process.argv.indexOf(name);
|
||||
return index >= 0 ? process.argv[index + 1] : undefined;
|
||||
};
|
||||
const project = loadProjectManifest({
|
||||
root,
|
||||
manifestPath: optionValue('--manifest'),
|
||||
projectId: optionValue('--project'),
|
||||
});
|
||||
const featureId = project.feature.id;
|
||||
const artifactPaths = projectArtifactPaths(project);
|
||||
// Plan-01: harness relocated to packages/casan-harness/, domain to apps/okr/domain/;
|
||||
// fall back to the pre-move paths so this runner works from either layout.
|
||||
const HARNESS_BASH = existsSync(join(root, 'packages/casan-harness/scripts/bash'))
|
||||
? 'packages/casan-harness/scripts/bash'
|
||||
: '.specify/scripts/bash';
|
||||
const HARNESS = `${HARNESS_BASH}/casan-harness.sh`;
|
||||
const GOLDEN_PLAN = existsSync(join(root, 'apps/okr/domain/golden-runs/okr-plan.golden.txt'))
|
||||
? 'apps/okr/domain/golden-runs/okr-plan.golden.txt'
|
||||
: '.specify/level5/golden-runs/okr-plan.golden.txt';
|
||||
const logDir = `docs/output/output_logs/${featureId}`;
|
||||
const goldenCandidates = [
|
||||
`${project.domain_root}/golden-runs/plan.golden.txt`,
|
||||
`${project.domain_root}/golden-runs/${project.project_id}-plan.golden.txt`,
|
||||
`${project.domain_root}/golden-runs/okr-plan.golden.txt`,
|
||||
];
|
||||
const GOLDEN_PLAN = goldenCandidates.find((candidate) => existsSync(join(root, candidate)));
|
||||
const logDir = artifactPaths.logDir;
|
||||
const casanDir = `${logDir}/casan`;
|
||||
const reportsDir = `${logDir}/reports`;
|
||||
const contextPath = dryRun ? `${logDir}/pipeline-context.dryrun.yaml` : `${logDir}/pipeline-context.yaml`;
|
||||
@@ -47,19 +60,19 @@ const DIAGRAM_STEP = {
|
||||
};
|
||||
|
||||
const FULL_DIAGRAM = [
|
||||
['STEP1', 'okr.srs'],
|
||||
['STEP2', 'okr.bd'],
|
||||
['STEP1', 'casan.srs'],
|
||||
['STEP2', 'casan.bd'],
|
||||
['STEP3', 'speckit.specify'],
|
||||
['STEP4', 'speckit.clarify'],
|
||||
['STEP5', 'okr.reviewspec'],
|
||||
['STEP5', 'casan.reviewspec'],
|
||||
['STEP6', 'speckit.plan'],
|
||||
['STEP7', 'okr.reviewplan'],
|
||||
['STEP8', 'okr.dd'],
|
||||
['STEP8b', 'okr.testkit'],
|
||||
['STEP7', 'casan.reviewplan'],
|
||||
['STEP8', 'casan.dd'],
|
||||
['STEP8b', 'casan.testkit'],
|
||||
['STEP9', 'speckit.tasks'],
|
||||
['STEP10', 'speckit.implement'],
|
||||
['STEP11', 'okr.reviewcode'],
|
||||
['STEP12', 'okr.testkit run-tests'],
|
||||
['STEP11', 'casan.reviewcode'],
|
||||
['STEP12', 'casan.testkit run-tests'],
|
||||
['STEP13', 'deploy'],
|
||||
];
|
||||
|
||||
@@ -70,7 +83,7 @@ mkdirSync(casanDir, { recursive: true });
|
||||
mkdirSync(reportsDir, { recursive: true });
|
||||
writeFileSync(
|
||||
contextPath,
|
||||
`feature-id: ${featureId}\nmodule-id: MOD-01\nmodule-keyword: okr-management\ntech-stack: NestJS + Prisma + SQLite + React + Vite + Tailwind\nsteps:\n`,
|
||||
`feature-id: ${featureId}\nproject-id: ${project.project_id}\nmanifest: ${project.manifest_path}\nmodule-id: ${project.feature.module_id}\nmodule-keyword: ${project.feature.slug}\ntech-stack: ${project.tech_stack || 'defined by architecture'}\nsteps:\n`,
|
||||
'utf8',
|
||||
);
|
||||
writeFileSync(bossLog, `# Boss Log ${featureId}\n\n`, 'utf8');
|
||||
@@ -176,7 +189,7 @@ function runHarness({ id, agent, step, attempt = '1', extraEnv = {} }) {
|
||||
const input = `${casanDir}/${id}-input.txt`;
|
||||
const output = `${casanDir}/${id}-output.md`;
|
||||
const phaseReport = `${casanDir}/${id}-phases.json`;
|
||||
const payload = `feature ${featureId}\nstep ${id}\nagent ${agent}\nattempt ${attempt}\nsource docs/input/okr-requirement.md\n`;
|
||||
const payload = `project ${project.project_id}\nfeature ${featureId}\nstep ${id}\nagent ${agent}\nattempt ${attempt}\nsource ${project.requirements}\nmanifest ${project.manifest_path}\n`;
|
||||
writeFileSync(input, payload, 'utf8');
|
||||
appendBoss(`START ${id} ${agent} attempt ${attempt}`);
|
||||
log('debug', 'boss', `${diagram} start agent=${agent} attempt=${attempt} action=agent_step_${id}`);
|
||||
@@ -195,6 +208,9 @@ function runHarness({ id, agent, step, attempt = '1', extraEnv = {} }) {
|
||||
CASAN_AGENT_NAME: agent,
|
||||
CASAN_STEP_NAME: id,
|
||||
CASAN_PHASE_REPORT: phaseReport,
|
||||
CASAN_PROJECT_MANIFEST: project.manifest_path,
|
||||
CASAN_PROJECT_ID: project.project_id,
|
||||
CASAN_DOMAIN_ROOT: join(root, project.domain_root),
|
||||
...extraEnv,
|
||||
},
|
||||
},
|
||||
@@ -269,6 +285,9 @@ function runDryStep({ diagram, agent, attempt = '1', verdict = 'APPROVED', extra
|
||||
CASAN_AGENT_NAME: agent,
|
||||
CASAN_STEP_NAME: id,
|
||||
CASAN_PHASE_REPORT: phaseReport,
|
||||
CASAN_PROJECT_MANIFEST: project.manifest_path,
|
||||
CASAN_PROJECT_ID: project.project_id,
|
||||
CASAN_DOMAIN_ROOT: join(root, project.domain_root),
|
||||
...extraEnv,
|
||||
},
|
||||
},
|
||||
@@ -322,8 +341,8 @@ if (dryRun) {
|
||||
|
||||
// ───────────────────────────── real pipeline ─────────────────────────────
|
||||
const sequence = [
|
||||
{ id: '01-srs', agent: 'okr.srs', step: '01-srs' },
|
||||
{ id: '02-bd', agent: 'okr.bd', step: '02-bd' },
|
||||
{ id: '01-srs', agent: 'casan.srs', step: '01-srs' },
|
||||
{ id: '02-bd', agent: 'casan.bd', step: '02-bd' },
|
||||
{ id: '03-spec', agent: 'speckit.specify', step: '03-spec' },
|
||||
];
|
||||
|
||||
@@ -331,7 +350,7 @@ for (const item of sequence) {
|
||||
runHarness(item);
|
||||
}
|
||||
|
||||
let reviewSpec = runHarness({ id: '04-reviewspec', agent: 'okr.reviewspec', step: '04-reviewspec' });
|
||||
let reviewSpec = runHarness({ id: '04-reviewspec', agent: 'casan.reviewspec', step: '04-reviewspec' });
|
||||
if (reviewSpec.verdict === 'REJECTED') {
|
||||
logLoop('STEP5', reviewSpec.verdict, 'STEP3', 'review-spec rejected; retrying spec with template fallback');
|
||||
runHarness({
|
||||
@@ -343,7 +362,7 @@ if (reviewSpec.verdict === 'REJECTED') {
|
||||
});
|
||||
reviewSpec = runHarness({
|
||||
id: '04c-reviewspec-attempt-2',
|
||||
agent: 'okr.reviewspec',
|
||||
agent: 'casan.reviewspec',
|
||||
step: '04-reviewspec',
|
||||
attempt: '2',
|
||||
extraEnv: { CASAN_MODEL_PRIMARY: process.env.CASAN_REVIEW_ESCALATE_MODEL || 'openai:gpt-4o-mini' },
|
||||
@@ -356,7 +375,7 @@ if (reviewSpec.verdict === 'REJECTED') {
|
||||
|
||||
runHarness({ id: '05-plan-attempt-1', agent: 'speckit.plan', step: '05-plan', attempt: '1' });
|
||||
|
||||
const reviewPlan1 = runHarness({ id: '06-reviewplan-attempt-1', agent: 'okr.reviewplan', step: '06-reviewplan', attempt: '1' });
|
||||
const reviewPlan1 = runHarness({ id: '06-reviewplan-attempt-1', agent: 'casan.reviewplan', step: '06-reviewplan', attempt: '1' });
|
||||
if (reviewPlan1.verdict === 'REJECTED') {
|
||||
logLoop('STEP7', reviewPlan1.verdict, 'STEP6', 'BACK-TO-PLAN: retrying plan with missing criteria fixed');
|
||||
}
|
||||
@@ -375,7 +394,9 @@ execFileSync(
|
||||
'--primary',
|
||||
'cat /nonexistent/casan/primary-model-endpoint',
|
||||
'--fallback',
|
||||
'printf "Generate a safe OKR plan for employee ***MASKED_EMAIL***.\\nExpected sections:\\n- Objective\\n- Key Results\\n- Security gate\\n- Governance decision\\n- AgentOps metrics\\n"',
|
||||
project.project_id === 'okr'
|
||||
? 'printf "Generate a safe OKR plan for employee ***MASKED_EMAIL***.\\nExpected sections:\\n- Objective\\n- Key Results\\n- Security gate\\n- Governance decision\\n- AgentOps metrics\\n"'
|
||||
: 'printf "Generate a safe project plan.\\nExpected sections:\\n- Requirements traceability\\n- Architecture constraints\\n- Security gate\\n- Governance decision\\n- Build and test evidence\\n- Rollback strategy\\n"',
|
||||
],
|
||||
{ cwd: root, stdio: 'inherit' },
|
||||
);
|
||||
@@ -387,17 +408,20 @@ appendBoss(`Model fallback invoked; output ${fallbackOut}`);
|
||||
// independently in adversarial-harness-tests.sh (H7 drift, two different files).
|
||||
const driftCandidate = `${casanDir}/drift-plan-candidate.txt`;
|
||||
copyFileSync(fallbackOut, driftCandidate);
|
||||
if (!GOLDEN_PLAN) {
|
||||
throw new Error(`CASAN_GOLDEN_PLAN_MISSING: ${project.domain_root}/golden-runs`);
|
||||
}
|
||||
log('debug', 'boss', 'drift-detect: fallback output vs golden baseline');
|
||||
execFileSync(`${HARNESS_BASH}/drift-detect.sh`, [
|
||||
GOLDEN_PLAN,
|
||||
driftCandidate,
|
||||
'.specify/logs/level5/okr-plan-drift-report.json',
|
||||
`.specify/logs/level5/${project.project_id}-plan-drift-report.json`,
|
||||
], { cwd: root, stdio: 'inherit' });
|
||||
appendBoss('Drift detection invoked: fallback output vs golden baseline.');
|
||||
|
||||
const reviewPlan2 = runHarness({
|
||||
id: '08-reviewplan-attempt-2',
|
||||
agent: 'okr.reviewplan',
|
||||
agent: 'casan.reviewplan',
|
||||
step: '06-reviewplan',
|
||||
attempt: '2',
|
||||
extraEnv: { CASAN_MODEL_PRIMARY: process.env.CASAN_REVIEW_ESCALATE_MODEL || 'openai:gpt-4o-mini' },
|
||||
@@ -406,17 +430,17 @@ if (reviewPlan2.verdict === 'REJECTED') {
|
||||
logLoop('STEP7', reviewPlan2.verdict, 'STEP6', 'BACK-TO-PLAN attempt 2 still rejected');
|
||||
throw new Error('review-plan rejected after retry');
|
||||
}
|
||||
runHarness({ id: '09-dd', agent: 'okr.dd', step: '07-dd' });
|
||||
runHarness({ id: '10-testkit', agent: 'okr.testkit', step: '08-testkit' });
|
||||
runHarness({ id: '09-dd', agent: 'casan.dd', step: '07-dd' });
|
||||
runHarness({ id: '10-testkit', agent: 'casan.testkit', step: '08-testkit' });
|
||||
runHarness({ id: '11-tasks', agent: 'speckit.tasks', step: '09-tasks' });
|
||||
runHarness({ id: '12-implement', agent: 'speckit.implement', step: '10-implement' });
|
||||
const reviewCode = runHarness({ id: '13-reviewcode', agent: 'okr.reviewcode', step: '11-reviewcode' });
|
||||
const reviewCode = runHarness({ id: '13-reviewcode', agent: 'casan.reviewcode', step: '11-reviewcode' });
|
||||
if (reviewCode.verdict === 'REJECTED') {
|
||||
logLoop('STEP11', reviewCode.verdict, 'STEP10', 'review-code rejected; implement step must be re-run');
|
||||
runHarness({ id: '13b-implement-attempt-2', agent: 'speckit.implement', step: '10-implement', attempt: '2' });
|
||||
const reviewCode2 = runHarness({
|
||||
id: '13c-reviewcode-attempt-2',
|
||||
agent: 'okr.reviewcode',
|
||||
agent: 'casan.reviewcode',
|
||||
step: '11-reviewcode',
|
||||
attempt: '2',
|
||||
extraEnv: { CASAN_MODEL_PRIMARY: process.env.CASAN_REVIEW_ESCALATE_MODEL || 'openai:gpt-4o-mini' },
|
||||
@@ -426,12 +450,12 @@ if (reviewCode.verdict === 'REJECTED') {
|
||||
throw new Error('review-code rejected after implement retry');
|
||||
}
|
||||
}
|
||||
const runTests = runHarness({ id: '14-runtests', agent: 'okr.testkit run-tests', step: '12-runtests' });
|
||||
const runTests = runHarness({ id: '14-runtests', agent: 'casan.testkit run-tests', step: '12-runtests' });
|
||||
if (runTests.verdict === 'FAIL') {
|
||||
logLoop('STEP12', runTests.verdict, 'STEP6', 'tests FAIL: re-plan/re-implement before acceptance');
|
||||
}
|
||||
|
||||
const rollbackDir = 'docs/output/casan/app-evidence';
|
||||
const rollbackDir = `${project.artifacts_root}/casan/${project.project_id}/app-evidence`;
|
||||
mkdirSync(rollbackDir, { recursive: true });
|
||||
const rollbackTarget = `${rollbackDir}/rollback-target.txt`;
|
||||
writeFileSync(rollbackTarget, 'original pipeline rollback content\n', 'utf8');
|
||||
|
||||
Reference in New Issue
Block a user