diff --git a/plugins/claude-ops/.claude-plugin/plugin.json b/plugins/claude-ops/.claude-plugin/plugin.json index 27c525167d..f34e402375 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.3", + "version": "0.79.4", "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 abb2803c71..9f09d399ff 100644 --- a/plugins/claude-ops/CHANGELOG.md +++ b/plugins/claude-ops/CHANGELOG.md @@ -3,6 +3,18 @@ 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.4] - 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, 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 ### Fixed diff --git a/plugins/claude-ops/skills/inventory/reference/extraction.md b/plugins/claude-ops/skills/inventory/reference/extraction.md index 186bd01302..9db862a883 100644 --- a/plugins/claude-ops/skills/inventory/reference/extraction.md +++ b/plugins/claude-ops/skills/inventory/reference/extraction.md @@ -272,7 +272,12 @@ 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, 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 152dfba25c..106bf2cebe 100755 --- a/plugins/claude-ops/skills/inventory/scripts/inventory.py +++ b/plugins/claude-ops/skills/inventory/scripts/inventory.py @@ -1921,6 +1921,91 @@ 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 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. + """ + ident_re = r"[A-Za-z_$][\w$]*" + simple = r"(?:" + _STR + r"|[\w$.]+|\[(?:" + _STR + r'|[^\[\]"])*\])' + chain = re.compile( + r"(? 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 + 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"(? re.Pattern[str]: return re.compile(r"function\s+" + re.escape(ident) + r"\s*\(([^()]*)\)\s*\{") @@ -3224,7 +3309,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,8 +3324,8 @@ def _array_names( if lit: names.append(_unescape(lit.group(1))) elif spread and hops > 0: - v = _nearest_binding(src, spread.group(1), at) - 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 a8bd023092..9585fc2bce 100755 --- a/plugins/claude-ops/skills/inventory/scripts/test_inventory.py +++ b/plugins/claude-ops/skills/inventory/scripts/test_inventory.py @@ -1157,6 +1157,81 @@ 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_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_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();', + '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"];', + 'var pY=[xt,"Artifact"];if(c){var pY=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_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: + 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]) self.assertFalse(notes["roster_found"])