Repoint every remaining literal `.specify/...` / `docs/input` reference to the real package/domain location and delete all compat symlinks. The harness now runs purely via packages/casan-harness/... with no .specify facade; .specify holds ONLY runtime state (logs/, agentops/alerts.log, level5/central-governance). Refs fixed (Phase 0.5 only caught `$VAR/.specify/` — these were bare/`__file__`/literal): - secrets-scan.sh: scan-target excludes -> packages/casan-harness/... (+ apps/okr/domain/corpus) - loop_common.py: loop-policy.yaml -> harness config (package-relative) - evidence-pack-build.py: judge-gate test + traceability-matrix.py -> harness/sibling - phase10-traceability: REQ -> $CASAN_DOMAIN_ROOT/input - run-casan-pipeline.mjs: model-fallback/drift-detect/rollback-manager -> HARNESS_BASH, golden -> GOLDEN_PLAN (apps/okr/domain), with .specify/logs state kept - casan-step.mjs: requirement fallback restored to docs/input for hermetic sandboxes - descriptive config (tool-registry/harness-package/drift-policy/hallucination/risk-registry/ loop-policy.schema + docstrings) repointed for accuracy - policy-bundle.yaml: 8 policy paths -> packages/casan-harness/...; manifest regenerated + re-signed (POLICY_HASHES_VALID files=8, POLICY_SIGNATURE_VALID) Removed 22 .specify code/config symlinks + docs/input symlink. Full gate via packages path, NO facade: PASS=64 FAIL=0 SKIP=3. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
418 lines
24 KiB
JavaScript
418 lines
24 KiB
JavaScript
import { spawnSync } from 'node:child_process';
|
|
import { existsSync, mkdirSync, readFileSync, writeFileSync, unlinkSync } 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 = '') {
|
|
return `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)}
|
|
`;
|
|
}
|
|
|
|
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 {
|
|
writeFileSync(tmpPrompt, renderSourcePrompt(stepId, title, templateContent, extraInstructions), 'utf8');
|
|
logDebug(`model call role=generate step=${stepId}`);
|
|
const r = spawnSync(
|
|
'bash',
|
|
[join(SCRIPTS_DIR, 'model-router.sh'), tmpPrompt, tmpOut, '--role', 'generate'],
|
|
{ timeout: 120000, encoding: 'utf8', env: { ...process.env, CASAN_STEP_NAME: stepId } },
|
|
);
|
|
if (r.status !== 0) {
|
|
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) {
|
|
return { content: templateContent, source: 'template-fallback', note: 'model_output_too_short' };
|
|
}
|
|
const missing = required.filter((token) => !generated.includes(token));
|
|
if (missing.length > 0) {
|
|
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) {
|
|
return { content: templateContent, source: 'template-fallback', note: `artifact_scan_rc=${scan.status}` };
|
|
}
|
|
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<!-- STEP-RESULT\nstatus: ${status}\nverdict: ${verdict}\nartifacts:\n${artifacts.map((artifact) => ` - ${artifact}`).join('\n')}\ncritical-issues:\n${issues.length === 0 ? ' - none' : issues.map((issue) => ` - ${issue}`).join('\n')}\n/STEP-RESULT -->\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 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': {
|
|
write(specPath, `# 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## Source Trace\nRequirement characters read: ${requirement.length}. Architecture characters read: ${architecture.length}.\n`);
|
|
report(`docs/output/output_logs/${featureId}/reports/03-spec-report.md`, '# STEP 3: Specify Report', `Generated ${specPath}.`, '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 {}
|
|
}
|
|
}
|
|
write(planPath, `# 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`);
|
|
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}.`, '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': {
|
|
write(ddPath, `# 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`);
|
|
report(`docs/output/output_logs/${featureId}/reports/07-dd-report.md`, '# STEP 7: Detail Design Report', `Generated ${ddPath}.`, 'APPROVED', [ddPath]);
|
|
break;
|
|
}
|
|
case '08-testkit': {
|
|
write(testcasePath, `# 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`);
|
|
report(`docs/output/output_logs/${featureId}/reports/08-testkit-report.md`, '# STEP 8: Testkit Report', `Generated ${testcasePath}.`, 'APPROVED', [testcasePath]);
|
|
break;
|
|
}
|
|
case '09-tasks': {
|
|
write(tasksPath, `# 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`);
|
|
report(`docs/output/output_logs/${featureId}/reports/09-tasks-report.md`, '# STEP 9: Tasks Report', `Generated ${tasksPath}.`, 'APPROVED', [tasksPath]);
|
|
break;
|
|
}
|
|
case '10-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 (!serviceText.includes('golden objective list response does not drift')) 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;
|
|
}
|
|
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',
|
|
);
|