@shadowforge0/aquifer-memory 1.6.0 → 1.7.0

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.
@@ -1,6 +1,7 @@
1
1
  'use strict';
2
2
 
3
3
  const crypto = require('crypto');
4
+ const { resolveApplicableRecords } = require('./memory-bootstrap');
4
5
 
5
6
  function requireField(obj, field) {
6
7
  if (!obj || obj[field] === undefined || obj[field] === null || obj[field] === '') {
@@ -24,10 +25,10 @@ function advisoryLockKeys(namespace, value) {
24
25
  const BOOTSTRAP_ORDER_SQL = `
25
26
  CASE m.memory_type
26
27
  WHEN 'constraint' THEN 0
27
- WHEN 'preference' THEN 1
28
- WHEN 'state' THEN 2
29
- WHEN 'open_loop' THEN 3
30
- WHEN 'decision' THEN 4
28
+ WHEN 'state' THEN 1
29
+ WHEN 'open_loop' THEN 2
30
+ WHEN 'decision' THEN 3
31
+ WHEN 'preference' THEN 4
31
32
  WHEN 'fact' THEN 5
32
33
  WHEN 'conclusion' THEN 6
33
34
  WHEN 'entity_note' THEN 7
@@ -46,6 +47,123 @@ const BOOTSTRAP_ORDER_SQL = `
46
47
  m.accepted_at DESC NULLS LAST,
47
48
  m.id ASC`;
48
49
 
50
+ const CURRENT_TYPE_PRIORITY = {
51
+ constraint: 0,
52
+ state: 1,
53
+ open_loop: 2,
54
+ decision: 3,
55
+ preference: 4,
56
+ fact: 5,
57
+ conclusion: 6,
58
+ entity_note: 7,
59
+ };
60
+
61
+ const CURRENT_AUTHORITY_PRIORITY = {
62
+ user_explicit: 0,
63
+ executable_evidence: 1,
64
+ manual: 2,
65
+ system: 3,
66
+ verified_summary: 4,
67
+ llm_inference: 5,
68
+ raw_transcript: 6,
69
+ };
70
+
71
+ function parseTime(value) {
72
+ const parsed = Date.parse(value || '');
73
+ return Number.isFinite(parsed) ? parsed : null;
74
+ }
75
+
76
+ function normalizeScopePath(activeScopePath, activeScopeKey) {
77
+ const source = Array.isArray(activeScopePath)
78
+ ? activeScopePath
79
+ : (typeof activeScopePath === 'string' ? activeScopePath.split(',') : null);
80
+ if (source && source.length > 0) {
81
+ const seen = new Set();
82
+ const path = [];
83
+ for (const value of source) {
84
+ const key = String(value || '').trim();
85
+ if (!key || seen.has(key)) continue;
86
+ seen.add(key);
87
+ path.push(key);
88
+ }
89
+ if (path.length > 0) return path;
90
+ }
91
+ if (activeScopeKey) return [String(activeScopeKey).trim()].filter(Boolean);
92
+ return ['global'];
93
+ }
94
+
95
+ function compareRecordIdAsc(a, b) {
96
+ const left = a.memoryId ?? a.memory_id ?? a.id ?? null;
97
+ const right = b.memoryId ?? b.memory_id ?? b.id ?? null;
98
+ const leftNum = Number(left);
99
+ const rightNum = Number(right);
100
+ if (Number.isFinite(leftNum) && Number.isFinite(rightNum) && leftNum !== rightNum) {
101
+ return leftNum - rightNum;
102
+ }
103
+ return String(left ?? '').localeCompare(String(right ?? ''));
104
+ }
105
+
106
+ function normalizeCurrentMemoryRow(row = {}) {
107
+ const memoryId = row.memoryId ?? row.memory_id ?? row.id ?? null;
108
+ const evidenceRefsValue = row.evidenceRefs ?? row.evidence_refs ?? [];
109
+ const evidenceRefs = Array.isArray(evidenceRefsValue) ? evidenceRefsValue : [];
110
+ return {
111
+ ...row,
112
+ memoryId: memoryId === null ? null : String(memoryId),
113
+ canonicalKey: row.canonicalKey ?? row.canonical_key ?? null,
114
+ memoryType: row.memoryType ?? row.memory_type ?? null,
115
+ scopeKey: row.scopeKey ?? row.scope_key ?? null,
116
+ scopeKind: row.scopeKind ?? row.scope_kind ?? null,
117
+ inheritanceMode: row.inheritanceMode ?? row.inheritance_mode ?? row.scope_inheritance_mode ?? null,
118
+ visibleInBootstrap: row.visibleInBootstrap ?? row.visible_in_bootstrap ?? false,
119
+ visibleInRecall: row.visibleInRecall ?? row.visible_in_recall ?? false,
120
+ acceptedAt: row.acceptedAt ?? row.accepted_at ?? null,
121
+ validFrom: row.validFrom ?? row.valid_from ?? null,
122
+ validTo: row.validTo ?? row.valid_to ?? null,
123
+ staleAfter: row.staleAfter ?? row.stale_after ?? null,
124
+ evidenceRefs,
125
+ evidence_refs: evidenceRefs,
126
+ };
127
+ }
128
+
129
+ function currentScopePriority(record, positions) {
130
+ return positions.get(record.scopeKey ?? record.scope_key) ?? -1;
131
+ }
132
+
133
+ function sortCurrentMemoryRecords(a, b, positions) {
134
+ const leftScope = currentScopePriority(a, positions);
135
+ const rightScope = currentScopePriority(b, positions);
136
+ if (rightScope !== leftScope) return rightScope - leftScope;
137
+
138
+ const leftType = CURRENT_TYPE_PRIORITY[a.memoryType ?? a.memory_type] ?? 99;
139
+ const rightType = CURRENT_TYPE_PRIORITY[b.memoryType ?? b.memory_type] ?? 99;
140
+ if (leftType !== rightType) return leftType - rightType;
141
+
142
+ const leftAuthority = CURRENT_AUTHORITY_PRIORITY[a.authority] ?? 99;
143
+ const rightAuthority = CURRENT_AUTHORITY_PRIORITY[b.authority] ?? 99;
144
+ if (leftAuthority !== rightAuthority) return leftAuthority - rightAuthority;
145
+
146
+ const leftAccepted = parseTime(a.acceptedAt ?? a.accepted_at);
147
+ const rightAccepted = parseTime(b.acceptedAt ?? b.accepted_at);
148
+ if (leftAccepted !== rightAccepted) return (rightAccepted ?? 0) - (leftAccepted ?? 0);
149
+
150
+ return compareRecordIdAsc(a, b);
151
+ }
152
+
153
+ function isCurrentProjectionRow(row, asOf) {
154
+ if ((row.status || 'candidate') !== 'active') return false;
155
+ if (row.visibleInBootstrap !== true && row.visibleInRecall !== true) return false;
156
+ const at = parseTime(asOf);
157
+ if (at === null) return true;
158
+ const validFrom = parseTime(row.validFrom ?? row.valid_from);
159
+ const validTo = parseTime(row.validTo ?? row.valid_to);
160
+ const staleAfter = parseTime(row.staleAfter ?? row.stale_after);
161
+ if (validFrom !== null && validFrom > at) return false;
162
+ if (validTo !== null && validTo <= at) return false;
163
+ if (staleAfter !== null && staleAfter <= at) return false;
164
+ return true;
165
+ }
166
+
49
167
  function createMemoryRecords({ pool, schema, defaultTenantId, inTransaction = false }) {
50
168
  const scopes = `${schema}.scopes`;
51
169
  const versions = `${schema}.versions`;
@@ -534,6 +652,103 @@ function createMemoryRecords({ pool, schema, defaultTenantId, inTransaction = fa
534
652
  return result.rows;
535
653
  }
536
654
 
655
+ async function currentProjection(input = {}) {
656
+ const tenantId = input.tenantId || defaultTenantId;
657
+ let activeScopePath = normalizeScopePath(input.activeScopePath, input.activeScopeKey);
658
+ let activeScopeKey = input.activeScopeKey || activeScopePath[activeScopePath.length - 1] || null;
659
+ if (input.scopeId && !input.activeScopeKey && !input.activeScopePath) {
660
+ const scopeResult = await pool.query(
661
+ `SELECT scope_key FROM ${scopes} WHERE tenant_id = $1 AND id = $2 LIMIT 1`,
662
+ [tenantId, input.scopeId],
663
+ );
664
+ const scopedKey = scopeResult.rows[0]?.scope_key || null;
665
+ if (scopedKey) {
666
+ activeScopePath = [scopedKey];
667
+ activeScopeKey = scopedKey;
668
+ }
669
+ }
670
+ const limit = Math.max(1, Math.min(100, input.limit || 50));
671
+ const fetchLimit = Math.max(limit + 1, Math.min(200, Math.max(limit * 4, 40)));
672
+ const asOf = input.asOf || new Date().toISOString();
673
+ const params = [tenantId, activeScopePath, asOf];
674
+ const where = [
675
+ `m.tenant_id = $1`,
676
+ `m.status = 'active'`,
677
+ `s.scope_key = ANY($2::text[])`,
678
+ `(m.visible_in_bootstrap = true OR m.visible_in_recall = true)`,
679
+ `(m.valid_from IS NULL OR m.valid_from <= $3::timestamptz)`,
680
+ `(m.valid_to IS NULL OR m.valid_to > $3::timestamptz)`,
681
+ `(m.stale_after IS NULL OR m.stale_after > $3::timestamptz)`,
682
+ ];
683
+
684
+ if (input.scopeId) {
685
+ params.push(input.scopeId);
686
+ where.push(`m.scope_id = $${params.length}`);
687
+ }
688
+
689
+ params.push(fetchLimit);
690
+ const limitParam = `$${params.length}`;
691
+ const evidenceRefsSelect = input.includeEvidenceRefs === true
692
+ ? `COALESCE((
693
+ SELECT jsonb_agg(
694
+ jsonb_build_object(
695
+ 'id', e.id,
696
+ 'sourceKind', e.source_kind,
697
+ 'sourceRef', e.source_ref,
698
+ 'relationKind', e.relation_kind,
699
+ 'weight', e.weight,
700
+ 'metadata', e.metadata
701
+ )
702
+ ORDER BY e.id ASC
703
+ )
704
+ FROM ${evidenceRefs} e
705
+ WHERE e.tenant_id = m.tenant_id
706
+ AND e.owner_kind = 'memory_record'
707
+ AND e.owner_id = m.id
708
+ ), '[]'::jsonb)`
709
+ : `'[]'::jsonb`;
710
+
711
+ const result = await pool.query(
712
+ `SELECT
713
+ m.*,
714
+ s.scope_kind,
715
+ s.scope_key,
716
+ s.inheritance_mode AS scope_inheritance_mode,
717
+ ${evidenceRefsSelect} AS evidence_refs
718
+ FROM ${memories} m
719
+ JOIN ${scopes} s ON s.id = m.scope_id
720
+ WHERE ${where.join(' AND ')}
721
+ ORDER BY array_position($2::text[], s.scope_key) DESC NULLS LAST,
722
+ ${BOOTSTRAP_ORDER_SQL}
723
+ LIMIT ${limitParam}`,
724
+ params,
725
+ );
726
+
727
+ const positions = new Map(activeScopePath.map((key, index) => [key, index]));
728
+ const applicable = resolveApplicableRecords(
729
+ result.rows
730
+ .map(normalizeCurrentMemoryRow)
731
+ .filter(row => isCurrentProjectionRow(row, asOf)),
732
+ { activeScopeKey, activeScopePath },
733
+ ).sort((left, right) => sortCurrentMemoryRecords(left, right, positions));
734
+
735
+ const selected = applicable.slice(0, limit);
736
+ const truncated = applicable.length > limit;
737
+ return {
738
+ memories: selected,
739
+ meta: {
740
+ source: 'memory_records',
741
+ servingContract: 'current_memory_v1',
742
+ count: selected.length,
743
+ activeScopeKey,
744
+ activeScopePath,
745
+ asOf,
746
+ truncated,
747
+ degraded: truncated,
748
+ },
749
+ };
750
+ }
751
+
537
752
  async function withTransaction(fn) {
538
753
  if (inTransaction) {
539
754
  return fn(api, { transactional: true });
@@ -572,6 +787,7 @@ function createMemoryRecords({ pool, schema, defaultTenantId, inTransaction = fa
572
787
  updateMemoryStatusIfCurrent,
573
788
  updateFactAssertionStatus,
574
789
  listActive,
790
+ currentProjection,
575
791
  withTransaction,
576
792
  };
577
793
 
@@ -40,6 +40,20 @@ function summarizeMemoryResults(results = [], extra = {}) {
40
40
  };
41
41
  }
42
42
 
43
+ function decorateCandidates(candidates = [], input = {}) {
44
+ const candidatePayload = input.candidatePayload && typeof input.candidatePayload === 'object'
45
+ ? input.candidatePayload
46
+ : null;
47
+ if (!candidatePayload) return candidates;
48
+ return candidates.map(candidate => ({
49
+ ...candidate,
50
+ payload: {
51
+ ...(candidate.payload || {}),
52
+ ...candidatePayload,
53
+ },
54
+ }));
55
+ }
56
+
43
57
  function normalizeFinalizationInput(input = {}, defaults = {}) {
44
58
  const tenantId = input.tenantId || defaults.defaultTenantId || 'default';
45
59
  return {
@@ -227,7 +241,7 @@ function createSessionFinalization({
227
241
  phase: base.phase,
228
242
  },
229
243
  }];
230
- const candidates = Array.isArray(input.candidates)
244
+ const rawCandidates = Array.isArray(input.candidates)
231
245
  ? input.candidates
232
246
  : promotion.extractCandidates({
233
247
  sessionId: base.sessionId,
@@ -239,6 +253,7 @@ function createSessionFinalization({
239
253
  authority: input.authority || 'verified_summary',
240
254
  evidenceRefs,
241
255
  });
256
+ const candidates = decorateCandidates(rawCandidates, input);
242
257
 
243
258
  const memoryResults = candidates.length > 0
244
259
  ? await promotion.promote(candidates, {
@@ -83,9 +83,15 @@ For first rollout, keep `AQUIFER_MEMORY_SERVING_MODE=legacy`. Switch to `curated
83
83
  | Start MCP server | `npx aquifer mcp` |
84
84
  | Search memory | `npx aquifer recall "auth middleware"` |
85
85
  | Plan curated compaction | `npx aquifer compact --cadence daily --period-start 2026-04-27T00:00:00Z --period-end 2026-04-28T00:00:00Z` |
86
+ | Generate a timer synthesis prompt | `npx aquifer operator compaction daily --include-synthesis-prompt --json` |
87
+ | Apply reviewed timer synthesis candidates | `npx aquifer operator compaction daily --synthesis-summary-file /tmp/timer-summary.json --apply --promote-candidates --json` |
86
88
  | Show stats | `npx aquifer stats` |
87
89
  | Enrich pending sessions | `npx aquifer backfill` |
88
90
 
91
+ Timer synthesis is an operator-reviewed candidate workflow. The prompt output
92
+ and summary JSON do not become active curated memory unless the apply step is
93
+ run with `--promote-candidates`.
94
+
89
95
  The default public serving mode is `legacy`. To test scoped curated memory serving, set `AQUIFER_MEMORY_SERVING_MODE=curated` plus `AQUIFER_MEMORY_ACTIVE_SCOPE_KEY` or `AQUIFER_MEMORY_ACTIVE_SCOPE_PATH`. Rollback is config-only: set the serving mode back to `legacy` and restart the MCP/CLI process.
90
96
 
91
97
  ## If something fails
package/docs/setup.md CHANGED
@@ -213,6 +213,45 @@ Do **not** use the OpenClaw plugin (`consumers/openclaw-plugin.js`) for tool del
213
213
 
214
214
  Curated serving rollback is config-only: set `AQUIFER_MEMORY_SERVING_MODE=legacy` and restart the MCP/CLI process. No destructive database rollback is required.
215
215
 
216
+ ## Operator compaction and timer synthesis
217
+
218
+ Compaction jobs are operator-safe by default. A dry-run plans lifecycle updates
219
+ and candidate output without writing active memory:
220
+
221
+ ```bash
222
+ npx aquifer operator compaction daily --include-synthesis-prompt --json
223
+ ```
224
+
225
+ If an operator or external model reviews that prompt and returns timer synthesis
226
+ JSON, attach it back to the plan with:
227
+
228
+ ```bash
229
+ npx aquifer operator compaction daily \
230
+ --synthesis-summary-file /tmp/timer-summary.json \
231
+ --apply \
232
+ --promote-candidates \
233
+ --json
234
+ ```
235
+
236
+ The summary file must match the normal structured summary shape, for example:
237
+
238
+ ```json
239
+ {
240
+ "summaryText": "Reviewed timer synthesis.",
241
+ "structuredSummary": {
242
+ "states": [
243
+ { "state": "The reviewed state that should continue into current memory." }
244
+ ],
245
+ "decisions": [],
246
+ "open_loops": []
247
+ }
248
+ }
249
+ ```
250
+
251
+ Without `--promote-candidates`, synthesis output is recorded as candidate
252
+ ledger material only. The prompt and summary file are producer material; active
253
+ curated memory still requires the explicit promotion gate.
254
+
216
255
  ## Release verification gates
217
256
 
218
257
  For the publish-surface checks:
@@ -222,13 +261,15 @@ node --test test/package-surface.test.js test/mcp-manifest.test.js
222
261
  npm pack --dry-run --json
223
262
  ```
224
263
 
225
- For the real DB-backed MCP integration gate:
264
+ For the real DB-backed release gate:
226
265
 
227
266
  ```bash
228
- AQUIFER_TEST_DB_URL="postgresql://..." node --test test/consumer-mcp.integration.test.js
267
+ AQUIFER_TEST_DB_URL="postgresql://..." npm run test:release:db
229
268
  ```
230
269
 
231
- That DB-backed test is the release proof that the stdio MCP server, current MCP manifest, and PostgreSQL path still line up on a live database.
270
+ That DB-backed test is the release proof that the stdio MCP server, CLI
271
+ consumer, Codex finalization serving path, current MCP manifest, and PostgreSQL
272
+ path still line up on a live database.
232
273
 
233
274
  ## Troubleshooting
234
275
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@shadowforge0/aquifer-memory",
3
- "version": "1.6.0",
3
+ "version": "1.7.0",
4
4
  "description": "PG-native long-term memory for AI agents. Turn-level embedding, hybrid RRF ranking, optional knowledge graph. MCP server, CLI, and library API.",
5
5
  "main": "index.js",
6
6
  "files": [
@@ -67,7 +67,7 @@
67
67
  "scripts": {
68
68
  "test": "node --test test/*.test.js",
69
69
  "test:integration": "node --test test/integration.test.js",
70
- "test:release:package": "node --test test/package-surface.test.js test/mcp-manifest.test.js",
70
+ "test:release:package": "node --test test/package-surface.test.js test/mcp-manifest.test.js test/v1-serving-cutover.test.js test/v1-current-memory-contract.test.js test/consumer-codex.test.js test/codex-handoff.test.js",
71
71
  "test:release:db": "node -e \"if (!process.env.AQUIFER_TEST_DB_URL) { console.error('AQUIFER_TEST_DB_URL is required for test:release:db'); process.exit(1); }\" && node --test test/consumer-mcp.integration.test.js test/consumer-cli.integration.test.js test/codex-finalization-serving.integration.test.js",
72
72
  "lint": "eslint index.js core/*.js consumers/cli.js consumers/mcp.js consumers/claude-code.js consumers/codex.js consumers/codex-handoff.js consumers/openclaw-plugin.js consumers/opencode.js consumers/shared/*.js consumers/default/*.js consumers/default/prompts/*.js consumers/openclaw-ext/*.js pipeline/*.js pipeline/consolidation/*.js scripts/*.js test/*.js",
73
73
  "hooks:install": "git config core.hooksPath .githooks"
@@ -36,6 +36,11 @@ const VALUE_FLAGS = new Set([
36
36
  'summary-json',
37
37
  'summary-text',
38
38
  'verdict',
39
+ 'workspace',
40
+ 'workspace-path',
41
+ 'project',
42
+ 'project-key',
43
+ 'repo-path',
39
44
  ]);
40
45
 
41
46
  function parseArgs(argv) {
@@ -109,6 +114,9 @@ function buildRecoveryOptions(flags = {}, env = process.env) {
109
114
  agentId: flags['agent-id'] || envDefault(env, 'CODEX_AQUIFER_AGENT_ID', 'AQUIFER_AGENT_ID') || 'main',
110
115
  source: flags.source || envDefault(env, 'CODEX_AQUIFER_SOURCE', 'AQUIFER_SOURCE') || 'codex',
111
116
  sessionKey: flags['session-key'] || envDefault(env, 'CODEX_AQUIFER_SESSION_KEY') || 'codex:cli',
117
+ workspace: flags.workspace || flags['workspace-path'] || envDefault(env, 'CODEX_AQUIFER_WORKSPACE', 'CODEX_WORKSPACE') || undefined,
118
+ project: flags.project || flags['project-key'] || envDefault(env, 'CODEX_AQUIFER_PROJECT', 'CODEX_PROJECT') || undefined,
119
+ repoPath: flags['repo-path'] || envDefault(env, 'CODEX_AQUIFER_REPO_PATH', 'CODEX_REPO_PATH') || undefined,
112
120
  codexHome: flags['codex-home'] || envDefault(env, 'CODEX_HOME') || undefined,
113
121
  stateDir: flags['state-dir'] || undefined,
114
122
  sessionsDir: flags['sessions-dir'] || undefined,
@@ -122,6 +130,7 @@ function buildRecoveryOptions(flags = {}, env = process.env) {
122
130
  includeJsonlPreviews: flags['include-jsonl-previews'] === true,
123
131
  includeDeferredRecovery: flags['include-deferred'] === true,
124
132
  excludeNewest: flags['include-current'] === true ? false : true,
133
+ strictWrapperEnv: flags['strict-wrapper-env'] === true,
125
134
  };
126
135
  for (const [key, value] of Object.entries(opts)) {
127
136
  if (value === undefined) delete opts[key];
@@ -129,6 +138,10 @@ function buildRecoveryOptions(flags = {}, env = process.env) {
129
138
  return opts;
130
139
  }
131
140
 
141
+ function addDoctorCheck(checks, name, status, detail, extra = {}) {
142
+ checks.push({ name, status, detail, ...extra });
143
+ }
144
+
132
145
  function shellQuote(value) {
133
146
  return `'${String(value || '').replace(/'/g, `'\\''`)}'`;
134
147
  }
@@ -266,6 +279,81 @@ function compactCandidate(candidate = {}) {
266
279
  };
267
280
  }
268
281
 
282
+ function compactDoctorOptions(opts = {}) {
283
+ return {
284
+ agentId: opts.agentId || 'main',
285
+ source: opts.source || 'codex',
286
+ sessionKey: opts.sessionKey || 'codex:cli',
287
+ workspace: opts.workspace || null,
288
+ project: opts.project || null,
289
+ repoPath: opts.repoPath || null,
290
+ codexHome: opts.codexHome || null,
291
+ sessionsDir: opts.sessionsDir || null,
292
+ stateDir: opts.stateDir || null,
293
+ excludeNewest: opts.excludeNewest !== false,
294
+ includeDeferredRecovery: opts.includeDeferredRecovery === true,
295
+ maxRecoveryCandidates: opts.maxRecoveryCandidates || null,
296
+ };
297
+ }
298
+
299
+ async function buildDoctorReport(aquifer, opts = {}, env = process.env) {
300
+ const checks = [];
301
+ const hasWrapperEnv = Boolean(
302
+ env.CODEX_AQUIFER_AGENT_ID
303
+ || env.CODEX_AQUIFER_SOURCE
304
+ || env.CODEX_AQUIFER_SESSION_KEY
305
+ || env.CODEX_HOME
306
+ || env.CODEX_ENV_PATH,
307
+ );
308
+ if (hasWrapperEnv) {
309
+ addDoctorCheck(checks, 'wrapper_env', 'ok', 'Codex wrapper env is present.');
310
+ } else if (opts.strictWrapperEnv) {
311
+ addDoctorCheck(checks, 'wrapper_env', 'fail', 'Strict wrapper env requested, but no CODEX_AQUIFER_* or CODEX_HOME env was found.');
312
+ } else {
313
+ addDoctorCheck(checks, 'wrapper_env', 'warn', 'Using CLI defaults; pass --strict-wrapper-env for live wrapper deployment checks.');
314
+ }
315
+
316
+ if (opts.excludeNewest === false) {
317
+ addDoctorCheck(checks, 'current_transcript_guard', 'fail', 'Current/newest transcript exclusion is disabled.');
318
+ } else {
319
+ addDoctorCheck(checks, 'current_transcript_guard', 'ok', 'Newest transcript exclusion is enabled.');
320
+ }
321
+
322
+ let candidates = [];
323
+ try {
324
+ candidates = await listDbEligibleCandidates(aquifer, {
325
+ ...opts,
326
+ idleMs: opts.idleMs ?? 0,
327
+ includeJsonlPreviews: true,
328
+ maxRecoveryCandidates: opts.maxRecoveryCandidates || 1,
329
+ });
330
+ addDoctorCheck(
331
+ checks,
332
+ 'sessionstart_preflight',
333
+ 'ok',
334
+ `Metadata-only recovery scan completed; eligibleCandidates=${candidates.length}.`,
335
+ { eligibleCandidates: candidates.length },
336
+ );
337
+ } catch (err) {
338
+ addDoctorCheck(
339
+ checks,
340
+ 'sessionstart_preflight',
341
+ 'fail',
342
+ err && err.message ? err.message : String(err),
343
+ );
344
+ }
345
+
346
+ const status = checks.some(check => check.status === 'fail')
347
+ ? 'fail'
348
+ : checks.some(check => check.status === 'warn') ? 'warn' : 'ok';
349
+ return {
350
+ status,
351
+ checks,
352
+ options: compactDoctorOptions(opts),
353
+ candidates: candidates.map(compactCandidate),
354
+ };
355
+ }
356
+
269
357
  function parseIdList(value) {
270
358
  if (!value || value === true) return new Set();
271
359
  return new Set(String(value).split(',').map(part => part.trim()).filter(Boolean));
@@ -467,6 +555,42 @@ async function cmdDecision(aquifer, flags, opts) {
467
555
  console.log(`Recovery ${verdict}: ${candidate.sessionId}`);
468
556
  }
469
557
 
558
+ async function cmdDoctor(aquifer, flags, opts, env = process.env) {
559
+ const report = await buildDoctorReport(aquifer, opts, env);
560
+ printDoctorReport(report, flags);
561
+ return report;
562
+ }
563
+
564
+ function printDoctorReport(report = {}, flags = {}) {
565
+ if (flags.json) {
566
+ console.log(JSON.stringify(report, null, 2));
567
+ } else {
568
+ console.log(`Codex recovery doctor: ${report.status}`);
569
+ for (const check of report.checks || []) {
570
+ console.log(`- ${check.status} ${check.name}: ${check.detail}`);
571
+ }
572
+ }
573
+ if (report.status === 'fail') process.exitCode = 1;
574
+ }
575
+
576
+ async function cmdDoctorInitFailure(flags, opts, err, env = process.env) {
577
+ let report = await buildDoctorReport(null, opts, env);
578
+ report = {
579
+ ...report,
580
+ status: 'fail',
581
+ checks: [
582
+ {
583
+ name: 'aquifer_init',
584
+ status: 'fail',
585
+ detail: err && err.message ? err.message : String(err),
586
+ },
587
+ ...(report.checks || []),
588
+ ],
589
+ };
590
+ printDoctorReport(report, flags);
591
+ return report;
592
+ }
593
+
470
594
  async function main(argv = process.argv.slice(2)) {
471
595
  const args = parseArgs(argv);
472
596
  const command = args._[0] || 'help';
@@ -480,7 +604,19 @@ async function main(argv = process.argv.slice(2)) {
480
604
  node scripts/codex-recovery.js prompt --session-id ID [options]
481
605
  node scripts/codex-recovery.js finalize --session-id ID --summary-stdin [options]
482
606
  node scripts/codex-recovery.js decision --session-id ID --verdict declined|deferred [options]
483
- node scripts/codex-recovery.js decision --all --verdict declined|deferred [options]`);
607
+ node scripts/codex-recovery.js decision --all --verdict declined|deferred [options]
608
+ node scripts/codex-recovery.js doctor [--strict-wrapper-env] [--json]`);
609
+ return;
610
+ }
611
+
612
+ if (command === 'doctor') {
613
+ try {
614
+ await withAquifer(async (aquifer) => {
615
+ await cmdDoctor(aquifer, args.flags, opts);
616
+ });
617
+ } catch (err) {
618
+ await cmdDoctorInitFailure(args.flags, opts, err);
619
+ }
484
620
  return;
485
621
  }
486
622
 
@@ -508,12 +644,16 @@ async function main(argv = process.argv.slice(2)) {
508
644
  }
509
645
 
510
646
  module.exports = {
647
+ buildDoctorReport,
511
648
  buildRecoveryOptions,
512
649
  cmdDecision,
650
+ cmdDoctor,
651
+ cmdDoctorInitFailure,
513
652
  cmdFinalize,
514
653
  cmdHookContext,
515
654
  cmdPrompt,
516
655
  loadCodexEnv,
656
+ main,
517
657
  parseArgs,
518
658
  renderFinalizeCommand,
519
659
  renderHookContext,