@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.
@@ -40,6 +40,7 @@
40
40
 
41
41
  const fs = require('fs');
42
42
  const path = require('path');
43
+ const crypto = require('crypto');
43
44
  const { execFileSync } = require('child_process');
44
45
 
45
46
  // kind → tier is the contract; the tier must agree with the kind. `rule → _rules` (dream-03) joins the
@@ -128,6 +129,16 @@ function wikiWritePage(root, tier, slug, description, body) {
128
129
  return JSON.parse(execFileSync('node', args, { encoding: 'utf8' }));
129
130
  }
130
131
 
132
+ // Delete a page VIA the wiki adapter's delete-by-reference path (the indirection contract — dream never
133
+ // unlinks a .md directly; --revert reverses this run's pages through it). wiki.cjs confines the delete to
134
+ // the wiki subtree (tier allowlist + kebab slug); the audit-recorded tier/slug are dream-written, but the
135
+ // flag-injection guard is the defense-in-depth backstop at the exec boundary (harvest parity).
136
+ function wikiDeletePage(root, tier, slug) {
137
+ guardArgv([String(tier), String(slug)]);
138
+ const args = [wikiAdapter(), 'delete-page', String(tier), String(slug), '--root', root];
139
+ return JSON.parse(execFileSync('node', args, { encoding: 'utf8' }));
140
+ }
141
+
131
142
  function normalizeTitle(t) {
132
143
  return String(t == null ? '' : t).toLowerCase().replace(/\s+/g, ' ').trim();
133
144
  }
@@ -169,6 +180,204 @@ function stampPageImportance(root, tier, slug, score) {
169
180
  fs.writeFileSync(file, stampImportance(fs.readFileSync(file, 'utf8'), score));
170
181
  }
171
182
 
183
+ // ── lineage stamp — the provenance PRODUCER (S3 / #22) ─────────────────────────
184
+ // Every committed page records WHO wrote it: origin_session (the session id), synth_run (the per-run id —
185
+ // the SAME value that keys this run's audit-log commit event, so --revert can resolve "this run's pages"),
186
+ // proposal_id (the staged proposal's stable id = its slug). Machine-written frontmatter only — the prose
187
+ // body is preserved byte-for-byte, like importance: (no churn). This is the seam sub-epic ② reuses for
188
+ // evidence citations (J = who wrote it). Values are sanitised to a single bare frontmatter scalar: a
189
+ // newline would inject an arbitrary key, a colon creates YAML ambiguity — both are collapsed to a space
190
+ // (same write-channel discipline as harvest's annotationValueProblem / importance's shape-safety).
191
+ const LINEAGE_KEYS = ['origin_session', 'synth_run', 'proposal_id'];
192
+
193
+ // One bare scalar — strip CR/LF/colon to a space so a hostile value can never inject a frontmatter key or
194
+ // YAML mapping (mirrors stampImportance's shape-safety). Empty/undefined → 'unknown' so the key is always
195
+ // present and parseable (a missing value must never silently drop a lineage key).
196
+ function lineageScalar(value) {
197
+ const s = String(value == null ? '' : value).replace(/[\r\n:]+/g, ' ').trim();
198
+ return s || 'unknown';
199
+ }
200
+
201
+ // PURE in-place stamp (mirrors stampImportance): set each lineage key when present (no duplicate key), else
202
+ // append it; the body after the closing fence is preserved byte-for-byte. A page with no frontmatter fence
203
+ // is returned unchanged (defensive — wiki pages always carry one).
204
+ function stampLineage(content, lineage) {
205
+ const text = String(content);
206
+ const m = /^---\r?\n([\s\S]*?)\r?\n---/.exec(text);
207
+ if (!m) return text;
208
+ const lines = m[1].split(/\r?\n/);
209
+ for (const key of LINEAGE_KEYS) {
210
+ const value = lineageScalar(lineage && lineage[key]);
211
+ let found = false;
212
+ for (let i = 0; i < lines.length; i++) {
213
+ if (new RegExp(`^${key}:\\s*`).test(lines[i])) { lines[i] = `${key}: ${value}`; found = true; break; }
214
+ }
215
+ if (!found) lines.push(`${key}: ${value}`);
216
+ }
217
+ // splice ONLY the frontmatter fence back; the body after the closing --- is byte-for-byte preserved.
218
+ return text.slice(0, m.index) + ['---', lines.join('\n'), '---'].join('\n') + text.slice(m.index + m[0].length);
219
+ }
220
+
221
+ // PURE resolver (deterministic given its injected IO): gather the evidence FACTS into { session, commit,
222
+ // symbols }. The git HEAD resolver + the touched set + the session anchor are all injected, so the core is
223
+ // unit-tested with no live repo (mirrors session-end-reward's gitFacts injection). FAIL-OPEN: a resolveHead
224
+ // that throws or returns nothing → commit null (stampEvidence omits it); a missing touched set → []. NEVER
225
+ // throws — a field that cannot be resolved must never break consolidation.
226
+ function resolveEvidence({ session, resolveHead, touched } = {}) {
227
+ let commit = null;
228
+ try {
229
+ const head = typeof resolveHead === 'function' ? resolveHead() : null;
230
+ commit = head ? String(head) : null;
231
+ } catch {
232
+ commit = null; // no git binary / not a repo / detached → no commit (fail-open)
233
+ }
234
+ return {
235
+ session: session == null ? '' : String(session),
236
+ commit,
237
+ symbols: Array.isArray(touched) ? touched : [],
238
+ };
239
+ }
240
+
241
+ // ── evidence production IO (the real resolvers resolveEvidence injects at the CLI boundary) ─────
242
+ // Resolve the install repo's current git HEAD sha — the citation's `commit`. Rooted at the install so a
243
+ // nested cwd can't resolve a different repo's HEAD. Fail-open: no git binary / not a repo / detached →
244
+ // null (commit omitted). Mirrors session-end-reward.cjs / session-start.cjs resolveGitHead byte-for-byte.
245
+ function resolveGitHead(root) {
246
+ try {
247
+ const out = execFileSync('git', ['rev-parse', 'HEAD'], { cwd: root, encoding: 'utf8', stdio: ['ignore', 'pipe', 'ignore'] });
248
+ const sha = String(out || '').trim();
249
+ return sha || null;
250
+ } catch {
251
+ return null; // no git binary / not a repo / detached with no commit → no commit (fail-open)
252
+ }
253
+ }
254
+
255
+ // The session-id → filename transform — MUST match code-intel-push's safeId byte-for-byte, or the read
256
+ // targets the wrong .touched file (replicated here, no shared import — the self-contained discipline).
257
+ function safeSessionId(sid) {
258
+ return String(sid || 'session').toLowerCase().replace(/[^a-z0-9]+/g, '-').replace(/^-+|-+$/g, '').slice(0, 48) || 'session';
259
+ }
260
+
261
+ // Read this session's edited paths from <root>/.wrxn/history/<safeId>.touched — the list code-intel-push
262
+ // already maintains (REUSE; no new persistence path). Deduped, order preserved. FAIL-OPEN: an absent/
263
+ // unreadable file → [] (the citation simply carries no symbols). This is the citation's `symbols` set.
264
+ function readTouched(root, sessionId) {
265
+ if (!root) return [];
266
+ let raw;
267
+ try {
268
+ raw = fs.readFileSync(path.join(root, '.wrxn', 'history', `${safeSessionId(sessionId)}.touched`), 'utf8');
269
+ } catch {
270
+ return []; // absent / unreadable → no edits this session
271
+ }
272
+ const seen = new Set();
273
+ const out = [];
274
+ for (const line of raw.split('\n')) {
275
+ const p = line.trim();
276
+ if (!p || seen.has(p)) continue;
277
+ seen.add(p);
278
+ out.push(p);
279
+ }
280
+ return out;
281
+ }
282
+
283
+ // Stamp the just-written wiki page's evidence in place (read → stampEvidence → write). Same page reach as
284
+ // stampPageImportance/Lineage — the stamp never re-routes the write (AC6: the existing stamp seam, not a new one).
285
+ function stampPageEvidence(root, tier, slug, evidence) {
286
+ const file = path.join(root, '.wrxn', 'wiki', tier, `${slug}.md`);
287
+ fs.writeFileSync(file, stampEvidence(fs.readFileSync(file, 'utf8'), evidence));
288
+ }
289
+
290
+ // Stamp the just-written wiki page's lineage in place (read → stampLineage → write). Same page reach as
291
+ // stampPageImportance — the stamp never re-routes the write.
292
+ function stampPageLineage(root, tier, slug, lineage) {
293
+ const file = path.join(root, '.wrxn', 'wiki', tier, `${slug}.md`);
294
+ fs.writeFileSync(file, stampLineage(fs.readFileSync(file, 'utf8'), lineage));
295
+ }
296
+
297
+ // ── evidence stamp — the forward citation PRODUCER (C3 / #36) ──────────────────
298
+ // Every committed page records the FACTS that make its citation resolvable: `session` (the quote-verified
299
+ // source anchor — the session id), `commit` (the real git HEAD at write time), `symbols` (the session's
300
+ // .touched set). This FREEZES the evidence-frontmatter contract recon-wrxn ②'s edge resolver reads to draw
301
+ // EVIDENCED_BY / DOCUMENTED_BY edges — every field is computed from ground truth, so the citation is
302
+ // inherently resolvable. Written as an `evidence:` MAPPING (nested under one key, distinct from the flat
303
+ // lineage/importance scalars). Machine-written frontmatter only — the prose body is preserved byte-for-byte.
304
+
305
+ // One bare nested scalar (session/commit) — strip CR/LF/colon to a space so a hostile value can never
306
+ // inject a frontmatter key or a YAML mapping (mirrors lineageScalar's shape-safety). Empty stays empty —
307
+ // the session sentinel + the commit/symbols omission are the caller-visible policy applied in stampEvidence.
308
+ function evidenceScalar(value) {
309
+ return String(value == null ? '' : value).replace(/[\r\n:]+/g, ' ').replace(/\s+/g, ' ').trim();
310
+ }
311
+
312
+ // One symbol list item — additionally strip the inline-flow-list breakers ([ ] ,) so a path can never break
313
+ // the `symbols: [a, b]` list shape (defense in depth; .touched values are real paths without these chars).
314
+ function symbolScalar(value) {
315
+ return String(value == null ? '' : value).replace(/[[\],\r\n:]+/g, ' ').replace(/\s+/g, ' ').trim();
316
+ }
317
+
318
+ // Drop any pre-existing `evidence:` mapping (its key line + the indented members beneath it) so a re-stamp
319
+ // updates in place with no duplicate block — the in-place discipline stampLineage gets for free with flat keys.
320
+ function stripEvidence(lines) {
321
+ const out = [];
322
+ let inBlock = false;
323
+ for (const line of lines) {
324
+ if (/^evidence:/.test(line)) { inBlock = true; continue; } // the mapping key line → drop
325
+ if (inBlock && /^\s+\S/.test(line)) continue; // an indented nested member → drop
326
+ inBlock = false;
327
+ out.push(line);
328
+ }
329
+ return out;
330
+ }
331
+
332
+ // PURE in-place stamp (mirrors stampLineage): append an `evidence:` mapping to the frontmatter fence; the
333
+ // body after the closing fence is preserved byte-for-byte. A page with no frontmatter fence is returned
334
+ // unchanged (defensive — wiki pages always carry one).
335
+ function stampEvidence(content, evidence) {
336
+ const text = String(content);
337
+ const m = /^---\r?\n([\s\S]*?)\r?\n---/.exec(text);
338
+ if (!m) return text;
339
+ const ev = evidence || {};
340
+ const lines = stripEvidence(m[1].split(/\r?\n/)); // in-place: remove any prior evidence block first
341
+ lines.push('evidence:');
342
+ // session — always present: an unresolvable anchor falls back to the 'unknown' sentinel (parseable key).
343
+ lines.push(` session: ${evidenceScalar(ev.session) || 'unknown'}`);
344
+ // commit / symbols — FAIL-OPEN: a field that cannot be resolved (no git HEAD → empty; empty .touched → no
345
+ // items) is OMITTED, never written blank. The block still writes with whatever facts ARE known.
346
+ const commit = evidenceScalar(ev.commit);
347
+ if (commit) lines.push(` commit: ${commit}`);
348
+ const symbols = (Array.isArray(ev.symbols) ? ev.symbols : []).map(symbolScalar).filter(Boolean);
349
+ if (symbols.length) lines.push(` symbols: [${symbols.join(', ')}]`);
350
+ // splice ONLY the frontmatter fence back; the body after the closing --- is byte-for-byte preserved.
351
+ return text.slice(0, m.index) + ['---', lines.join('\n'), '---'].join('\n') + text.slice(m.index + m[0].length);
352
+ }
353
+
354
+ // The current session id for a CLI-invoked adapter (no hook event payload exists here). The whole codebase
355
+ // keys provenance off the session id; hooks read event.session_id, the synth path reads the .pending stash,
356
+ // and Claude Code exports it to the environment — so a hand-run adapter resolves it from CLAUDE_SESSION_ID.
357
+ // Absent ⇒ 'unknown' (fail-open: a missing session id must never break consolidation).
358
+ function currentSession() {
359
+ return lineageScalar(process.env.CLAUDE_SESSION_ID);
360
+ }
361
+
362
+ // The per-run id, derived deterministically from the run's ISO timestamp (the SAME ts that keys this run's
363
+ // audit-log commit event). No new clock read and no random/uuid — the run id IS the audit timestamp, which
364
+ // is exactly what binds a stamped page to its audit entry for --revert. Colons stripped so it is a clean
365
+ // frontmatter scalar (an ISO ts carries `:` in the time).
366
+ function runIdFromTs(ts) {
367
+ return lineageScalar(ts);
368
+ }
369
+
370
+ // Read a committed page's full on-disk content (frontmatter + body). Reached by tier+slug under .wrxn/wiki/.
371
+ function pageContent(root, tier, slug) {
372
+ return fs.readFileSync(path.join(root, '.wrxn', 'wiki', tier, `${slug}.md`), 'utf8');
373
+ }
374
+
375
+ // sha256 of a page's exact bytes — the integrity fingerprint --revert recomputes to detect a hand-edit
376
+ // (mirrors harvest's mergeHash/decayHash sha256 discipline).
377
+ function sha256(text) {
378
+ return crypto.createHash('sha256').update(String(text)).digest('hex');
379
+ }
380
+
172
381
  // ── anti-superstition negative filters ────────────────────────────────────────
173
382
  // A mechanical backstop to the dream skill's prompt (the skill is the primary semantic filter; this is
174
383
  // "reinforced where mechanical"). Each pattern catches a class of transient/false "memory" that, if
@@ -504,6 +713,14 @@ function runCommit() {
504
713
  const io = makeIo(root);
505
714
  const source = readSource(); // null ⇒ legacy re-gate; string ⇒ re-verify every quote at the write boundary
506
715
  const staged = readStaged(root);
716
+ // One ts per run: it keys this run's audit event AND derives synth_run, so a stamped page's synth_run is
717
+ // byte-identical to the run id recorded in the audit log — the bind that lets --revert resolve this run.
718
+ const ts = new Date().toISOString();
719
+ const runId = runIdFromTs(ts);
720
+ const session = currentSession();
721
+ // C3 (#36): the forward citation FACTS, resolved ONCE per run from ground truth — the session anchor, the
722
+ // real git HEAD, the session's .touched symbol set. Fail-open by construction (resolveEvidence never throws).
723
+ const evidence = resolveEvidence({ session, resolveHead: () => resolveGitHead(root), touched: readTouched(root, session) });
507
724
  const written = [];
508
725
  const skipped = [];
509
726
  for (const slug of approved) {
@@ -521,7 +738,12 @@ function runCommit() {
521
738
  try {
522
739
  const r = wikiWritePage(root, p.tier, p.slug, p.title, p.body);
523
740
  stampPageImportance(root, p.tier, p.slug, p.confidence); // persist dream's score as importance: (harvest-10)
524
- written.push({ slug: p.slug, tier: p.tier, file: r.written });
741
+ stampPageLineage(root, p.tier, p.slug, { origin_session: session, synth_run: runId, proposal_id: p.slug }); // S3 provenance
742
+ stampPageEvidence(root, p.tier, p.slug, evidence); // C3 (#36): forward citation facts (session/commit/symbols)
743
+ // capture the content hash of EXACTLY what this run wrote (after all stamps) so --revert can detect a
744
+ // page hand-edited since (current hash ≠ this) and refuse to clobber it.
745
+ const hash = sha256(pageContent(root, p.tier, p.slug));
746
+ written.push({ slug: p.slug, tier: p.tier, file: r.written, hash });
525
747
  } catch (err) {
526
748
  // wiki.cjs write-page does process.exit(2) on an existing page — catch the non-zero exit so a
527
749
  // TOCTOU collision (or any single write failure) is recorded and the rest of the batch STILL writes.
@@ -530,8 +752,108 @@ function runCommit() {
530
752
  skipped.push({ slug: key, reason });
531
753
  }
532
754
  }
533
- appendLine(path.join(dreamDir(root), AUDIT_FILE), { ts: new Date().toISOString(), op: 'commit', written: written.map((w) => w.slug), skipped });
534
- return print({ written, skipped });
755
+ // the audit commit event records this run's id + each written page's tier/slug/hash the cross-check
756
+ // ledger --revert reads to reverse exactly this run's pages and detect hand-edits (#22).
757
+ appendLine(path.join(dreamDir(root), AUDIT_FILE), {
758
+ ts, op: 'commit', synth_run: runId, origin_session: session,
759
+ written: written.map((w) => w.slug),
760
+ pages: written.map((w) => ({ slug: w.slug, tier: w.tier, hash: w.hash })),
761
+ skipped,
762
+ });
763
+ return print({ written, skipped, synth_run: runId });
764
+ }
765
+
766
+ // ── revert (S3 / #22) — pull a bad consolidation batch back out ────────────────
767
+ // Resolve EXACTLY the pages a given synth_run wrote (the audit commit event is the source of truth — we
768
+ // never trust the on-disk synth_run stamp alone) and reverse them. SAFETY: a page hand-edited since the run
769
+ // wrote it (its current sha256 no longer matches the hash the audit recorded at commit) is REFUSED and
770
+ // reported, never clobbered; an unknown run id (no matching audit commit event) is REFUSED and reported.
771
+
772
+ // Read the append-only audit log into an array of parsed records (malformed lines skipped; absent → []).
773
+ function readAudit(root) {
774
+ let txt;
775
+ try {
776
+ txt = fs.readFileSync(path.join(root, ...DREAM_DIR, AUDIT_FILE), 'utf8');
777
+ } catch {
778
+ return [];
779
+ }
780
+ const out = [];
781
+ for (const line of txt.split('\n')) {
782
+ const s = line.trim();
783
+ if (!s) continue;
784
+ try { out.push(JSON.parse(s)); } catch { /* skip a malformed audit line */ }
785
+ }
786
+ return out;
787
+ }
788
+
789
+ // The pages this run committed, per the audit log (the cross-check ledger). Gathers every `commit` event
790
+ // whose synth_run matches, de-duplicating by tier/slug (a slug can't be committed twice in additive dream,
791
+ // but be defensive). Returns [] when no commit event matches the run id → the caller reports unknown_run.
792
+ function runPagesFromAudit(audit, runId) {
793
+ const seen = new Set();
794
+ const pages = [];
795
+ for (const rec of audit) {
796
+ if (!rec || rec.op !== 'commit' || rec.synth_run !== runId) continue;
797
+ for (const pg of Array.isArray(rec.pages) ? rec.pages : []) {
798
+ if (!pg || !pg.slug || !pg.tier) continue;
799
+ const key = `${pg.tier}/${pg.slug}`;
800
+ if (seen.has(key)) continue;
801
+ seen.add(key);
802
+ pages.push({ slug: String(pg.slug), tier: String(pg.tier), hash: String(pg.hash || '') });
803
+ }
804
+ }
805
+ return pages;
806
+ }
807
+
808
+ // PURE revert planner (deterministic given the audit + a content reader). Classifies each audit page of the
809
+ // run: missing (already gone — nothing to reverse), edited (current hash ≠ audit hash — REFUSE), or
810
+ // reversible (hash matches → safe to delete). `readContent(tier, slug)` returns the page text or null when
811
+ // absent. Unknown run (no audit pages) → { unknown_run:true }. v1 is delete-only: dream commit is purely
812
+ // additive (it dedup-SKIPS a pre-existing slug, never overwrites — proven by the AC2-backward-safe test), so
813
+ // a reverted page had no prior version to restore; reversing == removing the page the run created.
814
+ function resolveRevert(audit, runId, readContent) {
815
+ const pages = runPagesFromAudit(audit, runId);
816
+ if (pages.length === 0) return { unknown_run: true, reversible: [], edited: [], missing: [] };
817
+ const reversible = [];
818
+ const edited = [];
819
+ const missing = [];
820
+ for (const pg of pages) {
821
+ const content = readContent(pg.tier, pg.slug);
822
+ if (content == null) { missing.push({ slug: pg.slug, tier: pg.tier }); continue; }
823
+ if (sha256(content) !== pg.hash) { edited.push({ slug: pg.slug, tier: pg.tier }); continue; } // hand-edited → refuse
824
+ reversible.push({ slug: pg.slug, tier: pg.tier });
825
+ }
826
+ return { unknown_run: false, reversible, edited, missing };
827
+ }
828
+
829
+ function runRevert() {
830
+ const runId = positionalFile(); // the run id is the lone positional after `revert`
831
+ const root = installRoot();
832
+ const audit = readAudit(root);
833
+ const plan = resolveRevert(audit, runId, (tier, slug) => {
834
+ try { return pageContent(root, tier, slug); } catch { return null; }
835
+ });
836
+ if (plan.unknown_run) {
837
+ print({ run: runId, reverted: [], refused: [], missing: [], reason: 'unknown_run' });
838
+ process.exit(2); // an unknown run id is refused (and reported) — not a silent no-op success
839
+ }
840
+ const reverted = [];
841
+ const failed = [];
842
+ for (const pg of plan.reversible) {
843
+ try {
844
+ wikiDeletePage(root, pg.tier, pg.slug);
845
+ reverted.push(pg.slug);
846
+ } catch (e) {
847
+ // a delete that fails (vanished page / wiki refusal) is recorded — never aborts the rest of the batch.
848
+ failed.push({ slug: pg.slug, reason: String((e && e.message) || 'delete_failed').split('\n')[0] });
849
+ }
850
+ }
851
+ const refused = plan.edited.map((p) => ({ slug: p.slug, reason: 'hand_edited' })); // hand-edited pages are NOT clobbered
852
+ appendLine(path.join(dreamDir(root), AUDIT_FILE), {
853
+ ts: new Date().toISOString(), op: 'revert', synth_run: runId,
854
+ reverted, refused, missing: plan.missing.map((p) => p.slug), failed,
855
+ });
856
+ return print({ run: runId, reverted, refused, missing: plan.missing.map((p) => p.slug), failed });
535
857
  }
536
858
 
537
859
  function main() {
@@ -543,8 +865,10 @@ function main() {
543
865
  return runStage();
544
866
  case 'commit':
545
867
  return runCommit();
868
+ case 'revert':
869
+ return runRevert();
546
870
  default:
547
- process.stdout.write('Usage: node .wrxn/dream.cjs <check|stage|commit> <file.json> [--root <dir>]\n');
871
+ process.stdout.write('Usage: node .wrxn/dream.cjs <check|stage|commit> <file.json> | revert <run_id> [--root <dir>]\n');
548
872
  process.exit(cmd ? 2 : 0);
549
873
  }
550
874
  }
@@ -553,4 +877,4 @@ if (require.main === module) {
553
877
  main();
554
878
  }
555
879
 
556
- module.exports = { stampImportance };
880
+ module.exports = { stampImportance, stampLineage, stampEvidence, resolveEvidence, lineageScalar, sha256, resolveRevert, runPagesFromAudit };
File without changes
@@ -735,6 +735,142 @@ function composeSurvivor({ tier, slug, description, body, mergedFrom }) {
735
735
  ].join('\n');
736
736
  }
737
737
 
738
+ // ── lineage stamp — the provenance PRODUCER (S3 / #22, mirrors dream.cjs) ───────
739
+ // A merged survivor is a net-new durable page, so it records WHO wrote it: origin_session (the session id),
740
+ // synth_run (the per-run id = this run's audit ts), proposal_id (the survivor slug). Machine-written
741
+ // frontmatter only — the survivor body is preserved byte-for-byte (no churn). Replicated here (not imported)
742
+ // because each install-only adapter is self-contained (node stdlib only) — same discipline as secretScan.
743
+ const LINEAGE_KEYS = ['origin_session', 'synth_run', 'proposal_id'];
744
+
745
+ // One bare frontmatter scalar — strip CR/LF/colon to a space so a value can never inject a key or YAML
746
+ // mapping (composeSurvivor's frontmatter write-channel discipline). Empty → 'unknown' (the key is always present).
747
+ function lineageScalar(value) {
748
+ const s = String(value == null ? '' : value).replace(/[\r\n:]+/g, ' ').trim();
749
+ return s || 'unknown';
750
+ }
751
+
752
+ // PURE in-place stamp: set each lineage key (no duplicate key) in the frontmatter fence; the body after the
753
+ // closing fence is preserved byte-for-byte. No fence → returned unchanged (defensive — wiki pages carry one).
754
+ function stampLineage(content, lineage) {
755
+ const text = String(content);
756
+ const m = /^---\r?\n([\s\S]*?)\r?\n---/.exec(text);
757
+ if (!m) return text;
758
+ const lines = m[1].split(/\r?\n/);
759
+ for (const key of LINEAGE_KEYS) {
760
+ const value = lineageScalar(lineage && lineage[key]);
761
+ let found = false;
762
+ for (let i = 0; i < lines.length; i++) {
763
+ if (new RegExp(`^${key}:\\s*`).test(lines[i])) { lines[i] = `${key}: ${value}`; found = true; break; }
764
+ }
765
+ if (!found) lines.push(`${key}: ${value}`);
766
+ }
767
+ return text.slice(0, m.index) + ['---', lines.join('\n'), '---'].join('\n') + text.slice(m.index + m[0].length);
768
+ }
769
+
770
+ // The current session id for this CLI-invoked adapter — Claude Code exports it; absent ⇒ 'unknown' (fail-open).
771
+ function currentSession() {
772
+ return lineageScalar(process.env.CLAUDE_SESSION_ID);
773
+ }
774
+
775
+ // ── forward evidence stamp — the citation PRODUCER (C3 / #36, mirrors dream.cjs) ─
776
+ // A merged survivor records the FACTS that make its citation resolvable: session (the consolidating
777
+ // session id), commit (the real git HEAD at write time), symbols (the session's .touched set). The same
778
+ // evidence-frontmatter contract recon-wrxn ②'s edge resolver reads. Replicated here (not imported) — each
779
+ // install-only adapter is self-contained (node stdlib only), the same discipline as secretScan/stampLineage.
780
+
781
+ // A bare nested scalar (session/commit) — strip CR/LF/colon so a value can never inject a key or YAML mapping.
782
+ function evidenceScalar(value) {
783
+ return String(value == null ? '' : value).replace(/[\r\n:]+/g, ' ').replace(/\s+/g, ' ').trim();
784
+ }
785
+
786
+ // A symbol list item — also strip the inline-flow-list breakers ([ ] ,) so a path can never break the list shape.
787
+ function symbolScalar(value) {
788
+ return String(value == null ? '' : value).replace(/[[\],\r\n:]+/g, ' ').replace(/\s+/g, ' ').trim();
789
+ }
790
+
791
+ // Drop any pre-existing `evidence:` mapping (key line + indented members) so a re-stamp has no duplicate block.
792
+ function stripEvidence(lines) {
793
+ const out = [];
794
+ let inBlock = false;
795
+ for (const line of lines) {
796
+ if (/^evidence:/.test(line)) { inBlock = true; continue; }
797
+ if (inBlock && /^\s+\S/.test(line)) continue;
798
+ inBlock = false;
799
+ out.push(line);
800
+ }
801
+ return out;
802
+ }
803
+
804
+ // PURE in-place stamp (mirrors stampLineage): append an `evidence:` mapping to the frontmatter fence; the body
805
+ // after the closing fence is preserved byte-for-byte. No fence → returned unchanged. FAIL-OPEN: an empty
806
+ // commit/symbols field is OMITTED; session falls back to the 'unknown' sentinel so the key is always present.
807
+ function stampEvidence(content, evidence) {
808
+ const text = String(content);
809
+ const m = /^---\r?\n([\s\S]*?)\r?\n---/.exec(text);
810
+ if (!m) return text;
811
+ const ev = evidence || {};
812
+ const lines = stripEvidence(m[1].split(/\r?\n/));
813
+ lines.push('evidence:');
814
+ lines.push(` session: ${evidenceScalar(ev.session) || 'unknown'}`);
815
+ const commit = evidenceScalar(ev.commit);
816
+ if (commit) lines.push(` commit: ${commit}`);
817
+ const symbols = (Array.isArray(ev.symbols) ? ev.symbols : []).map(symbolScalar).filter(Boolean);
818
+ if (symbols.length) lines.push(` symbols: [${symbols.join(', ')}]`);
819
+ return text.slice(0, m.index) + ['---', lines.join('\n'), '---'].join('\n') + text.slice(m.index + m[0].length);
820
+ }
821
+
822
+ // PURE resolver (deterministic given injected IO): gather the evidence facts. The git HEAD resolver + touched
823
+ // set + session anchor are injected, so the core is unit-tested with no live repo. FAIL-OPEN: a resolveHead
824
+ // that throws/returns nothing → commit null; a missing touched set → []. NEVER throws.
825
+ function resolveEvidence({ session, resolveHead, touched } = {}) {
826
+ let commit = null;
827
+ try {
828
+ const head = typeof resolveHead === 'function' ? resolveHead() : null;
829
+ commit = head ? String(head) : null;
830
+ } catch {
831
+ commit = null; // no git binary / not a repo / detached → no commit (fail-open)
832
+ }
833
+ return { session: session == null ? '' : String(session), commit, symbols: Array.isArray(touched) ? touched : [] };
834
+ }
835
+
836
+ // Resolve the install repo's current git HEAD — the citation's `commit`. Rooted at the install. Fail-open:
837
+ // no git / not a repo → null (mirrors dream.cjs / session-end-reward.cjs resolveGitHead).
838
+ function resolveGitHead(root) {
839
+ try {
840
+ const out = execFileSync('git', ['rev-parse', 'HEAD'], { cwd: root, encoding: 'utf8', stdio: ['ignore', 'pipe', 'ignore'] });
841
+ const sha = String(out || '').trim();
842
+ return sha || null;
843
+ } catch {
844
+ return null;
845
+ }
846
+ }
847
+
848
+ // The session-id → filename transform — MUST match code-intel-push's safeId byte-for-byte (replicated, no import).
849
+ function safeSessionId(sid) {
850
+ return String(sid || 'session').toLowerCase().replace(/[^a-z0-9]+/g, '-').replace(/^-+|-+$/g, '').slice(0, 48) || 'session';
851
+ }
852
+
853
+ // Read this session's edited paths from <root>/.wrxn/history/<safeId>.touched — the citation's `symbols` set
854
+ // (REUSE the list code-intel-push maintains). Deduped, order preserved. FAIL-OPEN: absent/unreadable → [].
855
+ function readTouched(root, sessionId) {
856
+ if (!root) return [];
857
+ let raw;
858
+ try {
859
+ raw = fs.readFileSync(path.join(root, '.wrxn', 'history', `${safeSessionId(sessionId)}.touched`), 'utf8');
860
+ } catch {
861
+ return [];
862
+ }
863
+ const seen = new Set();
864
+ const out = [];
865
+ for (const line of raw.split('\n')) {
866
+ const p = line.trim();
867
+ if (!p || seen.has(p)) continue;
868
+ seen.add(p);
869
+ out.push(p);
870
+ }
871
+ return out;
872
+ }
873
+
738
874
  // ── the wiki delete bridge (dream.cjs indirection contract) ──────────────────────
739
875
  function wikiAdapter() {
740
876
  return path.join(__dirname, 'wiki.cjs'); // sibling in the same install .wrxn/ dir
@@ -837,7 +973,7 @@ function runStage() {
837
973
  // proposal cannot write or delete (AC2). Then — and only then — WRITE THE SURVIVOR FIRST (knowledge
838
974
  // preserved), THEN delete each absorbed (AC4 survivor-before-delete). Atomic on validation: if ANY target
839
975
  // is unsafe the whole merge is refused (no partial delete). Returns { ok, ... } | { ok:false, reason }.
840
- function commitOne(root, rec) {
976
+ function commitOne(root, rec, lineage, evidence) {
841
977
  const description = rec.description ? String(rec.description) : '';
842
978
  const sec = secretScan(`${description}\n${rec.body}`); // re-scan at the write boundary
843
979
  if (sec) return { ok: false, reason: sec };
@@ -869,8 +1005,18 @@ function commitOne(root, rec) {
869
1005
  // WRITE THE SURVIVOR FIRST — the merged knowledge exists on disk before any page is deleted (AC4).
870
1006
  const mergedFrom = targets.map((t) => t.slug).slice().sort();
871
1007
  const page = composeSurvivor({ tier: rec.tier, slug: rec.slug, description, body: rec.body, mergedFrom });
1008
+ // S3 (#22): stamp the survivor's lineage (origin_session/synth_run/proposal_id) into its frontmatter at
1009
+ // compose time — proposal_id is the survivor slug. The body is preserved byte-for-byte (no churn).
1010
+ const stamped = stampLineage(page, {
1011
+ origin_session: (lineage && lineage.origin_session) || currentSession(),
1012
+ synth_run: (lineage && lineage.synth_run) || 'unknown',
1013
+ proposal_id: rec.slug,
1014
+ });
1015
+ // C3 (#36): stamp the forward citation evidence (session/commit/symbols) at compose time — beside lineage,
1016
+ // before the write. The body is preserved byte-for-byte (no churn); fail-open omits any unresolved field.
1017
+ const withEvidence = stampEvidence(stamped, evidence);
872
1018
  fs.mkdirSync(path.dirname(survAbs), { recursive: true });
873
- fs.writeFileSync(survAbs, page);
1019
+ fs.writeFileSync(survAbs, withEvidence);
874
1020
 
875
1021
  // THEN delete each absorbed — ONLY the staged cluster members (no free-form delete path) (AC4).
876
1022
  const deleted = [];
@@ -897,15 +1043,21 @@ function runCommit() {
897
1043
  const staged = readStaged(root);
898
1044
  const merged = [];
899
1045
  const skipped = [];
1046
+ // One ts per run: it keys this run's audit event AND is the survivors' synth_run, so a survivor's stamped
1047
+ // synth_run is byte-identical to the run id in the audit log (S3 #22 provenance binding, dream parity).
1048
+ const ts = new Date().toISOString();
1049
+ const lineage = { origin_session: currentSession(), synth_run: lineageScalar(ts) };
1050
+ // C3 (#36): the forward citation FACTS, resolved ONCE per run from ground truth (session/git HEAD/.touched).
1051
+ const evidence = resolveEvidence({ session: lineage.origin_session, resolveHead: () => resolveGitHead(root), touched: readTouched(root, lineage.origin_session) });
900
1052
  for (const ref of approved) {
901
1053
  const key = String(ref);
902
1054
  const rec = staged.get(key);
903
1055
  if (!rec) { skipped.push({ survivor: key, reason: 'not_staged' }); continue; }
904
- const res = commitOne(root, rec);
1056
+ const res = commitOne(root, rec, lineage, evidence);
905
1057
  if (res.ok) merged.push({ survivor: res.survivor, merged_from: res.merged_from, deleted: res.deleted, deleteFailed: res.deleteFailed });
906
1058
  else skipped.push({ survivor: key, reason: res.reason });
907
1059
  }
908
- appendLine(path.join(harvestDir(root), AUDIT_FILE), { ts: new Date().toISOString(), op: 'commit', merged: merged.map((m) => m.survivor), skipped });
1060
+ appendLine(path.join(harvestDir(root), AUDIT_FILE), { ts, op: 'commit', synth_run: lineageScalar(ts), origin_session: lineage.origin_session, merged: merged.map((m) => m.survivor), skipped });
909
1061
  return print({ merged, skipped });
910
1062
  }
911
1063
 
@@ -1235,6 +1387,9 @@ module.exports = {
1235
1387
  descriptionProblem,
1236
1388
  composeSurvivor,
1237
1389
  isKebab,
1390
+ // evidence stamp (C3 / #36) — pure citation primitives
1391
+ stampEvidence,
1392
+ resolveEvidence,
1238
1393
  // decay (harvest-04) — pure gate + annotation primitives
1239
1394
  reinforcedSet,
1240
1395
  dayStamp,