kairn-cli 2.4.2 → 2.5.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.
package/dist/cli.js CHANGED
@@ -1464,6 +1464,361 @@ async function callLLM(config, userMessage, options) {
1464
1464
  }
1465
1465
  }
1466
1466
 
1467
+ // src/intent/patterns.ts
1468
+ var SYNONYM_MAP = {
1469
+ deploy: ["ship", "push\\s+to\\s+prod", "release", "publish"],
1470
+ test: ["run\\s+tests", "check", "verify", "run\\s+test\\s+suite"],
1471
+ lint: ["format", "style\\s+check", "linting"],
1472
+ build: ["compile", "bundle", "make"],
1473
+ dev: ["develop", "start\\s+dev", "run\\s+dev"],
1474
+ start: ["run", "launch", "serve"],
1475
+ migrate: ["migration", "schema\\s+change", "db\\s+update"],
1476
+ seed: ["populate", "seed\\s+data", "load\\s+fixtures"],
1477
+ clean: ["purge", "clear", "reset\\s+cache"],
1478
+ docs: ["document", "documentation", "write\\s+docs"],
1479
+ review: ["code\\s+review", "pr\\s+review", "check\\s+code"],
1480
+ commit: ["save\\s+changes", "check\\s+in", "git\\s+commit"],
1481
+ fix: ["repair", "patch", "debug", "resolve"],
1482
+ refactor: ["restructure", "reorganize", "clean\\s+up"],
1483
+ plan: ["design", "architect", "outline"],
1484
+ status: ["progress", "overview", "summary"]
1485
+ };
1486
+ function extractDescription(content) {
1487
+ const lines = content.split("\n");
1488
+ for (const line of lines) {
1489
+ const trimmed = line.trim();
1490
+ if (trimmed && !trimmed.startsWith("#")) {
1491
+ return trimmed;
1492
+ }
1493
+ }
1494
+ return "";
1495
+ }
1496
+ function extractVerb(commandName) {
1497
+ const parts = commandName.split("-");
1498
+ if (parts.length > 1) {
1499
+ return parts[parts.length - 1];
1500
+ }
1501
+ return parts[0];
1502
+ }
1503
+ function buildPatternAlternation(commandName) {
1504
+ const verb = extractVerb(commandName);
1505
+ const alternatives = [verb];
1506
+ const synonyms = SYNONYM_MAP[verb];
1507
+ if (synonyms) {
1508
+ alternatives.push(...synonyms);
1509
+ }
1510
+ if (commandName !== verb && !alternatives.includes(commandName)) {
1511
+ alternatives.push(commandName.replace(/-/g, "[\\s-]"));
1512
+ }
1513
+ return `\\b(${alternatives.join("|")})\\b`;
1514
+ }
1515
+ function generateScriptPatterns(scripts, existingCommands) {
1516
+ const patterns = [];
1517
+ for (const scriptName of Object.keys(scripts)) {
1518
+ const verb = extractVerb(scriptName);
1519
+ if (existingCommands.has(verb) || existingCommands.has(scriptName)) {
1520
+ continue;
1521
+ }
1522
+ const parts = scriptName.split(":");
1523
+ if (parts.length > 1) {
1524
+ const suffix = parts[parts.length - 1];
1525
+ const alternatives = [suffix];
1526
+ if (suffix === "e2e") {
1527
+ alternatives.push("end[\\s.-]to[\\s.-]end");
1528
+ }
1529
+ patterns.push({
1530
+ pattern: `\\b(${alternatives.join("|")})\\b`,
1531
+ command: `/project:${scriptName}`,
1532
+ description: `Run npm script: ${scriptName}`,
1533
+ source: "generated"
1534
+ });
1535
+ }
1536
+ }
1537
+ return patterns;
1538
+ }
1539
+ function generateIntentPatterns(commands, agents, projectProfile) {
1540
+ const patterns = [];
1541
+ const commandNames = new Set(Object.keys(commands));
1542
+ for (const [name, content] of Object.entries(commands)) {
1543
+ const description = extractDescription(content);
1544
+ const patternStr = buildPatternAlternation(name);
1545
+ patterns.push({
1546
+ pattern: patternStr,
1547
+ command: `/project:${name}`,
1548
+ description: description || `Run ${name} workflow`,
1549
+ source: "generated"
1550
+ });
1551
+ }
1552
+ const scriptPatterns = generateScriptPatterns(
1553
+ projectProfile.scripts,
1554
+ commandNames
1555
+ );
1556
+ patterns.push(...scriptPatterns);
1557
+ patterns.sort((a, b) => b.pattern.length - a.pattern.length);
1558
+ return patterns;
1559
+ }
1560
+
1561
+ // src/intent/prompt-template.ts
1562
+ function extractFirstLine(content) {
1563
+ const lines = content.split("\n");
1564
+ for (const line of lines) {
1565
+ const trimmed = line.trim();
1566
+ if (trimmed && !trimmed.startsWith("#")) {
1567
+ return trimmed;
1568
+ }
1569
+ }
1570
+ return "";
1571
+ }
1572
+ function compileIntentPrompt(commands, agents) {
1573
+ const workflowLines = [];
1574
+ for (const [name, content] of Object.entries(commands)) {
1575
+ const desc = extractFirstLine(content);
1576
+ workflowLines.push(`- /project:${name} \u2014 ${desc}`);
1577
+ }
1578
+ const workflowManifest = workflowLines.length > 0 ? workflowLines.join("\n") : "(no workflows defined)";
1579
+ const agentLines = [];
1580
+ for (const [name, content] of Object.entries(agents)) {
1581
+ const desc = extractFirstLine(content);
1582
+ agentLines.push(`- @${name} \u2014 ${desc}`);
1583
+ }
1584
+ const agentManifest = agentLines.length > 0 ? agentLines.join("\n") : "(no agents defined)";
1585
+ return `You are an intent classifier for a software project. The user said something that didn't match any known command keyword.
1586
+
1587
+ Available workflows:
1588
+ ${workflowManifest}
1589
+
1590
+ Available agents:
1591
+ ${agentManifest}
1592
+
1593
+ User input: $PROMPT
1594
+
1595
+ If this maps to one or more workflows, return JSON:
1596
+ {"additionalContext": "[INTENT ROUTED] Based on your request, use: /project:<command> \u2014 <description>"}
1597
+
1598
+ If the user is asking a question or making a statement that doesn't need a workflow, return:
1599
+ {"ok": true}
1600
+
1601
+ Do not activate workflows for questions like 'what does deploy do?' or 'how do I test?'. Only activate for action requests.`;
1602
+ }
1603
+
1604
+ // src/intent/router-template.ts
1605
+ function renderPatternEntry(p) {
1606
+ const escapedPattern = p.pattern.replace(/\\/g, "\\\\");
1607
+ const escapedDesc = p.description.replace(/'/g, "\\'");
1608
+ const escapedCmd = p.command.replace(/'/g, "\\'");
1609
+ return ` { pattern: /${p.pattern}/i,
1610
+ command: '${escapedCmd}',
1611
+ description: '${escapedDesc}' }`;
1612
+ }
1613
+ function renderIntentRouter(patterns, generationTimestamp) {
1614
+ const patternEntries = patterns.map((p) => renderPatternEntry(p)).join(",\n");
1615
+ return `#!/usr/bin/env node
1616
+ /**
1617
+ * Kairn Intent Router \u2014 Tier 1 (Regex)
1618
+ * Generated by kairn describe. Project-specific keyword patterns.
1619
+ *
1620
+ * If a keyword matches, injects a system-reminder pointing Claude
1621
+ * to the right command/workflow. If no match, falls through to
1622
+ * the Tier 2 prompt hook.
1623
+ *
1624
+ * Last updated: ${generationTimestamp}
1625
+ */
1626
+
1627
+ import { readFileSync } from 'fs';
1628
+
1629
+ // Read prompt from stdin (Claude Code hooks API)
1630
+ const input = readFileSync('/dev/stdin', 'utf-8');
1631
+ let prompt = '';
1632
+ try {
1633
+ const data = JSON.parse(input);
1634
+ prompt = data.prompt || '';
1635
+ } catch { process.exit(0); }
1636
+
1637
+ if (!prompt.trim()) {
1638
+ console.log(JSON.stringify({ "continue": true, suppressOutput: true }));
1639
+ process.exit(0);
1640
+ }
1641
+
1642
+ // Sanitize: strip code blocks, URLs, file paths
1643
+ const clean = prompt
1644
+ .replace(/\`\`\`[\\s\\S]*?\`\`\`/g, '')
1645
+ .replace(/\`[^\`]+\`/g, '')
1646
+ .replace(/https?:\\/\\/\\S+/g, '')
1647
+ .replace(/(\\/[\\w.-]+){2,}/g, '')
1648
+ .toLowerCase();
1649
+
1650
+ // Question filter: don't trigger on informational queries
1651
+ const QUESTION_PATTERNS = [
1652
+ /\\b(?:what(?:'s|\\s+is)|how\\s+(?:to|do\\s+i)\\s+use|explain|describe|tell\\s+me\\s+about)\\b/i,
1653
+ ];
1654
+ function isQuestion(text, position, length) {
1655
+ const start = Math.max(0, position - 80);
1656
+ const end = Math.min(text.length, position + length + 80);
1657
+ const ctx = text.slice(start, end);
1658
+ return QUESTION_PATTERNS.some(p => p.test(ctx));
1659
+ }
1660
+
1661
+ // \u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550
1662
+ // PROJECT-SPECIFIC KEYWORD PATTERNS
1663
+ // Generated from: kairn describe analysis
1664
+ // Last updated: ${generationTimestamp}
1665
+ // \u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550
1666
+
1667
+ const PATTERNS = [
1668
+ ${patternEntries}
1669
+ ];
1670
+
1671
+ // Match against patterns
1672
+ for (const { pattern, command, description } of PATTERNS) {
1673
+ const match = clean.match(pattern);
1674
+ if (match && !isQuestion(clean, match.index, match[0].length)) {
1675
+ const output = {
1676
+ "continue": true,
1677
+ hookSpecificOutput: {
1678
+ hookEventName: 'UserPromptSubmit',
1679
+ additionalContext: \`[INTENT ROUTED] Based on your request, use: \${command} \u2014 \${description}\\n\\nUser's original request: \${prompt}\\n\\nExecute the command above to fulfill the user's intent.\`
1680
+ }
1681
+ };
1682
+ console.log(JSON.stringify(output));
1683
+ process.exit(0);
1684
+ }
1685
+ }
1686
+
1687
+ // No match \u2014 fall through to Tier 2 (prompt hook)
1688
+ console.log(JSON.stringify({ "continue": true, suppressOutput: true }));
1689
+ `;
1690
+ }
1691
+
1692
+ // src/intent/learner-template.ts
1693
+ function renderIntentLearner() {
1694
+ return `#!/usr/bin/env node
1695
+ /**
1696
+ * Kairn Intent Learner \u2014 Pattern Promotion (Tier 2 \u2192 Tier 1)
1697
+ * Runs on SessionStart to promote recurring Tier 2 patterns to Tier 1 regexes.
1698
+ * Generated by kairn describe.
1699
+ */
1700
+
1701
+ import { readFileSync, writeFileSync, appendFileSync, existsSync } from 'fs';
1702
+ import { join, dirname } from 'path';
1703
+
1704
+ const hooksDir = join(dirname(new URL(import.meta.url).pathname));
1705
+ const logPath = join(hooksDir, 'intent-log.jsonl');
1706
+ const routerPath = join(hooksDir, 'intent-router.mjs');
1707
+ const promotionsPath = join(hooksDir, 'intent-promotions.jsonl');
1708
+
1709
+ // Read log entries
1710
+ let entries = [];
1711
+ try {
1712
+ const raw = readFileSync(logPath, 'utf-8').trim();
1713
+ if (!raw) {
1714
+ console.log(JSON.stringify({ "continue": true, suppressOutput: true }));
1715
+ process.exit(0);
1716
+ }
1717
+ entries = raw.split('\\n').map(line => {
1718
+ try { return JSON.parse(line); }
1719
+ catch { return null; }
1720
+ }).filter(Boolean);
1721
+ } catch {
1722
+ // Log file doesn't exist or is unreadable \u2014 nothing to promote
1723
+ console.log(JSON.stringify({ "continue": true, suppressOutput: true }));
1724
+ process.exit(0);
1725
+ }
1726
+
1727
+ if (entries.length === 0) {
1728
+ console.log(JSON.stringify({ "continue": true, suppressOutput: true }));
1729
+ process.exit(0);
1730
+ }
1731
+
1732
+ // Group by routed_to command
1733
+ const groups = {};
1734
+ const remaining = [];
1735
+ for (const entry of entries) {
1736
+ if (!entry.routed_to) { remaining.push(entry); continue; }
1737
+ if (!groups[entry.routed_to]) groups[entry.routed_to] = [];
1738
+ groups[entry.routed_to].push(entry);
1739
+ }
1740
+
1741
+ // For each command with 3+ entries, extract common words and build regex
1742
+ const promoted = [];
1743
+ for (const [command, cmdEntries] of Object.entries(groups)) {
1744
+ if (cmdEntries.length < 3) {
1745
+ remaining.push(...cmdEntries);
1746
+ continue;
1747
+ }
1748
+
1749
+ // Extract unique words from all prompts
1750
+ const wordFreq = {};
1751
+ for (const entry of cmdEntries) {
1752
+ const words = (entry.prompt || '').toLowerCase()
1753
+ .replace(/[^a-z\\s]/g, '')
1754
+ .split(/\\s+/)
1755
+ .filter(w => w.length > 2);
1756
+ const unique = [...new Set(words)];
1757
+ for (const word of unique) {
1758
+ wordFreq[word] = (wordFreq[word] || 0) + 1;
1759
+ }
1760
+ }
1761
+
1762
+ // Find words that appear in at least half the entries
1763
+ const threshold = Math.ceil(cmdEntries.length / 2);
1764
+ const commonWords = Object.entries(wordFreq)
1765
+ .filter(([, count]) => count >= threshold)
1766
+ .map(([word]) => word)
1767
+ .filter(w => !['the', 'and', 'for', 'this', 'that', 'can', 'you', 'please'].includes(w));
1768
+
1769
+ if (commonWords.length === 0) {
1770
+ remaining.push(...cmdEntries);
1771
+ continue;
1772
+ }
1773
+
1774
+ // Build regex pattern from common words
1775
+ const patternStr = '\\\\b(' + commonWords.join('|') + ')\\\\b';
1776
+
1777
+ // Read current router and check if pattern already exists
1778
+ try {
1779
+ const routerContent = readFileSync(routerPath, 'utf-8');
1780
+ if (commonWords.some(w => routerContent.includes(w + '|') || routerContent.includes('|' + w))) {
1781
+ remaining.push(...cmdEntries);
1782
+ continue;
1783
+ }
1784
+
1785
+ // Append new pattern to PATTERNS array
1786
+ const newEntry = \` { pattern: /\\\\b(\${commonWords.join('|')})/i,\\n command: '\${command}',\\n description: 'Learned from usage patterns' },\`;
1787
+ const insertPoint = routerContent.lastIndexOf('];');
1788
+ if (insertPoint === -1) {
1789
+ remaining.push(...cmdEntries);
1790
+ continue;
1791
+ }
1792
+
1793
+ const updatedRouter = routerContent.slice(0, insertPoint) +
1794
+ newEntry + '\\n' +
1795
+ routerContent.slice(insertPoint);
1796
+ writeFileSync(routerPath, updatedRouter, 'utf-8');
1797
+
1798
+ // Record promotion
1799
+ const promotion = {
1800
+ timestamp: new Date().toISOString(),
1801
+ command,
1802
+ pattern: patternStr,
1803
+ sourcePrompts: cmdEntries.map(e => e.prompt),
1804
+ commonWords,
1805
+ };
1806
+ promoted.push(promotion);
1807
+ appendFileSync(promotionsPath, JSON.stringify(promotion) + '\\n');
1808
+
1809
+ } catch {
1810
+ remaining.push(...cmdEntries);
1811
+ continue;
1812
+ }
1813
+ }
1814
+
1815
+ // Write back remaining (unprocessed) entries
1816
+ writeFileSync(logPath, remaining.map(e => JSON.stringify(e)).join('\\n') + (remaining.length ? '\\n' : ''), 'utf-8');
1817
+
1818
+ console.log(JSON.stringify({ "continue": true, suppressOutput: true }));
1819
+ `;
1820
+ }
1821
+
1467
1822
  // src/compiler/compile.ts
1468
1823
  function buildSkeletonMessage(intent, registry) {
1469
1824
  const registrySummary = registry.map(
@@ -1664,6 +2019,27 @@ async function compile(intent, onProgress) {
1664
2019
  onProgress?.({ phase: "pass3", status: "running", message: "Pass 3: Configuring MCP servers & settings..." });
1665
2020
  const settings = buildSettings(skeleton, registry);
1666
2021
  const mcpConfig = buildMcpConfig(skeleton, registry);
2022
+ const projectProfile = {
2023
+ language: skeleton.outline.tech_stack[0] ?? "unknown",
2024
+ framework: skeleton.outline.tech_stack[1] ?? "none",
2025
+ scripts: {}
2026
+ // scripts come from project scanning, not compilation
2027
+ };
2028
+ const intentPatterns = generateIntentPatterns(
2029
+ harness.commands,
2030
+ harness.agents ?? {},
2031
+ projectProfile
2032
+ );
2033
+ const intentPromptTemplate = compileIntentPrompt(
2034
+ harness.commands,
2035
+ harness.agents ?? {}
2036
+ );
2037
+ const generationTimestamp = (/* @__PURE__ */ new Date()).toISOString();
2038
+ const intentHooks = {};
2039
+ if (intentPatterns.length > 0) {
2040
+ intentHooks["intent-router"] = renderIntentRouter(intentPatterns, generationTimestamp);
2041
+ intentHooks["intent-learner"] = renderIntentLearner();
2042
+ }
1667
2043
  onProgress?.({ phase: "pass3", status: "success", message: "Pass 3: Configured MCP servers & settings" });
1668
2044
  const spec = {
1669
2045
  id: `env_${crypto.randomUUID()}`,
@@ -1681,7 +2057,10 @@ async function compile(intent, onProgress) {
1681
2057
  rules: harness.rules,
1682
2058
  skills: harness.skills ?? {},
1683
2059
  agents: harness.agents ?? {},
1684
- docs: harness.docs
2060
+ docs: harness.docs,
2061
+ hooks: intentHooks,
2062
+ intent_patterns: intentPatterns,
2063
+ intent_prompt_template: intentPromptTemplate
1685
2064
  }
1686
2065
  };
1687
2066
  const warnings = validateSpec(spec);
@@ -2146,6 +2525,41 @@ function resolveSettings(spec, options) {
2146
2525
  hooks.SessionStart = sessionStart;
2147
2526
  base.hooks = hooks;
2148
2527
  }
2528
+ const hasIntentHooks = spec.harness.hooks && Object.keys(spec.harness.hooks).length > 0;
2529
+ if (hasIntentHooks) {
2530
+ const hooks = base.hooks ?? {};
2531
+ const userPromptSubmit = hooks.UserPromptSubmit ?? [];
2532
+ const intentHookEntry = {
2533
+ matcher: "*",
2534
+ hooks: [
2535
+ {
2536
+ type: "command",
2537
+ command: 'node "$CLAUDE_PROJECT_DIR/.claude/hooks/intent-router.mjs"',
2538
+ timeout: 5
2539
+ }
2540
+ ]
2541
+ };
2542
+ if (spec.harness.intent_prompt_template) {
2543
+ intentHookEntry.hooks.push({
2544
+ type: "prompt",
2545
+ prompt: spec.harness.intent_prompt_template,
2546
+ timeout: 15
2547
+ });
2548
+ }
2549
+ userPromptSubmit.push(intentHookEntry);
2550
+ hooks.UserPromptSubmit = userPromptSubmit;
2551
+ const sessionStart = hooks.SessionStart ?? [];
2552
+ sessionStart.push({
2553
+ matcher: "*",
2554
+ hooks: [{
2555
+ type: "command",
2556
+ command: 'node "$CLAUDE_PROJECT_DIR/.claude/hooks/intent-learner.mjs"',
2557
+ timeout: 10
2558
+ }]
2559
+ });
2560
+ hooks.SessionStart = sessionStart;
2561
+ base.hooks = hooks;
2562
+ }
2149
2563
  if (Object.keys(base).length === 0) return null;
2150
2564
  return base;
2151
2565
  }
@@ -2194,6 +2608,14 @@ function buildFileMap(spec, options) {
2194
2608
  files.set(`.claude/docs/${name}.md`, content);
2195
2609
  }
2196
2610
  }
2611
+ if (spec.harness.hooks) {
2612
+ for (const [name, content] of Object.entries(spec.harness.hooks)) {
2613
+ files.set(`.claude/hooks/${name}.mjs`, content);
2614
+ }
2615
+ if (Object.keys(spec.harness.hooks).length > 0) {
2616
+ files.set(".claude/hooks/intent-log.jsonl", "");
2617
+ }
2618
+ }
2197
2619
  return files;
2198
2620
  }
2199
2621
  async function writeEnvironment(spec, targetDir, options) {
@@ -2252,6 +2674,18 @@ async function writeEnvironment(spec, targetDir, options) {
2252
2674
  written.push(`.claude/docs/${name}.md`);
2253
2675
  }
2254
2676
  }
2677
+ if (spec.harness.hooks) {
2678
+ for (const [name, content] of Object.entries(spec.harness.hooks)) {
2679
+ const p = path5.join(claudeDir, "hooks", `${name}.mjs`);
2680
+ await writeFile(p, content);
2681
+ written.push(`.claude/hooks/${name}.mjs`);
2682
+ }
2683
+ if (Object.keys(spec.harness.hooks).length > 0) {
2684
+ const logPath = path5.join(claudeDir, "hooks", "intent-log.jsonl");
2685
+ await writeFile(logPath, "");
2686
+ written.push(".claude/hooks/intent-log.jsonl");
2687
+ }
2688
+ }
2255
2689
  return written;
2256
2690
  }
2257
2691
  function summarizeSpec(spec, registry) {
@@ -3943,11 +4377,17 @@ var EVAL_TEMPLATES = {
3943
4377
  name: "Rule Compliance",
3944
4378
  description: "Does the agent follow all project rules without violations?",
3945
4379
  bestFor: ["feature-development", "backend", "maintenance", "architecture"]
4380
+ },
4381
+ "intent-routing": {
4382
+ id: "intent-routing",
4383
+ name: "Intent Routing",
4384
+ description: "Test that natural language prompts route to the correct workflow command via intent hooks",
4385
+ bestFor: ["feature-development", "full-stack", "api-building"]
3946
4386
  }
3947
4387
  };
3948
4388
  function selectTemplatesForWorkflow(workflowType) {
3949
4389
  const mapping = {
3950
- "feature-development": ["add-feature", "test-writing", "convention-adherence", "workflow-compliance"],
4390
+ "feature-development": ["add-feature", "test-writing", "convention-adherence", "workflow-compliance", "intent-routing"],
3951
4391
  "api-building": ["add-feature", "fix-bug", "test-writing", "convention-adherence"],
3952
4392
  "full-stack": ["add-feature", "fix-bug", "test-writing", "convention-adherence"],
3953
4393
  "maintenance": ["fix-bug", "refactor", "test-writing", "rule-compliance"],