diff --git a/bin/gstack-archive-check b/bin/gstack-archive-check new file mode 100755 index 000000000..50b15f14e --- /dev/null +++ b/bin/gstack-archive-check @@ -0,0 +1,79 @@ +#!/usr/bin/env -S bun --no-env-file --config=/dev/null +/** + * gstack-archive-check — a Captain's pre-archive orphan check and the land + * step's issue close (GST-2188; R86, R91). Logic: lib/archive-check.ts; how a + * Captain runs it: crew/docs/captain.md#no-orphaned-work and #direct-land-merge-direct. + * + * gstack-archive-check thread --thread --reads [--stub-out ] [--json] + * gstack-archive-check landed --pr [--repo ] [--json] + */ +import { spawnSync } from 'node:child_process'; +import * as fs from 'node:fs'; +import { checkThread, landedIssues, MAX_READ_AGE_MS } from '../lib/archive-check'; +import { flagValue } from '../lib/bin-context'; +import { EXIT } from '../lib/headless-artifacts'; +import { loadPlainTitlePolicy } from '../lib/plain-title'; +import { resultLine } from '../lib/result-codes'; +import type { Gh } from '../lib/ship-queue'; + +const HELP = `gstack-archive-check — refuse to archive a thread that still holds open work (crew/docs/captain.md#no-orphaned-work) + + thread --thread --reads [--stub-out ] [--json] + The reads file is what the Captain's execute program wrote: {"read_at": , + "thread": , "issues": }. + Every PR the thread holds is re-read with gh pr view --repo . Clean only when none is open + (R91: no successor makes an open PR OK) and no issue in a working state is still on the thread. + Prints ORPHAN and NOTE lines, a handoff stub, and last ARCHIVE_CHECK: clean|refused|unreadable. + Reads older than ${MAX_READ_AGE_MS / 60_000} minutes, truncated or unparsable are refused. + landed --pr [--repo ] [--json] + After gstack-ship-queue land: one ISSUE_CLOSE line per tracker code in the merged PR's trailing + title group (.gstack/plain-titles.json codes), or ISSUE_CLOSE: none. + +Exit codes: 0 clean (thread) or read (landed) · 1 refused, or landed on a PR that is not merged · 2 usage · 3 a read was stale, unparsable, for another thread, or gh failed`; + +function ghBound(repo?: string): Gh { + return (args: string[]) => { + const r = spawnSync('gh', args, { encoding: 'utf8', timeout: 60_000, maxBuffer: 64 * 1024 * 1024, env: repo ? { ...process.env, GH_REPO: repo } : process.env }); + return { ok: r.status === 0, out: r.stdout ?? '', err: r.stderr || (r.error ? String(r.error.message) : '') }; + }; +} + +function usage(message: string): number { + process.stderr.write('gstack-archive-check: ' + message + '\n' + HELP + '\n'); + return EXIT.usage; +} + +function thread(args: string[]): number { + const code = flagValue(args, '--thread'); + const readsFile = flagValue(args, '--reads'); + const stubOut = flagValue(args, '--stub-out'); + if (!code || !/^[A-Z][A-Z0-9]*-\d+$/.test(code)) return usage('thread needs --thread (a thread code such as GSTA-462)'); + if (!readsFile) return usage('thread needs --reads '); + let raw: unknown; + try { raw = JSON.parse(fs.readFileSync(readsFile, 'utf8')); } catch (e: any) { raw = { parseError: String(e?.message ?? e) }; } + const r = checkThread(raw, { thread: code, now: Date.now(), ghFor: ghBound }); + if (stubOut) { fs.writeFileSync(stubOut, r.stub, { mode: 0o600 }); fs.chmodSync(stubOut, 0o600); } + if (args.includes('--json')) process.stdout.write(JSON.stringify(r.json) + '\n'); + else { + const failure = r.failure ? [resultLine('gstack-archive-check thread', r.failure.code, r.failure.message)] : []; + process.stdout.write([...failure, ...r.lines.slice(0, -1), ...(r.stub ? [r.stub.trimEnd()] : []), r.lines.at(-1)!].join('\n') + '\n'); + } + return r.verdict === 'clean' ? EXIT.ok : r.verdict === 'refused' ? EXIT.fail : EXIT.refused; +} + +function landed(args: string[]): number { + const pr = flagValue(args, '--pr'); + const repo = flagValue(args, '--repo'); + if (!pr || !/^\d+$/.test(pr)) return usage('landed needs --pr '); + if (repo !== undefined && !/^[\w.-]+\/[\w.-]+$/.test(repo)) return usage('--repo takes owner/repo'); + let policy = null; + try { policy = loadPlainTitlePolicy(process.cwd()); } catch {} + const r = landedIssues({ pr: Number(pr), repo, gh: ghBound(repo), policy }); + if (args.includes('--json')) process.stdout.write(JSON.stringify(r.json) + '\n'); + else process.stdout.write([...(r.failure ? [resultLine('gstack-archive-check landed', r.failure.code, r.failure.message)] : []), ...r.lines].join('\n') + '\n'); + return r.exit; +} + +const [cmd, ...rest] = process.argv.slice(2); +if (cmd === '--help' || cmd === '-h') { process.stdout.write(HELP + '\n'); process.exit(EXIT.ok); } +process.exit(cmd === 'thread' ? thread(rest) : cmd === 'landed' ? landed(rest) : usage(cmd ? 'unknown command ' + JSON.stringify(cmd) : 'name a command')); diff --git a/bin/gstack-doctor-components.sh b/bin/gstack-doctor-components.sh index 8cace00ad..b7e347486 100644 --- a/bin/gstack-doctor-components.sh +++ b/bin/gstack-doctor-components.sh @@ -391,6 +391,11 @@ gstack_result_anchor() { PLAN_EXEMPT_UNKNOWN) printf '%s' 'plan-exempt-unknown' ;; PLAN_STORE_UNAVAILABLE) printf '%s' 'plan-store-unavailable' ;; QUEUE_PLAN_MISMATCH) printf '%s' 'queue-plan-mismatch' ;; + ARCHIVE_READS_STALE) printf '%s' 'archive-reads-stale' ;; + ARCHIVE_READS_UNPARSED) printf '%s' 'archive-reads-unparsed' ;; + ARCHIVE_THREAD_MISMATCH) printf '%s' 'archive-thread-mismatch' ;; + ARCHIVE_PR_NOT_MERGED) printf '%s' 'archive-pr-not-merged' ;; + ARCHIVE_GH_READ_FAILED) printf '%s' 'archive-gh-read-failed' ;; BUDGET_CAP_REACHED) printf '%s' 'budget-cap-reached' ;; BUDGET_READING_MISSING) printf '%s' 'budget-reading-missing' ;; METER_SCOPE_PARENT) printf '%s' 'meter-scope-parent' ;; diff --git a/captain/SKILL.md b/captain/SKILL.md index f05b071ac..2d6544b84 100644 --- a/captain/SKILL.md +++ b/captain/SKILL.md @@ -328,7 +328,8 @@ turn, three sections in this order, each row with its thread, one line, and an a received, trains built, incidents), newest first. 3. **In progress**: every working lane, what it owns, its state (building, READY, in train slot i, blocked on …) and how long since it last reported. A thread the Captain - archives leaves this list in the same turn. + archives leaves this list in the same turn. It archives only through `gstack-archive-check thread`, never a + thread that holds an open PR (R91; `crew/docs/captain.md` § No orphaned work). Ages make stalls visible: a lane in progress with no report for hours, or a READY waiting longer than `FILL_HOURS`, is the controller's next action. diff --git a/captain/SKILL.md.tmpl b/captain/SKILL.md.tmpl index 986a67f61..19a10af26 100644 --- a/captain/SKILL.md.tmpl +++ b/captain/SKILL.md.tmpl @@ -256,7 +256,8 @@ turn, three sections in this order, each row with its thread, one line, and an a received, trains built, incidents), newest first. 3. **In progress**: every working lane, what it owns, its state (building, READY, in train slot i, blocked on …) and how long since it last reported. A thread the Captain - archives leaves this list in the same turn. + archives leaves this list in the same turn. It archives only through `gstack-archive-check thread`, never a + thread that holds an open PR (R91; `crew/docs/captain.md` § No orphaned work). Ages make stalls visible: a lane in progress with no report for hours, or a READY waiting longer than `FILL_HOURS`, is the controller's next action. diff --git a/capy/skill/gstack/factory.md b/capy/skill/gstack/factory.md index d4ffb3ba3..fa10da5d1 100644 --- a/capy/skill/gstack/factory.md +++ b/capy/skill/gstack/factory.md @@ -13,6 +13,6 @@ Your crew's CREW.md and recorded approvals decide who may merge, update a base, 9. **Flakes: one rerun, a ledger, a daily fix.** Only the policy's rerun, never for a harness or infra red; a second red is real; never loosen a test. [crew/docs/captain.md: The daily flaky pass] 10. **Quiet.** Messages wake the Captain cold: send READY, a blocker, done or a Captain or owner call, else end_turn; no acks or progress notes. Broadcasts go in its notes. 11. **Ask once, plainly.** One ask per decision, explained like the owner is five: your Captain's name, then a lettered line each: "A: hit problem p, which happens when h. We could do 1) x, 2) y or 3) z. Recommendation x because why. A1 to take recommendation." "do it" takes every recommendation; "A2" takes option 2. Owner tasks: the exact link (in Capy: path and field), numbered steps, the reply word. Never re-ask a standing rule; check what you can yourself. [crew/docs/captain.md: The ask format] -12. **Fresh threads, closed out.** One thread per unit of work. A thread keeps its PR until it merges or closes and hands off only then, never mid-PR on a token count; past 1B own tokens it checks in with its Captain once and carries on. Standing threads that wake on every event (merge lanes, watchers) rotate at 500M tokens or 3 days [/runaway-stopper]. Crew threads never create threads: ask your Captain (Captain to Captain across projects), or report one you made by code for crew.add. Its Captain archives only its crew's threads, never one with an open PR or issue until a live successor holds it; a PR closes only as landed, superseded or dropped. Threads outside every crew are the owner's, even its automation's. Size reviews to the diff. [docs/ship-budget.md; crew/docs/captain.md: New threads join the crew] +12. **Fresh threads, closed out.** One thread per unit of work. A thread keeps its PR until it merges or closes and hands off only then, never mid-PR on a token count; past 1B own tokens it checks in with its Captain once and carries on. Standing threads that wake on every event (merge lanes, watchers) rotate at 500M tokens or 3 days [/runaway-stopper]. Crew threads never create threads: ask your Captain (Captain to Captain across projects), or report one you made by code for crew.add. Its Captain archives only its crew's threads, never one holding an open PR, successor or not (R91), or an open issue; a PR closes only as landed, superseded or dropped. Threads outside every crew are the owner's, even its automation's. Size reviews to the diff. [docs/ship-budget.md; crew/docs/captain.md: New threads join the crew] 13. **The owner's model preference.** `gstack-models resolve --role agent` picks agent models, except cross-family voices and eval subjects. [docs/model-policy.md: Your model preference] 14. **Finish before starting.** No new fix thread in a repo while more than 10 finished PRs (green, reviewed, waiting only to merge) sit in its queue: merge or close them first. Severe hotfixes are exempt: security, data loss, red main, a broken install. [crew/docs/captain.md: New threads join the crew] diff --git a/changelog.d/catch-orphaned-work-at-archive-and-land.md b/changelog.d/catch-orphaned-work-at-archive-and-land.md new file mode 100644 index 000000000..4893e279d --- /dev/null +++ b/changelog.d/catch-orphaned-work-at-archive-and-land.md @@ -0,0 +1 @@ +- **A Captain can no longer archive a thread that still holds an open PR, and a landed PR's issue closes in the same step.** The new `gstack-archive-check thread` runs right before a Captain archives a crew thread. The Captain hands it the thread and issue lists it read from Capy, and it re-reads every PR the thread holds from GitHub. It refuses while any of those PRs is open (a successor does not change that) or while an issue in a working state is still assigned to the thread. It also refuses when a read is stale, truncated, unparsable or failed. Every run prints a handoff stub (each open PR's branch, head commit and CI state at that head, plus the open issues) for the next thread's brief. `gstack-archive-check landed --pr ` runs after `gstack-ship-queue land` and names the tracker code in the merged PR's title, so the Captain closes that issue in the same program, but only when the issue links this PR, no other open PR and no open sub-issues. crew/docs/captain.md (No orphaned work, Direct land), the gstack factory page, /captain and the /runaway-stopper rotation steps now say a thread with an open PR stays until that PR merges or closes. diff --git a/crew/docs/captain.md b/crew/docs/captain.md index 995c12d98..2115c28d6 100644 --- a/crew/docs/captain.md +++ b/crew/docs/captain.md @@ -238,12 +238,37 @@ successor when X happens" comes true. In November 2026 gbrain's queue reached 22 partly this way: eight trains and a finished feature sat for 1 to 8 days with no owner, went stale and conflicting, and started to look like junk. -- **Archive only with a live owner.** Before the Captain archives or rotates a thread that - still has an open PR, a handoff or an In progress issue, it starts the successor in the - same turn, and the successor subscribes to every open PR first (the +- **Never archive a thread that holds an open PR (R91).** A thread stays, idle if need be, + until every PR it opened or carries is merged or closed. Work that has to move gets its + successor started first, and the successor subscribes to every open PR (the [/runaway-stopper successor brief](../../runaway-stopper/templates/successor-brief.md) - does this for rotations). When no successor is needed yet, the Captain takes the work + does this for rotations), while the old thread stays: a successor never makes archiving a + thread with an open PR OK. When no successor is needed yet, the Captain takes the work itself, on an issue assigned to a live thread with the trigger written in it. +- **Archive through the check.** Before archiving any crew thread, re-assign every open + issue it holds to a live thread or close it, then archive with the one program below. It + reads the thread and its open issues, runs `gstack-archive-check thread`, and archives + only on exit 0 (`ARCHIVE_CHECK: clean`). The check re-reads every PR the thread holds + from GitHub and refuses while any is open, or while an issue in a working state (anything + open but Backlog or Needs you) is still on the thread. It fails closed: stale, truncated or + unreadable reads and failed gh reads refuse too ([troubleshooting](../../docs/troubleshooting.md#archive-reads-stale)). + Every run prints a handoff stub (each open PR's branch, head and CI at that head, and the + open issues) to paste into a successor brief. When an issue list has more than one page, + read every page into `issues`. + + ```js + const code = "PROJ-123"; + const [thread, issues] = await Promise.all([ + tools.thread.get({ thread_id: code }), + tools.issue.read({ assignee: code, status: "open" }), + ]); + const file = `.gstack/tmp/archive-check-${code}.json`; + await tools.capy.write({ path: file, content: JSON.stringify({ read_at: new Date(await now()).toISOString(), thread, issues: issues.text }) }); + const check = await tools.capy.bash({ command: `. && gstack-archive-check thread --thread ${code} --reads ${file}`, description: "Checking the thread before archiving" }); + if (check.exit_code !== 0) return check.output; + await tools.thread.archive({ threads: [code] }); + return check.output; + ``` - **Triggers never live only in notes.** A "successor takes the slot when X" line goes on an issue owned by a live thread, never only in NOTES.md. - **Sweep for orphans.** Every Captain wake that reads its crew, and the daily issue sweep @@ -262,7 +287,9 @@ went stale and conflicting, and started to look like junk. Issues show what the crew is really doing, so keep them true: - Before archiving a thread, re-assign every open issue it holds to its successor or the - Captain, and close only what is done ([No orphaned work](#no-orphaned-work)). + Captain, and close only what is done; `gstack-archive-check thread` refuses the archive + while an issue in a working state is still on the thread, and lists Backlog and Needs you + issues as notes for you to re-assign ([No orphaned work](#no-orphaned-work)). - On each wake, leave every issue you touched accurate: its status, assignee and links. - An unassigned Todo that nobody will start goes to Backlog. - Deferred findings from review or /ship never become issues: they stay on the PR's @@ -475,7 +502,15 @@ commits. Each PR merges at its own green head, and versions are set at release leaves VERSION, the `package.json` version and CHANGELOG.md alone. 2. Captain: `gstack-ship-queue land --pr --pin ` (add `--allow-repo-commands` when the base policy lists `land.merged_checks`, so a stale base can run them). It merges with - `--match-head-commit`, checks main's tree and writes a land note. + `--match-head-commit`, checks main's tree and writes a land note. In the same program, + after `MERGED`, run `gstack-archive-check landed --pr `. For each + `ISSUE_CLOSE: ` line, read the issue with `tools.issue.read`. Move it to Done with a + comment naming the merge commit only when it links this PR, links no other open PR and has + no open sub-issues; skip one already Done; otherwise leave it open with a comment saying + why. Report the closed, skipped and failed codes. After a partial run, reconcile with + `tools.issue.read` and rerun: codes already Done are skipped. The landed PR's thread + stays unarchived until none of its PRs is open (R91); archive it then through + [the check](#no-orphaned-work). 3. Release: before each regular stable cut (Monday and Thursday, 14:00 UTC), and in the same turn it lands a PR whose fragment carries `important:` (the READY says `rides the daily cut`; [releases.md](../../docs/releases.md#the-daily-check)), the Captain runs diff --git a/docs/skills.md b/docs/skills.md index 05be118cc..c5b46157e 100644 --- a/docs/skills.md +++ b/docs/skills.md @@ -972,7 +972,8 @@ sets the defaults that keep it from coming back. thread keeps its PR until it merges or closes and checks in with its Captain once past 1B), a subagent past 100M (stop), empty wakes past 25% of the last 40 turns (warm-wait check-ins are left out), and review rounds past 2 per PR. Captains are reported, never rotated. - **Stop.** A live subagent past 100M is stopped with its reason. A standing thread is rotated at a clean point through - a handoff file and a successor brief (`runaway-stopper/templates/`), then archived by its own Captain. A thread + a handoff file and a successor brief (`runaway-stopper/templates/`), then archived by its own Captain once none of + its PRs is open (R91), through `gstack-archive-check thread`. A thread mid-/ship or mid-merge gets `DEFER:` and nothing happens until that work finishes. - **Prevent.** One wait on all pending operations, quiet wakes, compact program returns, one /ship subagent per PR head (metering its own row with a 100M default cap, `BUDGET_DEFAULT_CAP:`), two review rounds, and a fresh diff --git a/docs/troubleshooting.md b/docs/troubleshooting.md index f466da9b8..68e590c65 100644 --- a/docs/troubleshooting.md +++ b/docs/troubleshooting.md @@ -4623,6 +4623,58 @@ body and its receipt would disagree. Nothing was written. body (or rerun `gstack-issue-links --out` if the issue set changed), then rerun the receipt write. +## The pre-archive check and issue close on land + +Codes printed by `bin/gstack-archive-check` (crew/docs/captain.md#no-orphaned-work). Each +ends an error line in parentheses. In every case nothing was archived and no issue was closed. + + +### `ARCHIVE_READS_STALE` + +**Meaning.** The `read_at` in the reads file is more than 15 minutes old, or more than 2 +minutes in the future. Old reads can miss a PR or issue the thread picked up since. + +**Fix.** Rerun the whole archive program: it reads `tools.thread.get` and +`tools.issue.read` again and stamps `read_at` with the program's `now()`. + + +### `ARCHIVE_READS_UNPARSED` + +**Meaning.** A part of the reads (named in the line: `reads`, `read_at`, `thread` or +`issues`) is missing, empty, truncated or holds something the check does not know: no +`States:` header in the issue text, no `pull_requests` array, `pull_requests_total` +larger than the list, an issue list with neither a row nor `No issues match.`, an +unrecognized line or a state the header does not name. An empty +read looks like "no work", so the check refuses instead of guessing. + +**Fix.** Write the tool results raw, unedited. When the issue list has more pages, read +them all first, then rerun. A truncated PR list has no fix: `tools.thread.get` lists at +most 20 PRs and cannot page through the rest, so a thread that has held more than 20 PRs +cannot be proven clean and stays unarchived. + + +### `ARCHIVE_THREAD_MISMATCH` + +**Meaning.** The reads are for another thread than `--thread` names. + +**Fix.** Read the thread you mean to archive and pass the same code. + + +### `ARCHIVE_PR_NOT_MERGED` + +**Meaning.** `gstack-archive-check landed` ran on a PR GitHub does not report as merged. + +**Fix.** Run it after `gstack-ship-queue land` prints `MERGED`. + + +### `ARCHIVE_GH_READ_FAILED` + +**Meaning.** `gh pr view --repo ` failed, printed no JSON, or answered for a +different PR than the one asked for. A failed read is never clean. + +**Fix.** Check `gh auth status` and the repository, then rerun, at most twice. If it +still fails, leave the thread unarchived (or the issue open) and tell the Captain. + ## Eval plans, lane ownership, evidence verification, the owner brief Codes printed by `bin/gstack-eval-plan`, `bin/gstack-lane-check`, diff --git a/lib/archive-check.ts b/lib/archive-check.ts new file mode 100644 index 000000000..8e012d922 --- /dev/null +++ b/lib/archive-check.ts @@ -0,0 +1,177 @@ +/** + * gstack-archive-check logic (GST-2188; R86, R91). A Captain runs it right + * before archiving a crew thread. Capy's thread and issue state lives behind + * Capy tools a bin cannot call, so the Captain does those reads + * (tools.thread.get, tools.issue.read) in one execute program and hands them + * over raw; this module parses them, failing closed on anything it cannot + * fully read, and re-reads every PR the thread holds from GitHub. + * + * R91: a thread that holds an open PR is never archived, successor or not. + * An open issue still assigned to the thread refuses too until it is + * re-assigned to a live thread or closed. Every run prints a handoff stub + * (PR, branch, head, CI at that head, open issues) for a successor brief. + * + * landedIssues serves the land step: the tracker codes in a merged PR's + * trailing title group (R76), which the Captain then closes with + * tools.issue.update in the same program (crew/docs/captain.md). + */ +import { splitTitle, type PlainTitlePolicy } from './plain-title'; +import { ciAtHead, type Gh } from './ship-queue'; + +export type ArchiveCode = 'ARCHIVE_READS_STALE' | 'ARCHIVE_READS_UNPARSED' | 'ARCHIVE_THREAD_MISMATCH' | 'ARCHIVE_PR_NOT_MERGED' | 'ARCHIVE_GH_READ_FAILED'; +export interface ArchiveFailure { code: ArchiveCode; message: string } +export interface IssueRow { code: string; state: string; title: string } +export interface IssueList { open: string[]; closed: string[]; issues: IssueRow[] } +interface ThreadPr { repo: string; number: number; state: string; relation: string } +interface ThreadRead { code: string; status: string; prs: ThreadPr[] } +interface LivePr { repo: string; number: number; state: string; head: string; branch: string; title: string; ci: string } + +export const MAX_READ_AGE_MS = 15 * 60_000; +const MAX_CLOCK_SKEW_MS = 2 * 60_000; +const NOT_WORK = new Set(['Backlog', 'Needs you']); +const ISSUE_CODE = /^[A-Z][A-Z0-9]{1,9}-\d+$/; +const REPO = /^[\w.-]+\/[\w.-]+$/; + +export interface CheckOptions { + thread: string; + now: number; + ghFor: (repo: string) => Gh; + ci?: (repo: string, sha: string) => string; +} +export interface CheckResult { + verdict: 'clean' | 'refused' | 'unreadable'; + lines: string[]; + stub: string; + failure?: ArchiveFailure; + json: Record; +} + +const unparsed = (part: string, why: string): ArchiveFailure => ({ code: 'ARCHIVE_READS_UNPARSED', message: part + ': ' + why }); +const isFailure = (v: unknown): v is ArchiveFailure => typeof v === 'object' && v !== null && 'code' in v && 'message' in v && !('issues' in v); + +/** tools.issue.read list text: the States header is required; items are read by code and state only. */ +export function parseIssueList(text: unknown): IssueList | ArchiveFailure { + if (typeof text !== 'string' || !text.trim()) return unparsed('issues', 'empty or not the text of tools.issue.read'); + const lines = text.split('\n'); + const header = lines.find(l => l.startsWith('States:')); + if (!header) return unparsed('issues', 'no "States:" header line, so this is not a whole tools.issue.read list'); + const [openPart, closedPart = ''] = header.slice('States:'.length).split(/\s+·\s+closed:/); + const names = (s: string) => s.split(',').map(x => x.replace(/\(default\)/, '').trim()).filter(Boolean); + const open = names(openPart!); + const closed = names(closedPart); + const issues: IssueRow[] = []; + let none = false; + for (const raw of lines) { + const line = raw.trim(); + if (line === 'No issues match.') { none = true; continue; } + if (!line || line === header.trim() || line.startsWith('Labels:')) continue; + const parts = line.startsWith('- ') ? line.slice(2).split(/\s+·\s+/) : []; + const [code, state] = parts; + if (!code || !ISSUE_CODE.test(code) || !state) return unparsed('issues', 'unrecognized line ' + JSON.stringify(line.slice(0, 80))); + if (!open.includes(state) && !closed.includes(state)) return unparsed('issues', code + ' has state ' + JSON.stringify(state) + ', which the States header does not name'); + const at = parts.map(p => p.startsWith('@')).lastIndexOf(true); + issues.push({ code, state, title: at >= 0 && at + 1 < parts.length - 1 ? parts[at + 1]! : '' }); + } + if (!issues.length && !none) return unparsed('issues', 'no issue rows and no "No issues match." line, so the list may be cut off'); + return { open, closed, issues }; +} + +function parseThread(v: unknown): ThreadRead | ArchiveFailure { + const t = v as Record | null; + if (!t || typeof t !== 'object' || typeof t.code !== 'string' || typeof t.status !== 'string') return unparsed('thread', 'not a tools.thread.get result (code and status missing)'); + if (!Array.isArray(t.pull_requests)) return unparsed('thread', 'no pull_requests array'); + if (typeof t.pull_requests_total !== 'number' || t.pull_requests_total !== t.pull_requests.length) return unparsed('thread', 'pull_requests_total ' + String(t.pull_requests_total) + ' differs from the ' + t.pull_requests.length + ' listed, so the list is truncated'); + const prs: ThreadPr[] = []; + for (const p of t.pull_requests as Array>) { + if (typeof p?.repo !== 'string' || !REPO.test(p.repo) || !Number.isInteger(p.number) || typeof p.state !== 'string') return unparsed('thread', 'a pull_requests row lacks repo, number or state'); + prs.push({ repo: p.repo, number: p.number as number, state: p.state, relation: typeof p.relation === 'string' ? p.relation : '' }); + } + return { code: t.code, status: t.status, prs }; +} + +function readPr(gh: Gh, repo: string, n: number): Omit | ArchiveFailure { + const ghFail = (why: string): ArchiveFailure => ({ code: 'ARCHIVE_GH_READ_FAILED', message: repo + '#' + n + ': ' + why }); + const r = gh(['pr', 'view', String(n), '--repo', repo, '--json', 'number,url,state,headRefOid,headRefName,title']); + if (!r.ok) return ghFail((r.err ?? r.out).trim().slice(0, 160) || 'gh pr view failed'); + let j: Record; + try { j = JSON.parse(r.out); } catch { return ghFail('gh pr view printed no JSON'); } + const url = typeof j.url === 'string' ? j.url.toLowerCase() : ''; + if (j.number !== n || !url.endsWith(('github.com/' + repo + '/pull/' + n).toLowerCase())) return ghFail('gh answered for ' + (url || 'another PR') + ', not this PR'); + if (typeof j.state !== 'string') return ghFail('gh pr view returned no state'); + return { repo, number: n, state: j.state, head: typeof j.headRefOid === 'string' ? j.headRefOid : '', branch: typeof j.headRefName === 'string' ? j.headRefName : '', title: typeof j.title === 'string' ? j.title : '' }; +} + +function renderStub(thread: string, readAt: string, prs: LivePr[], issues: IssueRow[]): string { + const cell = (s: string) => s.replace(/\|/g, '\\|').replace(/\s+/g, ' '); + return [ + '', + '### Handoff from ' + thread + ' (Capy read ' + readAt + ')', + '', + prs.length ? '| PR | Title | Branch | Head | CI at head |\n|---|---|---|---|---|\n' + prs.map(p => '| ' + p.repo + '#' + p.number + ' | ' + cell(p.title) + ' | ' + cell(p.branch) + ' | ' + p.head + ' | ' + cell(p.ci) + ' |').join('\n') : 'Open PRs: none', + '', + issues.length ? 'Open issues:\n' + issues.map(i => '- ' + i.code + ' · ' + i.state + (i.title ? ' · ' + i.title : '')).join('\n') : 'Open issues: none', + '', + '', + ].join('\n'); +} + +/** The pre-archive check: clean only when the thread holds no open PR (read live) and no open working issue. */ +export function checkThread(raw: unknown, o: CheckOptions): CheckResult { + const r = raw as Record | null; + const fail = (failure: ArchiveFailure): CheckResult => ({ verdict: 'unreadable', lines: ['ARCHIVE_CHECK: unreadable thread=' + o.thread + ' (' + failure.code + ')'], stub: '', failure, json: { verdict: 'unreadable', thread: o.thread, prs: [], issues: [], notes: [], failure } }); + if (!r || typeof r !== 'object') return fail(unparsed('reads', 'not a JSON object')); + const readAt = typeof r.read_at === 'string' ? Date.parse(r.read_at) : NaN; + if (Number.isNaN(readAt)) return fail(unparsed('read_at', 'missing or not an ISO time')); + const age = o.now - readAt; + if (age > MAX_READ_AGE_MS || age < -MAX_CLOCK_SKEW_MS) return fail({ code: 'ARCHIVE_READS_STALE', message: 'read_at ' + String(r.read_at) + ' is ' + Math.round(age / 60_000) + ' min from now; reads must be under ' + MAX_READ_AGE_MS / 60_000 + ' min old' }); + const thread = parseThread(r.thread); + if (isFailure(thread)) return fail(thread); + if (thread.code !== o.thread) return fail({ code: 'ARCHIVE_THREAD_MISMATCH', message: 'the reads are for ' + thread.code + ', not ' + o.thread }); + const list = parseIssueList(r.issues); + if (isFailure(list)) return fail(list); + + const live: LivePr[] = []; + for (const p of thread.prs) { + const pr = readPr(o.ghFor(p.repo), p.repo, p.number); + if (isFailure(pr)) return fail(pr); + if (pr.state !== 'OPEN') continue; + let ci = 'unknown'; + try { ci = (o.ci ?? ((repo, sha) => ciAtHead({ repoRoot: process.cwd(), base: 'main', gh: o.ghFor(repo) }, sha).line))(p.repo, pr.head); } catch {} + live.push({ ...pr, ci }); + } + const openIssues = list.issues.filter(i => list.open.includes(i.state)); + const work = openIssues.filter(i => !NOT_WORK.has(i.state)); + const lines = [ + ...live.map(p => 'ORPHAN: pr ' + p.repo + '#' + p.number + ' open head=' + p.head.slice(0, 12) + ' ci=' + p.ci + ' (R91: never archive a thread that holds an open PR; it stays until the PR merges or closes)'), + ...work.map(i => 'ORPHAN: issue ' + i.code + ' ' + i.state + ' (re-assign it to a live thread or close it)'), + ...openIssues.filter(i => NOT_WORK.has(i.state)).map(i => 'NOTE: issue ' + i.code + ' ' + i.state + ' (not live work; re-assign or close it when the work is dropped)'), + ]; + const verdict = live.length || work.length ? 'refused' : 'clean'; + lines.push('ARCHIVE_CHECK: ' + verdict + ' thread=' + o.thread + ' prs=' + live.length + ' issues=' + work.length); + return { + verdict, lines, + stub: renderStub(o.thread, String(r.read_at), live, openIssues), + json: { verdict, thread: o.thread, prs: live, issues: openIssues.map(i => ({ code: i.code, state: i.state, orphan: !NOT_WORK.has(i.state) })), notes: lines.filter(l => l.startsWith('NOTE: ')) }, + }; +} + +export interface LandedResult { exit: 0 | 1 | 3; lines: string[]; codes: string[]; failure?: ArchiveFailure; json: Record } + +/** The tracker codes a merged PR names in its title's trailing group: what the land step closes. */ +export function landedIssues(o: { pr: number; repo?: string; gh: Gh; policy: PlainTitlePolicy | null }): LandedResult { + const ref = (o.repo ?? '') + '#' + o.pr; + const fail = (exit: 1 | 3, failure: ArchiveFailure): LandedResult => ({ exit, lines: ['ISSUE_CLOSE: refused pr=' + ref + ' (' + failure.code + ')'], codes: [], failure, json: { pr: o.pr, repo: o.repo ?? null, codes: [], failure } }); + const r = o.gh(['pr', 'view', String(o.pr), ...(o.repo ? ['--repo', o.repo] : []), '--json', 'number,url,state,title,mergeCommit']); + if (!r.ok) return fail(3, { code: 'ARCHIVE_GH_READ_FAILED', message: ref + ': ' + ((r.err ?? r.out).trim().slice(0, 160) || 'gh pr view failed') }); + let j: Record; + try { j = JSON.parse(r.out); } catch { return fail(3, { code: 'ARCHIVE_GH_READ_FAILED', message: ref + ': gh pr view printed no JSON' }); } + const repo = /github\.com\/([\w.-]+\/[\w.-]+)\/pull\//.exec(typeof j.url === 'string' ? j.url : '')?.[1] ?? o.repo ?? ''; + if (j.number !== o.pr || (o.repo && repo.toLowerCase() !== o.repo.toLowerCase())) return fail(3, { code: 'ARCHIVE_GH_READ_FAILED', message: ref + ': gh answered for another PR' }); + if (j.state !== 'MERGED') return fail(1, { code: 'ARCHIVE_PR_NOT_MERGED', message: repo + '#' + o.pr + ' is ' + String(j.state) }); + const merge = String((j.mergeCommit as { oid?: unknown } | null)?.oid ?? '').slice(0, 12); + const refs = splitTitle(typeof j.title === 'string' ? j.title : '').refs?.split(/[\s,]+/) ?? []; + const patterns = (o.policy?.codes ?? []).map(c => new RegExp(c, 'i')); + const codes = [...new Set(refs.map(x => x.toUpperCase()).filter(x => ISSUE_CODE.test(x) && (!patterns.length || patterns.some(p => p.test(x)))))]; + const lines = codes.length ? codes.map(c => 'ISSUE_CLOSE: ' + c + ' pr=' + repo + '#' + o.pr + ' merge=' + merge) : ['ISSUE_CLOSE: none (the title names no tracker code)']; + return { exit: 0, lines, codes, json: { pr: o.pr, repo, merge, codes } }; +} diff --git a/lib/result-codes-capy.ts b/lib/result-codes-capy.ts index 2f16da8d8..76e2365dd 100644 --- a/lib/result-codes-capy.ts +++ b/lib/result-codes-capy.ts @@ -1,7 +1,8 @@ /** * Result codes for gstack's Capy adaptations: the per-thread approval record * (gstack-approvals), the Captain brief (gstack-owner-brief render --captain), - * PR screenshots (gstack-pr-shots) and the installer's launch file. Kept beside lib/result-codes.ts (which + * PR screenshots (gstack-pr-shots), the installer's launch file and the Captain's pre-archive check and + * issue close on land (gstack-archive-check; R86, R91). Kept beside lib/result-codes.ts (which * spreads this table into RESULT_CODES) so the owner module stays within the * module-size ratchet; the same rules apply: one stable docs/troubleshooting.md * anchor, a summary and a fix clause each. @@ -119,4 +120,29 @@ export const CAPY_RESULT_CODES = { summary: 'land found a READY note with no plan record, or a note whose Plan line differs from the PR body\'s, under plan_record: require', fix: 'the lane fixes the PR body\'s Plan line if needed and reruns gstack-ship-queue ready --pr at the pin, which resolves the line and writes the record into the note', }, + ARCHIVE_READS_STALE: { + anchor: 'archive-reads-stale', + summary: 'the Capy reads handed to gstack-archive-check are older than 15 minutes (or dated in the future), so they may not show what the thread holds now', + fix: 'rerun the archive program from crew/docs/captain.md#no-orphaned-work, which reads tools.thread.get and tools.issue.read again and stamps read_at with now(); nothing was archived', + }, + ARCHIVE_READS_UNPARSED: { + anchor: 'archive-reads-unparsed', + summary: 'gstack-archive-check could not fully read the Capy reads (missing, empty or truncated output, or a line or state it does not know), so it cannot say the thread is clean', + fix: 'pass the raw tools.thread.get result and the raw tools.issue.read text unedited and read every page of a multi-page issue list; tools.thread.get lists at most 20 PRs and cannot page, so a thread that has held more cannot be proven clean and stays unarchived; nothing was archived', + }, + ARCHIVE_THREAD_MISMATCH: { + anchor: 'archive-thread-mismatch', + summary: 'the reads file holds another thread than --thread names', + fix: 'read the thread you are archiving and pass its code to --thread; nothing was archived', + }, + ARCHIVE_PR_NOT_MERGED: { + anchor: 'archive-pr-not-merged', + summary: 'gstack-archive-check landed was run on a PR GitHub does not report as merged, so no issue closes', + fix: 'run it after gstack-ship-queue land prints MERGED for this PR; close nothing until then', + }, + ARCHIVE_GH_READ_FAILED: { + anchor: 'archive-gh-read-failed', + summary: 'gh could not read a PR gstack-archive-check needs, or answered for another PR, so the check fails closed', + fix: 'check `gh auth status` and the repository, then rerun (at most 2 times); if it still fails, leave the thread unarchived (or the issue open) and tell the Captain', + }, } satisfies Record; diff --git a/runaway-stopper/SKILL.md b/runaway-stopper/SKILL.md index 68dffcfd8..a06b76ed0 100644 --- a/runaway-stopper/SKILL.md +++ b/runaway-stopper/SKILL.md @@ -223,7 +223,8 @@ Rotate only a standing thread, and only at a clean point: no `DEFER:`, no READY 2. Start the successor in your crew from `runaway-stopper/templates/successor-brief.md` (a lane asks its Captain, R68). Its first act subscribes to each open PR in the handoff (`tools.capy.subscribe`, a pull source), so CI, reviews and merges wake it instead of the old thread. -3. Archive the old thread once the successor reports its subscriptions: `tools.thread.archive({ threads: [""] })`. +3. Leave the old thread unarchived while it holds an open PR (R91); archive it once every PR merges or closes, + through `gstack-archive-check thread`. ## 4. Prevent diff --git a/runaway-stopper/SKILL.md.tmpl b/runaway-stopper/SKILL.md.tmpl index 3f183faad..efe81cbb5 100644 --- a/runaway-stopper/SKILL.md.tmpl +++ b/runaway-stopper/SKILL.md.tmpl @@ -99,7 +99,8 @@ Rotate only a standing thread, and only at a clean point: no `DEFER:`, no READY 2. Start the successor in your crew from `runaway-stopper/templates/successor-brief.md` (a lane asks its Captain, R68). Its first act subscribes to each open PR in the handoff (`tools.capy.subscribe`, a pull source), so CI, reviews and merges wake it instead of the old thread. -3. Archive the old thread once the successor reports its subscriptions: `tools.thread.archive({ threads: [""] })`. +3. Leave the old thread unarchived while it holds an open PR (R91); archive it once every PR merges or closes, + through `gstack-archive-check thread`. ## 4. Prevent diff --git a/runaway-stopper/templates/successor-brief.md b/runaway-stopper/templates/successor-brief.md index 821da75da..412ae2f8d 100644 --- a/runaway-stopper/templates/successor-brief.md +++ b/runaway-stopper/templates/successor-brief.md @@ -3,8 +3,8 @@ so this role starts from a fresh context. Read /handoffs/- whole handoff. Do not read the old thread's transcript. First acts: the gstack install check for your workflow; subscribe to every open PR in the handoff's "Open work" -table (tools.capy.subscribe with a pull source) and tell your Captain the subscriptions are in place, so it can -archive ; then pick up the table in order. +table (tools.capy.subscribe with a pull source) and tell your Captain the subscriptions are in place ( +stays unarchived until every PR it opened or carries is merged or closed, R91); then pick up the table in order. Work rules for this role (from /runaway-stopper, Step 4): one wait on all pending operations; a wake with nothing to decide ends without a message; no acknowledgment or progress messages to your Captain; compact program returns; one /ship subagent per PR head; at most 2 review rounds per PR. When you pass 500M own tokens or 3 days active, diff --git a/test/archive-check.test.ts b/test/archive-check.test.ts new file mode 100644 index 000000000..28c59ef4d --- /dev/null +++ b/test/archive-check.test.ts @@ -0,0 +1,260 @@ +/** + * gstack-archive-check: a Captain's pre-archive orphan check and the land step's + * issue close (GST-2188, R86). The Captain does the Capy reads (tools.thread.get, + * tools.issue.read) and hands them over raw; the bin re-reads every PR from GitHub + * and fails closed on anything it cannot fully read. R91: a thread that holds an + * open PR is never archived, successor or not. + */ +import { afterEach, describe, expect, test } from 'bun:test'; +import * as fs from 'node:fs'; +import * as path from 'node:path'; +import { checkThread, landedIssues, parseIssueList, type CheckOptions } from '../lib/archive-check'; +import type { Gh } from '../lib/ship-queue'; +import { RESULT_CODES } from '../lib/result-codes'; +import { cleanup, makeFixtureRepo, runBin, type FixtureRepo } from './helpers/pregate-fixture'; + +const STATES = 'States: Needs you, Backlog, Todo (default), In progress, Waiting, In review · closed: Done (default), Canceled'; +const issues = (...lines: string[]) => [STATES, 'Labels: none', ...(lines.length ? lines : ['No issues match.'])].join('\n'); +const pr = (number: number, repo = 'garrytan/gstack', state = 'open') => ({ repo, number, title: 'A PR', state, relation: 'owner' }); +const thread = (code: string, status: string, prs: ReturnType[] = []) => ({ id: 'jam_' + code, code, status, archived: status === 'archived', pull_requests: prs, pull_requests_total: prs.length }); +const NOW = Date.parse('2026-11-09T20:00:00Z'); +const reads = (o: Record = {}) => ({ read_at: '2026-11-09T19:55:00Z', thread: thread('GSTA-1', 'idle'), issues: issues(), ...o }); +type Live = { state: string; headRefOid?: string; headRefName?: string; title?: string; number?: number; url?: string } | 'fail'; +function ghFor(live: Record, calls: string[] = []): (repo: string) => Gh { + return (repo: string) => (args: string[]) => { + calls.push(repo + ' ' + args.join(' ')); + const n = args[2]!; + const v = live[repo + '#' + n]; + if (!v || v === 'fail') return { ok: false, out: '', err: 'HTTP 502: Bad Gateway' }; + return { ok: true, out: JSON.stringify({ number: Number(n), url: 'https://github.com/' + repo + '/pull/' + n, headRefOid: 'a'.repeat(40), headRefName: 'capy/x', title: 'A PR (GST-9)', ...v }) }; + }; +} +const opts = (live: Record = {}, o: Partial = {}): CheckOptions => ({ thread: 'GSTA-1', now: NOW, ghFor: ghFor(live), ci: () => 'green at aaaaaaaaaaaa (passed=3 failed=0 pending=0 skipped=0)', ...o }); + +describe('archive check (reads and verdict)', () => { + // Value: protects=a thread with nothing open archives cleanly; fails_when=the empty-list text or a Labels line is read as unparsed work + test('a thread with no open PRs and no open issues is clean', () => { + const r = checkThread(reads(), opts()); + expect(r.verdict).toBe('clean'); + expect(r.lines.at(-1)).toBe('ARCHIVE_CHECK: clean thread=GSTA-1 prs=0 issues=0'); + }); + + // Value: protects=an open PR with nobody named to own it refuses the archive; fails_when=Capy's open state is trusted without a successor + test('an open PR with no successor is an orphan', () => { + const r = checkThread(reads({ thread: thread('GSTA-1', 'idle', [pr(4237)]) }), opts({ 'garrytan/gstack#4237': { state: 'OPEN' } })); + expect(r.verdict).toBe('refused'); + expect(r.lines.some(l => l.startsWith('ORPHAN: pr garrytan/gstack#4237 open head=aaaaaaaaaaaa ci=green'))).toBe(true); + expect(r.lines.at(-1)).toBe('ARCHIVE_CHECK: refused thread=GSTA-1 prs=1 issues=0'); + }); + + // Value: protects=R91, a live successor never makes archiving a thread with an open PR OK; fails_when=a successor read or a successor field clears the PR + test('R91: an open PR refuses even when a live successor exists', () => { + const r = checkThread(reads({ thread: thread('GSTA-1', 'idle', [pr(4237)]), successors: [thread('GSTA-9', 'working', [pr(4237)])], successor: 'GSTA-9' }), opts({ 'garrytan/gstack#4237': { state: 'OPEN' } })); + expect(r.verdict).toBe('refused'); + expect(r.lines[0]).toContain('R91: never archive a thread that holds an open PR'); + }); + + // Value: protects=GitHub's live state decides, so a PR Capy still calls open but GitHub merged needs nothing, and a reopened one counts; fails_when=the Capy state is used + test('the live GitHub state decides, not the Capy row', () => { + expect(checkThread(reads({ thread: thread('GSTA-1', 'idle', [pr(10)]) }), opts({ 'garrytan/gstack#10': { state: 'MERGED' } })).verdict).toBe('clean'); + expect(checkThread(reads({ thread: thread('GSTA-1', 'idle', [pr(11, 'garrytan/gstack', 'closed')]) }), opts({ 'garrytan/gstack#11': { state: 'OPEN' } })).verdict).toBe('refused'); + }); + + // Value: protects=each PR is read in its own repository and a mismatched answer refuses (gate A); fails_when=gh reads the cwd repo and a same-numbered merged PR there passes + test('cross-repo PRs are read with --repo and a mismatched answer is unreadable', () => { + const calls: string[] = []; + const t = thread('GSTA-1', 'idle', [pr(5, 'garrytan/gbrain')]); + const r = checkThread(reads({ thread: t }), opts({}, { ghFor: ghFor({ 'garrytan/gbrain#5': { state: 'OPEN' } }, calls) })); + expect(r.verdict).toBe('refused'); + expect(calls[0]).toContain('pr view 5 --repo garrytan/gbrain'); + const wrong = checkThread(reads({ thread: t }), opts({}, { ghFor: ghFor({ 'garrytan/gbrain#5': { state: 'MERGED', url: 'https://github.com/garrytan/gstack/pull/5' } }) })); + expect(wrong.verdict).toBe('unreadable'); + expect(wrong.failure?.code).toBe('ARCHIVE_GH_READ_FAILED'); + }); + + // Value: protects=a failed PR read is never clean and names its fix (gate E); a failed CI read never blocks; fails_when=a gh error reads as merged or as zero work + test('a gh read failure is unreadable; a CI read failure is ci=unknown', () => { + const t = thread('GSTA-1', 'idle', [pr(7)]); + const r = checkThread(reads({ thread: t }), opts({ 'garrytan/gstack#7': 'fail' })); + expect(r.verdict).toBe('unreadable'); + expect(r.failure?.code).toBe('ARCHIVE_GH_READ_FAILED'); + expect(r.failure?.message).toContain('garrytan/gstack#7'); + expect(r.failure?.message).toContain('HTTP 502'); + const ci = checkThread(reads({ thread: t }), opts({ 'garrytan/gstack#7': { state: 'OPEN' } }, { ci: () => { throw new Error('boom'); } })); + expect(ci.verdict).toBe('refused'); + expect(ci.lines.some(l => l.includes('ci=unknown'))).toBe(true); + }); + + // Value: protects=empty, missing or truncated reads never parse as zero work (gate B); fails_when=any of them prints clean + test('empty, missing or truncated reads are unparsed', () => { + const bad: Array<[string, Record]> = [ + ['issues', { issues: '' }], + ['issues', { issues: 'No issues match.' }], + ['thread', { thread: { code: 'GSTA-1', status: 'idle' } }], + ['thread', { thread: { ...thread('GSTA-1', 'idle', [pr(1)]), pull_requests_total: 3 } }], + ['read_at', { read_at: undefined }], + ]; + for (const [part, o] of bad) { + const r = checkThread(reads(o), opts({ 'garrytan/gstack#1': { state: 'MERGED' } })); + expect([part, r.verdict, r.failure?.code]).toEqual([part, 'unreadable', 'ARCHIVE_READS_UNPARSED']); + expect(r.failure?.message).toContain(part); + } + }); + + // Value: protects=a stale read or a read of another thread is refused; fails_when=an old or wrong read certifies the archive + test('stale reads and another thread are refused', () => { + expect(checkThread(reads({ read_at: '2026-11-09T19:40:00Z' }), opts()).failure?.code).toBe('ARCHIVE_READS_STALE'); + expect(checkThread(reads({ read_at: '2026-11-09T20:10:00Z' }), opts()).failure?.code).toBe('ARCHIVE_READS_STALE'); + expect(checkThread(reads({ thread: thread('GSTA-2', 'idle') }), opts()).failure?.code).toBe('ARCHIVE_THREAD_MISMATCH'); + }); + + // Value: protects=open issues on the thread refuse in every working state, Backlog and Needs you only note; fails_when=a state outside a fixed list is skipped + test('issues: working states and custom open states are orphans, Backlog and Needs you are notes', () => { + const custom = 'States: Needs you, Backlog, Todo (default), In progress, Blocked, Waiting, In review · closed: Done (default), Canceled'; + const text = [custom, 'Labels: none', + '- GST-1 · In progress · @GSTA-1 · Build it · 2m', + '- GST-2 · Waiting · high · @GSTA-1, @GSTA-5 · Wait for it · 10/12 sub-issues closed · 6d', + ' - GST-3 · In review · urgent · @GSTA-1 · Review it · 4h', + '- GST-4 · Blocked · @GSTA-1 · Custom state · 1d', + '- GST-5 · Backlog · @GSTA-1 · Someday · 3d', + '- GST-6 · Needs you · @GSTA-1 · Ask · 1h', + '- GST-7 · Todo · @GSTA-1 · Next · 1h'].join('\n'); + const r = checkThread(reads({ issues: text }), opts()); + expect(r.verdict).toBe('refused'); + expect(r.lines.filter(l => l.startsWith('ORPHAN: issue')).map(l => l.split(' ')[2])).toEqual(['GST-1', 'GST-2', 'GST-3', 'GST-4', 'GST-7']); + expect(r.lines.filter(l => l.startsWith('NOTE: issue')).map(l => l.split(' ')[2])).toEqual(['GST-5', 'GST-6']); + expect(checkThread(reads({ issues: issues('- GST-5 · Backlog · @GSTA-1 · Someday · 3d') }), opts()).verdict).toBe('clean'); + }); + + // Value: protects=the issue grammar is checked against the States header and fails closed (gate D); fails_when=an unknown line or state is skipped + // Value: protects=a read cut off before its first issue row never archives; fails_when=header-only issue text parses as an empty list + test('issue text with headers but no rows and no "No issues match." is unparsed', () => { + for (const text of [STATES, STATES + '\nLabels: none']) { + const r = checkThread(reads({ issues: text }), opts()); + expect([r.verdict, r.failure?.code]).toEqual(['unreadable', 'ARCHIVE_READS_UNPARSED']); + } + }); + + test('issue lines: unknown states and unknown lines are unparsed', () => { + expect(parseIssueList(issues('- GST-1 · Doing · @GSTA-1 · x · 1m'))).toMatchObject({ code: 'ARCHIVE_READS_UNPARSED' }); + expect(parseIssueList(issues('Next page: cursor abc'))).toMatchObject({ code: 'ARCHIVE_READS_UNPARSED' }); + expect(parseIssueList(issues())).toEqual({ open: ['Needs you', 'Backlog', 'Todo', 'In progress', 'Waiting', 'In review'], closed: ['Done', 'Canceled'], issues: [] }); + }); + + // Value: protects=the successor brief starts from facts (part 3); fails_when=the stub drops the head, CI or issue rows + test('the handoff stub lists each open PR and open issue', () => { + const r = checkThread(reads({ thread: thread('GSTA-1', 'idle', [pr(4237), pr(9)]), issues: issues('- GST-2188 · In progress · @GSTA-1 · Catch orphans · 2m') }), opts({ 'garrytan/gstack#4237': { state: 'OPEN', title: 'Keep findings whole' }, 'garrytan/gstack#9': { state: 'MERGED' } })); + expect(r.stub.startsWith('')).toBe(true); + expect(r.stub).toContain('| garrytan/gstack#4237 | Keep findings whole | capy/x | ' + 'a'.repeat(40) + ' | green at aaaaaaaaaaaa'); + expect(r.stub).not.toContain('#9 '); + expect(r.stub).toContain('- GST-2188 · In progress · Catch orphans'); + expect(r.stub.trimEnd().endsWith('')).toBe(true); + }); +}); + +describe('landed (issue close on land)', () => { + const gh = (v: Record | 'fail'): Gh => (args) => v === 'fail' ? { ok: false, out: '', err: 'HTTP 401' } : { ok: true, out: JSON.stringify({ number: Number(args[2]), url: 'https://github.com/garrytan/gstack/pull/' + args[2], state: 'MERGED', mergeCommit: { oid: 'b'.repeat(40) }, ...v }) }; + const policy = { codes: ['\\b(?:GST|GSTA|GBRA)-\\d+\\b', '\\bR\\d{1,3}\\b'], terms: [], allow: [], since: '2026-01-01', exempt_branch_prefixes: [] }; + + // Value: protects=the land step names the issue in the title's trailing group, tracker codes only (gate G); fails_when=R-numbers, #N or other crews' codes are closed + test('prints one ISSUE_CLOSE per tracker code in the trailing group', () => { + const r = landedIssues({ pr: 4229, gh: gh({ title: 'Stop the queue from landing parent commits (GST-2153, R86, #4100, ZZZ-4)' }), policy }); + expect(r.exit).toBe(0); + expect(r.codes).toEqual(['GST-2153']); + expect(r.lines).toEqual(['ISSUE_CLOSE: GST-2153 pr=garrytan/gstack#4229 merge=bbbbbbbbbbbb']); + }); + + // Value: protects=no code means nothing to close, said plainly; fails_when=a title without a group prints nothing or an error + // Value: protects=a lowercase policy pattern still closes the PR's issues, as plain-title.ts reads it; fails_when=landed compiles policy codes case-sensitively + test('policy code patterns match case-insensitively', () => { + const r = landedIssues({ pr: 1, gh: gh({ title: 'Fix x (GST-12)' }), policy: { ...policy, codes: ['\\bgst-\\d+\\b'] } }); + expect(r.codes).toEqual(['GST-12']); + }); + + test('a title with no tracker code prints none', () => { + expect(landedIssues({ pr: 1, gh: gh({ title: 'Fix the thing' }), policy }).lines).toEqual(['ISSUE_CLOSE: none (the title names no tracker code)']); + }); + + // Value: protects=only a merged PR closes its issue, and a gh failure is never "none" (gate F); fails_when=an open PR or a failed read closes or skips silently + test('not merged exits 1; a gh failure exits 3', () => { + const open = landedIssues({ pr: 2, gh: gh({ state: 'OPEN', title: 'x (GST-1)' }), policy }); + expect([open.exit, open.failure?.code]).toEqual([1, 'ARCHIVE_PR_NOT_MERGED']); + const failed = landedIssues({ pr: 2, gh: gh('fail'), policy }); + expect([failed.exit, failed.failure?.code]).toEqual([3, 'ARCHIVE_GH_READ_FAILED']); + }); +}); + +describe('result codes', () => { + // Value: protects=every code the check prints has a summary, fix and troubleshooting anchor; fails_when=a code prints with no recovery + test('archive codes are registered with anchors', () => { + const doc = fs.readFileSync(path.join(import.meta.dir, '..', 'docs', 'troubleshooting.md'), 'utf8'); + for (const code of ['ARCHIVE_READS_STALE', 'ARCHIVE_READS_UNPARSED', 'ARCHIVE_THREAD_MISMATCH', 'ARCHIVE_PR_NOT_MERGED', 'ARCHIVE_GH_READ_FAILED'] as const) { + const row = (RESULT_CODES as Record)[code]; + expect(row?.anchor).toBeTruthy(); + expect(doc).toContain(''); + } + }); +}); + +describe('gstack-archive-check (bin)', () => { + const repos: FixtureRepo[] = []; + afterEach(() => { while (repos.length) cleanup(repos.pop()!); }); + function shim(repo: FixtureRepo, live: Record): Record { + const dir = path.join(repo.root, 'gh-shim'); + fs.mkdirSync(dir, { recursive: true }); + fs.writeFileSync(path.join(dir, 'live.json'), JSON.stringify(live)); + fs.writeFileSync(path.join(dir, 'gh.js'), [ + 'const fs = require("fs"); const p = require("path"); const a = process.argv.slice(2);', + 'fs.appendFileSync(p.join(__dirname, "calls.log"), (process.env.GH_REPO || "-") + " " + a.join(" ") + "\\n");', + 'const live = require(p.join(__dirname, "live.json"));', + 'if (a[0] === "pr" && a[1] === "view") { const r = a[a.indexOf("--repo") + 1]; const v = live[r + "#" + a[2]]; if (!v) { console.error("HTTP 502"); process.exit(1); }', + ' console.log(JSON.stringify({ number: Number(a[2]), url: "https://github.com/" + r + "/pull/" + a[2], headRefOid: "c".repeat(40), headRefName: "capy/y", title: "T", ...v })); process.exit(0); }', + 'if (a[0] === "api") { console.log(a.includes("--paginate") ? "[]" : JSON.stringify({ check_runs: [], statuses: [], total_count: 0 })); process.exit(0); }', + 'console.error("unexpected gh " + a.join(" ")); process.exit(1);', + ].join('\n')); + fs.writeFileSync(path.join(dir, 'gh'), '#!/bin/sh\nexec node "$(dirname "$0")/gh.js" "$@"\n', { mode: 0o755 }); + return { PATH: dir + path.delimiter + (process.env.PATH ?? '') }; + } + + // Value: protects=the CLI exit codes, the stub file's private mode and the repo-bound gh for CI reads (gate A); fails_when=the bin exits 0 on orphans, writes a world-readable stub or reads CI from the cwd repo + test('thread: refuses with exit 1, writes the stub 0600, binds CI reads to the PR repo', () => { + if (process.platform === 'win32') return; + const repo = makeFixtureRepo(); repos.push(repo); + const env = shim(repo, { 'garrytan/gbrain#5': { state: 'OPEN' } }); + const file = path.join(repo.root, 'reads.json'); + fs.writeFileSync(file, JSON.stringify({ ...reads({ thread: thread('GSTA-1', 'idle', [pr(5, 'garrytan/gbrain')]) }), read_at: new Date().toISOString() })); + const stub = path.join(repo.root, 'stub.md'); + const r = runBin(repo, 'gstack-archive-check', ['thread', '--thread', 'GSTA-1', '--reads', file, '--stub-out', stub], env); + expect(r.status).toBe(1); + expect(r.out).toContain('ORPHAN: pr garrytan/gbrain#5 open'); + expect(fs.statSync(stub).mode & 0o777).toBe(0o600); + const stale = path.join(repo.root, 'stale.json'); + fs.writeFileSync(stale, JSON.stringify(reads({ read_at: '2020-01-01T00:00:00Z' }))); + expect(runBin(repo, 'gstack-archive-check', ['thread', '--thread', 'GSTA-1', '--reads', stale, '--stub-out', stub], env).status).toBe(3); + expect(fs.readFileSync(stub, 'utf8')).toBe(''); + const calls = fs.readFileSync(path.join(repo.root, 'gh-shim', 'calls.log'), 'utf8').split('\n').filter(l => l.includes(' api ')); + expect(calls.length).toBeGreaterThan(0); + for (const c of calls) expect(c.startsWith('garrytan/gbrain ')).toBe(true); + const json = runBin(repo, 'gstack-archive-check', ['thread', '--thread', 'GSTA-1', '--reads', file, '--json'], env); + expect(json.status).toBe(1); + expect(JSON.parse(json.out)).toMatchObject({ verdict: 'refused', thread: 'GSTA-1' }); + fs.writeFileSync(file, JSON.stringify({ ...reads(), read_at: new Date().toISOString() })); + const clean = runBin(repo, 'gstack-archive-check', ['thread', '--thread', 'GSTA-1', '--reads', file], env); + expect([clean.status, clean.out.trim().split('\n').at(-1)]).toEqual([0, 'ARCHIVE_CHECK: clean thread=GSTA-1 prs=0 issues=0']); + expect(runBin(repo, 'gstack-archive-check', ['thread', '--reads', file], env).status).toBe(2); + }); + + // Value: protects=the land step's CLI: usage guards, the merged-PR close lines and the not-merged refusal; fails_when=a bad --pr or --repo reaches gh, or an open PR prints ISSUE_CLOSE + test('landed: usage exits 2, a merged PR prints its codes, an open PR exits 1', () => { + if (process.platform === 'win32') return; + const repo = makeFixtureRepo(); repos.push(repo); + const env = shim(repo, { 'garrytan/gstack#7': { state: 'MERGED', title: 'Fix x (GST-12)', mergeCommit: { oid: 'd'.repeat(40) } }, 'garrytan/gstack#8': { state: 'OPEN', title: 'Fix y (GST-13)' } }); + expect(runBin(repo, 'gstack-archive-check', ['landed', '--pr', 'x'], env).status).toBe(2); + expect(runBin(repo, 'gstack-archive-check', ['landed', '--pr', '7', '--repo', 'bad'], env).status).toBe(2); + const merged = runBin(repo, 'gstack-archive-check', ['landed', '--pr', '7', '--repo', 'garrytan/gstack'], env); + expect([merged.status, merged.out.trim()]).toEqual([0, 'ISSUE_CLOSE: GST-12 pr=garrytan/gstack#7 merge=' + 'd'.repeat(12)]); + const open = runBin(repo, 'gstack-archive-check', ['landed', '--pr', '8', '--repo', 'garrytan/gstack'], env); + expect(open.status).toBe(1); + expect(open.out).toContain('ARCHIVE_PR_NOT_MERGED'); + }); +}); diff --git a/test/runaway.test.ts b/test/runaway.test.ts index f6ee8fdd9..7fe6b1ef8 100644 --- a/test/runaway.test.ts +++ b/test/runaway.test.ts @@ -313,7 +313,7 @@ describe('/runaway-stopper skill', () => { test('rotation stops or lists live subagents, the successor subscribes to open PRs, and archiving waits for it', () => { expect(skill).toContain('stop your working and waiting subagents, or list each with its run'); expect(skill).toContain('subscribes to each open PR in the handoff'); - expect(skill).toContain('Archive the old thread once the successor reports its subscriptions'); + expect(skill).toContain('Leave the old thread unarchived while it holds an open PR (R91)'); expect(fs.readFileSync(path.resolve(import.meta.dir, '..', 'runaway-stopper', 'templates', 'successor-brief.md'), 'utf8')).toContain('subscribe to every open PR'); }); });