diff --git a/.agents/skills/strategic-compact/SKILL.md b/.agents/skills/strategic-compact/SKILL.md index cbad6c428..e402dd81c 100644 --- a/.agents/skills/strategic-compact/SKILL.md +++ b/.agents/skills/strategic-compact/SKILL.md @@ -73,7 +73,7 @@ Use this table to decide when to compact: | Phase Transition | Compact? | Why | |-----------------|----------|-----| | Research → Planning | Yes | Research context is bulky; plan is the distilled output | -| Planning → Implementation | Yes | Plan is in TodoWrite or a file; free up context for code | +| Planning → Implementation | Yes | Plan is written down (a file, or the task list if you have one); free up context for code | | Implementation → Testing | Maybe | Keep if tests reference recent code; compact if switching focus | | Debugging → Next feature | Yes | Debug traces pollute context for unrelated work | | Mid-implementation | No | Losing variable names, file paths, and partial state is costly | @@ -86,14 +86,28 @@ Understanding what persists helps you compact with confidence: | Persists | Lost | |----------|------| | CLAUDE.md instructions | Intermediate reasoning and analysis | -| TodoWrite task list | File contents you previously read | +| Files on disk | File contents you previously read | | Memory files (`~/.claude/memory/`) | Multi-step conversation context | | Git state (commits, branches) | Tool call history and counts | -| Files on disk | Nuanced user preferences stated verbally | +| The task list — **only if you have the todo tools** (see below) | Nuanced user preferences stated verbally | + +> ### Don't rely on the task list surviving — it may not exist +> +> Claude Code **2.1.233 removed the todo/task tools by default** on Opus 4.8, Sonnet 5, +> Fable 5, Mythos 5 and newer models (`TodoWrite`, `TaskCreate/Get/Update/List`). +> `CLAUDE_CODE_ENABLE_TODO_TOOLS=1` brings them back, but that is a per-machine +> environment setting — **it does not travel with this skill**, so you cannot assume the +> reader has it. +> +> This matters because "my todo list survives compaction" is a reason people compact +> *instead of* writing state down. If the tools are absent there is no list to survive, +> and the plan is simply gone. **Write the plan to a file before compacting** — a file +> persists on every version and every model. Treat the task list as a convenience that +> may be missing, never as your durable record. ## Best Practices -1. **Compact after planning** — Once plan is finalized in TodoWrite, compact to start fresh +1. **Compact after planning** — Once the plan is finalized **and written to a file**, compact to start fresh 2. **Compact after debugging** — Clear error-resolution context before continuing 3. **Don't compact mid-implementation** — Preserve context for related changes 4. **Read the suggestion** — The hook tells you *when*, you decide *if* diff --git a/.kiro/skills/strategic-compact/SKILL.md b/.kiro/skills/strategic-compact/SKILL.md index 0d88fe563..a9a1efe50 100644 --- a/.kiro/skills/strategic-compact/SKILL.md +++ b/.kiro/skills/strategic-compact/SKILL.md @@ -71,7 +71,7 @@ Use this table to decide when to compact: | Phase Transition | Compact? | Why | |-----------------|----------|-----| | Research → Planning | Yes | Research context is bulky; plan is the distilled output | -| Planning → Implementation | Yes | Plan is in TodoWrite or a file; free up context for code | +| Planning → Implementation | Yes | Plan is written down (a file, or the task list if you have one); free up context for code | | Implementation → Testing | Maybe | Keep if tests reference recent code; compact if switching focus | | Debugging → Next feature | Yes | Debug traces pollute context for unrelated work | | Mid-implementation | No | Losing variable names, file paths, and partial state is costly | @@ -84,14 +84,28 @@ Understanding what persists helps you compact with confidence: | Persists | Lost | |----------|------| | CLAUDE.md instructions | Intermediate reasoning and analysis | -| TodoWrite task list | File contents you previously read | +| Files on disk | File contents you previously read | | Memory files (`~/.claude/memory/`) | Multi-step conversation context | | Git state (commits, branches) | Tool call history and counts | -| Files on disk | Nuanced user preferences stated verbally | +| The task list — **only if you have the todo tools** (see below) | Nuanced user preferences stated verbally | + +> ### Don't rely on the task list surviving — it may not exist +> +> Claude Code **2.1.233 removed the todo/task tools by default** on Opus 4.8, Sonnet 5, +> Fable 5, Mythos 5 and newer models (`TodoWrite`, `TaskCreate/Get/Update/List`). +> `CLAUDE_CODE_ENABLE_TODO_TOOLS=1` brings them back, but that is a per-machine +> environment setting — **it does not travel with this skill**, so you cannot assume the +> reader has it. +> +> This matters because "my todo list survives compaction" is a reason people compact +> *instead of* writing state down. If the tools are absent there is no list to survive, +> and the plan is simply gone. **Write the plan to a file before compacting** — a file +> persists on every version and every model. Treat the task list as a convenience that +> may be missing, never as your durable record. ## Best Practices -1. **Compact after planning** — Once plan is finalized in TodoWrite, compact to start fresh +1. **Compact after planning** — Once the plan is finalized **and written to a file**, compact to start fresh 2. **Compact after debugging** — Clear error-resolution context before continuing 3. **Don't compact mid-implementation** — Preserve context for related changes 4. **Read the suggestion** — The hook tells you *when*, you decide *if* diff --git a/agents/gan-evaluator.md b/agents/gan-evaluator.md index 95060e711..363e0972b 100644 --- a/agents/gan-evaluator.md +++ b/agents/gan-evaluator.md @@ -1,7 +1,7 @@ --- name: gan-evaluator description: "GAN Harness — Evaluator agent. Tests the live running application via Playwright, scores against rubric, and provides actionable feedback to the Generator." -tools: Read, Write, Bash, Grep, Glob +tools: Read, Write, Bash, Grep, Glob, mcp__playwright__browser_navigate, mcp__playwright__browser_click, mcp__playwright__browser_take_screenshot, mcp__playwright__browser_snapshot, mcp__playwright__browser_type, mcp__playwright__browser_fill_form model: sonnet color: red --- @@ -35,6 +35,12 @@ You are the QA Engineer and Design Critic. You test the **live running applicati ## Evaluation Workflow +Before testing, record the mode that is actually available. The requested mode +is not proof that its tools were available: if the Playwright MCP tools cannot +be called, switch to the documented `screenshot` or `code-only` fallback and +report that degradation instead of silently scoring a static review as a live +browser evaluation. + ### Step 1: Read the Rubric ``` Read gan-harness/eval-rubric.md for project-specific criteria @@ -129,6 +135,14 @@ Write feedback to `gan-harness/feedback/feedback-NNN.md`: ## Scores +## Evaluation Mode + +**Achieved:** `playwright` | `screenshot` | `code-only` + +State the mode that was actually completed (not merely the mode requested by +the harness). If the requested mode was unavailable, briefly explain why and +which fallback was used. + | Criterion | Score | Weight | Weighted | |-----------|-------|--------|----------| | Design Quality | X/10 | 0.3 | X.X | diff --git a/commands/resume-session.md b/commands/resume-session.md index c9bf3b726..dcc54d06c 100644 --- a/commands/resume-session.md +++ b/commands/resume-session.md @@ -30,8 +30,9 @@ This command is the counterpart to `/save-session`. If no argument provided: 1. Check `~/.claude/session-data/` -2. Pick the most recently modified `*-session.tmp` file -3. If the folder does not exist or has no matching files, tell the user: +2. Read the matching `*-session.tmp` candidates and apply the candidate ranking below +3. Load the highest-ranked candidate +4. If the folder does not exist or has no eligible matching files, tell the user: ``` No session files found in ~/.claude/session-data/ Run /save-session at the end of a session to create one. @@ -42,11 +43,30 @@ If an argument is provided: - If it looks like a date (`YYYY-MM-DD`), search `~/.claude/session-data/` first, then the legacy `~/.claude/sessions/`, for files matching `YYYY-MM-DD-session.tmp` (legacy format) or - `YYYY-MM-DD--session.tmp` (current format) - and load the most recently modified variant for that date -- If it looks like a file path, read that file directly + `YYYY-MM-DD--session.tmp` (current format), apply the candidate ranking below across + all matches, and load the highest-ranked candidate for that date +- If it looks like a file path, read exactly that file directly. Do not apply candidate ranking or + substitute a different file, even if the requested file is empty or another file is newer - If not found, report clearly and stop +#### Candidate ranking for implicit and date-based lookup + +Rank only automatically discovered candidates. Never use this ranking for an explicit file path. + +1. Reject files that are unreadable, empty, whitespace-only, or contain only headings, metadata, + separators, and placeholder values such as `[Session context goes here]`, `- [ ]`, a lone `-`, + or `[relevant files]`. +2. Reject generated summaries with only one task and no populated files-modified, tools-used, + completed, in-progress, notes, or context-to-load content. This structural rule filters + one-message summarizer echoes without depending on any particular prompt text. +3. Keep candidates with substantive populated content: completed work, in-progress work, concrete + next-session notes, concrete context paths, multiple tasks, modified files, or tools used. +4. Among eligible substantive candidates, prefer the newest modification time. +5. If modification times are equal, prefer more populated sections, then more non-placeholder + content, then larger byte size, then the lexicographically smaller resolved path. Count populated + sections and content only after removing headings, metadata, separators, and placeholder text. + These final tie-breaks make selection deterministic. + ### Step 2: Read the entire session file Read the complete file. Do not summarize yet. @@ -96,7 +116,9 @@ If no next step is defined — ask the user where to start, and optionally sugge ## Edge Cases **Multiple sessions for the same date** (`2024-01-15-session.tmp`, `2024-01-15-abc123de-session.tmp`): -Load the most recently modified matching file for that date, regardless of whether it uses the legacy no-id format or the current short-id format. +Apply the candidate ranking across every matching legacy and current-format file. A substantive +session must win over a newer placeholder or one-message summarizer echo; modification time decides +between eligible candidates. **Session file references files that no longer exist:** Note this during the briefing — "WARNING: `path/to/file.ts` referenced in session but not found on disk." @@ -108,7 +130,10 @@ Note the gap — "WARNING: This session is from N days ago (threshold: 7 days). Read it and follow the same briefing process — the format is the same regardless of source. **Session file is empty or malformed:** -Report: "Session file found but appears empty or unreadable. You may need to create a new one with /save-session." +For implicit or date-based discovery, reject it and continue ranking the remaining candidates. If no +eligible candidate remains, report: "Session files were found but appear empty or unreadable. You may +need to create a new one with /save-session." For an explicit path, report that the requested file is +empty or unreadable without loading a substitute. --- diff --git a/scripts/hooks/session-end.js b/scripts/hooks/session-end.js index c224371aa..7139562ad 100644 --- a/scripts/hooks/session-end.js +++ b/scripts/hooks/session-end.js @@ -181,6 +181,29 @@ async function main() { } } + // ECC's LLM summary helper launches a one-shot Claude subprocess whose Stop + // hooks inherit this dedicated marker. Skip that known internal session + // before touching session state. Transcript cardinality is not a safe proxy: + // an ordinary user session may legitimately contain one prompt and no tools. + if (process.env.ECC_LLM_SUMMARY_SUBPROCESS === '1') { + log('[SessionEnd] Skipped ECC LLM summary subprocess'); + return; + } + + // Read known transcripts before resolving session metadata or touching the + // session directory. Missing, unreadable, or unparseable transcript data keeps + // the established fallback behavior because it cannot be classified reliably. + let summary = null; + let transcriptExists = false; + if (transcriptPath) { + transcriptExists = fs.existsSync(transcriptPath); + if (transcriptExists) { + summary = extractSessionSummary(transcriptPath); + } else { + log(`[SessionEnd] Transcript not found: ${transcriptPath}`); + } + } + const sessionsDir = getSessionsDir(); const today = getDateString(); // Derive shortId from transcript_path UUID when available, using the SAME @@ -211,21 +234,10 @@ async function main() { const currentTime = getTimeString(); - // Try to extract summary from transcript - let summary = null; - - if (transcriptPath) { - if (fs.existsSync(transcriptPath)) { - summary = extractSessionSummary(transcriptPath); - } else { - log(`[SessionEnd] Transcript not found: ${transcriptPath}`); - } - } - // Decide whether to call LLM for a richer summary. // Triggers: context remaining < 20%, or every 50 user messages as a baseline. let llmSummary = null; - if (transcriptPath && summary && fs.existsSync(transcriptPath)) { + if (transcriptPath && summary && transcriptExists) { const contextPct = getContextRemainingPct(transcriptPath); const isContextLow = contextPct !== null && contextPct < getContextThreshold(); const interval = parseInt(process.env.ECC_LLM_SUMMARY_INTERVAL || '50', 10); diff --git a/scripts/lib/llm-summary.js b/scripts/lib/llm-summary.js index e7d5d56a4..b53fabd89 100644 --- a/scripts/lib/llm-summary.js +++ b/scripts/lib/llm-summary.js @@ -156,7 +156,8 @@ function generateSessionSummary(transcriptPath) { env: { ...process.env, CLAUDECODE: '', - ECC_SKIP_LLM_SUMMARY: '1' + ECC_SKIP_LLM_SUMMARY: '1', + ECC_LLM_SUMMARY_SUBPROCESS: '1' }, timeout: LLM_TIMEOUT_MS, shell: process.platform === 'win32' diff --git a/skills/continuous-learning-v2/agents/observer-loop.sh b/skills/continuous-learning-v2/agents/observer-loop.sh index f75365920..74b8f5110 100755 --- a/skills/continuous-learning-v2/agents/observer-loop.sh +++ b/skills/continuous-learning-v2/agents/observer-loop.sh @@ -153,10 +153,17 @@ analyze_observations() { analysis_count=$(wc -l < "$analysis_file" 2>/dev/null || echo 0) echo "[$(date)] Using last $analysis_count of $obs_count observations for analysis" >> "$LOG_FILE" - # Use relative path from PROJECT_DIR for cross-platform compatibility (#842). - # On Windows (Git Bash/MSYS2), absolute paths from mktemp may use MSYS-style - # prefixes (e.g. /c/Users/...) that the Claude subprocess cannot resolve. - analysis_relpath=".observer-tmp/$(basename "$analysis_file")" + # Claude Code resolves relative paths against the user's home directory on + # macOS/Linux, even though the observer changes to PROJECT_DIR first. Use + # the absolute path there so the analyzer reads the file that was sampled. + # Keep the relative path on Windows (Git Bash/MSYS2), where absolute paths + # from mktemp can contain /c/ prefixes that the Claude subprocess cannot + # resolve (#842, #2673). + if [ "${CLV2_IS_WINDOWS:-false}" = "true" ]; then + analysis_relpath=".observer-tmp/$(basename "$analysis_file")" + else + analysis_relpath="$analysis_file" + fi prompt_file="$(mktemp "${observer_tmp_dir}/ecc-observer-prompt.XXXXXX")" cat > "$prompt_file" < int: # Generate Evolved Structures # ───────────────────────────────────────────── +def _evolved_description(trigger: str, instincts: list, kind: str) -> str: + """Build the frontmatter `description` for a generated artifact. + + Claude Code (and every spec-compliant Agent Skills client) injects only + `name` + `description` at startup and will not load an artifact that lacks + them, so a generated skill/agent without frontmatter is inert on disk. + """ + ids = ', '.join(i.get('id', 'unnamed') for i in instincts[:6]) + trig = (trigger or '').strip().rstrip('.') or 'a recurring situation' + description = ( + f"Evolved {kind} covering {len(instincts)} learned instinct(s). " + f"Use {trig}. Source instincts - {ids}." + ) + # `: ` breaks strict YAML parsers in an unquoted scalar; `<`/`>` can inject + # into the system prompt. + return description.replace(': ', ' - ').replace('<', '(').replace('>', ')') + + def _generate_evolved(skill_candidates: list, workflow_instincts: list, agent_candidates: list, evolved_dir: Path, limit: int = 0) -> list[str]: """Generate skill/command/agent files from analyzed instinct clusters. @@ -1966,7 +1984,11 @@ def _generate_evolved(skill_candidates: list, workflow_instincts: list, agent_ca skill_dir = evolved_dir / "skills" / name skill_dir.mkdir(parents=True, exist_ok=True) - content = f"# {name}\n\n" + content = "---\n" + content += f"name: {name}\n" + content += f"description: {_yaml_quote(_evolved_description(trigger, cand['instincts'], 'skill'))}\n" + content += "---\n\n" + content += f"# {name}\n\n" content += f"Evolved from {len(cand['instincts'])} instincts " content += f"(avg confidence: {cand['avg_confidence']:.0%})\n\n" content += f"## When to Apply\n\n" @@ -1993,7 +2015,10 @@ def _generate_evolved(skill_candidates: list, workflow_instincts: list, agent_ca continue cmd_file = evolved_dir / "commands" / f"{cmd_name}.md" - content = f"# {cmd_name}\n\n" + content = "---\n" + content += f"description: {_yaml_quote(_evolved_description(inst.get('trigger', ''), [inst], 'command'))}\n" + content += "---\n\n" + content += f"# {cmd_name}\n\n" content += f"Evolved from instinct: {inst.get('id', 'unnamed')}\n" content += f"Confidence: {inst.get('confidence', 0.5):.0%}\n\n" content += inst.get('content', '') @@ -2016,7 +2041,10 @@ def _generate_evolved(skill_candidates: list, workflow_instincts: list, agent_ca domains = ', '.join(cand['domains']) instinct_ids = [i.get('id', 'unnamed') for i in cand['instincts']] - content = f"---\nmodel: sonnet\ntools: Read, Grep, Glob\n---\n" + content = "---\n" + content += f"name: {agent_name}\n" + content += f"description: {_yaml_quote(_evolved_description(str(cand.get('trigger', '')), cand['instincts'], 'agent'))}\n" + content += "model: sonnet\ntools: Read, Grep, Glob\n---\n" content += f"# {agent_name}\n\n" content += f"Evolved from {len(cand['instincts'])} instincts " content += f"(avg confidence: {cand['avg_confidence']:.0%})\n" diff --git a/skills/strategic-compact/SKILL.md b/skills/strategic-compact/SKILL.md index 0f7923553..134e76715 100644 --- a/skills/strategic-compact/SKILL.md +++ b/skills/strategic-compact/SKILL.md @@ -80,7 +80,7 @@ Use this table to decide when to compact: | Phase Transition | Compact? | Why | |-----------------|----------|-----| | Research → Planning | Yes | Research context is bulky; plan is the distilled output | -| Planning → Implementation | Yes | Plan is in TodoWrite or a file; free up context for code | +| Planning → Implementation | Yes | Plan is written down (a file, or the task list if you have one); free up context for code | | Implementation → Testing | Maybe | Keep if tests reference recent code; compact if switching focus | | Debugging → Next feature | Yes | Debug traces pollute context for unrelated work | | Mid-implementation | No | Losing variable names, file paths, and partial state is costly | @@ -93,14 +93,28 @@ Understanding what persists helps you compact with confidence: | Persists | Lost | |----------|------| | CLAUDE.md instructions | Intermediate reasoning and analysis | -| TodoWrite task list | File contents you previously read | +| Files on disk | File contents you previously read | | Memory files (`~/.claude/memory/`) | Multi-step conversation context | | Git state (commits, branches) | Tool call history and counts | -| Files on disk | Nuanced user preferences stated verbally | +| The task list — **only if you have the todo tools** (see below) | Nuanced user preferences stated verbally | + +> ### Don't rely on the task list surviving — it may not exist +> +> Claude Code **2.1.233 removed the todo/task tools by default** on Opus 4.8, Sonnet 5, +> Fable 5, Mythos 5 and newer models (`TodoWrite`, `TaskCreate/Get/Update/List`). +> `CLAUDE_CODE_ENABLE_TODO_TOOLS=1` brings them back, but that is a per-machine +> environment setting — **it does not travel with this skill**, so you cannot assume the +> reader has it. +> +> This matters because "my todo list survives compaction" is a reason people compact +> *instead of* writing state down. If the tools are absent there is no list to survive, +> and the plan is simply gone. **Write the plan to a file before compacting** — a file +> persists on every version and every model. Treat the task list as a convenience that +> may be missing, never as your durable record. ## Best Practices -1. **Compact after planning** — Once plan is finalized in TodoWrite, compact to start fresh +1. **Compact after planning** — Once the plan is finalized **and written to a file**, compact to start fresh 2. **Compact after debugging** — Clear error-resolution context before continuing 3. **Don't compact mid-implementation** — Preserve context for related changes 4. **Read the suggestion** — The hook tells you *when*, you decide *if* diff --git a/tests/ci/gan-evaluator-tools.test.js b/tests/ci/gan-evaluator-tools.test.js new file mode 100644 index 000000000..2c51922e0 --- /dev/null +++ b/tests/ci/gan-evaluator-tools.test.js @@ -0,0 +1,34 @@ +/** + * Regression coverage for the GAN evaluator's live-browser capability. + * + * Run with: node tests/ci/gan-evaluator-tools.test.js + */ + +const assert = require('assert'); +const fs = require('fs'); +const path = require('path'); + +const evaluatorPath = path.join(__dirname, '..', '..', 'agents', 'gan-evaluator.md'); +const content = fs.readFileSync(evaluatorPath, 'utf8'); +const frontmatter = content.match(/^---\r?\n([\s\S]*?)\r?\n---/); + +assert.ok(frontmatter, 'gan-evaluator.md should have frontmatter'); +const toolsLine = frontmatter[1].match(/^tools:\s*(.+)$/m); +assert.ok(toolsLine, 'gan-evaluator.md should declare tools'); + +const tools = new Set(toolsLine[1].split(',').map(tool => tool.trim())); +for (const tool of [ + 'mcp__playwright__browser_navigate', + 'mcp__playwright__browser_click', + 'mcp__playwright__browser_take_screenshot', + 'mcp__playwright__browser_snapshot', + 'mcp__playwright__browser_type', + 'mcp__playwright__browser_fill_form', +]) { + assert.ok(tools.has(tool), `gan-evaluator.md should grant ${tool}`); +} + +assert.match(content, /\*\*Achieved:\*\* `playwright` \| `screenshot` \| `code-only`/); +assert.match(content, /mode that was actually completed/); + +console.log('GAN evaluator tools and achieved-mode contract are present.'); diff --git a/tests/hooks/hooks.test.js b/tests/hooks/hooks.test.js index 49d6f1e23..4c15970b0 100644 --- a/tests/hooks/hooks.test.js +++ b/tests/hooks/hooks.test.js @@ -4888,7 +4888,11 @@ async function runTests() { const testDir = createTestDir(); const transcriptPath = path.join(testDir, 'transcript.jsonl'); // Only user messages — no tool_use entries at all - const lines = ['{"type":"user","content":"How does authentication work?"}', '{"type":"assistant","message":{"content":[{"type":"text","text":"It uses JWT"}]}}']; + const lines = [ + '{"type":"user","content":"How does authentication work?"}', + '{"type":"assistant","message":{"content":[{"type":"text","text":"It uses JWT"}]}}', + '{"type":"user","content":"Explain the token refresh path too"}' + ]; fs.writeFileSync(transcriptPath, lines.join('\n')); const stdinJson = JSON.stringify({ transcript_path: transcriptPath }); @@ -5262,8 +5266,11 @@ async function runTests() { await asyncTest('handles stdin exceeding MAX_STDIN (1MB) gracefully', async () => { const testDir = createTestDir(); const transcriptPath = path.join(testDir, 'transcript.jsonl'); - // Create a minimal valid transcript so env var fallback works - fs.writeFileSync(transcriptPath, JSON.stringify({ type: 'user', content: 'Overflow test' }) + '\n'); + // Create a substantive valid transcript so env var fallback works + fs.writeFileSync( + transcriptPath, + [JSON.stringify({ type: 'user', content: 'Overflow test' }), JSON.stringify({ type: 'user', content: 'Verify fallback behavior' })].join('\n') + '\n' + ); // Create stdin > 1MB: truncated JSON will be invalid → falls back to env var const oversizedPayload = '{"transcript_path":"' + 'x'.repeat(1048600) + '"}'; @@ -5880,6 +5887,8 @@ async function runTests() { const lines = [ // Normal user message (string content) — should be included '{"type":"user","content":"Real user message"}', + // A second valid message keeps this fixture eligible for persistence + '{"type":"user","content":"Follow-up user message"}', // User message with numeric content — exercises the else: '' branch '{"type":"user","content":42}', // User message with boolean content — also hits the else branch diff --git a/tests/hooks/observer-memory.test.js b/tests/hooks/observer-memory.test.js index 86c324c46..c7dc9464d 100644 --- a/tests/hooks/observer-memory.test.js +++ b/tests/hooks/observer-memory.test.js @@ -220,7 +220,20 @@ test('prompt references analysis_file not full OBSERVATIONS_FILE', () => { assert.ok(heredocStart > 0, 'Should find prompt heredoc start'); assert.ok(heredocEnd > heredocStart, 'Should find prompt heredoc end'); const promptSection = content.substring(heredocStart, heredocEnd); - assert.ok(promptSection.includes('${analysis_relpath}'), 'Prompt should point Claude at the sampled analysis file (via relative path), not the full observations file'); + assert.ok(promptSection.includes('${analysis_relpath}'), 'Prompt should point Claude at the sampled analysis file, not the full observations file'); +}); + +test('observer uses an absolute analysis path outside Windows', () => { + const content = fs.readFileSync(observerLoopPath, 'utf8'); + assert.ok( + content.includes('if [ "${CLV2_IS_WINDOWS:-false}" = "true" ]') && + content.includes('analysis_relpath="$analysis_file"'), + 'macOS and Linux must pass the absolute analysis path to Claude' + ); + assert.ok( + content.includes('analysis_relpath=".observer-tmp/$(basename "$analysis_file")"'), + 'Windows must retain the MSYS-compatible relative analysis path' + ); }); test('observer-loop wait helper retries SIGUSR1-interrupted waits while claude child is alive', () => { diff --git a/tests/hooks/session-end.test.js b/tests/hooks/session-end.test.js index 9008674d9..05e74eeac 100644 --- a/tests/hooks/session-end.test.js +++ b/tests/hooks/session-end.test.js @@ -37,6 +37,20 @@ function countOccurrences(haystack, needle) { return n; } +function runHook(home, transcript, env = {}) { + return spawnSync('node', [script], { + encoding: 'utf8', + input: transcript ? JSON.stringify({ transcript_path: transcript }) : '', + env: { ...process.env, HOME: home, USERPROFILE: home, CLAUDE_SESSION_ID: '', ...env }, + timeout: 10000, + }); +} + +function sessionFileFor(home, uuid) { + const shortId = sanitizeSessionId(uuid.slice(-8).toLowerCase()); + return path.join(home, '.claude', 'session-data', `${getDateString()}-${shortId}-session.tmp`); +} + function runTests() { console.log('\n=== Testing session-end.js ===\n'); @@ -73,7 +87,10 @@ function runTests() { const transcript = path.join(home, `${uuid}.jsonl`); fs.writeFileSync( transcript, - JSON.stringify({ type: 'user', message: { role: 'user', content: userText } }) + '\n' + [ + JSON.stringify({ type: 'user', message: { role: 'user', content: userText } }), + JSON.stringify({ type: 'tool_use', tool_name: 'Edit', tool_input: { file_path: '/src/release.js' } }), + ].join('\n') + '\n' ); const res = spawnSync('node', [script], { @@ -95,6 +112,131 @@ function runTests() { } }) ? passed++ : failed++); + (test('writes a session for a multi-message transcript', () => { + const home = fs.mkdtempSync(path.join(os.tmpdir(), 'ecc-session-end-')); + try { + const uuid = '11111111-2222-4333-8444-555555555555'; + const transcript = path.join(home, `${uuid}.jsonl`); + fs.writeFileSync( + transcript, + [ + JSON.stringify({ type: 'user', content: 'Investigate the failing hook' }), + JSON.stringify({ type: 'user', content: 'Add regression coverage' }), + ].join('\n') + '\n' + ); + + const res = runHook(home, transcript); + assert.strictEqual(res.status || 0, 0, `hook exited ${res.status}: ${res.stderr}`); + + const sessionFile = sessionFileFor(home, uuid); + const out = fs.readFileSync(sessionFile, 'utf8'); + assert.ok(out.includes(START), 'Should include the generated summary start marker'); + assert.ok(out.includes(END), 'Should include the generated summary end marker'); + assert.ok(out.includes('**Last Updated:**'), 'Should include session metadata'); + assert.ok(out.includes('Add regression coverage'), 'Should include the latest user task'); + } finally { + fs.rmSync(home, { recursive: true, force: true }); + } + }) ? passed++ : failed++); + + (test('writes a session for one user message with tool activity', () => { + const home = fs.mkdtempSync(path.join(os.tmpdir(), 'ecc-session-end-')); + try { + const uuid = 'aaaaaaaa-bbbb-4ccc-8ddd-eeeeeeeeeeee'; + const transcript = path.join(home, `${uuid}.jsonl`); + fs.writeFileSync( + transcript, + [ + JSON.stringify({ type: 'user', content: 'Fix the configuration' }), + JSON.stringify({ type: 'tool_use', tool_name: 'Edit', tool_input: { file_path: '/src/config.js' } }), + ].join('\n') + '\n' + ); + + const res = runHook(home, transcript); + assert.strictEqual(res.status || 0, 0, `hook exited ${res.status}: ${res.stderr}`); + assert.ok(fs.existsSync(sessionFileFor(home, uuid)), 'Tool activity should make the session eligible'); + } finally { + fs.rmSync(home, { recursive: true, force: true }); + } + }) ? passed++ : failed++); + + (test('writes a session for a normal one-message prompt without tool activity', () => { + const home = fs.mkdtempSync(path.join(os.tmpdir(), 'ecc-session-end-')); + try { + const uuid = '12345678-1234-4234-8234-123456789abc'; + const transcript = path.join(home, `${uuid}.jsonl`); + fs.writeFileSync(transcript, JSON.stringify({ type: 'user', content: 'Print the current version' }) + '\n'); + + const res = runHook(home, transcript); + assert.strictEqual(res.status || 0, 0, `hook exited ${res.status}: ${res.stderr}`); + assert.ok(fs.existsSync(sessionFileFor(home, uuid)), 'A normal short user session should remain resumable'); + } finally { + fs.rmSync(home, { recursive: true, force: true }); + } + }) ? passed++ : failed++); + + (test('skips a one-message summarizer-style transcript without prompt matching', () => { + const home = fs.mkdtempSync(path.join(os.tmpdir(), 'ecc-session-end-')); + try { + const uuid = 'fedcba98-7654-4321-8765-fedcba987654'; + const transcript = path.join(home, `${uuid}.jsonl`); + fs.writeFileSync( + transcript, + [ + JSON.stringify({ type: 'user', message: { role: 'user', content: 'Summarize the supplied conversation as concise markdown.' } }), + JSON.stringify({ type: 'assistant', message: { role: 'assistant', content: '## Summary\nThe hook behavior was reviewed.' } }), + ].join('\n') + '\n' + ); + + const res = runHook(home, transcript, { ECC_LLM_SUMMARY_SUBPROCESS: '1' }); + assert.strictEqual(res.status || 0, 0, `hook exited ${res.status}: ${res.stderr}`); + assert.ok(!fs.existsSync(sessionFileFor(home, uuid)), 'Summarizer subprocess should not create a session file'); + } finally { + fs.rmSync(home, { recursive: true, force: true }); + } + }) ? passed++ : failed++); + + (test('does not rewrite an existing session for a rejected transcript', () => { + const home = fs.mkdtempSync(path.join(os.tmpdir(), 'ecc-session-end-')); + try { + const uuid = '99999999-8888-4777-8666-555555555555'; + const transcript = path.join(home, `${uuid}.jsonl`); + const sessionFile = sessionFileFor(home, uuid); + const original = '# Session: preserved\n**Last Updated:** 09:00\n\n---\n\nUser-authored context\n'; + const originalTime = new Date('2026-01-02T03:04:05.000Z'); + + fs.mkdirSync(path.dirname(sessionFile), { recursive: true }); + fs.writeFileSync(sessionFile, original); + fs.utimesSync(sessionFile, originalTime, originalTime); + fs.writeFileSync(transcript, JSON.stringify({ type: 'user', content: 'Internal summary request' }) + '\n'); + + const res = runHook(home, transcript, { ECC_LLM_SUMMARY_SUBPROCESS: '1' }); + assert.strictEqual(res.status || 0, 0, `hook exited ${res.status}: ${res.stderr}`); + assert.strictEqual(fs.readFileSync(sessionFile, 'utf8'), original, 'Internal summarizer should not change existing content'); + assert.strictEqual(fs.statSync(sessionFile).mtimeMs, originalTime.getTime(), 'Internal summarizer should not advance mtime'); + } finally { + fs.rmSync(home, { recursive: true, force: true }); + } + }) ? passed++ : failed++); + + (test('keeps fallback behavior when transcript metadata is malformed', () => { + const home = fs.mkdtempSync(path.join(os.tmpdir(), 'ecc-session-end-')); + try { + const res = spawnSync('node', [script], { + encoding: 'utf8', + input: '{not-json', + env: { ...process.env, HOME: home, USERPROFILE: home, CLAUDE_SESSION_ID: 'fallback-session-12345678', CLAUDE_TRANSCRIPT_PATH: '' }, + timeout: 10000, + }); + assert.strictEqual(res.status || 0, 0, `hook exited ${res.status}: ${res.stderr}`); + + const sessionsDir = path.join(home, '.claude', 'session-data'); + assert.strictEqual(fs.readdirSync(sessionsDir).filter(name => name.endsWith('-session.tmp')).length, 1, 'Fallback should still create the placeholder session'); + } finally { + fs.rmSync(home, { recursive: true, force: true }); + } + }) ? passed++ : failed++); + console.log(`\nResults: Passed: ${passed}, Failed: ${failed}`); process.exit(failed > 0 ? 1 : 0); } diff --git a/tests/lib/llm-summary.test.js b/tests/lib/llm-summary.test.js index e6537ba49..1705fe499 100644 --- a/tests/lib/llm-summary.test.js +++ b/tests/lib/llm-summary.test.js @@ -192,6 +192,14 @@ test('returns null for missing transcript (no conversation to summarize)', () => if (orig !== undefined) process.env.ECC_SKIP_LLM_SUMMARY = orig; }); +test('marks the spawned summarizer so its Stop hook cannot create resume state', () => { + const source = fs.readFileSync( + path.join(__dirname, '..', '..', 'scripts', 'lib', 'llm-summary.js'), + 'utf8' + ); + assert.match(source, /ECC_LLM_SUMMARY_SUBPROCESS:\s*'1'/); +}); + // --- Results --- console.log('\n=== Test Results ==='); console.log(`Passed: ${passed}`); diff --git a/tests/scripts/instinct-cli-evolve-generate.test.js b/tests/scripts/instinct-cli-evolve-generate.test.js index 956111f45..6459a56d9 100644 --- a/tests/scripts/instinct-cli-evolve-generate.test.js +++ b/tests/scripts/instinct-cli-evolve-generate.test.js @@ -243,6 +243,95 @@ test('preview names match the files --generate writes', () => { } }); +function parseFrontmatter(filePath) { + const raw = fs.readFileSync(filePath, 'utf8'); + const match = /^---\r?\n([\s\S]*?)\r?\n---\r?\n/.exec(raw); + if (!match) return null; + const fm = {}; + for (const line of match[1].split(/\r?\n/)) { + const idx = line.indexOf(':'); + if (idx > 0 && !line.startsWith(' ')) { + fm[line.slice(0, idx).trim()] = line.slice(idx + 1).trim(); + } + } + return fm; +} + +test('generated skills carry loadable name + description frontmatter', () => { + const root = createTempDir(); + try { + writeInstinct(root, 'first', 'when investigating complex systems'); + writeInstinct(root, 'second', 'when investigating complex systems'); + writeInstinct(root, 'third', 'when running tests'); + + assert.strictEqual(runCli(root, ['evolve', '--generate']).status, 0); + + const skillsDir = path.join(root, 'evolved', 'skills'); + const skillDirs = fs.existsSync(skillsDir) ? fs.readdirSync(skillsDir) : []; + assert.ok(skillDirs.length > 0, 'expected at least one generated skill'); + + for (const name of skillDirs) { + const skillFile = path.join(skillsDir, name, 'SKILL.md'); + const fm = parseFrontmatter(skillFile); + assert.ok(fm, `${name}/SKILL.md has no frontmatter block`); + assert.strictEqual(fm.name, name, `${name}: frontmatter name must match its folder`); + assert.ok(fm.description && fm.description.length > 0, `${name}: description must not be empty`); + assert.ok(!/[<>]/.test(fm.description), `${name}: description must not contain < or >`); + } + } finally { + cleanupDir(root); + } +}); + +test('generated agents carry name + description alongside model/tools', () => { + const root = createTempDir(); + try { + writeInstinct(root, 'a', 'when reviewing pull requests'); + writeInstinct(root, 'b', 'when reviewing pull requests'); + writeInstinct(root, 'c', 'when reviewing pull requests'); + + assert.strictEqual(runCli(root, ['evolve', '--generate']).status, 0); + + const agentsDir = path.join(root, 'evolved', 'agents'); + const agents = fs.existsSync(agentsDir) ? fs.readdirSync(agentsDir) : []; + assert.ok(agents.length > 0, 'expected at least one generated agent'); + + for (const file of agents) { + const fm = parseFrontmatter(path.join(agentsDir, file)); + assert.ok(fm, `${file} has no frontmatter block`); + assert.strictEqual(fm.name, path.basename(file, '.md')); + assert.ok(fm.description && fm.description.length > 0, `${file}: description must not be empty`); + assert.strictEqual(fm.model, 'sonnet'); + } + } finally { + cleanupDir(root); + } +}); + +test('generated descriptions quote YAML comment markers', () => { + const root = createTempDir(); + try { + writeInstinct(root, 'hash-marker', 'when reviewing output # preserve this text'); + writeInstinct(root, 'run-tests', 'when running tests'); + writeInstinct(root, 'build-images', 'when building images'); + + const result = runCli(root, ['evolve', '--generate']); + assert.strictEqual(result.status, 0, result.stderr); + + const commandsDir = path.join(root, 'evolved', 'commands'); + const descriptions = generatedCommands(root).map(file => + fs.readFileSync(path.join(commandsDir, file), 'utf8') + .split(/\r?\n/) + .find(line => line.startsWith('description: ')) + ); + const description = descriptions.find(line => line.includes('# preserve this text')); + assert.ok(description, `missing hash-bearing description in ${descriptions.join(', ')}`); + assert.match(description, /^description: ".* # preserve this text.*"$/); + } finally { + cleanupDir(root); + } +}); + console.log(`\nPassed: ${passed}`); console.log(`Failed: ${failed}`);