fix(mt#4816): Re-root the last repo-rooted telemetry writer and retire the "repo" location - #3528
Conversation
…e the "repo" location kind `verify-subagent-model.ts` resolved its log as `resolve(findRepoRoot(cwd), ".minsky/subagent-model-mismatch.jsonl")` — the last writer depositing a telemetry file into whatever Minsky-managed project the agent was in, which mt#4748's SC2 forbids. SC1 decided FLAT, not project-keyed, for the `special` family: `attachProjectIds` (ingest-service.ts) resolves each row's `project_id` from the record's `session_id` and uses the path only for `source_path`, so project-keying buys no attribution the row does not already carry. Measured against prod: fire-log and guard-health-log are flat and carry populated `project_id` at scale, and both of this stream's two rows are already ingested with it non-null. The `"repo"` member of `GuardEventStreamLocation` and its `resolveStreamPath` branch are retired in the same change, so re-introducing a repo-rooted stream is now a TYPE ERROR rather than something a later sweep has to find. SC5 ships the family's one behaviour-scoped check (`scripts/lib/repo-rooted-telemetry-paths.ts`) — keyed on the behaviour, not on a directory, because every prior sweep was scoped to `.minsky/hooks/**` and could never see `ask-form-lint-calibration.ts` in `src/adapters/shared/commands/`. It is verified to flag that historical writer. Consumers updated: both generated mirrors, the stream inventory, ADR-028 §D4 (second amendment), and the hook-module-inventory census.
Minsky Reviewer StatusVerdict: APPROVED — no blocking findings Commands
|
There was a problem hiding this comment.
Independent adversarial review (Chinese-wall)
Reviewer: minsky-reviewer[bot] via openai:gpt-5
Tier: 2
Overall the PR is coherent: it re-roots the last repo-rooted telemetry writer, removes the "repo" location from the manifest types, updates the reader accordingly, adds a behaviour-scoped safeguard, and updates docs. Tests assert writer/reader agreement and write isolation. However, I found one blocking issue: the generated .claude/hooks/verify-subagent-model.ts callsite invokes appendMismatchRecord(decision.record) without passing env, after the signature changed to (record, env). This risks losing the intended testability seam and parity with the reader; please pass process.env explicitly and regenerate. Two non-blocking concerns on the SC5 scanner: the regex is brittle (misses namespaced/aliased/multi-line join/resolve uses) and the telemetry-target heuristic can false-positive on substrings like catalogPath. Consider hardening or moving to a small AST. Spec criteria SC1/SC2/SC3/SC5 appear met from the diff; SC4 (file deletion in user work trees) is out-of-repo and unverifiable here. If you address the blocking callsite and consider the scan robustness nits, this will be ready to merge.
Findings
-
[BLOCKING] .claude/hooks/verify-subagent-model.ts:241 — Generated Claude hook diverges from source signature change;
appendMismatchRecordcallsite not updated to passenv
In.claude/hooks/verify-subagent-model.ts:312, the callsite was changed fromappendMismatchRecord(input.cwd, decision.record)toappendMismatchRecord(decision.record). However,appendMismatchRecord’s signature changed from(cwd: string, record)to(record, env = process.env). The.claude/script does not inject anenvand cannot import the domain reader, so tests that depend on passing a tempMINSKY_STATE_DIRwill not be able to redirect the write when invoking the generated hook directly. More importantly, the implementation here still importshomedirand builds the path itself, while the ingest reader usesresolveStateDir. To keep parity and to support test redirection, the generated hook should forwardprocess.envexplicitly:appendMismatchRecord(decision.record, process.env). Please update both.claude/hooks/verify-subagent-model.tsand.minsky/hooks/verify-subagent-model.tscallsites consistently (the.minsky/one is correct already) and regenerate the compiled outputs. -
[NON-BLOCKING] scripts/lib/repo-rooted-telemetry-paths.ts:76 — Behaviour-scan regex is brittle and can miss common shapes (aliases, namespaced joins, multi-line calls)
PATH_ASSIGNMENTonly matches single-linejoin(...)/resolve(...)calls with direct identifiers, e.g.,const p = join(repoRoot, ...). It will miss common variants: -
Namespaced calls (
path.join(...)) or aliased imports (import { join as j } from 'path'). -
Multi-line argument lists (
join(\n repoRoot,\n ...\n)), which are routine in this codebase. -
Template-literal roots (
${repoRoot}) or wrapper helpers aroundjoin/resolve.
You’ve acknowledged some blind spots in the comments, but these specific missed shapes are easy to hit in our tree and would yield false negatives while giving a green test. Consider expanding detection to include namespaced/aliased joins (e.g., /\b(?:join|resolve)|\b\w+\.join\(/) and multi-line matches ([\s\S]*?\); with minimal lookbehind), or switching to a small AST parse for assignments. Given this is our only mechanical floor for SC5, robustness here matters.
-
[NON-BLOCKING] scripts/lib/repo-rooted-telemetry-paths.ts:101 — Telemetry-target heuristic can false-positive on unrelated names like
catalogPath
TELEMETRY_TARGETuses/log|calibration|evaluation|baseline|watermark|claims|mismatch|observations|\.jsonl/iand is matched against${name} ${rest}. This will match identifiers containinglogas a substring (e.g.,catalogPath,loggerConfig) or arbitrary JSONL filenames unrelated to telemetry. You partially mitigate with test allowlist, but this can still raise noise in future refactors and make real regressions harder to see. Consider tightening: -
Use word boundaries for
log(e.g.,/\blog\b/) and anchor to filename segments (e.g.,/[\w/-]+\.(jsonl|json)\b/). -
Prefer a denylist of known non-telemetry patterns (e.g.,
Dockerfile,config.yaml) or, better, enumerate telemetry stems from the manifest to cross-check.
This is advisory; not blocking, but worth hardening since this is now the only behaviour-scoped guard.
Spec verification
| Criterion | Status | Evidence |
|---|---|---|
SC1 — A decision is recorded between flat and project-keyed for the special family, with its reason, before the path is changed. |
Met | docs/architecture/adr-028-guard-hook-dispatcher-consolidation.md:483-500 records the mt#4816 amendment; docs/architecture/guard-calibration-stream-inventory.md:186-206 explains the flat decision and rationale. Code reflects flat state-dir in packages/domain/src/guard-events/stream-sources.ts:305-315. |
SC2 — verify-subagent-model.ts no longer resolves its log under the repo root. |
Met | .minsky/hooks/verify-subagent-model.ts:248-273 now derives the path via getMismatchLogPath() -> state dir using MINSKY_STATE_DIR/homedir; findRepoRoot and resolve(repoRoot, ...) were removed. The same change exists in .claude/hooks/verify-subagent-model.ts:241-268. |
SC3 — stream-sources.ts has no location: "repo" rows left, and the writer resolves to the same path resolveStreamPath computes for that row. Verify by asserting the two agree, not by reading both. |
Met | packages/domain/src/guard-events/stream-sources.ts removes "repo" from GuardEventStreamLocation (lines ~53-67) and declares subagent-model-mismatch as location: "state-dir" with relativePath: "subagent-model-mismatch.jsonl" (lines 305-315). Tests in .minsky/hooks/verify-subagent-model.test.ts:308-358 assert equality against resolveStreamPath from the reader for both env-set and default branches. |
| SC4 — The existing repo-tree file is dispositioned (migrated or deleted with a reason), not orphaned. | Unverifiable | The artifact (existing files under managed projects' .minsky/) lives outside this repo. The PR body claims both records are already ingested and should be deleted, but no in-repo code can effect that deletion. Per Out-of-repo references policy, this cannot be verified from the diff. |
| SC5 — A check asserts SC2 mechanically. Coordinate with mt#4811/mt#4755/mt#4778 on ONE behaviour-scoped check rather than a fourth directory-scoped one — see the note below. | Met | scripts/lib/repo-rooted-telemetry-paths.ts adds a behaviour-scoped scan that flags repo-rooted telemetry path expressions; scripts/lib/repo-rooted-telemetry-paths.test.ts runs it against the real tree, verifies allowlist hygiene, and includes synthetic controls (including the historical ask-form-lint case). |
Adoption sweep
| Symbol | Kind | Consumers found | Classification | Notes |
|---|---|---|---|---|
| .minsky/hooks/verify-subagent-model.getMismatchLogPath | function | .minsky/hooks/verify-subagent-model.test.ts:319 — asserts equality with reader’s resolveStreamPath | Adopted | New exported helper introduced to expose the computed path for tests; only test consumer found, which is sufficient. |
Documentation impact
- updated-in-pr — This PR changes public behavior (stream location semantics) and updates docs accordingly: docs/architecture/adr-028-guard-hook-dispatcher-consolidation.md adds the mt#4816 amendment; docs/architecture/guard-calibration-stream-inventory.md updates the §C row and surrounding prose; docs/architecture/hook-module-inventory.md updates the divergence count to 34.
Affected: docs/architecture/adr-028-guard-hook-dispatcher-consolidation.md, docs/architecture/guard-calibration-stream-inventory.md, docs/architecture/hook-module-inventory.md
… a third module Both NON-BLOCKING findings were real defects in the scanner and are fixed. **Namespaced and multi-line calls (R1 finding 2).** `PATH_ASSIGNMENT` matched only bare, single-line `join(...)`. It now accepts an optional `<ident>.` prefix and a multi-line argument list, capped at 400 chars so one match cannot swallow a file. This was not theoretical: the widened scan immediately surfaced `src/cockpit/ask-state-cache.ts:304` — `path.join(repoRoot, WATERMARK_STORE_RELPATH)` — a reader of the same watermark store and the THIRD module in that carve-out family, invisible to the draft purely because of the `path.` prefix. Allowlisted with its reason and recorded in the spec. **`catalogPath` false positive (R1 finding 3).** `/log/i` matched `cataLOGPath` and `LOGgerConfig`. Word boundaries do not fix this — `\blog\b` matches neither `logPath` nor `mainLog`, which are exactly the names that must match. The candidate text is now SEGMENTED on case and non-alphanumeric boundaries and compared against whole stems. A single test pins both directions so the trap and the must-match cases cannot drift apart. Three new cases cover the namespaced, multi-line, and substring-trap shapes. The module's "what it cannot see" note is updated: aliased imports, wrapper helpers, cross-statement assembly, and parameter-supplied roots remain out of reach, and are stated rather than implied. The BLOCKING finding is addressed in the PR body: both callsites are byte-identical, so there is no divergence to fix.
There was a problem hiding this comment.
Independent adversarial review (Chinese-wall)
Reviewer: minsky-reviewer[bot] via openai:gpt-5
Tier: 2
The fix largely implements the task: the writer now targets the state dir, the manifest retires the "repo" location and declares the stream flat, tests assert writer/reader agreement under both env branches, a behaviour-scoped guard is added, and docs are updated. However, the round-1 BLOCKING issue persists: both the generated .claude/ hook and the source .minsky/ hook call appendMismatchRecord(decision.record) without forwarding process.env after the function’s signature was changed to (record, env = process.env). Please pass process.env explicitly at both callsites and regenerate compiled outputs. The SC5 robustness nits from R1 remain advisory. Once the explicit env forwarding is fixed, this should be ready to merge.
Findings
- [BLOCKING] .claude/hooks/verify-subagent-model.ts:312 — Generated Claude hook still does not pass
process.envtoappendMismatchRecordafter signature change
appendMismatchRecordwas refactored to take(record, env = process.env)so tests can redirect writes by injecting a tempMINSKY_STATE_DIRand to keep parity with the ingest reader’sresolveStateDir. At.claude/hooks/verify-subagent-model.ts:312, the call remainsappendMismatchRecord(decision.record)without forwardingprocess.env. While the default parameter covers the runtime case, this loses the intended explicitness and prevents callers of the generated hook from substituting the env via a single seam (the hook should consistently forward its ownprocess.env).
Please change this call to appendMismatchRecord(decision.record, process.env) and regenerate the compiled outputs. This was the R1 blocker and remains unresolved here.
- [BLOCKING] .minsky/hooks/verify-subagent-model.ts:309 — Hook callsite no longer forwards
process.envtoappendMismatchRecordafter signature change
appendMismatchRecordnow takes(record, env = process.env)to allow tests to inject a tempMINSKY_STATE_DIRand to keep parity with the ingest reader’sresolveStateDir. At.minsky/hooks/verify-subagent-model.ts:309, the call was changed toappendMismatchRecord(decision.record)and no longer passesprocess.env. While the default parameter yields the same runtime env, keeping the explicit forward ensures callers of the hook maintain a single seam to substitute env during direct execution and avoids future refactors accidentally changing the default.
Please change this call to appendMismatchRecord(decision.record, process.env) (matching the intended pattern used elsewhere like fire-log.ts) and regenerate compiled outputs so .claude/ mirrors it.
Spec verification
| Criterion | Status | Evidence |
|---|---|---|
SC1 — A decision is recorded between flat and project-keyed for the special family, with its reason, before the path is changed. |
Met | docs/architecture/adr-028-guard-hook-dispatcher-consolidation.md:483-500 adds the mt#4816 amendment; docs/architecture/guard-calibration-stream-inventory.md:186-206 records the flat decision and rationale. Code reflects flat state-dir in packages/domain/src/guard-events/stream-sources.ts:287-315. |
SC2 — verify-subagent-model.ts no longer resolves its log under the repo root. |
Met | .minsky/hooks/verify-subagent-model.ts:236-273 replaces repo-root resolution with state-dir via getMismatchLogPath(); findRepoRoot/resolve(repoRoot, …) removed. Same pattern mirrored in .claude/hooks/verify-subagent-model.ts:239-268. |
SC3 — stream-sources.ts has no location: "repo" rows left, and the writer resolves to the same path resolveStreamPath computes for that row. Verify by asserting the two agree, not by reading both. |
Met | packages/domain/src/guard-events/stream-sources.ts:61-78 retires "repo" from GuardEventStreamLocation; subagent-model-mismatch declared location: "state-dir" with relativePath: "subagent-model-mismatch.jsonl" at 287-297. Tests in .minsky/hooks/verify-subagent-model.test.ts:308-358 assert equality with the reader’s resolveStreamPath under both env-set and default branches. |
| SC4 — The existing repo-tree file is dispositioned (migrated or deleted with a reason), not orphaned. | Unverifiable | Disposition of existing files under managed projects’ .minsky/ is outside the repo. The PR text claims deletion is safe (already ingested), but no in-repo artifact can verify or perform that. Out-of-repo per policy. |
| SC5 — A check asserts SC2 mechanically. Coordinate with mt#4811/mt#4755/mt#4778 on ONE behaviour-scoped check rather than a fourth directory-scoped one — see the note below. | Met | scripts/lib/repo-rooted-telemetry-paths.ts adds a behaviour-scoped scan; scripts/lib/repo-rooted-telemetry-paths.test.ts runs it against the real tree, validates allowlist hygiene, and includes synthetic controls (including the historical ask-form-lint writer). |
Documentation impact
- updated-in-pr — This PR changes the stream’s location semantics and retires the "repo" location kind, and updates docs accordingly: docs/architecture/adr-028-guard-hook-dispatcher-consolidation.md adds a second amendment for mt#4816; docs/architecture/guard-calibration-stream-inventory.md updates §C/§D prose and the path; docs/architecture/hook-module-inventory.md updates the divergence count to include verify-subagent-model.ts.
Affected: docs/architecture/adr-028-guard-hook-dispatcher-consolidation.md, docs/architecture/guard-calibration-stream-inventory.md, docs/architecture/hook-module-inventory.md
R1 responseBoth NON-BLOCKING findings were real and are fixed in The BLOCKING finding does not hold, and the review's own text contains the contradiction. It asks to "update both
The stated rationale does not hold either: On the adjacent observation, which is fair: the hook does build the path itself rather than calling the reader's On SC4 — "Unverifiable": agreed that no in-repo code effects the deletion, and the classification is right from the diff alone. Recording the evidence for the audit trail: the file held 2 records (586 bytes, last write 2026-08-05), both confirmed present in |
There was a problem hiding this comment.
Independent adversarial review (Chinese-wall)
Reviewer: minsky-reviewer[bot] via openai:gpt-5
Tier: 2
The prior BLOCKING findings on the verify-subagent-model hook have been addressed: both .minsky and generated .claude copies now write to the state dir (MINSKY_STATE_DIR/~/.local/state/minsky), not the repo. The ingest manifest removes the "repo" location entirely (type-level enforcement), and the writer/reader path agreement is pinned by tests. The PR also introduces a behavior-scoped scanner and tests to assert no unallowlisted repo-rooted telemetry paths remain; the allowlist carries explicit reasons. I spot-verified stream-sources.ts and ingest-runtime.ts for consistency. Docs were updated to reflect the change. One criterion (SC4) concerns out-of-repo file disposition and is unverifiable from the diff, but does not block merge. I find no new critical defects introduced by this fix. Approving.
Spec verification
| Criterion | Status | Evidence |
|---|---|---|
SC1 — A decision is recorded between flat and project-keyed for the special family, with its reason, before the path is changed. |
Met | packages/domain/src/guard-events/stream-sources.ts:20-45, 64-72 — module header documents mt#4816 retiring "repo" and explicitly records that subagent-model-mismatch is now state-dir and flat (not project-keyed), with rationale tied to attachProjectIds attribution. Also reflected inline at :147-160 (SPECIAL_STREAMS preface). |
SC2 — verify-subagent-model.ts no longer resolves its log under the repo root. |
Met | .minsky/hooks/verify-subagent-model.ts:147-179, 181-186 — getSubagentModelStateDir derives from MINSKY_STATE_DIR or ~/.local/state/minsky, and getMismatchLogPath/appendMismatchRecord write under that state dir. No repo-rooted findRepoRoot/workspacePath joins remain. |
SC3 — stream-sources.ts has no location: "repo" rows left, and the writer resolves to the same path resolveStreamPath computes for that row. Verify by asserting the two agree, not by reading both. |
Met | packages/domain/src/guard-events/stream-sources.ts:64-72 — GuardEventStreamLocation is now the single-member union "state-dir" (type-level removal of "repo"). SPECIAL_STREAMS entry at :147-160 sets relativePath: "subagent-model-mismatch.jsonl". The hook exports MISMATCH_LOG = "subagent-model-mismatch.jsonl" and computes its path via state dir (see .minsky/hooks/verify-subagent-model.ts:92-101, 171-186), pinning writer/manifest agreement. |
| SC4 — The existing repo-tree file is dispositioned (migrated or deleted with a reason), not orphaned. | Unverifiable | The disposition concerns an on-disk file under a developer/operator workspace (e.g., .minsky/subagent-model-mismatch.jsonl) rather than an in-repo artifact. This review cannot observe filesystem state outside the repository. No in-repo migration script is part of the current diff segment to assert against. |
| SC5 — A check asserts SC2 mechanically. Coordinate with mt#4811/mt#4755/mt#4778 on ONE behaviour-scoped check rather than a fourth directory-scoped one — see the note below. | Met | scripts/lib/repo-rooted-telemetry-paths.ts (new) implements a behaviour-scoped scan that detects repo-rooted telemetry path constructions; scripts/lib/repo-rooted-telemetry-paths.test.ts (new) runs it over the real tree plus synthetic controls, asserts zero unallowlisted hits, validates allowlist reasons, and guards namespaced and multi-line joins. |
Adoption sweep
| Symbol | Kind | Consumers found | Classification | Notes |
|---|---|---|---|---|
| scripts/lib/repo-rooted-telemetry-paths.findRepoRootedTelemetryPaths | function | scripts/lib/repo-rooted-telemetry-paths.test.ts:21-33 — imported and exercised in multiple tests | Adopted | Utility is internal to scripts/, consumed by its dedicated test suite; no additional wiring required. |
Documentation impact
- updated-in-pr — This PR changes documented behavior and updates the docs accordingly. The inventory now reflects
subagent-model-mismatchas state-dir/flat and removes the lastlocation: "repo"exception. Verified in docs/architecture/guard-calibration-stream-inventory.md — §C/SPECIAL stream preface and section I additions align with the code changes in packages/domain/src/guard-events/stream-sources.ts.
Affected: docs/architecture/guard-calibration-stream-inventory.md
There was a problem hiding this comment.
Independent adversarial review (Chinese-wall)
Reviewer: minsky-reviewer[bot] via openai:gpt-5
Tier: 2
Verification complete. The prior BLOCKING issues are addressed: both .minsky and generated .claude hooks now write to the state dir, not the repo; the ingest manifest retires the "repo" location entirely with corresponding removal of the branch in resolveStreamPath; and tests pin writer/reader path agreement. A behavior-scoped scanner plus tests enforce no unallowlisted repo-rooted telemetry paths remain, with justified allowlist entries. Documentation is updated coherently (ADR-028 amendment and stream inventory). I find no new critical defects introduced by this change. SC4’s out-of-repo file disposition is inherently unverifiable from the diff and is recorded as such. Approving.
Spec verification
| Criterion | Status | Evidence |
|---|---|---|
| SC1 — A decision is recorded between flat and project-keyed for the special family, with its reason, before the path is changed. | Met | Decision and rationale are recorded in docs and code comments: docs/architecture/adr-028-guard-hook-dispatcher-consolidation.md:+483-500 adds the mt#4816 amendment stating "state-dir and flat" with reasoning; packages/domain/src/guard-events/stream-sources.ts:15-23 module header notes mt#4816 finishes it; .minsky/hooks/verify-subagent-model.ts:149-171 documents the flat decision and the ingestion-based project_id rationale. |
| SC2 — verify-subagent-model.ts no longer resolves its log under the repo root. | Met | .minsky/hooks/verify-subagent-model.ts:69-89 switches MISMATCH_LOG to a bare file name and introduces getSubagentModelStateDir/getMismatchLogPath joining the state dir; appendMismatchRecord now uses getMismatchLogPath (lines ~171-190) and no longer calls findRepoRoot/resolve. |
| SC3 — stream-sources.ts has no location: "repo" rows left, and the writer resolves to the same path resolveStreamPath computes for that row. Verify by asserting the two agree, not by reading both. | Met | packages/domain/src/guard-events/stream-sources.ts:47-58 retires "repo" from GuardEventStreamLocation; the SPECIAL stream row at :110-123 is location: "state-dir" with relativePath "subagent-model-mismatch.jsonl". The hook test .minsky/hooks/verify-subagent-model.test.ts:41-100 imports resolveStreamPath/resolveStateDir and asserts getMismatchLogPath(env) === resolveStreamPath(row, ...), including with and without MINSKY_STATE_DIR, and that all manifest rows are state-dir. |
| SC4 — The existing repo-tree file is dispositioned (migrated or deleted with a reason), not orphaned. | Unverifiable | The disposition concerns a file under a managed project's working tree (out-of-repo). The diff updates code and docs but cannot carry an external filesystem deletion/migration; no in-repo artifact can prove it. Per Out-of-repo references policy, this is not verifiable from the diff. |
| SC5 — A behaviour-scoped check asserts SC2 mechanically (one check coordinated with sibling tasks). | Met | New check and tests added at scripts/lib/repo-rooted-telemetry-paths.ts and scripts/lib/repo-rooted-telemetry-paths.test.ts. The test exercises the real tree and synthetic controls and enforces no unallowlisted repo-rooted telemetry paths exist; allowlist entries are justified and kept fresh. |
Documentation impact
- updated-in-pr — Documentation updated alongside code: docs/architecture/adr-028-guard-hook-dispatcher-consolidation.md adds a second amendment (mt#4816) clarifying the move to state-dir and retirement of the "repo" location; docs/architecture/guard-calibration-stream-inventory.md updates §C and §D to reflect subagent-model-mismatch now being flat under the state dir and notes that no streams are repo-rooted anymore.
Affected: docs/architecture/adr-028-guard-hook-dispatcher-consolidation.md, docs/architecture/guard-calibration-stream-inventory.md, docs/architecture/hook-module-inventory.md
There was a problem hiding this comment.
Independent adversarial review (Chinese-wall)
Reviewer: minsky-reviewer[bot] via openai:gpt-5
Tier: 2
The PR resolves the prior BLOCKING issues: the subagent-model mismatch writer now targets the state dir, the ingest manifest retires the "repo" location (type-level enforcement), resolveStreamPath’s repo branch is removed, tests pin writer/reader agreement and manifest-wide compliance, and docs are updated. A behavior-scoped scanner with tests enforces SC5. However, a new discrepancy introduced by this change breaks writer/reader agreement when MINSKY_STATE_DIR is set to an empty/whitespace value: the hook’s getSubagentModelStateDir returns the raw env without trimming, while the reader’s resolveStateDir treats empty-as-unset. This can cause the writer to write a relative path into CWD, violating SC2. Please mirror the reader’s empty/whitespace handling in both .minsky and .claude copies and add a test asserting agreement for MINSKY_STATE_DIR="". With that fixed, the PR will be ready to merge.
Findings
- [BLOCKING] .minsky/hooks/verify-subagent-model.ts:245 —
getSubagentModelStateDirdoes not trimMINSKY_STATE_DIR, diverging from reader; empty-string env causes writer/reader disagreement and can write to CWD
The newgetSubagentModelStateDir(env)returnsenv["MINSKY_STATE_DIR"]verbatim when set, without trimming or validating non-empty. The ingest reader’sresolveStateDir(packages/domain/src/guard-events/ingest-runtime.ts:36-41) explicitly treats empty/whitespace as unset and falls back to~/.local/state/minsky. With the current hook, settingMINSKY_STATE_DIR=""(or whitespace) yieldsjoin("", MISMATCH_LOG)⇒ a relativesubagent-model-mismatch.jsonlwritten to the process CWD, violating SC2 and breaking the writer/reader path-agreement the tests aim to pin. This discrepancy was introduced by this PR and is untested (tests cover set/non-set, not empty-set). Fix: mirror the reader’s logic — trim and treat empty as unset (fall back to homedir) ingetSubagentModelStateDir. Update the.claude/mirror and add a test asserting agreement whenMINSKY_STATE_DIRis set to an empty string.
Spec verification
| Criterion | Status | Evidence |
|---|---|---|
SC1 — A decision is recorded between flat and project-keyed for the special family, with its reason, before the path is changed. |
Met | Decision and rationale are recorded in code comments and docs: see packages/domain/src/guard-events/stream-sources.ts module header additions (mt#4816 decision notes) and docs/architecture/adr-028-guard-hook-dispatcher-consolidation.md (second amendment block added by this PR) describing FLAT for special with justification (project attribution resolved from session_id). |
SC2 — verify-subagent-model.ts no longer resolves its log under the repo root. |
Met | .minsky/hooks/verify-subagent-model.ts and its generated .claude/ mirror remove findRepoRoot/resolve(...) and introduce getSubagentModelStateDir + getMismatchLogPath to write under the state dir, keyed by MINSKY_STATE_DIR with homedir fallback. Callsites updated to appendMismatchRecord(decision.record) which uses the state-dir path. |
SC3 — stream-sources.ts has no location: "repo" rows left, and the writer resolves to the same path resolveStreamPath computes for that row. Verify by asserting the two agree, not by reading both. |
Met | packages/domain/src/guard-events/stream-sources.ts changes GuardEventStreamLocation to only "state-dir" and updates the subagent-model-mismatch row to location: "state-dir" with relativePath: "subagent-model-mismatch.jsonl". Tests in .minsky/hooks/verify-subagent-model.test.ts import resolveStreamPath/resolveStateDir and assert equality with getMismatchLogPath (AT3), and also assert the manifest has no non-state-dir rows. |
| SC4 — The existing repo-tree file is dispositioned (migrated or deleted with a reason), not orphaned. | Unverifiable | Disposition of any already-written file in a developer's working tree is out-of-repo. The PR updates code and docs but any on-disk deletion/migration cannot be verified from the diff; the task spec itself notes this as deploy/runtime evidence. |
| SC5 — A check asserts SC2 mechanically. Coordinate with mt#4811/mt#4755/mt#4778 on ONE behaviour-scoped check rather than a fourth directory-scoped one. | Met | New behaviour-scoped scanner added at scripts/lib/repo-rooted-telemetry-paths.ts with comprehensive tests in scripts/lib/repo-rooted-telemetry-paths.test.ts. It scans .minsky/hooks, src, and packages for repo-rooted telemetry path expressions, carries an allowlist with reasons, asserts no unallowlisted matches, and asserts allowlist entries remain live. |
Adoption sweep
| Symbol | Kind | Consumers found | Classification | Notes |
|---|---|---|---|---|
| .minsky/hooks/verify-subagent-model.getMismatchLogPath | function | .minsky/hooks/verify-subagent-model.test.ts: imports getMismatchLogPath and asserts equality with resolveStreamPath (AT3) |
Adopted | New exported helper introduced by this PR; exercised by the hook’s own test to pin writer/reader agreement. |
Documentation impact
- updated-in-pr — This PR changes documented behavior (retiring repo-rooted telemetry and making
subagent-model-mismatchstate-dir/flat) and updates the docs accordingly:docs/architecture/adr-028-guard-hook-dispatcher-consolidation.mdadds a second amendment recording the change, anddocs/architecture/guard-calibration-stream-inventory.mdupdates the stream’s location and explanatory prose.
Affected: docs/architecture/adr-028-guard-hook-dispatcher-consolidation.md, docs/architecture/guard-calibration-stream-inventory.md, docs/architecture/hook-module-inventory.md
Summary
verify-subagent-model.ts:234resolved its log asresolve(findRepoRoot(cwd), ".minsky/subagent-model-mismatch.jsonl")— the last telemetry writer depositing a file into whatever Minsky-managed project the agent happened to be in, the condition mt#4748's SC2 forbids. Unlike its siblings nothing was BROKEN: reader and writer agreed. The defect was only that the place they agreed on was a working tree.This is the fourth and last task in the family (mt#4752, mt#4778, mt#4811, this).
SC1's decision: FLAT, not project-keyed — and why
The spec left this open because
resolveStreamPathproject-keys only thecalibrationandevaluationfamilies;subagent-model-mismatchisfamily: "special", so flipping it tostate-dirlands it flat, and that decides whatspecialmeans. Three pieces of evidence, in the order that settled it:attachProjectIds(ingest-service.ts:313-328) takessourcePathbut uses it only for thesource_pathcolumn;projectIdcomes solely fromresolveProjectIds(sessionIds), resolving each record'ssession_idthroughconversationRunStateTable.projectId. This was the falsifier for the decision's own premise and was run before the decision was written.project_idat scale —fire-log1,003,816 rows non-null,guard-health-log121. Both of this stream's own rows are already inguard_eventswithproject_idnon-null.session_id,dispatching_agent_id,tool_name,requested,resolved) describes model-tier resolution on anAgentdispatch. Nothing in it is a property of the repository being worked.Key changes
.minsky/hooks/verify-subagent-model.ts—MISMATCH_LOGis now the BARE name, byte-identical to the manifest row'srelativePath;getSubagentModelStateDir/getMismatchLogPathresolve flat under the state dir.appendMismatchRecordtakesenv(injected, not read from the module) and no longer takescwd.getMinskyStateDir()(@minsky/shared/paths) keys onXDG_STATE_HOMEonly — mt#3965 records that as deliberate — while the ingest reader'sresolveStateDirkeys onMINSKY_STATE_DIR. Using the shared helper here would have reproduced exactly the writer/reader split mt#4811 found live inask-form-lint, where the sweep read an empty corpus and reported "this guard never fired." This writer follows the reader's convention instead, and AT3 pins the agreement so the choice cannot silently drift. Note the wider split is pre-existing and out of scope: the calibration writers use the shared helper while the ingest reader does not, so they diverge underXDG_STATE_HOME."repo"is retired fromGuardEventStreamLocation, and itsresolveStreamPathbranch is deleted. Re-introducing a repo-rooted stream is now a type error rather than something a later sweep has to find — strictly stronger than the grep AT4 asked for.roots.repoRootstays load-bearing: it isprojectStateKey's input.scripts/lib/repo-rooted-telemetry-paths.ts).SC5: why the check is shaped this way
Three prior sweeps were scoped by DIRECTORY (
.minsky/hooks/**) while the population is defined by BEHAVIOUR — which is why none could seeask-form-lint-calibration.tsinsrc/adapters/shared/commands/. This scans for the behaviour: a path expression rooted at the repo whose target is telemetry, over.minsky/hooks,src, andpackages.It deliberately does NOT key on the fs write call, and that was measured rather than assumed. A write-keyed draft was tried first and found neither remaining real writer:
calibration-review-cadence-detector.tsandcalibration.tsboth write through a one-hop helper (writeLastWarnedStore,writeFileMkdir). Computing a repo-rooted telemetry path at all is the smell.Against the real tree it returns exactly 3 files, all genuine, all allowlisted with reasons:
require-execution-evidence-before-merge.ts— TRACKED, not accepted: mt#4755 owns it, and four of its five streams were observed still writing into the repo tree today.calibration-review-cadence-detector.tsandcalibration.ts— the watermark / last-warned / claims stores. A deliberate carve-out whose justification is provisional: the code says they "stay repo-rooted … still correctly gitignored," which is a minsky-repo-only property and therefore the premise mt#4748 exists to retire. Recorded in mt#4816's## Contextas a boundary case someone must decide, not close silently.A
findStaleAllowlistEntriestest fails when an entry outlives the writer it excused — which is how mt#4755's entry will announce that it is ready to be removed.What it cannot see, stated rather than implied: a text scan over single-line assignments. It misses a path assembled across statements, one built by concatenation, and one whose root arrives through a parameter. It is a floor under a class that had no mechanical check at all, not a proof of absence.
Success criteria
## Decision — SC1.guard_events— same two timestamps, ingested 2026-08-12 — so migrating would have created a duplicate and leaving it would have made a 62nd orphan for mt#4777.AT4 did not hold as literally written — reported, not narrowed
grep -c 'location: "repo"' packages/domain/src/guard-events/stream-sources.tsreturns 1, not 0. The surviving match is the COMMENT recording the retirement — prose about the change satisfying a pattern written to detect its absence. The criterion's own text is amended in the spec. The property it is about is verified two ways a comment cannot satisfy: the type-level retirement, and a manifest-wide assertion.Consumers enumerated (gate (h))
Rows unioned: Function / type signature (
MISMATCH_LOGandappendMismatchRecord, both exported, one caller per copy, no external importers) and Config key / schema field (the manifest row is a schema field value the ingest reads, and it is documented indocs/, which is why the doc consumers below are in scope and would not be under the first row alone)..claude/hooks/verify-subagent-model.ts— regenerated viabun run src/cli.ts compile, verified to carry the change..codex/hooks/verify-subagent-model.ts— currently UNTRACKED; open PR feat(mt#3854): Make .codex a compile output so the harness config stops fossilizing #3253 (mt#3854) ADDS it as a compile output. Coordinate: whichever lands second regenerates. Read from that PR's actual 188-file list, not inferred.ingest-runtime.ts:78— thelocation === "repo"branch, removed.guard-calibration-stream-inventory.md— §C row, §C prose, and §D's now-false "only §C'ssubagent-model-mismatchremains repo-rooted."guard-events-schema.ts:20— checked, no change needed: it enumerates the stream by name and makes no location claim.hook-module-inventory.md— the census test caught that this hook newly grep-matchespackages/domain(three doc-comment references) without importing it, so it joined the Divergence table; count bumped 33 → 34.Testing
Execution evidence:
AT1 + AT3 + SC3 — the hook's own suite (6 new cases):
AT3 is asserted against
resolveStreamPathandresolveStateDirimported from the reader, not against a hand-written expected path — a hand-written expectation is precisely what would have passed while ask-form-lint's reader and writer disagreed. Both theMINSKY_STATE_DIR-set and unset branches are asserted; asserting only the temp-dir case would pass even if the two DEFAULT branches diverged, which is the configuration nearly every real invocation runs in.SC5 — the check against the real tree, plus 5 synthetic controls including the historical
ask-form-lintwriter it must flag (included in the 34 above).Full hooks suite (
ROOTSexcludes this tree from the gated runner, so this is required before push):One earlier run of this suite showed
memory-search > both silent on a trivial (affirmative) promptfailing on a 15.0s timeout. Confirmed a flake, not a regression: the diff touches nothing named memory, the file passes 72/72 in isolation, and the re-run above is clean.Related-test selector:
Typecheck — 0 errors across all 8 projects,
validatedWorkspacethe session dir (infra/skipped with its documented reason). Lint — 0 errors, 0 warnings, 4259 files.Negative control — control A: the manifest row and the reader's branch reverted to repo-rooted
Negative control — control B: the writer reverted to repo-rooted, manifest and reader left intact
Why two controls rather than one full revert, stated because the difference matters (mt#4512). A single full revert of all three production files was attempted FIRST and produced
0 pass / 1 fail— the whole test FILE failed to load, because the revert removes an exported symbol the new tests import. A load failure is not evidence the assertions work, so that control was discarded rather than reported. The two controls above isolate the two halves: A reverts the reader/manifest (AT1 correctly stays green — it asserts the writer), B reverts the writer. Together every new assertion is shown capable of failing, for its own reason.Deploy verification
Ran the predicate over the actual changed files rather than recalling the pattern set:
So this IS deploy surface, and no
[no-deploy-impact]claim is made here or in any commit message. Post-merge I will calldeployment_wait-for-latestwithnotBefore= the merge timestamp andexpectCommitSha= the merge SHA, readbuildIdentity, and onindeterminatecorrelate the deploy workflow run'shead_shaagainst the merge commit rather than treating a status code as identity.One post-merge re-check beyond the deploy: the OLD generated hook is still live in main until this merges, so an
Agentdispatch with a model mismatch in that window could recreate the repo-tree file SC4 removed. Mismatches run ~2 per 26 days, so this is unlikely; I will re-check the path after merge.