@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.
- package/bin/wrxn.cjs +10 -1
- package/lib/executor.cjs +110 -3
- package/lib/install.cjs +13 -0
- package/lib/release.cjs +13 -1
- package/manifest.json +30 -0
- package/package.json +2 -2
- package/payload/.claude/hooks/emit-event.cjs +181 -0
- package/payload/.claude/hooks/prune.cjs +252 -0
- package/payload/.claude/hooks/recall-surface.cjs +465 -52
- package/payload/.claude/hooks/reward.cjs +244 -0
- package/payload/.claude/hooks/session-end-reward.cjs +265 -0
- package/payload/.claude/hooks/session-start.cjs +87 -2
- package/payload/.claude/hooks/sidecar.cjs +93 -0
- package/payload/.claude/hooks/synapse-engine.cjs +45 -3
- package/payload/.claude/hooks/wiki-lint.cjs +70 -1
- package/payload/.claude/settings.json +9 -2
- package/payload/.mcp.json +1 -1
- package/payload/.wrxn/dream.cjs +329 -5
- package/payload/.wrxn/events/.gitkeep +0 -0
- package/payload/.wrxn/harvest.cjs +159 -4
- package/payload/.wrxn/memory-synth.cjs +158 -31
|
@@ -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
|
|
366
|
-
* fails WITHOUT calling the invoker (no key → no request).
|
|
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
|
-
|
|
371
|
-
|
|
372
|
-
|
|
373
|
-
|
|
374
|
-
}
|
|
375
|
-
|
|
376
|
-
|
|
377
|
-
}
|
|
378
|
-
|
|
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
|
-
|
|
381
|
-
|
|
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
|
-
*
|
|
391
|
-
*
|
|
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
|
|
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
|
|
397
|
-
|
|
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
|
|
510
|
-
|
|
511
|
-
|
|
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
|
-
|
|
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
|
};
|