auxilo-mcp 0.9.2 → 0.9.3

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.
@@ -19,7 +19,24 @@ const os = require('os');
19
19
 
20
20
  const CATEGORIES = ['data-processing', 'web-interaction', 'code-execution', 'communication', 'storage-state', 'content-generation', 'payment-financial', 'monitoring'];
21
21
 
22
- const EXTRACTION_PROMPT = `You are extracting reusable OPERATIONAL LEARNINGS from an AI agent's session transcript, to publish to a PUBLIC knowledge marketplace read by other AI agents.
22
+ /**
23
+ * SPEC3 slice A1 gate — score-at-extraction, BUILT BUT DARK by default.
24
+ *
25
+ * ┌─ CRITICAL SEQUENCING CONSTRAINT (SPEC3-BUILDER-REVIEW-LOOP §3.1/§8) ──────┐
26
+ * │ Under a server WITHOUT the B1 extraction-channel hold, a clean /learn │
27
+ * │ submission carrying a floor-passing quality_self_assessment publishes │
28
+ * │ IMMEDIATELY (seamlessEligible). Turning this gate on against such a │
29
+ * │ server silently flips hook extraction from "everything held" to "clean │
30
+ * │ items auto-publish" — an unrecorded consent change (2026-06-10 class). │
31
+ * │ Do NOT set AUXILO_SCORE_EXTRACTION=1 until the server holds │
32
+ * │ submission_channel:'extraction' items behind standing consent (B1). │
33
+ * └───────────────────────────────────────────────────────────────────────────┘
34
+ */
35
+ function scoreExtractionEnabled(env = process.env) {
36
+ return env.AUXILO_SCORE_EXTRACTION === '1';
37
+ }
38
+
39
+ const EXTRACTION_PROMPT_BASE = `You are extracting reusable OPERATIONAL LEARNINGS from an AI agent's session transcript, to publish to a PUBLIC knowledge marketplace read by other AI agents.
23
40
 
24
41
  Extract 0 to 5 GENUINE learnings: non-obvious solutions, workarounds, API quirks, error root-causes, integration gotchas — the kind of thing that cost real debugging or combined multiple sources. SKIP trivial lookups, well-documented standard approaches, opinions, and conversation.
25
42
 
@@ -31,12 +48,36 @@ Output STRICT JSON ONLY — an array (possibly empty []) of objects with these k
31
48
  "category": one of ${JSON.stringify(CATEGORIES)}
32
49
  "tags": array of lowercase keyword strings
33
50
  "task_context": one sentence describing the task
34
- "outcome": one of "success","partial","failure","workaround"
51
+ "outcome": one of "success","partial","failure","workaround"`;
52
+
53
+ /** A1: rubric addendum — appended ONLY when scoreExtractionEnabled(). */
54
+ const QUALITY_RUBRIC_ADDENDUM = `
55
+ "quality_self_assessment": an object scoring the learning honestly on four
56
+ dimensions, each an INTEGER 1-5: "specificity" (precise and detailed, not
57
+ vague), "actionability" (another agent can directly use it), "novelty"
58
+ (non-obvious; an LLM would likely get it wrong), "completeness" (context,
59
+ reproduction steps, caveats), plus "total" (the exact sum of the four).
60
+ A learning worth publishing scores at least 14/20 with no dimension below 3.
61
+ If a learning honestly scores below that bar, DROP it from the array rather
62
+ than inflating the numbers.`;
63
+
64
+ const PROMPT_SUFFIX = `
35
65
  No prose, no explanation, no markdown code fences — just the raw JSON array.
36
66
 
37
67
  TRANSCRIPT:
38
68
  `;
39
69
 
70
+ /** Build the extraction prompt for the current (or injected) gate state. */
71
+ function buildExtractionPrompt(opts = {}) {
72
+ const withScore = opts.scoreExtraction !== undefined
73
+ ? Boolean(opts.scoreExtraction)
74
+ : scoreExtractionEnabled();
75
+ return EXTRACTION_PROMPT_BASE + (withScore ? QUALITY_RUBRIC_ADDENDUM : '') + PROMPT_SUFFIX;
76
+ }
77
+
78
+ /** Back-compat export: the default (gate-evaluated-at-call) prompt. */
79
+ const EXTRACTION_PROMPT = buildExtractionPrompt({ scoreExtraction: false });
80
+
40
81
  /** Resolve the `claude` binary — hook/launchd env may have a minimal PATH. */
41
82
  function resolveClaudeBin() {
42
83
  const candidates = [
@@ -63,7 +104,7 @@ function resolveClaudeBin() {
63
104
  */
64
105
  function extractWithClaudeCode(transcript) {
65
106
  const bin = resolveClaudeBin();
66
- const input = EXTRACTION_PROMPT + String(transcript).slice(0, 200000);
107
+ const input = buildExtractionPrompt() + String(transcript).slice(0, 200000);
67
108
  // Do NOT pass ANTHROPIC_API_KEY through — we want the user's logged-in Claude
68
109
  // subscription (OAuth), not an API key (which would bill someone). Delete it.
69
110
  const childEnv = { ...process.env, AUXILO_EXTRACTING: '1' };
@@ -79,8 +120,41 @@ function extractWithClaudeCode(transcript) {
79
120
  return { ok: true, out, reason: null };
80
121
  }
81
122
 
82
- /** Defensively parse a JSON array of learnings out of model output. */
83
- function parseLearnings(raw) {
123
+ /**
124
+ * A1: validate a model-emitted quality_self_assessment. Returns the normalized
125
+ * object, or null when malformed/missing — the caller then OMITS the field
126
+ * entirely (never fabricate; the server's `awaiting_quality` hold is the
127
+ * correct fallback and is deliberately not a 400 — AUD19-6 quarantines rather
128
+ * than bounces).
129
+ */
130
+ const QUALITY_DIMENSIONS = ['specificity', 'actionability', 'novelty', 'completeness'];
131
+ function validateQualityAssessment(qa) {
132
+ if (!qa || typeof qa !== 'object' || Array.isArray(qa)) return null;
133
+ const out = {};
134
+ let sum = 0;
135
+ for (const dim of QUALITY_DIMENSIONS) {
136
+ const v = qa[dim];
137
+ if (!Number.isInteger(v) || v < 1 || v > 5) return null;
138
+ out[dim] = v;
139
+ sum += v;
140
+ }
141
+ if (!Number.isInteger(qa.total) || qa.total !== sum) return null;
142
+ out.total = sum;
143
+ return out;
144
+ }
145
+
146
+ /**
147
+ * Defensively parse a JSON array of learnings out of model output.
148
+ * A1: `quality_self_assessment` is attached ONLY when (a) the score gate is on
149
+ * (opts.scoreExtraction, default = env AUXILO_SCORE_EXTRACTION) AND (b) the
150
+ * assessment validates. Gate OFF strips the field even if the model emits one,
151
+ * so a dark 0.9.3 client can never arm seamless publish (see the sequencing
152
+ * constraint at scoreExtractionEnabled).
153
+ */
154
+ function parseLearnings(raw, opts = {}) {
155
+ const withScore = opts.scoreExtraction !== undefined
156
+ ? Boolean(opts.scoreExtraction)
157
+ : scoreExtractionEnabled();
84
158
  let s = String(raw || '').trim();
85
159
  const fence = s.match(/```(?:json)?\s*([\s\S]*?)```/i);
86
160
  if (fence) s = fence[1].trim();
@@ -92,14 +166,21 @@ function parseLearnings(raw) {
92
166
  if (!Array.isArray(arr)) return [];
93
167
  return arr
94
168
  .filter(l => l && typeof l.title === 'string' && typeof l.body === 'string' && l.title.length >= 10 && l.body.length >= 50)
95
- .map(l => ({
96
- title: l.title,
97
- body: l.body,
98
- category: CATEGORIES.includes(l.category) ? l.category : 'code-execution',
99
- tags: Array.isArray(l.tags) ? l.tags.slice(0, 8).map(String) : [],
100
- task_context: typeof l.task_context === 'string' ? l.task_context : '',
101
- outcome: ['success', 'partial', 'failure', 'workaround'].includes(l.outcome) ? l.outcome : 'success',
102
- }));
169
+ .map(l => {
170
+ const out = {
171
+ title: l.title,
172
+ body: l.body,
173
+ category: CATEGORIES.includes(l.category) ? l.category : 'code-execution',
174
+ tags: Array.isArray(l.tags) ? l.tags.slice(0, 8).map(String) : [],
175
+ task_context: typeof l.task_context === 'string' ? l.task_context : '',
176
+ outcome: ['success', 'partial', 'failure', 'workaround'].includes(l.outcome) ? l.outcome : 'success',
177
+ };
178
+ if (withScore) {
179
+ const qa = validateQualityAssessment(l.quality_self_assessment);
180
+ if (qa) out.quality_self_assessment = qa;
181
+ }
182
+ return out;
183
+ });
103
184
  }
104
185
 
105
186
  /**
@@ -116,4 +197,8 @@ async function extractLocally(transcript, sourceType) {
116
197
  return { learnings: parseLearnings(out) };
117
198
  }
118
199
 
119
- module.exports = { extractLocally, parseLearnings, resolveClaudeBin, EXTRACTION_PROMPT, CATEGORIES };
200
+ module.exports = {
201
+ extractLocally, parseLearnings, resolveClaudeBin, CATEGORIES,
202
+ EXTRACTION_PROMPT, buildExtractionPrompt, scoreExtractionEnabled,
203
+ validateQualityAssessment, QUALITY_DIMENSIONS,
204
+ };
@@ -0,0 +1,145 @@
1
+ #!/usr/bin/env node
2
+
3
+ /**
4
+ * scripts/review-notice.js — SessionStart held-count notice (LW-18 layer 1b)
5
+ *
6
+ * Invoked by the Claude Code SessionStart hook (structured entry written by
7
+ * `auxilo setup` → lib/installer.js registerClaudeCodeSessionStartNotice via
8
+ * the ~/.auxilo/bin/auxilo-review-notice.sh shim). Whatever this prints to
9
+ * stdout is added to the session's context — so the contract is strict:
10
+ *
11
+ * COUNT ONLY. Never titles, never bodies, never flags. Pending bodies are
12
+ * adversarial by assumption (some are held BECAUSE injection-flagged —
13
+ * LW-18 threat model); surfacing content to a live agent at session start
14
+ * would be a confused-deputy channel. One count + two command names, that
15
+ * is all.
16
+ *
17
+ * Behavior:
18
+ * - FAIL-SILENT: every non-happy path exits 0 with NO output. A broken
19
+ * notice must never break (or noise up) a session.
20
+ * - Recursion guard: AUXILO_EXTRACTING=1 (the headless `claude -p`
21
+ * extraction child fires SessionStart too) → silent exit.
22
+ * - Suppression: at most ONE notice per 4 hours, tracked in
23
+ * ~/.auxilo/review-notice-state.json (LW-18 spec).
24
+ * - Count source: GET /account/pending/summary with the credentials from
25
+ * ~/.auxilo/credentials.json; 3.5s abort so session start is never held
26
+ * hostage by a slow network.
27
+ *
28
+ * Self-contained (fs/path/os + global fetch) — ships in RUNNER_STACK to
29
+ * ~/.auxilo/bin/scripts/ and must not require anything outside that layout
30
+ * except node builtins.
31
+ *
32
+ * @module review-notice
33
+ */
34
+
35
+ 'use strict';
36
+
37
+ const fs = require('fs');
38
+ const path = require('path');
39
+ const os = require('os');
40
+
41
+ /** ≤ 1 notice per 4h (LW-18 spec). */
42
+ const NOTICE_SUPPRESSION_MS = 4 * 60 * 60 * 1000;
43
+
44
+ /** Network budget: SessionStart hooks block session start. */
45
+ const FETCH_TIMEOUT_MS = 3500;
46
+
47
+ function auxiloDir(homeDir) {
48
+ return path.join(homeDir || os.homedir(), '.auxilo');
49
+ }
50
+
51
+ function statePath(homeDir) {
52
+ return path.join(auxiloDir(homeDir), 'review-notice-state.json');
53
+ }
54
+
55
+ /** Read suppression state; malformed/absent → {} (fail-open to notifying). */
56
+ function readState(homeDir) {
57
+ try {
58
+ const s = JSON.parse(fs.readFileSync(statePath(homeDir), 'utf-8'));
59
+ return s && typeof s === 'object' ? s : {};
60
+ } catch {
61
+ return {};
62
+ }
63
+ }
64
+
65
+ /**
66
+ * Pure suppression decision: notify iff no prior notice, or the last one is
67
+ * older than the suppression window (bad timestamps read as "no prior").
68
+ */
69
+ function shouldNotify(state, now = Date.now(), suppressionMs = NOTICE_SUPPRESSION_MS) {
70
+ const last = state && state.last_notice_at ? Date.parse(state.last_notice_at) : NaN;
71
+ if (!Number.isFinite(last)) return true;
72
+ return now - last >= suppressionMs;
73
+ }
74
+
75
+ /** Persist the suppression stamp (best-effort — failure must not throw). */
76
+ function writeState(homeDir, now = Date.now()) {
77
+ try {
78
+ const p = statePath(homeDir);
79
+ fs.mkdirSync(path.dirname(p), { recursive: true });
80
+ fs.writeFileSync(p, JSON.stringify({ last_notice_at: new Date(now).toISOString() }, null, 2) + '\n');
81
+ } catch { /* fail-silent */ }
82
+ }
83
+
84
+ /** The one line. Count only — see the header contract. */
85
+ function renderNotice(count) {
86
+ return `Auxilo: ${count} learning(s) held for your review — run auxilo_review (MCP) or \`npx auxilo review\`.`;
87
+ }
88
+
89
+ /** Load credentials; null when absent/malformed/keyless. */
90
+ function readCredentials(homeDir) {
91
+ try {
92
+ const creds = JSON.parse(fs.readFileSync(path.join(auxiloDir(homeDir), 'credentials.json'), 'utf-8'));
93
+ return creds && typeof creds === 'object' && creds.api_key ? creds : null;
94
+ } catch {
95
+ return null;
96
+ }
97
+ }
98
+
99
+ async function fetchPendingCount(creds, fetchImpl = fetch) {
100
+ const baseUrl = String(creds.base_url || 'https://auxilo.io').replace(/\/+$/, '');
101
+ const controller = new AbortController();
102
+ const timer = setTimeout(() => controller.abort(), FETCH_TIMEOUT_MS);
103
+ try {
104
+ const res = await fetchImpl(`${baseUrl}/account/pending/summary`, {
105
+ headers: { 'X-API-Key': creds.api_key },
106
+ signal: controller.signal,
107
+ });
108
+ if (!res.ok) return null;
109
+ const body = await res.json();
110
+ const n = body && body.pending_count;
111
+ return Number.isFinite(n) ? n : null;
112
+ } catch {
113
+ return null;
114
+ } finally {
115
+ clearTimeout(timer);
116
+ }
117
+ }
118
+
119
+ async function main() {
120
+ // Recursion guard: extraction children must never notice (or burn the
121
+ // suppression window).
122
+ if (process.env.AUXILO_EXTRACTING === '1') return;
123
+
124
+ const homeDir = os.homedir();
125
+ const creds = readCredentials(homeDir);
126
+ if (!creds) return;
127
+
128
+ if (!shouldNotify(readState(homeDir))) return;
129
+
130
+ const count = await fetchPendingCount(creds);
131
+ if (count == null || count <= 0) return;
132
+
133
+ process.stdout.write(renderNotice(count) + '\n');
134
+ writeState(homeDir);
135
+ }
136
+
137
+ module.exports = {
138
+ shouldNotify, renderNotice, readState, writeState, readCredentials,
139
+ fetchPendingCount, NOTICE_SUPPRESSION_MS, FETCH_TIMEOUT_MS,
140
+ };
141
+
142
+ if (require.main === module) {
143
+ // FAIL-SILENT: exit 0 on every path, including internal errors.
144
+ main().then(() => process.exit(0)).catch(() => process.exit(0));
145
+ }
package/scripts/runner.js CHANGED
@@ -36,11 +36,9 @@ const fs = require('fs');
36
36
  const path = require('path');
37
37
  const os = require('os');
38
38
  const crypto = require('crypto');
39
+ const { spawn } = require('child_process');
39
40
  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
- const { GeminiCliSource } = require('./sources/gemini-cli.js');
43
- const { AntigravitySource } = require('./sources/antigravity.js');
41
+ const { TranscriptSource } = require('./sources/source.interface.js');
44
42
  const { GenericJsonlSource } = require('./sources/generic-jsonl.js');
45
43
 
46
44
  // ─── Constants ──────────────────────────────────────────────────────────────
@@ -87,14 +85,42 @@ const PENDING_DIR = path.join(AUXILO_DIR, 'pending-learnings');
87
85
  const LEDGER_PATH = path.join(AUXILO_DIR, 'ledger.json');
88
86
  const LOG_PATH = path.join(AUXILO_DIR, 'extract.log');
89
87
 
90
- // ─── Source Registry (§4.4) ─────────────────────────────────────────────────
88
+ // ─── Source Registry (§4.4 / UC-3 dynamic) ──────────────────────────────────
89
+ //
90
+ // UC-3: the registry is built by enumerating scripts/sources/*.js — a new
91
+ // adapter self-registers by dropping a file in that exports a TranscriptSource
92
+ // subclass with a static `id`. Excluded by name: the abstract interface, and
93
+ // generic-jsonl (the config-instantiated single-file FALLBACK, not a
94
+ // discoverable source — its detect() is deliberately inert).
95
+ //
96
+ // Per-file try/catch: one broken adapter must never kill the runner
97
+ // (fail-silent degradation, UC §5). Duplicate ids: first file wins
98
+ // (alphabetical), so a rogue copy can't shadow an established adapter.
91
99
 
92
- const SOURCES = [
93
- ClaudeCodeSource,
94
- OpenClawSource,
95
- GeminiCliSource,
96
- AntigravitySource,
97
- ];
100
+ const SOURCE_REGISTRY_EXCLUDE = new Set(['source.interface.js', 'generic-jsonl.js']);
101
+
102
+ function loadSources() {
103
+ const dir = path.join(__dirname, 'sources');
104
+ const classes = [];
105
+ let files = [];
106
+ try {
107
+ files = fs.readdirSync(dir).filter(f => f.endsWith('.js') && !SOURCE_REGISTRY_EXCLUDE.has(f)).sort();
108
+ } catch { return classes; }
109
+ for (const f of files) {
110
+ let mod;
111
+ try { mod = require(`./sources/${f}`); } catch { continue; } // broken adapter — skip silently
112
+ for (const exported of Object.values(mod || {})) {
113
+ if (typeof exported !== 'function') continue;
114
+ if (!(exported.prototype instanceof TranscriptSource)) continue;
115
+ if (!exported.id || exported.id === 'abstract') continue;
116
+ if (classes.some(c => c.id === exported.id)) continue;
117
+ classes.push(exported);
118
+ }
119
+ }
120
+ return classes;
121
+ }
122
+
123
+ const SOURCES = loadSources();
98
124
 
99
125
  async function enumerateActiveSources(filter) {
100
126
  const active = [];
@@ -233,32 +259,43 @@ function listPendingFiles() {
233
259
 
234
260
  // ─── Upload ─────────────────────────────────────────────────────────────────
235
261
 
236
- async function postExtract(transcript, sessionId, sourceType, _scrubReport) {
237
- // CLIENT-SIDE extraction (2026-07-02). Server /extract is deprecated (410) Auxilo
238
- // does not pay to extract. The local model (via `claude -p`) extracts + self-screens
239
- // the already-client-scrubbed transcript, and we submit finished learnings to /learn.
240
- const { extractLocally } = require('./extract-local.js');
241
- let learnings;
242
- let skipped;
243
- try {
244
- ({ learnings, skipped } = await extractLocally(transcript, sourceType));
245
- } catch (err) {
246
- throw new Error(`Local extraction failed: ${err.message}`);
247
- }
248
- if (skipped) {
249
- log(`[runner] ${skipped}`);
250
- return { learnings_published: 0, learnings_rejected: 0, extraction_id: 'client-skip' };
251
- }
262
+ /**
263
+ * Submit finished learnings to POST /learn, classifying each response
264
+ * truthfully (P1-13b / SPEC3 A2):
265
+ * 2xx + status 'approved' → published (seamless)
266
+ * 2xx + status 'pending_review' held (the contributor review queue)
267
+ * anything else / network error rejected
268
+ *
269
+ * Every submission carries `submission_channel: 'extraction'` UNCONDITIONALLY.
270
+ * SPEC3 §3.2 trust analysis: the marker is a BRAKE, never a gas pedal — a
271
+ * pre-B1 server destructures known fields only and ignores it; a post-B1
272
+ * server uses it to HOLD extraction-channel items behind standing consent.
273
+ * Shipping it dark means the brake is in the field before scoring ever arms
274
+ * (see the AUXILO_SCORE_EXTRACTION sequencing constraint in extract-local.js).
275
+ *
276
+ * `quality_self_assessment` passes through spread-if-present extract-local
277
+ * only attaches it when the A1 score gate is on.
278
+ *
279
+ * @param {Array<object>} learnings
280
+ * @param {string} sourceType
281
+ * @param {object} [opts] { fetchImpl, baseUrl, apiKey } — injectable for tests
282
+ * @returns {Promise<{published:number, held:number, rejected:number}>}
283
+ */
284
+ async function submitLearnings(learnings, sourceType, opts = {}) {
285
+ const fetchImpl = opts.fetchImpl || fetch;
286
+ const baseUrl = opts.baseUrl || BASE_URL;
287
+ const apiKey = opts.apiKey !== undefined ? opts.apiKey : API_KEY;
252
288
 
253
289
  let published = 0;
290
+ let held = 0;
254
291
  let rejected = 0;
255
292
  for (const l of learnings) {
256
293
  try {
257
- const res = await fetch(`${BASE_URL}/learn`, {
294
+ const res = await fetchImpl(`${baseUrl}/learn`, {
258
295
  method: 'POST',
259
296
  headers: {
260
297
  'Content-Type': 'application/json',
261
- 'X-API-Key': API_KEY,
298
+ 'X-API-Key': apiKey,
262
299
  'Idempotency-Key': crypto.randomUUID(),
263
300
  },
264
301
  body: JSON.stringify({
@@ -269,16 +306,69 @@ async function postExtract(transcript, sessionId, sourceType, _scrubReport) {
269
306
  task_context: l.task_context,
270
307
  outcome: l.outcome,
271
308
  contributor_agent: `auxilo-hook/${sourceType}`,
309
+ submission_channel: 'extraction',
310
+ ...(l.quality_self_assessment && { quality_self_assessment: l.quality_self_assessment }),
272
311
  }),
273
312
  });
274
- if (res.ok) published += 1;
275
- else rejected += 1;
313
+ if (!res.ok) { rejected += 1; continue; }
314
+ let body = {};
315
+ try { body = await res.json(); } catch { /* tolerate non-JSON 2xx */ }
316
+ if (body && body.status === 'pending_review') held += 1;
317
+ else published += 1;
276
318
  } catch (_) {
277
319
  rejected += 1;
278
320
  }
279
321
  }
280
- log(`[runner] client-side extraction: ${learnings.length} candidate(s), published=${published} rejected=${rejected}`);
281
- return { learnings_published: published, learnings_rejected: rejected, extraction_id: `client-${sessionId}` };
322
+ return { published, held, rejected };
323
+ }
324
+
325
+ async function postExtract(transcript, sessionId, sourceType, _scrubReport) {
326
+ // CLIENT-SIDE extraction (2026-07-02). Server /extract is deprecated (410) — Auxilo
327
+ // does not pay to extract. The local model (via `claude -p`) extracts + self-screens
328
+ // the already-client-scrubbed transcript, and we submit finished learnings to /learn.
329
+ const { extractLocally } = require('./extract-local.js');
330
+ let learnings;
331
+ let skipped;
332
+ try {
333
+ ({ learnings, skipped } = await extractLocally(transcript, sourceType));
334
+ } catch (err) {
335
+ throw new Error(`Local extraction failed: ${err.message}`);
336
+ }
337
+ if (skipped) {
338
+ log(`[runner] ${skipped}`);
339
+ return { learnings_published: 0, learnings_held: 0, learnings_rejected: 0, extraction_id: 'client-skip' };
340
+ }
341
+
342
+ const { published, held, rejected } = await submitLearnings(learnings, sourceType);
343
+ // NOTE: token-free prose — the digest-parsed `published=`/`held=`/`rejected=`
344
+ // tokens live ONLY on the per-run caller log lines. This line previously
345
+ // carried `published=` too and the digest double-counted every extraction
346
+ // (its dedup only drops byte-identical lines). P1-13b.
347
+ log(`[runner] client-side extraction: ${learnings.length} candidate(s) → ${published} live, ${held} held for review, ${rejected} rejected`);
348
+ return { learnings_published: published, learnings_held: held, learnings_rejected: rejected, extraction_id: `client-${sessionId}` };
349
+ }
350
+
351
+ // ─── Held-items local notification (LW-18 layer 2) ──────────────────────────
352
+
353
+ /**
354
+ * macOS notification when a run holds items for review: COUNT ONLY, no titles,
355
+ * no content (pending bodies are adversarial by assumption — LW-18 threat
356
+ * model). Fail-silent: darwin-only, detached osascript spawn, every error
357
+ * swallowed; a broken notifier must never fail the run. AUXILO_NO_NOTIFY=1
358
+ * disables (tests, headless CI).
359
+ */
360
+ function notifyHeld(count) {
361
+ try {
362
+ if (!count || count <= 0) return;
363
+ if (process.platform !== 'darwin') return;
364
+ if (process.env.AUXILO_NO_NOTIFY === '1') return;
365
+ const msg = `${count} learning(s) held for your review — run npx auxilo review`;
366
+ const child = spawn('/usr/bin/osascript', [
367
+ '-e', `display notification ${JSON.stringify(msg)} with title "Auxilo"`,
368
+ ], { stdio: 'ignore', detached: true });
369
+ child.unref();
370
+ child.on('error', () => { /* fail-silent */ });
371
+ } catch { /* fail-silent */ }
282
372
  }
283
373
 
284
374
  // ─── Install Hooks (B15) ────────────────────────────────────────────────────
@@ -362,19 +452,38 @@ function installHooks() {
362
452
  // Renamed 2026-07-15: the original label carried a dead pre-Fly host prefix (legacy-label purge).
363
453
  const SWEEPER_LABEL = 'io.auxilo.sweeper';
364
454
 
455
+ /**
456
+ * The sweeper copy manifest. Sources rows are DERIVED by enumerating
457
+ * scripts/sources/*.js (UC-3): a new adapter file is automatically installed —
458
+ * the d8c7099 bug class (adapter added, manifest not, installed sweepers crash
459
+ * with MODULE_NOT_FOUND) is now structurally impossible. Exported for the
460
+ * manifest-closure test in test/wave3-client-funnel.test.js.
461
+ */
462
+ function sweeperManifest(repoRoot = path.resolve(__dirname, '..')) {
463
+ const sourceRows = [];
464
+ try {
465
+ for (const f of fs.readdirSync(path.join(repoRoot, 'scripts', 'sources')).filter(f => f.endsWith('.js')).sort()) {
466
+ sourceRows.push([`scripts/sources/${f}`, `scripts/sources/${f}`, 0o644]);
467
+ }
468
+ } catch { /* missing dir surfaces as missing-file errors below */ }
469
+ return [
470
+ ['scripts/auxilo-sweeper-wrapper.sh', 'auxilo-sweeper-wrapper.sh', 0o755],
471
+ ['scripts/runner.js', 'scripts/runner.js', 0o755],
472
+ ...sourceRows,
473
+ ['lib/sensitivity-filter.js', 'lib/sensitivity-filter.js', 0o644],
474
+ // Client-side extraction (2026-07-02) — required by the sweep path since /extract went 410.
475
+ // Missing from this manifest until 2026-07-19: installed sweepers crashed with
476
+ // "Cannot find module './extract-local.js'" while queue files were retained.
477
+ ['scripts/extract-local.js', 'scripts/extract-local.js', 0o644],
478
+ ];
479
+ }
480
+
365
481
  function installSweeper() {
366
482
  const repoRoot = path.resolve(__dirname, '..');
367
483
  const binRoot = path.join(AUXILO_DIR, 'bin');
368
484
 
369
485
  // 1. Copy executable surface, preserving relative layout so requires resolve.
370
- const filesToCopy = [
371
- ['scripts/auxilo-sweeper-wrapper.sh', 'auxilo-sweeper-wrapper.sh', 0o755],
372
- ['scripts/runner.js', 'scripts/runner.js', 0o755],
373
- ['scripts/sources/source.interface.js', 'scripts/sources/source.interface.js', 0o644],
374
- ['scripts/sources/claude-code.js', 'scripts/sources/claude-code.js', 0o644],
375
- ['scripts/sources/openclaw.js', 'scripts/sources/openclaw.js', 0o644],
376
- ['lib/sensitivity-filter.js', 'lib/sensitivity-filter.js', 0o644],
377
- ];
486
+ const filesToCopy = sweeperManifest(repoRoot);
378
487
  for (const [src, dest, mode] of filesToCopy) {
379
488
  const srcPath = path.join(repoRoot, src);
380
489
  const destPath = path.join(binRoot, dest);
@@ -734,7 +843,8 @@ async function main() {
734
843
 
735
844
  try {
736
845
  const result = await postExtract(cleaned, sessionId, sourceType, report);
737
- log(`[runner] ✓ published=${result.learnings_published || 0} rejected=${result.learnings_rejected || 0} ${DIGEST_ACCOUNT} (extraction: ${result.extraction_id})`);
846
+ log(`[runner] ✓ published=${result.learnings_published || 0} held=${result.learnings_held || 0} rejected=${result.learnings_rejected || 0} ${DIGEST_ACCOUNT} (extraction: ${result.extraction_id})`);
847
+ notifyHeld(result.learnings_held || 0); // LW-18 layer 2: count-only, fail-silent
738
848
  // Mark only after a successful upload so a failed POST can be retried.
739
849
  const ledger = loadLedger();
740
850
  ledgerMark(ledger, sourceType, sessionId, contentSha, sessionRef.mtime);
@@ -751,13 +861,15 @@ async function main() {
751
861
  const pending = listPendingFiles();
752
862
  log(`[runner] Flushing ${pending.length} pending queue file(s)...`);
753
863
  let flushed = 0;
864
+ let flushHeld = 0;
754
865
  for (const qf of pending) {
755
866
  try {
756
867
  const payload = JSON.parse(fs.readFileSync(qf, 'utf-8'));
757
868
  const result = await postExtract(
758
869
  payload.transcript, payload.sessionId, payload.source, payload.scrubReport
759
870
  );
760
- log(`[runner] ✓ Flushed ${path.basename(qf)}: published=${result.learnings_published || 0} rejected=${result.learnings_rejected || 0} ${DIGEST_ACCOUNT}`);
871
+ log(`[runner] ✓ Flushed ${path.basename(qf)}: published=${result.learnings_published || 0} held=${result.learnings_held || 0} rejected=${result.learnings_rejected || 0} ${DIGEST_ACCOUNT}`);
872
+ flushHeld += result.learnings_held || 0;
761
873
  deleteQueueFile(qf);
762
874
  flushed++;
763
875
  } catch (err) {
@@ -765,6 +877,7 @@ async function main() {
765
877
  }
766
878
  }
767
879
  log(`[runner] Flush complete: ${flushed}/${pending.length} succeeded`);
880
+ notifyHeld(flushHeld); // LW-18 layer 2: count-only, fail-silent
768
881
  process.exit(0);
769
882
  }
770
883
 
@@ -780,6 +893,7 @@ async function main() {
780
893
  let totalProcessed = 0;
781
894
  let totalSkipped = 0;
782
895
  let totalFailed = 0;
896
+ let totalHeld = 0;
783
897
 
784
898
  for (const source of sources) {
785
899
  log(`[runner] Discovering sessions from ${source.label} (${source.type})...`);
@@ -878,7 +992,8 @@ async function main() {
878
992
 
879
993
  try {
880
994
  const result = await postExtract(cleaned, sessionRef.sessionId, source.type, report);
881
- log(`[runner] ✓ published=${result.learnings_published || 0} rejected=${result.learnings_rejected || 0} ${DIGEST_ACCOUNT} (extraction: ${result.extraction_id})`);
995
+ log(`[runner] ✓ published=${result.learnings_published || 0} held=${result.learnings_held || 0} rejected=${result.learnings_rejected || 0} ${DIGEST_ACCOUNT} (extraction: ${result.extraction_id})`);
996
+ totalHeld += result.learnings_held || 0;
882
997
  ledgerMark(ledger, source.type, sessionRef.sessionId, sha, sessionRef.mtime);
883
998
  deleteQueueFile(queueFile);
884
999
  saveLedger(ledger);
@@ -893,6 +1008,7 @@ async function main() {
893
1008
 
894
1009
  saveLedger(ledger);
895
1010
  log(`[runner] Summary: ${totalDiscovered} discovered, ${totalProcessed} processed, ${totalSkipped} skipped, ${totalFailed} failed`);
1011
+ notifyHeld(totalHeld); // LW-18 layer 2: one notification per sweep, count-only
896
1012
  process.exit(totalFailed > 0 ? 1 : 0);
897
1013
  }
898
1014
 
@@ -902,6 +1018,7 @@ module.exports = {
902
1018
  parseArgs, writeQueueFile, deleteQueueFile, listPendingFiles,
903
1019
  loadLedger, saveLedger, ledgerHighWater, ledgerHas, ledgerMark,
904
1020
  installHooks, installSweeper, installDigest, printStatus, scrubAndVerify, enumerateActiveSources,
1021
+ loadSources, SOURCES, sweeperManifest, submitLearnings, notifyHeld,
905
1022
  KILL_SWITCH_PATH, PENDING_DIR, LEDGER_PATH,
906
1023
  };
907
1024