@gcunharodrigues/wrxn 0.14.0 → 0.16.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/manifest.json CHANGED
@@ -528,6 +528,11 @@
528
528
  "class": "seeded",
529
529
  "profile": "project"
530
530
  },
531
+ {
532
+ "path": ".env.example",
533
+ "class": "managed",
534
+ "profile": "project"
535
+ },
531
536
  {
532
537
  "path": ".wrxn/sync.cjs",
533
538
  "class": "managed",
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@gcunharodrigues/wrxn",
3
- "version": "0.14.0",
3
+ "version": "0.16.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"
@@ -201,6 +201,99 @@ function holdForHandoff({ root, capMs = HOLD_CAP_MS, now = Date.now, sleep } = {
201
201
  }
202
202
  }
203
203
 
204
+ // ── baton-staleness guard (#51) ─────────────────────────────────────────────────
205
+ // The handoff baton is surfaced as the resume point with no health check, so a baton frozen by a failed
206
+ // SessionEnd synth (no-engine / error) is shown as current — silently (real incident 2026-06-22→23: the
207
+ // baton froze ~20h while .synth.log logged no-engine from later sessions). This guard reads the synth log +
208
+ // the baton mtime and, when the latest synth attempt failed and it is NOT a same-session double-spawn (the
209
+ // session that wrote the baton failing again right after — #45), surfaces a visible warning. The DECISION is
210
+ // pure (no IO, no clock — the holdDecision idiom); the hook does the IO and formats the age where it holds
211
+ // the clock. NO polling/sleeping — one file read + one stat.
212
+
213
+ const SYNTH_LOG_REL = ['.wrxn', 'continuity', '.synth.log'];
214
+
215
+ // A synth row is a FAILURE iff its outcome is `no-engine` or an `error…` string (`wrote`/`trivial` = healthy).
216
+ function isFailure(outcome) {
217
+ const o = String(outcome || '');
218
+ return o === 'no-engine' || /^error/.test(o);
219
+ }
220
+
221
+ // Parse the tab-separated synth log into chronological rows { timestampMs, sessionId, outcome }, dropping
222
+ // any line we cannot resolve (malformed / unparseable timestamp) — "newest-resolvable last". The sessionId
223
+ // (field 1, `-` when absent) is what discriminates a double-spawn from genuine rot. PURE + total, never throws.
224
+ function parseSynthLog(text) {
225
+ const rows = [];
226
+ for (const line of String(text || '').split('\n')) {
227
+ if (!line.trim()) continue;
228
+ const f = line.split('\t');
229
+ if (f.length < 6) continue; // not a full outcome row → skip (fail-open)
230
+ const ts = Date.parse(f[0]);
231
+ if (!Number.isFinite(ts)) continue; // unresolvable timestamp → drop
232
+ rows.push({ timestampMs: ts, sessionId: f[1], outcome: f.slice(5).join('\t').trim() });
233
+ }
234
+ return rows;
235
+ }
236
+
237
+ // Human age for the warning line (computed where the clock is, so batonStaleness stays clock-free). PURE.
238
+ function formatAge(ms) {
239
+ const m = Math.max(0, Math.round(Number(ms) / 60000));
240
+ if (m < 60) return `${m}m`;
241
+ const h = Math.floor(m / 60);
242
+ if (h < 24) return `${h}h`;
243
+ const d = Math.floor(h / 24);
244
+ const rem = h % 24;
245
+ return rem ? `${d}d ${rem}h` : `${d}d`;
246
+ }
247
+
248
+ // Sanitize the echoed synth outcome before it is interpolated into the orientation block (sec-F1). An
249
+ // `error: …` row carries a FREE-FORM message, so a crafted/corrupt log row could smuggle control chars or a
250
+ // forged `</wrxn-orientation>` close tag into additionalContext. Strip control chars (incl CR/LF/tab), drop
251
+ // angle brackets so no tag can be forged, and cap the length. PURE + total. (Mirrors memory-synth's
252
+ // sanitizeLogField idiom — each install-only hook is self-contained, node stdlib only.)
253
+ function sanitizeOutcome(s) {
254
+ return String(s == null ? '' : s)
255
+ // eslint-disable-next-line no-control-regex
256
+ .replace(/[\x00-\x1f\x7f-\x9f]/g, '')
257
+ .replace(/[<>]/g, '')
258
+ .slice(0, 120);
259
+ }
260
+
261
+ /**
262
+ * Pure baton-staleness decision (#51). Given the baton's last-write time and the synth-log rows
263
+ * (chronological, newest-resolvable last, each carrying its sessionId), return the FAILING outcome to name
264
+ * in the warning, or null when the baton is healthy/fresh. Warn IFF the newest row failed (`no-engine` |
265
+ * `error…`) AND the baton predates that newest row AND it is NOT a same-session double-spawn.
266
+ *
267
+ * Double-spawn vs genuine-rot is discriminated by SESSION ID, NOT by timestamp. The session that wrote the
268
+ * CURRENT baton is the session of the MOST-RECENT `wrote` row (every successful synth logs a `wrote` row in
269
+ * runHandoff's finally, a few ms AFTER it set the baton mtime — so that row's timestamp is ALWAYS ≥ the baton
270
+ * mtime, and a timestamp test can never tell the two apart). The #45 double-spawn logs `wrote` then a spurious
271
+ * `no-engine` ~2s later under the SAME session → suppress. A genuine freeze has later failures from DIFFERENT
272
+ * sessions (real incident 2026-06-22→23: baton written by 6898c0a9, then 7b69b97c / 39e5754b no-engine hours
273
+ * later) → warn. A missing/`-` session id on either side cannot confirm a double-spawn → default to warn (a
274
+ * loud false-positive beats silent rot). PURE + total: no IO, no clock, never throws.
275
+ * @param {{ batonMtimeMs:number, rows:Array<{timestampMs:number, sessionId?:string, outcome:string}> }} [input]
276
+ * @returns {string|null} the failing outcome to name, or null when healthy
277
+ */
278
+ function batonStaleness({ batonMtimeMs, rows } = {}) {
279
+ if (!Array.isArray(rows) || rows.length === 0) return null; // no rows → nothing to judge
280
+ if (typeof batonMtimeMs !== 'number' || !Number.isFinite(batonMtimeMs)) return null; // no usable mtime
281
+ const newest = rows[rows.length - 1];
282
+ if (!newest || !isFailure(newest.outcome)) return null; // latest run healthy → fresh
283
+ if (Number.isFinite(newest.timestampMs) && newest.timestampMs <= batonMtimeMs) return null; // baton at/after newest → fresh
284
+ // The session that wrote the CURRENT baton = the session of the most-recent `wrote` row.
285
+ let wroteSession;
286
+ for (const r of rows) {
287
+ if (r && r.outcome === 'wrote') wroteSession = r.sessionId;
288
+ }
289
+ const sameSession =
290
+ !!wroteSession && wroteSession !== '-' &&
291
+ !!newest.sessionId && newest.sessionId !== '-' &&
292
+ wroteSession === newest.sessionId;
293
+ if (sameSession) return null; // same-session double-spawn → not stale
294
+ return newest.outcome; // genuine rot (or unconfirmable) → name the failing outcome
295
+ }
296
+
204
297
  function main() {
205
298
  let consumed = '';
206
299
  try {
@@ -241,6 +334,24 @@ function main() {
241
334
  const baton = readBaton(root);
242
335
  if (baton && baton.trim()) {
243
336
  parts.push('', 'Resume — deliberate handoff baton (.wrxn/continuity/latest.md):', baton.trim());
337
+ // #51: warn if the latest SessionEnd synth failed and no successful write has refreshed the baton since
338
+ // — a frozen baton must not be surfaced as current silently. Fail-open: any read/stat/parse fault → no
339
+ // warning, never a throw. Pure file reads + a stat only (no polling/sleeping beyond the hold cap above).
340
+ try {
341
+ const rows = parseSynthLog(readFileOr(path.join(root, ...SYNTH_LOG_REL), ''));
342
+ const batonMtimeMs = fs.statSync(path.join(root, '.wrxn', 'continuity', 'latest.md')).mtimeMs;
343
+ const failing = batonStaleness({ batonMtimeMs, rows });
344
+ if (failing) {
345
+ const age = formatAge(Date.now() - batonMtimeMs);
346
+ const outcome = sanitizeOutcome(failing); // sec-F1: never echo a raw log field into the orientation
347
+ parts.push(
348
+ '',
349
+ `WARNING — stale handoff baton: the latest memory-synth run failed ("${outcome}") and the baton has not been refreshed for ${age}. It may not reflect the last session — see .wrxn/continuity/.synth.log.`,
350
+ );
351
+ }
352
+ } catch {
353
+ /* fail-open: a staleness-check fault must never block orientation (#51 AC3) */
354
+ }
244
355
  } else {
245
356
  parts.push('', 'Resume — no prior handoff.');
246
357
  }
@@ -261,4 +372,4 @@ if (require.main === module) {
261
372
  }
262
373
  }
263
374
 
264
- module.exports = { holdDecision, holdForHandoff, HOLD_CAP_MS, stampStartHead, resolveGitHead, BASELINE_DIR_REL };
375
+ module.exports = { holdDecision, holdForHandoff, HOLD_CAP_MS, stampStartHead, resolveGitHead, BASELINE_DIR_REL, batonStaleness };
@@ -0,0 +1,6 @@
1
+ # WRXN memory-synth — secret config. Copy this file to `.env` and fill in the value below.
2
+ # `.env` is gitignored (the secret is never committed); this `.env.example` is the tracked template.
3
+ #
4
+ # GEMINI_API_KEY powers the `gemini` engine for handoff + dream synthesis — the shipped DEFAULT
5
+ # primary for both. Without it, synth falls back to `claude`, which uses your CLI auth (no key needed).
6
+ GEMINI_API_KEY=
@@ -390,7 +390,11 @@ const NEGATIVE_FILTERS = [
390
390
  // a transient environment / setup failure — not a durable property of the system. (The bare adjectives
391
391
  // `transient`/`intermittent` are intentionally NOT here: they false-positive on DI-lifetime decisions
392
392
  // like "services are registered transient" — the concrete error codes below carry the failure intent.)
393
- { reason: 'negative_filter_transient_failure', re: /\b(econnrefused|enoent|eaddrinuse|etimedout|connection refused|connection reset|timed out|time-?out|flak(e|y|ey)|rate[- ]?limit(ed)?|http 5\d\d|50[234]|port (already )?in use|address already in use|network (error|issue|glitch)|dns (error|failure))\b/ },
393
+ // SAME discipline for rate-limiting: the bare noun "rate limit" / adjective "rate-limited" is NOT matched
394
+ // (it false-positives on rate limiting AS A FEATURE — a token-bucket decision, a "rate-limited tier").
395
+ // Only the ERROR sense matches: a passive/verb "got rate-limited", a transitive "rate-limited us", or a
396
+ // "rate-limit(ed) error" / "rate limit exceeded" cue — the transient war-story, never the design.
397
+ { reason: 'negative_filter_transient_failure', re: /\b(econnrefused|enoent|eaddrinuse|etimedout|connection refused|connection reset|timed out|time-?out|flak(e|y|ey)|(?:got|get|getting|gets|was|were|been|being|keeps?|kept|repeatedly) rate[- ]?limited|rate[- ]?limited (?:us|me|them|by|again)|rate[- ]?limit(?:ed|ing)? (?:error|errors|exceeded)|http 5\d\d|50[234]|port (already )?in use|address already in use|network (error|issue|glitch)|dns (error|failure))\b/ },
394
398
  // a smoke / sanity / happy-path RESULT — proves nothing durable. Gated on a result word so a forward
395
399
  // decision ("we adopt smoke tests", "the happy path must stay fast") is NOT a false positive.
396
400
  { reason: 'negative_filter_smoke_test', re: /\bhello[- ]?world\b|\b(smoke[- ]?tests?|sanity[- ]?checks?|happy path)\s+(pass(ed|es|ing)?|ran|run|succeed(ed|s)?|works?|worked|green)\b/ },
@@ -25,12 +25,13 @@ const https = require('https');
25
25
  const { spawnSync } = require('child_process');
26
26
 
27
27
  // ── default tiering (PRD) ──────────────────────────────────────────────────────
28
- // handoff + dream default to claude/claude-sonnet-4-6, falling back to gemini/gemini-3.1-flash-lite.
28
+ // handoff + dream default to gemini/gemini-3.1-flash-lite, falling back to claude/claude-sonnet-4-6.
29
29
  // The seeded memory.config.json is this object serialized; an operator edits it and `wrxn update`
30
- // preserves it (seeded class).
30
+ // preserves it (seeded class). Claude stays the fallback so a keyless fresh install still writes a
31
+ // baton via the operator's CLI auth (no GEMINI_API_KEY → gemini gated → claude carries it).
31
32
  const DEFAULT_TASK = {
32
- primary: { engine: 'claude', model: 'claude-sonnet-4-6' },
33
- fallback: { engine: 'gemini', model: 'gemini-3.1-flash-lite' },
33
+ primary: { engine: 'gemini', model: 'gemini-3.1-flash-lite' },
34
+ fallback: { engine: 'claude', model: 'claude-sonnet-4-6' },
34
35
  };
35
36
  const DEFAULTS = {
36
37
  tasks: {
@@ -389,10 +390,13 @@ function defaultSleep(ms) {
389
390
  * Run a single engine through the invoker; return `{ text, attempts }` (text null when no output). A
390
391
  * `gemini` engine with no API key fails WITHOUT calling the invoker (no key → no request, attempts 0). A
391
392
  * 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
+ * first text that is non-empty AND passes the optional task `validate` predicate wins. A non-empty text
394
+ * that FAILS `validate` (unusable output — e.g. dream prose the gate can't parse) is a failed attempt →
395
+ * retried, then null, so synthesizeDetailed advances to the fallback. No validator → any non-empty wins.
396
+ * An invoker that throws is caught → counts as a failed attempt, then null.
393
397
  * @returns {Promise<{ text: string|null, attempts: number }>}
394
398
  */
395
- async function runEngine(engine, { prompt, blob, apiKey, invoke, sleep = defaultSleep }) {
399
+ async function runEngine(engine, { prompt, blob, apiKey, invoke, sleep = defaultSleep, validate }) {
396
400
  if (!engine || !engine.engine) return { text: null, attempts: 0 };
397
401
  let spec;
398
402
  if (engine.engine === 'claude') {
@@ -414,12 +418,30 @@ async function runEngine(engine, { prompt, blob, apiKey, invoke, sleep = default
414
418
  } catch {
415
419
  text = null; // an invoker throw is a transient failure for this attempt (degrade, never throw).
416
420
  }
417
- if (text) return { text, attempts };
421
+ // success = non-empty AND task-usable (validate). No validator → permissive: any non-empty text wins.
422
+ if (text && (!validate || validate(text))) return { text, attempts };
418
423
  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.
419
424
  }
420
425
  return { text: null, attempts };
421
426
  }
422
427
 
428
+ // ── per-task engine-success validators (synth-robustness #50) ───────────────────
429
+ // An engine attempt succeeds only when its text is non-empty AND passes the task's validator, so an engine
430
+ // that returns non-empty-but-UNUSABLE output exhausts its retries and synthesizeDetailed advances to the
431
+ // fallback — instead of the unusable primary "winning" while the downstream gate parses zero proposals and
432
+ // writes nothing. A task with NO registered validator is permissive (any non-empty text wins, exactly as
433
+ // before), so every existing caller is preserved.
434
+ 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),
438
+ };
439
+
440
+ // Resolve a task's engine-success validator; an unregistered task is permissive (any non-empty text wins).
441
+ function validatorFor(task) {
442
+ return TASK_VALIDATORS[task] || (() => true);
443
+ }
444
+
423
445
  /**
424
446
  * Synthesize text for `task` from `prompt` + `blob`, resolving the engine per task (primary → fallback),
425
447
  * also reporting WHICH engine produced the text and HOW MANY attempts the producing engine spent (the
@@ -430,9 +452,10 @@ async function runEngine(engine, { prompt, blob, apiKey, invoke, sleep = default
430
452
  */
431
453
  async function synthesizeDetailed({ task, prompt, blob, config, apiKey, invoke = defaultInvoke, sleep }) {
432
454
  const { primary, fallback } = resolveTask(config || DEFAULTS, task);
455
+ const validate = validatorFor(task); // task-aware success: dream needs parseable proposals; handoff is permissive.
433
456
  let attempts = 0;
434
457
  for (const engine of [primary, fallback]) {
435
- const r = await runEngine(engine, { prompt, blob, apiKey, invoke, sleep });
458
+ const r = await runEngine(engine, { prompt, blob, apiKey, invoke, sleep, validate });
436
459
  attempts += r.attempts;
437
460
  if (r.text) return { text: r.text, engine: engine && engine.engine, attempts };
438
461
  }
@@ -1,12 +1,12 @@
1
1
  {
2
2
  "tasks": {
3
3
  "handoff": {
4
- "primary": { "engine": "claude", "model": "claude-sonnet-4-6" },
5
- "fallback": { "engine": "gemini", "model": "gemini-3.1-flash-lite" }
4
+ "primary": { "engine": "gemini", "model": "gemini-3.1-flash-lite" },
5
+ "fallback": { "engine": "claude", "model": "claude-sonnet-4-6" }
6
6
  },
7
7
  "dream": {
8
- "primary": { "engine": "claude", "model": "claude-sonnet-4-6" },
9
- "fallback": { "engine": "gemini", "model": "gemini-3.1-flash-lite" }
8
+ "primary": { "engine": "gemini", "model": "gemini-3.1-flash-lite" },
9
+ "fallback": { "engine": "claude", "model": "claude-sonnet-4-6" }
10
10
  }
11
11
  }
12
12
  }