diff --git a/.specgit.yaml b/.specgit.yaml index 63d268f986..155f3af9f2 100644 --- a/.specgit.yaml +++ b/.specgit.yaml @@ -1,8 +1,8 @@ version: 1 -delivery: event-idempotency-gate +delivery: specgit-bootstrap-wrapper context: kind: branch - branch: fix/523-event-idempotency-gate + branch: feat/521-specgit-bootstrap-wrapper issues: - - 523 -pr: 527 + - 521 +pr: 532 diff --git a/AGENTS.md b/AGENTS.md index 3a60205123..a4183a4032 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -41,7 +41,7 @@ feat/**, fix/** ──PR(Typecheck + Unit Tests 门禁)──▶ dev ──push 新功能开发、Debug 等一切交付范畴恒定走此循环;后续所有工作必须遵守该方案,不得另起流程: 1. **确立条目**:明确条目的内容、范围、类型(`feat`/`fix`/…)。一个 issue = 一个可独立验证的 WHY,无法独立验证的先拆分再立项。 -2. **SpecGit 立项**:`specgit issue ` 创建/复用 issues 批次,确立交付分支与草稿 PR 脚手架(`.specgit.yaml` 绑定);立项前先查重,避免同一 WHY 双开。 +2. **SpecGit 立项**:`script/specgit-bootstrap.sh ` 创建/复用 issues 批次,确立交付分支与草稿 PR 脚手架(`.specgit.yaml` 绑定);立项前先查重,避免同一 WHY 双开。wrapper 是 canonical 入口(见 "SpecGit harness local specializations");直跑裸 `specgit issue` 预期被 harness currency gate 以 `harness_stale` (exit 2) 拒绝。 3. **超流执行**:安排 DAG workflow(超流)承载实现——并行开发 + 多角度 Review + 复合(synthesize),其产出作为交付证据基线。 4. **PR 过门禁**:SpecGit 发起/推进 PR,过 TDD 与 CI 门禁(Typecheck、Unit Tests、DAG gate;`specgit finish` exit 0 是唯一 "done")。 5. **修复门禁问题**:门禁失败在交付分支修代码/测试,永远不削弱门禁本身。 @@ -273,6 +273,15 @@ Kept OUTSIDE the managed block so `specgit init`/`--force` never rewrites them; - The wait script hand-parses `spec_git/policy.yaml` (minimal line-based parse) instead of importing the `yaml` package: no root-reachable `yaml` exists under workspace catalog isolation, so `import { parse } from 'yaml'` would fail to resolve on the runner. - `spec_git/policy.yaml` `required_checks` uses the template's canonical check IDs (`unit-tests`, `e2e-tests`), not display names. +#### specgit-bootstrap wrapper (canonical `specgit issue` entry, #521) + +`script/specgit-bootstrap.sh ` is THE canonical way to run `specgit issue` in this repository. Bare `specgit issue` is expected to fail with `harness_stale` (exit 2) whenever the pinned CLI's harness template moves — the wrapper satisfies that gate safely: it snapshots the full init write surface to a temp dir outside the repo, runs `specgit init --force --no-protect` (hardcoded, offline), then `specgit issue "$@"` with arguments, exit status, and diagnostics passed through verbatim, and restores the specialized bytes above on success and every failure path (EXIT/INT/TERM/HUP), verifying each file byte-for-byte via `git hash-object`. + +- Never run bare `specgit init --force` here: it overwrites the six specialized bytes; the wrapper exists to make that refresh transient. +- Fail-closed rejections: dirty write-surface paths (tracked/staged/untracked) → exit 2 with the offending paths listed; no SpecGit binding (`.specgit.yaml` or `spec_git/policy.yaml` missing) → exit 3; restore hash mismatch → exit 3 with the snapshot kept for forensics. Rejection paths print plain `specgit-bootstrap:` stderr lines and NEVER produce a `--json` envelope. +- The inner `.specgit.yaml` written by `specgit issue` is a legitimate binding artifact and is never rolled back. +- Managed-block guidance referencing bare `specgit issue` commands is superseded by this section for this repository. Behavior tests: `bash script/specgit-bootstrap.test.sh` (stubbed CLI, zero network; not CI-wired). + ## SpecGit delivery harness diff --git a/script/specgit-bootstrap.sh b/script/specgit-bootstrap.sh new file mode 100755 index 0000000000..72ec0ebe4b --- /dev/null +++ b/script/specgit-bootstrap.sh @@ -0,0 +1,150 @@ +#!/bin/sh +# specgit-bootstrap — repository-local fail-safe wrapper around `specgit issue` (#521). +# +# Why: `specgit issue` (1.10.1) runs an unconditional harness-currency gate that +# exits 2 (`harness_stale`) unless the managed harness was refreshed by +# `specgit init --force`. But `init --force` overwrites this repository's six +# hand-applied specializations (see AGENTS.md, "SpecGit harness local +# specializations"). This wrapper makes the refresh safe: +# +# 1. refuses to run when any init write-surface path has uncommitted changes +# (tracked, staged, or untracked), or when the repo has no SpecGit binding; +# 2. snapshots every existing write-surface path to a temp directory OUTSIDE +# the repository, recording each file's `git hash-object` content hash; +# 3. runs `specgit init --force --no-protect` (hardcoded, offline; init's +# stdout prose is routed to stderr so a wrapped `--json` call's stdout +# stays exactly one JSON document) then `specgit issue "$@"` with all +# arguments preserved verbatim and stdin/stdout/stderr inherited; +# 4. restores the snapshots on every exit path (EXIT/INT/TERM/HUP) and +# verifies each restored file byte-for-byte against the recorded hash; +# any mismatch is reported loudly and exits 3. +# +# The `.specgit.yaml` binding written/updated by the inner `specgit issue` is a +# legitimate delivery artifact and is never rolled back. Wrapper rejections +# print plain stderr lines prefixed `specgit-bootstrap:` — never a `--json` +# envelope; only the inner CLI receives the wrapped arguments. +# +# Usage: script/specgit-bootstrap.sh +# +# Write surface below mirrors specgit 1.10.1 harness-placement; it is +# version-coupled to the pinned CLI in .github/workflows/specgit-accept.yml. + +set -u + +SURFACE=' +.github/workflows/specgit-accept.yml +AGENTS.md +CLAUDE.md +.opencode/hooks.json +.opencode/hooks/specgit-merge-guard.sh +.git/hooks/pre-push +.husky/_/pre-push +' + +say() { + printf 'specgit-bootstrap: %s\n' "$1" >&2 +} + +REPO=$(git rev-parse --show-toplevel 2>/dev/null) || { + say "not inside a git repository" + exit 3 +} +cd "$REPO" || exit 3 + +# Fail-closed: serve only bound delivery repositories; a fresh repo has no +# specializations to protect, so bare `specgit issue` is fine there. +if [ ! -f .specgit.yaml ] || [ ! -f spec_git/policy.yaml ]; then + say "no SpecGit binding (.specgit.yaml / spec_git/policy.yaml missing) - run bare 'specgit issue' instead" + exit 3 +fi + +# Fail-closed: ambiguous pre-existing changes on the write surface could be +# clobbered by init and could not be told apart from init's own writes. +# shellcheck disable=SC2086 +dirty=$(git status --porcelain -- $SURFACE) +if [ -n "$dirty" ]; then + say "refusing to run - init write-surface paths have uncommitted changes (inner CLI NOT executed):" + printf '%s\n' "$dirty" | sed 's/^/ /' >&2 + say "commit or stash those changes first, then retry" + exit 2 +fi + +SNAP=$(mktemp -d "${TMPDIR:-/tmp}/specgit-bootstrap.XXXXXX") || { + say "cannot create snapshot directory under \${TMPDIR:-/tmp}" + exit 3 +} +mkdir "$SNAP/tree" "$SNAP/hashes" || { + rm -rf "$SNAP" + say "cannot prepare snapshot directory layout" + exit 3 +} + +# shellcheck disable=SC2086 +for rel in $SURFACE; do + [ -f "$rel" ] || continue + mkdir -p "$SNAP/tree/$(dirname "$rel")" "$SNAP/hashes/$(dirname "$rel")" || { + rm -rf "$SNAP" + say "cannot stage snapshot for $rel" + exit 3 + } + cp "$rel" "$SNAP/tree/$rel" || { + rm -rf "$SNAP" + say "snapshot copy failed for $rel" + exit 3 + } + git hash-object -- "$rel" > "$SNAP/hashes/$rel" || { + rm -rf "$SNAP" + say "content hash failed for $rel" + exit 3 + } +done + +RESTORED=0 + +# Idempotent restore + byte verification. On mismatch the snapshot directory +# is KEPT for forensics and the wrapper exits 3 (fail-closed, aligning with +# the CLI's exit contract for "cannot proceed"). +restore_all() { + [ "$RESTORED" -eq 1 ] && return 0 + RESTORED=1 + mismatched=0 + # shellcheck disable=SC2086 + for rel in $SURFACE; do + [ -f "$SNAP/tree/$rel" ] || continue + cp "$SNAP/tree/$rel" "$rel" + now=$(git hash-object -- "$rel" 2>/dev/null) + want=$(cat "$SNAP/hashes/$rel" 2>/dev/null) + if [ "$now" != "$want" ]; then + printf 'specgit-bootstrap: RESTORE MISMATCH for %s (got %s, expected %s)\n' \ + "$rel" "${now:-}" "${want:-}" >&2 + mismatched=1 + fi + done + if [ "$mismatched" -eq 1 ]; then + say "restored bytes differ from pre-run snapshots - specialized harness bytes may be corrupted." + say "snapshot kept for forensics at $SNAP; inspect 'git diff' before continuing." + trap - EXIT + exit 3 + fi + rm -rf "$SNAP" +} + +trap 'restore_all' EXIT +trap 'restore_all; exit 129' HUP +trap 'restore_all; exit 130' INT +trap 'restore_all; exit 143' TERM + +# init's stdout prose must not pollute the wrapped --json parse surface. +specgit init --force --no-protect >&2 +init_status=$? +if [ "$init_status" -ne 0 ]; then + say "specgit init --force --no-protect failed (exit $init_status); restoring harness bytes" + restore_all + exit "$init_status" +fi + +# All arguments pass through verbatim; exit status and diagnostics inherit. +specgit issue "$@" +issue_status=$? +restore_all +exit "$issue_status" diff --git a/script/specgit-bootstrap.test.sh b/script/specgit-bootstrap.test.sh new file mode 100755 index 0000000000..ddb600c947 --- /dev/null +++ b/script/specgit-bootstrap.test.sh @@ -0,0 +1,271 @@ +#!/usr/bin/env bash +# shellcheck disable=SC2015,SC2329 +# ok/bad always return 0, so `cond && ok .. || bad ..` cannot mis-fire (SC2015); +# cleanup() runs via the EXIT trap, which shellcheck does not count (SC2329). +# Behavior tests for script/specgit-bootstrap.sh (#521). +# +# Zero network, zero forge: `specgit` is a stub placed first on PATH; every +# fixture is a throwaway git repo under $TMPDIR. The real repository is never +# touched: wrapper invocations run with cwd set to the fixture, and all stub +# artifacts (log, captured output) live outside the fixture worktree. +# +# Not wired into CI (#521 scope): run manually from anywhere via +# bash script/specgit-bootstrap.test.sh +set -u + +ROOT=$(cd "$(dirname "$0")/.." && pwd) +WRAPPER="$ROOT/script/specgit-bootstrap.sh" +WORK=$(mktemp -d "${TMPDIR:-/tmp}/specgit-bootstrap-test.XXXXXX") +PASS=0 +FAIL=0 + +cleanup() { rm -rf "$WORK"; } +trap cleanup EXIT + +if command -v sha256sum >/dev/null 2>&1; then + digest() { sha256sum "$1" | cut -d' ' -f1; } +else + digest() { shasum -a 256 "$1" | cut -d' ' -f1; } +fi + +SURFACE_FILES=( + .github/workflows/specgit-accept.yml + AGENTS.md + .opencode/hooks.json + .opencode/hooks/specgit-merge-guard.sh + .git/hooks/pre-push +) + +ok() { PASS=$((PASS + 1)); printf 'ok - %s\n' "$1"; } +bad() { FAIL=$((FAIL + 1)); printf 'FAIL - %s\n' "$1"; } + +surface_digests() { # + local fx="$1" f + for f in "${SURFACE_FILES[@]}"; do + printf '%s %s\n' "$(digest "$fx/$f")" "$f" + done +} + +assert_surface_restored() { # + diff <(surface_digests "$1") "$2" >/dev/null +} + +assert_clean() { # + [ -z "$(git -C "$1" status --porcelain)" ] +} + +assert_rc() { #