@ssheleg/make-skill 0.9.1 → 0.11.0
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 +149 -0
- package/README.md +12 -5
- package/SECURITY.md +10 -4
- package/bin/make-skill.js +18 -0
- package/cursor/rules/make-skill.mdc +20 -6
- package/package.json +2 -2
- package/plugins/make-skill/.claude-plugin/plugin.json +1 -1
- package/plugins/make-skill/agents/skill-auditor.md +9 -9
- package/plugins/make-skill/bin/make-skill-audit +41 -0
- package/plugins/make-skill/commands/skill-audit.md +10 -11
- package/plugins/make-skill/hooks/hooks.json +1 -1
- package/plugins/make-skill/skills/make-skill/SKILL.md +137 -156
- package/plugins/make-skill/skills/make-skill/assets/agent.template.md +8 -6
- package/plugins/make-skill/skills/make-skill/assets/command.template.md +7 -6
- package/plugins/make-skill/skills/make-skill/references/agent-skills-spec.md +3 -1
- package/plugins/make-skill/skills/make-skill/references/claude-code-plugin.md +9 -0
- package/plugins/make-skill/skills/make-skill/references/distribution.md +7 -1
- package/plugins/make-skill/skills/make-skill/references/host-capabilities.md +35 -9
- package/plugins/make-skill/skills/make-skill/references/mcp-ship.md +275 -0
- package/plugins/make-skill/skills/make-skill/references/retrofit.md +40 -13
- package/plugins/make-skill/skills/make-skill/scripts/audit_skill.py +43 -20
|
@@ -0,0 +1,275 @@
|
|
|
1
|
+
# Shipping an MCP server — mount, debug, publish
|
|
2
|
+
|
|
3
|
+
**Load this when:** the server is written and now has to reach someone —
|
|
4
|
+
mounting it inside an existing web app, fixing a client that cannot connect, or
|
|
5
|
+
publishing it to the official MCP Registry.
|
|
6
|
+
|
|
7
|
+
`references/mcp.md` is the author's model of the protocol: primitives,
|
|
8
|
+
lifecycle, transports, security. This file starts where that one stops. If you
|
|
9
|
+
are still designing tools and their schemas, Anthropic's `mcp-builder` skill
|
|
10
|
+
covers authoring and evaluation in depth and this file deliberately does not
|
|
11
|
+
repeat it — **it has nothing on the registry, on mounting, or on why a client
|
|
12
|
+
sees 404, which is exactly what is here.**
|
|
13
|
+
|
|
14
|
+
Registry schema URL and CLI flags move. Read them from
|
|
15
|
+
<https://modelcontextprotocol.io/registry/> and the
|
|
16
|
+
[registry repo](https://github.com/modelcontextprotocol/registry) rather than
|
|
17
|
+
from a copy — the pinned example below was current on **2026-08-06** and the
|
|
18
|
+
schema is dated by design.
|
|
19
|
+
|
|
20
|
+
## Contents
|
|
21
|
+
|
|
22
|
+
- Mounting into an existing web app
|
|
23
|
+
- Auth middleware and a health endpoint
|
|
24
|
+
- Client configuration
|
|
25
|
+
- Debugging a client that will not connect
|
|
26
|
+
- Publishing to the MCP Registry
|
|
27
|
+
- Registry name formats and who may claim them
|
|
28
|
+
- Automating publication
|
|
29
|
+
- Versioning
|
|
30
|
+
|
|
31
|
+
---
|
|
32
|
+
|
|
33
|
+
## Mounting into an existing web app
|
|
34
|
+
|
|
35
|
+
The common production shape: you already run a FastAPI/Starlette app, and the
|
|
36
|
+
MCP server should live at `/mcp` on the same host. It is also where the single
|
|
37
|
+
most common bug lives.
|
|
38
|
+
|
|
39
|
+
**The double-path pitfall.** FastMCP defaults its internal
|
|
40
|
+
`streamable_http_path` to `/mcp/`. Mount that app at `/mcp` and the real
|
|
41
|
+
endpoint becomes `/mcp/mcp` — so every client pointed at `/mcp` gets 404, and
|
|
42
|
+
the SSE fallback 404s too, which reads like the server is down.
|
|
43
|
+
|
|
44
|
+
```python
|
|
45
|
+
mcp = FastMCP(
|
|
46
|
+
"my_server",
|
|
47
|
+
streamable_http_path="/", # REQUIRED when mounting as a sub-app
|
|
48
|
+
stateless_http=True,
|
|
49
|
+
json_response=True,
|
|
50
|
+
)
|
|
51
|
+
|
|
52
|
+
app.mount("/mcp", mcp.streamable_http_app())
|
|
53
|
+
```
|
|
54
|
+
|
|
55
|
+
Set `streamable_http_path="/"` whenever the app is mounted rather than served
|
|
56
|
+
standalone. Standalone servers keep the default.
|
|
57
|
+
|
|
58
|
+
---
|
|
59
|
+
|
|
60
|
+
## Auth middleware and a health endpoint
|
|
61
|
+
|
|
62
|
+
Wrap the mounted ASGI app rather than adding auth inside tool handlers — the
|
|
63
|
+
transport handshake happens before any tool runs, so handler-level auth leaves
|
|
64
|
+
the protocol surface open.
|
|
65
|
+
|
|
66
|
+
```python
|
|
67
|
+
class MCPAuthMiddleware:
|
|
68
|
+
_PUBLIC_PATHS = {"/health"}
|
|
69
|
+
|
|
70
|
+
def __init__(self, app, *, store):
|
|
71
|
+
self._app, self._store = app, store
|
|
72
|
+
|
|
73
|
+
async def __call__(self, scope, receive, send):
|
|
74
|
+
if scope["type"] not in ("http", "websocket"):
|
|
75
|
+
return await self._app(scope, receive, send)
|
|
76
|
+
if scope.get("path", "") in self._PUBLIC_PATHS:
|
|
77
|
+
return await self._app(scope, receive, send)
|
|
78
|
+
|
|
79
|
+
headers = dict(scope.get("headers", []))
|
|
80
|
+
auth = headers.get(b"authorization", b"").decode()
|
|
81
|
+
if not auth.startswith("Bearer "):
|
|
82
|
+
return await self._send_401(send, "Missing Bearer token")
|
|
83
|
+
if not await self._store.validate_api_key(auth[7:]):
|
|
84
|
+
return await self._send_401(send, "Invalid API key")
|
|
85
|
+
return await self._app(scope, receive, send)
|
|
86
|
+
|
|
87
|
+
app.mount("/mcp", MCPAuthMiddleware(mcp.streamable_http_app(), store=key_store))
|
|
88
|
+
```
|
|
89
|
+
|
|
90
|
+
Keep a health endpoint **outside** the mount and exempt from auth. It is what
|
|
91
|
+
tells you "the app is up, the MCP path is wrong" apart from "the app is down" —
|
|
92
|
+
the two produce identical client errors.
|
|
93
|
+
|
|
94
|
+
```python
|
|
95
|
+
@app.get("/mcp/health")
|
|
96
|
+
async def mcp_health():
|
|
97
|
+
return {"status": "ok", "server": "my_server", "transport": "streamable-http"}
|
|
98
|
+
```
|
|
99
|
+
|
|
100
|
+
---
|
|
101
|
+
|
|
102
|
+
## Client configuration
|
|
103
|
+
|
|
104
|
+
Remote (streamable HTTP):
|
|
105
|
+
|
|
106
|
+
```json
|
|
107
|
+
{
|
|
108
|
+
"mcpServers": {
|
|
109
|
+
"my-server": {
|
|
110
|
+
"url": "https://example.com/mcp",
|
|
111
|
+
"headers": { "Authorization": "Bearer ${MY_SERVER_KEY}" }
|
|
112
|
+
}
|
|
113
|
+
}
|
|
114
|
+
}
|
|
115
|
+
```
|
|
116
|
+
|
|
117
|
+
Local (stdio):
|
|
118
|
+
|
|
119
|
+
```json
|
|
120
|
+
{
|
|
121
|
+
"mcpServers": {
|
|
122
|
+
"my-server": {
|
|
123
|
+
"command": "uv",
|
|
124
|
+
"args": ["--directory", "/path/to/project", "run", "python", "-m", "my_server"],
|
|
125
|
+
"env": { "API_KEY": "..." }
|
|
126
|
+
}
|
|
127
|
+
}
|
|
128
|
+
}
|
|
129
|
+
```
|
|
130
|
+
|
|
131
|
+
Ship both forms in your README. A user who has to derive the config from prose
|
|
132
|
+
files an issue instead.
|
|
133
|
+
|
|
134
|
+
---
|
|
135
|
+
|
|
136
|
+
## Debugging a client that will not connect
|
|
137
|
+
|
|
138
|
+
**404 — by far the most common.** Symptom: `Error POSTing to endpoint: Not
|
|
139
|
+
Found`, then the SSE fallback 404s as well. Bisect the path directly:
|
|
140
|
+
|
|
141
|
+
```bash
|
|
142
|
+
curl -X POST https://your-host/mcp/ -H 'Content-Type: application/json' -d '{}'
|
|
143
|
+
curl -X POST https://your-host/mcp -H 'Content-Type: application/json' -d '{}'
|
|
144
|
+
curl -X POST https://your-host/mcp/mcp -H 'Content-Type: application/json' -d '{}'
|
|
145
|
+
```
|
|
146
|
+
|
|
147
|
+
If `/mcp/mcp` answers (even 401) while `/mcp/` 404s, it is the double path.
|
|
148
|
+
A 401 is a **success** for this test: it proves routing reached the server.
|
|
149
|
+
|
|
150
|
+
**307.** Starlette redirects `/mcp` → `/mcp/`. Most clients follow it on POST;
|
|
151
|
+
some do not, and the failure looks like a hang. Either document the trailing
|
|
152
|
+
slash or add an explicit no-slash route.
|
|
153
|
+
|
|
154
|
+
**401.** Check the Bearer prefix and the key, then check that the client is
|
|
155
|
+
actually sending headers — several clients drop custom headers on the SSE
|
|
156
|
+
fallback specifically, so the initial POST authenticates and the stream does not.
|
|
157
|
+
|
|
158
|
+
---
|
|
159
|
+
|
|
160
|
+
## Publishing to the MCP Registry
|
|
161
|
+
|
|
162
|
+
The registry stores **metadata pointing at your server**, not the server. You
|
|
163
|
+
publish a `server.json`.
|
|
164
|
+
|
|
165
|
+
Remote server:
|
|
166
|
+
|
|
167
|
+
```json
|
|
168
|
+
{
|
|
169
|
+
"$schema": "https://static.modelcontextprotocol.io/schemas/2025-12-11/server.schema.json",
|
|
170
|
+
"name": "com.example/my-mcp",
|
|
171
|
+
"title": "My MCP Server",
|
|
172
|
+
"description": "What it does (max 100 chars).",
|
|
173
|
+
"version": "1.0.0",
|
|
174
|
+
"remotes": [
|
|
175
|
+
{
|
|
176
|
+
"type": "streamable-http",
|
|
177
|
+
"url": "https://example.com/mcp",
|
|
178
|
+
"headers": [
|
|
179
|
+
{ "name": "Authorization", "description": "Bearer API key",
|
|
180
|
+
"isRequired": true, "isSecret": true }
|
|
181
|
+
]
|
|
182
|
+
}
|
|
183
|
+
]
|
|
184
|
+
}
|
|
185
|
+
```
|
|
186
|
+
|
|
187
|
+
Local package (npm shown; `pypi` takes the same shape with `registryType`
|
|
188
|
+
changed):
|
|
189
|
+
|
|
190
|
+
```json
|
|
191
|
+
{
|
|
192
|
+
"packages": [
|
|
193
|
+
{
|
|
194
|
+
"registryType": "npm",
|
|
195
|
+
"identifier": "@username/my-mcp-server",
|
|
196
|
+
"version": "1.0.0",
|
|
197
|
+
"transport": { "type": "stdio" }
|
|
198
|
+
}
|
|
199
|
+
]
|
|
200
|
+
}
|
|
201
|
+
```
|
|
202
|
+
|
|
203
|
+
PyPI additionally requires `<!-- mcp-name: your-server-name -->` in the package
|
|
204
|
+
README, as proof the package and the registry entry belong to the same author.
|
|
205
|
+
|
|
206
|
+
Validate before you publish — `description` is capped at **100 characters** and
|
|
207
|
+
a version may be published only once:
|
|
208
|
+
|
|
209
|
+
```bash
|
|
210
|
+
mcp-publisher validate
|
|
211
|
+
mcp-publisher publish
|
|
212
|
+
curl "https://registry.modelcontextprotocol.io/v0.1/servers?search=my-mcp"
|
|
213
|
+
```
|
|
214
|
+
|
|
215
|
+
---
|
|
216
|
+
|
|
217
|
+
## Registry name formats and who may claim them
|
|
218
|
+
|
|
219
|
+
The name is not free-form: it encodes how you proved ownership.
|
|
220
|
+
|
|
221
|
+
| Auth method | Name format | Example |
|
|
222
|
+
|---|---|---|
|
|
223
|
+
| GitHub | `io.github.<user>/*` | `io.github.alice/weather` |
|
|
224
|
+
| DNS | reverse-DNS of the domain | `com.example/my-server` |
|
|
225
|
+
| HTTP | reverse-DNS of the domain | `com.example/my-server` |
|
|
226
|
+
|
|
227
|
+
**GitHub** is the shortest path — `mcp-publisher login github`, OAuth device
|
|
228
|
+
flow, done. The name must start with your username.
|
|
229
|
+
|
|
230
|
+
**DNS** and **HTTP** claim a domain with an Ed25519 key. DNS puts the public key
|
|
231
|
+
in a TXT record; HTTP serves it at
|
|
232
|
+
`https://<domain>/.well-known/mcp-registry-auth`. Use HTTP when you control the
|
|
233
|
+
web root but not the zone.
|
|
234
|
+
|
|
235
|
+
```bash
|
|
236
|
+
openssl genpkey -algorithm Ed25519 -out mcp-registry-key.pem
|
|
237
|
+
PUBLIC_KEY="$(openssl pkey -in mcp-registry-key.pem -pubout -outform DER | tail -c 32 | base64)"
|
|
238
|
+
# DNS: <domain>. IN TXT "v=MCPv1; k=ed25519; p=${PUBLIC_KEY}"
|
|
239
|
+
# HTTP: echo "v=MCPv1; k=ed25519; p=${PUBLIC_KEY}" > mcp-registry-auth
|
|
240
|
+
```
|
|
241
|
+
|
|
242
|
+
Treat `mcp-registry-key.pem` as a publishing credential: it is the thing that
|
|
243
|
+
lets someone replace your server entry. It belongs in a secret store, not the
|
|
244
|
+
repository, and the CI job that uses it should be the only consumer.
|
|
245
|
+
|
|
246
|
+
---
|
|
247
|
+
|
|
248
|
+
## Automating publication
|
|
249
|
+
|
|
250
|
+
Same shape as the skill releases in `references/distribution.md`: a `v*` tag
|
|
251
|
+
triggers validate → publish. Two rules carry over unchanged.
|
|
252
|
+
|
|
253
|
+
- **Gate the publish on the validator**, in the same job. A registry entry
|
|
254
|
+
pointing at a broken build is worse than no entry, because clients cache it.
|
|
255
|
+
- **Fail on a version already published** rather than skipping quietly. A silent
|
|
256
|
+
skip is how a tag ends up green while the registry still serves the previous
|
|
257
|
+
version.
|
|
258
|
+
|
|
259
|
+
Install the CLI in CI from the released binary:
|
|
260
|
+
|
|
261
|
+
```bash
|
|
262
|
+
curl -L "https://github.com/modelcontextprotocol/registry/releases/latest/download/mcp-publisher_$(uname -s | tr '[:upper:]' '[:lower:]')_$(uname -m | sed 's/x86_64/amd64/;s/aarch64/arm64/').tar.gz" | tar xz mcp-publisher
|
|
263
|
+
```
|
|
264
|
+
|
|
265
|
+
---
|
|
266
|
+
|
|
267
|
+
## Versioning
|
|
268
|
+
|
|
269
|
+
The registry version is the **server's** version, and it is what clients compare
|
|
270
|
+
against. Keep it equal to the package version — an npm package at 1.2.0 whose
|
|
271
|
+
registry entry says 1.0.0 makes every bug report unactionable.
|
|
272
|
+
|
|
273
|
+
The `$schema` date and your version are unrelated: the schema is the registry's
|
|
274
|
+
contract and moves on the registry's schedule, so re-read it before each publish
|
|
275
|
+
rather than carrying the pinned URL forward on faith.
|
|
@@ -14,18 +14,33 @@ standard — including the no-argument `/make-skill` path, where a `SKILL.md`,
|
|
|
14
14
|
|
|
15
15
|
## Run the script first
|
|
16
16
|
|
|
17
|
-
The mechanical half is deterministic — do not reason your way through it
|
|
17
|
+
The mechanical half is deterministic — do not reason your way through it. In
|
|
18
|
+
Claude Code the plugin's `bin/` is on the Bash tool's PATH, so the auditor is
|
|
19
|
+
reachable by name:
|
|
18
20
|
|
|
19
21
|
```bash
|
|
20
|
-
|
|
22
|
+
make-skill-audit <skill-dir> --house
|
|
21
23
|
```
|
|
22
24
|
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
25
|
+
On any other host, run the script it wraps — `scripts/audit_skill.py` inside the
|
|
26
|
+
make-skill directory you are reading this from (`~/.agents/skills/make-skill/`,
|
|
27
|
+
`~/.claude/skills/make-skill/`):
|
|
28
|
+
|
|
29
|
+
```bash
|
|
30
|
+
python3 <make-skill dir>/scripts/audit_skill.py <skill-dir> --house
|
|
31
|
+
```
|
|
32
|
+
|
|
33
|
+
**Do not build the command out of `${CLAUDE_PLUGIN_ROOT}`.** That variable is
|
|
34
|
+
substituted into skill, command and agent *text*, but it is **not exported to
|
|
35
|
+
the Bash tool's environment** — a command written around it expands to
|
|
36
|
+
`/skills/...` and dies with "No such file or directory" (measured, Claude Code
|
|
37
|
+
2.1.220). That failure is how a mechanical check turns into a reasoned-about
|
|
38
|
+
PASS, which is the one outcome this file exists to prevent.
|
|
39
|
+
|
|
40
|
+
No `python3` at all? Record items 1 and 9 as **NOT-RUN** with that reason and
|
|
41
|
+
check them by hand against `references/agent-skills-spec.md` — the script
|
|
42
|
+
implements that checklist and nothing more. Either way, the judgement items are
|
|
43
|
+
yours.
|
|
29
44
|
|
|
30
45
|
## How to report
|
|
31
46
|
|
|
@@ -35,11 +50,21 @@ Audit first, fix second, in the same session. One verdict per item:
|
|
|
35
50
|
|---|---|
|
|
36
51
|
| **PASS** | the check was actually run — a command's output or a `file:line` |
|
|
37
52
|
| **GAP** | what is wrong, the evidence, and the fix that closes it |
|
|
53
|
+
| **NOT-RUN** | the tool the check needs is absent — name the tool and the reason |
|
|
38
54
|
|
|
39
55
|
"Looks fine" is not a verdict. Neither is a PASS on a check that was reasoned
|
|
40
56
|
about rather than executed: `python3 test/validate.py`, `claude plugin validate
|
|
41
57
|
… --strict`, `npx skills add <repo> --list` all produce output, and the output is
|
|
42
|
-
the evidence.
|
|
58
|
+
the evidence.
|
|
59
|
+
|
|
60
|
+
**NOT-RUN exists so that "I could not run this" has somewhere to go.** A
|
|
61
|
+
checklist with only PASS and GAP forces an unrunnable check into one of them,
|
|
62
|
+
and the cheaper lie is PASS. Item 1's `skills-ref validate` is the standing
|
|
63
|
+
example: it installs from source only, so on most machines it is NOT-RUN, and
|
|
64
|
+
saying so is the honest result. A NOT-RUN you did not try to resolve is still a
|
|
65
|
+
gap in the audit — say what would make it runnable.
|
|
66
|
+
|
|
67
|
+
Report the table before changing anything, then fix.
|
|
43
68
|
|
|
44
69
|
## The 14-item audit checklist
|
|
45
70
|
|
|
@@ -84,15 +109,17 @@ the evidence. Report the gap table before changing anything, then fix.
|
|
|
84
109
|
skills add <repo> --list` lists ONLY real skills; `npx <name>` works from a
|
|
85
110
|
non-repo cwd if it is published; `.mdc` rules valid and free of relative
|
|
86
111
|
links.
|
|
87
|
-
11. **Repo meta**: homepage, description and topics set on the forge
|
|
112
|
+
11. **Repo meta**: homepage, description and topics set on the forge (any
|
|
113
|
+
consistent homepage convention — do not "fix" a deliberate one); LICENSE
|
|
88
114
|
present and declared in the front matter and the marketplace entry;
|
|
89
115
|
CHANGELOG current; public repos also carry CONTRIBUTING.md and SECURITY.md.
|
|
90
116
|
12. **Gotcha compliance**: the list in `SKILL.md`, plus the installer traps in
|
|
91
117
|
`references/distribution.md` when the repo ships a CLI or a validator.
|
|
92
118
|
13. **Protocol dependencies** if it touches MCP or A2A (`references/mcp.md`,
|
|
93
|
-
`references/
|
|
94
|
-
|
|
95
|
-
|
|
119
|
+
`references/mcp-ship.md` once a server is being shipped, `references/a2a.md`):
|
|
120
|
+
dependency and protocol version in `compatibility`, discovery instead of
|
|
121
|
+
hardcoded tool names, the untrusted-output rule stated, interactive auth
|
|
122
|
+
handled as a human step rather than a retry loop.
|
|
96
123
|
14. **Host capabilities and their fallbacks**
|
|
97
124
|
(`references/host-capabilities.md`) if it ships a hook, subagent, command or
|
|
98
125
|
MCP server: the degradation contract written in the body for all three axes
|
|
@@ -171,7 +171,10 @@ def _check_name(a, fm, lines, dir_name, rel):
|
|
|
171
171
|
a.gap("NAME_CHARSET", "name %r must be lowercase a-z0-9 with single internal "
|
|
172
172
|
"hyphens — no uppercase, no leading/trailing or doubled hyphen" % name, rel, ln)
|
|
173
173
|
else:
|
|
174
|
-
|
|
174
|
+
# Charset only. Saying "spec-legal" here would contradict NAME_RESERVED
|
|
175
|
+
# and NAME_XML below, and a PASS line quoted out of context is exactly
|
|
176
|
+
# how a wrong verdict acquires real command output as its evidence.
|
|
177
|
+
a.ok("NAME_CHARSET", "name %r uses a legal charset" % name, rel, ln)
|
|
175
178
|
if name != dir_name:
|
|
176
179
|
a.gap("NAME_DIR", "name %r != directory %r — the spec requires them to match, "
|
|
177
180
|
"and the Skills API matches the uploaded top-level directory against it"
|
|
@@ -211,10 +214,16 @@ def _check_description(a, fm, lines, rel, house):
|
|
|
211
214
|
else:
|
|
212
215
|
a.ok("DESC_PERSON", "description is third person", rel, ln)
|
|
213
216
|
if house:
|
|
217
|
+
# Both report either way: "the house rules were checked" has to be
|
|
218
|
+
# provable from the output the canon tells the agent to cite.
|
|
214
219
|
if not desc.lower().startswith("use when"):
|
|
215
220
|
a.gap("DESC_USEWHEN", "description must start with 'Use when …' (house rule)", rel, ln)
|
|
221
|
+
else:
|
|
222
|
+
a.ok("DESC_USEWHEN", "description opens with 'Use when …' (house rule)", rel, ln)
|
|
216
223
|
if not re.search(r"[а-яё]", desc, re.I):
|
|
217
224
|
a.gap("DESC_RU", "description carries no Russian trigger phrases (house rule)", rel, ln)
|
|
225
|
+
else:
|
|
226
|
+
a.ok("DESC_RU", "description carries Russian trigger phrases (house rule)", rel, ln)
|
|
218
227
|
|
|
219
228
|
|
|
220
229
|
def _check_optional_fields(a, fm, lines, rel):
|
|
@@ -253,13 +262,18 @@ def _check_keys(a, fm, lines, rel):
|
|
|
253
262
|
def _check_body_budget(a, body, rel):
|
|
254
263
|
n_lines = body.count("\n") + 1
|
|
255
264
|
est = int(len(body) / CHARS_PER_TOKEN)
|
|
265
|
+
# Both, not either: a body over the line budget still has to report its
|
|
266
|
+
# token count, or the second fix arrives only after the first one ships.
|
|
267
|
+
over = False
|
|
256
268
|
if n_lines >= BODY_MAX_LINES:
|
|
257
269
|
a.gap("BODY_LINES", "body is %d lines, the budget is < %d — move detail into "
|
|
258
270
|
"references/" % (n_lines, BODY_MAX_LINES), rel)
|
|
259
|
-
|
|
271
|
+
over = True
|
|
272
|
+
if est >= BODY_MAX_TOKENS:
|
|
260
273
|
a.gap("BODY_TOKENS", "body is ~%d tokens (%d chars / %s), the budget is < %d"
|
|
261
274
|
% (est, len(body), CHARS_PER_TOKEN, BODY_MAX_TOKENS), rel)
|
|
262
|
-
|
|
275
|
+
over = True
|
|
276
|
+
if not over:
|
|
263
277
|
a.ok("BODY_BUDGET", "body is %d lines / ~%d tokens (budget %d / %d)"
|
|
264
278
|
% (n_lines, est, BODY_MAX_LINES, BODY_MAX_TOKENS), rel)
|
|
265
279
|
|
|
@@ -295,21 +309,27 @@ def _check_bundle(a, skill_dir, skill_text, dir_name):
|
|
|
295
309
|
|
|
296
310
|
|
|
297
311
|
def _check_links(a, skill_dir, skill_text, rel):
|
|
298
|
-
"""A relative link that escapes the skill directory arrives broken everywhere.
|
|
299
|
-
|
|
300
|
-
|
|
301
|
-
|
|
302
|
-
|
|
303
|
-
|
|
304
|
-
|
|
305
|
-
|
|
306
|
-
|
|
307
|
-
|
|
308
|
-
|
|
309
|
-
|
|
310
|
-
|
|
311
|
-
|
|
312
|
-
|
|
312
|
+
"""A relative link that escapes the skill directory arrives broken everywhere.
|
|
313
|
+
|
|
314
|
+
Reported per line: the canon defines evidence as a `file:line`, so a finding
|
|
315
|
+
without one forces the agent to either drop the evidence or invent it.
|
|
316
|
+
"""
|
|
317
|
+
bad = False
|
|
318
|
+
for i, line in enumerate(skill_text.split("\n"), start=1):
|
|
319
|
+
for target in LINK_RE.findall(line):
|
|
320
|
+
if target.startswith(("http://", "https://", "mailto:", "#")):
|
|
321
|
+
continue
|
|
322
|
+
if target.startswith("../") or "/../" in target:
|
|
323
|
+
a.gap("LINK_ESCAPE", "link %r escapes the skill directory — packagers "
|
|
324
|
+
"ship that directory alone, so it arrives broken on every agent"
|
|
325
|
+
% target, rel, i)
|
|
326
|
+
bad = True
|
|
327
|
+
continue
|
|
328
|
+
path = os.path.normpath(os.path.join(skill_dir, target.split("#")[0]))
|
|
329
|
+
if not os.path.exists(path):
|
|
330
|
+
a.gap("LINK_BROKEN", "link %r does not resolve" % target, rel, i)
|
|
331
|
+
bad = True
|
|
332
|
+
if not bad:
|
|
313
333
|
a.ok("LINK_INTEGRITY", "every relative link resolves and stays inside the skill", rel)
|
|
314
334
|
|
|
315
335
|
|
|
@@ -335,8 +355,11 @@ def _check_prose(a, body, rel):
|
|
|
335
355
|
a.gap("TIME_BRANCH", "time-branching instruction (%r) — it is wrong the day "
|
|
336
356
|
"it ships; put superseded material under '## Old patterns'"
|
|
337
357
|
% raw.strip()[:60], rel, i)
|
|
338
|
-
#
|
|
339
|
-
|
|
358
|
+
# A pointer at a directory rather than a file with a load condition. Quoted
|
|
359
|
+
# spans are blanked here too: a canon that forbids "see references/" has to
|
|
360
|
+
# be able to quote the phrase it forbids.
|
|
361
|
+
for i, raw in enumerate(body.split("\n"), start=1):
|
|
362
|
+
line = _strip_quoted(raw)
|
|
340
363
|
if re.search(r"\b(?:see|read)\s+`?(?:references|scripts|assets)/`?(?![\w.-])", line, re.I):
|
|
341
364
|
a.gap("REF_NO_TRIGGER", "points at a directory instead of a file with a "
|
|
342
365
|
"stated load condition — 'read X when Y' beats 'see references/'", rel, i)
|