autonomous-flow-daemon 1.1.0 → 1.9.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 +85 -46
- package/LICENSE +21 -21
- package/README-ko.md +282 -0
- package/README.md +282 -337
- package/mcp-config.json +10 -10
- package/package.json +14 -6
- package/src/adapters/index.ts +370 -159
- package/src/cli.ts +162 -57
- package/src/commands/benchmark.ts +187 -0
- package/src/commands/correlate.ts +180 -0
- package/src/commands/dashboard.ts +404 -0
- package/src/commands/diagnose.ts +56 -14
- package/src/commands/doctor.ts +243 -0
- package/src/commands/evolution.ts +190 -0
- package/src/commands/fix.ts +158 -138
- package/src/commands/hooks.ts +136 -0
- package/src/commands/lang.ts +41 -41
- package/src/commands/mcp.ts +129 -0
- package/src/commands/plugin.ts +110 -0
- package/src/commands/restart.ts +14 -0
- package/src/commands/score.ts +276 -208
- package/src/commands/start.ts +155 -96
- package/src/commands/stats.ts +103 -0
- package/src/commands/status.ts +157 -0
- package/src/commands/stop.ts +68 -49
- package/src/commands/suggest.ts +211 -0
- package/src/commands/sync.ts +567 -21
- package/src/commands/vaccine.ts +177 -0
- package/src/constants.ts +32 -8
- package/src/core/boast.ts +280 -265
- package/src/core/config.ts +49 -49
- package/src/core/correlation-engine.ts +265 -0
- package/src/core/db.ts +145 -46
- package/src/core/discovery.ts +65 -65
- package/src/core/evolution.ts +215 -0
- package/src/core/federation.ts +129 -0
- package/src/core/hologram/engine.ts +71 -0
- package/src/core/hologram/fallback.ts +11 -0
- package/src/core/hologram/go-extractor.ts +203 -0
- package/src/core/hologram/incremental.ts +227 -0
- package/src/core/hologram/py-extractor.ts +132 -0
- package/src/core/hologram/rust-extractor.ts +244 -0
- package/src/core/hologram/ts-extractor.ts +406 -0
- package/src/core/hologram/types.ts +27 -0
- package/src/core/hologram.ts +73 -243
- package/src/core/hook-manager.ts +259 -0
- package/src/core/i18n/messages.ts +309 -266
- package/src/core/immune.ts +8 -123
- package/src/core/locale.ts +88 -88
- 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 +74 -66
- package/src/core/plugin-manager.ts +225 -0
- package/src/core/rule-engine.ts +287 -0
- package/src/core/rule-suggestion.ts +127 -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/validator-generator.ts +224 -0
- package/src/core/workspace.ts +28 -0
- package/src/core/yaml-minimal.ts +176 -0
- package/src/daemon/client.ts +78 -37
- package/src/daemon/event-batcher.ts +108 -0
- package/src/daemon/guards.ts +13 -0
- package/src/daemon/http-routes.ts +376 -0
- package/src/daemon/mcp-handler.ts +575 -0
- package/src/daemon/mcp-subscriptions.ts +81 -0
- package/src/daemon/mesh.ts +51 -0
- package/src/daemon/server.ts +655 -504
- package/src/daemon/types.ts +121 -0
- package/src/daemon/workspace-map.ts +104 -0
- package/src/platform.ts +60 -39
- package/src/version.ts +15 -0
- package/README.ko.md +0 -306
package/src/core/hologram.ts
CHANGED
|
@@ -1,243 +1,73 @@
|
|
|
1
|
-
|
|
2
|
-
|
|
3
|
-
|
|
4
|
-
|
|
5
|
-
|
|
6
|
-
|
|
7
|
-
|
|
8
|
-
}
|
|
9
|
-
|
|
10
|
-
|
|
11
|
-
|
|
12
|
-
|
|
13
|
-
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
// Fallback: skip unknown top-level statements
|
|
76
|
-
return null;
|
|
77
|
-
}
|
|
78
|
-
|
|
79
|
-
function extractInterface(node: ts.InterfaceDeclaration): string {
|
|
80
|
-
const mods = getModifiers(node);
|
|
81
|
-
const name = node.name.text;
|
|
82
|
-
const ext = node.heritageClauses
|
|
83
|
-
? " " + node.heritageClauses.map(h => h.getText()).join(", ")
|
|
84
|
-
: "";
|
|
85
|
-
const members = node.members.map(m => {
|
|
86
|
-
const text = collapseWhitespace(m.getText()).replace(/;$/, "");
|
|
87
|
-
return " " + text + ";";
|
|
88
|
-
}).join("\n");
|
|
89
|
-
return `${mods}interface ${name}${ext} {\n${members}\n}`;
|
|
90
|
-
}
|
|
91
|
-
|
|
92
|
-
function extractEnum(node: ts.EnumDeclaration): string {
|
|
93
|
-
const mods = getModifiers(node);
|
|
94
|
-
const name = node.name.text;
|
|
95
|
-
const members = node.members.map(m => collapseWhitespace(m.getText())).join(", ");
|
|
96
|
-
return `${mods}enum ${name} { ${members} }`;
|
|
97
|
-
}
|
|
98
|
-
|
|
99
|
-
function extractClass(node: ts.ClassDeclaration): string {
|
|
100
|
-
const mods = getModifiers(node);
|
|
101
|
-
const name = node.name?.text ?? "Anonymous";
|
|
102
|
-
const ext = node.heritageClauses
|
|
103
|
-
? " " + node.heritageClauses.map(h => h.getText()).join(", ")
|
|
104
|
-
: "";
|
|
105
|
-
|
|
106
|
-
const members: string[] = [];
|
|
107
|
-
for (const member of node.members) {
|
|
108
|
-
if (ts.isPropertyDeclaration(member)) {
|
|
109
|
-
members.push(" " + extractProperty(member) + ";");
|
|
110
|
-
} else if (ts.isMethodDeclaration(member) || ts.isGetAccessor(member) || ts.isSetAccessor(member)) {
|
|
111
|
-
members.push(" " + extractMethodSignature(member) + ";");
|
|
112
|
-
} else if (ts.isConstructorDeclaration(member)) {
|
|
113
|
-
const params = extractParams(member.parameters);
|
|
114
|
-
members.push(` constructor(${params});`);
|
|
115
|
-
}
|
|
116
|
-
}
|
|
117
|
-
|
|
118
|
-
return `${mods}class ${name}${ext} {\n${members.join("\n")}\n}`;
|
|
119
|
-
}
|
|
120
|
-
|
|
121
|
-
function extractFunction(node: ts.FunctionDeclaration): string {
|
|
122
|
-
const mods = getModifiers(node);
|
|
123
|
-
const name = node.name?.text ?? "anonymous";
|
|
124
|
-
const typeParams = node.typeParameters
|
|
125
|
-
? `<${node.typeParameters.map(t => t.getText()).join(", ")}>`
|
|
126
|
-
: "";
|
|
127
|
-
const params = extractParams(node.parameters);
|
|
128
|
-
const ret = node.type ? ": " + collapseWhitespace(node.type.getText()) : "";
|
|
129
|
-
const async = hasModifier(node, ts.SyntaxKind.AsyncKeyword) ? "async " : "";
|
|
130
|
-
return `${mods}${async}function ${name}${typeParams}(${params})${ret} {…}`;
|
|
131
|
-
}
|
|
132
|
-
|
|
133
|
-
function extractVariableStatement(node: ts.VariableStatement): string {
|
|
134
|
-
const mods = getModifiers(node);
|
|
135
|
-
const keyword = node.declarationList.flags & ts.NodeFlags.Const ? "const"
|
|
136
|
-
: node.declarationList.flags & ts.NodeFlags.Let ? "let" : "var";
|
|
137
|
-
|
|
138
|
-
const decls = node.declarationList.declarations.map(d => {
|
|
139
|
-
const name = d.name.getText();
|
|
140
|
-
const typeAnn = d.type ? ": " + collapseWhitespace(d.type.getText()) : "";
|
|
141
|
-
|
|
142
|
-
if (d.initializer) {
|
|
143
|
-
// Arrow function or function expression
|
|
144
|
-
if (ts.isArrowFunction(d.initializer) || ts.isFunctionExpression(d.initializer)) {
|
|
145
|
-
const fn = d.initializer;
|
|
146
|
-
const async = hasModifier(fn, ts.SyntaxKind.AsyncKeyword) ? "async " : "";
|
|
147
|
-
const typeParams = fn.typeParameters
|
|
148
|
-
? `<${fn.typeParameters.map(t => t.getText()).join(", ")}>`
|
|
149
|
-
: "";
|
|
150
|
-
const params = extractParams(fn.parameters);
|
|
151
|
-
const ret = fn.type ? ": " + collapseWhitespace(fn.type.getText()) : "";
|
|
152
|
-
return `${name} = ${async}${typeParams}(${params})${ret} => {…}`;
|
|
153
|
-
}
|
|
154
|
-
// Object/array/other — just show type or truncated value
|
|
155
|
-
if (typeAnn) return `${name}${typeAnn}`;
|
|
156
|
-
return `${name} = …`;
|
|
157
|
-
}
|
|
158
|
-
|
|
159
|
-
return `${name}${typeAnn}`;
|
|
160
|
-
});
|
|
161
|
-
|
|
162
|
-
return `${mods}${keyword} ${decls.join(", ")};`;
|
|
163
|
-
}
|
|
164
|
-
|
|
165
|
-
function extractProperty(node: ts.PropertyDeclaration): string {
|
|
166
|
-
const mods = getMemberModifiers(node);
|
|
167
|
-
const name = node.name.getText();
|
|
168
|
-
const type = node.type ? ": " + collapseWhitespace(node.type.getText()) : "";
|
|
169
|
-
const optional = node.questionToken ? "?" : "";
|
|
170
|
-
return `${mods}${name}${optional}${type}`;
|
|
171
|
-
}
|
|
172
|
-
|
|
173
|
-
function extractMethodSignature(node: ts.MethodDeclaration | ts.GetAccessorDeclaration | ts.SetAccessorDeclaration): string {
|
|
174
|
-
const mods = getMemberModifiers(node);
|
|
175
|
-
const name = node.name.getText();
|
|
176
|
-
const async = hasModifier(node, ts.SyntaxKind.AsyncKeyword) ? "async " : "";
|
|
177
|
-
|
|
178
|
-
if (ts.isGetAccessor(node)) {
|
|
179
|
-
const ret = node.type ? ": " + collapseWhitespace(node.type.getText()) : "";
|
|
180
|
-
return `${mods}get ${name}()${ret}`;
|
|
181
|
-
}
|
|
182
|
-
if (ts.isSetAccessor(node)) {
|
|
183
|
-
const params = extractParams(node.parameters);
|
|
184
|
-
return `${mods}set ${name}(${params})`;
|
|
185
|
-
}
|
|
186
|
-
|
|
187
|
-
const md = node as ts.MethodDeclaration;
|
|
188
|
-
const typeParams = md.typeParameters
|
|
189
|
-
? `<${md.typeParameters.map(t => t.getText()).join(", ")}>`
|
|
190
|
-
: "";
|
|
191
|
-
const params = extractParams(md.parameters);
|
|
192
|
-
const ret = md.type ? ": " + collapseWhitespace(md.type.getText()) : "";
|
|
193
|
-
return `${mods}${async}${name}${typeParams}(${params})${ret}`;
|
|
194
|
-
}
|
|
195
|
-
|
|
196
|
-
function extractParams(params: ts.NodeArray<ts.ParameterDeclaration>): string {
|
|
197
|
-
return params.map(p => {
|
|
198
|
-
const name = p.name.getText();
|
|
199
|
-
const optional = p.questionToken ? "?" : "";
|
|
200
|
-
const type = p.type ? ": " + collapseWhitespace(p.type.getText()) : "";
|
|
201
|
-
const rest = p.dotDotDotToken ? "..." : "";
|
|
202
|
-
return `${rest}${name}${optional}${type}`;
|
|
203
|
-
}).join(", ");
|
|
204
|
-
}
|
|
205
|
-
|
|
206
|
-
function getModifiers(node: ts.Node): string {
|
|
207
|
-
const mods = ts.canHaveModifiers(node) ? ts.getModifiers(node) : undefined;
|
|
208
|
-
if (!mods) return "";
|
|
209
|
-
const relevant = mods
|
|
210
|
-
.filter(m => m.kind === ts.SyntaxKind.ExportKeyword || m.kind === ts.SyntaxKind.DefaultKeyword || m.kind === ts.SyntaxKind.DeclareKeyword)
|
|
211
|
-
.map(m => m.getText());
|
|
212
|
-
return relevant.length ? relevant.join(" ") + " " : "";
|
|
213
|
-
}
|
|
214
|
-
|
|
215
|
-
function getMemberModifiers(node: ts.Node): string {
|
|
216
|
-
const mods = ts.canHaveModifiers(node) ? ts.getModifiers(node) : undefined;
|
|
217
|
-
if (!mods) return "";
|
|
218
|
-
const relevant = mods
|
|
219
|
-
.filter(m =>
|
|
220
|
-
m.kind === ts.SyntaxKind.PublicKeyword ||
|
|
221
|
-
m.kind === ts.SyntaxKind.PrivateKeyword ||
|
|
222
|
-
m.kind === ts.SyntaxKind.ProtectedKeyword ||
|
|
223
|
-
m.kind === ts.SyntaxKind.StaticKeyword ||
|
|
224
|
-
m.kind === ts.SyntaxKind.ReadonlyKeyword ||
|
|
225
|
-
m.kind === ts.SyntaxKind.AbstractKeyword
|
|
226
|
-
)
|
|
227
|
-
.map(m => m.getText());
|
|
228
|
-
return relevant.length ? relevant.join(" ") + " " : "";
|
|
229
|
-
}
|
|
230
|
-
|
|
231
|
-
function hasModifier(node: ts.Node, kind: ts.SyntaxKind): boolean {
|
|
232
|
-
const mods = ts.canHaveModifiers(node) ? ts.getModifiers(node) : undefined;
|
|
233
|
-
return mods?.some(m => m.kind === kind) ?? false;
|
|
234
|
-
}
|
|
235
|
-
|
|
236
|
-
function getTypeName(node: ts.Node): string {
|
|
237
|
-
if (ts.isIdentifier(node)) return node.text;
|
|
238
|
-
return "…";
|
|
239
|
-
}
|
|
240
|
-
|
|
241
|
-
function collapseWhitespace(s: string): string {
|
|
242
|
-
return s.replace(/\s+/g, " ").trim();
|
|
243
|
-
}
|
|
1
|
+
/**
|
|
2
|
+
* Hologram Engine — Language Dispatcher
|
|
3
|
+
*
|
|
4
|
+
* Routes file parsing to the appropriate tree-sitter extractor based on extension.
|
|
5
|
+
* Falls back to L0 (full source) for unsupported languages or parse errors.
|
|
6
|
+
*/
|
|
7
|
+
|
|
8
|
+
import { TreeSitterEngine } from "./hologram/engine";
|
|
9
|
+
import { tsExtractor } from "./hologram/ts-extractor";
|
|
10
|
+
import { pyExtractor } from "./hologram/py-extractor";
|
|
11
|
+
import { goExtractor } from "./hologram/go-extractor";
|
|
12
|
+
import { rustExtractor } from "./hologram/rust-extractor";
|
|
13
|
+
import { fallbackL0 } from "./hologram/fallback";
|
|
14
|
+
import { generateIncrementalHologram, setCachedHologram } from "./hologram/incremental";
|
|
15
|
+
import type { HologramResult, HologramOptions, LanguageExtractor } from "./hologram/types";
|
|
16
|
+
|
|
17
|
+
// Re-export types for backward compatibility
|
|
18
|
+
export type { HologramResult, HologramOptions } from "./hologram/types";
|
|
19
|
+
export { clearHologramCache } from "./hologram/incremental";
|
|
20
|
+
|
|
21
|
+
const extractors: LanguageExtractor[] = [tsExtractor, pyExtractor, goExtractor, rustExtractor];
|
|
22
|
+
|
|
23
|
+
function detectExtractor(filePath: string): LanguageExtractor | null {
|
|
24
|
+
const ext = filePath.split(".").pop()?.toLowerCase() ?? "";
|
|
25
|
+
return extractors.find(e => e.extensions.includes(ext)) ?? null;
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
export async function generateHologram(
|
|
29
|
+
filePath: string,
|
|
30
|
+
source: string,
|
|
31
|
+
options?: HologramOptions,
|
|
32
|
+
): Promise<HologramResult> {
|
|
33
|
+
const extractor = detectExtractor(filePath);
|
|
34
|
+
|
|
35
|
+
if (!extractor) {
|
|
36
|
+
return fallbackL0(filePath, source);
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
// Incremental diff mode
|
|
40
|
+
if (options?.diffOnly) {
|
|
41
|
+
try {
|
|
42
|
+
return await generateIncrementalHologram(filePath, source, extractor, options);
|
|
43
|
+
} catch {
|
|
44
|
+
return fallbackL0(filePath, source);
|
|
45
|
+
}
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
try {
|
|
49
|
+
const engine = await TreeSitterEngine.getInstance();
|
|
50
|
+
const tree = await engine.parse(source, extractor.grammarName);
|
|
51
|
+
const lines = extractor.extract(tree, source, options);
|
|
52
|
+
tree.delete();
|
|
53
|
+
|
|
54
|
+
// Cache for future incremental diffs
|
|
55
|
+
setCachedHologram(filePath, lines);
|
|
56
|
+
|
|
57
|
+
const hologram = lines.join("\n");
|
|
58
|
+
const hologramLength = hologram.length;
|
|
59
|
+
const savings = source.length > 0
|
|
60
|
+
? Math.round((source.length - hologramLength) / source.length * 1000) / 10
|
|
61
|
+
: 0;
|
|
62
|
+
|
|
63
|
+
return {
|
|
64
|
+
hologram,
|
|
65
|
+
originalLength: source.length,
|
|
66
|
+
hologramLength,
|
|
67
|
+
savings,
|
|
68
|
+
language: extractor.grammarName,
|
|
69
|
+
};
|
|
70
|
+
} catch {
|
|
71
|
+
return fallbackL0(filePath, source);
|
|
72
|
+
}
|
|
73
|
+
}
|
|
@@ -0,0 +1,259 @@
|
|
|
1
|
+
import { existsSync, readFileSync, writeFileSync, mkdirSync } from "fs";
|
|
2
|
+
import { dirname } from "path";
|
|
3
|
+
import { resolveHookCommand } from "../platform";
|
|
4
|
+
|
|
5
|
+
export type HookOwner = "afd" | "omc" | "user";
|
|
6
|
+
|
|
7
|
+
export interface HookEntry {
|
|
8
|
+
id?: string;
|
|
9
|
+
matcher?: string;
|
|
10
|
+
command: string;
|
|
11
|
+
[key: string]: unknown;
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
export interface HooksConfig {
|
|
15
|
+
hooks?: Record<string, HookEntry[]>;
|
|
16
|
+
[key: string]: unknown;
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
export interface ManagedHook {
|
|
20
|
+
id: string;
|
|
21
|
+
matcher: string;
|
|
22
|
+
command: string;
|
|
23
|
+
owner: HookOwner;
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
export interface HookConflict {
|
|
27
|
+
type: "matcher-overlap" | "duplicate-id";
|
|
28
|
+
hookA: ManagedHook;
|
|
29
|
+
hookB: ManagedHook;
|
|
30
|
+
resolution: string;
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
export interface MergeResult {
|
|
34
|
+
merged: HookEntry[];
|
|
35
|
+
conflicts: HookConflict[];
|
|
36
|
+
changes: { added: string[]; removed: string[]; reordered: string[] };
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
export interface HookSummary {
|
|
40
|
+
zones: Record<HookOwner, ManagedHook[]>;
|
|
41
|
+
conflicts: HookConflict[];
|
|
42
|
+
orderingOk: boolean;
|
|
43
|
+
total: number;
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
/**
|
|
47
|
+
* Canonical set of afd-managed hooks.
|
|
48
|
+
* Only hooks in this set are removed during `stop --clean`.
|
|
49
|
+
* This prevents accidental deletion of user hooks with an `afd-` prefix
|
|
50
|
+
* (e.g., project-local `afd-read-gate` scripts).
|
|
51
|
+
*/
|
|
52
|
+
export const KNOWN_AFD_HOOKS = new Set(["afd-auto-heal"]);
|
|
53
|
+
|
|
54
|
+
/** afd's canonical desired hooks — authoritative source for merge. */
|
|
55
|
+
export function getAfdDesiredHooks(): HookEntry[] {
|
|
56
|
+
return [
|
|
57
|
+
{
|
|
58
|
+
id: "afd-auto-heal",
|
|
59
|
+
matcher: "Write|Edit|MultiEdit",
|
|
60
|
+
command: resolveHookCommand(),
|
|
61
|
+
},
|
|
62
|
+
];
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
/** Classify a hook's owner by id prefix. */
|
|
66
|
+
export function classifyOwner(id: string): HookOwner {
|
|
67
|
+
if (id.startsWith("afd-")) return "afd";
|
|
68
|
+
if (id.startsWith("omc-")) return "omc";
|
|
69
|
+
return "user";
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
/** Classify a list of hook entries into owner zones. */
|
|
73
|
+
export function classifyHooks(entries: HookEntry[]): Map<HookOwner, ManagedHook[]> {
|
|
74
|
+
const zones = new Map<HookOwner, ManagedHook[]>([
|
|
75
|
+
["afd", []],
|
|
76
|
+
["omc", []],
|
|
77
|
+
["user", []],
|
|
78
|
+
]);
|
|
79
|
+
let anonIdx = 0;
|
|
80
|
+
for (const entry of entries) {
|
|
81
|
+
const id = entry.id ?? `user-anonymous-${anonIdx++}`;
|
|
82
|
+
const owner = classifyOwner(id);
|
|
83
|
+
zones.get(owner)!.push({
|
|
84
|
+
id,
|
|
85
|
+
matcher: entry.matcher ?? "",
|
|
86
|
+
command: entry.command,
|
|
87
|
+
owner,
|
|
88
|
+
});
|
|
89
|
+
}
|
|
90
|
+
return zones;
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
/** Detect conflicts between hooks from different owners. */
|
|
94
|
+
export function detectConflicts(hooks: ManagedHook[]): HookConflict[] {
|
|
95
|
+
const conflicts: HookConflict[] = [];
|
|
96
|
+
|
|
97
|
+
// 1. Duplicate ID check
|
|
98
|
+
const idMap = new Map<string, ManagedHook>();
|
|
99
|
+
for (const hook of hooks) {
|
|
100
|
+
if (idMap.has(hook.id)) {
|
|
101
|
+
conflicts.push({
|
|
102
|
+
type: "duplicate-id",
|
|
103
|
+
hookA: idMap.get(hook.id)!,
|
|
104
|
+
hookB: hook,
|
|
105
|
+
resolution: `Remove or rename one of the duplicate '${hook.id}' hooks`,
|
|
106
|
+
});
|
|
107
|
+
} else {
|
|
108
|
+
idMap.set(hook.id, hook);
|
|
109
|
+
}
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
// 2. Matcher overlap check (cross-owner only, O(n^2) — safe for <20 hooks)
|
|
113
|
+
for (let i = 0; i < hooks.length; i++) {
|
|
114
|
+
for (let j = i + 1; j < hooks.length; j++) {
|
|
115
|
+
if (hooks[i].owner === hooks[j].owner) continue;
|
|
116
|
+
const matcherA = hooks[i].matcher || "*";
|
|
117
|
+
const matcherB = hooks[j].matcher || "*";
|
|
118
|
+
const setA = new Set(matcherA.split("|").map(s => s.trim()));
|
|
119
|
+
const setB = new Set(matcherB.split("|").map(s => s.trim()));
|
|
120
|
+
const aIsWild = setA.has("*") || setA.has("");
|
|
121
|
+
const bIsWild = setB.has("*") || setB.has("");
|
|
122
|
+
const overlap =
|
|
123
|
+
aIsWild || bIsWild || [...setA].some(m => setB.has(m));
|
|
124
|
+
if (overlap) {
|
|
125
|
+
const shared =
|
|
126
|
+
aIsWild || bIsWild
|
|
127
|
+
? "*"
|
|
128
|
+
: [...setA].filter(m => setB.has(m)).join("|");
|
|
129
|
+
conflicts.push({
|
|
130
|
+
type: "matcher-overlap",
|
|
131
|
+
hookA: hooks[i],
|
|
132
|
+
hookB: hooks[j],
|
|
133
|
+
resolution: `Both hooks trigger on '${shared}'. Verify they don't conflict in behavior.`,
|
|
134
|
+
});
|
|
135
|
+
}
|
|
136
|
+
}
|
|
137
|
+
}
|
|
138
|
+
|
|
139
|
+
return conflicts;
|
|
140
|
+
}
|
|
141
|
+
|
|
142
|
+
/** Read hooks.json from disk, returning empty config on missing/invalid file. */
|
|
143
|
+
export function readHooksFile(hooksPath: string): HooksConfig {
|
|
144
|
+
if (!existsSync(hooksPath)) return { hooks: {} };
|
|
145
|
+
try {
|
|
146
|
+
return JSON.parse(readFileSync(hooksPath, "utf-8")) as HooksConfig;
|
|
147
|
+
} catch {
|
|
148
|
+
return { hooks: {} };
|
|
149
|
+
}
|
|
150
|
+
}
|
|
151
|
+
|
|
152
|
+
/** Write hooks.json to disk, creating parent directory if needed. */
|
|
153
|
+
export function writeHooksFile(hooksPath: string, config: HooksConfig): void {
|
|
154
|
+
mkdirSync(dirname(hooksPath), { recursive: true });
|
|
155
|
+
writeFileSync(hooksPath, JSON.stringify(config, null, 2), "utf-8");
|
|
156
|
+
}
|
|
157
|
+
|
|
158
|
+
/**
|
|
159
|
+
* Merge current hook entries with the desired afd hooks.
|
|
160
|
+
* Ordering guarantee: afd → omc → user.
|
|
161
|
+
* afd zone is fully authoritative — desired list overwrites existing afd hooks.
|
|
162
|
+
* omc and user zones are preserved as-is from the current file.
|
|
163
|
+
*/
|
|
164
|
+
export function mergeHooks(
|
|
165
|
+
current: HookEntry[],
|
|
166
|
+
desiredAfd: HookEntry[],
|
|
167
|
+
): MergeResult {
|
|
168
|
+
const changes: MergeResult["changes"] = { added: [], removed: [], reordered: [] };
|
|
169
|
+
|
|
170
|
+
const zones = classifyHooks(current);
|
|
171
|
+
const afdExisting = zones.get("afd")!;
|
|
172
|
+
const omcHooks = zones.get("omc")!;
|
|
173
|
+
const userHooks = zones.get("user")!;
|
|
174
|
+
|
|
175
|
+
// Build merged afd zone from desired list (authoritative)
|
|
176
|
+
const mergedAfd: HookEntry[] = desiredAfd.map(desired => {
|
|
177
|
+
const existing = afdExisting.find(h => h.id === desired.id);
|
|
178
|
+
if (!existing) changes.added.push(desired.id!);
|
|
179
|
+
return desired;
|
|
180
|
+
});
|
|
181
|
+
|
|
182
|
+
// Track explicitly removed afd hooks (only canonical ones)
|
|
183
|
+
for (const existing of afdExisting) {
|
|
184
|
+
if (KNOWN_AFD_HOOKS.has(existing.id) && !desiredAfd.find(d => d.id === existing.id)) {
|
|
185
|
+
changes.removed.push(existing.id);
|
|
186
|
+
}
|
|
187
|
+
}
|
|
188
|
+
|
|
189
|
+
// Detect reordering (only if no add/remove)
|
|
190
|
+
if (changes.added.length === 0 && changes.removed.length === 0) {
|
|
191
|
+
const originalOrder = current.map(h => h.id ?? "");
|
|
192
|
+
const newOrder = [
|
|
193
|
+
...mergedAfd.map(h => h.id!),
|
|
194
|
+
...omcHooks.map(h => h.id),
|
|
195
|
+
...userHooks.map(h => h.id),
|
|
196
|
+
];
|
|
197
|
+
const hasReordering =
|
|
198
|
+
originalOrder.length !== newOrder.length ||
|
|
199
|
+
originalOrder.some((id, i) => id !== (newOrder[i] ?? ""));
|
|
200
|
+
if (hasReordering) {
|
|
201
|
+
changes.reordered.push("hooks reordered to afd → omc → user zones");
|
|
202
|
+
}
|
|
203
|
+
}
|
|
204
|
+
|
|
205
|
+
const merged: HookEntry[] = [
|
|
206
|
+
...mergedAfd,
|
|
207
|
+
...omcHooks.map(({ id, matcher, command }) => ({ id, matcher, command })),
|
|
208
|
+
...userHooks.map(({ id, matcher, command }) => ({ id, matcher, command })),
|
|
209
|
+
];
|
|
210
|
+
|
|
211
|
+
const allManaged: ManagedHook[] = [
|
|
212
|
+
...mergedAfd.map(h => ({
|
|
213
|
+
id: h.id!,
|
|
214
|
+
matcher: h.matcher ?? "",
|
|
215
|
+
command: h.command,
|
|
216
|
+
owner: "afd" as HookOwner,
|
|
217
|
+
})),
|
|
218
|
+
...omcHooks,
|
|
219
|
+
...userHooks,
|
|
220
|
+
];
|
|
221
|
+
const conflicts = detectConflicts(allManaged);
|
|
222
|
+
|
|
223
|
+
return { merged, conflicts, changes };
|
|
224
|
+
}
|
|
225
|
+
|
|
226
|
+
/** Get a summary of current hook state for display and status commands. */
|
|
227
|
+
export function getHookSummary(hooksPath: string): HookSummary {
|
|
228
|
+
const config = readHooksFile(hooksPath);
|
|
229
|
+
const entries = config.hooks?.PreToolUse ?? [];
|
|
230
|
+
const zones = classifyHooks(entries);
|
|
231
|
+
|
|
232
|
+
const allHooks: ManagedHook[] = [
|
|
233
|
+
...(zones.get("afd") ?? []),
|
|
234
|
+
...(zones.get("omc") ?? []),
|
|
235
|
+
...(zones.get("user") ?? []),
|
|
236
|
+
];
|
|
237
|
+
const conflicts = detectConflicts(allHooks);
|
|
238
|
+
|
|
239
|
+
// Check ordering invariant: afd(0) → omc(1) → user(2)
|
|
240
|
+
const ownerPriority: Record<HookOwner, number> = { afd: 0, omc: 1, user: 2 };
|
|
241
|
+
let orderingOk = true;
|
|
242
|
+
let lastPriority = -1;
|
|
243
|
+
for (const entry of entries) {
|
|
244
|
+
const id = entry.id ?? "";
|
|
245
|
+
const priority = ownerPriority[classifyOwner(id)];
|
|
246
|
+
if (priority < lastPriority) {
|
|
247
|
+
orderingOk = false;
|
|
248
|
+
break;
|
|
249
|
+
}
|
|
250
|
+
lastPriority = priority;
|
|
251
|
+
}
|
|
252
|
+
|
|
253
|
+
return {
|
|
254
|
+
zones: Object.fromEntries(zones) as Record<HookOwner, ManagedHook[]>,
|
|
255
|
+
conflicts,
|
|
256
|
+
orderingOk,
|
|
257
|
+
total: entries.length,
|
|
258
|
+
};
|
|
259
|
+
}
|