auxilo-mcp 0.7.0 → 0.8.2

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.
@@ -0,0 +1,843 @@
1
+ #!/usr/bin/env node
2
+
3
+ /**
4
+ * scripts/runner.js — Client-side Extraction Runner (P2.1a §7)
5
+ *
6
+ * Transport layer for the autonomous extraction pipeline:
7
+ * 1. Check kill-switch sentinel + recursion guard (A5.2)
8
+ * 2. Enumerate active sources via TranscriptSource interface (A5.1)
9
+ * 3. For each new session: readSession() → client-side scrub → write queue file → POST /extract
10
+ * 4. On success: update ledger, delete queue file. On failure: leave queue file.
11
+ *
12
+ * Usage:
13
+ * node scripts/runner.js # sweep all sources
14
+ * node scripts/runner.js --source claude-code # specific source
15
+ * node scripts/runner.js --dry-run # scrub only, no upload
16
+ * node scripts/runner.js --session <id> # process specific session
17
+ * node scripts/runner.js --transcript <path> # single-file mode (hook-fired)
18
+ * node scripts/runner.js --install-hooks # install SessionEnd hook
19
+ * node scripts/runner.js --install-sweeper # install launchd sweeper to ~/.auxilo/bin (P1-13)
20
+ * node scripts/runner.js --install-digest # install launchd daily-digest to ~/.auxilo/bin (P1-13)
21
+ * node scripts/runner.js --status # print status (B14)
22
+ * node scripts/runner.js --flush-pending # retry queue files
23
+ * node scripts/runner.js --force # ignore ledger high-water
24
+ *
25
+ * Environment:
26
+ * AUXILO_API_KEY — API key for authenticated /extract calls (REQUIRED unless --dry-run)
27
+ * AUXILO_BASE_URL — Server URL (default: http://localhost:49152)
28
+ * AUXILO_EXTRACTING — Recursion guard (A5.2); set to "1" by this runner
29
+ *
30
+ * @module runner
31
+ */
32
+
33
+ 'use strict';
34
+
35
+ const fs = require('fs');
36
+ const path = require('path');
37
+ const os = require('os');
38
+ const crypto = require('crypto');
39
+ const { scanText, SENSITIVITY_FILTER_VERSION } = require('../lib/sensitivity-filter.js');
40
+ const { ClaudeCodeSource } = require('./sources/claude-code.js');
41
+ const { OpenClawSource } = require('./sources/openclaw.js');
42
+
43
+ // ─── Constants ──────────────────────────────────────────────────────────────
44
+
45
+ const AUXILO_DIR = path.join(os.homedir(), '.auxilo');
46
+ const CREDS_PATH = path.join(AUXILO_DIR, 'credentials.json');
47
+
48
+ /**
49
+ * Credentials resolution order (P1-8 fast-follow):
50
+ * 1. AUXILO_API_KEY env var (highest priority — for CI, launchd, hooks)
51
+ * 2. ~/.auxilo/credentials.json { api_key, base_url }
52
+ * 3. fallback to local dev URL
53
+ *
54
+ * Base URL resolution:
55
+ * 1. AUXILO_BASE_URL env var
56
+ * 2. credentials.json .base_url
57
+ * 3. http://localhost:49152 (dev default)
58
+ */
59
+ function loadCredentials() {
60
+ let fileCreds = {};
61
+ try {
62
+ if (fs.existsSync(CREDS_PATH)) {
63
+ fileCreds = JSON.parse(fs.readFileSync(CREDS_PATH, 'utf-8'));
64
+ }
65
+ } catch { /* malformed file — treat as absent */ }
66
+ return {
67
+ apiKey: process.env.AUXILO_API_KEY || fileCreds.api_key || null,
68
+ baseUrl: process.env.AUXILO_BASE_URL || fileCreds.base_url || 'http://localhost:49152',
69
+ accountLabel: fileCreds.label || fileCreds.account_id || null,
70
+ };
71
+ }
72
+
73
+ const { apiKey: API_KEY, baseUrl: BASE_URL, accountLabel: ACCOUNT_LABEL } = loadCredentials();
74
+
75
+ // Parsing contract with jobs/daily-digest.js readLogRows(): digest-relevant log
76
+ // lines must carry `account=` (builder attribution) and, on publish lines,
77
+ // `published=` / `rejected=` count tokens.
78
+ const DIGEST_ACCOUNT = `account=${ACCOUNT_LABEL || 'unknown'}`;
79
+ const MIN_CHARS = 1500;
80
+ const MAX_CHARS = 30000;
81
+
82
+ const KILL_SWITCH_PATH = path.join(AUXILO_DIR, 'autonomous-enabled');
83
+ const PENDING_DIR = path.join(AUXILO_DIR, 'pending-learnings');
84
+ const LEDGER_PATH = path.join(AUXILO_DIR, 'ledger.json');
85
+ const LOG_PATH = path.join(AUXILO_DIR, 'extract.log');
86
+
87
+ // ─── Source Registry (§4.4) ─────────────────────────────────────────────────
88
+
89
+ const SOURCES = [
90
+ ClaudeCodeSource,
91
+ OpenClawSource,
92
+ ];
93
+
94
+ async function enumerateActiveSources(filter) {
95
+ const active = [];
96
+ for (const S of SOURCES) {
97
+ const instance = new S();
98
+ if (filter && instance.type !== filter) continue;
99
+ if (await instance.detect()) active.push(instance);
100
+ }
101
+ return active;
102
+ }
103
+
104
+ // ─── CLI Parsing ────────────────────────────────────────────────────────────
105
+
106
+ function parseArgs(argv) {
107
+ const args = {
108
+ source: null, dryRun: false, session: null,
109
+ installHooks: false, installSweeper: false, installDigest: false, status: false,
110
+ transcript: null, flushPending: false,
111
+ force: false, verbose: false,
112
+ };
113
+ for (let i = 2; i < argv.length; i++) {
114
+ if (argv[i] === '--dry-run') args.dryRun = true;
115
+ else if (argv[i] === '--install-hooks') args.installHooks = true;
116
+ else if (argv[i] === '--install-sweeper') args.installSweeper = true;
117
+ else if (argv[i] === '--install-digest') args.installDigest = true;
118
+ else if (argv[i] === '--status') args.status = true;
119
+ else if (argv[i] === '--flush-pending') args.flushPending = true;
120
+ else if (argv[i] === '--force') args.force = true;
121
+ else if (argv[i] === '--verbose') args.verbose = true;
122
+ else if (argv[i] === '--source' && argv[i + 1]) { args.source = argv[++i]; }
123
+ else if (argv[i] === '--session' && argv[i + 1]) { args.session = argv[++i]; }
124
+ else if (argv[i] === '--transcript' && argv[i + 1]) { args.transcript = argv[++i]; }
125
+ }
126
+ return args;
127
+ }
128
+
129
+ // ─── Logging ────────────────────────────────────────────────────────────────
130
+
131
+ function log(msg) {
132
+ const line = `[${new Date().toISOString()}] ${msg}`;
133
+ console.log(line);
134
+ try {
135
+ fs.mkdirSync(path.dirname(LOG_PATH), { recursive: true });
136
+ fs.appendFileSync(LOG_PATH, line + '\n');
137
+ } catch { /* best-effort */ }
138
+ }
139
+
140
+ // ─── Ledger ─────────────────────────────────────────────────────────────────
141
+
142
+ function loadLedger() {
143
+ try {
144
+ return JSON.parse(fs.readFileSync(LEDGER_PATH, 'utf-8'));
145
+ } catch {
146
+ return { sources: {}, lastSweep: null };
147
+ }
148
+ }
149
+
150
+ function saveLedger(ledger) {
151
+ fs.mkdirSync(path.dirname(LEDGER_PATH), { recursive: true });
152
+ const tmp = LEDGER_PATH + '.tmp';
153
+ fs.writeFileSync(tmp, JSON.stringify(ledger, null, 2), 'utf-8');
154
+ fs.renameSync(tmp, LEDGER_PATH);
155
+ }
156
+
157
+ function ledgerHighWater(ledger, sourceId) {
158
+ return ledger.sources[sourceId]?.highWater || null;
159
+ }
160
+
161
+ function ledgerHas(ledger, sourceId, sessionId, sha) {
162
+ const key = `${sourceId}:${sessionId}:${sha}`;
163
+ return ledger.sources[sourceId]?.sessions?.[key] === true;
164
+ }
165
+
166
+ function ledgerMark(ledger, sourceId, sessionId, sha, mtime) {
167
+ if (!ledger.sources[sourceId]) {
168
+ ledger.sources[sourceId] = { highWater: null, sessions: {} };
169
+ }
170
+ const key = `${sourceId}:${sessionId}:${sha}`;
171
+ ledger.sources[sourceId].sessions[key] = true;
172
+ // Update high-water mark
173
+ if (!ledger.sources[sourceId].highWater || mtime > ledger.sources[sourceId].highWater) {
174
+ ledger.sources[sourceId].highWater = mtime;
175
+ }
176
+ ledger.lastSweep = new Date().toISOString();
177
+ }
178
+
179
+ // ─── Durable Queue (A5.3 / B6) ─────────────────────────────────────────────
180
+
181
+ let queueCounter = Date.now();
182
+
183
+ /**
184
+ * Write a queue file BEFORE POSTing to /extract.
185
+ * B6: Uses O_WRONLY|O_CREAT|O_EXCL|O_NOFOLLOW with mode 0o600 to prevent
186
+ * symlink attacks on the pending-learnings directory.
187
+ *
188
+ * @param {object} payload - The data to queue
189
+ * @returns {string} Absolute path to the queue file
190
+ */
191
+ function writeQueueFile(payload) {
192
+ fs.mkdirSync(PENDING_DIR, { recursive: true });
193
+ const slug = (payload.sessionId || 'unknown').replace(/[^a-z0-9_-]/gi, '_').slice(0, 40);
194
+ const filename = `${++queueCounter}-${slug}.json`;
195
+ const filePath = path.join(PENDING_DIR, filename);
196
+
197
+ // B6: O_EXCL blocks pre-planted symlinks; O_NOFOLLOW blocks symlink-following
198
+ const flags = fs.constants.O_WRONLY | fs.constants.O_CREAT | fs.constants.O_EXCL;
199
+ // O_NOFOLLOW may not be available on all platforms; use it where supported
200
+ const flagsWithNoFollow = typeof fs.constants.O_NOFOLLOW === 'number'
201
+ ? flags | fs.constants.O_NOFOLLOW
202
+ : flags;
203
+
204
+ const fd = fs.openSync(filePath, flagsWithNoFollow, 0o600);
205
+ try {
206
+ fs.writeSync(fd, JSON.stringify(payload, null, 2));
207
+ } finally {
208
+ fs.closeSync(fd);
209
+ }
210
+
211
+ return filePath;
212
+ }
213
+
214
+ function deleteQueueFile(filePath) {
215
+ try { fs.unlinkSync(filePath); } catch { /* already gone */ }
216
+ }
217
+
218
+ function listPendingFiles() {
219
+ try {
220
+ return fs.readdirSync(PENDING_DIR)
221
+ .filter(f => f.endsWith('.json'))
222
+ .map(f => path.join(PENDING_DIR, f))
223
+ .sort();
224
+ } catch {
225
+ return [];
226
+ }
227
+ }
228
+
229
+ // ─── Upload ─────────────────────────────────────────────────────────────────
230
+
231
+ async function postExtract(transcript, sessionId, sourceType, scrubReport) {
232
+ const transcriptSha256 = crypto.createHash('sha256').update(transcript).digest('hex');
233
+ const idempotencyKey = crypto.randomUUID();
234
+
235
+ const res = await fetch(`${BASE_URL}/extract`, {
236
+ method: 'POST',
237
+ headers: {
238
+ 'Content-Type': 'application/json',
239
+ 'X-API-Key': API_KEY,
240
+ 'Idempotency-Key': idempotencyKey,
241
+ },
242
+ body: JSON.stringify({
243
+ source: { type: sourceType, session_id: sessionId },
244
+ transcript,
245
+ transcript_sha256: transcriptSha256,
246
+ client_scrub_report: scrubReport,
247
+ }),
248
+ });
249
+
250
+ const data = await res.json();
251
+ if (!res.ok) {
252
+ throw new Error(`Upload failed (${res.status}): ${data.error || JSON.stringify(data)}`);
253
+ }
254
+ return { ...data, transcript_sha256: transcriptSha256 };
255
+ }
256
+
257
+ // ─── Install Hooks (B15) ────────────────────────────────────────────────────
258
+
259
+ function installHooks() {
260
+ const hookSrc = path.join(__dirname, 'hooks', 'auxilo-extract.sh');
261
+ const claudeHooksDir = path.join(os.homedir(), '.claude', 'hooks');
262
+ const hookDest = path.join(claudeHooksDir, 'auxilo-extract.sh');
263
+ const settingsPath = path.join(os.homedir(), '.claude', 'settings.json');
264
+ const hookCmd = hookDest;
265
+
266
+ // 1. Copy hook script
267
+ if (!fs.existsSync(hookSrc)) {
268
+ console.error(`[install-hooks] Hook template not found: ${hookSrc}`);
269
+ process.exit(1);
270
+ }
271
+ fs.mkdirSync(claudeHooksDir, { recursive: true });
272
+
273
+ // B15: Backup existing hook file before overwrite
274
+ if (fs.existsSync(hookDest)) {
275
+ const backupPath = `${hookDest}.bak.${Date.now()}`;
276
+ fs.copyFileSync(hookDest, backupPath);
277
+ log(`[install-hooks] Backed up existing hook to ${backupPath}`);
278
+ }
279
+
280
+ fs.copyFileSync(hookSrc, hookDest);
281
+ fs.chmodSync(hookDest, 0o755);
282
+ log(`[install-hooks] ✓ Copied hook to ${hookDest}`);
283
+
284
+ // 2. Patch settings.json SessionEnd array
285
+ // B15: Fail loudly on malformed settings.json — do NOT catch-and-overwrite
286
+ let settings = {};
287
+ if (fs.existsSync(settingsPath)) {
288
+ const raw = fs.readFileSync(settingsPath, 'utf8');
289
+ try {
290
+ settings = JSON.parse(raw);
291
+ } catch (parseErr) {
292
+ // B15: Malformed JSON — FAIL LOUDLY. Do NOT overwrite with fresh object.
293
+ throw new Error(
294
+ `[install-hooks] FATAL: ${settingsPath} contains malformed JSON. ` +
295
+ `Refusing to overwrite — fix the file manually or restore from backup. ` +
296
+ `Parse error: ${parseErr.message}`
297
+ );
298
+ }
299
+ }
300
+
301
+ if (!settings.hooks) settings.hooks = {};
302
+ if (!Array.isArray(settings.hooks.SessionEnd)) settings.hooks.SessionEnd = [];
303
+
304
+ if (!settings.hooks.SessionEnd.includes(hookCmd)) {
305
+ settings.hooks.SessionEnd.push(hookCmd);
306
+ log(`[install-hooks] ✓ Added ${hookCmd} to SessionEnd hooks`);
307
+ } else {
308
+ log(`[install-hooks] ✓ Hook already in SessionEnd array (no-op)`);
309
+ }
310
+
311
+ fs.writeFileSync(settingsPath, JSON.stringify(settings, null, 2) + '\n');
312
+ log(`[install-hooks] ✓ Updated ${settingsPath}`);
313
+
314
+ // 3. Verify
315
+ const exists = fs.existsSync(hookDest);
316
+ const isExec = (fs.statSync(hookDest).mode & 0o111) !== 0;
317
+ log(`[install-hooks] Verification: hook exists=${exists}, executable=${isExec}`);
318
+
319
+ if (!exists || !isExec) {
320
+ console.error('[install-hooks] ✗ Verification failed');
321
+ process.exit(1);
322
+ }
323
+ log('[install-hooks] Done. Hook will fire on SessionEnd.');
324
+ }
325
+
326
+ // ─── Install Sweeper (P1-13) ────────────────────────────────────────────────
327
+ //
328
+ // macOS TCC blocks launchd from executing anything under ~/Documents
329
+ // ("Operation not permitted" in sweeper.err.log). Fix: copy the entire
330
+ // executable surface (wrapper + runner + sources + sensitivity filter) into
331
+ // ~/.auxilo/bin/ at install time — copied, NOT symlinked (a symlink back into
332
+ // ~/Documents would still be TCC-blocked) — and point the LaunchAgent plist
333
+ // at the installed copy. Re-run after changing any of these files.
334
+
335
+ const SWEEPER_LABEL = 'tech.conway.auxilo-sweeper';
336
+
337
+ function installSweeper() {
338
+ const repoRoot = path.resolve(__dirname, '..');
339
+ const binRoot = path.join(AUXILO_DIR, 'bin');
340
+
341
+ // 1. Copy executable surface, preserving relative layout so requires resolve.
342
+ const filesToCopy = [
343
+ ['scripts/auxilo-sweeper-wrapper.sh', 'auxilo-sweeper-wrapper.sh', 0o755],
344
+ ['scripts/runner.js', 'scripts/runner.js', 0o755],
345
+ ['scripts/sources/source.interface.js', 'scripts/sources/source.interface.js', 0o644],
346
+ ['scripts/sources/claude-code.js', 'scripts/sources/claude-code.js', 0o644],
347
+ ['scripts/sources/openclaw.js', 'scripts/sources/openclaw.js', 0o644],
348
+ ['lib/sensitivity-filter.js', 'lib/sensitivity-filter.js', 0o644],
349
+ ];
350
+ for (const [src, dest, mode] of filesToCopy) {
351
+ const srcPath = path.join(repoRoot, src);
352
+ const destPath = path.join(binRoot, dest);
353
+ if (!fs.existsSync(srcPath)) {
354
+ console.error(`[install-sweeper] ✗ Missing source file: ${srcPath}`);
355
+ process.exit(1);
356
+ }
357
+ fs.mkdirSync(path.dirname(destPath), { recursive: true });
358
+ fs.copyFileSync(srcPath, destPath);
359
+ fs.chmodSync(destPath, mode);
360
+ log(`[install-sweeper] ✓ Installed ${dest}`);
361
+ }
362
+
363
+ // 2. Write the LaunchAgent plist pointing at the installed wrapper.
364
+ // WorkingDirectory must also live outside ~/Documents (TCC getcwd errors).
365
+ const wrapperPath = path.join(binRoot, 'auxilo-sweeper-wrapper.sh');
366
+ const plistPath = path.join(os.homedir(), 'Library', 'LaunchAgents', `${SWEEPER_LABEL}.plist`);
367
+ const plist = `<?xml version="1.0" encoding="UTF-8"?>
368
+ <!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
369
+ <plist version="1.0">
370
+ <dict>
371
+ <key>Label</key>
372
+ <string>${SWEEPER_LABEL}</string>
373
+
374
+ <!-- P1-13: executable surface lives in ~/.auxilo/bin — NEVER under ~/Documents (TCC). -->
375
+ <key>ProgramArguments</key>
376
+ <array>
377
+ <string>/bin/bash</string>
378
+ <string>${wrapperPath}</string>
379
+ </array>
380
+
381
+ <!-- Run daily at 03:15 local time. -->
382
+ <key>StartCalendarInterval</key>
383
+ <dict>
384
+ <key>Hour</key>
385
+ <integer>3</integer>
386
+ <key>Minute</key>
387
+ <integer>15</integer>
388
+ </dict>
389
+
390
+ <key>RunAtLoad</key>
391
+ <false/>
392
+
393
+ <key>StandardOutPath</key>
394
+ <string>${path.join(AUXILO_DIR, 'sweeper.out.log')}</string>
395
+ <key>StandardErrorPath</key>
396
+ <string>${path.join(AUXILO_DIR, 'sweeper.err.log')}</string>
397
+
398
+ <!-- P1-13: do NOT set AUXILO_EXTRACTING here — it trips the runner's own
399
+ recursion guard and silently no-ops every sweep. The runner sets it
400
+ itself for child processes. -->
401
+
402
+ <key>WorkingDirectory</key>
403
+ <string>${AUXILO_DIR}</string>
404
+ </dict>
405
+ </plist>
406
+ `;
407
+ fs.mkdirSync(path.dirname(plistPath), { recursive: true });
408
+ fs.writeFileSync(plistPath, plist);
409
+ log(`[install-sweeper] ✓ Wrote ${plistPath}`);
410
+
411
+ log('[install-sweeper] Done. Reload the agent:');
412
+ log(`[install-sweeper] launchctl bootout gui/$(id -u)/${SWEEPER_LABEL} 2>/dev/null; launchctl bootstrap gui/$(id -u) ${plistPath}`);
413
+ }
414
+
415
+ // ─── Install Daily Digest (P1-13 follow-up) ─────────────────────────────────
416
+ //
417
+ // Same TCC root cause as the sweeper: the original tech.conway.auxilo-digest
418
+ // plist ran node against jobs/daily-digest.js under ~/Documents, which launchd
419
+ // cannot read ("Unknown system error -11" in daily-digest.stderr.log). The
420
+ // digest is a purely local job — it summarizes ~/.auxilo/extract.log — so the
421
+ // fix is the same: copy it to ~/.auxilo/bin/ and point the plist there.
422
+ // daily-digest.js is self-contained (fs/path/os only), so it is the only file
423
+ // to copy. Re-run after changing it.
424
+
425
+ const DIGEST_LABEL = 'tech.conway.auxilo-digest';
426
+
427
+ function installDigest() {
428
+ const repoRoot = path.resolve(__dirname, '..');
429
+ const binRoot = path.join(AUXILO_DIR, 'bin');
430
+
431
+ // 1. Copy the job script (self-contained — no repo-local requires).
432
+ const srcPath = path.join(repoRoot, 'jobs', 'daily-digest.js');
433
+ const destPath = path.join(binRoot, 'jobs', 'daily-digest.js');
434
+ if (!fs.existsSync(srcPath)) {
435
+ console.error(`[install-digest] ✗ Missing source file: ${srcPath}`);
436
+ process.exit(1);
437
+ }
438
+ fs.mkdirSync(path.dirname(destPath), { recursive: true });
439
+ fs.copyFileSync(srcPath, destPath);
440
+ fs.chmodSync(destPath, 0o755);
441
+ log(`[install-digest] ✓ Installed jobs/daily-digest.js`);
442
+
443
+ // 2. Write the LaunchAgent plist pointing at the installed copy.
444
+ // Schedule and log paths preserved from the original plist (07:00 daily,
445
+ // ~/.auxilo/logs/). WorkingDirectory must live outside ~/Documents (TCC).
446
+ const plistPath = path.join(os.homedir(), 'Library', 'LaunchAgents', `${DIGEST_LABEL}.plist`);
447
+ const plist = `<?xml version="1.0" encoding="UTF-8"?>
448
+ <!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
449
+ <plist version="1.0">
450
+ <dict>
451
+ <key>Label</key>
452
+ <string>${DIGEST_LABEL}</string>
453
+
454
+ <!-- P1-13: executable surface lives in ~/.auxilo/bin — NEVER under ~/Documents (TCC). -->
455
+ <key>ProgramArguments</key>
456
+ <array>
457
+ <string>/usr/local/bin/node</string>
458
+ <string>${destPath}</string>
459
+ </array>
460
+
461
+ <key>StartCalendarInterval</key>
462
+ <dict>
463
+ <key>Hour</key>
464
+ <integer>7</integer>
465
+ <key>Minute</key>
466
+ <integer>0</integer>
467
+ </dict>
468
+
469
+ <key>RunAtLoad</key>
470
+ <false/>
471
+
472
+ <key>StandardOutPath</key>
473
+ <string>${path.join(AUXILO_DIR, 'logs', 'daily-digest.stdout.log')}</string>
474
+ <key>StandardErrorPath</key>
475
+ <string>${path.join(AUXILO_DIR, 'logs', 'daily-digest.stderr.log')}</string>
476
+
477
+ <key>EnvironmentVariables</key>
478
+ <dict>
479
+ <key>PATH</key>
480
+ <string>/usr/local/bin:/usr/bin:/bin</string>
481
+ </dict>
482
+
483
+ <key>WorkingDirectory</key>
484
+ <string>${AUXILO_DIR}</string>
485
+ </dict>
486
+ </plist>
487
+ `;
488
+ fs.mkdirSync(path.dirname(plistPath), { recursive: true });
489
+ fs.writeFileSync(plistPath, plist);
490
+ log(`[install-digest] ✓ Wrote ${plistPath}`);
491
+
492
+ log('[install-digest] Done. Reload the agent:');
493
+ log(`[install-digest] launchctl bootout gui/$(id -u)/${DIGEST_LABEL} 2>/dev/null; launchctl bootstrap gui/$(id -u) ${plistPath}`);
494
+ }
495
+
496
+ // ─── Status (B14) ───────────────────────────────────────────────────────────
497
+
498
+ async function printStatus() {
499
+ const ledger = loadLedger();
500
+
501
+ // 1. Kill-switch sentinel
502
+ const sentinelPresent = fs.existsSync(KILL_SWITCH_PATH);
503
+ console.log(`Kill-switch sentinel: ${sentinelPresent ? 'present' : 'MISSING'} (${KILL_SWITCH_PATH})`);
504
+
505
+ // 2. AUXILO_EXTRACTING env var
506
+ console.log(`AUXILO_EXTRACTING: ${process.env.AUXILO_EXTRACTING || 'unset'}`);
507
+
508
+ // 3. Account mode (best-effort — may fail if server not running)
509
+ let accountMode = 'unknown (server unreachable)';
510
+ if (API_KEY) {
511
+ try {
512
+ const res = await fetch(`${BASE_URL}/account/settings`, {
513
+ headers: { 'X-API-Key': API_KEY },
514
+ });
515
+ if (res.ok) {
516
+ const data = await res.json();
517
+ accountMode = data.autonomous_extraction_mode || 'off';
518
+ }
519
+ } catch { /* server not running */ }
520
+ } else {
521
+ accountMode = 'unknown (AUXILO_API_KEY not set)';
522
+ }
523
+ console.log(`Account mode: ${accountMode}`);
524
+
525
+ // 4. Hook install state
526
+ const claudeSettingsPath = path.join(os.homedir(), '.claude', 'settings.json');
527
+ let hookInstalled = false;
528
+ try {
529
+ const settings = JSON.parse(fs.readFileSync(claudeSettingsPath, 'utf-8'));
530
+ hookInstalled = Array.isArray(settings.hooks?.SessionEnd) &&
531
+ settings.hooks.SessionEnd.some(h => h.includes('auxilo-extract'));
532
+ } catch { /* no settings file */ }
533
+ console.log(`Hook installed: ${hookInstalled ? 'yes' : 'no'}`);
534
+
535
+ // 5. Last sweep ran at
536
+ console.log(`Last sweep: ${ledger.lastSweep || 'never'}`);
537
+
538
+ // 6. Pending queue size
539
+ const pendingCount = listPendingFiles().length;
540
+ console.log(`Pending queue: ${pendingCount} file(s)`);
541
+ }
542
+
543
+ // ─── Scrub + Verify ─────────────────────────────────────────────────────────
544
+
545
+ function scrubAndVerify(transcript) {
546
+ const first = scanText(transcript);
547
+ let cleaned = transcript;
548
+ let report = {
549
+ scrubber_version: SENSITIVITY_FILTER_VERSION,
550
+ patterns_matched: [],
551
+ clean: first.clean,
552
+ };
553
+
554
+ if (!first.clean) {
555
+ // Redact and re-scan (fail-closed per §7.5)
556
+ cleaned = first.redacted;
557
+ report.patterns_matched = first.matches.map(m => m.pattern);
558
+
559
+ const second = scanText(cleaned);
560
+ if (!second.clean) {
561
+ // Second pass still finds patterns — refuse to upload
562
+ return { cleaned: null, report, refused: true };
563
+ }
564
+ }
565
+
566
+ return { cleaned, report, refused: false };
567
+ }
568
+
569
+ // ─── Main ───────────────────────────────────────────────────────────────────
570
+
571
+ async function main() {
572
+ const args = parseArgs(process.argv);
573
+
574
+ // B14: --status subcommand
575
+ if (args.status) return printStatus();
576
+
577
+ // --install-hooks subcommand
578
+ if (args.installHooks) return installHooks();
579
+
580
+ // --install-sweeper subcommand (P1-13)
581
+ if (args.installSweeper) return installSweeper();
582
+
583
+ // --install-digest subcommand (P1-13 follow-up)
584
+ if (args.installDigest) return installDigest();
585
+
586
+ // ── A5.2: Kill-switch sentinel check ──────────────────────────────────
587
+ // MUST be the first check before any extraction work.
588
+ if (!fs.existsSync(KILL_SWITCH_PATH)) {
589
+ log('[runner] Kill-switch sentinel absent, exiting. Touch ~/.auxilo/autonomous-enabled to enable.');
590
+ process.exit(0);
591
+ }
592
+
593
+ // ── A5.2: Recursion guard ─────────────────────────────────────────────
594
+ if (process.env.AUXILO_EXTRACTING === '1') {
595
+ log('[runner] Recursion guard (AUXILO_EXTRACTING=1) tripped, exiting.');
596
+ process.exit(0);
597
+ }
598
+ process.env.AUXILO_EXTRACTING = '1';
599
+
600
+ // ── Credentials ───────────────────────────────────────────────────────
601
+ if (!API_KEY && !args.dryRun) {
602
+ console.error('[runner] AUXILO_API_KEY not set. Use --dry-run for local testing.');
603
+ process.exit(1);
604
+ }
605
+
606
+ // ── Single-file mode (--transcript <path>) ───────────────────────────
607
+ // P1-3 fast-follow: this flag was parsed but never handled. Processes
608
+ // one transcript file through the same scrub + POST pipeline used by
609
+ // the discover loop, bypassing ledger/discovery. Used by SessionEnd
610
+ // hooks and for targeted testing.
611
+ if (args.transcript) {
612
+ const transcriptPath = path.resolve(args.transcript);
613
+ if (!fs.existsSync(transcriptPath)) {
614
+ log(`[runner] Transcript file not found: ${transcriptPath}`);
615
+ process.exit(1);
616
+ }
617
+
618
+ // Pick the right source adapter based on file location — default to
619
+ // claude-code since it can parse either JSONL or plain text, and
620
+ // works for most hook-fired single-file invocations.
621
+ const sourceType = args.source || 'claude-code';
622
+ const SourceClass = SOURCES.find(S => S.id === sourceType);
623
+ if (!SourceClass) {
624
+ log(`[runner] Unknown source type: ${sourceType}`);
625
+ process.exit(1);
626
+ }
627
+ const source = new SourceClass();
628
+ const sessionId = path.basename(transcriptPath, path.extname(transcriptPath));
629
+ const stat = fs.statSync(transcriptPath);
630
+ const sessionRef = {
631
+ path: transcriptPath,
632
+ sessionId,
633
+ mtime: stat.mtime.toISOString(),
634
+ bytes: stat.size,
635
+ };
636
+
637
+ log(`[runner] Single-file mode: ${transcriptPath} (${stat.size} bytes)`);
638
+
639
+ let transcriptData;
640
+ try {
641
+ transcriptData = await source.readSession(sessionRef);
642
+ } catch (err) {
643
+ // Fallback: treat the file as a plain pre-formatted transcript
644
+ try {
645
+ transcriptData = { transcript: fs.readFileSync(transcriptPath, 'utf-8') };
646
+ } catch (e2) {
647
+ log(`[runner] Read failed: ${err.message}`);
648
+ process.exit(1);
649
+ }
650
+ }
651
+
652
+ let transcript = transcriptData.transcript;
653
+ if (transcript.length < MIN_CHARS) {
654
+ log(`[runner] Too short (${transcript.length} chars, min ${MIN_CHARS}). Exiting.`);
655
+ process.exit(0);
656
+ }
657
+ if (transcript.length > MAX_CHARS) {
658
+ log(`[runner] Truncating from ${transcript.length} to ${MAX_CHARS} chars`);
659
+ transcript = transcript.slice(0, MAX_CHARS);
660
+ }
661
+
662
+ const { cleaned, report, refused } = scrubAndVerify(transcript);
663
+ if (refused || !cleaned) {
664
+ log(`[runner] Scrub fail-closed — refusing to upload ${DIGEST_ACCOUNT}`);
665
+ process.exit(1);
666
+ }
667
+ if (!report.clean) {
668
+ log(`[runner] Scrubbed ${report.patterns_matched.length} sensitive pattern(s): ${[...new Set(report.patterns_matched)].join(', ')} ${DIGEST_ACCOUNT}`);
669
+ }
670
+
671
+ if (args.dryRun) {
672
+ log(`[runner] [DRY RUN] Would upload ${cleaned.length} chars to ${BASE_URL}/extract`);
673
+ process.exit(0);
674
+ }
675
+
676
+ try {
677
+ const result = await postExtract(cleaned, sessionId, sourceType, report);
678
+ log(`[runner] ✓ published=${result.learnings_published || 0} rejected=${result.learnings_rejected || 0} ${DIGEST_ACCOUNT} (extraction: ${result.extraction_id})`);
679
+ process.exit(0);
680
+ } catch (err) {
681
+ log(`[runner] ✗ Upload failed: ${err.message}`);
682
+ process.exit(1);
683
+ }
684
+ }
685
+
686
+ // ── Flush pending (--flush-pending) ───────────────────────────────────
687
+ if (args.flushPending) {
688
+ const pending = listPendingFiles();
689
+ log(`[runner] Flushing ${pending.length} pending queue file(s)...`);
690
+ let flushed = 0;
691
+ for (const qf of pending) {
692
+ try {
693
+ const payload = JSON.parse(fs.readFileSync(qf, 'utf-8'));
694
+ const result = await postExtract(
695
+ payload.transcript, payload.sessionId, payload.source, payload.scrubReport
696
+ );
697
+ log(`[runner] ✓ Flushed ${path.basename(qf)}: published=${result.learnings_published || 0} rejected=${result.learnings_rejected || 0} ${DIGEST_ACCOUNT}`);
698
+ deleteQueueFile(qf);
699
+ flushed++;
700
+ } catch (err) {
701
+ log(`[runner] ✗ Retry failed for ${path.basename(qf)}: ${err.message}`);
702
+ }
703
+ }
704
+ log(`[runner] Flush complete: ${flushed}/${pending.length} succeeded`);
705
+ process.exit(0);
706
+ }
707
+
708
+ // ── Enumerate sources ─────────────────────────────────────────────────
709
+ const sources = await enumerateActiveSources(args.source);
710
+ if (sources.length === 0) {
711
+ log(`[runner] No active sources found${args.source ? ` for type "${args.source}"` : ''}`);
712
+ process.exit(0);
713
+ }
714
+
715
+ const ledger = loadLedger();
716
+ let totalDiscovered = 0;
717
+ let totalProcessed = 0;
718
+ let totalSkipped = 0;
719
+ let totalFailed = 0;
720
+
721
+ for (const source of sources) {
722
+ log(`[runner] Discovering sessions from ${source.label} (${source.type})...`);
723
+
724
+ let sessions;
725
+ try {
726
+ const since = args.force ? undefined : ledgerHighWater(ledger, source.type);
727
+ sessions = await source.discoverSessions({ since });
728
+ } catch (err) {
729
+ log(`[runner] Discovery failed for ${source.type}: ${err.message}`);
730
+ continue;
731
+ }
732
+
733
+ // Filter to specific session if requested
734
+ if (args.session) {
735
+ sessions = sessions.filter(s => s.sessionId === args.session);
736
+ }
737
+
738
+ log(`[runner] Found ${sessions.length} new session(s)`);
739
+ totalDiscovered += sessions.length;
740
+
741
+ for (const sessionRef of sessions) {
742
+ log(`[runner] Processing ${sessionRef.sessionId} (${sessionRef.bytes} bytes)...`);
743
+
744
+ // Read transcript
745
+ let transcriptData;
746
+ try {
747
+ transcriptData = await source.readSession(sessionRef);
748
+ } catch (err) {
749
+ log(`[runner] Read failed: ${err.message}`);
750
+ totalFailed++;
751
+ continue;
752
+ }
753
+
754
+ let transcript = transcriptData.transcript;
755
+
756
+ // Size check
757
+ if (transcript.length < MIN_CHARS) {
758
+ log(`[runner] Skipped — too short (${transcript.length} chars, min ${MIN_CHARS}) ${DIGEST_ACCOUNT}`);
759
+ totalSkipped++;
760
+ ledgerMark(ledger, source.type, sessionRef.sessionId, 'skipped', sessionRef.mtime);
761
+ continue;
762
+ }
763
+
764
+ // Truncate if too long
765
+ if (transcript.length > MAX_CHARS) {
766
+ log(`[runner] Truncating from ${transcript.length} to ${MAX_CHARS} chars`);
767
+ transcript = transcript.slice(0, MAX_CHARS);
768
+ }
769
+
770
+ // Client-side scrub (§7.5)
771
+ const { cleaned, report, refused } = scrubAndVerify(transcript);
772
+ if (refused || !cleaned) {
773
+ log(`[runner] Scrub fail-closed — refusing to upload ${DIGEST_ACCOUNT}`);
774
+ totalFailed++;
775
+ continue;
776
+ }
777
+
778
+ if (!report.clean) {
779
+ log(`[runner] Scrubbed ${report.patterns_matched.length} sensitive pattern(s): ${[...new Set(report.patterns_matched)].join(', ')} ${DIGEST_ACCOUNT}`);
780
+ }
781
+
782
+ if (args.dryRun) {
783
+ log(`[runner] [DRY RUN] Would upload ${cleaned.length} chars to ${BASE_URL}/extract`);
784
+ totalProcessed++;
785
+ continue;
786
+ }
787
+
788
+ // A5.3: Write durable queue file BEFORE POST (write-before-POST pattern)
789
+ const sha = crypto.createHash('sha256').update(cleaned).digest('hex');
790
+
791
+ // Local dedup via ledger
792
+ if (ledgerHas(ledger, source.type, sessionRef.sessionId, sha)) {
793
+ log(`[runner] Skipped — already in ledger ${DIGEST_ACCOUNT}`);
794
+ totalSkipped++;
795
+ continue;
796
+ }
797
+
798
+ const queueFile = writeQueueFile({
799
+ source: source.type,
800
+ sessionId: sessionRef.sessionId,
801
+ transcript: cleaned,
802
+ sha,
803
+ scrubReport: report,
804
+ mtime: sessionRef.mtime,
805
+ queuedAt: new Date().toISOString(),
806
+ });
807
+
808
+ try {
809
+ const result = await postExtract(cleaned, sessionRef.sessionId, source.type, report);
810
+ log(`[runner] ✓ published=${result.learnings_published || 0} rejected=${result.learnings_rejected || 0} ${DIGEST_ACCOUNT} (extraction: ${result.extraction_id})`);
811
+ ledgerMark(ledger, source.type, sessionRef.sessionId, sha, sessionRef.mtime);
812
+ deleteQueueFile(queueFile);
813
+ saveLedger(ledger);
814
+ totalProcessed++;
815
+ } catch (err) {
816
+ log(`[runner] ✗ Upload failed: ${err.message} — queue file retained at ${queueFile}`);
817
+ totalFailed++;
818
+ // Do NOT exit loop; try next session
819
+ }
820
+ }
821
+ }
822
+
823
+ saveLedger(ledger);
824
+ log(`[runner] Summary: ${totalDiscovered} discovered, ${totalProcessed} processed, ${totalSkipped} skipped, ${totalFailed} failed`);
825
+ process.exit(totalFailed > 0 ? 1 : 0);
826
+ }
827
+
828
+ // ─── Exports (for testing) + Entry Point ────────────────────────────────────
829
+
830
+ module.exports = {
831
+ parseArgs, writeQueueFile, deleteQueueFile, listPendingFiles,
832
+ loadLedger, saveLedger, ledgerHighWater, ledgerHas, ledgerMark,
833
+ installHooks, installSweeper, installDigest, printStatus, scrubAndVerify, enumerateActiveSources,
834
+ KILL_SWITCH_PATH, PENDING_DIR, LEDGER_PATH,
835
+ };
836
+
837
+ // Only run main() when executed directly (not when required for tests)
838
+ if (require.main === module) {
839
+ main().catch(err => {
840
+ console.error('[runner] Fatal error:', err.message);
841
+ process.exit(1);
842
+ });
843
+ }