Skip to content

fix(ci): refuse card relations in commit messages at push, and re-scope the PR-time gate to the body - #17745

Merged
os-sales merged 4 commits into
mainfrom
claude/issue-17727-card-trailers-pre-push
Sep 12, 2026
Merged

os-sales merged 4 commits into
mainfrom
claude/issue-17727-card-trailers-pre-push

Conversation

@claude

@claude claude Bot commented Sep 12, 2026

Copy link
Copy Markdown
Contributor

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 #N in TRAILER position, where a declaration needs no keyword beside it; a reference in ordinary body prose is deliberately left alone.

.githooks/pre-push keeps 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 — partOfRe and refsRe in scripts/pm/check-half-states.mjs.
Measured before the change, on the commit surface:

spelling before after
Part of #N HIT HIT
Part-of #N MISS HIT
Part of: #N MISS HIT
Part-of: #N MISS HIT
Refs #N HIT HIT
Refs: #N MISS HIT
Fixes: #N HIT HIT

Every dispatch order in this repo lists Part-of as 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 one closingKeywordRe already 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, References and Parts of stay out, as before.

One pinned case flipped and was replaced rather than reworded: it asserted that Refs: #N was 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_message is PR_BODY (paired with PR_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 the pull-requests: read scope 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 ran pnpm install pushes 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):

  • LIT — the two real commits of this PR pushed clean: check:commit-card-trailers: 2 commit message(s) on this push carry no card relation.
  • DARK — a commit carrying Part of #17727 refused the push, exit 1, naming commit 1c4b62ca7 ("chore: dark control leg one"), line 3.
  • DARK — the colon spelling Part-of: #17727 refused, naming commit 1bb31a97e, line 3. This is the form that passed before this PR.
  • DARK — a bare reference in trailer position (Issue: #17727) refused, naming commit d18d3e2d2, line 3, as a trailer declaration rather than as a relation.

Each dark leg was an empty commit, removed with git reset --hard to 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

  • The residual this PR does not close: a hook is per-clone and reports to nobody, so enforcement depends on pnpm install having 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.md gains its one-line pointer in a separate PR: it is a governed surface with its own merge path.

Generated by Claude Code

…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>
@claude claude Bot added the skip-changeset PR has no user-facing published change; bypasses the changeset gate label Sep 12, 2026
@github-actions github-actions Bot added size/xl ci/cd dependencies Pull requests that update a dependency file labels Sep 12, 2026
`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>
@os-sales
os-sales marked this pull request as ready for review September 12, 2026 02:39
@os-sales
os-sales added this pull request to the merge queue Sep 12, 2026
Merged via the queue into main with commit 5d66e38 Sep 12, 2026
37 checks passed
@os-sales
os-sales deleted the claude/issue-17727-card-trailers-pre-push branch September 12, 2026 03:10
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>
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

ci/cd dependencies Pull requests that update a dependency file size/xl skip-changeset PR has no user-facing published change; bypasses the changeset gate

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants