mirror of
https://github.com/affaan-m/ECC.git
synced 2026-08-29 03:09:38 +02:00
fix(hooks): preserve short sessions and quote evolved metadata
This commit is contained in:
@@ -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}`);
|
||||
}
|
||||
|
||||
@@ -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 "
|
||||
|
||||
@@ -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 });
|
||||
}
|
||||
|
||||
@@ -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}`);
|
||||
|
||||
|
||||
Reference in New Issue
Block a user