ll-skills 2.0.2 → 3.0.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 +21 -0
- package/README.md +42 -20
- package/agents/ll-executor.md +1 -0
- package/assets/preamble.md +29 -35
- package/bin/install.js +4 -1
- package/hooks/ll-precompact.js +29 -1
- package/hooks/ll-skills-check-update.js +6 -6
- package/hooks/ll-state.js +30 -2
- package/package.json +3 -2
- package/scripts/evals/README.md +57 -0
- package/scripts/evals/cases/auto-dry-run/assert.sh +35 -0
- package/scripts/evals/cases/auto-dry-run/case.json +8 -0
- package/scripts/evals/cases/auto-dry-run/prompt.txt +1 -0
- package/scripts/evals/cases/auto-empty-repo/assert.sh +25 -0
- package/scripts/evals/cases/auto-empty-repo/case.json +8 -0
- package/scripts/evals/cases/auto-empty-repo/fixture/.gitkeep +0 -0
- package/scripts/evals/cases/auto-empty-repo/prompt.txt +1 -0
- package/scripts/evals/cases/decide-final-round/assert.sh +32 -0
- package/scripts/evals/cases/decide-final-round/case.json +8 -0
- package/scripts/evals/cases/decide-final-round/fixture/README.md +3 -0
- package/scripts/evals/cases/decide-final-round/prompt.txt +1 -0
- package/scripts/evals/cases/executor-block/assert.sh +33 -0
- package/scripts/evals/cases/executor-block/case.json +8 -0
- package/scripts/evals/cases/executor-block/prompt.txt +14 -0
- package/scripts/evals/cases/goal-autonomous/assert.sh +35 -0
- package/scripts/evals/cases/goal-autonomous/case.json +8 -0
- package/scripts/evals/cases/goal-autonomous/fixture/PLAN.md +42 -0
- package/scripts/evals/cases/goal-autonomous/fixture/PROGRESS.md +20 -0
- package/scripts/evals/cases/goal-autonomous/fixture/ROADMAP.md +29 -0
- package/scripts/evals/cases/goal-autonomous/fixture/package.json +8 -0
- package/scripts/evals/cases/goal-autonomous/fixture/src/money.js +6 -0
- package/scripts/evals/cases/goal-autonomous/fixture/test/reconcile.test.js +8 -0
- package/scripts/evals/cases/goal-autonomous/prompt.txt +1 -0
- package/scripts/evals/cases/implement-review-gate/assert.sh +35 -0
- package/scripts/evals/cases/implement-review-gate/case.json +8 -0
- package/scripts/evals/cases/implement-review-gate/prompt.txt +1 -0
- package/scripts/evals/cases/implement-stops-at-next/assert.sh +39 -0
- package/scripts/evals/cases/implement-stops-at-next/case.json +9 -0
- package/scripts/evals/cases/implement-stops-at-next/prompt.txt +1 -0
- package/scripts/evals/cases/preamble-no-ritual/assert.sh +17 -0
- package/scripts/evals/cases/preamble-no-ritual/case.json +8 -0
- package/scripts/evals/cases/preamble-no-ritual/fixture/README.md +3 -0
- package/scripts/evals/cases/preamble-no-ritual/fixture/src/a.ts +3 -0
- package/scripts/evals/cases/preamble-no-ritual/prompt.txt +1 -0
- package/scripts/evals/cases/router-execute/assert.sh +12 -0
- package/scripts/evals/cases/router-execute/case.json +8 -0
- package/scripts/evals/cases/router-execute/prompt.txt +1 -0
- package/scripts/evals/cases/router-research/assert.sh +11 -0
- package/scripts/evals/cases/router-research/case.json +8 -0
- package/scripts/evals/cases/router-research/fixture/README.md +3 -0
- package/scripts/evals/cases/router-research/prompt.txt +1 -0
- package/scripts/evals/cases/router-small/assert.sh +21 -0
- package/scripts/evals/cases/router-small/case.json +8 -0
- package/scripts/evals/cases/router-small/fixture/README.md +17 -0
- package/scripts/evals/cases/router-small/prompt.txt +1 -0
- package/scripts/evals/cases/scout-no-plan/assert.sh +41 -0
- package/scripts/evals/cases/scout-no-plan/case.json +8 -0
- package/scripts/evals/cases/scout-no-plan/prompt.txt +8 -0
- package/scripts/evals/cases/verifier-weakened-test/assert.sh +19 -0
- package/scripts/evals/cases/verifier-weakened-test/case.json +8 -0
- package/scripts/evals/cases/verifier-weakened-test/prompt.txt +13 -0
- package/scripts/evals/cases/verifier-weakened-test/setup.sh +19 -0
- package/scripts/evals/fixtures/manual-contract/out.json +29 -0
- package/scripts/evals/fixtures/manual-contract/out.txt +5 -0
- package/scripts/evals/fixtures/manual-contract/with-skill.json +46 -0
- package/scripts/evals/lib/assert.sh +107 -0
- package/scripts/evals/lib/extract.js +73 -0
- package/scripts/evals/run.sh +369 -0
- package/scripts/fixtures/auto-closed/PLAN.md +5 -0
- package/scripts/fixtures/auto-closed/PROGRESS.md +20 -0
- package/scripts/fixtures/auto-closed/ROADMAP.md +6 -0
- package/scripts/fixtures/auto-closed/docs/DELIVERY.md +3 -0
- package/scripts/fixtures/auto-decisions/decisions/DEC-0001-taken-alone.md +13 -0
- package/scripts/fixtures/auto-decisions/decisions/DEC-0002-owner.md +13 -0
- package/scripts/fixtures/auto-noroadmap/PLAN.md +20 -0
- package/scripts/fixtures/auto-noroadmap/PROGRESS.md +11 -0
- package/scripts/fixtures/auto-verify-next/PLAN.md +5 -0
- package/scripts/fixtures/auto-verify-next/PROGRESS.md +18 -0
- package/scripts/fixtures/auto-verify-next/ROADMAP.md +5 -0
- package/scripts/fixtures/auto-verify-next/phases/01/PLAN.md +6 -0
- package/scripts/fixtures/evals-auto/auto-dry-run/pass.txt +18 -0
- package/scripts/fixtures/evals-auto/auto-empty-repo/pass.txt +2 -0
- package/scripts/fixtures/evals-auto/goal-autonomous/pass.txt +29 -0
- package/scripts/fixtures/lint-bad/folded-description/SKILL.md +13 -0
- package/scripts/fixtures/lint-bad/model-invocation-false/SKILL.md +10 -0
- package/scripts/fixtures/next-bad/skills/ll-bad/SKILL.md +30 -0
- package/scripts/fixtures/next-good/skills/ll-good/SKILL.md +26 -0
- package/scripts/fixtures/project/PROGRESS.md +4 -0
- package/scripts/lint-contract.cjs +495 -0
- package/scripts/lint-prompts.sh +396 -0
- package/scripts/ll-tools.js +465 -447
- package/scripts/smoke-test.sh +380 -1
- package/skills/ll-auto/SKILL.md +74 -0
- package/skills/ll-auto/references/run.md +75 -0
- package/skills/ll-auto/references/stages.md +66 -0
- package/skills/ll-auto/scripts/ll-auto.js +345 -0
- package/skills/ll-brainstorm/SKILL.md +5 -4
- package/skills/ll-brainstorm/references/decision-policy.md +3 -0
- package/skills/ll-close/SKILL.md +5 -5
- package/skills/ll-close/references/delivery.md +3 -1
- package/skills/ll-decide/SKILL.md +13 -11
- package/skills/ll-decide/references/decision-policy.md +3 -0
- package/skills/ll-decide/references/interview.md +10 -0
- package/skills/ll-decide/references/plan-skeleton.md +14 -14
- package/skills/ll-decide/references/premise-gate.md +7 -0
- package/skills/ll-goal/SKILL.md +22 -4
- package/skills/ll-goal/references/goal-template.md +57 -0
- package/skills/ll-implement/SKILL.md +4 -2
- package/skills/ll-implement/references/decision-policy.md +3 -0
- package/skills/ll-oncall/SKILL.md +3 -2
- package/skills/ll-refine/SKILL.md +3 -2
- package/skills/ll-research/SKILL.md +3 -2
- package/skills/ll-resume/SKILL.md +4 -3
- package/skills/ll-update/SKILL.md +6 -1
- package/skills/ll-verify/SKILL.md +2 -1
|
@@ -0,0 +1,396 @@
|
|
|
1
|
+
#!/usr/bin/env bash
|
|
2
|
+
# Deterministic lint over the prompt texts: skill frontmatter (every skill locked
|
|
3
|
+
# with disable-model-invocation: true and a plain one-line description, 60-300
|
|
4
|
+
# chars, no trigger phrase), agent frontmatter, line ceilings and the preamble
|
|
5
|
+
# without a router, section shape, forbidden strings, identical copies, language
|
|
6
|
+
# and an optional private word list. One line per rule; exit 1 on any FAIL.
|
|
7
|
+
# Usage: lint-prompts.sh [--rule N] (no flag runs every rule)
|
|
8
|
+
set -euo pipefail
|
|
9
|
+
ROOT="$(cd -- "$(dirname -- "${BASH_SOURCE[0]}")/.." && pwd)"
|
|
10
|
+
|
|
11
|
+
exec python3 - "$ROOT" "${@}" <<'PYTHON'
|
|
12
|
+
import fnmatch, hashlib, json, os, re, subprocess, sys
|
|
13
|
+
|
|
14
|
+
ROOT = sys.argv[1]
|
|
15
|
+
|
|
16
|
+
# Skills that write no repository file: no Deliverables table.
|
|
17
|
+
EXCEPT_DELIVERABLES = ["ll-resume", "ll-update"]
|
|
18
|
+
# Skills whose step-by-step section carries another heading.
|
|
19
|
+
EXCEPT_FLOW = ["ll-resume"]
|
|
20
|
+
# Skills with no closing section on disk today.
|
|
21
|
+
EXCEPT_COMPLETION = []
|
|
22
|
+
# Files written in Portuguese by design.
|
|
23
|
+
EXCEPT_LANGUAGE = ["scripts/smoke-test.sh"]
|
|
24
|
+
EXCEPT_LANGUAGE_GLOB = ["scripts/evals/cases/*/prompt.txt"] # owner-shaped inputs, Portuguese by design
|
|
25
|
+
|
|
26
|
+
FORBIDDEN = ["MUST", "CRITICAL", "verify carefully", "as discussed", "IMPORTANT:"]
|
|
27
|
+
ALLOWED_TOOLS = "Bash(${CLAUDE_SKILL_DIR}/scripts/ll-tools.js *)"
|
|
28
|
+
# A skill that ships its own helper declares it here; every other skill gets ALLOWED_TOOLS.
|
|
29
|
+
ALLOWED_TOOLS_BY_SKILL = {"ll-auto": "Bash(${CLAUDE_SKILL_DIR}/scripts/ll-auto.js *)"}
|
|
30
|
+
# The one skill that follows another skill's instructions, and only while the owner typed it.
|
|
31
|
+
ORCHESTRATOR = ["ll-auto"]
|
|
32
|
+
# Strings the global preamble must not carry: they route a request to a skill.
|
|
33
|
+
PREAMBLE_FORBIDDEN = ["Route every request", "One word from the owner"]
|
|
34
|
+
PT = re.compile(r"[ãõçáéíóúâêô"
|
|
35
|
+
r"ÃÕÇÁÉÍÓÚÂÊÔ]")
|
|
36
|
+
|
|
37
|
+
rule_arg = None
|
|
38
|
+
if "--rule" in sys.argv:
|
|
39
|
+
rule_arg = int(sys.argv[sys.argv.index("--rule") + 1])
|
|
40
|
+
|
|
41
|
+
def sh(args, cwd=ROOT):
|
|
42
|
+
return subprocess.run(args, cwd=cwd, capture_output=True, text=True)
|
|
43
|
+
|
|
44
|
+
TRACKED = sh(["git", "ls-files"]).stdout.split()
|
|
45
|
+
|
|
46
|
+
def path(rel):
|
|
47
|
+
return os.path.join(ROOT, rel)
|
|
48
|
+
|
|
49
|
+
def read(rel):
|
|
50
|
+
with open(path(rel), encoding="utf-8") as fh:
|
|
51
|
+
return fh.read()
|
|
52
|
+
|
|
53
|
+
def under(prefix):
|
|
54
|
+
return [f for f in TRACKED if f.startswith(prefix)]
|
|
55
|
+
|
|
56
|
+
SKILLS = sorted(f for f in TRACKED if re.fullmatch(r"skills/[^/]+/SKILL\.md", f))
|
|
57
|
+
AGENTS = sorted(f for f in TRACKED if re.fullmatch(r"agents/[^/]+\.md", f))
|
|
58
|
+
SKILL_NAMES = [f.split("/")[1] for f in SKILLS]
|
|
59
|
+
|
|
60
|
+
try:
|
|
61
|
+
import yaml
|
|
62
|
+
except ImportError:
|
|
63
|
+
yaml = None
|
|
64
|
+
|
|
65
|
+
def strip_comment(value):
|
|
66
|
+
quote = None
|
|
67
|
+
for i, ch in enumerate(value):
|
|
68
|
+
if quote:
|
|
69
|
+
if ch == quote and value[i - 1] != "\\":
|
|
70
|
+
quote = None
|
|
71
|
+
elif ch in "\"'":
|
|
72
|
+
quote = ch
|
|
73
|
+
elif ch == "#" and (i == 0 or value[i - 1] in " \t"):
|
|
74
|
+
return value[:i]
|
|
75
|
+
return value
|
|
76
|
+
|
|
77
|
+
def unquote(value):
|
|
78
|
+
value = value.strip()
|
|
79
|
+
if len(value) >= 2 and value[0] == value[-1] and value[0] in "\"'":
|
|
80
|
+
body = value[1:-1]
|
|
81
|
+
if value[0] == '"':
|
|
82
|
+
body = body.replace('\\"', '"').replace("\\\\", "\\")
|
|
83
|
+
return body
|
|
84
|
+
return value
|
|
85
|
+
|
|
86
|
+
def mini_yaml(block):
|
|
87
|
+
out = {}
|
|
88
|
+
for line in block.split("\n"):
|
|
89
|
+
if not line.strip() or line.lstrip().startswith("#") or line[0] in " \t":
|
|
90
|
+
continue
|
|
91
|
+
if ":" not in line:
|
|
92
|
+
continue
|
|
93
|
+
key, value = line.split(":", 1)
|
|
94
|
+
out[key.strip()] = unquote(strip_comment(value).strip())
|
|
95
|
+
return out
|
|
96
|
+
|
|
97
|
+
def frontmatter(text):
|
|
98
|
+
lines = text.split("\n")
|
|
99
|
+
if not lines or lines[0].strip() != "---":
|
|
100
|
+
return None, None
|
|
101
|
+
for i in range(1, len(lines)):
|
|
102
|
+
if lines[i].strip() == "---":
|
|
103
|
+
block = "\n".join(lines[1:i])
|
|
104
|
+
body = "\n".join(lines[i + 1:])
|
|
105
|
+
if yaml is not None:
|
|
106
|
+
try:
|
|
107
|
+
data = yaml.safe_load(block)
|
|
108
|
+
if isinstance(data, dict):
|
|
109
|
+
return {str(k): v for k, v in data.items()}, body
|
|
110
|
+
except Exception:
|
|
111
|
+
return None, body
|
|
112
|
+
return mini_yaml(block), body
|
|
113
|
+
return None, None
|
|
114
|
+
|
|
115
|
+
def body_start(text):
|
|
116
|
+
lines = text.split("\n")
|
|
117
|
+
if lines and lines[0].strip() == "---":
|
|
118
|
+
for i in range(1, len(lines)):
|
|
119
|
+
if lines[i].strip() == "---":
|
|
120
|
+
return i + 1
|
|
121
|
+
return 0
|
|
122
|
+
|
|
123
|
+
def truthy(value):
|
|
124
|
+
return str(value).strip().lower() in ("true", "yes")
|
|
125
|
+
|
|
126
|
+
def rule1():
|
|
127
|
+
bad = []
|
|
128
|
+
for f in SKILLS:
|
|
129
|
+
name = f.split("/")[1]
|
|
130
|
+
fm, _ = frontmatter(read(f))
|
|
131
|
+
if fm is None:
|
|
132
|
+
bad.append((f, "frontmatter does not parse"))
|
|
133
|
+
continue
|
|
134
|
+
if fm.get("name") != name:
|
|
135
|
+
bad.append((f, "name is %r, directory is %r" % (fm.get("name"), name)))
|
|
136
|
+
desc = str(fm.get("description", ""))
|
|
137
|
+
if not 60 <= len(desc) <= 300:
|
|
138
|
+
bad.append((f, "description is %d chars, expected 60-300" % len(desc)))
|
|
139
|
+
first = desc.split(" ")[0] if desc else ""
|
|
140
|
+
if not re.fullmatch(r"[A-Z][a-z]+s", first):
|
|
141
|
+
bad.append((f, "description starts with %r, expected a third-person verb" % first))
|
|
142
|
+
if "Use when" in desc:
|
|
143
|
+
bad.append((f, "description carries a trigger phrase (\"Use when\")"))
|
|
144
|
+
if "\n" in desc.strip():
|
|
145
|
+
bad.append((f, "description is not one line"))
|
|
146
|
+
if not str(fm.get("argument-hint", "")).strip():
|
|
147
|
+
bad.append((f, "argument-hint missing"))
|
|
148
|
+
expected = ALLOWED_TOOLS_BY_SKILL.get(name, ALLOWED_TOOLS)
|
|
149
|
+
if "allowed-tools" in fm and str(fm["allowed-tools"]) != expected:
|
|
150
|
+
bad.append((f, "allowed-tools is %r, expected %r" % (str(fm["allowed-tools"]), expected)))
|
|
151
|
+
declared = "disable-model-invocation" in fm and truthy(fm["disable-model-invocation"])
|
|
152
|
+
if not declared:
|
|
153
|
+
bad.append((f, "disable-model-invocation: true missing"))
|
|
154
|
+
for key in ("model", "effort", "context"):
|
|
155
|
+
if key in fm:
|
|
156
|
+
bad.append((f, "%s: is not a skill frontmatter key" % key))
|
|
157
|
+
return bad, len(SKILLS)
|
|
158
|
+
|
|
159
|
+
def rule2():
|
|
160
|
+
bad = []
|
|
161
|
+
for f in AGENTS:
|
|
162
|
+
name = os.path.basename(f)[:-3]
|
|
163
|
+
fm, _ = frontmatter(read(f))
|
|
164
|
+
if fm is None:
|
|
165
|
+
bad.append((f, "frontmatter does not parse"))
|
|
166
|
+
continue
|
|
167
|
+
if fm.get("name") != name:
|
|
168
|
+
bad.append((f, "name is %r, file is %r" % (fm.get("name"), name)))
|
|
169
|
+
model = str(fm.get("model", "")).strip()
|
|
170
|
+
if model not in ("sonnet", "opus"):
|
|
171
|
+
bad.append((f, "model is %r, expected sonnet or opus" % model))
|
|
172
|
+
if re.search(r"\bAgent\b", str(fm.get("tools", ""))):
|
|
173
|
+
bad.append((f, "tools grants Agent"))
|
|
174
|
+
turns = str(fm.get("maxTurns", "")).strip()
|
|
175
|
+
if not turns.isdigit():
|
|
176
|
+
bad.append((f, "maxTurns is %r, expected an integer" % turns))
|
|
177
|
+
return bad, len(AGENTS)
|
|
178
|
+
|
|
179
|
+
def rule3():
|
|
180
|
+
bad = []
|
|
181
|
+
checked = 0
|
|
182
|
+
ceilings = [(f, 200) for f in SKILLS]
|
|
183
|
+
ceilings += [(f, 160) for f in AGENTS]
|
|
184
|
+
ceilings += [(f, 150) for f in TRACKED
|
|
185
|
+
if re.fullmatch(r"skills/[^/]+/references/[^/]+\.md", f)]
|
|
186
|
+
for f, cap in ceilings:
|
|
187
|
+
checked += 1
|
|
188
|
+
n = len(read(f).split("\n")) - 1
|
|
189
|
+
if n > cap:
|
|
190
|
+
bad.append((f, "%d lines, ceiling %d" % (n, cap)))
|
|
191
|
+
checked += 1
|
|
192
|
+
preamble = read("assets/preamble.md")
|
|
193
|
+
n = len(preamble.split("\n")) - 1
|
|
194
|
+
if n > 70:
|
|
195
|
+
bad.append(("assets/preamble.md", "%d lines, ceiling 70" % n))
|
|
196
|
+
checked += 1
|
|
197
|
+
routing = [w for w in PREAMBLE_FORBIDDEN if w in preamble]
|
|
198
|
+
if routing:
|
|
199
|
+
bad.append(("assets/preamble.md",
|
|
200
|
+
"routes to a skill: contains %s" % ", ".join(repr(w) for w in routing)))
|
|
201
|
+
checked += 1
|
|
202
|
+
helper = read("scripts/ll-tools.js")
|
|
203
|
+
hn = len(helper.split("\n")) - 1
|
|
204
|
+
hb = os.path.getsize(path("scripts/ll-tools.js"))
|
|
205
|
+
if hn > 700:
|
|
206
|
+
bad.append(("scripts/ll-tools.js", "%d lines, ceiling 700" % hn))
|
|
207
|
+
if hb > 32768:
|
|
208
|
+
bad.append(("scripts/ll-tools.js", "%d bytes, ceiling 32768" % hb))
|
|
209
|
+
checked += 1
|
|
210
|
+
pack_json_override = os.environ.get("LL_PACK_JSON")
|
|
211
|
+
if pack_json_override:
|
|
212
|
+
with open(pack_json_override, encoding="utf-8") as fh:
|
|
213
|
+
out = fh.read()
|
|
214
|
+
else:
|
|
215
|
+
out = sh(["npm", "pack", "--dry-run", "--json"]).stdout
|
|
216
|
+
starts = [i for i in (out.find("["), out.find("{")) if i != -1]
|
|
217
|
+
start = min(starts) if starts else -1
|
|
218
|
+
try:
|
|
219
|
+
data = json.loads(out[start:])
|
|
220
|
+
# npm <= 11 prints a list; npm 12 prints an object keyed by package name.
|
|
221
|
+
if isinstance(data, list):
|
|
222
|
+
entry = data[0]
|
|
223
|
+
elif isinstance(data, dict):
|
|
224
|
+
pkg_name = json.loads(read("package.json"))["name"]
|
|
225
|
+
entry = data[pkg_name] if pkg_name in data else next(iter(data.values()))
|
|
226
|
+
else:
|
|
227
|
+
raise ValueError("unexpected npm pack --dry-run --json shape")
|
|
228
|
+
size = entry["unpackedSize"]
|
|
229
|
+
except Exception:
|
|
230
|
+
bad.append(("package.json", "npm pack --dry-run --json did not report unpackedSize"))
|
|
231
|
+
else:
|
|
232
|
+
if size > 921600:
|
|
233
|
+
bad.append(("package.json", "unpackedSize %d bytes, ceiling 921600" % size))
|
|
234
|
+
return bad, checked
|
|
235
|
+
|
|
236
|
+
def sections(body):
|
|
237
|
+
out = {}
|
|
238
|
+
current = None
|
|
239
|
+
for line in body.split("\n"):
|
|
240
|
+
if line.startswith("## "):
|
|
241
|
+
current = line[3:].strip()
|
|
242
|
+
out[current] = []
|
|
243
|
+
elif current is not None:
|
|
244
|
+
out[current].append(line)
|
|
245
|
+
return {k: "\n".join(v) for k, v in out.items()}
|
|
246
|
+
|
|
247
|
+
def sec_get(sec, *names):
|
|
248
|
+
# a heading may be qualified by a mode: "## Flow — project"
|
|
249
|
+
found = [body for heading, body in sec.items()
|
|
250
|
+
if any(heading == n or heading.startswith(n + " ") for n in names)]
|
|
251
|
+
return "\n".join(found) if found else None
|
|
252
|
+
|
|
253
|
+
def rule4():
|
|
254
|
+
bad = []
|
|
255
|
+
for f in SKILLS:
|
|
256
|
+
name = f.split("/")[1]
|
|
257
|
+
_, body = frontmatter(read(f))
|
|
258
|
+
body = body or ""
|
|
259
|
+
sec = sections(body)
|
|
260
|
+
if name not in EXCEPT_DELIVERABLES:
|
|
261
|
+
deliverables = sec_get(sec, "Deliverables")
|
|
262
|
+
if deliverables is None:
|
|
263
|
+
bad.append((f, "no ## Deliverables"))
|
|
264
|
+
elif "| File | Role | Mutability |" not in deliverables:
|
|
265
|
+
bad.append((f, "Deliverables has no | File | Role | Mutability | header"))
|
|
266
|
+
if name not in EXCEPT_FLOW and sec_get(sec, "Flow") is None:
|
|
267
|
+
bad.append((f, "no ## Flow"))
|
|
268
|
+
if name not in EXCEPT_COMPLETION:
|
|
269
|
+
# the closing section carries either heading
|
|
270
|
+
closing = sec_get(sec, "Completion criterion", "Closing criterion")
|
|
271
|
+
if closing is None:
|
|
272
|
+
bad.append((f, "no ## Completion criterion"))
|
|
273
|
+
elif "▶ Next —" not in closing:
|
|
274
|
+
bad.append((f, "Completion criterion has no ▶ Next —"))
|
|
275
|
+
if under("skills/%s/references/" % name) and sec_get(sec, "References") is None:
|
|
276
|
+
bad.append((f, "no ## References while references/ has files"))
|
|
277
|
+
return bad, len(SKILLS)
|
|
278
|
+
|
|
279
|
+
def rule5():
|
|
280
|
+
files = [f for f in TRACKED
|
|
281
|
+
if f.startswith("skills/") or f.startswith("agents/")] + ["assets/preamble.md"]
|
|
282
|
+
files = sorted(set(files))
|
|
283
|
+
bad = []
|
|
284
|
+
invoke = re.compile(r"run `(ll-[a-z-]+)`")
|
|
285
|
+
for f in files:
|
|
286
|
+
text = read(f)
|
|
287
|
+
for i, line in enumerate(text.split("\n"), 1):
|
|
288
|
+
for word in FORBIDDEN:
|
|
289
|
+
if word in line:
|
|
290
|
+
bad.append((f, "line %d contains %r" % (i, word)))
|
|
291
|
+
if not f.endswith("SKILL.md"):
|
|
292
|
+
continue
|
|
293
|
+
if "Skill(" in text:
|
|
294
|
+
bad.append((f, "contains Skill("))
|
|
295
|
+
# The orchestrator names the other skills' commands; nobody else may.
|
|
296
|
+
if f.split("/")[1] in ORCHESTRATOR:
|
|
297
|
+
continue
|
|
298
|
+
for i, line in enumerate(text.split("\n")[body_start(text):], body_start(text) + 1):
|
|
299
|
+
if "▶ Next" in line:
|
|
300
|
+
continue
|
|
301
|
+
if re.match(r"^\s*/ll-", line):
|
|
302
|
+
bad.append((f, "line %d invokes a skill as a command" % i))
|
|
303
|
+
for hit in invoke.findall(line):
|
|
304
|
+
if hit in SKILL_NAMES:
|
|
305
|
+
bad.append((f, "line %d tells the session to run `%s`" % (i, hit)))
|
|
306
|
+
return bad, len(files)
|
|
307
|
+
|
|
308
|
+
def region(rel):
|
|
309
|
+
keep, out = False, []
|
|
310
|
+
for line in read(rel).split("\n"):
|
|
311
|
+
if line.strip() == "// <ll-shared:state>":
|
|
312
|
+
keep = True
|
|
313
|
+
if keep:
|
|
314
|
+
out.append(line)
|
|
315
|
+
if line.strip() == "// </ll-shared:state>":
|
|
316
|
+
keep = False
|
|
317
|
+
return "\n".join(out)
|
|
318
|
+
|
|
319
|
+
def rule6():
|
|
320
|
+
bad = []
|
|
321
|
+
copies = sorted(f for f in TRACKED
|
|
322
|
+
if re.fullmatch(r"skills/[^/]+/references/decision-policy\.md", f))
|
|
323
|
+
if len(copies) != 3:
|
|
324
|
+
bad.append(("skills/*/references/decision-policy.md", "%d copies, expected 3" % len(copies)))
|
|
325
|
+
digests = {f: hashlib.md5(read(f).encode("utf-8")).hexdigest() for f in copies}
|
|
326
|
+
if len(set(digests.values())) > 1:
|
|
327
|
+
for f, d in sorted(digests.items()):
|
|
328
|
+
bad.append((f, "md5 %s differs from the other copies" % d))
|
|
329
|
+
a, b = region("scripts/ll-tools.js"), region("hooks/ll-state.js")
|
|
330
|
+
if not a or not b:
|
|
331
|
+
bad.append(("scripts/ll-tools.js", "ll-shared:state region is empty in one of the two files"))
|
|
332
|
+
elif a != b:
|
|
333
|
+
bad.append(("hooks/ll-state.js", "ll-shared:state region differs from scripts/ll-tools.js"))
|
|
334
|
+
return bad, len(copies) + 2
|
|
335
|
+
|
|
336
|
+
def rule7():
|
|
337
|
+
files = [f for f in TRACKED
|
|
338
|
+
if f.split("/")[0] in ("skills", "agents", "assets", "hooks", "scripts")
|
|
339
|
+
and not f.startswith("scripts/fixtures/")
|
|
340
|
+
and f not in EXCEPT_LANGUAGE
|
|
341
|
+
and not any(fnmatch.fnmatch(f, g) for g in EXCEPT_LANGUAGE_GLOB)]
|
|
342
|
+
bad = []
|
|
343
|
+
for f in sorted(files):
|
|
344
|
+
fenced = False
|
|
345
|
+
for i, line in enumerate(read(f).split("\n"), 1):
|
|
346
|
+
if f.endswith(".md") and line.strip().startswith("```"):
|
|
347
|
+
fenced = not fenced
|
|
348
|
+
continue
|
|
349
|
+
if fenced:
|
|
350
|
+
continue
|
|
351
|
+
if PT.search(line) and '"' not in line and "`" not in line:
|
|
352
|
+
bad.append((f, "line %d is unquoted Portuguese prose" % i))
|
|
353
|
+
return bad, len(files)
|
|
354
|
+
|
|
355
|
+
def rule8():
|
|
356
|
+
words = os.environ.get("LL_FORBIDDEN_FILE", "")
|
|
357
|
+
if not words or not os.access(words, os.R_OK):
|
|
358
|
+
print("skip private word list (LL_FORBIDDEN_FILE unset)")
|
|
359
|
+
return None, 0
|
|
360
|
+
files = [f for f in TRACKED if f != ".gitignore"]
|
|
361
|
+
res = sh(["grep", "-n", "-i", "-w", "-E", "-f", words] + files)
|
|
362
|
+
bad = []
|
|
363
|
+
for line in res.stdout.split("\n"):
|
|
364
|
+
if not line.strip():
|
|
365
|
+
continue
|
|
366
|
+
parts = line.split(":", 2)
|
|
367
|
+
bad.append((parts[0], "line %s matches the private word list" % (parts[1] if len(parts) > 1 else "?")))
|
|
368
|
+
return bad, len(files)
|
|
369
|
+
|
|
370
|
+
RULES = [
|
|
371
|
+
(1, "skill frontmatter", rule1),
|
|
372
|
+
(2, "agent frontmatter", rule2),
|
|
373
|
+
(3, "line ceilings", rule3),
|
|
374
|
+
(4, "skill shape", rule4),
|
|
375
|
+
(5, "forbidden strings", rule5),
|
|
376
|
+
(6, "identical copies", rule6),
|
|
377
|
+
(7, "language", rule7),
|
|
378
|
+
(8, "private word list", rule8),
|
|
379
|
+
]
|
|
380
|
+
|
|
381
|
+
failed = False
|
|
382
|
+
for number, label, fn in RULES:
|
|
383
|
+
if rule_arg is not None and rule_arg != number:
|
|
384
|
+
continue
|
|
385
|
+
bad, n = fn()
|
|
386
|
+
if bad is None:
|
|
387
|
+
continue
|
|
388
|
+
if bad:
|
|
389
|
+
failed = True
|
|
390
|
+
for f, detail in bad:
|
|
391
|
+
print("FAIL %d %s: %s: %s" % (number, label, f, detail))
|
|
392
|
+
else:
|
|
393
|
+
print("ok %d %s (%d files)" % (number, label, n))
|
|
394
|
+
|
|
395
|
+
sys.exit(1 if failed else 0)
|
|
396
|
+
PYTHON
|