feat: pi project config — mifune banner, path-guard, harness-context skill, CI wiring
Stand up .pi/ as a real project workspace for the pi-coding-agent CLI:
- settings.json: OpenAI defaults (gpt-4o), model cycling list, declared resource paths
- mifune-banner extension: flush-left MIFUNE wordmark + pi harness · ryaneggz/mifune subtitle
- path-guard extension: tool_call interceptor with case-insensitive risky-bash regex,
hasUI headless guard, /dev/sd*|nvme*|hd*|disk* bare-redirection match, defensive
toolName.toLowerCase() (verified lowercase in pi source), canonical input key `path`
- APPEND_SYSTEM.md: generic, consumer-agnostic
- prompts/review.md: /review template
- skills + rules: directory symlinks to ../.claude/{skills,rules} (single source of
truth, mirrors .codex/ pattern)
- new canonical .claude/skills/harness-context/SKILL.md reachable from pi via symlink
Tests at .pi/extensions/__tests__/{path-guard,mifune-banner}.test.ts (32 + 7 cases,
hand-rolled mock ExtensionAPI, no pi devDep). vitest.config.ts include glob extended
to .pi/**/__tests__/**/*.test.ts. ci-harness.yml paths filter extended and a new
"Root tests" step runs pnpm test:scripts (also lifts cron-runtime.test.ts into CI).
86 tests passing across 6 files.
Closes #255
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
1 parent
ae40b56024
commit
00078a7e8b
19 files changed
+1445
-1
No files matched your search
@@ -0,0 +1,40 @@
|
||||
---
|
||||
name: harness-context
|
||||
description: |
|
||||
Explain the Open Harness architecture, layout, and conventions.
|
||||
Use this skill when the user asks about project structure, layout,
|
||||
git workflow, rules, or how things are organized. Returns specific
|
||||
file paths and `path:line` citations.
|
||||
TRIGGER when: asked about harness structure, layout, conventions,
|
||||
rules, process, or project organization.
|
||||
---
|
||||
|
||||
# Harness Context
|
||||
|
||||
Use this skill when the user asks about Open Harness layout, lifecycle,
|
||||
conventions, git workflow, or where files live.
|
||||
|
||||
## Steps
|
||||
|
||||
1. Read `CLAUDE.md` for the orchestrator contract — what the root-level
|
||||
agent does and does not do.
|
||||
2. Read `docs/architecture/container-runtime.md` under the `## Repo
|
||||
Layout` heading for the canonical annotated repo tree. This is the
|
||||
single source of truth for layout — do not paraphrase it elsewhere.
|
||||
3. Read relevant rules under `.claude/rules/`:
|
||||
- `git.md` — issue / branch / commit / PR conventions
|
||||
- `sandbox-processes.md` — tmux sessions for long-running processes
|
||||
- `repo-layout-source.md` — the no-duplicate-tree rule
|
||||
- `directory-readme.md` — when a directory needs a README
|
||||
- `advisor-model.md` — pattern for delegating to sub-agents
|
||||
4. Answer with `path:line` citations. For layout questions, link to the
|
||||
canonical anchor in `docs/architecture/container-runtime.md`; do not
|
||||
reproduce the tree.
|
||||
|
||||
## Output shape
|
||||
|
||||
- Short factual answer first, then file references.
|
||||
- Lifecycle questions (setup / validate / teardown): cite the relevant
|
||||
section of `CLAUDE.md`.
|
||||
- Convention questions: cite the specific rule file under
|
||||
`.claude/rules/`.
|
||||
@@ -5,9 +5,12 @@ on:
|
||||
push:
|
||||
paths:
|
||||
- "packages/**"
|
||||
- "scripts/**"
|
||||
- ".pi/**"
|
||||
- "package.json"
|
||||
- "pnpm-lock.yaml"
|
||||
- "pnpm-workspace.yaml"
|
||||
- "vitest.config.ts"
|
||||
- ".github/workflows/ci-harness.yml"
|
||||
|
||||
permissions:
|
||||
@@ -52,3 +55,6 @@ jobs:
|
||||
|
||||
- name: Test
|
||||
run: pnpm --filter './packages/**' -r run test
|
||||
|
||||
- name: Root tests (scripts + .pi extensions)
|
||||
run: pnpm test:scripts
|
||||
@@ -0,0 +1,3 @@
|
||||
sessions/
|
||||
git/
|
||||
npm/
|
||||
@@ -0,0 +1,5 @@
|
||||
## Project context
|
||||
|
||||
You are running inside a project workspace. Check `AGENTS.md` and `CLAUDE.md` if present — they define the project's voice, operating principles, and development workflow. Treat them as authoritative guides for how work happens here.
|
||||
|
||||
Application code and core logic are developed and tested within the local environment. Refer to project conventions for architecture patterns, tooling, and coding standards.
|
||||
@@ -0,0 +1,120 @@
|
||||
import { beforeEach, describe, expect, it, vi } from "vitest";
|
||||
import factory, { buildHeader } from "../mifune-banner";
|
||||
|
||||
type Handler = (event: any, ctx: any) => Promise<unknown> | unknown;
|
||||
type CommandDef = { description: string; handler: Handler };
|
||||
|
||||
interface MockPi {
|
||||
on: (event: string, handler: Handler) => void;
|
||||
registerCommand: (name: string, def: CommandDef) => void;
|
||||
}
|
||||
|
||||
interface Captured {
|
||||
sessionStartHandlers: Handler[];
|
||||
commands: Map<string, CommandDef>;
|
||||
}
|
||||
|
||||
function makePi(): { pi: MockPi; captured: Captured } {
|
||||
const captured: Captured = {
|
||||
sessionStartHandlers: [],
|
||||
commands: new Map(),
|
||||
};
|
||||
const pi: MockPi = {
|
||||
on: (event, handler) => {
|
||||
if (event === "session_start") captured.sessionStartHandlers.push(handler);
|
||||
},
|
||||
registerCommand: (name, def) => {
|
||||
captured.commands.set(name, def);
|
||||
},
|
||||
};
|
||||
return { pi, captured };
|
||||
}
|
||||
|
||||
const fakeTheme = {
|
||||
fg: (_role: string, text: string) => text,
|
||||
} as unknown as Parameters<typeof buildHeader>[0];
|
||||
|
||||
describe("mifune-banner extension", () => {
|
||||
let captured: Captured;
|
||||
|
||||
beforeEach(() => {
|
||||
const m = makePi();
|
||||
captured = m.captured;
|
||||
factory(m.pi as never);
|
||||
});
|
||||
|
||||
it("registers a session_start handler and the /builtin-header command", () => {
|
||||
expect(captured.sessionStartHandlers).toHaveLength(1);
|
||||
expect(captured.commands.has("builtin-header")).toBe(true);
|
||||
});
|
||||
|
||||
describe("buildHeader", () => {
|
||||
it("includes the wordmark and the repo URL in the subtitle", () => {
|
||||
const lines = buildHeader(fakeTheme);
|
||||
const joined = lines.join("\n");
|
||||
expect(joined).toContain("MIFUNE");
|
||||
expect(joined).toContain("pi harness");
|
||||
expect(joined).toContain("github.com/ryaneggz/mifune");
|
||||
});
|
||||
|
||||
it("includes the brand emojis (⚔ on wordmark, 🎬 on subtitle)", () => {
|
||||
const lines = buildHeader(fakeTheme);
|
||||
const wordmarkLine = lines.find((l) => l.includes("MIFUNE"));
|
||||
const subtitleLine = lines.find((l) => l.includes("ryaneggz/mifune"));
|
||||
expect(wordmarkLine).toContain("⚔");
|
||||
expect(subtitleLine).toContain("🎬");
|
||||
});
|
||||
|
||||
it("uses theme roles for color (accent for wordmark, muted for subtitle)", () => {
|
||||
const calls: Array<[string, string]> = [];
|
||||
const trackingTheme = {
|
||||
fg: (role: string, text: string) => {
|
||||
calls.push([role, text]);
|
||||
return text;
|
||||
},
|
||||
} as unknown as Parameters<typeof buildHeader>[0];
|
||||
buildHeader(trackingTheme);
|
||||
const wordmarkCall = calls.find(([, t]) => t === "MIFUNE");
|
||||
expect(wordmarkCall?.[0]).toBe("accent");
|
||||
const subtitleCall = calls.find(([, t]) => t.includes("ryaneggz/mifune"));
|
||||
expect(subtitleCall?.[0]).toBe("muted");
|
||||
});
|
||||
});
|
||||
|
||||
describe("session_start handler", () => {
|
||||
it("installs a header factory when the UI is available", async () => {
|
||||
const setHeader = vi.fn();
|
||||
const handler = captured.sessionStartHandlers[0];
|
||||
await handler({}, { hasUI: true, ui: { setHeader } });
|
||||
|
||||
expect(setHeader).toHaveBeenCalledOnce();
|
||||
const installedFactory = setHeader.mock.calls[0][0];
|
||||
const headerObj = installedFactory({}, fakeTheme);
|
||||
expect(typeof headerObj.render).toBe("function");
|
||||
expect(typeof headerObj.invalidate).toBe("function");
|
||||
|
||||
const lines = headerObj.render(80);
|
||||
expect(Array.isArray(lines)).toBe(true);
|
||||
expect(lines.join("\n")).toContain("MIFUNE");
|
||||
});
|
||||
|
||||
it("is a no-op when the UI is not available (e.g. RPC mode)", async () => {
|
||||
const setHeader = vi.fn();
|
||||
const handler = captured.sessionStartHandlers[0];
|
||||
await handler({}, { hasUI: false, ui: { setHeader } });
|
||||
expect(setHeader).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
|
||||
describe("/builtin-header command", () => {
|
||||
it("clears the custom header and notifies", async () => {
|
||||
const setHeader = vi.fn();
|
||||
const notify = vi.fn();
|
||||
const cmd = captured.commands.get("builtin-header")!;
|
||||
await cmd.handler("", { ui: { setHeader, notify } } as never);
|
||||
|
||||
expect(setHeader).toHaveBeenCalledWith(undefined);
|
||||
expect(notify).toHaveBeenCalledWith(expect.stringContaining("Built-in"), "info");
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,296 @@
|
||||
import { beforeEach, describe, expect, it, vi } from "vitest";
|
||||
import factory from "../path-guard";
|
||||
|
||||
type Handler = (event: any, ctx: any) => Promise<unknown>;
|
||||
type CommandDef = { description: string; handler: Handler };
|
||||
|
||||
interface MockPi {
|
||||
on: (event: string, handler: Handler) => void;
|
||||
registerCommand: (name: string, def: CommandDef) => void;
|
||||
}
|
||||
|
||||
interface Captured {
|
||||
toolCallHandlers: Handler[];
|
||||
commands: Map<string, CommandDef>;
|
||||
}
|
||||
|
||||
function makePi(): { pi: MockPi; captured: Captured } {
|
||||
const captured: Captured = {
|
||||
toolCallHandlers: [],
|
||||
commands: new Map(),
|
||||
};
|
||||
const pi: MockPi = {
|
||||
on: (event, handler) => {
|
||||
if (event === "tool_call") captured.toolCallHandlers.push(handler);
|
||||
},
|
||||
registerCommand: (name, def) => {
|
||||
captured.commands.set(name, def);
|
||||
},
|
||||
};
|
||||
return { pi, captured };
|
||||
}
|
||||
|
||||
function makeCtx(confirm: (title: string, body: string) => Promise<boolean>, hasUI = true) {
|
||||
const notify = vi.fn();
|
||||
return {
|
||||
notify,
|
||||
ctx: {
|
||||
hasUI,
|
||||
ui: {
|
||||
confirm: vi.fn(confirm),
|
||||
notify,
|
||||
},
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
async function fire(handlers: Handler[], event: unknown, ctx: unknown) {
|
||||
for (const h of handlers) {
|
||||
const result = await h(event, ctx);
|
||||
if (result !== undefined) return result;
|
||||
}
|
||||
return undefined;
|
||||
}
|
||||
|
||||
describe("path-guard extension", () => {
|
||||
let captured: Captured;
|
||||
|
||||
beforeEach(() => {
|
||||
const m = makePi();
|
||||
captured = m.captured;
|
||||
factory(m.pi as never);
|
||||
});
|
||||
|
||||
it("registers a tool_call handler and the /guard command", () => {
|
||||
expect(captured.toolCallHandlers).toHaveLength(1);
|
||||
expect(captured.commands.has("guard")).toBe(true);
|
||||
});
|
||||
|
||||
// Fix #1: hasUI guard tests
|
||||
describe("headless mode (hasUI === false)", () => {
|
||||
it("does not call confirm for write on sensitive path in headless mode", async () => {
|
||||
const { ctx } = makeCtx(async () => false, false);
|
||||
const result = await fire(
|
||||
captured.toolCallHandlers,
|
||||
{ toolName: "write", input: { path: ".env" } },
|
||||
ctx,
|
||||
);
|
||||
expect(ctx.ui.confirm).not.toHaveBeenCalled();
|
||||
expect(result).toBeUndefined();
|
||||
});
|
||||
|
||||
it("does not call confirm for bash risky command in headless mode", async () => {
|
||||
const { ctx } = makeCtx(async () => false, false);
|
||||
const result = await fire(
|
||||
captured.toolCallHandlers,
|
||||
{ toolName: "bash", input: { command: "rm -rf /tmp/x" } },
|
||||
ctx,
|
||||
);
|
||||
expect(ctx.ui.confirm).not.toHaveBeenCalled();
|
||||
expect(result).toBeUndefined();
|
||||
});
|
||||
});
|
||||
|
||||
describe("sensitive write/edit", () => {
|
||||
it.each([
|
||||
[".env"],
|
||||
["app/.env.production"],
|
||||
["secrets/api.json"],
|
||||
["config/credentials.yaml"],
|
||||
["keys/server.pem"],
|
||||
["keys/server.key"],
|
||||
["~/.ssh/id_rsa"],
|
||||
["~/.ssh/id_ed25519"],
|
||||
])("blocks write to %s when user declines", async (path) => {
|
||||
const { ctx } = makeCtx(async () => false);
|
||||
const result = await fire(
|
||||
captured.toolCallHandlers,
|
||||
// Fix #5: canonical key is `path` (verified from pi write.ts writeSchema)
|
||||
{ toolName: "write", input: { path } },
|
||||
ctx,
|
||||
);
|
||||
expect(ctx.ui.confirm).toHaveBeenCalledOnce();
|
||||
expect(result).toMatchObject({ block: true });
|
||||
});
|
||||
|
||||
it("allows write when user accepts", async () => {
|
||||
const { ctx } = makeCtx(async () => true);
|
||||
const result = await fire(
|
||||
captured.toolCallHandlers,
|
||||
{ toolName: "write", input: { path: ".env" } },
|
||||
ctx,
|
||||
);
|
||||
expect(ctx.ui.confirm).toHaveBeenCalledOnce();
|
||||
expect(result).toBeUndefined();
|
||||
});
|
||||
|
||||
it("ignores edits to ordinary paths", async () => {
|
||||
const { ctx } = makeCtx(async () => false);
|
||||
const result = await fire(
|
||||
captured.toolCallHandlers,
|
||||
{ toolName: "edit", input: { path: "src/index.ts" } },
|
||||
ctx,
|
||||
);
|
||||
expect(ctx.ui.confirm).not.toHaveBeenCalled();
|
||||
expect(result).toBeUndefined();
|
||||
});
|
||||
|
||||
it("ignores reads of sensitive paths (only write/edit guarded)", async () => {
|
||||
const { ctx } = makeCtx(async () => false);
|
||||
const result = await fire(
|
||||
captured.toolCallHandlers,
|
||||
{ toolName: "read", input: { path: ".env" } },
|
||||
ctx,
|
||||
);
|
||||
expect(ctx.ui.confirm).not.toHaveBeenCalled();
|
||||
expect(result).toBeUndefined();
|
||||
});
|
||||
|
||||
it("accepts alternative path field names (file_path, target, filename)", async () => {
|
||||
for (const key of ["file_path", "target", "filename"]) {
|
||||
const { ctx } = makeCtx(async () => false);
|
||||
const result = await fire(
|
||||
captured.toolCallHandlers,
|
||||
{ toolName: "write", input: { [key]: ".env" } },
|
||||
ctx,
|
||||
);
|
||||
expect(ctx.ui.confirm).toHaveBeenCalledOnce();
|
||||
expect(result).toMatchObject({ block: true });
|
||||
}
|
||||
});
|
||||
|
||||
it("does nothing when no path field is present", async () => {
|
||||
const { ctx } = makeCtx(async () => false);
|
||||
const result = await fire(
|
||||
captured.toolCallHandlers,
|
||||
{ toolName: "write", input: {} },
|
||||
ctx,
|
||||
);
|
||||
expect(ctx.ui.confirm).not.toHaveBeenCalled();
|
||||
expect(result).toBeUndefined();
|
||||
});
|
||||
|
||||
// Fix #4: capitalized tool names still fire the guard (toLowerCase normalization)
|
||||
it("fires guard for capitalized tool names (Write, Edit)", async () => {
|
||||
for (const toolName of ["Write", "Edit"]) {
|
||||
const { ctx } = makeCtx(async () => false);
|
||||
const result = await fire(
|
||||
captured.toolCallHandlers,
|
||||
{ toolName, input: { path: ".env" } },
|
||||
ctx,
|
||||
);
|
||||
expect(ctx.ui.confirm).toHaveBeenCalledOnce();
|
||||
expect(result).toMatchObject({ block: true });
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
describe("risky bash", () => {
|
||||
it.each([
|
||||
["rm -rf /tmp/x"],
|
||||
["sudo apt update"],
|
||||
["git push origin main --force"],
|
||||
["git reset --hard HEAD~3"],
|
||||
])("prompts and blocks %s when user declines", async (command) => {
|
||||
const { ctx } = makeCtx(async () => false);
|
||||
const result = await fire(
|
||||
captured.toolCallHandlers,
|
||||
{ toolName: "bash", input: { command } },
|
||||
ctx,
|
||||
);
|
||||
expect(ctx.ui.confirm).toHaveBeenCalledOnce();
|
||||
expect(result).toMatchObject({ block: true });
|
||||
});
|
||||
|
||||
// Fix #2: case-insensitive RISKY_BASH — uppercase variants must still trigger
|
||||
it.each([
|
||||
["RM -RF /tmp/x"],
|
||||
["Sudo apt update"],
|
||||
["GIT RESET --HARD HEAD~3"],
|
||||
])("prompts for uppercase risky command: %s", async (command) => {
|
||||
const { ctx } = makeCtx(async () => false);
|
||||
const result = await fire(
|
||||
captured.toolCallHandlers,
|
||||
{ toolName: "bash", input: { command } },
|
||||
ctx,
|
||||
);
|
||||
expect(ctx.ui.confirm).toHaveBeenCalledOnce();
|
||||
expect(result).toMatchObject({ block: true });
|
||||
});
|
||||
|
||||
// Fix #3: bare > /dev/... redirection without leading colon
|
||||
it.each([
|
||||
["> /dev/sda"],
|
||||
["dd if=/dev/zero > /dev/sdb"],
|
||||
["cat /dev/zero > /dev/nvme0"],
|
||||
])("prompts for bare block-device redirection: %s", async (command) => {
|
||||
const { ctx } = makeCtx(async () => false);
|
||||
const result = await fire(
|
||||
captured.toolCallHandlers,
|
||||
{ toolName: "bash", input: { command } },
|
||||
ctx,
|
||||
);
|
||||
expect(ctx.ui.confirm).toHaveBeenCalledOnce();
|
||||
expect(result).toMatchObject({ block: true });
|
||||
});
|
||||
|
||||
it("allows risky bash when user accepts", async () => {
|
||||
const { ctx } = makeCtx(async () => true);
|
||||
const result = await fire(
|
||||
captured.toolCallHandlers,
|
||||
{ toolName: "bash", input: { command: "rm -rf /tmp/x" } },
|
||||
ctx,
|
||||
);
|
||||
expect(result).toBeUndefined();
|
||||
});
|
||||
|
||||
it("does not prompt for ordinary bash commands", async () => {
|
||||
const { ctx } = makeCtx(async () => false);
|
||||
const result = await fire(
|
||||
captured.toolCallHandlers,
|
||||
{ toolName: "bash", input: { command: "ls -la" } },
|
||||
ctx,
|
||||
);
|
||||
expect(ctx.ui.confirm).not.toHaveBeenCalled();
|
||||
expect(result).toBeUndefined();
|
||||
});
|
||||
|
||||
it("truncates very long commands in the confirm body", async () => {
|
||||
const long = "rm -rf " + "/x".repeat(200);
|
||||
const { ctx } = makeCtx(async () => true);
|
||||
await fire(
|
||||
captured.toolCallHandlers,
|
||||
{ toolName: "bash", input: { command: long } },
|
||||
ctx,
|
||||
);
|
||||
const body = (ctx.ui.confirm as ReturnType<typeof vi.fn>).mock.calls[0][1];
|
||||
expect(body.length).toBeLessThanOrEqual(220);
|
||||
expect(body).toContain("...");
|
||||
});
|
||||
|
||||
// Fix #4: capitalized Bash tool name still fires the guard
|
||||
it("fires guard for capitalized tool name Bash", async () => {
|
||||
const { ctx } = makeCtx(async () => false);
|
||||
const result = await fire(
|
||||
captured.toolCallHandlers,
|
||||
{ toolName: "Bash", input: { command: "rm -rf /tmp/x" } },
|
||||
ctx,
|
||||
);
|
||||
expect(ctx.ui.confirm).toHaveBeenCalledOnce();
|
||||
expect(result).toMatchObject({ block: true });
|
||||
});
|
||||
});
|
||||
|
||||
describe("/guard command", () => {
|
||||
it("notifies with both protection lists", async () => {
|
||||
const { ctx } = makeCtx(async () => true);
|
||||
const cmd = captured.commands.get("guard")!;
|
||||
await cmd.handler("" as never, ctx as never);
|
||||
expect(ctx.ui.notify).toHaveBeenCalledOnce();
|
||||
const [message, level] = (ctx.ui.notify as ReturnType<typeof vi.fn>).mock.calls[0];
|
||||
expect(level).toBe("info");
|
||||
expect(message).toContain("Sensitive paths");
|
||||
expect(message).toContain("Risky bash patterns");
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,83 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import {
|
||||
extractPlanItems,
|
||||
formatPlanItems,
|
||||
isSafeReadOnlyCommand,
|
||||
markCompletedSteps,
|
||||
normalizeToolNames,
|
||||
pickPlanTools,
|
||||
} from "../plan-mode/utils";
|
||||
|
||||
describe("plan-mode utils", () => {
|
||||
describe("isSafeReadOnlyCommand", () => {
|
||||
it.each([
|
||||
"ls -la",
|
||||
"rg plan-mode .pi/extensions",
|
||||
"git status --short",
|
||||
"git diff -- README.md",
|
||||
"pnpm list --depth 0",
|
||||
"node --version",
|
||||
"sed -n '1,20p' README.md",
|
||||
])("allows read-only command: %s", (command) => {
|
||||
expect(isSafeReadOnlyCommand(command)).toBe(true);
|
||||
});
|
||||
|
||||
it.each([
|
||||
"rm -rf /tmp/x",
|
||||
"git push origin main",
|
||||
"git checkout -b feature",
|
||||
"pnpm install",
|
||||
"npm ci",
|
||||
"echo hi > file.txt",
|
||||
"mkdir new-dir",
|
||||
"sudo apt update",
|
||||
"vim README.md",
|
||||
])("blocks mutating command: %s", (command) => {
|
||||
expect(isSafeReadOnlyCommand(command)).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe("plan extraction", () => {
|
||||
it("extracts a numbered Plan section", () => {
|
||||
const items = extractPlanItems(`Analysis first.\n\nPlan:\n1. Read the current Pi extension setup\n2. Add a plan-mode extension\n3. Add tests\n\nThen wait.`);
|
||||
expect(items).toEqual([
|
||||
{ step: 1, text: "Read the current Pi extension setup", completed: false },
|
||||
{ step: 2, text: "Add a plan-mode extension", completed: false },
|
||||
{ step: 3, text: "Add tests", completed: false },
|
||||
]);
|
||||
});
|
||||
|
||||
it("accepts markdown plan headers and strips markdown emphasis/code", () => {
|
||||
const items = extractPlanItems("## Implementation Plan\n1) **Update** `index.ts`\n2) Wire `/plan approve`");
|
||||
expect(items.map((item) => item.text)).toEqual(["Update index.ts", "Wire /plan approve"]);
|
||||
});
|
||||
|
||||
it("returns no items without an explicit plan header", () => {
|
||||
expect(extractPlanItems("1. This is just a list")).toEqual([]);
|
||||
});
|
||||
});
|
||||
|
||||
describe("completion tracking", () => {
|
||||
it("marks steps from DONE markers", () => {
|
||||
const items = extractPlanItems("Plan:\n1. First\n2. Second");
|
||||
expect(markCompletedSteps("Completed [DONE:2]", items)).toBe(1);
|
||||
expect(formatPlanItems(items)).toContain("2. ✓ Second");
|
||||
});
|
||||
|
||||
it("does not count duplicate DONE markers as changes", () => {
|
||||
const items = extractPlanItems("Plan:\n1. First");
|
||||
expect(markCompletedSteps("[DONE:1] [DONE:1]", items)).toBe(1);
|
||||
expect(markCompletedSteps("[DONE:1]", items)).toBe(0);
|
||||
});
|
||||
});
|
||||
|
||||
describe("tool selection", () => {
|
||||
it("normalizes tool names from strings and tool objects", () => {
|
||||
expect(normalizeToolNames(["read", { name: "edit" }, { label: "missing" }])).toEqual(["read", "edit"]);
|
||||
});
|
||||
|
||||
it("picks only read-only tools when available", () => {
|
||||
expect(pickPlanTools([{ name: "read" }, { name: "edit" }, { name: "question" }])).toEqual(["read", "question"]);
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,212 @@
|
||||
import { beforeEach, describe, expect, it, vi } from "vitest";
|
||||
import factory from "../plan-mode";
|
||||
|
||||
type Handler = (event: any, ctx: any) => Promise<unknown> | unknown;
|
||||
type CommandDef = { description: string; handler: Handler };
|
||||
|
||||
interface MockPi {
|
||||
appendEntry: ReturnType<typeof vi.fn>;
|
||||
getActiveTools: ReturnType<typeof vi.fn>;
|
||||
getAllTools: ReturnType<typeof vi.fn>;
|
||||
getFlag: ReturnType<typeof vi.fn>;
|
||||
on: (event: string, handler: Handler) => void;
|
||||
registerCommand: (name: string, def: CommandDef) => void;
|
||||
registerFlag: ReturnType<typeof vi.fn>;
|
||||
registerShortcut: ReturnType<typeof vi.fn>;
|
||||
sendMessage: ReturnType<typeof vi.fn>;
|
||||
sendUserMessage: ReturnType<typeof vi.fn>;
|
||||
setActiveTools: ReturnType<typeof vi.fn>;
|
||||
}
|
||||
|
||||
interface Captured {
|
||||
commands: Map<string, CommandDef>;
|
||||
handlers: Map<string, Handler[]>;
|
||||
}
|
||||
|
||||
const allTools = [
|
||||
{ name: "read" },
|
||||
{ name: "bash" },
|
||||
{ name: "grep" },
|
||||
{ name: "find" },
|
||||
{ name: "ls" },
|
||||
{ name: "edit" },
|
||||
{ name: "write" },
|
||||
];
|
||||
|
||||
function makePi(): { pi: MockPi; captured: Captured } {
|
||||
const captured: Captured = {
|
||||
commands: new Map(),
|
||||
handlers: new Map(),
|
||||
};
|
||||
const pi: MockPi = {
|
||||
appendEntry: vi.fn(),
|
||||
getActiveTools: vi.fn(() => ["read", "bash", "edit", "write"]),
|
||||
getAllTools: vi.fn(() => allTools),
|
||||
getFlag: vi.fn(() => false),
|
||||
on: (event, handler) => {
|
||||
captured.handlers.set(event, [...(captured.handlers.get(event) ?? []), handler]);
|
||||
},
|
||||
registerCommand: (name, def) => {
|
||||
captured.commands.set(name, def);
|
||||
},
|
||||
registerFlag: vi.fn(),
|
||||
registerShortcut: vi.fn(),
|
||||
sendMessage: vi.fn(),
|
||||
sendUserMessage: vi.fn(),
|
||||
setActiveTools: vi.fn(),
|
||||
};
|
||||
return { pi, captured };
|
||||
}
|
||||
|
||||
function makeCtx(options: { select?: () => Promise<string | undefined>; hasUI?: boolean } = {}) {
|
||||
const theme = {
|
||||
fg: (_role: string, text: string) => text,
|
||||
strikethrough: (text: string) => `~~${text}~~`,
|
||||
};
|
||||
|
||||
return {
|
||||
hasUI: options.hasUI ?? true,
|
||||
sessionManager: { getEntries: vi.fn(() => []) },
|
||||
ui: {
|
||||
editor: vi.fn(async () => undefined),
|
||||
notify: vi.fn(),
|
||||
select: vi.fn(options.select ?? (async () => undefined)),
|
||||
setStatus: vi.fn(),
|
||||
setWidget: vi.fn(),
|
||||
theme,
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
async function fire(handlers: Handler[] | undefined, event: unknown, ctx: unknown) {
|
||||
for (const h of handlers ?? []) {
|
||||
const result = await h(event, ctx);
|
||||
if (result !== undefined) return result;
|
||||
}
|
||||
return undefined;
|
||||
}
|
||||
|
||||
describe("plan-mode extension", () => {
|
||||
let pi: MockPi;
|
||||
let captured: Captured;
|
||||
|
||||
beforeEach(() => {
|
||||
const m = makePi();
|
||||
pi = m.pi;
|
||||
captured = m.captured;
|
||||
factory(pi as never);
|
||||
});
|
||||
|
||||
it("registers commands, flag, shortcut, and lifecycle handlers", () => {
|
||||
expect(pi.registerFlag).toHaveBeenCalledWith("plan", expect.objectContaining({ type: "boolean" }));
|
||||
expect(pi.registerShortcut).toHaveBeenCalledWith("ctrl+alt+p", expect.any(Object));
|
||||
expect(captured.commands.has("plan")).toBe(true);
|
||||
expect(captured.commands.has("todos")).toBe(true);
|
||||
expect(captured.handlers.get("tool_call")).toHaveLength(1);
|
||||
expect(captured.handlers.get("before_agent_start")).toHaveLength(1);
|
||||
expect(captured.handlers.get("agent_end")).toHaveLength(1);
|
||||
});
|
||||
|
||||
it("/plan enables read-only tools and persists state", async () => {
|
||||
const ctx = makeCtx();
|
||||
await captured.commands.get("plan")!.handler("", ctx);
|
||||
|
||||
expect(pi.setActiveTools).toHaveBeenCalledWith(["read", "bash", "grep", "find", "ls"]);
|
||||
expect(pi.appendEntry).toHaveBeenCalledWith(
|
||||
"plan-mode-state",
|
||||
expect.objectContaining({ planning: true, executing: false }),
|
||||
);
|
||||
expect(ctx.ui.notify).toHaveBeenCalledWith(expect.stringContaining("enabled"), "info");
|
||||
});
|
||||
|
||||
it("blocks mutating tools and non-read-only bash while planning", async () => {
|
||||
const ctx = makeCtx();
|
||||
await captured.commands.get("plan")!.handler("on", ctx);
|
||||
|
||||
await expect(
|
||||
fire(captured.handlers.get("tool_call"), { toolName: "edit", input: { path: "README.md" } }, ctx),
|
||||
).resolves.toMatchObject({ block: true });
|
||||
|
||||
await expect(
|
||||
fire(captured.handlers.get("tool_call"), { toolName: "bash", input: { command: "git push" } }, ctx),
|
||||
).resolves.toMatchObject({ block: true });
|
||||
|
||||
await expect(
|
||||
fire(captured.handlers.get("tool_call"), { toolName: "bash", input: { command: "git diff" } }, ctx),
|
||||
).resolves.toBeUndefined();
|
||||
});
|
||||
|
||||
it("injects plan-only instructions before the agent starts", async () => {
|
||||
const ctx = makeCtx();
|
||||
await captured.commands.get("plan")!.handler("on", ctx);
|
||||
|
||||
const result = await fire(
|
||||
captured.handlers.get("before_agent_start"),
|
||||
{ systemPrompt: "base prompt" },
|
||||
ctx,
|
||||
);
|
||||
|
||||
expect(result).toMatchObject({
|
||||
message: expect.objectContaining({ customType: "plan-mode-context", display: false }),
|
||||
});
|
||||
expect((result as { systemPrompt: string }).systemPrompt).toContain("produce a plan only");
|
||||
});
|
||||
|
||||
it("captures an assistant Plan and asks for approval", async () => {
|
||||
const ctx = makeCtx({ select: async () => "Stay in plan mode" });
|
||||
await captured.commands.get("plan")!.handler("on", ctx);
|
||||
|
||||
await fire(
|
||||
captured.handlers.get("agent_end"),
|
||||
{
|
||||
messages: [
|
||||
{
|
||||
role: "assistant",
|
||||
content: [{ type: "text", text: "Plan:\n1. Read files\n2. Add extension" }],
|
||||
},
|
||||
],
|
||||
},
|
||||
ctx,
|
||||
);
|
||||
|
||||
expect(pi.sendMessage).toHaveBeenCalledWith(
|
||||
expect.objectContaining({ customType: "plan-mode-plan", content: expect.stringContaining("Add extension") }),
|
||||
{ triggerTurn: false },
|
||||
);
|
||||
expect(ctx.ui.select).toHaveBeenCalledWith("Plan mode", expect.arrayContaining(["Approve plan and implement"]));
|
||||
});
|
||||
|
||||
it("approval restores previous tools and sends an implementation prompt", async () => {
|
||||
const ctx = makeCtx({ select: async () => "Approve plan and implement" });
|
||||
await captured.commands.get("plan")!.handler("on", ctx);
|
||||
|
||||
await fire(
|
||||
captured.handlers.get("agent_end"),
|
||||
{
|
||||
messages: [
|
||||
{
|
||||
role: "assistant",
|
||||
content: [{ type: "text", text: "Plan:\n1. Read files\n2. Add extension" }],
|
||||
},
|
||||
],
|
||||
},
|
||||
ctx,
|
||||
);
|
||||
|
||||
expect(pi.setActiveTools).toHaveBeenLastCalledWith(["read", "bash", "edit", "write"]);
|
||||
expect(pi.sendUserMessage).toHaveBeenCalledWith(expect.stringContaining("Plan approved"));
|
||||
expect(pi.sendUserMessage).toHaveBeenCalledWith(expect.stringContaining("[DONE:n]"));
|
||||
});
|
||||
|
||||
it("/plan off restores previous tools", async () => {
|
||||
const ctx = makeCtx();
|
||||
await captured.commands.get("plan")!.handler("on", ctx);
|
||||
await captured.commands.get("plan")!.handler("off", ctx);
|
||||
|
||||
expect(pi.setActiveTools).toHaveBeenLastCalledWith(["read", "bash", "edit", "write"]);
|
||||
expect(pi.appendEntry).toHaveBeenLastCalledWith(
|
||||
"plan-mode-state",
|
||||
expect.objectContaining({ planning: false, executing: false, items: [] }),
|
||||
);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,29 @@
|
||||
import type { ExtensionAPI, Theme } from "@earendil-works/pi-coding-agent";
|
||||
|
||||
const WORDMARK = "MIFUNE";
|
||||
const SUBTITLE = "pi harness · github.com/ryaneggz/mifune";
|
||||
|
||||
export function buildHeader(theme: Theme): string[] {
|
||||
return [
|
||||
`⚔ ${theme.fg("accent", WORDMARK)}`,
|
||||
`🎬 ${theme.fg("muted", SUBTITLE)}`,
|
||||
];
|
||||
}
|
||||
|
||||
export default function (pi: ExtensionAPI) {
|
||||
pi.on("session_start", async (_event, ctx) => {
|
||||
if (!ctx.hasUI) return;
|
||||
ctx.ui.setHeader((_tui, theme) => ({
|
||||
render: () => buildHeader(theme),
|
||||
invalidate: () => {},
|
||||
}));
|
||||
});
|
||||
|
||||
pi.registerCommand("builtin-header", {
|
||||
description: "Restore the built-in pi header",
|
||||
handler: async (_args, ctx) => {
|
||||
ctx.ui.setHeader(undefined);
|
||||
ctx.ui.notify("Built-in header restored", "info");
|
||||
},
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,92 @@
|
||||
import type { ExtensionAPI } from "@earendil-works/pi-coding-agent";
|
||||
|
||||
const SENSITIVE_PATHS: RegExp[] = [
|
||||
/(^|\/)\.env(\..+)?$/,
|
||||
/(^|\/)secrets?\//,
|
||||
/(^|\/)credentials?\.(json|ya?ml)$/,
|
||||
/\.pem$/,
|
||||
/\.key$/,
|
||||
/(^|\/)id_(rsa|ed25519|ecdsa)$/,
|
||||
];
|
||||
|
||||
// Fix #2: All regexes now carry the `i` flag for case-insensitive matching.
|
||||
// Fix #3: Block-device redirection regex updated to match bare `> /dev/...`
|
||||
// without requiring a leading `:` (shell colon-truncate idiom).
|
||||
// nvme devices use numeric suffixes (nvme0n1), disk\d+ likewise.
|
||||
const RISKY_BASH = [
|
||||
/\brm\s+-rf\b/i,
|
||||
/\bsudo\b/i,
|
||||
/\bgit\s+push\s+.*--force\b/i,
|
||||
/\bgit\s+reset\s+--hard\b/i,
|
||||
/(^|\s)>\s*\/dev\/(sd[a-z]|nvme\d*|hd[a-z]|disk\d+)\b/i,
|
||||
];
|
||||
|
||||
// Tool names confirmed lowercase from pi source:
|
||||
// packages/coding-agent/src/core/tools/index.ts:
|
||||
// export type ToolName = "read" | "bash" | "edit" | "write" | "grep" | "find" | "ls";
|
||||
const MUTATING_TOOLS = new Set(["write", "edit"]);
|
||||
|
||||
// Canonical input key for write and edit is `path` (verified from pi source):
|
||||
// write.ts: const writeSchema = Type.Object({ path: Type.String(...), content: ... });
|
||||
// edit.ts: const editSchema = Type.Object({ path: Type.String(...), edits: ... });
|
||||
// Fallbacks retained for forward-compatibility with any future schema changes.
|
||||
function pickPath(input: Record<string, unknown> | undefined): string | undefined {
|
||||
if (!input) return undefined;
|
||||
for (const key of ["path", "file_path", "target", "filename"]) {
|
||||
const v = input[key];
|
||||
if (typeof v === "string" && v.length > 0) return v;
|
||||
}
|
||||
return undefined;
|
||||
}
|
||||
|
||||
export default function (pi: ExtensionAPI) {
|
||||
pi.on("tool_call", async (event, ctx) => {
|
||||
// Fix #1: Guard against headless/RPC mode where ctx.ui is unavailable.
|
||||
// Mirrors the pattern in mifune-banner.ts.
|
||||
if (!ctx.hasUI) return;
|
||||
|
||||
// Fix #4: Defensive toLowerCase normalization on toolName. Pi uses
|
||||
// lowercase names (confirmed from index.ts ToolName union), but this
|
||||
// costs nothing and protects against future renames.
|
||||
const toolName = event.toolName?.toLowerCase() ?? "";
|
||||
|
||||
if (MUTATING_TOOLS.has(toolName)) {
|
||||
const path = pickPath(event.input as Record<string, unknown>);
|
||||
if (path && SENSITIVE_PATHS.some((re) => re.test(path))) {
|
||||
const ok = await ctx.ui.confirm(
|
||||
"Sensitive path",
|
||||
`Allow ${toolName} on ${path}?`,
|
||||
);
|
||||
if (!ok) return { block: true, reason: `Path ${path} is protected` };
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
if (toolName === "bash") {
|
||||
const cmd = (event.input as { command?: string } | undefined)?.command ?? "";
|
||||
if (RISKY_BASH.some((re) => re.test(cmd))) {
|
||||
const ok = await ctx.ui.confirm(
|
||||
"Risky command",
|
||||
`Allow:\n${cmd.length > 200 ? cmd.slice(0, 200) + "..." : cmd}`,
|
||||
);
|
||||
if (!ok) return { block: true, reason: "User declined risky command" };
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
pi.registerCommand("guard", {
|
||||
description: "Show what path-guard is protecting",
|
||||
handler: async (_args, ctx) => {
|
||||
const lines = [
|
||||
"path-guard is active.",
|
||||
"",
|
||||
"Sensitive paths (write/edit prompt):",
|
||||
...SENSITIVE_PATHS.map((re) => ` ${re.source}`),
|
||||
"",
|
||||
"Risky bash patterns (prompt before run):",
|
||||
...RISKY_BASH.map((re) => ` ${re.source}`),
|
||||
];
|
||||
ctx.ui.notify(lines.join("\n"), "info");
|
||||
},
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,23 @@
|
||||
# Plan Mode Extension
|
||||
|
||||
Claude Code-style plan mode for Pi.
|
||||
|
||||
## Usage
|
||||
|
||||
- `/plan` or `ctrl+alt+p` toggles plan mode.
|
||||
- `/plan on` starts read-only planning.
|
||||
- `/plan approve` approves the captured plan and starts implementation.
|
||||
- `/plan off` cancels and restores the previous tool set.
|
||||
- `/todos` shows captured plan steps or execution progress.
|
||||
- `pi --plan` starts a session in plan mode.
|
||||
|
||||
## Behavior
|
||||
|
||||
When plan mode is active, Pi can inspect the repo but cannot mutate it:
|
||||
|
||||
- Active tools are reduced to read-only tools (`read`, `bash`, `grep`, `find`, `ls`, plus question tools when available).
|
||||
- `edit`, `write`, and unknown tools are blocked.
|
||||
- Bash is allowlisted to read-only inspection commands.
|
||||
- The agent is instructed to end with a numbered `Plan:` section.
|
||||
|
||||
After the agent returns a plan, the extension prompts for approval. On approval it restores the previous active tools and sends a follow-up implementation prompt. During execution the agent marks completed steps with `[DONE:n]`, and `/todos` reports progress.
|
||||
@@ -0,0 +1,334 @@
|
||||
import type { ExtensionAPI, ExtensionContext } from "@earendil-works/pi-coding-agent";
|
||||
import {
|
||||
extractPlanItems,
|
||||
formatPlanItems,
|
||||
isMutatingTool,
|
||||
isReadOnlyTool,
|
||||
isSafeReadOnlyCommand,
|
||||
markCompletedSteps,
|
||||
normalizeToolNames,
|
||||
pickPlanTools,
|
||||
type PlanItem,
|
||||
} from "./utils";
|
||||
|
||||
type AssistantTextMessage = {
|
||||
role: "assistant";
|
||||
content: Array<{ type: string; text?: string }>;
|
||||
};
|
||||
|
||||
type PlanModeEntry = {
|
||||
type: string;
|
||||
customType?: string;
|
||||
data?: {
|
||||
planning?: boolean;
|
||||
executing?: boolean;
|
||||
items?: PlanItem[];
|
||||
previousTools?: string[];
|
||||
};
|
||||
};
|
||||
|
||||
const STATE_ENTRY = "plan-mode-state";
|
||||
const SHORTCUT = "ctrl+alt+p";
|
||||
|
||||
function isAssistantTextMessage(message: unknown): message is AssistantTextMessage {
|
||||
return (
|
||||
!!message &&
|
||||
typeof message === "object" &&
|
||||
(message as { role?: unknown }).role === "assistant" &&
|
||||
Array.isArray((message as { content?: unknown }).content)
|
||||
);
|
||||
}
|
||||
|
||||
function getAssistantText(message: AssistantTextMessage): string {
|
||||
return message.content
|
||||
.filter((block) => block.type === "text" && typeof block.text === "string")
|
||||
.map((block) => block.text)
|
||||
.join("\n");
|
||||
}
|
||||
|
||||
function notify(ctx: ExtensionContext, message: string, level: "info" | "warning" | "error" = "info"): void {
|
||||
if (ctx.hasUI === false) return;
|
||||
ctx.ui.notify(message, level);
|
||||
}
|
||||
|
||||
export default function planModeExtension(pi: ExtensionAPI): void {
|
||||
let planning = false;
|
||||
let executing = false;
|
||||
let items: PlanItem[] = [];
|
||||
let previousTools: string[] | undefined;
|
||||
|
||||
pi.registerFlag("plan", {
|
||||
description: "Start in Claude Code-style plan mode (read-only until approval)",
|
||||
type: "boolean",
|
||||
default: false,
|
||||
});
|
||||
|
||||
function persistState(): void {
|
||||
pi.appendEntry(STATE_ENTRY, {
|
||||
planning,
|
||||
executing,
|
||||
items,
|
||||
previousTools,
|
||||
});
|
||||
}
|
||||
|
||||
function updateUi(ctx: ExtensionContext): void {
|
||||
if (ctx.hasUI === false) return;
|
||||
|
||||
if (planning) {
|
||||
ctx.ui.setStatus("plan-mode", ctx.ui.theme.fg("warning", "plan"));
|
||||
} else if (executing && items.length > 0) {
|
||||
const done = items.filter((item) => item.completed).length;
|
||||
ctx.ui.setStatus("plan-mode", ctx.ui.theme.fg("accent", `plan ${done}/${items.length}`));
|
||||
} else {
|
||||
ctx.ui.setStatus("plan-mode", undefined);
|
||||
}
|
||||
|
||||
if (executing && items.length > 0) {
|
||||
ctx.ui.setWidget(
|
||||
"plan-mode-items",
|
||||
items.map((item) => {
|
||||
if (item.completed) {
|
||||
return `${ctx.ui.theme.fg("success", "☑")} ${ctx.ui.theme.fg("muted", ctx.ui.theme.strikethrough(item.text))}`;
|
||||
}
|
||||
return `${ctx.ui.theme.fg("muted", "☐")} ${item.text}`;
|
||||
}),
|
||||
);
|
||||
} else {
|
||||
ctx.ui.setWidget("plan-mode-items", undefined);
|
||||
}
|
||||
}
|
||||
|
||||
function restoreTools(): void {
|
||||
const restored = previousTools?.length ? previousTools : normalizeToolNames(pi.getAllTools());
|
||||
if (restored.length > 0) pi.setActiveTools(restored);
|
||||
previousTools = undefined;
|
||||
}
|
||||
|
||||
function enablePlanMode(ctx: ExtensionContext): void {
|
||||
if (!previousTools) previousTools = normalizeToolNames(pi.getActiveTools());
|
||||
planning = true;
|
||||
executing = false;
|
||||
items = [];
|
||||
|
||||
pi.setActiveTools(pickPlanTools(pi.getAllTools()));
|
||||
updateUi(ctx);
|
||||
persistState();
|
||||
notify(ctx, "Plan mode enabled. Pi is read-only until you approve a plan.", "info");
|
||||
}
|
||||
|
||||
function disablePlanMode(ctx: ExtensionContext): void {
|
||||
planning = false;
|
||||
executing = false;
|
||||
items = [];
|
||||
restoreTools();
|
||||
updateUi(ctx);
|
||||
persistState();
|
||||
notify(ctx, "Plan mode disabled. Tool access restored.", "info");
|
||||
}
|
||||
|
||||
function approvePlan(ctx: ExtensionContext): void {
|
||||
planning = false;
|
||||
executing = items.length > 0;
|
||||
restoreTools();
|
||||
updateUi(ctx);
|
||||
persistState();
|
||||
|
||||
const kickoff =
|
||||
items.length > 0
|
||||
? `Plan approved. Implement it now, one step at a time. Mark each completed step with [DONE:n].\n\n${formatPlanItems(items)}`
|
||||
: "Plan approved. Implement the plan now.";
|
||||
pi.sendUserMessage(kickoff);
|
||||
}
|
||||
|
||||
pi.registerCommand("plan", {
|
||||
description: "Toggle/approve Claude Code-style plan mode: /plan, /plan on, /plan off, /plan approve",
|
||||
handler: async (args, ctx) => {
|
||||
const action = args.trim().toLowerCase();
|
||||
if (action === "on" || action === "start") return enablePlanMode(ctx);
|
||||
if (action === "off" || action === "stop" || action === "cancel") return disablePlanMode(ctx);
|
||||
if (action === "approve" || action === "accept" || action === "execute") return approvePlan(ctx);
|
||||
if (action === "status") {
|
||||
const status = planning ? "planning" : executing ? "executing" : "off";
|
||||
notify(ctx, items.length > 0 ? `Plan mode: ${status}\n${formatPlanItems(items)}` : `Plan mode: ${status}`, "info");
|
||||
return;
|
||||
}
|
||||
|
||||
if (planning || executing) return disablePlanMode(ctx);
|
||||
return enablePlanMode(ctx);
|
||||
},
|
||||
});
|
||||
|
||||
pi.registerCommand("todos", {
|
||||
description: "Show current plan-mode progress",
|
||||
handler: async (_args, ctx) => {
|
||||
if (items.length === 0) {
|
||||
notify(ctx, "No plan steps captured yet.", "info");
|
||||
return;
|
||||
}
|
||||
notify(ctx, formatPlanItems(items), "info");
|
||||
},
|
||||
});
|
||||
|
||||
pi.registerShortcut(SHORTCUT, {
|
||||
description: "Toggle plan mode",
|
||||
handler: async (ctx) => {
|
||||
if (planning || executing) disablePlanMode(ctx);
|
||||
else enablePlanMode(ctx);
|
||||
},
|
||||
});
|
||||
|
||||
pi.on("tool_call", async (event) => {
|
||||
if (!planning) return;
|
||||
|
||||
if (isMutatingTool(event.toolName) || !isReadOnlyTool(event.toolName)) {
|
||||
return {
|
||||
block: true,
|
||||
reason: `Plan mode is read-only. Approve the plan or run /plan off before using ${event.toolName}.`,
|
||||
};
|
||||
}
|
||||
|
||||
if (event.toolName === "bash") {
|
||||
const command = (event.input as { command?: unknown } | undefined)?.command;
|
||||
if (typeof command !== "string" || !isSafeReadOnlyCommand(command)) {
|
||||
return {
|
||||
block: true,
|
||||
reason: `Plan mode blocked a non-read-only bash command. Approve the plan or run /plan off first.`,
|
||||
};
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
pi.on("context", async (event) => {
|
||||
if (planning || executing) return;
|
||||
return {
|
||||
messages: event.messages.filter((message: unknown) => {
|
||||
const customType = (message as { customType?: unknown } | undefined)?.customType;
|
||||
return customType !== "plan-mode-context" && customType !== "plan-execution-context";
|
||||
}),
|
||||
};
|
||||
});
|
||||
|
||||
pi.on("before_agent_start", async (event) => {
|
||||
if (planning) {
|
||||
return {
|
||||
message: {
|
||||
customType: "plan-mode-context",
|
||||
content: `[PLAN MODE ACTIVE]
|
||||
You are in Claude Code-style plan mode.
|
||||
|
||||
Rules:
|
||||
- Explore and reason only. Do not modify files, run mutating shell commands, install packages, commit, or push.
|
||||
- Use read/search/list tools and read-only bash commands to understand the request.
|
||||
- Ask clarifying questions if the requested change is ambiguous.
|
||||
- End with a concise numbered plan under a "Plan:" heading.
|
||||
- Do not implement until the user approves the plan.`,
|
||||
display: false,
|
||||
},
|
||||
systemPrompt: `${event.systemPrompt}\n\nPlan mode is active: produce a plan only; do not implement until approval.`,
|
||||
};
|
||||
}
|
||||
|
||||
if (executing && items.length > 0) {
|
||||
const remaining = items.filter((item) => !item.completed);
|
||||
return {
|
||||
message: {
|
||||
customType: "plan-execution-context",
|
||||
content: `[EXECUTING APPROVED PLAN]
|
||||
Remaining steps:
|
||||
${formatPlanItems(remaining)}
|
||||
|
||||
Execute steps in order. After completing step n, include [DONE:n] in your response.`,
|
||||
display: false,
|
||||
},
|
||||
};
|
||||
}
|
||||
});
|
||||
|
||||
pi.on("turn_end", async (event, ctx) => {
|
||||
if (!executing || items.length === 0 || !isAssistantTextMessage(event.message)) return;
|
||||
if (markCompletedSteps(getAssistantText(event.message), items) > 0) {
|
||||
updateUi(ctx);
|
||||
persistState();
|
||||
}
|
||||
});
|
||||
|
||||
pi.on("agent_end", async (event, ctx) => {
|
||||
if (executing && items.length > 0 && items.every((item) => item.completed)) {
|
||||
pi.sendMessage(
|
||||
{
|
||||
customType: "plan-mode-complete",
|
||||
content: `Plan complete.\n\n${formatPlanItems(items)}`,
|
||||
display: true,
|
||||
},
|
||||
{ triggerTurn: false },
|
||||
);
|
||||
executing = false;
|
||||
items = [];
|
||||
updateUi(ctx);
|
||||
persistState();
|
||||
return;
|
||||
}
|
||||
|
||||
if (!planning) return;
|
||||
|
||||
const lastAssistant = [...event.messages].reverse().find(isAssistantTextMessage);
|
||||
if (lastAssistant) {
|
||||
const extracted = extractPlanItems(getAssistantText(lastAssistant));
|
||||
if (extracted.length > 0) items = extracted;
|
||||
}
|
||||
|
||||
if (items.length > 0) {
|
||||
pi.sendMessage(
|
||||
{
|
||||
customType: "plan-mode-plan",
|
||||
content: `Plan captured.\n\n${formatPlanItems(items)}`,
|
||||
display: true,
|
||||
},
|
||||
{ triggerTurn: false },
|
||||
);
|
||||
persistState();
|
||||
}
|
||||
|
||||
if (ctx.hasUI === false) return;
|
||||
|
||||
const choice = await ctx.ui.select("Plan mode", [
|
||||
items.length > 0 ? "Approve plan and implement" : "Approve and implement",
|
||||
"Stay in plan mode",
|
||||
"Refine plan",
|
||||
"Cancel plan mode",
|
||||
]);
|
||||
|
||||
if (choice?.startsWith("Approve")) {
|
||||
approvePlan(ctx);
|
||||
} else if (choice === "Refine plan") {
|
||||
const refinement = await ctx.ui.editor("Refine the plan", "");
|
||||
if (refinement?.trim()) pi.sendUserMessage(refinement.trim());
|
||||
} else if (choice === "Cancel plan mode") {
|
||||
disablePlanMode(ctx);
|
||||
} else {
|
||||
updateUi(ctx);
|
||||
persistState();
|
||||
}
|
||||
});
|
||||
|
||||
pi.on("session_start", async (_event, ctx) => {
|
||||
planning = pi.getFlag("plan") === true;
|
||||
|
||||
const state = ctx.sessionManager
|
||||
.getEntries()
|
||||
.filter((entry: PlanModeEntry) => entry.type === "custom" && entry.customType === STATE_ENTRY)
|
||||
.pop() as PlanModeEntry | undefined;
|
||||
|
||||
if (state?.data) {
|
||||
planning = state.data.planning ?? planning;
|
||||
executing = state.data.executing ?? executing;
|
||||
items = state.data.items ?? items;
|
||||
previousTools = state.data.previousTools ?? previousTools;
|
||||
}
|
||||
|
||||
if (planning) pi.setActiveTools(pickPlanTools(pi.getAllTools()));
|
||||
if (!planning && !executing) previousTools = undefined;
|
||||
updateUi(ctx);
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,165 @@
|
||||
export interface PlanItem {
|
||||
step: number;
|
||||
text: string;
|
||||
completed: boolean;
|
||||
}
|
||||
|
||||
const DESTRUCTIVE_PATTERNS: RegExp[] = [
|
||||
/\brm\b/i,
|
||||
/\brmdir\b/i,
|
||||
/\bmv\b/i,
|
||||
/\bcp\b/i,
|
||||
/\bmkdir\b/i,
|
||||
/\btouch\b/i,
|
||||
/\bchmod\b/i,
|
||||
/\bchown\b/i,
|
||||
/\bchgrp\b/i,
|
||||
/\bln\b/i,
|
||||
/\btee\b/i,
|
||||
/\btruncate\b/i,
|
||||
/\bdd\b/i,
|
||||
/\bshred\b/i,
|
||||
/(^|[^<])>(?!&|>)/,
|
||||
/>>/,
|
||||
/\bnpm\s+(install|uninstall|update|ci|link|publish)/i,
|
||||
/\byarn\s+(add|remove|install|publish)/i,
|
||||
/\bpnpm\s+(add|remove|install|publish|update)/i,
|
||||
/\bpip\s+(install|uninstall)/i,
|
||||
/\buv\s+(add|remove|pip\s+install)/i,
|
||||
/\bapt(-get)?\s+(install|remove|purge|update|upgrade)/i,
|
||||
/\bbrew\s+(install|uninstall|upgrade)/i,
|
||||
/\bgit\s+(add|commit|push|pull|merge|rebase|reset|checkout|switch|branch\s+-[dD]|stash|cherry-pick|revert|tag|init|clone)/i,
|
||||
/\bsudo\b/i,
|
||||
/\bsu\b/i,
|
||||
/\bkill\b/i,
|
||||
/\bpkill\b/i,
|
||||
/\bkillall\b/i,
|
||||
/\breboot\b/i,
|
||||
/\bshutdown\b/i,
|
||||
/\bsystemctl\s+(start|stop|restart|enable|disable)/i,
|
||||
/\bservice\s+\S+\s+(start|stop|restart)/i,
|
||||
/\b(vim?|nano|emacs|code|subl)\b/i,
|
||||
];
|
||||
|
||||
const SAFE_START_PATTERNS: RegExp[] = [
|
||||
/^\s*(cat|head|tail|less|more)\b/,
|
||||
/^\s*(grep|rg|find|fd|ag)\b/,
|
||||
/^\s*(ls|pwd|tree|eza|bat)\b/,
|
||||
/^\s*(echo|printf|wc|sort|uniq|diff|file|stat|du|df)\b/,
|
||||
/^\s*(which|whereis|type|env|printenv)\b/,
|
||||
/^\s*(uname|whoami|id|date|cal|uptime|ps|top|htop|free)\b/,
|
||||
/^\s*git\s+(status|log|diff|show|branch|remote|config\s+--get|ls-files|ls-tree|grep)\b/i,
|
||||
/^\s*(npm|pnpm|yarn)\s+(list|ls|view|info|search|outdated|audit|why)\b/i,
|
||||
/^\s*(node|python|python3|ruby|go|rustc|cargo|deno|bun)\s+(--version|-v|version)\b/i,
|
||||
/^\s*curl\s+/,
|
||||
/^\s*wget\s+-O\s*-/,
|
||||
/^\s*(jq|sed\s+-n|awk)\b/,
|
||||
];
|
||||
|
||||
const READ_ONLY_TOOL_NAMES = new Set([
|
||||
"read",
|
||||
"bash",
|
||||
"grep",
|
||||
"find",
|
||||
"ls",
|
||||
"question",
|
||||
"questionnaire",
|
||||
"ask_question",
|
||||
]);
|
||||
|
||||
const MUTATING_TOOL_NAMES = new Set(["edit", "write"]);
|
||||
|
||||
export function isReadOnlyTool(toolName: string): boolean {
|
||||
return READ_ONLY_TOOL_NAMES.has(toolName);
|
||||
}
|
||||
|
||||
export function isMutatingTool(toolName: string): boolean {
|
||||
return MUTATING_TOOL_NAMES.has(toolName);
|
||||
}
|
||||
|
||||
export function isSafeReadOnlyCommand(command: string): boolean {
|
||||
const trimmed = command.trim();
|
||||
if (!trimmed) return false;
|
||||
if (DESTRUCTIVE_PATTERNS.some((pattern) => pattern.test(trimmed))) return false;
|
||||
return SAFE_START_PATTERNS.some((pattern) => pattern.test(trimmed));
|
||||
}
|
||||
|
||||
export function normalizeToolNames(tools: unknown): string[] {
|
||||
if (!Array.isArray(tools)) return [];
|
||||
|
||||
return tools.flatMap((tool) => {
|
||||
if (typeof tool === "string") return [tool];
|
||||
if (tool && typeof tool === "object" && "name" in tool) {
|
||||
const name = (tool as { name?: unknown }).name;
|
||||
return typeof name === "string" ? [name] : [];
|
||||
}
|
||||
return [];
|
||||
});
|
||||
}
|
||||
|
||||
export function pickPlanTools(allTools: unknown, fallback = ["read", "bash", "grep", "find", "ls"]): string[] {
|
||||
const names = normalizeToolNames(allTools);
|
||||
const available = names.filter(isReadOnlyTool);
|
||||
return available.length > 0 ? available : fallback;
|
||||
}
|
||||
|
||||
function cleanPlanText(text: string): string {
|
||||
let cleaned = text
|
||||
.replace(/\*{1,2}([^*]+)\*{1,2}/g, "$1")
|
||||
.replace(/`([^`]+)`/g, "$1")
|
||||
.replace(/\s+/g, " ")
|
||||
.trim();
|
||||
|
||||
cleaned = cleaned.replace(/^[\[({]\s*/, "").replace(/\s*[\])}]$/, "").trim();
|
||||
if (cleaned.length > 140) cleaned = `${cleaned.slice(0, 137)}...`;
|
||||
return cleaned;
|
||||
}
|
||||
|
||||
export function extractPlanItems(message: string): PlanItem[] {
|
||||
const lines = message.split(/\r?\n/);
|
||||
const headerIndex = lines.findIndex((line) => /^\s*(#{1,6}\s*)?(implementation\s+)?plan\s*:?\s*$/i.test(line));
|
||||
if (headerIndex === -1) return [];
|
||||
|
||||
const items: PlanItem[] = [];
|
||||
let started = false;
|
||||
|
||||
for (const line of lines.slice(headerIndex + 1)) {
|
||||
const numbered = line.match(/^\s*(\d+)[.)]\s+(.+)$/);
|
||||
if (!numbered) {
|
||||
if (started && line.trim() !== "") break;
|
||||
continue;
|
||||
}
|
||||
|
||||
started = true;
|
||||
const cleaned = cleanPlanText(numbered[2] ?? "");
|
||||
if (cleaned.length < 4) continue;
|
||||
items.push({ step: items.length + 1, text: cleaned, completed: false });
|
||||
}
|
||||
|
||||
return items;
|
||||
}
|
||||
|
||||
export function extractDoneSteps(message: string): number[] {
|
||||
const steps: number[] = [];
|
||||
for (const match of message.matchAll(/\[DONE:(\d+)]/gi)) {
|
||||
const step = Number(match[1]);
|
||||
if (Number.isInteger(step) && step > 0) steps.push(step);
|
||||
}
|
||||
return steps;
|
||||
}
|
||||
|
||||
export function markCompletedSteps(message: string, items: PlanItem[]): number {
|
||||
let changed = 0;
|
||||
for (const step of extractDoneSteps(message)) {
|
||||
const item = items.find((candidate) => candidate.step === step);
|
||||
if (item && !item.completed) {
|
||||
item.completed = true;
|
||||
changed += 1;
|
||||
}
|
||||
}
|
||||
return changed;
|
||||
}
|
||||
|
||||
export function formatPlanItems(items: PlanItem[]): string {
|
||||
return items.map((item) => `${item.step}. ${item.completed ? "✓" : "☐"} ${item.text}`).join("\n");
|
||||
}
|
||||
@@ -0,0 +1,10 @@
|
||||
Review the staged or recent changes for:
|
||||
|
||||
- Correctness — logic errors, wrong assumptions, missed cases
|
||||
- Security — injection, auth, secret handling, OWASP top-10
|
||||
- Performance — obvious O(n²) traps, redundant work, memory leaks
|
||||
- Style — adherence to project conventions and surrounding patterns
|
||||
- Tests — coverage gaps, brittle assertions
|
||||
|
||||
Report findings as a punch list. Cite `file:line` for each issue.
|
||||
Skip generic advice; focus on what is actually wrong in this diff.
|
||||
@@ -0,0 +1,19 @@
|
||||
{
|
||||
"defaultProvider": "openai-codex",
|
||||
"defaultModel": "gpt-5.5",
|
||||
"defaultThinkingLevel": "high",
|
||||
"theme": "dark",
|
||||
"retry": {
|
||||
"enabled": true,
|
||||
"maxRetries": 3,
|
||||
"baseDelayMs": 2000
|
||||
},
|
||||
"steeringMode": "one-at-a-time",
|
||||
"followUpMode": "one-at-a-time",
|
||||
"extensions": ["./extensions"],
|
||||
"skills": ["./skills"],
|
||||
"prompts": ["./prompts"],
|
||||
"themes": ["./themes"],
|
||||
"enableSkillCommands": true,
|
||||
"packages": []
|
||||
}
|
||||
Symlink
+1
@@ -0,0 +1 @@
|
||||
../.claude/skills
|
||||
@@ -14,6 +14,8 @@ Update policy and release automation live in [`.claude/rules/git.md`](.claude/ru
|
||||
- `opencode` and `opencode /connect` failing with `EACCES: permission denied, mkdir '/home/sandbox/.local/state'` on a fresh sandbox. Docker auto-creates `~/.local` and `~/.local/share` as root to satisfy the `opencode-host` bind-mount path; the entrypoint now non-recursively chowns those parent dirs to `sandbox:sandbox` so the user can create siblings like `.local/state`. The bind-mounted `~/.local/share/opencode` itself stays untouched. Re-applies a fix that was lost when [#241](https://github.com/ryaneggz/open-harness/pull/241) merged a conflicting `entrypoint.sh`. ([#245](https://github.com/ryaneggz/open-harness/issues/245))
|
||||
|
||||
### Added
|
||||
- Project-local Pi config under `.pi/` standing the agent up at the harness root: OpenAI-default `settings.json` (`gpt-4o`, model-cycling list scoped to GPT/o-series), `mifune-banner.ts` extension replacing pi's startup header with a flush-left `MIFUNE` wordmark + `pi harness · github.com/ryaneggz/mifune` subtitle, `path-guard.ts` extension intercepting `tool_call` events to confirm before write/edit on sensitive paths (`.env*`, `secrets/`, `credentials.{json,yaml}`, `*.pem`, `*.key`, ssh keys) and risky bash (`rm -rf`, `sudo`, `git push --force`, `git reset --hard`, bare `> /dev/sd*|nvme*|hd*|disk*`) with case-insensitive matching and `ctx.hasUI` guards for headless/RPC mode, generic consumer-agnostic `APPEND_SYSTEM.md`, `prompts/review.md` template invokable as `/review`, and `skills` + `rules` directory symlinks to `../.claude/skills` / `../.claude/rules` matching the single-source-of-truth pattern already in `.codex/`. New canonical `.claude/skills/harness-context/SKILL.md` reachable from pi via the symlink. Test suite at `.pi/extensions/__tests__/{path-guard,mifune-banner}.test.ts` (32 + 7 cases, hand-rolled mock `ExtensionAPI`, no pi devDep); pi source verified via `gh api` for tool-name casing (lowercase) and write/edit canonical input key (`path`). `vitest.config.ts` `include` extended to `.pi/**/__tests__/**/*.test.ts`; `.github/workflows/ci-harness.yml` `paths:` filter extended (`.pi/**`, `scripts/**`, `vitest.config.ts`) and a new "Root tests" step runs `pnpm test:scripts`, also bringing the previously-orphaned `scripts/__tests__/cron-runtime.test.ts` into CI. ([#255](https://github.com/ryaneggz/open-harness/issues/255))
|
||||
- Project-local Pi plan-mode extension under `.pi/extensions/plan-mode/`, with `/plan`, `/plan approve`, `--plan`, read-only tool/bash gating, plan approval prompts, and `[DONE:n]` progress tracking.
|
||||
- Directory README pattern: tracked `README.md` in `apps/`, `crons/`, `scripts/`, `tasks/`, and `.worktrees/` documenting each directory's intent, subfolder conventions, and lifecycle. New rule `.claude/rules/directory-readme.md` codifies the pattern; `.gitignore` exempts only `.worktrees/README.md` (replacing the unused `.gitkeep` exemption); `.claude/rules/git.md` § Worktrees points to the new README and documents the `.worktrees/project/<project-name>/<repo>/` slot for independent project clones; `docs/architecture/container-runtime.md` moves `.worktrees/` into the canonical tree (README tracked, contents ignored). Three session lessons appended to `context/IDENTITY.md` covering README-over-`.gitkeep`, "treat X same as Y" interpretation discipline, and semantic-subfolder enumeration.
|
||||
- OpenCode as a core sandbox agent: install `opencode-ai` in the base image, persist `~/.local/share/opencode`, add the `opencode-host` overlay, support `scripts/ralph.sh --harness=opencode`, and document OpenAI OAuth as the default OpenCode provider path.
|
||||
- DeepAgents CLI support as a first-class supported sandbox runtime alongside Claude Code, Codex, OpenCode, and Pi. `.devcontainer/Dockerfile` installs `deepagents-cli` via `uv tool install` into `/usr/local/bin`; `.devcontainer/docker-compose.yml` adds a `deepagents-auth` named volume at `/home/sandbox/.deepagents` so provider keys, memory, skills, and sessions persist across rebuilds; `.devcontainer/docker-compose.deepagents-host.yml` is an opt-in host-state overlay (off by default — DeepAgents stores raw provider API keys with no scope/expiry, so the blast radius is wider than the Claude/Codex host overlays); `.devcontainer/entrypoint.sh` extends its chown loop to include `.deepagents` and skips it when `DEEPAGENTS_HOST_BIND_MOUNT=1`. `scripts/ralph.sh` accepts `--harness=deepagents` (or `RALPH_HARNESS=deepagents`) and dispatches to a constrained default invocation `deepagents -y --shell-allow-list recommended -q --no-stream --max-turns "$RALPH_DEEPAGENTS_MAX_TURNS" -n "$task"` (default 25 turns); `RALPH_DEEPAGENTS_FLAGS` overrides the safety/output flag string, and `--max-turns` is always appended after so the per-iteration cap cannot be silently dropped. `--shell-allow-list all` combined with the sandbox's mounted Docker socket can affect sibling containers or host Docker — only enable for trusted tasks via the env-var override. `install/banner.sh` reports DeepAgents `[installed]` from `command -v deepagents` and `[configured]` only when `~/.deepagents/.env` or `~/.deepagents/config.toml` is non-empty (an empty mounted directory is no longer treated as authenticated). New agent doc at `docs/agents/deepagents.md`; `docs/agents/overview.md`, `README.md`, `docs/quickstart.md`, `docs/onboarding.md`, `docs/installation.md`, `docs/operations/{provision,destroy,repair}.md`, `docs/guide/{overlays,permissions,configuration}.md`, and `docs/architecture/{container-runtime,overview}.md` enumerate the new agent, volume, overlay, and Ralph harness option. Repo-local `.deepagents/` is project data and follows normal git review/ignore rules; secrets and provider keys belong only in `~/.deepagents` or ignored local files. Claude remains the default Ralph harness; existing Claude/Pi/Codex execution and fallback behavior are unchanged. ([#237](https://github.com/ryaneggz/open-harness/issues/237))
|
||||
|
||||
+4
-1
@@ -2,7 +2,10 @@ import { defineConfig } from "vitest/config";
|
||||
|
||||
export default defineConfig({
|
||||
test: {
|
||||
include: ["scripts/__tests__/**/*.test.ts"],
|
||||
include: [
|
||||
"scripts/__tests__/**/*.test.ts",
|
||||
".pi/**/__tests__/**/*.test.ts",
|
||||
],
|
||||
globals: true,
|
||||
},
|
||||
});
|
||||
Reference in new issue
Block a user