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.
Files changed (115) hide show
  1. package/CHANGELOG.md +21 -0
  2. package/README.md +42 -20
  3. package/agents/ll-executor.md +1 -0
  4. package/assets/preamble.md +29 -35
  5. package/bin/install.js +4 -1
  6. package/hooks/ll-precompact.js +29 -1
  7. package/hooks/ll-skills-check-update.js +6 -6
  8. package/hooks/ll-state.js +30 -2
  9. package/package.json +3 -2
  10. package/scripts/evals/README.md +57 -0
  11. package/scripts/evals/cases/auto-dry-run/assert.sh +35 -0
  12. package/scripts/evals/cases/auto-dry-run/case.json +8 -0
  13. package/scripts/evals/cases/auto-dry-run/prompt.txt +1 -0
  14. package/scripts/evals/cases/auto-empty-repo/assert.sh +25 -0
  15. package/scripts/evals/cases/auto-empty-repo/case.json +8 -0
  16. package/scripts/evals/cases/auto-empty-repo/fixture/.gitkeep +0 -0
  17. package/scripts/evals/cases/auto-empty-repo/prompt.txt +1 -0
  18. package/scripts/evals/cases/decide-final-round/assert.sh +32 -0
  19. package/scripts/evals/cases/decide-final-round/case.json +8 -0
  20. package/scripts/evals/cases/decide-final-round/fixture/README.md +3 -0
  21. package/scripts/evals/cases/decide-final-round/prompt.txt +1 -0
  22. package/scripts/evals/cases/executor-block/assert.sh +33 -0
  23. package/scripts/evals/cases/executor-block/case.json +8 -0
  24. package/scripts/evals/cases/executor-block/prompt.txt +14 -0
  25. package/scripts/evals/cases/goal-autonomous/assert.sh +35 -0
  26. package/scripts/evals/cases/goal-autonomous/case.json +8 -0
  27. package/scripts/evals/cases/goal-autonomous/fixture/PLAN.md +42 -0
  28. package/scripts/evals/cases/goal-autonomous/fixture/PROGRESS.md +20 -0
  29. package/scripts/evals/cases/goal-autonomous/fixture/ROADMAP.md +29 -0
  30. package/scripts/evals/cases/goal-autonomous/fixture/package.json +8 -0
  31. package/scripts/evals/cases/goal-autonomous/fixture/src/money.js +6 -0
  32. package/scripts/evals/cases/goal-autonomous/fixture/test/reconcile.test.js +8 -0
  33. package/scripts/evals/cases/goal-autonomous/prompt.txt +1 -0
  34. package/scripts/evals/cases/implement-review-gate/assert.sh +35 -0
  35. package/scripts/evals/cases/implement-review-gate/case.json +8 -0
  36. package/scripts/evals/cases/implement-review-gate/prompt.txt +1 -0
  37. package/scripts/evals/cases/implement-stops-at-next/assert.sh +39 -0
  38. package/scripts/evals/cases/implement-stops-at-next/case.json +9 -0
  39. package/scripts/evals/cases/implement-stops-at-next/prompt.txt +1 -0
  40. package/scripts/evals/cases/preamble-no-ritual/assert.sh +17 -0
  41. package/scripts/evals/cases/preamble-no-ritual/case.json +8 -0
  42. package/scripts/evals/cases/preamble-no-ritual/fixture/README.md +3 -0
  43. package/scripts/evals/cases/preamble-no-ritual/fixture/src/a.ts +3 -0
  44. package/scripts/evals/cases/preamble-no-ritual/prompt.txt +1 -0
  45. package/scripts/evals/cases/router-execute/assert.sh +12 -0
  46. package/scripts/evals/cases/router-execute/case.json +8 -0
  47. package/scripts/evals/cases/router-execute/prompt.txt +1 -0
  48. package/scripts/evals/cases/router-research/assert.sh +11 -0
  49. package/scripts/evals/cases/router-research/case.json +8 -0
  50. package/scripts/evals/cases/router-research/fixture/README.md +3 -0
  51. package/scripts/evals/cases/router-research/prompt.txt +1 -0
  52. package/scripts/evals/cases/router-small/assert.sh +21 -0
  53. package/scripts/evals/cases/router-small/case.json +8 -0
  54. package/scripts/evals/cases/router-small/fixture/README.md +17 -0
  55. package/scripts/evals/cases/router-small/prompt.txt +1 -0
  56. package/scripts/evals/cases/scout-no-plan/assert.sh +41 -0
  57. package/scripts/evals/cases/scout-no-plan/case.json +8 -0
  58. package/scripts/evals/cases/scout-no-plan/prompt.txt +8 -0
  59. package/scripts/evals/cases/verifier-weakened-test/assert.sh +19 -0
  60. package/scripts/evals/cases/verifier-weakened-test/case.json +8 -0
  61. package/scripts/evals/cases/verifier-weakened-test/prompt.txt +13 -0
  62. package/scripts/evals/cases/verifier-weakened-test/setup.sh +19 -0
  63. package/scripts/evals/fixtures/manual-contract/out.json +29 -0
  64. package/scripts/evals/fixtures/manual-contract/out.txt +5 -0
  65. package/scripts/evals/fixtures/manual-contract/with-skill.json +46 -0
  66. package/scripts/evals/lib/assert.sh +107 -0
  67. package/scripts/evals/lib/extract.js +73 -0
  68. package/scripts/evals/run.sh +369 -0
  69. package/scripts/fixtures/auto-closed/PLAN.md +5 -0
  70. package/scripts/fixtures/auto-closed/PROGRESS.md +20 -0
  71. package/scripts/fixtures/auto-closed/ROADMAP.md +6 -0
  72. package/scripts/fixtures/auto-closed/docs/DELIVERY.md +3 -0
  73. package/scripts/fixtures/auto-decisions/decisions/DEC-0001-taken-alone.md +13 -0
  74. package/scripts/fixtures/auto-decisions/decisions/DEC-0002-owner.md +13 -0
  75. package/scripts/fixtures/auto-noroadmap/PLAN.md +20 -0
  76. package/scripts/fixtures/auto-noroadmap/PROGRESS.md +11 -0
  77. package/scripts/fixtures/auto-verify-next/PLAN.md +5 -0
  78. package/scripts/fixtures/auto-verify-next/PROGRESS.md +18 -0
  79. package/scripts/fixtures/auto-verify-next/ROADMAP.md +5 -0
  80. package/scripts/fixtures/auto-verify-next/phases/01/PLAN.md +6 -0
  81. package/scripts/fixtures/evals-auto/auto-dry-run/pass.txt +18 -0
  82. package/scripts/fixtures/evals-auto/auto-empty-repo/pass.txt +2 -0
  83. package/scripts/fixtures/evals-auto/goal-autonomous/pass.txt +29 -0
  84. package/scripts/fixtures/lint-bad/folded-description/SKILL.md +13 -0
  85. package/scripts/fixtures/lint-bad/model-invocation-false/SKILL.md +10 -0
  86. package/scripts/fixtures/next-bad/skills/ll-bad/SKILL.md +30 -0
  87. package/scripts/fixtures/next-good/skills/ll-good/SKILL.md +26 -0
  88. package/scripts/fixtures/project/PROGRESS.md +4 -0
  89. package/scripts/lint-contract.cjs +495 -0
  90. package/scripts/lint-prompts.sh +396 -0
  91. package/scripts/ll-tools.js +465 -447
  92. package/scripts/smoke-test.sh +380 -1
  93. package/skills/ll-auto/SKILL.md +74 -0
  94. package/skills/ll-auto/references/run.md +75 -0
  95. package/skills/ll-auto/references/stages.md +66 -0
  96. package/skills/ll-auto/scripts/ll-auto.js +345 -0
  97. package/skills/ll-brainstorm/SKILL.md +5 -4
  98. package/skills/ll-brainstorm/references/decision-policy.md +3 -0
  99. package/skills/ll-close/SKILL.md +5 -5
  100. package/skills/ll-close/references/delivery.md +3 -1
  101. package/skills/ll-decide/SKILL.md +13 -11
  102. package/skills/ll-decide/references/decision-policy.md +3 -0
  103. package/skills/ll-decide/references/interview.md +10 -0
  104. package/skills/ll-decide/references/plan-skeleton.md +14 -14
  105. package/skills/ll-decide/references/premise-gate.md +7 -0
  106. package/skills/ll-goal/SKILL.md +22 -4
  107. package/skills/ll-goal/references/goal-template.md +57 -0
  108. package/skills/ll-implement/SKILL.md +4 -2
  109. package/skills/ll-implement/references/decision-policy.md +3 -0
  110. package/skills/ll-oncall/SKILL.md +3 -2
  111. package/skills/ll-refine/SKILL.md +3 -2
  112. package/skills/ll-research/SKILL.md +3 -2
  113. package/skills/ll-resume/SKILL.md +4 -3
  114. package/skills/ll-update/SKILL.md +6 -1
  115. 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