diff --git a/.epic-loop/epics/mode-reminder/decision-log.md b/.epic-loop/epics/mode-reminder/decision-log.md index 86f56b5..30a1a1b 100644 --- a/.epic-loop/epics/mode-reminder/decision-log.md +++ b/.epic-loop/epics/mode-reminder/decision-log.md @@ -2,10 +2,12 @@ ## Active Decisions -- The per-turn mode reminder will use `hookSpecificOutput.additionalContext` on `UserPromptSubmit`, the same field name and shape on both Claude Code and Codex CLI. Confirmed via `developers.openai.com/codex/hooks` that Codex supports this field identically to Claude Code. **Not yet confirmed by a real POC on either platform** — Phase 3 of this epic exists specifically to verify this against actual running agents, not just docs. -- Codex's TUI may render `additionalContext` as a visible developer message (per `openai/codex` issues #16486, #16933), unlike Claude Code where it behaves more like an invisible system-reminder. Decision: accept this difference as-is; a visible reminder is a transparency plus, not a defect to mask. +- The per-turn mode reminder will use `hookSpecificOutput.additionalContext` on `UserPromptSubmit`, the same field name and shape on both Claude Code and Codex CLI. Confirmed via `developers.openai.com/codex/hooks` that Codex supports this field identically to Claude Code. Real Codex CLI POC completed on 2026-07-06: an interactive `codex --no-alt-screen --dangerously-bypass-hook-trust --enable hooks -C /projects/my-projects/epic-loop ...` run with a temporary trusted-project `UserPromptSubmit` hook injected `POC-CODEX-ADDITIONAL-CONTEXT-TRUSTED-1783273589-8273`; Codex visibly rendered `UserPromptSubmit hook (completed)` and `hook context: ...8273`, and the model returned the exact token. Real Claude Code POC also completed on 2026-07-06 (see the Claude Code POC decision below); the mechanism is now proven natively on both platforms. +- Claude Code POC evidence (2026-07-06, Claude Code 2.1.201): a scratch-project `.claude/settings.json` `UserPromptSubmit` hook echoing `{"hookSpecificOutput":{"hookEventName":"UserPromptSubmit","additionalContext":"POC-CLAUDE-ADDCTX-1783274622"}}` fired on a real headless turn (`claude -p`, nested run with `CLAUDECODE`/`CLAUDE_CODE_ENTRYPOINT`/`CLAUDE_CODE_SSE_PORT` unset); the model replied `TOKEN_SEEN=yes TOKEN_VALUE=POC-CLAUDE-ADDCTX-1783274622`. The session transcript JSONL represents the injection as a dedicated attachment record `{"attachment":{"type":"hook_additional_context","content":["POC-CLAUDE-ADDCTX-1783274622"],"hookName":"UserPromptSubmit","hookEvent":"UserPromptSubmit"}}` preceding the assistant reply. No trust/permission gating was hit on the headless path — the project-local hook fired without extra flags. Known limitation: interactive TUI rendering was not observed (nested interactive TUI is not runnable from the verifying environment); the transcript attachment record is the accepted primary evidence. +- Codex's TUI renders `additionalContext` visibly as hook context in the transcript/TUI. Decision: accept this difference as-is; a visible reminder is a transparency plus, not a defect to mask. The POC also showed that `codex exec --json --ephemeral` did not fire the project-local hook in the untrusted scratch setup, so the proven path is the interactive CLI/TUI path in a trusted project. - The new detach script is named `unbind-session.mjs`, for naming consistency with the existing `bind-session.mjs`. -- The unbind trigger should not be a single rigid memorized phrase. The agent should recognize user intent to work outside the epic (e.g. "do this right now", "let's work without the epic for a bit") and call `unbind-session.mjs` proactively, while a short canonical phrase is still designed as a reliable fallback and is made explicit in `SKILL.md`'s frontmatter description. Exact phrase to be decided in Phase 4. +- The unbind trigger should not be a single rigid memorized phrase. The agent should recognize user intent to work outside the epic (e.g. "do this right now", "let's work without the epic for a bit") and call `unbind-session.mjs` proactively, while a short canonical phrase is still designed as a reliable fallback and is made explicit in `SKILL.md`'s frontmatter description. +- The canonical unbind trigger phrase is **`unbind epic`** (decided 2026-07-06, Phase 4): it mirrors the `bind-session.mjs`/`unbind-session.mjs` naming pair, is ultra-short, and lists naturally among the frontmatter `description` triggers. `SKILL.md` now documents it in the frontmatter (verbatim) and in a `## Hooks` subsection: intent-based proactive unbind with the phrase as the always-reliable fallback, `--current`/`--session-id` command shapes, optional `--reason`, no `--slug`/`--mode`, no-op for unbound sessions, rebind via the normal resume flow. Live evidence: after `self-update`, the running Claude Code session reloaded the updated skill description containing the new trigger. - Rebinding/resuming after an unbind reuses the existing resume flow; no new reattach mechanism is assumed necessary unless Phase 1 design work finds a gap. - Phase 3 runs Codex's POC first, then Claude Code's POC, with an explicit stop-and-switch task in between. Each platform must run its own POC from a real session on that platform — not simulated from the other platform — so the evidence is genuine per-platform proof, not a cross-platform guess. - Phase 2 design proposal accepted (`docs/mode-reminder-design.md`): the mode reminder is built by a new `buildModeReminder(payload, binding)` in `lib/hooks.mjs` (not `loop.mjs`), sourced solely from `binding.mode` in the already-loaded `session-bindings.json` — no extra file reads. It fires only for bound `shaping`/`review` sessions on `UserPromptSubmit`; implementation-mode sessions are excluded because the existing `Stop`-driven loop machinery already carries role/mode context every turn. @@ -13,7 +15,16 @@ - Reminder text is fixed to one line per mode (see `docs/mode-reminder-design.md` section 1), prefixed `[epic-loop]`, naming the epic slug and pointing at the relevant `SKILL.md` rule section. Wording may be tuned after Phase 3 POC evidence, but the mechanism and source do not change. - `unbind-session.mjs` takes `--current`/`--session-id` (matching `bind-session.mjs`'s detection semantics) plus an optional `--reason`, but deliberately has no `--slug`/`--mode` — it deactivates whatever epic/mode the resolved session id is currently actively bound to, since a session can only ever hold one active binding. - Unbinding sets `active: false`, `deactivated_at`, and a new additive `deactivated_reason` field on the session's `session-bindings.json` entry (mirroring the shape `bindSession` already writes when a different session supersedes one), and clears the `active_sessions[":"]` pointer if it still points at this session id. No schema change for any other reader. A no-op (exit 0) if the session is already unbound or was never bound. +- **Epic-centric mode model accepted (2026-07-07, supersedes the same-day per-binding-mode plan; full design in `docs/epic-mode-model.md`):** an epic is always in exactly one lifecycle mode, held solely in `runtime-state.json` `mode` (`shaping | implementation | review`). Session bindings become mode-less **epic membership**: any number of sessions may be active members of one epic, all receive the same mode marker, and a mode change by any session propagates to every member's next `UserPromptSubmit` without rebinding. Parallel work on different epics is preserved unchanged (one active binding per session, distinct epics never interact). "Multiple sessions on the same epic in different modes" is no longer a supported state. +- `active_sessions` is deleted from the `session-bindings.json` schema (2026-07-07): it only enforced one-session-per-epic/mode, which membership abolishes for shaping/review. This removes the stale-pointer bug class by construction — the earlier "clear stale `active_sessions` on rebind" fix task is superseded, not implemented. Readers tolerate the old leftover shape; the state is gitignored runtime data, no migration script. +- Implementation keeps one exclusive **driver** session (2026-07-07): recorded as `implementation_loop.driver_session_id` in the epic's `runtime-state.json`. Only the driver's `Stop` events continue the loop; a `UserPromptSubmit` from a non-driver member does not interrupt the loop; unbinding the driver sets the loop to `idle` with a clear reason. Entering implementation = membership + set epic mode + designate driver + start loop. +- The human-facing `Current mode:` line in `state-of-epic.md` is dropped entirely (2026-07-07): removed from the `init-epic` template, and `readEpicStateSummary` in `lib/loop.mjs` stops regex-parsing prose for the mode (switches to `runtime-state.json`). Rationale: machines parsing human prose was the ambiguity source; the mode is machine state, `state-of-epic.md` stays purely narrative. New `set-epic-mode.mjs` maintains transitions that today go stale (observed on this very epic: runtime said `implementation` while actually shaping). +- Reminder sourcing change (2026-07-07, supersedes the Phase 2 "no extra file read" decision): `buildModeReminder` reads the epic's `runtime-state.json` `mode` instead of `binding.mode`. Cost: one extra small JSON read per prompt — accepted as the price of live mode propagation to all members; missing/corrupt runtime-state → silent skip, never a crash. +- Compact marker text accepted (2026-07-07): `[epic-loop] epic= mode= — follow epic-loop skill mode rules`. A bare marker without an action hint was rejected (risks being read as background noise when `SKILL.md` is not yet loaded); mode-specific instructions stay in `SKILL.md`, triggered via the frontmatter description mentioning the marker pattern. Constraint: the frontmatter description is currently 895 chars against Claude Code's 1024-char limit, so the marker mention must fit in ~120 chars. +- Auto-bind on resume, race guard (2026-07-07): resume by slug/path binds the current session as a member (no mode flag; reminders follow the epic mode automatically; no automatic driver designation for implementation epics). The current-session capture file is last-writer-wins per project with a 15-minute TTL and is written on every hook event of every session, so a parallel session can overwrite it between the user's prompt and the bind call. For auto-bind the capture is accepted only when fresh AND `hook_event_name === "UserPromptSubmit"`; on Codex the mtime transcript fallback makes the race worse, so the same guard applies there. If no acceptable capture exists (e.g. hooks installed but not yet trusted in the running thread), auto-bind is skipped with a one-line notice and resume orientation continues — the general "no hooks installed" path is out of scope because `doctor` gates the whole flow on hook setup before any epic work. +- Implementation **lock marker** for non-driver members (2026-07-07, closes the last open question of the epic-centric model): when the epic mode is `implementation`, non-driver member sessions receive `[epic-loop] epic= mode=implementation — loop running in another session; read-only, do not edit epic artifacts` on every `UserPromptSubmit`; the driver receives nothing (loop continuations already carry role context). The marker is a deliberate advisory locker protecting the loop's artifact writes (manager compaction, log appends, tracker renders) from concurrent shaping edits; it is not a mechanical write barrier, which is accepted for the agent-session threat model. +- Follow-up work is tracked as **Phase 7 — Epic-Centric Mode Model And Compact Reminder** (2026-07-07), ordered: mode source → membership/driver model → marker → auto-bind → live multi-session verification. The umbrella "design and fix binding lifecycle" follow-up task was removed as duplicative: its design content is this decision set plus `docs/epic-mode-model.md`. ## Historical Decisions -- None recorded yet. +- Superseded same day by the epic-centric model (2026-07-07): the first follow-up plan kept per-binding modes and single-active-session-per-epic/mode semantics — Phase 7 was initially "Fix Session Binding Lifecycle And Compact Reminder" with a "clear stale `active_sessions` pointers on rebind" fix task, "last bind wins" takeover for two sessions in the same mode on the same epic, and `state-of-epic.md`'s `Current mode:` line kept as human-facing information. Preserved in the superseded-marked "Accepted Plan (2026-07-07)" section of `docs/shaping-binding-gap.md`. diff --git a/.epic-loop/epics/mode-reminder/docs/epic-mode-model.md b/.epic-loop/epics/mode-reminder/docs/epic-mode-model.md new file mode 100644 index 0000000..bb6a318 --- /dev/null +++ b/.epic-loop/epics/mode-reminder/docs/epic-mode-model.md @@ -0,0 +1,156 @@ +# Epic-Centric Mode Model (v2) + +Status: accepted during follow-up shaping on 2026-07-07. Supersedes the session-mode parts of the +"Accepted Plan (2026-07-07)" section in `docs/shaping-binding-gap.md` (see the superseded note there) +and the per-binding-mode decisions in `decision-log.md`. + +## Core principle + +**An epic is always in exactly one lifecycle mode.** The mode belongs to the epic, not to a session +binding. Any number of sessions may be bound to the same epic; they are all "in" the epic's current +mode and all receive the same mode marker. When one session changes the epic's mode, every bound +session picks up the change on its next turn — no rebinding. + +## 1. Single machine-readable mode source + +- Source of truth: the existing `mode` field in `.epic-loop/epics//.runtime/runtime-state.json`, + values `shaping | implementation | review`. +- Writers (scripts only, never hand edits): + - `init-epic.mjs` → `shaping` (already does). + - New `set-epic-mode.mjs --slug --mode ` → explicit lifecycle transitions + (reopen shaping, enter review, etc.). Validates the mode, updates `updated_at`. + - Implementation start (`startImplementationLoop`) → `implementation` (already does). +- The human-facing `Current mode:` line in `state-of-epic.md` is **dropped**: removed from the + `init-epic` template and no longer written or parsed by anything. `readEpicStateSummary` in + `lib/loop.mjs` (which today regex-parses `state-of-epic.md` prose for `mode`) switches to + `runtime-state.json`. `Active phase:` / `Active task:` prose lines are out of scope here. +- If `runtime-state.json` is missing or unreadable for a bound epic, hooks stay silent (no reminder, + no crash); scripts that need the mode fail with an explicit error instead of guessing. + +## 2. Binding = epic membership (no per-binding mode) + +`.epic-loop/.runtime/session-bindings.json` v2: + +```json +{ + "sessions": { + "": { + "active": true, + "epic_slug": "", + "bound_at": "...", + "activated_at": "...", + "source": "current-claude-code-session" + } + } +} +``` + +- The `mode` field on a binding is removed. A binding only answers "which epic does this session + belong to". +- **Many sessions may be active members of the same epic simultaneously** — all of them get + reminders. Parallel work on *different* epics keeps working exactly as before: each session holds + exactly one active binding, distinct epics never interact. +- The `active_sessions` map (`":" -> session_id`) is **deleted from the schema**. It only + existed to enforce one-session-per-epic/mode, which the membership model abolishes for + shaping/review; the implementation driver moves to the epic's own runtime state (section 4). This + removes the stale-pointer bug class *by construction* — the previous "clear stale active_sessions + on rebind" fix becomes unnecessary. +- Rebinding a session to another epic rewrites only its own `sessions[session_id]` entry; nothing + shared can go stale. +- Migration: the state is gitignored runtime data. Readers tolerate the old shape (ignore a leftover + `mode` field and `active_sessions` map); no migration script needed. + +## 3. Reminders follow the epic mode + +On `UserPromptSubmit` for an active member session, the hook: + +1. resolves the binding (unchanged strict opt-in: unbound sessions are silent no-ops), +2. reads the epic's `runtime-state.json` `mode`, +3. emits the compact marker for `shaping`/`review`; for `implementation` emits the **lock marker** to + non-driver members and nothing to the driver. + +Marker text (exact): + +- `shaping`/`review`, every member: `[epic-loop] epic= mode= — follow epic-loop skill mode rules` +- `implementation`, non-driver members only: `[epic-loop] epic= mode=implementation — loop running in another session; read-only, do not edit epic artifacts` +- `implementation`, driver: nothing (the `Stop`-driven loop already carries role/mode context every turn). + +The lock marker is advisory, not a mechanical write barrier: context injection disciplines the agent +in that session but cannot block writes. That is sufficient for the threat model (agent sessions +that read their injected context); it protects the loop's artifact writes (manager compaction, +implementation-log appends, tracker renders) from concurrent shaping edits. + +- This supersedes the Phase 2 "no extra file read" decision: the reminder now costs one additional + small JSON read per prompt (the hook already reads `session-bindings.json` and writes several + event files per event; one more read is negligible and is the price of live mode propagation). +- Propagation guarantee: a mode change by any session (or by the implementation loop itself) is + reflected in every member session's very next `UserPromptSubmit` — reminders appear, change, or + stop accordingly. +- `SKILL.md` frontmatter description mentions the `[epic-loop] epic=... mode=...` marker pattern as a + trigger (must keep the description ≤1024 chars; currently 895). A short body note explains: the + marker means this session is a member of that epic in that mode; apply the existing mode rules. + +## 4. Implementation mode keeps one exclusive driver + +The Stop-hook loop must be driven by exactly one session, so implementation adds a driver on top of +membership: + +- The driver session id is recorded in the epic's `runtime-state.json` under `implementation_loop` + (explicit `driver_session_id`, alongside the existing `last_session_id` bookkeeping). +- Entering implementation (today's `bind-session --mode implementation` entry point) = bind + membership + set epic mode to `implementation` + designate this session as driver + start the + loop. Designating a new driver replaces the previous one. +- `maybeBuildImplementationContinuation` gates on: epic mode is `implementation` AND + `payload.session_id === driver_session_id`. +- Turn-interruption (`markInterruptedTurnIfNeeded`) gates on the driver too: a `UserPromptSubmit` + from a *non-driver* member session must NOT interrupt the loop. +- Non-driver members of an implementing epic get the read-only lock marker (section 3) and no loop + output; only the driver is excluded from reminders entirely. +- If the driver session unbinds (`unbind-session.mjs`), the loop cannot continue: record the event + and set the loop status to `idle` with a clear reason; resuming implementation designates a new + driver through the normal start/resume flow. + +## 5. Auto-bind on resume (simplified by this model) + +Resuming an epic by slug/path binds the current session as a member — **no mode flag needed**; the +reminder follows the epic's current mode automatically: + +- Epic mode `shaping`/`review` → bind membership; the marker appears from the next turn. +- Epic mode `implementation` → membership bind is still allowed (observer), but the explicit + implementation start/resume confirmation flow is unchanged and no driver is designated + automatically. +- Capture race guard (unchanged from v1 plan): the `--current` capture is last-writer-wins per + project (15-min TTL, written by every session's every hook event), so auto-bind accepts it only + when fresh AND `hook_event_name === "UserPromptSubmit"`. On Codex the mtime transcript fallback + makes the race worse; the same guard applies. If no acceptable capture exists (e.g. hooks + installed but not yet trusted in the running thread), skip auto-bind with a one-line notice and + continue orientation. + +## 6. Parallel-session semantics (v2) + +- One worktree works one epic during implementation; the epic has one driver. +- Any number of sessions may shape or review the same epic **at the same time, in the same mode** — + the epic's single mode is shared by all of them, and all receive the marker. +- "Multiple sessions on the same epic in *different* modes" is no longer a supported state: the epic + mode is global to the epic. `SKILL.md`'s Parallel Work section and + `references/parallel-sessions.md` must be updated accordingly (mode-owned artifact ownership + simplifies to: the epic's current mode owns its artifacts; append-dated-entries discipline stays). + +## 7. Consumers to switch off the old model + +Known code touchpoints (implementation tasks map the full list): + +- `lib/hooks.mjs`: `buildModeReminder` (source mode from epic runtime-state, not binding), + `getSessionBinding` (drop `active_sessions` check), `maybeBuildImplementationContinuation` / + `markInterruptedTurnIfNeeded` call sites (driver gate). +- `lib/epics.mjs`: `bindSession` (membership + driver designation split, drop `active_sessions`), + `unbindSession` (drop `active_sessions` cleanup, add driver-unbind loop pause), `init-epic` + template (drop `Current mode:` line). +- `lib/loop.mjs`: `readEpicStateSummary` (stop parsing prose `Current mode`), `startImplementationLoop` + (driver designation), loop summaries that expose `mode`. +- `scripts/unbind-session.mjs`, `scripts/bind-session.mjs` CLI docs; `role-summary.mjs` output if it + prints binding mode. +- Tests: `tests/unit/unbind-and-reminder.test.mjs`, `hook-contracts.test.mjs`, + `cli-contracts.test.mjs` contracts pinned to the old schema. +- Docs: `SKILL.md` (Hooks, Parallel Work, resume flow), `references/hooks-and-session-routing.md`, + `references/parallel-sessions.md`, `docs/mode-reminder-design.md` (superseded note). diff --git a/.epic-loop/epics/mode-reminder/docs/mode-reminder-design.md b/.epic-loop/epics/mode-reminder/docs/mode-reminder-design.md index 7d95f40..8df8ea8 100644 --- a/.epic-loop/epics/mode-reminder/docs/mode-reminder-design.md +++ b/.epic-loop/epics/mode-reminder/docs/mode-reminder-design.md @@ -1,6 +1,6 @@ # Mode Reminder + Unbind Design Proposal -Status: proposed (Phase 2). Not yet POC-validated (Phase 3) or implemented (Phase 5). +Status: historical Phase 2 design. POC validation and the original implementation completed in Phases 3-5, but the reminder source/text portions are superseded by the accepted epic-centric model in `docs/epic-mode-model.md` (2026-07-07): mode now comes from epic runtime state, bindings are mode-less members, shaping/review use the compact marker, and implementation non-driver members receive a read-only lock marker. ## 1. Per-Turn Mode Reminder (`UserPromptSubmit`) @@ -12,7 +12,9 @@ Status: proposed (Phase 2). Not yet POC-validated (Phase 3) or implemented (Phas ### Source of the mode value -`binding.mode` from `.epic-loop/.runtime/session-bindings.json`, which is already parsed and loaded in `handleHook` to gate on (`getSessionBinding`). No additional file read (not `state-of-epic.md`, not `runtime-state.json`) is needed to know the mode — this satisfies the constraint that the reminder must be cheap. +Historical design: `binding.mode` from `.epic-loop/.runtime/session-bindings.json`. + +Current design: `runtime-state.json` `mode` from the epic runtime state. This supersedes the "no additional file read" constraint so mode changes propagate to every active member session without rebinding. ### New function @@ -42,10 +44,9 @@ function buildModeReminder(payload, binding) { ```js const MODE_REMINDER_TEXT = { - shaping: (slug) => - `[epic-loop] Bound to epic "${slug}" — shaping mode. A plain imperative is a tracker.md task, not an action to run now — state which interpretation you're using (SKILL.md Shaping Rules).`, - review: (slug) => - `[epic-loop] Bound to epic "${slug}" — review mode. Compare against original intent, not just current docs, and state which resolution path you're taking (SKILL.md Review Rules).`, + marker: (slug, mode) => `[epic-loop] epic=${slug} mode=${mode} — follow epic-loop skill mode rules`, + implementationLock: (slug) => + `[epic-loop] epic=${slug} mode=implementation — loop running in another session; read-only, do not edit epic artifacts`, }; ``` @@ -95,9 +96,10 @@ Claude Code renders `additionalContext` as invisible model context (a system-rem | Binding state | `UserPromptSubmit` behavior | | --- | --- | | Unbound session | No-op (unchanged; gated by `getSessionBinding` before any new code runs) | -| Bound, `mode: shaping` | `additionalContext` reminder injected every turn | -| Bound, `mode: review` | `additionalContext` reminder injected every turn | -| Bound, `mode: implementation` | No reminder; existing loop machinery (`Stop`-driven continuations) already carries role/mode context every turn | +| Bound member, epic mode `shaping` | Compact `additionalContext` marker injected every turn | +| Bound member, epic mode `review` | Compact `additionalContext` marker injected every turn | +| Bound member, epic mode `implementation`, driver session | No reminder; existing loop machinery (`Stop`-driven continuations) already carries role/mode context every turn | +| Bound member, epic mode `implementation`, non-driver session | Read-only lock marker injected every turn | ## 2. `unbind-session.mjs` diff --git a/.epic-loop/epics/mode-reminder/docs/shaping-binding-gap.md b/.epic-loop/epics/mode-reminder/docs/shaping-binding-gap.md new file mode 100644 index 0000000..72738d8 --- /dev/null +++ b/.epic-loop/epics/mode-reminder/docs/shaping-binding-gap.md @@ -0,0 +1,63 @@ +# Shaping Binding Gap + +## Summary + +The mode reminder implementation works for sessions that are already bound in `shaping` or `review` mode, but the normal new-epic shaping flow does not automatically create that binding. + +Observed on 2026-07-06: + +- A new `set-up` epic was created and shaped through epic artifacts. +- The session was not registered in `.epic-loop/.runtime/session-bindings.json` during normal shaping. +- Because the hook is intentionally bound-session-only, `UserPromptSubmit` produced no `hookSpecificOutput.additionalContext`. +- Manually running `bind-session.mjs --current --slug set-up --mode shaping` made the next turn receive the expected reminder. + +## Additional Binding Consistency Issue + +After manually switching the same session from `set-up:shaping` to `mode-reminder:shaping`, the primary session entry changed to `mode-reminder` and `mode: shaping`, but `active_sessions["set-up:shaping"]` still pointed at the same session id. + +That means the binding model currently allows a stale active-session pointer for a previous epic/mode even though the session entry itself can only represent one active binding. + +## Problem Statement + +There are two related gaps: + +1. Shaping/review reminders depend on explicit runtime binding, but the normal shaping/resume path does not bind the session. +2. Rebinding a session to another epic/mode can leave stale `active_sessions` pointers for the old epic/mode. + +## Desired Direction + +- Define when shaping/review sessions should be bound automatically or explicitly. +- Make binding semantics internally consistent: one active session entry should not leave stale active pointers for older epic/mode pairs. +- Preserve the strict guarantee that unbound sessions produce no epic-loop hook output. +- Keep implementation-mode binding semantics unchanged unless the same consistency fix is needed there. + +## Accepted Plan (2026-07-07) + +> **Superseded later the same day (2026-07-07):** the session-mode parts of this plan were replaced +> by the epic-centric mode model in `docs/epic-mode-model.md` — the epic holds exactly one mode in +> `runtime-state.json`, bindings are mode-less epic membership, any number of sessions per epic get +> reminders, and `active_sessions` is deleted (which removes item 1's stale-pointer bug class by +> construction). The compact marker text, the capture race guard, and the auto-bind skip behavior +> below carried over into the new model unchanged. Kept for history. + +Accepted during follow-up shaping; tracked as **Phase 7 — Fix Session Binding Lifecycle And Compact Reminder** in `tracker.md`. Task order is deliberate: 1 → 2 → 3 → 4 → verification (task 4 depends on 1 for pointer hygiene and on 3 for its mode source; 2 lands before 4 so auto-bind tests assert the final reminder text). + +### 1. Rebind cleanup (`bindSession`) + +`bindSession` currently deactivates *other* sessions holding the same `slug:mode` key but never removes the *same* session's old `active_sessions` key when it rebinds to a different epic/mode — the session entry is overwritten while the old pointer survives. `getSessionBinding`'s dual check (`active === true` AND `active_sessions[key] === sessionId`) means reminders are unaffected, but the stale pointer lies to anything reading `active_sessions` directly (e.g. `previousSessionId` reporting). Fix: on bind, delete every `active_sessions` entry pointing at this session id except the new key — symmetric to what `unbindSession` already does for its own key. + +### 2. Compact mode marker + +Reminder text becomes exactly `[epic-loop] epic= mode= — follow epic-loop skill mode rules`. Rationale: the verbose per-mode one-liners duplicate `SKILL.md` rules on every turn; the marker plus a minimal action hint is cheaper and triggers the skill via the frontmatter description (which must mention the marker pattern and stay ≤1024 chars; currently 895). A fully bare marker was rejected — without a verb it risks being ignored when `SKILL.md` is not yet in context. + +### 3. Machine-readable epic mode + +The epic's lifecycle mode already exists machine-readably as the `mode` field in `.epic-loop/epics//.runtime/runtime-state.json`; the gap is that only `init-epic` (sets `shaping`) and `startImplementationLoop` (sets `implementation`) write it, so it goes stale when shaping reopens or review starts (this epic itself: runtime `mode: implementation` while actually shaping). Fix: a `set-epic-mode.mjs` script (or `lib/epics.mjs` helper) validates and writes `mode` + `updated_at`, and `SKILL.md` instructs the agent to call it on every lifecycle transition. `state-of-epic.md`'s `Current mode:` line stays as human-facing information only — no machine flow parses prose. + +### 4. Auto-bind on resume + +Resuming an epic by slug/path binds the current session with the epic's current mode **only when that mode is `shaping` or `review`** (safe: `bindSession` starts loop machinery only for `implementation`). The implementation/idle resume path keeps the existing explicit-confirmation flow, unchanged. Race guard: the `--current` capture is last-writer-wins per project (15-min TTL, written by every session's every hook event), so auto-bind accepts it only when fresh AND `hook_event_name === "UserPromptSubmit"`; the Codex mtime fallback makes the race worse there, same guard applies. If no acceptable capture exists (e.g. hooks installed but not yet trusted in the running thread — the only realistic failure, since `doctor` gates the flow on hook setup), auto-bind is skipped with a one-line notice and orientation continues. + +### Parallel-session semantics (accepted as-is) + +One worktree works one epic during implementation; parallel sessions are expected in shaping/review only. Parallel shaping across different epics works (distinct `slug:mode` keys). Two sessions in the same mode on the same epic share one hook-routed slot by design: the last bind wins and silently deactivates the previous session's reminders. Auto-bind makes this takeover implicit on resume, which is accepted; `bind-session` already reports the deactivated previous session id. diff --git a/.epic-loop/epics/mode-reminder/implementation-log.md b/.epic-loop/epics/mode-reminder/implementation-log.md index bd039f1..0e63184 100644 --- a/.epic-loop/epics/mode-reminder/implementation-log.md +++ b/.epic-loop/epics/mode-reminder/implementation-log.md @@ -11,3 +11,103 @@ - Recorded the accepted decisions in `decision-log.md`. - Closed `phase-2-task-1` and Phase 2 via `close-task.mjs` / `close-phase.mjs`; pointed the active phase at Phase 3 via `set-active-phase.mjs` (no phase-3 work started — its first task requires a real Codex CLI session, which this Claude Code session cannot provide). - No code changes in this session; Phase 3 is still todo. + +## 2026-07-05T17:48:51+00:00 - closed: real interactive Codex CLI POC proved UserPromptSubmit additionalContext reaches both visible TUI hook context and model context; token POC-CODEX-ADDITIONAL-CONTEXT-TRUSTED-1783273589-8273 was rendered and returned exactly; codex exec/untrusted scratch path did not prove hooks; temporary hooks/scratch sessions were cleaned up + +- Task: phase-3-task-1 +- Verdict: closed: real interactive Codex CLI POC proved UserPromptSubmit additionalContext reaches both visible TUI hook context and model context; token POC-CODEX-ADDITIONAL-CONTEXT-TRUSTED-1783273589-8273 was rendered and returned exactly; codex exec/untrusted scratch path did not prove hooks; temporary hooks/scratch sessions were cleaned up +- Commit: task-owned commit `Record Codex additionalContext POC evidence`; final hash reported by techlead after commit creation. + +## 2026-07-05T18:01:44+00:00 - closed + +- Task: Phase 3 Task 2 - Platform-switch handoff between Codex and Claude Code POCs +- Verdict: closed. The Codex session stopped after the Codex POC (loop set idle with reason `platform-switch-to-claude-code-poc`, user told to resume in Claude Code), and the epic was resumed in a real Claude Code session on 2026-07-06 (doctor `--platform claude-code` ready, session rebound, implementation-start housekeeping passed: clean tree on `feature/mode-keeper`, `pnpm run validate` and `pnpm run test:unit` green, 33/33). No POC evidence was produced from the wrong platform — acceptance satisfied. +- No code changes; task-owned changes are epic artifacts only (`tracker.md`, `state-of-epic.md`, this log). +- Commit: task-owned commit `docs: record Codex-to-Claude-Code platform handoff for mode-reminder POC`; hash reported by techlead after creation. + +## 2026-07-05T18:06:10+00:00 - closed: real headless Claude Code turn (claude -p, v2.1.201) proved UserPromptSubmit additionalContext injection — token POC-CLAUDE-ADDCTX-1783274622 echoed exactly; transcript JSONL shows a hook_additional_context attachment record carrying the injected text into model context; no trust gating on headless path; scratch project cleaned up; interactive TUI rendering not observed (documented limitation) + +- Task: phase-3-task-3 +- Verdict: closed: real headless Claude Code turn (claude -p, v2.1.201) proved UserPromptSubmit additionalContext injection — token POC-CLAUDE-ADDCTX-1783274622 echoed exactly; transcript JSONL shows a hook_additional_context attachment record carrying the injected text into model context; no trust gating on headless path; scratch project cleaned up; interactive TUI rendering not observed (documented limitation) + +## 2026-07-05T18:12:08+00:00 - closed: canonical unbind phrase 'unbind epic' decided and documented in SKILL.md source (frontmatter description + Hooks subsection with intent rule, command shapes, no-op and rebind semantics); validate passed; runtime copies re-synced and diff-clean; live session reloaded the new description + +- Task: phase-4-task-1 +- Verdict: closed: canonical unbind phrase 'unbind epic' decided and documented in SKILL.md source (frontmatter description + Hooks subsection with intent rule, command shapes, no-op and rebind semantics); validate passed; runtime copies re-synced and diff-clean; live session reloaded the new description + +## 2026-07-05T18:15:26+00:00 - closed: audit found zero discrepancies across SKILL.md, docs/mode-reminder-design.md section 2, and decision-log.md (flags, no-op, silent-hooks, rebind semantics, verbatim phrase); frontmatter YAML valid; .claude/.codex runtime copies diff-clean; validate passed; 33/33 unit tests green; read-only pass, no files changed. Follow-up for Phase 5 tests: cover the design doc's default --reason value user-requested-unbind + +- Task: Phase 4 Task 2 (verification) - cross-doc unbind contract audit +- Verdict: closed: audit found zero discrepancies across SKILL.md, docs/mode-reminder-design.md section 2, and decision-log.md (flags, no-op, silent-hooks, rebind semantics, verbatim phrase); frontmatter YAML valid; .claude/.codex runtime copies diff-clean; validate passed; 33/33 unit tests green; read-only pass, no files changed. Follow-up for Phase 5 tests: cover the design doc's default --reason value user-requested-unbind + +## 2026-07-05T18:18:39+00:00 - note: roadmap re-renders (start-phase/start-task) wiped the hand-added Phase 4 verification task from tracker.md a second time; durable fix applied - the task is now persisted in structured state via add-follow-up-task.mjs (id follow-up-01-...) and renders in the follow-ups section. Original Phase 4 placement remains visible in git history (commit 98e3e78). Candidate skill improvement outside this epic: hand-added tracker tasks do not survive re-renders + +- Task: bookkeeping +- Verdict: note: roadmap re-renders (start-phase/start-task) wiped the hand-added Phase 4 verification task from tracker.md a second time; durable fix applied - the task is now persisted in structured state via add-follow-up-task.mjs (id follow-up-01-...) and renders in the follow-ups section. Original Phase 4 placement remains visible in git history (commit 98e3e78). Candidate skill improvement outside this epic: hand-added tracker tasks do not survive re-renders + +## 2026-07-05T18:22:23+00:00 - closed: buildModeReminder added to lib/hooks.mjs with single ?? fallback in handleHook (one-line control-flow change, verified by diff); unbindSession added to lib/epics.mjs reusing bindSession's detection helpers; new thin scripts/unbind-session.mjs. Smoke against temp --root proved: shaping/review reminders injected on UserPromptSubmit, zero output for unbound sessions and Stop events, no-op exit 0 unbind for never-bound/already-unbound, deactivated_reason default and custom values, active_sessions cleanup, unbind.json mirror. validate green, 33/33 existing tests green, self-update deliberately deferred until new tests pass + +- Task: phase-5-task-1 +- Verdict: closed: buildModeReminder added to lib/hooks.mjs with single ?? fallback in handleHook (one-line control-flow change, verified by diff); unbindSession added to lib/epics.mjs reusing bindSession's detection helpers; new thin scripts/unbind-session.mjs. Smoke against temp --root proved: shaping/review reminders injected on UserPromptSubmit, zero output for unbound sessions and Stop events, no-op exit 0 unbind for never-bound/already-unbound, deactivated_reason default and custom values, active_sessions cleanup, unbind.json mirror. validate green, 33/33 existing tests green, self-update deliberately deferred until new tests pass + +## 2026-07-05T18:26:10+00:00 - closed: tests/unit/unbind-and-reminder.test.mjs adds 10 end-to-end tests via runNodeScript covering shaping/review reminder injection (exact strings), unbound/Stop/implementation-mode silence (empty stdout asserted), unbind no-op/default reason/custom reason/second-unbind/post-unbind hook silence; full suite 43/43 green, validate green; verified fresh by techlead + +- Task: phase-5-task-2 +- Verdict: closed: tests/unit/unbind-and-reminder.test.mjs adds 10 end-to-end tests via runNodeScript covering shaping/review reminder injection (exact strings), unbound/Stop/implementation-mode silence (empty stdout asserted), unbind no-op/default reason/custom reason/second-unbind/post-unbind hook silence; full suite 43/43 green, validate green; verified fresh by techlead + +## 2026-07-05T18:28:39+00:00 - closed: hooks-and-session-routing.md gained the mode-reminder bullet in 'Hooks can' and the unbind paragraph in Binding Sessions, consistent with SKILL.md; pnpm run self-update executed after the 43-test green suite; both runtime copies diff-clean vs plugins/; full suite re-verified 43/43. Note: set-task-status.mjs cannot address follow-up ids (searches phase tasks only) - tracker checkbox maintained by hand + +- Task: follow-up-02 (reference docs + runtime sync) +- Verdict: closed: hooks-and-session-routing.md gained the mode-reminder bullet in 'Hooks can' and the unbind paragraph in Binding Sessions, consistent with SKILL.md; pnpm run self-update executed after the 43-test green suite; both runtime copies diff-clean vs plugins/; full suite re-verified 43/43. Note: set-task-status.mjs cannot address follow-up ids (searches phase tasks only) - tracker checkbox maintained by hand + +## 2026-07-05T18:31:42+00:00 - closed: final full-suite verification green - pnpm run test:unit 43/43/0 with all 10 unbind-and-reminder tests named in output, pnpm run validate exit 0, both runtime copies diff-clean vs plugins/, no product files dirty + +- Task: phase-6-task-1 +- Verdict: closed: final full-suite verification green - pnpm run test:unit 43/43/0 with all 10 unbind-and-reminder tests named in output, pnpm run validate exit 0, both runtime copies diff-clean vs plugins/, no product files dirty + +## 2026-07-05T18:33:24+00:00 - epic implementation complete: all six phases closed; feature commits 64b6c81 (code), 02d6fae (tests), 6ce18f9 (reference docs + sync), a6810d0 (SKILL.md trigger contract); final suite 43/43, validate clean, runtime copies synced; unbound-silence risk marked mitigated per test coverage; remaining open risks (Codex reminder noisiness, intent-based unbind misfire) are post-release observational; next user-side step is review/merge of feature/mode-keeper + +- Task: implementation-exit +- Verdict: epic implementation complete: all six phases closed; feature commits 64b6c81 (code), 02d6fae (tests), 6ce18f9 (reference docs + sync), a6810d0 (SKILL.md trigger contract); final suite 43/43, validate clean, runtime copies synced; unbound-silence risk marked mitigated per test coverage; remaining open risks (Codex reminder noisiness, intent-based unbind misfire) are post-release observational; next user-side step is review/merge of feature/mode-keeper + +## 2026-07-07T06:18:39+00:00 - closed: runtime-state mode is now the only machine-readable lifecycle mode source for this slice; init-epic no longer emits Current mode prose; set-epic-mode.mjs validates shaping|implementation|review and writes mode plus updated_at; loop runtime merge no longer parses Current mode from state-of-epic.md; SKILL.md documents set-epic-mode for explicit shaping/review transitions; tests cover init output, set-mode update/invalid mode, and no-prose-parsing guard. Verification: node --test tests/unit/init-epic-cli.test.mjs passed 3/3; pnpm run test:unit passed 45/45; pnpm run validate passed; manual missing-runtime smoke observed explicit Runtime state not found failure. Metadata correction: set-epic-mode.mjs executable bit aligned with existing CLI scripts. Deferred to later Phase 7 tasks: membership bindings, implementation driver state, compact marker, lock marker, auto-bind-on-resume, and runtime copy sync. Commit: task-owned commit; final hash reported by techlead after commit creation. + +- Task: phase-7-task-1 +- Verdict: closed: runtime-state mode is now the only machine-readable lifecycle mode source for this slice; init-epic no longer emits Current mode prose; set-epic-mode.mjs validates shaping|implementation|review and writes mode plus updated_at; loop runtime merge no longer parses Current mode from state-of-epic.md; SKILL.md documents set-epic-mode for explicit shaping/review transitions; tests cover init output, set-mode update/invalid mode, and no-prose-parsing guard. Verification: node --test tests/unit/init-epic-cli.test.mjs passed 3/3; pnpm run test:unit passed 45/45; pnpm run validate passed; manual missing-runtime smoke observed explicit Runtime state not found failure. Metadata correction: set-epic-mode.mjs executable bit aligned with existing CLI scripts. Deferred to later Phase 7 tasks: membership bindings, implementation driver state, compact marker, lock marker, auto-bind-on-resume, and runtime copy sync. Commit: task-owned commit; final hash reported by techlead after commit creation. + +## 2026-07-07T07:48:51+00:00 - closed: session bindings are mode-less membership records; new writes omit mode and active_sessions while readers tolerate old state; implementation start records implementation_loop.driver_session_id; Stop continuations and UserPromptSubmit interruption are driver-gated; unbinding the driver idles the loop with reason implementation-driver-unbound; existing reminder text is preserved via runtime-mode bridge, with compact/lock marker deferred. Verification: focused unbind/reminder 13/13, hook-contracts 15/15, cli-contracts 11/11, pnpm run test:unit 50/50, pnpm run validate passed. + +- Task: phase-7-task-2 +- Verdict: closed: session bindings are mode-less membership records; new writes omit mode and active_sessions while readers tolerate old state; implementation start records implementation_loop.driver_session_id; Stop continuations and UserPromptSubmit interruption are driver-gated; unbinding the driver idles the loop with reason implementation-driver-unbound; existing reminder text is preserved via runtime-mode bridge, with compact/lock marker deferred. Verification: focused unbind/reminder 13/13, hook-contracts 15/15, cli-contracts 11/11, pnpm run test:unit 50/50, pnpm run validate passed. + +## 2026-07-07T08:22:07+00:00 - closed: compact marker and implementation lock marker implemented in source package. UserPromptSubmit now emits exact compact shaping/review markers from epic runtime mode, implementation driver receives no marker, non-driver members receive the read-only implementation lock marker, and missing/malformed/unsupported runtime state stays silent. Tests pin two-member shaping, review marker, mode propagation without rebinding, driver silence, non-driver lock marker, unbound silence, and malformed runtime silence. Verification: node --test tests/unit/unbind-and-reminder.test.mjs tests/unit/hook-contracts.test.mjs passed 30/30; pnpm run test:unit passed 52/52; pnpm run validate passed. Installed runtime copy sync intentionally not run because AGENTS.md requires explicit user request for scripts/self-update-skill.mjs manual promotion. Commit hash to be reported after task commit creation. + +- Task: phase-7-task-3 +- Verdict: closed: compact marker and implementation lock marker implemented in source package. UserPromptSubmit now emits exact compact shaping/review markers from epic runtime mode, implementation driver receives no marker, non-driver members receive the read-only implementation lock marker, and missing/malformed/unsupported runtime state stays silent. Tests pin two-member shaping, review marker, mode propagation without rebinding, driver silence, non-driver lock marker, unbound silence, and malformed runtime silence. Verification: node --test tests/unit/unbind-and-reminder.test.mjs tests/unit/hook-contracts.test.mjs passed 30/30; pnpm run test:unit passed 52/52; pnpm run validate passed. Installed runtime copy sync intentionally not run because AGENTS.md requires explicit user request for scripts/self-update-skill.mjs manual promotion. Commit hash to be reported after task commit creation. + +## 2026-07-07T08:29:56+00:00 - closed: added resume auto-bind via auto-bind-session.mjs for fresh UserPromptSubmit captures; binds mode-less epic membership for shaping/review/implementation observers without replacing implementation_loop.driver_session_id; rejects stale, non-UserPromptSubmit, wrong-root, Codex transcript-fallback, and Claude captures without transcript paths; updated source docs for resume auto-bind and v2 parallel-session semantics; verification passed: focused node --test tests/unit/cli-contracts.test.mjs tests/unit/unbind-and-reminder.test.mjs tests/unit/hook-contracts.test.mjs 48/48, pnpm run test:unit 59/59, pnpm run validate passed; installed runtime copies intentionally not synced per repo rules + +- Task: phase-7-task-4 +- Verdict: closed: added resume auto-bind via auto-bind-session.mjs for fresh UserPromptSubmit captures; binds mode-less epic membership for shaping/review/implementation observers without replacing implementation_loop.driver_session_id; rejects stale, non-UserPromptSubmit, wrong-root, Codex transcript-fallback, and Claude captures without transcript paths; updated source docs for resume auto-bind and v2 parallel-session semantics; verification passed: focused node --test tests/unit/cli-contracts.test.mjs tests/unit/unbind-and-reminder.test.mjs tests/unit/hook-contracts.test.mjs 48/48, pnpm run test:unit 59/59, pnpm run validate passed; installed runtime copies intentionally not synced per repo rules + +## 2026-07-07T08:38:15+00:00 - phase verification evidence: source package tests and validation passed; live Claude Code 2.1.199 temporary-project check proved auto-bind membership, two shaping members, review propagation without rebinding, implementation observer lock marker, driver no-marker UserPromptSubmit, and mode-less membership state without active_sessions; installed runtime copies are intentionally stale vs source and remain the phase acceptance gap until manual promotion + +- Task: phase-7-task-5 +- Verdict: verification pass. `pnpm run test:unit` passed 59/59; `pnpm run validate` passed (`epic-loop package validation passed`). Runtime copy diffs were non-clean for both `.codex/skills/epic-loop` and `.claude/skills/epic-loop`: `artifact-model.md`, `hooks-and-session-routing.md`, `parallel-sessions.md`, `SKILL.md`, `lib/common.mjs`, `lib/epics.mjs`, `lib/hooks.mjs`, `lib/loop.mjs` differ, and `auto-bind-session.mjs`, `set-epic-mode.mjs`, `unbind-session.mjs` exist only in source. This is expected because `scripts/self-update-skill.mjs` was not run per repo rules. +- Live Claude Code evidence used temp project `/tmp/epic-live-claude-4Fv6zd` with source-package project-local hooks. Session `11111111-1111-4111-8111-111111111111` auto-bound via `auto-bind-session.mjs --current --slug live-mode` during a real `claude -p` turn, then a resumed turn echoed `[epic-loop] epic=live-mode mode=shaping — follow epic-loop skill mode rules`. Session `22222222-2222-4222-8222-222222222222` also auto-bound and echoed the same shaping marker. After `set-epic-mode.mjs --mode review`, the already-bound second session echoed `[epic-loop] epic=live-mode mode=review — follow epic-loop skill mode rules`. After binding the first session as implementation driver, state showed `mode=implementation`, `implementation_loop.driver_session_id` set to session 1, both sessions active with no `mode` field, and no `active_sessions` map; the second session echoed `[epic-loop] epic=live-mode mode=implementation — loop running in another session; read-only, do not edit epic artifacts`. +- Driver no-marker evidence: the first driver's next real Claude Code turn returned `NO_EPIC_LOOP_CONTEXT`; its transcript had no `hook_additional_context` attachment between that user prompt and assistant reply, then the driver `Stop` hook correctly continued the implementation loop. The live driver run was interrupted after it entered the expected Stop-driven continuation (`num_turns: 18`, `stop_reason: tool_use`), so the no-marker evidence is from the transcript segment before continuation. + +## 2026-07-07T08:39:09+00:00 - closed-with-gap: Phase 7 verification task completed with strong source-package evidence. pnpm run test:unit passed 59/59, pnpm run validate passed, and live Claude Code 2.1.199 temp-project verification proved auto-bind, two member markers, mode propagation, implementation observer lock marker, driver no-marker turn, mode-less membership, and no active_sessions. Phase 7 remains open because installed runtime copies are intentionally stale vs source until Oleg approves manual promotion or explicit deferral. + +- Task: phase-7-task-5 +- Verdict: closed-with-gap: Phase 7 verification task completed with strong source-package evidence. pnpm run test:unit passed 59/59, pnpm run validate passed, and live Claude Code 2.1.199 temp-project verification proved auto-bind, two member markers, mode propagation, implementation observer lock marker, driver no-marker turn, mode-less membership, and no active_sessions. Phase 7 remains open because installed runtime copies are intentionally stale vs source until Oleg approves manual promotion or explicit deferral. Final commit hash reported by techlead after commit creation. + +## 2026-07-07T09:56:14+00:00 - closed: Oleg explicitly decided installed runtime copies do not need to be synced for this phase. Phase 7 is closed on source-package evidence: pnpm run test:unit 59/59, pnpm run validate passed, and live Claude Code 2.1.199 temporary-project verification proved auto-bind membership, two member markers, review propagation without rebinding, implementation observer lock marker, driver no-marker turn, mode-less membership entries, and no active_sessions. Runtime-copy diff remains intentionally ignored for phase acceptance. + +- Task: phase-7 +- Verdict: closed: Oleg explicitly decided installed runtime copies do not need to be synced for this phase. Phase 7 is closed on source-package evidence: pnpm run test:unit 59/59, pnpm run validate passed, and live Claude Code 2.1.199 temporary-project verification proved auto-bind membership, two member markers, review propagation without rebinding, implementation observer lock marker, driver no-marker turn, mode-less membership entries, and no active_sessions. Runtime-copy diff remains intentionally ignored for phase acceptance. + +## 2026-07-07T09:58:01+00:00 - complete: implementation loop finished after Phase 7 closure and implementation-end housekeeping. Final state: source-package changes verified, Phase 7 closed, no active task, no compaction needed, working tree clean before exit. Installed runtime copies intentionally remain unsynced because Oleg explicitly said they do not need to be synced for this implementation. + +- Task: implementation-exit +- Verdict: complete: implementation loop finished after Phase 7 closure and implementation-end housekeeping. Final state: source-package changes verified, Phase 7 closed, no active task, no compaction needed, working tree clean before exit. Installed runtime copies intentionally remain unsynced because Oleg explicitly said they do not need to be synced for this implementation. diff --git a/.epic-loop/epics/mode-reminder/risk-register.md b/.epic-loop/epics/mode-reminder/risk-register.md index 349312f..0244c02 100644 --- a/.epic-loop/epics/mode-reminder/risk-register.md +++ b/.epic-loop/epics/mode-reminder/risk-register.md @@ -2,8 +2,12 @@ | Risk | Impact | Mitigation | Status | | --- | --- | --- | --- | -| `additionalContext` capability claims are based on documentation/agent research, not a verified real run against either platform. | Design could be built on a mechanism that does not actually behave as documented. | Phase 2 requires real POC evidence on both Claude Code and Codex before implementation proceeds past design. | open | +| `additionalContext` capability claims are based on documentation/agent research, not a verified real run against either platform. | Design could be built on a mechanism that does not actually behave as documented. | Both real POCs passed on 2026-07-06: Codex interactive CLI (trusted project) and Claude Code headless `claude -p` (transcript shows the `hook_additional_context` attachment reaching model context). | mitigated | | Codex hooks are an experimental, disabled-by-default feature; a project may have Codex hooks off entirely. | The mode reminder silently does nothing for Codex users who have not enabled hooks, same as today's implementation-loop hooks. | Reuse the existing `doctor`-driven readiness reporting; do not treat this as a new failure mode beyond what already exists. | open | | A visible reminder on every Codex turn could read as noisy if the reminder text is verbose. | Degraded UX in Codex specifically. | Keep injected text to one short line; revisit if POC testing shows it is disruptive. | open | -| New hook logic for shaping/review modes could regress the existing guarantee that unbound sessions produce zero hook output. | Silent behavior change for all non-epic sessions using this repo's hooks, which would be a serious regression. | Explicit unit test asserting unbound sessions still produce no output after this change; keep the new logic strictly behind the existing `getSessionBinding` gate. | open | -| An intent-based (non-fixed-phrase) unbind trigger could misfire — either unbinding when the user did not want that, or failing to unbind when they did. | User loses epic context unexpectedly, or gets stuck receiving epic-scoped behavior when they wanted a quick unrelated action. | Phase 3 explicitly designs and documents a short canonical phrase as a reliable fallback alongside intent recognition; Phase 4/5 tests cover both paths. | open | +| New hook logic for shaping/review modes could regress the existing guarantee that unbound sessions produce zero hook output. | Silent behavior change for all non-epic sessions using this repo's hooks, which would be a serious regression. | Mitigated 2026-07-06: the reminder builder sits strictly behind the existing `getSessionBinding` gate, and `tests/unit/unbind-and-reminder.test.mjs` pins empty stdout for unbound sessions, Stop events, implementation-mode sessions, and freshly unbound session ids (suite 43/43). | mitigated | +| An intent-based (non-fixed-phrase) unbind trigger could misfire — either unbinding when the user did not want that, or failing to unbind when they did. | User loses epic context unexpectedly, or gets stuck receiving epic-scoped behavior when they wanted a quick unrelated action. | Phase 4 explicitly designs and documents a short canonical phrase as a reliable fallback alongside intent recognition; Phase 4/5 tests cover both paths. | open | +| Shaping/review reminders depend on an active binding, but normal shaping can proceed without creating that binding. | Users do not see the reminder precisely in the mode where it is meant to prevent imperative-action mistakes. | Epic-centric mode model accepted 2026-07-07 (`docs/epic-mode-model.md`): resume auto-binds the session as an epic member and reminders follow the epic's runtime-state mode; implementation tracked as Phase 7 tasks 1–4. | mitigation-planned | +| Rebinding the same session to another epic/mode can leave stale `active_sessions` pointers for the old epic/mode. | Hook routing and diagnostics can report inconsistent active sessions, even if the primary session entry points at the new epic. | Superseded by design 2026-07-07: the epic-centric model deletes the `active_sessions` map entirely (mode-less membership bindings), removing this bug class by construction; lands with Phase 7 task 2. | mitigation-planned | +| The implementation lock marker is advisory only: context injection disciplines an agent session but cannot mechanically block writes to epic artifacts. | A non-driver session that ignores its injected context (or a human editing files directly) can still collide with the loop's artifact writes (manager compaction, log appends, tracker renders). | Accepted 2026-07-07 as sufficient for the agent-session threat model; append-dated-entries discipline from the parallel-work rules remains the fallback. Revisit only if real collisions are observed. | accepted | +| One shared epic mode means a mode switch by any member session immediately changes behavior for all member sessions. | A session switching the epic to implementation silently converts every other member session into a read-only observer mid-work. | The lock marker itself announces the switch on each member's next turn; mode transitions go through explicit `set-epic-mode.mjs`/implementation-start flows, which the agent runs only on user direction. | open | diff --git a/.epic-loop/epics/mode-reminder/state-of-epic.md b/.epic-loop/epics/mode-reminder/state-of-epic.md index ec4fed4..8a86347 100644 --- a/.epic-loop/epics/mode-reminder/state-of-epic.md +++ b/.epic-loop/epics/mode-reminder/state-of-epic.md @@ -3,18 +3,23 @@ Epic: Epic-Loop Mode Reminder And Session Unbind Slug: `mode-reminder` Created: 2026-07-04T19:18:09+00:00 -Current mode: shaping -Active phase: Phase 3 - Validate With Real Proofs Of Concept On Codex And Claude Code -Active task: TBD +Active phase: none - Phase 7 closed +Active task: none ## Current State - Initial shaping is done: problem framing, scope, risks, and the full 6-phase roadmap are captured in `docs/problem-framing.md`, `decision-log.md`, `risk-register.md`, and `tracker.md`. - Phase 1 (Shape The Epic) is closed. - Phase 2 (Design The Solution And Write A Proposal) is closed. The concrete proposal lives in `docs/mode-reminder-design.md`: a new `buildModeReminder(payload, binding)` in `lib/hooks.mjs`, sourced only from `binding.mode` (no extra reads), fires on `UserPromptSubmit` for bound `shaping`/`review` sessions only; `handleHook` composes it with the existing Stop-hook continuation via a single `??` fallback since the two are mutually exclusive by construction. `unbind-session.mjs`'s CLI shape, behavior, and additive `deactivated_reason` field are also fixed. See `decision-log.md` for the accepted decisions. -- The feature still has two parts: (1) the per-turn `UserPromptSubmit` mode-reminder injection via `hookSpecificOutput.additionalContext` for shaping/review-bound sessions, and (2) the new `unbind-session.mjs` script that lets the current session detach from its epic on user intent. -- Codex parity for `additionalContext` is currently based on documentation research (`developers.openai.com/codex/hooks`) only — not yet proven with a real POC. Phase 3 exists specifically to close that gap before implementation. -- The unbind trigger phrase/intent rule is intentionally undecided; Phase 4 is where it gets designed and wired into `SKILL.md`. +- Both feature parts are now implemented and test-covered: (1) the per-turn `UserPromptSubmit` mode-reminder injection via `hookSpecificOutput.additionalContext` for shaping/review-bound sessions (`buildModeReminder` in `lib/hooks.mjs`), and (2) `scripts/unbind-session.mjs` detaching the current session from its epic on user intent (`unbindSession` in `lib/epics.mjs`). +- Phase 3 (Validate With Real Proofs Of Concept On Codex And Claude Code) is closed. Both platforms are natively proven for `UserPromptSubmit` → `hookSpecificOutput.additionalContext`: Codex via an interactive trusted-project CLI run (token `...8273` rendered as visible hook context and echoed by the model), Claude Code via a real headless `claude -p` run (token `POC-CLAUDE-ADDCTX-1783274622` echoed exactly; the transcript JSONL carries the injection as a `hook_additional_context` attachment record). Details and limitations are in `decision-log.md`. +- Phase 4 (Design The Unbind Trigger Phrase And Update The Skill) is closed. The canonical phrase is `unbind epic` (intent-based proactive unbind with the phrase as reliable fallback); `SKILL.md`'s frontmatter description and a `## Hooks` subsection now document the full `unbind-session.mjs` contract, runtime copies are re-synced, and a cross-doc audit confirmed zero discrepancies against `docs/mode-reminder-design.md` and `decision-log.md`. +- Phase 5 (Implement And Write Tests) is closed. Implementation matches the accepted design (commits `64b6c81` code, `02d6fae` tests); `tests/unit/unbind-and-reminder.test.mjs` pins both contracts with 10 end-to-end cases; `references/hooks-and-session-routing.md` documents the new behaviors; runtime copies re-synced via `self-update` after the green suite (both diff-clean). +- Phase 6 (Run The Full Test Suite) is closed. Final verification: `pnpm run test:unit` 43/43/0 with all 10 new tests present, `pnpm run validate` exit 0, both runtime copies diff-clean vs `plugins/`. +- Follow-up shaping reopened on 2026-07-06 to investigate a binding lifecycle gap: shaping/review reminders work after explicit binding, but normal shaping did not bind the session, and switching the same session between shaping epics left a stale `active_sessions["set-up:shaping"]` pointer. +- Follow-up shaping completed on 2026-07-07 in two passes. First pass reviewed the binding-lifecycle gap against the actual code and produced a per-binding-mode fix plan (now the superseded-marked Accepted Plan in `docs/shaping-binding-gap.md`). Second pass, on user direction, replaced it with the **epic-centric mode model** (`docs/epic-mode-model.md`): the epic holds exactly one mode in `runtime-state.json` (sole machine source; the `Current mode:` prose line in this file gets dropped by Phase 7), bindings become mode-less epic membership so any number of sessions per epic receive the compact `[epic-loop] epic= mode=` marker, a mode change by one session propagates to all members' next turn, `active_sessions` is deleted (stale-pointer bug class removed by construction), and implementation keeps one exclusive driver session recorded in the epic runtime state, with non-driver members receiving an advisory read-only lock marker while the loop runs. +- Phase 7 is closed. Source-package work and verification are complete: runtime mode source, mode-less membership plus exclusive implementation driver, compact markers, resume auto-bind, full unit/validation checks, and live Claude Code multi-session evidence are recorded in `implementation-log.md`. Installed runtime copies remain intentionally unsynced and are not required for this phase. +- Implementation mode is complete after final manager housekeeping. The working tree was clean at exit and no artifact compaction was needed. ## Blockers @@ -22,4 +27,4 @@ Active task: TBD ## Next Action -- Start Phase 3, task 1: prove the `additionalContext` mechanism on a **real Codex CLI session** (not this session — this is Claude Code). Open a Codex CLI session in this repo, resume epic `mode-reminder`, and run the Codex POC. Come back to a Claude Code session afterward for task 3 (Claude Code POC). +- Implementation is complete. Next human step is normal review/merge of the completed branch. diff --git a/.epic-loop/epics/mode-reminder/tracker.md b/.epic-loop/epics/mode-reminder/tracker.md index 7d2df04..c28b5e3 100644 --- a/.epic-loop/epics/mode-reminder/tracker.md +++ b/.epic-loop/epics/mode-reminder/tracker.md @@ -46,21 +46,21 @@ Epic: Epic-Loop Mode Reminder And Session Unbind ### Phase 3: Validate With Real Proofs Of Concept On Codex And Claude Code -- Phase status: todo +- Phase status: done -- [ ] Kind: verification | Status: todo | Prove, with a real running Codex CLI session, that a `UserPromptSubmit` hook returning `hookSpecificOutput.additionalContext` actually works, and capture how Codex's TUI actually renders it. +- [x] Kind: verification | Status: done | Prove, with a real running Codex CLI session, that a `UserPromptSubmit` hook returning `hookSpecificOutput.additionalContext` actually works, and capture how Codex's TUI actually renders it. - Outcome: Documented, reproducible evidence that the mechanism works on Codex, plus a concrete note on the visible-rendering behavior observed (matching or correcting the assumption from `openai/codex` issues #16486/#16933). - Surface: a throwaway/POC hook script, run against a real Codex CLI session with the experimental hooks feature flag enabled. - Acceptance: The evidence clearly shows the injected text reaching Codex's context/UI on a real turn; any discrepancy from the assumed behavior is recorded in `decision-log.md`. - Docs: `docs/problem-framing.md`, `decision-log.md`. -- [ ] Kind: documentation-only | Status: todo | Stop this session here. The Codex POC above must be run from a real Codex CLI session, and the Claude Code POC below must be run from a real Claude Code session — each platform validates its own POC natively, not simulated from the other platform. +- [x] Kind: documentation-only | Status: done | Stop this session here. The Codex POC above must be run from a real Codex CLI session, and the Claude Code POC below must be run from a real Claude Code session — each platform validates its own POC natively, not simulated from the other platform. - Outcome: A clean handoff point between the two POC tasks so no POC evidence is produced from the wrong platform. - Surface: this session only; no code changes. - Acceptance: Do not attempt the Claude Code POC task from within a Codex session, or vice versa. Tell the user which session/platform to open next, then stop and wait for them to resume this epic there. - Docs: `docs/problem-framing.md`. -- [ ] Kind: verification | Status: todo | Prove, with a real running Claude Code session, that the same `hookSpecificOutput.additionalContext` mechanism works, and capture how Claude Code actually renders it. +- [x] Kind: verification | Status: done | Prove, with a real running Claude Code session, that the same `hookSpecificOutput.additionalContext` mechanism works, and capture how Claude Code actually renders it. - Outcome: Documented, reproducible evidence (transcript excerpt or equivalent) that the mechanism works as designed on Claude Code. - Surface: the same POC hook script, run against a real Claude Code session in this repo or an isolated scratch project. - Acceptance: The evidence clearly shows the injected text reaching model context on a real turn, not just a unit-test mock. @@ -68,9 +68,9 @@ Epic: Epic-Loop Mode Reminder And Session Unbind ### Phase 4: Design The Unbind Trigger Phrase And Update The Skill -- Phase status: todo +- Phase status: done -- [ ] Kind: documentation-only | Status: todo | Decide the canonical ultra-short unbind trigger phrase and the intent-recognition rule around it, then update `SKILL.md`'s frontmatter `description` and body so the skill engages unambiguously and the agent understands the bind/unbind state. +- [x] Kind: documentation-only | Status: done | Decide the canonical ultra-short unbind trigger phrase and the intent-recognition rule around it, then update `SKILL.md`'s frontmatter `description` and body so the skill engages unambiguously and the agent understands the bind/unbind state. - Outcome: A specific short phrase (and the surrounding intent-based rule) is chosen, documented, and consistent with how the skill's `description` already lists concrete triggers. - Surface: `SKILL.md` frontmatter `description`, relevant mode-reference sections. - Acceptance: The phrase and rule read naturally alongside the existing description triggers; a future session can tell from `SKILL.md` alone when to call `unbind-session.mjs`. @@ -78,15 +78,15 @@ Epic: Epic-Loop Mode Reminder And Session Unbind ### Phase 5: Implement And Write Tests -- Phase status: todo +- Phase status: done -- [ ] Kind: implementation | Status: todo | Implement the `UserPromptSubmit` mode-reminder injection in `lib/hooks.mjs`/`lib/loop.mjs` and the new `unbind-session.mjs` script, per the Phase 2 proposal. +- [x] Kind: implementation | Status: done | Implement the `UserPromptSubmit` mode-reminder injection in `lib/hooks.mjs`/`lib/loop.mjs` and the new `unbind-session.mjs` script, per the Phase 2 proposal. - Outcome: Working code matching the accepted proposal, including the explicit guarantee that unbound sessions still produce zero output. - Surface: `scripts/lib/hooks.mjs`, `scripts/lib/loop.mjs`, `scripts/lib/epics.mjs`, new `scripts/unbind-session.mjs`. - Acceptance: Behavior matches the proposal and the Phase 3 POC evidence; existing implementation-mode hook behavior is unchanged. - Docs: `docs/problem-framing.md`, `decision-log.md`. -- [ ] Kind: verification | Status: todo | Write unit tests for the new hook behavior and `unbind-session.mjs`, following this repo's `node --test` / `runNodeScript` conventions (matching `hook-contracts.test.mjs` / `cli-contracts.test.mjs` style). +- [x] Kind: verification | Status: done | Write unit tests for the new hook behavior and `unbind-session.mjs`, following this repo's `node --test` / `runNodeScript` conventions (matching `hook-contracts.test.mjs` / `cli-contracts.test.mjs` style). - Outcome: Tests cover: mode reminder injected for bound shaping/review sessions, no reminder/no output for unbound sessions, `unbind-session.mjs` deactivating the current session, and hooks becoming no-ops for that session id afterward. - Surface: `tests/unit/`. - Acceptance: New tests fail against the pre-implementation state and pass after Phase 5's implementation task. @@ -94,11 +94,56 @@ Epic: Epic-Loop Mode Reminder And Session Unbind ### Phase 6: Run The Full Test Suite -- Phase status: todo +- Phase status: done -- [ ] Kind: verification | Status: todo | Run `pnpm run test:unit` and `pnpm run validate` and confirm everything is green, including the new tests from Phase 5. +- [x] Kind: verification | Status: done | Run `pnpm run test:unit` and `pnpm run validate` and confirm everything is green, including the new tests from Phase 5. - Outcome: Full green test run with no regressions in existing hook/loop/roadmap tests. - Surface: whole repo test suite. - Acceptance: `pnpm run test:unit` and `pnpm run validate` both exit clean. - Docs: `implementation-log.md`. +### Phase 7: Epic-Centric Mode Model And Compact Reminder + +- Phase status: done + +- [x] Kind: implementation | Status: done | Make `runtime-state.json` `mode` the sole lifecycle mode source and drop the human-readable `Current mode:` line. + - Outcome: The epic's lifecycle mode lives only in `.runtime/runtime-state.json` `mode`, is maintained across all transitions via scripts, and no code parses `state-of-epic.md` prose for it. + - Surface: New `scripts/set-epic-mode.mjs` (+ helper in `scripts/lib/epics.mjs`); `init-epic` state template (remove the `Current mode:` line); `readEpicStateSummary` in `scripts/lib/loop.mjs` and any other prose-mode consumers; `SKILL.md` mode-transition instructions; unit tests. + - Acceptance: `set-epic-mode.mjs --slug --mode shaping|implementation|review` validates the mode and writes `mode` + `updated_at`; `init-epic` no longer emits `Current mode:` and nothing regex-parses it (`readEpicStateSummary` reads runtime-state instead); `SKILL.md` instructs calling the script on every lifecycle transition (reopen shaping, enter review); missing/corrupt runtime-state makes hooks silently skip and scripts fail explicitly, never guess; tests cover transitions and the no-prose-parsing contract. + - Docs: docs/epic-mode-model.md + +- [x] Kind: implementation | Status: done | Rework session bindings to mode-less epic membership with an exclusive implementation driver. + - Outcome: Bindings answer only "which epic does this session belong to"; many sessions can be active members of one epic; `active_sessions` is deleted from the schema; implementation keeps exactly one driver session recorded in the epic's `runtime-state.json`. + - Surface: `bindSession`/`unbindSession` in `scripts/lib/epics.mjs`; `getSessionBinding`, `maybeBuildImplementationContinuation` gate, `markInterruptedTurnIfNeeded` gate in `scripts/lib/hooks.mjs`/`lib/loop.mjs`; `startImplementationLoop` driver designation; `bind-session.mjs`/`unbind-session.mjs` CLI docs; existing binding tests. + - Acceptance: Binding entries carry no `mode`; multiple simultaneous active members of one epic are supported while a session still holds at most one active binding; `active_sessions` is gone and readers tolerate the old leftover shape (stale-pointer bug class removed by construction); entering implementation designates `implementation_loop.driver_session_id` (replacing any previous driver) and only the driver's `Stop` events continue the loop; a `UserPromptSubmit` from a non-driver member does not interrupt the loop; unbinding the driver sets the loop to `idle` with a clear reason; parallel work on different epics is unaffected. + - Docs: docs/epic-mode-model.md + +- [x] Kind: implementation | Status: done | Emit the compact mode marker to all member sessions, plus a read-only lock marker for non-driver members during implementation. + - Outcome: On `UserPromptSubmit` every active member session of a `shaping`/`review` epic receives exactly `[epic-loop] epic= mode= — follow epic-loop skill mode rules`; during `implementation` non-driver members receive the lock marker `[epic-loop] epic= mode=implementation — loop running in another session; read-only, do not edit epic artifacts` while the driver receives nothing; a mode change by one session propagates to every member's next turn without rebinding. + - Surface: `buildModeReminder` + `MODE_REMINDER_TEXT` in `scripts/lib/hooks.mjs` (source mode from epic runtime-state, not the binding); `SKILL.md` frontmatter description plus a concise body note; unit tests. + - Acceptance: Reminder mode comes from `runtime-state.json`, not the binding; unbound sessions stay silent no-ops; two bound sessions on one epic both receive the shaping/review marker, and after one of them runs `set-epic-mode.mjs` the other's next reminder reflects the new mode — pinned by tests; in implementation mode the driver gets no reminder and a non-driver member gets exactly the lock marker text — pinned by tests; frontmatter description mentions the `[epic-loop] epic=... mode=...` marker pattern and stays within the 1024-char Claude Code limit (currently 895 chars); body note explains both marker variants (mode rules vs read-only lock; the lock is advisory, not a mechanical write barrier); installed runtime copies were intentionally not synced because runtime promotion is a manual step in this repo. + - Docs: docs/epic-mode-model.md, docs/mode-reminder-design.md, references/hooks-and-session-routing.md + +- [x] Kind: implementation | Status: done | Auto-bind the current session as an epic member when resuming by slug or path. + - Outcome: Resuming an epic binds the current session as a member (no mode flag); reminders follow the epic's current mode from the next turn; the implementation start/resume confirmation flow is unchanged. + - Surface: `SKILL.md` resume flow; `bind-session.mjs` membership usage; capture freshness validation at the bind call site; tests for shaping/review/implementation resume cases. + - Acceptance: For a `shaping`/`review` epic the session is auto-bound as a member and the marker appears on the next turn; for an `implementation` epic no driver is designated automatically and the existing explicit confirmation flow is unchanged; the `--current` capture is accepted for auto-bind only when fresh AND `hook_event_name === "UserPromptSubmit"` (last-writer-wins race guard; Codex mtime fallback is rejected for auto-bind); when no acceptable capture exists (e.g. stale capture, non-`UserPromptSubmit`, wrong-root capture, or missing Claude transcript path) auto-bind is skipped with a one-line notice and orientation continues; `SKILL.md` Parallel Work section and `references/parallel-sessions.md` are updated to the v2 semantics (one epic mode shared by all member sessions; different-modes-per-epic no longer a supported state). + - Docs: docs/epic-mode-model.md, references/hooks-and-session-routing.md, references/parallel-sessions.md + +- [x] Kind: verification | Status: done | Phase-level verification: full suite plus a live multi-session Claude Code check of the epic-centric model. + - Outcome: Proven-green source-package result: unit suite and package validation pass, live Claude Code evidence proves membership/marker/mode-propagation behavior, and installed runtime-copy sync is explicitly not required for this phase. + - Surface: Whole repo test suite; real Claude Code sessions in this repo (method analogous to the Phase 3 POC). + - Acceptance: `pnpm run test:unit` and `pnpm run validate` exit clean including all new Phase 7 tests; live evidence (transcript attachment or echoed token, as in Phase 3) shows: (a) a session resuming a shaping epic receives the compact marker on the next turn, (b) two member sessions of the same epic both receive it, (c) after `set-epic-mode.mjs` changes the mode, the other session's next reminder reflects the change, and with the mode set to implementation a non-driver member receives the read-only lock marker while the driver receives none; `session-bindings.json` contains mode-less membership entries and no `active_sessions` map; runtime-copy diff is intentionally ignored for this phase because installed copies do not need to be synced. + - Docs: implementation-log.md, decision-log.md + +- [x] Kind: verification | Status: done | Phase 4 verification gate: cross-doc audit of the documented unbind contract (executed 2026-07-06) + - Outcome: Zero discrepancies across SKILL.md, docs/mode-reminder-design.md section 2, and decision-log.md (flags, no-op, silent-hooks, rebind semantics, verbatim 'unbind epic' phrase); frontmatter YAML valid; runtime copies diff-clean; validate passed; 33/33 unit tests green + - Surface: read-only audit plus package checks; no code changes + - Acceptance: Met - discrepancy list empty; recorded as a follow-up entry because hand-added tracker tasks do not survive roadmap re-renders (see implementation-log 2026-07-06) + - Docs: docs/mode-reminder-design.md, decision-log.md + +- [x] Kind: documentation-only | Status: done | Update hooks-and-session-routing.md for the new reminder/unbind hook behavior and sync runtime copies + - Outcome: references/hooks-and-session-routing.md reflects the two new hook behaviors (UserPromptSubmit mode reminder for shaping/review bindings; user-requested unbind lifecycle), and .claude/.codex runtime copies are re-synced from plugins/ now that the Phase 5 tests are green + - Surface: plugins/epic-loop/skills/epic-loop/references/hooks-and-session-routing.md; pnpm run self-update + - Acceptance: Reference doc mentions the reminder output path and unbind deactivation consistently with SKILL.md; diff -rq of both runtime copies vs plugins/ clean except .runtime; full test suite still green + - Docs: docs/mode-reminder-design.md, decision-log.md diff --git a/.epic-loop/epics/set-up/decision-log.md b/.epic-loop/epics/set-up/decision-log.md new file mode 100644 index 0000000..117cfad --- /dev/null +++ b/.epic-loop/epics/set-up/decision-log.md @@ -0,0 +1,12 @@ +# Decision Log + +## Active Decisions + +- 2026-07-07: Split skill repository checks into two implementation phases. Mechanical skill package invariants should be enforced by deterministic scripts, while semantic skill quality review should run through a headless `codex exec --ephemeral` workflow wrapped by a repository script that requires structured JSON output in an ignored directory and validates that schema before reporting findings. +- 2026-07-07: Add targeted oxlint maintainability limits: source files may contain up to 600 lines, test files may contain up to 900 lines, and individual functions may contain up to 150 lines. Configure these limits to skip blank lines and comment-only lines. +- 2026-07-06: Use oxlint for JavaScript/ESM linting, Oxfmt (`oxfmt`) for formatting, and a small repository-owned Node.js script for the English-only lexical policy. The custom script should cover repo-specific language policy with explicit include/ignore patterns and allowlists. +- 2026-07-06: Treat `pnpm run validate` as the durable aggregate validation entry point. New checks should be integrated there unless implementation finds a stronger existing convention. + +## Historical Decisions + +- None recorded yet. diff --git a/.epic-loop/epics/set-up/docs/linting-and-language-policy.md b/.epic-loop/epics/set-up/docs/linting-and-language-policy.md new file mode 100644 index 0000000..5f144dc --- /dev/null +++ b/.epic-loop/epics/set-up/docs/linting-and-language-policy.md @@ -0,0 +1,189 @@ +# Linting And Language Policy + +## Baseline + +- Package manager: `pnpm@11.9.0`. +- Runtime style: Node.js ESM (`"type": "module"`). +- Existing checks: + - `pnpm run test:unit` + - `pnpm run validate` + - `pnpm run validate:epic-loop` + - platform doctor scripts for Codex and Claude Code. +- Current `validate` performs `node --check` over selected scripts and runs `scripts/validate-epic-loop-package.mjs`. +- No oxlint, Oxfmt, EditorConfig, or spelling/language config is currently present. + +## Policy Direction + +Use standard tooling for standard concerns and a small repository-owned script for project-specific language policy: + +- oxlint for JavaScript/ESM source, tests, scripts, and package code. +- Oxfmt (`oxfmt`) for formatting supported text/code/doc files. +- A custom Node.js validation script for English-only lexical usage, because the repository rule is product-specific and should be deterministic. + +## Proposed Oxlint Profile + +Research date: 2026-07-07. + +Recommended first-pass profile: + +- Enable default oxlint plugins plus `import`, `node`, and `promise`: `eslint`, `typescript`, `unicorn`, `oxc`, `import`, `node`, `promise`. +- Treat `correctness` as `error`. +- Treat `suspicious` as `warn` at first, then promote selected low-noise rules after repository cleanup. +- Keep `style`, `restriction`, `pedantic`, and `nursery` disabled globally. +- Keep `perf` disabled globally for the first pass; consider warning-only later for targeted rules. + +Rules to explicitly allow or tune for this repository: + +- Disable `eslint/no-console`, because this repository contains command-line utilities and validation scripts where stdout/stderr are product output. +- Disable `unicorn/no-process-exit`, because CLI entry points intentionally set exit status. +- Disable `node/no-sync`, because small deterministic repository scripts intentionally use synchronous filesystem operations. +- Disable `unicorn/no-array-sort` while the CLI package supports Node.js `>=18`; `Array#toSorted()` is not a safe baseline assumption for every supported runtime. +- Allow `__dirname` for `eslint/no-underscore-dangle`, because the CLI uses the standard ESM `fileURLToPath` dirname shim. +- Enable `eslint/max-lines` explicitly as a targeted maintainability rule, with `max: 600` for source files and `max: 900` for test files. +- Enable `eslint/max-lines-per-function` explicitly as a targeted maintainability rule, with `max: 150` for functions. +- Configure both line-count rules with `skipBlankLines: true` and `skipComments: true` so the limits focus on maintained code shape rather than spacing or explanatory comments. +- Do not enable `restriction` globally: it flags optional chaining, object spread, async/await, console output, and other patterns that are acceptable for this repository. +- Do not enable `style` globally: it creates broad formatting/preference churn such as `sort-keys`, `no-magic-numbers`, `func-style`, `no-ternary`, and null bans. + +Observed current repository findings with oxlint 1.73.0: + +- Default correctness pass reports three small issues: one unused import, one unnecessary regex escape, and one unnecessary object spread fallback. +- `suspicious` adds mostly useful warning candidates: missing `cause` on rethrown errors, mutable `Array#sort()` suggestions, and `__dirname` naming noise. +- `perf` adds a small number of warning candidates, including `no-await-in-loop`, `prefer-set-has`, and `oxc/no-map-spread`; these are better handled after the baseline passes. +- `style` and `restriction` generate large noisy reports and should not block the initial linting setup. + +## English-Only Check + +The check should inspect committed project text that humans maintain, while ignoring runtime/debug/generated surfaces. + +Candidate included surfaces: + +- `README.md`, `CHANGELOG.md`, `CLAUDE.md`, `AGENTS.md` +- `package.json` files +- `scripts/**/*.mjs` +- `tests/**/*.mjs` +- `packages/cli/src/**/*.mjs` +- `plugins/epic-loop/skills/epic-loop/**/*.md` +- `plugins/epic-loop/skills/epic-loop/**/*.mjs` +- `plugins/epic-loop/skills/epic-loop/**/*.yaml` + +Candidate ignored surfaces: + +- `node_modules/` +- lockfiles +- `.git/` +- `.epic-loop/epics/*/.runtime/` +- `.epic-loop/.runtime/` +- generated package output +- hook captures, prompt logs, progress logs, and transcript/debug traces + +The script should report filename, line, column, and offending token or short excerpt. It should support a small allowlist for legitimate non-dictionary or non-English tokens, but the default direction is to remove Russian or other non-English prose from committed source and docs. + +## Validation Contract + +`pnpm run validate` should become the single command that proves the repository is publishable: + +- syntax checks still run +- package validation still runs +- unit tests either run directly or remain available through a documented validation script +- oxlint check runs +- Oxfmt check runs +- English-only lexical check runs + +Formatting write commands should be separate from check commands so validation remains non-mutating. + +## Custom Skill Repository Validator Candidates + +Research date: 2026-07-07. + +These are not built-in oxlint or Oxfmt rules. They are repository-specific skill quality checks derived from OpenAI Codex, Anthropic Claude Code, and Agent Skills documentation. + +Implementation should split them into two layers: + +- Deterministic validation for mechanical invariants that can be proven offline and should block `pnpm run validate`. +- AI-assisted review for semantic quality signals that require judgment and should run through a separate script command. The script should make the workflow look like a normal validation command externally, while internally using `codex exec` in non-interactive mode. + +Primary sources: + +- OpenAI Codex Agent Skills documentation: `https://developers.openai.com/codex/skills` +- OpenAI Codex plugin authoring documentation: `https://developers.openai.com/codex/plugins/build` +- Agent Skills open specification: `https://agentskills.io/specification` +- Anthropic Agent Skills overview: `https://platform.claude.com/docs/en/agents-and-tools/agent-skills/overview` +- Anthropic skill authoring best practices: `https://platform.claude.com/docs/en/agents-and-tools/agent-skills/best-practices` +- Claude Code skills documentation: `https://code.claude.com/docs/en/skills` + +Portable skill shape rules worth considering for repository validation: + +- Every skill directory should contain a `SKILL.md` entrypoint. +- `SKILL.md` frontmatter should parse as YAML and include `name` and `description`. +- `name` should be 1-64 characters, lowercase kebab-case, contain only letters, numbers, and hyphens, not start or end with a hyphen, not contain consecutive hyphens, and match the parent skill directory for portable skill packages. +- `description` should be 1-1024 characters, avoid XML tags, describe both what the skill does and when to use it, and include concrete trigger terms. +- Skill instructions should stay concise. Anthropic recommends keeping the `SKILL.md` body under 500 lines for performance; the Agent Skills specification recommends splitting longer content into referenced files and notes a recommended instruction budget under 5000 tokens. +- Large or conditional detail should move to directly linked `references/` files rather than deeply nested reference chains. +- Reference files over 100 lines should include a table of contents near the top. +- Skill paths in instructions should use forward slashes for cross-platform compatibility. +- Scripts bundled with skills should live under `scripts/`, document dependencies, handle expected errors, and print actionable diagnostics. +- Skills that depend on external tools or MCP servers should declare those dependencies in product-specific metadata where supported, such as Codex `agents/openai.yaml`. +- Skills should avoid time-sensitive instructions in the active happy path; deprecated or historical behavior should live in an explicit old-patterns section. +- Skills should use consistent terminology across `SKILL.md` and referenced files. +- Security review should treat skills like software packages: audit bundled scripts, resources, external fetches, unexpected network calls, and file access patterns. + +Candidate deterministic checks for this repository: + +- Extend the existing package validator or add a focused validator that checks `SKILL.md` frontmatter, name/description shape, directory-name match, and description length. +- Add a documentation-shape check for `SKILL.md` line count, direct reference links, table-of-contents presence in long reference files, and forward-slash paths. +- Add a script-shape check for bundled skill scripts: expected extension, executable entrypoints where needed, syntax checks, and no accidental runtime/debug artifacts. + +Candidate AI-assisted checks for this repository: + +- Review whether each skill description gives reliable implicit invocation signals and clear non-trigger boundaries. +- Review whether `SKILL.md` uses progressive disclosure well instead of overloading the entrypoint. +- Review whether references are organized around task-local context rather than broad manuals. +- Review whether instructions give the right degree of freedom: exact scripts for fragile operations, heuristics for judgment-heavy work. +- Review whether bundled scripts and external dependencies create security or data-exposure concerns beyond what deterministic checks can prove. +- Add eval or fixture coverage for skill discovery descriptions, especially explicit invocation terms and implicit trigger phrases. + +### AI-Assisted Skill Review Runner + +The AI-assisted layer should be script-driven from the user's perspective: + +```bash +pnpm run review:skills:ai +``` + +Proposed implementation shape: + +1. A repository-owned runner script invokes `codex exec --ephemeral` with a focused prompt or repo-local review skill. +2. The Codex run reads the skill package, `SKILL.md`, references, scripts, and policy docs. +3. The Codex run writes a structured JSON report into an ignored output directory, for example `.validation-output/skill-review/latest.json`. +4. The runner reads that JSON, validates its schema deterministically, prints findings in a stable format, and exits non-zero when blocking findings exist. + +The report schema should be intentionally small and stable: + +```json +{ + "schemaVersion": 1, + "status": "pass", + "summary": "Short review summary.", + "findings": [ + { + "severity": "error", + "code": "skill.description.too-broad", + "path": "plugins/epic-loop/skills/epic-loop/SKILL.md", + "line": 2, + "message": "Description can trigger outside epic-loop work.", + "recommendation": "Narrow the trigger wording to explicit epic-loop workspace operations." + } + ] +} +``` + +Allowed `status` values: `pass`, `fail`, `needs-review`. + +Allowed `severity` values: `error`, `warning`, `info`. + +The runner should treat malformed JSON, missing required fields, unknown schema versions, missing output files, or a failed `codex exec` invocation as a validation failure. This keeps the outer command deterministic even though one step is model-backed. + +The generated output directory must be ignored by git. The runner may keep both `latest.json` and raw stdout/stderr logs for debugging, but committed source must not include generated AI review artifacts. + +By default, this AI-backed check should not be part of `pnpm run validate` unless the maintainer explicitly accepts model-backed validation in CI. It should be a separate pre-release or maintainer review command. diff --git a/.epic-loop/epics/set-up/docs/problem-framing.md b/.epic-loop/epics/set-up/docs/problem-framing.md new file mode 100644 index 0000000..cb4218d --- /dev/null +++ b/.epic-loop/epics/set-up/docs/problem-framing.md @@ -0,0 +1,41 @@ +# Epic Problem Framing + +## Problem + +Set up repository linting: add a linter and Oxfmt, plus additional repository checks such as enforcing English-only lexical usage in committed project content. + +## Desired Outcome + +- The repository has a single deterministic validation entry point that covers syntax checks, unit tests, linting, formatting, package validation, and repository-specific content checks. +- JavaScript/ESM source, tests, scripts, plugin skill files, and package metadata are checked by oxlint where supported. +- Formatting is handled by Oxfmt and can be checked in CI/local validation without changing files unexpectedly. +- A dedicated lexical policy check fails when committed project content contains non-English prose or identifiers outside documented allowlists. + +## Scope + +- Root package scripts and pnpm workflow. +- oxlint configuration for the current ESM codebase. +- Oxfmt configuration and check/write scripts. +- Repository-specific language policy tooling for English-only lexical usage. +- Validation integration through `pnpm run validate`. +- Focused tests for custom validation logic. + +## Non-Scope + +- Rewriting large documentation sections for tone or style beyond what is needed to satisfy the checks. +- Adding sample application code. +- Enforcing English inside runtime/debug artifacts, generated lockfiles, vendored dependencies, or files that are intentionally machine generated. +- Adding CI provider configuration unless implementation discovers an existing CI surface that already calls `pnpm run validate`. + +## Constraints + +- This repository publishes and validates the `epic-loop` plugin/skill package; changes should stay inside that product surface. +- Prefer small deterministic Node.js scripts for repository-specific checks. +- Do not commit local `.epic-loop` runtime/debug artifacts, hook captures, prompt logs, or target-project epic workspaces. +- The language check must have an explicit allowlist path for legitimate names, package identifiers, URLs, paths, code tokens, and technical terms. + +## Open Questions + +- Which file classes should be checked for English-only lexical usage on the first implementation pass: documentation/prose only, code identifiers/comments too, or every committed text file except explicit ignores? +- Should formatting changes be applied immediately across the repository, or should the first implementation only add check scripts and report existing violations? +- Should the public plugin package require the same checks under `packages/cli`, or should root validation delegate into that package only where needed? diff --git a/.epic-loop/epics/set-up/implementation-log.md b/.epic-loop/epics/set-up/implementation-log.md new file mode 100644 index 0000000..2e4a1ce --- /dev/null +++ b/.epic-loop/epics/set-up/implementation-log.md @@ -0,0 +1,6 @@ +# Implementation Log + +## 2026-07-06T02:54:26+00:00 - Epic Workspace Initialized + +- Created epic workspace for `set-up`. +- Initial mode: shaping. diff --git a/.epic-loop/epics/set-up/risk-register.md b/.epic-loop/epics/set-up/risk-register.md new file mode 100644 index 0000000..0e2db74 --- /dev/null +++ b/.epic-loop/epics/set-up/risk-register.md @@ -0,0 +1,8 @@ +# Risk Register + +| Risk | Impact | Mitigation | Status | +| --- | --- | --- | --- | +| English-only enforcement is too broad and flags code tokens, names, URLs, generated files, or runtime logs. | Validation becomes noisy and developers bypass it. | Start with explicit include/ignore lists, structured allowlists, and line-level evidence in failures. | open | +| Adding Oxfmt formats too much in one task and obscures behavioral changes. | Review becomes harder and task commits lose focus. | Keep configuration/check integration separate from optional repository-wide formatting cleanup. | open | +| oxlint configuration or rule coverage does not match every existing ESM script/test pattern. | Validation blocks on tooling friction or misses a rule the project expects. | Keep the initial oxlint config focused, verify against all maintained source sets, and add repository-owned checks only for gaps that matter. | open | +| AI-assisted skill review depends on model behavior, Codex auth/config, and structured output discipline. | CI or local review can become flaky, expensive, or hard to interpret. | Keep the AI review behind a script boundary, require schema-valid JSON output, treat malformed/missing output as failure, write artifacts to an ignored directory, and exclude it from `pnpm run validate` unless explicitly opted in. | open | diff --git a/.epic-loop/epics/set-up/state-of-epic.md b/.epic-loop/epics/set-up/state-of-epic.md new file mode 100644 index 0000000..e96c6a8 --- /dev/null +++ b/.epic-loop/epics/set-up/state-of-epic.md @@ -0,0 +1,23 @@ +# State Of Epic + +Epic: Linting And English Checks +Slug: `set-up` +Created: 2026-07-06T02:54:26+00:00 +Current mode: shaping +Active phase: Phase 1 - Tooling Baseline +Active task: TBD + +## Current State + +- Initial shaping captured the repository baseline: Node.js ESM, pnpm, existing syntax/package validation, and no current oxlint/Oxfmt configuration. +- The roadmap is ready for implementation once the user confirms the implementation loop. +- The central product requirement is adding linting, Oxfmt formatting, deterministic English-only lexical checks, deterministic skill package checks, and AI-assisted semantic skill review. +- Skill repository checks are split into deterministic script validation for mechanical invariants and a headless `codex exec` review runner that produces schema-validated JSON findings in an ignored output directory. + +## Blockers + +- None recorded. + +## Next Action + +- Confirm whether to start implementation in this session. diff --git a/.epic-loop/epics/set-up/tracker.md b/.epic-loop/epics/set-up/tracker.md new file mode 100644 index 0000000..b16a541 --- /dev/null +++ b/.epic-loop/epics/set-up/tracker.md @@ -0,0 +1,113 @@ +# Tracker + +Epic: Linting And English Checks + +## Task Statuses + +- todo +- doing +- need-review +- blocked +- partially-satisfied +- deferred +- reset-required +- done + +## Task Kinds + +- implementation +- verification +- review +- follow-up +- architecture-reset +- documentation-only + +## Active Roadmap + +### Phase 1: Tooling Baseline + +- Phase status: todo + +- [ ] Kind: implementation | Status: todo | Add oxlint configuration for the current Node.js ESM repository. + - Outcome: JavaScript source, scripts, tests, and plugin package code are linted consistently with the repository's ESM/Node style. + - Surface: `package.json`, oxlint config, scripts/tests/plugin source under root and `packages/cli`. + - Acceptance: A lint script exists, runs without false positives on the intended source set, enforces targeted `max-lines` and `max-lines-per-function` limits, and is included in aggregate validation. + - Docs: `docs/linting-and-language-policy.md`. + +- [ ] Kind: implementation | Status: todo | Add Oxfmt configuration and non-mutating format validation. + - Outcome: Repository formatting is standardized and can be checked without modifying files during validation. + - Surface: `package.json`, Oxfmt config/ignore files, supported source and documentation files. + - Acceptance: Format check and format write scripts exist; aggregate validation uses the check script only; ignored runtime/generated paths are explicit. + - Docs: `docs/linting-and-language-policy.md`. + +- [ ] Kind: verification | Status: todo | Verify lint and format tooling through the repository validation path. + - Outcome: The first phase tooling is proven through the same command future contributors will run. + - Surface: Local pnpm scripts, oxlint, Oxfmt, existing syntax/package validation. + - Acceptance: Run `pnpm run validate` and any focused lint/format scripts; evidence includes exit codes and any required follow-up fixes, with no generated runtime artifacts committed. + - Docs: `docs/linting-and-language-policy.md`. + +### Phase 2: Deterministic Skill Package Checks + +- Phase status: todo + +- [ ] Kind: implementation | Status: todo | Add deterministic skill package validation for mechanical Agent Skills invariants. + - Outcome: Maintained skill packages fail validation when their file shape, frontmatter, naming, references, or generated-artifact boundaries violate portable skill package rules. + - Surface: `scripts/validate-skills.mjs` or `scripts/validate-epic-loop-package.mjs`, `package.json` scripts, skill package files under `plugins/epic-loop/skills`. + - Acceptance: The validator checks `SKILL.md` presence, YAML frontmatter, required `name` and `description`, kebab-case name constraints, directory-name match, description length, `SKILL.md` line budget, direct reference links, long-reference table of contents, forward-slash paths, script syntax, and ignored runtime/debug artifact absence. + - Docs: `docs/linting-and-language-policy.md`. + +- [ ] Kind: implementation | Status: todo | Add focused tests or fixtures for deterministic skill package validation. + - Outcome: The deterministic skill validator has regression coverage for accepted package shape and representative failure cases. + - Surface: `tests/unit`, validator fixtures/helpers, package validation scripts. + - Acceptance: Tests cover valid skill metadata, invalid names, missing descriptions, long entrypoint files, missing table of contents for long references, Windows-style paths, and generated artifact detection. + - Docs: `docs/linting-and-language-policy.md`. + +- [ ] Kind: verification | Status: todo | Verify deterministic skill package checks through aggregate validation. + - Outcome: Skill package mechanical validation is proven as part of the standard repository validation path. + - Surface: `pnpm run validate`, focused validator command, unit tests, current plugin skill package. + - Acceptance: Run focused validator tests, run the deterministic skill validator, and run `pnpm run validate` successfully; evidence includes exit codes and any required package cleanup. + - Docs: `docs/linting-and-language-policy.md`. + +### Phase 3: AI-Assisted Skill Quality Review + +- Phase status: todo + +- [ ] Kind: implementation | Status: todo | Add a headless Codex skill review runner with structured JSON output. + - Outcome: Semantic skill quality review can be launched as a normal script command while internally using `codex exec` in non-interactive mode. + - Surface: `package.json` scripts, AI review runner script, repo-local review skill or prompt, ignored `.validation-output/skill-review/` output path, JSON schema validation. + - Acceptance: `pnpm run review:skills:ai` invokes `codex exec --ephemeral`, requires a structured JSON report, validates the report schema, prints stable findings, and exits non-zero for blocking findings, malformed JSON, missing output, unknown schema versions, or failed Codex execution. + - Docs: `docs/linting-and-language-policy.md`. + +- [ ] Kind: implementation | Status: todo | Define the AI skill quality review rubric and finding schema. + - Outcome: The model-backed review evaluates skill semantics consistently instead of producing free-form prose. + - Surface: Review skill or prompt file, JSON schema fixture, review runner tests, skill policy docs. + - Acceptance: The rubric covers invocation quality, trigger boundaries, progressive disclosure, task-local reference organization, degree of freedom, script/dependency safety concerns, and actionable recommendations with path and line evidence where possible. + - Docs: `docs/linting-and-language-policy.md`. + +- [ ] Kind: verification | Status: todo | Verify the AI-assisted review command behaves like a deterministic script boundary. + - Outcome: The AI-backed command is usable in maintainer workflows without ambiguous output handling. + - Surface: `pnpm run review:skills:ai`, controlled valid and invalid JSON outputs, current skill package, ignored output directory. + - Acceptance: Prove the runner accepts valid reports, rejects malformed or missing reports, prints findings deterministically, keeps generated artifacts ignored, and documents whether the AI-backed command is excluded from or included in `pnpm run validate`. + - Docs: `docs/linting-and-language-policy.md`. + +### Phase 4: Repository Language Policy + +- Phase status: todo + +- [ ] Kind: implementation | Status: todo | Implement deterministic English-only lexical validation for committed project content. + - Outcome: Validation fails with actionable diagnostics when maintained project files contain non-English prose or disallowed lexical tokens. + - Surface: Small Node.js validation script, package scripts, include/ignore policy, allowlist data if needed. + - Acceptance: The script reports path, line, column, and offending token/excerpt; runtime/debug/generated paths are ignored; legitimate technical tokens have an explicit allowlist path. + - Docs: `docs/linting-and-language-policy.md`. + +- [ ] Kind: implementation | Status: todo | Add focused tests or fixtures for the English-only lexical validator. + - Outcome: The custom repository policy has regression coverage for accepted English text, rejected non-English text, ignores, and allowlisted terms. + - Surface: `tests/unit`, validator script fixtures/helpers, package validation scripts. + - Acceptance: Tests cover pass and fail cases with deterministic assertions and run under the existing Node test runner. + - Docs: `docs/linting-and-language-policy.md`. + +- [ ] Kind: verification | Status: todo | Verify aggregate validation catches language policy violations and accepts the cleaned repository. + - Outcome: The language policy is proven both by focused tests and by aggregate validation. + - Surface: Validator script, test runner, `pnpm run validate`, temporary fixture or controlled failing input. + - Acceptance: Run focused validator tests, prove a controlled non-English sample fails with the expected diagnostic, remove any temporary sample, and run `pnpm run validate` successfully. + - Docs: `docs/linting-and-language-policy.md`. diff --git a/AGENTS.md b/AGENTS.md index 91e6eeb..69786d5 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -44,6 +44,46 @@ Global session and hook runtime belongs under: Runtime/debug files should not be committed to this plugin repository. +## Local Epic Runtime Operation + +This checkout can also run its own local epics. Treat the local epic runtime as a consumer of an installed skill, not as the source package under development. + +Installed runtime skill copies are separate from source: + +```text +.codex/skills/epic-loop/ # Codex runtime skill copy +.claude/skills/epic-loop/ # Claude Code runtime skill copy, when present +``` + +When an epic is invoked through an installed skill, runtime commands must use the same installed skill directory that contains the invoked `SKILL.md`. Do not substitute the source package path for runtime operations. + +For Codex local epic runtime in this repository: + +```bash +node .codex/skills/epic-loop/scripts/doctor.mjs --platform codex --json +node .codex/skills/epic-loop/scripts/install-hooks.mjs +node .codex/skills/epic-loop/scripts/bind-session.mjs --current --slug "" --mode implementation +``` + +For Claude Code local epic runtime in this repository: + +```bash +node .claude/skills/epic-loop/scripts/doctor.mjs --platform claude-code --json +node .claude/skills/epic-loop/scripts/install-hooks.mjs +node .claude/skills/epic-loop/scripts/bind-session.mjs --current --slug "" --mode implementation +``` + +If the matching installed skill copy is missing, do not silently fall back to `plugins/epic-loop/skills/epic-loop`. Report the missing runtime skill copy and ask before changing runtime setup. + +Runtime commands include: + +- hook readiness checks intended to assess the active local epic runtime +- hook install or repair commands +- session bind/unbind commands +- implementation loop role and tracker state commands for active local epics + +Do not run `install-hooks.mjs` from `plugins/epic-loop/skills/epic-loop` to repair active local runtime hooks unless the user explicitly wants to test the mutable source package as the live runtime. + ## Hook And Session Rules The skill installs project-local hooks in the target project — `.codex/hooks.json` for Codex, `.claude/settings.json` for Claude Code: @@ -59,6 +99,8 @@ The hook target is: node /scripts/hook.mjs ``` +For local runtime epics in this repository, `` is the installed runtime skill directory, such as `.codex/skills/epic-loop` or `.claude/skills/epic-loop`, not `plugins/epic-loop/skills/epic-loop`. + Required hook events: - `SessionStart` @@ -68,7 +110,7 @@ Required hook events: The runtime platform is selected explicitly (never inferred from payload shape, cwd, or environment) and stored in `.epic-loop/.runtime/platform.json`. Always run readiness checks through the matching platform: ```bash -node plugins/epic-loop/skills/epic-loop/scripts/doctor.mjs --platform codex|claude-code --json +node /scripts/doctor.mjs --platform codex|claude-code --json ``` Only bound sessions may write epic-loop runtime state. Unbound sessions must produce no epic-loop records. @@ -80,7 +122,7 @@ Implementation starts only after explicit user confirmation in the current sessi Binding command: ```bash -node plugins/epic-loop/skills/epic-loop/scripts/bind-session.mjs --current --slug "" --mode implementation +node /scripts/bind-session.mjs --current --slug "" --mode implementation ``` After binding, stop the current user turn. The `Stop` hook should continue with the first manager turn. @@ -91,22 +133,52 @@ Engineer owns one concrete brief, relevant code changes, verification, and a fin ## Required Script-Driven Operations -Use scripts for mechanical state changes: +Use installed runtime skill scripts for mechanical state changes in active local epics: ```bash -node plugins/epic-loop/skills/epic-loop/scripts/start-task.mjs --slug "" --task-id "" -node plugins/epic-loop/skills/epic-loop/scripts/close-task.mjs --slug "" --task-id "" -node plugins/epic-loop/skills/epic-loop/scripts/set-task-status.mjs --slug "" --task-id "" --status "" -node plugins/epic-loop/skills/epic-loop/scripts/start-phase.mjs --slug "" --phase-id "" -node plugins/epic-loop/skills/epic-loop/scripts/close-phase.mjs --slug "" --phase-id "" -node plugins/epic-loop/skills/epic-loop/scripts/append-implementation-log.mjs --slug "" --task "" --verdict "" -node plugins/epic-loop/skills/epic-loop/scripts/write-engineer-brief.mjs --slug "" --stdin -node plugins/epic-loop/skills/epic-loop/scripts/role-summary.mjs --slug "" +node /scripts/start-task.mjs --slug "" --task-id "" +node /scripts/close-task.mjs --slug "" --task-id "" +node /scripts/set-task-status.mjs --slug "" --task-id "" --status "" +node /scripts/start-phase.mjs --slug "" --phase-id "" +node /scripts/close-phase.mjs --slug "" --phase-id "" +node /scripts/append-implementation-log.mjs --slug "" --task "" --verdict "" +node /scripts/write-engineer-brief.mjs --slug "" --stdin +node /scripts/role-summary.mjs --slug "" ``` Avoid hand-editing generated tracker state, runtime state, or large implementation logs for mechanical transitions. -## Verification +## Source Package Development + +Source files under `plugins/epic-loop/skills/epic-loop/` are the package being developed. Editing them must not automatically change the installed runtime skill copies or active local hooks. + +Use source-package scripts only for package development, validation, and tests: + +```bash +node plugins/epic-loop/skills/epic-loop/scripts/doctor.mjs --platform codex --json +node plugins/epic-loop/skills/epic-loop/scripts/doctor.mjs --platform claude-code --json +pnpm run validate +``` + +When run from the source package, `doctor.mjs` and `install-hooks.mjs` compute hook targets relative to `plugins/epic-loop/skills/epic-loop`. A source-package doctor may report installed runtime hooks as stale. During normal local epic runtime operation, do not treat that as permission to repair hooks from the source package. + +Only use source-package `install-hooks.mjs` when deliberately testing the source package as the live runtime, and only after explicit user confirmation. + +## Manual Runtime Skill Update + +Updating installed runtime skill copies from source is an explicit manual promotion step, not an automatic agent repair. + +After source changes are complete and validated, the user may choose to run: + +```bash +node scripts/self-update-skill.mjs +``` + +This script syncs `plugins/epic-loop/skills/epic-loop` into the installed Codex and Claude Code skill locations. Agents must not run this script proactively during implementation unless the user explicitly asks to update the installed runtime skills. + +After a manual runtime skill update, run runtime hook readiness from the installed skill copy and reinstall hooks from that same installed copy if needed. + +## Source Verification For plugin and skill script changes: @@ -114,7 +186,7 @@ For plugin and skill script changes: pnpm run validate ``` -For hook readiness in this checkout: +For source package readiness in this checkout: ```bash node plugins/epic-loop/skills/epic-loop/scripts/doctor.mjs --platform codex|claude-code --json diff --git a/plugins/epic-loop/skills/epic-loop/SKILL.md b/plugins/epic-loop/skills/epic-loop/SKILL.md index 765c1f5..ebb8385 100644 --- a/plugins/epic-loop/skills/epic-loop/SKILL.md +++ b/plugins/epic-loop/skills/epic-loop/SKILL.md @@ -1,6 +1,6 @@ --- name: epic-loop -description: Use this skill for any work inside an epic-loop workspace: reading, creating, or editing files under `.epic-loop/` (`state-of-epic.md`, `tracker.md`, `decision-log.md`, `risk-register.md`, `docs/`); adding, editing, or closing an epic's tasks, research tasks, or phases; switching between the shaping (planning, including architecture reset), implementation (manager/techlead/engineer loop), or review lifecycle modes; or resuming an existing epic by slug. Also trigger on the `epic-loop` CLI/package by name. If the current session is already bound to an epic, keep operating through this skill on every following turn — in shaping/review mode, plain imperative requests usually describe epic tasks to capture in `tracker.md`, not actions to run immediately. +description: Use this skill for work inside an epic-loop workspace: reading or editing `.epic-loop/` artifacts; adding, editing, or closing epic tasks, research tasks, or phases; switching shaping, implementation, or review modes; resuming an epic by slug; detaching the current session when the user says `unbind epic` or asks to work outside the epic; or when hook context includes `[epic-loop] epic=... mode=...`. Also trigger on the `epic-loop` CLI/package by name. --- # Epic Loop @@ -123,13 +123,33 @@ Only after local epic context is clear, decide the mode before doing epic work: - **Review**: a completed slice must be checked against the original conversation intent, not only current docs. - **Resume**: the user gives an existing epic slug or asks to continue previous epic work. +For explicit lifecycle transitions outside the implementation-start binding flow, update the epic runtime mode with: + +```bash +node /scripts/set-epic-mode.mjs --slug "" --mode shaping|implementation|review +``` + +Use it when reopening shaping or entering review. Do not hand-edit `state-of-epic.md` to change lifecycle mode. + If no epic workspace exists, initialize one with: ```bash node /scripts/init-epic.mjs --description "Epic description" ``` -If the user provides a slug, resume from `.epic-loop/epics/{epic-slug}` in the current project unless they specify another root. +If the user provides a slug, resume from `.epic-loop/epics/{epic-slug}` in the current project unless they specify another root. During resume/orientation, auto-bind the current session as an epic member when a fresh `UserPromptSubmit` hook capture is available: + +```bash +node /scripts/auto-bind-session.mjs --current --slug "" +``` + +For a path-based resume, pass the epic folder path instead: + +```bash +node /scripts/auto-bind-session.mjs --current --path ".epic-loop/epics/" +``` + +If auto-bind prints that it skipped because no fresh `UserPromptSubmit` capture was available, continue orientation and mention in one line that this session was not auto-bound, so the compact marker may not appear on the next turn. Auto-bind only creates mode-less membership; it must not designate an implementation driver or start the implementation loop. When the user invokes the skill with only an epic slug, treat it as resume/orientation, not permission to execute implementation. Read the re-entry artifacts, report the current state, and stop with a short readiness prompt. If the epic is ready for implementation, use this shape: @@ -343,6 +363,8 @@ For Codex, the installer writes project-local `.codex/hooks.json`. For Claude Co 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` and `.claude/settings.json` as static config; all mutable epic-loop state belongs in `.epic-loop/`. +On `UserPromptSubmit`, a bound member session may receive a compact marker like `[epic-loop] epic= mode= — follow epic-loop skill mode rules`; apply the mode rules from this skill for that epic. If the marker says `mode=implementation — loop running in another session; read-only, do not edit epic artifacts`, treat this session as a non-driver observer: do not edit epic artifacts or implementation state from that session. + Codex requires non-managed command hooks to be reviewed and trusted before they run. Claude Code also requires hook review/trust through `/hooks`. A static `doctor` result can prove that project-local hook config exists and platform prerequisites are satisfied, but it cannot prove that the current already-running thread has loaded and trusted the hook. If implementation does not continue after binding, inspect `/hooks` in the active platform UI/CLI and start or resume a trusted session. Bind the current session to an epic explicitly when running parallel sessions: @@ -351,8 +373,18 @@ Bind the current session to an epic explicitly when running parallel sessions: node /scripts/bind-session.mjs --current --slug "" --mode implementation ``` -For Codex, `--current` uses the existing Codex hook capture/session fallback. For Claude Code, `--current` requires a fresh hook capture with `session_id` and `transcript_path`; if that cannot be detected safely, pass `--session-id ""` explicitly. +For resume/orientation membership, use `auto-bind-session.mjs --current --slug ""` or `--path ""`; it accepts only a fresh `UserPromptSubmit` hook capture and skips harmlessly otherwise. For implementation driver binding, `bind-session.mjs --current` uses the existing Codex hook capture/session fallback; Claude Code requires a fresh hook capture with `session_id` and `transcript_path`. If implementation driver binding cannot detect the current session safely, pass `--session-id ""` explicitly. + +Many sessions may be active members of the same epic and share the epic runtime mode. Implementation still has one exclusive driver recorded in the epic runtime state. + +Unbind the current session when the user wants it to stop working through the epic. The canonical trigger phrase is `unbind epic`, but do not require it verbatim: when the user expresses intent to work outside the epic in this session (for example "do this right now, without the epic" or "let's work outside the epic for a bit"), call the unbind script proactively and confirm in one line that the session was unbound: + +```bash +node /scripts/unbind-session.mjs --current +# or, when the current session cannot be detected safely: +node /scripts/unbind-session.mjs --session-id "" +``` -There is one active hook-routed session per epic/mode. Binding the current session for the same epic and mode deactivates the previous active session. +An optional `--reason ""` records why the session detached. The script has no `--slug`/`--mode` flags: it deactivates whatever epic/mode the resolved session is actively bound to, and it is a harmless no-op when the session is not bound. Epic-loop hooks become silent no-ops for that session id afterwards. To work on the epic again later, use the normal resume flow and `bind-session.mjs`; there is no separate reattach mechanism. Do not block epic work solely because hook automation is absent. diff --git a/plugins/epic-loop/skills/epic-loop/references/hooks-and-session-routing.md b/plugins/epic-loop/skills/epic-loop/references/hooks-and-session-routing.md index 0fcaeea..0c73ae2 100644 --- a/plugins/epic-loop/skills/epic-loop/references/hooks-and-session-routing.md +++ b/plugins/epic-loop/skills/epic-loop/references/hooks-and-session-routing.md @@ -133,18 +133,14 @@ Do not store mutable epic-loop state under `.codex/`, `.claude/`, or top-level ` `.runtime/sessions/{session_id}.json` stores the latest known event, transcript path, cwd, model, and turn ids for registered epic-loop sessions only. -`.runtime/session-bindings.json` maps a session to an epic: +`.runtime/session-bindings.json` maps a session to an epic membership: ```json { - "active_sessions": { - "runtime-token-migration:implementation": "019..." - }, "sessions": { "019...": { "active": true, "epic_slug": "runtime-token-migration", - "mode": "implementation", "bound_at": "2026-05-05T00:00:00+00:00" } } @@ -159,15 +155,28 @@ When a bound session emits a hook event, the handler also mirrors a lightweight ## Binding Sessions +When resuming/orienting an existing epic by slug or path, auto-bind the current session as a mode-less member if the current-session capture is safe: + +```bash +node /scripts/auto-bind-session.mjs --current --slug "" +node /scripts/auto-bind-session.mjs --current --path ".epic-loop/epics/" +``` + +Auto-bind accepts only a fresh `UserPromptSubmit` hook capture. For Codex, it deliberately rejects the mtime transcript fallback. For Claude Code, the capture must be fresh, match the project root, and include `session_id` plus `transcript_path`. If no acceptable capture exists, the script prints a skip notice and exits successfully so orientation can continue. + +Auto-bind creates membership only. It does not change the epic mode, designate an implementation driver, or start the implementation loop. + Bind the current session explicitly after the user confirms that implementation should run in this session: ```bash node /scripts/bind-session.mjs --current --slug "" --mode implementation ``` -This deactivates the previous active session for the same epic and mode. Do not infer bindings from cwd alone when multiple epic sessions can run inside the same project. If `--current` cannot detect the current session for the selected platform, pass `--session-id ""` explicitly. +This designates the current session as the exclusive implementation driver for the epic. Do not infer driver bindings from cwd alone when multiple sessions can run inside the same project. If `--current` cannot detect the current session for the selected platform, pass `--session-id ""` explicitly. + +For driver binding, Codex `--current` uses the existing Codex current-session capture and session metadata fallback. Claude Code `--current` reads only the Claude Code hook capture for the selected platform; the capture must be fresh, match the current project root, and include string `session_id` and `transcript_path`. If the Claude Code capture is missing, stale, malformed, wrong-root, or ambiguous, pass `--session-id ""` explicitly. -For Codex, `--current` uses the existing Codex current-session capture and session metadata fallback. For Claude Code, `--current` reads only the Claude Code hook capture for the selected platform; the capture must be fresh, match the current project root, and include string `session_id` and `transcript_path`. If the Claude Code capture is missing, stale, malformed, wrong-root, or ambiguous, pass `--session-id ""` explicitly. +Unbind on user request with `node /scripts/unbind-session.mjs --current` (or `--session-id ""`, optional `--reason`). It deactivates the session's entry in `session-bindings.json` (`active: false`, `deactivated_at`, `deactivated_reason`); hooks become silent no-ops for that session id afterwards. Unbinding an unbound session is a harmless no-op. Rebind later through the normal `bind-session.mjs` flow. ## What Hooks Can And Cannot Do @@ -177,6 +186,8 @@ Hooks can: - keep per-session state separate - update project-local routing metadata - prepare the next submode marker for the manager/techlead/engineer cycle +- inject a one-line compact marker via `hookSpecificOutput.additionalContext` on `UserPromptSubmit` for active member sessions in `shaping` or `review` mode: `[epic-loop] epic= mode= — follow epic-loop skill mode rules` +- inject an advisory implementation lock marker for non-driver member sessions while another session drives the implementation loop: `[epic-loop] epic= mode=implementation — loop running in another session; read-only, do not edit epic artifacts` - continue the current session from `Stop` by returning `{ "decision": "block", "reason": "" }` - keep chaining Claude Code roles across `stop_hook_active: true` Stop reentries within the same turn; `stop_hook_active` is informational, not a hard gate, so each reentry records the role report and issues the next block continuation - give an external runner enough data to recover the right session when hook continuation did not run @@ -192,9 +203,9 @@ Hooks cannot be assumed to: For parallel sessions: - each live platform session has its own `session_id` -- each session must be bound to one epic and mode +- each session may be bound to one epic as an active member; the epic's runtime mode is shared by all members - hook events are stored under `hook-events/{session_id}` only after the session is bound - active epic writes should remain mode-owned where possible - broad artifact rewrites require reading the file immediately before editing -If two sessions are bound to the same epic and same mode, prefer append-only logs and task-level ownership markers. +If multiple sessions are bound to the same epic, prefer append-only logs and task-level ownership markers. During implementation, only the driver session should edit epic artifacts; non-driver members receive the advisory read-only lock marker. diff --git a/plugins/epic-loop/skills/epic-loop/references/parallel-sessions.md b/plugins/epic-loop/skills/epic-loop/references/parallel-sessions.md index 496860c..516431c 100644 --- a/plugins/epic-loop/skills/epic-loop/references/parallel-sessions.md +++ b/plugins/epic-loop/skills/epic-loop/references/parallel-sessions.md @@ -2,13 +2,14 @@ ## Rule -One session works in one mode at a time. One epic may have multiple sessions in different modes. +One session may be an active member of one epic at a time. An epic has one shared runtime mode, so multiple sessions on the same epic are in the same mode; same-epic different-mode sessions are not supported. -For hook-driven routing, only one session may be active for a given epic and mode. When the user explicitly says to run implementation in the current session, bind the current session and deactivate the previous active implementation session for that epic. +For hook-driven routing, many sessions may be active members of one epic. When the epic is in implementation mode, exactly one session is the implementation driver; other member sessions are read-only observers and receive the implementation lock marker. Examples: -- implementation executes the current phase +- implementation driver executes the current phase +- implementation non-driver members observe only and do not edit epic artifacts - shaping prepares future phases, including architecture reset when needed - review inspects a completed slice @@ -23,11 +24,13 @@ Mode ownership: - Implementation techlead/engineer: active task status, implementation log, verification notes, execution brief. - Review: findings, drift analysis, follow-up proposals. -If two sessions need the same artifact, use dated sections with the mode name. +If two member sessions need the same artifact, use dated sections with the shared epic mode and avoid broad rewrites. During implementation, non-driver members should not edit epic artifacts. ## State Updates -Use `.epic-loop/.runtime/session-bindings.json` as the source of truth for active hook-routed sessions. Historical or inactive sessions may remain recorded, but hooks should ignore them. +Use `.epic-loop/.runtime/session-bindings.json` as the source of truth for active epic membership. Historical or inactive sessions may remain recorded, but hooks should ignore them. + +The epic runtime state is the source of truth for the shared mode and, during implementation, the exclusive `implementation_loop.driver_session_id`. `state-of-epic.md` should reflect the latest known whole-epic state. Keep it concise and edit it carefully. It is acceptable for parallel sessions to add a short note rather than rewrite the entire file. @@ -38,3 +41,4 @@ Pause and ask the user when: - two sessions need incompatible changes to active architecture - implementation would proceed on assumptions review just invalidated - reset would obsolete work currently being implemented +- a non-driver implementation member needs to modify epic artifacts diff --git a/plugins/epic-loop/skills/epic-loop/scripts/auto-bind-session.mjs b/plugins/epic-loop/skills/epic-loop/scripts/auto-bind-session.mjs new file mode 100755 index 0000000..9b1af0d --- /dev/null +++ b/plugins/epic-loop/skills/epic-loop/scripts/auto-bind-session.mjs @@ -0,0 +1,11 @@ +#!/usr/bin/env node + +import process from "node:process"; + +import { parseArgs, runCli } from "./lib/common.mjs"; +import { autoBindSession } from "./lib/epics.mjs"; + +runCli(() => { + const { flags } = parseArgs(process.argv.slice(2)); + autoBindSession(flags); +}); diff --git a/plugins/epic-loop/skills/epic-loop/scripts/lib/common.mjs b/plugins/epic-loop/skills/epic-loop/scripts/lib/common.mjs index b310038..b277809 100644 --- a/plugins/epic-loop/skills/epic-loop/scripts/lib/common.mjs +++ b/plugins/epic-loop/skills/epic-loop/scripts/lib/common.mjs @@ -11,7 +11,7 @@ export const CODEX_HOOK_CAPTURE_RELATIVE_PATH = path.join(".codex", "tmp", "last export const CLAUDE_HOOK_CAPTURE_RELATIVE_PATH = path.join(".epic-loop", ".runtime", "claude-code-last-hook-capture.json"); export const PLATFORM_CONFIG_RELATIVE_PATH = path.join(".epic-loop", ".runtime", "platform.json"); // A hook capture written within this window is treated as the live session. -const CURRENT_SESSION_CAPTURE_TTL_MS = 15 * 60 * 1000; +export const CURRENT_SESSION_CAPTURE_TTL_MS = 15 * 60 * 1000; export function nowIso() { return new Date().toISOString().replace(/\.\d{3}Z$/u, "+00:00"); diff --git a/plugins/epic-loop/skills/epic-loop/scripts/lib/epics.mjs b/plugins/epic-loop/skills/epic-loop/scripts/lib/epics.mjs index f642b3b..e0382f6 100644 --- a/plugins/epic-loop/skills/epic-loop/scripts/lib/epics.mjs +++ b/plugins/epic-loop/skills/epic-loop/scripts/lib/epics.mjs @@ -2,6 +2,7 @@ import fs from "node:fs"; import path from "node:path"; import { + CURRENT_SESSION_CAPTURE_TTL_MS, MODES, appendGitignore, epicRuntimeRoot, @@ -49,7 +50,6 @@ export function initEpic(flags = {}) { Epic: ${title} Slug: \`${slug}\` Created: ${createdAt} -Current mode: ${mode} Active phase: Phase 1 - Shape The Epic Active task: TBD @@ -167,6 +167,25 @@ TBD console.log(`Workspace: ${epicDir}`); } +export function setEpicMode(flags = {}) { + const root = resolveRoot(flags.root); + const slug = requireFlag(flags, "slug"); + const mode = requireFlag(flags, "mode"); + + if (!MODES.includes(mode)) { + throw new Error(`Invalid --mode "${mode}". Expected one of: ${MODES.join(", ")}.`); + } + + const epicDir = path.join(epicsRoot(root), slug); + if (!fs.existsSync(epicDir)) { + throw new Error(`Epic not found: ${epicDir}`); + } + + writeEpicRuntimeMode(root, slug, mode); + + console.log(`Epic mode set for ${slug}: ${mode}`); +} + export function status(flags = {}, positionals = []) { const root = resolveRoot(flags.root); const slug = positionals[0]; @@ -227,39 +246,17 @@ export function bindSession(flags = {}) { const bindings = readJson(bindingsPath, { sessions: {} }); const normalizedBindings = bindings && typeof bindings === "object" && !Array.isArray(bindings) ? bindings : { sessions: {} }; const sessions = normalizedBindings.sessions && typeof normalizedBindings.sessions === "object" && !Array.isArray(normalizedBindings.sessions) ? normalizedBindings.sessions : {}; - const activeSessions = - normalizedBindings.active_sessions && typeof normalizedBindings.active_sessions === "object" && !Array.isArray(normalizedBindings.active_sessions) - ? normalizedBindings.active_sessions - : {}; const boundAt = nowIso(); - const activeKey = `${slug}:${mode}`; - const previousSessionId = activeSessions[activeKey] ?? null; - - for (const [existingSessionId, binding] of Object.entries(sessions)) { - if (!binding || typeof binding !== "object") { - continue; - } - - if (binding.epic_slug === slug && binding.mode === mode && existingSessionId !== sessionId) { - sessions[existingSessionId] = { - ...binding, - active: false, - deactivated_at: boundAt, - }; - } - } sessions[sessionId] = { active: true, activated_at: boundAt, bound_at: boundAt, epic_slug: slug, - mode, source: currentSession ? (currentSession.source === "claude-hook-capture" ? "current-claude-code-session" : "current-codex-session") : "explicit-session-id", turn_id: currentSession?.turn_id ?? null, }; - activeSessions[activeKey] = sessionId; - normalizedBindings.active_sessions = activeSessions; + delete normalizedBindings.active_sessions; normalizedBindings.sessions = sessions; writeJson(bindingsPath, normalizedBindings); @@ -268,8 +265,7 @@ export function bindSession(flags = {}) { writeJson(path.join(sessionDir, "binding.json"), { bound_at: boundAt, epic_slug: slug, - mode, - previous_session_id: previousSessionId, + requested_mode: mode, session_id: sessionId, }); @@ -278,12 +274,195 @@ export function bindSession(flags = {}) { sessionId, slug, }); + } else { + writeEpicRuntimeMode(root, slug, mode); } console.log(`Active ${mode} session for ${slug}: ${sessionId}`); - if (previousSessionId && previousSessionId !== sessionId) { - console.log(`Previous active session deactivated: ${previousSessionId}`); +} + +export function autoBindSession(flags = {}) { + const root = resolveRoot(flags.root); + const slug = resolveAutoBindSlug(root, flags); + const currentPlatform = requireRuntimePlatform(root); + const currentSession = flags.current ? (currentPlatform === "claude-code" ? readCurrentClaudeSession(root) : readCurrentCodexSession(root)) : null; + + const epicDir = path.join(epicsRoot(root), slug); + if (!fs.existsSync(epicDir)) { + throw new Error(`Epic not found: ${epicDir}`); } + + if (!isAutoBindableCurrentSession(currentSession, currentPlatform)) { + console.log(`Auto-bind skipped for ${slug}: no fresh UserPromptSubmit capture for the current ${currentPlatform} session.`); + return; + } + + writeMemberBinding(root, slug, currentSession, "auto-resume-member"); + console.log(`Auto-bound current session to ${slug}: ${currentSession.session_id}`); +} + +export function unbindSession(flags = {}) { + const root = resolveRoot(flags.root); + let currentSession = null; + let currentPlatform = null; + + if (flags.current) { + currentPlatform = requireRuntimePlatform(root); + currentSession = currentPlatform === "claude-code" ? readCurrentClaudeSession(root) : readCurrentCodexSession(root); + } + + if (flags.current && !currentSession) { + if (currentPlatform === "claude-code") { + throw new Error("Cannot detect current Claude Code session from a fresh hook capture. Pass --session-id explicitly."); + } + throw new Error("Cannot detect current Codex session from .codex/tmp/last-hook-capture.json. Pass --session-id explicitly."); + } + + const sessionId = currentSession?.session_id ?? requireFlag(flags, "session-id"); + const reason = typeof flags.reason === "string" && flags.reason.trim() ? flags.reason.trim() : "user-requested-unbind"; + + const bindingsPath = path.join(sessionRoot(root), "session-bindings.json"); + const bindings = readJson(bindingsPath, { sessions: {} }); + const normalizedBindings = bindings && typeof bindings === "object" && !Array.isArray(bindings) ? bindings : { sessions: {} }; + const sessions = normalizedBindings.sessions && typeof normalizedBindings.sessions === "object" && !Array.isArray(normalizedBindings.sessions) ? normalizedBindings.sessions : {}; + const binding = sessions[sessionId]; + + if (!binding || typeof binding !== "object" || binding.active !== true) { + console.log(`Session ${sessionId} is not currently bound to any epic.`); + return; + } + + const epicSlug = String(binding.epic_slug); + const unboundAt = nowIso(); + const runtimePath = runtimeStatePath(root, epicSlug); + const runtime = readJson(runtimePath, {}); + const normalizedRuntime = runtime && typeof runtime === "object" && !Array.isArray(runtime) ? runtime : {}; + const mode = typeof normalizedRuntime.mode === "string" ? normalizedRuntime.mode : typeof binding.mode === "string" ? binding.mode : "unknown"; + const loop = normalizedRuntime.implementation_loop && typeof normalizedRuntime.implementation_loop === "object" && !Array.isArray(normalizedRuntime.implementation_loop) ? normalizedRuntime.implementation_loop : {}; + + sessions[sessionId] = { + ...binding, + active: false, + deactivated_at: unboundAt, + deactivated_reason: reason, + }; + + delete normalizedBindings.active_sessions; + normalizedBindings.sessions = sessions; + writeJson(bindingsPath, normalizedBindings); + + if (loop.driver_session_id === sessionId) { + writeJson(runtimePath, { + ...normalizedRuntime, + implementation_loop: { + ...loop, + driver_session_id: null, + last_reason: "implementation-driver-unbound", + last_transition_at: unboundAt, + last_transition_by: "unbind-session", + next_role: "idle", + status: "idle", + }, + updated_at: unboundAt, + }); + } + + const sessionDir = path.join(epicRuntimeRoot(root, epicSlug), "sessions", sessionId); + ensureDir(sessionDir); + writeJson(path.join(sessionDir, "unbind.json"), { + epic_slug: epicSlug, + mode, + reason, + session_id: sessionId, + unbound_at: unboundAt, + }); + + console.log(`Session ${sessionId} unbound from ${epicSlug} (${mode}).`); +} + +function resolveAutoBindSlug(root, flags = {}) { + if (typeof flags.slug === "string" && flags.slug.trim()) { + return flags.slug.trim(); + } + + const rawPath = typeof flags.path === "string" && flags.path.trim() ? flags.path.trim() : typeof flags["epic-path"] === "string" && flags["epic-path"].trim() ? flags["epic-path"].trim() : null; + if (!rawPath) { + throw new Error("Missing --slug or --path."); + } + + return path.basename(path.resolve(root, rawPath)); +} + +function isAutoBindableCurrentSession(currentSession, platform) { + if (!currentSession || currentSession.hook_event_name !== "UserPromptSubmit") { + return false; + } + + const capturedMs = Date.parse(currentSession.captured_at ?? ""); + if (!Number.isFinite(capturedMs) || Date.now() - capturedMs > CURRENT_SESSION_CAPTURE_TTL_MS) { + return false; + } + + if (platform === "claude-code") { + return currentSession.source === "claude-hook-capture" && typeof currentSession.transcript_path === "string" && currentSession.transcript_path.length > 0; + } + + return currentSession.source === "hook-capture"; +} + +function writeMemberBinding(root, slug, currentSession, reason) { + const bindingsPath = path.join(sessionRoot(root), "session-bindings.json"); + const bindings = readJson(bindingsPath, { sessions: {} }); + const normalizedBindings = bindings && typeof bindings === "object" && !Array.isArray(bindings) ? bindings : { sessions: {} }; + const sessions = normalizedBindings.sessions && typeof normalizedBindings.sessions === "object" && !Array.isArray(normalizedBindings.sessions) ? normalizedBindings.sessions : {}; + const boundAt = nowIso(); + + sessions[currentSession.session_id] = { + active: true, + activated_at: boundAt, + bound_at: boundAt, + epic_slug: slug, + source: currentSession.source === "claude-hook-capture" ? "current-claude-code-session" : "current-codex-session", + turn_id: currentSession.turn_id ?? null, + }; + delete normalizedBindings.active_sessions; + normalizedBindings.sessions = sessions; + writeJson(bindingsPath, normalizedBindings); + + const sessionDir = path.join(epicRuntimeRoot(root, slug), "sessions", currentSession.session_id); + ensureDir(sessionDir); + writeJson(path.join(sessionDir, "binding.json"), { + bound_at: boundAt, + epic_slug: slug, + reason, + requested_mode: null, + session_id: currentSession.session_id, + }); +} + +function writeEpicRuntimeMode(root, slug, mode) { + const runtimePath = runtimeStatePath(root, slug); + if (!fs.existsSync(runtimePath)) { + throw new Error(`Runtime state not found: ${runtimePath}`); + } + + let runtime; + try { + runtime = JSON.parse(fs.readFileSync(runtimePath, "utf8")); + } catch (error) { + const message = error instanceof Error ? error.message : String(error); + throw new Error(`Cannot read runtime state: ${message}`); + } + + if (!runtime || typeof runtime !== "object" || Array.isArray(runtime)) { + throw new Error(`Runtime state must be an object: ${runtimePath}`); + } + + writeJson(runtimePath, { + ...runtime, + mode, + updated_at: nowIso(), + }); } export function listEpics(flags = {}) { diff --git a/plugins/epic-loop/skills/epic-loop/scripts/lib/hooks.mjs b/plugins/epic-loop/skills/epic-loop/scripts/lib/hooks.mjs index 21c58b3..b752c10 100644 --- a/plugins/epic-loop/skills/epic-loop/scripts/lib/hooks.mjs +++ b/plugins/epic-loop/skills/epic-loop/scripts/lib/hooks.mjs @@ -6,8 +6,10 @@ import { CODEX_CONFIG_RELATIVE_PATH, CODEX_HOOKS_RELATIVE_PATH, HOOK_EVENTS, + MODES, canReadPath, canWritePath, + epicsRoot, epicRuntimeRoot, eventTimestamp, formatList, @@ -19,6 +21,8 @@ import { readJson, readJsonStrict, resolveRoot, + roadmapStatePath, + runtimeStatePath, sessionRoot, shellQuote, slugify, @@ -28,6 +32,7 @@ import { writeRuntimePlatform, } from "./common.mjs"; import { markInterruptedTurnIfNeeded, maybeBuildImplementationContinuation } from "./loop.mjs"; +import { createInitialRoadmapState } from "./roadmap.mjs"; const CLAUDE_SETTINGS_RELATIVE_PATH = path.join(".claude", "settings.json"); const LIB_DIR = path.dirname(fileURLToPath(import.meta.url)); @@ -480,6 +485,192 @@ function inspectClaudeStopHookBlockCap(env = process.env) { }; } +function inspectAndRepairEpicCompatibility(root) { + const epicsDir = epicsRoot(root); + const result = { + checked: 0, + invalid: [], + repaired: [], + ready: true, + }; + + if (!fs.existsSync(epicsDir)) { + return result; + } + + const bindingModes = readActiveBindingModes(root); + + for (const entry of fs.readdirSync(epicsDir, { withFileTypes: true })) { + if (!entry.isDirectory()) { + continue; + } + + const slug = entry.name; + result.checked += 1; + const roadmap = inspectAndRepairRoadmapState(root, slug, result); + inspectAndRepairRuntimeState(root, slug, roadmap, bindingModes.get(slug), result); + } + + result.ready = result.invalid.length === 0; + return result; +} + +function inspectAndRepairRoadmapState(root, slug, result) { + const roadmapPath = roadmapStatePath(root, slug); + const strict = readJsonStrict(roadmapPath); + + if (strict.error) { + result.invalid.push({ + path: roadmapPath, + reason: strict.error, + slug, + type: "roadmap-state", + }); + return null; + } + + if (strict.exists && isPlainObject(strict.value)) { + return strict.value; + } + + const roadmap = createInitialRoadmapState({ slug, title: slug }); + writeJson(roadmapPath, roadmap); + result.repaired.push({ + path: roadmapPath, + slug, + type: "created-roadmap-state", + }); + return roadmap; +} + +function inspectAndRepairRuntimeState(root, slug, roadmap, bindingMode, result) { + const runtimePath = runtimeStatePath(root, slug); + const strict = readJsonStrict(runtimePath); + + if (strict.error) { + result.invalid.push({ + path: runtimePath, + reason: strict.error, + slug, + type: "runtime-state", + }); + return; + } + + if (!strict.exists) { + writeJson(runtimePath, buildRuntimeStateFromStructuredData(slug, roadmap, bindingMode)); + result.repaired.push({ + path: runtimePath, + slug, + type: "created-runtime-state", + }); + return; + } + + if (!isPlainObject(strict.value)) { + result.invalid.push({ + path: runtimePath, + reason: "runtime state must be an object", + slug, + type: "runtime-state", + }); + return; + } + + const mode = typeof strict.value.mode === "string" && MODES.includes(strict.value.mode) ? strict.value.mode : bindingMode ?? null; + if (!mode) { + result.invalid.push({ + path: runtimePath, + reason: "missing mode", + slug, + type: "runtime-state", + }); + return; + } + + if (strict.value.mode !== mode) { + writeJson(runtimePath, { + ...strict.value, + mode, + updated_at: nowIso(), + }); + result.repaired.push({ + path: runtimePath, + slug, + type: "repaired-runtime-mode", + }); + } +} + +function buildRuntimeStateFromStructuredData(slug, roadmap, bindingMode) { + const timestamp = nowIso(); + const normalizedRoadmap = isPlainObject(roadmap) ? roadmap : createInitialRoadmapState({ slug, title: slug }); + + return { + active_phase: formatRoadmapPhase(normalizedRoadmap, normalizedRoadmap.active_phase_id), + active_task: formatRoadmapTask(normalizedRoadmap, normalizedRoadmap.active_task_id), + created_at: timestamp, + description: null, + execution_brief: null, + implementation_submode: "techlead", + mode: bindingMode ?? "shaping", + slug, + title: typeof normalizedRoadmap.title === "string" && normalizedRoadmap.title.trim() ? normalizedRoadmap.title.trim() : slug, + updated_at: timestamp, + }; +} + +function readActiveBindingModes(root) { + const bindingsPath = path.join(sessionRoot(root), "session-bindings.json"); + const bindings = readJson(bindingsPath, {}); + const sessions = isPlainObject(bindings?.sessions) ? bindings.sessions : {}; + const modes = new Map(); + + for (const binding of Object.values(sessions)) { + if (!isPlainObject(binding) || binding.active !== true || typeof binding.epic_slug !== "string" || !MODES.includes(binding.mode)) { + continue; + } + + modes.set(binding.epic_slug, binding.mode); + } + + return modes; +} + +function formatRoadmapPhase(roadmap, phaseId) { + const phases = Array.isArray(roadmap.phases) ? roadmap.phases : []; + const phase = phases.find((candidate) => candidate?.id === phaseId) ?? phases[0]; + if (!phase) { + return null; + } + + const index = phases.indexOf(phase); + const number = index >= 0 ? index + 1 : 1; + const title = typeof phase.title === "string" && phase.title.trim() ? phase.title.trim() : `Phase ${number}`; + return `Phase ${number} - ${title}`; +} + +function formatRoadmapTask(roadmap, taskId) { + if (typeof taskId !== "string" || !taskId) { + return null; + } + + const phases = Array.isArray(roadmap.phases) ? roadmap.phases : []; + for (const phase of phases) { + const tasks = Array.isArray(phase?.tasks) ? phase.tasks : []; + const task = tasks.find((candidate) => candidate?.id === taskId); + if (task) { + return typeof task.title === "string" && task.title.trim() ? task.title.trim() : task.id; + } + } + + return null; +} + +function isPlainObject(value) { + return value && typeof value === "object" && !Array.isArray(value); +} + export function doctor(flags = {}) { const root = resolveRoot(flags.root); if (typeof flags.platform !== "string") { @@ -506,11 +697,13 @@ function doctorCodex(root, platformConfig, flags = {}) { const feature = inspectCodexHooksFeature(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 epicCompatibility = inspectAndRepairEpicCompatibility(root); + const ready = hookConfig.ready && !hookConfig.invalid && feature.enabled === true && runtimeWritable.ok && scriptReadable.ok && epicCompatibility.ready; const setupPossible = !hookConfig.invalid && hookConfig.writable.ok; const status = { codexHooksFeature: feature, command: hookConfig.command, + epicCompatibility, hookConfig: { exists: hookConfig.exists, invalid: hookConfig.invalid, @@ -551,6 +744,9 @@ function doctorCodex(root, platformConfig, flags = {}) { console.log(`Hook command: ${hookConfig.command}`); console.log(`Required events missing: ${formatList(hookConfig.missingEvents)}`); console.log(`Stale epic-loop hook entries: ${formatList(hookConfig.staleEvents)}`); + console.log(`Epic compatibility: ${epicCompatibility.ready ? "ready" : "repair-required"}`); + console.log(`Epic compatibility repairs: ${formatList(epicCompatibility.repaired.map((repair) => `${repair.slug}:${repair.type}`))}`); + console.log(`Epic compatibility invalid: ${formatList(epicCompatibility.invalid.map((issue) => `${issue.slug}:${issue.type}`))}`); console.log(`Hook config writable: ${hookConfig.writable.ok ? "yes" : `no (${hookConfig.writable.reason})`}`); console.log(`Runtime state writable: ${runtimeWritable.ok ? "yes" : `no (${runtimeWritable.reason})`}`); @@ -588,7 +784,8 @@ function doctorClaudeCode(root, platformConfig, flags = {}) { const blockCap = inspectClaudeStopHookBlockCap(); const runtimeWritable = canWritePath(sessionRoot(root)); const scriptReadable = canReadPath(HOOK_SCRIPT_PATH); - const ready = hookConfig.ready && !hookConfig.invalid && blockCap.ready && runtimeWritable.ok && scriptReadable.ok; + const epicCompatibility = inspectAndRepairEpicCompatibility(root); + const ready = hookConfig.ready && !hookConfig.invalid && blockCap.ready && runtimeWritable.ok && scriptReadable.ok && epicCompatibility.ready; const setupPossible = !hookConfig.invalid && hookConfig.writable.ok; const status = { claudeCodeHookConfig: { @@ -602,6 +799,7 @@ function doctorClaudeCode(root, platformConfig, flags = {}) { writable: hookConfig.writable, }, command: hookConfig.command, + epicCompatibility, hookTarget: { exists: fs.existsSync(HOOK_SCRIPT_PATH), path: HOOK_SCRIPT_PATH, @@ -637,6 +835,9 @@ function doctorClaudeCode(root, platformConfig, flags = {}) { console.log(`Claude Code settings: ${hookConfig.exists ? hookConfig.path : `${hookConfig.path} (missing)`}`); console.log(`Required events missing: ${formatList(hookConfig.missingEvents)}`); console.log(`Stale epic-loop hook entries: ${formatList(hookConfig.staleEvents)}`); + console.log(`Epic compatibility: ${epicCompatibility.ready ? "ready" : "repair-required"}`); + console.log(`Epic compatibility repairs: ${formatList(epicCompatibility.repaired.map((repair) => `${repair.slug}:${repair.type}`))}`); + console.log(`Epic compatibility invalid: ${formatList(epicCompatibility.invalid.map((issue) => `${issue.slug}:${issue.type}`))}`); console.log(`Claude Code settings writable: ${hookConfig.writable.ok ? "yes" : `no (${hookConfig.writable.reason})`}`); console.log(`Runtime state writable: ${runtimeWritable.ok ? "yes" : `no (${runtimeWritable.reason})`}`); console.log(`Hook target exists: ${fs.existsSync(HOOK_SCRIPT_PATH) ? "yes" : "no"}`); @@ -742,6 +943,42 @@ function installClaudeHooks(root, flags = {}) { console.log(`Installed project-local Claude Code epic-loop hooks: ${settingsPath}`); } +const MODE_REMINDER_TEXT = { + implementationLock: (slug) => `[epic-loop] epic=${slug} mode=implementation — loop running in another session; read-only, do not edit epic artifacts`, + marker: (slug, mode) => `[epic-loop] epic=${slug} mode=${mode} — follow epic-loop skill mode rules`, +}; + +export function buildModeReminder(projectRoot, payload, binding) { + if (payload.hook_event_name !== "UserPromptSubmit") { + return null; + } + const runtime = readJson(runtimeStatePath(projectRoot, binding.epic_slug), {}); + const normalizedRuntime = runtime && typeof runtime === "object" && !Array.isArray(runtime) ? runtime : {}; + const mode = normalizedRuntime.mode; + const loop = + normalizedRuntime.implementation_loop && typeof normalizedRuntime.implementation_loop === "object" && !Array.isArray(normalizedRuntime.implementation_loop) + ? normalizedRuntime.implementation_loop + : {}; + + let text = null; + if (mode === "shaping" || mode === "review") { + text = MODE_REMINDER_TEXT.marker(binding.epic_slug, mode); + } else if (mode === "implementation" && loop.driver_session_id !== payload.session_id) { + text = MODE_REMINDER_TEXT.implementationLock(binding.epic_slug); + } + + if (!text) { + return null; + } + + return { + hookSpecificOutput: { + hookEventName: "UserPromptSubmit", + additionalContext: text, + }, + }; +} + export function handleHook(rawInput, flags = {}) { let payload; @@ -785,7 +1022,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) ?? buildModeReminder(projectRoot, payload, binding); if (continuation) { console.log(JSON.stringify(continuation)); } @@ -823,18 +1060,13 @@ function getSessionBinding(projectRoot, sessionId) { const bindingsPath = path.join(sessionRoot(projectRoot), "session-bindings.json"); const bindings = readJson(bindingsPath, { sessions: {} }); const sessions = bindings && typeof bindings === "object" && !Array.isArray(bindings) && bindings.sessions && typeof bindings.sessions === "object" ? bindings.sessions : {}; - const activeSessions = - bindings && typeof bindings === "object" && !Array.isArray(bindings) && bindings.active_sessions && typeof bindings.active_sessions === "object" - ? bindings.active_sessions - : {}; const binding = sessions[sessionId]; if (!binding || typeof binding !== "object" || binding.active !== true) { return null; } - const activeKey = `${binding.epic_slug}:${binding.mode}`; - if (activeSessions[activeKey] !== sessionId) { + if (!binding.epic_slug) { return null; } diff --git a/plugins/epic-loop/skills/epic-loop/scripts/lib/loop.mjs b/plugins/epic-loop/skills/epic-loop/scripts/lib/loop.mjs index 70c69dc..2d1df48 100644 --- a/plugins/epic-loop/skills/epic-loop/scripts/lib/loop.mjs +++ b/plugins/epic-loop/skills/epic-loop/scripts/lib/loop.mjs @@ -63,6 +63,7 @@ export function startImplementationLoop(projectRoot, { sessionId, slug }) { current_role: null, active_turn_started_at: null, active_turn_stopped_at: null, + driver_session_id: sessionId, iteration: Number.isFinite(loop.iteration) ? loop.iteration : 0, last_reason: "implementation-start", last_session_id: sessionId, @@ -153,7 +154,7 @@ export function setNextRole(flags = {}) { } export function maybeBuildImplementationContinuation(projectRoot, payload, binding) { - if (payload.hook_event_name !== "Stop" || binding.mode !== "implementation") { + if (payload.hook_event_name !== "Stop") { return null; } @@ -165,6 +166,10 @@ export function maybeBuildImplementationContinuation(projectRoot, payload, bindi let loop = normalizeObject(runtime.implementation_loop); const platform = readRuntimePlatform(projectRoot).platform; + if (runtime.mode !== "implementation" || loop.driver_session_id !== payload.session_id) { + return null; + } + ({ loop, runtime } = recordTurnStopIfNeeded(projectRoot, slug, runtime, loop, payload, timestamp)); ({ loop, runtime } = ensureClaudeBlockCapMetadata(projectRoot, slug, runtime, loop, timestamp)); @@ -315,7 +320,7 @@ export function maybeBuildImplementationContinuation(projectRoot, payload, bindi } export function markInterruptedTurnIfNeeded(projectRoot, payload, binding) { - if (payload.hook_event_name !== "UserPromptSubmit" || binding.mode !== "implementation") { + if (payload.hook_event_name !== "UserPromptSubmit") { return false; } @@ -325,6 +330,10 @@ export function markInterruptedTurnIfNeeded(projectRoot, payload, binding) { const runtime = mergeEpicStateIntoRuntime(projectRoot, slug, normalizeObject(readJson(runtimePath, {}))); const loop = normalizeObject(runtime.implementation_loop); + if (runtime.mode !== "implementation" || loop.driver_session_id !== payload.session_id) { + return false; + } + if (!hasOpenTurn(loop)) { return false; } @@ -484,7 +493,6 @@ function mergeEpicStateIntoRuntime(projectRoot, slug, runtime) { ...runtime, ...(summary.active_phase !== undefined ? { active_phase: summary.active_phase } : {}), ...(summary.active_task !== undefined ? { active_task: summary.active_task } : {}), - ...(summary.mode !== undefined ? { mode: summary.mode } : {}), }; } @@ -634,7 +642,6 @@ function readEpicStateSummary(projectRoot, slug) { return { active_phase: readStateLine(text, "Active phase"), active_task: readStateLine(text, "Active task"), - mode: readStateLine(text, "Current mode"), }; } diff --git a/plugins/epic-loop/skills/epic-loop/scripts/set-epic-mode.mjs b/plugins/epic-loop/skills/epic-loop/scripts/set-epic-mode.mjs new file mode 100755 index 0000000..b2d988f --- /dev/null +++ b/plugins/epic-loop/skills/epic-loop/scripts/set-epic-mode.mjs @@ -0,0 +1,11 @@ +#!/usr/bin/env node + +import process from "node:process"; + +import { parseArgs, runCli } from "./lib/common.mjs"; +import { setEpicMode } from "./lib/epics.mjs"; + +runCli(() => { + const { flags } = parseArgs(process.argv.slice(2)); + setEpicMode(flags); +}); diff --git a/plugins/epic-loop/skills/epic-loop/scripts/unbind-session.mjs b/plugins/epic-loop/skills/epic-loop/scripts/unbind-session.mjs new file mode 100644 index 0000000..77a9bdd --- /dev/null +++ b/plugins/epic-loop/skills/epic-loop/scripts/unbind-session.mjs @@ -0,0 +1,11 @@ +#!/usr/bin/env node + +import process from "node:process"; + +import { parseArgs, runCli } from "./lib/common.mjs"; +import { unbindSession } from "./lib/epics.mjs"; + +runCli(() => { + const { flags } = parseArgs(process.argv.slice(2)); + unbindSession(flags); +}); diff --git a/tests/unit/cli-contracts.test.mjs b/tests/unit/cli-contracts.test.mjs index 42095cb..69069c4 100644 --- a/tests/unit/cli-contracts.test.mjs +++ b/tests/unit/cli-contracts.test.mjs @@ -5,6 +5,24 @@ import { test } from "node:test"; import { assertSuccess, makeTempRoot, readJsonFile, runNodeScript } from "./test-utils.mjs"; +function runHook(root, payload) { + const result = runNodeScript("hook.mjs", ["--root", root], { + input: JSON.stringify(payload), + }); + assertSuccess(result); + return result; +} + +function userPromptPayload(root, sessionId, extra = {}) { + return { + cwd: root, + hook_event_name: "UserPromptSubmit", + session_id: sessionId, + turn_id: "turn-user-prompt", + ...extra, + }; +} + test("doctor and install-hooks expose readiness contracts in an isolated project", () => { const root = makeTempRoot("doctor-"); @@ -57,6 +75,104 @@ test("doctor and install-hooks expose readiness contracts in an isolated project } }); +test("doctor repairs structured epic compatibility without reading state markdown", () => { + const root = makeTempRoot("doctor-compat-"); + const legacySlug = "legacy"; + const emptySlug = "empty"; + + try { + fs.mkdirSync(path.join(root, ".codex"), { recursive: true }); + fs.writeFileSync(path.join(root, ".codex", "config.toml"), "[features]\nhooks = true\n", "utf8"); + + const legacyRoot = path.join(root, ".epic-loop", "epics", legacySlug); + fs.mkdirSync(path.join(legacyRoot, ".runtime"), { recursive: true }); + fs.writeFileSync( + path.join(legacyRoot, "state-of-epic.md"), + [ + "# State Of Epic", + "", + "Epic: Markdown Must Not Drive Mode", + "Slug: `legacy`", + "Current mode: review", + "Active phase: Phase 9 - Markdown Only", + "", + ].join("\n"), + "utf8", + ); + fs.writeFileSync( + path.join(legacyRoot, ".runtime", "roadmap-state.json"), + `${JSON.stringify( + { + schema_version: 1, + slug: legacySlug, + title: "Structured Legacy Epic", + active_phase_id: "phase-1", + active_task_id: null, + phases: [ + { + id: "phase-1", + status: "todo", + tasks: [], + title: "Structured Phase", + }, + ], + follow_ups: [], + }, + null, + 2, + )}\n`, + "utf8", + ); + + fs.mkdirSync(path.join(root, ".epic-loop", "epics", emptySlug, ".runtime"), { recursive: true }); + + const doctor = runNodeScript("doctor.mjs", ["--root", root, "--platform", "codex", "--json"]); + assertSuccess(doctor); + const status = JSON.parse(doctor.stdout); + assert.equal(status.epicCompatibility.ready, true); + assert.equal(status.epicCompatibility.checked, 2); + assert.deepEqual( + status.epicCompatibility.repaired.map((repair) => `${repair.slug}:${repair.type}`).sort(), + ["empty:created-roadmap-state", "empty:created-runtime-state", "legacy:created-runtime-state"], + ); + + const legacyRuntime = readJsonFile(path.join(legacyRoot, ".runtime", "runtime-state.json")); + assert.equal(legacyRuntime.mode, "shaping"); + assert.equal(legacyRuntime.title, "Structured Legacy Epic"); + assert.equal(legacyRuntime.active_phase, "Phase 1 - Structured Phase"); + + const emptyRoadmap = readJsonFile(path.join(root, ".epic-loop", "epics", emptySlug, ".runtime", "roadmap-state.json")); + const emptyRuntime = readJsonFile(path.join(root, ".epic-loop", "epics", emptySlug, ".runtime", "runtime-state.json")); + assert.equal(emptyRoadmap.slug, emptySlug); + assert.equal(emptyRuntime.mode, "shaping"); + + fs.writeFileSync( + path.join(root, ".epic-loop", ".runtime", "session-bindings.json"), + `${JSON.stringify( + { + sessions: { + "session-legacy": { + active: true, + epic_slug: legacySlug, + }, + }, + }, + null, + 2, + )}\n`, + "utf8", + ); + + const hook = runHook(root, userPromptPayload(root, "session-legacy")); + assert.equal( + JSON.parse(hook.stdout).hookSpecificOutput.additionalContext, + "[epic-loop] epic=legacy mode=shaping — follow epic-loop skill mode rules", + ); + } finally { + fs.rmSync(root, { force: true, recursive: true }); + } +}); + test("doctor exposes a Claude Code platform readiness boundary", () => { const root = makeTempRoot("doctor-claude-"); @@ -378,6 +494,10 @@ test("bind-session current lookup preserves Codex hook capture behavior", () => const bindings = readJsonFile(path.join(root, ".epic-loop", ".runtime", "session-bindings.json")); assert.equal(bindings.sessions["codex-current-session"].source, "current-codex-session"); assert.equal(bindings.sessions["codex-current-session"].turn_id, "turn-current"); + assert.equal(bindings.sessions["codex-current-session"].mode, undefined); + assert.equal(bindings.active_sessions, undefined); + const runtime = readJsonFile(path.join(root, ".epic-loop", "epics", slug, ".runtime", "runtime-state.json")); + assert.equal(runtime.implementation_loop.driver_session_id, "codex-current-session"); } finally { fs.rmSync(root, { force: true, recursive: true }); } @@ -410,7 +530,10 @@ test("bind-session current lookup uses fresh Claude Code hook captures", () => { const bindings = readJsonFile(path.join(root, ".epic-loop", ".runtime", "session-bindings.json")); assert.equal(bindings.sessions["claude-current-session"].source, "current-claude-code-session"); - assert.equal(bindings.active_sessions[`${slug}:implementation`], "claude-current-session"); + assert.equal(bindings.sessions["claude-current-session"].mode, undefined); + assert.equal(bindings.active_sessions, undefined); + const runtime = readJsonFile(path.join(root, ".epic-loop", "epics", slug, ".runtime", "runtime-state.json")); + assert.equal(runtime.implementation_loop.driver_session_id, "claude-current-session"); } finally { fs.rmSync(root, { force: true, recursive: true }); } @@ -486,6 +609,218 @@ test("bind-session preserves explicit session-id binding on Claude Code", () => const bindings = readJsonFile(path.join(root, ".epic-loop", ".runtime", "session-bindings.json")); assert.equal(bindings.sessions["explicit-claude-session"].source, "explicit-session-id"); + assert.equal(bindings.sessions["explicit-claude-session"].mode, undefined); + assert.equal(bindings.active_sessions, undefined); + } finally { + fs.rmSync(root, { force: true, recursive: true }); + } +}); + +test("auto-bind-session binds a resumed Codex shaping epic from a fresh UserPromptSubmit capture", () => { + const root = makeTempRoot("auto-bind-codex-shaping-"); + const slug = "auto-codex"; + + try { + assertSuccess(runNodeScript("init-epic.mjs", ["--root", root, "--description", "Auto Codex project", "--slug", slug, "--no-gitignore"])); + assertSuccess(runNodeScript("doctor.mjs", ["--root", root, "--platform", "codex", "--json"])); + + runHook(root, userPromptPayload(root, "codex-auto-session")); + + const bind = runNodeScript("auto-bind-session.mjs", ["--root", root, "--current", "--slug", slug]); + assertSuccess(bind); + assert.match(bind.stdout, /Auto-bound current session to auto-codex: codex-auto-session/u); + + const bindings = readJsonFile(path.join(root, ".epic-loop", ".runtime", "session-bindings.json")); + assert.equal(bindings.sessions["codex-auto-session"].active, true); + assert.equal(bindings.sessions["codex-auto-session"].epic_slug, slug); + assert.equal(bindings.sessions["codex-auto-session"].mode, undefined); + + const marker = runHook(root, userPromptPayload(root, "codex-auto-session")); + assert.equal(JSON.parse(marker.stdout).hookSpecificOutput.additionalContext, `[epic-loop] epic=${slug} mode=shaping — follow epic-loop skill mode rules`); + } finally { + fs.rmSync(root, { force: true, recursive: true }); + } +}); + +test("auto-bind-session accepts an epic path and preserves review runtime mode", () => { + const root = makeTempRoot("auto-bind-review-path-"); + const slug = "auto-review"; + + try { + assertSuccess(runNodeScript("init-epic.mjs", ["--root", root, "--description", "Auto review project", "--slug", slug, "--no-gitignore"])); + assertSuccess(runNodeScript("doctor.mjs", ["--root", root, "--platform", "codex", "--json"])); + assertSuccess(runNodeScript("set-epic-mode.mjs", ["--root", root, "--slug", slug, "--mode", "review"])); + + runHook(root, userPromptPayload(root, "review-auto-session")); + + const bind = runNodeScript("auto-bind-session.mjs", ["--root", root, "--current", "--path", path.join(root, ".epic-loop", "epics", slug)]); + assertSuccess(bind); + assert.match(bind.stdout, /Auto-bound current session to auto-review: review-auto-session/u); + + const marker = runHook(root, userPromptPayload(root, "review-auto-session")); + assert.equal(JSON.parse(marker.stdout).hookSpecificOutput.additionalContext, `[epic-loop] epic=${slug} mode=review — follow epic-loop skill mode rules`); + } finally { + fs.rmSync(root, { force: true, recursive: true }); + } +}); + +test("auto-bind-session binds implementation observers without replacing the driver", () => { + const root = makeTempRoot("auto-bind-implementation-"); + const slug = "auto-implementation"; + + try { + assertSuccess(runNodeScript("init-epic.mjs", ["--root", root, "--description", "Auto implementation project", "--slug", slug, "--no-gitignore"])); + assertSuccess(runNodeScript("doctor.mjs", ["--root", root, "--platform", "codex", "--json"])); + assertSuccess(runNodeScript("bind-session.mjs", ["--root", root, "--session-id", "driver-session", "--slug", slug, "--mode", "implementation"])); + + runHook(root, userPromptPayload(root, "observer-session")); + + const bind = runNodeScript("auto-bind-session.mjs", ["--root", root, "--current", "--slug", slug]); + assertSuccess(bind); + assert.match(bind.stdout, /Auto-bound current session to auto-implementation: observer-session/u); + + const runtime = readJsonFile(path.join(root, ".epic-loop", "epics", slug, ".runtime", "runtime-state.json")); + assert.equal(runtime.mode, "implementation"); + assert.equal(runtime.implementation_loop.driver_session_id, "driver-session"); + + const marker = runHook(root, userPromptPayload(root, "observer-session")); + assert.equal( + JSON.parse(marker.stdout).hookSpecificOutput.additionalContext, + `[epic-loop] epic=${slug} mode=implementation — loop running in another session; read-only, do not edit epic artifacts`, + ); + } finally { + fs.rmSync(root, { force: true, recursive: true }); + } +}); + +test("auto-bind-session skips unsafe Codex current-session captures without failing", () => { + const cases = [ + { + name: "stop-event", + payload: (root) => ({ + cwd: root, + hook_event_name: "Stop", + session_id: "stop-session", + turn_id: "turn-stop", + }), + }, + { + name: "wrong-root", + payload: (root) => ({ + cwd: path.join(root, "other"), + hook_event_name: "UserPromptSubmit", + session_id: "wrong-root-session", + turn_id: "turn-wrong-root", + }), + }, + ]; + + for (const testCase of cases) { + const root = makeTempRoot(`auto-bind-codex-${testCase.name}-`); + const slug = testCase.name === "stop-event" ? "auto-stop" : "auto-wrong"; + + try { + assertSuccess(runNodeScript("init-epic.mjs", ["--root", root, "--description", `Auto ${testCase.name} project`, "--slug", slug, "--no-gitignore"])); + assertSuccess(runNodeScript("doctor.mjs", ["--root", root, "--platform", "codex", "--json"])); + + runHook(root, testCase.payload(root)); + + const bind = runNodeScript("auto-bind-session.mjs", ["--root", root, "--current", "--slug", slug]); + assertSuccess(bind); + assert.match(bind.stdout, new RegExp(`Auto-bind skipped for ${slug}: no fresh UserPromptSubmit capture`, "u")); + assert.equal(fs.existsSync(path.join(root, ".epic-loop", ".runtime", "session-bindings.json")), false); + } finally { + fs.rmSync(root, { force: true, recursive: true }); + } + } +}); + +test("auto-bind-session skips stale Codex captures without using transcript fallback", () => { + const root = makeTempRoot("auto-bind-codex-stale-"); + const slug = "auto-stale"; + + try { + assertSuccess(runNodeScript("init-epic.mjs", ["--root", root, "--description", "Auto stale project", "--slug", slug, "--no-gitignore"])); + assertSuccess(runNodeScript("doctor.mjs", ["--root", root, "--platform", "codex", "--json"])); + + const capturePath = path.join(root, ".codex", "tmp", "last-hook-capture.json"); + fs.mkdirSync(path.dirname(capturePath), { recursive: true }); + fs.writeFileSync( + capturePath, + `${JSON.stringify( + { + capturedAt: "2000-01-01T00:00:00+00:00", + payload: userPromptPayload(root, "stale-session"), + }, + null, + 2, + )}\n`, + "utf8", + ); + + const bind = runNodeScript("auto-bind-session.mjs", ["--root", root, "--current", "--slug", slug]); + assertSuccess(bind); + assert.match(bind.stdout, /Auto-bind skipped for auto-stale: no fresh UserPromptSubmit capture/u); + assert.equal(fs.existsSync(path.join(root, ".epic-loop", ".runtime", "session-bindings.json")), false); + } finally { + fs.rmSync(root, { force: true, recursive: true }); + } +}); + +test("auto-bind-session supports fresh Claude Code UserPromptSubmit captures", () => { + const root = makeTempRoot("auto-bind-claude-"); + const slug = "auto-claude"; + const transcriptPath = path.join(root, "transcript.jsonl"); + + try { + fs.writeFileSync(transcriptPath, "{\"type\":\"assistant\",\"message\":{\"content\":\"ready\"}}\n", "utf8"); + assertSuccess(runNodeScript("init-epic.mjs", ["--root", root, "--description", "Auto Claude project", "--slug", slug, "--no-gitignore"])); + assertSuccess(runNodeScript("doctor.mjs", ["--root", root, "--platform", "claude-code", "--json"])); + + runHook(root, userPromptPayload(root, "claude-auto-session", { transcript_path: transcriptPath })); + + const bind = runNodeScript("auto-bind-session.mjs", ["--root", root, "--current", "--slug", slug]); + assertSuccess(bind); + assert.match(bind.stdout, /Auto-bound current session to auto-claude: claude-auto-session/u); + + const bindings = readJsonFile(path.join(root, ".epic-loop", ".runtime", "session-bindings.json")); + assert.equal(bindings.sessions["claude-auto-session"].source, "current-claude-code-session"); + } finally { + fs.rmSync(root, { force: true, recursive: true }); + } +}); + +test("auto-bind-session skips Claude Code captures without transcript paths", () => { + const root = makeTempRoot("auto-bind-claude-missing-transcript-"); + const slug = "auto-claude"; + + try { + assertSuccess(runNodeScript("init-epic.mjs", ["--root", root, "--description", "Auto Claude missing project", "--slug", slug, "--no-gitignore"])); + assertSuccess(runNodeScript("doctor.mjs", ["--root", root, "--platform", "claude-code", "--json"])); + + const capturePath = path.join(root, ".epic-loop", ".runtime", "claude-code-last-hook-capture.json"); + fs.mkdirSync(path.dirname(capturePath), { recursive: true }); + fs.writeFileSync( + capturePath, + `${JSON.stringify( + { + capturedAt: new Date().toISOString(), + payload: { + cwd: root, + hook_event_name: "UserPromptSubmit", + session_id: "claude-missing-transcript", + }, + }, + null, + 2, + )}\n`, + "utf8", + ); + + const bind = runNodeScript("auto-bind-session.mjs", ["--root", root, "--current", "--slug", slug]); + assertSuccess(bind); + assert.match(bind.stdout, /Auto-bind skipped for auto-claude: no fresh UserPromptSubmit capture/u); + assert.equal(fs.existsSync(path.join(root, ".epic-loop", ".runtime", "session-bindings.json")), false); } finally { fs.rmSync(root, { force: true, recursive: true }); } diff --git a/tests/unit/hook-contracts.test.mjs b/tests/unit/hook-contracts.test.mjs index 9a96f43..3ae993d 100644 --- a/tests/unit/hook-contracts.test.mjs +++ b/tests/unit/hook-contracts.test.mjs @@ -11,14 +11,10 @@ function writeSessionBinding(root, slug, sessionId) { path.join(root, ".epic-loop", ".runtime", "session-bindings.json"), `${JSON.stringify( { - active_sessions: { - [`${slug}:implementation`]: sessionId, - }, sessions: { [sessionId]: { active: true, epic_slug: slug, - mode: "implementation", }, }, }, @@ -27,6 +23,24 @@ function writeSessionBinding(root, slug, sessionId) { )}\n`, "utf8", ); + const runtimePath = path.join(root, ".epic-loop", "epics", slug, ".runtime", "runtime-state.json"); + const runtime = readJsonFile(runtimePath); + fs.writeFileSync( + runtimePath, + `${JSON.stringify( + { + ...runtime, + implementation_loop: { + ...(runtime.implementation_loop ?? {}), + driver_session_id: sessionId, + }, + mode: "implementation", + }, + null, + 2, + )}\n`, + "utf8", + ); } function writeOpenImplementationTurn(root, slug, role = "engineer") { @@ -40,6 +54,7 @@ function writeOpenImplementationTurn(root, slug, role = "engineer") { implementation_loop: { active_turn_started_at: "2026-07-01T00:00:00+00:00", current_role: role, + driver_session_id: runtime.implementation_loop?.driver_session_id ?? null, iteration: 2, next_role: "techlead", status: "running", @@ -137,6 +152,131 @@ test("hook CLI builds a deterministic bound Stop continuation", () => { } }); +test("bound Stop continuation only runs for the implementation driver", () => { + const root = makeTempRoot("hook-driver-stop-"); + const slug = "driver-routing"; + const driverSessionId = "session-driver"; + const observerSessionId = "session-observer"; + + try { + assertSuccess(runNodeScript("doctor.mjs", ["--root", root, "--platform", "codex", "--json"])); + assertSuccess(runNodeScript("init-epic.mjs", ["--root", root, "--description", "Driver routing project", "--slug", slug, "--no-gitignore"])); + + const runtimePath = path.join(root, ".epic-loop", "epics", slug, ".runtime", "runtime-state.json"); + const runtime = readJsonFile(runtimePath); + fs.writeFileSync( + runtimePath, + `${JSON.stringify( + { + ...runtime, + implementation_loop: { + current_role: null, + iteration: 0, + next_role: "manager", + status: "running", + }, + mode: "implementation", + }, + null, + 2, + )}\n`, + "utf8", + ); + + writeSessionBinding(root, slug, driverSessionId); + const bindingsPath = path.join(root, ".epic-loop", ".runtime", "session-bindings.json"); + const bindings = readJsonFile(bindingsPath); + bindings.sessions[observerSessionId] = { + active: true, + epic_slug: slug, + }; + fs.writeFileSync(bindingsPath, `${JSON.stringify(bindings, null, 2)}\n`, "utf8"); + + const observerStop = runNodeScript("hook.mjs", ["--root", root], { + input: JSON.stringify({ + cwd: root, + hook_event_name: "Stop", + session_id: observerSessionId, + stop_hook_active: true, + turn_id: "observer-turn", + }), + }); + assertSuccess(observerStop); + assert.equal(observerStop.stdout, ""); + assert.equal(readJsonFile(runtimePath).implementation_loop.current_role, null); + + const driverStop = runNodeScript("hook.mjs", ["--root", root], { + input: JSON.stringify({ + cwd: root, + hook_event_name: "Stop", + session_id: driverSessionId, + stop_hook_active: true, + turn_id: "driver-turn", + }), + }); + assertSuccess(driverStop); + assert.match(JSON.parse(driverStop.stdout).reason, /manager housekeeping turn 1/u); + } finally { + fs.rmSync(root, { force: true, recursive: true }); + } +}); + +test("non-driver UserPromptSubmit does not interrupt an open implementation turn", () => { + const root = makeTempRoot("hook-driver-interrupt-"); + const slug = "driver-interrupt"; + const driverSessionId = "session-driver"; + const observerSessionId = "session-observer"; + + try { + assertSuccess(runNodeScript("doctor.mjs", ["--root", root, "--platform", "codex", "--json"])); + assertSuccess(runNodeScript("init-epic.mjs", ["--root", root, "--description", "Driver interrupt project", "--slug", slug, "--no-gitignore"])); + writeOpenImplementationTurn(root, slug, "engineer"); + writeSessionBinding(root, slug, driverSessionId); + + const bindingsPath = path.join(root, ".epic-loop", ".runtime", "session-bindings.json"); + const bindings = readJsonFile(bindingsPath); + bindings.sessions[observerSessionId] = { + active: true, + epic_slug: slug, + }; + fs.writeFileSync(bindingsPath, `${JSON.stringify(bindings, null, 2)}\n`, "utf8"); + + const runtimePath = path.join(root, ".epic-loop", "epics", slug, ".runtime", "runtime-state.json"); + const observerPrompt = runNodeScript("hook.mjs", ["--root", root], { + input: JSON.stringify({ + cwd: root, + hook_event_name: "UserPromptSubmit", + session_id: observerSessionId, + turn_id: "observer-prompt", + }), + }); + assertSuccess(observerPrompt); + assert.equal( + JSON.parse(observerPrompt.stdout).hookSpecificOutput.additionalContext, + `[epic-loop] epic=${slug} mode=implementation — loop running in another session; read-only, do not edit epic artifacts`, + ); + let runtime = readJsonFile(runtimePath); + assert.equal(runtime.implementation_loop.status, "running"); + assert.equal(runtime.implementation_loop.active_turn_stopped_at, undefined); + + const driverPrompt = runNodeScript("hook.mjs", ["--root", root], { + input: JSON.stringify({ + cwd: root, + hook_event_name: "UserPromptSubmit", + session_id: driverSessionId, + turn_id: "driver-prompt", + }), + }); + assertSuccess(driverPrompt); + assert.equal(driverPrompt.stdout, ""); + runtime = readJsonFile(runtimePath); + assert.equal(runtime.implementation_loop.status, "interrupted"); + assert.equal(runtime.implementation_loop.last_interrupt_session_id, driverSessionId); + } finally { + fs.rmSync(root, { force: true, recursive: true }); + } +}); + test("Claude Code unbound hook payload exits without epic-loop runtime records", () => { const root = makeTempRoot("hook-claude-unbound-"); const transcriptPath = path.join(root, "transcript.jsonl"); diff --git a/tests/unit/init-epic-cli.test.mjs b/tests/unit/init-epic-cli.test.mjs index d02149a..30dc1fb 100644 --- a/tests/unit/init-epic-cli.test.mjs +++ b/tests/unit/init-epic-cli.test.mjs @@ -3,7 +3,7 @@ import fs from "node:fs"; import path from "node:path"; import { test } from "node:test"; -import { assertSuccess, makeTempRoot, runNodeScript } from "./test-utils.mjs"; +import { assertSuccess, makeTempRoot, repoRoot, runNodeScript } from "./test-utils.mjs"; test("init-epic CLI initializes an isolated temporary project", () => { const tempRoot = makeTempRoot("init-"); @@ -18,6 +18,7 @@ test("init-epic CLI initializes an isolated temporary project", () => { assert.equal(fs.existsSync(path.join(epicRoot, "state-of-epic.md")), true); assert.equal(fs.existsSync(path.join(epicRoot, "tracker.md")), true); assert.equal(fs.existsSync(path.join(epicRoot, "docs", "problem-framing.md")), true); + assert.doesNotMatch(fs.readFileSync(path.join(epicRoot, "state-of-epic.md"), "utf8"), /^Current mode:/mu); const runtimeState = JSON.parse(fs.readFileSync(path.join(epicRoot, ".runtime", "runtime-state.json"), "utf8")); assert.equal(runtimeState.slug, "unit-harness"); @@ -26,3 +27,37 @@ test("init-epic CLI initializes an isolated temporary project", () => { fs.rmSync(tempRoot, { force: true, recursive: true }); } }); + +test("set-epic-mode CLI updates only the runtime mode source", () => { + const tempRoot = makeTempRoot("mode-"); + + try { + assertSuccess(runNodeScript("init-epic.mjs", ["--root", tempRoot, "--description", "Mode source smoke", "--no-gitignore"])); + + const epicRoot = path.join(tempRoot, ".epic-loop", "epics", "mode-source"); + const runtimePath = path.join(epicRoot, ".runtime", "runtime-state.json"); + const before = JSON.parse(fs.readFileSync(runtimePath, "utf8")); + fs.writeFileSync(runtimePath, `${JSON.stringify({ ...before, updated_at: "2000-01-01T00:00:00+00:00" }, null, 2)}\n`, "utf8"); + + const setReview = runNodeScript("set-epic-mode.mjs", ["--root", tempRoot, "--slug", "mode-source", "--mode", "review"]); + assertSuccess(setReview); + assert.match(setReview.stdout, /Epic mode set for mode-source: review/u); + + const after = JSON.parse(fs.readFileSync(runtimePath, "utf8")); + assert.equal(after.mode, "review"); + assert.notEqual(after.updated_at, "2000-01-01T00:00:00+00:00"); + assert.doesNotMatch(fs.readFileSync(path.join(epicRoot, "state-of-epic.md"), "utf8"), /^Current mode:/mu); + + const invalid = runNodeScript("set-epic-mode.mjs", ["--root", tempRoot, "--slug", "mode-source", "--mode", "planning"]); + assert.equal(invalid.status, 1); + assert.match(invalid.stderr, /Invalid --mode "planning"/u); + } finally { + fs.rmSync(tempRoot, { force: true, recursive: true }); + } +}); + +test("loop summaries do not parse lifecycle mode from state-of-epic prose", () => { + const loopSource = fs.readFileSync(path.join(repoRoot, "plugins", "epic-loop", "skills", "epic-loop", "scripts", "lib", "loop.mjs"), "utf8"); + + assert.doesNotMatch(loopSource, /readStateLine\(text,\s*["']Current mode["']\)/u); +}); diff --git a/tests/unit/unbind-and-reminder.test.mjs b/tests/unit/unbind-and-reminder.test.mjs new file mode 100644 index 0000000..2adcdf1 --- /dev/null +++ b/tests/unit/unbind-and-reminder.test.mjs @@ -0,0 +1,351 @@ +import assert from "node:assert/strict"; +import fs from "node:fs"; +import path from "node:path"; +import { test } from "node:test"; + +import { assertSuccess, makeTempRoot, readJsonFile, runNodeScript } from "./test-utils.mjs"; + +function scaffoldEpicRoot(prefix) { + const root = makeTempRoot(prefix); + assertSuccess(runNodeScript("doctor.mjs", ["--root", root, "--platform", "codex", "--json"])); + assertSuccess(runNodeScript("init-epic.mjs", ["--root", root, "--description", "reminder and unbind fixture"])); + const slug = fs.readdirSync(path.join(root, ".epic-loop", "epics"))[0]; + return { root, slug }; +} + +function bindSession(root, slug, sessionId, mode) { + assertSuccess(runNodeScript("bind-session.mjs", ["--root", root, "--session-id", sessionId, "--slug", slug, "--mode", mode])); +} + +function runHook(root, payload) { + const result = runNodeScript("hook.mjs", ["--root", root], { + input: JSON.stringify(payload), + }); + assertSuccess(result); + return result; +} + +function promptPayload(root, sessionId, eventName = "UserPromptSubmit") { + return { + cwd: root, + hook_event_name: eventName, + session_id: sessionId, + turn_id: "turn-1", + }; +} + +function bindingsPath(root) { + return path.join(root, ".epic-loop", ".runtime", "session-bindings.json"); +} + +function runtimeStatePath(root, slug) { + return path.join(root, ".epic-loop", "epics", slug, ".runtime", "runtime-state.json"); +} + +function readAdditionalContext(result) { + const output = JSON.parse(result.stdout.trim()); + assert.equal(output.hookSpecificOutput.hookEventName, "UserPromptSubmit"); + return output.hookSpecificOutput.additionalContext; +} + +test("compact mode marker is injected on UserPromptSubmit for two bound shaping members", () => { + const { root, slug } = scaffoldEpicRoot("reminder-shaping-"); + + try { + bindSession(root, slug, "session-a", "shaping"); + bindSession(root, slug, "session-b", "shaping"); + + assert.equal(readAdditionalContext(runHook(root, promptPayload(root, "session-a"))), `[epic-loop] epic=${slug} mode=shaping — follow epic-loop skill mode rules`); + assert.equal(readAdditionalContext(runHook(root, promptPayload(root, "session-b"))), `[epic-loop] epic=${slug} mode=shaping — follow epic-loop skill mode rules`); + } finally { + fs.rmSync(root, { force: true, recursive: true }); + } +}); + +test("compact mode marker is injected on UserPromptSubmit for a bound review member", () => { + const { root, slug } = scaffoldEpicRoot("reminder-review-"); + + try { + bindSession(root, slug, "session-review", "review"); + + assert.equal(readAdditionalContext(runHook(root, promptPayload(root, "session-review"))), `[epic-loop] epic=${slug} mode=review — follow epic-loop skill mode rules`); + } finally { + fs.rmSync(root, { force: true, recursive: true }); + } +}); + +test("mode marker follows runtime mode changes without rebinding members", () => { + const { root, slug } = scaffoldEpicRoot("reminder-propagation-"); + + try { + bindSession(root, slug, "session-a", "shaping"); + bindSession(root, slug, "session-b", "shaping"); + + assert.equal(readAdditionalContext(runHook(root, promptPayload(root, "session-b"))), `[epic-loop] epic=${slug} mode=shaping — follow epic-loop skill mode rules`); + + assertSuccess(runNodeScript("set-epic-mode.mjs", ["--root", root, "--slug", slug, "--mode", "review"])); + + assert.equal(readAdditionalContext(runHook(root, promptPayload(root, "session-b"))), `[epic-loop] epic=${slug} mode=review — follow epic-loop skill mode rules`); + } finally { + fs.rmSync(root, { force: true, recursive: true }); + } +}); + +test("unbound sessions still produce zero stdout on UserPromptSubmit", () => { + const { root, slug } = scaffoldEpicRoot("reminder-unbound-"); + + try { + // Bind a different session so bindings exist but this session id stays unbound. + bindSession(root, slug, "session-other", "shaping"); + + const result = runHook(root, promptPayload(root, "session-never-bound")); + assert.equal(result.stdout, ""); + } finally { + fs.rmSync(root, { force: true, recursive: true }); + } +}); + +test("no reminder leaks into Stop events for a bound shaping session", () => { + const { root, slug } = scaffoldEpicRoot("reminder-stop-"); + + try { + bindSession(root, slug, "session-shaping", "shaping"); + + const result = runHook(root, promptPayload(root, "session-shaping", "Stop")); + assert.equal(result.stdout, ""); + } finally { + fs.rmSync(root, { force: true, recursive: true }); + } +}); + +test("implementation driver gets no reminder and non-driver member gets the lock marker", () => { + const { root, slug } = scaffoldEpicRoot("reminder-implementation-"); + + try { + bindSession(root, slug, "driver-session", "implementation"); + + const bindings = readJsonFile(bindingsPath(root)); + bindings.sessions["observer-session"] = { + active: true, + activated_at: "2026-07-01T00:00:00+00:00", + bound_at: "2026-07-01T00:00:00+00:00", + epic_slug: slug, + source: "explicit-session-id", + turn_id: null, + }; + fs.writeFileSync(bindingsPath(root), `${JSON.stringify(bindings, null, 2)}\n`, "utf8"); + + assert.equal(runHook(root, promptPayload(root, "driver-session")).stdout, ""); + assert.equal( + readAdditionalContext(runHook(root, promptPayload(root, "observer-session"))), + `[epic-loop] epic=${slug} mode=implementation — loop running in another session; read-only, do not edit epic artifacts`, + ); + } finally { + fs.rmSync(root, { force: true, recursive: true }); + } +}); + +test("bindings are mode-less memberships and allow multiple active members", () => { + const { root, slug } = scaffoldEpicRoot("membership-schema-"); + const otherSlug = "other-epic"; + + try { + bindSession(root, slug, "session-a", "shaping"); + bindSession(root, slug, "session-b", "shaping"); + + const bindings = readJsonFile(bindingsPath(root)); + assert.equal(bindings.active_sessions, undefined); + assert.equal(bindings.sessions["session-a"].active, true); + assert.equal(bindings.sessions["session-a"].epic_slug, slug); + assert.equal(bindings.sessions["session-a"].mode, undefined); + assert.equal(bindings.sessions["session-b"].active, true); + assert.equal(bindings.sessions["session-b"].epic_slug, slug); + assert.equal(bindings.sessions["session-b"].mode, undefined); + + assert.equal(readAdditionalContext(runHook(root, promptPayload(root, "session-a"))), `[epic-loop] epic=${slug} mode=shaping — follow epic-loop skill mode rules`); + assert.equal(readAdditionalContext(runHook(root, promptPayload(root, "session-b"))), `[epic-loop] epic=${slug} mode=shaping — follow epic-loop skill mode rules`); + + assertSuccess(runNodeScript("init-epic.mjs", ["--root", root, "--description", "Other epic", "--slug", otherSlug, "--no-gitignore"])); + bindSession(root, otherSlug, "session-a", "review"); + + const rebound = readJsonFile(bindingsPath(root)); + assert.equal(rebound.sessions["session-a"].active, true); + assert.equal(rebound.sessions["session-a"].epic_slug, otherSlug); + assert.equal(rebound.sessions["session-b"].active, true); + assert.equal(rebound.sessions["session-b"].epic_slug, slug); + assert.equal(rebound.active_sessions, undefined); + } finally { + fs.rmSync(root, { force: true, recursive: true }); + } +}); + +test("implementation binding designates and replaces the runtime driver", () => { + const { root, slug } = scaffoldEpicRoot("membership-driver-"); + + try { + bindSession(root, slug, "driver-one", "implementation"); + let runtime = readJsonFile(path.join(root, ".epic-loop", "epics", slug, ".runtime", "runtime-state.json")); + assert.equal(runtime.mode, "implementation"); + assert.equal(runtime.implementation_loop.driver_session_id, "driver-one"); + + bindSession(root, slug, "driver-two", "implementation"); + const bindings = readJsonFile(bindingsPath(root)); + runtime = readJsonFile(path.join(root, ".epic-loop", "epics", slug, ".runtime", "runtime-state.json")); + assert.equal(bindings.sessions["driver-one"].active, true); + assert.equal(bindings.sessions["driver-two"].active, true); + assert.equal(runtime.implementation_loop.driver_session_id, "driver-two"); + assert.equal(bindings.active_sessions, undefined); + } finally { + fs.rmSync(root, { force: true, recursive: true }); + } +}); + +test("unbind-session is a no-op for a never-bound session id", () => { + const { root, slug } = scaffoldEpicRoot("unbind-noop-"); + + try { + bindSession(root, slug, "session-other", "shaping"); + const before = fs.readFileSync(bindingsPath(root), "utf8"); + + const result = runNodeScript("unbind-session.mjs", ["--root", root, "--session-id", "session-never-bound"]); + assertSuccess(result); + assert.equal(result.stdout.trim(), "Session session-never-bound is not currently bound to any epic."); + assert.equal(fs.readFileSync(bindingsPath(root), "utf8"), before); + } finally { + fs.rmSync(root, { force: true, recursive: true }); + } +}); + +test("unbind-session deactivates a bound session with the default reason", () => { + const { root, slug } = scaffoldEpicRoot("unbind-default-"); + + try { + bindSession(root, slug, "session-bound", "shaping"); + + const result = runNodeScript("unbind-session.mjs", ["--root", root, "--session-id", "session-bound"]); + assertSuccess(result); + assert.equal(result.stdout.trim(), `Session session-bound unbound from ${slug} (shaping).`); + + const bindings = readJsonFile(bindingsPath(root)); + const entry = bindings.sessions["session-bound"]; + assert.equal(entry.active, false); + assert.equal(typeof entry.deactivated_at, "string"); + assert.equal(entry.deactivated_reason, "user-requested-unbind"); + assert.equal(entry.mode, undefined); + assert.equal(bindings.active_sessions, undefined); + + const unbindRecord = readJsonFile(path.join(root, ".epic-loop", "epics", slug, ".runtime", "sessions", "session-bound", "unbind.json")); + assert.equal(unbindRecord.epic_slug, slug); + assert.equal(unbindRecord.mode, "shaping"); + assert.equal(unbindRecord.reason, "user-requested-unbind"); + assert.equal(unbindRecord.session_id, "session-bound"); + assert.equal(typeof unbindRecord.unbound_at, "string"); + } finally { + fs.rmSync(root, { force: true, recursive: true }); + } +}); + +test("unbind-session records a custom --reason in the binding and the unbind mirror", () => { + const { root, slug } = scaffoldEpicRoot("unbind-reason-"); + + try { + bindSession(root, slug, "session-bound", "review"); + + const result = runNodeScript("unbind-session.mjs", ["--root", root, "--session-id", "session-bound", "--reason", "quick unrelated check"]); + assertSuccess(result); + + const bindings = readJsonFile(bindingsPath(root)); + assert.equal(bindings.sessions["session-bound"].deactivated_reason, "quick unrelated check"); + + const unbindRecord = readJsonFile(path.join(root, ".epic-loop", "epics", slug, ".runtime", "sessions", "session-bound", "unbind.json")); + assert.equal(unbindRecord.reason, "quick unrelated check"); + } finally { + fs.rmSync(root, { force: true, recursive: true }); + } +}); + +test("a second unbind of the same session is a no-op", () => { + const { root, slug } = scaffoldEpicRoot("unbind-twice-"); + + try { + bindSession(root, slug, "session-bound", "shaping"); + assertSuccess(runNodeScript("unbind-session.mjs", ["--root", root, "--session-id", "session-bound"])); + + const result = runNodeScript("unbind-session.mjs", ["--root", root, "--session-id", "session-bound"]); + assertSuccess(result); + assert.equal(result.stdout.trim(), "Session session-bound is not currently bound to any epic."); + } finally { + fs.rmSync(root, { force: true, recursive: true }); + } +}); + +test("hooks are silent for a session id after it is unbound", () => { + const { root, slug } = scaffoldEpicRoot("unbind-silence-"); + + try { + bindSession(root, slug, "session-bound", "shaping"); + + const before = runHook(root, promptPayload(root, "session-bound")); + assert.equal(before.stdout.includes("hookSpecificOutput"), true); + + assertSuccess(runNodeScript("unbind-session.mjs", ["--root", root, "--session-id", "session-bound"])); + + const after = runHook(root, promptPayload(root, "session-bound")); + assert.equal(after.stdout, ""); + } finally { + fs.rmSync(root, { force: true, recursive: true }); + } +}); + +test("mode reminder silently skips malformed or unsupported runtime state", () => { + const { root, slug } = scaffoldEpicRoot("reminder-runtime-skip-"); + + try { + bindSession(root, slug, "session-bound", "shaping"); + + fs.rmSync(runtimeStatePath(root, slug), { force: true }); + assert.equal(runHook(root, promptPayload(root, "session-bound")).stdout, ""); + + fs.writeFileSync(runtimeStatePath(root, slug), "{", "utf8"); + assert.equal(runHook(root, promptPayload(root, "session-bound")).stdout, ""); + + fs.writeFileSync(runtimeStatePath(root, slug), `${JSON.stringify({ mode: "paused" }, null, 2)}\n`, "utf8"); + assert.equal(runHook(root, promptPayload(root, "session-bound")).stdout, ""); + } finally { + fs.rmSync(root, { force: true, recursive: true }); + } +}); + +test("unbinding the implementation driver idles the loop but unbinding a non-driver does not", () => { + const { root, slug } = scaffoldEpicRoot("unbind-driver-"); + const runtimePath = path.join(root, ".epic-loop", "epics", slug, ".runtime", "runtime-state.json"); + + try { + bindSession(root, slug, "driver-session", "implementation"); + + const bindings = readJsonFile(bindingsPath(root)); + bindings.sessions["observer-session"] = { + active: true, + activated_at: "2026-07-01T00:00:00+00:00", + bound_at: "2026-07-01T00:00:00+00:00", + epic_slug: slug, + source: "explicit-session-id", + turn_id: null, + }; + fs.writeFileSync(bindingsPath(root), `${JSON.stringify(bindings, null, 2)}\n`, "utf8"); + + assertSuccess(runNodeScript("unbind-session.mjs", ["--root", root, "--session-id", "observer-session"])); + let runtime = readJsonFile(runtimePath); + assert.equal(runtime.implementation_loop.status, "running"); + assert.equal(runtime.implementation_loop.driver_session_id, "driver-session"); + + assertSuccess(runNodeScript("unbind-session.mjs", ["--root", root, "--session-id", "driver-session"])); + runtime = readJsonFile(runtimePath); + assert.equal(runtime.implementation_loop.status, "idle"); + assert.equal(runtime.implementation_loop.next_role, "idle"); + assert.equal(runtime.implementation_loop.driver_session_id, null); + assert.equal(runtime.implementation_loop.last_reason, "implementation-driver-unbound"); + } finally { + fs.rmSync(root, { force: true, recursive: true }); + } +});