diff --git a/Tools/PhaseGate.hook.ts b/Tools/PhaseGate.hook.ts new file mode 100755 index 000000000..61feec1e7 --- /dev/null +++ b/Tools/PhaseGate.hook.ts @@ -0,0 +1,160 @@ +#!/usr/bin/env bun +/** + * PhaseGate.hook.ts — Enforce Algorithm gates via PRD evidence check + * + * A Claude Code PostToolUse hook that watches for PRD.md edits and verifies + * required evidence exists before allowing Algorithm phase transitions. + * + * Gates enforced: + * - phase -> think: requires ENVIRONMENT: entry in ## Decisions + * - phase -> build: requires VALIDATE: entry in ## Decisions + * + * WARNING mode only — logs to stderr (visible to the AI) and optionally + * sends a voice notification. Never blocks execution. + * + * Registration (add to settings.json hooks.PostToolUse): + * { "matcher": "Write", "hooks": [{ "type": "command", "command": "~/.claude/Tools/PhaseGate.hook.ts" }] } + * { "matcher": "Edit", "hooks": [{ "type": "command", "command": "~/.claude/Tools/PhaseGate.hook.ts" }] } + * + * Why this exists: + * Analysis of 49 algorithm reflections showed the top two failure patterns are: + * 1. Build-before-validate (31%) — building complex systems without proving the core assumption + * 2. Environment assumptions (17%) — assuming tools/libraries work without checking + * Both have "HARD GATE" text in the Algorithm, but text gates don't constrain a generative model. + * This hook moves enforcement from text instructions to code the AI cannot bypass. + * + * Usage: bun PhaseGate.hook.ts (called automatically by Claude Code hook system) + */ + +import { readFileSync, existsSync } from 'fs'; +import { join } from 'path'; +import { homedir } from 'os'; + +// --- Configuration --- + +const CLAUDE_DIR = join(homedir(), '.claude'); +const WORK_DIR = join(CLAUDE_DIR, 'MEMORY', 'WORK'); +const WORK_JSON = join(CLAUDE_DIR, 'MEMORY', 'STATE', 'work.json'); + +/** Voice server URL — set to empty string to disable voice warnings */ +const VOICE_URL = 'http://localhost:8888/notify'; + +interface GateCheck { + targetPhase: string; + requiredPrefix: string; + gateName: string; + warning: string; +} + +const GATES: GateCheck[] = [ + { + targetPhase: 'think', + requiredPrefix: 'ENVIRONMENT:', + gateName: 'ENVIRONMENT PRE-FLIGHT', + warning: 'Entering THINK without environment check. Add "ENVIRONMENT: [status]" to ## Decisions.', + }, + { + targetPhase: 'build', + requiredPrefix: 'VALIDATE:', + gateName: 'VALIDATE GATE', + warning: 'Entering BUILD without validation. Add "VALIDATE: [assumption] -> [result]" to ## Decisions.', + }, +]; + +/** PRD types that skip gate checks (no code execution = no validation needed) */ +const SKIP_TYPES = new Set(['docs', 'research', 'config']); + +// --- Minimal PRD parsing (self-contained, no lib/ dependencies) --- + +function parseFrontmatter(content: string): Record | null { + const match = content.match(/^---\n([\s\S]*?)\n---/); + if (!match) return null; + const fm: Record = {}; + for (const line of match[1].split('\n')) { + const idx = line.indexOf(':'); + if (idx > 0) fm[line.slice(0, idx).trim()] = line.slice(idx + 1).trim().replace(/^["']|["']$/g, ''); + } + return fm; +} + +function getOldPhase(slug: string): string { + try { + if (!existsSync(WORK_JSON)) return ''; + const registry = JSON.parse(readFileSync(WORK_JSON, 'utf-8')); + const entry = registry?.sessions?.[slug]; + return entry?.phase?.toLowerCase() || ''; + } catch { + return ''; + } +} + +function extractDecisions(content: string): string { + const match = content.match(/## Decisions\n([\s\S]*?)(?=\n## |$)/); + return match ? match[1] : ''; +} + +// --- Voice notification (optional, non-fatal) --- + +async function sendVoiceWarning(message: string): Promise { + if (!VOICE_URL) return; + try { + await fetch(VOICE_URL, { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ + message: `Warning. ${message}`, + voice_name: 'algorithm', + voice_enabled: true, + }), + signal: AbortSignal.timeout(3000), + }); + } catch { /* voice server may be down — non-fatal */ } +} + +// --- Main --- + +let input: any; +try { + input = JSON.parse(readFileSync(0, 'utf-8')); +} catch { + console.log(JSON.stringify({ continue: true })); + process.exit(0); +} + +const toolInput = input.tool_input || {}; + +async function main() { + // Only trigger for PRD.md files in MEMORY/WORK/ + const filePath: string = toolInput.file_path || ''; + if (!filePath.includes('MEMORY/WORK/') || !filePath.endsWith('PRD.md')) return; + if (!existsSync(filePath)) return; + + const content = readFileSync(filePath, 'utf-8'); + const fm = parseFrontmatter(content); + if (!fm) return; + + const newPhase = (fm.phase || '').toLowerCase(); + const prdType = (fm.type || '').toLowerCase(); + + // Skip for non-code PRD types + if (SKIP_TYPES.has(prdType)) return; + + // Detect phase transition + const oldPhase = fm.slug ? getOldPhase(fm.slug) : ''; + if (newPhase === oldPhase) return; + + // Check each gate + const decisions = extractDecisions(content); + for (const gate of GATES) { + if (newPhase !== gate.targetPhase) continue; + if (!decisions.includes(gate.requiredPrefix)) { + console.error(`\n\u26a0\ufe0f [PhaseGate] ${gate.gateName} WARNING: ${gate.warning}\n`); + await sendVoiceWarning(gate.warning); + } + } +} + +main().catch(() => {}).finally(() => { + console.log(JSON.stringify({ continue: true })); + process.exit(0); +}); diff --git a/Tools/README.md b/Tools/README.md index 8cd8f35a7..a340d0fb5 100644 --- a/Tools/README.md +++ b/Tools/README.md @@ -29,6 +29,31 @@ bun BackupRestore.ts list # List backups bun BackupRestore.ts restore # Restore ``` +### PhaseGate.hook.ts + +**Algorithm Gate Enforcement (Claude Code Hook)** + +Watches PRD.md edits and warns when the AI transitions to THINK without an `ENVIRONMENT:` check or to BUILD without a `VALIDATE:` entry in `## Decisions`. Warning mode only — never blocks execution. + +Register as a Claude Code PostToolUse hook: +```json +{ "matcher": "Write", "hooks": [{ "type": "command", "command": "~/.claude/Tools/PhaseGate.hook.ts" }] } +{ "matcher": "Edit", "hooks": [{ "type": "command", "command": "~/.claude/Tools/PhaseGate.hook.ts" }] } +``` + +### ReflectionDigest.ts + +**Reflection-to-Action Loop** + +Reads `algorithm-reflections.jsonl`, clusters failure patterns, identifies missed capabilities, and generates ranked heuristic rules. Closes the gap between writing reflections and acting on them. + +```bash +bun ReflectionDigest.ts # Write digest to MEMORY/LEARNING/ +bun ReflectionDigest.ts --dry-run # Print without writing +``` + +Run every ~10 Algorithm sessions to keep the digest current. + --- ## Quick Reference @@ -37,6 +62,8 @@ bun BackupRestore.ts restore # Restore |------|---------| | validate-protected.ts | Validate no sensitive data in commits | | BackupRestore.ts | Backup and restore PAI installations | +| PhaseGate.hook.ts | Enforce VALIDATE and ENVIRONMENT gates via PRD check | +| ReflectionDigest.ts | Extract failure patterns from reflections into heuristics | --- diff --git a/Tools/ReflectionDigest.ts b/Tools/ReflectionDigest.ts new file mode 100755 index 000000000..063a265a7 --- /dev/null +++ b/Tools/ReflectionDigest.ts @@ -0,0 +1,233 @@ +#!/usr/bin/env bun +/** + * ReflectionDigest.ts — Extract failure patterns from Algorithm reflections + * + * Reads the algorithm-reflections.jsonl file, clusters failure patterns by + * keyword, identifies low-sentiment sessions, and generates ranked heuristic + * rules that close the reflection-to-action loop. + * + * Usage: + * bun ReflectionDigest.ts # Write digest to MEMORY/LEARNING/ + * bun ReflectionDigest.ts --dry-run # Print without writing + * + * Why this exists: + * PAI's Algorithm writes reflections after every session (Q1: what went wrong, + * Q2: what would a smarter algorithm do, Q3: what capabilities were missed). + * But reflections are write-only — they sit in a JSONL file and are never + * systematically read back. This tool closes the loop by extracting patterns + * and generating distilled heuristics that can be injected at session start. + * + * Output: MEMORY/LEARNING/reflection-digest.md + * - Ranked failure patterns with frequency and sentiment correlation + * - Missed capabilities ranked by occurrence + * - Low-sentiment session analysis + * - Generated heuristic rules (candidate rules for user review) + */ + +import { readFileSync, writeFileSync, existsSync } from 'fs'; +import { join } from 'path'; +import { homedir } from 'os'; + +// --- Configuration --- + +const CLAUDE_DIR = process.env.PAI_DIR || join(homedir(), '.claude'); +const REFLECTIONS_PATH = join(CLAUDE_DIR, 'MEMORY', 'LEARNING', 'REFLECTIONS', 'algorithm-reflections.jsonl'); +const DIGEST_PATH = join(CLAUDE_DIR, 'MEMORY', 'LEARNING', 'reflection-digest.md'); +const DRY_RUN = process.argv.includes('--dry-run'); + +// --- Types --- + +interface Reflection { + timestamp: string; + effort_level: string; + task_description: string; + criteria_count: number; + criteria_passed: number; + criteria_failed: number; + prd_id: string; + implied_sentiment: number; + reflection_q1: string; + reflection_q2: string; + reflection_q3: string; + within_budget: boolean; +} + +interface Pattern { + keyword: string; + count: number; + frequency: string; + examples: string[]; + sentiment_avg: number; +} + +// --- Pattern dictionaries --- + +/** Failure pattern keywords extracted from Q1 reflections */ +const FAILURE_KEYWORDS: Record = { + 'validate-before-build': ['validate', 'assumption', 'poc', 'proof', 'prove', 'verify first', 'tested first'], + 'parallel-failures': ['parallel', 'worktree', 'agent spawn', 'concurrent', 'multiple agents'], + 'testing-after-code': ['test first', 'tdd', 'tests before', 'should have tested', 'without testing'], + 'env-assumptions': ['environment', 'install', 'import', 'dependency', 'vram', 'ram', 'python 3'], + 'phantom-capabilities': ['capability', 'skill', 'invoke', 'should have used', 'never called'], + 'over-commitment': ['too many', 'over-commit', 'too ambitious', 'scope'], + 'delayed-diagnosis': ['after the damage', 'too late', 'should have caught', 'already'], + 'single-path-blindness': ['alternative', 'only one approach', 'single path', 'benchmark', 'compare'], +}; + +/** Missed capability keywords extracted from Q3 reflections */ +const CAPABILITY_KEYWORDS: Record = { + 'Research': ['research skill', 'research formal', 'web research'], + 'Browser': ['browser', 'dom inspection', 'live dom', 'selector'], + '/batch': ['batch', 'multi-file', 'parallel file'], + '/simplify': ['simplify', 'quality', 'review'], + 'Council': ['council', 'debate', 'perspectives'], + 'FirstPrinciples': ['first principles', 'root cause'], + 'Science': ['science', 'hypothesis', 'experiment'], + 'Agents': ['background agents', 'parallel research', 'spawn'], +}; + +// --- Core logic --- + +function loadReflections(): Reflection[] { + if (!existsSync(REFLECTIONS_PATH)) { + console.error(`No reflections file found at ${REFLECTIONS_PATH}`); + console.error('The Algorithm writes reflections to this file during the LEARN phase.'); + process.exit(1); + } + + return readFileSync(REFLECTIONS_PATH, 'utf-8') + .trim() + .split('\n') + .filter(l => l.trim()) + .map(l => { try { return JSON.parse(l); } catch { return null; } }) + .filter((r): r is Reflection => r !== null); +} + +function clusterPatterns( + reflections: Reflection[], + field: 'reflection_q1' | 'reflection_q3', + keywords: Record, +): Pattern[] { + const patterns: Pattern[] = []; + + for (const [name, terms] of Object.entries(keywords)) { + const matches = reflections.filter(r => { + const text = (r[field] || '').toLowerCase(); + return terms.some(t => text.includes(t)); + }); + + if (matches.length === 0) continue; + + patterns.push({ + keyword: name, + count: matches.length, + frequency: `${((matches.length / reflections.length) * 100).toFixed(0)}%`, + examples: matches.slice(0, 3).map(r => + `${r.task_description.substring(0, 60)}: "${(r[field] || '').substring(0, 80)}"` + ), + sentiment_avg: Math.round( + (matches.reduce((sum, r) => sum + (r.implied_sentiment || 7), 0) / matches.length) * 10 + ) / 10, + }); + } + + return patterns.sort((a, b) => b.count - a.count); +} + +function generateHeuristics(failurePatterns: Pattern[], capPatterns: Pattern[]): string[] { + const heuristics: string[] = []; + + const HEURISTIC_MAP: Record = { + 'validate-before-build': 'NEVER build until the core assumption is proven via a 30-second PoC.', + 'parallel-failures': 'RUN `git worktree list` before spawning parallel agents. If it fails, go sequential.', + 'testing-after-code': 'WRITE the test file BEFORE the implementation file. No exceptions for code tasks.', + 'env-assumptions': 'RUN `which`/`import`/version check for every tool BEFORE designing around it.', + 'phantom-capabilities': 'At EXECUTE START, list the EXACT tool call for each selected capability.', + 'over-commitment': 'Before accepting parallel work, verify current commitments can be completed first.', + 'delayed-diagnosis': 'Diagnose problems at the FIRST signal, not after investing further.', + 'single-path-blindness': 'For Extended+, generate 2-3 alternative approaches BEFORE committing to one.', + }; + + for (const p of failurePatterns.slice(0, 5)) { + const rule = HEURISTIC_MAP[p.keyword] || `Address "${p.keyword}" pattern (${p.frequency} frequency).`; + heuristics.push(`[${p.frequency}] ${rule}`); + } + + for (const p of capPatterns.slice(0, 3)) { + heuristics.push(`[missed ${p.count}x] Consider ${p.keyword} for tasks involving ${p.examples[0]?.split(':')[0]?.trim() || 'similar work'}.`); + } + + return heuristics; +} + +function formatDigest( + reflections: Reflection[], + failurePatterns: Pattern[], + capPatterns: Pattern[], + lowSentiment: Reflection[], + heuristics: string[], +): string { + const now = new Date().toISOString().split('T')[0]; + const avgSentiment = reflections.reduce((s, r) => s + (r.implied_sentiment || 7), 0) / reflections.length; + + let out = `# Reflection Digest\n\n`; + out += `**Generated:** ${now} | **Reflections analyzed:** ${reflections.length} | **Avg sentiment:** ${avgSentiment.toFixed(1)}/10\n\n`; + + out += `## Top Failure Patterns\n\n`; + out += `| Pattern | Frequency | Avg Sentiment | Count |\n`; + out += `|---------|-----------|---------------|-------|\n`; + for (const p of failurePatterns) { + out += `| ${p.keyword} | ${p.frequency} | ${p.sentiment_avg} | ${p.count}/${reflections.length} |\n`; + } + + out += `\n## Missed Capabilities\n\n`; + out += `| Capability | Times Missed |\n`; + out += `|-----------|-------------|\n`; + for (const p of capPatterns) { + out += `| ${p.keyword} | ${p.count} |\n`; + } + + if (lowSentiment.length > 0) { + out += `\n## Low-Sentiment Sessions (\u22646/10)\n\n`; + for (const r of lowSentiment) { + out += `- **[${r.implied_sentiment}]** ${r.task_description}: ${r.reflection_q1.substring(0, 120)}\n`; + } + } + + out += `\n## Generated Heuristics\n\n`; + out += `Distilled rules from pattern analysis. Review and approve to create feedback memories.\n\n`; + for (let i = 0; i < heuristics.length; i++) { + out += `${i + 1}. ${heuristics[i]}\n`; + } + + out += `\n---\n*Run \`bun Tools/ReflectionDigest.ts\` periodically (every ~10 sessions) to update.*\n`; + return out; +} + +// --- Main --- + +const reflections = loadReflections(); +console.log(`\ud83d\udcca Loaded ${reflections.length} reflections`); + +const failurePatterns = clusterPatterns(reflections, 'reflection_q1', FAILURE_KEYWORDS); +const capPatterns = clusterPatterns(reflections, 'reflection_q3', CAPABILITY_KEYWORDS); +const lowSentiment = reflections.filter(r => (r.implied_sentiment || 10) <= 6); +const heuristics = generateHeuristics(failurePatterns, capPatterns); + +console.log(`\u26a0\ufe0f Failure patterns: ${failurePatterns.length}`); +for (const p of failurePatterns) { + console.log(` ${p.keyword}: ${p.count}/${reflections.length} (${p.frequency}), avg sentiment ${p.sentiment_avg}`); +} +console.log(`\ud83d\udd27 Missed capabilities: ${capPatterns.length}`); +console.log(`\ud83d\udcc9 Low-sentiment sessions: ${lowSentiment.length}`); +console.log(`\ud83d\udca1 Generated ${heuristics.length} heuristics`); + +const digest = formatDigest(reflections, failurePatterns, capPatterns, lowSentiment, heuristics); + +if (DRY_RUN) { + console.log('\n--- DRY RUN ---\n'); + console.log(digest); +} else { + writeFileSync(DIGEST_PATH, digest); + console.log(`\n\u2705 Digest written to ${DIGEST_PATH}`); +}