@gcunharodrigues/wrxn 0.15.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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@gcunharodrigues/wrxn",
3
- "version": "0.15.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 };
@@ -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/ },
@@ -390,10 +390,13 @@ function defaultSleep(ms) {
390
390
  * Run a single engine through the invoker; return `{ text, attempts }` (text null when no output). A
391
391
  * `gemini` engine with no API key fails WITHOUT calling the invoker (no key → no request, attempts 0). A
392
392
  * transient `ok:false` is retried up to ENGINE_ATTEMPTS with a fixed backoff via the injected sleep; the
393
- * 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.
394
397
  * @returns {Promise<{ text: string|null, attempts: number }>}
395
398
  */
396
- async function runEngine(engine, { prompt, blob, apiKey, invoke, sleep = defaultSleep }) {
399
+ async function runEngine(engine, { prompt, blob, apiKey, invoke, sleep = defaultSleep, validate }) {
397
400
  if (!engine || !engine.engine) return { text: null, attempts: 0 };
398
401
  let spec;
399
402
  if (engine.engine === 'claude') {
@@ -415,12 +418,30 @@ async function runEngine(engine, { prompt, blob, apiKey, invoke, sleep = default
415
418
  } catch {
416
419
  text = null; // an invoker throw is a transient failure for this attempt (degrade, never throw).
417
420
  }
418
- 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 };
419
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.
420
424
  }
421
425
  return { text: null, attempts };
422
426
  }
423
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
+
424
445
  /**
425
446
  * Synthesize text for `task` from `prompt` + `blob`, resolving the engine per task (primary → fallback),
426
447
  * also reporting WHICH engine produced the text and HOW MANY attempts the producing engine spent (the
@@ -431,9 +452,10 @@ async function runEngine(engine, { prompt, blob, apiKey, invoke, sleep = default
431
452
  */
432
453
  async function synthesizeDetailed({ task, prompt, blob, config, apiKey, invoke = defaultInvoke, sleep }) {
433
454
  const { primary, fallback } = resolveTask(config || DEFAULTS, task);
455
+ const validate = validatorFor(task); // task-aware success: dream needs parseable proposals; handoff is permissive.
434
456
  let attempts = 0;
435
457
  for (const engine of [primary, fallback]) {
436
- const r = await runEngine(engine, { prompt, blob, apiKey, invoke, sleep });
458
+ const r = await runEngine(engine, { prompt, blob, apiKey, invoke, sleep, validate });
437
459
  attempts += r.attempts;
438
460
  if (r.text) return { text: r.text, engine: engine && engine.engine, attempts };
439
461
  }