tribunal-kit 5.8.1 → 5.8.3

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.
@@ -0,0 +1,173 @@
1
+ /**
2
+ * signal_detector.js — Tribunal Kit Signal Detector
3
+ * ====================================================
4
+ * Parses runtime log files, test logs, and build transcripts to extract
5
+ * structured "Improvement Signals" (errors, bottlenecks, gaps).
6
+ *
7
+ * Supported log sources/patterns:
8
+ * - JS/TS Node Stack Traces
9
+ * - Python Stack Traces
10
+ * - Rust Compiler Errors
11
+ * - Jest/Vitest Test Failures
12
+ * - ESLint Warnings/Errors
13
+ * - Performance measurements (e.g. "Slow query", "duration: 1200ms")
14
+ */
15
+
16
+ "use strict";
17
+
18
+ const fs = require("fs");
19
+ const path = require("path");
20
+
21
+ const SIGNAL_TYPES = {
22
+ ERROR: "log_error",
23
+ PERF: "perf_bottleneck",
24
+ GAP: "capability_gap",
25
+ };
26
+
27
+ /**
28
+ * Parses a string log and extracts list of typed signals.
29
+ * @param {string} logText
30
+ * @returns {Array<{type: string, file: string|null, line: number|null, message: string, context: string}>}
31
+ */
32
+ function detectSignals(logText) {
33
+ const signals = [];
34
+ const lines = logText.split(/\r?\n/);
35
+
36
+ // 1. Check for stack traces and syntax/runtime errors
37
+ // JS/TS stack trace line: " at Object.foo (src/utils.js:12:45)" or " at src/utils.js:12:45"
38
+ const jsStackRegex = /\bat\s+(?:async\s+)?(?:[^(]+\()?([^:()\s]+):(\d+):(\d+)\)?/;
39
+ // Python File location: ' File "src/main.py", line 12, in <module>'
40
+ const pyStackRegex = /File\s+"([^"]+)"\s*,\s*line\s*(\d+)/;
41
+ // Rust location: ' --> src/main.rs:12:34'
42
+ const rustRegex = /-->\s*([^:\s]+):(\d+):(\d+)/;
43
+ // ESLint standard line: ' /path/to/file.js:12:34: error: Message' or ' 12:34 error Message'
44
+ // But ESLint often has file heading first. So let's handle inline: 'file.js: line 12, col 34, Error - Message'
45
+ const eslintInlineRegex = /([^:\s]+):\s*line\s*(\d+)\s*,\s*col\s*\d+,\s*(?:Error|Warning)\s*-\s*(.+)/i;
46
+ // Also standard unix format: '/path/to/file.js:12:34: error message'
47
+ const unixErrorRegex = /^([^:\s\(\)]+):(\d+):(?:\d+:)?\s*(error|warning|info):?\s*(.+)/i;
48
+
49
+ // Let's also scan for performance bottlenecks
50
+ const slowQueryRegex = /slow\s+query|duration:\s*(\d+)ms|execution\s+time:\s*(\d+)ms|response\s+time\s*>\s*(\d+)ms/i;
51
+
52
+ let currentFile = null;
53
+
54
+ for (let i = 0; i < lines.length; i++) {
55
+ const line = lines[i];
56
+
57
+ // Detect Jest/Vitest file heading: "FAIL src/utils.test.js"
58
+ if (line.startsWith("FAIL ")) {
59
+ const match = line.match(/^FAIL\s+(\S+)/);
60
+ if (match) {
61
+ currentFile = match[1];
62
+ signals.push({
63
+ type: SIGNAL_TYPES.ERROR,
64
+ file: currentFile,
65
+ line: null,
66
+ message: `Test suite failed: ${path.basename(currentFile)}`,
67
+ context: lines.slice(Math.max(0, i - 1), Math.min(lines.length, i + 5)).join("\n"),
68
+ });
69
+ }
70
+ continue;
71
+ }
72
+
73
+ // JS Stack Trace Match
74
+ const jsMatch = line.match(jsStackRegex);
75
+ if (jsMatch && !line.includes("node_modules")) {
76
+ const filePath = jsMatch[1].replace(/\\/g, "/");
77
+ signals.push({
78
+ type: SIGNAL_TYPES.ERROR,
79
+ file: filePath,
80
+ line: parseInt(jsMatch[2], 10),
81
+ message: `Stack Trace Error: ${line.trim()}`,
82
+ context: lines.slice(Math.max(0, i - 2), Math.min(lines.length, i + 3)).join("\n"),
83
+ });
84
+ continue;
85
+ }
86
+
87
+ // Python Stack Trace Match
88
+ const pyMatch = line.match(pyStackRegex);
89
+ if (pyMatch) {
90
+ const filePath = pyMatch[1].replace(/\\/g, "/");
91
+ signals.push({
92
+ type: SIGNAL_TYPES.ERROR,
93
+ file: filePath,
94
+ line: parseInt(pyMatch[2], 10),
95
+ message: `Python Exception: ${lines[i+1] ? lines[i+1].trim() : line.trim()}`,
96
+ context: lines.slice(Math.max(0, i - 1), Math.min(lines.length, i + 4)).join("\n"),
97
+ });
98
+ continue;
99
+ }
100
+
101
+ // Rust Compiler Error Match
102
+ const rustMatch = line.match(rustRegex);
103
+ if (rustMatch) {
104
+ const filePath = rustMatch[1].replace(/\\/g, "/");
105
+ signals.push({
106
+ type: SIGNAL_TYPES.ERROR,
107
+ file: filePath,
108
+ line: parseInt(rustMatch[2], 10),
109
+ message: `Rust compilation failure: ${lines[Math.max(0, i - 1)].trim()}`,
110
+ context: lines.slice(Math.max(0, i - 2), Math.min(lines.length, i + 5)).join("\n"),
111
+ });
112
+ continue;
113
+ }
114
+
115
+ // ESLint Inline Match
116
+ const eslintMatch = line.match(eslintInlineRegex);
117
+ if (eslintMatch) {
118
+ signals.push({
119
+ type: SIGNAL_TYPES.ERROR,
120
+ file: eslintMatch[1].replace(/\\/g, "/"),
121
+ line: parseInt(eslintMatch[2], 10),
122
+ message: `Linter Violation: ${eslintMatch[3]}`,
123
+ context: line.trim(),
124
+ });
125
+ continue;
126
+ }
127
+
128
+ // Unix Error Format Match
129
+ const unixMatch = line.match(unixErrorRegex);
130
+ if (unixMatch && !line.includes("node_modules")) {
131
+ const filePath = unixMatch[1].replace(/\\/g, "/");
132
+ signals.push({
133
+ type: SIGNAL_TYPES.ERROR,
134
+ file: filePath,
135
+ line: parseInt(unixMatch[2], 10),
136
+ message: `${unixMatch[3].toUpperCase()}: ${unixMatch[4]}`,
137
+ context: line.trim(),
138
+ });
139
+ continue;
140
+ }
141
+
142
+ // Slow Query/Performance Match
143
+ const slowMatch = line.match(slowQueryRegex);
144
+ if (slowMatch) {
145
+ signals.push({
146
+ type: SIGNAL_TYPES.PERF,
147
+ file: currentFile || null,
148
+ line: null,
149
+ message: `Performance warning: ${line.trim()}`,
150
+ context: lines.slice(Math.max(0, i - 1), Math.min(lines.length, i + 2)).join("\n"),
151
+ });
152
+ continue;
153
+ }
154
+ }
155
+
156
+ // Deduplicate near-identical signals (same file, line, and message type)
157
+ const unique = [];
158
+ const keys = new Set();
159
+ for (const s of signals) {
160
+ const key = `${s.type}:${s.file}:${s.line}:${s.message.substring(0, 40)}`;
161
+ if (!keys.has(key)) {
162
+ keys.add(key);
163
+ unique.push(s);
164
+ }
165
+ }
166
+
167
+ return unique;
168
+ }
169
+
170
+ module.exports = {
171
+ detectSignals,
172
+ SIGNAL_TYPES,
173
+ };
@@ -487,50 +487,209 @@ async function callLlmApi(prompt, provider, apiKey) {
487
487
  return null;
488
488
  }
489
489
 
490
+ // ── Commands ──────────────────────────────────────────────────────────────────
491
+ // ── Strategy Filtering ────────────────────────────────────────────────────────
492
+ function applyStrategyFilter(signals, strategy) {
493
+ if (strategy === "repair-only") {
494
+ return signals.filter((s) => s.type === "log_error");
495
+ }
496
+ return signals;
497
+ }
498
+
499
+ // ── Log Reflection Prompt ─────────────────────────────────────────────────────
500
+ function generateLogReflectionPrompt(delta) {
501
+ return `You are analyzing runtime log signals (errors, tracebacks, and warnings) from an AI agent's execution.
502
+ Your job is to identify systemic coding issues and extract evolution assets:
503
+ 1. **Idioms (Genes)**: Coding rules/conventions to prevent the AI from generating this mistake again.
504
+ 2. **Cases (Capsules)**: Rejection precedents (offending code pattern and why it failed) to block reviewers from approving it.
505
+
506
+ Rules:
507
+ - Return ONLY a YAML structure containing 'idioms:' and 'cases:'. No markdown boxes, no conversational prose.
508
+ - Maximum 3 idioms and 3 cases.
509
+ - If an asset category is not applicable, leave it empty (e.g., 'idioms: []').
510
+
511
+ Log Signals:
512
+ \`\`\`
513
+ ${delta.slice(0, 1800)}
514
+ \`\`\`
515
+
516
+ Output format (YAML only):
517
+ idioms:
518
+ - pattern: "<code convention or rule to avoid this error>"
519
+ reason: "<why it is needed based on the error>"
520
+ domain: "<backend|frontend|database|security|performance|general>"
521
+ cases:
522
+ - pattern: "<offending code snippet or error-triggering pattern>"
523
+ reason: "<specific runtime error / traceback reason>"
524
+ domain: "<backend|frontend|database|security|performance|general>"
525
+ verdict: "REJECTED"
526
+ `;
527
+ }
528
+
529
+ // ── Combined YAML Parser ──────────────────────────────────────────────────────
530
+ function parseEvolutionYaml(response) {
531
+ const idioms = [];
532
+ const cases = [];
533
+ let currentSection = null; // 'idioms' | 'cases'
534
+ let current = null;
535
+
536
+ const lines = response.split("\n");
537
+ for (const line of lines) {
538
+ const trimmed = line.trim();
539
+ if (trimmed === "idioms:") {
540
+ if (current) {
541
+ if (currentSection === "idioms") idioms.push(current);
542
+ if (currentSection === "cases") cases.push(current);
543
+ }
544
+ currentSection = "idioms";
545
+ current = null;
546
+ continue;
547
+ }
548
+ if (trimmed === "cases:") {
549
+ if (current) {
550
+ if (currentSection === "idioms") idioms.push(current);
551
+ if (currentSection === "cases") cases.push(current);
552
+ }
553
+ currentSection = "cases";
554
+ current = null;
555
+ continue;
556
+ }
557
+
558
+ if (trimmed.startsWith("- pattern:") || (trimmed.startsWith("- ") && trimmed.includes("pattern:"))) {
559
+ if (current) {
560
+ if (currentSection === "idioms") idioms.push(current);
561
+ if (currentSection === "cases") cases.push(current);
562
+ }
563
+ let pat = "";
564
+ if (trimmed.startsWith("- pattern:")) {
565
+ pat = trimmed.substring("- pattern:".length).trim();
566
+ } else {
567
+ pat = trimmed.split("pattern:", 2)[1].trim();
568
+ }
569
+ current = { pattern: pat.replace(/^['"]|['"]$/g, "") };
570
+ } else if (trimmed.startsWith("reason:") && current) {
571
+ current.reason = trimmed.substring("reason:".length).trim().replace(/^['"]|['"]$/g, "");
572
+ } else if (trimmed.startsWith("domain:") && current) {
573
+ current.domain = trimmed.substring("domain:".length).trim().replace(/^['"]|['"]$/g, "");
574
+ } else if (trimmed.startsWith("verdict:") && current) {
575
+ current.verdict = trimmed.substring("verdict:".length).trim().replace(/^['"]|['"]$/g, "");
576
+ }
577
+ }
578
+
579
+ if (current) {
580
+ if (currentSection === "idioms") idioms.push(current);
581
+ if (currentSection === "cases") cases.push(current);
582
+ }
583
+
584
+ return { idioms, cases };
585
+ }
586
+
490
587
  // ── Commands ──────────────────────────────────────────────────────────────────
491
588
  async function cmdDigest(args) {
492
589
  const dryRun = args.includes("--dry-run");
493
590
  const diffMode = args.includes("--head") ? "head" : "staged";
494
591
 
592
+ const logArg = args.find((a) => a.startsWith("--log="));
593
+ const logFile = logArg ? logArg.split("=").slice(1).join("=") : null;
594
+
595
+ const strategyArg = args.find((a) => a.startsWith("--strategy="));
596
+ const strategy = strategyArg ? strategyArg.split("=").slice(1).join("=") : "balanced";
597
+
495
598
  console.log(
496
599
  `\n${BOLD}${CYAN}━━━ Skill Evolution — Digest Cycle ━━━━━━━━━━━━━━━━${RESET}`,
497
600
  );
498
601
  if (dryRun)
499
602
  console.log(` ${YELLOW}DRY RUN — no files will be written${RESET}\n`);
500
603
 
501
- console.log(` ${DIM}[1/5] Fetching git diff (${diffMode})...${RESET}`);
502
- const rawDiff = getGitDiff(diffMode);
503
- if (!rawDiff.trim()) {
604
+ let rawDiff = "";
605
+ let delta = "";
606
+ let rawTokens = 0;
607
+ let deltaTokens = 0;
608
+ let logSignals = [];
609
+ const isLogMode = !!logFile;
610
+
611
+ if (isLogMode) {
612
+ console.log(` ${DIM}[1/5] Reading log file: ${logFile}...${RESET}`);
613
+ if (!fs.existsSync(logFile)) {
614
+ console.log(` ${RED}✖ Log file not found: ${logFile}${RESET}\n`);
615
+ return;
616
+ }
617
+ const logText = fs.readFileSync(logFile, "utf8");
618
+ rawTokens = countTokensEstimate(logText);
619
+
504
620
  console.log(
505
- ` ${YELLOW} No diff found. Commit or stage changes first.${RESET}`,
621
+ ` ${DIM}[2/5] Extracting signals from log (Signal Detector)...${RESET}`,
506
622
  );
623
+ const { detectSignals } = require("./signal_detector");
624
+ const signals = detectSignals(logText);
625
+
626
+ if (signals.length === 0) {
627
+ console.log(
628
+ ` ${GREEN}✔ No errors or performance signals found in logs.${RESET}`,
629
+ );
630
+ console.log(` ${DIM} No self-evolution reflection needed.${RESET}\n`);
631
+ return;
632
+ }
633
+
634
+ logSignals = applyStrategyFilter(signals, strategy);
635
+ if (logSignals.length === 0) {
636
+ console.log(
637
+ ` ${YELLOW}⚠ Strategy filter [${strategy}] filtered out all signals.${RESET}\n`,
638
+ );
639
+ return;
640
+ }
641
+
507
642
  console.log(
508
- ` ${DIM}Tip: Use --head to diff against the last commit.${RESET}\n`,
643
+ ` ${GREEN} Filtered to ${logSignals.length} signal(s) using [${strategy}] strategy.${RESET}`,
509
644
  );
510
- return;
511
- }
512
645
 
513
- const rawTokens = countTokensEstimate(rawDiff);
514
- console.log(
515
- ` ${DIM} Raw diff: ~${rawTokens} tokens (${rawDiff.length} chars)${RESET}`,
516
- );
646
+ delta = logSignals
647
+ .map((s) => {
648
+ let chunk = `--- SIGNAL: ${s.type} ---\n`;
649
+ if (s.file) chunk += `File: ${s.file}${s.line ? `:${s.line}` : ""}\n`;
650
+ chunk += `Message: ${s.message}\n`;
651
+ chunk += `Context:\n${s.context}\n`;
652
+ return chunk;
653
+ })
654
+ .join("\n\n");
655
+
656
+ deltaTokens = countTokensEstimate(delta);
657
+ } else {
658
+ console.log(` ${DIM}[1/5] Fetching git diff (${diffMode})...${RESET}`);
659
+ rawDiff = getGitDiff(diffMode);
660
+ if (!rawDiff.trim()) {
661
+ console.log(
662
+ ` ${YELLOW}⚠ No diff found. Commit or stage changes first.${RESET}`,
663
+ );
664
+ console.log(
665
+ ` ${DIM}Tip: Use --head to diff against the last commit.${RESET}\n`,
666
+ );
667
+ return;
668
+ }
517
669
 
518
- console.log(
519
- ` ${DIM}[2/5] Extracting architectural delta (Semantic Filter)...${RESET}`,
520
- );
521
- const delta = semanticDelta(rawDiff, 2);
522
- if (!delta.trim()) {
670
+ rawTokens = countTokensEstimate(rawDiff);
523
671
  console.log(
524
- ` ${GREEN} Delta is 100% trivial (whitespace/comments/imports only).${RESET}`,
672
+ ` ${DIM} Raw diff: ~${rawTokens} tokens (${rawDiff.length} chars)${RESET}`,
525
673
  );
674
+
526
675
  console.log(
527
- ` ${DIM} No LLM call needed. Zero tokens consumed.${RESET}\n`,
676
+ ` ${DIM}[2/5] Extracting architectural delta (Semantic Filter)...${RESET}`,
528
677
  );
529
- return;
678
+ delta = semanticDelta(rawDiff, 2);
679
+ if (!delta.trim()) {
680
+ console.log(
681
+ ` ${GREEN}✔ Delta is 100% trivial (whitespace/comments/imports only).${RESET}`,
682
+ );
683
+ console.log(
684
+ ` ${DIM} No LLM call needed. Zero tokens consumed.${RESET}\n`,
685
+ );
686
+ return;
687
+ }
688
+
689
+ deltaTokens = countTokensEstimate(delta);
530
690
  }
531
691
 
532
- const deltaTokens = countTokensEstimate(delta);
533
- const savedTokens = rawTokens - deltaTokens;
692
+ const savedTokens = Math.max(0, rawTokens - deltaTokens);
534
693
  const savedPct = Math.floor((savedTokens / Math.max(rawTokens, 1)) * 100);
535
694
  console.log(
536
695
  ` ${GREEN}✔ Filtered to ~${deltaTokens} tokens (${savedPct}% reduction, saved ~${savedTokens} tokens)${RESET}`,
@@ -546,7 +705,7 @@ async function cmdDigest(args) {
546
705
  }
547
706
  if (delta.split("\n").length > 20)
548
707
  console.log(
549
- ` ${DIM}... (${delta.split("\n").length - 20} more lines)${RESET}`,
708
+ ` ... (${delta.split("\n").length - 20} more lines)`,
550
709
  );
551
710
 
552
711
  if (dryRun) {
@@ -559,8 +718,10 @@ async function cmdDigest(args) {
559
718
  return;
560
719
  }
561
720
 
562
- // GENERATE: Auto-LLM call. Tries API first, falls back to manual paste if no key.
563
- const reflectionPrompt = generateReflectionPrompt(delta);
721
+ // GENERATE: Auto-LLM call.
722
+ const reflectionPrompt = isLogMode
723
+ ? generateLogReflectionPrompt(delta)
724
+ : generateReflectionPrompt(delta);
564
725
  let llmResponse = "";
565
726
 
566
727
  let llmCreds = detectLlmProvider();
@@ -582,12 +743,12 @@ async function cmdDigest(args) {
582
743
  console.log(
583
744
  ` ${YELLOW}⚠ API call failed — falling back to manual mode${RESET}`,
584
745
  );
585
- llmCreds = null; // triggers manual fallback below
746
+ llmCreds = null;
586
747
  }
587
748
  }
588
749
 
589
750
  if (!llmCreds || !llmResponse) {
590
- // Manual fallback: copy-paste mode (no API key configured)
751
+ // Manual fallback: copy-paste mode
591
752
  console.log(
592
753
  `\n ${DIM}[3/5] LLM Reflection — copy the prompt below and paste the response${RESET}`,
593
754
  );
@@ -619,21 +780,42 @@ async function cmdDigest(args) {
619
780
  llmResponse = responseLines.join("\n");
620
781
  }
621
782
 
622
- console.log(`\n ${DIM}[4/5] Parsing idioms...${RESET}`);
623
- const newIdioms = parseLlmYamlResponse(llmResponse);
624
- if (!newIdioms.length) {
625
- console.log(` ${YELLOW}⚠ No idioms extracted from LLM response.${RESET}`);
783
+ console.log(`\n ${DIM}[4/5] Parsing idioms/cases...${RESET}`);
784
+ let newIdioms = [];
785
+ let newCases = [];
786
+
787
+ if (isLogMode) {
788
+ const parsed = parseEvolutionYaml(llmResponse);
789
+ newIdioms = parsed.idioms;
790
+ newCases = parsed.cases;
791
+ } else {
792
+ newIdioms = parseLlmYamlResponse(llmResponse);
793
+ }
794
+
795
+ if (!newIdioms.length && !newCases.length) {
626
796
  console.log(
627
- ` ${DIM} The LLM may have returned idioms: [] no architectural pattern detected.${RESET}\n`,
797
+ ` ${YELLOW} No idioms or cases extracted from LLM response.${RESET}`,
628
798
  );
799
+ console.log(`\n`);
629
800
  return;
630
801
  }
631
802
 
632
- console.log(` ${GREEN}✔ Extracted ${newIdioms.length} idiom(s)${RESET}`);
633
- for (const idiom of newIdioms) {
634
- console.log(
635
- ` ${CYAN}• ${idiom.pattern || "?"}${RESET} — ${idiom.reason || ""}`,
636
- );
803
+ if (newIdioms.length > 0) {
804
+ console.log(` ${GREEN}✔ Extracted ${newIdioms.length} idiom(s)${RESET}`);
805
+ for (const idiom of newIdioms) {
806
+ console.log(
807
+ ` ${CYAN}• ${idiom.pattern || "?"}${RESET} — ${idiom.reason || ""}`,
808
+ );
809
+ }
810
+ }
811
+
812
+ if (newCases.length > 0) {
813
+ console.log(` ${GREEN}✔ Extracted ${newCases.length} case precedent(s)${RESET}`);
814
+ for (const c of newCases) {
815
+ console.log(
816
+ ` ${RED}• ${c.pattern || "?"}${RESET} — ${c.reason || ""}`,
817
+ );
818
+ }
637
819
  }
638
820
 
639
821
  console.log(
@@ -647,10 +829,8 @@ async function cmdDigest(args) {
647
829
  let added = 0;
648
830
 
649
831
  for (const idiom of newIdioms) {
650
- // FIX: Use Levenshtein normalised similarity (threshold 0.80) instead of
651
- // substring .includes() which was over-aggressive and blocked valid idioms.
652
832
  if (isDuplicateIdiom(idiom.pattern || "", existing)) {
653
- console.log(` ${DIM} Skipped near-duplicate: ${idiom.pattern}${RESET}`);
833
+ console.log(` ${DIM} Skipped near-duplicate idiom: ${idiom.pattern}${RESET}`);
654
834
  continue;
655
835
  }
656
836
  merged.push({
@@ -664,17 +844,86 @@ async function cmdDigest(args) {
664
844
  added++;
665
845
  }
666
846
 
667
- if (added === 0) {
668
- console.log(
669
- ` ${YELLOW}⚠ All extracted idioms were duplicates. SKILL.md unchanged.${RESET}\n`,
670
- );
671
- return;
847
+ if (added > 0) {
848
+ log.total_idioms = merged.length;
849
+ const skillMd = renderSkillMd(merged, (log.cycles || []).length + 1);
850
+ fs.mkdirSync(SKILL_DIR, { recursive: true });
851
+ fs.writeFileSync(SKILL_FILE, skillMd, "utf8");
852
+ console.log(` ${GREEN}✔ ${added} new idiom(s) added to SKILL.md${RESET}`);
853
+ } else {
854
+ console.log(` ${DIM} No new unique idioms added to SKILL.md.${RESET}`);
672
855
  }
673
856
 
674
- log.total_idioms = merged.length;
675
- const skillMd = renderSkillMd(merged, (log.cycles || []).length + 1);
676
- fs.mkdirSync(SKILL_DIR, { recursive: true });
677
- fs.writeFileSync(SKILL_FILE, skillMd, "utf8");
857
+ // Record Case Precedents if present
858
+ if (newCases && newCases.length > 0) {
859
+ console.log(`\n ${DIM}Merging into Case Law precedents...${RESET}`);
860
+ const caseLawManager = require("./case_law_manager");
861
+ const clIndex = caseLawManager.loadIndex();
862
+ let clNextId = clIndex.next_id;
863
+ let clAdded = 0;
864
+
865
+ for (const c of newCases) {
866
+ const diffText = c.pattern || "";
867
+ const reason = c.reason || "";
868
+ const domain = c.domain || "general";
869
+ const verdict = c.verdict || "REJECTED";
870
+ const fingerprint = caseLawManager.contentHash(diffText);
871
+
872
+ let isDup = false;
873
+ for (const existing of clIndex.cases) {
874
+ if (existing.fingerprint === fingerprint) {
875
+ isDup = true;
876
+ break;
877
+ }
878
+ }
879
+ if (isDup) {
880
+ console.log(
881
+ ` ${DIM} Skipped duplicate case: ${reason.slice(0, 50)}...${RESET}`,
882
+ );
883
+ continue;
884
+ }
885
+
886
+ const deltaCase = caseLawManager.semanticDelta(diffText);
887
+ const tags = caseLawManager.extractTags(diffText + " " + reason);
888
+ const caseRecord = {
889
+ id: clNextId,
890
+ fingerprint,
891
+ timestamp: new Date().toISOString().slice(0, 19),
892
+ domain,
893
+ verdict,
894
+ reason: reason.trim(),
895
+ pr_ref: "auto-evolution",
896
+ reviewer: "skill-evolution",
897
+ tags,
898
+ stack_version: null,
899
+ diff_raw: diffText.trim(),
900
+ diff_delta: deltaCase,
901
+ auto_recorded: true,
902
+ };
903
+
904
+ caseLawManager.saveCase(caseRecord);
905
+ clIndex.cases.push({
906
+ id: clNextId,
907
+ fingerprint,
908
+ domain,
909
+ verdict,
910
+ tags,
911
+ timestamp: caseRecord.timestamp,
912
+ reason_summary: reason.trim().slice(0, 120),
913
+ stack_version: null,
914
+ });
915
+ clNextId++;
916
+ clAdded++;
917
+ }
918
+
919
+ if (clAdded > 0) {
920
+ clIndex.next_id = clNextId;
921
+ caseLawManager.saveIndex(clIndex);
922
+ console.log(` ${GREEN}✔ ${clAdded} new Case Law precedent(s) recorded.${RESET}`);
923
+ } else {
924
+ console.log(` ${DIM} No new unique Case Law precedents recorded.${RESET}`);
925
+ }
926
+ }
678
927
 
679
928
  log.cycles = log.cycles || [];
680
929
  log.cycles.push({
@@ -687,15 +936,11 @@ async function cmdDigest(args) {
687
936
  log.total_tokens_saved = (log.total_tokens_saved || 0) + savedTokens;
688
937
  saveLog(log);
689
938
 
690
- console.log(`\n ${GREEN}✔ ${added} new idiom(s) added to SKILL.md${RESET}`);
691
- console.log(` ${DIM} File: ${SKILL_FILE}${RESET}`);
939
+ console.log(`\n ${GREEN}✔ Learn cycle complete.${RESET}`);
692
940
  console.log(` ${DIM} Total idioms: ${merged.length}${RESET}`);
693
941
  console.log(
694
942
  ` ${DIM} Lifetime tokens saved: ${log.total_tokens_saved}${RESET}\n`,
695
943
  );
696
- console.log(
697
- ` ${CYAN}Commit SKILL.md to share your Engineering Culture with the team.${RESET}\n`,
698
- );
699
944
  }
700
945
 
701
946
  function cmdShow() {
@@ -177,7 +177,7 @@ After integration verification, start the dev server:
177
177
 
178
178
  ```bash
179
179
  # Check for dev script
180
- python .agent/scripts/auto_preview.py start
180
+ node .agent/scripts/auto_preview.js start
181
181
 
182
182
  # Or manually
183
183
  npm run dev
@@ -185,8 +185,8 @@ repos:
185
185
 
186
186
  | Script | Purpose | Run With |
187
187
  | -------------------------- | ----------------------------------------- | ------------------------------------------------ |
188
- | `scripts/lint_runner.py` | Runs project linting and reports findings | `python scripts/lint_runner.py <project_path>` |
189
- | `scripts/type_coverage.py` | Measures TypeScript type coverage | `python scripts/type_coverage.py <project_path>` |
188
+ | `.agent/scripts/lint_runner.js` | Runs project linting and reports findings | `node .agent/scripts/lint_runner.js <project_path>` |
189
+ | `scripts/type_coverage.py` | Measures TypeScript type coverage | `python scripts/type_coverage.py <project_path>` |
190
190
 
191
191
  ---
192
192
 
@@ -2,7 +2,7 @@
2
2
  name: project-idioms
3
3
  description: >
4
4
  Auto-evolved skill containing project-specific architectural idioms extracted
5
- from the developer's own code decisions. Generated by skill_evolution.py.
5
+ from the developer's own code decisions. Generated by skill_evolution.js.
6
6
  Commit this file to share your Engineering Culture across the team.
7
7
  Every agent MUST respect these idioms above generic defaults.
8
8
  version: auto
@@ -33,7 +33,7 @@ routing:
33
33
  ## How Idioms Are Born
34
34
 
35
35
  1. Developer commits code that **differs** from what the AI proposed.
36
- 2. `skill_evolution.py digest` extracts the architectural delta (semantic filter).
36
+ 2. `skill_evolution.js digest` extracts the architectural delta (semantic filter).
37
37
  3. A minimal LLM prompt (< 500 tokens) identifies the **WHY** behind the change.
38
38
  4. The idiom is recorded here with a stable pattern + reason pair.
39
39
  5. All future code generations must align with these idioms.
@@ -54,16 +54,16 @@ Run after committing or staging a meaningful architectural change:
54
54
 
55
55
  ```bash
56
56
  # Analyze staged changes (default)
57
- python .agent/scripts/skill_evolution.py digest
57
+ node .agent/scripts/skill_evolution.js digest
58
58
 
59
59
  # Preview without writing
60
- python .agent/scripts/skill_evolution.py digest --dry-run
60
+ node .agent/scripts/skill_evolution.js digest --dry-run
61
61
 
62
62
  # Analyze last commit instead of staged
63
- python .agent/scripts/skill_evolution.py digest --head
63
+ node .agent/scripts/skill_evolution.js digest --head
64
64
 
65
65
  # Check current idiom count and token savings
66
- python .agent/scripts/skill_evolution.py status
66
+ node .agent/scripts/skill_evolution.js status
67
67
  ```
68
68
 
69
69
  ---
@@ -87,7 +87,7 @@ python .agent/scripts/skill_evolution.py status
87
87
  Last digest: `never`
88
88
  Total cycles: `0`
89
89
 
90
- Run `python .agent/scripts/skill_evolution.py status` to see full statistics.
90
+ Run `node .agent/scripts/skill_evolution.js status` to see full statistics.
91
91
 
92
92
  ---
93
93