Merge pull request #2870 from actus7/consolidate/hooks-observer-v3

fix(hooks): consolidate hooks and observer fixes (5 PRs)
This commit is contained in:
haelyra
2026-08-28 18:29:03 -04:00
committed by GitHub
15 changed files with 469 additions and 45 deletions
+18 -4
View File
@@ -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*
+18 -4
View File
@@ -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*
+15 -1
View File
@@ -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 |
+32 -7
View File
@@ -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-<shortid>-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-<shortid>-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.
---
+24 -12
View File
@@ -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);
+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'
@@ -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" <<PROMPT
@@ -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: {_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"
+18 -4
View File
@@ -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*
+34
View File
@@ -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.');
+12 -3
View File
@@ -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
+14 -1
View File
@@ -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', () => {
+143 -1
View File
@@ -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);
}
+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}`);
@@ -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}`);