@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
package/src/server.js CHANGED
@@ -16,11 +16,19 @@ import { createInterface } from 'readline';
16
16
  import {
17
17
  existsSync, mkdirSync, readFileSync, writeFileSync,
18
18
  appendFileSync, readdirSync, statSync, renameSync, unlinkSync,
19
- openSync, closeSync, fsyncSync, realpathSync
19
+ openSync, closeSync, fsyncSync, realpathSync,
20
+ accessSync, constants as fsConstants
20
21
  } from 'fs';
21
22
  import { join, resolve, isAbsolute, normalize, basename, dirname } from 'path';
23
+ import { resolveBrainPaths } from './brain/paths.js';
24
+ import { migrateFactsInternalOnce } from './brain/migrate-facts-internal-once.js';
25
+ // v1.5.2.1 F3: fs-layout migrations live in their own directory with their
26
+ // own registry (not auto-discovered by the SQL migration-runner). Invoked
27
+ // inside __mainEntryPoint() only — never as a top-level side effect of
28
+ // importing server.js.
29
+ import { runLayoutMigrations } from './memory/layout-migrations/index.js';
22
30
  import { homedir } from 'os';
23
- import { fileURLToPath } from 'url';
31
+ import { fileURLToPath, pathToFileURL } from 'url';
24
32
  import { createHash, randomBytes } from 'crypto';
25
33
 
26
34
  // Read version dynamically from package.json so bumps don't require a code change.
@@ -30,6 +38,7 @@ const PKG_VERSION = (() => {
30
38
  catch { return 'unknown'; }
31
39
  })();
32
40
  import { checkPrompt } from './prompt-check.js';
41
+ import { handleIjfwBrain, IJFW_BRAIN_VERBS } from './handlers/brain-handler.js';
33
42
  import { applyCaps, CAP_CONTENT } from './caps.js';
34
43
  import { ensureSchemaHeader, SCHEMA_HEADER } from './schema.js';
35
44
  import { searchCorpus } from './search-bm25.js';
@@ -276,14 +285,16 @@ function validatePath(raw) {
276
285
  function isWritable(dir) {
277
286
  try {
278
287
  if (!existsSync(dir)) {
279
- // Try to create it; if that works it's writable.
288
+ // Try to create it; if mkdir fails, treat as non-writable.
280
289
  mkdirSync(dir, { recursive: true });
281
290
  return true;
282
291
  }
283
- // Exists -- probe with a tmp file.
284
- const probe = join(dir, `.ijfw-probe-${process.pid}-${Date.now()}`);
285
- writeFileSync(probe, '');
286
- unlinkSync(probe);
292
+ // v1.5.2.1 H-1 (Lens 1): previously wrote+unlinked a probe file
293
+ // (`.ijfw-probe-<pid>-<ts>`). That broke the "importing server.js
294
+ // produces ZERO filesystem artifacts" contract: the probe leaked
295
+ // under inotify/fswatch even when self-tests in tmpdir saw nothing.
296
+ // accessSync(W_OK) gives the same writability signal with no I/O.
297
+ accessSync(dir, fsConstants.W_OK);
287
298
  return true;
288
299
  } catch {
289
300
  return false;
@@ -312,6 +323,33 @@ function safeProjectDir() {
312
323
  const PROJECT_DIR = safeProjectDir();
313
324
  const PROJECT_HASH = createHash('sha256').update(PROJECT_DIR).digest('hex').slice(0, 12);
314
325
  const IJFW_DIR = join(PROJECT_DIR, '.ijfw');
326
+ // REPO_ROOT is the parent of .ijfw/ — required by resolveBrainPaths.
327
+ const REPO_ROOT = dirname(IJFW_DIR);
328
+ // paths() re-reads the layout sentinel on every call so a long-running server
329
+ // process picks up the migration-010 sentinel flip without restart.
330
+ function paths() { return resolveBrainPaths(REPO_ROOT); }
331
+ // v1.5.2.1 F1: __isServerEntryPoint gates ALL top-level imperative effects
332
+ // (facts relocation, fs-layout migration, dir bootstrap, WS client, stdio
333
+ // transport, signal handlers). Without this gate, importing server.js from
334
+ // a test or helper would fire every side-effect against the importer's cwd:
335
+ // stray `ijfw/`, `.ijfw/`, .ijfw-probe-* files in repos that aren't IJFW
336
+ // projects. The gate uses realpathSync on BOTH sides because process.argv[1]
337
+ // can be a symlinked bin shim (npm global installs) while import.meta.url
338
+ // resolves to the real path inside node_modules.
339
+ const __isServerEntryPoint = (() => {
340
+ if (!process.argv[1]) return false;
341
+ try {
342
+ const entryReal = realpathSync(process.argv[1]);
343
+ const selfReal = realpathSync(fileURLToPath(import.meta.url));
344
+ return entryReal === selfReal;
345
+ } catch (e) {
346
+ if (process.env.IJFW_DEBUG) {
347
+ try { process.stderr.write(`[ijfw entry-gate] check failed: ${e.message}\n`); } catch {}
348
+ }
349
+ return false;
350
+ }
351
+ })();
352
+ // Back-compat values for the line-2349 re-export. New code MUST use paths().memoryDir / paths().sessionsDir.
315
353
  const MEMORY_DIR = join(IJFW_DIR, 'memory');
316
354
  const SESSIONS_DIR = join(IJFW_DIR, 'sessions');
317
355
  const GLOBAL_DIR = join(homedir(), '.ijfw', 'memory');
@@ -347,14 +385,18 @@ const NATIVE_CLAUDE_DIR = join(
347
385
  // Failures here do NOT write to stderr during startup -- any stderr byte during
348
386
  // MCP handshake can make strict clients (incl. Claude Code) mark the server
349
387
  // as failed. Subsequent store/read calls surface structured errors instead.
350
- try {
351
- [MEMORY_DIR, SESSIONS_DIR].forEach(dir => {
352
- if (!existsSync(dir)) mkdirSync(dir, { recursive: true });
353
- });
354
- } catch { /* handleStore/recall surface structured errors on first use */ }
355
- try {
356
- if (!existsSync(GLOBAL_DIR)) mkdirSync(GLOBAL_DIR, { recursive: true });
357
- } catch { /* handleStore reports on attempted write */ }
388
+ // v1.5.2.1 F1: called from __mainEntryPoint() so importing server.js no
389
+ // longer creates directories in the importer's cwd.
390
+ function __bootstrapDirs() {
391
+ try {
392
+ [paths().memoryDir, paths().sessionsDir].forEach(dir => {
393
+ if (!existsSync(dir)) mkdirSync(dir, { recursive: true });
394
+ });
395
+ } catch { /* handleStore/recall surface structured errors on first use */ }
396
+ try {
397
+ if (!existsSync(GLOBAL_DIR)) mkdirSync(GLOBAL_DIR, { recursive: true });
398
+ } catch { /* handleStore reports on attempted write */ }
399
+ }
358
400
 
359
401
  // R2-E -- sanitizeContent moved to mcp-server/src/sanitizer.js so MCP stores
360
402
  // and auto-memorize stores share a single implementation. Imported above.
@@ -467,7 +509,7 @@ function emitRecallObservation({ context_hint, from_project } = {}) {
467
509
 
468
510
  // --- Storage helpers ---
469
511
  function appendToJournal(entry) {
470
- const journalPath = join(MEMORY_DIR, 'project-journal.md');
512
+ const journalPath = join(paths().memoryDir, 'project-journal.md');
471
513
  const ts = new Date().toISOString();
472
514
  const line = `- [${ts}] ${entry}`;
473
515
  return appendLine(journalPath, line);
@@ -477,7 +519,7 @@ function appendToJournal(entry) {
477
519
  // similar to Claude's native auto-memory format: YAML frontmatter plus a body with
478
520
  // Why / How-to-apply sections. This is the format users retrieve well from.
479
521
  function appendStructuredToKnowledge({ type, summary, content, why, howToApply, tags }) {
480
- const filepath = join(MEMORY_DIR, 'knowledge.md');
522
+ const filepath = join(paths().memoryDir, 'knowledge.md');
481
523
  const ts = new Date().toISOString();
482
524
  const tagLine = tags && tags.length ? tags.join(', ') : '';
483
525
  const block = [
@@ -524,10 +566,10 @@ function appendToGlobalPrefs(entry, tags = []) {
524
566
  }
525
567
 
526
568
  function readKnowledgeBase() {
527
- return readOr(join(MEMORY_DIR, 'knowledge.md'));
569
+ return readOr(join(paths().memoryDir, 'knowledge.md'));
528
570
  }
529
571
  function readHandoff() {
530
- return readOr(join(MEMORY_DIR, 'handoff.md'));
572
+ return readOr(join(paths().memoryDir, 'handoff.md'));
531
573
  }
532
574
  // Read Claude Code native auto-memory for this project. Returns concatenated
533
575
  // sanitized content of all project_*.md files (skipping MEMORY.md index).
@@ -597,7 +639,7 @@ function readGlobalKnowledge() {
597
639
  }
598
640
 
599
641
  function getRecentJournalEntries(count = 5) {
600
- const journal = readOr(join(MEMORY_DIR, 'project-journal.md'));
642
+ const journal = readOr(join(paths().memoryDir, 'project-journal.md'));
601
643
  if (!journal) return '';
602
644
  const entries = journal.split('\n').filter(l => /^- \[\d{4}-/.test(l));
603
645
  return entries.slice(-count).join('\n');
@@ -608,7 +650,7 @@ function getRecentJournalEntries(count = 5) {
608
650
  // down to ms; collisions would only happen on near-simultaneous writes which
609
651
  // our atomic-append + fs flush ordering already serialise).
610
652
  function getRecentMemoriesForDedup(limit = 50) {
611
- const journal = readOr(join(MEMORY_DIR, 'project-journal.md'));
653
+ const journal = readOr(join(paths().memoryDir, 'project-journal.md'));
612
654
  if (!journal) return [];
613
655
  const lines = journal.split('\n').filter(l => /^- \[\d{4}-/.test(l));
614
656
  // Most-recent-last in the file → reverse so findNearDuplicate sees newest first.
@@ -623,7 +665,18 @@ function getRecentMemoriesForDedup(limit = 50) {
623
665
 
624
666
  // H5.5 — sidecar file for structured facts (one JSON object per line).
625
667
  // Append-only; consumed by handleRecall({context_hint:'facts'}).
626
- const FACTS_FILE = join(MEMORY_DIR, 'facts.jsonl');
668
+ // v1.5.2 F5: now resolves via paths().factsJsonl (.ijfw/facts.jsonl) — internal
669
+ // hidden location per Task 3 design. The one-shot migrateFactsInternalOnce()
670
+ // call above relocated existing data from the legacy .ijfw/memory/ location.
671
+ //
672
+ // v1.5.2.1 H-2 (Lens 1): use accessor functions for internal callers so that
673
+ // a layout-migration sentinel flip mid-process (rare but possible) is picked
674
+ // up without a server restart. The named export `FACTS_FILE` (consumed by
675
+ // test-server-ingest.js as a one-shot import-time snapshot) is preserved
676
+ // below for back-compat.
677
+ function factsFile() { return paths().factsJsonl; }
678
+ function factsDbFile() { return paths().indexDb; }
679
+ const FACTS_FILE = factsFile();
627
680
 
628
681
  // Stable short id for joining a fact back to its journal entry. We don't have
629
682
  // a uuid; the journal-line text itself + ts is unique enough for cross-ref.
@@ -638,7 +691,8 @@ function appendFactsToSidecar(facts, meta) {
638
691
  if (!Array.isArray(facts) || facts.length === 0) return { ok: true, written: 0 };
639
692
  try {
640
693
  const lines = facts.map(f => factToJsonl(f, meta)).join('\n') + '\n';
641
- appendFileSync(FACTS_FILE, lines);
694
+ // v1.5.2.1 H-2: factsFile() re-reads layout sentinel each call.
695
+ appendFileSync(factsFile(), lines);
642
696
  return { ok: true, written: facts.length };
643
697
  } catch (err) {
644
698
  // Non-fatal: facts are augmentation, not source-of-truth. Journal already
@@ -652,12 +706,16 @@ function appendFactsToSidecar(facts, meta) {
652
706
  // table (queryable point-in-time view) stay co-located. Lazy-opened on first
653
707
  // use so a project that never stores a memory never pays the better-sqlite3
654
708
  // load cost.
655
- const FACTS_DB_FILE = join(MEMORY_DIR, 'facts.db');
709
+ // v1.5.2 F5: now resolves via paths().indexDb (.ijfw/index/memory.db) — internal
710
+ // hidden location per Task 3 design. Data migrated by migrateFactsInternalOnce().
711
+ // v1.5.2.1 H-2: FACTS_DB_FILE is the export-time snapshot; internal use goes
712
+ // through factsDbFile() above so mid-process sentinel flips are honored.
713
+ const FACTS_DB_FILE = factsDbFile();
656
714
  let _factsDbHandle = null;
657
715
  function getFactsDb() {
658
716
  if (_factsDbHandle) return _factsDbHandle;
659
717
  try {
660
- _factsDbHandle = openTemporalDbSync(FACTS_DB_FILE);
718
+ _factsDbHandle = openTemporalDbSync(factsDbFile());
661
719
  return _factsDbHandle;
662
720
  } catch (err) {
663
721
  // Non-fatal. JSONL sidecar still gets written; we just lose the bi-temporal
@@ -792,10 +850,10 @@ async function searchMemory(query, limit = 10, scope = 'project', opts = {}) {
792
850
  }
793
851
 
794
852
  const sources = [
795
- { name: 'team', content: readTeamKnowledge(), boost: 1.25, path: join(MEMORY_DIR, 'team-knowledge.md') },
796
- { name: 'knowledge', content: readKnowledgeBase(), boost: 1.15, path: join(MEMORY_DIR, 'knowledge.md') },
797
- { name: 'journal', content: readOr(join(MEMORY_DIR, 'project-journal.md')), boost: 1.0, path: join(MEMORY_DIR, 'project-journal.md') },
798
- { name: 'handoff', content: readHandoff(), boost: 1.1, path: join(MEMORY_DIR, 'handoff.md') },
853
+ { name: 'team', content: readTeamKnowledge(), boost: 1.25, path: join(paths().memoryDir, 'team-knowledge.md') },
854
+ { name: 'knowledge', content: readKnowledgeBase(), boost: 1.15, path: join(paths().memoryDir, 'knowledge.md') },
855
+ { name: 'journal', content: readOr(join(paths().memoryDir, 'project-journal.md')), boost: 1.0, path: join(paths().memoryDir, 'project-journal.md') },
856
+ { name: 'handoff', content: readHandoff(), boost: 1.1, path: join(paths().memoryDir, 'handoff.md') },
799
857
  { name: 'global', content: readGlobalKnowledge(), boost: 0.95, path: null },
800
858
  { name: 'claude-native', content: readNativeClaudeMemory(), boost: 0.95, path: null },
801
859
  ];
@@ -1071,6 +1129,21 @@ const TOOLS = [
1071
1129
  required: ['command'],
1072
1130
  },
1073
1131
  },
1132
+ {
1133
+ // v1.5.2 T24-27+T29: ijfw_brain — combined brain-query tool (slot 14/14).
1134
+ // Replaces what would have been 4 standalone tools; combined-tool pattern
1135
+ // keeps the cap raise to +1 instead of +4.
1136
+ name: 'ijfw_brain',
1137
+ description: 'IJFW Brain — query, links, wiki, conflict-resolve in one combined tool. verb=' + IJFW_BRAIN_VERBS.join('|'),
1138
+ inputSchema: {
1139
+ type: 'object',
1140
+ properties: {
1141
+ verb: { type: 'string', enum: IJFW_BRAIN_VERBS, description: 'Brain verb: think|links|wiki.get|wiki.compile|wiki.promote|wiki.export|wiki.shareReadme|conflict.resolve' },
1142
+ args: { type: 'object', description: 'Verb-specific arguments (see verb docs).' },
1143
+ },
1144
+ required: ['verb'],
1145
+ },
1146
+ },
1074
1147
  {
1075
1148
  // v1.5.0 T13: ijfw_state — single MCP face for the state-SDK verb facade.
1076
1149
  // Absorbs the retired ijfw_subagent_post_done tool (post-done IS a state
@@ -1098,7 +1171,7 @@ const TOOLS = [
1098
1171
  // (codex/gemini/claude by default) in parallel; if verdicts diverge,
1099
1172
  // re-runs with a CYCLE_SUMMARY of the disagreement until consensus or
1100
1173
  // maxIterations (default 3). Stall breaker halts on byte-identical
1101
- // iterations. Slot 12 of the 13/13 tool cap.
1174
+ // iterations. Slot 14 of the 14/14 tool cap.
1102
1175
  name: 'ijfw_cross_audit_converge',
1103
1176
  description: 'Multi-lens Trident audit with consensus convergence loop. Dispatches codex/gemini/claude in parallel against a commit range, detects verdict divergence, and re-runs with a cycle summary until consensus or maxIterations. Returns {verdict, iterations, findings, divergence?, stalled?}. Verdict: PASS / CONDITIONAL / FAIL / consensus_failed / UNREACHABLE.',
1104
1177
  inputSchema: {
@@ -1239,10 +1312,12 @@ function handleRecall({ context_hint, detail_level = 'standard', from_project })
1239
1312
  }
1240
1313
  // SQL table empty -- fall through to JSONL back-compat path below.
1241
1314
  }
1242
- if (!existsSync(FACTS_FILE)) {
1315
+ // v1.5.2.1 H-2: factsFile() re-resolves via paths() each call.
1316
+ const _ff = factsFile();
1317
+ if (!existsSync(_ff)) {
1243
1318
  return { text: 'No structured facts extracted yet. Store memories with key:value lines, "X uses Y", or "decided to ..." phrases to populate.' };
1244
1319
  }
1245
- const raw = readFileSync(FACTS_FILE, 'utf8');
1320
+ const raw = readFileSync(_ff, 'utf8');
1246
1321
  // detail_level === 'summary' → just count + a sample tail. Keeps token
1247
1322
  // usage bounded for session-start hydration.
1248
1323
  if (detail_level === 'summary') {
@@ -1467,7 +1542,7 @@ function handleStore({ content, type, tags = [], summary, why, how_to_apply }) {
1467
1542
  }
1468
1543
 
1469
1544
  if (type === 'handoff') {
1470
- const handoffPath = join(MEMORY_DIR, 'handoff.md');
1545
+ const handoffPath = join(paths().memoryDir, 'handoff.md');
1471
1546
  const prior = readMarkdownFile(handoffPath);
1472
1547
  if (prior.ok && prior.content.trim()) {
1473
1548
  appendToJournal(`prior-handoff-archived: ${sanitizeContent(prior.content).substring(0, 500)}`);
@@ -1566,7 +1641,7 @@ async function handlePrelude({ detail_level = 'summary' } = {}) {
1566
1641
  try {
1567
1642
  const top = topKSuccessfulSkills(db, { k: 5 });
1568
1643
  if (top.length > 0) {
1569
- const names = top.map((r) => `${r.skill_id} (${r.success_count}×)`).join(', ');
1644
+ const names = top.map((r) => `${r.skill_id} (${r.success_count}x)`).join(', ');
1570
1645
  parts.push(
1571
1646
  '<ijfw-recommended-skills>',
1572
1647
  `Observed success this project: ${names}`,
@@ -1741,7 +1816,21 @@ async function handlePrelude({ detail_level = 'summary' } = {}) {
1741
1816
  return { text: abstain.join('\n') };
1742
1817
  }
1743
1818
 
1744
- return { text };
1819
+ // v1.5.2 brain context injection — env-gated (IJFW_BRAIN_INJECT=auto|always|never).
1820
+ // Default 'never' keeps existing behavior unchanged; opt-in surfaces the
1821
+ // top-N most-recently-touched wiki pages to the prelude. Best-effort —
1822
+ // any failure is swallowed so prelude never breaks.
1823
+ let injectedText = text;
1824
+ try {
1825
+ const mode = process.env.IJFW_BRAIN_INJECT || 'never';
1826
+ if (mode === 'auto' || mode === 'always') {
1827
+ const { buildContextInjection } = await import('./brain/context-injection.js');
1828
+ const injection = buildContextInjection(REPO_ROOT, { mode });
1829
+ if (injection) injectedText = text + injection;
1830
+ }
1831
+ } catch { /* swallow — injection is best-effort */ }
1832
+
1833
+ return { text: injectedText };
1745
1834
  }
1746
1835
 
1747
1836
  async function handleSearch({ query, limit = 10, scope = 'project', label }) {
@@ -2001,6 +2090,28 @@ function handleMessage(msg) {
2001
2090
  result = { text: JSON.stringify(r, null, 2), isError: !!(r && r.error) };
2002
2091
  break;
2003
2092
  }
2093
+ case 'ijfw_brain': {
2094
+ // v1.5.2 T24-27+T29: combined brain verb facade.
2095
+ const a = args || {};
2096
+ if (typeof a.verb !== 'string' || a.verb.length === 0) {
2097
+ result = { text: JSON.stringify({ ok: false, error: 'verb (string) is required' }), isError: true };
2098
+ break;
2099
+ }
2100
+ try {
2101
+ const r = await handleIjfwBrain({
2102
+ verb: a.verb,
2103
+ args: (a.args && typeof a.args === 'object') ? a.args : {},
2104
+ db: getFactsDb(),
2105
+ repoRoot: REPO_ROOT,
2106
+ env: process.env,
2107
+ });
2108
+ result = { text: JSON.stringify(r, null, 2), isError: r.ok === false };
2109
+ } catch (err) {
2110
+ const msg = err && err.message ? err.message : String(err);
2111
+ result = { text: JSON.stringify({ ok: false, error: msg }), isError: true };
2112
+ }
2113
+ break;
2114
+ }
2004
2115
  case 'ijfw_state': {
2005
2116
  // v1.5.0 T13: single MCP face for the state-SDK. Routes every call
2006
2117
  // into the same `query(verb, payload, ctx)` core that the JS module
@@ -2278,7 +2389,11 @@ function handleMessage(msg) {
2278
2389
  // of the import graph entirely unless the operator opts in via the env var, so
2279
2390
  // MCP startup never opens a socket for the common (unset) case. Best-effort:
2280
2391
  // a failed WS bind must never block the stdio transport.
2281
- if (process.env.IJFW_REGISTRY_WS_URL || process.env.IJFW_REGISTRY_WS_SOURCE) {
2392
+ // v1.5.2.1 F1: called from __mainEntryPoint() so importing server.js no
2393
+ // longer fires the WS client even when the env var is set in the importer's
2394
+ // process (relevant in tests / dashboards that share env with the server).
2395
+ function __maybeStartWsClient() {
2396
+ if (!process.env.IJFW_REGISTRY_WS_URL && !process.env.IJFW_REGISTRY_WS_SOURCE) return;
2282
2397
  (async () => {
2283
2398
  try {
2284
2399
  const { initWsClient } = await import('./extension-registry-ws.js');
@@ -2293,50 +2408,149 @@ if (process.env.IJFW_REGISTRY_WS_URL || process.env.IJFW_REGISTRY_WS_SOURCE) {
2293
2408
  }
2294
2409
 
2295
2410
  // --- stdio Transport ---
2296
- const rl = createInterface({ input: process.stdin, terminal: false });
2411
+ // v1.5.2.1 F1: called from __mainEntryPoint(). Importing server.js used to
2412
+ // install a global stdin listener that swallowed every byte the importer's
2413
+ // process received — making the import poisonous to any host with its own
2414
+ // stdin loop.
2415
+ // v1.5.2.1 M-3 (Lens 1): idempotency flag so a re-entrant invocation (test
2416
+ // harness, repeated __mainEntryPoint call, etc.) does NOT install a second
2417
+ // stdin listener — duplicate listeners cause double-handling of each line.
2418
+ let __stdioAttached = false;
2419
+ function __attachStdioTransport() {
2420
+ if (__stdioAttached) return;
2421
+ __stdioAttached = true;
2422
+ const rl = createInterface({ input: process.stdin, terminal: false });
2423
+ rl.on('line', (line) => {
2424
+ if (!line.trim()) return;
2425
+ let msg;
2426
+ try {
2427
+ msg = JSON.parse(line);
2428
+ } catch {
2429
+ process.stdout.write(JSON.stringify({
2430
+ jsonrpc: '2.0', id: null,
2431
+ error: { code: -32700, message: 'Parse error' }
2432
+ }) + '\n');
2433
+ return;
2434
+ }
2435
+ try {
2436
+ const response = handleMessage(msg);
2437
+ if (response && typeof response.then === 'function') {
2438
+ response.then(r => { if (r) process.stdout.write(r + '\n'); }).catch(err => {
2439
+ process.stdout.write(JSON.stringify({
2440
+ jsonrpc: '2.0',
2441
+ id: msg && msg.id ? msg.id : null,
2442
+ error: { code: -32603, message: `Internal error: ${err.message}` }
2443
+ }) + '\n');
2444
+ });
2445
+ } else if (response) {
2446
+ process.stdout.write(response + '\n');
2447
+ }
2448
+ } catch (err) {
2449
+ process.stdout.write(JSON.stringify({
2450
+ jsonrpc: '2.0',
2451
+ id: msg && msg.id ? msg.id : null,
2452
+ error: { code: -32603, message: `Internal error: ${err.message}` }
2453
+ }) + '\n');
2454
+ }
2455
+ });
2456
+ }
2457
+
2458
+ // v1.5.2.1 F1: signal handlers belong to the server process — installing
2459
+ // them on import would steal SIGINT/SIGTERM from whatever host imported us.
2460
+ // v1.5.2.1 M-2 (Lens 1): idempotency flag — a re-entrant call would stack
2461
+ // duplicate SIGINT/SIGTERM handlers (each calling process.exit(0)) and
2462
+ // stack uncaughtException loggers (duplicating stderr noise per crash).
2463
+ let __signalsAttached = false;
2464
+ function __attachSignalHandlers() {
2465
+ if (__signalsAttached) return;
2466
+ __signalsAttached = true;
2467
+ process.on('SIGINT', () => process.exit(0));
2468
+ process.on('SIGTERM', () => process.exit(0));
2469
+ process.on('uncaughtException', (err) => {
2470
+ process.stderr.write(`IJFW: uncaught: ${err.stack || err.message}\n`);
2471
+ });
2472
+ process.on('unhandledRejection', (err) => {
2473
+ process.stderr.write(`IJFW: unhandled rejection: ${err}\n`);
2474
+ });
2475
+ }
2297
2476
 
2298
- rl.on('line', (line) => {
2299
- if (!line.trim()) return;
2300
- let msg;
2477
+ // v1.5.2.1 F6.META: when migrateFactsInternalOnce returns a list of orphan
2478
+ // files (stray facts at visible-layer paths after relocation), surface them
2479
+ // to the operator ONCE — keyed by a sentinel containing the fingerprint of
2480
+ // the orphan list, so repeated server starts don't spam stderr unless the
2481
+ // orphan set changes. Best-effort: any write failure is silent (stderr may
2482
+ // be detached during smoke tests; the sentinel may be in a read-only fs).
2483
+ async function __maybeWarnFactsOrphans(orphans) {
2484
+ if (!orphans || orphans.length === 0) return;
2485
+ const sentinelPath = join(REPO_ROOT, '.ijfw', '.facts-orphan-warned');
2486
+ // v1.5.2.1 M-5 (Lens 1): switched from '\n'.join to JSON.stringify so a
2487
+ // path containing an embedded newline cannot collide-by-fingerprint with
2488
+ // a different orphan set. JSON encodes the separator and each element
2489
+ // unambiguously.
2490
+ const orphanFingerprint = JSON.stringify(orphans.slice().sort());
2301
2491
  try {
2302
- msg = JSON.parse(line);
2303
- } catch {
2304
- process.stdout.write(JSON.stringify({
2305
- jsonrpc: '2.0', id: null,
2306
- error: { code: -32700, message: 'Parse error' }
2307
- }) + '\n');
2308
- return;
2492
+ const prev = readFileSync(sentinelPath, 'utf8');
2493
+ if (prev === orphanFingerprint) return;
2494
+ } catch { /* sentinel missing; fall through to warn */ }
2495
+ try {
2496
+ process.stderr.write(
2497
+ `[ijfw facts-migrate] stray facts files at visible-layer paths ` +
2498
+ `(NOT read at runtime; safe to delete after backup):\n` +
2499
+ orphans.map(p => ` rm "${p}"`).join('\n') + '\n'
2500
+ );
2501
+ // v1.5.2.1 M-4 (Lens 1): ensure parent dir exists before writing the
2502
+ // sentinel. The orphan-warn helper can fire before any code has created
2503
+ // .ijfw/ (e.g. on a brand-new repo where the only IJFW artifact is the
2504
+ // stray legacy file we're warning about).
2505
+ try {
2506
+ mkdirSync(dirname(sentinelPath), { recursive: true });
2507
+ writeFileSync(sentinelPath, orphanFingerprint, 'utf8');
2508
+ } catch {}
2509
+ } catch { /* stderr detached */ }
2510
+ }
2511
+
2512
+ // v1.5.2.1 F1: the single entry point for ALL server-startup side effects.
2513
+ // Anything that touches the filesystem, opens sockets, or installs process
2514
+ // listeners belongs here. The __isServerEntryPoint gate below ensures this
2515
+ // only fires when server.js is the Node entry point — never on import.
2516
+ async function __mainEntryPoint() {
2517
+ // F5: relocate FACTS_FILE / FACTS_DB_FILE from legacy .ijfw/memory/ to
2518
+ // .ijfw/. Sync + idempotent. F6.META: surface any orphan files left
2519
+ // behind so the operator can clean them up.
2520
+ try {
2521
+ const factsResult = migrateFactsInternalOnce(REPO_ROOT);
2522
+ if (factsResult && Array.isArray(factsResult.orphans) && factsResult.orphans.length > 0) {
2523
+ await __maybeWarnFactsOrphans(factsResult.orphans);
2524
+ }
2525
+ } catch (e) {
2526
+ try { process.stderr.write(`[ijfw facts-migrate] failed: ${e && e.message ? e.message : e}\n`); } catch {}
2309
2527
  }
2528
+ // F3.4: fs-layout migrations via static registry (NOT the SQL runner).
2529
+ // L-2 (Lens 1): runLayoutMigrations returns per-migration results so one
2530
+ // failure cannot abort siblings. Surface each failure to stderr.
2310
2531
  try {
2311
- const response = handleMessage(msg);
2312
- if (response && typeof response.then === 'function') {
2313
- response.then(r => { if (r) process.stdout.write(r + '\n'); }).catch(err => {
2314
- process.stdout.write(JSON.stringify({
2315
- jsonrpc: '2.0',
2316
- id: msg && msg.id ? msg.id : null,
2317
- error: { code: -32603, message: `Internal error: ${err.message}` }
2318
- }) + '\n');
2319
- });
2320
- } else if (response) {
2321
- process.stdout.write(response + '\n');
2532
+ const layoutResults = await runLayoutMigrations(REPO_ROOT);
2533
+ for (const r of layoutResults) {
2534
+ if (!r.ok) {
2535
+ try {
2536
+ process.stderr.write(
2537
+ `[ijfw layout-migrate] ${r.description}: ${r.error}\n`
2538
+ );
2539
+ } catch {}
2540
+ }
2322
2541
  }
2323
- } catch (err) {
2324
- process.stdout.write(JSON.stringify({
2325
- jsonrpc: '2.0',
2326
- id: msg && msg.id ? msg.id : null,
2327
- error: { code: -32603, message: `Internal error: ${err.message}` }
2328
- }) + '\n');
2329
- }
2330
- });
2331
-
2332
- process.on('SIGINT', () => process.exit(0));
2333
- process.on('SIGTERM', () => process.exit(0));
2334
- process.on('uncaughtException', (err) => {
2335
- process.stderr.write(`IJFW: uncaught: ${err.stack || err.message}\n`);
2336
- });
2337
- process.on('unhandledRejection', (err) => {
2338
- process.stderr.write(`IJFW: unhandled rejection: ${err}\n`);
2339
- });
2542
+ } catch (e) {
2543
+ try { process.stderr.write(`[ijfw layout-migrate] failed: ${e && e.message ? e.message : e}\n`); } catch {}
2544
+ }
2545
+ __bootstrapDirs();
2546
+ __maybeStartWsClient();
2547
+ __attachStdioTransport();
2548
+ __attachSignalHandlers();
2549
+ }
2550
+
2551
+ if (__isServerEntryPoint) {
2552
+ await __mainEntryPoint();
2553
+ }
2340
2554
 
2341
2555
  // Export for tests (Node ESM allows this -- only consumed when imported, not on stdio run)
2342
2556
  // gatePermissionAndQuota is exported inline at its declaration above (B16/SEC-M-03)
@@ -2348,4 +2562,5 @@ export {
2348
2562
  handleStore, handleRecall, handleSearch, handlePrelude,
2349
2563
  MEMORY_DIR, FACTS_FILE, FACTS_DB_FILE,
2350
2564
  getFactsDb,
2565
+ paths,
2351
2566
  };
@@ -7,7 +7,7 @@
7
7
  // terminal CLI does not require the sentinel to confirm — the token itself is
8
8
  // authoritative. The tool is retained for v1.5.0 back-compat (older skills that
9
9
  // still call it work unchanged) and slated for retirement in v1.6.0 to free the
10
- // MCP-tool slot (see CLAUDE.md "MCP server: ≤13 tools" cap).
10
+ // MCP-tool slot (see CLAUDE.md "MCP server: ≤14 tools" cap).
11
11
  //
12
12
  // Does NOT execute the update. Validates the token, writes (or overwrites)
13
13
  // the pending sentinel, returns instruction telling the user to run the