@ijfw/memory-server 1.5.0 → 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 (71) hide show
  1. package/bin/ijfw-memorize +14 -7
  2. package/fixtures/team/book.json +6 -6
  3. package/fixtures/team/business.json +146 -20
  4. package/fixtures/team/content.json +6 -6
  5. package/fixtures/team/design.json +148 -20
  6. package/fixtures/team/mixed.json +206 -27
  7. package/fixtures/team/research.json +146 -20
  8. package/fixtures/team/software.json +148 -20
  9. package/package.json +8 -4
  10. package/src/brain/budget-guard.js +86 -0
  11. package/src/brain/citation-resolver.js +41 -0
  12. package/src/brain/context-injection.js +69 -0
  13. package/src/brain/discovery.js +83 -0
  14. package/src/brain/dream-pipeline.js +324 -0
  15. package/src/brain/dump-ingest.js +88 -0
  16. package/src/brain/entity-collapse.js +28 -0
  17. package/src/brain/export.js +112 -0
  18. package/src/brain/extractors/index.js +24 -0
  19. package/src/brain/extractors/markdown.js +27 -0
  20. package/src/brain/extractors/pdf.js +31 -0
  21. package/src/brain/extractors/transcript.js +38 -0
  22. package/src/brain/first-run-scan.js +61 -0
  23. package/src/brain/index.js +1 -0
  24. package/src/brain/layout-sentinel.js +29 -0
  25. package/src/brain/migrate-facts-internal-once.js +87 -0
  26. package/src/brain/path-guard.js +103 -0
  27. package/src/brain/paths.js +26 -0
  28. package/src/brain/promotion-suggester.js +41 -0
  29. package/src/brain/stub-detector.js +33 -0
  30. package/src/brain/tiered-llm.js +83 -0
  31. package/src/brain/wiki-compiler.js +144 -0
  32. package/src/brain/wiki-sentinels.js +45 -0
  33. package/src/brain/wiki-templates.js +94 -0
  34. package/src/cross-orchestrator-cli.js +336 -150
  35. package/src/cross-orchestrator.js +52 -3
  36. package/src/dashboard-server.js +1 -1
  37. package/src/dispatch/extension.js +1 -1
  38. package/src/dream/runner.mjs +21 -0
  39. package/src/extension-registry.js +2 -2
  40. package/src/handlers/brain-handler.js +319 -0
  41. package/src/hardware-signer.js +4 -2
  42. package/src/lib/ui-review-runner.js +48 -7
  43. package/src/memory/auto-linker.js +121 -2
  44. package/src/memory/benchmark.js +4 -3
  45. package/src/memory/layout-migrations/001-visible-layer.js +131 -0
  46. package/src/memory/layout-migrations/index.js +50 -0
  47. package/src/memory/migration-runner.js +37 -3
  48. package/src/memory/migrations/009-obsidian-backfill.js +50 -0
  49. package/src/memory/obsidian-parser.js +65 -2
  50. package/src/memory/reader.js +2 -1
  51. package/src/memory/search.js +190 -41
  52. package/src/memory/temporal.js +40 -1
  53. package/src/orchestrator/agents-md-blackboard.js +114 -1
  54. package/src/orchestrator/debug-trident-trigger.js +374 -0
  55. package/src/orchestrator/discipline-selector.js +276 -0
  56. package/src/orchestrator/merge-block-aware.js +15 -5
  57. package/src/orchestrator/post-done-runner.js +36 -8
  58. package/src/orchestrator/state-sdk.js +216 -10
  59. package/src/orchestrator/subagent-telemetry.js +19 -0
  60. package/src/orchestrator/wave-state.js +38 -0
  61. package/src/override-resolver.js +5 -3
  62. package/src/recovery/code-fixer.js +311 -6
  63. package/src/runtime-mediator.js +0 -1
  64. package/src/server.js +486 -132
  65. package/src/swarm-config.js +30 -22
  66. package/src/team/domain-templates/business.json +4 -1
  67. package/src/team/domain-templates/research.json +4 -1
  68. package/src/team/generator.js +162 -0
  69. package/src/update-apply.js +1 -1
  70. package/src/dashboard-charts.js +0 -239
  71. package/src/orchestrator/runtime-loop.js +0 -430
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';
@@ -41,6 +50,11 @@ import { maybeRerankWithVectors } from './search-hybrid.js';
41
50
  import { crossProjectSearch } from './cross-project-search.js';
42
51
  // R2-E -- single source of truth for markdown/HTML/control-char defanger.
43
52
  import { sanitizeContent } from './sanitizer.js';
53
+ // v1.5.1 R4-H3 — secret redaction on the direct ijfw_store write path.
54
+ // The redactor is already wired into FTS5 ingest + auto-memorize; the
55
+ // direct MCP store was the one bypass, so a secret pasted into an
56
+ // ijfw_store call could land in .ijfw/memory/*.md cleartext.
57
+ import { redactSecrets } from './redactor.js';
44
58
  // H5.5 / H5.6 — ingest-time fact extraction + semantic dedup. Closes
45
59
  // memory-engine.md competitor gaps (mem0/Zep extract facts; Graphiti dedups).
46
60
  // Both are pure-JS, zero-LLM, deterministic.
@@ -271,14 +285,16 @@ function validatePath(raw) {
271
285
  function isWritable(dir) {
272
286
  try {
273
287
  if (!existsSync(dir)) {
274
- // Try to create it; if that works it's writable.
288
+ // Try to create it; if mkdir fails, treat as non-writable.
275
289
  mkdirSync(dir, { recursive: true });
276
290
  return true;
277
291
  }
278
- // Exists -- probe with a tmp file.
279
- const probe = join(dir, `.ijfw-probe-${process.pid}-${Date.now()}`);
280
- writeFileSync(probe, '');
281
- 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);
282
298
  return true;
283
299
  } catch {
284
300
  return false;
@@ -307,6 +323,33 @@ function safeProjectDir() {
307
323
  const PROJECT_DIR = safeProjectDir();
308
324
  const PROJECT_HASH = createHash('sha256').update(PROJECT_DIR).digest('hex').slice(0, 12);
309
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.
310
353
  const MEMORY_DIR = join(IJFW_DIR, 'memory');
311
354
  const SESSIONS_DIR = join(IJFW_DIR, 'sessions');
312
355
  const GLOBAL_DIR = join(homedir(), '.ijfw', 'memory');
@@ -342,14 +385,18 @@ const NATIVE_CLAUDE_DIR = join(
342
385
  // Failures here do NOT write to stderr during startup -- any stderr byte during
343
386
  // MCP handshake can make strict clients (incl. Claude Code) mark the server
344
387
  // as failed. Subsequent store/read calls surface structured errors instead.
345
- try {
346
- [MEMORY_DIR, SESSIONS_DIR].forEach(dir => {
347
- if (!existsSync(dir)) mkdirSync(dir, { recursive: true });
348
- });
349
- } catch { /* handleStore/recall surface structured errors on first use */ }
350
- try {
351
- if (!existsSync(GLOBAL_DIR)) mkdirSync(GLOBAL_DIR, { recursive: true });
352
- } 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
+ }
353
400
 
354
401
  // R2-E -- sanitizeContent moved to mcp-server/src/sanitizer.js so MCP stores
355
402
  // and auto-memorize stores share a single implementation. Imported above.
@@ -462,7 +509,7 @@ function emitRecallObservation({ context_hint, from_project } = {}) {
462
509
 
463
510
  // --- Storage helpers ---
464
511
  function appendToJournal(entry) {
465
- const journalPath = join(MEMORY_DIR, 'project-journal.md');
512
+ const journalPath = join(paths().memoryDir, 'project-journal.md');
466
513
  const ts = new Date().toISOString();
467
514
  const line = `- [${ts}] ${entry}`;
468
515
  return appendLine(journalPath, line);
@@ -472,7 +519,7 @@ function appendToJournal(entry) {
472
519
  // similar to Claude's native auto-memory format: YAML frontmatter plus a body with
473
520
  // Why / How-to-apply sections. This is the format users retrieve well from.
474
521
  function appendStructuredToKnowledge({ type, summary, content, why, howToApply, tags }) {
475
- const filepath = join(MEMORY_DIR, 'knowledge.md');
522
+ const filepath = join(paths().memoryDir, 'knowledge.md');
476
523
  const ts = new Date().toISOString();
477
524
  const tagLine = tags && tags.length ? tags.join(', ') : '';
478
525
  const block = [
@@ -519,10 +566,10 @@ function appendToGlobalPrefs(entry, tags = []) {
519
566
  }
520
567
 
521
568
  function readKnowledgeBase() {
522
- return readOr(join(MEMORY_DIR, 'knowledge.md'));
569
+ return readOr(join(paths().memoryDir, 'knowledge.md'));
523
570
  }
524
571
  function readHandoff() {
525
- return readOr(join(MEMORY_DIR, 'handoff.md'));
572
+ return readOr(join(paths().memoryDir, 'handoff.md'));
526
573
  }
527
574
  // Read Claude Code native auto-memory for this project. Returns concatenated
528
575
  // sanitized content of all project_*.md files (skipping MEMORY.md index).
@@ -591,25 +638,8 @@ function readGlobalKnowledge() {
591
638
  ).join('\n\n');
592
639
  }
593
640
 
594
- function getSessionCount() {
595
- try {
596
- if (!existsSync(SESSIONS_DIR)) return 0;
597
- return readdirSync(SESSIONS_DIR).filter(f => f.endsWith('.md')).length;
598
- } catch {
599
- return 0;
600
- }
601
- }
602
-
603
- function getDecisionCount() {
604
- const journal = readOr(join(MEMORY_DIR, 'project-journal.md'));
605
- if (!journal) return 0;
606
- // Match only journal entry lines (we now prefix with - [timestamp]) -- not
607
- // arbitrary list bullets that might appear in seeded content.
608
- return (journal.match(/^- \[\d{4}-\d{2}-\d{2}T/gm) || []).length;
609
- }
610
-
611
641
  function getRecentJournalEntries(count = 5) {
612
- const journal = readOr(join(MEMORY_DIR, 'project-journal.md'));
642
+ const journal = readOr(join(paths().memoryDir, 'project-journal.md'));
613
643
  if (!journal) return '';
614
644
  const entries = journal.split('\n').filter(l => /^- \[\d{4}-/.test(l));
615
645
  return entries.slice(-count).join('\n');
@@ -620,7 +650,7 @@ function getRecentJournalEntries(count = 5) {
620
650
  // down to ms; collisions would only happen on near-simultaneous writes which
621
651
  // our atomic-append + fs flush ordering already serialise).
622
652
  function getRecentMemoriesForDedup(limit = 50) {
623
- const journal = readOr(join(MEMORY_DIR, 'project-journal.md'));
653
+ const journal = readOr(join(paths().memoryDir, 'project-journal.md'));
624
654
  if (!journal) return [];
625
655
  const lines = journal.split('\n').filter(l => /^- \[\d{4}-/.test(l));
626
656
  // Most-recent-last in the file → reverse so findNearDuplicate sees newest first.
@@ -635,7 +665,18 @@ function getRecentMemoriesForDedup(limit = 50) {
635
665
 
636
666
  // H5.5 — sidecar file for structured facts (one JSON object per line).
637
667
  // Append-only; consumed by handleRecall({context_hint:'facts'}).
638
- 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();
639
680
 
640
681
  // Stable short id for joining a fact back to its journal entry. We don't have
641
682
  // a uuid; the journal-line text itself + ts is unique enough for cross-ref.
@@ -650,7 +691,8 @@ function appendFactsToSidecar(facts, meta) {
650
691
  if (!Array.isArray(facts) || facts.length === 0) return { ok: true, written: 0 };
651
692
  try {
652
693
  const lines = facts.map(f => factToJsonl(f, meta)).join('\n') + '\n';
653
- appendFileSync(FACTS_FILE, lines);
694
+ // v1.5.2.1 H-2: factsFile() re-reads layout sentinel each call.
695
+ appendFileSync(factsFile(), lines);
654
696
  return { ok: true, written: facts.length };
655
697
  } catch (err) {
656
698
  // Non-fatal: facts are augmentation, not source-of-truth. Journal already
@@ -664,12 +706,16 @@ function appendFactsToSidecar(facts, meta) {
664
706
  // table (queryable point-in-time view) stay co-located. Lazy-opened on first
665
707
  // use so a project that never stores a memory never pays the better-sqlite3
666
708
  // load cost.
667
- 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();
668
714
  let _factsDbHandle = null;
669
715
  function getFactsDb() {
670
716
  if (_factsDbHandle) return _factsDbHandle;
671
717
  try {
672
- _factsDbHandle = openTemporalDbSync(FACTS_DB_FILE);
718
+ _factsDbHandle = openTemporalDbSync(factsDbFile());
673
719
  return _factsDbHandle;
674
720
  } catch (err) {
675
721
  // Non-fatal. JSONL sidecar still gets written; we just lose the bi-temporal
@@ -804,10 +850,10 @@ async function searchMemory(query, limit = 10, scope = 'project', opts = {}) {
804
850
  }
805
851
 
806
852
  const sources = [
807
- { name: 'team', content: readTeamKnowledge(), boost: 1.25, path: join(MEMORY_DIR, 'team-knowledge.md') },
808
- { name: 'knowledge', content: readKnowledgeBase(), boost: 1.15, path: join(MEMORY_DIR, 'knowledge.md') },
809
- { name: 'journal', content: readOr(join(MEMORY_DIR, 'project-journal.md')), boost: 1.0, path: join(MEMORY_DIR, 'project-journal.md') },
810
- { 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') },
811
857
  { name: 'global', content: readGlobalKnowledge(), boost: 0.95, path: null },
812
858
  { name: 'claude-native', content: readNativeClaudeMemory(), boost: 0.95, path: null },
813
859
  ];
@@ -991,7 +1037,7 @@ const TOOLS = [
991
1037
  },
992
1038
  {
993
1039
  name: 'ijfw_memory_search',
994
- description: 'Keyword search across memory sources. Up to 20 results. Scope defaults to current project; pass scope:"all" to search across every IJFW project ever opened on this machine (results tagged [project:<name>]). Pass scope:"sandbox" to retrieve sandboxed ijfw_run output -- include label to get the full output of a specific run, or omit label to list all available sandbox entries.',
1040
+ description: 'Keyword search across memory sources. Up to 20 results. Scope defaults to current project; pass scope:"all" to search across every IJFW project ever opened on this machine (results tagged [project:<name>]). Pass scope:"sandbox" to retrieve sandboxed ijfw_run output -- include label to get the full output of a specific run, or omit label to list all available sandbox entries. The query field also accepts colon-namespaced commands: "compute:<query>" hits the per-project FTS5 index, "graph:<query>" routes through the knowledge-graph search.',
995
1041
  inputSchema: {
996
1042
  type: 'object',
997
1043
  properties: {
@@ -1050,8 +1096,8 @@ const TOOLS = [
1050
1096
  inputSchema: {
1051
1097
  type: 'object',
1052
1098
  properties: {
1053
- period: { type: 'string', enum: ['today', '7d', '30d', 'all'], description: 'Time window (default 7d).' },
1054
- metric: { type: 'string', enum: ['tokens', 'cost', 'sessions', 'routing'], description: 'Which metric to render (default tokens).' }
1099
+ period: { type: 'string', enum: ['today', '7d', '30d', 'all'], default: '7d', description: 'Time window (default 7d).' },
1100
+ metric: { type: 'string', enum: ['tokens', 'cost', 'sessions', 'routing'], default: 'tokens', description: 'Which metric to render (default tokens).' }
1055
1101
  },
1056
1102
  required: []
1057
1103
  }
@@ -1072,7 +1118,7 @@ const TOOLS = [
1072
1118
  UPDATE_APPLY_TOOL,
1073
1119
  {
1074
1120
  name: 'ijfw_run',
1075
- description: 'Run a shell command. For commands likely to produce large output (builds, test suites, grep -r, log tails), use this instead of Bash -- full output is sandboxed to disk and a smart summary is returned to context. For git/nav/quick ops, use Bash directly.',
1121
+ description: 'Run a shell command. For commands likely to produce large output (builds, test suites, grep -r, log tails), use this instead of Bash -- full output is sandboxed to disk and a smart summary is returned to context. For git/nav/quick ops, use Bash directly. Also accepts colon-namespaced commands instead of a shell line: "compute:python", "compute:js", "index:<source>", "detect:project_type".',
1076
1122
  inputSchema: {
1077
1123
  type: 'object',
1078
1124
  properties: {
@@ -1083,15 +1129,30 @@ const TOOLS = [
1083
1129
  required: ['command'],
1084
1130
  },
1085
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
+ },
1086
1147
  {
1087
1148
  // v1.5.0 T13: ijfw_state — single MCP face for the state-SDK verb facade.
1088
1149
  // Absorbs the retired ijfw_subagent_post_done tool (post-done IS a state
1089
1150
  // transition → reachable as the `subagent.post-done` verb). All 20 frozen
1090
1151
  // verbs from STATE-SDK-CONTRACT §7 are reachable through this one tool,
1091
- // keeping the MCP cap at 12/12. The same `query(verb, payload, ctx)` core
1152
+ // keeping the MCP cap at 13/13. The same `query(verb, payload, ctx)` core
1092
1153
  // is also exposed as a JS import and a CLI colon-namespace (`ijfw state:<verb>`).
1093
1154
  name: 'ijfw_state',
1094
- description: 'State-SDK verb facade — invoke any of the 20 frozen verbs (workflow.*, wave.*, phase.*, subagent.*, event.emit, telemetry.record, roster.*, extension.set-active, decision.add, blocker.*, state.replay, state.validate) over the canonical physical state files. Single MCP face for the state-SDK; subagent.post-done is the verb that absorbed the retired ijfw_subagent_post_done tool. Returns the verb result with `ok` + `verbId` + verb-specific fields (see STATE-SDK-CONTRACT §7).',
1155
+ description: 'State-SDK verb facade — invoke any of the 20 frozen verbs over the canonical physical state files. The 20 verbs: workflow.get, workflow.set-phase, wave.get, wave.advance, wave.record-task, phase.plan-check, phase.complete, subagent.dispatch, subagent.checkpoint, subagent.post-done, event.emit, telemetry.record, roster.synthesize, roster.record, extension.set-active, decision.add, blocker.add, blocker.resolve, state.replay, state.validate. Single MCP face for the state-SDK; subagent.post-done is the verb that absorbed the retired ijfw_subagent_post_done tool. Returns the verb result with `ok` + `verbId` + verb-specific fields (see STATE-SDK-CONTRACT §7).',
1095
1156
  inputSchema: {
1096
1157
  type: 'object',
1097
1158
  properties: {
@@ -1110,15 +1171,16 @@ const TOOLS = [
1110
1171
  // (codex/gemini/claude by default) in parallel; if verdicts diverge,
1111
1172
  // re-runs with a CYCLE_SUMMARY of the disagreement until consensus or
1112
1173
  // maxIterations (default 3). Stall breaker halts on byte-identical
1113
- // iterations. Fills the 12th tool-cap slot.
1174
+ // iterations. Slot 14 of the 14/14 tool cap.
1114
1175
  name: 'ijfw_cross_audit_converge',
1115
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.',
1116
1177
  inputSchema: {
1117
1178
  type: 'object',
1118
1179
  properties: {
1119
1180
  commitRange: { type: 'string', description: 'Git commit range to audit (e.g. "HEAD~1..HEAD", "main..feature/x"). Required.' },
1120
- maxIterations: { type: 'number', description: 'Max convergence iterations (default 3). 1 → single-shot (fallback mode).' },
1181
+ maxIterations: { type: 'number', minimum: 1, maximum: 10, description: 'Max convergence iterations (default 3, capped at 10). 1 → single-shot (fallback mode).' },
1121
1182
  lenses: { type: 'array', items: { type: 'string' }, description: 'Lens ids to dispatch (default ["codex","gemini","claude"]).' },
1183
+ autoFix: { type: 'boolean', description: 'v1.5.1 (T27) — opt-in consensus auto-fix. When true, after a non-PASS convergence the consensus code-fixer AUTOMATICALLY MODIFIES CODE: it runs an atomic per-finding fix loop (one revertable git commit per fix) over HIGH findings that 2+ lenses agreed on. SAFETY BOUNDS: the fixer can only write files inside the audited project root (path-containment guard refuses out-of-root paths) and touches at most 10 distinct files per run (change cap — beyond it it stops and reports rather than mass-rewriting). Logic bugs are deferred to humans, never auto-patched. Results surface on result.autoFix without changing the verdict. Default false — the audit is read-only unless you explicitly opt in.' },
1122
1184
  },
1123
1185
  required: ['commitRange'],
1124
1186
  },
@@ -1250,10 +1312,12 @@ function handleRecall({ context_hint, detail_level = 'standard', from_project })
1250
1312
  }
1251
1313
  // SQL table empty -- fall through to JSONL back-compat path below.
1252
1314
  }
1253
- 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)) {
1254
1318
  return { text: 'No structured facts extracted yet. Store memories with key:value lines, "X uses Y", or "decided to ..." phrases to populate.' };
1255
1319
  }
1256
- const raw = readFileSync(FACTS_FILE, 'utf8');
1320
+ const raw = readFileSync(_ff, 'utf8');
1257
1321
  // detail_level === 'summary' → just count + a sample tail. Keeps token
1258
1322
  // usage bounded for session-start hydration.
1259
1323
  if (detail_level === 'summary') {
@@ -1272,6 +1336,69 @@ function handleRecall({ context_hint, detail_level = 'standard', from_project })
1272
1336
  return { text: results.map(r => `[${r.source}] ${r.content}`).join('\n') };
1273
1337
  }
1274
1338
 
1339
+ // v1.5.1 R4-H2 — wire the v1.5.0 memory-moat to the real write path.
1340
+ //
1341
+ // M1 (Obsidian wikilink/tag/meta indexing -> memory_links/_tags/_meta) and
1342
+ // M2 (A-Mem auto-linking) only fire inside memory/fts5.js#indexEntry. But the
1343
+ // production memory writers never called indexEntry: handleStore wrote the
1344
+ // markdown journal only, and search.js#autoIndex did a raw INSERT. So the
1345
+ // memory-moat's flagship — "memory that learns about you" — ran ONLY in the
1346
+ // benchmark harness. This helper routes a real ijfw_store through indexEntry,
1347
+ // which in one atomic INSERT also fires M1 (synchronous, idempotent) + M2
1348
+ // (fire-and-forget, env-gated via IJFW_AUTOLINK_OFF, budget-capped).
1349
+ //
1350
+ // Best-effort + fire-and-forget: handleStore stays synchronous and a missing
1351
+ // driver / unmigrated schema / DB error never breaks the markdown-or-JSONL
1352
+ // store path. The journal markdown remains the source of truth (hot tier);
1353
+ // the FTS5 row is the warm-tier mirror. Dedup safety: handleStore previously
1354
+ // did NO DB INSERT at all, so this is a NEW row, not a duplicate of an
1355
+ // existing write. search.js#autoIndex only batch-rebuilds when the FTS table
1356
+ // is empty (rowCount === 0) — it will skip an already-populated table — so a
1357
+ // store followed by a search cannot double-index the same entry.
1358
+ async function indexStoredEntryToFts5({ body, source, sessionId }) {
1359
+ if (typeof body !== 'string' || body.length === 0) return null;
1360
+ const fts5Mod = await import('./memory/fts5.js');
1361
+ const root = process.env.IJFW_PROJECT_DIR || PROJECT_DIR;
1362
+ const db = await fts5Mod.openDb(root);
1363
+ try {
1364
+ // indexEntry runs the ingest scrub gate + M1 indexObsidianRelations +
1365
+ // M2 autoLink internally. body is already sanitised + redacted by the
1366
+ // handleStore caller; the scrub gate re-running over already-clean text
1367
+ // is idempotent.
1368
+ const inserted = fts5Mod.indexEntry(db, {
1369
+ body,
1370
+ source: source || 'memory_store',
1371
+ session_id: sessionId || null,
1372
+ });
1373
+ // indexEntry dispatches M2 autoLink + the D2 graph auto-index as
1374
+ // fire-and-forget promises that still hold the db handle. We own the
1375
+ // handle here, so we MUST let those settle before closing the db —
1376
+ // otherwise autoLink races into a "database connection is not open"
1377
+ // error. Capture the promise references SYNCHRONOUSLY right after
1378
+ // indexEntry returns (no await in between) so an interleaved store
1379
+ // can't overwrite the module-level statics before we read them. Both
1380
+ // promises swallow their own failures, so awaiting them never rejects.
1381
+ // This keeps M2 wired on the real store path without changing
1382
+ // handleStore's fire-and-forget contract (the caller already treats
1383
+ // this whole function as fire-and-forget).
1384
+ const autoLinkP = fts5Mod.indexEntry.__lastAutoLinkPromise;
1385
+ const autoIndexP = typeof fts5Mod.__getLastAutoIndexPromise === 'function'
1386
+ ? fts5Mod.__getLastAutoIndexPromise()
1387
+ : null;
1388
+ try { await autoLinkP; } catch { /* swallowed by indexEntry */ }
1389
+ try { await autoIndexP; } catch { /* swallowed by indexEntry */ }
1390
+ return inserted;
1391
+ } finally {
1392
+ try { fts5Mod.closeDb(db); } catch { /* best-effort */ }
1393
+ }
1394
+ }
1395
+
1396
+ // Diagnostic hook for tests — holds the most recent FTS5/M1/M2 indexing
1397
+ // promise fired by handleStore so end-to-end tests can await deterministic
1398
+ // completion before asserting on memory_links / memory_tags. Production
1399
+ // callers do not read this.
1400
+ handleStore.__lastIndexPromise = null;
1401
+
1275
1402
  function handleStore({ content, type, tags = [], summary, why, how_to_apply }) {
1276
1403
  // --- Input Validation ---
1277
1404
  if (!content || typeof content !== 'string') {
@@ -1304,13 +1431,22 @@ function handleStore({ content, type, tags = [], summary, why, how_to_apply }) {
1304
1431
 
1305
1432
  // Sanitize ALL text fields -- never store raw user/agent text in markdown
1306
1433
  // that gets re-injected into a future LLM context.
1307
- const safeContent = sanitizeContent(content);
1434
+ //
1435
+ // v1.5.1 R4-H3 — secret redaction. sanitizeContent strips prompt-injection
1436
+ // control chars but does NOT scrub secret-shaped tokens (API keys, OAuth
1437
+ // secrets). Without this, a secret pasted into a direct ijfw_store call
1438
+ // lands in .ijfw/memory/*.md cleartext and re-injects into every future
1439
+ // recall. The redactor is already wired into the FTS5 ingest path
1440
+ // (memory/fts5.js#indexEntry) and the auto-memorize path; this closes the
1441
+ // direct MCP store as the one remaining bypass. Redact AFTER sanitize so
1442
+ // the redaction labels ([REDACTED:*]) are never themselves scrubbed.
1443
+ const safeContent = redactSecrets(sanitizeContent(content));
1308
1444
  if (!safeContent) {
1309
1445
  return { text: 'content was empty after sanitisation (only control/format chars).', isError: true };
1310
1446
  }
1311
- const safeSummary = summary ? sanitizeContent(summary).substring(0, 120) : '';
1312
- const safeWhy = why ? sanitizeContent(why) : '';
1313
- const safeHow = how_to_apply ? sanitizeContent(how_to_apply) : '';
1447
+ const safeSummary = summary ? redactSecrets(sanitizeContent(summary)).substring(0, 120) : '';
1448
+ const safeWhy = why ? redactSecrets(sanitizeContent(why)) : '';
1449
+ const safeHow = how_to_apply ? redactSecrets(sanitizeContent(how_to_apply)) : '';
1314
1450
 
1315
1451
  const tagStr = tags.length > 0 ? ` [${tags.join(', ')}]` : '';
1316
1452
  const journalEntry = `**${type}**${tagStr}: ${safeSummary || safeContent.substring(0, 200)}`;
@@ -1345,6 +1481,27 @@ function handleStore({ content, type, tags = [], summary, why, how_to_apply }) {
1345
1481
  return { text: `Memory journal is not writable (${journalResult.code}) -- check .ijfw/ directory permissions and retry.`, isError: true };
1346
1482
  }
1347
1483
 
1484
+ // v1.5.1 R4-H2 — mirror the stored entry into the FTS5 warm tier, which
1485
+ // also fires M1 (Obsidian indexing) + M2 (A-Mem auto-linking). Index the
1486
+ // full content (already sanitised + redacted above) so [[wikilinks]],
1487
+ // #tags and [key:: value] metadata land in memory_links/_tags/_meta and
1488
+ // the auto-linker sees the real body. Fire-and-forget: handleStore stays
1489
+ // synchronous and a DB failure never breaks the markdown store. The
1490
+ // promise is exposed for tests that need deterministic completion.
1491
+ try {
1492
+ handleStore.__lastIndexPromise = indexStoredEntryToFts5({
1493
+ body: safeContent,
1494
+ source: `memory_store:${type}`,
1495
+ sessionId: null,
1496
+ }).catch((e) => {
1497
+ try { console.error('[ijfw memory] FTS5/M1/M2 index failed:', e?.message || e); } catch { /* never throw */ }
1498
+ return null;
1499
+ });
1500
+ } catch (e) {
1501
+ handleStore.__lastIndexPromise = null;
1502
+ try { console.error('[ijfw memory] FTS5 index dispatch failed:', e?.message || e); } catch { /* never throw */ }
1503
+ }
1504
+
1348
1505
  // H5.5 — Fact extraction AFTER successful append. Best-effort: a failure
1349
1506
  // here is logged in the return text but does NOT poison the store result.
1350
1507
  // Memory-id ties facts.jsonl rows back to their journal entry.
@@ -1385,7 +1542,7 @@ function handleStore({ content, type, tags = [], summary, why, how_to_apply }) {
1385
1542
  }
1386
1543
 
1387
1544
  if (type === 'handoff') {
1388
- const handoffPath = join(MEMORY_DIR, 'handoff.md');
1545
+ const handoffPath = join(paths().memoryDir, 'handoff.md');
1389
1546
  const prior = readMarkdownFile(handoffPath);
1390
1547
  if (prior.ok && prior.content.trim()) {
1391
1548
  appendToJournal(`prior-handoff-archived: ${sanitizeContent(prior.content).substring(0, 500)}`);
@@ -1484,7 +1641,7 @@ async function handlePrelude({ detail_level = 'summary' } = {}) {
1484
1641
  try {
1485
1642
  const top = topKSuccessfulSkills(db, { k: 5 });
1486
1643
  if (top.length > 0) {
1487
- 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(', ');
1488
1645
  parts.push(
1489
1646
  '<ijfw-recommended-skills>',
1490
1647
  `Observed success this project: ${names}`,
@@ -1659,7 +1816,21 @@ async function handlePrelude({ detail_level = 'summary' } = {}) {
1659
1816
  return { text: abstain.join('\n') };
1660
1817
  }
1661
1818
 
1662
- 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 };
1663
1834
  }
1664
1835
 
1665
1836
  async function handleSearch({ query, limit = 10, scope = 'project', label }) {
@@ -1848,32 +2019,6 @@ function handleMetrics({ period = '7d', metric = 'tokens' } = {}) {
1848
2019
  return { text: out.join('\n') };
1849
2020
  }
1850
2021
 
1851
- function handleStatus() {
1852
- const sessionCount = getSessionCount();
1853
- const decisionCount = getDecisionCount();
1854
- const hasKnowledge = existsSync(join(MEMORY_DIR, 'knowledge.md'));
1855
- const hasHandoff = existsSync(join(MEMORY_DIR, 'handoff.md'));
1856
- const hasGlobal = readGlobalKnowledge().trim().length > 0;
1857
-
1858
- const parts = [];
1859
- if (hasKnowledge) {
1860
- const kb = readKnowledgeBase();
1861
- const kbLines = kb.split('\n').filter(l => l.trim().startsWith('**')).length;
1862
- parts.push(`Knowledge: ${kbLines} entries`);
1863
- }
1864
- if (sessionCount > 0 || decisionCount > 0) {
1865
- parts.push(`History: ${sessionCount} sessions, ${decisionCount} decisions`);
1866
- }
1867
- if (hasHandoff) {
1868
- const handoff = readHandoff();
1869
- const statusLine = handoff.split('\n').find(l => l.trim().length > 0 && !l.startsWith('<!--') && !l.startsWith('#'));
1870
- if (statusLine) parts.push(`Last: ${statusLine.trim().substring(0, 150)}`);
1871
- }
1872
- if (hasGlobal) parts.push('Project preferences loaded');
1873
-
1874
- return { text: parts.join('\n') || 'Fresh project -- no memory yet.' };
1875
- }
1876
-
1877
2022
  // --- MCP Protocol Handler (JSON-RPC 2.0 over stdio) ---
1878
2023
 
1879
2024
  function createResponse(id, result) {
@@ -1945,6 +2090,28 @@ function handleMessage(msg) {
1945
2090
  result = { text: JSON.stringify(r, null, 2), isError: !!(r && r.error) };
1946
2091
  break;
1947
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
+ }
1948
2115
  case 'ijfw_state': {
1949
2116
  // v1.5.0 T13: single MCP face for the state-SDK. Routes every call
1950
2117
  // into the same `query(verb, payload, ctx)` core that the JS module
@@ -1999,9 +2166,74 @@ function handleMessage(msg) {
1999
2166
  lenses: Array.isArray(a.lenses) && a.lenses.length > 0 ? a.lenses : undefined,
2000
2167
  dispatch: defaultConvergeDispatch,
2001
2168
  projectRoot: process.cwd(),
2169
+ // v1.5.1 R4-H4 — opt-in consensus auto-fix (T27). Threaded
2170
+ // from the tool schema so the code-fixer can genuinely fire;
2171
+ // default false so the audit stays non-mutating unless the
2172
+ // caller explicitly asks for it.
2173
+ autoFix: a.autoFix === true,
2002
2174
  });
2003
2175
  const isErr = r.verdict === 'consensus_failed' || r.verdict === 'FAIL' || r.verdict === 'UNREACHABLE';
2004
- result = { text: JSON.stringify(r, null, 2), isError: isErr };
2176
+ // v1.5.1 W2.D emit a canonical gate-result block through the
2177
+ // gate-result-formatter so this Trident-as-a-service surface is
2178
+ // consistent with the Trident gate (dispatch.js) and preflight
2179
+ // gates. appendGateResult guarantees the fenced block is the
2180
+ // LAST content emitted and is idempotent. Failure to format the
2181
+ // block must NOT clobber the verdict payload — observability,
2182
+ // not correctness.
2183
+ let convergeText = JSON.stringify(r, null, 2);
2184
+ try {
2185
+ const { emitGateResult } = await import('./gate-result.js');
2186
+ const { appendGateResult } = await import('./gate-result-formatter.js');
2187
+ // Map the converge verdict onto a schema-valid gate status.
2188
+ const VERDICT_TO_STATUS = {
2189
+ PASS: 'PASS',
2190
+ CONDITIONAL: 'CONDITIONAL',
2191
+ WARN: 'WARN',
2192
+ FLAG: 'FLAG',
2193
+ FAIL: 'FAIL',
2194
+ consensus_failed: 'FAIL',
2195
+ UNREACHABLE: 'FAIL',
2196
+ INCONCLUSIVE: 'FLAG',
2197
+ };
2198
+ const gateStatus = VERDICT_TO_STATUS[r.verdict] || 'FLAG';
2199
+ const block = await emitGateResult(
2200
+ {
2201
+ gate: 'cross-audit',
2202
+ status: gateStatus,
2203
+ lenses: [],
2204
+ affected_artifacts: [],
2205
+ accounting: {
2206
+ duration_ms:
2207
+ typeof r.duration_ms === 'number' ? r.duration_ms : 0,
2208
+ lenses_invoked: Array.isArray(a.lenses)
2209
+ ? a.lenses.length
2210
+ : 0,
2211
+ cost_usd: null,
2212
+ },
2213
+ remediation: [],
2214
+ },
2215
+ { projectRoot: process.cwd() },
2216
+ );
2217
+ // emitGateResult returns the fenced block as a string; the
2218
+ // formatter validates it back into an object before append.
2219
+ const parsed = JSON.parse(
2220
+ block.replace(/^```gate-result\n/, '').replace(/\n```$/, ''),
2221
+ );
2222
+ convergeText = appendGateResult(convergeText, parsed);
2223
+ } catch (gateErr) {
2224
+ try {
2225
+ const msg =
2226
+ gateErr && gateErr.message
2227
+ ? gateErr.message
2228
+ : String(gateErr);
2229
+ process.stderr.write(
2230
+ `ijfw: cross_audit_converge gate-result emit failed: ${msg}\n`,
2231
+ );
2232
+ } catch {
2233
+ /* never crash the tool on a logging-channel failure */
2234
+ }
2235
+ }
2236
+ result = { text: convergeText, isError: isErr };
2005
2237
  } catch (err) {
2006
2238
  result = { text: JSON.stringify({ error: err && err.message ? err.message : String(err) }), isError: true };
2007
2239
  }
@@ -2038,9 +2270,6 @@ function handleMessage(msg) {
2038
2270
  result = await handleSearch(searchArgs);
2039
2271
  break;
2040
2272
  }
2041
- case 'ijfw_memory_status':
2042
- result = handleStatus();
2043
- break;
2044
2273
  case 'ijfw_memory_prelude':
2045
2274
  result = await handlePrelude(args || {});
2046
2275
  break;
@@ -2153,51 +2382,175 @@ function handleMessage(msg) {
2153
2382
  }
2154
2383
  }
2155
2384
 
2385
+ // --- B17 WebSocket revocation client (dynamic-import gate) ---
2386
+ // extension-registry-ws.js is dormant by default. Its docstring contract:
2387
+ // "Imported via `await import(...)` ONLY when `process.env.IJFW_REGISTRY_WS_URL`
2388
+ // is set at startup." Firing the gate here at MCP startup keeps the module out
2389
+ // of the import graph entirely unless the operator opts in via the env var, so
2390
+ // MCP startup never opens a socket for the common (unset) case. Best-effort:
2391
+ // a failed WS bind must never block the stdio transport.
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;
2397
+ (async () => {
2398
+ try {
2399
+ const { initWsClient } = await import('./extension-registry-ws.js');
2400
+ const res = await initWsClient();
2401
+ if (!res.ok) {
2402
+ process.stderr.write(`IJFW: WS revocation client not started: ${res.error}\n`);
2403
+ }
2404
+ } catch (err) {
2405
+ process.stderr.write(`IJFW: WS revocation client init failed: ${err && err.message ? err.message : err}\n`);
2406
+ }
2407
+ })();
2408
+ }
2409
+
2156
2410
  // --- stdio Transport ---
2157
- 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
+ }
2158
2457
 
2159
- rl.on('line', (line) => {
2160
- if (!line.trim()) return;
2161
- let msg;
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
+ }
2476
+
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());
2162
2491
  try {
2163
- msg = JSON.parse(line);
2164
- } catch {
2165
- process.stdout.write(JSON.stringify({
2166
- jsonrpc: '2.0', id: null,
2167
- error: { code: -32700, message: 'Parse error' }
2168
- }) + '\n');
2169
- 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 {}
2170
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.
2171
2531
  try {
2172
- const response = handleMessage(msg);
2173
- if (response && typeof response.then === 'function') {
2174
- response.then(r => { if (r) process.stdout.write(r + '\n'); }).catch(err => {
2175
- process.stdout.write(JSON.stringify({
2176
- jsonrpc: '2.0',
2177
- id: msg && msg.id ? msg.id : null,
2178
- error: { code: -32603, message: `Internal error: ${err.message}` }
2179
- }) + '\n');
2180
- });
2181
- } else if (response) {
2182
- 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
+ }
2183
2541
  }
2184
- } catch (err) {
2185
- process.stdout.write(JSON.stringify({
2186
- jsonrpc: '2.0',
2187
- id: msg && msg.id ? msg.id : null,
2188
- error: { code: -32603, message: `Internal error: ${err.message}` }
2189
- }) + '\n');
2190
- }
2191
- });
2192
-
2193
- process.on('SIGINT', () => process.exit(0));
2194
- process.on('SIGTERM', () => process.exit(0));
2195
- process.on('uncaughtException', (err) => {
2196
- process.stderr.write(`IJFW: uncaught: ${err.stack || err.message}\n`);
2197
- });
2198
- process.on('unhandledRejection', (err) => {
2199
- process.stderr.write(`IJFW: unhandled rejection: ${err}\n`);
2200
- });
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
+ }
2201
2554
 
2202
2555
  // Export for tests (Node ESM allows this -- only consumed when imported, not on stdio run)
2203
2556
  // gatePermissionAndQuota is exported inline at its declaration above (B16/SEC-M-03)
@@ -2209,4 +2562,5 @@ export {
2209
2562
  handleStore, handleRecall, handleSearch, handlePrelude,
2210
2563
  MEMORY_DIR, FACTS_FILE, FACTS_DB_FILE,
2211
2564
  getFactsDb,
2565
+ paths,
2212
2566
  };