@gcunharodrigues/wrxn 0.11.0 → 0.12.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.
@@ -6,11 +6,14 @@
6
6
  // (node stdlib only). The dream SKILL (the LLM, dream-02) PROPOSES; THIS adapter JUDGES (a
7
7
  // deterministic gate) and records — "bad memory is worse than no memory".
8
8
  //
9
- // In THIS slice the adapter is driven by hand-fed proposal JSON (no LLM). Four subcommands:
9
+ // In THIS slice the adapter is driven by hand-fed proposal JSON (no LLM). Three subcommands:
10
10
  // check <proposal.json | batch.json> run the Validation gate.
11
11
  // · a single Proposal object → a single Verdict { ok, reason? }.
12
12
  // · an array / { proposals:[…] } / { abstain:true } → a batch result
13
13
  // { abstained, accepted[], rejected[{index,slug,reason}] } (applies the ≤5 run cap + restraint).
14
+ // · --source <file> (optional, auto-memory-01): the session transcript blob. When present,
15
+ // every evidence quote must verifiably appear in it (normalized substring) or the proposal
16
+ // is rejected quote_not_in_source. Absent ⇒ no quote-verify (the trusted manual-dream path).
14
17
  // stage <batch.json> record the VALIDATED (accepted) batch into the audit trail
15
18
  // under .wrxn/dream/ as .jsonl (NEVER .md, so recon's prose ingestion never recalls a
16
19
  // staged-but-unapproved proposal). Nothing is written to the wiki.
@@ -22,21 +25,18 @@
22
25
  // can never reach recall even if its slug is force-approved. Additive + dedup-SKIP; a slug not
23
26
  // staged (not_staged) or one that fails re-validation is recorded skipped with the reason, and
24
27
  // the rest of the batch still writes. Then the outcome is appended to the .wrxn/dream/ audit log.
25
- // set-focus <focus.json> write/OVERWRITE the durable standing-focus slot
26
- // `_slots/current-focus.md` (input { title?, body }) via the wiki adapter's --force path, then
27
- // log it. This is the LONE update-exception: only the focus slot may be overwritten; the
28
- // knowledge gate/commit above stay additive + dedup-skip. The focus slot is DISJOINT from the
29
- // continuity baton (`.wrxn/continuity/latest.md`, single-writer = the handoff skill) — set-focus
30
- // NEVER reads or writes that baton (different path, different writer: the continuity doctrine).
28
+ // · --source <file> (optional, auto-memory-01): re-verifies every quote at the write boundary,
29
+ // so a hallucinated proposal is blocked from recall even if its slug is force-approved.
31
30
  //
32
- // Flag: --root <dir> (override the install-root walk-up; mainly for tests).
31
+ // Flags: --root <dir> (override the install-root walk-up; mainly for tests).
32
+ // --source <file> (check|commit only) — the transcript blob for quote-verification (auto-memory-01).
33
33
  //
34
34
  // Proposal { kind:"concept"|"decision"|"gotcha"|"rule"; tier:"concepts"|"decisions"|"gotchas"|"_rules";
35
35
  // slug; title; body /* starts "# " */; confidence /*0–1*/; rationale; evidence:[{quote,source?}] }
36
36
  // Verdict { ok:boolean; reason?:string /* machine code on reject */ }
37
37
  // NOTE: `_slots` is NOT a knowledge-gate tier — KIND_TIER stays {concepts,decisions,gotchas,_rules}; a
38
- // knowledge proposal targeting `_slots` is rejected `unsupported_tier`. The slot is reached ONLY
39
- // via set-focus.
38
+ // knowledge proposal targeting `_slots` is rejected `unsupported_tier`. (The standing-focus slot and
39
+ // its set-focus op were retired in auto-memory-05; the auto-handoff baton carries "where we are".)
40
40
 
41
41
  const fs = require('fs');
42
42
  const path = require('path');
@@ -51,13 +51,7 @@ const BODY_MAX = 32000; // size cap (chars) — a durable page, not a transcript
51
51
  const MAX_ACCEPTED = 5; // one run can't flood the wiki.
52
52
  const DREAM_DIR = ['.wrxn', 'dream'];
53
53
  const STAGED_FILE = 'staged.jsonl'; // the validated-but-unapproved batch (full proposals).
54
- const AUDIT_FILE = 'audit.jsonl'; // the append-only outcome log (stage + commit + set-focus events).
55
-
56
- // The durable standing-focus slot — a FIXED wiki path (tier + slug). set-focus is its ONLY writer and
57
- // overwrites it in place (the lone update-exception). `_slots` is deliberately ABSENT from KIND_TIER /
58
- // TIERS above: it is not a knowledge-gate tier, so the additive + dedup-skip knowledge gate is untouched.
59
- const FOCUS_TIER = '_slots';
60
- const FOCUS_SLUG = 'current-focus';
54
+ const AUDIT_FILE = 'audit.jsonl'; // the append-only outcome log (stage + commit events).
61
55
 
62
56
  // ── install-root resolution (mirrors wiki.cjs / enforce-managed-guard.cjs) ─────
63
57
  function findInstallRoot(start) {
@@ -134,14 +128,6 @@ function wikiWritePage(root, tier, slug, description, body) {
134
128
  return JSON.parse(execFileSync('node', args, { encoding: 'utf8' }));
135
129
  }
136
130
 
137
- // Overwrite a page in place via the wiki adapter's --force path (the indirection contract — never a
138
- // direct .md write). wiki.cjs restricts --force to the `_slots` tier, so only the focus slot is reachable.
139
- function wikiForceWritePage(root, tier, slug, description, body) {
140
- guardArgv([slug, String(description || ''), String(body || '')]);
141
- const args = [wikiAdapter(), 'write-page', tier, slug, '--description', String(description || ''), '--body', String(body || ''), '--force', '--root', root];
142
- return JSON.parse(execFileSync('node', args, { encoding: 'utf8' }));
143
- }
144
-
145
131
  function normalizeTitle(t) {
146
132
  return String(t == null ? '' : t).toLowerCase().replace(/\s+/g, ' ').trim();
147
133
  }
@@ -235,11 +221,66 @@ function secretScan(text) {
235
221
  return null;
236
222
  }
237
223
 
224
+ // ── source quote-verification (auto-memory-01 + F1 substantive floor) ──────────
225
+ // The single mechanical control that lets a NON-human proposer (auto-dream) write durable memory
226
+ // without poisoning recall: when a --source transcript blob is supplied, every evidence quote must be a
227
+ // SUBSTANTIVE verbatim span that VERIFIABLY appears in it, else the proposal is a hallucination. Matching
228
+ // is normalized — lowercased + whitespace-collapsed — so transcript formatting (line wraps, indentation,
229
+ // case) never causes a false reject, while the substantive quote text must still be present contiguously
230
+ // (we do NOT strip punctuation: the AC scopes normalization to whitespace + case only). When no source
231
+ // is supplied (the manual dream skill — a trusted main-agent proposer) this is a no-op, so behavior is
232
+ // byte-identical to today. A non-string quote never reaches here: the evidence-presence check rejects it.
233
+ //
234
+ // F1 (security MED): a bare substring match is satisfied by a trivially-present quote — "the" is a
235
+ // substring of essentially every transcript — so the proposer needed only ANY real word to clear the
236
+ // gate, under-delivering the PRD's load-bearing "a hallucination can't poison recall" claim. So a quote
237
+ // must FIRST be substantive before its presence counts: the NORMALIZED quote must be ≥ QUOTE_MIN_CHARS
238
+ // chars AND ≥ QUOTE_MIN_TOKENS whitespace-delimited word tokens, else quote_not_substantive. The token
239
+ // floor rejects single/two-word fragments ("the", "it works"); the char floor backstops it against tiny
240
+ // 3-token spans ("a b c"). The bar is low enough never to false-reject a terse real decision quote
241
+ // ("use pino logs"), and a proposer grounding a real decision can always cite a fuller span.
242
+ function normalizeForMatch(s) {
243
+ return String(s == null ? '' : s).toLowerCase().replace(/\s+/g, ' ').trim();
244
+ }
245
+
246
+ // The substantive-quote floor (F1). Operates on the NORMALIZED quote so it is independent of transcript
247
+ // formatting/case. Tunable here; pinned in BOTH directions (reject "the"/"authentication"; admit
248
+ // "use pino logs") by test/dream.test.cjs.
249
+ const QUOTE_MIN_CHARS = 12;
250
+ const QUOTE_MIN_TOKENS = 3;
251
+
252
+ function isSubstantiveQuote(quote) {
253
+ const norm = normalizeForMatch(quote);
254
+ if (norm.length < QUOTE_MIN_CHARS) return false;
255
+ return norm.split(' ').filter(Boolean).length >= QUOTE_MIN_TOKENS;
256
+ }
257
+
258
+ // Quote-verify (auto-memory-01 + F1): returns a reject reason or null. PRECEDENCE — substantiveness is a
259
+ // property of the quote ALONE, so it is a global precondition checked BEFORE any source-presence match:
260
+ // if ANY quote is non-substantive → quote_not_substantive; else if ANY quote is absent from the source →
261
+ // quote_not_in_source. (So quote_not_substantive is reported before quote_not_in_source.)
262
+ function verifyQuotes(p, source) {
263
+ if (!p.evidence.every((e) => isSubstantiveQuote(e.quote))) return 'quote_not_substantive';
264
+ const hay = normalizeForMatch(source);
265
+ if (!p.evidence.every((e) => hay.includes(normalizeForMatch(e.quote)))) return 'quote_not_in_source';
266
+ return null;
267
+ }
268
+
238
269
  // ── the pure per-proposal gate ────────────────────────────────────────────────
239
- // Deterministic given (proposal, io). `io` injects the dedup IO so the gate stays a pure, unit-testable
240
- // function (mirrors Phase-2 decideRecall): io.pathExists(tier,slug) / io.titleExists(title,tier,slug).
241
- // Precedence: routing validity quality content safety dedup (the last, most-expensive check).
242
- function validateProposal(p, io) {
270
+ // Deterministic given (proposal, io, source). `io` injects the dedup IO so the gate stays a pure,
271
+ // unit-testable function (mirrors Phase-2 decideRecall): io.pathExists(tier,slug) /
272
+ // io.titleExists(title,tier,slug). `source` (auto-memory-01) is the optional transcript blob: when a
273
+ // non-null string, every evidence quote must be substantive (quote_not_substantive) and verifiably appear
274
+ // in it (quote_not_in_source); when null (the manual dream path) the quote-verify is skipped — behavior is
275
+ // byte-identical to today.
276
+ // PRECEDENCE (deterministic, documented): routing validity (tier, kind↔tier) → confidence floor →
277
+ // evidence presence → SOURCE quote-verify [substantive floor (quote_not_substantive) BEFORE
278
+ // source-presence (quote_not_in_source)] → rationale → body → negative filters → secret-scan →
279
+ // identity → dedup. So the quote-verify reasons are reported AFTER the cheap structural checks the quote
280
+ // itself depends on (it needs evidence to exist) and the confidence floor, but BEFORE the negative
281
+ // filters, secret-scan, identity and the expensive dedup IO. Quote-verify composes with — never
282
+ // bypasses — every existing check: a proposal that passes quote-verify still faces all later gates.
283
+ function validateProposal(p, io, source) {
243
284
  if (!p || typeof p !== 'object' || Array.isArray(p)) return { ok: false, reason: 'invalid_proposal' };
244
285
  if (!TIERS.includes(p.tier)) return { ok: false, reason: 'unsupported_tier' };
245
286
  if (KIND_TIER[p.kind] !== p.tier) return { ok: false, reason: 'kind_tier_mismatch' };
@@ -248,6 +289,14 @@ function validateProposal(p, io) {
248
289
  !p.evidence.every((e) => e && typeof e.quote === 'string' && e.quote.trim().length > 0)) {
249
290
  return { ok: false, reason: 'missing_evidence' };
250
291
  }
292
+ // SOURCE quote-verify (auto-memory-01 + F1): runs ONLY when a --source blob is supplied. The evidence is
293
+ // known present + non-blank here, so each quote must be a substantive span (quote_not_substantive) AND
294
+ // normalize-substring-match the source (quote_not_in_source) or the proposal is a hallucination. Null
295
+ // source (manual dream) ⇒ skipped ⇒ byte-identical to today.
296
+ if (source != null) {
297
+ const qr = verifyQuotes(p, source);
298
+ if (qr) return { ok: false, reason: qr };
299
+ }
251
300
  if (typeof p.rationale !== 'string' || p.rationale.trim().length === 0) return { ok: false, reason: 'missing_rationale' };
252
301
  if (typeof p.body !== 'string' || !p.body.startsWith('# ')) return { ok: false, reason: 'body_missing_h1' };
253
302
  if (p.body.length > BODY_MAX) return { ok: false, reason: 'body_too_large' };
@@ -266,14 +315,15 @@ function validateProposal(p, io) {
266
315
  return { ok: true };
267
316
  }
268
317
 
269
- // Run-level gate: restraint (empty/abstain ⇒ write nothing) + the ≤5 accepted cap.
270
- function validateRun(proposals, io) {
318
+ // Run-level gate: restraint (empty/abstain ⇒ write nothing) + the ≤5 accepted cap. `source` threads
319
+ // the optional --source blob to every per-proposal quote-verify (auto-memory-01); null ⇒ legacy path.
320
+ function validateRun(proposals, io, source) {
271
321
  const accepted = [];
272
322
  const rejected = [];
273
323
  let acceptedCount = 0;
274
324
  for (let i = 0; i < proposals.length; i++) {
275
325
  const p = proposals[i];
276
- const v = validateProposal(p, io);
326
+ const v = validateProposal(p, io, source);
277
327
  if (v.ok) {
278
328
  acceptedCount += 1;
279
329
  if (acceptedCount > MAX_ACCEPTED) {
@@ -342,6 +392,30 @@ function readJson(file) {
342
392
  }
343
393
  }
344
394
 
395
+ // Read the optional --source transcript blob (auto-memory-01). Absent ⇒ null (the legacy path, no
396
+ // quote-verify). PRESENT ⇒ it MUST carry a real, readable file value: a value-less / empty / --leading
397
+ // token (F2) or an unreadable path ⇒ HARD fail (exit 2). A missing source must NEVER silently disable the
398
+ // gate — that would let a malformed argv (or a broken file) turn off the one defense against a
399
+ // hallucinated memory. We read argv DIRECTLY here (not flag()): flag('source') collapses "flag absent"
400
+ // and "flag present-but-valueless" both to undefined, but only the former may fall through to legacy.
401
+ // The blob is raw text (the transcript), read as-is, not parsed.
402
+ function readSource() {
403
+ const i = process.argv.indexOf('--source');
404
+ if (i === -1) return null; // the flag is truly absent → legacy path (the trusted manual-dream proposer)
405
+ const file = process.argv[i + 1];
406
+ // --source WAS asked for, so it must not silently revert to no-verify: a trailing token (undefined), an
407
+ // empty value, or another flag (e.g. `--root`) is a fail-CLOSED malformed argv, never "gate off" (F2).
408
+ if (file === undefined || file === '' || file.startsWith('--')) {
409
+ fail('--source was given without a readable file value — refusing to silently disable quote-verify');
410
+ }
411
+ try {
412
+ return fs.readFileSync(file, 'utf8');
413
+ } catch (err) {
414
+ fail(`cannot read --source file "${file}": ${err.message}`);
415
+ return undefined;
416
+ }
417
+ }
418
+
345
419
  function appendLine(file, obj) {
346
420
  fs.appendFileSync(file, JSON.stringify(obj) + '\n');
347
421
  }
@@ -357,12 +431,13 @@ function runCheck() {
357
431
  const input = readJson(positionalFile());
358
432
  const root = installRoot();
359
433
  const io = makeIo(root);
434
+ const source = readSource(); // null ⇒ legacy path (no quote-verify); string ⇒ verify every quote
360
435
  if (isBatchInput(input)) {
361
436
  const { proposals, abstain } = normalizeBatch(input);
362
437
  if (abstain) return print({ abstained: true, accepted: [], rejected: [] });
363
- return print(validateRun(proposals, io));
438
+ return print(validateRun(proposals, io, source));
364
439
  }
365
- return print(validateProposal(input, io)); // single proposal → single Verdict
440
+ return print(validateProposal(input, io, source)); // single proposal → single Verdict
366
441
  }
367
442
 
368
443
  function runStage() {
@@ -427,6 +502,7 @@ function runCommit() {
427
502
  const root = installRoot();
428
503
  const approved = approvedSlugs(input);
429
504
  const io = makeIo(root);
505
+ const source = readSource(); // null ⇒ legacy re-gate; string ⇒ re-verify every quote at the write boundary
430
506
  const staged = readStaged(root);
431
507
  const written = [];
432
508
  const skipped = [];
@@ -437,7 +513,7 @@ function runCommit() {
437
513
  skipped.push({ slug: key, reason: 'not_staged' });
438
514
  continue;
439
515
  }
440
- const v = validateProposal(p, io); // the re-gate — additive + dedup-skip + every quality/safety check
516
+ const v = validateProposal(p, io, source); // the re-gate — additive + dedup-skip + quote-verify + every quality/safety check
441
517
  if (!v.ok) {
442
518
  skipped.push({ slug: key, reason: v.reason });
443
519
  continue;
@@ -458,34 +534,6 @@ function runCommit() {
458
534
  return print({ written, skipped });
459
535
  }
460
536
 
461
- // set-focus — write/overwrite the durable standing-focus slot. The LONE update-exception: it is the
462
- // only op that overwrites a wiki page, and only ever `_slots/current-focus.md`. It is NOT a knowledge
463
- // proposal (no gate, no dedup) — the skill drafts a short standing-focus statement, the operator
464
- // confirms, then this writes it via the wiki --force path. DISJOINT from the continuity baton:
465
- // set-focus never reads or writes `.wrxn/continuity/latest.md` (single-writer = the handoff skill).
466
- function runSetFocus() {
467
- const input = readJson(positionalFile());
468
- const root = installRoot();
469
- const obj = input && typeof input === 'object' && !Array.isArray(input) ? input : {};
470
- const title = obj.title ? String(obj.title) : 'Current focus';
471
- const body = obj.body;
472
- if (typeof body !== 'string' || body.trim().length === 0) {
473
- fail('set-focus needs a non-empty "body" — the standing-focus statement to pin');
474
- }
475
- // Gate the focus slot too (security M1): it is an ungated --force write, so run the same content-safety
476
- // checks the knowledge gate runs — the anti-superstition negative filters + the credential secret-scan
477
- // — over the focus title+body, and refuse a `--`-leading flag-injection value. Reject ⇒ nothing written.
478
- const scan = `${title}\n${body}`;
479
- const neg = negativeFilter(scan);
480
- if (neg) fail(`set-focus rejected — the focus body trips a negative filter (${neg}); state durable standing context, not a transient note`);
481
- const sec = secretScan(scan);
482
- if (sec) fail(`set-focus rejected — the focus body contains a credential (${sec}); never pin a session secret`);
483
- if (title.startsWith('--') || body.startsWith('--')) fail('set-focus rejected — a --leading title/body is refused (flag-injection guard)');
484
- const r = wikiForceWritePage(root, FOCUS_TIER, FOCUS_SLUG, title, body);
485
- appendLine(path.join(dreamDir(root), AUDIT_FILE), { ts: new Date().toISOString(), op: 'set-focus', file: r.written });
486
- return print({ focus: r.written });
487
- }
488
-
489
537
  function main() {
490
538
  const cmd = process.argv[2];
491
539
  switch (cmd) {
@@ -495,10 +543,8 @@ function main() {
495
543
  return runStage();
496
544
  case 'commit':
497
545
  return runCommit();
498
- case 'set-focus':
499
- return runSetFocus();
500
546
  default:
501
- process.stdout.write('Usage: node .wrxn/dream.cjs <check|stage|commit|set-focus> <file.json> [--root <dir>]\n');
547
+ process.stdout.write('Usage: node .wrxn/dream.cjs <check|stage|commit> <file.json> [--root <dir>]\n');
502
548
  process.exit(cmd ? 2 : 0);
503
549
  }
504
550
  }