vigiles 2.0.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (156) hide show
  1. package/.claude/settings.json +46 -0
  2. package/.claude/settings.local.json +8 -0
  3. package/.claude-plugin/hooks/post-edit.sh +34 -0
  4. package/.claude-plugin/hooks/pre-edit.sh +40 -0
  5. package/.claude-plugin/hooks/session-start.sh +38 -0
  6. package/.claude-plugin/marketplace.json +14 -0
  7. package/.claude-plugin/plugin.json +47 -0
  8. package/.github/workflows/ci.yml +81 -0
  9. package/.prettierignore +1 -0
  10. package/.vigiles/generated.d.ts +205 -0
  11. package/CLAUDE.md +95 -0
  12. package/CLAUDE.md.spec.ts +142 -0
  13. package/CONTRIBUTING.md +121 -0
  14. package/LICENSE +21 -0
  15. package/README.md +377 -0
  16. package/action.yml +25 -0
  17. package/dist/action.d.ts +7 -0
  18. package/dist/action.d.ts.map +1 -0
  19. package/dist/action.js +180 -0
  20. package/dist/action.js.map +1 -0
  21. package/dist/cli.d.ts +12 -0
  22. package/dist/cli.d.ts.map +1 -0
  23. package/dist/cli.js +1267 -0
  24. package/dist/cli.js.map +1 -0
  25. package/dist/cli.test.d.ts +2 -0
  26. package/dist/cli.test.d.ts.map +1 -0
  27. package/dist/cli.test.js +650 -0
  28. package/dist/cli.test.js.map +1 -0
  29. package/dist/compile.d.ts +101 -0
  30. package/dist/compile.d.ts.map +1 -0
  31. package/dist/compile.js +503 -0
  32. package/dist/compile.js.map +1 -0
  33. package/dist/evolve.d.ts +132 -0
  34. package/dist/evolve.d.ts.map +1 -0
  35. package/dist/evolve.js +599 -0
  36. package/dist/evolve.js.map +1 -0
  37. package/dist/freshness.d.ts +67 -0
  38. package/dist/freshness.d.ts.map +1 -0
  39. package/dist/freshness.js +244 -0
  40. package/dist/freshness.js.map +1 -0
  41. package/dist/freshness.test.d.ts +2 -0
  42. package/dist/freshness.test.d.ts.map +1 -0
  43. package/dist/freshness.test.js +356 -0
  44. package/dist/freshness.test.js.map +1 -0
  45. package/dist/generate-types.d.ts +34 -0
  46. package/dist/generate-types.d.ts.map +1 -0
  47. package/dist/generate-types.js +381 -0
  48. package/dist/generate-types.js.map +1 -0
  49. package/dist/inline.d.ts +58 -0
  50. package/dist/inline.d.ts.map +1 -0
  51. package/dist/inline.js +142 -0
  52. package/dist/inline.js.map +1 -0
  53. package/dist/inline.test.d.ts +5 -0
  54. package/dist/inline.test.d.ts.map +1 -0
  55. package/dist/inline.test.js +152 -0
  56. package/dist/inline.test.js.map +1 -0
  57. package/dist/linters.d.ts +38 -0
  58. package/dist/linters.d.ts.map +1 -0
  59. package/dist/linters.js +588 -0
  60. package/dist/linters.js.map +1 -0
  61. package/dist/proofs.d.ts +272 -0
  62. package/dist/proofs.d.ts.map +1 -0
  63. package/dist/proofs.js +622 -0
  64. package/dist/proofs.js.map +1 -0
  65. package/dist/proofs.test.d.ts +9 -0
  66. package/dist/proofs.test.d.ts.map +1 -0
  67. package/dist/proofs.test.js +952 -0
  68. package/dist/proofs.test.js.map +1 -0
  69. package/dist/spec.d.ts +258 -0
  70. package/dist/spec.d.ts.map +1 -0
  71. package/dist/spec.js +113 -0
  72. package/dist/spec.js.map +1 -0
  73. package/dist/spec.test.d.ts +2 -0
  74. package/dist/spec.test.d.ts.map +1 -0
  75. package/dist/spec.test.js +1222 -0
  76. package/dist/spec.test.js.map +1 -0
  77. package/dist/types.d.ts +101 -0
  78. package/dist/types.d.ts.map +1 -0
  79. package/dist/types.js +3 -0
  80. package/dist/types.js.map +1 -0
  81. package/dist/validate.d.ts +10 -0
  82. package/dist/validate.d.ts.map +1 -0
  83. package/dist/validate.js +286 -0
  84. package/dist/validate.js.map +1 -0
  85. package/dist/validate.test.d.ts +2 -0
  86. package/dist/validate.test.d.ts.map +1 -0
  87. package/dist/validate.test.js +531 -0
  88. package/dist/validate.test.js.map +1 -0
  89. package/docs/agent-setup.md +85 -0
  90. package/docs/agent-workflows.md +103 -0
  91. package/docs/comparison.md +71 -0
  92. package/docs/freshness.md +124 -0
  93. package/docs/inline-mode.md +119 -0
  94. package/docs/linter-support.md +166 -0
  95. package/docs/spec-format.md +194 -0
  96. package/eslint.config.mjs +79 -0
  97. package/examples/CLAUDE.md +54 -0
  98. package/examples/CLAUDE.md.spec.ts +65 -0
  99. package/examples/SKILL.md +50 -0
  100. package/examples/SKILL.md.spec.ts +57 -0
  101. package/fixtures/example-project/CLAUDE.md +11 -0
  102. package/fixtures/example-project/package.json +9 -0
  103. package/fixtures/example-project/src/index.ts +3 -0
  104. package/fixtures/example-project/src/utils.test.ts +2 -0
  105. package/fixtures/example-project/src/utils.ts +3 -0
  106. package/logo.png +0 -0
  107. package/package.json +42 -0
  108. package/research/adoption-strategy.md +111 -0
  109. package/research/agent-integration.md +145 -0
  110. package/research/ai-code-quality.md +197 -0
  111. package/research/code-search-for-agents.md +313 -0
  112. package/research/competitive-landscape.md +163 -0
  113. package/research/doc-freshness.md +516 -0
  114. package/research/executable-specs.md +368 -0
  115. package/research/feature-ideas.md +464 -0
  116. package/research/formal-proofs-for-agents.md +338 -0
  117. package/research/fp-for-agent-harness.md +150 -0
  118. package/research/fp-for-deterministic-ai.md +131 -0
  119. package/research/self-evolving-specs.md +298 -0
  120. package/schemas/claude-md-strict.yml +18 -0
  121. package/schemas/claude-md.yml +6 -0
  122. package/schemas/skill-strict.yml +12 -0
  123. package/schemas/skill.yml +5 -0
  124. package/skills/audit-feedback-loop/SKILL.md +76 -0
  125. package/skills/edit-spec/SKILL.md +131 -0
  126. package/skills/enforce-rules-format/SKILL.md +71 -0
  127. package/skills/generate-logo/SKILL.md +102 -0
  128. package/skills/generate-rule/SKILL.md +90 -0
  129. package/skills/linter-docs/clippy.md +241 -0
  130. package/skills/linter-docs/eslint.md +384 -0
  131. package/skills/linter-docs/pylint.md +288 -0
  132. package/skills/linter-docs/rubocop.md +277 -0
  133. package/skills/linter-docs/ruff.md +187 -0
  134. package/skills/linter-docs/stylelint.md +247 -0
  135. package/skills/migrate-to-spec/SKILL.md +124 -0
  136. package/skills/pr-to-lint-rule/SKILL.md +97 -0
  137. package/skills/strengthen/SKILL.md +168 -0
  138. package/src/action.ts +214 -0
  139. package/src/cli.test.ts +914 -0
  140. package/src/cli.ts +1631 -0
  141. package/src/compile.ts +691 -0
  142. package/src/evolve.ts +781 -0
  143. package/src/freshness.test.ts +449 -0
  144. package/src/freshness.ts +299 -0
  145. package/src/generate-types.ts +448 -0
  146. package/src/inline.test.ts +206 -0
  147. package/src/inline.ts +164 -0
  148. package/src/linters.ts +739 -0
  149. package/src/proofs.test.ts +1314 -0
  150. package/src/proofs.ts +849 -0
  151. package/src/spec.test.ts +1471 -0
  152. package/src/spec.ts +427 -0
  153. package/src/types.ts +117 -0
  154. package/src/validate.test.ts +701 -0
  155. package/src/validate.ts +381 -0
  156. package/tsconfig.json +23 -0
package/src/compile.ts ADDED
@@ -0,0 +1,691 @@
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
+ }