diff --git a/.claude/skills/verify-open-pstack/features/registry.json b/.claude/skills/verify-open-pstack/features/registry.json index 95ec4ed4..8a50d4e4 100644 --- a/.claude/skills/verify-open-pstack/features/registry.json +++ b/.claude/skills/verify-open-pstack/features/registry.json @@ -73,7 +73,7 @@ ], "project": [ ".claude/skills/verify-open-pstack/**", ".agents/skills/verify-open-pstack", - "AGENTS.md", "CLAUDE.md", "tests/skill-collision-repro.sh", ".github/workflows/ci.yml", "package.json", + "AGENTS.md", "CLAUDE.md", "tests/skill-collision-repro.sh", "tests/t3-e2e.py", ".github/workflows/ci.yml", "package.json", ".github/workflows/windows-hooks.yml" ], "nonRuntime": [ diff --git a/CHANGES.md b/CHANGES.md index 30240cba..c9df9c40 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. +## Local T3 host route, 2026-10-28 + +Issue #253 adds compatibility with T3-owned delegation at the existing provider-dispatch boundary. The port-authored `runner/t3.ts` and `runner/pstack-t3` compile JSON intent into a native plan, a direct T3 request, or a named refusal, and normalize actual T3 responses into separate evidence records. They do not launch models or change the CLI runner. `tests/t3-e2e.py` exercises the executable and saves replayable JSONL evidence. Provider dispatch includes a real-host exercise recipe. Codex tool mappings, the six affected skill entrypoints, and directly related playbooks point to this host route. One shared skill tree, model defaults, versions, and standalone CLI receipts remain unchanged. + ## 1.16.0 syncs to Cursor pstack 0.15.29 Open Pstack 1.16.0 tracks Cursor pstack 0.15.29 at `88d47514b82e7c44f00f2b2a72ff6dd12158038c`. Sixteen upstream commits since 0.15.25 touch pstack. Nine change skill content, and all nine are applied with the port adaptations below. `a968159` names two skills after their folders: the port's `poteto-mode` already complies, and `make-bot-ui` stays out. Three add upstream's own Claude Code install and a docs check, which stay out apart from their README text. Three bump only the Cursor manifests and guide, which stay out. The release also fixes #46 and #235, and ships Claude Haiku 5.5 lanes (#223), foreground waits on Claude Code (#232, #233) and the comment sweep (#218), all merged after 1.15.0. Default roles and panels are unchanged. diff --git a/NOTICE.md b/NOTICE.md index 7ceac4a1..f9b43501 100644 --- a/NOTICE.md +++ b/NOTICE.md @@ -47,6 +47,8 @@ Summary of structural changes: Per the MIT license, modifications are permitted. Skill bodies have been edited to substitute Cursor-specific primitives with their Claude Code equivalents (the full substitution table is in [CHANGES.md](CHANGES.md)). All upstream copyright notices in source files (where present) are preserved. +Local addition, 2026-10-28, for compatibility with T3-owned delegation: the T3 host section in provider dispatch, its narrow shared-skill and playbook pointers, the JSON compiler and normalizer, and the process-boundary verification harness. These are independently authored port additions for #253. + Files authored for this port (not derived from upstream): - `plugins/pstack/.claude-plugin/plugin.json` @@ -54,6 +56,8 @@ Files authored for this port (not derived from upstream): - `plugins/pstack/.codex-plugin/plugin.json` - `.agents/plugins/marketplace.json` (repo root) - `plugins/pstack/skills/poteto-mode/references/codex-tools.md` +- `plugins/pstack/skills/poteto-mode/scripts/runner/t3.ts` and `runner/pstack-t3` (T3 request compilation and response normalization) +- `tests/t3-e2e.py` (T3 process-boundary checks and replayable artifacts) - `plugins/pstack/skills/poteto-mode/scripts/bootstrap.test.ts` - `plugins/pstack/skills/poteto-mode/scripts/check-plan.test.ts` - `plugins/pstack/skills/poteto-mode/scripts/resolve-codex-transcript`, `resolve-codex-transcript.ts`, and `resolve-codex-transcript.test.ts` (Reflect's known-chat Codex transcript lookup, #140; behavior and test cases from Donncha O'Toole's contribution) diff --git a/README.md b/README.md index 962cf4dc..6a2da966 100644 --- a/README.md +++ b/README.md @@ -20,6 +20,7 @@ Compared with Lauren's pstack, Open Pstack adds: - **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. - **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. +- **T3-owned delegation.** Inside T3 Code, the [host route](plugins/pstack/skills/poteto-mode/references/provider-dispatch.md#t3-host-2026-10-28) keeps pstack's role and model choices while T3 launches and tracks agents. A JSON helper validates requests and records T3 results separately from CLI receipts. Unattested sandbox requirements refuse before launch. - **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)). - **Changes tested where they run.** Before a change merges, the exact candidate is installed in each app it affects and the changed behavior is tested there. The pull request records the result ([AGENTS.md](AGENTS.md)). diff --git a/package.json b/package.json index 4560426a..91dd27ac 100644 --- a/package.json +++ b/package.json @@ -3,7 +3,8 @@ "private": true, "packageManager": "bun@1.4.0", "scripts": { - "test": "cd plugins/pstack/skills/poteto-mode/scripts && bun install --frozen-lockfile && bun run test", + "test": "bun run test:t3 && cd plugins/pstack/skills/poteto-mode/scripts && bun install --frozen-lockfile && bun run test", + "test:t3": "python3 tests/t3-e2e.py --helper plugins/pstack/skills/poteto-mode/scripts/runner/pstack-t3 --artifacts \"$(mktemp -d)\"", "typecheck": "cd plugins/pstack/skills/poteto-mode/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" } diff --git a/plugins/pstack/skills/arena/SKILL.md b/plugins/pstack/skills/arena/SKILL.md index f5f51562..320ff4fb 100644 --- a/plugins/pstack/skills/arena/SKILL.md +++ b/plugins/pstack/skills/arena/SKILL.md @@ -9,6 +9,8 @@ description: "Spawn N parallel candidates at the same task, pick a base, graft t Open a todolist with one entry per phase (Frame, Fan out, Cross-judge, Pick, Graft, Verify) before launching anything. +On T3, the [host route](../poteto-mode/references/provider-dispatch.md#t3-host-2026-10-28) replaces the standalone launcher, worktree, background, and receipt instructions below. Use the compiled request and distinct T3 result records. + ## Phase A: Frame Every candidate gets the same prompt, so the prompt is the contract. diff --git a/plugins/pstack/skills/how/SKILL.md b/plugins/pstack/skills/how/SKILL.md index 17a39961..27573e78 100644 --- a/plugins/pstack/skills/how/SKILL.md +++ b/plugins/pstack/skills/how/SKILL.md @@ -9,6 +9,8 @@ Explore the codebase to answer "how does X work?" questions. Produce architectur **Dispatch contract.** Resolve every configured role through [`provider-dispatch.md`](../poteto-mode/references/provider-dispatch.md). Values are provider-qualified descriptors; the parent chooses native versus external execution. Every lane runs in `read-only` mode. Explorers use the `how explorer` descriptor, and every explainer uses the `how explainer` descriptor. When the sheet lacks either line, use that role's [role default](../poteto-mode/references/model-catalog.md#role-defaults). A native lane uses the parent subagent primitive; an external lane uses the launcher directly. On Codex, resolve remaining Claude tool names via [`codex-tools.md`](../poteto-mode/references/codex-tools.md). Pick each subagent's effort for the job, per the Subagents section of the **poteto-mode** skill. +On T3, the [host route](../poteto-mode/references/provider-dispatch.md#t3-host-2026-10-28) replaces the standalone launcher, worktree, background, and receipt instructions below. Label no-edit intent as advisory. Required enforced read-only access refuses without attestation. Use the compiled request and distinct T3 result records. + ## 1. Assess complexity If the scope is ambiguous, state your interpretation and explore. The user can redirect. diff --git a/plugins/pstack/skills/interrogate/SKILL.md b/plugins/pstack/skills/interrogate/SKILL.md index 4e3d43b5..e37aa72f 100644 --- a/plugins/pstack/skills/interrogate/SKILL.md +++ b/plugins/pstack/skills/interrogate/SKILL.md @@ -9,6 +9,8 @@ Spawn one reviewer per configured model to adversarially review code changes. Th **Dispatch contract.** Read [`provider-dispatch.md`](../poteto-mode/references/provider-dispatch.md) before launching reviewers. Configured entries are provider-qualified descriptors; the parent starts native and external read-only lanes directly. On Codex, resolve remaining Claude tool names via [`codex-tools.md`](../poteto-mode/references/codex-tools.md). +On T3, the [host route](../poteto-mode/references/provider-dispatch.md#t3-host-2026-10-28) replaces the standalone launcher, worktree, background, and receipt instructions below. Label no-edit intent as advisory. Required enforced read-only access refuses without attestation. Use the compiled request and distinct T3 result records. + ## Step 1, Scope Identify what to review from context: diff --git a/plugins/pstack/skills/poteto-mode/SKILL.md b/plugins/pstack/skills/poteto-mode/SKILL.md index 9fe88cf5..8a77e791 100644 --- a/plugins/pstack/skills/poteto-mode/SKILL.md +++ b/plugins/pstack/skills/poteto-mode/SKILL.md @@ -91,6 +91,8 @@ Read the leaf skill in full for any principle you apply. Each entry names when i ## Subagents +On T3, the [host route](references/provider-dispatch.md#t3-host-2026-10-28) takes precedence over the standalone defaults below. Compile the selected assignment, call the parent tools directly, and retain distinct T3 evidence. T3 creates worktrees and owns async completion. Required enforcement without attestation refuses. Do not retry a rejected effort or fall back to a CLI on this host. + 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. **Defaults for every delegation.** Start independent lanes together, 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/playbooks/autopilot-full.md b/plugins/pstack/skills/poteto-mode/playbooks/autopilot-full.md index 3012d98d..826b2e4a 100644 --- a/plugins/pstack/skills/poteto-mode/playbooks/autopilot-full.md +++ b/plugins/pstack/skills/poteto-mode/playbooks/autopilot-full.md @@ -1,5 +1,7 @@ ### Autopilot-full +On T3, follow [host dispatch](../references/provider-dispatch.md#t3-host-2026-10-28): the parent calls `delegate_task`, T3 prepares worktrees, and distinct T3 records replace CLI receipts. Use completion notifications instead of agent polling or audit loops, cancel with `task_cancel`, and create fresh review tasks. Do not create a child checkout in the shell or its brief. + **You own the verdicts, never the PRs. One owner carries each PR from build to merge, and nothing merges until your swarm, which never wrote the code, has proven the code that lands.** A verdict covers one patch and green CI covers one head. Step 4 runs a full swarm once, and again only for a major change. The patch-id rule in `playbooks/shipping.md` can carry a lane's result, and every new head needs its own green CI. diff --git a/plugins/pstack/skills/poteto-mode/playbooks/autopilot-stack.md b/plugins/pstack/skills/poteto-mode/playbooks/autopilot-stack.md index 6c2cab23..211d5912 100644 --- a/plugins/pstack/skills/poteto-mode/playbooks/autopilot-stack.md +++ b/plugins/pstack/skills/poteto-mode/playbooks/autopilot-stack.md @@ -1,5 +1,7 @@ ### Autopilot-stack +On T3, follow [host dispatch](../references/provider-dispatch.md#t3-host-2026-10-28): the parent calls `delegate_task`, T3 prepares worktrees, and distinct T3 records replace CLI receipts. Use completion notifications instead of agent polling or audit loops, cancel with `task_cancel`, and create fresh review tasks. Do not create a child checkout in the shell or its brief. + **You own the stack, never the landing. Hand the operator one linear stack of verified PRs to review and land.** **Autopilot-full** holds except for how verified work leaves. Each tick re-reads both playbooks from disk (`skills/poteto-mode/playbooks/autopilot-stack.md` and `autopilot-full.md` under the installed plugin), and this one wins where they differ. 1. **Run Autopilot-full's owners, minus the landing.** Resolve the forge, ``, ``, ``, and `` as Autopilot-full step 2 does; this playbook never merges. When the head repository is a fork, validate its identity and record its owner and repository name as `` and ``. Owners report STACK-READY with the exact head SHA in place of merge-ready. They never rebase onto another branch, trunk included, and never retarget, merge, close a PR, or arm auto-merge, because the root owns the chain's topology and the operator owns the landing. When an item depends on another, its owner branches from that item's branch, not from trunk. Nothing enters the stack before a clean verdict from Autopilot-full step 4. diff --git a/plugins/pstack/skills/poteto-mode/playbooks/bug-fix.md b/plugins/pstack/skills/poteto-mode/playbooks/bug-fix.md index 63844d6d..ff7abaec 100644 --- a/plugins/pstack/skills/poteto-mode/playbooks/bug-fix.md +++ b/plugins/pstack/skills/poteto-mode/playbooks/bug-fix.md @@ -1,5 +1,7 @@ ### Bug fix +On T3, follow [host dispatch](../references/provider-dispatch.md#t3-host-2026-10-28): the parent calls `delegate_task`, T3 prepares worktrees, and distinct T3 records replace CLI receipts. Do not create a child checkout in the shell or its brief. + **You own this task. Plan, review, verify.** Delegate investigation and the fix to subagents, stay in the lead. Pick each subagent's effort for the job, per the Subagents section. Be scientific. Every shipped line traces to runtime evidence. Belt-and-suspenders that "might help" is a hypothesis, not a fix. It does not ship. When evidence refutes a hypothesis, revert what it motivated. The smallest change the evidence justifies ships, nothing more. diff --git a/plugins/pstack/skills/poteto-mode/playbooks/feature.md b/plugins/pstack/skills/poteto-mode/playbooks/feature.md index ceb3e71b..9acb3ad3 100644 --- a/plugins/pstack/skills/poteto-mode/playbooks/feature.md +++ b/plugins/pstack/skills/poteto-mode/playbooks/feature.md @@ -1,5 +1,7 @@ ### Feature +On T3, follow [host dispatch](../references/provider-dispatch.md#t3-host-2026-10-28): the parent calls `delegate_task`, T3 prepares worktrees, and distinct T3 records replace CLI receipts. Do not create a child checkout in the shell or its brief. + **You own the design. Plan, review, verify.** Delegate implementation. Stay in the lead. 1. `how` over the affected subsystem. diff --git a/plugins/pstack/skills/poteto-mode/playbooks/hillclimb.md b/plugins/pstack/skills/poteto-mode/playbooks/hillclimb.md index a667d1f7..72459f37 100644 --- a/plugins/pstack/skills/poteto-mode/playbooks/hillclimb.md +++ b/plugins/pstack/skills/poteto-mode/playbooks/hillclimb.md @@ -1,5 +1,7 @@ ### Hillclimb +On T3, follow [host dispatch](../references/provider-dispatch.md#t3-host-2026-10-28): the parent calls `delegate_task`, T3 prepares worktrees, and distinct T3 records replace CLI receipts. Do not create a child checkout in the shell or its brief. + **You own the metric and the experiment's integrity. Supervise and review. Delegate the attempts.** For sustained, iterative improvement of one measurable thing against a target. A one-off fix is Bug fix or Perf issue. This is the loop. Core discipline: one change, one measurement, keep or revert. Never stack untested changes, and never claim a win from code inspection (the **prove-it-works** principle skill). diff --git a/plugins/pstack/skills/poteto-mode/playbooks/multi-phase-plan.md b/plugins/pstack/skills/poteto-mode/playbooks/multi-phase-plan.md index 7eb10146..5e5473cb 100644 --- a/plugins/pstack/skills/poteto-mode/playbooks/multi-phase-plan.md +++ b/plugins/pstack/skills/poteto-mode/playbooks/multi-phase-plan.md @@ -1,5 +1,7 @@ ### Multi-phase or multi-PR plan +On T3, follow [host dispatch](../references/provider-dispatch.md#t3-host-2026-10-28): the parent calls `delegate_task`, T3 prepares worktrees, and distinct T3 records replace CLI receipts. Do not create a child checkout in the shell or its brief. + **You own the plan, not the code. The plan is a checklist an owner runs box by box and the operator audits from the evidence.** The plan is the deliverable. Do not implement. 1. When the change is one or two files with an obvious approach, skip the plan. Say so and stop. diff --git a/plugins/pstack/skills/poteto-mode/playbooks/orchestrate.md b/plugins/pstack/skills/poteto-mode/playbooks/orchestrate.md index 42cfa0be..0bc33436 100644 --- a/plugins/pstack/skills/poteto-mode/playbooks/orchestrate.md +++ b/plugins/pstack/skills/poteto-mode/playbooks/orchestrate.md @@ -1,5 +1,7 @@ ### Orchestrate +On T3, follow [host dispatch](../references/provider-dispatch.md#t3-host-2026-10-28): the parent calls `delegate_task`, T3 prepares worktrees, and distinct T3 records replace CLI receipts. Use completion notifications instead of agent polling or audit loops, cancel with `task_cancel`, and create fresh review tasks. Do not create a child checkout in the shell or its brief. + **You own the program, never the code. Author briefs, drain the queue, keep the frontier green, decide.** For a whole project handed to one standing coordinator chat: multi-day, many stacked PRs, dozens to hundreds of subagents, the human checking in twice a day instead of every five minutes. One task driven to a predicate is Autonomous run. One ambitious run needing a bespoke workflow is figure-it-out. Route here when the work outlives any single agent. Work one agent could finish inside the session's budget is not a program. Ceremony must scale with the program. On cheap near-identical units, collapse it as each section directs. diff --git a/plugins/pstack/skills/poteto-mode/playbooks/perf-issue.md b/plugins/pstack/skills/poteto-mode/playbooks/perf-issue.md index 618f32e9..632480c0 100644 --- a/plugins/pstack/skills/poteto-mode/playbooks/perf-issue.md +++ b/plugins/pstack/skills/poteto-mode/playbooks/perf-issue.md @@ -1,5 +1,7 @@ ### Perf issue +On T3, follow [host dispatch](../references/provider-dispatch.md#t3-host-2026-10-28): the parent calls `delegate_task`, T3 prepares worktrees, and distinct T3 records replace CLI receipts. Do not create a child checkout in the shell or its brief. + **You own the measurement story. Plan, review, verify the numbers.** Tie every fix to a measurement, don't read source instead of measuring. 1. Capture a baseline trace via the driver skill (`run` for CLIs/TUIs, `verify` for UIs). Vet the baseline, and each later number, with the **benchmark-checklist** skill. diff --git a/plugins/pstack/skills/poteto-mode/references/codex-tools.md b/plugins/pstack/skills/poteto-mode/references/codex-tools.md index 68cd040f..62ca3d89 100644 --- a/plugins/pstack/skills/poteto-mode/references/codex-tools.md +++ b/plugins/pstack/skills/poteto-mode/references/codex-tools.md @@ -2,6 +2,10 @@ pstack skills retain Claude Code tool language (`Skill`, `Agent`, `AskUserQuestion`) in shared prose. On Codex the files are the same; only those tool names resolve differently. Model execution is not translated here. Read [`provider-dispatch.md`](provider-dispatch.md) for the parent-owned Claude/Codex/Grok route table and provider-qualified descriptors. +## T3 host exceptions + +When the actual session is hosted in T3, follow [T3 host dispatch](provider-dispatch.md#t3-host-2026-10-28) before the standalone mappings below. Only unchanged inherited provider, model, and settings, shared isolation, and completion this turn use native `spawn_agent` and `wait_agent`, without overrides. Requested settings, cross-provider or cross-model choices, worktrees, and background tasks use the parent's direct T3 `delegate_task`. Missing tools refuse. T3 creates worktrees. Async completion notifications replace process-handle polling and standalone background/wait mappings. Retain distinct T3 records, follow `next`, cancel with `task_cancel`, and start fresh review tasks. Never invoke the CLI runner as a T3 fallback. + ## 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: diff --git a/plugins/pstack/skills/poteto-mode/references/provider-dispatch.md b/plugins/pstack/skills/poteto-mode/references/provider-dispatch.md index bcb09043..689b3d1a 100644 --- a/plugins/pstack/skills/poteto-mode/references/provider-dispatch.md +++ b/plugins/pstack/skills/poteto-mode/references/provider-dispatch.md @@ -28,9 +28,94 @@ 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. 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, a provider native to the parent uses the parent's subagent primitive. Every other provider uses the external runner. -`inherit-parent` and `auto` remain aliases. 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. +`inherit-parent` and `auto` remain aliases. They use the parent's current model and effort. On T3, apply the host eligibility rules below before choosing the native tool. In a panel they still consume one lane, but they reduce provider diversity; say so in the synthesis record. + +## T3 host, 2026-10-28 + +This host route makes pstack compatible with host-owned delegation. It takes precedence over the standalone native, external CLI, worktree, wait, and receipt mappings below. Keep the same skill tree and model sheet. T3 is a host, not a runner provider or a new CLI Parent. + +The parent identifies T3 from the actual session instructions. Never infer the host from inherited environment variables. A required T3 tool missing from that session is a named refusal. Do not fall back to a model CLI, another provider, or another orchestration mechanism. + +### Compile the selected assignment + +The parent calls `orchestrator_capabilities` once per thread before its first `delegate_task`. Refresh only after an error or a user-named provider change. Preserve the actual structured capability object. If the snapshot omits a selected model or a rolling family's revisions, report incomplete capabilities rather than declaring the model unavailable. Do not guess from the short list or silently search and select another model. + +Run `skills/poteto-mode/scripts/runner/pstack-t3` with one JSON object on stdin. The helper emits one JSON object on stdout. It never calls MCP or HTTP, launches models, or manages tasks. Its shell launcher disables project Bun config, dotenv loading, and injected Bun or Node options, as the CLI launchers do. A tagged refusal exits zero so the parent can save and inspect it. Read `kind`, not just the process exit code. + +Compile input has these fields: + +- `operation: "compile"`, `host: "t3"`, and `parent: {nativeAvailable, delegateAvailable}` from the actual session tool inventory. +- `role`, the pstack role name, and `apiRole`, one of T3's `implementation`, `research`, `review`, `design`, `test`, or `general` roles. These are separate identities. +- `descriptor`, the selected `provider:model@effort`, `inherit-parent`, or `auto`, plus `task` and `title`. +- `lifecycle: {isolation: "shared"|"worktree", completion: "this-turn"|"background"}`. +- `access: {intent: "advisory-read-only"|"write", requireConfinement: boolean, requireNoDelegation: boolean}`. +- `capabilities`, the parent-obtained T3 structured object, with `inheritedProviderInstanceId`, `inheritedModel`, `inheritedOptions`, `runtimeMode`, `interactionMode`, `providers`, and `features`. +- Optional `options`, a record of exact requested option IDs and values, and `providerInstanceId` to select an ambiguous instance. Local delegates stay in the current thread's project. A `projectId` selection refuses; linked-environment delegation is outside this helper's scope. + +The output is a `native`, `delegate`, or `dropout` plan. Native eligibility requires unchanged inherited provider, model, and settings, shared isolation, and completion this turn. Explicit requested settings require `delegate_task` even when equal to the inherited setting. Cross-provider, cross-model, worktree, background, or explicit project selection also requires `delegate_task`. A native plan contains the task and expected inheritance. Use the session's native tool without provider, model, or option overrides. Claude `Agent` uses `run_in_background: false`. Wait for that native result this turn and retain its transcript separately. + +A delegate plan contains `request` with only these API fields: + +```json +{ + "task": "The complete child brief", + "title": "A short title", + "role": "implementation", + "isolation": "worktree", + "mode": "wait", + "target": { + "providerInstanceId": "the advertised instance", + "model": "the advertised concrete model", + "options": {"reasoningEffort": "max"} + } +} +``` + +Pass `request` directly to the real `delegate_task` tool. No child checkout commands or `cwd` belong in it. T3 prepares the worktree. It starts from the parent's commit, so uncommitted changes are not shared. Worktrees separate files but do not confine a process. Never create a child checkout in the shell or tell the child to create one. + +The compiler maps pstack's `codex` provider to T3's `codex` driver and `claude` to `claudeAgent`. An ambiguous provider instance refuses unless explicitly selected. Claude rolling aliases use the existing catalog's family prefixes and select the highest numeric revision from a complete family listing. Partial family listings refuse. After that refusal, obtain a focused capability snapshot for the selected family and retain both snapshots. Exact registered pins stay exact. `inherit-parent` preserves the exact inherited revision. No other model is substituted. + +Codex effort maps to `target.options.reasoningEffort`. Claude effort maps to `target.options.effort`. The exact value must appear in that model's advertised option values. Never lower an unsupported requested effort. Explicit descriptors start with the selected model's advertised option defaults, including `serviceTier` when present. `@default` adds no effort override. Optional settings without advertised defaults are omitted unless explicitly requested. `inherit-parent` and `auto` copy the complete inherited options, including service tier. Explicit `options` then override these values. A conflicting descriptor effort refuses. A different model never inherits foreign option IDs. Empty options explicitly clear overrides under T3's API contract. + +Runtime and interaction modes are context, not permission attestations or launch overrides. The compiler passes neither field to `delegate_task`. An advisory no-edit review appends a labeled instruction to the task. REQUIRED enforced confinement, read-only sandboxing, or disabled recursive delegation refuses before launch because this API does not attest those controls. Do not reinterpret a CLI `read-only` access mode or the model sheet's Codex writer sandbox setting as a T3 guarantee. State advisory intent explicitly or record the refusal when enforcement is required. + +### Retain T3 lifecycle evidence + +Save the compiled plan and actual structured tool response. Normalize them with `{"operation":"normalize","plan":,"response":}` on stdin to the same executable. After a pending launch, add `launch` with that retained structured launch response when normalizing a later response. Task identities must match. Async completion requires launch evidence. Normalize only delegate plans. Native results keep native transcript evidence. + +The result is tagged `t3-pending`, `t3-complete`, or `t3-dropout`. Running, queued, waiting, approval, question, wait timeout, and a completed turn with live child runs remain pending. A wait timeout does not cancel a child. Only matching provider, concrete model, and complete option values, a completed terminal status, no live work, and nonempty actual summary output yield `t3-complete`. Failure, cancellation, interruption, mismatch, empty output, and ambiguous acceptance stay distinct dropouts. Keep the raw response, task identity, child thread link, `next`, and opaque context-transfer references. An ambiguous response may represent an accepted task. Never relaunch automatically. + +Follow the actual response's `next`. Async completion wakes the parent. End the turn when instructed and resume on the completion notification. Do not poll with `task_status`, sleep loops, watchers, Monitor, or ScheduleWakeup. Use `task_cancel` with the retained task identity when cancelling superseded or unwanted work. Never use `t3_thread_interrupt` on its child thread. A new review or re-check is a fresh `delegate_task` with the brief, findings, and objections, not `t3_thread_send`. Fix small review findings directly. If told to use fewer delegates, cancel extras without replacements. + +T3 records prove the host reported a configuration and result. They are not schema-version-1 CLI receipts or proof of the model actually served. They never synthesize argv, an exit status, pinned-argv evidence, usage, cost, sandbox enforcement, or recursion enforcement. Unknown controls remain `unattested`. Keep any fields the host really returns under the raw response rather than promoting them into CLI evidence. + +### Exercise the real host + +Run the process checks from the source root and retain the JSONL artifact: + +```sh +python3 tests/t3-e2e.py --helper plugins/pstack/skills/poteto-mode/scripts/runner/pstack-t3 --artifacts /absolute/private-artifacts/t3-boundary +``` + +For the live exercise, choose a permitted selected descriptor and advisory read-only shared task. A useful task is `Reply with exactly PSTACK_T3_LIVE_2026_10_28. Do not edit files or run commands.` Use `completion: "this-turn"` and a descriptor with an explicit effort to exercise `delegate_task`. Obtain the capability snapshot and tool inventory from the actual parent. Save the compile input as `input.json` in a private artifact directory. Then run: + +```sh +"/skills/poteto-mode/scripts/runner/pstack-t3" < input.json > plan.json +jq -e '.kind == "delegate"' plan.json +jq '.request' plan.json > request.json +``` + +The parent invokes the actual T3 `delegate_task` with the exact object in `request.json`. Save its actual `structuredContent` object as `response.json`, without reconstructing a terminal response from a child message. If pending, follow `next` and retain the completion notification before normalization. No polling. A live child notification alone is not proof that all descendants finished. Keep each actual response separately. + +```sh +jq -n --slurpfile plan plan.json --slurpfile response response.json --slurpfile launch launch.json '{operation:"normalize",plan:$plan[0],response:$response[0],launch:$launch[0]}' > normalize.json +"/skills/poteto-mode/scripts/runner/pstack-t3" < normalize.json > result.json +jq -e '.kind == "t3-complete" and (.output | contains("PSTACK_T3_LIVE_2026_10_28"))' result.json +``` + +For an immediate terminal wait response, save it as both `launch.json` and `response.json`. For a pending response, save it as `launch.json`, follow `next`, and obtain `response.json` with `task_status` only after the completion notification. Retain `input.json`, `plan.json`, `request.json`, `launch.json`, `response.json`, `normalize.json`, `result.json`, and the real child result or artifact. These files let a reviewer replay compilation and normalization. They do not replay the model launch. Record the installed candidate, host, selected assignment, observed output, and any refusal. Process checks alone do not satisfy the live gate. ## Native lanes diff --git a/plugins/pstack/skills/poteto-mode/scripts/runner/pstack-t3 b/plugins/pstack/skills/poteto-mode/scripts/runner/pstack-t3 new file mode 100755 index 00000000..f415068c --- /dev/null +++ b/plugins/pstack/skills/poteto-mode/scripts/runner/pstack-t3 @@ -0,0 +1,4 @@ +#!/bin/sh +unset BUN_OPTIONS NODE_OPTIONS +case $0 in */*) dir=${0%/*} ;; *) dir=. ;; esac +exec bun --no-env-file --config=/dev/null "$dir/t3.ts" "$@" diff --git a/plugins/pstack/skills/poteto-mode/scripts/runner/t3.ts b/plugins/pstack/skills/poteto-mode/scripts/runner/t3.ts new file mode 100644 index 00000000..587e55f5 --- /dev/null +++ b/plugins/pstack/skills/poteto-mode/scripts/runner/t3.ts @@ -0,0 +1,336 @@ +import { FAMILIES } from "./registry/models.ts"; +import { EFFORTS } from "./types.ts"; + +type Options = Record; +type ApiRole = "implementation" | "research" | "review" | "design" | "test" | "general"; +type Isolation = "shared" | "worktree"; +interface Model { + id: string; + options: { id: string; values: (string | boolean)[]; default: string | boolean | null }[]; +} +interface Provider { + providerInstanceId: string; + driverKind: string; + canRunChildTask: boolean; + canRunCrossProviderChildTask: boolean; + models: Model[]; + omittedModels: number; + constraints: string[]; +} +interface Capabilities { + inheritedProviderInstanceId: string; + inheritedModel: string; + inheritedOptions: Options; + runtimeMode: string; + interactionMode: string; + appOwnedSubagents: boolean; + providers: Provider[]; +} +interface Intent { + parent: { nativeAvailable: boolean; delegateAvailable: boolean }; + role: string; + apiRole: ApiRole; + descriptor: string; + task: string; + title: string; + isolation: Isolation; + background: boolean; + access: { intent: "advisory-read-only" | "write"; requireConfinement: boolean; requireNoDelegation: boolean }; + options: Options; + providerInstanceId?: string; + capabilities: Capabilities; +} +interface Target { + providerInstanceId: string; + model: string; + options: Options; +} +interface Request { + task: string; + title: string; + role: ApiRole; + isolation: Isolation; + mode: "wait" | "async"; + target: Target; +} +interface Controls { + readOnly: "advisory" | "not-requested"; + confinement: "unattested"; + recursiveDelegation: "unattested"; +} +interface Assignment { role: string; descriptor: string } +interface PlanFacts { + assignment: Assignment; + controls: Controls; + context: { runtimeMode: string; interactionMode: string }; +} +type Plan = + | PlanFacts & { kind: "native"; task: string; title: string; expected: Target } + | PlanFacts & { kind: "delegate"; request: Request } + | { kind: "dropout"; reason: string; message: string }; + +class Refusal extends Error { + constructor(readonly reason: string, message: string) { super(message); } +} +function refuse(reason: string, message: string): never { throw new Refusal(reason, message); } +function object(value: unknown): Record { + if (value === null || typeof value !== "object" || Array.isArray(value)) refuse("invalid-input", "Expected a JSON object"); + return value as Record; +} +function string(value: unknown): string { + if (typeof value !== "string" || !value.trim()) refuse("invalid-input", "Expected a nonempty string"); + return value; +} +function boolean(value: unknown): boolean { + if (typeof value !== "boolean") refuse("invalid-input", "Expected a boolean"); + return value; +} +function array(value: unknown): unknown[] { + if (!Array.isArray(value)) refuse("invalid-input", "Expected an array"); + return value; +} +function optionValue(value: unknown): string | boolean { + if (typeof value !== "string" && typeof value !== "boolean") refuse("invalid-input", "Option values must be strings or booleans"); + return value; +} +function options(value: unknown): Options { + return Object.fromEntries(Object.entries(object(value)).map(([id, value]) => [id, optionValue(value)])); +} +function optionList(value: unknown): Options { + const entries = array(value).map((entry): [string, string | boolean] => { + const item = object(entry); + return [string(item.id), optionValue(item.value)]; + }); + if (new Set(entries.map(([id]) => id)).size !== entries.length) refuse("invalid-input", "Duplicate option IDs"); + return Object.fromEntries(entries); +} +function choice(value: unknown, choices: readonly T[]): T { + for (const allowed of choices) if (value === allowed) return allowed; + return refuse("invalid-input", `Expected one of ${choices.join(", ")}`); +} +function parseCapabilities(value: unknown): Capabilities { + const cap = object(value); + if (cap.inheritedOptions == null || !cap.inheritedModel || !cap.inheritedProviderInstanceId) { + refuse("incomplete-capabilities", "Inherited provider, model and options must be known"); + } + const providers = array(cap.providers).map((value): Provider => { + const provider = object(value); + const models = array(provider.models).map((value): Model => { + const model = object(value); + const parsed = array(model.options ?? []).map((value) => { + const option = object(value); + return { id: string(option.id), values: array(option.values).map(optionValue), default: option.default == null ? null : optionValue(option.default) }; + }); + if (new Set(parsed.map((option) => option.id)).size !== parsed.length) refuse("invalid-input", "Duplicate advertised option IDs"); + return { id: string(model.id), options: parsed }; + }); + if (new Set(models.map((model) => model.id)).size !== models.length) refuse("invalid-input", "Duplicate advertised models"); + if (provider.omittedModels != null && (typeof provider.omittedModels !== "number" || !Number.isInteger(provider.omittedModels) || provider.omittedModels < 0)) { + refuse("invalid-input", "omittedModels must be a nonnegative integer"); + } + return { + providerInstanceId: string(provider.providerInstanceId), driverKind: string(provider.driverKind), + canRunChildTask: boolean(provider.canRunChildTask), canRunCrossProviderChildTask: boolean(provider.canRunCrossProviderChildTask), + models, omittedModels: typeof provider.omittedModels === "number" ? provider.omittedModels : 0, + constraints: array(provider.constraints).map(string), + }; + }); + if (new Set(providers.map((provider) => provider.providerInstanceId)).size !== providers.length) refuse("invalid-input", "Duplicate provider instances"); + return { + inheritedProviderInstanceId: string(cap.inheritedProviderInstanceId), inheritedModel: string(cap.inheritedModel), inheritedOptions: optionList(cap.inheritedOptions), + runtimeMode: string(cap.runtimeMode), interactionMode: string(cap.interactionMode), + appOwnedSubagents: boolean(object(cap.features).appOwnedSubagents), providers, + }; +} +function parseIntent(input: Record): Intent { + choice(input.host, ["t3"]); + if (input.projectId !== undefined) refuse("unsupported-project", "Local T3 delegation stays in this thread's project; project selection requires a linked environment"); + const parent = object(input.parent); + const lifecycle = object(input.lifecycle); + const access = object(input.access); + return { + parent: { nativeAvailable: boolean(parent.nativeAvailable), delegateAvailable: boolean(parent.delegateAvailable) }, + role: string(input.role), apiRole: choice(input.apiRole, ["implementation", "research", "review", "design", "test", "general"]), + descriptor: string(input.descriptor), task: string(input.task), title: string(input.title), + isolation: choice(lifecycle.isolation, ["shared", "worktree"]), background: choice(lifecycle.completion, ["this-turn", "background"]) === "background", + access: { intent: choice(access.intent, ["advisory-read-only", "write"]), requireConfinement: boolean(access.requireConfinement), requireNoDelegation: boolean(access.requireNoDelegation) }, + options: input.options === undefined ? {} : options(input.options), + ...(input.providerInstanceId === undefined ? {} : { providerInstanceId: string(input.providerInstanceId) }), + capabilities: parseCapabilities(input.capabilities), + }; +} +function advertisedModel(provider: Provider, requested: string): Model { + const exactFamily = FAMILIES.find((family) => family.provider === "claude" && family.model === requested); + const rollingFamily = exactFamily ?? FAMILIES.find((family) => family.provider === "claude" && "rolling" in family + && requested.startsWith(family.rolling.revisionPrefix) && /^[0-9]+(?:-[0-9]+)*$/.test(requested.slice(family.rolling.revisionPrefix.length))); + const rolling = provider.driverKind === "claudeAgent" && rollingFamily && "rolling" in rollingFamily ? rollingFamily.rolling : null; + const matches = provider.models.filter((model) => rolling + ? model.id.startsWith(rolling.revisionPrefix) && /^[0-9]+(?:-[0-9]+)*$/.test(model.id.slice(rolling.revisionPrefix.length)) + : model.id === requested); + if (rolling) matches.sort((left, right) => { + const leftRevision = left.id.slice(rolling.revisionPrefix.length).split("-").map(Number); + const rightRevision = right.id.slice(rolling.revisionPrefix.length).split("-").map(Number); + for (let index = 0; index < Math.max(leftRevision.length, rightRevision.length); index++) { + const difference = (rightRevision[index] ?? 0) - (leftRevision[index] ?? 0); + if (difference) return difference; + } + return left.id.localeCompare(right.id); + }); + const selected = matches[0]; + if (!selected) refuse(provider.omittedModels > 0 ? "incomplete-capabilities" : "unavailable-model", `No advertised model for ${requested}; omitted models are not evidence of unavailability`); + if (rolling && provider.omittedModels > 0) refuse("incomplete-capabilities", `The ${requested} family listing is incomplete`); + return selected; +} +function checkOptions(model: Model, selected: Options): void { + for (const [id, value] of Object.entries(selected)) { + const option = model.options.find((option) => option.id === id); + if (!option || !option.values.includes(value)) refuse("unsupported-option", `${model.id} does not advertise ${id}=${value}`); + } +} +function compile(intent: Intent): Plan { + const cap = intent.capabilities; + if (intent.access.requireConfinement || intent.access.requireNoDelegation) { + refuse("unattested-enforcement", "T3 delegation does not attest enforced read-only confinement or disabled recursive delegation"); + } + const inherited = cap.providers.find((provider) => provider.providerInstanceId === cap.inheritedProviderInstanceId); + if (!inherited) refuse("incomplete-capabilities", "Inherited provider instance is absent"); + const inherit = intent.descriptor === "auto" || intent.descriptor === "inherit-parent"; + let provider = inherited; + let modelName = cap.inheritedModel; + let effort = "default"; + if (!inherit) { + const parts = /^([^:@]+):([^@]+)@([^@]+)$/.exec(intent.descriptor); + if (!parts || !parts[1] || !parts[2] || !parts[3] || !EFFORTS.some((value) => value === parts[3])) refuse("invalid-input", "Expected provider:model@effort or inherit-parent/auto"); + const [providerName, requested, requestedEffort] = [parts[1], parts[2], parts[3]]; + if (providerName !== "claude" && providerName !== "codex") refuse("unavailable-provider", `No T3 driver mapping for ${providerName}`); + const driverKind = providerName === "claude" ? "claudeAgent" : "codex"; + const candidates = cap.providers.filter((candidate) => candidate.driverKind === driverKind + && (intent.providerInstanceId === undefined || candidate.providerInstanceId === intent.providerInstanceId)); + if (candidates.length > 1) refuse("ambiguous-provider", `Select one ${providerName} providerInstanceId`); + const candidate = candidates[0]; + if (!candidate) refuse("unavailable-provider", `No advertised T3 provider for ${providerName}`); + provider = candidate; + modelName = requested; + effort = requestedEffort; + } else if (intent.providerInstanceId !== undefined && intent.providerInstanceId !== inherited.providerInstanceId) { + refuse("invalid-input", "inherit-parent cannot select another provider instance"); + } + const model = inherit ? provider.models.find((model) => model.id === modelName) : advertisedModel(provider, modelName); + if (!model) refuse("incomplete-capabilities", "The inherited model must be advertised exactly; never substitute a family revision"); + const selected: Options = inherit ? { ...cap.inheritedOptions } : Object.fromEntries(model.options.flatMap((option) => option.default === null ? [] : [[option.id, option.default]])); + const effortId = provider.driverKind === "codex" ? "reasoningEffort" : "effort"; + if (effort !== "default") { + if (intent.options[effortId] !== undefined && intent.options[effortId] !== effort) refuse("conflicting-effort", "Descriptor effort and explicit options disagree"); + selected[effortId] = effort; + } + Object.assign(selected, intent.options); + checkOptions(model, selected); + const target: Target = { providerInstanceId: provider.providerInstanceId, model: model.id, options: selected }; + const controls: Controls = { readOnly: intent.access.intent === "advisory-read-only" ? "advisory" : "not-requested", confinement: "unattested", recursiveDelegation: "unattested" }; + const facts: PlanFacts = { assignment: { role: intent.role, descriptor: intent.descriptor }, controls, context: { runtimeMode: cap.runtimeMode, interactionMode: cap.interactionMode } }; + const task = intent.task + (controls.readOnly === "advisory" ? "\n\nAccess intent: advisory read-only. Do not edit files. This is an instruction, not enforced confinement." : ""); + const native = provider.providerInstanceId === cap.inheritedProviderInstanceId && model.id === cap.inheritedModel + && sameOptions(selected, cap.inheritedOptions) && effort === "default" && Object.keys(intent.options).length === 0 + && intent.isolation === "shared" && !intent.background; + if (native) { + if (!intent.parent.nativeAvailable) refuse("missing-tool", "The inherited native tool is unavailable; do not fall back"); + return { kind: "native", ...facts, task, title: intent.title, expected: target }; + } + if (!intent.parent.delegateAvailable || !cap.appOwnedSubagents) refuse("missing-tool", "T3 delegate_task is unavailable; do not use a CLI fallback"); + if (!provider.canRunChildTask || provider.constraints.length || inherited.constraints.length + || (provider.providerInstanceId !== inherited.providerInstanceId && (!provider.canRunCrossProviderChildTask || !inherited.canRunCrossProviderChildTask))) { + refuse("provider-constraint", "Advertised provider constraints do not permit this delegation"); + } + return { kind: "delegate", ...facts, request: { task, title: intent.title, role: intent.apiRole, isolation: intent.isolation, mode: intent.background ? "async" : "wait", target } }; +} +function sameOptions(left: Options, right: Options): boolean { + return Object.keys(left).length === Object.keys(right).length && Object.entries(left).every(([id, value]) => Object.hasOwn(right, id) && right[id] === value); +} +interface ResultFacts { + assignment: Assignment; + request: Request; + launch: unknown; + response: unknown; + evidence: { configuration: "t3-report" | "unattested"; servedModel: "unattested"; controls: Controls }; +} +type Result = ResultFacts & ( + | { kind: "t3-pending" } + | { kind: "t3-complete"; output: string } + | { kind: "t3-dropout"; reason: string } +); +function normalize(planValue: unknown, response: unknown, launch: unknown): Result { + const plan = object(planValue); + if (plan.kind !== "delegate") refuse("invalid-input", "Only a compiled delegate plan has a T3 response; keep native transcripts separately"); + const target = object(object(plan.request).target); + const expected = { providerInstanceId: string(target.providerInstanceId), model: string(target.model), options: options(target.options) }; + const rawControls = object(plan.controls); + const controls: Controls = { + readOnly: choice(rawControls.readOnly, ["advisory", "not-requested"]), + confinement: choice(rawControls.confinement, ["unattested"]), + recursiveDelegation: choice(rawControls.recursiveDelegation, ["unattested"]), + }; + const rawRequest = object(plan.request); + const rawAssignment = object(plan.assignment); + const request: Request = { + task: string(rawRequest.task), title: string(rawRequest.title), + role: choice(rawRequest.role, ["implementation", "research", "review", "design", "test", "general"]), + isolation: choice(rawRequest.isolation, ["shared", "worktree"]), mode: choice(rawRequest.mode, ["wait", "async"]), + target: expected, + }; + const facts: ResultFacts = { + assignment: { role: string(rawAssignment.role), descriptor: string(rawAssignment.descriptor) }, request, launch: launch ?? null, response, + evidence: { configuration: "unattested", servedModel: "unattested", controls }, + }; + const dropout = (reason: string): Result => ({ kind: "t3-dropout", reason, ...facts }); + let actual; + let raw; + try { + raw = object(response); + actual = { + taskId: string(raw.taskId), + status: choice(raw.status, ["queued", "running", "waiting", "completed", "failed", "cancelled", "interrupted"]), + workState: choice(raw.workState, ["working", "waiting_for_children", "result_available"]), + hasPendingChildRuns: boolean(raw.hasPendingChildRuns), hasPendingQuestion: boolean(raw.hasPendingQuestion), waitTimedOut: boolean(raw.waitTimedOut), + pendingApproval: raw.pendingApproval, latestTerminalStatus: raw.latestTerminalStatus, + output: typeof raw.latestTerminalSummary === "string" && raw.latestTerminalSummary.trim() ? raw.latestTerminalSummary : raw.summary, + }; + if (raw.pendingApproval === undefined || (raw.pendingApproval !== null && (typeof raw.pendingApproval !== "object" || Array.isArray(raw.pendingApproval)))) return dropout("ambiguous-acceptance"); + } catch (error) { + if (!(error instanceof Refusal)) throw error; + return dropout("ambiguous-acceptance"); + } + const failure = actual.status === "failed" || actual.status === "cancelled" || actual.status === "interrupted"; + const pending = actual.status !== "completed" || actual.workState !== "result_available" || actual.hasPendingChildRuns || actual.pendingApproval !== null || actual.hasPendingQuestion || actual.waitTimedOut; + if (launch != null) { + try { + if (string(object(launch).taskId) !== actual.taskId) return dropout("task-mismatch"); + } catch (error) { + if (!(error instanceof Refusal)) throw error; + return dropout("ambiguous-acceptance"); + } + } else if (request.mode === "async" && !pending && !failure) { + return dropout("missing-launch-evidence"); + } + try { + if (typeof raw.providerInstanceId === "string" && typeof raw.model === "string" && Array.isArray(raw.options)) facts.evidence.configuration = "t3-report"; + const reported = { providerInstanceId: string(raw.providerInstanceId), model: string(raw.model), options: optionList(raw.options) }; + if (!sameOptions(reported.options, expected.options) || reported.providerInstanceId !== expected.providerInstanceId || reported.model !== expected.model) return dropout("configuration-mismatch"); + } catch (error) { + if (!(error instanceof Refusal)) throw error; + if (failure) return dropout(actual.status); + return pending ? { kind: "t3-pending", ...facts } : dropout("ambiguous-acceptance"); + } + if (failure) return dropout(actual.status); + if (pending) return { kind: "t3-pending", ...facts }; + if (actual.latestTerminalStatus !== "completed") return dropout("ambiguous-acceptance"); + if (typeof actual.output !== "string" || !actual.output.trim()) return dropout("empty-output"); + return { kind: "t3-complete", ...facts, output: actual.output }; +} +try { + const input = object(JSON.parse(await Bun.stdin.text())); + const operation = choice(input.operation, ["compile", "normalize"]); + const result = operation === "compile" ? compile(parseIntent(input)) : normalize(input.plan, input.response, input.launch); + process.stdout.write(`${JSON.stringify(result)}\n`); +} catch (error) { + process.stdout.write(`${JSON.stringify({ kind: "dropout", reason: error instanceof Refusal ? error.reason : "invalid-input", message: error instanceof Error ? error.message : String(error) })}\n`); +} diff --git a/plugins/pstack/skills/setup-pstack/SKILL.md b/plugins/pstack/skills/setup-pstack/SKILL.md index 9da5cb0a..05ff739d 100644 --- a/plugins/pstack/skills/setup-pstack/SKILL.md +++ b/plugins/pstack/skills/setup-pstack/SKILL.md @@ -29,7 +29,7 @@ Codex writes `/pstack-models.md`. Codex has no `@` include, so mirr ### 1. Establish the parent -Use the harness and tool surface running this skill: Claude Code or Codex. 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. +Use the actual session instructions to identify the host as well as the Claude Code or Codex parent. On T3, follow the [host route](../poteto-mode/references/provider-dispatch.md#t3-host-2026-10-28). Its capability snapshot, compiled requests, and actual T3 result records replace the standalone CLI availability checks, route probes, receipt checks, and background-drain smoke below. Probe each selected assignment with advisory no-edit intent, or refuse when enforcement is required. Preserve the final-write permission checks. Missing T3 tools refuse without CLI fallback. 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. ### 2. Load current state diff --git a/plugins/pstack/skills/swarm/SKILL.md b/plugins/pstack/skills/swarm/SKILL.md index 4e2f8c67..489eb0a6 100644 --- a/plugins/pstack/skills/swarm/SKILL.md +++ b/plugins/pstack/skills/swarm/SKILL.md @@ -7,6 +7,8 @@ description: "Fan out N parallel workers, drain them, and return one report. Use **Dispatch contract.** Resolve each worker descriptor through [`provider-dispatch.md`](../poteto-mode/references/provider-dispatch.md). The parent starts native and external lanes; workers never route themselves. On Codex, resolve remaining Claude tool names via [`codex-tools.md`](../poteto-mode/references/codex-tools.md). +On T3, the [host route](../poteto-mode/references/provider-dispatch.md#t3-host-2026-10-28) replaces the standalone launcher, worktree, background, and receipt instructions below. Use the compiled request and distinct T3 result records. + ## Start Open a todolist with one entry per phase before launching anything. diff --git a/tests/t3-e2e.py b/tests/t3-e2e.py new file mode 100755 index 00000000..07a9d1f8 --- /dev/null +++ b/tests/t3-e2e.py @@ -0,0 +1,173 @@ +#!/usr/bin/env python3 +import argparse +import copy +import json +from pathlib import Path +import subprocess +import sys + +parser = argparse.ArgumentParser(description="Exercise the T3 JSON helper as a process, without launching a model.") +parser.add_argument("--helper", type=Path, required=True) +parser.add_argument("--artifacts", type=Path, required=True) +args = parser.parse_args() +args.artifacts.mkdir(parents=True, exist_ok=True) + +caps = { + "inheritedProviderInstanceId": "codex-local", "inheritedModel": "gpt-6.1-sol", + "inheritedOptions": [{"id": "reasoningEffort", "value": "high"}, {"id": "serviceTier", "value": "fast"}], + "runtimeMode": "full-access", "interactionMode": "default", + "features": {"appOwnedSubagents": True, "cancellation": True}, + "providers": [ + {"providerInstanceId": "codex-local", "driverKind": "codex", "canRunChildTask": True, + "canRunCrossProviderChildTask": True, "constraints": [], "models": [ + {"id": "gpt-6.1-sol", "options": [ + {"id": "reasoningEffort", "values": ["high", "max"], "default": "high"}, + {"id": "serviceTier", "values": ["standard", "fast"], "default": "standard"}]}, + {"id": "gpt-6-luna", "options": [{"id": "reasoningEffort", "values": ["high"], "default": "high"}]}]}, + {"providerInstanceId": "claude-local", "driverKind": "claudeAgent", "canRunChildTask": True, + "canRunCrossProviderChildTask": True, "constraints": [], "models": [ + {"id": "claude-opus-4-8", "options": [{"id": "effort", "values": ["high", "max"], "default": "high"}]}]}, + ], +} +base = { + "operation": "compile", "host": "t3", "parent": {"nativeAvailable": True, "delegateAvailable": True}, + "role": "arena runners", "apiRole": "implementation", "descriptor": "codex:gpt-6.1-sol@max", + "task": "Read the assigned files and return a short result.", "title": "T3 boundary exercise", + "lifecycle": {"isolation": "shared", "completion": "this-turn"}, + "access": {"intent": "advisory-read-only", "requireConfinement": False, "requireNoDelegation": False}, + "capabilities": caps, +} +rows = [] +failures = 0 + +def run(name, payload, check): + global failures + row = {"scenario": name, "input": payload} + try: + result = subprocess.run([str(args.helper.resolve())], input=json.dumps(payload), text=True, capture_output=True) + row.update(exitCode=result.returncode, stdout=result.stdout, stderr=result.stderr) + assert result.returncode == 0, f"helper exited {result.returncode}" + value = json.loads(result.stdout) + check(value) + row["passed"] = True + except (OSError, AssertionError, ValueError, KeyError, TypeError) as error: + failures += 1 + row.update(passed=False, error=str(error)) + rows.append(row) + print(f"{'PASS' if row['passed'] else 'FAIL'} {name}" + (f": {row['error']}" if not row["passed"] else "")) + return json.loads(row["stdout"]) if row.get("passed") else None + +def equal(actual, expected): + assert actual == expected, f"expected {expected!r}, got {actual!r}" + +def compile_case(name, edit, check): + payload = copy.deepcopy(base) + edit(payload) + return run(name, payload, check) + +def refusal(code): + def check(value): + equal(value["kind"], "dropout") + equal(value["reason"], code) + assert "request" not in value + return check + +def check_request(value): + equal(value["kind"], "delegate") + equal(value["request"], { + "task": base["task"] + "\n\nAccess intent: advisory read-only. Do not edit files. This is an instruction, not enforced confinement.", + "title": base["title"], "role": "implementation", "isolation": "shared", "mode": "wait", + "target": {"providerInstanceId": "codex-local", "model": "gpt-6.1-sol", + "options": {"reasoningEffort": "max", "serviceTier": "standard"}}, + }) + equal(value["assignment"], {"role": "arena runners", "descriptor": base["descriptor"]}) + equal(value["controls"], {"readOnly": "advisory", "confinement": "unattested", "recursiveDelegation": "unattested"}) + +plan = compile_case("exact Codex API fields and honest evidence", lambda x: None, check_request) +compile_case("Claude alias and effort key", lambda x: x.update(descriptor="claude:opus@max", apiRole="review"), + lambda x: equal(x["request"]["target"], {"providerInstanceId": "claude-local", "model": "claude-opus-4-8", "options": {"effort": "max"}})) +compile_case("unchanged inherited native", lambda x: x.update(descriptor="inherit-parent"), lambda x: equal(x["kind"], "native")) +compile_case("explicit equal inherited setting still delegates", lambda x: x.update(descriptor="auto", options={"reasoningEffort": "high"}), lambda x: equal(x["kind"], "delegate")) +compile_case("worktree background uses the local T3 project", lambda x: x.update(descriptor="auto", lifecycle={"isolation": "worktree", "completion": "background"}), + lambda x: (equal(x["request"]["mode"], "async"), equal(x["request"]["isolation"], "worktree"), equal(set(x["request"]["target"]), {"providerInstanceId", "model", "options"}), equal(x["request"]["target"]["options"]["serviceTier"], "fast"))) +compile_case("local target cannot change projects", lambda x: x.update(projectId="another-project"), refusal("unsupported-project")) +compile_case("cross-model default clears foreign options", lambda x: x.update(descriptor="codex:gpt-6-luna@default"), + lambda x: equal(x["request"]["target"], {"providerInstanceId": "codex-local", "model": "gpt-6-luna", "options": {"reasoningEffort": "high"}})) +compile_case("explicit service tier", lambda x: x.update(options={"serviceTier": "fast"}), lambda x: equal(x["request"]["target"]["options"]["serviceTier"], "fast")) +compile_case("unsupported exact effort", lambda x: x.update(descriptor="codex:gpt-6.1-sol@ultra"), refusal("unsupported-option")) +compile_case("conflicting effort", lambda x: x.update(options={"reasoningEffort": "high"}), refusal("conflicting-effort")) +compile_case("required confinement", lambda x: x["access"].update(requireConfinement=True), refusal("unattested-enforcement")) +compile_case("required recursion disabled", lambda x: x["access"].update(requireNoDelegation=True), refusal("unattested-enforcement")) +compile_case("missing delegate tool", lambda x: x["parent"].update(delegateAvailable=False), refusal("missing-tool")) +compile_case("missing native tool", lambda x: (x.update(descriptor="auto"), x["parent"].update(nativeAvailable=False)), refusal("missing-tool")) +compile_case("host explicit, never environment detection", lambda x: x.update(host="standalone"), refusal("invalid-input")) +compile_case("ambiguous provider instances", lambda x: x["capabilities"]["providers"].append(dict(x["capabilities"]["providers"][0], providerInstanceId="codex-other")), refusal("ambiguous-provider")) +compile_case("unknown driver never guessed", lambda x: x.update(descriptor="grok:grok-4.7@max"), refusal("unavailable-provider")) +compile_case("provider refuses child", lambda x: x["capabilities"]["providers"][0].update(canRunChildTask=False), refusal("provider-constraint")) +compile_case("cross-provider constraint", lambda x: (x.update(descriptor="claude:opus@max"), x["capabilities"]["providers"][0].update(canRunCrossProviderChildTask=False)), refusal("provider-constraint")) +compile_case("uninterpreted constraint refuses", lambda x: x["capabilities"]["providers"][0].update(constraints=["host-specific limit"]), refusal("provider-constraint")) +compile_case("omitted model is incomplete", lambda x: x["capabilities"]["providers"][0].update(models=[], omittedModels=12), refusal("incomplete-capabilities")) +compile_case("rolling alias selects latest advertised revision", lambda x: (x.update(descriptor="claude:opus@max"), x["capabilities"]["providers"][1]["models"].append({"id": "claude-opus-4-7", "options": []})), + lambda x: equal(x["request"]["target"]["model"], "claude-opus-4-8")) +compile_case("partial rolling family refuses", lambda x: (x.update(descriptor="claude:opus@max"), x["capabilities"]["providers"][1].update(omittedModels=1)), refusal("incomplete-capabilities")) +compile_case("optional settings without defaults need no invented override", lambda x: x["capabilities"]["providers"][0]["models"][0]["options"].append({"id": "optionalMode", "values": [True, False]}), check_request) +compile_case("invalid API role", lambda x: x.update(apiRole="arena runners"), refusal("invalid-input")) +compile_case("inherited options missing", lambda x: x["capabilities"].update(inheritedOptions=None), refusal("incomplete-capabilities")) +compile_case("unknown selected option", lambda x: x.update(options={"sandbox": "read-only"}), refusal("unsupported-option")) +compile_case("missing model in complete list", lambda x: x.update(descriptor="codex:unknown-model@max"), refusal("unavailable-model")) + +if plan: + terminal = { + "taskId": "actual-task", "childThreadId": "actual-thread", "link": "t3-thread://v1/actual-thread", + "providerInstanceId": "codex-local", "model": "gpt-6.1-sol", + "options": [{"id": "reasoningEffort", "value": "max"}, {"id": "serviceTier", "value": "standard"}], + "status": "completed", "workState": "result_available", "hasPendingChildRuns": False, + "pendingApproval": None, "hasPendingQuestion": False, "waitTimedOut": False, "next": None, + "summary": "Actual child output.", "latestTerminalStatus": "completed", "latestTerminalSummary": "Actual child output.", + "resultContextTransferId": "opaque-transfer", "latestTerminalResultContextTransferId": "opaque-terminal-transfer", + } + def normalize_case(name, changes, check): + response = dict(terminal, **changes) + def checked(value): + check(value) + equal(value["response"], response) + equal(value["evidence"], {"configuration": "t3-report", "servedModel": "unattested", "controls": plan["controls"]}) + for key in ("argv", "exitCode", "modelVerified", "usage", "costUsd", "sandboxEnforced"): + assert key not in value, key + return run(name, {"operation": "normalize", "plan": plan, "response": response}, checked) + normalize_case("matching terminal with actual output", {}, lambda x: (equal(x["kind"], "t3-complete"), equal(x["output"], "Actual child output."))) + for name, changes in [ + ("running", {"status": "running", "workState": "working", "latestTerminalStatus": None}), + ("waiting", {"status": "waiting", "workState": "working"}), + ("live descendants", {"hasPendingChildRuns": True, "workState": "waiting_for_children"}), + ("approval", {"pendingApproval": {"requestId": "approval", "kind": "command"}}), + ("question", {"hasPendingQuestion": True}), + ("wait timeout", {"waitTimedOut": True}), + ]: + normalize_case(name, changes, lambda x: equal(x["kind"], "t3-pending")) + for status in ("failed", "cancelled", "interrupted"): + normalize_case(status, {"status": status, "latestTerminalStatus": status}, lambda x, s=status: (equal(x["kind"], "t3-dropout"), equal(x["reason"], s))) + for field, value in (("model", "wrong-model"), ("providerInstanceId", "other"), ("options", [])): + normalize_case(f"mismatch {field}", {field: value}, lambda x: (equal(x["kind"], "t3-dropout"), equal(x["reason"], "configuration-mismatch"))) + normalize_case("empty output", {"summary": " ", "latestTerminalSummary": None}, lambda x: equal(x["reason"], "empty-output")) + normalize_case("missing terminal confirmation", {"latestTerminalStatus": None}, lambda x: equal(x["reason"], "ambiguous-acceptance")) + normalize_case("duplicate returned options", {"options": terminal["options"] * 2}, lambda x: equal(x["reason"], "ambiguous-acceptance")) + run("ambiguous response preserves acceptance", {"operation": "normalize", "plan": plan, "response": {"taskId": "accepted-but-incomplete"}}, + lambda x: (equal(x["kind"], "t3-dropout"), equal(x["reason"], "ambiguous-acceptance"), equal(x["response"]["taskId"], "accepted-but-incomplete"))) + async_plan = copy.deepcopy(plan) + async_plan["request"]["mode"] = "async" + launch = dict(terminal, status="running", workState="working", latestTerminalStatus=None, latestTerminalSummary=None, summary=None) + for name, response, retained, expected in [ + ("async completion binds retained launch", terminal, launch, "t3-complete"), + ("wrong task cannot supply async completion", dict(terminal, taskId="another-task"), launch, "task-mismatch"), + ("async completion requires launch evidence", terminal, None, "missing-launch-evidence"), + ]: + run(name, {"operation": "normalize", "plan": async_plan, "response": response, "launch": retained}, + lambda x, expected=expected: equal(x["kind"] if expected == "t3-complete" else x["reason"], expected)) +else: + print("Normalizer cases blocked by the failing compilation baseline.") + +(args.artifacts / "cases.jsonl").write_text("".join(json.dumps(row) + "\n" for row in rows)) +(args.artifacts / "summary.json").write_text(json.dumps({"passed": len(rows) - failures, "failed": failures, "helper": str(args.helper.resolve())}, indent=2) + "\n") +print(f"{len(rows) - failures} passed; {failures} failed. Evidence: {args.artifacts / 'cases.jsonl'}") +sys.exit(1 if failures else 0)