@ijfw/memory-server 1.5.4 → 1.5.6

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 (37) hide show
  1. package/package.json +15 -1
  2. package/src/brain/dream-pipeline.js +77 -14
  3. package/src/brain/dump-ingest.js +32 -0
  4. package/src/brain/entity-collapse.js +2 -2
  5. package/src/brain/export.js +60 -6
  6. package/src/brain/extractors/markdown.js +28 -2
  7. package/src/brain/layout-sentinel.js +19 -14
  8. package/src/brain/path-guard.js +17 -0
  9. package/src/brain/wiki-compiler.js +35 -39
  10. package/src/codex-agents.js +25 -2
  11. package/src/cross-orchestrator-cli.js +176 -18
  12. package/src/dashboard-server.js +36 -3
  13. package/src/dispatch/override.js +18 -2
  14. package/src/dispatch/signer-cli.js +14 -9
  15. package/src/dream/stage-runner.js +17 -0
  16. package/src/dream/state-file.js +15 -1
  17. package/src/extension-installer.js +91 -2
  18. package/src/extension-registry.js +15 -4
  19. package/src/handlers/brain-handler.js +44 -5
  20. package/src/lib/atomic-io.js +69 -12
  21. package/src/lib/shasum-verify.js +46 -22
  22. package/src/lib/ui-review-runner.js +7 -2
  23. package/src/lib/uispec-drift.js +8 -3
  24. package/src/lib/uispec-intake.js +5 -2
  25. package/src/memory/layout-migrations/001-visible-layer.js +71 -7
  26. package/src/memory/reader.js +111 -58
  27. package/src/orchestrator/merge-block-aware.js +75 -37
  28. package/src/orchestrator/post-done-runner.js +6 -1
  29. package/src/orchestrator/state-sdk.js +242 -14
  30. package/src/orchestrator/wave-state.js +22 -69
  31. package/src/recovery/checkpoint.js +30 -6
  32. package/src/recovery/code-fixer.js +52 -7
  33. package/src/runtime-mediator.js +2 -2
  34. package/src/server.js +57 -8
  35. package/src/swarm/planner.js +46 -1
  36. package/src/update-apply.js +27 -35
  37. package/src/update-check.js +6 -2
@@ -53,23 +53,47 @@ function walkMd(dir, base, depth = 0) {
53
53
  return results;
54
54
  }
55
55
 
56
- /** Build a file entry from a path + tier label. */
56
+ /**
57
+ * Build a file entry from a path + tier label.
58
+ *
59
+ * V155-026 (v1.5.5): instead of swallowing failures as `null` (which the
60
+ * caller `.filter(Boolean)`-ed away), now returns a tagged shape:
61
+ * - `{ ok: true, entry }` when parsing succeeded
62
+ * - `{ ok: false, error: {...} }` when readFile / stat / parseFrontmatter
63
+ * threw
64
+ * Dashboard `/api/memory/list` + MCP `ijfw_memory_*` consumers can now warn
65
+ * the operator that N files were on disk but M failed to read — silent drop
66
+ * was previously masking corruption + permission issues.
67
+ */
57
68
  function buildEntry(full, rel, tier) {
58
- try {
59
- const st = statSync(full);
60
- const raw = readFileSync(full, 'utf8');
61
- const fm = parseFrontmatter(raw);
69
+ let st;
70
+ try { st = statSync(full); }
71
+ catch (e) {
72
+ return { ok: false, error: { path: full, relpath: rel, tier, reason: 'stat-fail', message: e?.message || String(e) } };
73
+ }
74
+ let raw;
75
+ try { raw = readFileSync(full, 'utf8'); }
76
+ catch (e) {
77
+ return { ok: false, error: { path: full, relpath: rel, tier, reason: 'unreadable', message: e?.message || String(e) } };
78
+ }
79
+ let fm;
80
+ try { fm = parseFrontmatter(raw); }
81
+ catch (e) {
82
+ return { ok: false, error: { path: full, relpath: rel, tier, reason: 'parse-fail', message: e?.message || String(e) } };
83
+ }
62
84
 
63
- let title = fm.title;
64
- if (!title) {
65
- const hm = raw.match(/^#\s+(.+)/m);
66
- title = hm ? hm[1].trim() : basename(full, '.md');
67
- }
85
+ let title = fm.title;
86
+ if (!title) {
87
+ const hm = raw.match(/^#\s+(.+)/m);
88
+ title = hm ? hm[1].trim() : basename(full, '.md');
89
+ }
68
90
 
69
- const body = raw.replace(/^---[\s\S]*?---\r?\n/, '').trimStart();
70
- const preview = body.slice(0, PREVIEW_CHARS).replace(/\s+/g, ' ').trim();
91
+ const body = raw.replace(/^---[\s\S]*?---\r?\n/, '').trimStart();
92
+ const preview = body.slice(0, PREVIEW_CHARS).replace(/\s+/g, ' ').trim();
71
93
 
72
- return {
94
+ return {
95
+ ok: true,
96
+ entry: {
73
97
  path: full,
74
98
  relpath: rel,
75
99
  title,
@@ -79,10 +103,24 @@ function buildEntry(full, rel, tier) {
79
103
  last_modified: st.mtimeMs,
80
104
  size: st.size,
81
105
  tier,
82
- };
83
- } catch {
84
- return null;
106
+ },
107
+ };
108
+ }
109
+
110
+ /**
111
+ * Helper: partition the `walkMd` output into `{entries[], errors[]}` using the
112
+ * new tagged `buildEntry` shape. Pulled into one place so each Tier reader
113
+ * stays terse.
114
+ */
115
+ function partitionEntries(items, tier) {
116
+ const entries = [];
117
+ const errors = [];
118
+ for (const { full, rel } of items) {
119
+ const r = buildEntry(full, rel, tier);
120
+ if (r && r.ok) entries.push(r.entry);
121
+ else if (r && r.ok === false) errors.push(r.error);
85
122
  }
123
+ return { entries, errors };
86
124
  }
87
125
 
88
126
  /** Map a project dir path to its Claude project slug. */
@@ -117,15 +155,14 @@ function findClaudeSlug(repoRoot) {
117
155
 
118
156
  /**
119
157
  * Tier 1: Claude auto-memory files for the current project.
158
+ * Returns `{entries, errors}` so the caller can surface unreadable files.
120
159
  */
121
160
  function readTier1(repoRoot) {
122
161
  const slug = findClaudeSlug(repoRoot);
123
- if (!slug) return [];
162
+ if (!slug) return { entries: [], errors: [] };
124
163
  const memDir = join(CLAUDE_PROJS, slug, 'memory');
125
- if (!existsSync(memDir)) return [];
126
- return walkMd(memDir, memDir)
127
- .map(({ full, rel }) => buildEntry(full, rel, 'Auto-memory'))
128
- .filter(Boolean);
164
+ if (!existsSync(memDir)) return { entries: [], errors: [] };
165
+ return partitionEntries(walkMd(memDir, memDir), 'Auto-memory');
129
166
  }
130
167
 
131
168
  /**
@@ -139,29 +176,29 @@ function readTier2(repoRoot) {
139
176
  if (existsSync(globalMem)) dirs.push(globalMem);
140
177
 
141
178
  const files = [];
179
+ const errors = [];
142
180
  for (const dir of dirs) {
143
181
  if (!existsSync(dir)) continue;
144
- const entries = walkMd(dir, dir)
145
- .map(({ full, rel }) => buildEntry(full, rel, 'Project'))
146
- .filter(Boolean);
182
+ const { entries, errors: errs } = partitionEntries(walkMd(dir, dir), 'Project');
147
183
  // Deduplicate by path
148
184
  for (const e of entries) {
149
- if (!files.find(f => f.path === e.path)) files.push(e);
185
+ if (!files.find((f) => f.path === e.path)) files.push(e);
186
+ }
187
+ for (const er of errs) {
188
+ if (!errors.find((x) => x.path === er.path)) errors.push(er);
150
189
  }
151
190
  }
152
- return files;
191
+ return { entries: files, errors };
153
192
  }
154
193
 
155
194
  /**
156
195
  * Tier 3: Session records -- .md files in <repoRoot>/.ijfw/sessions/.
157
196
  */
158
197
  function readTier3(repoRoot) {
159
- if (!repoRoot) return [];
198
+ if (!repoRoot) return { entries: [], errors: [] };
160
199
  const sessDir = join(repoRoot, '.ijfw', 'sessions');
161
- if (!existsSync(sessDir)) return [];
162
- return walkMd(sessDir, sessDir)
163
- .map(({ full, rel }) => buildEntry(full, rel, 'Sessions'))
164
- .filter(Boolean);
200
+ if (!existsSync(sessDir)) return { entries: [], errors: [] };
201
+ return partitionEntries(walkMd(sessDir, sessDir), 'Sessions');
165
202
  }
166
203
 
167
204
  /**
@@ -170,11 +207,11 @@ function readTier3(repoRoot) {
170
207
  */
171
208
  function readTier4() {
172
209
  const obsPath = join(IJFW_DIR, 'observations.jsonl');
173
- if (!existsSync(obsPath)) return [];
210
+ if (!existsSync(obsPath)) return { entries: [], errors: [] };
174
211
  try {
175
212
  const lines = readFileSync(obsPath, 'utf8').split('\n').filter(Boolean);
176
213
  const total = lines.length;
177
- if (!total) return [];
214
+ if (!total) return { entries: [], errors: [] };
178
215
  const st = statSync(obsPath);
179
216
  // Count by platform for recall counts
180
217
  const platformCounts = {};
@@ -188,20 +225,26 @@ function readTier4() {
188
225
  const platformSummary = Object.entries(platformCounts)
189
226
  .map(([p, c]) => `${p}: ${c}`)
190
227
  .join(', ');
191
- return [{
192
- path: obsPath,
193
- relpath: 'observations.jsonl',
194
- title: `Global observations (${total} events)`,
195
- description: platformSummary || null,
196
- type: 'observations',
197
- preview: `${total} observation events across ${Object.keys(platformCounts).length} platforms. ${platformSummary}`,
198
- last_modified: st.mtimeMs,
199
- size: st.size,
200
- tier: 'Global',
201
- count: total,
202
- }];
203
- } catch {
204
- return [];
228
+ return {
229
+ entries: [{
230
+ path: obsPath,
231
+ relpath: 'observations.jsonl',
232
+ title: `Global observations (${total} events)`,
233
+ description: platformSummary || null,
234
+ type: 'observations',
235
+ preview: `${total} observation events across ${Object.keys(platformCounts).length} platforms. ${platformSummary}`,
236
+ last_modified: st.mtimeMs,
237
+ size: st.size,
238
+ tier: 'Global',
239
+ count: total,
240
+ }],
241
+ errors: [],
242
+ };
243
+ } catch (e) {
244
+ return {
245
+ entries: [],
246
+ errors: [{ path: obsPath, relpath: 'observations.jsonl', tier: 'Global', reason: 'unreadable', message: e?.message || String(e) }],
247
+ };
205
248
  }
206
249
  }
207
250
 
@@ -210,16 +253,23 @@ function readTier4() {
210
253
  */
211
254
  function readTier5() {
212
255
  const handoffPath = join(IJFW_DIR, 'HANDOFF.md');
213
- if (!existsSync(handoffPath)) return [];
214
- const entry = buildEntry(handoffPath, 'HANDOFF.md', 'Handoff');
215
- return entry ? [entry] : [];
256
+ if (!existsSync(handoffPath)) return { entries: [], errors: [] };
257
+ const r = buildEntry(handoffPath, 'HANDOFF.md', 'Handoff');
258
+ if (r && r.ok) return { entries: [r.entry], errors: [] };
259
+ if (r && r.ok === false) return { entries: [], errors: [r.error] };
260
+ return { entries: [], errors: [] };
216
261
  }
217
262
 
218
263
  /**
219
264
  * List all memory files across all 5 tiers.
265
+ *
266
+ * V155-026 (v1.5.5): adds `errors` field to the return so dashboard +
267
+ * MCP consumers can surface unreadable / corrupt-frontmatter files
268
+ * instead of silently dropping them via `.filter(Boolean)`.
269
+ *
220
270
  * @param {string|null} repoRoot
221
271
  * @param {string|null} tierFilter - filter to one tier label (optional)
222
- * @returns {{ files: Array, total: number, root: string|null, tiers: Object }}
272
+ * @returns {{ files: Array, errors: Array, total: number, root: string|null, tiers: Object }}
223
273
  */
224
274
  export function listMemoryFiles(repoRoot, tierFilter = null) {
225
275
  const t1 = readTier1(repoRoot);
@@ -228,20 +278,23 @@ export function listMemoryFiles(repoRoot, tierFilter = null) {
228
278
  const t4 = readTier4();
229
279
  const t5 = readTier5();
230
280
 
231
- const all = [...t1, ...t2, ...t3, ...t4, ...t5];
281
+ const all = [...t1.entries, ...t2.entries, ...t3.entries, ...t4.entries, ...t5.entries];
282
+ const allErrors = [...t1.errors, ...t2.errors, ...t3.errors, ...t4.errors, ...t5.errors];
232
283
 
233
284
  // Compute per-tier counts before filtering
234
285
  const tiers = {
235
- 'Auto-memory': t1.length,
236
- 'Project': t2.length,
237
- 'Sessions': t3.length,
238
- 'Global': t4.length,
239
- 'Handoff': t5.length,
286
+ 'Auto-memory': t1.entries.length,
287
+ 'Project': t2.entries.length,
288
+ 'Sessions': t3.entries.length,
289
+ 'Global': t4.entries.length,
290
+ 'Handoff': t5.entries.length,
240
291
  };
241
292
 
242
293
  let files = all;
294
+ let errors = allErrors;
243
295
  if (tierFilter) {
244
296
  files = all.filter(f => f.tier === tierFilter);
297
+ errors = allErrors.filter(e => e.tier === tierFilter);
245
298
  }
246
299
 
247
300
  // Sort by most recently modified within each tier grouping
@@ -250,7 +303,7 @@ export function listMemoryFiles(repoRoot, tierFilter = null) {
250
303
  // Use the first non-null path as the security root for /api/memory/file
251
304
  const root = repoRoot || IJFW_DIR;
252
305
 
253
- return { files, total: files.length, root, tiers };
306
+ return { files, errors, total: files.length, root, tiers };
254
307
  }
255
308
 
256
309
  /** List all known projects by scanning ~/.claude/projects/. */
@@ -90,6 +90,8 @@ const DEFAULT_TEMPLATE = pathResolve(
90
90
  * - 'ERR_BAD_BLOCK' — block name not in RESERVED_BLOCKS (sh: exit 2)
91
91
  * - 'ERR_TEMPLATE_MISSING' — target absent + no template available (sh: 3)
92
92
  * - 'ERR_BAD_PAYLOAD' — pairs argument malformed (sh: 2)
93
+ * - 'ERR_BACKUP_REQUIRED' — existing target's backup failed; refusing to
94
+ * overwrite without rollback (V155-010)
93
95
  */
94
96
  export class MergeBlockAwareError extends Error {
95
97
  constructor(code, message) {
@@ -203,48 +205,64 @@ export function backupDirFor(targetAbsPath, opts = {}) {
203
205
  * Take a millisecond-timestamped backup of `targetAbsPath` into the canonical
204
206
  * backup directory, then prune older entries past `retain` (newest-first).
205
207
  *
206
- * Mirrors the shell script's `cp` + `node -e '…sort by mtime…'` block. Best-
207
- * effort: any IO failure is swallowed so a backup hiccup never blocks the
208
- * merge (the shell script does `2>/dev/null || true`).
208
+ * V155-010: distinguish 'absent' (no prior file to back up) from 'io-error'
209
+ * (prior file exists but copy/mkdir/stat failed). Caller (`mergeFile`) needs
210
+ * to refuse the merge when an existing target had its backup fail, so the
211
+ * write isn't silently destructive. Returned shapes:
212
+ * - { taken: false, reason: 'absent', pruned: 0 } — no prior file
213
+ * - { taken: false, reason: 'empty', pruned: 0 } — prior file is 0 bytes
214
+ * - { taken: false, reason: 'io-error', error, pruned: 0 } — copy/mkdir/stat threw
215
+ * - { taken: true, path, pruned } — backup succeeded
216
+ *
217
+ * Mirrors the shell script's `cp` + `node -e '…sort by mtime…'` block.
218
+ * Retention errors remain best-effort (the backup itself is what mergeFile
219
+ * cares about).
209
220
  *
210
221
  * @param {string} targetAbsPath absolute path of the target being merged
211
222
  * @param {{homeDir?: string, retain?: number, now?: () => number}} [opts]
212
- * @returns {{taken: boolean, path?: string, pruned: number}}
223
+ * @returns {{taken: boolean, path?: string, pruned: number, reason?: string, error?: string}}
213
224
  */
214
225
  export function rotateBackups(targetAbsPath, opts = {}) {
215
226
  const retain = typeof opts.retain === 'number' ? opts.retain : BACKUP_RETAIN;
216
227
  const now = typeof opts.now === 'function' ? opts.now : Date.now;
217
- try {
218
- if (!existsSync(targetAbsPath)) return { taken: false, pruned: 0 };
219
- const st = statSync(targetAbsPath);
220
- if (!st || !st.size) return { taken: false, pruned: 0 };
221
- const dir = backupDirFor(targetAbsPath, opts);
222
- try { mkdirSync(dir, { recursive: true }); } catch { /* best-effort */ }
223
- const backupName = `AGENTS.md.bak.${String(now())}`;
224
- const backupPath = join(dir, backupName);
225
- try { copyFileSync(targetAbsPath, backupPath); } catch { return { taken: false, pruned: 0 }; }
228
+ if (!existsSync(targetAbsPath)) return { taken: false, reason: 'absent', pruned: 0 };
229
+ let st;
230
+ try { st = statSync(targetAbsPath); }
231
+ catch (e) {
232
+ return { taken: false, reason: 'io-error', error: e?.message || String(e), pruned: 0 };
233
+ }
234
+ if (!st || !st.size) return { taken: false, reason: 'empty', pruned: 0 };
235
+ const dir = backupDirFor(targetAbsPath, opts);
236
+ try { mkdirSync(dir, { recursive: true }); }
237
+ catch (e) {
238
+ return { taken: false, reason: 'io-error', error: e?.message || String(e), pruned: 0 };
239
+ }
240
+ const backupName = `AGENTS.md.bak.${String(now())}`;
241
+ const backupPath = join(dir, backupName);
242
+ try { copyFileSync(targetAbsPath, backupPath); }
243
+ catch (e) {
244
+ return { taken: false, reason: 'io-error', error: e?.message || String(e), pruned: 0 };
245
+ }
226
246
 
227
- // Retention — keep newest `retain`, unlink the rest. mtime-sorted to
228
- // match the shell node payload's `sort((a,b) => b.mt - a.mt)`.
229
- let pruned = 0;
230
- try {
231
- const entries = readdirSync(dir)
232
- .filter((n) => n.startsWith('AGENTS.md.bak.'))
233
- .map((n) => {
234
- try { return { n, mt: statSync(join(dir, n)).mtimeMs }; }
235
- catch { return null; }
236
- })
237
- .filter(Boolean)
238
- .sort((a, b) => b.mt - a.mt);
239
- for (const e of entries.slice(retain)) {
240
- try { unlinkSync(join(dir, e.n)); pruned += 1; } catch { /* best-effort */ }
241
- }
242
- } catch { /* best-effort */ }
247
+ // Retention — keep newest `retain`, unlink the rest. mtime-sorted to
248
+ // match the shell node payload's `sort((a,b) => b.mt - a.mt)`. Retention
249
+ // errors stay best-effort because the backup itself already succeeded.
250
+ let pruned = 0;
251
+ try {
252
+ const entries = readdirSync(dir)
253
+ .filter((n) => n.startsWith('AGENTS.md.bak.'))
254
+ .map((n) => {
255
+ try { return { n, mt: statSync(join(dir, n)).mtimeMs }; }
256
+ catch { return null; }
257
+ })
258
+ .filter(Boolean)
259
+ .sort((a, b) => b.mt - a.mt);
260
+ for (const e of entries.slice(retain)) {
261
+ try { unlinkSync(join(dir, e.n)); pruned += 1; } catch { /* best-effort */ }
262
+ }
263
+ } catch { /* best-effort */ }
243
264
 
244
- return { taken: true, path: backupPath, pruned };
245
- } catch {
246
- return { taken: false, pruned: 0 };
247
- }
265
+ return { taken: true, path: backupPath, pruned };
248
266
  }
249
267
 
250
268
  // ---------------------------------------------------------------------------
@@ -322,12 +340,32 @@ export function mergeFile(targetAbsPath, pairs, opts = {}) {
322
340
  };
323
341
  }
324
342
 
325
- // Backup + retention (best-effort, defaults on). `opts.backups === false`
326
- // suppresses (used by some tests to keep tmp clean).
343
+ // Backup + retention. V155-010: when `opts.backups !== false` AND the target
344
+ // already exists on disk, a backup IO error MUST abort the merge — the only
345
+ // alternative is to overwrite real bytes with no rollback path. Callers that
346
+ // accept the risk (cleanup tooling, fresh installs) opt out via
347
+ // `opts.backups === false`. `reason === 'absent' | 'empty'` are non-errors:
348
+ // the target either didn't exist (seed path) or was a zero-byte placeholder.
327
349
  let backup;
328
350
  if (opts.backups !== false) {
329
- const rot = rotateBackups(abs, opts);
330
- if (rot.taken && rot.path) backup = rot.path;
351
+ // TR-004 (v1.5.5 Trident): `opts._rotateBackups` is an internal
352
+ // dependency-injection seam — test-only. The default (`rotateBackups`
353
+ // imported above) is what production callers always hit. Tests inject
354
+ // a stub returning `{taken:false, reason:'io-error', error:'simulated'}`
355
+ // to exercise the BLOCKER refusal path deterministically, since
356
+ // chmod-0o500 doesn't reliably provoke an io-error on root-as-CI
357
+ // runners (FS layer ignores POSIX mode for uid 0).
358
+ const rotator = (typeof opts._rotateBackups === 'function')
359
+ ? opts._rotateBackups : rotateBackups;
360
+ const rot = rotator(abs, opts);
361
+ if (rot.taken && rot.path) {
362
+ backup = rot.path;
363
+ } else if (rot.reason === 'io-error') {
364
+ throw new MergeBlockAwareError(
365
+ 'ERR_BACKUP_REQUIRED',
366
+ `mergeFile: refusing to write ${abs} — backup failed on existing target (${rot.error || 'unknown'}). Retry, or pass {backups:false} to override.`,
367
+ );
368
+ }
331
369
  }
332
370
 
333
371
  const res = writeAtomic(abs, next, { mode: 0o644, ensureDir: true });
@@ -54,6 +54,7 @@
54
54
  */
55
55
 
56
56
  import { existsSync } from 'node:fs';
57
+ import { isAbsolute, join } from 'node:path';
57
58
  import { execFileSync } from 'node:child_process';
58
59
  import { reviewTask } from './review.js';
59
60
  import { checkVerificationGate, recordViolation } from './verification-gate.js';
@@ -110,8 +111,12 @@ function extractClaimedCommits(reportText) {
110
111
  export function runSelfCheck(reportText, projectRoot) {
111
112
  const claimedPaths = extractClaimedPaths(reportText);
112
113
  const claimedCommits = extractClaimedCommits(reportText);
114
+ // V155-019: use isAbsolute() so Windows absolute paths (C:\Users\…) aren't
115
+ // misclassified as relative. Previously `p.startsWith('/')` returned false
116
+ // for Windows absolutes → they were joined to projectRoot → existsSync
117
+ // false → real subagent work was flagged as missing files.
113
118
  const filesPresent = claimedPaths.filter((p) =>
114
- existsSync(p.startsWith('/') ? p : `${projectRoot}/${p}`),
119
+ existsSync(isAbsolute(p) ? p : join(projectRoot, p)),
115
120
  );
116
121
  let commitsPresent = [];
117
122
  try {