@ijfw/memory-server 1.5.1 → 1.5.3

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.
Files changed (48) hide show
  1. package/package.json +6 -5
  2. package/src/brain/budget-guard.js +86 -0
  3. package/src/brain/citation-resolver.js +41 -0
  4. package/src/brain/context-injection.js +69 -0
  5. package/src/brain/discovery.js +83 -0
  6. package/src/brain/dream-pipeline.js +324 -0
  7. package/src/brain/dump-ingest.js +88 -0
  8. package/src/brain/entity-collapse.js +28 -0
  9. package/src/brain/export.js +112 -0
  10. package/src/brain/extractors/index.js +24 -0
  11. package/src/brain/extractors/markdown.js +27 -0
  12. package/src/brain/extractors/pdf.js +31 -0
  13. package/src/brain/extractors/transcript.js +38 -0
  14. package/src/brain/first-run-scan.js +61 -0
  15. package/src/brain/index.js +1 -0
  16. package/src/brain/layout-sentinel.js +29 -0
  17. package/src/brain/migrate-facts-internal-once.js +87 -0
  18. package/src/brain/path-guard.js +103 -0
  19. package/src/brain/paths.js +26 -0
  20. package/src/brain/promotion-suggester.js +41 -0
  21. package/src/brain/stub-detector.js +33 -0
  22. package/src/brain/tiered-llm.js +83 -0
  23. package/src/brain/wiki-compiler.js +144 -0
  24. package/src/brain/wiki-sentinels.js +45 -0
  25. package/src/brain/wiki-templates.js +94 -0
  26. package/src/cross-orchestrator-cli.js +132 -5
  27. package/src/cross-orchestrator.js +2 -2
  28. package/src/dashboard-server.js +1 -1
  29. package/src/dream/runner.mjs +21 -0
  30. package/src/extension-registry.js +2 -2
  31. package/src/handlers/brain-handler.js +319 -0
  32. package/src/memory/auto-linker.js +5 -1
  33. package/src/memory/benchmark.js +4 -3
  34. package/src/memory/layout-migrations/001-visible-layer.js +131 -0
  35. package/src/memory/layout-migrations/index.js +50 -0
  36. package/src/memory/migration-runner.js +31 -2
  37. package/src/memory/obsidian-parser.js +3 -1
  38. package/src/memory/reader.js +2 -1
  39. package/src/memory/search.js +144 -16
  40. package/src/memory/temporal.js +40 -1
  41. package/src/orchestrator/agents-md-blackboard.js +114 -1
  42. package/src/orchestrator/discipline-selector.js +276 -0
  43. package/src/orchestrator/merge-block-aware.js +15 -5
  44. package/src/orchestrator/state-sdk.js +42 -4
  45. package/src/orchestrator/wave-state.js +38 -0
  46. package/src/recovery/code-fixer.js +1 -1
  47. package/src/server.js +290 -75
  48. package/src/update-apply.js +1 -1
@@ -0,0 +1,41 @@
1
+ // IJFW v1.5.2 -- brain promotion suggester.
2
+ //
3
+ // Walks per-project db paths and surfaces (subject, predicate, object) triples
4
+ // that appear across >= minProjects (default 3) projects. Promotion is ALWAYS
5
+ // operator-confirmed -- this module only surfaces candidates.
6
+
7
+ import Database from 'better-sqlite3';
8
+
9
+ export function suggestPromotions({ projectDbs, minProjects = 3 } = {}) {
10
+ if (!Array.isArray(projectDbs) || projectDbs.length === 0) return [];
11
+ const tally = new Map();
12
+ for (const entry of projectDbs) {
13
+ const { name, dbPath } = entry;
14
+ let db;
15
+ try { db = new Database(dbPath, { readonly: true, fileMustExist: true }); }
16
+ catch { continue; }
17
+ try {
18
+ const rows = db.prepare(`
19
+ SELECT DISTINCT subject, predicate, object
20
+ FROM facts
21
+ WHERE valid_to IS NULL
22
+ `).all();
23
+ for (const r of rows) {
24
+ const key = `${r.subject || ''}|${r.predicate || ''}|${r.object || ''}`;
25
+ if (!tally.has(key)) tally.set(key, new Set());
26
+ tally.get(key).add(name);
27
+ }
28
+ } finally { try { db.close(); } catch {} }
29
+ }
30
+ const out = [];
31
+ for (const [key, projects] of tally) {
32
+ if (projects.size < minProjects) continue;
33
+ const [subject, predicate, object] = key.split('|');
34
+ out.push({
35
+ subject, predicate, object,
36
+ projects: [...projects].sort(),
37
+ confidence: projects.size >= 5 ? 'high' : 'medium',
38
+ });
39
+ }
40
+ return out.sort((a, b) => b.projects.length - a.projects.length);
41
+ }
@@ -0,0 +1,33 @@
1
+ // IJFW v1.5.2 -- brain stub detector.
2
+ //
3
+ // A "stub" is a wikilink target with N+ incoming references but no actual
4
+ // wiki page at ijfw/wiki/<type>s/<slug>.md. Surfaces gaps the operator may
5
+ // want to fill. Checks both visible ijfw/ and legacy .ijfw/ paths during
6
+ // the v1->v2 transition.
7
+
8
+ import { existsSync } from 'node:fs';
9
+ import { join } from 'node:path';
10
+
11
+ const WIKI_TYPES = ['concepts', 'entities', 'decisions', 'milestones'];
12
+
13
+ function pageExistsAnywhere(repoRoot, target) {
14
+ for (const t of WIKI_TYPES) {
15
+ if (existsSync(join(repoRoot, 'ijfw', 'wiki', t, `${target}.md`))) return true;
16
+ if (existsSync(join(repoRoot, '.ijfw', 'wiki', t, `${target}.md`))) return true;
17
+ }
18
+ return false;
19
+ }
20
+
21
+ export function detectStubs(db, repoRoot, { minIncomingLinks = 3 } = {}) {
22
+ const rows = db.prepare(`
23
+ SELECT to_target AS target, COUNT(*) AS incomingLinks
24
+ FROM memory_links
25
+ WHERE to_target IS NOT NULL AND to_target != ''
26
+ GROUP BY to_target
27
+ HAVING COUNT(*) >= ?
28
+ `).all(minIncomingLinks);
29
+ return rows
30
+ .filter((r) => !pageExistsAnywhere(repoRoot, r.target))
31
+ .map((r) => ({ target: r.target, incomingLinks: r.incomingLinks }))
32
+ .sort((a, b) => b.incomingLinks - a.incomingLinks);
33
+ }
@@ -0,0 +1,83 @@
1
+ // IJFW v1.5.2 -- brain tiered LLM router.
2
+ //
3
+ // Two tiers used by the dream-cycle pipeline:
4
+ // - extract: cheap, high-volume fact extraction (default: claude-haiku-4-5-20251001)
5
+ // - synth: mid-tier reconciliation / page synthesis (default: claude-sonnet-4-6)
6
+ //
7
+ // When IJFW_BRAIN_LOCAL_URL is set, we try the local endpoint first (Ollama-
8
+ // compatible /api/generate). On any local-call error, we fall back to the
9
+ // Anthropic API. This makes "local-first" cost discipline opt-in via env.
10
+ //
11
+ // Tests inject custom callers via opts._callers to avoid hitting real APIs.
12
+
13
+ const DEFAULTS = {
14
+ extract: 'claude-haiku-4-5-20251001',
15
+ synth: 'claude-sonnet-4-6',
16
+ };
17
+
18
+ const DEFAULT_MAX_TOKENS = {
19
+ extract: 512,
20
+ synth: 1500,
21
+ };
22
+
23
+ export function resolveTierModel(tier, env = process.env) {
24
+ if (tier === 'extract') return env.IJFW_BRAIN_EXTRACT_MODEL || DEFAULTS.extract;
25
+ if (tier === 'synth') return env.IJFW_BRAIN_SYNTH_MODEL || DEFAULTS.synth;
26
+ throw new Error(`tiered-llm: unknown tier '${tier}'`);
27
+ }
28
+
29
+ function defaultCallers() {
30
+ return {
31
+ async local({ url, model, prompt, maxTokens }) {
32
+ // Ollama-compatible /api/generate -- streamless single-response mode.
33
+ const res = await fetch(url.replace(/\/$/, '') + '/api/generate', {
34
+ method: 'POST',
35
+ headers: { 'Content-Type': 'application/json' },
36
+ body: JSON.stringify({ model, prompt, stream: false, options: { num_predict: maxTokens } }),
37
+ });
38
+ if (!res.ok) throw new Error(`local LLM HTTP ${res.status}`);
39
+ const data = await res.json();
40
+ return { text: data.response || '', usage: { input: data.prompt_eval_count, output: data.eval_count }, model, via: 'local' };
41
+ },
42
+ async anthropic({ model, prompt, maxTokens, apiKey }) {
43
+ if (!apiKey) throw new Error('tiered-llm: ANTHROPIC_API_KEY (or IJFW_BRAIN_API_KEY) required for Anthropic fallback');
44
+ const res = await fetch('https://api.anthropic.com/v1/messages', {
45
+ method: 'POST',
46
+ headers: {
47
+ 'Content-Type': 'application/json',
48
+ 'x-api-key': apiKey,
49
+ 'anthropic-version': '2023-06-01',
50
+ },
51
+ body: JSON.stringify({
52
+ model,
53
+ max_tokens: maxTokens,
54
+ messages: [{ role: 'user', content: prompt }],
55
+ }),
56
+ });
57
+ if (!res.ok) throw new Error(`Anthropic HTTP ${res.status}`);
58
+ const data = await res.json();
59
+ const text = (data.content || []).map((b) => b.text || '').join('');
60
+ return { text, usage: data.usage, model, via: 'anthropic' };
61
+ },
62
+ };
63
+ }
64
+
65
+ export async function callTiered(tier, prompt, opts = {}) {
66
+ const env = opts.env || process.env;
67
+ const model = resolveTierModel(tier, env);
68
+ const maxTokens = opts.maxTokens || DEFAULT_MAX_TOKENS[tier] || 512;
69
+ const callers = opts._callers || defaultCallers();
70
+ if (env.IJFW_BRAIN_LOCAL_URL) {
71
+ try {
72
+ return await callers.local({ url: env.IJFW_BRAIN_LOCAL_URL, model, prompt, maxTokens });
73
+ } catch {
74
+ // fall through to Anthropic
75
+ }
76
+ }
77
+ return callers.anthropic({
78
+ model,
79
+ prompt,
80
+ maxTokens,
81
+ apiKey: env.IJFW_BRAIN_API_KEY || env.ANTHROPIC_API_KEY,
82
+ });
83
+ }
@@ -0,0 +1,144 @@
1
+ // IJFW v1.5.2 -- brain wiki compiler (Trident F-B1 + F-F2 enforcement).
2
+ //
3
+ // compileWikiPage(db, { repoRoot, type, subject }) renders the page from
4
+ // structured facts + history + backlinks + sources, runs the result through
5
+ // resolveCitations(), and ATOMICALLY writes (.tmp + rename) only if every
6
+ // citation resolves. Returns {ok:false, unresolved[]} when any cite is
7
+ // dangling -- this is the hallucination defense.
8
+ //
9
+ // Wiki path: <ijfw|.ijfw>/wiki/<type>s/<slug>.md per the layout sentinel.
10
+ // Slug is the subject lowercased + non-alphanum -> '-'.
11
+
12
+ import { existsSync, mkdirSync, readFileSync, writeFileSync, renameSync, openSync, closeSync, unlinkSync, statSync } from 'node:fs';
13
+ import { join } from 'node:path';
14
+
15
+ const STALE_LOCK_MS = 60_000; // 60s — locks older than this are assumed dead-process orphans
16
+ import { resolveBrainPaths } from './paths.js';
17
+ import { applyTemplate } from './wiki-templates.js';
18
+ import { resolveCitations } from './citation-resolver.js';
19
+ import { getHistoryWindow } from '../memory/temporal.js';
20
+ import { validateSafeRepoPath } from './path-guard.js';
21
+
22
+ export function slugify(s) {
23
+ return String(s || '').toLowerCase().trim()
24
+ .replace(/[^a-z0-9]+/g, '-')
25
+ .replace(/^-+|-+$/g, '') || 'untitled';
26
+ }
27
+
28
+ function pluralType(type) {
29
+ // Templates store under <type>s/ -- e.g. type='entity' -> 'entities'.
30
+ if (type === 'entity') return 'entities';
31
+ if (type === 'concept') return 'concepts';
32
+ if (type === 'decision') return 'decisions';
33
+ if (type === 'milestone') return 'milestones';
34
+ return `${type}s`;
35
+ }
36
+
37
+ function queryFacts(db, subject) {
38
+ try {
39
+ return db.prepare(
40
+ 'SELECT id, predicate, object, valid_from, valid_to, memory_id, source, confidence FROM facts WHERE subject = ? AND valid_to IS NULL ORDER BY id DESC'
41
+ ).all(subject);
42
+ } catch { return []; }
43
+ }
44
+
45
+ function queryBacklinks(db, target) {
46
+ try {
47
+ return db.prepare(
48
+ `SELECT to_target AS target, COUNT(*) AS count
49
+ FROM memory_links
50
+ WHERE to_target IS NOT NULL AND to_target = ?
51
+ GROUP BY to_target`
52
+ ).all(target);
53
+ } catch { return []; }
54
+ }
55
+
56
+ function querySources(db, subject) {
57
+ // Top-5 memory entries whose body mentions the subject.
58
+ try {
59
+ const rows = db.prepare(
60
+ `SELECT path, kind, COUNT(*) AS mentions
61
+ FROM memory_entries
62
+ WHERE body LIKE ?
63
+ GROUP BY path, kind
64
+ ORDER BY mentions DESC
65
+ LIMIT 5`
66
+ ).all(`%${subject}%`);
67
+ return rows.map((r) => ({ path: r.path, kind: r.kind, mentions: r.mentions }));
68
+ } catch { return []; }
69
+ }
70
+
71
+ export function compileWikiPage(db, { repoRoot, type, subject } = {}) {
72
+ if (!subject) return { ok: false, error: 'missing-subject' };
73
+ const paths = resolveBrainPaths(repoRoot);
74
+ const slug = slugify(subject);
75
+ const pageDir = join(paths.wikiDir, pluralType(type));
76
+ const pagePath = join(pageDir, `${slug}.md`);
77
+
78
+ // F-LENS2-05: enforce containment + reserved-name on the compile target.
79
+ // A symlinked wiki dir or a maliciously-crafted subject (slugify removes
80
+ // most danger, but defense-in-depth) could otherwise direct the atomic
81
+ // rename outside the repo. mkdirSync(pageDir,…) below runs only if guard
82
+ // passes so we never even create a parent directory outside repoRoot.
83
+ const guard = validateSafeRepoPath(repoRoot, pagePath);
84
+ if (!guard.ok) return guard;
85
+
86
+ // F8: per-page advisory lock prevents two concurrent compiles for the
87
+ // same subject from interleaving (both read existing → both render →
88
+ // both rename → operator NOTES from the intermediate state could be
89
+ // lost). EEXIST-based exclusive open is portable + atomic.
90
+ mkdirSync(pageDir, { recursive: true });
91
+ const lockPath = pagePath + '.lock';
92
+ let lockFd;
93
+ try {
94
+ lockFd = openSync(lockPath, 'wx');
95
+ } catch (e) {
96
+ if (e.code === 'EEXIST') {
97
+ // FLAG-8: stale-lock recovery. If a prior compile process was SIGKILL'd
98
+ // between acquire and finally, the lockfile orphans and every subsequent
99
+ // compile fails. Check the lockfile's age; if older than STALE_LOCK_MS,
100
+ // assume it's a dead-process orphan and reclaim.
101
+ let stale = false;
102
+ try {
103
+ const age = Date.now() - statSync(lockPath).mtimeMs;
104
+ if (age > STALE_LOCK_MS) stale = true;
105
+ } catch { /* lockfile vanished while we checked — race won by us */ stale = true; }
106
+ if (stale) {
107
+ try { unlinkSync(lockPath); } catch {}
108
+ try { lockFd = openSync(lockPath, 'wx'); }
109
+ catch (e2) { return { ok: false, error: 'page-locked-by-concurrent-compile', pagePath, staleReclaimFailed: e2.code }; }
110
+ } else {
111
+ return { ok: false, error: 'page-locked-by-concurrent-compile', pagePath };
112
+ }
113
+ } else {
114
+ throw e;
115
+ }
116
+ }
117
+
118
+ try {
119
+ // Read existing AFTER acquiring the lock so we see the freshest committed
120
+ // state (no race with another compile that just landed its rename).
121
+ const existing = existsSync(pagePath) ? readFileSync(pagePath, 'utf8') : '';
122
+ const facts = queryFacts(db, subject);
123
+ const history = getHistoryWindow(db, subject, null, { limit: 50 });
124
+ const backlinks = queryBacklinks(db, slug);
125
+ const sources = querySources(db, subject);
126
+
127
+ const candidate = applyTemplate(type, existing, { subject, facts, history, backlinks, sources });
128
+
129
+ const verdict = resolveCitations(db, candidate);
130
+ if (!verdict.ok) {
131
+ return { ok: false, error: 'unresolved-citations', unresolved: verdict.unresolved, pagePath };
132
+ }
133
+
134
+ // Atomic write
135
+ const tmp = pagePath + '.tmp';
136
+ writeFileSync(tmp, candidate);
137
+ renameSync(tmp, pagePath);
138
+
139
+ return { ok: true, pagePath, factsCount: facts.length, historyRows: history.rows.length };
140
+ } finally {
141
+ try { closeSync(lockFd); } catch {}
142
+ try { unlinkSync(lockPath); } catch {}
143
+ }
144
+ }
@@ -0,0 +1,45 @@
1
+ // IJFW v1.5.2 -- brain wiki section sentinels (Trident F-F2).
2
+ //
3
+ // Wiki pages carry LLM-managed regions delimited by HTML comments:
4
+ // <!-- ijfw:auto:begin section="current-state" -->
5
+ // ...AUTO content (replaced by the compiler)...
6
+ // <!-- ijfw:auto:end section="current-state" -->
7
+ //
8
+ // Everything OUTSIDE these blocks is operator-owned ("NOTES") and the
9
+ // compiler MUST preserve it verbatim. extractSections() returns the AUTO
10
+ // regions; replaceSection() swaps a named region's body while preserving
11
+ // the NOTES content and all other AUTO regions unchanged.
12
+
13
+ const SECTION_RE = /<!--\s*ijfw:auto:begin\s+section="([^"]+)"\s*-->([\s\S]*?)<!--\s*ijfw:auto:end\s+section="\1"\s*-->/g;
14
+
15
+ export function extractSections(md) {
16
+ if (typeof md !== 'string') return [];
17
+ const out = [];
18
+ for (const m of md.matchAll(SECTION_RE)) {
19
+ out.push({ name: m[1], body: m[2] });
20
+ }
21
+ return out;
22
+ }
23
+
24
+ function escapeRe(s) {
25
+ return s.replace(/[-/\\^$*+?.()|[\]{}]/g, '\\$&');
26
+ }
27
+
28
+ function makeBlock(name, body) {
29
+ return `<!-- ijfw:auto:begin section="${name}" -->\n${body}\n<!-- ijfw:auto:end section="${name}" -->`;
30
+ }
31
+
32
+ export function replaceSection(md, name, newBody) {
33
+ if (typeof md !== 'string') md = '';
34
+ const escaped = escapeRe(name);
35
+ const targetRe = new RegExp(
36
+ `<!--\\s*ijfw:auto:begin\\s+section="${escaped}"\\s*-->[\\s\\S]*?<!--\\s*ijfw:auto:end\\s+section="${escaped}"\\s*-->`
37
+ );
38
+ const replacement = makeBlock(name, newBody);
39
+ if (targetRe.test(md)) {
40
+ return md.replace(targetRe, replacement);
41
+ }
42
+ // Section absent -- append it. If md ends without trailing newline, add one.
43
+ const sep = md.length === 0 || md.endsWith('\n') ? '' : '\n';
44
+ return md + sep + '\n' + replacement + '\n';
45
+ }
@@ -0,0 +1,94 @@
1
+ // IJFW v1.5.2 -- brain wiki page templates.
2
+ //
3
+ // applyTemplate(type, existingMd, { subject, facts, history, backlinks, sources })
4
+ // returns markdown with 4 AUTO sections (current-state, history, backlinks,
5
+ // sources) wrapped in ijfw:auto sentinels. Operator-owned NOTES regions OUTSIDE
6
+ // the sentinels are preserved verbatim by the underlying replaceSection().
7
+ //
8
+ // Per Trident F-B2, the history section is windowed (consumer passes
9
+ // {rows, older} from getHistoryWindow); when older is present we append a
10
+ // rollup line ("Older: 55 events between A and B") instead of pasting more rows.
11
+
12
+ import { replaceSection } from './wiki-sentinels.js';
13
+
14
+ function isoDate(s) {
15
+ if (!s) return '';
16
+ try {
17
+ const d = new Date(s);
18
+ if (Number.isNaN(d.getTime())) return s;
19
+ return d.toISOString().slice(0, 10);
20
+ } catch { return s; }
21
+ }
22
+
23
+ function renderCurrentState({ subject, facts }) {
24
+ if (!facts || facts.length === 0) {
25
+ return `_No facts about **${subject}** yet._`;
26
+ }
27
+ const lines = [];
28
+ for (const f of facts) {
29
+ const cite = f.id != null ? ` [fact:${f.id}]` : '';
30
+ const memCite = f.memory_id != null ? ` [mem:${f.memory_id}]` : '';
31
+ lines.push(`- ${f.predicate || '(no predicate)'}: **${f.object ?? ''}**${cite}${memCite}`);
32
+ }
33
+ return lines.join('\n');
34
+ }
35
+
36
+ function renderHistory({ history }) {
37
+ if (!history) return '_No history available._';
38
+ const { rows = [], older = null } = history;
39
+ if (rows.length === 0) return '_No history available._';
40
+ const lines = [];
41
+ for (const r of rows) {
42
+ const date = isoDate(r.valid_from);
43
+ const closed = r.valid_to ? ` (closed ${isoDate(r.valid_to)})` : '';
44
+ const memCite = r.memory_id != null ? ` [mem:${r.memory_id}]` : '';
45
+ const factCite = r.id != null ? ` [fact:${r.id}]` : '';
46
+ lines.push(`- ${date}: ${r.predicate || '(no predicate)'} = **${r.object ?? ''}**${closed}${factCite}${memCite}`);
47
+ }
48
+ if (older) {
49
+ lines.push('');
50
+ lines.push(`_Older: ${older.count} event${older.count === 1 ? '' : 's'} between ${isoDate(older.fromIso)} and ${isoDate(older.toIso)}._`);
51
+ }
52
+ return lines.join('\n');
53
+ }
54
+
55
+ function renderBacklinks({ backlinks }) {
56
+ if (!backlinks || backlinks.length === 0) return '_No backlinks._';
57
+ const lines = [];
58
+ for (const b of backlinks) {
59
+ const target = b.target || b.to_target || '';
60
+ const n = b.count ?? b.incomingLinks ?? 1;
61
+ if (!target) continue;
62
+ lines.push(`- [[${target}]] (${n} link${n === 1 ? '' : 's'})`);
63
+ }
64
+ return lines.length ? lines.join('\n') : '_No backlinks._';
65
+ }
66
+
67
+ function renderSources({ sources }) {
68
+ if (!sources || sources.length === 0) return '_No sources._';
69
+ const top = sources.slice(0, 5);
70
+ const lines = [];
71
+ for (const s of top) {
72
+ const path = s.path || '(no path)';
73
+ const kind = s.kind ? ` (${s.kind})` : '';
74
+ const mentions = s.mentions != null ? ` — ${s.mentions} mention${s.mentions === 1 ? '' : 's'}` : '';
75
+ lines.push(`- \`${path}\`${kind}${mentions}`);
76
+ }
77
+ return lines.join('\n');
78
+ }
79
+
80
+ export function applyTemplate(type, existingMd, data) {
81
+ let md = existingMd || '';
82
+ md = replaceSection(md, 'current-state', renderCurrentState(data));
83
+ md = replaceSection(md, 'history', renderHistory(data));
84
+ md = replaceSection(md, 'backlinks', renderBacklinks(data));
85
+ md = replaceSection(md, 'sources', renderSources(data));
86
+ return md;
87
+ }
88
+
89
+ export {
90
+ renderCurrentState as _renderCurrentState,
91
+ renderHistory as _renderHistory,
92
+ renderBacklinks as _renderBacklinks,
93
+ renderSources as _renderSources,
94
+ };
@@ -410,6 +410,13 @@ function parseArgsInner(args) {
410
410
  return { cmd: 'extension', sub: args[1] || 'list', rest: args.slice(2) };
411
411
  }
412
412
 
413
+ if (args[0] === 'env') {
414
+ // v1.5.2 F6: `ijfw env` lists every IJFW_* env var with current value,
415
+ // default, and one-line description. Closes the configuration-sprawl
416
+ // discoverability gap surfaced in the v1.5.2 cross-audit.
417
+ return { cmd: 'env' };
418
+ }
419
+
413
420
  if (args[0] === 'swarm') {
414
421
  return { cmd: 'swarm', sub: args[1] || 'status' };
415
422
  }
@@ -500,6 +507,40 @@ function parseArgsInner(args) {
500
507
  // Commands
501
508
  // ---------------------------------------------------------------------------
502
509
 
510
+ // v1.5.2 F6: every IJFW_* env var the brain + memory subsystems read at runtime.
511
+ // Order: most-likely-to-set first. `default` is the documented fallback when
512
+ // the var is unset; `description` is one short line for `ijfw env` output.
513
+ const IJFW_ENV_VARS = [
514
+ // Brain (v1.5.2)
515
+ { name: 'IJFW_DREAM_BUDGET_USD', default: '0.50', description: 'Per-cycle USD cap for dream-cycle LLM extraction.' },
516
+ { name: 'IJFW_DREAM_BUDGET_DAY_USD', default: '5.00', description: 'Per-day USD cap; cycle stops when reached.' },
517
+ { name: 'IJFW_BRAIN_LOCAL_URL', default: '(unset)', description: 'Ollama-compatible local LLM endpoint to try first.' },
518
+ { name: 'IJFW_BRAIN_EXTRACT_MODEL', default: 'claude-haiku-4-5-20251001', description: 'Cheap-tier model id for fact extraction.' },
519
+ { name: 'IJFW_BRAIN_SYNTH_MODEL', default: 'claude-sonnet-4-6', description: 'Mid-tier model id for reconciliation + page synthesis.' },
520
+ { name: 'IJFW_BRAIN_API_KEY', default: '(falls back to ANTHROPIC_API_KEY)', description: 'API key for the synth-tier Anthropic call.' },
521
+ { name: 'IJFW_BRAIN_INJECT', default: 'never', description: '"auto"|"always" appends top-N wiki pages to handlePrelude.' },
522
+ // Memory-moat (v1.5.0 — A-Mem auto-linker)
523
+ { name: 'IJFW_AUTOLINK_OFF', default: '(unset)', description: 'Set to disable the A-Mem auto-linker entirely.' },
524
+ { name: 'IJFW_AUTOLINK_BUDGET_USD', default: '(unbounded if unset)', description: 'Per-write USD cap for auto-linker LLM calls.' },
525
+ { name: 'IJFW_AUTOLINK_BACKFILL', default: '(unset)', description: 'Set to "1" to opt into M2 backfill during `memory reindex --m2`.' },
526
+ ];
527
+
528
+ export function cmdEnv() {
529
+ const widthName = Math.max(...IJFW_ENV_VARS.map((v) => v.name.length)) + 2;
530
+ console.log('IJFW environment variables\n');
531
+ for (const v of IJFW_ENV_VARS) {
532
+ const current = process.env[v.name];
533
+ const shownValue = current !== undefined && current !== '' ? current : '(unset)';
534
+ const isOverridden = current !== undefined && current !== '';
535
+ const tag = isOverridden ? ' [SET]' : '';
536
+ console.log(` ${v.name.padEnd(widthName)} = ${shownValue}${tag}`);
537
+ console.log(` ${' '.repeat(widthName)} default: ${v.default}`);
538
+ console.log(` ${' '.repeat(widthName)} ${v.description}`);
539
+ console.log('');
540
+ }
541
+ console.log('Tip: variables tagged [SET] override their defaults. Unset values show the documented default in effect.');
542
+ }
543
+
503
544
  function printMemoryHelp() {
504
545
  console.log(`
505
546
  ijfw memory -- project memory namespace
@@ -516,6 +557,7 @@ Usage:
516
557
 
517
558
  Related:
518
559
  ijfw recover [status|latest] Inspect checkpoints and recovery state.
560
+ ijfw env List every IJFW_* env var, current value, default, description.
519
561
  ijfw --help Top-level user-facing commands.
520
562
  ijfw commands Full command surface (all verbs).
521
563
  `.trim());
@@ -1037,7 +1079,7 @@ async function cmdCross({ mode, target, only, confirm, expand, chunk }) {
1037
1079
  const chunks = buildChunkedTargets(absPath, rawTarget);
1038
1080
  console.log('');
1039
1081
  console.log(`--chunk: splitting ${rawTarget} into ${chunks.length} chunks (≈${(CHUNKER_DEFAULTS.chunkSize / 1024).toFixed(0)} KB each, ${(CHUNKER_DEFAULTS.overlap / 1024).toFixed(0)} KB overlap).`);
1040
- console.log(`Trident dispatches: ${chunks.length} × per-chunk audit. Cost scales linearly.`);
1082
+ console.log(`Trident dispatches: ${chunks.length} x per-chunk audit. Cost scales linearly.`);
1041
1083
 
1042
1084
  const perChunkResults = [];
1043
1085
  const auditorIds = new Set();
@@ -1072,7 +1114,7 @@ async function cmdCross({ mode, target, only, confirm, expand, chunk }) {
1072
1114
  }
1073
1115
  for (const f of merged) {
1074
1116
  const sev = (f.severity || 'note').toUpperCase();
1075
- const cluster = f.clusterSize > 1 ? ` [×${f.clusterSize}]` : '';
1117
+ const cluster = f.clusterSize > 1 ? ` [x${f.clusterSize}]` : '';
1076
1118
  const tgt = f.target ? ` ${f.target} —` : '';
1077
1119
  // v1.5.0 wire-W4: widen field fallback to cover description/issue/
1078
1120
  // detail/note/summary keys auditors emit. Closes the r19 "(no detail)"
@@ -2395,6 +2437,8 @@ if (isMainModule) {
2395
2437
  cmdMemoryCheckpoint(parsed.label);
2396
2438
  } else if (parsed.cmd === 'memory-reindex') {
2397
2439
  cmdMemoryReindex(parsed).catch(err => { console.error(err.message); process.exit(1); });
2440
+ } else if (parsed.cmd === 'env') {
2441
+ cmdEnv();
2398
2442
  } else if (parsed.cmd === 'recover') {
2399
2443
  cmdRecover(parsed.sub);
2400
2444
  } else {
@@ -2417,10 +2461,19 @@ function repoRootFromCli() {
2417
2461
  return join(here, '..', '..');
2418
2462
  }
2419
2463
  function findCliAsset(...rel) {
2464
+ // F-C-4 (Lens 3): probe XDG_DATA_HOME and XDG_CONFIG_HOME in addition to
2465
+ // ~/.ijfw and IJFW_HOME. Users who installed via a distro-aware packager
2466
+ // (e.g. dotfiles repo, nix, distro RPM, or any wrapper that honours XDG
2467
+ // base-dir spec) land their ijfw tree under $XDG_DATA_HOME/ijfw rather
2468
+ // than ~/.ijfw, and were silently invisible to the doctor fallback.
2469
+ const xdgData = process.env.XDG_DATA_HOME;
2470
+ const xdgConfig = process.env.XDG_CONFIG_HOME;
2420
2471
  const candidates = [
2421
2472
  join(repoRootFromCli(), ...rel),
2422
2473
  process.env.IJFW_HOME ? join(process.env.IJFW_HOME, ...rel) : null,
2423
2474
  join(homedir(), '.ijfw', ...rel),
2475
+ xdgData ? join(xdgData, 'ijfw', ...rel) : null,
2476
+ xdgConfig ? join(xdgConfig, 'ijfw', ...rel) : null,
2424
2477
  ].filter(Boolean);
2425
2478
  return candidates.find(p => existsSync(p)) || null;
2426
2479
  }
@@ -2919,6 +2972,42 @@ function cmdCodex(sub) {
2919
2972
  process.exit(1);
2920
2973
  }
2921
2974
 
2975
+ // F4.1/F4.2: resolve the canonical IJFW version with explicit source labelling
2976
+ // and a corrupt-vs-missing distinction. Exported so the codex-doctor tests can
2977
+ // exercise the fallback matrix without spawning the CLI against synthetic
2978
+ // repo trees. Inputs are absolute paths (or null) so callers can stub them.
2979
+ //
2980
+ // Returns: { canonicalVersion, canonicalSource, canonicalParseError } where
2981
+ // canonicalVersion : string | null (first source whose JSON parses)
2982
+ // canonicalSource : 'installer' | 'mcp-server' | null
2983
+ // canonicalParseError : string | null (only set if installer pkg corrupt)
2984
+ export function resolveCanonicalVersion({ installerPkg, selfPkg } = {}) {
2985
+ let canonicalVersion = null;
2986
+ let canonicalSource = null;
2987
+ let canonicalParseError = null;
2988
+ for (const [src, p] of [['installer', installerPkg], ['mcp-server', selfPkg]]) {
2989
+ if (!p || !existsSync(p)) continue;
2990
+ let raw;
2991
+ try {
2992
+ raw = readFileSync(p, 'utf8');
2993
+ } catch { continue; }
2994
+ try {
2995
+ canonicalVersion = JSON.parse(raw).version;
2996
+ canonicalSource = src;
2997
+ break;
2998
+ } catch (e) {
2999
+ // F-C-2 (Lens 3): capture parse error from whichever source was attempted
3000
+ // and label the source. Previously only installer parse errors surfaced,
3001
+ // so a corrupt mcp-server/package.json (e.g. partial pnpm install) caused
3002
+ // the doctor to render misleading "install @ijfw/install" fix text.
3003
+ if (canonicalParseError == null) {
3004
+ canonicalParseError = `${src}: ${e.message}`;
3005
+ }
3006
+ }
3007
+ }
3008
+ return { canonicalVersion, canonicalSource, canonicalParseError };
3009
+ }
3010
+
2922
3011
  function codexDoctor(projectRoot) {
2923
3012
  const root = resolve(projectRoot);
2924
3013
  const checks = [];
@@ -2946,12 +3035,50 @@ function codexDoctor(projectRoot) {
2946
3035
  const agentsMd = join(root, 'AGENTS.md');
2947
3036
 
2948
3037
  const plugin = readJsonFile(pluginPath);
3038
+ // v1.5.2.1: read the canonical version dynamically from
3039
+ // installer/package.json instead of comparing against a hardcoded literal.
3040
+ // The previous hardcoded '1.3.2' check drifted out of sync on every
3041
+ // release (latest observed: project at 1.5.1, doctor still expecting
3042
+ // 1.3.2 → false-positive failures on every fresh install). Reading from
3043
+ // installer/package.json keeps the doctor honest as long as that file
3044
+ // and the codex plugin.json are bumped together at ship-gate.
3045
+ //
3046
+ // F4.1: standalone @ijfw/memory-server installs do not ship installer/.
3047
+ // F4.3: regressing against the established findCliAsset() convention. Reuse it.
3048
+ // F4.2: differentiate ENOENT (missing) from SyntaxError (corrupt).
3049
+ const { canonicalVersion, canonicalSource, canonicalParseError } =
3050
+ resolveCanonicalVersion({
3051
+ installerPkg: findCliAsset('installer', 'package.json'),
3052
+ // F-C-1 (Lens 3): IJFW_HOME fallback was previously installer-only; make
3053
+ // mcp-server resolution symmetric so a custom-checkout user with IJFW_HOME
3054
+ // pointed at a partial tree also gets a working fallback.
3055
+ selfPkg: findCliAsset('mcp-server', 'package.json')
3056
+ ?? join(dirname(fileURLToPath(import.meta.url)), '..', 'package.json'),
3057
+ });
2949
3058
  checks.push({
2950
3059
  name: 'plugin metadata',
2951
- ok: plugin?.version === '1.3.2',
3060
+ ok: !!plugin && !!canonicalVersion && plugin.version === canonicalVersion,
2952
3061
  required: true,
2953
- message: plugin ? `version ${plugin.version}` : 'missing plugin.json',
2954
- fix: 'update codex/.codex-plugin/plugin.json',
3062
+ message: plugin
3063
+ ? (canonicalVersion
3064
+ ? `version ${plugin.version}${plugin.version === canonicalVersion ? '' : ` (expected ${canonicalVersion} per ${canonicalSource}/package.json)`}`
3065
+ : canonicalParseError
3066
+ ? `version ${plugin.version} (canonical source corrupt: ${canonicalParseError})`
3067
+ : `version ${plugin.version} (canonical version unreadable -- install @ijfw/install or set IJFW_HOME)`)
3068
+ : 'missing plugin.json',
3069
+ // F-C-9 (Lens 3): if plugin.json itself is missing, the fix text needs
3070
+ // to point at restoring plugin.json, NOT at the canonical-version dance.
3071
+ // canonicalParseError is now source-labelled by F-C-2 ("installer: ..."
3072
+ // or "mcp-server: ..."), so derive the specific package.json to restore.
3073
+ fix: !plugin
3074
+ ? `restore codex/.codex-plugin/plugin.json (try \`git checkout codex/.codex-plugin/plugin.json\`)`
3075
+ : canonicalVersion
3076
+ ? (plugin.version !== canonicalVersion
3077
+ ? `update codex/.codex-plugin/plugin.json version to ${canonicalVersion}`
3078
+ : null)
3079
+ : canonicalParseError
3080
+ ? `restore canonical source (try \`git checkout ${canonicalParseError.split(':')[0]}/package.json\`)`
3081
+ : `install @ijfw/install (npm i -g @ijfw/install), or set IJFW_HOME=<ijfw-repo-checkout>`,
2955
3082
  });
2956
3083
 
2957
3084
  const hooks = readJsonFile(hooksPath);
@@ -1084,7 +1084,7 @@ const DEFAULT_LENSES = ['codex', 'gemini', 'claude'];
1084
1084
 
1085
1085
  // v1.5.0 audit-H4.1 — hard upper bound on convergence iterations. A caller
1086
1086
  // asking for 100 rounds would burn 100 rounds of full Trident dispatch (~3
1087
- // auditors × ~90s = ~4.5h per cycle on cold start). 10 is well above the
1087
+ // auditors x ~90s = ~4.5h per cycle on cold start). 10 is well above the
1088
1088
  // observed empirical ceiling — the convergence loop almost always settles in
1089
1089
  // 2-3 iters; >5 is a smell, >10 is a misuse. Anything above the cap is
1090
1090
  // silently clamped to MAX_CONVERGE_ITERATIONS + emits a single dedup'd warning.
@@ -1176,7 +1176,7 @@ function buildCycleSummary(iteration, prior) {
1176
1176
  // projectRoot string (passed through to dispatch)
1177
1177
  // totalTimeoutMs v1.5.0 audit-MED-trident-M6 — cumulative wall-clock cap.
1178
1178
  // When set, an AbortController fires at the deadline and
1179
- // cancels remaining iterations. 3 iters × 3 lenses × 90s =
1179
+ // cancels remaining iterations. 3 iters x 3 lenses x 90s =
1180
1180
  // 270s worst case without a cap; this lets a caller say
1181
1181
  // "no more than 4 minutes for the whole convergence".
1182
1182
  // Defaults to env IJFW_AUDIT_CONVERGE_TOTAL_TIMEOUT_SEC.