Commit c396e34
feat(mt#4393): Join the observer roster to the interceptor catalog, and close the real gap
## Summary
mt#4393 was filed on a subtraction: catalog `detector`/`injector` entries minus roster entries,
read as "roughly 13 observers with no roster entry." That number is an artifact, because the two
artifacts **share no join key and do not enumerate the same population.** This PR builds the join,
measures the real gap, and closes it.
- **No join key.** The roster labels entries in prose; the catalog keys on `guardName`.
`- **Subagent model verification**` is `verify-subagent-model`.
- **Many-to-one.** `- **Injection (per-turn)**` documents four hooks; `- **Operator deferral**`
says "Six surfaces". A count difference is off by at least six before it starts.
- **Wrong population, both directions.** `detector ∪ injector` includes blocking merge gates and
excludes the recorders and stampers the roster already documents (`families: []`). The roster's
real population is what its header states — non-blocking, i.e. `interventions` carrying no `deny`.
## Criterion accounting
- **SC1** — **discharged.** Every non-blocking interceptor now has a roster entry or a reasoned
exemption. The criterion itself was **amended** (R1): it required the `detector ∪ injector`
population, which is wrong in both directions, and the spec now records why.
- **SC2** (every roster entry carries a `Detail:` pointer) — **`[sc2-deferred: mt#4992]`** for the
19 pre-existing entries. The 14 entries this PR ADDS each carry an explicit no-page-yet note, so
it does not add to that debt (R3).
- **SC3** — **discharged.** `audit:observer-roster` runs enforcing and exits 0; the enforcing form
joins `audit:interceptors`, already run at `ci.yml:338` and in `validate-all`.
- **SC4** (measurement re-run and recorded, not asserted) — **discharged**, against current main.
## What ships
- **`scripts/observer-roster-map.ts`** — the join: roster label → `guardName[]`, 19 exemptions each
with a reason, and `ROSTER_NO_CATALOG_PEER` for the inverse direction.
- **`scripts/audit-observer-roster.ts`** — the reconciliation, seven classes in both directions.
- **`scripts/audit-observer-roster.test.ts`** — 19 tests.
- **14 new roster entries** in `hook-observers.mdc`, plus compile outputs.
## The 20 gaps were not 20 — working them individually changed the answer
- **Four were already documented** inside entries whose text names them — a many-to-one the map
missed. Map fix, no roster change.
- **Two were MIS-ATTRIBUTED, and this is the find that mattered.** `Agent-dispatch record` mapped
to `record-subagent-invocation`, and `SubagentStop recording` mapped to nothing. They are
swapped. **A wrong mapping is worse than a missing one — it reports clean.**
- **One is plumbing** (`record-turn-anchor`), now exempt with that reason.
- **Fourteen were genuinely undocumented** and now have entries.
## `Consumer-account` needed the inverse declaration
It has no catalog peer by design — it rides `require-execution-evidence-before-merge` as that
gate's fifth calibration surface (mt#4493). Left alone it is a permanent finding, and **a check
that can never reach zero is one nobody can gate on.** `ROSTER_NO_CATALOG_PEER` declares it with a
reason; a declaration that contradicts the map is its own finding class.
## Three defects found
**In the check, while writing it.** A label mapped to an EMPTY guard list PASSED — silently, for
exactly the case the class exists to catch.
**In the check, at review.** `parseRosterLabels` split on a bare `---`, so a horizontal rule in the
body truncated the label list — in the direction that reports FEWER labels and misses drift.
**In the repo.** `hook-observers.mdc` still documented `Stop-at-decision`, whose detector mt#4978
retired the same day (`eece1b535`). Removed here — exactly the class mt#4852 was filed for.
## Execution evidence
**AT1 — the check fails against the uncovered tree and passes once covered. Both runs recorded;
the failing run is the negative control.** Note AT1's "13 uncovered entries" is itself the artifact
of the subtraction this task disproved; the real figure under the join was 22 findings.
# BEFORE (this branch, rebased onto current main, coverage not yet written)
$ bun scripts/audit-observer-roster.ts --report
[observer-roster] 59 roster entries against 101 non-blocking catalog entries (18 exempt)
20 non-blocking interceptors with no roster entry and no exemption
2 roster entries the catalog does not enumerate at all
$ bun scripts/audit-observer-roster.ts # enforcing
ENFORCING EXIT=1
# AFTER (14 entries, 4 map corrections, 1 exemption, 1 no-peer declaration)
$ bun scripts/audit-observer-roster.ts
[observer-roster] 73 roster entries against 101 non-blocking catalog entries (19 exempt)
[observer-roster] reconciled — no gaps.
EXIT=0
**AT2 — a synthetic non-blocking catalog entry with no roster entry makes the check fail**, so it
detects the class rather than passing on a healthy tree. Asserted as a named unit test:
(pass) reconcile > AT2 — a NON-BLOCKING catalog entry with no roster entry and no exemption is reported
A second negative control at the integration level: removing one mapped entry from the REAL roster
failed exactly the integrity test and only that one —
(fail) SC3 ... > the four join-integrity classes are empty
16 pass / 1 fail
— after which the file was restored and verified clean.
**AT3 — compile stays clean and the roster still compiles with its `paths:` frontmatter intact.**
$ bun run minsky compile
[compile] Target "claude-rules": 17 file(s) written
[compile] Target "claude-hooks": 184 file(s) written
$ head -3 .claude/rules/hook-observers.md
---
paths: ['.minsky/hooks/**', '.claude/hooks/**']
---
**Full suites:**
$ bun test --preload ./tests/setup.ts --timeout=15000 ./scripts/audit-observer-roster.test.ts
19 pass / 0 fail / 30 expect() calls Ran 19 tests across 1 file.
$ bun scripts/run-related-tests.ts .minsky/rules/hook-observers.mdc scripts/observer-roster-map.ts scripts/audit-observer-roster.ts
89 pass / 0 fail / 193 expect() calls Ran 89 tests across 4 files.
$ bun run audit:interceptors # the CI step, now carrying the roster check
UNRESOLVED : 0 / OK / [observer-roster] reconciled — no gaps.
`reconcile` now takes its maps as a parameter, defaulting to `SHIPPED_MAPS` — several classes are
by design empty against the shipped maps, so a test that can only see them passes because the input
is clean rather than because the logic is right.
Typecheck clean over 8 projects (`validatedWorkspace` confirmed as the session). Lint clean over
4,384 files, 0 errors, 0 warnings.
## Deploy verification
`isDeploySurfaceFile` returns **true for `package.json`** — run over the actual changed-file list,
not recalled — so this PR IS deploy surface and no `[no-deploy-impact]` tag applies. The change to
that file is script-only (`audit:interceptors` gains the roster check; `audit:observer-roster` drops
`--report` and gains a `:report` sibling), which does not touch the runtime, but that is a reason to
expect the deploy to be clean, not a reason to skip checking it.
**After merge I will run `mcp__minsky__deployment_wait-for-latest` and confirm SUCCESS plus a
started runtime** before treating mt#4393 as done. A tool or auth flake is a blocker to reconnect
and retry, not a licence to defer; "applied" is the action, not the outcome.
🤖 Generated with [Claude Code](https://claude.com/claude-code)
Co-Authored-By: minsky-ai[bot] <minsky-ai[bot]@users.noreply.github.com>7 files changed
Lines changed: 578 additions & 5 deletions
File tree
- .claude/rules
- .cursor/rules
- .minsky/rules
- scripts
| Original file line number | Diff line number | Diff line change | |
|---|---|---|---|
| |||
55 | 55 | | |
56 | 56 | | |
57 | 57 | | |
58 | | - | |
59 | 58 | | |
60 | 59 | | |
61 | 60 | | |
| |||
126 | 125 | | |
127 | 126 | | |
128 | 127 | | |
129 | | - | |
| 128 | + | |
| 129 | + | |
| 130 | + | |
| 131 | + | |
| 132 | + | |
| 133 | + | |
| 134 | + | |
| 135 | + | |
| 136 | + | |
| 137 | + | |
| 138 | + | |
| 139 | + | |
| 140 | + | |
| 141 | + | |
| 142 | + | |
0 commit comments