diff --git a/plugins/claude-ops/.claude-plugin/plugin.json b/plugins/claude-ops/.claude-plugin/plugin.json index 3a51162608..5f928fea54 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.0", + "version": "0.79.1", "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 b42b9cfade..0acefcc50a 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.1] - 2026-10-01 + +### Fixed + +- **A local declaration shadows an imported name in the inventory.** A name the module imports + but the reader's own enclosing block declares now resolves as a runtime value, not through the + import to the exporting module's value. +- **A `for` head's `let`/`const` shadows outer names in the loop body.** `for (const x of ...)`, + `for (let i = 0; ...)` and `for await` bind their names for the body only, so a same-named + outer binding no longer supplies the value. This includes an unbraced loop body and a binding + named `of` or `in`. + ## [0.79.0] - 2026-10-01 ### Added diff --git a/plugins/claude-ops/skills/inventory/reference/extraction.md b/plugins/claude-ops/skills/inventory/reference/extraction.md index 9330aef9ce..80446c6107 100644 --- a/plugins/claude-ops/skills/inventory/reference/extraction.md +++ b/plugins/claude-ops/skills/inventory/reference/extraction.md @@ -199,8 +199,9 @@ the else branch of a ternary, the final `return` of a getter, which is the defau every 2.1.284 case. An operand is a `+` concatenation whose parts may also be a parenthesized expression (`d+(x()?m:c)+p`) or a literal array's `.join(sep)`. -A parameter of the function or method being read is a runtime value: it is shadowed, so it never -resolves to a same-named binding elsewhere, and what depends on it becomes a condition, an +A parameter of the function or method being read, a `catch` parameter, a `let`/`const` bound in a +`for (...)` head, and a declaration in an enclosing block that the reader can see are runtime +values: each is shadowed, so it never resolves to a same-named import or outer binding, and what depends on it becomes a condition, an ellipsis, or nothing. A result whose ellipses leave no static word (`${a}\n\n${b}` with neither resolved) is unresolved, not a value. diff --git a/plugins/claude-ops/skills/inventory/scripts/inventory.py b/plugins/claude-ops/skills/inventory/scripts/inventory.py index 2331d62162..e697a1d94b 100755 --- a/plugins/claude-ops/skills/inventory/scripts/inventory.py +++ b/plugins/claude-ops/skills/inventory/scripts/inventory.py @@ -650,6 +650,36 @@ def _catch_params(src: str, braces: BraceMap, brace: int) -> Scope: return _param_names(src[k + 1 : j]) +_FOR_KEYWORD_RE = re.compile(r"(? Scope: + """The `let`/`const` names of the `for (...)` head whose body opens at + `brace`, each a runtime value; empty for any other block.""" + j = brace - 1 + while j >= 0 and src[j] in " \t\r\n": + j -= 1 + if j < 0 or src[j] != ")": + return NO_SCOPE + return _for_head_names(src, braces, j) + + +def _for_head_names(src: str, braces: BraceMap, close: int) -> Scope: + """The `let`/`const` names of the `for (...)` head closing at `close`. + + The whole head after the keyword is taken, iterable included: shadowing + extra names only leaves more unresolved, and a binding named `of` or `in` + is still caught. + """ + j = close + k = _head_open(src, braces, j) + if not _FOR_KEYWORD_RE.search(src[max(0, k - 24) : k]): + return NO_SCOPE + decl = _FOR_DECL_RE.match(_mask_strings(src[k + 1 : j])) + return _param_names(decl.group(1)) if decl else NO_SCOPE + + def _head_open(src: str, braces: BraceMap, close: int) -> int: """The `(` matching the `)` at `close`, matched with quoted text blanked from the enclosing block's start. Raises ValueError when unmatched.""" @@ -967,6 +997,7 @@ def _scan( active = at_value = not block depth = 0 prev, prev_word = "", "" + loop = NO_SCOPE n = min(end, len(src)) while i < n: c = src[i] @@ -992,7 +1023,7 @@ def _scan( acc, hops=hops, anchor=anchor, - shadow=shadow, + shadow=shadow | loop, deferred=deferred, ) at_value, prev, prev_word = False, "x", "" @@ -1021,9 +1052,18 @@ def _scan( block=True, hops=hops, anchor=anchor, - shadow=shadow | _catch_params(src, braces, i), + shadow=shadow + | loop + | _catch_params(src, braces, i) + | _for_params(src, braces, i), deferred=deferred, ) + # A statement block ends the unbraced loop body holding it. + ends = not re.match( + r"\s*(?:else|catch|finally)(?![\w$])", src[close + 1 : close + 17] + ) + if ends: + loop = NO_SCOPE i, at_value, prev, prev_word = close + 1, False, "}", "" continue if c == "}": @@ -1034,7 +1074,13 @@ def _scan( if depth == 0: break depth -= 1 + if depth == 0 and c == ")" and block: + # An unbraced loop body is no block, so its head binds here. + nxt = _skip_ws(src, i + 1, n) + if not src.startswith("{", nxt): + loop = loop | _for_head_names(src, braces, i) elif depth == 0 and c in ",;": + loop = NO_SCOPE if not block: break if c == ";": @@ -1725,6 +1771,11 @@ def _declaration( return found lo, hi = _chunk_span(src, at) exported = _chunk_imports(src, lo, hi).get(ident) + if exported is not None and any( + braces.enclosing(m.start()) is not None and _visible(braces, m.start(), at, src) + for m in pattern_for(ident).finditer(src, lo, hi) + ): + exported = None if exported is not None: homes = _export_index(src).get(exported, []) if len(homes) != 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..83e8dd7c40 100755 --- a/plugins/claude-ops/skills/inventory/scripts/test_inventory.py +++ b/plugins/claude-ops/skills/inventory/scripts/test_inventory.py @@ -1507,6 +1507,15 @@ def test_an_imported_name_resolves_in_its_exporting_module(self) -> None: ) self.assertEqual(_tool(src, "Probe")["description"], "Write a Workflow script") + def test_a_local_declaration_shadows_an_imported_name(self) -> None: + src = _modules( + 'var jd="WRONG";export{jd};', + 'import{jd}from"/$bunfs/root/chunk-a.js";var Qz="Probe";' + 'function ff(){let jd="LOCAL";return jd}' + "$t({name:Qz,maxResultSizeChars:1,description:ff()});", + ) + self.assertNotEqual(_tool(src, "Probe")["description"], "WRONG") + def test_a_name_neither_imported_nor_declared_is_a_runtime_value(self) -> None: src = _modules( 'var jd="host_exit";', @@ -1679,6 +1688,86 @@ def test_a_catch_parameter_shadows_a_bound_parameter(self) -> None: ) self.assertNotEqual(_tool(src, "Probe").get("description"), "REAL") + def test_a_for_head_binding_shadows_a_bound_parameter(self) -> None: + src = _modules( + 'var Qz="Probe";' + 'function ff(x){for(const x of ["LOCAL"]){return x}}' + '$t({name:Qz,maxResultSizeChars:1,description:ff("REAL")});' + ) + self.assertNotEqual(_tool(src, "Probe").get("description"), "REAL") + + def test_a_destructured_for_head_without_a_space_shadows_a_bound_parameter( + self, + ) -> None: + for head in ("const{x}", "let{x}", "const[x]", "let[x]"): + of = "[{x:'L'}]" if "{" in head else "[['L']]" + with self.subTest(head=head): + src = _modules( + 'var Qz="Probe";' + f"function ff(x){{for({head}of{of}){{return x}}}}" + '$t({name:Qz,maxResultSizeChars:1,description:ff("REAL")});' + ) + self.assertNotEqual(_tool(src, "Probe").get("description"), "REAL") + + def test_an_unbraced_for_body_sees_the_head_binding(self) -> None: + src = _modules( + 'var Qz="Probe";' + "function ff(x){for(const x of a)return x}" + '$t({name:Qz,maxResultSizeChars:1,description:ff("REAL")});' + ) + self.assertNotEqual(_tool(src, "Probe").get("description"), "REAL") + + def test_an_unbraced_for_body_ends_at_its_statement(self) -> None: + src = _modules( + 'var Qz="Probe";' + "function ff(x){for(const x of a)g(x);return x}" + '$t({name:Qz,maxResultSizeChars:1,description:ff("REAL")});' + ) + self.assertEqual(_tool(src, "Probe")["description"], "REAL") + + def test_a_nested_statement_in_an_unbraced_for_body_keeps_the_head_binding( + self, + ) -> None: + for body in ( + "if(x)return x", + "for(const y of b)return x", + "if(t(x,{k:1}))return x", + "return c?{k:1}:x", + ): + with self.subTest(body=body): + src = _modules( + 'var Qz="Probe";' + f"function ff(x){{for(const x of a){body}}}" + '$t({name:Qz,maxResultSizeChars:1,description:ff("REAL")});' + ) + self.assertNotEqual(_tool(src, "Probe").get("description"), "REAL") + + def test_an_unbraced_for_body_ending_in_a_block_ends_its_head_binding(self) -> None: + src = _modules( + 'var Qz="Probe";' + "function ff(x,c){for(const x of a)if(c){g()}return x}" + '$t({name:Qz,maxResultSizeChars:1,description:ff("REAL")});' + ) + self.assertEqual(_tool(src, "Probe")["description"], "REAL") + + def test_a_for_head_binding_named_of_shadows_a_bound_parameter(self) -> None: + for head in ("const of of a", "const{of}of a"): + with self.subTest(head=head): + src = _modules( + 'var Qz="Probe";' + f"function ff(of){{for({head}){{return of}}}}" + '$t({name:Qz,maxResultSizeChars:1,description:ff("REAL")});' + ) + self.assertNotEqual(_tool(src, "Probe").get("description"), "REAL") + + def test_a_for_head_binding_shadows_an_outer_binding(self) -> None: + src = _modules( + 'var Qz="Probe";var xx="WRONG";' + "function ff(){for(let xx of a){return xx}}" + "$t({name:Qz,maxResultSizeChars:1,description:ff()});" + ) + self.assertNotEqual(_tool(src, "Probe").get("description"), "WRONG") + def test_a_quoted_paren_in_a_control_head_keeps_a_var_function_scoped( self, ) -> None: