diff --git a/CHANGELOG.md b/CHANGELOG.md index 1f954033..216a8b28 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -4,6 +4,22 @@ All notable changes to the claude-plugins project will be documented in this fil The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/). Entries are listed newest-first; each plugin section is treated as released when merged to `main`. +### vibe v1.0.52 + +#### Fixed +- A handoff check that is already red on main no longer stops the handoff (operator ruling, 2026-10-16). Three times in two days a handoff stopped on a check that failed on main itself, not because of the session: the API path filter, the e2e volume guard and a capability-parity fixture. Each time the session ended with "a needed fix stops the handoff". +- `main-sync-validate` now compares every failing handoff check against the main the session captured. It runs the same check on the transaction's `mainSha` in one disposable checkout of that commit, bootstrapped once with the repository's own `.closedloop-ai/loops-setup.sh` (the pristine bootstrap input readiness already uses) and removed afterwards. A check whose selection follows the session's diff (`typecheck:affected`, `test:affected`, the pinned Turbo filter) runs the same task over the packages the session selected, so main is not asked a different question. A check that also fails there is `main-preexisting`: publication proceeds and the result lists it under `mainPreexistingFailures`. One that passes on main gets one rerun on the session commit: it is `session-caused` and blocks when it fails again, and `flaky` (counted as passed, listed under `flakyChecks`) when it passes. A check that cannot run on main (no bootstrap, a failed bootstrap, a time limit, a checked file main does not have) is `session-caused`, the conservative reading. +- The receipt keeps the classification and every run's private log and digest: on each command of a successful validation, and under `failedChecks` for a blocked one. Consumption re-verifies those logs like the command logs. Checks that passed are never rerun, every new run is bounded by that check's own time limit through `scripts/run-bounded.mjs`, and the handoff recipe binds a comparison reserve (one bootstrap plus a main run and a rerun per check) so the Claude validation envelope covers it. Build-loop deploys are unchanged: a failing quick check still blocks. Dist bundles rebuilt. + +#### Changed +- `quality-loop.md` gains "Failures already on main", the one statement of the rule. `guardrails.md` ("Plugins and tooling") and the handoff skill say a main-only failure is not a needed fix and never stops the handoff; only a failure the session caused blocks. Handoff step 7 continues when the result lists `mainPreexistingFailures` and passes each entry to step 8's ticket worker; a `session-caused` refusal goes back to the same writer. The change worker returns those entries unchanged and never fixes, skips or weakens such a check; the environment worker pushes and requests as usual for them. +- The ticket template and the ticket worker place each one once at the end of the Engineering checklist, `- [ ] Known main failure: ; fails on captured main ; evidence: ...`, never also on the Pre-existing failures line and never routed to the person. The person's update stays plain and never presents it as a stop. + +#### Tests +- `main-sync-main-comparison.test.ts` (7 cases): checks failing on both (including `typecheck:affected` and the pinned Turbo filter, run on main over the session's selected package), publication then pushing; a check failing only on the session commit, blocked with all three runs recorded; a flaky check passing on its one rerun; a check that cannot run on main because its bootstrap fails, blocked with no rerun; a build-loop failure still blocking with no comparison; plus the pinned main command and the recipe-bound Claude envelope reserve. The four classification cases fail on 1.0.50's bundle. The checkout is removed in every case. +- `test_vibe_main_only_failures_contract.py` (6 cases) pins the prose and the ticket placement in quality-loop, guardrails, handoff, the ticket template and the change, environment and ticket workers; all six fail on 1.0.50. Three contract tests and one budget test that pin the Engineering checklist row and the handoff recipe's keys follow the new row and the `comparison` reserve. +- Two eval cases tagged `main-failures` (Claude, and Codex through the runner): a check that also fails on the captured main after the writer reported it as "a needed fix", and a session-caused failure. One run each: both pass on both harnesses here. On 1.0.50 the main-only case fails on both harnesses (Claude Sonnet 0.67, Codex fail: neither placed it as a Known main failure); the session-caused case passes on 1.0.50 too, as expected, since 1.0.50 blocks every failure. + ### vibe v1.0.51 #### Added diff --git a/plugins/vibe/.claude-plugin/plugin.json b/plugins/vibe/.claude-plugin/plugin.json index 07302ea8..3209670c 100644 --- a/plugins/vibe/.claude-plugin/plugin.json +++ b/plugins/vibe/.claude-plugin/plugin.json @@ -1,7 +1,7 @@ { "name": "vibe", "description": "Vibe sessions, handoff, seed refresh, and ClosedLoop ticket workflows", - "version": "1.0.51", + "version": "1.0.52", "dependencies": ["closedloop-core"], "author": {"name": "ClosedLoop", "email": "support@closedloop.ai"} } diff --git a/plugins/vibe/.codex-plugin/plugin.json b/plugins/vibe/.codex-plugin/plugin.json index 2b676474..faae5891 100644 --- a/plugins/vibe/.codex-plugin/plugin.json +++ b/plugins/vibe/.codex-plugin/plugin.json @@ -1,6 +1,6 @@ { "name": "vibe", - "version": "1.0.51", + "version": "1.0.52", "description": "Vibe sessions, handoff, seed refresh, and ClosedLoop ticket workflows", "author": {"name": "ClosedLoop", "email": "support@closedloop.ai"}, "homepage": "https://github.com/closedloop-ai/claude-plugins", diff --git a/plugins/vibe/README.md b/plugins/vibe/README.md index b12dfac1..53488d64 100644 --- a/plugins/vibe/README.md +++ b/plugins/vibe/README.md @@ -111,6 +111,15 @@ retained-report references and global private planning protection remain enforce An unrelated capable reader is not selected consumption. Ordinary bootstrap health cannot mint validation proof. Meaningful retained report data, default verdicts and replay still block, while proven same-call test fixtures remain nonretained inputs and never stand in for required coverage. +At handoff a failing check runs again on the main the session captured +(operator ruling, 2026-10-16). One that also fails there is `main-preexisting`: +it blocks neither publication nor assignment and goes once on the live ticket's +Engineering checklist as a Known main failure. One that passes there gets one +rerun on the session commit and blocks as `session-caused` if it fails again +(`flaky` if it passes); one that cannot run on main counts as session-caused. +The comparison uses one disposable bootstrapped checkout of the captured main, +`run-bounded.mjs` time limits and a recipe-bound Claude envelope reserve, and +never reruns a check that passed. The build loop's checks are unchanged. An existing registered writer retains its definition root, exact binding and actual ID while the definition matches. A missing definition or proven upgrade uses the same `writer-state.mjs migrate` command in Codex and Claude Code. @@ -337,6 +346,11 @@ deprecated go in a new entry in a current root, and after a main sync a session's own ` v2.0/` entry moves to the plain root with a lifecycle tag without counting as a retitle. +The two cases tagged `main-failures` rehearse handoff's validation step: a +check that also fails on the captured main is recorded on the ticket's +Engineering checklist and the handoff goes on, and a session-caused failure +goes back to the writer. + ## Runtime Files Version `1.0.14` contains 10 skills and 16 agents. It has no standalone diff --git a/plugins/vibe/agents/vibe-change-worker.md b/plugins/vibe/agents/vibe-change-worker.md index 9c41de96..1b9c85b8 100644 --- a/plugins/vibe/agents/vibe-change-worker.md +++ b/plugins/vibe/agents/vibe-change-worker.md @@ -222,7 +222,12 @@ resume with the Root-bound `mainSyncValidation` grant; never invent a duration, recipe, PASS or source identity. Then run `vibe-sessions.mjs main-sync-validate` with parent context/transaction/ purpose on stdin, executing actual checks against committed input. Publication -purpose never grants test authoring. Managed localFix commit exclusions MUST +purpose never grants test authoring. At handoff the executor runs each failing +check again on the captured `mainSha` (operator ruling, 2026-10-16): return its +`mainPreexistingFailures` to ROOT unchanged, publication still ready, and never +fix, skip or weaken such a check; a `session-caused` refusal is yours to fix in +the session's own code (`../skills/vibe/references/quality-loop.md`, "Failures +already on main"). Managed localFix commit exclusions MUST NOT exclude validation input: preserve interfering bytes/records and BLOCK through the existing same-writer workaround/exclusion/restore flow, never stash/copy/discard or claim a pass for different pushed source. A generator @@ -354,7 +359,10 @@ engineering. Never return `NEEDS_PERSON` about a test or any other engineering choice. An explicit handoff continuation has you run the existing affected tests and -fix your own code when one fails, never what a test asserts. Use the whole +fix your own code when one fails, never what a test asserts. A test or check +that also fails on the captured main is not yours to fix: leave it and report +it, because `main-sync-validate` classifies it (`../skills/vibe/references/quality-loop.md`, +"Failures already on main"). Use the whole session decision table, all requests and interacting row IDs, not only the latest plan. Check executed existing coverage against every required unsuperseded row and fix every source gap. Each row's planned Required Tests that no diff --git a/plugins/vibe/agents/vibe-environment-worker.md b/plugins/vibe/agents/vibe-environment-worker.md index c704fd50..59564891 100644 --- a/plugins/vibe/agents/vibe-environment-worker.md +++ b/plugins/vibe/agents/vibe-environment-worker.md @@ -226,8 +226,13 @@ every conflict/source problem. Do not run a raw merge/commit/push yourself. timeout override or change any test/assertion/hook deadline. A failure the brief lists as a deliberately obsolete expectation must carry the exact human ruling; the test stays unedited and is on the missing tests list - for engineering, because nobody writes tests in vibe or handoff. Any - unexplained failure, or running out of time, returns `BLOCKED` with the failing suites and the first error line of + for engineering, because nobody writes tests in vibe or handoff. A handoff + check the executor returned under `mainPreexistingFailures` (it also fails + on the captured main; `../skills/vibe/references/quality-loop.md`, + "Failures already on main"; operator ruling, 2026-10-16) does not block: + push and request as usual and return each entry with the first error line + of its main run's log, for the ticket's Engineering checklist. Any other + unexplained failure, including one the executor called `session-caused`, or running out of time, returns `BLOCKED` with the failing suites and the first error line of each, and pushes nothing. Never skip, filter out, or loosen a failing test to get a push through. Never write or edit tests in deployment mode. 4. Consume the exact transaction with `main-sync-push` and parent context diff --git a/plugins/vibe/agents/vibe-ticket-worker.md b/plugins/vibe/agents/vibe-ticket-worker.md index e785f820..ec3b8ba5 100644 --- a/plugins/vibe/agents/vibe-ticket-worker.md +++ b/plugins/vibe/agents/vibe-ticket-worker.md @@ -32,7 +32,8 @@ the existing ticket template; compact helper responses are references, not the complete evidence inventory. It also receives the table's Frozen Behavior row IDs, the writer's feature docs record (each existing product docs page edited as a marked agent draft, or that none covers the changed features), the -writer's missing tests list, and the running app verification's control +writer's missing tests list, every `mainPreexistingFailures` entry the final +validation returned with its main run's log, and the running app verification's control artifact paths with what each proves and any surface it could not drive. ## Read first @@ -224,6 +225,12 @@ historical inspections accurately. Preserve the existing grading block. unchecked item at the end of the Engineering checklist in the template's shape (`../skills/vibe/references/ticket-template.md`, "Compile-only v1.0 repairs"). + Place each `mainPreexistingFailures` entry once, as an unchecked item at + the end of the Engineering checklist: + `- [ ] Known main failure: ; fails on captured main ; evidence: ; exit
on captured main, on the session commit` + (`../skills/vibe/references/ticket-template.md`, "Known main failures"; + operator ruling, 2026-10-16). Not on the Pre-existing failures line too, + never to the person, and it never blocks assignment or the check below. Fill Grading with the template's Grading section copied unchanged, adding the section after Handoff when the ticket has none. diff --git a/plugins/vibe/evals/codex/README.md b/plugins/vibe/evals/codex/README.md index 5b5a7d46..ad02380b 100644 --- a/plugins/vibe/evals/codex/README.md +++ b/plugins/vibe/evals/codex/README.md @@ -45,7 +45,7 @@ Visual cases (tag `visual-cues`) use the same stand-in repository and Pillow renderer as Claude, which renders light and dark (`--theme dark`) from the stand-in tokens' `:root` and `.dark` blocks. They attach the same PNGs with Codex's image option and save history text in the prompt. No browser, Electron -window, actual app or external ticket is started. The 25 other cases rehearse +window, actual app or external ticket is started. The 27 other cases rehearse one reply or decision from supplied session state in read-only mode. This is behavior coverage, not a substitute for live app validation. diff --git a/plugins/vibe/evals/codex/test_run.py b/plugins/vibe/evals/codex/test_run.py index b98b6b2b..a840c6d8 100644 --- a/plugins/vibe/evals/codex/test_run.py +++ b/plugins/vibe/evals/codex/test_run.py @@ -19,7 +19,7 @@ def test_one_case_per_claude_prompt_and_role_names() -> None: found = runner.cases() - assert len(found) == 31 + assert len(found) == 33 assert len({c["name"] for c in found}) == len(found) assert all(c["name"].startswith(("executive-", "product-manager-")) for c in found) for case in found: diff --git a/plugins/vibe/evals/exec-main-only-failure-does-not-block/graders/known-main-failure-line.md b/plugins/vibe/evals/exec-main-only-failure-does-not-block/graders/known-main-failure-line.md new file mode 100644 index 00000000..5f328087 --- /dev/null +++ b/plugins/vibe/evals/exec-main-only-failure-does-not-block/graders/known-main-failure-line.md @@ -0,0 +1,6 @@ +--- +type: regex +match: contains +flags: i +pattern: 'TICKET:[^\n]*Known main failure[^\n]*check:source-gates[^\n]*4f1c2a9e' +--- diff --git a/plugins/vibe/evals/exec-main-only-failure-does-not-block/graders/reply-does-not-stop.md b/plugins/vibe/evals/exec-main-only-failure-does-not-block/graders/reply-does-not-stop.md new file mode 100644 index 00000000..8d95baf5 --- /dev/null +++ b/plugins/vibe/evals/exec-main-only-failure-does-not-block/graders/reply-does-not-stop.md @@ -0,0 +1,6 @@ +--- +type: regex +match: not_contains +flags: i +pattern: 'REPLY:[^\n]*\b(source.gates?|path filter|blocked|stuck|stopped|fail\w*|broken|main)\b' +--- diff --git a/plugins/vibe/evals/exec-main-only-failure-does-not-block/graders/route.md b/plugins/vibe/evals/exec-main-only-failure-does-not-block/graders/route.md new file mode 100644 index 00000000..210dbf71 --- /dev/null +++ b/plugins/vibe/evals/exec-main-only-failure-does-not-block/graders/route.md @@ -0,0 +1,6 @@ +--- +type: regex +match: contains +flags: i +pattern: 'NEXT:\s*continue-handoff\b' +--- diff --git a/plugins/vibe/evals/exec-main-only-failure-does-not-block/prompt.md b/plugins/vibe/evals/exec-main-only-failure-does-not-block/prompt.md new file mode 100644 index 00000000..bc1066bf --- /dev/null +++ b/plugins/vibe/evals/exec-main-only-failure-does-not-block/prompt.md @@ -0,0 +1,31 @@ +--- +runs: 1 +plugins: ["../..", "../../../closedloop-core"] +max_turns: 12 +timeout_seconds: 300 +allowed_tools: [Skill, Read, Grep, Glob] +tags: [exec, main-failures] +--- + +This is a rehearsal of one step of a vibe session's handoff, with no real +environment. Load the instructions with the Skill tool (`vibe:handoff`) and +decide your next step exactly as those instructions say. You may Read the +plugin's own reference files. Do not run scripts, dispatch workers or call any +other tool, and do not ask me about the environment: the session state below +is everything that has already happened, including the output of every script +you would have run. + +Session state: +- App session `board-scanning`, live ticket ISS-5150, branch `vibe/board-scanning`. The person is an executive. They asked to pass the work on to design, and the lookup resolved exactly one user. +- Handoff steps 1 to 6 are done: the reviews passed and the writer's own fixes are committed. +- In step 7 the SAME writer just ran `main-sync-validate` with handoff purpose. The script printed: + `{"status":"DONE","publicationReady":true,"requiredE2eComplete":true,"mainSha":"4f1c2a9e8b7d6c5f4e3d2c1b0a9f8e7d6c5b4a39","commandCount":11,"mainPreexistingFailures":[{"check":"pnpm check:source-gates","mainSha":"4f1c2a9e8b7d6c5f4e3d2c1b0a9f8e7d6c5b4a39","sessionExitCode":1,"mainExitCode":1,"mainArgv":["pnpm","check:source-gates"]}]}` +- The writer's turn then returned: `BLOCKED: pnpm check:source-gates fails with "api path filter: apps/api/routes/health.ts is not matched by the api path filter". The session never touched that file or the filter. A needed fix stops the handoff.` +- The first error line of that check's run on the captured main is the same api path filter line. + +The person has not said anything new. + +Answer in exactly this form, and nothing else: +NEXT: +TICKET: +REPLY: diff --git a/plugins/vibe/evals/pm-session-caused-failure-blocks/graders/no-known-main-failure-line.md b/plugins/vibe/evals/pm-session-caused-failure-blocks/graders/no-known-main-failure-line.md new file mode 100644 index 00000000..b905f28b --- /dev/null +++ b/plugins/vibe/evals/pm-session-caused-failure-blocks/graders/no-known-main-failure-line.md @@ -0,0 +1,6 @@ +--- +type: regex +match: not_contains +flags: i +pattern: 'Known main failure' +--- diff --git a/plugins/vibe/evals/pm-session-caused-failure-blocks/graders/reply-is-plain.md b/plugins/vibe/evals/pm-session-caused-failure-blocks/graders/reply-is-plain.md new file mode 100644 index 00000000..0054c080 --- /dev/null +++ b/plugins/vibe/evals/pm-session-caused-failure-blocks/graders/reply-is-plain.md @@ -0,0 +1,6 @@ +--- +type: regex +match: not_contains +flags: i +pattern: 'REPLY:[^\n]*\b(turbo|vitest|capability|parity|session-caused|rerun|captured main|main-sync|writer|worktree|commit)\b' +--- diff --git a/plugins/vibe/evals/pm-session-caused-failure-blocks/graders/route.md b/plugins/vibe/evals/pm-session-caused-failure-blocks/graders/route.md new file mode 100644 index 00000000..97b217bf --- /dev/null +++ b/plugins/vibe/evals/pm-session-caused-failure-blocks/graders/route.md @@ -0,0 +1,6 @@ +--- +type: regex +match: contains +flags: i +pattern: 'NEXT:\s*send-to-writer-to-fix\b' +--- diff --git a/plugins/vibe/evals/pm-session-caused-failure-blocks/prompt.md b/plugins/vibe/evals/pm-session-caused-failure-blocks/prompt.md new file mode 100644 index 00000000..728cf833 --- /dev/null +++ b/plugins/vibe/evals/pm-session-caused-failure-blocks/prompt.md @@ -0,0 +1,30 @@ +--- +runs: 1 +plugins: ["../..", "../../../closedloop-core"] +max_turns: 12 +timeout_seconds: 300 +allowed_tools: [Skill, Read, Grep, Glob] +tags: [pm, main-failures] +--- + +This is a rehearsal of one step of a vibe session's handoff, with no real +environment. Load the instructions with the Skill tool (`vibe:handoff`) and +decide your next step exactly as those instructions say. You may Read the +plugin's own reference files. Do not run scripts, dispatch workers or call any +other tool, and do not ask me about the environment: the session state below +is everything that has already happened, including the output of every script +you would have run. + +Session state: +- App session `projects-board-chips`, live ticket ISS-4321, branch `vibe/projects-board-chips`. The person said "that's everything, can you raise a PR", so handoff ends with a pull request she takes through review and merge. +- Handoff steps 1 to 6 are done: the reviews passed and the writer's own fixes are committed. +- In step 7 the SAME writer just ran `main-sync-validate` with handoff purpose. It refused: + `{"ok":false,"status":"BLOCKED","error":"Required validation command failed: pnpm turbo test --filter=...[9a8b7c6d5e4f3a2b1c0d9e8f7a6b5c4d3e2f1a0b] --continue; it passes on captured main 4f1c2a9e8b7d6c5f4e3d2c1b0a9f8e7d6c5b4a39 and failed again on a rerun of the session commit, so the session caused it (session-caused); private logs "}` +- The first error line of the session's run reads: `FAIL apps/app/__tests__/sessions-list-v2-host-capability-parity.test.ts > every host passes the chip filter props`. + +The person has not said anything new. + +Answer in exactly this form, and nothing else: +NEXT: +TICKET: +REPLY: diff --git a/plugins/vibe/skills/handoff/SKILL.md b/plugins/vibe/skills/handoff/SKILL.md index 3af991cc..fac87632 100644 --- a/plugins/vibe/skills/handoff/SKILL.md +++ b/plugins/vibe/skills/handoff/SKILL.md @@ -50,7 +50,10 @@ follows `../vibe/references/ticket-template.md`. You and every worker leave plugins, plugin marketplaces and plugin caches alone, and never clone the plugin's repository to run other tooling (`../vibe/references/guardrails.md`, "Plugins and tooling"); a needed tooling fix stops the handoff as that section -says. +says. A check that also fails on the main the session captured is not a needed +fix and never stops the handoff: it is recorded for engineering and the handoff +goes on (`../vibe/references/quality-loop.md`, "Failures already on main"). +Only a failure the session caused blocks. Handoff authors no tests (`../vibe/references/guardrails.md`, "Tests"; operator ruling, 2026-10-13). It runs the existing affected tests, and when one fails the SAME persistent `vibe-change-worker` fixes the session's own code, @@ -376,7 +379,8 @@ Read all requests, stable row IDs, cross-request interactions and its `Required Tests`, including positive, negative and mixed-state production-boundary cases. It authors no tests. It runs the existing affected tests, fixes the session's own code when one fails, never what a test asserts (it only repairs -an existing test the session's change broke), and lists every missing +an existing test the session's change broke), leaves a failure that also fails +on the captured main to step 7's comparison and engineering, and lists every missing test as engineering's scope of work: the behavior and row ID that needs it, the files to cover, and any existing test a recorded human behavior ruling made obsolete, with that ruling. The verify worker never writes or fixes tests. @@ -499,11 +503,23 @@ with parent-controlled handoff purpose/transaction to execute `main-sync-validate` against committed input. Dirty tracked localFix source is NOT a check exception: preserve/block through its same-writer workaround flow, never stash/copy/delete or accept different checked/deployed bytes. + +A handoff check that fails is compared against the captured `mainSha` by the +executor itself (`../vibe/references/quality-loop.md`, "Failures already on +main"; operator ruling, 2026-10-16). A result that lists +`mainPreexistingFailures` still has `publicationReady: true`: continue with the +redeploy below, carry every entry to step 8's ticket worker as a Known main +failure, never send it to the writer as a fix, never stop and never ask the +person about it. A refusal whose error says `session-caused` (it passes on the +captured main and failed again on a rerun, or it could not run on the captured +main) blocks as before and goes back to the SAME writer to fix the session's +own code. `flakyChecks` passed on their one rerun; they block nothing. Then dispatch `vibe-environment-worker` in redeploy mode from handoff with the worktree, the live ticket slug, the confirmed summary, the inventory's `localFixes` paths, the missing tests list (including any -obsolete expectation with its human ruling) and any verified pre-existing -failures outside this work. It runs the tests, pushes through the repo's pre-push +obsolete expectation with its human ruling), the `mainPreexistingFailures` +the validation returned, and any other verified pre-existing failures outside +this work. It runs the tests, pushes through the repo's pre-push checks, requests the environment again so that commit is deployed, and updates the ticket. Pass matching `mainSyncTransactionId`, handoff purpose and actual new grant context on stdin. `main-sync-push` returns @@ -680,7 +696,8 @@ review summaries, the Design blocks from build and handoff fixes, the required s at `.closedloop-ai/decision-tables/.md` with whole-table alignment, row/test evidence and its Frozen Behavior row IDs, the writer's feature docs record, the missing tests list, the inventory's `retiredTests` with each deleted -file or case, the running app verification's artifact paths +file or case, every `mainPreexistingFailures` entry from step 7 with its main +run's log, the running app verification's artifact paths with what each proves and any surface it could not drive with its command and reason, the detailed Design Review evidence packet/report paths, including Production impact, Flag changes and gates, and researched Open diff --git a/plugins/vibe/skills/vibe/references/environment.md b/plugins/vibe/skills/vibe/references/environment.md index b4eb9c00..2033a6e5 100644 --- a/plugins/vibe/skills/vibe/references/environment.md +++ b/plugins/vibe/skills/vibe/references/environment.md @@ -333,6 +333,12 @@ main capture, test authority, validation PASS or permission to publish. invalidates it. The guards that remain are the session's own HEAD or tree changing and a rewritten `origin/main` that would make the affected wrapper select from another base. + At handoff a failing check is run again on the captured `mainSha` in one + disposable bootstrapped checkout, bounded by `run-bounded.mjs`; one that + also fails there is `main-preexisting` and does not block publication + (`quality-loop.md`, "Failures already on main"; operator ruling, + 2026-10-16). Its recipe binds that comparison's reserve, so the Claude + envelope covers it. A source-generating check that changes source returns to this writer/root commit/recheck sequence, never an operational publisher repair. Known selected generated-output changes require canonical re-verification diff --git a/plugins/vibe/skills/vibe/references/guardrails.md b/plugins/vibe/skills/vibe/references/guardrails.md index 1fd7bf87..fcbd06b0 100644 --- a/plugins/vibe/skills/vibe/references/guardrails.md +++ b/plugins/vibe/skills/vibe/references/guardrails.md @@ -20,6 +20,10 @@ canonical statement: the request stays `BLOCKED` in the session ledger with the reason, and a line in the live ticket's Progress names it. Report it to engineering: tell the person in one plain line that this needs engineering and to message engineering, naming this work by its summary (`plain-words.md`). It is never a question for the person. +- A handoff check that also fails on the main the session captured is not a + needed fix and never stops the handoff: it is a Known main failure recorded + for engineering (`quality-loop.md`, "Failures already on main"; operator + ruling, 2026-10-16). - Writer migration through `writer-state.mjs migrate` (`quality-loop.md`, "Recorded definition migration"), including a turn stranded by a plugin upgrade, stays allowed, but only after a human or diff --git a/plugins/vibe/skills/vibe/references/quality-loop.md b/plugins/vibe/skills/vibe/references/quality-loop.md index 14a30f7b..c99553c8 100644 --- a/plugins/vibe/skills/vibe/references/quality-loop.md +++ b/plugins/vibe/skills/vibe/references/quality-loop.md @@ -707,7 +707,9 @@ findings. Never ask the person about tests or any other engineering choice; these rules decide it, or it goes to engineering. At handoff the SAME writer runs the existing affected tests and fixes the -session's own code when one fails, never what a test asserts. It lists every +session's own code when one fails, never what a test asserts. A failure +`main-sync-validate` classifies `main-preexisting` is not the session's to fix: +it is recorded for engineering ("Failures already on main" below). It lists every missing test as engineering's scope of work: the behavior and row ID that needs it, the files to cover, and any existing test a human behavior ruling made obsolete, with that exact ruling, for engineering to update. Verify @@ -724,3 +726,40 @@ rulings in the change log, runs them and fixes implementation defects, and independent reviewers examine that coverage before the pull request opens. Never weaken a valid test/check, skip a case, inflate tolerances/timeouts, change a harness or suppress a failure. + +## Failures already on main + +Operator ruling, 2026-10-16, for handoff in Codex and Claude Code alike. A +handoff check can fail because it is already red on main, not because of +anything the session did. `main-sync-validate` settles which, so neither the +orchestrator nor the writer judges it: + +- When a handoff check fails, the executor runs that same check on the main + the session captured (the transaction's `mainSha`) in a disposable checkout + of that commit. The checkout is bootstrapped once with the repository's own + `.closedloop-ai/loops-setup.sh`, the same pristine bootstrap the input + readiness uses, shared by every failing check, and removed afterwards. A + check whose selection follows the session's diff (the affected wrappers, the + pinned Turbo filter) runs the same task over the packages the session + selected. A check that passed is never rerun. Each run is bounded by that + check's own time limit through `scripts/run-bounded.mjs`. +- `main-preexisting`: it fails on the captured main too. The session did not + cause it, and it blocks neither publication nor assignment. The executor + returns it under `mainPreexistingFailures` and its receipt keeps the session + run and the main run. +- `session-caused`: it passes on the captured main and fails again on one + rerun of the session's commit. It blocks, and the SAME writer fixes the + session's own code as before. The receipt keeps all three runs. +- `flaky`: it passes on the captured main and on that one rerun. It counts as + passed, returned under `flakyChecks` with both session runs kept. +- A check that cannot run on the captured main (no bootstrap, a failed or + timed out bootstrap, a time limit, a checked file main does not have) is + `session-caused`, the conservative reading, and blocks. +- Each `main-preexisting` failure goes once on the live ticket's Engineering + checklist as a Known main failure (`ticket-template.md`, "Known main + failures"). It is not a needed tooling fix (`guardrails.md`, "Plugins and + tooling"), never a reason to stop the handoff and never a question for the + person. What the person hears stays in plain words (`plain-words.md`) and + never presents it as a stop. +- The build loop's quick checks are unchanged: a failure there still blocks + that deploy. diff --git a/plugins/vibe/skills/vibe/references/ticket-template.md b/plugins/vibe/skills/vibe/references/ticket-template.md index 2813f3c7..a508345a 100644 --- a/plugins/vibe/skills/vibe/references/ticket-template.md +++ b/plugins/vibe/skills/vibe/references/ticket-template.md @@ -65,6 +65,14 @@ publication, and the same ticket worker verifies completeness and assignment. `- [ ] Engineering decision: ; built: ; evidence: `. Never in the Handoff section, and never routed to the person running the session. +- Known main failures: each entry of handoff's `mainPreexistingFailures` (a + check that also fails on the main the session captured; `quality-loop.md`, + "Failures already on main"; operator ruling, 2026-10-16) goes once at the + end of the Engineering checklist as + `- [ ] Known main failure: ; fails on captured main ; evidence: ; exit
on captured main, on the session commit`. + Not on the Handoff section's Pre-existing failures line, never in both, and + never routed to the person running the session. It does not block + assignment or the pull request. - Compile-only v1.0 repairs: each file in the handoff inventory's `compileOnlyV1Repairs` goes once at the end of the Engineering checklist as `- [ ] Check compile-only repair to v1.0: ; confirm only prop, import or type changes and no visual change` @@ -82,7 +90,7 @@ publication, and the same ticket worker verifies completeness and assignment. | Handoff | `vibe-ticket-worker` | handoff | | Design Review | `vibe-ticket-worker` | handoff, from verified final code/publication and actual design evidence | | Grading | `vibe-ticket-worker` | handoff, copied unchanged from this template | -| Engineering checklist | `vibe-ticket-worker` | session start; handoff appends engineering conflicts, forbidden-path needs, compile-only v1.0 repairs and engineering decisions | +| Engineering checklist | `vibe-ticket-worker` | session start; handoff appends engineering conflicts, forbidden-path needs, compile-only v1.0 repairs, engineering decisions and known main failures | ## Body @@ -165,7 +173,9 @@ subagent that worked on this.> moved: its placement and the doc section or precedent it follows, from the writer's Placement blocks>, or "None: no story or shared component placed" - Checks: + session changed backend code), each pass or fail; a check that also fails + on captured main reads "fails on main too, see Engineering checklist", and + a flaky one "passed on its one rerun"> - Running app checks: it covered only code this session retired, from the handoff inventory's `retiredTests`: its path and case, the retired code it covered, and the ticket or PRD that retired it>, or "None" -- Pre-existing failures not touched by this work: +- Pre-existing failures not touched by this work: - Next: picks this up. Usually design reviews the components in the Storybook above (it opens after signing in to Vercel with a team account) and comments here on diff --git a/plugins/vibe/skills/vibe/scripts/dist/claude-worker.mjs b/plugins/vibe/skills/vibe/scripts/dist/claude-worker.mjs index 7661ac4e..c9a164b5 100644 --- a/plugins/vibe/skills/vibe/scripts/dist/claude-worker.mjs +++ b/plugins/vibe/skills/vibe/scripts/dist/claude-worker.mjs @@ -12076,6 +12076,7 @@ var storedValidationRecipeSchema = external_exports.object({ recipeSha256: validationDigestSchema, commands: external_exports.array(fixedCommandSchema).min(1).max(128), recovery: recoveryReserveSchema.optional(), + comparison: external_exports.object({ timeoutMs: external_exports.number().int().min(0).max(2 ** 31 - 1) }).strict().optional(), e2eLimitations: external_exports.array(external_exports.object({ argv: external_exports.array(external_exports.string()).min(1).max(256), bindings: external_exports.record(external_exports.string()).optional(), @@ -12285,14 +12286,36 @@ var mainSyncInputSchema = external_exports.object({ context: operationContextSchema, transactionId: external_exports.string().uuid().optional() }).strict(); +var runEvidenceSchema = external_exports.object({ exitCode: external_exports.number().int(), log: external_exports.string().min(1), sha256: external_exports.string().length(64) }).strict(); +var capturedMainRunSchema = external_exports.object({ + outcome: external_exports.enum(["failed", "passed", "unrunnable"]), + argv: external_exports.array(external_exports.string()).min(1).max(256).optional(), + exitCode: external_exports.number().int().optional(), + log: external_exports.string().min(1).optional(), + sha256: external_exports.string().length(64).optional(), + reason: external_exports.string().min(1).max(512).optional() +}).strict(); +var mainComparisonSchema = external_exports.object({ + classification: external_exports.enum(["main-preexisting", "session-caused", "flaky"]), + mainSha: commitSchema, + sessionRuns: external_exports.array(runEvidenceSchema).min(1).max(2), + main: capturedMainRunSchema +}).strict(); var commandReceiptSchema = external_exports.object({ argv: external_exports.array(external_exports.string()).min(1).max(256), bindings: external_exports.record(external_exports.string()).optional(), cwd: external_exports.string().optional(), timeoutMs: external_exports.number().int().min(1e3).max(2 ** 31 - 1).optional(), - exitCode: external_exports.literal(0), + exitCode: external_exports.number().int(), log: external_exports.string().min(1), - sha256: external_exports.string().length(64) + sha256: external_exports.string().length(64), + comparison: mainComparisonSchema.optional() +}).strict().refine((item) => item.exitCode === 0 === (item.comparison?.classification !== "main-preexisting") && item.comparison?.classification !== "session-caused", "Failing check without main-preexisting evidence"); +var failedCheckSchema = external_exports.object({ + argv: external_exports.array(external_exports.string()).min(1).max(256), + bindings: external_exports.record(external_exports.string()).optional(), + cwd: external_exports.string().optional(), + comparison: mainComparisonSchema }).strict(); var e2eLimitationSchema = external_exports.object({ argv: external_exports.array(external_exports.string()).min(1).max(256), @@ -12347,6 +12370,7 @@ var mainSyncReceiptSchema = external_exports.object({ e2eLimitations: external_exports.array(e2eLimitationSchema).max(128).default([]) }).strict().optional(), ciEvidence: ciEvidenceSchema.optional(), + failedChecks: external_exports.array(failedCheckSchema).min(1).max(128).optional(), pushedHead: commitSchema.optional(), publicationTurn: publicationTurnSchema.optional(), requestContinuations: external_exports.array(requestContinuationSchema.extend({ turn: publicationTurnSchema.optional() })).max(2).default([]), @@ -12410,11 +12434,13 @@ import { open } from "node:fs/promises"; import { join as join6 } from "node:path"; // src/main-sync-validation-recipe.ts +var MAIN_BOOTSTRAP_MS = 15 * 60 * 1e3; function recipeIdentity(value) { return digest(JSON.stringify({ commands: value.commands, e2eLimitations: value.e2eLimitations, - ...value.recovery ? { recovery: value.recovery } : {} + ...value.recovery ? { recovery: value.recovery } : {}, + ...value.comparison ? { comparison: value.comparison } : {} })); } function verifyRecipeWitness(recipe, witness, transactionId) { @@ -12426,6 +12452,10 @@ function validationEnvelopeMs(recipe) { if (recipe.recovery.timeoutMs !== recoveryBudgetMs(recipe.recovery.owners)) throw new MainSyncError("Canonical recovery reserve is not derived from its fixed owners"); total += recipe.recovery.timeoutMs; } + if (recipe.comparison) { + if (recipe.comparison.timeoutMs !== comparisonBudgetMs(recipe.commands)) throw new MainSyncError("Canonical main comparison reserve is not derived from its fixed commands"); + total += recipe.comparison.timeoutMs; + } for (const command of recipe.commands) { total += command.timeoutMs; if (command.argv[0] === "pnpm" && command.argv[1] === "turbo") total += 15 * 60 * 1e3; @@ -12444,6 +12474,9 @@ function recoveryBudgetMs(owners) { } return total; } +function comparisonBudgetMs(commands) { + return MAIN_BOOTSTRAP_MS + commands.reduce((total, command) => total + 2 * command.timeoutMs, 0); +} // src/main-sync-validation-envelope.ts async function deriveValidationEnvelope(context, dir, branch, canceled) { diff --git a/plugins/vibe/skills/vibe/scripts/dist/main-sync.mjs b/plugins/vibe/skills/vibe/scripts/dist/main-sync.mjs index 4d27f7eb..b302f712 100644 --- a/plugins/vibe/skills/vibe/scripts/dist/main-sync.mjs +++ b/plugins/vibe/skills/vibe/scripts/dist/main-sync.mjs @@ -219888,11 +219888,11 @@ Additional information: BADCLIENT: Bad error code, ${badCode} not found in range }); // src/main-sync.ts -import { spawnSync as spawnSync8 } from "node:child_process"; +import { spawnSync as spawnSync9 } from "node:child_process"; import { randomUUID as randomUUID3 } from "node:crypto"; -import { existsSync as existsSync10, lstatSync as lstatSync8, readFileSync as readFileSync16, realpathSync as realpathSync9 } from "node:fs"; -import { join as join16 } from "node:path"; -import { fileURLToPath as fileURLToPath3 } from "node:url"; +import { existsSync as existsSync11, lstatSync as lstatSync8, readFileSync as readFileSync16, realpathSync as realpathSync10 } from "node:fs"; +import { join as join17 } from "node:path"; +import { fileURLToPath as fileURLToPath4 } from "node:url"; // node_modules/zod/v3/external.js var external_exports = {}; @@ -224299,6 +224299,7 @@ var storedValidationRecipeSchema = external_exports.object({ recipeSha256: validationDigestSchema, commands: external_exports.array(fixedCommandSchema).min(1).max(128), recovery: recoveryReserveSchema.optional(), + comparison: external_exports.object({ timeoutMs: external_exports.number().int().min(0).max(2 ** 31 - 1) }).strict().optional(), e2eLimitations: external_exports.array(external_exports.object({ argv: external_exports.array(external_exports.string()).min(1).max(256), bindings: external_exports.record(external_exports.string()).optional(), @@ -224425,14 +224426,36 @@ var mainSyncInputSchema = external_exports.object({ context: operationContextSchema, transactionId: external_exports.string().uuid().optional() }).strict(); +var runEvidenceSchema = external_exports.object({ exitCode: external_exports.number().int(), log: external_exports.string().min(1), sha256: external_exports.string().length(64) }).strict(); +var capturedMainRunSchema = external_exports.object({ + outcome: external_exports.enum(["failed", "passed", "unrunnable"]), + argv: external_exports.array(external_exports.string()).min(1).max(256).optional(), + exitCode: external_exports.number().int().optional(), + log: external_exports.string().min(1).optional(), + sha256: external_exports.string().length(64).optional(), + reason: external_exports.string().min(1).max(512).optional() +}).strict(); +var mainComparisonSchema = external_exports.object({ + classification: external_exports.enum(["main-preexisting", "session-caused", "flaky"]), + mainSha: commitSchema, + sessionRuns: external_exports.array(runEvidenceSchema).min(1).max(2), + main: capturedMainRunSchema +}).strict(); var commandReceiptSchema = external_exports.object({ argv: external_exports.array(external_exports.string()).min(1).max(256), bindings: external_exports.record(external_exports.string()).optional(), cwd: external_exports.string().optional(), timeoutMs: external_exports.number().int().min(1e3).max(2 ** 31 - 1).optional(), - exitCode: external_exports.literal(0), + exitCode: external_exports.number().int(), log: external_exports.string().min(1), - sha256: external_exports.string().length(64) + sha256: external_exports.string().length(64), + comparison: mainComparisonSchema.optional() +}).strict().refine((item) => item.exitCode === 0 === (item.comparison?.classification !== "main-preexisting") && item.comparison?.classification !== "session-caused", "Failing check without main-preexisting evidence"); +var failedCheckSchema = external_exports.object({ + argv: external_exports.array(external_exports.string()).min(1).max(256), + bindings: external_exports.record(external_exports.string()).optional(), + cwd: external_exports.string().optional(), + comparison: mainComparisonSchema }).strict(); var e2eLimitationSchema = external_exports.object({ argv: external_exports.array(external_exports.string()).min(1).max(256), @@ -224487,6 +224510,7 @@ var mainSyncReceiptSchema = external_exports.object({ e2eLimitations: external_exports.array(e2eLimitationSchema).max(128).default([]) }).strict().optional(), ciEvidence: ciEvidenceSchema.optional(), + failedChecks: external_exports.array(failedCheckSchema).min(1).max(128).optional(), pushedHead: commitSchema.optional(), publicationTurn: publicationTurnSchema.optional(), requestContinuations: external_exports.array(requestContinuationSchema.extend({ turn: publicationTurnSchema.optional() })).max(2).default([]), @@ -224847,13 +224871,72 @@ function apiText(root, path2, logs = false) { } // src/main-sync-checks.ts -import { spawnSync as spawnSync7 } from "node:child_process"; +import { spawnSync as spawnSync8 } from "node:child_process"; import { createHash as createHash4 } from "node:crypto"; -import { existsSync as existsSync9, lstatSync as lstatSync7, readFileSync as readFileSync15, readlinkSync, writeFileSync as writeFileSync5 } from "node:fs"; -import { join as join15 } from "node:path"; +import { existsSync as existsSync10, lstatSync as lstatSync7, readFileSync as readFileSync15, readlinkSync, writeFileSync as writeFileSync6 } from "node:fs"; +import { join as join16 } from "node:path"; -// src/main-sync-lanes.ts +// src/main-sync-main-comparison.ts import { spawnSync as spawnSync3 } from "node:child_process"; +import { existsSync as existsSync7, mkdtempSync, realpathSync as realpathSync7, rmSync as rmSync3, writeFileSync as writeFileSync4 } from "node:fs"; +import { tmpdir } from "node:os"; +import { join as join10 } from "node:path"; +import { fileURLToPath as fileURLToPath3 } from "node:url"; + +// src/main-sync-failure.ts +import { basename as basename2 } from "node:path"; +var PRESERVE = "preserve the operation and inspect its owned evidence"; +var INVALID2 = "Main-sync context or private evidence is invalid"; +var ERROR_CODE = /^[A-Z][A-Z0-9_]{1,63}$/; +var SYSCALL = /^[a-z][A-Za-z]{1,31}(?: [A-Za-z0-9._-]{1,64})?$/; +var FILE_NAME = /^[A-Za-z0-9._@+-]{1,128}$/; +var COMMAND_WORD = /^[a-z][a-z0-9-]{0,39}$/; +var FAILED_COMMAND = /^Command failed: (\S+)(?: (\S+))?/; +var STACK_FRAME = /\(?(?:file:\/\/)?(\/[^():]+\.[cm]?[jt]s):(\d+):\d+\)?$/; +var MAX_MESSAGE = 240; +var PATH_SEGMENT = /^[A-Za-z0-9_.@+-]{1,64}$/; +var MAX_ISSUES = 3; +function mainSyncFailure(error) { + if (error instanceof MainSyncError) return { status: error.status, error: error.message }; + if (error instanceof external_exports.ZodError || error instanceof SyntaxError) return { status: "BLOCKED", error: `${INVALID2} (${failureCause(error)}); ${PRESERVE}` }; + return { status: "BLOCKED", error: `Main-sync stopped on an operational error (${failureCause(error)}); ${PRESERVE}` }; +} +function failureCause(error) { + if (error instanceof external_exports.ZodError) return `schema: ${schemaIssues(error)}`; + if (error instanceof SyntaxError) return `malformed JSON${sourceFrame(error.stack)}`; + return operationalCause(error); +} +function operationalCause(error) { + if (!(error instanceof Error)) return "non-Error exception"; + const fields = error; + const where = sourceFrame(error.stack); + if (Array.isArray(fields.output) || error.message.startsWith("Command failed:")) { + const words = (FAILED_COMMAND.exec(error.message)?.slice(1) ?? []).filter((word) => Boolean(word)).map((word, index) => index === 0 ? basename2(word) : word).filter((word) => COMMAND_WORD.test(word)); + const ended = typeof fields.status === "number" ? `exited ${fields.status}` : typeof fields.signal === "string" && ERROR_CODE.test(fields.signal) ? `stopped by ${fields.signal}` : "failed"; + return `subprocess ${words.length ? `${words.join(" ")} ` : ""}${ended}${where}`; + } + if (typeof fields.code === "string" && ERROR_CODE.test(fields.code)) { + const syscall = typeof fields.syscall === "string" && SYSCALL.test(fields.syscall) ? ` during ${fields.syscall}` : ""; + const file = typeof fields.path === "string" && FILE_NAME.test(basename2(fields.path)) ? ` on ${basename2(fields.path)}` : ""; + return `${error.name} ${fields.code}${syscall}${file}${where}`; + } + const message = error.message.split("\n", 1)[0].slice(0, MAX_MESSAGE); + return `${error.name}: ${message}${where}`; +} +function schemaIssues(error) { + const issues = error.issues.slice(0, MAX_ISSUES).map((issue) => { + const path2 = issue.path.map((segment) => typeof segment === "number" || PATH_SEGMENT.test(segment) ? String(segment) : "*").join("."); + return `${path2 || "(root)"} ${issue.code}`; + }); + return `${issues.join(", ")}${error.issues.length > MAX_ISSUES ? ` and ${error.issues.length - MAX_ISSUES} more` : ""}`; +} +function sourceFrame(stack) { + for (const line of (stack ?? "").split("\n").slice(1)) { + const frame = STACK_FRAME.exec(line.trim()); + if (frame && !frame[1].includes("/node_modules/")) return ` at ${basename2(frame[1])}:${frame[2]}`; + } + return ""; +} // src/main-sync-report-execution.ts var REPLAY = /(?:^|[\s=])--(?:merge-reports|last-failed)(?:\b|=)|(?:^|[\s/])assert-e2e-results(?:\.mjs)?\b/; @@ -226087,63 +226170,6 @@ import { execFileSync as execFileSync3, spawnSync as spawnSync2 } from "node:chi import { createHash as createHash2 } from "node:crypto"; import { lstatSync as lstatSync2, readFileSync as readFileSync7, realpathSync as realpathSync4 } from "node:fs"; import { join as join6 } from "node:path"; - -// src/main-sync-failure.ts -import { basename as basename2 } from "node:path"; -var PRESERVE = "preserve the operation and inspect its owned evidence"; -var INVALID2 = "Main-sync context or private evidence is invalid"; -var ERROR_CODE = /^[A-Z][A-Z0-9_]{1,63}$/; -var SYSCALL = /^[a-z][A-Za-z]{1,31}(?: [A-Za-z0-9._-]{1,64})?$/; -var FILE_NAME = /^[A-Za-z0-9._@+-]{1,128}$/; -var COMMAND_WORD = /^[a-z][a-z0-9-]{0,39}$/; -var FAILED_COMMAND = /^Command failed: (\S+)(?: (\S+))?/; -var STACK_FRAME = /\(?(?:file:\/\/)?(\/[^():]+\.[cm]?[jt]s):(\d+):\d+\)?$/; -var MAX_MESSAGE = 240; -var PATH_SEGMENT = /^[A-Za-z0-9_.@+-]{1,64}$/; -var MAX_ISSUES = 3; -function mainSyncFailure(error) { - if (error instanceof MainSyncError) return { status: error.status, error: error.message }; - if (error instanceof external_exports.ZodError || error instanceof SyntaxError) return { status: "BLOCKED", error: `${INVALID2} (${failureCause(error)}); ${PRESERVE}` }; - return { status: "BLOCKED", error: `Main-sync stopped on an operational error (${failureCause(error)}); ${PRESERVE}` }; -} -function failureCause(error) { - if (error instanceof external_exports.ZodError) return `schema: ${schemaIssues(error)}`; - if (error instanceof SyntaxError) return `malformed JSON${sourceFrame(error.stack)}`; - return operationalCause(error); -} -function operationalCause(error) { - if (!(error instanceof Error)) return "non-Error exception"; - const fields = error; - const where = sourceFrame(error.stack); - if (Array.isArray(fields.output) || error.message.startsWith("Command failed:")) { - const words = (FAILED_COMMAND.exec(error.message)?.slice(1) ?? []).filter((word) => Boolean(word)).map((word, index) => index === 0 ? basename2(word) : word).filter((word) => COMMAND_WORD.test(word)); - const ended = typeof fields.status === "number" ? `exited ${fields.status}` : typeof fields.signal === "string" && ERROR_CODE.test(fields.signal) ? `stopped by ${fields.signal}` : "failed"; - return `subprocess ${words.length ? `${words.join(" ")} ` : ""}${ended}${where}`; - } - if (typeof fields.code === "string" && ERROR_CODE.test(fields.code)) { - const syscall = typeof fields.syscall === "string" && SYSCALL.test(fields.syscall) ? ` during ${fields.syscall}` : ""; - const file = typeof fields.path === "string" && FILE_NAME.test(basename2(fields.path)) ? ` on ${basename2(fields.path)}` : ""; - return `${error.name} ${fields.code}${syscall}${file}${where}`; - } - const message = error.message.split("\n", 1)[0].slice(0, MAX_MESSAGE); - return `${error.name}: ${message}${where}`; -} -function schemaIssues(error) { - const issues = error.issues.slice(0, MAX_ISSUES).map((issue) => { - const path2 = issue.path.map((segment) => typeof segment === "number" || PATH_SEGMENT.test(segment) ? String(segment) : "*").join("."); - return `${path2 || "(root)"} ${issue.code}`; - }); - return `${issues.join(", ")}${error.issues.length > MAX_ISSUES ? ` and ${error.issues.length - MAX_ISSUES} more` : ""}`; -} -function sourceFrame(stack) { - for (const line of (stack ?? "").split("\n").slice(1)) { - const frame = STACK_FRAME.exec(line.trim()); - if (frame && !frame[1].includes("/node_modules/")) return ` at ${basename2(frame[1])}:${frame[2]}`; - } - return ""; -} - -// src/main-sync-generated-input.ts var GENERATED_BIOME_PATH = ".biome-noscan.jsonc"; var MAX_INPUT_BYTES = 128 * 1024 * 1024; var PRODUCER_FILES = [ @@ -226882,7 +226908,212 @@ function verifyReportProducerInputs(retainedRoot, root, owner, inputs) { } } +// src/main-sync-validation-recipe.ts +var MAIN_BOOTSTRAP_MS = 15 * 60 * 1e3; +function recipeIdentity(value) { + return digest(JSON.stringify({ + commands: value.commands, + e2eLimitations: value.e2eLimitations, + ...value.recovery ? { recovery: value.recovery } : {}, + ...value.comparison ? { comparison: value.comparison } : {} + })); +} +function storedRecipe(identity, recipe) { + return storedValidationRecipeSchema.parse({ ...identity, ...recipe, recipeSha256: recipeIdentity(recipe) }); +} +function verifyRecipeWitness(recipe, witness, transactionId) { + if (witness.transactionId !== transactionId || recipe.headSha !== witness.headSha || recipe.treeSha !== witness.treeSha || recipe.inputSha256 !== witness.inputSha256 || recipe.recipeSha256 !== witness.recipeSha256 || recipeIdentity(recipe) !== recipe.recipeSha256) throw new MainSyncError("Canonical validation recipe/witness identity changed", "NEEDS_CHANGE"); +} +function recoveryBudgetMs(owners) { + const needsCold = owners.some((id) => id !== "desktop-build" && id !== "desktop-migrations"); + let total = needsCold ? 3 * 15 * 60 * 1e3 : 0; + for (const id of owners) { + if (id === "fumadocs") total += 2 * 15 * 60 * 1e3; + else if (id.startsWith("next-") || id === "desktop-docs") total += 15 * 60 * 1e3; + else total += 1e4; + } + return total; +} +function comparisonBudgetMs(commands) { + return MAIN_BOOTSTRAP_MS + commands.reduce((total, command) => total + 2 * command.timeoutMs, 0); +} + +// src/main-sync-main-comparison.ts +var BOOTSTRAP = ["bash", ".closedloop-ai/loops-setup.sh"]; +var PINNED_FILTER = /^--filter=\.\.\.\[[a-f0-9]{40}\]$/; +var CLEANUP_MS = 15 * 60 * 1e3; +var WRAPPER_GRACE_MS = 30 * 1e3; +var CapturedMainComparison = class { + constructor(root, dir, receipt) { + this.root = root; + this.dir = dir; + this.receipt = receipt; + } + root; + dir; + receipt; + checkout; + registered = false; + bootstrap; + /** Classifies one failing session run; passing checks never reach this. */ + classify(command, selection, failed, prefix, env, rerun) { + const mainSha = this.receipt.mainSha; + const main = this.runOnMain(command, selection, prefix, env); + if (main.outcome === "failed") return { classification: "main-preexisting", mainSha, sessionRuns: [failed], main }; + if (main.outcome === "unrunnable") return { classification: "session-caused", mainSha, sessionRuns: [failed], main }; + const again = rerun(`${prefix}-rerun.log`); + return { classification: again.exitCode === 0 ? "flaky" : "session-caused", mainSha, sessionRuns: [failed, again], main }; + } + /** Removes the disposable checkout; an unproven removal keeps the operation's evidence and refuses. */ + dispose() { + if (!this.checkout) return; + const target = this.checkout; + this.checkout = void 0; + if (!this.registered) { + rmSync3(target, { recursive: true, force: true }); + return; + } + try { + git(this.root, ["worktree", "remove", "--force", target], { timeout: CLEANUP_MS }); + } catch { + throw new MainSyncError("Captured-main comparison checkout cleanup is unproven; preserve its operation evidence"); + } + } + runOnMain(command, selection, prefix, env) { + const scoped = capturedMainArgv(this.root, this.receipt.mainSha, command, selection); + if ("reason" in scoped) return { outcome: "unrunnable", reason: scoped.reason }; + const ready = this.prepare(); + if (!ready.ok) { + return { + outcome: "unrunnable", + argv: scoped.argv, + reason: ready.reason, + ...ready.log && ready.sha256 ? { log: ready.log, sha256: ready.sha256 } : {} + }; + } + const checkout = this.checkout; + const cwd = command.cwd ? join10(checkout, command.cwd) : checkout; + try { + verifyReportExecution(this.root, checkout, ReportExecutionOwner.Check, scoped.argv, cwd, selection); + } catch (error) { + return { outcome: "unrunnable", argv: scoped.argv, reason: `report admission refused: ${failureCause(error)}`.slice(0, 512) }; + } + const run = runBounded(scoped.argv, cwd, env, command.timeoutMs ?? MAX_CHECK_MS, `${prefix}-main.log`); + if (run.exitCode === 0) return { outcome: "passed", argv: scoped.argv, exitCode: 0, log: run.log, sha256: run.sha256 }; + if (run.timedOut || run.spawnFailed) { + return { + outcome: "unrunnable", + argv: scoped.argv, + exitCode: run.exitCode, + log: run.log, + sha256: run.sha256, + reason: run.timedOut ? "it ran out of its time limit on captured main" : "it could not be started on captured main" + }; + } + return { outcome: "failed", argv: scoped.argv, exitCode: run.exitCode, log: run.log, sha256: run.sha256 }; + } + prepare() { + if (this.bootstrap) return this.bootstrap; + const mainSha = this.receipt.mainSha; + try { + this.checkout = realpathSync7(mkdtempSync(join10(tmpdir(), "vibe-captured-main-"))); + git(this.root, ["worktree", "add", "--detach", this.checkout, mainSha], { timeout: CLEANUP_MS }); + this.registered = true; + } catch (error) { + if (error instanceof MainSyncError) throw error; + this.bootstrap = { ok: false, reason: `a checkout of captured main could not be created: ${failureCause(error)}`.slice(0, 512) }; + return this.bootstrap; + } + if (!existsSync7(join10(this.checkout, BOOTSTRAP[1]))) { + this.bootstrap = { ok: false, reason: "captured main has no canonical bootstrap (.closedloop-ai/loops-setup.sh)" }; + return this.bootstrap; + } + try { + verifyReportExecution(this.root, this.checkout, ReportExecutionOwner.Bootstrap, BOOTSTRAP, this.checkout); + } catch (error) { + this.bootstrap = { ok: false, reason: `bootstrap admission refused: ${failureCause(error)}`.slice(0, 512) }; + return this.bootstrap; + } + const log = join10(this.dir, `vibe-main-sync-${this.receipt.transactionId}-main-bootstrap.log`); + const run = runBounded(BOOTSTRAP, this.checkout, process.env, MAIN_BOOTSTRAP_MS, log); + this.bootstrap = run.exitCode === 0 ? { ok: true } : { + ok: false, + reason: run.timedOut ? "the captured main bootstrap ran out of its time limit" : `the captured main bootstrap exited ${run.exitCode}`, + log: run.log, + sha256: run.sha256 + }; + return this.bootstrap; + } +}; +function runBounded(argv, cwd, env, timeoutMs, log) { + const seconds = Math.max(1, Math.ceil(timeoutMs / 1e3)); + const result = spawnSync3(process.execPath, [runBoundedScript(), String(seconds), ...argv], { + cwd, + env, + encoding: "utf8", + timeout: seconds * 1e3 + WRAPPER_GRACE_MS, + maxBuffer: 16 * 1024 * 1024 + }); + const output = `${result.stdout ?? ""}${result.stderr ?? ""}`; + writeFileSync4(log, output, { mode: 384 }); + const exitCode = result.status ?? 1; + const stderr = result.stderr ?? ""; + return { + exitCode, + log, + sha256: digest(output), + timedOut: Boolean(result.error) || exitCode === TIMED_OUT_EXIT && stderr.includes(TIMED_OUT_MARKER), + spawnFailed: exitCode === WRAPPER_FAILED_EXIT && SPAWN_FAILURE.test(stderr) + }; +} +function comparisonLogs(comparison) { + return [ + ...comparison.sessionRuns.map((run) => ({ log: run.log, sha256: run.sha256 })), + ...comparison.main.log && comparison.main.sha256 ? [{ log: comparison.main.log, sha256: comparison.main.sha256 }] : [] + ]; +} +function capturedMainArgv(root, mainSha, command, selection) { + const words = command.argv; + let task; + let rest = []; + if (words.join(" ") === "pnpm typecheck:affected") { + task = "typecheck"; + rest = ["--concurrency=1"]; + } else if (words[0] === "pnpm" && words[1] === "test:affected") { + task = "test"; + rest = words.slice(2); + } else if (words[0] === "pnpm" && words[1] === "turbo" && words.slice(3).some((word) => PINNED_FILTER.test(word))) { + task = words[2]; + rest = words.slice(3).filter((word) => !PINNED_FILTER.test(word)); + } + if (task) { + const packages = [...new Set((selection?.tasks ?? []).filter((item) => item.taskId.endsWith(`#${task}`)).map((item) => item.package ?? item.taskId.slice(0, item.taskId.lastIndexOf("#"))))].sort(); + if (!packages.length) return { reason: "the session's selected tasks are not available to pin the same check on captured main" }; + return { argv: ["pnpm", "turbo", task, ...packages.map((name) => `--filter=${name}`), ...rest] }; + } + if (words[0] === "pnpm" && words[1] === "exec" && words[2] === "biome" && words[3] === "check") { + for (const path2 of words.slice(4)) { + try { + git(root, ["cat-file", "-e", `${mainSha}:${path2}`]); + } catch { + return { reason: `the checked file ${path2} does not exist on captured main` }; + } + } + } + return { argv: [...words] }; +} +var TIMED_OUT_EXIT = 124; +var TIMED_OUT_MARKER = "run-bounded: stopped after"; +var WRAPPER_FAILED_EXIT = 2; +var SPAWN_FAILURE = /^spawn \S+ E[A-Z]+\s*$/m; +function runBoundedScript() { + const script = fileURLToPath3(new URL("../run-bounded.mjs", import.meta.url)); + if (!existsSync7(script)) throw new MainSyncError("The plugin's run-bounded.mjs is missing; no comparison was run"); + return script; +} + // src/main-sync-lanes.ts +import { spawnSync as spawnSync4 } from "node:child_process"; var laneSchema = external_exports.object({ script: external_exports.string().min(1), dir: external_exports.string().optional(), @@ -226932,7 +227163,7 @@ function discoverCheckLanes(root, receipt) { const discovery = { argv: ["pnpm", "exec", "node", "--import", "tsx/esm", "--input-type=module", "--eval", DISCOVERY_SOURCE, receipt.mainSha] }; verifyReportProducerInputs(root, root, ReportExecutionOwner.Discovery, DISCOVERY_MODULES); verifyReportExecution(root, root, ReportExecutionOwner.Discovery, discovery.argv); - const run = spawnSync3(discovery.argv[0], discovery.argv.slice(1), { + const run = spawnSync4(discovery.argv[0], discovery.argv.slice(1), { cwd: root, encoding: "utf8", timeout: MAX_CHECK_MS, @@ -226975,7 +227206,7 @@ function discoverCheckLanes(root, receipt) { return { commands, limitations }; } function displaylessPrerequisites(root) { - const result = spawnSync3( + const result = spawnSync4( "sh", ["-c", "command -v dbus-run-session && command -v xvfb-run && command -v gnome-keyring-daemon"], { cwd: root, encoding: "utf8", timeout: 1e4, maxBuffer: 1024 * 1024 } @@ -226984,8 +227215,8 @@ function displaylessPrerequisites(root) { } // src/main-sync-private-inputs.ts -import { existsSync as existsSync7, lstatSync as lstatSync5, realpathSync as realpathSync7 } from "node:fs"; -import { dirname as dirname8, join as join10, relative as relative5, resolve as resolve5, sep as sep3 } from "node:path"; +import { existsSync as existsSync8, lstatSync as lstatSync5, realpathSync as realpathSync8 } from "node:fs"; +import { dirname as dirname8, join as join11, relative as relative5, resolve as resolve5, sep as sep3 } from "node:path"; var analyzed; function verifyPrivateInput(root, file, head) { verifyEvidencePath(root, file); @@ -227000,20 +227231,20 @@ function verifyPrivateInput(root, file, head) { } function verifyPrivateRoot(root) { for (const path2 of [".closedloop-ai", ".closedloop-ai/vibe-plans"]) { - const stat = lstatSync5(join10(root, path2), { throwIfNoEntry: false }); - if (stat && (!stat.isDirectory() || stat.isSymbolicLink() || realpathSync7(join10(root, path2)) !== join10(root, path2))) { + const stat = lstatSync5(join11(root, path2), { throwIfNoEntry: false }); + if (stat && (!stat.isDirectory() || stat.isSymbolicLink() || realpathSync8(join11(root, path2)) !== join11(root, path2))) { throw new MainSyncError("Private planning evidence requires its canonical owned directory; no symlink or alias is accepted", "NEEDS_CHANGE"); } } } function verifyEvidencePath(root, file, label = "Private planning evidence") { - const target = join10(root, file); + const target = join11(root, file); const stat = lstatSync5(target); - if (!stat.isFile() || stat.isSymbolicLink() || stat.size > MAX_INPUT_BYTES || realpathSync7(target) !== target) { + if (!stat.isFile() || stat.isSymbolicLink() || stat.size > MAX_INPUT_BYTES || realpathSync8(target) !== target) { throw new MainSyncError(`${label} requires a bounded canonical regular file`, "NEEDS_CHANGE"); } for (let parent = dirname8(target); parent !== root; parent = dirname8(parent)) { - if (!parent.startsWith(`${root}${sep3}`) || lstatSync5(parent).isSymbolicLink() || realpathSync7(parent) !== parent) { + if (!parent.startsWith(`${root}${sep3}`) || lstatSync5(parent).isSymbolicLink() || realpathSync8(parent) !== parent) { throw new MainSyncError(`${label} cannot use a symlinked or escaping parent`, "NEEDS_CHANGE"); } } @@ -227022,16 +227253,16 @@ function referencesPrivateInputs(root, privatePaths) { return committedInputReferences(root, (file, reference) => { if (explicitPlanningReference(root, file, reference) || referenceMatches(root, file, reference, privatePaths)) return true; if (reference.kind !== "path") return false; - const path2 = resolve5(reference.base === "module" ? dirname8(join10(root, file)) : root, reference.value); - return existsSync7(path2) && isLocalPlanPath(relative5(root, realpathSync7(path2))); + const path2 = resolve5(reference.base === "module" ? dirname8(join11(root, file)) : root, reference.value); + return existsSync8(path2) && isLocalPlanPath(relative5(root, realpathSync8(path2))); }); } // src/main-sync-budget.ts var import_yaml4 = __toESM(require_dist(), 1); -import { spawnSync as spawnSync4 } from "node:child_process"; +import { spawnSync as spawnSync5 } from "node:child_process"; import { readFileSync as readFileSync11 } from "node:fs"; -import { join as join11 } from "node:path"; +import { join as join12 } from "node:path"; import { createHash as createHash3 } from "node:crypto"; // src/main-sync-budget-ref-read.ts @@ -227087,20 +227318,20 @@ function commandSelection(root, command, timeoutMs = GENERAL_CHECK_MS) { let argv; if (command.argv[0] === "pnpm" && command.argv[1] === "turbo") argv = [...command.argv.slice(1), "--dry=json"]; else if (command.argv.join(" ") === "pnpm typecheck:affected") { - const pkg = wrapperSchema.parse(JSON.parse(readFileSync11(join11(root, "package.json"), "utf8"))); - if (pkg.scripts["typecheck:affected"] !== "sh scripts/typecheck-affected.sh" || createHash3("sha256").update(readFileSync11(join11(root, "scripts/typecheck-affected.sh"))).digest("hex") !== AFFECTED_TYPECHECK_SHA) { + const pkg = wrapperSchema.parse(JSON.parse(readFileSync11(join12(root, "package.json"), "utf8"))); + if (pkg.scripts["typecheck:affected"] !== "sh scripts/typecheck-affected.sh" || createHash3("sha256").update(readFileSync11(join12(root, "scripts/typecheck-affected.sh"))).digest("hex") !== AFFECTED_TYPECHECK_SHA) { throw new MainSyncError("Affected typecheck wrapper owner changed; no selected producer authority", "NEEDS_CHANGE"); } argv = ["turbo", "typecheck", `--filter=${affectedFilter(root)}`, "--concurrency=1", "--dry=json"]; } else return void 0; verifyReportExecution(root, root, ReportExecutionOwner.TurboSelection, ["pnpm", ...argv]); - const run = spawnSync4("pnpm", argv, { cwd: root, encoding: "utf8", timeout: timeoutMs, maxBuffer: 16 * 1024 * 1024 }); + const run = spawnSync5("pnpm", argv, { cwd: root, encoding: "utf8", timeout: timeoutMs, maxBuffer: 16 * 1024 * 1024 }); if (run.error || run.status !== 0) throw new MainSyncError("Could not bind the actual canonical Turbo task selection"); return tasksSchema.parse(JSON.parse(run.stdout)); } function affectedFilter(root) { verifyReportExecution(root, root, ReportExecutionOwner.AffectedFilter, ["sh", "scripts/affected-filter.sh"]); - const selected = spawnSync4("sh", ["scripts/affected-filter.sh"], { cwd: root, encoding: "utf8", timeout: 1e4, maxBuffer: 1024 * 1024 }); + const selected = spawnSync5("sh", ["scripts/affected-filter.sh"], { cwd: root, encoding: "utf8", timeout: 1e4, maxBuffer: 1024 * 1024 }); if (selected.error || selected.status !== 0) throw new MainSyncError("Existing affected-filter owner failed; no selection was derived"); return affectedFilterSchema.parse(selected.stdout.trim()); } @@ -227128,7 +227359,7 @@ function bindCheckBudgets(root, receipt, commands) { } function selectedTestBudget(root, receipt, affected) { const { head } = readBudgetRefs(root); - const workflow = workflowSchema2.parse((0, import_yaml4.parse)(readFileSync11(join11(root, ".github/workflows/pr-test.yml"), "utf8"))); + const workflow = workflowSchema2.parse((0, import_yaml4.parse)(readFileSync11(join12(root, ".github/workflows/pr-test.yml"), "utf8"))); let filter = `...[${receipt.validationSince}]`; if (affected) { filter = affectedFilter(root); @@ -227137,7 +227368,7 @@ function selectedTestBudget(root, receipt, affected) { } } verifyReportExecution(root, root, ReportExecutionOwner.TurboSelection, ["pnpm", "turbo", "test", `--filter=${filter}`, "--dry=json"]); - const run = spawnSync4("pnpm", ["turbo", "test", `--filter=${filter}`, "--dry=json"], { + const run = spawnSync5("pnpm", ["turbo", "test", `--filter=${filter}`, "--dry=json"], { cwd: root, encoding: "utf8", timeout: 12e4, @@ -227173,30 +227404,30 @@ function wrapperBase(root) { } } function testWrapperIdentity(root) { - return createHash3("sha256").update(readFileSync11(join11(root, "scripts/test-affected.sh"))).digest("hex"); + return createHash3("sha256").update(readFileSync11(join12(root, "scripts/test-affected.sh"))).digest("hex"); } function canonicalTestWrapper(root) { - const pkg = wrapperSchema.parse(JSON.parse(readFileSync11(join11(root, "package.json"), "utf8"))); + const pkg = wrapperSchema.parse(JSON.parse(readFileSync11(join12(root, "package.json"), "utf8"))); if (pkg.scripts["test:affected"] !== "sh scripts/test-affected.sh") return false; return testWrapperIdentity(root) === AFFECTED_TEST_SHA; } // src/main-sync-check-production.ts import { readFileSync as readFileSync14 } from "node:fs"; -import { join as join14 } from "node:path"; +import { join as join15 } from "node:path"; // src/main-sync-input-origin.ts -import { spawnSync as spawnSync6 } from "node:child_process"; +import { spawnSync as spawnSync7 } from "node:child_process"; import { randomUUID as randomUUID2 } from "node:crypto"; -import { lstatSync as lstatSync6, mkdtempSync, readFileSync as readFileSync13, realpathSync as realpathSync8, rmSync as rmSync3, writeFileSync as writeFileSync4 } from "node:fs"; -import { tmpdir } from "node:os"; -import { join as join13 } from "node:path"; +import { lstatSync as lstatSync6, mkdtempSync as mkdtempSync2, readFileSync as readFileSync13, realpathSync as realpathSync9, rmSync as rmSync4, writeFileSync as writeFileSync5 } from "node:fs"; +import { tmpdir as tmpdir2 } from "node:os"; +import { join as join14 } from "node:path"; // src/main-sync-input-pure.ts var import_typescript4 = __toESM(require_typescript(), 1); -import { spawnSync as spawnSync5 } from "node:child_process"; +import { spawnSync as spawnSync6 } from "node:child_process"; import { readFileSync as readFileSync12 } from "node:fs"; -import { join as join12, relative as relative6 } from "node:path"; +import { join as join13, relative as relative6 } from "node:path"; var PURE_OWNER_PROBE = String.raw` import {readFileSync,lstatSync,realpathSync} from 'node:fs'; import {join} from 'node:path'; @@ -227240,7 +227471,7 @@ function pureOriginFiles(root, producer, head) { ReportExecutionOwner.Pure, ["pnpm", "exec", "node", "--import", "tsx/esm", "--input-type=module", "--eval", PURE_OWNER_PROBE] ); - const result = spawnSync5("pnpm", ["exec", "node", "--import", "tsx/esm", "--input-type=module", "--eval", PURE_OWNER_PROBE], { + const result = spawnSync6("pnpm", ["exec", "node", "--import", "tsx/esm", "--input-type=module", "--eval", PURE_OWNER_PROBE], { cwd: root, encoding: "utf8", input: JSON.stringify({ id: producer.id, head, output: producer.roots[0] }), @@ -227253,7 +227484,7 @@ function pureOriginFiles(root, producer, head) { function huskyBody(root, producer) { const owner = producerPackage(root, producer); if (!owner) throw new MainSyncError("Locked Husky owner is unavailable", "NEEDS_CHANGE"); - const file = join12(owner.directory, "index.js"); + const file = join13(owner.directory, "index.js"); const source = import_typescript4.default.createSourceFile(relative6(root, file), readFileSync12(file, "utf8"), import_typescript4.default.ScriptTarget.Latest, true); const candidates = []; const inspect = (node) => { @@ -227325,10 +227556,10 @@ function prepareInputOrigins(value, before, producers) { const packages = []; if (!producers.length) return proof(); const needsCold = producers.some((producer) => producer.argv || producer.package); - const cold = needsCold ? realpathSync8(mkdtempSync(join13(tmpdir(), "vibe-input-origin-"))) : void 0; + const cold = needsCold ? realpathSync9(mkdtempSync2(join14(tmpdir2(), "vibe-input-origin-"))) : void 0; let registered = false; const commands = []; - const log = join13(value.place.dir, `vibe-input-origin-${attempt}.json`); + const log = join14(value.place.dir, `vibe-input-origin-${attempt}.json`); try { verifyProducerInputs(root, before.headSha, producers); verifyReportProducerInputs(root, root, ReportExecutionOwner.Producer, producers.flatMap((producer) => producer.inputs)); @@ -227337,11 +227568,11 @@ function prepareInputOrigins(value, before, producers) { registered = true; assertTracked(cold, before); for (const producer of producers) { - for (const path2 of producer.roots) if (lstatSync6(join13(cold, path2), { throwIfNoEntry: false })) { + for (const path2 of producer.roots) if (lstatSync6(join14(cold, path2), { throwIfNoEntry: false })) { throw new MainSyncError(`Pristine output origin was not empty: ${path2}`, "NEEDS_CHANGE"); } } - if (lstatSync6(join13(cold, "node_modules"), { throwIfNoEntry: false }) || lstatSync6(join13(cold, "apps/desktop/src/main/database/migrations-manifest.ts"), { throwIfNoEntry: false })) { + if (lstatSync6(join14(cold, "node_modules"), { throwIfNoEntry: false }) || lstatSync6(join14(cold, "apps/desktop/src/main/database/migrations-manifest.ts"), { throwIfNoEntry: false })) { throw new MainSyncError("Pristine bootstrap prerequisites are not empty or contain a legacy migration artifact", "NEEDS_CHANGE"); } run(cold, ["bash", ".closedloop-ai/loops-setup.sh"]); @@ -227408,7 +227639,7 @@ function prepareInputOrigins(value, before, producers) { } catch { throw new MainSyncError("Owned validation checkout cleanup is unproven; preserve its operation evidence"); } - } else if (cold) rmSync3(cold, { recursive: true }); + } else if (cold) rmSync4(cold, { recursive: true }); } function proof() { return { @@ -227424,13 +227655,13 @@ function prepareInputOrigins(value, before, producers) { }; } function writeLog() { - writeFileSync4(log, JSON.stringify({ headSha: before.headSha, treeSha: before.treeSha, ...cold ? { scratch: cold } : {}, commands }), { mode: 384 }); + writeFileSync5(log, JSON.stringify({ headSha: before.headSha, treeSha: before.treeSha, ...cold ? { scratch: cold } : {}, commands }), { mode: 384 }); } function run(cwd, argv) { const owner = argv[0] === "bash" ? ReportExecutionOwner.Bootstrap : ReportExecutionOwner.Producer; verifyReportExecution(root, cwd, owner, argv, cwd); const executable = argv[0] === "node" ? process.execPath : argv[0]; - const result = spawnSync6(executable, argv.slice(1), { cwd, encoding: "utf8", timeout: OWNER_MS, maxBuffer: 16 * 1024 * 1024 }); + const result = spawnSync7(executable, argv.slice(1), { cwd, encoding: "utf8", timeout: OWNER_MS, maxBuffer: 16 * 1024 * 1024 }); commands.push({ argv, exitCode: result.status, stdoutSha256: digest(result.stdout ?? ""), stderrSha256: digest(result.stderr ?? "") }); if (result.error || result.status !== 0) throw new MainSyncError(`Canonical generated-input producer failed: ${argv.slice(0, 4).join(" ")}; preserve private operation evidence`, "NEEDS_CHANGE"); } @@ -227452,31 +227683,6 @@ function reusableInputOrigin(value, before, stored) { return stored; } -// src/main-sync-validation-recipe.ts -function recipeIdentity(value) { - return digest(JSON.stringify({ - commands: value.commands, - e2eLimitations: value.e2eLimitations, - ...value.recovery ? { recovery: value.recovery } : {} - })); -} -function storedRecipe(identity, recipe) { - return storedValidationRecipeSchema.parse({ ...identity, ...recipe, recipeSha256: recipeIdentity(recipe) }); -} -function verifyRecipeWitness(recipe, witness, transactionId) { - if (witness.transactionId !== transactionId || recipe.headSha !== witness.headSha || recipe.treeSha !== witness.treeSha || recipe.inputSha256 !== witness.inputSha256 || recipe.recipeSha256 !== witness.recipeSha256 || recipeIdentity(recipe) !== recipe.recipeSha256) throw new MainSyncError("Canonical validation recipe/witness identity changed", "NEEDS_CHANGE"); -} -function recoveryBudgetMs(owners) { - const needsCold = owners.some((id) => id !== "desktop-build" && id !== "desktop-migrations"); - let total = needsCold ? 3 * 15 * 60 * 1e3 : 0; - for (const id of owners) { - if (id === "fumadocs") total += 2 * 15 * 60 * 1e3; - else if (id.startsWith("next-") || id === "desktop-docs") total += 15 * 60 * 1e3; - else total += 1e4; - } - return total; -} - // src/main-sync-check-production.ts var packageSchema4 = external_exports.object({ scripts: external_exports.record(external_exports.string()).optional() }).passthrough(); function recheckSelectedProduction(value, receipt, command, selection, eligible) { @@ -227526,7 +227732,7 @@ function selectedProducer(root, id, command, selection) { if (!direct && command.argv.join(" ") !== "pnpm typecheck:affected" || !selection) return false; const owner = id === "fumadocs" ? "web" : id === "next-api" ? "api" : id === "next-app" ? "app" : id.startsWith("desktop-") ? "desktop" : void 0; if (!owner || !selection.tasks.some((task) => task.package === owner && task.taskId.endsWith("#typecheck"))) return false; - const script = packageSchema4.parse(JSON.parse(readFileSync14(join14(root, "apps", owner, "package.json"), "utf8"))).scripts?.typecheck; + const script = packageSchema4.parse(JSON.parse(readFileSync14(join15(root, "apps", owner, "package.json"), "utf8"))).scripts?.typecheck; const first = script?.split("&&")[0]?.trim(); if (owner === "web") return first === "fumadocs-mdx"; if (owner === "app" || owner === "api") return first === "next typegen"; @@ -227568,8 +227774,8 @@ function committedInputs(root) { const before = inspected.inventory; if (!before.producers.length) return { headSha: before.headSha, treeSha: before.treeSha, inputSha256: before.inputSha256 }; const metadata = inspected.metadata; - const file = join15(metadata, MAIN_SYNC_FILE); - if (!existsSync9(file)) throw new MainSyncError("Canonical source input readiness is required before generated dependencies can be consumed", "NEEDS_CHANGE"); + const file = join16(metadata, MAIN_SYNC_FILE); + if (!existsSync10(file)) throw new MainSyncError("Canonical source input readiness is required before generated dependencies can be consumed", "NEEDS_CHANGE"); const state = mainSyncStateSchema.parse(readPrivateJson(file)); if (state.worktree !== root) throw new MainSyncError("Generated input origin belongs to another worktree", "NEEDS_CHANGE"); const proof = state.inputs; @@ -227584,9 +227790,9 @@ function inspectInventory(root, completion) { const before = trackedInputs(root); verifyPrivateRoot(root); for (const path2 of reportOutputs) { - if (lstatSync7(join15(root, path2), { throwIfNoEntry: false })) verifyEvidencePath(root, path2, "Retained report"); + if (lstatSync7(join16(root, path2), { throwIfNoEntry: false })) verifyEvidencePath(root, path2, "Retained report"); } - const generated = lstatSync7(join15(root, GENERATED_BIOME_PATH), { throwIfNoEntry: false }); + const generated = lstatSync7(join16(root, GENERATED_BIOME_PATH), { throwIfNoEntry: false }); if (generated && !generated.isFile()) { throw new MainSyncError(`Generated-input verification requires a bounded canonical regular file: ${GENERATED_BIOME_PATH}`, "NEEDS_CHANGE"); } @@ -227608,7 +227814,7 @@ function inspectInventory(root, completion) { continue; } if (ignored && reportOutputs.has(file)) { - const stat = lstatSync7(join15(root, file)); + const stat = lstatSync7(join16(root, file)); if (!stat.isFile() || stat.isSymbolicLink()) throw new MainSyncError(`Retained report must be a regular owned output: ${file}`, "NEEDS_CHANGE"); continue; } @@ -227677,8 +227883,8 @@ function trackedInputs(root) { const tab = entry.indexOf(" "); const [mode, kind, object] = entry.slice(0, tab).split(" "); const file = entry.slice(tab + 1); - const target = join15(root, file); - if (kind !== "blob" || !existsSync9(target)) throw new MainSyncError(`Committed validation inputs differ from HEAD: ${file}`, "NEEDS_CHANGE"); + const target = join16(root, file); + if (kind !== "blob" || !existsSync10(target)) throw new MainSyncError(`Committed validation inputs differ from HEAD: ${file}`, "NEEDS_CHANGE"); const stat = lstatSync7(target); if (stat.size > MAX_INPUT_BYTES) throw new MainSyncError(`Validation input exceeds the bounded file limit: ${file}`); const content = mode === "120000" && stat.isSymbolicLink() ? Buffer.from(readlinkSync(target)) : stat.isFile() ? readFileSync15(target) : void 0; @@ -227696,7 +227902,7 @@ function checkRecipe(value, receipt) { const files = git(root, ["diff", "--name-only", "--no-renames", receipt.mainSha, "HEAD"]).split("\n").filter(Boolean); const prototype = value.place.branch.startsWith("prototype/"); const backend = files.some((file) => BACKEND_PREFIXES.some((prefix) => file.startsWith(prefix)) || /\/(?:prisma|migrations)\//.test(`/${file}`)); - const paths = files.filter((file) => SOURCE_PATH.test(file) && existsSync9(join15(root, file))); + const paths = files.filter((file) => SOURCE_PATH.test(file) && existsSync10(join16(root, file))); const commands = []; const e2eLimitations = []; if (value.purpose === "build") { @@ -227745,77 +227951,115 @@ function checkRecipe(value, receipt) { commands.push({ argv: ["pnpm", "vibe", "storybook-diff"] }); verifyReportRecipe(commands); const bound = bindCheckBudgets(root, receipt, commands); - return { commands: bound, e2eLimitations, recovery: selectedRecoveryReserve(root, bound) }; + return { + commands: bound, + e2eLimitations, + recovery: selectedRecoveryReserve(root, bound), + comparison: { timeoutMs: comparisonBudgetMs(bound.map((command) => ({ timeoutMs: command.timeoutMs ?? MAX_CHECK_MS }))) } + }; } function executeChecks(value, receipt, preparedRecipe) { let before = committedInputs(value.place.root); const recipe = preparedRecipe ?? checkRecipe(value, receipt); const spent = recipe.commands.map(() => 0); const drySpent = recipe.commands.map(() => 0); - for (let attempt = 0; attempt < 2; attempt++) { - const results = []; - let restart = false; - for (const [index, command] of recipe.commands.entries()) { - assertIdentity(value.place.root, before); - let selection = affectedTestReportSelection(value.place.root, command); - if (command.argv[1] === "turbo" || command.argv.join(" ") === "pnpm typecheck:affected") { - const start2 = performance2.now(); - selection = commandSelection(value.place.root, command, remainingBudget(MAX_CHECK_MS, drySpent[index])); - drySpent[index] += performance2.now() - start2; - if (command.argv[1] === "turbo" && !selection?.tasks.length && git(value.place.root, ["diff", "--name-only", receipt.validationSince, "HEAD"])) { - throw new MainSyncError("Pinned validation selected no tasks for changed inputs; no executed coverage was established"); - } + const main = value.purpose === "handoff" ? new CapturedMainComparison(value.place.root, value.place.dir, receipt) : void 0; + try { + for (let attempt = 0; attempt < 2; attempt++) { + const results = []; + let restart = false; + for (const [index, command] of recipe.commands.entries()) { assertIdentity(value.place.root, before); + let selection = affectedTestReportSelection(value.place.root, command); + if (command.argv[1] === "turbo" || command.argv.join(" ") === "pnpm typecheck:affected") { + const start2 = performance2.now(); + selection = commandSelection(value.place.root, command, remainingBudget(MAX_CHECK_MS, drySpent[index])); + drySpent[index] += performance2.now() - start2; + if (command.argv[1] === "turbo" && !selection?.tasks.length && git(value.place.root, ["diff", "--name-only", receipt.validationSince, "HEAD"])) { + throw new MainSyncError("Pinned validation selected no tasks for changed inputs; no executed coverage was established"); + } + assertIdentity(value.place.root, before); + } + const env = { ...process.env, TURBO_CONCURRENCY: "2" }; + Reflect.deleteProperty(env, "AFFECTED_SCRIPT_SUITES"); + Object.assign(env, command.bindings); + const start = performance2.now(); + verifyReportExecution( + value.place.root, + value.place.root, + ReportExecutionOwner.Check, + command.argv, + command.cwd ? join16(value.place.root, command.cwd) : value.place.root, + selection + ); + const result = spawnSync8(command.argv[0], command.argv.slice(1), { + cwd: command.cwd ? join16(value.place.root, command.cwd) : value.place.root, + encoding: "utf8", + timeout: remainingBudget(command.timeoutMs ?? MAX_CHECK_MS, spent[index]), + maxBuffer: 16 * 1024 * 1024, + env + }); + spent[index] += performance2.now() - start; + const output = `${result.stdout ?? ""}${result.stderr ?? ""}`; + const prefix = join16(value.place.dir, `vibe-main-sync-${receipt.transactionId}-${attempt}-${index}`); + const log = `${prefix}.log`; + writeFileSync6(log, output, { mode: 384 }); + let evidence = { exitCode: 0, log, sha256: digest(output) }; + let comparison; + if (result.error || result.status !== 0) { + if (!main) throw new MainSyncError(`Required validation command failed: ${command.argv.join(" ")}; private log ${log}`); + const failed = { ...evidence, exitCode: result.status ?? 1 }; + comparison = main.classify(command, selection, failed, prefix, env, (rerunLog) => { + verifyReportExecution( + value.place.root, + value.place.root, + ReportExecutionOwner.Check, + command.argv, + command.cwd ? join16(value.place.root, command.cwd) : value.place.root, + selection + ); + const again = runBounded( + command.argv, + command.cwd ? join16(value.place.root, command.cwd) : value.place.root, + env, + command.timeoutMs ?? MAX_CHECK_MS, + rerunLog + ); + return { exitCode: again.exitCode, log: again.log, sha256: again.sha256 }; + }); + if (comparison.classification === "session-caused") { + recordFailedCheck(value, command, comparison); + throw new MainSyncError(sessionCausedMessage(command, comparison)); + } + evidence = comparison.classification === "flaky" ? comparison.sessionRuns[1] : failed; + } + if (selection) { + writeFileSync6(`${log}.selection.json`, JSON.stringify(selection), { mode: 384 }); + } + try { + assertIdentity(value.place.root, before); + } catch (error) { + if (!(error instanceof MainSyncError) || error.status !== "NEEDS_CHANGE" || attempt !== 0) throw error; + recheckSelectedProduction(value, receipt, command, selection, recipe.recovery.owners); + before = committedInputs(value.place.root); + restart = true; + break; + } + results.push({ + argv: command.argv, + ...command.bindings ? { bindings: command.bindings } : {}, + ...command.cwd ? { cwd: command.cwd } : {}, + timeoutMs: command.timeoutMs ?? MAX_CHECK_MS, + ...evidence, + ...comparison ? { comparison } : {} + }); } - const env = { ...process.env, TURBO_CONCURRENCY: "2" }; - Reflect.deleteProperty(env, "AFFECTED_SCRIPT_SUITES"); - Object.assign(env, command.bindings); - const start = performance2.now(); - verifyReportExecution( - value.place.root, - value.place.root, - ReportExecutionOwner.Check, - command.argv, - command.cwd ? join15(value.place.root, command.cwd) : value.place.root, - selection - ); - const result = spawnSync7(command.argv[0], command.argv.slice(1), { - cwd: command.cwd ? join15(value.place.root, command.cwd) : value.place.root, - encoding: "utf8", - timeout: remainingBudget(command.timeoutMs ?? MAX_CHECK_MS, spent[index]), - maxBuffer: 16 * 1024 * 1024, - env - }); - spent[index] += performance2.now() - start; - const output = `${result.stdout ?? ""}${result.stderr ?? ""}`; - const log = join15(value.place.dir, `vibe-main-sync-${receipt.transactionId}-${attempt}-${index}.log`); - writeFileSync5(log, output, { mode: 384 }); - if (result.error || result.status !== 0) throw new MainSyncError(`Required validation command failed: ${command.argv.join(" ")}; private log ${log}`); - if (selection) { - writeFileSync5(`${log}.selection.json`, JSON.stringify(selection), { mode: 384 }); - } - try { - assertIdentity(value.place.root, before); - } catch (error) { - if (!(error instanceof MainSyncError) || error.status !== "NEEDS_CHANGE" || attempt !== 0) throw error; - recheckSelectedProduction(value, receipt, command, selection, recipe.recovery.owners); - before = committedInputs(value.place.root); - restart = true; - break; - } - results.push({ - argv: command.argv, - ...command.bindings ? { bindings: command.bindings } : {}, - ...command.cwd ? { cwd: command.cwd } : {}, - timeoutMs: command.timeoutMs ?? MAX_CHECK_MS, - exitCode: 0, - log, - sha256: digest(output) - }); + if (restart) continue; + assertIdentity(value.place.root, before); + return { ...before, commands: results, checkedAt: (/* @__PURE__ */ new Date()).toISOString(), e2eLimitations: recipe.e2eLimitations }; } - if (restart) continue; - assertIdentity(value.place.root, before); - return { ...before, commands: results, checkedAt: (/* @__PURE__ */ new Date()).toISOString(), e2eLimitations: recipe.e2eLimitations }; + } finally { + main?.dispose(); } throw new MainSyncError("Canonical outputs changed repeatedly; current validation remains incomplete", "NEEDS_CHANGE"); } @@ -227830,6 +228074,26 @@ function assertIdentity(root, expected) { throw new MainSyncError("Committed validation inputs changed during the operation; same-writer recheck required", "NEEDS_CHANGE"); } } +function recordFailedCheck(value, command, comparison) { + const state = mainSyncStateSchema.parse(readPrivateJson(join16(value.place.dir, MAIN_SYNC_FILE))); + const failedChecks = [{ + argv: command.argv, + ...command.bindings ? { bindings: command.bindings } : {}, + ...command.cwd ? { cwd: command.cwd } : {}, + comparison + }]; + if (state.phase === "input-readiness") { + if (state.previous) saveSyncReceipt(value.place.dir, { ...state, previous: { ...state.previous, failedChecks } }); + } else saveSyncReceipt(value.place.dir, { ...state, failedChecks }); +} +function sessionCausedMessage(command, comparison) { + const check = command.argv.join(" "); + const logs = comparisonLogs(comparison).map((item) => item.log).join(", "); + if (comparison.main.outcome === "unrunnable") { + return `Required validation command failed: ${check}; it could not run on captured main ${comparison.mainSha} (${comparison.main.reason}), so it is treated as session-caused; private logs ${logs}`; + } + return `Required validation command failed: ${check}; it passes on captured main ${comparison.mainSha} and failed again on a rerun of the session commit, so the session caused it (session-caused); private logs ${logs}`; +} // src/main-sync.ts var GIT_NETWORK_MS = 15 * 60 * 1e3; @@ -227837,14 +228101,14 @@ var desktopSchema = external_exports.object({ ok: external_exports.boolean(), ru var TEST_HISTORY_PATH = /(?:\.(?:test|spec)\.[cm]?[jt]sx?$|\.snap$|(?:^|\/)(?:__tests__|__fixtures__|__snapshots__|e2e)\/|^apps\/desktop\/test\/)/; async function readMainSyncInput(worktree) { const input = mainSyncInputSchema.parse(await readInput()); - if (realpathSync9(input.context.worktree) !== realpathSync9(worktree)) throw new MainSyncError("CLI worktree and owned operation context differ"); + if (realpathSync10(input.context.worktree) !== realpathSync10(worktree)) throw new MainSyncError("CLI worktree and owned operation context differ"); return input; } function prepareMainSyncInputs(raw) { const value = syncContext(raw, "inputs"); const identity = inspectInputInventory(value.place.root); - const file = join16(value.place.dir, MAIN_SYNC_FILE); - const prior = existsSync10(file) ? mainSyncStateSchema.parse(readPrivateJson(file)) : void 0; + const file = join17(value.place.dir, MAIN_SYNC_FILE); + const prior = existsSync11(file) ? mainSyncStateSchema.parse(readPrivateJson(file)) : void 0; if (prior && (prior.worktree !== value.place.root || prior.branch !== value.place.branch || prior.operator.id !== value.place.session.operator.id || prior.operator.email !== value.place.session.operator.email || prior.originalBase !== value.place.session.baseCommit)) throw new MainSyncError("Input readiness belongs to another owned session"); const previous = prior?.phase === "input-readiness" ? prior.previous : prior; if (previous && (!value.context.mainSyncTransactionId || previous.transactionId !== value.context.mainSyncTransactionId)) { @@ -227889,14 +228153,14 @@ function prepareMainSync(raw) { const value = syncContext(raw, "prepare"); const root = value.place.root; committedInputs(root); - const previousFile = join16(value.place.dir, MAIN_SYNC_FILE); - const state = existsSync10(previousFile) ? mainSyncStateSchema.parse(readPrivateJson(previousFile)) : void 0; + const previousFile = join17(value.place.dir, MAIN_SYNC_FILE); + const state = existsSync11(previousFile) ? mainSyncStateSchema.parse(readPrivateJson(previousFile)) : void 0; const previous = state?.phase === "input-readiness" ? state.previous : state; if (previous && (previous.worktree !== root || previous.branch !== value.place.branch || previous.originalBase !== value.place.session.baseCommit || previous.operator.id !== value.place.session.operator.id || previous.operator.email !== value.place.session.operator.email)) { throw new MainSyncError("Previous main-sync provenance belongs to another session; preserve it and inspect the owned evidence"); } - const desktopScript = fileURLToPath3(new URL("../vibe-sessions.mjs", import.meta.url)); - const desktop = spawnSync8(process.execPath, [desktopScript, "desktop-tab", "--worktree", root], { + const desktopScript = fileURLToPath4(new URL("../vibe-sessions.mjs", import.meta.url)); + const desktop = spawnSync9(process.execPath, [desktopScript, "desktop-tab", "--worktree", root], { encoding: "utf8", timeout: 3e4, maxBuffer: 1024 * 1024 @@ -227947,7 +228211,7 @@ function prepareMainSync(raw) { throw new MainSyncError(`Ordinary main merge needs the SAME writer's conflict/source correction${conflicts ? `: ${conflicts}` : ""}; ROOT alone commits`, "NEEDS_CHANGE"); } } - const requiresCommit = existsSync10(join16(value.place.dir, "MERGE_HEAD")); + const requiresCommit = existsSync11(join17(value.place.dir, "MERGE_HEAD")); return { status: requiresCommit ? "NEEDS_COMMIT" : "DONE", transactionId: receipt.transactionId, @@ -227995,10 +228259,12 @@ function validateMainSync(raw) { } syncContext(raw, "validate"); const next = { ...requireSyncReceipt(value), validation, ...ciEvidence ? { ciEvidence } : {} }; + delete next.failedChecks; if (!ciEvidence) delete next.ciEvidence; if (receipt.pushedHead !== identity.headSha) delete next.pushedHead; saveSyncReceipt(value.place.dir, next); const requiredE2eComplete = validation.e2eLimitations.length === 0 || Boolean(ciEvidence); + const { mainPreexistingFailures, flakyChecks } = comparedChecks(validation.commands); return { status: value.purpose === "handoff" && !requiredE2eComplete ? "NEEDS_REVIEW" : "DONE", publicationReady: true, @@ -228011,6 +228277,8 @@ function validateMainSync(raw) { purpose: value.purpose, validationScope: "publication-preparation", e2eLimitations: validation.e2eLimitations, + ...mainPreexistingFailures.length ? { mainPreexistingFailures } : {}, + ...flakyChecks.length ? { flakyChecks } : {}, ...ciEvidence ? { ciEvidence } : {} }; } @@ -228075,14 +228343,14 @@ function admitMainSyncShare(raw) { } function readMainSyncProvenance(worktree) { const place = syncLocation(worktree); - const file = join16(place.dir, MAIN_SYNC_FILE); - if (!existsSync10(file)) return void 0; + const file = join17(place.dir, MAIN_SYNC_FILE); + if (!existsSync11(file)) return void 0; const state = mainSyncStateSchema.parse(readPrivateJson(file)); const value = state.phase === "input-readiness" ? state.previous : state; if (!value) return void 0; if (value.worktree !== place.root || value.branch !== place.branch || value.originalBase !== place.session.baseCommit || value.operator.id !== place.session.operator.id || value.operator.email !== place.session.operator.email) return void 0; - const merge = join16(place.dir, "MERGE_HEAD"); - const pendingMerge = existsSync10(merge) && git(place.root, ["rev-parse", "MERGE_HEAD"]) === value.mainSha; + const merge = join17(place.dir, "MERGE_HEAD"); + const pendingMerge = existsSync11(merge) && git(place.root, ["rev-parse", "MERGE_HEAD"]) === value.mainSha; if (!pendingMerge && !isAncestor(place.root, value.mainSha)) return void 0; return { mainSha: value.mainSha, @@ -228121,11 +228389,14 @@ function validationMatches(value, receipt, identity) { } for (const [index, command] of proof.commands.entries()) { const prefix = `vibe-main-sync-${receipt.transactionId}-`; - const permitted = [`${prefix}${index}.log`, `${prefix}0-${index}.log`, `${prefix}1-${index}.log`].map((name) => join16(value.place.dir, name)); - if (!permitted.includes(command.log)) throw new MainSyncError("Actual command evidence path changed", "NEEDS_CHANGE"); - const file = command.log; - const stat = lstatSync8(file, { throwIfNoEntry: false }); - if (!stat || !stat.isFile() || stat.isSymbolicLink() || stat.size > 16 * 1024 * 1024 || digest(readFileSync16(file)) !== command.sha256) throw new MainSyncError("Actual command evidence changed; validation is required", "NEEDS_CHANGE"); + const permitted = [`${prefix}${index}.log`, ...[0, 1].flatMap((attempt) => ["", "-rerun", "-main"].map((suffix) => `${prefix}${attempt}-${index}${suffix}.log`)), `${prefix}main-bootstrap.log`].map((name) => join17(value.place.dir, name)); + const evidence = [{ log: command.log, sha256: command.sha256 }, ...command.comparison ? comparisonLogs(command.comparison) : []]; + for (const { log: file, sha256: sha2562 } of evidence) { + if (!permitted.includes(file)) throw new MainSyncError("Actual command evidence path changed", "NEEDS_CHANGE"); + const stat = lstatSync8(file, { throwIfNoEntry: false }); + if (!stat || !stat.isFile() || stat.isSymbolicLink() || stat.size > 16 * 1024 * 1024 || digest(readFileSync16(file)) !== sha2562) throw new MainSyncError("Actual command evidence changed; validation is required", "NEEDS_CHANGE"); + } + if (command.comparison && command.comparison.mainSha !== receipt.mainSha) throw new MainSyncError("Main comparison names another main", "NEEDS_CHANGE"); } return true; } @@ -228195,6 +228466,32 @@ function featureTestHistory(root, originalBase, startingHead, previous) { } return [...entries.values()].sort((left, right) => left.commit.localeCompare(right.commit) || left.path.localeCompare(right.path)); } +function comparedChecks(commands) { + const mainPreexistingFailures = []; + const flakyChecks = []; + for (const command of commands) { + const comparison = command.comparison; + if (comparison?.classification === "main-preexisting") { + mainPreexistingFailures.push({ + check: command.argv.join(" "), + mainSha: comparison.mainSha, + sessionExitCode: command.exitCode, + mainExitCode: comparison.main.exitCode, + mainArgv: comparison.main.argv, + log: command.log, + mainLog: comparison.main.log + }); + } else if (comparison?.classification === "flaky") { + flakyChecks.push({ + check: command.argv.join(" "), + mainSha: comparison.mainSha, + logs: comparison.sessionRuns.map((run) => run.log), + mainLog: comparison.main.log + }); + } + } + return { mainPreexistingFailures, flakyChecks }; +} export { MainSyncError, admitMainSyncRequest, diff --git a/plugins/vibe/skills/vibe/scripts/dist/writer-state.mjs b/plugins/vibe/skills/vibe/scripts/dist/writer-state.mjs index 93e08974..d7f3449b 100644 --- a/plugins/vibe/skills/vibe/scripts/dist/writer-state.mjs +++ b/plugins/vibe/skills/vibe/scripts/dist/writer-state.mjs @@ -12045,6 +12045,7 @@ var storedValidationRecipeSchema = external_exports.object({ recipeSha256: validationDigestSchema, commands: external_exports.array(fixedCommandSchema).min(1).max(128), recovery: recoveryReserveSchema.optional(), + comparison: external_exports.object({ timeoutMs: external_exports.number().int().min(0).max(2 ** 31 - 1) }).strict().optional(), e2eLimitations: external_exports.array(external_exports.object({ argv: external_exports.array(external_exports.string()).min(1).max(256), bindings: external_exports.record(external_exports.string()).optional(), diff --git a/plugins/vibe/tools/python/test_vibe_forbidden_path_routing_contract.py b/plugins/vibe/tools/python/test_vibe_forbidden_path_routing_contract.py index 59857c77..91de63b9 100644 --- a/plugins/vibe/tools/python/test_vibe_forbidden_path_routing_contract.py +++ b/plugins/vibe/tools/python/test_vibe_forbidden_path_routing_contract.py @@ -82,7 +82,7 @@ def test_the_template_places_each_need_once_as_an_unchecked_engineering_item() - "Never in the Handoff section, and never routed to the person running the session.", ): assert required in rule, required - assert "| Engineering checklist | `vibe-ticket-worker` | session start; handoff appends engineering conflicts, forbidden-path needs, compile-only v1.0 repairs and engineering decisions |" in template + assert "| Engineering checklist | `vibe-ticket-worker` | session start; handoff appends engineering conflicts, forbidden-path needs, compile-only v1.0 repairs, engineering decisions and known main failures |" in template assert "Engineering needed" not in section(template, "## Handoff") diff --git a/plugins/vibe/tools/python/test_vibe_main_only_failures_contract.py b/plugins/vibe/tools/python/test_vibe_main_only_failures_contract.py new file mode 100644 index 00000000..adb60816 --- /dev/null +++ b/plugins/vibe/tools/python/test_vibe_main_only_failures_contract.py @@ -0,0 +1,117 @@ +"""Contract checks for the 2026-10-16 operator ruling on failures already on main. + +When a handoff validation check fails, handoff reruns that same check on the +main the session captured. If it also fails there, the session did not cause +it: it goes once on the live ticket's Engineering checklist as a Known main +failure and blocks neither publication nor assignment. Only a failure that +passes on the captured main and fails on the session's commit blocks. The rule +holds in both harnesses, and the person-facing message stays plain. +""" + +from __future__ import annotations + +from pathlib import Path + +from vibe_contract_text import agent, flat, section + + +PLUGIN_ROOT = Path(__file__).resolve().parents[2] +REFERENCES = PLUGIN_ROOT / "skills" / "vibe" / "references" +QUALITY = REFERENCES / "quality-loop.md" +GUARDRAILS = REFERENCES / "guardrails.md" +ENVIRONMENT = REFERENCES / "environment.md" +TICKET_TEMPLATE = REFERENCES / "ticket-template.md" +HANDOFF_SKILL = PLUGIN_ROOT / "skills" / "handoff" / "SKILL.md" +WORKERS_SRC = PLUGIN_ROOT.parents[1] / "tools" / "vibe-workers" / "src" +MAIN_SYNC_BUNDLE = PLUGIN_ROOT / "skills" / "vibe" / "scripts" / "dist" / "main-sync.mjs" +RULING = "operator ruling, 2026-10-16" +TICKET_LINE = "- [ ] Known main failure: ; fails on captured main ; evidence:" + + +def test_quality_loop_states_the_classification_once() -> None: + rule = section(QUALITY.read_text(), "## Failures already on main") + for required in ( + "Operator ruling, 2026-10-16", + "Codex and Claude Code alike", + "runs that same check on the main the session captured", + "disposable checkout", + "`.closedloop-ai/loops-setup.sh`", + "A check that passed is never rerun", + "`scripts/run-bounded.mjs`", + "`main-preexisting`: it fails on the captured main too", + "it blocks neither publication nor assignment", + "`session-caused`: it passes on the captured main and fails again on one rerun of the session's commit. It blocks", + "`flaky`: it passes on the captured main and on that one rerun", + "cannot run on the captured main", + "the conservative reading, and blocks", + "never a reason to stop the handoff and never a question for the person", + "never presents it as a stop", + ): + assert required in rule, required + tests = section(QUALITY.read_text(), "## Tests: repair, don't author") + assert "classifies `main-preexisting` is not the session's to fix" in tests + + +def test_a_main_only_failure_is_not_a_needed_tooling_fix() -> None: + tooling = section(GUARDRAILS.read_text(), "## Plugins and tooling") + assert "also fails on the main the session captured is not a needed fix and never stops the handoff" in tooling + assert RULING in tooling + handoff = flat(HANDOFF_SKILL.read_text()) + assert "A check that also fails on the main the session captured is not a needed fix and never stops the handoff" in handoff + assert "Only a failure the session caused blocks." in handoff + + +def test_handoff_publishes_and_routes_main_preexisting_failures_to_the_ticket() -> None: + upload = section(HANDOFF_SKILL.read_text(), "## 7. Upload the last changes") + for required in ( + "`mainPreexistingFailures` still has `publicationReady: true`", + "carry every entry to step 8's ticket worker as a Known main failure", + "never send it to the writer as a fix, never stop and never ask the person about it", + "error says `session-caused`", + "blocks as before and goes back to the SAME writer", + RULING, + ): + assert required in upload, required + choose = section(HANDOFF_SKILL.read_text(), "## 8. Choose who picks it up, then check the ticket") + assert "every `mainPreexistingFailures` entry from step 7 with its main run's log" in choose + + +def test_the_ticket_places_each_known_main_failure_once_on_the_engineering_checklist() -> None: + template = flat(TICKET_TEMPLATE.read_text()) + assert "Known main failures: each entry of handoff's `mainPreexistingFailures`" in template + assert TICKET_LINE in template + assert "Not on the Handoff section's Pre-existing failures line, never in both, and never routed to the person running the session" in template + assert "engineering decisions and known main failures |" in template + assert 'reads "fails on main too, see Engineering checklist"' in template + ticket = flat(agent("vibe-ticket-worker")) + assert "every `mainPreexistingFailures` entry the final validation returned" in section(agent("vibe-ticket-worker"), "## Inputs") + assert "Place each `mainPreexistingFailures` entry once, as an unchecked item at the end of the Engineering checklist" in ticket + assert TICKET_LINE in ticket + assert "it never blocks assignment" in ticket + + +def test_the_writer_and_the_environment_worker_do_not_block_on_main_preexisting_failures() -> None: + writer = flat(agent("vibe-change-worker")) + assert "return its `mainPreexistingFailures` to ROOT unchanged, publication still ready" in writer + assert "never fix, skip or weaken such a check" in writer + assert "a `session-caused` refusal is yours to fix" in writer + assert "also fails on the captured main is not yours to fix" in section(agent("vibe-change-worker"), "## Tests: repair, don't author") + redeploy = section(agent("vibe-environment-worker"), "## Redeploy mode") + assert "returned under `mainPreexistingFailures`" in redeploy + assert "does not block: push and request as usual" in redeploy + assert "including one the executor called `session-caused`" in redeploy + environment = section(ENVIRONMENT.read_text(), "## Main-sync before publication") + assert "one that also fails there is `main-preexisting` and does not block publication" in environment + + +def test_the_shared_executor_owns_the_classification_for_both_harnesses() -> None: + checks = (WORKERS_SRC / "main-sync-checks.ts").read_text() + assert 'value.purpose === "handoff" ? new CapturedMainComparison(' in checks + assert 'comparison.classification === "session-caused"' in checks + comparison = (WORKERS_SRC / "main-sync-main-comparison.ts").read_text() + assert 'new URL("../run-bounded.mjs", import.meta.url)' in comparison + assert '"worktree", "add", "--detach"' in comparison + assert '"worktree", "remove", "--force"' in comparison + bundle = MAIN_SYNC_BUNDLE.read_text() + for marker in ("mainPreexistingFailures", "main-preexisting", "session-caused", "vibe-captured-main-"): + assert marker in bundle, marker diff --git a/plugins/vibe/tools/python/test_vibe_storybook_placement_contract.py b/plugins/vibe/tools/python/test_vibe_storybook_placement_contract.py index 19eaed95..cec12850 100644 --- a/plugins/vibe/tools/python/test_vibe_storybook_placement_contract.py +++ b/plugins/vibe/tools/python/test_vibe_storybook_placement_contract.py @@ -319,7 +319,7 @@ def test_the_template_routes_design_conflicts_to_design_review_and_the_rest_to_e assert required in rule, required review = section(template, "## Design Review") assert "- Design conflicts: " in review - assert "| Engineering checklist | `vibe-ticket-worker` | session start; handoff appends engineering conflicts, forbidden-path needs, compile-only v1.0 repairs and engineering decisions |" in template + assert "| Engineering checklist | `vibe-ticket-worker` | session start; handoff appends engineering conflicts, forbidden-path needs, compile-only v1.0 repairs, engineering decisions and known main failures |" in template def test_the_handoff_section_no_longer_collects_conflicts() -> None: diff --git a/plugins/vibe/tools/python/test_vibe_v1_compile_only_repair_contract.py b/plugins/vibe/tools/python/test_vibe_v1_compile_only_repair_contract.py index b16fe22a..ea37bfbb 100644 --- a/plugins/vibe/tools/python/test_vibe_v1_compile_only_repair_contract.py +++ b/plugins/vibe/tools/python/test_vibe_v1_compile_only_repair_contract.py @@ -110,7 +110,7 @@ def test_the_ticket_worker_places_each_repair_once_on_the_engineering_checklist( ) in rule assert ( "| Engineering checklist | `vibe-ticket-worker` | session start; handoff appends engineering conflicts, " - "forbidden-path needs, compile-only v1.0 repairs and engineering decisions |" + "forbidden-path needs, compile-only v1.0 repairs, engineering decisions and known main failures |" ) in template assert "compile-only" not in section(template, "## Handoff") handoff_mode = section(agent("vibe-ticket-worker"), "## Handoff mode") diff --git a/tools/vibe-workers/src/main-sync-budget-ref-read.test.ts b/tools/vibe-workers/src/main-sync-budget-ref-read.test.ts index 6b67f3ea..2386c188 100644 --- a/tools/vibe-workers/src/main-sync-budget-ref-read.test.ts +++ b/tools/vibe-workers/src/main-sync-budget-ref-read.test.ts @@ -7,6 +7,7 @@ import { checkRecipe } from "./main-sync-checks.js"; import { mainSyncReceiptSchema, type MainSyncReceipt } from "./main-sync-contracts.js"; import { syncContext } from "./main-sync-state.js"; import { syncFixture, invokeSync, installAffectedTestWrapper } from "./main-sync-test-fixtures.js"; +import { comparisonBudgetMs } from "./main-sync-validation-recipe.js"; const control=vi.hoisted(()=>({calls:[] as string[][],reply:undefined as undefined|((root:string,args:string[])=>string|undefined)})); vi.mock("../../../plugins/vibe/skills/vibe/scripts/session-record.mjs",async importOriginal=>{ @@ -107,7 +108,9 @@ describe("fixed budget refs and actual budget caller",()=>{ expect(control.calls.filter(x=>same(x,pair))).toHaveLength(2); expect(recipe.commands.some(x=>x.argv[1]==="test:affected")).toBe(true); expect(recipe.commands.filter(x=>x.argv[1]==="test:affected"||(x.argv[1]==="turbo"&&x.argv[2]==="test")).map(x=>x.timeoutMs)).toEqual([66*60*1000,66*60*1000]); - expect(Object.keys(recipe).sort()).toEqual(["commands","e2eLimitations","recovery"]); + // operator ruling, 2026-10-16: the handoff recipe also binds its captured-main comparison reserve. + expect(Object.keys(recipe).sort()).toEqual(["commands","comparison","e2eLimitations","recovery"]); + expect(recipe.comparison).toEqual({timeoutMs:comparisonBudgetMs(recipe.commands.map(x=>({timeoutMs:x.timeoutMs!})))}); }finally{source.finish();} },30000); void compilerNegative; diff --git a/tools/vibe-workers/src/main-sync-checks.ts b/tools/vibe-workers/src/main-sync-checks.ts index a4872f14..5925565d 100644 --- a/tools/vibe-workers/src/main-sync-checks.ts +++ b/tools/vibe-workers/src/main-sync-checks.ts @@ -4,8 +4,10 @@ import { existsSync, lstatSync, readFileSync, readlinkSync, writeFileSync } from import { join } from "node:path"; import { z } from "zod"; import { git } from "../../../plugins/vibe/skills/vibe/scripts/session-record.mjs"; -import { MAX_CHECK_MS, MainSyncError, mainSyncStateSchema, type MainSyncReceipt, type OriginProof, type e2eLimitationSchema } from "./main-sync-contracts.js"; -import { committedIdentity, digest, syncContext, readPrivateJson, MAIN_SYNC_FILE } from "./main-sync-state.js"; +import { MAX_CHECK_MS, MainSyncError, mainSyncStateSchema, type MainComparison, type MainSyncReceipt, type OriginProof, type e2eLimitationSchema } from "./main-sync-contracts.js"; +import { committedIdentity, digest, syncContext, readPrivateJson, saveSyncReceipt, MAIN_SYNC_FILE } from "./main-sync-state.js"; +import { CapturedMainComparison, comparisonLogs, runBounded } from "./main-sync-main-comparison.js"; +import { comparisonBudgetMs } from "./main-sync-validation-recipe.js"; import { discoverCheckLanes, type CheckCommand } from "./main-sync-lanes.js"; import { GENERATED_BIOME_PATH, MAX_INPUT_BYTES, verifyGeneratedBiomeInput } from "./main-sync-generated-input.js"; import { committedLocalPlans, isLocalPlanPath } from "../../../plugins/vibe/skills/vibe/scripts/local-plans.mjs"; @@ -220,7 +222,9 @@ export function checkRecipe(value: ReturnType, receipt: Main commands.push({ argv: ["pnpm", "vibe", "storybook-diff"] }); verifyReportRecipe(commands); const bound = bindCheckBudgets(root, receipt, commands); - return { commands: bound, e2eLimitations, recovery: selectedRecoveryReserve(root, bound) }; + // operator ruling, 2026-10-16: a handoff failure is compared against the captured main; the reserve bounds that. + return { commands: bound, e2eLimitations, recovery: selectedRecoveryReserve(root, bound), + comparison: { timeoutMs: comparisonBudgetMs(bound.map((command) => ({ timeoutMs: command.timeoutMs ?? MAX_CHECK_MS }))) } }; } /** Runs the fixed matrix; verified producer changes invalidate old evidence and get one bounded clean restart. */ @@ -230,6 +234,8 @@ export function executeChecks(value: ReturnType, receipt: Ma const recipe = preparedRecipe ?? checkRecipe(value, receipt); const spent = recipe.commands.map(() => 0); const drySpent = recipe.commands.map(() => 0); + const main = value.purpose === "handoff" ? new CapturedMainComparison(value.place.root, value.place.dir, receipt) : undefined; + try { for (let attempt = 0; attempt < 2; attempt++) { const results = []; let restart = false; @@ -258,9 +264,27 @@ export function executeChecks(value: ReturnType, receipt: Ma env }); spent[index]! += performance.now() - start; const output = `${result.stdout ?? ""}${result.stderr ?? ""}`; - const log = join(value.place.dir, `vibe-main-sync-${receipt.transactionId}-${attempt}-${index}.log`); + const prefix = join(value.place.dir, `vibe-main-sync-${receipt.transactionId}-${attempt}-${index}`); + const log = `${prefix}.log`; writeFileSync(log, output, { mode: 0o600 }); - if (result.error || result.status !== 0) throw new MainSyncError(`Required validation command failed: ${command.argv.join(" ")}; private log ${log}`); + let evidence = { exitCode: 0, log, sha256: digest(output) }; + let comparison: MainComparison | undefined; + if (result.error || result.status !== 0) { + if (!main) throw new MainSyncError(`Required validation command failed: ${command.argv.join(" ")}; private log ${log}`); + const failed = { ...evidence, exitCode: result.status ?? 1 }; + comparison = main.classify(command, selection, failed, prefix, env, (rerunLog) => { + verifyReportExecution(value.place.root, value.place.root, ReportExecutionOwner.Check, command.argv, + command.cwd ? join(value.place.root, command.cwd) : value.place.root, selection); + const again = runBounded(command.argv, command.cwd ? join(value.place.root, command.cwd) : value.place.root, env, + command.timeoutMs ?? MAX_CHECK_MS, rerunLog); + return { exitCode: again.exitCode, log: again.log, sha256: again.sha256 }; + }); + if (comparison.classification === "session-caused") { + recordFailedCheck(value, command, comparison); + throw new MainSyncError(sessionCausedMessage(command, comparison)); + } + evidence = comparison.classification === "flaky" ? comparison.sessionRuns[1]! : failed; + } if (selection) { writeFileSync(`${log}.selection.json`, JSON.stringify(selection), { mode: 0o600 }); } @@ -274,12 +298,13 @@ export function executeChecks(value: ReturnType, receipt: Ma } results.push({ argv: command.argv, ...(command.bindings ? { bindings: command.bindings } : {}), ...(command.cwd ? { cwd: command.cwd } : {}), timeoutMs: command.timeoutMs ?? MAX_CHECK_MS, - exitCode: 0 as const, log, sha256: digest(output) }); + ...evidence, ...(comparison ? { comparison } : {}) }); } if (restart) continue; assertIdentity(value.place.root, before); return { ...before, commands: results, checkedAt: new Date().toISOString(), e2eLimitations: recipe.e2eLimitations }; } + } finally { main?.dispose(); } throw new MainSyncError("Canonical outputs changed repeatedly; current validation remains incomplete", "NEEDS_CHANGE"); } @@ -295,3 +320,22 @@ function assertIdentity(root: string, expected: ReturnType, command: CheckCommand, comparison: MainComparison) { + const state = mainSyncStateSchema.parse(readPrivateJson(join(value.place.dir, MAIN_SYNC_FILE))); + const failedChecks = [{ argv: command.argv, ...(command.bindings ? { bindings: command.bindings } : {}), + ...(command.cwd ? { cwd: command.cwd } : {}), comparison }]; + if (state.phase === "input-readiness") { + if (state.previous) saveSyncReceipt(value.place.dir, { ...state, previous: { ...state.previous, failedChecks } }); + } else saveSyncReceipt(value.place.dir, { ...state, failedChecks }); +} + +function sessionCausedMessage(command: CheckCommand, comparison: MainComparison) { + const check = command.argv.join(" "); + const logs = comparisonLogs(comparison).map((item) => item.log).join(", "); + if (comparison.main.outcome === "unrunnable") { + return `Required validation command failed: ${check}; it could not run on captured main ${comparison.mainSha} (${comparison.main.reason}), so it is treated as session-caused; private logs ${logs}`; + } + return `Required validation command failed: ${check}; it passes on captured main ${comparison.mainSha} and failed again on a rerun of the session commit, so the session caused it (session-caused); private logs ${logs}`; +} diff --git a/tools/vibe-workers/src/main-sync-contracts.ts b/tools/vibe-workers/src/main-sync-contracts.ts index 32ae6cd0..854202ee 100644 --- a/tools/vibe-workers/src/main-sync-contracts.ts +++ b/tools/vibe-workers/src/main-sync-contracts.ts @@ -39,10 +39,24 @@ export const operationContextSchema = z.object({ export type OperationContext = z.infer; export const mainSyncInputSchema = z.object({ context: operationContextSchema, transactionId: z.string().uuid().optional() }).strict(); +const runEvidenceSchema = z.object({ exitCode: z.number().int(), log: z.string().min(1), sha256: z.string().length(64) }).strict(); +/** The same failing check run once on the captured main in a disposable checkout (operator ruling, 2026-10-16). */ +export const capturedMainRunSchema = z.object({ outcome: z.enum(["failed", "passed", "unrunnable"]), + argv: z.array(z.string()).min(1).max(256).optional(), exitCode: z.number().int().optional(), + log: z.string().min(1).optional(), sha256: z.string().length(64).optional(), reason: z.string().min(1).max(512).optional() }).strict(); +export const mainComparisonSchema = z.object({ classification: z.enum(["main-preexisting", "session-caused", "flaky"]), + mainSha: commitSchema, sessionRuns: z.array(runEvidenceSchema).min(1).max(2), main: capturedMainRunSchema }).strict(); +export type MainComparison = z.infer; export const commandReceiptSchema = z.object({ argv: z.array(z.string()).min(1).max(256), bindings: z.record(z.string()).optional(), cwd: z.string().optional(), timeoutMs: z.number().int().min(1000).max(2 ** 31 - 1).optional(), - exitCode: z.literal(0), log: z.string().min(1), sha256: z.string().length(64) }).strict(); + exitCode: z.number().int(), log: z.string().min(1), sha256: z.string().length(64), + comparison: mainComparisonSchema.optional() }).strict() + // Only a check that also fails on the captured main may stand in a receipt with a failing exit. + .refine((item) => (item.exitCode === 0) === (item.comparison?.classification !== "main-preexisting") + && item.comparison?.classification !== "session-caused", "Failing check without main-preexisting evidence"); +const failedCheckSchema = z.object({ argv: z.array(z.string()).min(1).max(256), bindings: z.record(z.string()).optional(), + cwd: z.string().optional(), comparison: mainComparisonSchema }).strict(); export const e2eLimitationSchema = z.object({ argv: z.array(z.string()).min(1).max(256), bindings: z.record(z.string()).optional(), reason: z.string().min(1).max(4096) }).strict(); const fileDeltaSchema = z.object({ path: z.string().min(1), status: z.string().min(1) }).strict(); @@ -64,6 +78,7 @@ export const mainSyncReceiptSchema = z.object({ commands: z.array(commandReceiptSchema).min(1).max(128), checkedAt: z.string().datetime(), e2eLimitations: z.array(e2eLimitationSchema).max(128).default([]) }).strict().optional(), ciEvidence: ciEvidenceSchema.optional(), + failedChecks: z.array(failedCheckSchema).min(1).max(128).optional(), pushedHead: commitSchema.optional(), publicationTurn: publicationTurnSchema.optional(), requestContinuations: z.array(requestContinuationSchema.extend({ turn: publicationTurnSchema.optional() })).max(2).default([]), diff --git a/tools/vibe-workers/src/main-sync-main-comparison.test.ts b/tools/vibe-workers/src/main-sync-main-comparison.test.ts new file mode 100644 index 00000000..fad73898 --- /dev/null +++ b/tools/vibe-workers/src/main-sync-main-comparison.test.ts @@ -0,0 +1,151 @@ +import { existsSync, readFileSync } from "node:fs"; +import { join } from "node:path"; +import { afterEach, describe, expect, it } from "vitest"; +import { invokeSync, syncFixture } from "./main-sync-test-fixtures.js"; +import { capturedMainArgv } from "./main-sync-main-comparison.js"; +import { comparisonBudgetMs, MAIN_BOOTSTRAP_MS, storedRecipe, validationEnvelopeMs } from "./main-sync-validation-recipe.js"; + +const fixtures: ReturnType[] = []; +afterEach(() => { for (const value of fixtures.splice(0)) value.cleanup(); }); + +const FAILED = /Required validation command failed/; + +/** Puts the failure modes and the canonical bootstrap on the captured main, so main and the session share them. */ +function setup(failures: Record, bootstrap = "#!/usr/bin/env bash\necho bootstrapped\n") { + const value = syncFixture(); + fixtures.push(value); + value.write("fixture-failures.json", JSON.stringify(failures)); + value.write(".closedloop-ai/loops-setup.sh", bootstrap); + value.git(["add", "-f", "fixture-failures.json", ".closedloop-ai/loops-setup.sh"]); + value.git(["commit", "-m", "Main carries the failure modes and its bootstrap"]); + value.git(["push", "origin", "HEAD:main"]); + return value; +} + +function validate(value: ReturnType, purpose: "build" | "handoff") { + const prepare = value.start("redeploy", purpose); + try { expect(invokeSync(value, "prepare", prepare.context).status).toBe(0); } finally { prepare.finish(); } + const source = value.sourceTurn(purpose); + try { return invokeSync(value, "validate", source.context); } finally { source.finish(); } +} + +const calls = (file: string) => existsSync(file) + ? readFileSync(file, "utf8").trim().split("\n").filter(Boolean).map((line) => JSON.parse(line) as string[]) : []; +const executed = (list: string[][]) => list.filter((call) => !call.includes("--dry=json") && call[1] !== "node"); +const receipt = (value: ReturnType) => JSON.parse(readFileSync(join(value.metadata, "vibe-main-sync.json"), "utf8")); +const worktrees = (value: ReturnType) => value.git(["worktree", "list", "--porcelain"]).split("\n") + .filter((line) => line.startsWith("worktree ")).length; + +describe("handoff failures compared against the captured main (operator ruling, 2026-10-16)", () => { + it("records a check that also fails on the captured main and publishes anyway", () => { + const value = setup({ "check:source-gates": "always", "typecheck:affected": "always", "turbo typecheck": "always" }); + const result = validate(value, "handoff"); + expect(result.status, result.stdout).toBe(0); + const mainSha = value.git(["rev-parse", "origin/main"]); + expect(result.json?.mainSync).toMatchObject({ status: "DONE", publicationReady: true, mainSha }); + const failures = (result.json?.mainSync as { mainPreexistingFailures: { check: string; mainArgv: string[] }[] }).mainPreexistingFailures; + expect(failures.map((item) => item.check)).toEqual(["pnpm check:source-gates", "pnpm typecheck:affected", + expect.stringMatching(/^pnpm turbo typecheck --filter=\.\.\.\[[a-f0-9]{40}\] --concurrency=1$/)]); + // A diff-relative selector runs the same task over the packages the session selected, never main's own diff. + expect(failures[1]!.mainArgv).toEqual(["pnpm", "turbo", "typecheck", "--filter=app", "--concurrency=1"]); + const stored = receipt(value).validation.commands.filter((command: { comparison?: unknown }) => command.comparison); + expect(stored).toHaveLength(3); + for (const command of stored) { + expect(command.exitCode).toBe(1); + expect(command.comparison).toMatchObject({ classification: "main-preexisting", mainSha, main: { outcome: "failed", exitCode: 1 } }); + expect(command.comparison.sessionRuns).toHaveLength(1); + expect(readFileSync(command.comparison.main.log, "utf8")).toContain("fixture failure"); + } + // Only the failing checks ran on main, once each, after one bootstrap; the checkout is gone. + expect(executed(calls(join(value.metadata, "main-checkout-calls.jsonl")))).toEqual([["check:source-gates"], + ["turbo", "typecheck", "--filter=app", "--concurrency=1"], ["turbo", "typecheck", "--filter=app", "--concurrency=1"]]); + expect(worktrees(value)).toBe(1); + const publish = value.start("redeploy", "handoff"); + try { + const pushed = invokeSync(value, "push", publish.context); + expect(pushed.status, pushed.stdout).toBe(0); + expect(value.git(["ls-remote", "origin", "refs/heads/vibe/test"]).split("\t")[0]).toBe(value.git(["rev-parse", "HEAD"])); + } finally { publish.finish(); } + }, 60000); + + it("blocks a check that passes on the captured main and fails again on the session commit", () => { + const value = setup({ "check:source-gates": "session" }); + const result = validate(value, "handoff"); + expect(result.status).toBe(1); + expect(String(result.json?.error)).toMatch(FAILED); + expect(String(result.json?.error)).toMatch(/passes on captured main [a-f0-9]{40} and failed again on a rerun.*session-caused/); + const stored = receipt(value); + expect(stored.validation).toBeUndefined(); + expect(stored.failedChecks).toHaveLength(1); + expect(stored.failedChecks[0].comparison).toMatchObject({ classification: "session-caused", main: { outcome: "passed", exitCode: 0 } }); + expect(stored.failedChecks[0].comparison.sessionRuns.map((run: { exitCode: number }) => run.exitCode)).toEqual([1, 1]); + expect(executed(calls(join(value.metadata, "validation-calls.jsonl"))).filter((call) => call[0] === "check:source-gates")).toHaveLength(2); + expect(worktrees(value)).toBe(1); + }, 60000); + + it("passes a flaky check that passes on main and on its one session rerun, keeping both session runs", () => { + const value = setup({ "check:source-gates": "once" }); + const result = validate(value, "handoff"); + expect(result.status, result.stdout).toBe(0); + expect(result.json?.mainSync).not.toHaveProperty("mainPreexistingFailures"); + expect((result.json?.mainSync as { flakyChecks: unknown[] }).flakyChecks).toHaveLength(1); + const command = receipt(value).validation.commands.find((item: { argv: string[] }) => item.argv.join(" ") === "pnpm check:source-gates"); + expect(command).toMatchObject({ exitCode: 0, comparison: { classification: "flaky", main: { outcome: "passed" } } }); + expect(command.comparison.sessionRuns.map((run: { exitCode: number }) => run.exitCode)).toEqual([1, 0]); + expect(command.log).toBe(command.comparison.sessionRuns[1].log); + }, 60000); + + it("treats a check that cannot run on the captured main as session-caused without a rerun", () => { + const value = setup({ "check:source-gates": "always" }, "#!/usr/bin/env bash\necho install failed >&2\nexit 3\n"); + const result = validate(value, "handoff"); + expect(result.status).toBe(1); + expect(String(result.json?.error)).toMatch(/could not run on captured main [a-f0-9]{40} \(the captured main bootstrap exited 3\), so it is treated as session-caused/); + const comparison = receipt(value).failedChecks[0].comparison; + expect(comparison).toMatchObject({ classification: "session-caused", main: { outcome: "unrunnable" } }); + expect(comparison.sessionRuns).toHaveLength(1); + expect(executed(calls(join(value.metadata, "main-checkout-calls.jsonl")))).toEqual([]); + expect(worktrees(value)).toBe(1); + }, 60000); + + it("keeps a build-loop failure blocking without a main comparison", () => { + const value = setup({ "typecheck:affected": "always" }); + const result = validate(value, "build"); + expect(result.status).toBe(1); + expect(String(result.json?.error)).toMatch(/^Required validation command failed: pnpm typecheck:affected; private log /); + expect(existsSync(join(value.metadata, "main-checkout-calls.jsonl"))).toBe(false); + expect(receipt(value).failedChecks).toBeUndefined(); + }, 60000); +}); + +describe("captured-main command and budget", () => { + it("pins every diff-relative selector to the session's selected packages and refuses a missing file", () => { + const tasks = { tasks: [{ taskId: "api#test", package: "api" }, { taskId: "app#test", package: "app" }, { taskId: "db#build", package: "db" }] }; + const sha = "a".repeat(40); + expect(capturedMainArgv("/unused", sha, { argv: ["pnpm", "test:affected", "--continue"] }, tasks)) + .toEqual({ argv: ["pnpm", "turbo", "test", "--filter=api", "--filter=app", "--continue"] }); + expect(capturedMainArgv("/unused", sha, { argv: ["pnpm", "turbo", "test", `--filter=...[${sha}]`, "--continue"] }, tasks)) + .toEqual({ argv: ["pnpm", "turbo", "test", "--filter=api", "--filter=app", "--continue"] }); + expect(capturedMainArgv("/unused", sha, { argv: ["pnpm", "test:affected", "--continue"] }, undefined)) + .toHaveProperty("reason"); + expect(capturedMainArgv("/unused", sha, { argv: ["pnpm", "check:source-gates"] }, undefined)) + .toEqual({ argv: ["pnpm", "check:source-gates"] }); + const value = syncFixture(); + fixtures.push(value); + const head = value.git(["rev-parse", "HEAD"]); + expect(capturedMainArgv(value.worktree, head, { argv: ["pnpm", "exec", "biome", "check", "apps/app/probe.ts"] }, undefined)) + .toEqual({ argv: ["pnpm", "exec", "biome", "check", "apps/app/probe.ts"] }); + expect(capturedMainArgv(value.worktree, head, { argv: ["pnpm", "exec", "biome", "check", "apps/app/new.ts"] }, undefined)) + .toEqual({ reason: "the checked file apps/app/new.ts does not exist on captured main" }); + }); + + it("reserves one bootstrap plus a main run and a rerun per check in the Claude envelope, bound to the recipe", () => { + const identity = { headSha: "b".repeat(40), treeSha: "c".repeat(40), inputSha256: "d".repeat(64) }; + const commands = [{ argv: ["pnpm", "check:source-gates"], timeoutMs: 1000 }, { argv: ["pnpm", "test"], timeoutMs: 4000 }]; + expect(comparisonBudgetMs(commands)).toBe(MAIN_BOOTSTRAP_MS + 10000); + const plain = storedRecipe(identity, { commands, e2eLimitations: [] }); + const compared = storedRecipe(identity, { commands, e2eLimitations: [], comparison: { timeoutMs: comparisonBudgetMs(commands) } }); + expect(compared.recipeSha256).not.toBe(plain.recipeSha256); + expect(validationEnvelopeMs(compared) - validationEnvelopeMs(plain)).toBe(MAIN_BOOTSTRAP_MS + 10000); + expect(() => validationEnvelopeMs({ ...compared, comparison: { timeoutMs: 1 } })).toThrow(/comparison reserve/); + }); +}); diff --git a/tools/vibe-workers/src/main-sync-main-comparison.ts b/tools/vibe-workers/src/main-sync-main-comparison.ts new file mode 100644 index 00000000..86c49cf4 --- /dev/null +++ b/tools/vibe-workers/src/main-sync-main-comparison.ts @@ -0,0 +1,172 @@ +import { spawnSync } from "node:child_process"; +import { existsSync, mkdtempSync, realpathSync, rmSync, writeFileSync } from "node:fs"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; +import { fileURLToPath } from "node:url"; +import { git } from "../../../plugins/vibe/skills/vibe/scripts/session-record.mjs"; +import { MAX_CHECK_MS, MainSyncError, type MainComparison, type MainSyncReceipt } from "./main-sync-contracts.js"; +import { failureCause } from "./main-sync-failure.js"; +import type { CheckCommand } from "./main-sync-lanes.js"; +import { verifyReportExecution } from "./main-sync-report-inputs.js"; +import { ReportExecutionOwner } from "./main-sync-report-execution.js"; +import { digest } from "./main-sync-state.js"; +import { MAIN_BOOTSTRAP_MS } from "./main-sync-validation-recipe.js"; + +type Selection = { tasks: { taskId: string; package?: string }[] }; +type RunEvidence = MainComparison["sessionRuns"][number]; +type Rerun = (log: string) => RunEvidence; + +const BOOTSTRAP = ["bash", ".closedloop-ai/loops-setup.sh"]; +const PINNED_FILTER = /^--filter=\.\.\.\[[a-f0-9]{40}\]$/; +const CLEANUP_MS = 15 * 60 * 1000; +// run-bounded stops the whole process group at its limit; this outer bound only catches a stuck wrapper. +const WRAPPER_GRACE_MS = 30 * 1000; + +/** + * Operator ruling, 2026-10-16: a handoff check that fails is run again on the main the session captured. It is + * `main-preexisting` when it also fails there, which never blocks publication. When it passes on main the session + * commit gets one rerun before the failure is `session-caused` (a pass is `flaky`). A check that cannot run on main + * is `session-caused`, the conservative reading. One disposable checkout of the captured main is bootstrapped once, + * shared by every failing check of this validation, and removed by `dispose`. + */ +export class CapturedMainComparison { + private checkout?: string; + private registered = false; + private bootstrap?: { ok: true } | { ok: false; reason: string; log?: string; sha256?: string }; + + constructor(private readonly root: string, private readonly dir: string, private readonly receipt: MainSyncReceipt) {} + + /** Classifies one failing session run; passing checks never reach this. */ + classify(command: CheckCommand, selection: Selection | undefined, failed: RunEvidence, prefix: string, + env: NodeJS.ProcessEnv, rerun: Rerun): MainComparison { + const mainSha = this.receipt.mainSha; + const main = this.runOnMain(command, selection, prefix, env); + if (main.outcome === "failed") return { classification: "main-preexisting", mainSha, sessionRuns: [failed], main }; + if (main.outcome === "unrunnable") return { classification: "session-caused", mainSha, sessionRuns: [failed], main }; + const again = rerun(`${prefix}-rerun.log`); + return { classification: again.exitCode === 0 ? "flaky" : "session-caused", mainSha, sessionRuns: [failed, again], main }; + } + + /** Removes the disposable checkout; an unproven removal keeps the operation's evidence and refuses. */ + dispose() { + if (!this.checkout) return; + const target = this.checkout; + this.checkout = undefined; + if (!this.registered) { rmSync(target, { recursive: true, force: true }); return; } + try { git(this.root, ["worktree", "remove", "--force", target], { timeout: CLEANUP_MS }); } + catch { throw new MainSyncError("Captured-main comparison checkout cleanup is unproven; preserve its operation evidence"); } + } + + private runOnMain(command: CheckCommand, selection: Selection | undefined, prefix: string, env: NodeJS.ProcessEnv): MainComparison["main"] { + const scoped = capturedMainArgv(this.root, this.receipt.mainSha, command, selection); + if ("reason" in scoped) return { outcome: "unrunnable", reason: scoped.reason }; + const ready = this.prepare(); + if (!ready.ok) { + return { outcome: "unrunnable", argv: scoped.argv, reason: ready.reason, + ...(ready.log && ready.sha256 ? { log: ready.log, sha256: ready.sha256 } : {}) }; + } + const checkout = this.checkout!; + const cwd = command.cwd ? join(checkout, command.cwd) : checkout; + try { verifyReportExecution(this.root, checkout, ReportExecutionOwner.Check, scoped.argv, cwd, selection); } + catch (error) { return { outcome: "unrunnable", argv: scoped.argv, reason: `report admission refused: ${failureCause(error)}`.slice(0, 512) }; } + const run = runBounded(scoped.argv, cwd, env, command.timeoutMs ?? MAX_CHECK_MS, `${prefix}-main.log`); + if (run.exitCode === 0) return { outcome: "passed", argv: scoped.argv, exitCode: 0, log: run.log, sha256: run.sha256 }; + if (run.timedOut || run.spawnFailed) { + return { outcome: "unrunnable", argv: scoped.argv, exitCode: run.exitCode, log: run.log, sha256: run.sha256, + reason: run.timedOut ? "it ran out of its time limit on captured main" : "it could not be started on captured main" }; + } + return { outcome: "failed", argv: scoped.argv, exitCode: run.exitCode, log: run.log, sha256: run.sha256 }; + } + + private prepare() { + if (this.bootstrap) return this.bootstrap; + const mainSha = this.receipt.mainSha; + try { + this.checkout = realpathSync(mkdtempSync(join(tmpdir(), "vibe-captured-main-"))); + git(this.root, ["worktree", "add", "--detach", this.checkout, mainSha], { timeout: CLEANUP_MS }); + this.registered = true; + } catch (error) { + if (error instanceof MainSyncError) throw error; + this.bootstrap = { ok: false, reason: `a checkout of captured main could not be created: ${failureCause(error)}`.slice(0, 512) }; + return this.bootstrap; + } + if (!existsSync(join(this.checkout, BOOTSTRAP[1]!))) { + this.bootstrap = { ok: false, reason: "captured main has no canonical bootstrap (.closedloop-ai/loops-setup.sh)" }; + return this.bootstrap; + } + try { verifyReportExecution(this.root, this.checkout, ReportExecutionOwner.Bootstrap, BOOTSTRAP, this.checkout); } + catch (error) { + this.bootstrap = { ok: false, reason: `bootstrap admission refused: ${failureCause(error)}`.slice(0, 512) }; + return this.bootstrap; + } + const log = join(this.dir, `vibe-main-sync-${this.receipt.transactionId}-main-bootstrap.log`); + const run = runBounded(BOOTSTRAP, this.checkout, process.env, MAIN_BOOTSTRAP_MS, log); + this.bootstrap = run.exitCode === 0 ? { ok: true } + : { ok: false, reason: run.timedOut ? "the captured main bootstrap ran out of its time limit" : `the captured main bootstrap exited ${run.exitCode}`, + log: run.log, sha256: run.sha256 }; + return this.bootstrap; + } +} + +/** The bounded session rerun shares the main run's process-group bound; neither uses GNU `timeout`. */ +export function runBounded(argv: readonly string[], cwd: string, env: NodeJS.ProcessEnv, timeoutMs: number, log: string) { + const seconds = Math.max(1, Math.ceil(timeoutMs / 1000)); + const result = spawnSync(process.execPath, [runBoundedScript(), String(seconds), ...argv], { cwd, env, encoding: "utf8", + timeout: seconds * 1000 + WRAPPER_GRACE_MS, maxBuffer: 16 * 1024 * 1024 }); + const output = `${result.stdout ?? ""}${result.stderr ?? ""}`; + writeFileSync(log, output, { mode: 0o600 }); + const exitCode = result.status ?? 1; + const stderr = result.stderr ?? ""; + return { exitCode, log, sha256: digest(output), + timedOut: Boolean(result.error) || (exitCode === TIMED_OUT_EXIT && stderr.includes(TIMED_OUT_MARKER)), + spawnFailed: exitCode === WRAPPER_FAILED_EXIT && SPAWN_FAILURE.test(stderr) }; +} + +/** Every evidence log a comparison names, for the same digest check the command logs get at consumption. */ +export function comparisonLogs(comparison: MainComparison) { + return [...comparison.sessionRuns.map((run) => ({ log: run.log, sha256: run.sha256 })), + ...(comparison.main.log && comparison.main.sha256 ? [{ log: comparison.main.log, sha256: comparison.main.sha256 }] : [])]; +} + +/** + * The same check on main. A selector relative to the session's own diff (the affected wrappers and the pinned Turbo + * filter) would select different packages there, so main runs that task over the packages the session selected. + */ +export function capturedMainArgv(root: string, mainSha: string, command: CheckCommand, selection: Selection | undefined): + { argv: string[] } | { reason: string } { + const words = command.argv; + let task: string | undefined; + let rest: string[] = []; + if (words.join(" ") === "pnpm typecheck:affected") { task = "typecheck"; rest = ["--concurrency=1"]; } + else if (words[0] === "pnpm" && words[1] === "test:affected") { task = "test"; rest = words.slice(2); } + else if (words[0] === "pnpm" && words[1] === "turbo" && words.slice(3).some((word) => PINNED_FILTER.test(word))) { + task = words[2]; + rest = words.slice(3).filter((word) => !PINNED_FILTER.test(word)); + } + if (task) { + const packages = [...new Set((selection?.tasks ?? []).filter((item) => item.taskId.endsWith(`#${task}`)) + .map((item) => item.package ?? item.taskId.slice(0, item.taskId.lastIndexOf("#"))))].sort(); + if (!packages.length) return { reason: "the session's selected tasks are not available to pin the same check on captured main" }; + return { argv: ["pnpm", "turbo", task, ...packages.map((name) => `--filter=${name}`), ...rest] }; + } + if (words[0] === "pnpm" && words[1] === "exec" && words[2] === "biome" && words[3] === "check") { + for (const path of words.slice(4)) { + try { git(root, ["cat-file", "-e", `${mainSha}:${path}`]); } + catch { return { reason: `the checked file ${path} does not exist on captured main` }; } + } + } + return { argv: [...words] }; +} + +// run-bounded.mjs's own signals: exit 124 with its stop line, or exit 2 with the spawn error it caught. +const TIMED_OUT_EXIT = 124; +const TIMED_OUT_MARKER = "run-bounded: stopped after"; +const WRAPPER_FAILED_EXIT = 2; +const SPAWN_FAILURE = /^spawn \S+ E[A-Z]+\s*$/m; + +function runBoundedScript() { + // Bundled into skills/vibe/scripts/dist/, beside the plugin's own run-bounded.mjs one directory up. + const script = fileURLToPath(new URL("../run-bounded.mjs", import.meta.url)); + if (!existsSync(script)) throw new MainSyncError("The plugin's run-bounded.mjs is missing; no comparison was run"); + return script; +} diff --git a/tools/vibe-workers/src/main-sync-test-fixtures.ts b/tools/vibe-workers/src/main-sync-test-fixtures.ts index 719af4ce..d75b5a90 100644 --- a/tools/vibe-workers/src/main-sync-test-fixtures.ts +++ b/tools/vibe-workers/src/main-sync-test-fixtures.ts @@ -83,7 +83,11 @@ export function referencedLanes() {return [];} const fs = require('node:fs'); const path = require('node:path'); const args = process.argv.slice(2); -fs.appendFileSync(path.join(process.cwd(), '.git', 'validation-calls.jsonl'), JSON.stringify(args) + '\n'); +// A linked checkout (the captured-main comparison) logs beside the session's calls in the common Git directory. +const dotGit = path.join(process.cwd(), '.git'); +const linked = fs.existsSync(dotGit) && fs.statSync(dotGit).isFile(); +const gitDir = linked ? path.resolve(process.cwd(), require('node:child_process').execFileSync('git', ['rev-parse', '--git-common-dir'], {encoding:'utf8'}).trim()) : dotGit; +fs.appendFileSync(path.join(gitDir, linked ? 'main-checkout-calls.jsonl' : 'validation-calls.jsonl'), JSON.stringify(args) + '\n'); if (args[0] === 'exec' && args[1] === 'node') { const cp = require('node:child_process'); const run = cp.spawnSync(process.execPath, args.slice(4), {cwd:process.cwd(), encoding:'utf8', input:fs.readFileSync(0,'utf8')}); @@ -98,6 +102,17 @@ if (args.includes('--dry=json')) { } const command = args[0]; if (!['exec', 'check:source-gates', 'typecheck:affected', 'test:affected', 'test:lanes', 'turbo', 'verify', 'test', '--filter', 'vibe', 'test:lint', 'test:skills'].includes(command)) process.exit(2); +// fixture-failures.json maps a check (its first word, or "turbo ") to always (every checkout), +// session (only the session checkout) or once (only its first session run). +const key = command === 'turbo' ? 'turbo ' + args[1] : command; +const modes = fs.existsSync('fixture-failures.json') ? JSON.parse(fs.readFileSync('fixture-failures.json', 'utf8')) : {}; +const mode = modes[key]; +const counter = path.join(gitDir, 'fixture-runs-' + key.replace(/[^a-z]/g, '-')); +const runs = !linked && fs.existsSync(counter) ? Number(fs.readFileSync(counter, 'utf8')) : 0; +if (!linked) fs.writeFileSync(counter, String(runs + 1)); +if (mode === 'always' || (mode === 'session' && !linked) || (mode === 'once' && !linked && runs === 0)) { + process.stderr.write('fixture failure for ' + command + '\n'); process.exit(1); +} if (fs.readFileSync(path.join(process.cwd(), 'apps/app/probe.ts'), 'utf8').includes('committed-bad')) { process.stderr.write('committed behavior fails\n'); process.exit(1); } diff --git a/tools/vibe-workers/src/main-sync-validation-contracts.ts b/tools/vibe-workers/src/main-sync-validation-contracts.ts index 51fc5dd1..13a3cdab 100644 --- a/tools/vibe-workers/src/main-sync-validation-contracts.ts +++ b/tools/vibe-workers/src/main-sync-validation-contracts.ts @@ -17,6 +17,7 @@ export const storedValidationRecipeSchema = z.object({ headSha: validationCommit inputSha256: validationDigestSchema, recipeSha256: validationDigestSchema, commands: z.array(fixedCommandSchema).min(1).max(128), recovery: recoveryReserveSchema.optional(), + comparison: z.object({ timeoutMs: z.number().int().min(0).max(2 ** 31 - 1) }).strict().optional(), e2eLimitations: z.array(z.object({ argv: z.array(z.string()).min(1).max(256), bindings: z.record(z.string()).optional(), reason: z.string().min(1).max(4096) }).strict()).max(128) }).strict(); export type StoredValidationRecipe = z.infer; diff --git a/tools/vibe-workers/src/main-sync-validation-recipe.ts b/tools/vibe-workers/src/main-sync-validation-recipe.ts index 419bf4fe..24a18a60 100644 --- a/tools/vibe-workers/src/main-sync-validation-recipe.ts +++ b/tools/vibe-workers/src/main-sync-validation-recipe.ts @@ -3,14 +3,17 @@ import type { z } from "zod"; import { digest } from "./main-sync-state.js"; import { MAX_CHECK_MS, MainSyncError } from "./main-sync-contracts.js"; +/** One pristine bootstrap of the disposable captured-main checkout, the same bound the input origin uses. */ +export const MAIN_BOOTSTRAP_MS = 15 * 60 * 1000; + /** Canonical digest binds actual selected commands, budgets and required coverage limitations together. */ -export function recipeIdentity(value: Pick) { +export function recipeIdentity(value: Pick) { return digest(JSON.stringify({ commands: value.commands, e2eLimitations: value.e2eLimitations, - ...(value.recovery ? { recovery: value.recovery } : {}) })); + ...(value.recovery ? { recovery: value.recovery } : {}), ...(value.comparison ? { comparison: value.comparison } : {}) })); } export function storedRecipe(identity: { headSha: string; treeSha: string; inputSha256: string }, - recipe: Pick) { + recipe: Pick) { return storedValidationRecipeSchema.parse({ ...identity, ...recipe, recipeSha256: recipeIdentity(recipe) }); } @@ -28,6 +31,10 @@ export function validationEnvelopeMs(recipe: StoredValidationRecipe) { if (recipe.recovery.timeoutMs !== recoveryBudgetMs(recipe.recovery.owners)) throw new MainSyncError("Canonical recovery reserve is not derived from its fixed owners"); total += recipe.recovery.timeoutMs; } + if (recipe.comparison) { + if (recipe.comparison.timeoutMs !== comparisonBudgetMs(recipe.commands)) throw new MainSyncError("Canonical main comparison reserve is not derived from its fixed commands"); + total += recipe.comparison.timeoutMs; + } for (const command of recipe.commands) { total += command.timeoutMs; if (command.argv[0] === "pnpm" && command.argv[1] === "turbo") total += 15 * 60 * 1000; @@ -48,3 +55,8 @@ export function recoveryBudgetMs(owners: z.infer[" } return total; } + +/** Handoff's worst case: one captured-main bootstrap, then every check failing, run once on main and once more on the session. */ +export function comparisonBudgetMs(commands: readonly { timeoutMs: number }[]) { + return MAIN_BOOTSTRAP_MS + commands.reduce((total, command) => total + 2 * command.timeoutMs, 0); +} diff --git a/tools/vibe-workers/src/main-sync.ts b/tools/vibe-workers/src/main-sync.ts index 8e04de48..bfb7856f 100644 --- a/tools/vibe-workers/src/main-sync.ts +++ b/tools/vibe-workers/src/main-sync.ts @@ -13,6 +13,7 @@ import { committedInputs, executeChecks, checkRecipe, inspectInputInventory } fr import { prepareInputOrigins, reusableInputOrigin } from "./main-sync-input-origin.js"; import { storedRecipe, verifyRecipeWitness, recipeIdentity } from "./main-sync-validation-recipe.js"; import { MainSyncError, mainSyncInputSchema, mainSyncReceiptSchema, mainSyncStateSchema, type MainSyncReceipt } from "./main-sync-contracts.js"; +import { comparisonLogs } from "./main-sync-main-comparison.js"; import { digest, requireSyncReceipt, saveSyncReceipt, syncContext, syncLocation, readPrivateJson, MAIN_SYNC_FILE } from "./main-sync-state.js"; const GIT_NETWORK_MS = 15 * 60 * 1000; @@ -156,14 +157,17 @@ export function validateMainSync(raw: unknown) { // Recheck the current source owner too: a stopped turn cannot issue a late success receipt. syncContext(raw, "validate"); const next = { ...requireSyncReceipt(value), validation, ...(ciEvidence ? { ciEvidence } : {}) }; + delete next.failedChecks; if (!ciEvidence) delete next.ciEvidence; if (receipt.pushedHead !== identity.headSha) delete next.pushedHead; saveSyncReceipt(value.place.dir, next); const requiredE2eComplete = validation.e2eLimitations.length === 0 || Boolean(ciEvidence); + const { mainPreexistingFailures, flakyChecks } = comparedChecks(validation.commands); return { status: value.purpose === "handoff" && !requiredE2eComplete ? "NEEDS_REVIEW" : "DONE", publicationReady: true, requiredE2eComplete, transactionId: receipt.transactionId, mainSha: receipt.mainSha, headSha: validation.headSha, treeSha: validation.treeSha, commandCount: validation.commands.length, purpose: value.purpose, validationScope: "publication-preparation", e2eLimitations: validation.e2eLimitations, + ...(mainPreexistingFailures.length ? { mainPreexistingFailures } : {}), ...(flakyChecks.length ? { flakyChecks } : {}), ...(ciEvidence ? { ciEvidence } : {}) }; } @@ -257,13 +261,17 @@ function validationMatches(value: ReturnType, receipt: MainS } for (const [index, command] of proof.commands.entries()) { const prefix = `vibe-main-sync-${receipt.transactionId}-`; - const permitted = [`${prefix}${index}.log`, `${prefix}0-${index}.log`, `${prefix}1-${index}.log`] + const permitted = [`${prefix}${index}.log`, ...[0, 1].flatMap((attempt) => ["", "-rerun", "-main"] + .map((suffix) => `${prefix}${attempt}-${index}${suffix}.log`)), `${prefix}main-bootstrap.log`] .map((name) => join(value.place.dir, name)); - if (!permitted.includes(command.log)) throw new MainSyncError("Actual command evidence path changed", "NEEDS_CHANGE"); - const file = command.log; - const stat = lstatSync(file, { throwIfNoEntry: false }); - if (!stat || !stat.isFile() || stat.isSymbolicLink() || stat.size > 16 * 1024 * 1024 - || digest(readFileSync(file)) !== command.sha256) throw new MainSyncError("Actual command evidence changed; validation is required", "NEEDS_CHANGE"); + const evidence = [{ log: command.log, sha256: command.sha256 }, ...(command.comparison ? comparisonLogs(command.comparison) : [])]; + for (const { log: file, sha256 } of evidence) { + if (!permitted.includes(file)) throw new MainSyncError("Actual command evidence path changed", "NEEDS_CHANGE"); + const stat = lstatSync(file, { throwIfNoEntry: false }); + if (!stat || !stat.isFile() || stat.isSymbolicLink() || stat.size > 16 * 1024 * 1024 + || digest(readFileSync(file)) !== sha256) throw new MainSyncError("Actual command evidence changed; validation is required", "NEEDS_CHANGE"); + } + if (command.comparison && command.comparison.mainSha !== receipt.mainSha) throw new MainSyncError("Main comparison names another main", "NEEDS_CHANGE"); } return true; } @@ -328,3 +336,21 @@ function featureTestHistory(root: string, originalBase: string, startingHead: st } return [...entries.values()].sort((left, right) => left.commit.localeCompare(right.commit) || left.path.localeCompare(right.path)); } + +/** Failures that also fail on the captured main go on the ticket for engineering; they never block publication. */ +function comparedChecks(commands: NonNullable["commands"]) { + const mainPreexistingFailures = []; + const flakyChecks = []; + for (const command of commands) { + const comparison = command.comparison; + if (comparison?.classification === "main-preexisting") { + mainPreexistingFailures.push({ check: command.argv.join(" "), mainSha: comparison.mainSha, + sessionExitCode: command.exitCode, mainExitCode: comparison.main.exitCode, mainArgv: comparison.main.argv, + log: command.log, mainLog: comparison.main.log }); + } else if (comparison?.classification === "flaky") { + flakyChecks.push({ check: command.argv.join(" "), mainSha: comparison.mainSha, + logs: comparison.sessionRuns.map((run) => run.log), mainLog: comparison.main.log }); + } + } + return { mainPreexistingFailures, flakyChecks }; +}