@ijfw/memory-server 1.6.1 → 1.6.2

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.
@@ -40,10 +40,19 @@ function removePidFiles() {
40
40
 
41
41
  function openBrowser(url) {
42
42
  if (process.env.CI || process.env.NO_OPEN) return;
43
- const cmd = process.platform === 'darwin' ? 'open'
44
- : process.platform === 'win32' ? 'start'
45
- : 'xdg-open';
46
- try { spawn(cmd, [url], { detached: true, stdio: 'ignore' }).unref(); } catch {}
43
+ // `start` is a cmd.exe builtin (no start.exe on PATH), so it must be run
44
+ // through cmd. The empty '' arg is start's window-title slot, which would
45
+ // otherwise swallow the URL. url is built internally from a parsed port.
46
+ const [cmd, args] = process.platform === 'darwin' ? ['open', [url]]
47
+ : process.platform === 'win32' ? ['cmd', ['/c', 'start', '', url]]
48
+ : ['xdg-open', [url]];
49
+ try {
50
+ const child = spawn(cmd, args, { detached: true, stdio: 'ignore' });
51
+ // Missing opener surfaces as an async 'error' event, not a throw --
52
+ // swallow it so it can never become an uncaught exception.
53
+ child.on('error', () => {});
54
+ child.unref();
55
+ } catch {}
47
56
  }
48
57
 
49
58
  // Probe a URL for HTTP 200, retrying on connection-refused until timeoutMs elapses.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@ijfw/memory-server",
3
- "version": "1.6.1",
3
+ "version": "1.6.2",
4
4
  "description": "Cross-platform persistent memory server for IJFW. 14 MCP tools (memory + admin/update + brain). Works with 15 platforms: 14 via MCP (Claude Code, Codex, Gemini CLI, Cursor, Windsurf, Copilot, Hermes, Wayland, OpenCode, QwenCode, Cline, KimiCode, OpenClaw, Antigravity) plus Aider via the rules-only tier.",
5
5
  "author": "Sean Donahoe",
6
6
  "contributors": [
@@ -281,10 +281,22 @@ export function isInstalled(id) {
281
281
  // `[ -x ]` (or be a shell builtin/keyword/function with no filesystem path,
282
282
  // which `command -v` reports without a leading slash — those are genuinely
283
283
  // runnable). A real installed CLI is an executable file and still passes.
284
- const probe = `p=$(command -v ${JSON.stringify(bin)} 2>/dev/null) || exit 1; ` +
285
- `case "$p" in /*) [ -x "$p" ] ;; *) : ;; esac`;
286
- const r = spawnSync('bash', ['-lc', probe], { timeout: 2000 });
287
- const installed = r.status === 0;
284
+ //
285
+ // On native Windows `bash` is either absent (probe would mark every auditor
286
+ // uninstalled) or the WSL launcher (probe would report the Linux distro's
287
+ // PATH, which the Windows-side invoker cannot run). Use `where` instead:
288
+ // it resolves .cmd/.exe shims on the real Windows PATH, matching how the
289
+ // auditors are actually spawned (shell:true on win32).
290
+ let installed;
291
+ if (process.platform === 'win32') {
292
+ const r = spawnSync('where', [bin], { timeout: 2000, encoding: 'utf8', windowsHide: true });
293
+ installed = r.status === 0 && Boolean((r.stdout || '').trim());
294
+ } else {
295
+ const probe = `p=$(command -v ${JSON.stringify(bin)} 2>/dev/null) || exit 1; ` +
296
+ `case "$p" in /*) [ -x "$p" ] ;; *) : ;; esac`;
297
+ const r = spawnSync('bash', ['-lc', probe], { timeout: 2000 });
298
+ installed = r.status === 0;
299
+ }
288
300
  _installedCache.set(id, { value: installed, ts: Date.now() });
289
301
  return installed;
290
302
  }
@@ -242,9 +242,34 @@ function readUserVersion(db) {
242
242
  return Number(row.user_version ?? row.USER_VERSION ?? 0);
243
243
  }
244
244
 
245
+ // quick_check throttle state, keyed per db handle. First write per handle
246
+ // always checks; then every Nth write or once the time floor elapses.
247
+ const QUICK_CHECK_EVERY_N = 100;
248
+ const QUICK_CHECK_MIN_INTERVAL_MS = 5 * 60 * 1000;
249
+ const quickCheckState = new WeakMap(); // db handle -> { writes, lastTs }
250
+
251
+ function shouldQuickCheck(db, now = Date.now()) {
252
+ let st = quickCheckState.get(db);
253
+ if (!st) {
254
+ st = { writes: 0, lastTs: 0 };
255
+ quickCheckState.set(db, st);
256
+ }
257
+ st.writes++;
258
+ if (
259
+ st.writes === 1 ||
260
+ st.writes % QUICK_CHECK_EVERY_N === 0 ||
261
+ (now - st.lastTs) >= QUICK_CHECK_MIN_INTERVAL_MS
262
+ ) {
263
+ st.lastTs = now;
264
+ return true;
265
+ }
266
+ return false;
267
+ }
268
+
245
269
  // Insert one row into a content table inside a transaction, then run
246
- // PRAGMA quick_check on the whole db. Throws IntegrityError on anything
247
- // other than 'ok'. Returns { id } of the inserted row.
270
+ // PRAGMA quick_check on the whole db (throttled; see above). Throws
271
+ // IntegrityError on anything other than 'ok'. Returns { id } of the
272
+ // inserted row.
248
273
  //
249
274
  // Allowed tables: raw, compiled, trident_run, schema_meta. Caller passes a
250
275
  // row object whose keys match the table's columns; binding is positional
@@ -291,19 +316,28 @@ export function safeWrite(db, table, row) {
291
316
  const sql = `INSERT INTO ${table} (${cols.join(', ')}) VALUES (${placeholders})`;
292
317
  const values = cols.map(c => row[c]);
293
318
 
294
- // Run insert + quick_check inside a single transaction. Rollback on
295
- // either failure so we never leave a half-written FTS index behind.
319
+ // Run insert + (throttled) quick_check inside a single transaction.
320
+ // Rollback on either failure so we never leave a half-written FTS index
321
+ // behind. quick_check is a full-database scan, so it can't run on every
322
+ // insert (O(db size) per write while the lock is held); the corruption
323
+ // tripwire fires on the FIRST write per handle (compute callers hold
324
+ // long-lived handles, so reopen-after-corruption is still caught), then
325
+ // every Nth write or after a time floor, whichever comes first. State is
326
+ // keyed per HANDLE (WeakMap), unlike memory/fts5.js which keys per
327
+ // filename because server.js re-opens that db per store.
296
328
  let inserted;
297
329
  const tx = db.txn(() => {
298
330
  const stmt = db.prepare(sql);
299
331
  const info = stmt.run(...values);
300
332
  inserted = { id: info && info.lastInsertRowid != null ? Number(info.lastInsertRowid) : null };
301
- const qc = db.prepare('PRAGMA quick_check').get();
302
- const status = qc && (qc.quick_check ?? qc.QUICK_CHECK);
303
- if (status !== 'ok') {
304
- throw new IntegrityError(
305
- `PRAGMA quick_check failed after insert into ${table}: ${status || '(no result)'}.`
306
- );
333
+ if (shouldQuickCheck(db)) {
334
+ const qc = db.prepare('PRAGMA quick_check').get();
335
+ const status = qc && (qc.quick_check ?? qc.QUICK_CHECK);
336
+ if (status !== 'ok') {
337
+ throw new IntegrityError(
338
+ `PRAGMA quick_check failed after insert into ${table}: ${status || '(no result)'}.`
339
+ );
340
+ }
307
341
  }
308
342
  });
309
343
  tx();
@@ -166,11 +166,11 @@ export function propagateStale(db, supersededNodeId, options = {}) {
166
166
  // timeout in fts5.openDb, so concurrent reads remain unblocked.
167
167
  const updateRaw = db.prepare(
168
168
  `UPDATE raw SET stale_candidate = ? ` +
169
- `WHERE stale_candidate < ? AND body LIKE ?`
169
+ `WHERE stale_candidate < ? AND body LIKE ? ESCAPE '\\'`
170
170
  );
171
171
  const updateCompiled = db.prepare(
172
172
  `UPDATE compiled SET stale_candidate = ? ` +
173
- `WHERE stale_candidate < ? AND (topic LIKE ? OR body LIKE ?)`
173
+ `WHERE stale_candidate < ? AND (topic LIKE ? ESCAPE '\\' OR body LIKE ? ESCAPE '\\')`
174
174
  );
175
175
 
176
176
  const tx = (typeof db.txn === 'function')
@@ -204,12 +204,13 @@ export function propagateStale(db, supersededNodeId, options = {}) {
204
204
  };
205
205
  }
206
206
 
207
- // LIKE pattern escape -- our names can contain `%` or `_` (e.g. error
208
- // codes like `E_BUSY%` -- unlikely but possible). Escape both, plus the
209
- // backslash escape character. Use `\` as the escape; SQLite needs an
210
- // explicit ESCAPE clause to honour it, so we add that to the prepared
211
- // statement above. (Skipped here because in practice kg_node names from
212
- // our regex extractor never contain `%` or `_` chars.)
207
+ // LIKE pattern escape -- kg_node names routinely contain `_` (the entity
208
+ // extractor's snake_case/UPPER_SNAKE regexes REQUIRE underscores) and can
209
+ // contain `%`. Escape both, plus the backslash escape character. SQLite
210
+ // LIKE has NO default escape character, so every consuming statement above
211
+ // carries an explicit ESCAPE '\' clause -- without it, `\_` matches a
212
+ // literal backslash followed by ANY character and snake_case names
213
+ // silently flag zero rows.
213
214
  function escapeLike(s) {
214
215
  return String(s).replace(/[\\%_]/g, '\\$&');
215
216
  }
@@ -8,7 +8,7 @@
8
8
  */
9
9
 
10
10
  import { readdirSync, readFileSync, existsSync, statSync } from 'node:fs';
11
- import { join } from 'node:path';
11
+ import { join, basename } from 'node:path';
12
12
  import { homedir } from 'node:os';
13
13
 
14
14
  const CODEX_SESSIONS_DIR = join(homedir(), '.codex', 'sessions');
@@ -63,13 +63,13 @@ function processCodexFile(filePath, turns) {
63
63
  }
64
64
 
65
65
  if (!sessionId) {
66
- // Derive session id from filename
67
- const base = filePath.split('/').pop().replace('.jsonl', '');
68
- sessionId = base;
66
+ // Derive session id from filename (basename handles both path separators)
67
+ sessionId = basename(filePath, '.jsonl');
69
68
  }
70
69
 
71
- // Extract timestamp from session path (YYYY/MM/DD/filename)
72
- const dateParts = filePath.match(/(\d{4})\/(\d{2})\/(\d{2})/);
70
+ // Extract timestamp from session path (YYYY/MM/DD/filename); paths are
71
+ // join()-built, so accept either separator (backslashes on Windows).
72
+ const dateParts = filePath.match(/(\d{4})[\\/](\d{2})[\\/](\d{2})/);
73
73
  const datePrefix = dateParts ? `${dateParts[1]}-${dateParts[2]}-${dateParts[3]}` : null;
74
74
 
75
75
  // Accumulate per-session totals from response_item/message content
@@ -9,7 +9,7 @@
9
9
 
10
10
  import { readFileSync, existsSync, writeFileSync, mkdirSync, statSync, openSync, readSync, closeSync, readdirSync, rmSync, realpathSync, copyFileSync } from 'node:fs';
11
11
  import { fileURLToPath, pathToFileURL } from 'node:url';
12
- import { join, dirname, basename, isAbsolute, resolve } from 'node:path';
12
+ import { join, dirname, basename, isAbsolute, resolve, parse as parsePath, sep } from 'node:path';
13
13
  import { homedir } from 'node:os';
14
14
  import { spawnSync } from 'node:child_process';
15
15
  import { writeAtomic } from './lib/atomic-io.js';
@@ -210,7 +210,7 @@ function emitJson(value) {
210
210
  process.stdout.write(JSON.stringify(value, null, 2) + '\n');
211
211
  }
212
212
 
213
- function parseArgs(argv) {
213
+ export function parseArgs(argv) {
214
214
  const args = argv.slice(2); // strip node + script path
215
215
 
216
216
  // Global --json flag: any command can be forced to JSON output regardless
@@ -508,20 +508,12 @@ function parseArgsInner(args) {
508
508
  return { cmd: 'cross-project-audit', rule, dryRun };
509
509
  }
510
510
 
511
- const target = args[2];
512
- let only = null;
513
- let confirm = false;
514
- let expand = false;
515
- let chunk = false;
516
-
517
- for (let i = 3; i < args.length; i++) {
518
- if (args[i] === '--confirm') { confirm = true; }
519
- else if (args[i] === '--expand') { expand = true; }
520
- else if (args[i] === '--chunk') { chunk = true; } // v1.5.1 H1.6 — wire chunker
521
- else if (args[i] === '--with' && args[i + 1]) { only = args[++i]; }
522
- }
523
-
524
- return { cmd: 'cross', mode, target, only, confirm, expand, chunk };
511
+ // Reuse the alias parser so `ijfw cross <mode>` and `ijfw cross-<mode>`
512
+ // share one grammar: --with=<id> form, flags-before-target ordering, and
513
+ // flag tokens never consumed as the target. The old position-rigid parser
514
+ // (target = args[2], space-separated --with only) silently dropped
515
+ // --with=<id> and dispatched the full paid roster.
516
+ return parseCrossAlias(mode, args.slice(1));
525
517
  }
526
518
 
527
519
  return { cmd: 'unknown', raw: args[0] };
@@ -1373,7 +1365,7 @@ async function cmdCross({ mode, target, only, confirm, expand, chunk }) {
1373
1365
  console.log(`Auditors fired (union): ${[...auditorIds].join(', ') || '(none)'}`);
1374
1366
  if (!firedAny) {
1375
1367
  console.log('No auditors fired -- run `ijfw doctor` to see the install hints.');
1376
- process.exit(2); // r17.1 degraded exit code
1368
+ process.exit(3); // zero picks contributed -- INCONCLUSIVE, same code as the normal path
1377
1369
  }
1378
1370
  for (const f of merged) {
1379
1371
  const sev = (f.severity || 'note').toUpperCase();
@@ -1401,7 +1393,10 @@ async function cmdCross({ mode, target, only, confirm, expand, chunk }) {
1401
1393
  console.log('Trident is standing by -- no auditors reachable yet.');
1402
1394
  console.log('Wire one in 30 seconds: run `ijfw doctor` for the exact install commands.');
1403
1395
  console.log('Tip: any one of codex / gemini / claude / copilot is enough to start.');
1404
- process.exit(0);
1396
+ // No audit happened: exit 3 per the documented contract below (zero picks
1397
+ // contributed = INCONCLUSIVE). Exit 0 here let CI gates pass green on a
1398
+ // machine with no auditors installed.
1399
+ process.exit(3);
1405
1400
  }
1406
1401
 
1407
1402
  const projectDir = process.cwd();
@@ -1425,7 +1420,7 @@ async function cmdCross({ mode, target, only, confirm, expand, chunk }) {
1425
1420
  if (picks.length === 0) {
1426
1421
  console.log('\nIJFW has the Trident ready -- install codex or gemini (or set OPENAI_API_KEY / GEMINI_API_KEY), then run `ijfw demo`.');
1427
1422
  console.log('Run `ijfw doctor` to see which auditors are available on this machine.');
1428
- return;
1423
+ process.exit(3); // zero picks contributed -- INCONCLUSIVE per the exit contract
1429
1424
  }
1430
1425
 
1431
1426
  console.log(`Fired: ${picks.map(p => p.id).join(', ')}`);
@@ -2972,8 +2967,14 @@ function cmdInit(parsed = {}) {
2972
2967
  try { phys = realpathSync(cwd); } catch { phys = resolve(cwd); }
2973
2968
  let homePhys;
2974
2969
  try { homePhys = realpathSync(homedir()); } catch { homePhys = homedir(); }
2975
- if (phys === '/' || phys === homePhys) {
2976
- console.error('ijfw init: refusing to bless your home directory or the filesystem root for indexing.');
2970
+ // Refuse any filesystem/drive root (parse().root catches '/', 'C:\\', UNC
2971
+ // roots), the home directory itself, or any ANCESTOR of home (blessing
2972
+ // /Users or /home would let the indexer walk every user's home -- the
2973
+ // issue #16 privacy hole one directory up).
2974
+ const isFsRoot = parsePath(phys).root === phys;
2975
+ const isHomeOrAncestor = phys === homePhys || homePhys.startsWith(phys + sep);
2976
+ if (isFsRoot || isHomeOrAncestor) {
2977
+ console.error('ijfw init: refusing to bless your home directory, its ancestors, or a filesystem root for indexing.');
2977
2978
  console.error('Run `ijfw init` from inside an actual project folder.');
2978
2979
  process.exit(1);
2979
2980
  }
@@ -3007,6 +3008,9 @@ function cmdInstall() {
3007
3008
  process.exit(1);
3008
3009
  }
3009
3010
  const res = spawnSync('bash', [script, ...process.argv.slice(3)], { stdio: 'inherit' });
3011
+ // spawnSync failure (bash missing, EACCES) yields status null + res.error
3012
+ // and stdio:'inherit' prints nothing -- surface it instead of exiting mute.
3013
+ if (res.error) console.error(`ijfw: failed to launch ${script}: ${res.error.message}`);
3010
3014
  process.exit(res.status ?? 1);
3011
3015
  }
3012
3016
  function cmdUninstall() {
@@ -3016,6 +3020,7 @@ function cmdUninstall() {
3016
3020
  process.exit(1);
3017
3021
  }
3018
3022
  const res = spawnSync(process.execPath, [script, ...process.argv.slice(3)], { stdio: 'inherit' });
3023
+ if (res.error) console.error(`ijfw: failed to launch ${script}: ${res.error.message}`);
3019
3024
  process.exit(res.status ?? 1);
3020
3025
  }
3021
3026
  function cmdPreflight() {
@@ -3025,6 +3030,7 @@ function cmdPreflight() {
3025
3030
  process.exit(1);
3026
3031
  }
3027
3032
  const res = spawnSync(process.execPath, [script, ...process.argv.slice(3)], { stdio: 'inherit' });
3033
+ if (res.error) console.error(`ijfw: failed to launch ${script}: ${res.error.message}`);
3028
3034
  process.exit(res.status ?? 1);
3029
3035
  }
3030
3036
  function cmdDashboard(sub) {
@@ -3044,6 +3050,7 @@ function cmdDashboard(sub) {
3044
3050
  // stays authoritative. argv = [node, cli, 'dashboard', <sub>, ...flags].
3045
3051
  const passthrough = process.argv.slice(4);
3046
3052
  const res = spawnSync(process.execPath, [launcher, sub, ...passthrough], { stdio: 'inherit' });
3053
+ if (res.error) console.error(`ijfw: failed to launch ${launcher}: ${res.error.message}`);
3047
3054
  process.exit(res.status ?? 1);
3048
3055
  }
3049
3056
 
@@ -7,7 +7,7 @@
7
7
  */
8
8
 
9
9
  import { createServer } from 'node:http';
10
- import { existsSync, readFileSync, watch, writeFileSync, mkdirSync, readdirSync, statSync, realpathSync, renameSync, unlinkSync } from 'node:fs';
10
+ import { existsSync, readFileSync, watch, writeFileSync, mkdirSync, readdirSync, statSync, realpathSync, renameSync, unlinkSync, openSync, readSync, closeSync, chmodSync } from 'node:fs';
11
11
  import { readFile } from 'node:fs/promises';
12
12
  import { homedir } from 'node:os';
13
13
  import { join, dirname, resolve, relative, isAbsolute, basename, sep } from 'node:path';
@@ -178,14 +178,31 @@ function requireLocalhost(req, res) {
178
178
  // stamp Sec-Fetch-Site; the dashboard's own page is 'same-origin', direct tools
179
179
  // (curl, address bar) send 'none'/nothing. Only same-machine cross-origin pages
180
180
  // hit 'cross-site'/'same-site' -- block those on /api.
181
+ //
182
+ // State-changing methods (POST/PUT/PATCH/DELETE) fail CLOSED when Sec-Fetch-Site
183
+ // is absent: a browser cross-origin write always carries an Origin header (even
184
+ // for no-preflight text/plain "simple requests"), so any Origin/Referer that does
185
+ // not match our own Host is rejected. Requests with no browser markers at all
186
+ // (curl, local scripts) carry neither header and stay allowed.
181
187
  function rejectCrossSiteApi(req, res, path) {
182
188
  if (!path.startsWith('/api')) return false;
183
- const sfs = req.headers['sec-fetch-site'];
184
- if (sfs === 'cross-site' || sfs === 'same-site') {
189
+ function reject() {
185
190
  res.writeHead(403, { 'Content-Type': 'application/json' });
186
191
  res.end('{"error":"cross-origin request rejected"}');
187
192
  return true;
188
193
  }
194
+ const sfs = req.headers['sec-fetch-site'];
195
+ if (sfs === 'cross-site' || sfs === 'same-site') return reject();
196
+ const method = (req.method || 'GET').toUpperCase();
197
+ if (method === 'GET' || method === 'HEAD' || method === 'OPTIONS') return false;
198
+ // Writes: 'same-origin'/'none' are browser-proven safe; otherwise require any
199
+ // Origin/Referer present to match the host the request was addressed to.
200
+ if (sfs === 'same-origin' || sfs === 'none') return false;
201
+ const proof = req.headers['origin'] || req.headers['referer'];
202
+ if (!proof) return false;
203
+ let proofHost = null;
204
+ try { proofHost = new URL(proof).host; } catch {}
205
+ if (proofHost === null || proofHost !== (req.headers.host || '')) return reject();
189
206
  return false;
190
207
  }
191
208
 
@@ -205,14 +222,43 @@ function route(req, res, routes) {
205
222
  }
206
223
 
207
224
  // ---------- JSONL reader ----------
225
+ // observations.jsonl is append-only with no rotation, so an unbounded
226
+ // synchronous read+parse on every poll would stall the single-threaded server
227
+ // (same hazard tailEvents() was rewritten to avoid). Bound the read to the
228
+ // last OBS_TAIL_BYTES via fd+position, and cache the parsed array keyed on
229
+ // mtime+size so repeated polls of an unchanged file never re-read it.
230
+ const OBS_TAIL_BYTES = 5 * 1024 * 1024; // 5MB
231
+ const obsCache = new Map(); // ledgerPath -> { key, data }
232
+
208
233
  function readObservations(ledgerPath) {
209
- if (!existsSync(ledgerPath)) return [];
234
+ let st;
235
+ try { st = statSync(ledgerPath); } catch { return []; }
236
+ const key = `${st.mtimeMs}:${st.size}`;
237
+ const cached = obsCache.get(ledgerPath);
238
+ if (cached && cached.key === key) return cached.data;
210
239
  try {
211
- return readFileSync(ledgerPath, 'utf8')
212
- .split('\n')
213
- .filter(Boolean)
240
+ const start = Math.max(0, st.size - OBS_TAIL_BYTES);
241
+ const len = st.size - start;
242
+ const buf = Buffer.allocUnsafe(len);
243
+ const fd = openSync(ledgerPath, 'r');
244
+ let read = 0;
245
+ try {
246
+ while (read < len) {
247
+ const n = readSync(fd, buf, read, len - read, start + read);
248
+ if (n === 0) break;
249
+ read += n;
250
+ }
251
+ } finally {
252
+ closeSync(fd);
253
+ }
254
+ let lines = buf.subarray(0, read).toString('utf8').split('\n').filter(Boolean);
255
+ // If we sliced mid-line, the first element may be truncated -- drop it.
256
+ if (start > 0) lines = lines.slice(1);
257
+ const data = lines
214
258
  .map(l => { try { return JSON.parse(l); } catch { return null; } })
215
259
  .filter(Boolean);
260
+ obsCache.set(ledgerPath, { key, data });
261
+ return data;
216
262
  } catch {
217
263
  return [];
218
264
  }
@@ -830,8 +876,11 @@ export async function startServer(options = {}) {
830
876
  req.on('end', () => {
831
877
  try {
832
878
  const parsed = JSON.parse(body);
833
- mkdirSync(join(homedir(), '.ijfw'), { recursive: true });
834
- writeFileSync(configPath, JSON.stringify(parsed, null, 2), 'utf8');
879
+ mkdirSync(join(homedir(), '.ijfw'), { recursive: true, mode: 0o700 });
880
+ // 0o600: config holds account/subscription data; keep it owner-only
881
+ // (mode only applies on create, so chmod existing files too).
882
+ writeFileSync(configPath, JSON.stringify(parsed, null, 2), { encoding: 'utf8', mode: 0o600 });
883
+ try { chmodSync(configPath, 0o600); } catch {}
835
884
  res.writeHead(200, { 'Content-Type': 'application/json' });
836
885
  res.end(JSON.stringify({ ok: true }));
837
886
  } catch (err) {
@@ -1497,6 +1546,50 @@ if (process.argv.includes('--daemon')) {
1497
1546
  const pidFile = process.env.IJFW_PID_FILE || join(homedir(), '.ijfw', 'dashboard.pid');
1498
1547
  const portFile = process.env.IJFW_PORT_FILE || join(homedir(), '.ijfw', 'dashboard.port');
1499
1548
 
1549
+ // Singleton guard: the spawner's pid-check (session-start.sh) is a classic
1550
+ // check-then-act race -- two sessions starting in the same window both see no
1551
+ // live daemon and both spawn --daemon. Without a guard here, the loser walks
1552
+ // to the next port and OVERWRITES the shared pid/port files, orphaning the
1553
+ // winner forever. Acquire the pid file with O_EXCL BEFORE binding: exactly
1554
+ // one starter wins; losers exit 0 without binding or clobbering. A pid file
1555
+ // whose owner is dead is stale and reclaimed once.
1556
+ const ijfwDir = dirname(pidFile);
1557
+ mkdirSync(ijfwDir, { recursive: true });
1558
+
1559
+ function pidAlive(pid) {
1560
+ if (!Number.isInteger(pid) || pid <= 0) return false;
1561
+ try { process.kill(pid, 0); return true; }
1562
+ catch (err) { return err.code === 'EPERM'; }
1563
+ }
1564
+
1565
+ function acquirePidFile() {
1566
+ for (let attempt = 0; attempt < 2; attempt++) {
1567
+ try {
1568
+ writeFileSync(pidFile, String(process.pid), { encoding: 'utf8', flag: 'wx' });
1569
+ return true;
1570
+ } catch (err) {
1571
+ if (err.code !== 'EEXIST') throw err;
1572
+ let holder = NaN;
1573
+ try { holder = Number.parseInt(readFileSync(pidFile, 'utf8').trim(), 10); } catch {}
1574
+ if (pidAlive(holder)) return false; // live daemon already owns it
1575
+ try { unlinkSync(pidFile); } catch {} // stale -- reclaim and retry once
1576
+ }
1577
+ }
1578
+ return false; // lost the reclaim race to another starter; that one serves
1579
+ }
1580
+
1581
+ if (!acquirePidFile()) {
1582
+ process.stderr.write('[ijfw-dashboard] daemon already running -- exiting\n');
1583
+ process.exit(0);
1584
+ }
1585
+
1586
+ function releasePidFile() {
1587
+ // Only remove the pid file if it is still ours.
1588
+ try {
1589
+ if (readFileSync(pidFile, 'utf8').trim() === String(process.pid)) unlinkSync(pidFile);
1590
+ } catch {}
1591
+ }
1592
+
1500
1593
  // Optional preferred-port override (forwarded by the launcher's `--port N`).
1501
1594
  // Unset = default 37891-37900 walk. Invalid values fall back to the default.
1502
1595
  const startOpts = {};
@@ -1504,10 +1597,6 @@ if (process.argv.includes('--daemon')) {
1504
1597
  if (Number.isInteger(envPort) && envPort > 0 && envPort < 65536) startOpts.port = envPort;
1505
1598
 
1506
1599
  startServer(startOpts).then(({ port }) => {
1507
- const ijfwDir = dirname(pidFile);
1508
- mkdirSync(ijfwDir, { recursive: true });
1509
- // PID file: plain write (single writer; pid is meaningless mid-write)
1510
- writeFileSync(pidFile, String(process.pid), 'utf8');
1511
1600
  // Port file: atomic write via tmp+rename so readers never see a partial value (W4.2).
1512
1601
  // Cleanup tmp on rename failure so it doesn't leak (W9-M1).
1513
1602
  const portTmp = `${portFile}.tmp.${process.pid}.${Date.now()}`;
@@ -1519,6 +1608,7 @@ if (process.argv.includes('--daemon')) {
1519
1608
  throw new Error(`atomic write failed for ${portFile}: ${err.message}`);
1520
1609
  }
1521
1610
  }).catch(err => {
1611
+ releasePidFile();
1522
1612
  process.stderr.write('[ijfw-dashboard] ' + err.message + '\n');
1523
1613
  process.exit(1);
1524
1614
  });
@@ -99,10 +99,18 @@ export async function createPreviewSandbox({ html, name } = {}) {
99
99
  // surface for vercel-sandbox is evolving; we accept any JSON line that
100
100
  // contains a `url` field.
101
101
  try {
102
+ // shell:true on Windows so the vercel.cmd npm shim resolves; harmless on
103
+ // POSIX. sandboxId/tmpFile are internally generated (sanitized name +
104
+ // uuid under tmpdir), not user input.
102
105
  const r = spawnSync('vercel', ['sandbox', 'create', '--file', tmpFile, '--name', sandboxId], {
103
106
  encoding: 'utf8',
104
107
  timeout: PROVISION_TIMEOUT_MS,
108
+ shell: process.platform === 'win32',
105
109
  });
110
+ if (r.error) {
111
+ _advise(`createPreviewSandbox: vercel CLI not spawnable (${r.error.message}) -- falling back to static`);
112
+ return null;
113
+ }
106
114
  if (r.status !== 0) {
107
115
  _advise(`createPreviewSandbox: vercel CLI exit ${r.status} -- falling back to static`);
108
116
  return null;
@@ -140,7 +148,11 @@ export async function destroySandbox(sandboxId) {
140
148
  return;
141
149
  }
142
150
  if (entry.mode === 'cli') {
143
- spawnSync('vercel', ['sandbox', 'delete', sandboxId], { encoding: 'utf8', timeout: DESTROY_TIMEOUT_MS });
151
+ spawnSync('vercel', ['sandbox', 'delete', sandboxId], {
152
+ encoding: 'utf8',
153
+ timeout: DESTROY_TIMEOUT_MS,
154
+ shell: process.platform === 'win32',
155
+ });
144
156
  return;
145
157
  }
146
158
  } catch (err) {
@@ -8,7 +8,10 @@
8
8
  // Mirrors src/compute/fts5.js patterns:
9
9
  // - WAL journal mode for concurrent readers
10
10
  // - PRAGMA busy_timeout = 5000 + BEGIN IMMEDIATE for racing writers
11
- // - PRAGMA quick_check post-write enforces integrity
11
+ // - PRAGMA quick_check corruption tripwire on a throttled cadence
12
+ // (first write per db file per process, then every Nth write or
13
+ // after a time floor -- never on every single-row insert, because
14
+ // quick_check is a full-database scan)
12
15
  //
13
16
  // Security model (D-PILLAR-SPEC section 12, real fix-wave C3):
14
17
  // indexEntry runs `redactSecrets()` over `entry.body` AND `entry.source`
@@ -182,9 +185,52 @@ function readUserVersion(db) {
182
185
  return Number(row.user_version ?? row.USER_VERSION ?? 0);
183
186
  }
184
187
 
185
- // Insert one row into memory_entries inside a BEGIN IMMEDIATE transaction,
186
- // then run PRAGMA quick_check on the whole db. Throws MemoryIntegrityError
187
- // on anything other than 'ok'. Returns { id } of the inserted row.
188
+ // Corruption tripwire cadence. PRAGMA quick_check walks every page of the
189
+ // database, so running it inside EVERY single-row insert transaction is
190
+ // O(db size) per write while the RESERVED lock is held -- a quadratic
191
+ // total-cost cliff as the warm tier grows. The tripwire is kept, but on a
192
+ // throttle: the FIRST write per db file per process always checks (so a
193
+ // reopen-after-corruption is caught on the next write), then every Nth
194
+ // write or once the time floor elapses, whichever fires first. State is
195
+ // keyed by filename, NOT by handle, because server.js re-opens the db per
196
+ // store -- a per-open or per-handle check would put the full scan right
197
+ // back on the hot path.
198
+ const QUICK_CHECK_EVERY_N = 100;
199
+ const QUICK_CHECK_MIN_INTERVAL_MS = 5 * 60 * 1000;
200
+ const __quickCheckState = new Map(); // filename -> { writes, lastTs }
201
+
202
+ function shouldQuickCheck(filename, now = Date.now()) {
203
+ const key = filename || ':unknown:';
204
+ let st = __quickCheckState.get(key);
205
+ if (!st) {
206
+ st = { writes: 0, lastTs: 0 };
207
+ __quickCheckState.set(key, st);
208
+ }
209
+ st.writes++;
210
+ if (
211
+ st.writes === 1 ||
212
+ st.writes % QUICK_CHECK_EVERY_N === 0 ||
213
+ (now - st.lastTs) >= QUICK_CHECK_MIN_INTERVAL_MS
214
+ ) {
215
+ st.lastTs = now;
216
+ return true;
217
+ }
218
+ return false;
219
+ }
220
+
221
+ // Test hook -- cadence logic is invisible from outside (it only changes
222
+ // WHEN the scan runs), so tests assert on it directly.
223
+ export const __quickCheck = {
224
+ shouldQuickCheck,
225
+ QUICK_CHECK_EVERY_N,
226
+ QUICK_CHECK_MIN_INTERVAL_MS,
227
+ reset: () => __quickCheckState.clear(),
228
+ };
229
+
230
+ // Insert one row into memory_entries inside a BEGIN IMMEDIATE transaction.
231
+ // On the throttled cadence above, runs PRAGMA quick_check inside the same
232
+ // transaction and throws MemoryIntegrityError on anything other than 'ok'
233
+ // (rolling the insert back -- fail-safe). Returns { id } of the inserted row.
188
234
  //
189
235
  // Caller passes { body, source?, session_id? }. created_at is set here
190
236
  // (unix ms) so callers don't have to remember the convention.
@@ -224,12 +270,14 @@ export function indexEntry(db, entry) {
224
270
  inserted = {
225
271
  id: info && info.lastInsertRowid != null ? Number(info.lastInsertRowid) : null,
226
272
  };
227
- const qc = db.prepare('PRAGMA quick_check').get();
228
- const status = qc && (qc.quick_check ?? qc.QUICK_CHECK);
229
- if (status !== 'ok') {
230
- throw new MemoryIntegrityError(
231
- `PRAGMA quick_check failed after insert into memory_entries: ${status || '(no result)'}.`
232
- );
273
+ if (shouldQuickCheck(db.__ijfw_filename)) {
274
+ const qc = db.prepare('PRAGMA quick_check').get();
275
+ const status = qc && (qc.quick_check ?? qc.QUICK_CHECK);
276
+ if (status !== 'ok') {
277
+ throw new MemoryIntegrityError(
278
+ `PRAGMA quick_check failed after insert into memory_entries: ${status || '(no result)'}.`
279
+ );
280
+ }
233
281
  }
234
282
  });
235
283
  tx();
@@ -282,6 +330,13 @@ export function indexEntry(db, entry) {
282
330
  const ts = row.created_at;
283
331
  const sessionId = row.session_id;
284
332
  const body = row.body;
333
+ // Receipt path must belong to the project that owns THIS db, never the
334
+ // process cwd (MCP hosts commonly spawn servers from $HOME, and openDb
335
+ // supports an explicit projectRoot distinct from cwd). The db lives at
336
+ // <root>/.ijfw/index/memory.db, so dirname(filename) IS the index dir.
337
+ const receiptDir = db.__ijfw_filename
338
+ ? dirname(db.__ijfw_filename)
339
+ : join(process.env.IJFW_PROJECT_DIR || process.cwd(), IJFW_DIR_NAME, INDEX_DIR_NAME);
285
340
  // v1.5.0 audit-LOW-memory-#14: dead-letter receipt for auto-index failures.
286
341
  // Fire-and-forget was already swallowed silently; now we append an
287
342
  // append-only JSONL receipt so silent indexer breakage is detectable in
@@ -293,10 +348,10 @@ export function indexEntry(db, entry) {
293
348
  // Lazy import; node:fs/promises is always available.
294
349
  import('node:fs/promises').then(({ appendFile, mkdir }) => {
295
350
  try {
296
- const indexDir = '.ijfw/index';
351
+ const indexDir = receiptDir;
297
352
  return mkdir(indexDir, { recursive: true })
298
353
  .then(() => appendFile(
299
- `${indexDir}/graph-errors.jsonl`,
354
+ join(indexDir, 'graph-errors.jsonl'),
300
355
  JSON.stringify({
301
356
  ts: new Date().toISOString(),
302
357
  session_id: sessionId || null,
@@ -38,6 +38,12 @@ import { loadMigrations } from './migration-runner.js';
38
38
  // is imported directly so M1 runs synchronously inside the same txn batch.
39
39
  import { indexObsidianRelations } from './obsidian-parser.js';
40
40
  import { autoLink } from './auto-linker.js';
41
+ // Ingest scrub gate (D-PILLAR-SPEC section 12) -- the warm-tier rebuild
42
+ // reads raw markdown from disk, which is NOT guaranteed pre-scrubbed
43
+ // (hand-edited notes, hook-written files, imports never went through
44
+ // handleStore's redaction). autoIndex must apply the same redactSecrets
45
+ // pass as fts5.js#indexEntry or secrets land cleartext in memory.db.
46
+ import { redactSecrets } from '../redactor.js';
41
47
 
42
48
  const MAX_RESULTS = 50;
43
49
  const SNIPPET_HALF = 60;
@@ -259,25 +265,35 @@ function runMemoryMigrationsSync(db, currentVersion, targetVersion) {
259
265
  }
260
266
 
261
267
  function autoIndex(db, files) {
262
- let n = 0;
263
268
  // v1.5.1 R4-H2 — capture the rowid of every inserted entry so the
264
269
  // memory-moat aux indexing (M1 Obsidian relations, M2 auto-link) can run
265
270
  // over the warm-tier rebuild, not just the benchmark harness. The bulk
266
271
  // INSERT stays in one transaction for FTS write performance; M1/M2 run
267
272
  // AFTER commit so a parse/link failure can never abort the rebuild.
273
+ //
274
+ // Rollback safety: ids are collected in a transaction-local array and
275
+ // only published to `inserted` after txfn commits. If the batch rolls
276
+ // back, the rowids it produced no longer exist (and AUTOINCREMENT will
277
+ // reuse them), so running M1/M2 over them would attach links/tags/meta
278
+ // to the WRONG future entries.
268
279
  const inserted = [];
269
280
  const txfn = db.transaction((batch) => {
270
281
  const stmt = db.prepare(
271
282
  'INSERT INTO memory_entries (body, source, session_id, created_at) VALUES (?, ?, ?, ?)'
272
283
  );
284
+ const out = [];
273
285
  for (const item of batch) {
274
286
  const info = stmt.run(item.body, item.source, null, item.created_at);
275
287
  const id = info && info.lastInsertRowid != null ? Number(info.lastInsertRowid) : null;
276
- inserted.push({ id, body: item.body });
277
- n++;
288
+ out.push({ id, body: item.body });
278
289
  }
290
+ return out;
279
291
  });
280
292
 
293
+ // Same ingest scrub gate as fts5.js#indexEntry (IJFW_INGEST_SCRUB=0 is
294
+ // the only escape hatch, local debugging only). Body AND source are
295
+ // scrubbed so the FTS index and downstream M1/M2 only see safe text.
296
+ const scrub = process.env.IJFW_INGEST_SCRUB !== '0';
281
297
  const batch = [];
282
298
  const now = Date.now();
283
299
  for (const f of files) {
@@ -286,10 +302,22 @@ function autoIndex(db, files) {
286
302
  let body;
287
303
  try { body = readFileSync(f.path, 'utf8'); } catch { continue; }
288
304
  if (!body) continue;
289
- batch.push({ body, source: f.relpath || f.path, created_at: now });
305
+ const rawSource = f.relpath || f.path;
306
+ batch.push({
307
+ body: scrub ? redactSecrets(body) : body,
308
+ source: scrub ? redactSecrets(String(rawSource)) : rawSource,
309
+ created_at: now,
310
+ });
290
311
  }
291
312
  if (batch.length === 0) return 0;
292
- try { txfn.immediate(batch); } catch { /* one bad batch should not abort the search */ }
313
+ let n = 0;
314
+ try {
315
+ const committed = txfn.immediate(batch);
316
+ if (Array.isArray(committed)) {
317
+ inserted.push(...committed);
318
+ n = committed.length;
319
+ }
320
+ } catch { /* one bad batch should not abort the search; rollback discards ids */ }
293
321
 
294
322
  // v1.5.1 R4-H2 — M1: Obsidian wikilink/tag/meta indexing into
295
323
  // memory_links/_tags/_meta. Synchronous + idempotent (indexObsidianRelations
@@ -169,7 +169,7 @@ export function propagateStaleMemory(memDb, computeDb, supersededNodeId, options
169
169
  if (namesToFlag.length > 0) {
170
170
  const updateMem = memDb.prepare(
171
171
  `UPDATE memory_entries SET stale_candidate = ? ` +
172
- `WHERE COALESCE(stale_candidate, 0) < ? AND body LIKE ?`
172
+ `WHERE COALESCE(stale_candidate, 0) < ? AND body LIKE ? ESCAPE '\\'`
173
173
  );
174
174
 
175
175
  const txWrap = (typeof memDb.transaction === 'function')
@@ -42,7 +42,7 @@ function writeJson(rows) {
42
42
  return p;
43
43
  }
44
44
 
45
- // 10 authors × 6 long docs each — comfortably over the floors.
45
+ // 10 authors x 6 long docs each — comfortably over the floors.
46
46
  function tenAuthors() {
47
47
  const rows = [];
48
48
  for (let a = 0; a < 10; a += 1) rows.push(...makeAuthorRows(`u${a}`, 6));
@@ -218,7 +218,7 @@ export async function runGateBProduction(opts = {}) {
218
218
  // budget-guarded cloud transport here: the allowed-set is the closed set of EVERY brief
219
219
  // the pool's own personas + foreigner-pool produce (baseline '' + derived + fewShotOracle
220
220
  // + register-echo) — foreign prose is never a target, only a fingerprint. The budget is
221
- // sized from arms × pool × probes × (pilot + confirmatory) with headroom.
221
+ // sized from arms x pool x probes x (pilot + confirmatory) with headroom.
222
222
  const poolForGuard = [...personas, ...foreigners];
223
223
  const budget = opts.budget || {
224
224
  calls: 0,
@@ -328,7 +328,7 @@ export function buildAllowedSys(personas, cfg = {}) {
328
328
  return sys;
329
329
  }
330
330
 
331
- // Estimate the cloud-call budget: arms × subjects × probes, per spend phase.
331
+ // Estimate the cloud-call budget: arms x subjects x probes, per spend phase.
332
332
  export function estimateCalls({
333
333
  nArms = 4, nSubjects, nProbes,
334
334
  }) {
@@ -225,7 +225,7 @@ export function cohenKappa(raterA = [], raterB = []) {
225
225
 
226
226
  // ---------------------------------------------------------------------------
227
227
  // ECE — Expected Calibration Error on the profile's `confidence` field. Bins
228
- // (confidence, correctness) pairs and measures |avg-confidence accuracy| per
228
+ // (confidence, correctness) pairs and measures |avg-confidence - accuracy| per
229
229
  // bin, weighted by bin mass. A well-calibrated profile that says "0.7 confident"
230
230
  // is right ~70% of the time. This is what makes `confidence` an honest number
231
231
  // instead of decoration.
@@ -52,7 +52,7 @@ export function bonferroniAlpha(familyAlpha, verdictArms) {
52
52
  }
53
53
 
54
54
  // Measured-scale floor: the minimum mean margin that counts as a real effect, expressed
55
- // in the instrument's OWN units = floorK * (betweenMean withinMean) from validateInstrument.
55
+ // in the instrument's OWN units = floorK * (betweenMean - withinMean) from validateInstrument.
56
56
  // This REPLACES the blind absolute constant (the prior attempt's failure class). Frozen
57
57
  // before any cloud spend (floorK is hashed; the derived value is recorded in the run).
58
58
  export function deriveMinMeanMargin(validation, floorK) {
@@ -1,7 +1,7 @@
1
1
  // wrong-target-control.mjs — Gate B v2, Task T5. THE discriminator.
2
2
  //
3
3
  // For each subject P and arm, the margin is:
4
- // m_P = distance(output, NEAREST same-register foreigner) distance(output, OWN test)
4
+ // m_P = distance(output, NEAREST same-register foreigner) - distance(output, OWN test)
5
5
  // m_P > 0 means the styled output landed closer to P's OWN held-out fingerprint than to
6
6
  // the CLOSEST same-register stranger. A generic register-obeyer is ~equidistant from all
7
7
  // same-register targets ⇒ m≈0 ⇒ NULL. Only idiosyncratic voice capture wins.
@@ -118,7 +118,7 @@ export function wrongTargetControl(harnessOut, personas, opts = {}) {
118
118
  }
119
119
  const ownLoss = margins.map((m) => (m < 0 ? 1 : 0));
120
120
  const ci = bootstrapCI(margins, { iters: cfg.bootstrapIters, alpha: cfg.alpha, seed: cfg.seed });
121
- // zeros-vs-wins sign test: b = #(margin>0), c = #(margin<0); two-sided p on |bc|.
121
+ // zeros-vs-wins sign test: b = #(margin>0), c = #(margin<0); two-sided p on |b-c|.
122
122
  const sign = mcnemar(ownLoss, ownWin);
123
123
  perArm[arm] = {
124
124
  arm,
@@ -141,7 +141,7 @@ export function wrongTargetControl(harnessOut, personas, opts = {}) {
141
141
  for (const arm of harnessOut.arms) {
142
142
  if (arm === 'baseline' || !perArm.baseline) continue;
143
143
  const m = mcnemar(perArm.baseline.ownWin, perArm[arm].ownWin);
144
- // mcnemar.pValue is TWO-SIDED (|bc|), so the direction guard m.b > m.c is mandatory:
144
+ // mcnemar.pValue is TWO-SIDED (|b-c|), so the direction guard m.b > m.c is mandatory:
145
145
  // the arm must FLIP MORE subjects to own-match than baseline does, not merely differ.
146
146
  perArm[arm].vsBaseline = {
147
147
  b: m.b, c: m.c, pValue: m.pValue, beatsBaseline: significantAt(m.pValue, cfg.perTestAlpha) && m.b > m.c,
@@ -61,7 +61,7 @@ export const EXEMPLAR_TEXT_MAX = 600;
61
61
  * Max bytes we will read from the on-disk JSONL. The store is bounded by
62
62
  * MAX_EXEMPLARS short records, so a file larger than this is a corrupt/hand-
63
63
  * edited artifact; refusing to slurp it whole avoids an OOM. ~2 MiB is orders
64
- * of magnitude above any legitimate exemplar set (200 × 600 chars ≈ 120 KiB).
64
+ * of magnitude above any legitimate exemplar set (200 x 600 chars ≈ 120 KiB).
65
65
  */
66
66
  const MAX_STORE_BYTES = 2 * 1024 * 1024;
67
67
 
@@ -3,8 +3,8 @@
3
3
  *
4
4
  * The NO-JUDGE behavioral metric (design spec §"The honest bar", claim 2):
5
5
  * "Repeat-correction-rate drop — how often you re-issue the SAME correction,
6
- * bucketed by session age. A working system bends the curve down ( in week 1
7
- * -> by week 4). The most honest single number."
6
+ * bucketed by session age. A working system bends the curve down (3x in week 1
7
+ * -> 0x by week 4). The most honest single number."
8
8
  *
9
9
  * This module records, per preference SLUG, every time the user RE-ISSUES a
10
10
  * correction that the profile should already have learned, and computes the drop
@@ -286,14 +286,25 @@ export function tier2SyntaxCheckCmd(filePath) {
286
286
  ],
287
287
  };
288
288
  case '.py':
289
- return { cmd: 'python3', args: ['-m', 'py_compile', filePath] };
289
+ // Windows ships python.exe, not python3. If neither exists the spawn
290
+ // ENOENT is treated as SKIP by verifyTier2, not a syntax failure.
291
+ return {
292
+ cmd: process.platform === 'win32' ? 'python' : 'python3',
293
+ args: ['-m', 'py_compile', filePath],
294
+ };
290
295
  case '.sh':
291
296
  case '.bash':
297
+ // On Windows this only works when a real bash.exe (Git Bash) is on
298
+ // PATH; otherwise verifyTier2 maps the ENOENT to SKIP.
292
299
  return { cmd: 'bash', args: ['-n', filePath] };
293
300
  case '.ts':
294
301
  case '.tsx': {
295
302
  // Only if tsc on PATH. The agent contract says SKIP when absent.
296
- const which = spawnSync(process.platform === 'win32' ? 'where' : 'which', ['tsc'], {
303
+ // On Windows tsc is a .cmd shim which Node cannot spawn without a
304
+ // shell (CVE-2024-27980), and shelling out with an interpolated
305
+ // filePath would be an injection vector -- so SKIP honestly there.
306
+ if (process.platform === 'win32') return null;
307
+ const which = spawnSync('which', ['tsc'], {
297
308
  encoding: 'utf8',
298
309
  });
299
310
  if (which.status === 0 && which.stdout.trim()) {
@@ -319,6 +330,11 @@ export async function verifyTier2(filePath) {
319
330
  await execFileAsync(spec.cmd, spec.args, { timeout: 15_000 });
320
331
  return { ok: true, skipped: false };
321
332
  } catch (err) {
333
+ // Checker binary missing/not spawnable (ENOENT, or EINVAL for Windows
334
+ // .cmd shims) is "cannot verify", not "syntax error" -- honest SKIP.
335
+ if (err && (err.code === 'ENOENT' || err.code === 'EINVAL')) {
336
+ return { ok: true, skipped: true };
337
+ }
322
338
  const stderr = err.stderr || err.stdout || err.message || '';
323
339
  return {
324
340
  ok: false,
@@ -369,10 +385,15 @@ async function resolveProjectVerifyCmd(projectRoot, verifyCmdOverride) {
369
385
  export async function verifyTier3(projectRoot, verifyCmdOverride) {
370
386
  const cmd = await resolveProjectVerifyCmd(projectRoot, verifyCmdOverride);
371
387
  if (!cmd) return { ok: true, skipped: true };
372
- // Run the command via `sh -c` so script lines like `npm test --silent` work
373
- // verbatim. Timeout is generous (5 min) because real test suites can be slow.
388
+ // Run the command via the platform shell so script lines like
389
+ // `npm test --silent` work verbatim: `sh -c` on POSIX, `cmd /d /s /c` on
390
+ // Windows ('sh' is not on PATH there). Timeout is generous (5 min)
391
+ // because real test suites can be slow.
392
+ const [shellBin, shellArgs] = process.platform === 'win32'
393
+ ? [process.env.ComSpec || 'cmd.exe', ['/d', '/s', '/c', cmd]]
394
+ : ['sh', ['-c', cmd]];
374
395
  return new Promise((resolve) => {
375
- execFile('sh', ['-c', cmd], { cwd: projectRoot, timeout: 5 * 60_000 }, (err, stdout, stderr) => {
396
+ execFile(shellBin, shellArgs, { cwd: projectRoot, timeout: 5 * 60_000 }, (err, stdout, stderr) => {
376
397
  const combined = `${String(stdout || '')}\n${String(stderr || '')}`;
377
398
  if (err) {
378
399
  const evidence = combined.split('\n').slice(0, 20).join('\n');
@@ -215,8 +215,11 @@ export async function maybeWarnDivergence(opts = {}) {
215
215
 
216
216
  /**
217
217
  * Map an MCP tool name (+ args) to the (action, target) tuple used for
218
- * permission checks. Returns null for unrecognised tool names; callers
219
- * should treat null as "no policy applies, allow" (these are bundled-only).
218
+ * permission checks. Returns null for unrecognised tool names. Callers MUST
219
+ * treat null as fail-closed whenever an extension is active: every tool the
220
+ * server advertises has an explicit mapping here, so a null mapping means a
221
+ * future tool was added without a policy entry -- denying is the only answer
222
+ * that keeps the sandbox sound (see gatePermissionAndQuota in server.js).
220
223
  */
221
224
  export function toolNameToActionTarget(toolName, args) {
222
225
  switch (toolName) {
@@ -225,8 +228,23 @@ export function toolNameToActionTarget(toolName, args) {
225
228
  case 'ijfw_memory_recall':
226
229
  case 'ijfw_memory_search':
227
230
  case 'ijfw_memory_prelude':
231
+ case 'ijfw_memory_facts':
228
232
  case 'ijfw_cross_project_search':
229
233
  return { action: 'read', target: 'memory:read' };
234
+ case 'ijfw_brain': {
235
+ // Brain verbs can write to the facts DB (wiki rebuilds, fact upserts),
236
+ // so classify the whole facade as a write -- conservative by design.
237
+ const verb = (args && typeof args.verb === 'string' && args.verb) ? args.verb : '*';
238
+ return { action: 'write', target: `brain:${verb}` };
239
+ }
240
+ case 'ijfw_state': {
241
+ // state-sdk verbs mutate project orchestration state.
242
+ const verb = (args && typeof args.verb === 'string' && args.verb) ? args.verb : '*';
243
+ return { action: 'write', target: `state:${verb}` };
244
+ }
245
+ case 'ijfw_cross_audit_converge':
246
+ // autoFix:true mutates source -- always treat as a write.
247
+ return { action: 'write', target: 'audit:converge' };
230
248
  case 'ijfw_metrics':
231
249
  return { action: 'read', target: 'metrics:read' };
232
250
  case 'ijfw_update_check':
package/src/server.js CHANGED
@@ -119,7 +119,29 @@ export async function gatePermissionAndQuota({ toolName, args, activeExt, home,
119
119
  }
120
120
  const mapping = toolNameToActionTarget(toolName, args || {});
121
121
  if (!mapping) {
122
- return { allowed: true };
122
+ // Fail-closed: an extension is active (possibly MALFORMED) and this tool
123
+ // has no policy mapping. Allowing here would let any future tool silently
124
+ // bypass the sandbox -- and would also defeat the malformed-state deny,
125
+ // which lives inside checkPermission. Every advertised tool must have an
126
+ // explicit entry in toolNameToActionTarget (runtime-mediator.js).
127
+ const reason = `tool "${toolName}" not covered by extension policy`;
128
+ await logPermissionEvent({
129
+ tool: toolName,
130
+ extension: activeExt && activeExt.name ? activeExt.name : null,
131
+ action: null,
132
+ target: null,
133
+ allowed: false,
134
+ reason,
135
+ ts: new Date().toISOString(),
136
+ }).catch(() => {});
137
+ return {
138
+ allowed: false,
139
+ reason,
140
+ response: {
141
+ content: [{ type: 'text', text: `extension permission denied: ${reason}` }],
142
+ isError: true,
143
+ },
144
+ };
123
145
  }
124
146
  const permCheck = checkPermission(mapping.action, mapping.target, activeExt);
125
147
  if (!permCheck.allowed) {
@@ -389,12 +411,21 @@ const TEAM_DIR_NAME = 'team';
389
411
  const TEAM_FACETS = ['decisions', 'patterns', 'stack', 'members'];
390
412
 
391
413
  // Claude Code's native auto-memory lives at ~/.claude/projects/<encoded>/memory/
392
- // where <encoded> is the project path with `/` `-`. IJFW reads these files
393
- // and surfaces them via MCP so all platforms (not just Claude) see the same
394
- // memories -- no fighting Claude's native "Remember X" handler.
414
+ // where <encoded> is the project path with separators replaced by `-`. IJFW
415
+ // reads these files and surfaces them via MCP so all platforms (not just
416
+ // Claude) see the same memories -- no fighting Claude's native "Remember X"
417
+ // handler. On Windows the path is `C:\Users\...` -- strip the drive letter and
418
+ // replace both separator styles so the slug is a single flat dir segment
419
+ // (a bare `\/`-only replace left backslashes + the drive colon in the segment,
420
+ // producing a nonexistent nested path, so this source was silently empty on
421
+ // Windows). Mirrors pathToSlug() in src/memory/reader.js -- keep in sync.
422
+ // Exported for the Windows-encoding regression test.
423
+ export function encodeClaudeProjectSlug(projectPath) {
424
+ return String(projectPath).replace(/^[A-Za-z]:/, '').replace(/[\\/]/g, '-');
425
+ }
395
426
  const NATIVE_CLAUDE_DIR = join(
396
427
  homedir(), '.claude', 'projects',
397
- PROJECT_DIR.replace(/\//g, '-'),
428
+ encodeClaudeProjectSlug(PROJECT_DIR),
398
429
  'memory'
399
430
  );
400
431
 
@@ -736,7 +767,11 @@ function appendFactsToSidecar(facts, meta) {
736
767
  return { ok: true, written: facts.length };
737
768
  } catch (err) {
738
769
  // Non-fatal: facts are augmentation, not source-of-truth. Journal already
739
- // captured the raw memory.
770
+ // captured the raw memory. Still surface on stderr so operators see the
771
+ // degradation -- callers also fold the failure into the store result.
772
+ try {
773
+ process.stderr.write(`[ijfw facts] sidecar append failed (${err.code || err.message}); fact extraction degraded\n`);
774
+ } catch { /* stderr may be detached */ }
740
775
  return { ok: false, code: err.code || 'EUNKNOWN', message: err.message };
741
776
  }
742
777
  }
@@ -1556,26 +1591,30 @@ function handleStore({ content, type, tags = [], summary, why, how_to_apply }) {
1556
1591
  try { console.error('[ijfw memory] FTS5 index dispatch failed:', e?.message || e); } catch { /* never throw */ }
1557
1592
  }
1558
1593
 
1594
+ // Secondary writes (facts + type-specific). Each tracked so we report
1595
+ // partial success accurately rather than lying about "stored."
1596
+ const failures = [];
1597
+
1559
1598
  // H5.5 — Fact extraction AFTER successful append. Best-effort: a failure
1560
- // here is logged in the return text but does NOT poison the store result.
1561
- // Memory-id ties facts.jsonl rows back to their journal entry.
1599
+ // here is folded into the partial-failure return text (and stderr) but the
1600
+ // journal write above already succeeded. Memory-id ties facts.jsonl rows
1601
+ // back to their journal entry.
1562
1602
  const factMeta = {
1563
1603
  ts: new Date().toISOString(),
1564
1604
  memory_id: factMemoryIdFor(journalEntry),
1565
1605
  source: `memory_store:${type}`,
1566
1606
  };
1567
1607
  const facts = extractFacts(safeContent);
1568
- appendFactsToSidecar(facts, factMeta);
1608
+ const factsResult = appendFactsToSidecar(facts, factMeta);
1609
+ if (!factsResult.ok) failures.push(`facts sidecar (${factsResult.code})`);
1569
1610
  // v1.5.0 audit H5.4 — mirror to bi-temporal SQL store. For each fact,
1570
1611
  // closes any prior currently-valid fact with the same (subject, predicate)
1571
1612
  // but different object before inserting. Same-object stores are a no-op.
1572
1613
  // Wrapped in a per-fact transaction inside temporal.js. Best-effort: any
1573
- // failure is logged to stderr but never breaks the journal-or-JSONL path.
1574
- writeFactsBitemporal(facts, factMeta);
1575
-
1576
- // 2. Type-specific secondary writes. Each tracked so we report partial
1577
- // success accurately rather than lying about "stored."
1578
- const failures = [];
1614
+ // failure is logged to stderr and never breaks the journal-or-JSONL path,
1615
+ // but an unavailable facts DB is reported honestly in the store result.
1616
+ const bitemporalResult = writeFactsBitemporal(facts, factMeta);
1617
+ if (!bitemporalResult.ok) failures.push(`facts db (${bitemporalResult.code})`);
1579
1618
 
1580
1619
  if (type === 'decision' || type === 'pattern') {
1581
1620
  // Richer frontmatter block for retrieval-quality entries.
@@ -1986,8 +2025,9 @@ function handleCrossProjectSearch({ pattern, limit = 10 } = {}) {
1986
2025
  }
1987
2026
 
1988
2027
  // Phase 3 #6: aggregate session metrics. Reads .ijfw/metrics/sessions.jsonl,
1989
- // tolerates v1 lines (treats missing token/cost fields as 0), groups by day,
1990
- // renders compact text. Positive-framed zero-state when no sessions logged yet.
2028
+ // tolerates v1 lines (treats missing token/cost fields as 0), dedupes the
2029
+ // per-turn cumulative v5 rows to the latest row per session_id, groups by
2030
+ // day, renders compact text. Positive-framed zero-state when nothing logged.
1991
2031
  function handleMetrics({ period = '7d', metric = 'tokens' } = {}) {
1992
2032
  const file = join(IJFW_DIR, 'metrics', 'sessions.jsonl');
1993
2033
  const r = readMarkdownFile(file);
@@ -2024,19 +2064,45 @@ function handleMetrics({ period = '7d', metric = 'tokens' } = {}) {
2024
2064
  return { text: `Window ${period}: no sessions in range.${hint}` };
2025
2065
  }
2026
2066
 
2067
+ // Schema v5: the Stop hook fires after EVERY assistant turn and appends one
2068
+ // row per turn carrying the CUMULATIVE totals for the whole session so far,
2069
+ // tagged with session_id + a monotonic turn counter. Summing every row
2070
+ // therefore overcounts quadratically -- keep only the LATEST row per
2071
+ // session_id (highest turn, falling back to timestamp; ties keep the later
2072
+ // line). Old-format rows without a session_id are treated as one session
2073
+ // per row, exactly as before.
2074
+ const latestBySession = new Map();
2075
+ const sessions = [];
2076
+ for (const row of within) {
2077
+ const sid = row.session_id;
2078
+ if (typeof sid !== 'string' || sid.length === 0) {
2079
+ sessions.push(row);
2080
+ continue;
2081
+ }
2082
+ const prev = latestBySession.get(sid);
2083
+ if (!prev) { latestBySession.set(sid, row); continue; }
2084
+ const rowTurn = typeof row.turn === 'number' ? row.turn : null;
2085
+ const prevTurn = typeof prev.turn === 'number' ? prev.turn : null;
2086
+ const later = (rowTurn !== null && prevTurn !== null && rowTurn !== prevTurn)
2087
+ ? rowTurn > prevTurn
2088
+ : Date.parse(row.timestamp) >= Date.parse(prev.timestamp);
2089
+ if (later) latestBySession.set(sid, row);
2090
+ }
2091
+ for (const row of latestBySession.values()) sessions.push(row);
2092
+
2027
2093
  if (metric === 'sessions') {
2028
- const handoffs = within.filter(r => r.handoff).length;
2029
- const memEntries = within.reduce((s, r) => s + (r.memory_stores || 0), 0);
2094
+ const handoffs = sessions.filter(r => r.handoff).length;
2095
+ const memEntries = sessions.reduce((s, r) => s + (r.memory_stores || 0), 0);
2030
2096
  return { text: [
2031
- `Sessions in ${period}: ${within.length}`,
2032
- `Handoffs preserved: ${handoffs} (${Math.round(100 * handoffs / within.length)}%)`,
2097
+ `Sessions in ${period}: ${sessions.length}`,
2098
+ `Handoffs preserved: ${handoffs} (${Math.round(100 * handoffs / sessions.length)}%)`,
2033
2099
  `Memory entries logged: ${memEntries}`
2034
2100
  ].join('\n') };
2035
2101
  }
2036
2102
 
2037
2103
  if (metric === 'routing') {
2038
2104
  const counts = {};
2039
- for (const r of within) counts[r.routing || 'native'] = (counts[r.routing || 'native'] || 0) + 1;
2105
+ for (const r of sessions) counts[r.routing || 'native'] = (counts[r.routing || 'native'] || 0) + 1;
2040
2106
  return { text: ['Routing mix:'].concat(
2041
2107
  Object.entries(counts).map(([k, v]) => ` ${k}: ${v}`)
2042
2108
  ).join('\n') };
@@ -2044,7 +2110,7 @@ function handleMetrics({ period = '7d', metric = 'tokens' } = {}) {
2044
2110
 
2045
2111
  // Group by UTC day for tokens / cost.
2046
2112
  const byDay = {};
2047
- for (const row of within) {
2113
+ for (const row of sessions) {
2048
2114
  const day = String(row.timestamp).slice(0, 10);
2049
2115
  byDay[day] = byDay[day] || { in: 0, out: 0, cr: 0, cc: 0, cost: 0, n: 0 };
2050
2116
  byDay[day].in += row.input_tokens || 0;
@@ -2060,7 +2126,7 @@ function handleMetrics({ period = '7d', metric = 'tokens' } = {}) {
2060
2126
  const total = days.reduce((s, d) => s + byDay[d].cost, 0);
2061
2127
  const lines = ['Day | sessions | cost (USD)'];
2062
2128
  for (const d of days) lines.push(`${d} | ${String(byDay[d].n).padStart(8)} | $${byDay[d].cost.toFixed(4)}`);
2063
- lines.push(`Total: $${total.toFixed(4)} across ${within.length} session(s) -- clean session-ends only.`);
2129
+ lines.push(`Total: $${total.toFixed(4)} across ${sessions.length} session(s) -- clean session-ends only.`);
2064
2130
  return { text: lines.join('\n') };
2065
2131
  }
2066
2132
 
@@ -2604,7 +2670,10 @@ function handleMessage(msg) {
2604
2670
  return createResponse(id, {});
2605
2671
 
2606
2672
  default:
2607
- if (id) return createError(id, -32601, `Method not found: ${method}`);
2673
+ // Presence check, not truthiness: id 0 and id "" are valid JSON-RPC
2674
+ // request ids (the MCP TS SDK numbers requests from 0) and MUST get a
2675
+ // response. Only notifications (id absent/null) go unanswered.
2676
+ if (id !== undefined && id !== null) return createError(id, -32601, `Method not found: ${method}`);
2608
2677
  return null;
2609
2678
  }
2610
2679
  }
@@ -2665,7 +2734,9 @@ function __attachStdioTransport() {
2665
2734
  response.then(r => { if (r) process.stdout.write(r + '\n'); }).catch(err => {
2666
2735
  process.stdout.write(JSON.stringify({
2667
2736
  jsonrpc: '2.0',
2668
- id: msg && msg.id ? msg.id : null,
2737
+ // Presence check: id 0 / "" must round-trip so the client can
2738
+ // correlate the error to its pending request.
2739
+ id: (msg && msg.id !== undefined) ? msg.id : null,
2669
2740
  error: { code: -32603, message: `Internal error: ${err.message}` }
2670
2741
  }) + '\n');
2671
2742
  });
@@ -2675,7 +2746,7 @@ function __attachStdioTransport() {
2675
2746
  } catch (err) {
2676
2747
  process.stdout.write(JSON.stringify({
2677
2748
  jsonrpc: '2.0',
2678
- id: msg && msg.id ? msg.id : null,
2749
+ id: (msg && msg.id !== undefined) ? msg.id : null,
2679
2750
  error: { code: -32603, message: `Internal error: ${err.message}` }
2680
2751
  }) + '\n');
2681
2752
  }
@@ -2787,6 +2858,7 @@ if (__isServerEntryPoint) {
2787
2858
  export {
2788
2859
  sanitizeContent, atomicWrite, readMarkdownFile, PROJECT_HASH,
2789
2860
  handleStore, handleRecall, handleSearch, handlePrelude,
2861
+ handleMetrics,
2790
2862
  MEMORY_DIR, FACTS_FILE, FACTS_DB_FILE,
2791
2863
  getFactsDb,
2792
2864
  paths,