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