From 403c7e77fbe79f7db634331be45cc892fd5f91d2 Mon Sep 17 00:00:00 2001 From: arkon Date: Wed, 21 Jan 2026 19:49:44 +0100 Subject: [PATCH] feat: auto-detect completion phrase from CLAUDE.md - Extract PHRASE from CLAUDE.md on session start - Configure inner loop tracker before first broadcast so UI shows phrase immediately - More lenient bare phrase detection (triggers when loop is active) - Auto-detect on session restoration from screens - Add logging for auto-detection debugging Co-Authored-By: Claude Opus 4.5 --- .gitignore | 1 + CLAUDE.md | 21 +++++++++- src/inner-loop-tracker.ts | 20 ++++++---- src/web/server.ts | 82 +++++++++++++++++++++++++++++++++++++++ 4 files changed, 115 insertions(+), 9 deletions(-) diff --git a/.gitignore b/.gitignore index 0cd6fa8b..dd758533 100644 --- a/.gitignore +++ b/.gitignore @@ -34,3 +34,4 @@ Thumbs.db *.tmp *.temp .claude/ +media-assets/ diff --git a/CLAUDE.md b/CLAUDE.md index 55585d12..c067ea80 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -70,6 +70,7 @@ npx vitest run -t "should create session" # By pattern # Tests mock PTY - no real Claude CLI spawned # Test timeout: 30s (configured in vitest.config.ts) +# Global test utilities (describe/it/expect) available without imports (globals: true) # TypeScript checking npm run typecheck # Type check without building (or: npx tsc --noEmit) @@ -107,6 +108,7 @@ pkill -f "SCREEN.*claudeman" # Force kill all claudeman screens | `src/tui/components/*.tsx` | TUI components: StartScreen, TabBar, TerminalView, StatusBar, RalphPanel, HelpOverlay | | `src/tui/hooks/useSessionManager.ts` | TUI session state, screen polling, input handling | | `src/types.ts` | All TypeScript interfaces | +| `src/templates/claude-md.ts` | CLAUDE.md template generation with placeholder support | ### Data Flow @@ -345,7 +347,17 @@ Writes debounced to `~/.claudeman/state.json`. Batches rapid changes. ### TypeScript Config -Module resolution: NodeNext. Target: ES2022. Strict mode enabled. See `tsconfig.json` for full settings. +Module resolution: NodeNext. Target: ES2022. Strict mode with additional checks: + +| Setting | Effect | +|---------|--------| +| `noUnusedLocals` | Error on unused local variables | +| `noUnusedParameters` | Error on unused function parameters | +| `noImplicitReturns` | All code paths must return a value | +| `noFallthroughCasesInSwitch` | Require break/return in switch cases | +| `allowUnreachableCode: false` | Error on unreachable code | + +TUI uses React JSX (`jsxImportSource: react`) for Ink components. ## Adding New Features @@ -471,6 +483,13 @@ Long-running sessions are supported with automatic trimming: Cases created in `~/claudeman-cases/` by default. +### Custom CLAUDE.md Templates + +New cases can use custom CLAUDE.md templates via `generateClaudeMd()` in `src/templates/claude-md.ts`. Placeholders: +- `[PROJECT_NAME]` → Case name +- `[PROJECT_DESCRIPTION]` → Description +- `[DATE]` → Current date (YYYY-MM-DD) + ## Screen Session Manager (CLI Tool) Interactive bash tool for managing claudeman screen sessions directly from the terminal. diff --git a/src/inner-loop-tracker.ts b/src/inner-loop-tracker.ts index f1ea15ee..cb3c0219 100644 --- a/src/inner-loop-tracker.ts +++ b/src/inner-loop-tracker.ts @@ -786,14 +786,14 @@ export class InnerLoopTracker extends EventEmitter { // If we have an expected completion phrase, also check for bare phrase // This handles cases where Claude outputs "ALL_TASKS_DONE" without the tags const expectedPhrase = this._loopState.completionPhrase; - if (expectedPhrase && line.includes(expectedPhrase)) { - // Avoid false positives: don't trigger on the original prompt echo - // Only trigger if line looks like completion output (standalone or at end) - const isStandalone = line.trim() === expectedPhrase; - const isAtEnd = line.trim().endsWith(expectedPhrase); + if (expectedPhrase && line.toUpperCase().includes(expectedPhrase.toUpperCase())) { + // Avoid false positives: don't trigger on prompt context const isNotInPromptContext = !line.includes('') && !line.includes('output:'); + // Also avoid triggering on "completion phrase is X" explanatory text + const isNotExplanation = !line.toLowerCase().includes('completion phrase') && + !line.toLowerCase().includes('output exactly'); - if ((isStandalone || isAtEnd) && isNotInPromptContext) { + if (isNotInPromptContext && isNotExplanation) { this.handleBareCompletionPhrase(expectedPhrase); } } @@ -817,9 +817,13 @@ export class InnerLoopTracker extends EventEmitter { * @fires loopUpdate - When loop state changes */ private handleBareCompletionPhrase(phrase: string): void { - // Only count if this phrase was already seen in tagged form (from the prompt) + // Allow bare phrase detection if: + // 1. Loop is explicitly active (via startLoop()) - phrase was set programmatically + // 2. OR phrase was seen in tagged form (from terminal output) const taggedCount = this._completionPhraseCount.get(phrase) || 0; - if (taggedCount === 0) return; + const loopExplicitlyActive = this._loopState.active; + + if (taggedCount === 0 && !loopExplicitlyActive) return; // Track bare occurrences to avoid double-firing const bareKey = `bare:${phrase}`; diff --git a/src/web/server.ts b/src/web/server.ts index 0ac36238..4c9e0b21 100644 --- a/src/web/server.ts +++ b/src/web/server.ts @@ -73,6 +73,63 @@ const SCHEDULED_RUN_MAX_AGE = 60 * 60 * 1000; // Maximum concurrent sessions to prevent resource exhaustion const MAX_CONCURRENT_SESSIONS = 50; +// Pattern to extract completion phrase from CLAUDE.md +// Matches PHRASE with optional whitespace +const CLAUDE_MD_PROMISE_PATTERN = /\s*([A-Z0-9_]+)\s*<\/promise>/gi; + +/** + * Extract completion phrase from CLAUDE.md content. + * Looks for PHRASE pattern. + * + * Handles multiple variations: + * - Raw text: PHRASE + * - In backticks: `PHRASE` + * - With whitespace: PHRASE + * - Multiple occurrences: returns the first one + * + * @param claudeMdPath - Path to CLAUDE.md file + * @returns The completion phrase (uppercase), or null if not found + */ +function extractCompletionPhrase(claudeMdPath: string): string | null { + try { + if (!existsSync(claudeMdPath)) return null; + const content = readFileSync(claudeMdPath, 'utf-8'); + + // Reset regex state (global flag) + CLAUDE_MD_PROMISE_PATTERN.lastIndex = 0; + + // Find all matches and return the first one + const match = CLAUDE_MD_PROMISE_PATTERN.exec(content); + if (match && match[1]) { + const phrase = match[1].trim().toUpperCase(); + console.log(`[auto-detect] Found completion phrase in CLAUDE.md: ${phrase}`); + return phrase; + } + return null; + } catch (err) { + console.error(`[auto-detect] Error reading CLAUDE.md: ${err}`); + return null; + } +} + +/** + * Auto-configure inner loop tracker for a session based on CLAUDE.md + */ +function autoConfigureInnerLoop(session: Session, workingDir: string, broadcast: (event: string, data: unknown) => void): void { + const claudeMdPath = join(workingDir, 'CLAUDE.md'); + const completionPhrase = extractCompletionPhrase(claudeMdPath); + + if (completionPhrase) { + session.innerLoopTracker.enable(); + session.innerLoopTracker.startLoop(completionPhrase); + console.log(`[auto-detect] Configured inner loop for session ${session.id} with phrase: ${completionPhrase}`); + broadcast('session:innerLoopUpdate', { + sessionId: session.id, + state: session.innerLoopTracker.loopState, + }); + } +} + export class WebServer extends EventEmitter { private app: FastifyInstance; private sessions: Map = new Map(); @@ -377,9 +434,13 @@ export class WebServer extends EventEmitter { } try { + // Auto-detect completion phrase from CLAUDE.md BEFORE starting + autoConfigureInnerLoop(session, session.workingDir, () => {}); + await session.startInteractive(); this.broadcast('session:interactive', { id }); this.broadcast('session:updated', { session: session.toDetailedState() }); + return { success: true, message: 'Interactive session started' }; } catch (err) { return { error: getErrorMessage(err) }; @@ -582,6 +643,9 @@ export class WebServer extends EventEmitter { } try { + // Auto-detect completion phrase from CLAUDE.md BEFORE starting + autoConfigureInnerLoop(session, session.workingDir, () => {}); + // Start interactive session await session.startInteractive(); this.broadcast('session:interactive', { id }); @@ -984,6 +1048,13 @@ export class WebServer extends EventEmitter { useScreen: true, mode: mode, }); + + // Auto-detect completion phrase from CLAUDE.md BEFORE broadcasting + // so the initial state already has the phrase configured + if (mode === 'claude') { + autoConfigureInnerLoop(session, casePath, () => {}); // no broadcast yet + } + this.sessions.set(session.id, session); this.setupSessionListeners(session); this.broadcast('session:created', session.toDetailedState()); @@ -1729,6 +1800,17 @@ export class WebServer extends EventEmitter { console.log(`[Server] Enabled inner loop tracker for session ${session.id} from screen config`); } + // Auto-detect completion phrase from CLAUDE.md if not already set + if (!session.innerLoopTracker.loopState.completionPhrase) { + const claudeMdPath = join(session.workingDir, 'CLAUDE.md'); + const completionPhrase = extractCompletionPhrase(claudeMdPath); + if (completionPhrase) { + session.innerLoopTracker.enable(); + session.innerLoopTracker.startLoop(completionPhrase); + console.log(`[Server] Auto-detected completion phrase for session ${session.id}: ${completionPhrase}`); + } + } + // Restore respawn controller if it was enabled if (screen.respawnConfig?.enabled) { try {