feat(log): unified CASAN_LOG_LEVEL across Boss, agent step, harness wrapper

Add a shared log taxonomy (error<warn<info<debug<trace, default info) wired
through all three layers, plus a machine-readable per-step run log.

- scripts/casan-log.mjs + .specify/scripts/bash/casan-log.sh: shared logger
  ([LEVEL] ts [component] msg), stderr-only so stdout contracts and exit codes
  are byte-identical. Node side includes trace-level redaction (secret/PII).
- run-casan-pipeline.mjs: per-STEP info line, loop-activation warnings
  (BACK-TO-PLAN, FAIL->STEP6), debug harness rc + trace_id, trace payload
  excerpt, end-of-run 13-STEP summary table, and one JSONL line/step in
  .specify/logs/pipeline-run.jsonl. New --dry-run stubs all agents but keeps
  the real wrapper in the loop (deterministic, offline, full 13 STEP + loops).
- casan-step.mjs: debug logs for judge verdict, checkpoint, rollback; logger
  import degrades to noop when the file is copied standalone (T1 test).
- casan-harness.sh: debug-log each phase H4-in -> H5 -> [H2-gate] -> H6-exec
  -> H4-out with its rc; optional CASAN_PHASE_REPORT JSON for the Boss.

Default level (info) keeps output close to before; behavior opt-in via env.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
thanhnv
2026-07-03 10:04:55 +09:00
co-authored by Claude Opus 4.8
parent 2f06662f5d
commit 1b61d7f381
5 changed files with 403 additions and 23 deletions
+25 -2
View File
@@ -8,6 +8,17 @@ import { fileURLToPath } from 'node:url';
const __filename = fileURLToPath(import.meta.url);
const SCRIPTS_DIR = join(dirname(__filename), '..', '.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 */
}
function ollamaAvailable() {
try {
const r = spawnSync('curl', ['-sS', '-m', '3', 'http://127.0.0.1:11434/api/tags'], { timeout: 5000 });
@@ -18,7 +29,10 @@ function ollamaAvailable() {
}
function judgeArtifact(filePath, criteria) {
if (!ollamaAvailable()) return { verdict: 'SKIP', note: 'ollama_unavailable' };
if (!ollamaAvailable()) {
logDebug(`judge skipped (ollama_unavailable) artifact=${filePath}`);
return { verdict: 'SKIP', note: 'ollama_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}`;
@@ -26,11 +40,16 @@ function judgeArtifact(filePath, criteria) {
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) return { verdict: 'SKIP', note: `judge_error_rc=${r.status}` };
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' }; }
}
@@ -42,6 +61,7 @@ function checkpointArtifact(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;
}
@@ -49,6 +69,7 @@ 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;
}
@@ -62,6 +83,8 @@ 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',