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/evolve.ts ADDED
@@ -0,0 +1,781 @@
1
+ /**
2
+ * vigiles — Evolution engine for self-evolving specifications.
3
+ *
4
+ * AI agents propose mutations. The engine applies them, runs the proof suite,
5
+ * and only accepts mutations that pass all proofs AND improve fitness.
6
+ *
7
+ * Pattern: "LLM proposes, deterministic algorithm disposes."
8
+ */
9
+
10
+ import type { Rule, ClaudeSpec, EnforceRule, GuidanceRule } from "./spec.js";
11
+ import {
12
+ checkMonotonicity,
13
+ findSimilarRules,
14
+ BloomFilter,
15
+ ruleToBloomFilter,
16
+ fitness,
17
+ ruleStrength,
18
+ MerkleHistory,
19
+ type Mutation,
20
+ type ProofReceipt,
21
+ type FitnessResult,
22
+ type ReadonlyMerkleHistory,
23
+ } from "./proofs.js";
24
+ import { computeHash } from "./compile.js";
25
+
26
+ // ---------------------------------------------------------------------------
27
+ // Mutation types
28
+ // ---------------------------------------------------------------------------
29
+
30
+ export interface AddRuleMutation {
31
+ type: "add";
32
+ ruleId: string;
33
+ rule: Rule;
34
+ }
35
+
36
+ export interface RemoveRuleMutation {
37
+ type: "remove";
38
+ ruleId: string;
39
+ }
40
+
41
+ export interface StrengthenMutation {
42
+ type: "strengthen";
43
+ ruleId: string;
44
+ /** For guidance→enforce, provide the linter rule to enforce. */
45
+ linterRule?: string;
46
+ }
47
+
48
+ export interface WeakenMutation {
49
+ type: "weaken";
50
+ ruleId: string;
51
+ justification: string;
52
+ }
53
+
54
+ export interface MergeRulesMutation {
55
+ type: "merge";
56
+ sourceIds: [string, string];
57
+ mergedId: string;
58
+ mergedRule: Rule;
59
+ }
60
+
61
+ export interface RewordMutation {
62
+ type: "reword";
63
+ ruleId: string;
64
+ newText: string;
65
+ }
66
+
67
+ export type SpecMutation =
68
+ | AddRuleMutation
69
+ | RemoveRuleMutation
70
+ | StrengthenMutation
71
+ | WeakenMutation
72
+ | MergeRulesMutation
73
+ | RewordMutation;
74
+
75
+ // ---------------------------------------------------------------------------
76
+ // Mutation application
77
+ // ---------------------------------------------------------------------------
78
+
79
+ export interface MutationError {
80
+ mutation: SpecMutation;
81
+ reason: string;
82
+ }
83
+
84
+ /**
85
+ * Shallow-clone a Rule. Rules are simple value types (primitive fields only),
86
+ * so a spread is sufficient to decouple engine state from the caller's
87
+ * mutation object — without this, later caller-side edits would silently
88
+ * alter accepted engine state without re-running proofs.
89
+ */
90
+ function cloneRule(rule: Rule): Rule {
91
+ return { ...rule };
92
+ }
93
+
94
+ /**
95
+ * Canonical string representation of a rules map for hashing.
96
+ *
97
+ * `JSON.stringify` preserves insertion order, so two logically identical
98
+ * rule maps built in a different order would hash to different values.
99
+ * That would make the Merkle head/specHash comparison in the constructor
100
+ * falsely reject a valid imported history just because the caller
101
+ * reconstructed rules in a different order.
102
+ *
103
+ * Sort by rule id, stringify each entry with its own stable key order,
104
+ * and join with a separator.
105
+ */
106
+ function canonicalRulesJson(rules: Record<string, Rule>): string {
107
+ const sortedIds = Object.keys(rules).sort();
108
+ const entries = sortedIds.map((id) => {
109
+ const rule = rules[id];
110
+ // Also sort the keys within each rule object so { _kind, text } and
111
+ // { text, _kind } produce the same output.
112
+ const ruleKeys = Object.keys(rule).sort();
113
+ const orderedRule: Record<string, unknown> = {};
114
+ for (const k of ruleKeys) {
115
+ orderedRule[k] = (rule as unknown as Record<string, unknown>)[k];
116
+ }
117
+ return [id, orderedRule];
118
+ });
119
+ return JSON.stringify(entries);
120
+ }
121
+
122
+ /**
123
+ * Apply a mutation to a spec's rules, producing a new rule map.
124
+ * Returns null + error if the mutation is invalid.
125
+ */
126
+ export function applyMutation(
127
+ rules: Record<string, Rule>,
128
+ mutation: SpecMutation,
129
+ ): { rules: Record<string, Rule>; error?: MutationError } {
130
+ const next = { ...rules };
131
+
132
+ switch (mutation.type) {
133
+ case "add": {
134
+ if (mutation.ruleId in next) {
135
+ return {
136
+ rules,
137
+ error: {
138
+ mutation,
139
+ reason: `Rule "${mutation.ruleId}" already exists`,
140
+ },
141
+ };
142
+ }
143
+ next[mutation.ruleId] = cloneRule(mutation.rule);
144
+ return { rules: next };
145
+ }
146
+
147
+ case "remove": {
148
+ if (!(mutation.ruleId in next)) {
149
+ return {
150
+ rules,
151
+ error: {
152
+ mutation,
153
+ reason: `Rule "${mutation.ruleId}" not found`,
154
+ },
155
+ };
156
+ }
157
+ const { [mutation.ruleId]: _removed, ...rest } = next;
158
+ void _removed;
159
+ return { rules: rest };
160
+ }
161
+
162
+ case "strengthen": {
163
+ const rule = next[mutation.ruleId];
164
+ if (!rule) {
165
+ return {
166
+ rules,
167
+ error: {
168
+ mutation,
169
+ reason: `Rule "${mutation.ruleId}" not found`,
170
+ },
171
+ };
172
+ }
173
+
174
+ if (rule._kind === "guidance") {
175
+ if (mutation.linterRule) {
176
+ // guidance → enforce
177
+ next[mutation.ruleId] = {
178
+ _kind: "enforce",
179
+ linterRule: mutation.linterRule,
180
+ why: rule.text,
181
+ verify: true,
182
+ } as EnforceRule;
183
+ } else {
184
+ return {
185
+ rules,
186
+ error: {
187
+ mutation,
188
+ reason:
189
+ "Strengthening guidance requires a linterRule to enforce against.",
190
+ },
191
+ };
192
+ }
193
+ } else {
194
+ return {
195
+ rules,
196
+ error: {
197
+ mutation,
198
+ reason: `Rule "${mutation.ruleId}" is already at maximum strength (enforce)`,
199
+ },
200
+ };
201
+ }
202
+ return { rules: next };
203
+ }
204
+
205
+ case "weaken": {
206
+ const rule = next[mutation.ruleId];
207
+ if (!rule) {
208
+ return {
209
+ rules,
210
+ error: {
211
+ mutation,
212
+ reason: `Rule "${mutation.ruleId}" not found`,
213
+ },
214
+ };
215
+ }
216
+
217
+ if (rule._kind === "enforce") {
218
+ next[mutation.ruleId] = {
219
+ _kind: "guidance",
220
+ text: rule.why,
221
+ } as GuidanceRule;
222
+ } else {
223
+ return {
224
+ rules,
225
+ error: {
226
+ mutation,
227
+ reason: `Rule "${mutation.ruleId}" is already at minimum strength (guidance)`,
228
+ },
229
+ };
230
+ }
231
+ return { rules: next };
232
+ }
233
+
234
+ case "merge": {
235
+ const [idA, idB] = mutation.sourceIds;
236
+ if (idA === idB) {
237
+ return {
238
+ rules,
239
+ error: {
240
+ mutation,
241
+ reason: `Merge requires two distinct source rules; got "${idA}" twice`,
242
+ },
243
+ };
244
+ }
245
+ if (!(idA in next) || !(idB in next)) {
246
+ return {
247
+ rules,
248
+ error: {
249
+ mutation,
250
+ reason: `One or both source rules not found: "${idA}", "${idB}"`,
251
+ },
252
+ };
253
+ }
254
+ // mergedId must not collide with an unrelated existing rule —
255
+ // otherwise the assignment would silently overwrite that rule and
256
+ // drop its constraints. Allowed only when mergedId is one of the
257
+ // sources being consumed (renaming-in-place).
258
+ if (
259
+ mutation.mergedId in next &&
260
+ mutation.mergedId !== idA &&
261
+ mutation.mergedId !== idB
262
+ ) {
263
+ return {
264
+ rules,
265
+ error: {
266
+ mutation,
267
+ reason: `Merge target "${mutation.mergedId}" collides with an existing unrelated rule; pick a new id or remove the existing rule first`,
268
+ },
269
+ };
270
+ }
271
+ // Merge must not silently weaken enforcement. The merged rule's
272
+ // strength must be at least as strong as the strongest source —
273
+ // otherwise a caller could launder two enforced rules into a
274
+ // single guidance rule and bypass monotonicity (because the
275
+ // per-call allowWeaken for merge sources means removals don't
276
+ // trigger the violation).
277
+ const sourceStrengths = mutation.sourceIds.map((id) =>
278
+ ruleStrength(next[id]._kind),
279
+ );
280
+ const maxSourceStrength = Math.max(...sourceStrengths);
281
+ const mergedStrength = ruleStrength(mutation.mergedRule._kind);
282
+ if (mergedStrength < maxSourceStrength) {
283
+ return {
284
+ rules,
285
+ error: {
286
+ mutation,
287
+ reason: `Merged rule "${mutation.mergedId}" (${mutation.mergedRule._kind}) is weaker than source rules (strongest was ${maxSourceStrength === 1 ? "enforce" : "guidance"}). Merges may not downgrade enforcement.`,
288
+ },
289
+ };
290
+ }
291
+ const { [idA]: _a, [idB]: _b, ...rest } = next;
292
+ void _a;
293
+ void _b;
294
+ return {
295
+ rules: { ...rest, [mutation.mergedId]: cloneRule(mutation.mergedRule) },
296
+ };
297
+ }
298
+
299
+ case "reword": {
300
+ const rule = next[mutation.ruleId];
301
+ if (!rule) {
302
+ return {
303
+ rules,
304
+ error: {
305
+ mutation,
306
+ reason: `Rule "${mutation.ruleId}" not found`,
307
+ },
308
+ };
309
+ }
310
+
311
+ if (rule._kind === "guidance") {
312
+ next[mutation.ruleId] = { ...rule, text: mutation.newText };
313
+ } else if (rule._kind === "enforce") {
314
+ next[mutation.ruleId] = { ...rule, why: mutation.newText };
315
+ }
316
+ return { rules: next };
317
+ }
318
+ }
319
+ }
320
+
321
+ // ---------------------------------------------------------------------------
322
+ // Proof suite runner
323
+ // ---------------------------------------------------------------------------
324
+
325
+ export interface ProofSuiteResult {
326
+ passed: boolean;
327
+ receipts: ProofReceipt[];
328
+ fitness: FitnessResult;
329
+ }
330
+
331
+ /**
332
+ * Run all proofs on a candidate spec mutation.
333
+ *
334
+ * Proofs:
335
+ * 1. Monotonicity — rules don't weaken (unless explicitly allowed)
336
+ * 2. NCD deduplication — no near-duplicate rules introduced
337
+ * 3. Bloom filter overlap — fast cross-check for token similarity
338
+ */
339
+ export function runProofSuite(
340
+ before: Record<string, Rule>,
341
+ after: Record<string, Rule>,
342
+ options: {
343
+ allowWeaken?: Set<string>;
344
+ ncdThreshold?: number;
345
+ maxTokens?: number;
346
+ } = {},
347
+ ): ProofSuiteResult {
348
+ const receipts: ProofReceipt[] = [];
349
+ const ncdThreshold = options.ncdThreshold ?? 0.3;
350
+
351
+ // 1. Monotonicity
352
+ const mono = checkMonotonicity(before, after, {
353
+ allowWeaken: options.allowWeaken,
354
+ });
355
+ receipts.push({
356
+ name: "monotonicity",
357
+ passed: mono.valid,
358
+ detail: mono.valid
359
+ ? `${mono.strengthened.length} strengthened, ${mono.added.length} added`
360
+ : `${mono.violations.length} violations: ${mono.violations.map((v) => `${v.ruleId} (${v.from}→${v.to})`).join(", ")}`,
361
+ });
362
+
363
+ // 2. NCD deduplication. Only fail on pairs NEWLY INTRODUCED by the
364
+ // candidate change — a repo with historical duplication must not
365
+ // block every unrelated mutation. Compute similar pairs on `after`
366
+ // and `before`, then subtract.
367
+ //
368
+ // Wrapped in try/catch so an unknown rule kind (legacy data / JS
369
+ // caller) surfaces as a clean proof failure rather than crashing
370
+ // the whole audit pipeline.
371
+ try {
372
+ const beforePairs = new Set(
373
+ findSimilarRules(before, ncdThreshold).map((p) =>
374
+ [p.idA, p.idB].sort().join("|"),
375
+ ),
376
+ );
377
+ const similar = findSimilarRules(after, ncdThreshold).filter(
378
+ (p) => !beforePairs.has([p.idA, p.idB].sort().join("|")),
379
+ );
380
+ const ncdPassed = similar.length === 0;
381
+ receipts.push({
382
+ name: "ncd-dedup",
383
+ passed: ncdPassed,
384
+ detail: ncdPassed
385
+ ? "No new near-duplicate rules"
386
+ : `${similar.length} new near-duplicate pairs: ${similar.map((p) => `${p.idA}↔${p.idB} (${p.distance.toFixed(3)})`).join(", ")}`,
387
+ });
388
+ } catch (e) {
389
+ receipts.push({
390
+ name: "ncd-dedup",
391
+ passed: false,
392
+ detail: `Similarity check failed: ${e instanceof Error ? e.message : String(e)}`,
393
+ });
394
+ }
395
+
396
+ // 3. Bloom filter cross-check (fast sanity check for token overlap).
397
+ // Baseline is built from rules that still exist in `after` — any
398
+ // rule removed by the candidate mutation (e.g. the two sources of
399
+ // a merge) must be excluded, otherwise the newly introduced merge
400
+ // rule would collide against its own sources and the merge would
401
+ // be rejected for the very similarity it was meant to deduplicate.
402
+ //
403
+ // Wrapped for the same reason as NCD: ruleToBloomFilter → ruleToText
404
+ // throws on unknown rule kinds.
405
+ try {
406
+ let bloomPassed = true;
407
+ const newRuleIds = Object.keys(after).filter((id) => !(id in before));
408
+ const existingFilters = new Map<string, BloomFilter>();
409
+
410
+ for (const [id, rule] of Object.entries(before)) {
411
+ if (!(id in after)) continue; // removed — skip
412
+ existingFilters.set(id, ruleToBloomFilter(rule));
413
+ }
414
+
415
+ const bloomOverlaps: string[] = [];
416
+ for (const newId of newRuleIds) {
417
+ const newFilter = ruleToBloomFilter(after[newId]);
418
+ for (const [existingId, existingFilter] of existingFilters) {
419
+ if (existingId === newId) continue;
420
+ try {
421
+ const similarity = BloomFilter.jaccardSimilarity(
422
+ newFilter,
423
+ existingFilter,
424
+ );
425
+ if (similarity > 0.7) {
426
+ bloomOverlaps.push(
427
+ `${newId}↔${existingId} (jaccard=${similarity.toFixed(3)})`,
428
+ );
429
+ bloomPassed = false;
430
+ }
431
+ } catch {
432
+ // Different filter sizes — skip comparison
433
+ }
434
+ }
435
+ }
436
+
437
+ receipts.push({
438
+ name: "bloom-overlap",
439
+ passed: bloomPassed,
440
+ detail: bloomPassed
441
+ ? "No suspicious token overlap"
442
+ : `High overlap: ${bloomOverlaps.join(", ")}`,
443
+ });
444
+ } catch (e) {
445
+ receipts.push({
446
+ name: "bloom-overlap",
447
+ passed: false,
448
+ detail: `Bloom filter check failed: ${e instanceof Error ? e.message : String(e)}`,
449
+ });
450
+ }
451
+
452
+ // Compute fitness of the new spec. Guard against bad runtime data: fitness
453
+ // recomputes similarity internally and would otherwise crash on unknown
454
+ // rule kinds (legacy `check`, JS caller), propagating the throw up through
455
+ // the engine. Fall back to a neutral fitness and mark the proof failed.
456
+ const specForFitness = { rules: after } as ClaudeSpec;
457
+ let fitnessResult: FitnessResult;
458
+ try {
459
+ fitnessResult = fitness(specForFitness, {
460
+ maxTokens: options.maxTokens,
461
+ ncdThreshold,
462
+ });
463
+ } catch (e) {
464
+ receipts.push({
465
+ name: "fitness",
466
+ passed: false,
467
+ detail: `Fitness computation failed: ${e instanceof Error ? e.message : String(e)}`,
468
+ });
469
+ fitnessResult = {
470
+ score: 0,
471
+ coverage: 0,
472
+ redundancy: 0,
473
+ budgetPressure: 0,
474
+ };
475
+ }
476
+
477
+ const allPassed = receipts.every((r) => r.passed);
478
+ return { passed: allPassed, receipts, fitness: fitnessResult };
479
+ }
480
+
481
+ // ---------------------------------------------------------------------------
482
+ // Evolution Engine
483
+ // ---------------------------------------------------------------------------
484
+
485
+ export interface EvolutionResult {
486
+ accepted: boolean;
487
+ mutation: SpecMutation;
488
+ proofs: ProofSuiteResult;
489
+ beforeFitness: FitnessResult;
490
+ afterFitness: FitnessResult;
491
+ historyHash?: string;
492
+ error?: string;
493
+ }
494
+
495
+ /**
496
+ * The evolution engine: the core of the self-evolving spec system.
497
+ *
498
+ * Maintains a spec, a proof suite, and a Merkle history.
499
+ * Accepts mutations that pass all proofs and improve (or maintain) fitness.
500
+ */
501
+ export class EvolutionEngine {
502
+ private rules: Record<string, Rule>;
503
+ private readonly history: MerkleHistory;
504
+ private readonly options: {
505
+ allowWeaken: Set<string>;
506
+ ncdThreshold: number;
507
+ maxTokens: number;
508
+ /** Accept mutations that don't improve fitness (only require proofs pass). */
509
+ acceptNeutral: boolean;
510
+ };
511
+
512
+ constructor(
513
+ initialRules: Record<string, Rule>,
514
+ options: {
515
+ allowWeaken?: Set<string>;
516
+ ncdThreshold?: number;
517
+ maxTokens?: number;
518
+ acceptNeutral?: boolean;
519
+ history?: MerkleHistory;
520
+ } = {},
521
+ ) {
522
+ this.rules = Object.fromEntries(
523
+ Object.entries(initialRules).map(([id, rule]) => [id, cloneRule(rule)]),
524
+ );
525
+ // Snapshot the supplied history by serializing and rehydrating, so a
526
+ // caller that retains their reference to the original MerkleHistory
527
+ // can't append to the audit trail behind the engine's back. Without
528
+ // this, external code could bypass propose()'s proof + fitness gates
529
+ // and mutate provenance — defeating the whole tamper-evident point.
530
+ this.history = options.history
531
+ ? MerkleHistory.fromJSON(options.history.toJSON())
532
+ : new MerkleHistory();
533
+ this.options = {
534
+ // Clone the Set so a caller mutating their own reference after
535
+ // construction can't silently change acceptance policy (e.g. by
536
+ // adding a rule id to allow a weakening mutation that would
537
+ // otherwise be rejected).
538
+ allowWeaken: new Set(options.allowWeaken ?? []),
539
+ ncdThreshold: options.ncdThreshold ?? 0.3,
540
+ maxTokens: options.maxTokens ?? 2000,
541
+ acceptNeutral: options.acceptNeutral ?? false,
542
+ };
543
+
544
+ // If a history was supplied, verify its chain integrity and that its
545
+ // head corresponds to initialRules. A stale or mismatched history would
546
+ // produce misleading provenance for subsequent mutations.
547
+ if (options.history) {
548
+ const verification = this.history.verify();
549
+ if (!verification.valid) {
550
+ throw new Error(
551
+ `Supplied Merkle history is invalid at node ${String(verification.invalidAt)}. Tamper detected or corrupted chain.`,
552
+ );
553
+ }
554
+ if (this.history.length > 0) {
555
+ const expectedHash = computeHash(canonicalRulesJson(this.rules));
556
+ const head = this.history.head();
557
+ if (head && head.specHash !== expectedHash) {
558
+ throw new Error(
559
+ `Supplied Merkle history head does not match initialRules. ` +
560
+ `History head specHash="${head.specHash}", expected="${expectedHash}". ` +
561
+ `The history and rules are mismatched; refusing to record new mutations on the wrong chain.`,
562
+ );
563
+ }
564
+ }
565
+ }
566
+
567
+ // Record genesis state
568
+ if (this.history.length === 0) {
569
+ const specHash = computeHash(canonicalRulesJson(this.rules));
570
+ this.history.append(
571
+ specHash,
572
+ {
573
+ type: "add",
574
+ ruleIds: Object.keys(this.rules),
575
+ description: "Genesis",
576
+ },
577
+ [{ name: "genesis", passed: true }],
578
+ );
579
+ }
580
+ }
581
+
582
+ /**
583
+ * Get current rules as a deep defensive copy. Callers cannot mutate
584
+ * engine state through the returned map because every rule is cloned —
585
+ * otherwise a JS caller or a TS cast could silently alter accepted
586
+ * state without running proofs or appending to history.
587
+ */
588
+ getRules(): Record<string, Rule> {
589
+ return Object.fromEntries(
590
+ Object.entries(this.rules).map(([id, rule]) => [id, cloneRule(rule)]),
591
+ );
592
+ }
593
+
594
+ /**
595
+ * Get a snapshot of the Merkle history.
596
+ *
597
+ * Returns a freshly deserialized copy so even a JS caller that casts
598
+ * to MerkleHistory and calls `.append()` cannot inject nodes into the
599
+ * engine's real chain. The snapshot is read-only at the TS level
600
+ * (ReadonlyMerkleHistory) and isolated at the runtime level (separate
601
+ * instance via toJSON/fromJSON).
602
+ */
603
+ getHistory(): ReadonlyMerkleHistory {
604
+ return MerkleHistory.fromJSON(this.history.toJSON());
605
+ }
606
+
607
+ /** Get current fitness. */
608
+ getFitness(): FitnessResult {
609
+ return fitness({ rules: this.rules } as ClaudeSpec, {
610
+ maxTokens: this.options.maxTokens,
611
+ ncdThreshold: this.options.ncdThreshold,
612
+ });
613
+ }
614
+
615
+ /**
616
+ * Propose a mutation. The engine applies it, runs proofs, and accepts or rejects.
617
+ *
618
+ * Returns a detailed result including proof receipts and fitness comparison.
619
+ */
620
+ propose(mutation: SpecMutation): EvolutionResult {
621
+ // Guard baseline fitness: if engine state contains a malformed rule
622
+ // (legacy _kind, JS caller, cast bypass), fitness() reaches ruleToText
623
+ // and throws BEFORE we enter runProofSuite's own try/catch. Return a
624
+ // clean rejection with a neutral fitness instead of crashing the flow.
625
+ let beforeFitness: FitnessResult;
626
+ try {
627
+ beforeFitness = this.getFitness();
628
+ } catch (e) {
629
+ const neutral: FitnessResult = {
630
+ score: 0,
631
+ coverage: 0,
632
+ redundancy: 0,
633
+ budgetPressure: 0,
634
+ };
635
+ return {
636
+ accepted: false,
637
+ mutation,
638
+ proofs: {
639
+ passed: false,
640
+ receipts: [
641
+ {
642
+ name: "baseline-fitness",
643
+ passed: false,
644
+ detail: `Baseline fitness failed: ${e instanceof Error ? e.message : String(e)}`,
645
+ },
646
+ ],
647
+ fitness: neutral,
648
+ },
649
+ beforeFitness: neutral,
650
+ afterFitness: neutral,
651
+ error: `Baseline fitness failed: ${e instanceof Error ? e.message : String(e)}`,
652
+ };
653
+ }
654
+
655
+ // Apply the mutation
656
+ const { rules: candidateRules, error } = applyMutation(
657
+ this.rules,
658
+ mutation,
659
+ );
660
+ if (error) {
661
+ return {
662
+ accepted: false,
663
+ mutation,
664
+ proofs: { passed: false, receipts: [], fitness: beforeFitness },
665
+ beforeFitness,
666
+ afterFitness: beforeFitness,
667
+ error: error.reason,
668
+ };
669
+ }
670
+
671
+ // For merge mutations, the source rule IDs are removed by design.
672
+ // Add them to a per-call allowWeaken set so checkMonotonicity doesn't
673
+ // reject the removal as a monotonicity violation — merging is the
674
+ // intended constraint-reducing operation, not silent deletion.
675
+ const perCallAllowWeaken = new Set(this.options.allowWeaken);
676
+ if (mutation.type === "merge") {
677
+ for (const id of mutation.sourceIds) {
678
+ perCallAllowWeaken.add(id);
679
+ }
680
+ }
681
+
682
+ // Run proof suite
683
+ const proofResult = runProofSuite(this.rules, candidateRules, {
684
+ allowWeaken: perCallAllowWeaken,
685
+ ncdThreshold: this.options.ncdThreshold,
686
+ maxTokens: this.options.maxTokens,
687
+ });
688
+
689
+ const afterFitness = proofResult.fitness;
690
+
691
+ // Decision: all proofs pass AND fitness doesn't decrease.
692
+ //
693
+ // `acceptNeutral` relaxes strict improvement (>) to ≥ — i.e. it accepts
694
+ // mutations that keep fitness the same. It must NOT accept regressions:
695
+ // a previous version short-circuited on acceptNeutral, which meant any
696
+ // proof-passing mutation was accepted regardless of score, silently
697
+ // driving spec quality downward over time.
698
+ const fitnessOk = this.options.acceptNeutral
699
+ ? afterFitness.score >= beforeFitness.score
700
+ : afterFitness.score > beforeFitness.score;
701
+ const accepted = proofResult.passed && fitnessOk;
702
+
703
+ let historyHash: string | undefined;
704
+
705
+ if (accepted) {
706
+ // Accept the mutation
707
+ this.rules = candidateRules;
708
+
709
+ // Record in Merkle history
710
+ const specHash = computeHash(canonicalRulesJson(this.rules));
711
+ const historyMutation: Mutation = {
712
+ type: mutation.type === "merge" ? "merge" : mutation.type,
713
+ ruleIds:
714
+ mutation.type === "merge"
715
+ ? [...mutation.sourceIds, mutation.mergedId]
716
+ : ["ruleId" in mutation ? mutation.ruleId : "unknown"],
717
+ description: describeMutation(mutation),
718
+ };
719
+ // Defensive copy: the same proofResult is returned to the caller,
720
+ // so without this clone a caller mutating result.proofs.receipts
721
+ // would retroactively alter the stored history node.
722
+ historyHash = this.history.append(
723
+ specHash,
724
+ historyMutation,
725
+ proofResult.receipts.map((r) => ({ ...r })),
726
+ );
727
+ }
728
+
729
+ return {
730
+ accepted,
731
+ mutation,
732
+ proofs: proofResult,
733
+ beforeFitness,
734
+ afterFitness,
735
+ historyHash,
736
+ error: !proofResult.passed
737
+ ? `Proofs failed: ${proofResult.receipts
738
+ .filter((r) => !r.passed)
739
+ .map((r) => r.name)
740
+ .join(", ")}`
741
+ : !fitnessOk
742
+ ? `Fitness decreased: ${beforeFitness.score.toFixed(3)} → ${afterFitness.score.toFixed(3)}`
743
+ : undefined,
744
+ };
745
+ }
746
+
747
+ /**
748
+ * Propose multiple mutations in sequence.
749
+ * Stops at the first rejection unless `continueOnReject` is true.
750
+ */
751
+ proposeAll(
752
+ mutations: SpecMutation[],
753
+ options: { continueOnReject?: boolean } = {},
754
+ ): EvolutionResult[] {
755
+ const results: EvolutionResult[] = [];
756
+ for (const mutation of mutations) {
757
+ const result = this.propose(mutation);
758
+ results.push(result);
759
+ if (!result.accepted && !options.continueOnReject) break;
760
+ }
761
+ return results;
762
+ }
763
+ }
764
+
765
+ /** Human-readable description of a mutation. */
766
+ function describeMutation(mutation: SpecMutation): string {
767
+ switch (mutation.type) {
768
+ case "add":
769
+ return `Add rule "${mutation.ruleId}" (${mutation.rule._kind})`;
770
+ case "remove":
771
+ return `Remove rule "${mutation.ruleId}"`;
772
+ case "strengthen":
773
+ return `Strengthen rule "${mutation.ruleId}"${mutation.linterRule ? ` → enforce(${mutation.linterRule})` : ""}`;
774
+ case "weaken":
775
+ return `Weaken rule "${mutation.ruleId}": ${mutation.justification}`;
776
+ case "merge":
777
+ return `Merge "${mutation.sourceIds[0]}" + "${mutation.sourceIds[1]}" → "${mutation.mergedId}"`;
778
+ case "reword":
779
+ return `Reword rule "${mutation.ruleId}"`;
780
+ }
781
+ }