chill-agent 1.4.0 → 1.6.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 (240) hide show
  1. package/.agents/rules/ponytail.md +30 -0
  2. package/.claude-plugin/contrast-mcp-launcher.mjs +31 -0
  3. package/.claude-plugin/marketplace.json +17 -0
  4. package/.claude-plugin/plugin.json +8 -3
  5. package/.cline-plugin/plugin.js +6 -0
  6. package/.clinerules/ponytail.md +30 -0
  7. package/.codex-plugin/plugin.json +4 -3
  8. package/.cursor/rules/ponytail.mdc +36 -0
  9. package/.cursor-plugin/marketplace.json +16 -0
  10. package/.cursor-plugin/plugin.json +2 -2
  11. package/.devin-plugin/plugin.json +2 -2
  12. package/.grok-plugin/marketplace.json +16 -0
  13. package/.kimi-plugin/plugin.json +11 -29
  14. package/.kiro/steering/ponytail.md +35 -0
  15. package/.omp-plugin/marketplace.json +15 -0
  16. package/.openclaw/skills/ponytail/SKILL.md +47 -0
  17. package/.openclaw/skills/ponytail-audit/SKILL.md +100 -0
  18. package/.openclaw/skills/ponytail-debt/SKILL.md +43 -0
  19. package/.openclaw/skills/ponytail-gain/SKILL.md +51 -0
  20. package/.openclaw/skills/ponytail-help/SKILL.md +76 -0
  21. package/.openclaw/skills/ponytail-review/SKILL.md +98 -0
  22. package/.opencode/command/ponytail-audit.md +5 -0
  23. package/.opencode/command/ponytail-debt.md +5 -0
  24. package/.opencode/command/ponytail-gain.md +5 -0
  25. package/.opencode/command/ponytail-help.md +5 -0
  26. package/.opencode/command/ponytail-review.md +5 -0
  27. package/.opencode/command/ponytail.md +5 -0
  28. package/.opencode/plugins/index.js +4 -0
  29. package/.opencode/plugins/ponytail-frontmatter.cjs +63 -0
  30. package/.opencode/plugins/ponytail.mjs +206 -0
  31. package/.qoder-plugin/plugin.json +12 -26
  32. package/.windsurf/rules/ponytail.md +30 -0
  33. package/AGENTS.md +32 -0
  34. package/__init__.py +228 -0
  35. package/assets/antislop-banner.png +0 -0
  36. package/assets/band/band.png +0 -0
  37. package/assets/band/board.png +0 -0
  38. package/assets/benchmark-3model.svg +21 -0
  39. package/assets/benchmark-agentic.svg +62 -0
  40. package/assets/codex-icon.png +0 -0
  41. package/assets/compare/code/after.webp +0 -0
  42. package/assets/compare/code/before.webp +0 -0
  43. package/assets/compare/text/after.webp +0 -0
  44. package/assets/compare/text/before.webp +0 -0
  45. package/assets/compare/ui/w-all.webp +0 -0
  46. package/assets/compare/ui/w-antislop.webp +0 -0
  47. package/assets/compare/ui/w-design.webp +0 -0
  48. package/assets/compare/ui/without.webp +0 -0
  49. package/assets/logo-dark.png +0 -0
  50. package/assets/logo-dark.svg +115 -0
  51. package/assets/logo-greenpt-dark.svg +27 -0
  52. package/assets/logo-greenpt.svg +27 -0
  53. package/assets/logo.png +0 -0
  54. package/assets/photoreunion-logo-dark.svg +4 -0
  55. package/assets/photoreunion-logo-light.svg +4 -0
  56. package/assets/profile.png +0 -0
  57. package/assets/retriever-logo-dark.svg +4 -0
  58. package/assets/retriever-logo-light.svg +4 -0
  59. package/assets/stories-banner.png +0 -0
  60. package/assets/v5/audit-es.png +0 -0
  61. package/assets/v5/audit-ja.png +0 -0
  62. package/assets/v5/audit-ko.png +0 -0
  63. package/assets/v5/audit-zh-CN.png +0 -0
  64. package/assets/v5/audit.png +0 -0
  65. package/assets/v5/beforeafter-es.png +0 -0
  66. package/assets/v5/beforeafter-ja.png +0 -0
  67. package/assets/v5/beforeafter-ko.png +0 -0
  68. package/assets/v5/beforeafter-zh-CN.png +0 -0
  69. package/assets/v5/beforeafter.png +0 -0
  70. package/assets/v5/chart-es.png +0 -0
  71. package/assets/v5/chart-ja.png +0 -0
  72. package/assets/v5/chart-ko.png +0 -0
  73. package/assets/v5/chart-zh-CN.png +0 -0
  74. package/assets/v5/chart.png +0 -0
  75. package/assets/v5/hero-es.jpg +0 -0
  76. package/assets/v5/hero-ja.jpg +0 -0
  77. package/assets/v5/hero-ko.jpg +0 -0
  78. package/assets/v5/hero-zh-CN.jpg +0 -0
  79. package/assets/v5/hero.jpg +0 -0
  80. package/assets/v5/ladder-es.png +0 -0
  81. package/assets/v5/ladder-ja.png +0 -0
  82. package/assets/v5/ladder-ko.png +0 -0
  83. package/assets/v5/ladder-zh-CN.png +0 -0
  84. package/assets/v5/ladder.png +0 -0
  85. package/assets/v5/review-es.png +0 -0
  86. package/assets/v5/review-ja.png +0 -0
  87. package/assets/v5/review-ko.png +0 -0
  88. package/assets/v5/review-zh-CN.png +0 -0
  89. package/assets/v5/review.png +0 -0
  90. package/assets/v5/tests-es.png +0 -0
  91. package/assets/v5/tests-ja.png +0 -0
  92. package/assets/v5/tests-ko.png +0 -0
  93. package/assets/v5/tests-zh-CN.png +0 -0
  94. package/assets/v5/tests.png +0 -0
  95. package/assets/waitlist-banner-es.png +0 -0
  96. package/assets/waitlist-banner-ja.png +0 -0
  97. package/assets/waitlist-banner-ko.png +0 -0
  98. package/assets/waitlist-banner-zh-CN.png +0 -0
  99. package/assets/waitlist-banner.png +0 -0
  100. package/benchmarks/.env.example +2 -0
  101. package/benchmarks/agentic/README.md +201 -0
  102. package/benchmarks/agentic/answer.py +288 -0
  103. package/benchmarks/agentic/compare.py +93 -0
  104. package/benchmarks/agentic/complete.py +178 -0
  105. package/benchmarks/agentic/judge.py +198 -0
  106. package/benchmarks/agentic/run.py +543 -0
  107. package/benchmarks/agentic/tasks.py +1544 -0
  108. package/benchmarks/arms/baseline.js +2 -0
  109. package/benchmarks/arms/caveman-SKILL.md +67 -0
  110. package/benchmarks/arms/caveman.js +8 -0
  111. package/benchmarks/arms/ponytail.js +8 -0
  112. package/benchmarks/behavior.js +54 -0
  113. package/benchmarks/behavior.yaml +40 -0
  114. package/benchmarks/correctness.js +291 -0
  115. package/benchmarks/loc.js +17 -19
  116. package/benchmarks/promptfooconfig.gemini.yaml +32 -0
  117. package/benchmarks/promptfooconfig.gpt-newest.yaml +33 -0
  118. package/benchmarks/promptfooconfig.yaml +41 -0
  119. package/benchmarks/results/2026-06-12-caveman-vs-ponytail.md +71 -0
  120. package/benchmarks/results/2026-06-12-v4-hardening-vs-caveman.md +125 -0
  121. package/benchmarks/results/2026-06-15-llama3.2-local.md +82 -0
  122. package/benchmarks/results/2026-06-16-correctness-gate-fix.md +113 -0
  123. package/benchmarks/results/2026-06-16-robustness-audit.md +135 -0
  124. package/benchmarks/results/2026-06-17-agentic-safety.md +164 -0
  125. package/benchmarks/results/2026-06-17-cost-verification.md +93 -0
  126. package/benchmarks/results/2026-06-18-agentic.md +219 -0
  127. package/benchmarks/results/2026-06-22-issue-245-217-comprehension.md +98 -0
  128. package/benchmarks/results/2026-10-07-agentic.md +186 -0
  129. package/cli/index.mjs +241 -0
  130. package/cli/lib/banner.mjs +21 -0
  131. package/cli/lib/install.mjs +498 -0
  132. package/cli/lib/settings.mjs +48 -0
  133. package/cli/package-lock.json +87 -0
  134. package/cli/package.json +42 -0
  135. package/cli/scripts/smoke-test.mjs +95 -0
  136. package/cli/scripts/smoke-worker.mjs +411 -0
  137. package/cli/scripts/sync-skills.mjs +81 -0
  138. package/commands/ponytail-audit.toml +2 -0
  139. package/commands/ponytail-debt.toml +2 -0
  140. package/commands/ponytail-gain.toml +2 -0
  141. package/commands/ponytail-help.toml +2 -0
  142. package/commands/ponytail-review.toml +2 -0
  143. package/commands/ponytail.toml +2 -0
  144. package/examples/email-validation.md +156 -0
  145. package/examples/group-by.md +35 -0
  146. package/examples/infinite-scroll.md +58 -0
  147. package/examples/number-formatting.md +37 -0
  148. package/examples/rate-limit.md +272 -0
  149. package/examples/react-countdown.md +390 -0
  150. package/examples/url-params.md +41 -0
  151. package/gemini-extension.json +2 -2
  152. package/hooks/band/board.ts +560 -0
  153. package/hooks/band/logo.ts +3 -0
  154. package/hooks/band/metrics.ts +311 -0
  155. package/hooks/band/register.tsx +495 -0
  156. package/hooks/band/types.d.ts +34 -0
  157. package/hooks/chill-statusline.ps1 +1 -1
  158. package/hooks/claude-band.json +6 -0
  159. package/hooks/claude-codex-hooks.json +34 -4
  160. package/hooks/copilot-hooks.json +12 -2
  161. package/hooks/cursor-hooks.json +2 -2
  162. package/hooks/ponytail-activate.js +201 -0
  163. package/hooks/ponytail-band-import.js +274 -0
  164. package/hooks/ponytail-config.js +175 -0
  165. package/hooks/ponytail-instructions.js +98 -0
  166. package/hooks/ponytail-map.js +111 -0
  167. package/hooks/ponytail-mode-tracker.js +216 -0
  168. package/hooks/ponytail-runtime.js +182 -0
  169. package/hooks/ponytail-statusline.ps1 +24 -0
  170. package/hooks/ponytail-statusline.sh +18 -0
  171. package/hooks/ponytail-subagent.js +94 -0
  172. package/hooks/qoder-hooks.json +19 -4
  173. package/i18n/README.ko.md +230 -0
  174. package/package.json +17 -6
  175. package/pi-extension/index.js +254 -0
  176. package/pi-extension/package.json +8 -0
  177. package/pi-extension/test/extension.test.js +367 -0
  178. package/pi-extension/test/helpers.test.js +179 -0
  179. package/plugin.yaml +21 -12
  180. package/rules/antislop.mdc +18 -0
  181. package/scripts/build-openclaw-skills.js +60 -0
  182. package/scripts/check-repo.mjs +389 -0
  183. package/scripts/check-rule-copies.js +74 -0
  184. package/scripts/check-versions.js +77 -0
  185. package/scripts/cursor-hooks.js +145 -0
  186. package/scripts/generate-chillslop.mjs +42 -0
  187. package/scripts/generate-examples.mjs +149 -0
  188. package/scripts/generate-plugin-doors.mjs +108 -0
  189. package/scripts/publish-openclaw-skills.js +75 -0
  190. package/scripts/skill-index.mjs +81 -0
  191. package/scripts/sync-ponytail.mjs +65 -0
  192. package/scripts/uninstall.js +92 -0
  193. package/skills/antislop/SKILL.md +700 -0
  194. package/skills/antislop/VERSION +1 -0
  195. package/skills/antislop-code/SKILL.md +128 -0
  196. package/skills/antislop-copywriting/SKILL.md +407 -0
  197. package/skills/antislop-human/LICENSE-web-quality-skills +21 -0
  198. package/skills/antislop-human/SKILL.md +204 -0
  199. package/skills/antislop-human/contrast-check.py +168 -0
  200. package/skills/antislop-human/contrast-mcp.py +121 -0
  201. package/skills/antislop-human/references/A11Y-PATTERNS.md +249 -0
  202. package/skills/antislop-human/references/WCAG.md +127 -0
  203. package/skills/antislop-layoutmobile/SKILL.md +170 -0
  204. package/skills/antislop-ui/LICENSE-web-quality-skills +21 -0
  205. package/skills/antislop-ui/SKILL.md +344 -0
  206. package/skills/antislop-ui/references/CORE-WEB-VITALS.md +181 -0
  207. package/skills/antislop-ui/references/WEB-BASELINE.md +28 -0
  208. package/skills/plugin.json +5 -0
  209. package/skills/ponytail/SKILL.md +52 -0
  210. package/skills/ponytail-audit/SKILL.md +103 -0
  211. package/skills/ponytail-debt/SKILL.md +44 -0
  212. package/skills/ponytail-gain/SKILL.md +52 -0
  213. package/skills/ponytail-help/SKILL.md +76 -0
  214. package/skills/ponytail-review/SKILL.md +101 -0
  215. package/src/package.json +3 -0
  216. package/tests/activation-map.test.js +63 -0
  217. package/tests/band-import.test.js +111 -0
  218. package/tests/band.test.tsx +520 -0
  219. package/tests/behavior.test.js +80 -0
  220. package/tests/commands.test.js +94 -0
  221. package/tests/copilot-plugin.test.js +35 -0
  222. package/tests/correctness.test.js +245 -0
  223. package/tests/cursor-hooks.test.js +343 -0
  224. package/tests/gemini-extension.test.js +91 -0
  225. package/tests/grok-plugin.test.js +39 -0
  226. package/tests/hermes-plugin.test.js +403 -0
  227. package/tests/hooks-windows.test.js +205 -0
  228. package/tests/hooks.test.js +1005 -0
  229. package/tests/kimi-plugin.test.js +25 -0
  230. package/tests/loc.test.js +18 -0
  231. package/tests/map.test.js +30 -0
  232. package/tests/openclaw-skills.test.js +26 -0
  233. package/tests/opencode-plugin.test.js +340 -0
  234. package/tests/package-scripts.test.js +35 -0
  235. package/tests/package.test.js +23 -0
  236. package/tests/qoder-plugin.test.js +84 -0
  237. package/tests/uninstall.test.js +202 -0
  238. /package/bin/{chill-agent.js → chill-agent.mjs} +0 -0
  239. /package/tests/{manifests.test.js → manifests.test.mjs} +0 -0
  240. /package/tests/{rules.test.js → rules.test.mjs} +0 -0
@@ -0,0 +1,179 @@
1
+ import assert from "node:assert/strict";
2
+ import { existsSync, mkdirSync, mkdtempSync, readFileSync, rmSync, writeFileSync } from "node:fs";
3
+ import { tmpdir } from "node:os";
4
+ import { join } from "node:path";
5
+ import test from "node:test";
6
+
7
+ import {
8
+ filterSkillBodyForMode,
9
+ parsePonytailCommand,
10
+ readDefaultMode,
11
+ readQuietStartup,
12
+ resolveSessionMode,
13
+ writeDefaultMode,
14
+ } from "../index.js";
15
+
16
+ test("parsePonytailCommand falls back to full when invoked bare and default is off", () => {
17
+ assert.deepEqual(parsePonytailCommand("", "off"), { type: "set-mode", mode: "full" });
18
+ // #639: when ponytail is already on, a bare command reports instead of resetting the level.
19
+ assert.deepEqual(parsePonytailCommand("", "full", "ultra"), { type: "status" });
20
+ assert.deepEqual(parsePonytailCommand("", "full", "off"), { type: "set-mode", mode: "full" });
21
+ });
22
+
23
+ test("parsePonytailCommand parses modes, status, and default subcommand", () => {
24
+ assert.deepEqual(parsePonytailCommand("ultra", "full"), { type: "set-mode", mode: "ultra" });
25
+ assert.deepEqual(parsePonytailCommand("status", "full"), { type: "status" });
26
+ assert.deepEqual(parsePonytailCommand("default lite", "full"), { type: "set-default", mode: "lite" });
27
+ });
28
+
29
+ test("parsePonytailCommand rejects review as a default (session-only mode, #377)", () => {
30
+ assert.deepEqual(parsePonytailCommand("default review", "full"), { type: "invalid", reason: "invalid-default-mode" });
31
+ });
32
+
33
+ test("resolveSessionMode still honors review as a session mode (not a default)", () => {
34
+ const entries = [{ type: "custom", customType: "ponytail-mode", data: { mode: "review" } }];
35
+ assert.equal(resolveSessionMode(entries, "full"), "review");
36
+ });
37
+
38
+ test("resolveSessionMode prefers latest persisted session mode", () => {
39
+ const entries = [
40
+ { type: "custom", customType: "ponytail-mode", data: { mode: "lite" } },
41
+ { type: "custom", customType: "ponytail-mode", data: { mode: "ultra" } },
42
+ ];
43
+
44
+ assert.equal(resolveSessionMode(entries, "full"), "ultra");
45
+ });
46
+
47
+ test("resolveSessionMode returns fallback when entries is not an array", () => {
48
+ assert.equal(resolveSessionMode(null, "ultra"), "ultra");
49
+ assert.equal(resolveSessionMode(undefined, "lite"), "lite");
50
+ assert.equal(resolveSessionMode({}, "full"), "full");
51
+ assert.equal(resolveSessionMode("not an array"), "full"); // DEFAULT_MODE fallback
52
+ });
53
+
54
+ test("readDefaultMode and writeDefaultMode use XDG config path", () => {
55
+ const tempDir = mkdtempSync(join(tmpdir(), "ponytail-config-"));
56
+ const previousXdg = process.env.XDG_CONFIG_HOME;
57
+ const previousDefault = process.env.PONYTAIL_DEFAULT_MODE;
58
+ const configPath = join(tempDir, "ponytail", "config.json");
59
+ process.env.XDG_CONFIG_HOME = tempDir;
60
+ delete process.env.PONYTAIL_DEFAULT_MODE;
61
+
62
+ try {
63
+ assert.equal(readDefaultMode(), "full");
64
+ assert.equal(writeDefaultMode("ultra"), "ultra");
65
+ assert.equal(readDefaultMode(), "ultra");
66
+ assert.ok(existsSync(configPath));
67
+ assert.deepEqual(JSON.parse(readFileSync(configPath, "utf8")), { defaultMode: "ultra" });
68
+ } finally {
69
+ if (previousXdg === undefined) delete process.env.XDG_CONFIG_HOME;
70
+ else process.env.XDG_CONFIG_HOME = previousXdg;
71
+ if (previousDefault === undefined) delete process.env.PONYTAIL_DEFAULT_MODE;
72
+ else process.env.PONYTAIL_DEFAULT_MODE = previousDefault;
73
+ rmSync(tempDir, { recursive: true, force: true });
74
+ }
75
+ });
76
+
77
+ test("readDefaultMode trims and normalizes whitespace in env and config values", () => {
78
+ const tempDir = mkdtempSync(join(tmpdir(), "ponytail-default-normalize-"));
79
+ const previousXdg = process.env.XDG_CONFIG_HOME;
80
+ const previousDefault = process.env.PONYTAIL_DEFAULT_MODE;
81
+ const configDir = join(tempDir, "ponytail");
82
+ const configPath = join(configDir, "config.json");
83
+ mkdirSync(configDir, { recursive: true });
84
+ process.env.XDG_CONFIG_HOME = tempDir;
85
+ process.env.PONYTAIL_DEFAULT_MODE = " FULL ";
86
+ writeFileSync(configPath, JSON.stringify({ defaultMode: "\tultra\n" }), "utf8");
87
+
88
+ try {
89
+ assert.equal(readDefaultMode(), "full");
90
+ process.env.PONYTAIL_DEFAULT_MODE = "\n lite \r\n";
91
+ assert.equal(readDefaultMode(), "lite");
92
+ delete process.env.PONYTAIL_DEFAULT_MODE;
93
+ assert.equal(readDefaultMode(), "ultra");
94
+ } finally {
95
+ if (previousXdg === undefined) delete process.env.XDG_CONFIG_HOME;
96
+ else process.env.XDG_CONFIG_HOME = previousXdg;
97
+ if (previousDefault === undefined) delete process.env.PONYTAIL_DEFAULT_MODE;
98
+ else process.env.PONYTAIL_DEFAULT_MODE = previousDefault;
99
+ rmSync(tempDir, { recursive: true, force: true });
100
+ }
101
+ });
102
+
103
+ test("readQuietStartup resolves env var, config file, and default in that order", () => {
104
+ const tempDir = mkdtempSync(join(tmpdir(), "ponytail-quiet-"));
105
+ const previousXdg = process.env.XDG_CONFIG_HOME;
106
+ const previousEnv = process.env.PONYTAIL_QUIET_STARTUP;
107
+ const configDir = join(tempDir, "ponytail");
108
+ const configPath = join(configDir, "config.json");
109
+ process.env.XDG_CONFIG_HOME = tempDir;
110
+ delete process.env.PONYTAIL_QUIET_STARTUP;
111
+
112
+ try {
113
+ // No env, no config -> default false (toast still shows)
114
+ assert.equal(readQuietStartup(), false);
115
+
116
+ // Config file true -> respected
117
+ mkdirSync(configDir, { recursive: true });
118
+ writeFileSync(configPath, JSON.stringify({ quietStartup: true }), "utf8");
119
+ assert.equal(readQuietStartup(), true);
120
+
121
+ // Env var overrides config
122
+ process.env.PONYTAIL_QUIET_STARTUP = "false";
123
+ assert.equal(readQuietStartup(), false);
124
+ process.env.PONYTAIL_QUIET_STARTUP = "1";
125
+ assert.equal(readQuietStartup(), true);
126
+ } finally {
127
+ if (previousXdg === undefined) delete process.env.XDG_CONFIG_HOME;
128
+ else process.env.XDG_CONFIG_HOME = previousXdg;
129
+ if (previousEnv === undefined) delete process.env.PONYTAIL_QUIET_STARTUP;
130
+ else process.env.PONYTAIL_QUIET_STARTUP = previousEnv;
131
+ rmSync(tempDir, { recursive: true, force: true });
132
+ }
133
+ });
134
+
135
+ test("filterSkillBodyForMode keeps only requested intensity examples and rows", () => {
136
+ // Examples are quoted in the real SKILL.md (`- lite: "..."`) — match that
137
+ // shape here too; see the next test for why the quote is load-bearing.
138
+ const body = `---\nname: ponytail\n---\n| **lite** | keep lite |\n| **full** | keep full |\n| **ultra** | keep ultra |\n- lite: "Lite example"\n- full: "Full example"\n- ultra: "Ultra example"\nOther line`;
139
+
140
+ const filtered = filterSkillBodyForMode(body, "ultra");
141
+
142
+ assert.ok(!filtered.includes("keep lite"));
143
+ assert.ok(!filtered.includes("keep full"));
144
+ assert.ok(filtered.includes("keep ultra"));
145
+ assert.ok(!filtered.includes("Lite example"));
146
+ assert.ok(filtered.includes("Ultra example"));
147
+ assert.ok(filtered.includes("Other line"));
148
+ });
149
+
150
+ test("filterSkillBodyForMode does not drop a rule bullet whose label matches a mode name", () => {
151
+ // A rule bullet like "- Full: ..." has the same "label: text" shape as a
152
+ // worked example, but isn't one — it must survive in every mode. Only the
153
+ // quoted, `- lite: "..."`-style bullets are real per-mode examples.
154
+ const body = `- Full: do not confuse this rule label with the mode name.\n- Lite: same risk, this is a real rule bullet.\n- lite: "real worked example"\n- ultra: "real worked example"`;
155
+
156
+ const filtered = filterSkillBodyForMode(body, "ultra");
157
+
158
+ assert.ok(filtered.includes("Full: do not confuse"), "an unquoted rule bullet must not be treated as a mode example");
159
+ assert.ok(filtered.includes("Lite: same risk"), "an unquoted rule bullet must not be treated as a mode example");
160
+ assert.ok(!filtered.includes("- lite:"), "the real quoted lite example must still be filtered out in ultra mode");
161
+ assert.ok(filtered.includes('ultra: "real worked example"'));
162
+ });
163
+
164
+ test("filterSkillBodyForMode keeps rule bullets that contain a colon", () => {
165
+ // Regression: rule bullets outside the Intensity section (e.g. the
166
+ // "No unrequested abstractions:" rule or the `shortcut:` comment convention)
167
+ // contain a colon and must not be mistaken for mode-example lines.
168
+ const skillPath = new URL("../../skills/ponytail/SKILL.md", import.meta.url);
169
+ const body = readFileSync(skillPath, "utf8");
170
+
171
+ const filtered = filterSkillBodyForMode(body, "full");
172
+
173
+ assert.ok(filtered.includes("Bug fix: before you edit, grep every caller"));
174
+ assert.ok(filtered.includes("Never cut: validation at trust boundaries"));
175
+ assert.ok(filtered.includes("code comment in this form: `shortcut: <the limit>, <when to upgrade>`"));
176
+ // The level rows are still filtered down to the active mode.
177
+ assert.ok(filtered.includes("| **full** |"));
178
+ assert.ok(!filtered.includes("| **lite** |"));
179
+ });
package/plugin.yaml CHANGED
@@ -1,12 +1,21 @@
1
- name: chill-agent
2
- version: 1.2.0
3
- description: ChillAgent Anti-Slop 2.0 & Pragmatic Senior Dev Standard
4
- skills:
5
- - ./skills/chill-slop/
6
- - ./skills/chill-slop-ui/
7
- - ./skills/chill-slop-code/
8
- - ./skills/chill-slop-copywriting/
9
- - ./skills/chill-slop-human/
10
- - ./skills/chill-slop-mobile/
11
- - ./skills/chill-audit/
12
- - ./skills/chill-review/
1
+ name: ponytail
2
+ version: 1.6.0
3
+ description: Lazy senior dev mode for Hermes Agent, always-on context, bundled skills, and slash commands.
4
+ author: sann
5
+ provides_hooks:
6
+ - pre_llm_call
7
+ - pre_gateway_dispatch
8
+ provides_commands:
9
+ - ponytail
10
+ - ponytail-review
11
+ - ponytail-audit
12
+ - ponytail-debt
13
+ - ponytail-gain
14
+ - ponytail-help
15
+ provides_skills:
16
+ - ponytail
17
+ - ponytail-review
18
+ - ponytail-audit
19
+ - ponytail-debt
20
+ - ponytail-gain
21
+ - ponytail-help
@@ -0,0 +1,18 @@
1
+ ---
2
+ description: Always-on antislop pointer for Cursor sessions. Loads the matching skills and saved usage mode.
3
+ globs: **/*
4
+ alwaysApply: true
5
+ ---
6
+
7
+ <EXTREMELY_IMPORTANT>
8
+ You use antislop (Anti Slop: Rules for AI Coding Agents). It is a filter, not a style guide: it stops generic AI slop in generated UI, copy, and code, without prescribing aesthetics.
9
+
10
+ For UI, copy, people, mobile layout, or code comments work, load the matching antislop skill before starting. Each skill is a folder in this plugin's skills/ directory (read its SKILL.md):
11
+ - Core filter, always on: skills/antislop/SKILL.md
12
+ - UI / visual: skills/antislop-ui/SKILL.md
13
+ - Copy & text: skills/antislop-copywriting/SKILL.md
14
+ - People: skills/antislop-human/SKILL.md
15
+ - Mobile / responsive: skills/antislop-layoutmobile/SKILL.md
16
+ - Code comments: skills/antislop-code/SKILL.md
17
+ Before starting, follow the core's "Two Usage Modes" section: explicit session instruction first, then global preference, then ask. For a resolved mode, announce `antislop active: <mode> (session override).` or `antislop active: <mode> (global preference).` once, using the actual mode and source. Ask only when no mode is resolved.
18
+ </EXTREMELY_IMPORTANT>
@@ -0,0 +1,60 @@
1
+ #!/usr/bin/env node
2
+ // Generate the OpenClaw / ClawHub skill package (.openclaw/skills/) from the
3
+ // canonical skills/. OpenClaw skills are SKILL.md (frontmatter + body), the same
4
+ // format ponytail already uses, with one difference: `description` must be a
5
+ // single line under 160 chars. The canonical descriptions are long (tuned for
6
+ // Claude's skill picker), so each ships a short one here. The body is copied
7
+ // verbatim from skills/<name>/SKILL.md so the ruleset never drifts; only the
8
+ // frontmatter is rewritten.
9
+ //
10
+ // Run: node scripts/build-openclaw-skills.js
11
+ // tests/openclaw-skills.test.js fails if the committed copies are stale.
12
+
13
+ const fs = require('fs');
14
+ const path = require('path');
15
+
16
+ const ROOT = path.join(__dirname, '..');
17
+ const HOMEPAGE = 'https://github.com/DietrichGebert/ponytail';
18
+
19
+ const DESCRIPTIONS = {
20
+ 'ponytail': 'Lazy senior dev mode for any coding task: the smallest change that fully solves it, and a reply a busy human understands in one read.',
21
+ 'ponytail-review': 'Quality review of a diff: bugs, security, real load, missing tests, speed, and what to delete. Each finding says what goes wrong and how to fix it.',
22
+ 'ponytail-audit': 'Quality audit of the whole repo: bugs, security, real load, missing tests, speed, and what to delete. Most important first.',
23
+ 'ponytail-debt': 'Harvest every shortcut: comment into one debt ledger, so deferrals get tracked instead of forgotten. One-shot report.',
24
+ 'ponytail-gain': 'Show ponytail measured impact as a scoreboard: less code, less cost, more speed, from the agentic benchmark averages. One-shot display.',
25
+ 'ponytail-help': "Quick reference for ponytail's modes, skills, and commands. One-shot display.",
26
+ };
27
+
28
+ const NAMES = Object.keys(DESCRIPTIONS);
29
+
30
+ function sourceBody(name) {
31
+ const src = fs.readFileSync(path.join(ROOT, 'skills', name, 'SKILL.md'), 'utf8').replace(/\r\n/g, '\n');
32
+ const fm = src.match(/^---\n[\s\S]*?\n---\n?/);
33
+ if (!fm) throw new Error(`skills/${name}/SKILL.md has no frontmatter`);
34
+ return src.slice(fm[0].length);
35
+ }
36
+
37
+ function render(name) {
38
+ const desc = DESCRIPTIONS[name];
39
+ if (desc.length > 160 || desc.includes('\n') || desc.includes('"')) {
40
+ throw new Error(`description for ${name} must be one line, no quotes, under 160 chars`);
41
+ }
42
+ const frontmatter =
43
+ `---\nname: ${name}\ndescription: "${desc}"\nhomepage: ${HOMEPAGE}\nlicense: MIT\n---\n`;
44
+ return frontmatter + sourceBody(name);
45
+ }
46
+
47
+ function outPath(name) {
48
+ return path.join(ROOT, '.openclaw', 'skills', name, 'SKILL.md');
49
+ }
50
+
51
+ module.exports = { DESCRIPTIONS, NAMES, render, outPath, sourceBody };
52
+
53
+ if (require.main === module) {
54
+ for (const name of NAMES) {
55
+ const p = outPath(name);
56
+ fs.mkdirSync(path.dirname(p), { recursive: true });
57
+ fs.writeFileSync(p, render(name));
58
+ console.log('wrote', path.relative(ROOT, p).replace(/\\/g, '/'));
59
+ }
60
+ }
@@ -0,0 +1,389 @@
1
+ #!/usr/bin/env node
2
+ /**
3
+ * The rules antislop applies to other people's repos, applied to this one.
4
+ * Run it before opening a PR:
5
+ *
6
+ * node scripts/check-repo.mjs
7
+ */
8
+ import fs from 'node:fs'
9
+ import path from 'node:path'
10
+ import { fileURLToPath } from 'node:url'
11
+ import { buildIndex, START, END } from './skill-index.mjs'
12
+
13
+ const root = path.join(path.dirname(fileURLToPath(import.meta.url)), '..')
14
+ const read = (p) => fs.readFileSync(path.join(root, p), 'utf8')
15
+ // Split on /\r?\n/, not '\n': on a Windows checkout a trailing \r survives and
16
+ // every ^...$ regex below silently stops matching.
17
+ const lines = (p) => read(p).split(/\r?\n/)
18
+
19
+ const SKILLS = fs
20
+ .readdirSync(path.join(root, 'skills'), { withFileTypes: true })
21
+ .filter((e) => e.isDirectory())
22
+ .map((e) => `skills/${e.name}/SKILL.md`)
23
+
24
+ const MANIFESTS = [
25
+ 'package.json',
26
+ '.claude-plugin/plugin.json',
27
+ '.claude-plugin/marketplace.json',
28
+ '.codex-plugin/plugin.json',
29
+ '.cursor-plugin/plugin.json',
30
+ '.cursor-plugin/marketplace.json',
31
+ '.kimi-plugin/plugin.json',
32
+ '.agents/plugins/marketplace.json',
33
+ '.omp-plugin/marketplace.json',
34
+ 'plugin.json',
35
+ 'cli/package.json',
36
+ ]
37
+
38
+ /** The community files GitHub reads. Held to the rules the documents above already follow. */
39
+ const COMMUNITY = [
40
+ 'CONTRIBUTING.md',
41
+ 'CODE_OF_CONDUCT.md',
42
+ '.github/PULL_REQUEST_TEMPLATE.md',
43
+ '.github/ISSUE_TEMPLATE/bug_report.yml',
44
+ '.github/ISSUE_TEMPLATE/rule_gap.yml',
45
+ '.github/ISSUE_TEMPLATE/config.yml',
46
+ ]
47
+
48
+ /** R-02's carve-out, encoded so a new em dash in ordinary prose still fails. */
49
+ function emDashes() {
50
+ const bad = []
51
+ // The rules/ pointers ship to plugin users, so the ban covers them like any other doc.
52
+ const files = [
53
+ 'antislop.md',
54
+ 'README.md',
55
+ 'GUIDE.md',
56
+ 'ROADMAP.md',
57
+ 'SECURITY.md',
58
+ 'rules/antislop.md',
59
+ 'rules/antislop.mdc',
60
+ ...COMMUNITY,
61
+ ...SKILLS,
62
+ ]
63
+
64
+ for (const file of files) {
65
+ let section = ''
66
+ lines(file).forEach((line, i) => {
67
+ const heading = line.match(/^#{2,4}\s+(.*)$/)
68
+ if (heading) section = heading[1]
69
+ if (!/[—–]/.test(line)) return
70
+
71
+ const exempt =
72
+ // "#### R-02 — Copywriting" and "### C-1 — Intentionality"
73
+ /^#{2,4}\s+(R-\d{2}|C-\d)\s+—\s/.test(line) ||
74
+ // The rule that defines the ban, and the skill section that teaches it.
75
+ /^(R-02|Em Dashes)\b/.test(section) ||
76
+ // The Part 1 table row that shows the pattern.
77
+ /^\|\s*\*\*Em Dash/.test(line) ||
78
+ // The Delivery Gate item that quotes the character it checks for.
79
+ (/^\s*- \[ \]/.test(line) && /\*\(R-02\)\*/.test(line))
80
+
81
+ if (!exempt) bad.push(`${file}:${i + 1}: ${line.trim().slice(0, 90)}`)
82
+ })
83
+ }
84
+ return bad
85
+ }
86
+
87
+ /** Every rule the core defines must have a Delivery Gate item citing it, and vice versa. */
88
+ function gateCoverage() {
89
+ const core = read('antislop.md')
90
+ const defined = new Set([...core.matchAll(/^#### (R-\d{2}) —/gm)].map((m) => m[1]))
91
+ const gate = core.split('## Delivery Gate (Mandatory)')[1] ?? ''
92
+ const cited = new Set([...gate.matchAll(/\((R-\d{2})\)/g)].map((m) => m[1]))
93
+
94
+ const bad = []
95
+ for (const r of [...defined].sort()) {
96
+ if (!cited.has(r)) bad.push(`${r} is defined in Part 2 but no Delivery Gate item cites it`)
97
+ }
98
+ for (const r of [...cited].sort()) {
99
+ if (!defined.has(r)) bad.push(`${r} is cited in the Delivery Gate but Part 2 does not define it`)
100
+ }
101
+ return bad
102
+ }
103
+
104
+ /** A skill may reference core rules by number. It may not invent one. */
105
+ function skillReferences() {
106
+ const defined = new Set([...read('antislop.md').matchAll(/^#### (R-\d{2}) —/gm)].map((m) => m[1]))
107
+ const bad = []
108
+ for (const file of SKILLS) {
109
+ for (const r of new Set([...read(file).matchAll(/\bR-\d{2}\b/g)].map((m) => m[0]))) {
110
+ if (!defined.has(r)) bad.push(`${file} cites ${r}, which antislop.md does not define`)
111
+ }
112
+ }
113
+ return bad
114
+ }
115
+
116
+ /** The version is hand-written in every manifest and stamp. They have to agree. */
117
+ function versions() {
118
+ // A malformed file is already reported by the manifest check; do not crash here.
119
+ const json = (p) => {
120
+ try {
121
+ return JSON.parse(read(p))
122
+ } catch {
123
+ return null
124
+ }
125
+ }
126
+ const want = json('cli/package.json')?.version
127
+ if (!want) return ['cli/package.json has no readable version']
128
+
129
+ const found = [
130
+ // The lock file carries the version twice, and npm ci will not correct a stale one.
131
+ ['cli/package-lock.json', json('cli/package-lock.json')?.version],
132
+ ['cli/package-lock.json (packages "")', json('cli/package-lock.json')?.packages?.['']?.version],
133
+ // The Cline plugin manifest and, from v3.2.17, the Pi package. Same release.
134
+ ['package.json', json('package.json')?.version],
135
+ ['.claude-plugin/plugin.json', json('.claude-plugin/plugin.json')?.version],
136
+ ['.claude-plugin/marketplace.json', json('.claude-plugin/marketplace.json')?.plugins?.[0]?.version],
137
+ ['.codex-plugin/plugin.json', json('.codex-plugin/plugin.json')?.version],
138
+ ['.cursor-plugin/plugin.json', json('.cursor-plugin/plugin.json')?.version],
139
+ ['.omp-plugin/marketplace.json', json('.omp-plugin/marketplace.json')?.plugins?.[0]?.version],
140
+ ['.kimi-plugin/plugin.json', json('.kimi-plugin/plugin.json')?.version],
141
+ // Stamped into every install, so a stale one makes the installer misreport what is on disk.
142
+ ['skills/antislop/VERSION', read('skills/antislop/VERSION').trim()],
143
+ ['cli/lib/banner.mjs', read('cli/lib/banner.mjs').match(/installer v(\d+\.\d+\.\d+)/)?.[1]],
144
+ ['skills/antislop-human/contrast-mcp.py', read('skills/antislop-human/contrast-mcp.py').match(/SERVER_VERSION = "(\d+\.\d+\.\d+)"/)?.[1]],
145
+ // The contributors image is cached per URL, so a changed version is what makes it redraw.
146
+ ['README.md (contributors image)', read('README.md').match(/contrib\.rocks\/image\?repo=[^"'&\s]+&v=(\d+\.\d+\.\d+)/)?.[1]],
147
+ ]
148
+
149
+ return found
150
+ .filter(([, got]) => got !== want)
151
+ .map(([file, got]) => `${file} says ${got ?? 'nothing'}, cli/package.json says ${want}`)
152
+ }
153
+
154
+ /** A manifest that does not parse takes its whole install path down. */
155
+ function manifests() {
156
+ const bad = []
157
+ for (const file of MANIFESTS) {
158
+ try {
159
+ JSON.parse(read(file))
160
+ } catch (err) {
161
+ bad.push(`${file} is not valid JSON: ${err.message}`)
162
+ }
163
+ }
164
+ return bad
165
+ }
166
+
167
+ /** An agent finds a skill by its frontmatter. Without it the folder is inert. */
168
+ function frontmatter() {
169
+ const bad = []
170
+ for (const file of SKILLS) {
171
+ const head = read(file).split('---')[1] ?? ''
172
+ // [ \t] not \s: \s spans newlines, so an empty value would match the next key.
173
+ if (!/^name:[ \t]*\S/m.test(head)) bad.push(`${file} has no name in its frontmatter`)
174
+ if (!/^description:[ \t]*\S/m.test(head)) bad.push(`${file} has no description in its frontmatter`)
175
+ }
176
+ return bad
177
+ }
178
+
179
+ /** A new skill has to be registered in all four places below or it cannot install. */
180
+ function registration() {
181
+ const names = SKILLS.map((f) => f.split('/')[1])
182
+ const core = 'antislop'
183
+ const places = [
184
+ ['rules/antislop.md', read('rules/antislop.md')],
185
+ ['rules/antislop.mdc', read('rules/antislop.mdc')],
186
+ ["cli/index.mjs (EXTRA_SKILLS)", read('cli/index.mjs')],
187
+ ['cli/lib/install.mjs (SKILL_LINES)', read('cli/lib/install.mjs')],
188
+ ]
189
+
190
+ const bad = []
191
+ for (const name of names) {
192
+ for (const [label, text] of places) {
193
+ // The core is always on, so the picker's optional-extras menu never lists it.
194
+ if (name === core && label.startsWith('cli/index.mjs')) continue
195
+ if (!text.includes(name)) bad.push(`${name} is not registered in ${label}`)
196
+ }
197
+ }
198
+ return bad
199
+ }
200
+
201
+ /** A rule's tier decides its gate block: Hard Gate 1, Purpose-Gate 2, Quality Locks 4. */
202
+ function tiers() {
203
+ const core = read('antislop.md')
204
+ const part2 = core.split('## Part 2:')[1]?.split('## Part 3:')[0] ?? ''
205
+
206
+ const group = {}
207
+ let current = null
208
+ for (const line of part2.split('\n')) {
209
+ const g = line.match(/^### Group \d: ([^(]+)/)
210
+ if (g) current = g[1].trim()
211
+ const r = line.match(/^#### (R-\d{2}) —/)
212
+ if (r && current) group[r[1]] = current
213
+ }
214
+
215
+ const block = {}
216
+ let currentBlock = null
217
+ for (const line of (core.split('## Delivery Gate (Mandatory)')[1] ?? '').split('\n')) {
218
+ const b = line.match(/^### (Block \d):/)
219
+ if (b) currentBlock = b[1]
220
+ for (const m of line.matchAll(/\((R-\d{2})\)/g)) {
221
+ if (!block[m[1]]) block[m[1]] = currentBlock
222
+ }
223
+ }
224
+
225
+ const wants = { 'Hard Gate': 'Block 1', 'Purpose-Gate': 'Block 2', 'Quality Locks': 'Block 4' }
226
+ const bad = []
227
+ for (const [rule, grp] of Object.entries(group).sort()) {
228
+ const want = wants[grp]
229
+ const got = block[rule]
230
+ if (want && got && want !== got) bad.push(`${rule} is a ${grp} rule but the gate cites it from ${got}`)
231
+ }
232
+ return bad
233
+ }
234
+
235
+ /** A manifest that points at a file that is gone breaks its install path quietly. */
236
+ function manifestPaths() {
237
+ const bad = []
238
+ for (const file of MANIFESTS) {
239
+ let text
240
+ try {
241
+ text = read(file)
242
+ } catch {
243
+ continue
244
+ }
245
+ for (const m of text.matchAll(/"(\.\/[^"]+)"/g)) {
246
+ const target = m[1].replace(/^\.\//, '')
247
+ if (!existsExactly(target)) bad.push(`${file} points at ${m[1]}, which does not exist`)
248
+ }
249
+ // A plugin-root path is absolute only once the agent expands it, so check the tail.
250
+ for (const m of text.matchAll(/"\$\{CLAUDE_PLUGIN_ROOT\}\/([^"]+)"/g)) {
251
+ if (!existsExactly(m[1])) bad.push(`${file} points at \${CLAUDE_PLUGIN_ROOT}/${m[1]}, which does not exist`)
252
+ }
253
+ }
254
+ return bad
255
+ }
256
+
257
+ /** existsSync misses a case-only rename on macOS; compare the real dir entry. */
258
+ const existsExactly = (target) => {
259
+ const full = path.join(root, target)
260
+ const dir = path.dirname(full)
261
+ if (!fs.existsSync(dir)) return false
262
+ return fs.readdirSync(dir).includes(path.basename(full))
263
+ }
264
+
265
+ /** GitHub's anchor rule: lowercase, drop punctuation, spaces to hyphens, duplicates numbered. */
266
+ function anchorsIn(text) {
267
+ const taken = new Map()
268
+ const out = new Set()
269
+ for (const line of text.split(/\r?\n/)) {
270
+ const m = line.match(/^#{1,6}\s+(.+?)\s*#*\s*$/)
271
+ if (!m) continue
272
+ const base = m[1].toLowerCase().replace(/[^\p{L}\p{N}\s-]/gu, '').trim().replace(/\s+/g, '-')
273
+ const seen = taken.get(base) ?? 0
274
+ taken.set(base, seen + 1)
275
+ out.add(seen === 0 ? base : `${base}-${seen}`)
276
+ }
277
+ return out
278
+ }
279
+
280
+ function docLinks() {
281
+ const bad = []
282
+ for (const file of ['README.md', 'GUIDE.md', 'ROADMAP.md', 'SECURITY.md', ...COMMUNITY]) {
283
+ // Markdown links, plus the raw HTML images and badges the README is built from.
284
+ for (const m of read(file).matchAll(/\]\(([^)\s]+)\)|(?:src|href)="([^"]+)"/g)) {
285
+ const [target, anchor] = (m[1] ?? m[2]).split('#')
286
+ if (/^(https?:|mailto:)/.test(target)) continue
287
+ // Every table of contents is a list of these, and a renamed heading breaks one silently.
288
+ if (!target) {
289
+ if (anchor && !anchorsIn(read(file)).has(anchor)) {
290
+ bad.push(`${file} links to #${anchor}, which is not a heading in it`)
291
+ }
292
+ continue
293
+ }
294
+ if (!existsExactly(target)) {
295
+ bad.push(`${file} links to ${target}, which does not exist`)
296
+ continue
297
+ }
298
+ if (anchor && target.endsWith('.md') && !anchorsIn(read(target)).has(anchor)) {
299
+ bad.push(`${file} links to ${target}#${anchor}, which is not a heading in it`)
300
+ }
301
+ }
302
+ }
303
+ return bad
304
+ }
305
+
306
+ /** A YAML file that routes people by URL rots the same way a markdown link does. */
307
+ function repoUrls() {
308
+ const bad = []
309
+ const prefix = 'https://github.com/miqdadbadjuber/anti-slop/blob/main/'
310
+ for (const file of COMMUNITY) {
311
+ for (const m of read(file).matchAll(/url:\s*(\S+)/g)) {
312
+ // A URL that leaves this repository is not this check's to resolve.
313
+ if (!m[1].startsWith(prefix)) continue
314
+ const target = m[1].slice(prefix.length)
315
+ if (!existsExactly(target)) bad.push(`${file} points at ${target}, which does not exist`)
316
+ }
317
+ }
318
+ return bad
319
+ }
320
+
321
+ /** The WCAG formula is duplicated, and only contrast-check.py has a selftest. */
322
+ function contrastTwins() {
323
+ const check = read('skills/antislop-human/contrast-check.py')
324
+ const mcp = read('skills/antislop-human/contrast-mcp.py')
325
+ const constants = ['0.03928', '12.92', '1.055', '0.2126', '0.7152', '0.0722', '0.05']
326
+
327
+ const bad = []
328
+ for (const c of constants) {
329
+ if (check.includes(c) !== mcp.includes(c)) {
330
+ bad.push(`the WCAG constant ${c} appears in only one of contrast-check.py and contrast-mcp.py`)
331
+ }
332
+ }
333
+ return bad
334
+ }
335
+
336
+ /** The guide carries a generated catalogue, so a skill that adds a pattern cannot leave it behind. */
337
+ function skillIndex() {
338
+ const guide = read('GUIDE.md').replace(/\r\n/g, '\n')
339
+ const from = guide.indexOf(START)
340
+ const to = guide.indexOf(END)
341
+ if (from === -1 || to === -1) return [`GUIDE.md has no ${START} ... ${END} pair`]
342
+
343
+ let generated
344
+ try {
345
+ generated = buildIndex()
346
+ } catch (e) {
347
+ return [e.message]
348
+ }
349
+ const shipped = guide.slice(from + START.length, to).replace(/^\n+|\n+$/g, '')
350
+ if (shipped !== generated.replace(/\n+$/, '')) {
351
+ return ['the skill catalogue in GUIDE.md is stale: run node scripts/skill-index.mjs']
352
+ }
353
+ return []
354
+ }
355
+
356
+ const CHECKS = [
357
+ // Manifests first: the later checks read them as data.
358
+ ['every manifest is valid JSON', manifests],
359
+ ['no em dash outside the R-02 carve-out', emDashes],
360
+ ['every rule has a Delivery Gate item', gateCoverage],
361
+ ['skills cite only rules that exist', skillReferences],
362
+ ['the version agrees everywhere', versions],
363
+ ['every skill has usable frontmatter', frontmatter],
364
+ ['every skill is registered everywhere', registration],
365
+ ['each rule sits in the gate block its tier implies', tiers],
366
+ ['every manifest path resolves', manifestPaths],
367
+ ['every doc link resolves', docLinks],
368
+ ['every URL in a community file resolves', repoUrls],
369
+ ['both contrast implementations use the same formula', contrastTwins],
370
+ ['the skill index in the guide is current', skillIndex],
371
+ ]
372
+
373
+ let failed = 0
374
+ for (const [label, run] of CHECKS) {
375
+ const problems = run()
376
+ if (problems.length === 0) {
377
+ console.log(`ok ${label}`)
378
+ continue
379
+ }
380
+ failed += 1
381
+ console.log(`FAIL ${label}`)
382
+ for (const p of problems) console.log(` ${p}`)
383
+ }
384
+
385
+ if (failed > 0) {
386
+ console.error(`\n${failed} of ${CHECKS.length} checks failed`)
387
+ process.exit(1)
388
+ }
389
+ console.log(`\nall ${CHECKS.length} checks passed`)