Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
53 changes: 42 additions & 11 deletions .agents/skills/epic-loop/SKILL.md
Original file line number Diff line number Diff line change
Expand Up @@ -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 <project-root>
node .agents/skills/epic-loop/scripts/install-hooks.mjs
Expand All @@ -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 <project-root>
node .agents/skills/epic-loop/scripts/install-hooks.mjs
Expand Down Expand Up @@ -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": "<next prompt>" }`, 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:

Expand Down
37 changes: 34 additions & 3 deletions .agents/skills/epic-loop/references/hooks-and-session-routing.md
Original file line number Diff line number Diff line change
Expand Up @@ -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/<encoded-cwd>/<session-id>.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":
"<next prompt>" }`. 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:
Expand All @@ -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.

Expand Down
124 changes: 124 additions & 0 deletions .agents/skills/epic-loop/scripts/lib/common.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -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");
Expand Down Expand Up @@ -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";
}
Expand Down
7 changes: 5 additions & 2 deletions .agents/skills/epic-loop/scripts/lib/debug.mjs
Original file line number Diff line number Diff line change
@@ -1,23 +1,25 @@
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);

const payload = {
active_sessions: bindings.active_sessions ?? {},
bindings_path: bindingsPath,
current_session_candidate: currentSession,
platform,
implementation_loops: readImplementationLoops(root),
recent_hook_events: recentHookEvents,
root,
Expand All @@ -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"}`);
Expand Down
12 changes: 8 additions & 4 deletions .agents/skills/epic-loop/scripts/lib/epics.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -4,12 +4,13 @@ import path from "node:path";
import {
MODES,
appendGitignore,
detectPlatform,
epicRuntimeRoot,
epicSlugify,
epicsRoot,
ensureDir,
nowIso,
readCurrentCodexSession,
readCurrentSession,
readJson,
requireFlag,
resolveRoot,
Expand Down Expand Up @@ -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/<encoded-cwd>/*.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");
Expand Down Expand Up @@ -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;
Expand Down
Loading