enhance(#4285): resolve context-monitor fire-points from .planning/config.json (#4366)

* enhance(#4285): resolve context-monitor fire-points from .planning/config.json

The monitor's WARNING (35%) and CRITICAL (25%) fire-points were module
constants, so the only way to tune them was editing gsd-context-monitor.js —
a file in the MANAGED hooks registry, whose body the next install re-stages,
silently discarding the edit. The alternative was turning the safety net off.

Both are now readable from the config block the hook already opens:
hooks.context_warning_threshold and hooks.context_critical_threshold. Absent
keys resolve to today's 35/25, so every existing project is byte-identical.

Resolution is total and never throws — this hook must not block the tool call
it rides in on. A value is usable only if Number.isFinite (type-strict, so the
string "30" and true are rejected) and inside the 0-100 domain of the
remaining_percentage it is compared against; anything else falls back to the
default. The PAIR falls back together: critical >= warning has no coherent
reading, and honouring one side silently picks which of the operator's two
numbers to discard. That also covers a single override contradicting the other
key's default.

config-set validates the domain per key so accept and honour agree, but
deliberately does not enforce the pair — it writes one key per call, so a
two-step retune is transiently inconsistent on disk and refusing it there
would block a legitimate configuration.

Registration follows the statusline.show_git precedent: schema manifest plus
src/config.cts validation, not config-defaults.manifest.json and not
buildNewProjectConfig — emitting 35/25 into every new project would pin the
defaults at creation time for a setting nobody has tuned.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01DsUAawHKUy9pCpnye1Jzd2

* enhance(#4285): address Codex review — per-key fallback docs, discriminating tests

Codex full-PR review (gpt-6-astra, read-only) returned five findings. Each was
verified against source before acting; all five are real.

1. docs/CONFIGURATION.md described the wrong fallback. An out-of-domain value
   falls back PER KEY; both defaults apply only when the RESOLVED pair violates
   critical < warning. warning 150 with critical 30 resolves to 35/30, not
   35/25 — at remaining 28 that difference changes the severity emitted. The
   table now states the two rules in the order they compose, and
   docs/context-monitor.md gains the same worked example.

2. The inconsistent-pair test could not prove the CRITICAL side reverts: its
   pair was 20/25, and 25 is already the default, so an implementation that
   reset only `warning` passed it. A 45/50 pair — both halves away from their
   defaults — now pins each side with its own reading, and an equal 45/45 pair
   pins that the rule is strict (`<`, not `<=`).

3. The rejection table's rows could not tell rejection from acceptance: an
   accepted -5 pairs with the default critical 25, trips the pair check, and
   produces the same silence. Two rows now separate those: a below-domain
   critical must escalate remaining 20 to CRITICAL (proving -5 was rejected,
   not honoured), and an unusable critical beside a usable warning 45 must
   still fire WARNING at remaining 40 (proving per-key fallback rather than
   reset-both). The over-claiming comments are narrowed to what each row
   actually shows.

4. Scope, reproduced rather than assumed: config-set writes through
   planningDir(), so under GSD_WORKSTREAM it lands in
   .planning/workstreams/<name>/config.json while this hook reads only
   <cwd>/.planning/config.json. That is the pre-existing root-only scope
   hooks.context_warnings has always had, but this PR advertises the setter
   route, so both docs now say the keys are root-project settings.

5. Four other English docs still stated 35/25 as fixed: the REQ-CTX-02/03
   requirements fragment, ARCHITECTURE.md's hook table and threshold table,
   and INVENTORY.md's hook row. All now name them as defaults and point at the
   config keys; docs/FEATURES.md is regenerated from its fragment via
   scripts/gen-features.cjs --write, not hand-edited.

Four new mutations, each reverted after: resetting only the warning half on an
inconsistent pair (1 red), resetting both on any unusable key (1), dropping the
>= 0 bound (1), and accepting critical == warning (1). perf-317 is 116/0.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01DsUAawHKUy9pCpnye1Jzd2

* enhance(#4285): tighten claims after Codex round 2 — scoped paths, one more discriminator

Confirmation round found no runtime defect and confirmed the five round-1 fixes
landed. Four precision items, all real, all fixed here.

1. The scoped-write note named the wrong path for GSD_PROJECT. planningDir()
   composes three distinct shapes, confirmed by running config-set under each:
   .planning/<project>/config.json, .planning/workstreams/<ws>/config.json, and
   .planning/<project>/workstreams/<ws>/config.json. docs/context-monitor.md
   now tabulates all four cases instead of collapsing them into one.

2. The 45/50 silence row asserted empty stdout without pinning the exit code.
   runMonitorRaw turns a spawn failure, a non-zero exit or a timeout into empty
   stdout as well, so the row could have passed on a dead child. It asserts
   exitCode === 0 first now, like the equal-pair row already did.

3. The sibling row's message claimed it proved critical fell back to 25. It
   does not: coercing '30' to 30 yields WARNING at remaining 40 too, so the row
   pins the WARNING side surviving and nothing more. Message narrowed, and a
   new row reads the same config at remaining 28, where the two candidate
   resolutions diverge — rejected gives (45, 25) and WARNING, coerced gives
   (45, 30) and CRITICAL. Mutation-verified: swapping Number.isFinite for the
   coercing global reds it.

4. "Accept and honour must agree" was too absolute in the src/config.cts and
   tests/config.test.cjs comments. The agreement holds on the DOMAIN and per
   key: an accepted value can still lose to the hook's pair check at read time,
   and a scoped write never reaches the hook at all. Likewise a two-step retune
   only CAN be transiently inconsistent — 35/25 to 20/10 is valid throughout if
   critical moves first — so the docs now say what a setter-side pair check
   would actually cost: rejecting that intermediate write and forcing an order.

The same over-absolute phrasing is in b7d179c89's message, which is left as
written rather than rewriting history; this commit and the PR body carry the
precise claim.

perf-317 117/0, config 192/0, config-field-docs 47/0, features-index-gate 84/0,
lint:ci clean cold.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01DsUAawHKUy9pCpnye1Jzd2

* chore(#4285): add changeset

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01DsUAawHKUy9pCpnye1Jzd2

* enhance(#4285): address review — planning-config rows, resolveThresholds properties

Two Minor findings from the maintainer review, no behaviour change.

Minor 1: gsd-core/references/planning-config.md's "Hook Fields" table gains
rows for hooks.context_warning_threshold and hooks.context_critical_threshold,
in that table's 5-column form, carrying the same per-key-fallback,
pair-reversion and root-config-scope claims docs/CONFIGURATION.md already
makes. hooks.workflow_guard's absence from that table is pre-existing and
out of scope here.

Minor 2: resolveThresholds() gets fast-check property coverage, which ADR 456
requires of a threshold/limit contract. Reaching it needed a require-time
seam: the resolver was previously observable only by spawning the hook, and a
subprocess per case cannot drive 200 runs — the same conclusion CONTEXT-INDEX
records for the ROADMAP Requirements parser. The stdin adapter therefore moves
into main() behind `require.main === module`, mirroring
gsd-cursor-subagent-start.js and gsd-statusline.js, and module.exports exposes
the resolver plus both default constants so a test asserts fallback against
the source of truth rather than a second copy of 35/25. Spawned behaviour is
unchanged: the 10s stdin timeout still arms per invocation (stdinTimeout is
now a module-scope let assigned in main(), still cleared by the end handler),
and the try/catch crash(ON_CRASH) path is untouched.

Seven properties: totality, ordering, exactness, togetherness, non-vacuity,
per-key fallback, non-object argument. Exactness is stated PER KEY — a mixed
result (one key honoured, one fallen back) is legal and is the documented
contract; the property falsified a per-pair phrasing of it in 4 runs.

Verified: cold lint:ci 0; perf-317 file 125/0; seven mutations killed and
restored, one of which (upper bound widened to 120) is invisible to the 17
hand-written cases and caught only by a property.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01UZw5UhR474YLyE4knjHrte

* enhance(#4285): close the Codex-found gap in the property coverage

Codex whole-PR review of round 3 returned no Blocker and no Major. Two items,
both in the tests added this round, both verified against source before acting.

Minor — the per-key fallback property was asymmetric: it required a usable
warning to survive an unusable critical, but never the reverse. A resolver
that reverted BOTH keys the moment warning was unusable passed all seven
properties. Reproduced exactly: that mutant answers 35/25 for
{warning: 150, critical: 30} where the resolver answers 35/30, and the file
stayed green at 125/0. The mirrored property closes it — with the mutant
re-applied it is now the single failing row, and it is the only row that
fails, so it is load-bearing rather than incidental.

Nit — the ordering property's comment credited it with catching a
half-honoured pair, which it does not: 45/50 "repaired" by resetting only
critical yields 45/25, perfectly ordered. That case belongs to togetherness.
The same comment claimed the behavioural rows sample an inconsistent pair at
exactly one point; stale — they cover 20/25, 45/50 and the 45/45 equality
boundary. Both claims corrected in place.

Verified: cold lint:ci 0; perf-317 file 126/0; the mutant above killed by the
new property alone and the hook restored byte-identical afterwards.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01UZw5UhR474YLyE4knjHrte

* enhance(#4285): name the installed-monitor prerequisite; close the negative-critical gap

Second Codex whole-PR pass, run because the base moved: the author's three
"Update branch" merges pulled ~26 upstream commits in, so the previously
reviewed diff sat on a base that no longer exists. No Blocker, no Major, two
Minor — both verified against source before acting.

Minor 1, and only reachable because of what the merge brought in: #2586
(03738824d) landed in that window and stops staging
hooks/gsd-context-monitor.js for Codex, since the metrics bridge it reads is
written only by hooks/gsd-statusline.js, which Codex never installs
(bin/install.js: "gsd-context-monitor.js is deliberately NOT copied for
Codex"). These two keys are read by that hook and nothing else, so on such a
runtime config-set stores and validates them and nothing consumes them — a
claim the docs this PR adds did not make. docs/context-monitor.md now carries
the explanation and both key tables carry a clause pointing at it; the FEATURES
and INVENTORY entries already link through to those two files, so they are not
edited again. The changeset says it too, because it is user-facing.

Accepting the keys on every runtime is kept deliberately: config is shared
across runtimes, so validation stays runtime-independent and the runtime
caveat lives in documentation rather than in the setter.

Minor 2: the per-key fallback property's junk generator had no negative arm,
though its mirror did — and that asymmetry hid a gap. A resolver reverting
BOTH keys whenever critical is negative answers 35/25 for {45, -5} where the
resolver answers 45/25, and it passed all 126 tests. With the negative arm
added it is the single failing row.

Verified: cold lint:ci 0; perf-317 126/0; both mutants above killed and the
hook restored byte-identical; 538/0 across the config, changeset, doc-parity
and emitted-attribution gates.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01UZw5UhR474YLyE4knjHrte

* enhance(#4285): refuse the two dead threshold endpoints; resolve absent keys

Maintainer review round 2 raised two Minors and a nit.

Minor 1 — `hooks.context_warning_threshold: 0` was accepted and stored but can
never take effect: `critical < warning` must hold and both sides are clamped to
0-100, so nothing can sit below a warning of 0. Verifying it surfaced the MIRROR
case the review did not name: `critical: 100` is equally dead, since nothing can
sit above it. Both confirmed against the real resolver for partners {absent, 0,
50, 100}, with 0.001 and 99.999 honoured as controls.

`config-set` now refuses both, because storing a value the reader always
discards is the accept-then-discard shape this codebase refuses elsewhere. The
hook is unchanged and still total — it degrades to defaults rather than
throwing, so a project that already carries one of these on disk still loads.
The old "accepts the domain bounds 0 and 100" row asserted the misleading half
and is replaced by tables that make the asymmetry the point (0 is legal for
critical and illegal for warning; 100 is the reverse), plus a control row so
"refuse both endpoints outright" would not pass in its place.

Minor 2 — the keys are absent from config-defaults.manifest.json /
buildNewProjectConfig where the sibling `hooks.context_warnings` lives. Kept
that way: buildNewProjectConfig writes a hooks object into every NEW project's
config.json, which would freeze today's fire-points as an explicit per-project
override everywhere — the opposite of this PR's premise. But the underlying
complaint was real, so the actual symptom is fixed: `config-get` on an absent
key returned "Key not found" while the hook silently used 35/25. It now resolves
through SCHEMA_DEFAULTS. Restated rather than derived because CONFIG_DEFAULTS is
re-exported flattened and has no `hooks` member at runtime; the one resulting
copy of 35/25 outside the hook is pinned against the hook's exported constants
by a drift test (red-checked: moving the literal to 40 reds it).

Nit — PR-body counts unverifiable from the diff. Noted, no code change.

Codex round 3 then found a broken doc link (`context-monitor.md` resolved
inside gsd-core/references/, where it does not exist; the emitted tree's own
convention is `../../docs/...`) and a stale comment still describing the
manifest-derived approach I had backed out. Both fixed. It also corrected my
rationale on a point of fact: manifest entries alone would NOT have reached new
project configs, since buildNewProjectConfig builds its own literal — the
freezing argument applies to that function, not to the manifest. The comment now
says so rather than running the two together.

Verified: cold lint:ci 0; full suite 36,082 / 0 fail before these two fixes,
config + perf-317 321/0 after; drift pin red-checked.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01UZw5UhR474YLyE4knjHrte

---------

Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com>
Co-authored-by: Tom Boucher <trekkie@nomorestars.com>
This commit is contained in:
Behruz Nassre Esfahani
2026-09-08 21:31:14 -07:00
committed by GitHub
parent 6c5e11049b
commit 3ad75a6d59
13 changed files with 919 additions and 22 deletions

View File

@@ -0,0 +1,5 @@
---
type: Changed
pr: 4366
---
**Context-monitor WARNING/CRITICAL fire-points are now readable from `.planning/config.json`** — `hooks.context_warning_threshold` (default 35) and `hooks.context_critical_threshold` (default 25) move the two rungs per project, so a tuned fire-point survives an update instead of being re-staged away with the managed hook file. Absent keys resolve to today's 35/25, so existing projects are unchanged. An unusable value falls back per key; both revert to their defaults only when the resolved pair violates `critical < warning`. The keys are root-project settings — the hook reads `<cwd>/.planning/config.json` only, and they are read by that hook and nothing else, so on a runtime where it is not installed (Codex, per #2586) both keys are stored and validated but inert. `config-set` refuses the two endpoints that can never take effect — a warning of 0 and a critical of 100 — because `critical < warning` has no legal partner for either, and an absent key now reports the shipped default (35/25) instead of "Key not found". (#4285)