auxilo-mcp 0.9.10 → 0.9.12

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/bin/auxilo-cli.js CHANGED
@@ -493,6 +493,10 @@ async function cmdStatus() {
493
493
  console.log(`Account mode: ${s.accountMode}`);
494
494
  console.log(`Kill-switch sentinel: ${s.sentinel ? 'present (extraction enabled)' : 'absent (extraction disabled)'}`);
495
495
  console.log(`Runner installed: ${s.runnerInstalled ? 'yes (~/.auxilo/bin)' : 'no'}`);
496
+ if (s.runnerInstalled) {
497
+ const line = runnerSkewLine(installer.runnerVersionSkew(HOME));
498
+ if (line) console.log(line);
499
+ }
496
500
  console.log(`SessionEnd hook: ${s.hookInstalled ? 'installed' : 'not installed'}${s.hookRegistered ? ', registered in Claude Code settings' : ''}`);
497
501
  for (const c of s.clients.filter((c) => c.captureHook)) {
498
502
  console.log(`Capture hooks: ${c.name} (${c.captureEvent}, ${c.captureRegistered ? 'registered' : 'not registered'})`);
@@ -501,6 +505,17 @@ async function cmdStatus() {
501
505
  console.log(`Pending upload queue: ${s.pendingCount} file(s)\n`);
502
506
  }
503
507
 
508
+ /**
509
+ * CLEAN-LANE-FLIP Phase B: ONE line when ~/.auxilo/bin/VERSION is missing or
510
+ * differs from this CLI's package version; null when the stack is current.
511
+ * `setup` is idempotent and re-copies the stack, so that is the remedy.
512
+ */
513
+ function runnerSkewLine(skew) {
514
+ if (!skew || !skew.skew) return null;
515
+ const installed = skew.installed ? `v${skew.installed}` : 'unstamped (pre-0.9.12)';
516
+ return ` ⚠ Installed runner is ${installed} (package v${skew.package}) — run: npx auxilo setup`;
517
+ }
518
+
504
519
  // ─── auxilo disable ─────────────────────────────────────────────────────────
505
520
 
506
521
  async function cmdDisable(flags) {
@@ -860,6 +875,238 @@ async function cmdReview(flags) {
860
875
  console.log(`\nReview complete: approved ${approved}, kept private ${keptPrivate}, rejected ${rejected}, skipped ${skipped} of ${ordered.length}.`);
861
876
  }
862
877
 
878
+ // ─── auxilo clean-lane (SPEC3-C1 standing consent; CLEAN-LANE-FLIP Phase A) ──
879
+ //
880
+ // GOV-3 (ratified language, Gate-A 2026-09-05): `grant` runs ONLY on a TTY and
881
+ // requires the human to TYPE the affirmation sentence verbatim — no --yes, no
882
+ // flag. The TTY gate + verbatim affirmation prevent ACCIDENTAL enrollment and
883
+ // create a hash-chained record of a DELIBERATE act by the credential holder.
884
+ // That record is EVIDENTIARY, not preventive: it is not a defense against a
885
+ // holder of the account's contribute-scoped key, who can reach the same routes
886
+ // directly. `status` / `revoke` are non-interactive.
887
+ // While the server flag is off the routes answer the catch-all 404 and every
888
+ // subcommand prints "not yet available" (exit 0).
889
+ //
890
+ // The sentence below MIRRORS lib/clean-lane.js CLEAN_LANE_AFFIRMATION. It is
891
+ // a literal here only because lib/clean-lane.js is server-side and not in the
892
+ // published package's files[]; test/clean-lane-phase-a.test.js pins the two
893
+ // byte-equal. The consent VERSION is never a client literal — it always comes
894
+ // from GET /account/clean-lane (consent_version_current).
895
+ const CLEAN_LANE_AFFIRMATION = 'I understand and choose auto-publish for qualifying extracted learnings.';
896
+ const CLEAN_LANE_UNAVAILABLE = 'Auto-publish for clean learnings is not yet available on this account.';
897
+ // CLEAN-LANE-FLIP Phase B (legal; DRAFT pending Tyler): the full text of ToS
898
+ // §5.9.3(g) (plus its ratchet paragraph) prints ABOVE the affirmation prompt —
899
+ // counsel condition: the enrollment surface must show what "qualifying",
900
+ // revocation and the 7-day retraction mean, on both the dashboard and CLI
901
+ // paths. Same package-boundary reason as the affirmation: these literals are
902
+ // pinned byte-equal to docs/TERMS-OF-SERVICE.md and public/dashboard.html by
903
+ // test/clean-lane-phase-b-legal.test.js. Edit the Terms first, then mirror.
904
+ const CLEAN_LANE_TERMS_G = '(g) Standing publication consent (optional). Standing publication consent is off by default. A Builder may turn it on by an affirmative act — a dashboard setting, or a terminal command that requires typing the affirmation sentence shown on that screen. Auxilo records that act, the affirmation, and the consent-text version in a durable, hash-chained consent log, retained for the life of the account plus three (3) years under subsection (b). While it is on, a Learning submitted through Autonomous Extraction is published without separate per-item approval only if it passes every Platform screen and the quality threshold the Builder chose at activation. An account\'s first public Learning is never published this way; it is held for operator review under Section 4.1. Auxilo records each such publication in the Builder\'s dashboard and returns a notice in the response to the submission that produced it; each is retractable for seven (7) days under Section 5.9.4. If more than five percent (5%) of a Builder\'s Learnings published this way in any thirty (30) day period are retracted, Auxilo freezes the feature for that account until the Builder turns it on again. A Builder may turn it off at any time, effective immediately for later submissions; doing so does not affect Learnings already published. Subsection (c) applies in full to every Learning so published.';
905
+ const CLEAN_LANE_TERMS_G2 = 'The quality threshold in effect for a Builder is the one that Builder selected, and Auxilo will not broaden the conditions under which a Learning qualifies for publication under this subsection without recording a new consent; Auxilo may make those conditions stricter at any time.';
906
+ // CLEAN-LANE-FLIP Phase B (notice hardening): the no-email enrollment line —
907
+ // GOV-2 counsel draft §6 read #2 "move 3" — printed verbatim before the
908
+ // affirmation prompt on every enrollment surface. Byte-equal to the dashboard's
909
+ // #clean-lane-no-email-line (test/clean-lane-phase-b-notice.test.js).
910
+ const CLEAN_LANE_NO_EMAIL_LINE = 'You will not receive an email for these. Publications appear in your dashboard and in the response to the session that submitted them. The 7-day retraction window runs from publication.';
911
+ const CLEAN_LANE_MIN_QUALITY_MIN = 14;
912
+ const CLEAN_LANE_MIN_QUALITY_MAX = 20;
913
+ const CLEAN_LANE_MIN_QUALITY_DEFAULT = 16;
914
+
915
+ const CLEAN_LANE_EXPLAINER = `
916
+ Auto-publish clean learnings
917
+
918
+ When this is on, a learning is published without waiting for your review
919
+ only when all three hold: it was extracted by your own model, every server
920
+ screen came back clean, and its quality score is at or above the threshold
921
+ you set.
922
+
923
+ What is never auto-published: your first public learning (it waits for
924
+ operator review), anything a screen flags, anything below your threshold,
925
+ and anything after an auto-freeze. Every auto-published learning can be
926
+ retracted for 7 days (\`npx auxilo review\` or your dashboard).
927
+ `;
928
+
929
+ /** Word-wrap a single paragraph at `width` columns (whitespace only; no word is altered). */
930
+ function wrapForTerminal(paragraph, width = 78) {
931
+ const lines = [];
932
+ let line = '';
933
+ for (const word of paragraph.split(' ')) {
934
+ if (line && (line.length + 1 + word.length) > width) { lines.push(line); line = word; }
935
+ else line = line ? `${line} ${word}` : word;
936
+ }
937
+ if (line) lines.push(line);
938
+ return lines.map((l) => ` ${l}`).join('\n');
939
+ }
940
+
941
+ async function cleanLaneRequest({ apiKey, baseUrl, method, route, body }) {
942
+ const url = `${String(baseUrl).replace(/\/+$/, '')}${route}`;
943
+ const headers = { 'X-API-Key': apiKey };
944
+ if (body !== undefined) headers['Content-Type'] = 'application/json';
945
+ const res = await fetch(url, {
946
+ method,
947
+ headers,
948
+ ...(body !== undefined && { body: JSON.stringify(body) }),
949
+ });
950
+ let data = {};
951
+ try { data = await res.json(); } catch { /* non-JSON body */ }
952
+ return { status: res.status, ok: res.ok, data: data || {} };
953
+ }
954
+
955
+ function printCleanLaneStatus(data) {
956
+ const active = data.clean_lane_active === true;
957
+ console.log(`Auto-publish clean learnings: ${active ? 'ON' : 'OFF'}`);
958
+ if (active) {
959
+ console.log(` since: ${data.last_action_at || 'unknown'}`);
960
+ console.log(` quality at least: ${data.min_auto_publish_quality}`);
961
+ console.log(` consent version: ${data.consent_version_recorded || data.consent_version_current}`);
962
+ } else if (data.last_action === 'freeze') {
963
+ console.log(` FROZEN: ${data.freeze_reason || 'unknown reason'} (${data.last_action_at || 'unknown time'})`);
964
+ console.log(' Nothing auto-publishes until you grant consent again: npx auxilo clean-lane grant');
965
+ } else if (data.last_action === 'revoke') {
966
+ console.log(` revoked at: ${data.last_action_at || 'unknown'}`);
967
+ } else if (data.last_action === 'grant') {
968
+ console.log(` a grant exists under consent version ${data.consent_version_recorded} but the current version is ${data.consent_version_current}; re-grant to re-activate.`);
969
+ }
970
+ console.log(` current consent version: ${data.consent_version_current}`);
971
+ // CLEAN-LANE-FLIP Phase B (notice hardening): the unread count, printed only
972
+ // when > 0. Nothing here acknowledges it — only the dashboard button does.
973
+ const unread = data.unacknowledged_publications;
974
+ if (Number.isInteger(unread) && unread > 0) {
975
+ console.log(` auto-published since you last checked: ${unread} (review and acknowledge them in your dashboard)`);
976
+ }
977
+ }
978
+
979
+ async function cmdCleanLane(flags) {
980
+ const sub = process.argv[3];
981
+ if (!['status', 'grant', 'revoke'].includes(sub)) {
982
+ if (sub) console.error(`Unknown clean-lane subcommand: ${sub}`);
983
+ usage('clean-lane');
984
+ process.exit(sub ? 1 : 0);
985
+ }
986
+
987
+ // The TTY gate runs BEFORE credentials and BEFORE any network call: a
988
+ // piped or scripted stdin can never reach the grant.
989
+ if (sub === 'grant' && !process.stdin.isTTY) {
990
+ console.error('auxilo clean-lane grant must be run by a person in an interactive terminal. It does not accept piped input, and there is no flag that skips typing the consent sentence.');
991
+ process.exit(1);
992
+ }
993
+
994
+ const creds = installer.readCredentials(HOME);
995
+ if (!creds || !creds.api_key) {
996
+ console.error('Not logged in. Run `npx auxilo setup` first.');
997
+ process.exit(1);
998
+ }
999
+ const baseUrl = resolveBaseUrl(flags);
1000
+ const apiKey = creds.api_key;
1001
+
1002
+ let status;
1003
+ try {
1004
+ status = await cleanLaneRequest({ apiKey, baseUrl, method: 'GET', route: '/account/clean-lane' });
1005
+ } catch (err) {
1006
+ console.error(`Could not reach the server: ${err.message}`);
1007
+ process.exit(1);
1008
+ }
1009
+ if (status.status === 404) {
1010
+ console.log(CLEAN_LANE_UNAVAILABLE);
1011
+ return;
1012
+ }
1013
+ if (!status.ok) {
1014
+ console.error(`Could not read auto-publish status (HTTP ${status.status}): ${status.data.error || 'unknown error'}`);
1015
+ process.exit(1);
1016
+ }
1017
+
1018
+ if (sub === 'status') {
1019
+ printCleanLaneStatus(status.data);
1020
+ return;
1021
+ }
1022
+
1023
+ if (sub === 'revoke') {
1024
+ let res;
1025
+ try {
1026
+ res = await cleanLaneRequest({ apiKey, baseUrl, method: 'POST', route: '/account/clean-lane/revoke', body: {} });
1027
+ } catch (err) {
1028
+ console.error(`Could not reach the server: ${err.message}`);
1029
+ process.exit(1);
1030
+ }
1031
+ if (res.status === 404) { console.log(CLEAN_LANE_UNAVAILABLE); return; }
1032
+ if (!res.ok) {
1033
+ console.error(`Revoke failed (HTTP ${res.status}): ${res.data.error || 'unknown error'}`);
1034
+ process.exit(1);
1035
+ }
1036
+ console.log(res.data.message || 'Auto-publish is now OFF.');
1037
+ return;
1038
+ }
1039
+
1040
+ // ── grant: explainer → threshold → the sentence, typed verbatim ──────────
1041
+ console.log(CLEAN_LANE_EXPLAINER);
1042
+ if (status.data.clean_lane_active === true) {
1043
+ console.log(`Auto-publish is already ON (quality at least ${status.data.min_auto_publish_quality}, since ${status.data.last_action_at}). Granting again records a fresh consent row with the threshold you choose now.\n`);
1044
+ } else if (status.data.last_action === 'freeze') {
1045
+ console.log(`Auto-publish is FROZEN: ${status.data.freeze_reason || 'unknown reason'}. Granting again re-activates it.\n`);
1046
+ }
1047
+
1048
+ let minQuality = CLEAN_LANE_MIN_QUALITY_DEFAULT;
1049
+ for (;;) {
1050
+ const answer = await ask(`Publish only when the quality score is at least [${CLEAN_LANE_MIN_QUALITY_MIN}-${CLEAN_LANE_MIN_QUALITY_MAX}, default ${CLEAN_LANE_MIN_QUALITY_DEFAULT}]: `);
1051
+ if (answer === '') break;
1052
+ const n = parseInt(answer, 10);
1053
+ if (Number.isInteger(n) && String(n) === answer && n >= CLEAN_LANE_MIN_QUALITY_MIN && n <= CLEAN_LANE_MIN_QUALITY_MAX) {
1054
+ minQuality = n;
1055
+ break;
1056
+ }
1057
+ if (readlineEnded) { console.log('Aborted. Nothing changed.'); return; }
1058
+ console.log(`Enter a whole number from ${CLEAN_LANE_MIN_QUALITY_MIN} to ${CLEAN_LANE_MIN_QUALITY_MAX}.`);
1059
+ }
1060
+
1061
+ // The consent text itself, verbatim (word-wrapped for the terminal only), before the sentence.
1062
+ console.log('\nTerms of Service, Section 5.9.3(g): the consent you are giving\n');
1063
+ console.log(wrapForTerminal(CLEAN_LANE_TERMS_G));
1064
+ console.log('');
1065
+ console.log(wrapForTerminal(CLEAN_LANE_TERMS_G2));
1066
+ console.log(`\nFull Terms: ${baseUrl}/terms`);
1067
+ // The no-email line, verbatim, directly before the affirmation prompt.
1068
+ console.log(`\n${wrapForTerminal(CLEAN_LANE_NO_EMAIL_LINE)}`);
1069
+ console.log('\nTo turn on auto-publish, type this sentence exactly as written, then press Enter:');
1070
+ console.log(`\n ${CLEAN_LANE_AFFIRMATION}\n`);
1071
+ const typed = await ask('> ');
1072
+ if (typed !== CLEAN_LANE_AFFIRMATION) {
1073
+ console.log('The sentence did not match. Aborted. Nothing changed.');
1074
+ return;
1075
+ }
1076
+
1077
+ let res;
1078
+ try {
1079
+ res = await cleanLaneRequest({
1080
+ apiKey,
1081
+ baseUrl,
1082
+ method: 'POST',
1083
+ route: '/account/clean-lane/grant',
1084
+ body: {
1085
+ consent_version: status.data.consent_version_current,
1086
+ agree: true,
1087
+ affirmation: typed, // what the human typed, transmitted verbatim
1088
+ min_auto_publish_quality: minQuality,
1089
+ },
1090
+ });
1091
+ } catch (err) {
1092
+ console.error(`Could not reach the server: ${err.message}`);
1093
+ process.exit(1);
1094
+ }
1095
+ if (res.status === 404) { console.log(CLEAN_LANE_UNAVAILABLE); return; }
1096
+ if (res.status === 409) {
1097
+ console.error('The consent version changed on the server while you were reading. Run `npx auxilo clean-lane grant` again.');
1098
+ process.exit(1);
1099
+ }
1100
+ if (!res.ok) {
1101
+ console.error(`Grant failed (HTTP ${res.status}): ${res.data.error || 'unknown error'}`);
1102
+ process.exit(1);
1103
+ }
1104
+ console.log(`\n${res.data.message || 'Auto-publish is now ON.'}`);
1105
+ console.log(` quality at least: ${res.data.min_auto_publish_quality}`);
1106
+ console.log(` consent version: ${res.data.consent_version}`);
1107
+ console.log(' Turn it off any time: npx auxilo clean-lane revoke');
1108
+ }
1109
+
863
1110
  // ─── Entry point ────────────────────────────────────────────────────────────
864
1111
 
865
1112
  function usage(command) {
@@ -903,6 +1150,18 @@ them private or sanitize-promote a corrected replacement.`,
903
1150
  disable: `Usage: auxilo disable [--base-url <url>]
904
1151
 
905
1152
  Disable background extraction locally and optionally revoke server consent.`,
1153
+ 'clean-lane': `Usage: auxilo clean-lane <status|grant|revoke> [--base-url <url>]
1154
+
1155
+ Auto-publish clean learnings (standing consent). While the feature is not yet
1156
+ available on your account every subcommand says so and changes nothing.
1157
+
1158
+ status Show whether auto-publish is on, the quality threshold, and the
1159
+ consent version.
1160
+ grant Turn it on. Interactive ONLY: you choose the threshold and then
1161
+ TYPE the consent sentence exactly. No flag or piped input can do
1162
+ this for you.
1163
+ revoke Turn it off (one step, no confirmation). Already-published
1164
+ learnings keep their 7-day retraction window.`,
906
1165
  };
907
1166
  if (command && blocks[command]) {
908
1167
  console.log(`\n${blocks[command]}\n`);
@@ -951,6 +1210,9 @@ Commands:
951
1210
  skips that step.
952
1211
  disable Turn off background extraction (local kill-switch; optional
953
1212
  server-side consent revoke).
1213
+ clean-lane <status|grant|revoke>
1214
+ Auto-publish clean learnings (standing consent). grant is
1215
+ interactive only: you type the consent sentence yourself.
954
1216
 
955
1217
  Docs: https://auxilo.io · API: ${installer.DEFAULT_BASE_URL}
956
1218
  `);
@@ -959,7 +1221,7 @@ Docs: https://auxilo.io · API: ${installer.DEFAULT_BASE_URL}
959
1221
  async function main() {
960
1222
  const cmd = process.argv[2];
961
1223
  const subcommandHelp = ['help', '--help', '-h'].includes(process.argv[3]);
962
- if (['setup', 'init', 'status', 'review', 'disable'].includes(cmd) && subcommandHelp) {
1224
+ if (['setup', 'init', 'status', 'review', 'disable', 'clean-lane'].includes(cmd) && subcommandHelp) {
963
1225
  return usage(cmd);
964
1226
  }
965
1227
  const flags = parseFlags(process.argv);
@@ -969,6 +1231,7 @@ async function main() {
969
1231
  case 'status': return cmdStatus(flags);
970
1232
  case 'review': return cmdReview(flags);
971
1233
  case 'disable': return cmdDisable(flags);
1234
+ case 'clean-lane': return cmdCleanLane(flags);
972
1235
  case 'help': case '--help': case '-h': case undefined: return usage();
973
1236
  default:
974
1237
  console.error(`Unknown command: ${cmd}`);
@@ -996,10 +1259,17 @@ if (require.main === module) {
996
1259
 
997
1260
  module.exports = {
998
1261
  parseFlags,
1262
+ runnerSkewLine,
999
1263
  resolveBaseUrl,
1000
1264
  shortFlags,
1001
1265
  groupSummaryRows,
1002
1266
  printSummaryTable,
1003
1267
  usage,
1004
1268
  run,
1269
+ CLEAN_LANE_AFFIRMATION,
1270
+ CLEAN_LANE_UNAVAILABLE,
1271
+ CLEAN_LANE_TERMS_G,
1272
+ CLEAN_LANE_TERMS_G2,
1273
+ CLEAN_LANE_NO_EMAIL_LINE,
1274
+ wrapForTerminal,
1005
1275
  };
@@ -140,6 +140,20 @@ function localIndexRow(learning, response = {}, opts = {}) {
140
140
  ...(typeof response.status === 'string' && VALID_STATUSES.has(response.status) && {
141
141
  status: response.status,
142
142
  }),
143
+ // CLEAN-LANE-FLIP Phase A2: persist the standing-consent publish stamps the
144
+ // /learn response carries (published_via / standing_consent_version /
145
+ // retractable_until) so the SessionStart rollup (scripts/review-notice.js)
146
+ // can count clean-lane publishes from the local index. Spread only when
147
+ // present as non-empty strings; absent on every non-clean-lane response.
148
+ ...(typeof response.published_via === 'string' && response.published_via && {
149
+ published_via: response.published_via,
150
+ }),
151
+ ...(typeof response.standing_consent_version === 'string' && response.standing_consent_version && {
152
+ standing_consent_version: response.standing_consent_version,
153
+ }),
154
+ ...(typeof response.retractable_until === 'string' && response.retractable_until && {
155
+ retractable_until: response.retractable_until,
156
+ }),
143
157
  };
144
158
  }
145
159
 
package/lib/installer.js CHANGED
@@ -792,10 +792,18 @@ exit 0
792
792
  * relative layout runner.js requires. Missing package files throw — the lib
793
793
  * never calls process.exit.
794
794
  *
795
+ * CLEAN-LANE-FLIP Phase B: the install also stamps <bin>/VERSION with the
796
+ * package version read from <packageRoot>/package.json at install time, so
797
+ * `auxilo status` can tell a stale copied stack from the CLI that is running
798
+ * (npx resolves a fresh auxilo-mcp; ~/.auxilo/bin keeps whatever setup last
799
+ * copied). VERSION is a stamp, not a stack row — it is deliberately NOT in
800
+ * RUNNER_STACK (test/runner-packaging-closure.test.js walks the require
801
+ * closure against the stack, and a version file is not code).
802
+ *
795
803
  * @param {string} homeDir
796
804
  * @param {object} [opts]
797
805
  * @param {string} [opts.packageRoot=PACKAGE_ROOT]
798
- * @returns {{ binRoot: string, hookPath: string, installed: string[] }}
806
+ * @returns {{ binRoot: string, hookPath: string, versionPath: string, version: string, installed: string[] }}
799
807
  */
800
808
  function installRunner(homeDir, opts = {}) {
801
809
  if (!homeDir) throw new Error('installRunner: homeDir is required');
@@ -820,7 +828,55 @@ function installRunner(homeDir, opts = {}) {
820
828
  fs.chmodSync(hookPath, 0o755);
821
829
  installed.push(hookPath);
822
830
 
823
- return { binRoot, hookPath, installed };
831
+ const version = packageVersion(packageRoot);
832
+ const versionPath = runnerVersionPath(homeDir);
833
+ fs.writeFileSync(versionPath, `${version}\n`);
834
+ fs.chmodSync(versionPath, 0o644);
835
+ installed.push(versionPath);
836
+
837
+ return { binRoot, hookPath, versionPath, version, installed };
838
+ }
839
+
840
+ // ─── Runner-stack freshness (CLEAN-LANE-FLIP Phase B) ───────────────────────
841
+
842
+ /** <home>/.auxilo/bin/VERSION — the package version installRunner last copied. */
843
+ function runnerVersionPath(homeDir) {
844
+ return path.join(binRootFor(homeDir), 'VERSION');
845
+ }
846
+
847
+ /** Package version as shipped in <packageRoot>/package.json (throws if unreadable). */
848
+ function packageVersion(packageRoot = PACKAGE_ROOT) {
849
+ const pkg = JSON.parse(fs.readFileSync(path.join(packageRoot, 'package.json'), 'utf-8'));
850
+ if (!pkg || typeof pkg.version !== 'string' || !pkg.version) {
851
+ throw new Error(`packageVersion: no version in ${path.join(packageRoot, 'package.json')}`);
852
+ }
853
+ return pkg.version;
854
+ }
855
+
856
+ /** Installed runner stamp, or null when the stamp is missing/unreadable (pre-0.9.12 installs). */
857
+ function installedRunnerVersion(homeDir) {
858
+ try {
859
+ const v = fs.readFileSync(runnerVersionPath(homeDir), 'utf-8').trim();
860
+ return v || null;
861
+ } catch {
862
+ return null;
863
+ }
864
+ }
865
+
866
+ /**
867
+ * Compare the copied runner stack against the running package.
868
+ *
869
+ * @param {string} homeDir
870
+ * @param {object} [opts]
871
+ * @param {string} [opts.packageRoot=PACKAGE_ROOT]
872
+ * @returns {{ installed: string|null, package: string, skew: boolean }}
873
+ * skew is true when the stamp is missing OR differs from the package version.
874
+ */
875
+ function runnerVersionSkew(homeDir, opts = {}) {
876
+ if (!homeDir) throw new Error('runnerVersionSkew: homeDir is required');
877
+ const installed = installedRunnerVersion(homeDir);
878
+ const pkg = packageVersion(opts.packageRoot || PACKAGE_ROOT);
879
+ return { installed, package: pkg, skew: installed !== pkg };
824
880
  }
825
881
 
826
882
  // ─── Claude Code hook registration (spec §LW-12 step 4) ─────────────────────
@@ -1704,6 +1760,10 @@ module.exports = {
1704
1760
  hookScriptPathFor,
1705
1761
  renderHookScript,
1706
1762
  installRunner,
1763
+ runnerVersionPath,
1764
+ packageVersion,
1765
+ installedRunnerVersion,
1766
+ runnerVersionSkew,
1707
1767
  registerClaudeCodeHook,
1708
1768
  removeClaudeCodeHook,
1709
1769
  captureShimPath,
package/mcp-server.js CHANGED
@@ -198,7 +198,7 @@ async function postBulkChunks(headers, decisions) {
198
198
  }
199
199
 
200
200
  const server = new Server(
201
- { name: 'auxilo', version: '0.9.10' },
201
+ { name: 'auxilo', version: '0.9.12' },
202
202
  {
203
203
  capabilities: { tools: {} },
204
204
  instructions: `You are connected to Auxilo, a knowledge marketplace where AI agents buy and sell operational learnings.
@@ -384,7 +384,7 @@ server.setRequestHandler(ListToolsRequestSchema, async () => ({
384
384
  },
385
385
  {
386
386
  name: 'auxilo_contributor',
387
- description: 'Check earnings for a contributor wallet. Shows total earned, per-learning breakdown. Free.',
387
+ description: 'Check aggregate earnings totals for a contributor wallet. Per-learning earnings require the authenticated auxilo_account_earnings tool. Free.',
388
388
  inputSchema: {
389
389
  type: 'object',
390
390
  properties: {
@@ -408,7 +408,7 @@ server.setRequestHandler(ListToolsRequestSchema, async () => ({
408
408
  },
409
409
  {
410
410
  name: 'auxilo_account_earnings',
411
- description: 'View earnings for your authenticated Auxilo account. Authenticates with your configured API key automatically, or pass a session_token (JWT). Returns total gross, contributor share, pending balance, total withdrawn, whether withdrawal is available (can_withdraw), and held_pending_assent — undisbursable receipts recorded before you accepted the current Terms, released to your withdrawable balance when you accept via auxilo_accept_terms. Earnings from on-chain-settled sales are paid to your wallet at sale time and appear in settlement history, not in pending balance. Free.',
411
+ description: 'View aggregate and per-learning earnings for your authenticated Auxilo account. Authenticates with your configured API key automatically, or pass a session_token (JWT). Returns total gross, contributor share, pending balance, total withdrawn, whether withdrawal is available (can_withdraw), and held_pending_assent — undisbursable receipts recorded before you accepted the current Terms, released to your withdrawable balance when you accept via auxilo_accept_terms. Earnings from on-chain-settled sales are paid to your wallet at sale time and appear in settlement history, not in pending balance. Free.',
412
412
  inputSchema: {
413
413
  type: 'object',
414
414
  properties: {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "auxilo-mcp",
3
- "version": "0.9.10",
3
+ "version": "0.9.12",
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",
@@ -36,20 +36,36 @@ const PRIVATE_CATEGORIES = [...CATEGORIES, 'non-technical'];
36
36
  const RETIRED_CATEGORIES = ['communication', 'content-generation'];
37
37
 
38
38
  /**
39
- * SPEC3 slice A1 gate — score-at-extraction, BUILT BUT DARK by default.
39
+ * SPEC3 slice A1 gate — score-at-extraction, ON BY DEFAULT since 0.9.12
40
+ * (CLEAN-LANE-FLIP Phase B). Explicit opt-out only: AUXILO_SCORE_EXTRACTION=0
41
+ * (or 'false') disables scoring; any other value, including unset, scores.
40
42
  *
41
- * ┌─ CRITICAL SEQUENCING CONSTRAINT (SPEC3-BUILDER-REVIEW-LOOP §3.1/§8) ──────┐
42
- * │ Under a server WITHOUT the B1 extraction-channel hold, a clean /learn
43
- * │ submission carrying a floor-passing quality_self_assessment publishes
44
- * │ IMMEDIATELY (seamlessEligible). Turning this gate on against such a
45
- * │ server silently flips hook extraction from "everything held" to "clean
46
- * │ items auto-publish" an unrecorded consent change (2026-06-10 class).
47
- * │ Do NOT set AUXILO_SCORE_EXTRACTION=1 until the server holds
48
- * │ submission_channel:'extraction' items behind standing consent (B1).
49
- * └───────────────────────────────────────────────────────────────────────────┘
43
+ * ┌─ SEQUENCING CONSTRAINT — SATISFIED (SPEC3-BUILDER-REVIEW-LOOP §3.1/§8) ──┐
44
+ * │ This gate shipped dark (opt-in via AUXILO_SCORE_EXTRACTION=1) because
45
+ * │ under a server WITHOUT the B1 extraction-channel hold, a clean /learn
46
+ * │ submission carrying a floor-passing quality_self_assessment published
47
+ * │ IMMEDIATELY (seamlessEligible) arming scoring would have silently
48
+ * │ flipped hook extraction from "everything held" to "clean items
49
+ * │ auto-publish", an unrecorded consent change (2026-06-10 class).
50
+ * │
51
+ * │ B1 SHIPPED (lib/clean-lane.js; server.js /learn): every submission │
52
+ * │ carrying submission_channel:'extraction' (runner.js stamps it │
53
+ * │ unconditionally) is HELD with reason 'standing_consent_off' unless the │
54
+ * │ account holds an active standing-consent grant — regardless of the │
55
+ * │ score. The server brake, not the absence of a score, is what keeps │
56
+ * │ unconsented items out of the catalog, so default-on scoring is safe: │
57
+ * │ the score can only move a held item into the ready_to_publish lane (one │
58
+ * │ counted approve away) or, under an active grant, into the clean lane the │
59
+ * │ contributor explicitly opted into. Without a score every extraction │
60
+ * │ submission held awaiting_quality forever, so a stock install could never │
61
+ * │ enter the clean lane at all — that was the Phase B defect this closes. │
62
+ * └──────────────────────────────────────────────────────────────────────────┘
50
63
  */
51
64
  function scoreExtractionEnabled(env = process.env) {
52
- return env.AUXILO_SCORE_EXTRACTION === '1';
65
+ const raw = env.AUXILO_SCORE_EXTRACTION;
66
+ if (raw === undefined || raw === null) return true;
67
+ const v = String(raw).trim().toLowerCase();
68
+ return !(v === '0' || v === 'false');
53
69
  }
54
70
 
55
71
  const EXTRACTION_PROMPT_BASE = `You are extracting reusable OPERATIONAL LEARNINGS from an AI agent's session transcript, to publish to a PUBLIC knowledge marketplace read by other AI agents.
@@ -662,11 +678,55 @@ async function runAnchoredJudge(candidates, indexState, opts = {}) {
662
678
 
663
679
  /**
664
680
  * Extract learnings locally. Returns { learnings: [...] } or { learnings: [], skipped }.
665
- * Claude Code and Codex rollout captures use the existing client-local Claude
666
- * extractor; other clients rely on the agent's proactive auxilo_contribute
667
- * (MCP) call.
681
+ *
682
+ * EXT-GATE: every capture source id runs the client-local extractor. The
683
+ * extractor is transcript-text based (buildExtractionPrompt carries no
684
+ * per-source branch), so nothing here depends on WHICH client captured.
685
+ * Unknown ids still short-circuit: a `--source` value the registry does not
686
+ * know is a misconfigured shim, not a client, and the skip message below is
687
+ * matched by runner.js and test/uc6-codex-capture.test.js — do not change it.
688
+ *
689
+ * The list is static on purpose: lib/installer.js is not in RUNNER_STACK, so
690
+ * this file cannot enumerate the registry at runtime. The closure test
691
+ * (test/ext-gate-closure.test.js) derives the expected set from the two live
692
+ * enumerations — scripts/sources/*.js adapter ids ∪ installer hook-client
693
+ * source ids — and is the authority; a new adapter or hook client that is not
694
+ * added here turns CI red.
668
695
  */
669
- const EXTRACTABLE_SOURCES = new Set(['claude-code', 'codex-cli']);
696
+ const EXTRACTABLE_SOURCE_IDS = Object.freeze([
697
+ 'antigravity',
698
+ 'claude-code',
699
+ 'cline',
700
+ 'codex-cli',
701
+ 'continue',
702
+ 'copilot',
703
+ 'cursor',
704
+ 'factory',
705
+ 'gemini-cli',
706
+ 'openclaw',
707
+ 'roo-code',
708
+ 'windsurf',
709
+ ]);
710
+
711
+ // Gate-A 2026-09-05: the exported set is IMMUTABLE. It stays a real Set (same
712
+ // name, `.has()` / iteration / `instanceof Set` unchanged) but its own
713
+ // add/delete/clear shadow the prototype's and throw, so no importer can widen
714
+ // or narrow the allowlist at runtime — the frozen id array above is the only
715
+ // source and the closure test is the only authority.
716
+ function immutableSet(ids) {
717
+ const set = new Set(ids);
718
+ const refuse = (op) => function () {
719
+ throw new TypeError(`EXTRACTABLE_SOURCES is immutable (${op} refused)`);
720
+ };
721
+ Object.defineProperties(set, {
722
+ add: { value: refuse('add'), writable: false, configurable: false, enumerable: false },
723
+ delete: { value: refuse('delete'), writable: false, configurable: false, enumerable: false },
724
+ clear: { value: refuse('clear'), writable: false, configurable: false, enumerable: false },
725
+ });
726
+ return Object.freeze(set);
727
+ }
728
+
729
+ const EXTRACTABLE_SOURCES = immutableSet(EXTRACTABLE_SOURCE_IDS);
670
730
 
671
731
  async function extractLocally(transcript, sourceType, opts = {}) {
672
732
  if (sourceType && !EXTRACTABLE_SOURCES.has(sourceType)) {
@@ -780,7 +840,7 @@ async function extractLocally(transcript, sourceType, opts = {}) {
780
840
  }
781
841
 
782
842
  module.exports = {
783
- extractLocally, extractWithClaudeCode, checkClaudeAuthStatus,
843
+ extractLocally, extractWithClaudeCode, checkClaudeAuthStatus, EXTRACTABLE_SOURCES, EXTRACTABLE_SOURCE_IDS,
784
844
  parseLearnings, parseExtractionOutput, resolveClaudeBin,
785
845
  CATEGORIES, PRIVATE_CATEGORIES, RETIRED_CATEGORIES,
786
846
  EXTRACTION_PROMPT, buildExtractionPrompt, scoreExtractionEnabled,
@@ -24,6 +24,13 @@
24
24
  * - Count source: GET /account/pending/summary with the credentials from
25
25
  * ~/.auxilo/credentials.json; 3.5s abort so session start is never held
26
26
  * hostage by a slow network.
27
+ * - Standing-consent rollup (CLEAN-LANE-FLIP Phase A, SPEC3-C1 §4.3): ONE
28
+ * more count-only line when the LOCAL submitted-learnings log
29
+ * (~/.auxilo/extracted-index.jsonl, lib/extraction-index.js) holds rows
30
+ * stamped published_via = clean_lane_standing_consent since the last
31
+ * notice. Zero platform cost; reaches the human in their own client.
32
+ * Suppression and the last-notice stamp are shared with the held-count
33
+ * line (one state file, one 4h window).
27
34
  *
28
35
  * Self-contained (fs/path/os + global fetch) — ships in RUNNER_STACK to
29
36
  * ~/.auxilo/bin/scripts/ and must not require anything outside that layout
@@ -86,6 +93,61 @@ function renderNotice(count) {
86
93
  return `Auxilo: ${count} learning(s) held for your review — run auxilo_review (MCP) or \`npx auxilo review\`.`;
87
94
  }
88
95
 
96
+ /**
97
+ * Stamp lib/clean-lane.js writes on lane publishes (PUBLISHED_VIA_CLEAN_LANE).
98
+ * A literal here because this script is self-contained (RUNNER_STACK layout);
99
+ * test/clean-lane-phase-a.test.js pins the two byte-equal.
100
+ */
101
+ const PUBLISHED_VIA_CLEAN_LANE = 'clean_lane_standing_consent';
102
+
103
+ /** Local submitted-learnings log (lib/extraction-index.js DEFAULT_INDEX_PATH). */
104
+ function submittedIndexPath(homeDir) {
105
+ return path.join(auxiloDir(homeDir), 'extracted-index.jsonl');
106
+ }
107
+
108
+ /** Read the local log; absent/unreadable → []; malformed lines skipped. */
109
+ function readSubmittedRows(homeDir) {
110
+ let raw;
111
+ try {
112
+ raw = fs.readFileSync(submittedIndexPath(homeDir), 'utf-8');
113
+ } catch {
114
+ return [];
115
+ }
116
+ const rows = [];
117
+ for (const line of String(raw).split(/\r?\n/)) {
118
+ const trimmed = line.trim();
119
+ if (!trimmed) continue;
120
+ try {
121
+ const row = JSON.parse(trimmed);
122
+ if (row && typeof row === 'object') rows.push(row);
123
+ } catch { /* skip */ }
124
+ }
125
+ return rows;
126
+ }
127
+
128
+ /**
129
+ * Pure: how many local rows were published under standing consent AFTER
130
+ * `sinceIso` (the last notice stamp). No stamp → every such row counts.
131
+ */
132
+ function countStandingConsentPublishes(rows, sinceIso) {
133
+ const since = sinceIso ? Date.parse(sinceIso) : NaN;
134
+ let n = 0;
135
+ for (const row of rows || []) {
136
+ if (!row || row.published_via !== PUBLISHED_VIA_CLEAN_LANE) continue;
137
+ if (Number.isFinite(since)) {
138
+ const t = Date.parse(row.submitted_at);
139
+ if (!Number.isFinite(t) || t <= since) continue;
140
+ }
141
+ n += 1;
142
+ }
143
+ return n;
144
+ }
145
+
146
+ /** The rollup line. Count only — same contract as renderNotice. */
147
+ function renderStandingConsentNotice(count) {
148
+ return `Auxilo: ${count} learning(s) auto-published under your standing consent (retract within 7 days: npx auxilo review).`;
149
+ }
150
+
89
151
  /** Load credentials; null when absent/malformed/keyless. */
90
152
  function readCredentials(homeDir) {
91
153
  try {
@@ -125,18 +187,26 @@ async function main() {
125
187
  const creds = readCredentials(homeDir);
126
188
  if (!creds) return;
127
189
 
128
- if (!shouldNotify(readState(homeDir))) return;
190
+ const state = readState(homeDir);
191
+ if (!shouldNotify(state)) return;
129
192
 
130
193
  const count = await fetchPendingCount(creds);
131
- if (count == null || count <= 0) return;
194
+ const autoPublished = countStandingConsentPublishes(readSubmittedRows(homeDir), state.last_notice_at);
195
+
196
+ const lines = [];
197
+ if (count != null && count > 0) lines.push(renderNotice(count));
198
+ if (autoPublished > 0) lines.push(renderStandingConsentNotice(autoPublished));
199
+ if (lines.length === 0) return;
132
200
 
133
- process.stdout.write(renderNotice(count) + '\n');
201
+ process.stdout.write(lines.join('\n') + '\n');
134
202
  writeState(homeDir);
135
203
  }
136
204
 
137
205
  module.exports = {
138
206
  shouldNotify, renderNotice, readState, writeState, readCredentials,
139
207
  fetchPendingCount, NOTICE_SUPPRESSION_MS, FETCH_TIMEOUT_MS,
208
+ PUBLISHED_VIA_CLEAN_LANE, submittedIndexPath, readSubmittedRows,
209
+ countStandingConsentPublishes, renderStandingConsentNotice,
140
210
  };
141
211
 
142
212
  if (require.main === module) {