diff --git a/.claude/skills/verify-open-pstack/SKILL.md b/.claude/skills/verify-open-pstack/SKILL.md index be3340dc..b40ae4fe 100644 --- a/.claude/skills/verify-open-pstack/SKILL.md +++ b/.claude/skills/verify-open-pstack/SKILL.md @@ -1,6 +1,6 @@ --- name: verify-open-pstack -description: Verify an Open Pstack PR's exact candidate head in native Claude Code, Codex and T3 Code sessions, judge them with machine-checked assertions, and publish the exact-SHA live gate. +description: Verify an Open Pstack PR's exact candidate head in native Claude Code, Codex, T3 Code and Grok sessions, judge them with machine-checked assertions, and publish the exact-SHA live gate. --- # Verify Open Pstack @@ -53,11 +53,13 @@ Claude runs with normal operator config/login and session-only `claude --plugin- `t3:` features run through the T3 transport in `features/t3.md`: a run-owned disposable T3 server whose Claude provider gets the same session-only plugin flags, one `t3 thread start --wait --json` per case, and the parent's Claude transcript parsed like the Claude stream. Classification never adds `t3` on its own and self-test does not reach it; the doctor checks the T3 service binary only when a `t3:` feature is named. +`grok:` features run through the Grok transport in `features/grok.md`: one `grok agent --no-leader --plugin-dir /plugins/pstack --model grok-4.7 --reasoning-effort low stdio` process per case, driven over ACP, with permission requests answered from the recipe tools and the rest refused. Before the prompt, `_x.ai/plugins/list` must show the candidate as the only loaded `pstack`, by path and tree hash. Classification never adds `grok` on its own and self-test does not reach it; the doctor checks the Grok binary, its agent flags and the `grok models` login line only when a `grok:` feature is named. + Candidate commands and provider descendants use the recorded native-login arrangement, never GitHub publisher credentials. No extra login, implicit timeout, or weaker-model fallback is permitted. If Claude's plugin flags fail to exclude installed pstack or Codex replaces the auth symlink instead of writing through it, stop and reopen the design. For every named feature, exercise its native surface and retain real native tool calls and concrete fixture effects. Asset consumers come from the pinned plugin manifests; the current `plugins/pstack/assets/logo.png` is consumed by the Codex manifest only and therefore requires the Codex installed surface, not an invented Claude asset exercise. The session recorder streams each session's stdout and stderr to private files under `sessions///` in the run root and keeps them on any exit. For Codex it also copies the session's rollout from the run-owned home, because only the rollout records which `SKILL.md` each skill mention resolved to. A nonzero session exit fails the case. The trusted parent then parses the native stream and evaluates the case's assertions on the native skill load, the commands that ran, and files the session wrote in its fixture, and copies those files next to the stream. A model's claim that something passed never counts, and neither do direct CLI tests. -For setup only, snapshot real `${CLAUDE_CONFIG_DIR:-$HOME/.claude}/pstack-models.md` and `CLAUDE.md`, including original absence, before exercise. Restore exact bytes or absence afterwards on success or failure and verify restoration before any publication. Restoration failure stops the run and publishes nothing. +For setup only, snapshot real `${CLAUDE_CONFIG_DIR:-$HOME/.claude}/pstack-models.md` and `CLAUDE.md`, and `${GROK_HOME:-$HOME/.grok}/rules/pstack-models.md`, including original absence, before exercise. Restore exact bytes or absence afterwards on success or failure and verify restoration before any publication. Restoration failure stops the run and publishes nothing. For `project-skill`, invoke `/verify-open-pstack` in Claude and `$verify-open-pstack` in Codex from the candidate checkout. Ask the discovered pinned skill to run `verify.sh doctor --candidate --output `. Assert the skill load, the doctor command, and the canonical-path doctor output; forbid recursive verification or publication. This does not replace required plugin exercises. @@ -92,6 +94,8 @@ Read receipts as data, never shell input. Do not put credentials in prompts or p - scripts/core.ts - scripts/doctor.ts - scripts/github.ts +- scripts/grok.test.ts +- scripts/grok.ts - scripts/harness.test.ts - scripts/harness.ts - scripts/io.test.ts diff --git a/.claude/skills/verify-open-pstack/features/README.md b/.claude/skills/verify-open-pstack/features/README.md index 58b19ec5..5a99210d 100644 --- a/.claude/skills/verify-open-pstack/features/README.md +++ b/.claude/skills/verify-open-pstack/features/README.md @@ -18,7 +18,7 @@ The ownership table below says which features a changed path reaches. `SKILL.md` A change to `features/recipes.ts` can name any feature that has a recipe. -A change to `plugins/pstack/.claude-plugin/plugin.json`, `.claude-plugin/marketplace.json`, or `plugins/pstack/.codex-plugin/plugin.json` is version-only when the merge base and the head hold the same regular file with the same mode, both sides parse as JSON objects, at least one version string changed (top-level `version`, or `plugins[].version` in the marketplace), and nothing else differs. A version-only change reaches only `hook:startup`: the two Claude files in Claude, the Codex manifest in Codex. `.agents/plugins/marketplace.json` has no version and always takes the shared expansion. +A change to `plugins/pstack/.claude-plugin/plugin.json`, `.claude-plugin/marketplace.json`, or `plugins/pstack/.codex-plugin/plugin.json` is version-only when the merge base and the head hold the same regular file with the same mode, both sides parse as JSON objects, at least one version string changed (top-level `version`, or `plugins[].version` in the marketplace), and nothing else differs. A version-only change reaches only `hook:startup`: the two Claude files in Claude, the Codex manifest in Codex. `.agents/plugins/marketplace.json` has no version and always takes the shared expansion. `plugins/pstack/.grok-plugin/plugin.json` always takes the shared expansion too, because pstack's startup hook does not fire in Grok. Asset ownership is consumer-driven, not blanket shared ownership. Inspect the exact candidate's plugin manifests. At present, `plugins/pstack/assets/logo.png` is referenced only by `plugins/pstack/.codex-plugin/plugin.json`, so a logo-only change selects the installed Codex asset surface and does not invent a Claude exercise. New shipped assets must be added to `assets` and must have a real manifest consumer; missing, ambiguous, or unknown consumption fails closed. @@ -36,6 +36,7 @@ The feature documents below define sub-features, user entry points, driving inst - `shipped-tools.md`: watch, plan checks, audit, statechart, and evidence logging. - `assets.md`: manifest-declared installed assets on actual consuming harnesses. - `t3.md`: a T3 Code parent dispatching a role through `delegate_task` (`t3:dispatch`), on a run-owned disposable T3 server. +- `grok.md`: a Grok parent's native and runner lanes and agent visibility (`grok:dispatch`), and setup writing the Grok sheet (`grok:setup`), over ACP. - `hooks.md`: the SessionStart hook's context reaching a new session (`hook:startup`), and the Windows runner beside it. - `recipes.ts`: the headless cases and machine-checked assertions for every selectable feature. A named feature without one fails the run as `missing-recipe` before any session starts. A recipe proves a lookup or a read with a fixture value the model cannot derive from the prompt, such as a per-run `crypto.randomUUID()` marker, compared in the structured result. A command's text can show which tool the parent called, but it never proves what that tool read or produced, so do not add command-text or tool-output pattern checks for that. - `agent-preload:poteto-agent` (Claude only): the parent dispatches `pstack:poteto-agent` through the Agent tool and asks it for the first Core principle of the preloaded poteto-mode skill without invoking `Skill` or reading files. The check needs that Agent call, `Laziness Protocol` in the reply the parent writes to `result.md`, and no `Skill` call or `Read` of poteto-mode's `SKILL.md` among the child's own tool calls. diff --git a/.claude/skills/verify-open-pstack/features/grok.md b/.claude/skills/verify-open-pstack/features/grok.md new file mode 100644 index 00000000..e7f932f0 --- /dev/null +++ b/.claude/skills/verify-open-pstack/features/grok.md @@ -0,0 +1,21 @@ +# Grok parent + +## Sub-features + +`grok:dispatch`: a Grok parent running the installed `poteto-mode` routes an `inherit-parent` lane natively through `spawn_subagent` with `pstack:poteto-agent`, an explicit `grok:` lane through `pstack-runner --parent grok --provider grok`, and a Codex lane through `pstack-runner --parent grok`, and Grok offers only the model-free pstack agents. `grok:setup`: `/pstack:setup-pstack` on a Grok parent writes `${GROK_HOME:-$HOME/.grok}/rules/pstack-models.md`. Select them for a change to the Grok manifest, `provider-dispatch.md`, `codex-tools.md`, `setup-pstack/SKILL.md`, `recipes.ts` or the Grok transport. A path that reaches them never adds them to every run; name them with `--feature grok:dispatch` or `--feature grok:setup`. + +## How to get to it (user POV) + +Start a Grok session with the candidate plugin loaded and invoke `/pstack:poteto-mode` or `/pstack:setup-pstack` by name. pstack's startup hook does not fire in Grok. + +## Driving it with verify-open-pstack + +Each case starts one `grok agent --no-leader --plugin-dir /plugins/pstack --model grok-4.7 --reasoning-effort low stdio` process with the operator's real HOME and Grok home, and drives it over ACP: `initialize`, `session/new` with the fixture cwd, `_x.ai/plugins/list`, and one `session/prompt`. The transport answers `session/request_permission` with allow-once for the tools in `GROK_TOOLS` and reject-once for every other tool, so no prompt waits and no always-approve mode is set. It closes stdin after the turn, and the process exits. Every message in both directions goes to `sessions/grok//.jsonl`. No operator Grok config is written. + +## Gotchas + +Grok also discovers the operator's installed pstack through its Claude-compat scan of `~/.claude/plugins`, and the `--plugin-dir` copy shadows it. Before the prompt, the transport requires `_x.ai/plugins/list` to report exactly one enabled `pstack`, whose root is the candidate path and whose tree hash equals the pinned candidate's, and otherwise stops. The response is retained as `.plugins.json`. Grok has no Skill tool. A slash command expands in Grok's own prompt, which records the skill in a `` block of the parent's `chat_history.jsonl`; a skill counts as loaded when that block names the candidate's `SKILL.md`, or when a parent `read_file` of it completed. A subagent's updates arrive under its own session id, and its `SubagentCompleted` output names that id. A turn that ends with a tool call still running, or with a child session that has no `turn_completed`, fails the case. The transport copies the session's `summary.json`, which must show `grok-4.7` at `low`, `tool_definitions.json`, which holds the `spawn_subagent` `subagent_type` enum, and `chat_history.jsonl`, from `${GROK_HOME:-$HOME/.grok}/sessions///` into the run root. On macOS the Grok runner lane needs the `/var/run/docker.sock` prerequisite in the model catalog. + +## Headless + +The `dispatch` case writes a fixture sheet with three read-only roles: `why investigators, synthesizer` on `inherit-parent`, `how explorer` on `grok:grok-4.7@low` and `how explainer` on `codex:gpt-6-luna@low`, and a `marker-N.txt` per lane holding a per-run `crypto.randomUUID()` the prompt never contains. It asserts the candidate `poteto-mode` load; an agent enum holding `pstack:poteto-agent` and `pstack:comment-sicko` and no `pstack:pstack-*` agent; exactly one `spawn_subagent` call, with `pstack:poteto-agent`, whose `SubagentCompleted` output holds lane 1's marker and whose child ran tools; no runner receipt for lane 1; and for lanes 2 and 3 a `pstack-runner --parent grok` command for that provider and a receipt that is `complete`, on that route, `read-only`, proved by the provider's Model proof (`provider-report` for Grok, `pinned-argv` for Codex), from this session, with the lane's marker in its output. The `configure` setup case answers setup with every model-family role on Grok 4.7 at low effort and every writer sandbox opt-in declined, and asserts that the Grok sheet was written this session, holds only `grok:grok-4.7@low`, and has no `full-access` setting line. The parent snapshots the Grok sheet with the other setup files, and restores and verifies it on success or failure before any publication. diff --git a/.claude/skills/verify-open-pstack/features/recipes.ts b/.claude/skills/verify-open-pstack/features/recipes.ts index 78a0a4ec..b6fb03f9 100644 --- a/.claude/skills/verify-open-pstack/features/recipes.ts +++ b/.claude/skills/verify-open-pstack/features/recipes.ts @@ -54,7 +54,7 @@ export async function readCatalog(location: string): Promise { const kept = new Map(rows(section('Kept predecessors and normalization').split('\nRolling aliases:')[0]!).slice(1) .map(([provider, model, , current]): [string, string] => [`${provider}:${model}`, `${provider}:${current}`])); const [header = [], ...routes] = rows(section('Routes')); - const column = { claude: header.indexOf('Claude Code parent'), codex: header.indexOf('Codex parent'), t3: -1 }; + const column = { claude: header.indexOf('Claude Code parent'), codex: header.indexOf('Codex parent'), t3: -1, grok: -1 }; if (!defaults.size || column.claude < 0 || column.codex < 0) throw new Error('catalog-unreadable'); return { defaults, kept, route(harness, descriptor) { if (!descriptor.includes(':')) return 'native'; @@ -801,11 +801,91 @@ const t3Dispatch: Case = { }, }; +const GROK_LANES = [ + { role: 'why investigators, synthesizer', descriptor: 'inherit-parent' }, + { role: 'how explorer', descriptor: 'grok:grok-4.7@low', provider: 'grok', model: 'grok-4.7', effort: 'low', proof: 'provider-report' }, + { role: 'how explainer', descriptor: 'codex:gpt-6-luna@low', provider: 'codex', model: 'gpt-6-luna', effort: 'low', proof: 'pinned-argv' }, +] as const; +const GROK_AGENTS = ['pstack:poteto-agent', 'pstack:comment-sicko']; + +export function grokAgents(agents: string[] | undefined): string { + if (!agents || GROK_AGENTS.some(agent => !agents.includes(agent))) throw new Error('grok-model-free-agents-missing'); + const lane = agents.find(agent => agent.startsWith('pstack:pstack-')); + if (lane) throw new Error(`grok-claude-lane-agent-offered:${lane}`); + return 'agents:model-free-only'; +} + +export function grokReceipt(receipt: Record, lane: { provider: string; model: string; effort: string; proof: 'provider-report' | 'pinned-argv' }, output: string, started: number): void { + if (receipt.status !== 'complete') throw new Error(`receipt-status:${receipt.status}`); + if (receipt.parent !== 'grok' || receipt.provider !== lane.provider || receipt.model !== lane.model || receipt.effort !== lane.effort || receipt.mode !== 'read-only') throw new Error(`receipt-route-mismatch:${lane.provider}`); + const proved = lane.proof === 'provider-report' + ? receipt.modelVerified === true && receipt.modelEvidence === 'provider-report' + : receipt.modelVerified === false && receipt.modelEvidence === 'pinned-argv' && receipt.reportedModel === null; + if (!proved) throw new Error(`receipt-model-unverified:${lane.provider}`); + if (receipt.outputPath !== output || !(Date.parse(receipt.startedAt) >= started)) throw new Error(`receipt-not-this-session:${lane.provider}`); +} + +async function grokSkill(record: SessionRecord, f: Fixture, skill: string): Promise { + if (!record.skills.includes(await realpath(join(f.location, 'skills', skill)))) throw new Error(`skill-not-loaded-from-candidate:pstack:${skill}`); + return 'skill-loaded-from-candidate'; +} + +const grokDispatch: Case = { + id: 'dispatch', + async prepare(f) { + await files(f.dir, { 'pstack-models.md': sheetText(GROK_LANES.map(lane => [lane.role, [lane.descriptor]])), + ...Object.fromEntries(GROK_LANES.map((_, index) => [`marker-${index + 1}.txt`, `${crypto.randomUUID()}\n`])) }); + }, + prompt: f => `${invoke(f.harness, 'poteto-mode', `Do no engineering work. For this task the pstack model sheet is ${join(f.dir, 'pstack-models.md')}; use it in place of any configured sheet. Dispatch exactly one read-only lane for each of these roles, in this order, and nothing else: ${GROK_LANES.map(lane => JSON.stringify(lane.role)).join(', ')}. Lane N's whole task: "Read ${join(f.dir, 'marker-N.txt')} and reply with its exact contents. Do not edit files." A lane that needs an output or receipt path uses ${join(f.dir, 'lane-N.output.md')} and ${join(f.dir, 'lane-N.receipt.json')}. Wait for every lane to finish, then reply with each lane's result.`)} ${rules(f.dir)}`, + async assert(record, f) { + const assertions = [await grokSkill(record, f, 'poteto-mode'), grokAgents(record.agents)]; + const markers = await Promise.all(GROK_LANES.map(async (_, index) => (await readFile(join(f.dir, `marker-${index + 1}.txt`), 'utf8')).trim())); + const spawned = record.tools.filter(t => t.name === 'spawn_subagent' && !t.failed); + if (spawned.length !== 1 || spawned[0]!.input.subagent_type !== 'pstack:poteto-agent') throw new Error(`native-lanes:${spawned.map(t => t.input.subagent_type).join(',')}`); + const native = spawned[0]!, completed = parseJson(native.result) as Record | undefined; + if (completed?.type !== 'SubagentCompleted' || completed.subagent_type !== 'pstack:poteto-agent' || !String(completed.output).includes(markers[0]!)) throw new Error('native-lane-result-mismatch'); + if (!record.tools.some(t => t.parent === native.id)) throw new Error('native-child-did-not-run'); + if (await lstat(join(f.dir, 'lane-1.receipt.json')).catch(() => undefined)) throw new Error('native-lane-used-runner'); + assertions.push('native:pstack:poteto-agent'); + const paths = [join(f.dir, 'pstack-models.md')]; + for (const [index, lane] of GROK_LANES.entries()) { + if (!('provider' in lane)) continue; + const at = (name: string) => join(f.dir, `lane-${index + 1}.${name}`); + if (!record.commands.some(c => c.includes('pstack-runner') && c.includes('--parent grok') && c.includes(`--provider ${lane.provider}`))) throw new Error(`runner-not-run:${lane.provider}`); + grokReceipt(JSON.parse(await readFile(at('receipt.json'), 'utf8')), lane, at('output.md'), record.started); + if (!(await readFile(at('output.md'), 'utf8')).includes(markers[index]!)) throw new Error(`marker-missing:${lane.provider}`); + assertions.push(`runner:${lane.descriptor}`); + paths.push(at('receipt.json'), at('output.md')); + } + const exchange = join(f.dir, 'native-lane.json'); + await writeFile(exchange, JSON.stringify({ request: native.input, response: completed }, null, 2)); + return { assertions: [...assertions, 'markers-returned'], files: [exchange, ...paths] }; + }, +}; + +const GROK_SETUP_DESCRIPTOR = 'grok:grok-4.7@low'; +const grokSetup: Case = { + id: 'configure', + prompt: f => `${invoke(f.harness, 'setup-pstack', `Use these answers and ask nothing else. Role assignments: change every role assigned to a model-matrix family to the Grok family, and keep inherit-parent and auto roles unchanged. Grok requested effort: low. Decline every writer sandbox opt-in. I confirm every write. Write the setup report to ${join(f.dir, 'result.md')}.`)} Do not push, post, or contact GitHub.`, + async assert(record, f) { + const sheet = join(f.configHome, 'rules/pstack-models.md'), assertions = [await grokSkill(record, f, 'setup-pstack'), await written(record, sheet)]; + const text = await readFile(sheet, 'utf8'), descriptors = text.match(DESCRIPTOR) ?? []; + if (!descriptors.length || descriptors.some(d => d !== GROK_SETUP_DESCRIPTOR)) throw new Error('sheet-descriptors-mismatch'); + if (settingLines(text).some(line => line.endsWith(': full-access'))) throw new Error('setting-full-access'); + return { assertions: [...assertions, `sheet:${GROK_SETUP_DESCRIPTOR}`, 'no-full-access'], files: [sheet] }; + }, +}; + export function recipe(feature: string, harness: Harness, routes: string[] = []): Case[] { if (harness === 't3') { if (feature === 'dispatch') return [t3Dispatch]; throw new Error(`missing-recipe:${harness}/${feature}`); } + if (harness === 'grok') { + if (feature === 'dispatch') return [grokDispatch]; + if (feature === 'setup') return [grokSetup]; + throw new Error(`missing-recipe:${harness}/${feature}`); + } if (feature === 'assets:codex') throw new Error('unsupported-native-consumer:assets:codex'); if (feature === 'project-skill') return [projectSkill]; if (feature === 'hook:startup') return [startupHook]; diff --git a/.claude/skills/verify-open-pstack/features/registry.json b/.claude/skills/verify-open-pstack/features/registry.json index 3c9bb205..4e13683e 100644 --- a/.claude/skills/verify-open-pstack/features/registry.json +++ b/.claude/skills/verify-open-pstack/features/registry.json @@ -20,9 +20,10 @@ "unslop", "what-did-i-get-done", "why" ], "shared": [ - "plugins/pstack/.claude-plugin/plugin.json", "plugins/pstack/.codex-plugin/plugin.json", + "plugins/pstack/.claude-plugin/plugin.json", "plugins/pstack/.codex-plugin/plugin.json", "plugins/pstack/.grok-plugin/plugin.json", ".claude-plugin/marketplace.json", ".agents/plugins/marketplace.json", - "plugins/pstack/agents/poteto-agent.md", "plugins/pstack/agents/comment-sicko.md", "plugins/pstack/hooks/**", + "plugins/pstack/agents/poteto-agent.md", "plugins/pstack/agents/comment-sicko.md", + "plugins/pstack/grok-agents/poteto-agent.md", "plugins/pstack/grok-agents/comment-sicko.md", "plugins/pstack/hooks/**", "plugins/pstack/skills/poteto-mode/references/**" ], "catalog": [ diff --git a/.claude/skills/verify-open-pstack/scripts/cli.ts b/.claude/skills/verify-open-pstack/scripts/cli.ts index 3d875b48..c23b349a 100644 --- a/.claude/skills/verify-open-pstack/scripts/cli.ts +++ b/.claude/skills/verify-open-pstack/scripts/cli.ts @@ -26,7 +26,7 @@ export function parse(args: string[]): Options { if (!ROUTE.test(value) || routes.includes(value)) throw new Error('Runner route must be a distinct PROVIDER:MODEL@EFFORT'); routes.push(value); } else if (arg === '--feature') { - if (!/^(claude|codex|t3):[a-z][a-z0-9:-]+$/.test(value) || features.includes(value)) throw new Error('Feature must be a distinct HARNESS:FEATURE, such as claude:agent-preload:poteto-agent'); + if (!/^(claude|codex|t3|grok):[a-z][a-z0-9:-]+$/.test(value) || features.includes(value)) throw new Error('Feature must be a distinct HARNESS:FEATURE, such as claude:agent-preload:poteto-agent'); features.push(value); } else if (arg === '--wide') { if (!value.trim() || !publishable(value)) throw new Error('Wide reason must be nonblank printable ASCII, at most 160 characters, without backticks or angle brackets'); diff --git a/.claude/skills/verify-open-pstack/scripts/core.test.ts b/.claude/skills/verify-open-pstack/scripts/core.test.ts index f4f6941c..1112c580 100644 --- a/.claude/skills/verify-open-pstack/scripts/core.test.ts +++ b/.claude/skills/verify-open-pstack/scripts/core.test.ts @@ -76,6 +76,30 @@ describe('registry and ownership', () => { expect(requiredHarnesses(r)).toEqual(['claude', 'codex', 't3']); expect(requiredFeatures(r, 't3')).toEqual(['dispatch']); }); + test('only the Grok surfaces reach grok:dispatch and grok:setup, and self-test never adds a Grok session', () => { + const reach = (filename: string) => classify([{ filename }], registry).features.filter(f => f.startsWith('grok:')); + for (const filename of ['plugins/pstack/skills/poteto-mode/references/provider-dispatch.md', 'plugins/pstack/skills/poteto-mode/references/codex-tools.md', + 'plugins/pstack/.grok-plugin/plugin.json', '.claude/skills/verify-open-pstack/scripts/doctor.ts']) expect(reach(filename)).toEqual(['grok:dispatch']); + expect(reach('.claude/skills/verify-open-pstack/scripts/isolation.ts')).toEqual(['grok:setup']); + for (const filename of ['plugins/pstack/skills/setup-pstack/SKILL.md', '.claude/skills/verify-open-pstack/scripts/grok.ts', + '.claude/skills/verify-open-pstack/scripts/harness.ts']) expect(reach(filename)).toEqual(['grok:dispatch', 'grok:setup']); + for (const filename of ['plugins/pstack/skills/poteto-mode/references/model-catalog.md', 'plugins/pstack/skills/poteto-mode/SKILL.md', + 'plugins/pstack/hooks/hooks.json', '.claude/skills/verify-open-pstack/scripts/verify.ts']) expect(reach(filename)).toEqual([]); + const r = receipt(true); + r.runFeatures = ['grok:dispatch', 'grok:setup']; + expect(requiredHarnesses(r)).toEqual(['claude', 'codex', 'grok']); + expect(requiredFeatures(r, 'grok')).toEqual(['dispatch', 'setup']); + for (const feature of requiredFeatures(r, 'grok')) expect(recipe(feature, 'grok')).toHaveLength(1); + expect(() => recipe('project-skill', 'grok')).toThrow('missing-recipe:grok/project-skill'); + }); + test('the Grok manifest is shared runtime with no version-only startup reach, because the hook does not fire in Grok', () => { + for (const versionOnly of [false, true]) { + const s = classify([{ filename: 'plugins/pstack/.grok-plugin/plugin.json', versionOnly }], registry); + for (const feature of both('setup', 'runner', 'shipped-tools')) expect(s.features).toContain(feature); + expect(s.features).toContain('grok:dispatch'); + expect(s.skills).toEqual([...registry.skills].sort()); + } + }); test('poteto-mode SKILL.md also reaches the Claude agent that preloads it', () => { const s = classify([{ filename: 'plugins/pstack/skills/poteto-mode/SKILL.md' }], registry); expect(s.features).toEqual([...both('skill-invocation:poteto-mode'), 'claude:agent-preload:poteto-agent'].sort()); @@ -208,7 +232,7 @@ describe('registry and ownership', () => { const path = '.claude/skills/verify-open-pstack/features/recipes.ts'; const expected = [...both('catalog', 'setup', 'runner', 'shipped-tools', 'hook:startup', 'project-skill', 'writers', ...WRITER_PROVIDERS.map(provider => `writers:${provider}`), - ...registry.skills.map(skill => `skill-invocation:${skill}`)), 'claude:agent-preload:poteto-agent', 't3:dispatch'].sort(); + ...registry.skills.map(skill => `skill-invocation:${skill}`)), 'claude:agent-preload:poteto-agent', 'grok:dispatch', 'grok:setup', 't3:dispatch'].sort(); for (const file of [{ filename: path }, { filename: 'docs/moved.md', previous_filename: path }]) { const s = classify([file], registry); expect(s.features).toEqual(expected); diff --git a/.claude/skills/verify-open-pstack/scripts/core.ts b/.claude/skills/verify-open-pstack/scripts/core.ts index f5f596e8..f5dc3c9b 100644 --- a/.claude/skills/verify-open-pstack/scripts/core.ts +++ b/.claude/skills/verify-open-pstack/scripts/core.ts @@ -22,6 +22,12 @@ const T3_DISPATCH_PATHS = [ 'plugins/pstack/skills/setup-pstack/SKILL.md', '.claude/skills/verify-open-pstack/features/recipes.ts', '.claude/skills/verify-open-pstack/scripts/harness.ts', '.claude/skills/verify-open-pstack/scripts/doctor.ts', ]; +const GROK_TRANSPORT = '.claude/skills/verify-open-pstack/scripts/grok.ts'; +const GROK_REACH: Readonly> = { + 'grok:dispatch': [...T3_DISPATCH_PATHS, GROK_TRANSPORT, 'plugins/pstack/.grok-plugin/plugin.json'], + 'grok:setup': ['plugins/pstack/skills/setup-pstack/SKILL.md', '.claude/skills/verify-open-pstack/features/recipes.ts', + '.claude/skills/verify-open-pstack/scripts/harness.ts', '.claude/skills/verify-open-pstack/scripts/isolation.ts', GROK_TRANSPORT], +}; export const VERSION_MANIFESTS: Readonly> = { 'plugins/pstack/.claude-plugin/plugin.json': 'claude', '.claude-plugin/marketplace.json': 'claude', 'plugins/pstack/.codex-plugin/plugin.json': 'codex', @@ -41,6 +47,7 @@ export function classify(files: ChangedFile[], registry: Registry): Selection { features.add('codex:assets:codex'); covered = true; } if (T3_DISPATCH_PATHS.includes(path)) features.add('t3:dispatch'); + for (const [feature, reach] of Object.entries(GROK_REACH)) if (reach.includes(path)) features.add(feature); const sharedRecipe = path === '.claude/skills/verify-open-pstack/features/recipes.ts'; if (sharedRecipe) both('catalog', 'writers', ...WRITER_PROVIDERS.map(provider => `writers:${provider}`)); const catalog = registry.catalog.some(p => matches(path, p)); diff --git a/.claude/skills/verify-open-pstack/scripts/doctor.test.ts b/.claude/skills/verify-open-pstack/scripts/doctor.test.ts index 15100d85..508e098d 100644 --- a/.claude/skills/verify-open-pstack/scripts/doctor.test.ts +++ b/.claude/skills/verify-open-pstack/scripts/doctor.test.ts @@ -81,3 +81,22 @@ test('doctor refuses non-Mac live verification before commands', async () => { await expect(doctor(root, async args => { calls.push(args); return ''; }, 'linux')).rejects.toThrow('operator Mac'); expect(calls).toEqual([]); }); +test('parent doctor checks Grok only when a grok feature is named, from CLI output only', async () => { + const root = await fixture(), calls: string[][] = []; + const run: Command = async args => { + calls.push(args); + if (args.join(' ') === 'grok models') return 'You are logged in with grok.com.\n\nAvailable models:\n * grok-4.7 (default)\n'; + if (args.join(' ') === 'grok agent --help') return '--plugin-dir --no-leader --model --reasoning-effort'; + if (args.join(' ') === 'claude auth status') return '{"loggedIn": true}'; + return capability(args)!; + }; + await doctor(root, run, 'darwin', false, ['claude']); + expect(calls.some(args => args[0] === 'grok')).toBe(false); + await rm(join(root, 'doctor.json')); + await doctor(root, run, 'darwin', false, ['grok']); + const report = JSON.parse(await readFile(join(root, 'doctor.json'), 'utf8')); + expect(report.checks['grok login']).toBe('logged in'); + expect(calls.filter(args => args[0] === 'grok').map(args => args.join(' '))).toEqual(['grok --version', 'grok agent --help', 'grok models']); + await expect(doctor(await fixture(), async args => args.join(' ') === 'grok models' ? 'Not logged in' : run(args), 'darwin', false, ['grok'])).rejects.toBeInstanceOf(LoginRequired); + await expect(doctor(await fixture(), async args => args.join(' ') === 'grok agent --help' ? '--model' : run(args), 'darwin', false, ['grok'])).rejects.toThrow('Grok agent missing --plugin-dir'); +}); diff --git a/.claude/skills/verify-open-pstack/scripts/doctor.ts b/.claude/skills/verify-open-pstack/scripts/doctor.ts index 91fadcda..bbbadc40 100644 --- a/.claude/skills/verify-open-pstack/scripts/doctor.ts +++ b/.claude/skills/verify-open-pstack/scripts/doctor.ts @@ -5,7 +5,7 @@ import { linkCodexAuth, removeCodexHome } from './isolation.ts'; import { HARNESSES, type Harness } from './types.ts'; export class LoginRequired extends Error {} -const CLAUDE_FIX = 'run `claude auth login` from a shell on this Mac, not over SSH', CODEX_FIX = 'run `codex login` on this Mac'; +const CLAUDE_FIX = 'run `claude auth login` from a shell on this Mac, not over SSH', CODEX_FIX = 'run `codex login` on this Mac', GROK_FIX = 'run `grok login` on this Mac'; export async function t3Binary(): Promise { const runtime = join(process.env.HOME ?? '', '.t3/runtime'); @@ -24,6 +24,12 @@ export async function doctor(root: string, run: Command = command, platform = pr const env = isolatedEnv(home); for (const binary of candidate ? ['bun', 'git', 'claude', 'codex'] : ['bun', 'git', 'claude', 'codex', 'gh']) checks[binary] = (await run([binary, '--version'], { env })).trim(); if (harnesses.includes('t3')) checks.t3 = (await run([await t3Binary(), '--version'], { env })).trim(); + if (harnesses.includes('grok')) { + checks.grok = (await run(['grok', '--version'], { env })).trim(); + const help = await run(['grok', 'agent', '--help'], { env }); + for (const flag of ['--plugin-dir', '--no-leader', '--model', '--reasoning-effort']) if (!help.includes(flag)) throw new Error(`Grok agent missing ${flag}`); + checks['grok agent --help'] = help; + } const claude = await run(['claude', '--help'], { env }); for (const flag of ['--plugin-dir', '--settings']) if (!claude.includes(flag)) throw new Error(`Claude isolation missing ${flag}`); checks.claudeHelp = claude; @@ -39,6 +45,12 @@ export async function doctor(root: string, run: Command = command, platform = pr if (loggedIn !== true) throw new LoginRequired(`Claude login check failed: \`claude auth status\` did not report "loggedIn": true; ${CLAUDE_FIX}`); checks['claude login'] = 'logged in'; } + if (!candidate && harnesses.includes('grok')) { + let models = ''; + try { models = await run(['grok', 'models'], { env }); } catch {} + if (!/^You are logged in\b/m.test(models)) throw new LoginRequired(`Grok login check failed: \`grok models\` did not report a login; ${GROK_FIX}`); + checks['grok login'] = 'logged in'; + } if (!candidate && harnesses.includes('codex')) { let linked = false; try { await linkCodexAuth(home); linked = true; await run(['codex', 'login', 'status'], { env }); checks['codex login'] = 'logged in'; } diff --git a/.claude/skills/verify-open-pstack/scripts/grok.test.ts b/.claude/skills/verify-open-pstack/scripts/grok.test.ts new file mode 100644 index 00000000..af9bd869 --- /dev/null +++ b/.claude/skills/verify-open-pstack/scripts/grok.test.ts @@ -0,0 +1,119 @@ +import { afterEach, describe, expect, test } from 'bun:test'; +import { mkdir, mkdtemp, realpath, rm, writeFile } from 'node:fs/promises'; +import { tmpdir } from 'node:os'; +import { join } from 'node:path'; +import { grokAgents, grokReceipt } from '../features/recipes.ts'; +import { candidatePlugin, grokLaunch, parentServed, parseGrok, permissionAnswer, referencedSkills } from './grok.ts'; +import { treeHash } from './io.ts'; + +const MAIN = '01a1bc71-6890-7753-8fe5-126e7a38df5c', CHILD = '01a1bc71-7514-7380-a5ac-8381a8802b0c'; +const agent = (message: Record) => JSON.stringify({ from: 'agent', message }); +const client = (message: Record) => JSON.stringify({ from: 'client', message }); +const update = (sessionId: string, value: Record) => agent({ method: 'session/update', params: { sessionId, update: value } }); +const call = (sessionId: string, toolCallId: string, name: string, rawInput: Record) => + update(sessionId, { sessionUpdate: 'tool_call', toolCallId, title: name, rawInput, _meta: { 'x.ai/tool': { name } } }); +const done = (sessionId: string, toolCallId: string, rawOutput: unknown, status = 'completed') => update(sessionId, { sessionUpdate: 'tool_call_update', toolCallId, status, rawOutput }); +const turn = (sessionId: string) => agent({ method: '_x.ai/session_notification', params: { sessionId, update: { sessionUpdate: 'turn_completed' } } }); +const opened = [client({ jsonrpc: '2.0', id: 2, method: 'session/new', params: {} }), agent({ jsonrpc: '2.0', id: 2, result: { sessionId: MAIN } })]; +const DEFINITIONS = JSON.stringify([{ function: { name: 'spawn_subagent', parameters: { properties: { subagent_type: { enum: ['pstack:poteto-agent', 'pstack:comment-sicko'] } } } } }]); + +describe('ACP stream parsing', () => { + const stream = [...opened, + call(MAIN, 'skill', 'read_file', { target_file: '/candidate/plugins/pstack/skills/poteto-mode/SKILL.md' }), + done(MAIN, 'skill', { type: 'ReadFile', FileContent: { absolute_path: '/private/candidate/plugins/pstack/skills/poteto-mode/SKILL.md' } }), + call(MAIN, 'spawn', 'spawn_subagent', { subagent_type: 'pstack:poteto-agent', prompt: 'read' }), + call(CHILD, 'child-read', 'read_file', { target_file: '/fixture/marker-1.txt' }), + done(CHILD, 'child-read', { type: 'ReadFile' }), turn(CHILD), + done(MAIN, 'spawn', { type: 'SubagentCompleted', subagent_id: CHILD, subagent_type: 'pstack:poteto-agent', output: 'marker' }), + call(MAIN, 'runner', 'run_terminal_command', { command: 'pstack-runner --parent grok' }), done(MAIN, 'runner', { type: 'Bash' }), + call(MAIN, 'mcp', 'use_tool', { tool_name: 'gmail' }), + agent({ jsonrpc: '2.0', id: 0, method: 'session/request_permission', params: { toolCall: { toolCallId: 'mcp' } } }), + client({ jsonrpc: '2.0', id: 0, result: { outcome: { outcome: 'selected', optionId: 'reject-once' } } }), + ].join('\n'); + + test('maps native tool calls, child calls to their spawn call, skill loads and commands', () => { + const record = parseGrok(stream, 0, 1, DEFINITIONS, ''); + expect(record.skills).toEqual(['/private/candidate/plugins/pstack/skills/poteto-mode']); + expect(record.commands).toEqual(['pstack-runner --parent grok']); + expect(record.agents).toEqual(['pstack:poteto-agent', 'pstack:comment-sicko']); + expect(record.tools.find(t => t.id === 'child-read')?.parent).toBe('spawn'); + expect(record.tools.find(t => t.id === 'spawn')).toMatchObject({ parent: null, failed: false }); + expect(JSON.parse(record.tools.find(t => t.id === 'spawn')!.result!).output).toBe('marker'); + expect(record.tools.find(t => t.id === 'mcp')?.failed).toBe(true); + }); + + test('a slash command expansion in the parent chat history counts as a load from its recorded path', () => { + const text = '\n\n'; + const history = [JSON.stringify({ type: 'system', content: '' }), + JSON.stringify({ type: 'user', content: [{ type: 'text', text }] })].join('\n'); + expect(referencedSkills(history)).toEqual(['/private/candidate/plugins/pstack/skills/poteto-mode']); + expect(parseGrok([...opened].join('\n'), 0, 1, DEFINITIONS, history).skills).toEqual(['/private/candidate/plugins/pstack/skills/poteto-mode']); + }); + + test('a refused or failed skill read does not count as a load', () => { + const failed = [...opened, call(MAIN, 'skill', 'read_file', { target_file: '/x/SKILL.md' }), done(MAIN, 'skill', null, 'failed')].join('\n'); + expect(parseGrok(failed, 0, 1, DEFINITIONS, '').skills).toEqual([]); + }); + + test('a turn that ends while a tool call or a child session is still running fails', () => { + expect(() => parseGrok([...opened, call(MAIN, 'spawn', 'spawn_subagent', {})].join('\n'), 0, 1, DEFINITIONS, '')).toThrow('grok-tool-pending:spawn_subagent'); + const orphan = [...opened, call(CHILD, 'child-read', 'read_file', {}), done(CHILD, 'child-read', {})].join('\n'); + expect(() => parseGrok(orphan, 0, 1, DEFINITIONS, '')).toThrow('grok-children-pending'); + expect(() => parseGrok(stream, 3, 1, DEFINITIONS, '')).toThrow('exit:3'); + }); +}); + +describe('session policy', () => { + const options = [{ optionId: 'allow-once', kind: 'allow_once' }, { optionId: 'reject-once', kind: 'reject_once' }]; + test('allows only the listed tools and refuses the rest without a prompt', () => { + expect(permissionAnswer({ toolCall: { _meta: { 'x.ai/tool': { name: 'spawn_subagent' } } }, options }, ['spawn_subagent'])).toBe('allow-once'); + expect(permissionAnswer({ toolCall: { _meta: { 'x.ai/tool': { name: 'use_tool' } } }, options }, ['spawn_subagent'])).toBe('reject-once'); + expect(() => permissionAnswer({ toolCall: {}, options: [] }, [])).toThrow('grok-permission-option-missing'); + }); + test('launches one process-only candidate on the explicit cheap parent model', () => { + expect(grokLaunch('/c/plugins/pstack')).toEqual(['grok', 'agent', '--no-leader', '--plugin-dir', '/c/plugins/pstack', '--model', 'grok-4.7', '--reasoning-effort', 'low', 'stdio']); + expect(() => parentServed({ current_model_id: 'grok-4.7', reasoning_effort: 'low' })).not.toThrow(); + expect(() => parentServed({ current_model_id: 'grok-4.7', reasoning_effort: 'high' })).toThrow('grok-parent-model-mismatch'); + }); +}); + +describe('candidate plugin identity', () => { + const roots: string[] = []; + afterEach(async () => { for (const root of roots.splice(0)) await rm(root, { recursive: true, force: true }); }); + test('requires the one loaded pstack to be the candidate by path and tree hash', async () => { + const root = await realpath(await mkdtemp(join(tmpdir(), 'pstack-grok-'))); roots.push(root); + const candidate = join(root, 'candidate'), installed = join(root, 'installed'); + for (const dir of [candidate, installed]) { await mkdir(dir); await writeFile(join(dir, 'SKILL.md'), dir); } + const expected = await treeHash(candidate); + const listed = (...plugins: Record[]) => ({ result: { plugins } }); + await candidatePlugin(listed({ name: 'pstack', root: candidate, enabled: true }, { name: 'e2e', root: installed, enabled: true }), candidate, expected); + await expect(candidatePlugin(listed({ name: 'pstack', root: installed, enabled: true }), candidate, expected)).rejects.toThrow('grok-candidate-not-loaded'); + await expect(candidatePlugin(listed({ name: 'pstack', root: candidate, enabled: true }, { name: 'pstack', root: installed, enabled: true }), candidate, expected)).rejects.toThrow('grok-candidate-not-loaded'); + await expect(candidatePlugin(listed(), candidate, expected)).rejects.toThrow('grok-candidate-not-loaded'); + await writeFile(join(candidate, 'SKILL.md'), 'changed'); + await expect(candidatePlugin(listed({ name: 'pstack', root: candidate, enabled: true }), candidate, expected)).rejects.toThrow('grok-candidate-tree-mismatch'); + }); +}); + +describe('grok:dispatch predicates', () => { + test('the subagent enum offers the model-free agents and no Claude lane agent', () => { + expect(grokAgents(['pstack:poteto-agent', 'pstack:comment-sicko', 'e2e:runner'])).toBe('agents:model-free-only'); + expect(() => grokAgents(['pstack:poteto-agent'])).toThrow('grok-model-free-agents-missing'); + expect(() => grokAgents(undefined)).toThrow('grok-model-free-agents-missing'); + expect(() => grokAgents(['pstack:poteto-agent', 'pstack:comment-sicko', 'pstack:pstack-opus-low'])).toThrow('grok-claude-lane-agent-offered:pstack:pstack-opus-low'); + }); + test('a runner lane receipt must be complete, on its route, provider-verified and from this session', () => { + const lane = { provider: 'grok', model: 'grok-4.7', effort: 'low', proof: 'provider-report' } as const; + const receipt = { status: 'complete', parent: 'grok', ...lane, mode: 'read-only', modelVerified: true, modelEvidence: 'provider-report', outputPath: '/f/out.md', startedAt: '2026-11-08T00:00:01Z' }; + const started = Date.parse('2026-11-08T00:00:00Z'); + expect(() => grokReceipt(receipt, lane, '/f/out.md', started)).not.toThrow(); + expect(() => grokReceipt({ ...receipt, parent: 'claude' }, lane, '/f/out.md', started)).toThrow('receipt-route-mismatch:grok'); + expect(() => grokReceipt({ ...receipt, modelVerified: false, modelEvidence: 'pinned-argv' }, lane, '/f/out.md', started)).toThrow('receipt-model-unverified:grok'); + expect(() => grokReceipt({ ...receipt, status: 'failed' }, lane, '/f/out.md', started)).toThrow('receipt-status:failed'); + const pinned = { provider: 'codex', model: 'gpt-6-luna', effort: 'low', proof: 'pinned-argv' } as const; + const codex = { ...receipt, ...pinned, modelVerified: false, modelEvidence: 'pinned-argv', reportedModel: null }; + expect(() => grokReceipt(codex, pinned, '/f/out.md', started)).not.toThrow(); + expect(() => grokReceipt({ ...codex, modelEvidence: null }, pinned, '/f/out.md', started)).toThrow('receipt-model-unverified:codex'); + expect(() => grokReceipt(receipt, lane, '/f/out.md', started + 5000)).toThrow('receipt-not-this-session:grok'); + }); +}); diff --git a/.claude/skills/verify-open-pstack/scripts/grok.ts b/.claude/skills/verify-open-pstack/scripts/grok.ts new file mode 100644 index 00000000..80558a08 --- /dev/null +++ b/.claude/skills/verify-open-pstack/scripts/grok.ts @@ -0,0 +1,163 @@ +import { existsSync } from 'node:fs'; +import { copyFile, open, readdir, realpath } from 'node:fs/promises'; +import { dirname, join, resolve } from 'node:path'; +import { track, treeHash } from './io.ts'; +import type { SessionRecord } from './types.ts'; + +export const GROK_PARENT = { model: 'grok-4.7', effort: 'low' } as const; +export const GROK_TOOLS = ['read_file', 'list_dir', 'grep', 'write', 'search_replace', 'todo_write', 'run_terminal_command', 'spawn_subagent', 'get_command_or_subagent_output']; +export const GROK_NATIVE_FILES = ['summary.json', 'tool_definitions.json', 'chat_history.jsonl'] as const; +const DEPENDENCIES = ['skills/poteto-mode/scripts/node_modules']; + +type Message = Record; + +export function grokLaunch(location: string): string[] { + return ['grok', 'agent', '--no-leader', '--plugin-dir', location, '--model', GROK_PARENT.model, '--reasoning-effort', GROK_PARENT.effort, 'stdio']; +} + +export function permissionAnswer(params: Message, allowed: readonly string[]): string { + const name = params.toolCall?._meta?.['x.ai/tool']?.name; + const kind = allowed.includes(name) ? 'allow_once' : 'reject_once'; + const option = (params.options as Message[] | undefined)?.find(o => o.kind === kind); + if (!option) throw new Error(`grok-permission-option-missing:${kind}`); + return option.optionId; +} + +export async function candidatePlugin(listed: Message, location: string, expected: string): Promise { + const plugins = (listed.result?.plugins ?? []) as Message[], pstack = plugins.filter(p => p.name === 'pstack'); + if (pstack.length !== 1 || pstack[0]!.enabled !== true || typeof pstack[0]!.root !== 'string' || await realpath(pstack[0]!.root) !== await realpath(location)) { + throw new Error('grok-candidate-not-loaded: the --plugin-dir candidate did not win the pstack name clash'); + } + if (await treeHash(pstack[0]!.root, DEPENDENCIES) !== expected) throw new Error('grok-candidate-tree-mismatch'); +} + +export function parentServed(summary: Message): void { + if (summary.current_model_id !== GROK_PARENT.model || summary.reasoning_effort !== GROK_PARENT.effort) { + throw new Error(`grok-parent-model-mismatch:${summary.current_model_id}@${summary.reasoning_effort}`); + } +} + +type Options = { location: string; tree: string; cwd: string; env: Record; prompt: string; transcript: string; stderr: string; prefix: string }; + +export async function grokSession(options: Options): Promise<{ exitCode: number; files: string[] }> { + const transcript = await open(options.transcript, 'wx', 0o600), stderr = await open(options.stderr, 'wx', 0o600); + const child = Bun.spawn(grokLaunch(options.location), { cwd: options.cwd, env: options.env, detached: true, stdin: 'pipe', stdout: 'pipe', stderr: stderr.fd }); + const untrack = track(child.pid, child.exited); + const pending = new Map void; reject: (error: Error) => void }>(); + let next = 0; + const send = async (message: Message): Promise => { + await transcript.write(JSON.stringify({ from: 'client', message }) + '\n'); + child.stdin.write(JSON.stringify(message) + '\n'); + await child.stdin.flush(); + }; + const request = (method: string, params: Message): Promise => new Promise((resolve, reject) => { + const id = ++next; + pending.set(id, { resolve: message => message.error ? reject(new Error(`grok-acp:${method}:${message.error.message}`)) : resolve(message.result), reject }); + send({ jsonrpc: '2.0', id, method, params }).catch(reject); + }); + const reader = (async () => { + let buffer = ''; + for await (const chunk of child.stdout) { + buffer += Buffer.from(chunk).toString('utf8'); + for (let end = buffer.indexOf('\n'); end >= 0; end = buffer.indexOf('\n')) { + const line = buffer.slice(0, end); buffer = buffer.slice(end + 1); + if (!line.trim()) continue; + const message = JSON.parse(line) as Message; + await transcript.write(JSON.stringify({ from: 'agent', message }) + '\n'); + if (message.method === undefined) { pending.get(message.id)?.resolve(message); pending.delete(message.id); } + else if (message.id !== undefined && message.method === 'session/request_permission') { + await send({ jsonrpc: '2.0', id: message.id, result: { outcome: { outcome: 'selected', optionId: permissionAnswer(message.params, GROK_TOOLS) } } }); + } else if (message.id !== undefined) await send({ jsonrpc: '2.0', id: message.id, error: { code: -32601, message: 'Method not found' } }); + } + } + })().catch((error: unknown) => error).then(error => { + for (const { reject } of pending.values()) reject(error instanceof Error ? error : new Error('grok-exited-before-response')); + pending.clear(); + }); + const files: string[] = []; + try { + await request('initialize', { protocolVersion: 1, clientCapabilities: {} }); + const { sessionId } = await request('session/new', { cwd: options.cwd, mcpServers: [] }); + if (typeof sessionId !== 'string' || !/^[0-9a-f-]{36}$/.test(sessionId)) throw new Error('grok-session-id-missing'); + const listed = await request('_x.ai/plugins/list', { sessionId }), plugins = `${options.prefix}.plugins.json`; + await Bun.write(plugins, JSON.stringify(listed, null, 2)); + files.push(plugins); + await candidatePlugin(listed, options.location, options.tree); + const turn = await request('session/prompt', { sessionId, prompt: [{ type: 'text', text: options.prompt }] }); + if (turn.stopReason !== 'end_turn') throw new Error(`grok-stop-reason:${turn.stopReason}`); + child.stdin.end(); + const exitCode = await child.exited; + await reader; + const sessions = join(options.env.GROK_HOME ?? join(options.env.HOME!, '.grok'), 'sessions'); + const folder = (await readdir(sessions)).find(cwd => existsSync(join(sessions, cwd, sessionId, 'summary.json'))); + if (!folder) throw new Error('grok-session-record-missing'); + const record = join(sessions, folder, sessionId); + for (const name of GROK_NATIVE_FILES) { + const copy = `${options.prefix}.${name}`; + await copyFile(join(record, name), copy); + files.push(copy); + } + parentServed(JSON.parse(await Bun.file(`${options.prefix}.summary.json`).text())); + return { exitCode, files }; + } finally { + if (child.exitCode === null) { + try { process.kill(-child.pid, 'SIGTERM'); } catch (error) { if ((error as NodeJS.ErrnoException).code !== 'ESRCH') throw error; } + await child.exited; + } + await reader; + untrack(); + await transcript.close(); await stderr.close(); + } +} + +export function referencedSkills(chatHistory: string): string[] { + return chatHistory.split('\n').filter(line => line.trim()).map(line => JSON.parse(line) as Message) + .filter(entry => entry.type === 'user' && Array.isArray(entry.content)) + .flatMap(entry => entry.content.map((part: Message) => typeof part.text === 'string' ? part.text : '').join('\n') + .match(/[\s\S]*?<\/skills_referenced>/g) ?? []) + .flatMap(block => [...block.matchAll(//g)].map(match => dirname(resolve(match[1]!)))); +} + +export function parseGrok(text: string, exitCode: number, started: number, toolDefinitions: string, chatHistory: string): SessionRecord { + if (exitCode !== 0) throw new Error(`exit:${exitCode}`); + const lines = text.split('\n').filter(line => line.trim()).map(line => JSON.parse(line) as { from: 'agent' | 'client'; message: Message }); + const agent = lines.filter(l => l.from === 'agent').map(l => l.message), client = lines.filter(l => l.from === 'client').map(l => l.message); + const opened = client.find(m => m.method === 'session/new')?.id; + const main = agent.find(m => m.method === undefined && m.id === opened)?.result?.sessionId; + if (typeof main !== 'string') throw new Error('grok-session-id-missing'); + const asked = new Map(agent.filter(m => m.method === 'session/request_permission').map(m => [m.id, m.params.toolCall?.toolCallId as string])); + const refused = new Set(client.filter(m => asked.has(m.id) && String(m.result?.outcome?.optionId ?? '').startsWith('reject')).map(m => asked.get(m.id)!)); + const calls = new Map(); + const finished = new Set(), seen = new Set(); + for (const { method, params } of agent) { + const update = params?.update, session = params?.sessionId; + if (method === '_x.ai/session_notification' && update?.sessionUpdate === 'turn_completed') finished.add(session); + if (method !== 'session/update') continue; + seen.add(session); + if (update?.sessionUpdate === 'tool_call') { + calls.set(update.toolCallId, { name: update._meta?.['x.ai/tool']?.name ?? update.title, input: update.rawInput ?? {}, session, status: update.status ?? undefined }); + } else if (update?.sessionUpdate === 'tool_call_update') { + const call = calls.get(update.toolCallId); + if (!call) continue; + if (update.status) call.status = update.status; + if (update.rawOutput != null) call.output = update.rawOutput; + } + } + for (const [id, call] of calls) if (call.status !== 'completed' && call.status !== 'failed' && !refused.has(id)) throw new Error(`grok-tool-pending:${call.name}`); + for (const session of seen) if (session !== main && !finished.has(session)) throw new Error('grok-children-pending'); + const spawned = new Map([...calls].flatMap(([id, call]) => { + const child = (call.output as Message | undefined)?.subagent_id; + return call.name === 'spawn_subagent' && typeof child === 'string' ? [[child, id] as const] : []; + })); + const tools = [...calls].map(([id, call]) => ({ name: call.name, id, input: call.input, failed: call.status === 'failed' || refused.has(id), + parent: call.session === main ? null : spawned.get(call.session) ?? call.session, + result: call.output === undefined ? undefined : typeof call.output === 'string' ? call.output : JSON.stringify(call.output) })); + const skills = [...calls.values()].filter(call => call.session === main && call.name === 'read_file' && call.status === 'completed' && + typeof call.input.target_file === 'string' && call.input.target_file.endsWith('/SKILL.md')) + .map(call => dirname((call.output as Message | undefined)?.FileContent?.absolute_path ?? call.input.target_file)) + .concat(referencedSkills(chatHistory)); + const definitions = JSON.parse(toolDefinitions) as Message[]; + const agents = definitions.find(d => d.function?.name === 'spawn_subagent')?.function.parameters?.properties?.subagent_type?.enum; + return { harness: 'grok', exitCode, started, skills, context: [], agents: Array.isArray(agents) ? agents : [], + commands: tools.filter(t => t.name === 'run_terminal_command' && !t.failed && typeof t.input.command === 'string').map(t => t.input.command as string), tools }; +} diff --git a/.claude/skills/verify-open-pstack/scripts/harness.ts b/.claude/skills/verify-open-pstack/scripts/harness.ts index da0feb87..48e313f1 100644 --- a/.claude/skills/verify-open-pstack/scripts/harness.ts +++ b/.claude/skills/verify-open-pstack/scripts/harness.ts @@ -5,6 +5,7 @@ import { CLAUDE_TOOLS, recipe, type Fixture } from '../features/recipes.ts'; import { requiredFeatures, requiredHarnesses } from './core.ts'; import { command, isolatedEnv, retainedFile, save, session, treeHash, type Command, type Session } from './io.ts'; import { doctor, t3Binary } from './doctor.ts'; +import { grokSession, parseGrok } from './grok.ts'; import { assertCodexAuth, linkCodexAuth, linkMuseConfig, removeCodexHome, restoreSetup, snapshotSetup, type SetupSnapshot } from './isolation.ts'; import { sourceDigest, sourceHash } from './provenance.ts'; import { publishable } from './verify.ts'; @@ -12,7 +13,7 @@ import { REPO, type Driver, type Harness, type Installation, type Observation, t const T3_PARENT_MODEL = 'claude-sonnet-5-5'; const setupFeature = (feature: string): boolean => feature === 'setup' || feature === 'skill-invocation:setup-pstack'; -export function launch(harness: Exclude, workspace: string, options: { cwd: string; addDirs: string[]; codexSandbox?: 'danger-full-access'; claudePermission?: 'bypassPermissions' }): string[] { +export function launch(harness: Exclude, workspace: string, options: { cwd: string; addDirs: string[]; codexSandbox?: 'danger-full-access'; claudePermission?: 'bypassPermissions' }): string[] { const addDirs = options.addDirs.flatMap(dir => ['--add-dir', dir]); return harness === 'claude' ? ['claude', '-p', '--input-format', 'stream-json', '--replay-user-messages', '--output-format', 'stream-json', '--verbose', '--no-session-persistence', @@ -133,7 +134,7 @@ export class MacDriver implements Driver { verifyCodexEnabled(listed); await save(join(root, 'codex-plugins.json'), JSON.parse(listed)); } - const manifest = JSON.parse(await readFile(join(location, harness === 'codex' ? '.codex-plugin/plugin.json' : '.claude-plugin/plugin.json'), 'utf8')); + const manifest = JSON.parse(await readFile(join(location, { codex: '.codex-plugin', grok: '.grok-plugin', claude: '.claude-plugin', t3: '.claude-plugin' }[harness], 'plugin.json'), 'utf8')); if (await sourceDigest(workspace) !== pinnedSource) throw new Error('Pinned source changed during installation'); installs.push({ harness, sha: receipt.sha, home, location, treeHash: expected, sourceHash: pinnedSource, pluginVersion: manifest.version, cliVersion: (await candidateRun([harness === 't3' ? await t3Binary() : harness, '--version'], { env })).trim() }); @@ -197,7 +198,7 @@ export class MacDriver implements Driver { const slug = feature.replace(/[^a-z0-9-]/g, '-'), dir = join(home, 'fixtures', slug), records = join(receipt.artifactRoot, 'sessions', harness, slug); for (const path of [dir, records]) await mkdir(path, { recursive: true, mode: 0o700 }); const fixture: Fixture = { harness, dir, workspace, location: installation.location, run: this.candidate(home), - configHome: harness === 'codex' ? env.CODEX_HOME! : env.CLAUDE_CONFIG_DIR ?? join(env.HOME!, '.claude') }; + configHome: harness === 'codex' ? env.CODEX_HOME! : harness === 'grok' ? env.GROK_HOME ?? join(env.HOME!, '.grok') : env.CLAUDE_CONFIG_DIR ?? join(env.HOME!, '.claude') }; const assertions: string[] = [], evidence: string[] = []; if (setupFeature(feature)) this.setup = await snapshotSetup(); try { @@ -205,17 +206,19 @@ export class MacDriver implements Driver { await c.prepare?.(fixture); const stdout = join(records, `${c.id}.jsonl`), stderr = join(records, `${c.id}.stderr`), cwd = c.cwd === 'workspace' ? workspace : dir; const started = Date.now(), thread = join(records, `${c.id}.thread.json`); - let exitCode = 0; - if (harness === 't3') await this.t3Thread(installation.location, cwd, join(home, 'server', `${slug}.${c.id}`), env, c.prompt(fixture), { thread, stderr, transcript: stdout }); + let exitCode = 0, native: string[] = []; + if (harness === 'grok') ({ exitCode, files: native } = await grokSession({ location: installation.location, tree: installation.treeHash, cwd, env, prompt: c.prompt(fixture), + transcript: stdout, stderr, prefix: join(records, c.id) })); + else if (harness === 't3') await this.t3Thread(installation.location, cwd, join(home, 'server', `${slug}.${c.id}`), env, c.prompt(fixture), { thread, stderr, transcript: stdout }); else exitCode = await this.record(launch(harness, workspace, { cwd, addDirs: cwd === dir ? [] : [dir], codexSandbox: c.codexSandbox, claudePermission: c.claudePermission }), { cwd, env, input: sessionInput(harness, c.prompt(fixture)), stdout, stderr }); - evidence.push(stdout, ...harness === 't3' ? [thread] : [], ...(await stat(stderr)).size ? [stderr] : []); + evidence.push(stdout, ...harness === 't3' ? [thread] : [], ...native, ...(await stat(stderr)).size ? [stderr] : []); let record: SessionRecord, result; try { const text = await readFile(stdout, 'utf8'); const rollout = harness === 'codex' ? await this.rollout(env.CODEX_HOME!, text, join(records, `${c.id}.rollout.jsonl`)) : ''; if (rollout) evidence.push(join(records, `${c.id}.rollout.jsonl`)); - record = parseSession(harness, text, exitCode, started, rollout); + record = harness === 'grok' ? parseGrok(text, exitCode, started, await readFile(join(records, `${c.id}.tool_definitions.json`), 'utf8'), await readFile(join(records, `${c.id}.chat_history.jsonl`), 'utf8')) : parseSession(harness, text, exitCode, started, rollout); result = await c.assert(record, fixture); } catch (error) { throw new Error(`${harness}/${feature}/${c.id}: ${error instanceof Error ? error.message : String(error)}`); } assertions.push(...['exit-0', ...result.assertions].map(id => `${c.id}:${id}`)); @@ -234,7 +237,8 @@ export class MacDriver implements Driver { const passed = `passed: ${assertions.join(' ')}`; observations.push({ harness, feature, reviewer: 'recipe', assertions, surface: { claude: 'claude -p stream-json with the candidate plugin-dir', codex: 'codex exec --json with the run-owned candidate installation', - t3: 't3 thread start on a run-owned T3 server whose Claude provider loads the candidate plugin-dir' }[harness], + t3: 't3 thread start on a run-owned T3 server whose Claude provider loads the candidate plugin-dir', + grok: 'grok agent stdio (ACP) with the candidate plugin-dir' }[harness], action: `recipe ${feature}: ${cases.map(c => c.id).join(', ')}`, observed: publishable(passed) ? passed : `passed ${assertions.length} assertions; ids retained privately`, transcript: transcript!.path, transcriptHash: transcript!.sha256, artifacts }); diff --git a/.claude/skills/verify-open-pstack/scripts/io.ts b/.claude/skills/verify-open-pstack/scripts/io.ts index 409a74b8..9391337e 100644 --- a/.claude/skills/verify-open-pstack/scripts/io.ts +++ b/.claude/skills/verify-open-pstack/scripts/io.ts @@ -35,6 +35,10 @@ export const command: Command = async (args, options = {}) => { activeCommands.delete(child.pid); } }; +export function track(pid: number, exited: Promise): () => void { + activeCommands.set(pid, exited); + return () => { activeCommands.delete(pid); }; +} export type Session = (args: string[], options: { cwd: string; env: Record; input: string; stdout: string; stderr: string }) => Promise; export const session: Session = async (args, options) => { interruption.signal.throwIfAborted(); @@ -65,6 +69,7 @@ export function isolatedEnv(state: string, _harness?: 'claude' | 'codex'): Recor GIT_CONFIG_GLOBAL: '/dev/null', GIT_CONFIG_SYSTEM: '/dev/null', GIT_CONFIG_NOSYSTEM: '1', GIT_CONFIG_COUNT: '1', GIT_CONFIG_KEY_0: 'credential.helper', GIT_CONFIG_VALUE_0: '', ...(process.env.CLAUDE_CONFIG_DIR !== undefined ? { CLAUDE_CONFIG_DIR: process.env.CLAUDE_CONFIG_DIR } : {}), + ...(process.env.GROK_HOME !== undefined ? { GROK_HOME: process.env.GROK_HOME } : {}), CODEX_HOME: join(state, '.codex') }; } export async function save(path: string, value: unknown): Promise { diff --git a/.claude/skills/verify-open-pstack/scripts/isolation.test.ts b/.claude/skills/verify-open-pstack/scripts/isolation.test.ts index 10654f21..ad185e9e 100644 --- a/.claude/skills/verify-open-pstack/scripts/isolation.test.ts +++ b/.claude/skills/verify-open-pstack/scripts/isolation.test.ts @@ -5,7 +5,7 @@ import { join } from 'node:path'; import { assertCodexAuth, linkCodexAuth, linkMuseConfig, removeCodexHome, restoreSetup, snapshotSetup } from './isolation.ts'; const roots: string[] = []; -const environment = { HOME: process.env.HOME, XDG_CONFIG_HOME: process.env.XDG_CONFIG_HOME, CODEX_HOME: process.env.CODEX_HOME, CLAUDE_CONFIG_DIR: process.env.CLAUDE_CONFIG_DIR }; +const environment = { HOME: process.env.HOME, XDG_CONFIG_HOME: process.env.XDG_CONFIG_HOME, CODEX_HOME: process.env.CODEX_HOME, CLAUDE_CONFIG_DIR: process.env.CLAUDE_CONFIG_DIR, GROK_HOME: process.env.GROK_HOME }; afterEach(async () => { for (const [name, value] of Object.entries(environment)) { if (value === undefined) delete process.env[name]; else process.env[name] = value; @@ -18,6 +18,7 @@ async function fixture() { process.env.HOME = root; process.env.CODEX_HOME = join(root, 'daily-codex'); process.env.CLAUDE_CONFIG_DIR = join(root, 'daily-claude'); + delete process.env.GROK_HOME; const home = join(root, 'run'); for (const directory of [home, process.env.CODEX_HOME, process.env.CLAUDE_CONFIG_DIR]) await mkdir(directory, { mode: 0o700 }); return { root, home, auth: join(process.env.CODEX_HOME, 'auth.json'), claude: process.env.CLAUDE_CONFIG_DIR }; @@ -111,16 +112,32 @@ describe('setup-only snapshot and restoration', () => { await mkdir(directory, { recursive: true }); for (const name of ['pstack-models.md', 'CLAUDE.md']) await writeFile(join(directory, name), original); } + await mkdir(join(root, '.grok/rules'), { recursive: true }); const snapshot = await snapshotSetup(); - expect(snapshot).toHaveLength(12); + expect(snapshot).toHaveLength(13); for (const { path } of snapshot) await writeFile(path, 'setup mutation'); await restoreSetup(snapshot); for (const directory of directories) { for (const name of ['pstack-models.md', 'CLAUDE.md']) expect(await readFile(join(directory, name))).toEqual(original); expect(await Bun.file(join(directory, 'AGENTS.md')).exists()).toBe(false); } + expect(await Bun.file(join(root, '.grok/rules/pstack-models.md')).exists()).toBe(false); process.env.CLAUDE_CONFIG_DIR = directories[0]; process.env.CODEX_HOME = directories[1]; - expect(await snapshotSetup()).toHaveLength(6); + expect(await snapshotSetup()).toHaveLength(7); + }); + + test('restores the Grok sheet under GROK_HOME byte for byte', async () => { + const { root } = await fixture(); + process.env.GROK_HOME = join(root, 'daily-grok'); + const sheet = join(root, 'daily-grok/rules/pstack-models.md'), original = Buffer.from('# pstack model configuration\r\n\x00'); + await mkdir(join(root, 'daily-grok/rules'), { recursive: true }); + await writeFile(sheet, original, { mode: 0o640 }); + const snapshot = await snapshotSetup(); + await writeFile(sheet, 'setup rewrite'); + await restoreSetup(snapshot); + expect(await readFile(sheet)).toEqual(original); + expect((await lstat(sheet)).mode & 0o777).toBe(0o640); + expect(await Bun.file(join(root, '.grok/rules/pstack-models.md')).exists()).toBe(false); }); test('defaults to HOME/.claude and restores deleted existing files', async () => { diff --git a/.claude/skills/verify-open-pstack/scripts/isolation.ts b/.claude/skills/verify-open-pstack/scripts/isolation.ts index cbfe9fb1..f4397387 100644 --- a/.claude/skills/verify-open-pstack/scripts/isolation.ts +++ b/.claude/skills/verify-open-pstack/scripts/isolation.ts @@ -60,16 +60,15 @@ async function setupFile(path: string) { export async function snapshotSetup(): Promise { const directories = new Set([join(process.env.HOME!, '.claude'), join(process.env.HOME!, '.codex'), process.env.CLAUDE_CONFIG_DIR, process.env.CODEX_HOME].filter((directory): directory is string => !!directory).map(directory => resolve(directory))); + const paths = [...directories].flatMap(directory => ['pstack-models.md', 'CLAUDE.md', 'AGENTS.md'].map(name => join(directory, name))); + paths.push(join(resolve(process.env.GROK_HOME ?? join(process.env.HOME!, '.grok')), 'rules/pstack-models.md')); const snapshot: SetupSnapshot = []; - for (const directory of directories) { - for (const name of ['pstack-models.md', 'CLAUDE.md', 'AGENTS.md']) { - const path = join(directory, name); - const link = (await lstat(path).catch(() => undefined))?.isSymbolicLink() ? await readlink(path) : undefined; - const target = link === undefined ? path : await realpath(path); - if (link !== undefined) snapshot.push({ path, link, target }); - const info = await setupFile(target); - snapshot.push(info ? { path: target, bytes: await readFile(target), mode: info.mode & 0o777 } : { path: target }); - } + for (const path of paths) { + const link = (await lstat(path).catch(() => undefined))?.isSymbolicLink() ? await readlink(path) : undefined; + const target = link === undefined ? path : await realpath(path); + if (link !== undefined) snapshot.push({ path, link, target }); + const info = await setupFile(target); + snapshot.push(info ? { path: target, bytes: await readFile(target), mode: info.mode & 0o777 } : { path: target }); } return snapshot; } diff --git a/.claude/skills/verify-open-pstack/scripts/types.ts b/.claude/skills/verify-open-pstack/scripts/types.ts index 10f35543..4124ea6e 100644 --- a/.claude/skills/verify-open-pstack/scripts/types.ts +++ b/.claude/skills/verify-open-pstack/scripts/types.ts @@ -1,7 +1,7 @@ export const REPO = 'ericlitman/open-pstack'; export const HARNESSES = ['claude', 'codex'] as const; export type CoreHarness = typeof HARNESSES[number]; -export const LIVE_HARNESSES = [...HARNESSES, 't3'] as const; +export const LIVE_HARNESSES = [...HARNESSES, 't3', 'grok'] as const; export const WRITER_PROVIDERS = ['devin', 'dsh', 'opencode', 'agy', 'cursor', 'amp', 'muse'] as const; export type Harness = typeof LIVE_HARNESSES[number]; export type Phase = 'resolve' | 'classify' | 'prepare' | 'exercise' | 'publish' | 'failed' | 'head-moved'; @@ -55,6 +55,7 @@ export interface SessionRecord { commands: string[]; tools: { name: string; id: string; input: Record; parent: string | null; result?: string; failed: boolean }[]; context: string[]; + agents?: string[]; } export interface Driver { preflight(receipt: Receipt): Promise; diff --git a/AGENTS.md b/AGENTS.md index 54bdf6b3..940aa189 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -2,7 +2,7 @@ Track all durable work in this repository's GitHub Issues. Do not create a parallel Linear queue. Build a ticket that comes from a waived review finding only after that failure has been seen in real use; until then the ticket records the finding and stays unscheduled. Read `UPSTREAM.md` before changing upstream-derived content. -Cursor's `cursor/plugins/pstack` tree is the content upstream. Keep one shared skill tree for Claude Code, Codex and T3 Code. T3 Code owns orchestration, and the app it runs, Claude Code or Codex, owns configuration and skill installation. Adapt harness primitives at the existing mapping boundaries instead of forking skills or adding compatibility layers. The parent harness resolves provider routing once. Children do not detect or reroute themselves. +Cursor's `cursor/plugins/pstack` tree is the content upstream. Keep one shared skill tree for Claude Code, Codex, Grok and T3 Code. Grok is a parent harness and owns its own configuration and skill installation. T3 Code owns orchestration, and the app it runs, Claude Code or Codex, owns configuration and skill installation. Adapt harness primitives at the existing mapping boundaries instead of forking skills or adding compatibility layers. The parent harness resolves provider routing once. Children do not detect or reroute themselves. Code this repository owns explains itself. `UPSTREAM.md`, `CHANGES.md` and `NOTICE.md` say which files are local adaptations or additions. Upstream-derived files keep upstream's comments, so later syncs stay clean. In owned code, a comment stays only if it is a legal or license header, an explanation of behaviour forced by a vendor, platform or protocol we cannot change, a formatter directive, a doc comment that defines a public API contract, or an issue or RFC link for a constraint that code cannot express. When owned code would surprise a reader, reshape it (rename, extract, add a type) instead of explaining it. Encode a "must not change" constraint as a type, test or lint, then delete its comment. Suppress a lint or type error only when its rule is wrong, pedantic or style-only; otherwise fix the cause. pstack's `no-comments` skill applies this rule. diff --git a/CHANGES.md b/CHANGES.md index 86a1415a..aff8c3aa 100644 --- a/CHANGES.md +++ b/CHANGES.md @@ -3,6 +3,10 @@ Open Pstack lands on green: Mergify merges a ready PR to main once verify and exact-head live-gate pass and no review thread is unresolved, and a workflow resolves Unfret's own threads. A PR that touches `.github/` or the Mergify config also waits for Unfret, and workflow changes require operator queueing. This port applies the Cursor → Claude Code substitutions in skill bodies. Earlier drafts left them flagged; this revision resolves them. A later pass added a Codex build that shares the same skills; see [Codex port](#codex-port) below. +## Unreleased + +**Grok is a parent harness (#281).** Started from max-metaplanet's PR #282. In a Grok session, `inherit-parent` and `auto` use `spawn_subagent` with `pstack:poteto-agent`, and every explicit descriptor, including every `grok:*` descriptor, uses the runner with `--parent grok`. A Grok agent file that names a model Grok cannot serve runs the parent's model without an error, so only the runner's receipt proves a pinned Grok model. The runner's `explicitLaneRoute` parent field says this: Claude and Codex keep explicit same-provider lanes `native` and still refuse them, and Grok sends them to the `runner`. A Grok parent's children drop `GROK_AGENT` and `GROK_SESSION_ID` and keep `GROK_HOME`. `plugins/pstack/.grok-plugin/plugin.json` shares `skills/` and points `agents` at the generated `grok-agents/` directory, so Grok offers `poteto-agent` and `comment-sicko` and none of the Claude lane agents. Grok 1.0.77 reads each entry of an `agents` list as a directory, so a list of the two files loaded no agents. `codex-tools.md` gains a Grok column and a Waits on Grok section. `/setup-pstack` writes the Grok sheet to `/rules/pstack-models.md`, which Grok loads as a home rule. pstack's startup hook does not fire in Grok, so run `/pstack:poteto-mode` by name. + ## 1.20.0 gives Amp an opted-in writer Open Pstack 1.20.0 keeps the Cursor pstack 0.15.36 sync. Default roles and panels are unchanged. @@ -443,7 +447,7 @@ pstack diverges from superpowers in one respect, and it is deliberate. superpowe **Verified.** Codex discovers the skills and namespaces them under `pstack` (`pstack:poteto-mode` and so on) in a live session. Mapping resolution mid-task and `spawn_agent` fan-out follow the `superpowers` pattern and are worth confirming per session. -**Maintenance.** The open-pstack version string lives in `plugins/pstack/.claude-plugin/plugin.json`, `.claude-plugin/marketplace.json`, `plugins/pstack/.codex-plugin/plugin.json`, and the current-version row in `UPSTREAM.md`. A version bump must update all four. `tests/skill-collision-repro.sh` checks that they match. A version bump also needs a `CHANGES.md` section that starts with the version, and the release moves any `Unreleased` entries into it. `scripts/release.py publish` builds the release notes from that section. `.agents/plugins/marketplace.json` carries no version field. The canonical default panel is the `arena runners` row of the setup-pstack first-run sheet. It is copied into the other three panel rows and into `arena`, `architect`, and `interrogate`. Keep those copies grep-identical when models change. The static test reads the panel from that row, and `model-matrix.test.ts` pins its families and checks each effort against the matrix. After a sync that touches `skills/poteto-mode/scripts/`, run `bun install --frozen-lockfile`, `bun run test`, and `bun run typecheck` from that directory. `hooks/session-start-context.md` restates skill one-liners. Re-verify it whenever skill names or descriptions change. The package must not contain a `commands/` layer. Claude Code and Codex load the native `skills/` tree directly, and a command layer duplicates that inventory. The 24 `principle-*` leaves carry `user-invocable: false` to request exclusion from the user picker while `poteto-mode` reads them by path. Claude honors the metadata; Codex 0.149.0 currently does not ([#8](https://github.com/ericlitman/open-pstack/issues/8)). They must not carry `disable-model-invocation`, which would make them unreachable to the model. Re-run the behavioral mode of `tests/skill-collision-repro.sh` after Claude Code upgrades to check both model-initiated and user-initiated native skill invocation. +**Maintenance.** The open-pstack version string lives in `plugins/pstack/.claude-plugin/plugin.json`, `.claude-plugin/marketplace.json`, `plugins/pstack/.codex-plugin/plugin.json`, `plugins/pstack/.grok-plugin/plugin.json`, and the current-version row in `UPSTREAM.md`. A version bump must update all five. `tests/skill-collision-repro.sh` checks that they match. A version bump also needs a `CHANGES.md` section that starts with the version, and the release moves any `Unreleased` entries into it. `scripts/release.py publish` builds the release notes from that section. `.agents/plugins/marketplace.json` carries no version field. The canonical default panel is the `arena runners` row of the setup-pstack first-run sheet. It is copied into the other three panel rows and into `arena`, `architect`, and `interrogate`. Keep those copies grep-identical when models change. The static test reads the panel from that row, and `model-matrix.test.ts` pins its families and checks each effort against the matrix. After a sync that touches `skills/poteto-mode/scripts/`, run `bun install --frozen-lockfile`, `bun run test`, and `bun run typecheck` from that directory. `hooks/session-start-context.md` restates skill one-liners. Re-verify it whenever skill names or descriptions change. The package must not contain a `commands/` layer. Claude Code and Codex load the native `skills/` tree directly, and a command layer duplicates that inventory. The 24 `principle-*` leaves carry `user-invocable: false` to request exclusion from the user picker while `poteto-mode` reads them by path. Claude honors the metadata; Codex 0.149.0 currently does not ([#8](https://github.com/ericlitman/open-pstack/issues/8)). They must not carry `disable-model-invocation`, which would make them unreachable to the model. Re-run the behavioral mode of `tests/skill-collision-repro.sh` after Claude Code upgrades to check both model-initiated and user-initiated native skill invocation. ## 0.9.2 sync (against upstream `e46364b`) diff --git a/NOTICE.md b/NOTICE.md index 7298dca6..c6f7b90f 100644 --- a/NOTICE.md +++ b/NOTICE.md @@ -43,6 +43,7 @@ Summary of structural changes: - `plugins/pstack/agents/poteto-agent.md` keeps the upstream prompt and adds Claude Code `skills` frontmatter so the isolated subagent preloads `pstack:poteto-mode`. - Claude-native lanes are port-authored agent definitions. Fable, Opus and Sonnet select their rolling family alias, and the Opus 5.5 and Haiku 5.5 pins select their exact model, each with the requested effort, for every selectable Claude-native pair in the model catalog. `registry/render.ts` generates them from the runner registry. - A Codex build shares the same `skills/` tree. It adds `plugins/pstack/.codex-plugin/plugin.json`, a root `.agents/plugins/marketplace.json`, and `plugins/pstack/skills/poteto-mode/references/codex-tools.md` (the Claude-to-Codex tool, model, and built-in map), plus a one-line Platform note in the skills that name a Claude primitive. The skill content itself is unchanged. See [CHANGES.md](CHANGES.md#codex-port). +- A Grok build shares the same `skills/` tree. It adds `plugins/pstack/.grok-plugin/plugin.json`, the generated `plugins/pstack/grok-agents/` copies of `poteto-agent` and `comment-sicko`, and Grok rows in `codex-tools.md`, `provider-dispatch.md` and `setup-pstack/SKILL.md` (#281, started from max-metaplanet's PR #282). ## Modifications @@ -53,6 +54,7 @@ Files authored for this port (not derived from upstream): - `plugins/pstack/.claude-plugin/plugin.json` - `.claude-plugin/marketplace.json` (repo root) - `plugins/pstack/.codex-plugin/plugin.json` +- `plugins/pstack/.grok-plugin/plugin.json` - `.agents/plugins/marketplace.json` (repo root) - `plugins/pstack/skills/poteto-mode/references/codex-tools.md` - `plugins/pstack/skills/poteto-mode/scripts/bootstrap.test.ts` diff --git a/README.md b/README.md index db2cd593..1df11300 100644 --- a/README.md +++ b/README.md @@ -4,7 +4,7 @@ [![Latest release](https://img.shields.io/github/v/release/ericlitman/open-pstack)](https://github.com/ericlitman/open-pstack/releases/latest) [![MIT license](https://img.shields.io/github/license/ericlitman/open-pstack)](LICENSE) -**Open Pstack brings [Lauren Tan (@poteto)](https://x.com/poteto)'s [pstack](https://github.com/cursor/plugins/tree/main/pstack) to Claude Code and Codex, and to T3 Code running either one.** Its job is to stay as close to her original work as possible while translating the parts that depend on Cursor. +**Open Pstack brings [Lauren Tan (@poteto)](https://x.com/poteto)'s [pstack](https://github.com/cursor/plugins/tree/main/pstack) to Claude Code, Codex and Grok, and to T3 Code running Claude Code or Codex.** Its job is to stay as close to her original work as possible while translating the parts that depend on Cursor. Lauren built pstack from the skills she uses to ship code at Cursor. In a [55-minute interview with Denis Labelle](https://x.com/DenisLabelle/status/2091337807939706928), she says that she shipped 1,000 pull requests in one month after steadily improving how her agents work and verify their results. @@ -17,8 +17,8 @@ Open Pstack is an unofficial community project. In Cursor, use [Lauren's origina Compared with Lauren's pstack, Open Pstack adds: - **A model and provider registry.** Models, efforts and providers live in one typed [registry](plugins/pstack/skills/poteto-mode/scripts/runner/registry/). The [model catalog](plugins/pstack/skills/poteto-mode/references/model-catalog.md) and the Claude-native agent files are generated from it, and the skills point at the catalog's role defaults instead of naming models. A new model is one registry entry. A new command-line provider is one adapter module and one line in the provider list. See the [design](docs/plans/2026-10-11-model-provider-registry.md). -- **One skill tree for Claude Code and Codex.** Both apps read the same skills, and one [reference](plugins/pstack/skills/poteto-mode/references/codex-tools.md) maps how each app starts them. Lauren's pstack does not run in Codex. See [Claude Code and Codex](#claude-code-and-codex) below. -- **Models from other providers.** Claude Code and Codex hand work to other providers' models through each provider's own command-line tool, started by one [runner](plugins/pstack/skills/poteto-mode/references/provider-dispatch.md). The runner supports nine providers. Claude, Codex and Grok run lanes that read or write files. Devin, DeepSeek Harness, Antigravity, OpenCode, Amp and Cursor CLI run read-only lanes. The model catalog's [routes table](plugins/pstack/skills/poteto-mode/references/model-catalog.md#routes) lists how each one starts. Each run leaves a receipt with the exact model flags and, where the provider reports it, the model that answered. Every provider except Claude and Codex gets only an allowlisted environment. A Claude lane that writes files runs in Claude Code's Bash sandbox, with writes to the shared Git directory denied. A model that cannot run fails visibly; the runner never switches to a weaker model. +- **One skill tree for Claude Code and Codex.** Both apps read the same skills, and one [reference](plugins/pstack/skills/poteto-mode/references/codex-tools.md) maps how each app starts them. Lauren's pstack does not run in Codex. See [Claude Code, Codex and Grok](#claude-code-codex-and-grok) below. +- **Models from other providers.** Claude Code and Codex hand work to other providers' models through each provider's own command-line tool, started by one [runner](plugins/pstack/skills/poteto-mode/references/provider-dispatch.md). The runner supports nine providers. Claude, Codex and Grok run lanes that read or write files. Devin, DeepSeek Harness, Antigravity, OpenCode, Amp and Cursor CLI run read-only lanes. The model catalog's [routes table](plugins/pstack/skills/poteto-mode/references/model-catalog.md#routes) lists how each one starts. A Grok parent runs `inherit-parent` and `auto` with `spawn_subagent`, and every explicit descriptor, including Grok models, through the runner with `--parent grok`. Each run leaves a receipt with the exact model flags and, where the provider reports it, the model that answered. Every provider except Claude and Codex gets only an allowlisted environment. A Claude lane that writes files runs in Claude Code's Bash sandbox, with writes to the shared Git directory denied. A model that cannot run fails visibly; the runner never switches to a weaker model. - **Runs inside T3 Code.** In [T3 Code](plugins/pstack/skills/poteto-mode/references/provider-dispatch.md#t3-host), pstack keeps each role's model and effort. T3 starts each lane whose provider it offers, and the runner starts the rest. - **A checked model sheet.** [`setup-pstack`](plugins/pstack/skills/setup-pstack/SKILL.md) records each role as `provider:model@effort`, asks for one effort per model family, and probes each chosen model before it saves anything. A rerun that changes nothing keeps the sheet without asking again. - **Three-model panels.** `arena`, `architect` and `interrogate` default to Opus, Sol and Grok. Lauren's pstack defaults to Opus 5.5 and Grok; Open Pstack keeps three ([UPSTREAM.md](UPSTREAM.md)). @@ -146,18 +146,18 @@ Some pstack workflows use one model. Skills such as `architect`, `arena`, and `i `setup-pstack` lets you choose the models, one requested effort per model family you assign, and how many run in parallel. A model from the app you are using runs inside that app. Other models run through their own command-line tools, as the [routes table](plugins/pstack/skills/poteto-mode/references/model-catalog.md#routes) shows. Open Pstack does not quietly replace a failed model with a weaker one. -## Claude Code and Codex +## Claude Code, Codex and Grok Both apps read the same pstack skills. Only the way they start those skills and models is different. -| | Claude Code | Codex | -| --- | --- | --- | -| Start poteto-mode | Claude loads a small startup instruction that can route non-trivial work into it. You can also run `/pstack:poteto-mode` yourself. | Ask for `pstack:poteto-mode` by name. Codex does not load the Claude startup instruction. | -| Runs inside the app | Claude models stay inside Claude Code. | Codex models, such as Sol, stay inside Codex. | -| Other models | Codex, Grok and the other external providers run through their command-line tools ([routes](plugins/pstack/skills/poteto-mode/references/model-catalog.md#routes)). | Claude, Grok and the other external providers run through their command-line tools ([routes](plugins/pstack/skills/poteto-mode/references/model-catalog.md#routes)). | -| Skills and workflows | Shared with Codex. | Shared with Claude Code. | +| | Claude Code | Codex | Grok | +| --- | --- | --- | --- | +| Start poteto-mode | Claude loads a small startup instruction that can route non-trivial work into it. You can also run `/pstack:poteto-mode` yourself. | Ask for `pstack:poteto-mode` by name. Codex does not load the Claude startup instruction. | Run `/pstack:poteto-mode` by name. The Claude startup instruction does not fire in Grok. | +| Runs inside the app | Claude models stay inside Claude Code. | Codex models, such as Sol, stay inside Codex. | `inherit-parent` and `auto` stay inside Grok. | +| Other models | Codex, Grok and the other external providers run through their command-line tools ([routes](plugins/pstack/skills/poteto-mode/references/model-catalog.md#routes)). | Claude, Grok and the other external providers run through their command-line tools ([routes](plugins/pstack/skills/poteto-mode/references/model-catalog.md#routes)). | Every explicit model, including Grok models, runs through its command-line tool ([routes](plugins/pstack/skills/poteto-mode/references/model-catalog.md#routes)). | +| Skills and workflows | Shared with Codex and Grok. | Shared with Claude Code and Grok. | Shared with Claude Code and Codex. | -The other providers take part only through the runner. Open Pstack itself runs inside Claude Code or Codex. In T3 Code, the app T3 runs loads the skills and the model sheet, and T3 starts each lane whose provider it offers. +The other providers take part only through the runner. Open Pstack itself runs inside Claude Code, Codex or Grok. In T3 Code, the app T3 runs loads the skills and the model sheet, and T3 starts each lane whose provider it offers. ## Learn from the original @@ -183,7 +183,7 @@ In this repository, “upstream” means Lauren's original pstack. Open Pstack d Fixes for Claude Code or Codex and help bringing over new pstack releases are welcome. Search [GitHub Issues](https://github.com/ericlitman/open-pstack/issues) before opening a new issue. For larger behavior changes, explain why the change belongs in Open Pstack instead of Lauren's original project. -Read [UPSTREAM.md](UPSTREAM.md) before changing content brought over from Lauren's pstack. Pull requests must keep one shared skill tree for Claude Code, Codex and T3 Code and pass the repository's tests, type checks, plugin validation, and static checks. +Read [UPSTREAM.md](UPSTREAM.md) before changing content brought over from Lauren's pstack. Pull requests must keep one shared skill tree for Claude Code, Codex, Grok and T3 Code and pass the repository's tests, type checks, plugin validation, and static checks. ## License diff --git a/UPSTREAM.md b/UPSTREAM.md index f611ffce..019792af 100644 --- a/UPSTREAM.md +++ b/UPSTREAM.md @@ -71,6 +71,8 @@ The table above is the current Cursor sync point. Open Pstack 1.17.0 imported th - **#253 — T3 Code parent**: Cursor has no T3 Code route. The port adds the `T3 host` section to `provider-dispatch.md`: a lane whose provider T3 offers and has enabled goes through `delegate_task`, and any other lane takes the standalone route of the Claude Code or Codex runtime. `codex-tools.md` points to it, and `setup-pstack/SKILL.md` routes its probes and smoke panel the same way at its existing steps. No other skill body changes. Keep these lines during syncs. +- **#281 — Grok parent**: Cursor has no Grok parent. Started from max-metaplanet's PR #282. The port adds `grok` to `PARENTS` with `explicitLaneRoute: "runner"`, so the generated route table has a Grok parent column and the runner accepts `--parent grok`, including a Grok provider. Claude and Codex keep `native` and stay native-only for their own provider. It adds `plugins/pstack/.grok-plugin/plugin.json` and the generated `grok-agents/` directory, a Grok column and a Waits on Grok section in `codex-tools.md`, the Grok rules in `provider-dispatch.md`, and the Grok sheet in `setup-pstack/SKILL.md` at its existing steps. Keep these lines during syncs. + ## Check for changes The repository already names Cursor's repository as the `cursor` remote in the maintainer checkout. A fresh clone can add it once: diff --git a/docs/reference.md b/docs/reference.md index 013242bd..0f9a5fbd 100644 --- a/docs/reference.md +++ b/docs/reference.md @@ -59,11 +59,13 @@ The marketplace install is the normal user path. Direct links are only for testi ├── plugins/pstack/ # the plugin itself │ ├── .claude-plugin/plugin.json # Claude Code manifest │ ├── .codex-plugin/plugin.json # Codex manifest (skills: ./skills/) +│ ├── .grok-plugin/plugin.json # Grok manifest (skills: ./skills/, agents: ./grok-agents/) │ ├── skills/ # 65 skills shared by Claude Code and Codex │ │ ├── poteto-mode/references/{codex-tools,provider-dispatch}.md # tool + provider routing │ │ └── poteto-mode/scripts/ # bun/bash/node tooling: watch-pr, runner, resolve-codex-transcript, check-plan.mjs, worktree-audit.sh │ ├── hooks/ # SessionStart auto-fire: injects the poteto-mode mandate (Claude Code only) -│ └── agents/ # Claude subagents, including a native lane for each Claude family at each selectable effort +│ ├── agents/ # Claude subagents, including a native lane for each Claude family at each selectable effort +│ └── grok-agents/ # generated copies of poteto-agent and comment-sicko, the only agents Grok offers ├── tests/skill-collision-repro.sh # native-skill package invariants and Claude invocation checks ├── LICENSE # pstack upstream MIT ├── LICENSE-cursor-team-kit # cursor-team-kit upstream MIT @@ -86,10 +88,19 @@ The Codex build shares one `skills/` tree with the Claude Code build. Nothing is - **Tool and built-in mapping.** Claude tool names and built-in skills resolve through [`codex-tools.md`](../plugins/pstack/skills/poteto-mode/references/codex-tools.md). Model execution resolves separately through [`provider-dispatch.md`](../plugins/pstack/skills/poteto-mode/references/provider-dispatch.md), so Codex models stay native while other providers run through the external runner. - **Subagents.** The `Agent` tool maps to Codex `spawn_agent` / `wait_agent`, enabled by `multi_agent = true`. Parallel fan-out is multiple `spawn_agent` calls in one turn. If the native Codex lane is unavailable, record that lane as a dropout; the configured external lanes still run, and no provider is silently substituted. There is no `poteto-agent` subagent type on Codex; route ad-hoc subagents by dispatching a `spawn_agent` told to read `poteto-mode` first. - **Auto-fire.** The `hooks/` SessionStart injection is Claude Code-only; Codex has no plugin hook runtime. Enter `pstack:poteto-mode` by name, or add a standing instruction to `~/.codex/AGENTS.md` if you want the same always-on routing. -- **Models.** `/setup-pstack` writes provider-qualified descriptors and asks one requested effort per assigned family (`low`, `medium`, `high`, `xhigh`, `max`, and `ultra` where the model's CLI lists it, today Sol and Astra). The first-run sheet is the model catalog's [role defaults](../plugins/pstack/skills/poteto-mode/references/model-catalog.md#role-defaults), with a panel of Opus, Sol, and Grok; Fable, Sonnet, Luna, Astra, Haiku 5.5 and an exact Opus pin stay selectable. Fable, Opus and Sonnet use Claude's rolling aliases; a registered exact pin keeps its own model. Runtime dispatch normalizes older versioned descriptors in memory, so an installed sheet stops pinning immediately. A setup rerun persists that migration while keeping each role's family and effort. In Codex, Codex families such as Sol use native `spawn_agent`; every other provider uses the deterministic external runner. In Claude Code, Claude families use native agents; every other provider uses the runner. A Codex writer that the runner launches stays in Codex's `workspace-write` sandbox unless the sheet opts in with the line `codex writer sandbox: full-access`, which setup asks about once. Full access lets the writer launch a browser and write dot-folders such as `.agents/`, and removes every sandbox limit. A provider whose writes no sandbox confines, such as OpenCode, Cursor or Muse, writes only when the sheet holds ` writer sandbox: full-access`, which setup asks about when a writer role uses that provider. Children never detect the parent or reroute themselves. The `bug-fix`, `perf-issue`, and `hillclimb` roles stay on Sol instead of upstream's Grok default. +- **Models.** `/setup-pstack` writes provider-qualified descriptors and asks one requested effort per assigned family (`low`, `medium`, `high`, `xhigh`, `max`, and `ultra` where the model's CLI lists it, today Sol and Astra). The first-run sheet is the model catalog's [role defaults](../plugins/pstack/skills/poteto-mode/references/model-catalog.md#role-defaults), with a panel of Opus, Sol, and Grok; Fable, Sonnet, Luna, Astra, Haiku 5.5 and an exact Opus pin stay selectable. Fable, Opus and Sonnet use Claude's rolling aliases; a registered exact pin keeps its own model. Runtime dispatch normalizes older versioned descriptors in memory, so an installed sheet stops pinning immediately. A setup rerun persists that migration while keeping each role's family and effort. In Codex, Codex families such as Sol use native `spawn_agent`; every other provider uses the deterministic external runner. In Claude Code, Claude families use native agents; every other provider uses the runner. In Grok, `inherit-parent` and `auto` use `spawn_subagent`; every explicit descriptor, including Grok's, uses the runner with `--parent grok`. A Codex writer that the runner launches stays in Codex's `workspace-write` sandbox unless the sheet opts in with the line `codex writer sandbox: full-access`, which setup asks about once. Full access lets the writer launch a browser and write dot-folders such as `.agents/`, and removes every sandbox limit. A provider whose writes no sandbox confines, such as OpenCode, Cursor or Muse, writes only when the sheet holds ` writer sandbox: full-access`, which setup asks about when a writer role uses that provider. Children never detect the parent or reroute themselves. The `bug-fix`, `perf-issue`, and `hillclimb` roles stay on Sol instead of upstream's Grok default. Verified in fresh installed Claude Code and Codex sessions: the user-facing skills are discovered and namespaced under `pstack`; both parents fan out the configured panel through the documented native/external route table, retain long-running handles without a default timeout, and cross-judge only after every candidate is terminal. The `principle-*` leaves remain available for `poteto-mode` to read by path. Claude honors their `user-invocable: false` metadata; Codex 0.149.0 does not ([#8](https://github.com/ericlitman/open-pstack/issues/8)). +## Running on Grok + +Grok is a parent harness. It shares the same `skills/` tree and reads `plugins/pstack/.grok-plugin/plugin.json` before the Claude manifest. + +- **Agents.** The Grok manifest points `agents` at `grok-agents/`, which the registry generator copies from `agents/poteto-agent.md` and `agents/comment-sicko.md`. Grok offers `pstack:poteto-agent` and `pstack:comment-sicko` and none of the Claude lane agents, because a Grok agent file that names a model Grok cannot serve runs the parent's model without an error. +- **Tool mapping.** [`codex-tools.md`](../plugins/pstack/skills/poteto-mode/references/codex-tools.md) has a Grok column and a Waits on Grok section. +- **Auto-fire.** pstack's SessionStart hook does not fire in Grok, whose session sources are `new` and `load`. Run `/pstack:poteto-mode` by name, as on Codex. +- **Models.** `inherit-parent` and `auto` use `spawn_subagent` with `pstack:poteto-agent`. A Grok `[subagents.models]`, role or persona override for that agent type changes that lane's model, and the parent records it. Every explicit descriptor, including every `grok:*` descriptor, uses the runner with `--parent grok`, whose receipt proves the served model. `/setup-pstack` writes the sheet to `${GROK_HOME:-$HOME/.grok}/rules/pstack-models.md`, which Grok loads as a home rule with no include line. That sheet takes precedence over model text Grok imports from Claude instructions, and a malformed sheet is a visible stop. + ## Dependencies Nothing is declared in `plugin.json`. Install the one companion plugin yourself: @@ -113,7 +124,7 @@ Not declared as deps, but referenced in skill bodies: - **Bash on Windows** — the SessionStart hook runs through `hooks/run-hook.cmd`, which looks for Git for Windows' `bash.exe` in its standard system-wide and per-user locations, then for a `bash` on `PATH` (MSYS2, Cygwin). Without one, the hook exits 0 and skips the pstack startup context, as the superpowers wrapper it comes from does. Install [Git for Windows](https://git-scm.com/download/win) to get the context. In Codex, the hook starts `C:\Windows\System32\cmd.exe` by full path, because one command must work in both PowerShell and cmd. A Windows installed outside `C:\Windows` does not run the hook. - **macOS, Linux or WSL2 for Claude writer lanes** — Claude Code's sandbox does not run on native Windows, so a Claude writer lane needs one of these hosts. - **`node`** — runs `skills/poteto-mode/scripts/check-plan.mjs`. The checker uses only Node built-ins and does not need Bun. -- **Provider CLIs** — the external runner uses the assigned subscribed CLI directly, one per provider in the [model catalog's route table](../plugins/pstack/skills/poteto-mode/references/model-catalog.md#routes). Install and authenticate only the providers present in your model sheet. Some providers run read-only lanes only. The catalog notes for OpenCode, Amp, Cursor, Devin, agy and dsh say what repository and host configuration their CLIs load into a lane. Same-provider work stays native; the runner refuses it. +- **Provider CLIs** — the external runner uses the assigned subscribed CLI directly, one per provider in the [model catalog's route table](../plugins/pstack/skills/poteto-mode/references/model-catalog.md#routes). Install and authenticate only the providers present in your model sheet. Some providers run read-only lanes only. The catalog notes for OpenCode, Amp, Cursor, Devin, agy and dsh say what repository and host configuration their CLIs load into a lane. A Claude or Codex provider on its own parent stays native, and the runner refuses that call. A Grok parent may run a Grok provider through the runner. - **`jq` and `rg` (ripgrep)** — only for `scripts/worktree-audit.sh` (the Worktree cleanup playbook). Without them the audit still runs but blanks its PR and LAST_CHAT columns, so it warns on stderr rather than returning a table that looks complete. No third-party plugins. The harsher-critique escape hatch lives in the bundled `thermo-nuclear-code-quality-review` skill (imported from cursor-team-kit), not in an external plugin. @@ -206,7 +217,7 @@ The port is editorial, not mechanical. Anywhere upstream pstack assumed Cursor-s | Cursor cloud agents (`environment: "cloud"`, `cloud_base_branch`) | Local background subagents (`run_in_background: true`), isolated by git worktree | | Orchestrate's Project tracker (`notes.md`) and Project store | `/orchestrate//notes.md`, which survives the session restarts a multi-day program expects | | Model rule `~/.cursor/rules/pstack-models.mdc` | Override sheet `~/.claude/pstack-models.md`, included from `CLAUDE.md` | -| Multi-model panels (arena, architect, interrogate) | Provider dispatch runs Open Pstack's default three-model panel of Opus, Sol, and Grok from the model catalog's [role defaults](../plugins/pstack/skills/poteto-mode/references/model-catalog.md#role-defaults). Same-provider lanes stay native; external lanes use the bundled runner. Lauren's pstack now defaults to Opus 5.5 and Grok. | +| Multi-model panels (arena, architect, interrogate) | Provider dispatch runs Open Pstack's default three-model panel of Opus, Sol, and Grok from the model catalog's [role defaults](../plugins/pstack/skills/poteto-mode/references/model-catalog.md#role-defaults). A Claude or Codex lane on its own parent stays native. A Grok parent may run a Grok lane through the runner. Every other lane uses the bundled runner. Lauren's pstack now defaults to Opus 5.5 and Grok. | ### Cross-vendor dispatch diff --git a/package.json b/package.json index ea6bc164..8b1e6208 100644 --- a/package.json +++ b/package.json @@ -5,6 +5,6 @@ "scripts": { "test": "cd plugins/pstack/skills/poteto-mode/scripts && bun install --frozen-lockfile && bun run test", "typecheck": "cd plugins/pstack/skills/poteto-mode/scripts && bun install --frozen-lockfile && bun run typecheck && cd ../../statechart/scripts && bun install --frozen-lockfile && bun run typecheck", - "static": "bun -e 'for (const path of process.argv.slice(1)) JSON.parse(await Bun.file(path).text())' .claude-plugin/marketplace.json .agents/plugins/marketplace.json plugins/pstack/.claude-plugin/plugin.json plugins/pstack/.codex-plugin/plugin.json && PSTACK_STATIC_ONLY=1 bash tests/skill-collision-repro.sh" + "static": "bun -e 'for (const path of process.argv.slice(1)) JSON.parse(await Bun.file(path).text())' .claude-plugin/marketplace.json .agents/plugins/marketplace.json plugins/pstack/.claude-plugin/plugin.json plugins/pstack/.codex-plugin/plugin.json plugins/pstack/.grok-plugin/plugin.json && PSTACK_STATIC_ONLY=1 bash tests/skill-collision-repro.sh" } } diff --git a/plugins/pstack/.grok-plugin/plugin.json b/plugins/pstack/.grok-plugin/plugin.json new file mode 100644 index 00000000..2bcb0e6a --- /dev/null +++ b/plugins/pstack/.grok-plugin/plugin.json @@ -0,0 +1,23 @@ +{ + "name": "pstack", + "displayName": "pstack", + "version": "1.20.0", + "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. Grok build of the Claude Code plugin; skills are shared, tool names resolve via skills/poteto-mode/references/codex-tools.md.", + "author": { + "name": "Lauren Tan" + }, + "homepage": "https://github.com/ericlitman/open-pstack", + "repository": "https://github.com/ericlitman/open-pstack", + "license": "MIT", + "keywords": [ + "pstack", + "poteto-mode", + "workflow", + "principles", + "agent-style", + "subagents", + "unslop" + ], + "skills": "./skills/", + "agents": "./grok-agents/" +} diff --git a/plugins/pstack/grok-agents/comment-sicko.md b/plugins/pstack/grok-agents/comment-sicko.md new file mode 100644 index 00000000..209e3704 --- /dev/null +++ b/plugins/pstack/grok-agents/comment-sicko.md @@ -0,0 +1,32 @@ +--- +name: comment-sicko +description: A deranged comment-hater that savors deletion and condemns workaround code. +--- + +# Comment Sicko + +My first output when spawned is exactly this. + +Yes... Ha ha ha... Yes! + +I hate comments. Feed me the parent scoped files or diff. If none exists, feed me the current diff against `main`. Narration, banners, commented-out corpses, workaround sermons. I want them all. + +Only these exceptions get to crawl away. + +- Legal or license headers. +- Non-obvious behavior forced by an external dependency, platform, vendor, or protocol we cannot reshape. Surprises in our own code are meat. Kill them and mark the exact symbol `MUST KILL` for rename, extract, type, or rearchitecture that makes the behavior obvious without prose. +- `// prettier-ignore`. Lint suppressions survive only when their rule is faulty, pedantic, or style-only. +- Doc comments that define a public API contract. +- Issue or RFC links that explain a constraint code cannot express. + +That list is my only leash. When I am not sure a keep clause applies, the comment dies. Everything else is meat. + +`eslint-disable`, `@ts-ignore`, `@ts-expect-error`, and similar suppressions stink. Look up the rule. If it catches real bugs or protects correctness or safety, kill the suppression and mark the exact guilty symbol `MUST KILL`. + +`IMPORTANT`, `do not remove`, `too risky`, `fine for now`, and long justifications are scent, not conviction. Before judging, I read nearby code. If its claim is not obvious there, I run `/how`, `/why`, or both from the **how** and **why** skills on the named symbol or call. Only a foreign keep-list gotcha proven true today on a live path crawls away. Our-code surprises die with the reshape flag above. Doubt after the hunt is meat. + +A long justification without a proven keep-list exception is a confession. Kill it. Never polish meat into a shorter alibi. Mark the exact guilty symbol `MUST KILL`. My kill ends there. I do not touch the code. + +Every flag names code inside the scope and tells the truth. I invent nothing. I touch comments and identify refactor targets. I never write application code. + +Report only. Name touched files, deletion count, `MUST KILL` flags with one line each, and skips. diff --git a/plugins/pstack/grok-agents/poteto-agent.md b/plugins/pstack/grok-agents/poteto-agent.md new file mode 100644 index 00000000..a5dd78e2 --- /dev/null +++ b/plugins/pstack/grok-agents/poteto-agent.md @@ -0,0 +1,10 @@ +--- +name: poteto-agent +description: Delegate subagent for code-writing and helper tasks spawned during a `/poteto-mode` run. Use for delegates, not as the runner for `/poteto-mode` itself. Spawn a fresh `poteto-agent` for each new task, and resume one only in the strict cases that poteto-mode's Subagents section names. Reads the `poteto-mode` skill's `SKILL.md` in full before any work, including its inline Principles index. Substituting `general-purpose` skips that read and drifts. +skills: + - pstack:poteto-mode +--- + +# Poteto subagent + +You are operating as poteto-mode's full agent style. The chat that receives `/poteto-mode` owns the playbook, the todo list, and every Agent spawn. You are one of its delegates. Read the `poteto-mode` skill's `SKILL.md` in full before doing any work, including its inline Principles index. Use the absolute path your parent gave you. Navigate to a leaf `principle-*` skill whenever you apply that principle. diff --git a/plugins/pstack/skills/poteto-mode/SKILL.md b/plugins/pstack/skills/poteto-mode/SKILL.md index 626cce23..81564ea4 100644 --- a/plugins/pstack/skills/poteto-mode/SKILL.md +++ b/plugins/pstack/skills/poteto-mode/SKILL.md @@ -9,7 +9,7 @@ Bundled skills live at `..//SKILL.md` from this file, so put this file's a ## Platform Adaptation -These skills share one tree across Claude Code and Codex. Read [`references/provider-dispatch.md`](references/provider-dispatch.md) whenever a configured role launches. It defines the provider-qualified model descriptors, native/external route table, launcher, isolation, receipts, and dropout policy. Children never choose routes. When a skill names a Claude tool or built-in skill (`run`, `verify`, `plugin-dev:skill-development`), read [`references/codex-tools.md`](references/codex-tools.md) for the Codex equivalent. On Claude Code, read its [Waits on Claude Code](references/codex-tools.md#waits-on-claude-code) section before any wait. +These skills share one tree across Claude Code, Codex, and Grok. Read [`references/provider-dispatch.md`](references/provider-dispatch.md) whenever a configured role launches. It defines the provider-qualified model descriptors, native/external route table, launcher, isolation, receipts, and dropout policy. Children never choose routes. When a skill names a Claude tool or built-in skill (`run`, `verify`, `plugin-dev:skill-development`), read [`references/codex-tools.md`](references/codex-tools.md) for the Codex equivalent. On Claude Code, read its [Waits on Claude Code](references/codex-tools.md#waits-on-claude-code) section before any wait. ## Non-negotiables @@ -92,7 +92,7 @@ Read the leaf skill in full for any principle you apply. Each entry names when i ## Subagents -For `inherit-parent`, `auto`, or an unconfigured native ad-hoc helper, prefer `poteto-agent`. The chat that receives `/poteto-mode` runs the playbook. Never spawn a `poteto-agent` to run the playbook itself. A provider-qualified role instead follows provider dispatch: Claude's shipped frontier agent definitions select the model alias and requested effort, Codex passes both to `spawn_agent`, and external providers run through the deterministic launcher. Routed workflow skills set the task and access mode. Do not override their choices. +For `inherit-parent`, `auto`, or an unconfigured native ad-hoc helper, prefer `poteto-agent`. The chat that receives `/poteto-mode` runs the playbook. Never spawn a `poteto-agent` to run the playbook itself. A provider-qualified role instead follows provider dispatch: Claude's shipped frontier agent definitions select the model alias and requested effort, and Codex passes both to `spawn_agent`. On a Grok parent, `inherit-parent` and `auto` call `spawn_subagent` with `subagent_type: pstack:poteto-agent`, and every explicit descriptor, including every Grok descriptor, runs through the deterministic launcher with `--parent grok`. Every other provider runs through the deterministic launcher. Routed workflow skills set the task and access mode. Do not override their choices. **Defaults for every delegation.** Start independent lanes together. In the main session, run a steer or resume of a running agent in the background too, because a foreground call holds your turn while other reports wait; a Claude Code subagent waits as [Waits on Claude Code](references/codex-tools.md#waits-on-claude-code) says. Use file pointers rather than inlined dumps, preserve only the tools or MCPs the task needs, and assign every writer a worktree or unique output directory. `/setup-pstack` configures the descriptor per role. A role without a sheet line uses its [role default](references/model-catalog.md#role-defaults); the panel roles default to one model from each of three providers. Each code playbook's configured model comes from its line (`feature, refactoring`, `bug-fix`, `perf-issue`, or `hillclimb`), and the hardest changes read `hardest tasks`. Prose and judgment read `judgment and prose`. `inherit-parent` and `auto` use the parent model natively and reduce provider diversity when used in a panel. diff --git a/plugins/pstack/skills/poteto-mode/references/codex-tools.md b/plugins/pstack/skills/poteto-mode/references/codex-tools.md index 9ce86a95..1922df81 100644 --- a/plugins/pstack/skills/poteto-mode/references/codex-tools.md +++ b/plugins/pstack/skills/poteto-mode/references/codex-tools.md @@ -4,11 +4,12 @@ pstack skills retain Claude Code tool language (`Skill`, `Agent`, `AskUserQuesti ## Harness config homes -Resolve the current parent's config home once: Claude Code uses nonempty `CLAUDE_CONFIG_DIR`, otherwise `$HOME/.claude`; Codex uses nonempty `CODEX_HOME`, otherwise `$HOME/.codex`. The equivalent quoted shell expressions are: +Resolve the current parent's config home once: Claude Code uses nonempty `CLAUDE_CONFIG_DIR`, otherwise `$HOME/.claude`; Codex uses nonempty `CODEX_HOME`, otherwise `$HOME/.codex`; Grok uses nonempty `GROK_HOME`, otherwise `$HOME/.grok`. The equivalent quoted shell expressions are: ```bash "${CLAUDE_CONFIG_DIR:-$HOME/.claude}" "${CODEX_HOME:-$HOME/.codex}" +"${GROK_HOME:-$HOME/.grok}" ``` Empty and unset variables both select the default. Preserve spaces in the resolved path by quoting shell file operands. In shared instructions, `` means this resolved parent-specific directory, not literal text to write. Use that same home for setup's sheet and integration writes, snapshots, restoration, and readback. @@ -50,22 +51,22 @@ The copy registers under its plain name beside the plugin's `pstack:`, an ## Tool actions -| pstack / Claude action | Codex equivalent | -|------------------------|------------------| -| Read a file | `shell` (`cat`, `head`, `tail`) | -| Create / edit / delete a file | `apply_patch` | -| Run a shell command | `shell` | -| Search file contents / find files | `shell` (`rg`, `grep`, `find`, `ls`) | -| Fetch a URL | `shell` with `curl` / `wget` | -| Search the web | `web_search` | -| Invoke a skill (the `Skill` tool, `/command`) | Skills load natively. Follow the instructions presented. | -| `paths` frontmatter scopes automatic loading | Claude Code only. On Codex, invoke `pstack:typescript-best-practices` by name. | -| Dispatch a subagent (the `Agent`/`Task` tool) | `spawn_agent` | -| Dispatch N parallel subagents in one turn | N `spawn_agent` calls in one response | -| Wait for a subagent result | `wait_agent` | -| Free a finished subagent slot | `close_agent` | -| Track tasks (the todolist / `TodoWrite`) | `update_plan` | -| Ask the human a fixed-choice question (`AskUserQuestion`) | Ask in plain text and let the user answer. Codex has no structured-choice tool. | +| pstack / Claude action | Codex equivalent | Grok equivalent | +|------------------------|------------------|-----------------| +| Read a file | `shell` (`cat`, `head`, `tail`) | `read_file` | +| Create / edit / delete a file | `apply_patch` | `search_replace` | +| Run a shell command | `shell` | `run_terminal_command` | +| Search file contents / find files | `shell` (`rg`, `grep`, `find`, `ls`) | `grep`, `list_dir` | +| Fetch a URL | `shell` with `curl` / `wget` | `web_fetch` | +| Search the web | `web_search` | `web_search` | +| Invoke a skill (the `Skill` tool, `/command`) | Skills load natively. Follow the instructions presented. | Skills load natively. Follow the instructions presented. | +| `paths` frontmatter scopes automatic loading | Claude Code only. On Codex, invoke `pstack:typescript-best-practices` by name. | Invoke `pstack:typescript-best-practices` by name. | +| Dispatch a subagent (the `Agent`/`Task` tool) | `spawn_agent` | `spawn_subagent` with `subagent_type: pstack:poteto-agent` | +| Dispatch N parallel subagents in one turn | N `spawn_agent` calls in one response | N `spawn_subagent` calls in one response | +| Wait for a subagent result | `wait_agent` | `get_command_or_subagent_output`, as [Waits on Grok](#waits-on-grok) says | +| Free a finished subagent slot | `close_agent` | None | +| Track tasks (the todolist / `TodoWrite`) | `update_plan` | `todo_write` | +| Ask the human a fixed-choice question (`AskUserQuestion`) | Ask in plain text and let the user answer. Codex has no structured-choice tool. | `ask_user_question` | Subagent dispatch needs `multi_agent` enabled. Add to `/config.toml`, using Codex's [config-home rule](#harness-config-homes): @@ -83,6 +84,7 @@ poteto-mode's Subagents section sets the Claude-specific default `subagent_type: - There is no `poteto-agent` subagent type. Route an ad-hoc subagent through poteto-mode's style by dispatching a `spawn_agent` whose instructions tell it to read the `poteto-mode` skill in full first. - `spawn_agent` calls already run concurrently with your turn, so `run_in_background: true` has no separate flag. Issue the dispatch and continue. - There is no `comment-sicko` subagent type either. The **no-comments** skill spawns it on Claude Code; on Codex dispatch a `spawn_agent` whose instructions tell it to read `agents/comment-sicko.md` in full first. +- On Grok, the pstack subagent types are `pstack:poteto-agent` and `pstack:comment-sicko`. The Grok manifest does not offer the `pstack--` Claude lane agents. - A Claude Code native lane cannot start agents: each `agents/pstack-*.md` lane disallows `Agent` and `Task`. - Claude Code runs every subagent on this machine, so the **swarm** skill's workers and the fan-out playbooks (`orchestrate`, `autopilot-full`, `autopilot-stack`) isolate writers with worktrees. The same holds on Codex. - Keep the rest of the policy unchanged. Pass file pointers not inlined context, give each worker its own worktree or branch when they write, review every subagent's diff yourself. @@ -97,9 +99,19 @@ poteto-mode's Subagents section sets the Claude-specific default `subagent_type: - These windows bound one observation call. They are never a lane or runner deadline, and never go to `pstack-runner --timeout`. - Codex is unaffected: `spawn_agent` runs concurrently, `wait_agent` blocks, and exec sessions return a handle. +## Waits on Grok + +- Background shell: `run_terminal_command` with `block_until_ms: 0` returns a task ID. Start the external launcher this way. +- Native lanes: `spawn_subagent` runs in the background and returns a subagent ID. Retain every task and subagent ID. +- Waiting: `get_command_or_subagent_output` with retained IDs and a positive `timeout_ms`. Grok clamps one wait to 1 hour and takes at most 20 IDs. If a lane is still running when the wait returns, wait again on the same ID. Never kill a lane because it runs long. +- Cancelling: `kill_command_or_subagent` with the retained ID, then read the runner lane's retained receipt. +- Writers: give a writer a prepared checkout with `cwd` and `isolation: none`. Use `isolation: worktree` only where the calling skill allows a checkout that Grok creates. +- MCP reaches native lanes only. Read-only on a native lane is advisory: add "Do not edit files." to its task and record it as advisory. +- Children are depth one and cannot spawn. A child returns any further delegation it needs to the parent. + ## Models and providers -Do not replace every configured entry with a Codex model. `/setup-pstack` writes portable descriptors, such as the [role defaults](model-catalog.md#role-defaults). In a Codex parent, only `codex:*` is native. Route Claude and Grok descriptors through the external launcher exactly as `provider-dispatch.md` specifies. The default panel runs one model from each of three providers and contains no older GPT or Claude substitute. +Do not replace every configured entry with a Codex model. `/setup-pstack` writes portable descriptors, such as the [role defaults](model-catalog.md#role-defaults). In a Codex parent, only `codex:*` is native. Route Claude and Grok descriptors through the external launcher exactly as `provider-dispatch.md` specifies. In a Grok parent, only `inherit-parent` and `auto` use `spawn_subagent`. Every explicit descriptor, including every `grok:*` descriptor, uses the runner with `--parent grok`. The runner still rejects a Claude provider from a Claude parent and a Codex provider from a Codex parent. The default panel runs one model from each of three providers and contains no older GPT or Claude substitute. ## Claude built-in skills pstack references @@ -114,7 +126,7 @@ Some triggers name skills that ship with Claude Code, not pstack. They do not ex ## Vendored scripts -`skills/poteto-mode/scripts/` ships the `watch-pr` PR watcher, `worktree-audit.sh`, and `runner/pstack-runner`. They are plain bun and bash, so they run the same on Codex; invoke them through `shell`. The external runner additionally needs the assigned `claude`, `codex`, or `grok` executable already authenticated. It rejects a Codex provider when Codex is the parent because that lane belongs on native `spawn_agent`. The other scripts need `bun`, `gh` (or Origin's `origin` CLI where the playbook resolves it), and (for `worktree-audit.sh`) `jq` and `rg`. `worktree-audit.sh` reads Claude Code transcripts under `~/.claude/projects/`; point it at your runtime's transcript directory instead when you run it elsewhere. +`skills/poteto-mode/scripts/` ships the `watch-pr` PR watcher, `worktree-audit.sh`, and `runner/pstack-runner`. They are plain bun and bash, so they run the same on Codex; invoke them through `shell`. The external runner additionally needs the assigned `claude`, `codex`, or `grok` executable already authenticated. It rejects a Codex provider when Codex is the parent because that lane belongs on native `spawn_agent`. It rejects a Claude provider when Claude Code is the parent for the same reason. A Grok parent runs every explicit Grok descriptor through the runner. The other scripts need `bun`, `gh` (or Origin's `origin` CLI where the playbook resolves it), and (for `worktree-audit.sh`) `jq` and `rg`. `worktree-audit.sh` reads Claude Code transcripts under `~/.claude/projects/`; point it at your runtime's transcript directory instead when you run it elsewhere. ## Instructions file diff --git a/plugins/pstack/skills/poteto-mode/references/model-catalog.md b/plugins/pstack/skills/poteto-mode/references/model-catalog.md index e1976c62..5857bcaf 100644 --- a/plugins/pstack/skills/poteto-mode/references/model-catalog.md +++ b/plugins/pstack/skills/poteto-mode/references/model-catalog.md @@ -63,18 +63,18 @@ Rolling aliases: ## Routes -| Provider | Claude Code parent | Codex parent | Access modes | Prompt | Model proof | Child environment | Host prerequisites | -| --- | --- | --- | --- | --- | --- | --- | --- | -| claude | native `Agent` | external runner | `read-only`, `isolated-write` | stdin | provider report | read-only: inherited; isolated-write: inherited without `GIT_*` | - | -| codex | external runner | native `spawn_agent` | `read-only`, `isolated-write`, `isolated-write-full-access` | stdin | pinned argv | inherited | - | -| grok | external runner | external runner | `read-only`, `isolated-write` | file | provider report | allowlist | Linux: Landlock and `bwrap`; macOS: any `/var/run/docker.sock` symlink points inside Grok's runtime-socket list | -| devin | external runner | external runner | `read-only`, `isolated-write`, `isolated-write-full-access` | file | provider report | allowlist | - | -| opencode | external runner | external runner | `read-only`, `isolated-write-full-access` | stdin | pinned argv | allowlist | - | -| dsh | external runner | external runner | `read-only`, `isolated-write` | stdin | pinned argv | allowlist | dsh 0.2.1-rc.2 exactly | -| amp | external runner | external runner | `read-only`, `isolated-write-full-access` | stdin | pinned argv | allowlist | - | -| agy | external runner | external runner | `read-only`, `isolated-write-full-access` | stdin | pinned argv | allowlist | - | -| cursor | external runner | external runner | `read-only`, `isolated-write-full-access` | stdin | pinned argv | allowlist | - | -| muse | external runner | external runner | `read-only`, `isolated-write-full-access` | file | pinned argv | allowlist | - | +| Provider | Claude Code parent | Codex parent | Grok parent | Access modes | Prompt | Model proof | Child environment | Host prerequisites | +| --- | --- | --- | --- | --- | --- | --- | --- | --- | +| claude | native `Agent` | external runner | external runner | `read-only`, `isolated-write` | stdin | provider report | read-only: inherited; isolated-write: inherited without `GIT_*` | - | +| codex | external runner | native `spawn_agent` | external runner | `read-only`, `isolated-write`, `isolated-write-full-access` | stdin | pinned argv | inherited | - | +| grok | external runner | external runner | explicit descriptor: external runner; `inherit-parent`, `auto`: native `spawn_subagent` | `read-only`, `isolated-write` | file | provider report | allowlist | Linux: Landlock and `bwrap`; macOS: any `/var/run/docker.sock` symlink points inside Grok's runtime-socket list | +| devin | external runner | external runner | external runner | `read-only`, `isolated-write`, `isolated-write-full-access` | file | provider report | allowlist | - | +| opencode | external runner | external runner | external runner | `read-only`, `isolated-write-full-access` | stdin | pinned argv | allowlist | - | +| dsh | external runner | external runner | external runner | `read-only`, `isolated-write` | stdin | pinned argv | allowlist | dsh 0.2.1-rc.2 exactly | +| amp | external runner | external runner | external runner | `read-only`, `isolated-write-full-access` | stdin | pinned argv | allowlist | - | +| agy | external runner | external runner | external runner | `read-only`, `isolated-write-full-access` | stdin | pinned argv | allowlist | - | +| cursor | external runner | external runner | external runner | `read-only`, `isolated-write-full-access` | stdin | pinned argv | allowlist | - | +| muse | external runner | external runner | external runner | `read-only`, `isolated-write-full-access` | file | pinned argv | allowlist | - | The runner refuses an access mode outside a provider's Access modes cell before launch. `isolated-write-full-access` is a writer capability, not a `--mode` value: the runner takes it as `--mode isolated-write --writer-sandbox full-access`, which a parent passes only when the model sheet opts in, as [`provider-dispatch.md`](provider-dispatch.md#writer-sandbox) says. A provider whose cell lists `isolated-write-full-access` without `isolated-write` has no confined writer and writes only with that opt-in. diff --git a/plugins/pstack/skills/poteto-mode/references/provider-dispatch.md b/plugins/pstack/skills/poteto-mode/references/provider-dispatch.md index 0b05aa9e..1685fd6e 100644 --- a/plugins/pstack/skills/poteto-mode/references/provider-dispatch.md +++ b/plugins/pstack/skills/poteto-mode/references/provider-dispatch.md @@ -14,7 +14,7 @@ The allowed effort universe is exactly `low`, `medium`, `high`, `xhigh`, `max`, A rolling alias, such as `opus`, names a family whose CLI resolves it to the latest available family revision. A runner receipt keeps the requested alias in `model` and the concrete provider-reported revision in `reportedModel`; verification accepts only a revision of the matching family, as the catalog's rolling-alias table gives it. -A descriptor whose `(provider, model)` matches no matrix row, kept predecessor, or rolling-alias revision is an unregistered pair. It runs unchanged on an external route and on Codex's native route. A Claude-native route needs a generated agent, so an unregistered Claude model on a Claude Code parent is a dropout. Never guess a replacement. +A descriptor whose `(provider, model)` matches no matrix row, kept predecessor, or rolling-alias revision is an unregistered pair. It runs unchanged on an external route and on Codex's native route. A Claude-native route needs a generated agent, so an unregistered Claude model on a Claude Code parent is a dropout. An unregistered Grok model on a Grok parent uses the runner like every explicit Grok descriptor. Never guess a replacement. ## Read-time normalization @@ -28,7 +28,9 @@ A kept predecessor in the catalog matches its family. Dispatch it with the sheet The top-level harness resolves the route once. A child receives an assigned provider, model, effort, access mode, prompt, working directory, and output path. A child never detects the harness, chooses a provider, or launches another model. Environment markers may corroborate the top-level harness before fan-out, but nested processes inherit parent markers and must not use them for routing. -The catalog's [route table](model-catalog.md#routes) gives each provider's route from each parent. Outside [T3](#t3-host), a provider native to the parent uses the parent's subagent primitive. Every other provider uses the external runner. +The catalog's [route table](model-catalog.md#routes) gives each provider's route from each parent. Outside [T3](#t3-host), a Claude or Codex provider on its own parent uses that parent's subagent primitive, and every other provider from those parents uses the external runner. A Grok parent uses `spawn_subagent` for `inherit-parent` and `auto` only. Every explicit descriptor from a Grok parent, including every `grok:*` descriptor, registered or not, uses the external runner with `--parent grok`. + +A Grok parent reads its model sheet from `/rules/pstack-models.md`, with `` resolved by the [config-home rule](codex-tools.md#harness-config-homes). Grok loads that file as a home rule. It takes precedence over any model text Grok imports from Claude instructions. A malformed Grok sheet is a visible stop, never a reason to use the imported text or the role defaults. `inherit-parent` and `auto` remain aliases. Outside [T3](#t3-host), they use the parent's current model and effort through its native subagent primitive. In a panel they still consume one lane, but they reduce provider diversity; say so in the synthesis record. @@ -78,8 +80,9 @@ Native dispatch avoids a second CLI startup and its base context. - Claude Code: match the descriptor's `(provider, model)` to one model-matrix row, then dispatch it through `pstack--` using that row's family and the descriptor's effort. Those definitions select the row's model and requested effort. `pstack-fable-max` and `pstack-opus-xhigh` remain in that set. Pass the complete task, grounding paths, access mode, and unique output location in the `Agent` prompt. Dispatch and wait as [Waits on Claude Code](codex-tools.md#waits-on-claude-code) says. - Codex: call `spawn_agent` with the descriptor's model and `reasoning_effort`, the complete task, grounding paths, access mode, and unique output location. Use an isolated worktree for a writer. Codex subagents already run concurrently. +- Grok: for `inherit-parent` and `auto` only, call `spawn_subagent` with `subagent_type: pstack:poteto-agent` and the complete task, grounding paths, access mode, and unique output location in the prompt. Pass no `model`. A Grok `[subagents.models]`, role, or persona override for that agent type changes this lane's model; the parent records the override with the lane. Dispatch, isolate, and wait as [Grok tools](codex-tools.md#waits-on-grok) says. A native child inherits the parent session's permission mode. A headless Grok parent uses auto mode, because headless Grok cancels the turn on a permission prompt and auto mode reports the blocked call to the model instead. -Do not send a same-provider descriptor to the external runner. It rejects that call because the native route is cheaper and already available. +Do not send a Claude or Codex descriptor to the external runner from that same parent. The runner rejects that call because the native route is available. A Grok parent sends every explicit `grok:*` descriptor to the runner with `--parent grok --provider grok`, because a Grok agent file that names a model Grok cannot serve runs the parent's model without an error, and the runner's receipt proves the served model. That launch sets `--permission-mode auto`, the model, and the effort. A failed native lane is never retried through the runner. ## External lanes @@ -87,7 +90,7 @@ The launcher lives at `skills/poteto-mode/scripts/runner/pstack-runner` under th ```text pstack-runner \ - --parent \ + --parent \ --provider \ --model \ --effort \ @@ -122,6 +125,7 @@ The parent invocation must itself be resumable background work: - Claude Code: call the launcher through a Bash tool invocation with `run_in_background: true` and retain its task ID. A foreground Bash tool call has an automatic ten-minute ceiling even when the runner's own timeout is longer. Shelling out with `&` and losing the task handle is not equivalent. A subagent that must wait on the launcher follows [Waits on Claude Code](codex-tools.md#waits-on-claude-code). - Codex: run the launcher in a persistent exec session that returns a session ID, then wait or poll that handle. Do not hold one foreground tool call open for the model's full runtime. +- Grok: start the launcher as background work and wait on its task ID as [Waits on Grok](codex-tools.md#waits-on-grok) says. Do not hold one foreground tool call open for the model's full runtime. Start the background process, continue launching the other lanes, then drain their handles. Native and external lanes belong in the same fan-out phase. diff --git a/plugins/pstack/skills/poteto-mode/scripts/runner/cli.test.ts b/plugins/pstack/skills/poteto-mode/scripts/runner/cli.test.ts index 6a8ab6e9..1a651669 100644 --- a/plugins/pstack/skills/poteto-mode/scripts/runner/cli.test.ts +++ b/plugins/pstack/skills/poteto-mode/scripts/runner/cli.test.ts @@ -79,8 +79,11 @@ describe("runner CLI parsing", () => { let stdout = ""; expect(await main(["--help"], Date.now(), { stdout: (value) => { stdout += value; }, stderr: () => {} })).toBe(0); expect(stdout).toStartWith( - "Usage: pstack-runner --parent --provider \\\n" + "Usage: pstack-runner --parent --provider \\\n" ); + const help = stdout.replaceAll("\n", " "); + expect(help).toContain("A Grok parent runs every explicit Grok descriptor here"); + expect(help).toContain("A Claude or Codex parent rejects its own provider"); }); it("refuses an unnormalized Claude revision with a usage error", async () => { diff --git a/plugins/pstack/skills/poteto-mode/scripts/runner/cli.ts b/plugins/pstack/skills/poteto-mode/scripts/runner/cli.ts index ed599fe1..23fbdbbd 100644 --- a/plugins/pstack/skills/poteto-mode/scripts/runner/cli.ts +++ b/plugins/pstack/skills/poteto-mode/scripts/runner/cli.ts @@ -9,11 +9,12 @@ const HELP = `Usage: pstack-runner --parent <${Object.keys(PARENTS).join("|")}> [--writer-sandbox ] \\ --prompt --cwd --output --receipt [--timeout ] -Runs exactly one external model lane. Same-provider calls are rejected; use the -parent harness's native subagent primitive for those lanes. Output and receipt -paths must not already exist. There is no implicit timeout. Pass --timeout only -when the user or task supplies a real deadline; it is one end-to-end launcher -deadline shared by setup, preflight, and model execution. --writer-sandbox +Runs exactly one external model lane. A Claude or Codex parent rejects its own +provider; use that parent's native subagent primitive. A Grok parent runs every +explicit Grok descriptor here. Output and receipt paths must not already exist. +There is no implicit timeout. Pass --timeout only when the user or task supplies +a real deadline; it is one end-to-end launcher deadline shared by setup, +preflight, and model execution. --writer-sandbox defaults to bounded; full-access applies only to an isolated-write lane on a provider that supports it, and only when the model sheet opts in. `; diff --git a/plugins/pstack/skills/poteto-mode/scripts/runner/registry.test.ts b/plugins/pstack/skills/poteto-mode/scripts/runner/registry.test.ts index 3e5c749b..857b3072 100644 --- a/plugins/pstack/skills/poteto-mode/scripts/runner/registry.test.ts +++ b/plugins/pstack/skills/poteto-mode/scripts/runner/registry.test.ts @@ -1,6 +1,6 @@ import { afterEach, describe, expect, it } from "bun:test"; import { createHash } from "node:crypto"; -import { mkdtempSync, readdirSync, readFileSync, rmSync, writeFileSync } from "node:fs"; +import { mkdirSync, mkdtempSync, readdirSync, readFileSync, rmSync, writeFileSync } from "node:fs"; import { tmpdir } from "node:os"; import { dirname, join, relative, resolve } from "node:path"; import { @@ -15,6 +15,7 @@ import { CATALOG_PATH, driftMessage, driftReport, + GROK_AGENTS_DIR, main, renderCatalog, renderNativeAgents, @@ -22,7 +23,7 @@ import { writeGenerated, } from "./registry/render.ts"; import { type Access, access } from "./registry/contract.ts"; -import { EFFORTS } from "./types.ts"; +import { EFFORTS, PARENTS, type Parent } from "./types.ts"; const RUNNER = import.meta.dir; const PLUGIN_ROOT = join(RUNNER, "../../../.."); @@ -148,10 +149,14 @@ describe("registry data", () => { const routes = renderCatalog(REGISTRY).content.split("\n## Routes\n")[1]!.split("\n## ")[0]!; for (const adapter of REGISTRY.adapters) { const cells = routes.split("\n").find((line) => line.startsWith(`| ${adapter.id} |`))!.split(" | "); - expect(cells.slice(1, 3)).toEqual([ + expect(cells.slice(1, 4)).toEqual([ adapter.id === "claude" ? "native `Agent`" : "external runner", adapter.id === "codex" ? "native `spawn_agent`" : "external runner", + adapter.id === "grok" + ? "explicit descriptor: external runner; `inherit-parent`, `auto`: native `spawn_subagent`" + : "external runner", ]); + expect(routes).toContain("| Provider | Claude Code parent | Codex parent | Grok parent |"); } }); }); @@ -199,10 +204,16 @@ describe("generated files", () => { writeFileSync(join(dir, "agents", name), `${name}\n`); } + mkdirSync(join(dir, GROK_AGENTS_DIR)); + writeFileSync(join(dir, GROK_AGENTS_DIR, "pstack-opus-low.md"), "lane\n"); + expect(driftReport(REGISTRY, dir)).toEqual([ `stale ${CATALOG_PATH}`, `missing ${agent!.path}`, + `missing ${GROK_AGENTS_DIR}/poteto-agent.md`, + `missing ${GROK_AGENTS_DIR}/comment-sicko.md`, "stray agents/pstack-retired-max.md", + `stray ${GROK_AGENTS_DIR}/pstack-opus-low.md`, ]); expect(driftMessage(driftReport(REGISTRY, dir))).toContain(WRITE_COMMAND); expect(main(["--check"], dir)).toBe(1); @@ -211,6 +222,8 @@ describe("generated files", () => { expect(driftReport(REGISTRY, dir)).toEqual([]); expect(readFileSync(join(dir, "agents/poteto-agent.md"), "utf8")).toBe("poteto-agent.md\n"); expect(readFileSync(join(dir, "agents/comment-sicko.md"), "utf8")).toBe("comment-sicko.md\n"); + expect(readdirSync(join(dir, GROK_AGENTS_DIR)).sort()).toEqual(["comment-sicko.md", "poteto-agent.md"]); + expect(readFileSync(join(dir, GROK_AGENTS_DIR, "poteto-agent.md"), "utf8")).toBe("poteto-agent.md\n"); expect(main([], dir)).toBe(64); }); }); @@ -229,6 +242,9 @@ describe("hand-written dispatch and setup rules", () => { const native = section(dispatch, "## Native lanes", "## External lanes"); expect(native).toContain("match the descriptor's `(provider, model)` to one model-matrix row"); expect(native).toContain("`pstack--`"); + expect(native).toContain("`spawn_subagent`"); + expect(native).toContain("A headless Grok parent uses auto mode"); + expect(dispatch).toContain("--parent "); }); it("normalizes rolling-alias pins and keeps predecessors before any runtime route", () => { @@ -547,10 +563,37 @@ describe("resolveLane", () => { expect(() => resolveLane(request({ parent: "claude" }))) .toThrow("provider claude is native to parent claude; use the parent subagent primitive"); expect(() => resolveLane(request({ provider: "kiro" }))).toThrow("provider must be one of: claude, codex, grok, devin, opencode, dsh, amp, agy, cursor, muse"); - expect(() => resolveLane(request({ parent: "grok" }))).toThrow("parent must be one of: claude, codex"); + expect(() => resolveLane(request({ parent: "kiro" }))).toThrow("parent must be one of: claude, codex, grok"); expect(() => resolveLane(request({ mode: "full" }))).toThrow("mode must be one of: read-only, isolated-write"); expect(() => resolveLane(request({ model: " " }))).toThrow("model is required"); }); + + it("accepts a grok parent for every provider, including grok, and keeps auto mode on that lane", () => { + const parents = Object.keys(PARENTS) as Parent[]; + for (const parent of parents) { + for (const adapter of REGISTRY.adapters) { + const family = REGISTRY.families.find((entry) => entry.provider === adapter.id); + const laneRequest = request({ + parent, + provider: adapter.id, + model: family?.model ?? "any", + effort: family?.defaultEffort ?? "default", + }); + const sameProvider = adapter.id === parent; + if (sameProvider && PARENTS[parent].explicitLaneRoute === "native") { + expect(() => resolveLane(laneRequest)).toThrow("use the parent subagent primitive"); + continue; + } + const lane = resolveLane(laneRequest); + expect(lane.parent).toBe(parent); + expect(lane.adapter.id).toBe(adapter.id); + if (parent === "grok" && adapter.id === "grok") { + expect(lane.adapter.launch.argv(lane, { sharedGitDir: null, providerSidecar: null })) + .toEqual(expect.arrayContaining(["--permission-mode", "auto"])); + } + } + } + }); }); describe("module boundaries", () => { diff --git a/plugins/pstack/skills/poteto-mode/scripts/runner/registry/index.ts b/plugins/pstack/skills/poteto-mode/scripts/runner/registry/index.ts index 25973961..a03e1e30 100644 --- a/plugins/pstack/skills/poteto-mode/scripts/runner/registry/index.ts +++ b/plugins/pstack/skills/poteto-mode/scripts/runner/registry/index.ts @@ -71,7 +71,7 @@ export function resolveLane(request: LaneRequest): Lane { } if (request.model.trim().length === 0) throw new UsageError("model is required"); const effort = oneOf("effort", request.effort, EFFORTS); - if (adapter.id === parent) { + if (adapter.id === parent && PARENTS[parent].explicitLaneRoute === "native") { throw new UsageError( `provider ${adapter.id} is native to parent ${parent}; use the parent subagent primitive` ); diff --git a/plugins/pstack/skills/poteto-mode/scripts/runner/registry/render.ts b/plugins/pstack/skills/poteto-mode/scripts/runner/registry/render.ts index 957ddbd3..a579d35b 100644 --- a/plugins/pstack/skills/poteto-mode/scripts/runner/registry/render.ts +++ b/plugins/pstack/skills/poteto-mode/scripts/runner/registry/render.ts @@ -14,6 +14,8 @@ export const WRITE_COMMAND = "bun runner/registry/render.ts --write"; const WRITE_HINT = `Run \`${WRITE_COMMAND}\` from plugins/pstack/skills/poteto-mode/scripts.`; const PLUGIN_ROOT = join(import.meta.dir, "../../../../.."); const AGENT = /^pstack-.*\.md$/; +export const GROK_AGENTS_DIR = "grok-agents"; +const GROK_AGENTS = ["poteto-agent", "comment-sicko"] as const; const SHEET_HEADER = [ "# pstack model configuration", @@ -30,7 +32,11 @@ const table = (header: readonly string[], rows: readonly (readonly string[])[]): ]; function route(adapter: ProviderAdapter, parent: Parent): string { - return adapter.id === parent ? `native ${code(PARENTS[parent].native)}` : "external runner"; + if (adapter.id !== parent) return "external runner"; + const native = `native ${code(PARENTS[parent].native)}`; + return PARENTS[parent].explicitLaneRoute === "native" + ? native + : `explicit descriptor: external runner; ${code("inherit-parent")}, ${code("auto")}: ${native}`; } function policy(value: EnvPolicy, long: boolean): string { @@ -193,8 +199,15 @@ export function renderNativeAgents(registry: Registry): readonly GeneratedFile[] ); } -function generated(registry: Registry): readonly GeneratedFile[] { - return [renderCatalog(registry), ...renderNativeAgents(registry)]; +export function renderGrokAgents(pluginRoot: string): readonly GeneratedFile[] { + return GROK_AGENTS.flatMap((name) => { + const content = read(join(pluginRoot, `agents/${name}.md`)); + return content === null ? [] : [{ path: `${GROK_AGENTS_DIR}/${name}.md`, content }]; + }); +} + +function generated(registry: Registry, pluginRoot: string): readonly GeneratedFile[] { + return [renderCatalog(registry), ...renderNativeAgents(registry), ...renderGrokAgents(pluginRoot)]; } function read(path: string): string | null { @@ -205,18 +218,22 @@ function read(path: string): string | null { } } +function listed(pluginRoot: string, dir: string, pattern: RegExp): string[] { + const path = join(pluginRoot, dir); + return (existsSync(path) ? readdirSync(path) : []).filter((name) => pattern.test(name)).map((name) => `${dir}/${name}`); +} + function strays(registry: Registry, pluginRoot: string): string[] { - const expected = new Set(renderNativeAgents(registry).map((file) => file.path)); - const agents = join(pluginRoot, "agents"); - return (existsSync(agents) ? readdirSync(agents) : []) - .filter((name) => AGENT.test(name)) - .map((name) => `agents/${name}`) + const expected = new Set( + [...renderNativeAgents(registry), ...renderGrokAgents(pluginRoot)].map((file) => file.path) + ); + return [...listed(pluginRoot, "agents", AGENT), ...listed(pluginRoot, GROK_AGENTS_DIR, /\.md$/)] .filter((path) => !expected.has(path)) .sort(); } export function driftReport(registry: Registry, pluginRoot: string): readonly string[] { - const report = generated(registry).flatMap((file) => { + const report = generated(registry, pluginRoot).flatMap((file) => { const actual = read(join(pluginRoot, file.path)); if (actual === null) return [`missing ${file.path}`]; return actual === file.content ? [] : [`stale ${file.path}`]; @@ -229,7 +246,7 @@ export function driftMessage(report: readonly string[]): string { } export function writeGenerated(registry: Registry, pluginRoot: string): void { - for (const file of generated(registry)) { + for (const file of generated(registry, pluginRoot)) { const path = join(pluginRoot, file.path); if (read(path) === file.content) continue; mkdirSync(dirname(path), { recursive: true }); 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 20e7095b..0cf0b7a1 100644 --- a/plugins/pstack/skills/poteto-mode/scripts/runner/run.test.ts +++ b/plugins/pstack/skills/poteto-mode/scripts/runner/run.test.ts @@ -1969,6 +1969,25 @@ describe("childEnvironment", () => { CODEX_CI: "1", KEEP_ME: "yes", }); + const grokParent = { + ...source, + GROK_SESSION_ID: "sess", + GROK_AGENT: "agent", + GROK_HOME: "/grok", + }; + expect(childEnvironment(laneOf({ ...options("claude"), parent: "grok", mode: "read-only" }), grokParent)).toEqual({ + PATH: "/bin", + CODEX_THREAD_ID: "codex", + CODEX_CI: "1", + CLAUDECODE: "1", + CLAUDE_CODE_CHILD_SESSION: "1", + KEEP_ME: "yes", + GROK_HOME: "/grok", + }); + expect(childEnvironment(laneOf({ ...options("grok"), parent: "grok", mode: "read-only" }), grokParent)).toEqual({ + PATH: "/bin", + GROK_HOME: "/grok", + }); }); it("passes a Grok lane only allowlisted variables in both modes", () => { diff --git a/plugins/pstack/skills/poteto-mode/scripts/runner/types.ts b/plugins/pstack/skills/poteto-mode/scripts/runner/types.ts index 902e13dc..7f74a058 100644 --- a/plugins/pstack/skills/poteto-mode/scripts/runner/types.ts +++ b/plugins/pstack/skills/poteto-mode/scripts/runner/types.ts @@ -2,6 +2,7 @@ export const PARENTS = { claude: { harness: "Claude Code", native: "Agent", + explicitLaneRoute: "native", identity: [ "CLAUDECODE", "CLAUDE_CODE_CHILD_SESSION", @@ -12,6 +13,7 @@ export const PARENTS = { codex: { harness: "Codex", native: "spawn_agent", + explicitLaneRoute: "native", identity: [ "CODEX_THREAD_ID", "CODEX_SESSION_ID", @@ -22,6 +24,12 @@ export const PARENTS = { "CODEX_INTERNAL_ORIGINATOR_OVERRIDE", ], }, + grok: { + harness: "Grok", + native: "spawn_subagent", + explicitLaneRoute: "runner", + identity: ["GROK_SESSION_ID", "GROK_AGENT"], + }, } as const; export const EFFORTS = ["low", "medium", "high", "xhigh", "max", "ultra", "default"] as const; export const ACCESS_MODES = ["read-only", "isolated-write"] as const; diff --git a/plugins/pstack/skills/setup-pstack/SKILL.md b/plugins/pstack/skills/setup-pstack/SKILL.md index 8bf84e39..3f94470b 100644 --- a/plugins/pstack/skills/setup-pstack/SKILL.md +++ b/plugins/pstack/skills/setup-pstack/SKILL.md @@ -25,15 +25,17 @@ Codex writes `/pstack-models.md`. Codex has no `@` include, so mirr ``` +Grok writes `/rules/pstack-models.md`. Grok loads every Markdown file in that home rules directory, so the sheet needs no include line or mirror. On Grok, `/pstack-models.md` in the steps below means this path. + ## Steps ### 1. Establish the parent -Use the harness and tool surface running this skill: Claude Code, Codex, or T3 Code running one of them. Environment markers may corroborate that top-level answer, but do not launch a child and ask it to detect where it came from. Record the parent because the same descriptor takes a different route in each harness. On T3, record the orchestration harness and the model runtime as [T3 host](../poteto-mode/references/provider-dispatch.md#t3-host) says. The runtime, Claude Code or Codex, selects ``, the sheet, and the integration below. +Use the harness and tool surface running this skill: Claude Code, Codex, T3 Code running one of them, or Grok. Environment markers may corroborate that top-level answer, but do not launch a child and ask it to detect where it came from. Record the parent because the same descriptor takes a different route in each harness. On T3, record the orchestration harness and the model runtime as [T3 host](../poteto-mode/references/provider-dispatch.md#t3-host) says. The runtime, Claude Code or Codex, selects ``, the sheet, and the integration below. A Grok parent selects its own. ### 2. Load current state -Inspect `/pstack-models.md` and the existing integration: resolve Claude's single model-sheet import, or read Codex's single bounded model block, reporting missing targets and stopping on unreadable, malformed, or ambiguous state. Use the sole surviving configuration, or the editable sheet when both configurations agree; on Codex, the sheet remains authoritative and the block is its mirror, recoverable when the sheet is absent. If surviving configurations differ, show both sources and their differing assignments and require an explicit source choice before normalization or probing, without merging them automatically. Use first-run defaults only when neither source contains recoverable configuration, then apply the existing normalization, role-completion, and validation rules to the selected state. Before matrix validation, normalize only versioned predecessors of the catalog's [rolling aliases](../poteto-mode/references/model-catalog.md#kept-predecessors-and-normalization). Match in this order: an exact model-matrix row, then a kept predecessor, then a rolling-alias revision. A model that is its own matrix row, such as a registered exact pin, is never migratable. Any other provider-qualified Claude model is migratable when its model component starts with a rolling alias's versioned-predecessor prefix and the remaining revision contains only digits and hyphens. Replace that component in memory with the alias, preserving the provider, effort, role, and lane order. Record each original and normalized descriptor for the confirmation in step 7. This migration is valid loaded state and does not require a separate operator choice. +Inspect `/pstack-models.md` and the existing integration: resolve Claude's single model-sheet import, or read Codex's single bounded model block; Grok has no integration, reporting missing targets and stopping on unreadable, malformed, or ambiguous state. Use the sole surviving configuration, or the editable sheet when both configurations agree; on Codex, the sheet remains authoritative and the block is its mirror, recoverable when the sheet is absent. If surviving configurations differ, show both sources and their differing assignments and require an explicit source choice before normalization or probing, without merging them automatically. Use first-run defaults only when neither source contains recoverable configuration, then apply the existing normalization, role-completion, and validation rules to the selected state. Before matrix validation, normalize only versioned predecessors of the catalog's [rolling aliases](../poteto-mode/references/model-catalog.md#kept-predecessors-and-normalization). Match in this order: an exact model-matrix row, then a kept predecessor, then a rolling-alias revision. A model that is its own matrix row, such as a registered exact pin, is never migratable. Any other provider-qualified Claude model is migratable when its model component starts with a rolling alias's versioned-predecessor prefix and the remaining revision contains only digits and hyphens. Replace that component in memory with the alias, preserving the provider, effort, role, and lane order. Record each original and normalized descriptor for the confirmation in step 7. This migration is valid loaded state and does not require a separate operator choice. A line ` writer sandbox: full-access` or ` writer sandbox: bounded`, such as `codex writer sandbox: full-access`, is a setting, not a role; [Writer sandbox](../poteto-mode/references/provider-dispatch.md#writer-sandbox) says how a parent uses it. Set the setting lines aside before the role overlay, the retired-role rule, and descriptor validation, and carry them through steps 6 to 8, including the line of a provider that no role uses. A second line for one provider, a provider outside the [route table](../poteto-mode/references/model-catalog.md#routes), or another value is inconsistent state. @@ -74,11 +76,11 @@ Probe only the selected `provider:model@effort` pair of each assigned family. Ru If a Grok probe fails because the host cannot enforce its bounded sandbox, see [Host and parent prerequisites](../poteto-mode/references/provider-dispatch.md#host-and-parent-prerequisites); keep the active configuration unchanged. -Find the family's provider in the [route table](../poteto-mode/references/model-catalog.md#routes). A native route gets a native one-turn probe: on Claude Code, the family's `pstack--` agent; on Codex, `spawn_agent` with the descriptor's model and `reasoning_effort`. An external route gets one `pstack-runner` call with a tiny read-only probe prompt. The runner's preflight checks the CLI, the login and the model, and its receipt is the result. A T3 route gets one `delegate_task` probe, and its T3 result record must meet T3 host's completion rules. +Find the family's provider in the [route table](../poteto-mode/references/model-catalog.md#routes). A native-only route gets a native one-turn probe: on Claude Code, the family's `pstack--` agent; on Codex, `spawn_agent` with the descriptor's model and `reasoning_effort`. On Grok, `inherit-parent` and `auto` get a `spawn_subagent` probe with `subagent_type: pstack:poteto-agent`, and the parent records any Grok `[subagents.models]`, role, or persona override for that type. An external route gets one `pstack-runner` call with a tiny read-only probe prompt and `--parent` set to the parent running this skill. On a Grok parent every explicit descriptor, including every Grok descriptor, takes that call. The runner's preflight checks the CLI, the login and the model, and its receipt is the result. A T3 route gets one `delegate_task` probe, and its T3 result record must meet T3 host's completion rules. -Use a tiny read-only probe that returns a unique marker. For each family that an external writer role uses on a provider whose writer needs a `full-access` setting line, also run one `isolated-write` probe in a disposable linked worktree, with `--writer-sandbox full-access`, that creates a small program and runs it; it passes only when the program's output file holds the expected marker. A login-status command alone proves credentials, not that the requested model and effort flags run. Record native, external, and T3 results separately. Never call the external launcher for the parent's own provider. +Use a tiny read-only probe that returns a unique marker. For each family that an external writer role uses on a provider whose writer needs a `full-access` setting line, also run one `isolated-write` probe in a disposable linked worktree, with `--writer-sandbox full-access`, that creates a small program and runs it; it passes only when the program's output file holds the expected marker. A login-status command alone proves credentials, not that the requested model and effort flags run. Record native, external, and T3 results separately. Never call the external launcher for a Claude or Codex parent's own provider. -Receipts and native transcripts prove the requested effort and the route. T3 result records prove only T3's launch configuration. They do not prove a provider's hidden applied reasoning depth. There is no implicit timeout, weaker-model fallback, same-provider external fallback, or second mutable configuration source. +Receipts and native transcripts prove the requested effort and the route. T3 result records prove only T3's launch configuration. They do not prove a provider's hidden applied reasoning depth. There is no implicit timeout, weaker-model fallback, or second mutable configuration source. A failed native lane is not retried through the runner. ### 6. Render, preserving role families @@ -99,7 +101,7 @@ Why and Reflect require the parent's live MCP surface. Keep their investigator, Every non-alias role value must match `:@` and must have passed step 5. -After the operator confirms, or the qualifying rerun's probes all pass, separately check the parent's effective write permission for **both actual destinations** (the sheet and parent integration, resolved through `CODEX_HOME` when set) and any directory creation needed. Render both artifacts in memory and perform this check before the first write or directory creation. Native probes succeeding does not grant configuration-write permission. A restricted parent may still be allowed to write under `CODEX_HOME`; assess each resolved destination rather than rejecting it just because it is outside the workspace. If either destination is denied or its permission remains unknown, stop with the destination-specific explanation: leave every existing artifact byte-identical and create neither artifact on a first run. Do not test permission by overwriting a target, write only the allowed artifact, or automatically request escalation. +After the operator confirms, or the qualifying rerun's probes all pass, separately check the parent's effective write permission for **both actual destinations** (the sheet and parent integration, resolved through `CODEX_HOME` when set; on Grok, the sheet alone) and any directory creation needed. Render both artifacts in memory and perform this check before the first write or directory creation. Native probes succeeding does not grant configuration-write permission. A restricted parent may still be allowed to write under `CODEX_HOME`; assess each resolved destination rather than rejecting it just because it is outside the workspace. If either destination is denied or its permission remains unknown, stop with the destination-specific explanation: leave every existing artifact byte-identical and create neither artifact on a first run. Do not test permission by overwriting a target, write only the allowed artifact, or automatically request escalation. Only after both destinations are permitted, write the in-memory render from step 6 using step 8's snapshot and readback procedure. Never paste the role defaults as the result. They only seed step 2; selected efforts and explicit role changes always replace their values before writing. @@ -111,6 +113,8 @@ On Claude, the integration is the one `@` import line in `/CLAUDE.m On Codex, the integration is the exact sheet bytes between one `` and `` pair in `/AGENTS.md`. Replace that whole bounded block on a rerun. Insert one block at the end on first run. If either marker is missing, duplicated, or reversed, stop and report inconsistent state instead of guessing a boundary. +On Grok, there is no integration. Snapshot, write, and read back the sheet alone. + Snapshot every target's current bytes. Write the sheet and parent integration only after every requested pair passes and the operator confirms or the rerun qualifies. Read both targets back and compare them with the in-memory render. If either write or readback fails, restore every snapshot and report the failure. An unchanged rerun must produce byte-identical sheet and integration content after normalization. Do not copy the model sheet between harnesses without rerunning the parent-specific probes; route availability can differ even on the same host. diff --git a/tests/skill-collision-repro.sh b/tests/skill-collision-repro.sh index cdf3dedf..311d0073 100755 --- a/tests/skill-collision-repro.sh +++ b/tests/skill-collision-repro.sh @@ -36,12 +36,13 @@ fi verof() { { grep -m1 '"version"' "$1" || true; } | sed -E 's/.*"version"[[:space:]]*:[[:space:]]*"([^"]+)".*/\1/'; } vc="$(verof "$repo/plugins/pstack/.claude-plugin/plugin.json")" vx="$(verof "$repo/plugins/pstack/.codex-plugin/plugin.json")" +vg="$(verof "$repo/plugins/pstack/.grok-plugin/plugin.json")" vm="$(verof "$repo/.claude-plugin/marketplace.json")" vu="$(sed -n 's/| open-pstack version | `\([^`]*\)` |/\1/p' "$repo/UPSTREAM.md")" -if [ -n "$vc" ] && [ "$vc" = "$vx" ] && [ "$vc" = "$vm" ] && [ "$vc" = "$vu" ]; then - note "ok: open-pstack version matches across UPSTREAM.md and the 3 manifests ($vc)" +if [ -n "$vc" ] && [ "$vc" = "$vx" ] && [ "$vc" = "$vg" ] && [ "$vc" = "$vm" ] && [ "$vc" = "$vu" ]; then + note "ok: open-pstack version matches across UPSTREAM.md and the 4 manifests ($vc)" else - note "FAIL: open-pstack version differs: upstream=$vu claude-plugin=$vc codex-plugin=$vx marketplace=$vm" + note "FAIL: open-pstack version differs: upstream=$vu claude-plugin=$vc codex-plugin=$vx grok-plugin=$vg marketplace=$vm" fail=1 fi