@gcunharodrigues/wrxn 0.13.2 → 0.14.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.
@@ -70,7 +70,11 @@ FORMAT (markdown, exactly these sections; omit one only if empty):
70
70
  **Files/artifacts** - created/changed, with exact path.
71
71
  **Next step** - the immediate, concrete next action.
72
72
  **Open / to confirm** - pending items, blockers, questions.
73
- **Don't repeat** - dead ends already tried / gotchas discovered.`;
73
+ **Don't repeat** - dead ends already tried / gotchas discovered.
74
+
75
+ OUTPUT (critical — this text becomes the durable handoff verbatim):
76
+ - Output ONLY the handoff document itself: start at the first "**TL;DR**" line.
77
+ - NO preamble, NO commentary, NO thinking, NO "Let me…"/"Here is…" lead-in, NO closing remarks, NO code fences around the document. Emit the markdown and nothing else.`;
74
78
 
75
79
  // The dream task: propose durable wiki pages from the session, each grounded in a SUBSTANTIVE VERBATIM
76
80
  // quote copied from the transcript. The quote rule is load-bearing: auto-dream is unattended, so the only
@@ -361,42 +365,89 @@ async function defaultInvoke(spec) {
361
365
  // caller writes nothing (fail-safe). NEVER throws — an invoker error / missing key / missing CLI is a
362
366
  // per-engine failure that degrades to the next engine, then to null.
363
367
 
368
+ // ── transient-spawn retry (synth-handoff-fix-01) ────────────────────────────────
369
+ // The detached SessionEnd child's FIRST `claude -p` call after the parent session tears down
370
+ // intermittently returns no output (`ok:false`), so a single flaky call used to cost the whole handoff
371
+ // baton (the dream call moments later succeeds). We retry the engine SPAWN at this single seam, so both
372
+ // the handoff and the dream calls are hardened by one change. A transient failure returns fast, so the
373
+ // bounded retries stay well within the 180s session-start hold-cap. Only a transient `ok:false` is
374
+ // retried — the `gemini`-no-key early-out below still fails immediately (no key → no request).
375
+ const ENGINE_ATTEMPTS = 3; // total attempts (1 try + 2 retries).
376
+ const ENGINE_BACKOFF_MS = 1500; // fixed short backoff between attempts.
377
+
378
+ // The real backoff (the production path). Atomics.wait blocks without a busy-spin (node stdlib). Tests
379
+ // inject a no-op sleep and never reach this. Mirrors session-start.cjs's sleepMs.
380
+ function defaultSleep(ms) {
381
+ try {
382
+ Atomics.wait(new Int32Array(new SharedArrayBuffer(4)), 0, 0, ms);
383
+ } catch {
384
+ /* SharedArrayBuffer unavailable → degrade to no wait (the bounded attempt count still caps us) */
385
+ }
386
+ }
387
+
364
388
  /**
365
- * Run a single engine through the invoker; return its text or null. A `gemini` engine with no API key
366
- * fails WITHOUT calling the invoker (no key → no request). An invoker that throws is caught → null.
389
+ * Run a single engine through the invoker; return `{ text, attempts }` (text null when no output). A
390
+ * `gemini` engine with no API key fails WITHOUT calling the invoker (no key → no request, attempts 0). A
391
+ * transient `ok:false` is retried up to ENGINE_ATTEMPTS with a fixed backoff via the injected sleep; the
392
+ * first non-empty text wins. An invoker that throws is caught → counts as a failed attempt, then null.
393
+ * @returns {Promise<{ text: string|null, attempts: number }>}
367
394
  */
368
- async function runEngine(engine, { prompt, blob, apiKey, invoke }) {
369
- if (!engine || !engine.engine) return null;
370
- try {
371
- let spec;
372
- if (engine.engine === 'claude') {
373
- spec = buildClaudeSpec({ model: engine.model, prompt, blob });
374
- } else if (engine.engine === 'gemini') {
375
- if (!apiKey) return null; // missing key fails this engine (→ fallback / null), never throws.
376
- spec = buildGeminiSpec({ model: engine.model, prompt, blob, apiKey });
377
- } else {
378
- return null; // unknown engine name → skip.
395
+ async function runEngine(engine, { prompt, blob, apiKey, invoke, sleep = defaultSleep }) {
396
+ if (!engine || !engine.engine) return { text: null, attempts: 0 };
397
+ let spec;
398
+ if (engine.engine === 'claude') {
399
+ spec = buildClaudeSpec({ model: engine.model, prompt, blob });
400
+ } else if (engine.engine === 'gemini') {
401
+ if (!apiKey) return { text: null, attempts: 0 }; // missing key fails this engine (→ fallback / null), no request, no retry.
402
+ spec = buildGeminiSpec({ model: engine.model, prompt, blob, apiKey });
403
+ } else {
404
+ return { text: null, attempts: 0 }; // unknown engine name → skip.
405
+ }
406
+ let attempts = 0;
407
+ for (let i = 0; i < ENGINE_ATTEMPTS; i++) {
408
+ attempts += 1;
409
+ let text = null;
410
+ try {
411
+ const r = await invoke(spec);
412
+ const t = r && r.ok && typeof r.text === 'string' ? r.text.trim() : '';
413
+ text = t || null;
414
+ } catch {
415
+ text = null; // an invoker throw is a transient failure for this attempt (degrade, never throw).
379
416
  }
380
- const r = await invoke(spec);
381
- const text = r && r.ok && typeof r.text === 'string' ? r.text.trim() : '';
382
- return text || null;
383
- } catch {
384
- return null;
417
+ if (text) return { text, attempts };
418
+ if (i < ENGINE_ATTEMPTS - 1) sleep(ENGINE_BACKOFF_MS); // back off before the next attempt (not after the last). sleep is synchronous (Atomics.wait / injected no-op) no await.
385
419
  }
420
+ return { text: null, attempts };
386
421
  }
387
422
 
388
423
  /**
389
- * Synthesize text for `task` from `prompt` + `blob`, resolving the engine per task (primary → fallback).
390
- * @param {{ task:string, prompt:string, blob:string, config?:object, apiKey?:string, invoke?:Function }} opts
391
- * @returns {Promise<string|null>} the synthesized text, or null if no engine produced any.
424
+ * Synthesize text for `task` from `prompt` + `blob`, resolving the engine per task (primary → fallback),
425
+ * also reporting WHICH engine produced the text and HOW MANY attempts the producing engine spent (the
426
+ * total attempts across all engines tried when none produced output) the diagnosability the synth log
427
+ * records. The transient-spawn retry lives in runEngine, so both engines are hardened here.
428
+ * @param {{ task:string, prompt:string, blob:string, config?:object, apiKey?:string, invoke?:Function, sleep?:Function }} opts
429
+ * @returns {Promise<{ text:string|null, engine:string|null, attempts:number }>}
392
430
  */
393
- async function synthesize({ task, prompt, blob, config, apiKey, invoke = defaultInvoke }) {
431
+ async function synthesizeDetailed({ task, prompt, blob, config, apiKey, invoke = defaultInvoke, sleep }) {
394
432
  const { primary, fallback } = resolveTask(config || DEFAULTS, task);
433
+ let attempts = 0;
395
434
  for (const engine of [primary, fallback]) {
396
- const text = await runEngine(engine, { prompt, blob, apiKey, invoke });
397
- if (text) return text;
435
+ const r = await runEngine(engine, { prompt, blob, apiKey, invoke, sleep });
436
+ attempts += r.attempts;
437
+ if (r.text) return { text: r.text, engine: engine && engine.engine, attempts };
398
438
  }
399
- return null;
439
+ return { text: null, engine: null, attempts };
440
+ }
441
+
442
+ /**
443
+ * Synthesize text for `task` from `prompt` + `blob`, resolving the engine per task (primary → fallback).
444
+ * Thin text-only wrapper over synthesizeDetailed (preserves the slice-02/04 contract).
445
+ * @param {{ task:string, prompt:string, blob:string, config?:object, apiKey?:string, invoke?:Function, sleep?:Function }} opts
446
+ * @returns {Promise<string|null>} the synthesized text, or null if no engine produced any.
447
+ */
448
+ async function synthesize(opts) {
449
+ const { text } = await synthesizeDetailed(opts);
450
+ return text;
400
451
  }
401
452
 
402
453
  // ── the handoff path (auto-memory-03): stash → blob → synth → baton → clear marker ──
@@ -410,6 +461,9 @@ const CONTINUITY_REL = ['.wrxn', 'continuity'];
410
461
  const BATON = 'latest.md';
411
462
  const PENDING = '.pending';
412
463
  const PENDING_HANDOFF = '.pending-handoff';
464
+ // One outcome line per synth run lands here so a missed baton is never silent (synth-handoff-fix-01).
465
+ // Install state under .wrxn/continuity/ — gitignored by `wrxn init`, NEVER shipped in the payload manifest.
466
+ const SYNTH_LOG = '.synth.log';
413
467
  // Below this many chars of blob the session is trivial/empty — write nothing, no model spend (PRD story 18).
414
468
  const TRIVIAL_BLOB_MIN = 40;
415
469
 
@@ -417,6 +471,66 @@ function continuityPath(root, ...rel) {
417
471
  return path.join(root, ...CONTINUITY_REL, ...rel);
418
472
  }
419
473
 
474
+ // The session id comes from the untrusted `.pending` stash (transcript-adjacent). A control char in it
475
+ // (newline/tab) would inject extra rows into the tab-separated .synth.log — forging fake outcomes and
476
+ // wrecking the log's whole purpose (trustworthy diagnosability). Strip ALL C0/C1 control chars and cap
477
+ // the length before the id enters the line. PURE and total — it never throws (so it can't break the
478
+ // best-effort log) and only touches the log field (never the baton).
479
+ const LOG_FIELD_MAX = 64;
480
+ function sanitizeLogField(v) {
481
+ // eslint-disable-next-line no-control-regex
482
+ return String(v == null ? '' : v).replace(/[\x00-\x1f\x7f-\x9f]/g, '').slice(0, LOG_FIELD_MAX);
483
+ }
484
+
485
+ /**
486
+ * Append exactly one tab-separated outcome line to `.wrxn/continuity/.synth.log` — timestamp, session id
487
+ * (or `-`), task, engine (or `-`), attempts, outcome (`wrote`|`trivial`|`no-engine`|`error…`). Best-effort
488
+ * and FAIL-OPEN: a logging fault is swallowed so it can NEVER affect the handoff (the diagnosability log
489
+ * must not become a new failure mode). One `appendFileSync` per run. The untrusted session id is
490
+ * control-char-stripped + length-capped so a hostile id can't forge extra log rows.
491
+ * @param {{ sessionId?:string, task:string, engine?:string|null, attempts?:number, outcome:string }} rec
492
+ */
493
+ function appendSynthLog(root, { sessionId, task, engine, attempts, outcome }) {
494
+ try {
495
+ const dir = continuityPath(root);
496
+ fs.mkdirSync(dir, { recursive: true });
497
+ const id = sanitizeLogField(sessionId) || '-'; // untrusted stash value — strip control chars, cap length.
498
+ const line = [
499
+ new Date().toISOString(),
500
+ id,
501
+ task || '-',
502
+ engine || '-',
503
+ `attempts=${attempts || 0}`,
504
+ outcome || '-',
505
+ ].join('\t');
506
+ fs.appendFileSync(path.join(dir, SYNTH_LOG), line + '\n');
507
+ } catch {
508
+ /* the log is best-effort — a write fault must never affect the handoff */
509
+ }
510
+ }
511
+
512
+ // ── preamble strip (synth-handoff-fix-01, QA F-01) ──────────────────────────────
513
+ // The output-only HANDOFF_PROMPT directive is non-deterministic: a live run still sometimes opens with a
514
+ // conversational preamble (e.g. "Per the session baton, the operator chose…") BEFORE the required
515
+ // **TL;DR**. The durable baton (PRD story 5) must contain ONLY the handoff document, so we deterministically
516
+ // drop anything before the canonical `**TL;DR` marker. FAIL-OPEN: a handoff with no marker is returned
517
+ // unchanged — the prompt already requires a TL;DR, so this is a safety net, never a mangler.
518
+ const TLDR_MARKER = '**TL;DR';
519
+
520
+ /**
521
+ * Drop any model preamble before the canonical bolded TL;DR marker. Returns the text from the first
522
+ * `**TL;DR` occurrence onward (left-trimmed). If the marker is absent, returns the text unchanged
523
+ * (fail-open). PURE and total — never throws.
524
+ * @param {string} text
525
+ * @returns {string}
526
+ */
527
+ function stripPreamble(text) {
528
+ const s = String(text == null ? '' : text);
529
+ const i = s.indexOf(TLDR_MARKER);
530
+ if (i === -1) return s; // no marker → never mangle (the prompt already requires a TL;DR).
531
+ return s.slice(i).replace(/^\s+/, '');
532
+ }
533
+
420
534
  // ── secret redaction (PRD story 19) ─────────────────────────────────────────────
421
535
  // A model can echo a credential it saw in the transcript into its handoff. Scrub the body before it
422
536
  // becomes the durable baton. Pattern-based (high-signal vendor token shapes, JWTs incl. Bearer
@@ -493,12 +607,16 @@ function writeBatonAtomic(root, body) {
493
607
  * @param {{ root:string, invoke?:Function }} opts
494
608
  * @returns {Promise<{ wrote:boolean, blob:string, reason?:string }>}
495
609
  */
496
- async function runHandoff({ root, invoke = defaultInvoke }) {
610
+ async function runHandoff({ root, invoke = defaultInvoke, sleep }) {
497
611
  let wrote = false;
498
612
  let reason;
499
613
  let safeBlob = ''; // the redacted blob, returned so dream can reuse it in memory (auto-memory-04).
614
+ let engine = null; // which engine produced the baton (for the synth log).
615
+ let attempts = 0; // total engine attempts spent (retries included) — for the synth log.
616
+ let sessionId; // the session id from the stash, if present (for the synth log).
500
617
  try {
501
618
  const stash = readPending(root);
619
+ sessionId = stash.session_id;
502
620
  const blob = stash.transcript_path ? readTranscriptBlob(stash.transcript_path) : '';
503
621
  if (blob.trim().length < TRIVIAL_BLOB_MIN) {
504
622
  reason = 'trivial'; // empty/near-empty session — write nothing, spend no model call.
@@ -506,9 +624,12 @@ async function runHandoff({ root, invoke = defaultInvoke }) {
506
624
  const config = loadConfig(root);
507
625
  const apiKey = loadEnv(root).GEMINI_API_KEY;
508
626
  safeBlob = redactSecrets(blob); // scrub BEFORE the blob egresses to the external model (claude -p / off-box gemini POST).
509
- const text = await synthesize({ task: 'handoff', prompt: PROMPTS.handoff, blob: safeBlob, config, apiKey, invoke });
510
- if (text && text.trim()) {
511
- const body = redactSecrets(text); // scrub secrets BEFORE the durable baton is written.
627
+ const r = await synthesizeDetailed({ task: 'handoff', prompt: PROMPTS.handoff, blob: safeBlob, config, apiKey, invoke, sleep });
628
+ engine = r.engine;
629
+ attempts = r.attempts;
630
+ if (r.text && r.text.trim()) {
631
+ const stripped = stripPreamble(r.text); // drop any model preamble before the **TL;DR** (QA F-01).
632
+ const body = redactSecrets(stripped); // scrub secrets BEFORE the durable baton is written.
512
633
  writeBatonAtomic(root, body.endsWith('\n') ? body : body + '\n');
513
634
  wrote = true;
514
635
  } else {
@@ -518,6 +639,8 @@ async function runHandoff({ root, invoke = defaultInvoke }) {
518
639
  } catch (e) {
519
640
  reason = `error: ${(e && e.message) || e}`;
520
641
  } finally {
642
+ // One outcome line per synth run, so a missed baton is never silent (best-effort/fail-open).
643
+ appendSynthLog(root, { sessionId, task: 'handoff', engine, attempts, outcome: wrote ? 'wrote' : (reason || 'no-engine') });
521
644
  // Clear the handoff gate FIRST (releases SessionStart), then the pending marker. Always runs.
522
645
  rmQuiet(continuityPath(root, PENDING_HANDOFF));
523
646
  rmQuiet(continuityPath(root, PENDING));
@@ -725,7 +848,10 @@ async function run(args, { invoke = defaultInvoke, out = process.stdout, err = p
725
848
  err.write('memory-synth: no engine produced output (claude CLI unavailable and no Gemini key?) — wrote nothing\n');
726
849
  return 1;
727
850
  }
728
- out.write(text.endsWith('\n') ? text : text + '\n');
851
+ // Strip any model preamble before the **TL;DR** so the debug surface matches the durable baton (QA F-01).
852
+ // Fail-open for non-handoff tasks: their output has no TL;DR marker, so stripPreamble returns it unchanged.
853
+ const printed = task === 'handoff' ? stripPreamble(text) : text;
854
+ out.write(printed.endsWith('\n') ? printed : printed + '\n');
729
855
  return 0;
730
856
  }
731
857
 
@@ -757,5 +883,6 @@ module.exports = {
757
883
  runHandoff,
758
884
  runDream,
759
885
  redactSecrets,
886
+ stripPreamble,
760
887
  run,
761
888
  };