@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
package/payload/.wrxn/dream.cjs
CHANGED
|
@@ -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
|
-
|
|
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
|
-
|
|
534
|
-
|
|
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,
|
|
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:
|
|
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,
|