autonomous-flow-daemon 1.0.0 → 1.1.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/README.ko.md +93 -36
- package/README.md +84 -28
- package/package.json +1 -1
- package/src/adapters/index.ts +2 -1
- package/src/cli.ts +9 -1
- package/src/commands/lang.ts +41 -0
- package/src/commands/score.ts +91 -31
- package/src/commands/start.ts +64 -23
- package/src/commands/stop.ts +19 -5
- package/src/constants.ts +2 -1
- package/src/core/boast.ts +265 -0
- package/src/core/config.ts +49 -0
- package/src/core/discovery.ts +65 -0
- package/src/core/i18n/messages.ts +266 -0
- package/src/core/locale.ts +88 -0
- package/src/core/notify.ts +43 -12
- package/src/daemon/server.ts +150 -17
- package/src/platform.ts +39 -0
package/src/daemon/server.ts
CHANGED
|
@@ -8,9 +8,11 @@ import { diagnose } from "../core/immune";
|
|
|
8
8
|
import type { PatchOp } from "../core/immune";
|
|
9
9
|
import { detectEcosystem } from "../adapters/index";
|
|
10
10
|
import type { DetectionResult } from "../adapters/index";
|
|
11
|
+
import { calcHealMetrics, maybeHealBoast, formatHealLog, formatDormantLog, buildShiftSummary } from "../core/boast";
|
|
12
|
+
import { discoverWatchTargets } from "../core/discovery";
|
|
11
13
|
|
|
12
14
|
// ── Suppression Safety Constants ──
|
|
13
|
-
const DOUBLE_TAP_WINDOW_MS =
|
|
15
|
+
const DOUBLE_TAP_WINDOW_MS = 30_000; // 30 seconds — balances demo speed and production safety
|
|
14
16
|
const MASS_EVENT_THRESHOLD = 3; // >3 unlinks in 1 second
|
|
15
17
|
const MASS_EVENT_WINDOW_MS = 1_000; // 1 second window
|
|
16
18
|
|
|
@@ -36,6 +38,7 @@ interface DaemonState {
|
|
|
36
38
|
firstTapTimestamps: Map<string, number>;
|
|
37
39
|
suppressionSkippedCount: number;
|
|
38
40
|
dormantTransitions: { antibodyId: string; at: number }[];
|
|
41
|
+
totalFileBytesSaved: number;
|
|
39
42
|
}
|
|
40
43
|
|
|
41
44
|
const state: DaemonState = {
|
|
@@ -52,6 +55,7 @@ const state: DaemonState = {
|
|
|
52
55
|
firstTapTimestamps: new Map(),
|
|
53
56
|
suppressionSkippedCount: 0,
|
|
54
57
|
dormantTransitions: [],
|
|
58
|
+
totalFileBytesSaved: 0,
|
|
55
59
|
};
|
|
56
60
|
|
|
57
61
|
function cleanup() {
|
|
@@ -59,7 +63,11 @@ function cleanup() {
|
|
|
59
63
|
try { unlinkSync(PORT_FILE); } catch {}
|
|
60
64
|
}
|
|
61
65
|
|
|
62
|
-
|
|
66
|
+
interface DaemonOptions {
|
|
67
|
+
mcp?: boolean;
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
export function main(options: DaemonOptions = {}) {
|
|
63
71
|
// Detect ecosystem at startup
|
|
64
72
|
state.ecosystems = detectEcosystem(process.cwd());
|
|
65
73
|
|
|
@@ -75,6 +83,32 @@ function main() {
|
|
|
75
83
|
const findAntibodyByFile = db.prepare("SELECT id, dormant FROM antibodies WHERE file_target = ? AND dormant = 0");
|
|
76
84
|
const setAntibodyDormant = db.prepare("UPDATE antibodies SET dormant = 1 WHERE id = ?");
|
|
77
85
|
|
|
86
|
+
// ── S.E.A.M Cycle Logger ──
|
|
87
|
+
function seam(phase: string, msg: string) {
|
|
88
|
+
console.log(`[afd] [${phase}] ${msg}`);
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
// ── Auto-Seed Antibodies on Startup ──
|
|
92
|
+
// Read existing immune-critical files and learn antibodies so they can be restored on delete
|
|
93
|
+
function seedAntibodies() {
|
|
94
|
+
const immuneFiles = [
|
|
95
|
+
{ id: "IMM-001", path: ".claudeignore" },
|
|
96
|
+
{ id: "IMM-002", path: ".claude/hooks.json" },
|
|
97
|
+
{ id: "IMM-003", path: "CLAUDE.md" },
|
|
98
|
+
];
|
|
99
|
+
|
|
100
|
+
for (const { id, path: filePath } of immuneFiles) {
|
|
101
|
+
if (existsSync(filePath)) {
|
|
102
|
+
const content = readFileSync(filePath, "utf-8");
|
|
103
|
+
const patches: PatchOp[] = [{ op: "add", path: `/${filePath}`, value: content }];
|
|
104
|
+
insertAntibody.run(id, "auto-seed", filePath, JSON.stringify(patches));
|
|
105
|
+
seam("Adapt", `Antibody ${id} seeded for ${filePath} (${content.length} bytes)`);
|
|
106
|
+
}
|
|
107
|
+
}
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
seedAntibodies();
|
|
111
|
+
|
|
78
112
|
// ── Suppression Safety: Helper Functions ──
|
|
79
113
|
|
|
80
114
|
/** Check if we're in a mass-event burst (>3 unlinks within 1 second) */
|
|
@@ -89,21 +123,33 @@ function main() {
|
|
|
89
123
|
state.firstTapTimestamps.clear();
|
|
90
124
|
}
|
|
91
125
|
|
|
92
|
-
/** Auto-heal: re-apply patches for a given antibody */
|
|
126
|
+
/** Auto-heal: re-apply patches for a given antibody, with metrics & boast */
|
|
93
127
|
function autoHealFile(antibodyId: string, fileTarget: string, patchOp: string) {
|
|
128
|
+
const t0 = performance.now();
|
|
94
129
|
try {
|
|
130
|
+
seam("Mutate", `Restoring ${fileTarget} via antibody ${antibodyId}...`);
|
|
95
131
|
const patches = JSON.parse(patchOp) as PatchOp[];
|
|
132
|
+
let bytesWritten = 0;
|
|
96
133
|
for (const patch of patches) {
|
|
97
134
|
if (patch.op === "add" && patch.value) {
|
|
98
135
|
const targetPath = resolve(patch.path.replace(/^\//, ""));
|
|
99
136
|
writeFileSync(targetPath, patch.value, "utf-8");
|
|
137
|
+
bytesWritten += patch.value.length;
|
|
100
138
|
}
|
|
101
139
|
}
|
|
140
|
+
const healMs = Math.round(performance.now() - t0);
|
|
102
141
|
state.autoHealCount++;
|
|
142
|
+
state.totalFileBytesSaved += bytesWritten;
|
|
103
143
|
state.autoHealLog.push({ id: antibodyId, at: Date.now() });
|
|
104
144
|
if (state.autoHealLog.length > 100) state.autoHealLog.shift();
|
|
105
|
-
|
|
106
|
-
//
|
|
145
|
+
|
|
146
|
+
// Delightful logging: metrics + occasional boast
|
|
147
|
+
const metrics = calcHealMetrics(bytesWritten, healMs);
|
|
148
|
+
const boast = maybeHealBoast(5); // 1-in-5 chance
|
|
149
|
+
const fileName = fileTarget.split("/").pop() ?? fileTarget;
|
|
150
|
+
console.log(formatHealLog(fileName, metrics, boast));
|
|
151
|
+
} catch (err) {
|
|
152
|
+
seam("Mutate", `FAILED to restore ${fileTarget}: ${err instanceof Error ? err.message : String(err)}`);
|
|
107
153
|
}
|
|
108
154
|
}
|
|
109
155
|
|
|
@@ -111,7 +157,11 @@ function main() {
|
|
|
111
157
|
* Handle unlink event with Double-Tap and Mass-Event heuristics.
|
|
112
158
|
* Returns true if the event was handled (healed or made dormant).
|
|
113
159
|
*/
|
|
114
|
-
|
|
160
|
+
/**
|
|
161
|
+
* Handle unlink event with Double-Tap and Mass-Event heuristics.
|
|
162
|
+
* Returns: "healed" | "dormant" | null
|
|
163
|
+
*/
|
|
164
|
+
function handleUnlink(filePath: string, now: number): "healed" | "dormant" | null {
|
|
115
165
|
// Record for mass-event detection
|
|
116
166
|
state.recentUnlinks.push(now);
|
|
117
167
|
insertUnlinkLog.run(filePath, now);
|
|
@@ -120,18 +170,18 @@ function main() {
|
|
|
120
170
|
if (isMassEvent(now)) {
|
|
121
171
|
state.suppressionSkippedCount++;
|
|
122
172
|
clearTapsOnMassEvent();
|
|
123
|
-
return
|
|
173
|
+
return null; // Do nothing — likely git checkout or bulk operation
|
|
124
174
|
}
|
|
125
175
|
|
|
126
176
|
// Find active (non-dormant) antibody protecting this file
|
|
127
177
|
const antibody = findAntibodyByFile.get(filePath) as { id: string; dormant: number } | null;
|
|
128
|
-
if (!antibody) return
|
|
178
|
+
if (!antibody) return null; // No antibody for this file
|
|
129
179
|
|
|
130
180
|
// Fetch full antibody data for healing
|
|
131
181
|
const fullAntibody = db.prepare("SELECT * FROM antibodies WHERE id = ?").get(antibody.id) as {
|
|
132
182
|
id: string; patch_op: string; file_target: string;
|
|
133
183
|
} | null;
|
|
134
|
-
if (!fullAntibody) return
|
|
184
|
+
if (!fullAntibody) return null;
|
|
135
185
|
|
|
136
186
|
// Double-Tap detection
|
|
137
187
|
const previousTap = state.firstTapTimestamps.get(filePath);
|
|
@@ -141,19 +191,29 @@ function main() {
|
|
|
141
191
|
setAntibodyDormant.run(antibody.id);
|
|
142
192
|
state.firstTapTimestamps.delete(filePath);
|
|
143
193
|
state.dormantTransitions.push({ antibodyId: antibody.id, at: now });
|
|
144
|
-
|
|
194
|
+
console.log(formatDormantLog(antibody.id));
|
|
195
|
+
return "dormant";
|
|
145
196
|
}
|
|
146
197
|
|
|
147
198
|
// FIRST TAP: record timestamp and auto-heal
|
|
148
199
|
state.firstTapTimestamps.set(filePath, now);
|
|
149
200
|
autoHealFile(fullAntibody.id, fullAntibody.file_target, fullAntibody.patch_op);
|
|
150
|
-
return
|
|
201
|
+
return "healed";
|
|
202
|
+
}
|
|
203
|
+
|
|
204
|
+
// ── Smart Discovery: scan for AI-context files ──
|
|
205
|
+
const discovery = discoverWatchTargets(WATCH_TARGETS);
|
|
206
|
+
seam("Sense", `Smart Discovery: ${discovery.targets.length} targets found in ${discovery.elapsedMs}ms`);
|
|
207
|
+
if (discovery.discoveredCount > 0) {
|
|
208
|
+
seam("Sense", `Discovered ${discovery.discoveredCount} extra: ${discovery.targets.filter(t => !WATCH_TARGETS.includes(t)).join(", ")}`);
|
|
151
209
|
}
|
|
152
210
|
|
|
153
|
-
// File watcher
|
|
154
|
-
|
|
211
|
+
// File watcher — atomic: 100 to handle rapid delete-recreate cycles
|
|
212
|
+
// watcher.add() after restore ensures re-detection (see handleUnlink caller)
|
|
213
|
+
const watcher = watch(discovery.targets, {
|
|
155
214
|
ignoreInitial: false,
|
|
156
215
|
persistent: true,
|
|
216
|
+
atomic: 100,
|
|
157
217
|
});
|
|
158
218
|
|
|
159
219
|
watcher.on("all", (event, path) => {
|
|
@@ -165,12 +225,68 @@ function main() {
|
|
|
165
225
|
}
|
|
166
226
|
insertEvent.run(event, path, Date.now());
|
|
167
227
|
|
|
168
|
-
//
|
|
228
|
+
// S.E.A.M cycle logging
|
|
229
|
+
seam("Sense", `${event} → ${path}`);
|
|
230
|
+
|
|
169
231
|
if (event === "unlink") {
|
|
170
|
-
|
|
232
|
+
seam("Extract", `File deleted: ${path} — checking antibodies`);
|
|
233
|
+
const result = handleUnlink(path, Date.now());
|
|
234
|
+
if (result === "healed") {
|
|
235
|
+
seam("Mutate", `Restore complete for ${path}`);
|
|
236
|
+
// Re-add to watcher so future deletes are detected
|
|
237
|
+
watcher.add(path);
|
|
238
|
+
} else if (result === "dormant") {
|
|
239
|
+
seam("Adapt", `Double-tap confirmed — ${path} deletion honored`);
|
|
240
|
+
} else {
|
|
241
|
+
seam("Extract", `No antibody found for ${path} — skipped`);
|
|
242
|
+
}
|
|
243
|
+
} else if (event === "change") {
|
|
244
|
+
// Re-seed antibody on file change so restore always has latest content
|
|
245
|
+
const immuneMap: Record<string, string> = {
|
|
246
|
+
".claudeignore": "IMM-001",
|
|
247
|
+
".claude/hooks.json": "IMM-002",
|
|
248
|
+
"CLAUDE.md": "IMM-003",
|
|
249
|
+
};
|
|
250
|
+
const abId = immuneMap[path];
|
|
251
|
+
if (abId && existsSync(path)) {
|
|
252
|
+
const content = readFileSync(path, "utf-8");
|
|
253
|
+
const patches: PatchOp[] = [{ op: "add", path: `/${path}`, value: content }];
|
|
254
|
+
insertAntibody.run(abId, "auto-seed", path, JSON.stringify(patches));
|
|
255
|
+
seam("Adapt", `Antibody ${abId} refreshed for ${path}`);
|
|
256
|
+
}
|
|
171
257
|
}
|
|
172
258
|
});
|
|
173
259
|
|
|
260
|
+
// ── MCP stdio mode: JSON-RPC over stdin/stdout ──
|
|
261
|
+
if (options.mcp) {
|
|
262
|
+
console.error("[afd] MCP stdio mode — awaiting JSON-RPC on stdin");
|
|
263
|
+
(async () => {
|
|
264
|
+
const decoder = new TextDecoder();
|
|
265
|
+
for await (const chunk of Bun.stdin.stream()) {
|
|
266
|
+
const line = decoder.decode(chunk).trim();
|
|
267
|
+
if (!line) continue;
|
|
268
|
+
try {
|
|
269
|
+
const req = JSON.parse(line);
|
|
270
|
+
const response = {
|
|
271
|
+
jsonrpc: "2.0",
|
|
272
|
+
id: req.id,
|
|
273
|
+
result: {
|
|
274
|
+
tools: [
|
|
275
|
+
{ name: "afd_diagnose", description: "Run afd health diagnosis" },
|
|
276
|
+
{ name: "afd_score", description: "Get daemon score/stats" },
|
|
277
|
+
{ name: "afd_hologram", description: "Generate hologram for a file" },
|
|
278
|
+
],
|
|
279
|
+
},
|
|
280
|
+
};
|
|
281
|
+
process.stdout.write(JSON.stringify(response) + "\n");
|
|
282
|
+
} catch {
|
|
283
|
+
// Crash-only: malformed input is ignored
|
|
284
|
+
}
|
|
285
|
+
}
|
|
286
|
+
})();
|
|
287
|
+
return; // file watcher + stdin loop keep process alive
|
|
288
|
+
}
|
|
289
|
+
|
|
174
290
|
// HTTP server for IPC
|
|
175
291
|
const server = Bun.serve({
|
|
176
292
|
port: 0,
|
|
@@ -271,7 +387,7 @@ function main() {
|
|
|
271
387
|
lastEvent: state.lastEvent,
|
|
272
388
|
lastEventAt: state.lastEventAt,
|
|
273
389
|
watchedFiles: state.watchedFiles,
|
|
274
|
-
watchTargets:
|
|
390
|
+
watchTargets: discovery.targets,
|
|
275
391
|
hologram: {
|
|
276
392
|
requests: hs.totalRequests,
|
|
277
393
|
originalChars: hs.totalOriginalChars,
|
|
@@ -340,6 +456,20 @@ function main() {
|
|
|
340
456
|
return Response.json({ status: "exported", path: payloadPath, count: sanitized.length });
|
|
341
457
|
}
|
|
342
458
|
|
|
459
|
+
if (url.pathname === "/shift-summary") {
|
|
460
|
+
const uptime = Math.floor((Date.now() - state.startedAt) / 1000);
|
|
461
|
+
const eventCount = db.query("SELECT COUNT(*) as cnt FROM events").get() as { cnt: number };
|
|
462
|
+
const summary = buildShiftSummary({
|
|
463
|
+
uptimeSeconds: uptime,
|
|
464
|
+
totalEvents: eventCount.cnt,
|
|
465
|
+
healsPerformed: state.autoHealCount,
|
|
466
|
+
totalFileBytesSaved: state.totalFileBytesSaved,
|
|
467
|
+
suppressionsSkipped: state.suppressionSkippedCount,
|
|
468
|
+
dormantTransitions: state.dormantTransitions.length,
|
|
469
|
+
});
|
|
470
|
+
return Response.json(summary);
|
|
471
|
+
}
|
|
472
|
+
|
|
343
473
|
if (url.pathname === "/stop") {
|
|
344
474
|
cleanup();
|
|
345
475
|
setTimeout(() => process.exit(0), 100);
|
|
@@ -368,4 +498,7 @@ function main() {
|
|
|
368
498
|
process.on("SIGINT", () => { cleanup(); process.exit(0); });
|
|
369
499
|
}
|
|
370
500
|
|
|
371
|
-
|
|
501
|
+
// Auto-execute when run directly (not imported)
|
|
502
|
+
if (import.meta.main) {
|
|
503
|
+
main();
|
|
504
|
+
}
|
package/src/platform.ts
ADDED
|
@@ -0,0 +1,39 @@
|
|
|
1
|
+
import { platform } from "os";
|
|
2
|
+
import type { SpawnOptions } from "child_process";
|
|
3
|
+
|
|
4
|
+
export const IS_WINDOWS = platform() === "win32";
|
|
5
|
+
export const IS_MACOS = platform() === "darwin";
|
|
6
|
+
export const IS_LINUX = platform() === "linux";
|
|
7
|
+
|
|
8
|
+
/**
|
|
9
|
+
* Returns spawn options appropriate for detaching a background daemon.
|
|
10
|
+
* On Windows, `shell: true` is required for `detached` to create a new console.
|
|
11
|
+
*/
|
|
12
|
+
export function detachedSpawnOptions(
|
|
13
|
+
logFd: number,
|
|
14
|
+
): SpawnOptions {
|
|
15
|
+
const base: SpawnOptions = {
|
|
16
|
+
detached: true,
|
|
17
|
+
stdio: ["ignore", logFd, logFd],
|
|
18
|
+
cwd: process.cwd(),
|
|
19
|
+
env: { ...process.env },
|
|
20
|
+
};
|
|
21
|
+
|
|
22
|
+
if (IS_WINDOWS) {
|
|
23
|
+
// Windows needs shell:true for detached to work properly
|
|
24
|
+
// and windowsHide to prevent a console flash
|
|
25
|
+
return { ...base, shell: true, windowsHide: true };
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
return base;
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
/**
|
|
32
|
+
* Resolve the hook command for invoking afd diagnose.
|
|
33
|
+
* Priority:
|
|
34
|
+
* 1. Global `afd` binary (npm/bun global install)
|
|
35
|
+
* 2. `bunx afd` fallback
|
|
36
|
+
*/
|
|
37
|
+
export function resolveHookCommand(): string {
|
|
38
|
+
return "afd diagnose --format a2a --auto-heal";
|
|
39
|
+
}
|