fix template, remove okr, use casan.*

This commit is contained in:
thanhnv
2026-07-18 16:45:31 +07:00
parent 0dfd1742d3
commit 13fae3e6c3
249 changed files with 4881 additions and 5702 deletions
+176
View File
@@ -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
View File
@@ -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;
}
+54 -30
View File
@@ -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');