fix(ci): refuse card relations in commit messages at push, and re-scope the PR-time gate to the body - #17745
Merged
Conversation
…lings `partOfRe` required a literal space on both sides of the word, so `Part-of #N`, `Part of: #N` and `Part-of: #N` — the three spellings every dispatch order lists as forbidden, and the last of which is the git-trailer form — were invisible to every reader of this grammar. `refsRe` carried the same colon gap, and a pinned case asserted it as if it were a decision. The separator is now the one `closingKeywordRe` already ships (an optional colon), and the word accepts a hyphen where it accepts a space. Strict superset: nothing that matched before stops matching. Claude-Session: https://claude.ai/code/session_01MCLBsUgfykL74aU716rzVK Co-authored-by: Claude <noreply@anthropic.com>
…e PR The rule that no commit message carries a card relation was enforced on the pull request, over its commit list. By then the cheap repair was gone: a new commit on top joins that list, and removing the message is the history rewrite this repo forbids, so two rounds each paid a full redo. - `scripts/check-commit-card-trailers.mjs` judges exactly the commits a push would publish — everything it sees is unpublished, so its remedy is an ordinary reword. It adds no grammar: the extractors are the sweep's, and the trailer-position rule covers a bare reference with no keyword beside it. - `.githooks/pre-push` calls it after the os-regen deferral check, which keeps its behaviour unchanged; the ref lines git writes are now read once and handed to the check whose input they are. - `check:partof-closing-keyword` drops its commit-list rule: the squash message is taken from the PR body now, so a branch commit's trailer never lands. Claude-Session: https://claude.ai/code/session_01MCLBsUgfykL74aU716rzVK Co-authored-by: Claude <noreply@anthropic.com>
`pnpm check:nul-bytes` refuses a raw ASCII control byte in any tracked file, and two places here carried one: the `git log -z` record separator and the self-test fixture that exercises it. The escape was materialised into the byte by the editor that wrote it, which is exactly the failure the gate names. `String.fromCharCode(0)` cannot be materialised into anything. Claude-Session: https://claude.ai/code/session_01MCLBsUgfykL74aU716rzVK Co-authored-by: Claude <noreply@anthropic.com>
This was referenced Sep 12, 2026
os-sales
marked this pull request as ready for review
September 12, 2026 02:39
This was referenced Sep 12, 2026
akarma-synetal
pushed a commit
to akarma-synetal/framework
that referenced
this pull request
Sep 17, 2026
objectstack-ai#17752) Fixes objectstack-ai#17727 One clause added to `.claude/agents/os-dev.md`, on the push step, pointing at the pre-push card-trailer refusal that PR objectstack-ai#17745 adds. The file already carries the rule (「卡片关系只在 PR 正文声明一次:commit ⛔ 不带卡片 trailer,其 trailer pair 一律 model-free。」); what it did not carry is that a hook now refuses the push over it. The line, before and after: ``` - 用 `git push -u origin claude/issue-<n>-<slug>` 推上去,网络失败退避重试。 - 用 `git push -u origin claude/issue-<n>-<slug>` 推上去,网络失败退避重试;pre-push 拒卡片 trailer。 ``` **Line budget.** The file is at 403/403 with zero headroom (`pnpm check:pm-skill-ratchet`), and the ratchet's ceiling is maintainer-only. The clause therefore costs NO line: it rides the existing push line, which grows 88 → 115 bytes, under the file's 120-byte widest-line convention. Nothing was deleted to pay for it, and no line was re-wrapped to free one. After the edit: `.claude/agents/os-dev.md is 403 lines (ceiling 403; headroom 0)` — green. This is deliberately a separate PR from the code half: `.claude/**` is a governed surface, so this one waits for a human merge while objectstack-ai#17745 lands through the queue. `node scripts/pm/check-governed-merges.mjs --test .claude/agents/os-dev.md` → `⛔ GOVERNED — a human merge is the review record for this PR`. Gates run locally on this diff (derived with `node scripts/pm/dispatch-gates.mjs --commands`, 17 commands, all exit 0): `check:pm-skill-ratchet`, `check:pm-skill-id-lint`, `check:agent-model-declared`, `check:agent-test-spelling`, `check:doc-authoring`, `check:nul-bytes`, `check:partof-closing-keyword`, `check:pm-governed-merges`, `check:skill-frame-sync`, `check:watch-hint-literal`, `check:refd-timer-probe`, `check:driver-memory-census`, `check-closing-keyword-parity` (+ self-test), `check-comment-mask-corpus`, `check-governed-queue-guard --self-test`, and `check:doc-formula-expressions` (exit 3 PREREQUISITE NOT MET until `@objectstack/formula` and `@objectstack/lint` were built, then exit 0). ## 维护者速读(草稿) **改了什么** — `.claude/agents/os-dev.md` 的推送那一行加了半句:`pre-push 拒卡片 trailer`。仅此一处,不增行。 **为什么改** — 裁决 A(objectstack-ai#17606 决策批次 objectstack-ai#116)把 commit 卡片 trailer 的检查从 PR 时移到推送前。规则本来就写在这个文件里,但执行它的机制没有写:dev 读到「commit 不带卡片 trailer」,不知道推送会被钩子拦下,也就不知道此刻的修法是便宜的(改写未推送的 message),而不是推完之后无解的那种。这一句把规则和它的执行点连起来。 **风险与代价(含回滚)** — 风险极低:纯说明文字,不改任何行为;真正的机制在 objectstack-ai#17745。行数账本未动(403/403),未删任何既有内容,未靠折行腾行。回滚就是回退这一个 commit;若 objectstack-ai#17745 未落地而本 PR 先合,这半句会指向一个尚不存在的钩子 —— 因此建议在 objectstack-ai#17745 落地后再合本 PR。 **席位意见** — **你要做的** — 读这半句是否值得占那 27 字节;确认它指向的机制(objectstack-ai#17745)已落地或即将落地;人工合并本 PR(受管面,任何 AI 席位都不得合并、入队或武装自动合并)。 --- _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#17770) Part of objectstack-ai#17280 `AGENTS.md` declares the agent commit trailer pair model-free and, until this PR, nothing read it: the pre-push check that PR objectstack-ai#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.md` specifies the model-free pair. **`AGENTS.md` owns 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: - a co-author value at the anthropic address must read the bare declared name in front of the address; whatever else stands there is quoted back as the finding; - an id-form model name (the word claude, a hyphen, a model word) anywhere in that value — an address can carry a model as easily as a display name; - the session trailer's value, cheaply: a session URL, or a finding. ⛔ 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 - The **18 branch commits** and the **73-of-200 landed squash bodies** STAND. 「landed history is not rewritten」, and branches carrying open PRs are not rebased. This check judges only what the remote does not already have, so it never prescribes a rewrite. - The merge commits `scripts/pm/os-regen-merge.sh` writes with `git merge --no-edit` carry **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. - Ruling A on the squash message (`PR_TITLE` + `PR_BODY`) means branch trailers no longer reach `main`'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 on `main`), 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. - 15 cases on the finding side: each of the four measured display-name spellings, four id forms, a model word nobody has shipped, the qualified harness form, the other trailer-key capitalisation (git matches keys case-insensitively), a session value that is not a session URL, and the finding's line, quoted text and remedy. - 10 controls: the declared pair, a differently-cased bare name, a trailer-less merge commit, a human co-author, body prose naming a model, a subject naming a model, the three substring false positives PR objectstack-ai#17745 pinned, a pair outside a trailer block, and the dedup case (one line carrying both a relation and a model is ONE finding — one line to reword). **Live legs through the real hook in the worktree** (not a simulation of it): 1. `git push` of this branch's own commit — the hook printed the new clean line and the push landed. 2. An empty commit carrying a model-named co-author trailer — `git push` **refused** (exit 1), naming the commit, line 4, the quoted trailer and the remedy; then `git reset --hard` to the pushed tip. Nothing was published. **Ablation** — the finding really comes from the new scan, not from something else: | leg | scan | same dark commit, same live hook (`git push --dry-run`) | exit | |:--|:--|:--|--:| | 1 | present | refused, naming the trailer line | 1 | | 2 | ablated (wiring removed) | clean — "carry no card relation and no model identifier" | 0 | 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 HEAD` empty and `git status` clean. Restored under a `trap … EXIT INT TERM` with an absolute path. `--dry-run` runs 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 commit `131c937fa`** — including `check:self-test-wired`, `check:scripts-symbol-anchors`, `check:declaration-mirrors`, `check:nul-bytes` and `check:commit-card-trailers` itself. Exit codes were captured before any pipe (redirect first, then read `$?`). `check:pm-dispatch-gates` exceeds the foreground cap, so it was detached and waited on with `tail --pid`; its own verdict line reads `✓ dispatch-gates self-test: 1678 cases pass.` with zero `✗` lines in the log. The `--ran` reconciliation accounted for every derived family. `skip-changeset`: the diff is one repo-root script that no package's `files[]` ships — nothing published moves. ## Acceptance notes - Not filed, noted only: the `.githooks/pre-push` header 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](https://claude.ai/code/session_01MCLBsUgfykL74aU716rzVK)_ --- _Generated by [Claude Code](https://claude.ai/code)_ --------- 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 #17727
The card-trailer rule was enforced on the pull request, over its commit list. By the time it spoke, the cheap repair was gone: a new commit on top JOINS that list, and removing the message is the history rewrite this repository forbids — so two rounds, hours apart, each paid a full redo (a new branch, the diff re-applied, a new PR, the old one closed as superseded). This moves the refusal to the moment the repair is still free, closes the spelling gap that let the forbidden form through, and re-scopes the PR-time gate now that the squash message comes from the body.
Three parts
1. A pre-push refusal —
scripts/check-commit-card-trailers.mjs, called from.githooks/pre-push.It judges exactly the commits a push would publish: the local sha, minus the remote sha the push protocol hands it, minus every remote-tracking ref. Everything it judges is therefore UNPUBLISHED, which is what lets it prescribe an ordinary reword instead of a rewrite — the whole trade the move buys. It adds no grammar: the three relation extractors are the half-state sweep's, imported and read at
markdown: false(a commit message is not markdown, so backticks do not defuse a trailer). A fourth shape is new — a bare#Nin TRAILER position, where a declaration needs no keyword beside it; a reference in ordinary body prose is deliberately left alone..githooks/pre-pushkeeps the os-regen deferral refusal it carried, unchanged in behaviour. The ref lines git writes on stdin used to be drained and discarded; they are now read once and handed to the check whose whole input they are.2. The spelling gap —
partOfReandrefsReinscripts/pm/check-half-states.mjs.Measured before the change, on the commit surface:
Part of #NPart-of #NPart of: #NPart-of: #NRefs #NRefs: #NFixes: #NEvery dispatch order in this repo lists
Part-ofas a forbidden form, so a dev who greps for it before pushing gets 0 and trusts a net with no thread in that square. The separator is now the oneclosingKeywordRealready ships (an optional colon), and the word takes a hyphen where it takes a space — a strict superset, so nothing that matched before stops matching.Refs:is the same colon gap in the sibling relation and is the git-trailer spelling of that declaration; it is fixed in the same edit.Ref,ReferencesandParts ofstay out, as before.One pinned case flipped and was replaced rather than reworded: it asserted that
Refs: #Nwas not the spelling, which pinned the gap rather than a decision.3. The PR-time gate is re-scoped —
check:partof-closing-keyword.Its RULE 2 rested on one premise: the squash message is assembled from the COMMIT MESSAGES. That premise is gone —
squash_merge_commit_messageisPR_BODY(paired withPR_TITLE, the only combination GitHub accepts it in), so every squash, queue merges included, lands the body. RULE 2 and its commit-list input are removed; the gather step and thepull-requests: readscope it needed go with them. RULE 1 and RULE 3 are untouched, the numbering is left alone (the numbers are the order the rules were learned), and the header now says where RULE 2 went and what the move gives up: a hook is registered per clone, so a clone that never ranpnpm installpushes unchecked. That is the ruled trade; CI holds the self-test.Measured, live
Four legs on this branch, against the real hook (
core.hooksPath=.githooks):check:commit-card-trailers: 2 commit message(s) on this push carry no card relation.Part of #17727refused the push, exit 1, namingcommit 1c4b62ca7 ("chore: dark control leg one"),line 3.Part-of: #17727refused, namingcommit 1bb31a97e,line 3. This is the form that passed before this PR.Issue: #17727) refused, namingcommit d18d3e2d2,line 3, as a trailer declaration rather than as a relation.Each dark leg was an empty commit, removed with
git reset --hardto the pushed tip afterwards; the branch tip is unchanged by them.Self-tests
check:commit-card-trailers— 56 cases, 8 batteries with a pinned floor. Includes the three substring shapes that must stay GREEN, each measured against the hand grep this replaces: the stem in ordinary prose (a closed ten-member enum), in an identifier (findClosestMatches), and in an ordinary word (fixture) — a bare-stem grep flags all three, and this must not.check:partof-closing-keyword— 45 cases (from 82: RULE 2's batteries moved out), roster and floor updated in the same edit so the removal cannot read as batteries that stopped running.check:pm-half-states— 3616 cases (from 3606).Acceptance notes
pnpm installhaving run in that clone. Noted, not filed — the ruling chose this trade explicitly, and the alternative (a PR-time read of a commit list whose text no longer lands) is what this PR removes..claude/agents/os-dev.mdgains its one-line pointer in a separate PR: it is a governed surface with its own merge path.Generated by Claude Code