From 40c8235d48dbb68f99d4e8db39e42d274ba3eec4 Mon Sep 17 00:00:00 2001 From: Matt Van Horn <455140+mvanhorn@users.noreply.github.com> Date: Mon, 10 Aug 2026 00:44:38 -0700 Subject: [PATCH 1/7] fix: address self-review findings --- commands/resume-session.md | 39 +++++++-- scripts/hooks/session-end.js | 35 +++++--- tests/hooks/hooks.test.js | 15 +++- tests/hooks/session-end.test.js | 145 +++++++++++++++++++++++++++++++- 4 files changed, 211 insertions(+), 23 deletions(-) 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..fcb94e84a 100644 --- a/scripts/hooks/session-end.js +++ b/scripts/hooks/session-end.js @@ -94,6 +94,10 @@ function extractSessionSummary(transcriptPath) { }; } +function isLowSubstanceTranscript(summary) { + return summary.totalMessages === 1 && summary.toolsUsed.length === 0 && summary.filesModified.length === 0; +} + // Read hook input from stdin (Claude Code provides transcript_path via stdin JSON) const MAX_STDIN = 1024 * 1024; let stdinData = ''; @@ -181,6 +185,24 @@ async function main() { } } + // Classify 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); + if (summary && isLowSubstanceTranscript(summary)) { + log('[SessionEnd] Skipped one-message session without tool or file activity'); + return; + } + } 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 +233,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/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/session-end.test.js b/tests/hooks/session-end.test.js index 9008674d9..c7eda47d8 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,132 @@ 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('skips a one-message prompt with no 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)), 'One-shot prompt should not create a session file'); + assert.ok(!fs.existsSync(path.join(home, '.claude', 'session-data')), 'Rejected transcript should not create the sessions directory'); + } 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); + 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: 'Answer this one question' }) + '\n'); + + const res = runHook(home, transcript); + assert.strictEqual(res.status || 0, 0, `hook exited ${res.status}: ${res.stderr}`); + assert.strictEqual(fs.readFileSync(sessionFile, 'utf8'), original, 'Rejected transcript should not change existing content'); + assert.strictEqual(fs.statSync(sessionFile).mtimeMs, originalTime.getTime(), 'Rejected transcript 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); } From dac72d199711f232b8d3fc37940e922e7ceba750 Mon Sep 17 00:00:00 2001 From: Nitay Kufert Date: Mon, 17 Aug 2026 14:46:03 -0400 Subject: [PATCH 2/7] =?UTF-8?q?fix(strategic-compact):=20the=20task=20list?= =?UTF-8?q?=20may=20not=20exist=20=E2=80=94=20stop=20promising=20it=20surv?= =?UTF-8?q?ives=20compaction?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 restores them, but that is a per-machine environment setting that does not travel with a skill, so this skill cannot assume its reader has a task list at all. Three claims are wrong for most readers on a current version: - "What Survives Compaction" listed "TodoWrite task list" unconditionally - "Plan is in TodoWrite or a file" as the reason to compact at Planning→Implementation - "Once plan is finalized in TodoWrite, compact to start fresh" This is load-bearing advice rather than a cosmetic detail: "my todo list survives compaction" is a reason to compact INSTEAD of writing state down. If the tools are absent there is no list to survive, so the reader follows the advice, compacts, and the plan is simply gone. Changes: - Promote "Files on disk" into the survives table — the claim that holds on every version and model. - Make the task-list row conditional and add a short caveat naming the version, the env var, and the fact that it does not travel with the skill. - Point readers at a file as the durable record before compacting. - Reword the Decision Guide and Best Practices lines so neither depends on the tool existing. Applied identically to the Codex (.agents/) and Kiro (.kiro/) mirrors so the three copies agree. Those mirrors have pre-existing drift from the main skill; this change deliberately does not touch anything beyond the same three claims. Verified locally: all eight scripts/ci/ validators pass (unicode-safety, skills, agents, commands, rules, hooks, install-manifests, no-personal-paths), plus catalog:check, command-registry:check, and harness-adapter-compliance (12 adapters). No emoji in the added block, per check-unicode-safety. --- .agents/skills/strategic-compact/SKILL.md | 22 ++++++++++++++++++---- .kiro/skills/strategic-compact/SKILL.md | 22 ++++++++++++++++++---- skills/strategic-compact/SKILL.md | 22 ++++++++++++++++++---- 3 files changed, 54 insertions(+), 12 deletions(-) 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/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* From 7aa071c5e943cd6e4746111f361b358ff818dcba Mon Sep 17 00:00:00 2001 From: John Ellison Date: Thu, 20 Aug 2026 16:59:37 +0800 Subject: [PATCH 3/7] fix(continuous-learning-v2): emit loadable frontmatter from evolve --generate Artifacts written by `evolve --generate` are inert: Claude Code (and every spec-compliant Agent Skills client) injects only `name` + `description` at startup and will not load an artifact missing them. Today the generator writes: - skills: `# {name}` with no frontmatter block at all - commands: `# {cmd_name}` with no frontmatter block at all - agents: `model`/`tools` only, no `name`, no `description` So the whole evolve pipeline terminates in files that can never load. I hit this on a real install: 12 generated artifacts across two projects, none of which Claude Code had ever seen. This adds a `_evolved_description()` helper and emits proper frontmatter for all three artifact kinds. The description is sanitised for the two things that break loaders: `: ` in an unquoted scalar (rejected by strict YAML parsers) and `<`/`>` (system-prompt injection risk). Adds two tests to tests/scripts/instinct-cli-evolve-generate.test.js. Both fail against current main and pass with this change. Co-Authored-By: Claude Opus 5 --- .../scripts/instinct-cli.py | 34 +++++++++- .../instinct-cli-evolve-generate.test.js | 65 +++++++++++++++++++ 2 files changed, 96 insertions(+), 3 deletions(-) diff --git a/skills/continuous-learning-v2/scripts/instinct-cli.py b/skills/continuous-learning-v2/scripts/instinct-cli.py index 98f3724b5..7430f7ef1 100755 --- a/skills/continuous-learning-v2/scripts/instinct-cli.py +++ b/skills/continuous-learning-v2/scripts/instinct-cli.py @@ -1934,6 +1934,24 @@ def _cmd_projects_merge(args) -> 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: {_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: {_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: {_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/tests/scripts/instinct-cli-evolve-generate.test.js b/tests/scripts/instinct-cli-evolve-generate.test.js index 956111f45..a4f339849 100644 --- a/tests/scripts/instinct-cli-evolve-generate.test.js +++ b/tests/scripts/instinct-cli-evolve-generate.test.js @@ -243,6 +243,71 @@ test('preview names match the files --generate writes', () => { } }); +function parseFrontmatter(filePath) { + const raw = fs.readFileSync(filePath, 'utf8'); + const match = /^---\n([\s\S]*?)\n---\n/.exec(raw); + if (!match) return null; + const fm = {}; + for (const line of match[1].split('\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); + } +}); + console.log(`\nPassed: ${passed}`); console.log(`Failed: ${failed}`); From b7faf3d70eb926671309fc4ef65e4e0f092ace76 Mon Sep 17 00:00:00 2001 From: Suliman Abdulrazzaq Date: Mon, 10 Aug 2026 23:02:41 +0300 Subject: [PATCH 4/7] fix: pass observer analysis path explicitly --- .../agents/observer-loop.sh | 15 +++++++++++---- tests/hooks/observer-memory.test.js | 15 ++++++++++++++- 2 files changed, 25 insertions(+), 5 deletions(-) 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" < { 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', () => { From ef68f816d1b2fb2109d6572891a083034ef2b602 Mon Sep 17 00:00:00 2001 From: Suliman Abdulrazzaq Date: Mon, 10 Aug 2026 23:00:59 +0300 Subject: [PATCH 5/7] fix(gan): grant evaluator Playwright tools --- agents/gan-evaluator.md | 16 ++++++++++++- tests/ci/gan-evaluator-tools.test.js | 34 ++++++++++++++++++++++++++++ 2 files changed, 49 insertions(+), 1 deletion(-) create mode 100644 tests/ci/gan-evaluator-tools.test.js 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/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.'); From 950caaaae1fbe5b421a6f837aa5ef7bf7872893e Mon Sep 17 00:00:00 2001 From: haelyra <49814733+haelyra@users.noreply.github.com> Date: Fri, 28 Aug 2026 16:08:19 -0400 Subject: [PATCH 6/7] fix(hooks): preserve short sessions and quote evolved metadata --- scripts/hooks/session-end.js | 19 ++++++++------- scripts/lib/llm-summary.js | 3 ++- .../scripts/instinct-cli.py | 6 ++--- tests/hooks/session-end.test.js | 15 ++++++------ tests/lib/llm-summary.test.js | 8 +++++++ .../instinct-cli-evolve-generate.test.js | 24 +++++++++++++++++++ 6 files changed, 54 insertions(+), 21 deletions(-) diff --git a/scripts/hooks/session-end.js b/scripts/hooks/session-end.js index fcb94e84a..7139562ad 100644 --- a/scripts/hooks/session-end.js +++ b/scripts/hooks/session-end.js @@ -94,10 +94,6 @@ function extractSessionSummary(transcriptPath) { }; } -function isLowSubstanceTranscript(summary) { - return summary.totalMessages === 1 && summary.toolsUsed.length === 0 && summary.filesModified.length === 0; -} - // Read hook input from stdin (Claude Code provides transcript_path via stdin JSON) const MAX_STDIN = 1024 * 1024; let stdinData = ''; @@ -185,7 +181,16 @@ async function main() { } } - // Classify known transcripts before resolving session metadata or touching the + // 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; @@ -194,10 +199,6 @@ async function main() { transcriptExists = fs.existsSync(transcriptPath); if (transcriptExists) { summary = extractSessionSummary(transcriptPath); - if (summary && isLowSubstanceTranscript(summary)) { - log('[SessionEnd] Skipped one-message session without tool or file activity'); - return; - } } else { log(`[SessionEnd] Transcript not found: ${transcriptPath}`); } 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/scripts/instinct-cli.py b/skills/continuous-learning-v2/scripts/instinct-cli.py index 7430f7ef1..f7f35abbb 100755 --- a/skills/continuous-learning-v2/scripts/instinct-cli.py +++ b/skills/continuous-learning-v2/scripts/instinct-cli.py @@ -1986,7 +1986,7 @@ def _generate_evolved(skill_candidates: list, workflow_instincts: list, agent_ca content = "---\n" content += f"name: {name}\n" - content += f"description: {_evolved_description(trigger, cand['instincts'], 'skill')}\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 " @@ -2016,7 +2016,7 @@ def _generate_evolved(skill_candidates: list, workflow_instincts: list, agent_ca cmd_file = evolved_dir / "commands" / f"{cmd_name}.md" content = "---\n" - content += f"description: {_evolved_description(inst.get('trigger', ''), [inst], 'command')}\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" @@ -2043,7 +2043,7 @@ def _generate_evolved(skill_candidates: list, workflow_instincts: list, agent_ca content = "---\n" content += f"name: {agent_name}\n" - content += f"description: {_evolved_description(str(cand.get('trigger', '')), cand['instincts'], 'agent')}\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 " diff --git a/tests/hooks/session-end.test.js b/tests/hooks/session-end.test.js index c7eda47d8..05e74eeac 100644 --- a/tests/hooks/session-end.test.js +++ b/tests/hooks/session-end.test.js @@ -160,7 +160,7 @@ function runTests() { } }) ? passed++ : failed++); - (test('skips a one-message prompt with no tool activity', () => { + (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'; @@ -169,8 +169,7 @@ function runTests() { const res = runHook(home, transcript); assert.strictEqual(res.status || 0, 0, `hook exited ${res.status}: ${res.stderr}`); - assert.ok(!fs.existsSync(sessionFileFor(home, uuid)), 'One-shot prompt should not create a session file'); - assert.ok(!fs.existsSync(path.join(home, '.claude', 'session-data')), 'Rejected transcript should not create the sessions directory'); + assert.ok(fs.existsSync(sessionFileFor(home, uuid)), 'A normal short user session should remain resumable'); } finally { fs.rmSync(home, { recursive: true, force: true }); } @@ -189,7 +188,7 @@ function runTests() { ].join('\n') + '\n' ); - const res = runHook(home, transcript); + 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 { @@ -209,12 +208,12 @@ function runTests() { fs.mkdirSync(path.dirname(sessionFile), { recursive: true }); fs.writeFileSync(sessionFile, original); fs.utimesSync(sessionFile, originalTime, originalTime); - fs.writeFileSync(transcript, JSON.stringify({ type: 'user', content: 'Answer this one question' }) + '\n'); + fs.writeFileSync(transcript, JSON.stringify({ type: 'user', content: 'Internal summary request' }) + '\n'); - const res = runHook(home, transcript); + 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, 'Rejected transcript should not change existing content'); - assert.strictEqual(fs.statSync(sessionFile).mtimeMs, originalTime.getTime(), 'Rejected transcript should not advance mtime'); + 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 }); } 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 a4f339849..dd6a0dc40 100644 --- a/tests/scripts/instinct-cli-evolve-generate.test.js +++ b/tests/scripts/instinct-cli-evolve-generate.test.js @@ -308,6 +308,30 @@ test('generated agents carry name + description alongside model/tools', () => { } }); +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('\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}`); From 70f42102fc4b6abe1b26af2233db16c6de6181fe Mon Sep 17 00:00:00 2001 From: haelyra <49814733+haelyra@users.noreply.github.com> Date: Fri, 28 Aug 2026 16:25:30 -0400 Subject: [PATCH 7/7] test(evolve): support Windows line endings --- tests/scripts/instinct-cli-evolve-generate.test.js | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/tests/scripts/instinct-cli-evolve-generate.test.js b/tests/scripts/instinct-cli-evolve-generate.test.js index dd6a0dc40..6459a56d9 100644 --- a/tests/scripts/instinct-cli-evolve-generate.test.js +++ b/tests/scripts/instinct-cli-evolve-generate.test.js @@ -245,10 +245,10 @@ test('preview names match the files --generate writes', () => { function parseFrontmatter(filePath) { const raw = fs.readFileSync(filePath, 'utf8'); - const match = /^---\n([\s\S]*?)\n---\n/.exec(raw); + 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('\n')) { + 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(); @@ -321,7 +321,7 @@ test('generated descriptions quote YAML comment markers', () => { const commandsDir = path.join(root, 'evolved', 'commands'); const descriptions = generatedCommands(root).map(file => fs.readFileSync(path.join(commandsDir, file), 'utf8') - .split('\n') + .split(/\r?\n/) .find(line => line.startsWith('description: ')) ); const description = descriptions.find(line => line.includes('# preserve this text'));