@ijfw/memory-server 1.5.0 → 1.5.1

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/src/server.js CHANGED
@@ -41,6 +41,11 @@ import { maybeRerankWithVectors } from './search-hybrid.js';
41
41
  import { crossProjectSearch } from './cross-project-search.js';
42
42
  // R2-E -- single source of truth for markdown/HTML/control-char defanger.
43
43
  import { sanitizeContent } from './sanitizer.js';
44
+ // v1.5.1 R4-H3 — secret redaction on the direct ijfw_store write path.
45
+ // The redactor is already wired into FTS5 ingest + auto-memorize; the
46
+ // direct MCP store was the one bypass, so a secret pasted into an
47
+ // ijfw_store call could land in .ijfw/memory/*.md cleartext.
48
+ import { redactSecrets } from './redactor.js';
44
49
  // H5.5 / H5.6 — ingest-time fact extraction + semantic dedup. Closes
45
50
  // memory-engine.md competitor gaps (mem0/Zep extract facts; Graphiti dedups).
46
51
  // Both are pure-JS, zero-LLM, deterministic.
@@ -591,23 +596,6 @@ function readGlobalKnowledge() {
591
596
  ).join('\n\n');
592
597
  }
593
598
 
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
599
  function getRecentJournalEntries(count = 5) {
612
600
  const journal = readOr(join(MEMORY_DIR, 'project-journal.md'));
613
601
  if (!journal) return '';
@@ -991,7 +979,7 @@ const TOOLS = [
991
979
  },
992
980
  {
993
981
  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.',
982
+ 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
983
  inputSchema: {
996
984
  type: 'object',
997
985
  properties: {
@@ -1050,8 +1038,8 @@ const TOOLS = [
1050
1038
  inputSchema: {
1051
1039
  type: 'object',
1052
1040
  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).' }
1041
+ period: { type: 'string', enum: ['today', '7d', '30d', 'all'], default: '7d', description: 'Time window (default 7d).' },
1042
+ metric: { type: 'string', enum: ['tokens', 'cost', 'sessions', 'routing'], default: 'tokens', description: 'Which metric to render (default tokens).' }
1055
1043
  },
1056
1044
  required: []
1057
1045
  }
@@ -1072,7 +1060,7 @@ const TOOLS = [
1072
1060
  UPDATE_APPLY_TOOL,
1073
1061
  {
1074
1062
  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.',
1063
+ 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
1064
  inputSchema: {
1077
1065
  type: 'object',
1078
1066
  properties: {
@@ -1088,10 +1076,10 @@ const TOOLS = [
1088
1076
  // Absorbs the retired ijfw_subagent_post_done tool (post-done IS a state
1089
1077
  // transition → reachable as the `subagent.post-done` verb). All 20 frozen
1090
1078
  // 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
1079
+ // keeping the MCP cap at 13/13. The same `query(verb, payload, ctx)` core
1092
1080
  // is also exposed as a JS import and a CLI colon-namespace (`ijfw state:<verb>`).
1093
1081
  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).',
1082
+ 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
1083
  inputSchema: {
1096
1084
  type: 'object',
1097
1085
  properties: {
@@ -1110,15 +1098,16 @@ const TOOLS = [
1110
1098
  // (codex/gemini/claude by default) in parallel; if verdicts diverge,
1111
1099
  // re-runs with a CYCLE_SUMMARY of the disagreement until consensus or
1112
1100
  // maxIterations (default 3). Stall breaker halts on byte-identical
1113
- // iterations. Fills the 12th tool-cap slot.
1101
+ // iterations. Slot 12 of the 13/13 tool cap.
1114
1102
  name: 'ijfw_cross_audit_converge',
1115
1103
  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
1104
  inputSchema: {
1117
1105
  type: 'object',
1118
1106
  properties: {
1119
1107
  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).' },
1108
+ maxIterations: { type: 'number', minimum: 1, maximum: 10, description: 'Max convergence iterations (default 3, capped at 10). 1 → single-shot (fallback mode).' },
1121
1109
  lenses: { type: 'array', items: { type: 'string' }, description: 'Lens ids to dispatch (default ["codex","gemini","claude"]).' },
1110
+ 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
1111
  },
1123
1112
  required: ['commitRange'],
1124
1113
  },
@@ -1272,6 +1261,69 @@ function handleRecall({ context_hint, detail_level = 'standard', from_project })
1272
1261
  return { text: results.map(r => `[${r.source}] ${r.content}`).join('\n') };
1273
1262
  }
1274
1263
 
1264
+ // v1.5.1 R4-H2 — wire the v1.5.0 memory-moat to the real write path.
1265
+ //
1266
+ // M1 (Obsidian wikilink/tag/meta indexing -> memory_links/_tags/_meta) and
1267
+ // M2 (A-Mem auto-linking) only fire inside memory/fts5.js#indexEntry. But the
1268
+ // production memory writers never called indexEntry: handleStore wrote the
1269
+ // markdown journal only, and search.js#autoIndex did a raw INSERT. So the
1270
+ // memory-moat's flagship — "memory that learns about you" — ran ONLY in the
1271
+ // benchmark harness. This helper routes a real ijfw_store through indexEntry,
1272
+ // which in one atomic INSERT also fires M1 (synchronous, idempotent) + M2
1273
+ // (fire-and-forget, env-gated via IJFW_AUTOLINK_OFF, budget-capped).
1274
+ //
1275
+ // Best-effort + fire-and-forget: handleStore stays synchronous and a missing
1276
+ // driver / unmigrated schema / DB error never breaks the markdown-or-JSONL
1277
+ // store path. The journal markdown remains the source of truth (hot tier);
1278
+ // the FTS5 row is the warm-tier mirror. Dedup safety: handleStore previously
1279
+ // did NO DB INSERT at all, so this is a NEW row, not a duplicate of an
1280
+ // existing write. search.js#autoIndex only batch-rebuilds when the FTS table
1281
+ // is empty (rowCount === 0) — it will skip an already-populated table — so a
1282
+ // store followed by a search cannot double-index the same entry.
1283
+ async function indexStoredEntryToFts5({ body, source, sessionId }) {
1284
+ if (typeof body !== 'string' || body.length === 0) return null;
1285
+ const fts5Mod = await import('./memory/fts5.js');
1286
+ const root = process.env.IJFW_PROJECT_DIR || PROJECT_DIR;
1287
+ const db = await fts5Mod.openDb(root);
1288
+ try {
1289
+ // indexEntry runs the ingest scrub gate + M1 indexObsidianRelations +
1290
+ // M2 autoLink internally. body is already sanitised + redacted by the
1291
+ // handleStore caller; the scrub gate re-running over already-clean text
1292
+ // is idempotent.
1293
+ const inserted = fts5Mod.indexEntry(db, {
1294
+ body,
1295
+ source: source || 'memory_store',
1296
+ session_id: sessionId || null,
1297
+ });
1298
+ // indexEntry dispatches M2 autoLink + the D2 graph auto-index as
1299
+ // fire-and-forget promises that still hold the db handle. We own the
1300
+ // handle here, so we MUST let those settle before closing the db —
1301
+ // otherwise autoLink races into a "database connection is not open"
1302
+ // error. Capture the promise references SYNCHRONOUSLY right after
1303
+ // indexEntry returns (no await in between) so an interleaved store
1304
+ // can't overwrite the module-level statics before we read them. Both
1305
+ // promises swallow their own failures, so awaiting them never rejects.
1306
+ // This keeps M2 wired on the real store path without changing
1307
+ // handleStore's fire-and-forget contract (the caller already treats
1308
+ // this whole function as fire-and-forget).
1309
+ const autoLinkP = fts5Mod.indexEntry.__lastAutoLinkPromise;
1310
+ const autoIndexP = typeof fts5Mod.__getLastAutoIndexPromise === 'function'
1311
+ ? fts5Mod.__getLastAutoIndexPromise()
1312
+ : null;
1313
+ try { await autoLinkP; } catch { /* swallowed by indexEntry */ }
1314
+ try { await autoIndexP; } catch { /* swallowed by indexEntry */ }
1315
+ return inserted;
1316
+ } finally {
1317
+ try { fts5Mod.closeDb(db); } catch { /* best-effort */ }
1318
+ }
1319
+ }
1320
+
1321
+ // Diagnostic hook for tests — holds the most recent FTS5/M1/M2 indexing
1322
+ // promise fired by handleStore so end-to-end tests can await deterministic
1323
+ // completion before asserting on memory_links / memory_tags. Production
1324
+ // callers do not read this.
1325
+ handleStore.__lastIndexPromise = null;
1326
+
1275
1327
  function handleStore({ content, type, tags = [], summary, why, how_to_apply }) {
1276
1328
  // --- Input Validation ---
1277
1329
  if (!content || typeof content !== 'string') {
@@ -1304,13 +1356,22 @@ function handleStore({ content, type, tags = [], summary, why, how_to_apply }) {
1304
1356
 
1305
1357
  // Sanitize ALL text fields -- never store raw user/agent text in markdown
1306
1358
  // that gets re-injected into a future LLM context.
1307
- const safeContent = sanitizeContent(content);
1359
+ //
1360
+ // v1.5.1 R4-H3 — secret redaction. sanitizeContent strips prompt-injection
1361
+ // control chars but does NOT scrub secret-shaped tokens (API keys, OAuth
1362
+ // secrets). Without this, a secret pasted into a direct ijfw_store call
1363
+ // lands in .ijfw/memory/*.md cleartext and re-injects into every future
1364
+ // recall. The redactor is already wired into the FTS5 ingest path
1365
+ // (memory/fts5.js#indexEntry) and the auto-memorize path; this closes the
1366
+ // direct MCP store as the one remaining bypass. Redact AFTER sanitize so
1367
+ // the redaction labels ([REDACTED:*]) are never themselves scrubbed.
1368
+ const safeContent = redactSecrets(sanitizeContent(content));
1308
1369
  if (!safeContent) {
1309
1370
  return { text: 'content was empty after sanitisation (only control/format chars).', isError: true };
1310
1371
  }
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) : '';
1372
+ const safeSummary = summary ? redactSecrets(sanitizeContent(summary)).substring(0, 120) : '';
1373
+ const safeWhy = why ? redactSecrets(sanitizeContent(why)) : '';
1374
+ const safeHow = how_to_apply ? redactSecrets(sanitizeContent(how_to_apply)) : '';
1314
1375
 
1315
1376
  const tagStr = tags.length > 0 ? ` [${tags.join(', ')}]` : '';
1316
1377
  const journalEntry = `**${type}**${tagStr}: ${safeSummary || safeContent.substring(0, 200)}`;
@@ -1345,6 +1406,27 @@ function handleStore({ content, type, tags = [], summary, why, how_to_apply }) {
1345
1406
  return { text: `Memory journal is not writable (${journalResult.code}) -- check .ijfw/ directory permissions and retry.`, isError: true };
1346
1407
  }
1347
1408
 
1409
+ // v1.5.1 R4-H2 — mirror the stored entry into the FTS5 warm tier, which
1410
+ // also fires M1 (Obsidian indexing) + M2 (A-Mem auto-linking). Index the
1411
+ // full content (already sanitised + redacted above) so [[wikilinks]],
1412
+ // #tags and [key:: value] metadata land in memory_links/_tags/_meta and
1413
+ // the auto-linker sees the real body. Fire-and-forget: handleStore stays
1414
+ // synchronous and a DB failure never breaks the markdown store. The
1415
+ // promise is exposed for tests that need deterministic completion.
1416
+ try {
1417
+ handleStore.__lastIndexPromise = indexStoredEntryToFts5({
1418
+ body: safeContent,
1419
+ source: `memory_store:${type}`,
1420
+ sessionId: null,
1421
+ }).catch((e) => {
1422
+ try { console.error('[ijfw memory] FTS5/M1/M2 index failed:', e?.message || e); } catch { /* never throw */ }
1423
+ return null;
1424
+ });
1425
+ } catch (e) {
1426
+ handleStore.__lastIndexPromise = null;
1427
+ try { console.error('[ijfw memory] FTS5 index dispatch failed:', e?.message || e); } catch { /* never throw */ }
1428
+ }
1429
+
1348
1430
  // H5.5 — Fact extraction AFTER successful append. Best-effort: a failure
1349
1431
  // here is logged in the return text but does NOT poison the store result.
1350
1432
  // Memory-id ties facts.jsonl rows back to their journal entry.
@@ -1848,32 +1930,6 @@ function handleMetrics({ period = '7d', metric = 'tokens' } = {}) {
1848
1930
  return { text: out.join('\n') };
1849
1931
  }
1850
1932
 
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
1933
  // --- MCP Protocol Handler (JSON-RPC 2.0 over stdio) ---
1878
1934
 
1879
1935
  function createResponse(id, result) {
@@ -1999,9 +2055,74 @@ function handleMessage(msg) {
1999
2055
  lenses: Array.isArray(a.lenses) && a.lenses.length > 0 ? a.lenses : undefined,
2000
2056
  dispatch: defaultConvergeDispatch,
2001
2057
  projectRoot: process.cwd(),
2058
+ // v1.5.1 R4-H4 — opt-in consensus auto-fix (T27). Threaded
2059
+ // from the tool schema so the code-fixer can genuinely fire;
2060
+ // default false so the audit stays non-mutating unless the
2061
+ // caller explicitly asks for it.
2062
+ autoFix: a.autoFix === true,
2002
2063
  });
2003
2064
  const isErr = r.verdict === 'consensus_failed' || r.verdict === 'FAIL' || r.verdict === 'UNREACHABLE';
2004
- result = { text: JSON.stringify(r, null, 2), isError: isErr };
2065
+ // v1.5.1 W2.D emit a canonical gate-result block through the
2066
+ // gate-result-formatter so this Trident-as-a-service surface is
2067
+ // consistent with the Trident gate (dispatch.js) and preflight
2068
+ // gates. appendGateResult guarantees the fenced block is the
2069
+ // LAST content emitted and is idempotent. Failure to format the
2070
+ // block must NOT clobber the verdict payload — observability,
2071
+ // not correctness.
2072
+ let convergeText = JSON.stringify(r, null, 2);
2073
+ try {
2074
+ const { emitGateResult } = await import('./gate-result.js');
2075
+ const { appendGateResult } = await import('./gate-result-formatter.js');
2076
+ // Map the converge verdict onto a schema-valid gate status.
2077
+ const VERDICT_TO_STATUS = {
2078
+ PASS: 'PASS',
2079
+ CONDITIONAL: 'CONDITIONAL',
2080
+ WARN: 'WARN',
2081
+ FLAG: 'FLAG',
2082
+ FAIL: 'FAIL',
2083
+ consensus_failed: 'FAIL',
2084
+ UNREACHABLE: 'FAIL',
2085
+ INCONCLUSIVE: 'FLAG',
2086
+ };
2087
+ const gateStatus = VERDICT_TO_STATUS[r.verdict] || 'FLAG';
2088
+ const block = await emitGateResult(
2089
+ {
2090
+ gate: 'cross-audit',
2091
+ status: gateStatus,
2092
+ lenses: [],
2093
+ affected_artifacts: [],
2094
+ accounting: {
2095
+ duration_ms:
2096
+ typeof r.duration_ms === 'number' ? r.duration_ms : 0,
2097
+ lenses_invoked: Array.isArray(a.lenses)
2098
+ ? a.lenses.length
2099
+ : 0,
2100
+ cost_usd: null,
2101
+ },
2102
+ remediation: [],
2103
+ },
2104
+ { projectRoot: process.cwd() },
2105
+ );
2106
+ // emitGateResult returns the fenced block as a string; the
2107
+ // formatter validates it back into an object before append.
2108
+ const parsed = JSON.parse(
2109
+ block.replace(/^```gate-result\n/, '').replace(/\n```$/, ''),
2110
+ );
2111
+ convergeText = appendGateResult(convergeText, parsed);
2112
+ } catch (gateErr) {
2113
+ try {
2114
+ const msg =
2115
+ gateErr && gateErr.message
2116
+ ? gateErr.message
2117
+ : String(gateErr);
2118
+ process.stderr.write(
2119
+ `ijfw: cross_audit_converge gate-result emit failed: ${msg}\n`,
2120
+ );
2121
+ } catch {
2122
+ /* never crash the tool on a logging-channel failure */
2123
+ }
2124
+ }
2125
+ result = { text: convergeText, isError: isErr };
2005
2126
  } catch (err) {
2006
2127
  result = { text: JSON.stringify({ error: err && err.message ? err.message : String(err) }), isError: true };
2007
2128
  }
@@ -2038,9 +2159,6 @@ function handleMessage(msg) {
2038
2159
  result = await handleSearch(searchArgs);
2039
2160
  break;
2040
2161
  }
2041
- case 'ijfw_memory_status':
2042
- result = handleStatus();
2043
- break;
2044
2162
  case 'ijfw_memory_prelude':
2045
2163
  result = await handlePrelude(args || {});
2046
2164
  break;
@@ -2153,6 +2271,27 @@ function handleMessage(msg) {
2153
2271
  }
2154
2272
  }
2155
2273
 
2274
+ // --- B17 WebSocket revocation client (dynamic-import gate) ---
2275
+ // extension-registry-ws.js is dormant by default. Its docstring contract:
2276
+ // "Imported via `await import(...)` ONLY when `process.env.IJFW_REGISTRY_WS_URL`
2277
+ // is set at startup." Firing the gate here at MCP startup keeps the module out
2278
+ // of the import graph entirely unless the operator opts in via the env var, so
2279
+ // MCP startup never opens a socket for the common (unset) case. Best-effort:
2280
+ // a failed WS bind must never block the stdio transport.
2281
+ if (process.env.IJFW_REGISTRY_WS_URL || process.env.IJFW_REGISTRY_WS_SOURCE) {
2282
+ (async () => {
2283
+ try {
2284
+ const { initWsClient } = await import('./extension-registry-ws.js');
2285
+ const res = await initWsClient();
2286
+ if (!res.ok) {
2287
+ process.stderr.write(`IJFW: WS revocation client not started: ${res.error}\n`);
2288
+ }
2289
+ } catch (err) {
2290
+ process.stderr.write(`IJFW: WS revocation client init failed: ${err && err.message ? err.message : err}\n`);
2291
+ }
2292
+ })();
2293
+ }
2294
+
2156
2295
  // --- stdio Transport ---
2157
2296
  const rl = createInterface({ input: process.stdin, terminal: false });
2158
2297
 
@@ -53,20 +53,24 @@ const V150_SPECIALISTS = [
53
53
  RELEASE_ENG, DOC_WRITER, ACCESSIBILITY_ENG,
54
54
  ];
55
55
 
56
- // F-FUN-3 (audit-MED-teams-#6): non-software domain specialists. Mirrors
57
- // the fixture role definitions so we don't duplicate prompts; the IDs here
58
- // are the *bench* names the swarm config tracks. Selecting a non-software
59
- // archetype yields a bench tailored to the domain (book gets story-architect,
60
- // not accessibility-eng). Software remains the default to preserve back-compat.
61
- const STORY_ARCHITECT = { id: 'story-architect', role: 'Plot + structure architecture', agent_type: 'ijfw-story-architect', since: '1.5.0' };
62
- const CONTINUITY_EDITOR = { id: 'continuity-editor', role: 'Timeline + voice continuity', agent_type: 'ijfw-continuity-editor', since: '1.5.0' };
63
- const PROSE_STYLIST = { id: 'prose-stylist', role: 'Sentence-level voice + pacing', agent_type: 'ijfw-prose-stylist', since: '1.5.0' };
64
-
65
- const CAMPAIGN_STRATEGIST = { id: 'campaign-strategist', role: 'Audience + funnel strategy', agent_type: 'ijfw-campaign-strategist', since: '1.5.0' };
66
- const COPY_EDITOR = { id: 'copy-editor', role: 'Channel-aware copy editing', agent_type: 'ijfw-copy-editor', since: '1.5.0' };
67
-
68
- const RESEARCH_LEAD = { id: 'research-lead', role: 'Methodology + literature review', agent_type: 'ijfw-research-lead', since: '1.5.0' };
69
- const DATA_ANALYST = { id: 'data-analyst', role: 'Quantitative analysis', agent_type: 'ijfw-data-analyst', since: '1.5.0' };
56
+ // v1.5.1 W1.5.D non-software domain specialists, aligned with T26
57
+ // domain-templates (the canonical agent-id spec per ADR
58
+ // .planning/1.5.1/decisions/W1.5-canonical-source.md). Every `agent_type`
59
+ // here MUST resolve to a real `claude/agents/<id>.md` file on disk; the 5
60
+ // phantom constants that previously lived here (STORY_ARCHITECT,
61
+ // CONTINUITY_EDITOR, PROSE_STYLIST, COPY_EDITOR, DATA_ANALYST) were deleted
62
+ // because no markdown shipped for them.
63
+ const NARRATIVE_CONTINUITY_CHECKER = { id: 'narrative-continuity-checker', role: 'Timeline + voice continuity', agent_type: 'ijfw-narrative-continuity-checker', since: '1.5.0' };
64
+ const LINE_EDITOR = { id: 'line-editor', role: 'Sentence-level voice + pacing', agent_type: 'ijfw-line-editor', since: '1.5.0' };
65
+ const LORE_KEEPER = { id: 'lore-keeper', role: 'World/canon consistency', agent_type: 'ijfw-lore-keeper', since: '1.5.0' };
66
+ const CAMPAIGN_STRATEGIST = { id: 'campaign-strategist', role: 'Audience + funnel strategy', agent_type: 'ijfw-campaign-strategist', since: '1.5.0' };
67
+ const COPY_REVIEWER = { id: 'copy-reviewer', role: 'Channel-aware copy editing', agent_type: 'ijfw-copy-reviewer', since: '1.5.0' };
68
+ const DESIGN_CRITIC = { id: 'design-critic', role: 'Usability + design heuristics', agent_type: 'ijfw-design-critic', since: '1.5.0' };
69
+ const ACCESSIBILITY_REVIEWER = { id: 'accessibility-reviewer', role: 'WCAG + a11y audit', agent_type: 'ijfw-accessibility-reviewer', since: '1.5.0' };
70
+ const RESEARCH_LEAD = { id: 'research-lead', role: 'Methodology + literature review', agent_type: 'ijfw-research-lead', since: '1.5.1' };
71
+ const METHOD_REVIEWER = { id: 'method-reviewer', role: 'Method + bias audit', agent_type: 'ijfw-method-reviewer', since: '1.5.1' };
72
+ const STRATEGY_LEAD = { id: 'strategy-lead', role: 'Strategy + decision quality', agent_type: 'ijfw-strategy-lead', since: '1.5.1' };
73
+ const RISK_REVIEWER = { id: 'risk-reviewer', role: 'Feasibility + downside audit', agent_type: 'ijfw-risk-reviewer', since: '1.5.1' };
70
74
 
71
75
  // Per-archetype bench definitions. Keys here track the project_archetypes
72
76
  // vocabulary used by team/generator.js so a brief-detected archetype maps
@@ -76,9 +80,12 @@ const TYPED_BENCH = [...BASE, TESTS_SPECIALIST, TYPES_SPECIALIST, DOC_VERIFIE
76
80
  const GO_RUST_BENCH = [...BASE, DOC_VERIFIER, PATTERN_MAPPER, SECURITY_AUDITOR, INTEGRATION_CHECKER, NYQUIST_AUDITOR, ...V150_SPECIALISTS];
77
81
  const OTHER_BENCH = [...BASE, DOC_VERIFIER, PATTERN_MAPPER, SECURITY_AUDITOR, INTEGRATION_CHECKER, NYQUIST_AUDITOR, ...V150_SPECIALISTS];
78
82
 
79
- const BOOK_BENCH = [STORY_ARCHITECT, CONTINUITY_EDITOR, PROSE_STYLIST, DOC_VERIFIER, NYQUIST_AUDITOR];
80
- const CONTENT_BENCH = [CAMPAIGN_STRATEGIST, COPY_EDITOR, DOC_VERIFIER, NYQUIST_AUDITOR];
81
- const RESEARCH_BENCH = [RESEARCH_LEAD, DATA_ANALYST, DOC_VERIFIER, NYQUIST_AUDITOR];
83
+ const BOOK_BENCH = [NARRATIVE_CONTINUITY_CHECKER, LINE_EDITOR, LORE_KEEPER, DOC_VERIFIER, NYQUIST_AUDITOR];
84
+ const CONTENT_BENCH = [CAMPAIGN_STRATEGIST, COPY_REVIEWER, DOC_VERIFIER, NYQUIST_AUDITOR];
85
+ const DESIGN_BENCH = [DESIGN_CRITIC, ACCESSIBILITY_REVIEWER, DOC_VERIFIER, NYQUIST_AUDITOR];
86
+ const RESEARCH_BENCH = [RESEARCH_LEAD, METHOD_REVIEWER, DOC_VERIFIER, NYQUIST_AUDITOR];
87
+ const BUSINESS_BENCH = [STRATEGY_LEAD, RISK_REVIEWER, DOC_VERIFIER, NYQUIST_AUDITOR];
88
+ const MIXED_BENCH = [...BASE, DOC_VERIFIER, DESIGN_CRITIC, CAMPAIGN_STRATEGIST, NYQUIST_AUDITOR];
82
89
 
83
90
  export const DEFAULT_SPECIALISTS = {
84
91
  // Language-keyed defaults (preserved for back-compat with v1.4.x callers
@@ -89,16 +96,17 @@ export const DEFAULT_SPECIALISTS = {
89
96
  go: GO_RUST_BENCH,
90
97
  rust: GO_RUST_BENCH,
91
98
  other: OTHER_BENCH,
92
- // Archetype-keyed defaults (new in v1.5.0 audit-MED-teams-#6) -- selected
93
- // when the caller hands in a project archetype from the team detector.
99
+ // Archetype-keyed defaults (new in v1.5.0 audit-MED-teams-#6; bench
100
+ // mis-mappings fixed in v1.5.1 W1.5.D so every agent_type resolves to a
101
+ // real claude/agents/<id>.md file on disk).
94
102
  software: SOFTWARE_BENCH,
95
103
  book: BOOK_BENCH,
96
104
  content: CONTENT_BENCH,
97
105
  marketing: CONTENT_BENCH,
98
106
  research: RESEARCH_BENCH,
99
- design: CONTENT_BENCH,
100
- business: SOFTWARE_BENCH,
101
- mixed: SOFTWARE_BENCH,
107
+ design: DESIGN_BENCH, // v1.5.1 W1.5.D: was CONTENT_BENCH (mis-mapped)
108
+ business: BUSINESS_BENCH, // v1.5.1 W1.5.D: was SOFTWARE_BENCH (mis-mapped)
109
+ mixed: MIXED_BENCH, // v1.5.1 W1.5.D: was SOFTWARE_BENCH (mis-mapped)
102
110
  };
103
111
 
104
112
  // F-FUN-3: helper -- pick the right bench for a (language, archetype) pair.
@@ -3,7 +3,10 @@
3
3
  "domain": "business",
4
4
  "display_name": "Business / Strategy",
5
5
  "description": "Business plans, strategy memos, GTM plans, investor decks, OKRs, and operational roadmaps.",
6
- "agent_ids": [],
6
+ "agent_ids": [
7
+ "ijfw-strategy-lead",
8
+ "ijfw-risk-reviewer"
9
+ ],
7
10
  "agent_id_source": "domain-specialist",
8
11
  "workflow_phases": ["diagnose", "plan", "decide", "review"],
9
12
  "brief_fields": [
@@ -3,7 +3,10 @@
3
3
  "domain": "research",
4
4
  "display_name": "Research / Analysis",
5
5
  "description": "Academic papers, whitepapers, literature reviews, studies, and investigative analysis.",
6
- "agent_ids": [],
6
+ "agent_ids": [
7
+ "ijfw-research-lead",
8
+ "ijfw-method-reviewer"
9
+ ],
7
10
  "agent_id_source": "domain-specialist",
8
11
  "workflow_phases": ["question", "collect", "synthesize", "review"],
9
12
  "brief_fields": [