diff --git a/harness/pstack/claude-code/skills/make-bot-ui/SKILL.md b/harness/pstack/claude-code/skills/make-bot-ui/SKILL.md index 7f05b0504..83ce56529 100644 --- a/harness/pstack/claude-code/skills/make-bot-ui/SKILL.md +++ b/harness/pstack/claude-code/skills/make-bot-ui/SKILL.md @@ -1,9 +1,9 @@ --- name: make-bot-ui description: >- - Use when building a custom UI (page, dashboard, buttons) that should wake a - Grok Bot over a webhook, when the user must provide a webhook sender key, or - when exposing that UI on Tailscale. + Build a custom UI (page, dashboard, buttons) that wakes a Grok Bot over a + webhook, keeps the webhook sender key on the server, and optionally exposes + the UI on Tailscale. Run it with /make-bot-ui. disable-model-invocation: true --- # How to make a bot UI diff --git a/harness/pstack/claude-code/skills/poteto-mode/playbooks/worktree-cleanup.md b/harness/pstack/claude-code/skills/poteto-mode/playbooks/worktree-cleanup.md index 45e1af693..fb7cfad88 100644 --- a/harness/pstack/claude-code/skills/poteto-mode/playbooks/worktree-cleanup.md +++ b/harness/pstack/claude-code/skills/poteto-mode/playbooks/worktree-cleanup.md @@ -1,10 +1,12 @@ ### Worktree and simulator cleanup -1. Snapshot and audit. Record `df -h /`, then run `scripts/worktree-audit.sh [-b ] [repo-path]` (principle-build-the-lever). Without `-b` it checks `MERGED` against `origin/HEAD`, so pass `-b origin/` when PRs target another branch. It reads paths from `git worktree list`, never hand-typed, since a hand-typed `myrepo-worktrees/x` misses one that lives at `.cursor/worktrees/myrepo/x` (principle-encode-lessons-in-structure). It classifies each worktree by size, age, merge state, uncommitted work, PR state, and the newest chat that touched it, then suggests a bucket. The transcript scan is slow, so background it. +**You own the disk and the safety gate.** Prune merged or abandoned git worktrees and stale iOS simulators to reclaim space. Deletion is irreversible, so every step guards against deleting something in use or holding uncommitted work. + +1. Snapshot and audit. Record `df -h /`, then run `scripts/worktree-audit.sh [-b ] [repo-path]` (principle-build-the-lever). Without `-b` it checks `MERGED` against `origin/HEAD`, so pass `-b origin/` when PRs target another branch. It reads paths from `git worktree list`, never hand-typed, since a hand-typed `myrepo-worktrees/x` misses one that lives at `.cursor/worktrees/myrepo/x` (principle-encode-lessons-in-structure). It classifies each worktree by size, age, merge state, uncommitted work, PR state, and the newest chat that touched it, then suggests a bucket. `hold-unpushed` means the worktree holds commits that exist nowhere else (ahead of its remote, no remote branch, or detached and not on the base, and HEAD is not the head of one of its PRs); push them or get a decision before anything else. A closed-but-unmerged PR is not evidence the work is safe. The transcript scan is slow, so background it. 2. The bucket is advice, not permission. The pinned and active chats are the real artifact (principle-prove-it-works). Get that set from the user or sidebar and cross-check every candidate. The lever has marked `safe` a worktree the user had pinned, so the pinned set wins. 3. Verify usage before deleting. For every `verify-recent-chat` row, or anything you doubt, fan subagents out to read the transcripts and report whether the chat is pinned or ongoing and which worktrees it touches (principle-guard-the-context-window, transcripts are bulk). A pinned chat spawns arena and repro trees into sibling worktrees via background subagents, and those are in use even when their names never hit the sidebar. Pick each subagent's reasoning level and fast or standard speed for the job, per the Subagents section. -4. Pause on irreversible loss. `wip:N` is N tracked uncommitted edits. Show the diff and get a decision first, since removing a clean worktree is recoverable from its branch but uncommitted work is gone. `scratch:N` is untracked throwaway, safe to drop, but name the files. Per Autonomy, clean and merged and not-in-use proceeds. `wip` and in-use pause. -5. Prune the confirmed set. Per path, `git worktree remove --force `. If the dir survives on ignored build artifacts, `rm -rf` it, then `git worktree prune`. Branch refs survive, so no commits are lost. Confirm with `df -h /` and re-list. +4. Pause on irreversible loss. `wip:N` counts tracked, untracked, and ignored paths. For each candidate, run `git -C status --porcelain=v1 --untracked-files=all --ignored=matching`; show diffs for tracked edits and list untracked and ignored paths without opening their contents. Default to preserving every worktree with `wip`. Remove one only after the user explicitly approves every path that its removal will delete. Never treat untracked or ignored files as disposable scratch. +5. Prune only the fully approved set. `git worktree remove ` can delete ignored-only files without `--force`, so the command is not a preservation check. Re-run the step 4 status preflight immediately before removal; if any path lacks explicit approval, retain the entire worktree. If Git refuses, stop and retain it; do not use `rm -rf` or `git worktree prune` to bypass the refusal. Branch refs survive, so no commits are lost. Confirm with `df -h /` and re-list. 6. Simulators and other reclaimers. Simulators are usually the next-biggest win. `xcrun simctl --set testing delete all` (XCTestDevices clones), `xcrun simctl delete unavailable`, and `xcrun simctl runtime list` then `runtime delete ` for old runtimes. More when needed: Xcode `DerivedData` and `iOS DeviceSupport`, `~/Library/Application Support/Cursor` (`state.vscdb.backup`, and `snapshots/roots/` where a `` named for a folder you opened as a workspace balloons), package caches (pnpm, uv, brew, yarn). Clear only caches the user has not said to keep. This is the one playbook that deletes user state with no code review to catch a slip, so the gates above are the review. diff --git a/harness/pstack/claude-code/skills/poteto-mode/scripts/package.json b/harness/pstack/claude-code/skills/poteto-mode/scripts/package.json index c8839d74d..5b1f38161 100644 --- a/harness/pstack/claude-code/skills/poteto-mode/scripts/package.json +++ b/harness/pstack/claude-code/skills/poteto-mode/scripts/package.json @@ -3,7 +3,7 @@ "private": true, "type": "module", "scripts": { - "test": "bun test watch-pr check-plan.test.ts", + "test": "bun test watch-pr check-plan.test.ts worktree-audit", "typecheck": "tsc --project watch-pr/tsconfig.json --noEmit --strict" }, "dependencies": { diff --git a/harness/pstack/claude-code/skills/poteto-mode/scripts/watch-pr/cli.test.ts b/harness/pstack/claude-code/skills/poteto-mode/scripts/watch-pr/cli.test.ts index 0656b413e..73977530e 100644 --- a/harness/pstack/claude-code/skills/poteto-mode/scripts/watch-pr/cli.test.ts +++ b/harness/pstack/claude-code/skills/poteto-mode/scripts/watch-pr/cli.test.ts @@ -1,6 +1,7 @@ import { describe, expect, it } from "bun:test"; import { type CliRuntime, main, parseArgs } from "./cli.ts"; import { fakeReader, passingCheck } from "./fakes.test-helper.ts"; +import { WatcherQueryError } from "./github.ts"; import { renderJson, renderPretty } from "./render.ts"; import type { GitHubReader, WatcherVerdict } from "./types.ts"; import { parsePrNumber } from "./types.ts"; @@ -103,6 +104,80 @@ describe("parseArgs", () => { }); }); +describe("heads without checks", () => { + const noChecks = () => + fakeReader({ + fastPath: { + kind: "unusable", + exitCode: 1, + stderr: "no checks reported on the 'feature' branch", + }, + rollupPages: [{ checks: [], endCursor: null }], + }); + + it("--status-only reports the head once instead of retrying", async () => { + const harness = testRuntime(noChecks()); + expect(await main(["--pr", "1", "--status-only"], harness.runtime)).toBe(0); + expect(harness.stdout).toHaveLength(1); + const verdict = JSON.parse(harness.stdout[0] ?? "") as WatcherVerdict; + expect(verdict.kind).toBe("STATUS"); + if (verdict.kind !== "STATUS") throw new Error("expected STATUS"); + const row = verdict.rows[0]; + expect(row.kind).toBe("open"); + if (row.kind !== "open") throw new Error("expected open row"); + expect(row.ci.kind).toBe("ci-none"); + }); + + it("--status-only --pretty names the missing checks", async () => { + const harness = testRuntime(noChecks()); + expect( + await main(["--pr", "1", "--status-only", "--pretty"], harness.runtime) + ).toBe(0); + expect(harness.stdout.join("")).toContain("no checks reported"); + }); + + it("--status-only ends on the first query error instead of sleeping", async () => { + const reader = noChecks(); + const failing: GitHubReader = { + ...reader, + async pullRequest() { + throw new WatcherQueryError({ + kind: "json-parse", + retryable: true, + detail: "simulated outage", + }); + }, + }; + const harness = testRuntime(failing); + const code = await main(["--pr", "1", "--status-only"], harness.runtime); + expect(code).not.toBe(0); + expect(harness.stdout).toHaveLength(1); + const verdict = JSON.parse(harness.stdout[0] ?? "") as WatcherVerdict; + expect(verdict.kind).not.toBe("RETRY"); + }); + + it("polling still treats a head without checks as a retryable error", async () => { + const harness = testRuntime(noChecks()); + const slept: number[] = []; + const runtime: CliRuntime = { + ...harness.runtime, + clock: { + ...harness.runtime.clock, + async sleep(seconds) { + slept.push(seconds); + if (slept.length >= 2) throw new Error("stop test"); + }, + }, + }; + await expect( + main(["--pr", "1", "--max-query-errors", "5"], runtime) + ).rejects.toThrow("stop test"); + expect(slept.length).toBe(2); + const first = JSON.parse(harness.stdout[0] ?? "") as WatcherVerdict; + expect(first.kind).toBe("RETRY"); + }); +}); + describe("rendering", () => { const context = { owner: "owner", diff --git a/harness/pstack/claude-code/skills/poteto-mode/scripts/watch-pr/cli.ts b/harness/pstack/claude-code/skills/poteto-mode/scripts/watch-pr/cli.ts index ec8a2f27c..ea9f0895c 100644 --- a/harness/pstack/claude-code/skills/poteto-mode/scripts/watch-pr/cli.ts +++ b/harness/pstack/claude-code/skills/poteto-mode/scripts/watch-pr/cli.ts @@ -126,7 +126,11 @@ export function parseArgs( positiveInteger, 5 ) - .option("--status-only", "print one status table and exit 0", false) + .option( + "--status-only", + "read GitHub once, print one status table and exit 0; a head with no checks is reported, not retried", + false + ) .option("--allow-draft", "do not treat a draft as a merge gate", false) .option("--pretty", "render human text instead of JSON", false); program.parse(argv, { from: "user" }); diff --git a/harness/pstack/claude-code/skills/poteto-mode/scripts/watch-pr/policy.test.ts b/harness/pstack/claude-code/skills/poteto-mode/scripts/watch-pr/policy.test.ts index 966b75367..a67521835 100644 --- a/harness/pstack/claude-code/skills/poteto-mode/scripts/watch-pr/policy.test.ts +++ b/harness/pstack/claude-code/skills/poteto-mode/scripts/watch-pr/policy.test.ts @@ -92,6 +92,54 @@ describe("readiness truth table", () => { }); describe("snapshot query planning", () => { + it("reports a head without checks as ci-none when asked to", async () => { + const reader = fakeReader({ + fastPath: { + kind: "unusable", + exitCode: 1, + stderr: "no checks reported on the 'feature' branch", + }, + rollupPages: [{ checks: [], endCursor: null }], + }); + const snapshot = await readSnapshot({ + reader, + context: context(7), + pendingHistory: "include", + allowDraft: false, + noChecks: "report", + }); + expect(snapshot.kind).toBe("open"); + if (snapshot.kind !== "open") throw new Error("expected open snapshot"); + expect(snapshot.ci.kind).toBe("ci-none"); + expect(snapshot.ci.all).toEqual([]); + expect(snapshot.reviewAutomationRunning).toBe(false); + if (snapshot.ci.kind !== "ci-none") throw new Error("expected ci-none"); + expect(snapshot.ci.github.kind).toBe("allowed"); + expect(reader.calls).toContain("commitRollups"); + }); + + it("keeps a head without checks as a retryable query error by default", async () => { + const reader = fakeReader({ + fastPath: { kind: "checks", checks: [] }, + rollupPages: [{ checks: [], endCursor: null }], + }); + let caught: unknown = null; + try { + await readSnapshot({ + reader, + context: context(8), + pendingHistory: "include", + allowDraft: false, + }); + } catch (error) { + caught = error; + } + expect(caught).toBeInstanceOf(WatcherQueryError); + if (!(caught instanceof WatcherQueryError)) throw new Error("expected query error"); + expect(caught.failure.kind).toBe("checks-unavailable"); + expect(caught.failure.retryable).toBe(true); + }); + it("does not query commit rollups while queued checks are pending", async () => { const reader = fakeReader({ fastPath: { kind: "checks", checks: [pendingCheck()] }, diff --git a/harness/pstack/claude-code/skills/poteto-mode/scripts/watch-pr/policy.ts b/harness/pstack/claude-code/skills/poteto-mode/scripts/watch-pr/policy.ts index 8179f2d8b..2552538fc 100644 --- a/harness/pstack/claude-code/skills/poteto-mode/scripts/watch-pr/policy.ts +++ b/harness/pstack/claude-code/skills/poteto-mode/scripts/watch-pr/policy.ts @@ -1,4 +1,5 @@ import { + ChecksUnavailable, WatcherQueryError, resolveChecks, resolveReviewThreads, @@ -61,6 +62,8 @@ export async function readSnapshot(args: { readonly context: T.PrContext; readonly pendingHistory: "include" | "omit"; readonly allowDraft: boolean; + /** `report`: a head with no checks becomes `ci-none`. `retry` (default): it stays a retryable query error, because a just-pushed head looks the same. */ + readonly noChecks?: "report" | "retry"; }): Promise { const facts = await args.reader.pullRequest(args.context); if (facts.state === "MERGED" || facts.mergedAt !== null) @@ -68,7 +71,30 @@ export async function readSnapshot(args: { if (facts.state === "CLOSED") return { kind: "closed", context: args.context, facts }; const threads = await resolveReviewThreads(args.reader, args.context); - const checks = await resolveChecks(args.reader, args.context); + let checks: T.CheckRead; + try { + checks = await resolveChecks(args.reader, args.context); + } catch (error) { + if (!(error instanceof ChecksUnavailable) || args.noChecks !== "report") + throw error; + const merge = await mergeAssessment(args.reader, facts); + return { + kind: "open", + context: args.context, + facts, + threads, + ci: { + kind: "ci-none", + source: "none", + all: [], + failed: [], + pending: [], + hadPreviousPassingCi: merge.hadPreviousPassingCi, + github: merge.github, + }, + reviewAutomationRunning: false, + }; + } const failed = nonEmpty( checks.checks.filter( (check): check is T.FailedCheck => check.kind === "failed" @@ -428,6 +454,12 @@ export async function runSimple(args: { readonly options: T.PollingOptions; }): Promise { const stamp = verdictFactory(args.dependencies.clock, args.mode); + // `--status-only` reads GitHub once: a head without checks is reported as + // such instead of being retried, and a query error ends the run on the + // first failure instead of sleeping through the backoff schedule. + const options: T.PollingOptions = args.statusOnly + ? { ...args.options, maxQueryErrors: 1 } + : args.options; const step = async (): Promise> => { const rows: T.PrSnapshot[] = []; for (const context of args.contexts) @@ -437,6 +469,7 @@ export async function runSimple(args: { context, pendingHistory: "include", allowDraft: args.options.allowDraft, + noChecks: args.statusOnly ? "report" : "retry", }) ); const complete = nonEmpty(rows); @@ -518,7 +551,7 @@ export async function runSimple(args: { }; return pollUntilTerminal({ dependencies: args.dependencies, - options: args.options, + options, stamp, step, }); diff --git a/harness/pstack/claude-code/skills/poteto-mode/scripts/watch-pr/render.ts b/harness/pstack/claude-code/skills/poteto-mode/scripts/watch-pr/render.ts index 0e9358393..a0da9ac18 100644 --- a/harness/pstack/claude-code/skills/poteto-mode/scripts/watch-pr/render.ts +++ b/harness/pstack/claude-code/skills/poteto-mode/scripts/watch-pr/render.ts @@ -13,6 +13,8 @@ function ciCell(row: T.PrSnapshot): string { return `❌ ${row.ci.failed.length} failed${row.ci.pending.length ? `, ${row.ci.pending.length} pending` : ""}${was}`; case "ci-github-rejected": return `❌ GitHub reports failing checks${was}`; + case "ci-none": + return `no checks reported${was}`; default: { const exhaustive: never = row.ci; return exhaustive; diff --git a/harness/pstack/claude-code/skills/poteto-mode/scripts/watch-pr/types.ts b/harness/pstack/claude-code/skills/poteto-mode/scripts/watch-pr/types.ts index 60b9abcac..6f6849b5e 100644 --- a/harness/pstack/claude-code/skills/poteto-mode/scripts/watch-pr/types.ts +++ b/harness/pstack/claude-code/skills/poteto-mode/scripts/watch-pr/types.ts @@ -142,7 +142,17 @@ export type CiClean = CiBase & { readonly pending: readonly []; readonly github: GitHubMergeAllowed; }; -export type CiState = CiFailing | CiGithubRejected | CiPending | CiClean; +/** GitHub reported no checks at all for the head. Only `--status-only` produces this; polling keeps retrying, since a freshly pushed head has no checks yet either. */ +export interface CiNone { + readonly kind: "ci-none"; + readonly source: "none"; + readonly all: readonly []; + readonly failed: readonly []; + readonly pending: readonly []; + readonly hadPreviousPassingCi: boolean; + readonly github: GitHubMergeAssessment; +} +export type CiState = CiFailing | CiGithubRejected | CiPending | CiClean | CiNone; export type PrSnapshot = | { readonly kind: "merged" | "closed"; diff --git a/harness/pstack/claude-code/skills/poteto-mode/scripts/worktree-audit.fixture.ts b/harness/pstack/claude-code/skills/poteto-mode/scripts/worktree-audit.fixture.ts new file mode 100644 index 000000000..19b327d07 --- /dev/null +++ b/harness/pstack/claude-code/skills/poteto-mode/scripts/worktree-audit.fixture.ts @@ -0,0 +1,83 @@ +import { spawnSync } from "node:child_process"; +import { mkdtempSync, mkdirSync, rmSync, writeFileSync } from "node:fs"; +import { tmpdir } from "node:os"; +import { basename, join } from "node:path"; + +type PullRequest = { + number: number; + state: string; + headRefName: string; + headRefOid: string; +}; + +type AuditRow = { + merged: string; + dirty: string; + remote: string; + pr: string; + lastChat: string; + bucket: string; +}; + +export function createAuditFixture() { + const root = mkdtempSync(join(tmpdir(), "worktree-audit-test-")); + const home = join(root, "home"); + const bin = join(root, "bin"); + const origin = join(root, "origin.git"); + const repo = join(root, "repo"); + const prs = join(root, "prs.json"); + const env = { + ...process.env, + HOME: home, + GIT_CONFIG_GLOBAL: join(root, "gitconfig"), + GIT_CONFIG_NOSYSTEM: "1", + PATH: `${bin}:${process.env.PATH}`, + AUDIT_FIXTURE_PRS: prs, + }; + const cleanup = () => rmSync(root, { recursive: true, force: true }); + const run = (directory: string, command: string, args: string[]) => { + const result = spawnSync(command, args, { cwd: directory, env, encoding: "utf8" }); + if (result.status !== 0) + throw new Error(`${command} ${args.join(" ")} failed in ${directory}: ${result.error ?? result.stderr}`); + return result.stdout.trim(); + }; + const git = (directory: string, ...args: string[]) => run(directory, "git", args); + const setPrs = (value: PullRequest[]) => writeFileSync(prs, JSON.stringify(value)); + + try { + mkdirSync(home); + mkdirSync(bin); + setPrs([]); + writeFileSync(join(bin, "gh"), '#!/bin/sh\ncat "$AUDIT_FIXTURE_PRS"\n', { mode: 0o755 }); + git(root, "config", "--global", "user.name", "Audit Test"); + git(root, "config", "--global", "user.email", "audit@example.test"); + git(root, "config", "--global", "init.defaultBranch", "main"); + git(root, "init", "--bare", origin); + git(root, "clone", origin, repo); + writeFileSync(join(repo, "base"), "base\n"); + git(repo, "add", "base"); + git(repo, "commit", "-m", "base"); + git(repo, "push", "origin", "main"); + git(repo, "remote", "set-head", "origin", "main"); + } catch (error) { + cleanup(); + throw error; + } + + const audit = (extraEnv: NodeJS.ProcessEnv = {}) => { + const result = spawnSync("bash", [join(import.meta.dir, "worktree-audit.sh"), repo], { + cwd: repo, + env: { ...env, ...extraEnv }, + encoding: "utf8", + }); + const rows: Record = {}; + for (const line of result.stdout.trim().split("\n")) { + if (!line || line.startsWith("# base:") || line.startsWith("SIZE\t")) continue; + const [, , merged, dirty, remote, pr, lastChat, bucket, worktree] = line.split("\t"); + rows[basename(worktree)] = { merged, dirty, remote, pr, lastChat, bucket }; + } + return { status: result.status, stderr: result.stderr, rows }; + }; + + return { root, home, origin, repo, env, run, git, setPrs, audit, cleanup }; +} diff --git a/harness/pstack/claude-code/skills/poteto-mode/scripts/worktree-audit.sh b/harness/pstack/claude-code/skills/poteto-mode/scripts/worktree-audit.sh index 47e08919d..814619627 100755 --- a/harness/pstack/claude-code/skills/poteto-mode/scripts/worktree-audit.sh +++ b/harness/pstack/claude-code/skills/poteto-mode/scripts/worktree-audit.sh @@ -1,8 +1,9 @@ #!/usr/bin/env bash -# Read-only worktree prune audit. Classifies every git worktree by size, merge -# state, uncommitted work, remote/PR state, and the most recent chat that -# operated in it. Emits a table sorted by size with a suggested bucket. Never -# deletes anything; deletion stays a human-gated step in the playbook. +# Worktree prune audit. Classifies every git worktree by size, merge state, +# uncommitted work, remote/PR state, and the most recent chat that operated in +# it. Emits a table sorted by size with a suggested bucket. Never deletes +# anything; deletion stays a human-gated step in the playbook. The only write +# is the best-effort fetch of the base ref for the merge check. # # Usage: worktree-audit.sh [-b|--base ] [repo-path] # repo-path defaults to the current repo. @@ -62,10 +63,10 @@ prs=$(mktemp) if command -v origin >/dev/null 2>&1 \ && raw=$(origin pr list --author "@me" --state all --limit 1000 \ --json number,status,headRef 2>/dev/null); then - printf '%s' "$raw" | jq '[.[] | {number, state: .status, head: .headRef}]' > "$prs" 2>/dev/null + printf '%s' "$raw" | jq '[.[] | {number, state: .status, head: .headRef, head_oid: ""}]' > "$prs" 2>/dev/null elif raw=$(gh pr list --author "@me" --state all --limit 1000 \ - --json number,state,headRefName 2>/dev/null); then - printf '%s' "$raw" | jq '[.[] | {number, state: (.state | ascii_downcase), head: .headRefName}]' > "$prs" 2>/dev/null + --json number,state,headRefName,headRefOid 2>/dev/null); then + printf '%s' "$raw" | jq '[.[] | {number, state: (.state | ascii_downcase), head: .headRefName, head_oid: (.headRefOid // "")}]' > "$prs" 2>/dev/null fi [ -s "$prs" ] || echo "[]" > "$prs" @@ -89,12 +90,10 @@ git worktree list --porcelain | awk '/^worktree /{print $2}' | while read -r wt; # real signal; merge-base only catches fast-forward/rebase merges. git merge-base --is-ancestor "$head" "$base" 2>/dev/null && merged=YES || merged=no - # Distinguish real WIP (tracked edits) from disposable untracked scratch. - porcelain=$(git -C "$wt" status --porcelain 2>/dev/null) + # Treat tracked, untracked, and ignored paths as work that must be held. + porcelain=$(git -C "$wt" status --porcelain --untracked-files=all --ignored=matching 2>/dev/null) if [ -z "$porcelain" ]; then dirty=clean - elif printf '%s\n' "$porcelain" | grep -qv '^??'; then - dirty="wip:$(printf '%s\n' "$porcelain" | grep -cv '^??')" - else dirty="scratch:$(printf '%s\n' "$porcelain" | grep -c '^??')"; fi + else dirty="wip:$(printf '%s\n' "$porcelain" | wc -l | tr -d '[:space:]')"; fi branch=$(git -C "$wt" symbolic-ref --quiet --short HEAD 2>/dev/null || echo "") if [ -z "$branch" ]; then remote=detached @@ -102,10 +101,15 @@ git worktree list --porcelain | awk '/^worktree /{print $2}' | while read -r wt; [ "$(git -C "$wt" rev-parse "origin/$branch" 2>/dev/null)" = "$head" ] \ && remote=pushed \ || remote="ahead$(git -C "$wt" rev-list --count "origin/$branch..HEAD" 2>/dev/null)" + [ "$remote" = ahead0 ] && remote=behind else remote=no-remote; fi - pr=$([ -n "$branch" ] && jq -r --arg b "$branch" \ - '.[] | select(.head==$b) | "#\(.number)/\(.state)"' "$prs" 2>/dev/null | head -1) + # Several PRs can share a branch name; prefer the one whose head is HEAD. + pr_record=$([ -n "$branch" ] && jq -r --arg b "$branch" --arg h "$head" \ + '[.[] | select(.head==$b)] | (map(select(.head_oid==$h)) + .)[0] + | select(.) | "#\(.number)/\(.state)\t\(.head_oid // "")"' "$prs" 2>/dev/null) + pr=${pr_record%%$'\t'*} + pr_head=${pr_record#*$'\t'} [ -z "$pr" ] && pr="-" # Most recent chat whose transcript operated in this worktree. Match path @@ -119,10 +123,19 @@ git worktree list --porcelain | awk '/^worktree /{print $2}' | while read -r wt; fi recent=$([ "$last_ts" -gt 0 ] 2>/dev/null && [ $(( (now - last_ts) / 86400 )) -le 4 ] && echo yes || echo no) + # An exact PR head proves the commits were pushed even if origin has no + # branch (deleted after merging, or hosted on a fork). A closed PR still + # proves nothing about whether those commits were merged. + case "$pr" in */merged) pr_merged=yes ;; *) pr_merged=no ;; esac + case "$remote" in ahead*|no-remote|detached) unpushed=yes ;; *) unpushed=no ;; esac + [ "$merged" = YES ] && unpushed=no + [ -n "$pr_head" ] && [ "$head" = "$pr_head" ] && unpushed=no + case "$dirty" in wip:*) bucket=hold-wip ;; *) case "$pr" in */open|*/draft) bucket=hold-open-pr ;; *) - if [ "$recent" = yes ]; then bucket=verify-recent-chat - elif [ "$merged" = YES ] || [ "$pr" != "-" ]; then bucket=safe + if [ "$unpushed" = yes ]; then bucket=hold-unpushed + elif [ "$recent" = yes ]; then bucket=verify-recent-chat + elif [ "$merged" = YES ] || [ "$pr_merged" = yes ]; then bucket=safe else bucket=review; fi ;; esac ;; esac diff --git a/harness/pstack/claude-code/skills/poteto-mode/scripts/worktree-audit.test.ts b/harness/pstack/claude-code/skills/poteto-mode/scripts/worktree-audit.test.ts new file mode 100644 index 000000000..46166b4a2 --- /dev/null +++ b/harness/pstack/claude-code/skills/poteto-mode/scripts/worktree-audit.test.ts @@ -0,0 +1,112 @@ +import { mkdirSync, rmSync, writeFileSync } from "node:fs"; +import { join } from "node:path"; +import { afterAll, expect, test } from "bun:test"; +import { createAuditFixture } from "./worktree-audit.fixture"; + +const fixture = createAuditFixture(); +afterAll(fixture.cleanup); +const { root, repo, origin, git } = fixture; +const worktree = (branch: string) => { + const directory = join(root, branch); + git(repo, "worktree", "add", "-b", branch, directory, "main"); + return directory; +}; +const commit = (directory: string, file: string) => { + writeFileSync(join(directory, file), `${file}\n`); + git(directory, "add", file); + git(directory, "commit", "-m", file); + return git(directory, "rev-parse", "HEAD"); +}; +const pushed = (branch: string) => { + const directory = worktree(branch); + const head = commit(directory, branch); + git(directory, "push", "origin", branch); + return { directory, head }; +}; + +const closedUnpushed = pushed("closed-unpushed"); +commit(closedUnpushed.directory, "local-only"); +const closedPushed = pushed("closed-pushed"); +const behind = pushed("behind"); +const other = join(root, "other"); +git(root, "clone", origin, other); +git(other, "checkout", "behind"); +commit(other, "remote-only"); +git(other, "push", "origin", "behind"); +git(behind.directory, "fetch", "origin"); + +const squashPruned = pushed("squash-pruned"); +const squashKept = pushed("squash-kept"); +const mergedThenMore = pushed("merged-then-more"); +for (const branch of ["squash-pruned", "squash-kept", "merged-then-more"]) { + git(repo, "merge", "--squash", branch); + git(repo, "commit", "-m", `Squash ${branch}`); +} +git(repo, "push", "origin", "main"); +git(repo, "push", "origin", "--delete", "squash-pruned", "merged-then-more"); +git(repo, "fetch", "--prune", "origin"); +commit(mergedThenMore.directory, "after-merge"); + +const detached = join(root, "detached-off-base"); +git(repo, "worktree", "add", "--detach", detached, "main"); +commit(detached, "detached-only"); + +const gone = join(root, "detached-gone"); +git(repo, "worktree", "add", "--detach", gone, "main"); +commit(gone, "gone-only"); +rmSync(gone, { recursive: true, force: true }); + +const reused = pushed("reused-name"); +git(repo, "push", "origin", "--delete", "reused-name"); +git(repo, "fetch", "--prune", "origin"); + +// Local-only files that git status hides by default: an untracked file, and +// ignored build output. Both are work the audit must hold, never scratch. +const untrackedOnly = pushed("untracked-only"); +writeFileSync(join(untrackedOnly.directory, "notes.md"), "untracked\n"); +const ignoredOnly = pushed("ignored-only"); +writeFileSync(join(ignoredOnly.directory, ".gitignore"), "build/\nrun.log\n"); +git(ignoredOnly.directory, "add", ".gitignore"); +git(ignoredOnly.directory, "commit", "-m", "ignore build output"); +git(ignoredOnly.directory, "push", "origin", "ignored-only"); +mkdirSync(join(ignoredOnly.directory, "build")); +writeFileSync(join(ignoredOnly.directory, "build", "artifact.bin"), "bin\n"); +writeFileSync(join(ignoredOnly.directory, "run.log"), "log\n"); + +fixture.setPrs([ + { number: 8, state: "CLOSED", headRefName: "reused-name", headRefOid: "0".repeat(40) }, + { number: 7, state: "MERGED", headRefName: "reused-name", headRefOid: reused.head }, + { number: 1, state: "CLOSED", headRefName: "closed-unpushed", headRefOid: closedUnpushed.head }, + { number: 2, state: "CLOSED", headRefName: "closed-pushed", headRefOid: closedPushed.head }, + { number: 4, state: "MERGED", headRefName: "squash-pruned", headRefOid: squashPruned.head }, + { number: 5, state: "MERGED", headRefName: "squash-kept", headRefOid: squashKept.head }, + { number: 6, state: "MERGED", headRefName: "merged-then-more", headRefOid: mergedThenMore.head }, +]); +const result = fixture.audit(); + +const expectations = [ + ["closed-unpushed", "ahead1", "#1/closed", "hold-unpushed"], + ["closed-pushed", "pushed", "#2/closed", "review"], + ["behind", "behind", "-", "review"], + ["squash-pruned", "no-remote", "#4/merged", "safe"], + ["squash-kept", "pushed", "#5/merged", "safe"], + ["merged-then-more", "no-remote", "#6/merged", "hold-unpushed"], + ["detached-off-base", "detached", "-", "hold-unpushed"], + ["detached-gone", "detached", "-", "hold-unpushed"], + ["reused-name", "no-remote", "#7/merged", "safe"], + ["untracked-only", "pushed", "-", "hold-wip"], + ["ignored-only", "pushed", "-", "hold-wip"], +]; +for (const [worktree, remote, pr, bucket] of expectations) { + test(`worktree audit: ${worktree}`, () => { + expect(result.status).toBe(0); + expect(result.rows[worktree]).toMatchObject({ remote, pr, bucket }); + if (worktree === "squash-pruned") expect(result.rows[worktree].merged).toBe("no"); + }); +} + +test("worktree audit: untracked and ignored paths count as wip, never scratch", () => { + expect(result.rows["untracked-only"].dirty).toBe("wip:1"); + expect(result.rows["ignored-only"].dirty).toBe("wip:2"); + for (const row of Object.values(result.rows)) expect(row.dirty).not.toMatch(/^scratch:/); +}); diff --git a/harness/pstack/claude-code/skills/reflect/SKILL.md b/harness/pstack/claude-code/skills/reflect/SKILL.md index dcb6c9175..99f85cf83 100644 --- a/harness/pstack/claude-code/skills/reflect/SKILL.md +++ b/harness/pstack/claude-code/skills/reflect/SKILL.md @@ -1,6 +1,6 @@ --- name: reflect -description: Spawn three parallel review subagents over the active transcript, surface learnings, and route each to a concrete edit on an existing skill. Use when the user says reflect. +description: Spawn three parallel review subagents over the active transcript, surface learnings, and route each to a concrete edit on an existing skill. Run it with /reflect. disable-model-invocation: true --- diff --git a/harness/pstack/claude-code/skills/tdd/SKILL.md b/harness/pstack/claude-code/skills/tdd/SKILL.md index ca6f24c96..6df3cb04f 100644 --- a/harness/pstack/claude-code/skills/tdd/SKILL.md +++ b/harness/pstack/claude-code/skills/tdd/SKILL.md @@ -1,6 +1,6 @@ --- name: tdd -description: "Use only when the user explicitly asks for TDD, a failing test, or a regression test, OR when the bug has an obvious cheap local test target. Skip when the test path is unclear, expensive, integration-heavy, or not requested." +description: "TDD workflow for bug fixes: write one focused failing regression test before changing production code, when the bug has a clear, cheap local test target. Skip when the test path is unclear, expensive, or integration-heavy. Run it with /tdd, or when asked for TDD, a failing test, or a regression test." disable-model-invocation: true --- diff --git a/harness/pstack/claude-code/skills/typescript-best-practices/SKILL.md b/harness/pstack/claude-code/skills/typescript-best-practices/SKILL.md index e1e5aa7c9..2aa678abf 100644 --- a/harness/pstack/claude-code/skills/typescript-best-practices/SKILL.md +++ b/harness/pstack/claude-code/skills/typescript-best-practices/SKILL.md @@ -1,6 +1,6 @@ --- name: typescript-best-practices -description: TypeScript best practices. Use when reading or editing any .ts or .tsx file. +description: TypeScript best practices grounded in type-system discipline. Applies to .ts and .tsx files; run it with /typescript-best-practices. paths: ["**/*.ts", "**/*.tsx"] disable-model-invocation: true --- diff --git a/pstack/skills/make-bot-ui/SKILL.md b/pstack/skills/make-bot-ui/SKILL.md index 446342ca3..ba5d28b77 100644 --- a/pstack/skills/make-bot-ui/SKILL.md +++ b/pstack/skills/make-bot-ui/SKILL.md @@ -1,9 +1,9 @@ --- name: make-bot-ui description: >- - Use when building a custom UI (page, dashboard, buttons) that should wake a - Grok Bot over a webhook, when the user must provide a webhook sender key, or - when exposing that UI on Tailscale. + Build a custom UI (page, dashboard, buttons) that wakes a Grok Bot over a + webhook, keeps the webhook sender key on the server, and optionally exposes + the UI on Tailscale. Run it with /make-bot-ui. disable-model-invocation: true --- # How to make a bot UI diff --git a/pstack/skills/poteto-mode/playbooks/worktree-cleanup.md b/pstack/skills/poteto-mode/playbooks/worktree-cleanup.md index 45e1af693..fb7cfad88 100644 --- a/pstack/skills/poteto-mode/playbooks/worktree-cleanup.md +++ b/pstack/skills/poteto-mode/playbooks/worktree-cleanup.md @@ -1,10 +1,12 @@ ### Worktree and simulator cleanup -1. Snapshot and audit. Record `df -h /`, then run `scripts/worktree-audit.sh [-b ] [repo-path]` (principle-build-the-lever). Without `-b` it checks `MERGED` against `origin/HEAD`, so pass `-b origin/` when PRs target another branch. It reads paths from `git worktree list`, never hand-typed, since a hand-typed `myrepo-worktrees/x` misses one that lives at `.cursor/worktrees/myrepo/x` (principle-encode-lessons-in-structure). It classifies each worktree by size, age, merge state, uncommitted work, PR state, and the newest chat that touched it, then suggests a bucket. The transcript scan is slow, so background it. +**You own the disk and the safety gate.** Prune merged or abandoned git worktrees and stale iOS simulators to reclaim space. Deletion is irreversible, so every step guards against deleting something in use or holding uncommitted work. + +1. Snapshot and audit. Record `df -h /`, then run `scripts/worktree-audit.sh [-b ] [repo-path]` (principle-build-the-lever). Without `-b` it checks `MERGED` against `origin/HEAD`, so pass `-b origin/` when PRs target another branch. It reads paths from `git worktree list`, never hand-typed, since a hand-typed `myrepo-worktrees/x` misses one that lives at `.cursor/worktrees/myrepo/x` (principle-encode-lessons-in-structure). It classifies each worktree by size, age, merge state, uncommitted work, PR state, and the newest chat that touched it, then suggests a bucket. `hold-unpushed` means the worktree holds commits that exist nowhere else (ahead of its remote, no remote branch, or detached and not on the base, and HEAD is not the head of one of its PRs); push them or get a decision before anything else. A closed-but-unmerged PR is not evidence the work is safe. The transcript scan is slow, so background it. 2. The bucket is advice, not permission. The pinned and active chats are the real artifact (principle-prove-it-works). Get that set from the user or sidebar and cross-check every candidate. The lever has marked `safe` a worktree the user had pinned, so the pinned set wins. 3. Verify usage before deleting. For every `verify-recent-chat` row, or anything you doubt, fan subagents out to read the transcripts and report whether the chat is pinned or ongoing and which worktrees it touches (principle-guard-the-context-window, transcripts are bulk). A pinned chat spawns arena and repro trees into sibling worktrees via background subagents, and those are in use even when their names never hit the sidebar. Pick each subagent's reasoning level and fast or standard speed for the job, per the Subagents section. -4. Pause on irreversible loss. `wip:N` is N tracked uncommitted edits. Show the diff and get a decision first, since removing a clean worktree is recoverable from its branch but uncommitted work is gone. `scratch:N` is untracked throwaway, safe to drop, but name the files. Per Autonomy, clean and merged and not-in-use proceeds. `wip` and in-use pause. -5. Prune the confirmed set. Per path, `git worktree remove --force `. If the dir survives on ignored build artifacts, `rm -rf` it, then `git worktree prune`. Branch refs survive, so no commits are lost. Confirm with `df -h /` and re-list. +4. Pause on irreversible loss. `wip:N` counts tracked, untracked, and ignored paths. For each candidate, run `git -C status --porcelain=v1 --untracked-files=all --ignored=matching`; show diffs for tracked edits and list untracked and ignored paths without opening their contents. Default to preserving every worktree with `wip`. Remove one only after the user explicitly approves every path that its removal will delete. Never treat untracked or ignored files as disposable scratch. +5. Prune only the fully approved set. `git worktree remove ` can delete ignored-only files without `--force`, so the command is not a preservation check. Re-run the step 4 status preflight immediately before removal; if any path lacks explicit approval, retain the entire worktree. If Git refuses, stop and retain it; do not use `rm -rf` or `git worktree prune` to bypass the refusal. Branch refs survive, so no commits are lost. Confirm with `df -h /` and re-list. 6. Simulators and other reclaimers. Simulators are usually the next-biggest win. `xcrun simctl --set testing delete all` (XCTestDevices clones), `xcrun simctl delete unavailable`, and `xcrun simctl runtime list` then `runtime delete ` for old runtimes. More when needed: Xcode `DerivedData` and `iOS DeviceSupport`, `~/Library/Application Support/Cursor` (`state.vscdb.backup`, and `snapshots/roots/` where a `` named for a folder you opened as a workspace balloons), package caches (pnpm, uv, brew, yarn). Clear only caches the user has not said to keep. This is the one playbook that deletes user state with no code review to catch a slip, so the gates above are the review. diff --git a/pstack/skills/poteto-mode/scripts/package.json b/pstack/skills/poteto-mode/scripts/package.json index c8839d74d..5b1f38161 100644 --- a/pstack/skills/poteto-mode/scripts/package.json +++ b/pstack/skills/poteto-mode/scripts/package.json @@ -3,7 +3,7 @@ "private": true, "type": "module", "scripts": { - "test": "bun test watch-pr check-plan.test.ts", + "test": "bun test watch-pr check-plan.test.ts worktree-audit", "typecheck": "tsc --project watch-pr/tsconfig.json --noEmit --strict" }, "dependencies": { diff --git a/pstack/skills/poteto-mode/scripts/watch-pr/cli.test.ts b/pstack/skills/poteto-mode/scripts/watch-pr/cli.test.ts index 0656b413e..73977530e 100644 --- a/pstack/skills/poteto-mode/scripts/watch-pr/cli.test.ts +++ b/pstack/skills/poteto-mode/scripts/watch-pr/cli.test.ts @@ -1,6 +1,7 @@ import { describe, expect, it } from "bun:test"; import { type CliRuntime, main, parseArgs } from "./cli.ts"; import { fakeReader, passingCheck } from "./fakes.test-helper.ts"; +import { WatcherQueryError } from "./github.ts"; import { renderJson, renderPretty } from "./render.ts"; import type { GitHubReader, WatcherVerdict } from "./types.ts"; import { parsePrNumber } from "./types.ts"; @@ -103,6 +104,80 @@ describe("parseArgs", () => { }); }); +describe("heads without checks", () => { + const noChecks = () => + fakeReader({ + fastPath: { + kind: "unusable", + exitCode: 1, + stderr: "no checks reported on the 'feature' branch", + }, + rollupPages: [{ checks: [], endCursor: null }], + }); + + it("--status-only reports the head once instead of retrying", async () => { + const harness = testRuntime(noChecks()); + expect(await main(["--pr", "1", "--status-only"], harness.runtime)).toBe(0); + expect(harness.stdout).toHaveLength(1); + const verdict = JSON.parse(harness.stdout[0] ?? "") as WatcherVerdict; + expect(verdict.kind).toBe("STATUS"); + if (verdict.kind !== "STATUS") throw new Error("expected STATUS"); + const row = verdict.rows[0]; + expect(row.kind).toBe("open"); + if (row.kind !== "open") throw new Error("expected open row"); + expect(row.ci.kind).toBe("ci-none"); + }); + + it("--status-only --pretty names the missing checks", async () => { + const harness = testRuntime(noChecks()); + expect( + await main(["--pr", "1", "--status-only", "--pretty"], harness.runtime) + ).toBe(0); + expect(harness.stdout.join("")).toContain("no checks reported"); + }); + + it("--status-only ends on the first query error instead of sleeping", async () => { + const reader = noChecks(); + const failing: GitHubReader = { + ...reader, + async pullRequest() { + throw new WatcherQueryError({ + kind: "json-parse", + retryable: true, + detail: "simulated outage", + }); + }, + }; + const harness = testRuntime(failing); + const code = await main(["--pr", "1", "--status-only"], harness.runtime); + expect(code).not.toBe(0); + expect(harness.stdout).toHaveLength(1); + const verdict = JSON.parse(harness.stdout[0] ?? "") as WatcherVerdict; + expect(verdict.kind).not.toBe("RETRY"); + }); + + it("polling still treats a head without checks as a retryable error", async () => { + const harness = testRuntime(noChecks()); + const slept: number[] = []; + const runtime: CliRuntime = { + ...harness.runtime, + clock: { + ...harness.runtime.clock, + async sleep(seconds) { + slept.push(seconds); + if (slept.length >= 2) throw new Error("stop test"); + }, + }, + }; + await expect( + main(["--pr", "1", "--max-query-errors", "5"], runtime) + ).rejects.toThrow("stop test"); + expect(slept.length).toBe(2); + const first = JSON.parse(harness.stdout[0] ?? "") as WatcherVerdict; + expect(first.kind).toBe("RETRY"); + }); +}); + describe("rendering", () => { const context = { owner: "owner", diff --git a/pstack/skills/poteto-mode/scripts/watch-pr/cli.ts b/pstack/skills/poteto-mode/scripts/watch-pr/cli.ts index ec8a2f27c..ea9f0895c 100644 --- a/pstack/skills/poteto-mode/scripts/watch-pr/cli.ts +++ b/pstack/skills/poteto-mode/scripts/watch-pr/cli.ts @@ -126,7 +126,11 @@ export function parseArgs( positiveInteger, 5 ) - .option("--status-only", "print one status table and exit 0", false) + .option( + "--status-only", + "read GitHub once, print one status table and exit 0; a head with no checks is reported, not retried", + false + ) .option("--allow-draft", "do not treat a draft as a merge gate", false) .option("--pretty", "render human text instead of JSON", false); program.parse(argv, { from: "user" }); diff --git a/pstack/skills/poteto-mode/scripts/watch-pr/policy.test.ts b/pstack/skills/poteto-mode/scripts/watch-pr/policy.test.ts index 966b75367..a67521835 100644 --- a/pstack/skills/poteto-mode/scripts/watch-pr/policy.test.ts +++ b/pstack/skills/poteto-mode/scripts/watch-pr/policy.test.ts @@ -92,6 +92,54 @@ describe("readiness truth table", () => { }); describe("snapshot query planning", () => { + it("reports a head without checks as ci-none when asked to", async () => { + const reader = fakeReader({ + fastPath: { + kind: "unusable", + exitCode: 1, + stderr: "no checks reported on the 'feature' branch", + }, + rollupPages: [{ checks: [], endCursor: null }], + }); + const snapshot = await readSnapshot({ + reader, + context: context(7), + pendingHistory: "include", + allowDraft: false, + noChecks: "report", + }); + expect(snapshot.kind).toBe("open"); + if (snapshot.kind !== "open") throw new Error("expected open snapshot"); + expect(snapshot.ci.kind).toBe("ci-none"); + expect(snapshot.ci.all).toEqual([]); + expect(snapshot.reviewAutomationRunning).toBe(false); + if (snapshot.ci.kind !== "ci-none") throw new Error("expected ci-none"); + expect(snapshot.ci.github.kind).toBe("allowed"); + expect(reader.calls).toContain("commitRollups"); + }); + + it("keeps a head without checks as a retryable query error by default", async () => { + const reader = fakeReader({ + fastPath: { kind: "checks", checks: [] }, + rollupPages: [{ checks: [], endCursor: null }], + }); + let caught: unknown = null; + try { + await readSnapshot({ + reader, + context: context(8), + pendingHistory: "include", + allowDraft: false, + }); + } catch (error) { + caught = error; + } + expect(caught).toBeInstanceOf(WatcherQueryError); + if (!(caught instanceof WatcherQueryError)) throw new Error("expected query error"); + expect(caught.failure.kind).toBe("checks-unavailable"); + expect(caught.failure.retryable).toBe(true); + }); + it("does not query commit rollups while queued checks are pending", async () => { const reader = fakeReader({ fastPath: { kind: "checks", checks: [pendingCheck()] }, diff --git a/pstack/skills/poteto-mode/scripts/watch-pr/policy.ts b/pstack/skills/poteto-mode/scripts/watch-pr/policy.ts index 8179f2d8b..2552538fc 100644 --- a/pstack/skills/poteto-mode/scripts/watch-pr/policy.ts +++ b/pstack/skills/poteto-mode/scripts/watch-pr/policy.ts @@ -1,4 +1,5 @@ import { + ChecksUnavailable, WatcherQueryError, resolveChecks, resolveReviewThreads, @@ -61,6 +62,8 @@ export async function readSnapshot(args: { readonly context: T.PrContext; readonly pendingHistory: "include" | "omit"; readonly allowDraft: boolean; + /** `report`: a head with no checks becomes `ci-none`. `retry` (default): it stays a retryable query error, because a just-pushed head looks the same. */ + readonly noChecks?: "report" | "retry"; }): Promise { const facts = await args.reader.pullRequest(args.context); if (facts.state === "MERGED" || facts.mergedAt !== null) @@ -68,7 +71,30 @@ export async function readSnapshot(args: { if (facts.state === "CLOSED") return { kind: "closed", context: args.context, facts }; const threads = await resolveReviewThreads(args.reader, args.context); - const checks = await resolveChecks(args.reader, args.context); + let checks: T.CheckRead; + try { + checks = await resolveChecks(args.reader, args.context); + } catch (error) { + if (!(error instanceof ChecksUnavailable) || args.noChecks !== "report") + throw error; + const merge = await mergeAssessment(args.reader, facts); + return { + kind: "open", + context: args.context, + facts, + threads, + ci: { + kind: "ci-none", + source: "none", + all: [], + failed: [], + pending: [], + hadPreviousPassingCi: merge.hadPreviousPassingCi, + github: merge.github, + }, + reviewAutomationRunning: false, + }; + } const failed = nonEmpty( checks.checks.filter( (check): check is T.FailedCheck => check.kind === "failed" @@ -428,6 +454,12 @@ export async function runSimple(args: { readonly options: T.PollingOptions; }): Promise { const stamp = verdictFactory(args.dependencies.clock, args.mode); + // `--status-only` reads GitHub once: a head without checks is reported as + // such instead of being retried, and a query error ends the run on the + // first failure instead of sleeping through the backoff schedule. + const options: T.PollingOptions = args.statusOnly + ? { ...args.options, maxQueryErrors: 1 } + : args.options; const step = async (): Promise> => { const rows: T.PrSnapshot[] = []; for (const context of args.contexts) @@ -437,6 +469,7 @@ export async function runSimple(args: { context, pendingHistory: "include", allowDraft: args.options.allowDraft, + noChecks: args.statusOnly ? "report" : "retry", }) ); const complete = nonEmpty(rows); @@ -518,7 +551,7 @@ export async function runSimple(args: { }; return pollUntilTerminal({ dependencies: args.dependencies, - options: args.options, + options, stamp, step, }); diff --git a/pstack/skills/poteto-mode/scripts/watch-pr/render.ts b/pstack/skills/poteto-mode/scripts/watch-pr/render.ts index 0e9358393..a0da9ac18 100644 --- a/pstack/skills/poteto-mode/scripts/watch-pr/render.ts +++ b/pstack/skills/poteto-mode/scripts/watch-pr/render.ts @@ -13,6 +13,8 @@ function ciCell(row: T.PrSnapshot): string { return `❌ ${row.ci.failed.length} failed${row.ci.pending.length ? `, ${row.ci.pending.length} pending` : ""}${was}`; case "ci-github-rejected": return `❌ GitHub reports failing checks${was}`; + case "ci-none": + return `no checks reported${was}`; default: { const exhaustive: never = row.ci; return exhaustive; diff --git a/pstack/skills/poteto-mode/scripts/watch-pr/types.ts b/pstack/skills/poteto-mode/scripts/watch-pr/types.ts index 60b9abcac..6f6849b5e 100644 --- a/pstack/skills/poteto-mode/scripts/watch-pr/types.ts +++ b/pstack/skills/poteto-mode/scripts/watch-pr/types.ts @@ -142,7 +142,17 @@ export type CiClean = CiBase & { readonly pending: readonly []; readonly github: GitHubMergeAllowed; }; -export type CiState = CiFailing | CiGithubRejected | CiPending | CiClean; +/** GitHub reported no checks at all for the head. Only `--status-only` produces this; polling keeps retrying, since a freshly pushed head has no checks yet either. */ +export interface CiNone { + readonly kind: "ci-none"; + readonly source: "none"; + readonly all: readonly []; + readonly failed: readonly []; + readonly pending: readonly []; + readonly hadPreviousPassingCi: boolean; + readonly github: GitHubMergeAssessment; +} +export type CiState = CiFailing | CiGithubRejected | CiPending | CiClean | CiNone; export type PrSnapshot = | { readonly kind: "merged" | "closed"; diff --git a/pstack/skills/poteto-mode/scripts/worktree-audit.fixture.ts b/pstack/skills/poteto-mode/scripts/worktree-audit.fixture.ts new file mode 100644 index 000000000..19b327d07 --- /dev/null +++ b/pstack/skills/poteto-mode/scripts/worktree-audit.fixture.ts @@ -0,0 +1,83 @@ +import { spawnSync } from "node:child_process"; +import { mkdtempSync, mkdirSync, rmSync, writeFileSync } from "node:fs"; +import { tmpdir } from "node:os"; +import { basename, join } from "node:path"; + +type PullRequest = { + number: number; + state: string; + headRefName: string; + headRefOid: string; +}; + +type AuditRow = { + merged: string; + dirty: string; + remote: string; + pr: string; + lastChat: string; + bucket: string; +}; + +export function createAuditFixture() { + const root = mkdtempSync(join(tmpdir(), "worktree-audit-test-")); + const home = join(root, "home"); + const bin = join(root, "bin"); + const origin = join(root, "origin.git"); + const repo = join(root, "repo"); + const prs = join(root, "prs.json"); + const env = { + ...process.env, + HOME: home, + GIT_CONFIG_GLOBAL: join(root, "gitconfig"), + GIT_CONFIG_NOSYSTEM: "1", + PATH: `${bin}:${process.env.PATH}`, + AUDIT_FIXTURE_PRS: prs, + }; + const cleanup = () => rmSync(root, { recursive: true, force: true }); + const run = (directory: string, command: string, args: string[]) => { + const result = spawnSync(command, args, { cwd: directory, env, encoding: "utf8" }); + if (result.status !== 0) + throw new Error(`${command} ${args.join(" ")} failed in ${directory}: ${result.error ?? result.stderr}`); + return result.stdout.trim(); + }; + const git = (directory: string, ...args: string[]) => run(directory, "git", args); + const setPrs = (value: PullRequest[]) => writeFileSync(prs, JSON.stringify(value)); + + try { + mkdirSync(home); + mkdirSync(bin); + setPrs([]); + writeFileSync(join(bin, "gh"), '#!/bin/sh\ncat "$AUDIT_FIXTURE_PRS"\n', { mode: 0o755 }); + git(root, "config", "--global", "user.name", "Audit Test"); + git(root, "config", "--global", "user.email", "audit@example.test"); + git(root, "config", "--global", "init.defaultBranch", "main"); + git(root, "init", "--bare", origin); + git(root, "clone", origin, repo); + writeFileSync(join(repo, "base"), "base\n"); + git(repo, "add", "base"); + git(repo, "commit", "-m", "base"); + git(repo, "push", "origin", "main"); + git(repo, "remote", "set-head", "origin", "main"); + } catch (error) { + cleanup(); + throw error; + } + + const audit = (extraEnv: NodeJS.ProcessEnv = {}) => { + const result = spawnSync("bash", [join(import.meta.dir, "worktree-audit.sh"), repo], { + cwd: repo, + env: { ...env, ...extraEnv }, + encoding: "utf8", + }); + const rows: Record = {}; + for (const line of result.stdout.trim().split("\n")) { + if (!line || line.startsWith("# base:") || line.startsWith("SIZE\t")) continue; + const [, , merged, dirty, remote, pr, lastChat, bucket, worktree] = line.split("\t"); + rows[basename(worktree)] = { merged, dirty, remote, pr, lastChat, bucket }; + } + return { status: result.status, stderr: result.stderr, rows }; + }; + + return { root, home, origin, repo, env, run, git, setPrs, audit, cleanup }; +} diff --git a/pstack/skills/poteto-mode/scripts/worktree-audit.sh b/pstack/skills/poteto-mode/scripts/worktree-audit.sh index 47e08919d..814619627 100755 --- a/pstack/skills/poteto-mode/scripts/worktree-audit.sh +++ b/pstack/skills/poteto-mode/scripts/worktree-audit.sh @@ -1,8 +1,9 @@ #!/usr/bin/env bash -# Read-only worktree prune audit. Classifies every git worktree by size, merge -# state, uncommitted work, remote/PR state, and the most recent chat that -# operated in it. Emits a table sorted by size with a suggested bucket. Never -# deletes anything; deletion stays a human-gated step in the playbook. +# Worktree prune audit. Classifies every git worktree by size, merge state, +# uncommitted work, remote/PR state, and the most recent chat that operated in +# it. Emits a table sorted by size with a suggested bucket. Never deletes +# anything; deletion stays a human-gated step in the playbook. The only write +# is the best-effort fetch of the base ref for the merge check. # # Usage: worktree-audit.sh [-b|--base ] [repo-path] # repo-path defaults to the current repo. @@ -62,10 +63,10 @@ prs=$(mktemp) if command -v origin >/dev/null 2>&1 \ && raw=$(origin pr list --author "@me" --state all --limit 1000 \ --json number,status,headRef 2>/dev/null); then - printf '%s' "$raw" | jq '[.[] | {number, state: .status, head: .headRef}]' > "$prs" 2>/dev/null + printf '%s' "$raw" | jq '[.[] | {number, state: .status, head: .headRef, head_oid: ""}]' > "$prs" 2>/dev/null elif raw=$(gh pr list --author "@me" --state all --limit 1000 \ - --json number,state,headRefName 2>/dev/null); then - printf '%s' "$raw" | jq '[.[] | {number, state: (.state | ascii_downcase), head: .headRefName}]' > "$prs" 2>/dev/null + --json number,state,headRefName,headRefOid 2>/dev/null); then + printf '%s' "$raw" | jq '[.[] | {number, state: (.state | ascii_downcase), head: .headRefName, head_oid: (.headRefOid // "")}]' > "$prs" 2>/dev/null fi [ -s "$prs" ] || echo "[]" > "$prs" @@ -89,12 +90,10 @@ git worktree list --porcelain | awk '/^worktree /{print $2}' | while read -r wt; # real signal; merge-base only catches fast-forward/rebase merges. git merge-base --is-ancestor "$head" "$base" 2>/dev/null && merged=YES || merged=no - # Distinguish real WIP (tracked edits) from disposable untracked scratch. - porcelain=$(git -C "$wt" status --porcelain 2>/dev/null) + # Treat tracked, untracked, and ignored paths as work that must be held. + porcelain=$(git -C "$wt" status --porcelain --untracked-files=all --ignored=matching 2>/dev/null) if [ -z "$porcelain" ]; then dirty=clean - elif printf '%s\n' "$porcelain" | grep -qv '^??'; then - dirty="wip:$(printf '%s\n' "$porcelain" | grep -cv '^??')" - else dirty="scratch:$(printf '%s\n' "$porcelain" | grep -c '^??')"; fi + else dirty="wip:$(printf '%s\n' "$porcelain" | wc -l | tr -d '[:space:]')"; fi branch=$(git -C "$wt" symbolic-ref --quiet --short HEAD 2>/dev/null || echo "") if [ -z "$branch" ]; then remote=detached @@ -102,10 +101,15 @@ git worktree list --porcelain | awk '/^worktree /{print $2}' | while read -r wt; [ "$(git -C "$wt" rev-parse "origin/$branch" 2>/dev/null)" = "$head" ] \ && remote=pushed \ || remote="ahead$(git -C "$wt" rev-list --count "origin/$branch..HEAD" 2>/dev/null)" + [ "$remote" = ahead0 ] && remote=behind else remote=no-remote; fi - pr=$([ -n "$branch" ] && jq -r --arg b "$branch" \ - '.[] | select(.head==$b) | "#\(.number)/\(.state)"' "$prs" 2>/dev/null | head -1) + # Several PRs can share a branch name; prefer the one whose head is HEAD. + pr_record=$([ -n "$branch" ] && jq -r --arg b "$branch" --arg h "$head" \ + '[.[] | select(.head==$b)] | (map(select(.head_oid==$h)) + .)[0] + | select(.) | "#\(.number)/\(.state)\t\(.head_oid // "")"' "$prs" 2>/dev/null) + pr=${pr_record%%$'\t'*} + pr_head=${pr_record#*$'\t'} [ -z "$pr" ] && pr="-" # Most recent chat whose transcript operated in this worktree. Match path @@ -119,10 +123,19 @@ git worktree list --porcelain | awk '/^worktree /{print $2}' | while read -r wt; fi recent=$([ "$last_ts" -gt 0 ] 2>/dev/null && [ $(( (now - last_ts) / 86400 )) -le 4 ] && echo yes || echo no) + # An exact PR head proves the commits were pushed even if origin has no + # branch (deleted after merging, or hosted on a fork). A closed PR still + # proves nothing about whether those commits were merged. + case "$pr" in */merged) pr_merged=yes ;; *) pr_merged=no ;; esac + case "$remote" in ahead*|no-remote|detached) unpushed=yes ;; *) unpushed=no ;; esac + [ "$merged" = YES ] && unpushed=no + [ -n "$pr_head" ] && [ "$head" = "$pr_head" ] && unpushed=no + case "$dirty" in wip:*) bucket=hold-wip ;; *) case "$pr" in */open|*/draft) bucket=hold-open-pr ;; *) - if [ "$recent" = yes ]; then bucket=verify-recent-chat - elif [ "$merged" = YES ] || [ "$pr" != "-" ]; then bucket=safe + if [ "$unpushed" = yes ]; then bucket=hold-unpushed + elif [ "$recent" = yes ]; then bucket=verify-recent-chat + elif [ "$merged" = YES ] || [ "$pr_merged" = yes ]; then bucket=safe else bucket=review; fi ;; esac ;; esac diff --git a/pstack/skills/poteto-mode/scripts/worktree-audit.test.ts b/pstack/skills/poteto-mode/scripts/worktree-audit.test.ts new file mode 100644 index 000000000..46166b4a2 --- /dev/null +++ b/pstack/skills/poteto-mode/scripts/worktree-audit.test.ts @@ -0,0 +1,112 @@ +import { mkdirSync, rmSync, writeFileSync } from "node:fs"; +import { join } from "node:path"; +import { afterAll, expect, test } from "bun:test"; +import { createAuditFixture } from "./worktree-audit.fixture"; + +const fixture = createAuditFixture(); +afterAll(fixture.cleanup); +const { root, repo, origin, git } = fixture; +const worktree = (branch: string) => { + const directory = join(root, branch); + git(repo, "worktree", "add", "-b", branch, directory, "main"); + return directory; +}; +const commit = (directory: string, file: string) => { + writeFileSync(join(directory, file), `${file}\n`); + git(directory, "add", file); + git(directory, "commit", "-m", file); + return git(directory, "rev-parse", "HEAD"); +}; +const pushed = (branch: string) => { + const directory = worktree(branch); + const head = commit(directory, branch); + git(directory, "push", "origin", branch); + return { directory, head }; +}; + +const closedUnpushed = pushed("closed-unpushed"); +commit(closedUnpushed.directory, "local-only"); +const closedPushed = pushed("closed-pushed"); +const behind = pushed("behind"); +const other = join(root, "other"); +git(root, "clone", origin, other); +git(other, "checkout", "behind"); +commit(other, "remote-only"); +git(other, "push", "origin", "behind"); +git(behind.directory, "fetch", "origin"); + +const squashPruned = pushed("squash-pruned"); +const squashKept = pushed("squash-kept"); +const mergedThenMore = pushed("merged-then-more"); +for (const branch of ["squash-pruned", "squash-kept", "merged-then-more"]) { + git(repo, "merge", "--squash", branch); + git(repo, "commit", "-m", `Squash ${branch}`); +} +git(repo, "push", "origin", "main"); +git(repo, "push", "origin", "--delete", "squash-pruned", "merged-then-more"); +git(repo, "fetch", "--prune", "origin"); +commit(mergedThenMore.directory, "after-merge"); + +const detached = join(root, "detached-off-base"); +git(repo, "worktree", "add", "--detach", detached, "main"); +commit(detached, "detached-only"); + +const gone = join(root, "detached-gone"); +git(repo, "worktree", "add", "--detach", gone, "main"); +commit(gone, "gone-only"); +rmSync(gone, { recursive: true, force: true }); + +const reused = pushed("reused-name"); +git(repo, "push", "origin", "--delete", "reused-name"); +git(repo, "fetch", "--prune", "origin"); + +// Local-only files that git status hides by default: an untracked file, and +// ignored build output. Both are work the audit must hold, never scratch. +const untrackedOnly = pushed("untracked-only"); +writeFileSync(join(untrackedOnly.directory, "notes.md"), "untracked\n"); +const ignoredOnly = pushed("ignored-only"); +writeFileSync(join(ignoredOnly.directory, ".gitignore"), "build/\nrun.log\n"); +git(ignoredOnly.directory, "add", ".gitignore"); +git(ignoredOnly.directory, "commit", "-m", "ignore build output"); +git(ignoredOnly.directory, "push", "origin", "ignored-only"); +mkdirSync(join(ignoredOnly.directory, "build")); +writeFileSync(join(ignoredOnly.directory, "build", "artifact.bin"), "bin\n"); +writeFileSync(join(ignoredOnly.directory, "run.log"), "log\n"); + +fixture.setPrs([ + { number: 8, state: "CLOSED", headRefName: "reused-name", headRefOid: "0".repeat(40) }, + { number: 7, state: "MERGED", headRefName: "reused-name", headRefOid: reused.head }, + { number: 1, state: "CLOSED", headRefName: "closed-unpushed", headRefOid: closedUnpushed.head }, + { number: 2, state: "CLOSED", headRefName: "closed-pushed", headRefOid: closedPushed.head }, + { number: 4, state: "MERGED", headRefName: "squash-pruned", headRefOid: squashPruned.head }, + { number: 5, state: "MERGED", headRefName: "squash-kept", headRefOid: squashKept.head }, + { number: 6, state: "MERGED", headRefName: "merged-then-more", headRefOid: mergedThenMore.head }, +]); +const result = fixture.audit(); + +const expectations = [ + ["closed-unpushed", "ahead1", "#1/closed", "hold-unpushed"], + ["closed-pushed", "pushed", "#2/closed", "review"], + ["behind", "behind", "-", "review"], + ["squash-pruned", "no-remote", "#4/merged", "safe"], + ["squash-kept", "pushed", "#5/merged", "safe"], + ["merged-then-more", "no-remote", "#6/merged", "hold-unpushed"], + ["detached-off-base", "detached", "-", "hold-unpushed"], + ["detached-gone", "detached", "-", "hold-unpushed"], + ["reused-name", "no-remote", "#7/merged", "safe"], + ["untracked-only", "pushed", "-", "hold-wip"], + ["ignored-only", "pushed", "-", "hold-wip"], +]; +for (const [worktree, remote, pr, bucket] of expectations) { + test(`worktree audit: ${worktree}`, () => { + expect(result.status).toBe(0); + expect(result.rows[worktree]).toMatchObject({ remote, pr, bucket }); + if (worktree === "squash-pruned") expect(result.rows[worktree].merged).toBe("no"); + }); +} + +test("worktree audit: untracked and ignored paths count as wip, never scratch", () => { + expect(result.rows["untracked-only"].dirty).toBe("wip:1"); + expect(result.rows["ignored-only"].dirty).toBe("wip:2"); + for (const row of Object.values(result.rows)) expect(row.dirty).not.toMatch(/^scratch:/); +}); diff --git a/pstack/skills/reflect/SKILL.md b/pstack/skills/reflect/SKILL.md index 9bb153bd2..1fd1f39bc 100644 --- a/pstack/skills/reflect/SKILL.md +++ b/pstack/skills/reflect/SKILL.md @@ -1,6 +1,6 @@ --- name: reflect -description: Spawn three parallel review subagents over the active transcript, surface learnings, and route each to a concrete edit on an existing skill. Use when the user says reflect. +description: Spawn three parallel review subagents over the active transcript, surface learnings, and route each to a concrete edit on an existing skill. Run it with /reflect. disable-model-invocation: true --- diff --git a/pstack/skills/tdd/SKILL.md b/pstack/skills/tdd/SKILL.md index ca6f24c96..6df3cb04f 100644 --- a/pstack/skills/tdd/SKILL.md +++ b/pstack/skills/tdd/SKILL.md @@ -1,6 +1,6 @@ --- name: tdd -description: "Use only when the user explicitly asks for TDD, a failing test, or a regression test, OR when the bug has an obvious cheap local test target. Skip when the test path is unclear, expensive, integration-heavy, or not requested." +description: "TDD workflow for bug fixes: write one focused failing regression test before changing production code, when the bug has a clear, cheap local test target. Skip when the test path is unclear, expensive, or integration-heavy. Run it with /tdd, or when asked for TDD, a failing test, or a regression test." disable-model-invocation: true --- diff --git a/pstack/skills/typescript-best-practices/SKILL.md b/pstack/skills/typescript-best-practices/SKILL.md index e1e5aa7c9..2aa678abf 100644 --- a/pstack/skills/typescript-best-practices/SKILL.md +++ b/pstack/skills/typescript-best-practices/SKILL.md @@ -1,6 +1,6 @@ --- name: typescript-best-practices -description: TypeScript best practices. Use when reading or editing any .ts or .tsx file. +description: TypeScript best practices grounded in type-system discipline. Applies to .ts and .tsx files; run it with /typescript-best-practices. paths: ["**/*.ts", "**/*.tsx"] disable-model-invocation: true ---