feat: auto-detect completion phrase from CLAUDE.md

- Extract <promise>PHRASE</promise> 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 <noreply@anthropic.com>
This commit is contained in:
arkon
2026-01-21 19:49:44 +01:00
co-authored by Claude Opus 4.5
parent e469b30ddb
commit 403c7e77fb
4 changed files with 115 additions and 9 deletions
+1
View File
@@ -34,3 +34,4 @@ Thumbs.db
*.tmp *.tmp
*.temp *.temp
.claude/ .claude/
media-assets/
+20 -1
View File
@@ -70,6 +70,7 @@ npx vitest run -t "should create session" # By pattern
# Tests mock PTY - no real Claude CLI spawned # Tests mock PTY - no real Claude CLI spawned
# Test timeout: 30s (configured in vitest.config.ts) # Test timeout: 30s (configured in vitest.config.ts)
# Global test utilities (describe/it/expect) available without imports (globals: true)
# TypeScript checking # TypeScript checking
npm run typecheck # Type check without building (or: npx tsc --noEmit) 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/components/*.tsx` | TUI components: StartScreen, TabBar, TerminalView, StatusBar, RalphPanel, HelpOverlay |
| `src/tui/hooks/useSessionManager.ts` | TUI session state, screen polling, input handling | | `src/tui/hooks/useSessionManager.ts` | TUI session state, screen polling, input handling |
| `src/types.ts` | All TypeScript interfaces | | `src/types.ts` | All TypeScript interfaces |
| `src/templates/claude-md.ts` | CLAUDE.md template generation with placeholder support |
### Data Flow ### Data Flow
@@ -345,7 +347,17 @@ Writes debounced to `~/.claudeman/state.json`. Batches rapid changes.
### TypeScript Config ### 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 ## Adding New Features
@@ -471,6 +483,13 @@ Long-running sessions are supported with automatic trimming:
Cases created in `~/claudeman-cases/` by default. 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) ## Screen Session Manager (CLI Tool)
Interactive bash tool for managing claudeman screen sessions directly from the terminal. Interactive bash tool for managing claudeman screen sessions directly from the terminal.
+12 -8
View File
@@ -786,14 +786,14 @@ export class InnerLoopTracker extends EventEmitter {
// If we have an expected completion phrase, also check for bare phrase // If we have an expected completion phrase, also check for bare phrase
// This handles cases where Claude outputs "ALL_TASKS_DONE" without the tags // This handles cases where Claude outputs "ALL_TASKS_DONE" without the tags
const expectedPhrase = this._loopState.completionPhrase; const expectedPhrase = this._loopState.completionPhrase;
if (expectedPhrase && line.includes(expectedPhrase)) { if (expectedPhrase && line.toUpperCase().includes(expectedPhrase.toUpperCase())) {
// Avoid false positives: don't trigger on the original prompt echo // Avoid false positives: don't trigger on prompt context
// Only trigger if line looks like completion output (standalone or at end)
const isStandalone = line.trim() === expectedPhrase;
const isAtEnd = line.trim().endsWith(expectedPhrase);
const isNotInPromptContext = !line.includes('<promise>') && !line.includes('output:'); const isNotInPromptContext = !line.includes('<promise>') && !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); this.handleBareCompletionPhrase(expectedPhrase);
} }
} }
@@ -817,9 +817,13 @@ export class InnerLoopTracker extends EventEmitter {
* @fires loopUpdate - When loop state changes * @fires loopUpdate - When loop state changes
*/ */
private handleBareCompletionPhrase(phrase: string): void { 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; 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 // Track bare occurrences to avoid double-firing
const bareKey = `bare:${phrase}`; const bareKey = `bare:${phrase}`;
+82
View File
@@ -73,6 +73,63 @@ const SCHEDULED_RUN_MAX_AGE = 60 * 60 * 1000;
// Maximum concurrent sessions to prevent resource exhaustion // Maximum concurrent sessions to prevent resource exhaustion
const MAX_CONCURRENT_SESSIONS = 50; const MAX_CONCURRENT_SESSIONS = 50;
// Pattern to extract completion phrase from CLAUDE.md
// Matches <promise>PHRASE</promise> with optional whitespace
const CLAUDE_MD_PROMISE_PATTERN = /<promise>\s*([A-Z0-9_]+)\s*<\/promise>/gi;
/**
* Extract completion phrase from CLAUDE.md content.
* Looks for <promise>PHRASE</promise> pattern.
*
* Handles multiple variations:
* - Raw text: <promise>PHRASE</promise>
* - In backticks: `<promise>PHRASE</promise>`
* - With whitespace: <promise> PHRASE </promise>
* - 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 { export class WebServer extends EventEmitter {
private app: FastifyInstance; private app: FastifyInstance;
private sessions: Map<string, Session> = new Map(); private sessions: Map<string, Session> = new Map();
@@ -377,9 +434,13 @@ export class WebServer extends EventEmitter {
} }
try { try {
// Auto-detect completion phrase from CLAUDE.md BEFORE starting
autoConfigureInnerLoop(session, session.workingDir, () => {});
await session.startInteractive(); await session.startInteractive();
this.broadcast('session:interactive', { id }); this.broadcast('session:interactive', { id });
this.broadcast('session:updated', { session: session.toDetailedState() }); this.broadcast('session:updated', { session: session.toDetailedState() });
return { success: true, message: 'Interactive session started' }; return { success: true, message: 'Interactive session started' };
} catch (err) { } catch (err) {
return { error: getErrorMessage(err) }; return { error: getErrorMessage(err) };
@@ -582,6 +643,9 @@ export class WebServer extends EventEmitter {
} }
try { try {
// Auto-detect completion phrase from CLAUDE.md BEFORE starting
autoConfigureInnerLoop(session, session.workingDir, () => {});
// Start interactive session // Start interactive session
await session.startInteractive(); await session.startInteractive();
this.broadcast('session:interactive', { id }); this.broadcast('session:interactive', { id });
@@ -984,6 +1048,13 @@ export class WebServer extends EventEmitter {
useScreen: true, useScreen: true,
mode: mode, 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.sessions.set(session.id, session);
this.setupSessionListeners(session); this.setupSessionListeners(session);
this.broadcast('session:created', session.toDetailedState()); 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`); 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 // Restore respawn controller if it was enabled
if (screen.respawnConfig?.enabled) { if (screen.respawnConfig?.enabled) {
try { try {