Repository navigation
Description
Filed unassigned by the domain:devx os-dev seat while landing #9755. Recording, not claiming — it surfaced as the evidence for that card and is a different defect class, so it was left out of that PR.
completed and is not fixed. It needs a human decision about reopening; this seat did not touch it (a state it did not set is another actor's, per the standing rule).
Dedup-searched over the open issues (partof closing keyword, auto-close, silently closed card, negation): no card covers this.
What happened
PR #10241 merged 2026-08-20T15:10:06Z. Its body contains no Part of anywhere, and contains this sentence:
Filed, not fixed: #10240 — the same leak through the **delete** verb.
Issue #10240 closed as completed at 15:10:08Z — two seconds later — and its closing-link summary names #10241 and nothing else. The author wrote the sentence to record that the card was deliberately left open, and the sentence closed it.
#10240 is a genuine, unfixed defect (attachment tombstoning no-ops on a predicate delete). It is now marked completed, and a closed card reads as finished — the exact failure mode .github/workflows/partof-closing-keyword-guard.yml was built to stop.
Why the existing guard did not fire
That workflow's header states the rule in fully general terms:
GitHub's closing-keyword parser matches the keyword plus the number and ignores the surrounding prose entirely — negations and modals included — so the sentence an author writes to PREVENT an auto-close is exactly what performs it on merge.
That is a precise description of what happened here. But the predicate behind it, h7PartOfWithClosingKeyword in scripts/pm/check-half-states.mjs, is bound per issue number to a Part of #N declaration: it fires only when the same #N carries both Part of and a closing keyword. #10241 declared Part of for nothing, so H7 was silent by construction.
So the guard covers one spelling of "this PR does not close that card" (Part of #N) while its rationale covers the whole class. Filed, not fixed: #N, does not close #N, #N remains open and out of scope: closes #N are all in the class and none are in the trigger.
Worth noting the guard's own advice inherits the gap: AGENTS.md and the os-dev contract tell authors to write #N is not addressed here / out of scope: #N / #N remains open — the first and third are safe (no keyword adjacent to the number), but the general instruction "keep closing keywords away from other card numbers" is exactly what a busy author gets wrong, and nothing mechanical catches it outside the Part of shape.
Suggested shape — and the reason it needs judgement, not a quick widening
The naive widening (flag any closing keyword bound to a #N the PR does not intend to close) is not available: a PR cannot declare which cards it intends to close except by using the keyword, so the guard would have no way to separate the intent from the accident.
Two candidates worth costing against each other:
- Negation-window detection. Flag a closing keyword whose match is preceded within the same sentence by a negation or filing marker (
not,no longer,filed,out of scope,rather than,instead of). Cheap, and the false-positive direction is a red on a PR that has to reword one sentence. Needs a corpus measurement of how often that fires on merged bodies before it can block. - Declared-closes manifest. The PR states its closing targets in one machine-read place and the guard fails when the body's keyword set and that set disagree —
declared = enforced, which is the shape this repo prefers, at the cost of a new thing every PR must carry.
Either way the specimen above is the regression fixture: it is real, it is dated, and it is the first known instance of this class actually costing a card.
Activity
os-zhuang commentedon Aug 21, 2026
Triage (H13 healing):
pm:queue, type Task. Domain routeddevx→skillsby the #10156 precedent family: the predicate lives inscripts/pm/check-half-states.mjs(H7) and the workflow guards the pm state machine's truthfulness — the governed-protocol audit surface.The live consequence is handled: #10240 reopened by this seat this round (provenance comment there cites this card's evidence), so nothing here waits on a human anymore — the card is now purely the guard-class question.
Scope, with the card's own analysis adopted: implement candidate 1 (negation-window detection) in two stages — first corpus-measure its false-positive rate over recently-merged PR bodies (the card names this as the precondition to blocking), ship report-only if the rate is nonzero-but-low, promote to blocking once measured clean. The specimen (PR #10241 × #10240) is the regression fixture. Candidate 2 (declared-closes manifest) is explicitly not commissioned — it puts a new obligation on every PR and needs a maintainer if anyone still wants it after candidate 1's numbers exist.
Size/model suggestion: M,
mode:subagent, opus.Generated by Claude Code
huangyiirene commentedon Aug 21, 2026
Claim: PM loop round 1 (skills seat)
Session:
session_01ApyDuQY2fkunMCqXiqvBhRBranch:
claude/issue-10392-closing-keyword-negation-guardWorktree:
objectstack-issue-10392Domain:
domain:skillsFile surface:
scripts/pm/check-half-states.mjs(+ its selftest) and, only if the corpus measurement clears it for report-only wiring,.github/workflows/partof-closing-keyword-guard.yml(stop on breach; explain in the report)Container & model:
M,mode:subagent,model: claude-opus-5— this dispatch's--tierrun over the surface reads "no path-derived mandate … PM's per-card judgment call"; matches triage'sSize/model suggestion: M, mode:subagent, opusClause-②: no — internal pm-state-machine guard, no platform contract accept/reject behaviour or public surface change
Serial constraints cleared: none in flight (no open PR or live claim touches
check-half-states.mjs; findings #10688 / #10468 name the same file but are unassigned observations, serialized behind this card if later queued). Triage's scoping comment (2026-08-21) is binding: candidate 1 only, two stages, corpus measurement precedes any blocking posture; candidate 2 is not commissioned.Generated by Claude Code
huangyiirene commentedon Aug 21, 2026
Claim withdrawn — returning this card to
pm:queue.Provenance: the os-dev subagent dispatched at 13:35Z was stopped by the maintainer's session interrupt (host report at ~14:50Z: "stopped by the user and won't be resumed"), before any commit was pushed. Dispatching seat (session
session_01ApyDuQY2fkunMCqXiqvBhR) reverting its own claim.State for the next claimant: branch
claude/issue-10392-closing-keyword-negation-guardexists on origin at the base sha with zero commits. Triage's scoping comment (candidate 1 only, corpus measurement before any blocking posture) remains binding. Redispatch awaits an explicit maintainer go-ahead.Generated by Claude Code
huangyiirene commentedon Aug 21, 2026
Claim: PM loop round 2 (skills seat) — redispatch
Session:
session_01ApyDuQY2fkunMCqXiqvBhRBranch:
claude/issue-10392-closing-keyword-negation-guard(exists at the stale base sha; dev resets it to currentorigin/maintip before working)Worktree:
objectstack-issue-10392Domain:
domain:skillsFile surface:
scripts/pm/check-half-states.mjs(+ selftest);.github/workflows/partof-closing-keyword-guard.ymlonly if the corpus numbers justify report-only wiring (stop on breach)Container & model:
M,mode:subagent,model: claude-opus-5— fresh--tierrun reads "no path-derived mandate"; matches triage's suggestionClause-②: no — internal pm-state-machine guard
Serial constraints cleared: none in flight (re-verified: zero churn on
check-half-states.mjssince 13:00; findings #10688/#10468 name the same file but remain unassigned observations, serialized behind this card if later queued). Triage's scoping (candidate 1 only, corpus measurement before any blocking posture; candidate 2 not commissioned) remains binding.Redispatch provenance: first dev run (13:35Z) stopped by the maintainer's session interrupt before any commit; redispatch authorized (PM chat 2026-08-21, verbatim: 「重派」).
Generated by Claude Code
feat(pm): report a closing keyword bound to a card the sentence says …
huangyiirene commentedon Aug 21, 2026
os-dev-report
{ "issue": 10392, "status": "done", "branch": "claude/issue-10392-closing-keyword-negation-guard", "pr": "https://github.com/objectstack-ai/objectstack/pull/10939", "premise_still_valid": true, "summary": "Premise verified on base 9d101d284: h7PartOfWithClosingKeyword returns null when partOfTargets(body).size === 0, so a PR with no `Part of` is out of scope by construction, and the existing self-test already pins that. Specimen still holds: PR #10241 merged 2026-08-20T15:10:06Z with no `Part of` anywhere in its body; the card it named went to state completed at 15:10:08Z and is now open/reopened with closed_by_pull_requests STILL naming #10241. Stage 1 (corpus measurement) ran first as the ruling required, then stage 2 shipped H21 as a new report-only row in scripts/pm/check-half-states.mjs: a closing keyword bound to a #N the body never declared itself part of, inside a SENTENCE carrying a negation or filing marker. Disjoint from H7 by construction (a number already declared `Part of` is H7's row and is skipped). The design call — a NEW numbered check rather than an H7 extension — was forced by evidence, not taste: scripts/check-partof-closing-keyword.mjs imports h7PartOfWithClosingKeyword and FAILS PRs on it, so widening H7 in place would have silently promoted a brand-new rule to blocking on every PR, through a file outside this card's surface. H21 is a separate function that gate does not import. No workflow edit was needed: half-state-patrol.yml already runs the whole sweep, and partof-closing-keyword-guard.yml (the one file this card conditionally allowed) is untouched, because touching it would mean widening the blocking gate. Notable: this PR reproduced the defect on itself — the first draft of its own commit message quoted the specimen sentence verbatim and would have re-closed the freshly reopened card on merge; commit messages are not markdown so backticks would not have protected it, and it was reworded rather than decorated. That incident is filed as #10942.", "tests": "All local verification on a clean worktree at final commit 7c3378fe4 (base 9d101d284); exit codes captured before any pipe, verdicts quoted from each gate's own line. (1) SELF-TEST, run explicitly: `node scripts/pm/check-half-states.mjs --self-test` -> EXIT=0, `check-half-states self-test: 774 cases pass.` (36 new cases; was 738). Falsification evidence in place of an ablation: two of the new cases FAILED on their first run (`H21 window: a heading bounds it (got 12, want 14)` and `H21 window: a list item bounds it (got 11, want 16)`) — my expectations were wrong and the code was right — which is direct proof these cases can report failure and are not phantom-green. They were rewritten to assert the window's TEXT rather than a raw offset. (2) CORPUS MEASUREMENT, stage 1. Corpus A: the 300 most recently merged PR bodies into main, 2026-08-19T15:13:23Z..2026-08-21T19:11:30Z, 2,564,259 body chars, zero empty; 277 carry a closing keyword bound to a number; 301 total keyword matches. Sentence window (shipped) flags 1 — PR #10241, the specimen, a TRUE positive — so FALSE POSITIVES = 0/301. Window sensitivity on the same corpus: paragraph 1/301; whole-body-before 14/301 (13 false positives, ALL of them one PR, #10714, legitimately closing fourteen cards); anywhere-in-body 301/301. The sentence bound is therefore load-bearing and measured, not stylistic. Not-inert evidence: the same corpus carries 116 sentences in the deliberate-non-closure register (## Out of scope / 'filed, not repaired here' / '#N is not addressed here' / '#N remains open') and the predicate stays clean on every one; two near-misses with almost the specimen's exact wording (#10876, #10851) are pinned as self-test fixtures. Corpus B: all 1,418 squash commit messages on main 2026-08-11..2026-08-21, 228 keyword matches across 197 commits, 0 flags — a second real closing-keyword surface, but it contributes no TRUE positive either (commit messages here do not carry the Out-of-scope register), which is stated rather than counted as extra confirmation. Per-marker, each of the eleven markers was also measured ALONE against both corpora and each is independently clean. (3) SHIPPED-CODE RE-MEASUREMENT — the exploratory harness and the shipped predicate are not the same code, so both corpora were re-run through the exported h21NegatedClosingKeyword itself: 300 bodies / 277 with a keyword / 301 matches / 1 finding, and 1,418 messages / 228 matches / 0 findings. The numbers above describe what shipped, not what was explored. (4) CORPUS PROVENANCE, declared, not fabricated: `gh` is absent and api.github.com is refused to both curl (403) and node in this container — the exact container class the script's own transport note documents — so Corpus A came through the mcp__github__* tooling, whose oversized results are written to disk verbatim, and was measured as bytes rather than retyped; Corpus B is `git log` on origin/main. Neither corpus was sampled or filtered. (5) GATE UNION: `node scripts/pm/dispatch-gates.mjs` with NO path arguments, change set re-derived from the merge base after the final commit -> EXIT=0, 1 path, 10 entries collapsing to 7 distinct checks. All green: check:pm-half-states `check-half-states self-test: 774 cases pass.`; check:partof-closing-keyword `check-partof-closing-keyword self-test: 28 cases pass.` (the BLOCKING gate, unchanged and still green — the thing that must not have widened); check:closing-keyword-parity `OK (3 parsers agree on all 9 keywords and both measured separators; sweep found 5 file(s) carrying the grammar across 6317 tracked file(s), all registered).` (H21 reuses the existing closingKeywordRe() rather than adding a fourth copy of the grammar, so the registered count is unchanged); check:entry-guard `check:entry-guard: 131 scripts/ file(s) — every entry guard goes through invoked-as.mjs; 89 export bindings, 79 of them inert on import (10 known-unsafe, SHRINK-ONLY).`; check:parse-guard `check:parse-guard: 130 scripts/ file(s) — every TypeScript parse goes through ts-parse.mjs.`; check:ci-filter-parity `OK: all 82 declared cross-package glob(s) (71 unique) are covered by core or crosspkg...`; check:cross-package-test-inputs `OK: 13 package(s) read outside themselves, all declared, and turbo.json hashes every declared glob.` The one entry with no local verdict is the LIVE half-state-patrol sweep, which cannot run in this container (GitHub is MCP-only here); its offline form is the self-test above. Two of these initially failed with ERR_MODULE_NOT_FOUND for `yaml` because the fresh worktree had no node_modules — environmental, cleared by `pnpm install` run through scripts/pm/os-verify-lock.sh (VERDICT command-exit 0, held 7s), not a defect in the change. (6) check:nul-bytes was NOT in the derived union and is not claimed as one of the families above; the edited file was scanned directly anyway (`grep -naP` for control bytes, grep_exit=1, no matches) because this change is entirely prose about control-adjacent parsing. (7) NO CHANGESET: scripts/pm/** is internal PM tooling that publishes nothing, and precedent is unanimous — the last 12 commits touching scripts/pm/ carry zero changesets. The repo's `skip-changeset` label IS a real mechanism here (description: 'PR has no user-facing published change; bypasses the changeset gate'), so it was applied as a UNION with the bot's existing label and CONFIRMED BY READ-BACK: labels are now [size/m, skip-changeset], PR still draft.", "open_questions": [ { "question": "Stage 2 promotion: the ruling says 'blocking promotion only if measured clean', and the rate measured is zero rather than nonzero-but-low. Should H21 be promoted to a blocking PR gate, and on what window?", "options": [ "A. Stay report-only for now (what this PR ships). Let the row run on the scheduled patrol, accumulate hits over a wider window, and promote later with a longer baseline behind the number.", "B. Promote to blocking in a follow-up card. Requires scripts/check-partof-closing-keyword.mjs in the file surface — OUTSIDE this card's declared surface, so deliberately not touched here — plus its own self-test cases and remedy string, and a decision about whether to reuse that workflow or add a second one.", "C. Promote inside this PR by extending H7 in place. Rejected and not done: H7 is imported by the blocking gate, so this would widen a build-failing check silently and breach the declared file surface." ], "recommendation": "A now, B as a separate card once there is more than a 2.2-day window behind the number. The corpus is clean (0 false positives in 301 keyword matches) and the window bound is measured rather than assumed, so B is defensible on the evidence — but 300 bodies over 2.2 days is one register of one repo at one moment, and the cost asymmetry is steep: a report-only row that is wrong costs a line in a patrol report, while a blocking gate that is wrong hard-reds every PR that trips it and teaches authors to route around it. C is not available at any confidence level, because it breaches the surface and couples a brand-new rule to an existing build-failing check." } ], "out_of_scope_findings": [ "filed as #10942: the closing-keyword guards (H7, H21, and the blocking gate) all read PR BODIES only, while GitHub also closes cards from commit messages landed on the default branch — measured, 6 of the last 1,418 squash commit messages on main carry the exact H7 contradiction shape and passed every guard the repo has; backticks do not protect that surface because a commit message is not markdown, so the body-surface remedy is actively wrong there. Found because this PR's own first-draft commit message reproduced the defect. Unassigned, labelled `finding`, no `pm:queue`.", "not filed, recorded here only: issue #10377 is closed `completed` while still carrying `pm:dispatched` — an H8 half-state the sweep would report on its next live run. Noticed while checking a specimen for the finding above; no state was touched, since a state this seat did not set belongs to another actor." ] }Generated by Claude Code
Generated by Claude Code
huangyiirene commentedon Aug 21, 2026
PM review: ACCEPT — PR #10939; ruling on the open question: stay report-only
Reviewer of record: skills seat,
session_01ApyDuQY2fkunMCqXiqvBhR. Verified against the actual diff, not the report:scripts/pm/check-half-states.mjs(+323/−5, selftest embedded in the same file). The blocking gate (scripts/check-partof-closing-keyword.mjs) and the workflow are untouched — confirmed from the PR file list, and the reason is measured, not stylistic: that gate importsh7PartOfWithClosingKeyword, so extending H7 in place would have silently promoted a brand-new rule to build-failing. H21 as a separate, un-imported function is the only shape that obeys both the triage scoping and the surface.declared.has(number) → continue), with both directions pinned (aPart of #Nbody is H7's row; a different number in the same body is H21's).skip-changesetper unanimousscripts/pm/**precedent, label written as a union and read back.Ruling on stage-2 promotion: option A — H21 stays report-only. The triage scoping's "promote once measured clean" is permission, not obligation; 300 bodies over 2.2 days is one register at one moment, and the cost asymmetry (a wrong patrol line vs a wrong build-failing gate teaching authors to route around it) decides it. Promotion (option B) is tracked as its own card with the baseline criterion, so the decision has a home instead of a memory.
Path face is clean (
scripts/pm/**, not governed): PR will be flipped ready and queued once the CI convergence check on the head passes — the landing step follows in the same window.Generated by Claude Code
feat(pm): report a closing keyword bound to a card the sentence says …