From 4b2f6e5803230142fa4255898eae83888ad8c22e Mon Sep 17 00:00:00 2001 From: Lex Date: Tue, 1 Sep 2026 23:09:40 +0800 Subject: [PATCH 1/5] chore: record delivery binding for specgit-harness --- .specgit.yaml | 7 +++++++ spec_git/policy.yaml | 3 +++ 2 files changed, 10 insertions(+) create mode 100644 .specgit.yaml create mode 100644 spec_git/policy.yaml diff --git a/.specgit.yaml b/.specgit.yaml new file mode 100644 index 0000000..70a46b8 --- /dev/null +++ b/.specgit.yaml @@ -0,0 +1,7 @@ +version: 1 +delivery: specgit-harness +context: + kind: branch + branch: feat/17-specgit-harness +issues: + - 17 diff --git a/spec_git/policy.yaml b/spec_git/policy.yaml new file mode 100644 index 0000000..fb1c960 --- /dev/null +++ b/spec_git/policy.yaml @@ -0,0 +1,3 @@ +version: 1 +required_checks: + - validate From c4f77cab5777c3dbffee5e2da4985f7d36a0e2bb Mon Sep 17 00:00:00 2001 From: Lex Date: Tue, 1 Sep 2026 23:09:49 +0800 Subject: [PATCH 2/5] chore: record delivery binding for specgit-harness --- .specgit.yaml | 1 + 1 file changed, 1 insertion(+) diff --git a/.specgit.yaml b/.specgit.yaml index 70a46b8..0fa4b5b 100644 --- a/.specgit.yaml +++ b/.specgit.yaml @@ -5,3 +5,4 @@ context: branch: feat/17-specgit-harness issues: - 17 +pr: 20 From c6b9f0aac1df270e7838b5c50a9d85a30b49c913 Mon Sep 17 00:00:00 2001 From: Lex Date: Tue, 1 Sep 2026 23:12:18 +0800 Subject: [PATCH 3/5] chore: record delivery binding for specgit-harness --- .specgit.yaml | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/.specgit.yaml b/.specgit.yaml index 0fa4b5b..9c8ce01 100644 --- a/.specgit.yaml +++ b/.specgit.yaml @@ -2,7 +2,7 @@ version: 1 delivery: specgit-harness context: kind: branch - branch: feat/17-specgit-harness + branch: chore/17-specgit-harness issues: - 17 -pr: 20 +pr: 21 From d833f2effaa76496abcb2b38c3ced79bf2ee85f5 Mon Sep 17 00:00:00 2001 From: Lex Date: Tue, 1 Sep 2026 23:12:20 +0800 Subject: [PATCH 4/5] chore: adopt SpecGit harness for validate check --- .github/workflows/specgit-accept.yml | 291 +++++++++++++++++++++++++ .gitignore | 4 + .opencode/hooks.json | 14 ++ .opencode/hooks/specgit-merge-guard.sh | 175 +++++++++++++++ AGENTS.md | 110 ++++++++++ 5 files changed, 594 insertions(+) create mode 100644 .github/workflows/specgit-accept.yml create mode 100644 .gitignore create mode 100644 .opencode/hooks.json create mode 100755 .opencode/hooks/specgit-merge-guard.sh create mode 100644 AGENTS.md diff --git a/.github/workflows/specgit-accept.yml b/.github/workflows/specgit-accept.yml new file mode 100644 index 0000000..6e4a2cb --- /dev/null +++ b/.github/workflows/specgit-accept.yml @@ -0,0 +1,291 @@ +name: SpecGit Acceptance + +on: + pull_request: + 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] + workflow_dispatch: + +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 + steps: + - name: Checkout code + uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 + with: + # Check out the PR head branch by name so HEAD is on the branch + # (not the detached merge ref): the execution context gate reads + # live git. Falls back to the default ref on non-PR events. + ref: ${{ github.head_ref || github.ref }} + fetch-depth: 0 + persist-credentials: false + + - name: Setup Node.js + uses: actions/setup-node@820762786026740c76f36085b0efc47a31fe5020 # v7.0.0 + 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.10.1 + + - name: Wait for sibling checks + # The verdict must see the OTHER required checks in a terminal + # state. Sibling jobs start in parallel AND may not have registered + # 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 || '' }} + 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)); + } + } + }; + 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; + } + }; + // 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) { + // #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); + } + 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: npx --no-install specgit finish --json + env: + GH_TOKEN: ${{ github.token }} diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..2bd6a10 --- /dev/null +++ b/.gitignore @@ -0,0 +1,4 @@ +# >>> specgit: local delivery assets (managed by specgit init) >>> +/.specgit.yaml +/spec_git/ +# <<< specgit: local delivery assets (managed by specgit init) <<< diff --git a/.opencode/hooks.json b/.opencode/hooks.json new file mode 100644 index 0000000..6720938 --- /dev/null +++ b/.opencode/hooks.json @@ -0,0 +1,14 @@ +{ + "PreToolUse": [ + { + "matcher": "Bash|Edit|Write", + "hooks": [ + { + "type": "command", + "command": ".opencode/hooks/specgit-merge-guard.sh", + "timeout": 600 + } + ] + } + ] +} diff --git a/.opencode/hooks/specgit-merge-guard.sh b/.opencode/hooks/specgit-merge-guard.sh new file mode 100755 index 0000000..01981b8 --- /dev/null +++ b/.opencode/hooks/specgit-merge-guard.sh @@ -0,0 +1,175 @@ +#!/bin/sh +# 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. +if [ -n "$1" ]; then + payload=$1 +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 + gh\ pr\ merge*|glab\ mr\ merge*) + exec node -e ' + const { spawn } = require("child_process"); + const fs = require("fs"); + const path = require("path"); + const ghMsRaw = parseInt(process.env.SPECGIT_GH_TIMEOUT_MS || "", 10); + const ghMs = Number.isFinite(ghMsRaw) && ghMsRaw > 0 ? ghMsRaw : 15000; + const ghS = Math.max(1, Math.floor(ghMs / 1000)); + let budgetS = Math.max(60, ghS * 8); + const overrideRaw = parseInt(process.env.SPECGIT_GUARD_BUDGET_S || "", 10); + if (Number.isFinite(overrideRaw) && overrideRaw > 0) { + budgetS = Math.max(overrideRaw, ghS); + } + // The hook runner kills long hooks; surface the mismatch instead of + // being cut off mid-verdict. + try { + const hooks = JSON.parse( + fs.readFileSync(path.join(process.env.GUARD_DIR || ".", "..", "hooks.json"), "utf8") + ); + const runner = (hooks.PreToolUse || []) + .flatMap((entry) => entry.hooks || []) + .map((hook) => hook.timeout) + .find((timeout) => typeof timeout === "number"); + if (runner !== undefined && runner - 10 < budgetS) { + console.error( + "specgit: guard budget " + budgetS + "s exceeds the hook runner timeout " + + runner + "s in .opencode/hooks.json - raise the runner timeout or lower SPECGIT_GUARD_BUDGET_S." + ); + } + } catch {} + const cp = require("child_process"); + const isWin = process.platform === "win32"; + // Windows: cmd.exe cannot exec an extensionless sh shim, so prefer + // git-bash sh when present; only then fall back to shell mode. + let child; + if (isWin) { + const probe = cp.spawnSync("sh", ["-c", "exit 0"]); + if (probe.status === 0) { + child = spawn("sh", ["-c", "specgit finish --json"], { + stdio: ["ignore", "pipe", "pipe"], + }); + } + } + if (!child) { + child = spawn("specgit", ["finish", "--json"], { + shell: isWin, + stdio: ["ignore", "pipe", "pipe"], + }); + } + let out = ""; + let err = ""; + let expired = false; + child.stdout.on("data", (chunk) => (out += chunk)); + child.stderr.on("data", (chunk) => (err += chunk)); + const timer = setTimeout(() => { + expired = true; + // Bound the wait strictly: descendants may inherit the pipes, so + // destroy them and exit now — never lag behind orphaned children. + child.stdout.destroy(); + child.stderr.destroy(); + child.kill("SIGKILL"); + console.error( + "specgit: merge blocked - guard budget " + budgetS + "s exhausted before a verdict. This says nothing about the delivery; run specgit finish directly for the full verdict." + ); + process.exit(2); + }, budgetS * 1000); + child.on("error", (error) => { + clearTimeout(timer); + console.error( + "specgit: merge blocked - the verdict could not run (" + error.message + "). Install specgit on PATH, then retry the merge." + ); + process.exit(2); + }); + child.on("close", (code) => { + clearTimeout(timer); + if (expired) { + process.exit(2); + } + if (code === 0) { + process.exit(0); + } + let envelope = null; + try { + envelope = JSON.parse(out); + } catch {} + const verdict = envelope && envelope.verdict; + const gates = (envelope && (envelope.gates || (verdict && verdict.gates))) || []; + const failures = []; + for (const gate of gates) { + for (const failure of (gate && gate.failures) || []) failures.push(failure); + } + const label = (failure, suffix) => { + const detail = failure.detail || {}; + const name = detail.name || failure.code; + const state = suffix || detail.status || detail.conclusion || ""; + return name + (state ? " [" + state + "]" : ""); + }; + const pending = failures.filter((f) => f.code === "checks_pending"); + const failed = failures.filter((f) => f.code === "checks_failed"); + const other = failures.filter( + (f) => f.code !== "checks_pending" && f.code !== "checks_failed" + ); + const lines = []; + if (code === 1) { + lines.push( + "specgit: merge blocked - verdict rejected (exit 1). Fix what the failures name; never weaken spec_git/policy.yaml to pass." + ); + } else { + lines.push( + "specgit: merge blocked - no verdict possible (evidence incomplete, exit " + code + "). This is not a rejection: fix evidence gathering (network, gh auth), then retry." + ); + } + if (pending.length > 0) { + lines.push( + " pending (transient - wait, then re-run): " + pending.map((f) => label(f)).join(", ") + ); + } + if (failed.length > 0) { + lines.push( + " failed (repair required): " + + failed + .map((f) => + label( + f, + f.detail && f.detail.conclusion === "action_required" + ? "action_required - run awaits maintainer approval" + : undefined + ) + ) + .join(", ") + ); + } + if (other.length > 0) { + lines.push(" other failures: " + other.map((f) => label(f)).join(", ")); + } + lines.push("Full verdict: specgit finish"); + console.error(lines.join("\n")); + process.exit(2); + }); + ' + ;; + git\ push\ origin\ main*|git\ push\ origin\ +main*|git\ push\ origin\ HEAD:main*) + echo "specgit: direct push to main is not the delivery path. Deliveries go: specgit issue -> PR -> CI -> specgit finish (exit 0) -> merge." >&2 + exit 2 + ;; +esac +exit 0 diff --git a/AGENTS.md b/AGENTS.md new file mode 100644 index 0000000..71a13df --- /dev/null +++ b/AGENTS.md @@ -0,0 +1,110 @@ +<!-- specgit:block:start --> +## SpecGit delivery harness + +Managed by `specgit init`. Everything between the markers is regenerated +whenever init writes the harness (a fresh init, or `--force` when a policy +already exists); keep manual guidance outside them. + +### The delivery story + +- Start with `specgit issue <title-or-number>...`: it creates or reuses + the issues, branches, opens the draft pull request pre-filled with a + 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. +- 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 + auto-discovers the pull request for this head branch, errors with a fix + when none is found, and refuses with a list when several match. +- `specgit status` shows local evidence only: record, state, drift, + origin. `specgit doctor` probes git, repository, origin, gh, and + policy. + +### The command surface + +- Ten commands: `specgit init`, `specgit setup`, `specgit issue`, + `specgit pr`, `specgit finish`, `specgit bind`, `specgit unbind`, + `specgit status`, `specgit accept`, `specgit doctor`. +- `specgit setup` installs the agent entry points (commands for opencode, + portable skills for other tools); `specgit bind`, `specgit unbind`, + and `specgit accept` are automation aliases for scripts and CI. + +### Before creating an issue, check for duplicates + +- Before running `specgit issue` with a new title, search the tracker for + similar open work: `gh issue list` with keywords from the title + (state, labels, and search terms via `gh search issues`). +- Open and read every plausible candidate (`gh issue view <n>`) — compare + the WHY, not just the wording. +- If a candidate covers the same WHY, continue that issue instead of + creating a new one; if it is close but different, say how they differ. +- When unsure, ask the requester to decide between continuing the existing + issue and creating a duplicate. The team ships one line of work per WHY, + never two. + +### Issue granularity + +One issue = one independently verifiable WHY. If a deliverable cannot be +verified on its own evidence, split it before binding. + +### Iron rules + +- `specgit finish` exit code other than 0: never request merge. Fix the + delivery, not the gate. +- 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 aa825ccbb97bcb7717d9cecf3dda58467c4f1664 Mon Sep 17 00:00:00 2001 From: Lex <sunsan05@Gmail.com> Date: Tue, 1 Sep 2026 23:23:06 +0800 Subject: [PATCH 5/5] fix: ignore hidden dotfiles in route catalog inventory --- script/validate-route-catalog.test.ts | 18 ++++++++++++++++++ script/validate-route-catalog.ts | 3 +++ 2 files changed, 21 insertions(+) diff --git a/script/validate-route-catalog.test.ts b/script/validate-route-catalog.test.ts index 2b9c1cb..24d1dbd 100644 --- a/script/validate-route-catalog.test.ts +++ b/script/validate-route-catalog.test.ts @@ -80,6 +80,24 @@ describe("route catalog guardrails", () => { "must not select another route or control action inside a child block", ) }) + + test("ignores hidden delivery dotfiles when checking the catalog", async () => { + await Bun.write(join(fixture, ".specgit.yaml"), "version: 1\n") + + const result = await validate() + expect(result.exitCode).toBe(0) + }) + + test("still rejects a visible yaml outside the catalog", async () => { + await Bun.write( + join(fixture, "extra-route.yaml"), + "config:\n name: extra-route\n", + ) + + const result = await validate() + expect(result.exitCode).toBe(1) + expect(result.stderr).toContain("Route catalog must contain exactly") + }) }) async function rewrite(file: string, before: string, after: string) { diff --git a/script/validate-route-catalog.ts b/script/validate-route-catalog.ts index 7938d84..4ad5f44 100644 --- a/script/validate-route-catalog.ts +++ b/script/validate-route-catalog.ts @@ -21,6 +21,9 @@ const crossDomainRoutes = ["release-route.yaml", "ultra-flow-route.yaml"] const catalog = [...expected, ...crossDomainRoutes].sort() const routeNames = catalog.map((file) => path.basename(file, ".yaml")) const files = (await fs.readdir(root)) + // Hidden dotfiles are delivery tooling (e.g. the SpecGit `.specgit.yaml` + // binding), never route templates; the catalog inventory is over visible files. + .filter((file) => !file.startsWith(".")) .filter((file) => /\.ya?ml$/i.test(file)) .sort()