@ijfw/memory-server 1.3.0 → 1.4.0

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 (64) hide show
  1. package/fixtures/team/book.json +47 -0
  2. package/fixtures/team/business.json +47 -0
  3. package/fixtures/team/content.json +47 -0
  4. package/fixtures/team/design.json +47 -0
  5. package/fixtures/team/mixed.json +59 -0
  6. package/fixtures/team/research.json +47 -0
  7. package/fixtures/team/software.json +47 -0
  8. package/package.json +1 -9
  9. package/src/active-extension-writer.js +116 -0
  10. package/src/blackboard.js +360 -0
  11. package/src/cli-run.js +91 -0
  12. package/src/codex-agents.js +177 -0
  13. package/src/compute/extract.js +3 -0
  14. package/src/compute/fts5.js +4 -4
  15. package/src/compute/graph-lock.js +0 -2
  16. package/src/compute/migrations/003-tier-semantic.js +3 -3
  17. package/src/compute/runner.js +44 -15
  18. package/src/compute/schema.sql +1 -1
  19. package/src/cross-orchestrator-cli.js +974 -13
  20. package/src/cross-orchestrator.js +9 -1
  21. package/src/dashboard-client.html +144 -1
  22. package/src/dashboard-server.js +75 -2
  23. package/src/design-intelligence.js +721 -0
  24. package/src/dispatch/colon-syntax.js +31 -3
  25. package/src/dispatch/domain-manifest.js +251 -0
  26. package/src/dispatch/extension.js +404 -0
  27. package/src/dispatch/override.js +221 -0
  28. package/src/dispatch-planner.js +1 -0
  29. package/src/dream/runner.mjs +3 -3
  30. package/src/extension-installer.js +1230 -0
  31. package/src/extension-manifest-schema.js +301 -0
  32. package/src/extension-signer.js +740 -0
  33. package/src/gate-result-formatter.js +95 -0
  34. package/src/gate-result-schema.js +274 -0
  35. package/src/gate-result.js +195 -0
  36. package/src/intent-router.js +2 -0
  37. package/src/lib/npm-view.js +1 -0
  38. package/src/memory/fts5.js +3 -3
  39. package/src/memory/migrations/002-tier-semantic.js +2 -2
  40. package/src/memory/staleness.js +1 -1
  41. package/src/memory/tier-promotion.js +6 -6
  42. package/src/memory/tokenize.js +1 -1
  43. package/src/memory-feedback.js +188 -0
  44. package/src/override-manifest-schema.js +146 -0
  45. package/src/override-resolver.js +699 -0
  46. package/src/override-use-registry.js +307 -0
  47. package/src/overrides/presets/academic.md +101 -0
  48. package/src/overrides/presets/book.md +87 -0
  49. package/src/overrides/presets/campaign.md +95 -0
  50. package/src/overrides/presets/screenplay.md +99 -0
  51. package/src/recovery/checkpoint.js +191 -0
  52. package/src/redactor.js +2 -0
  53. package/src/runtime-mediator.js +178 -0
  54. package/src/sandbox.js +17 -3
  55. package/src/server.js +94 -2
  56. package/src/swarm/dispatch-prompt.js +154 -0
  57. package/src/swarm/planner.js +399 -0
  58. package/src/swarm/review.js +136 -0
  59. package/src/swarm/worktree.js +239 -0
  60. package/src/team/generator.js +119 -0
  61. package/src/team/schemas.js +341 -0
  62. package/src/trident/dispatch.js +47 -0
  63. package/src/update-check.js +1 -1
  64. package/src/vectors.js +7 -8
@@ -0,0 +1,191 @@
1
+ // Workflow memory safety net.
2
+ //
3
+ // Checkpoints are durable, project-local markdown + JSON snapshots. They are
4
+ // not a replacement for IJFW memory, but they make recovery possible when chat
5
+ // context or a generated memory summary goes missing.
6
+
7
+ import { existsSync, mkdirSync, readdirSync, readFileSync } from 'node:fs';
8
+ import { basename, join, resolve } from 'node:path';
9
+ import { writeAtomic } from '../lib/atomic-io.js';
10
+ import { appendBlackboardEvent, blackboardStatus, readBlackboard } from '../blackboard.js';
11
+ import { readTeamAssembly } from '../team/generator.js';
12
+ import { buildSwarmPlan } from '../swarm/planner.js';
13
+
14
+ export function checkpointPaths(projectRoot = process.cwd()) {
15
+ const root = resolve(projectRoot);
16
+ const dir = join(root, '.ijfw', 'checkpoints');
17
+ return { root, dir, latest: join(dir, 'latest.json') };
18
+ }
19
+
20
+ export function createCheckpoint(projectRoot = process.cwd(), label = 'checkpoint', options = {}) {
21
+ const paths = checkpointPaths(projectRoot);
22
+ mkdirSync(paths.dir, { recursive: true, mode: 0o700 });
23
+ const ts = new Date().toISOString();
24
+ const safeLabel = safeName(label);
25
+ const id = `${ts.replace(/[:.]/g, '-')}-${safeLabel}`;
26
+ const jsonPath = join(paths.dir, `${id}.json`);
27
+ const mdPath = join(paths.dir, `${id}.md`);
28
+ const snapshot = buildSnapshot(projectRoot, { id, label, ts, message: options.message });
29
+
30
+ writeAtomic(jsonPath, `${JSON.stringify(snapshot, null, 2)}\n`, { mode: 0o600 });
31
+ writeAtomic(mdPath, renderCheckpoint(snapshot), { mode: 0o600 });
32
+ writeAtomic(paths.latest, `${JSON.stringify({ id, label, ts, jsonPath, mdPath }, null, 2)}\n`, { mode: 0o600 });
33
+ appendBlackboardEvent(projectRoot, {
34
+ type: 'checkpoint.created',
35
+ actor: options.actor || 'ijfw',
36
+ message: label,
37
+ data: { id, jsonPath, mdPath },
38
+ });
39
+ return { ok: true, id, label, jsonPath, mdPath, snapshot };
40
+ }
41
+
42
+ export function recoveryStatus(projectRoot = process.cwd()) {
43
+ const paths = checkpointPaths(projectRoot);
44
+ const blackboard = readBlackboard(projectRoot);
45
+ const team = readTeamAssembly(projectRoot);
46
+ const plan = buildSwarmPlan(projectRoot);
47
+ const tasks = blackboard.tasks.data.tasks || [];
48
+ const latest = readLatest(paths.latest);
49
+ return {
50
+ ok: true,
51
+ latest,
52
+ team: {
53
+ ok: team.ok,
54
+ name: team.charter?.team_name || null,
55
+ },
56
+ swarm: plan.ok ? {
57
+ ok: true,
58
+ summary: plan.summary,
59
+ } : {
60
+ ok: false,
61
+ message: plan.message,
62
+ },
63
+ tasks: summarizeTasks(tasks),
64
+ claims: blackboardStatus(projectRoot).claims,
65
+ recent: {
66
+ events: blackboard.recent.events,
67
+ blockers: blackboard.recent.blockers,
68
+ decisions: blackboard.recent.decisions,
69
+ },
70
+ next: recommendedNext(team, plan, tasks),
71
+ };
72
+ }
73
+
74
+ export function latestCheckpoint(projectRoot = process.cwd()) {
75
+ const paths = checkpointPaths(projectRoot);
76
+ const latest = readLatest(paths.latest);
77
+ if (!latest) return { ok: false, error: 'no-checkpoint' };
78
+ let markdown = '';
79
+ try { markdown = readFileSync(latest.mdPath, 'utf8'); } catch { /* optional */ }
80
+ return { ok: true, ...latest, markdown };
81
+ }
82
+
83
+ export function listCheckpoints(projectRoot = process.cwd()) {
84
+ const paths = checkpointPaths(projectRoot);
85
+ if (!existsSync(paths.dir)) return [];
86
+ return readdirSync(paths.dir)
87
+ .filter((file) => file.endsWith('.json') && file !== 'latest.json')
88
+ .sort()
89
+ .map((file) => join(paths.dir, file));
90
+ }
91
+
92
+ function buildSnapshot(projectRoot, meta) {
93
+ const blackboard = readBlackboard(projectRoot);
94
+ const team = readTeamAssembly(projectRoot);
95
+ const plan = buildSwarmPlan(projectRoot);
96
+ const status = blackboardStatus(projectRoot);
97
+ const tasks = blackboard.tasks.data.tasks || [];
98
+ return {
99
+ schema_version: 'ijfw-checkpoint/v1',
100
+ id: meta.id,
101
+ label: meta.label,
102
+ message: meta.message || null,
103
+ created_at: meta.ts,
104
+ project: basename(resolve(projectRoot)),
105
+ team: team.ok ? {
106
+ name: team.charter.team_name,
107
+ archetypes: team.charter.project_archetypes,
108
+ roles: team.charter.roles.map((role) => role.name),
109
+ } : { ok: false },
110
+ swarm: plan.ok ? {
111
+ summary: plan.summary,
112
+ waves: plan.waves.map((wave) => ({
113
+ id: wave.id,
114
+ mode: wave.mode,
115
+ blocked: wave.blocked,
116
+ artifact_ids: wave.artifact_ids,
117
+ })),
118
+ } : { ok: false, message: plan.message },
119
+ tasks: summarizeTasks(tasks),
120
+ active_tasks: tasks.filter((task) => ['ready', 'in_progress', 'blocked'].includes(task.status)),
121
+ claims: status.claims,
122
+ recent: blackboard.recent,
123
+ next: recommendedNext(team, plan, tasks),
124
+ };
125
+ }
126
+
127
+ function renderCheckpoint(snapshot) {
128
+ const lines = [
129
+ `# IJFW Checkpoint: ${snapshot.label}`,
130
+ '',
131
+ `Created: ${snapshot.created_at}`,
132
+ `Project: ${snapshot.project}`,
133
+ `Next: ${snapshot.next}`,
134
+ '',
135
+ '## Team',
136
+ snapshot.team.ok === false ? 'No complete team assembly.' : `Team ${snapshot.team.name} (${snapshot.team.archetypes.join(', ')})`,
137
+ '',
138
+ '## Swarm',
139
+ snapshot.swarm.ok === false ? snapshot.swarm.message : snapshot.swarm.summary,
140
+ '',
141
+ '## Tasks',
142
+ `Ready: ${snapshot.tasks.ready}`,
143
+ `In progress: ${snapshot.tasks.in_progress}`,
144
+ `Blocked: ${snapshot.tasks.blocked}`,
145
+ `Done: ${snapshot.tasks.done}`,
146
+ '',
147
+ '## Active Tasks',
148
+ ];
149
+ for (const task of snapshot.active_tasks) lines.push(`- ${task.id} [${task.status}] ${task.owner || 'unowned'}`);
150
+ if (!snapshot.active_tasks.length) lines.push('- none');
151
+ lines.push('', '## Active Claims');
152
+ for (const claim of snapshot.claims.active_items || []) lines.push(`- ${claim.artifact_id} -> ${claim.agent}`);
153
+ if (!snapshot.claims.active_items?.length) lines.push('- none');
154
+ return `${lines.join('\n')}\n`;
155
+ }
156
+
157
+ function summarizeTasks(tasks) {
158
+ const counts = { total: tasks.length, ready: 0, in_progress: 0, blocked: 0, done: 0, other: 0 };
159
+ for (const task of tasks) {
160
+ if (task.status in counts) counts[task.status] += 1;
161
+ else counts.other += 1;
162
+ }
163
+ return counts;
164
+ }
165
+
166
+ function recommendedNext(team, plan, tasks) {
167
+ if (!team.ok) return 'Run: ijfw team init';
168
+ if (!plan.ok) return 'Run: ijfw swarm plan';
169
+ if (!tasks.length) return 'Run: ijfw swarm prepare';
170
+ const blocked = tasks.filter((task) => task.status === 'blocked');
171
+ if (blocked.length) return `Resolve blocked task: ${blocked[0].id}`;
172
+ const inProgress = tasks.find((task) => task.status === 'in_progress');
173
+ if (inProgress) return `Continue task: ${inProgress.id}`;
174
+ const ready = tasks.find((task) => task.status === 'ready');
175
+ if (ready) return `Start task: ${ready.id}`;
176
+ return 'Verify completed work or prepare next wave';
177
+ }
178
+
179
+ function readLatest(path) {
180
+ try {
181
+ if (!existsSync(path)) return null;
182
+ return JSON.parse(readFileSync(path, 'utf8'));
183
+ } catch {
184
+ return null;
185
+ }
186
+ }
187
+
188
+ function safeName(label) {
189
+ return String(label || 'checkpoint').toLowerCase().replace(/[^a-z0-9._-]+/g, '-').replace(/^-|-$/g, '') || 'checkpoint';
190
+ }
191
+
package/src/redactor.js CHANGED
@@ -36,10 +36,12 @@ const PATTERNS = [
36
36
  // GCP / Google API keys -- `AIza...` (39 chars total).
37
37
  { re: /AIza[0-9A-Za-z_-]{35}/g, label: 'gcp' },
38
38
  // Sentry DSN -- https://<key>@o<org>.ingest.sentry.io/<project>.
39
+ // eslint-disable-next-line security/detect-unsafe-regex -- redactor scans bounded tool output; this is an anchored secret pattern, not user-controlled matching logic.
39
40
  { re: /https?:\/\/[0-9a-f]{32,}(?::[0-9a-f]{32,})?@[\w.-]*sentry\.io\/[0-9]+/gi, label: 'sentry' },
40
41
  // Cloudflare API tokens (40 chars base64url). Conservative: only flag when
41
42
  // contextualized (CF_API_TOKEN=..., CLOUDFLARE_TOKEN=..., cf_auth_key=...)
42
43
  // so we don't eat bare git commit SHAs or content hashes.
44
+ // eslint-disable-next-line security/detect-unsafe-regex -- redactor scans bounded tool output and requires a Cloudflare context prefix before matching a token.
43
45
  { re: /(?:cf|cloudflare)[_-]?(?:api[_-]?)?(?:token|auth|key)s?[= :]+[A-Za-z0-9_-]{40,}/gi, label: 'cloudflare' },
44
46
  // Webhook URLs (Slack, Discord, MS Teams) -- include the secret path segment.
45
47
  { re: /https:\/\/hooks\.slack\.com\/services\/T[A-Z0-9]+\/B[A-Z0-9]+\/[A-Za-z0-9]+/g, label: 'webhook' },
@@ -0,0 +1,178 @@
1
+ /**
2
+ * runtime-mediator.js -- IJFW 1.4.0 W7/B2
3
+ *
4
+ * Tier-1 cross-platform runtime sandbox mediation for installed extensions.
5
+ *
6
+ * Reads the active extension descriptor at ~/.ijfw/state/active-extension.json
7
+ * (written by the extension installer when an extension takes the active
8
+ * context). Maps MCP tool calls to (action, target) and decides whether the
9
+ * extension's declared permissions allow them.
10
+ *
11
+ * Backwards compat invariant: with NO state file present, behaviour is
12
+ * identical to today (allow all) -- callers see activeExt === null and
13
+ * checkPermission() returns { allowed: true }.
14
+ *
15
+ * Fail-closed invariant: if the file exists but is unparseable / malformed,
16
+ * the caller MUST treat it as a deny. A corrupted state file is not a free
17
+ * pass -- that would defeat the sandbox.
18
+ */
19
+
20
+ import { readFile, mkdir, appendFile } from 'node:fs/promises';
21
+ import { join } from 'node:path';
22
+ import { homedir } from 'node:os';
23
+
24
+ // Sentinel returned from getActiveExtension when the file exists but is
25
+ // invalid. Callers compare with === to distinguish from null (no file).
26
+ const MALFORMED = Object.freeze({ __malformed: true });
27
+
28
+ function stateDir(home) {
29
+ return join(home, '.ijfw', 'state');
30
+ }
31
+
32
+ function activeExtPath(home) {
33
+ return join(stateDir(home), 'active-extension.json');
34
+ }
35
+
36
+ function eventLogPath(home) {
37
+ return join(stateDir(home), 'permission-events.jsonl');
38
+ }
39
+
40
+ /**
41
+ * Read and validate ~/.ijfw/state/active-extension.json.
42
+ *
43
+ * Returns:
44
+ * - null -- file absent (bundled IJFW context, allow all).
45
+ * - {name, scope, permissions:{reads,writes}} -- well-formed descriptor.
46
+ * - {__malformed: true} (=== MALFORMED) -- file exists but broken; deny.
47
+ */
48
+ export async function getActiveExtension(opts = {}) {
49
+ const home = opts.homeDir || process.env.HOME || homedir();
50
+ let raw;
51
+ try {
52
+ raw = await readFile(activeExtPath(home), 'utf8');
53
+ } catch (err) {
54
+ if (err && err.code === 'ENOENT') return null;
55
+ // Any other read error (permission denied, etc) is fail-closed.
56
+ return MALFORMED;
57
+ }
58
+ let parsed;
59
+ try {
60
+ parsed = JSON.parse(raw);
61
+ } catch {
62
+ return MALFORMED;
63
+ }
64
+ if (!parsed || typeof parsed !== 'object') return MALFORMED;
65
+ if (typeof parsed.name !== 'string' || !parsed.name) return MALFORMED;
66
+ if (!parsed.permissions || typeof parsed.permissions !== 'object') return MALFORMED;
67
+ const reads = parsed.permissions.reads;
68
+ const writes = parsed.permissions.writes;
69
+ if (!Array.isArray(reads) || !Array.isArray(writes)) return MALFORMED;
70
+ return {
71
+ name: parsed.name,
72
+ scope: typeof parsed.scope === 'string' ? parsed.scope : 'project',
73
+ permissions: { reads, writes },
74
+ };
75
+ }
76
+
77
+ /**
78
+ * Match a target against an entry in a permissions list. Supports:
79
+ * - exact match
80
+ * - '*' wildcard (matches everything)
81
+ * - prefix glob 'memory:*' (matches anything beginning with 'memory:')
82
+ */
83
+ function matchesEntry(entry, target) {
84
+ if (entry === '*') return true;
85
+ if (entry === target) return true;
86
+ if (typeof entry === 'string' && entry.endsWith(':*')) {
87
+ const prefix = entry.slice(0, -1); // 'memory:*' -> 'memory:'
88
+ if (target.startsWith(prefix)) return true;
89
+ }
90
+ return false;
91
+ }
92
+
93
+ /**
94
+ * Decide whether `action` against `target` is permitted under the active
95
+ * extension's permissions.
96
+ *
97
+ * - activeExt === null -> allowed (bundled IJFW context).
98
+ * - activeExt === MALFORMED -> denied with 'malformed active-extension state'.
99
+ * - action === 'read' -> target must match an entry in permissions.reads.
100
+ * - action === 'write' -> target must match an entry in permissions.writes.
101
+ *
102
+ * Returns { allowed: boolean, reason: string }.
103
+ */
104
+ export function checkPermission(action, target, activeExt) {
105
+ if (activeExt === null || activeExt === undefined) {
106
+ return { allowed: true, reason: 'bundled context' };
107
+ }
108
+ if (activeExt && activeExt.__malformed) {
109
+ return { allowed: false, reason: 'malformed active-extension state' };
110
+ }
111
+ if (action !== 'read' && action !== 'write') {
112
+ return { allowed: false, reason: `unknown action: ${action}` };
113
+ }
114
+ const list = action === 'read' ? activeExt.permissions.reads : activeExt.permissions.writes;
115
+ for (const entry of list) {
116
+ if (matchesEntry(entry, target)) {
117
+ return { allowed: true, reason: `matched ${entry}` };
118
+ }
119
+ }
120
+ return {
121
+ allowed: false,
122
+ reason: `${action} ${target} not permitted by extension "${activeExt.name}"`,
123
+ };
124
+ }
125
+
126
+ /**
127
+ * Append one JSON line to ~/.ijfw/state/permission-events.jsonl. Best effort:
128
+ * never throws. The forensic trail is a nice-to-have, not a critical path.
129
+ */
130
+ export async function logPermissionEvent(event, opts = {}) {
131
+ try {
132
+ const home = opts.homeDir || process.env.HOME || homedir();
133
+ await mkdir(stateDir(home), { recursive: true });
134
+ const line = JSON.stringify(event) + '\n';
135
+ await appendFile(eventLogPath(home), line, 'utf8');
136
+ } catch {
137
+ // Swallow: logging must never break tool dispatch.
138
+ }
139
+ }
140
+
141
+ /**
142
+ * Map an MCP tool name (+ args) to the (action, target) tuple used for
143
+ * permission checks. Returns null for unrecognised tool names; callers
144
+ * should treat null as "no policy applies, allow" (these are bundled-only).
145
+ */
146
+ export function toolNameToActionTarget(toolName, args) {
147
+ switch (toolName) {
148
+ case 'ijfw_memory_store':
149
+ return { action: 'write', target: 'memory:write' };
150
+ case 'ijfw_memory_recall':
151
+ case 'ijfw_memory_search':
152
+ case 'ijfw_memory_status':
153
+ case 'ijfw_memory_prelude':
154
+ case 'ijfw_cross_project_search':
155
+ return { action: 'read', target: 'memory:read' };
156
+ case 'ijfw_metrics':
157
+ return { action: 'read', target: 'metrics:read' };
158
+ case 'ijfw_update_check':
159
+ return { action: 'read', target: 'update:check' };
160
+ case 'ijfw_update_apply':
161
+ return { action: 'write', target: 'update:apply' };
162
+ case 'ijfw_prompt_check':
163
+ return { action: 'read', target: 'prompt:check' };
164
+ case 'ijfw_run': {
165
+ // Parse the first colon-syntax token out of args.command -- e.g.
166
+ // "compute:python ..." -> subject 'compute'. Anything without a
167
+ // colon-prefixed leading token gets target 'run:*'.
168
+ let subject = '*';
169
+ if (args && typeof args.command === 'string') {
170
+ const m = args.command.match(/^\s*([A-Za-z][A-Za-z0-9_-]*)\s*:/);
171
+ if (m) subject = m[1];
172
+ }
173
+ return { action: 'write', target: `run:${subject}` };
174
+ }
175
+ default:
176
+ return null;
177
+ }
178
+ }
package/src/sandbox.js CHANGED
@@ -34,6 +34,7 @@ function sanitizeLabel(label) {
34
34
  export function runCommand(command, opts = {}) {
35
35
  return new Promise((resolve) => {
36
36
  const cwd = opts.cwd || process.cwd();
37
+ const timeoutMs = Number(opts.timeoutMs || opts.timeout || TIMEOUT_MS);
37
38
  const start = Date.now();
38
39
  let timedOut = false;
39
40
  let totalBytes = 0;
@@ -45,12 +46,25 @@ export function runCommand(command, opts = {}) {
45
46
  cwd,
46
47
  env: process.env,
47
48
  stdio: ['ignore', 'pipe', 'pipe'],
49
+ detached: process.platform !== 'win32',
48
50
  });
49
51
 
52
+ function killChild() {
53
+ try {
54
+ if (process.platform !== 'win32' && child.pid) {
55
+ process.kill(-child.pid, 'SIGKILL');
56
+ } else {
57
+ child.kill('SIGKILL');
58
+ }
59
+ } catch {
60
+ try { child.kill('SIGKILL'); } catch { /* already gone */ }
61
+ }
62
+ }
63
+
50
64
  const timer = setTimeout(() => {
51
65
  timedOut = true;
52
- try { child.kill('SIGKILL'); } catch {}
53
- }, TIMEOUT_MS);
66
+ killChild();
67
+ }, Number.isFinite(timeoutMs) && timeoutMs > 0 ? timeoutMs : TIMEOUT_MS);
54
68
 
55
69
  function onData(chunk) {
56
70
  if (capped) return;
@@ -59,7 +73,7 @@ export function runCommand(command, opts = {}) {
59
73
  chunks.push(chunk.slice(0, remaining));
60
74
  totalBytes += remaining;
61
75
  capped = true;
62
- try { child.kill('SIGKILL'); } catch {}
76
+ killChild();
63
77
  } else {
64
78
  chunks.push(chunk);
65
79
  totalBytes += chunk.length;
package/src/server.js CHANGED
@@ -45,6 +45,16 @@ import { runCommand, detectDomain, summarize, writeToSandbox, readFromSandbox, p
45
45
  // W1B (1.3.0-alpha) -- colon-syntax dispatcher. Extends ijfw_run + ijfw_memory_search
46
46
  // with compute:/index:/detect: sub-commands without registering new MCP tools.
47
47
  import { parseColonCommand, dispatchRun, dispatchSearch } from './dispatch/colon-syntax.js';
48
+ // W7/B2 -- runtime sandbox mediation. With no installed extension active,
49
+ // getActiveExtension() returns null and checkPermission() allows everything
50
+ // (backwards-compat invariant). With an active extension, each MCP tool call
51
+ // is gated against its declared permissions before the handler runs.
52
+ import {
53
+ getActiveExtension,
54
+ checkPermission,
55
+ logPermissionEvent,
56
+ toolNameToActionTarget,
57
+ } from './runtime-mediator.js';
48
58
  const SANDBOX_DIR = join(process.env.HOME || homedir(), '.ijfw', 'session-sandbox');
49
59
 
50
60
  // --- Constants ---
@@ -917,7 +927,7 @@ function cmpSemverPrelude(a, b) {
917
927
  return A.pre < B.pre ? -1 : 1;
918
928
  }
919
929
 
920
- function handlePrelude({ detail_level = 'summary' } = {}) {
930
+ async function handlePrelude({ detail_level = 'summary' } = {}) {
921
931
  const KB_LINES = detail_level === 'full' ? 200 : detail_level === 'standard' ? 80 : 40;
922
932
  const HO_LINES = detail_level === 'full' ? 80 : detail_level === 'standard' ? 30 : 15;
923
933
  const JN_LINES = detail_level === 'full' ? 20 : detail_level === 'standard' ? 10 : 5;
@@ -999,6 +1009,56 @@ function handlePrelude({ detail_level = 'summary' } = {}) {
999
1009
  if (body) parts.push('## Project preferences', body, '');
1000
1010
  }
1001
1011
 
1012
+ // t14: cross-project override-use intelligence. Reads the current project's
1013
+ // active overrides from ~/.ijfw/state/active-overrides.json and asks the
1014
+ // override-use-registry whether N+ other projects share the set. Wrapped
1015
+ // in a single try/catch — a registry failure must NEVER fail the prelude.
1016
+ try {
1017
+ const activePath = join(homedir(), '.ijfw', 'state', 'active-overrides.json');
1018
+ let currentOverrides = [];
1019
+ if (existsSync(activePath)) {
1020
+ try {
1021
+ const parsed = JSON.parse(readFileSync(activePath, 'utf8'));
1022
+ const entry = parsed && parsed.projects && parsed.projects[PROJECT_DIR];
1023
+ if (entry && Array.isArray(entry.active_overrides)) {
1024
+ currentOverrides = entry.active_overrides
1025
+ .filter((o) => o && typeof o.preset === 'string')
1026
+ .map((o) => o.preset);
1027
+ }
1028
+ } catch {
1029
+ // malformed json -- treat as no overrides, fall through silently
1030
+ }
1031
+ }
1032
+ if (currentOverrides.length > 0) {
1033
+ const { getPromoteSuggestion } = await import('./override-use-registry.js');
1034
+ const suggestion = await getPromoteSuggestion(PROJECT_DIR, currentOverrides, {});
1035
+ if (suggestion) {
1036
+ parts.push('## Cross-project intelligence');
1037
+ parts.push(suggestion.message);
1038
+ parts.push(`Projects: ${suggestion.projects.join(', ')}`);
1039
+ parts.push('Run: `' + suggestion.suggestion + '`');
1040
+ parts.push('');
1041
+ }
1042
+ }
1043
+ } catch {
1044
+ // Registry failures are non-fatal -- the prelude must always succeed.
1045
+ }
1046
+
1047
+ // B3 (1.4.0/W7): memory-feedback pattern hints. Reads gate-receipts and
1048
+ // surfaces "N of last M flagged on artifact:type" hints. Wrapped in a
1049
+ // single try/catch -- pattern detection failure must NEVER fail the prelude.
1050
+ try {
1051
+ const { getFeedbackSuggestions } = await import('./memory-feedback.js');
1052
+ const suggestions = await getFeedbackSuggestions(PROJECT_DIR);
1053
+ if (Array.isArray(suggestions) && suggestions.length > 0) {
1054
+ parts.push('## Pattern hints');
1055
+ for (const s of suggestions) parts.push(`- ${s}`);
1056
+ parts.push('');
1057
+ }
1058
+ } catch {
1059
+ // Best-effort; never fail the prelude on memory-feedback issues.
1060
+ }
1061
+
1002
1062
  parts.push('</ijfw-memory>');
1003
1063
 
1004
1064
  const text = parts.join('\n');
@@ -1213,6 +1273,38 @@ function handleMessage(msg) {
1213
1273
  return (async () => {
1214
1274
  let result;
1215
1275
  try {
1276
+ // W7/B2 -- tier-1 runtime mediation. If an extension is active,
1277
+ // gate the call against its declared permissions before any handler
1278
+ // runs. With no extension active (bundled IJFW context), activeExt
1279
+ // is null and the gate is a no-op -- preserving the backwards-compat
1280
+ // invariant. Malformed state is fail-closed (denied).
1281
+ let activeExt = null;
1282
+ try {
1283
+ activeExt = await getActiveExtension();
1284
+ } catch {
1285
+ activeExt = { __malformed: true };
1286
+ }
1287
+ if (activeExt !== null) {
1288
+ const mapping = toolNameToActionTarget(name, args || {});
1289
+ if (mapping) {
1290
+ const check = checkPermission(mapping.action, mapping.target, activeExt);
1291
+ if (!check.allowed) {
1292
+ await logPermissionEvent({
1293
+ tool: name,
1294
+ extension: activeExt && activeExt.name ? activeExt.name : null,
1295
+ action: mapping.action,
1296
+ target: mapping.target,
1297
+ allowed: false,
1298
+ reason: check.reason,
1299
+ ts: new Date().toISOString(),
1300
+ }).catch(() => {});
1301
+ return createResponse(id, {
1302
+ content: [{ type: 'text', text: `extension permission denied: ${check.reason}` }],
1303
+ isError: true,
1304
+ });
1305
+ }
1306
+ }
1307
+ }
1216
1308
  switch (name) {
1217
1309
  case 'ijfw_update_check': {
1218
1310
  const r = await ijfwUpdateCheck(args || {});
@@ -1259,7 +1351,7 @@ function handleMessage(msg) {
1259
1351
  result = handleStatus();
1260
1352
  break;
1261
1353
  case 'ijfw_memory_prelude':
1262
- result = handlePrelude(args || {});
1354
+ result = await handlePrelude(args || {});
1263
1355
  break;
1264
1356
  case 'ijfw_metrics':
1265
1357
  result = handleMetrics(args || {});