From 96210696b94ac791dfdb87c87d56a22985efcc8c Mon Sep 17 00:00:00 2001 From: Kyle Sexton <153232337+kyle-sexton@users.noreply.github.com> Date: Thu, 1 Oct 2026 10:15:06 -0400 Subject: [PATCH 1/2] fix(claude-ops): leave a built-in name unresolved when it is bound to a non-constant expression The name resolvers took the nearest preceding string constant from an index that holds only string bindings, so a nearer binding to a conditional or call (the 2.1.286 skill loader's `Vt=$t?smt(e):e`) was invisible and an unrelated `Vt="string"` far ahead became a phantom built-in command. Every candidate now must be the string constant the module and scope rule says the read sees; otherwise the name stays unresolved. Revalidated against 2.1.286. Co-Authored-By: Claude Opus 5.5 --- .../skills/inventory/scripts/inventory.py | 76 +++++++++++++---- .../inventory/scripts/test_inventory.py | 83 +++++++++++++++++-- 2 files changed, 134 insertions(+), 25 deletions(-) diff --git a/plugins/claude-ops/skills/inventory/scripts/inventory.py b/plugins/claude-ops/skills/inventory/scripts/inventory.py index 2331d62162..ef3ac1e064 100755 --- a/plugins/claude-ops/skills/inventory/scripts/inventory.py +++ b/plugins/claude-ops/skills/inventory/scripts/inventory.py @@ -55,7 +55,7 @@ # the skill's evals. Drift from it is not an error - the extraction is designed # to survive ordinary releases - but it downgrades every count from "verified" # to "believed", which the report has to say out loud. -VALIDATED_AGAINST = "2.1.284" +VALIDATED_AGAINST = "2.1.286" # Commands that have shipped in every build observed. Their absence means the # extraction broke, not that Anthropic deleted /help. This is the cheapest @@ -1823,12 +1823,18 @@ def _binding_pattern(ident: str) -> re.Pattern[str]: def _binding_value( - src: str, braces: BraceMap, ident: str, at: int, *, deferred: bool = False + src: str, + braces: BraceMap, + ident: str, + at: int, + *, + deferred: bool = False, + window: int = SHORT_VALUE_LOCALITY_BYTES, ) -> int | None: """Offset of the `ident=` value `at` reads. A single-character name is function-local: the nearest binding before - `at` in `at`'s own module, within `SHORT_VALUE_LOCALITY_BYTES`. A longer + `at` in `at`'s own module, within `window` bytes. A longer one follows the module rule in `_declaration`. A binding after `at` is taken only when the read is `deferred`, reached through a getter, a method, an arrow, or a function-valued field, which run after the module @@ -1837,7 +1843,7 @@ def _binding_value( initializer. """ if len(ident) == 1: - lo = max(_chunk_span(src, at)[0], at - SHORT_VALUE_LOCALITY_BYTES) + lo = max(_chunk_span(src, at)[0], at - window) found = None for m in _binding_pattern(ident).finditer(src, lo, at): if _visible(braces, m.start(), at, src): @@ -2263,7 +2269,7 @@ def extract_builtin_commands(src: str, braces: BraceMap) -> dict[str, dict[str, c = _NAME_IDENT_RE.search(body) if not c or c.group(1) not in idents: continue - name = resolve_name_ident(c.group(1), open_i, index) + name = resolve_name_ident(src, braces, c.group(1), open_i, index) if not name or not _NAME_OK.fullmatch(name): continue aliases = _read_aliases(body) @@ -2428,7 +2434,11 @@ def build_const_index( def resolve_name_ident( - ident: str, at: int, index: dict[str, list[tuple[int, str]]] + src: str, + braces: BraceMap, + ident: str, + at: int, + index: dict[str, list[tuple[int, str]]], ) -> str | None: """Resolve a registration's name identifier by its nearest preceding binding. @@ -2439,7 +2449,7 @@ def resolve_name_ident( is trusted only when that nearest binding lies within `SHORT_IDENT_LOCALITY_BYTES`, because such names are function-local and a far binding belongs to some other function. No preceding binding at all is - unresolved, never guessed. + unresolved, never guessed. The candidate must also pass `_scoped_constant`. """ bindings = index.get(ident) if not bindings: @@ -2450,7 +2460,31 @@ def resolve_name_ident( pos, value = bindings[k] if len(ident) == 1 and at - pos > SHORT_IDENT_LOCALITY_BYTES: return None - return value + return _scoped_constant(src, braces, ident, at, value) + + +# A binding's value is a constant only when one string literal is the whole +# expression: `x="a",` or `x="a";`, not `x="a"+y` or `x="a"?b:c`. +_CONST_VALUE_RE = re.compile(_STR + r"(?=[ \t]*(?:[,;)}\n]|\Z))") + + +def _scoped_constant( + src: str, braces: BraceMap, ident: str, at: int, candidate: str +) -> str | None: + """`candidate` when the binding the read of `ident` at `at` sees, under + the module and scope rule of `_binding_value`, is that string constant. + + A constant index holds only string bindings, so its nearest entry can be + an unrelated one far behind a nearer binding to a conditional or a call + (`Vt=$t?smt(e):e`), or one in another module. Either way the read's + value is not that constant, and the name stays unresolved. + """ + try: + v = _binding_value(src, braces, ident, at, window=SHORT_IDENT_LOCALITY_BYTES) + except (ValueError, IndexError, RecursionError): + return None + m = _CONST_VALUE_RE.match(src, v) if v is not None else None + return candidate if m and _unescape(m.group(1)) == candidate else None def _nearest_binding(src: str, ident: str, at: int) -> int | None: @@ -2729,7 +2763,7 @@ def add(rec: dict[str, Any]) -> None: continue name, descriptor = found else: - resolved = resolve_name_ident(nm.group(2), call_start, index) + resolved = resolve_name_ident(src, braces, nm.group(2), call_start, index) if resolved is None: unresolved.append(nm.group(2)) continue @@ -3070,7 +3104,11 @@ def _workflow_invocation( def resolve_tool_ident( - ident: str, at: int, index: dict[str, list[tuple[int, str]]] + src: str, + braces: BraceMap, + ident: str, + at: int, + index: dict[str, list[tuple[int, str]]], ) -> str | None: """Resolve a tool-name identifier by its nearest preceding PascalCase binding. @@ -3079,7 +3117,8 @@ def resolve_tool_ident( identifier in between (`no="SendMessage"`, then `no="column"`). The index holds only tool-shaped values; among them the nearest PascalCase binding wins, and a snake_case one is taken only when no PascalCase binding - precedes. A single-character identifier keeps the usual locality limit. + precedes. A single-character identifier keeps the usual locality limit, + and the chosen value must pass `_scoped_constant`. """ bindings = index.get(ident) if not bindings: @@ -3087,10 +3126,11 @@ def resolve_tool_ident( before = bindings[: bisect.bisect_left(bindings, (at, ""))] if len(ident) == 1: before = [b for b in before if at - b[0] <= SHORT_IDENT_LOCALITY_BYTES] - for _, value in reversed(before): - if _PASCAL_RE.fullmatch(value): - return value - return before[-1][1] if before else None + value = next( + (v for _, v in reversed(before) if _PASCAL_RE.fullmatch(v)), + before[-1][1] if before else None, + ) + return None if value is None else _scoped_constant(src, braces, ident, at, value) def _name_expr( @@ -3154,7 +3194,7 @@ def _array_names( else: complete = False elif ident and not spread: - value = resolve_tool_ident(ident.group(0), at, index) + value = resolve_tool_ident(src, braces, ident.group(0), at, index) if value is None: complete = False else: @@ -3311,7 +3351,7 @@ def extract_builtin_agents( if kind == "literal": name = text elif kind == "ident" and text: - name = resolve_name_ident(text, open_i, name_index) + name = resolve_name_ident(src, braces, text, open_i, name_index) else: name = None if not name or not re.fullmatch(AGENT_NAME_RE, name): @@ -3419,7 +3459,7 @@ def extract_builtin_tools( if kind == "literal": name = text elif kind == "ident" and text: - name = resolve_tool_ident(text, open_i, index) + name = resolve_tool_ident(src, braces, text, open_i, index) if name is None and ( kind == "member" or (kind == "ident" and len(text or "") == 1) ): diff --git a/plugins/claude-ops/skills/inventory/scripts/test_inventory.py b/plugins/claude-ops/skills/inventory/scripts/test_inventory.py index 2870f14017..fc013698a6 100755 --- a/plugins/claude-ops/skills/inventory/scripts/test_inventory.py +++ b/plugins/claude-ops/skills/inventory/scripts/test_inventory.py @@ -138,6 +138,45 @@ def test_factory_parameter_name_is_not_resolved(self) -> None: src = 'var e="wrong";function t1t(e,n){return{type:"local-jsx",name:e,description:n}}' self.assertEqual(self._extract(src), {}) + def test_a_name_bound_to_a_conditional_is_not_resolved(self) -> None: + # The 2.1.286 skill loader: `Vt` is bound to a conditional in the + # loader's own scope, so an unrelated `Vt="string"` far ahead is not + # what the literal reads. + src = ( + 'var Vt="string";' + + "z" * 5000 + + 'function ld(e,xe){let $t=xe==="syncedSkills",Vt=$t?smt(e):e,' + 'Jt={type:"prompt",name:Vt,description:"d"};return Jt}' + ) + self.assertEqual(self._extract(src), {}) + + def test_a_name_bound_to_a_call_is_not_resolved(self) -> None: + src = ( + 'var Vt="string";function ld(e){let Vt=smt(e);' + 'return{type:"prompt",name:Vt,description:"d"}}' + ) + self.assertEqual(self._extract(src), {}) + + def test_a_constant_in_another_module_is_not_resolved(self) -> None: + src = _modules( + 'var Vt="string";', + 'var x={type:"prompt",name:Vt,description:"d"};', + ) + self.assertEqual(self._extract(src), {}) + + def test_a_constant_in_scope_or_imported_resolves(self) -> None: + local = ( + 'var Vt="string";function ld(){let Vt="review";' + 'return{type:"prompt",name:Vt,description:"d"}}' + ) + self.assertEqual(list(self._extract(local)), ["review"]) + imported = _modules( + 'var Vt="review";export{Vt};', + 'import{Vt}from"/$bunfs/root/chunk-a.js";' + 'var x={type:"prompt",name:Vt,description:"d"};', + ) + self.assertEqual(list(self._extract(imported)), ["review"]) + def test_internal_names_are_marked(self) -> None: src = 'x={type:"prompt",name:"mcp__",description:"d"};' self.assertTrue(self._extract(src)["mcp__"]["internal"]) @@ -562,6 +601,17 @@ def test_a_long_identifier_bound_far_ahead_resolves(self) -> None: skills, _ = self._skills(src) self.assertIn("simplify", skills) + def test_a_nearer_non_constant_binding_shadows_a_constant(self) -> None: + src = ( + self.HEAD + + 'var kYe="simplify";' + + "z" * 500 + + 'function f(e){let kYe=e?g(e):"x";eo({name:kYe,menuDescription:"D"})}' + ) + skills, notes = self._skills(src) + self.assertEqual(skills, {}) + self.assertEqual(notes["unresolved_dynamic_names"], ["kYe"]) + def test_a_binding_after_the_registration_does_not_resolve_it(self) -> None: src = self.HEAD + 'eo({name:zz,menuDescription:"D"});var zz="later";' skills, notes = self._skills(src) @@ -1117,7 +1167,7 @@ def test_no_roster_leaves_every_agent_absent(self) -> None: 'var Qz="Bash",at="Read",xt="Edit",hn="Write",wr="WebFetch";' 'var k1="SendUserFile";var m1="memory_read";' "var Kl={isEnabled:()=>!0,isConcurrencySafe:(e)=>!1};" - 'k1="system_assigned_identity";' + 'function Ku(){let k1="system_assigned_identity";return k1}' '$t({name:Qz,searchHint:"execute shell commands",' "get maxResultSizeChars(){return 1}," 'async description({description:e}){return e||"Run"},isEnabled(){return!0}});' @@ -1159,13 +1209,23 @@ def test_every_tool_shape_is_found_without_the_builder_name(self) -> None: def test_pascal_case_binding_wins_over_a_nearer_snake_case_one(self) -> None: # `k1` is rebound to a snake_case string nearer the definition, as - # unrelated modules rebind minified names; a snake_case value is taken + # unrelated code rebinds minified names; a snake_case value is taken # only when no PascalCase binding precedes (`m1`). tools = self._extract()[0] self.assertIn("SendUserFile", tools) self.assertIn("memory_read", tools) self.assertNotIn("system_assigned_identity", tools) + def test_a_snake_case_rebinding_the_definition_reads_is_not_skipped(self) -> None: + # The definition reads the nearer rebinding, so the PascalCase + # binding behind it is not its name: unresolved, never guessed. + src = TOOL_SRC.replace( + "$t({name:k1,", 'k1="system_assigned_identity";$t({name:k1,' + ) + tools, notes = self._extract(src) + self.assertNotIn("SendUserFile", tools) + self.assertIn("k1", notes["unresolved_names"]) + def test_descriptions_hints_and_names(self) -> None: tools = self._extract()[0] self.assertEqual(tools["Read"]["description"], "Read a file") @@ -1196,11 +1256,11 @@ def test_an_unbound_name_is_unresolved_not_a_factory(self) -> None: self.assertEqual(notes["unresolved_names"], ["zzq"]) def test_resolve_tool_ident_honors_short_locality(self) -> None: - index = {"e": [(0, "Bash")]} - self.assertIsNone( - inv.resolve_tool_ident("e", inv.SHORT_IDENT_LOCALITY_BYTES + 10, index) - ) - self.assertEqual(inv.resolve_tool_ident("e", 10, index), "Bash") + src = 'var e="Bash";' + "z" * (inv.SHORT_IDENT_LOCALITY_BYTES + 10) + braces = inv.build_brace_map(src) + index = inv.build_const_index(src, None, inv.TOOL_NAME_RE) + self.assertIsNone(inv.resolve_tool_ident(src, braces, "e", len(src), index)) + self.assertEqual(inv.resolve_tool_ident(src, braces, "e", 20, index), "Bash") def _tool(src: str, name: str) -> dict: @@ -1521,6 +1581,15 @@ def test_a_single_letter_binding_never_crosses_a_module_boundary(self) -> None: ) self.assertEqual(_tool(src, "Probe")["description"], "Use … here") + def test_a_tool_name_bound_to_a_conditional_is_not_resolved(self) -> None: + src = _modules( + 'var Qz="Probe";' + 'function f(e){let Qz=e?"Probe":"Other";' + '$t({name:Qz,maxResultSizeChars:1,description:"d"})}' + ) + tools, _ = inv.extract_builtin_tools(src, inv.build_brace_map(src)) + self.assertEqual(tools, {}) + def test_a_later_binding_resolves_only_inside_a_function_body(self) -> None: src = _modules( 'var Qz="Probe",Pz="Lazy";' From bb49f6cb6979141ee2088e3a51c7661744519a1f Mon Sep 17 00:00:00 2001 From: Kyle Sexton <153232337+kyle-sexton@users.noreply.github.com> Date: Thu, 1 Oct 2026 10:16:25 -0400 Subject: [PATCH 2/2] chore(claude-ops): bump to 0.79.2 and document the scoped name check 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 | 7 +++++++ 3 files changed, 19 insertions(+), 1 deletion(-) diff --git a/plugins/claude-ops/.claude-plugin/plugin.json b/plugins/claude-ops/.claude-plugin/plugin.json index 5f928fea54..1fbd652f05 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.1", + "version": "0.79.2", "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 0acefcc50a..8f26242d5f 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.2] - 2026-10-01 + +### Fixed + +- **The inventory no longer reports a phantom built-in command `string` on Claude Code 2.1.286.** + A command, bundled-skill, subagent or tool name held in an identifier now resolves only when the + binding that read sees, by the module and scope rule, is that string constant. A name bound to a + conditional or a call, or a constant in another module, stays unresolved; before, the nearest + string constant anywhere ahead won, so the skill loader's `Vt=$t?smt(e):e` read an unrelated + `Vt="string"`. `VALIDATED_AGAINST` is `2.1.286`. + ## [0.79.1] - 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 80446c6107..186bd01302 100644 --- a/plugins/claude-ops/skills/inventory/reference/extraction.md +++ b/plugins/claude-ops/skills/inventory/reference/extraction.md @@ -93,6 +93,13 @@ everywhere, so it is trusted only when that nearest binding lies within `eo({name:r,...})`) resolves, while a loop variable whose only binding is megabytes away does not. No preceding binding is unresolved, never guessed. +The index holds only string bindings, so its nearest entry can sit behind a nearer binding it +cannot see. Every candidate, for commands, bundled skills, subagents and tools alike, must also be +the string constant the read sees under the module and scope rule that field resolution uses +(`_scoped_constant`); otherwise the name stays unresolved. In 2.1.286 the generic skill loader +builds `{type:"prompt",name:Vt,...}` with `Vt=$t?smt(e):e` in its own scope, and an unrelated +`Vt="string"` megabytes ahead used to surface as a built-in command `string`. + Two further shapes, both first seen in 2.1.284: - **Descriptor member.** `let t=c;ps({name:t.name,description:t.description,...})` with