feat: deterministic PreToolUse enforcement of the reviewer read-scope bound (2.3.4) - #545
Conversation
Slice evidence - issue 539 final tree (tip 132c44a)All runs from the worktree, Slice 1: smoke+unitRed disposition: t205 scenario 11 green-alone ( Slice 2: integrationRed disposition: both green-alone in the filtered rerun below; both are live-SDK Slice 3: red rerun (filtered, green-alone gate)Earlier full-tier contextAn earlier full smoke+unit+integration run on the pre-review-fix tree |
… bound The per-unit reviewer read-scope bound (stage-protocol 12a) gains a deterministic twin: core/hooks/aidlc-reviewer-scope.ts, the framework's second flow-altering hook. While a conductor-written dispatch record (<record>/.aidlc-reviewer-dispatch.json, written at 12a step 1, deleted at step 3, 6h staleness janitor) is fresh, the dispatched reviewer's tool calls that reach into sibling units' construction/ paths - file reads, writes, and grep/glob/shell patterns that span siblings - are refused (exit 2 + a redirecting stderr reason) unless the target is on the record's exempt list (consumes contracts, stage file, Q&A file, and any conductor-named integration-point file). Every refusal emits a REVIEWER_SCOPE_BLOCKED audit event. Fail-open on every ambiguity, with AIDLC_DISABLE_REVIEWER_SCOPE_HOOK=1 as the deterministic off-switch. Registration per harness: Claude Code settings.json gains its first PreToolUse entry (agent_type identity, probe-verified); Kiro CLI wires the adapter's new reviewer-scope target inside the two reviewer agents' own JSON configs (registration IS the identity); Kiro IDE ships a new aidlc-reviewer-scope.kiro.hook (window-scoped best-effort); Codex adds a PreToolUse row to HOOK_WIRING + a trust entry (agent_type verified on 0.142.5), with stderr now riding the duplicate-delivery replay cache.
t218 pins the pure matcher (20-case decision table), the dispatch-record lifecycle against the shipped hook (block/allow/stale-janitor/off-switch/ audit emit), and the four harness registrations + the 12a prose. Collateral pins updated: t01/t02 hook roster (11 -> 12), t01 plan parity (67 -> 68), t150 codex hooks.json events (+PreToolUse), coverage-registry allowlist. Docs: hooks chapter gains the reviewer-scope section and the second flow-altering contract; audit taxonomy 70 -> 71 across audit-format.md, 12-state-machine.md, 06-hooks-and-tools.md; hook-count sweeps in AGENTS.md + guide pages; porting guide notes the two block channels and the scoped-registration identity pattern. CHANGELOG 2.2.17 + README badge.
The base PRs merged while this branch was in flight: PR 538 (2.2.16, the prose bound), the Kiro IDE hook rework (2.2.17: USER_PROMPT context with toolArgs always empty), and the CLAUDE_PROJECT_DIR quoting fix (2.2.19). Re-slots: version 2.2.17 -> 2.2.20, test t218 -> t220 (t218/t219 taken). Consequences absorbed: the Claude PreToolUse entry now quotes CLAUDE_PROJECT_DIR (t219 pin bumped 13 -> 14); the Kiro IDE registration is DROPPED rather than rebased - the reworked IDE adapter's payloads carry no tool inputs, so a pre-tool matcher has nothing to inspect there; t220 pins the deliberate absence and the docs/CHANGELOG state the gap.
Codex-review findings, all reproduced before fixing and pinned in t220: 1. Dot-dot traversal: construction/U03/../U01/design.md was judged on the first segment after construction/ and allowed. Path components are now normalized with .. collapsed against parents before judging. 2. Bare search roots: 'grep -rn TODO construction' / 'find construction' name the whole tree with no construction/ literal, so the token scan missed them. A quote-stripped pass now blocks any token ending in the bare construction component; the word inside quoted content regexes stays content. 3. Codex apply_patch Delete File / Move to directives were not fanned out to the scope check (patchedFiles covers only Add/Update for the audit surface); the reviewer-scope target now includes them as Edit-class mutations.
REVIEWER_SCOPE_BLOCKED is event 71; both baseline pins updated with the lineage comment extended.
Fresh-eyes Claude review verdict APPROVE-WITH-FIXES; all findings fixed: - Kiro registrations now pass their own agent name as argv[3], forwarded as agent_type, so the core hook compares against the dispatch record's reviewer field on Kiro too - a stale record naming a different reviewer fails open instead of scoping the wrong agent (bare scoped_registration remains as the no-arg fallback). - blockReason now tells a reviewer blocked on a shell variable to use the literal unit name, and one blocked on an unquoted bare word to quote the pattern; the matcher comment no longer claims variables are invisible (they block conservatively). - The four SKILL.md verdict sentences read the Review section 'from the primary artifact' (the inserted delete step had left 'its' pointing at the dispatch record). - Codex adapter persists the ANSWERED exit code (2 or 0) so a duplicate delivery replays exactly what the original answered even if the core hook ever crashed with exit 1. - Doc-count stragglers: core/templates/onboarding.md (shipped CLAUDE.md/ AGENTS.md) and docs/reference/01-architecture.md test-levels row bumped to 12 hooks; stale 67 comment in t01.
- Troubleshooting guide: hook roster names all 12 (adds reviewer-scope), the settings.json check names PreToolUse, and a new section documents the reviewer read-scope refusal message, the AIDLC_DISABLE_REVIEWER_SCOPE_HOOK=1 off-switch for repos with their own construction/ source dirs, and the stale-record cleanup path. - Kiro CLI adapter: the write side of the reviewer-scope target now iterates a batch operations[] collection like the read side, so a batched fs_write across siblings cannot bypass on a missing top-level path. - The blocked-call audit emit is time-bounded (5 x 50ms lock budget, emit-unlocked inside) so a lock-starved Bolt fan-out cannot stretch a fast refuse to 5s; a contended lock drops the advisory row and records a hook drop instead. - The missing-dispatch-record advisory is rate-bounded to one drop line per 10 minutes (marker mtime) so a conductor bug plus a chatty reviewer cannot flood the drops file. - t220 pins the bare 'construction' Grep search root; stage-protocol 12a notes the record's stage field lands verbatim in the audit row. - Follow-up drafted (tmp): shard the dispatch record per unit before any parallel per-unit review lands.
Version re-bumped 2.2.20 -> 2.3.4 (2.3.1-2.3.3 landed while this PR was in review) and the unit test file re-slotted t220 -> t221 (the tier-projection PR took t220), including the coverage-registry pin. The two kiro reviewer agent JSONs merged with the tunable-tier projection: the authored files keep this PR's preToolUse hooks and drop the authored model field (the model dial is projection-owned since 2.3.1 - the build stamps claude-sonnet-4.5 for the balanced tier). The troubleshooting doc unions this PR's 12-hook count with the 2.3.0 hook-drops prose.
6d51552 to
66f9c4b
Compare
leandrodamascena
left a comment
There was a problem hiding this comment.
CHANGES REQUESTED.
I like where this is going — a deterministic backstop for a bound that prose lost is the right idea. But I extracted evaluateReviewerScope from the hook and ran it under bun with a {unit:"U03", exempt:[]} record, and I got a dispatched reviewer to read a sibling unit's construction/ content through about a dozen inputs, several of them the way a reviewer would naturally type the command. The round-1 hardening does hold against the shapes it was written for — I confirmed construction/U01/design.md, construction/*/*/*.md, the single-token .., and the whole-path quote all block. The problem is everything one step to the side of it.
The root cause is one design choice: the matcher is a lexical scan for the literal token construction, but the bound it defends is a path-containment property. Those aren't the same thing, and the gap is every finding below. All of these return block:false on the real matcher (I ran each):
- Recursive search rooted above
construction/.grep -rn X .,rg Contract docs/aidlc,find docs/aidlc -name design.md -exec cat {} +. The construction subtree lives under.../intents/<intent>/construction/<unit>/, so any recursion rooted at an ancestor carries noconstructiontoken and is allowed — while descending into every sibling. This is the exact "14 recursive greps" the PR names as motivation; the reviewer just roots one level up instead of typingconstruction/*/. - Pathless Grep/Glob.
Grep {pattern:"PaymentGateway"}with nopath/globcontributes zero candidate strings, so the matcher loop never runs and the tool recurses cwd. The reviewer JSONs shipexecute_bash/fs_read, so it's reachable. - Case.
Read {file_path:"Construction/U02/design.md"}— one capital letter opens the real lowercase file on macOS/Windows. NotoLowerCaseanywhere. - Wildcard in the word / quote-split /
cd+relative / bare relative.cat construction*/U01/x,cat "construction"/U01/x,cd construction/U03 && cat ../U01/design.md,Read {file_path:"../U01/design.md"}— all allow. The shell resolves them; the matcher never sees a contiguousconstruction/<sibling>token.
There's also a fail-closed twin of the case bug: scoped to U01, Read {file_path:"construction/u01/design.md"} returns block:true — a reviewer's own-unit read refused. Lower impact (§12a hands it the literal path), but same missing FS-case normalization, so fix both together.
My suggestion is to move the decision off the string and onto the resolved path: the hook already has the record's unit and path, so judge whether a resolved path or a search root lands inside a sibling construction/<other-unit>/, rather than whether the string spells construction. Add case-insensitive comparison, treat a pathless recursive Grep/Glob as rooted at cwd, and widen the tool allow-list (NotebookRead and anything outside the six-name matcher fail open today). Then re-pin t221 with the ancestor-root, pathless, case-variant, and cd-relative shapes — every current block case names construction explicitly, which is why the suite passed.
One structural note, not a blocker: the only writer of .aidlc-reviewer-dispatch.json is prose in the four SKILL.md orchestrators. If the conductor skips step 1 the hook fails open. I think that's acceptable — it's defense-in-depth over the reviewer's own prose bound, fail-open is the safe direction, and forgetting the write falls back to today's baseline, not worse. But it means this is a second layer, not a replacement, and the matcher coverage is what has to change before it earns the "deterministic twin" framing.
Non-blocking: Kiro IDE prose-only is the right call and well documented; product-lead wires the hook but reviews no per-unit stage so it always fails open (harmless, but dead wiring); the block-reason text tells a blocked reviewer to "quote the pattern," which nudges toward the exact quoting that flips a block to an allow — worth changing regardless.
Replace the lexical construction-token matcher with path/root containment checks for reviewer-scope enforcement. Block ancestor-root recursion, pathless Grep/Glob, case variants, wildcard or quote-split construction components, and cd-relative sibling reads while keeping current-unit and exact-exempt paths allowed. Widen the Claude PreToolUse matcher to file/search/shell surfaces, update docs/CHANGELOG, regenerate dist, and pin the review bypass shapes in t221.
|
Addressed the blocking reviewer-scope findings in What changed:
Verification:
|
PR #545 took 2.3.4 and the t221 slot on v2. Version trio re-bumped to 2.3.5 (aidlc-version.ts + CHANGELOG heading + README badge); the four plugin-selection tests re-slot to t222-t225 (t221 is the reviewer-scope hook's); audit-event pins merge both sides' additions (72 events, 19 categories: REVIEWER_SCOPE_BLOCKED from #545 + PLUGIN_SELECTION_CHANGED from this branch); coverage registry + ratchet regenerated; dist regen in sync.
…me plugin selection (2.3.5) (#550) * feat: rename plugin ownership frontmatter from bundle: to plugin: One noun everywhere: the distribution unit, the ownership tag on stages/contributions, and (in later commits) the selection surface are all "plugin". "bundle" was legacy naming from the extension-mechanism era; a plugin defines exactly one ownership tag in every real case, so the two-word split bought nothing and confused authors. The word "bundle" is deliberately left free for a possible future collection-of- plugins concept. - Schema: plugin: is canonical; bundle: remains a deprecated read-side alias (both keys known). Both present with different values fails validation; the validator returns a normalized clone exposing only plugin. parseStageFrontmatter synthesizes plugin from an alias-only file; emitStageFrontmatter writes plugin: only (and now carries number/name/plugin in FIELD_ORDER so plugin-authored frontmatter round-trips). - Compose hook: contributions read plugin: first, fall back to bundle:, drop-log a conflict. Fragment sentinel markers were already <!-- plugin:... --> and are byte-unchanged. - test-pro, docs 18 + 10, and fixtures author plugin:; t188 pins the alias contract (one bundle:-only fixture still composes; a conflicting pair drop-logs), t62 pins schema accept/reject, t64 pins parse->emit-> parse round-trip for plugin metadata and the alias. No behavioral change for installs authored with bundle:. * feat(plugins): project and compose scopes, agents, and knowledge buckets A plugin can now ship scope definitions, agent personas, and per-agent knowledge alongside stages/sensors/tools/contributions. Proven at scale by a customer pilot (a real plugin shipping 3 scopes, 18 personas, 90 knowledge files) before landing here. - packager: scopes/agents/knowledge join the plugin projection contentDirs; walk() is recursive so knowledge/<agent-slug>/ subtrees project without special cases. - compose hook: three copyTreeNoClobber calls route the buckets into the harness scopes/agents/knowledge dirs - additive-only, same no-clobber + {{HARNESS_DIR}} substitution + collision drop-log as stages. A plugin ADDS a persona; it never overwrites a core one. - naming: plugin scope files are <plugin>-<name>.md and agent files <plugin>-<role>-agent.md - the plugin prefix replaces core's aidlc- filename prefix, stem == frontmatter name. - test-pro gains a scope, an agent, and a knowledge dir as living proof; its content test's agent roster now unions the plugin's own agents/ bucket so a stage naming a plugin-shipped persona validates at author time (previously only the core roster counted - a plugin could never satisfy it). - stage-body-non-empty guard in the plugin content tests AND t188: a frontmatter-only stage file compiles, routes, and passes every other check while being behaviorally dead (a real pilot regression shipped 23 such stages); now it fails loudly at author and compose time. - doc 18 Section 6 rows flip from deferred to implemented for the three buckets; memory/ stays deferred deliberately (it targets the workspace method tree where core ships org/team.md - real collision semantics that warrant their own design pass). * feat: plugin ownership through compile, plugin-namespaced runner skills, compose-time runner regen Wires plugin: ownership end to end - previously the schema accepted it and the compiler dropped it, so nothing downstream could act on it. - compile: plugin: carries from stage frontmatter onto the compiled stage-graph.json node (absent on core nodes, so core emission is byte-identical). Invariants: a plugin-owned slug must start with "<plugin>-", and plugin: aidlc is rejected (core-ness = omitting the field). Authored number/name stay on the existing re-seeded path - carrying them is deliberately out of scope here. - runner skills: a plugin-owned stage's runner is named by its own already-prefixed slug (/test-pro-integration); core stages keep aidlc-<slug> byte-identically. Drift detection parses the driven slug from the skill body instead of assuming the aidlc- prefix. - scope runners: the hardcoded FIRST_BATCH constant is replaced by scope frontmatter - runner: true selects a scope into the default batch. The four shipped scopes carry the flag so shipped output is unchanged; FIRST_BATCH remains as a derived compatibility export. Plugin-owned scopes (plugin: in frontmatter) get bare-name runners. - compose hook: after a successful recompile it now regenerates stage runners (and scope runners when the plugin ships scopes/), so a composed plugin stage's slash command appears the moment it composes in - previously routable but not typeable. Failures drop-log; installs without a skills dir (codex-style, skills emitted at package time) skip with a note. New self-heal probe: plugin stage in the graph but missing its runner dir triggers regeneration even when nothing else changed. - t221-plugin-runner-naming pins carry-through, both invariants, the naming rules, and data-driven batch selection; t188 asserts composed runner skills exist and core runners are untouched. * feat: enforce filename==identity at compile + duplicate-name guards in loaders The naming conventions the docs present as binding were enforced for sensors only; stages, scopes, and agents silently tolerated mismatches that break path-derived lookups (runtime stage_file paths, knowledge dirs, drift guards) or last-write-win. With plugins now composing stages/scopes/agents into shared install dirs, both hazards became plugin-vs-core collisions - enforcement is load-bearing. - compile: a stage file whose name stem differs from its frontmatter slug fails loudly (file, stem, slug all named). The schema comment that punted this check to "the parser" now points here. - loaders: duplicate scope names and duplicate agent slugs across two files throw at load naming BOTH files (previously silent last-write- wins), mirroring the sensor loader's discipline. New AIDLC_AGENTS_DIR env seam + _resetAgentsForTests, matching the scopes/sensors seams. - doctor: advisory rows for agent/scope files whose stem does not match their declared name (plugin-owned files must match exactly; core scopes accept the aidlc- filename prefix). Advisory, not hard-fail: a mismatch is recoverable misconfiguration. - plugin content tests gain stem==name assertions so plugin authors catch it before compose; t222-naming-enforcement pins the compile error, both duplicate guards, the env seam, and the doctor advisory. * feat: generated Stage Graph table in SKILL.md + state-template becomes the contract Two generated-surface cleanups that unblock install-time plugin selection: every surface that enumerates stages must derive from the compiled graph, because selection filters that graph. - stage-table: new utility verb mirroring scope-table - renders the SKILL.md Stage Graph table from compiled stage-graph.json inside BEGIN/END markers, --check byte-compares for drift. All four harness SKILL.md copies now carry the generated region. The hand-maintained table had already drifted in two Mode cells (reverse-engineering and code-generation carried prose annotations the compiled graph does not have; the agent names they duplicated are already in the Lead/Support columns) - the generator is authoritative. - compose hook refreshes both generated regions (scope-table + stage-table) after a successful recompile, so composed plugin stages appear in the table the moment they compose in; missing SKILL.md or marker skips with an advisory drop. t188 asserts test-pro's stages land in the composed region. - t32 repointed: bidirectional hand-sync check becomes stage-table --check drift guard + a coverage assertion that the rendered table names every compiled stage exactly once. - state-template.md now documents the state-file section/field contract only (headings, field bullets, checkbox legend, one placeholder row); the engine is the sole source of stage enumeration - the template's hand-enumerated stage list was dead data that had drifted. state-init and the reference/troubleshooting docs point at the compiled grid and the --doctor resync path instead of the template. * feat: install-time plugin selection - select-plugins, closure guard, transactional regen Plugins add, the install selects. An install can now choose which plugins' content its users see: a plugins list in tools/data/ harness.json filters the graph compile to enabled plugins (core is the implicit "aidlc" plugin; the three initialization stages are always included), and every generated surface (runner skills, scope grid, SKILL.md stage/scope tables, state rows, doctor) follows the filtered graph automatically. A test-pro-only install shows only /test-pro-* commands and the test-pro scope while core remains installed and re-enableable. An absent plugins key means everything is enabled, so existing installs are unaffected and the shipped dist is byte-identical. - select-plugins utility verb: validates requested names against the known plugin set, writes the selection, recompiles, regenerates runners (pruning newly-disabled ones) and both SKILL.md generated regions - one step, TRANSACTIONAL: harness.json + stage-graph.json + scope-grid.json snapshot before mutating; any late-step failure restores all three and re-runs the regeneration chain against the restored selection, with a loud recovery message if that also fails. - compiled graph persists the FULL stage set; disabled nodes carry enabled: false (key omitted when enabled). loadStageGraph() filters for all runtime consumers; loadStageGraphAll() serves doctor and selection tooling. Stage numbers are seeded over the full graph, so a disable/re-enable round trip is number-stable (pinned byte-for-byte). - closure guard: a selection leaving an enabled stage consuming a required artifact whose only producers are disabled fails compile naming consumer, artifact, producers, and the plugin(s) to enable. - scope fallbacks degrade gracefully under a core-disabled selection: sole-enabled-plugin installs fall back to that plugin's first scope; multi-plugin selections keep the hard error. - compose does not auto-enable: composing a plugin into an install with an active selection that excludes it lands the files and records an advisory drop naming the select-plugins command; the self-heal probes respect enabled flags so a disabled stage is not a compile trigger. - doctor: enabled plugins + per-plugin stage counts; hard-fails on selection/graph disagreement and on torn-run leftovers, naming the recovery command. - test-pro's cross-plugin consumes edges relax to required: false so a test-pro-only selection satisfies closure (its stage bodies already describe those inputs as use-when-present). - t223-plugin-selection: full journey over the shipped test-pro plugin - select, verify surfaces, re-enable with byte-identical numbers, prune, unknown-name error, rollback on late-step failure, closure-guard message; t188 gains the compose-not-enabled advisory case. * feat: decouple the walking-skeleton ceremony from core scope names The engine's last behavior keyed on hardcoded core scope NAMES was the walking-skeleton stance: SKELETON_ON_SCOPES, a six-name code constant a plugin scope could never join without a code edit. The stance now resolves through the active scope's own file. - new optional scope frontmatter skeleton: on|off, read through loadScopeMetadata; absent = off (composed scopes reshape an existing plan and must not conjure a skeleton Bolt; plugin scopes opt in explicitly). Invalid values throw naming the file. All nine core scopes declare the field explicitly - the same six resolve on as the deleted constant, pinned by test. - env-scope fallback ADOPTED, not just validated: resolve-env-scope and orchestrate's resolveScope previously let an AWS_AIDLC_DEFAULT_SCOPE naming a disabled/unknown scope through to a generic unknown-scope death; both now route through selectionAwareDefaultScope, so a plugin-only install with the shipped core default starts from plain /aidlc. selectionAwareDefaultScope itself now keys off scope OWNERSHIP (core vs plugin) rather than owner-bucket count, fixing a wrong fallback when core scopes were enabled but the preferred one disabled. - prose decoupled: conductor.md, org.md, rules-reading.md, and the four SKILL.md copies describe the ceremony via the skeleton: field instead of name lists; stage-protocol.md's hardcoded per-scope stage-count table is replaced by a pointer at the compiled grid (scope-table). - t224-scope-name-decoupling: a static probe (comment-stripped source scan) asserts no core tool regrows a 3+ core-scope-name literal; fixture tests pin on/off/absent/invalid parsing; the env fallback is pinned under a plugin-only selection. * fix: doctor selection coverage hard-fails only under an active selection + help-text harness seam Three tier reds, two defects: - The new Enabled-stage-compile-coverage doctor check hard-failed on ANY uncompiled stage file, breaking the pre-existing contract that an uncompiled stage without a selection is a deliberate authoring state surfaced as an advisory (t184's pin). The hard-fail is now gated on an active selection - there a missing node means a torn select-plugins run; without one, the pre-existing advisory row owns the case. - The select-plugins help-text example hardcoded .claude/tools instead of the ${harnessDir()} seam (t153 in core, t150 after packaging propagated it to the codex tree verbatim). * fix: integration-tier reds - protocol depth table, export fixture, dupe-slug fixture route Four integration reds after the branch's feature commits, all adaptation gaps rather than engine defects: - t34: WP7's prose decoupling deleted the per-scope stage-count table wholesale, but t34's contract (from the original .sh) is that the depth section lists every scope. Restore a scope->default-depth table (mirroring the scope files' depth: frontmatter - names and depths only, no stage counts, which stay banned in this protocol). - t66: the designer-export golden fixture predates the runner/skeleton scope frontmatter; regenerate it (delta is exactly the new fields on the scopes block). - t-custom-harness-compile E5: the duplicate-slug fixture wrote the dupe under a mismatched filename, which now trips the new stem==slug guard before the duplicate-slug guard it pins. Route the dupe through a different phase dir with the slug as its stem so the intended guard fires. - t78 red was a parallel-load flake (5s timeout): green alone, no change. * chore: version 2.3.4 - changelog, badge, dist regen Consolidated entry for the plugin-selection feature set: plugin content buckets (scopes/agents/knowledge), plugin ownership through compile, namespaced runners, install-time selection with transactional regeneration and the closure guard, generated stage table, state contract, skeleton frontmatter decoupling, and the bundle->plugin rename. t68 pins version/changelog/badge agreement. * feat: reject the renamed bundle: key outright - no read-side alias The bundle->plugin rename originally kept bundle: as a deprecated read-side alias in three layers (parser synthesis, schema normalization, compose fallback), each with slightly different semantics. The alias is gone: one word, one key, everywhere. - schema: a stage carrying bundle: fails validation with 'bundle: was renamed; write plugin: for ownership' - a targeted error naming the fix, not a generic unknown-key. - parser: back to a dumb extractor; no alias synthesis. The schema is the single normalization boundary. - compose hook: a contribution carrying bundle: (alone or beside plugin:) is skipped with a drop naming the rename - a stale plugin tree fails visibly instead of composing under wrong or ambiguous ownership. - t62/t64/t188 flip from alias-accepted pins to rejected-with-named-fix pins; docs and CHANGELOG drop the alias language. 'bundle' remains reserved for a possible future collection-of-plugins concept and is otherwise absent from the mechanism. * fix: version-skew guard on plugin-owned content copy Composing a plugin that ships plugin:-keyed stages/scopes/agents onto an installed engine whose schema predates the plugin: ownership key broke that install's graph compile permanently (the retry marker re-failed every SessionStart). compose now probes the INSTALLED schema for plugin: acceptance up front; on rejection it skips the plugin-owned content copy with a degraded drop naming the remediation and does not write the retry marker for that cause (retrying cannot fix an old engine). knowledge/, sensors/, and tools/ still copy. t188 gains an old-engine fixture proving no copy, the drop, no marker, and a still-green compile. * fix: frontmatter-name collision guard in compose + statusline fail-open copyTreeNoClobber was filename-only: a plugin agents/scopes file with a unique filename but a frontmatter name duplicating an installed agent/scope landed cleanly, then the duplicate-name throws in loadAgents and loadScopeMetadataAll bricked compile, orchestrate, help, and the statusline on every prompt with no drop attributing the plugin. compose now parses each agents/scopes file's frontmatter name and checks it against the installed roster before copying; a collision skips that file with a degraded drop naming the plugin, the file, and the colliding installed file. The statusline's agentDisplayMap additionally degrades to slug display when loadAgents throws (hooks fail open by design). t188 gains a synthetic collision fixture proving skip + drop + compile green + statusline exit 0; t61 gains a duplicate-name slug-fallback case. * fix: composed scopes survive plugin selection unconditionally A composed scope (runtime-approved user state: a scopes/aidlc-<name>.md with no plugin: field plus a hand-appended grid entry no stage frontmatter produces) was destroyed by plugin selection twice over: filterScopeGrid stripped it from the written grid when core was deselected (unrecoverable - the transpose cannot re-derive it), and even with core enabled the transpose seeded its name from enabledScopeNames and fabricated a flattened init-only twin that shadowed the real on-disk entry in mergeComposedScopes. The grid pipeline now derives the composed set (on-disk grid keys with no stage frontmatter producer over the FULL stage list), excludes them from transpose seeding, and passes them to filterScopeGrid as an explicit exempt set - a composed entry round-trips byte-identical through any select-plugins run. discoverScopes keeps a composed scope's runner via written-grid membership so the select-plugins regen chain no longer prunes it. t223 gains a composed-scope journey: seed, select test-pro only, re-enable both, byte-identical grid entry each time, runner intact, and next --scope routes a run-stage directive for the real first EXECUTE stage. * fix: runner-prune provenance, empty-batch gate, lazy scope batch Three runner-gen hazards closed. (1) Both prune sites deleted ANY skills dir whose SKILL.md matched the command signature - a user-authored skill that documents those commands was rm -rf'd silently. Generated runners now carry a generated-by: aidlc-runner-gen frontmatter marker; pruning requires signature AND marker, with a one-release legacy rule for pre-marker installs (dir named exactly aidlc-<slug>, or the bare slug when it is plugin-owned). A signature-matching dir that is neither is left alone and listed on stderr as unmanaged. (2) An empty scope batch pruned every scope runner and exited 0 success-shaped, indistinguishable from a stale-scopes upgrade or a mispointed AIDLC_SCOPES_DIR; prune-on-empty now runs only under an active selection, otherwise it warns naming the two likely causes and preserves runners; discoverScopes warns on an unreadable scope dir. (3) The eager FIRST_BATCH module const ran scope-dir I/O at import and threw on one malformed scope file, killing even write; deleted - importers (t123 twins, t130, codex emit) call defaultScopeBatch() at use time, and a malformed scope file no longer kills write. t221 gains six cases pinning all three. * fix: skills-dir guard, env-fallback note, doctor parse-fail coverage, covers headers Four small hardening fixes. select-plugins no longer errors on a Codex-style install with no harness skills/ dir: regenerateSelectionSurfaces skips the two runner-gen spawns with a note naming the skipped surface and the real path (mirrors compose's advisory; never mkdirs). The AWS_AIDLC_DEFAULT_SCOPE sole-plugin fallback is no longer silent: the lib result carries a note field and both env call sites print it to stderr naming the substituted scope; exit code and stdout unchanged. Doctor's enabled-stage-coverage walk no longer skips a stage file whose frontmatter fails to parse when a selection is active - it cannot be proven in the graph, so it counts as missingEnabled with the parse error in the fix message; the two issue-number references in doctor comments are rewritten as prose contracts. t223/t224 covers: headers rewritten in the registry's class:id grammar; the regenerated coverage registry flips subcommand aidlc-utility select-plugins, function:selectionAwareDefaultScope, function:pluginsEnabled, and function:mergeComposedScopes to covered. * refactor: cleanup set - table-check dedup, shared predicate, marker grammar, fixtures hoist, doctor dedup, selection audit event Six review cleanups. The stage-table/scope-table --check twins collapse into one checkGeneratedTableRegion helper, and replaceGeneratedRegion now shares the same marker-locating core (findGeneratedRegion) so all three surfaces validate duplicate/out-of-order markers identically. The stage-enabled-by-selection predicate is exported once from aidlc-lib (stageEnabledBySelection) and used by graph compile and doctor; compose keeps its self-contained copy with a sync comment. The scope-table BEGIN marker drops its em dash for the hyphen grammar the stage-table already uses (constants, compose copies, all four harness SKILL.md files; an installed SKILL.md still carrying the old marker degrades to compose's existing missing-BEGIN advisory drop, no error). The triplicated test scaffolding hoists into fixtures.ts withEnvAndFreshCaches (delete-aware env restore + all five cache resets) with t221/t222/t223/t224 converted. Doctor reuses a parameterized artifactsRegistryFor and a shared frontmatterBlock helper instead of hand-rolled twins. select-plugins' set-mode emits a PLUGIN_SELECTION_CHANGED audit event (previous + new selection) - registered in VALID_EVENT_TYPES, documented in the state- machine chapter and audit-format registry, pinned by the count tests (70 -> 71) and asserted end-to-end in t223. * docs: fold the review-fix behavior changes into the 2.3.4 changelog entry The compose skew guard, the frontmatter-name collision guard, the runner provenance marker + empty-batch gate, composed-scope selection survival, the PLUGIN_SELECTION_CHANGED audit event, the env-fallback stderr note, the doctor parse-fail coverage rule, the Codex skills-dir note, and the scope-table marker grammar change are all user-visible amendments to the same unreleased 2.3.4 - no new version. * fix: lazy-require core cache resets in test fixtures The withEnvAndFreshCaches hoist gave fixtures.ts module-load-time imports of core/tools/aidlc-graph.ts and aidlc-lib.ts. t52's drift meta-test runs t48 inside a sandbox copy that carries dist + docs + tests but no core/, so importing fixtures.ts there failed module resolution and broke all six t52 cases. The resets now load the two modules with createRequire at call time (no sandbox test calls them); resolution identity with the tests' own ESM imports verified behaviorally via the t221/t222/t223/t224 consumers. * chore: rebase onto v2 2.3.4 - re-bump to 2.3.5, re-slot tests t222-t225 PR #545 took 2.3.4 and the t221 slot on v2. Version trio re-bumped to 2.3.5 (aidlc-version.ts + CHANGELOG heading + README badge); the four plugin-selection tests re-slot to t222-t225 (t221 is the reviewer-scope hook's); audit-event pins merge both sides' additions (72 events, 19 categories: REVIEWER_SCOPE_BLOCKED from #545 + PLUGIN_SELECTION_CHANGED from this branch); coverage registry + ratchet regenerated; dist regen in sync. * fix(test): t225 bracket scan - linear scanner replaces backtracking regex The array-literal probe's regex form backtracked past the 5s test timeout on the rebased aidlc-lib.ts and, worse, could silently skip literals when a match starting at an earlier bracket spanned them inside a quoted-string alternative. A linear bracket-stack scanner is O(n) and found one real coupling the regex missed: the workspace-detection greenfield advisory's three incremental scope names. That advisory predates the probe, so it is exempted by exact signature; any new literal (or that one growing) still fails. * fix: schema-precheck plugin stage files at compose copy time Graph compile is all-or-nothing: aidlc-graph.ts throws on the first schema-invalid stage file, so one bad plugin stage copied into the install (e.g. a stale tree still authoring the renamed bundle: key) bricked every later compile of the whole install with no self-heal. The compose stage copy now validates each plugin stage file against the INSTALLED engine's parser + validator before it lands: an invalid file (or a frontmatter-only empty body) is skip-and-dropped with the file and errors named, the rest of the plugin composes normally, and the self-heal probe excludes deliberately-dropped slugs so it doesn't force a futile recompile every session. Fails open when the installed lib can't be loaded (a partial install already can't compile). * fix: reserve the aidlc- prefix for core - plugin names may not claim it runnerDirName returns the bare slug for plugin stages but aidlc-<slug> for core, so a plugin named aidlc-<x> generated runner dirs identical to core runner paths and runner-gen write clobbered them silently - /aidlc-<slug> could route to the wrong stage. Four chokepoints now refuse the name: graph compile rejects plugin: aidlc-* stage frontmatter (the install-path enforcement point), the compose stage precheck mirrors the same invariants so a bad stage file drops instead of bricking compile, loadScopeMetadataAll rejects aidlc-* plugin: in scope frontmatter (covers scope-runner dirs), and the packager refuses an aidlc/aidlc-* plugin directory at discover + plugin-build time. * fix: select-plugins refuses to strand an active workflow; doctor flags a stranded one Disabling a plugin an active workflow depends on hard-errored every later /aidlc on that workflow (the state file's scope out-ranks --scope, so 'Unknown scope' had no in-band recovery) while doctor stayed green. select-plugins set-mode now enumerates every non-complete workflow across all spaces and refuses when the new selection would disable (a) the plugin owning the workflow's scope or (b) a plugin owning a pending EXECUTE stage in its plan, naming each dependency and the remediation (complete or park the workflow, or keep the plugin). Doctor gains a matching row that fails when the CURRENT selection (pre-guard or hand-edited) already strands one. * fix: disabling a plugin now strips its merged contributions from core stages select-plugins disable removed a plugin's own stages/scopes/runners but left its merged produces/sensors/consumes/required_sections and spliced prose welded into CORE stage source and the compiled graph - a disabled plugin kept steering enabled stages. Compose now records what it ACTUALLY added per target stage in a per-plugin sidecar (tools/data/plugin-contrib-<key>.json; actually-added entries only, so removal can never strip a value core already had), and select-plugins strips on disable: structural adds via the sidecar, prose fragments via their existing sentinel markers, with stripped stage files joining the transaction's rollback snapshots. Re-enabling restores everything on the next session start (the plugin's compose hook re-merges; round-trip is byte-identical). Compose also stops merging contributions for a plugin the current selection disables - stage copies stay (runtime-filtered), but contribution merges into unfiltered core source would have undone the disable-time strip every session. * fix: non-blocking review set - adds parse shortfall log, dropped ordering-edge advisory, packager manifest error, changelog + doc accuracy Four smaller review items plus the doc updates the bigger fixes need: - adds.produces/sensors entries that fail the 4-space parse now drop-log a parsed-N-of-M shortfall naming the indentation rule, mirroring the consumes parser (they truncated silently). - An enabled stage whose requires_stage names a selection-disabled stage is surfaced by doctor as an advisory listing each dropped ordering edge (not a closure error: the edge is vacuous when the dep never runs, and plugin-only installs legitimately order plugin stages after core ones). - A malformed plugins/<name>/.aidlc-plugin/plugin.json fails the packager with the plugin and file named instead of a raw JSON.parse stack. - CHANGELOG: the empty-body stage guard is now accurately described as the compose stage-file precheck (it was claimed as a runtime compose guard while test-only); the entry also gains bullets for the contribution strip, the strand guard, and the aidlc- name reservation. Doc 18 documents strip/restore semantics, the not-filtered agents/knowledge surfaces, the dropped-edge advisory, and the reserved names; the CLI guide documents the strip + refusal. * chore: regenerate coverage registry after review-fix test additions * style: hyphens for em dashes in lines added by this review-fix series * chore: bun.lock configVersion metadata from the current bun A newer bun adds a configVersion field to the lockfile on install, leaving every fresh checkout permanently dirty on git status. One-line metadata; no dependency changes.
Deterministic PreToolUse enforcement of the reviewer read-scope bound
Fixes #539.
Problem
The per-unit reviewer read-scope bound shipped in #538 (2.2.16) is prose on six surfaces. The field evidence on that PR's thread shows why prose alone loses against this persona: the U03 transcript carried 14 recursive greps with cross-unit globs (
construction/*/*/*.md) that bypassed the first wording, and the reporter's latency table shows per-call time degrading ~30x once sibling reads push context past the ~100k-token knee - so the damage from each violation is superlinear. Per the framework's own layering (determinism belongs in tools/hooks, knowledge in agents, judgment with humans), the bound needs a deterministic twin.Design (D1-D5 outcomes)
D1 - how the hook learns the dispatch. The conductor writes
<record>/.aidlc-reviewer-dispatch.json({reviewer, stage, unit, exempt[]}) immediately before invoking a per-unit reviewer (stage-protocol 12a step 1) and deletes it the moment the verdict is read (step 3). The record is the enforcement window. Staleness is bounded by mtime + a 6h TTL with a janitor delete (the compose-marker discipline); the worst observed pre-fix review ran ~3h, so 6h covers the pathological case. Concurrent swarm clones each have their own record tree (state is forked per worktree), so no sharding is needed. A reviewer-agent sighting with NO record is never blocked - it records an advisory drop surfaced by/aidlc --doctor(the conductor forgot the step-1 write).D2 - enforcement matrix. The matcher is an exported pure function (
evaluateReviewerScope, pinned by t220's 20-case decision table). It scans path fields (Read/Edit/Write), pattern/glob + search-root fields (Glob/Grep), and the whole command string (Bash) forconstruction/<seg>tokens: the dispatched unit passes; a wildcard segment or bareconstruction/sweep root blocks; a concrete sibling blocks unless the full token exactly matches an exempt entry'sconstruction/suffix. Grep's content regex is deliberately NOT scanned (matching file content is not a file access). The spot-check carve-out became dispatch-time data: the conductor resolves the explicitly named integration point and puts that one owning file onexemptwhen writing the record - the reviewer can no longer self-expand scope, which is exactly the prose-vs-persona contest the issue documents (the reporter's transcripts confirm the conductor already names sibling invariants by ID at dispatch time). A shell variable in the unit segment (construction/$UNIT/...) blocks conservatively (the matcher cannot resolve it; the refusal message says to use the literal unit name).D3 - hard-block vs advisory per harness (user decision: hard-block where possible):
settings.json, firstPreToolUseentry (matcherRead|Edit|Write|Glob|Grep|Bash, quoted per #522)agent_type(probe-verified)reviewer-scopetarget, wired inside BOTH reviewer agents' own JSON configshooks.jsonPreToolUserow + one new trust entryagent_type(verified on 0.142.5)USER_PROMPTwithtoolArgsalways empty - a pre-tool matcher has nothing to inspect, so per the porting-guide policy no dead hook ships; the 12a prose bound governs. t220 pins the deliberate absence.Safety rails everywhere: fail-open on any ambiguity (no/stale/malformed record, unknown tool, non-reviewer agent, malformed stdin, any throw), TTY guard, health heartbeat,
recordHookDropdiagnostics, and the deterministic off-switchAIDLC_DISABLE_REVIEWER_SCOPE_HOOK=1for false-positive storms (e.g. a source tree with its ownconstruction/directory).D4 - builder coverage: out of scope. The field evidence is reviewer-specific; builders have a weaker temptation, no transcripts, and a wider legitimate read surface. The record + matcher design generalizes if evidence appears.
D5 - audit. Every refusal emits
REVIEWER_SCOPE_BLOCKED(Tool, Target, Stage, Unit) through the standardappendAuditEntrypath; taxonomy registered acrossaidlc-audit.ts,audit-format.md(70 -> 71 events),12-state-machine.md, and the hooks chapter. Audit failure never changes the block decision.What the reviewer experiences
The blocked call never runs; the harness hands the reviewer the hook's stderr, which names the scope, the offending token, and the sanctioned alternative ("verify cross-unit claims against the passed contracts"). All three live probes showed the agent reading the reason, not retrying, and continuing the review. The human sees nothing in-session; the audit shard records each refusal.
Test evidence
hooks.jsonevents (+PreToolUse), t219 quoting count 13 -> 14, coverage-registry allowlist. t48 (audit taxonomy drift), t68 (version trio), t148, t217 all green with the additions.agent_typein payload; block reason relayed), Codex 0.142.5 (subagentagent_type= spawned agent name; block inside a collab subagent handled gracefully), and Kiro CLI 2.7.0 (per-agent hook registration fires for the subagent's own calls only;PreToolHook blocked the tool executionrelayed). Probe artifacts under the effort's tmp dir.Review
Two independent adversarial reviewers ran on the committed diff; all findings were reproduced before fixing.
Round 1 (codex-review, gpt-5.5) found three matcher bypasses, now pinned in t220's decision table:
construction/U03/../U01/design.mdwas judged on the first segment afterconstruction/and allowed; path components are now normalized with..collapsed against parents before judging.grep -rn TODO construction/find constructionname the whole tree with noconstruction/literal, so the token scan missed them; a quote-stripped pass now blocks any token ending in the bareconstructioncomponent while the word inside quoted content regexes stays content.apply_patchDelete/Move -*** Delete File:/*** Move to:directives were not fanned out to the scope check; they now forward as Edit-class mutations.Round 2 (fresh Claude agent, verdict APPROVE-WITH-FIXES) found no blockers; all minors fixed:
agent_type, so the record'sreviewerfield is compared on Kiro too (a stale record naming a different reviewer fails open instead of scoping the wrong agent).construction/$U/) or an unquoted bare-word grep is now told exactly how to self-correct (literal unit name / quote the pattern); both are conservative blocks, not sibling reads.## Reviewsection verdict from the primary artifact" (the inserted delete step had left "its" pointing at the dispatch record).CLAUDE.md/AGENTS.md) and the architecture doc's test-levels row now say 12 hooks.Round 1's consolidated report (delivered after round 2) added five more items, all folded in:
construction/source directories).operations[]like the read side.The reviews also flagged pre-existing stale "68-event taxonomy" references in eight doc files (wrong on the v2 base already); left for a follow-up sweep rather than folded into this PR.
Base note
Originally stacked on #538; that PR (and the 2.2.17-2.2.19 wave) merged while this was in flight, so the branch is rebased onto current v2 with re-slots: version 2.2.20, test t220. The Kiro IDE registration present in the pre-rebase design was dropped after the 2.2.17 IDE hook rework made the seam unenforceable (empty
toolArgs); the drop is deliberate, documented, and pinned.Credits
The deterministic-hook proposal comes from the issue-534 reporter's transcript evidence on the #538 thread; the
.reviewerignore-style negative filter was independently suggested on the issue thread - this PR is that idea at the framework's native enforcement point.