From 204481292e0bd2793c108260512891c234b578cb Mon Sep 17 00:00:00 2001 From: Myke Matos <123015330+shishiv@users.noreply.github.com> Date: Wed, 26 Aug 2026 13:10:05 -0300 Subject: [PATCH 1/3] feat(pstack): give gh-stack inspect an ordered member list Delivery membership is proven parent-first tuples from gh stack view JSON. Graphite is removed from delivery contracts so stack proof has one backend. --- README.md | 7 +- extensions/poteto-mode.ts | 23 +- skills/poteto-mode/SKILL.md | 7 +- .../poteto-mode/playbooks/autopilot-stack.md | 2 +- skills/poteto-mode/playbooks/opening-a-pr.md | 2 +- skills/poteto-mode/playbooks/shipping.md | 8 +- src/delivery/adapters.ts | 169 ++++++-------- src/delivery/authorization.ts | 3 +- src/delivery/types.ts | 36 ++- test/delivery/delivery.test.mjs | 219 ++++++++++-------- test/resources/resources.test.mjs | 1 - 11 files changed, 243 insertions(+), 234 deletions(-) diff --git a/README.md b/README.md index 58f63b5..063efdd 100644 --- a/README.md +++ b/README.md @@ -14,7 +14,7 @@ O `pi-pstack` preserva o método verification-first do projeto original: context - `/poteto-mode` persistente durante a sessão ativa. - Feature maps, artifacts de navegador e receipts vinculados ao `HEAD` exato. - Evals cegos entre modelos, com hard assertions que o judge não pode ignorar. -- Stacked PRs com `gh stack` por padrão e Graphite como backend opcional. +- Stacked PRs com `gh stack`. - Benny em modo draft-only: ele pode preparar uma draft PR, mas nunca faz merge ou deploy. ## Requisitos @@ -26,8 +26,7 @@ O `pi-pstack` preserva o método verification-first do projeto original: context - Bun para as ferramentas locais que o utilizam. - `portless` para fluxos locais que expõem serviços. - Playwright Chromium para verificação de navegador. -- `github/gh-stack` como backend padrão de stacked PRs. -- Graphite `gt` somente quando o backend opcional for selecionado. +- `github/gh-stack` como backend de stacked PRs. - Um provider externo de Slack e tracker para executar Benny contra serviços reais. ## Instalação @@ -84,7 +83,7 @@ O modo sticky vale somente para a sessão ativa. O Pi restaura o estado pelo his ## Stacked PRs -`gh stack` é o backend padrão. O adapter Graphite só aparece quando `gt` está instalado. O pacote traduz operações para os CLIs oficiais e não mantém um segundo grafo de branches. +`gh stack` é o backend de delivery. O pacote traduz operações para o CLI oficial e não mantém um segundo grafo de branches. O merge atômico exige um receipt por PR até o alvo. Gere cada receipt no checkout limpo do respectivo `HEAD` e mantenha os artifacts em caminhos imutáveis disponíveis durante a validação final. Se um digest de uma camada anterior não estiver disponível no checkout atual, o merge falha fechado. diff --git a/extensions/poteto-mode.ts b/extensions/poteto-mode.ts index 20a9ac5..b7fb320 100644 --- a/extensions/poteto-mode.ts +++ b/extensions/poteto-mode.ts @@ -245,7 +245,7 @@ export default function potetoModeExtension(pi: ExtensionAPI): void { description: "Hash local verification, review, and eval artifacts into a receipt for the current repository HEAD.", parameters: Type.Object({ - backend: Type.Union([Type.Literal("gh-stack"), Type.Literal("graphite")]), + backend: Type.Literal("gh-stack"), featureMapPath: Type.String({ minLength: 1 }), skillPath: Type.String({ minLength: 1 }), reviewPath: Type.String({ minLength: 1 }), @@ -303,9 +303,9 @@ export default function potetoModeExtension(pi: ExtensionAPI): void { name: "pstack_delivery", label: "Run gated pstack delivery", description: - "Inspect a stack or run a receipt-gated gh-stack or Graphite mutation. Auto-merge also verifies the live PR head and checks.", + "Inspect a stack or run a receipt-gated gh-stack mutation. Auto-merge also verifies the live PR head and checks.", parameters: Type.Object({ - backend: Type.Union([Type.Literal("gh-stack"), Type.Literal("graphite")]), + backend: Type.Literal("gh-stack"), operation: Type.Union([ Type.Literal("inspect"), Type.Literal("prepare"), @@ -344,8 +344,6 @@ export default function potetoModeExtension(pi: ExtensionAPI): void { paths.map((path) => loadEvidenceReceipt(path, ctx.cwd)), ); if (params.operation === "auto-merge") { - if (params.backend !== "gh-stack") - return deliveryRejected("Graphite auto-merge lacks stack-wide receipt verification"); if (!params.pullRequest) return deliveryRejected("auto-merge requires a pull request number"); const stackResult = await pi.exec("gh", ["stack", "view", "--json"], { @@ -414,18 +412,8 @@ export default function potetoModeExtension(pi: ExtensionAPI): void { return { exitCode: result.code, stdout: result.stdout, stderr: result.stderr }; }, }; - const availableCommands = ["gh"]; - if (params.backend === "graphite") { - const gt = await pi.exec("gt", ["--version"], { cwd: ctx.cwd, signal }); - if (gt.code === 0) availableCommands.push("gt"); - } - const backends = createDeliveryBackends({ - runner, - availableCommands, - }); - const backend = params.backend === "graphite" ? backends.graphite : backends.ghStack; - if (!backend) - return deliveryRejected("Graphite is unavailable; install and authenticate gt first"); + const backends = createDeliveryBackends({ runner }); + const backend = backends.ghStack; const operation = deliveryOperation(params); const result = await backend.execute(operation); return { @@ -692,6 +680,7 @@ function deliveryOperation(params: { case "rebase": return { kind: "rebase" }; case "auto-merge": + if (!params.pullRequest) throw new Error("auto-merge requires pull request"); return { kind: "auto-merge", pullRequest: params.pullRequest }; default: throw new Error(`unsupported delivery operation: ${params.operation}`); diff --git a/skills/poteto-mode/SKILL.md b/skills/poteto-mode/SKILL.md index fb80b18..b99fff7 100644 --- a/skills/poteto-mode/SKILL.md +++ b/skills/poteto-mode/SKILL.md @@ -17,9 +17,8 @@ managed worktrees isolate their edits. Resolve models through Pi profiles and agent roles, never through provider-specific slugs embedded in a skill. Delivery resources prepare evidence and receipts only. The default named -backend is `gh stack`; Graphite is an optional named backend. Executable -delivery behavior belongs to the later `src/delivery` adapters, not these -skills. +backend is `gh stack`. Executable delivery behavior belongs to the later +`src/delivery` adapters, not these skills. ## Non-negotiables @@ -139,7 +138,7 @@ A large or cross-cutting effort (a migration across many call sites, an ambitiou - **Authoring or modifying a skill.** Writing or editing a SKILL.md. `playbooks/authoring-a-skill.md`. - **Eval.** Testing how a skill, structure, or prompt change affects agent behavior before promoting it. `playbooks/eval.md`. - **Babysit.** Driving a PR or a stack to merge-ready: conflicts, review threads, CI. `playbooks/babysit.md`. -- **Shipping.** The half after Babysit. Independently verifying a green stack, then handing the contiguous verified run to the default `gh stack` adapter (Graphite is optional). `playbooks/shipping.md`. +- **Shipping.** The half after Babysit. Independently verifying a green stack, then handing the contiguous verified run to the `gh stack` adapter. `playbooks/shipping.md`. - **Autonomous run.** A long task to drive to completion without stopping ("run until done", "wait until X"). `playbooks/autonomous-run.md`. - **Orchestrate.** A standing project handed to one coordinator chat: multi-day, many stacked PRs, dozens to hundreds of subagents, minimal human turns ("run this whole project", "own this migration until it lands"). Distinct from Autonomous run, which drives one task to a predicate; work one agent could finish inside the session's budget routes there, not here, however program-shaped the phrasing sounds. `playbooks/orchestrate.md`. - **Autopilot-full.** A queue of independent PRs run to merged with full autonomy: one owner per PR carries build through merge, and the root swarm-verifies each merge-ready head before its owner merges ("autopilot this queue", "full autopilot", one-owner-per-PR programs). `playbooks/autopilot-full.md`. diff --git a/skills/poteto-mode/playbooks/autopilot-stack.md b/skills/poteto-mode/playbooks/autopilot-stack.md index c1ef8af..7a95f90 100644 --- a/skills/poteto-mode/playbooks/autopilot-stack.md +++ b/skills/poteto-mode/playbooks/autopilot-stack.md @@ -2,7 +2,7 @@ **You own the stack, never the landing. Build and verify the queue with full autonomy, then hand the operator one linear reviewed stack.** `gh stack` is -the default named backend; Graphite is optional. For "autopilot-stack", "stack +the delivery backend. For "autopilot-stack", "stack them, don't ship", "build the stack, I'll land it". The sibling of **Autopilot-full**. The owner loop and verification gate are the same; only the terminal differs. Nothing auto-ships from this skill. diff --git a/skills/poteto-mode/playbooks/opening-a-pr.md b/skills/poteto-mode/playbooks/opening-a-pr.md index ebfd4b5..340b322 100644 --- a/skills/poteto-mode/playbooks/opening-a-pr.md +++ b/skills/poteto-mode/playbooks/opening-a-pr.md @@ -20,7 +20,7 @@ Invoked at the end of every other playbook. After these sections, attach videos or screenshots when they prove a claim. Do not use `## Summary` or `## Test plan` boilerplate. A commit body does not restate its subject. -**Size and stacks.** Prefer narrow PRs to one large PR. Use the configured delivery adapter, with `gh stack` as the default and Graphite as an explicit option. Keep the ordered stack visible to reviewers. Branch from main only for independent work. Rebase on `main` before substantial stack work. +**Size and stacks.** Prefer narrow PRs to one large PR. Use the `gh stack` delivery adapter. Keep the ordered stack visible to reviewers. Branch from main only for independent work. Rebase on `main` before substantial stack work. **Readiness.** Open every PR ready, never as a draft. Set `draft: false` on every PR creation call. If a PR still opens as a draft, run the host's ready command, such as `gh pr ready `. Run `gh pr view ` before you refer to PR status. diff --git a/skills/poteto-mode/playbooks/shipping.md b/skills/poteto-mode/playbooks/shipping.md index 4cc2fe1..11ef875 100644 --- a/skills/poteto-mode/playbooks/shipping.md +++ b/skills/poteto-mode/playbooks/shipping.md @@ -4,17 +4,15 @@ This is the half after `playbooks/babysit.md`. Babysit makes a stack mergeable. Shipping decides what is actually safe to merge and hands a verified receipt to -the selected delivery adapter. `gh stack` is the default backend; Graphite is -an optional named backend. These resources do not implement backend state. +the selected delivery adapter. `gh stack` is the delivery backend. These resources do not implement backend state. Green is not safe, and the gap between those two words is where this playbook lives. 1. **Verify every PR independently before arming anything.** One subagent per PR, not batched, each a managed-worktree subagent, each exercising the real surface through the available browser, CLI, desktop, or mobile verification skill against parent versus head. Each returns `PASS`, `PASS+NOTES` or `FAIL` and posts that verdict on its own PR so the record outlives the chat. Safe means a verdict from an agent that did not write the code. CI green is not a verdict, and an approving bot review is not a verdict. 2. **Land only the contiguous verified run rooted at the bottom.** Walk up from the lowest unmerged PR and stop at the first one without a passing verdict, where both `PASS` and `PASS+NOTES` pass. A verified PR sitting above an unverified one is not landable, because merging it would pull the gap in underneath it. Report the ceiling as a PR number and say what breaks the chain. 3. **Re-check that the verdicts still describe the code.** A restack rewrites every SHA above it and silently invalidates every verdict without touching a single check. Compare `git patch-id` at the verdict SHA against the current head before trusting an older verdict, and re-verify anything that actually drifted. Twenty-one verdicts went stale this way in one run with no signal at all. -4. **Hand the receipt to the delivery adapter.** Use the default `gh stack` - adapter unless the operator explicitly selected the named Graphite adapter. - Do not invent or mutate stack state in a skill; the later +4. **Hand the receipt to the delivery adapter.** Use the `gh stack` + adapter. Do not invent or mutate stack state in a skill; the later `src/delivery` adapter owns submission, merge-when-ready, and exact-head checks. 5. **Never enable GitHub auto-merge on a stack directly.** Only the selected diff --git a/src/delivery/adapters.ts b/src/delivery/adapters.ts index 926aa65..ca118c1 100644 --- a/src/delivery/adapters.ts +++ b/src/delivery/adapters.ts @@ -1,9 +1,13 @@ import type { CommandResult, CommandRunner, + GitSha, + ProvenMembers, + PullRequestNumber, StackActionResult, StackBackend, StackBackendName, + StackMember, StackOperation, StackSnapshot, } from "./types.js"; @@ -12,6 +16,17 @@ export interface AdapterOptions { runner: CommandRunner; } +const PULL_REQUEST_PATTERN = /^[1-9][0-9]*$/; +const GIT_SHA_PATTERN = /^[a-f0-9]{6,64}$/i; + +function gitSha(value: string): GitSha | undefined { + return GIT_SHA_PATTERN.test(value) ? (value as GitSha) : undefined; +} + +function pullRequestNumber(value: string): PullRequestNumber | undefined { + return PULL_REQUEST_PATTERN.test(value) ? (value as PullRequestNumber) : undefined; +} + function parseOutput(result: CommandResult): unknown { const output = result.stdout.trim(); if (!output) return {}; @@ -31,25 +46,55 @@ function field(value: unknown, ...names: string[]): string | undefined { return undefined; } -function stackFields(value: unknown): Pick { - if ( - !value || - typeof value !== "object" || - !Array.isArray((value as { branches?: unknown }).branches) - ) - return {}; - const snapshot = value as { - currentBranch?: unknown; - branches: { name?: unknown; head?: unknown; pr?: { url?: unknown } }[]; - }; - const current = snapshot.branches.find( - (branch) => - typeof snapshot.currentBranch === "string" && branch.name === snapshot.currentBranch, - ); - return { - headSha: typeof current?.head === "string" ? current.head : undefined, - pullRequest: typeof current?.pr?.url === "string" ? current.pr.url : undefined, - }; +function parseGhStackMembership(raw: unknown): ProvenMembers | undefined { + if (!raw || typeof raw !== "object" || !Array.isArray((raw as { branches?: unknown }).branches)) + return undefined; + const entries = (raw as { branches: unknown[] }).branches.flatMap((branch) => { + if (!branch || typeof branch !== "object") return []; + const item = branch as { + head?: unknown; + base?: unknown; + isMerged?: unknown; + pr?: { number?: unknown; state?: unknown }; + }; + if (item.isMerged === true || item.pr?.state === "MERGED") return []; + const head = typeof item.head === "string" ? gitSha(item.head) : undefined; + const base = typeof item.base === "string" ? gitSha(item.base) : undefined; + const number = item.pr?.number; + if (typeof number !== "number" || !Number.isInteger(number) || number <= 0) return []; + const pullRequest = pullRequestNumber(String(number)); + if (!head || !base || !pullRequest) return []; + return [{ pullRequest, headSha: head, baseSha: base }]; + }); + if (entries.length === 0) return undefined; + const heads = new Map(entries.map((entry) => [entry.headSha, entry])); + if (heads.size !== entries.length) return undefined; + const roots = entries.filter((entry) => !heads.has(entry.baseSha)); + if (roots.length !== 1) return undefined; + const ordered = [] as StackMember[]; + const visited = new Set(); + let current: StackMember | undefined = roots[0]; + while (current) { + if (visited.has(current.headSha)) return undefined; + visited.add(current.headSha); + ordered.push(current); + const children = entries.filter((entry) => entry.baseSha === current!.headSha); + if (children.length > 1) return undefined; + current = children[0]; + } + if (ordered.length !== entries.length) return undefined; + if (ordered.length === 0) return undefined; + return ordered as unknown as ProvenMembers; +} + +export function membersThrough( + members: ProvenMembers, + targetPullRequest: string, +): ProvenMembers | undefined { + if (!PULL_REQUEST_PATTERN.test(targetPullRequest)) return undefined; + const target = members.findIndex((member) => member.pullRequest === targetPullRequest); + if (target < 0) return undefined; + return members.slice(0, target + 1) as unknown as ProvenMembers; } function asCommandFailure(error: unknown): CommandResult { @@ -75,9 +120,8 @@ function branchOperand(value: string): string { return value; } -function pullRequestOperand(value: string | undefined): string[] { - if (value === undefined) return []; - if (!/^[1-9][0-9]*$/.test(value)) throw new Error(`unsafe pull request operand: ${value}`); +function pullRequestOperand(value: string): string[] { + if (!PULL_REQUEST_PATTERN.test(value)) throw new Error(`unsafe pull request operand: ${value}`); return [value]; } @@ -96,17 +140,11 @@ abstract class CliStackBackend implements StackBackend { public constructor(protected readonly runner: CommandRunner) {} public async inspect(): Promise { - const argv = this.argv({ kind: "inspect" }); - const result = await run(this.runner, argv); - const raw = parseOutput(result); - const stack = this.name === "gh-stack" ? stackFields(raw) : {}; - return { - backend: this.name, - repoIdentity: field(raw, "repoIdentity", "repository", "repo", "name"), - headSha: stack.headSha ?? field(raw, "headSha", "head_sha", "sha", "oid"), - pullRequest: stack.pullRequest ?? field(raw, "pullRequest", "pull_request", "pr", "url"), - raw, - }; + const result = await run(this.runner, this.argv({ kind: "inspect" })); + if (result.exitCode !== 0) return { kind: "unproven", backend: this.name }; + const members = parseGhStackMembership(parseOutput(result)); + if (!members) return { kind: "unproven", backend: this.name }; + return { kind: "proven", backend: this.name, members }; } public async execute(operation: StackOperation): Promise { @@ -153,80 +191,17 @@ export class GhStackBackend extends CliStackBackend { export const GhStackAdapter = GhStackBackend; -/** Adapter for Graphite's official `gt` CLI. It stores no stack state. */ -export class GraphiteBackend extends CliStackBackend { - public readonly name = "graphite" as const; - - protected argv(operation: StackOperation): readonly string[] { - switch (operation.kind) { - case "inspect": - return ["gt", "log", "short", "--stack", "--reverse"]; - case "prepare": - return ["gt", "create", branchOperand(operation.branch), "--no-interactive"]; - case "submit": - return [ - "gt", - "submit", - ...(operation.draft ? ["--draft"] : []), - ...(operation.draft ? [] : ["--publish"]), - "--no-edit", - "--no-interactive", - ]; - case "sync": - return ["gt", "sync", "--no-interactive"]; - case "rebase": - return ["gt", "restack", "--no-interactive"]; - case "auto-merge": - return [ - "gt", - "submit", - "--merge-when-ready", - "--always", - "--update-only", - "--no-edit", - "--no-interactive", - ]; - } - } -} - -export const GraphiteAdapter = GraphiteBackend; - -export function isGraphiteAvailable( - availableCommands: readonly (string | { name: string })[], -): boolean { - return availableCommands.some( - (command) => (typeof command === "string" ? command : command.name) === "gt", - ); -} - export interface DeliveryBackends { ghStack: GhStackBackend; - graphite?: GraphiteBackend; } -/** gh is the default; Graphite is exposed only after a capability preflight. */ export function createDeliveryBackends(options: { runner: CommandRunner; availableCommands?: readonly (string | { name: string })[]; }): DeliveryBackends { - const backends: DeliveryBackends = { ghStack: new GhStackBackend(options.runner) }; - if (options.availableCommands && isGraphiteAvailable(options.availableCommands)) { - backends.graphite = new GraphiteBackend(options.runner); - } - return backends; + return { ghStack: new GhStackBackend(options.runner) }; } export function createGhStackBackend(options: AdapterOptions): GhStackBackend { return new GhStackBackend(options.runner); } - -/** Returns no adapter when the optional `gt` capability is not present. */ -export function createGraphiteBackend(options: { - runner: CommandRunner; - availableCommands: readonly (string | { name: string })[]; -}): GraphiteBackend | undefined { - return isGraphiteAvailable(options.availableCommands) - ? new GraphiteBackend(options.runner) - : undefined; -} diff --git a/src/delivery/authorization.ts b/src/delivery/authorization.ts index 05d3a76..abd9e0f 100644 --- a/src/delivery/authorization.ts +++ b/src/delivery/authorization.ts @@ -38,8 +38,7 @@ export function validateEvidenceReceipt(receipt: unknown): string[] { } if (!validFileEvidence(value.featureMap)) reasons.push("invalid feature map evidence"); if (!validFileEvidence(value.skill)) reasons.push("invalid skill evidence"); - if (value.backend !== "gh-stack" && value.backend !== "graphite") - reasons.push("missing evidence: backend"); + if (value.backend !== "gh-stack") reasons.push("missing evidence: backend"); const checks = value.deterministicChecks; if ( diff --git a/src/delivery/types.ts b/src/delivery/types.ts index c64d980..5db0e90 100644 --- a/src/delivery/types.ts +++ b/src/delivery/types.ts @@ -2,10 +2,34 @@ export const AUTONOMY_LEVELS = ["verify", "prepare", "pr", "merge-ready", "auto-merge"] as const; export type AutonomyLevel = (typeof AUTONOMY_LEVELS)[number]; -export type StackBackendName = "gh-stack" | "graphite"; +export type StackBackendName = "gh-stack"; export type DeliveryOrigin = "human" | "benny"; export type ProjectReadiness = "ready" | "not-ready"; +export type GitSha = string & { readonly __brand: "GitSha" }; +export type PullRequestNumber = string & { readonly __brand: "PullRequestNumber" }; + +export interface StackMember { + readonly pullRequest: PullRequestNumber; + readonly headSha: GitSha; + readonly baseSha: GitSha; +} + +export type ProvenMembers = readonly [StackMember, ...StackMember[]] & { + readonly __brand: "ProvenMembers"; +}; + +export type StackSnapshot = + | { + readonly kind: "proven"; + readonly backend: StackBackendName; + readonly members: ProvenMembers; + } + | { + readonly kind: "unproven"; + readonly backend: StackBackendName; + }; + export interface CommandResult { exitCode: number; stdout: string; @@ -135,15 +159,7 @@ export type StackOperation = | { kind: "submit"; draft: boolean } | { kind: "sync" } | { kind: "rebase" } - | { kind: "auto-merge"; pullRequest?: string }; - -export interface StackSnapshot { - backend: StackBackendName; - repoIdentity?: string; - headSha?: string; - pullRequest?: string; - raw: unknown; -} + | { kind: "auto-merge"; pullRequest: string }; export interface StackActionResult { backend: StackBackendName; diff --git a/test/delivery/delivery.test.mjs b/test/delivery/delivery.test.mjs index bdeec21..92c2e9d 100644 --- a/test/delivery/delivery.test.mjs +++ b/test/delivery/delivery.test.mjs @@ -5,7 +5,27 @@ import { createJiti } from "jiti"; const jiti = createJiti(import.meta.url, { interopDefault: true }); const delivery = await jiti.import("../../src/delivery/index.ts"); -function runnerFor(backend) { +const twoPrStack = { + trunk: "main", + branches: [ + { + name: "base-feature", + head: "111111", + base: "000000", + isMerged: false, + pr: { number: 41, state: "OPEN" }, + }, + { + name: "feature", + head: "abc123", + base: "111111", + isMerged: false, + pr: { number: 42, state: "OPEN" }, + }, + ], +}; + +function runnerForStack(payload, exitCode = 0) { const calls = []; return { calls, @@ -13,29 +33,8 @@ function runnerFor(backend) { calls.push([...argv]); if (argv[0] === "gh" && argv.includes("view")) { return { - exitCode: 0, - stdout: JSON.stringify({ - trunk: "main", - currentBranch: "feature", - branches: [ - { - name: "feature", - head: "abc123", - pr: { number: 42, url: "https://github.com/acme/demo/pull/42" }, - }, - ], - }), - stderr: "", - }; - } - if (argv.includes("log")) { - return { - exitCode: 0, - stdout: JSON.stringify({ - repoIdentity: "acme/demo", - headSha: "abc123", - pullRequest: "42", - }), + exitCode, + stdout: typeof payload === "string" ? payload : JSON.stringify(payload), stderr: "", }; } @@ -45,35 +44,105 @@ function runnerFor(backend) { stderr: "", }; }, - backend, }; } -for (const [label, Backend, expectedCommand] of [ - ["gh stack", delivery.GhStackBackend, "gh"], - ["Graphite", delivery.GraphiteBackend, "gt"], -]) { - test(`${label} adapter translates argv and parses fixture output`, async () => { - const runner = runnerFor(label); - const backend = new Backend(runner); +test("gh stack inspect returns proven parent-first members for a two-PR chain", async () => { + const runner = runnerForStack(twoPrStack); + const backend = new delivery.GhStackBackend(runner); + const snapshot = await backend.inspect(); + assert.equal(snapshot.kind, "proven"); + assert.equal(snapshot.backend, "gh-stack"); + assert.deepEqual( + snapshot.members.map((member) => ({ + pullRequest: member.pullRequest, + headSha: member.headSha, + baseSha: member.baseSha, + })), + [ + { pullRequest: "41", headSha: "111111", baseSha: "000000" }, + { pullRequest: "42", headSha: "abc123", baseSha: "111111" }, + ], + ); + const prefix = delivery.membersThrough(snapshot.members, "42"); + assert.deepEqual(prefix, snapshot.members); +}); + +test("gh stack inspect is unproven for invalid membership topologies", async () => { + const cases = [ + [ + "fork", + { + branches: [ + { head: "aaa", base: "000", pr: { number: 1, state: "OPEN" } }, + { head: "bbb", base: "000", pr: { number: 2, state: "OPEN" } }, + ], + }, + ], + [ + "cycle", + { + branches: [ + { head: "aaa", base: "bbb", pr: { number: 1, state: "OPEN" } }, + { head: "bbb", base: "aaa", pr: { number: 2, state: "OPEN" } }, + ], + }, + ], + [ + "duplicate head", + { + branches: [ + { head: "aaa", base: "000", pr: { number: 1, state: "OPEN" } }, + { head: "aaa", base: "111", pr: { number: 2, state: "OPEN" } }, + ], + }, + ], + [ + "missing base", + { + branches: [{ head: "aaa", pr: { number: 1, state: "OPEN" } }], + }, + ], + ["exit code 1 with valid JSON", twoPrStack, 1], + ["empty stdout", "", 0], + ["malformed stdout", "not-json", 0], + [ + "all merged", + { + branches: [ + { + head: "aaa", + base: "000", + isMerged: true, + pr: { number: 1, state: "MERGED" }, + }, + ], + }, + ], + ]; + for (const [label, payload, exitCode = 0] of cases) { + const backend = new delivery.GhStackBackend(runnerForStack(payload, exitCode)); const snapshot = await backend.inspect(); - assert.equal(snapshot.backend, label === "gh stack" ? "gh-stack" : "graphite"); - if (label === "gh stack") { - assert.equal(snapshot.headSha, "abc123"); - assert.equal(snapshot.pullRequest, "https://github.com/acme/demo/pull/42"); - } - const result = await backend.execute({ kind: "submit", draft: true }); - assert.equal(result.accepted, true); - assert.equal(result.pullRequest, "42"); - assert.equal(runner.calls[0][0], expectedCommand); - assert.ok(runner.calls.every((argv) => Array.isArray(argv))); - }); -} + assert.equal(snapshot.kind, "unproven", label); + assert.equal(snapshot.backend, "gh-stack"); + } +}); + +test("membersThrough returns undefined for missing and malformed targets without throwing", () => { + const members = [ + { pullRequest: "41", headSha: "111111", baseSha: "000000" }, + { pullRequest: "42", headSha: "abc123", baseSha: "111111" }, + ]; + const proven = delivery.membersThrough(members, "99"); + assert.equal(proven, undefined); + assert.doesNotThrow(() => delivery.membersThrough(members, "--admin")); + assert.equal(delivery.membersThrough(members, "--admin"), undefined); +}); test("gh stack adapter emits only documented non-interactive commands", async () => { - const runner = runnerFor("gh stack"); + const runner = runnerForStack(twoPrStack); const backend = new delivery.GhStackBackend(runner); - await backend.inspect(); + await backend.execute({ kind: "inspect" }); await backend.execute({ kind: "prepare", branch: "feature" }); await backend.execute({ kind: "submit", draft: false }); await backend.execute({ kind: "sync" }); @@ -89,45 +158,15 @@ test("gh stack adapter emits only documented non-interactive commands", async () ]); }); -test("Graphite adapter emits documented non-interactive commands", async () => { - const runner = runnerFor("Graphite"); - const backend = new delivery.GraphiteBackend(runner); - await backend.inspect(); - await backend.execute({ kind: "prepare", branch: "feature" }); - await backend.execute({ kind: "submit", draft: true }); - await backend.execute({ kind: "sync" }); - await backend.execute({ kind: "rebase" }); - await backend.execute({ kind: "auto-merge" }); - assert.deepEqual(runner.calls, [ - ["gt", "log", "short", "--stack", "--reverse"], - ["gt", "create", "feature", "--no-interactive"], - ["gt", "submit", "--draft", "--no-edit", "--no-interactive"], - ["gt", "sync", "--no-interactive"], - ["gt", "restack", "--no-interactive"], - [ - "gt", - "submit", - "--merge-when-ready", - "--always", - "--update-only", - "--no-edit", - "--no-interactive", - ], - ]); -}); - -test("Graphite is capability-gated while gh stack remains the default", () => { - const runner = runnerFor("both"); - const withoutGt = delivery.createDeliveryBackends({ runner, availableCommands: ["gh"] }); - assert.ok(withoutGt.ghStack); - assert.equal(withoutGt.graphite, undefined); - assert.equal(delivery.isGraphiteAvailable(["gh", { name: "other" }]), false); - const withGt = delivery.createDeliveryBackends({ runner, availableCommands: ["gh", "gt"] }); - assert.ok(withGt.graphite); +test("createDeliveryBackends exposes gh stack only", () => { + const runner = runnerForStack(twoPrStack); + const backends = delivery.createDeliveryBackends({ runner, availableCommands: ["gh", "gt"] }); + assert.ok(backends.ghStack); + assert.equal(backends.graphite, undefined); }); test("stack adapters reject flag-shaped branch and pull request operands", async () => { - const backend = new delivery.GhStackBackend(runnerFor("gh stack")); + const backend = new delivery.GhStackBackend(runnerForStack(twoPrStack)); await assert.rejects( backend.execute({ kind: "prepare", branch: "--help" }), /unsafe branch operand/, @@ -177,7 +216,7 @@ function receipt(overrides = {}) { }); } -test("complete receipt authorizes only the selected backend action", () => { +test("complete receipt authorizes gh-stack auto-merge", () => { const allowed = delivery.authorizeDelivery({ receipt: receipt(), currentHeadSha: "abc123", @@ -188,15 +227,11 @@ test("complete receipt authorizes only the selected backend action", () => { assert.equal(allowed.allowed, true); assert.equal(allowed.backend, "gh-stack"); assert.equal(allowed.draftOnly, false); - const wrongBackend = delivery.authorizeDelivery({ - receipt: receipt(), - currentHeadSha: "abc123", - backend: "graphite", - level: "auto-merge", - projectReadiness: "ready", - }); - assert.equal(wrongBackend.allowed, false); - assert.match(wrongBackend.reasons.join(" "), /backend/i); +}); + +test("validateEvidenceReceipt rejects non-gh-stack backend values", () => { + const reasons = delivery.validateEvidenceReceipt(receipt({ backend: "graphite" })); + assert.match(reasons.join(" "), /backend/i); }); test("negative controls fail closed for every delivery gate", () => { diff --git a/test/resources/resources.test.mjs b/test/resources/resources.test.mjs index cbadc26..3098ba6 100644 --- a/test/resources/resources.test.mjs +++ b/test/resources/resources.test.mjs @@ -148,7 +148,6 @@ test("active resources use Pi runtime contracts", async () => { assert.match(noComments, /agent: "comment-sicko"/); assert.match(recall, /PI_SESSION_FILE/); assert.match(shipping, /gh stack/); - assert.match(shipping, /Graphite/); }); test("agent frontmatter uses valid Pi roles", async () => { From 650922c68c24f29f5ac9db798d1f52a662b3ba58 Mon Sep 17 00:00:00 2001 From: Myke Matos <123015330+shishiv@users.noreply.github.com> Date: Wed, 26 Aug 2026 13:11:40 -0300 Subject: [PATCH 2/3] fix(pstack): prove inspect topology with six-character SHAs Short fixture SHAs never reached the fork and cycle walks. Drop the leftover availableCommands factory argument. --- extensions/poteto-mode.ts | 6 +++--- src/delivery/adapters.ts | 5 +---- test/delivery/delivery.test.mjs | 27 ++++++++++++++++----------- 3 files changed, 20 insertions(+), 18 deletions(-) diff --git a/extensions/poteto-mode.ts b/extensions/poteto-mode.ts index b7fb320..f7b07db 100644 --- a/extensions/poteto-mode.ts +++ b/extensions/poteto-mode.ts @@ -366,7 +366,7 @@ export default function potetoModeExtension(pi: ExtensionAPI): void { root: ctx.cwd, repoIdentity: repository.stdout.trim(), headSha: entry.headSha, - backend: params.backend as StackBackendName, + backend: params.backend, level, }); if (rejection) return deliveryRejected(rejection); @@ -389,7 +389,7 @@ export default function potetoModeExtension(pi: ExtensionAPI): void { root: ctx.cwd, repoIdentity: repository.stdout.trim(), headSha: currentHead.stdout.trim(), - backend: params.backend as StackBackendName, + backend: params.backend, level, }); if (rejection) return deliveryRejected(rejection); @@ -397,7 +397,7 @@ export default function potetoModeExtension(pi: ExtensionAPI): void { receipt, repoIdentity: repository.stdout.trim(), currentHeadSha: currentHead.stdout.trim(), - backend: params.backend as StackBackendName, + backend: params.backend, level, }); if (authorization.draftOnly && params.operation === "submit" && params.draft === false) diff --git a/src/delivery/adapters.ts b/src/delivery/adapters.ts index ca118c1..70886c8 100644 --- a/src/delivery/adapters.ts +++ b/src/delivery/adapters.ts @@ -195,10 +195,7 @@ export interface DeliveryBackends { ghStack: GhStackBackend; } -export function createDeliveryBackends(options: { - runner: CommandRunner; - availableCommands?: readonly (string | { name: string })[]; -}): DeliveryBackends { +export function createDeliveryBackends(options: { runner: CommandRunner }): DeliveryBackends { return { ghStack: new GhStackBackend(options.runner) }; } diff --git a/test/delivery/delivery.test.mjs b/test/delivery/delivery.test.mjs index 92c2e9d..aa6b8b7 100644 --- a/test/delivery/delivery.test.mjs +++ b/test/delivery/delivery.test.mjs @@ -66,6 +66,11 @@ test("gh stack inspect returns proven parent-first members for a two-PR chain", ); const prefix = delivery.membersThrough(snapshot.members, "42"); assert.deepEqual(prefix, snapshot.members); + assert.deepEqual( + delivery.membersThrough(snapshot.members, "41")?.map((member) => member.pullRequest), + ["41"], + ); + assert.equal(delivery.membersThrough(snapshot.members, "99"), undefined); }); test("gh stack inspect is unproven for invalid membership topologies", async () => { @@ -74,8 +79,8 @@ test("gh stack inspect is unproven for invalid membership topologies", async () "fork", { branches: [ - { head: "aaa", base: "000", pr: { number: 1, state: "OPEN" } }, - { head: "bbb", base: "000", pr: { number: 2, state: "OPEN" } }, + { head: "aaa111", base: "000000", pr: { number: 1, state: "OPEN" } }, + { head: "bbb222", base: "000000", pr: { number: 2, state: "OPEN" } }, ], }, ], @@ -83,8 +88,8 @@ test("gh stack inspect is unproven for invalid membership topologies", async () "cycle", { branches: [ - { head: "aaa", base: "bbb", pr: { number: 1, state: "OPEN" } }, - { head: "bbb", base: "aaa", pr: { number: 2, state: "OPEN" } }, + { head: "aaa111", base: "bbb222", pr: { number: 1, state: "OPEN" } }, + { head: "bbb222", base: "aaa111", pr: { number: 2, state: "OPEN" } }, ], }, ], @@ -92,15 +97,15 @@ test("gh stack inspect is unproven for invalid membership topologies", async () "duplicate head", { branches: [ - { head: "aaa", base: "000", pr: { number: 1, state: "OPEN" } }, - { head: "aaa", base: "111", pr: { number: 2, state: "OPEN" } }, + { head: "aaa111", base: "000000", pr: { number: 1, state: "OPEN" } }, + { head: "aaa111", base: "111111", pr: { number: 2, state: "OPEN" } }, ], }, ], [ "missing base", { - branches: [{ head: "aaa", pr: { number: 1, state: "OPEN" } }], + branches: [{ head: "aaa111", pr: { number: 1, state: "OPEN" } }], }, ], ["exit code 1 with valid JSON", twoPrStack, 1], @@ -111,8 +116,8 @@ test("gh stack inspect is unproven for invalid membership topologies", async () { branches: [ { - head: "aaa", - base: "000", + head: "aaa111", + base: "000000", isMerged: true, pr: { number: 1, state: "MERGED" }, }, @@ -160,9 +165,9 @@ test("gh stack adapter emits only documented non-interactive commands", async () test("createDeliveryBackends exposes gh stack only", () => { const runner = runnerForStack(twoPrStack); - const backends = delivery.createDeliveryBackends({ runner, availableCommands: ["gh", "gt"] }); + const backends = delivery.createDeliveryBackends({ runner }); assert.ok(backends.ghStack); - assert.equal(backends.graphite, undefined); + assert.equal("graphite" in backends, false); }); test("stack adapters reject flag-shaped branch and pull request operands", async () => { From c072556e9315eda3caf180f208004e358ccd831c Mon Sep 17 00:00:00 2001 From: Myke Matos <123015330+shishiv@users.noreply.github.com> Date: Wed, 26 Aug 2026 13:13:23 -0300 Subject: [PATCH 3/3] chore(pstack): drop leftover comments in touched delivery files The phase 8 diff added no comments. These lines were narration on restore, preflight, a private helper, Benny speculation, and a types banner. --- extensions/poteto-mode.ts | 5 ----- src/delivery/authorization.ts | 2 -- src/delivery/types.ts | 1 - 3 files changed, 8 deletions(-) diff --git a/extensions/poteto-mode.ts b/extensions/poteto-mode.ts index f7b07db..d023576 100644 --- a/extensions/poteto-mode.ts +++ b/extensions/poteto-mode.ts @@ -51,8 +51,6 @@ export default function potetoModeExtension(pi: ExtensionAPI): void { } function restore(ctx: ExtensionContext): void { - // This closure is intentionally reset from the active branch on every - // session lifecycle event. It prevents state leaking across sessions. active = branchState(ctx); } @@ -68,8 +66,6 @@ export default function potetoModeExtension(pi: ExtensionAPI): void { pi.on("session_start", async (_event, ctx) => { restore(ctx); - // Probe all host capability surfaces during startup without changing - // settings or failing print/JSON sessions. A task invocation fails closed. preflight(ctx); }); @@ -569,7 +565,6 @@ async function verifyPullRequestState( return undefined; } -/** Flatten shell-like and structured tool payloads before applying merge gates. */ function normalizeToolCallInput(input: unknown): string { const values: string[] = []; const seen = new WeakSet(); diff --git a/src/delivery/authorization.ts b/src/delivery/authorization.ts index abd9e0f..6c32797 100644 --- a/src/delivery/authorization.ts +++ b/src/delivery/authorization.ts @@ -129,8 +129,6 @@ export function authorizeDelivery(request: DeliveryAuthorizationRequest): Delive reasons.push("insufficient project readiness"); const origin = request.origin ?? receipt?.origin; - // The shipped receipt tool emits human origin only. The Benny branch remains - // defense-in-depth for any future coordinator-issued receipt mechanism. const draftOnly = origin === "benny"; if (origin === "benny" && (level === "merge-ready" || level === "auto-merge")) reasons.push("Benny origin is draft-only"); diff --git a/src/delivery/types.ts b/src/delivery/types.ts index 5db0e90..3bf4f65 100644 --- a/src/delivery/types.ts +++ b/src/delivery/types.ts @@ -1,4 +1,3 @@ -/** The only delivery state kept by this package is evidence for one head SHA. */ export const AUTONOMY_LEVELS = ["verify", "prepare", "pr", "merge-ready", "auto-merge"] as const; export type AutonomyLevel = (typeof AUTONOMY_LEVELS)[number];