mirror of
https://github.com/affaan-m/ECC.git
synced 2026-08-29 03:09:38 +02:00
Merge pull request #2870 from actus7/consolidate/hooks-observer-v3
fix(hooks): consolidate hooks and observer fixes (5 PRs)
This commit is contained in:
@@ -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*
|
||||
|
||||
@@ -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
@@ -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 |
|
||||
|
||||
@@ -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.
|
||||
|
||||
---
|
||||
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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*
|
||||
|
||||
@@ -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.');
|
||||
@@ -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
|
||||
|
||||
@@ -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', () => {
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
@@ -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}`);
|
||||
|
||||
|
||||
Reference in New Issue
Block a user