From 36462452c6b7ac2bc9d58e85cf5fef9663375e37 Mon Sep 17 00:00:00 2001 From: Oleg Proskurin Date: Mon, 6 Jul 2026 00:49:06 +0700 Subject: [PATCH 01/24] Record Codex additionalContext POC evidence --- .epic-loop/epics/mode-reminder/decision-log.md | 4 ++-- .epic-loop/epics/mode-reminder/implementation-log.md | 6 ++++++ .epic-loop/epics/mode-reminder/risk-register.md | 2 +- .epic-loop/epics/mode-reminder/state-of-epic.md | 6 +++--- .epic-loop/epics/mode-reminder/tracker.md | 4 ++-- 5 files changed, 14 insertions(+), 8 deletions(-) diff --git a/.epic-loop/epics/mode-reminder/decision-log.md b/.epic-loop/epics/mode-reminder/decision-log.md index 86f56b5..675c215 100644 --- a/.epic-loop/epics/mode-reminder/decision-log.md +++ b/.epic-loop/epics/mode-reminder/decision-log.md @@ -2,8 +2,8 @@ ## 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. Claude Code real POC is still pending. +- 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. - 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. diff --git a/.epic-loop/epics/mode-reminder/implementation-log.md b/.epic-loop/epics/mode-reminder/implementation-log.md index bd039f1..8374c73 100644 --- a/.epic-loop/epics/mode-reminder/implementation-log.md +++ b/.epic-loop/epics/mode-reminder/implementation-log.md @@ -11,3 +11,9 @@ - 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. diff --git a/.epic-loop/epics/mode-reminder/risk-register.md b/.epic-loop/epics/mode-reminder/risk-register.md index 349312f..beada3b 100644 --- a/.epic-loop/epics/mode-reminder/risk-register.md +++ b/.epic-loop/epics/mode-reminder/risk-register.md @@ -2,7 +2,7 @@ | 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. | Codex interactive CLI POC passed on 2026-07-06; run the remaining real Claude Code POC before implementation proceeds past design. | partially-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 | diff --git a/.epic-loop/epics/mode-reminder/state-of-epic.md b/.epic-loop/epics/mode-reminder/state-of-epic.md index ec4fed4..12ff2b2 100644 --- a/.epic-loop/epics/mode-reminder/state-of-epic.md +++ b/.epic-loop/epics/mode-reminder/state-of-epic.md @@ -3,7 +3,7 @@ Epic: Epic-Loop Mode Reminder And Session Unbind Slug: `mode-reminder` Created: 2026-07-04T19:18:09+00:00 -Current mode: shaping +Current mode: implementation Active phase: Phase 3 - Validate With Real Proofs Of Concept On Codex And Claude Code Active task: TBD @@ -13,7 +13,7 @@ Active task: TBD - 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. +- Codex parity for `additionalContext` is now proven for the interactive Codex CLI/TUI path in a trusted project. The POC used a temporary `UserPromptSubmit` hook and token `POC-CODEX-ADDITIONAL-CONTEXT-TRUSTED-1783273589-8273`; Codex visibly rendered `UserPromptSubmit hook (completed)` plus `hook context: ...8273`, and the model returned the exact token. Claude Code still needs its own real POC before implementation. - The unbind trigger phrase/intent rule is intentionally undecided; Phase 4 is where it gets designed and wired into `SKILL.md`. ## Blockers @@ -22,4 +22,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). +- Stop this Codex session for Phase 3's platform-switch handoff. Resume `mode-reminder` in a real Claude Code session and run the Claude Code `additionalContext` POC next; do not treat the Codex proof as a substitute for Claude Code evidence. diff --git a/.epic-loop/epics/mode-reminder/tracker.md b/.epic-loop/epics/mode-reminder/tracker.md index 7d2df04..bfb9eeb 100644 --- a/.epic-loop/epics/mode-reminder/tracker.md +++ b/.epic-loop/epics/mode-reminder/tracker.md @@ -46,9 +46,9 @@ 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: doing -- [ ] 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`. From f2a145dabc30a4b21171f4ef7d6b8c836057fcfa Mon Sep 17 00:00:00 2001 From: Oleg Proskurin Date: Mon, 6 Jul 2026 01:02:23 +0700 Subject: [PATCH 02/24] docs: record Codex-to-Claude-Code platform handoff for mode-reminder POC The Codex additionalContext POC is done; the epic is now resumed in a real Claude Code session for the second POC. Close the Phase 3 handoff task and point the active task at the Claude Code POC. Co-Authored-By: Claude Fable 5 --- .epic-loop/epics/mode-reminder/implementation-log.md | 7 +++++++ .epic-loop/epics/mode-reminder/state-of-epic.md | 4 ++-- .epic-loop/epics/mode-reminder/tracker.md | 4 ++-- 3 files changed, 11 insertions(+), 4 deletions(-) diff --git a/.epic-loop/epics/mode-reminder/implementation-log.md b/.epic-loop/epics/mode-reminder/implementation-log.md index 8374c73..8db2b35 100644 --- a/.epic-loop/epics/mode-reminder/implementation-log.md +++ b/.epic-loop/epics/mode-reminder/implementation-log.md @@ -17,3 +17,10 @@ - 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. diff --git a/.epic-loop/epics/mode-reminder/state-of-epic.md b/.epic-loop/epics/mode-reminder/state-of-epic.md index 12ff2b2..34a460d 100644 --- a/.epic-loop/epics/mode-reminder/state-of-epic.md +++ b/.epic-loop/epics/mode-reminder/state-of-epic.md @@ -5,7 +5,7 @@ Slug: `mode-reminder` Created: 2026-07-04T19:18:09+00:00 Current mode: implementation Active phase: Phase 3 - Validate With Real Proofs Of Concept On Codex And Claude Code -Active task: TBD +Active task: Phase 3 Task 3 - Prove, with a real running Claude Code session, that the same `hookSpecificOutput.additionalContext` mechanism works, and capture how Claude Code actually renders it ## Current State @@ -22,4 +22,4 @@ Active task: TBD ## Next Action -- Stop this Codex session for Phase 3's platform-switch handoff. Resume `mode-reminder` in a real Claude Code session and run the Claude Code `additionalContext` POC next; do not treat the Codex proof as a substitute for Claude Code evidence. +- The platform-switch handoff happened: `mode-reminder` is now resumed in a real Claude Code session (2026-07-06). Run the Claude Code `additionalContext` POC next; do not treat the Codex proof as a substitute for Claude Code evidence. diff --git a/.epic-loop/epics/mode-reminder/tracker.md b/.epic-loop/epics/mode-reminder/tracker.md index bfb9eeb..8ba4c66 100644 --- a/.epic-loop/epics/mode-reminder/tracker.md +++ b/.epic-loop/epics/mode-reminder/tracker.md @@ -54,13 +54,13 @@ Epic: Epic-Loop Mode Reminder And Session Unbind - 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. +- [ ] Kind: verification | Status: doing | 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. From 38a2cb29b121a8f2a700d024c8375ccb1b5768cf Mon Sep 17 00:00:00 2001 From: Oleg Proskurin Date: Mon, 6 Jul 2026 01:06:43 +0700 Subject: [PATCH 03/24] Record Claude Code additionalContext POC evidence and close Phase 3 A real headless claude -p turn in a scratch project proved that a project-local UserPromptSubmit hook returning hookSpecificOutput.additionalContext reaches model context: the model echoed the injected token exactly, and the transcript JSONL carries the injection as a hook_additional_context attachment record. No trust gating was hit on the headless path; interactive TUI rendering was not observed and is recorded as a known limitation. With both platform POCs green, the mechanism-validation risk is marked mitigated and Phase 3 is closed; Phase 4 (unbind trigger phrase design) is next. Co-Authored-By: Claude Fable 5 --- .epic-loop/epics/mode-reminder/decision-log.md | 3 ++- .epic-loop/epics/mode-reminder/implementation-log.md | 5 +++++ .epic-loop/epics/mode-reminder/risk-register.md | 4 ++-- .epic-loop/epics/mode-reminder/state-of-epic.md | 8 ++++---- .epic-loop/epics/mode-reminder/tracker.md | 4 ++-- 5 files changed, 15 insertions(+), 9 deletions(-) diff --git a/.epic-loop/epics/mode-reminder/decision-log.md b/.epic-loop/epics/mode-reminder/decision-log.md index 675c215..e10cf2e 100644 --- a/.epic-loop/epics/mode-reminder/decision-log.md +++ b/.epic-loop/epics/mode-reminder/decision-log.md @@ -2,7 +2,8 @@ ## 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. 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. Claude Code real POC is still pending. +- 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. diff --git a/.epic-loop/epics/mode-reminder/implementation-log.md b/.epic-loop/epics/mode-reminder/implementation-log.md index 8db2b35..9e21a7f 100644 --- a/.epic-loop/epics/mode-reminder/implementation-log.md +++ b/.epic-loop/epics/mode-reminder/implementation-log.md @@ -24,3 +24,8 @@ - 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) diff --git a/.epic-loop/epics/mode-reminder/risk-register.md b/.epic-loop/epics/mode-reminder/risk-register.md index beada3b..81bbd91 100644 --- a/.epic-loop/epics/mode-reminder/risk-register.md +++ b/.epic-loop/epics/mode-reminder/risk-register.md @@ -2,8 +2,8 @@ | 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. | Codex interactive CLI POC passed on 2026-07-06; run the remaining real Claude Code POC before implementation proceeds past design. | partially-mitigated | +| `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 | +| 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 | diff --git a/.epic-loop/epics/mode-reminder/state-of-epic.md b/.epic-loop/epics/mode-reminder/state-of-epic.md index 34a460d..082c62b 100644 --- a/.epic-loop/epics/mode-reminder/state-of-epic.md +++ b/.epic-loop/epics/mode-reminder/state-of-epic.md @@ -4,8 +4,8 @@ Epic: Epic-Loop Mode Reminder And Session Unbind Slug: `mode-reminder` Created: 2026-07-04T19:18:09+00:00 Current mode: implementation -Active phase: Phase 3 - Validate With Real Proofs Of Concept On Codex And Claude Code -Active task: Phase 3 Task 3 - Prove, with a real running Claude Code session, that the same `hookSpecificOutput.additionalContext` mechanism works, and capture how Claude Code actually renders it +Active phase: Phase 4 - Design The Unbind Trigger Phrase And Update The Skill +Active task: TBD ## Current State @@ -13,7 +13,7 @@ Active task: Phase 3 Task 3 - Prove, with a real running Claude Code session, th - 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 now proven for the interactive Codex CLI/TUI path in a trusted project. The POC used a temporary `UserPromptSubmit` hook and token `POC-CODEX-ADDITIONAL-CONTEXT-TRUSTED-1783273589-8273`; Codex visibly rendered `UserPromptSubmit hook (completed)` plus `hook context: ...8273`, and the model returned the exact token. Claude Code still needs its own real POC before implementation. +- 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`. - The unbind trigger phrase/intent rule is intentionally undecided; Phase 4 is where it gets designed and wired into `SKILL.md`. ## Blockers @@ -22,4 +22,4 @@ Active task: Phase 3 Task 3 - Prove, with a real running Claude Code session, th ## Next Action -- The platform-switch handoff happened: `mode-reminder` is now resumed in a real Claude Code session (2026-07-06). Run the Claude Code `additionalContext` POC next; do not treat the Codex proof as a substitute for Claude Code evidence. +- Start Phase 4: decide the canonical ultra-short unbind trigger phrase and the intent-recognition rule, then update `SKILL.md`'s frontmatter `description` and body accordingly. diff --git a/.epic-loop/epics/mode-reminder/tracker.md b/.epic-loop/epics/mode-reminder/tracker.md index 8ba4c66..b70878d 100644 --- a/.epic-loop/epics/mode-reminder/tracker.md +++ b/.epic-loop/epics/mode-reminder/tracker.md @@ -46,7 +46,7 @@ Epic: Epic-Loop Mode Reminder And Session Unbind ### Phase 3: Validate With Real Proofs Of Concept On Codex And Claude Code -- Phase status: doing +- Phase status: done - [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). @@ -60,7 +60,7 @@ Epic: Epic-Loop Mode Reminder And Session Unbind - 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: doing | 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. From a6810d0b0a21a2b0e6686efbbe842a27077a7e99 Mon Sep 17 00:00:00 2001 From: Oleg Proskurin Date: Mon, 6 Jul 2026 01:13:10 +0700 Subject: [PATCH 04/24] docs(skill): document 'unbind epic' trigger and unbind-session contract in SKILL.md The frontmatter description now lists detaching the current session from its epic (canonical phrase 'unbind epic', or recognized intent to work outside the epic) among the skill triggers. A new Hooks subsection documents when to unbind, the unbind-session.mjs --current/--session-id command shapes with optional --reason, the no-op guarantee for unbound sessions, and rebinding via the normal resume flow. The script itself lands in Phase 5; a cross-doc contract audit task is added as Phase 4's verification gate. Co-Authored-By: Claude Fable 5 --- .epic-loop/epics/mode-reminder/decision-log.md | 3 ++- .epic-loop/epics/mode-reminder/implementation-log.md | 5 +++++ .epic-loop/epics/mode-reminder/state-of-epic.md | 4 ++-- .epic-loop/epics/mode-reminder/tracker.md | 10 ++++++++-- plugins/epic-loop/skills/epic-loop/SKILL.md | 12 +++++++++++- 5 files changed, 28 insertions(+), 6 deletions(-) diff --git a/.epic-loop/epics/mode-reminder/decision-log.md b/.epic-loop/epics/mode-reminder/decision-log.md index e10cf2e..7c28688 100644 --- a/.epic-loop/epics/mode-reminder/decision-log.md +++ b/.epic-loop/epics/mode-reminder/decision-log.md @@ -6,7 +6,8 @@ - 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. diff --git a/.epic-loop/epics/mode-reminder/implementation-log.md b/.epic-loop/epics/mode-reminder/implementation-log.md index 9e21a7f..28ad12e 100644 --- a/.epic-loop/epics/mode-reminder/implementation-log.md +++ b/.epic-loop/epics/mode-reminder/implementation-log.md @@ -29,3 +29,8 @@ - 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 diff --git a/.epic-loop/epics/mode-reminder/state-of-epic.md b/.epic-loop/epics/mode-reminder/state-of-epic.md index 082c62b..950f1fb 100644 --- a/.epic-loop/epics/mode-reminder/state-of-epic.md +++ b/.epic-loop/epics/mode-reminder/state-of-epic.md @@ -5,7 +5,7 @@ Slug: `mode-reminder` Created: 2026-07-04T19:18:09+00:00 Current mode: implementation Active phase: Phase 4 - Design The Unbind Trigger Phrase And Update The Skill -Active task: TBD +Active task: Phase 4 Task 2 (verification) - Audit the documented unbind contract for cross-doc consistency and confirm the updated skill package is intact ## Current State @@ -22,4 +22,4 @@ Active task: TBD ## Next Action -- Start Phase 4: decide the canonical ultra-short unbind trigger phrase and the intent-recognition rule, then update `SKILL.md`'s frontmatter `description` and body accordingly. +- Run Phase 4's verification task (cross-doc unbind-contract audit), then close Phase 4 and move to Phase 5 (implement `buildModeReminder` + `unbind-session.mjs` and their unit tests). The canonical phrase `unbind epic` is decided and documented in `SKILL.md`. diff --git a/.epic-loop/epics/mode-reminder/tracker.md b/.epic-loop/epics/mode-reminder/tracker.md index b70878d..6b01953 100644 --- a/.epic-loop/epics/mode-reminder/tracker.md +++ b/.epic-loop/epics/mode-reminder/tracker.md @@ -68,14 +68,20 @@ Epic: Epic-Loop Mode Reminder And Session Unbind ### Phase 4: Design The Unbind Trigger Phrase And Update The Skill -- Phase status: todo +- Phase status: doing -- [ ] 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`. - Docs: `docs/problem-framing.md`, `decision-log.md`. +- [ ] Kind: verification | Status: todo | Verify Phase 4's combined result: audit the documented unbind contract for cross-doc consistency and confirm the updated skill package is intact. + - Outcome: Confirmed (or precisely reported) that the unbind contract reads identically across `plugins/epic-loop/skills/epic-loop/SKILL.md`, `docs/mode-reminder-design.md` section 2, and `decision-log.md` — same flags (`--current`/`--session-id`, optional `--reason`, no `--slug`/`--mode`), same no-op and rebind semantics, phrase `unbind epic` verbatim in the frontmatter description. + - Surface: read-only audit of the named docs plus package-level checks; no code changes. + - Acceptance: Method — side-by-side contract comparison of the three sources plus `pnpm run validate`, `pnpm run test:unit`, YAML-parse of the SKILL.md frontmatter, and `diff -rq` of runtime copies vs `plugins/`; evidence — a discrepancy list (empty if consistent) with file/line pointers and command outputs; cleanup — none required (read-only). + - Docs: `docs/mode-reminder-design.md`, `decision-log.md`. + ### Phase 5: Implement And Write Tests - Phase status: todo diff --git a/plugins/epic-loop/skills/epic-loop/SKILL.md b/plugins/epic-loop/skills/epic-loop/SKILL.md index 765c1f5..baa308c 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 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; resuming an existing epic by slug; or detaching the current session from its epic when the user says `unbind epic` or otherwise asks to work outside the epic. 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. --- # Epic Loop @@ -355,4 +355,14 @@ For Codex, `--current` uses the existing Codex hook capture/session fallback. Fo 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. +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 "" +``` + +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. From 98e3e7817df6fa39128c0f256ded0a2e2dcf209b Mon Sep 17 00:00:00 2001 From: Oleg Proskurin Date: Mon, 6 Jul 2026 01:16:25 +0700 Subject: [PATCH 05/24] Close Phase 4 after clean cross-doc audit of the unbind contract The verification pass compared the documented unbind-session.mjs contract across SKILL.md, the Phase 2 design doc, and the decision log: zero discrepancies (flags, no-op guarantee, silent-hooks-after-unbind, rebind-via-resume, verbatim 'unbind epic' phrase). Frontmatter YAML parses, runtime copies are diff-clean, validate and all 33 unit tests pass. Also restores the hand-added Phase 4 verification task that close-phase.mjs re-rendered away from tracker.md (tracker is the source of truth; the structured roadmap state never knew the task) and records the default --reason follow-up for Phase 5 tests. Co-Authored-By: Claude Fable 5 --- .epic-loop/epics/mode-reminder/implementation-log.md | 5 +++++ .epic-loop/epics/mode-reminder/state-of-epic.md | 8 ++++---- .epic-loop/epics/mode-reminder/tracker.md | 10 +++++----- 3 files changed, 14 insertions(+), 9 deletions(-) diff --git a/.epic-loop/epics/mode-reminder/implementation-log.md b/.epic-loop/epics/mode-reminder/implementation-log.md index 28ad12e..8c8cf2c 100644 --- a/.epic-loop/epics/mode-reminder/implementation-log.md +++ b/.epic-loop/epics/mode-reminder/implementation-log.md @@ -34,3 +34,8 @@ - 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 diff --git a/.epic-loop/epics/mode-reminder/state-of-epic.md b/.epic-loop/epics/mode-reminder/state-of-epic.md index 950f1fb..610430e 100644 --- a/.epic-loop/epics/mode-reminder/state-of-epic.md +++ b/.epic-loop/epics/mode-reminder/state-of-epic.md @@ -4,8 +4,8 @@ Epic: Epic-Loop Mode Reminder And Session Unbind Slug: `mode-reminder` Created: 2026-07-04T19:18:09+00:00 Current mode: implementation -Active phase: Phase 4 - Design The Unbind Trigger Phrase And Update The Skill -Active task: Phase 4 Task 2 (verification) - Audit the documented unbind contract for cross-doc consistency and confirm the updated skill package is intact +Active phase: Phase 5 - Implement And Write Tests +Active task: TBD ## Current State @@ -14,7 +14,7 @@ Active task: Phase 4 Task 2 (verification) - Audit the documented unbind contrac - 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. - 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`. -- The unbind trigger phrase/intent rule is intentionally undecided; Phase 4 is where it gets designed and wired into `SKILL.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`. ## Blockers @@ -22,4 +22,4 @@ Active task: Phase 4 Task 2 (verification) - Audit the documented unbind contrac ## Next Action -- Run Phase 4's verification task (cross-doc unbind-contract audit), then close Phase 4 and move to Phase 5 (implement `buildModeReminder` + `unbind-session.mjs` and their unit tests). The canonical phrase `unbind epic` is decided and documented in `SKILL.md`. +- Start Phase 5: implement `buildModeReminder` in `lib/hooks.mjs` and `scripts/unbind-session.mjs` per `docs/mode-reminder-design.md`, then the unit tests (including the default `--reason` value `user-requested-unbind` follow-up noted in `implementation-log.md`). diff --git a/.epic-loop/epics/mode-reminder/tracker.md b/.epic-loop/epics/mode-reminder/tracker.md index 6b01953..0f2eca9 100644 --- a/.epic-loop/epics/mode-reminder/tracker.md +++ b/.epic-loop/epics/mode-reminder/tracker.md @@ -68,7 +68,7 @@ Epic: Epic-Loop Mode Reminder And Session Unbind ### Phase 4: Design The Unbind Trigger Phrase And Update The Skill -- Phase status: doing +- Phase status: done - [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. @@ -76,10 +76,10 @@ Epic: Epic-Loop Mode Reminder And Session Unbind - 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`. - Docs: `docs/problem-framing.md`, `decision-log.md`. -- [ ] Kind: verification | Status: todo | Verify Phase 4's combined result: audit the documented unbind contract for cross-doc consistency and confirm the updated skill package is intact. - - Outcome: Confirmed (or precisely reported) that the unbind contract reads identically across `plugins/epic-loop/skills/epic-loop/SKILL.md`, `docs/mode-reminder-design.md` section 2, and `decision-log.md` — same flags (`--current`/`--session-id`, optional `--reason`, no `--slug`/`--mode`), same no-op and rebind semantics, phrase `unbind epic` verbatim in the frontmatter description. - - Surface: read-only audit of the named docs plus package-level checks; no code changes. - - Acceptance: Method — side-by-side contract comparison of the three sources plus `pnpm run validate`, `pnpm run test:unit`, YAML-parse of the SKILL.md frontmatter, and `diff -rq` of runtime copies vs `plugins/`; evidence — a discrepancy list (empty if consistent) with file/line pointers and command outputs; cleanup — none required (read-only). +- [x] Kind: verification | Status: done | Verify Phase 4's combined result: audit the documented unbind contract for cross-doc consistency and confirm the updated skill package is intact. + - Outcome: Zero discrepancies found across `plugins/epic-loop/skills/epic-loop/SKILL.md`, `docs/mode-reminder-design.md` section 2, and `decision-log.md` (flags `--current`/`--session-id`/optional `--reason`, no `--slug`/`--mode`, no-op and rebind semantics, phrase `unbind epic` verbatim in the frontmatter). + - Surface: read-only audit plus package checks; no code changes. + - Acceptance: Met — side-by-side contract comparison clean; frontmatter YAML valid; runtime copies diff-clean; `pnpm run validate` and 33/33 unit tests green. (Note: this task lives only in `tracker.md`; the structured roadmap state does not know it, and `close-phase.mjs` once re-rendered it away — restored by hand, tracker is the source of truth.) - Docs: `docs/mode-reminder-design.md`, `decision-log.md`. ### Phase 5: Implement And Write Tests From 64b6c819d69401da401267a52ac1f268455aa219 Mon Sep 17 00:00:00 2001 From: Oleg Proskurin Date: Mon, 6 Jul 2026 01:22:34 +0700 Subject: [PATCH 06/24] feat(skill): per-turn mode reminder injection and unbind-session script buildModeReminder in lib/hooks.mjs injects a one-line [epic-loop] reminder via hookSpecificOutput.additionalContext on UserPromptSubmit for sessions bound in shaping or review mode, sourced solely from the already-loaded binding; handleHook composes it with the implementation continuation through a single ?? fallback. New unbind-session.mjs (unbindSession in lib/epics.mjs) detaches the resolved session from its active binding: --current/--session-id detection identical to bind-session, optional --reason (default user-requested-unbind), active:false + deactivated_at + additive deactivated_reason, cleans the active_sessions pointer, mirrors unbind.json into the epic runtime, and is a no-op (exit 0) for unbound sessions. Unbound sessions still produce zero hook output; implementation-mode Stop behavior unchanged. Smoke-tested against a temp root; runtime copies deliberately not re-synced until the Phase 5 unit tests pass. Co-Authored-By: Claude Fable 5 --- .../epics/mode-reminder/implementation-log.md | 10 +++ .../epics/mode-reminder/state-of-epic.md | 2 +- .epic-loop/epics/mode-reminder/tracker.md | 20 +++--- .../skills/epic-loop/scripts/lib/epics.mjs | 68 +++++++++++++++++++ .../skills/epic-loop/scripts/lib/hooks.mjs | 27 +++++++- .../epic-loop/scripts/unbind-session.mjs | 11 +++ 6 files changed, 127 insertions(+), 11 deletions(-) create mode 100644 plugins/epic-loop/skills/epic-loop/scripts/unbind-session.mjs diff --git a/.epic-loop/epics/mode-reminder/implementation-log.md b/.epic-loop/epics/mode-reminder/implementation-log.md index 8c8cf2c..582485f 100644 --- a/.epic-loop/epics/mode-reminder/implementation-log.md +++ b/.epic-loop/epics/mode-reminder/implementation-log.md @@ -39,3 +39,13 @@ - 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 diff --git a/.epic-loop/epics/mode-reminder/state-of-epic.md b/.epic-loop/epics/mode-reminder/state-of-epic.md index 610430e..cf3bf94 100644 --- a/.epic-loop/epics/mode-reminder/state-of-epic.md +++ b/.epic-loop/epics/mode-reminder/state-of-epic.md @@ -5,7 +5,7 @@ Slug: `mode-reminder` Created: 2026-07-04T19:18:09+00:00 Current mode: implementation Active phase: Phase 5 - Implement And Write Tests -Active task: TBD +Active task: Phase 5 Task 2 - 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) ## Current State diff --git a/.epic-loop/epics/mode-reminder/tracker.md b/.epic-loop/epics/mode-reminder/tracker.md index 0f2eca9..442c9af 100644 --- a/.epic-loop/epics/mode-reminder/tracker.md +++ b/.epic-loop/epics/mode-reminder/tracker.md @@ -76,23 +76,17 @@ Epic: Epic-Loop Mode Reminder And Session Unbind - 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`. - Docs: `docs/problem-framing.md`, `decision-log.md`. -- [x] Kind: verification | Status: done | Verify Phase 4's combined result: audit the documented unbind contract for cross-doc consistency and confirm the updated skill package is intact. - - Outcome: Zero discrepancies found across `plugins/epic-loop/skills/epic-loop/SKILL.md`, `docs/mode-reminder-design.md` section 2, and `decision-log.md` (flags `--current`/`--session-id`/optional `--reason`, no `--slug`/`--mode`, no-op and rebind semantics, phrase `unbind epic` verbatim in the frontmatter). - - Surface: read-only audit plus package checks; no code changes. - - Acceptance: Met — side-by-side contract comparison clean; frontmatter YAML valid; runtime copies diff-clean; `pnpm run validate` and 33/33 unit tests green. (Note: this task lives only in `tracker.md`; the structured roadmap state does not know it, and `close-phase.mjs` once re-rendered it away — restored by hand, tracker is the source of truth.) - - Docs: `docs/mode-reminder-design.md`, `decision-log.md`. - ### Phase 5: Implement And Write Tests -- Phase status: todo +- Phase status: doing -- [ ] 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). +- [ ] Kind: verification | Status: doing | 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. @@ -108,3 +102,11 @@ Epic: Epic-Loop Mode Reminder And Session Unbind - Acceptance: `pnpm run test:unit` and `pnpm run validate` both exit clean. - Docs: `implementation-log.md`. +## Follow-Up Tasks + +- [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 here 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 + 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..e396040 100644 --- a/plugins/epic-loop/skills/epic-loop/scripts/lib/epics.mjs +++ b/plugins/epic-loop/skills/epic-loop/scripts/lib/epics.mjs @@ -286,6 +286,74 @@ export function bindSession(flags = {}) { } } +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 activeSessions = + normalizedBindings.active_sessions && typeof normalizedBindings.active_sessions === "object" && !Array.isArray(normalizedBindings.active_sessions) + ? normalizedBindings.active_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 mode = String(binding.mode); + const unboundAt = nowIso(); + const activeKey = `${epicSlug}:${mode}`; + + sessions[sessionId] = { + ...binding, + active: false, + deactivated_at: unboundAt, + deactivated_reason: reason, + }; + + if (activeSessions[activeKey] === sessionId) { + delete activeSessions[activeKey]; + } + + normalizedBindings.active_sessions = activeSessions; + normalizedBindings.sessions = sessions; + writeJson(bindingsPath, normalizedBindings); + + 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}).`); +} + export function listEpics(flags = {}) { const root = resolveRoot(flags.root); const epicsDir = epicsRoot(root); 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..3466c3e 100644 --- a/plugins/epic-loop/skills/epic-loop/scripts/lib/hooks.mjs +++ b/plugins/epic-loop/skills/epic-loop/scripts/lib/hooks.mjs @@ -742,6 +742,31 @@ function installClaudeHooks(root, flags = {}) { console.log(`Installed project-local Claude Code epic-loop hooks: ${settingsPath}`); } +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).`, +}; + +export function buildModeReminder(payload, binding) { + if (payload.hook_event_name !== "UserPromptSubmit") { + return null; + } + if (binding.mode !== "shaping" && binding.mode !== "review") { + return null; + } + + const text = MODE_REMINDER_TEXT[binding.mode](binding.epic_slug); + + return { + hookSpecificOutput: { + hookEventName: "UserPromptSubmit", + additionalContext: text, + }, + }; +} + export function handleHook(rawInput, flags = {}) { let payload; @@ -785,7 +810,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(payload, binding); if (continuation) { console.log(JSON.stringify(continuation)); } 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); +}); From 02d6fae7a8715f81ff7dc7e8e801a4581959c5df Mon Sep 17 00:00:00 2001 From: Oleg Proskurin Date: Mon, 6 Jul 2026 01:26:10 +0700 Subject: [PATCH 07/24] test: end-to-end unit tests for mode reminder injection and unbind-session Ten node --test cases exercise the real CLIs against temp roots: shaping/review UserPromptSubmit turns get the exact [epic-loop] additionalContext reminder, while unbound sessions, Stop events, implementation-mode sessions, and freshly unbound session ids all produce strictly empty stdout. unbind-session contract is pinned: no-op message and exit 0 for never-bound/already-unbound ids with the bindings file byte-identical, active:false + deactivated_at + deactivated_reason (default user-requested-unbind and custom --reason), active_sessions pointer removal, and the unbind.json mirror fields. Full suite: 43/43. Co-Authored-By: Claude Fable 5 --- .../epics/mode-reminder/implementation-log.md | 5 + .../epics/mode-reminder/state-of-epic.md | 2 +- .epic-loop/epics/mode-reminder/tracker.md | 2 +- tests/unit/unbind-and-reminder.test.mjs | 214 ++++++++++++++++++ 4 files changed, 221 insertions(+), 2 deletions(-) create mode 100644 tests/unit/unbind-and-reminder.test.mjs diff --git a/.epic-loop/epics/mode-reminder/implementation-log.md b/.epic-loop/epics/mode-reminder/implementation-log.md index 582485f..a533953 100644 --- a/.epic-loop/epics/mode-reminder/implementation-log.md +++ b/.epic-loop/epics/mode-reminder/implementation-log.md @@ -49,3 +49,8 @@ - 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 diff --git a/.epic-loop/epics/mode-reminder/state-of-epic.md b/.epic-loop/epics/mode-reminder/state-of-epic.md index cf3bf94..610430e 100644 --- a/.epic-loop/epics/mode-reminder/state-of-epic.md +++ b/.epic-loop/epics/mode-reminder/state-of-epic.md @@ -5,7 +5,7 @@ Slug: `mode-reminder` Created: 2026-07-04T19:18:09+00:00 Current mode: implementation Active phase: Phase 5 - Implement And Write Tests -Active task: Phase 5 Task 2 - 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) +Active task: TBD ## Current State diff --git a/.epic-loop/epics/mode-reminder/tracker.md b/.epic-loop/epics/mode-reminder/tracker.md index 442c9af..d34d140 100644 --- a/.epic-loop/epics/mode-reminder/tracker.md +++ b/.epic-loop/epics/mode-reminder/tracker.md @@ -86,7 +86,7 @@ Epic: Epic-Loop Mode Reminder And Session Unbind - 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: doing | 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. diff --git a/tests/unit/unbind-and-reminder.test.mjs b/tests/unit/unbind-and-reminder.test.mjs new file mode 100644 index 0000000..ea18235 --- /dev/null +++ b/tests/unit/unbind-and-reminder.test.mjs @@ -0,0 +1,214 @@ +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"); +} + +test("mode reminder is injected on UserPromptSubmit for a bound shaping session", () => { + const { root, slug } = scaffoldEpicRoot("reminder-shaping-"); + + try { + bindSession(root, slug, "session-shaping", "shaping"); + + const result = runHook(root, promptPayload(root, "session-shaping")); + const lines = result.stdout.trim().split("\n"); + assert.equal(lines.length, 1); + + const output = JSON.parse(lines[0]); + assert.equal(output.hookSpecificOutput.hookEventName, "UserPromptSubmit"); + const text = output.hookSpecificOutput.additionalContext; + assert.equal(text.startsWith("[epic-loop]"), true); + assert.equal(text.includes(`Bound to epic "${slug}" — shaping mode.`), true); + assert.equal(text.includes("SKILL.md Shaping Rules"), true); + } finally { + fs.rmSync(root, { force: true, recursive: true }); + } +}); + +test("mode reminder is injected on UserPromptSubmit for a bound review session", () => { + const { root, slug } = scaffoldEpicRoot("reminder-review-"); + + try { + bindSession(root, slug, "session-review", "review"); + + const result = runHook(root, promptPayload(root, "session-review")); + const output = JSON.parse(result.stdout.trim()); + assert.equal(output.hookSpecificOutput.hookEventName, "UserPromptSubmit"); + const text = output.hookSpecificOutput.additionalContext; + assert.equal(text.startsWith("[epic-loop]"), true); + assert.equal(text.includes(`Bound to epic "${slug}" — review mode.`), true); + assert.equal(text.includes("SKILL.md Review Rules"), true); + } 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("no reminder is injected for a bound implementation session on UserPromptSubmit", () => { + const { root, slug } = scaffoldEpicRoot("reminder-implementation-"); + + try { + bindSession(root, slug, "session-impl", "implementation"); + + const result = runHook(root, promptPayload(root, "session-impl")); + assert.equal(result.stdout, ""); + } 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(Object.hasOwn(bindings.active_sessions, `${slug}:shaping`), false); + + 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 }); + } +}); From 6ce18f96ad23f0201c957b3c1b80f31f3c510e55 Mon Sep 17 00:00:00 2001 From: Oleg Proskurin Date: Mon, 6 Jul 2026 01:29:33 +0700 Subject: [PATCH 08/24] docs(refs): document mode reminder and unbind lifecycle in hooks-and-session-routing; close Phase 5 hooks-and-session-routing.md now lists the UserPromptSubmit additionalContext mode reminder among hook capabilities and documents user-requested unbind (command shape, deactivation fields, post-unbind silence, rebind via bind-session) in Binding Sessions, consistent with SKILL.md. Runtime copies were re-synced via self-update after the 43-test green suite (both diff-clean), completing the repo's change-hook-behavior convention and closing Phase 5. Co-Authored-By: Claude Fable 5 --- .epic-loop/epics/mode-reminder/implementation-log.md | 5 +++++ .epic-loop/epics/mode-reminder/state-of-epic.md | 8 +++++--- .epic-loop/epics/mode-reminder/tracker.md | 8 +++++++- .../epic-loop/references/hooks-and-session-routing.md | 3 +++ 4 files changed, 20 insertions(+), 4 deletions(-) diff --git a/.epic-loop/epics/mode-reminder/implementation-log.md b/.epic-loop/epics/mode-reminder/implementation-log.md index a533953..304e085 100644 --- a/.epic-loop/epics/mode-reminder/implementation-log.md +++ b/.epic-loop/epics/mode-reminder/implementation-log.md @@ -54,3 +54,8 @@ - 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 diff --git a/.epic-loop/epics/mode-reminder/state-of-epic.md b/.epic-loop/epics/mode-reminder/state-of-epic.md index 610430e..b8f0a79 100644 --- a/.epic-loop/epics/mode-reminder/state-of-epic.md +++ b/.epic-loop/epics/mode-reminder/state-of-epic.md @@ -4,7 +4,7 @@ Epic: Epic-Loop Mode Reminder And Session Unbind Slug: `mode-reminder` Created: 2026-07-04T19:18:09+00:00 Current mode: implementation -Active phase: Phase 5 - Implement And Write Tests +Active phase: Phase 6 - Run The Full Test Suite Active task: TBD ## Current State @@ -12,7 +12,7 @@ Active task: TBD - 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. +- 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`. @@ -20,6 +20,8 @@ Active task: TBD - None recorded. +- 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). + ## Next Action -- Start Phase 5: implement `buildModeReminder` in `lib/hooks.mjs` and `scripts/unbind-session.mjs` per `docs/mode-reminder-design.md`, then the unit tests (including the default `--reason` value `user-requested-unbind` follow-up noted in `implementation-log.md`). +- Run Phase 6: full `pnpm run test:unit` (43 tests expected) and `pnpm run validate`, confirm green, then close the epic's implementation with final housekeeping. diff --git a/.epic-loop/epics/mode-reminder/tracker.md b/.epic-loop/epics/mode-reminder/tracker.md index d34d140..ff629cd 100644 --- a/.epic-loop/epics/mode-reminder/tracker.md +++ b/.epic-loop/epics/mode-reminder/tracker.md @@ -78,7 +78,7 @@ Epic: Epic-Loop Mode Reminder And Session Unbind ### Phase 5: Implement And Write Tests -- Phase status: doing +- Phase status: done - [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. @@ -110,3 +110,9 @@ Epic: Epic-Loop Mode Reminder And Session Unbind - Acceptance: Met - discrepancy list empty; recorded here 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/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..6697fab 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 @@ -169,6 +169,8 @@ This deactivates the previous active session for the same epic and mode. Do not 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`) and clears the `active_sessions` pointer; 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 Hooks can: @@ -177,6 +179,7 @@ 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 mode reminder via `hookSpecificOutput.additionalContext` on `UserPromptSubmit` for sessions bound in `shaping` or `review` mode; implementation-mode and unbound sessions get no reminder - 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 From e39708a5cef0912a0b43682db87097bbac7686ea Mon Sep 17 00:00:00 2001 From: Oleg Proskurin Date: Mon, 6 Jul 2026 01:32:14 +0700 Subject: [PATCH 09/24] Close Phase 6 after green final verification of the reminder/unbind feature The full suite runs 43/43 with all ten unbind-and-reminder tests present, package validation exits clean, and both runtime skill copies are diff-identical to plugins/. All six phases of the mode-reminder epic are now closed; state-of-epic records the completed lifecycle and the remaining user-side step (branch review/merge). Co-Authored-By: Claude Fable 5 --- .epic-loop/epics/mode-reminder/implementation-log.md | 5 +++++ .epic-loop/epics/mode-reminder/state-of-epic.md | 10 +++++----- .epic-loop/epics/mode-reminder/tracker.md | 4 ++-- 3 files changed, 12 insertions(+), 7 deletions(-) diff --git a/.epic-loop/epics/mode-reminder/implementation-log.md b/.epic-loop/epics/mode-reminder/implementation-log.md index 304e085..4ce9d77 100644 --- a/.epic-loop/epics/mode-reminder/implementation-log.md +++ b/.epic-loop/epics/mode-reminder/implementation-log.md @@ -59,3 +59,8 @@ - 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 diff --git a/.epic-loop/epics/mode-reminder/state-of-epic.md b/.epic-loop/epics/mode-reminder/state-of-epic.md index b8f0a79..1ad81b4 100644 --- a/.epic-loop/epics/mode-reminder/state-of-epic.md +++ b/.epic-loop/epics/mode-reminder/state-of-epic.md @@ -4,8 +4,8 @@ Epic: Epic-Loop Mode Reminder And Session Unbind Slug: `mode-reminder` Created: 2026-07-04T19:18:09+00:00 Current mode: implementation -Active phase: Phase 6 - Run The Full Test Suite -Active task: TBD +Active phase: none - all six phases are closed +Active task: none - implementation complete ## Current State @@ -15,13 +15,13 @@ Active task: TBD - 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/`. ## Blockers - None recorded. -- 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). - ## Next Action -- Run Phase 6: full `pnpm run test:unit` (43 tests expected) and `pnpm run validate`, confirm green, then close the epic's implementation with final housekeeping. +- Implementation of this epic is complete; all six phases are closed. Remaining lifecycle steps: merge/review of `feature/mode-keeper` as the user decides, and a future review-mode pass if drift against original intent is suspected. diff --git a/.epic-loop/epics/mode-reminder/tracker.md b/.epic-loop/epics/mode-reminder/tracker.md index ff629cd..0a19ea3 100644 --- a/.epic-loop/epics/mode-reminder/tracker.md +++ b/.epic-loop/epics/mode-reminder/tracker.md @@ -94,9 +94,9 @@ 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. From 7e05b06913f16f8106f69e518ebab574a59415bf Mon Sep 17 00:00:00 2001 From: Oleg Proskurin Date: Mon, 6 Jul 2026 01:33:33 +0700 Subject: [PATCH 10/24] Record epic implementation exit: unbound-silence risk mitigated by test coverage The reminder builder sits behind the existing getSessionBinding gate and the unbind-and-reminder suite pins empty stdout for unbound, Stop, implementation-mode, and freshly unbound sessions, so the zero-output guarantee risk is closed as mitigated. The implementation log records the epic exit summary with the four feature commits and final green verification. Co-Authored-By: Claude Fable 5 --- .epic-loop/epics/mode-reminder/implementation-log.md | 5 +++++ .epic-loop/epics/mode-reminder/risk-register.md | 2 +- 2 files changed, 6 insertions(+), 1 deletion(-) diff --git a/.epic-loop/epics/mode-reminder/implementation-log.md b/.epic-loop/epics/mode-reminder/implementation-log.md index 4ce9d77..126bdf8 100644 --- a/.epic-loop/epics/mode-reminder/implementation-log.md +++ b/.epic-loop/epics/mode-reminder/implementation-log.md @@ -64,3 +64,8 @@ - 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 diff --git a/.epic-loop/epics/mode-reminder/risk-register.md b/.epic-loop/epics/mode-reminder/risk-register.md index 81bbd91..d215e5b 100644 --- a/.epic-loop/epics/mode-reminder/risk-register.md +++ b/.epic-loop/epics/mode-reminder/risk-register.md @@ -5,5 +5,5 @@ | `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 | +| 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 | From 96871b41518c2337ce6b8a140b05afda44164f5b Mon Sep 17 00:00:00 2001 From: Oleg Proskurin Date: Mon, 6 Jul 2026 11:07:55 +0700 Subject: [PATCH 11/24] docs: lint epic --- .epic-loop/epics/set-up/decision-log.md | 10 +++ .../docs/linting-and-language-policy.md | 61 ++++++++++++++++ .../epics/set-up/docs/problem-framing.md | 41 +++++++++++ .epic-loop/epics/set-up/implementation-log.md | 6 ++ .epic-loop/epics/set-up/risk-register.md | 7 ++ .epic-loop/epics/set-up/state-of-epic.md | 22 ++++++ .epic-loop/epics/set-up/tracker.md | 70 +++++++++++++++++++ 7 files changed, 217 insertions(+) create mode 100644 .epic-loop/epics/set-up/decision-log.md create mode 100644 .epic-loop/epics/set-up/docs/linting-and-language-policy.md create mode 100644 .epic-loop/epics/set-up/docs/problem-framing.md create mode 100644 .epic-loop/epics/set-up/implementation-log.md create mode 100644 .epic-loop/epics/set-up/risk-register.md create mode 100644 .epic-loop/epics/set-up/state-of-epic.md create mode 100644 .epic-loop/epics/set-up/tracker.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..9506279 --- /dev/null +++ b/.epic-loop/epics/set-up/decision-log.md @@ -0,0 +1,10 @@ +# Decision Log + +## Active Decisions + +- 2026-07-06: Use oxlint for JavaScript/ESM linting, Prettier 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..f7d7b31 --- /dev/null +++ b/.epic-loop/epics/set-up/docs/linting-and-language-policy.md @@ -0,0 +1,61 @@ +# 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, Prettier, 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. +- Prettier 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. + +## 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 +- Prettier check runs +- English-only lexical check runs + +Formatting write commands should be separate from check commands so validation remains non-mutating. 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..82b93cc --- /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 Prettier, 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 Prettier 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. +- Prettier 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..5a38a84 --- /dev/null +++ b/.epic-loop/epics/set-up/risk-register.md @@ -0,0 +1,7 @@ +# 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 Prettier 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 | 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..e76ce36 --- /dev/null +++ b/.epic-loop/epics/set-up/state-of-epic.md @@ -0,0 +1,22 @@ +# 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/Prettier configuration. +- The roadmap is ready for implementation once the user confirms the implementation loop. +- The central product requirement is adding linting, Prettier, and deterministic English-only lexical checks for committed project content. + +## 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..2f670d3 --- /dev/null +++ b/.epic-loop/epics/set-up/tracker.md @@ -0,0 +1,70 @@ +# 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, and is included in aggregate validation. + - Docs: `docs/linting-and-language-policy.md`. + +- [ ] Kind: implementation | Status: todo | Add Prettier configuration and non-mutating format validation. + - Outcome: Repository formatting is standardized and can be checked without modifying files during validation. + - Surface: `package.json`, Prettier 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, Prettier, 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: 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`. + From 0f52391f2ca8f348140c49363be84eff08a09162 Mon Sep 17 00:00:00 2001 From: Oleg Proskurin Date: Tue, 7 Jul 2026 00:59:17 +0700 Subject: [PATCH 12/24] update epics --- .../mode-reminder/docs/shaping-binding-gap.md | 32 +++++++++++++++++++ .../epics/mode-reminder/risk-register.md | 2 ++ .../epics/mode-reminder/state-of-epic.md | 9 +++--- .epic-loop/epics/mode-reminder/tracker.md | 24 ++++++++++++++ .epic-loop/epics/set-up/decision-log.md | 2 +- .../docs/linting-and-language-policy.md | 6 ++-- .../epics/set-up/docs/problem-framing.md | 6 ++-- .epic-loop/epics/set-up/risk-register.md | 2 +- .epic-loop/epics/set-up/state-of-epic.md | 4 +-- .epic-loop/epics/set-up/tracker.md | 6 ++-- 10 files changed, 76 insertions(+), 17 deletions(-) create mode 100644 .epic-loop/epics/mode-reminder/docs/shaping-binding-gap.md 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..b081d0c --- /dev/null +++ b/.epic-loop/epics/mode-reminder/docs/shaping-binding-gap.md @@ -0,0 +1,32 @@ +# 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. diff --git a/.epic-loop/epics/mode-reminder/risk-register.md b/.epic-loop/epics/mode-reminder/risk-register.md index d215e5b..4db0129 100644 --- a/.epic-loop/epics/mode-reminder/risk-register.md +++ b/.epic-loop/epics/mode-reminder/risk-register.md @@ -7,3 +7,5 @@ | 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. | 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. | Follow-up shaping captured the gap in `docs/shaping-binding-gap.md`; next step is to define automatic vs explicit shaping/review binding semantics. | open | +| 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. | Add a consistency fix so binding a session deactivates or clears prior active pointers for that same session id. | open | diff --git a/.epic-loop/epics/mode-reminder/state-of-epic.md b/.epic-loop/epics/mode-reminder/state-of-epic.md index 1ad81b4..4ab894a 100644 --- a/.epic-loop/epics/mode-reminder/state-of-epic.md +++ b/.epic-loop/epics/mode-reminder/state-of-epic.md @@ -3,9 +3,9 @@ Epic: Epic-Loop Mode Reminder And Session Unbind Slug: `mode-reminder` Created: 2026-07-04T19:18:09+00:00 -Current mode: implementation -Active phase: none - all six phases are closed -Active task: none - implementation complete +Current mode: shaping +Active phase: Phase 6 - Run The Full Test Suite +Active task: TBD ## Current State @@ -17,6 +17,7 @@ Active task: none - implementation complete - 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. ## Blockers @@ -24,4 +25,4 @@ Active task: none - implementation complete ## Next Action -- Implementation of this epic is complete; all six phases are closed. Remaining lifecycle steps: merge/review of `feature/mode-keeper` as the user decides, and a future review-mode pass if drift against original intent is suspected. +- Decide the desired binding behavior for shaping/review sessions and turn `docs/shaping-binding-gap.md` into a focused follow-up implementation plan. diff --git a/.epic-loop/epics/mode-reminder/tracker.md b/.epic-loop/epics/mode-reminder/tracker.md index 0a19ea3..e9f4577 100644 --- a/.epic-loop/epics/mode-reminder/tracker.md +++ b/.epic-loop/epics/mode-reminder/tracker.md @@ -116,3 +116,27 @@ Epic: Epic-Loop Mode Reminder And Session Unbind - 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 +- [ ] Kind: follow-up | Status: todo | Design and fix shaping/review session binding lifecycle + - Outcome: Shaping and review mode reminders are reliably active when intended, and rebinding a session cannot leave stale active-session pointers for older epic/mode pairs. + - Surface: bind-session.mjs, session binding data model, shaping/review resume flow, hook diagnostics, tests. + - Acceptance: Define automatic vs explicit binding behavior for shaping/review; ensure binding one session to a new epic/mode clears stale active pointers for the same session id; add regression tests for reminder activation and stale active_sessions cleanup. + - Docs: docs/shaping-binding-gap.md + +- [ ] Kind: follow-up | Status: todo | Clear stale active session pointers when rebinding the same session + - Outcome: Rebinding a session to a new epic/mode leaves exactly one active_sessions pointer for that session id, matching the primary sessions[session_id] binding. + - Surface: bindSession in scripts/lib/epics.mjs, session-bindings.json update semantics, unit tests around bind-session.mjs. + - Acceptance: When a session already active for old-slug:old-mode is bound to new-slug:new-mode, active_sessions no longer contains old-slug:old-mode for that same session id; existing replacement behavior for another session active on the new key still works; tests cover both cases. + - Docs: docs/shaping-binding-gap.md + +- [ ] Kind: follow-up | Status: todo | Replace verbose mode reminders with a compact epic-loop mode marker + - Outcome: UserPromptSubmit reminders use a short stable marker like '[epic-loop] epic= mode=', and SKILL.md/frontmatter treat that marker as a trigger for the existing mode rules without duplicating mode instructions. + - Surface: MODE_REMINDER_TEXT in scripts/lib/hooks.mjs, SKILL.md frontmatter description, concise SKILL.md marker note, runtime skill copies, unit tests that assert the compact text. + - Acceptance: Shaping and review reminders emit only the compact marker; implementation mode remains excluded; SKILL.md description explicitly mentions the marker pattern so the skill triggers on it; body documentation explains the marker means the session is bound to that epic/mode and should follow existing mode rules; tests updated for the exact compact text. + - Docs: docs/mode-reminder-design.md, references/hooks-and-session-routing.md + +- [ ] Kind: follow-up | Status: todo | Bind shaping and review sessions when resuming an epic by slug or path + - Outcome: When a user resumes an epic by slug or path, the current session is bound to the epic's existing non-implementation mode so mode reminders work immediately without changing the epic mode. + - Surface: SKILL.md resume flow, bind-session.mjs usage, session binding semantics, slug/path resume handling, tests for shaping/review/implementation resume cases. + - Acceptance: If the resumed epic's current mode is shaping or review, the session is bound with that same mode and the epic mode is not changed; if the resumed epic is implementation or implementation-idle, the existing explicit implementation start/resume flow remains unchanged and no automatic implementation binding occurs; slug and path inputs are covered; stale active-session pointers are not introduced. + - Docs: docs/shaping-binding-gap.md + diff --git a/.epic-loop/epics/set-up/decision-log.md b/.epic-loop/epics/set-up/decision-log.md index 9506279..57071e3 100644 --- a/.epic-loop/epics/set-up/decision-log.md +++ b/.epic-loop/epics/set-up/decision-log.md @@ -2,7 +2,7 @@ ## Active Decisions -- 2026-07-06: Use oxlint for JavaScript/ESM linting, Prettier 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: 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 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 index f7d7b31..316667b 100644 --- a/.epic-loop/epics/set-up/docs/linting-and-language-policy.md +++ b/.epic-loop/epics/set-up/docs/linting-and-language-policy.md @@ -10,14 +10,14 @@ - `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, Prettier, EditorConfig, or spelling/language config is currently present. +- 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. -- Prettier for formatting supported text/code/doc files. +- 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. ## English-Only Check @@ -55,7 +55,7 @@ The script should report filename, line, column, and offending token or short ex - package validation still runs - unit tests either run directly or remain available through a documented validation script - oxlint check runs -- Prettier check runs +- Oxfmt check runs - English-only lexical check runs Formatting write commands should be separate from check commands so validation remains non-mutating. diff --git a/.epic-loop/epics/set-up/docs/problem-framing.md b/.epic-loop/epics/set-up/docs/problem-framing.md index 82b93cc..cb4218d 100644 --- a/.epic-loop/epics/set-up/docs/problem-framing.md +++ b/.epic-loop/epics/set-up/docs/problem-framing.md @@ -2,20 +2,20 @@ ## Problem -Set up repository linting: add a linter and Prettier, plus additional repository checks such as enforcing English-only lexical usage in committed project content. +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 Prettier and can be checked in CI/local validation without changing files unexpectedly. +- 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. -- Prettier configuration and check/write scripts. +- 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. diff --git a/.epic-loop/epics/set-up/risk-register.md b/.epic-loop/epics/set-up/risk-register.md index 5a38a84..6a8a4f3 100644 --- a/.epic-loop/epics/set-up/risk-register.md +++ b/.epic-loop/epics/set-up/risk-register.md @@ -3,5 +3,5 @@ | 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 Prettier 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 | +| 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 | diff --git a/.epic-loop/epics/set-up/state-of-epic.md b/.epic-loop/epics/set-up/state-of-epic.md index e76ce36..637d4c3 100644 --- a/.epic-loop/epics/set-up/state-of-epic.md +++ b/.epic-loop/epics/set-up/state-of-epic.md @@ -9,9 +9,9 @@ Active task: TBD ## Current State -- Initial shaping captured the repository baseline: Node.js ESM, pnpm, existing syntax/package validation, and no current oxlint/Prettier configuration. +- 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, Prettier, and deterministic English-only lexical checks for committed project content. +- The central product requirement is adding linting, Oxfmt formatting, and deterministic English-only lexical checks for committed project content. ## Blockers diff --git a/.epic-loop/epics/set-up/tracker.md b/.epic-loop/epics/set-up/tracker.md index 2f670d3..6a3767b 100644 --- a/.epic-loop/epics/set-up/tracker.md +++ b/.epic-loop/epics/set-up/tracker.md @@ -34,15 +34,15 @@ Epic: Linting And English Checks - Acceptance: A lint script exists, runs without false positives on the intended source set, and is included in aggregate validation. - Docs: `docs/linting-and-language-policy.md`. -- [ ] Kind: implementation | Status: todo | Add Prettier configuration and non-mutating format validation. +- [ ] 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`, Prettier config/ignore files, supported source and documentation files. + - 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, Prettier, existing syntax/package validation. + - 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`. From 119a0c8b9b72b307fb522969446c1faff387984a Mon Sep 17 00:00:00 2001 From: Oleg Proskurin Date: Tue, 7 Jul 2026 10:31:40 +0700 Subject: [PATCH 13/24] docs(epic): accept epic-centric mode model and rebuild Phase 7 Shaping outcome for the mode-reminder follow-up: the epic holds exactly one lifecycle mode in runtime-state.json (Current mode: prose line to be dropped), bindings become mode-less epic membership with any number of member sessions receiving the compact mode marker, active_sessions is deleted by design, the implementation loop keeps one exclusive driver, and non-driver members get an advisory read-only lock marker while the loop runs. New design doc docs/epic-mode-model.md; decision-log, risk-register, tracker (Phase 7: Epic-Centric Mode Model And Compact Reminder) and state-of-epic updated. Co-Authored-By: Claude Fable 5 --- .../epics/mode-reminder/decision-log.md | 11 +- .../mode-reminder/docs/epic-mode-model.md | 156 ++++++++++++++++++ .../mode-reminder/docs/shaping-binding-gap.md | 31 ++++ .../epics/mode-reminder/risk-register.md | 6 +- .../epics/mode-reminder/state-of-epic.md | 5 +- .epic-loop/epics/mode-reminder/tracker.md | 60 ++++--- 6 files changed, 239 insertions(+), 30 deletions(-) create mode 100644 .epic-loop/epics/mode-reminder/docs/epic-mode-model.md diff --git a/.epic-loop/epics/mode-reminder/decision-log.md b/.epic-loop/epics/mode-reminder/decision-log.md index 7c28688..30a1a1b 100644 --- a/.epic-loop/epics/mode-reminder/decision-log.md +++ b/.epic-loop/epics/mode-reminder/decision-log.md @@ -15,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/shaping-binding-gap.md b/.epic-loop/epics/mode-reminder/docs/shaping-binding-gap.md index b081d0c..72738d8 100644 --- a/.epic-loop/epics/mode-reminder/docs/shaping-binding-gap.md +++ b/.epic-loop/epics/mode-reminder/docs/shaping-binding-gap.md @@ -30,3 +30,34 @@ There are two related gaps: - 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/risk-register.md b/.epic-loop/epics/mode-reminder/risk-register.md index 4db0129..0244c02 100644 --- a/.epic-loop/epics/mode-reminder/risk-register.md +++ b/.epic-loop/epics/mode-reminder/risk-register.md @@ -7,5 +7,7 @@ | 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. | 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. | Follow-up shaping captured the gap in `docs/shaping-binding-gap.md`; next step is to define automatic vs explicit shaping/review binding semantics. | open | -| 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. | Add a consistency fix so binding a session deactivates or clears prior active pointers for that same session id. | 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 4ab894a..fcefb70 100644 --- a/.epic-loop/epics/mode-reminder/state-of-epic.md +++ b/.epic-loop/epics/mode-reminder/state-of-epic.md @@ -4,7 +4,7 @@ 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 6 - Run The Full Test Suite +Active phase: Phase 7 - Epic-Centric Mode Model And Compact Reminder Active task: TBD ## Current State @@ -18,6 +18,7 @@ Active task: TBD - 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 — Epic-Centric Mode Model And Compact Reminder — is rebuilt accordingly in `roadmap-state.json` and the re-rendered `tracker.md`: mode source → membership/driver model → marker → auto-bind on resume → live multi-session verification. ## Blockers @@ -25,4 +26,4 @@ Active task: TBD ## Next Action -- Decide the desired binding behavior for shaping/review sessions and turn `docs/shaping-binding-gap.md` into a focused follow-up implementation plan. +- Phase 7 is ready for implementation. Start the implementation loop in a session after explicit user confirmation (`bind-session.mjs --current --slug mode-reminder --mode implementation`). diff --git a/.epic-loop/epics/mode-reminder/tracker.md b/.epic-loop/epics/mode-reminder/tracker.md index e9f4577..dae8ad0 100644 --- a/.epic-loop/epics/mode-reminder/tracker.md +++ b/.epic-loop/epics/mode-reminder/tracker.md @@ -102,12 +102,46 @@ Epic: Epic-Loop Mode Reminder And Session Unbind - 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: todo + +- [ ] Kind: implementation | Status: todo | 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 + +- [ ] Kind: implementation | Status: todo | 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 + +- [ ] Kind: implementation | Status: todo | 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; runtime skill copies; 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); runtime copies re-synced via self-update. + - Docs: docs/epic-mode-model.md, docs/mode-reminder-design.md, references/hooks-and-session-routing.md + +- [ ] Kind: implementation | Status: todo | 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 included); when 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 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 + +- [ ] Kind: verification | Status: todo | Phase-level verification: full suite plus a live multi-session Claude Code check of the epic-centric model. + - Outcome: Proven-green phase result: unit suite and package validation pass, runtime copies are clean, and the membership/marker/mode-propagation behavior is observed working in real sessions. + - 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; `diff -rq` of both runtime copies vs `plugins/` is clean except `.runtime`; 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. + - Docs: implementation-log.md, decision-log.md + ## Follow-Up Tasks - [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 here as a follow-up entry because hand-added tracker tasks do not survive roadmap re-renders (see implementation-log 2026-07-06) + - 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 @@ -116,27 +150,3 @@ Epic: Epic-Loop Mode Reminder And Session Unbind - 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 -- [ ] Kind: follow-up | Status: todo | Design and fix shaping/review session binding lifecycle - - Outcome: Shaping and review mode reminders are reliably active when intended, and rebinding a session cannot leave stale active-session pointers for older epic/mode pairs. - - Surface: bind-session.mjs, session binding data model, shaping/review resume flow, hook diagnostics, tests. - - Acceptance: Define automatic vs explicit binding behavior for shaping/review; ensure binding one session to a new epic/mode clears stale active pointers for the same session id; add regression tests for reminder activation and stale active_sessions cleanup. - - Docs: docs/shaping-binding-gap.md - -- [ ] Kind: follow-up | Status: todo | Clear stale active session pointers when rebinding the same session - - Outcome: Rebinding a session to a new epic/mode leaves exactly one active_sessions pointer for that session id, matching the primary sessions[session_id] binding. - - Surface: bindSession in scripts/lib/epics.mjs, session-bindings.json update semantics, unit tests around bind-session.mjs. - - Acceptance: When a session already active for old-slug:old-mode is bound to new-slug:new-mode, active_sessions no longer contains old-slug:old-mode for that same session id; existing replacement behavior for another session active on the new key still works; tests cover both cases. - - Docs: docs/shaping-binding-gap.md - -- [ ] Kind: follow-up | Status: todo | Replace verbose mode reminders with a compact epic-loop mode marker - - Outcome: UserPromptSubmit reminders use a short stable marker like '[epic-loop] epic= mode=', and SKILL.md/frontmatter treat that marker as a trigger for the existing mode rules without duplicating mode instructions. - - Surface: MODE_REMINDER_TEXT in scripts/lib/hooks.mjs, SKILL.md frontmatter description, concise SKILL.md marker note, runtime skill copies, unit tests that assert the compact text. - - Acceptance: Shaping and review reminders emit only the compact marker; implementation mode remains excluded; SKILL.md description explicitly mentions the marker pattern so the skill triggers on it; body documentation explains the marker means the session is bound to that epic/mode and should follow existing mode rules; tests updated for the exact compact text. - - Docs: docs/mode-reminder-design.md, references/hooks-and-session-routing.md - -- [ ] Kind: follow-up | Status: todo | Bind shaping and review sessions when resuming an epic by slug or path - - Outcome: When a user resumes an epic by slug or path, the current session is bound to the epic's existing non-implementation mode so mode reminders work immediately without changing the epic mode. - - Surface: SKILL.md resume flow, bind-session.mjs usage, session binding semantics, slug/path resume handling, tests for shaping/review/implementation resume cases. - - Acceptance: If the resumed epic's current mode is shaping or review, the session is bound with that same mode and the epic mode is not changed; if the resumed epic is implementation or implementation-idle, the existing explicit implementation start/resume flow remains unchanged and no automatic implementation binding occurs; slug and path inputs are covered; stale active-session pointers are not introduced. - - Docs: docs/shaping-binding-gap.md - From 387ec7b25bec4360f27cc6e5c209a1f6d316259a Mon Sep 17 00:00:00 2001 From: Oleg Proskurin Date: Tue, 7 Jul 2026 13:18:57 +0700 Subject: [PATCH 14/24] Add runtime-backed epic mode command --- .../epics/mode-reminder/implementation-log.md | 5 +++ .epic-loop/epics/mode-reminder/tracker.md | 5 +-- plugins/epic-loop/skills/epic-loop/SKILL.md | 8 ++++ .../skills/epic-loop/scripts/lib/epics.mjs | 42 ++++++++++++++++++- .../skills/epic-loop/scripts/lib/loop.mjs | 2 - .../epic-loop/scripts/set-epic-mode.mjs | 11 +++++ tests/unit/init-epic-cli.test.mjs | 37 +++++++++++++++- 7 files changed, 103 insertions(+), 7 deletions(-) create mode 100755 plugins/epic-loop/skills/epic-loop/scripts/set-epic-mode.mjs diff --git a/.epic-loop/epics/mode-reminder/implementation-log.md b/.epic-loop/epics/mode-reminder/implementation-log.md index 126bdf8..83c1acc 100644 --- a/.epic-loop/epics/mode-reminder/implementation-log.md +++ b/.epic-loop/epics/mode-reminder/implementation-log.md @@ -69,3 +69,8 @@ - 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. diff --git a/.epic-loop/epics/mode-reminder/tracker.md b/.epic-loop/epics/mode-reminder/tracker.md index dae8ad0..ec31766 100644 --- a/.epic-loop/epics/mode-reminder/tracker.md +++ b/.epic-loop/epics/mode-reminder/tracker.md @@ -104,9 +104,9 @@ Epic: Epic-Loop Mode Reminder And Session Unbind ### Phase 7: Epic-Centric Mode Model And Compact Reminder -- Phase status: todo +- Phase status: doing -- [ ] Kind: implementation | Status: todo | Make `runtime-state.json` `mode` the sole lifecycle mode source and drop the human-readable `Current mode:` line. +- [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. @@ -149,4 +149,3 @@ Epic: Epic-Loop Mode Reminder And Session Unbind - 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/plugins/epic-loop/skills/epic-loop/SKILL.md b/plugins/epic-loop/skills/epic-loop/SKILL.md index baa308c..6f7144d 100644 --- a/plugins/epic-loop/skills/epic-loop/SKILL.md +++ b/plugins/epic-loop/skills/epic-loop/SKILL.md @@ -123,6 +123,14 @@ 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 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 e396040..2142937 100644 --- a/plugins/epic-loop/skills/epic-loop/scripts/lib/epics.mjs +++ b/plugins/epic-loop/skills/epic-loop/scripts/lib/epics.mjs @@ -49,7 +49,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 +166,47 @@ 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}`); + } + + 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}`); + } + + const timestamp = nowIso(); + writeJson(runtimePath, { + ...runtime, + mode, + updated_at: timestamp, + }); + + console.log(`Epic mode set for ${slug}: ${mode}`); +} + export function status(flags = {}, positionals = []) { const root = resolveRoot(flags.root); const slug = positionals[0]; 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..b67ff05 100644 --- a/plugins/epic-loop/skills/epic-loop/scripts/lib/loop.mjs +++ b/plugins/epic-loop/skills/epic-loop/scripts/lib/loop.mjs @@ -484,7 +484,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 +633,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/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); +}); From 38effd4aa9444290645a529b16c01187b404b2b6 Mon Sep 17 00:00:00 2001 From: Oleg Proskurin Date: Tue, 7 Jul 2026 14:28:00 +0700 Subject: [PATCH 15/24] Clarify epic-loop runtime and source separation --- AGENTS.md | 98 +++++++++++++++++++++++++++++++++++++++++++++++-------- 1 file changed, 85 insertions(+), 13 deletions(-) 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 From ad1be4420882908a4c6cc772057c66d1dd3e19f5 Mon Sep 17 00:00:00 2001 From: Oleg Proskurin Date: Tue, 7 Jul 2026 14:49:16 +0700 Subject: [PATCH 16/24] Rework epic bindings around runtime driver --- .../epics/mode-reminder/implementation-log.md | 5 + .epic-loop/epics/mode-reminder/tracker.md | 2 +- .../skills/epic-loop/scripts/lib/epics.mjs | 114 +++++++------- .../skills/epic-loop/scripts/lib/hooks.mjs | 18 +-- .../skills/epic-loop/scripts/lib/loop.mjs | 13 +- tests/unit/cli-contracts.test.mjs | 11 +- tests/unit/hook-contracts.test.mjs | 145 +++++++++++++++++- tests/unit/unbind-and-reminder.test.mjs | 94 +++++++++++- 8 files changed, 321 insertions(+), 81 deletions(-) diff --git a/.epic-loop/epics/mode-reminder/implementation-log.md b/.epic-loop/epics/mode-reminder/implementation-log.md index 83c1acc..75ac6c9 100644 --- a/.epic-loop/epics/mode-reminder/implementation-log.md +++ b/.epic-loop/epics/mode-reminder/implementation-log.md @@ -74,3 +74,8 @@ - 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. diff --git a/.epic-loop/epics/mode-reminder/tracker.md b/.epic-loop/epics/mode-reminder/tracker.md index ec31766..409ebda 100644 --- a/.epic-loop/epics/mode-reminder/tracker.md +++ b/.epic-loop/epics/mode-reminder/tracker.md @@ -112,7 +112,7 @@ Epic: Epic-Loop Mode Reminder And Session Unbind - 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 -- [ ] Kind: implementation | Status: todo | Rework session bindings to mode-less epic membership with an exclusive implementation driver. +- [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. 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 2142937..8e8c35a 100644 --- a/plugins/epic-loop/skills/epic-loop/scripts/lib/epics.mjs +++ b/plugins/epic-loop/skills/epic-loop/scripts/lib/epics.mjs @@ -180,29 +180,7 @@ export function setEpicMode(flags = {}) { throw new Error(`Epic not found: ${epicDir}`); } - 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}`); - } - - const timestamp = nowIso(); - writeJson(runtimePath, { - ...runtime, - mode, - updated_at: timestamp, - }); + writeEpicRuntimeMode(root, slug, mode); console.log(`Epic mode set for ${slug}: ${mode}`); } @@ -267,39 +245,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); @@ -308,8 +264,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, }); @@ -318,12 +273,11 @@ 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 unbindSession(flags = {}) { @@ -350,10 +304,6 @@ export function unbindSession(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 binding = sessions[sessionId]; if (!binding || typeof binding !== "object" || binding.active !== true) { @@ -362,9 +312,12 @@ export function unbindSession(flags = {}) { } const epicSlug = String(binding.epic_slug); - const mode = String(binding.mode); const unboundAt = nowIso(); - const activeKey = `${epicSlug}:${mode}`; + 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, @@ -373,14 +326,26 @@ export function unbindSession(flags = {}) { deactivated_reason: reason, }; - if (activeSessions[activeKey] === sessionId) { - delete activeSessions[activeKey]; - } - - normalizedBindings.active_sessions = activeSessions; + 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"), { @@ -394,6 +359,31 @@ export function unbindSession(flags = {}) { console.log(`Session ${sessionId} unbound from ${epicSlug} (${mode}).`); } +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 = {}) { const root = resolveRoot(flags.root); const epicsDir = epicsRoot(root); 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 3466c3e..f3dcf6a 100644 --- a/plugins/epic-loop/skills/epic-loop/scripts/lib/hooks.mjs +++ b/plugins/epic-loop/skills/epic-loop/scripts/lib/hooks.mjs @@ -19,6 +19,7 @@ import { readJson, readJsonStrict, resolveRoot, + runtimeStatePath, sessionRoot, shellQuote, slugify, @@ -749,15 +750,17 @@ const MODE_REMINDER_TEXT = { `[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).`, }; -export function buildModeReminder(payload, binding) { +export function buildModeReminder(projectRoot, payload, binding) { if (payload.hook_event_name !== "UserPromptSubmit") { return null; } - if (binding.mode !== "shaping" && binding.mode !== "review") { + const runtime = readJson(runtimeStatePath(projectRoot, binding.epic_slug), {}); + const mode = runtime && typeof runtime === "object" && !Array.isArray(runtime) ? runtime.mode : null; + if (mode !== "shaping" && mode !== "review") { return null; } - const text = MODE_REMINDER_TEXT[binding.mode](binding.epic_slug); + const text = MODE_REMINDER_TEXT[mode](binding.epic_slug); return { hookSpecificOutput: { @@ -810,7 +813,7 @@ export function handleHook(rawInput, flags = {}) { mirrorBoundEvent(projectRoot, payload, eventRecord, binding); markInterruptedTurnIfNeeded(projectRoot, payload, binding); - const continuation = maybeBuildImplementationContinuation(projectRoot, payload, binding) ?? buildModeReminder(payload, binding); + const continuation = maybeBuildImplementationContinuation(projectRoot, payload, binding) ?? buildModeReminder(projectRoot, payload, binding); if (continuation) { console.log(JSON.stringify(continuation)); } @@ -848,18 +851,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 b67ff05..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; } diff --git a/tests/unit/cli-contracts.test.mjs b/tests/unit/cli-contracts.test.mjs index 42095cb..ab26408 100644 --- a/tests/unit/cli-contracts.test.mjs +++ b/tests/unit/cli-contracts.test.mjs @@ -378,6 +378,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 +414,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 +493,8 @@ 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 }); } diff --git a/tests/unit/hook-contracts.test.mjs b/tests/unit/hook-contracts.test.mjs index 9a96f43..8a4a461 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,128 @@ 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(observerPrompt.stdout, ""); + 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/unbind-and-reminder.test.mjs b/tests/unit/unbind-and-reminder.test.mjs index ea18235..af0a8c2 100644 --- a/tests/unit/unbind-and-reminder.test.mjs +++ b/tests/unit/unbind-and-reminder.test.mjs @@ -117,6 +117,63 @@ test("no reminder is injected for a bound implementation session on UserPromptSu } }); +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); + + const firstReminder = runHook(root, promptPayload(root, "session-a")); + const secondReminder = runHook(root, promptPayload(root, "session-b")); + assert.equal(JSON.parse(firstReminder.stdout).hookSpecificOutput.additionalContext.includes(`Bound to epic "${slug}" — shaping mode.`), true); + assert.equal(JSON.parse(secondReminder.stdout).hookSpecificOutput.additionalContext.includes(`Bound to epic "${slug}" — shaping mode.`), true); + + 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-"); @@ -148,7 +205,8 @@ test("unbind-session deactivates a bound session with the default reason", () => assert.equal(entry.active, false); assert.equal(typeof entry.deactivated_at, "string"); assert.equal(entry.deactivated_reason, "user-requested-unbind"); - assert.equal(Object.hasOwn(bindings.active_sessions, `${slug}:shaping`), false); + 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); @@ -212,3 +270,37 @@ test("hooks are silent for a session id after it is unbound", () => { 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 }); + } +}); From 72bb3f6afddcc6393c7665a1d489cfd8f4aada5a Mon Sep 17 00:00:00 2001 From: Oleg Proskurin Date: Tue, 7 Jul 2026 15:22:35 +0700 Subject: [PATCH 17/24] Emit compact epic mode markers --- .../docs/mode-reminder-design.md | 20 ++-- .../epics/mode-reminder/implementation-log.md | 5 + .../epics/mode-reminder/state-of-epic.md | 3 +- .epic-loop/epics/mode-reminder/tracker.md | 6 +- plugins/epic-loop/skills/epic-loop/SKILL.md | 4 +- .../references/hooks-and-session-routing.md | 9 +- .../skills/epic-loop/scripts/lib/hooks.mjs | 25 +++-- tests/unit/hook-contracts.test.mjs | 5 +- tests/unit/unbind-and-reminder.test.mjs | 101 +++++++++++++----- 9 files changed, 121 insertions(+), 57 deletions(-) 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/implementation-log.md b/.epic-loop/epics/mode-reminder/implementation-log.md index 75ac6c9..55aedcf 100644 --- a/.epic-loop/epics/mode-reminder/implementation-log.md +++ b/.epic-loop/epics/mode-reminder/implementation-log.md @@ -79,3 +79,8 @@ - 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. diff --git a/.epic-loop/epics/mode-reminder/state-of-epic.md b/.epic-loop/epics/mode-reminder/state-of-epic.md index fcefb70..815c739 100644 --- a/.epic-loop/epics/mode-reminder/state-of-epic.md +++ b/.epic-loop/epics/mode-reminder/state-of-epic.md @@ -3,7 +3,6 @@ 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 7 - Epic-Centric Mode Model And Compact Reminder Active task: TBD @@ -26,4 +25,4 @@ Active task: TBD ## Next Action -- Phase 7 is ready for implementation. Start the implementation loop in a session after explicit user confirmation (`bind-session.mjs --current --slug mode-reminder --mode implementation`). +- Continue Phase 7 Task 3: implement the compact member marker and implementation non-driver lock marker. diff --git a/.epic-loop/epics/mode-reminder/tracker.md b/.epic-loop/epics/mode-reminder/tracker.md index 409ebda..35f5e96 100644 --- a/.epic-loop/epics/mode-reminder/tracker.md +++ b/.epic-loop/epics/mode-reminder/tracker.md @@ -118,10 +118,10 @@ Epic: Epic-Loop Mode Reminder And Session Unbind - 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 -- [ ] Kind: implementation | Status: todo | Emit the compact mode marker to all member sessions, plus a read-only lock marker for non-driver members during implementation. +- [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; runtime skill copies; 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); runtime copies re-synced via self-update. + - 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; body note explains both marker variants (mode rules vs read-only lock; the lock is advisory, not a mechanical write barrier); runtime copy sync deferred because `AGENTS.md` requires explicit user request for `scripts/self-update-skill.mjs`. - Docs: docs/epic-mode-model.md, docs/mode-reminder-design.md, references/hooks-and-session-routing.md - [ ] Kind: implementation | Status: todo | Auto-bind the current session as an epic member when resuming by slug or path. @@ -136,8 +136,6 @@ Epic: Epic-Loop Mode Reminder And Session Unbind - Acceptance: `pnpm run test:unit` and `pnpm run validate` exit clean including all new Phase 7 tests; `diff -rq` of both runtime copies vs `plugins/` is clean except `.runtime`; 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. - Docs: implementation-log.md, decision-log.md -## Follow-Up Tasks - - [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 diff --git a/plugins/epic-loop/skills/epic-loop/SKILL.md b/plugins/epic-loop/skills/epic-loop/SKILL.md index 6f7144d..ffebc58 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; resuming an existing epic by slug; or detaching the current session from its epic when the user says `unbind epic` or otherwise asks to work outside the epic. 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 @@ -351,6 +351,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: 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 6697fab..3b2ed0f 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 @@ -169,7 +169,7 @@ This deactivates the previous active session for the same epic and mode. Do not 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`) and clears the `active_sessions` pointer; 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. +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 @@ -179,7 +179,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 mode reminder via `hookSpecificOutput.additionalContext` on `UserPromptSubmit` for sessions bound in `shaping` or `review` mode; implementation-mode and unbound sessions get no reminder +- 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 @@ -195,9 +196,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/scripts/lib/hooks.mjs b/plugins/epic-loop/skills/epic-loop/scripts/lib/hooks.mjs index f3dcf6a..ac3e38f 100644 --- a/plugins/epic-loop/skills/epic-loop/scripts/lib/hooks.mjs +++ b/plugins/epic-loop/skills/epic-loop/scripts/lib/hooks.mjs @@ -744,10 +744,8 @@ function installClaudeHooks(root, flags = {}) { } 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).`, + 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) { @@ -755,12 +753,23 @@ export function buildModeReminder(projectRoot, payload, binding) { return null; } const runtime = readJson(runtimeStatePath(projectRoot, binding.epic_slug), {}); - const mode = runtime && typeof runtime === "object" && !Array.isArray(runtime) ? runtime.mode : null; - if (mode !== "shaping" && mode !== "review") { - return null; + 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); } - const text = MODE_REMINDER_TEXT[mode](binding.epic_slug); + if (!text) { + return null; + } return { hookSpecificOutput: { diff --git a/tests/unit/hook-contracts.test.mjs b/tests/unit/hook-contracts.test.mjs index 8a4a461..3ae993d 100644 --- a/tests/unit/hook-contracts.test.mjs +++ b/tests/unit/hook-contracts.test.mjs @@ -251,7 +251,10 @@ test("non-driver UserPromptSubmit does not interrupt an open implementation turn }), }); assertSuccess(observerPrompt); - assert.equal(observerPrompt.stdout, ""); + 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); diff --git a/tests/unit/unbind-and-reminder.test.mjs b/tests/unit/unbind-and-reminder.test.mjs index af0a8c2..2adcdf1 100644 --- a/tests/unit/unbind-and-reminder.test.mjs +++ b/tests/unit/unbind-and-reminder.test.mjs @@ -38,40 +38,54 @@ function bindingsPath(root) { return path.join(root, ".epic-loop", ".runtime", "session-bindings.json"); } -test("mode reminder is injected on UserPromptSubmit for a bound shaping session", () => { +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-shaping", "shaping"); - - const result = runHook(root, promptPayload(root, "session-shaping")); - const lines = result.stdout.trim().split("\n"); - assert.equal(lines.length, 1); + bindSession(root, slug, "session-a", "shaping"); + bindSession(root, slug, "session-b", "shaping"); - const output = JSON.parse(lines[0]); - assert.equal(output.hookSpecificOutput.hookEventName, "UserPromptSubmit"); - const text = output.hookSpecificOutput.additionalContext; - assert.equal(text.startsWith("[epic-loop]"), true); - assert.equal(text.includes(`Bound to epic "${slug}" — shaping mode.`), true); - assert.equal(text.includes("SKILL.md Shaping Rules"), true); + 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("mode reminder is injected on UserPromptSubmit for a bound review session", () => { +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"); - const result = runHook(root, promptPayload(root, "session-review")); - const output = JSON.parse(result.stdout.trim()); - assert.equal(output.hookSpecificOutput.hookEventName, "UserPromptSubmit"); - const text = output.hookSpecificOutput.additionalContext; - assert.equal(text.startsWith("[epic-loop]"), true); - assert.equal(text.includes(`Bound to epic "${slug}" — review mode.`), true); - assert.equal(text.includes("SKILL.md Review Rules"), true); + 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 }); } @@ -104,14 +118,28 @@ test("no reminder leaks into Stop events for a bound shaping session", () => { } }); -test("no reminder is injected for a bound implementation session on UserPromptSubmit", () => { +test("implementation driver gets no reminder and non-driver member gets the lock marker", () => { const { root, slug } = scaffoldEpicRoot("reminder-implementation-"); try { - bindSession(root, slug, "session-impl", "implementation"); + bindSession(root, slug, "driver-session", "implementation"); - const result = runHook(root, promptPayload(root, "session-impl")); - assert.equal(result.stdout, ""); + 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 }); } @@ -134,10 +162,8 @@ test("bindings are mode-less memberships and allow multiple active members", () assert.equal(bindings.sessions["session-b"].epic_slug, slug); assert.equal(bindings.sessions["session-b"].mode, undefined); - const firstReminder = runHook(root, promptPayload(root, "session-a")); - const secondReminder = runHook(root, promptPayload(root, "session-b")); - assert.equal(JSON.parse(firstReminder.stdout).hookSpecificOutput.additionalContext.includes(`Bound to epic "${slug}" — shaping mode.`), true); - assert.equal(JSON.parse(secondReminder.stdout).hookSpecificOutput.additionalContext.includes(`Bound to epic "${slug}" — shaping mode.`), true); + 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"); @@ -271,6 +297,25 @@ test("hooks are silent for a session id after it is unbound", () => { } }); +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"); From 41623de3fa62ea185919a7c07e96946f2cdfc88e Mon Sep 17 00:00:00 2001 From: Oleg Proskurin Date: Tue, 7 Jul 2026 15:30:03 +0700 Subject: [PATCH 18/24] Auto-bind resumed epic member sessions --- .../epics/mode-reminder/implementation-log.md | 5 + .../epics/mode-reminder/state-of-epic.md | 7 +- .epic-loop/epics/mode-reminder/tracker.md | 8 +- plugins/epic-loop/skills/epic-loop/SKILL.md | 18 +- .../references/hooks-and-session-routing.md | 21 +- .../epic-loop/references/parallel-sessions.md | 14 +- .../epic-loop/scripts/auto-bind-session.mjs | 11 + .../skills/epic-loop/scripts/lib/common.mjs | 2 +- .../skills/epic-loop/scripts/lib/epics.mjs | 81 +++++++ tests/unit/cli-contracts.test.mjs | 228 ++++++++++++++++++ 10 files changed, 372 insertions(+), 23 deletions(-) create mode 100755 plugins/epic-loop/skills/epic-loop/scripts/auto-bind-session.mjs diff --git a/.epic-loop/epics/mode-reminder/implementation-log.md b/.epic-loop/epics/mode-reminder/implementation-log.md index 55aedcf..08605e4 100644 --- a/.epic-loop/epics/mode-reminder/implementation-log.md +++ b/.epic-loop/epics/mode-reminder/implementation-log.md @@ -84,3 +84,8 @@ - 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 diff --git a/.epic-loop/epics/mode-reminder/state-of-epic.md b/.epic-loop/epics/mode-reminder/state-of-epic.md index 815c739..c5d5561 100644 --- a/.epic-loop/epics/mode-reminder/state-of-epic.md +++ b/.epic-loop/epics/mode-reminder/state-of-epic.md @@ -4,7 +4,7 @@ Epic: Epic-Loop Mode Reminder And Session Unbind Slug: `mode-reminder` Created: 2026-07-04T19:18:09+00:00 Active phase: Phase 7 - Epic-Centric Mode Model And Compact Reminder -Active task: TBD +Active task: Phase 7 Task 5 - Phase-level verification: full suite plus a live multi-session Claude Code check of the epic-centric model ## Current State @@ -17,7 +17,8 @@ Active task: TBD - 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 — Epic-Centric Mode Model And Compact Reminder — is rebuilt accordingly in `roadmap-state.json` and the re-rendered `tracker.md`: mode source → membership/driver model → marker → auto-bind on resume → live multi-session verification. +- 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 implementation slices through Task 4 are complete in the source package: runtime mode source, mode-less membership plus exclusive implementation driver, compact markers, and resume auto-bind are implemented and unit-covered. Installed runtime copies have not been synced; this repo treats that as an explicit manual promotion step. ## Blockers @@ -25,4 +26,4 @@ Active task: TBD ## Next Action -- Continue Phase 7 Task 3: implement the compact member marker and implementation non-driver lock marker. +- Continue Phase 7 Task 5: run phase-level verification, including the full suite and live multi-session Claude Code evidence where feasible. diff --git a/.epic-loop/epics/mode-reminder/tracker.md b/.epic-loop/epics/mode-reminder/tracker.md index 35f5e96..31970e8 100644 --- a/.epic-loop/epics/mode-reminder/tracker.md +++ b/.epic-loop/epics/mode-reminder/tracker.md @@ -120,14 +120,14 @@ Epic: Epic-Loop Mode Reminder And Session Unbind - [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; runtime skill copies; 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; body note explains both marker variants (mode rules vs read-only lock; the lock is advisory, not a mechanical write barrier); runtime copy sync deferred because `AGENTS.md` requires explicit user request for `scripts/self-update-skill.mjs`. + - 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 -- [ ] Kind: implementation | Status: todo | Auto-bind the current session as an epic member when resuming by slug or path. +- [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 included); when 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 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). + - 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 - [ ] Kind: verification | Status: todo | Phase-level verification: full suite plus a live multi-session Claude Code check of the epic-centric model. diff --git a/plugins/epic-loop/skills/epic-loop/SKILL.md b/plugins/epic-loop/skills/epic-loop/SKILL.md index ffebc58..ebb8385 100644 --- a/plugins/epic-loop/skills/epic-loop/SKILL.md +++ b/plugins/epic-loop/skills/epic-loop/SKILL.md @@ -137,7 +137,19 @@ If no epic workspace exists, initialize one with: 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: @@ -361,9 +373,9 @@ 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. -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. +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: 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 3b2ed0f..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,26 @@ 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 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. +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. 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. 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 8e8c35a..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, @@ -280,6 +281,26 @@ export function bindSession(flags = {}) { console.log(`Active ${mode} session for ${slug}: ${sessionId}`); } +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; @@ -359,6 +380,66 @@ export function unbindSession(flags = {}) { 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)) { diff --git a/tests/unit/cli-contracts.test.mjs b/tests/unit/cli-contracts.test.mjs index ab26408..d8f7e7e 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-"); @@ -500,6 +518,216 @@ test("bind-session preserves explicit session-id binding on Claude Code", () => } }); +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 }); + } +}); + test("platform-aware CLIs reject missing or invalid runtime platform config", () => { const root = makeTempRoot("platform-aware-"); From 104b0344c9a6ac020d2cc20f6300dcb20d6448a9 Mon Sep 17 00:00:00 2001 From: Oleg Proskurin Date: Tue, 7 Jul 2026 15:39:12 +0700 Subject: [PATCH 19/24] Record phase seven verification evidence --- .epic-loop/epics/mode-reminder/implementation-log.md | 12 ++++++++++++ .epic-loop/epics/mode-reminder/state-of-epic.md | 8 ++++---- .epic-loop/epics/mode-reminder/tracker.md | 6 +++--- 3 files changed, 19 insertions(+), 7 deletions(-) diff --git a/.epic-loop/epics/mode-reminder/implementation-log.md b/.epic-loop/epics/mode-reminder/implementation-log.md index 08605e4..51166bf 100644 --- a/.epic-loop/epics/mode-reminder/implementation-log.md +++ b/.epic-loop/epics/mode-reminder/implementation-log.md @@ -89,3 +89,15 @@ - 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. diff --git a/.epic-loop/epics/mode-reminder/state-of-epic.md b/.epic-loop/epics/mode-reminder/state-of-epic.md index c5d5561..077f2d3 100644 --- a/.epic-loop/epics/mode-reminder/state-of-epic.md +++ b/.epic-loop/epics/mode-reminder/state-of-epic.md @@ -4,7 +4,7 @@ Epic: Epic-Loop Mode Reminder And Session Unbind Slug: `mode-reminder` Created: 2026-07-04T19:18:09+00:00 Active phase: Phase 7 - Epic-Centric Mode Model And Compact Reminder -Active task: Phase 7 Task 5 - Phase-level verification: full suite plus a live multi-session Claude Code check of the epic-centric model +Active task: TBD ## Current State @@ -18,12 +18,12 @@ Active task: Phase 7 Task 5 - Phase-level verification: full suite plus a live m - 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 implementation slices through Task 4 are complete in the source package: runtime mode source, mode-less membership plus exclusive implementation driver, compact markers, and resume auto-bind are implemented and unit-covered. Installed runtime copies have not been synced; this repo treats that as an explicit manual promotion step. +- Phase 7 source-package work and verification are complete through Task 5: 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 stale vs source because this repo treats runtime promotion as an explicit manual step. ## Blockers -- None recorded. +- Phase 7 cannot be honestly closed until the installed runtime skill copies are manually promoted from source, or the phase acceptance is explicitly revised to defer that promotion. ## Next Action -- Continue Phase 7 Task 5: run phase-level verification, including the full suite and live multi-session Claude Code evidence where feasible. +- Await user decision on manual runtime skill promotion (`node scripts/self-update-skill.mjs`) or explicit deferral before closing Phase 7. diff --git a/.epic-loop/epics/mode-reminder/tracker.md b/.epic-loop/epics/mode-reminder/tracker.md index 31970e8..c321767 100644 --- a/.epic-loop/epics/mode-reminder/tracker.md +++ b/.epic-loop/epics/mode-reminder/tracker.md @@ -130,10 +130,10 @@ Epic: Epic-Loop Mode Reminder And Session Unbind - 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 -- [ ] Kind: verification | Status: todo | Phase-level verification: full suite plus a live multi-session Claude Code check of the epic-centric model. - - Outcome: Proven-green phase result: unit suite and package validation pass, runtime copies are clean, and the membership/marker/mode-propagation behavior is observed working in real sessions. +- [x] Kind: verification | Status: done | Phase-level verification: full suite plus a live multi-session Claude Code check of the epic-centric model. + - Outcome: Verification completed: unit suite and package validation pass, live Claude Code evidence proves membership/marker/mode-propagation behavior, and installed runtime copies are confirmed stale vs source until manual promotion. - 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; `diff -rq` of both runtime copies vs `plugins/` is clean except `.runtime`; 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. + - 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; gap: `diff -rq` of both runtime copies vs `plugins/` is not clean because runtime promotion is manual in this repo and `scripts/self-update-skill.mjs` was intentionally not run. - 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) From 6aa9ad038899188f1445a4520f29139ae54473dc Mon Sep 17 00:00:00 2001 From: Oleg Proskurin Date: Tue, 7 Jul 2026 16:56:26 +0700 Subject: [PATCH 20/24] Close phase seven on source verification --- .epic-loop/epics/mode-reminder/implementation-log.md | 5 +++++ .epic-loop/epics/mode-reminder/state-of-epic.md | 8 ++++---- .epic-loop/epics/mode-reminder/tracker.md | 6 +++--- 3 files changed, 12 insertions(+), 7 deletions(-) diff --git a/.epic-loop/epics/mode-reminder/implementation-log.md b/.epic-loop/epics/mode-reminder/implementation-log.md index 51166bf..0a34ec7 100644 --- a/.epic-loop/epics/mode-reminder/implementation-log.md +++ b/.epic-loop/epics/mode-reminder/implementation-log.md @@ -101,3 +101,8 @@ - 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. diff --git a/.epic-loop/epics/mode-reminder/state-of-epic.md b/.epic-loop/epics/mode-reminder/state-of-epic.md index 077f2d3..1583cd3 100644 --- a/.epic-loop/epics/mode-reminder/state-of-epic.md +++ b/.epic-loop/epics/mode-reminder/state-of-epic.md @@ -3,7 +3,7 @@ Epic: Epic-Loop Mode Reminder And Session Unbind Slug: `mode-reminder` Created: 2026-07-04T19:18:09+00:00 -Active phase: Phase 7 - Epic-Centric Mode Model And Compact Reminder +Active phase: none - Phase 7 closed Active task: TBD ## Current State @@ -18,12 +18,12 @@ Active task: TBD - 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 source-package work and verification are complete through Task 5: 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 stale vs source because this repo treats runtime promotion as an explicit manual step. +- 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. ## Blockers -- Phase 7 cannot be honestly closed until the installed runtime skill copies are manually promoted from source, or the phase acceptance is explicitly revised to defer that promotion. +- None recorded. ## Next Action -- Await user decision on manual runtime skill promotion (`node scripts/self-update-skill.mjs`) or explicit deferral before closing Phase 7. +- Run phase-closure housekeeping, then finish implementation mode if no housekeeping blocker appears. diff --git a/.epic-loop/epics/mode-reminder/tracker.md b/.epic-loop/epics/mode-reminder/tracker.md index c321767..c28b5e3 100644 --- a/.epic-loop/epics/mode-reminder/tracker.md +++ b/.epic-loop/epics/mode-reminder/tracker.md @@ -104,7 +104,7 @@ Epic: Epic-Loop Mode Reminder And Session Unbind ### Phase 7: Epic-Centric Mode Model And Compact Reminder -- Phase status: doing +- 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. @@ -131,9 +131,9 @@ Epic: Epic-Loop Mode Reminder And Session Unbind - 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: Verification completed: unit suite and package validation pass, live Claude Code evidence proves membership/marker/mode-propagation behavior, and installed runtime copies are confirmed stale vs source until manual promotion. + - 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; gap: `diff -rq` of both runtime copies vs `plugins/` is not clean because runtime promotion is manual in this repo and `scripts/self-update-skill.mjs` was intentionally not run. + - 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) From 46325b8cfa52823caa657151bc9500e53f354028 Mon Sep 17 00:00:00 2001 From: Oleg Proskurin Date: Tue, 7 Jul 2026 16:58:19 +0700 Subject: [PATCH 21/24] Record mode reminder implementation exit --- .epic-loop/epics/mode-reminder/implementation-log.md | 5 +++++ .epic-loop/epics/mode-reminder/state-of-epic.md | 5 +++-- 2 files changed, 8 insertions(+), 2 deletions(-) diff --git a/.epic-loop/epics/mode-reminder/implementation-log.md b/.epic-loop/epics/mode-reminder/implementation-log.md index 0a34ec7..0e63184 100644 --- a/.epic-loop/epics/mode-reminder/implementation-log.md +++ b/.epic-loop/epics/mode-reminder/implementation-log.md @@ -106,3 +106,8 @@ - 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/state-of-epic.md b/.epic-loop/epics/mode-reminder/state-of-epic.md index 1583cd3..8a86347 100644 --- a/.epic-loop/epics/mode-reminder/state-of-epic.md +++ b/.epic-loop/epics/mode-reminder/state-of-epic.md @@ -4,7 +4,7 @@ Epic: Epic-Loop Mode Reminder And Session Unbind Slug: `mode-reminder` Created: 2026-07-04T19:18:09+00:00 Active phase: none - Phase 7 closed -Active task: TBD +Active task: none ## Current State @@ -19,6 +19,7 @@ Active task: TBD - 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 @@ -26,4 +27,4 @@ Active task: TBD ## Next Action -- Run phase-closure housekeeping, then finish implementation mode if no housekeeping blocker appears. +- Implementation is complete. Next human step is normal review/merge of the completed branch. From 5d37a5fb5103814cb927d9d8c83d504ff585da55 Mon Sep 17 00:00:00 2001 From: Oleg Proskurin Date: Tue, 7 Jul 2026 18:44:46 +0700 Subject: [PATCH 22/24] fix: repair epic runtime compatibility in doctor --- .../skills/epic-loop/scripts/lib/hooks.mjs | 204 +++++++++++++++++- tests/unit/cli-contracts.test.mjs | 98 +++++++++ 2 files changed, 300 insertions(+), 2 deletions(-) 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 ac3e38f..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,7 @@ import { readJson, readJsonStrict, resolveRoot, + roadmapStatePath, runtimeStatePath, sessionRoot, shellQuote, @@ -29,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)); @@ -481,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") { @@ -507,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, @@ -552,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})`}`); @@ -589,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: { @@ -603,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, @@ -638,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"}`); diff --git a/tests/unit/cli-contracts.test.mjs b/tests/unit/cli-contracts.test.mjs index d8f7e7e..69069c4 100644 --- a/tests/unit/cli-contracts.test.mjs +++ b/tests/unit/cli-contracts.test.mjs @@ -75,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-"); From b0a6cc7b60aacf17221115b5bf4833d49842feca Mon Sep 17 00:00:00 2001 From: Oleg Proskurin Date: Tue, 7 Jul 2026 18:59:41 +0700 Subject: [PATCH 23/24] chore(epic): update rules --- .epic-loop/epics/set-up/decision-log.md | 1 + .../docs/linting-and-language-policy.md | 32 +++++++++++++++++++ .epic-loop/epics/set-up/tracker.md | 3 +- 3 files changed, 34 insertions(+), 2 deletions(-) diff --git a/.epic-loop/epics/set-up/decision-log.md b/.epic-loop/epics/set-up/decision-log.md index 57071e3..f71c75d 100644 --- a/.epic-loop/epics/set-up/decision-log.md +++ b/.epic-loop/epics/set-up/decision-log.md @@ -2,6 +2,7 @@ ## Active Decisions +- 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. 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 index 316667b..5518ce6 100644 --- a/.epic-loop/epics/set-up/docs/linting-and-language-policy.md +++ b/.epic-loop/epics/set-up/docs/linting-and-language-policy.md @@ -20,6 +20,38 @@ Use standard tooling for standard concerns and a small repository-owned script f - 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. diff --git a/.epic-loop/epics/set-up/tracker.md b/.epic-loop/epics/set-up/tracker.md index 6a3767b..5f56287 100644 --- a/.epic-loop/epics/set-up/tracker.md +++ b/.epic-loop/epics/set-up/tracker.md @@ -31,7 +31,7 @@ Epic: Linting And English Checks - [ ] 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, and is included in aggregate validation. + - 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. @@ -67,4 +67,3 @@ Epic: Linting And English Checks - 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`. - From abbc61bd3216a920357b0d18e9f77f6c9cf319dd Mon Sep 17 00:00:00 2001 From: Oleg Proskurin Date: Tue, 7 Jul 2026 19:22:07 +0700 Subject: [PATCH 24/24] Shape skill validation phases --- .epic-loop/epics/set-up/decision-log.md | 1 + .../docs/linting-and-language-policy.md | 96 +++++++++++++++++++ .epic-loop/epics/set-up/risk-register.md | 1 + .epic-loop/epics/set-up/state-of-epic.md | 3 +- .epic-loop/epics/set-up/tracker.md | 46 ++++++++- 5 files changed, 145 insertions(+), 2 deletions(-) diff --git a/.epic-loop/epics/set-up/decision-log.md b/.epic-loop/epics/set-up/decision-log.md index f71c75d..117cfad 100644 --- a/.epic-loop/epics/set-up/decision-log.md +++ b/.epic-loop/epics/set-up/decision-log.md @@ -2,6 +2,7 @@ ## 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. 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 index 5518ce6..5f144dc 100644 --- a/.epic-loop/epics/set-up/docs/linting-and-language-policy.md +++ b/.epic-loop/epics/set-up/docs/linting-and-language-policy.md @@ -91,3 +91,99 @@ The script should report filename, line, column, and offending token or short ex - 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/risk-register.md b/.epic-loop/epics/set-up/risk-register.md index 6a8a4f3..0e2db74 100644 --- a/.epic-loop/epics/set-up/risk-register.md +++ b/.epic-loop/epics/set-up/risk-register.md @@ -5,3 +5,4 @@ | 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 index 637d4c3..e96c6a8 100644 --- a/.epic-loop/epics/set-up/state-of-epic.md +++ b/.epic-loop/epics/set-up/state-of-epic.md @@ -11,7 +11,8 @@ Active task: TBD - 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, and deterministic English-only lexical checks for committed project content. +- 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 diff --git a/.epic-loop/epics/set-up/tracker.md b/.epic-loop/epics/set-up/tracker.md index 5f56287..b16a541 100644 --- a/.epic-loop/epics/set-up/tracker.md +++ b/.epic-loop/epics/set-up/tracker.md @@ -46,7 +46,51 @@ Epic: Linting And English Checks - 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: Repository Language Policy +### 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