diff --git a/cline/plugins/slow-powers.js b/cline/plugins/slow-powers.js index 196751c..d78ff00 100644 --- a/cline/plugins/slow-powers.js +++ b/cline/plugins/slow-powers.js @@ -8,12 +8,30 @@ * prompt. This replaces the SessionStart-hook injection used on Claude/Codex * and the system-prompt transform used on OpenCode. * - * 2. PLAN GATE — the FIRST switch_to_act_mode call of a conversation is skipped - * with an instruction to run the hardening-plans skill on the plan first. - * switch_to_act_mode is how Cline presents a finished plan and leaves plan - * mode; skipping it keeps the session in plan mode, so the agent can load - * the skill, fix findings inline, and re-submit a hardened plan. The - * re-submission finds the per-conversation marker and is allowed through. + * 2. PLAN GATE — in Cline, presenting a plan is a free-form assistant message: + * the agent shows the plan, ends its turn, the user approves in a follow-up + * message, and ONLY THEN does the agent call switch_to_act_mode (the CLI's + * own plan-mode prompt and the tool description mandate that order). So, + * unlike Claude Code — where the plan text rides inside the ExitPlanMode + * call and a PreToolUse deny lands before the user ever sees the plan — + * there is NO hook moment in Cline that precedes plan presentation. The + * gate therefore works in two layers: + * + * a. PRE-PRESENTATION (rule): the plan-presentation rule registered below + * tells plan-mode agents to run hardening-plans on a draft BEFORE + * presenting it. A rule is the only mechanism that reaches the agent + * before a plan is shown. + * b. PRE-EXECUTION (hook): the first switch_to_act_mode call of a + * conversation whose transcript shows no hardening-plans invocation is + * skipped with an instruction to harden, re-present the hardened plan, + * and retry. This is the deterministic backstop: an un-hardened plan can + * never be executed even if the agent skipped the rule. + * + * ALREADY-HARDENED SHORT-CIRCUIT: when the rule was followed, the transcript + * already holds a skills tool call for hardening-plans, and the hook lets the + * switch through with no beat (parity with hooks/exit-plan-mode, issue #153). + * Detection matches the tool-input shape only, never prose, so this hook's own + * skip reason in the transcript cannot false-positive. * * WHY DENY-ONCE (and not deny-until-proven-hardened): keying the marker per * conversation and allowing the second attempt guarantees we can never @@ -70,9 +88,49 @@ function markerPath(context) { } const SKIP_REASON = - "A plan is about to be presented. Before it leaves your hands, use the " + - "hardening-plans skill to review the plan file as a skeptical executor, " + - "then call switch_to_act_mode again to present the hardened plan."; + "Plan execution is gated. This conversation has not run the hardening-plans " + + "skill on the plan yet, so the plan must not be executed as-is. Use the " + + "hardening-plans skill to review the plan as a skeptical executor and fix " + + "its findings, present the hardened plan to the user, and call " + + "switch_to_act_mode again once they approve it."; + +// Pre-presentation half of the plan gate. Cline offers no hook moment before a +// plan is shown (presentation is a free-form assistant message), so this rule +// is what puts the hardening beat ahead of presentation; the switch_to_act_mode +// hook below is the deterministic backstop. +const PLAN_PRESENTATION_RULE = + "Plan-mode discipline: when you are working in plan mode, never present a " + + "drafted plan to the user until you have invoked the hardening-plans skill " + + "on it and applied its findings — a plan reaches the user hardened or not " + + "at all. The switch_to_act_mode tool is gated the same way: if it is " + + "skipped with a hardening instruction, run hardening-plans on the plan, " + + "present the hardened plan, and wait for approval before calling " + + "switch_to_act_mode again."; + +// Already-hardened short-circuit (parity with hooks/exit-plan-mode, issue +// #153): if the agent ran hardening-plans this conversation, the transcript +// holds a skills tool call whose input names the skill. Match that tool-input +// shape ONLY — never prose — so this hook's own skip reason (which mentions +// "hardening-plans" and lands in the transcript as tool output) can never +// false-positive. Any missing/odd shape falls through to deny-once below. +function planAlreadyHardened(context) { + const messages = context?.snapshot?.messages; + if (!Array.isArray(messages)) return false; + for (const message of messages) { + const content = message?.content; + if (!Array.isArray(content)) continue; + for (const part of content) { + if (part?.type !== "tool-call" || part?.toolName !== "skills") continue; + const input = part.input; + const skill = + input && typeof input === "object" ? input.skill : undefined; + if (typeof skill === "string" && skill.includes("hardening-plans")) { + return true; + } + } + } + return false; +} /** @type {import("@cline/sdk").AgentPlugin} */ const SlowPowersPlugin = { @@ -89,6 +147,11 @@ const SlowPowersPlugin = { source: "slow-powers", content: bootstrap, }); + api.registerRule({ + id: "slow-powers/plan-presentation", + source: "slow-powers", + content: PLAN_PRESENTATION_RULE, + }); }, hooks: { @@ -100,9 +163,13 @@ const SlowPowersPlugin = { const toolName = context?.tool?.name ?? context?.toolCall?.name; if (toolName !== "switch_to_act_mode") return undefined; + // The agent already hardened the plan this conversation — let the + // approved plan be executed with no redundant beat. + if (planAlreadyHardened(context)) return undefined; + const marker = markerPath(context); if (fs.existsSync(marker)) { - // Re-submission after hardening — let the plan be presented. + // Re-submission after the skip-once beat — let it through. return undefined; } diff --git a/memory-bank/activeContext.md b/memory-bank/activeContext.md index b3c2086..40f0841 100644 --- a/memory-bank/activeContext.md +++ b/memory-bank/activeContext.md @@ -2,57 +2,72 @@ ## Current focus -Cline support was just added (August 2026). Two halves: +Cline plan-gate timing fix (August 2026). The first live test of the Cline +plugin showed the gate rejecting an un-hardened plan only AFTER the plan was +presented and approved — because `switch_to_act_mode` is called post-approval +in Cline, unlike Claude's `ExitPlanMode` which carries the plan text. The gate +is now two-layer: 1. **Cline plugin** (`cline/plugins/slow-powers.js`, declared via the `cline` - field in `package.json`): registers `bootstrap.md` as a session rule and - gates the first `switch_to_act_mode` of each conversation on - hardening-plans (skip-once + tmp marker, mirroring `hooks/exit-plan-mode`). + field in `package.json`): registers `bootstrap.md` AND a + `slow-powers/plan-presentation` rule (harden before presenting — the only + mechanism that reaches the agent pre-presentation), and gates + `switch_to_act_mode` as a pre-EXECUTION backstop with an + already-hardened transcript short-circuit (skip-once marker as fail-open + floor, mirroring `hooks/exit-plan-mode`). Skills are auto-discovered from the package root — no wiring needed. 2. **Repo-local Cline setup**: `.clinerules/memory-bank.md` (canonical Memory Bank instructions) and this `memory-bank/` directory, both committed. ## Recent changes -- `cline/plugins/slow-powers.js` (new), `package.json` `cline` field + `files` -- `tests/harness/spec.ts` Cline entry; Cline assertions in `manifests.test.ts`; - new `tests/harness/cline-plugin.test.ts` -- README Cline install section; AGENTS.md four-harness update; - `.gitignore` covers `.cline/plugins/` install artifacts +- `fix/cline-plan-gate-timing` branch: plugin header docs rewritten (real + Cline plan flow), `PLAN_PRESENTATION_RULE` added, `planAlreadyHardened()` + transcript scan added (matches the `skills` tool-input shape only, never + prose, so the hook's own skip reason can't false-positive), `SKIP_REASON` + reworded for execution-gate semantics; 5 new tests in + `tests/harness/cline-plugin.test.ts` (rule registration, short-circuit, + false-positive guards, full flow). +- Earlier (merged via PR #266/#267/#268): `cline/plugins/slow-powers.js` (new), + `package.json` `cline` field + `files`; `tests/harness/spec.ts` Cline entry; + Cline assertions in `manifests.test.ts`; README Cline install section; + AGENTS.md four-harness update; `.gitignore` covers `.cline/plugins/`. -## Verification results (Cline CLI 3.0.51, headless) +## Verification results -- `cline plugin install --cwd ` works; installer copies the - repo and registers the plugin entry. -- Live session: all 8 skills discovered; `` bootstrap - block present in instructions; bootstrap behavior observed (agent invoked a - skill on a ~1% match, per the bootstrap rule). +- `bun test`: 167 pass / 0 fail; typecheck and biome clean on changed files. + (Baseline note: `bun run check` fails on three pre-existing + `.eval-magic/hardening-plans/iteration-2` eval-fixture files — unrelated.) +- Live (Cline CLI 3.0.51, headless): install, skills discovery, bootstrap rule + injection confirmed. First interactive test exposed the gate-timing issue + this branch fixes. - Plan gate: unit-tested against the documented `AgentBeforeToolResult` - contract. The runtime's `skip` handling (tool doesn't run, `reason` goes to - the model) and the hook context shape were confirmed in the shipped CLI - source — the first-party `core.plan-mode-command-guard` extension uses the - same pattern. `switch_to_act_mode` is NOT exposed in headless one-shot - sessions, so an interactive (TUI) confirmation of the gate firing is the one - remaining manual check. + contract; runtime `skip` handling and hook context shape confirmed in the + shipped CLI source. `switch_to_act_mode` is NOT exposed in headless one-shot + sessions, so an interactive (TUI) confirmation of the new two-layer behavior + is the one remaining manual check. ## Next steps -- PR opened: https://github.com/slowdini/slow-powers/pull/266 (base `dev`). -- Manually confirm the plan gate in an interactive `cline -i` plan-mode - session (present plan → approve → first `switch_to_act_mode` gets skipped - with the hardening instruction) — easiest via a test release, per the - maintainer. -- After merge to `dev`: trigger the Release PR workflow with the next version - to ship the Cline plugin (that release doubles as the test release). +- Manually confirm the new behavior in an interactive `cline -i` plan-mode + session: with the rule active the agent should harden BEFORE presenting; + if it skips hardening, the first `switch_to_act_mode` after approval is + skipped with the hardening instruction and the retry (transcript now holds + the skills call) passes. +- Then open the PR for `fix/cline-plan-gate-timing` (base `dev`). ## Active decisions - Distribution reuses the root `package.json` (git install); no separate npm package or release-workflow change. -- The Cline gate is skip-once only. The already-hardened short-circuit - (upstream #153 refinement) is deferred — it needs reliable detection that - hardening-plans already ran (the skill-invocation tool is `skills` in the - Cline runtime). +- Pre-presentation enforcement is a RULE, not a hook: Cline has no hook moment + before a plan is shown (verified against the installed binary and + `@cline/shared` `AgentRuntimeHooks`). The hook stays as the pre-execution + backstop. Trust guarantee moves from "user only ever sees a hardened plan" + (Claude, achievable) to "an un-hardened plan is never executed, and hook + firing routes the agent to harden + re-present" (Cline). +- The already-hardened short-circuit (upstream #153 refinement) is now + implemented for Cline via the `snapshot.messages` transcript scan. - No `.cline/skills/` dogfooding symlinks: Cline's skill registry is last-wins with plugin dirs scanned *after* workspace dirs, so an installed slow-powers plugin would silently shadow the repo's skills. The @@ -64,11 +79,25 @@ Cline support was just added (August 2026). Two halves: - Cline plugins load only in CLI/SDK/Kanban — not VSCode/JetBrains. IDE users get skills via manual copy into `.cline/skills/` or `~/.cline/skills/`. - Cline reads `AGENTS.md` natively; no memory-file symlink needed for it. -- Cline's plan-exit tool is `switch_to_act_mode`; `AgentBeforeToolResult.skip` - + `reason` is the deny mechanism; `registerRule` puts content in the system - prompt every session. +- **Cline plan-mode flow (verified in CLI 3.0.51 source):** the plan is + presented as a free-form assistant message; the CLI's plan-mode system + prompt and the `switch_to_act_mode` tool description both mandate: present + plan → end turn → user approves in a follow-up message → ONLY THEN call + `switch_to_act_mode` (`lifecycle.completesRun`, then a continuation turn + with "The user approved switching to act mode..."). So + `switch_to_act_mode` is an execution boundary, never a presentation moment. +- **Complete plugin hook surface** (`AgentRuntimeHooks`, binary + SDK agree): + `beforeRun`, `afterRun` (observe), `beforeModel` (rewrite request / stop), + `afterModel` (stop only — and `stop:true` aborts the whole run), + `beforeTool` (skip/input/policy/stop), `afterTool` (result/stop), + `onEvent` (observe only). Nothing fires before streamed assistant text, + so no hook can gate plan presentation. - Hook contexts pass the tool name on BOTH `tool.name` (first-party shape) and - `toolCall.name` (docs shape) — read `tool?.name ?? toolCall?.name`. + `toolCall.name` (docs shape) — read `tool?.name ?? toolCall?.name`. The + `beforeTool` context also carries `snapshot.messages` — the full + conversation transcript, usable for detection logic. +- Skill invocation in Cline goes through a `skills` tool with input + `{skill, args}` — match that shape for skill-use detection. - Headless one-shot sessions (`cline -p "..."`) don't expose `switch_to_act_mode` and can't drive TTY-only commands (`cline config`); use interactive sessions for plan-gate verification. diff --git a/memory-bank/progress.md b/memory-bank/progress.md index 0b10832..fa3d679 100644 --- a/memory-bank/progress.md +++ b/memory-bank/progress.md @@ -5,16 +5,22 @@ - Eight skills with eval coverage; bootstrap injection and plan gates on Claude Code, Codex CLI, and OpenCode. - Full test suite green (`bun test`), typecheck and biome clean. -- **Cline support (new)**: plugin entry, manifest field, unit + manifest - tests, README/AGENTS.md docs, memory bank initialized. Verified live on - Cline CLI 3.0.51: install, skills discovery, and bootstrap rule injection - all confirmed in headless sessions. +- **Cline support**: plugin entry, manifest field, unit + manifest tests, + README/AGENTS.md docs, memory bank initialized. Verified live on Cline CLI + 3.0.51: install, skills discovery, and bootstrap rule injection confirmed + in headless sessions. +- **Cline plan-gate timing fixed** (`fix/cline-plan-gate-timing`): first live + test showed the old skip-once hook firing after plan presentation and + approval (Cline's `switch_to_act_mode` is post-approval by design). Now + two-layer: a plan-presentation rule enforces hardening BEFORE presentation + (no Cline hook fires earlier than that), and the hook is the pre-execution + backstop with an already-hardened transcript short-circuit. ## What's left -- Manual interactive check of the Cline plan gate (`switch_to_act_mode` is - only exposed in interactive sessions), then PR. -- Release: next version bump will carry the Cline plugin via the normal flow. +- Commit/PR for `fix/cline-plan-gate-timing`; manual interactive check of the + new two-layer gate (`switch_to_act_mode` is only exposed in interactive + sessions), then release: next version bump carries the Cline plugin. ## Known issues / deferred @@ -24,8 +30,10 @@ workspace ones, so an installed slow-powers plugin shadows same-named workspace skills (the reverse of what this repo wants for development). Deferred: cross-harness installed-vs-repo precedence exploration. -- Cline plan gate has no already-hardened short-circuit yet (deferred; needs - skill-invocation detection). +- Cline pre-presentation enforcement is prompt-level (rule) — Cline exposes no + hook moment before streamed assistant text. The hook backstop guarantees an + un-hardened plan is never executed; if it fires, the user briefly saw an + un-hardened draft before the agent hardens and re-presents. ## Decision log @@ -35,3 +43,7 @@ `memory-bank/`). - 2026-08: No `.cline/skills/` symlinks (option (c)) pending the precedence exploration. +- 2026-08: Cline plan gate re-anchored to a two-layer design (rule + pre-presentation + hook pre-execution backstop) after the first live test + showed `switch_to_act_mode` fires post-approval in Cline; the deferred + already-hardened short-circuit implemented via `snapshot.messages` scan. diff --git a/memory-bank/systemPatterns.md b/memory-bank/systemPatterns.md index b788965..1c645ae 100644 --- a/memory-bank/systemPatterns.md +++ b/memory-bank/systemPatterns.md @@ -17,7 +17,7 @@ mechanism: | Claude | `hooks/session-start` (SessionStart) | `hooks/exit-plan-mode` (PreToolUse, deny-once) | | Codex | shared `hooks/hooks.json` SessionStart | `hooks/codex-stop-plan-mode` (Stop hook) | | OpenCode | `opencode/plugins/slow-powers.js` system-prompt transform | same plugin, `file.edited` event on plan files | -| Cline | `cline/plugins/slow-powers.js` `registerRule` | same plugin, `beforeTool` skip-once on `switch_to_act_mode` | +| Cline | `cline/plugins/slow-powers.js` `registerRule` (bootstrap + plan-presentation rules) | same plugin, `beforeTool` on `switch_to_act_mode` — pre-execution backstop: transcript short-circuit when hardening-plans already ran, else skip-once | Claude/Codex hooks are extensionless bash scripts dispatched by the `hooks/run-hook.cmd` polyglot (Windows-safe). OpenCode/Cline integrations are diff --git a/tests/harness/cline-plugin.test.ts b/tests/harness/cline-plugin.test.ts index 15ac288..22a580d 100644 --- a/tests/harness/cline-plugin.test.ts +++ b/tests/harness/cline-plugin.test.ts @@ -25,10 +25,39 @@ interface FakeApi { registerRule: (rule: RuleContribution) => void; } +interface ToolCallPart { + type: "tool-call"; + toolCallId?: string; + toolName?: string; + input?: unknown; +} + +interface TextPart { + type: "text"; + text?: string; +} + +interface ToolResultPart { + type: "tool-result"; + toolCallId?: string; + toolName?: string; + output?: unknown; + isError?: boolean; +} + +interface FakeMessage { + role?: string; + content?: (ToolCallPart | TextPart | ToolResultPart)[] | string; +} + interface HookContext { tool?: { name?: string }; toolCall?: { name?: string; input?: unknown }; - snapshot?: { conversationId?: string; agentId?: string }; + snapshot?: { + conversationId?: string; + agentId?: string; + messages?: FakeMessage[]; + }; } interface BeforeToolResult { @@ -91,14 +120,23 @@ describe("cline plugin module", () => { expect(typeof plugin.hooks?.beforeTool).toBe("function"); }); - test("setup registers bootstrap.md as a session rule", () => { + test("setup registers bootstrap.md and the plan-presentation rule", () => { const registered: RuleContribution[] = []; plugin.setup?.({ registerRule: (rule) => registered.push(rule) }); - expect(registered.length).toBe(1); + expect(registered.length).toBe(2); expect(registered[0].id).toBe("slow-powers/bootstrap"); expect(registered[0].source).toBe("slow-powers"); expect(registered[0].content).toContain(BOOTSTRAP_MARKER); + + // The second rule is the pre-presentation half of the plan gate: it is the + // only mechanism that reaches the agent before a plan is shown, so it must + // demand hardening-plans before presentation. + expect(registered[1].id).toBe("slow-powers/plan-presentation"); + expect(registered[1].source).toBe("slow-powers"); + expect(registered[1].content).toContain("hardening-plans"); + expect(registered[1].content.toLowerCase()).toContain("before"); + expect(registered[1].content.toLowerCase()).toContain("present"); }); }); @@ -156,3 +194,97 @@ describe("cline plugin plan gate", () => { expect(beforeTool({ toolCall: {} })).toBeUndefined(); }); }); + +// Mirrors the runtime's assistant-message shape for a skills-tool invocation. +function skillsCallMessage(skill: string): FakeMessage { + return { + role: "assistant", + content: [ + { + type: "tool-call", + toolCallId: "call_skills_1", + toolName: "skills", + input: { skill }, + }, + ], + }; +} + +const switchContext = ( + conversationId: string, + messages?: FakeMessage[], +): HookContext => ({ + tool: { name: "switch_to_act_mode" }, + toolCall: { name: "switch_to_act_mode", input: {} }, + snapshot: { conversationId, messages }, +}); + +describe("cline plugin already-hardened short-circuit", () => { + test("allows switch_to_act_mode when hardening-plans was invoked (bare name)", () => { + const result = beforeTool( + switchContext("conv-H1", [skillsCallMessage("hardening-plans")]), + ); + + expect(result).toBeUndefined(); + // No marker needed: the transcript itself is the proof. + expect(markers()).toEqual([]); + }); + + test("allows it for the namespaced skill name too", () => { + const result = beforeTool( + switchContext("conv-H2", [ + skillsCallMessage("slow-powers:hardening-plans"), + ]), + ); + + expect(result).toBeUndefined(); + expect(markers()).toEqual([]); + }); + + test("full flow: skip un-hardened, allow after the agent hardens and retries", () => { + expect(beforeTool(switchContext("conv-flow"))?.skip).toBe(true); + expect( + beforeTool( + switchContext("conv-flow", [skillsCallMessage("hardening-plans")]), + ), + ).toBeUndefined(); + }); + + test("does not false-positive on prose mentions of the skill", () => { + // A prior skip reason lands in the transcript as tool output and mentions + // "hardening-plans" in prose. Only the skills tool-input shape may count, + // mirroring hooks/exit-plan-mode's false-positive guard. + const result = beforeTool( + switchContext("conv-H3", [ + { + role: "assistant", + content: [ + { type: "text", text: "you must use the hardening-plans skill" }, + ], + }, + { + role: "tool", + content: [ + { + type: "tool-result", + toolCallId: "call_x", + toolName: "switch_to_act_mode", + output: "use the hardening-plans skill first", + isError: true, + }, + ], + }, + ]), + ); + + expect(result?.skip).toBe(true); + }); + + test("does not false-positive on other skills invocations", () => { + const result = beforeTool( + switchContext("conv-H4", [skillsCallMessage("test-driven-development")]), + ); + + expect(result?.skip).toBe(true); + }); +});