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 +11 -6
- package/bin/auxilo-cli.js +221 -48
- package/lib/extraction-index.js +6 -1
- package/lib/hook-status.js +24 -0
- package/lib/installer.js +11 -3
- package/lib/review.js +115 -23
- package/lib/similarity.js +34 -3
- package/mcp-server.js +110 -10
- package/package.json +2 -1
- package/scripts/extract-local.js +28 -4
- package/scripts/runner.js +33 -9
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.
|
|
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
|
|
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
|
|
48
|
-
npx auxilo
|
|
49
|
-
npx auxilo
|
|
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
|
-
|
|
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
|
-
|
|
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)
|
|
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
|
-
|
|
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
|
|
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
|
|
551
|
+
/** Short lane/flag codes for a summary row, e.g. 'ready' or 'inj+sens'. */
|
|
526
552
|
function shortFlags(row) {
|
|
527
|
-
|
|
528
|
-
const
|
|
529
|
-
|
|
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
|
-
|
|
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
|
|
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: ${
|
|
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 (
|
|
554
|
-
console.log(`\
|
|
555
|
-
|
|
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 (
|
|
558
|
-
console.log(`\
|
|
559
|
-
|
|
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-
|
|
644
|
-
//
|
|
645
|
-
|
|
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: '
|
|
648
|
-
console.log(`approve-
|
|
649
|
-
if (sel.
|
|
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}).
|
|
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
|
|
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 (
|
|
666
|
-
console.log(
|
|
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}
|
|
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
|
|
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; //
|
|
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
|
|
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-
|
|
779
|
-
|
|
780
|
-
|
|
781
|
-
|
|
782
|
-
|
|
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-
|
|
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 = {
|
|
997
|
+
module.exports = {
|
|
998
|
+
parseFlags,
|
|
999
|
+
resolveBaseUrl,
|
|
1000
|
+
shortFlags,
|
|
1001
|
+
groupSummaryRows,
|
|
1002
|
+
printSummaryTable,
|
|
1003
|
+
usage,
|
|
1004
|
+
run,
|
|
1005
|
+
};
|
package/lib/extraction-index.js
CHANGED
|
@@ -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(
|
|
1571
|
-
|
|
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
|
|
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 "
|
|
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
|
|
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-
|
|
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
|
|
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
|
-
*
|
|
237
|
-
*
|
|
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
|
-
*
|
|
240
|
-
*
|
|
241
|
-
|
|
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' : '
|
|
252
|
-
const minQuality = mode === '
|
|
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 (
|
|
265
|
-
|
|
297
|
+
if (row.visibility === 'private') {
|
|
298
|
+
excluded_private.push(row);
|
|
266
299
|
continue;
|
|
267
300
|
}
|
|
268
|
-
|
|
269
|
-
|
|
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 {
|
|
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
|
-
*
|
|
208
|
-
*
|
|
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
|
|
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.
|
|
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
|
|
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
|
|
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
|
-
|
|
410
|
-
|
|
411
|
-
|
|
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 = {
|
|
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.
|
|
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",
|
package/scripts/extract-local.js
CHANGED
|
@@ -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
|
|
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
|
|
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)
|
|
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 {
|
|
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 =
|
|
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
|
|