diff --git a/.agents/skills/verify-open-pstack/features/worker-contract.md b/.agents/skills/verify-open-pstack/features/worker-contract.md index b6d8701..6044eb3 100644 --- a/.agents/skills/verify-open-pstack/features/worker-contract.md +++ b/.agents/skills/verify-open-pstack/features/worker-contract.md @@ -9,6 +9,7 @@ The repository owner keeps Git authority while subordinate workers operate withi - `worker-handoff`: a final response can request a parent operation without completing the assignment or authorizing that operation. - `worker-continuation`: an explicit saved allowance permits only a bounded, inspected continuation of the same descriptor. - `worker-operation`: duplicate or interrupted parent operations reconcile against recorded expected state before replay. +- `worker-local-checks`: command-capable writers check, repair, and recheck locally; file-only writers request parent checks. ## How to get to it (user POV) @@ -48,6 +49,24 @@ From each installed parent, exercise these outcomes and retain the parent tool t Record unsupported or blocked provider paths individually. A successful CLI test does not validate either installed parent. A provider report rejected by identity or spending guards is an unsuccessful run even when a side effect looks correct. +For local checks, create a separate fixture for each installed-parent lane. The helper performs Git operations and runs only in the parent. + +```bash +capture local-check-fixture python3 "$VERIFY_REPO/tests/worker-contract/local-check.py" create --root "$VERIFY_SCRATCH/local-check" +``` + +Pass `prompt.txt` and the fixture's `writer` checkout through the installed workflow. The task requires a failing baseline, a change only to `normalize.py`, and a passing check. Use the saved descriptor and spending policy. Exercise native Codex and Claude assignments, external Claude's sandboxed Bash, and Devin's sandboxed exec where those routes are configured. Repeat with fresh paths for each lane. + +Retain native host tool events or bounded provider tool observations from transparent process-boundary instrumentation. Record the actual check call and its matching failure or success result. Instrumentation must preserve argv, stdin, stdout, and exit status. Devin's runner deletes its private ATIF export after the attempt, so a verification recorder must select the check observations before cleanup. Never retain system context, reasoning, credentials, or unrelated tool output. Runner receipts and worker-written check logs alone do not prove that the worker ran a check. + +```bash +capture local-check-observed python3 "$VERIFY_REPO/tests/worker-contract/local-check.py" inspect --root "$VERIFY_SCRATCH/local-check" +``` + +Require a passing parent check, an edited implementation, unchanged tracked tests and worker HEAD, and an unchanged seed. Inspect the actual diff and extra files. These state checks detect changes; they do not prove confinement. Record the installed version and tree identity, parent action, worker tool observations, and independent parent result separately. + +Use fresh fixtures for negative paths. Put project sandbox exclusions in a Claude writer checkout and confirm empty setting sources keep an outside-worktree canary write denied. Observe one actual denial and no retry. Exercise sandbox startup failure and an old or malformed CLI version without model execution. Check a required outside-directory cache or network operation, and retain the concrete blocker instead of weakening isolation. A file-only lane must deliver a valid `run-checks` request without claiming command execution; validate it with the parent's fixed task, checkpoint, file, check identifier, and HEAD before running acceptance. Native and external strict preparation must remain unsupported. Label fake CLI and synthetic policy cases as boundary tests, never installed-parent proof. + ## Gotchas - File-only workers cannot promise shell checks. The parent may run authorized checks and Git operations itself. diff --git a/.claude-plugin/marketplace.json b/.claude-plugin/marketplace.json index 308400a..8a8c9a5 100644 --- a/.claude-plugin/marketplace.json +++ b/.claude-plugin/marketplace.json @@ -9,7 +9,7 @@ "name": "pstack", "source": "./plugins/pstack", "description": "if you want to go fast, go deep first. pstack helps you write less, but higher quality code. rigorous agent workflows you can parallelize with confidence.", - "version": "1.10.1", + "version": "1.10.2", "author": { "name": "Lauren Tan (original)" }, diff --git a/CHANGELOG.md b/CHANGELOG.md index 41f7894..9a59fd5 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -4,6 +4,12 @@ This file records what each version of the Open Pstack package changed. Versions Entries describe package versions. Published release checkpoints have tag links; installation follows `main` unless pinned. Validation belongs to the linked pull requests. Older reports remain available through immutable links. +## 1.10.2 enables worker local checks + +Cursor baseline: [0.15.5](https://github.com/cursor/plugins/tree/12d587dfb20741cafc376c42c696c5f6e2a64487/pstack). [Issue #103](https://github.com/arjitj2/open-pstack/issues/103). [PR #104](https://github.com/arjitj2/open-pstack/pull/104). + +Writers receive tool-specific guidance and check their changes before returning. External Claude writers use sandboxed Bash for local checks, with no unsandboxed retry. File-only providers retain parent check handoffs. Parent acceptance and Git ownership remain separate from worker reports. + ## 1.10.1 fixes the documented receipt-normalization command Cursor baseline: [0.15.5](https://github.com/cursor/plugins/tree/12d587dfb20741cafc376c42c696c5f6e2a64487/pstack). [Issue #99](https://github.com/arjitj2/open-pstack/issues/99). [PR #100](https://github.com/arjitj2/open-pstack/pull/100). Tag [v1.10.1](https://github.com/arjitj2/open-pstack/releases/tag/v1.10.1). diff --git a/UPSTREAM.md b/UPSTREAM.md index 4e22fe1..80cc310 100644 --- a/UPSTREAM.md +++ b/UPSTREAM.md @@ -12,7 +12,7 @@ This page records the current Cursor baseline and the maintainer procedure for r | Path | `pstack/` | | Commit | `12d587dfb20741cafc376c42c696c5f6e2a64487` | | Upstream version | `0.15.5` | -| open-pstack version | `1.10.1` | +| open-pstack version | `1.10.2` | The table records the packaged version on `main` and the Cursor content it incorporates, minus the exclusions below. Detecting or reviewing a newer Cursor commit does not advance this baseline. Cursor's version identifies the imported content. The open-pstack version identifies the cross-harness package, and its numbers are independent of Cursor and Eric's port. diff --git a/plugins/pstack/.claude-plugin/plugin.json b/plugins/pstack/.claude-plugin/plugin.json index b5734c8..6f70b93 100644 --- a/plugins/pstack/.claude-plugin/plugin.json +++ b/plugins/pstack/.claude-plugin/plugin.json @@ -1,7 +1,7 @@ { "name": "pstack", "displayName": "pstack", - "version": "1.10.1", + "version": "1.10.2", "description": "if you want to go fast, go deep first. pstack helps you write less, but higher quality code. rigorous agent workflows you can parallelize with confidence. Ported from cursor/plugins/pstack for Claude Code and Codex.", "author": { "name": "Lauren Tan" diff --git a/plugins/pstack/.codex-plugin/plugin.json b/plugins/pstack/.codex-plugin/plugin.json index 4e55b06..97fa7fd 100644 --- a/plugins/pstack/.codex-plugin/plugin.json +++ b/plugins/pstack/.codex-plugin/plugin.json @@ -1,6 +1,6 @@ { "name": "pstack", - "version": "1.10.1", + "version": "1.10.2", "description": "if you want to go fast, go deep first. pstack helps you write less, but higher quality code. rigorous agent workflows you can parallelize with confidence. Codex port of the Claude Code plugin; skills are shared, tool names resolve via skills/poteto-mode/references/codex-tools.md.", "author": { "name": "Lauren Tan" diff --git a/plugins/pstack/skills/poteto-mode/references/provider-dispatch.md b/plugins/pstack/skills/poteto-mode/references/provider-dispatch.md index 5345145..79b2a55 100644 --- a/plugins/pstack/skills/poteto-mode/references/provider-dispatch.md +++ b/plugins/pstack/skills/poteto-mode/references/provider-dispatch.md @@ -170,7 +170,7 @@ Pass arguments as an argv array or quote every path. Never interpolate prompt te `legacy` is the default for existing assignments. Its ownership rule is prompt guidance. `strict` requires live proof that file edits remain possible while repository Git metadata, alternate gitdirs, metadata pointers and hardlinks, shell descendants, and authenticated remote mutation stay blocked. No current native or external route has that complete proof. `prepare --contract strict` returns `unsupported`; the runner writes a schema 2 `unsupported-capability` preflight receipt with `processStarted:false` and exit 79. This is a terminal capability stop, not a route failure that can advance a saved fallback. CLI help flags, tool allowlists, sandbox labels, and an inherited full-access parent do not establish the boundary. -Claude has no separate authentication preflight on any platform. Do not run `claude auth status` during setup or worker dispatch: its short-lived process can consume a refresh token and exit before saving the replacement ([upstream issue 95822](https://github.com/anthropics/claude-code/issues/95822)). The actual task handles authentication. Receipts record `preflight.status: not-run` and an unverified billing route. Known API environment checks remain, but Claude's account billing type is not asserted. See [the exception and conditions for revisiting it](https://github.com/arjitj2/open-pstack/issues/38). +Claude has no separate authentication preflight on any platform. Do not run `claude auth status` during setup or worker dispatch: its short-lived process can consume a refresh token and exit before saving the replacement ([upstream issue 95822](https://github.com/anthropics/claude-code/issues/95822)). The actual task handles authentication. Read-only receipts record `preflight.status: not-run`. Writers first run `claude --version` and require a stable version of at least `2.1.285`, the first version validated with the writer sandbox profile. Old or malformed versions produce `unavailable-cli` before model execution. A passing version probe does not establish authentication or billing. Known API environment checks remain, but Claude's account billing type is not asserted. See [the exception and conditions for revisiting it](https://github.com/arjitj2/open-pstack/issues/38). Grok authentication preflight has one bounded retry. If the first `grok models` result would be classified as unauthenticated, the runner waits five seconds and tries the same preflight once more. A second failure is terminal. The delay and second attempt share the runner's absolute deadline and cancellation latch, and the receipt keeps evidence from both attempts. Model execution is never retried. @@ -203,7 +203,9 @@ Surface a lane update to the user only when its rendered `changeKey` differs fro The runner and its preflight have no implicit timeout. Do not invent a duration from role, mode, or a convenient round number; real implementation lanes can run for 90 minutes or much longer. Pass `--timeout` only when the user, an external service deadline, or a measured task contract supplies a real bound. That value starts at wrapper entry, before module loading and argument parsing, and remains one absolute deadline across setup, preflight, model execution, and output capture. It is never a fresh allowance per child, and long waits are armed in runtime-safe chunks without shortening the supplied deadline. Otherwise supervise liveness through the retained background task/session handle and cancel manually only on evidence that the run is dead. Cancel through that retained handle so the runner receives SIGINT or SIGTERM, sends it to an active child when one remains, stops waiting on inherited output pipes, removes the empty output reservation, and writes a `cancelled` receipt. Preserve that receipt; a retry is a new attempt with new unique output and receipt paths. Unchanged running state is not a dropout, and Claude's ten-minute foreground ceiling is never a reason to terminate a healthy lane. -Read-only mode maps to Claude plan mode with project-only settings and an explicit tool list, Codex's read-only sandbox, and Grok plan mode plus its `read-only` sandbox and read-oriented tool list. Grok's built-in read-only profile deliberately keeps its own state and system temporary directories writable, so point a read-only Grok lane at the actual checkout rather than a worktree under `/tmp`, `/var/tmp`, or the host's temporary directory. `isolated-write` maps to Claude `acceptEdits` with project-only settings, Codex `workspace-write`, and Grok `acceptEdits` plus its `workspace` sandbox and write-capable tool list. Give every writer only a dedicated worktree or output directory. Never route a writer into the primary checkout. +Read-only mode maps to Claude plan mode with an explicit tool list and project-only settings, or empty setting sources under `apiSpend: deny`. It maps to Codex's read-only sandbox and Grok plan mode plus its `read-only` sandbox and read-oriented tool list. Grok's built-in read-only profile deliberately keeps its own state and system temporary directories writable, so point a read-only Grok lane at the actual checkout rather than a worktree under `/tmp`, `/var/tmp`, or the host's temporary directory. `isolated-write` maps to Claude `acceptEdits`, Codex `workspace-write`, and Grok `acceptEdits` plus its `workspace` sandbox and write-capable tool list. Give every writer only a dedicated worktree or output directory. Never route a writer into the primary checkout. + +External Claude writers receive session `--settings` that enable sandboxed Bash and `autoAllowBashIfSandboxed`, require `failIfUnavailable`, disable `allowUnsandboxedCommands`, leave `excludedCommands` empty, keep filesystem isolation enabled, and allow no network domains. Writer setting sources are empty for every spending policy, so project sandbox exclusions cannot widen command access. Managed policies still apply. Read-only lanes receive no sandbox settings because automatic Bash approval could permit writes. A missing sandbox dependency or unsupported platform fails loudly; the runner never retries without isolation or grants unrestricted Bash. Checks that need outside-directory caches or network access remain blocked and require parent inspection. This shell profile does not establish strict Git confinement. See [Claude's sandbox contract](https://code.claude.com/docs/en/sandboxing). Cursor uses the explicit `cursor-agent` executable, not `agent` (which can name another CLI). For stored OAuth credentials, preflight requires `cursor-agent status --format json` to return `isAuthenticated: true`. With a nonblank `CURSOR_API_KEY`, preflight checks `cursor-agent --version` only and records that authentication is deferred to the real model invocation; Cursor status does not report API-key authentication. The key stays in the environment. Authentication failures from execution fail the lane. Setup always performs a real model probe because preflight alone does not prove model access. The runner sends the prompt through stdin, pins `--model`, requests a successful JSON terminal result, and uses `--workspace` with `--sandbox enabled`. Read-only adds `--mode ask` and denies `Write(**)` and `Shell(*)`; it cannot run shell-based tests. Writers use the sandbox in their dedicated workspace without `--force` or `--yolo`. @@ -236,6 +238,8 @@ Start native and external lanes in the same fan-out phase, then wait for all of The parent owns the assigned repository's index, refs, configuration, Git metadata, and remote writes. Every native or external worker receives this rule in its prepared prompt. Workers may edit ordinary assigned files and run allowed checks; tests may create disposable fixture repositories only when the task allows them. Read-only tool settings alone do not remove every route's shell or network authority, and the legacy prompt is not an enforcement claim. +Writer assignments require suitable local checks, repair of failures caused by their edits, and another affected check run. The parent gives provider-neutral tasks; each external invocation supplies guidance for its configured tools. Devin writers use sandboxed `exec` for edits and checks. OpenCode and Antigravity remain file-only and request required parent checks through `run-checks`. Native workers use permitted inherited host tools without an enforcement promise. A failing assertion permits repair; a denied tool ends the worker turn without retry, workaround, or escalation. Final responses name actual commands, observed results, and checks left unverified. Worker reports and worker-written logs are advisory. Provider tool observations establish worker execution; separate parent acceptance checks establish the reviewed candidate's behavior. A `complete` receipt proves final delivery, not independently verified checks. + A worker that needs a parent-only operation may end its ordinary final response with exactly one `pstack-handoff` fenced JSON object containing `task`, `checkpoint`, `operation`, `files`, `checks`, and `summary`. `operation` is `commit-checkpoint` or `run-checks`. A delivered handoff is **not** task completion. Schema 2 receipts use status `needs-parent-operation`; an ordinary final response uses `complete`. Truncated, repeated, or malformed reserved markers fail closed. Older schema 1 readers reject schema 2 rather than interpreting the handoff as completed work. A verified permission refusal is classified from provider-owned evidence; worker prose alone cannot prove it, and an earlier nonessential refusal cannot replay a delivered completion. The parent can inspect a response with `pstack-worker-contract interpret --response `. Before acting on a handoff, run: diff --git a/plugins/pstack/skills/poteto-mode/scripts/runner/antigravity.test.ts b/plugins/pstack/skills/poteto-mode/scripts/runner/antigravity.test.ts index 734bba4..587608a 100644 --- a/plugins/pstack/skills/poteto-mode/scripts/runner/antigravity.test.ts +++ b/plugins/pstack/skills/poteto-mode/scripts/runner/antigravity.test.ts @@ -133,6 +133,21 @@ describe("Antigravity external lanes", () => { expect(existsSync(join(opts.cwd,".agents"))).toBe(false); }); + it("sends the rendered contract with file-only guidance and workspace addressing", async () => { + const opts = options({mode:"isolated-write"}); + const result = await runLane(opts); + expect(result.receipt.status).toBe("complete"); + const seen = observed(); + const turn = JSON.parse(seen.prompt); + expect(turn.event).toBe("user"); + const content = turn.message.content as string; + expect(content).toContain(`Edit only the assigned dedicated worktree at ${opts.cwd}.`); + expect(content).toContain("## Worker contract"); + expect(content).toContain("command execution is unavailable, so request required checks through the run-checks handoff"); + expect(content).not.toContain("the parent runs tests"); + expect(content).toContain("Read the assigned value and answer."); + }); + it("blocks ambient API credentials before any CLI process starts", async () => { process.env.GEMINI_API_KEY = "secret-not-for-receipts"; const result = await runLane(options({apiSpend:"deny"})); diff --git a/plugins/pstack/skills/poteto-mode/scripts/runner/antigravity.ts b/plugins/pstack/skills/poteto-mode/scripts/runner/antigravity.ts index 103c936..de182ad 100644 --- a/plugins/pstack/skills/poteto-mode/scripts/runner/antigravity.ts +++ b/plugins/pstack/skills/poteto-mode/scripts/runner/antigravity.ts @@ -106,7 +106,7 @@ export function createAntigravityLaneFiles(options: RunnerOptions): AntigravityC export function antigravityStdin(prompt: string, cwd: string, mode: AccessMode): string { const instruction = mode === "read-only" ? `Inspect the assigned workspace at ${cwd}. Do not write files.` - : `Edit only the assigned dedicated worktree at ${cwd}. File tools only; the parent runs tests.`; + : `Edit only the assigned dedicated worktree at ${cwd}.`; return `${JSON.stringify({ event: "user", message: { content: `${instruction}\n\n${prompt}` } })}\n`; } diff --git a/plugins/pstack/skills/poteto-mode/scripts/runner/commands.test.ts b/plugins/pstack/skills/poteto-mode/scripts/runner/commands.test.ts index 32fecac..27dfdf3 100644 --- a/plugins/pstack/skills/poteto-mode/scripts/runner/commands.test.ts +++ b/plugins/pstack/skills/poteto-mode/scripts/runner/commands.test.ts @@ -1,5 +1,11 @@ import { describe, expect, it } from "bun:test"; -import { invocationCommand, preflightCommand } from "./commands.ts"; +import { + CLAUDE_WRITER_MINIMUM_VERSION, + CLAUDE_WRITER_SETTINGS, + claudeWriterVersionError, + invocationCommand, + preflightCommand, +} from "./commands.ts"; import type { RunnerOptions } from "./types.ts"; function options(overrides: Partial = {}): RunnerOptions { @@ -232,13 +238,102 @@ describe("invocationCommand", () => { }); }); -it("omits Claude preflight while retaining its invocation settings restriction", () => { - expect(preflightCommand("claude")).toBeNull(); - const input = options({ provider: "claude", model: "opus", apiSpend: "deny" }); - const denied = invocationCommand(input); - expect(denied.args[denied.args.indexOf("--setting-sources") + 1]).toBe(""); - const approved = invocationCommand({ ...input, apiSpend: "approved" }); - expect(approved.args[approved.args.indexOf("--setting-sources") + 1]).toBe("project"); +describe("Claude writer sandbox profile", () => { + it("probes claude --version before writer workloads and skips read-only preflight", () => { + expect(preflightCommand("claude", "isolated-write")).toEqual({ + command: "claude", + args: ["--version"], + stdin: "none", + }); + expect(preflightCommand("claude", "read-only")).toBeNull(); + }); + + it("accepts the minimum or newer stable versions and rejects older or malformed output", () => { + for (const version of [CLAUDE_WRITER_MINIMUM_VERSION, "2.1.285 (Claude Code)", "2.1.286", "2.10.0", "3.0.0"]) { + expect(claudeWriterVersionError(`${version}\n`), version).toBeNull(); + } + for (const version of ["2.1.284", "2.1.284 (Claude Code)", "2.0.0", "1.99.0", "2.1.285-beta.1", "2.1.285 (unexpected)", "v2.1.285", "2.1", "", "unstable"]) { + expect(claudeWriterVersionError(version), version).not.toBeNull(); + } + }); + + it("passes the exact measured sandbox profile inline for writers only", () => { + const writer = invocationCommand( + options({ provider: "claude", model: "opus", mode: "isolated-write" }) + ); + const settingsIndex = writer.args.indexOf("--settings"); + expect(settingsIndex).toBeGreaterThanOrEqual(0); + expect(writer.args[settingsIndex + 1]).toBe(CLAUDE_WRITER_SETTINGS); + expect(JSON.parse(writer.args[settingsIndex + 1])).toEqual({ + sandbox: { + enabled: true, + autoAllowBashIfSandboxed: true, + allowUnsandboxedCommands: false, + failIfUnavailable: true, + excludedCommands: [], + filesystem: { disabled: false }, + network: { allowedDomains: [] }, + }, + }); + const readOnly = invocationCommand( + options({ provider: "claude", model: "opus", mode: "read-only" }) + ); + expect(readOnly.args).not.toContain("--settings"); + }); + + it("excludes project settings on writer lanes for every apiSpend value", () => { + for (const apiSpend of ["deny", "approved", null] as const) { + const spec = invocationCommand( + options({ provider: "claude", model: "opus", mode: "isolated-write", apiSpend }) + ); + const sources = spec.args[spec.args.indexOf("--setting-sources") + 1]; + expect(sources, String(apiSpend)).toBe(""); + } + }); + + it("omits Claude auth preflight while retaining read-only settings restrictions", () => { + const input = options({ provider: "claude", model: "opus", apiSpend: "deny" }); + const denied = invocationCommand(input); + expect(denied.args[denied.args.indexOf("--setting-sources") + 1]).toBe(""); + const approved = invocationCommand({ ...input, apiSpend: "approved" }); + expect(approved.args[approved.args.indexOf("--setting-sources") + 1]).toBe("project"); + }); +}); + +describe("worker guidance paired with invocation flags", () => { + it("returns corresponding guidance for every provider and access mode", () => { + for (const mode of ["read-only", "isolated-write"] as const) { + for (const provider of ["claude", "codex", "grok", "devin", "cursor", "antigravity", "opencode"] as const) { + const spec = invocationCommand( + options({ provider, model: "any-model", effort: "default", mode }) + ); + expect(spec.workerGuidance.trim().length, `${provider} ${mode}`).toBeGreaterThan(0); + expect(spec.workerGuidance, `${provider} ${mode}`).toContain("Provider tools:"); + } + } + }); + + it("guides Devin writers through sandboxed exec and readers without exec", () => { + const writer = invocationCommand( + options({ provider: "devin", model: "swe-2", mode: "isolated-write" }) + ); + expect(writer.workerGuidance).toContain("sandboxed `exec`"); + const reader = invocationCommand( + options({ provider: "devin", model: "swe-2", mode: "read-only" }) + ); + expect(reader.workerGuidance).toContain("`exec` is disabled"); + expect(reader.workerGuidance).not.toContain("sandboxed `exec`"); + }); + + it("directs file-only writers to the run-checks handoff", () => { + for (const provider of ["antigravity", "opencode"] as const) { + const spec = invocationCommand( + options({ provider, model: "any-model", effort: "default", mode: "isolated-write" }) + ); + expect(spec.workerGuidance, provider).toContain("run-checks"); + expect(spec.workerGuidance, provider).toContain("command execution is unavailable"); + } + }); }); describe("strict worker contract argv", () => { diff --git a/plugins/pstack/skills/poteto-mode/scripts/runner/commands.ts b/plugins/pstack/skills/poteto-mode/scripts/runner/commands.ts index 995ec22..6d85037 100644 --- a/plugins/pstack/skills/poteto-mode/scripts/runner/commands.ts +++ b/plugins/pstack/skills/poteto-mode/scripts/runner/commands.ts @@ -19,7 +19,33 @@ export interface CommandSpec { readonly cwd?: string; } -export function preflightCommand(provider: Provider): CommandSpec | null { +export interface WorkerCommandSpec extends CommandSpec { + readonly workerGuidance: string; +} + +export const CLAUDE_WRITER_MINIMUM_VERSION = "2.1.285"; + +export const CLAUDE_WRITER_SETTINGS = + '{"sandbox":{"enabled":true,"autoAllowBashIfSandboxed":true,"allowUnsandboxedCommands":false,"failIfUnavailable":true,"excludedCommands":[],"filesystem":{"disabled":false},"network":{"allowedDomains":[]}}}'; + +export function claudeWriterVersionError(stdout: string): string | null { + const version = stdout.trim(); + const match = /^(0|[1-9][0-9]*)\.(0|[1-9][0-9]*)\.(0|[1-9][0-9]*)(?: \(Claude Code\))?$/.exec(version); + const parts = match?.slice(1).map(Number); + if (parts === undefined || !parts.every(Number.isSafeInteger)) { + return `Cannot verify the Claude Code version. Install Claude Code ${CLAUDE_WRITER_MINIMUM_VERSION} or newer and check claude --version.`; + } + const minimum = CLAUDE_WRITER_MINIMUM_VERSION.split(".").map(Number); + for (let index = 0; index < minimum.length; index++) { + if (parts[index] > minimum[index]) return null; + if (parts[index] < minimum[index]) { + return `Claude Code ${version} is too old. Writer lanes require ${CLAUDE_WRITER_MINIMUM_VERSION} or newer for the verified sandbox profile.`; + } + } + return null; +} + +export function preflightCommand(provider: Provider, mode: AccessMode): CommandSpec | null { switch (provider) { case "opencode": return { command: "opencode", args: ["--pure", "debug", "config"], stdin: "none" }; @@ -32,7 +58,9 @@ export function preflightCommand(provider: Provider): CommandSpec | null { stdin: "none", }; case "claude": - return null; + return mode === "isolated-write" + ? { command: "claude", args: ["--version"], stdin: "none" } + : null; case "codex": return { command: "codex", @@ -79,10 +107,47 @@ function effortOverride(effort: Effort): string { return `model_reasoning_effort=${JSON.stringify(effort)}`; } +function workerGuidance(provider: Provider, mode: AccessMode): string { + if (mode === "read-only") { + switch (provider) { + case "claude": + return "- Provider tools: inspect with `Read`, `Grep`, `Glob`, and `Bash`. Write tools are denied on this lane."; + case "codex": + return "- Provider tools: inspect with the CLI's tools inside its `read-only` sandbox."; + case "grok": + return "- Provider tools: `read_file`, `grep`, `list_dir`, and `run_terminal_cmd` are available; `search_replace` is denied."; + case "devin": + return "- Provider tools: inspect with your read tools. `exec` is disabled on this lane, so command execution is unavailable."; + case "cursor": + return "- Provider tools: inspect with the available read tools in ask mode. Writes and shell commands are denied on this lane."; + case "antigravity": + return "- Provider tools: `view_file`, `list_dir`, and `grep_search` are your only tools; no write or command tools are available."; + case "opencode": + return "- Provider tools: read, glob, and grep are your only tools; no write or command tools are available."; + } + } + switch (provider) { + case "claude": + return "- Provider tools: use `Edit`/`Write` for file changes and `Bash` for suitable checks; commands run inside the configured sandbox. Stop on a denied command."; + case "codex": + return "- Provider tools: use the CLI's file and shell tools inside its `workspace-write` sandbox."; + case "grok": + return "- Provider tools: use `search_replace` for edits and `run_terminal_cmd` for checks inside the configured workspace sandbox."; + case "devin": + return "- Provider tools: use sandboxed `exec` for every file creation, modification, and check, including the first file operation. Direct `edit` and `write` tools are disabled."; + case "cursor": + return "- Provider tools: use the available file and shell tools inside the configured workspace sandbox."; + case "antigravity": + return "- Provider tools: the configured file tools are your only tools; command execution is unavailable, so request required checks through the run-checks handoff."; + case "opencode": + return "- Provider tools: the configured edit tool is your only write path; command execution is unavailable, so request required checks through the run-checks handoff."; + } +} + export function invocationCommand( options: RunnerOptions, effectivePromptPath: string = options.promptPath -): CommandSpec { +): WorkerCommandSpec { if ((options.contract ?? "legacy") === "strict") { const verdict = strictRouteSupport({ parent: options.parent, @@ -107,10 +172,11 @@ export function invocationCommand( ], stdin: "prompt-ndjson", cwd: files.childCwd, + workerGuidance: workerGuidance(options.provider, options.mode), }; } case "opencode": - return { command: "opencode", args: ["run", "--pure", "--format", "json", "--model", options.model, "--agent", openCodeAgent(options), "--dir", options.cwd, "--title", "pstack-worker"], stdin: "prompt" }; + return { command: "opencode", args: ["run", "--pure", "--format", "json", "--model", options.model, "--agent", openCodeAgent(options), "--dir", options.cwd, "--title", "pstack-worker"], stdin: "prompt", workerGuidance: workerGuidance(options.provider, options.mode) }; case "devin": return { command: "devin", @@ -131,6 +197,7 @@ export function invocationCommand( "--print", ], stdin: "none", + workerGuidance: workerGuidance(options.provider, options.mode), }; case "cursor": return { @@ -143,6 +210,7 @@ export function invocationCommand( ...(options.mode === "read-only" ? ["--mode", "ask"] : []), ], stdin: "prompt", + workerGuidance: workerGuidance(options.provider, options.mode), }; case "claude": return { @@ -156,7 +224,10 @@ export function invocationCommand( "--permission-mode", permissionMode(options.mode), "--setting-sources", - options.apiSpend === "deny" ? "" : "project", + options.mode === "isolated-write" ? "" : options.apiSpend === "deny" ? "" : "project", + ...(options.mode === "isolated-write" + ? ["--settings", CLAUDE_WRITER_SETTINGS] + : []), "--strict-mcp-config", "--tools", claudeTools(options.mode), @@ -169,6 +240,7 @@ export function invocationCommand( "--verbose", ], stdin: "prompt", + workerGuidance: workerGuidance(options.provider, options.mode), }; case "codex": return { @@ -197,6 +269,7 @@ export function invocationCommand( "-", ], stdin: "prompt", + workerGuidance: workerGuidance(options.provider, options.mode), }; case "grok": return { @@ -225,6 +298,7 @@ export function invocationCommand( "--verbatim", ], stdin: "none", + workerGuidance: workerGuidance(options.provider, options.mode), }; } } diff --git a/plugins/pstack/skills/poteto-mode/scripts/runner/devin.test.ts b/plugins/pstack/skills/poteto-mode/scripts/runner/devin.test.ts index 852dc06..bb9bdb3 100644 --- a/plugins/pstack/skills/poteto-mode/scripts/runner/devin.test.ts +++ b/plugins/pstack/skills/poteto-mode/scripts/runner/devin.test.ts @@ -185,7 +185,7 @@ describe("Devin external provider", () => { }); } - it("adds writer tool constraints without modifying the assigned prompt", async () => { + it("renders one contract prompt with exec guidance without modifying the assigned prompt", async () => { const original = "Create a file.\nThen run its test.\n"; writeFileSync(options.promptPath, original); fakeDevin("DONE"); @@ -193,7 +193,11 @@ describe("Devin external provider", () => { const result = await runLane(input); expect(result.exitCode).toBe(0); const sent = readFileSync(join(scratch, "captured-prompt.txt"), "utf8"); - expect(sent).toContain("Use sandboxed exec for ALL file creation, modification, and testing"); + expect(sent).toContain("## Worker contract"); + expect(sent).toContain("sandboxed `exec` for every file creation, modification, and check"); + expect(sent).toContain("Direct `edit` and `write` tools are disabled"); + expect(sent).toContain("Local checks:"); + expect(sent).not.toContain("Execution constraints for this Devin worker"); expect(sent.endsWith(original)).toBe(true); expect(readFileSync(options.promptPath, "utf8")).toBe(original); expect(result.receipt.promptPath).toBe(options.promptPath); @@ -201,6 +205,18 @@ describe("Devin external provider", () => { expect(existsSync(devinExportDirectory(input))).toBe(false); }); + it("guides read-only lanes without exec", async () => { + fakeDevin("DONE"); + const input = { ...options, mode: "read-only" as const }; + const result = await runLane(input); + expect(result.exitCode).toBe(0); + const sent = readFileSync(join(scratch, "captured-prompt.txt"), "utf8"); + expect(sent).toContain("## Worker contract"); + expect(sent).toContain("`exec` is disabled"); + expect(sent).not.toContain("sandboxed `exec` for every file creation"); + expect(sent).not.toContain("Local checks:"); + }); + it("keeps unproven account-restriction wording as an ordinary child failure", async () => { fakeDevin("Upgrade to Pro to access this model", 1); const result = await runLane(options); diff --git a/plugins/pstack/skills/poteto-mode/scripts/runner/devin.ts b/plugins/pstack/skills/poteto-mode/scripts/runner/devin.ts index 0d07f36..4d2a9d7 100644 --- a/plugins/pstack/skills/poteto-mode/scripts/runner/devin.ts +++ b/plugins/pstack/skills/poteto-mode/scripts/runner/devin.ts @@ -1,6 +1,5 @@ import { readFileSync } from "node:fs"; import { OutputValidationError, UsageError, type Effort, type RunnerOptions } from "./types.ts"; -import { renderWorkerPrompt } from "../worker-contract/worker-contract.ts"; export function devinModel(model: string, effort: Effort): string { if (model === "swe-2" && ["medium", "high", "max"].includes(effort)) { @@ -34,27 +33,6 @@ export function devinPromptPath(options: RunnerOptions): string { return `${devinExportDirectory(options)}/prompt.md`; } -// Devin-specific tool guidance appended to the shared worker contract: the -// common ownership and handoff instructions live in the worker-contract -// module so native and external dispatch render the same contract. -export function devinWriterPrompt(prompt: string, options: RunnerOptions): string { - return "Execution constraints for this Devin worker:\n" + - "You are running non-interactively in an isolated-write workspace. " + - "Direct write and edit tools are disabled and terminate the run if attempted. " + - "Use sandboxed exec for ALL file creation, modification, and testing, including the first file operation. " + - "Do not request permissions or use direct write/edit tools.\n\n" + - renderWorkerPrompt( - { - parent: options.parent, - provider: "devin", - route: "external", - access: options.mode, - contract: options.contract ?? "legacy", - }, - prompt - ); -} - export function readDevinExport(options: RunnerOptions): string { try { return readFileSync(devinExportPath(options), "utf8"); diff --git a/plugins/pstack/skills/poteto-mode/scripts/runner/run.test.ts b/plugins/pstack/skills/poteto-mode/scripts/runner/run.test.ts index b646fcb..0992971 100644 --- a/plugins/pstack/skills/poteto-mode/scripts/runner/run.test.ts +++ b/plugins/pstack/skills/poteto-mode/scripts/runner/run.test.ts @@ -28,7 +28,7 @@ import { appendFileSync, existsSync, unlinkSync, writeFileSync } from "node:fs"; const args = process.argv.slice(2); const name = process.argv[1].split("/").at(-1); const isPreflight = - (name === "claude" && args.includes("auth")) || + (name === "claude" && (args.includes("auth") || args.includes("--version"))) || (name === "codex" && args[0] === "login") || (name === "grok" && args[0] === "models") || (name === "devin" && args[0] === "auth") || @@ -55,6 +55,12 @@ if (process.env.FAKE_STDIN_CAPTURE_PATH && !isPreflight) { const stdinText = await new Response(process.stdin).text(); writeFileSync(process.env.FAKE_STDIN_CAPTURE_PATH, stdinText); } +if (process.env.FAKE_PROMPT_FILE_CAPTURE_PATH && args.includes("--prompt-file")) { + writeFileSync( + process.env.FAKE_PROMPT_FILE_CAPTURE_PATH, + await Bun.file(args[args.indexOf("--prompt-file") + 1]).text() + ); +} const startedPath = isPreflight ? process.env.FAKE_PREFLIGHT_STARTED_PATH : process.env.FAKE_MODEL_STARTED_PATH; @@ -98,6 +104,10 @@ if (isPreflight && process.env.FAKE_REMOVE_EXECUTABLE_AFTER_PREFLIGHT === "1") { if (name === "claude" && args.includes("auth")) { throw new Error("Unexpected Claude auth probe"); } +if (name === "claude" && args.includes("--version")) { + console.log(process.env.FAKE_CLAUDE_VERSION ?? "2.1.285"); + process.exit(Number(process.env.FAKE_CLAUDE_VERSION_EXIT ?? "0")); +} if (name === "codex" && args[0] === "login") { console.log(process.env.FAKE_CODEX_LOGIN_STATUS ?? "Logged in using ChatGPT"); process.exit(0); @@ -373,6 +383,9 @@ beforeEach(() => { delete process.env.FAKE_GROK_TRANSIENT_UNAUTH_PATH; delete process.env.FAKE_HELP_TEXT; delete process.env.FAKE_STDIN_CAPTURE_PATH; + delete process.env.FAKE_PROMPT_FILE_CAPTURE_PATH; + delete process.env.FAKE_CLAUDE_VERSION; + delete process.env.FAKE_CLAUDE_VERSION_EXIT; delete process.env.FAKE_GROK_PREFLIGHT_LOG_PATH; delete process.env.FAKE_GROK_MISSING_MODEL; delete process.env.FAKE_DESCENDANT_HOLDS_PIPES_MS; @@ -445,6 +458,9 @@ afterEach(() => { delete process.env.FAKE_GROK_TRANSIENT_UNAUTH_PATH; delete process.env.FAKE_HELP_TEXT; delete process.env.FAKE_STDIN_CAPTURE_PATH; + delete process.env.FAKE_PROMPT_FILE_CAPTURE_PATH; + delete process.env.FAKE_CLAUDE_VERSION; + delete process.env.FAKE_CLAUDE_VERSION_EXIT; delete process.env.FAKE_GROK_PREFLIGHT_LOG_PATH; delete process.env.FAKE_GROK_MISSING_MODEL; delete process.env.FAKE_DESCENDANT_HOLDS_PIPES_MS; @@ -2105,9 +2121,38 @@ describe("worker contract dispatch", () => { const sent = readFileSync(stdinPath, "utf8"); expect(sent).toContain("## Worker contract"); expect(sent).toContain("Never stage, commit, reset, rebase"); + expect(sent).toContain("Provider tools:"); + expect(sent).toContain("workspace-write"); + expect(sent).toContain("Local checks:"); expect(sent.endsWith("Assigned task:\nReturn the marker.")).toBe(true); }); + it("sends the same rendered bytes through a file transport", async () => { + const filePath = join(scratch, "grok-prompt.txt"); + process.env.FAKE_PROMPT_FILE_CAPTURE_PATH = filePath; + const input: RunnerOptions = { ...options("grok", "grok-writer"), mode: "isolated-write" }; + const result = await runLane(input); + expect(result.receipt.status).toBe("complete"); + const sent = readFileSync(filePath, "utf8"); + expect(sent).toContain("## Worker contract"); + expect(sent).toContain("Provider tools:"); + expect(sent).toContain("search_replace"); + expect(sent).toContain("run_terminal_cmd"); + expect(sent).toContain("Local checks:"); + expect(sent.endsWith("Assigned task:\nReturn the marker.")).toBe(true); + }); + + it("keeps read-only lanes free of the writer check obligation", async () => { + const stdinPath = join(scratch, "ro-stdin-guidance.txt"); + process.env.FAKE_STDIN_CAPTURE_PATH = stdinPath; + const readOnly = options("codex", "ro-guidance"); + await runLane(readOnly); + const sent = readFileSync(stdinPath, "utf8"); + expect(sent).toContain("Provider tools:"); + expect(sent).toContain("read-only"); + expect(sent).not.toContain("Local checks:"); + }); + it("prepends the shared contract to read-only external lanes", async () => { const stdinPath = join(scratch, "ro-stdin.txt"); process.env.FAKE_STDIN_CAPTURE_PATH = stdinPath; @@ -2180,6 +2225,92 @@ describe("worker contract dispatch", () => { }); }); +describe("claude writer lanes", () => { + const writerOptions = (suffix: string, overrides: Partial = {}): RunnerOptions => ({ + ...options("claude", suffix), + mode: "isolated-write", + apiSpend: "deny", + ...overrides, + }); + + it("runs the version gate and sends the contract prompt with sandbox argv", async () => { + const stdinPath = join(scratch, "claude-writer-stdin.txt"); + process.env.FAKE_STDIN_CAPTURE_PATH = stdinPath; + process.env.FAKE_PREFLIGHT_STARTED_PATH = join(scratch, "version-started"); + const input = writerOptions("claude-writer"); + const result = await runLane(input); + expect(result.receipt.status).toBe("complete"); + expect(result.receipt.preflight.status).toBe("passed"); + expect(result.receipt.preflight.argv).toContain("--version"); + expect(existsSync(join(scratch, "version-started"))).toBe(true); + expect(result.receipt.argv).toContain("--settings"); + const settings = result.receipt.argv[result.receipt.argv.indexOf("--settings") + 1]; + expect(JSON.parse(settings)).toEqual({ + sandbox: { + enabled: true, + autoAllowBashIfSandboxed: true, + allowUnsandboxedCommands: false, + failIfUnavailable: true, + excludedCommands: [], + filesystem: { disabled: false }, + network: { allowedDomains: [] }, + }, + }); + expect(result.receipt.argv[result.receipt.argv.indexOf("--setting-sources") + 1]).toBe(""); + const sent = readFileSync(stdinPath, "utf8"); + expect(sent).toContain("## Worker contract"); + expect(sent).toContain("Local checks:"); + expect(sent).toContain("`Bash` for suitable checks"); + expect(sent.endsWith("Assigned task:\nReturn the marker.")).toBe(true); + }); + + it("keeps project settings excluded for approved and unset apiSpend", async () => { + for (const [label, apiSpend] of [["approved", "approved"], ["unset", null]] as const) { + const input = writerOptions(`claude-writer-${label}`, { apiSpend }); + const result = await runLane(input); + expect(result.receipt.status, label).toBe("complete"); + expect(result.receipt.argv[result.receipt.argv.indexOf("--setting-sources") + 1], label).toBe(""); + } + }); + + for (const [name, version] of [["an old", "2.1.284"], ["a malformed", "not-a-version"]] as const) { + it(`fails ${name} CLI version before any model execution`, async () => { + process.env.FAKE_CLAUDE_VERSION = version; + process.env.FAKE_MODEL_STARTED_PATH = join(scratch, `model-started-${name}`); + const input = writerOptions(`claude-writer-${name}-version`); + const result = await runLane(input); + expect(result.receipt.status).toBe("unavailable-cli"); + expect(result.exitCode).toBe(69); + expect(result.receipt.processStarted).toBe(false); + expect(result.receipt.failurePhase).toBe("preflight"); + expect(result.receipt.preflight.status).toBe("failed"); + expect(existsSync(join(scratch, `model-started-${name}`))).toBe(false); + expect(existsSync(input.outputPath)).toBe(false); + }); + } + + it("fails a missing version command before model execution", async () => { + process.env.FAKE_CLAUDE_VERSION = ""; + process.env.FAKE_CLAUDE_VERSION_EXIT = "1"; + process.env.FAKE_MODEL_STARTED_PATH = join(scratch, "model-started-exit"); + const input = writerOptions("claude-writer-version-exit"); + const result = await runLane(input); + expect(result.receipt.status).toBe("unavailable-cli"); + expect(result.receipt.processStarted).toBe(false); + expect(existsSync(join(scratch, "model-started-exit"))).toBe(false); + }); + + it("keeps read-only lanes on the existing no-version invocation", async () => { + const input = options("claude", "claude-reader"); + const result = await runLane(input); + expect(result.receipt.status).toBe("complete"); + expect(result.receipt.preflight.status).toBe("not-run"); + expect(result.receipt.argv).not.toContain("--settings"); + expect(result.receipt.argv).not.toContain("--version"); + expect(result.receipt.argv[result.receipt.argv.indexOf("--setting-sources") + 1]).toBe("project"); + }); +}); + describe("failure receipt privacy", () => { const RECEIPT_CANARIES = [ "CANARY_PROMPT_EVENT", diff --git a/plugins/pstack/skills/poteto-mode/scripts/runner/run.ts b/plugins/pstack/skills/poteto-mode/scripts/runner/run.ts index c6d4912..13ebe63 100644 --- a/plugins/pstack/skills/poteto-mode/scripts/runner/run.ts +++ b/plugins/pstack/skills/poteto-mode/scripts/runner/run.ts @@ -17,7 +17,7 @@ import { type ChildRole, type ProgressReporter, } from "./progress.ts"; -import { invocationCommand, preflightCommand, type CommandSpec } from "./commands.ts"; +import { CLAUDE_WRITER_MINIMUM_VERSION, claudeWriterVersionError, invocationCommand, preflightCommand, type CommandSpec } from "./commands.ts"; import { nativeLane } from "./native-route.ts"; import { openCodeConfig, openCodeDirectory, openCodeEnvironment, openCodePreflightPassed, openCodeVersionError, OPENCODE_MINIMUM_VERSION, validateOpenCodeModel } from "./opencode.ts"; import { cursorConfigDirectory, cursorConfig, cursorHasApiKey, cursorUserConfigPath, validateCursorModel } from "./cursor.ts"; @@ -49,7 +49,7 @@ import type { RunnerReceipt, } from "./types.ts"; import { OutputValidationError, UsageError } from "./types.ts"; -import { devinConfig, devinConfigPath, devinExportDirectory, devinExportPath, devinModel, devinPromptPath, devinWriterPrompt, readDevinExport } from "./devin.ts"; +import { devinConfig, devinConfigPath, devinExportDirectory, devinExportPath, devinModel, devinPromptPath, readDevinExport } from "./devin.ts"; const ERROR_EVIDENCE_LIMIT = 4_000; const GROK_PREFLIGHT_RETRY_DELAY_MS = 5_000; @@ -93,10 +93,6 @@ function contractPromptPath(options: RunnerOptions): string { return `${options.receiptPath}.contract-prompt.md`; } -function filePromptProvider(provider: Provider): boolean { - return provider === "devin" || provider === "grok"; -} - function reserve(path: string): void { mkdirSync(dirname(path), { recursive: true }); const descriptor = openSync(path, "wx", 0o600); @@ -719,22 +715,10 @@ async function executeLane( preflight: CommandSpec | null, progress: LaneProgress, antigravityCreated: AntigravityCreatedFiles | null, - reporter: ProgressReporter + reporter: ProgressReporter, + prompt: string ): Promise { const startedAt = new Date(started).toISOString(); - const rawPrompt = readFileSync(options.promptPath, "utf8"); - const prompt = !filePromptProvider(options.provider) - ? renderWorkerPrompt( - { - parent: options.parent, - provider: options.provider, - route: "external", - access: options.mode, - contract: options.contract ?? "legacy", - }, - rawPrompt - ) - : rawPrompt; const inherited = childEnvironment(options.provider); const env = options.provider === "opencode" ? openCodeEnvironment(options, inherited) : inherited; const apiKeyAuth = options.provider === "cursor" && cursorHasApiKey(env); @@ -892,13 +876,19 @@ async function executeLane( progress.preflight = { ...progress.preflight, argv: [preflightExecutable, ...activePreflight.args] }; preflightResult = await runProcess(preflightExecutable, activePreflight, options.cwd, env, "", deadlineAt, cancellation, { reporter, role: "preflight" }); } + } else if (options.provider === "claude") { + versionError = preflightResult.exitCode === 0 + ? claudeWriterVersionError(preflightResult.stdout) + : `Claude Code version check failed. Install Claude Code ${CLAUDE_WRITER_MINIMUM_VERSION} or newer and check claude --version.`; } let rawPreflightEvidence = versionError ?? (options.provider === "opencode" ? "OpenCode effective agent preflight failed; configuration output withheld" : evidence(`${preflightResult.stdout}\n${preflightResult.stderr}`)); let passed = options.provider === "opencode" ? versionError === null && preflightResult.exitCode === 0 && openCodePreflightPassed(preflightResult.stdout, options, env) - : preflightPassed(options.provider, options.model, preflightResult, apiKeyAuth); + : options.provider === "claude" + ? versionError === null && preflightResult.exitCode === 0 + : preflightPassed(options.provider, options.model, preflightResult, apiKeyAuth); const renderPreflightDetail = (): string => failureDiagnostic(options.provider, { phase: "preflight", stdout: preflightResult.stdout, @@ -908,7 +898,11 @@ async function executeLane( : undefined), }); let preflightEvidence = passed - ? options.provider === "opencode" ? "effective agent model and tool policy verified; authentication deferred to execution" : successfulPreflightEvidence(options.provider, options.model, apiKeyAuth) + ? options.provider === "opencode" + ? "effective agent model and tool policy verified; authentication deferred to execution" + : options.provider === "claude" + ? `Claude Code ${preflightResult.stdout.trim()} satisfies the writer minimum; authentication deferred to invocation` + : successfulPreflightEvidence(options.provider, options.model, apiKeyAuth) : renderPreflightDetail(); if ( @@ -1299,7 +1293,7 @@ export async function runLane( ? contractPromptPath(options) : options.promptPath; const invocation = invocationCommand(options, effectivePromptPath); - const preflight = preflightCommand(options.provider); + const preflight = preflightCommand(options.provider, options.mode); const progress: LaneProgress = { executable: null, preflight: { @@ -1336,6 +1330,17 @@ export async function runLane( }); } try { + const prompt = renderWorkerPrompt( + { + parent: options.parent, + provider: options.provider, + route: "external", + access: options.mode, + contract: options.contract ?? "legacy", + }, + readFileSync(options.promptPath, "utf8"), + invocation.workerGuidance + ); if (options.provider === "devin") { writeFileSync(devinConfigPath(options), JSON.stringify(devinConfig(options)), { encoding: "utf8", mode: 0o600, flag: "wx", @@ -1344,21 +1349,12 @@ export async function runLane( mkdirSync(devinExportDirectory(options), { mode: 0o700 }); devinExportCreated = true; reserve(devinExportPath(options)); - writeFileSync(devinPromptPath(options), devinWriterPrompt(readFileSync(options.promptPath, "utf8"), options), { + writeFileSync(devinPromptPath(options), prompt, { encoding: "utf8", mode: 0o600, flag: "wx", }); } if (options.provider === "grok") { - writeFileSync(contractPromptPath(options), renderWorkerPrompt( - { - parent: options.parent, - provider: "grok", - route: "external", - access: options.mode, - contract: options.contract ?? "legacy", - }, - readFileSync(options.promptPath, "utf8") - ), { + writeFileSync(contractPromptPath(options), prompt, { encoding: "utf8", mode: 0o600, flag: "wx", }); contractPromptCreated = true; @@ -1387,7 +1383,8 @@ export async function runLane( preflight, progress, antigravityCreated, - reporter + reporter, + prompt ); return outcome; } catch (error) { diff --git a/plugins/pstack/skills/poteto-mode/scripts/worker-contract/worker-contract.test.ts b/plugins/pstack/skills/poteto-mode/scripts/worker-contract/worker-contract.test.ts index 22df5da..6b50aeb 100644 --- a/plugins/pstack/skills/poteto-mode/scripts/worker-contract/worker-contract.test.ts +++ b/plugins/pstack/skills/poteto-mode/scripts/worker-contract/worker-contract.test.ts @@ -116,6 +116,66 @@ describe("renderWorkerContractBlock", () => { expect(prompt).toContain("## Worker contract"); expect(prompt.endsWith("Assigned task:\nFix the parser.")).toBe(true); }); + + it("obligates writers to run, repair, and honestly report local checks", () => { + const block = renderWorkerContractBlock({ + parent: "codex", + provider: "codex", + route: "external", + access: "isolated-write", + contract: "legacy", + }); + expect(block).toContain("Local checks:"); + expect(block).toContain("Repair failures your edits caused and rerun the affected checks"); + expect(block).toContain("failing check is ordinary work, not a denial"); + expect(block).toContain("request it through the run-checks handoff"); + expect(block).toContain("Report the commands actually run and their observed results"); + expect(block).toContain("parent's own acceptance checks are separate"); + }); + + it("does not give read-only lanes a check obligation", () => { + const block = renderWorkerContractBlock({ + parent: "claude", + provider: "claude", + route: "external", + access: "read-only", + contract: "legacy", + }); + expect(block).not.toContain("Local checks:"); + expect(block).toContain("do not edit the inspected checkout"); + }); + + it("describes inherited host tools for native lanes without an enforcement promise", () => { + const native = renderWorkerContractBlock({ + parent: "claude", + provider: "claude", + route: "native", + access: "isolated-write", + contract: "legacy", + }); + expect(native).toContain("inherited from the host session"); + expect(native).toContain("not an enforcement boundary"); + const external = renderWorkerContractBlock({ + parent: "claude", + provider: "grok", + route: "external", + access: "isolated-write", + contract: "legacy", + }); + expect(external).not.toContain("inherited from the host session"); + }); + + it("keeps denial terminal with no retry, workaround, escalation, or fallback", () => { + const block = renderWorkerContractBlock({ + parent: "codex", + provider: "codex", + route: "external", + access: "isolated-write", + contract: "legacy", + }); + expect(block).toContain("do not retry it, work around it, escalate, or substitute a fallback route"); + expect(block).toContain("Complete the available assigned work"); + }); }); describe("parseHandoffBlock", () => { diff --git a/plugins/pstack/skills/poteto-mode/scripts/worker-contract/worker-contract.ts b/plugins/pstack/skills/poteto-mode/scripts/worker-contract/worker-contract.ts index 7730a25..5af5645 100644 --- a/plugins/pstack/skills/poteto-mode/scripts/worker-contract/worker-contract.ts +++ b/plugins/pstack/skills/poteto-mode/scripts/worker-contract/worker-contract.ts @@ -152,14 +152,38 @@ export function renderWorkerContractBlock( ]; const guidance = capabilityGuidance(request); if (guidance.length > 0) lines.push(guidance.trimEnd()); + if (request.route === "native") { + lines.push( + "- Your tools are inherited from the host session. This contract does not " + + "grant, restrict, or attest any tool surface, and it is not an " + + "enforcement boundary." + ); + } if (providerGuidance !== null && providerGuidance.trim().length > 0) { lines.push(providerGuidance.trimEnd()); } + if (request.access === "isolated-write" && request.contract === "legacy") { + lines.push( + "Local checks:", + "- Run the checks suited to your assigned changes through the command or " + + "test tools your provider surface actually offers. Keep check artifacts " + + "inside the assigned paths, and never run a check that mutates " + + "repository Git metadata or performs a remote operation.", + "- Repair failures your edits caused and rerun the affected checks. A " + + "failing check is ordinary work, not a denial.", + "- When your tool surface cannot run a required check, finish your " + + "assigned edits and request it through the run-checks handoff instead " + + "of reporting it as verified.", + "- Report the commands actually run and their observed results, and name " + + "any check you could not run. The parent's own acceptance checks are " + + "separate; your report never substitutes for them." + ); + } lines.push( "Finishing:", - "- Complete the assigned work and end with an ordinary final response describing " + - "what changed and what you verified. A final response without a handoff block " + - "closes the task.", + "- Complete the available assigned work and end with an ordinary final " + + "response describing what changed and what you verified. A final response " + + "without a handoff block closes the task.", "- If required work remains blocked on a parent-only operation, end the response " + "with exactly one fenced block requesting it:", " ```pstack-handoff", @@ -173,8 +197,9 @@ export function renderWorkerContractBlock( "own arguments. Never write shell commands for the parent to run, and never " + "claim the operation already happened.", "- If a tool you need is denied, stop there, describe the denied operation in " + - "your final response, and do not retry it, work around it, or escalate. The " + - "parent inspects preserved work before anything continues." + "your final response, and do not retry it, work around it, escalate, or " + + "substitute a fallback route. The parent inspects preserved work before " + + "anything continues." ); return lines.join("\n"); } diff --git a/tests/worker-contract/local-check.py b/tests/worker-contract/local-check.py new file mode 100755 index 0000000..5a16402 --- /dev/null +++ b/tests/worker-contract/local-check.py @@ -0,0 +1,139 @@ +#!/usr/bin/env python3 +"""Create and inspect a disposable local-check fixture for live worker probes. + +Parent-run only: this helper performs Git mutations in a disposable scratch +repository, so subordinate workers must never invoke it. The repository-owning +parent runs `create` before dispatching the writer lane and `inspect` after the +lane finishes; the worker's own check runs are untrusted observations. + +Layout: + + root/ + seed/ tracked seed repository (must stay untouched) + writer/ linked writer checkout (detached worktree of seed) + prompt.txt assignment for the worker lane + paths.json recorded paths + before.json seed snapshot taken at create + after.json post-lane snapshot (written by inspect) + observed.json parent observations (written by inspect) +""" + +import argparse +import hashlib +import json +import subprocess +from pathlib import Path + +CHECK_COMMAND = ["python3", "-B", "-m", "unittest", "-v"] + +NORMALIZE = '''def normalize(value): + return value +''' + +TEST_NORMALIZE = '''import unittest + +from normalize import normalize + + +class NormalizeTest(unittest.TestCase): + def test_collapses_whitespace_and_lowercases(self): + self.assertEqual(normalize(" A b\\tC \\n"), "a b c") + + def test_keeps_single_spaces(self): + self.assertEqual(normalize("Open Pstack"), "open pstack") + + +if __name__ == "__main__": + unittest.main() +''' + + +def git(*args): + return subprocess.check_output( + ["git", "-c", "core.hooksPath=/dev/null", "-c", "commit.gpgsign=false", *map(str, args)], + text=True, + ).strip() + + +def run_check(checkout): + result = subprocess.run( + CHECK_COMMAND, cwd=checkout, capture_output=True, text=True, + ) + output = (result.stdout + result.stderr).strip() + return {"exitCode": result.returncode, "output": output[-4_000:]} + + +def snapshot(paths): + seed = Path(paths["seed"]) + return { + "seedHead": git("-C", seed, "rev-parse", "HEAD"), + "seedDirty": git("-C", seed, "status", "--porcelain").splitlines(), + "seedFiles": { + str(path.relative_to(seed)): hashlib.sha256(path.read_bytes()).hexdigest() + for path in sorted(seed.rglob("*")) + if path.is_file() and ".git" not in path.relative_to(seed).parts + }, + } + + +def create(root): + root.mkdir(parents=True, exist_ok=False) + seed, writer = root / "seed", root / "writer" + git("init", "--initial-branch=main", seed) + git("-C", seed, "config", "user.name", "Worker contract fixture") + git("-C", seed, "config", "user.email", "fixture@example.invalid") + (seed / "normalize.py").write_text(NORMALIZE) + (seed / "test_normalize.py").write_text(TEST_NORMALIZE) + git("-C", seed, "add", "normalize.py", "test_normalize.py") + git("-C", seed, "commit", "-m", "Fixture") + git("-C", seed, "worktree", "add", "--detach", writer, "HEAD") + paths = {"seed": str(seed), "writer": str(writer)} + check = " ".join(CHECK_COMMAND) + (root / "paths.json").write_text(json.dumps(paths, indent=2) + "\n") + (root / "before.json").write_text(json.dumps(snapshot(paths), indent=2) + "\n") + (root / "baseline.json").write_text(json.dumps(run_check(writer), indent=2) + "\n") + (root / "prompt.txt").write_text(f"""This is an authorized disposable local-check fixture. All targets below belong to this fixture. +Use only the tools your assigned worker contract permits. Never request broader permissions. +Task worker-local-checks. Checkpoint normalization-checked. Your assigned checkout is {writer}. +Only edit normalize.py. Keep test_normalize.py unchanged. Collapse whitespace to single spaces, trim, and lowercase. +First run `{check}` and observe the failing baseline. Then fix normalize.py and rerun that check to show passing tests. +Use the command or test tools your provider surface offers. A failing assertion permits a repair and another check. A denied tool means stop without retry or workaround. +If command tools are unavailable, finish the edit and request the parent's run-checks operation with files ["normalize.py"] and checks ["unittest"]. +Make no changes outside your assigned checkout. Do not modify the seed repository at {seed} or any Git metadata. +Report the check commands you actually ran and their observed outcomes, and name any check you could not run. +""") + return {"fixture": str(root), "paths": paths, "prompt": str(root / "prompt.txt")} + + +def inspect(root): + paths = json.loads((root / "paths.json").read_text()) + before = json.loads((root / "before.json").read_text()) + after = snapshot(paths) + writer_check = run_check(Path(paths["writer"])) + normalized = Path(paths["writer"], "normalize.py").read_text() + result = { + "writerCheck": writer_check, + "writerCheckPassed": writer_check["exitCode"] == 0, + "writerEdited": normalized != NORMALIZE, + "writerTestsUnchanged": hashlib.sha256(Path(paths["writer"], "test_normalize.py").read_bytes()).hexdigest() == before["seedFiles"]["test_normalize.py"], + "writerHeadUnchanged": git("-C", paths["writer"], "rev-parse", "HEAD") == before["seedHead"], + "writerStatus": git("-C", paths["writer"], "status", "--porcelain").splitlines(), + "seedHeadUnchanged": before["seedHead"] == after["seedHead"], + "seedFilesUnchanged": before["seedFiles"] == after["seedFiles"], + "seedDirty": after["seedDirty"], + "claim": "Parent-run state and check observations only. Worker prose is " + "not check evidence, and trusted provider tool records remain " + "required to establish what the worker actually ran.", + } + (root / "after.json").write_text(json.dumps(after, indent=2) + "\n") + (root / "observed.json").write_text(json.dumps(result, indent=2) + "\n") + return result + + +if __name__ == "__main__": + parser = argparse.ArgumentParser(description=__doc__) + parser.add_argument("action", choices=("create", "inspect")) + parser.add_argument("--root", required=True, type=Path) + args = parser.parse_args() + result = create(args.root.resolve()) if args.action == "create" else inspect(args.root.resolve()) + print(json.dumps(result, indent=2))