From 91bc5e01b373f4240a8d363d5508a44b72e8081f Mon Sep 17 00:00:00 2001 From: Lex Date: Fri, 28 Aug 2026 14:59:06 +0800 Subject: [PATCH 1/6] chore: record delivery binding for giant-summary-guard --- .specgit.yaml | 7 +++---- 1 file changed, 3 insertions(+), 4 deletions(-) diff --git a/.specgit.yaml b/.specgit.yaml index fb943af14..a74a9b624 100644 --- a/.specgit.yaml +++ b/.specgit.yaml @@ -1,8 +1,7 @@ version: 1 -delivery: ad-hoc-sign +delivery: giant-summary-guard context: kind: branch - branch: fix/454-ad-hoc-sign + branch: feat/458-giant-summary-guard issues: - - 454 -pr: 455 + - 458 From 529f7d4d1fe9d7b4735f984ce8cc192e09f8875b Mon Sep 17 00:00:00 2001 From: Lex Date: Fri, 28 Aug 2026 14:59:39 +0800 Subject: [PATCH 2/6] chore: record delivery binding for giant-summary-guard --- .specgit.yaml | 1 + 1 file changed, 1 insertion(+) diff --git a/.specgit.yaml b/.specgit.yaml index a74a9b624..da464b8fa 100644 --- a/.specgit.yaml +++ b/.specgit.yaml @@ -5,3 +5,4 @@ context: branch: feat/458-giant-summary-guard issues: - 458 +pr: 459 From fb6fa1fa4dc4db2b5c0fe160aa0c1ae1adac02af Mon Sep 17 00:00:00 2001 From: Lex Date: Fri, 28 Aug 2026 15:00:32 +0800 Subject: [PATCH 3/6] chore(specgit): refresh harness assets for CLI version, re-apply local specializations --- .github/workflows/specgit-accept.yml | 277 +++++++++++++++++++++---- .gitignore | 4 + .opencode/command/specgit-doctor.md | 26 +++ .opencode/command/specgit-finish.md | 2 + .opencode/command/specgit-issue.md | 9 +- .opencode/command/specgit-pr.md | 27 +++ .opencode/command/specgit-status.md | 28 +++ .opencode/hooks/specgit-merge-guard.sh | 17 +- AGENTS.md | 49 ++++- CLAUDE.md | 49 ++++- 10 files changed, 439 insertions(+), 49 deletions(-) create mode 100644 .opencode/command/specgit-doctor.md create mode 100644 .opencode/command/specgit-pr.md create mode 100644 .opencode/command/specgit-status.md diff --git a/.github/workflows/specgit-accept.yml b/.github/workflows/specgit-accept.yml index c280272a4..20c253211 100644 --- a/.github/workflows/specgit-accept.yml +++ b/.github/workflows/specgit-accept.yml @@ -2,23 +2,34 @@ name: SpecGit Acceptance on: pull_request: - # Delivery PRs target dev (fast-integration layer); the acceptance - # verdict runs only on the dev→main promotion PR, where protect-main's - # checks apply. Keep the trigger main-only (d6ce53a83): running it on - # dev PRs duplicated the verdict against the lighter dev gate. branches: [main] + # A draft PR fails the verdict (pr_draft), so the draft→ready + # transition must re-verdict. Listing types replaces the defaults, + # so the default activity types are listed alongside. + types: [opened, synchronize, reopened, ready_for_review] permissions: contents: read + issues: read + pull-requests: read + +# One verdict per head at a time (#319): a newer trigger event (a push +# after the draft opened, then ready_for_review) supersedes the older +# run of the same pull request instead of leaving parallel copies +# burning identical wait budgets. The surviving run re-verdicts fully. +concurrency: + group: specgit-accept-${{ github.ref }} + cancel-in-progress: true jobs: specgit-acceptance: name: SpecGit Acceptance + # Portable gate for any adopting repository: the published CLI is + # installed at the exact version `specgit init` pinned. The adopting + # project's own toolchain (package manager, lockfile, build, layout) + # is never assumed and never invoked. runs-on: ubuntu-latest - # Must exceed the slowest required sibling (Unit Tests (linux) runs - # ~28min on PRs): the verdict waits for every policy check to reach a - # terminal state before evaluating. - timeout-minutes: 45 + timeout-minutes: 15 steps: - name: Checkout code uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 @@ -33,15 +44,13 @@ jobs: - name: Setup Node.js uses: actions/setup-node@820762786026740c76f36085b0efc47a31fe5020 # v7.0.0 with: - node-version: '22' + node-version: '20.19' - # This repo is a bun workspace and does not vendor the SpecGit CLI; - # install the published CLI instead of building from source. Pinned - # with a caret floor (#366): the CLI releases multiple times a day and - # an unpinned install would let an unnoticed upstream change flip CI - # acceptance verdicts repo-wide. - - name: Install specgit CLI - run: npm install -g specgit@^0.5.0 + - name: Install pinned SpecGit CLI + # Exact version on purpose (no ^): the gate must evaluate with the + # same CLI generation that wrote the binding; upgrades are a + # deliberate re-init. --no-save keeps the adopting tree clean. + run: npm install --no-save --no-audit --no-fund specgit@1.8.0 - name: Wait for sibling checks # The verdict must see the OTHER required checks in a terminal @@ -49,51 +58,233 @@ jobs: # their check-runs yet, so an empty poll is not "done": wait until # every name in spec_git/policy.yaml is present with a terminal # conclusion. This job is not in the policy, so no self-deadlock. + # #315: a terminal run only counts when it started at/after the + # delivery's ready-for-review transition — a stale green keeps + # waiting for the fresh run the transition triggers. + # All GitHub access goes through the authenticated gh CLI. env: GH_TOKEN: ${{ github.token }} WAIT_REPO: ${{ github.repository }} - WAIT_SHA: ${{ github.event.pull_request.head.sha }} + WAIT_SHA: ${{ github.event.pull_request.head.sha || github.sha }} + WAIT_PR: ${{ github.event.pull_request.number || '' }} run: | node --input-type=module <<'EOF' - import { readFileSync } from 'node:fs'; - // Minimal parse of policy.yaml's required_checks block list — - // avoids a yaml dependency in this bun-based repo. - const policy = readFileSync('spec_git/policy.yaml', 'utf8'); - const section = policy.slice(policy.indexOf('required_checks:')); - const required = [...section.matchAll(/^\s*-\s*(.+)$/gm)].map((m) => m[1].trim()); - const headers = { - authorization: 'Bearer ' + process.env.GH_TOKEN, - accept: 'application/vnd.github+json', + import { existsSync, readFileSync } from 'node:fs'; + import { execFileSync } from 'node:child_process'; + import { parse } from 'yaml'; + if (!existsSync('spec_git/policy.yaml')) { + console.error('spec_git/policy.yaml is absent at this head — an adoption PR carries no binding commit yet (expected once; merge it before enabling branch protection), and a delivery PR must carry it via specgit issue.'); + process.exit(1); + } + const policy = parse(readFileSync('spec_git/policy.yaml', 'utf8')); + const required = policy.required_checks ?? []; + // gh.cmd needs a shell on Windows; POSIX execs the binary + + // directly (shell stays off where it is not needed). The + + // query rides --field args (never a raw "?" URL): cmd.exe + + // treats a bare "&" as a command separator, so a URL query + + // would be split mid-parameter on Windows. + + const listChecks = (page) => + JSON.parse( + execFileSync( + 'gh', + [ + 'api', + 'repos/' + process.env.WAIT_REPO + '/commits/' + process.env.WAIT_SHA + '/check-runs', + '--method', 'GET', + '--field', 'per_page=' + PER_PAGE, + '--field', 'page=' + page, + ], + { encoding: 'utf-8', stdio: ['ignore', 'pipe', 'pipe'], shell: process.platform === 'win32' } + ) + ); + // Transient API failures (5xx, 429, network) retry with bounded + + // exponential backoff — a platform blip must not fail the gate. + + const MAX_ATTEMPTS = 5; + const listChecksWithRetry = async (page) => { + for (let attempt = 1; ; attempt += 1) { + try { + return listChecks(page); + } catch (error) { + const text = String(error) + ' ' + String(error && error.stderr ? error.stderr : ''); + const transient = /HTTP 5\d\d|HTTP 429|ETIMEDOUT|ECONNRESET|ENOTFOUND|timed out/i.test(text); + if (attempt >= MAX_ATTEMPTS || !transient) throw error; + const backoff = Math.min(30000, 2000 * 2 ** (attempt - 1)); + console.log('Transient failure; retry ' + attempt + '/' + MAX_ATTEMPTS + ' in ' + backoff + 'ms'); + await new Promise((r) => setTimeout(r, backoff)); + } + } + }; + // #315: the ready-for-review anchor rides the issue-timeline + // endpoint through gh api --field args (GET, like the listing). + const fetchTimelinePage = (page) => + JSON.parse( + execFileSync( + 'gh', + [ + 'api', + 'repos/' + process.env.WAIT_REPO + '/issues/' + process.env.WAIT_PR + '/timeline', + '--method', 'GET', + '--field', 'per_page=' + PER_PAGE, + '--field', 'page=' + page, + ], + { encoding: 'utf-8', stdio: ['ignore', 'pipe', 'pipe'], shell: process.platform === 'win32' } + ) + ); + const fetchTimelineWithRetry = async (page) => { + for (let attempt = 1; ; attempt += 1) { + try { + const payload = fetchTimelinePage(page); + if (!Array.isArray(payload)) throw new Error('GitHub returned a non-array timeline payload.'); + return payload; + } catch (error) { + if (error && error.message === 'GitHub returned a non-array timeline payload.') throw error; + const text = String(error) + ' ' + String(error && error.stderr ? error.stderr : ''); + const transient = /HTTP 5\d\d|HTTP 429|ETIMEDOUT|ECONNRESET|ENOTFOUND|timed out/i.test(text); + if (attempt >= MAX_ATTEMPTS || !transient) throw error; + const backoff = Math.min(30000, 2000 * 2 ** (attempt - 1)); + console.log('Transient failure; retry ' + attempt + '/' + MAX_ATTEMPTS + ' in ' + backoff + 'ms'); + await new Promise((r) => setTimeout(r, backoff)); + } + } }; - const url = 'https://api.github.com/repos/' + process.env.WAIT_REPO - + '/commits/' + process.env.WAIT_SHA + '/check-runs?per_page=100'; const terminal = new Set(['completed']); - const terminalHas = (byName, name) => { - if (byName.has(name)) return terminal.has(byName.get(name)); - const retried = [...byName.keys()].find((k) => k.startsWith(name + ' (')); - return retried !== undefined && terminal.has(byName.get(retried)); + const PER_PAGE = 100; + // #300: page the listing to exhaustion — a head with more than + + // PER_PAGE check-runs must still expose every required name. + + const fetchAllCheckRuns = async () => { + const runs = []; + for (let page = 1; ; page += 1) { + const payload = await listChecksWithRetry(page); + runs.push(...(payload.check_runs ?? [])); + if (!payload.check_runs || payload.check_runs.length < PER_PAGE) break; + } + return runs; + }; + // #315: the evidence anchor — created_at of the latest + + // ready_for_review event on the pull request's issue timeline, + + // paged to exhaustion through the same transport seam. Empty + + // WAIT_PR (a push or workflow_dispatch event) means no anchor + + // and no freshness bound; a fetch failure fails the step + + // loudly instead of silently unbounding freshness. + + const fetchAnchor = async () => { + if (!process.env.WAIT_PR) return null; + let anchor = null; + let anchorTime = null; + for (let page = 1; ; page += 1) { + const events = await fetchTimelineWithRetry(page); + if (!Array.isArray(events)) throw new Error('GitHub returned a non-array timeline payload.'); + for (const event of events) { + if (event && event.event === 'ready_for_review') { + if (typeof event.created_at !== 'string' || event.created_at === '' + || Number.isNaN(Date.parse(event.created_at))) { + throw new Error('GitHub returned a ready-for-review event without a valid timestamp.'); + } + const eventTime = Date.parse(event.created_at); + if (anchor === null || anchorTime === null || eventTime > anchorTime) { + anchor = event.created_at; + anchorTime = eventTime; + } + } + } + if (!Array.isArray(events) || events.length < PER_PAGE) return anchor; + } }; - // Must outlast the slowest required sibling (Unit Tests (linux) - // runs ~28min on PRs); the job timeout above bounds this too. - const deadline = Date.now() + 40 * 60 * 1000; + // Poll deadline sits BELOW the job's timeout-minutes (15) on + + // purpose: when the deadline loses the race against a slow + + // sibling, the script exits with its own diagnosis instead of + + // being killed by the job timeout mid-line. + + const deadline = Date.now() + 13 * 60 * 1000; while (Date.now() < deadline) { - const res = await fetch(url, { headers }); - if (!res.ok) throw new Error('check-runs API ' + res.status); - const payload = await res.json(); - const byName = new Map(payload.check_runs.map((r) => [r.name, r.status])); - const missing = required.filter((n) => !terminalHas(byName, n)); - if (missing.length === 0) { + // #315: re-read the anchor every cycle — the transition + + // event landing after this job started, or the fresh runs + + // registering late, self-heal on the next poll. + + let anchor; + try { + anchor = await fetchAnchor(); + } catch (error) { + console.error('Could not read the ready-for-review anchor: ' + + (error && error.message ? error.message : String(error))); + process.exit(1); + } + const runs = await fetchAllCheckRuns(); + // #119: re-runs keep every same-name run; terminality is + // decided on the truth run — latest started_at, ties broken + // by the higher check-run id (docs/reference.md) — never on + // response position. + const truth = new Map(); + const startedTime = (run) => { + if (typeof run.started_at !== 'string') return Number.NEGATIVE_INFINITY; + const parsed = Date.parse(run.started_at); + return Number.isNaN(parsed) ? Number.NEGATIVE_INFINITY : parsed; + }; + for (const r of runs) { + const cur = truth.get(r.name); + const runTime = startedTime(r); + const currentTime = cur === undefined ? Number.NEGATIVE_INFINITY : startedTime(cur); + const later = cur === undefined + || runTime > currentTime + || (runTime === currentTime && (r.id || 0) > (cur.id || 0)); + if (later) truth.set(r.name, r); + } + const truthRunFor = (name) => { + if (truth.has(name)) return truth.get(name); + const retried = [...truth.keys()].find((k) => k.startsWith(name + ' (')); + return retried === undefined ? undefined : truth.get(retried); + }; + // #315: a required check settles only when its truth run is + // terminal AND (when an anchor exists) started at/after the + // ready-for-review transition — a stale green keeps waiting. + const missing = []; + const stale = []; + const anchorTime = anchor === null ? null : Date.parse(anchor); + for (const name of required) { + const run = truthRunFor(name); + if (run === undefined || !terminal.has(run.status)) { + missing.push(name); + } else if (anchorTime !== null && (Number.isNaN(anchorTime) || startedTime(run) < anchorTime)) { + stale.push(name); + } + } + if (missing.length === 0 && stale.length === 0) { console.log('All required checks are in a terminal state.'); process.exit(0); } - console.log('Waiting for: ' + missing.join(', ')); + if (missing.length > 0) { + console.log('Waiting for: ' + missing.join(', ')); + } + if (stale.length > 0) { + console.log('Waiting for a fresh run after ready for review: ' + stale.join(', ')); + } await new Promise((r) => setTimeout(r, 10000)); } console.error('Timed out waiting for sibling checks.'); process.exit(1); EOF + - name: specgit finish - run: specgit finish --json + run: npx --no-install specgit finish --json env: GH_TOKEN: ${{ github.token }} diff --git a/.gitignore b/.gitignore index ee0809b00..f2caa4f7c 100644 --- a/.gitignore +++ b/.gitignore @@ -59,3 +59,7 @@ tsconfig.tsbuildinfo .opencode/dag-init.json .opencode/workflow-drafts/ .opencode/workflow-reports/ +# >>> specgit: local delivery assets (managed by specgit init) >>> +/.specgit.yaml +/spec_git/ +# <<< specgit: local delivery assets (managed by specgit init) <<< diff --git a/.opencode/command/specgit-doctor.md b/.opencode/command/specgit-doctor.md new file mode 100644 index 000000000..e078b5431 --- /dev/null +++ b/.opencode/command/specgit-doctor.md @@ -0,0 +1,26 @@ +--- +description: Diagnose the SpecGit environment probes and drive the exit-3 repair loop +--- + + + +# /specgit-doctor + +Thin trigger for the exit-3 diagnostic loop. The canonical behavior lives in +the AGENTS.md SpecGit block; this command only launches it. + +## Steps + +1. Run from the repo root: + + ```bash + specgit doctor --json + ``` + +2. Read `probes[]`: every failing probe carries a `code` (git, repo, + origin, gh/glab presence and auth, policy). +3. Fix exactly what the failing probe names, then re-run + `specgit doctor --json` until exit 0. +4. Return to the verdict: `specgit finish --json`. Exit 3 is environment, + never delivery — do not edit the record or the policy to work around it. +5. `--json` is the only parse surface. diff --git a/.opencode/command/specgit-finish.md b/.opencode/command/specgit-finish.md index 63e86e2cc..e828bcdd5 100644 --- a/.opencode/command/specgit-finish.md +++ b/.opencode/command/specgit-finish.md @@ -2,6 +2,8 @@ description: Run the SpecGit evidence verdict and drive the fix loop to exit 0 --- + + # /specgit-finish Thin trigger for the acceptance verdict. The canonical behavior lives in the diff --git a/.opencode/command/specgit-issue.md b/.opencode/command/specgit-issue.md index 6ec61c6e5..aff9adebd 100644 --- a/.opencode/command/specgit-issue.md +++ b/.opencode/command/specgit-issue.md @@ -2,6 +2,8 @@ description: Start a SpecGit delivery from a title or existing issue number --- + + # /specgit-issue Thin trigger for the delivery bootstrap. The canonical behavior lives in the @@ -17,6 +19,11 @@ AGENTS.md SpecGit block; this command only launches it. specgit issue "$ARGUMENTS" --json ``` -3. On success report the brief: issue URL(s), PR URL (draft), branch name. +3. On success report the brief: issue URL(s), PR URL (draft), branch name — + then fill each issue body it created (Why / Scope / Approach / + Acceptance) from the discussion with `gh issue edit `, then + implement. Fill in the draft PR's scaffold (Why / What changed / + Evidence) as you deliver; its placeholders are advisory, never gates, + and the closing references stay intact. 4. Switch to the delivery branch and begin the TDD loop. 5. On error, read `errors[].fix` and follow it — never bypass the record. diff --git a/.opencode/command/specgit-pr.md b/.opencode/command/specgit-pr.md new file mode 100644 index 000000000..63150c805 --- /dev/null +++ b/.opencode/command/specgit-pr.md @@ -0,0 +1,27 @@ +--- +description: Repair the SpecGit PR binding — auto-discover by head branch or bind explicitly +--- + + + +# /specgit-pr + +Thin trigger for PR-binding repair. The canonical behavior lives in the +AGENTS.md SpecGit block; this command only launches it. + +## Steps + +1. Run from the delivery branch: + + ```bash + specgit pr --json + ``` + +2. Branch on the result: + - `exit 0` → the record's PR binding is repaired; resume the delivery. + - `pr_not_found` → push the branch (re-running `specgit issue` + resumes the bootstrap), then rerun this command. + - `pr_ambiguous` → several open PRs share the head branch; bind one + explicitly: `specgit pr `. +3. `specgit pr` owns the PR binding; never hand-edit `.specgit.yaml`. + `--json` is the only parse surface. diff --git a/.opencode/command/specgit-status.md b/.opencode/command/specgit-status.md new file mode 100644 index 000000000..0fb8a69c0 --- /dev/null +++ b/.opencode/command/specgit-status.md @@ -0,0 +1,28 @@ +--- +description: Show local SpecGit evidence — record, delivery state, drift, origin +--- + + + +# /specgit-status + +Thin trigger for local evidence. The canonical behavior lives in the +AGENTS.md SpecGit block; this command only launches it. + +## Steps + +1. Run from the repo root: + + ```bash + specgit status --json + ``` + +2. Read `state` and `record` from the envelope: local evidence only — + record, drift, origin. Platform evidence (issues, PR, checks) belongs + to `specgit finish`. +3. No record is not an error: `state: "unbound"` with exit `0` is the + normal pre-binding state — bootstrap with `specgit issue` (the + `record_missing` warning carries the next step in `warnings[].fix`). + Exit `3` is different: `state: "unknown"`, a genuine evidence + failure — read `errors[].fix`. +4. Never hand-edit `.specgit.yaml`. diff --git a/.opencode/hooks/specgit-merge-guard.sh b/.opencode/hooks/specgit-merge-guard.sh index ad407724b..f261123f3 100755 --- a/.opencode/hooks/specgit-merge-guard.sh +++ b/.opencode/hooks/specgit-merge-guard.sh @@ -1,5 +1,5 @@ #!/bin/sh -# SpecGit merge guard (managed by specgit init). Exit 2 = block with reason. +# SpecGit guard (managed by specgit init): start gate + merge guard. Exit 2 = block with reason. GUARD_DIR=$(cd "$(dirname "$0")" && pwd) export GUARD_DIR # Hook payloads arrive as the first argument or on stdin; accept both. @@ -8,6 +8,21 @@ if [ -n "$1" ]; then else payload=$(cat) fi +tool=$(printf '%s' "$payload" | node -e "let s='';process.stdin.on('data',d=>s+=d).on('end',()=>{try{const j=JSON.parse(s);process.stdout.write((j.tool_name)||'')}catch{process.stdout.write('')}})") +case "$tool" in + edit|write|Edit|Write) + # Start gate (#335): mutating files requires the delivery binding on + # THIS branch. The record's context.branch is written by specgit and + # matched as a fixed WHOLE line — no YAML parsing, no prefix collision + # (branch "feat/1-a" must never satisfy a record for "feat/1-a2"). + branch=$(git branch --show-current 2>/dev/null) + if [ -z "$branch" ] || [ ! -f .specgit.yaml ] || ! grep -qFx " branch: $branch" .specgit.yaml; then + echo "specgit: start gate - this branch has no delivery binding. Start the delivery first: specgit issue \": \", then fill each issue body from the discussion, then edit files." >&2 + exit 2 + fi + exit 0 + ;; +esac command=$(printf '%s' "$payload" | node -e "let s='';process.stdin.on('data',d=>s+=d).on('end',()=>{try{const j=JSON.parse(s);process.stdout.write((j.tool_input&&j.tool_input.command)||'')}catch{process.stdout.write('')}})") case "$command" in diff --git a/AGENTS.md b/AGENTS.md index c08d5319d..cdea59a0d 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -283,13 +283,30 @@ already exists); keep manual guidance outside them. deterministic scaffold (the `Closes #n` line for every bound issue, then Why / What changed / Evidence / Checklist sections), and writes `.specgit.yaml`. Re-running resumes; it is idempotent. -- Fill in the scaffold sections as you deliver. Its placeholders are - advisory — the closing references are the only body gate. The PR body +- Issue bodies are filled at bootstrap, from the conversation: right after + `specgit issue` succeeds, edit each issue it created (`gh issue edit <n>`) + with the discussed Why / Scope / Approach / Acceptance, then implement. + The PR scaffold's placeholders are advisory — fill those sections in as + you deliver; the closing references are the only body gate. The PR body is written once at creation; no SpecGit command edits an existing PR body, and the repository's own pull-request template is never read. +- A draft pull request always fails the verdict (`pr_draft`): before + `specgit finish`, mark it ready for review — `gh pr ready <number>` + on GitHub, `glab mr update <number> --ready` on GitLab. - Finish with `specgit finish`: the verdict, derived from real git, PR, and CI evidence. Exit code 0 is the only "done". +### Issue tags + +- Every bootstrap applies the title's `kind::<type>` member + automatically; pass `--tags <a,b>` to choose the full set explicitly. +- Selection is pool-first: existing on-spec labels win verbatim; anything + missing is seeded from the built-in `kind::` catalog or the policy's + `tags:` declarations. Unknown vocabulary exits 2 naming the universe. +- Choose with restraint: at most one label per axis, none when unsure — + off-spec pool labels are reported (`tag_pool_dirty` warnings are for + humans) and never renamed by SpecGit. + ### Repair and diagnostics - `specgit pr` repairs the pull-request binding: with no arguments it @@ -333,6 +350,34 @@ verified on its own evidence, split it before binding. - Never weaken `spec_git/policy.yaml` to make a verdict pass. - `--json` is the only parse surface: stdout is exactly one JSON document; never scrape human-readable output. + +### Agent contract essentials + +- **SpecGit is the default way of working here.** Any non-trivial + task — a feature, a fix, a refactor, a docs change — is a delivery: + work items live in this tracker as issues, never in private task + lists or conversational checklists. The trigger is the decision to + start: the moment the conversation settles and you begin turning + the plan into changes, the FIRST action is + `specgit issue <type>: <title>...` — before any file edit. + Working without a binding is a contract violation, not a style + choice. Immediately after bootstrap, fill each issue body + (Why / Scope / Approach / Acceptance) from the discussion with + `gh issue edit`, then implement. Mid-conversation inventories + ("let me list everything to do") become issues, not chat + artifacts. Trivial replies and read-only questions need none of + this. +- The one rule: a delivery is done if and only if `specgit finish` + exits `0`. Never declare completion from task lists, file states, or + test runs you performed yourself. +- Branch on exit codes, not phrasing: `1` = evidence complete, fix what + the gates named; `3` = evidence missing, fix the environment first + (`specgit doctor`). Never present exit `3` as success. +- Keep the `Closes #n` references in the PR body intact; after changing + the PR body, head branch, or CI, re-run `specgit finish`. Never + bypass or reconfig a required check to make acceptance pass. +- Forge evidence flows through the user's authenticated CLI session only + (`gh` / `glab`): never read, log, or pass around tokens. <!-- specgit:block:end --> ## Tool-call discipline (hard rules) diff --git a/CLAUDE.md b/CLAUDE.md index 93e9a7e68..86505927f 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -190,13 +190,30 @@ already exists); keep manual guidance outside them. deterministic scaffold (the `Closes #n` line for every bound issue, then Why / What changed / Evidence / Checklist sections), and writes `.specgit.yaml`. Re-running resumes; it is idempotent. -- Fill in the scaffold sections as you deliver. Its placeholders are - advisory — the closing references are the only body gate. The PR body +- Issue bodies are filled at bootstrap, from the conversation: right after + `specgit issue` succeeds, edit each issue it created (`gh issue edit <n>`) + with the discussed Why / Scope / Approach / Acceptance, then implement. + The PR scaffold's placeholders are advisory — fill those sections in as + you deliver; the closing references are the only body gate. The PR body is written once at creation; no SpecGit command edits an existing PR body, and the repository's own pull-request template is never read. +- A draft pull request always fails the verdict (`pr_draft`): before + `specgit finish`, mark it ready for review — `gh pr ready <number>` + on GitHub, `glab mr update <number> --ready` on GitLab. - Finish with `specgit finish`: the verdict, derived from real git, PR, and CI evidence. Exit code 0 is the only "done". +### Issue tags + +- Every bootstrap applies the title's `kind::<type>` member + automatically; pass `--tags <a,b>` to choose the full set explicitly. +- Selection is pool-first: existing on-spec labels win verbatim; anything + missing is seeded from the built-in `kind::` catalog or the policy's + `tags:` declarations. Unknown vocabulary exits 2 naming the universe. +- Choose with restraint: at most one label per axis, none when unsure — + off-spec pool labels are reported (`tag_pool_dirty` warnings are for + humans) and never renamed by SpecGit. + ### Repair and diagnostics - `specgit pr` repairs the pull-request binding: with no arguments it @@ -240,4 +257,32 @@ verified on its own evidence, split it before binding. - Never weaken `spec_git/policy.yaml` to make a verdict pass. - `--json` is the only parse surface: stdout is exactly one JSON document; never scrape human-readable output. + +### Agent contract essentials + +- **SpecGit is the default way of working here.** Any non-trivial + task — a feature, a fix, a refactor, a docs change — is a delivery: + work items live in this tracker as issues, never in private task + lists or conversational checklists. The trigger is the decision to + start: the moment the conversation settles and you begin turning + the plan into changes, the FIRST action is + `specgit issue <type>: <title>...` — before any file edit. + Working without a binding is a contract violation, not a style + choice. Immediately after bootstrap, fill each issue body + (Why / Scope / Approach / Acceptance) from the discussion with + `gh issue edit`, then implement. Mid-conversation inventories + ("let me list everything to do") become issues, not chat + artifacts. Trivial replies and read-only questions need none of + this. +- The one rule: a delivery is done if and only if `specgit finish` + exits `0`. Never declare completion from task lists, file states, or + test runs you performed yourself. +- Branch on exit codes, not phrasing: `1` = evidence complete, fix what + the gates named; `3` = evidence missing, fix the environment first + (`specgit doctor`). Never present exit `3` as success. +- Keep the `Closes #n` references in the PR body intact; after changing + the PR body, head branch, or CI, re-run `specgit finish`. Never + bypass or reconfig a required check to make acceptance pass. +- Forge evidence flows through the user's authenticated CLI session only + (`gh` / `glab`): never read, log, or pass around tokens. <!-- specgit:block:end --> From 481e31e2b7de005e43911f2e9bbb7b5928bd1d1b Mon Sep 17 00:00:00 2001 From: Lex <sunsan05@Gmail.com> Date: Fri, 28 Aug 2026 16:05:55 +0800 Subject: [PATCH 4/6] fix(session): cap summary.diffs at 256KB to stop event-loop starvation --- packages/opencode/src/session/session.ts | 27 +- packages/opencode/src/session/summary.ts | 4 +- .../test/session/summary-diff-guard.test.ts | 238 ++++++++++++++++++ 3 files changed, 265 insertions(+), 4 deletions(-) create mode 100644 packages/opencode/test/session/summary-diff-guard.test.ts diff --git a/packages/opencode/src/session/session.ts b/packages/opencode/src/session/session.ts index 59fce4afc..36a6440a0 100644 --- a/packages/opencode/src/session/session.ts +++ b/packages/opencode/src/session/session.ts @@ -64,6 +64,29 @@ export function isDefaultTitle(title: string) { type SessionRow = typeof SessionTable.$inferSelect +export const MAX_SUMMARY_DIFF_BYTES = 256 * 1024 + +// Byte accounting mirrors the JSON serialization: 2 bytes for the "[]" wrapper, +// +1 per comma separator, so kept output never exceeds MAX_SUMMARY_DIFF_BYTES. +export function truncateSummaryDiffs(diffs: Snapshot.FileDiff[] | undefined) { + if (!diffs) return undefined + let total = 2 + const kept: Snapshot.FileDiff[] = [] + for (const item of diffs) { + const size = Buffer.byteLength(JSON.stringify(item)) + (kept.length > 0 ? 1 : 0) + if (total + size > MAX_SUMMARY_DIFF_BYTES) break + total += size + kept.push(item) + } + return kept +} + +function stripOversizedDiffs<T>(diffs: T[] | null | undefined) { + if (!diffs) return undefined + if (Buffer.byteLength(JSON.stringify(diffs)) > MAX_SUMMARY_DIFF_BYTES) return undefined + return diffs +} + export function fromRow(row: SessionRow): Info { const summary = row.summary_additions !== null || row.summary_deletions !== null || row.summary_files !== null @@ -71,7 +94,7 @@ export function fromRow(row: SessionRow): Info { additions: row.summary_additions ?? 0, deletions: row.summary_deletions ?? 0, files: row.summary_files ?? 0, - diffs: row.summary_diffs ?? undefined, + diffs: stripOversizedDiffs(row.summary_diffs), } : undefined const share = row.share_url ? { url: row.share_url } : undefined @@ -142,7 +165,7 @@ export function toRow(info: Info) { summary_additions: info.summary?.additions, summary_deletions: info.summary?.deletions, summary_files: info.summary?.files, - summary_diffs: info.summary?.diffs, + summary_diffs: truncateSummaryDiffs(info.summary?.diffs), metadata: info.metadata, cost: info.cost ?? 0, tokens_input: (info.tokens ?? EmptyTokens).input, diff --git a/packages/opencode/src/session/summary.ts b/packages/opencode/src/session/summary.ts index 3a5ddc3ce..13a72b19f 100644 --- a/packages/opencode/src/session/summary.ts +++ b/packages/opencode/src/session/summary.ts @@ -3,7 +3,7 @@ import { Effect, Layer, Context, Schema } from "effect" import { SessionV1 } from "@opencode-ai/core/v1/session" import { EventV2Bridge } from "@/event-v2-bridge" import { Snapshot } from "@/snapshot" -import { Session } from "./session" +import { Session, truncateSummaryDiffs } from "./session" import { SessionID, MessageID } from "./schema" import { Config } from "@/config/config" @@ -122,7 +122,7 @@ export const layer = Layer.effect( const target = messages.find((m) => m.info.id === input.messageID) if (!target || target.info.role !== "user") return const msgDiffs = yield* computeDiff({ messages }) - target.info.summary = { ...target.info.summary, diffs: msgDiffs } + target.info.summary = { ...target.info.summary, diffs: truncateSummaryDiffs(msgDiffs) ?? [] } yield* sessions.updateMessage(target.info) }) diff --git a/packages/opencode/test/session/summary-diff-guard.test.ts b/packages/opencode/test/session/summary-diff-guard.test.ts new file mode 100644 index 000000000..7d109b40c --- /dev/null +++ b/packages/opencode/test/session/summary-diff-guard.test.ts @@ -0,0 +1,238 @@ +import { describe, expect, test } from "bun:test" +import { Database } from "@opencode-ai/core/database/database" +import { LayerNode } from "@opencode-ai/core/effect/layer-node" +import { CrossSpawnSpawner } from "@opencode-ai/core/cross-spawn-spawner" +import { SessionProjector } from "@opencode-ai/core/session/projector" +import { SessionTable } from "@opencode-ai/core/session/sql" +import { SessionV1 } from "@opencode-ai/core/v1/session" +import { ModelV2 } from "@opencode-ai/core/model" +import { ProviderV2 } from "@opencode-ai/core/provider" +import { Effect, Layer } from "effect" +import { eq } from "drizzle-orm" +import { Snapshot } from "@/snapshot" +import { Session as SessionNs, truncateSummaryDiffs, MAX_SUMMARY_DIFF_BYTES } from "@/session/session" +import { SessionSummary } from "@/session/summary" +import { MessageID, PartID, type SessionID } from "@/session/schema" +import { RuntimeFlags } from "@/effect/runtime-flags" +import { pollWithTimeout, testEffect } from "../lib/effect" + +const stub = { diffs: [] as Snapshot.FileDiff[] } + +const snapshotStub = Layer.mock(Snapshot.Service, { + diffFull: () => Effect.succeed(stub.diffs), +}) + +const root = LayerNode.group([ + SessionNs.node, + SessionProjector.node, + SessionSummary.node, + Database.node, + CrossSpawnSpawner.node, +]) + +const it = testEffect( + LayerNode.buildLayer(root, { + replacements: [ + LayerNode.replace(Snapshot.node, snapshotStub), + LayerNode.replace(RuntimeFlags.node, RuntimeFlags.layer({ experimentalWorkspaces: false })), + ], + }), +) + +const giantDiffs = (count: number) => + Array.from({ length: count }, (_, i) => ({ + file: `f${String(i).padStart(3, "0")}.txt`, + patch: "x".repeat(2048), + additions: 10, + deletions: 2, + status: "modified" as const, + })) + +const setSummaryRow = (sessionID: SessionID, summary: { additions: number; deletions: number; files: number; diffs: Snapshot.FileDiff[] }) => + Effect.gen(function* () { + const database = yield* Database.Service + yield* database.db + .update(SessionTable) + .set({ + summary_additions: summary.additions, + summary_deletions: summary.deletions, + summary_files: summary.files, + summary_diffs: summary.diffs, + }) + .where(eq(SessionTable.id, sessionID)) + .run() + .pipe(Effect.orDie) + }) + +const seedUserTurn = Effect.fnUntraced(function* (sessionID: SessionID) { + const sessions = yield* SessionNs.Service + const userMessageID = MessageID.ascending() + yield* sessions.updateMessage({ + id: userMessageID, + sessionID, + role: "user", + time: { created: Date.now() }, + agent: "user", + model: { providerID: ProviderV2.ID.make("test"), modelID: ModelV2.ID.make("test") }, + } satisfies SessionV1.User) + const assistantMessageID = MessageID.ascending() + yield* sessions.updateMessage({ + id: assistantMessageID, + sessionID, + role: "assistant", + parentID: userMessageID, + time: { created: Date.now() }, + agent: "build", + modelID: ModelV2.ID.make("test"), + providerID: ProviderV2.ID.make("test"), + mode: "build", + path: { cwd: sessionID, root: sessionID }, + cost: 0, + tokens: { input: 0, output: 0, reasoning: 0, cache: { read: 0, write: 0 } }, + } satisfies SessionV1.Assistant) + yield* sessions.updatePart({ + id: PartID.ascending(), + messageID: assistantMessageID, + sessionID, + type: "step-start", + snapshot: "from", + }) + yield* sessions.updatePart({ + id: PartID.ascending(), + messageID: assistantMessageID, + sessionID, + type: "step-finish", + reason: "stop", + snapshot: "to", + cost: 0, + tokens: { input: 0, output: 0, reasoning: 0, cache: { read: 0, write: 0 } }, + }) + return userMessageID +}) + +describe("summary.diffs source truncation", () => { + it.instance( + "summarize truncates oversized diffs to the byte budget and keeps the leading files", + () => + Effect.gen(function* () { + const sessions = yield* SessionNs.Service + const summary = yield* SessionSummary.Service + const session = yield* sessions.create({ title: "giant-summary" }) + const userMessageID = yield* seedUserTurn(session.id) + + stub.diffs = giantDiffs(300) + yield* summary.summarize({ sessionID: session.id, messageID: userMessageID }) + + const diffs = yield* pollWithTimeout( + Effect.gen(function* () { + const list = yield* summary.diff({ sessionID: session.id, messageID: userMessageID }) + return list.length > 0 ? list : undefined + }), + "summarized diffs never persisted", + ) + + expect(diffs.length).toBeLessThan(300) + expect(Buffer.byteLength(JSON.stringify(diffs))).toBeLessThanOrEqual(SessionNs.MAX_SUMMARY_DIFF_BYTES) + expect(diffs[0]?.file).toBe("f000.txt") + expect(diffs.at(-1)?.file).toBe(`f${String(diffs.length - 1).padStart(3, "0")}.txt`) + }), + { timeout: 30000 }, + ) +}) + +describe("summary_diffs read guard", () => { + it.instance("strips oversized legacy summary_diffs on read and keeps stats", () => + Effect.gen(function* () { + const sessions = yield* SessionNs.Service + const database = yield* Database.Service + const session = yield* sessions.create({ title: "legacy-giant-diffs" }) + + yield* database.db + .update(SessionTable) + .set({ + summary_additions: 12, + summary_deletions: 34, + summary_files: 56, + summary_diffs: giantDiffs(300), + }) + .where(eq(SessionTable.id, session.id)) + .run() + .pipe(Effect.orDie) + + const info = yield* sessions.get(session.id) + expect(info.summary?.additions).toBe(12) + expect(info.summary?.deletions).toBe(34) + expect(info.summary?.files).toBe(56) + expect(info.summary?.diffs).toBeUndefined() + }), + ) +}) + +describe("truncateSummaryDiffs boundaries", () => { + const item = { + file: "a.txt", + patch: "x".repeat(1024), + additions: 1, + deletions: 1, + status: "modified" as const, + } + + test("keeps an empty array as empty", () => { + expect(truncateSummaryDiffs([])).toEqual([]) + expect(truncateSummaryDiffs(undefined)).toBeUndefined() + }) + + test("returns an empty array when a single entry exceeds the budget, without throwing", () => { + const huge = [{ ...item, patch: "x".repeat(MAX_SUMMARY_DIFF_BYTES) }] + expect(truncateSummaryDiffs(huge)).toEqual([]) + }) + + test("fills the budget exactly to the largest complete entry count", () => { + const size = Buffer.byteLength(JSON.stringify(item)) + const count = Math.floor((MAX_SUMMARY_DIFF_BYTES - 1) / (size + 1)) + const kept = truncateSummaryDiffs(Array.from({ length: count + 5 }, () => item)) + expect(kept?.length).toBe(count) + expect(Buffer.byteLength(JSON.stringify(kept))).toBeLessThanOrEqual(MAX_SUMMARY_DIFF_BYTES) + }) +}) + +describe("summary diffs budget boundary", () => { + it.instance("keeps diffs at just under the budget on write and read", () => + Effect.gen(function* () { + const sessions = yield* SessionNs.Service + const session = yield* sessions.create({ title: "under-budget" }) + const info = yield* sessions.get(session.id) + + const diffs = giantDiffs(100) + expect(Buffer.byteLength(JSON.stringify(diffs))).toBeLessThan(SessionNs.MAX_SUMMARY_DIFF_BYTES) + const row = SessionNs.toRow({ ...info, summary: { additions: 5, deletions: 6, files: 100, diffs } }) + expect(row.summary_diffs).toEqual(diffs) + + yield* setSummaryRow(session.id, { additions: 5, deletions: 6, files: 100, diffs }) + const back = yield* sessions.get(session.id) + expect(back.summary?.diffs).toEqual(diffs) + expect(back.summary?.additions).toBe(5) + expect(back.summary?.deletions).toBe(6) + expect(back.summary?.files).toBe(100) + }), + ) + + it.instance("truncates oversized diffs on write within the budget", () => + Effect.gen(function* () { + const sessions = yield* SessionNs.Service + const session = yield* sessions.create({ title: "over-budget-write" }) + const info = yield* sessions.get(session.id) + + const row = SessionNs.toRow({ + ...info, + summary: { additions: 5, deletions: 6, files: 300, diffs: giantDiffs(300) }, + }) + const kept = row.summary_diffs + expect(kept?.length).toBeGreaterThan(0) + expect(kept?.length).toBeLessThan(300) + expect(Buffer.byteLength(JSON.stringify(kept))).toBeLessThanOrEqual(SessionNs.MAX_SUMMARY_DIFF_BYTES) + expect(kept?.[0]?.file).toBe("f000.txt") + expect(kept?.at(-1)?.file).toBe(`f${String((kept?.length ?? 1) - 1).padStart(3, "0")}.txt`) + }), + ) +}) From 68e6a5dcdb9ff5f57f79579cfec020fc64120cda Mon Sep 17 00:00:00 2001 From: Lex <sunsan05@Gmail.com> Date: Fri, 28 Aug 2026 16:34:57 +0800 Subject: [PATCH 5/6] fix(ci): restore global specgit CLI install in specgit-accept (re-apply #434 after init --force) --- .github/workflows/specgit-accept.yml | 11 ++++++----- AGENTS.md | 1 + 2 files changed, 7 insertions(+), 5 deletions(-) diff --git a/.github/workflows/specgit-accept.yml b/.github/workflows/specgit-accept.yml index 20c253211..2c19d41e8 100644 --- a/.github/workflows/specgit-accept.yml +++ b/.github/workflows/specgit-accept.yml @@ -46,11 +46,12 @@ jobs: with: node-version: '20.19' - - name: Install pinned SpecGit CLI - # Exact version on purpose (no ^): the gate must evaluate with the - # same CLI generation that wrote the binding; upgrades are a - # deliberate re-init. --no-save keeps the adopting tree clean. - run: npm install --no-save --no-audit --no-fund specgit@1.8.0 + # Local specialization (survives specgit init --force): the template's + # "npm install --no-save specgit@X" reads this repo's package.json and + # dies on bun's "catalog:" protocol (EUNSUPPORTEDPROTOCOL, #434). Global + # install never touches the workspace manifest. + - name: Install specgit CLI + run: npm install -g specgit@^0.5.0 - name: Wait for sibling checks # The verdict must see the OTHER required checks in a terminal diff --git a/AGENTS.md b/AGENTS.md index cdea59a0d..1287a9e4a 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -267,6 +267,7 @@ This repository uses a multi-context domain-document layout rooted at `CONTEXT-M Kept OUTSIDE the managed block so `specgit init`/`--force` never rewrites them; re-apply each deviation after every re-init: - `specgit-accept.yml` drops the template's `workflow_dispatch` trigger. Dispatch is the privileged context that fires CodeQL's cache-poisoning taint rule on the `head_ref` checkout (false positive: no cache use, read-only token, `persist-credentials: false`), and on dispatch events `head_ref` is empty so the verdict would evaluate the default branch — the wrong tree. Delivery here always goes through a PR. The head-ref checkout itself must NOT be replaced with a SHA: `specgit finish` requires HEAD on the delivery branch (detached_head otherwise). +- `specgit-accept.yml` installs the CLI globally (`npm install -g specgit@^0.5.0`), NOT the template's `npm install --no-save specgit@X`: a workspace-local install reads this bun workspace's package.json and dies on the `catalog:` protocol (EUNSUPPORTEDPROTOCOL, #434, again on #459). Re-apply after every `specgit init --force`. - `spec_git/policy.yaml` `required_checks` uses the template's canonical check IDs (`unit-tests`, `e2e-tests`), not display names. <!-- specgit:block:start --> From 367dd670d504bad217560c1bb14e88f5769c64d5 Mon Sep 17 00:00:00 2001 From: Lex <sunsan05@Gmail.com> Date: Fri, 28 Aug 2026 17:02:11 +0800 Subject: [PATCH 6/6] fix(ci): align specgit-accept with main-line harness (global install, regex policy parse) --- .github/workflows/specgit-accept.yml | 274 ++++----------------------- 1 file changed, 41 insertions(+), 233 deletions(-) diff --git a/.github/workflows/specgit-accept.yml b/.github/workflows/specgit-accept.yml index 2c19d41e8..c280272a4 100644 --- a/.github/workflows/specgit-accept.yml +++ b/.github/workflows/specgit-accept.yml @@ -2,34 +2,23 @@ name: SpecGit Acceptance on: pull_request: + # Delivery PRs target dev (fast-integration layer); the acceptance + # verdict runs only on the dev→main promotion PR, where protect-main's + # checks apply. Keep the trigger main-only (d6ce53a83): running it on + # dev PRs duplicated the verdict against the lighter dev gate. branches: [main] - # A draft PR fails the verdict (pr_draft), so the draft→ready - # transition must re-verdict. Listing types replaces the defaults, - # so the default activity types are listed alongside. - types: [opened, synchronize, reopened, ready_for_review] permissions: contents: read - issues: read - pull-requests: read - -# One verdict per head at a time (#319): a newer trigger event (a push -# after the draft opened, then ready_for_review) supersedes the older -# run of the same pull request instead of leaving parallel copies -# burning identical wait budgets. The surviving run re-verdicts fully. -concurrency: - group: specgit-accept-${{ github.ref }} - cancel-in-progress: true jobs: specgit-acceptance: name: SpecGit Acceptance - # Portable gate for any adopting repository: the published CLI is - # installed at the exact version `specgit init` pinned. The adopting - # project's own toolchain (package manager, lockfile, build, layout) - # is never assumed and never invoked. runs-on: ubuntu-latest - timeout-minutes: 15 + # Must exceed the slowest required sibling (Unit Tests (linux) runs + # ~28min on PRs): the verdict waits for every policy check to reach a + # terminal state before evaluating. + timeout-minutes: 45 steps: - name: Checkout code uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 @@ -44,12 +33,13 @@ jobs: - name: Setup Node.js uses: actions/setup-node@820762786026740c76f36085b0efc47a31fe5020 # v7.0.0 with: - node-version: '20.19' + node-version: '22' - # Local specialization (survives specgit init --force): the template's - # "npm install --no-save specgit@X" reads this repo's package.json and - # dies on bun's "catalog:" protocol (EUNSUPPORTEDPROTOCOL, #434). Global - # install never touches the workspace manifest. + # This repo is a bun workspace and does not vendor the SpecGit CLI; + # install the published CLI instead of building from source. Pinned + # with a caret floor (#366): the CLI releases multiple times a day and + # an unpinned install would let an unnoticed upstream change flip CI + # acceptance verdicts repo-wide. - name: Install specgit CLI run: npm install -g specgit@^0.5.0 @@ -59,233 +49,51 @@ jobs: # their check-runs yet, so an empty poll is not "done": wait until # every name in spec_git/policy.yaml is present with a terminal # conclusion. This job is not in the policy, so no self-deadlock. - # #315: a terminal run only counts when it started at/after the - # delivery's ready-for-review transition — a stale green keeps - # waiting for the fresh run the transition triggers. - # All GitHub access goes through the authenticated gh CLI. env: GH_TOKEN: ${{ github.token }} WAIT_REPO: ${{ github.repository }} - WAIT_SHA: ${{ github.event.pull_request.head.sha || github.sha }} - WAIT_PR: ${{ github.event.pull_request.number || '' }} + WAIT_SHA: ${{ github.event.pull_request.head.sha }} run: | node --input-type=module <<'EOF' - import { existsSync, readFileSync } from 'node:fs'; - import { execFileSync } from 'node:child_process'; - import { parse } from 'yaml'; - if (!existsSync('spec_git/policy.yaml')) { - console.error('spec_git/policy.yaml is absent at this head — an adoption PR carries no binding commit yet (expected once; merge it before enabling branch protection), and a delivery PR must carry it via specgit issue.'); - process.exit(1); - } - const policy = parse(readFileSync('spec_git/policy.yaml', 'utf8')); - const required = policy.required_checks ?? []; - // gh.cmd needs a shell on Windows; POSIX execs the binary - - // directly (shell stays off where it is not needed). The - - // query rides --field args (never a raw "?" URL): cmd.exe - - // treats a bare "&" as a command separator, so a URL query - - // would be split mid-parameter on Windows. - - const listChecks = (page) => - JSON.parse( - execFileSync( - 'gh', - [ - 'api', - 'repos/' + process.env.WAIT_REPO + '/commits/' + process.env.WAIT_SHA + '/check-runs', - '--method', 'GET', - '--field', 'per_page=' + PER_PAGE, - '--field', 'page=' + page, - ], - { encoding: 'utf-8', stdio: ['ignore', 'pipe', 'pipe'], shell: process.platform === 'win32' } - ) - ); - // Transient API failures (5xx, 429, network) retry with bounded - - // exponential backoff — a platform blip must not fail the gate. - - const MAX_ATTEMPTS = 5; - const listChecksWithRetry = async (page) => { - for (let attempt = 1; ; attempt += 1) { - try { - return listChecks(page); - } catch (error) { - const text = String(error) + ' ' + String(error && error.stderr ? error.stderr : ''); - const transient = /HTTP 5\d\d|HTTP 429|ETIMEDOUT|ECONNRESET|ENOTFOUND|timed out/i.test(text); - if (attempt >= MAX_ATTEMPTS || !transient) throw error; - const backoff = Math.min(30000, 2000 * 2 ** (attempt - 1)); - console.log('Transient failure; retry ' + attempt + '/' + MAX_ATTEMPTS + ' in ' + backoff + 'ms'); - await new Promise((r) => setTimeout(r, backoff)); - } - } - }; - // #315: the ready-for-review anchor rides the issue-timeline - // endpoint through gh api --field args (GET, like the listing). - const fetchTimelinePage = (page) => - JSON.parse( - execFileSync( - 'gh', - [ - 'api', - 'repos/' + process.env.WAIT_REPO + '/issues/' + process.env.WAIT_PR + '/timeline', - '--method', 'GET', - '--field', 'per_page=' + PER_PAGE, - '--field', 'page=' + page, - ], - { encoding: 'utf-8', stdio: ['ignore', 'pipe', 'pipe'], shell: process.platform === 'win32' } - ) - ); - const fetchTimelineWithRetry = async (page) => { - for (let attempt = 1; ; attempt += 1) { - try { - const payload = fetchTimelinePage(page); - if (!Array.isArray(payload)) throw new Error('GitHub returned a non-array timeline payload.'); - return payload; - } catch (error) { - if (error && error.message === 'GitHub returned a non-array timeline payload.') throw error; - const text = String(error) + ' ' + String(error && error.stderr ? error.stderr : ''); - const transient = /HTTP 5\d\d|HTTP 429|ETIMEDOUT|ECONNRESET|ENOTFOUND|timed out/i.test(text); - if (attempt >= MAX_ATTEMPTS || !transient) throw error; - const backoff = Math.min(30000, 2000 * 2 ** (attempt - 1)); - console.log('Transient failure; retry ' + attempt + '/' + MAX_ATTEMPTS + ' in ' + backoff + 'ms'); - await new Promise((r) => setTimeout(r, backoff)); - } - } + import { readFileSync } from 'node:fs'; + // Minimal parse of policy.yaml's required_checks block list — + // avoids a yaml dependency in this bun-based repo. + const policy = readFileSync('spec_git/policy.yaml', 'utf8'); + const section = policy.slice(policy.indexOf('required_checks:')); + const required = [...section.matchAll(/^\s*-\s*(.+)$/gm)].map((m) => m[1].trim()); + const headers = { + authorization: 'Bearer ' + process.env.GH_TOKEN, + accept: 'application/vnd.github+json', }; + const url = 'https://api.github.com/repos/' + process.env.WAIT_REPO + + '/commits/' + process.env.WAIT_SHA + '/check-runs?per_page=100'; const terminal = new Set(['completed']); - const PER_PAGE = 100; - // #300: page the listing to exhaustion — a head with more than - - // PER_PAGE check-runs must still expose every required name. - - const fetchAllCheckRuns = async () => { - const runs = []; - for (let page = 1; ; page += 1) { - const payload = await listChecksWithRetry(page); - runs.push(...(payload.check_runs ?? [])); - if (!payload.check_runs || payload.check_runs.length < PER_PAGE) break; - } - return runs; - }; - // #315: the evidence anchor — created_at of the latest - - // ready_for_review event on the pull request's issue timeline, - - // paged to exhaustion through the same transport seam. Empty - - // WAIT_PR (a push or workflow_dispatch event) means no anchor - - // and no freshness bound; a fetch failure fails the step - - // loudly instead of silently unbounding freshness. - - const fetchAnchor = async () => { - if (!process.env.WAIT_PR) return null; - let anchor = null; - let anchorTime = null; - for (let page = 1; ; page += 1) { - const events = await fetchTimelineWithRetry(page); - if (!Array.isArray(events)) throw new Error('GitHub returned a non-array timeline payload.'); - for (const event of events) { - if (event && event.event === 'ready_for_review') { - if (typeof event.created_at !== 'string' || event.created_at === '' - || Number.isNaN(Date.parse(event.created_at))) { - throw new Error('GitHub returned a ready-for-review event without a valid timestamp.'); - } - const eventTime = Date.parse(event.created_at); - if (anchor === null || anchorTime === null || eventTime > anchorTime) { - anchor = event.created_at; - anchorTime = eventTime; - } - } - } - if (!Array.isArray(events) || events.length < PER_PAGE) return anchor; - } + const terminalHas = (byName, name) => { + if (byName.has(name)) return terminal.has(byName.get(name)); + const retried = [...byName.keys()].find((k) => k.startsWith(name + ' (')); + return retried !== undefined && terminal.has(byName.get(retried)); }; - // Poll deadline sits BELOW the job's timeout-minutes (15) on - - // purpose: when the deadline loses the race against a slow - - // sibling, the script exits with its own diagnosis instead of - - // being killed by the job timeout mid-line. - - const deadline = Date.now() + 13 * 60 * 1000; + // Must outlast the slowest required sibling (Unit Tests (linux) + // runs ~28min on PRs); the job timeout above bounds this too. + const deadline = Date.now() + 40 * 60 * 1000; while (Date.now() < deadline) { - // #315: re-read the anchor every cycle — the transition - - // event landing after this job started, or the fresh runs - - // registering late, self-heal on the next poll. - - let anchor; - try { - anchor = await fetchAnchor(); - } catch (error) { - console.error('Could not read the ready-for-review anchor: ' - + (error && error.message ? error.message : String(error))); - process.exit(1); - } - const runs = await fetchAllCheckRuns(); - // #119: re-runs keep every same-name run; terminality is - // decided on the truth run — latest started_at, ties broken - // by the higher check-run id (docs/reference.md) — never on - // response position. - const truth = new Map(); - const startedTime = (run) => { - if (typeof run.started_at !== 'string') return Number.NEGATIVE_INFINITY; - const parsed = Date.parse(run.started_at); - return Number.isNaN(parsed) ? Number.NEGATIVE_INFINITY : parsed; - }; - for (const r of runs) { - const cur = truth.get(r.name); - const runTime = startedTime(r); - const currentTime = cur === undefined ? Number.NEGATIVE_INFINITY : startedTime(cur); - const later = cur === undefined - || runTime > currentTime - || (runTime === currentTime && (r.id || 0) > (cur.id || 0)); - if (later) truth.set(r.name, r); - } - const truthRunFor = (name) => { - if (truth.has(name)) return truth.get(name); - const retried = [...truth.keys()].find((k) => k.startsWith(name + ' (')); - return retried === undefined ? undefined : truth.get(retried); - }; - // #315: a required check settles only when its truth run is - // terminal AND (when an anchor exists) started at/after the - // ready-for-review transition — a stale green keeps waiting. - const missing = []; - const stale = []; - const anchorTime = anchor === null ? null : Date.parse(anchor); - for (const name of required) { - const run = truthRunFor(name); - if (run === undefined || !terminal.has(run.status)) { - missing.push(name); - } else if (anchorTime !== null && (Number.isNaN(anchorTime) || startedTime(run) < anchorTime)) { - stale.push(name); - } - } - if (missing.length === 0 && stale.length === 0) { + const res = await fetch(url, { headers }); + if (!res.ok) throw new Error('check-runs API ' + res.status); + const payload = await res.json(); + const byName = new Map(payload.check_runs.map((r) => [r.name, r.status])); + const missing = required.filter((n) => !terminalHas(byName, n)); + if (missing.length === 0) { console.log('All required checks are in a terminal state.'); process.exit(0); } - if (missing.length > 0) { - console.log('Waiting for: ' + missing.join(', ')); - } - if (stale.length > 0) { - console.log('Waiting for a fresh run after ready for review: ' + stale.join(', ')); - } + console.log('Waiting for: ' + missing.join(', ')); await new Promise((r) => setTimeout(r, 10000)); } console.error('Timed out waiting for sibling checks.'); process.exit(1); EOF - - name: specgit finish - run: npx --no-install specgit finish --json + run: specgit finish --json env: GH_TOKEN: ${{ github.token }}