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