diff --git a/.github/docker/Dockerfile.ci b/.github/docker/Dockerfile.ci index 841291cca..1ed8c693d 100644 --- a/.github/docker/Dockerfile.ci +++ b/.github/docker/Dockerfile.ci @@ -193,6 +193,21 @@ RUN for i in 1 2 3; do \ && fc-cache -f \ && rm -rf /var/lib/apt/lists/* +# ffmpeg (with ffprobe): /design-motion's checks and exports shell out to it +# (lib/motion-encode.ts), and the marathon design-motion-showcase case runs +# that flow end to end. The image had none, so every trial stopped at +# FFMPEG_MISSING (#3687). The build fails if the packaged ffmpeg ever lacks a +# filter or encoder the checks and exports use. +RUN for i in 1 2 3; do \ + apt-get update && apt-get install -y --no-install-recommends ffmpeg && break || \ + (echo "ffmpeg install retry $i/3"; sleep 10); \ + done \ + && rm -rf /var/lib/apt/lists/* \ + && command -v ffprobe \ + && filters="$(ffmpeg -hide_banner -filters)" && encoders="$(ffmpeg -hide_banner -encoders)" \ + && for f in psnr gblur palettegen paletteuse signalstats tile; do echo "$filters" | grep -qw "$f" || { echo "ERROR: ffmpeg lacks the $f filter" >&2; exit 1; }; done \ + && for e in libx264 gif; do echo "$encoders" | grep -qw "$e" || { echo "ERROR: ffmpeg lacks the $e encoder" >&2; exit 1; }; done + # Pre-install dependencies (cached layer — only rebuilds when package.json or # bun.lock changes). Copy BOTH so install is deterministic and matches local # resolution. Without bun.lock here, bun install resolved transitive deps diff --git a/.github/workflows/free-tests.yml b/.github/workflows/free-tests.yml index 966419ad3..0d3cff19e 100644 --- a/.github/workflows/free-tests.yml +++ b/.github/workflows/free-tests.yml @@ -41,13 +41,21 @@ on: description: 'Runner-sizing experiments only: shard count, 1-40 (empty keeps FREE_SUITE_SHARDS)' type: string default: '' + free_jobs: + description: 'Runner-sizing experiments only: sub-shards per CI shard (--shard-jobs), 1-8 (empty keeps FREE_SUITE_JOBS)' + type: string + default: '' -# The one source of the free-suite runner and shard count. pull_request and -# push runs never see workflow_dispatch inputs, so free-plan resolves these -# (or a dispatch override) into outputs that free-suite reads. +# The one source of the free-suite runner, shard count and sub-shards per +# shard. pull_request and push runs never see workflow_dispatch inputs, so +# free-plan resolves these (or a dispatch override) into outputs and the plan +# that free-suite reads. FREE_SUITE_JOBS is k in `--ci-plan --shard-jobs k`: +# each shard runs min(k, its parallel files) sandboxed sub-shards side by side +# (scripts/lib/shard-jobs.ts); the exclusive host-state shard stays one child. env: - FREE_SUITE_RUNNER: ubicloud-standard-4 - FREE_SUITE_SHARDS: '20' + FREE_SUITE_RUNNER: ubicloud-standard-8 + FREE_SUITE_SHARDS: '10' + FREE_SUITE_JOBS: '4' # Keyed on the PR number, not head_ref: a bare branch name carries no fork # prefix, so same-name branches from two forks would share one group and a @@ -120,10 +128,12 @@ jobs: env: RUNNER_OVERRIDE: ${{ inputs.free_runner }} SHARDS_OVERRIDE: ${{ inputs.free_shards }} + JOBS_OVERRIDE: ${{ inputs.free_jobs }} run: | set -euo pipefail runner="${RUNNER_OVERRIDE:-$FREE_SUITE_RUNNER}" shards="${SHARDS_OVERRIDE:-$FREE_SUITE_SHARDS}" + jobs="${JOBS_OVERRIDE:-$FREE_SUITE_JOBS}" case "$runner" in ubicloud-standard-2|ubicloud-standard-4|ubicloud-standard-8) ;; *) echo "::error::free_runner '$runner' is not ubicloud-standard-2, -4 or -8"; exit 1 ;; @@ -132,10 +142,14 @@ jobs: ''|*[!0-9]*) echo "::error::free_shards '$shards' is not a whole number"; exit 1 ;; esac if [ "$shards" -lt 1 ] || [ "$shards" -gt 40 ]; then echo "::error::free_shards $shards is outside 1-40"; exit 1; fi - if [ "$runner" != "$FREE_SUITE_RUNNER" ] || [ "$shards" != "$FREE_SUITE_SHARDS" ]; then - echo "- Runner-sizing experiment: $shards shards on $runner (defaults $FREE_SUITE_SHARDS on $FREE_SUITE_RUNNER); not a PR or train Free Tests result" >> "$GITHUB_STEP_SUMMARY" + case "$jobs" in + ''|*[!0-9]*) echo "::error::free_jobs '$jobs' is not a whole number"; exit 1 ;; + esac + if [ "$jobs" -lt 1 ] || [ "$jobs" -gt 8 ]; then echo "::error::free_jobs $jobs is outside 1-8"; exit 1; fi + if [ "$runner" != "$FREE_SUITE_RUNNER" ] || [ "$shards" != "$FREE_SUITE_SHARDS" ] || [ "$jobs" != "$FREE_SUITE_JOBS" ]; then + echo "- Runner-sizing experiment: $shards shards on $runner at $jobs sub-shards each (defaults $FREE_SUITE_SHARDS on $FREE_SUITE_RUNNER at $FREE_SUITE_JOBS); not a PR or train Free Tests result" >> "$GITHUB_STEP_SUMMARY" fi - matrix=$(bun run scripts/test-free-shards.ts --ci-plan "$RUNNER_TEMP/free-plan.json" --shards "$shards") + matrix=$(bun run scripts/test-free-shards.ts --ci-plan "$RUNNER_TEMP/free-plan.json" --shards "$shards" --shard-jobs "$jobs") echo "matrix=$matrix" >> "$GITHUB_OUTPUT" echo "runner=$runner" >> "$GITHUB_OUTPUT" - name: Seed growth ratchet (60 s ceiling, shrink-only allowlist) @@ -186,7 +200,8 @@ jobs: # code that compares a realpath with an unresolved path passes on Linux's # /tmp and fails here (#2982). TMPDIR is deliberately left at the runner's # default, and the job fails if that layout is not symlinked. It also sources - # the Codex probe from zsh, macOS's default shell (#3024; no paid call). + # the Codex probe from zsh, macOS's default shell (#3024; no paid call), and + # runs ubi-runner under macOS's bsdtar and one-true-awk (#3680, #3679). macos-named-regressions: needs: reuse if: ${{ !cancelled() && (needs.reuse.result != 'success' || needs.reuse.outputs.reused != 'true') }} @@ -229,7 +244,7 @@ jobs: resolved="$(realpath "$tmp")" echo "temp root: $tmp -> $resolved" test "$resolved" != "${tmp%/}" - files=(test/gstack-design-detect.test.ts browse/test/chromium-profile-isolation.test.ts test/cso-git-hardening.test.ts test/plan-count-file-permission.test.ts test/spec-quality-gate-secret-sink.test.ts test/gstack-memory-ingest.test.ts test/regression-issue3024-zsh-sourced-helpers.test.ts test/codex-probe-subcommands.test.ts browse/test/proxy-env-daemon.test.ts browse/test/proxy-direct-launch-args.test.ts) + files=(test/gstack-design-detect.test.ts browse/test/chromium-profile-isolation.test.ts test/cso-git-hardening.test.ts test/plan-count-file-permission.test.ts test/spec-quality-gate-secret-sink.test.ts test/gstack-memory-ingest.test.ts test/regression-issue3024-zsh-sourced-helpers.test.ts test/codex-probe-subcommands.test.ts browse/test/proxy-env-daemon.test.ts browse/test/proxy-direct-launch-args.test.ts test/regression-issue3680-ubicloud-bsd-userland.test.ts) for file in "${files[@]}"; do test -f "$file"; done bun test "${files[@]}" diff --git a/.github/workflows/windows-free-tests.yml b/.github/workflows/windows-free-tests.yml index 6146e927d..d94d1d6f2 100644 --- a/.github/workflows/windows-free-tests.yml +++ b/.github/workflows/windows-free-tests.yml @@ -42,6 +42,10 @@ on: description: Record per-file Windows durations (artifact) instead of running the lane type: boolean default: false + windows_jobs: + description: 'Sub-shard experiments only: sub-shards per Windows shard (--shard-jobs), 1-4 (empty keeps WINDOWS_FREE_JOBS). Never cite such a run as a PR result.' + type: string + default: '' # free-durations-record.yml calls the recording path weekly. workflow_call: inputs: @@ -49,6 +53,11 @@ on: type: boolean default: false +# k in `--ci-plan --shard-jobs k`: each Windows shard runs min(k, its parallel +# files) sandboxed sub-shards side by side (scripts/lib/shard-jobs.ts). +env: + WINDOWS_FREE_JOBS: '1' + concurrency: group: windows-free-${{ github.event.pull_request.number || github.run_id }} cancel-in-progress: true @@ -105,8 +114,18 @@ jobs: # 4-vCPU Windows runner, so the lane lands near 4 min with setup. env: GSTACK_FREE_TEST_DURATIONS: scripts/free-test-durations-windows.json + JOBS_OVERRIDE: ${{ inputs.windows_jobs }} run: | - matrix=$(bun run scripts/test-free-shards.ts --windows-only --ci-plan "$RUNNER_TEMP/windows-plan.json" --shards 6) + set -euo pipefail + jobs="${JOBS_OVERRIDE:-$WINDOWS_FREE_JOBS}" + case "$jobs" in + ''|*[!0-9]*) echo "::error::windows_jobs '$jobs' is not a whole number"; exit 1 ;; + esac + if [ "$jobs" -lt 1 ] || [ "$jobs" -gt 4 ]; then echo "::error::windows_jobs $jobs is outside 1-4"; exit 1; fi + if [ "$jobs" != "$WINDOWS_FREE_JOBS" ]; then + echo "- Sub-shard experiment: $jobs sub-shards per Windows shard (default $WINDOWS_FREE_JOBS); not a PR Windows Free Tests result" >> "$GITHUB_STEP_SUMMARY" + fi + matrix=$(bun run scripts/test-free-shards.ts --windows-only --ci-plan "$RUNNER_TEMP/windows-plan.json" --shards 6 --shard-jobs "$jobs") echo "matrix=$matrix" >> "$GITHUB_OUTPUT" - uses: actions/upload-artifact@cf430e030ddbb5b0abf93d22962f4752f3646cd9 # v7 with: diff --git a/CHANGELOG.md b/CHANGELOG.md index 0c95b691a..b89ddb8c9 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,88 @@ # Changelog +## [1.92.83.0] - 2026-11-02 + +**The sealed guard no longer rescans commits the destination already holds, so a stacked branch retargeted after its parent squash-landed stops being refused for the parent's history.** gstack-evals#22 listed its parent #6's seven commits after #6 landed as one squash. One of them had added and later deleted a held-out file, so the guard refused a push whose every byte GitHub already had as `refs/pull/6/head`, and the queue sat blocked for about 50 minutes until a waiver landed. + +### Changed +- **`lib/sealed-guard.ts`** (`evaluateSealed`, shared by the pre-push hook and `gstack-pregate sealed`): a listed commit is skipped when it is an ancestor of the destination's own `refs/pull//head` for a squash `(#n)` on the base since the merge-base (at most 50 refs, a 20-second budget, an egress receipt per remote call, fetched refs deleted afterwards), or when its own `git patch-id --verbatim` and diff equal a base commit's (off above 2,000 base or 400 listed commits; a landed commit still gets the `sealed.label_join` check, which reads the whole file). Nothing is matched against a combined diff, and any failed lookup scans everything with one line saying why. docs/pregate.md#sealed-skip. +- **`gstack-pregate sealed`**: the `SEALED:` line gains `skipped=` (and `commits=` counts the commits scanned), stderr names each skipped group, and `--no-skip` scans every listed commit. + +### Tests +- `test/sealed-skip.test.ts`: the #22 shape is clear with seven commits skipped as `refs/pull/6/head`, the hook and the preflight agree, `--no-skip` refuses, no fetched ref is left behind and the egress ledger records the lookup. The same parent never published refuses although its squash equals the combined diff. An unreachable remote scans everything and says so. A cherry-pick that landed on the base is skipped and named. A whitespace near-miss (`/home/alice/` against `/home/ alice/`) is scanned and refuses, and so is a landed commit that differs only in bytes that aren't valid UTF-8. + +## [1.92.82.0] - 2026-11-02 + +**`gstack-review-dispatch verify` now catches a merged findings file edited after collect.** Verify used to compare only output hashes and line counts. So a `merged.jsonl` row whose label was lowered, or whose text was replaced with another line's summary, passed with `VERIFY: clean` once the judge agreed with the edited row. Verify now re-parses each reader's unchanged output and checks every merged row against the finding at its index, label and raw text byte for byte. A mismatch prints `merged_line_differs` on the OUTPUT and JUDGE rows, and verify is dirty. A missing output file or an output event with no file recorded (even for a reply with no findings), a judgment run on a copy of the merged file, merged rows that collect never wrote, and an output whose finding count no longer matches its recorded ids are dirty too. A reply refused for naming the wrong checkout is still allowed to hold findings collect never merged, but only when verify re-checks its tree line and finds it wrong. + +### Known +- Deleting a whole output event together with its merged rows still verifies clean, because the ledger keeps no anchor for it. It is listed for the structured-findings plan. + +### Tests +- `test/review-dispatch.test.ts`: a forged label, a summary copied onto a blocker's id, a deleted output file, a judgment on a forged copy and an extra merged row all verify dirty. Each verified clean at main c830c9b. +- `test/review-dispatch.test.ts`: an output event edited to look like a wrong-tree refusal (no ids, `review: false`, `tree_ok: false`) verifies dirty, even after a re-sent brief is answered, and so does one edited to drop its file and ids. A real wrong-tree reply stays `NOT_REVIEWED` until a re-sent brief gets a review, then verifies clean. + +### Docs +- `docs/review-policy.md` (verify) describes the per-row check. + +## [1.92.81.0] - 2026-11-02 + +**`gstack-ship-release finish` now accepts the owner's edit after a fix-check instead of asking for another review round (GST-965).** On gbrain #8425, the fix-check found a blocker, the owner's pre-agreed fallback was applied and "then ship" was granted. Finish still classed the edit as behavior and stopped with `insert 5–11.5 → 12–14 → 16`, a third round the R51 cap forbids, so /ship could not get through Step 16 without running its stages by hand. + +### Added +- **`finish --review-loop-exception `** (`lib/ship-release.ts`) takes the same record `gstack-ship-receipt write --review-loop-exception` takes. Paths changed since `fix_check.head` that the branch already touched at that commit (narrowed to the record's `fix_check.files` when it lists them) route as `accepted=` on the `ROUTE:` line, a `REVIEW_LOOP_EXCEPTION:` line names them with the owner, date and channel, and the docs stage carries them (`accepted=` in the DOCS line). A path outside that scope and a gate-edited test (with or without `--reviewed-base`) still route as behavior, and with `--reviewed-base` so does a file too large for the scoped review. A malformed record, or a `fix_check.head` that is not HEAD or an ancestor of it, stops before the release commit. Without the flag nothing changes. + +### Tests +- `test/ship-rereview-triggers.test.ts`: an owner fallback edit to a source file and a doc after the fix-check stops without the record and passes Step 16 with it; a new file outside the scope (even when `fix_check.files` lists it), a fix-check head off the branch, a malformed record, an unreadable record file and an empty `fix_check.files` stop; a `fix_check.files` list narrows what is accepted; a test weakened after the fix-check stops with and without `--reviewed-base`. + +## [1.92.80.0] - 2026-11-02 + +**On a Capy machine, the git fixture tests no longer fail locally because of Capy's git shim log.** Capy's `/usr/bin/git` shim appends to `$HOME/.capy/git-shim.log`, so each fixture home that ran git during its template build ended up with a log naming the template. The leak scan in `test/helpers/git-fixture-template.ts` flagged it, and 34 tests in `test/train-backstop.test.ts`, `test/restamp-mentions.test.ts` and `test/pregate-user-policy.test.ts` failed on every Capy lane, though they passed on the remote gate. + +### Tests +- `templateReferences` skips `.capy/git-shim.log`. Nothing reads that file back, and every other file is still scanned. +- `test/git-fixture-template.test.ts` plants the shim log in a template's `home/.capy/` (the copy succeeds), and plants `home/.capy/state.json` as a control (the copy still throws). + +## [1.92.79.0] - 2026-11-02 + +**/plan-tune no longer reports a mismatch between your stated and observed preferences when it has no observed answers behind it (#3436).** `--check-mismatch` compared a declared value against an inferred value that had never moved from its 0.5 starting point, so a strong declaration with no matching answers printed `scope_appetite: declared 0.9 vs inferred 0.5 (gap 0.4)` even though no logged answer touched that dimension. + +### Fixed +- **`bin/gstack-developer-profile`**: `--check-mismatch`, `--gap` and the gap that `--derive` stores compare a dimension only when at least 3 logged answers moved it. `--check-mismatch` still needs 10 logged answers overall before it reports anything. Below the 3-answer floor, `--check-mismatch` prints `no observed evidence` or `too little evidence (n of 3 events)` instead of a gap, and `--gap` lists the dimension under a new `low_evidence` field. A profile derived before `contributing_events` existed keeps the old behavior until its next derive. +- **/plan-tune "Show gap"**: says "not enough observed answers yet" for those dimensions instead of calling them drift. + +### Tests +- `test/gstack-developer-profile.test.ts`: a declared dimension with no evidence is not flagged by `--check-mismatch` or `--gap` (both failed on main), 1 or 2 events report too little evidence, 3 events are compared, three same-day answers that agree with the declaration still report not enough data, and `--derive` stores no gap without evidence. + +## [1.92.78.0] - 2026-11-02 + +**Seven flaky tests from the 2026-11-01 flaky pass stop failing correct runs, and six more now name their cause when they fail.** One product lock broke a fresh lock it had misjudged as abandoned, a /ship lane tripped the free runner's home check with its own log, and the rest were fixtures that raced their own setup, leaned on wall-clock sleeps or read the caller's state root. + +### Fixed +- **`bin/gstack-codex-probe`**: a waiter that cannot read an owner-less lock's age (the holder removed it in that instant, or `stat -f` printed filesystem text on Linux) now treats the lock as fresh instead of abandoned, so it no longer deletes a live owner's lock on a misread age and the probe no longer ends `MODEL_PROBE_INCONCLUSIVE` (Free Tests 41216564482, 41235671867, 41263831361). A stat stub for both race windows fails without the fix; 64 stress runs went from 2 failures to 0. +- **`scripts/lib/free-home-guard.ts`, `bin/gstack-evidence`**: `gstack-evidence run` names its own lane log in `GSTACK_EVIDENCE_LOG`, and the real-home tripwire ignores exactly that file, so a /ship lane no longer trips over its own output. A foreign write under the home's `~/.gstack` still fails. +- **`test/autoplan-publication-guard.test.ts`**: the render-root cases clear the state-root variables they don't name, so a `GSTACK_STATE_ROOT` exported by the gstack launch file no longer picks the root. Three cases failed every time under that variable, and the lookalike controls passed for the wrong reason. +- **`test/gstack-design-detect.test.ts`**: the directory scan proves one batch by counting engine calls instead of sleeping near the 1,500 ms detect timeout (macOS job 124813933194). The product timeout is unchanged. +- **`test/pregate-regate.test.ts`**: the two fixtures are built once and copied per test through the `gitFixtureTemplate` helper from #3586, so 8 builds become 2. +- **`test/ship-queue-train.test.ts`**: the planted seam has its own name, so the fixture's own background `git maintenance` lock no longer collides with it (EEXIST, Free Tests 41288253305, 41288417894), and the test now holds a real `maintenance.lock` beside it. +- **`test/ship-budget.test.ts`**: the fake child writes its args to a temporary file and renames it, so the poll never reads an empty file (Free Tests 41242243205). + +### Changed +- `scripts/test-free-shards.ts` names the recorded browser child it never saw in the daemon's process tree, and whether it was still alive, when it fails a shard for unconfirmed ownership (Free Tests 41264845860 shard 7). The shard still fails. +- `test/bootstrap-retention-shard.test.ts`, `test/office-hours-attempt.test.ts`, `test/session-update-autostash.test.ts`, `test/shard-engine.test.ts` and `test/auq-parallel.test.ts` print the process state, exit status or cleanup events behind their assertion, so the next red names its cause. + +## [1.92.77.0] - 2026-11-02 + +**The weekly marathon evals can run /design-motion again: the CI eval image now ships ffmpeg (#3687).** The `design-motion-showcase` case joined the marathon tier on 2026-10-26, and its first scheduled run (Actions run 41200316994, main @ 088bfc5) failed 0/3. Every trial built and captured the page, then stopped at `FFMPEG_MISSING`, because the image had no ffmpeg and the skill rightly refuses to export before its checks run. The model did nothing wrong; the case could not pass on that image. + +### Fixed +- **`.github/docker/Dockerfile.ci`** installs Ubuntu's `ffmpeg` (6.1.1, with `ffprobe`). The build fails if that ffmpeg lacks a filter or encoder the motion checks and exports use (`psnr`, `gblur`, `palettegen`, `paletteuse`, `signalstats`, `tile`, `libx264`, `gif`). +- **`test/ci-image-cli-pin.test.ts`** fails the free suite if the image stops installing ffmpeg or stops checking it. + +The macOS `macos-named-regressions` CI job now runs `test/regression-issue3680-ubicloud-bsd-userland.test.ts`, so the ubi-runner cases for #3680 (bsdtar) and #3679 (macOS awk) run on a real Mac against `/usr/bin/tar` and `/usr/bin/awk`. Before, the file ran only on Linux CI, where bsdtar and one-true-awk aren't installed, so every case skipped and a regression in either could merge green. A new wiring test fails if the macOS job stops naming the file. + +The Linux free suite now runs on 10 `ubicloud-standard-8` machines, each running its tests as 4 sandboxed sub-shards side by side, instead of main's 20 `ubicloud-standard-4` machines (set by #3710) running one file at a time. Both use 80 vCPUs per run. In a 3-run A/B at one commit against that main setup, the slowest shard dropped from 389–450 s to 224–258 s and the Ubicloud cost per run dropped from about $0.39 to $0.32 at premium prices, with the same per-file test counts and no new failures. The CI plan now stores each shard's sub-shard count (`--ci-plan --shard-jobs k`), keeps the exclusive host-state shard at one child, and `--ci-verify` rejects a shard that ran at a different count. The nightly Windows suite stays at one child per shard, because two sub-shards made Windows file-lock cleanup failures show up in two of three runs. Dispatch-only `free_jobs` and `windows_jobs` inputs exist for future experiments. See docs/TESTING_INTERNALS.md#sub-shards-inside-one-ci-job. + ## [1.92.76.0] - 2026-11-02 **/retro now remembers its history across git worktrees, so a run in a linked worktree compares against last week instead of announcing "First retro recorded".** Retro snapshots used to live in `.context/retros/` inside the working tree. A worktree (Conductor, Claude Code) saw none of the main checkout's history, and its own snapshot was deleted along with the worktree (#3749). diff --git a/VERSION b/VERSION index 730cd3e00..097482065 100644 --- a/VERSION +++ b/VERSION @@ -1 +1 @@ -1.92.76.0 +1.92.83.0 diff --git a/agents-digest/gstack-AGENTS.md b/agents-digest/gstack-AGENTS.md index f297fb296..b332bb94e 100644 --- a/agents-digest/gstack-AGENTS.md +++ b/agents-digest/gstack-AGENTS.md @@ -1,4 +1,4 @@ -# gstack digest v1.92.76.0 — regenerate/re-copy after upgrading gstack +# gstack digest v1.92.83.0 — regenerate/re-copy after upgrading gstack Behavioral rules from gstack (https://github.com/garrytan/gstack), compressed for agent hosts without a full skill install. The full skills add workflows, diff --git a/bin/gstack-codex-probe b/bin/gstack-codex-probe index 0292e5674..981ae1499 100755 --- a/bin/gstack-codex-probe +++ b/bin/gstack-codex-probe @@ -662,8 +662,8 @@ _gstack_codex_lock_acquire() { if [ "$_ohost" = "$_host" ] && ! kill -0 "$_pid" 2>/dev/null; then _dead=1; fi [ $((_now - _ts)) -ge "$_stale" ] && _dead=1 else - _ts=$(stat -c %Y "$_dir" 2>/dev/null || stat -f %m "$_dir" 2>/dev/null || echo 0) - case "$_ts" in ''|*[!0-9]*) _ts=0 ;; esac + _ts=$(stat -c %Y "$_dir" 2>/dev/null || stat -f %m "$_dir" 2>/dev/null || echo "$_now") + case "$_ts" in ''|*[!0-9]*) _ts="$_now" ;; esac [ $((_now - _ts)) -ge 5 ] && _dead=1 fi [ "$_dead" -eq 1 ] && _gstack_codex_lock_break "$_dir" "$_owner" diff --git a/bin/gstack-developer-profile b/bin/gstack-developer-profile index bc489c5c1..409c4f56e 100755 --- a/bin/gstack-developer-profile +++ b/bin/gstack-developer-profile @@ -54,6 +54,7 @@ case "$(uname -s)" in esac PROFILE_FILE="$GSTACK_HOME/developer-profile.json" LEGACY_FILE="$GSTACK_HOME/builder-profile.jsonl" +MIN_EVIDENCE=3 eval "$("$SCRIPT_DIR/gstack-slug" 2>/dev/null || true)" SLUG="${SLUG:-unknown}" @@ -512,14 +513,19 @@ do_gap() { const p = JSON.parse(await Bun.stdin.text()); const declared = p.declared || {}; const inferred = (p.inferred && p.inferred.values) || {}; + const contributing = p.inferred && p.inferred.contributing_events; const dims = ['scope_appetite','risk_tolerance','detail_preference','autonomy','architecture_care']; const gap = {}; + const low_evidence = {}; for (const d of dims) { - if (declared[d] !== undefined && inferred[d] !== undefined) { - gap[d] = +(Math.abs(declared[d] - inferred[d])).toFixed(3); + if (declared[d] === undefined || inferred[d] === undefined) continue; + if (contributing && (contributing[d] || 0) < $MIN_EVIDENCE) { + low_evidence[d] = contributing[d] || 0; + continue; } + gap[d] = +(Math.abs(declared[d] - inferred[d])).toFixed(3); } - console.log(JSON.stringify({ declared, inferred, gap }, null, 2)); + console.log(JSON.stringify({ declared, inferred, gap, low_evidence }, null, 2)); " } @@ -657,6 +663,7 @@ do_derive() { // Recompute gap. const gap = {}; for (const d of Object.keys(values)) { + if ((contributing[d] || 0) < $MIN_EVIDENCE) continue; if (profile.declared && profile.declared[d] !== undefined) { gap[d] = +(Math.abs(profile.declared[d] - values[d])).toFixed(3); } @@ -727,9 +734,8 @@ do_check_mismatch() { const declared = p.declared || {}; const inferred = (p.inferred && p.inferred.values) || {}; const sampleSize = (p.inferred && p.inferred.sample_size) || 0; - const diversity = (p.inferred && p.inferred.diversity) || {}; + const contributing = p.inferred && p.inferred.contributing_events; - // Require enough data before reporting mismatch. if (sampleSize < 10) { console.log('MISMATCH: not enough data (' + sampleSize + ' events; need 10+)'); process.exit(0); @@ -737,8 +743,14 @@ do_check_mismatch() { const THRESHOLD = 0.3; const flagged = []; + const thin = []; for (const d of Object.keys(declared)) { if (inferred[d] === undefined) continue; + const n = contributing ? (contributing[d] || 0) : undefined; + if (n !== undefined && n < $MIN_EVIDENCE) { + thin.push(' ' + d + ': ' + (n === 0 ? 'no observed evidence' : 'too little evidence (' + n + ' of $MIN_EVIDENCE events)')); + continue; + } const gap = Math.abs(declared[d] - inferred[d]); if (gap > THRESHOLD) { flagged.push({ dim: d, declared: declared[d], inferred: inferred[d], gap: +gap.toFixed(3) }); @@ -753,6 +765,7 @@ do_check_mismatch() { console.log(' ' + f.dim + ': declared ' + f.declared + ' vs inferred ' + f.inferred + ' (gap ' + f.gap + ')'); } } + for (const line of thin) console.log(line); " } diff --git a/bin/gstack-evidence b/bin/gstack-evidence index 59f8bbe30..35ca01d7b 100755 --- a/bin/gstack-evidence +++ b/bin/gstack-evidence @@ -400,7 +400,7 @@ async function cmdRun(argv: string[]): Promise { try { if (shell && !shell.ok) throw new Error(shell.error); const spawnArgv = shell?.ok ? [shell.cmd, ...shell.argv] : cmdArgv; - proc = Bun.spawn(spawnArgv, { stdin: "inherit", stdout: "pipe", stderr: "pipe" }); + proc = Bun.spawn(spawnArgv, { stdin: "inherit", stdout: "pipe", stderr: "pipe", env: log ? { ...process.env, GSTACK_EVIDENCE_LOG: log.path } : process.env }); } catch (e: any) { // Spawn failure (ENOENT on argv-direct form): record exit 127, propagate 127. exitCode = 127; diff --git a/changelog.d/capy-gsta156-sealed-skip.md b/changelog.d/capy-gsta156-sealed-skip.md deleted file mode 100644 index 6126e96fe..000000000 --- a/changelog.d/capy-gsta156-sealed-skip.md +++ /dev/null @@ -1,8 +0,0 @@ -**The sealed guard no longer rescans commits the destination already holds, so a stacked branch retargeted after its parent squash-landed stops being refused for the parent's history.** gstack-evals#22 listed its parent #6's seven commits after #6 landed as one squash. One of them had added and later deleted a held-out file, so the guard refused a push whose every byte GitHub already had as `refs/pull/6/head`, and the queue sat blocked for about 50 minutes until a waiver landed. - -### Changed -- **`lib/sealed-guard.ts`** (`evaluateSealed`, shared by the pre-push hook and `gstack-pregate sealed`): a listed commit is skipped when it is an ancestor of the destination's own `refs/pull//head` for a squash `(#n)` on the base since the merge-base (at most 50 refs, a 20-second budget, an egress receipt per remote call, fetched refs deleted afterwards), or when its own `git patch-id --verbatim` and diff equal a base commit's (off above 2,000 base or 400 listed commits; a landed commit still gets the `sealed.label_join` check, which reads the whole file). Nothing is matched against a combined diff, and any failed lookup scans everything with one line saying why. docs/pregate.md#sealed-skip. -- **`gstack-pregate sealed`**: the `SEALED:` line gains `skipped=` (and `commits=` counts the commits scanned), stderr names each skipped group, and `--no-skip` scans every listed commit. - -### Tests -- `test/sealed-skip.test.ts`: the #22 shape is clear with seven commits skipped as `refs/pull/6/head`, the hook and the preflight agree, `--no-skip` refuses, no fetched ref is left behind and the egress ledger records the lookup. The same parent never published refuses although its squash equals the combined diff. An unreachable remote scans everything and says so. A cherry-pick that landed on the base is skipped and named. A whitespace near-miss (`/home/alice/` against `/home/ alice/`) is scanned and refuses, and so is a landed commit that differs only in bytes that aren't valid UTF-8. diff --git a/docs/TESTING_INTERNALS.md b/docs/TESTING_INTERNALS.md index 91c400897..441525762 100644 --- a/docs/TESTING_INTERNALS.md +++ b/docs/TESTING_INTERNALS.md @@ -195,15 +195,18 @@ fallback; unknown files get 99th-percentile pessimism, and both full-suite and the long pole. Packed shards get duration-aware walls (`max(base, predicted × 3, files × 5s)`). The legacy `--shards N --shard i` path keeps stable hash indices. Required CI uses -one duration-packed `--ci-plan`, 20 ordinary `--ci-run` shards plus a separate +one duration-packed `--ci-plan`, 10 ordinary `--ci-run` shards plus a separate exclusive-fixture shard, and a `--ci-verify` aggregate. CI shards still run on -independent machines without ordering unrelated jobs. Two workflow-level +independent machines without ordering unrelated jobs. Three workflow-level constants in `free-tests.yml` set that size for every event: `FREE_SUITE_RUNNER` -(`ubicloud-standard-4`) and `FREE_SUITE_SHARDS` (`'20'`), which `free-plan` -resolves into the shards' runner and shard count. A `workflow_dispatch` run may -override them with `free_runner` (`ubicloud-standard-2`, `-4` or `-8`) and -`free_shards` (1–40) for runner-sizing experiments only; its job summary says -so, and it is never cited as a PR or train Free Tests result. Each shard prints +(`ubicloud-standard-8`), `FREE_SUITE_SHARDS` (`'10'`) and `FREE_SUITE_JOBS` +(`'4'`, the [sub-shards](#sub-shards-inside-one-ci-job) each shard runs side by +side), which `free-plan` resolves into the shards' runner, shard count and plan. +GSTA-203 chose them by a 3-run A/B against 20 × `ubicloud-standard-4` at k = 1 +(the PR that set them has the table). A `workflow_dispatch` run may +override them with `free_runner` (`ubicloud-standard-2`, `-4` or `-8`), +`free_shards` (1–40) and `free_jobs` (1–8) for runner-sizing experiments only; +its job summary says so, and it is never cited as a PR or train Free Tests result. Each shard prints `RESOURCES: peak_used_mib=… min_free_disk_mib=… samples=…` from a memory and disk sample every 2 seconds, even when the suite fails. The public `TREE_MUTATING` map now classifies exclusive host-state fixtures; its sole @@ -1553,7 +1556,7 @@ refresh to a maintainer. `scripts/ubicloud/setup-free-suite.sh` and the Windows seed through `windows-free-tests.yml`'s `record_durations` path. The recorder stays on `ubicloud-standard-8` while the free-suite shards run on `FREE_SUITE_RUNNER` - (`ubicloud-standard-4`); both are Ubicloud standard runners. When either recording adds + (`ubicloud-standard-8`); both are Ubicloud standard runners. When either recording adds or drops a test file, it force-updates the `bot/free-duration-seeds` branch, opens or refreshes one seed PR whose body states the ratchet result, and dispatches Free Tests, Windows Smoke and Quality gate on it. Those @@ -1575,14 +1578,24 @@ k sub-shards side by side on the same machine (`scripts/lib/shard-jobs.ts`). Eac sub-shard is an ordinary free shard child: one `bun test` process, serial inside, with its own sandbox, process-group kill and log. Bun's worker `--parallel` mode is still not used (decision V3/D6 in -`scripts/test-free-shards.ts`). The default is `k = 1`, which runs today's one -child, so no workflow changes behavior until it passes `--jobs`. +`scripts/test-free-shards.ts`). Without `--jobs`, a shard runs at the plan's +per-shard k (below); a shard with no planned k runs one child (`k = 1`). -- `--jobs` applies only to `--ci-run`. It takes a positive whole number up to the - shard's file count (an empty shard takes 1); anything else stops with - `FREE_JOBS_INVALID`. `GSTACK_FREE_JOBS` sets the local full-suite process count - and never changes `--ci-run`. The exclusive host-state shard has one file, so - a workflow passes it `--jobs 1` or no `--jobs`. +- Workflows set k in the plan, per shard: `--ci-plan --shards N + --shard-jobs k` stores `jobs = min(k, the shard's files that may run in + parallel)` on each shard, and only when that is above 1. The exclusive + host-state shard holds only `TREE_MUTATING` files, so it stays one child, and + a plan made at k = 1 is byte-identical to one made without `--shard-jobs`. + `--ci-run` runs the shard at the plan's k, and `--ci-verify` rejects any + shard result that ran at a different k. `free-tests.yml` reads k from + `FREE_SUITE_JOBS` and `windows-free-tests.yml` from `WINDOWS_FREE_JOBS`; the + `free_jobs` and `windows_jobs` dispatch inputs override them for experiments + only. +- `--jobs` applies only to `--ci-run` and overrides the plan's k, for local + reproduction. It takes a positive whole number up to the shard's file count + (an empty shard takes 1); anything else stops with `FREE_JOBS_INVALID`, as + does a bad `--shard-jobs` or one without `--ci-plan`. `GSTACK_FREE_JOBS` sets + the local full-suite process count and never changes `--ci-run`. - Files are split into disjoint sub-shards packed by the duration seed. Each sub-shard has an id (`:.`) bound to its file list. Files in `TREE_MUTATING` or `SLOW_UNDER_LOAD` run after the parallel @@ -1606,6 +1619,13 @@ reproduce command. If the file passes serially, add it to `SLOW_UNDER_LOAD` in `scripts/lib/shard-jobs.ts` with that run id and a reason. Never raise the timeout. `SLOW_UNDER_LOAD` is empty today. +The nightly Windows lane stays at `WINDOWS_FREE_JOBS: '1'`. In GSTA-203's A/B, k = 2 +made the slowest Windows shard faster, but two of three runs needed the flaky +retry for file-lock cleanup errors (`EBUSY` in +`browse/test/stealth-webdriver.test.ts` and a native fixture cleanup in +`browse/test/cookie-import-native-job.test.ts`), which k = 1 never showed. Fix +those cleanups before raising it. + Every `--ci-run` result, at any k, records per-file test and `expect()` counts in `outcome.counts` (`tests`, `pass`, `fail`, `skip`, `todo`, `expects` per file, plus shard totals). They come from bun's JUnit reporter, which the runner @@ -1624,8 +1644,9 @@ test list in its last step, where each file's reason sits beside it. `test/windows-native-workflows.test.ts` keeps that list Windows-safe and fast. The curated suite below runs nightly on main and on dispatch, never on a PR. -`windows-free-tests.yml` plans on Linux (`--windows-only --ci-plan --shards 6`), -runs one strict serial shard per `windows-latest` job +`windows-free-tests.yml` plans on Linux (`--windows-only --ci-plan --shards 6 +--shard-jobs $WINDOWS_FREE_JOBS`), runs one strict serial shard per +`windows-latest` job (`bun run test:windows --ci-run`), and verifies every shard receipt on Linux (`--windows-only --ci-verify`). Packing uses Windows-measured durations from `scripts/free-test-durations-windows.json`; the weekly diff --git a/docs/designs/PLAN_TUNING_V0.md b/docs/designs/PLAN_TUNING_V0.md index e9bf2b440..77bf95e8d 100644 --- a/docs/designs/PLAN_TUNING_V0.md +++ b/docs/designs/PLAN_TUNING_V0.md @@ -105,7 +105,7 @@ After weighing Codex's argument, we chose to roll back CEO EXPANSION and ship an } ``` -`signal_events` counts the logged answers that moved any dimension, and `contributing_events` counts, per dimension, the logged answers that moved it (an answer that writes two deltas to one dimension counts once), so a dimension with no evidence (0) is visible beside one whose evidence balances out; both print 0.5 (#2623). Each value is the sigmoid of its accumulated deltas, and each UTC day's sum per dimension (by the row's `ts`) is clamped to the largest single delta in the signal map (currently ±0.06), so one burst of answers can't pin a dimension; `--trace ` lists every contributing answer, each clamped day and the total. The bound keys on the day, not on `session_id`: the PostToolUse hook and the agent log different ids for the same review (the host's conversation id and the preamble's run id), and an agent can leave the `SESSION_ID` placeholder unsubstituted, so no logged id reliably marks one review. The cost is that several reviews on one day count as one. `contributing_events` still counts an answer logged by both writers twice. Only an ISO 8601 date or date-time `ts` counts as dated (a time without a zone is read as UTC), every other row shares one undated bucket, and `diversity.days_span` counts the same UTC days. Nothing displays `contributing_events` yet (#3436). +`signal_events` counts the logged answers that moved any dimension, and `contributing_events` counts, per dimension, the logged answers that moved it (an answer that writes two deltas to one dimension counts once), so a dimension with no evidence (0) is visible beside one whose evidence balances out; both print 0.5 (#2623). Each value is the sigmoid of its accumulated deltas, and each UTC day's sum per dimension (by the row's `ts`) is clamped to the largest single delta in the signal map (currently ±0.06), so one burst of answers can't pin a dimension; `--trace ` lists every contributing answer, each clamped day and the total. The bound keys on the day, not on `session_id`: the PostToolUse hook and the agent log different ids for the same review (the host's conversation id and the preamble's run id), and an agent can leave the `SESSION_ID` placeholder unsubstituted, so no logged id reliably marks one review. The cost is that several reviews on one day count as one. `contributing_events` still counts an answer logged by both writers twice. Only an ISO 8601 date or date-time `ts` counts as dated (a time without a zone is read as UTC), every other row shares one undated bucket, and `diversity.days_span` counts the same UTC days. `--check-mismatch`, `--gap`, the stored `gap` and /plan-tune's profile view compare a dimension only when its `contributing_events` is at least 3; `--gap` lists the others under `low_evidence` and the stored `gap` omits them (#3436). **Diversity check** (Codex #13): `inferred` is considered "enough data" only when `sample_size >= 20 AND skills_covered >= 3 AND question_ids_covered >= 8 AND days_span >= 7`. Below this, `/plan-tune profile` shows "not enough observed data yet" instead of a potentially-misleading inferred value. diff --git a/docs/review-policy.md b/docs/review-policy.md index db10c3753..f7b872281 100644 --- a/docs/review-policy.md +++ b/docs/review-policy.md @@ -429,7 +429,14 @@ rules hash, base and registry commits, the gate scan bound to the head and the r the queue refuses a receipt below the depth its head needs ([ship-policy.md#ship-receipt](ship-policy.md#ship-receipt)). A stop the owner accepted after a fix-check edit goes in `review_loop_exception`, never in prose -under `REVIEW_LOOP:`. +under `REVIEW_LOOP:`. Step 16 takes the same record: +`gstack-ship-release finish --review-loop-exception ` routes the +paths the branch touched at `fix_check.head` (narrowed to the record's +`fix_check.files` when it lists them) and changed since as `accepted=` in +`ROUTE:`, prints them on a `REVIEW_LOOP_EXCEPTION:` line, and carries them +through the docs stage. A path outside that scope and a gate-edited test still +route as behavior, with `--reviewed-base` (as /ship passes it) so does a file +too large for the scoped review, and without the record nothing changes. The receipt's summary line prints `passes= blocking_found= deferred=`, which is what a scorecard tracks (target: a median of 1 pass per PR). A @@ -572,7 +579,18 @@ ones, and the arm lost blockers. `bin/gstack-review-dispatch` closes both ends: missing coverage leaves the review incomplete. A mismatched send re-sent byte-exact before anything was collected is void and counted (`voided=N` on the verdict line); one that produced an output stays `MISMATCH`. Then output hashes - against the merged lines they produced, and the judgment rerun against the + against the merged lines they produced, and each merged line against the finding collect + parses at that id's index from the unchanged output file, label and raw text byte for byte + (`merged_line_differs` on the OUTPUT and JUDGE rows otherwise, GST-541), so an edited + merged.jsonl cannot lower a finding or swap its text. A recorded output file that is missing + (`output_file_missing`), a judgment run on a merged file collect did not write + (`judge_merged_not_collected`) and merged rows collect never recorded + (`merged_rows_not_collected`) are dirty too, and so is an output whose re-parsed findings + outnumber or undercount the ids collect recorded (`merged_count_differs`). The one exception + is a reply collect refused for its tree: verify re-checks the reply's `REVIEWED_TREE` line + against the render's tree, and such an output stays `NOT_REVIEWED` until a re-sent brief is + answered by a reply that passes the same check. Deleting a whole output event with its rows is + not caught yet, because the ledger has no anchor for it. The judgment is then rerun against the recorded judged set, covering every collected id. A ledger from a run that kept no prompts prints `VERIFY: no brief records` (exit 3), never clean. 6. `pass --base --out --pass -- ` runs one pass from a diff --git a/lib/review-ledger.ts b/lib/review-ledger.ts index 5f2973f66..dd3266dee 100644 --- a/lib/review-ledger.ts +++ b/lib/review-ledger.ts @@ -16,7 +16,7 @@ */ import * as fs from 'node:fs'; import * as path from 'node:path'; -import { isReviewedTreeLine, promptHash, type TreeCheck } from './review-brief'; +import { checkReviewedTree, isReviewedTreeLine, promptHash, type TreeCheck } from './review-brief'; export interface LedgerEvent { event: 'rendered' | 'sent' | 'output' | 'judgment' | 'judge' | 'program'; [k: string]: unknown } export const readerKey = (role: string, name?: string) => (name ? `${role}/${name}` : role); @@ -236,6 +236,21 @@ export function recordJudgment(ledger: string, merged: MergedLine[], judged: Jud return res; } +/** A recorded output's file text while it still hashes to what collect recorded. */ +function unchangedOutput(o: LedgerEvent): string | undefined { + try { + const text = typeof o.file === 'string' ? fs.readFileSync(o.file, 'utf8') : undefined; + return text !== undefined && promptHash(text) === o.sha256 ? text : undefined; + } catch { return undefined; } +} + +/** Whether a reply names the tree its render recorded (GST-482), checked on the reply itself, never on an output event's editable fields. */ +const treeHolds = (render: LedgerEvent | undefined, reply: string | undefined) => !render || !('tree' in render) + || (reply !== undefined && checkReviewedTree(reply, typeof render.tree === 'string' ? render.tree : null).ok); + +/** One file however its path is spelled: collect and judge may reach a run directory through a symlink. */ +const realPath = (f: string) => { try { return fs.realpathSync(f); } catch { return path.resolve(f); } }; + /** * The blocking findings a run's ledger holds (GST-951): every collected line whose label ranks CRITICAL, raised to its judged * severity when the newest judge accepted a judged set that is unchanged since; lead judgment's additions count and a folded @@ -284,15 +299,16 @@ export function ledgerBlockers(ledger: string): LedgerBlocker[] { } /** - * Whether a recorded output was a review: it has findings, or its file (unchanged since collect) carries the reader's clean marker, so - * a ledger written before collect recorded the field is judged by the same rule; with no readable file, collect's own review field. + * Whether a recorded output was a review: its unchanged reply names its render's tree, and it has findings or carries the reader's + * clean marker, so a ledger written before collect recorded the field is judged by the same rule; with no readable file, collect's own + * review field. */ -function reviewed(o: LedgerEvent): boolean { +function reviewed(o: LedgerEvent, render?: LedgerEvent): boolean { if (o.tree_ok === false || unavailableOutput(o)) return false; + const text = unchangedOutput(o); + if (!treeHolds(render, text)) return false; if (Array.isArray(o.ids) && o.ids.length) return true; - let text: string | undefined; - try { text = typeof o.file === 'string' ? fs.readFileSync(o.file, 'utf8') : undefined; } catch { /* unreadable */ } - return text !== undefined && promptHash(text) === o.sha256 ? hasCleanMarker(String(o.reader), text) : o.review === true; + return text !== undefined ? hasCleanMarker(String(o.reader), text) : o.review === true; } /** Every id collect recorded, in order. */ @@ -314,6 +330,7 @@ export function verify(ledger: string): { rows: VerifyRow[]; clean: boolean; bri return { at, next, reader, label: nth > 1 ? `${reader}@${nth}` : reader }; }); let voided = 0; + const differs: string[] = []; for (const p of episodes) { const r = events[p.at]!; const inside = events.map((e, i) => ({ e, i })).filter(({ e, i }) => i > p.at && i < p.next && e.reader === p.reader); @@ -331,7 +348,7 @@ export function verify(ledger: string): { rows: VerifyRow[]; clean: boolean; bri voided += epVoided; const lastMatch = [...sent].reverse().find(({ e }) => e.sha256 === r.sha256); const outputs = lastMatch ? inside.filter(({ e, i }) => e.event === 'output' && i > lastMatch.i) : []; - const collected = outputs.some(({ e }) => reviewed(e)); + const collected = outputs.some(({ e }) => reviewed(e, r)); const unavailable = !collected && outputs.some(({ e }) => unavailableOutput(e)); const status = !sent.length ? 'NOT_SENT' : mismatched ? 'MISMATCH' : collected ? 'MATCH' : unavailable ? 'UNAVAILABLE' : 'NOT_COLLECTED'; const tampered = fileNow && fileNow !== r.sha256 ? ' file_changed_since_render' : ''; @@ -346,20 +363,38 @@ export function verify(ledger: string): { rows: VerifyRow[]; clean: boolean; bri const lastSent = p ? events.slice(p.at + 1, i).filter(e => e.event === 'sent' && e.reader === reader).at(-1) : undefined; if (!p) { status = 'MISMATCH'; notes.push('brief_not_rendered'); } else if (!lastSent || lastSent.sha256 !== events[p.at]!.sha256) { status = 'MISMATCH'; notes.push('brief_not_sent_verbatim'); } - if (typeof o.file === 'string' && fs.existsSync(o.file) && promptHash(fs.readFileSync(o.file, 'utf8')) !== o.sha256) { status = 'MISMATCH'; notes.push('output_file_changed'); } + let text: string | undefined; + try { text = typeof o.file === 'string' ? fs.readFileSync(o.file, 'utf8') : undefined; } catch { /* missing or unreadable */ } + const render = p ? events[p.at]! : undefined; + if (text !== undefined && promptHash(text) !== o.sha256) { status = 'MISMATCH'; notes.push('output_file_changed'); } const ids = Array.isArray(o.ids) ? (o.ids as string[]) : []; let lines: MergedLine[] = []; try { lines = typeof o.merged === 'string' ? readJsonl(o.merged).filter(l => ids.includes(l.id)) : []; } catch { notes.push('merged_unreadable'); status = 'MISMATCH'; } if (lines.length !== ids.length) { status = 'MISMATCH'; notes.push(`merged_lines=${lines.length}/${ids.length}`); } if (lines.some(l => l.output_sha256 !== o.sha256)) { status = 'MISMATCH'; notes.push('merged_line_from_other_output'); } - if (status === 'MATCH' && !reviewed(o)) { + // Each merged row must be the finding collect parses at that id's index from the unchanged output, label and raw text byte for byte. + if (text === undefined) { + status = 'MISMATCH'; + notes.push(typeof o.file === 'string' ? 'output_file_missing' : 'output_file_unrecorded'); + differs.push(...ids); + } else if (promptHash(text) === o.sha256) { + const parsed = parseFindings(reader, text); + const off = ids.filter((id, k) => { const m = lines.find(l => l.id === id); return !m || parsed[k]?.label !== m.label || parsed[k]?.raw !== m.raw; }); + if (unavailableOutput(o) && parsed.length) { status = 'MISMATCH'; notes.push('unavailable_output_has_findings'); } + else if (!unavailableOutput(o)) { + if (off.length) { status = 'MISMATCH'; notes.push(`merged_line_differs=${JSON.stringify(off)}`); differs.push(...off); } + // A reply collect refused for its tree (GST-482) records no ids whatever it holds; the refusal is re-derived from the reply. + if (parsed.length !== ids.length && !(o.tree_ok === false && !ids.length && !treeHolds(render, text))) { status = 'MISMATCH'; notes.push(`merged_count_differs=${parsed.length}/${ids.length}`); } + } + } + if (status === 'MATCH' && !reviewed(o, render)) { // A non-review the same dispatch later replaced with a review (the brief re-sent verbatim, then answered) is void, counted like a voided send. const later = p ? events.slice(i + 1, p.next) : []; - const review = later.findIndex(e => e.event === 'output' && e.reader === reader && reviewed(e)); + const review = later.findIndex(e => e.event === 'output' && e.reader === reader && reviewed(e, render)); const replaced = review > 0 && later.slice(0, review).some(e => e.event === 'sent' && e.reader === reader && e.sha256 === events[p!.at]!.sha256); if (unavailableOutput(o)) { status = 'UNAVAILABLE'; notes.push(`reason=${JSON.stringify(o.unavailable)}`); } else if (replaced) { status = 'VOIDED'; voided++; notes.push('replaced_by_a_review'); } - else if (o.tree_ok === false) { status = 'NOT_REVIEWED'; notes.push(`wrong_tree reported=${JSON.stringify(o.reported_tree)} expected=${JSON.stringify(o.tree)}`); } + else if (o.tree_ok === false || !treeHolds(render, text)) { status = 'NOT_REVIEWED'; notes.push(`wrong_tree reported=${JSON.stringify(o.reported_tree)} expected=${JSON.stringify(o.tree)}`); } else { status = 'NOT_REVIEWED'; notes.push('no_finding_no_clean_marker'); } } rows.push({ kind: 'OUTPUT', reader: p?.label ?? reader, status, detail: `sha=${short(o.sha256)} findings=${ids.length}${notes.length ? ' ' + notes.join(' ') : ''}` }); @@ -373,13 +408,22 @@ export function verify(ledger: string): { rows: VerifyRow[]; clean: boolean; bri const judgedIds = new Set(Array.isArray(v.merged_ids) ? (v.merged_ids as string[]) : []); const missing = collectedIds(events.slice(0, lastJudge)).filter(id => !judgedIds.has(id)); if (missing.length) notes.push(`merged_ids_missing=${missing.join(',')}`); + // The judged set must come from the merged file collect wrote, holding only rows collect recorded. + const outputs = events.slice(0, lastJudge).filter(e => e.event === 'output' && Array.isArray(e.ids) && e.ids.length); + if (outputs.some(e => typeof e.merged !== 'string' || realPath(e.merged) !== realPath(String(v.merged)))) notes.push('judge_merged_not_collected'); let rerun: Judgment | null = null; try { const judgedText = fs.readFileSync(String(v.judged), 'utf8'); if (promptHash(judgedText) !== v.judged_sha256) notes.push('judged_changed_since_judge'); - rerun = judge(readJsonl(String(v.merged)), readJsonl(String(v.judged))); + const merged = readJsonl(String(v.merged)); + const collected = new Set(collectedIds(events.slice(0, lastJudge))); + const extra = merged.filter(m => !collected.has(m.id)).map(m => m.id); + if (extra.length) notes.push(`merged_rows_not_collected=${JSON.stringify(extra)}`); + rerun = judge(merged, readJsonl(String(v.judged))); } catch { notes.push('judged_or_merged_unreadable'); } if (rerun && !rerun.ok) notes.push(`rerun_rejects dropped=${JSON.stringify(rerun.dropped)} downgraded=${JSON.stringify(rerun.downgraded)}`); + const forged = differs.filter(id => judgedIds.has(id)); + if (forged.length) notes.push(`merged_line_differs=${JSON.stringify(forged)}`); const status = !v.ok ? 'REJECTED' : notes.length ? 'MISMATCH' : 'OK'; rows.push({ kind: 'JUDGE', reader: '-', status, detail: `dropped=${JSON.stringify(v.dropped)} downgraded=${JSON.stringify(v.downgraded)} added=${v.added} promoted=${v.promoted} folded=${v.folded}${notes.length ? ' ' + notes.join(' ') : ''}` }); } else if (events.some(e => e.event === 'output')) rows.push({ kind: 'JUDGE', reader: '-', status: 'MISSING', detail: 'outputs were collected but no judged set was checked' }); diff --git a/lib/ship-release.ts b/lib/ship-release.ts index bdce14414..02f5218a7 100644 --- a/lib/ship-release.ts +++ b/lib/ship-release.ts @@ -15,6 +15,7 @@ import { mergeChangelogs, normalizeChangelog } from './changelog-check'; import { versionAtRef } from './restamp'; import { classifyPath } from './review-depth'; import { comparePath, contextPacket, gateEditedPaths, isTestCode, PACKET_SECTION_CAP, readScopedReview, touchedPaths, type PatchSides } from './ship-patch'; +import { parseReviewLoopException, type ReviewLoopException } from './ship-receipt-depth'; import { parseReceiptDocs, type ReceiptDocs } from './ship-receipt-docs'; import { resolveStateRoot } from './state-root'; import { bumpVersion, cmpVersion, fmtVersion, parseVersion, versionWidth, type Bump } from './version-source'; @@ -27,10 +28,11 @@ export const RELEASE_HELP = `gstack-ship-release — /ship Steps 12 and 15-17 (s --keep-current (or --version ) keeps VERSION; under bump ask a session no person answers needs --answer before a bump is written finish --base --reviewed-tree [--reviewed-base ] --docs-post [--lane