@inetafrica/open-claudia 3.0.43 → 3.1.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 (44) hide show
  1. package/CHANGELOG.md +44 -0
  2. package/README.md +9 -5
  3. package/bin/tool.js +15 -5
  4. package/bot.js +78 -3
  5. package/core/boot-health.js +84 -0
  6. package/core/doctor-memory.js +213 -0
  7. package/core/doctor.js +5 -0
  8. package/core/dream.js +11 -9
  9. package/core/entities.js +47 -3
  10. package/core/handlers.js +118 -1
  11. package/core/keyring.js +24 -4
  12. package/core/memory-mutation-queue.js +42 -11
  13. package/core/pack-review.js +60 -4
  14. package/core/packs.js +47 -3
  15. package/core/providers/claude-events.js +36 -3
  16. package/core/providers/events.js +11 -0
  17. package/core/recall/discoverer.js +22 -6
  18. package/core/recall/graph.js +84 -52
  19. package/core/recall/tuning.js +1 -2
  20. package/core/router.js +18 -0
  21. package/core/run-context.js +10 -4
  22. package/core/runner.js +111 -2
  23. package/core/safe-mode.js +227 -0
  24. package/core/state.js +50 -2
  25. package/core/system-prompt.js +43 -3
  26. package/core/tool-graph.js +99 -71
  27. package/core/tools.js +3 -2
  28. package/core/turn-observer.js +23 -1
  29. package/package.json +3 -3
  30. package/test-extractive-injection.js +21 -0
  31. package/test-fixtures/fake-agent-cli.js +29 -1
  32. package/test-fixtures/sim-no-sqlite.js +14 -0
  33. package/test-memory-mutation-queue.js +33 -3
  34. package/test-provider-compaction.js +5 -0
  35. package/test-provider-core-prompt.js +3 -0
  36. package/test-provider-events.js +71 -0
  37. package/test-provider-language.js +61 -1
  38. package/test-provider-recall.js +33 -0
  39. package/test-provider-runner.js +50 -0
  40. package/test-recall-discoverer.js +8 -0
  41. package/test-recall-graph.js +66 -11
  42. package/test-tool-graph.js +67 -16
  43. package/test-tool-manifest.js +32 -0
  44. package/test-tools.js +31 -0
@@ -23,6 +23,14 @@
23
23
  // and traversed directionally. Each edge carries a Hebbian `weight` bumped on
24
24
  // co-use and decayed over time; unlike pack edges there is no structural floor,
25
25
  // so a chain that stops being used eventually decays away and is pruned.
26
+ //
27
+ // Decay is computed ON READ (same doctrine as core/recall/graph.js): the stored
28
+ // `weight` is the value AS OF `last_reinforced`; reads map it through
29
+ // effectiveWeight() and reinforcement settles accrued decay before bumping.
30
+ // Nothing rewrites weights nightly — tend() only physically deletes edges whose
31
+ // effective weight has faded under `pruneBelow`. The old nightly decay()
32
+ // rewrite compounded (full age reapplied to an already-decayed weight each
33
+ // night), emptying chains in days; that path no longer exists.
26
34
 
27
35
  const fs = require("fs");
28
36
  const path = require("path");
@@ -37,8 +45,8 @@ const GRAPH_DB = process.env.TOOL_GRAPH_DB
37
45
 
38
46
  // Tunables (overridable via dream knobs later).
39
47
  const DEFAULTS = {
40
- halfLifeDays: 45, // Hebbian weight half-life for decay()
41
- pruneBelow: 0.15, // edges decayed under this are dropped (no structural floor)
48
+ halfLifeDays: 45, // Hebbian weight half-life for read-time decay
49
+ pruneBelow: 0.15, // edges whose effective weight falls under this are dead (no structural floor)
42
50
  };
43
51
 
44
52
  let _db = null;
@@ -82,6 +90,18 @@ function openDb() {
82
90
  )`);
83
91
  db.exec("CREATE INDEX IF NOT EXISTS pack_tool_edges_pack ON pack_tool_edges (pack)");
84
92
  db.exec("CREATE INDEX IF NOT EXISTS pack_tool_edges_tool ON pack_tool_edges (tool)");
93
+ // v1 migration: legacy DBs carry weights the old nightly decay() rewrite
94
+ // had already over-decayed (compounding). Treat stored values as current
95
+ // and restart the clock — decay is read-time from here on.
96
+ try {
97
+ const uv = db.prepare("PRAGMA user_version").get() || {};
98
+ if (Number(uv.user_version || 0) < 1) {
99
+ const stamp = now();
100
+ db.prepare("UPDATE tool_edges SET last_reinforced=? WHERE last_reinforced IS NOT NULL").run(stamp);
101
+ db.prepare("UPDATE pack_tool_edges SET last_reinforced=? WHERE last_reinforced IS NOT NULL").run(stamp);
102
+ db.exec("PRAGMA user_version = 1");
103
+ }
104
+ } catch (e) {}
85
105
  _db = db;
86
106
  return db;
87
107
  } catch (e) {
@@ -91,23 +111,49 @@ function openDb() {
91
111
 
92
112
  function now() { return new Date().toISOString(); }
93
113
 
94
- // Directed upsert of a follows-edge src→dst. `bump` adds to the current weight
95
- // (Hebbian) and stamps last_reinforced; with no bump it just ensures the edge
96
- // exists at `weight` (default 1).
114
+ // Read-time weight of an edge row: exponential decay of the stored weight since
115
+ // last_reinforced. Pure — storage is never touched. No structural floor here;
116
+ // an edge that was never reinforced (last_reinforced null) does not decay.
117
+ function effectiveWeight(e, nowMs = Date.now(), halfLifeDays = DEFAULTS.halfLifeDays) {
118
+ if (!e || !Number.isFinite(e.weight)) return 0;
119
+ if (!e.last_reinforced) return e.weight;
120
+ const ageDays = (nowMs - Date.parse(e.last_reinforced)) / 86400000;
121
+ if (!(ageDays > 0)) return e.weight;
122
+ return e.weight * Math.exp(-Math.log(2) * ageDays / Math.max(1, halfLifeDays));
123
+ }
124
+
125
+ // Rank rows by read-time weight, dropping ones already faded under pruneBelow
126
+ // (dead advice — physically deleted on the next tend()). Weights are rounded
127
+ // to 4dp so a seconds-old reinforcement still reads as its whole value.
128
+ function rankLive(rows, limit) {
129
+ const nowMs = Date.now();
130
+ return rows
131
+ .map((r) => ({ ...r, weight: Math.round(effectiveWeight(r, nowMs) * 1e4) / 1e4 }))
132
+ .filter((r) => r.weight >= DEFAULTS.pruneBelow)
133
+ .sort((a, b) => b.weight - a.weight
134
+ || String(b.last_reinforced || "").localeCompare(String(a.last_reinforced || "")))
135
+ .slice(0, Math.max(0, limit));
136
+ }
137
+
138
+ // Directed upsert of a follows-edge src→dst. `bump` settles the decay accrued
139
+ // since last_reinforced, adds to the effective weight (Hebbian) and restamps;
140
+ // with no bump it just ensures the edge exists at `weight` (default 1).
97
141
  function addFollow(src, dst, opts = {}) {
98
142
  const db = openDb();
99
143
  if (!db || !src || !dst || src === dst) return false;
100
144
  const bump = Number.isFinite(opts.bump) ? opts.bump : 0;
101
145
  const weight = Number.isFinite(opts.weight) ? opts.weight : 1;
102
146
  try {
103
- const existing = db.prepare("SELECT weight FROM tool_edges WHERE src=? AND dst=?").get(src, dst);
147
+ const existing = db.prepare("SELECT weight, last_reinforced FROM tool_edges WHERE src=? AND dst=?").get(src, dst);
104
148
  if (existing) {
105
- const next = bump ? existing.weight + bump : Math.max(existing.weight, weight);
106
149
  if (bump) {
107
150
  db.prepare("UPDATE tool_edges SET weight=?, last_reinforced=? WHERE src=? AND dst=?")
108
- .run(next, now(), src, dst);
151
+ .run(effectiveWeight(existing) + bump, now(), src, dst);
109
152
  } else {
110
- db.prepare("UPDATE tool_edges SET weight=? WHERE src=? AND dst=?").run(next, src, dst);
153
+ const next = Math.max(existing.weight, weight);
154
+ if (next !== existing.weight) {
155
+ db.prepare("UPDATE tool_edges SET weight=? WHERE src=? AND dst=?").run(next, src, dst);
156
+ }
111
157
  }
112
158
  } else {
113
159
  // No structural floor for tools: a brand-new edge is worth exactly its
@@ -148,14 +194,14 @@ function allEdges() {
148
194
  catch (e) { return []; }
149
195
  }
150
196
 
151
- // Tools that tend to follow `name`, strongest first. This is the surfaced
152
- // signal: "you ran X — you usually run these next".
197
+ // Tools that tend to follow `name`, strongest first (read-time weights). This
198
+ // is the surfaced signal: "you ran X — you usually run these next".
153
199
  function followers(name, limit = 5) {
154
200
  const db = openDb();
155
201
  if (!db || !name) return [];
156
202
  try {
157
- return db.prepare("SELECT dst AS name, weight FROM tool_edges WHERE src=? ORDER BY weight DESC LIMIT ?")
158
- .all(name, limit);
203
+ const rows = db.prepare("SELECT dst AS name, weight, last_reinforced FROM tool_edges WHERE src=?").all(name);
204
+ return rankLive(rows, limit).map((r) => ({ name: r.name, weight: r.weight }));
159
205
  } catch (e) { return []; }
160
206
  }
161
207
 
@@ -164,8 +210,8 @@ function predecessors(name, limit = 5) {
164
210
  const db = openDb();
165
211
  if (!db || !name) return [];
166
212
  try {
167
- return db.prepare("SELECT src AS name, weight FROM tool_edges WHERE dst=? ORDER BY weight DESC LIMIT ?")
168
- .all(name, limit);
213
+ const rows = db.prepare("SELECT src AS name, weight, last_reinforced FROM tool_edges WHERE dst=?").all(name);
214
+ return rankLive(rows, limit).map((r) => ({ name: r.name, weight: r.weight }));
169
215
  } catch (e) { return []; }
170
216
  }
171
217
 
@@ -206,7 +252,13 @@ function parseToolRuns(command) {
206
252
  const out = [];
207
253
  const cmd = String(command || "");
208
254
  if (!/tool\s+(?:run|exec)\s+/i.test(cmd)) return out;
209
- for (const seg of cmd.split(/&&|\|\||[;\n]/)) {
255
+ // B17: split on ALL shell composition operators — &&, ||, |, &, ;, newline.
256
+ // The single pipe was missed, so `tool run a … | tool run b …` parsed as one
257
+ // segment and only the first tool earned a follows-edge. Separators inside
258
+ // quoted spans are neutralized first so a pipe in an argument doesn't split
259
+ // (quoted content never reaches the shape anyway — shapeCommand masks it).
260
+ const masked = cmd.replace(/(["'])(?:\\.|(?!\1).)*\1/g, (q) => q.replace(/[;\n|&]/g, " "));
261
+ for (const seg of masked.split(/[;\n|&]+/)) {
210
262
  const m = seg.match(/\btool\s+(?:run|exec)\s+["']?([a-z0-9][\w.-]*)["']?\s*(.*)$/i);
211
263
  if (!m) continue;
212
264
  out.push({ name: m[1], shape: shapeCommand(`${m[1]} ${m[2] || ""}`) });
@@ -227,7 +279,8 @@ function recordPackTool(pack, tool, opts = {}) {
227
279
  try {
228
280
  const row = db.prepare("SELECT weight, intent, last_reinforced FROM pack_tool_edges WHERE pack=? AND tool=? AND command=?").get(pack, tool, command);
229
281
  if (row) {
230
- const next = bump ? row.weight + bump : Math.max(row.weight, weight);
282
+ // Bump settles accrued decay first (same invariant as addFollow).
283
+ const next = bump ? effectiveWeight(row) + bump : Math.max(row.weight, weight);
231
284
  const intent = (opts.intent != null && opts.intent !== "") ? String(opts.intent).slice(0, 200) : row.intent;
232
285
  db.prepare("UPDATE pack_tool_edges SET weight=?, intent=?, last_reinforced=? WHERE pack=? AND tool=? AND command=?")
233
286
  .run(next, intent, bump ? now() : row.last_reinforced, pack, tool, command);
@@ -243,15 +296,16 @@ function recordPackTool(pack, tool, opts = {}) {
243
296
  }
244
297
 
245
298
  // Strongest contextual tool usages recorded under a pack, for surfacing in that
246
- // pack's injected context. Ordered by reinforced weight, newest first as a
299
+ // pack's injected context. Ordered by read-time weight, newest first as a
247
300
  // tiebreak. One row per distinct command shape.
248
301
  function packTools(pack, limit = 2) {
249
302
  const db = openDb();
250
303
  if (!db || !pack) return [];
251
304
  try {
252
- return db.prepare(
253
- "SELECT tool, command, intent, weight FROM pack_tool_edges WHERE pack=? ORDER BY weight DESC, last_reinforced DESC LIMIT ?"
254
- ).all(pack, limit);
305
+ const rows = db.prepare(
306
+ "SELECT tool, command, intent, weight, last_reinforced FROM pack_tool_edges WHERE pack=?"
307
+ ).all(pack);
308
+ return rankLive(rows, limit).map((r) => ({ tool: r.tool, command: r.command, intent: r.intent, weight: r.weight }));
255
309
  } catch (e) { return []; }
256
310
  }
257
311
 
@@ -260,9 +314,10 @@ function toolPacks(tool, limit = 5) {
260
314
  const db = openDb();
261
315
  if (!db || !tool) return [];
262
316
  try {
263
- return db.prepare(
264
- "SELECT pack, command, intent, weight FROM pack_tool_edges WHERE tool=? ORDER BY weight DESC, last_reinforced DESC LIMIT ?"
265
- ).all(tool, limit);
317
+ const rows = db.prepare(
318
+ "SELECT pack, command, intent, weight, last_reinforced FROM pack_tool_edges WHERE tool=?"
319
+ ).all(tool);
320
+ return rankLive(rows, limit).map((r) => ({ pack: r.pack, command: r.command, intent: r.intent, weight: r.weight }));
266
321
  } catch (e) { return []; }
267
322
  }
268
323
 
@@ -280,53 +335,25 @@ function allPackToolEdges() {
280
335
  catch (e) { return []; }
281
336
  }
282
337
 
283
- // Exponential time decay. Tools have no structural floor, so edges decay toward
284
- // zero and are pruned once under `pruneBelow` — a chain that fell out of use
285
- // disappears instead of lingering as stale advice. Applies to both the tool→tool
286
- // follows-edges and the pack↔tool contextual edges (same physics).
287
- function decay({ halfLifeDays = DEFAULTS.halfLifeDays, pruneBelow = DEFAULTS.pruneBelow } = {}) {
338
+ // Physically delete edges whose EFFECTIVE weight has faded under `pruneBelow` —
339
+ // a chain that fell out of use disappears instead of lingering as stale advice.
340
+ // Weights themselves are never rewritten (decay-on-read); this is pure garbage
341
+ // collection. Applies to both the tool→tool follows-edges and the pack↔tool
342
+ // contextual edges (same physics). Never-reinforced ensure-edges don't fade.
343
+ function prune({ halfLifeDays = DEFAULTS.halfLifeDays, pruneBelow = DEFAULTS.pruneBelow } = {}) {
288
344
  const db = openDb();
289
- if (!db) return { decayed: 0, pruned: 0 };
345
+ if (!db) return { pruned: 0 };
290
346
  const nowMs = Date.now();
291
- const ln2 = Math.log(2);
292
- let decayed = 0, pruned = 0;
293
- const decayFactor = (lastReinforced) => {
294
- if (!lastReinforced) return null;
295
- const ageDays = (nowMs - Date.parse(lastReinforced)) / 86400000;
296
- if (!(ageDays > 0)) return null;
297
- return Math.exp(-ln2 * ageDays / Math.max(1, halfLifeDays));
298
- };
347
+ let pruned = 0;
299
348
  for (const e of allEdges()) {
300
- const factor = decayFactor(e.last_reinforced);
301
- if (factor === null) continue;
302
- const next = e.weight * factor;
303
- if (next < pruneBelow) {
304
- if (removeEdge(e.src, e.dst)) pruned++;
305
- continue;
306
- }
307
- if (Math.abs(next - e.weight) > 1e-6) {
308
- try {
309
- db.prepare("UPDATE tool_edges SET weight=? WHERE src=? AND dst=?").run(next, e.src, e.dst);
310
- decayed++;
311
- } catch (e2) {}
312
- }
349
+ if (!e.last_reinforced) continue;
350
+ if (effectiveWeight(e, nowMs, halfLifeDays) < pruneBelow && removeEdge(e.src, e.dst)) pruned++;
313
351
  }
314
352
  for (const e of allPackToolEdges()) {
315
- const factor = decayFactor(e.last_reinforced);
316
- if (factor === null) continue;
317
- const next = e.weight * factor;
318
- if (next < pruneBelow) {
319
- if (removePackTool(e.pack, e.tool, e.command)) pruned++;
320
- continue;
321
- }
322
- if (Math.abs(next - e.weight) > 1e-6) {
323
- try {
324
- db.prepare("UPDATE pack_tool_edges SET weight=? WHERE pack=? AND tool=? AND command=?").run(next, e.pack, e.tool, e.command);
325
- decayed++;
326
- } catch (e2) {}
327
- }
353
+ if (!e.last_reinforced) continue;
354
+ if (effectiveWeight(e, nowMs, halfLifeDays) < pruneBelow && removePackTool(e.pack, e.tool, e.command)) pruned++;
328
355
  }
329
- return { decayed, pruned };
356
+ return { pruned };
330
357
  }
331
358
 
332
359
  // Drop edges whose endpoints are no longer registered tools (a tool was removed
@@ -361,13 +388,14 @@ function pruneOrphans(toolsLib, packsLib) {
361
388
  return removed;
362
389
  }
363
390
 
364
- // Nightly maintenance (called by the dream): decay reinforced weights and drop
365
- // orphaned/faded edges. Deterministic + safe — no model needed. Pass
366
- // { packsLib } to also prune pack↔tool edges whose pack was removed.
391
+ // Nightly maintenance (called by the dream): drop faded and orphaned edges.
392
+ // No weight pass — decay is applied on read. Deterministic + safe — no model
393
+ // needed. Pass { packsLib } to also prune pack↔tool edges whose pack was
394
+ // removed.
367
395
  function tend(toolsLib, opts = {}) {
368
- const { decayed, pruned } = decay(opts);
396
+ const { pruned } = prune(opts);
369
397
  const orphaned = toolsLib ? pruneOrphans(toolsLib, opts.packsLib) : 0;
370
- return { decayed, pruned: pruned + orphaned, ...stats() };
398
+ return { pruned: pruned + orphaned, ...stats() };
371
399
  }
372
400
 
373
401
  function stats() {
@@ -384,7 +412,7 @@ module.exports = {
384
412
  DEFAULTS, GRAPH_DB,
385
413
  available, openDb,
386
414
  addFollow, reinforceSequence, removeEdge, allEdges,
387
- followers, predecessors, decay, pruneOrphans, tend, stats,
415
+ followers, predecessors, effectiveWeight, prune, pruneOrphans, tend, stats,
388
416
  shapeCommand, parseToolRuns, recordPackTool, packTools, toolPacks,
389
417
  removePackTool, allPackToolEdges,
390
418
  _resetForTest,
package/core/tools.js CHANGED
@@ -30,6 +30,7 @@ const fs = require("fs");
30
30
  const path = require("path");
31
31
  const crypto = require("crypto");
32
32
  const CONFIG_DIR = require("../config-dir");
33
+ const { atomicWriteFileSync } = require("./fsutil");
33
34
 
34
35
  const TOOLS_DIR = process.env.TOOLS_DIR ? path.resolve(process.env.TOOLS_DIR) : path.join(CONFIG_DIR, "tools");
35
36
 
@@ -90,7 +91,7 @@ function readUsage() {
90
91
  }
91
92
 
92
93
  function writeUsage(u) {
93
- try { ensureDir(); fs.writeFileSync(USAGE_FILE, JSON.stringify(u), { mode: 0o600 }); }
94
+ try { ensureDir(); atomicWriteFileSync(USAGE_FILE, JSON.stringify(u), { mode: 0o600 }); }
94
95
  catch (e) { /* best-effort */ }
95
96
  }
96
97
 
@@ -106,7 +107,7 @@ function writeState(name, s) {
106
107
  const file = p.state || path.join(TOOLS_DIR, sanitizeName(name), "state.json");
107
108
  try {
108
109
  fs.mkdirSync(path.dirname(file), { recursive: true, mode: 0o700 });
109
- fs.writeFileSync(file, JSON.stringify(s, null, 1), { mode: 0o600 });
110
+ atomicWriteFileSync(file, JSON.stringify(s, null, 1), { mode: 0o600 });
110
111
  } catch (e) { /* best-effort */ }
111
112
  }
112
113
 
@@ -13,6 +13,9 @@
13
13
  const { chatContext, currentAdapter } = require("./context");
14
14
  const { send } = require("./io");
15
15
 
16
+ // Consecutive recall failures on one channel before the owner is alerted (B5).
17
+ const RECALL_FAILURE_ALERT_AFTER = 3;
18
+
16
19
  function esc(s) {
17
20
  return String(s).replace(/&/g, "&amp;").replace(/</g, "&lt;").replace(/>/g, "&gt;");
18
21
  }
@@ -71,8 +74,27 @@ function createTurnObserver({ state, store }) {
71
74
  // 🧠 /recall + 🔧 /tooltrace banners, fired once the prompt bundle (and its
72
75
  // recall metadata) exists — before the provider spawns, like v2.15.
73
76
  const onRecallSurfaced = (metadata) => {
74
- if (guarded) return;
75
77
  const r = metadata;
78
+ // B5: recall fails OPEN (the turn proceeds without memory), so a persistent
79
+ // breakage is invisible unless someone counts. Track consecutive failures
80
+ // on channel state and tell the owner once per streak — a one-off transient
81
+ // stays quiet, an outage does not.
82
+ if (r && r.reason === "optional_recall_unavailable") {
83
+ state.recallFailureStreak = (state.recallFailureStreak || 0) + 1;
84
+ if (settings.showRecall) {
85
+ notify("banner:recall", "🧠 Recall FAILED this turn — I'm answering without pack/entity/tool memory. See bot log: [recall] builder failed.");
86
+ }
87
+ if (state.recallFailureStreak === RECALL_FAILURE_ALERT_AFTER) {
88
+ notify("alert:recall-failure", [
89
+ `⚠️ Memory recall has failed ${state.recallFailureStreak} turns in a row — I'm running without pack/entity/tool context.`,
90
+ r.detail ? `Last error: ${r.detail}` : null,
91
+ "Check the bot log for \"[recall] builder failed\". I'll stay quiet until it recovers or breaks again.",
92
+ ].filter(Boolean).join("\n"));
93
+ }
94
+ } else if (r && !r.omitted) {
95
+ state.recallFailureStreak = 0;
96
+ }
97
+ if (guarded) return;
76
98
  if (!r || r.omitted) return;
77
99
  const fmt = (arr, icon) => (arr || []).map((x) => (x.why ? `${icon} <b>${esc(x.name)}</b> — ${esc(x.why)}` : `${icon} <b>${esc(x.name)}</b>`));
78
100
  if (settings.showRecall) {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@inetafrica/open-claudia",
3
- "version": "3.0.43",
3
+ "version": "3.1.0",
4
4
  "description": "An always-on, provider-agnostic coding-agent harness for Claude Code and OpenAI Codex via chat",
5
5
  "main": "bot.js",
6
6
  "bin": {
@@ -9,7 +9,7 @@
9
9
  "scripts": {
10
10
  "setup": "node setup.js",
11
11
  "start": "node bot.js",
12
- "pretest": "node test-provider-fixture.js && node test-provider-core-prompt.js && node test-provider-prompt-parity.js && node test-provider-env-isolation.js && node test-provider-resume-parity.js && node test-provider-bootstrap.js && node test-provider-registry.js && node test-provider-stream-decoder.js && node test-provider-events.js && node test-provider-claude.js && node test-provider-codex.js && node test-provider-runner.js && node test-provider-run-lifecycle.js && node test-provider-gateway.js && node test-provider-side-chat.js && node test-single-runtime.js && node test-provider-migration-backup.js && node test-provider-state-migration.js && node test-provider-session-history.js && node test-provider-session-commands.js && node test-provider-compaction.js && node test-provider-scheduler.js && node test-credential-capture.js && node test-cursor-removal.js && node test-utility-provider-policy.js && node test-provider-subagent.js && node test-provider-recall.js && node test-provider-pack-review.js && node test-memory-mutation-queue.js && node test-provider-dream.js && node test-provider-enforcer.js && node test-provider-tool-hooks.js && node test-provider-boot-matrix.js && node test-provider-capabilities.js && node test-provider-language.js && node test-provider-coupling-audit.js && node test-provider-parity-e2e.js && node test-kazee-channel-health.js && node test-prompt-budget.js && node test-agency-ledger.js && node test-agency-deliberate.js && node test-guardrail.js && node test-spaces-comment-content.js",
12
+ "pretest": "node test-provider-fixture.js && node test-provider-core-prompt.js && node test-turn-observer.js && node test-provider-prompt-parity.js && node test-provider-env-isolation.js && node test-provider-resume-parity.js && node test-provider-bootstrap.js && node test-provider-registry.js && node test-provider-stream-decoder.js && node test-provider-events.js && node test-provider-claude.js && node test-provider-codex.js && node test-provider-runner.js && node test-provider-refusal-recovery.js && node test-provider-run-lifecycle.js && node test-provider-gateway.js && node test-provider-side-chat.js && node test-single-runtime.js && node test-provider-migration-backup.js && node test-provider-state-migration.js && node test-provider-session-history.js && node test-provider-session-commands.js && node test-provider-compaction.js && node test-provider-scheduler.js && node test-credential-capture.js && node test-cursor-removal.js && node test-utility-provider-policy.js && node test-provider-subagent.js && node test-provider-recall.js && node test-provider-pack-review.js && node test-memory-mutation-queue.js && node test-provider-dream.js && node test-provider-enforcer.js && node test-provider-tool-hooks.js && node test-provider-boot-matrix.js && node test-provider-capabilities.js && node test-provider-language.js && node test-provider-coupling-audit.js && node test-provider-parity-e2e.js && node test-kazee-channel-health.js && node test-prompt-budget.js && node test-agency-ledger.js && node test-agency-deliberate.js && node test-guardrail.js && node test-spaces-comment-content.js && node test-boot-health.js && node test-lexical-floor.js && node test-safe-mode.js",
13
13
  "test": "OPEN_CLAUDIA_TEST=1 WORKSPACE=/tmp/open-claudia-test CLAUDE_PATH=./test-fixtures/fake-agent-cli.js FAKE_AGENT_KIND=claude AGENT_ENV_PASSTHROUGH=FAKE_AGENT_KIND node -e \"require('./vault'); console.log('OK')\" && OPEN_CLAUDIA_TEST=1 WORKSPACE=/tmp/open-claudia-test CLAUDE_PATH=./test-fixtures/fake-agent-cli.js FAKE_AGENT_KIND=claude AGENT_ENV_PASSTHROUGH=FAKE_AGENT_KIND node test-runner-watchdog-static.js && OPEN_CLAUDIA_TEST=1 WORKSPACE=/tmp/open-claudia-test CLAUDE_PATH=./test-fixtures/fake-agent-cli.js FAKE_AGENT_KIND=claude AGENT_ENV_PASSTHROUGH=FAKE_AGENT_KIND node test-usage-accounting.js && OPEN_CLAUDIA_TEST=1 WORKSPACE=/tmp/open-claudia-test CLAUDE_PATH=./test-fixtures/fake-agent-cli.js FAKE_AGENT_KIND=claude AGENT_ENV_PASSTHROUGH=FAKE_AGENT_KIND node test-recall-engine.js && OPEN_CLAUDIA_TEST=1 WORKSPACE=/tmp/open-claudia-test CLAUDE_PATH=./test-fixtures/fake-agent-cli.js FAKE_AGENT_KIND=claude AGENT_ENV_PASSTHROUGH=FAKE_AGENT_KIND node test-recall-graph.js && OPEN_CLAUDIA_TEST=1 WORKSPACE=/tmp/open-claudia-test CLAUDE_PATH=./test-fixtures/fake-agent-cli.js FAKE_AGENT_KIND=claude AGENT_ENV_PASSTHROUGH=FAKE_AGENT_KIND node test-recall-discoverer.js && OPEN_CLAUDIA_TEST=1 WORKSPACE=/tmp/open-claudia-test CLAUDE_PATH=./test-fixtures/fake-agent-cli.js FAKE_AGENT_KIND=claude AGENT_ENV_PASSTHROUGH=FAKE_AGENT_KIND node test-walker-score.js && OPEN_CLAUDIA_TEST=1 WORKSPACE=/tmp/open-claudia-test CLAUDE_PATH=./test-fixtures/fake-agent-cli.js FAKE_AGENT_KIND=claude AGENT_ENV_PASSTHROUGH=FAKE_AGENT_KIND node test-score-gate.js && OPEN_CLAUDIA_TEST=1 WORKSPACE=/tmp/open-claudia-test CLAUDE_PATH=./test-fixtures/fake-agent-cli.js FAKE_AGENT_KIND=claude AGENT_ENV_PASSTHROUGH=FAKE_AGENT_KIND node test-cokept-warming.js && OPEN_CLAUDIA_TEST=1 WORKSPACE=/tmp/open-claudia-test CLAUDE_PATH=./test-fixtures/fake-agent-cli.js FAKE_AGENT_KIND=claude AGENT_ENV_PASSTHROUGH=FAKE_AGENT_KIND node test-project-transcripts-smoke.js && OPEN_CLAUDIA_TEST=1 WORKSPACE=/tmp/open-claudia-test CLAUDE_PATH=./test-fixtures/fake-agent-cli.js FAKE_AGENT_KIND=claude AGENT_ENV_PASSTHROUGH=FAKE_AGENT_KIND node test-abilities.js && OPEN_CLAUDIA_TEST=1 WORKSPACE=/tmp/open-claudia-test CLAUDE_PATH=./test-fixtures/fake-agent-cli.js FAKE_AGENT_KIND=claude AGENT_ENV_PASSTHROUGH=FAKE_AGENT_KIND node test-ability-extraction.js && OPEN_CLAUDIA_TEST=1 WORKSPACE=/tmp/open-claudia-test CLAUDE_PATH=./test-fixtures/fake-agent-cli.js FAKE_AGENT_KIND=claude AGENT_ENV_PASSTHROUGH=FAKE_AGENT_KIND node test-ability-couse.js && OPEN_CLAUDIA_TEST=1 WORKSPACE=/tmp/open-claudia-test CLAUDE_PATH=./test-fixtures/fake-agent-cli.js FAKE_AGENT_KIND=claude AGENT_ENV_PASSTHROUGH=FAKE_AGENT_KIND node test-ability-transfer.js && OPEN_CLAUDIA_TEST=1 WORKSPACE=/tmp/open-claudia-test CLAUDE_PATH=./test-fixtures/fake-agent-cli.js FAKE_AGENT_KIND=claude AGENT_ENV_PASSTHROUGH=FAKE_AGENT_KIND node test-ability-tiers.js && OPEN_CLAUDIA_TEST=1 WORKSPACE=/tmp/open-claudia-test CLAUDE_PATH=./test-fixtures/fake-agent-cli.js FAKE_AGENT_KIND=claude AGENT_ENV_PASSTHROUGH=FAKE_AGENT_KIND node test-ability-merge-guard.js && OPEN_CLAUDIA_TEST=1 WORKSPACE=/tmp/open-claudia-test CLAUDE_PATH=./test-fixtures/fake-agent-cli.js FAKE_AGENT_KIND=claude AGENT_ENV_PASSTHROUGH=FAKE_AGENT_KIND node test-learning-e2e.js && OPEN_CLAUDIA_TEST=1 WORKSPACE=/tmp/open-claudia-test CLAUDE_PATH=./test-fixtures/fake-agent-cli.js FAKE_AGENT_KIND=claude AGENT_ENV_PASSTHROUGH=FAKE_AGENT_KIND node test-pack-nesting.js && OPEN_CLAUDIA_TEST=1 WORKSPACE=/tmp/open-claudia-test CLAUDE_PATH=./test-fixtures/fake-agent-cli.js FAKE_AGENT_KIND=claude AGENT_ENV_PASSTHROUGH=FAKE_AGENT_KIND node test-pack-search.js && OPEN_CLAUDIA_TEST=1 WORKSPACE=/tmp/open-claudia-test CLAUDE_PATH=./test-fixtures/fake-agent-cli.js FAKE_AGENT_KIND=claude AGENT_ENV_PASSTHROUGH=FAKE_AGENT_KIND node test-extractive-injection.js && OPEN_CLAUDIA_TEST=1 WORKSPACE=/tmp/open-claudia-test CLAUDE_PATH=./test-fixtures/fake-agent-cli.js FAKE_AGENT_KIND=claude AGENT_ENV_PASSTHROUGH=FAKE_AGENT_KIND node test-journal-dedupe.js && OPEN_CLAUDIA_TEST=1 WORKSPACE=/tmp/open-claudia-test CLAUDE_PATH=./test-fixtures/fake-agent-cli.js FAKE_AGENT_KIND=claude AGENT_ENV_PASSTHROUGH=FAKE_AGENT_KIND node test-read-signal.js && OPEN_CLAUDIA_TEST=1 WORKSPACE=/tmp/open-claudia-test CLAUDE_PATH=./test-fixtures/fake-agent-cli.js FAKE_AGENT_KIND=claude AGENT_ENV_PASSTHROUGH=FAKE_AGENT_KIND node test-tools.js && OPEN_CLAUDIA_TEST=1 WORKSPACE=/tmp/open-claudia-test CLAUDE_PATH=./test-fixtures/fake-agent-cli.js FAKE_AGENT_KIND=claude AGENT_ENV_PASSTHROUGH=FAKE_AGENT_KIND node test-tool-graph.js && OPEN_CLAUDIA_TEST=1 WORKSPACE=/tmp/open-claudia-test CLAUDE_PATH=./test-fixtures/fake-agent-cli.js FAKE_AGENT_KIND=claude AGENT_ENV_PASSTHROUGH=FAKE_AGENT_KIND node test-tool-guard.js && OPEN_CLAUDIA_TEST=1 WORKSPACE=/tmp/open-claudia-test CLAUDE_PATH=./test-fixtures/fake-agent-cli.js FAKE_AGENT_KIND=claude AGENT_ENV_PASSTHROUGH=FAKE_AGENT_KIND node test-tooling-mode.js && OPEN_CLAUDIA_TEST=1 WORKSPACE=/tmp/open-claudia-test CLAUDE_PATH=./test-fixtures/fake-agent-cli.js FAKE_AGENT_KIND=claude AGENT_ENV_PASSTHROUGH=FAKE_AGENT_KIND node test-tool-manifest.js && OPEN_CLAUDIA_TEST=1 WORKSPACE=/tmp/open-claudia-test CLAUDE_PATH=./test-fixtures/fake-agent-cli.js FAKE_AGENT_KIND=claude AGENT_ENV_PASSTHROUGH=FAKE_AGENT_KIND node test-recall-evolution.js && OPEN_CLAUDIA_TEST=1 WORKSPACE=/tmp/open-claudia-test CLAUDE_PATH=./test-fixtures/fake-agent-cli.js FAKE_AGENT_KIND=claude AGENT_ENV_PASSTHROUGH=FAKE_AGENT_KIND node test-approval-async.js && OPEN_CLAUDIA_TEST=1 WORKSPACE=/tmp/open-claudia-test CLAUDE_PATH=./test-fixtures/fake-agent-cli.js FAKE_AGENT_KIND=claude AGENT_ENV_PASSTHROUGH=FAKE_AGENT_KIND node test-unified-identity.js && OPEN_CLAUDIA_TEST=1 WORKSPACE=/tmp/open-claudia-test CLAUDE_PATH=./test-fixtures/fake-agent-cli.js FAKE_AGENT_KIND=claude AGENT_ENV_PASSTHROUGH=FAKE_AGENT_KIND node test-queue-routing.js && OPEN_CLAUDIA_TEST=1 WORKSPACE=/tmp/open-claudia-test CLAUDE_PATH=./test-fixtures/fake-agent-cli.js FAKE_AGENT_KIND=claude AGENT_ENV_PASSTHROUGH=FAKE_AGENT_KIND node test-persona-packs.js && OPEN_CLAUDIA_TEST=1 WORKSPACE=/tmp/open-claudia-test CLAUDE_PATH=./test-fixtures/fake-agent-cli.js FAKE_AGENT_KIND=claude AGENT_ENV_PASSTHROUGH=FAKE_AGENT_KIND node test-speaker-persona.js && OPEN_CLAUDIA_TEST=1 WORKSPACE=/tmp/open-claudia-test CLAUDE_PATH=./test-fixtures/fake-agent-cli.js FAKE_AGENT_KIND=claude AGENT_ENV_PASSTHROUGH=FAKE_AGENT_KIND node test-persona-pipeline.js && OPEN_CLAUDIA_TEST=1 WORKSPACE=/tmp/open-claudia-test CLAUDE_PATH=./test-fixtures/fake-agent-cli.js FAKE_AGENT_KIND=claude AGENT_ENV_PASSTHROUGH=FAKE_AGENT_KIND node test-recall-relationship-gate.js && OPEN_CLAUDIA_TEST=1 WORKSPACE=/tmp/open-claudia-test CLAUDE_PATH=./test-fixtures/fake-agent-cli.js FAKE_AGENT_KIND=claude AGENT_ENV_PASSTHROUGH=FAKE_AGENT_KIND node test-relationship.js && OPEN_CLAUDIA_TEST=1 WORKSPACE=/tmp/open-claudia-test CLAUDE_PATH=./test-fixtures/fake-agent-cli.js FAKE_AGENT_KIND=claude AGENT_ENV_PASSTHROUGH=FAKE_AGENT_KIND node test-enforcer.js && OPEN_CLAUDIA_TEST=1 WORKSPACE=/tmp/open-claudia-test CLAUDE_PATH=./test-fixtures/fake-agent-cli.js FAKE_AGENT_KIND=claude AGENT_ENV_PASSTHROUGH=FAKE_AGENT_KIND node test-identity-prune.js && OPEN_CLAUDIA_TEST=1 WORKSPACE=/tmp/open-claudia-test CLAUDE_PATH=./test-fixtures/fake-agent-cli.js FAKE_AGENT_KIND=claude AGENT_ENV_PASSTHROUGH=FAKE_AGENT_KIND node test-kazee-message-id.js && OPEN_CLAUDIA_TEST=1 WORKSPACE=/tmp/open-claudia-test CLAUDE_PATH=./test-fixtures/fake-agent-cli.js FAKE_AGENT_KIND=claude AGENT_ENV_PASSTHROUGH=FAKE_AGENT_KIND node test-delivery-contract.js && OPEN_CLAUDIA_TEST=1 WORKSPACE=/tmp/open-claudia-test CLAUDE_PATH=./test-fixtures/fake-agent-cli.js FAKE_AGENT_KIND=claude AGENT_ENV_PASSTHROUGH=FAKE_AGENT_KIND node test-run-lock.js && OPEN_CLAUDIA_TEST=1 WORKSPACE=/tmp/open-claudia-test CLAUDE_PATH=./test-fixtures/fake-agent-cli.js FAKE_AGENT_KIND=claude AGENT_ENV_PASSTHROUGH=FAKE_AGENT_KIND node test-web-sessions.js && OPEN_CLAUDIA_TEST=1 WORKSPACE=/tmp/open-claudia-test CLAUDE_PATH=./test-fixtures/fake-agent-cli.js FAKE_AGENT_KIND=claude AGENT_ENV_PASSTHROUGH=FAKE_AGENT_KIND node test-telegram-poll-recovery.js && OPEN_CLAUDIA_TEST=1 WORKSPACE=/tmp/open-claudia-test CLAUDE_PATH=./test-fixtures/fake-agent-cli.js FAKE_AGENT_KIND=claude AGENT_ENV_PASSTHROUGH=FAKE_AGENT_KIND node test-telegram-409-grace.js && OPEN_CLAUDIA_TEST=1 WORKSPACE=/tmp/open-claudia-test CLAUDE_PATH=./test-fixtures/fake-agent-cli.js FAKE_AGENT_KIND=claude AGENT_ENV_PASSTHROUGH=FAKE_AGENT_KIND node test-telegram-conflict-heal-guard.js && OPEN_CLAUDIA_TEST=1 WORKSPACE=/tmp/open-claudia-test CLAUDE_PATH=./test-fixtures/fake-agent-cli.js FAKE_AGENT_KIND=claude AGENT_ENV_PASSTHROUGH=FAKE_AGENT_KIND node test-single-instance.js && OPEN_CLAUDIA_TEST=1 WORKSPACE=/tmp/open-claudia-test CLAUDE_PATH=./test-fixtures/fake-agent-cli.js FAKE_AGENT_KIND=claude AGENT_ENV_PASSTHROUGH=FAKE_AGENT_KIND node test-streaming-split.js && OPEN_CLAUDIA_TEST=1 WORKSPACE=/tmp/open-claudia-test CLAUDE_PATH=./test-fixtures/fake-agent-cli.js FAKE_AGENT_KIND=claude AGENT_ENV_PASSTHROUGH=FAKE_AGENT_KIND node test-spaces-autoenable.js"
14
14
  },
15
15
  "files": [
@@ -132,7 +132,7 @@
132
132
  "license": "MIT",
133
133
  "type": "commonjs",
134
134
  "engines": {
135
- "node": ">=18.0.0"
135
+ "node": ">=24.0.0"
136
136
  },
137
137
  "dependencies": {
138
138
  "node-cron": "^4.2.1",
@@ -67,4 +67,25 @@ assert.ok(/#### Stance/.test(outHeadline), "headline still shows Stance");
67
67
  assert.ok(!outHeadline.includes("The ArgoCD sync for payments"), "headline does NOT inline State (unchanged legacy behavior)");
68
68
  assert.ok(/not shown/.test(outHeadline), "headline still points to hidden sections");
69
69
 
70
+ // ── LOCKED hoist: a Stance line carrying LOCKED (uppercase) is pulled under
71
+ // a binding-constraints header in every mode; prose "locked" stays put ──
72
+ const lockedPack = {
73
+ ...pack,
74
+ dir: "coders-africa",
75
+ sections: {
76
+ ...pack.sections,
77
+ Stance: "Audience-first design.\nLOCKED: heading font is Marcellus, body font is Nunito.\nThe staging door is locked after 18:00.",
78
+ },
79
+ };
80
+ const outLockedX = formatPackForContext(lockedPack, packsLib, { extractive: true, matchedLines: [] });
81
+ assert.ok(/BINDING CONSTRAINTS \(LOCKED/.test(outLockedX), "extractive mode hoists LOCKED lines under the binding header");
82
+ assert.ok(/- LOCKED: heading font is Marcellus, body font is Nunito\./.test(outLockedX), "the LOCKED line is bulleted verbatim");
83
+ const bindingIdx = outLockedX.indexOf("BINDING CONSTRAINTS");
84
+ assert.ok(bindingIdx !== -1 && bindingIdx < outLockedX.indexOf("Audience-first design."), "binding block leads the Stance section");
85
+ assert.ok(outLockedX.includes("The staging door is locked after 18:00."), "lowercase 'locked' prose is not hoisted");
86
+ assert.strictEqual((outLockedX.match(/heading font is Marcellus/g) || []).length, 1, "hoisted line is moved, not duplicated");
87
+ assert.ok(/BINDING CONSTRAINTS \(LOCKED/.test(formatPackForContext(lockedPack, packsLib, { headline: true })), "headline mode hoists too");
88
+ assert.ok(/BINDING CONSTRAINTS \(LOCKED/.test(formatPackForContext(lockedPack, packsLib, {})), "full mode hoists too");
89
+ assert.ok(!/BINDING CONSTRAINTS/.test(out), "packs without LOCKED lines are untouched");
90
+
70
91
  console.log("extractive injection OK");
@@ -252,6 +252,28 @@ function inferSessionId() {
252
252
 
253
253
  function claudeEvents(sessionId) {
254
254
  const init = { type: "system", subtype: "init", session_id: sessionId };
255
+ if (scenario === "refusal") {
256
+ return [
257
+ init,
258
+ {
259
+ type: "assistant",
260
+ message: {
261
+ role: "assistant",
262
+ content: [],
263
+ stop_reason: "refusal",
264
+ stop_details: { type: "refusal", category: "cyber", explanation: "This request was declined because it could enable cyber harm." },
265
+ },
266
+ session_id: sessionId,
267
+ },
268
+ {
269
+ type: "result",
270
+ subtype: "error_during_execution",
271
+ is_error: true,
272
+ session_id: sessionId,
273
+ result: "API Error: Claude Code is unable to respond to this request, which appears to violate our Usage Policy (https://www.anthropic.com/legal/aup). Request ID: req_fixture123",
274
+ },
275
+ ];
276
+ }
255
277
  if (scenario === "auth-failure") {
256
278
  return [
257
279
  init,
@@ -332,6 +354,12 @@ function claudeEvents(sessionId) {
332
354
 
333
355
  function codexEvents(sessionId) {
334
356
  const started = { type: "thread.started", thread_id: sessionId };
357
+ if (scenario === "refusal") {
358
+ return [
359
+ started,
360
+ { type: "turn.failed", error: { message: "Your request was rejected by the safety classifier under the usage policy." } },
361
+ ];
362
+ }
335
363
  if (scenario === "auth-failure") {
336
364
  return [
337
365
  started,
@@ -491,7 +519,7 @@ async function main() {
491
519
  setInterval(() => {}, 1000);
492
520
  return;
493
521
  }
494
- if (scenario === "auth-failure") {
522
+ if (scenario === "auth-failure" || scenario === "refusal") {
495
523
  process.exitCode = 1;
496
524
  return;
497
525
  }
@@ -0,0 +1,14 @@
1
+ // Preload shim: makes require("node:sqlite") throw, reproducing CI's node:20
2
+ // where the module is absent. Release lesson (v3.0.39): run changed tests with
3
+ // NODE_OPTIONS=--require ./test-fixtures/sim-no-sqlite.js
4
+ // before cutting a version, or node22-vs-node20 graph divergences slip through.
5
+ "use strict";
6
+
7
+ const Module = require("module");
8
+ const originalLoad = Module._load;
9
+ Module._load = function patchedLoad(request, ...rest) {
10
+ if (request === "node:sqlite" || request === "sqlite") {
11
+ throw new Error("node:sqlite unavailable (sim-no-sqlite shim)");
12
+ }
13
+ return originalLoad.call(this, request, ...rest);
14
+ };
@@ -15,6 +15,7 @@ fs.mkdirSync(process.env.OPEN_CLAUDIA_CONFIG_DIR, { recursive: true });
15
15
  const {
16
16
  MemoryMutationQueue,
17
17
  MutationQueueError,
18
+ inspectLock,
18
19
  } = require("./core/memory-mutation-queue");
19
20
  const { atomicWriteFileSync } = require("./core/fsutil");
20
21
 
@@ -62,14 +63,12 @@ async function main() {
62
63
  journalFile,
63
64
  maxPending: 2,
64
65
  lockPollMs: 5,
65
- staleLockMs: 10,
66
66
  });
67
67
  const liveContender = new MemoryMutationQueue({
68
68
  lockDir: liveLockDir,
69
69
  journalFile,
70
70
  maxPending: 2,
71
71
  lockPollMs: 5,
72
- staleLockMs: 10,
73
72
  lockTimeoutMs: 50,
74
73
  });
75
74
  const liveMutation = liveOwner.enqueue("long-dream", async () => liveGate.promise);
@@ -92,11 +91,42 @@ async function main() {
92
91
  lockDir: deadLockDir,
93
92
  journalFile,
94
93
  lockPollMs: 5,
95
- staleLockMs: 10,
96
94
  lockTimeoutMs: 200,
97
95
  });
98
96
  assert.strictEqual(await deadRecovery.enqueue("recover-dead-lock", async () => "recovered"), "recovered");
99
97
 
98
+ // Container pid-1 reuse: a lock left by a CRASHED incarnation of this same
99
+ // pid (different bootId) must be broken immediately — pid-liveness alone
100
+ // reads it as held forever, which starved reviewers on restarting pods.
101
+ const rebootLockDir = path.join(root, "reboot-stale.lock");
102
+ fs.mkdirSync(rebootLockDir);
103
+ fs.writeFileSync(path.join(rebootLockDir, "owner.json"),
104
+ JSON.stringify({ token: "prev", pid: process.pid, bootId: "previous-incarnation" }));
105
+ const rebootRecovery = new MemoryMutationQueue({
106
+ lockDir: rebootLockDir,
107
+ journalFile,
108
+ lockPollMs: 5,
109
+ lockTimeoutMs: 200,
110
+ });
111
+ assert.strictEqual(await rebootRecovery.enqueue("recover-reboot-lock", async () => "recovered"), "recovered",
112
+ "a lock from a previous incarnation sharing our pid is broken immediately");
113
+ assert.strictEqual(
114
+ inspectLock(rebootLockDir).held, false,
115
+ "inspectLock reports a released lock as not held");
116
+
117
+ // Orphan: mkdir succeeded but owner.json never landed (crash mid-acquire).
118
+ const orphanLockDir = path.join(root, "orphan.lock");
119
+ fs.mkdirSync(orphanLockDir);
120
+ fs.utimesSync(orphanLockDir, old, old);
121
+ assert.strictEqual(inspectLock(orphanLockDir).stale, true, "an aged ownerless lock dir reads as stale");
122
+ const orphanRecovery = new MemoryMutationQueue({
123
+ lockDir: orphanLockDir,
124
+ journalFile,
125
+ lockPollMs: 5,
126
+ lockTimeoutMs: 200,
127
+ });
128
+ assert.strictEqual(await orphanRecovery.enqueue("recover-orphan-lock", async () => "recovered"), "recovered");
129
+
100
130
  const failureQueue = new MemoryMutationQueue({
101
131
  lockDir: path.join(root, "failure.lock"),
102
132
  journalFile,
@@ -122,6 +122,11 @@ async function immutableSuccessProbe() {
122
122
  assert.strictEqual(contexts[1].permissionMode, "plan");
123
123
  assert.strictEqual(contexts[1].maxTurns, 1);
124
124
  assert.strictEqual(contexts[1].timeoutMs, contexts[0].timeoutMs);
125
+ assert.strictEqual(contexts[0].purpose, "compact-summary");
126
+ assert.strictEqual(contexts[0].recallQuery, null, "summary stage carries no recall query");
127
+ assert.strictEqual(contexts[1].purpose, "compact-seed");
128
+ assert.ok(String(contexts[1].recallQuery || "").includes("A sufficiently detailed condensed continuation"),
129
+ "seed recall query carries the condensed brief, not seed-prompt boilerplate");
125
130
  const restrictedEnv = runner.decorateForegroundInvocation(
126
131
  { env: { FIXTURE_SAFE: "1" } },
127
132
  contexts[0],
@@ -164,6 +164,9 @@ async function probe() {
164
164
  assert.strictEqual(omitted.userPrompt, "RECALL FAILURE USER SENTINEL");
165
165
  assert.strictEqual(omitted.recallMetadata.status, "omitted");
166
166
  assert.strictEqual(omitted.recallMetadata.omitted, true);
167
+ assert.strictEqual(omitted.recallMetadata.reason, "optional_recall_unavailable");
168
+ assert.strictEqual(omitted.recallMetadata.detail, "fixture optional recall failure",
169
+ "the swallowed recall error surfaces in metadata for the owner alert");
167
170
  assert.ok(omitted.coreInstructions.includes("## Delivery"));
168
171
  assert.ok(omitted.dynamicContext.includes("## Runtime state"));
169
172