@allansantos-dev/smart-tool 0.9.32 → 0.9.33

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/CHANGELOG.md CHANGED
@@ -1,5 +1,14 @@
1
1
  # Changelog
2
2
 
3
+ ## 0.9.33 - beta
4
+
5
+ - Recommended models prefer the Vertex route. When the gateway lists a model on several routes, `model_defaults` now
6
+ picks `vertex_ai.*` first, any other route next and `bedrock.*` only when nothing else serves the model (Bedrock was
7
+ chosen before because ids were taken in alphabetical order). `DEFAULTS_VERSION` is 2, so users who already hold the
8
+ Bedrock ids get the "New recommended models" offer again and decide whether to apply it.
9
+ - `tests/vertex_route_probe.py` asks the live gateway: every recommended chat id must resolve (Anthropic ones on
10
+ Vertex) and answer a real request. Result of 2026-10-26 in `docs/research/2026-10-26-rota-vertex-nas-recomendacoes.md`.
11
+
3
12
  ## 0.9.32 - beta
4
13
 
5
14
  - Users who updated are told about the new recommended models. `model_upgrade` compares the saved `router_model`,
package/README.md CHANGED
@@ -16,7 +16,7 @@ registered as the `playwright` server where this machine lets a browser be drive
16
16
  An optional hook routes the agent's native Grep/Glob/Read/Bash/WebSearch/WebFetch calls to these tools when they
17
17
  are cheaper, or just tells the agent what Smart Tool would do.
18
18
 
19
- **Status:** 0.9.32 beta. Windows only. What changed in each version: [CHANGELOG.md](CHANGELOG.md).
19
+ **Status:** 0.9.33 beta. Windows only. What changed in each version: [CHANGELOG.md](CHANGELOG.md).
20
20
 
21
21
  ## How it works
22
22
 
@@ -111,8 +111,18 @@ Redirects) and Maintenance (Setup audit, Startup).
111
111
  URL ending where the API starts, like the OpenAI SDK `base_url` (`https://api.openai.com/v1`,
112
112
  `https://openrouter.ai/api/v1`, `http://localhost:11434/v1`), and an optional API key. Saving tests `/models` first.
113
113
  2. **Models**: embedding, rerank (optional; without it results use the hybrid order), the router model (small, no
114
- reasoning: web search tier order and result checks), the index classification model (also reviews block patterns) and the research model. Any id the gateway
115
- accepts works; the catalog is shown as suggestions.
114
+ reasoning: web search tier order and result checks), the index classification model (also reviews block patterns), the
115
+ scout model (the short answer of `smart_scout`) and the research model. Any id the gateway accepts works; the catalog is
116
+ shown as suggestions. Empty roles are filled with the recommended models the gateway offers.
117
+
118
+ When a new version recommends other models than the ones you use, a Windows notification (**Apply now**, **Don't
119
+ show again**) and the **New recommended models** card on this page offer them: nothing changes until you apply, the
120
+ models you had are kept, and **Switch back to my previous models** undoes it. The **Model failures (last 24 h)** table
121
+ lists every failed gateway call by role, model and tool; a model that fails half of its calls (5 or more) also raises a
122
+ notification and a tray alert. A failure inside a tool call also comes back as a warning line in the tool answer;
123
+ calls made by a hook or by background work (index scope and profile, block-pattern proposals) have no answer to
124
+ carry it, so they show only in the table, the log and the notification. The local embedder is not in that table or in
125
+ the usage meter: it is a sidecar on your machine with no price, and its state is the **Local models** section.
116
126
  3. **Agents**: register the MCP server in Claude Code or Codex (runs their official `mcp add` command), install the
117
127
  hook and choose its behavior under **Agent rules**. A hook written by an older Smart Tool shows as outdated, with
118
128
  what an update changes; updating previews the entry and rewrites only Smart Tool's, after a backup.
@@ -177,11 +187,13 @@ Redirects) and Maintenance (Setup audit, Startup).
177
187
  plus changed tests and tests using a pytest fixture whose `conftest.py` imports the changed code, ordered with the
178
188
  likeliest failures first, and the command to run them. Changed files are read from disk, so new files and imports
179
189
  count before the project is reindexed. `run_all` is true, with
180
- the reason, when a configuration or lockfile changed or a changed code file is outside the import graph.
190
+ the reason, when a configuration or lockfile changed or a changed code file is outside the import graph. Go is read
191
+ by package: a file that imports a package depends on every file of it, so a changed package selects the tests of every
192
+ package that imports it (`go test ./pkg ...`); the Go and Rust documentation and duplicate hooks stay silent.
181
193
 
182
194
  `project_manage` with `action=setup_audit` and a `project_root` (or `python setup_audit.py --project <folder>`)
183
- reports what Claude Code and Codex actually load for that folder (settings layers, enabled plugins, hooks, MCP
184
- servers, skills, CLAUDE.md and AGENTS.md) and where they collide: several hooks deciding on the same tool in
195
+ reports what Claude Code and Codex actually load for that folder (settings layers, enabled plugins of both,
196
+ hooks, MCP servers, skills, CLAUDE.md and AGENTS.md) and where they collide: several hooks deciding on the same tool in
185
197
  parallel, hook scripts that do not exist or hide their errors, hooks calling an MCP server that is not configured,
186
198
  MCP servers that do not start or offer the same tools twice (a gateway re-exposing a server also registered
187
199
  directly), skills sharing a name or over the description budget, an AGENTS.md only Codex reads, broken `@imports`
package/affected_tests.py CHANGED
@@ -22,7 +22,7 @@ RUN_ALL = re.compile(
22
22
  r"bun\.lockb?|pyproject\.toml|setup\.cfg|setup\.py|pytest\.ini|tox\.ini|requirements[^/]*\.txt|poetry\.lock|"
23
23
  r"uv\.lock|Pipfile(\.lock)?|tsconfig[^/]*\.json|jsconfig\.json|(jest|vitest|vite|babel)\.config\.[^/]+|"
24
24
  r"\.babelrc|\.mocharc[^/]*|pom\.xml|build\.gradle(\.kts)?|settings\.gradle(\.kts)?|gradle\.properties|"
25
- r"\.env[^/]*)$")
25
+ r"\.env[^/]*|go\.(mod|sum|work(\.sum)?))$")
26
26
  CI_CONFIG = re.compile(r"(^|/)(\.github|\.circleci|\.buildkite)/|(^|/)(\.gitlab-ci\.ya?ml|azure-pipelines\.ya?ml|"
27
27
  r"Jenkinsfile|\.travis\.ya?ml|bitbucket-pipelines\.ya?ml|\.gitignore|\.gitattributes|"
28
28
  r"\.editorconfig|\.mailmap|CODEOWNERS|LICENSE(\.[^/]*)?|\.npmignore)$")
@@ -31,7 +31,7 @@ NOTE = ("Selection from the indexed import graph: tests that load code through s
31
31
  "suite before committing; run_all is true when a changed file can change every test (config, lockfile, "
32
32
  "unanalyzed code). Changed files and files committed after the last indexing are read from disk, so new "
33
33
  "files and imports count before reindexing; pytest fixtures link a test to the conftest.py that defines them.")
34
- TEST_SOURCES = (".py", ".js", ".jsx", ".ts", ".tsx", ".mjs", ".cjs", ".mts", ".cts", ".java")
34
+ TEST_SOURCES = (".py", ".js", ".jsx", ".ts", ".tsx", ".mjs", ".cjs", ".mts", ".cts", ".java", ".go")
35
35
  _PATH_LITERAL = re.compile(r"""['"`]((?:\.{0,2}/)?[\w.-]+(?:/[\w.-]+)*\.(?:py|js|mjs|cjs|ts|mts|cts|java))['"`]""")
36
36
  _STRING_LITERAL = re.compile(r"""'([^'\n]*)'|"([^"\n]*)\"""")
37
37
  _IMPORT_STATEMENT = re.compile(r"(?:^|;)\s*(?:import|from)\s+([A-Za-z_]\w*(?:\.\w+)*)")
@@ -132,6 +132,9 @@ def _commands(root, tests):
132
132
  if runner is None and re.search(rf"\bnode\s+(\./)?{re.escape(local)}\b", scripts):
133
133
  runner = "node-script"
134
134
  groups[(folder or "", runner)].append(path)
135
+ elif lower.endswith(".go"):
136
+ folder, name = _nearest(root, path, ("go.mod",))
137
+ groups[(folder or "", "go" if name else None)].append(path)
135
138
  elif lower.endswith(".java"):
136
139
  folder, name = _nearest(root, path, ("pom.xml", "build.gradle", "build.gradle.kts"))
137
140
  groups[(folder or "", "maven" if name == "pom.xml" else "gradle" if name else None)].append(path)
@@ -147,6 +150,8 @@ def _commands(root, tests):
147
150
  if runner == "maven":
148
151
  names = ",".join(sorted({os.path.basename(f).rsplit(".", 1)[0] for f in files}))
149
152
  line = f"mvn test -Dtest={names} -Dsurefire.failIfNoSpecifiedTests=false"
153
+ elif runner == "go":
154
+ line = "go test " + " ".join(sorted({"./" + posixpath.dirname(f) if posixpath.dirname(f) else "." for f in local}))
150
155
  elif runner == "node-script":
151
156
  commands.extend({"cwd": folder or ".", "command": f"node {f}"} for f in sorted(local))
152
157
  continue
@@ -260,6 +265,7 @@ _TEST_CASES = {
260
265
  ".py": re.compile(r"^\s*(async\s+)?def\s+test\w*\s*\(|^\s*class\s+Test\w*|unittest\.TestCase", re.M),
261
266
  ".java": re.compile(r"@(Test|ParameterizedTest|RepeatedTest|TestFactory|TestTemplate|RunWith|Suite)\b|"
262
267
  r"\bstatic\s+\w*Test\s+suite\s*\(|\bextends\s+TestCase\b|\bvoid\s+test\w*\s*\("),
268
+ ".go": re.compile(r"^func\s+(Test|Benchmark|Fuzz|Example)\w*\s*\(", re.M),
263
269
  "script": re.compile(r"\b(it|test|describe|suite)(\.\w+)?\s*\(|\bDeno\.test\b|\bnode:test\b|\bassert\b"),
264
270
  }
265
271
 
package/code_graph.py CHANGED
@@ -20,10 +20,11 @@ import index_scope
20
20
  import project_identity
21
21
  import web_search_adapters
22
22
  import web_document_graph
23
+ import code_graph_go
23
24
  import document_text
24
25
 
25
- VERSION = 2
26
- SOURCE_EXTENSIONS = ('.py','.java','.js','.jsx','.ts','.tsx','.mjs','.cjs','.mts','.cts','.json','.html','.htm','.css','.scss','.sass','.less','.md','.markdown')
26
+ VERSION = 3
27
+ SOURCE_EXTENSIONS = ('.py','.go','.mod','.java','.js','.jsx','.ts','.tsx','.mjs','.cjs','.mts','.cts','.json','.html','.htm','.css','.scss','.sass','.less','.md','.markdown')
27
28
  MAX_OVERLAY_BYTES = 5 * 1024 * 1024
28
29
  MAX_FILES = 3000
29
30
  MAX_TEXT = 24 * 1024 * 1024
@@ -284,7 +285,7 @@ def _analyze(path):
284
285
  def _analyze_sources(files,sources,diagnostics,truncated):
285
286
  merged={'symbols':[],'dependencies':[],'calls':[],'unresolved':[],'diagnostics':diagnostics,'parsed':[]}
286
287
  retryable=False
287
- for output in (_python_graph(sources),_javascript_graph(sources,[f['path'] for f in files]),_java_graph(sources),web_document_graph.analyze(sources,[f['path'] for f in files])):
288
+ for output in (_python_graph(sources),_javascript_graph(sources,[f['path'] for f in files]),_java_graph(sources),code_graph_go.analyze(sources),web_document_graph.analyze(sources,[f['path'] for f in files])):
288
289
  for key in merged:merged[key].extend(output.get(key,[]))
289
290
  truncated |= output.get('truncated',False)
290
291
  retryable |= output.get('retryable',False)
@@ -0,0 +1,85 @@
1
+ """Imports between Go files, read from the package clause and the import declarations of the indexed snapshot.
2
+
3
+ A Go package is a directory, so a file that imports a package depends on every non-test file of that directory, and an
4
+ internal test file (`package x` in `x_test.go`) depends on every non-test file of its own directory. Import paths are
5
+ resolved against the nearest `go.mod` module path; with no `go.mod` in the snapshot an import resolves to the longest
6
+ directory whose path ends the import path. No symbols or calls are produced: the doc and duplicate hooks stay silent
7
+ for Go until their thresholds are measured on it, and `affected_tests` selects by package import."""
8
+ import posixpath
9
+ import re
10
+
11
+ _COMMENT = re.compile(r"//[^\n]*|/\*.*?\*/", re.S)
12
+ _PACKAGE = re.compile(r"^package\s+(\w+)", re.M)
13
+ _IMPORT_BLOCK = re.compile(r"^import\s*\((.*?)^\)", re.M | re.S)
14
+ _IMPORT_ONE = re.compile(r'^import\s+(?:[\w.]+\s+)?"([^"]+)"', re.M)
15
+ _IMPORT_SPEC = re.compile(r'^\s*(?:[\w.]+\s+)?"([^"]+)"', re.M)
16
+ _MODULE = re.compile(r"^module\s+(\S+)", re.M)
17
+
18
+
19
+ def _module_roots(sources):
20
+ """Folder of each go.mod in the snapshot -> its module path."""
21
+ roots = {}
22
+ for path, text in sources.items():
23
+ if posixpath.basename(path) == "go.mod":
24
+ match = _MODULE.search(_COMMENT.sub("", text))
25
+ if match:
26
+ roots[posixpath.dirname(path)] = match.group(1).strip('"')
27
+ return roots
28
+
29
+
30
+ def _enclosing_module(folder, roots):
31
+ """(module folder, module path) of the nearest go.mod above `folder`, or (None, None)."""
32
+ while True:
33
+ if folder in roots:
34
+ return folder, roots[folder]
35
+ if not folder:
36
+ return None, None
37
+ folder = posixpath.dirname(folder)
38
+
39
+
40
+ def _imports(text):
41
+ """Import paths of one Go file (comments already blanked)."""
42
+ found = [match.group(1) for match in _IMPORT_ONE.finditer(text)]
43
+ for block in _IMPORT_BLOCK.finditer(text):
44
+ found.extend(spec.group(1) for spec in _IMPORT_SPEC.finditer(block.group(1)))
45
+ return found
46
+
47
+
48
+ def analyze(sources):
49
+ go_files = {path: text for path, text in sources.items() if path.endswith(".go")}
50
+ parsed = [path for path in sources if path.endswith(".go") or posixpath.basename(path) == "go.mod"]
51
+ if not go_files:
52
+ return {"parsed": parsed}
53
+ roots = _module_roots(sources)
54
+ packages = {}
55
+ for path in go_files:
56
+ if not path.endswith("_test.go"):
57
+ packages.setdefault(posixpath.dirname(path), []).append(path)
58
+
59
+ def package_dir(path, spec):
60
+ folder, module = _enclosing_module(posixpath.dirname(path), roots)
61
+ if module is not None:
62
+ if spec != module and not spec.startswith(module + "/"):
63
+ return None
64
+ return folder if spec == module else posixpath.normpath(posixpath.join(folder, spec[len(module) + 1:]))
65
+ candidates = [d for d in packages if d and (spec == d or spec.endswith("/" + d))]
66
+ return max(candidates, key=len, default=None)
67
+
68
+ dependencies = []
69
+ for path, text in sorted(go_files.items()):
70
+ bare = _COMMENT.sub(lambda m: re.sub(r"[^\n]", " ", m.group(0)), text)
71
+ for spec in _imports(bare):
72
+ folder = package_dir(path, spec)
73
+ line = bare[:bare.find(f'"{spec}"')].count("\n") + 1
74
+ targets = [t for t in packages.get(folder, ()) if t != path] if folder is not None else []
75
+ if not targets:
76
+ dependencies.append({"source": path, "target": None, "specifier": spec, "line": line, "kind": "import",
77
+ "resolution": "external"})
78
+ dependencies.extend({"source": path, "target": target, "specifier": spec, "line": line, "kind": "import",
79
+ "resolution": "resolved"} for target in targets)
80
+ clause = _PACKAGE.search(bare)
81
+ if path.endswith("_test.go") and clause and not clause.group(1).endswith("_test"):
82
+ dependencies.extend({"source": path, "target": target, "specifier": posixpath.dirname(path) or ".",
83
+ "line": 1, "kind": "package", "resolution": "resolved"}
84
+ for target in packages.get(posixpath.dirname(path), ()))
85
+ return {"dependencies": dependencies, "parsed": parsed}
package/config.py CHANGED
@@ -20,6 +20,9 @@ CONFIG_PATH = os.path.join(CONFIG_DIR, "config.json")
20
20
  # RLock: update_config() chama load/save sob o mesmo lock; Lock travaria a própria thread.
21
21
  _LOCK = threading.RLock()
22
22
 
23
+ # The config fields that select a gateway model; every per-role table (recommended models, failure log) keys on these.
24
+ MODEL_ROLES = ("router_model", "scope_model", "scout_model", "research_model", "embedding_model", "rerank_model")
25
+
23
26
  DEFAULT_CONFIG = {
24
27
  "router_model": "",
25
28
  "scope_model": "",
package/index_profile.py CHANGED
@@ -12,10 +12,12 @@ KINDS = ("code", "test", "doc")
12
12
  RETRY_AFTER_S = 3600
13
13
  MAX_DIRS = 90
14
14
  PROSE = {"md", "txt", "docx", "rst", "adoc"}
15
- # Data and markup inside a documentation folder are reports, not code (GitHub Linguist treats docs/ as documentation).
15
+ # Data and markup inside a documentation, design or data-dump folder are reports and mockups, not code (GitHub Linguist
16
+ # treats docs/ as documentation; a design/ HTML or a probe's JSON result pushed hook_decision.py out of the code block).
16
17
  DOC_DATA = {"html", "htm", "json", "csv", "xml", "yaml", "yml", "svg"}
17
18
  _NAME_DOC = re.compile(r"(^|/)(readme|changelog|license|licence|contributing|install|history|authors)(\.[^/]*)?$", re.I)
18
19
  _DIR_DOC = re.compile(r"(^|/)(docs?|adrs?|documentation)/", re.I)
20
+ _DIR_DOC_DATA = re.compile(r"(^|/)(docs?|adrs?|documentation|designs?|references?|mockups?|prototypes?|data|datasets?|reports?|results?)/", re.I)
19
21
  _TEST = re.compile(r"(^|/)(tests?|__tests__|spec|specs|e2e|fixtures?|testdata|test-utils?)(/|$)|(^|/)test_[^/]+\.py$|"
20
22
  r"(^|/)conftest\.py$|_test\.(py|go)$|\.(test|spec)(-d)?\.[cm]?[jt]sx?$|(^|/)[^/]*[a-z0-9](?-i:Tests?)\.java$|"
21
23
  r"(^|/)src/test/|(^|/)test[-_][^/]*\.[cm]?[jt]sx?$|[-_]test\.[cm]?[jt]sx?$", re.I)
@@ -151,7 +153,7 @@ def kind(path, profile):
151
153
  return _model_kind(path, groups or [{"path": ".", "kind": "doc"}])
152
154
  if _TEST.search(path):
153
155
  return "test"
154
- if ext in DOC_DATA and _DIR_DOC.search(path) and _explicit_kind(path, groups) != "code":
156
+ if ext in DOC_DATA and _DIR_DOC_DATA.search(path) and _explicit_kind(path, groups) != "code":
155
157
  return "doc"
156
158
  if not ext and _NAME_DOC.search(path):
157
159
  return "doc"
package/model_defaults.py CHANGED
@@ -11,7 +11,7 @@ import model_catalog
11
11
  # first and price second (docs/research/2026-10-24-modelos-do-scout-e-dos-outros-papeis.md): the router answers pages
12
12
  # and the scout reads code excerpts, where Claude Haiku 5.5 answered every request and led the no-index path; scope and
13
13
  # research pick the GPT-5.4 nano, as accurate there at about half the price and without the long reasoning of the mini.
14
- RECOMMENDED_MODELS = {
14
+ _CHOICES = {
15
15
  "router_model": ("chat", "claude-haiku-5-5"),
16
16
  "scope_model": ("chat", "gpt-5.4-nano"),
17
17
  "scout_model": ("chat", "claude-haiku-5-5"),
@@ -19,10 +19,18 @@ RECOMMENDED_MODELS = {
19
19
  "embedding_model": ("embedding", "text-embedding-3-small"),
20
20
  "rerank_model": ("rerank", "rerank-3-5"),
21
21
  }
22
+ RECOMMENDED_MODELS = {role: _CHOICES[role] for role in config.MODEL_ROLES}
23
+
24
+
25
+ def _route_rank(model_id):
26
+ """Vertex first (the owner's gateway is stable there), Bedrock only when nothing else serves the model."""
27
+ route = re.split(r"[/.:]", model_id)[0].lower()
28
+ return 0 if route.startswith("vertex") else 2 if route.startswith("bedrock") else 1
22
29
 
23
30
 
24
31
  def _matching(catalog, name):
25
- return next((model_id for model_id in sorted(catalog) if re.split(r"[/.:]", model_id)[-1] == name
32
+ return next((model_id for model_id in sorted(catalog, key=lambda found: (_route_rank(found), found))
33
+ if re.split(r"[/.:]", model_id)[-1] == name
26
34
  or model_id.endswith(("/" + name, "." + name)) or model_id == name), None)
27
35
 
28
36
 
package/model_health.py CHANGED
@@ -23,7 +23,6 @@ MUTE_S = 7 * 24 * 3600
23
23
  KEEP_S = 7 * 24 * 3600
24
24
  KEEP_FAILURES = 500
25
25
  ERROR_CHARS = 300
26
- _ROLES = ("router_model", "scope_model", "scout_model", "research_model", "embedding_model", "rerank_model")
27
26
  _PATHS = ("/v1/chat/completions", "/v1/embeddings", "/v1/rerank")
28
27
  _TOOL = contextvars.ContextVar("model_health_tool", default=None)
29
28
  _COLLECTED = contextvars.ContextVar("model_health_collected", default=None)
@@ -60,7 +59,7 @@ def _tables(db):
60
59
  def role_of(model, cfg=None):
61
60
  """Name of the config field that selects `model` ('other' when none does)."""
62
61
  cfg = cfg if cfg is not None else config.load_config()
63
- return next((field for field in _ROLES if cfg.get(field) == model), "other")
62
+ return next((field for field in config.MODEL_ROLES if cfg.get(field) == model), "other")
64
63
 
65
64
 
66
65
  def error_text(exc):
package/model_upgrade.py CHANGED
@@ -14,7 +14,7 @@ import config
14
14
  import model_catalog
15
15
  import model_defaults
16
16
 
17
- DEFAULTS_VERSION = 1
17
+ DEFAULTS_VERSION = 2
18
18
  ROLES = ("router_model", "scope_model", "scout_model", "research_model")
19
19
  REMIND_EVERY_S = 24 * 3600
20
20
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@allansantos-dev/smart-tool",
3
- "version": "0.9.32",
3
+ "version": "0.9.33",
4
4
  "description": "Local MCP server that gives coding agents (Claude Code, Codex) cheaper, sharper tools than their built-in search. Windows.",
5
5
  "license": "Apache-2.0",
6
6
  "author": "Allan Santos",
package/setup_audit.py CHANGED
@@ -11,6 +11,7 @@ Usage: python setup_audit.py [--project DIR] [--no-probe] [--json]
11
11
  """
12
12
  import argparse
13
13
  import concurrent.futures
14
+ import glob
14
15
  import hashlib
15
16
  import json
16
17
  import os
@@ -41,18 +42,22 @@ PROBE_MAX_AGE_S = 6 * 3600
41
42
  SKILL_DESCRIPTION_MAX = 1536
42
43
  SKILL_BUDGET_CHARS = 8000
43
44
  CODEX_PROJECT_DOC_MAX = 32 * 1024
45
+ SMART_TOOL_SERVER = "smart-tool"
44
46
  CLAUDE_TOOLS = ("Bash", "Read", "Grep", "Glob", "LS", "Edit", "MultiEdit", "Write", "NotebookEdit", "WebFetch",
45
47
  "WebSearch", "Task")
46
48
  CODEX_TOOLS = ("Bash", "apply_patch")
47
49
  DECIDING_EVENTS = ("PreToolUse",)
48
50
  DOCS = {"claude_hooks": "https://code.claude.com/docs/en/hooks.md", "codex_hooks": "https://learn.chatgpt.com/docs/hooks.md",
49
51
  "claude_skills": "https://code.claude.com/docs/en/skills.md",
52
+ "claude_subagents": "https://code.claude.com/docs/en/sub-agents",
50
53
  "codex_skills": "https://learn.chatgpt.com/docs/build-skills.md",
51
54
  "claude_memory": "https://code.claude.com/docs/en/memory.md",
52
55
  "codex_agents_md": "https://developers.openai.com/codex/guides/agents-md",
53
56
  "claude_mcp": "https://code.claude.com/docs/en/mcp.md", "mcp_lifecycle":
54
57
  "https://modelcontextprotocol.io/specification/2025-06-18/basic/lifecycle"}
55
58
  _SCRIPT_RE = re.compile(r"""["']?([^\s"'|&;<>]+\.(?:js|cjs|mjs|py|ps1|sh|bat|cmd|exe))["']?""", re.IGNORECASE)
59
+ # The quoted program text after -e / -c / -p / -Command / --eval: code, not a file the hook runs.
60
+ _INLINE_CODE_RE = re.compile(r"""\s(?:-e|-c|-p|-Command|--eval)\s+(?:"(?:[^"\\]|\\.)*"|'[^']*')""", re.IGNORECASE)
56
61
  _SILENCED_RE = re.compile(r"\|\|\s*(?:true|exit\s+0)\b|2>\s*(?:/dev/null|nul)\b|;\s*exit\s+0\s*$", re.IGNORECASE)
57
62
  _IMPORT_RE = re.compile(r"(?<![\w`])@((?:~|\.{1,2})?/?[\w.\-/\\]+\.\w+)")
58
63
 
@@ -204,6 +209,20 @@ def _approvals(data, approval):
204
209
  approval["off"].update(data.get("disabledMcpjsonServers") or [])
205
210
 
206
211
 
212
+ def _agents(snapshot, folder, scope):
213
+ """Subagent definitions (*.md with frontmatter) in folder: name, scope, path and `tools` (None when the field is
214
+ absent, which makes the agent inherit every tool, MCP included)."""
215
+ found = []
216
+ for path in sorted(glob.glob(os.path.join(folder, "*.md"))):
217
+ meta = _frontmatter(snapshot.read(path))
218
+ tools = meta.get("tools")
219
+ if isinstance(tools, str):
220
+ tools = [item.strip() for item in tools.split(",") if item.strip()]
221
+ found.append({"name": str(meta.get("name") or os.path.basename(path)[:-3]), "scope": scope, "path": path,
222
+ "tools": [str(item) for item in tools] if isinstance(tools, list) else None})
223
+ return found
224
+
225
+
207
226
  def collect_claude(project, snapshot):
208
227
  """Claude Code's effective set for project: settings layers, enabled plugins, hooks, MCP, skills, CLAUDE.md."""
209
228
  layers = [(path, "managed") for path in _managed_settings()] + [
@@ -234,6 +253,8 @@ def collect_claude(project, snapshot):
234
253
  mcp += [e for e in shared if e["name"] not in unapproved]
235
254
  skills = _skills(snapshot, os.path.join(endpoint_sync.claude_dir(), "skills"), "user", disabled=disabled_skills)
236
255
  skills += _skills(snapshot, os.path.join(project, ".claude", "skills"), "project", disabled=disabled_skills)
256
+ agents = _agents(snapshot, os.path.join(endpoint_sync.claude_dir(), "agents"), "user")
257
+ agents += _agents(snapshot, os.path.join(project, ".claude", "agents"), "project")
237
258
  plugins = []
238
259
  installed = snapshot.json(os.path.join(endpoint_sync.claude_dir(), "plugins", "installed_plugins.json")) or {}
239
260
  for plugin_id, installs in (installed.get("plugins") or {}).items():
@@ -261,10 +282,64 @@ def collect_claude(project, snapshot):
261
282
  text = snapshot.read(path)
262
283
  if text is not None:
263
284
  instructions.append({"path": path, "kind": kind, "bytes": len(text.encode("utf-8")), "_text": text})
264
- return {"settings": settings, "plugins": plugins, "hooks": hooks, "mcp": mcp, "skills": skills,
285
+ return {"settings": settings, "plugins": plugins, "hooks": hooks, "mcp": mcp, "skills": skills, "agents": agents,
265
286
  "instructions": instructions, "unapproved_mcp": unapproved}
266
287
 
267
288
 
289
+ def _codex_plugin_root(market, name):
290
+ """Newest installed version folder of a Codex plugin (`plugins/cache/<marketplace>/<name>/<version>/`), skipping
291
+ the `latest` alias unless it is all there is; None when the plugin is not in the cache."""
292
+ base = os.path.join(codex_dir(), "plugins", "cache", market, name)
293
+ if not os.path.isdir(base):
294
+ return None
295
+ versions = [os.path.join(base, entry) for entry in os.listdir(base) if os.path.isdir(os.path.join(base, entry))]
296
+ real = [path for path in versions if os.path.basename(path) != "latest"]
297
+ return max(real or versions, key=os.path.getmtime) if versions else None
298
+
299
+
300
+ def _codex_plugin_path(manifest, key, root, defaults):
301
+ """Path a Codex plugin manifest names for `key` (relative to the plugin root), else the first default that exists."""
302
+ named = manifest.get(key)
303
+ if isinstance(named, str):
304
+ return os.path.join(root, named)
305
+ return next((os.path.join(root, d) for d in defaults if os.path.exists(os.path.join(root, d))), None)
306
+
307
+
308
+ def _collect_codex_plugins(config, snapshot):
309
+ """Skills, hooks and MCP servers of the Codex plugins enabled in config.toml (`[plugins."<name>@<marketplace>"]`
310
+ unless `enabled = false`), read from the plugin cache: manifest `.codex-plugin/plugin.json`, `skills/`,
311
+ `hooks/hooks.json`, `.mcp.json`. A plugin whose folder or manifest is missing is reported as not verified."""
312
+ plugins, hooks, mcp, skills = [], [], [], []
313
+ for plugin_id, options in (config.get("plugins") or {}).items():
314
+ if isinstance(options, dict) and options.get("enabled") is False:
315
+ continue
316
+ name, _, market = plugin_id.rpartition("@")
317
+ root = _codex_plugin_root(market, name) if name else None
318
+ if not root:
319
+ snapshot.unverified.append(f"Codex plugin {plugin_id}: not found under {os.path.join(codex_dir(), 'plugins', 'cache')}")
320
+ continue
321
+ manifest_path = next((p for p in (os.path.join(root, ".codex-plugin", "plugin.json"),
322
+ os.path.join(root, "plugin.json")) if os.path.exists(p)), None)
323
+ manifest = snapshot.json(manifest_path) if manifest_path else None
324
+ if manifest is None:
325
+ snapshot.unverified.append(f"Codex plugin {plugin_id}: no readable manifest (.codex-plugin/plugin.json) in {root}")
326
+ continue
327
+ plugins.append({"id": plugin_id, "path": root})
328
+ hook_file = _codex_plugin_path(manifest, "hooks", root, (os.path.join("hooks", "hooks.json"),))
329
+ hooks += _hooks_from(snapshot.json(hook_file) if hook_file else None, f"plugin:{name}", root)
330
+ mcp_file = _codex_plugin_path(manifest, "mcpServers", root, (".mcp.json", "mcp.json"))
331
+ plugin_mcp = snapshot.json(mcp_file) if mcp_file else None
332
+ if plugin_mcp is None and isinstance(manifest.get("mcpServers"), dict):
333
+ plugin_mcp = manifest["mcpServers"]
334
+ plugin_mcp = plugin_mcp or {}
335
+ mcp += _mcp_entries(plugin_mcp.get("mcpServers", plugin_mcp), f"plugin:{name}", mcp_file or manifest_path,
336
+ f"plugin:{name}:", {"PLUGIN_ROOT": root})
337
+ skill_dir = _codex_plugin_path(manifest, "skills", root, ("skills",))
338
+ skills += [{**s, "name": f"{name}:{s['name']}"} for s in
339
+ _skills(snapshot, skill_dir, f"plugin:{name}", recursive=True)] if skill_dir else []
340
+ return plugins, hooks, mcp, skills
341
+
342
+
268
343
  def collect_codex(project, snapshot):
269
344
  """Codex's effective set for project: config.toml (MCP, hooks), hooks.json, skills, AGENTS.md chain."""
270
345
  config = snapshot.toml(os.path.join(codex_dir(), "config.toml")) or {}
@@ -274,6 +349,10 @@ def collect_codex(project, snapshot):
274
349
  skills = _skills(snapshot, os.path.join(codex_dir(), "skills"), "user", recursive=True)
275
350
  skills += _skills(snapshot, _home(".agents", "skills"), "user-agents")
276
351
  skills += _skills(snapshot, os.path.join(project, ".agents", "skills"), "project")
352
+ plugins, plugin_hooks, plugin_mcp, plugin_skills = _collect_codex_plugins(config, snapshot)
353
+ hooks += plugin_hooks
354
+ mcp += plugin_mcp
355
+ skills += plugin_skills
277
356
  instructions = []
278
357
  for name in ("AGENTS.override.md", "AGENTS.md"):
279
358
  text = snapshot.read(os.path.join(codex_dir(), name))
@@ -292,8 +371,7 @@ def collect_codex(project, snapshot):
292
371
  instructions.append({"path": os.path.join(folder, name), "kind": "project",
293
372
  "bytes": len(text.encode("utf-8")), "_text": text})
294
373
  break
295
- return {"hooks": hooks, "mcp": mcp, "skills": skills, "instructions": instructions,
296
- "plugins_unverified": bool(config.get("plugins"))}
374
+ return {"plugins": plugins, "hooks": hooks, "mcp": mcp, "skills": skills, "instructions": instructions}
297
375
 
298
376
 
299
377
  def _finding(check, severity, harness, subject, detail, source, evidence=()):
@@ -336,8 +414,9 @@ def check_hook_overlap(harness, hooks, tools):
336
414
 
337
415
 
338
416
  def _expand(token, root):
339
- """A hook script path with ${CLAUDE_PLUGIN_ROOT}, ~ and environment variables expanded."""
340
- token = token.replace("${CLAUDE_PLUGIN_ROOT}", root or "${CLAUDE_PLUGIN_ROOT}")
417
+ """A hook script path with ${CLAUDE_PLUGIN_ROOT} / ${PLUGIN_ROOT}, ~ and environment variables expanded."""
418
+ for variable in ("CLAUDE_PLUGIN_ROOT", "PLUGIN_ROOT"):
419
+ token = token.replace("${%s}" % variable, root or "${%s}" % variable)
341
420
  return os.path.expandvars(os.path.expanduser(token))
342
421
 
343
422
 
@@ -348,7 +427,9 @@ def check_hook_targets(harness, hooks, mcp_names, probe):
348
427
  for hook in hooks:
349
428
  where = f"{hook['source']} {hook['event']} {hook['matcher'] or '*'}"
350
429
  if hook["type"] == "command" and hook["command"]:
351
- for token in _SCRIPT_RE.findall(hook["command"]):
430
+ for token in _SCRIPT_RE.findall(_INLINE_CODE_RE.sub(" ", hook["command"])):
431
+ if not re.search(r"[\\/]", token) and shutil.which(token):
432
+ continue
352
433
  path = _expand(token, hook.get("root"))
353
434
  if "$" in path or "%" in path:
354
435
  continue
@@ -384,6 +465,24 @@ def _reachable(url):
384
465
  return str(exc)
385
466
 
386
467
 
468
+ def check_agents(claude):
469
+ """Subagents whose `tools` list names no Smart Tool tool while the smart-tool server is registered: the redirect
470
+ leaves them alone (nothing to redirect to), so they search folders with Grep and Read at full cost. A subagent
471
+ with no `tools` field inherits every tool, MCP included; with the field, only what it lists."""
472
+ if not any(entry["name"] == SMART_TOOL_SERVER for entry in claude["mcp"]):
473
+ return []
474
+ blind = [a for a in claude.get("agents", []) if a["tools"] is not None
475
+ and not any(tool == "*" or tool.startswith(f"mcp__{SMART_TOOL_SERVER}") for tool in a["tools"])]
476
+ if not blind:
477
+ return []
478
+ names = ", ".join(sorted(a["name"] for a in blind)[:8]) + (" ..." if len(blind) > 8 else "")
479
+ return [_finding("agent_without_smart_tool", "warn", "claude", "subagents without Smart Tool",
480
+ f"{len(blind)} subagent(s) list tools but none of mcp__{SMART_TOOL_SERVER}__*: {names}. Add "
481
+ f"mcp__{SMART_TOOL_SERVER}__smart_search and mcp__{SMART_TOOL_SERVER}__smart_search_result to "
482
+ "their `tools`, or drop the `tools` line to inherit every tool.", DOCS["claude_subagents"],
483
+ [a["path"] for a in blind[:8]])]
484
+
485
+
387
486
  def check_skills(harness, skills):
388
487
  """Skill name collisions, descriptions over Claude's per-skill cut, and the total over the list budget."""
389
488
  findings, by_name = [], {}
@@ -635,6 +734,7 @@ def run(project, probe=True, save=True, probe_max_age=0, trigger="manual"):
635
734
  results = (probe_mcp(data["mcp"], project, max_age=probe_max_age) if probe
636
735
  else [None] * len(data["mcp"]))
637
736
  findings += check_mcp(harness, data["mcp"], results)
737
+ findings += check_agents(claude)
638
738
  findings += check_instructions(project, claude, codex)
639
739
  unverified = list(snapshot.unverified)
640
740
  if not probe:
@@ -642,8 +742,6 @@ def run(project, probe=True, save=True, probe_max_age=0, trigger="manual"):
642
742
  for name in claude["unapproved_mcp"]:
643
743
  unverified.append(f".mcp.json server '{name}' is not approved in Claude Code, so it is neither loaded nor "
644
744
  "started here")
645
- if codex.get("plugins_unverified"):
646
- unverified.append("Codex plugins (their skills and hooks) are not collected yet")
647
745
  unverified += ["skill trigger overlap and contradictions between instructions need a model (not run)",
648
746
  "the order between two parallel rewrites is undocumented and cannot be checked"]
649
747
  key = hashlib.sha256(json.dumps(sorted(snapshot.files.items())).encode()).hexdigest()[:16]
package/version.py CHANGED
@@ -1,7 +1,7 @@
1
1
  """Single source of the Smart Tool version and of the User-Agent sent to public APIs."""
2
2
  import re
3
3
 
4
- VERSION = "0.9.32"
4
+ VERSION = "0.9.33"
5
5
  _CONTACT_RE = re.compile(r"^(?:[^\s@()<>;]+@[^\s@()<>;]+\.[^\s@()<>;]+|https?://[^\s()<>;]+)$")
6
6
 
7
7