From 896fa9161061c18960c5cc92480be23ee53b33ed Mon Sep 17 00:00:00 2001 From: Parelegal Date: Mon, 3 Aug 2026 20:08:29 +0000 Subject: [PATCH 1/2] Fix dead slash-command refs and cookbook doc/YAML drift MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The documented validation suite passes clean on main, but two defect classes sit outside what any of it checks. Both are user-visible. ## Dead slash-command references CLAUDE.md requires slash-command names in prose to be the actual `skills//` directory name — short forms "look right in prose but are dead commands." `claude plugin validate` treats `/foo` in prose as text, so these shipped silently: - `ip-legal/skills/cold-start-interview` — 11 refs across 6 lines used short forms: `/cd` (→ `cease-desist`), `/fto` (→ `fto-triage`), `/infringe` (→ `infringement-triage`), `/oss` (→ `oss-review`), `/clause` (→ `ip-clause-review`). These are the lines that tell the user which skills their answers light up, so every name in them is one the user may type. - `legal-clinic/README.md` — `/legal-clinicgrations`, a mangled find-and-replace. The intact form of the identical paragraph in `product-legal/README.md` gives the correct text. - `litigation-legal/skills/matter-intake` — `` `/matter` briefing `` → `` `/matter-briefing` ``. - `product-legal/skills/customize` — `/ai-governance-legal` addressed a plugin as a command; reworded to name the plugin. - Three command names were line-wrapped inside their backticks (`` `/handbook-\nupdates` ``), so they render with a space and are not typable as written. Reflowed. ## Cookbook README security tables contradicting their own YAML Cookbook rule 2 requires the README security table and `agent.yaml` comments to match what the YAML grants. `lint-tool-scope.py` only enforces rule 1 and never opens `subagents/*.yaml` or `README.md`, so rule 2 was unenforced: - `launch-radar` README claimed the orchestrator holds Linear/Jira/Asana/Drive. It grants no `mcp_toolset` at all — and the adjacent `agent.yaml` comment said so. A reader auditing blast radius from the README would overestimate it; a reader "fixing" the YAML to match would break rule 1. - `docket-watcher` and `reg-monitor` READMEs advertised a gdrive connector on components that grant none. `docket-watcher`'s own README refuted its table three lines later. - `diligence-grid` claimed iManage read access without noting it ships disabled. - Five MCP servers were declared in `mcp_servers` but granted to no component (`definely`, `gdrive`×3, `docusign`), with deploy instructions telling operators to export URLs for connectors nothing could reach. Removed the declarations and the env-var docs rather than widening any subagent's scope. Nothing in `scripts/` or `.github/` referenced them. - Two `agent.yaml` comments claimed leaves hold `web_fetch` that no leaf has (copy-paste from `reg-monitor`, the one cookbook where it is true). - Two adaptation notes pointed at `agent.yaml` for an iManage toggle that lives in a subagent; following them literally would have added an MCP grant to an orchestrator and broken rule 1. - Split the combined ` / Orchestrator` rows. That row published a union of two tiers, so it over-claimed `Glob` for three leaves and `Agent` for every leaf (all have `callable_agents: []`), and made the row uncheckable against either component. No under-disclosure was found: every enabled tool and connector, including `feed-reader`'s `web_fetch` and each Write-holder's exact toolset, was already disclosed. All defects ran in the over-claim direction. ## Regression cover Both classes now fail CI instead of shipping: - `scripts/lint-skill-refs.py` — every `/foo` and `/plugin:skill` in plugin prose resolves to a real skill directory; also catches names line-wrapped inside backticks. Built-ins are exempt. - `scripts/lint-cookbook-docs.py` — README security-table tools and connectors match the YAML in both directions, declared MCP servers are granted somewhere, `agent.yaml` comments are true, and combined rows are rejected. Verified both scripts reproduce the full hand-found defect set against the pre-fix tree and pass against this one. Also corrected the JSON sanity command in CLAUDE.md: `glob('**/*.json')` does not match dotfiles, so it was checking 15 files and skipping all 27 that live under `.claude-plugin/` or are named `.mcp.json`. Left alone per CLAUDE.md: the I1 curated sort order, the expected plugin-root CLAUDE.md template warnings, per-plugin `.gitignore` differences, the two missing `hooks/hooks.json`, the `references/` path gap, and vendor-authored content under `external_plugins/`. Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_01Vz1DwCuBbxbqkG6fBGBZp9 --- CLAUDE.md | 20 +- corporate-legal/skills/customize/SKILL.md | 4 +- employment-legal/skills/customize/SKILL.md | 11 +- ip-legal/skills/cold-start-interview/SKILL.md | 12 +- legal-clinic/README.md | 2 +- .../skills/matter-intake/SKILL.md | 2 +- .../diligence-grid/README.md | 10 +- .../diligence-grid/agent.yaml | 1 - .../docket-watcher/README.md | 7 +- .../docket-watcher/agent.yaml | 5 +- .../launch-radar/README.md | 7 +- .../launch-radar/agent.yaml | 1 - managed-agent-cookbooks/reg-monitor/README.md | 6 +- .../reg-monitor/agent.yaml | 8 +- .../renewal-watcher/README.md | 9 +- .../renewal-watcher/agent.yaml | 1 - product-legal/skills/customize/SKILL.md | 4 +- scripts/lint-cookbook-docs.py | 232 ++++++++++++++++++ scripts/lint-skill-refs.py | 154 ++++++++++++ 19 files changed, 449 insertions(+), 47 deletions(-) create mode 100755 scripts/lint-cookbook-docs.py create mode 100755 scripts/lint-skill-refs.py diff --git a/CLAUDE.md b/CLAUDE.md index d6705e9760..f8892079d3 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -20,7 +20,8 @@ agents, hooks), plugin metadata, or cookbook config — not application code. .gitignore external_plugins// # vendor-maintained plugins (CoCounsel) managed-agent-cookbooks// # CMA agent.yaml + subagents/ + steering-examples.json -scripts/ # validate.py, lint-tool-scope.py, orchestrate.py, +scripts/ # validate.py, lint-tool-scope.py, lint-skill-refs.py, + # lint-cookbook-docs.py, orchestrate.py, # deploy-managed-agent.sh, test-cookbooks.sh references/ # shared templates (company-profile, dashboard) ``` @@ -39,8 +40,21 @@ claude plugin validate external_plugins/cocounsel-legal # 2. Cookbook tool-scope lint (orchestrators must not over-grant tools) python3 scripts/lint-tool-scope.py -# 3. JSON/YAML sanity -python3 -c "import json,glob; [json.load(open(f)) for f in glob.glob('**/*.json', recursive=True)]" +# 3. Slash-command refs in prose resolve to real skills (see "Skill names in +# prose must be canonical" below — `claude plugin validate` does not check this) +python3 scripts/lint-skill-refs.py + +# 4. Cookbook README security tables match what the YAML actually grants +# (cookbook rule 2 — lint-tool-scope.py only covers rule 1) +python3 scripts/lint-cookbook-docs.py + +# 5. JSON/YAML sanity. Note `**/*.json` does NOT match dotfiles, so a bare glob +# silently skips every .claude-plugin/plugin.json and .mcp.json — walk instead. +python3 -c " +import json, pathlib +for f in pathlib.Path('.').rglob('*.json'): + if '.git' not in f.parts: json.load(open(f)) +print('json ok')" ``` ### Marketplace invariants (I1–I11) diff --git a/corporate-legal/skills/customize/SKILL.md b/corporate-legal/skills/customize/SKILL.md index b299fa1585..68aff27913 100644 --- a/corporate-legal/skills/customize/SKILL.md +++ b/corporate-legal/skills/customize/SKILL.md @@ -73,8 +73,8 @@ and without hand-editing YAML. - *Turning on the Public Company module:* "I'll prompt you for reporting calendar and disclosure controls next time you run anything in that area." - - *AI bulk-review trust "check every row" → "spot-check 10%":* "`/ai-tool- - handoff` will QA a 10% sample rather than every extraction." + - *AI bulk-review trust "check every row" → "spot-check 10%":* + "`/ai-tool-handoff` will QA a 10% sample rather than every extraction." 5. **For shared-profile changes** (company name, industry, jurisdictions, practice setting, stage): write to diff --git a/employment-legal/skills/customize/SKILL.md b/employment-legal/skills/customize/SKILL.md index f1ecfd2282..d3155dd2b4 100644 --- a/employment-legal/skills/customize/SKILL.md +++ b/employment-legal/skills/customize/SKILL.md @@ -66,11 +66,12 @@ interview and without hand-editing YAML. Examples: - *Adding Washington to the jurisdictional footprint:* "`/wage-hour-qa` - and `/termination-review` will start applying WA rules. `/handbook- - updates` will prompt for a WA supplement. `/hiring-review` will now - flag non-compete attempts in WA (unenforceable)." - - *Severance framework 2 weeks/year → 4 weeks/year:* "`/termination- - review` will use the new baseline in severance calculations." + and `/termination-review` will start applying WA rules. + `/handbook-updates` will prompt for a WA supplement. `/hiring-review` + will now flag non-compete attempts in WA (unenforceable)." + - *Severance framework 2 weeks/year → 4 weeks/year:* + "`/termination-review` will use the new baseline in severance + calculations." - *Risk posture middle → conservative:* "I'll flag more terminations for escalation, recommend more protective release language, and be stricter on restrictive covenants." diff --git a/ip-legal/skills/cold-start-interview/SKILL.md b/ip-legal/skills/cold-start-interview/SKILL.md index 2d78853ab1..5f7ee19774 100644 --- a/ip-legal/skills/cold-start-interview/SKILL.md +++ b/ip-legal/skills/cold-start-interview/SKILL.md @@ -283,7 +283,7 @@ Write `## Who's using this` and `## Available integrations` sections immediately **What does [your company] do?** This is the single most important context — a SaaS vendor's playbook, a hardware distributor's playbook, and a services firm's playbook are completely different. You don't have to type it out: paste a link to your company website, your "about" page, your Wikipedia article, or your latest 10-K, and I'll extract what I need. Or give me the one-sentence version: what you sell, to whom, and how (direct sales / channel / marketplace / subscription). If you're a private practice firm, the same applies to the clients you do most of your IP work for. -> Which IP areas do you actually work in? I'll skip questions in the ones you don't. (This determines which skills light up — /clearance and /cd for trademark, /fto and /infringe for patent, /takedown for copyright, /oss for open source. Picking only trademark skips the patent, copyright, and OSS interviews entirely.) +> Which IP areas do you actually work in? I'll skip questions in the ones you don't. (This determines which skills light up — /clearance and /cease-desist for trademark, /fto-triage and /infringement-triage for patent, /takedown for copyright, /oss-review for open source. Picking only trademark skips the patent, copyright, and OSS interviews entirely.) > > - **Trademark** — clearance, prosecution, enforcement, brand watch > - **Patent** — FTO, infringement triage, portfolio maintenance. *(Not claim drafting — this plugin doesn't go there.)* @@ -302,7 +302,7 @@ Record in the practice profile as context, not a gate. Volume affects the cadenc ### Part 2: Jurisdiction footprint (1-2 minutes) -> Where do you hold registrations and where do you enforce? (This feeds /clearance, /fto, /portfolio — every clearance check and FTO triage needs to know which jurisdictions matter, and the portfolio register tracks renewals in each one.) +> Where do you hold registrations and where do you enforce? (This feeds /clearance, /fto-triage, /portfolio — every clearance check and FTO triage needs to know which jurisdictions matter, and the portfolio register tracks renewals in each one.) > > - **Marks registered in:** US (USPTO)? EU (EUIPO)? UK (UKIPO)? Madrid member states — which? National filings elsewhere? Common-law only? > - **Patents granted in:** US? EPO? PCT national phase countries? Any specific jurisdictions that matter (Germany, Japan, China)? @@ -316,7 +316,7 @@ Record in `## IP practice profile` under `Registered in:`, and note enforcement Before asking enforcement or approval questions, check what they already have. -> Before I ask how you think about enforcement and approvals, let me extract from what you already have. Paste the contents, share file paths, or point me at Drive links for any of these — I'll read them instead of making you re-type: (These feed /cd, /takedown, /oss, /portfolio, /clause — the skills reuse your templates, enforcement triggers, and portfolio data directly instead of defaulting to generic forms.) +> Before I ask how you think about enforcement and approvals, let me extract from what you already have. Paste the contents, share file paths, or point me at Drive links for any of these — I'll read them instead of making you re-type: (These feed /cease-desist, /takedown, /oss-review, /portfolio, /ip-clause-review — the skills reuse your templates, enforcement triggers, and portfolio data directly instead of defaulting to generic forms.) > > - **Portfolio list** (from your IP management system, or a spreadsheet) — mark / patent / copyright registrations with jurisdictions, status, renewal dates > - **Brand guidelines** — the trademark-use guide, brand book, or house rules for external parties @@ -336,7 +336,7 @@ Record the documents in `## IP practice profile` under a `Seed documents reviewe ### Part 4: Enforcement posture (2-3 minutes) -> When you see an apparent infringement — a knockoff mark, a copied image, a product that looks too close — where does your practice land? (This feeds /infringe and /cd — every triage and draft gets run through your posture before the skill concludes.) +> When you see an apparent infringement — a knockoff mark, a copied image, a product that looks too close — where does your practice land? (This feeds /infringement-triage and /cease-desist — every triage and draft gets run through your posture before the skill concludes.) > > - **Aggressive** — you send C&Ds early, you're willing to file. > - **Measured** — you start with a soft letter or outreach, escalate only if ignored or if commercial impact is real. @@ -352,7 +352,7 @@ Then drill in: **Who approves sending?** Ask one batch: -> Who signs off on each of these before they go out? (This feeds /cd and /takedown — when you tell the skill to draft a letter, it runs the draft through the named approver and waits for sign-off before it goes anywhere.) +> Who signs off on each of these before they go out? (This feeds /cease-desist and /takedown — when you tell the skill to draft a letter, it runs the draft through the named approver and waits for sign-off before it goes anywhere.) > > - **DMCA takedown (ordinary):** often delegated to counsel or brand protection; who owns it on your team? > - **Soft letter:** same question. @@ -383,7 +383,7 @@ Record in `## Enforcement posture` as escalation routing, not as a separate sect Skip if the user does not practice trademark. -> Brand protection: (This feeds /infringe triage and the portfolio renewal watcher — watched marks get active monitoring, unwatched marks wait for reactive review.) +> Brand protection: (This feeds /infringement-triage and the portfolio renewal watcher — watched marks get active monitoring, unwatched marks wait for reactive review.) > > - **Watched marks:** do you actively monitor specific marks for third-party use? List them, or say "none — reactive only." > - **Watch jurisdictions:** US / EU / UK / global via watch service? diff --git a/legal-clinic/README.md b/legal-clinic/README.md index c8f12d3925..228f3f0a11 100644 --- a/legal-clinic/README.md +++ b/legal-clinic/README.md @@ -185,4 +185,4 @@ legal-clinic/ ## Prerequisites -Some features reference external integrations (document management, launch trackers, eDiscovery, case management, regulatory feeds). These are not bundled — if you have an MCP server for one of these in your environment, the relevant features will use it. Without one, the plugin falls back to file upload and manual workflows. Run `/legal-clinicgrations` to see what's available in your environment. +Some features reference external integrations (document management, launch trackers, eDiscovery, case management, regulatory feeds). These are not bundled — if you have an MCP server for one of these in your environment, the relevant features will use it. Without one, the plugin falls back to file upload and manual workflows. Run `/legal-clinic:cold-start-interview --check-integrations` to see what's available in your environment. diff --git a/litigation-legal/skills/matter-intake/SKILL.md b/litigation-legal/skills/matter-intake/SKILL.md index 56d3385817..77b2bd929a 100644 --- a/litigation-legal/skills/matter-intake/SKILL.md +++ b/litigation-legal/skills/matter-intake/SKILL.md @@ -73,7 +73,7 @@ Behavior by status: rationale: [why conflicts were bypassed — permanent record; does not auto-expire] ``` - This field is visible in every `/portfolio-status`, every `/matter` briefing, and every `/matter-update` until removed. It is never removed by the skill — only by explicit user edit to `_log.yaml` after conflicts are actually cleared. + This field is visible in every `/portfolio-status`, every `/matter-briefing`, and every `/matter-update` until removed. It is never removed by the skill — only by explicit user edit to `_log.yaml` after conflicts are actually cleared. **Do not proceed silently.** "I'll do it later" is not an acceptable response. One of Path 1/2/3 must be chosen, and the choice is captured in the record. diff --git a/managed-agent-cookbooks/diligence-grid/README.md b/managed-agent-cookbooks/diligence-grid/README.md index 4c5eba0d82..e6d33000ac 100644 --- a/managed-agent-cookbooks/diligence-grid/README.md +++ b/managed-agent-cookbooks/diligence-grid/README.md @@ -22,8 +22,7 @@ Same source as the [`corporate-legal`](../../corporate-legal) plugin — this di export ANTHROPIC_API_KEY=sk-ant-... export BOX_MCP_URL=... export GDRIVE_MCP_URL=... -export IMANAGE_MCP_URL=... # optional; set the toolset default to enabled if used -export DEFINELY_MCP_URL=... # optional; for clause-structure QA of the normalizer pass +export IMANAGE_MCP_URL=... # optional; enable the toolset in subagents/doc-reader.yaml if used ../../scripts/deploy-managed-agent.sh diligence-grid ``` @@ -37,10 +36,11 @@ VDR documents — contracts, board minutes, side letters, counterparty uploads | Tier | Touches untrusted docs? | Tools | Connectors | |---|---|---|---| -| **`doc-reader`** | **Yes** (read-only) | `Read`, `Grep` | Box, Google Drive, iManage (read) | +| **`doc-reader`** | **Yes** (read-only) | `Read`, `Grep` | Box, Google Drive (read-only); iManage off by default | | **`extractor`** | **Yes** (read-only) | `Read`, `Grep` | None | -| `normalizer` / Orchestrator | No | `Read`, `Grep`, `Glob`, `Agent` | None (definely optional, read-only) | +| `normalizer` | No | `Read`, `Grep` | None | | **`grid-writer`** (Write-holder) | No | `Read`, `Write` | None | +| Orchestrator | No | `Read`, `Grep`, `Glob`, `Agent` | None | `doc-reader` and `extractor` return length-capped, schema-validated JSON. The orchestrator and `normalizer` see only structured data. `grid-writer` produces `./out/diligence-grid-.csv`, `./out/diligence-grid-_sources.csv`, and `./out/diligence-grid--summary.md`. @@ -52,7 +52,7 @@ VDR documents — contracts, board minutes, side letters, counterparty uploads ## Adaptation notes -- **VDR URL.** Set `BOX_MCP_URL` / `GDRIVE_MCP_URL` / `IMANAGE_MCP_URL` to match your data room. The default enables Box and Google Drive; flip the `default_config` in [`agent.yaml`](./agent.yaml) if you run iManage or Datasite as primary. If your VDR is Intralinks or Datasite, add an entry to `mcp_servers` and `tools` with the matching MCP URL. +- **VDR URL.** Set `BOX_MCP_URL` / `GDRIVE_MCP_URL` / `IMANAGE_MCP_URL` to match your data room. The default enables Box and Google Drive; flip the `default_config` on the `imanage` entry in [`subagents/doc-reader.yaml`](./subagents/doc-reader.yaml) if you run iManage as primary. If your VDR is Intralinks or Datasite, declare it in the orchestrator's `mcp_servers` and add the matching `mcp_toolset` to `doc-reader` — not to `agent.yaml`'s `tools`, which must stay local-only. - **Column schema.** The M&A diligence standard in [`corporate-legal/skills/tabular-review/references/ma-diligence-columns.md`](../../corporate-legal/skills/tabular-review/references/ma-diligence-columns.md) is the default. Customize for your deal type — tech/IP, healthcare, real estate, government contractor, regulated financial — using the additions in that reference. - **Output destination.** Outputs land in `./out/`. Wire them to your deal folder, Google Drive, iManage workspace, or Box folder through your deploy pipeline. Do not give `grid-writer` an MCP to upload them; a handoff to your upload step is cleaner and keeps the Write tier isolated. - **Default mode.** Watch vs grid is selected per steering event. If your workflow is almost always one or the other, seed the steering event template in your orchestrator accordingly. diff --git a/managed-agent-cookbooks/diligence-grid/agent.yaml b/managed-agent-cookbooks/diligence-grid/agent.yaml index c4d06d2aff..3259edf825 100644 --- a/managed-agent-cookbooks/diligence-grid/agent.yaml +++ b/managed-agent-cookbooks/diligence-grid/agent.yaml @@ -88,7 +88,6 @@ mcp_servers: - { type: url, name: box, url: "${BOX_MCP_URL}" } - { type: url, name: gdrive, url: "${GDRIVE_MCP_URL}" } - { type: url, name: imanage, url: "${IMANAGE_MCP_URL}" } - - { type: url, name: definely, url: "${DEFINELY_MCP_URL}" } skills: - { from_plugin: ../../corporate-legal } diff --git a/managed-agent-cookbooks/docket-watcher/README.md b/managed-agent-cookbooks/docket-watcher/README.md index aba2c7fc56..8e355b5cc0 100644 --- a/managed-agent-cookbooks/docket-watcher/README.md +++ b/managed-agent-cookbooks/docket-watcher/README.md @@ -19,7 +19,6 @@ Same source as the [`docket-watcher`](../../litigation-legal/agents/docket-watch export ANTHROPIC_API_KEY=sk-ant-... export TRELLIS_MCP_URL=... export COURTLISTENER_MCP_URL=... -export GDRIVE_MCP_URL=... ../../scripts/deploy-managed-agent.sh docket-watcher ``` @@ -34,8 +33,9 @@ Court filings are public records, but they are also UNTRUSTED INPUT. The filer c | Tier | Touches filings? | Tools | Connectors | |---|---|---|---| | **`docket-reader`** | **Yes** | `Read`, `Grep` only | trellis, courtlistener (read-only) | -| `deadline-mapper` / Orchestrator | No — sees structured JSON only | `Read`, `Grep`, `Glob`, `Agent` | gdrive (jurisdiction config, read-only) | +| `deadline-mapper` | No — sees structured JSON only | `Read`, `Grep` | None | | **`tracker-writer`** (Write-holder) | No | `Read`, `Write`, `Edit` | None | +| Orchestrator | No — sees structured JSON only | `Read`, `Grep`, `Glob`, `Agent` | None | `docket-reader` returns length-capped, schema-validated JSON. `deadline-mapper` has no MCP and no web — it applies rules the deploying team has configured. `tracker-writer` produces `./out/docket-report-.md` and `./out/deadlines.yaml` and never sees raw filings. @@ -43,7 +43,8 @@ Court filings are public records, but they are also UNTRUSTED INPUT. The filer c This cookbook is a starting point. It will not work in production until you have done the following: -- **Set the MCP URLs.** `TRELLIS_MCP_URL` and `COURTLISTENER_MCP_URL` must point at your deployment's endpoints, with whatever authentication your platform requires. `GDRIVE_MCP_URL` (or a substitute) points at wherever your jurisdiction-rule tables live. +- **Set the MCP URLs.** `TRELLIS_MCP_URL` and `COURTLISTENER_MCP_URL` must point at your deployment's endpoints, with whatever authentication your platform requires. Both are granted to `docket-reader` only. +- **Supply the jurisdiction-rule tables locally.** `deadline-mapper` has no MCP and no network, so it reads the rule tables off the local filesystem. Sync them into the deploying team's litigation-legal config path before the first run — there is no connector that will fetch them at runtime. - **Load the portfolio.** The agent reads `matters/_log.yaml` plus the per-matter `docket_id` and `court` from the deploying team's litigation-legal configuration. If your docketing system is the source of truth, front it with an MCP or a scheduled sync into the config path. - **Configure jurisdiction rules.** Ship the deadline-mapper a local-rule table for every court in your portfolio. Federal rules you can encode once; state trial courts and individual judges are where the landmines live. An unknown court should produce `confidence: low` + `needs_verification: true`, never a silent default. - **Wire delivery.** Decide where the output goes: your docketing system ingests `./out/deadlines.yaml`; the narrative report goes to Slack, email, or your matter management workspace; critical flags route to whoever you want woken up. diff --git a/managed-agent-cookbooks/docket-watcher/agent.yaml b/managed-agent-cookbooks/docket-watcher/agent.yaml index 5c4b3006fe..d29c6418a0 100644 --- a/managed-agent-cookbooks/docket-watcher/agent.yaml +++ b/managed-agent-cookbooks/docket-watcher/agent.yaml @@ -28,8 +28,8 @@ system: read cleaner — loud is correct. tools: - # Orchestrator is scoped to local-only tools; MCP and web_fetch are held by - # the subagent leaves (see callable_agents). + # Orchestrator is scoped to local-only tools; MCP is held by the + # subagent leaves (see callable_agents). - type: agent_toolset_20260401 default_config: { enabled: false } configs: @@ -40,7 +40,6 @@ tools: mcp_servers: - { type: url, name: trellis, url: "${TRELLIS_MCP_URL}" } - { type: url, name: courtlistener, url: "${COURTLISTENER_MCP_URL}" } - - { type: url, name: gdrive, url: "${GDRIVE_MCP_URL}" } skills: - { from_plugin: ../../litigation-legal } diff --git a/managed-agent-cookbooks/launch-radar/README.md b/managed-agent-cookbooks/launch-radar/README.md index ca2bc06f92..9240fd4c63 100644 --- a/managed-agent-cookbooks/launch-radar/README.md +++ b/managed-agent-cookbooks/launch-radar/README.md @@ -17,11 +17,11 @@ This is a **cookbook, not a product.** It will not work out of the box. You need ```bash export ANTHROPIC_API_KEY=sk-ant-... -export LINEAR_MCP_URL=... ATLASSIAN_MCP_URL=... ASANA_MCP_URL=... GDRIVE_MCP_URL=... +export LINEAR_MCP_URL=... ATLASSIAN_MCP_URL=... ASANA_MCP_URL=... ../../scripts/deploy-managed-agent.sh launch-radar ``` -Only set the MCP URLs for the trackers you actually use. The orchestrator and `tracker-reader` skip MCPs that aren't configured. +Only set the MCP URLs for the trackers you actually use. `tracker-reader` — the only component with MCP access — skips MCPs that aren't configured. ## Steering events @@ -34,8 +34,9 @@ Tracker tickets are untrusted input. A product manager can put arbitrary text in | Tier | Touches untrusted tracker content? | Tools | Connectors | |---|---|---|---| | **`tracker-reader`** | **Yes** | `Read`, `Grep` only | Linear, Jira (atlassian), Asana (read-only) | -| `risk-classifier` / Orchestrator | No | `Read`, `Grep`, `Glob`, `Agent` | Orchestrator only: Linear / Jira / Asana / Drive (read-only) | +| `risk-classifier` | No | `Read`, `Grep` | None | | **`memo-writer`** (Write-holder) | No | `Read`, `Write`, `Edit` | None | +| Orchestrator | No | `Read`, `Grep`, `Glob`, `Agent` | None | `tracker-reader` returns a length-capped, schema-validated JSON list of launches. `risk-classifier` has no MCP and no network; it works from the validated list plus the user's calibration file. `memo-writer` is the only worker with Write, and produces `./out/launch-radar-.md`. The orchestrator holds no Write and never parses raw ticket bodies itself. diff --git a/managed-agent-cookbooks/launch-radar/agent.yaml b/managed-agent-cookbooks/launch-radar/agent.yaml index 1e3c356d50..d35dd9db10 100644 --- a/managed-agent-cookbooks/launch-radar/agent.yaml +++ b/managed-agent-cookbooks/launch-radar/agent.yaml @@ -37,7 +37,6 @@ mcp_servers: - { type: url, name: linear, url: "${LINEAR_MCP_URL}" } - { type: url, name: atlassian, url: "${ATLASSIAN_MCP_URL}" } - { type: url, name: asana, url: "${ASANA_MCP_URL}" } - - { type: url, name: gdrive, url: "${GDRIVE_MCP_URL}" } skills: - { from_plugin: ../../product-legal } diff --git a/managed-agent-cookbooks/reg-monitor/README.md b/managed-agent-cookbooks/reg-monitor/README.md index dd3a6713b0..f08c53c609 100644 --- a/managed-agent-cookbooks/reg-monitor/README.md +++ b/managed-agent-cookbooks/reg-monitor/README.md @@ -15,7 +15,6 @@ Checks regulatory feeds on a schedule, filters by the deploying team's materiali ```bash export ANTHROPIC_API_KEY=sk-ant-... -export GDRIVE_MCP_URL=... ../../scripts/deploy-managed-agent.sh reg-monitor ``` @@ -30,8 +29,11 @@ Regulatory feed content (Federal Register entries, agency RSS posts, paid feed a | Tier | Touches untrusted docs? | Tools | Connectors | |---|---|---|---| | **`feed-reader`** | **Yes** | `Read`, `Grep`, `WebFetch` only | None | -| `materiality-filter` / Orchestrator | No | `Read`, `Grep`, `Glob`, `Agent` | gdrive (orchestrator only) | +| `materiality-filter` | No | `Read`, `Grep`, `Glob` | None | | **`digest-writer`** (Write-holder) | No | `Read`, `Write`, `Edit` | None | +| Orchestrator | No | `Read`, `Grep`, `Glob`, `Agent` | None | + +No component in this cookbook holds an MCP connector — `feed-reader`'s allowlisted `WebFetch` is the only outbound network access. `feed-reader` returns length-capped, schema-validated JSON. `materiality-filter` is pure computation over that JSON plus the regulatory-legal configuration on disk — no MCP, no web. `digest-writer` produces `./out/reg-digest-.md` and emits a `handoff_request` for Slack delivery. diff --git a/managed-agent-cookbooks/reg-monitor/agent.yaml b/managed-agent-cookbooks/reg-monitor/agent.yaml index 8e870ff3e9..b313454881 100644 --- a/managed-agent-cookbooks/reg-monitor/agent.yaml +++ b/managed-agent-cookbooks/reg-monitor/agent.yaml @@ -17,8 +17,9 @@ system: that framing in the digest to make it read cleaner. tools: - # Orchestrator is scoped to local-only tools; MCP and web_fetch are held by - # the subagent leaves (see callable_agents). + # Orchestrator is scoped to local-only tools; web_fetch is held by the + # feed-reader leaf (see callable_agents). No component in this cookbook + # holds an MCP toolset. - type: agent_toolset_20260401 default_config: { enabled: false } configs: @@ -26,8 +27,7 @@ tools: - { name: grep, enabled: true } - { name: glob, enabled: true } -mcp_servers: - - { type: url, name: gdrive, url: "${GDRIVE_MCP_URL}" } +mcp_servers: [] skills: - { from_plugin: ../../regulatory-legal } diff --git a/managed-agent-cookbooks/renewal-watcher/README.md b/managed-agent-cookbooks/renewal-watcher/README.md index dc2d8a80c1..0ad60989d1 100644 --- a/managed-agent-cookbooks/renewal-watcher/README.md +++ b/managed-agent-cookbooks/renewal-watcher/README.md @@ -18,9 +18,9 @@ This is a **cookbook, not a product.** It assumes Ironclad as the CLM of record export ANTHROPIC_API_KEY=sk-ant-... export IRONCLAD_MCP_URL=... export GDRIVE_MCP_URL=... -# Optional — enable in the manifest if your signed agreements live here +# Optional — enable the imanage toolset in subagents/repo-reader.yaml if your +# signed agreements live there export IMANAGE_MCP_URL=... -export DOCUSIGN_MCP_URL=... ../../scripts/deploy-managed-agent.sh renewal-watcher ``` @@ -35,8 +35,9 @@ Contract text, counterparty messages, and CLM comments are **untrusted input.** | Tier | Touches untrusted docs? | Tools | Connectors | |---|---|---|---| | **`repo-reader`** | **Yes** | `Read`, `Grep` only | ironclad, gdrive (read-only); imanage off by default | -| `deadline-calculator` / Orchestrator | No | `Read`, `Grep`, `Glob`, `Agent` | None | +| `deadline-calculator` | No | `Read`, `Grep`, `Glob` | None | | **`alert-writer`** (Write-holder) | No | `Read`, `Write`, `Edit` | None | +| Orchestrator | No | `Read`, `Grep`, `Glob`, `Agent` | None | `repo-reader` returns length-capped, schema-validated JSON. `deadline-calculator` is pure computation over that JSON plus the playbook configuration on disk — no MCP, no web. `alert-writer` produces `./out/renewal-alerts-.md` and emits a `handoff_request` for Slack delivery. @@ -50,7 +51,7 @@ Contract text, counterparty messages, and CLM comments are **untrusted input.** Before you trust the output on your workflow: -- **Point at your CLM.** `IRONCLAD_MCP_URL` is the default. If signed agreements live in iManage, flip `imanage` to `default_config: { enabled: true }` in `agent.yaml` and `subagents/repo-reader.yaml` and set `IMANAGE_MCP_URL`. If they live in a Google Drive folder, rely on `gdrive` and the repo-reader's fallback search path. If they live in a CLM without a public MCP (Agiloft, Conga), wire a custom connector and update the MCP server block. +- **Point at your CLM.** `IRONCLAD_MCP_URL` is the default. If signed agreements live in iManage, flip `imanage` to `default_config: { enabled: true }` in [`subagents/repo-reader.yaml`](./subagents/repo-reader.yaml) — the only place the toolset is granted — and set `IMANAGE_MCP_URL`. Don't add it to `agent.yaml`'s `tools`, which must stay local-only. If they live in a Google Drive folder, rely on `gdrive` and the repo-reader's fallback search path. If they live in a CLM without a public MCP (Agiloft, Conga), wire a custom connector and update the MCP server block. - **Set the Slack channel.** The alert-writer emits a `handoff_request` that names a Slack channel. The orchestrator reads that channel from your playbook configuration's **House style → Renewal alerts** field. Set it before the first scheduled run or the handoff will dead-letter. - **Tune the lookahead windows.** The deadline-calculator's default tiers are overdue / 30 / 60 / 90 / 180 days. If your renewal cycle is shorter (SaaS order forms under one year) or longer (multi-year enterprise MSAs with 12-month notice windows), adjust the tier thresholds in the deadline-calculator prompt and the corresponding sections in `alert-writer.yaml`. - **Adjust the escalation matrix.** The deadline-calculator reads your playbook's escalation matrix to decide whether to set `escalation_needed: true` and who to route to. Confirm the matrix reflects your current approval authority (who signs off on letting an auto-renewal lapse, who signs off on a renegotiation above a dollar threshold) before enabling scheduled runs. The [`escalation-flagger`](../../commercial-legal/skills/escalation-flagger) skill is loaded in `alert-writer` for formatting. diff --git a/managed-agent-cookbooks/renewal-watcher/agent.yaml b/managed-agent-cookbooks/renewal-watcher/agent.yaml index b8b36c9734..6f1814c95a 100644 --- a/managed-agent-cookbooks/renewal-watcher/agent.yaml +++ b/managed-agent-cookbooks/renewal-watcher/agent.yaml @@ -37,7 +37,6 @@ mcp_servers: - { type: url, name: ironclad, url: "${IRONCLAD_MCP_URL}" } - { type: url, name: gdrive, url: "${GDRIVE_MCP_URL}" } - { type: url, name: imanage, url: "${IMANAGE_MCP_URL}" } - - { type: url, name: docusign, url: "${DOCUSIGN_MCP_URL}" } skills: - { from_plugin: ../../commercial-legal } diff --git a/product-legal/skills/customize/SKILL.md b/product-legal/skills/customize/SKILL.md index 0f812dfc32..1a294f66fa 100644 --- a/product-legal/skills/customize/SKILL.md +++ b/product-legal/skills/customize/SKILL.md @@ -89,8 +89,8 @@ cold-start interview and without hand-editing YAML. the plugin / team that picks it up. - **Flag internal inconsistency.** If the change would make the profile inconsistent (e.g., AI-feature claims scrutiny on + no AI policy - commitments set in `/ai-governance-legal`; or "fast SLA" + "every - launch requires GC sign-off"), flag the tension. + commitments set in the `ai-governance-legal` plugin; or "fast SLA" + + "every launch requires GC sign-off"), flag the tension. - **Flag guardrail degradation.** The `[review]` flag, source attribution tags, and `[verify]` tags on cited regulations are load-bearing — do not remove. The substantiation requirement on claims is the thing diff --git a/scripts/lint-cookbook-docs.py b/scripts/lint-cookbook-docs.py new file mode 100755 index 0000000000..a0437fe720 --- /dev/null +++ b/scripts/lint-cookbook-docs.py @@ -0,0 +1,232 @@ +#!/usr/bin/env python3 +# Copyright 2026 Anthropic PBC +# SPDX-License-Identifier: Apache-2.0 +"""Assert each cookbook's README security table matches what its YAML grants. + +CLAUDE.md, cookbook rule 2: + + The README's security table and the `agent.yaml` comments must match what + the YAML actually grants. Don't claim a tool a subagent doesn't have. + +`lint-tool-scope.py` enforces rule 1 (the orchestrator stays local-only) but +only ever reads `agent.yaml`. It never opens `subagents/*.yaml` or `README.md`, +so rule 2 was unenforced — a README could advertise connectors on a component +that holds none, and nothing failed. This lint closes that gap. + +Three checks per cookbook: + + 1. TABLE DRIFT — every README security-table row naming a component + (`` `` `` or `Orchestrator`) must list exactly the tools that + component's YAML grants. Both directions are errors: claiming a tool the + YAML withholds (over-claim) and omitting one the YAML grants + (under-disclosure — the more serious of the two). + 2. PHANTOM CONNECTOR — every server declared in the orchestrator's + `mcp_servers` must be granted to at least one component via an + `mcp_toolset`. A declared-but-ungranted server reads as a live connector + in the README and in deploy instructions while reaching nothing. + 3. STALE COMMENT — an `agent.yaml` comment claiming the leaves hold MCP or + `web_fetch` must be true of at least one leaf. + +Exits non-zero listing every mismatch. Exits 0 with a one-line summary per +cookbook on success. +""" +from __future__ import annotations + +import re +import sys +from pathlib import Path + +import yaml + +ROOT = Path(__file__).resolve().parent.parent +COOKBOOKS_DIR = ROOT / "managed-agent-cookbooks" + +# README tool label -> the name used in an agent_toolset config. +LABELS = { + "read": "read", "grep": "grep", "glob": "glob", "write": "write", + "edit": "edit", "webfetch": "web_fetch", "web_fetch": "web_fetch", +} +# `Agent` in a README table means "can call subagents", i.e. callable_agents. +AGENT_LABEL = "agent" + + +def _granted(doc: dict) -> tuple[set[str], set[str], bool]: + """Return (agent_toolset tool names, mcp server names, has_callable_agents).""" + tools, mcp = set(), set() + for entry in doc.get("tools") or []: + if not isinstance(entry, dict): + continue + ttype = entry.get("type", "") + if ttype == "mcp_toolset": + if (entry.get("default_config") or {}).get("enabled", False): + mcp.add(entry.get("mcp_server_name", "")) + continue + if not ttype.startswith("agent_toolset"): + continue + default = bool((entry.get("default_config") or {}).get("enabled", False)) + for cfg in entry.get("configs") or []: + if isinstance(cfg, dict) and bool(cfg.get("enabled", default)): + tools.add(cfg.get("name")) + return tools, mcp, bool(doc.get("callable_agents")) + + +def _wired_servers(cb: Path, comp: str) -> set[str]: + """Servers a component has an mcp_toolset for, including ones off by default.""" + path = cb / "agent.yaml" if comp == "" else cb / "subagents" / f"{comp}.yaml" + if not path.is_file(): + return set() + doc = yaml.safe_load(path.read_text()) or {} + return { + e.get("mcp_server_name") + for e in (doc.get("tools") or []) + if isinstance(e, dict) and e.get("type") == "mcp_toolset" + } + + +def _table_rows(readme: str): + """Yield (line_no, component_label, declared_tool_labels) for security rows.""" + for i, line in enumerate(readme.split("\n"), 1): + if not line.startswith("|") or line.count("|") < 4: + continue + cells = [c.strip() for c in line.strip().strip("|").split("|")] + if len(cells) < 3: + continue + head = cells[0] + if head.lower().startswith(("tier", "---", ":--")) or set(head) <= set("-: "): + continue + # Component name: a backticked leaf name, or the literal "Orchestrator". + m = re.search(r"`([a-z][a-z0-9-]*)`", head) + if m: + comp = m.group(1) + elif head.strip("* ").lower() == "orchestrator": + comp = "" + else: + continue + # A combined "`leaf` / Orchestrator" row publishes a union of two tiers + # and cannot be checked against either — flag it rather than guess. + combined = "/" in head and "orchestrator" in head.lower() + declared = {t.lower() for t in re.findall(r"`([A-Za-z_]+)`", cells[-2])} + yield i, comp, declared, combined, cells[-1] + + +def _lint_one(cb: Path) -> list[str]: + errs: list[str] = [] + agent_path = cb / "agent.yaml" + if not agent_path.is_file(): + return [f"{cb.name}: missing agent.yaml"] + agent_doc = yaml.safe_load(agent_path.read_text()) or {} + orch_tools, orch_mcp, orch_agents = _granted(agent_doc) + + comps: dict[str, tuple[set[str], set[str], bool]] = {"": (orch_tools, orch_mcp, True)} + for sub in sorted((cb / "subagents").glob("*.yaml")): + comps[sub.stem] = _granted(yaml.safe_load(sub.read_text()) or {}) + + rel_agent = agent_path.relative_to(ROOT) + + # 2. Phantom connectors. + declared_servers = { + s.get("name") for s in (agent_doc.get("mcp_servers") or []) if isinstance(s, dict) + } + all_granted_mcp = set().union(*(m for _, m, _ in comps.values())) if comps else set() + # A toolset present but disabled still counts as "wired", just off by default. + for sub in sorted((cb / "subagents").glob("*.yaml")): + for entry in (yaml.safe_load(sub.read_text()) or {}).get("tools") or []: + if isinstance(entry, dict) and entry.get("type") == "mcp_toolset": + all_granted_mcp.add(entry.get("mcp_server_name")) + for name in sorted(declared_servers - all_granted_mcp): + errs.append( + f"{rel_agent}: PHANTOM CONNECTOR '{name}' declared in mcp_servers but " + f"granted to no component via mcp_toolset — remove it or grant it to a leaf" + ) + + # 3. Stale orchestrator comments. + agent_src = agent_path.read_text() + # Join the comment block into one logical string so a claim wrapped across + # two `#` lines still reads as one sentence, then split on sentence ends so + # a later sentence can negate an earlier claim without tripping the match. + comment_block = " ".join( + l.strip().lstrip("#").strip() for l in agent_src.split("\n") if l.strip().startswith("#") + ) + claims = [s for s in re.split(r"(?<=[.;])\s+", comment_block) if "held by" in s] + leaf_tools = set().union(*(t for k, (t, _, _) in comps.items() if k != "")) or set() + leaf_mcp = set().union(*(m for k, (_, m, _) in comps.items() if k != "")) or set() + if any("web_fetch" in c for c in claims) and "web_fetch" not in leaf_tools: + errs.append( + f"{rel_agent}: STALE COMMENT claims web_fetch is held by a subagent leaf, " + f"but no leaf grants web_fetch" + ) + if any("MCP" in c for c in claims) and not leaf_mcp: + errs.append( + f"{rel_agent}: STALE COMMENT claims MCP is held by the subagent leaves, " + f"but no leaf grants an enabled mcp_toolset" + ) + + # 1. Table drift. + readme_path = cb / "README.md" + if readme_path.is_file(): + rel_readme = readme_path.relative_to(ROOT) + for line, comp, declared, combined, connectors in _table_rows(readme_path.read_text()): + if comp not in comps: + continue + # A component with no mcp_toolset at all must advertise no connector. + # Name-by-name matching is left to review (READMEs use display names + # like "Google Drive" for `gdrive`), but none-vs-some is checkable — + # and it is the direction that overstates the security surface. + wired = comps[comp][1] or _wired_servers(cb, comp) + if not wired and not re.match(r"none\b", connectors.strip().strip("*"), re.I): + errs.append( + f"{rel_readme}:{line}: CONNECTOR DRIFT '{comp}' README advertises " + f"connectors ({connectors!r}) but the YAML grants no mcp_toolset" + ) + if combined: + errs.append( + f"{rel_readme}:{line}: COMBINED ROW '{comp} / Orchestrator' publishes a " + f"union of two tiers — split into one row per component so each is checkable" + ) + continue + tools, _, has_agents = comps[comp] + # Compare canonical tool names, not README labels — `WebFetch` and + # `web_fetch` are the same grant and must not read as a mismatch. + actual = {LABELS[k] for k in LABELS if LABELS[k] in tools} + if has_agents: + actual.add(AGENT_LABEL) + declared_norm = { + LABELS[d] if d in LABELS else d + for d in declared + if d in LABELS or d == AGENT_LABEL + } + over = declared_norm - actual + under = actual - declared_norm + if over: + errs.append( + f"{rel_readme}:{line}: TABLE DRIFT '{comp}' README claims " + f"{sorted(over)} that the YAML does not grant" + ) + if under: + errs.append( + f"{rel_readme}:{line}: TABLE DRIFT '{comp}' YAML grants " + f"{sorted(under)} that the README does not disclose" + ) + return errs + + +def main() -> int: + if not COOKBOOKS_DIR.is_dir(): + print(f"no cookbooks dir at {COOKBOOKS_DIR}", file=sys.stderr) + return 2 + total, clean = [], [] + for cb in sorted(d for d in COOKBOOKS_DIR.iterdir() if (d / "agent.yaml").is_file()): + errs = _lint_one(cb) + total.extend(errs) if errs else clean.append(cb.name) + if total: + print("cookbook-docs lint FAILED:", file=sys.stderr) + for e in total: + print(f" {e}", file=sys.stderr) + return 1 + for slug in clean: + print(f" ✓ {slug:24s} README security table matches YAML grants") + return 0 + + +if __name__ == "__main__": + sys.exit(main()) diff --git a/scripts/lint-skill-refs.py b/scripts/lint-skill-refs.py new file mode 100755 index 0000000000..0ce7bcfa66 --- /dev/null +++ b/scripts/lint-skill-refs.py @@ -0,0 +1,154 @@ +#!/usr/bin/env python3 +# Copyright 2026 Anthropic PBC +# SPDX-License-Identifier: Apache-2.0 +"""Assert every slash-command reference in plugin prose resolves to a real skill. + +CLAUDE.md, "Skill names in prose must be canonical": + + When a SKILL.md (especially `customize` or `cold-start-interview`) tells the + user "run `/foo`," `foo` must be the actual `skills//` directory name. + Short forms like `/triage` for `/use-case-triage` look right in prose but are + dead commands — the user types them and nothing happens. + +`claude plugin validate` does not check this: a `/foo` in prose is just text, so +a short form or a mangled find-and-replace ships silently and the user gets +nothing when they type it. This lint closes that gap. + +Two defect classes are reported: + + 1. DEAD REF — `/foo` (or `/:`) names no skill directory. Refs to + Claude Code built-ins (`/mcp`, `/plugin`, ...) are exempt. + 2. WRAPPED REF — the name resolves, but the source splits it across a line + inside the backticks, so it renders with a space (`/handbook- updates`) + and is not typable as written. + +Scanned: each plugin's CLAUDE.md, README.md, skills/*/SKILL.md, agents/*.md. +Exits non-zero listing every offending file:line on any violation. +""" +from __future__ import annotations + +import re +import sys +from pathlib import Path + +ROOT = Path(__file__).resolve().parent.parent + +# Claude Code built-in slash commands — legitimate refs, not plugin skills. +BUILTINS = { + "add-dir", "agents", "bug", "clear", "compact", "config", "context", "cost", + "doctor", "exit", "export", "feedback", "help", "hooks", "ide", "init", + "install-github-app", "login", "logout", "loop", "mcp", "memory", "model", + "output-style", "permissions", "plugin", "pr-comments", "privacy-settings", + "quit", "release-notes", "resume", "review", "rewind", "sandbox", "settings", + "skills", "statusline", "status", "terminal-setup", "todos", "upgrade", + "usage", "vim", "workflows", "worktree", +} + +# A backtick-delimited token starting with `/`. DOTALL so we can detect a name +# broken across a source line inside the backticks. +BACKTICKED = re.compile(r"`(/[^`]{1,120}?)`", re.S) +# A bare `/foo` in prose, when preceded by whitespace or an opening delimiter. +# Excludes path-like refs (`foo/bar`, `/foo/bar`), numeric folder prefixes, and +# the plugin half of a qualified `/plugin:skill` ref (handled by BACKTICKED). +BARE = re.compile( + r"(?:(?<=\s)|(?<=\()|(?<=\[)|(?<=^))/([a-z][a-z0-9-]{1,63})(?![a-z0-9:/-])", re.M +) +NAME_OK = re.compile(r"^[a-z0-9][a-z0-9:-]*$") + + +def _plugin_dirs() -> list[Path]: + dirs = [ + d for d in sorted(ROOT.iterdir()) + if d.is_dir() and (d / ".claude-plugin" / "plugin.json").is_file() + ] + ext = ROOT / "external_plugins" + if ext.is_dir(): + dirs += [ + d for d in sorted(ext.iterdir()) + if d.is_dir() and (d / ".claude-plugin" / "plugin.json").is_file() + ] + return dirs + + +def _skills(plugin: Path) -> set[str]: + skills_dir = plugin / "skills" + if not skills_dir.is_dir(): + return set() + return {d.name for d in skills_dir.iterdir() if d.is_dir()} + + +def _scan_files(plugin: Path): + for rel in ("CLAUDE.md", "README.md"): + p = plugin / rel + if p.is_file(): + yield p + yield from sorted(plugin.glob("skills/*/SKILL.md")) + yield from sorted(plugin.glob("agents/*.md")) + + +def main() -> int: + plugins = _plugin_dirs() + if not plugins: + print(f"no plugins found under {ROOT}", file=sys.stderr) + return 2 + skills = {p.name: _skills(p) for p in plugins} + errs: list[str] = [] + + for plugin in plugins: + own = skills[plugin.name] + for path in _scan_files(plugin): + src = path.read_text(encoding="utf-8") + rel = path.relative_to(ROOT) + + # Collect (name, line, wrapped) candidates from both syntaxes. + found: list[tuple[str, int, bool]] = [] + for m in BACKTICKED.finditer(src): + raw = m.group(1) + wrapped = "\n" in raw + joined = re.sub(r"\n\s*", "", raw) if wrapped else raw + parts = joined[1:].split() + if not parts: + continue + # First token is the command; the rest are arguments (--redo, ...). + found.append((parts[0].rstrip(".,;:)!?"), src[: m.start()].count("\n") + 1, wrapped)) + for m in BARE.finditer(src): + # Skip path-like refs: `/foo/bar` or `dir/foo`. + if m.end() < len(src) and src[m.end()] == "/": + continue + found.append((m.group(1), src[: m.start()].count("\n") + 1, False)) + + for name, line, wrapped in found: + if not NAME_OK.match(name): + continue + if ":" in name: + target, _, skill = name.partition(":") + if target not in skills or not skill: + continue # not one of ours — leave it alone + pool, label = skills[target], f"/{name}" + else: + if name in BUILTINS: + continue + target, skill = plugin.name, name + pool, label = own, f"/{name}" + if skill not in pool: + near = sorted(s for s in pool if s.startswith(skill) or skill in s) + hint = f" — did you mean {'/' + near[0]}?" if near else "" + errs.append(f"{rel}:{line}: DEAD REF {label} — no skills/{skill}/ in {target}{hint}") + elif wrapped: + errs.append( + f"{rel}:{line}: WRAPPED REF {label} — name is split across a " + f"source line inside backticks; it renders with a space" + ) + + if errs: + print("skill-ref lint FAILED:", file=sys.stderr) + for e in dict.fromkeys(errs): + print(f" {e}", file=sys.stderr) + return 1 + total = sum(len(v) for v in skills.values()) + print(f" ✓ slash-command refs resolve across {len(plugins)} plugins / {total} skills") + return 0 + + +if __name__ == "__main__": + sys.exit(main()) From ed1c062f8677a658004a754114801d1826d94317 Mon Sep 17 00:00:00 2001 From: peterzhang12312-jpg Date: Mon, 3 Aug 2026 17:09:29 -0400 Subject: [PATCH 2/2] Fix lint scripts to run cleanly on Windows without PYTHONUTF8=1 MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Repo text is UTF-8 and the lint scripts print non-ASCII markers (✓, —), but Windows consoles default to cp1252, so print() raised UnicodeEncodeError and lint-cookbook-docs.py's bare read_text()/open() calls raised UnicodeDecodeError on the same files. Reconfigure stdout/stderr to UTF-8 in each script and pass encoding="utf-8" explicitly on every file read, instead of requiring every caller to remember PYTHONUTF8=1. Verified: all three scripts pass with no env override and under PYTHONIOENCODING=cp1252 forced. --- scripts/lint-cookbook-docs.py | 20 ++++++++++++++------ scripts/lint-skill-refs.py | 8 ++++++++ scripts/lint-tool-scope.py | 9 ++++++++- 3 files changed, 30 insertions(+), 7 deletions(-) diff --git a/scripts/lint-cookbook-docs.py b/scripts/lint-cookbook-docs.py index a0437fe720..89389ae8b9 100755 --- a/scripts/lint-cookbook-docs.py +++ b/scripts/lint-cookbook-docs.py @@ -38,6 +38,14 @@ import yaml +# Repo text is UTF-8 and this script's output uses non-ASCII markers (✓, —). +# On Windows the console defaults to a legacy code page (cp1252), which makes +# print() raise UnicodeEncodeError. Re-encode the streams instead of requiring +# every caller to remember PYTHONUTF8=1. +for _stream in (sys.stdout, sys.stderr): + if hasattr(_stream, "reconfigure"): + _stream.reconfigure(encoding="utf-8", errors="replace") + ROOT = Path(__file__).resolve().parent.parent COOKBOOKS_DIR = ROOT / "managed-agent-cookbooks" @@ -75,7 +83,7 @@ def _wired_servers(cb: Path, comp: str) -> set[str]: path = cb / "agent.yaml" if comp == "" else cb / "subagents" / f"{comp}.yaml" if not path.is_file(): return set() - doc = yaml.safe_load(path.read_text()) or {} + doc = yaml.safe_load(path.read_text(encoding="utf-8")) or {} return { e.get("mcp_server_name") for e in (doc.get("tools") or []) @@ -114,12 +122,12 @@ def _lint_one(cb: Path) -> list[str]: agent_path = cb / "agent.yaml" if not agent_path.is_file(): return [f"{cb.name}: missing agent.yaml"] - agent_doc = yaml.safe_load(agent_path.read_text()) or {} + agent_doc = yaml.safe_load(agent_path.read_text(encoding="utf-8")) or {} orch_tools, orch_mcp, orch_agents = _granted(agent_doc) comps: dict[str, tuple[set[str], set[str], bool]] = {"": (orch_tools, orch_mcp, True)} for sub in sorted((cb / "subagents").glob("*.yaml")): - comps[sub.stem] = _granted(yaml.safe_load(sub.read_text()) or {}) + comps[sub.stem] = _granted(yaml.safe_load(sub.read_text(encoding="utf-8")) or {}) rel_agent = agent_path.relative_to(ROOT) @@ -130,7 +138,7 @@ def _lint_one(cb: Path) -> list[str]: all_granted_mcp = set().union(*(m for _, m, _ in comps.values())) if comps else set() # A toolset present but disabled still counts as "wired", just off by default. for sub in sorted((cb / "subagents").glob("*.yaml")): - for entry in (yaml.safe_load(sub.read_text()) or {}).get("tools") or []: + for entry in (yaml.safe_load(sub.read_text(encoding="utf-8")) or {}).get("tools") or []: if isinstance(entry, dict) and entry.get("type") == "mcp_toolset": all_granted_mcp.add(entry.get("mcp_server_name")) for name in sorted(declared_servers - all_granted_mcp): @@ -140,7 +148,7 @@ def _lint_one(cb: Path) -> list[str]: ) # 3. Stale orchestrator comments. - agent_src = agent_path.read_text() + agent_src = agent_path.read_text(encoding="utf-8") # Join the comment block into one logical string so a claim wrapped across # two `#` lines still reads as one sentence, then split on sentence ends so # a later sentence can negate an earlier claim without tripping the match. @@ -165,7 +173,7 @@ def _lint_one(cb: Path) -> list[str]: readme_path = cb / "README.md" if readme_path.is_file(): rel_readme = readme_path.relative_to(ROOT) - for line, comp, declared, combined, connectors in _table_rows(readme_path.read_text()): + for line, comp, declared, combined, connectors in _table_rows(readme_path.read_text(encoding="utf-8")): if comp not in comps: continue # A component with no mcp_toolset at all must advertise no connector. diff --git a/scripts/lint-skill-refs.py b/scripts/lint-skill-refs.py index 0ce7bcfa66..e9865ef532 100755 --- a/scripts/lint-skill-refs.py +++ b/scripts/lint-skill-refs.py @@ -31,6 +31,14 @@ import sys from pathlib import Path +# Repo text is UTF-8 and this script's output uses non-ASCII markers (✓, —). +# On Windows the console defaults to a legacy code page (cp1252), which makes +# print() raise UnicodeEncodeError. Re-encode the streams instead of requiring +# every caller to remember PYTHONUTF8=1. +for _stream in (sys.stdout, sys.stderr): + if hasattr(_stream, "reconfigure"): + _stream.reconfigure(encoding="utf-8", errors="replace") + ROOT = Path(__file__).resolve().parent.parent # Claude Code built-in slash commands — legitimate refs, not plugin skills. diff --git a/scripts/lint-tool-scope.py b/scripts/lint-tool-scope.py index 9b75dbb44d..fefee4c986 100755 --- a/scripts/lint-tool-scope.py +++ b/scripts/lint-tool-scope.py @@ -24,6 +24,13 @@ import yaml +# Repo text is UTF-8 and this script's output uses non-ASCII markers (✓, —). +# On Windows the console defaults to a legacy code page (cp1252), which makes +# print() raise UnicodeEncodeError. Re-encode the streams instead of requiring +# every caller to remember PYTHONUTF8=1. +for _stream in (sys.stdout, sys.stderr): + if hasattr(_stream, "reconfigure"): + _stream.reconfigure(encoding="utf-8", errors="replace") ROOT = Path(__file__).resolve().parent.parent COOKBOOKS_DIR = ROOT / "managed-agent-cookbooks" @@ -32,7 +39,7 @@ def _lint_one(path: Path) -> list[str]: """Return a list of violation strings (empty if clean).""" errs: list[str] = [] - with path.open() as f: + with path.open(encoding="utf-8") as f: doc = yaml.safe_load(f) tools = doc.get("tools") or [] for idx, entry in enumerate(tools):