From 30758833bf39752caad30a39c83cbf27262ecd48 Mon Sep 17 00:00:00 2001 From: Kyle Sexton <153232337+kyle-sexton@users.noreply.github.com> Date: Thu, 1 Oct 2026 11:10:28 -0400 Subject: [PATCH 1/5] fix(claude-ops): resolve the Explore and Plan disallowed-tools spread in its own scope A `...spread` in an agent's tools or disallowedTools now reads the binding its own module and scope see (`_binding_value`, the rule name and field resolution use), not the nearest same-name binding in the bundle. On 2.1.286 that nearest binding was an unrelated call, so Explore and Plan lost the shared entries, Artifact tools included, and read `partial`. A spread whose scoped binding is not an array literal stays `partial`. Closes #5711 Co-Authored-By: Claude Opus 5.5 --- plugins/claude-ops/.claude-plugin/plugin.json | 2 +- plugins/claude-ops/CHANGELOG.md | 11 ++++++++++ .../skills/inventory/reference/extraction.md | 4 +++- .../skills/inventory/scripts/inventory.py | 15 ++++++++++++-- .../inventory/scripts/test_inventory.py | 20 +++++++++++++++++++ 5 files changed, 48 insertions(+), 4 deletions(-) diff --git a/plugins/claude-ops/.claude-plugin/plugin.json b/plugins/claude-ops/.claude-plugin/plugin.json index 1fbd652f05..27c525167d 100644 --- a/plugins/claude-ops/.claude-plugin/plugin.json +++ b/plugins/claude-ops/.claude-plugin/plugin.json @@ -1,7 +1,7 @@ { "$schema": "https://json.schemastore.org/claude-code-plugin-manifest.json", "name": "claude-ops", - "version": "0.79.2", + "version": "0.79.3", "description": "Claude Code operations toolkit. Fourteen skills: audit-skill-visibility (audit whether each installed skill is actually VISIBLE to the model, and diagnose why most of a fleet never gets used: a skill is invisible when its description is dropped by Claude Code's skill-listing context budget, which sheds descriptions lowest-score-first so an unused skill loses the keywords that would let it be matched, from skills genuinely not wanted, from skills the run cannot observe at all; computes whether the listing overflows from documented settings, and withholds every cold verdict the data cannot support rather than reporting absence of data as absence of use), inventory (read-only enumeration of the complete invocable surface: every built-in CLI command with aliases and hidden/gated status, every bundled skill, every built-in subagent and tool, and every component of every installed plugin across all marketplaces; reads the shipped binary because upstream publishes no built-in command list, and carries an integrity verdict so a drifted build reports counts as floors rather than silently short totals), audit-install-state (read-only audit of the machine-scope ~/.claude installation directory and ~/.claude.json: full inventory split into an authored surface and rolled-up bulk trees, product-managed retention vs genuinely unmanaged state, filename-scheme resolution before any process-liveness check, and deliberate/mid-experiment detection; reports, never deletes), audit-performance (read-only slowness-diagnostic capture run at the moment the machine or a session feels slow: CLI version, retention-sweep health including the unparsable-settings pause, which warns in /status, a timed census walk of the install tree as a sweep-cost proxy, active-session and plugin-fleet counts, a process census, and the fan-out layer, which covers a load-labeled no-op spawn baseline, every hook that will fire bucketed per-tool-call versus per-turn with its invocation shape, the configured statusline, subagent concurrency and spawn-depth ceilings against documented defaults, whether running sessions predate the settings file they are judged by, and orphan attribution by parent liveness rather than age, plus on Windows a kernel-object census (Token objects against uptime, paged pool) that names a host-level leak beneath all four suspects; read against a bundled known-performance-issues reference that also records the causes tested and cleared; separates the four documented suspects of accumulated state, version regression, component bloat, and per-spawn fan-out cost, and routes remediation out; reports, never mutates, and never executes a discovered hook or statusline command), audit-native-overlap (map native Claude Code surfaces, namely built-in CLI commands, bundled skills, plugin-backed built-ins, and session-provided skills, against the current repo's plugin skills and agents, so a custom component never silently duplicates what Claude Code itself ships; bare invocation is a read-only overlap report carrying the extraction's integrity floors and a shared-listing-budget exposure section, verdicts are human-gated in a committed store rendered into a generated registry whose every row carries an observable recheck trigger, and only an explicit apply step bakes presence-gated native references into descriptions and Boundary sections), observability (read locally captured telemetry from the OTEL store, the collector, the per-session hook event log and hook-event JSONL, and ccusage, with trend reports, a per-session report of what fired, what was blocked and the event timeline, and store pruning), known-issues (search known Claude product GitHub bugs, check service health, maintain a persistent tracked-issue registry), changelog (ingest Claude Code changelog entries and turn them into decisions: apply executes those in scope one PR per owner plugin and hands larger ones off as work items, then re-extract the native surface and file its drift as work items), prerequisites (read-only table of external binaries declared by enabled plugins; never installs), check (read-only check that node and jq resolve for the claude-ops hooks; never installs), plugins (bring a machine's plugin fleet current on demand: marketplace refresh, effective-scope updates including in-repo project/local installs, new-plugin install per policy, scope-divergence detection and explicit convergence), morning-brief (read-only gh-based operator morning view: queue-label counts, merge-ready PRs, parked decisions with their RECOMMENDED lines, and loop-lane telemetry freshness), lanes (start/restart/stop/status loop lanes as named background Claude Code sessions seeded from canonical prompt files, with per-lane model/effort, a repo-pull + marketplace-refresh launch step, and a consume-restarts action, an OS-schedulable reader that relaunches stopped lanes whose telemetry carries a restart_request), and a re-runnable setup action that settles where the known-issues registry, the skill-usage log and the hook log root live, places the root's self-ignoring guard, and detects retired conventions. Plus an opt-in, default-off per-session hook event log (one JSON line per hook event on every event the generated registry marks observable, written to /sessions/.jsonl, with SessionEnd retention by session count or age and an optional detached pre-prune command), a family of eight advisory *-audit hooks (API errors, config changes, instruction loads, permission denials, pre-compaction, skill usage, tool failures, and unsurfaced hook failures. The last also warns the user via systemMessage, since a hook that fails to launch enforces nothing and Claude Code surfaces the failure to nobody) that emit the shared hook-telemetry envelope, and a reference sink that routes envelopes under the same root: per session when the envelope carries a session id, else into the shared hook-events.jsonl the observability skill reads.", "author": { "name": "Melodic Software", diff --git a/plugins/claude-ops/CHANGELOG.md b/plugins/claude-ops/CHANGELOG.md index 8f26242d5f..c26f4ecabe 100644 --- a/plugins/claude-ops/CHANGELOG.md +++ b/plugins/claude-ops/CHANGELOG.md @@ -3,6 +3,17 @@ All notable changes to the `claude-ops` plugin are documented here. Format follows [Keep a Changelog](https://keepachangelog.com/en/1.1.0/); this plugin uses semantic versioning. +## [0.79.3] - 2026-10-01 + +### Fixed + +- **The inventory reads the Explore and Plan agents' full disallowed-tools list on Claude Code + 2.1.286.** A `...spread` inside `tools` or `disallowedTools` now resolves to the binding its own + module and scope see, by the same rule name and field resolution use. Before, it took the + nearest same-name binding in the bundle, an unrelated call on 2.1.286, so the shared entries + (the Artifact tools among them) were dropped and the field read `partial`. A spread whose + binding is not an array literal still reads `partial`. + ## [0.79.2] - 2026-10-01 ### Fixed diff --git a/plugins/claude-ops/skills/inventory/reference/extraction.md b/plugins/claude-ops/skills/inventory/reference/extraction.md index 186bd01302..30b397e1c4 100644 --- a/plugins/claude-ops/skills/inventory/reference/extraction.md +++ b/plugins/claude-ops/skills/inventory/reference/extraction.md @@ -272,7 +272,9 @@ An agent in the initializer is `default`, one pushed is `conditional`, one never `export{X as NAME}` for a three-character-or-longer binding is read, since the chunk's own closing export can name it plainly (`export{qHe}`) while a later statement renames it. `tools` and `disallowedTools` resolve element by element (literals, tool-name constants, one level of -`...spread`); `get tools(){...}` is `getter` and `tools:uw.tools` is `reference`, each null. +`...spread`); `get tools(){...}` is `getter` and `tools:uw.tools` is `reference`, each null. A +spread reads the binding its own module and scope see, not the nearest same-name binding in the +bundle; when that binding is not an array literal, the list is `partial`. ### 10. Find built-in tools by shape, not by builder diff --git a/plugins/claude-ops/skills/inventory/scripts/inventory.py b/plugins/claude-ops/skills/inventory/scripts/inventory.py index 152dfba25c..a32d5d9761 100755 --- a/plugins/claude-ops/skills/inventory/scripts/inventory.py +++ b/plugins/claude-ops/skills/inventory/scripts/inventory.py @@ -3224,7 +3224,9 @@ def _array_names( """Tool names in the array literal at `open_i`, and whether all resolved. Elements are string literals, tool-name constants, or a `...spread` of - another array constant, which is followed `hops` deep. + another array constant, which is followed `hops` deep. The spread reads + the binding its own module and scope see (`_binding_value`), not the + nearest same-name binding in the bundle. """ names: list[str] = [] complete = True @@ -3237,7 +3239,16 @@ def _array_names( if lit: names.append(_unescape(lit.group(1))) elif spread and hops > 0: - v = _nearest_binding(src, spread.group(1), at) + try: + v = _binding_value( + src, + braces, + spread.group(1), + start, + window=SHORT_IDENT_LOCALITY_BYTES, + ) + except (ValueError, IndexError, RecursionError): + v = None if v is not None and src.startswith("[", v): more, ok = _array_names(src, braces, v, index, v, hops - 1) names.extend(more) diff --git a/plugins/claude-ops/skills/inventory/scripts/test_inventory.py b/plugins/claude-ops/skills/inventory/scripts/test_inventory.py index a8bd023092..ace2097e0e 100755 --- a/plugins/claude-ops/skills/inventory/scripts/test_inventory.py +++ b/plugins/claude-ops/skills/inventory/scripts/test_inventory.py @@ -1157,6 +1157,26 @@ def test_an_unresolved_type_is_counted(self) -> None: self.assertEqual(notes["unresolved_names"], ["qq9"]) self.assertEqual(notes["resolved"], notes["definitions_seen"] - 1) + def test_a_spread_reads_its_own_scope_not_the_nearest_binding(self) -> None: + src = AGENT_SRC + ( + 'var pY=[xt,"Artifact"];function g(){let pY=p(1);return pY}' + 'var SP={agentType:"spread-probe",whenToUse:"s",source:"built-in",' + 'disallowedTools:[yt,...pY],getSystemPrompt:()=>""};' + ) + rec = self._extract(src)[0]["spread-probe"] + self.assertEqual(rec["disallowed_tools"], ["Agent", "Edit", "Artifact"]) + self.assertEqual(rec["disallowed_tools_source"], "literal") + + def test_a_spread_of_a_non_constant_binding_stays_partial(self) -> None: + src = AGENT_SRC + ( + 'var pY=[xt,"Artifact"];var pY=c?[xt]:[yt];' + 'var SP={agentType:"spread-probe",whenToUse:"s",source:"built-in",' + 'disallowedTools:[yt,...pY],getSystemPrompt:()=>""};' + ) + rec = self._extract(src)[0]["spread-probe"] + self.assertEqual(rec["disallowed_tools"], ["Agent"]) + self.assertEqual(rec["disallowed_tools_source"], "partial") + def test_no_roster_leaves_every_agent_absent(self) -> None: agents, notes = self._extract(AGENT_SRC.split("function R()")[0]) self.assertFalse(notes["roster_found"]) From f88d9bf2902b146b1912ad949e32ae83136aa414 Mon Sep 17 00:00:00 2001 From: Kyle Sexton <153232337+kyle-sexton@users.noreply.github.com> Date: Thu, 1 Oct 2026 11:23:21 -0400 Subject: [PATCH 2/5] fix(claude-ops): leave a spread partial when another function reassigns its binding `var pY=[a];function init(){pY=[b]}` makes a later `...pY` read b once init has run, but the scoped lookup returned the declaration's a. The spread path now treats a binding that another function in the module assigns (and does not shadow) as not static, so the list stays `partial`. The check is confined to the spread path: applied inside `_binding_value` it changed many unrelated description fields on both 2.1.285 and 2.1.286. Co-Authored-By: Claude Opus 5.5 --- plugins/claude-ops/CHANGELOG.md | 2 +- .../skills/inventory/reference/extraction.md | 3 +- .../skills/inventory/scripts/inventory.py | 61 +++++++++++++++---- .../inventory/scripts/test_inventory.py | 10 +++ 4 files changed, 63 insertions(+), 13 deletions(-) diff --git a/plugins/claude-ops/CHANGELOG.md b/plugins/claude-ops/CHANGELOG.md index c26f4ecabe..69a2947baf 100644 --- a/plugins/claude-ops/CHANGELOG.md +++ b/plugins/claude-ops/CHANGELOG.md @@ -12,7 +12,7 @@ All notable changes to the `claude-ops` plugin are documented here. Format follo module and scope see, by the same rule name and field resolution use. Before, it took the nearest same-name binding in the bundle, an unrelated call on 2.1.286, so the shared entries (the Artifact tools among them) were dropped and the field read `partial`. A spread whose - binding is not an array literal still reads `partial`. + binding is not an array literal, or is assigned inside another function, still reads `partial`. ## [0.79.2] - 2026-10-01 diff --git a/plugins/claude-ops/skills/inventory/reference/extraction.md b/plugins/claude-ops/skills/inventory/reference/extraction.md index 30b397e1c4..ea4b157435 100644 --- a/plugins/claude-ops/skills/inventory/reference/extraction.md +++ b/plugins/claude-ops/skills/inventory/reference/extraction.md @@ -274,7 +274,8 @@ export can name it plainly (`export{qHe}`) while a later statement renames it. ` `disallowedTools` resolve element by element (literals, tool-name constants, one level of `...spread`); `get tools(){...}` is `getter` and `tools:uw.tools` is `reference`, each null. A spread reads the binding its own module and scope see, not the nearest same-name binding in the -bundle; when that binding is not an array literal, the list is `partial`. +bundle; when that binding is not an array literal, or another function in the module assigns it +(`function init(){pY=[...]}`), the list is `partial`. ### 10. Find built-in tools by shape, not by builder diff --git a/plugins/claude-ops/skills/inventory/scripts/inventory.py b/plugins/claude-ops/skills/inventory/scripts/inventory.py index a32d5d9761..5025b828c3 100755 --- a/plugins/claude-ops/skills/inventory/scripts/inventory.py +++ b/plugins/claude-ops/skills/inventory/scripts/inventory.py @@ -1921,6 +1921,54 @@ def _binding_value( return found.end() +def _spread_array(src: str, braces: BraceMap, ident: str, at: int) -> int | None: + """The `[` of the array literal `...ident` at `at` spreads, or None. + + The binding is the one `at`'s module and scope see (`_binding_value`), + and another function must not write it: `var x=[a];function f(){x=[b]}` + reads b once f has run, so the list is not static. + """ + try: + v = _binding_value(src, braces, ident, at, window=SHORT_IDENT_LOCALITY_BYTES) + except (ValueError, IndexError, RecursionError): + return None + if v is None or not src.startswith("[", v): + return None + head = re.search( + r"(? bool: + """Whether a function other than the one declaring `ident` at `pos` + writes that binding: `var x=1;function f(){x=2}` leaves x's value to + whether and when f runs. A write in a function that declares its own + `ident` is to that local, not to this binding.""" + home = _function_block(src, braces, pos) + lo, hi = _chunk_span(src, pos) + name = re.compile(r"(? re.Pattern[str]: return re.compile(r"function\s+" + re.escape(ident) + r"\s*\(([^()]*)\)\s*\{") @@ -3239,17 +3287,8 @@ def _array_names( if lit: names.append(_unescape(lit.group(1))) elif spread and hops > 0: - try: - v = _binding_value( - src, - braces, - spread.group(1), - start, - window=SHORT_IDENT_LOCALITY_BYTES, - ) - except (ValueError, IndexError, RecursionError): - v = None - if v is not None and src.startswith("[", v): + v = _spread_array(src, braces, spread.group(1), start) + if v is not None: more, ok = _array_names(src, braces, v, index, v, hops - 1) names.extend(more) complete = complete and ok diff --git a/plugins/claude-ops/skills/inventory/scripts/test_inventory.py b/plugins/claude-ops/skills/inventory/scripts/test_inventory.py index ace2097e0e..7e9cebc362 100755 --- a/plugins/claude-ops/skills/inventory/scripts/test_inventory.py +++ b/plugins/claude-ops/skills/inventory/scripts/test_inventory.py @@ -1177,6 +1177,16 @@ def test_a_spread_of_a_non_constant_binding_stays_partial(self) -> None: self.assertEqual(rec["disallowed_tools"], ["Agent"]) self.assertEqual(rec["disallowed_tools_source"], "partial") + def test_a_spread_another_function_reassigns_stays_partial(self) -> None: + src = AGENT_SRC + ( + 'var pY=[xt,"Artifact"];function init(){pY=["Other"]}init();' + 'var SP={agentType:"spread-probe",whenToUse:"s",source:"built-in",' + 'disallowedTools:[yt,...pY],getSystemPrompt:()=>""};' + ) + rec = self._extract(src)[0]["spread-probe"] + self.assertEqual(rec["disallowed_tools"], ["Agent"]) + self.assertEqual(rec["disallowed_tools_source"], "partial") + def test_no_roster_leaves_every_agent_absent(self) -> None: agents, notes = self._extract(AGENT_SRC.split("function R()")[0]) self.assertFalse(notes["roster_found"]) From 117a3fb64614805da3125f2eb68abf93124344d1 Mon Sep 17 00:00:00 2001 From: Kyle Sexton <153232337+kyle-sexton@users.noreply.github.com> Date: Thu, 1 Oct 2026 11:29:03 -0400 Subject: [PATCH 3/5] fix(claude-ops): count block and arrow writes against a spread's binding `_written_elsewhere` only counted writes in another function, so `var pY=[a];if(c){pY=[b]}` (or a loop body) and an expression-bodied arrow `var f=()=>pY=[b]` still read `literal` a. A write now counts when it sits in a different block than the declaration or inside an arrow expression, including when the arrow's own assignment is the binding `_binding_value` picked. The shadow check also read declarations at doubled offsets; it now uses the absolute positions finditer returns. Co-Authored-By: Claude Opus 5.5 --- plugins/claude-ops/CHANGELOG.md | 3 +- .../skills/inventory/reference/extraction.md | 5 +-- .../skills/inventory/scripts/inventory.py | 36 +++++++++++++------ .../inventory/scripts/test_inventory.py | 24 +++++++++++++ 4 files changed, 54 insertions(+), 14 deletions(-) diff --git a/plugins/claude-ops/CHANGELOG.md b/plugins/claude-ops/CHANGELOG.md index 69a2947baf..da196278af 100644 --- a/plugins/claude-ops/CHANGELOG.md +++ b/plugins/claude-ops/CHANGELOG.md @@ -12,7 +12,8 @@ All notable changes to the `claude-ops` plugin are documented here. Format follo module and scope see, by the same rule name and field resolution use. Before, it took the nearest same-name binding in the bundle, an unrelated call on 2.1.286, so the shared entries (the Artifact tools among them) were dropped and the field read `partial`. A spread whose - binding is not an array literal, or is assigned inside another function, still reads `partial`. + binding is not an array literal, or is assigned in a nested block, another function, or an + expression-bodied arrow, still reads `partial`. ## [0.79.2] - 2026-10-01 diff --git a/plugins/claude-ops/skills/inventory/reference/extraction.md b/plugins/claude-ops/skills/inventory/reference/extraction.md index ea4b157435..79c937ef4c 100644 --- a/plugins/claude-ops/skills/inventory/reference/extraction.md +++ b/plugins/claude-ops/skills/inventory/reference/extraction.md @@ -274,8 +274,9 @@ export can name it plainly (`export{qHe}`) while a later statement renames it. ` `disallowedTools` resolve element by element (literals, tool-name constants, one level of `...spread`); `get tools(){...}` is `getter` and `tools:uw.tools` is `reference`, each null. A spread reads the binding its own module and scope see, not the nearest same-name binding in the -bundle; when that binding is not an array literal, or another function in the module assigns it -(`function init(){pY=[...]}`), the list is `partial`. +bundle; when that binding is not an array literal, or code off the declaring block's straight line +assigns it (a nested block, a function, or an expression-bodied arrow such as +`function init(){pY=[...]}`), the list is `partial`. ### 10. Find built-in tools by shape, not by builder diff --git a/plugins/claude-ops/skills/inventory/scripts/inventory.py b/plugins/claude-ops/skills/inventory/scripts/inventory.py index 5025b828c3..c3923e671a 100755 --- a/plugins/claude-ops/skills/inventory/scripts/inventory.py +++ b/plugins/claude-ops/skills/inventory/scripts/inventory.py @@ -1945,25 +1945,39 @@ def _spread_array(src: str, braces: BraceMap, ident: str, at: int) -> int | None def _written_elsewhere(src: str, braces: BraceMap, ident: str, pos: int) -> bool: - """Whether a function other than the one declaring `ident` at `pos` - writes that binding: `var x=1;function f(){x=2}` leaves x's value to - whether and when f runs. A write in a function that declares its own - `ident` is to that local, not to this binding.""" - home = _function_block(src, braces, pos) + """Whether code outside the straight line of the block declaring `ident` + at `pos` writes that binding: a nested block (`if(c){x=2}`, a loop + body), a function (`function f(){x=2}`), or an expression-bodied arrow + (`()=>x=2`). Whether and when that write runs is not static. A write + that a nearer declaration of `ident` shadows is to that local instead. + """ + home = braces.enclosing(pos) lo, hi = _chunk_span(src, pos) + + def in_arrow(at: int) -> bool: + head = _statement_start(src, at) + head = max(lo, at - 4096) if head is None else head + return "=>" in _mask_strings(src[head:at]) + + if in_arrow(pos): + return True name = re.compile(r"(? None: self.assertEqual(rec["disallowed_tools"], ["Agent"]) self.assertEqual(rec["disallowed_tools_source"], "partial") + def test_a_spread_written_off_the_straight_line_stays_partial(self) -> None: + for prelude in ( + 'var pY=[xt,"Artifact"];if(c){pY=["Other"]}', + 'var pY=[xt,"Artifact"];for(;;){pY=["Other"]}', + 'var pY=[xt,"Artifact"];var f=()=>pY=["Other"];f();', + 'var f=()=>pY=["Other"];var pY=[xt,"Artifact"];f();', + ): + src = AGENT_SRC + ( + prelude + 'var SP={agentType:"spread-probe",' + 'whenToUse:"s",source:"built-in",disallowedTools:[yt,...pY],' + 'getSystemPrompt:()=>""};' + ) + rec = self._extract(src)[0]["spread-probe"] + self.assertEqual(rec["disallowed_tools_source"], "partial", prelude) + + def test_a_spread_whose_writer_shadows_the_name_still_resolves(self) -> None: + src = AGENT_SRC + ( + 'var pY=[xt,"Artifact"];function g(){let pY=[];pY=["Other"]}' + 'var SP={agentType:"spread-probe",whenToUse:"s",source:"built-in",' + 'disallowedTools:[yt,...pY],getSystemPrompt:()=>""};' + ) + rec = self._extract(src)[0]["spread-probe"] + self.assertEqual(rec["disallowed_tools"], ["Agent", "Edit", "Artifact"]) + def test_no_roster_leaves_every_agent_absent(self) -> None: agents, notes = self._extract(AGENT_SRC.split("function R()")[0]) self.assertFalse(notes["roster_found"]) From 24d26c52a9431e0cba9118ec64c01ecec822abf7 Mon Sep 17 00:00:00 2001 From: Kyle Sexton <153232337+kyle-sexton@users.noreply.github.com> Date: Thu, 1 Oct 2026 11:46:18 -0400 Subject: [PATCH 4/5] fix(claude-ops): leave a spread partial when its binding is a conditional write `var pY=[a];if(c)pY=[b]` (or `pY=c?[b]:pY`, `c&&(pY=[b])`, an unbraced `for(...)pY=[b]`) made `_binding_value` select the bare assignment, and `_written_elsewhere` skipped same-block writes, so the spread read [b] as a literal. A spread's binding must now be a declaration, and any other write to it in the module, including one in the same block, makes the list `partial`. A `var` reached back through a chain of simple declarators counts as a declaration: on 2.1.285 and 2.1.286 the shared `pY` follows a function declaration with no `;`, which `_declares` misses. extraction.md no longer labels `function init(){...}` as an expression-bodied arrow. Co-Authored-By: Claude Opus 5.5 --- plugins/claude-ops/CHANGELOG.md | 4 +-- .../skills/inventory/reference/extraction.md | 7 +++-- .../skills/inventory/scripts/inventory.py | 31 +++++++++++++------ .../inventory/scripts/test_inventory.py | 14 +++++++++ 4 files changed, 41 insertions(+), 15 deletions(-) diff --git a/plugins/claude-ops/CHANGELOG.md b/plugins/claude-ops/CHANGELOG.md index 26eb04d186..9f09d399ff 100644 --- a/plugins/claude-ops/CHANGELOG.md +++ b/plugins/claude-ops/CHANGELOG.md @@ -12,8 +12,8 @@ All notable changes to the `claude-ops` plugin are documented here. Format follo module and scope see, by the same rule name and field resolution use. Before, it took the nearest same-name binding in the bundle, an unrelated call on 2.1.286, so the shared entries (the Artifact tools among them) were dropped and the field read `partial`. A spread whose - binding is not an array literal, or is assigned in a nested block, another function, or an - expression-bodied arrow, still reads `partial`. + binding is not an array literal, or is assigned anywhere else (a conditional write in the same + block, a nested block, another function, or an expression-bodied arrow), still reads `partial`. ## [0.79.3] - 2026-10-01 diff --git a/plugins/claude-ops/skills/inventory/reference/extraction.md b/plugins/claude-ops/skills/inventory/reference/extraction.md index 79c937ef4c..9db862a883 100644 --- a/plugins/claude-ops/skills/inventory/reference/extraction.md +++ b/plugins/claude-ops/skills/inventory/reference/extraction.md @@ -274,9 +274,10 @@ export can name it plainly (`export{qHe}`) while a later statement renames it. ` `disallowedTools` resolve element by element (literals, tool-name constants, one level of `...spread`); `get tools(){...}` is `getter` and `tools:uw.tools` is `reference`, each null. A spread reads the binding its own module and scope see, not the nearest same-name binding in the -bundle; when that binding is not an array literal, or code off the declaring block's straight line -assigns it (a nested block, a function, or an expression-bodied arrow such as -`function init(){pY=[...]}`), the list is `partial`. +bundle. When that binding is not an array literal, is itself a bare or conditional assignment +rather than a declaration, or any other code assigns it (a conditional write in the same block such +as `if(c)pY=["B"]`, a nested block, a function such as `function init(){pY=["B"]}`, or an +expression-bodied arrow such as `()=>pY=["B"]`), the list is `partial`. ### 10. Find built-in tools by shape, not by builder diff --git a/plugins/claude-ops/skills/inventory/scripts/inventory.py b/plugins/claude-ops/skills/inventory/scripts/inventory.py index c3923e671a..c0f510541a 100755 --- a/plugins/claude-ops/skills/inventory/scripts/inventory.py +++ b/plugins/claude-ops/skills/inventory/scripts/inventory.py @@ -1945,13 +1945,27 @@ def _spread_array(src: str, braces: BraceMap, ident: str, at: int) -> int | None def _written_elsewhere(src: str, braces: BraceMap, ident: str, pos: int) -> bool: - """Whether code outside the straight line of the block declaring `ident` - at `pos` writes that binding: a nested block (`if(c){x=2}`, a loop - body), a function (`function f(){x=2}`), or an expression-bodied arrow - (`()=>x=2`). Whether and when that write runs is not static. A write - that a nearer declaration of `ident` shadows is to that local instead. + """Whether the binding at `pos` may not hold its initializer when read: + it is a bare assignment rather than a declaration (`if(c)x=2`, + `c&&(x=2)`), it sits in an expression-bodied arrow (`()=>x=2`), or any + other code writes it, in the same block (`if(c)x=2;`), a nested block, + or a function. A write that a nearer declaration of `ident` shadows is + to that local instead. """ - home = braces.enclosing(pos) + ident_re = r"[A-Za-z_$][\w$]*" + simple = r"(?:" + _STR + r"|[\w$.]+|\[(?:" + _STR + r'|[^\[\]"])*\])' + chain = re.compile( + r"(? bool: @@ -1969,10 +1983,7 @@ def in_arrow(at: int) -> bool: w = w.start() if not _visible(braces, pos, w, src): continue - block = braces.enclosing(w) - if block == home and not in_arrow(w): - continue - scope = _function_block(src, braces, w) or block + scope = _function_block(src, braces, w) or braces.enclosing(w) if scope is None or not any( d.start() != pos and _declares(src, d.start()) diff --git a/plugins/claude-ops/skills/inventory/scripts/test_inventory.py b/plugins/claude-ops/skills/inventory/scripts/test_inventory.py index fff9a56ca5..93be5e4957 100755 --- a/plugins/claude-ops/skills/inventory/scripts/test_inventory.py +++ b/plugins/claude-ops/skills/inventory/scripts/test_inventory.py @@ -1193,6 +1193,10 @@ def test_a_spread_written_off_the_straight_line_stays_partial(self) -> None: 'var pY=[xt,"Artifact"];for(;;){pY=["Other"]}', 'var pY=[xt,"Artifact"];var f=()=>pY=["Other"];f();', 'var f=()=>pY=["Other"];var pY=[xt,"Artifact"];f();', + 'var pY=[xt,"Artifact"];if(c)pY=["Other"];', + 'var pY=[xt,"Artifact"];pY=c?["Other"]:pY;', + 'var pY=[xt,"Artifact"];c&&(pY=["Other"]);', + 'var pY=[xt,"Artifact"];for(;;)pY=["Other"];', ): src = AGENT_SRC + ( prelude + 'var SP={agentType:"spread-probe",' @@ -1202,6 +1206,16 @@ def test_a_spread_written_off_the_straight_line_stays_partial(self) -> None: rec = self._extract(src)[0]["spread-probe"] self.assertEqual(rec["disallowed_tools_source"], "partial", prelude) + def test_a_spread_declared_after_a_function_declaration_resolves(self) -> None: + src = AGENT_SRC + ( + 'function h(){return 1}var a="x",b=["y","z"],pY=[xt,"Artifact"],q=1;' + 'var SP={agentType:"spread-probe",whenToUse:"s",source:"built-in",' + 'disallowedTools:[yt,...pY],getSystemPrompt:()=>""};' + ) + rec = self._extract(src)[0]["spread-probe"] + self.assertEqual(rec["disallowed_tools"], ["Agent", "Edit", "Artifact"]) + self.assertEqual(rec["disallowed_tools_source"], "literal") + def test_a_spread_whose_writer_shadows_the_name_still_resolves(self) -> None: src = AGENT_SRC + ( 'var pY=[xt,"Artifact"];function g(){let pY=[];pY=["Other"]}' From 53372f0a6e257de1aa0ca7a37b9d26f781c33b89 Mon Sep 17 00:00:00 2001 From: Kyle Sexton <153232337+kyle-sexton@users.noreply.github.com> Date: Thu, 1 Oct 2026 12:05:35 -0400 Subject: [PATCH 5/5] fix(claude-ops): treat a same-function var redeclaration as a write to a spread's binding `var pY=["A"];if(c){var pY=f()}` redeclares the same function-scoped binding, but `_written_elsewhere` skipped every declarator and took it as a shadow, so the spread read A as a literal. A declaration now shadows only when it introduces a separate binding: a `var` in another function, or a `let`/`const` in another block. A `var` in the same function is the binding again and its initializer counts as a write. Co-Authored-By: Claude Opus 5.5 --- .../skills/inventory/scripts/inventory.py | 20 ++++++++++++++---- .../inventory/scripts/test_inventory.py | 21 ++++++++++++------- 2 files changed, 30 insertions(+), 11 deletions(-) diff --git a/plugins/claude-ops/skills/inventory/scripts/inventory.py b/plugins/claude-ops/skills/inventory/scripts/inventory.py index c0f510541a..106bf2cebe 100755 --- a/plugins/claude-ops/skills/inventory/scripts/inventory.py +++ b/plugins/claude-ops/skills/inventory/scripts/inventory.py @@ -1975,19 +1975,31 @@ def in_arrow(at: int) -> bool: if in_arrow(pos): return True + home_fn = _function_block(src, braces, pos) + home_block = braces.enclosing(pos) + + def separate(d: int) -> bool: + """Whether a declaration at `d` introduces its own binding: a `var` + in another function, or a `let`/`const` in another block. A `var` + in the same function is this binding again, and its initializer a + write.""" + if d == pos or not _declares(src, d): + return False + if _is_var(src, d) or re.search(r"\bvar\s+$", src[max(0, d - 8) : d]): + return _function_block(src, braces, d) != home_fn + return braces.enclosing(d) != home_block + name = re.compile(r"(? None: 'var pY=[xt,"Artifact"];pY=c?["Other"]:pY;', 'var pY=[xt,"Artifact"];c&&(pY=["Other"]);', 'var pY=[xt,"Artifact"];for(;;)pY=["Other"];', + 'var pY=[xt,"Artifact"];if(c){var pY=f()}', ): src = AGENT_SRC + ( prelude + 'var SP={agentType:"spread-probe",' @@ -1217,13 +1218,19 @@ def test_a_spread_declared_after_a_function_declaration_resolves(self) -> None: self.assertEqual(rec["disallowed_tools_source"], "literal") def test_a_spread_whose_writer_shadows_the_name_still_resolves(self) -> None: - src = AGENT_SRC + ( - 'var pY=[xt,"Artifact"];function g(){let pY=[];pY=["Other"]}' - 'var SP={agentType:"spread-probe",whenToUse:"s",source:"built-in",' - 'disallowedTools:[yt,...pY],getSystemPrompt:()=>""};' - ) - rec = self._extract(src)[0]["spread-probe"] - self.assertEqual(rec["disallowed_tools"], ["Agent", "Edit", "Artifact"]) + for writer in ( + 'function g(){let pY=[];pY=["Other"]}', + 'if(c){let pY=f();pY=["Other"]}', + ): + src = AGENT_SRC + ( + 'var pY=[xt,"Artifact"];' + writer + 'var SP={agentType:"spread-probe",' + 'whenToUse:"s",source:"built-in",disallowedTools:[yt,...pY],' + 'getSystemPrompt:()=>""};' + ) + rec = self._extract(src)[0]["spread-probe"] + self.assertEqual( + rec["disallowed_tools"], ["Agent", "Edit", "Artifact"], writer + ) def test_no_roster_leaves_every_agent_absent(self) -> None: agents, notes = self._extract(AGENT_SRC.split("function R()")[0])