autonomous-flow-daemon 1.1.0 → 1.6.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/CHANGELOG.md +39 -0
- package/README.ko.md +124 -164
- package/README.md +99 -170
- package/package.json +11 -5
- package/src/adapters/index.ts +246 -35
- package/src/cli.ts +71 -1
- package/src/commands/benchmark.ts +187 -0
- package/src/commands/diagnose.ts +56 -14
- package/src/commands/doctor.ts +243 -0
- package/src/commands/evolution.ts +107 -0
- package/src/commands/fix.ts +22 -2
- package/src/commands/hooks.ts +136 -0
- package/src/commands/mcp.ts +129 -0
- package/src/commands/restart.ts +14 -0
- package/src/commands/score.ts +164 -96
- package/src/commands/start.ts +74 -15
- package/src/commands/stats.ts +103 -0
- package/src/commands/status.ts +157 -0
- package/src/commands/stop.ts +23 -4
- package/src/commands/sync.ts +253 -20
- package/src/commands/vaccine.ts +177 -0
- package/src/constants.ts +25 -1
- package/src/core/boast.ts +27 -12
- package/src/core/db.ts +74 -3
- package/src/core/evolution.ts +215 -0
- package/src/core/hologram/engine.ts +71 -0
- package/src/core/hologram/fallback.ts +11 -0
- package/src/core/hologram/incremental.ts +227 -0
- package/src/core/hologram/py-extractor.ts +132 -0
- package/src/core/hologram/ts-extractor.ts +320 -0
- package/src/core/hologram/types.ts +25 -0
- package/src/core/hologram.ts +64 -236
- package/src/core/hook-manager.ts +259 -0
- package/src/core/i18n/messages.ts +43 -0
- package/src/core/immune.ts +8 -123
- package/src/core/log-rotate.ts +33 -0
- package/src/core/log-utils.ts +38 -0
- package/src/core/lru-map.ts +61 -0
- package/src/core/notify.ts +27 -19
- package/src/core/rule-engine.ts +287 -0
- package/src/core/semantic-diff.ts +432 -0
- package/src/core/telemetry.ts +94 -0
- package/src/core/vaccine-registry.ts +212 -0
- package/src/core/workspace.ts +28 -0
- package/src/core/yaml-minimal.ts +176 -0
- package/src/daemon/client.ts +34 -6
- package/src/daemon/event-batcher.ts +108 -0
- package/src/daemon/guards.ts +13 -0
- package/src/daemon/http-routes.ts +293 -0
- package/src/daemon/mcp-handler.ts +270 -0
- package/src/daemon/server.ts +439 -353
- package/src/daemon/types.ts +100 -0
- package/src/daemon/workspace-map.ts +92 -0
- package/src/platform.ts +23 -2
- package/src/version.ts +15 -0
package/src/core/immune.ts
CHANGED
|
@@ -1,4 +1,6 @@
|
|
|
1
|
-
import {
|
|
1
|
+
import { diagnoseWithRules, loadAllRules, evaluateRules } from "./rule-engine";
|
|
2
|
+
export type { DiagnosticRule } from "./rule-engine";
|
|
3
|
+
export { loadAllRules, evaluateRules };
|
|
2
4
|
|
|
3
5
|
// RFC 6902 JSON-Patch operation
|
|
4
6
|
export interface PatchOp {
|
|
@@ -24,127 +26,10 @@ export interface DiagnosisResult {
|
|
|
24
26
|
healthy: string[];
|
|
25
27
|
}
|
|
26
28
|
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
*.log
|
|
32
|
-
.env
|
|
33
|
-
`;
|
|
34
|
-
|
|
35
|
-
const HOOKS_DEFAULT = `{
|
|
36
|
-
"hooks": []
|
|
37
|
-
}
|
|
38
|
-
`;
|
|
39
|
-
|
|
40
|
-
type Check = () => Symptom | null;
|
|
41
|
-
|
|
42
|
-
const checks: Check[] = [
|
|
43
|
-
// Check 1: Missing .claudeignore
|
|
44
|
-
() => {
|
|
45
|
-
if (existsSync(".claudeignore")) return null;
|
|
46
|
-
return {
|
|
47
|
-
id: "IMM-001",
|
|
48
|
-
patternType: "missing-file",
|
|
49
|
-
fileTarget: ".claudeignore",
|
|
50
|
-
title: "Missing .claudeignore",
|
|
51
|
-
description:
|
|
52
|
-
"No .claudeignore found. Without it, AI agents ingest node_modules, build artifacts, and other noise — wasting tokens and degrading context quality.",
|
|
53
|
-
severity: "critical",
|
|
54
|
-
patches: [
|
|
55
|
-
{ op: "add", path: "/.claudeignore", value: CLAUDEIGNORE_DEFAULT },
|
|
56
|
-
],
|
|
57
|
-
};
|
|
58
|
-
},
|
|
59
|
-
|
|
60
|
-
// Check 2: Missing .claude/hooks.json fallback
|
|
61
|
-
() => {
|
|
62
|
-
const hooksPath = ".claude/hooks.json";
|
|
63
|
-
if (existsSync(hooksPath)) {
|
|
64
|
-
try {
|
|
65
|
-
const content = readFileSync(hooksPath, "utf-8");
|
|
66
|
-
JSON.parse(content);
|
|
67
|
-
return null;
|
|
68
|
-
} catch {
|
|
69
|
-
return {
|
|
70
|
-
id: "IMM-002",
|
|
71
|
-
patternType: "invalid-json",
|
|
72
|
-
fileTarget: hooksPath,
|
|
73
|
-
title: "Invalid hooks.json",
|
|
74
|
-
description:
|
|
75
|
-
"hooks.json exists but contains invalid JSON. Agents may fail silently when hooks cannot be parsed.",
|
|
76
|
-
severity: "critical",
|
|
77
|
-
patches: [
|
|
78
|
-
{ op: "replace", path: "/.claude/hooks.json", value: HOOKS_DEFAULT },
|
|
79
|
-
],
|
|
80
|
-
};
|
|
81
|
-
}
|
|
82
|
-
}
|
|
83
|
-
return {
|
|
84
|
-
id: "IMM-002",
|
|
85
|
-
patternType: "missing-file",
|
|
86
|
-
fileTarget: hooksPath,
|
|
87
|
-
title: "No fallback in hooks.json",
|
|
88
|
-
description:
|
|
89
|
-
"No .claude/hooks.json found. Without a hooks file, pre/post-command automation cannot be configured.",
|
|
90
|
-
severity: "warning",
|
|
91
|
-
patches: [
|
|
92
|
-
{ op: "add", path: "/.claude/hooks.json", value: HOOKS_DEFAULT },
|
|
93
|
-
],
|
|
94
|
-
};
|
|
95
|
-
},
|
|
96
|
-
|
|
97
|
-
// Check 3: CLAUDE.md missing or empty
|
|
98
|
-
() => {
|
|
99
|
-
if (!existsSync("CLAUDE.md")) {
|
|
100
|
-
return {
|
|
101
|
-
id: "IMM-003",
|
|
102
|
-
patternType: "missing-file",
|
|
103
|
-
fileTarget: "CLAUDE.md",
|
|
104
|
-
title: "Missing CLAUDE.md",
|
|
105
|
-
description:
|
|
106
|
-
"No CLAUDE.md found. AI agents have no project constitution to follow.",
|
|
107
|
-
severity: "critical",
|
|
108
|
-
patches: [
|
|
109
|
-
{ op: "add", path: "/CLAUDE.md", value: "# Project Constitution\n\n<!-- Add project rules here -->\n" },
|
|
110
|
-
],
|
|
111
|
-
};
|
|
112
|
-
}
|
|
113
|
-
const content = readFileSync("CLAUDE.md", "utf-8").trim();
|
|
114
|
-
if (content.length < 20) {
|
|
115
|
-
return {
|
|
116
|
-
id: "IMM-003",
|
|
117
|
-
patternType: "insufficient-content",
|
|
118
|
-
fileTarget: "CLAUDE.md",
|
|
119
|
-
title: "CLAUDE.md is nearly empty",
|
|
120
|
-
description:
|
|
121
|
-
`CLAUDE.md has only ${content.length} chars. Agents work better with clear project rules.`,
|
|
122
|
-
severity: "info",
|
|
123
|
-
patches: [],
|
|
124
|
-
};
|
|
125
|
-
}
|
|
126
|
-
return null;
|
|
127
|
-
},
|
|
128
|
-
];
|
|
129
|
-
|
|
29
|
+
/**
|
|
30
|
+
* Run diagnosis using the rule engine.
|
|
31
|
+
* Loads built-in rules + custom .afd/rules/*.yml rules.
|
|
32
|
+
*/
|
|
130
33
|
export function diagnose(knownAntibodies: string[], opts?: { raw?: boolean }): DiagnosisResult {
|
|
131
|
-
|
|
132
|
-
const healthy: string[] = [];
|
|
133
|
-
|
|
134
|
-
for (const check of checks) {
|
|
135
|
-
const symptom = check();
|
|
136
|
-
if (!symptom) {
|
|
137
|
-
healthy.push("OK");
|
|
138
|
-
continue;
|
|
139
|
-
}
|
|
140
|
-
// In raw mode, report all symptoms regardless of antibodies
|
|
141
|
-
// (used by auto-heal to detect regressions)
|
|
142
|
-
if (!opts?.raw && knownAntibodies.includes(symptom.id)) {
|
|
143
|
-
healthy.push(`${symptom.id} (immunized)`);
|
|
144
|
-
continue;
|
|
145
|
-
}
|
|
146
|
-
symptoms.push(symptom);
|
|
147
|
-
}
|
|
148
|
-
|
|
149
|
-
return { symptoms, healthy };
|
|
34
|
+
return diagnoseWithRules(knownAntibodies, opts);
|
|
150
35
|
}
|
|
@@ -0,0 +1,33 @@
|
|
|
1
|
+
import { existsSync, statSync, renameSync, unlinkSync } from "fs";
|
|
2
|
+
|
|
3
|
+
const MAX_LOG_SIZE = 5 * 1024 * 1024; // 5 MB
|
|
4
|
+
const MAX_ROTATED_FILES = 3; // daemon.log.1, .2, .3
|
|
5
|
+
|
|
6
|
+
/**
|
|
7
|
+
* Rotate log file if it exceeds MAX_LOG_SIZE.
|
|
8
|
+
* Keeps up to MAX_ROTATED_FILES old logs.
|
|
9
|
+
*
|
|
10
|
+
* daemon.log → daemon.log.1 → daemon.log.2 → daemon.log.3 (deleted)
|
|
11
|
+
*/
|
|
12
|
+
export function rotateLogIfNeeded(logPath: string): void {
|
|
13
|
+
if (!existsSync(logPath)) return;
|
|
14
|
+
|
|
15
|
+
try {
|
|
16
|
+
const { size } = statSync(logPath);
|
|
17
|
+
if (size < MAX_LOG_SIZE) return;
|
|
18
|
+
|
|
19
|
+
// Shift existing rotated files: .3→delete, .2→.3, .1→.2
|
|
20
|
+
for (let i = MAX_ROTATED_FILES; i >= 1; i--) {
|
|
21
|
+
const src = i === 1 ? logPath : `${logPath}.${i - 1}`;
|
|
22
|
+
const dst = `${logPath}.${i}`;
|
|
23
|
+
if (!existsSync(src)) continue;
|
|
24
|
+
if (i === MAX_ROTATED_FILES && existsSync(dst)) {
|
|
25
|
+
unlinkSync(dst);
|
|
26
|
+
}
|
|
27
|
+
renameSync(src, dst);
|
|
28
|
+
}
|
|
29
|
+
// logPath has been renamed to logPath.1, fresh log will be created on open
|
|
30
|
+
} catch {
|
|
31
|
+
// Non-critical: if rotation fails, just keep appending
|
|
32
|
+
}
|
|
33
|
+
}
|
|
@@ -0,0 +1,38 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Logging utilities for the afd daemon.
|
|
3
|
+
* Extracted for testability and reuse.
|
|
4
|
+
*/
|
|
5
|
+
|
|
6
|
+
/** Format current time as HH:MM:SS.mmm */
|
|
7
|
+
export function formatTimestamp(date: Date = new Date()): string {
|
|
8
|
+
const hh = String(date.getHours()).padStart(2, "0");
|
|
9
|
+
const mm = String(date.getMinutes()).padStart(2, "0");
|
|
10
|
+
const ss = String(date.getSeconds()).padStart(2, "0");
|
|
11
|
+
const ms = String(date.getMilliseconds()).padStart(3, "0");
|
|
12
|
+
return `${hh}:${mm}:${ss}.${ms}`;
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
/**
|
|
16
|
+
* Build a concise line-diff between old and new content.
|
|
17
|
+
* Returns at most `maxLines` diff entries (default 10).
|
|
18
|
+
*/
|
|
19
|
+
export function lineDiff(oldText: string, newText: string, maxLines = 10): string[] {
|
|
20
|
+
const oldLines = oldText.split("\n");
|
|
21
|
+
const newLines = newText.split("\n");
|
|
22
|
+
const diffs: string[] = [];
|
|
23
|
+
const maxLen = Math.max(oldLines.length, newLines.length);
|
|
24
|
+
for (let i = 0; i < maxLen; i++) {
|
|
25
|
+
if (oldLines[i] !== newLines[i]) {
|
|
26
|
+
const ln = i + 1;
|
|
27
|
+
if (i < oldLines.length && i < newLines.length) {
|
|
28
|
+
diffs.push(` L${ln}: "${oldLines[i].trimEnd()}" → "${newLines[i].trimEnd()}"`);
|
|
29
|
+
} else if (i < oldLines.length) {
|
|
30
|
+
diffs.push(` L${ln}: - "${oldLines[i].trimEnd()}"`);
|
|
31
|
+
} else {
|
|
32
|
+
diffs.push(` L${ln}: + "${newLines[i]!.trimEnd()}"`);
|
|
33
|
+
}
|
|
34
|
+
}
|
|
35
|
+
if (diffs.length >= maxLines) { diffs.push(" ... (truncated)"); break; }
|
|
36
|
+
}
|
|
37
|
+
return diffs;
|
|
38
|
+
}
|
|
@@ -0,0 +1,61 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Size-bounded LRU Map that evicts by total byte size of values.
|
|
3
|
+
* Uses Map insertion order for LRU eviction (oldest first).
|
|
4
|
+
*/
|
|
5
|
+
export class LruStringMap {
|
|
6
|
+
private map = new Map<string, string>();
|
|
7
|
+
private currentBytes = 0;
|
|
8
|
+
|
|
9
|
+
constructor(private readonly maxBytes: number) {}
|
|
10
|
+
|
|
11
|
+
get(key: string): string | undefined {
|
|
12
|
+
const val = this.map.get(key);
|
|
13
|
+
if (val !== undefined) {
|
|
14
|
+
// Move to end (most recently used)
|
|
15
|
+
this.map.delete(key);
|
|
16
|
+
this.map.set(key, val);
|
|
17
|
+
}
|
|
18
|
+
return val;
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
/** Returns false if the value was too large to store (exceeds maxBytes). */
|
|
22
|
+
set(key: string, value: string): boolean {
|
|
23
|
+
const valueBytes = value.length * 2;
|
|
24
|
+
|
|
25
|
+
// Skip if single value exceeds budget (check BEFORE removing old entry)
|
|
26
|
+
if (valueBytes > this.maxBytes) return false;
|
|
27
|
+
|
|
28
|
+
// Remove old entry if exists
|
|
29
|
+
const old = this.map.get(key);
|
|
30
|
+
if (old !== undefined) {
|
|
31
|
+
this.currentBytes -= old.length * 2; // JS string ≈ 2 bytes per char
|
|
32
|
+
this.map.delete(key);
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
// Evict oldest entries until we have room
|
|
36
|
+
while (this.currentBytes + valueBytes > this.maxBytes && this.map.size > 0) {
|
|
37
|
+
const first = this.map.keys().next();
|
|
38
|
+
if (first.done) break;
|
|
39
|
+
const evicted = this.map.get(first.value)!;
|
|
40
|
+
this.currentBytes -= evicted.length * 2;
|
|
41
|
+
this.map.delete(first.value);
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
this.map.set(key, value);
|
|
45
|
+
this.currentBytes += valueBytes;
|
|
46
|
+
return true;
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
delete(key: string): boolean {
|
|
50
|
+
const val = this.map.get(key);
|
|
51
|
+
if (val !== undefined) {
|
|
52
|
+
this.currentBytes -= val.length * 2;
|
|
53
|
+
this.map.delete(key);
|
|
54
|
+
return true;
|
|
55
|
+
}
|
|
56
|
+
return false;
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
get size(): number { return this.map.size; }
|
|
60
|
+
get bytes(): number { return this.currentBytes; }
|
|
61
|
+
}
|
package/src/core/notify.ts
CHANGED
|
@@ -26,13 +26,33 @@ export function notifyAutoHeal(patternId: string): void {
|
|
|
26
26
|
}
|
|
27
27
|
}
|
|
28
28
|
|
|
29
|
+
/** Escape single quotes for PowerShell string literals */
|
|
30
|
+
function escapePS(s: string): string {
|
|
31
|
+
return s.replace(/'/g, "''");
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
/** Escape double quotes and backslashes for AppleScript string literals */
|
|
35
|
+
function escapeAS(s: string): string {
|
|
36
|
+
return s.replace(/\\/g, "\\\\").replace(/"/g, '\\"');
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
function safeSpawn(cmd: string, args: string[], opts: Record<string, unknown> = {}): void {
|
|
40
|
+
try {
|
|
41
|
+
const child = spawn(cmd, args, { detached: true, stdio: "ignore", ...opts });
|
|
42
|
+
child.on("error", () => {}); // Swallow async ENOENT when binary is missing
|
|
43
|
+
child.unref();
|
|
44
|
+
} catch {
|
|
45
|
+
// Binary not found or spawn failed — silently ignore
|
|
46
|
+
}
|
|
47
|
+
}
|
|
48
|
+
|
|
29
49
|
function notifyWindows(title: string, body: string): void {
|
|
30
50
|
const ps = `
|
|
31
51
|
Add-Type -AssemblyName System.Windows.Forms
|
|
32
52
|
$n = New-Object System.Windows.Forms.NotifyIcon
|
|
33
53
|
$n.Icon = [System.Drawing.SystemIcons]::Shield
|
|
34
|
-
$n.BalloonTipTitle = '${title}'
|
|
35
|
-
$n.BalloonTipText = '${body}'
|
|
54
|
+
$n.BalloonTipTitle = '${escapePS(title)}'
|
|
55
|
+
$n.BalloonTipText = '${escapePS(body)}'
|
|
36
56
|
$n.BalloonTipIcon = 'Info'
|
|
37
57
|
$n.Visible = $true
|
|
38
58
|
$n.ShowBalloonTip(3000)
|
|
@@ -40,27 +60,15 @@ function notifyWindows(title: string, body: string): void {
|
|
|
40
60
|
$n.Dispose()
|
|
41
61
|
`.replace(/\n\s*/g, " ");
|
|
42
62
|
|
|
43
|
-
|
|
44
|
-
detached: true,
|
|
45
|
-
stdio: "ignore",
|
|
46
|
-
windowsHide: true,
|
|
47
|
-
});
|
|
48
|
-
child.unref();
|
|
63
|
+
safeSpawn("powershell", ["-NoProfile", "-NonInteractive", "-Command", ps], { windowsHide: true });
|
|
49
64
|
}
|
|
50
65
|
|
|
51
66
|
function notifyMacOS(title: string, body: string): void {
|
|
52
|
-
const script = `display notification "${body}" with title "${title}"`;
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
stdio: "ignore",
|
|
56
|
-
});
|
|
57
|
-
child.unref();
|
|
67
|
+
const script = `display notification "${escapeAS(body)}" with title "${escapeAS(title)}"`;
|
|
68
|
+
|
|
69
|
+
safeSpawn("osascript", ["-e", script]);
|
|
58
70
|
}
|
|
59
71
|
|
|
60
72
|
function notifyLinux(title: string, body: string): void {
|
|
61
|
-
|
|
62
|
-
detached: true,
|
|
63
|
-
stdio: "ignore",
|
|
64
|
-
});
|
|
65
|
-
child.unref();
|
|
73
|
+
safeSpawn("notify-send", [title, body, "--icon=dialog-information"]);
|
|
66
74
|
}
|
|
@@ -0,0 +1,287 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Custom Diagnostic Rule Engine
|
|
3
|
+
*
|
|
4
|
+
* Loads diagnostic rules from:
|
|
5
|
+
* 1. Built-in rules (hardcoded IMM-001~003 equivalents)
|
|
6
|
+
* 2. Project rules: .afd/rules/*.yml
|
|
7
|
+
*
|
|
8
|
+
* Each rule defines a condition + severity + auto-heal patches.
|
|
9
|
+
*/
|
|
10
|
+
|
|
11
|
+
import { existsSync, readFileSync, readdirSync, statSync } from "fs";
|
|
12
|
+
import { join, extname } from "path";
|
|
13
|
+
import { parse as parseYaml } from "./yaml-minimal";
|
|
14
|
+
|
|
15
|
+
// Re-declare types locally to avoid circular import with immune.ts
|
|
16
|
+
interface Symptom {
|
|
17
|
+
id: string;
|
|
18
|
+
patternType: string;
|
|
19
|
+
fileTarget: string;
|
|
20
|
+
title: string;
|
|
21
|
+
description: string;
|
|
22
|
+
severity: "critical" | "warning" | "info";
|
|
23
|
+
patches: PatchOp[];
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
interface PatchOp {
|
|
27
|
+
op: "add" | "remove" | "replace" | "move" | "copy" | "test";
|
|
28
|
+
path: string;
|
|
29
|
+
value?: string;
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
// ── Rule Definition ──
|
|
33
|
+
|
|
34
|
+
export interface DiagnosticRule {
|
|
35
|
+
id: string;
|
|
36
|
+
title: string;
|
|
37
|
+
description: string;
|
|
38
|
+
severity: "critical" | "warning" | "info";
|
|
39
|
+
condition: RuleCondition;
|
|
40
|
+
patches: PatchOp[];
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
export type RuleCondition =
|
|
44
|
+
| { type: "file-missing"; path: string }
|
|
45
|
+
| { type: "file-empty"; path: string; minLength?: number }
|
|
46
|
+
| { type: "file-invalid-json"; path: string }
|
|
47
|
+
| { type: "file-missing-line"; path: string; pattern: string }
|
|
48
|
+
| { type: "file-contains"; path: string; pattern: string }; // inverse: triggers when pattern IS found
|
|
49
|
+
|
|
50
|
+
// ── Built-in Rules (IMM-001~003) ──
|
|
51
|
+
|
|
52
|
+
const CLAUDEIGNORE_DEFAULT = `# Autonomous Flow Daemon defaults
|
|
53
|
+
node_modules/
|
|
54
|
+
dist/
|
|
55
|
+
.afd/
|
|
56
|
+
*.log
|
|
57
|
+
.env
|
|
58
|
+
`;
|
|
59
|
+
|
|
60
|
+
const HOOKS_DEFAULT = `{
|
|
61
|
+
"hooks": []
|
|
62
|
+
}
|
|
63
|
+
`;
|
|
64
|
+
|
|
65
|
+
const BUILTIN_RULES: DiagnosticRule[] = [
|
|
66
|
+
{
|
|
67
|
+
id: "IMM-001",
|
|
68
|
+
title: "Missing .claudeignore",
|
|
69
|
+
description:
|
|
70
|
+
"No .claudeignore found. Without it, AI agents ingest node_modules, build artifacts, and other noise — wasting tokens and degrading context quality.",
|
|
71
|
+
severity: "critical",
|
|
72
|
+
condition: { type: "file-missing", path: ".claudeignore" },
|
|
73
|
+
patches: [{ op: "add", path: "/.claudeignore", value: CLAUDEIGNORE_DEFAULT }],
|
|
74
|
+
},
|
|
75
|
+
{
|
|
76
|
+
id: "IMM-002",
|
|
77
|
+
title: "Missing or invalid hooks.json",
|
|
78
|
+
description:
|
|
79
|
+
"No valid .claude/hooks.json found. Without a hooks file, pre/post-command automation cannot be configured.",
|
|
80
|
+
severity: "warning",
|
|
81
|
+
condition: { type: "file-invalid-json", path: ".claude/hooks.json" },
|
|
82
|
+
patches: [{ op: "add", path: "/.claude/hooks.json", value: HOOKS_DEFAULT }],
|
|
83
|
+
},
|
|
84
|
+
{
|
|
85
|
+
id: "IMM-003",
|
|
86
|
+
title: "Missing or empty CLAUDE.md",
|
|
87
|
+
description:
|
|
88
|
+
"No CLAUDE.md found or content is too short. AI agents have no project constitution to follow.",
|
|
89
|
+
severity: "critical",
|
|
90
|
+
condition: { type: "file-empty", path: "CLAUDE.md", minLength: 20 },
|
|
91
|
+
patches: [{ op: "add", path: "/CLAUDE.md", value: "# Project Constitution\n\n<!-- Add project rules here -->\n" }],
|
|
92
|
+
},
|
|
93
|
+
];
|
|
94
|
+
|
|
95
|
+
// ── Condition Evaluator ──
|
|
96
|
+
|
|
97
|
+
function evaluateCondition(cond: RuleCondition): { triggered: boolean; detail?: string } {
|
|
98
|
+
switch (cond.type) {
|
|
99
|
+
case "file-missing":
|
|
100
|
+
return { triggered: !existsSync(cond.path) };
|
|
101
|
+
|
|
102
|
+
case "file-empty": {
|
|
103
|
+
if (!existsSync(cond.path)) return { triggered: true, detail: "file not found" };
|
|
104
|
+
try {
|
|
105
|
+
const content = readFileSync(cond.path, "utf-8").trim();
|
|
106
|
+
const min = cond.minLength ?? 1;
|
|
107
|
+
return {
|
|
108
|
+
triggered: content.length < min,
|
|
109
|
+
detail: `${content.length} chars (min: ${min})`,
|
|
110
|
+
};
|
|
111
|
+
} catch {
|
|
112
|
+
return { triggered: true, detail: "unreadable" };
|
|
113
|
+
}
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
case "file-invalid-json": {
|
|
117
|
+
if (!existsSync(cond.path)) return { triggered: true, detail: "file not found" };
|
|
118
|
+
try {
|
|
119
|
+
JSON.parse(readFileSync(cond.path, "utf-8"));
|
|
120
|
+
return { triggered: false };
|
|
121
|
+
} catch {
|
|
122
|
+
return { triggered: true, detail: "invalid JSON" };
|
|
123
|
+
}
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
case "file-missing-line": {
|
|
127
|
+
if (!existsSync(cond.path)) return { triggered: true, detail: "file not found" };
|
|
128
|
+
try {
|
|
129
|
+
const content = readFileSync(cond.path, "utf-8");
|
|
130
|
+
const regex = new RegExp(cond.pattern);
|
|
131
|
+
return { triggered: !regex.test(content), detail: `pattern /${cond.pattern}/ not found` };
|
|
132
|
+
} catch {
|
|
133
|
+
return { triggered: true, detail: "unreadable" };
|
|
134
|
+
}
|
|
135
|
+
}
|
|
136
|
+
|
|
137
|
+
case "file-contains": {
|
|
138
|
+
if (!existsSync(cond.path)) return { triggered: false }; // file doesn't exist → pattern can't be found
|
|
139
|
+
try {
|
|
140
|
+
const content = readFileSync(cond.path, "utf-8");
|
|
141
|
+
const regex = new RegExp(cond.pattern);
|
|
142
|
+
return { triggered: regex.test(content), detail: `pattern /${cond.pattern}/ found` };
|
|
143
|
+
} catch {
|
|
144
|
+
return { triggered: false };
|
|
145
|
+
}
|
|
146
|
+
}
|
|
147
|
+
|
|
148
|
+
default:
|
|
149
|
+
return { triggered: false };
|
|
150
|
+
}
|
|
151
|
+
}
|
|
152
|
+
|
|
153
|
+
// ── Rule Loader ──
|
|
154
|
+
|
|
155
|
+
function loadYamlRules(rulesDir: string): DiagnosticRule[] {
|
|
156
|
+
if (!existsSync(rulesDir)) return [];
|
|
157
|
+
|
|
158
|
+
const rules: DiagnosticRule[] = [];
|
|
159
|
+
|
|
160
|
+
let files: string[];
|
|
161
|
+
try {
|
|
162
|
+
files = readdirSync(rulesDir).filter(f => extname(f) === ".yml" || extname(f) === ".yaml");
|
|
163
|
+
} catch {
|
|
164
|
+
return [];
|
|
165
|
+
}
|
|
166
|
+
|
|
167
|
+
for (const file of files) {
|
|
168
|
+
const filePath = join(rulesDir, file);
|
|
169
|
+
try {
|
|
170
|
+
if (!statSync(filePath).isFile()) continue;
|
|
171
|
+
const content = readFileSync(filePath, "utf-8");
|
|
172
|
+
const parsed = parseYaml(content);
|
|
173
|
+
if (!parsed || !parsed.id || !parsed.condition) continue;
|
|
174
|
+
|
|
175
|
+
const rule: DiagnosticRule = {
|
|
176
|
+
id: String(parsed.id),
|
|
177
|
+
title: String(parsed.title ?? parsed.id),
|
|
178
|
+
description: String(parsed.description ?? ""),
|
|
179
|
+
severity: validateSeverity(parsed.severity),
|
|
180
|
+
condition: parseCondition(parsed.condition),
|
|
181
|
+
patches: parsePatchOps(parsed.patches),
|
|
182
|
+
};
|
|
183
|
+
rules.push(rule);
|
|
184
|
+
} catch {
|
|
185
|
+
// Skip malformed rule files — crash-only design
|
|
186
|
+
}
|
|
187
|
+
}
|
|
188
|
+
|
|
189
|
+
return rules;
|
|
190
|
+
}
|
|
191
|
+
|
|
192
|
+
function validateSeverity(val: unknown): "critical" | "warning" | "info" {
|
|
193
|
+
if (val === "critical" || val === "warning" || val === "info") return val;
|
|
194
|
+
return "warning";
|
|
195
|
+
}
|
|
196
|
+
|
|
197
|
+
function parseCondition(raw: unknown): RuleCondition {
|
|
198
|
+
if (!raw || typeof raw !== "object") return { type: "file-missing", path: "" };
|
|
199
|
+
const obj = raw as Record<string, unknown>;
|
|
200
|
+
const type = String(obj.type ?? "file-missing");
|
|
201
|
+
const path = String(obj.path ?? "");
|
|
202
|
+
|
|
203
|
+
switch (type) {
|
|
204
|
+
case "file-missing":
|
|
205
|
+
return { type: "file-missing", path };
|
|
206
|
+
case "file-empty":
|
|
207
|
+
return { type: "file-empty", path, minLength: Number(obj.minLength ?? 1) };
|
|
208
|
+
case "file-invalid-json":
|
|
209
|
+
return { type: "file-invalid-json", path };
|
|
210
|
+
case "file-missing-line":
|
|
211
|
+
return { type: "file-missing-line", path, pattern: String(obj.pattern ?? "") };
|
|
212
|
+
case "file-contains":
|
|
213
|
+
return { type: "file-contains", path, pattern: String(obj.pattern ?? "") };
|
|
214
|
+
default:
|
|
215
|
+
return { type: "file-missing", path };
|
|
216
|
+
}
|
|
217
|
+
}
|
|
218
|
+
|
|
219
|
+
function parsePatchOps(raw: unknown): PatchOp[] {
|
|
220
|
+
if (!Array.isArray(raw)) return [];
|
|
221
|
+
return raw
|
|
222
|
+
.filter((p): p is Record<string, unknown> => p && typeof p === "object")
|
|
223
|
+
.map(p => ({
|
|
224
|
+
op: (String(p.op ?? "add")) as PatchOp["op"],
|
|
225
|
+
path: String(p.path ?? ""),
|
|
226
|
+
value: p.value !== undefined ? String(p.value) : undefined,
|
|
227
|
+
}));
|
|
228
|
+
}
|
|
229
|
+
|
|
230
|
+
// ── Public API ──
|
|
231
|
+
|
|
232
|
+
const RULES_DIR = join(".afd", "rules");
|
|
233
|
+
|
|
234
|
+
export function loadAllRules(): DiagnosticRule[] {
|
|
235
|
+
const custom = loadYamlRules(RULES_DIR);
|
|
236
|
+
// Custom rules can override built-in by ID
|
|
237
|
+
const customIds = new Set(custom.map(r => r.id));
|
|
238
|
+
const builtins = BUILTIN_RULES.filter(r => !customIds.has(r.id));
|
|
239
|
+
return [...builtins, ...custom];
|
|
240
|
+
}
|
|
241
|
+
|
|
242
|
+
export function evaluateRules(
|
|
243
|
+
rules: DiagnosticRule[],
|
|
244
|
+
knownAntibodies: string[],
|
|
245
|
+
opts?: { raw?: boolean },
|
|
246
|
+
): { symptoms: Symptom[]; healthy: string[] } {
|
|
247
|
+
const symptoms: Symptom[] = [];
|
|
248
|
+
const healthy: string[] = [];
|
|
249
|
+
|
|
250
|
+
for (const rule of rules) {
|
|
251
|
+
const result = evaluateCondition(rule.condition);
|
|
252
|
+
|
|
253
|
+
if (!result.triggered) {
|
|
254
|
+
healthy.push("OK");
|
|
255
|
+
continue;
|
|
256
|
+
}
|
|
257
|
+
|
|
258
|
+
// In raw mode, report all symptoms regardless of antibodies
|
|
259
|
+
if (!opts?.raw && knownAntibodies.includes(rule.id)) {
|
|
260
|
+
healthy.push(`${rule.id} (immunized)`);
|
|
261
|
+
continue;
|
|
262
|
+
}
|
|
263
|
+
|
|
264
|
+
symptoms.push({
|
|
265
|
+
id: rule.id,
|
|
266
|
+
patternType: rule.condition.type,
|
|
267
|
+
fileTarget: "path" in rule.condition ? rule.condition.path : "",
|
|
268
|
+
title: rule.title,
|
|
269
|
+
description: result.detail
|
|
270
|
+
? `${rule.description} (${result.detail})`
|
|
271
|
+
: rule.description,
|
|
272
|
+
severity: rule.severity,
|
|
273
|
+
patches: rule.patches,
|
|
274
|
+
});
|
|
275
|
+
}
|
|
276
|
+
|
|
277
|
+
return { symptoms, healthy };
|
|
278
|
+
}
|
|
279
|
+
|
|
280
|
+
/** Convenience: load rules + evaluate in one call (replaces old diagnose()) */
|
|
281
|
+
export function diagnoseWithRules(
|
|
282
|
+
knownAntibodies: string[],
|
|
283
|
+
opts?: { raw?: boolean },
|
|
284
|
+
): { symptoms: Symptom[]; healthy: string[] } {
|
|
285
|
+
const rules = loadAllRules();
|
|
286
|
+
return evaluateRules(rules, knownAntibodies, opts);
|
|
287
|
+
}
|