auxilo-mcp 0.9.6 → 0.9.8

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 CHANGED
@@ -33,7 +33,7 @@ Enable extraction and a session-end hook runs when your agent finishes a session
33
33
  1. The hook hands the runner the path to the session transcript.
34
34
  2. The runner reads the transcript on your machine and scrubs it with a fail-closed secret filter: 24 patterns covering API keys, tokens, private keys, JWTs, connection strings, cookies, email addresses, phone numbers, and internal IPs. If a rescan still finds a match, the run stops and nothing is sent.
35
35
  3. Your own model client (your claude CLI, on your subscription) drafts learnings from the scrubbed text and screens them again.
36
- 4. Clean drafts publish to the marketplace with a 7-day retraction window. Anything a screen flags waits in your private pending queue instead.
36
+ 4. Drafts held for review appear in three server-defined lanes: **Ready to publish**, **Needs a score**, and **Needs your eyes**. The last lane includes the server's reason so you can inspect the exception directly.
37
37
 
38
38
  ### What never leaves your machine
39
39
 
@@ -41,15 +41,20 @@ Your raw transcripts. They are read and scrubbed on your machine, and they are n
41
41
 
42
42
  ### How publishing works
43
43
 
44
- Extraction defaults to seamless: a draft that passes every screen (secrets, sensitivity, injection, near-duplicate, quality) publishes right away, and you can retract it for 7 days. A draft that any screen flags waits in a pending queue only you can see.
44
+ Extraction defaults to seamless: a draft that passes every screen (secrets, sensitivity, injection, near-duplicate, quality) publishes right away, and you can retract it for 7 days. A draft held for review waits in a pending queue only you can see, grouped by the server's Ready to publish / Needs a score / Needs your eyes verdict.
45
45
 
46
46
  ```bash
47
- npx auxilo review # approve, reject, or skip each queued draft
48
- npx auxilo status # clients, hooks, queue depth, consent state
49
- npx auxilo disable # kill switch: extraction stops immediately
47
+ npx auxilo review --list # show all three lanes and each exception reason
48
+ npx auxilo review --approve-ready # approve public-destined ready_to_publish rows
49
+ npx auxilo review --keep-private # keep Needs your eyes owner-only at $0 recall
50
+ npx auxilo review # approve, reject, keep private, view, or skip
51
+ npx auxilo status # clients, hooks, queue depth, consent state
52
+ npx auxilo disable # kill switch: extraction stops immediately
50
53
  ```
51
54
 
52
- Approve a queued draft and it goes live in the marketplace. Reject it and it stays private. Prefer approve-first for everything? Switch your account to manual mode in account settings and every draft waits for you.
55
+ Every bulk decision prints the exact selection and requires you to type its count. `--min-quality 16` narrows the ready lane; values below 14 explicitly reach into Needs a score and print a warning before the same counted confirmation. Private-destined rows are excluded from approval: keep one private for owner-only $0 recall, or sanitize and promote a corrected replacement through public review. A private-destined row can never go public through Approve.
56
+
57
+ Private extraction is opt-in: set `AUXILO_CAPTURE_VISIBILITY=private`, or set `"capture_visibility": "private"` in `~/.auxilo/credentials.json`. It can retain reusable non-technical candidates in your owner-only lane while preserving the same mandatory local sensitivity scrub and dedup pipeline. With the setting absent, extraction remains public-destined and technical-only.
53
58
 
54
59
  Extraction off? Your agent can still contribute in-session: tell it to submit a learning with the `auxilo_contribute` tool.
55
60
 
package/bin/auxilo-cli.js CHANGED
@@ -32,19 +32,45 @@ const HOME = os.homedir();
32
32
  // question N+1 arrives with question N's buffer and the next prompt hangs
33
33
  // on EOF (the 0.8.1 consent step silently never completed).
34
34
  let sharedRl = null;
35
+ let bufferedLines = [];
36
+ let lineWaiters = [];
37
+ let readlineEnded = false;
35
38
  function getRl() {
36
39
  if (!sharedRl) {
37
- sharedRl = readline.createInterface({ input: process.stdin, output: process.stdout });
40
+ readlineEnded = false;
41
+ sharedRl = readline.createInterface({
42
+ input: process.stdin,
43
+ output: process.stdout,
44
+ terminal: !!process.stdin.isTTY,
45
+ });
46
+ sharedRl.on('line', (line) => {
47
+ const answer = line.trim();
48
+ const waiter = lineWaiters.shift();
49
+ if (waiter) waiter(answer);
50
+ else bufferedLines.push(answer);
51
+ });
52
+ sharedRl.on('close', () => {
53
+ readlineEnded = true;
54
+ while (lineWaiters.length) lineWaiters.shift()('');
55
+ });
38
56
  }
39
57
  return sharedRl;
40
58
  }
41
59
  function closeRl() {
42
- if (sharedRl) { sharedRl.close(); sharedRl = null; }
60
+ if (sharedRl) sharedRl.close();
61
+ sharedRl = null;
62
+ bufferedLines = [];
63
+ lineWaiters = [];
64
+ readlineEnded = false;
43
65
  }
44
66
 
45
67
  function ask(question) {
68
+ getRl();
69
+ process.stdout.write(question);
70
+ if (bufferedLines.length > 0) return Promise.resolve(bufferedLines.shift());
71
+ if (readlineEnded) return Promise.resolve('');
46
72
  return new Promise((resolve) => {
47
- getRl().question(question, (answer) => resolve(answer.trim()));
73
+ lineWaiters.push(resolve);
48
74
  });
49
75
  }
50
76
 
@@ -509,7 +535,7 @@ async function cmdDisable(flags) {
509
535
  // Operates ONLY on the caller's own pending learnings (account-scoped API key).
510
536
  //
511
537
  // 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
538
+ // (quality-desc rows grouped into the server's three lanes), with bulk modes that batch through
513
539
  // POST /account/pending/bulk. HARD RULE for every bulk APPROVE path: print the
514
540
  // exact list and count first, then require the operator to TYPE THE COUNT.
515
541
  // There is no --yes bypass on any approve path; publishing always costs one
@@ -522,11 +548,20 @@ function fit(s, width) {
522
548
  return str.padEnd(width);
523
549
  }
524
550
 
525
- /** Short flag codes for a summary row: 'clean' or e.g. 'inj+sens'. */
551
+ /** Short lane/flag codes for a summary row, e.g. 'ready' or 'inj+sens'. */
526
552
  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';
553
+ const resolved = review.reviewLane(row);
554
+ const visibility = row.visibility === 'private' ? ['priv'] : [];
555
+ if (resolved.lane === 'ready_to_publish') return visibility.concat('ready').join('+');
556
+ if (resolved.lane === 'needs_score') return visibility.concat('score').join('+');
557
+ const map = {
558
+ injection: 'inj',
559
+ content_sensitivity: 'sens',
560
+ near_duplicate: 'dup',
561
+ process_advice: 'advice',
562
+ account_vocab: 'vocab',
563
+ };
564
+ return visibility.concat((row.flags || []).map((f) => map[f] || f)).join('+') || 'flagged';
530
565
  }
531
566
 
532
567
  /** One compact triage line (shared by the table and rapid mode). */
@@ -535,13 +570,41 @@ function triageLine(row, n, total) {
535
570
  return ` ${String(n).padStart(String(total).length)}. q=${q} ${fit(shortFlags(row), 9)} ${fit(row.category || '', 20)} ${fit(row.title || '(no title)', 52)}`;
536
571
  }
537
572
 
538
- /** Print the triage summary: counts, then clean rows, then flagged rows. */
573
+ function groupSummaryRows(summary) {
574
+ const groups = {
575
+ ready_to_publish: [],
576
+ needs_score: [],
577
+ needs_your_eyes: [],
578
+ };
579
+ let versionSkew = false;
580
+ for (const row of summary.items || []) {
581
+ const resolved = review.reviewLane(row);
582
+ versionSkew = versionSkew || resolved.version_skew;
583
+ groups[resolved.lane].push(row);
584
+ }
585
+ const serverCounts = summary.counts && summary.counts.by_lane;
586
+ const counts = !versionSkew && serverCounts
587
+ ? {
588
+ ready_to_publish: serverCounts.ready_to_publish || 0,
589
+ needs_score: serverCounts.needs_score || 0,
590
+ needs_your_eyes: serverCounts.needs_your_eyes || 0,
591
+ }
592
+ : {
593
+ ready_to_publish: groups.ready_to_publish.length,
594
+ needs_score: groups.needs_score.length,
595
+ needs_your_eyes: groups.needs_your_eyes.length,
596
+ };
597
+ return { groups, counts, versionSkew };
598
+ }
599
+
600
+ /** Print the triage summary in the server's three-lane order. */
539
601
  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);
602
+ const { groups, counts, versionSkew } = groupSummaryRows(summary);
543
603
 
544
- console.log(`\n${summary.pending_count} learning(s) pending your review: ${clean.length} passed every platform screen, ${flagged.length} flagged.`);
604
+ console.log(`\n${summary.pending_count} learning(s) pending your review: ${counts.ready_to_publish} ready to publish, ${counts.needs_score} need a score, ${counts.needs_your_eyes} need your eyes.`);
605
+ if (versionSkew) {
606
+ console.log('VERSION SKEW: this server did not return lane on every row; using the legacy screens-and-quality fallback.');
607
+ }
545
608
  const bands = summary.counts && summary.counts.by_quality_band;
546
609
  if (bands) {
547
610
  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}`);
@@ -550,13 +613,20 @@ function printSummaryTable(summary) {
550
613
  console.log(`Near-duplicate clusters among your pending items: ${summary.near_dup_clusters.length} (review these together)`);
551
614
  }
552
615
 
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)));
616
+ if (groups.ready_to_publish.length > 0) {
617
+ console.log(`\nREADY TO PUBLISH (${counts.ready_to_publish}) - sorted by quality:`);
618
+ groups.ready_to_publish.forEach((r, i) => console.log(triageLine(r, i + 1, groups.ready_to_publish.length)));
556
619
  }
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)));
620
+ if (groups.needs_score.length > 0) {
621
+ console.log(`\nNEEDS A SCORE (${counts.needs_score}) - score or review individually:`);
622
+ groups.needs_score.forEach((r, i) => console.log(triageLine(r, i + 1, groups.needs_score.length)));
623
+ }
624
+ if (groups.needs_your_eyes.length > 0) {
625
+ console.log(`\nNEEDS YOUR EYES (${counts.needs_your_eyes}) - review individually:`);
626
+ groups.needs_your_eyes.forEach((r, i) => {
627
+ console.log(triageLine(r, i + 1, groups.needs_your_eyes.length));
628
+ if (r.why) console.log(` why: ${r.why}`);
629
+ });
560
630
  }
561
631
  console.log('');
562
632
  }
@@ -591,7 +661,7 @@ async function runBulk({ apiKey, baseUrl, rows, decision, reason }) {
591
661
  baseUrl,
592
662
  decisions,
593
663
  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}`);
664
+ console.log(` chunk ${chunkIndex + 1}/${chunkCount}: approved ${response.approved || 0}, kept private ${response.kept_private || 0}, rejected ${response.rejected || 0}, already done ${response.idempotent || 0}, failed ${response.failed || 0}`);
595
665
  },
596
666
  });
597
667
  for (const r of totals.results) {
@@ -640,32 +710,49 @@ async function cmdReview(flags) {
640
710
  // ── --list: summary only, no mutations ────────────────────────────────────
641
711
  if (flags.list) return;
642
712
 
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']) {
713
+ // ── --approve-ready: consume the server's ready_to_publish verdict.
714
+ // --approve-clean remains a hidden compatibility alias. A stricter
715
+ // threshold narrows the ready lane; a lower one explicitly reaches into
716
+ // needs_score. Typed-count confirmation, no bypass. ─────────────────────
717
+ if (flags['approve-ready'] || flags['approve-clean']) {
718
+ if (flags['approve-clean']) {
719
+ console.log('Note: --approve-clean was renamed to --approve-ready; the old flag remains a compatibility alias.');
720
+ }
646
721
  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).`);
722
+ const sel = review.selectForBulkApprove(rows, { mode: 'ready', minQuality });
723
+ console.log(`approve-ready selection: ${sel.selected.length} of ${rows.length} pending (threshold: quality >= ${minQuality}).`);
724
+ if (sel.excluded_private.length) {
725
+ console.log(` excluded ${sel.excluded_private.length} private-destined item(s); use [p]/--keep-private to keep them owner-only, or sanitize-promote them for public review.`);
726
+ }
727
+ if (minQuality < review.DEFAULT_QUALITY_THRESHOLD) {
728
+ const approvableCount = Number.isFinite(summary.approvable_count)
729
+ ? summary.approvable_count
730
+ : review.selectForBulkApprove(rows, { mode: 'ready' }).selected.length;
731
+ console.log(`WARNING: selection goes beyond the server's approvable verdict (approvable_count=${approvableCount}); including ${sel.included_beyond_verdict.length} items from needs_score.`);
732
+ }
733
+ if (sel.excluded_flagged.length) console.log(` excluded ${sel.excluded_flagged.length} needs_your_eyes item(s) (review those individually).`);
650
734
  if (sel.excluded_low_quality.length) console.log(` excluded ${sel.excluded_low_quality.length} below the quality threshold.`);
651
735
  if (sel.excluded_unscored.length) console.log(` excluded ${sel.excluded_unscored.length} with no quality score (pass --min-quality 0 to include).`);
652
736
  if (sel.selected.length === 0) { console.log('Nothing qualifies. No changes made.'); return; }
653
737
 
654
738
  if (!await confirmByTypedCount(sel.selected, 'APPROVE and PUBLISH')) return;
655
739
  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.`);
740
+ console.log(`\nDone. Approved ${totals.approved} (already approved earlier: ${totals.idempotent}, failed: ${totals.failed}). Items outside the selection stay pending.`);
657
741
  return;
658
742
  }
659
743
 
660
- // ── --all: bulk-approve everything EXCEPT screen-flagged items unless
744
+ // ── --all: bulk-approve everything EXCEPT needs_your_eyes items unless
661
745
  // --include-flagged. Typed-count confirmation, no bypass. ───────────────
662
746
  if (flags.all) {
663
747
  const includeFlagged = flags['include-flagged'] === true;
664
748
  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.');
749
+ if (sel.excluded_private.length) {
750
+ console.log(`Excluded ${sel.excluded_private.length} private-destined item(s); use [p]/--keep-private to keep them owner-only, or sanitize-promote them for public review.`);
751
+ }
752
+ if (includeFlagged && sel.selected.some((r) => review.reviewLane(r).lane === 'needs_your_eyes')) {
753
+ console.log('WARNING: --include-flagged is set. This selection INCLUDES needs_your_eyes items (possible injection, sensitive content, process advice, account vocabulary, or near-duplicates). Approving publishes them publicly.');
667
754
  } 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).`);
755
+ console.log(`Excluding ${sel.excluded_flagged.length} needs_your_eyes item(s) (pass --include-flagged to include them; safer to review those individually).`);
669
756
  }
670
757
  if (sel.selected.length === 0) { console.log('Nothing qualifies. No changes made.'); return; }
671
758
 
@@ -675,6 +762,30 @@ async function cmdReview(flags) {
675
762
  return;
676
763
  }
677
764
 
765
+ // ── --keep-private: finalize one explicit server lane as owner-only.
766
+ // Defaults to needs_your_eyes. Because this never publishes, --yes may
767
+ // bypass its counted confirmation; all approve paths retain the rail. ───
768
+ if (flags['keep-private']) {
769
+ let sel;
770
+ try {
771
+ sel = review.selectForKeepPrivate(rows, { lane: flags.lane });
772
+ } catch (err) {
773
+ console.error(err.message);
774
+ process.exit(1);
775
+ }
776
+ console.log(`keep-private selection: ${sel.selected.length} of ${rows.length} pending (lane: ${sel.lane}).`);
777
+ for (const lane of ['ready_to_publish', 'needs_score', 'needs_your_eyes']) {
778
+ const excluded = sel.excluded_by_lane[lane];
779
+ if (excluded.length) console.log(` excluded ${excluded.length} ${lane} item(s).`);
780
+ }
781
+ if (sel.selected.length === 0) { console.log('Nothing qualifies. No changes made.'); return; }
782
+ const ok = flags.yes || await confirmByTypedCount(sel.selected, 'KEEP PRIVATE (owner-only, $0 recall)');
783
+ if (!ok) return;
784
+ const totals = await runBulk({ apiKey, baseUrl, rows: sel.selected, decision: 'keep_private' });
785
+ console.log(`\nDone. Kept private ${totals.kept_private} of ${sel.selected.length} (already done earlier: ${totals.idempotent}, failed: ${totals.failed}).`);
786
+ return;
787
+ }
788
+
678
789
  // ── --all-reject: bulk reject the whole batch (incident escape hatch).
679
790
  // Now batched through the bulk endpoint. --yes keeps its scripted-incident
680
791
  // bypass because rejection is the SAFE direction (nothing goes public). ──
@@ -704,16 +815,17 @@ async function cmdReview(flags) {
704
815
  process.exit(1);
705
816
  }
706
817
 
707
- console.log('Rapid review. Per item: [y] approve (goes live) · [n] reject (stays private) · [v] view full body · [s] skip · [q] quit\n');
818
+ console.log('Rapid review. Per item: [y] approve (goes public) · [n] reject · [p] keep private (owner-only, $0 recall) · [v] view · [s] skip · [q] quit\n');
708
819
 
709
- let approved = 0, rejected = 0, skipped = 0;
710
- const ordered = rows; // clean-first, quality desc (server order preserved by printSummaryTable groups)
820
+ let approved = 0, keptPrivate = 0, rejected = 0, skipped = 0;
821
+ const ordered = rows; // quality-desc server order; the summary above groups lanes
711
822
  for (let i = 0; i < ordered.length; i++) {
712
823
  const row = ordered[i];
713
824
  console.log(triageLine(row, i + 1, ordered.length));
714
825
  let choice = '';
715
826
  for (;;) {
716
- choice = (await ask(' [y/n/v/s/q]? ')).toLowerCase().slice(0, 1);
827
+ choice = (await ask(' [y/n/p/v/s/q]? ')).toLowerCase().slice(0, 1);
828
+ if (!choice && readlineEnded) choice = 'q';
717
829
  if (choice === 'v') {
718
830
  const full = bodies.get(row.id);
719
831
  console.log(' ------------------------------------------------------------');
@@ -725,7 +837,7 @@ async function cmdReview(flags) {
725
837
  console.log(' ------------------------------------------------------------');
726
838
  continue;
727
839
  }
728
- if (['y', 'n', 's', 'q'].includes(choice)) break;
840
+ if (['y', 'n', 'p', 's', 'q'].includes(choice)) break;
729
841
  }
730
842
  if (choice === 'q') { console.log(' Stopping. Remaining items left pending.'); break; }
731
843
  if (choice === 's') { skipped += 1; continue; }
@@ -733,6 +845,9 @@ async function cmdReview(flags) {
733
845
  if (choice === 'y') {
734
846
  await review.submitDecision({ apiKey, baseUrl, id: row.id, decision: 'approve' });
735
847
  approved += 1; console.log(' ✓ approved, now live');
848
+ } else if (choice === 'p') {
849
+ await review.submitDecision({ apiKey, baseUrl, id: row.id, decision: 'keep_private' });
850
+ keptPrivate += 1; console.log(' ✓ kept private (owner-only)');
736
851
  } else {
737
852
  await review.submitDecision({ apiKey, baseUrl, id: row.id, decision: 'reject' });
738
853
  rejected += 1; console.log(' ✗ rejected, stays private');
@@ -742,12 +857,57 @@ async function cmdReview(flags) {
742
857
  }
743
858
  }
744
859
 
745
- console.log(`\nReview complete: approved ${approved}, rejected ${rejected}, skipped ${skipped} of ${ordered.length}.`);
860
+ console.log(`\nReview complete: approved ${approved}, kept private ${keptPrivate}, rejected ${rejected}, skipped ${skipped} of ${ordered.length}.`);
746
861
  }
747
862
 
748
863
  // ─── Entry point ────────────────────────────────────────────────────────────
749
864
 
750
- function usage() {
865
+ function usage(command) {
866
+ const blocks = {
867
+ setup: `Usage: auxilo setup [--re-auth] [--base-url <url>]
868
+
869
+ Interactively detect clients, register Auxilo, sign in, install the optional
870
+ extraction runner and SessionEnd hook, and record the extraction choice.`,
871
+ init: `Usage: auxilo init [--scope <read|earnings-read|contribute>] [--label <name>]
872
+ [--env-file <path>] [--save] [--json] [--no-browser]
873
+ [--base-url <url>]
874
+
875
+ Mint a scoped API key for CI or a second machine without running full setup.`,
876
+ status: `Usage: auxilo status
877
+
878
+ Show detected clients, auth, extraction mode, kill switch, SessionEnd hook,
879
+ last sweep, and pending queue depth.`,
880
+ review: `Usage: auxilo review [flags]
881
+
882
+ Render YOUR pending-review learnings in three server-defined lanes:
883
+ Ready to publish, Needs a score, and Needs your eyes.
884
+
885
+ Flags:
886
+ --list show the three-lane summary only; make no changes
887
+ --approve-ready select the server's ready_to_publish lane (default quality
888
+ floor 14; a higher --min-quality narrows it, while a lower
889
+ value explicitly reaches into needs_score)
890
+ --min-quality N quality threshold for --approve-ready (0-20)
891
+ --all approve everything except needs_your_eyes items
892
+ --include-flagged include needs_your_eyes with --all
893
+ --keep-private keep one lane owner-only (default: needs_your_eyes)
894
+ --lane <lane> lane for --keep-private: ready_to_publish, needs_score,
895
+ or needs_your_eyes
896
+ --all-reject reject the whole batch [--yes for scripted incident use]
897
+ --yes bypass count only for --keep-private or --all-reject
898
+ --base-url <url>
899
+
900
+ Every bulk path prints the exact list and requires typing its count. Approval
901
+ never accepts --yes. Private-destined rows are excluded from approval; keep
902
+ them private or sanitize-promote a corrected replacement.`,
903
+ disable: `Usage: auxilo disable [--base-url <url>]
904
+
905
+ Disable background extraction locally and optionally revoke server consent.`,
906
+ };
907
+ if (command && blocks[command]) {
908
+ console.log(`\n${blocks[command]}\n`);
909
+ return;
910
+ }
751
911
  console.log(`
752
912
  Usage: auxilo <command>
753
913
 
@@ -771,18 +931,19 @@ Commands:
771
931
  status Show install/auth/extraction status.
772
932
  review Review YOUR pending-review learnings (from background extraction)
773
933
  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.
934
+ summary first in the server's Ready to publish / Needs a score /
935
+ Needs your eyes lanes, then rapid y/n/s review.
776
936
  Flags:
777
937
  --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).
938
+ --approve-ready select the server's ready_to_publish lane
939
+ (default floor 14; tune with --min-quality N).
940
+ Prints the exact list, then requires its count.
941
+ --all bulk-approve everything EXCEPT needs_your_eyes
942
+ (add --include-flagged to include that lane).
784
943
  Same typed-count confirmation.
785
- --min-quality N quality threshold for --approve-clean (0-20)
944
+ --min-quality N quality threshold for --approve-ready (0-20)
945
+ --keep-private keep needs_your_eyes owner-only by default
946
+ --lane <lane> choose a server lane for --keep-private
786
947
  --all-reject reject the whole batch [--yes for scripted
787
948
  incident response; rejects stay private]
788
949
  --base-url <url>
@@ -797,6 +958,10 @@ Docs: https://auxilo.io · API: ${installer.DEFAULT_BASE_URL}
797
958
 
798
959
  async function main() {
799
960
  const cmd = process.argv[2];
961
+ const subcommandHelp = ['help', '--help', '-h'].includes(process.argv[3]);
962
+ if (['setup', 'init', 'status', 'review', 'disable'].includes(cmd) && subcommandHelp) {
963
+ return usage(cmd);
964
+ }
800
965
  const flags = parseFlags(process.argv);
801
966
  switch (cmd) {
802
967
  case 'setup': return cmdSetup(flags);
@@ -829,4 +994,12 @@ if (require.main === module) {
829
994
  run();
830
995
  }
831
996
 
832
- module.exports = { parseFlags, resolveBaseUrl, run };
997
+ module.exports = {
998
+ parseFlags,
999
+ resolveBaseUrl,
1000
+ shortFlags,
1001
+ groupSummaryRows,
1002
+ printSummaryTable,
1003
+ usage,
1004
+ run,
1005
+ };
@@ -405,6 +405,7 @@ function filterIndexedNearDuplicates(candidates, indexState, opts = {}) {
405
405
  if (!indexState || !indexState.usable) {
406
406
  return { kept: input.slice(), dropped: [], disabled: true };
407
407
  }
408
+ const comparisonAccountId = opts.contributorAccountId || '__local_index_owner';
408
409
  const localRows = indexState.rows
409
410
  .filter((row) => typeof row.body === 'string' && row.body.trim())
410
411
  .map((row, index) => ({
@@ -413,6 +414,8 @@ function filterIndexedNearDuplicates(candidates, indexState, opts = {}) {
413
414
  body: row.body,
414
415
  category: row.category,
415
416
  status: row.status || null,
417
+ visibility: row.visibility,
418
+ contributor_account_id: row.contributor_account_id || comparisonAccountId,
416
419
  }));
417
420
  if (!localRows.length) return { kept: input.slice(), dropped: [], disabled: false };
418
421
 
@@ -420,7 +423,9 @@ function filterIndexedNearDuplicates(candidates, indexState, opts = {}) {
420
423
  const kept = [];
421
424
  const dropped = [];
422
425
  for (const candidate of input) {
423
- const result = findNearDuplicate(candidate, localRows);
426
+ const result = findNearDuplicate(candidate, localRows, {
427
+ contributorAccountId: comparisonAccountId,
428
+ });
424
429
  if (result.verdict === 'flag') {
425
430
  const matchedRow = localRows.find((row) => row.id === result.match.id);
426
431
  dropped.push({
@@ -0,0 +1,24 @@
1
+ 'use strict';
2
+
3
+ /**
4
+ * True when a Claude Code SessionEnd collection contains an Auxilo extraction
5
+ * hook. Accepts both the legacy bare-command string and the current matcher
6
+ * group shape ({ hooks: [{ type: 'command', command }] }).
7
+ *
8
+ * @param {unknown} sessionEnd
9
+ * @returns {boolean}
10
+ */
11
+ function hasAuxiloSessionEndHook(sessionEnd) {
12
+ if (!Array.isArray(sessionEnd)) return false;
13
+
14
+ return sessionEnd.some((entry) => {
15
+ if (typeof entry === 'string') return entry.includes('auxilo-extract');
16
+ if (!entry || typeof entry !== 'object' || !Array.isArray(entry.hooks)) return false;
17
+ return entry.hooks.some((hook) =>
18
+ hook && typeof hook === 'object' &&
19
+ typeof hook.command === 'string' &&
20
+ hook.command.includes('auxilo-extract'));
21
+ });
22
+ }
23
+
24
+ module.exports = { hasAuxiloSessionEndHook };
package/lib/installer.js CHANGED
@@ -24,6 +24,7 @@
24
24
 
25
25
  const fs = require('fs');
26
26
  const path = require('path');
27
+ const { hasAuxiloSessionEndHook } = require('./hook-status.js');
27
28
 
28
29
  // ─── Constants ──────────────────────────────────────────────────────────────
29
30
 
@@ -75,6 +76,7 @@ const RUNNER_STACK = Object.freeze([
75
76
  ['lib/sensitivity-filter.js', 'lib/sensitivity-filter.js', 0o644],
76
77
  ['lib/extraction-index.js', 'lib/extraction-index.js', 0o644],
77
78
  ['lib/similarity.js', 'lib/similarity.js', 0o644],
79
+ ['lib/hook-status.js', 'lib/hook-status.js', 0o644],
78
80
  ['config/near-duplicate.json', 'config/near-duplicate.json', 0o644],
79
81
  ]);
80
82
 
@@ -1559,16 +1561,21 @@ async function getStatus(homeDir, opts = {}) {
1559
1561
  // 4. Sentinel + runner + hook install state
1560
1562
  const hookPath = hookScriptPathFor(homeDir);
1561
1563
  const runnerInstalled = fs.existsSync(path.join(binRootFor(homeDir), 'scripts', 'runner.js'));
1564
+ let hookInstalled = false;
1562
1565
  let hookRegistered = false;
1563
1566
  try {
1564
1567
  const settings = JSON.parse(
1565
1568
  fs.readFileSync(path.join(homeDir, '.claude', 'settings.json'), 'utf-8')
1566
1569
  );
1570
+ const sessionEnd = settings.hooks && settings.hooks.SessionEnd;
1571
+ // SPEC3-A3: installation detection accepts both the legacy string and the
1572
+ // current object shape through the same helper runner --status uses.
1573
+ hookInstalled = hasAuxiloSessionEndHook(sessionEnd);
1567
1574
  // LW-17: only STRUCTURED entries count — Claude Code silently ignores
1568
1575
  // bare-string entries (the 0.8.1 dead-hook bug), so reporting one as
1569
1576
  // "registered" would mask exactly the failure this status exists to catch.
1570
- hookRegistered = Array.isArray(settings.hooks && settings.hooks.SessionEnd) &&
1571
- settings.hooks.SessionEnd.some((h) =>
1577
+ hookRegistered = Array.isArray(sessionEnd) &&
1578
+ sessionEnd.some((h) =>
1572
1579
  h && typeof h === 'object' && Array.isArray(h.hooks) && h.hooks.some(isAuxiloCommandHook));
1573
1580
  } catch { /* no settings */ }
1574
1581
 
@@ -1592,7 +1599,8 @@ async function getStatus(homeDir, opts = {}) {
1592
1599
  accountMode,
1593
1600
  sentinel: sentinelPresent(homeDir),
1594
1601
  runnerInstalled,
1595
- hookInstalled: fs.existsSync(hookPath),
1602
+ hookInstalled,
1603
+ hookScriptInstalled: fs.existsSync(hookPath),
1596
1604
  hookRegistered,
1597
1605
  // LW-18 layer 1b: SessionStart held-count notice
1598
1606
  noticeRegistered: sessionStartNoticeRegistered(homeDir),
package/lib/review.js CHANGED
@@ -59,12 +59,12 @@ async function fetchPending(opts = {}) {
59
59
  }
60
60
 
61
61
  /**
62
- * POST /account/pending/:id/approve|reject — submit a self-review decision.
62
+ * POST /account/pending/:id/approve|reject|keep-private — submit a self-review decision.
63
63
  *
64
64
  * @param {object} opts
65
65
  * @param {string} opts.apiKey
66
66
  * @param {string} opts.id
67
- * @param {'approve'|'reject'} opts.decision
67
+ * @param {'approve'|'reject'|'keep_private'} opts.decision
68
68
  * @param {string} [opts.reason] only sent on reject
69
69
  * @param {string} [opts.baseUrl]
70
70
  * @param {Function} [opts.fetchImpl]
@@ -74,8 +74,8 @@ async function submitDecision(opts = {}) {
74
74
  const { apiKey, id, decision, reason } = opts;
75
75
  if (!apiKey) throw new Error('submitDecision: apiKey is required');
76
76
  if (!id) throw new Error('submitDecision: id is required');
77
- if (decision !== 'approve' && decision !== 'reject') {
78
- throw new Error('submitDecision: decision must be "approve" or "reject"');
77
+ if (decision !== 'approve' && decision !== 'reject' && decision !== 'keep_private') {
78
+ throw new Error('submitDecision: decision must be "approve", "reject", or "keep_private"');
79
79
  }
80
80
  const baseUrl = normBase(opts.baseUrl);
81
81
  const fetchImpl = opts.fetchImpl || fetch;
@@ -89,10 +89,14 @@ async function submitDecision(opts = {}) {
89
89
  init.body = JSON.stringify(reason ? { reason } : {});
90
90
  }
91
91
 
92
- const res = await fetchImpl(`${baseUrl}/account/pending/${encodeURIComponent(id)}/${decision}`, init);
92
+ const routeDecision = decision === 'keep_private' ? 'keep-private' : decision;
93
+ const res = await fetchImpl(`${baseUrl}/account/pending/${encodeURIComponent(id)}/${routeDecision}`, init);
93
94
  let body = {};
94
95
  try { body = await res.json(); } catch { /* non-JSON error body */ }
95
96
  if (!res.ok) {
97
+ if (decision === 'keep_private' && res.status === 400 && body.code === 'bad_decision') {
98
+ throw new Error('server predates the private tier — update the server');
99
+ }
96
100
  throw new Error(`${decision} failed for ${id} (HTTP ${res.status}): ${body.error || 'unknown error'}`);
97
101
  }
98
102
  return body;
@@ -121,7 +125,7 @@ function formatFlags(l) {
121
125
 
122
126
  // ── Review-seamless additions (2026-07-18) ──────────────────────────────────
123
127
  //
124
- // Summary + bulk network calls, plus the pure approve-clean selection logic
128
+ // Summary + bulk network calls, plus the pure approve-ready selection logic
125
129
  // shared by the CLI, the MCP auxilo_review tool, and the triage report script.
126
130
  // One selection implementation everywhere, so what a dry run PRINTS is exactly
127
131
  // what a confirmed run DOES.
@@ -129,8 +133,12 @@ function formatFlags(l) {
129
133
  /** Server-enforced max decisions per bulk call; clients chunk at this size. */
130
134
  const BULK_CHUNK = 100;
131
135
 
132
- /** Default approve-clean quality threshold (repo quality gate: total >= 14/20). */
136
+ /** Default ready-to-publish quality threshold (repo quality gate: total >= 14/20). */
133
137
  const DEFAULT_QUALITY_THRESHOLD = 14;
138
+ const LANE_READY = 'ready_to_publish';
139
+ const LANE_NEEDS_SCORE = 'needs_score';
140
+ const LANE_NEEDS_EYES = 'needs_your_eyes';
141
+ const REVIEW_LANES = new Set([LANE_READY, LANE_NEEDS_SCORE, LANE_NEEDS_EYES]);
134
142
 
135
143
  /**
136
144
  * GET /account/pending/summary: compact triage rows + counts (no bodies).
@@ -206,15 +214,16 @@ function chunkDecisions(list, size = BULK_CHUNK) {
206
214
  * stay applied; the endpoint is idempotent per id, so re-running is safe).
207
215
  *
208
216
  * @param {object} opts { apiKey, decisions, baseUrl?, fetchImpl?, onChunk? }
209
- * @returns {Promise<{approved:number, rejected:number, idempotent:number, failed:number, results:Array<object>}>}
217
+ * @returns {Promise<{approved:number, kept_private:number, rejected:number, idempotent:number, failed:number, results:Array<object>}>}
210
218
  */
211
219
  async function submitBulkChunked(opts = {}) {
212
220
  const { decisions } = opts;
213
- const totals = { approved: 0, rejected: 0, idempotent: 0, failed: 0, results: [] };
221
+ const totals = { approved: 0, kept_private: 0, rejected: 0, idempotent: 0, failed: 0, results: [] };
214
222
  const chunks = chunkDecisions(decisions || []);
215
223
  for (let i = 0; i < chunks.length; i++) {
216
224
  const resp = await submitBulk({ ...opts, decisions: chunks[i] });
217
225
  totals.approved += resp.approved || 0;
226
+ totals.kept_private += resp.kept_private || 0;
218
227
  totals.rejected += resp.rejected || 0;
219
228
  totals.idempotent += resp.idempotent || 0;
220
229
  totals.failed += resp.failed || 0;
@@ -233,46 +242,127 @@ function qualityClears(row, minQuality) {
233
242
  }
234
243
 
235
244
  /**
236
- * THE approve-clean selection. Operates on summary rows (screens_passed,
237
- * flags, quality) from GET /account/pending/summary.
245
+ * Resolve the server lane on a summary row. Old servers did not return lane;
246
+ * those rows fall back to the legacy screens+quality split and are marked as
247
+ * version-skewed so the caller can tell the operator.
238
248
  *
239
- * mode 'clean' (approve-clean): screens_passed AND quality >= minQuality.
240
- * mode 'all': every row EXCEPT screens-flagged ones unless includeFlagged
241
- * is set; no quality gate.
249
+ * @param {object} row
250
+ * @returns {{lane:string, version_skew:boolean}}
251
+ */
252
+ function reviewLane(row) {
253
+ if (row && REVIEW_LANES.has(row.lane)) {
254
+ return { lane: row.lane, version_skew: false };
255
+ }
256
+ if (!row || !row.screens_passed) {
257
+ return { lane: LANE_NEEDS_EYES, version_skew: true };
258
+ }
259
+ return {
260
+ lane: qualityClears(row, DEFAULT_QUALITY_THRESHOLD) ? LANE_READY : LANE_NEEDS_SCORE,
261
+ version_skew: true,
262
+ };
263
+ }
264
+
265
+ /**
266
+ * THE approve-ready selection. The server's lane is authoritative:
267
+ *
268
+ * - threshold 14 (default): exactly rows in ready_to_publish
269
+ * - threshold >14: narrows within ready_to_publish
270
+ * - threshold <14: includes qualifying needs_score rows too
271
+ * - needs_your_eyes is excluded unless mode=all + includeFlagged
242
272
  *
243
273
  * Returns the selection plus what was excluded and why, so every confirmation
244
274
  * surface can show the operator the exact consequences before anything runs.
245
275
  *
246
276
  * @param {Array<object>} rows
247
- * @param {object} [opts] { mode: 'clean'|'all', minQuality, includeFlagged }
248
- * @returns {{selected:Array<object>, excluded_flagged:Array<object>, excluded_low_quality:Array<object>, excluded_unscored:Array<object>, min_quality:number|null}}
277
+ * @param {object} [opts] { mode: 'ready'|'clean'|'all', minQuality, includeFlagged }
278
+ * @returns {{selected:Array<object>, included_beyond_verdict:Array<object>, excluded_private:Array<object>, excluded_flagged:Array<object>, excluded_low_quality:Array<object>, excluded_unscored:Array<object>, min_quality:number|null, version_skew:boolean}}
249
279
  */
250
280
  function selectForBulkApprove(rows, opts = {}) {
251
- const mode = opts.mode === 'all' ? 'all' : 'clean';
252
- const minQuality = mode === 'clean'
281
+ const mode = opts.mode === 'all' ? 'all' : 'ready';
282
+ const minQuality = mode === 'ready'
253
283
  ? (Number.isFinite(opts.minQuality) ? opts.minQuality : DEFAULT_QUALITY_THRESHOLD)
254
284
  : null;
255
285
  const includeFlagged = mode === 'all' && opts.includeFlagged === true;
256
286
 
257
287
  const selected = [];
288
+ const included_beyond_verdict = [];
289
+ const excluded_private = [];
258
290
  const excluded_flagged = [];
259
291
  const excluded_low_quality = [];
260
292
  const excluded_unscored = [];
293
+ let version_skew = false;
261
294
 
262
295
  for (const row of rows || []) {
263
296
  if (!row || !row.id) continue;
264
- if (!row.screens_passed && !includeFlagged) {
265
- excluded_flagged.push(row);
297
+ if (row.visibility === 'private') {
298
+ excluded_private.push(row);
266
299
  continue;
267
300
  }
268
- if (mode === 'clean' && !qualityClears(row, minQuality)) {
269
- (row.quality == null ? excluded_unscored : excluded_low_quality).push(row);
301
+ const resolved = reviewLane(row);
302
+ version_skew = version_skew || resolved.version_skew;
303
+
304
+ if (resolved.lane === LANE_NEEDS_EYES && !includeFlagged) {
305
+ excluded_flagged.push(row);
270
306
  continue;
271
307
  }
308
+
309
+ if (mode === 'ready') {
310
+ if (resolved.lane === LANE_READY) {
311
+ // At the server floor, consume the verdict verbatim. A stricter
312
+ // operator threshold may narrow it, but never broaden it.
313
+ if (minQuality > DEFAULT_QUALITY_THRESHOLD && !qualityClears(row, minQuality)) {
314
+ (row.quality == null ? excluded_unscored : excluded_low_quality).push(row);
315
+ continue;
316
+ }
317
+ } else if (resolved.lane === LANE_NEEDS_SCORE) {
318
+ if (minQuality < DEFAULT_QUALITY_THRESHOLD && qualityClears(row, minQuality)) {
319
+ included_beyond_verdict.push(row);
320
+ } else {
321
+ (row.quality == null ? excluded_unscored : excluded_low_quality).push(row);
322
+ continue;
323
+ }
324
+ }
325
+ }
326
+
272
327
  selected.push(row);
273
328
  }
274
329
 
275
- return { selected, excluded_flagged, excluded_low_quality, excluded_unscored, min_quality: minQuality };
330
+ return {
331
+ selected,
332
+ included_beyond_verdict,
333
+ excluded_private,
334
+ excluded_flagged,
335
+ excluded_low_quality,
336
+ excluded_unscored,
337
+ min_quality: minQuality,
338
+ version_skew,
339
+ };
340
+ }
341
+
342
+ /**
343
+ * Select one server lane to finalize as owner-only private. The default is
344
+ * needs_your_eyes; another lane requires an explicit --lane value.
345
+ */
346
+ function selectForKeepPrivate(rows, opts = {}) {
347
+ const lane = opts.lane || LANE_NEEDS_EYES;
348
+ if (!REVIEW_LANES.has(lane)) {
349
+ throw new Error(`Invalid --lane "${lane}" (expected ${Array.from(REVIEW_LANES).join(', ')})`);
350
+ }
351
+ const selected = [];
352
+ const excluded_by_lane = {
353
+ [LANE_READY]: [],
354
+ [LANE_NEEDS_SCORE]: [],
355
+ [LANE_NEEDS_EYES]: [],
356
+ };
357
+ let version_skew = false;
358
+ for (const row of rows || []) {
359
+ if (!row || !row.id) continue;
360
+ const resolved = reviewLane(row);
361
+ version_skew = version_skew || resolved.version_skew;
362
+ if (resolved.lane === lane) selected.push(row);
363
+ else excluded_by_lane[resolved.lane].push(row);
364
+ }
365
+ return { selected, excluded_by_lane, lane, version_skew };
276
366
  }
277
367
 
278
368
  module.exports = {
@@ -287,5 +377,7 @@ module.exports = {
287
377
  chunkDecisions,
288
378
  submitBulkChunked,
289
379
  qualityClears,
380
+ reviewLane,
290
381
  selectForBulkApprove,
382
+ selectForKeepPrivate,
291
383
  };
package/lib/similarity.js CHANGED
@@ -204,13 +204,39 @@ function similarityScore(a, b) {
204
204
  }
205
205
 
206
206
  /**
207
- * Scan the full catalog for a near-verbatim match of `candidate`.
208
- * Rejected and cross-category predecessors are intentionally included.
207
+ * SPEC3-G1 comparison-set doctrine. Public predecessors collide for every
208
+ * caller; any non-public predecessor (pending, rejected, retracted, or
209
+ * approved-private) is visible to duplicate screening only for its owner.
210
+ * Missing visibility is the zero-migration public default.
211
+ */
212
+ function isPublicComparisonPredecessor(learning) {
213
+ return !!learning &&
214
+ (!learning.status || learning.status === 'approved') &&
215
+ learning.visibility !== 'private';
216
+ }
217
+
218
+ function isComparisonEligible(learning, contributorAccountId) {
219
+ if (isPublicComparisonPredecessor(learning)) return true;
220
+ return !!contributorAccountId &&
221
+ learning &&
222
+ learning.contributor_account_id === contributorAccountId;
223
+ }
224
+
225
+ function comparisonCatalog(catalog, contributorAccountId) {
226
+ return (catalog || []).filter((learning) =>
227
+ isComparisonEligible(learning, contributorAccountId));
228
+ }
229
+
230
+ /**
231
+ * Scan the caller-safe comparison catalog for a near-verbatim match.
232
+ * Public predecessors compare for everyone. Non-public predecessors compare
233
+ * only for their contributor account.
209
234
  *
210
235
  * @param {{title:string, body:string, category:string}} candidate
211
236
  * @param {Array<object>} catalog existing learnings
212
237
  * @param {{flagThreshold?:number, tfCosineThreshold?:number,
213
- * compositeThreshold?:number, excludeId?:string}} [opts]
238
+ * compositeThreshold?:number, excludeId?:string,
239
+ * contributorAccountId?:string}} [opts]
214
240
  * @returns {{verdict:'flag'|'clean', match: null|object}}
215
241
  */
216
242
  function findNearDuplicate(candidate, catalog, opts = {}) {
@@ -223,6 +249,7 @@ function findNearDuplicate(candidate, catalog, opts = {}) {
223
249
  for (const existing of catalog || []) {
224
250
  if (!existing) continue;
225
251
  if (opts.excludeId && existing.id === opts.excludeId) continue;
252
+ if (!isComparisonEligible(existing, opts.contributorAccountId)) continue;
226
253
 
227
254
  const channels = scorePrepared(candidatePrepared, prepareLearning(existing));
228
255
  const shingleFlagged = channels.d1 >= flagAt;
@@ -232,6 +259,7 @@ function findNearDuplicate(candidate, catalog, opts = {}) {
232
259
  best = {
233
260
  id: existing.id,
234
261
  status: existing.status || null,
262
+ visibility: existing.visibility === 'private' ? 'private' : 'public',
235
263
  category: existing.category || null,
236
264
  similarity: channels.composite,
237
265
  channel: shingleFlagged ? 'shingle' : 'near_verbatim',
@@ -251,6 +279,9 @@ function findNearDuplicate(candidate, catalog, opts = {}) {
251
279
  module.exports = {
252
280
  similarityScore,
253
281
  findNearDuplicate,
282
+ isPublicComparisonPredecessor,
283
+ isComparisonEligible,
284
+ comparisonCatalog,
254
285
  tokenize,
255
286
  tokenSet,
256
287
  jaccard,
package/mcp-server.js CHANGED
@@ -134,10 +134,46 @@ function planApproveClean(summary, opts = {}) {
134
134
  };
135
135
  }
136
136
 
137
+ // Private-tier counterpart to planApproveClean. Selection is delegated to the
138
+ // same lib/review.js helper as the CLI; the default is needs_your_eyes.
139
+ function planKeepPrivate(summary, opts = {}) {
140
+ const sel = reviewLib.selectForKeepPrivate((summary && summary.items) || [], {
141
+ lane: opts.lane,
142
+ });
143
+ const brief = (r) => ({
144
+ id: r.id,
145
+ title: r.title,
146
+ category: r.category,
147
+ quality: r.quality,
148
+ visibility: r.visibility,
149
+ lane: reviewLib.reviewLane(r).lane,
150
+ });
151
+ return {
152
+ dry_run: true,
153
+ lane: sel.lane,
154
+ pending_count: summary ? summary.pending_count : 0,
155
+ would_keep_private_count: sel.selected.length,
156
+ would_keep_private: sel.selected.map(brief),
157
+ excluded_by_lane: Object.fromEntries(
158
+ Object.entries(sel.excluded_by_lane).map(([lane, rows]) => [lane, rows.map(brief)])
159
+ ),
160
+ next_step: sel.selected.length > 0
161
+ ? `Show the operator this list and count (${sel.selected.length}). After confirmation, call auxilo_review again with {action:"keep_private", dry_run:false, confirm:true, expected_count:${sel.selected.length}}. Each item stays yours; owner-only recall at $0; never published.`
162
+ : 'Nothing is in the selected lane. No follow-up call needed.',
163
+ };
164
+ }
165
+
166
+ function keepPrivateDecisions(plan) {
167
+ return (plan && plan.would_keep_private || []).map((row) => ({
168
+ id: row.id,
169
+ decision: 'keep_private',
170
+ }));
171
+ }
172
+
137
173
  // Review-seamless: POST one confirmed decision batch through the counted bulk
138
174
  // endpoint, chunked at the server cap. Used by approve/reject/approve_clean.
139
175
  async function postBulkChunks(headers, decisions) {
140
- const totals = { approved: 0, rejected: 0, idempotent: 0, failed: 0, results: [] };
176
+ const totals = { approved: 0, kept_private: 0, rejected: 0, idempotent: 0, failed: 0, results: [] };
141
177
  for (const chunk of reviewLib.chunkDecisions(decisions)) {
142
178
  const resp = await fetch(`${AUXILO_BASE}/account/pending/bulk`, {
143
179
  method: 'POST',
@@ -151,6 +187,7 @@ async function postBulkChunks(headers, decisions) {
151
187
  return totals;
152
188
  }
153
189
  totals.approved += data.approved || 0;
190
+ totals.kept_private += data.kept_private || 0;
154
191
  totals.rejected += data.rejected || 0;
155
192
  totals.idempotent += data.idempotent || 0;
156
193
  totals.failed += data.failed || 0;
@@ -160,7 +197,7 @@ async function postBulkChunks(headers, decisions) {
160
197
  }
161
198
 
162
199
  const server = new Server(
163
- { name: 'auxilo', version: '0.9.4' },
200
+ { name: 'auxilo', version: '0.9.8' },
164
201
  {
165
202
  capabilities: { tools: {} },
166
203
  instructions: `You are connected to Auxilo, a knowledge marketplace where AI agents buy and sell operational learnings.
@@ -394,21 +431,22 @@ server.setRequestHandler(ListToolsRequestSchema, async () => ({
394
431
  },
395
432
  {
396
433
  name: 'auxilo_review',
397
- description: 'Review YOUR OWN pending-review learnings (from background extraction) so they can be approved to the public marketplace or rejected to stay private. Account-scoped: only the authenticated account\'s own pending items are ever visible or affected. ACTIONS: "list" returns the triage summary (counts incl. by_signal + compact rows with quality score, lane, a one-sentence why for flagged items, and platform screen verdicts: injection, content sensitivity, near-duplicate). "approve" / "reject" apply explicit decisions to the ids you pass (the operator must have named or confirmed these items). "approve_clean" selects every item that passed ALL platform screens AND has quality >= min_quality (default 14/20); it is DRY-RUN BY DEFAULT and returns exactly what WOULD be approved. "reject_by_signal" bulk-rejects every pending item carrying one flag signal (e.g. social_handle) — REJECT ONLY (items stay private; there is deliberately no bulk approve by class): the operator must confirm the signal AND its by_signal count from "list", and you pass that count as expected_count — the server refuses if the live selection differs. "sanitize" resubmits ONE operator-corrected item through EVERY screen with lineage (the original is retired to private, reason sanitize-resubmit; the replacement is ALWAYS held for the operator\'s explicit approval — never auto-published): only call it with a correction the operator reviewed. CONSENT CONTRACT: nothing goes public without the contributor\'s explicit approval. So before executing approve_clean you MUST show the operator the dry-run list and count and get their confirmation, then call again with dry_run:false, confirm:true, and expected_count set to the dry-run count. The server also enforces a counted-confirmation gate on every bulk call. Requires your configured API key (or session_token).',
434
+ description: 'Review YOUR OWN pending-review learnings (from background extraction) so they can be approved to the public marketplace, rejected, or kept private. Account-scoped: only the authenticated account\'s own pending items are ever visible or affected. ACTIONS: "list" returns the triage summary (counts incl. by_signal + compact rows with quality score, lane, a one-sentence why for flagged items, and platform screen verdicts: injection, content sensitivity, near-duplicate). "approve" / "reject" apply explicit decisions to the ids you pass (the operator must have named or confirmed these items). "keep_private" finalizes one id or the selected lane (needs_your_eyes by default): it stays yours; owner-only recall at $0; never published. Bulk keep_private is DRY-RUN BY DEFAULT and uses the same selection helper as the CLI. "approve_clean" selects every item that passed ALL platform screens AND has quality >= min_quality (default 14/20); it is DRY-RUN BY DEFAULT and returns exactly what WOULD be approved. "reject_by_signal" bulk-rejects every pending item carrying one flag signal (e.g. social_handle) — REJECT ONLY (items stay private; there is deliberately no bulk approve by class): the operator must confirm the signal AND its by_signal count from "list", and you pass that count as expected_count — the server refuses if the live selection differs. "sanitize" resubmits ONE operator-corrected item through EVERY screen with lineage (the original is retired to private, reason sanitize-resubmit; the replacement is ALWAYS held for the operator\'s explicit approval — never auto-published): only call it with a correction the operator reviewed. CONSENT CONTRACT: nothing goes public without the contributor\'s explicit approval. Before executing approve_clean or bulk keep_private, show the operator the dry-run list and count, then call again with dry_run:false, confirm:true, and expected_count set to the dry-run count. The server also enforces a counted-confirmation gate on every bulk call. Requires your configured API key (or session_token).',
398
435
  inputSchema: {
399
436
  type: 'object',
400
437
  properties: {
401
- action: { type: 'string', enum: ['list', 'approve', 'reject', 'approve_clean', 'reject_by_signal', 'sanitize'], description: 'What to do. Start with "list".' },
438
+ action: { type: 'string', enum: ['list', 'approve', 'reject', 'keep_private', 'approve_clean', 'reject_by_signal', 'sanitize'], description: 'What to do. Start with "list".' },
402
439
  ids: { type: 'array', items: { type: 'string' }, description: 'Learning ids for action approve/reject. These must be items the operator explicitly chose.' },
403
440
  reason: { type: 'string', description: 'Optional rejection reason (actions reject / reject_by_signal; max 500 chars).' },
404
441
  signal: { type: 'string', description: 'reject_by_signal only. The flag signal to reject by (a name from counts.by_signal, e.g. social_handle, person_name, injection).' },
405
- id: { type: 'string', description: 'sanitize only. The pending/rejected learning being corrected (operator-chosen).' },
442
+ id: { type: 'string', description: 'keep_private single-item or sanitize. The operator-chosen learning id.' },
406
443
  title: { type: 'string', description: 'sanitize only. Corrected title (omit to keep the original).' },
407
444
  body: { type: 'string', description: 'sanitize only. Corrected body (omit to keep the original). At least one of title/body is required.' },
408
445
  tags: { type: 'array', items: { type: 'string' }, description: 'sanitize only. Corrected tags (omit to keep the original).' },
409
- dry_run: { type: 'boolean', description: 'approve_clean only. Default TRUE: report what would be approved without changing anything. Set false only together with confirm:true and expected_count after the operator confirmed the dry-run list.' },
410
- confirm: { type: 'boolean', description: 'approve_clean only. Must be exactly true to execute. Never set this without the operator\'s explicit go-ahead on the dry-run output.' },
411
- expected_count: { type: 'number', description: 'approve_clean execute + reject_by_signal. The count the operator confirmed (dry-run count / by_signal count), echoed back. If the live selection differs (queue changed), nothing is mutated.' },
446
+ lane: { type: 'string', enum: ['ready_to_publish', 'needs_score', 'needs_your_eyes'], description: 'keep_private bulk only. Lane to select; defaults to needs_your_eyes.' },
447
+ dry_run: { type: 'boolean', description: 'approve_clean or bulk keep_private. Default TRUE: report exactly what would change. Set false only with confirm:true and expected_count after operator confirmation.' },
448
+ confirm: { type: 'boolean', description: 'approve_clean or bulk keep_private. Must be exactly true to execute after operator confirmation.' },
449
+ expected_count: { type: 'number', description: 'approve_clean, bulk keep_private, or reject_by_signal. The confirmed selection count. If the live selection differs, nothing is mutated.' },
412
450
  min_quality: { type: 'number', description: 'approve_clean quality threshold 0-20 (default 14). 0 includes unscored items.' },
413
451
  session_token: { type: 'string', description: 'Optional JWT session token from /auth/verify. If omitted, your configured API key authenticates the account.' },
414
452
  },
@@ -665,6 +703,58 @@ server.setRequestHandler(CallToolRequestSchema, async (request) => {
665
703
  return text({ action: args.action, submitted: decisions.length, ...totals });
666
704
  }
667
705
 
706
+ if (args.action === 'keep_private') {
707
+ // A named id is an explicit single-item decision. It uses the
708
+ // existing single route and therefore surfaces server 4xx truth.
709
+ if (typeof args.id === 'string' && args.id) {
710
+ const resp = await fetch(
711
+ `${AUXILO_BASE}/account/pending/${encodeURIComponent(args.id)}/keep-private`,
712
+ { method: 'POST', headers }
713
+ );
714
+ const data = await resp.json();
715
+ return text(data);
716
+ }
717
+
718
+ const summaryResp = await fetch(`${AUXILO_BASE}/account/pending/summary`, { headers });
719
+ const summary = await summaryResp.json();
720
+ if (!summaryResp.ok) return text(summary);
721
+
722
+ let plan;
723
+ try {
724
+ plan = planKeepPrivate(summary, args);
725
+ } catch (err) {
726
+ return text({ error: err.message });
727
+ }
728
+ if (args.dry_run !== false || args.confirm !== true) {
729
+ return text(plan);
730
+ }
731
+ if (!Number.isInteger(args.expected_count)) {
732
+ return text({
733
+ error: 'expected_count is required to execute keep_private: echo the would_keep_private_count from the dry run.',
734
+ ...plan,
735
+ });
736
+ }
737
+ if (args.expected_count !== plan.would_keep_private_count) {
738
+ return text({
739
+ error: `Selection changed since the dry run (expected ${args.expected_count}, now ${plan.would_keep_private_count}). Nothing was changed. Re-run the dry run and re-confirm.`,
740
+ ...plan,
741
+ });
742
+ }
743
+ const decisions = keepPrivateDecisions(plan);
744
+ if (decisions.length === 0) {
745
+ return text({ action: 'keep_private', kept_private: 0, message: 'Nothing is in the selected lane.' });
746
+ }
747
+ const totals = await postBulkChunks(headers, decisions);
748
+ return text({
749
+ action: 'keep_private',
750
+ executed: true,
751
+ lane: plan.lane,
752
+ submitted: decisions.length,
753
+ ...totals,
754
+ note: 'These items stay yours; owner-only recall at $0; never published.',
755
+ });
756
+ }
757
+
668
758
  if (args.action === 'approve_clean') {
669
759
  const summaryResp = await fetch(`${AUXILO_BASE}/account/pending/summary`, { headers });
670
760
  const summary = await summaryResp.json();
@@ -752,7 +842,7 @@ server.setRequestHandler(CallToolRequestSchema, async (request) => {
752
842
  return text(await resp.json());
753
843
  }
754
844
 
755
- return text({ error: `Unknown action: ${args.action}. Use list, approve, reject, approve_clean, reject_by_signal, or sanitize.` });
845
+ return text({ error: `Unknown action: ${args.action}. Use list, approve, reject, keep_private, approve_clean, reject_by_signal, or sanitize.` });
756
846
  }
757
847
 
758
848
  case 'get_knowledge_stats': {
@@ -775,7 +865,17 @@ function text(obj) {
775
865
  // LW-3(a): export the pure helpers so they can be unit-tested without starting
776
866
  // the stdio transport. When this file is required (not run directly), stop here
777
867
  // before the CLI dispatch and MCP startup below.
778
- module.exports = { fenceUnlockResult, UNTRUSTED_CONTENT_ADVISORY, baseHeaders, planApproveClean, unlockPaymentRequired, shapeWithdrawStatus, verifyWalletRequestBody };
868
+ module.exports = {
869
+ fenceUnlockResult,
870
+ UNTRUSTED_CONTENT_ADVISORY,
871
+ baseHeaders,
872
+ planApproveClean,
873
+ planKeepPrivate,
874
+ keepPrivateDecisions,
875
+ unlockPaymentRequired,
876
+ shapeWithdrawStatus,
877
+ verifyWalletRequestBody,
878
+ };
779
879
  if (require.main !== module) {
780
880
  return;
781
881
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "auxilo-mcp",
3
- "version": "0.9.6",
3
+ "version": "0.9.8",
4
4
  "mcpName": "io.github.silent-architects/auxilo",
5
5
  "description": "MCP server for Auxilo. Your agent stops solving the same problem twice: auto-extracted learnings, free self-unlocks, and earnings when other agents unlock yours.",
6
6
  "main": "mcp-server.js",
@@ -13,6 +13,7 @@
13
13
  "bin/",
14
14
  "lib/installer.js",
15
15
  "lib/review.js",
16
+ "lib/hook-status.js",
16
17
  "lib/sensitivity-filter.js",
17
18
  "lib/extraction-index.js",
18
19
  "lib/similarity.js",
@@ -32,6 +32,7 @@ const {
32
32
  // lists are duplicated here from lib/category-scope-migration.js (server truth);
33
33
  // test/ci5-scope-enforcement.test.js pins the copies equal.
34
34
  const CATEGORIES = ['data-processing', 'web-interaction', 'code-execution', 'storage-state', 'payment-financial', 'monitoring'];
35
+ const PRIVATE_CATEGORIES = [...CATEGORIES, 'non-technical'];
35
36
  const RETIRED_CATEGORIES = ['communication', 'content-generation'];
36
37
 
37
38
  /**
@@ -76,6 +77,23 @@ Output STRICT JSON ONLY — an object with:
76
77
  "matched_title": "<exact matched title>"}
77
78
  Scope/quality/sensitivity skips are not dedup_drops.`;
78
79
 
80
+ const PUBLIC_SCOPE_BLOCK = `HARD SCOPE RULE — TECHNICAL LEARNINGS ONLY (the marketplace accepts nothing else): extract ONLY technical/operational learnings — APIs, developer tools, code, infrastructure, data pipelines, monitoring/observability, payment/crypto TECHNOLOGY, debugging. NEVER extract interpersonal or communication strategy, copywriting/content/marketing insights, business or negotiation strategy, personal matters, or creative-writing technique — DROP such candidates entirely, do not relabel them. A technical learning about a messaging/email/notification API belongs under "web-interaction" or "code-execution"; content/data pipeline TECH belongs under "data-processing".
81
+
82
+ SYSTEM-FACT TEST (CI-7): Extract ONLY when a system and a symptom are at the core — an error, an undocumented limitation, a reproducible behavior of an external tool/API/OS. If the candidate is advice about how to work (process, workflow, methodology, decision practice), do NOT extract it. "Odesli cannot resolve Tidal artist URLs" is a learning; "use a two-phase consultation workflow" is not, no matter how well it would score.`;
83
+
84
+ const PRIVATE_SCOPE_BLOCK = `PRIVATE CAPTURE SCOPE — OWNER-ONLY: extract reusable technical OR non-technical operational learnings. Non-technical process, workflow, communication, content, business, or creative learnings may use category "non-technical"; do not drop a genuine reusable candidate solely because it is non-technical. This private lane is never published unless the owner later sanitizes and promotes an item through public review. The mandatory sensitivity screen still applies without exception.`;
85
+
86
+ function promptBaseForVisibility(captureVisibility) {
87
+ if (captureVisibility !== 'private') return EXTRACTION_PROMPT_BASE;
88
+ return EXTRACTION_PROMPT_BASE
89
+ .replace(
90
+ "to publish to a PUBLIC knowledge marketplace read by other AI agents.",
91
+ "for the owner's private, owner-only knowledge lane."
92
+ )
93
+ .replace(PUBLIC_SCOPE_BLOCK, PRIVATE_SCOPE_BLOCK)
94
+ .replace(JSON.stringify(CATEGORIES), JSON.stringify(PRIVATE_CATEGORIES));
95
+ }
96
+
79
97
  /** A1: rubric addendum — appended ONLY when scoreExtractionEnabled(). */
80
98
  const QUALITY_RUBRIC_ADDENDUM = `
81
99
  "quality_self_assessment": an object scoring the learning honestly on four
@@ -105,7 +123,7 @@ function buildExtractionPrompt(opts = {}) {
105
123
  const memory = typeof opts.previousLessonsSection === 'string'
106
124
  ? opts.previousLessonsSection
107
125
  : '';
108
- return EXTRACTION_PROMPT_BASE +
126
+ return promptBaseForVisibility(opts.captureVisibility) +
109
127
  (withScore ? QUALITY_RUBRIC_ADDENDUM : '') +
110
128
  (memory ? `\n\n${memory}` : '') +
111
129
  PROMPT_SUFFIX;
@@ -144,6 +162,7 @@ function extractWithClaudeCode(transcript, opts = {}) {
144
162
  ? opts.prompt
145
163
  : buildExtractionPrompt({
146
164
  previousLessonsSection: opts.previousLessonsSection,
165
+ captureVisibility: opts.captureVisibility,
147
166
  ...(opts.scoreExtraction !== undefined && { scoreExtraction: opts.scoreExtraction }),
148
167
  });
149
168
  const input = prompt + String(transcript).slice(0, 200000);
@@ -226,11 +245,15 @@ function normalizeLearningArray(arr, opts = {}) {
226
245
  // would launder a non-tech candidate (e.g. one the model labeled
227
246
  // 'communication') into the catalog wearing a tech label. Category-based,
228
247
  // so it applies identically in BOTH score-gate states.
229
- const inScope = shaped.filter(l => CATEGORIES.includes(l.category));
248
+ const allowedCategories = opts.captureVisibility === 'private' ? PRIVATE_CATEGORIES : CATEGORIES;
249
+ const inScope = shaped.filter(l => allowedCategories.includes(l.category));
230
250
  // Gate-A F5: make the drop observable — count to stderr (never stdout; the
231
251
  // hook log captures it) so a silently over-dropping prompt is diagnosable.
232
252
  const dropped = shaped.length - inScope.length;
233
- if (dropped > 0) console.error(`[extract-local] dropped ${dropped} candidate(s) outside the technical category set (CI-5 scope)`);
253
+ if (dropped > 0) {
254
+ const scope = opts.captureVisibility === 'private' ? 'private category set' : 'technical category set (CI-5 scope)';
255
+ console.error(`[extract-local] dropped ${dropped} candidate(s) outside the ${scope}`);
256
+ }
234
257
  return inScope
235
258
  .map(l => {
236
259
  const out = {
@@ -570,6 +593,7 @@ async function extractLocally(transcript, sourceType, opts = {}) {
570
593
  };
571
594
  const prompt = buildExtractionPrompt({
572
595
  previousLessonsSection: promptMemory.section,
596
+ captureVisibility: opts.captureVisibility,
573
597
  ...(opts.scoreExtraction !== undefined && { scoreExtraction: opts.scoreExtraction }),
574
598
  });
575
599
  const invokeModel = typeof opts.invokeModel === 'function'
@@ -639,7 +663,7 @@ async function extractLocally(transcript, sourceType, opts = {}) {
639
663
 
640
664
  module.exports = {
641
665
  extractLocally, parseLearnings, parseExtractionOutput, resolveClaudeBin,
642
- CATEGORIES, RETIRED_CATEGORIES,
666
+ CATEGORIES, PRIVATE_CATEGORIES, RETIRED_CATEGORIES,
643
667
  EXTRACTION_PROMPT, buildExtractionPrompt, scoreExtractionEnabled,
644
668
  validateQualityAssessment, QUALITY_DIMENSIONS,
645
669
  buildAnchoredJudgePrompt, parseJudgeDecisions, runAnchoredJudge,
package/scripts/runner.js CHANGED
@@ -39,6 +39,7 @@ const crypto = require('crypto');
39
39
  const { spawn } = require('child_process');
40
40
  const { scanText, SENSITIVITY_FILTER_VERSION } = require('../lib/sensitivity-filter.js');
41
41
  const { appendSubmittedLearning } = require('../lib/extraction-index.js');
42
+ const { hasAuxiloSessionEndHook } = require('../lib/hook-status.js');
42
43
  const { TranscriptSource } = require('./sources/source.interface.js');
43
44
  const { GenericJsonlSource } = require('./sources/generic-jsonl.js');
44
45
 
@@ -58,6 +59,13 @@ const CREDS_PATH = path.join(AUXILO_DIR, 'credentials.json');
58
59
  * 2. credentials.json .base_url
59
60
  * 3. http://localhost:49152 (dev default)
60
61
  */
62
+ function resolveCaptureVisibility(env = {}, fileCreds = {}) {
63
+ const value = Object.prototype.hasOwnProperty.call(env, 'AUXILO_CAPTURE_VISIBILITY')
64
+ ? env.AUXILO_CAPTURE_VISIBILITY
65
+ : fileCreds.capture_visibility;
66
+ return value === 'private' ? 'private' : 'public';
67
+ }
68
+
61
69
  function loadCredentials() {
62
70
  let fileCreds = {};
63
71
  try {
@@ -69,10 +77,16 @@ function loadCredentials() {
69
77
  apiKey: process.env.AUXILO_API_KEY || fileCreds.api_key || null,
70
78
  baseUrl: process.env.AUXILO_BASE_URL || fileCreds.base_url || 'http://localhost:49152',
71
79
  accountLabel: fileCreds.label || fileCreds.account_id || null,
80
+ captureVisibility: resolveCaptureVisibility(process.env, fileCreds),
72
81
  };
73
82
  }
74
83
 
75
- const { apiKey: API_KEY, baseUrl: BASE_URL, accountLabel: ACCOUNT_LABEL } = loadCredentials();
84
+ const {
85
+ apiKey: API_KEY,
86
+ baseUrl: BASE_URL,
87
+ accountLabel: ACCOUNT_LABEL,
88
+ captureVisibility: CAPTURE_VISIBILITY,
89
+ } = loadCredentials();
76
90
 
77
91
  // Parsing contract with jobs/daily-digest.js readLogRows(): digest-relevant log
78
92
  // lines must carry `account=` (builder attribution) and, on publish lines,
@@ -289,7 +303,7 @@ function listPendingFiles() {
289
303
  *
290
304
  * @param {Array<object>} learnings
291
305
  * @param {string} sourceType
292
- * @param {object} [opts] { fetchImpl, baseUrl, apiKey, indexPath, now } —
306
+ * @param {object} [opts] { fetchImpl, baseUrl, apiKey, captureVisibility, indexPath, now } —
293
307
  * injectable for tests
294
308
  * @returns {Promise<{published:number, held:number, rejected:number}>}
295
309
  */
@@ -297,6 +311,7 @@ async function submitLearnings(learnings, sourceType, opts = {}) {
297
311
  const fetchImpl = opts.fetchImpl || fetch;
298
312
  const baseUrl = opts.baseUrl || BASE_URL;
299
313
  const apiKey = opts.apiKey !== undefined ? opts.apiKey : API_KEY;
314
+ const captureVisibility = opts.captureVisibility || CAPTURE_VISIBILITY;
300
315
 
301
316
  let published = 0;
302
317
  let held = 0;
@@ -319,6 +334,7 @@ async function submitLearnings(learnings, sourceType, opts = {}) {
319
334
  outcome: l.outcome,
320
335
  contributor_agent: `auxilo-hook/${sourceType}`,
321
336
  submission_channel: 'extraction',
337
+ ...(captureVisibility === 'private' && { visibility: 'private' }),
322
338
  ...(l.quality_self_assessment && { quality_self_assessment: l.quality_self_assessment }),
323
339
  }),
324
340
  });
@@ -340,7 +356,7 @@ async function submitLearnings(learnings, sourceType, opts = {}) {
340
356
  return { published, held, rejected };
341
357
  }
342
358
 
343
- async function postExtract(transcript, sessionId, sourceType, _scrubReport) {
359
+ async function postExtract(transcript, sessionId, sourceType, _scrubReport, opts = {}) {
344
360
  // CLIENT-SIDE extraction (2026-07-02). Server /extract is deprecated (410) — Auxilo
345
361
  // does not pay to extract. The local model (via `claude -p`) extracts + self-screens
346
362
  // the already-client-scrubbed transcript, and we submit finished learnings to /learn.
@@ -364,8 +380,9 @@ async function postExtract(transcript, sessionId, sourceType, _scrubReport) {
364
380
  judge_prompt_tokens: judgePromptTokens = 0,
365
381
  judge_completion_tokens: judgeCompletionTokens = 0,
366
382
  } = await extractLocally(transcript, sourceType, {
367
- baseUrl: BASE_URL,
368
- apiKey: API_KEY,
383
+ baseUrl: opts.baseUrl || BASE_URL,
384
+ apiKey: opts.apiKey !== undefined ? opts.apiKey : API_KEY,
385
+ captureVisibility: opts.captureVisibility || CAPTURE_VISIBILITY,
369
386
  log,
370
387
  auditLog: auditDropLog,
371
388
  }));
@@ -377,7 +394,10 @@ async function postExtract(transcript, sessionId, sourceType, _scrubReport) {
377
394
  return { learnings_published: 0, learnings_held: 0, learnings_rejected: 0, extraction_id: 'client-skip' };
378
395
  }
379
396
 
380
- const { published, held, rejected } = await submitLearnings(learnings, sourceType);
397
+ const { published, held, rejected } = await submitLearnings(learnings, sourceType, {
398
+ ...opts,
399
+ captureVisibility: opts.captureVisibility || CAPTURE_VISIBILITY,
400
+ });
381
401
  // NOTE: token-free prose — the digest-parsed `published=`/`held=`/`rejected=`
382
402
  // tokens live ONLY on the per-run caller log lines. This line previously
383
403
  // carried `published=` too and the digest double-counted every extraction
@@ -516,6 +536,7 @@ function sweeperManifest(repoRoot = path.resolve(__dirname, '..')) {
516
536
  ['lib/sensitivity-filter.js', 'lib/sensitivity-filter.js', 0o644],
517
537
  ['lib/extraction-index.js', 'lib/extraction-index.js', 0o644],
518
538
  ['lib/similarity.js', 'lib/similarity.js', 0o644],
539
+ ['lib/hook-status.js', 'lib/hook-status.js', 0o644],
519
540
  ['config/near-duplicate.json', 'config/near-duplicate.json', 0o644],
520
541
  // Client-side extraction (2026-07-02) — required by the sweep path since /extract went 410.
521
542
  // Missing from this manifest until 2026-07-19: installed sweepers crashed with
@@ -711,10 +732,10 @@ async function printStatus() {
711
732
  let hookInstalled = false;
712
733
  try {
713
734
  const settings = JSON.parse(fs.readFileSync(claudeSettingsPath, 'utf-8'));
714
- hookInstalled = Array.isArray(settings.hooks?.SessionEnd) &&
715
- settings.hooks.SessionEnd.some(h => h.includes('auxilo-extract'));
735
+ hookInstalled = hasAuxiloSessionEndHook(settings.hooks?.SessionEnd);
716
736
  } catch { /* no settings file */ }
717
737
  console.log(`Hook installed: ${hookInstalled ? 'yes' : 'no'}`);
738
+ console.log(`Settings inspected: ${claudeSettingsPath}`);
718
739
 
719
740
  // 5. Last sweep ran at
720
741
  console.log(`Last sweep: ${ledger.lastSweep || 'never'}`);
@@ -922,7 +943,8 @@ async function main() {
922
943
  try {
923
944
  const payload = JSON.parse(fs.readFileSync(qf, 'utf-8'));
924
945
  const result = await postExtract(
925
- payload.transcript, payload.sessionId, payload.source, payload.scrubReport
946
+ payload.transcript, payload.sessionId, payload.source, payload.scrubReport,
947
+ { captureVisibility: payload.capture_visibility || CAPTURE_VISIBILITY }
926
948
  );
927
949
  log(`[runner] ✓ Flushed ${path.basename(qf)}: published=${result.learnings_published || 0} held=${result.learnings_held || 0} rejected=${result.learnings_rejected || 0} ${DIGEST_ACCOUNT}`);
928
950
  flushHeld += result.learnings_held || 0;
@@ -1057,6 +1079,7 @@ async function main() {
1057
1079
  scrubReport: report,
1058
1080
  mtime: sessionRef.mtime,
1059
1081
  queuedAt: new Date().toISOString(),
1082
+ ...(CAPTURE_VISIBILITY === 'private' && { capture_visibility: 'private' }),
1060
1083
  });
1061
1084
 
1062
1085
  try {
@@ -1091,6 +1114,7 @@ module.exports = {
1091
1114
  loadLedger, saveLedger, ledgerHighWater, ledgerHas, ledgerMark,
1092
1115
  installHooks, installSweeper, installDigest, printStatus, scrubAndVerify, enumerateActiveSources,
1093
1116
  loadSources, SOURCES, sweeperManifest, submitLearnings, notifyHeld,
1117
+ resolveCaptureVisibility, postExtract,
1094
1118
  KILL_SWITCH_PATH, PENDING_DIR, LEDGER_PATH,
1095
1119
  };
1096
1120