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.
- package/README.md +9 -9
- package/bin/auxilo-cli.js +447 -0
- package/lib/installer.js +647 -0
- package/lib/review.js +124 -0
- package/lib/sensitivity-filter.js +388 -0
- package/mcp-server.js +230 -64
- package/package.json +16 -4
- package/prompts/auxilo-learning-claude-code.md +250 -0
- package/prompts/auxilo-learning-generic.md +327 -0
- package/prompts/auxilo-learning-schema.json +98 -0
- package/scripts/hooks/auxilo-extract.sh +49 -0
- package/scripts/runner.js +843 -0
- package/scripts/sources/claude-code.js +182 -0
- package/scripts/sources/openclaw.js +171 -0
- package/scripts/sources/source.interface.js +85 -0
|
@@ -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
|
+
}
|