notoken-core 1.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 (118) hide show
  1. package/config/file-hints.json +255 -0
  2. package/config/hosts.json +14 -0
  3. package/config/intents.json +3920 -0
  4. package/config/playbooks.json +112 -0
  5. package/config/rules.json +100 -0
  6. package/dist/agents/agentSpawner.d.ts +56 -0
  7. package/dist/agents/agentSpawner.js +180 -0
  8. package/dist/agents/planner.d.ts +40 -0
  9. package/dist/agents/planner.js +175 -0
  10. package/dist/agents/playbookRunner.d.ts +45 -0
  11. package/dist/agents/playbookRunner.js +120 -0
  12. package/dist/agents/taskRunner.d.ts +61 -0
  13. package/dist/agents/taskRunner.js +142 -0
  14. package/dist/context/history.d.ts +36 -0
  15. package/dist/context/history.js +115 -0
  16. package/dist/conversation/coreference.d.ts +27 -0
  17. package/dist/conversation/coreference.js +147 -0
  18. package/dist/conversation/secrets.d.ts +43 -0
  19. package/dist/conversation/secrets.js +129 -0
  20. package/dist/conversation/store.d.ts +94 -0
  21. package/dist/conversation/store.js +184 -0
  22. package/dist/execution/git.d.ts +11 -0
  23. package/dist/execution/git.js +146 -0
  24. package/dist/execution/ssh.d.ts +2 -0
  25. package/dist/execution/ssh.js +17 -0
  26. package/dist/handlers/executor.d.ts +8 -0
  27. package/dist/handlers/executor.js +216 -0
  28. package/dist/healing/claudeHealer.d.ts +17 -0
  29. package/dist/healing/claudeHealer.js +300 -0
  30. package/dist/healing/patchPromoter.d.ts +25 -0
  31. package/dist/healing/patchPromoter.js +118 -0
  32. package/dist/healing/ruleBuilder.d.ts +5 -0
  33. package/dist/healing/ruleBuilder.js +111 -0
  34. package/dist/healing/ruleRepairer.d.ts +8 -0
  35. package/dist/healing/ruleRepairer.js +29 -0
  36. package/dist/healing/ruleValidator.d.ts +22 -0
  37. package/dist/healing/ruleValidator.js +145 -0
  38. package/dist/healing/runHealer.d.ts +11 -0
  39. package/dist/healing/runHealer.js +74 -0
  40. package/dist/index.d.ts +51 -0
  41. package/dist/index.js +62 -0
  42. package/dist/intents/catalog.d.ts +4 -0
  43. package/dist/intents/catalog.js +7 -0
  44. package/dist/nlp/disambiguate.d.ts +2 -0
  45. package/dist/nlp/disambiguate.js +46 -0
  46. package/dist/nlp/fuzzyResolver.d.ts +14 -0
  47. package/dist/nlp/fuzzyResolver.js +108 -0
  48. package/dist/nlp/llmFallback.d.ts +63 -0
  49. package/dist/nlp/llmFallback.js +338 -0
  50. package/dist/nlp/llmParser.d.ts +8 -0
  51. package/dist/nlp/llmParser.js +118 -0
  52. package/dist/nlp/multiClassifier.d.ts +39 -0
  53. package/dist/nlp/multiClassifier.js +181 -0
  54. package/dist/nlp/parseIntent.d.ts +2 -0
  55. package/dist/nlp/parseIntent.js +34 -0
  56. package/dist/nlp/ruleParser.d.ts +2 -0
  57. package/dist/nlp/ruleParser.js +234 -0
  58. package/dist/nlp/semantic.d.ts +104 -0
  59. package/dist/nlp/semantic.js +419 -0
  60. package/dist/nlp/uncertainty.d.ts +42 -0
  61. package/dist/nlp/uncertainty.js +103 -0
  62. package/dist/parsers/apacheParser.d.ts +50 -0
  63. package/dist/parsers/apacheParser.js +152 -0
  64. package/dist/parsers/bindParser.d.ts +40 -0
  65. package/dist/parsers/bindParser.js +189 -0
  66. package/dist/parsers/envFile.d.ts +39 -0
  67. package/dist/parsers/envFile.js +128 -0
  68. package/dist/parsers/fileFinder.d.ts +30 -0
  69. package/dist/parsers/fileFinder.js +226 -0
  70. package/dist/parsers/index.d.ts +27 -0
  71. package/dist/parsers/index.js +193 -0
  72. package/dist/parsers/jsonParser.d.ts +16 -0
  73. package/dist/parsers/jsonParser.js +57 -0
  74. package/dist/parsers/nginxParser.d.ts +47 -0
  75. package/dist/parsers/nginxParser.js +161 -0
  76. package/dist/parsers/passwd.d.ts +25 -0
  77. package/dist/parsers/passwd.js +41 -0
  78. package/dist/parsers/shadow.d.ts +23 -0
  79. package/dist/parsers/shadow.js +50 -0
  80. package/dist/parsers/yamlParser.d.ts +13 -0
  81. package/dist/parsers/yamlParser.js +54 -0
  82. package/dist/policy/confirm.d.ts +2 -0
  83. package/dist/policy/confirm.js +29 -0
  84. package/dist/policy/safety.d.ts +4 -0
  85. package/dist/policy/safety.js +32 -0
  86. package/dist/types/intent.d.ts +205 -0
  87. package/dist/types/intent.js +32 -0
  88. package/dist/types/rules.d.ts +237 -0
  89. package/dist/types/rules.js +50 -0
  90. package/dist/utils/analysis.d.ts +25 -0
  91. package/dist/utils/analysis.js +307 -0
  92. package/dist/utils/autoBackup.d.ts +43 -0
  93. package/dist/utils/autoBackup.js +144 -0
  94. package/dist/utils/config.d.ts +11 -0
  95. package/dist/utils/config.js +32 -0
  96. package/dist/utils/dirAnalysis.d.ts +23 -0
  97. package/dist/utils/dirAnalysis.js +192 -0
  98. package/dist/utils/explain.d.ts +8 -0
  99. package/dist/utils/explain.js +145 -0
  100. package/dist/utils/logger.d.ts +5 -0
  101. package/dist/utils/logger.js +29 -0
  102. package/dist/utils/output.d.ts +2 -0
  103. package/dist/utils/output.js +26 -0
  104. package/dist/utils/paths.d.ts +26 -0
  105. package/dist/utils/paths.js +47 -0
  106. package/dist/utils/permissions.d.ts +64 -0
  107. package/dist/utils/permissions.js +298 -0
  108. package/dist/utils/platform.d.ts +53 -0
  109. package/dist/utils/platform.js +253 -0
  110. package/dist/utils/smartFile.d.ts +29 -0
  111. package/dist/utils/smartFile.js +188 -0
  112. package/dist/utils/spinner.d.ts +53 -0
  113. package/dist/utils/spinner.js +140 -0
  114. package/dist/utils/verbose.d.ts +27 -0
  115. package/dist/utils/verbose.js +131 -0
  116. package/dist/utils/wslPaths.d.ts +31 -0
  117. package/dist/utils/wslPaths.js +145 -0
  118. package/package.json +39 -0
@@ -0,0 +1,216 @@
1
+ import { getIntentDef, loadHosts } from "../utils/config.js";
2
+ import { runRemoteCommand, runLocalCommand } from "../execution/ssh.js";
3
+ import { gitStatus, gitLog, gitDiff, gitPull, gitPush, gitBranch, gitCheckout, gitCommit, gitAdd, gitStash, gitReset, } from "../execution/git.js";
4
+ import { resolveFuzzyFields } from "../nlp/fuzzyResolver.js";
5
+ import { recordHistory } from "../context/history.js";
6
+ import { createBackup, getRemoteBackupCommand } from "../utils/autoBackup.js";
7
+ import { detectLocalPlatform, getPackageForCommand, getInstallCommand } from "../utils/platform.js";
8
+ import { withSpinner } from "../utils/spinner.js";
9
+ import { analyzeOutput } from "../utils/analysis.js";
10
+ import { smartRead, smartSearch } from "../utils/smartFile.js";
11
+ /**
12
+ * Generic command executor.
13
+ *
14
+ * For git.* intents, uses simple-git for richer programmatic output.
15
+ * For everything else, interpolates command templates and runs via shell.
16
+ */
17
+ export async function executeIntent(intent) {
18
+ const def = getIntentDef(intent.intent);
19
+ if (!def) {
20
+ throw new Error(`No intent definition found for: ${intent.intent}`);
21
+ }
22
+ // Fuzzy resolve file paths if needed
23
+ const resolved = await resolveFuzzyFields(intent);
24
+ const fields = resolved.fields;
25
+ const environment = fields.environment ?? "local";
26
+ // "local" environment means run on this machine, not SSH
27
+ // Also run locally if no real hosts are configured (placeholder hosts)
28
+ const isLocal = def.execution === "local"
29
+ || environment === "local"
30
+ || environment === "localhost"
31
+ || !hasRealHost(environment);
32
+ let result;
33
+ let command;
34
+ // Auto-backup before destructive file operations
35
+ const destructiveIntents = ["files.copy", "files.move", "files.remove", "env.set"];
36
+ if (destructiveIntents.includes(intent.intent)) {
37
+ const targetFile = (fields.source ?? fields.target ?? fields.path);
38
+ if (targetFile) {
39
+ if (def.execution === "local") {
40
+ const backup = createBackup(targetFile, intent.intent);
41
+ if (backup) {
42
+ console.error(`\x1b[2m[auto-backup] ${backup.originalPath} → ${backup.backupPath}\x1b[0m`);
43
+ }
44
+ }
45
+ // For remote: prepend backup command
46
+ }
47
+ }
48
+ // Smart file reading — size check, sampling, context search
49
+ if (intent.intent === "file.read" || intent.intent === "file.parse") {
50
+ const filePath = fields.path ?? "";
51
+ if (filePath) {
52
+ command = `[smart-read] ${filePath}`;
53
+ result = await withSpinner(`Reading ${filePath}...`, () => smartRead(filePath, !isLocal, isLocal ? undefined : environment));
54
+ recordHistory({ timestamp: new Date().toISOString(), rawText: intent.rawText, intent: intent.intent, fields, command, environment, success: true });
55
+ return result;
56
+ }
57
+ }
58
+ if (intent.intent === "file.search_in") {
59
+ const filePath = fields.path ?? "";
60
+ const query = fields.query ?? "";
61
+ if (filePath && query) {
62
+ command = `[smart-search] ${query} in ${filePath}`;
63
+ result = await withSpinner(`Searching "${query}" in ${filePath}...`, () => smartSearch(filePath, query, !isLocal, isLocal ? undefined : environment));
64
+ recordHistory({ timestamp: new Date().toISOString(), rawText: intent.rawText, intent: intent.intent, fields, command, environment, success: true });
65
+ return result;
66
+ }
67
+ }
68
+ // Route git intents through simple-git for better output
69
+ if (intent.intent.startsWith("git.")) {
70
+ command = `[simple-git] ${intent.intent}`;
71
+ result = await withSpinner(`${intent.intent}...`, () => executeGitIntent(intent.intent, fields));
72
+ }
73
+ else {
74
+ command = interpolateCommand(def, fields);
75
+ // For remote destructive ops, prepend a backup command
76
+ if (destructiveIntents.includes(intent.intent) && !isLocal) {
77
+ const targetFile = (fields.source ?? fields.target ?? fields.path);
78
+ if (targetFile) {
79
+ command = getRemoteBackupCommand(targetFile) + command;
80
+ }
81
+ }
82
+ const spinnerMsg = isLocal
83
+ ? `${intent.intent}...`
84
+ : `${intent.intent} on ${environment}...`;
85
+ try {
86
+ result = await withSpinner(spinnerMsg, async () => {
87
+ if (isLocal) {
88
+ return runLocalCommand(command);
89
+ }
90
+ else {
91
+ return runRemoteCommand(environment, command);
92
+ }
93
+ });
94
+ }
95
+ catch (err) {
96
+ // Auto-detect missing commands and suggest install
97
+ const msg = err instanceof Error ? err.message : String(err);
98
+ if (msg.includes("command not found") || msg.includes("not found")) {
99
+ const missingCmd = extractMissingCommand(msg);
100
+ if (missingCmd) {
101
+ const platform = detectLocalPlatform();
102
+ const pkg = getPackageForCommand(missingCmd, platform);
103
+ const installCmd = getInstallCommand(pkg ?? missingCmd, platform);
104
+ throw new Error(`${msg}\n\nMissing command: ${missingCmd}\nInstall with: ${installCmd}`);
105
+ }
106
+ }
107
+ throw err;
108
+ }
109
+ }
110
+ recordHistory({
111
+ timestamp: new Date().toISOString(),
112
+ rawText: intent.rawText,
113
+ intent: intent.intent,
114
+ fields,
115
+ command,
116
+ environment,
117
+ success: true,
118
+ });
119
+ // Append intelligent analysis if applicable
120
+ const analysis = analyzeOutput(intent.intent, result, fields);
121
+ if (analysis) {
122
+ result += "\n" + analysis;
123
+ }
124
+ return result;
125
+ }
126
+ async function executeGitIntent(intentName, fields) {
127
+ const path = fields.path ?? ".";
128
+ switch (intentName) {
129
+ case "git.status":
130
+ return gitStatus(path);
131
+ case "git.log":
132
+ return gitLog(path, fields.count ?? 10);
133
+ case "git.diff":
134
+ return gitDiff(path, fields.target);
135
+ case "git.pull":
136
+ return gitPull(path, fields.remote ?? "origin", fields.branch);
137
+ case "git.push":
138
+ return gitPush(path, fields.remote ?? "origin", fields.branch);
139
+ case "git.branch":
140
+ return gitBranch(path);
141
+ case "git.checkout":
142
+ return gitCheckout(fields.branch, path);
143
+ case "git.commit":
144
+ return gitCommit(fields.message, path);
145
+ case "git.add":
146
+ return gitAdd(fields.target ?? ".", path);
147
+ case "git.stash":
148
+ return gitStash(fields.action ?? "push", path);
149
+ case "git.reset":
150
+ return gitReset(fields.target ?? "HEAD", path);
151
+ default:
152
+ throw new Error(`Unknown git intent: ${intentName}`);
153
+ }
154
+ }
155
+ function interpolateCommand(def, fields) {
156
+ let cmd = def.command;
157
+ for (const [key, value] of Object.entries(fields)) {
158
+ if (value !== undefined && value !== null) {
159
+ const safe = sanitize(String(value));
160
+ cmd = cmd.replaceAll(`{{${key}}}`, safe);
161
+ }
162
+ }
163
+ for (const [fieldName, fieldDef] of Object.entries(def.fields)) {
164
+ if (fieldDef.default !== undefined) {
165
+ const safe = sanitize(String(fieldDef.default));
166
+ cmd = cmd.replaceAll(`{{${fieldName}}}`, safe);
167
+ }
168
+ }
169
+ cmd = cmd.replace(/\{\{[a-zA-Z_]+\}\}/g, "");
170
+ return cmd.trim();
171
+ }
172
+ function sanitize(value) {
173
+ if (value === "")
174
+ return "";
175
+ if (!/^[a-zA-Z0-9_.\/\\\- :@~]+$/.test(value)) {
176
+ throw new Error(`Unsafe field value rejected: "${value}"`);
177
+ }
178
+ return value;
179
+ }
180
+ function extractMissingCommand(errorMsg) {
181
+ const match1 = errorMsg.match(/bash: (\w+): command not found/);
182
+ if (match1)
183
+ return match1[1];
184
+ const match2 = errorMsg.match(/sh: \d+: (\w+): not found/);
185
+ if (match2)
186
+ return match2[1];
187
+ const match3 = errorMsg.match(/\/bin\/\w+: (\w+): not found/);
188
+ if (match3)
189
+ return match3[1];
190
+ return null;
191
+ }
192
+ /**
193
+ * Check if a real (non-placeholder) SSH host is configured for an environment.
194
+ * Placeholder hosts like "user@dev-server" are detected and treated as unconfigured.
195
+ */
196
+ function hasRealHost(environment) {
197
+ try {
198
+ const hosts = loadHosts();
199
+ const entry = hosts[environment];
200
+ if (!entry)
201
+ return false;
202
+ const host = entry.host;
203
+ // Detect common placeholder patterns
204
+ const placeholders = [
205
+ /user@(dev|staging|prod|test)-server$/,
206
+ /user@(dev|staging|prod|test)$/,
207
+ /example\.com$/,
208
+ /localhost$/,
209
+ /127\.0\.0\.1$/,
210
+ ];
211
+ return !placeholders.some((p) => p.test(host));
212
+ }
213
+ catch {
214
+ return false;
215
+ }
216
+ }
@@ -0,0 +1,17 @@
1
+ #!/usr/bin/env tsx
2
+ /**
3
+ * Claude-powered auto-learning.
4
+ *
5
+ * Uses Claude CLI to:
6
+ * 1. Read the current rules.json and intents.json structure
7
+ * 2. Read the failure log (phrases that didn't match)
8
+ * 3. Read the uncertainty log (phrases with low confidence)
9
+ * 4. Analyze gaps and propose structured changes
10
+ * 5. Let Claude request to see/grep specific files
11
+ * 6. Validate and apply the changes
12
+ *
13
+ * Usage:
14
+ * npx tsx src/healing/claudeHealer.ts [--promote] [--dry-run]
15
+ * MYCLI_LLM_CLI=claude npm run heal:claude
16
+ */
17
+ export {};
@@ -0,0 +1,300 @@
1
+ #!/usr/bin/env tsx
2
+ /**
3
+ * Claude-powered auto-learning.
4
+ *
5
+ * Uses Claude CLI to:
6
+ * 1. Read the current rules.json and intents.json structure
7
+ * 2. Read the failure log (phrases that didn't match)
8
+ * 3. Read the uncertainty log (phrases with low confidence)
9
+ * 4. Analyze gaps and propose structured changes
10
+ * 5. Let Claude request to see/grep specific files
11
+ * 6. Validate and apply the changes
12
+ *
13
+ * Usage:
14
+ * npx tsx src/healing/claudeHealer.ts [--promote] [--dry-run]
15
+ * MYCLI_LLM_CLI=claude npm run heal:claude
16
+ */
17
+ import { execSync, execFileSync } from "node:child_process";
18
+ import { readFileSync, existsSync } from "node:fs";
19
+ import { resolve } from "node:path";
20
+ import { validatePatch } from "./ruleValidator.js";
21
+ import { promotePatch } from "./patchPromoter.js";
22
+ import { CONFIG_DIR, PACKAGE_ROOT } from "../utils/paths.js";
23
+ import { clearFailures, loadFailures } from "../utils/logger.js";
24
+ import { loadUncertaintyLog } from "../nlp/uncertainty.js";
25
+ const c = { reset: "\x1b[0m", bold: "\x1b[1m", dim: "\x1b[2m", green: "\x1b[32m", yellow: "\x1b[33m", red: "\x1b[31m", cyan: "\x1b[36m" };
26
+ async function main() {
27
+ const args = process.argv.slice(2);
28
+ const shouldPromote = args.includes("--promote");
29
+ const dryRun = args.includes("--dry-run");
30
+ const force = args.includes("--force");
31
+ console.log(`${c.bold}${c.cyan}=== Claude Auto-Learning ====${c.reset}\n`);
32
+ // Check Claude CLI
33
+ try {
34
+ execSync("command -v claude", { stdio: "pipe" });
35
+ }
36
+ catch {
37
+ console.error(`${c.red}Claude CLI not found. Install it or set MYCLI_LLM_CLI=claude.${c.reset}`);
38
+ process.exit(1);
39
+ }
40
+ // Gather context
41
+ const failures = loadFailures();
42
+ const uncertainty = loadUncertaintyLog();
43
+ const rulesJson = readFileSync(resolve(CONFIG_DIR, "rules.json"), "utf-8");
44
+ const intentsJson = readFileSync(resolve(CONFIG_DIR, "intents.json"), "utf-8");
45
+ // Summarize intents (don't send the whole 70KB file)
46
+ const intents = JSON.parse(intentsJson);
47
+ const intentSummary = intents.intents.map((i) => ({
48
+ name: i.name,
49
+ synonyms: i.synonyms,
50
+ description: i.description,
51
+ }));
52
+ if (failures.length === 0 && uncertainty.length === 0) {
53
+ console.log(`${c.green}✓ No failures or uncertainty to fix.${c.reset}`);
54
+ return;
55
+ }
56
+ console.log(`${c.dim}Failures: ${failures.length} | Uncertain phrases: ${uncertainty.length}${c.reset}\n`);
57
+ // Build the prompt for Claude
58
+ const prompt = buildHealerPrompt(failures, uncertainty, rulesJson, intentSummary);
59
+ console.log(`${c.dim}Asking Claude to analyze...${c.reset}\n`);
60
+ // Call Claude CLI
61
+ const response = callClaude(prompt);
62
+ if (!response) {
63
+ console.error(`${c.red}Claude returned no response.${c.reset}`);
64
+ return;
65
+ }
66
+ // Extract the JSON patch from Claude's response
67
+ const patch = extractPatch(response);
68
+ if (!patch) {
69
+ // Claude might want to see a file first — check for requests
70
+ const fileRequest = extractFileRequest(response);
71
+ if (fileRequest) {
72
+ console.log(`${c.cyan}Claude wants to see: ${fileRequest}${c.reset}`);
73
+ const fileContent = readRequestedFile(fileRequest);
74
+ // Follow up with the file content
75
+ const followUp = `Here is the content of ${fileRequest}:\n\n${fileContent}\n\nNow based on this, provide the structured JSON patch as described.`;
76
+ const response2 = callClaude(followUp);
77
+ const patch2 = response2 ? extractPatch(response2) : null;
78
+ if (patch2) {
79
+ applyPatch(patch2, shouldPromote, dryRun, force);
80
+ }
81
+ else {
82
+ console.log(`\n${c.bold}Claude's analysis:${c.reset}`);
83
+ console.log(response2 ?? response);
84
+ }
85
+ }
86
+ else {
87
+ // Just show Claude's analysis
88
+ console.log(`\n${c.bold}Claude's analysis:${c.reset}`);
89
+ console.log(response);
90
+ }
91
+ return;
92
+ }
93
+ applyPatch(patch, shouldPromote, dryRun, force);
94
+ }
95
+ function buildHealerPrompt(failures, uncertainty, rulesJson, intentSummary) {
96
+ const failureList = failures.slice(-20).map((f) => ` - "${f.rawText}"`).join("\n");
97
+ const uncertainList = uncertainty.slice(-15).map((u) => ` - "${u.rawText}" (conf: ${(u.overallConfidence * 100).toFixed(0)}%, unknown: ${u.unknownTokens.join(", ") || "none"})`).join("\n");
98
+ return `You are analyzing an NLP-based CLI tool that parses natural language into server operation commands.
99
+
100
+ ## HOW THE SYSTEM WORKS
101
+
102
+ The CLI has two config files:
103
+
104
+ 1. **rules.json** — contains environment aliases (prod, staging, dev) and service aliases (nginx, redis, api, etc.)
105
+ These are used to extract entities from user input.
106
+
107
+ 2. **intents.json** — each intent has:
108
+ - "name": the intent identifier (e.g., "service.restart")
109
+ - "synonyms": an array of phrases that trigger this intent via substring matching
110
+ - "fields": what gets extracted (service, environment, path, etc.)
111
+ - "command": the shell command template with {{field}} placeholders
112
+
113
+ **The parser matches by finding the LONGEST synonym substring in the user's input.**
114
+ So if user says "restart nginx on prod", and "restart" (7 chars) is a synonym for service.restart,
115
+ it matches. If another intent had "restart nginx" (13 chars), that would win.
116
+
117
+ ## CURRENT RULES.JSON
118
+ ${rulesJson}
119
+
120
+ ## CURRENT INTENTS (${intentSummary.length} total)
121
+ ${JSON.stringify(intentSummary, null, 2)}
122
+
123
+ ## FAILED PHRASES (these didn't match any intent)
124
+ ${failureList || " (none)"}
125
+
126
+ ## UNCERTAIN PHRASES (matched but with low confidence or unknown tokens)
127
+ ${uncertainList || " (none)"}
128
+
129
+ ## YOUR TASK
130
+
131
+ Analyze the failures and uncertainties. Propose a JSON patch to fix them.
132
+
133
+ You can:
134
+ 1. Add new synonyms to existing intents (most common fix)
135
+ 2. Add new service/environment aliases to rules.json
136
+ 3. Suggest new intents if truly needed
137
+
138
+ **IMPORTANT**: Synonyms work by substring matching. A synonym like "check" will match ANY text containing "check".
139
+ Short synonyms can cause false positives. Prefer longer, more specific synonyms.
140
+
141
+ If you need to see a specific file to understand the system better, say:
142
+ "I need to see: <filepath>"
143
+
144
+ Otherwise, return a JSON patch in this format:
145
+ \`\`\`json
146
+ {
147
+ "summary": "what this patch does",
148
+ "confidence": 0.0-1.0,
149
+ "changes": [
150
+ { "type": "add_intent_synonym", "intent": "service.restart", "phrase": "recycle" },
151
+ { "type": "add_env_alias", "canonical": "prod", "alias": "production-server" },
152
+ { "type": "add_service_alias", "canonical": "nginx", "alias": "webserver" }
153
+ ],
154
+ "tests": [
155
+ { "input": "recycle nginx on prod", "expectedIntent": "service.restart" },
156
+ { "input": "random unrelated phrase", "shouldReject": true }
157
+ ],
158
+ "warnings": ["any concerns about these changes"]
159
+ }
160
+ \`\`\`
161
+
162
+ Be conservative. Only add changes that clearly fix the reported failures.`;
163
+ }
164
+ function callClaude(prompt) {
165
+ try {
166
+ const result = execFileSync("claude", ["-p", prompt, "--no-session-persistence", "--max-turns", "2",
167
+ "--append-system-prompt", "IMPORTANT: Do NOT use any tools. Do NOT read files. All the context you need is in the prompt. Respond with ONLY the JSON patch object. No explanation, just JSON."], {
168
+ encoding: "utf-8",
169
+ timeout: 180_000,
170
+ stdio: ["pipe", "pipe", "pipe"],
171
+ cwd: PACKAGE_ROOT,
172
+ maxBuffer: 10 * 1024 * 1024,
173
+ });
174
+ return result.trim();
175
+ }
176
+ catch (err) {
177
+ const e = err;
178
+ if (e.stdout && e.stdout.trim())
179
+ return e.stdout.trim();
180
+ const msg = e.stderr ?? e.message ?? String(err);
181
+ console.error(`${c.red}Claude error: ${msg.split("\n")[0]}${c.reset}`);
182
+ return null;
183
+ }
184
+ }
185
+ function extractPatch(response) {
186
+ // Try JSON code block
187
+ const jsonMatch = response.match(/```json\s*([\s\S]*?)```/);
188
+ if (jsonMatch) {
189
+ try {
190
+ const parsed = JSON.parse(jsonMatch[1].trim());
191
+ if (parsed.changes && Array.isArray(parsed.changes)) {
192
+ return { ...parsed, warnings: parsed.warnings ?? [] };
193
+ }
194
+ }
195
+ catch { }
196
+ }
197
+ // Try raw JSON
198
+ const rawMatch = response.match(/\{[\s\S]*"changes"[\s\S]*\}/);
199
+ if (rawMatch) {
200
+ try {
201
+ const parsed = JSON.parse(rawMatch[0]);
202
+ if (parsed.changes)
203
+ return { ...parsed, warnings: parsed.warnings ?? [] };
204
+ }
205
+ catch { }
206
+ }
207
+ return null;
208
+ }
209
+ function extractFileRequest(response) {
210
+ const match = response.match(/I need to see:\s*(\S+)/i);
211
+ if (match)
212
+ return match[1];
213
+ const match2 = response.match(/(?:show me|let me see|can I see|read)\s+(\S+\.\w+)/i);
214
+ if (match2)
215
+ return match2[1];
216
+ return null;
217
+ }
218
+ function readRequestedFile(request) {
219
+ // Try relative to config dir, then project root, then absolute
220
+ const candidates = [
221
+ resolve(CONFIG_DIR, request),
222
+ resolve(CONFIG_DIR, "..", request),
223
+ request,
224
+ ];
225
+ for (const path of candidates) {
226
+ if (existsSync(path)) {
227
+ const content = readFileSync(path, "utf-8");
228
+ // Truncate if huge
229
+ if (content.length > 10000) {
230
+ return content.slice(0, 10000) + `\n\n... (truncated, ${content.length} total chars)`;
231
+ }
232
+ return content;
233
+ }
234
+ }
235
+ return `File not found: ${request}`;
236
+ }
237
+ function applyPatch(patch, shouldPromote, dryRun, force = false) {
238
+ console.log(`\n${c.bold}--- Proposed Patch ---${c.reset}`);
239
+ console.log(`${c.cyan}Summary:${c.reset} ${patch.summary}`);
240
+ console.log(`${c.cyan}Confidence:${c.reset} ${(patch.confidence * 100).toFixed(0)}%`);
241
+ console.log(`${c.cyan}Changes:${c.reset} ${patch.changes.length}`);
242
+ for (const change of patch.changes) {
243
+ switch (change.type) {
244
+ case "add_intent_synonym":
245
+ console.log(` ${c.green}+${c.reset} synonym "${change.phrase}" → ${change.intent}`);
246
+ break;
247
+ case "add_env_alias":
248
+ console.log(` ${c.green}+${c.reset} env alias "${change.alias}" → ${change.canonical}`);
249
+ break;
250
+ case "add_service_alias":
251
+ console.log(` ${c.green}+${c.reset} service alias "${change.alias}" → ${change.canonical}`);
252
+ break;
253
+ case "remove_intent_synonym":
254
+ console.log(` ${c.red}-${c.reset} remove synonym "${change.phrase}" from ${change.intent}`);
255
+ break;
256
+ }
257
+ }
258
+ if (patch.tests.length > 0) {
259
+ console.log(`\n${c.cyan}Tests:${c.reset} ${patch.tests.length}`);
260
+ for (const t of patch.tests) {
261
+ const label = t.shouldReject ? `${c.red}REJECT${c.reset}` : `${c.green}${t.expectedIntent}${c.reset}`;
262
+ console.log(` "${t.input}" → ${label}`);
263
+ }
264
+ }
265
+ if (patch.warnings.length > 0) {
266
+ console.log(`\n${c.yellow}Warnings:${c.reset}`);
267
+ for (const w of patch.warnings)
268
+ console.log(` - ${w}`);
269
+ }
270
+ // Validate
271
+ console.log(`\n${c.bold}--- Validation ---${c.reset}`);
272
+ const validation = validatePatch(patch);
273
+ console.log(`Valid: ${validation.valid ? `${c.green}yes${c.reset}` : `${c.red}no${c.reset}`}`);
274
+ if (validation.errors.length > 0) {
275
+ for (const e of validation.errors)
276
+ console.log(` ${c.red}✗ ${e}${c.reset}`);
277
+ }
278
+ for (const t of validation.testResults) {
279
+ console.log(` ${t.passed ? `${c.green}✓${c.reset}` : `${c.red}✗${c.reset}`} "${t.input}"${t.reason ? ` (${t.reason})` : ""}`);
280
+ }
281
+ if (shouldPromote) {
282
+ console.log(`\n${c.bold}--- Promoting ---${c.reset}`);
283
+ const result = promotePatch(patch, { force, dryRun });
284
+ if (result.promoted) {
285
+ console.log(`${c.green}✓ Patch applied. Rules updated to v${result.newVersion}${c.reset}`);
286
+ clearFailures();
287
+ console.log(`${c.green}✓ Failure log cleared.${c.reset}`);
288
+ }
289
+ else {
290
+ console.log(`${c.yellow}Patch not promoted.${c.reset}`);
291
+ }
292
+ }
293
+ else {
294
+ console.log(`\n${c.dim}Run with --promote to apply. Add --dry-run for safe preview.${c.reset}`);
295
+ }
296
+ }
297
+ main().catch((err) => {
298
+ console.error(`${c.red}Healer error:${c.reset}`, err.message ?? err);
299
+ process.exit(1);
300
+ });
@@ -0,0 +1,25 @@
1
+ import type { RulePatch } from "../types/rules.js";
2
+ import { type ValidationResult } from "./ruleValidator.js";
3
+ export interface PromotionResult {
4
+ promoted: boolean;
5
+ validation: ValidationResult;
6
+ backupPath?: string;
7
+ newVersion?: string;
8
+ }
9
+ /**
10
+ * PatchPromoter: validates and applies a rule patch to the live config.
11
+ *
12
+ * Flow:
13
+ * 1. Validate the patch
14
+ * 2. If valid, back up current rules
15
+ * 3. Apply changes to rules.json
16
+ * 4. Bump version
17
+ *
18
+ * Options:
19
+ * - force: skip validation (not recommended)
20
+ * - dryRun: validate but don't write
21
+ */
22
+ export declare function promotePatch(patch: RulePatch, options?: {
23
+ force?: boolean;
24
+ dryRun?: boolean;
25
+ }): PromotionResult;
@@ -0,0 +1,118 @@
1
+ import { readFileSync, writeFileSync, copyFileSync } from "node:fs";
2
+ import { resolve } from "node:path";
3
+ import { RulesConfig } from "../types/rules.js";
4
+ import { getConfigDir } from "../utils/config.js";
5
+ import { validatePatch } from "./ruleValidator.js";
6
+ /**
7
+ * PatchPromoter: validates and applies a rule patch to the live config.
8
+ *
9
+ * Flow:
10
+ * 1. Validate the patch
11
+ * 2. If valid, back up current rules
12
+ * 3. Apply changes to rules.json
13
+ * 4. Bump version
14
+ *
15
+ * Options:
16
+ * - force: skip validation (not recommended)
17
+ * - dryRun: validate but don't write
18
+ */
19
+ export function promotePatch(patch, options = {}) {
20
+ const validation = validatePatch(patch);
21
+ if (!validation.valid && !options.force) {
22
+ return { promoted: false, validation };
23
+ }
24
+ if (validation.warnings.length > 0) {
25
+ console.log("Warnings:");
26
+ for (const w of validation.warnings)
27
+ console.log(` - ${w}`);
28
+ }
29
+ if (options.dryRun) {
30
+ console.log("Dry run — patch is valid but not applied.");
31
+ return { promoted: false, validation };
32
+ }
33
+ const configDir = getConfigDir();
34
+ const rulesPath = resolve(configDir, "rules.json");
35
+ const raw = readFileSync(rulesPath, "utf-8");
36
+ const rules = RulesConfig.parse(JSON.parse(raw));
37
+ // Backup
38
+ const backupPath = resolve(configDir, `rules.backup.${Date.now()}.json`);
39
+ copyFileSync(rulesPath, backupPath);
40
+ // Also load intents.json for synonym changes (primary source now)
41
+ const intentsPath = resolve(configDir, "intents.json");
42
+ const intentsRaw = JSON.parse(readFileSync(intentsPath, "utf-8"));
43
+ const intentsBackup = resolve(configDir, `intents.backup.${Date.now()}.json`);
44
+ copyFileSync(intentsPath, intentsBackup);
45
+ let intentsChanged = false;
46
+ // Apply changes
47
+ for (const change of patch.changes) {
48
+ switch (change.type) {
49
+ case "add_intent_synonym": {
50
+ // Add to intents.json (primary)
51
+ const intentDef = intentsRaw.intents?.find((i) => i.name === change.intent);
52
+ if (intentDef && Array.isArray(intentDef.synonyms)) {
53
+ if (!intentDef.synonyms.includes(change.phrase)) {
54
+ intentDef.synonyms.push(change.phrase);
55
+ intentsChanged = true;
56
+ }
57
+ }
58
+ // Also add to rules.json if the intent exists there (backward compat)
59
+ if (rules.intentSynonyms[change.intent]) {
60
+ if (!rules.intentSynonyms[change.intent].includes(change.phrase)) {
61
+ rules.intentSynonyms[change.intent].push(change.phrase);
62
+ }
63
+ }
64
+ break;
65
+ }
66
+ case "add_env_alias":
67
+ if (rules.environmentAliases[change.canonical]) {
68
+ if (!rules.environmentAliases[change.canonical].includes(change.alias)) {
69
+ rules.environmentAliases[change.canonical].push(change.alias);
70
+ }
71
+ }
72
+ break;
73
+ case "add_service_alias":
74
+ if (rules.serviceAliases[change.canonical]) {
75
+ if (!rules.serviceAliases[change.canonical].includes(change.alias)) {
76
+ rules.serviceAliases[change.canonical].push(change.alias);
77
+ }
78
+ }
79
+ break;
80
+ case "remove_intent_synonym": {
81
+ // Remove from intents.json
82
+ const rmDef = intentsRaw.intents?.find((i) => i.name === change.intent);
83
+ if (rmDef && Array.isArray(rmDef.synonyms)) {
84
+ rmDef.synonyms = rmDef.synonyms.filter((s) => s !== change.phrase);
85
+ intentsChanged = true;
86
+ }
87
+ // Also remove from rules.json
88
+ if (rules.intentSynonyms[change.intent]) {
89
+ rules.intentSynonyms[change.intent] = rules.intentSynonyms[change.intent].filter((p) => p !== change.phrase);
90
+ }
91
+ break;
92
+ }
93
+ }
94
+ }
95
+ // Write intents.json if changed
96
+ if (intentsChanged) {
97
+ writeFileSync(intentsPath, JSON.stringify(intentsRaw, null, 2) + "\n");
98
+ console.log(`Intents updated. Backup: ${intentsBackup}`);
99
+ }
100
+ // Bump version
101
+ const newVersion = bumpVersion(rules.version);
102
+ rules.version = newVersion;
103
+ // Write
104
+ writeFileSync(rulesPath, JSON.stringify(rules, null, 2) + "\n");
105
+ console.log(`Patch promoted. Rules updated to v${newVersion}`);
106
+ console.log(`Backup saved: ${backupPath}`);
107
+ return {
108
+ promoted: true,
109
+ validation,
110
+ backupPath,
111
+ newVersion,
112
+ };
113
+ }
114
+ function bumpVersion(version) {
115
+ const parts = version.split(".").map(Number);
116
+ parts[2] = (parts[2] ?? 0) + 1;
117
+ return parts.join(".");
118
+ }
@@ -0,0 +1,5 @@
1
+ import type { RulePatch } from "../types/rules.js";
2
+ /**
3
+ * RuleBuilder: asks an LLM to propose new rules from a set of example phrases.
4
+ */
5
+ export declare function buildRulesFromExamples(examples: string[]): Promise<RulePatch | null>;