From e7741d984175efcd339c28551958d64ac52b85d2 Mon Sep 17 00:00:00 2001 From: Garry Tan Date: Sun, 31 May 2026 09:13:16 -0700 Subject: [PATCH 01/10] feat(merge): gstack-merge regime-aware merge helper + tests Pure regime logic in lib/merge.ts (detect/submit-plan/classify-land/handoff schema+validation) behind a thin bin/gstack-merge CLI (detect/submit/wait/ write-state/read-state). Supports none, GitHub native merge queue, and the trunk.io merge queue with a comment-first submit chain. 41 deterministic tests (30 unit + 11 CLI). Co-Authored-By: Claude Opus 4.8 (1M context) --- bin/gstack-merge | 433 ++++++++++++++++++++++++++++++++++ lib/merge.ts | 306 ++++++++++++++++++++++++ test/gstack-merge-cli.test.ts | 131 ++++++++++ test/gstack-merge.test.ts | 263 +++++++++++++++++++++ 4 files changed, 1133 insertions(+) create mode 100755 bin/gstack-merge create mode 100644 lib/merge.ts create mode 100644 test/gstack-merge-cli.test.ts create mode 100644 test/gstack-merge.test.ts diff --git a/bin/gstack-merge b/bin/gstack-merge new file mode 100755 index 000000000..90f030d2c --- /dev/null +++ b/bin/gstack-merge @@ -0,0 +1,433 @@ +#!/usr/bin/env bun +// gstack-merge — regime-aware merge driver for /land and /land-and-deploy. +// +// Owns the four risky merge operations as a thin CLI over the pure logic in +// lib/merge.ts (which is unit-tested). Supports three regimes: none, GitHub +// native merge queue, and trunk.io merge queue. +// +// Subcommands: +// detect [--base B] [--pr N] [--json] +// submit --regime R --pr N [--base B] [--priority P] +// wait --pr N --base B [--regime R] [--timeout S] [--interval S] +// [--enqueue-timeout S] [--once] +// write-state --pr N --base B --slug SLUG [--regime R] [--sha-timeout S] +// read-state --slug SLUG --pr N --repo OWNER/NAME [--max-age-ms N] [--json] +// +// Contract: detect/wait/read-state never mutate git/PR state. submit and +// write-state are the only state-touching subcommands. + +import { spawnSync } from 'node:child_process'; +import { existsSync, readFileSync, writeFileSync, mkdirSync, renameSync } from 'node:fs'; +import { homedir } from 'node:os'; +import { join } from 'node:path'; +import { + detectRegime, + planSubmit, + classifyLand, + buildLandState, + validateConsume, + isTrunkQueueCheck, + trunkQueueCheckName, + type Regime, + type PrCheck, +} from '../lib/merge'; + +// --- arg parsing -------------------------------------------------------- + +function parseArgs(argv: string[]): { cmd: string; flags: Record; bools: Set } { + const cmd = argv[0] || ''; + const flags: Record = {}; + const bools = new Set(); + for (let i = 1; i < argv.length; i++) { + const a = argv[i]; + if (a.startsWith('--')) { + const key = a.slice(2); + const next = argv[i + 1]; + if (next !== undefined && !next.startsWith('--')) { + flags[key] = next; + i++; + } else { + bools.add(key); + } + } + } + return { cmd, flags, bools }; +} + +// --- shell helpers ------------------------------------------------------ + +function run(cmd: string, args: string[]): { code: number; stdout: string; stderr: string } { + const r = spawnSync(cmd, args, { encoding: 'utf-8', timeout: 60000 }); + return { code: r.status ?? 1, stdout: (r.stdout || '').trim(), stderr: (r.stderr || '').trim() }; +} + +function ghJson(args: string[]): T | null { + const r = run('gh', args); + if (!r.stdout) return null; + try { + return JSON.parse(r.stdout) as T; + } catch { + return null; + } +} + +function which(bin: string): boolean { + return run('command', ['-v', bin]).code === 0 || run('sh', ['-c', `command -v ${bin}`]).code === 0; +} + +function sleep(ms: number): Promise { + return new Promise((r) => setTimeout(r, ms)); +} + +// --- data gatherers ----------------------------------------------------- + +interface PrView { + number?: number; + state?: string; + mergeCommit?: { oid?: string } | null; + headRefOid?: string; + headRefName?: string; + baseRefName?: string; + autoMergeRequest?: unknown; +} + +function prView(pr: string | undefined): PrView | null { + const args = ['pr', 'view']; + if (pr) args.push(pr); + args.push('--json', 'number,state,mergeCommit,headRefOid,headRefName,baseRefName,autoMergeRequest'); + return ghJson(args); +} + +function prChecks(pr: string | undefined): PrCheck[] { + const args = ['pr', 'checks']; + if (pr) args.push(pr); + // gh pr checks exits non-zero when checks are failing/pending; we still want the JSON. + args.push('--json', 'name,state,bucket'); + const r = run('gh', args); + if (!r.stdout) return []; + try { + return JSON.parse(r.stdout) as PrCheck[]; + } catch { + return []; + } +} + +function repoSlug(): string { + const r = ghJson<{ nameWithOwner?: string }>(['repo', 'view', '--json', 'nameWithOwner']); + return r?.nameWithOwner || ''; +} + +function readTrunkYaml(): string | null { + const p = join(process.cwd(), '.trunk', 'trunk.yaml'); + if (existsSync(p)) { + try { return readFileSync(p, 'utf-8'); } catch { return null; } + } + return null; +} + +/** Parse "Merge queue: X" from the ## Merge Configuration section of CLAUDE.md. */ +function readConfigRegime(): string | null { + const p = join(process.cwd(), 'CLAUDE.md'); + if (!existsSync(p)) return null; + let body: string; + try { body = readFileSync(p, 'utf-8'); } catch { return null; } + const m = body.match(/##\s*Merge Configuration[\s\S]*?(?=\n##\s|\n#\s|$)/); + if (!m) return null; + const line = m[0].match(/Merge queue:\s*([a-zA-Z]+)/); + return line ? line[1].toLowerCase() : null; +} + +/** Best-effort GitHub-native merge-queue detection via the GraphQL API. */ +function githubMergeQueueEnabled(slug: string, base: string): boolean { + if (!slug.includes('/')) return false; + const [owner, name] = slug.split('/'); + const q = `query($o:String!,$n:String!,$b:String!){repository(owner:$o,name:$n){mergeQueue(branch:$b){id}}}`; + const r = run('gh', ['api', 'graphql', '-f', `query=${q}`, '-f', `o=${owner}`, '-f', `n=${name}`, '-f', `b=${base}`]); + if (r.code !== 0 || !r.stdout) return false; + try { + const j = JSON.parse(r.stdout); + return !!j?.data?.repository?.mergeQueue?.id; + } catch { + return false; + } +} + +/** Does origin/ contain the head commit? (fetches first). */ +function baseContainsHead(base: string, headRefOid: string): boolean { + if (!headRefOid) return false; + run('git', ['fetch', 'origin', base]); + return run('git', ['merge-base', '--is-ancestor', headRefOid, `origin/${base}`]).code === 0; +} + +function findQueueCheck(checks: PrCheck[], base: string): PrCheck | null { + return ( + checks.find((c) => c.name === trunkQueueCheckName(base)) || + checks.find((c) => isTrunkQueueCheck(c.name)) || + null + ); +} + +// --- subcommands -------------------------------------------------------- + +async function cmdDetect(flags: Record, bools: Set): Promise { + const pr = flags.pr; + const slug = repoSlug(); + const base = flags.base || prView(pr)?.baseRefName || 'main'; + const checks = prChecks(pr); + const result = detectRegime({ + base, + checks, + trunkYaml: readTrunkYaml(), + configRegime: readConfigRegime(), + githubMergeQueue: githubMergeQueueEnabled(slug, base), + }); + if (bools.has('json')) { + process.stdout.write(JSON.stringify({ ...result, base }) + '\n'); + } else { + process.stdout.write(`MERGE_REGIME=${result.regime}\nMERGE_REGIME_SOURCE=${result.source}\nBASE=${base}\n`); + } + return 0; +} + +function cmdSubmit(flags: Record): number { + const pr = flags.pr; + const regime = (flags.regime || 'none') as Regime; + if (!pr) { process.stderr.write('submit: --pr is required\n'); return 2; } + + const plan = planSubmit(regime, Number(pr), { + trunkCliAvailable: which('trunk'), + trunkToken: !!process.env.TRUNK_API_TOKEN, + priority: flags.priority, + }); + + for (const cand of plan.candidates) { + process.stdout.write(`→ ${cand.desc}: ${cand.cmd} ${cand.args.join(' ')}\n`); + let r: { code: number; stdout: string; stderr: string }; + if (cand.cmd === 'trunk-rest') { + r = submitViaRest(Number(pr), flags.base || '', flags.priority); + } else { + r = run(cand.cmd, cand.args); + } + if (r.code === 0) { + process.stdout.write(`SUBMIT_OK=${regime}\nSUBMIT_VIA=${cand.desc}\n`); + if (r.stdout) process.stdout.write(r.stdout + '\n'); + return 0; + } + process.stderr.write(` (failed: ${r.stderr || r.stdout || 'exit ' + r.code})\n`); + } + process.stderr.write(`SUBMIT_FAILED=${regime} — no candidate succeeded\n`); + return 1; +} + +function submitViaRest(pr: number, base: string, priority?: string): { code: number; stdout: string; stderr: string } { + const token = process.env.TRUNK_API_TOKEN; + if (!token) return { code: 1, stdout: '', stderr: 'TRUNK_API_TOKEN not set' }; + const slug = repoSlug(); + const [owner, name] = slug.split('/'); + const payload = JSON.stringify({ + repo: { host: 'github.com', owner, name }, + pr: { number: pr }, + targetBranch: base || undefined, + ...(priority ? { priority } : {}), + }); + return run('curl', [ + '-sS', '-X', 'POST', 'https://api.trunk.io/v1/submitPullRequest', + '-H', `x-api-token: ${token}`, + '-H', 'Content-Type: application/json', + '-d', payload, + ]); +} + +async function cmdWait(flags: Record): Promise { + const pr = flags.pr; + const base = flags.base; + if (!pr || !base) { process.stderr.write('wait: --pr and --base are required\n'); return 2; } + const regime = (flags.regime || 'none') as Regime; + const intervalMs = (Number(flags.interval) || 30) * 1000; + const timeoutMs = (Number(flags.timeout) || 1800) * 1000; // 30 min + const enqueueMs = (Number(flags['enqueue-timeout']) || 120) * 1000; + const once = flags.once !== undefined || process.argv.includes('--once'); + + const classifyNow = () => { + const v = prView(pr) || {}; + const checks = prChecks(pr); + const queueCheck = findQueueCheck(checks, base); + const oid = v.mergeCommit?.oid || null; + return classifyLand({ + state: v.state || 'OPEN', + mergeCommitOid: oid, + baseContainsHead: oid ? true : baseContainsHead(base, v.headRefOid || ''), + queueCheck, + autoMergeEnabled: v.autoMergeRequest != null, + }); + }; + + if (once) { + const c = classifyNow(); + process.stdout.write(`LAND_STATUS=${c.status}\nLAND_REASON=${c.reason}\n`); + return c.status === 'landed' ? 0 : c.status === 'pending' ? 0 : 1; + } + + const start = Date.now(); + + // Trunk phase 1: confirm the comment actually enqueued (the queue check + // appears) — a posted "/trunk merge" comment is silently inert if the + // GitHub App isn't installed or "GitHub commands" is off. + if (regime === 'trunk') { + let enqueued = false; + while (Date.now() - start < enqueueMs) { + const v = prView(pr) || {}; + if ((v.state || '').toUpperCase() === 'MERGED') { enqueued = true; break; } + if (findQueueCheck(prChecks(pr), base)) { enqueued = true; break; } + await sleep(Math.min(intervalMs, 15000)); + } + if (!enqueued) { + process.stderr.write( + 'TRUNK_ENQUEUE_TIMEOUT — posted "/trunk merge" but no "' + trunkQueueCheckName(base) + + '" check appeared. Is the Trunk GitHub App installed and "GitHub commands" enabled?\n', + ); + return 1; + } + process.stdout.write('Trunk picked up the PR — waiting for the queue to finish.\n'); + } + + // Phase 2: resolution. + let lastNarrate = Date.now(); + while (Date.now() - start < timeoutMs) { + const c = classifyNow(); + if (c.status === 'landed') { + process.stdout.write(`LAND_STATUS=landed\nLAND_REASON=${c.reason}\n`); + return 0; + } + if (c.status === 'ejected') { + process.stderr.write(`LAND_STATUS=ejected\nLAND_REASON=${c.reason}\n`); + return 1; + } + if (c.status === 'closed') { + process.stderr.write(`LAND_STATUS=closed\nLAND_REASON=${c.reason}\n`); + return 1; + } + if (Date.now() - lastNarrate > 120000) { + const mins = Math.round((Date.now() - start) / 60000); + process.stdout.write(`Still waiting to land... (${mins}m so far)\n`); + lastNarrate = Date.now(); + } + await sleep(intervalMs); + } + process.stderr.write(`LAND_STATUS=timeout\nLAND_REASON=did not land within ${Math.round(timeoutMs / 60000)}m\n`); + return 1; +} + +async function cmdWriteState(flags: Record): Promise { + const pr = flags.pr; + const base = flags.base; + const slug = flags.slug; + if (!pr || !base || !slug) { process.stderr.write('write-state: --pr, --base, --slug are required\n'); return 2; } + const regime = (flags.regime || 'none') as Regime; + const shaTimeoutMs = (Number(flags['sha-timeout']) || 90) * 1000; + + // Poll until MERGED with a non-null merge SHA (the SHA can lag the state + // flip on squash/queue merges). Fall back to the base tip only in the + // genuine rebase-null case (MERGED + base contains head but oid stays null). + const start = Date.now(); + let v: PrView | null = null; + let oid: string | null = null; + while (Date.now() - start < shaTimeoutMs) { + v = prView(pr); + const state = (v?.state || '').toUpperCase(); + oid = v?.mergeCommit?.oid || null; + if (state === 'MERGED' && oid) break; + if (state === 'CLOSED') { process.stderr.write('write-state: PR is CLOSED, not merged\n'); return 1; } + await sleep(5000); + } + v = v || prView(pr) || {}; + const state = (v.state || '').toUpperCase(); + const headRefOid = v.headRefOid || ''; + const contains = baseContainsHead(base, headRefOid); + + if (state !== 'MERGED' || !(oid || contains)) { + process.stderr.write('write-state: landing not confirmed (PR not MERGED on base) — refusing to write handoff\n'); + return 1; + } + + let sha = oid || ''; + let shaNote = ''; + if (!sha) { + // rebase-null edge: use the base tip as the best available revert target. + const tip = run('git', ['rev-parse', `origin/${base}`]).stdout; + sha = tip; + shaNote = ' (no mergeCommit.oid — using base tip; rebase-merge repo)'; + } + + const state2 = buildLandState({ + pr: Number(pr), + sha, + headRefOid, + base, + head_branch: v.headRefName || '', + repo: repoSlug(), + regime, + ts: new Date().toISOString(), + }); + + const dir = join(homedir(), '.gstack', 'projects', slug); + mkdirSync(dir, { recursive: true }); + const dest = join(dir, 'last-land.json'); + const tmp = `${dest}.${process.pid}.tmp`; + writeFileSync(tmp, JSON.stringify(state2, null, 2) + '\n', { mode: 0o600 }); + renameSync(tmp, dest); + + process.stdout.write(`LANDED: pr=#${pr} sha=${sha} regime=${regime} base=${base}${shaNote}\n`); + process.stdout.write(`HANDOFF_FILE=${dest}\n`); + return 0; +} + +function cmdReadState(flags: Record, bools: Set): number { + const slug = flags.slug; + const pr = flags.pr; + const repo = flags.repo; + if (!slug || !pr || !repo) { process.stderr.write('read-state: --slug, --pr, --repo are required\n'); return 2; } + const dest = join(homedir(), '.gstack', 'projects', slug, 'last-land.json'); + let state: any = null; + if (existsSync(dest)) { + try { state = JSON.parse(readFileSync(dest, 'utf-8')); } catch { state = null; } + } + const v = validateConsume(state, { + pr: Number(pr), + repo, + maxAgeMs: flags['max-age-ms'] ? Number(flags['max-age-ms']) : undefined, + }, Date.now()); + if (!v.ok) { + process.stderr.write(`READ_STATE_INVALID=${v.reason}\n`); + return 1; + } + if (bools.has('json')) { + process.stdout.write(JSON.stringify(state) + '\n'); + } else { + process.stdout.write(`LAND_SHA=${state.sha}\nLAND_BASE=${state.base}\nLAND_REGIME=${state.regime}\nLAND_HEAD=${state.head_branch}\n`); + } + return 0; +} + +// --- main --------------------------------------------------------------- + +async function main(): Promise { + const { cmd, flags, bools } = parseArgs(process.argv.slice(2)); + switch (cmd) { + case 'detect': return cmdDetect(flags, bools); + case 'submit': return cmdSubmit(flags); + case 'wait': return cmdWait(flags); + case 'write-state': return cmdWriteState(flags); + case 'read-state': return cmdReadState(flags, bools); + default: + process.stderr.write('usage: gstack-merge [flags]\n'); + return 2; + } +} + +if (import.meta.main) { + main().then((code) => process.exit(code)).catch((err) => { + process.stderr.write(`gstack-merge: ${err?.message || err}\n`); + process.exit(3); + }); +} diff --git a/lib/merge.ts b/lib/merge.ts new file mode 100644 index 000000000..769c8adbc --- /dev/null +++ b/lib/merge.ts @@ -0,0 +1,306 @@ +// lib/merge.ts — pure merge-regime logic for /land and /land-and-deploy. +// +// This module is the single source of truth for the four risky merge +// operations, kept pure (no I/O) so they can be unit-tested with fixtures: +// +// detectRegime — none | github | trunk, from gh/check/config signals +// planSubmit — ordered list of submit commands per regime (trunk = comment-first) +// classifyLand — landed | ejected | pending | closed, from PR state + checks +// buildLandState / validateConsume — the last-land.json handoff contract +// +// The CLI wrapper (bin/gstack-merge) gathers the live data via gh/git/fs and +// calls these functions. Keeping logic here means an E2E that inspects a +// command tests prompt text, but the unit tests here test behavior. + +export type Regime = 'none' | 'github' | 'trunk'; + +export const LAND_STATE_SCHEMA_VERSION = 1; + +// --- Regime detection --------------------------------------------------- + +export interface PrCheck { + /** Check name, e.g. "Trunk Merge Queue (main)". */ + name?: string; + /** gh exposes `state` (e.g. SUCCESS) and/or `bucket` (pass/fail/pending). */ + state?: string; + bucket?: string; +} + +export interface DetectInput { + /** Base branch the PR targets, e.g. "main". */ + base: string; + /** Output of `gh pr checks --json name,state,bucket`. */ + checks: PrCheck[]; + /** Contents of .trunk/trunk.yaml if present, else null. */ + trunkYaml: string | null; + /** Explicit "Merge queue: X" from CLAUDE.md ## Merge Configuration, else null. */ + configRegime?: string | null; + /** True when branch protection on `base` has a GitHub-native merge queue. */ + githubMergeQueue?: boolean; +} + +export interface DetectResult { + regime: Regime; + /** How the regime was decided — for honest narration. */ + source: 'config' | 'trunk-status-check' | 'trunk-yaml' | 'github-branch-protection' | 'default'; +} + +const VALID_REGIMES: Regime[] = ['none', 'github', 'trunk']; + +/** Name of the GitHub status check Trunk posts on PRs, e.g. "Trunk Merge Queue (main)". */ +export function trunkQueueCheckName(base: string): string { + return `Trunk Merge Queue (${base})`; +} + +/** Match any "Trunk Merge Queue ()" check regardless of branch. */ +export function isTrunkQueueCheck(name: string | undefined): boolean { + return !!name && /^Trunk Merge Queue \(.+\)$/.test(name); +} + +/** + * Decide the merge regime. Precedence: + * 1. explicit config key (the project owns its config) + * 2. live Trunk status check on the PR (the authoritative live signal) + * 3. .trunk/trunk.yaml `merge:` section (secondary — NOT `.trunk/` presence + * alone, which `trunk check` also creates) + * 4. GitHub-native merge queue from branch protection + * 5. none + */ +export function detectRegime(input: DetectInput): DetectResult { + const cfg = (input.configRegime || '').trim().toLowerCase(); + if (VALID_REGIMES.includes(cfg as Regime)) { + return { regime: cfg as Regime, source: 'config' }; + } + + if (input.checks.some((c) => isTrunkQueueCheck(c.name))) { + return { regime: 'trunk', source: 'trunk-status-check' }; + } + + // Secondary: a `merge:` section in .trunk/trunk.yaml means merge-queue is + // configured for this repo (distinct from a bare .trunk/ that only carries + // `trunk check` linter config). + if (input.trunkYaml && /^\s*merge\s*:/m.test(input.trunkYaml)) { + return { regime: 'trunk', source: 'trunk-yaml' }; + } + + if (input.githubMergeQueue) { + return { regime: 'github', source: 'github-branch-protection' }; + } + + return { regime: 'none', source: 'default' }; +} + +// --- Submit planning ---------------------------------------------------- + +export interface SubmitCandidate { + /** Program to run. */ + cmd: string; + /** Args (pr number substituted). */ + args: string[]; + /** Human description for narration. */ + desc: string; +} + +export interface SubmitPlan { + regime: Regime; + /** Candidates to try in order; first that runs cleanly wins. */ + candidates: SubmitCandidate[]; + /** Whether `--delete-branch` is owned by us (false for trunk — Trunk owns it). */ + deleteBranch: boolean; +} + +export interface SubmitOpts { + /** `trunk` CLI is installed + on PATH. */ + trunkCliAvailable?: boolean; + /** $TRUNK_API_TOKEN is set (enables the REST fallback). */ + trunkToken?: boolean; + /** Optional priority word for trunk (urgent|high|medium|low|lowest). */ + priority?: string; +} + +/** + * Build the ordered submit plan for a regime. + * + * trunk is **comment-first**: `gh pr comment "/trunk merge"` needs zero new + * auth (gh is already required and Trunk's "GitHub commands" toggle is + * default-ON), so it works the moment Trunk's GitHub App is installed. The + * trunk CLI and REST are opportunistic upgrades. + */ +export function planSubmit(regime: Regime, pr: number, opts: SubmitOpts = {}): SubmitPlan { + const prRef = String(pr); + if (regime === 'none') { + return { + regime, + deleteBranch: true, + candidates: [ + { cmd: 'gh', args: ['pr', 'merge', prRef, '--squash', '--delete-branch'], desc: 'direct squash merge' }, + ], + }; + } + + if (regime === 'github') { + return { + regime, + deleteBranch: true, + candidates: [ + { cmd: 'gh', args: ['pr', 'merge', prRef, '--auto', '--delete-branch'], desc: 'GitHub auto-merge / merge queue' }, + // If --auto is not enabled on the repo, fall back to a direct squash. + { cmd: 'gh', args: ['pr', 'merge', prRef, '--squash', '--delete-branch'], desc: 'direct squash merge (auto unavailable)' }, + ], + }; + } + + // trunk — comment-first, then CLI, then REST. NEVER `gh pr merge`, NEVER + // --delete-branch (Trunk owns the merge and branch cleanup). + const commentBody = opts.priority ? `/trunk merge --priority=${opts.priority}` : '/trunk merge'; + const candidates: SubmitCandidate[] = [ + { cmd: 'gh', args: ['pr', 'comment', prRef, '--body', commentBody], desc: 'enqueue via GitHub comment (/trunk merge)' }, + ]; + if (opts.trunkCliAvailable) { + const cliArgs = ['merge', prRef]; + if (opts.priority) cliArgs.push('--priority', opts.priority); + candidates.push({ cmd: 'trunk', args: cliArgs, desc: 'enqueue via trunk CLI' }); + } + if (opts.trunkToken) { + // REST fallback is executed specially by the CLI wrapper (curl with token); + // represented here so callers/tests see the full ordered chain. + candidates.push({ cmd: 'trunk-rest', args: [prRef], desc: 'enqueue via Trunk REST API' }); + } + return { regime, deleteBranch: false, candidates }; +} + +// --- Landing classification --------------------------------------------- + +export type LandStatus = 'landed' | 'ejected' | 'pending' | 'closed'; + +export interface ClassifyInput { + /** PR state: OPEN | MERGED | CLOSED. */ + state: string; + /** `gh pr view --json mergeCommit -q .mergeCommit.oid`, null if absent. */ + mergeCommitOid: string | null; + /** True if `git branch -r --contains ` shows the base branch. */ + baseContainsHead: boolean; + /** The Trunk/GitHub merge-queue check on the PR, if present. */ + queueCheck?: { name?: string; state?: string; bucket?: string } | null; + /** Whether GitHub auto-merge is enabled (autoMergeRequest non-null). */ + autoMergeEnabled?: boolean; +} + +const EJECTED_STATES = new Set(['FAILURE', 'ERROR', 'CANCELLED', 'CANCELED', 'FAIL']); + +/** + * Decide whether a PR has landed. Uniform across all three regimes: + * + * landed = MERGED AND (mergeCommit.oid non-null OR base contains the head) + * pending = MERGED but SHA not yet visible (squash/rebase lag), or still in queue + * ejected = queue check failed/cancelled while PR is still OPEN + * closed = CLOSED without merging + * + * The (oid OR baseContainsHead) guard handles rebase-merge repos where + * mergeCommit.oid stays null — H3. + */ +export function classifyLand(input: ClassifyInput): { status: LandStatus; reason: string } { + const state = (input.state || '').toUpperCase(); + + if (state === 'MERGED') { + if (input.mergeCommitOid || input.baseContainsHead) { + return { status: 'landed', reason: 'PR is MERGED and the commit is on the base branch' }; + } + return { status: 'pending', reason: 'PR is MERGED but the merge SHA is not visible yet (squash/rebase lag)' }; + } + + if (state === 'CLOSED') { + return { status: 'closed', reason: 'PR was closed without merging' }; + } + + // state OPEN + const cs = (input.queueCheck?.state || input.queueCheck?.bucket || '').toUpperCase(); + if (input.queueCheck && EJECTED_STATES.has(cs)) { + return { status: 'ejected', reason: `merge-queue check reported ${cs}` }; + } + + return { status: 'pending', reason: 'PR is still open (in queue or merge pending)' }; +} + +// --- Handoff state (last-land.json) ------------------------------------- + +export interface LandState { + schema_version: number; + pr: number; + sha: string; + headRefOid: string; + base: string; + head_branch: string; + repo: string; // owner/name + regime: Regime; + ts: string; // ISO 8601 +} + +export interface BuildLandStateInput { + pr: number; + sha: string; + headRefOid: string; + base: string; + head_branch: string; + repo: string; + regime: Regime; + /** ISO timestamp — injected so this stays pure/deterministic in tests. */ + ts: string; +} + +/** Assemble a validated LandState. Throws if the landing SHA is missing. */ +export function buildLandState(input: BuildLandStateInput): LandState { + if (!input.sha) { + throw new Error('refusing to write last-land.json with an empty merge SHA — landing not confirmed'); + } + return { + schema_version: LAND_STATE_SCHEMA_VERSION, + pr: input.pr, + sha: input.sha, + headRefOid: input.headRefOid, + base: input.base, + head_branch: input.head_branch, + repo: input.repo, + regime: input.regime, + ts: input.ts, + }; +} + +export interface ConsumeExpectation { + pr: number; + repo: string; + /** Max age in ms before the state is considered stale. Default 6h. */ + maxAgeMs?: number; +} + +const DEFAULT_MAX_AGE_MS = 6 * 60 * 60 * 1000; + +/** + * Validate a last-land.json the parent is about to consume. Guards against + * stale-state-drives-wrong-deploy (H5): the file must be for THIS pr+repo, + * recent, schema-compatible, and carry a non-null SHA. + */ +export function validateConsume( + state: Partial | null, + expected: ConsumeExpectation, + nowMs: number, +): { ok: boolean; reason?: string } { + if (!state) return { ok: false, reason: 'no last-land.json found' }; + if (state.schema_version !== LAND_STATE_SCHEMA_VERSION) { + return { ok: false, reason: `schema_version mismatch (got ${state.schema_version}, want ${LAND_STATE_SCHEMA_VERSION})` }; + } + if (!state.sha) return { ok: false, reason: 'last-land.json has no merge SHA' }; + if (state.pr !== expected.pr) { + return { ok: false, reason: `last-land.json is for PR #${state.pr}, not #${expected.pr}` }; + } + if (state.repo !== expected.repo) { + return { ok: false, reason: `last-land.json is for repo ${state.repo}, not ${expected.repo}` }; + } + const ts = state.ts ? Date.parse(state.ts) : NaN; + if (Number.isNaN(ts)) return { ok: false, reason: 'last-land.json has an invalid timestamp' }; + const maxAge = expected.maxAgeMs ?? DEFAULT_MAX_AGE_MS; + if (nowMs - ts > maxAge) { + return { ok: false, reason: `last-land.json is stale (${Math.round((nowMs - ts) / 60000)} min old)` }; + } + return { ok: true }; +} diff --git a/test/gstack-merge-cli.test.ts b/test/gstack-merge-cli.test.ts new file mode 100644 index 000000000..b33f594cf --- /dev/null +++ b/test/gstack-merge-cli.test.ts @@ -0,0 +1,131 @@ +// Deterministic end-to-end coverage of the bin/gstack-merge CLI (arg parsing, +// file IO, and the lib/merge.ts wiring through the real binary). Free + fast — +// no claude -p, no network. The submit/wait/classify *logic* is unit-tested in +// gstack-merge.test.ts; this proves the executable plumbs it correctly and that +// the handoff contract (write → consume, with null/stale/foreign rejection) +// round-trips through the actual CLI surface /land and /land-and-deploy call. + +import { describe, test, expect, beforeEach, afterEach } from 'bun:test'; +import { spawnSync } from 'node:child_process'; +import { mkdtempSync, rmSync, mkdirSync, writeFileSync } from 'node:fs'; +import { tmpdir } from 'node:os'; +import { join } from 'node:path'; + +const BIN = join(import.meta.dir, '..', 'bin', 'gstack-merge'); + +function runCli(args: string[], opts: { cwd?: string; home?: string } = {}) { + const r = spawnSync('bun', [BIN, ...args], { + encoding: 'utf-8', + cwd: opts.cwd || process.cwd(), + env: { ...process.env, ...(opts.home ? { HOME: opts.home } : {}) }, + timeout: 30000, + }); + return { code: r.status ?? 1, stdout: r.stdout || '', stderr: r.stderr || '' }; +} + +let tmp: string; +beforeEach(() => { tmp = mkdtempSync(join(tmpdir(), 'gstack-merge-cli-')); }); +afterEach(() => { rmSync(tmp, { recursive: true, force: true }); }); + +describe('gstack-merge CLI: detect', () => { + test('an explicit "Merge queue: trunk" config wins (no git/gh needed)', () => { + // A non-git temp dir: gh/git calls return nothing, so the config key is the + // only signal — exactly the precedence /land relies on. + writeFileSync(join(tmp, 'CLAUDE.md'), '# proj\n\n## Merge Configuration\n- Merge queue: trunk\n'); + const r = runCli(['detect', '--base', 'main', '--json'], { cwd: tmp }); + expect(r.code).toBe(0); + const out = JSON.parse(r.stdout.trim()); + expect(out.regime).toBe('trunk'); + expect(out.source).toBe('config'); + }); + + test('config "Merge queue: github" is honored', () => { + writeFileSync(join(tmp, 'CLAUDE.md'), '## Merge Configuration\n- Merge queue: github\n'); + const r = runCli(['detect', '--base', 'main', '--json'], { cwd: tmp }); + expect(JSON.parse(r.stdout.trim()).regime).toBe('github'); + }); + + test('no config + no signals → none', () => { + const r = runCli(['detect', '--base', 'main', '--json'], { cwd: tmp }); + expect(r.code).toBe(0); + expect(JSON.parse(r.stdout.trim()).regime).toBe('none'); + }); +}); + +describe('gstack-merge CLI: handoff round-trip (write → consume)', () => { + const SLUG = 'acme-widget'; + const REPO = 'acme/widget'; + + function seedHandoff(home: string, overrides: Record = {}) { + const dir = join(home, '.gstack', 'projects', SLUG); + mkdirSync(dir, { recursive: true }); + const state = { + schema_version: 1, + pr: 42, + sha: 'deadbeefcafe', + headRefOid: 'cafef00d', + base: 'main', + head_branch: 'feat/x', + repo: REPO, + regime: 'trunk', + ts: new Date().toISOString(), + ...overrides, + }; + writeFileSync(join(dir, 'last-land.json'), JSON.stringify(state, null, 2)); + } + + test('read-state accepts a matching recent handoff and emits the SHA', () => { + seedHandoff(tmp); + const r = runCli(['read-state', '--slug', SLUG, '--pr', '42', '--repo', REPO], { home: tmp }); + expect(r.code).toBe(0); + expect(r.stdout).toContain('LAND_SHA=deadbeefcafe'); + expect(r.stdout).toContain('LAND_REGIME=trunk'); + }); + + test('read-state rejects a handoff for a different PR (no cross-PR deploy)', () => { + seedHandoff(tmp); + const r = runCli(['read-state', '--slug', SLUG, '--pr', '99', '--repo', REPO], { home: tmp }); + expect(r.code).toBe(1); + expect(r.stderr).toContain('READ_STATE_INVALID'); + }); + + test('read-state rejects a handoff for a different repo', () => { + seedHandoff(tmp); + const r = runCli(['read-state', '--slug', SLUG, '--pr', '42', '--repo', 'other/repo'], { home: tmp }); + expect(r.code).toBe(1); + expect(r.stderr).toContain('READ_STATE_INVALID'); + }); + + test('read-state rejects a stale handoff', () => { + seedHandoff(tmp, { ts: '2020-01-01T00:00:00.000Z' }); + const r = runCli(['read-state', '--slug', SLUG, '--pr', '42', '--repo', REPO], { home: tmp }); + expect(r.code).toBe(1); + expect(r.stderr).toContain('READ_STATE_INVALID'); + }); + + test('read-state rejects a handoff with an empty SHA (the null-SHA STOP)', () => { + seedHandoff(tmp, { sha: '' }); + const r = runCli(['read-state', '--slug', SLUG, '--pr', '42', '--repo', REPO], { home: tmp }); + expect(r.code).toBe(1); + expect(r.stderr).toContain('READ_STATE_INVALID'); + }); + + test('read-state reports missing when there is no handoff at all', () => { + const r = runCli(['read-state', '--slug', 'nope', '--pr', '1', '--repo', REPO], { home: tmp }); + expect(r.code).toBe(1); + expect(r.stderr).toContain('READ_STATE_INVALID'); + }); +}); + +describe('gstack-merge CLI: argument handling', () => { + test('unknown subcommand exits 2 with usage', () => { + const r = runCli(['frobnicate']); + expect(r.code).toBe(2); + expect(r.stderr).toContain('usage: gstack-merge'); + }); + + test('submit without --pr exits 2', () => { + const r = runCli(['submit', '--regime', 'none']); + expect(r.code).toBe(2); + }); +}); diff --git a/test/gstack-merge.test.ts b/test/gstack-merge.test.ts new file mode 100644 index 000000000..6c1adcaf9 --- /dev/null +++ b/test/gstack-merge.test.ts @@ -0,0 +1,263 @@ +import { describe, test, expect } from 'bun:test'; +import { + detectRegime, + planSubmit, + classifyLand, + buildLandState, + validateConsume, + isTrunkQueueCheck, + trunkQueueCheckName, + LAND_STATE_SCHEMA_VERSION, + type LandState, +} from '../lib/merge'; + +describe('detectRegime', () => { + test('explicit config key wins over everything', () => { + const r = detectRegime({ + base: 'main', + configRegime: 'github', + checks: [{ name: trunkQueueCheckName('main'), state: 'PENDING' }], // would say trunk + trunkYaml: 'merge:\n required_statuses: [ci]', + githubMergeQueue: false, + }); + expect(r.regime).toBe('github'); + expect(r.source).toBe('config'); + }); + + test('invalid config key is ignored and falls through to live signals', () => { + const r = detectRegime({ + base: 'main', + configRegime: 'banana', + checks: [{ name: 'Trunk Merge Queue (main)' }], + trunkYaml: null, + }); + expect(r.regime).toBe('trunk'); + expect(r.source).toBe('trunk-status-check'); + }); + + test('trunk status check on the PR → trunk (even on a non-main base)', () => { + const r = detectRegime({ + base: 'develop', + checks: [{ name: 'Trunk Merge Queue (develop)', state: 'TESTING' }], + trunkYaml: null, + }); + expect(r.regime).toBe('trunk'); + expect(r.source).toBe('trunk-status-check'); + }); + + test('.trunk/trunk.yaml with a merge: section → trunk (secondary signal)', () => { + const r = detectRegime({ + base: 'main', + checks: [{ name: 'build' }, { name: 'test' }], + trunkYaml: 'version: 0.1\nmerge:\n required_statuses:\n - build\n', + }); + expect(r.regime).toBe('trunk'); + expect(r.source).toBe('trunk-yaml'); + }); + + test('bare .trunk/trunk.yaml WITHOUT a merge: section is NOT trunk (check-only false positive guard)', () => { + const r = detectRegime({ + base: 'main', + checks: [{ name: 'build' }], + trunkYaml: 'version: 0.1\ncli:\n version: 1.22.0\nlint:\n enabled:\n - eslint\n', + }); + expect(r.regime).toBe('none'); + expect(r.source).toBe('default'); + }); + + test('github branch-protection merge queue → github', () => { + const r = detectRegime({ + base: 'main', + checks: [{ name: 'build' }], + trunkYaml: null, + githubMergeQueue: true, + }); + expect(r.regime).toBe('github'); + expect(r.source).toBe('github-branch-protection'); + }); + + test('no signals → none', () => { + const r = detectRegime({ base: 'main', checks: [], trunkYaml: null }); + expect(r.regime).toBe('none'); + expect(r.source).toBe('default'); + }); +}); + +describe('isTrunkQueueCheck', () => { + test('matches the queue check name for any branch', () => { + expect(isTrunkQueueCheck('Trunk Merge Queue (main)')).toBe(true); + expect(isTrunkQueueCheck('Trunk Merge Queue (release/v2)')).toBe(true); + }); + test('does not match unrelated checks', () => { + expect(isTrunkQueueCheck('Trunk Check')).toBe(false); + expect(isTrunkQueueCheck('build')).toBe(false); + expect(isTrunkQueueCheck(undefined)).toBe(false); + }); +}); + +describe('planSubmit', () => { + test('none → single direct squash with branch delete', () => { + const p = planSubmit('none', 42); + expect(p.deleteBranch).toBe(true); + expect(p.candidates).toHaveLength(1); + expect(p.candidates[0].args).toEqual(['pr', 'merge', '42', '--squash', '--delete-branch']); + }); + + test('github → auto-merge first, squash fallback', () => { + const p = planSubmit('github', 42); + expect(p.deleteBranch).toBe(true); + expect(p.candidates[0].args).toContain('--auto'); + expect(p.candidates[1].args).toContain('--squash'); + }); + + test('trunk is comment-first and never deletes the branch', () => { + const p = planSubmit('trunk', 7, { trunkCliAvailable: false, trunkToken: false }); + expect(p.deleteBranch).toBe(false); + expect(p.candidates).toHaveLength(1); + expect(p.candidates[0].cmd).toBe('gh'); + expect(p.candidates[0].args).toEqual(['pr', 'comment', '7', '--body', '/trunk merge']); + // No gh pr merge anywhere in the trunk plan. + expect(p.candidates.some((c) => c.args.includes('merge') && c.cmd === 'gh' && c.args.includes('pr') && c.args[1] === 'merge')).toBe(false); + }); + + test('trunk adds CLI then REST as opportunistic fallbacks, in order', () => { + const p = planSubmit('trunk', 7, { trunkCliAvailable: true, trunkToken: true }); + expect(p.candidates.map((c) => c.cmd)).toEqual(['gh', 'trunk', 'trunk-rest']); + }); + + test('trunk priority threads into the comment body and CLI', () => { + const p = planSubmit('trunk', 7, { trunkCliAvailable: true, priority: 'high' }); + expect(p.candidates[0].args).toContain('/trunk merge --priority=high'); + expect(p.candidates[1].args).toEqual(['merge', '7', '--priority', 'high']); + }); +}); + +describe('classifyLand', () => { + test('MERGED with a merge SHA → landed', () => { + const c = classifyLand({ state: 'MERGED', mergeCommitOid: 'abc123', baseContainsHead: false }); + expect(c.status).toBe('landed'); + }); + + test('MERGED with null SHA but base contains head → landed (rebase-merge case, H3)', () => { + const c = classifyLand({ state: 'MERGED', mergeCommitOid: null, baseContainsHead: true }); + expect(c.status).toBe('landed'); + }); + + test('MERGED but SHA not visible and base does not yet contain head → pending (squash lag)', () => { + const c = classifyLand({ state: 'MERGED', mergeCommitOid: null, baseContainsHead: false }); + expect(c.status).toBe('pending'); + }); + + test('OPEN with a failed queue check → ejected', () => { + const c = classifyLand({ + state: 'OPEN', + mergeCommitOid: null, + baseContainsHead: false, + queueCheck: { name: 'Trunk Merge Queue (main)', state: 'FAILURE' }, + }); + expect(c.status).toBe('ejected'); + }); + + test('OPEN with a cancelled queue check → ejected', () => { + const c = classifyLand({ + state: 'OPEN', + mergeCommitOid: null, + baseContainsHead: false, + queueCheck: { name: 'Trunk Merge Queue (main)', bucket: 'CANCELLED' }, + }); + expect(c.status).toBe('ejected'); + }); + + test('OPEN with a still-testing queue check → pending', () => { + const c = classifyLand({ + state: 'OPEN', + mergeCommitOid: null, + baseContainsHead: false, + queueCheck: { name: 'Trunk Merge Queue (main)', state: 'IN_PROGRESS' }, + }); + expect(c.status).toBe('pending'); + }); + + test('CLOSED → closed', () => { + const c = classifyLand({ state: 'CLOSED', mergeCommitOid: null, baseContainsHead: false }); + expect(c.status).toBe('closed'); + }); +}); + +describe('buildLandState', () => { + const base = { + pr: 12, + sha: 'deadbeef', + headRefOid: 'cafe', + base: 'main', + head_branch: 'feat/x', + repo: 'owner/name', + regime: 'trunk' as const, + ts: '2026-05-31T00:00:00.000Z', + }; + + test('assembles a schema-versioned state', () => { + const s = buildLandState(base); + expect(s.schema_version).toBe(LAND_STATE_SCHEMA_VERSION); + expect(s.sha).toBe('deadbeef'); + expect(s.repo).toBe('owner/name'); + // scope is intentionally absent (T2 — parent recomputes diff-scope). + expect('scope' in s).toBe(false); + }); + + test('refuses to build with an empty SHA (handoff would silently kill revert)', () => { + expect(() => buildLandState({ ...base, sha: '' })).toThrow(/empty merge SHA/); + }); +}); + +describe('validateConsume', () => { + const now = Date.parse('2026-05-31T01:00:00.000Z'); + const good: LandState = { + schema_version: LAND_STATE_SCHEMA_VERSION, + pr: 12, + sha: 'deadbeef', + headRefOid: 'cafe', + base: 'main', + head_branch: 'feat/x', + repo: 'owner/name', + regime: 'trunk', + ts: '2026-05-31T00:30:00.000Z', + }; + + test('accepts a matching recent state', () => { + expect(validateConsume(good, { pr: 12, repo: 'owner/name' }, now).ok).toBe(true); + }); + + test('rejects when no state file', () => { + const v = validateConsume(null, { pr: 12, repo: 'owner/name' }, now); + expect(v.ok).toBe(false); + }); + + test('rejects a state for a different PR (stale-state-drives-wrong-deploy, H5)', () => { + const v = validateConsume(good, { pr: 99, repo: 'owner/name' }, now); + expect(v.ok).toBe(false); + expect(v.reason).toMatch(/PR #12/); + }); + + test('rejects a state for a different repo', () => { + const v = validateConsume(good, { pr: 12, repo: 'other/name' }, now); + expect(v.ok).toBe(false); + }); + + test('rejects a stale state past max age', () => { + const stale = { ...good, ts: '2026-05-30T00:00:00.000Z' }; + const v = validateConsume(stale, { pr: 12, repo: 'owner/name' }, now); + expect(v.ok).toBe(false); + expect(v.reason).toMatch(/stale/); + }); + + test('rejects an empty SHA', () => { + const v = validateConsume({ ...good, sha: '' }, { pr: 12, repo: 'owner/name' }, now); + expect(v.ok).toBe(false); + }); + + test('rejects a schema_version mismatch', () => { + const v = validateConsume({ ...good, schema_version: 99 }, { pr: 12, repo: 'owner/name' }, now); + expect(v.ok).toBe(false); + }); +}); From 948f55d1ab668f755c93fe777d60071536ea42cc Mon Sep 17 00:00:00 2001 From: Garry Tan Date: Sun, 31 May 2026 09:13:21 -0700 Subject: [PATCH 02/10] =?UTF-8?q?feat(land):=20new=20/land=20skill=20?= =?UTF-8?q?=E2=80=94=20land=20a=20PR=20through=20the=20right=20merge=20reg?= =?UTF-8?q?ime?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Extracts the land half of /land-and-deploy into a standalone, composable skill: pre-flight, CI wait, VERSION-drift, the pre-merge readiness gate (with --fast), and a regime-aware merge that drives bin/gstack-merge. Confirms landing (state==MERGED + commit on base, handling rebase-null oid) and writes the last-land.json handoff. Carries the never-blind-retry post-failure invariant (cli/cli#3442, cli/cli#13380). Co-Authored-By: Claude Opus 4.8 (1M context) --- gstack/llms.txt | 1 + land/SKILL.md | 1217 ++++++++++++++++++++++++++++ land/SKILL.md.tmpl | 467 +++++++++++ scripts/proactive-suggestions.json | 5 + 4 files changed, 1690 insertions(+) create mode 100644 land/SKILL.md create mode 100644 land/SKILL.md.tmpl diff --git a/gstack/llms.txt b/gstack/llms.txt index a11b045d1..2c03f3f7a 100644 --- a/gstack/llms.txt +++ b/gstack/llms.txt @@ -39,6 +39,7 @@ Conventions: - [/ios-fix](ios-fix/SKILL.md): Autonomous iOS bug fixer. - [/ios-qa](ios-qa/SKILL.md): Live-device iOS QA for SwiftUI apps. - [/ios-sync](ios-sync/SKILL.md): Regenerate the iOS debug bridge against the latest upstream gstack templates. +- [/land](land/SKILL.md): Land a PR through the right merge regime: pre-flight, CI wait, VERSION-drift check, pre-merge readiness gate, then merge via no-queue, GitHub native merge queue, or trunk.io merge queue. - [/land-and-deploy](land-and-deploy/SKILL.md): Land and deploy workflow. - [/landing-report](landing-report/SKILL.md): Read-only queue dashboard for workspace-aware ship. - [/learn](learn/SKILL.md): Manage project learnings. diff --git a/land/SKILL.md b/land/SKILL.md new file mode 100644 index 000000000..f3b1f7559 --- /dev/null +++ b/land/SKILL.md @@ -0,0 +1,1217 @@ +--- +name: land +preamble-tier: 4 +version: 1.0.0 +description: Land a PR through the right merge regime: pre-flight, CI wait, VERSION-drift check, pre-merge readiness gate, then merge via no-queue, (gstack) +allowed-tools: + - Bash + - Read + - Write + - Glob + - AskUserQuestion +triggers: + - land the pr + - land it + - merge the pr + - merge it + - get it merged +--- + + + + +## When to invoke this skill + +GitHub native merge +queue, or trunk.io merge queue. This is the "land" half of /land-and-deploy, +usable on its own when you want to merge but not deploy. Use when: "land", +"land the pr", "land it", "merge", "merge the pr", "merge it", "get it merged". +For deploy + canary verification after landing, use /land-and-deploy. + +## Preamble (run first) + +```bash +_UPD=$(~/.claude/skills/gstack/bin/gstack-update-check 2>/dev/null || .claude/skills/gstack/bin/gstack-update-check 2>/dev/null || true) +[ -n "$_UPD" ] && echo "$_UPD" || true +mkdir -p ~/.gstack/sessions +touch ~/.gstack/sessions/"$PPID" +_SESSIONS=$(find ~/.gstack/sessions -mmin -120 -type f 2>/dev/null | wc -l | tr -d ' ') +find ~/.gstack/sessions -mmin +120 -type f -exec rm {} + 2>/dev/null || true +_PROACTIVE=$(~/.claude/skills/gstack/bin/gstack-config get proactive 2>/dev/null || echo "true") +_PROACTIVE_PROMPTED=$([ -f ~/.gstack/.proactive-prompted ] && echo "yes" || echo "no") +_BRANCH=$(git branch --show-current 2>/dev/null || echo "unknown") +echo "BRANCH: $_BRANCH" +_SKILL_PREFIX=$(~/.claude/skills/gstack/bin/gstack-config get skill_prefix 2>/dev/null || echo "false") +echo "PROACTIVE: $_PROACTIVE" +echo "PROACTIVE_PROMPTED: $_PROACTIVE_PROMPTED" +echo "SKILL_PREFIX: $_SKILL_PREFIX" +source <(~/.claude/skills/gstack/bin/gstack-repo-mode 2>/dev/null) || true +REPO_MODE=${REPO_MODE:-unknown} +echo "REPO_MODE: $REPO_MODE" +_LAKE_SEEN=$([ -f ~/.gstack/.completeness-intro-seen ] && echo "yes" || echo "no") +echo "LAKE_INTRO: $_LAKE_SEEN" +_TEL=$(~/.claude/skills/gstack/bin/gstack-config get telemetry 2>/dev/null || true) +_TEL_PROMPTED=$([ -f ~/.gstack/.telemetry-prompted ] && echo "yes" || echo "no") +_TEL_START=$(date +%s) +_SESSION_ID="$$-$(date +%s)" +echo "TELEMETRY: ${_TEL:-off}" +echo "TEL_PROMPTED: $_TEL_PROMPTED" +_EXPLAIN_LEVEL=$(~/.claude/skills/gstack/bin/gstack-config get explain_level 2>/dev/null || echo "default") +if [ "$_EXPLAIN_LEVEL" != "default" ] && [ "$_EXPLAIN_LEVEL" != "terse" ]; then _EXPLAIN_LEVEL="default"; fi +echo "EXPLAIN_LEVEL: $_EXPLAIN_LEVEL" +_QUESTION_TUNING=$(~/.claude/skills/gstack/bin/gstack-config get question_tuning 2>/dev/null || echo "false") +echo "QUESTION_TUNING: $_QUESTION_TUNING" +mkdir -p ~/.gstack/analytics +if [ "$_TEL" != "off" ]; then +echo '{"skill":"land","ts":"'$(date -u +%Y-%m-%dT%H:%M:%SZ)'","repo":"'$(basename "$(git rev-parse --show-toplevel 2>/dev/null)" 2>/dev/null || echo "unknown")'"}' >> ~/.gstack/analytics/skill-usage.jsonl 2>/dev/null || true +fi +for _PF in $(find ~/.gstack/analytics -maxdepth 1 -name '.pending-*' 2>/dev/null); do + if [ -f "$_PF" ]; then + if [ "$_TEL" != "off" ] && [ -x "~/.claude/skills/gstack/bin/gstack-telemetry-log" ]; then + ~/.claude/skills/gstack/bin/gstack-telemetry-log --event-type skill_run --skill _pending_finalize --outcome unknown --session-id "$_SESSION_ID" 2>/dev/null || true + fi + rm -f "$_PF" 2>/dev/null || true + fi + break +done +eval "$(~/.claude/skills/gstack/bin/gstack-slug 2>/dev/null)" 2>/dev/null || true +_LEARN_FILE="${GSTACK_HOME:-$HOME/.gstack}/projects/${SLUG:-unknown}/learnings.jsonl" +if [ -f "$_LEARN_FILE" ]; then + _LEARN_COUNT=$(wc -l < "$_LEARN_FILE" 2>/dev/null | tr -d ' ') + echo "LEARNINGS: $_LEARN_COUNT entries loaded" + if [ "$_LEARN_COUNT" -gt 5 ] 2>/dev/null; then + ~/.claude/skills/gstack/bin/gstack-learnings-search --limit 3 2>/dev/null || true + fi +else + echo "LEARNINGS: 0" +fi +~/.claude/skills/gstack/bin/gstack-timeline-log '{"skill":"land","event":"started","branch":"'"$_BRANCH"'","session":"'"$_SESSION_ID"'"}' 2>/dev/null & +_HAS_ROUTING="no" +if [ -f CLAUDE.md ] && grep -q "## Skill routing" CLAUDE.md 2>/dev/null; then + _HAS_ROUTING="yes" +fi +_ROUTING_DECLINED=$(~/.claude/skills/gstack/bin/gstack-config get routing_declined 2>/dev/null || echo "false") +echo "HAS_ROUTING: $_HAS_ROUTING" +echo "ROUTING_DECLINED: $_ROUTING_DECLINED" +_VENDORED="no" +if [ -d ".claude/skills/gstack" ] && [ ! -L ".claude/skills/gstack" ]; then + if [ -f ".claude/skills/gstack/VERSION" ] || [ -d ".claude/skills/gstack/.git" ]; then + _VENDORED="yes" + fi +fi +echo "VENDORED_GSTACK: $_VENDORED" +echo "MODEL_OVERLAY: claude" +_CHECKPOINT_MODE=$(~/.claude/skills/gstack/bin/gstack-config get checkpoint_mode 2>/dev/null || echo "explicit") +_CHECKPOINT_PUSH=$(~/.claude/skills/gstack/bin/gstack-config get checkpoint_push 2>/dev/null || echo "false") +echo "CHECKPOINT_MODE: $_CHECKPOINT_MODE" +echo "CHECKPOINT_PUSH: $_CHECKPOINT_PUSH" +# Plan-mode hint for skills like /spec that branch behavior on plan-mode state. +# Claude Code exposes plan mode via system reminders; we detect best-effort +# from CLAUDE_PLAN_FILE (set by the harness when plan mode is active) and +# fall back to "inactive". Codex hosts and Claude execution mode both end up +# inactive, which is the safe default (defaults to file+execute pipeline). +if [ -n "${CLAUDE_PLAN_FILE:-}${GSTACK_PLAN_MODE_FORCE:-}" ]; then + export GSTACK_PLAN_MODE="active" +elif [ "${GSTACK_PLAN_MODE:-}" = "active" ]; then + export GSTACK_PLAN_MODE="active" +else + export GSTACK_PLAN_MODE="inactive" +fi +echo "GSTACK_PLAN_MODE: $GSTACK_PLAN_MODE" +[ -n "$OPENCLAW_SESSION" ] && echo "SPAWNED_SESSION: true" || true +``` + +## Plan Mode Safe Operations + +In plan mode, allowed because they inform the plan: `$B`, `$D`, `codex exec`/`codex review`, writes to `~/.gstack/`, writes to the plan file, and `open` for generated artifacts. + +## Skill Invocation During Plan Mode + +If the user invokes a skill in plan mode, the skill takes precedence over generic plan mode behavior. **Treat the skill file as executable instructions, not reference.** Follow it step by step starting from Step 0; the first AskUserQuestion is the workflow entering plan mode, not a violation of it. AskUserQuestion (any variant — `mcp__*__AskUserQuestion` or native; see "AskUserQuestion Format → Tool resolution") satisfies plan mode's end-of-turn requirement. If no variant is callable, the skill is BLOCKED — stop and report `BLOCKED — AskUserQuestion unavailable` per the AskUserQuestion Format rule. At a STOP point, stop immediately. Do not continue the workflow or call ExitPlanMode there. Commands marked "PLAN MODE EXCEPTION — ALWAYS RUN" execute. Call ExitPlanMode only after the skill workflow completes, or if the user tells you to cancel the skill or leave plan mode. + +If `PROACTIVE` is `"false"`, do not auto-invoke or proactively suggest skills. If a skill seems useful, ask: "I think /skillname might help here — want me to run it?" + +If `SKILL_PREFIX` is `"true"`, suggest/invoke `/gstack-*` names. Disk paths stay `~/.claude/skills/gstack/[skill-name]/SKILL.md`. + +If output shows `UPGRADE_AVAILABLE `: read `~/.claude/skills/gstack/gstack-upgrade/SKILL.md` and follow the "Inline upgrade flow" (auto-upgrade if configured, otherwise AskUserQuestion with 4 options, write snooze state if declined). + +If output shows `JUST_UPGRADED `: print "Running gstack v{to} (just updated!)". If `SPAWNED_SESSION` is true, skip feature discovery. + +Feature discovery, max one prompt per session: +- Missing `~/.claude/skills/gstack/.feature-prompted-continuous-checkpoint`: AskUserQuestion for Continuous checkpoint auto-commits. If accepted, run `~/.claude/skills/gstack/bin/gstack-config set checkpoint_mode continuous`. Always touch marker. +- Missing `~/.claude/skills/gstack/.feature-prompted-model-overlay`: inform "Model overlays are active. MODEL_OVERLAY shows the patch." Always touch marker. + +After upgrade prompts, continue workflow. + +If `WRITING_STYLE_PENDING` is `yes`: ask once about writing style: + +> v1 prompts are simpler: first-use jargon glosses, outcome-framed questions, shorter prose. Keep default or restore terse? + +Options: +- A) Keep the new default (recommended — good writing helps everyone) +- B) Restore V0 prose — set `explain_level: terse` + +If A: leave `explain_level` unset (defaults to `default`). +If B: run `~/.claude/skills/gstack/bin/gstack-config set explain_level terse`. + +Always run (regardless of choice): +```bash +rm -f ~/.gstack/.writing-style-prompt-pending +touch ~/.gstack/.writing-style-prompted +``` + +Skip if `WRITING_STYLE_PENDING` is `no`. + +If `LAKE_INTRO` is `no`: say "gstack follows the **Boil the Lake** principle — do the complete thing when AI makes marginal cost near-zero. Read more: https://garryslist.org/posts/boil-the-ocean" Offer to open: + +```bash +open https://garryslist.org/posts/boil-the-ocean +touch ~/.gstack/.completeness-intro-seen +``` + +Only run `open` if yes. Always run `touch`. + +If `TEL_PROMPTED` is `no` AND `LAKE_INTRO` is `yes`: ask telemetry once via AskUserQuestion: + +> Help gstack get better. Share usage data only: skill, duration, crashes, stable device ID. No code, file paths, or repo names. + +Options: +- A) Help gstack get better! (recommended) +- B) No thanks + +If A: run `~/.claude/skills/gstack/bin/gstack-config set telemetry community` + +If B: ask follow-up: + +> Anonymous mode sends only aggregate usage, no unique ID. + +Options: +- A) Sure, anonymous is fine +- B) No thanks, fully off + +If B→A: run `~/.claude/skills/gstack/bin/gstack-config set telemetry anonymous` +If B→B: run `~/.claude/skills/gstack/bin/gstack-config set telemetry off` + +Always run: +```bash +touch ~/.gstack/.telemetry-prompted +``` + +Skip if `TEL_PROMPTED` is `yes`. + +If `PROACTIVE_PROMPTED` is `no` AND `TEL_PROMPTED` is `yes`: ask once: + +> Let gstack proactively suggest skills, like /qa for "does this work?" or /investigate for bugs? + +Options: +- A) Keep it on (recommended) +- B) Turn it off — I'll type /commands myself + +If A: run `~/.claude/skills/gstack/bin/gstack-config set proactive true` +If B: run `~/.claude/skills/gstack/bin/gstack-config set proactive false` + +Always run: +```bash +touch ~/.gstack/.proactive-prompted +``` + +Skip if `PROACTIVE_PROMPTED` is `yes`. + +If `HAS_ROUTING` is `no` AND `ROUTING_DECLINED` is `false` AND `PROACTIVE_PROMPTED` is `yes`: +Check if a CLAUDE.md file exists in the project root. If it does not exist, create it. + +Use AskUserQuestion: + +> gstack works best when your project's CLAUDE.md includes skill routing rules. + +Options: +- A) Add routing rules to CLAUDE.md (recommended) +- B) No thanks, I'll invoke skills manually + +If A: Append this section to the end of CLAUDE.md: + +```markdown + +## Skill routing + +When the user's request matches an available skill, invoke it via the Skill tool. When in doubt, invoke the skill. + +Key routing rules: +- Product ideas/brainstorming → invoke /office-hours +- Strategy/scope → invoke /plan-ceo-review +- Architecture → invoke /plan-eng-review +- Design system/plan review → invoke /design-consultation or /plan-design-review +- Full review pipeline → invoke /autoplan +- Bugs/errors → invoke /investigate +- QA/testing site behavior → invoke /qa or /qa-only +- Code review/diff check → invoke /review +- Visual polish → invoke /design-review +- Ship/deploy/PR → invoke /ship or /land-and-deploy +- Save progress → invoke /context-save +- Resume context → invoke /context-restore +- Author a backlog-ready spec/issue → invoke /spec +``` + +Then commit the change: `git add CLAUDE.md && git commit -m "chore: add gstack skill routing rules to CLAUDE.md"` + +If B: run `~/.claude/skills/gstack/bin/gstack-config set routing_declined true` and say they can re-enable with `gstack-config set routing_declined false`. + +This only happens once per project. Skip if `HAS_ROUTING` is `yes` or `ROUTING_DECLINED` is `true`. + +If `VENDORED_GSTACK` is `yes`, warn once via AskUserQuestion unless `~/.gstack/.vendoring-warned-$SLUG` exists: + +> This project has gstack vendored in `.claude/skills/gstack/`. Vendoring is deprecated. +> Migrate to team mode? + +Options: +- A) Yes, migrate to team mode now +- B) No, I'll handle it myself + +If A: +1. Run `git rm -r .claude/skills/gstack/` +2. Run `echo '.claude/skills/gstack/' >> .gitignore` +3. Run `~/.claude/skills/gstack/bin/gstack-team-init required` (or `optional`) +4. Run `git add .claude/ .gitignore CLAUDE.md && git commit -m "chore: migrate gstack from vendored to team mode"` +5. Tell the user: "Done. Each developer now runs: `cd ~/.claude/skills/gstack && ./setup --team`" + +If B: say "OK, you're on your own to keep the vendored copy up to date." + +Always run (regardless of choice): +```bash +eval "$(~/.claude/skills/gstack/bin/gstack-slug 2>/dev/null)" 2>/dev/null || true +touch ~/.gstack/.vendoring-warned-${SLUG:-unknown} +``` + +If marker exists, skip. + +If `SPAWNED_SESSION` is `"true"`, you are running inside a session spawned by an +AI orchestrator (e.g., OpenClaw). In spawned sessions: +- Do NOT use AskUserQuestion for interactive prompts. Auto-choose the recommended option. +- Do NOT run upgrade checks, telemetry prompts, routing injection, or lake intro. +- Focus on completing the task and reporting results via prose output. +- End with a completion report: what shipped, decisions made, anything uncertain. + +## AskUserQuestion Format + +### Tool resolution (read first) + +"AskUserQuestion" can resolve to two tools at runtime: the **host MCP variant** (e.g. `mcp__conductor__AskUserQuestion` — appears in your tool list when the host registers it) or the **native** Claude Code tool. + +**Rule:** if any `mcp__*__AskUserQuestion` variant is in your tool list, prefer it. Hosts may disable native AUQ via `--disallowedTools AskUserQuestion` (Conductor does, by default) and route through their MCP variant; calling native there silently fails. Same questions/options shape; same decision-brief format applies. + +**If no AskUserQuestion variant appears in your tool list, this skill is BLOCKED.** Stop, report `BLOCKED — AskUserQuestion unavailable`, and wait for the user. Do not write decisions to the plan file as a substitute, do not emit them as prose and stop, and do not silently auto-decide (only `/plan-tune` AUTO_DECIDE opt-ins authorize auto-picking). + +### Format + +Every AskUserQuestion is a decision brief and must be sent as tool_use, not prose. + +``` +D +Project/branch/task: <1 short grounding sentence using _BRANCH> +ELI10: +Stakes if we pick wrong: +Recommendation: because +Completeness: A=X/10, B=Y/10 (or: Note: options differ in kind, not coverage — no completeness score) +Pros / cons: +A)