Skip to content

fix(pm): H9 classifies the Restart-when: value instead of testing its spelling - #17603

Merged
os-litant merged 1 commit into
mainfrom
claude/issue-17377-h9-restart-when-classes
Sep 11, 2026
Merged

fix(pm): H9 classifies the Restart-when: value instead of testing its spelling#17603
os-litant merged 1 commit into
mainfrom
claude/issue-17377-h9-restart-when-classes

Conversation

@os-litant

@os-litant os-litant commented Sep 11, 2026

Copy link
Copy Markdown
Collaborator

Fixes #17377

H9's fireability test was a spelling test — directiveValues(text, 'Restart-when').some((v) => !/^manual\b/i.test(v)) — so every value that is not the single word manual read as fireable. This classifies the value instead, and REPORTS the two unfireable shapes the card measured live. Report-only throughout: same H9 code, same state band, no new exit code, no refusal, no state write, and a completed sweep still exits 0 whatever it finds.

Order per the dispatch: B first (a measurement, no ruling), then A in its report-only form. C refused by the seat, not implemented or argued here.


B — is H17's trigger-file index rendered, and does it carry #7898's row?

Read from the patrol's own landed output, the anchor issue #9857's body (half-state-patrol.yml rewrites it; body read 2026-09-11, updated_at 2026-09-11T01:55:42Z). One REST read.

The index IS rendered. The heading is present verbatim at body line 106:

### On-hold trigger-file index (H17)

and its intro line states its own coverage:

Before dispatching, intersect your dispatch's file surface against this list and NAME any card it hits in the dispatch brief. […] (read on 104 of 104 open pm:on-hold card(s); 8356 tracked file(s) in the oracle.)

It carries 26 rows (#2657, #6009, #6736, #7401, #8345, #8346, #8347, #8740, #8897, #8966, #9139, #10315, #10572, #10694, #11331, #12576, #12789, #12799, #13528, #13542, #14121, #14169, #14290, #14490, #16173, #16222). The sweep's summary line in the same body states the same two coverage pairs the code owes: Hold comments read on 104 of 104 H17 candidate(s) and `Restart-when:` hold comments read on 54 of 54 H9 candidate(s).

#7898 has no row, and the card's premise for why is falsified — twice over.

  1. It is no longer in the population. One REST read of [Decision] Fail-close isAuthGateAllowlisted's "no path ⇒ exempt" default (Option 2 of #7432) #7898: its labels today are needs-user-decision, domain:cli. h17NeedsComments(issue) admits pm:on-hold only, so [Decision] Fail-close isAuthGateAllowlisted's "no path ⇒ exempt" default (Option 2 of #7432) #7898 is out of H17's population entirely — the filer's own audit moved it, as the card says. An absent row here is not rendered because not a member, not rendered wrong.

  2. It would have contributed no row even while it WAS a member. The card states "the substring restart condition is present, so the line anchors, and its two tracked paths extract. [Decision] Fail-close isAuthGateAllowlisted's "no path ⇒ exempt" default (Option 2 of #7432) #7898 has been a row in the trigger-file index." The first half is right; the second is not. H17's prose-anchor stage harvests backtickedSpans(lines[i])backticked spans only — and [Decision] Fail-close isAuthGateAllowlisted's "no path ⇒ exempt" default (Option 2 of #7432) #7898's Restart-when: line carries zero backticks. Run against the real body on this head:

    h17TriggerFileCandidates(#7898 body) = []
    h17TriggerFiles([body], isTracked)   = []
    hasFireableRestartWhen(#7898 body)   = true
    h17NeedsComments(#7898 as-is)        = false
    

    And auth-gate appears nowhere in the rendered index. That is by design and documented: backtickedSpans' own header says a bare prose token "would mean deciding whether rest-server.ts in a sentence is a trigger or a mention, which is the LLM-grade judgement this item refuses to make. A hold that names its trigger without backticks contributes no row and is invisible here — a stated boundary […] rather than a reason to widen the parser."

Finding 2's sharper half does not hold as filed. The 0-of-11 rider count is real, but the cause is not an index that renders and is unread — it is a hold whose trigger clause was never in the index at all, because it was written in bare prose. That is exactly the gap this PR's tracked-path class reports on the H9 side, and it is the argument for Restart-touch: rather than for widening H17.

Live sweep: NOT RUN. node scripts/pm/check-half-states.mjs --probe was run locally (exit 0): ✓ check-half-states: transport prerequisite met — api.github.com is reachable and the token authenticates. The probe reports no core-requests-remaining figure, so the dispatch's stated precondition ("the probe reports live mode can run here with >= 3000 core requests remaining") is not met by the probe's own output. It is also unnecessary: #7898 falls in the dispatch's "no longer in the population" branch, whose prescribed route is the renderer's code path plus the anchor body's section as it stands — which is what is quoted above. Read budget on the shared identity is preserved accordingly.


The census, and what the classes cover

GET /repos/objectstack-ai/objectstack/issues?labels=pm:on-hold&state=open&per_page=100, two pages (100 + 4). 104 open holds — independently equal to the anchor's own 104 of 104. Bodies only, no per-card comment fetch. Every Restart-when: value read through the same directiveValues the sweep uses.

reading count
open pm:on-hold cards 104
…carrying >= 1 Restart-when: body value 50
…carrying none (already H9 candidates today) 54
total values classified 50

Every value lands in exactly one class — no residue:

class fireable? n cards
closed-ref yes 4 #11182, #8360, #8213, #3257
issue-ref yes 15 #14500, #13718, #13515, #10757, #10164, #9882, #8753, #8607, #8589, #7443, #7401, #7219, #6009, #3267, #3166
command yes 8 #17446, #17348, #17301, #17286, #12799, #11331, #9613, #9500
tracked-path no — new row 7 #8966, #8740, #8133, #8113, #7881, #7877, #6736
prose no — new row 16 #14251, #13559, #11509, #9591, #8897, #8285, #8276, #8241, #7880, #7571, #7497, #5930, #5180, #4676, #4426, #4220
manual no (unchanged) 0 none in the body channel today

The seven tracked-path values and the exact paths the classifier validates against git ls-files:

card tracked path(s) named under Restart-when:
#8966 content/docs/meta.json
#8740 packages/drivers/driver-sql/src/sql-driver.ts
#8133 packages/spec/package.json
#8113 scripts/check-type-check-coverage.mjs
#7881 packages/plugins/plugin-auth/src/objectql-adapter.ts
#7877 packages/metadata-protocol/src/protocol.ts
#6736 packages/plugins/plugin-sharing/src/sharing-plugin.ts

The bare command heads are derived, never invented. git (#7443 bare; #17446/#17348/#17301/#17286/#11331 backticked), pnpm (#12799), npx (#9613) — all from this census — plus npm, from this file's own pinned live specimen (npm view create-objectstack dist-tags reports >= 17.0.0). A value opening with a backticked span needs no head at all.

The price of the ruled order, measured. issue-ref is tested before the two unfireable classes, because H9's header advertises Restart-when: #N rules on X as fireable and that ruling is not this card's to reopen. On this census that costs four false-cleans — values naming a tracked repo path that read clean on an unrelated issue mention: #8753 (packages/objectql/src/registry.ts, mentions #7865), #8607 (scripts/check-adr-0087-registration.mjs, mentions #8299), #8589 (packages/runtime/src/domains/mcp.ts, mentions #7823), #6009 (packages/drivers/driver-sql/src/sql-driver.ts, mentions cloud#1005). Recorded in the header note rather than worked around: ranking tracked-path above issue-ref would make the advertised ruling spelling fire, which is the question the header reserves.

One more shape the order deliberately leaves clean and worth a reader's eye: #7401, whose value says in its own words "⚠️ This is a maintainer act with no closing event — no card closing will fire it, and no scan can detect it", yet reads issue-ref on a #5493 context mention. The card describes itself as unfireable and the classifier still clears it. Same ruling, same reason, recorded not patched.


Read-cost delta — zero additional requests

needsRestartWhenComments gates the comment fetch on the body not already answering, so the 23 cards whose only values are now unfireable join the fetch set: the H9 candidate counter moves 54 → 77 (54 with no value at all, plus 23 newly unfireable).

Those fetches cost nothing new on the wire. H17 already buys a comment page for every open pm:on-hold card (h17NeedsComments = the pm:on-hold label, 104 of 104 in the landed report), and H9's fallback rides the same commentCache. The file says so itself at the gather site:

The Restart-when: comment fallback (#10403) — the same pattern, gated by needsRestartWhenComments and riding the same shared comment cache, so an on-hold card H17 fetches below costs no second request here.

⇒ the union of comment fetches per sweep is unchanged at one page per open hold. What moves is which gate pays first and the read X of Y pair the summary line prints.

New rows: at most 23, and the body census cannot say the exact number. A card here still clears if a comment carries a fireable line — which is how 39 of today's 54 candidates clear (the landed report renders 15 H9 rows). Measuring the residue exactly would need the per-card comment fetch this census deliberately did not make.


isTracked — one oracle, no second tracker

The live sweep's predicate is built from readTrackedFiles() (a local git ls-files -z, not a request). Source line, before this PR:

const tracked = readTrackedFiles();
const triggerIndex = {
  rows: h17IndexRows(hold.entries, (path) => (tracked ? tracked.has(path) : false)),

It was read after sweepInto, and H9 runs inside it. So the read moved ahead of the sweep and the identical predicate is threaded down as sweepInto's last parameter into H9's call site; h17IndexRows then consumes the same isTracked. No fs.existsSync, no second tracker, one reading per sweep. In --self-test a stub set is passed instead.

Note for the record: the dispatch located this at "the H17 call site near :17289". That site (h17NeedsComments + the comment gather) takes no oracle; the predicate is built at the index assembly in sweep(). The threading is as specified, from where it actually lives.


Before / after

// before
export function hasFireableRestartWhen(text) {
  return directiveValues(text, 'Restart-when').some((v) => !/^manual\b/i.test(v));
}

// after
export function hasFireableRestartWhen(text, isTracked = () => false) {
  return directiveValues(text, 'Restart-when').some((v) =>
    FIREABLE_RESTART_WHEN_CLASSES.includes(classifyRestartWhen(v, isTracked)),
  );
}

classifyRestartWhen(value, isTracked) returns one of six, and the ORDER is the contract: manual (the existing regex, first, so the one-word opt-out can never be rescued) → closed-ref (the unlock sweep's own literal form) → issue-ref (any issue reference the value carries — the header's ruling spelling) → command (a backticked opening span, or one of the four measured bare heads) → tracked-path (some token the oracle validates) → prose.

isTracked defaults to recognising nothing, so every existing caller keeps its verdict: a tracked-path value collapses into prosesame row, same verdict, a less specific remedy sentence.

The two new row sentences

  • tracked-path — ``its Restart-when: names tracked repo path(s) THE-LIST — a file trigger, which is `Restart-touch:`'s key; the unlock sweep fires only the `closed OWNER/REPO#N` form. Rewrite the line as `Restart-touch: PATH` (one path per line) and give `Restart-when:` a real exit or `manual```
  • proseits `Restart-when:` is prose naming no issue, no tracked path and no runnable command — a `manual` in disguise; nothing schedules the actor it waits on. Mark it `manual` or name the event

The existing trailing remedy text (verify → unwrap → add → close last) and the whole two-channel clause are byte-identical.

The three specimens, pinned as literal strings, both directions

specimen value class verdict
#7898 any PR touches packages/core/src/security/auth-gate.ts, packages/runtime/src/http-dispatcher.ts or adds an adapter under packages/adapters/** (a second transport adapter is restart condition 1 by definition) tracked-path (with the oracle) / prose (without) row, naming both paths
#3739 the triage seat performs the contract-first split (parent + per-repo sub-issues) prose row, naming the missing event
the reserved class closed objectstack-ai/objectstack#5499 closed-ref clean

Why H9's path test is wider than H17's

H17 demands backticks and says why: a wrong row there sends a seat to intersect its dispatch against a file nobody nominated. H9 tokenises the whole value on whitespace and commas and validates every token against the same oracle, because the error costs are not the same shape — tracked-path and prose are both unfireable and both fire the row, so a token read too eagerly changes which remedy sentence prints, never whether the card is reported. That licence is written into the header note and it expires the moment any class of this classifier is allowed to CLEAR a card.


What this PR deliberately does NOT do

  • The reserved class stays reserved. An unreachable well-formed closed OWNER/REPO#N target classifies on its spelling alone, whatever the target's reachability. H9's reserved-class paragraph is byte-identical in this diff.
  • ⛔ No H17 code change. H17_TRIGGER_ANCHOR_TERMS, h17TriggerFileCandidates and the lower.includes(term) anchor were read for finding 2 and not edited.
  • ⛔ No band change (H9: 'state'), no new exit code, no refusal, no label or state write, no other row.

The one judgement, on the four axes

The judgement is: classify and REPORT the two unfireable shapes, leave the reserved class out, and accept the read-cost delta. 实际业务需求 — measured, not speculative: 23 of 104 live holds are priced as machine-unlockable and are not, and the specimen that motivated the card (#7898) sat 0-for-11 on its named trigger files; the delta is 0 additional requests because H17 already buys every one of those pages, so the cost side is real-measured too, not assumed. 项目长远合理性 — it removes a workaround rather than adding one: the classifier is contract-first (the value must be one of three fireable shapes), and the tracked-path row points the author at the channel that already exists (Restart-touch:) instead of teaching the unlock sweep a second key. 防 AI 写错元数据 — this is the axis that decides it: Restart-when: is metadata written by one seat and consumed by another seat's machinery, and the old predicate was pure consumer-side tolerance — anything not manual was accepted, which is exactly the shape that lets a whole population of unfireable holds accumulate unnoticed; a loud, specific row at the point of authorship is the tightening, and REPORT-ONLY is as far as it can go this card, because refusal on a shared predicate is the ruling H9's header reserves. 创业阶段不扩散需求 — the surface added is one pure function plus one exported constant list derived strictly from a measured census, with no new code, no new band and no new state; nothing is staged or dual-spelled, and the head list grows only by measurement. The axes do not conflict here; the one cost they do surface — four false-cleans from ranking issue-ref first — is reported above rather than patched, because reordering it would reopen a ruled question.


Verification

Local = targeted gates; the farm is CI.

gate exit verdict line
node scripts/pm/check-half-states.mjs --self-test (before, on origin/main 82cb69f) 0 ✓ check-half-states self-test: 3559 cases pass.
node scripts/pm/check-half-states.mjs --self-test (after) 0 ✓ check-half-states self-test: 3606 cases pass.
node scripts/pm/check-governed-merges.mjs --branch claude/issue-17377-h9-restart-when-classes 0 ✅ NOT governed — ordinary queue landing applies to a PR with exactly this file list. (0 of 1 path(s) hit the register)
node scripts/pm/dispatch-gates.mjs --commands 0 39 command(s) derived from the tree at this head vs merge base 82cb69f; changeset = scripts/pm/check-half-states.mjs

The 39 derived commands: 39 run, 39 exit 0. Reconciled with the tool itself —

$ node scripts/pm/dispatch-gates.mjs --ran RECORD-FILE   # each line: COMMAND :: exit CODE
Run reconciliation — 39 derived, 39 run, 0 NOT-MEASURED, 0 UNRUN.
  EXIT CODES — all 39 accounted famil(ies) carry one, so the NOT-MEASURED count above is DERIVED from them.
✓ dispatch-gates --ran: 39 derived famil(ies) accounted for — 39 run, 0 NOT-MEASURED
  (a DERIVED zero — all 39 recorded an exit code and none of them is 3).

All 39, with exit codes (captured before any pipe):

exit 0 :: node packages/lint/scripts/check-reference-carrier-shape.mjs
exit 0 :: node packages/lint/scripts/check-reference-carrier-shape.mjs --self-test
exit 0 :: node scripts/check-changeset-no-major.mjs --base origin/main
exit 0 :: node scripts/check-changeset-no-major.mjs --self-test
exit 0 :: node scripts/check-ci-filter-parity.mjs
exit 0 :: node scripts/check-closing-keyword-parity.mjs
exit 0 :: node scripts/check-closing-keyword-parity.mjs --self-test
exit 0 :: node scripts/check-comment-mask-corpus.mjs
exit 0 :: node scripts/check-declaration-mirrors.mjs
exit 0 :: node scripts/check-declaration-mirrors.mjs --self-test
exit 0 :: node scripts/check-scripts-symbol-anchors.mjs
exit 0 :: node scripts/check-scripts-symbol-anchors.mjs --self-test
exit 0 :: node scripts/check-self-test-wired.mjs
exit 0 :: node scripts/check-self-test-wired.mjs --self-test
exit 0 :: node scripts/check-self-test-workflow-commands.mjs
exit 0 :: node scripts/check-self-test-workflow-commands.mjs --self-test
exit 0 :: node scripts/check-whole-set-label-write.mjs
exit 0 :: node scripts/check-whole-set-label-write.mjs --self-test
exit 0 :: node scripts/pm/bare-root-worklist.mjs --self-test
exit 0 :: node scripts/pm/board-snapshot.mjs --self-test
exit 0 :: node scripts/pm/check-governed-queue-guard.mjs --self-test
exit 0 :: node scripts/pm/sweep-closed-cards.mjs --self-test
exit 0 :: node scripts/report-test-timings.mjs --self-test
exit 0 :: pnpm check:agent-test-spelling
exit 0 :: pnpm check:bash32-floor
exit 0 :: pnpm check:changeset-gate-self-tests
exit 0 :: pnpm check:cli-command-ids
exit 0 :: pnpm check:cross-package-test-inputs
exit 0 :: pnpm check:driver-memory-census
exit 0 :: pnpm check:entry-guard
exit 0 :: pnpm check:nul-bytes
exit 0 :: pnpm check:parse-guard
exit 0 :: pnpm check:partof-closing-keyword
exit 0 :: pnpm check:pm-dispatch-gates
exit 0 :: pnpm check:pm-half-states
exit 0 :: pnpm check:pnpm-filter-targets
exit 0 :: pnpm check:ratchet-remedy-authority
exit 0 :: pnpm check:refd-timer-probe
exit 0 :: pnpm check:watch-hint-literal

The artifact-roster gates the derivation flags as "silence is not evidence in EITHER direction" for these paths — 8 of the 47 keep their roster under scripts or scripts/pm, which one of my paths is in, so the derivation says to read them rather than treat them as passed:

gate exit reading
node scripts/check-published-list-mirrors.mjs 0 pass
node scripts/check-published-list-mirrors.mjs --self-test 0 pass (checker-health)
pnpm check:pm-label-desc-cap 0 pass — the roster nearest my path
pnpm check:console-injection 0 pass
pnpm check:engine-double-contract 0 pass
pnpm check:i18n-stale-fill 0 pass
pnpm check:published-readme-exports 3 NOT MEASURED — exit 3 is this gate's own PREREQUISITE-NOT-MET code: 45 packages have no dist/index.d.ts in this worktree. Its own words: "This is NOT a pass and NOT a finding: nothing was measured." Building 45 packages to measure README-vs-type-surface agreement is unrelated to a scripts/pm/** diff; left to CI.
pnpm check:dts-closure NOT MEASURED — same prerequisite (built dist/), same reason; not run.

Exit codes were captured before any pipe (each command redirected to its own log, $? read immediately).

Reverse verification — two legs, on-disk proof, byte-exact restore

Both run from the committed state, with trap restore EXIT INT TERM and absolute paths; restore is git checkout HEAD -- PATH and is proved by blob hash plus an empty git diff HEAD, never by an exit code. Predicted direction: red (the ordinary one). Both were red.

leg mutation on-disk proof result
widen the fireable set FIREABLE_RESTART_WHEN_CLASSES gains 'tracked-path' and 'prose' injected 1, removed 0; blob dc97c0f2 vs head 57118597 exit 1 — ✗ 15 of 3606 case(s) failed, every new class row among them
drop the tracked-path class the restartWhenTrackedPaths(...) test replaced by if (false) injected 1, removed 0; blob 6db4adeb vs head 57118597 exit 1 — ✗ 5 of 3606 case(s) failed, exactly the five tracked-path cases

Leg two is the sharper one: it proves the tracked-path class is genuinely exercised and not merely shadowed by prose. After each leg the blob hash returned to 57118597 and git diff HEAD printed nothing.

Self-test delta: +47 cases (3559 → 3606)

Three replacing one in the #9591 fixture triage (the case's subject is decoration tolerance and survives; its expectation moved, because #9591's exit really is prose and the old null asserted otherwise — it is now pinned that the backticked line is still READ, that the row it produces is the prose class, and that it carries no "maybe it is there" hedge, which is the whole #10102 lesson), plus 44 new class cases: every class in both directions, the closed head list, the two decoy shapes (a glob and a line suffix), the collapse with no oracle injected, the ordering when several unfireable values coexist, both channels, and the five gathering-policy pins.

Scope notes


Acceptance notes

  • noted, not filed: H17's prose-anchor stage harvests backticked spans only, so a trigger clause written in bare prose — #7898's, verbatim — anchors on a term and contributes no path. Measured (h17TriggerFileCandidates returns the empty array on the real body) but NOT a defect: backtickedSpans' header states the boundary and argues for the Restart-touch: convention instead of a wider parser. Carrier: the next H17 card, if one is opened.
  • noted, not filed: the reserved unreachable-closed-target class remains a one-line row if anyone ever rules for it — classifyRestartWhen would need one reachability call at the closed-ref branch and nothing else. Left out on purpose; H9's header reserves the ruling. Carrier: whoever takes that ruling.
  • noted, not filed: four live holds name a tracked repo path under Restart-when: and read clean only because the value also mentions an unrelated issue number (#8753, #8607, #8589, #6009), and #7401 reads clean while its own text says no scan can detect it. This is the measured price of testing issue-ref before tracked-path, which the header's ruling requires. Carrier: whoever reopens the ordering question.
  • noted, not filed: the card's Finding 2 premise — "its two tracked paths extract. #7898 has been a row in the trigger-file index" — is falsified on this head. The index renders; #7898 was never in it. Recorded in the PR body above rather than filed, since #17377 is the card that carries it.

Surface note, declared rather than slipped: one sentence inside h9OnHoldNoRestartWhen's own docblock ("Legal iff SOME Restart-when: line […] is not manual…") was corrected, because this change makes it false. It is the contract sentence of the function the file surface names; leaving a false contract in place was not an option. Nothing else outside the declared region moved.

Authored by the skills seat's dev agent in Claude Code session session_01YKEjmbYNvYWJvWGSWx26zK, on branch claude/issue-17377-h9-restart-when-classes. Durable attribution is stated here in prose because a PR body's trailing footer block is rewritten differently by each write channel — measured again on this very PR: a raw REST PATCH appended a second, bare footer (+58 bytes exactly), reproducing the reading in pm-dispatch references/platform-readings.md to the byte.

Clause-②: no


维护者速读(草稿)

改了什么 —— H9 原来只问「这个 Restart-when: 值是不是以 manual 开头」,不是 manual 就算可点火。现在改成给值分六类,只有三类算可点火;另外两类(值里写的是仓库里真实存在的文件路径 / 值是一段谁都触发不了的散文)照旧只出一行报告,并在那行里写清楚该怎么改。⛔ 不拒绝、不改状态、不加新错误码、不换档位,扫描完照样 exit 0。

为什么改 —— 现场量出来的:104 张在挂的卡里,50 张正文写了 Restart-when:,其中 23 张写的东西机器永远点不着,却一直被当成「有机器出口」记账。卡里点名的 #7898 就是这样挂着,它点名的两个文件被动过 11 次,一次都没被捎上。

风险与代价(含回滚) —— 代价是每轮巡检多出最多 23 行报告。网络请求增加为零:H17 本来就为每一张在挂的卡各拉一次评论,H9 走的是同一个缓存,文件里自己写着这件事。回滚就是 revert 这一个 commit,一个文件,没有数据迁移、没有已发布产物、没有 changeset。

席位意见 ——(留空,由席位定稿)

你要做的 —— 有一个被本 PR 量出来、但按裁决故意没动的口子等你拍板:值里只要随手提到任何一个 issue 编号,就会被判成可点火。现场量到 4 张卡(#8753#8607#8589#6009)明明写的是文件触发,却因为顺口提了一个无关的编号而判绿;还有 #7401 自己在正文里写着「这是一个没有关闭事件的维护者动作,没有任何扫描能发现它」,照样判绿。要不要把「文件路径」排到「issue 编号」前面,是 H9 头注里明确留给裁决的那类问题,本卡没碰。


Generated by Claude Code

…ts spelling

`hasFireableRestartWhen` asked whether some value did not begin with `manual`,
so every shape but that one word read as fireable — including two measured live
on one lane on one day: a value naming tracked repo paths (a file trigger, which
is `Restart-touch:`'s key, and the unlock sweep fires only the `closed` form),
and a value that is prose naming no issue, no tracked path and no runnable
command (a `manual` in disguise, waiting on an actor nothing schedules).

`classifyRestartWhen(value, isTracked)` sorts a value into six classes —
manual / closed-ref / issue-ref / command / tracked-path / prose — and
fireability is now membership in the three fireable ones. The two new
unfireable classes produce H9 rows carrying their own remedy sentence.

Report-only, and the boundary is exact: same `H9` code, same `state` band, no
new exit code, no refusal, no state change; a completed sweep still exits 0.
The class H9's header reserves for a ruling (an unreachable well-formed
`closed` target) stays clean, and the ruling spelling the header advertises
is classified before the two unfireable classes so it reads exactly as before.

The tracked-file oracle now reads once BEFORE the sweep and the same reading
answers for both H9's classifier and H17's trigger-file index, so no second
tracker exists to drift from it. `isTracked` defaults to recognising nothing,
which collapses tracked-path into prose — same verdict, a less specific row.

Self-test 3559 -> 3606 cases, both directions per class, with the three live
specimens pinned as literal strings.

Claude-Session: https://claude.ai/code/session_01YKEjmbYNvYWJvWGSWx26zK
Co-authored-by: Claude <noreply@anthropic.com>
@os-litant os-litant added the skip-changeset PR has no user-facing published change; bypasses the changeset gate label Sep 11, 2026 — with Claude
@os-litant
os-litant marked this pull request as ready for review September 11, 2026 03:31
@os-litant
os-litant enabled auto-merge September 11, 2026 03:31
@os-litant
os-litant added this pull request to the merge queue Sep 11, 2026
Merged via the queue into main with commit 5d39d5c Sep 11, 2026
50 checks passed
@os-litant
os-litant deleted the claude/issue-17377-h9-restart-when-classes branch September 11, 2026 03:51
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

size/m skip-changeset PR has no user-facing published change; bypasses the changeset gate

Projects

None yet

2 participants