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}`);