From eedf4cb349b1f4d30b2b870a6130444119f9e0ac Mon Sep 17 00:00:00 2001 From: Claude Date: Sun, 31 May 2026 22:38:05 +0000 Subject: [PATCH] feat(epic-loop): run the hook loop on Claude Code, not just Codex Add dual-platform support so the same skill drives the techlead/engineer Stop-hook loop on either Codex or Claude Code (one platform per session). The loop engine is unchanged: Codex and Claude Code share the same Stop continuation contract ({ "decision": "block", "reason": }), the same hook-config entry shape, and the same stdin payload fields. Only the platform-specific setup/detection branches: - platform helpers in lib/common.mjs: detectPlatform (auto via CLAUDECODE / config presence, overridable with --platform), hookConfigRelativePath, readCurrentClaudeSession (session id = transcript filename under ~/.claude/projects/), and readLastAssistantMessage which falls back to the transcript tail on Claude Code (no last_assistant_message in its Stop payload) - install-hooks / doctor target .claude/settings.json (deep-merged, no feature flag) for Claude Code and keep .codex/hooks.json for Codex - bind-session --current and debug detect the platform - hook.mjs is invoked with --platform; handleHook threads it into the engineer-report capture - expose the skill at .claude/skills/epic-loop as a symlink to the single source in .agents/skills/epic-loop so Claude Code discovers it without a duplicated copy - docs: SKILL.md, hooks-and-session-routing.md, and README cover both platforms https://claude.ai/code/session_0157kdfjwZJmDd1mx7S93TyT --- .agents/skills/epic-loop/SKILL.md | 53 ++++++-- .../references/hooks-and-session-routing.md | 37 +++++- .../skills/epic-loop/scripts/lib/common.mjs | 124 ++++++++++++++++++ .../skills/epic-loop/scripts/lib/debug.mjs | 7 +- .../skills/epic-loop/scripts/lib/epics.mjs | 12 +- .../skills/epic-loop/scripts/lib/hooks.mjs | 63 ++++++--- .agents/skills/epic-loop/scripts/lib/loop.mjs | 14 +- .claude/skills/epic-loop | 1 + README.md | 25 ++-- 9 files changed, 280 insertions(+), 56 deletions(-) create mode 120000 .claude/skills/epic-loop diff --git a/.agents/skills/epic-loop/SKILL.md b/.agents/skills/epic-loop/SKILL.md index 23cf225..ad898bb 100644 --- a/.agents/skills/epic-loop/SKILL.md +++ b/.agents/skills/epic-loop/SKILL.md @@ -21,23 +21,27 @@ If the result is `ready`, continue to local epic discovery. If the result is `setup-required`, do not ask the shaping/resume question yet. Use a very short setup exchange and do not mention internal diagnostics unless the user asks. -- **Automatic setup**: if `.codex/hooks.json` is writable and the user explicitly approves setup, run `node .agents/skills/epic-loop/scripts/install-hooks.mjs`. -- **Manual setup**: if `.codex/hooks.json` is not writable from the current session, give the exact command for the user to run from a writable project checkout or host terminal. +`doctor` auto-detects the platform (Codex or Claude Code) and checks the matching +hook config — `.codex/hooks.json` for Codex, `.claude/settings.json` for Claude +Code. The setup steps below apply to whichever platform is active. -Do not edit global Codex config from this skill. If `doctor` reports that `hooks` is missing or disabled, explain where it appears to be missing and ask the user before changing any project-local config. +- **Automatic setup**: if the platform hook config is writable and the user explicitly approves setup, run `node .agents/skills/epic-loop/scripts/install-hooks.mjs`. +- **Manual setup**: if the platform hook config is not writable from the current session, give the exact command for the user to run from a writable project checkout or host terminal. + +Do not edit global Codex config from this skill. If `doctor` reports that `hooks` is missing or disabled, explain where it appears to be missing and ask the user before changing any project-local config. (Claude Code needs no feature flag; this disabled-feature case only applies to Codex.) Keep the user-facing setup message ultra-short. Do not paste the full doctor output unless the user asks for details. Do not mention `ready: true`, config paths, global config, event lists, or other diagnostics in the normal flow. Use this shape when setup is possible but not yet approved: ```text -проверила: epic-loop needs to add project-local Codex hooks. Install them now? +проверила: epic-loop needs to add project-local hooks. Install them now? ``` -Use this shape when the current session cannot write `.codex/hooks.json`: +Use this shape when the current session cannot write the platform hook config: ```text -проверила: hooks need setup, but this session cannot write `.codex/hooks.json`. +проверила: hooks need setup, but this session cannot write the hook config. cd node .agents/skills/epic-loop/scripts/install-hooks.mjs @@ -46,7 +50,7 @@ node .agents/skills/epic-loop/scripts/install-hooks.mjs Use this shape when the user asked to install and the automatic install failed: ```text -попробовала установить hooks, но `.codex/hooks.json` is not writable here. +попробовала установить hooks, но the hook config is not writable here. cd node .agents/skills/epic-loop/scripts/install-hooks.mjs @@ -319,15 +323,42 @@ When parallel work may collide, read current files immediately before editing an ## Hooks -Use project-local hooks for epic-loop work. Install them from the project root with: +epic-loop runs on either **Codex** or **Claude Code** through the same `Stop` +hook mechanism. The scripts auto-detect the platform (Claude Code sets +`CLAUDECODE=1`; otherwise Codex is assumed), or you can force it with +`--platform codex|claude` on `doctor`, `install-hooks`, and `bind-session`. + +Use project-local hooks for epic-loop work. Install them from the project root +with: ```bash node .agents/skills/epic-loop/scripts/install-hooks.mjs ``` -The local `.codex/hooks.json` should route `SessionStart`, `UserPromptSubmit`, and `Stop` events to the epic-loop hook handler. The installer must preserve unrelated hooks, add missing epic-loop event entries, and update stale epic-loop hook commands when the skill path changed. - -The hook handler is strict opt-in: it writes state only when `session_id` is already registered in `.epic-loop/.runtime/session-bindings.json`. Unbound sessions must be a silent no-op. Keep `.codex/hooks.json` as static config; all mutable epic-loop state belongs in `.epic-loop/` because `.codex/` may be read-only in sandboxed sessions. +The installer routes `SessionStart`, `UserPromptSubmit`, and `Stop` events to the +epic-loop hook handler in the platform's config file: + +- **Codex** → `.codex/hooks.json` (also requires `hooks = true` under + `[features]` in the active Codex config/profile). +- **Claude Code** → `.claude/settings.json` (no feature flag needed; the + installer deep-merges the `hooks` block and preserves existing settings such as + `permissions` and MCP config). + +In both cases the installer preserves unrelated hooks, adds missing epic-loop +event entries, and updates stale epic-loop hook commands when the skill path or +platform changed. + +The hook handler is strict opt-in: it writes state only when `session_id` is +already registered in `.epic-loop/.runtime/session-bindings.json`. Unbound +sessions must be a silent no-op. Keep the platform hook config as static config; +all mutable epic-loop state belongs in `.epic-loop/` because `.codex/` may be +read-only in sandboxed sessions. + +The continuation contract is identical on both platforms: the `Stop` hook prints +`{ "decision": "block", "reason": "" }`, which re-prompts the same +session. Codex supplies the engineer's final message as `last_assistant_message` +in the `Stop` payload; Claude Code does not, so the handler reads it from the +session transcript (`transcript_path`) instead. After updating human-readable epic docs, run the artifact limit checker for the affected epic slug: diff --git a/.agents/skills/epic-loop/references/hooks-and-session-routing.md b/.agents/skills/epic-loop/references/hooks-and-session-routing.md index c85cfe0..fcc5776 100644 --- a/.agents/skills/epic-loop/references/hooks-and-session-routing.md +++ b/.agents/skills/epic-loop/references/hooks-and-session-routing.md @@ -52,6 +52,34 @@ User-facing setup messages should be tiny. Normal flow is: Do not show full `doctor` output by default. Do not mention `ready: true`, config paths, global config, event lists, or other diagnostics unless the user asks. If install was attempted and failed, say that explicitly in one sentence. +## Platform Targets (Codex and Claude Code) + +epic-loop drives the same loop on Codex and Claude Code because both expose the +same `Stop`-hook continuation contract. `doctor`, `install-hooks`, `bind-session`, +and `debug` auto-detect the platform (Claude Code sets `CLAUDECODE=1`; otherwise +Codex is assumed) and accept an explicit `--platform codex|claude` override. + +What differs by platform: + +| Concern | Codex | Claude Code | +| --- | --- | --- | +| Hook config file | `.codex/hooks.json` | `.claude/settings.json` (shared file; installer deep-merges the `hooks` block and preserves other keys) | +| Feature flag | `hooks = true` under `[features]` | none required | +| Hook command | `node …/hook.mjs --platform codex` | `node …/hook.mjs --platform claude` | +| `--current` session source | `.codex/tmp/last-hook-capture.json`, then `~/.codex/sessions/**/*.jsonl` | newest `~/.claude/projects//.jsonl` (filename is the session id) | +| Engineer report source on `Stop` | `payload.last_assistant_message` | last `assistant` entry read from `transcript_path` | + +What is identical: the hook config entry shape, the stdin payload fields the +handler routes on (`session_id`, `cwd`, `hook_event_name`, `transcript_path`, +`stop_hook_active`), the three events, the silent-no-op-when-unbound rule, the +binding store, and the continuation contract `{ "decision": "block", "reason": +"" }`. Only the platform-specific install/detection code branches; +`hook.mjs` and the loop engine are shared unchanged. + +For Claude Code discoverability the skill is exposed at `.claude/skills/epic-loop` +as a symlink to the single source of truth in `.agents/skills/epic-loop`, so there +is no duplicated copy to drift. + ## Installer Behavior The installer must be conservative: @@ -67,15 +95,18 @@ The installer does not fix every Codex feature/profile configuration. Its job is ## Hook Payload -Codex hook payloads are JSON on stdin. Observed useful fields: +Codex and Claude Code hook payloads are JSON on stdin. Shared useful fields: - `session_id` -- `turn_id` - `transcript_path` - `cwd` - `hook_event_name` +- `stop_hook_active` - `prompt` for `UserPromptSubmit` -- `last_assistant_message` for `Stop` + +Codex-only fields the handler tolerates but does not require: `turn_id`, and +`last_assistant_message` for `Stop`. On Claude Code there is no +`last_assistant_message`; the engineer report is read from `transcript_path`. Route by `session_id` first. Use `cwd` as the project root boundary. Use `turn_id` only as event identity inside a registered session. diff --git a/.agents/skills/epic-loop/scripts/lib/common.mjs b/.agents/skills/epic-loop/scripts/lib/common.mjs index c4311b9..0b6bc92 100644 --- a/.agents/skills/epic-loop/scripts/lib/common.mjs +++ b/.agents/skills/epic-loop/scripts/lib/common.mjs @@ -4,8 +4,36 @@ import process from "node:process"; export const HOOK_EVENTS = ["SessionStart", "UserPromptSubmit", "Stop"]; export const MODES = ["shaping", "implementation", "review", "reset"]; +export const PLATFORMS = ["codex", "claude"]; export const CODEX_HOOKS_RELATIVE_PATH = path.join(".codex", "hooks.json"); export const CODEX_CONFIG_RELATIVE_PATH = path.join(".codex", "config.toml"); +export const CLAUDE_SETTINGS_RELATIVE_PATH = path.join(".claude", "settings.json"); + +export function hookConfigRelativePath(platform) { + return platform === "claude" ? CLAUDE_SETTINGS_RELATIVE_PATH : CODEX_HOOKS_RELATIVE_PATH; +} + +export function detectPlatform(flags = {}, root = process.cwd()) { + const explicit = typeof flags.platform === "string" ? flags.platform.trim().toLowerCase() : ""; + if (explicit) { + if (!PLATFORMS.includes(explicit)) { + throw new Error(`Invalid --platform "${explicit}". Expected one of: ${PLATFORMS.join(", ")}.`); + } + return explicit; + } + + if (process.env.CLAUDECODE === "1" || process.env.CLAUDE_PROJECT_DIR) { + return "claude"; + } + + const hasCodexHooks = fs.existsSync(path.join(root, CODEX_HOOKS_RELATIVE_PATH)); + const hasClaudeSettings = fs.existsSync(path.join(root, CLAUDE_SETTINGS_RELATIVE_PATH)); + if (hasClaudeSettings && !hasCodexHooks) { + return "claude"; + } + + return "codex"; +} export function nowIso() { return new Date().toISOString().replace(/\.\d{3}Z$/u, "+00:00"); @@ -427,6 +455,102 @@ function parseDateMs(value) { return Number.isFinite(timestamp) ? timestamp : null; } +function claudeProjectsDir(projectRoot) { + const encoded = String(projectRoot).replace(/[/\\]/gu, "-"); + return path.join(process.env.HOME ?? "", ".claude", "projects", encoded); +} + +export function readCurrentClaudeSession(projectRoot) { + const projectDir = claudeProjectsDir(projectRoot); + if (!fs.existsSync(projectDir)) { + return null; + } + + const candidates = []; + for (const entry of fs.readdirSync(projectDir, { withFileTypes: true })) { + if (!entry.isFile() || !entry.name.endsWith(".jsonl")) { + continue; + } + + const filePath = path.join(projectDir, entry.name); + candidates.push({ + captured_at: null, + hook_event_name: null, + prompt: null, + session_id: entry.name.slice(0, -".jsonl".length), + source: "claude-transcript", + transcript_path: filePath, + turn_id: null, + updated_at_ms: getMtimeMs(filePath) ?? 0, + }); + } + + candidates.sort((a, b) => b.updated_at_ms - a.updated_at_ms); + return candidates[0] ?? null; +} + +export function readCurrentSession(platform, projectRoot) { + return platform === "claude" ? readCurrentClaudeSession(projectRoot) : readCurrentCodexSession(projectRoot); +} + +function extractAssistantText(content) { + if (typeof content === "string") { + return content.trim(); + } + if (!Array.isArray(content)) { + return ""; + } + return content + .filter((part) => part && part.type === "text" && typeof part.text === "string") + .map((part) => part.text) + .join("\n") + .trim(); +} + +export function readClaudeTranscriptLastAssistantMessage(transcriptPath) { + if (!transcriptPath || !fs.existsSync(transcriptPath)) { + return ""; + } + + const lines = fs.readFileSync(transcriptPath, "utf8").split(/\r?\n/u); + for (let index = lines.length - 1; index >= 0; index -= 1) { + const line = lines[index].trim(); + if (!line) { + continue; + } + + const item = readJsonLine(line); + if (!item || item.type !== "assistant" || !item.message) { + continue; + } + + const text = extractAssistantText(item.message.content); + if (text) { + return text; + } + } + + return ""; +} + +export function readLastAssistantMessage(platform, payload) { + if (platform === "claude") { + return readClaudeTranscriptLastAssistantMessage(payload?.transcript_path); + } + return typeof payload?.last_assistant_message === "string" ? payload.last_assistant_message.trim() : ""; +} + +export function detectPlatformFromPayload(payload) { + if (payload && typeof payload.last_assistant_message === "string") { + return "codex"; + } + const transcript = payload?.transcript_path; + if (typeof transcript === "string" && transcript.includes(`${path.sep}.claude${path.sep}`)) { + return "claude"; + } + return "codex"; +} + export function formatList(values) { return values.length > 0 ? values.join(", ") : "none"; } diff --git a/.agents/skills/epic-loop/scripts/lib/debug.mjs b/.agents/skills/epic-loop/scripts/lib/debug.mjs index c41e802..f4d463a 100644 --- a/.agents/skills/epic-loop/scripts/lib/debug.mjs +++ b/.agents/skills/epic-loop/scripts/lib/debug.mjs @@ -1,16 +1,17 @@ import fs from "node:fs"; import path from "node:path"; -import { readCurrentCodexSession, readJson, resolveRoot, sessionRoot } from "./common.mjs"; +import { detectPlatform, readCurrentSession, readJson, resolveRoot, sessionRoot } from "./common.mjs"; import { readImplementationLoops } from "./loop.mjs"; export function debugState(flags = {}) { const root = resolveRoot(flags.root); + const platform = detectPlatform(flags, root); const limit = Number.parseInt(flags.limit ?? "10", 10); const stateRoot = sessionRoot(root); const bindingsPath = path.join(stateRoot, "session-bindings.json"); const bindings = readJson(bindingsPath, { active_sessions: {}, sessions: {} }); - const currentSession = readCurrentCodexSession(root); + const currentSession = readCurrentSession(platform, root); const sessionStates = readSessionStates(path.join(stateRoot, "sessions")); const recentHookEvents = listRecentFiles(path.join(stateRoot, "hook-events"), Number.isFinite(limit) ? limit : 10); @@ -18,6 +19,7 @@ export function debugState(flags = {}) { active_sessions: bindings.active_sessions ?? {}, bindings_path: bindingsPath, current_session_candidate: currentSession, + platform, implementation_loops: readImplementationLoops(root), recent_hook_events: recentHookEvents, root, @@ -31,6 +33,7 @@ export function debugState(flags = {}) { } console.log(`Project: ${root}`); + console.log(`Platform: ${platform}`); console.log(`Bindings: ${fs.existsSync(bindingsPath) ? bindingsPath : "none"}`); console.log(`Current session candidate: ${currentSession?.session_id ?? "none"}`); console.log(`Active sessions: ${Object.keys(payload.active_sessions).length > 0 ? JSON.stringify(payload.active_sessions) : "none"}`); diff --git a/.agents/skills/epic-loop/scripts/lib/epics.mjs b/.agents/skills/epic-loop/scripts/lib/epics.mjs index 4928243..8b7aefe 100644 --- a/.agents/skills/epic-loop/scripts/lib/epics.mjs +++ b/.agents/skills/epic-loop/scripts/lib/epics.mjs @@ -4,12 +4,13 @@ import path from "node:path"; import { MODES, appendGitignore, + detectPlatform, epicRuntimeRoot, epicSlugify, epicsRoot, ensureDir, nowIso, - readCurrentCodexSession, + readCurrentSession, readJson, requireFlag, resolveRoot, @@ -193,10 +194,13 @@ export function status(flags = {}, positionals = []) { export function bindSession(flags = {}) { const root = resolveRoot(flags.root); - const currentSession = flags.current ? readCurrentCodexSession(root) : null; + const platform = detectPlatform(flags, root); + const currentSession = flags.current ? readCurrentSession(platform, root) : null; if (flags.current && !currentSession) { - throw new Error("Cannot detect current Codex session from .codex/tmp/last-hook-capture.json. Pass --session-id explicitly."); + const detectionSource = + platform === "claude" ? `~/.claude/projects//*.jsonl for ${root}` : ".codex/tmp/last-hook-capture.json"; + throw new Error(`Cannot detect current ${platform} session from ${detectionSource}. Pass --session-id explicitly.`); } const sessionId = currentSession?.session_id ?? requireFlag(flags, "session-id"); @@ -244,7 +248,7 @@ export function bindSession(flags = {}) { bound_at: boundAt, epic_slug: slug, mode, - source: currentSession ? "current-codex-session" : "explicit-session-id", + source: currentSession ? `current-${platform}-session` : "explicit-session-id", turn_id: currentSession?.turn_id ?? null, }; activeSessions[activeKey] = sessionId; diff --git a/.agents/skills/epic-loop/scripts/lib/hooks.mjs b/.agents/skills/epic-loop/scripts/lib/hooks.mjs index b607aa7..e1cc77a 100644 --- a/.agents/skills/epic-loop/scripts/lib/hooks.mjs +++ b/.agents/skills/epic-loop/scripts/lib/hooks.mjs @@ -4,13 +4,15 @@ import { fileURLToPath } from "node:url"; import { CODEX_CONFIG_RELATIVE_PATH, - CODEX_HOOKS_RELATIVE_PATH, HOOK_EVENTS, canReadPath, canWritePath, + detectPlatform, + detectPlatformFromPayload, epicRuntimeRoot, eventTimestamp, formatList, + hookConfigRelativePath, nowIso, readJson, readJsonStrict, @@ -26,8 +28,8 @@ const LIB_DIR = path.dirname(fileURLToPath(import.meta.url)); const SCRIPTS_DIR = path.dirname(LIB_DIR); const HOOK_SCRIPT_PATH = path.join(SCRIPTS_DIR, "hook.mjs"); -export function buildHookCommand() { - return `node ${shellQuote(HOOK_SCRIPT_PATH)}`; +export function buildHookCommand(platform = "codex") { + return `node ${shellQuote(HOOK_SCRIPT_PATH)} --platform ${platform}`; } function isEpicLoopHookCommand(command) { @@ -115,10 +117,10 @@ function normalizeHookDocument(document) { return document && typeof document === "object" && !Array.isArray(document) ? document : {}; } -function buildHooksDocument(existingDocument) { +function buildHooksDocument(existingDocument, platform = "codex") { const normalizedDocument = normalizeHookDocument(existingDocument); const hooks = normalizedDocument.hooks && typeof normalizedDocument.hooks === "object" && !Array.isArray(normalizedDocument.hooks) ? normalizedDocument.hooks : {}; - const command = buildHookCommand(); + const command = buildHookCommand(platform); const changes = []; for (const eventName of HOOK_EVENTS) { @@ -201,11 +203,22 @@ function buildHooksDocument(existingDocument) { }; } -function inspectHookConfig(root) { - const hooksPath = path.join(root, CODEX_HOOKS_RELATIVE_PATH); +function featureCheck(platform, root) { + if (platform === "claude") { + return { + enabled: true, + scope: "n/a", + source: null, + }; + } + return inspectCodexHooksFeature(root); +} + +function inspectHookConfig(root, platform = "codex") { + const hooksPath = path.join(root, hookConfigRelativePath(platform)); const strict = readJsonStrict(hooksPath); const writable = canWritePath(hooksPath); - const command = buildHookCommand(); + const command = buildHookCommand(platform); if (strict.error) { return { @@ -257,13 +270,15 @@ function inspectHookConfig(root) { export function doctor(flags = {}) { const root = resolveRoot(flags.root); - const hookConfig = inspectHookConfig(root); - const feature = inspectCodexHooksFeature(root); + const platform = detectPlatform(flags, root); + const hookConfig = inspectHookConfig(root, platform); + const feature = featureCheck(platform, root); const runtimeWritable = canWritePath(sessionRoot(root)); const scriptReadable = canReadPath(HOOK_SCRIPT_PATH); const ready = hookConfig.ready && !hookConfig.invalid && feature.enabled === true && runtimeWritable.ok && scriptReadable.ok; const setupPossible = !hookConfig.invalid && hookConfig.writable.ok; const status = { + platform, hooksFeature: feature, command: hookConfig.command, hookConfig: { @@ -295,6 +310,7 @@ export function doctor(flags = {}) { } console.log(`Epic-loop hook readiness: ${ready ? "ready" : "setup-required"}`); + console.log(`Platform: ${platform}`); console.log(`Project root: ${root}`); console.log(`Hook config: ${hookConfig.exists ? hookConfig.hooksPath : `${hookConfig.hooksPath} (missing)`}`); console.log(`Hook command: ${hookConfig.command}`); @@ -303,7 +319,9 @@ export function doctor(flags = {}) { console.log(`Hook config writable: ${hookConfig.writable.ok ? "yes" : `no (${hookConfig.writable.reason})`}`); console.log(`Runtime state writable: ${runtimeWritable.ok ? "yes" : `no (${runtimeWritable.reason})`}`); - if (feature.enabled === true) { + if (platform === "claude") { + console.log("hooks feature: enabled (Claude Code needs no feature flag)"); + } else if (feature.enabled === true) { console.log(`hooks feature: enabled via ${feature.scope} config ${feature.source}`); } else if (feature.enabled === false) { console.log(`hooks feature: disabled via ${feature.scope} config ${feature.source}`); @@ -333,17 +351,19 @@ export function doctor(flags = {}) { export function installHooks(flags = {}) { const root = resolveRoot(flags.root); - const hooksPath = path.join(root, CODEX_HOOKS_RELATIVE_PATH); + const platform = detectPlatform(flags, root); + const hooksPath = path.join(root, hookConfigRelativePath(platform)); const strict = readJsonStrict(hooksPath); if (strict.error) { throw new Error(`Cannot update invalid JSON in ${hooksPath}: ${strict.error}`); } - const next = buildHooksDocument(strict.value ?? {}); + const next = buildHooksDocument(strict.value ?? {}, platform); + const featureNote = platform === "codex" ? "Requires hooks = true in the active Codex config/profile." : null; if (flags["dry-run"]) { - console.log(`Dry run: ${hooksPath}`); + console.log(`Dry run (${platform}): ${hooksPath}`); console.log(`Hook command: ${next.command}`); console.log(`Events that would change: ${formatList(next.changes)}`); console.log(JSON.stringify(next.document, null, 2)); @@ -351,8 +371,10 @@ export function installHooks(flags = {}) { } if (next.changes.length === 0) { - console.log(`Epic-loop hooks already installed: ${hooksPath}`); - console.log("Requires hooks = true in the active Codex config/profile."); + console.log(`Epic-loop hooks already installed (${platform}): ${hooksPath}`); + if (featureNote) { + console.log(featureNote); + } return; } @@ -363,8 +385,10 @@ export function installHooks(flags = {}) { writeJson(hooksPath, next.document); - console.log(`Installed project-local epic-loop hooks: ${hooksPath}`); - console.log("Requires hooks = true in the active Codex config/profile."); + console.log(`Installed project-local epic-loop hooks (${platform}): ${hooksPath}`); + if (featureNote) { + console.log(featureNote); + } } export function handleHook(rawInput, flags = {}) { @@ -380,6 +404,7 @@ export function handleHook(rawInput, flags = {}) { } const projectRoot = resolveRoot(payload.cwd ?? flags.root); + const platform = typeof flags.platform === "string" && flags.platform.trim() ? flags.platform.trim().toLowerCase() : detectPlatformFromPayload(payload); const sessionId = String(payload.session_id ?? "no-session"); const binding = getSessionBinding(projectRoot, sessionId); @@ -399,7 +424,7 @@ export function handleHook(rawInput, flags = {}) { mirrorBoundEvent(projectRoot, payload, eventRecord, binding); markInterruptedTurnIfNeeded(projectRoot, payload, binding); - const continuation = maybeBuildImplementationContinuation(projectRoot, payload, binding); + const continuation = maybeBuildImplementationContinuation(projectRoot, payload, binding, platform); if (continuation) { console.log(JSON.stringify(continuation)); } diff --git a/.agents/skills/epic-loop/scripts/lib/loop.mjs b/.agents/skills/epic-loop/scripts/lib/loop.mjs index 48768fb..9cb51e7 100644 --- a/.agents/skills/epic-loop/scripts/lib/loop.mjs +++ b/.agents/skills/epic-loop/scripts/lib/loop.mjs @@ -2,7 +2,7 @@ import fs from "node:fs"; import path from "node:path"; import { fileURLToPath } from "node:url"; -import { ensureDir, epicRuntimeRoot, epicsRoot, nowIso, readJson, requireFlag, resolveRoot, runtimeStatePath, writeJson } from "./common.mjs"; +import { ensureDir, epicRuntimeRoot, epicsRoot, nowIso, readJson, readLastAssistantMessage, requireFlag, resolveRoot, runtimeStatePath, writeJson } from "./common.mjs"; import { readRoadmapSummary } from "./roadmap.mjs"; const LOOP_ROLES = ["techlead", "engineer", "idle"]; @@ -139,7 +139,7 @@ export function setNextRole(flags = {}) { } } -export function maybeBuildImplementationContinuation(projectRoot, payload, binding) { +export function maybeBuildImplementationContinuation(projectRoot, payload, binding, platform = "codex") { if (payload.hook_event_name !== "Stop" || binding.mode !== "implementation") { return null; } @@ -151,7 +151,7 @@ export function maybeBuildImplementationContinuation(projectRoot, payload, bindi let runtime = mergeEpicStateIntoRuntime(projectRoot, slug, normalizeObject(readJson(runtimePath, {}))); let loop = normalizeObject(runtime.implementation_loop); - ({ loop, runtime } = recordTurnStopIfNeeded(projectRoot, slug, runtime, loop, payload, timestamp)); + ({ loop, runtime } = recordTurnStopIfNeeded(projectRoot, slug, runtime, loop, payload, timestamp, platform)); if (loop.status !== "running") { appendLoopLog(projectRoot, { @@ -477,13 +477,13 @@ function renderTemplate(template, values) { return Object.entries(values).reduce((result, [key, value]) => result.replaceAll(`-<<*{{${key}}}*>>-`, value), template).trim(); } -function recordTurnStopIfNeeded(projectRoot, slug, runtime, loop, payload, timestamp) { +function recordTurnStopIfNeeded(projectRoot, slug, runtime, loop, payload, timestamp, platform = "codex") { if (!loop.current_role || !loop.active_turn_started_at || loop.active_turn_stopped_at) { return { loop, runtime }; } const durationMs = durationMsBetween(loop.active_turn_started_at, timestamp); - const engineerReport = loop.current_role === "engineer" ? appendEngineerReportIfPresent(projectRoot, slug, loop, payload, timestamp) : null; + const engineerReport = loop.current_role === "engineer" ? appendEngineerReportIfPresent(projectRoot, slug, loop, payload, timestamp, platform) : null; const stoppedLoop = { ...loop, active_turn_stopped_at: timestamp, @@ -601,8 +601,8 @@ function appendPromptMarkdown(filePath, entry) { ); } -function appendEngineerReportIfPresent(projectRoot, slug, loop, payload, timestamp) { - const message = typeof payload.last_assistant_message === "string" ? payload.last_assistant_message.trim() : ""; +function appendEngineerReportIfPresent(projectRoot, slug, loop, payload, timestamp, platform = "codex") { + const message = readLastAssistantMessage(platform, payload); if (!message) { return null; } diff --git a/.claude/skills/epic-loop b/.claude/skills/epic-loop new file mode 120000 index 0000000..4d4db11 --- /dev/null +++ b/.claude/skills/epic-loop @@ -0,0 +1 @@ +../../.agents/skills/epic-loop \ No newline at end of file diff --git a/README.md b/README.md index 94f11f1..e39e2fd 100644 --- a/README.md +++ b/README.md @@ -1,8 +1,10 @@ # epic-loop -> A Codex skill for running long autonomous coding sessions without losing the plot. +> A Codex / Claude Code skill for running long autonomous coding sessions without losing the plot. -epic-loop splits a single Codex session into two roles — **techlead** and **engineer** — that hand off through the Codex Stop hook. The techlead plans, the engineer executes, the loop runs until the work is actually done. Epic state lives on disk, so sessions can be resumed by anyone, at any time. +epic-loop splits a single agent session into two roles — **techlead** and **engineer** — that hand off through the **Stop hook**. The techlead plans, the engineer executes, the loop runs until the work is actually done. Epic state lives on disk, so sessions can be resumed by anyone, at any time. + +It runs on **Codex** or **Claude Code**: both expose the same Stop-hook continuation contract, so the same skill drives the loop on either platform (one platform per session). The scripts auto-detect the platform and also accept an explicit `--platform codex|claude`. --- @@ -18,16 +20,16 @@ Long autonomous coding sessions waste hours on human glue: These are repeatable patterns. They don't need a human in the loop — they need the right orchestration. -When Codex shipped hooks — particularly the **Stop hook**, which can return a continuation prompt to the same session — the orchestration became possible. epic-loop is the skill that makes it usable. +Hooks make the orchestration possible — particularly the **Stop hook**, which can return a continuation prompt to the same session. Codex and Claude Code both expose this contract (`{ "decision": "block", "reason": "" }`), so epic-loop is the skill that makes it usable on either one. --- ## How it works -A single Codex session alternates between two roles, driven by the Stop hook: +A single agent session (Codex or Claude Code) alternates between two roles, driven by the Stop hook: ``` -single Codex session: +single agent session: techlead ──writes prompt for──▶ engineer ▲ │ @@ -38,7 +40,7 @@ single Codex session: **engineer role.** Gets a focused, custom prompt for one task. Executes it. Hands back to techlead. -**Single session.** Both roles run in the same Codex session — no second process, no inter-process plumbing. The Stop hook is what makes the alternation possible. +**Single session.** Both roles run in the same agent session — no second process, no inter-process plumbing. The Stop hook is what makes the alternation possible. **Durable epic state.** Each epic lives at `.epic-loop/epics//` with its own docs, tracker, decisions, and progress logs. Sessions die, contexts compact, machines crash — the epic survives. Any developer can resume any epic in any session. @@ -70,7 +72,7 @@ Modes are explicit. The skill knows which mode it's in and adapts behavior accor ## Requirements -- Codex with hooks support +- **Codex** (with `hooks = true` enabled) **or Claude Code** — either provides the Stop hook - Node.js (skill scripts are `.mjs`) - A project where you want to run long autonomous coding sessions @@ -78,13 +80,16 @@ Modes are explicit. The skill knows which mode it's in and adapts behavior accor ## Installation -epic-loop is a project-local Codex skill. Drop the skill into `.agents/skills/epic-loop/` in your project, then set up the project-local hooks: +epic-loop is a project-local skill. Drop the skill into `.agents/skills/epic-loop/` in your project. For Claude Code discoverability it is also exposed at `.claude/skills/epic-loop` (a symlink to the same source). Then set up the project-local hooks: ```bash -# Check technical readiness +# Check technical readiness (auto-detects Codex vs Claude Code) node .agents/skills/epic-loop/scripts/doctor.mjs -# Install project-local Codex hooks +# Install project-local hooks +# Codex -> .codex/hooks.json +# Claude Code -> .claude/settings.json +# Add --platform codex|claude to override auto-detection. node .agents/skills/epic-loop/scripts/install-hooks.mjs # Bind the current session to an epic