fix(ci): refuse a model identifier in the commit trailer pair at push - #17770
Merged
os-sales merged 2 commits intoSep 12, 2026
Merged
Conversation
The pre-push check judged card relations only. The model-free half of the same declared pair — the one the rules state with an exact spelling — had no instrument at all, and once a branch lands the residue is permanent. The second finding class reads the trailer VALUE inside the trailer block: a co-author value at the anthropic address must read the bare declared name, an id-form model name anywhere in that value binds, and the session value is a session URL. Body prose is untouched, and a trailer-less merge commit is clean. Exit table, dedup and remedy register unchanged in shape. Claude-Session: https://claude.ai/code/session_01MCLBsUgfykL74aU716rzVK Co-authored-by: Claude <noreply@anthropic.com>
A floor below the cases that ran lets one stop running unnoticed, which is the whole point of the roster. 14 was a hand-written guess; the battery registers 15. Claude-Session: https://claude.ai/code/session_01MCLBsUgfykL74aU716rzVK Co-authored-by: Claude <noreply@anthropic.com>
os-sales
marked this pull request as ready for review
September 12, 2026 04:21
os-sales
deleted the
claude/issue-17280-model-free-trailer-pair-enforced
branch
September 12, 2026 04:52
akarma-synetal
pushed a commit
to akarma-synetal/framework
that referenced
this pull request
Sep 17, 2026
… the trailer pair (objectstack-ai#17771) Fixes objectstack-ai#17280 One clause on the existing 「Commit message」 sentence in `AGENTS.md`: the pre-push hook now refuses a model identifier in the trailer pair. The rule already declared the pair model-free; until PR objectstack-ai#17770 nothing read it, and a reader of the rule could not tell whether anything ever would. The enforcement itself is PR objectstack-ai#17770 (`scripts/check-commit-card-trailers.mjs`, `Part of objectstack-ai#17280`). This PR is the governed half and closes the card, because a rule and its instrument are one statement to a reader. ⛔ Draft, human-merged: `node scripts/pm/check-governed-merges.mjs --test AGENTS.md` exits 3 (GOVERNED). No seat flips it ready, enqueues it, or arms auto-merge. ## The edit Before, the sentence declared the pair and stopped. After, it declares the pair, names the instrument, and continues unchanged: `…and the pre-push hook refuses a model identifier in that pair; no model identifier lands in a PR title or body, a comment, a changeset, a doc or a code comment.` The reporting exemption, the model-free pair and 「landed history is not rewritten」 are untouched, and nothing else in the file moves. Where the hook enforces mechanically, the rule is stated once here and the script's own header is the authority on detail — which is the convention this file already states for itself, so the clause deliberately says what is refused and not how. ## Budget **Paid by density, not by a new line.** The file is at 1075/1075 under the line ratchet with zero headroom, so the clause is absorbed by re-wrapping the same five lines: two lines changed, ⛔ no line added, ⛔ no rule deleted, ⛔ ceiling untouched. ``` ✓ check-skill-line-ratchet: AGENTS.md is 1075 lines (ceiling 1075; headroom 0). ✓ check-skill-line-ratchet: AGENTS.md: widest table row is 768 bytes (pin 768; headroom 0). ``` Every line in the rewrapped block is within the 120-byte budget, and the inline code spans are kept whole on one line each rather than split across the wrap. ## Verification Gates derived with `node scripts/pm/dispatch-gates.mjs --commands --repo objectstack-ai/objectstack` (the script took the change set itself, three-dot): 14 commands, all run, all exit 0 — including `check:pm-skill-ratchet`, `check:pm-governed-prose`, `check:pm-governed-merges`, `check:pm-skill-id-lint` and `check:nul-bytes`. Exit codes were captured before any pipe. `skip-changeset`: a repo-root instruction file that no package's `files[]` ships — nothing published moves. ## 维护者速读(草稿) **改了什么** — `AGENTS.md` 里「Commit message」那句加一条从句:commit 的 trailer pair 里带模型名,现在由 pre-push 钩子当场拒绝。规则文字本身没变,只是把执行者写进了同一句话。 **为什么改** — 这条规则此前是「声明了但没有任何东西执行」:一个班次里 5 条分支上的 18 个 commit 带着模型名过了绿 CI,另一个席位量到最近 200 个已落地 commit 正文里有 73 个带模型名。按 ADR-0049 的 enforce-or-remove,裁决是 ENFORCE,机制在 PR objectstack-ai#17770;本 PR 是规则侧的那一半,让读规则的人同时知道它会被拒。 **风险与代价(含回滚)** — 风险很小:这是一句话的重排,不新增规则、不改变任何既有判词。代价是 `AGENTS.md` 已在行数棘轮上限,本次以密度支付(重排同一段五行),没有占用未来预算。回滚 = revert 本 PR;规则回到「声明而不执行」的状态,机制 PR 可独立存续或一并 revert,两者没有代码依赖。 **席位意见** — **你要做的** — 这是受管面,需要你手工合并(⛔ 不进合并队列、不挂 auto-merge)。合并前值得确认一件事:你是否同意「AGENTS.md 拥有这个格子」这一裁决 —— 即 harness 的署名模板写的是带模型名的 co-author 行,而本仓规则要求 model-free,冲突以本仓规则为准。已按此裁决执行;若你的判断相反,机制 PR objectstack-ai#17770 需要一并撤回。 --- _Generated by [Claude Code](https://claude.ai/code/session_01MCLBsUgfykL74aU716rzVK)_ Co-authored-by: Claude <noreply@anthropic.com>
akarma-synetal
pushed a commit
to akarma-synetal/framework
that referenced
this pull request
Sep 17, 2026
…objectstack-ai#17801) Closes objectstack-ai#15845 Clause-②: no — no `packages/spec/src/**` path in the diff, no schema key, no closed-set member, no published export, no registry entry. `pnpm check:pm-widening-tells` exits 0 on this branch. The card carrier was placed by the dispatching PM and is untouched. ## What lands `scripts/check-closing-target-claim.mjs` and `.github/workflows/closing-target-claim-guard.yml` — the card's design, implemented, not a second one. For every closing-keyword target in the PR body, the card's comment thread must carry a `Claim:` whose `Branch:` line names this PR's head branch. Everything decisive is **imported, never restated**: | reading | shipped owner | |---|---| | closing-keyword grammar | `closingKeywordTargets` (H7 / H21 / H46; the module parser `check-closing-keyword-parity` pins) | | claim predicate | `h46ClaimNamesBranch` = `CLAIM_COMMENT_MARKER` + `claimedBranches` | | classification of the negative half | `claimGovernance` — the same three-valued reader `check-clause2-carriers.mjs` uses | | merge-queue ref | `pullNumberFromQueueRef` from `check-governed-queue-guard.mjs` | H46 itself is **unchanged** — still the patrol's after-the-fact view, still report-only, still the exporter of the predicate this gate calls. Two consumers, one predicate, no fork. No assignee-field logic. No change to what a claim *is*. A `Part of` target is never read. ## The one thing the live board forced, and it is the interesting half A live probe against the real card **found a false red on this PR's own claim.** The dispatch comment on objectstack-ai#15845 writes its branch on the `Claim:` line itself, after a separator, with no `Branch:` directive under it: ```text Claim: session_012GK... · claude/issue-15845-claim-comment-pr-gate Clause-②: no ``` `CLAIM_COMMENT_MARKER` matches it. `claimedBranches` yields **zero** branches. So `h46ClaimNamesBranch` answers *false* on a card that really is claimed, on the very branch being judged — and objectstack-ai#17620 carries the same spelling, so it is a shape and not a typo. For a report-only row that is a row. For a gate that fails builds it is a red on a correctly-claimed card, and a dispatched executor ⛔ may not post a second claim to clear it. So the malformed case takes the third value `claimGovernance` exists to return: **UNDETERMINED**, warned about by name, never folded into either verdict. That is the tree's own ruling applied where it now has teeth — *"An unparsed claim is an UNCLASSIFIED result, ⛔ never a 'no'."* ⛔ The branch reader is **not** widened; the repair direction stays the write side. ## Acceptance, with live controls against the real board Every row below was run against `objectstack-ai/objectstack` itself, not a fixture. | control | input | result | |---|---|---| | ⭐ positive — correct claim | head `claude/issue-17729-narrowing-names-replacement-owner`, body `Closes objectstack-ai#17729` | **exit 0**, names the card it verified | | ⭐ positive control — claim names another branch | head `claude/issue-17729-someone-else`, body `Closes objectstack-ai#17729` | **exit 1**, and the row quotes the branch the claim *does* name | | negative — only `Part of` | body `Part of objectstack-ai#17729` | exit 0, **zero API calls**, no annotation | | negative — closes nothing | ordinary prose body | exit 0, **zero API calls**, no annotation | | malformed claim | this card, this branch | exit 0 + one `::warning::UNDETERMINED` | | declined number | body `Closes objectstack-ai#17770` (a PR) | exit 0, says the number names a pull request | Offline: `pnpm check:closing-target-claim` — **80 cases pass**, 10 batteries at or above their floors. The red/green pair, the zero-call negative controls, the declined-number policy, the UNDETERMINED arms, the queue leg and the wiring are all pinned there; costs are measured over a fake transport with a call log, so "bounded" is a test rather than a sentence in a header. ## Queue behaviour — asserted, not assumed The card says merge-queue builds see the same PR body. That is true **here only because this file makes it true**, and both sibling PR-scoped guards deliberately do the opposite (their headers say a `merge_group` event carries no pull request, so they take no queue leg). This one takes the leg anyway, because the queue **ref names its pull request** — `gh-readonly-queue/BASE/pr-NUMBER-SHA`, read by the imported `pullNumberFromQueueRef`. With the number in hand the body and head ref are one ordinary read away, so the queue build judges the **same PR body** against the **same live threads**, through the **same** `collect` and `judge`. Pinned by `the wiring takes the merge_group leg — the queue claim is wiring, not prose` and by a queue-leg red/green pair that reaches the identical verdict as the `pull_request` leg.⚠️ Limit stated rather than discovered: in a multi-PR group the queue ref names only the **last** pull request. Every member passed the `pull_request` leg to be armed at all. ⛔ This PR does **not** add itself to `REQUIRED_CONTEXTS`, and does not touch `scripts/check-required-contexts.mjs` (objectstack-ai#15233 is live in that file). What the queue leg buys today is that the question is asked on the queue build; what it buys the day a maintainer *does* required-ize this context is that it cannot deadlock the queue — which a workflow with no queue leg always does. ## Cost Zero API calls when the body binds no closing keyword. Otherwise, per closing target: one comment page; and **only on the path about to go red**, one issue read to classify the number before accusing anyone. Asserted: the green path costs exactly 1 call, the red path exactly 2. ## The branch-rename edge triage asked to be decided, not discovered Decided in the **failure text**, not in the predicate — remedy 2 names it verbatim ("Claimed already, then RENAMED or RE-CREATED the branch?"). Widening to a session identity would make this gate a second reader of what a claim is, and would accept a claim pointing at a ref nobody can find. ## Reverse-read — which existing sentence does this make false Four, all repaired in this diff; the rest are zeros, reported as zeros. 1. `partof-closing-keyword-guard.yml` — "the Duplicate Fix Guard is this repo's **other** PR-body-scoped blocking check" ⇒ "one of this repo's other …". 2. `scripts/check-partof-closing-keyword.mjs` header — the same singular ⇒ the same repair. 3. `single-claim-path-guard.yml` — "the Part-of Closing-Keyword Guard is this repo's **other** single-script PR-scoped blocking check" ⇒ "one of …". 4. `pr-automation.yml` — "this repo's **two** other PR-body-scoped blocking checks" ⇒ the three of them, with a note that the run-count measurement below it is a dated 2026-09-08 reading over the two that existed then and is left exactly as measured. **Zeros, each looked for and each empty:** H46's own `Report-only patrol INPUT` sentence stays true (H46 is untouched and still report-only). No `REQUIRED_CONTEXTS` row is implied or added — `pnpm check:required-contexts` exits 0. No sentence claims the claim question is asked *only* by H46. `required-set-patrol.yml`'s "this is the ONLY workflow running the flag" is untouched. No test in the tree pins the shipped guard set, the sibling count, or `h46ClaimNamesBranch`'s treatment of an unparsed claim — so **nothing had to be re-judged in place, and nothing was deleted**. ## Changeset — measured, then judged `skip-changeset`, applied as the **label**. Measurement: the root manifest is `private: true`; no published package's `files[]` names `scripts/` or `.github/`; `git grep` for the new script under `packages/` returns zero. Every changed path is repo-root config, `scripts/**` or `.github/workflows/**`. Nothing published moved. ## Verification - `pnpm check:closing-target-claim` — 80 cases pass. - Gate families derived with `node scripts/pm/dispatch-gates.mjs --commands --repo objectstack-ai/objectstack` and re-derived after the reverse-read repairs; every derived family that can run without a package build was run and exits 0 — including `check:pm-dispatch-gates`, `check:required-contexts`, `check:self-test-wired`, `check:self-test-workflow-commands`, `check:step-collectors`, `check:closing-keyword-parity`, `check:scripts-symbol-anchors`, `check:watch-hint-literal`, `check:workflow-step-name-quoting`, `check:workflow-status-functions`, `check:parse-guard`, `check:nul-bytes`, `check:whole-set-label-write`, `check:published-list-mirrors`, and the two sibling guard self-tests. - `eslint . --no-inline-config` over the **whole** repository at `23182d7087`: **6641 files, 0 errors, 0 warnings** — the full population, so no narrowing was claimed. - `check:type-check-debt` and `check:sourcemap-no-sources-content` exit **3 — PREREQUISITE NOT MET**, both needing a full closure build. Recorded as **NOT MEASURED**, not as green: this diff adds no package, no export and no `dist`, so they are declared to CI. 🤖 Generated with [Claude Code](https://claude.com/claude-code) https://claude.ai/code/session_012GKcPZbMoGq7WPzKLfRBTU --- _Generated by [Claude Code](https://claude.ai/code/session_012GKcPZbMoGq7WPzKLfRBTU)_ --------- Co-authored-by: Claude <noreply@anthropic.com>
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Part of #17280
AGENTS.mddeclares the agent commit trailer pair model-free and, until this PR, nothing read it: the pre-push check that PR #17745 landed judges card relations only, and quotes the rule sentence — whose second half is the pair — while reading no attribution at all. The card measured 18 commits on 5 open branches carrying a model-named co-author value past green CI in one shift; a sibling seat measured 73 of the last 200 landed commit bodies carrying one. Under ADR-0049 enforce-or-remove, this is the ENFORCE half.The authority question the card asks, answered on the record
The card asks which authority owns this cell, because the dispatching harness's attribution block specifies a model-named co-author trailer while
AGENTS.mdspecifies the model-free pair.AGENTS.mdowns the cell. The maintainer's standing instruction across this shift is model-free everywhere, and this repository's own rules take precedence over a harness reminder, which is a reporting form rather than a rule about this repo. So the "losing document" is nothing inside this repository — there is nothing here to change in the other direction, and the enforcement is the fix. The rules file already carries the reporting exemption (a harness-written trailer is not declared a deviation) and this check is consistent with it: it refuses the spelling before it can be pushed, which is the one moment the repair is free.What this adds
A second finding class in
scripts/check-commit-card-trailers.mjs— one script, one hook invocation (unchanged), one CI self-test step (unchanged), one override (unchanged), the same exit table (0 clean / 1 finding / 2 not judged).What is judged is the trailer VALUE, inside the trailer block only:
⛔ Not a list of model names. A list goes stale the day a model is renamed, and the population this exists for is whatever the harness writes next; the rule binds the declared spelling itself, so a name nobody has shipped yet fails it for the same reason the four measured ones do (pinned as a case).
⛔ Not the message body. The rule is about the pair, so a commit whose prose names a model for a legitimate reason — explaining a fixture, quoting the order it is correcting — declares nothing and stays clean. That is the line the file's bare-reference class already draws, and it is where GitHub reads co-authorship from too: trailer position only.
⛔ Not a co-author who is not that identity. A human co-author at their own address is never this rule's business.
Dispositions, stated plainly
scripts/pm/os-regen-merge.shwrites withgit merge --no-editcarry no trailer at all, so they carry no model and PASS — the rule is about the pair when the pair is present. Pinned as a case, which answers the third failure direction the card records: an agent following the sanctioned tool is not refused.PR_TITLE+PR_BODY) means branch trailers no longer reachmain's squash bodies. That narrows the blast radius; it does not discharge the rule on branch commits, which is what this refuses.Verification
Self-test —
pnpm check:commit-card-trailers: 81 cases pass (was 56 onmain), two new registered batteries, both floored at their landed counts, roster floor raised 8 → 10 so a deleted battery cannot take its own floor with it.Live legs through the real hook in the worktree (not a simulation of it):
git pushof this branch's own commit — the hook printed the new clean line and the push landed.git pushrefused (exit 1), naming the commit, line 4, the quoted trailer and the remedy; thengit reset --hardto the pushed tip. Nothing was published.Ablation — the finding really comes from the new scan, not from something else:
git push --dry-run)On-disk proof rather than an editor's exit code: the wiring's occurrence count went 1 → 0 and the injected marker 0 → 1; the file's blob hash moved off the HEAD blob and back to it on restore, with
git diff HEADempty andgit statusclean. Restored under atrap … EXIT INT TERMwith an absolute path.--dry-runruns the hook (leg 1 proves it) and publishes nothing (the remote tip was re-read afterwards and had not moved).Gates — derived with
node scripts/pm/dispatch-gates.mjs --commands --repo objectstack-ai/objectstack(change set taken by the script itself, three-dot), 32 derived, 32 run, all green, at the final commit131c937fa— includingcheck:self-test-wired,check:scripts-symbol-anchors,check:declaration-mirrors,check:nul-bytesandcheck:commit-card-trailersitself. Exit codes were captured before any pipe (redirect first, then read$?).check:pm-dispatch-gatesexceeds the foreground cap, so it was detached and waited on withtail --pid; its own verdict line reads✓ dispatch-gates self-test: 1678 cases pass.with zero✗lines in the log. The--ranreconciliation accounted for every derived family.skip-changeset: the diff is one repo-root script that no package'sfiles[]ships — nothing published moves.Acceptance notes
.githooks/pre-pushheader describes refusal 2 as card relations. It is left as-is deliberately — that file says "The script's header is the authority; this file is the invocation", and the script's header now carries both classes. No behaviour depends on it, and no PR or person is routed by it.Generated by Claude Code
Generated by Claude Code