Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
35 commits
Select commit Hold shift + click to select a range
de925de
Preserve truthful approval semantics for Claude plan bypass
AgileInnov8tor May 5, 2026
c92abe2
Keep approval payloads truthful across Claude Code and Pi
AgileInnov8tor May 5, 2026
5661731
Keep Plannotator approvals actionable for bypass clear context
AgileInnov8tor May 6, 2026
0211777
feat(hook): auto-confirm native plan-accept dialog via keystroke inje…
AgileInnov8tor May 11, 2026
2f1d5a9
refactor(hook): deslop keystrokeInjector — collapse builders, unify s…
AgileInnov8tor May 11, 2026
cdd0e83
fix(hook): detect WarpTerminal via bundle name, not process name
AgileInnov8tor May 11, 2026
0826118
Keep plan approvals in the current session by default
AgileInnov8tor May 11, 2026
78243fe
omx(team): auto-checkpoint worker-4 [unknown]
AgileInnov8tor May 14, 2026
ea2ad0f
omx(team): auto-checkpoint worker-4 [unknown]
AgileInnov8tor May 14, 2026
2e1b50e
omx(team): auto-checkpoint worker-4 [unknown]
AgileInnov8tor May 14, 2026
95b47f6
Install Plannotator command skills under Codex home (#669)
backnotprop May 5, 2026
7f0c8f0
Expose bypass clear reminder permission mode (#668)
AgileInnov8tor May 5, 2026
e2441cc
task: resolve hook server conflict surfaces
AgileInnov8tor May 14, 2026
b341b3a
Preserve truthful approval semantics for Claude plan bypass
AgileInnov8tor May 5, 2026
ef9640a
Keep approval payloads truthful across Claude Code and Pi
AgileInnov8tor May 5, 2026
77ac251
Keep Plannotator approvals actionable for bypass clear context
AgileInnov8tor May 6, 2026
fb38d31
Keep plan approvals in the current session by default
AgileInnov8tor May 11, 2026
d6e92a5
Expose bypass clear reminder permission mode (#668)
AgileInnov8tor May 5, 2026
3b9fda4
Revert "Expose bypass clear reminder permission mode (#668)"
backnotprop May 6, 2026
3e38041
feat(review): add jj review workflows (#675)
graemefolk May 8, 2026
8685266
feat(hook): PFM reminder & improvement hook support across all runtim…
backnotprop May 11, 2026
863d52d
feat(pfm): code line range references, hover preview, sketch Graphviz…
backnotprop May 11, 2026
734d75a
omx(team): auto-checkpoint worker-4 [unknown]
AgileInnov8tor May 14, 2026
933544a
Expose bypass clear reminder permission mode (#668)
AgileInnov8tor May 5, 2026
ba50652
Revert "Expose bypass clear reminder permission mode (#668)"
backnotprop May 6, 2026
8201366
feat(hook): PFM reminder & improvement hook support across all runtim…
backnotprop May 11, 2026
cc84392
feat(pfm): code line range references, hover preview, sketch Graphviz…
backnotprop May 11, 2026
fe70c47
feat: standalone skills package + HTML render-annotate mode (#687)
backnotprop May 11, 2026
e5384b1
feat(ui): copyable hook path + guidance in Settings Hooks tab (#707)
backnotprop May 12, 2026
f65e451
Preserve truthful approval semantics for Claude plan bypass
AgileInnov8tor May 5, 2026
4a27c9b
Keep approval payloads truthful across Claude Code and Pi
AgileInnov8tor May 5, 2026
8647968
omx(team): checkpoint worker-1 shutdown changes
AgileInnov8tor May 6, 2026
59f089e
task: Resolve UI/editor conflict surfaces
AgileInnov8tor May 14, 2026
a3316d4
omx(team): merge worker-3
AgileInnov8tor May 14, 2026
7187057
fix(hook): fail closed for clear-context approvals
AgileInnov8tor May 14, 2026
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
146 changes: 146 additions & 0 deletions apps/hook/server/clearContextSetting.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,146 @@
import { afterEach, beforeEach, describe, expect, mock, test } from "bun:test";
import {
existsSync,
mkdirSync,
mkdtempSync,
readFileSync,
rmSync,
writeFileSync,
} from "fs";
import { tmpdir } from "os";
import { join } from "path";

let tmpHome: string;

beforeEach(() => {
tmpHome = mkdtempSync(join(tmpdir(), "plannotator-clear-context-test-"));
mock.module("os", () => {
const realOs = require("node:os");
return { ...realOs, homedir: () => tmpHome };
});
});

afterEach(() => {
mock.restore();
rmSync(tmpHome, { recursive: true, force: true });
});

async function freshImport() {
return (await import(
`./clearContextSetting?t=${Date.now()}-${Math.random()}`
)) as typeof import("./clearContextSetting");
}

function writeConsent() {
mkdirSync(join(tmpHome, ".plannotator", "consent"), { recursive: true });
writeFileSync(
join(tmpHome, ".plannotator", "consent", "clear-context-setting.json"),
JSON.stringify({ consented: true }),
"utf8",
);
}

describe("clearContextSetting", () => {
test("does not create settings without consent", async () => {
const { ensureClearContextSettingEnabled } = await freshImport();
await ensureClearContextSettingEnabled();
expect(existsSync(join(tmpHome, ".claude", "settings.json"))).toBe(false);
});

test("creates settings with showClearContextOnPlanAccept when consent exists", async () => {
writeConsent();
const { ensureClearContextSettingEnabled } = await freshImport();
await ensureClearContextSettingEnabled();

const settings = JSON.parse(
readFileSync(join(tmpHome, ".claude", "settings.json"), "utf8"),
);
expect(settings.showClearContextOnPlanAccept).toBe(true);
});

test("preserves existing settings keys", async () => {
writeConsent();
mkdirSync(join(tmpHome, ".claude"), { recursive: true });
writeFileSync(
join(tmpHome, ".claude", "settings.json"),
JSON.stringify({ theme: "dark", env: { A: "B" } }),
"utf8",
);

const { ensureClearContextSettingEnabled } = await freshImport();
await ensureClearContextSettingEnabled();

const settings = JSON.parse(
readFileSync(join(tmpHome, ".claude", "settings.json"), "utf8"),
);
expect(settings.theme).toBe("dark");
expect(settings.env).toEqual({ A: "B" });
expect(settings.showClearContextOnPlanAccept).toBe(true);
});

test("is idempotent when setting is already enabled", async () => {
writeConsent();
mkdirSync(join(tmpHome, ".claude"), { recursive: true });
writeFileSync(
join(tmpHome, ".claude", "settings.json"),
JSON.stringify({ showClearContextOnPlanAccept: true }),
"utf8",
);

const { ensureClearContextSettingEnabled } = await freshImport();
await ensureClearContextSettingEnabled();
await ensureClearContextSettingEnabled();

const settings = JSON.parse(
readFileSync(join(tmpHome, ".claude", "settings.json"), "utf8"),
);
expect(settings.showClearContextOnPlanAccept).toBe(true);
});

test("leaves malformed settings JSON untouched", async () => {
writeConsent();
mkdirSync(join(tmpHome, ".claude"), { recursive: true });
const malformed = "{ this is not valid json";
writeFileSync(join(tmpHome, ".claude", "settings.json"), malformed, "utf8");

const { ensureClearContextSettingEnabled } = await freshImport();
await ensureClearContextSettingEnabled();

expect(readFileSync(join(tmpHome, ".claude", "settings.json"), "utf8")).toBe(
malformed,
);
});

test("records consent atomically", async () => {
const { recordConsent } = await freshImport();
recordConsent();

const consentPath = join(
tmpHome,
".plannotator",
"consent",
"clear-context-setting.json",
);
expect(existsSync(consentPath)).toBe(true);
const consent = JSON.parse(readFileSync(consentPath, "utf8"));
expect(consent.consented).toBe(true);
expect(typeof consent.recordedAt).toBe("string");
});

test("reports disabled when settings are missing", async () => {
const { isClearContextSettingEnabled } = await freshImport();
expect(isClearContextSettingEnabled()).toBe(false);
});

test("reports enabled when setting is true", async () => {
mkdirSync(join(tmpHome, ".claude"), { recursive: true });
writeFileSync(
join(tmpHome, ".claude", "settings.json"),
JSON.stringify({ showClearContextOnPlanAccept: true }),
"utf8",
);

const { isClearContextSettingEnabled } = await freshImport();
expect(isClearContextSettingEnabled()).toBe(true);
});
});
105 changes: 105 additions & 0 deletions apps/hook/server/clearContextSetting.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,105 @@
import {
existsSync,
mkdirSync,
readFileSync,
renameSync,
writeFileSync,
} from "fs";
import { randomBytes } from "crypto";
import { homedir } from "os";
import { dirname, join } from "path";

const SETTING_KEY = "showClearContextOnPlanAccept";

function consentPath(): string {
return join(
homedir(),
".plannotator",
"consent",
"clear-context-setting.json",
);
}

function settingsPath(): string {
return join(homedir(), ".claude", "settings.json");
}

function hasConsent(): boolean {
try {
if (!existsSync(consentPath())) return false;
const data = JSON.parse(readFileSync(consentPath(), "utf8"));
return data?.consented === true;
} catch {
return false;
}
}

function writeJsonAtomic(path: string, data: Record<string, unknown>): void {
const tmp = join(
dirname(path),
`plannotator-settings-${randomBytes(4).toString("hex")}.json`,
);
writeFileSync(tmp, JSON.stringify(data, null, 2) + "\n", "utf8");
renameSync(tmp, path);
}

export function recordConsent(): void {
const dir = join(homedir(), ".plannotator", "consent");
mkdirSync(dir, { recursive: true });
writeJsonAtomic(consentPath(), {
consented: true,
recordedAt: new Date().toISOString(),
});
}

export function isClearContextSettingEnabled(): boolean {
try {
if (!existsSync(settingsPath())) return false;
const settings = JSON.parse(readFileSync(settingsPath(), "utf8"));
return settings?.[SETTING_KEY] === true;
} catch {
return false;
}
}

export async function ensureClearContextSettingEnabled(): Promise<boolean> {
if (!hasConsent()) {
console.error(
"[plannotator] clearContextSetting: no consent recorded; skipping settings mutation",
);
return isClearContextSettingEnabled();
}

let settings: Record<string, unknown>;
try {
settings = existsSync(settingsPath())
? JSON.parse(readFileSync(settingsPath(), "utf8"))
: {};
} catch (error: any) {
console.error(
`[plannotator] clearContextSetting: malformed settings JSON; skipping mutation: ${error?.message}`,
);
return false;
}

if (settings[SETTING_KEY] === true) return true;

settings[SETTING_KEY] = true;
mkdirSync(join(homedir(), ".claude"), { recursive: true });

try {
writeJsonAtomic(settingsPath(), settings);
} catch (error: any) {
try {
await Bun.sleep(50);
writeJsonAtomic(settingsPath(), settings);
} catch (retryError: any) {
console.error(
`[plannotator] clearContextSetting: write failed after retry; skipping: ${retryError?.message}`,
);
return false;
}
}

return true;
}
56 changes: 56 additions & 0 deletions apps/hook/server/hookDecision.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,56 @@
import { describe, expect, test } from "bun:test";
import { formatClaudePlanHookOutput } from "./hookDecision";

describe("formatClaudePlanHookOutput", () => {
test("native handoff emits PreToolUse ask only when native clear was enabled", () => {
expect(formatClaudePlanHookOutput({
result: { approved: true, permissionMode: "bypassPermissions", deferToNativeForClear: true },
hookEventName: "PreToolUse",
toolName: "ExitPlanMode",
detectedOrigin: "claude-code",
nativeClearEnabled: true,
})).toEqual({
hookSpecificOutput: {
hookEventName: "PreToolUse",
permissionDecision: "ask",
},
});
});

test("PermissionRequest native defer falls back to explicit allow JSON", () => {
const output = formatClaudePlanHookOutput({
result: { approved: true, deferToNativeForClear: true },
hookEventName: "PermissionRequest",
toolName: "ExitPlanMode",
detectedOrigin: "claude-code",
nativeClearEnabled: true,
}) as any;

expect(output.hookSpecificOutput.hookEventName).toBe("PermissionRequest");
expect(output.hookSpecificOutput.decision.behavior).toBe("allow");
expect(output.hookSpecificOutput.decision.updatedPermissions).toEqual([
{ type: "setMode", mode: "bypassPermissions", destination: "session" },
]);
expect(output.systemMessage).toContain("/clear");
});

test("normal PermissionRequest approval includes updatedPermissions", () => {
expect(formatClaudePlanHookOutput({
result: { approved: true, permissionMode: "bypassPermissions", clearContextNudge: true },
hookEventName: "PermissionRequest",
toolName: "ExitPlanMode",
detectedOrigin: "claude-code",
})).toEqual({
systemMessage: expect.stringContaining("/clear"),
hookSpecificOutput: {
hookEventName: "PermissionRequest",
decision: {
behavior: "allow",
updatedPermissions: [
{ type: "setMode", mode: "bypassPermissions", destination: "session" },
],
},
},
});
});
});
Loading
Loading