fix(hooks): preserve short sessions and quote evolved metadata

This commit is contained in:
haelyra
2026-08-28 16:08:19 -04:00
parent b7d6c61b1e
commit 950caaaae1
6 changed files with 54 additions and 21 deletions
+10 -9
View File
@@ -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}`);
}
+2 -1
View File
@@ -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'
@@ -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 "
+7 -8
View File
@@ -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 });
}
+8
View File
@@ -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}`);
@@ -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}`);