auxilo-mcp 0.9.1 → 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.
package/bin/auxilo-cli.js CHANGED
@@ -90,14 +90,25 @@ const CONSENT_TEXT = `
90
90
  --------------------------------
91
91
  If you opt in, a session-end hook runs after each session in your wired
92
92
  clients (Claude Code, Cursor, Gemini CLI, …) and a local runner:
93
- • READS the session transcript from your machine,
94
- • SCRUBS it locally (sensitivity filter: API keys, tokens, emails, PII
95
- redacted BEFORE anything leaves your machine),
96
- UPLOADS only the scrubbed transcript to Auxilo (${'POST /extract'}),
97
- where reusable learnings are extracted, quality-gated, moderated, and
98
- published to the marketplace under your account. You earn 70% of sales.
93
+ • READS the session transcript on your machine,
94
+ • SCRUBS it locally (sensitivity filter: API keys, tokens, emails, PII
95
+ are redacted first),
96
+ EXTRACTS reusable learnings locally using your own claude CLI (your
97
+ existing subscription). For this step your transcript is processed
98
+ only by your own model provider the same way your normal sessions
99
+ are, and is never sent to Auxilo, raw or scrubbed.
100
+ • UPLOADS only the finished learning drafts (title, body, category,
101
+ tags, task context, outcome) to Auxilo (${'POST /learn'}). A draft
102
+ that passes every screen publishes to the marketplace immediately
103
+ under your account, and you can retract it for 7 days. A draft that
104
+ any screen flags (sensitive, duplicate, uncertain quality) waits in
105
+ your private queue for \`auxilo review\`. Manual mode (approve first,
106
+ for everything) is available in your account settings. You earn 70%
107
+ of sales.
99
108
  You can stop any time with \`auxilo disable\` (local kill-switch) and review
100
- every run in ~/.auxilo/extract.log. Saying No installs the MCP server only.
109
+ every run in ~/.auxilo/extract.log. Saying No installs the MCP server only
110
+ no session-end capture hook is written into any client config unless you say
111
+ Yes, and any capture hooks left by an earlier install are removed.
101
112
  `;
102
113
 
103
114
  async function cmdSetup(flags) {
@@ -185,7 +196,11 @@ async function cmdSetup(flags) {
185
196
  }
186
197
  }
187
198
 
188
- // 4. Runner install + Claude Code hook --------------------------------------
199
+ // 4. Runner install + review notice -----------------------------------------
200
+ // UC-1a (GOV-3): NO capture hook is written into any third-party client
201
+ // config here — only files under our own ~/.auxilo/bin, plus the
202
+ // SessionStart review notice (a count-only account-status surface, not a
203
+ // capture hook). Capture hooks are written ONLY after consent=Yes (step 5).
189
204
  console.log('');
190
205
  try {
191
206
  const { binRoot } = installer.installRunner(HOME);
@@ -197,54 +212,95 @@ async function cmdSetup(flags) {
197
212
 
198
213
  const claudeCode = chosen.find((c) => c.id === 'claude-code');
199
214
  if (claudeCode) {
215
+ // LW-18: SessionStart held-count notice ("N learnings held for your
216
+ // review") — fail-silent, ≤1 per 4h, count-only. Installed regardless of
217
+ // extraction consent: the review queue also holds MCP contributions.
200
218
  try {
201
- const hook = installer.registerClaudeCodeHook(HOME);
202
- if (hook.changed) {
203
- console.log(` ✓ Claude Code SessionEnd hook registered (${hook.hookCmd})`);
204
- if (hook.removedLegacy.length > 0) {
205
- console.log(` (replaced legacy hook entr${hook.removedLegacy.length === 1 ? 'y' : 'ies'}: ${hook.removedLegacy.join(', ')})`);
206
- }
207
- } else {
208
- console.log(' ✓ Claude Code SessionEnd hook already registered (no changes)');
209
- }
219
+ const notice = installer.registerClaudeCodeSessionStartNotice(HOME);
220
+ console.log(notice.changed
221
+ ? ` ✓ Claude Code SessionStart review notice registered (${notice.hookCmd})`
222
+ : ' ✓ Claude Code SessionStart review notice already registered (no changes)');
210
223
  } catch (err) {
211
- console.error(` ✗ Hook registration SKIPPED — ${err.message}`);
224
+ console.error(` ✗ Review notice SKIPPED — ${err.message}`);
212
225
  }
213
226
  }
214
227
 
215
- // UC-1: capture hooks for every other chosen client that supports one.
216
- try {
217
- for (const r of installer.installCaptureHooks(HOME, chosen)) {
218
- if (r.error) {
219
- console.error(` ✗ ${r.name}: SKIPPED — ${r.error}`);
220
- } else if (r.changed) {
221
- console.log(` ✓ ${r.name}: capture hook registered (${r.event})`);
222
- if (r.notes) console.log(` ${r.notes}`);
223
- } else {
224
- console.log(` ✓ ${r.name}: capture hook already registered`);
225
- if (r.notes) console.log(` ${r.notes}`);
226
- }
227
- }
228
- } catch (err) {
229
- console.error(` ✗ Capture hooks SKIPPED — ${err.message}`);
230
- }
231
-
232
- // 5. Explicit consent (default No) ------------------------------------------
228
+ // 5. Explicit consent (default No), THEN capture hooks ----------------------
233
229
  console.log(CONSENT_TEXT);
234
230
  const consented = await askYesNo(' Enable background extraction?', false);
231
+ let extractionArmed = false;
235
232
  if (consented) {
236
233
  try {
237
234
  await installer.recordConsent({ action: 'grant', apiKey: creds.api_key, baseUrl });
238
235
  installer.enableSentinel(HOME);
236
+ extractionArmed = true;
239
237
  console.log(' ✓ Consent recorded; background extraction ENABLED (~/.auxilo/autonomous-enabled)');
240
238
  } catch (err) {
241
239
  console.error(` ✗ Could not record consent on server: ${err.message}`);
242
- console.error(' Background extraction NOT enabled (no sentinel created). Re-run `auxilo setup` to retry.');
240
+ console.error(' Background extraction NOT enabled (no sentinel created, no hooks written). Re-run `auxilo setup` to retry.');
243
241
  }
244
242
  } else {
245
243
  console.log(' ✓ Background extraction left OFF (MCP-only install). Enable later by re-running `auxilo setup`.');
246
244
  }
247
245
 
246
+ if (extractionArmed) {
247
+ // Consent recorded — NOW write the capture hooks (UC-1a ordering).
248
+ if (claudeCode) {
249
+ try {
250
+ const hook = installer.registerClaudeCodeHook(HOME);
251
+ if (hook.changed) {
252
+ console.log(` ✓ Claude Code SessionEnd hook registered (${hook.hookCmd})`);
253
+ if (hook.removedLegacy.length > 0) {
254
+ console.log(` (replaced legacy hook entr${hook.removedLegacy.length === 1 ? 'y' : 'ies'}: ${hook.removedLegacy.join(', ')})`);
255
+ }
256
+ } else {
257
+ console.log(' ✓ Claude Code SessionEnd hook already registered (no changes)');
258
+ }
259
+ } catch (err) {
260
+ console.error(` ✗ Hook registration SKIPPED — ${err.message}`);
261
+ }
262
+ }
263
+
264
+ // UC-1: capture hooks for every other chosen client that supports one.
265
+ try {
266
+ for (const r of installer.installCaptureHooks(HOME, chosen)) {
267
+ if (r.error) {
268
+ console.error(` ✗ ${r.name}: SKIPPED — ${r.error}`);
269
+ } else if (r.changed) {
270
+ console.log(` ✓ ${r.name}: capture hook registered (${r.event})`);
271
+ if (r.notes) console.log(` ${r.notes}`);
272
+ } else {
273
+ console.log(` ✓ ${r.name}: capture hook already registered`);
274
+ if (r.notes) console.log(` ${r.notes}`);
275
+ }
276
+ }
277
+ } catch (err) {
278
+ console.error(` ✗ Capture hooks SKIPPED — ${err.message}`);
279
+ }
280
+ } else {
281
+ // UC-1a: consent not granted (No, or consent record failed) — ensure NO
282
+ // capture-hook artifact remains in any client config, including ones a
283
+ // pre-UC-1a install wrote before the consent step.
284
+ try {
285
+ const removedHook = installer.removeClaudeCodeHook(HOME);
286
+ if (removedHook.changed) {
287
+ console.log(` ✓ Removed pre-existing Claude Code SessionEnd capture hook (${removedHook.removed.join(', ')})`);
288
+ }
289
+ } catch (err) {
290
+ console.error(` ✗ Claude Code hook cleanup SKIPPED — ${err.message}`);
291
+ }
292
+ try {
293
+ const allClients = installer.detectClients(HOME); // clean beyond the chosen set
294
+ for (const r of installer.removeCaptureHooks(HOME, allClients)) {
295
+ if (r.error) console.error(` ✗ ${r.name}: capture-hook cleanup SKIPPED — ${r.error}`);
296
+ else if (r.changed) console.log(` ✓ ${r.name}: removed pre-existing capture hook`);
297
+ }
298
+ } catch (err) {
299
+ console.error(` ✗ Capture-hook cleanup SKIPPED — ${err.message}`);
300
+ }
301
+ console.log(' ✓ No capture hooks are present in any client config.');
302
+ }
303
+
248
304
  // 6. Rules-file snippet (UC-0 agent-prompted contribution; opt-in, default No)
249
305
  const rulesClients = chosen.filter((c) => c.rulesPath);
250
306
  if (rulesClients.length > 0) {
@@ -275,6 +331,119 @@ async function cmdSetup(flags) {
275
331
  console.log('\nDone. Check `npx auxilo status` any time. Restart your client(s) to pick up the MCP server.\n');
276
332
  }
277
333
 
334
+ // ─── auxilo init (NF-3, Wave 3.4) ───────────────────────────────────────────
335
+ //
336
+ // Non-interactive builder onboarding: mint a SCOPED key for CI or a second
337
+ // machine WITHOUT re-running full setup (no client detection, no hooks, no
338
+ // consent flow, and — unless --save — no touch of ~/.auxilo/credentials.json).
339
+ // ZERO prompts by design: every input is a flag with a default, so piped/
340
+ // scripted stdin can never hang (LW-17 lesson class; init never opens the
341
+ // shared readline at all). The only human step is the device-code approval
342
+ // in a browser, which can happen on any machine.
343
+
344
+ const INIT_SCOPES = ['read', 'earnings-read', 'contribute'];
345
+
346
+ async function cmdInit(flags) {
347
+ const baseUrl = resolveBaseUrl(flags);
348
+
349
+ const scope = flags.scope === undefined ? 'contribute' : String(flags.scope);
350
+ if (!INIT_SCOPES.includes(scope)) {
351
+ console.error(`Invalid --scope '${scope}'. Must be one of: ${INIT_SCOPES.join(', ')}.`);
352
+ console.error('(admin keys cannot be created via the API or device flow.)');
353
+ process.exit(1);
354
+ }
355
+ const label = (flags.label === undefined || flags.label === true
356
+ ? `init-${os.hostname()}`
357
+ : String(flags.label)).trim().slice(0, 50);
358
+
359
+ if (!flags.json) {
360
+ console.log('\nAuxilo init — mint a scoped API key');
361
+ console.log('===================================');
362
+ console.log(`Server: ${baseUrl}`);
363
+ console.log(`Scope: ${scope} Label: ${label}\n`);
364
+ }
365
+
366
+ let result;
367
+ try {
368
+ result = await installer.deviceLogin({
369
+ baseUrl,
370
+ scope,
371
+ label,
372
+ onCode: (code, url) => {
373
+ if (!flags.json) {
374
+ console.log(` Your device code: ${code}`);
375
+ console.log(` Approve at: ${url}`);
376
+ console.log(' Waiting for authorization (Ctrl-C to abort)...\n');
377
+ } else {
378
+ // --json keeps stdout machine-readable; the human-step info goes to stderr.
379
+ console.error(`device_code=${code} approve_at=${url}`);
380
+ }
381
+ if (!flags['no-browser']) openBrowser(url);
382
+ },
383
+ });
384
+ } catch (err) {
385
+ console.error(`✗ init failed: ${err.message}`);
386
+ process.exit(1);
387
+ }
388
+
389
+ // Pre-Wave-3.4 servers ignore the requested scope and mint 'contribute'
390
+ // (and don't echo). Detect and WARN — never silently mislabel a credential.
391
+ const grantedScope = result.scope || 'contribute';
392
+ const grantedLabel = result.label || label;
393
+ if (grantedScope !== scope) {
394
+ console.error(` ! Server granted scope '${grantedScope}' (requested '${scope}') — older server version. The key below carries '${grantedScope}'.`);
395
+ }
396
+
397
+ if (flags['env-file']) {
398
+ const envPath = String(flags['env-file']);
399
+ try {
400
+ const w = installer.writeEnvFile(envPath, { api_key: result.api_key, base_url: baseUrl });
401
+ if (!flags.json) {
402
+ console.log(` ✓ ${w.created ? 'Created' : 'Updated'} ${w.path} (AUXILO_API_KEY, mode 0600)`);
403
+ }
404
+ } catch (err) {
405
+ // The key was already minted — surface it rather than losing it.
406
+ console.error(` ✗ Could not write ${envPath}: ${err.message}`);
407
+ console.error(' Your key (shown once — store it now):');
408
+ console.error(` ${result.api_key}`);
409
+ process.exit(1);
410
+ }
411
+ }
412
+
413
+ if (flags.save) {
414
+ installer.writeCredentials(HOME, {
415
+ api_key: result.api_key,
416
+ base_url: baseUrl,
417
+ email: result.email,
418
+ account_id: result.account_id,
419
+ });
420
+ if (!flags.json) console.log(' ✓ Saved to ~/.auxilo/credentials.json (mode 0600) — this machine now uses this key.');
421
+ }
422
+
423
+ if (flags.json) {
424
+ console.log(JSON.stringify({
425
+ api_key: result.api_key,
426
+ scope: grantedScope,
427
+ label: grantedLabel,
428
+ account_id: result.account_id,
429
+ email: result.email,
430
+ base_url: baseUrl,
431
+ env_file: flags['env-file'] ? String(flags['env-file']) : null,
432
+ saved_credentials: !!flags.save,
433
+ }));
434
+ return;
435
+ }
436
+
437
+ console.log(` ✓ Key minted for ${result.email || result.account_id} (scope: ${grantedScope}, label: ${grantedLabel})`);
438
+ if (!flags['env-file']) {
439
+ console.log('\n Your API key (shown ONCE — store it now, e.g. in your CI secret store):');
440
+ console.log(` ${result.api_key}\n`);
441
+ console.log(' Use it as the X-API-Key header, or write it to an env file next time with --env-file <path>.');
442
+ }
443
+ console.log(' Rotate later: POST /account/api-keys/rotate with this key (self-rotate).');
444
+ console.log(' Revoke later: from the dashboard, or DELETE /account/api-keys/:label with this key.\n');
445
+ }
446
+
278
447
  // ─── auxilo status ──────────────────────────────────────────────────────────
279
448
 
280
449
  async function cmdStatus() {
@@ -338,20 +507,108 @@ async function cmdDisable(flags) {
338
507
  // The human gate that makes background extraction safe to re-enable:
339
508
  // extract -> pending_review -> `auxilo review` -> approve the safe ones.
340
509
  // Operates ONLY on the caller's own pending learnings (account-scoped API key).
510
+ //
511
+ // Review-seamless (2026-07-18): the default view is now a triage summary table
512
+ // (quality desc, clean before flagged), with bulk modes that batch through
513
+ // POST /account/pending/bulk. HARD RULE for every bulk APPROVE path: print the
514
+ // exact list and count first, then require the operator to TYPE THE COUNT.
515
+ // There is no --yes bypass on any approve path; publishing always costs one
516
+ // typed number (2026-06-10 mass-publish lesson).
517
+
518
+ /** Truncate + pad a string to an exact width (for the triage table). */
519
+ function fit(s, width) {
520
+ const str = String(s == null ? '' : s);
521
+ if (str.length > width) return str.slice(0, Math.max(0, width - 1)) + '…';
522
+ return str.padEnd(width);
523
+ }
524
+
525
+ /** Short flag codes for a summary row: 'clean' or e.g. 'inj+sens'. */
526
+ function shortFlags(row) {
527
+ if (row.screens_passed) return 'clean';
528
+ const map = { injection: 'inj', content_sensitivity: 'sens', near_duplicate: 'dup' };
529
+ return (row.flags || []).map((f) => map[f] || f).join('+') || 'flagged';
530
+ }
531
+
532
+ /** One compact triage line (shared by the table and rapid mode). */
533
+ function triageLine(row, n, total) {
534
+ const q = row.quality == null ? ' --' : String(row.quality).padStart(3);
535
+ return ` ${String(n).padStart(String(total).length)}. q=${q} ${fit(shortFlags(row), 9)} ${fit(row.category || '', 20)} ${fit(row.title || '(no title)', 52)}`;
536
+ }
537
+
538
+ /** Print the triage summary: counts, then clean rows, then flagged rows. */
539
+ function printSummaryTable(summary) {
540
+ const items = summary.items || [];
541
+ const clean = items.filter((r) => r.screens_passed);
542
+ const flagged = items.filter((r) => !r.screens_passed);
543
+
544
+ console.log(`\n${summary.pending_count} learning(s) pending your review: ${clean.length} passed every platform screen, ${flagged.length} flagged.`);
545
+ const bands = summary.counts && summary.counts.by_quality_band;
546
+ if (bands) {
547
+ console.log(`Quality bands: 18-20: ${bands['18-20'] || 0} · 14-17: ${bands['14-17'] || 0} · 10-13: ${bands['10-13'] || 0} · below 10: ${bands.below_10 || 0} · unscored: ${bands.unscored || 0}`);
548
+ }
549
+ if (Array.isArray(summary.near_dup_clusters) && summary.near_dup_clusters.length > 0) {
550
+ console.log(`Near-duplicate clusters among your pending items: ${summary.near_dup_clusters.length} (review these together)`);
551
+ }
552
+
553
+ if (clean.length > 0) {
554
+ console.log(`\nCLEAN (passed every screen) - sorted by quality:`);
555
+ clean.forEach((r, i) => console.log(triageLine(r, i + 1, clean.length)));
556
+ }
557
+ if (flagged.length > 0) {
558
+ console.log(`\nFLAGGED (a platform screen raised a signal) - review individually:`);
559
+ flagged.forEach((r, i) => console.log(triageLine(r, i + 1, flagged.length)));
560
+ }
561
+ console.log('');
562
+ }
563
+
564
+ /**
565
+ * The counted confirmation: print the exact selection, then require the
566
+ * operator to type the exact count. Returns true ONLY on an exact match.
567
+ * No flag bypasses this for approve paths.
568
+ */
569
+ async function confirmByTypedCount(rows, actionLabel) {
570
+ console.log(`\n${actionLabel} the following ${rows.length} learning(s):`);
571
+ rows.forEach((r) => {
572
+ const q = r.quality == null ? '--' : r.quality;
573
+ console.log(` ${r.id} q=${q} [${shortFlags(r)}] ${r.title || '(no title)'}`);
574
+ });
575
+ console.log(`\nCount: ${rows.length}.`);
576
+ const answer = await ask(`Type the count (${rows.length}) to confirm, anything else aborts: `);
577
+ if (answer !== String(rows.length)) {
578
+ console.log('Aborted. Nothing changed.');
579
+ return false;
580
+ }
581
+ return true;
582
+ }
341
583
 
342
- /** Print one candidate's full detail for the interactive reviewer. */
343
- function printCandidate(l, n, total) {
344
- console.log(`\n──────────────────────────────────────────────────────────────`);
345
- console.log(`[${n}/${total}] ${l.title}`);
346
- console.log(` id: ${l.id}`);
347
- console.log(` category: ${l.category}${l.outcome ? ` outcome: ${l.outcome}` : ''}`);
348
- if (l.tags && l.tags.length) console.log(` tags: ${l.tags.join(', ')}`);
349
- if (l.created_at) console.log(` created: ${l.created_at}`);
350
- const flags = review.formatFlags(l);
351
- if (flags) console.log(` ⚠ flags: ${flags}`);
352
- console.log(` ----------------------------------------------------------`);
353
- console.log(l.body || '(no body)');
354
- console.log(` ----------------------------------------------------------`);
584
+ /** Run a confirmed selection through the bulk endpoint in chunks, with progress. */
585
+ async function runBulk({ apiKey, baseUrl, rows, decision, reason }) {
586
+ const decisions = rows.map((r) => (decision === 'reject' && reason)
587
+ ? { id: r.id, decision, reason }
588
+ : { id: r.id, decision });
589
+ const totals = await review.submitBulkChunked({
590
+ apiKey,
591
+ baseUrl,
592
+ decisions,
593
+ onChunk: ({ chunkIndex, chunkCount, response }) => {
594
+ console.log(` chunk ${chunkIndex + 1}/${chunkCount}: approved ${response.approved || 0}, rejected ${response.rejected || 0}, already done ${response.idempotent || 0}, failed ${response.failed || 0}`);
595
+ },
596
+ });
597
+ for (const r of totals.results) {
598
+ if (!r.ok) console.error(` ! ${r.id || `entry #${r.index}`}: ${r.error || r.code}`);
599
+ }
600
+ return totals;
601
+ }
602
+
603
+ /** Parse --min-quality into an integer 0-20 (default DEFAULT_QUALITY_THRESHOLD). */
604
+ function parseMinQuality(flags) {
605
+ if (flags['min-quality'] === undefined) return review.DEFAULT_QUALITY_THRESHOLD;
606
+ const n = parseInt(flags['min-quality'], 10);
607
+ if (!Number.isFinite(n) || n < 0 || n > 20) {
608
+ console.error('Invalid --min-quality (expected an integer 0-20).');
609
+ process.exit(1);
610
+ }
611
+ return n;
355
612
  }
356
613
 
357
614
  async function cmdReview(flags) {
@@ -363,80 +620,129 @@ async function cmdReview(flags) {
363
620
  const baseUrl = resolveBaseUrl(flags);
364
621
  const apiKey = creds.api_key;
365
622
 
366
- let res;
623
+ // ── Summary FIRST, always (triage before any decision surface) ────────────
624
+ let summary;
367
625
  try {
368
- res = await review.fetchPending({ apiKey, baseUrl, limit: 200 });
626
+ summary = await review.fetchPendingSummary({ apiKey, baseUrl });
369
627
  } catch (err) {
370
- console.error(`Could not fetch pending review queue: ${err.message}`);
628
+ console.error(`Could not fetch pending review summary: ${err.message}`);
371
629
  process.exit(1);
372
630
  }
373
631
 
374
- const items = res.learnings || [];
375
- const total = res.pending_count != null ? res.pending_count : items.length;
376
-
377
- if (items.length === 0) {
632
+ const rows = summary.items || [];
633
+ if (rows.length === 0) {
378
634
  console.log('No learnings pending your review. Nothing to do.');
379
635
  return;
380
636
  }
381
637
 
382
- // ── --list: non-interactive inspection, no mutations ──────────────────────
383
- if (flags.list) {
384
- console.log(`\n${total} learning(s) pending your review:\n`);
385
- items.forEach((l, i) => {
386
- const flagStr = review.formatFlags(l);
387
- console.log(` ${i + 1}. ${l.title} [${l.id}]${flagStr ? ` ⚠ ${flagStr}` : ''}`);
388
- });
389
- console.log('');
638
+ printSummaryTable(summary);
639
+
640
+ // ── --list: summary only, no mutations ────────────────────────────────────
641
+ if (flags.list) return;
642
+
643
+ // ── --approve-clean: bulk-approve items that passed EVERY platform screen
644
+ // AND clear the quality threshold. Typed-count confirmation, no bypass. ──
645
+ if (flags['approve-clean']) {
646
+ const minQuality = parseMinQuality(flags);
647
+ const sel = review.selectForBulkApprove(rows, { mode: 'clean', minQuality });
648
+ console.log(`approve-clean selection: ${sel.selected.length} of ${rows.length} pending (threshold: quality >= ${minQuality}).`);
649
+ if (sel.excluded_flagged.length) console.log(` excluded ${sel.excluded_flagged.length} screen-flagged item(s) (review those individually).`);
650
+ if (sel.excluded_low_quality.length) console.log(` excluded ${sel.excluded_low_quality.length} below the quality threshold.`);
651
+ if (sel.excluded_unscored.length) console.log(` excluded ${sel.excluded_unscored.length} with no quality score (pass --min-quality 0 to include).`);
652
+ if (sel.selected.length === 0) { console.log('Nothing qualifies. No changes made.'); return; }
653
+
654
+ if (!await confirmByTypedCount(sel.selected, 'APPROVE and PUBLISH')) return;
655
+ const totals = await runBulk({ apiKey, baseUrl, rows: sel.selected, decision: 'approve' });
656
+ console.log(`\nDone. Approved ${totals.approved} (already approved earlier: ${totals.idempotent}, failed: ${totals.failed}). Flagged and below-threshold items stay pending.`);
390
657
  return;
391
658
  }
392
659
 
393
- // ── --all-reject: bulk reject the whole batch (incident escape hatch) ─────
394
- if (flags['all-reject']) {
395
- const ok = flags.yes || await askYesNo(`Reject ALL ${items.length} pending learning(s)? This cannot be undone`, false);
396
- if (!ok) { console.log('Aborted nothing changed.'); return; }
397
- let rejected = 0;
398
- for (const l of items) {
399
- try {
400
- await review.submitDecision({ apiKey, baseUrl, id: l.id, decision: 'reject' });
401
- rejected += 1;
402
- console.log(` ✗ rejected (${rejected}/${items.length}): ${l.title}`);
403
- } catch (err) {
404
- console.error(` ! failed to reject ${l.id}: ${err.message}`);
405
- }
660
+ // ── --all: bulk-approve everything EXCEPT screen-flagged items unless
661
+ // --include-flagged. Typed-count confirmation, no bypass. ───────────────
662
+ if (flags.all) {
663
+ const includeFlagged = flags['include-flagged'] === true;
664
+ const sel = review.selectForBulkApprove(rows, { mode: 'all', includeFlagged });
665
+ if (includeFlagged && sel.selected.some((r) => !r.screens_passed)) {
666
+ console.log('WARNING: --include-flagged is set. This selection INCLUDES items a platform screen flagged (possible injection, sensitive content, or near-duplicates). Approving publishes them publicly.');
667
+ } else if (sel.excluded_flagged.length) {
668
+ console.log(`Excluding ${sel.excluded_flagged.length} screen-flagged item(s) (pass --include-flagged to include them; safer to review those individually).`);
406
669
  }
407
- console.log(`\nDone. Rejected ${rejected} of ${items.length}.`);
670
+ if (sel.selected.length === 0) { console.log('Nothing qualifies. No changes made.'); return; }
671
+
672
+ if (!await confirmByTypedCount(sel.selected, 'APPROVE and PUBLISH')) return;
673
+ const totals = await runBulk({ apiKey, baseUrl, rows: sel.selected, decision: 'approve' });
674
+ console.log(`\nDone. Approved ${totals.approved} (already approved earlier: ${totals.idempotent}, failed: ${totals.failed}).`);
408
675
  return;
409
676
  }
410
677
 
411
- // ── interactive (default) ─────────────────────────────────────────────────
412
- console.log(`\n${total} learning(s) pending your review.`);
413
- console.log('For each: [a]pprove (go live) · [r]eject (stays private) · [s]kip · [q]uit\n');
678
+ // ── --all-reject: bulk reject the whole batch (incident escape hatch).
679
+ // Now batched through the bulk endpoint. --yes keeps its scripted-incident
680
+ // bypass because rejection is the SAFE direction (nothing goes public). ──
681
+ if (flags['all-reject']) {
682
+ const ok = flags.yes || await confirmByTypedCount(rows, 'REJECT (keep private)');
683
+ if (!ok) return;
684
+ const totals = await runBulk({ apiKey, baseUrl, rows, decision: 'reject' });
685
+ console.log(`\nDone. Rejected ${totals.rejected} of ${rows.length} (already rejected earlier: ${totals.idempotent}, failed: ${totals.failed}).`);
686
+ return;
687
+ }
688
+
689
+ // ── interactive rapid mode (default): y/n/s per item, one line each.
690
+ // [v] shows the full body before deciding; decisions post one at a time
691
+ // through the same single-item endpoints as before. ──────────────────────
692
+ const bodies = new Map();
693
+ try {
694
+ let offset = 0;
695
+ const pageLimit = 200;
696
+ for (;;) {
697
+ const page = await review.fetchPending({ apiKey, baseUrl, limit: pageLimit, offset });
698
+ for (const l of page.learnings || []) bodies.set(l.id, l);
699
+ offset += (page.learnings || []).length;
700
+ if (!page.learnings || page.learnings.length === 0 || offset >= (page.pending_count || 0)) break;
701
+ }
702
+ } catch (err) {
703
+ console.error(`Could not fetch pending bodies: ${err.message}`);
704
+ process.exit(1);
705
+ }
706
+
707
+ console.log('Rapid review. Per item: [y] approve (goes live) · [n] reject (stays private) · [v] view full body · [s] skip · [q] quit\n');
414
708
 
415
- let approved = 0, rejected = 0, skipped = 0, seen = 0;
416
- for (const l of items) {
417
- seen += 1;
418
- printCandidate(l, seen, items.length);
709
+ let approved = 0, rejected = 0, skipped = 0;
710
+ const ordered = rows; // clean-first, quality desc (server order preserved by printSummaryTable groups)
711
+ for (let i = 0; i < ordered.length; i++) {
712
+ const row = ordered[i];
713
+ console.log(triageLine(row, i + 1, ordered.length));
419
714
  let choice = '';
420
- while (!['a', 'r', 's', 'q'].includes(choice)) {
421
- choice = (await ask(' approve / reject / skip / quit [a/r/s/q]? ')).toLowerCase().slice(0, 1);
715
+ for (;;) {
716
+ choice = (await ask(' [y/n/v/s/q]? ')).toLowerCase().slice(0, 1);
717
+ if (choice === 'v') {
718
+ const full = bodies.get(row.id);
719
+ console.log(' ------------------------------------------------------------');
720
+ console.log(full && full.body ? full.body : '(body not loaded)');
721
+ if (full) {
722
+ const flagStr = review.formatFlags(full);
723
+ if (flagStr) console.log(` ⚠ flags: ${flagStr}`);
724
+ }
725
+ console.log(' ------------------------------------------------------------');
726
+ continue;
727
+ }
728
+ if (['y', 'n', 's', 'q'].includes(choice)) break;
422
729
  }
423
730
  if (choice === 'q') { console.log(' Stopping. Remaining items left pending.'); break; }
424
- if (choice === 's') { skipped += 1; console.log(' → skipped (still pending)'); continue; }
731
+ if (choice === 's') { skipped += 1; continue; }
425
732
  try {
426
- if (choice === 'a') {
427
- await review.submitDecision({ apiKey, baseUrl, id: l.id, decision: 'approve' });
428
- approved += 1; console.log(' ✓ approved now live');
733
+ if (choice === 'y') {
734
+ await review.submitDecision({ apiKey, baseUrl, id: row.id, decision: 'approve' });
735
+ approved += 1; console.log(' ✓ approved, now live');
429
736
  } else {
430
- const reason = await ask(' reason (optional, Enter to skip): ');
431
- await review.submitDecision({ apiKey, baseUrl, id: l.id, decision: 'reject', reason: reason || undefined });
432
- rejected += 1; console.log(' ✗ rejected — stays private');
737
+ await review.submitDecision({ apiKey, baseUrl, id: row.id, decision: 'reject' });
738
+ rejected += 1; console.log(' ✗ rejected, stays private');
433
739
  }
434
740
  } catch (err) {
435
- console.error(` ! decision failed: ${err.message} (item left pending)`);
741
+ console.error(` ! decision failed: ${err.message} (item left pending)`);
436
742
  }
437
743
  }
438
744
 
439
- console.log(`\nReview complete: approved ${approved}, rejected ${rejected}, skipped ${skipped} of ${items.length}.`);
745
+ console.log(`\nReview complete: approved ${approved}, rejected ${rejected}, skipped ${skipped} of ${ordered.length}.`);
440
746
  }
441
747
 
442
748
  // ─── Entry point ────────────────────────────────────────────────────────────
@@ -449,10 +755,39 @@ Commands:
449
755
  setup Interactive install: client detection, MCP registration,
450
756
  device-code login, extraction runner + hook, consent.
451
757
  Flags: --re-auth, --base-url <url>
758
+ init Mint a SCOPED API key for CI or a second machine — no full setup,
759
+ no prompts (device-code approval in a browser is the only human
760
+ step). Does NOT touch ~/.auxilo/credentials.json unless --save.
761
+ Flags:
762
+ --scope <s> read | earnings-read | contribute (default:
763
+ contribute; admin is never issuable)
764
+ --label <name> key label (default: init-<hostname>)
765
+ --env-file <path> write AUXILO_API_KEY to an env file (0600,
766
+ updated in place) instead of printing the key
767
+ --save also write ~/.auxilo/credentials.json
768
+ --json machine-readable result on stdout
769
+ --no-browser don't try to open the approval URL
770
+ --base-url <url>
452
771
  status Show install/auth/extraction status.
453
772
  review Review YOUR pending-review learnings (from background extraction)
454
- and approve/reject each before it goes public.
455
- Flags: --list (inspect only), --all-reject [--yes], --base-url <url>
773
+ and approve/reject before anything goes public. Default: triage
774
+ summary table first (quality desc, clean vs flagged), then rapid
775
+ y/n/s review.
776
+ Flags:
777
+ --list summary table only, no changes
778
+ --approve-clean bulk-approve items that passed EVERY platform
779
+ screen AND quality >= threshold (default 14;
780
+ tune with --min-quality N). Prints the exact
781
+ list, then requires typing the count.
782
+ --all bulk-approve everything EXCEPT screen-flagged
783
+ items (add --include-flagged to include them).
784
+ Same typed-count confirmation.
785
+ --min-quality N quality threshold for --approve-clean (0-20)
786
+ --all-reject reject the whole batch [--yes for scripted
787
+ incident response; rejects stay private]
788
+ --base-url <url>
789
+ Bulk approvals ALWAYS require typing the exact count. No flag
790
+ skips that step.
456
791
  disable Turn off background extraction (local kill-switch; optional
457
792
  server-side consent revoke).
458
793
 
@@ -465,6 +800,7 @@ async function main() {
465
800
  const flags = parseFlags(process.argv);
466
801
  switch (cmd) {
467
802
  case 'setup': return cmdSetup(flags);
803
+ case 'init': return cmdInit(flags);
468
804
  case 'status': return cmdStatus(flags);
469
805
  case 'review': return cmdReview(flags);
470
806
  case 'disable': return cmdDisable(flags);