@gcunharodrigues/wrxn 0.16.0 → 0.17.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.
package/lib/install.cjs CHANGED
@@ -99,6 +99,9 @@ function init(opts) {
99
99
  // the memory-synth writes one outcome line per synth run to `.wrxn/continuity/.synth.log` (synth-handoff-fix-01)
100
100
  // — install runtime state (never shipped in the payload), so keep it out of version control.
101
101
  ensureGitignoreLine(target, '.wrxn/continuity/.synth.log');
102
+ // memory-synth-spawn claims each session with a persistent `.wrxn/continuity/.spawned-<id>` marker (#45) —
103
+ // install runtime state (never shipped), so keep the family out of version control like the synth log.
104
+ ensureGitignoreLine(target, '.wrxn/continuity/.spawned-*');
102
105
  // the memory-synth gemini fallback reads GEMINI_API_KEY from `.env` — ignore it so the secret is
103
106
  // never committed (the synth's doc-comment calls it the install's gitignored `.env`).
104
107
  ensureGitignoreLine(target, '.env');
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@gcunharodrigues/wrxn",
3
- "version": "0.16.0",
3
+ "version": "0.17.0",
4
4
  "description": "WRXN Kernel — installable AI operating system. Two profiles (project | workspace), pull-based updates, managed/seeded/state file classes.",
5
5
  "bin": {
6
6
  "wrxn": "bin/wrxn.cjs"
@@ -25,6 +25,19 @@ const { spawn } = require('child_process');
25
25
 
26
26
  const SENTINEL = 'WRXN_MEMORY_SYNTH'; // recursion guard (mirrors memory-synth.cjs's SENTINEL).
27
27
 
28
+ // safeId — canonicalize a session id used as a FILESYSTEM PATH component (#45): lowercase, collapse every
29
+ // non-alnum run to '-', trim, cap length. A raw session id concatenated into a path is a traversal surface;
30
+ // this keeps the once-per-session marker INSIDE .wrxn/continuity. REPLICATED byte-identically from
31
+ // session-start.cjs (and code-intel-push / the reward shell) — each install-only hook is self-contained
32
+ // (node stdlib only, no shared import).
33
+ function safeId(sid) {
34
+ return String(sid || 'session')
35
+ .toLowerCase()
36
+ .replace(/[^a-z0-9]+/g, '-')
37
+ .replace(/^-+|-+$/g, '')
38
+ .slice(0, 48) || 'session';
39
+ }
40
+
28
41
  function findInstallRoot(startDir) {
29
42
  let dir = startDir || process.env.CLAUDE_PROJECT_DIR || process.cwd();
30
43
  for (let i = 0; i < 12; i++) {
@@ -52,6 +65,19 @@ function run({ payload, root, env = process.env, spawn: spawnFn = spawn }) {
52
65
  const dir = path.join(root, '.wrxn', 'continuity');
53
66
  fs.mkdirSync(dir, { recursive: true });
54
67
 
68
+ // Once-per-session claim (#45): the harness fires SessionEnd more than once per session, so an
69
+ // unguarded hook launches N synths that race on one shared `.pending`. CLAIM the session ATOMICALLY
70
+ // with an exclusive create (`wx` throws EEXIST if the marker exists) BEFORE staging markers / spawning:
71
+ // the FIRST fire creates the marker and proceeds; every later fire for the same session throws → the
72
+ // outer catch returns {} (spawns nothing, writes no markers). Race-safe across the separate hook
73
+ // processes (no TOCTOU). The marker is a PERSISTENT per-session file — it must OUTLIVE the synth, so it
74
+ // is NOT `.pending`/`.pending-handoff` (which the synth clears on exit). A missing/empty session_id
75
+ // cannot be deduped → preserve today's spawn-every-time behavior for that path.
76
+ const sid = payload && payload.session_id;
77
+ if (sid) {
78
+ fs.closeSync(fs.openSync(path.join(dir, `.spawned-${safeId(sid)}`), 'wx'));
79
+ }
80
+
55
81
  // Stash the payload as .pending (the synth reads it for transcript_path) and raise the handoff gate.
56
82
  fs.writeFileSync(path.join(dir, '.pending'), JSON.stringify(payload || {}));
57
83
  fs.writeFileSync(path.join(dir, '.pending-handoff'), String(Date.now()));
@@ -212,6 +212,33 @@ function holdForHandoff({ root, capMs = HOLD_CAP_MS, now = Date.now, sleep } = {
212
212
 
213
213
  const SYNTH_LOG_REL = ['.wrxn', 'continuity', '.synth.log'];
214
214
 
215
+ // sec-F2 (#52): cap the .synth.log read at its TAIL. The staleness decision needs only the NEWEST rows (+ the
216
+ // latest `wrote`), which live at the END of this append-only log — so reading the last SYNTH_LOG_TAIL_BYTES
217
+ // bounds memory against a pathologically large log without losing the signal. A partial first line (a row
218
+ // sliced mid-way by the byte window) is dropped so parseSynthLog only ever sees complete rows. FAIL-OPEN +
219
+ // total: a sub-cap log is read whole (unchanged); any stat/open/read fault → "" (no rows → no warning, no throw).
220
+ const SYNTH_LOG_TAIL_BYTES = 64 * 1024;
221
+
222
+ function readSynthLogTail(p) {
223
+ let fd;
224
+ try {
225
+ const size = fs.statSync(p).size;
226
+ if (size <= SYNTH_LOG_TAIL_BYTES) return readFileOr(p, '');
227
+ fd = fs.openSync(p, 'r');
228
+ const buf = Buffer.allocUnsafe(SYNTH_LOG_TAIL_BYTES);
229
+ const read = fs.readSync(fd, buf, 0, SYNTH_LOG_TAIL_BYTES, size - SYNTH_LOG_TAIL_BYTES);
230
+ const text = buf.toString('utf8', 0, read);
231
+ const nl = text.indexOf('\n');
232
+ return nl === -1 ? text : text.slice(nl + 1); // drop the partial first line sliced by the byte window
233
+ } catch {
234
+ return ''; // fail-open: any stat/open/read fault → no rows → no warning
235
+ } finally {
236
+ if (fd !== undefined) {
237
+ try { fs.closeSync(fd); } catch { /* fd already gone — never throw out of the cleanup */ }
238
+ }
239
+ }
240
+ }
241
+
215
242
  // A synth row is a FAILURE iff its outcome is `no-engine` or an `error…` string (`wrote`/`trivial` = healthy).
216
243
  function isFailure(outcome) {
217
244
  const o = String(outcome || '');
@@ -338,7 +365,7 @@ function main() {
338
365
  // — a frozen baton must not be surfaced as current silently. Fail-open: any read/stat/parse fault → no
339
366
  // warning, never a throw. Pure file reads + a stat only (no polling/sleeping beyond the hold cap above).
340
367
  try {
341
- const rows = parseSynthLog(readFileOr(path.join(root, ...SYNTH_LOG_REL), ''));
368
+ const rows = parseSynthLog(readSynthLogTail(path.join(root, ...SYNTH_LOG_REL)));
342
369
  const batonMtimeMs = fs.statSync(path.join(root, '.wrxn', 'continuity', 'latest.md')).mtimeMs;
343
370
  const failing = batonStaleness({ batonMtimeMs, rows });
344
371
  if (failing) {
@@ -372,4 +399,4 @@ if (require.main === module) {
372
399
  }
373
400
  }
374
401
 
375
- module.exports = { holdDecision, holdForHandoff, HOLD_CAP_MS, stampStartHead, resolveGitHead, BASELINE_DIR_REL, batonStaleness };
402
+ module.exports = { holdDecision, holdForHandoff, HOLD_CAP_MS, stampStartHead, resolveGitHead, BASELINE_DIR_REL, batonStaleness, parseSynthLog, readSynthLogTail, SYNTH_LOG_TAIL_BYTES };
@@ -242,9 +242,10 @@ function readTranscriptBlob(transcriptPath) {
242
242
  const CLAUDE_TIMEOUT_MS = 120000; // headless sonnet handoff/dream — generous but bounded.
243
243
  const GEMINI_TIMEOUT_MS = 30000; // mirrors the proven aimem `curl -m 30` fallback.
244
244
  const SENTINEL = 'WRXN_MEMORY_SYNTH'; // recursion guard: set on every engine spawn (the spawn hook no-ops when it sees it).
245
- // 1200 (vs the aimem reference's 900): the handoff prompt self-caps at ~400 words, so the extra
246
- // headroom is for the denser `dream` consolidation that shares this engine (lands in slice 04).
247
- const GEMINI_MAX_OUTPUT_TOKENS = 1200;
245
+ // 4096 (vs the aimem reference's 900, and the prior 1200): a thinking-default model spends part of the
246
+ // output budget on reasoning BEFORE the answer, and a rich `dream` consolidation runs ~4.3k chars 1200
247
+ // truncated even a non-thinking model on a dense session. 4096 fits the observed clean dream JSON (#30).
248
+ const GEMINI_MAX_OUTPUT_TOKENS = 4096;
248
249
 
249
250
  /** Assemble the model input: the task system prompt, then the transcript blob (mirrors the reference). */
250
251
  function assemblePrompt(prompt, blob) {
@@ -270,8 +271,16 @@ function buildClaudeSpec({ model, prompt, blob }) {
270
271
  * Build the `gemini` engine spec: a POST to `…/v1beta/models/<model>:generateContent` with the
271
272
  * `x-goog-api-key` header, the task prompt as `system_instruction` and the blob as user content
272
273
  * (mirrors the proven aimem-handoff-synth call). PURE.
274
+ *
275
+ * `thinking` controls the `generationConfig.thinkingConfig` directive (#30): when true (default) the spec
276
+ * carries `{ thinkingBudget: 0 }`, which DISABLES model-side thinking on thinking-default models (a verified
277
+ * HTTP-200 no-op on non-thinking models) so reasoning tokens never consume the output budget and the answer
278
+ * is never split into a dropped `thought` part. Pass `false` to OMIT the directive — the AC3 retry path for
279
+ * forced-thinking models (gemma-class) that reject thinkingConfig with an HTTP 400.
273
280
  */
274
- function buildGeminiSpec({ model, prompt, blob, apiKey }) {
281
+ function buildGeminiSpec({ model, prompt, blob, apiKey, thinking = true }) {
282
+ const generationConfig = { temperature: 0.2, maxOutputTokens: GEMINI_MAX_OUTPUT_TOKENS };
283
+ if (thinking) generationConfig.thinkingConfig = { thinkingBudget: 0 };
275
284
  return {
276
285
  engine: 'gemini',
277
286
  method: 'POST',
@@ -280,30 +289,59 @@ function buildGeminiSpec({ model, prompt, blob, apiKey }) {
280
289
  body: {
281
290
  system_instruction: { parts: [{ text: prompt || '' }] },
282
291
  contents: [{ role: 'user', parts: [{ text: `TRANSCRIPT:\n${blob || ''}` }] }],
283
- generationConfig: { temperature: 0.2, maxOutputTokens: GEMINI_MAX_OUTPUT_TOKENS },
292
+ generationConfig,
284
293
  },
285
294
  timeoutMs: GEMINI_TIMEOUT_MS,
286
295
  };
287
296
  }
288
297
 
289
298
  /**
290
- * Extract the model text from a Gemini `generateContent` response body
291
- * (`candidates[0].content.parts[0].text`). An error payload, unexpected shape, or unparseable body
292
- * null, so the engine fails cleanly ( fallback / null). PURE pins the response contract with no
293
- * network round-trip.
299
+ * Extract the model text from a Gemini `generateContent` response body. Concatenates the `.text` of every
300
+ * content part EXCEPT parts flagged `thought: true` a thinking-default / forced-thinking model emits its
301
+ * reasoning as a separate `thought` part (often parts[0]), which must be dropped, not returned, and the
302
+ * answer may itself span several parts (#30). An error payload, unexpected shape, no answer text, or an
303
+ * unparseable body → null, so the engine fails cleanly (→ fallback / null). PURE + total — pins the response
304
+ * contract with no network round-trip, never throws.
294
305
  */
295
306
  function parseGeminiResponse(bodyString) {
296
307
  try {
297
308
  const d = JSON.parse(bodyString);
298
- const t = d && d.candidates && d.candidates[0] && d.candidates[0].content
299
- && d.candidates[0].content.parts && d.candidates[0].content.parts[0]
300
- && d.candidates[0].content.parts[0].text;
301
- return typeof t === 'string' && t.trim() ? t : null;
309
+ const parts = d && d.candidates && d.candidates[0] && d.candidates[0].content
310
+ && d.candidates[0].content.parts;
311
+ if (!Array.isArray(parts)) return null;
312
+ const text = parts
313
+ .filter((p) => p && p.thought !== true && typeof p.text === 'string')
314
+ .map((p) => p.text)
315
+ .join('');
316
+ return text.trim() ? text : null;
302
317
  } catch {
303
318
  return null;
304
319
  }
305
320
  }
306
321
 
322
+ // A forced-thinking model (gemma-class) rejects the thinkingConfig directive with an HTTP 400 whose body
323
+ // mentions "thinking" (e.g. "Thinking budget is not supported"). That single discriminating token gates the
324
+ // retry-without path (#30) — narrow enough that an unrelated 400 is NOT mis-flagged (it degrades as a plain
325
+ // failure → fallback / null). A false positive would only cost one harmless retry-without-thinkingConfig.
326
+ const THINKING_UNSUPPORTED_RE = /thinking/i;
327
+
328
+ /**
329
+ * Classify a Gemini HTTP response into the invoke-result the orchestration consumes (#30). PURE + total:
330
+ * - a parseable answer → `{ ok:true, text }`;
331
+ * - HTTP 400 whose body mentions thinking → `{ ok:false, thinkingUnsupported:true, detail }` (the signal
332
+ * runEngine uses to rebuild the spec WITHOUT thinkingConfig and retry once);
333
+ * - anything else → `{ ok:false, detail }` (a plain failure, exactly as before).
334
+ * Kept pure (statusCode + body in, result out) so invokeGemini's 400-detection is unit-tested with no network.
335
+ */
336
+ function parseGeminiResult(statusCode, bodyString) {
337
+ const text = parseGeminiResponse(bodyString);
338
+ if (text) return { ok: true, text };
339
+ if (statusCode === 400 && THINKING_UNSUPPORTED_RE.test(String(bodyString || ''))) {
340
+ return { ok: false, text: '', thinkingUnsupported: true, detail: 'gemini http 400 thinking-unsupported' };
341
+ }
342
+ return { ok: false, text: '', detail: `gemini http ${statusCode}` };
343
+ }
344
+
307
345
  // ── defaultInvoke: the REAL engine invoker (the production path behind the seam) ──
308
346
  // Mirrors lib/protect.cjs's defaultInvoke: the single real-call site. The suite NEVER reaches here — it
309
347
  // injects a fake. `claude` → spawnSync `claude -p`; `gemini` → an HTTPS POST. Both judge success by the
@@ -341,8 +379,9 @@ function invokeGemini(spec) {
341
379
  let data = '';
342
380
  res.on('data', (c) => { data += c; });
343
381
  res.on('end', () => {
344
- const text = parseGeminiResponse(data);
345
- resolve(text ? { ok: true, text } : { ok: false, text: '', detail: `gemini http ${res.statusCode}` });
382
+ // parseGeminiResult is status-aware: a 400 thinking-unsupported body becomes a `thinkingUnsupported`
383
+ // signal so runEngine retries WITHOUT thinkingConfig; any other failure stays a plain ok:false (#30).
384
+ resolve(parseGeminiResult(res.statusCode, data));
346
385
  });
347
386
  },
348
387
  );
@@ -398,12 +437,27 @@ function defaultSleep(ms) {
398
437
  */
399
438
  async function runEngine(engine, { prompt, blob, apiKey, invoke, sleep = defaultSleep, validate }) {
400
439
  if (!engine || !engine.engine) return { text: null, attempts: 0 };
401
- let spec;
440
+
441
+ // `callOnce()` is ONE engine call — the unit the transient-spawn retry loop below repeats. For gemini it
442
+ // also encapsulates the thinking-unsupported fallback (#30): issue the spec carrying thinkingConfig; if the
443
+ // model REJECTS that directive (HTTP 400 → `thinkingUnsupported`), rebuild the SAME request WITHOUT
444
+ // thinkingConfig and issue it once more, using that result. This lives ABOVE the invoke seam, so a fake
445
+ // invoke simulates both halves with no network; it composes with the transient retries (the whole callOnce
446
+ // is the retried unit) and preserves the gemini-no-key early-out (no key → no request, below).
447
+ let callOnce;
402
448
  if (engine.engine === 'claude') {
403
- spec = buildClaudeSpec({ model: engine.model, prompt, blob });
449
+ const spec = buildClaudeSpec({ model: engine.model, prompt, blob });
450
+ callOnce = () => invoke(spec);
404
451
  } else if (engine.engine === 'gemini') {
405
452
  if (!apiKey) return { text: null, attempts: 0 }; // missing key fails this engine (→ fallback / null), no request, no retry.
406
- spec = buildGeminiSpec({ model: engine.model, prompt, blob, apiKey });
453
+ callOnce = async () => {
454
+ const r = await invoke(buildGeminiSpec({ model: engine.model, prompt, blob, apiKey, thinking: true }));
455
+ if (r && r.thinkingUnsupported) {
456
+ // this model rejects the thinkingConfig directive (forced-thinking / gemma-class) — retry once without it.
457
+ return invoke(buildGeminiSpec({ model: engine.model, prompt, blob, apiKey, thinking: false }));
458
+ }
459
+ return r;
460
+ };
407
461
  } else {
408
462
  return { text: null, attempts: 0 }; // unknown engine name → skip.
409
463
  }
@@ -412,7 +466,7 @@ async function runEngine(engine, { prompt, blob, apiKey, invoke, sleep = default
412
466
  attempts += 1;
413
467
  let text = null;
414
468
  try {
415
- const r = await invoke(spec);
469
+ const r = await callOnce();
416
470
  const t = r && r.ok && typeof r.text === 'string' ? r.text.trim() : '';
417
471
  text = t || null;
418
472
  } catch {
@@ -432,9 +486,11 @@ async function runEngine(engine, { prompt, blob, apiKey, invoke, sleep = default
432
486
  // writes nothing. A task with NO registered validator is permissive (any non-empty text wins, exactly as
433
487
  // before), so every existing caller is preserved.
434
488
  const TASK_VALIDATORS = {
435
- // dream output is usable only if the gate (parseProposals) reads ≥1 proposal from it, OR it is an explicit
436
- // abstain — a deliberate "nothing to record" is a valid answer, NOT a reason to churn through the fallback.
437
- dream: (text) => parseProposals(text).length > 0 || /abstain/i.test(text),
489
+ // dream output is usable only if the gate (parseProposals) reads ≥1 proposal from it, OR it is a STRUCTURED
490
+ // abstain (isAbstain the text carries an actual {abstain:true}, NOT merely the word in prose). A deliberate
491
+ // "nothing to record" is a valid answer, NOT a reason to churn through the fallback (#52: the old /abstain/i
492
+ // substring let a model that just wrote the word pass as an abstain).
493
+ dream: (text) => parseProposals(text).length > 0 || isAbstain(text),
438
494
  };
439
495
 
440
496
  // Resolve a task's engine-success validator; an unregistered task is permissive (any non-empty text wins).
@@ -631,6 +687,18 @@ function writeBatonAtomic(root, body) {
631
687
  * @returns {Promise<{ wrote:boolean, blob:string, reason?:string }>}
632
688
  */
633
689
  async function runHandoff({ root, invoke = defaultInvoke, sleep }) {
690
+ // Stash-presence gate (#45): a synth with NO `.pending` stash has no session to process — no-op cleanly
691
+ // and write NO synth-log row. This is the secondary defense behind the spawn hook's once-per-session
692
+ // guard: a 2nd synth whose sibling already consumed+cleared the stash (or a manual --from-spawn with
693
+ // none) must not log a spurious `trivial`/`no-engine` row that pollutes the log + the baton-staleness
694
+ // signal. SPECIFIC to the ABSENT-file case — a present-but-trivial stash still logs `trivial` below.
695
+ // existsSync is total (never throws). NB-1: a stranded `.pending-handoff` can linger here (a concurrent /
696
+ // no-session-id race cleared `.pending` after a sibling re-raised both) — release session-start's hold
697
+ // before returning, or its holdForHandoff waits the full cap. rmQuiet is best-effort/total.
698
+ if (!fs.existsSync(continuityPath(root, PENDING))) {
699
+ rmQuiet(continuityPath(root, PENDING_HANDOFF));
700
+ return { wrote: false, blob: '', reason: 'no-stash' };
701
+ }
634
702
  let wrote = false;
635
703
  let reason;
636
704
  let safeBlob = ''; // the redacted blob, returned so dream can reuse it in memory (auto-memory-04).
@@ -693,37 +761,61 @@ function dreamAdapter() {
693
761
  }
694
762
 
695
763
  /**
696
- * Parse the engine's dream output into a proposals array. The model is asked for STRICT JSON, but a real
697
- * model may wrap it in prose or ```json fences — so we extract the first balanced {...} / [...] span and
698
- * parse it. An `{ abstain:true }` or anything unparseable / shapeless yields [] (write nothing). PURE.
764
+ * Extract the first JSON value from model output. The model is asked for STRICT JSON, but a real model may
765
+ * wrap it in prose or ```json fences — so on a direct-parse miss we grab the first balanced {} / [] span and
766
+ * parse that. Returns the parsed value, or null when nothing parses. PURE + total — never throws. This is the
767
+ * SINGLE tolerant parse shared by both parseProposals and isAbstain so the two can never diverge (#52).
699
768
  * @param {string} text the engine output
700
- * @returns {Array} the proposals (possibly empty)
769
+ * @returns {object|Array|null}
701
770
  */
702
- function parseProposals(text) {
771
+ function firstJsonValue(text) {
703
772
  const s = String(text || '');
704
- let parsed = null;
705
773
  try {
706
- parsed = JSON.parse(s);
774
+ return JSON.parse(s);
707
775
  } catch {
708
776
  // tolerate prose/fences around the JSON: grab the first { … } or [ … ] span and try that.
709
777
  const start = s.search(/[[{]/);
710
- if (start === -1) return [];
778
+ if (start === -1) return null;
711
779
  const open = s[start];
712
780
  const close = open === '{' ? '}' : ']';
713
781
  const end = s.lastIndexOf(close);
714
- if (end <= start) return [];
782
+ if (end <= start) return null;
715
783
  try {
716
- parsed = JSON.parse(s.slice(start, end + 1));
784
+ return JSON.parse(s.slice(start, end + 1));
717
785
  } catch {
718
- return [];
786
+ return null;
719
787
  }
720
788
  }
789
+ }
790
+
791
+ /**
792
+ * Parse the engine's dream output into a proposals array (via firstJsonValue's tolerant extraction). An
793
+ * `{ abstain:true }` or anything unparseable / shapeless yields [] (write nothing). PURE.
794
+ * @param {string} text the engine output
795
+ * @returns {Array} the proposals (possibly empty)
796
+ */
797
+ function parseProposals(text) {
798
+ const parsed = firstJsonValue(text);
721
799
  if (parsed && typeof parsed === 'object' && parsed.abstain === true) return [];
722
800
  if (Array.isArray(parsed)) return parsed;
723
801
  if (parsed && typeof parsed === 'object' && Array.isArray(parsed.proposals)) return parsed.proposals;
724
802
  return [];
725
803
  }
726
804
 
805
+ /**
806
+ * A STRUCTURED dream abstain (#52): the output carries an actual `{ abstain:true }` — not merely the word
807
+ * "abstain" somewhere in prose. Reuses firstJsonValue's extraction (clean / fenced / prose-wrapped JSON all
808
+ * resolve) and returns true only when the parsed object literally has `abstain === true`. PURE + total — never
809
+ * throws. The dream engine-success validator uses it so a model that just writes the word does NOT pass as a
810
+ * deliberate "nothing to record" (the old broad /abstain/i substring did).
811
+ * @param {string} text the engine output
812
+ * @returns {boolean}
813
+ */
814
+ function isAbstain(text) {
815
+ const parsed = firstJsonValue(text);
816
+ return !!(parsed && typeof parsed === 'object' && !Array.isArray(parsed) && parsed.abstain === true);
817
+ }
818
+
727
819
  /** Run a dream.cjs subcommand in-process-but-separate (spawnSync node), rooted at the install. Parses its
728
820
  * JSON stdout; a non-zero exit / unparseable output → null (the caller treats it as "no result"). */
729
821
  function runDreamCli(root, args) {
@@ -750,17 +842,19 @@ function writeTemp(root, tag, content) {
750
842
  * the dream.cjs gate (check → stage accepted → commit, all `--source`-verified against the blob), and
751
843
  * returns the committed slugs. Writes nothing on a trivial blob / abstain / empty-or-rejected set. Cleans
752
844
  * up its temp files. Never throws (a fault degrades to "wrote nothing").
753
- * @param {{ root:string, blob:string, invoke?:Function }} opts
845
+ * @param {{ root:string, blob:string, invoke?:Function, sleep?:Function }} opts
754
846
  * @returns {Promise<{ written:string[], reason?:string }>}
755
847
  */
756
- async function runDream({ root, blob, invoke = defaultInvoke }) {
848
+ async function runDream({ root, blob, invoke = defaultInvoke, sleep }) {
757
849
  const temps = [];
758
850
  try {
759
851
  if (!blob || blob.trim().length < TRIVIAL_BLOB_MIN) return { written: [], reason: 'trivial' };
760
852
  const config = loadConfig(root);
761
853
  const apiKey = loadEnv(root).GEMINI_API_KEY;
762
854
  const safeBlob = redactSecrets(blob); // scrub BEFORE the blob egresses to the external model.
763
- const text = await synthesize({ task: 'dream', prompt: PROMPTS.dream, blob: safeBlob, config, apiKey, invoke });
855
+ // `sleep` is threaded to the engine retry loop (default real backoff in production; tests inject a no-op so
856
+ // a validate-fail dream — which exhausts ENGINE_ATTEMPTS × ENGINE_BACKOFF_MS — runs instantly, #52).
857
+ const text = await synthesize({ task: 'dream', prompt: PROMPTS.dream, blob: safeBlob, config, apiKey, invoke, sleep });
764
858
  const proposals = parseProposals(text);
765
859
  if (proposals.length === 0) return { written: [], reason: 'abstain' };
766
860
 
@@ -900,7 +994,9 @@ module.exports = {
900
994
  buildClaudeSpec,
901
995
  buildGeminiSpec,
902
996
  parseGeminiResponse,
997
+ parseGeminiResult,
903
998
  parseProposals,
999
+ isAbstain,
904
1000
  defaultInvoke,
905
1001
  synthesize,
906
1002
  runHandoff,