@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.
- package/bin/ijfw-memorize +14 -7
- package/fixtures/team/book.json +6 -6
- package/fixtures/team/business.json +146 -20
- package/fixtures/team/content.json +6 -6
- package/fixtures/team/design.json +148 -20
- package/fixtures/team/mixed.json +206 -27
- package/fixtures/team/research.json +146 -20
- package/fixtures/team/software.json +148 -20
- package/package.json +8 -4
- package/src/brain/budget-guard.js +86 -0
- package/src/brain/citation-resolver.js +41 -0
- package/src/brain/context-injection.js +69 -0
- package/src/brain/discovery.js +83 -0
- package/src/brain/dream-pipeline.js +324 -0
- package/src/brain/dump-ingest.js +88 -0
- package/src/brain/entity-collapse.js +28 -0
- package/src/brain/export.js +112 -0
- package/src/brain/extractors/index.js +24 -0
- package/src/brain/extractors/markdown.js +27 -0
- package/src/brain/extractors/pdf.js +31 -0
- package/src/brain/extractors/transcript.js +38 -0
- package/src/brain/first-run-scan.js +61 -0
- package/src/brain/index.js +1 -0
- package/src/brain/layout-sentinel.js +29 -0
- package/src/brain/migrate-facts-internal-once.js +87 -0
- package/src/brain/path-guard.js +103 -0
- package/src/brain/paths.js +26 -0
- package/src/brain/promotion-suggester.js +41 -0
- package/src/brain/stub-detector.js +33 -0
- package/src/brain/tiered-llm.js +83 -0
- package/src/brain/wiki-compiler.js +144 -0
- package/src/brain/wiki-sentinels.js +45 -0
- package/src/brain/wiki-templates.js +94 -0
- package/src/cross-orchestrator-cli.js +336 -150
- package/src/cross-orchestrator.js +52 -3
- package/src/dashboard-server.js +1 -1
- package/src/dispatch/extension.js +1 -1
- package/src/dream/runner.mjs +21 -0
- package/src/extension-registry.js +2 -2
- package/src/handlers/brain-handler.js +319 -0
- package/src/hardware-signer.js +4 -2
- package/src/lib/ui-review-runner.js +48 -7
- package/src/memory/auto-linker.js +121 -2
- package/src/memory/benchmark.js +4 -3
- package/src/memory/layout-migrations/001-visible-layer.js +131 -0
- package/src/memory/layout-migrations/index.js +50 -0
- package/src/memory/migration-runner.js +37 -3
- package/src/memory/migrations/009-obsidian-backfill.js +50 -0
- package/src/memory/obsidian-parser.js +65 -2
- package/src/memory/reader.js +2 -1
- package/src/memory/search.js +190 -41
- package/src/memory/temporal.js +40 -1
- package/src/orchestrator/agents-md-blackboard.js +114 -1
- package/src/orchestrator/debug-trident-trigger.js +374 -0
- package/src/orchestrator/discipline-selector.js +276 -0
- package/src/orchestrator/merge-block-aware.js +15 -5
- package/src/orchestrator/post-done-runner.js +36 -8
- package/src/orchestrator/state-sdk.js +216 -10
- package/src/orchestrator/subagent-telemetry.js +19 -0
- package/src/orchestrator/wave-state.js +38 -0
- package/src/override-resolver.js +5 -3
- package/src/recovery/code-fixer.js +311 -6
- package/src/runtime-mediator.js +0 -1
- package/src/server.js +486 -132
- package/src/swarm-config.js +30 -22
- package/src/team/domain-templates/business.json +4 -1
- package/src/team/domain-templates/research.json +4 -1
- package/src/team/generator.js +162 -0
- package/src/update-apply.js +1 -1
- package/src/dashboard-charts.js +0 -239
- 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
|
|
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
|
-
//
|
|
279
|
-
|
|
280
|
-
|
|
281
|
-
|
|
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
|
-
|
|
346
|
-
|
|
347
|
-
|
|
348
|
-
|
|
349
|
-
|
|
350
|
-
|
|
351
|
-
|
|
352
|
-
} catch { /* handleStore
|
|
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(
|
|
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(
|
|
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(
|
|
569
|
+
return readOr(join(paths().memoryDir, 'knowledge.md'));
|
|
523
570
|
}
|
|
524
571
|
function readHandoff() {
|
|
525
|
-
return readOr(join(
|
|
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(
|
|
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(
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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(
|
|
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(
|
|
808
|
-
{ name: 'knowledge', content: readKnowledgeBase(), boost: 1.15, path: join(
|
|
809
|
-
{ name: 'journal', content: readOr(join(
|
|
810
|
-
{ name: 'handoff', content: readHandoff(), boost: 1.1, path: join(
|
|
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
|
|
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
|
|
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.
|
|
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
|
-
|
|
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(
|
|
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
|
-
|
|
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(
|
|
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}
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
2160
|
-
|
|
2161
|
-
|
|
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
|
-
|
|
2164
|
-
|
|
2165
|
-
|
|
2166
|
-
|
|
2167
|
-
|
|
2168
|
-
|
|
2169
|
-
|
|
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
|
|
2173
|
-
|
|
2174
|
-
|
|
2175
|
-
|
|
2176
|
-
|
|
2177
|
-
|
|
2178
|
-
|
|
2179
|
-
}
|
|
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 (
|
|
2185
|
-
process.
|
|
2186
|
-
|
|
2187
|
-
|
|
2188
|
-
|
|
2189
|
-
|
|
2190
|
-
|
|
2191
|
-
}
|
|
2192
|
-
|
|
2193
|
-
|
|
2194
|
-
|
|
2195
|
-
|
|
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
|
};
|