auxilo-mcp 0.9.4 → 0.9.7

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.
@@ -0,0 +1,462 @@
1
+ 'use strict';
2
+
3
+ /**
4
+ * Client-side extraction memory for SPEC3-F2.
5
+ *
6
+ * The index is local, append-only, and advisory. Every read or write failure
7
+ * fails open: extraction/submission continues without prompt memory or lexical
8
+ * filtering. The only shared detector is lib/similarity.js.
9
+ */
10
+
11
+ const crypto = require('node:crypto');
12
+ const fs = require('node:fs');
13
+ const os = require('node:os');
14
+ const path = require('node:path');
15
+ const { findNearDuplicate, scoreChannels } = require('./similarity.js');
16
+
17
+ const DEFAULT_INDEX_PATH = path.join(os.homedir(), '.auxilo', 'extracted-index.jsonl');
18
+ const HYDRATION_PAGE_SIZE = 500;
19
+ const PROMPT_MEMORY_MAX_TOKENS = 1200;
20
+ const PROMPT_MEMORY_MAX_ROWS = 40;
21
+ const VALID_STATUSES = new Set(['approved', 'rejected', 'pending_review']);
22
+
23
+ const MEMORY_INSTRUCTIONS = `PREVIOUSLY CAPTURED LESSONS
24
+ The following lessons were already submitted by this account.
25
+ A candidate that re-states any listed lesson — reworded, or a different facet of the same operational insight — is DROPPED, not relabeled and not "improved."
26
+ Extract new facts about a listed lesson ONLY when the new fact would change what another agent does: it must create a behavioral difference, not merely a wording difference.
27
+ Never discard a memory match invisibly. Put the complete candidate plus the matched lesson id/title in dedup_drops so the local runner can write its audit trail.`;
28
+
29
+ const CATEGORY_HINTS = Object.freeze({
30
+ 'data-processing': ['data', 'parse', 'parser', 'json', 'csv', 'transform', 'pipeline'],
31
+ 'web-interaction': ['api', 'http', 'browser', 'gmail', 'webhook', 'request', 'response'],
32
+ 'code-execution': ['code', 'script', 'python', 'node', 'shell', 'command', 'runtime'],
33
+ 'storage-state': ['state', 'queue', 'database', 'storage', 'file', 'commit', 'cache'],
34
+ 'payment-financial': ['payment', 'stripe', 'usdc', 'wallet', 'settlement', 'withdraw'],
35
+ monitoring: ['monitor', 'alert', 'health', 'log', 'metric', 'observability', 'incident'],
36
+ });
37
+
38
+ function loud(log, message) {
39
+ try {
40
+ (log || console.error)(`[extraction-index] ${message}`);
41
+ } catch {
42
+ // A broken logger must not turn advisory dedup into a submission blocker.
43
+ }
44
+ }
45
+
46
+ function normalizeTags(tags) {
47
+ return Array.isArray(tags) ? tags.slice(0, 8).map(String) : [];
48
+ }
49
+
50
+ function validIndexRow(row) {
51
+ return Boolean(
52
+ row &&
53
+ typeof row === 'object' &&
54
+ typeof row.title === 'string' &&
55
+ row.title.trim() &&
56
+ typeof row.category === 'string'
57
+ );
58
+ }
59
+
60
+ /**
61
+ * Read an append-only JSONL index. A malformed line is skipped and logged, but
62
+ * marks the read unusable so extraction fails open instead of trusting a
63
+ * partially corrupt memory/filter corpus.
64
+ */
65
+ function readExtractionIndex(opts = {}) {
66
+ const indexPath = opts.indexPath || DEFAULT_INDEX_PATH;
67
+ const fsImpl = opts.fsImpl || fs;
68
+ let exists;
69
+ try {
70
+ exists = fsImpl.existsSync(indexPath);
71
+ } catch (error) {
72
+ loud(opts.log, `index existence check failed; dedup disabled for this run: ${error.message}`);
73
+ return { state: 'unreadable', usable: false, rows: [], bad_lines: 0 };
74
+ }
75
+ if (!exists) return { state: 'missing', usable: false, rows: [], bad_lines: 0 };
76
+
77
+ let raw;
78
+ try {
79
+ raw = fsImpl.readFileSync(indexPath, 'utf8');
80
+ } catch (error) {
81
+ loud(opts.log, `index read failed; dedup disabled for this run: ${error.message}`);
82
+ return { state: 'unreadable', usable: false, rows: [], bad_lines: 0 };
83
+ }
84
+
85
+ const rows = [];
86
+ let badLines = 0;
87
+ const lines = String(raw).split(/\r?\n/);
88
+ for (let index = 0; index < lines.length; index += 1) {
89
+ const line = lines[index].trim();
90
+ if (!line) continue;
91
+ try {
92
+ const row = JSON.parse(line);
93
+ if (!validIndexRow(row)) throw new Error('missing title/category');
94
+ rows.push(row);
95
+ } catch (error) {
96
+ badLines += 1;
97
+ loud(opts.log, `skipping corrupt line ${index + 1} in ${indexPath}: ${error.message}`);
98
+ }
99
+ }
100
+
101
+ if (badLines > 0) {
102
+ loud(opts.log, `index contains ${badLines} corrupt line(s); prompt memory and lexical dedup are disabled for this run`);
103
+ return { state: 'corrupt', usable: false, rows, bad_lines: badLines };
104
+ }
105
+ return { state: 'ready', usable: true, rows, bad_lines: 0 };
106
+ }
107
+
108
+ function appendJsonlRows(rows, opts = {}) {
109
+ const indexPath = opts.indexPath || DEFAULT_INDEX_PATH;
110
+ const fsImpl = opts.fsImpl || fs;
111
+ try {
112
+ fsImpl.mkdirSync(path.dirname(indexPath), { recursive: true, mode: 0o700 });
113
+ if (!rows.length) {
114
+ const fd = fsImpl.openSync(indexPath, 'a', 0o600);
115
+ fsImpl.closeSync(fd);
116
+ return true;
117
+ }
118
+ const payload = rows.map((row) => JSON.stringify(row)).join('\n') + '\n';
119
+ fsImpl.appendFileSync(indexPath, payload, { encoding: 'utf8', mode: 0o600 });
120
+ return true;
121
+ } catch (error) {
122
+ loud(opts.log, `index append failed; extraction/submission continues: ${error.message}`);
123
+ return false;
124
+ }
125
+ }
126
+
127
+ function localIndexRow(learning, response = {}, opts = {}) {
128
+ const nowValue = typeof opts.now === 'function'
129
+ ? opts.now()
130
+ : (opts.now || new Date().toISOString());
131
+ const body = String(learning.body || '');
132
+ return {
133
+ title: String(learning.title || ''),
134
+ category: String(learning.category || ''),
135
+ tags: normalizeTags(learning.tags),
136
+ body_hash: crypto.createHash('sha256').update(body).digest('hex'),
137
+ body,
138
+ submitted_at: String(nowValue),
139
+ ...(typeof response.id === 'string' && response.id && { learning_id: response.id }),
140
+ ...(typeof response.status === 'string' && VALID_STATUSES.has(response.status) && {
141
+ status: response.status,
142
+ }),
143
+ };
144
+ }
145
+
146
+ /**
147
+ * Append only after POST /learn returns 2xx. A network/4xx failure remains
148
+ * retryable and is not allowed to poison the local dedup memory.
149
+ */
150
+ function appendSubmittedLearning(learning, response = {}, opts = {}) {
151
+ if (!learning || typeof learning !== 'object') return false;
152
+ return appendJsonlRows([localIndexRow(learning, response, opts)], opts);
153
+ }
154
+
155
+ function hydratedIndexRow(learning) {
156
+ return {
157
+ title: String(learning.title || ''),
158
+ category: String(learning.category || ''),
159
+ tags: normalizeTags(learning.tags),
160
+ body_hash: null,
161
+ submitted_at: learning.created_at || null,
162
+ ...(typeof learning.id === 'string' && learning.id && { learning_id: learning.id }),
163
+ ...(typeof learning.status === 'string' && VALID_STATUSES.has(learning.status) && {
164
+ status: learning.status,
165
+ }),
166
+ };
167
+ }
168
+
169
+ /**
170
+ * Hydrate only a missing index from the caller's own metadata endpoint. The
171
+ * endpoint intentionally carries no body, so hydrated rows inform the prompt
172
+ * only; lexical filtering is limited to locally appended rows with a body.
173
+ */
174
+ async function hydrateExtractionIndex(opts = {}) {
175
+ const indexPath = opts.indexPath || DEFAULT_INDEX_PATH;
176
+ const fsImpl = opts.fsImpl || fs;
177
+ try {
178
+ if (fsImpl.existsSync(indexPath)) return { hydrated: false, reason: 'not_fresh' };
179
+ } catch (error) {
180
+ loud(opts.log, `fresh-index check failed; hydration skipped: ${error.message}`);
181
+ return { hydrated: false, reason: 'unreadable' };
182
+ }
183
+
184
+ const fetchImpl = opts.fetchImpl || globalThis.fetch;
185
+ const baseUrl = String(opts.baseUrl || '').replace(/\/+$/, '');
186
+ const apiKey = opts.apiKey;
187
+ if (typeof fetchImpl !== 'function' || !baseUrl || !apiKey) {
188
+ loud(opts.log, 'fresh-machine hydration unavailable (missing fetch, base URL, or API key); extracting without memory');
189
+ return { hydrated: false, reason: 'unavailable' };
190
+ }
191
+
192
+ const hydratedRows = [];
193
+ let offset = 0;
194
+ try {
195
+ for (let page = 0; page < 10000; page += 1) {
196
+ const response = await fetchImpl(
197
+ `${baseUrl}/account/learnings?limit=${HYDRATION_PAGE_SIZE}&offset=${offset}`,
198
+ { headers: { 'X-API-Key': apiKey } }
199
+ );
200
+ if (!response || !response.ok) {
201
+ const status = response && Number.isFinite(response.status) ? response.status : 'unknown';
202
+ throw new Error(`GET /account/learnings returned ${status}`);
203
+ }
204
+ const payload = await response.json();
205
+ if (!payload || !Array.isArray(payload.learnings)) {
206
+ throw new Error('GET /account/learnings returned an invalid payload');
207
+ }
208
+ for (const row of payload.learnings) {
209
+ const hydrated = hydratedIndexRow(row);
210
+ if (!validIndexRow(hydrated)) {
211
+ loud(opts.log, 'skipping malformed hydration row from GET /account/learnings');
212
+ continue;
213
+ }
214
+ hydratedRows.push(hydrated);
215
+ }
216
+ offset += payload.learnings.length;
217
+ const total = Number.isFinite(payload.total) ? payload.total : null;
218
+ if (payload.learnings.length < HYDRATION_PAGE_SIZE ||
219
+ (total !== null && offset >= total)) break;
220
+ if (payload.learnings.length === 0) break;
221
+ }
222
+ } catch (error) {
223
+ loud(opts.log, `fresh-machine hydration failed; extracting without memory: ${error.message}`);
224
+ return { hydrated: false, reason: 'request_failed' };
225
+ }
226
+
227
+ if (!appendJsonlRows(hydratedRows, { indexPath, fsImpl, log: opts.log })) {
228
+ return { hydrated: false, reason: 'write_failed' };
229
+ }
230
+ loud(opts.log, `hydrated ${hydratedRows.length} own learning metadata row(s) into ${indexPath}`);
231
+ return { hydrated: true, count: hydratedRows.length };
232
+ }
233
+
234
+ async function loadIndexForExtraction(opts = {}) {
235
+ const first = readExtractionIndex(opts);
236
+ if (first.state !== 'missing') return first;
237
+ const hydration = await hydrateExtractionIndex(opts);
238
+ if (!hydration.hydrated) return first;
239
+ return readExtractionIndex(opts);
240
+ }
241
+
242
+ function estimatePromptTokens(text) {
243
+ return Math.ceil(Buffer.byteLength(String(text || ''), 'utf8') / 4);
244
+ }
245
+
246
+ function inferCategoryHints(transcript) {
247
+ const text = String(transcript || '').toLowerCase();
248
+ const scores = [];
249
+ for (const [category, words] of Object.entries(CATEGORY_HINTS)) {
250
+ let score = 0;
251
+ for (const word of words) {
252
+ const matches = text.match(new RegExp(`\\b${word.replace(/[.*+?^${}()|[\]\\]/g, '\\$&')}\\b`, 'g'));
253
+ score += matches ? matches.length : 0;
254
+ }
255
+ if (score > 0) scores.push({ category, score });
256
+ }
257
+ scores.sort((a, b) => b.score - a.score || a.category.localeCompare(b.category));
258
+ if (!scores.length) return [];
259
+ const top = scores[0].score;
260
+ return scores.filter((entry) => entry.score === top).map((entry) => entry.category);
261
+ }
262
+
263
+ function submittedTime(row) {
264
+ const parsed = Date.parse(row.submitted_at || row.created_at || '');
265
+ return Number.isFinite(parsed) ? parsed : 0;
266
+ }
267
+
268
+ function oneLineGist(row) {
269
+ if (typeof row.body === 'string' && row.body.trim()) {
270
+ const compact = row.body.replace(/\s+/g, ' ').trim();
271
+ const sentence = compact.match(/^(.{1,220}?[.!?])(?:\s|$)/);
272
+ return (sentence ? sentence[1] : compact.slice(0, 220)).trim();
273
+ }
274
+ const tags = normalizeTags(row.tags).filter(Boolean);
275
+ if (tags.length) return `Previously captured ${row.category} lesson tagged ${tags.join(', ')}.`;
276
+ return `Previously captured ${row.category} lesson.`;
277
+ }
278
+
279
+ function indexRowId(row, index = 0) {
280
+ return String(
281
+ row.learning_id ||
282
+ row.id ||
283
+ row.body_hash ||
284
+ `local-index-${index}`
285
+ );
286
+ }
287
+
288
+ function promptMemoryLine(row, index) {
289
+ const title = String(row.title || '').replace(/\s+/g, ' ').trim().slice(0, 180);
290
+ const gist = oneLineGist(row).slice(0, 240);
291
+ return `- [id:${indexRowId(row, index)} category:${row.category}] ${title} — ${gist}`;
292
+ }
293
+
294
+ /**
295
+ * Budget strategy: inferred same-category rows first, then most recent rows,
296
+ * with deterministic title/ID tie-breaking. The instruction header and rows
297
+ * together may never exceed maxTokens.
298
+ */
299
+ function buildPromptMemory(rows, opts = {}) {
300
+ const maxTokens = Number.isFinite(opts.maxTokens)
301
+ ? Math.max(0, Math.floor(opts.maxTokens))
302
+ : PROMPT_MEMORY_MAX_TOKENS;
303
+ const maxRows = Number.isFinite(opts.maxRows)
304
+ ? Math.max(0, Math.floor(opts.maxRows))
305
+ : PROMPT_MEMORY_MAX_ROWS;
306
+ const hints = new Set(opts.categoryHints || inferCategoryHints(opts.transcript));
307
+ const eligible = (Array.isArray(rows) ? rows : [])
308
+ .filter(validIndexRow)
309
+ .slice()
310
+ .sort((a, b) => {
311
+ const aMatch = hints.has(a.category) ? 1 : 0;
312
+ const bMatch = hints.has(b.category) ? 1 : 0;
313
+ return bMatch - aMatch ||
314
+ submittedTime(b) - submittedTime(a) ||
315
+ String(a.title).localeCompare(String(b.title)) ||
316
+ String(a.learning_id || '').localeCompare(String(b.learning_id || ''));
317
+ });
318
+
319
+ if (!eligible.length || maxRows === 0 || estimatePromptTokens(MEMORY_INSTRUCTIONS) > maxTokens) {
320
+ return {
321
+ section: '',
322
+ estimated_tokens: 0,
323
+ included_count: 0,
324
+ included_rows: [],
325
+ category_hints: [...hints],
326
+ };
327
+ }
328
+
329
+ const lines = [];
330
+ const includedRows = [];
331
+ for (let index = 0; index < eligible.length; index += 1) {
332
+ const row = eligible[index];
333
+ if (lines.length >= maxRows) break;
334
+ const candidateLines = [...lines, promptMemoryLine(row, index)];
335
+ const candidate = `${MEMORY_INSTRUCTIONS}\n${candidateLines.join('\n')}\n\n`;
336
+ if (estimatePromptTokens(candidate) > maxTokens) break;
337
+ lines.push(candidateLines[candidateLines.length - 1]);
338
+ includedRows.push({
339
+ id: indexRowId(row, index),
340
+ title: row.title,
341
+ category: row.category,
342
+ });
343
+ }
344
+ if (!lines.length) {
345
+ return {
346
+ section: '',
347
+ estimated_tokens: 0,
348
+ included_count: 0,
349
+ included_rows: [],
350
+ category_hints: [...hints],
351
+ };
352
+ }
353
+ const section = `${MEMORY_INSTRUCTIONS}\n${lines.join('\n')}\n\n`;
354
+ return {
355
+ section,
356
+ estimated_tokens: estimatePromptTokens(section),
357
+ included_count: lines.length,
358
+ included_rows: includedRows,
359
+ category_hints: [...hints],
360
+ };
361
+ }
362
+
363
+ /**
364
+ * Rank index rows against one extracted candidate with the shared F1 scoring
365
+ * implementation. No flag threshold is applied: the score is used only for a
366
+ * deterministic candidate-anchored top-K list for the local judge.
367
+ */
368
+ function rankIndexRowsForCandidate(candidate, rows, opts = {}) {
369
+ const topK = Number.isFinite(opts.topK)
370
+ ? Math.max(0, Math.floor(opts.topK))
371
+ : 10;
372
+ if (!candidate || topK === 0) return [];
373
+ return (Array.isArray(rows) ? rows : [])
374
+ .filter(validIndexRow)
375
+ .map((row, index) => {
376
+ const ranked = {
377
+ id: indexRowId(row, index),
378
+ title: row.title,
379
+ category: row.category,
380
+ body: typeof row.body === 'string' ? row.body : '',
381
+ };
382
+ const channels = scoreChannels(candidate, ranked);
383
+ return {
384
+ id: ranked.id,
385
+ title: ranked.title,
386
+ category: ranked.category,
387
+ similarity: channels.composite,
388
+ channels,
389
+ };
390
+ })
391
+ .sort((a, b) =>
392
+ b.similarity - a.similarity ||
393
+ a.id.localeCompare(b.id) ||
394
+ String(a.title).localeCompare(String(b.title))
395
+ )
396
+ .slice(0, topK);
397
+ }
398
+
399
+ /**
400
+ * Filter candidates against locally appended rows only. Hydrated metadata has
401
+ * no body and therefore never participates in lexical detection.
402
+ */
403
+ function filterIndexedNearDuplicates(candidates, indexState, opts = {}) {
404
+ const input = Array.isArray(candidates) ? candidates : [];
405
+ if (!indexState || !indexState.usable) {
406
+ return { kept: input.slice(), dropped: [], disabled: true };
407
+ }
408
+ const localRows = indexState.rows
409
+ .filter((row) => typeof row.body === 'string' && row.body.trim())
410
+ .map((row, index) => ({
411
+ id: row.learning_id || `local-index-${index}`,
412
+ title: row.title,
413
+ body: row.body,
414
+ category: row.category,
415
+ status: row.status || null,
416
+ }));
417
+ if (!localRows.length) return { kept: input.slice(), dropped: [], disabled: false };
418
+
419
+ try {
420
+ const kept = [];
421
+ const dropped = [];
422
+ for (const candidate of input) {
423
+ const result = findNearDuplicate(candidate, localRows);
424
+ if (result.verdict === 'flag') {
425
+ const matchedRow = localRows.find((row) => row.id === result.match.id);
426
+ dropped.push({
427
+ candidate,
428
+ match: {
429
+ ...result.match,
430
+ title: matchedRow ? matchedRow.title : null,
431
+ },
432
+ });
433
+ } else {
434
+ kept.push(candidate);
435
+ }
436
+ }
437
+ return { kept, dropped, disabled: false };
438
+ } catch (error) {
439
+ loud(opts.log, `lexical dedup failed; keeping all extracted candidates: ${error.message}`);
440
+ return { kept: input.slice(), dropped: [], disabled: true };
441
+ }
442
+ }
443
+
444
+ module.exports = {
445
+ DEFAULT_INDEX_PATH,
446
+ HYDRATION_PAGE_SIZE,
447
+ PROMPT_MEMORY_MAX_TOKENS,
448
+ PROMPT_MEMORY_MAX_ROWS,
449
+ MEMORY_INSTRUCTIONS,
450
+ readExtractionIndex,
451
+ appendSubmittedLearning,
452
+ hydrateExtractionIndex,
453
+ loadIndexForExtraction,
454
+ buildPromptMemory,
455
+ estimatePromptTokens,
456
+ inferCategoryHints,
457
+ filterIndexedNearDuplicates,
458
+ rankIndexRowsForCandidate,
459
+ indexRowId,
460
+ localIndexRow,
461
+ hydratedIndexRow,
462
+ };
@@ -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
 
@@ -73,6 +74,10 @@ const RUNNER_STACK = Object.freeze([
73
74
  ['scripts/review-notice.js', 'scripts/review-notice.js', 0o755],
74
75
  ...sourceAdapterRows(),
75
76
  ['lib/sensitivity-filter.js', 'lib/sensitivity-filter.js', 0o644],
77
+ ['lib/extraction-index.js', 'lib/extraction-index.js', 0o644],
78
+ ['lib/similarity.js', 'lib/similarity.js', 0o644],
79
+ ['lib/hook-status.js', 'lib/hook-status.js', 0o644],
80
+ ['config/near-duplicate.json', 'config/near-duplicate.json', 0o644],
76
81
  ]);
77
82
 
78
83
  // ─── Client registry + detection (spec §LW-12 step 1; UC-0 expansion) ───────
@@ -1556,16 +1561,21 @@ async function getStatus(homeDir, opts = {}) {
1556
1561
  // 4. Sentinel + runner + hook install state
1557
1562
  const hookPath = hookScriptPathFor(homeDir);
1558
1563
  const runnerInstalled = fs.existsSync(path.join(binRootFor(homeDir), 'scripts', 'runner.js'));
1564
+ let hookInstalled = false;
1559
1565
  let hookRegistered = false;
1560
1566
  try {
1561
1567
  const settings = JSON.parse(
1562
1568
  fs.readFileSync(path.join(homeDir, '.claude', 'settings.json'), 'utf-8')
1563
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);
1564
1574
  // LW-17: only STRUCTURED entries count — Claude Code silently ignores
1565
1575
  // bare-string entries (the 0.8.1 dead-hook bug), so reporting one as
1566
1576
  // "registered" would mask exactly the failure this status exists to catch.
1567
- hookRegistered = Array.isArray(settings.hooks && settings.hooks.SessionEnd) &&
1568
- settings.hooks.SessionEnd.some((h) =>
1577
+ hookRegistered = Array.isArray(sessionEnd) &&
1578
+ sessionEnd.some((h) =>
1569
1579
  h && typeof h === 'object' && Array.isArray(h.hooks) && h.hooks.some(isAuxiloCommandHook));
1570
1580
  } catch { /* no settings */ }
1571
1581
 
@@ -1589,7 +1599,8 @@ async function getStatus(homeDir, opts = {}) {
1589
1599
  accountMode,
1590
1600
  sentinel: sentinelPresent(homeDir),
1591
1601
  runnerInstalled,
1592
- hookInstalled: fs.existsSync(hookPath),
1602
+ hookInstalled,
1603
+ hookScriptInstalled: fs.existsSync(hookPath),
1593
1604
  hookRegistered,
1594
1605
  // LW-18 layer 1b: SessionStart held-count notice
1595
1606
  noticeRegistered: sessionStartNoticeRegistered(homeDir),
package/lib/review.js CHANGED
@@ -121,7 +121,7 @@ function formatFlags(l) {
121
121
 
122
122
  // ── Review-seamless additions (2026-07-18) ──────────────────────────────────
123
123
  //
124
- // Summary + bulk network calls, plus the pure approve-clean selection logic
124
+ // Summary + bulk network calls, plus the pure approve-ready selection logic
125
125
  // shared by the CLI, the MCP auxilo_review tool, and the triage report script.
126
126
  // One selection implementation everywhere, so what a dry run PRINTS is exactly
127
127
  // what a confirmed run DOES.
@@ -129,8 +129,12 @@ function formatFlags(l) {
129
129
  /** Server-enforced max decisions per bulk call; clients chunk at this size. */
130
130
  const BULK_CHUNK = 100;
131
131
 
132
- /** Default approve-clean quality threshold (repo quality gate: total >= 14/20). */
132
+ /** Default ready-to-publish quality threshold (repo quality gate: total >= 14/20). */
133
133
  const DEFAULT_QUALITY_THRESHOLD = 14;
134
+ const LANE_READY = 'ready_to_publish';
135
+ const LANE_NEEDS_SCORE = 'needs_score';
136
+ const LANE_NEEDS_EYES = 'needs_your_eyes';
137
+ const REVIEW_LANES = new Set([LANE_READY, LANE_NEEDS_SCORE, LANE_NEEDS_EYES]);
134
138
 
135
139
  /**
136
140
  * GET /account/pending/summary: compact triage rows + counts (no bodies).
@@ -233,46 +237,95 @@ function qualityClears(row, minQuality) {
233
237
  }
234
238
 
235
239
  /**
236
- * THE approve-clean selection. Operates on summary rows (screens_passed,
237
- * flags, quality) from GET /account/pending/summary.
240
+ * Resolve the server lane on a summary row. Old servers did not return lane;
241
+ * those rows fall back to the legacy screens+quality split and are marked as
242
+ * version-skewed so the caller can tell the operator.
238
243
  *
239
- * mode 'clean' (approve-clean): screens_passed AND quality >= minQuality.
240
- * mode 'all': every row EXCEPT screens-flagged ones unless includeFlagged
241
- * is set; no quality gate.
244
+ * @param {object} row
245
+ * @returns {{lane:string, version_skew:boolean}}
246
+ */
247
+ function reviewLane(row) {
248
+ if (row && REVIEW_LANES.has(row.lane)) {
249
+ return { lane: row.lane, version_skew: false };
250
+ }
251
+ if (!row || !row.screens_passed) {
252
+ return { lane: LANE_NEEDS_EYES, version_skew: true };
253
+ }
254
+ return {
255
+ lane: qualityClears(row, DEFAULT_QUALITY_THRESHOLD) ? LANE_READY : LANE_NEEDS_SCORE,
256
+ version_skew: true,
257
+ };
258
+ }
259
+
260
+ /**
261
+ * THE approve-ready selection. The server's lane is authoritative:
262
+ *
263
+ * - threshold 14 (default): exactly rows in ready_to_publish
264
+ * - threshold >14: narrows within ready_to_publish
265
+ * - threshold <14: includes qualifying needs_score rows too
266
+ * - needs_your_eyes is excluded unless mode=all + includeFlagged
242
267
  *
243
268
  * Returns the selection plus what was excluded and why, so every confirmation
244
269
  * surface can show the operator the exact consequences before anything runs.
245
270
  *
246
271
  * @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}}
272
+ * @param {object} [opts] { mode: 'ready'|'clean'|'all', minQuality, includeFlagged }
273
+ * @returns {{selected:Array<object>, included_beyond_verdict:Array<object>, excluded_flagged:Array<object>, excluded_low_quality:Array<object>, excluded_unscored:Array<object>, min_quality:number|null, version_skew:boolean}}
249
274
  */
250
275
  function selectForBulkApprove(rows, opts = {}) {
251
- const mode = opts.mode === 'all' ? 'all' : 'clean';
252
- const minQuality = mode === 'clean'
276
+ const mode = opts.mode === 'all' ? 'all' : 'ready';
277
+ const minQuality = mode === 'ready'
253
278
  ? (Number.isFinite(opts.minQuality) ? opts.minQuality : DEFAULT_QUALITY_THRESHOLD)
254
279
  : null;
255
280
  const includeFlagged = mode === 'all' && opts.includeFlagged === true;
256
281
 
257
282
  const selected = [];
283
+ const included_beyond_verdict = [];
258
284
  const excluded_flagged = [];
259
285
  const excluded_low_quality = [];
260
286
  const excluded_unscored = [];
287
+ let version_skew = false;
261
288
 
262
289
  for (const row of rows || []) {
263
290
  if (!row || !row.id) continue;
264
- if (!row.screens_passed && !includeFlagged) {
291
+ const resolved = reviewLane(row);
292
+ version_skew = version_skew || resolved.version_skew;
293
+
294
+ if (resolved.lane === LANE_NEEDS_EYES && !includeFlagged) {
265
295
  excluded_flagged.push(row);
266
296
  continue;
267
297
  }
268
- if (mode === 'clean' && !qualityClears(row, minQuality)) {
269
- (row.quality == null ? excluded_unscored : excluded_low_quality).push(row);
270
- continue;
298
+
299
+ if (mode === 'ready') {
300
+ if (resolved.lane === LANE_READY) {
301
+ // At the server floor, consume the verdict verbatim. A stricter
302
+ // operator threshold may narrow it, but never broaden it.
303
+ if (minQuality > DEFAULT_QUALITY_THRESHOLD && !qualityClears(row, minQuality)) {
304
+ (row.quality == null ? excluded_unscored : excluded_low_quality).push(row);
305
+ continue;
306
+ }
307
+ } else if (resolved.lane === LANE_NEEDS_SCORE) {
308
+ if (minQuality < DEFAULT_QUALITY_THRESHOLD && qualityClears(row, minQuality)) {
309
+ included_beyond_verdict.push(row);
310
+ } else {
311
+ (row.quality == null ? excluded_unscored : excluded_low_quality).push(row);
312
+ continue;
313
+ }
314
+ }
271
315
  }
316
+
272
317
  selected.push(row);
273
318
  }
274
319
 
275
- return { selected, excluded_flagged, excluded_low_quality, excluded_unscored, min_quality: minQuality };
320
+ return {
321
+ selected,
322
+ included_beyond_verdict,
323
+ excluded_flagged,
324
+ excluded_low_quality,
325
+ excluded_unscored,
326
+ min_quality: minQuality,
327
+ version_skew,
328
+ };
276
329
  }
277
330
 
278
331
  module.exports = {
@@ -287,5 +340,6 @@ module.exports = {
287
340
  chunkDecisions,
288
341
  submitBulkChunked,
289
342
  qualityClears,
343
+ reviewLane,
290
344
  selectForBulkApprove,
291
345
  };