auxilo-mcp 0.9.10 → 0.9.11

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
@@ -860,6 +860,198 @@ async function cmdReview(flags) {
860
860
  console.log(`\nReview complete: approved ${approved}, kept private ${keptPrivate}, rejected ${rejected}, skipped ${skipped} of ${ordered.length}.`);
861
861
  }
862
862
 
863
+ // ─── auxilo clean-lane (SPEC3-C1 standing consent; CLEAN-LANE-FLIP Phase A) ──
864
+ //
865
+ // GOV-3 (ratified language, Gate-A 2026-09-05): `grant` runs ONLY on a TTY and
866
+ // requires the human to TYPE the affirmation sentence verbatim — no --yes, no
867
+ // flag. The TTY gate + verbatim affirmation prevent ACCIDENTAL enrollment and
868
+ // create a hash-chained record of a DELIBERATE act by the credential holder.
869
+ // That record is EVIDENTIARY, not preventive: it is not a defense against a
870
+ // holder of the account's contribute-scoped key, who can reach the same routes
871
+ // directly. `status` / `revoke` are non-interactive.
872
+ // While the server flag is off the routes answer the catch-all 404 and every
873
+ // subcommand prints "not yet available" (exit 0).
874
+ //
875
+ // The sentence below MIRRORS lib/clean-lane.js CLEAN_LANE_AFFIRMATION. It is
876
+ // a literal here only because lib/clean-lane.js is server-side and not in the
877
+ // published package's files[]; test/clean-lane-phase-a.test.js pins the two
878
+ // byte-equal. The consent VERSION is never a client literal — it always comes
879
+ // from GET /account/clean-lane (consent_version_current).
880
+ const CLEAN_LANE_AFFIRMATION = 'I understand and choose auto-publish for qualifying extracted learnings.';
881
+ const CLEAN_LANE_UNAVAILABLE = 'Auto-publish for clean learnings is not yet available on this account.';
882
+ const CLEAN_LANE_MIN_QUALITY_MIN = 14;
883
+ const CLEAN_LANE_MIN_QUALITY_MAX = 20;
884
+ const CLEAN_LANE_MIN_QUALITY_DEFAULT = 16;
885
+
886
+ const CLEAN_LANE_EXPLAINER = `
887
+ Auto-publish clean learnings
888
+
889
+ When this is on, a learning is published without waiting for your review
890
+ only when all three hold: it was extracted by your own model, every server
891
+ screen came back clean, and its quality score is at or above the threshold
892
+ you set.
893
+
894
+ What is never auto-published: your first public learning (it waits for
895
+ operator review), anything a screen flags, anything below your threshold,
896
+ and anything after an auto-freeze. Every auto-published learning can be
897
+ retracted for 7 days (\`npx auxilo review\` or your dashboard).
898
+ `;
899
+
900
+ async function cleanLaneRequest({ apiKey, baseUrl, method, route, body }) {
901
+ const url = `${String(baseUrl).replace(/\/+$/, '')}${route}`;
902
+ const headers = { 'X-API-Key': apiKey };
903
+ if (body !== undefined) headers['Content-Type'] = 'application/json';
904
+ const res = await fetch(url, {
905
+ method,
906
+ headers,
907
+ ...(body !== undefined && { body: JSON.stringify(body) }),
908
+ });
909
+ let data = {};
910
+ try { data = await res.json(); } catch { /* non-JSON body */ }
911
+ return { status: res.status, ok: res.ok, data: data || {} };
912
+ }
913
+
914
+ function printCleanLaneStatus(data) {
915
+ const active = data.clean_lane_active === true;
916
+ console.log(`Auto-publish clean learnings: ${active ? 'ON' : 'OFF'}`);
917
+ if (active) {
918
+ console.log(` since: ${data.last_action_at || 'unknown'}`);
919
+ console.log(` quality at least: ${data.min_auto_publish_quality}`);
920
+ console.log(` consent version: ${data.consent_version_recorded || data.consent_version_current}`);
921
+ } else if (data.last_action === 'freeze') {
922
+ console.log(` FROZEN: ${data.freeze_reason || 'unknown reason'} (${data.last_action_at || 'unknown time'})`);
923
+ console.log(' Nothing auto-publishes until you grant consent again: npx auxilo clean-lane grant');
924
+ } else if (data.last_action === 'revoke') {
925
+ console.log(` revoked at: ${data.last_action_at || 'unknown'}`);
926
+ } else if (data.last_action === 'grant') {
927
+ 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.`);
928
+ }
929
+ console.log(` current consent version: ${data.consent_version_current}`);
930
+ }
931
+
932
+ async function cmdCleanLane(flags) {
933
+ const sub = process.argv[3];
934
+ if (!['status', 'grant', 'revoke'].includes(sub)) {
935
+ if (sub) console.error(`Unknown clean-lane subcommand: ${sub}`);
936
+ usage('clean-lane');
937
+ process.exit(sub ? 1 : 0);
938
+ }
939
+
940
+ // The TTY gate runs BEFORE credentials and BEFORE any network call: a
941
+ // piped or scripted stdin can never reach the grant.
942
+ if (sub === 'grant' && !process.stdin.isTTY) {
943
+ 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.');
944
+ process.exit(1);
945
+ }
946
+
947
+ const creds = installer.readCredentials(HOME);
948
+ if (!creds || !creds.api_key) {
949
+ console.error('Not logged in. Run `npx auxilo setup` first.');
950
+ process.exit(1);
951
+ }
952
+ const baseUrl = resolveBaseUrl(flags);
953
+ const apiKey = creds.api_key;
954
+
955
+ let status;
956
+ try {
957
+ status = await cleanLaneRequest({ apiKey, baseUrl, method: 'GET', route: '/account/clean-lane' });
958
+ } catch (err) {
959
+ console.error(`Could not reach the server: ${err.message}`);
960
+ process.exit(1);
961
+ }
962
+ if (status.status === 404) {
963
+ console.log(CLEAN_LANE_UNAVAILABLE);
964
+ return;
965
+ }
966
+ if (!status.ok) {
967
+ console.error(`Could not read auto-publish status (HTTP ${status.status}): ${status.data.error || 'unknown error'}`);
968
+ process.exit(1);
969
+ }
970
+
971
+ if (sub === 'status') {
972
+ printCleanLaneStatus(status.data);
973
+ return;
974
+ }
975
+
976
+ if (sub === 'revoke') {
977
+ let res;
978
+ try {
979
+ res = await cleanLaneRequest({ apiKey, baseUrl, method: 'POST', route: '/account/clean-lane/revoke', body: {} });
980
+ } catch (err) {
981
+ console.error(`Could not reach the server: ${err.message}`);
982
+ process.exit(1);
983
+ }
984
+ if (res.status === 404) { console.log(CLEAN_LANE_UNAVAILABLE); return; }
985
+ if (!res.ok) {
986
+ console.error(`Revoke failed (HTTP ${res.status}): ${res.data.error || 'unknown error'}`);
987
+ process.exit(1);
988
+ }
989
+ console.log(res.data.message || 'Auto-publish is now OFF.');
990
+ return;
991
+ }
992
+
993
+ // ── grant: explainer → threshold → the sentence, typed verbatim ──────────
994
+ console.log(CLEAN_LANE_EXPLAINER);
995
+ if (status.data.clean_lane_active === true) {
996
+ 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`);
997
+ } else if (status.data.last_action === 'freeze') {
998
+ console.log(`Auto-publish is FROZEN: ${status.data.freeze_reason || 'unknown reason'}. Granting again re-activates it.\n`);
999
+ }
1000
+
1001
+ let minQuality = CLEAN_LANE_MIN_QUALITY_DEFAULT;
1002
+ for (;;) {
1003
+ 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}]: `);
1004
+ if (answer === '') break;
1005
+ const n = parseInt(answer, 10);
1006
+ if (Number.isInteger(n) && String(n) === answer && n >= CLEAN_LANE_MIN_QUALITY_MIN && n <= CLEAN_LANE_MIN_QUALITY_MAX) {
1007
+ minQuality = n;
1008
+ break;
1009
+ }
1010
+ if (readlineEnded) { console.log('Aborted. Nothing changed.'); return; }
1011
+ console.log(`Enter a whole number from ${CLEAN_LANE_MIN_QUALITY_MIN} to ${CLEAN_LANE_MIN_QUALITY_MAX}.`);
1012
+ }
1013
+
1014
+ console.log('\nTo turn on auto-publish, type this sentence exactly as written, then press Enter:');
1015
+ console.log(`\n ${CLEAN_LANE_AFFIRMATION}\n`);
1016
+ const typed = await ask('> ');
1017
+ if (typed !== CLEAN_LANE_AFFIRMATION) {
1018
+ console.log('The sentence did not match. Aborted. Nothing changed.');
1019
+ return;
1020
+ }
1021
+
1022
+ let res;
1023
+ try {
1024
+ res = await cleanLaneRequest({
1025
+ apiKey,
1026
+ baseUrl,
1027
+ method: 'POST',
1028
+ route: '/account/clean-lane/grant',
1029
+ body: {
1030
+ consent_version: status.data.consent_version_current,
1031
+ agree: true,
1032
+ affirmation: typed, // what the human typed, transmitted verbatim
1033
+ min_auto_publish_quality: minQuality,
1034
+ },
1035
+ });
1036
+ } catch (err) {
1037
+ console.error(`Could not reach the server: ${err.message}`);
1038
+ process.exit(1);
1039
+ }
1040
+ if (res.status === 404) { console.log(CLEAN_LANE_UNAVAILABLE); return; }
1041
+ if (res.status === 409) {
1042
+ console.error('The consent version changed on the server while you were reading. Run `npx auxilo clean-lane grant` again.');
1043
+ process.exit(1);
1044
+ }
1045
+ if (!res.ok) {
1046
+ console.error(`Grant failed (HTTP ${res.status}): ${res.data.error || 'unknown error'}`);
1047
+ process.exit(1);
1048
+ }
1049
+ console.log(`\n${res.data.message || 'Auto-publish is now ON.'}`);
1050
+ console.log(` quality at least: ${res.data.min_auto_publish_quality}`);
1051
+ console.log(` consent version: ${res.data.consent_version}`);
1052
+ console.log(' Turn it off any time: npx auxilo clean-lane revoke');
1053
+ }
1054
+
863
1055
  // ─── Entry point ────────────────────────────────────────────────────────────
864
1056
 
865
1057
  function usage(command) {
@@ -903,6 +1095,18 @@ them private or sanitize-promote a corrected replacement.`,
903
1095
  disable: `Usage: auxilo disable [--base-url <url>]
904
1096
 
905
1097
  Disable background extraction locally and optionally revoke server consent.`,
1098
+ 'clean-lane': `Usage: auxilo clean-lane <status|grant|revoke> [--base-url <url>]
1099
+
1100
+ Auto-publish clean learnings (standing consent). While the feature is not yet
1101
+ available on your account every subcommand says so and changes nothing.
1102
+
1103
+ status Show whether auto-publish is on, the quality threshold, and the
1104
+ consent version.
1105
+ grant Turn it on. Interactive ONLY: you choose the threshold and then
1106
+ TYPE the consent sentence exactly. No flag or piped input can do
1107
+ this for you.
1108
+ revoke Turn it off (one step, no confirmation). Already-published
1109
+ learnings keep their 7-day retraction window.`,
906
1110
  };
907
1111
  if (command && blocks[command]) {
908
1112
  console.log(`\n${blocks[command]}\n`);
@@ -951,6 +1155,9 @@ Commands:
951
1155
  skips that step.
952
1156
  disable Turn off background extraction (local kill-switch; optional
953
1157
  server-side consent revoke).
1158
+ clean-lane <status|grant|revoke>
1159
+ Auto-publish clean learnings (standing consent). grant is
1160
+ interactive only: you type the consent sentence yourself.
954
1161
 
955
1162
  Docs: https://auxilo.io · API: ${installer.DEFAULT_BASE_URL}
956
1163
  `);
@@ -959,7 +1166,7 @@ Docs: https://auxilo.io · API: ${installer.DEFAULT_BASE_URL}
959
1166
  async function main() {
960
1167
  const cmd = process.argv[2];
961
1168
  const subcommandHelp = ['help', '--help', '-h'].includes(process.argv[3]);
962
- if (['setup', 'init', 'status', 'review', 'disable'].includes(cmd) && subcommandHelp) {
1169
+ if (['setup', 'init', 'status', 'review', 'disable', 'clean-lane'].includes(cmd) && subcommandHelp) {
963
1170
  return usage(cmd);
964
1171
  }
965
1172
  const flags = parseFlags(process.argv);
@@ -969,6 +1176,7 @@ async function main() {
969
1176
  case 'status': return cmdStatus(flags);
970
1177
  case 'review': return cmdReview(flags);
971
1178
  case 'disable': return cmdDisable(flags);
1179
+ case 'clean-lane': return cmdCleanLane(flags);
972
1180
  case 'help': case '--help': case '-h': case undefined: return usage();
973
1181
  default:
974
1182
  console.error(`Unknown command: ${cmd}`);
@@ -1002,4 +1210,6 @@ module.exports = {
1002
1210
  printSummaryTable,
1003
1211
  usage,
1004
1212
  run,
1213
+ CLEAN_LANE_AFFIRMATION,
1214
+ CLEAN_LANE_UNAVAILABLE,
1005
1215
  };
@@ -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/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.11' },
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.11",
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",
@@ -662,11 +662,55 @@ async function runAnchoredJudge(candidates, indexState, opts = {}) {
662
662
 
663
663
  /**
664
664
  * 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.
665
+ *
666
+ * EXT-GATE: every capture source id runs the client-local extractor. The
667
+ * extractor is transcript-text based (buildExtractionPrompt carries no
668
+ * per-source branch), so nothing here depends on WHICH client captured.
669
+ * Unknown ids still short-circuit: a `--source` value the registry does not
670
+ * know is a misconfigured shim, not a client, and the skip message below is
671
+ * matched by runner.js and test/uc6-codex-capture.test.js — do not change it.
672
+ *
673
+ * The list is static on purpose: lib/installer.js is not in RUNNER_STACK, so
674
+ * this file cannot enumerate the registry at runtime. The closure test
675
+ * (test/ext-gate-closure.test.js) derives the expected set from the two live
676
+ * enumerations — scripts/sources/*.js adapter ids ∪ installer hook-client
677
+ * source ids — and is the authority; a new adapter or hook client that is not
678
+ * added here turns CI red.
668
679
  */
669
- const EXTRACTABLE_SOURCES = new Set(['claude-code', 'codex-cli']);
680
+ const EXTRACTABLE_SOURCE_IDS = Object.freeze([
681
+ 'antigravity',
682
+ 'claude-code',
683
+ 'cline',
684
+ 'codex-cli',
685
+ 'continue',
686
+ 'copilot',
687
+ 'cursor',
688
+ 'factory',
689
+ 'gemini-cli',
690
+ 'openclaw',
691
+ 'roo-code',
692
+ 'windsurf',
693
+ ]);
694
+
695
+ // Gate-A 2026-09-05: the exported set is IMMUTABLE. It stays a real Set (same
696
+ // name, `.has()` / iteration / `instanceof Set` unchanged) but its own
697
+ // add/delete/clear shadow the prototype's and throw, so no importer can widen
698
+ // or narrow the allowlist at runtime — the frozen id array above is the only
699
+ // source and the closure test is the only authority.
700
+ function immutableSet(ids) {
701
+ const set = new Set(ids);
702
+ const refuse = (op) => function () {
703
+ throw new TypeError(`EXTRACTABLE_SOURCES is immutable (${op} refused)`);
704
+ };
705
+ Object.defineProperties(set, {
706
+ add: { value: refuse('add'), writable: false, configurable: false, enumerable: false },
707
+ delete: { value: refuse('delete'), writable: false, configurable: false, enumerable: false },
708
+ clear: { value: refuse('clear'), writable: false, configurable: false, enumerable: false },
709
+ });
710
+ return Object.freeze(set);
711
+ }
712
+
713
+ const EXTRACTABLE_SOURCES = immutableSet(EXTRACTABLE_SOURCE_IDS);
670
714
 
671
715
  async function extractLocally(transcript, sourceType, opts = {}) {
672
716
  if (sourceType && !EXTRACTABLE_SOURCES.has(sourceType)) {
@@ -780,7 +824,7 @@ async function extractLocally(transcript, sourceType, opts = {}) {
780
824
  }
781
825
 
782
826
  module.exports = {
783
- extractLocally, extractWithClaudeCode, checkClaudeAuthStatus,
827
+ extractLocally, extractWithClaudeCode, checkClaudeAuthStatus, EXTRACTABLE_SOURCES, EXTRACTABLE_SOURCE_IDS,
784
828
  parseLearnings, parseExtractionOutput, resolveClaudeBin,
785
829
  CATEGORIES, PRIVATE_CATEGORIES, RETIRED_CATEGORIES,
786
830
  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) {