vigiles 2.0.0 → 2.2.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 (184) hide show
  1. package/README.md +182 -134
  2. package/dist/action-gate.d.ts +28 -0
  3. package/dist/action-gate.js +73 -0
  4. package/dist/cli.js +705 -230
  5. package/dist/community-skills.d.ts +22 -0
  6. package/dist/community-skills.js +86 -0
  7. package/dist/compile-generator.d.ts +48 -0
  8. package/dist/compile-generator.js +322 -0
  9. package/dist/compile.d.ts +4 -0
  10. package/dist/compile.js +239 -45
  11. package/dist/coverage.d.ts +56 -0
  12. package/dist/coverage.js +178 -0
  13. package/dist/doc-refs.d.ts +60 -0
  14. package/dist/doc-refs.js +246 -0
  15. package/dist/eval.d.ts +62 -0
  16. package/dist/eval.js +174 -0
  17. package/dist/evolve.js +60 -125
  18. package/dist/frontmatter.d.ts +75 -0
  19. package/dist/frontmatter.js +263 -0
  20. package/dist/generate-schema.d.ts +51 -0
  21. package/dist/generate-schema.js +125 -0
  22. package/dist/generate-types.js +39 -1
  23. package/dist/harness-test.d.ts +38 -0
  24. package/dist/harness-test.js +129 -0
  25. package/dist/hash.d.ts +8 -0
  26. package/dist/hash.js +16 -0
  27. package/dist/inline.d.ts +22 -4
  28. package/dist/inline.js +60 -13
  29. package/dist/integrity.d.ts +29 -0
  30. package/dist/integrity.js +44 -0
  31. package/dist/linters.d.ts +5 -3
  32. package/dist/linters.js +144 -4
  33. package/dist/mock-model.d.ts +31 -0
  34. package/dist/mock-model.js +189 -0
  35. package/dist/orphans.d.ts +52 -0
  36. package/dist/orphans.js +124 -0
  37. package/dist/proofs.js +22 -16
  38. package/dist/refs.d.ts +44 -0
  39. package/dist/refs.js +144 -0
  40. package/dist/session.d.ts +97 -0
  41. package/dist/session.js +306 -0
  42. package/dist/sidecar.d.ts +35 -0
  43. package/dist/sidecar.js +102 -0
  44. package/dist/skill-driver.d.ts +77 -0
  45. package/dist/skill-driver.js +76 -0
  46. package/dist/skill-runtime.d.ts +101 -0
  47. package/dist/skill-runtime.js +289 -0
  48. package/dist/skill-test.d.ts +47 -0
  49. package/dist/skill-test.js +77 -0
  50. package/dist/spec.d.ts +119 -13
  51. package/dist/spec.js +51 -4
  52. package/dist/symbols.d.ts +30 -0
  53. package/dist/symbols.js +142 -0
  54. package/dist/test-utils.d.ts +8 -0
  55. package/dist/test-utils.js +41 -0
  56. package/dist/types.d.ts +34 -8
  57. package/dist/types.js +19 -0
  58. package/dist/validate.js +5 -3
  59. package/package.json +26 -5
  60. package/.claude/settings.json +0 -46
  61. package/.claude/settings.local.json +0 -8
  62. package/.github/workflows/ci.yml +0 -81
  63. package/.prettierignore +0 -1
  64. package/.vigiles/generated.d.ts +0 -205
  65. package/CLAUDE.md +0 -95
  66. package/CLAUDE.md.spec.ts +0 -142
  67. package/CONTRIBUTING.md +0 -121
  68. package/dist/action.d.ts.map +0 -1
  69. package/dist/action.js.map +0 -1
  70. package/dist/cli.d.ts.map +0 -1
  71. package/dist/cli.js.map +0 -1
  72. package/dist/cli.test.d.ts +0 -2
  73. package/dist/cli.test.d.ts.map +0 -1
  74. package/dist/cli.test.js +0 -650
  75. package/dist/cli.test.js.map +0 -1
  76. package/dist/compile.d.ts.map +0 -1
  77. package/dist/compile.js.map +0 -1
  78. package/dist/evolve.d.ts.map +0 -1
  79. package/dist/evolve.js.map +0 -1
  80. package/dist/freshness.d.ts +0 -67
  81. package/dist/freshness.d.ts.map +0 -1
  82. package/dist/freshness.js +0 -244
  83. package/dist/freshness.js.map +0 -1
  84. package/dist/freshness.test.d.ts +0 -2
  85. package/dist/freshness.test.d.ts.map +0 -1
  86. package/dist/freshness.test.js +0 -356
  87. package/dist/freshness.test.js.map +0 -1
  88. package/dist/generate-types.d.ts.map +0 -1
  89. package/dist/generate-types.js.map +0 -1
  90. package/dist/inline.d.ts.map +0 -1
  91. package/dist/inline.js.map +0 -1
  92. package/dist/inline.test.d.ts +0 -5
  93. package/dist/inline.test.d.ts.map +0 -1
  94. package/dist/inline.test.js +0 -152
  95. package/dist/inline.test.js.map +0 -1
  96. package/dist/linters.d.ts.map +0 -1
  97. package/dist/linters.js.map +0 -1
  98. package/dist/proofs.d.ts.map +0 -1
  99. package/dist/proofs.js.map +0 -1
  100. package/dist/proofs.test.d.ts +0 -9
  101. package/dist/proofs.test.d.ts.map +0 -1
  102. package/dist/proofs.test.js +0 -952
  103. package/dist/proofs.test.js.map +0 -1
  104. package/dist/spec.d.ts.map +0 -1
  105. package/dist/spec.js.map +0 -1
  106. package/dist/spec.test.d.ts +0 -2
  107. package/dist/spec.test.d.ts.map +0 -1
  108. package/dist/spec.test.js +0 -1222
  109. package/dist/spec.test.js.map +0 -1
  110. package/dist/types.d.ts.map +0 -1
  111. package/dist/types.js.map +0 -1
  112. package/dist/validate.d.ts.map +0 -1
  113. package/dist/validate.js.map +0 -1
  114. package/dist/validate.test.d.ts +0 -2
  115. package/dist/validate.test.d.ts.map +0 -1
  116. package/dist/validate.test.js +0 -531
  117. package/dist/validate.test.js.map +0 -1
  118. package/docs/agent-setup.md +0 -85
  119. package/docs/agent-workflows.md +0 -103
  120. package/docs/comparison.md +0 -71
  121. package/docs/freshness.md +0 -124
  122. package/docs/inline-mode.md +0 -119
  123. package/docs/linter-support.md +0 -166
  124. package/docs/spec-format.md +0 -194
  125. package/eslint.config.mjs +0 -79
  126. package/examples/CLAUDE.md +0 -54
  127. package/examples/CLAUDE.md.spec.ts +0 -65
  128. package/examples/SKILL.md +0 -50
  129. package/examples/SKILL.md.spec.ts +0 -57
  130. package/fixtures/example-project/CLAUDE.md +0 -11
  131. package/fixtures/example-project/package.json +0 -9
  132. package/fixtures/example-project/src/index.ts +0 -3
  133. package/fixtures/example-project/src/utils.test.ts +0 -2
  134. package/fixtures/example-project/src/utils.ts +0 -3
  135. package/logo.png +0 -0
  136. package/research/adoption-strategy.md +0 -111
  137. package/research/agent-integration.md +0 -145
  138. package/research/ai-code-quality.md +0 -197
  139. package/research/code-search-for-agents.md +0 -313
  140. package/research/competitive-landscape.md +0 -163
  141. package/research/doc-freshness.md +0 -516
  142. package/research/executable-specs.md +0 -368
  143. package/research/feature-ideas.md +0 -464
  144. package/research/formal-proofs-for-agents.md +0 -338
  145. package/research/fp-for-agent-harness.md +0 -150
  146. package/research/fp-for-deterministic-ai.md +0 -131
  147. package/research/self-evolving-specs.md +0 -298
  148. package/schemas/claude-md-strict.yml +0 -18
  149. package/schemas/claude-md.yml +0 -6
  150. package/schemas/skill-strict.yml +0 -12
  151. package/schemas/skill.yml +0 -5
  152. package/skills/audit-feedback-loop/SKILL.md +0 -76
  153. package/skills/edit-spec/SKILL.md +0 -131
  154. package/skills/enforce-rules-format/SKILL.md +0 -71
  155. package/skills/generate-logo/SKILL.md +0 -102
  156. package/skills/generate-rule/SKILL.md +0 -90
  157. package/skills/linter-docs/clippy.md +0 -241
  158. package/skills/linter-docs/eslint.md +0 -384
  159. package/skills/linter-docs/pylint.md +0 -288
  160. package/skills/linter-docs/rubocop.md +0 -277
  161. package/skills/linter-docs/ruff.md +0 -187
  162. package/skills/linter-docs/stylelint.md +0 -247
  163. package/skills/migrate-to-spec/SKILL.md +0 -124
  164. package/skills/pr-to-lint-rule/SKILL.md +0 -97
  165. package/skills/strengthen/SKILL.md +0 -168
  166. package/src/action.ts +0 -214
  167. package/src/cli.test.ts +0 -914
  168. package/src/cli.ts +0 -1631
  169. package/src/compile.ts +0 -691
  170. package/src/evolve.ts +0 -781
  171. package/src/freshness.test.ts +0 -449
  172. package/src/freshness.ts +0 -299
  173. package/src/generate-types.ts +0 -448
  174. package/src/inline.test.ts +0 -206
  175. package/src/inline.ts +0 -164
  176. package/src/linters.ts +0 -739
  177. package/src/proofs.test.ts +0 -1314
  178. package/src/proofs.ts +0 -849
  179. package/src/spec.test.ts +0 -1471
  180. package/src/spec.ts +0 -427
  181. package/src/types.ts +0 -117
  182. package/src/validate.test.ts +0 -701
  183. package/src/validate.ts +0 -381
  184. package/tsconfig.json +0 -23
package/src/compile.ts DELETED
@@ -1,691 +0,0 @@
1
- /**
2
- * vigiles v2 — Compiler: spec → markdown.
3
- *
4
- * Reads .spec.ts files, validates references, and produces
5
- * markdown instruction files with integrity hashes.
6
- */
7
-
8
- import { createHash } from "node:crypto";
9
- import { existsSync, readFileSync } from "node:fs";
10
- import { resolve, dirname, basename } from "node:path";
11
-
12
- import type {
13
- ClaudeSpec,
14
- SkillSpec,
15
- Rule,
16
- InstructionFragment,
17
- } from "./spec.js";
18
-
19
- import { checkLinterRule, extractLinterName } from "./linters.js";
20
- import type { LinterCheckResult } from "./linters.js";
21
-
22
- // ---------------------------------------------------------------------------
23
- // Hash utilities
24
- // ---------------------------------------------------------------------------
25
-
26
- const HASH_RE =
27
- /^<!-- vigiles:sha256:([a-f0-9]+) compiled from (.+) -->\r?\n\r?\n?/;
28
-
29
- /** @internal Compute SHA-256 hash of content (excluding any existing hash line). */
30
- export function computeHash(content: string): string {
31
- const body = content.replace(HASH_RE, "");
32
- return createHash("sha256").update(body).digest("hex").slice(0, 16);
33
- }
34
-
35
- /** @internal Prepend a hash comment to compiled content. */
36
- export function addHash(content: string, specFile: string): string {
37
- const hash = computeHash(content);
38
- return `<!-- vigiles:sha256:${hash} compiled from ${specFile} -->\n\n${content}`;
39
- }
40
-
41
- /** @internal Check if a file's hash matches its content. Returns null if no hash found. */
42
- export function verifyHash(
43
- content: string,
44
- ): { valid: boolean; specFile: string } | null {
45
- const match = content.match(HASH_RE);
46
- if (!match) return null;
47
- const expectedHash = match[1];
48
- const specFile = match[2];
49
- const body = content.replace(HASH_RE, "");
50
- const actualHash = createHash("sha256")
51
- .update(body)
52
- .digest("hex")
53
- .slice(0, 16);
54
- return { valid: actualHash === expectedHash, specFile };
55
- }
56
-
57
- // ---------------------------------------------------------------------------
58
- // Token estimation
59
- // ---------------------------------------------------------------------------
60
-
61
- /**
62
- * Estimate token count for a string.
63
- *
64
- * Uses the ~4 characters per token heuristic (accurate within ~10% for
65
- * English text and code). Swap in a real BPE tokenizer (tiktoken, gpt-tokenizer)
66
- * for exact counts if needed.
67
- */
68
- /** @internal */ export function estimateTokens(text: string): number {
69
- return Math.ceil(text.length / 4);
70
- }
71
-
72
- // ---------------------------------------------------------------------------
73
- // Validation errors
74
- // ---------------------------------------------------------------------------
75
-
76
- export interface CompileError {
77
- type:
78
- | "stale-file"
79
- | "stale-command"
80
- | "stale-ref"
81
- | "invalid-rule"
82
- | "budget-exceeded"
83
- | "section-too-long"
84
- | "section-has-header"
85
- | "reserved-section-key"
86
- | "spec-name-mismatch";
87
- message: string;
88
- path?: string;
89
- }
90
-
91
- // ---------------------------------------------------------------------------
92
- // Reference validation
93
- // ---------------------------------------------------------------------------
94
-
95
- function validateFileRef(
96
- filePath: string,
97
- basePath: string,
98
- ): CompileError | null {
99
- const resolved = resolve(basePath, filePath);
100
- if (!existsSync(resolved)) {
101
- return {
102
- type: "stale-file",
103
- message: `File not found: "${filePath}"`,
104
- path: filePath,
105
- };
106
- }
107
- return null;
108
- }
109
-
110
- function readPackageScripts(basePath: string): Record<string, string> | null {
111
- const pkgPath = resolve(basePath, "package.json");
112
- if (!existsSync(pkgPath)) return null;
113
- try {
114
- const pkg = JSON.parse(readFileSync(pkgPath, "utf-8")) as {
115
- scripts?: Record<string, string>;
116
- };
117
- return pkg.scripts ?? null;
118
- } catch {
119
- return null;
120
- }
121
- }
122
-
123
- function validateCommandRef(
124
- command: string,
125
- basePath: string,
126
- ): CompileError | null {
127
- // Check "npm run <script>" or "npm <script>" against package.json
128
- const npmRunMatch = command.match(/^npm\s+run\s+(\S+)/);
129
- const npmMatch = command.match(/^npm\s+(test|start|build|pretest)\b/);
130
- const scriptName = npmRunMatch?.[1] ?? npmMatch?.[1];
131
- if (!scriptName) return null;
132
-
133
- const scripts = readPackageScripts(basePath);
134
- if (!scripts) return null;
135
-
136
- if (!scripts[scriptName]) {
137
- return {
138
- type: "stale-command",
139
- message: `Script "${scriptName}" not found in package.json`,
140
- path: command,
141
- };
142
- }
143
- return null;
144
- }
145
-
146
- function validateRefs(
147
- fragments: InstructionFragment[],
148
- basePath: string,
149
- ): CompileError[] {
150
- const errors: CompileError[] = [];
151
- for (const fragment of fragments) {
152
- if (typeof fragment === "string") continue;
153
- const r = fragment;
154
- switch (r._ref) {
155
- case "file": {
156
- const err = validateFileRef(r.path, basePath);
157
- if (err) errors.push(err);
158
- break;
159
- }
160
- case "cmd": {
161
- const err = validateCommandRef(r.command, basePath);
162
- if (err) errors.push(err);
163
- break;
164
- }
165
- case "skill": {
166
- const err = validateFileRef(r.path, basePath);
167
- if (err) {
168
- errors.push({
169
- type: "stale-ref",
170
- message: `Skill not found: "${r.path}"`,
171
- path: r.path,
172
- });
173
- }
174
- break;
175
- }
176
- }
177
- }
178
- return errors;
179
- }
180
-
181
- function renderFragment(fragment: InstructionFragment): string {
182
- if (typeof fragment === "string") return fragment;
183
- const r = fragment;
184
- switch (r._ref) {
185
- case "file":
186
- return `\`${r.path}\``;
187
- case "cmd":
188
- return `\`${r.command}\``;
189
- case "skill":
190
- return `[${basename(dirname(r.path))}](${r.path})`;
191
- }
192
- }
193
-
194
- // ---------------------------------------------------------------------------
195
- // Compile CLAUDE.md spec → markdown
196
- // ---------------------------------------------------------------------------
197
-
198
- function compileRule(id: string, rule: Rule): string {
199
- const title = id
200
- .replace(/[-_]/g, " ")
201
- .replace(/\b\w/g, (c) => c.toUpperCase());
202
-
203
- switch (rule._kind) {
204
- case "enforce":
205
- return [
206
- `### ${title}`,
207
- "",
208
- `**Enforced by:** \`${rule.linterRule}\``,
209
- `**Why:** ${rule.why}`,
210
- ].join("\n");
211
-
212
- case "guidance":
213
- return [`### ${title}`, "", `**Guidance only** — ${rule.text}`].join(
214
- "\n",
215
- );
216
-
217
- default: {
218
- // Unknown rule kind — legacy compiled JS spec artifacts, JS caller,
219
- // or cast bypass. Fail loudly rather than silently dropping the
220
- // rule from output, which would remove constraints without any
221
- // compile error.
222
- const unknown = (rule as { _kind?: unknown })._kind;
223
- throw new Error(
224
- `Unknown rule kind "${String(unknown)}" for rule "${id}". ` +
225
- `Expected "enforce" or "guidance". Runtime data is out of sync with the Rule type.`,
226
- );
227
- }
228
- }
229
- }
230
-
231
- export interface CompileClaudeResult {
232
- markdown: string;
233
- errors: CompileError[];
234
- linterResults: LinterCheckResult[];
235
- /** Estimated token count of compiled output (~4 chars/token). */
236
- tokens: number;
237
- /** All targets from the spec (for multi-target compilation). */
238
- targets: string[];
239
- }
240
-
241
- export interface CompileClaudeOptions {
242
- basePath?: string;
243
- specFile?: string;
244
- /** Maximum number of rules allowed. Compilation fails if exceeded. */
245
- maxRules?: number;
246
- /** Maximum estimated tokens for compiled output. */
247
- maxTokens?: number;
248
- /** Maximum lines per prose section. Forces splitting into named sections. */
249
- maxSectionLines?: number;
250
- /** Skip config-enabled checks, only verify rule exists in catalog. */
251
- catalogOnly?: boolean;
252
- /** Custom linter configs (rulesDir). */
253
- linters?: Record<string, { rulesDir?: string | string[] }>;
254
- /** Global kill switch: skip ALL linter verification. */
255
- verifyLinters?: boolean;
256
- /** Per-linter verification mode: true (full), "catalog-only", or false (skip). */
257
- linterModes?: Record<string, boolean | "catalog-only">;
258
- }
259
-
260
- // ---------------------------------------------------------------------------
261
- // compileClaude section helpers
262
- // ---------------------------------------------------------------------------
263
-
264
- interface SectionResult {
265
- lines: string[];
266
- errors: CompileError[];
267
- }
268
-
269
- function validateSectionContent(
270
- name: string,
271
- text: string,
272
- maxSectionLines?: number,
273
- ): CompileError[] {
274
- const errors: CompileError[] = [];
275
- const contentLines = text.split("\n");
276
-
277
- // Reject markdown headers inside sections — sections compile to ## headings,
278
- // so raw # headers break document structure and signal pasted-in content.
279
- // Skip lines inside fenced code blocks (``` or ~~~).
280
- let inFence = false;
281
- for (const line of contentLines) {
282
- if (/^ {0,3}(`{3,}|~{3,})/.test(line)) {
283
- inFence = !inFence;
284
- continue;
285
- }
286
- if (inFence) continue;
287
- if (/^ {0,3}#{1,2}\s/.test(line)) {
288
- errors.push({
289
- type: "section-has-header",
290
- message: `Section "${name}" contains a markdown header ("${line.trim().slice(0, 60)}"). Break into separate named sections instead.`,
291
- });
292
- break;
293
- }
294
- }
295
-
296
- if (maxSectionLines && contentLines.length > maxSectionLines) {
297
- errors.push({
298
- type: "section-too-long",
299
- message: `Section "${name}" is ${String(contentLines.length)} lines (max ${String(maxSectionLines)}). Split into smaller named sections.`,
300
- });
301
- }
302
-
303
- return errors;
304
- }
305
-
306
- const RESERVED_SECTION_KEYS = new Set([
307
- "commands",
308
- "keyFiles",
309
- "key-files",
310
- "key_files",
311
- "rules",
312
- ]);
313
-
314
- function compileSectionsSection(
315
- spec: ClaudeSpec,
316
- basePath: string,
317
- maxSectionLines?: number,
318
- ): SectionResult {
319
- if (!spec.sections) return { lines: [], errors: [] };
320
- const lines: string[] = [];
321
- const errors: CompileError[] = [];
322
- for (const [name, content] of Object.entries(spec.sections)) {
323
- // #4: reject reserved section keys that clash with structured fields
324
- if (RESERVED_SECTION_KEYS.has(name)) {
325
- errors.push({
326
- type: "reserved-section-key",
327
- message: `Section key "${name}" is reserved — use the dedicated \`${name}\` field on the spec instead.`,
328
- });
329
- }
330
- const heading = name.charAt(0).toUpperCase() + name.slice(1);
331
- if (typeof content === "string") {
332
- errors.push(...validateSectionContent(name, content, maxSectionLines));
333
- lines.push(`## ${heading}\n\n${content.trim()}`);
334
- } else {
335
- errors.push(...validateRefs(content, basePath));
336
- const rendered = content.map(renderFragment).join("");
337
- errors.push(...validateSectionContent(name, rendered, maxSectionLines));
338
- lines.push(`## ${heading}\n\n${rendered.trim()}`);
339
- }
340
- }
341
- return { lines, errors };
342
- }
343
-
344
- function compileKeyFilesSection(
345
- spec: ClaudeSpec,
346
- basePath: string,
347
- ): SectionResult {
348
- if (!spec.keyFiles) return { lines: [], errors: [] };
349
- const lines = ["## Key Files", ""];
350
- const errors: CompileError[] = [];
351
- for (const [filePath, desc] of Object.entries(spec.keyFiles)) {
352
- lines.push(`- \`${filePath}\` — ${desc}`);
353
- const err = validateFileRef(filePath, basePath);
354
- if (err) errors.push(err);
355
- }
356
- return { lines: [lines.join("\n")], errors };
357
- }
358
-
359
- function compileCommandsSection(
360
- spec: ClaudeSpec,
361
- basePath: string,
362
- ): SectionResult {
363
- if (!spec.commands) return { lines: [], errors: [] };
364
- const lines = ["## Commands", ""];
365
- const errors: CompileError[] = [];
366
- for (const [command, desc] of Object.entries(spec.commands)) {
367
- lines.push(`- \`${command}\` — ${desc}`);
368
- const err = validateCommandRef(command, basePath);
369
- if (err) errors.push(err);
370
- }
371
- return { lines: [lines.join("\n")], errors };
372
- }
373
-
374
- /**
375
- * Determine if a rule should be verified, checking three levels:
376
- * 1. Per-rule: enforce("...", "...", { verify: false })
377
- * 2. Global: options.verifyLinters === false
378
- * 3. Per-linter: options.linterModes[linterName] === false
379
- */
380
- function shouldVerifyRule(
381
- rule: { linterRule: string; verify: boolean },
382
- options: CompileClaudeOptions,
383
- ): boolean {
384
- if (!rule.verify) return false;
385
- if (options.verifyLinters === false) return false;
386
- const linterName = extractLinterName(rule.linterRule);
387
- const linterMode = options.linterModes?.[linterName];
388
- if (linterMode === false) return false;
389
- return true;
390
- }
391
-
392
- interface RulesSectionResult extends SectionResult {
393
- linterResults: LinterCheckResult[];
394
- }
395
-
396
- function compileRulesSection(
397
- spec: ClaudeSpec,
398
- basePath: string,
399
- options: CompileClaudeOptions,
400
- ): RulesSectionResult {
401
- const ruleEntries = Object.entries(spec.rules);
402
- if (ruleEntries.length === 0) {
403
- return { lines: [], errors: [], linterResults: [] };
404
- }
405
- const ruleLines = ["## Rules"];
406
- const errors: CompileError[] = [];
407
- const linterResults: LinterCheckResult[] = [];
408
-
409
- for (const [id, rule] of ruleEntries) {
410
- ruleLines.push("");
411
- ruleLines.push(compileRule(id, rule));
412
- if (rule._kind === "enforce") {
413
- const shouldVerify = shouldVerifyRule(rule, options);
414
- if (!shouldVerify) continue;
415
-
416
- const linterName = extractLinterName(rule.linterRule);
417
- const linterMode = options.linterModes?.[linterName];
418
- const catalogOnly = options.catalogOnly || linterMode === "catalog-only";
419
-
420
- const result = checkLinterRule(rule.linterRule, basePath, {
421
- catalogOnly,
422
- linters: options.linters,
423
- });
424
- linterResults.push(result);
425
- if (!result.exists) {
426
- errors.push({
427
- type: "invalid-rule",
428
- message:
429
- result.error ??
430
- `Rule "${rule.linterRule}" not found in ${result.linter}`,
431
- path: rule.linterRule,
432
- });
433
- } else if (result.enabled === "disabled") {
434
- errors.push({
435
- type: "invalid-rule",
436
- message: `Rule "${result.rule}" exists but is disabled in ${result.linter} config`,
437
- path: rule.linterRule,
438
- });
439
- }
440
- }
441
- }
442
- return { lines: [ruleLines.join("\n")], errors, linterResults };
443
- }
444
-
445
- // ---------------------------------------------------------------------------
446
- // compileClaude
447
- // ---------------------------------------------------------------------------
448
-
449
- /**
450
- * Compile a ClaudeSpec into markdown.
451
- *
452
- * Returns the compiled markdown, validation errors, and linter check results.
453
- * The markdown is generated even if there are errors (with warnings).
454
- */
455
- export function compileClaude(
456
- spec: ClaudeSpec,
457
- options: CompileClaudeOptions = {},
458
- ): CompileClaudeResult {
459
- const targets = spec.target ?? "CLAUDE.md";
460
- const target = Array.isArray(targets) ? targets[0] : targets;
461
- const basePath = options.basePath ?? process.cwd();
462
- const specFile = options.specFile ?? `${target}.spec.ts`;
463
- const errors: CompileError[] = [];
464
- const sections: string[] = [`# ${target}`];
465
-
466
- // Verify spec file naming matches the primary target
467
- if (!specFile.endsWith(".spec.ts")) {
468
- errors.push({
469
- type: "spec-name-mismatch",
470
- message: `Spec file "${specFile}" must end with .spec.ts`,
471
- });
472
- } else {
473
- const baseName = basename(specFile, ".spec.ts");
474
- if (baseName !== target) {
475
- errors.push({
476
- type: "spec-name-mismatch",
477
- message: `Spec file "${specFile}" doesn't match target "${target}". Expected "${target}.spec.ts".`,
478
- });
479
- }
480
- }
481
-
482
- // maxRules check
483
- const ruleCount = Object.keys(spec.rules).length;
484
- if (options.maxRules && ruleCount > options.maxRules) {
485
- errors.push({
486
- type: "invalid-rule",
487
- message: `${String(ruleCount)} rules exceeds maxRules limit of ${String(options.maxRules)}. Split into subdirectory specs.`,
488
- });
489
- }
490
-
491
- // Per-spec maxSectionLines takes precedence, then compile options
492
- const maxSectionLines = spec.maxSectionLines ?? options.maxSectionLines;
493
- const prose = compileSectionsSection(spec, basePath, maxSectionLines);
494
- const keyFiles = compileKeyFilesSection(spec, basePath);
495
- const commands = compileCommandsSection(spec, basePath);
496
- const rules = compileRulesSection(spec, basePath, options);
497
-
498
- sections.push(
499
- ...prose.lines,
500
- ...keyFiles.lines,
501
- ...commands.lines,
502
- ...rules.lines,
503
- );
504
- errors.push(
505
- ...prose.errors,
506
- ...keyFiles.errors,
507
- ...commands.errors,
508
- ...rules.errors,
509
- );
510
-
511
- const body = sections.join("\n\n") + "\n";
512
- const tokens = estimateTokens(body);
513
-
514
- // Per-spec maxTokens takes precedence, then compile options
515
- const maxTokens = spec.maxTokens ?? options.maxTokens;
516
- if (maxTokens && tokens > maxTokens) {
517
- errors.push({
518
- type: "budget-exceeded",
519
- message: `Compiled output is ~${String(tokens)} tokens, exceeding maxTokens limit of ${String(maxTokens)}. Trim prose sections, split into multiple specs, or raise maxTokens.`,
520
- });
521
- }
522
-
523
- const markdown = addHash(body, specFile);
524
- const allTargets = Array.isArray(targets) ? targets : [targets];
525
- return {
526
- markdown,
527
- errors,
528
- linterResults: rules.linterResults,
529
- tokens,
530
- targets: allTargets,
531
- };
532
- }
533
-
534
- // ---------------------------------------------------------------------------
535
- // Compile SKILL.md spec → markdown
536
- // ---------------------------------------------------------------------------
537
-
538
- function renderBody(body: string | InstructionFragment[]): string {
539
- if (typeof body === "string") return body;
540
- return body.map(renderFragment).join("");
541
- }
542
-
543
- export interface CompileSkillResult {
544
- markdown: string;
545
- errors: CompileError[];
546
- }
547
-
548
- /**
549
- * Compile a SkillSpec into SKILL.md markdown with YAML frontmatter.
550
- */
551
- export function compileSkill(
552
- spec: SkillSpec,
553
- options: { basePath?: string; specFile?: string } = {},
554
- ): CompileSkillResult {
555
- const basePath = options.basePath ?? process.cwd();
556
- const specFile = options.specFile ?? "SKILL.md.spec.ts";
557
- const errors: CompileError[] = [];
558
-
559
- // Verify spec file naming
560
- if (!specFile.endsWith(".spec.ts")) {
561
- errors.push({
562
- type: "spec-name-mismatch",
563
- message: `Spec file "${specFile}" must end with .spec.ts`,
564
- });
565
- } else {
566
- const baseName = basename(specFile, ".spec.ts");
567
- if (!/\.md$/i.test(baseName)) {
568
- errors.push({
569
- type: "spec-name-mismatch",
570
- message: `Spec file "${specFile}" should be named <output>.spec.ts (e.g., SKILL.md.spec.ts)`,
571
- });
572
- }
573
- }
574
-
575
- // Validate refs in body
576
- if (Array.isArray(spec.body)) {
577
- errors.push(...validateRefs(spec.body, basePath));
578
- }
579
-
580
- // Build frontmatter (blank lines after opening/before closing --- for prettier)
581
- const fm: string[] = ["---", ""];
582
- fm.push(`name: ${spec.name}`);
583
- fm.push(`description: ${spec.description}`);
584
- if (spec.disableModelInvocation !== undefined) {
585
- fm.push(`disable-model-invocation: ${String(spec.disableModelInvocation)}`);
586
- }
587
- if (spec.argumentHint) {
588
- fm.push(`argument-hint: ${spec.argumentHint}`);
589
- }
590
- fm.push("", "---");
591
-
592
- const body = renderBody(spec.body);
593
- const content = fm.join("\n") + "\n\n" + body.trim() + "\n";
594
- const markdown = addHash(content, specFile);
595
-
596
- return { markdown, errors };
597
- }
598
-
599
- // ---------------------------------------------------------------------------
600
- // Hash check for existing files
601
- // ---------------------------------------------------------------------------
602
-
603
- export interface HashCheckResult {
604
- hasHash: boolean;
605
- valid: boolean;
606
- specFile: string | null;
607
- }
608
-
609
- /** Check if a generated file's hash is intact. */
610
- export function checkFileHash(filePath: string): HashCheckResult {
611
- if (!existsSync(filePath)) {
612
- return { hasHash: false, valid: false, specFile: null };
613
- }
614
- const content = readFileSync(filePath, "utf-8");
615
- const result = verifyHash(content);
616
- if (!result) {
617
- return { hasHash: false, valid: false, specFile: null };
618
- }
619
- return { hasHash: true, valid: result.valid, specFile: result.specFile };
620
- }
621
-
622
- // ---------------------------------------------------------------------------
623
- // Adopt: detect manual edits and show diff
624
- // ---------------------------------------------------------------------------
625
-
626
- export interface AdoptResult {
627
- filePath: string;
628
- hasHash: boolean;
629
- valid: boolean;
630
- specFile: string | null;
631
- currentContent: string;
632
- compiledContent: string | null;
633
- addedLines: string[];
634
- removedLines: string[];
635
- changed: boolean;
636
- }
637
-
638
- /**
639
- * Compare a generated file against what the spec would produce.
640
- * Returns the diff so users can see what was manually changed.
641
- */
642
- export function adoptDiff(
643
- filePath: string,
644
- spec: ClaudeSpec | SkillSpec,
645
- basePath: string,
646
- ): AdoptResult {
647
- const fullPath = resolve(basePath, filePath);
648
- const currentContent = existsSync(fullPath)
649
- ? readFileSync(fullPath, "utf-8")
650
- : "";
651
-
652
- const hashResult = verifyHash(currentContent);
653
-
654
- // Compile the spec to get what it WOULD produce
655
- let compiledContent: string | null = null;
656
- if (spec._specType === "claude") {
657
- const { markdown } = compileClaude(spec, { basePath, specFile: filePath });
658
- compiledContent = markdown;
659
- } else if (spec._specType === "skill") {
660
- const { markdown } = compileSkill(spec, { basePath, specFile: filePath });
661
- compiledContent = markdown;
662
- }
663
-
664
- // Simple line-based diff
665
- const currentLines = currentContent.replace(HASH_RE, "").split("\n");
666
- const compiledLines = (compiledContent ?? "")
667
- .replace(HASH_RE, "")
668
- .split("\n");
669
-
670
- const currentSet = new Set(currentLines);
671
- const compiledSet = new Set(compiledLines);
672
-
673
- const addedLines = currentLines.filter(
674
- (l) => l.trim() && !compiledSet.has(l),
675
- );
676
- const removedLines = compiledLines.filter(
677
- (l) => l.trim() && !currentSet.has(l),
678
- );
679
-
680
- return {
681
- filePath,
682
- hasHash: hashResult !== null,
683
- valid: hashResult?.valid ?? false,
684
- specFile: hashResult?.specFile ?? null,
685
- currentContent,
686
- compiledContent,
687
- addedLines,
688
- removedLines,
689
- changed: addedLines.length > 0 || removedLines.length > 0,
690
- };
691
- }