Repository navigation
finding(hooks): guard-main-checkout.sh is the only PreToolUse guard with no self-test — the one both CLAUDE.md files name by path as the enforcement mechanism #6451
Description
Activity
分诊解锁 ·
pm:blocked→pm:queue—— 阻塞已过期本轮「objectui 停放面」复核:本卡的
Blocked-by:目标已全部关闭,阻塞比阻塞方活得久。Blocked-by:目标直读结果 objectstack-ai/objectstack#11800closed ⇒ 声明的前置条件已兑现 ⇒ 解除,回
pm:queue。⚠️ 关于本次读数的来源,如实声明check-half-states.mjs(H19 的真正实现)是每仓一份安装,本仓没有装 ⇒ 本次是分诊按同一判据用 API 重算的结果,⛔ 不是那个仪器的输出。若将来本仓装上 sweep 且结论不一致,以 sweep 为准。目标状态是逐个直读的,不是推断的。⚠️ 本席只解除了「机器可读的那条阻塞线」若本卡另有未写进
Blocked-by:的实际前置(设计未定、上游 PR 在飞、等一句裁定),⇒ 请补一条Blocked-by:或Restart-when:再挂回pm:blocked,⛔ 不要只把标签改回去 —— 一条没有机器可读出口的阻塞,与被遗弃无法区分。⚠️ 本仓停放面同批读数:pm:on-hold67 ·pm:blocked44,其中 26 张 on-hold 没有可点火的Restart-when:。本卡不在那 26 张内。
Generated by Claude Code
Claim: PM loop round R40 (batch widened to 5 on the maintainer's instruction)
Session:session_01BGMDbrVa8JjZcCQ7DWYH1b
Branch:claude/issue-6451-guard-main-checkout-selftest
Worktree:objectui-issue-6451
Domain:domain:devx
File surface:.claude/hooks/guard-main-checkout.selftest.sh(new, ported from objectstack's matrix) and, only if the header's recorded counts still need it,.github/workflows/hook-selftests.ymlheader prose (stop on breach; explain in the report). ⛔ No edit toguard-main-checkout.shitself unless a ported case proves a real divergence — then report, do not patch silently. Governed surface (.claude/**): draft PR only, human merge.
Container & model: M,mode:subagent,model: opus(governed hooks matrix; judgment in the case set). Tier is this seat's explicit decision (nodispatch-gates.mjsin objectui).
Clause-②: no
Serial constraints cleared: unblocked by the 2026-08-31 unlock scan (objectstack#11800 closed); objectstackorigin/maincarries.claude/hooks/guard-main-checkout.selftest.sh(320 lines) ⇒ the "port the matrix" shape from triage applies. objectui'shook-selftests.ymldiscovers matrices byfindsinced42c2bee2(#7019), so no wiring edit is expected. Recent churn on the hooks:e55fdae6b(#7000),b28a2c5d0(#6908); the two repos'guard-main-checkout.shdiffer by 9 diff lines — the port must be checked against objectui's copy, not assumed identical. No in-flight PR in either lane touches.claude/hooks/**; batch siblings (#6640 test infra · #6705 parity scanner · #6291 derivation gate · #6606 designer-field-key parity) are disjoint by construction.
Generated by Claude Code
Dev claim (os-dev subagent, dispatched by the PM claim above).
Session:
session_01BGMDbrVa8JjZcCQ7DWYH1b
Branch:claude/issue-6451-guard-main-checkout-selftest(pushed empty at67dadd602, before any edit)
Worktree:/home/user/objectui-issue-6451
Base:67dadd602a3a891666ea1513c5de677140784b6aAssignee untouched (PM owns it). Planned file surface:
.claude/hooks/guard-main-checkout.selftest.sh(new, ported from the objectstack matrix) plus.github/workflows/hook-selftests.ymlheader prose only if that header's own convention still asks for it. No edit toguard-main-checkout.sh; a ported case that fails gets reported as evidence, not patched away. Governed surface: draft PR, human merge.
Generated by Claude Code
Port measurement (before filing the two hole cards, so the numbers below are measured, not assumed).
The sibling matrix (
objectstack-ai/objectstack@d63c8a2:.claude/hooks/guard-main-checkout.selftest.sh, 320 lines) was run byte-for-byte unmodified against this repo's hook andsettings.json, via its ownGUARD_MAIN_CHECKOUT_HOOK/GUARD_MAIN_CHECKOUT_SETTINGSindirection:87 passed, 0 failedZero cases needed adapting. That is consistent with the diff of the two hooks: 9 diff lines, all of them inside one comment block (objectstack's copy carries an extra "the recipe printed below is the highest-leverage copy of PD#11" note above the heredoc). No executable line differs, and
settings.jsonroutes the sameEdit|Write|NotebookEditmatcher here.Consequence worth stating plainly: the two
KNOWN HOLEsections in that matrix are not objectstack-only. They reproduce here identically, which is what the four$ODDcases and the fiveNotebookEditcases above measure. Filing them as this repo's own cards next, and the ported matrix will name those numbers rather than the objectstack ones.
Generated by Claude Code
os-dev-report
{ "issue": 6451, "status": "done", "branch": "claude/issue-6451-guard-main-checkout-selftest", "pr": "https://github.com/objectstack-ai/objectui/pull/7264", "premise_still_valid": true, "summary": "Ported objectstack's guard-main-checkout self-test matrix into objectui as .claude/hooks/guard-main-checkout.selftest.sh (333 lines, mode 100755, 26 top-level `expect` lines plus the inline specials = 87 cases). The port is verbatim and that was measured, not assumed: the two repos' guard-main-checkout.sh differ by 9 diff lines that are ALL inside one comment block, no executable line differs, and both settings.json route the same Edit|Write|NotebookEdit matcher, so the sibling file was first run here BYTE-FOR-BYTE unmodified via its own GUARD_MAIN_CHECKOUT_HOOK / GUARD_MAIN_CHECKOUT_SETTINGS indirection and returned 87 passed, 0 failed. ZERO cases needed adapting; the only 4 diff hunks vs upstream are comment/echo-label text (one added PORTED header paragraph, and the two KNOWN HOLE issue references re-pointed at this repo's own new cards, each keeping the sibling number as a cross-ref). Legs 2 and 3 of the 2026-08-26 triage ruling were re-checked and are both NO-OPS on today's tree, so neither the workflow nor the hook was touched (detail in `deviations`). guard-main-checkout.sh itself is byte-identical to HEAD: the fence held, and the two defects the matrix surfaced were filed rather than patched.", "tests": "NEW MATRIX, in place: `.claude/hooks/guard-main-checkout.selftest.sh` -> verdict line `87 passed, 0 failed`, exit 0, 0 skipped (the submodule fixture built, so neither of its 2 cases skipped). SIBLING FILE RUN UNMODIFIED FIRST against objectui's hook+settings -> `87 passed, 0 failed` (this is the evidence that zero adaptation was owed). WHOLE CI STEP BODY, extracted verbatim from hook-selftests.yml:174-206 and run locally on HEAD `3f5d6faf0`: `discovered 4 hook self-test(s)` -> guard-main-checkout-bash 121 passed 0 failed PASS / guard-main-checkout 87 passed 0 failed PASS / guard-shared-stash 41 passed 0 failed PASS / guard-tree-enum 36 passed 0 failed PASS -> `Hook self-tests — all 4 ran and passed`, step exit 0. NON-VACUITY (5 mutation classes, each against a SCRATCH COPY of the hook under $TMPDIR via GUARD_MAIN_CHECKOUT_HOOK — the tracked hook is never mutated, so the restore leg is a scratch-file delete, and `git diff --stat` on guard-main-checkout.sh was empty afterwards with only the new untracked matrix present; EACH mutation was confirmed to have LANDED ON DISK by a marker grep count going 1 -> 0 before its reading was accepted, and a non-landing would have discarded the reading): drop the `*/worktrees/*) exit 0` arm -> 62 passed, 25 failed; replace dirname of the file with CLAUDE_PROJECT_DIR -> 52 passed, 35 failed; drop the OS_ALLOW_MAIN_EDITS line -> 85 passed, 2 failed; final `exit 2` -> `exit 0` -> 45 passed, 42 failed; rename the key in the jq-less grep fallback -> 83 passed, 4 failed. No build/dist step exists for a shell hook, so there is no rebuild leg to confirm. GATES (derived from every .github/workflows/*.yml that can see .claude/hooks/** — only hook-selftests.yml carries a matching paths: filter, the rest are unfiltered; exit code captured by redirecting to a file BEFORE any pipe, and each verdict quoted from the gate's own printed line, never from a bare $?): check-control-bytes exit 0 `✅ check-control-bytes: OK (scanned 5997 tracked text file(s); skipped 85 binary).`; check-shell-escape-residue exit 0 `✅ check-shell-escape-residue: OK (4/4 root(s) resolved ...)`; check-changeset-presence exit 0 `✅ No source or published contract of a released package changed in this range, so no changeset is owed.` (also `1 file(s) changed, 0 of them published source ..., 0 changeset(s) added`); check-upstream-port-parity exit 0 `✓ check-upstream-port-parity: 2 ported file(s) match objectstack-ai/objectstack@bf10debd5 modulo their declared divergences.`; check-governed-queue-guard --self-test exit 0 `OK check-governed-queue-guard self-test: 132 cases pass`. TWO SCOPE HONESTIES, reported as NOT MEASURED rather than as green: (1) check-shell-escape-residue resolves only AGENTS.md, CLAUDE.md, skills/ and content/docs/ — NOT .claude/hooks/ — so its green is true and says nothing about this file; (2) check-control-bytes DOES cover it (tracked, inside the 5997) and an independent manual `grep -naP` over the new file returns 0 hits. NOT RUN, declared: no pnpm install and no vitest/build in this worktree — the change is one shell file that no package imports, eslint.config.js is scoped to ts/tsx throughout and there is no shellcheck in the repo, so no unit/type/lint surface reaches it. The shared verify lock was NOT used for these: its own --status text names `check:*` gate scripts as unlocked sibling work it never covers. One earlier batched attempt did queue behind another dev's vitest shard (position 2/2) and was abandoned in favour of running the light node gates directly rather than waiting; the lock slot was parked, blocking nobody.", "mcp_calls": "12 — issue get, issue get_comments, 3 issue comments (claim, port measurement, this report), 2 targeted search_issues (dedup before filing), 3 issue creates, create_pull_request, pull_request_read (full body read-back for sanitizer damage: intact, only entity-encoding of quotes)", "open_questions": [], "out_of_scope_findings": [ "filed as #7259: guard-main-checkout.sh matches any git-dir path containing /worktrees/ as a linked worktree, so a PRIMARY checkout living under a dir named `worktrees` is unguarded — and the verdict flips BY DEPTH, since git prints a relative git-dir at a toplevel and an absolute one from a subdirectory. Reproduces identically in objectui; 4 cases pinned in the new matrix under a KNOWN HOLE banner. Sibling: objectstack#11809.", "filed as #7260: settings.json routes NotebookEdit at the hook, but the hook only extracts .tool_input.file_path and NotebookEdit's key is notebook_path, so every notebook edit is judged by CLAUDE_PROJECT_DIR instead of by the file — wrong in 4 of 5 measured directions, two of them fail-open (an agent correctly in a worktree can write a notebook into the shared checkout unguarded). 5 cases pinned. Sibling: objectstack#11810.", "filed as #7263 (label `finding`): scripts/upstream-port-pin.json holds only two scripts/ entries, so the verbatim cross-repo ports under .claude/hooks/** have no drift gate — the exact shape check-upstream-port-parity was built for after #6642 measured ~212 KB of silent drift, in the directory where the drift has actually kept recurring (#5459, #5712, #5789, #6042 were each caught by a human, never by a gate). This PR's file could be registered today with 4 declared divergences, but whether the ledger's scope reaches governed surface (where --resync rewrites files in place) is a decision, not a mechanical edit." ] }
Generated by Claude Code
Addendum to the report above — the
deviationsblock itssummarypoints at (it was described in prose there but not carried as its own field; recording it here so the GitHub copy is complete, and the returned JSON carries it asdeviations).1. Leg 2 of the triage ruling ("wire it into
hook-selftests.yml") — NO-OP, no edit made. Verified by reading the workflow and by running itsrun:block: discovery isfind .claude/hooks -type f -name '*.selftest.sh'since #7019, and the local run reporteddiscovered 4 hook self-test(s)including the new one, with no workflow edit. The header anticipates this card by name at:51. Matches PM mechanism assumption (c).2. Leg 3 ("refresh the header's stale recorded counts, 100/32 vs 121/41") — NO-OP, deliberately not done; this is the one place the dispatch's own instruction and the file's text diverge. The header at
:95-101does not merely tolerate the stale figures, it forbids refreshing them: "Those counts stay, and the distinction is deliberate (objectui#6089): a measurement is HISTORY... a matrix that grows afterwards does not make it false — it makes it older. Both have grown since (121 and 41 as of 2026-08-31, plus a third matrix), which is exactly what a dated measurement is allowed to do." The 2026-08-24 measurement is dated and stays; the 2026-08-31 sentence is also dated and already records 121/41, so it is history too and adding "and a fourth matrix" would convert it into an undated live claim — the exact shape the same header rejects for step names. The live count is published by the thing that cannot get it wrong: the step prints its own discovery count. Dispatch §2(c) invited this check with "follow the header's text, do not invent a convention", so this is a declared falsification of the triage hypothesis, not a skipped leg.3.
guard-main-checkout.shuntouched — the fence held; no ported case failed, so nothing demanded an adaptation or a hook change. Its header gains no self-test pointer either, which keeps parity: objectstack's copy has had the matrix far longer and carries no pointer.4.
scripts/upstream-port-pin.jsonuntouched — a breach was considered and declined. This PR's file is a textbook ledger candidate (verbatim port, 4 declared divergences), but the ledger holds onlyscripts/entries today, and extending its scope to governed surface (where--resyncrewrites the ported file in place) is a decision rather than a mechanical edit. Filed as #7263 instead.5. Shared verify lock not used for the gates. Its own
--statustext namescheck:*gate scripts as unlocked sibling work it never covers. One batched attempt did queue (position 2/2 behind another dev's vitest shard) and was abandoned in favour of running the light bare-nodegates directly; the slot stayed parked, blocking nobody. No heavy build or test was owed — the change is one shell file no package imports.
Generated by Claude Code
ACCEPT — PR #7264 (draft; governed surface ⇒ human merge)
Reviewer of record:
domain:devx@ objectui execution seat, PM sessionsession_01BGMDbrVa8JjZcCQ7DWYH1b, R40. Verified against GitHub, not against the report.Checklist
- PR shape: draft ✓ · base
main✓ · first body lineFixes #6451✓ (this PR is the whole card; closing on merge is correct) · body scanned for closing keywords near other refs: none — finding(hooks):guard-main-checkout.shreads any git-dir path containing/worktrees/as a linked worktree — a PRIMARY checkout under a dir namedworktreesis unguarded, and by DEPTH #7259 / finding(hooks):guard-main-checkout.shnever readsnotebook_path, so everyNotebookEditis judged byCLAUDE_PROJECT_DIR— wrong in BOTH directions, though the matcher routes that tool to it #7260 appear only after "Neither card is closed by this PR;", no keyword adjacent to either number ✓. - Changed files (from the PR, not the report): exactly one,
.claude/hooks/guard-main-checkout.selftest.sh(new, +333) ✓ — inside the claimed file surface;hook-selftests.ymland the hook untouched, each with a reason from the tree's own text (discovery byfindsince ci(hook-selftests): discover the self-test matrices instead of enumerating them #7019; header:95-101forbids refreshing dated counts). Triage's legs 2 and 3 were hypotheses; both falsified with citations — good run, not a deviation. - Changeset:
.claude/**, presence gate's own verdict "no changeset is owed" ✓. - Tests: matrix verdict
87 passed, 0 failed, the CI step body run locally discovering 4 matrices, and five ablations against a scratch copy with the on-disk marker count going 1→0 before each reading ✓. Spot-check on the diff: theverdict()/check()/expect()helpers, the fail-open default (CWD/PROJin a no-repo dir), the jq-less lane and thesettings.jsonmatcher pin are all present; the two KNOWN HOLE sections pin today's wrong verdicts and name the objectui cards. - CI on head
3f5d6faf0:Hook Self-Testssuccess,Governed Surface Queue Guardsuccess, control-byte / shell-escape / changeset / docs gates success;Lint,Type Check,Test (shard 1–4),Build & E2Estillin_progressat review time — expected for a report delivered at draft-PR time; the maintainer's merge waits on those anyway. - Out-of-scope findings exist as filed and unassigned: finding(hooks):
guard-main-checkout.shreads any git-dir path containing/worktrees/as a linked worktree — a PRIMARY checkout under a dir namedworktreesis unguarded, and by DEPTH #7259 (worktrees substring match, depth-dependent), finding(hooks):guard-main-checkout.shnever readsnotebook_path, so everyNotebookEditis judged byCLAUDE_PROJECT_DIR— wrong in BOTH directions, though the matcher routes that tool to it #7260 (notebook_pathnever read — two fail-open rows), finding(devx):upstream-port-pin.jsoncovers onlyscripts/, so the verbatim cross-repo ports under.claude/hooks/**have no drift gate — the same shape #6642 measured at ~212 KB #7263 (upstream-port-pin.jsonscope,finding). All three left unlabelled for triage. - Report hygiene:
mcp_calls13, MCP used only for writes and two targeted dedupe searches; the verify lock was correctly not used for bare-node gates.
Landing path — governed surface (
.claude/**): ⛔ not flipped ready, not enqueued, no auto-merge. Review requested from the two authorised approversos-zhuangandhotlong(this seat's identity is the PR author, which is neither, so both requests are valid). Merge criterion for the approver: all checks green on the current head, then human merge or an APPROVED review pinned to that head lets the queue take it. Card stayspm:dispatched+ assigned until MERGED.
Generated by Claude Code
- PR shape: draft ✓ · base
Found while porting the worktree-recipe repair (#6208 / PR #6449); out of scope for that card, filed rather than fixed.
The gap
This repo has three PreToolUse guards. Two carry hermetic self-test matrices that
.github/workflows/hook-selftests.ymlruns on every PR touching.claude/hooks/**:.claude/hooks/guard-main-checkout-bash.shguard-main-checkout-bash.selftest.sh.claude/hooks/guard-shared-stash.shguard-shared-stash.selftest.sh.claude/hooks/guard-main-checkout.shNothing in CI exercises
guard-main-checkout.shat all.Why this one is the awkward omission
guard-main-checkout.shis the original guard — theEdit/Write/NotebookEditone. It is the hook bothCLAUDE.mdandAGENTS.md§9 name by path as the mechanism that enforces worktree-first. The Bash-side guard exists to cover the same rule expressed as a shell command, andguard-main-checkout-bash.sh's own header describes itself as the second line ("Same rule as guard-main-checkout.sh — that hook is registered on Edit|Write|NotebookEdit only, so the identical edit expressed as a shell command used to slip through in silence").So the covered guard is the follow-on and the uncovered one is the primary. #5754 added the standing CI caller and wired it to the two self-tests that existed; nothing was left recording that the third guard had none.
What the absence actually permits
Both a false-negative and a false-positive path are unguarded:
Neither has a test that would go red. Concretely: PR #6449 edited the recipe text this guard prints (
guard-main-checkout.sh:57) and no gate in this repo could have noticed a mistake in it — the wholescripts/__tests__tree stayed green at 2318 passed, correctly, because nothing there reads the file.Not asserting the shape of the fix
The sibling self-test is a ready template (
guard-main-checkout-bash.selftest.shbuilds a throwaway repo plus a linked worktree and drives the hook through a case matrix, with a jq-less fallback lane). Whether the right move is a third matrix, folding the Edit-side cases into the existing one, or something narrower is for whoever picks this up. Notehook-selftests.yml's header states a per-file case-count-recipe convention that a new self-test would need to follow, and that its recorded counts (100 / 32) are already behind the tree's (121 / 41).Filed unassigned; no
domain:*or type label set.Generated by Claude Code