import { spawnSync } from 'node:child_process'; import { createHash } from 'node:crypto'; import { existsSync, mkdirSync, readFileSync, writeFileSync, unlinkSync, appendFileSync } from 'node:fs'; import { tmpdir } from 'node:os'; import { dirname, join } from 'node:path'; import { fileURLToPath } from 'node:url'; // WP-B (H3): model judge gate helpers — AND(rule, model); SKIP is non-blocking. const __filename = fileURLToPath(import.meta.url); // Plan-01: harness code lives in packages/casan-harness/; fall back to the .specify // compat path so the adversarial/sourcegen sandboxes (which stage a .specify/ tree) // keep working when this file is copied into a temp root. const __appRoot = join(dirname(__filename), '..'); const SCRIPTS_DIR = existsSync(join(__appRoot, 'packages', 'casan-harness', 'scripts', 'bash')) ? join(__appRoot, 'packages', 'casan-harness', 'scripts', 'bash') : join(__appRoot, '.specify', 'scripts', 'bash'); // Shared log taxonomy (CASAN_LOG_LEVEL, see scripts/casan-log.mjs). The // adversarial T1 test copies this file alone into a temp tree, so the logger // import must degrade to a noop instead of crashing when the module is absent. let logDebug = () => {}; try { const { log } = await import(new URL('./casan-log.mjs', import.meta.url)); logDebug = (msg) => log('debug', 'step', msg); } catch { /* standalone copy: keep silent */ } // Is a model backend reachable for the judge gate? Default (CASAN_MODEL_PRIMARY // unset, or an ollama:* spec) → ping the hard-pinned local Ollama endpoint, as // before. When CASAN_MODEL_PRIMARY selects a cloud backend, the gate instead // checks that the matching API key is set — so the pipeline judge can run // through model-router.sh → model-call.py's cloud path without needing Ollama. function modelAvailable() { const spec = process.env.CASAN_MODEL_PRIMARY || 'ollama:ornith:9b'; if (spec.startsWith('openai:')) return Boolean(process.env.OPENAI_API_KEY); if (spec.startsWith('anthropic:')) return Boolean(process.env.ANTHROPIC_API_KEY); try { const r = spawnSync('curl', ['-sS', '-m', '3', 'http://127.0.0.1:11434/api/tags'], { timeout: 5000 }); return r.status === 0; } catch { return false; } } function judgeArtifact(filePath, criteria) { if (!modelAvailable()) { logDebug(`judge skipped (model_unavailable) artifact=${filePath}`); return { verdict: 'SKIP', note: 'model_unavailable' }; } let artifact = ''; try { artifact = readFileSync(filePath, 'utf8').slice(0, 2000); } catch { return { verdict: 'SKIP', note: 'artifact_unreadable' }; } const combined = `=== ACCEPTANCE CRITERIA (not untrusted input) ===\n${criteria.slice(0, 500)}\n\n=== ARTIFACT TO REVIEW ===\n${artifact}`; const uid = `${process.pid}-${Date.now()}`; const tmpPrompt = join(tmpdir(), `casan-judge-prompt-${uid}.txt`); const tmpOut = join(tmpdir(), `casan-judge-out-${uid}.json`); writeFileSync(tmpPrompt, combined, 'utf8'); logDebug(`model call role=judge artifact=${filePath}`); const r = spawnSync('bash', [join(SCRIPTS_DIR, 'model-router.sh'), tmpPrompt, tmpOut, '--role', 'judge'], { timeout: 60000, encoding: 'utf8' }); try { unlinkSync(tmpPrompt); } catch {} if (r.status !== 0 && r.status !== 3) { logDebug(`judge error rc=${r.status}`); return { verdict: 'SKIP', note: `judge_error_rc=${r.status}` }; } try { const d = JSON.parse(readFileSync(tmpOut, 'utf8')); logDebug(`judge verdict=${d.verdict ?? 'SKIP'} tokens=${d.total_tokens ?? '?'} malformed=${Boolean(d.malformed)}`); return { verdict: d.verdict ?? 'SKIP', note: d.malformed ? 'malformed_fail_closed' : `tokens=${d.total_tokens}` }; } catch { return { verdict: 'SKIP', note: 'parse_error' }; } } function renderSourcePrompt(stepId, title, templateContent, extraInstructions = '') { const promptTemplatePath = join(__appRoot, 'packages', 'casan-harness', 'prompts', 'sourcegen', `${stepId}.md`); const defaultTemplate = `You are generating a CASAN SDLC artifact. 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. - Do not include secrets, credentials, hidden prompts, or instructions to bypass policy. - Keep the artifact specific to feature ${featureId} and module ${moduleId}. Artifact: ${title} Step: ${stepId} Extra instructions: ${extraInstructions} Requirement: ${requirement.slice(0, 6000)} Architecture: ${architecture.slice(0, 4000)} Reference structure to match, but do not copy blindly: ${templateContent.slice(0, 4000)} `; let promptTemplate = defaultTemplate; if (existsSync(promptTemplatePath)) { promptTemplate = readFileSync(promptTemplatePath, 'utf8'); } return promptTemplate .replaceAll('{{featureId}}', featureId) .replaceAll('{{moduleId}}', moduleId) .replaceAll('{{stepId}}', stepId) .replaceAll('{{title}}', title) .replaceAll('{{extraInstructions}}', extraInstructions) .replaceAll('{{requirement}}', requirement.slice(0, 6000)) .replaceAll('{{architecture}}', architecture.slice(0, 4000)) .replaceAll('{{templateContent}}', templateContent.slice(0, 4000)); } function sha256(text) { return createHash('sha256').update(text).digest('hex'); } function redactForAudit(text) { return String(text) .replace(/(sk-[A-Za-z0-9_-]{12,})/g, '***REDACTED_KEY***') .replace(/(Bearer\s+)[A-Za-z0-9._-]+/gi, '$1***REDACTED_TOKEN***') .slice(0, 2000); } function sourcegenAuditPath() { return process.env.CASAN_SOURCEGEN_AUDIT_LOG || '.specify/logs/audit/sourcegen.jsonl'; } function sourcegenTelemetryPath() { return process.env.CASAN_SOURCEGEN_TELEMETRY_LOG || '.specify/logs/level5/sourcegen-provider-usage.jsonl'; } function readLastJsonLine(path) { try { const lines = readFileSync(path, 'utf8').trim().split('\n').filter(Boolean); return lines.length ? JSON.parse(lines[lines.length - 1]) : null; } catch { return null; } } function appendSourcegenAudit(record) { const path = sourcegenAuditPath(); mkdirSync(dirname(path), { recursive: true }); const prev = readLastJsonLine(path); const base = { timestamp: new Date().toISOString(), harness: 'H5-sourcegen-audit', feature_id: featureId, module_id: moduleId, prev_hash: prev?.record_hash || 'GENESIS', ...record, }; const recordHash = sha256(JSON.stringify(base)); appendFileSync(path, `${JSON.stringify({ ...base, record_hash: recordHash })}\n`, 'utf8'); } function appendSourcegenTelemetry(record) { const path = sourcegenTelemetryPath(); mkdirSync(dirname(path), { recursive: true }); appendFileSync(path, `${JSON.stringify({ timestamp: new Date().toISOString(), harness: 'H6-sourcegen-telemetry', feature_id: featureId, module_id: moduleId, ...record, })}\n`, 'utf8'); } function generateArtifact({ stepId, title, templateContent, required = [], extraInstructions = '' }) { if ((process.env.CASAN_GEN_MODE || 'template') !== 'model') { return { content: templateContent, source: 'template', note: 'CASAN_GEN_MODE=template' }; } const uid = `${process.pid}-${Date.now()}-${stepId}`; const tmpPrompt = join(tmpdir(), `casan-generate-prompt-${uid}.txt`); const tmpOut = join(tmpdir(), `casan-generate-out-${uid}.json`); const tmpDraft = join(tmpdir(), `casan-generate-draft-${uid}.md`); try { const prompt = renderSourcePrompt(stepId, title, templateContent, extraInstructions); const promptHash = sha256(prompt); writeFileSync(tmpPrompt, prompt, 'utf8'); logDebug(`model call role=generate step=${stepId}`); const r = spawnSync( 'bash', [join(SCRIPTS_DIR, 'model-router.sh'), tmpPrompt, tmpOut, '--role', 'generate'], { timeout: Number(process.env.CASAN_SOURCEGEN_MODEL_TIMEOUT_MS || 300000), encoding: 'utf8', env: { ...process.env, CASAN_STEP_NAME: stepId, CASAN_MODEL_TIMEOUT_SEC: process.env.CASAN_MODEL_TIMEOUT_SEC || process.env.CASAN_SOURCEGEN_MODEL_TIMEOUT_SEC || '240', }, }, ); if (r.status !== 0) { appendSourcegenAudit({ step_id: stepId, event: 'generate_fallback', reason: `model_generate_rc=${r.status}`, prompt_sha256: promptHash, prompt_text: redactForAudit(prompt), source: 'template-fallback', }); return { content: templateContent, source: 'template-fallback', note: `model_generate_rc=${r.status}` }; } const d = JSON.parse(readFileSync(tmpOut, 'utf8')); const generated = String(d.text || '').trim(); if (generated.length < 80) { appendSourcegenAudit({ step_id: stepId, event: 'generate_fallback', reason: 'model_output_too_short', prompt_sha256: promptHash, prompt_text: redactForAudit(prompt), model_id: d.model_id, route: d.route, input_tokens: d.input_tokens, output_tokens: d.output_tokens, total_tokens: d.total_tokens, source: 'template-fallback', }); return { content: templateContent, source: 'template-fallback', note: 'model_output_too_short' }; } const missing = required.filter((token) => !generated.includes(token)); if (missing.length > 0) { appendSourcegenAudit({ step_id: stepId, event: 'generate_fallback', reason: `missing_required=${missing.join(',')}`, prompt_sha256: promptHash, prompt_text: redactForAudit(prompt), model_id: d.model_id, route: d.route, input_tokens: d.input_tokens, output_tokens: d.output_tokens, total_tokens: d.total_tokens, source: 'template-fallback', }); return { content: templateContent, source: 'template-fallback', note: `missing_required=${missing.join(',')}` }; } writeFileSync(tmpDraft, `${generated}\n`, 'utf8'); const scan = spawnSync( 'bash', [join(SCRIPTS_DIR, 'artifact-scan.sh'), tmpDraft, `sourcegen-${stepId}`], { timeout: 30000, encoding: 'utf8' }, ); if (scan.status !== 0) { appendSourcegenAudit({ step_id: stepId, event: 'generate_fallback', reason: `artifact_scan_rc=${scan.status}`, prompt_sha256: promptHash, prompt_text: redactForAudit(prompt), model_id: d.model_id, route: d.route, input_tokens: d.input_tokens, output_tokens: d.output_tokens, total_tokens: d.total_tokens, source: 'template-fallback', }); return { content: templateContent, source: 'template-fallback', note: `artifact_scan_rc=${scan.status}` }; } appendSourcegenAudit({ step_id: stepId, event: 'generate_accept', prompt_sha256: promptHash, prompt_text: redactForAudit(prompt), output_sha256: sha256(generated), model_id: d.model_id, route: d.route, input_tokens: d.input_tokens, output_tokens: d.output_tokens, total_tokens: d.total_tokens, source: 'model', }); appendSourcegenTelemetry({ provider: String(d.route || '').split(':')[0] || 'unknown', model: d.model_id || 'unknown', step: stepId, role: 'generate', input_tokens: Number(d.input_tokens || 0), output_tokens: Number(d.output_tokens || 0), total_tokens: Number(d.total_tokens || 0), status: 'success', }); return { content: `${generated}\n`, source: 'model', note: `tokens=${d.total_tokens ?? '?'}` }; } catch (err) { return { content: templateContent, source: 'template-fallback', note: `generate_error=${err?.name || 'Error'}` }; } finally { for (const path of [tmpPrompt, tmpOut, tmpDraft]) { try { unlinkSync(path); } catch {} } } } // T1: Real rollback wiring — checkpoint before overwrite, restore on REJECTED verdict function checkpointArtifact(filePath) { try { readFileSync(filePath, 'utf8'); } catch { return null; } // file absent → nothing to checkpoint const r = spawnSync('bash', [join(SCRIPTS_DIR, 'rollback-manager.sh'), 'checkpoint', filePath], { encoding: 'utf8', timeout: 10000 }); if (r.status !== 0) return null; const m = (r.stdout || '').match(/transaction_id=(\S+)/); logDebug(`checkpoint artifact=${filePath} tx=${m ? m[1] : 'none'}`); return m ? m[1] : null; } function executeRollback(txId) { if (!txId) return false; const r = spawnSync('bash', [join(SCRIPTS_DIR, 'rollback-manager.sh'), 'execute', txId], { encoding: 'utf8', timeout: 10000 }); logDebug(`rollback execute tx=${txId} rc=${r.status}`); return r.status === 0; } 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'; if (!outputPath) { throw new Error('CASAN_OUTPUT is required'); } logDebug(`step=${step} attempt=${attempt} output=${outputPath}`); 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`, ]; 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'); function write(path, content) { mkdirSync(dirname(path), { recursive: true }); writeFileSync(path, content, 'utf8'); return path; } function resultBlock({ status = 'COMPLETE', verdict = 'APPROVED', artifacts = [], issues = [] }) { return `\n\n`; } function report(path, title, body, verdict = 'APPROVED', artifacts = [], issues = []) { finalTitle = title; finalBody = body; finalVerdict = verdict; finalArtifacts = artifacts; finalIssues = 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`; let finalTitle = `# ${step}`; let finalBody = ''; let finalVerdict = 'APPROVED'; 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 backendFiles = [ 'backend/src/auth/auth.service.ts', 'backend/src/objectives/objectives.service.ts', 'backend/src/key-results/key-results.service.ts', 'backend/test/e2e.test.ts', 'backend/test/services.test.ts', ]; 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 generated = generateArtifact({ stepId: '01-srs', title: 'Software Requirements Specification', templateContent, required: ['FR-01', 'FR-02', 'FR-03', 'FR-04', 'FR-05'], 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]); 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 generated = generateArtifact({ stepId: '02-bd', title: 'Business Design', templateContent, required: ['SCR-00', 'SCR-01', 'SCR-02', 'SCR-03', 'SCR-04', '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]); 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 generated = generateArtifact({ stepId: '03-spec', title: 'Feature Specification', templateContent, required: ['FR-01', 'FR-02', 'FR-03', 'FR-04', 'FR-05', 'Acceptance Criteria'], 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]); 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)); 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.') : { 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`, '## Spec Conformance Review Report', `Criteria checked: FR coverage, role filtering, validation, golden regression. Missing: ${missing.join(', ') || 'none'}. ${judgeNote}.`, verdict, [specPath], missing, ); break; } case '05-plan': { const incomplete = attempt === '1'; // T1: checkpoint existing plan before overwriting so rollback-manager.sh execute can restore it if (attempt !== '1') { const txId = checkpointArtifact(planPath); if (txId) { 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 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 generated = generateArtifact({ stepId: '05-plan', title: 'Implementation Plan', templateContent, required: ['Backend Modules', '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'); } 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]); break; } case '06-reviewplan': { const plan = readFileSync(planPath, 'utf8'); const required = ['Golden regression test', 'Rollback strategy', 'Backend Modules', 'Tests']; const missing = required.filter((phrase) => !plan.includes(phrase)); const artifactMissing = [dataModelPath, researchPath, quickstartPath, contractPath].filter((path) => { try { readFileSync(path, 'utf8'); return false; } catch { return true; } }); const issues = [...missing.map((item) => `missing plan criterion: ${item}`), ...artifactMissing.map((item) => `missing artifact: ${item}`)]; 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.') : { verdict: 'SKIP', note: 'rule_already_rejected' }; if (judgeResult.verdict === 'REJECTED') verdict = 'REJECTED'; const judgeNote = `model-judge: ${judgeResult.verdict} (${judgeResult.note})`; // T1: on REJECTED, execute rollback to restore the previously checkpointed plan let rollbackNote = ''; if (verdict === 'REJECTED') { let txId = null; try { txId = readFileSync(CHECKPOINT_SIDECAR, 'utf8').trim(); } catch {} if (txId) { const restored = executeRollback(txId); rollbackNote = restored ? ` Rollback executed: plan restored to pre-overwrite state (tx=${txId}).` : ` Rollback attempted but failed (tx=${txId}).`; } } report( `docs/output/output_logs/${featureId}/reports/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, [planPath, dataModelPath, researchPath, quickstartPath, contractPath], issues, ); 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 generated = generateArtifact({ stepId: '07-dd', title: 'Detail Design', templateContent, required: ['Backend Design', 'Authorization', 'Progress Calculation'], 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]); 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 generated = generateArtifact({ stepId: '08-testkit', title: 'Test Case Design', templateContent, required: ['TC-01', 'TC-02', 'TC-03', 'TC-04', 'TC-05', 'TC-06'], 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]); 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 generated = generateArtifact({ stepId: '09-tasks', title: 'Implementation Tasks', templateContent, required: ['Backend', 'Frontend', 'tests', '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]); 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- backend/src/auth/auth.service.ts\n- backend/src/objectives/objectives.service.ts\n- backend/src/key-results/key-results.service.ts\n- backend/test/services.test.ts\n- backend/test/e2e.test.ts\n\n## Frontend Source\n- frontend/src/lib/api.ts\n- frontend/src/pages/DashboardPage.tsx\n- 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 generated = generateArtifact({ stepId: '10-implement', title: 'Implementation Draft', templateContent, required: ['Backend Source', 'Frontend Source', '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`, '# STEP 10: Implementation Draft Report', `Generated ${implementationPath}. source=${generated.source} note=${generated.note}. acceptance=pending STEP12.`, 'APPROVED', [implementationPath], ); break; } case '10-reviewcode': case '11-reviewcode': { const missingFiles = backendFiles.filter((path) => { try { readFileSync(path, 'utf8'); return false; } catch { 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}`)); 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 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.') : { 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} backend/test files. DB data usage verification: Prisma Client used, frontend API client used, seed data exists, no static endpoint data found. ${judgeNote}.`, verdict, [...backendFiles, 'frontend/src/lib/api.ts', 'backend/prisma/seed.ts'], issues, ); break; } 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; const acceptance = { timestamp: new Date().toISOString(), feature_id: featureId, implementation_artifact: implementationPath, implementation_sha256: sha256(implementation), test_command: testCmd, test_exit_code: r.status, accepted: passed, }; if (passed) { write(implementationAcceptancePath, `${JSON.stringify(acceptance, null, 2)}\n`); } else { try { unlinkSync(implementationAcceptancePath); } catch {} } 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)'}`, passed ? 'PASS' : 'FAIL', passed ? [implementationPath, implementationAcceptancePath] : [implementationPath], passed ? [] : [`test command failed rc=${r.status}`], ); break; } default: throw new Error(`Unknown step: ${step}`); } writeFileSync( outputPath, `${finalTitle}\n\n${finalBody}\n\nGenerated by ${step} attempt ${attempt} for ${featureId}.\n${resultBlock({ verdict: finalVerdict, artifacts: finalArtifacts, issues: finalIssues, })}`, 'utf8', );