@gcunharodrigues/wrxn 0.4.0 → 0.6.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.
@@ -0,0 +1,468 @@
1
+ #!/usr/bin/env node
2
+ 'use strict';
3
+
4
+ // WRXN dream adapter — the install-local Validation gate + audit/commit CLI.
5
+ // Sibling to wiki.cjs. Self-contained: this ships INTO an install and MUST NOT import the kernel lib
6
+ // (node stdlib only). The dream SKILL (the LLM, dream-02) PROPOSES; THIS adapter JUDGES (a
7
+ // deterministic gate) and records — "bad memory is worse than no memory".
8
+ //
9
+ // In THIS slice the adapter is driven by hand-fed proposal JSON (no LLM). Four subcommands:
10
+ // check <proposal.json | batch.json> run the Validation gate.
11
+ // · a single Proposal object → a single Verdict { ok, reason? }.
12
+ // · an array / { proposals:[…] } / { abstain:true } → a batch result
13
+ // { abstained, accepted[], rejected[{index,slug,reason}] } (applies the ≤5 run cap + restraint).
14
+ // stage <batch.json> record the VALIDATED (accepted) batch into the audit trail
15
+ // under .wrxn/dream/ as .jsonl (NEVER .md, so recon's prose ingestion never recalls a
16
+ // staged-but-unapproved proposal). Nothing is written to the wiki.
17
+ // commit <approved.json> write the operator-approved subset additively to their tiers,
18
+ // BY REFERENCE: approved.json is the approved SLUG list (["slug-a",…] or { approved:[…] }). For
19
+ // each slug we look up its staged proposal in staged.jsonl and RE-RUN the gate (validateProposal)
20
+ // at the write boundary, writing ONLY those that still pass — VIA the wiki.cjs adapter (the
21
+ // indirection contract). This binds committed == staged == presented: a gate-rejected proposal
22
+ // can never reach recall even if its slug is force-approved. Additive + dedup-SKIP; a slug not
23
+ // staged (not_staged) or one that fails re-validation is recorded skipped with the reason, and
24
+ // 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).
31
+ //
32
+ // Flag: --root <dir> (override the install-root walk-up; mainly for tests).
33
+ //
34
+ // Proposal { kind:"concept"|"decision"|"gotcha"|"rule"; tier:"concepts"|"decisions"|"gotchas"|"_rules";
35
+ // slug; title; body /* starts "# " */; confidence /*0–1*/; rationale; evidence:[{quote,source?}] }
36
+ // Verdict { ok:boolean; reason?:string /* machine code on reject */ }
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.
40
+
41
+ const fs = require('fs');
42
+ const path = require('path');
43
+ const { execFileSync } = require('child_process');
44
+
45
+ // kind → tier is the contract; the tier must agree with the kind. `rule → _rules` (dream-03) joins the
46
+ // three semantic tiers — this single map auto-extends the tier allowlist (TIERS) and the kind↔tier gate.
47
+ const KIND_TIER = { concept: 'concepts', decision: 'decisions', gotcha: 'gotchas', rule: '_rules' };
48
+ const TIERS = Object.values(KIND_TIER);
49
+ const CONFIDENCE_FLOOR = 0.75;
50
+ const BODY_MAX = 32000; // size cap (chars) — a durable page, not a transcript dump.
51
+ const MAX_ACCEPTED = 5; // one run can't flood the wiki.
52
+ const DREAM_DIR = ['.wrxn', 'dream'];
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';
61
+
62
+ // ── install-root resolution (mirrors wiki.cjs / enforce-managed-guard.cjs) ─────
63
+ function findInstallRoot(start) {
64
+ let dir = start || process.env.CLAUDE_PROJECT_DIR || process.cwd();
65
+ for (let i = 0; i < 12; i++) {
66
+ if (fs.existsSync(path.join(dir, 'wrxn.install.json'))) return dir;
67
+ const up = path.dirname(dir);
68
+ if (up === dir) break;
69
+ dir = up;
70
+ }
71
+ return null;
72
+ }
73
+
74
+ function flag(name) {
75
+ const i = process.argv.indexOf(`--${name}`);
76
+ return i > -1 ? process.argv[i + 1] : undefined;
77
+ }
78
+
79
+ function installRoot() {
80
+ const root = flag('root') || findInstallRoot();
81
+ if (!root) {
82
+ fail('cannot resolve the install root — run inside a wrxn install (no wrxn.install.json found walking up) or pass --root <dir>');
83
+ }
84
+ return root;
85
+ }
86
+
87
+ function fail(msg) {
88
+ process.stderr.write(`dream: ${msg}\n`);
89
+ process.exit(2);
90
+ }
91
+
92
+ function print(obj) {
93
+ process.stdout.write(JSON.stringify(obj, null, 2) + '\n');
94
+ }
95
+
96
+ // The first positional after the subcommand (the JSON file path), up to the first --flag.
97
+ function positionalFile() {
98
+ for (let i = 3; i < process.argv.length; i++) {
99
+ if (process.argv[i].startsWith('--')) break;
100
+ return process.argv[i];
101
+ }
102
+ fail('missing <file.json> argument');
103
+ return undefined;
104
+ }
105
+
106
+ // ── the wiki adapter (the indirection contract — we never read/write wiki .md files directly) ──
107
+ function wikiAdapter() {
108
+ return path.join(__dirname, 'wiki.cjs'); // sibling in the same install .wrxn/ dir
109
+ }
110
+
111
+ // Guard the dream→wiki bridge against argv flag-injection (security M3): a user-controlled value
112
+ // beginning with `--` would be parsed by wiki.cjs's flag()/positionals() scan as a flag (e.g. a title
113
+ // "--root" redirects the write out of the wiki). The gate already rejects a `--`-leading slug
114
+ // (invalid_slug) and title (invalid_title); this is the defense-in-depth backstop at the exec boundary.
115
+ function guardArgv(values) {
116
+ for (const v of values) {
117
+ if (typeof v === 'string' && v.startsWith('--')) {
118
+ throw new Error(`flag-injection guard: refusing a --leading value at the wiki bridge: ${JSON.stringify(v.slice(0, 32))}`);
119
+ }
120
+ }
121
+ }
122
+
123
+ function wikiQuery(root, terms, opts) {
124
+ const o = opts || {};
125
+ guardArgv(terms.map(String));
126
+ const args = [wikiAdapter(), 'query', ...terms.map(String), '--root', root, '--limit', String(o.limit || 5000)];
127
+ if (o.tier) args.push('--tier', o.tier);
128
+ return JSON.parse(execFileSync('node', args, { encoding: 'utf8' }));
129
+ }
130
+
131
+ function wikiWritePage(root, tier, slug, description, body) {
132
+ guardArgv([slug, String(description || ''), String(body || '')]);
133
+ const args = [wikiAdapter(), 'write-page', tier, slug, '--description', String(description || ''), '--body', String(body || ''), '--root', root];
134
+ return JSON.parse(execFileSync('node', args, { encoding: 'utf8' }));
135
+ }
136
+
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
+ function normalizeTitle(t) {
146
+ return String(t == null ? '' : t).toLowerCase().replace(/\s+/g, ' ').trim();
147
+ }
148
+
149
+ // ── anti-superstition negative filters ────────────────────────────────────────
150
+ // A mechanical backstop to the dream skill's prompt (the skill is the primary semantic filter; this is
151
+ // "reinforced where mechanical"). Each pattern catches a class of transient/false "memory" that, if
152
+ // hardened into a recalled page, would poison future sessions. Matched over the proposal's AUTHORED
153
+ // text (title + body + rationale) ONLY — never the verbatim evidence quotes, which legitimately may
154
+ // contain the very failure phrasing a durable gotcha records.
155
+ const NEGATIVE_FILTERS = [
156
+ // "tool X is broken" — a broad negative tool claim hardens into a permanent false refusal.
157
+ { reason: 'negative_filter_tool_broken', re: /\bis (currently |completely |totally |again |now )?broken\b|\b(are|was|were) broken\b|\b(does|do|did)(n['’‛]t| not) work\b|\bnot working\b|\bis (down|unusable|busted|borked|useless)\b|\b(always|constantly|keeps?|forever) (fail(s|ing)?|break(s|ing)?|crash(es|ing)?)\b/ },
158
+ // a transient environment / setup failure — not a durable property of the system. (The bare adjectives
159
+ // `transient`/`intermittent` are intentionally NOT here: they false-positive on DI-lifetime decisions
160
+ // like "services are registered transient" — the concrete error codes below carry the failure intent.)
161
+ { reason: 'negative_filter_transient_failure', re: /\b(econnrefused|enoent|eaddrinuse|etimedout|connection refused|connection reset|timed out|time-?out|flak(e|y|ey)|rate[- ]?limit(ed)?|http 5\d\d|50[234]|port (already )?in use|address already in use|network (error|issue|glitch)|dns (error|failure))\b/ },
162
+ // a smoke / sanity / happy-path RESULT — proves nothing durable. Gated on a result word so a forward
163
+ // decision ("we adopt smoke tests", "the happy path must stay fast") is NOT a false positive.
164
+ { reason: 'negative_filter_smoke_test', re: /\bhello[- ]?world\b|\b(smoke[- ]?tests?|sanity[- ]?checks?|happy path)\s+(pass(ed|es|ing)?|ran|run|succeed(ed|s)?|works?|worked|green)\b/ },
165
+ // a release / version EVENT — a one-time act, not durable knowledge. (Bare nouns `released`/`changelog`/
166
+ // `version bump` are intentionally NOT here: they false-positive on release-POLICY decisions.)
167
+ { reason: 'negative_filter_release_marker', re: /\b(release notes?|bump(ed|ing)? (the )?version|tagged v\d|published to npm|npm publish|cut (a|the) release)\b/ },
168
+ // a one-off task narrative — "today I renamed/fixed-a-typo" is episodic, not semantic.
169
+ { reason: 'negative_filter_one_off', re: /\b(one[- ]?off|just this once|one[- ]?time only|fixed a typo|typo fix|renamed (the )?(file|variable|function|method)|moved (the )?file|trivial (chore|fix|task)|quick chore)\b/ },
170
+ // never memorialize wrxn itself (its own routing / skill / engine text) — the memory system must not
171
+ // pollute itself. (The bare word `synapse` is intentionally NOT here — it false-positives on "Azure
172
+ // Synapse" / "Matrix Synapse"; wrxn's OWN synapse is still caught by the qualified `wrxn…synapse` clause.)
173
+ { reason: 'negative_filter_wrxn_self', re: /\bsynapse-engine\b|\.claude\/(skills|hooks)\b|\bskill\.md\b|\bwiki\.cjs\b|\bdream\.cjs\b|\bconstitution\.md\b|\b(routing|keyword[- ]?recall) domain\b|\bwrxn['’‛]?s?\s+(own|routing|skill|synapse|hook|constitution|manifest|payload|kernel|adapter)\b/ },
174
+ ];
175
+
176
+ function negativeFilter(text) {
177
+ const lc = String(text || '').toLowerCase();
178
+ for (const f of NEGATIVE_FILTERS) if (f.re.test(lc)) return f.reason;
179
+ return null;
180
+ }
181
+
182
+ // ── credential / secret scan (security M2) ────────────────────────────────────
183
+ // A durable page must never harden a session secret into recalled memory. Scanned over the AUTHORED
184
+ // text (title + body + rationale) — the same scope as the negative filters, and CASE-SENSITIVE (these
185
+ // token shapes are case-specific). Evidence quotes are audit-only (never written to a page) so they
186
+ // stay out of scope here, like the negative filters.
187
+ const SECRET_PATTERNS = [
188
+ /AKIA[0-9A-Z]{16}/, // AWS access key id
189
+ /gh[pousr]_[A-Za-z0-9]{36}/, // GitHub token (ghp_/gho_/ghu_/ghs_/ghr_)
190
+ /npm_[A-Za-z0-9]{36}/, // npm automation token
191
+ /sk-[A-Za-z0-9]{20,}/, // OpenAI-style secret key
192
+ /-----BEGIN [A-Z ]*PRIVATE KEY-----/, // PEM private-key header
193
+ ];
194
+
195
+ function secretScan(text) {
196
+ const s = String(text || ''); // NOT lowercased — the token shapes are case-sensitive.
197
+ for (const re of SECRET_PATTERNS) if (re.test(s)) return 'contains_secret';
198
+ return null;
199
+ }
200
+
201
+ // ── the pure per-proposal gate ────────────────────────────────────────────────
202
+ // Deterministic given (proposal, io). `io` injects the dedup IO so the gate stays a pure, unit-testable
203
+ // function (mirrors Phase-2 decideRecall): io.pathExists(tier,slug) / io.titleExists(title,tier,slug).
204
+ // Precedence: routing validity → quality → content safety → dedup (the last, most-expensive check).
205
+ function validateProposal(p, io) {
206
+ if (!p || typeof p !== 'object' || Array.isArray(p)) return { ok: false, reason: 'invalid_proposal' };
207
+ if (!TIERS.includes(p.tier)) return { ok: false, reason: 'unsupported_tier' };
208
+ if (KIND_TIER[p.kind] !== p.tier) return { ok: false, reason: 'kind_tier_mismatch' };
209
+ if (typeof p.confidence !== 'number' || p.confidence < CONFIDENCE_FLOOR) return { ok: false, reason: 'confidence_below_threshold' };
210
+ if (!Array.isArray(p.evidence) || p.evidence.length === 0 ||
211
+ !p.evidence.every((e) => e && typeof e.quote === 'string' && e.quote.trim().length > 0)) {
212
+ return { ok: false, reason: 'missing_evidence' };
213
+ }
214
+ if (typeof p.rationale !== 'string' || p.rationale.trim().length === 0) return { ok: false, reason: 'missing_rationale' };
215
+ if (typeof p.body !== 'string' || !p.body.startsWith('# ')) return { ok: false, reason: 'body_missing_h1' };
216
+ if (p.body.length > BODY_MAX) return { ok: false, reason: 'body_too_large' };
217
+ const authored = `${p.title || ''}\n${p.body}\n${p.rationale}`;
218
+ const neg = negativeFilter(authored);
219
+ if (neg) return { ok: false, reason: neg };
220
+ const sec = secretScan(authored);
221
+ if (sec) return { ok: false, reason: sec };
222
+ // identity fields — gated BEFORE the (expensive) dedup IO so a bad/missing slug or a flag-injecting
223
+ // title can never reach the wiki bridge (a no-slug proposal otherwise passes check then drops at commit).
224
+ if (typeof p.slug !== 'string' || !/^[a-z0-9][a-z0-9-]*$/.test(p.slug)) return { ok: false, reason: 'invalid_slug' };
225
+ if (typeof p.title !== 'string' || p.title.trim().length === 0) return { ok: false, reason: 'missing_title' };
226
+ if (p.title.startsWith('--')) return { ok: false, reason: 'invalid_title' };
227
+ if (io.pathExists(p.tier, p.slug)) return { ok: false, reason: 'duplicate_existing_path' };
228
+ if (io.titleExists(p.title, p.tier, p.slug)) return { ok: false, reason: 'duplicate_existing_title' };
229
+ return { ok: true };
230
+ }
231
+
232
+ // Run-level gate: restraint (empty/abstain ⇒ write nothing) + the ≤5 accepted cap.
233
+ function validateRun(proposals, io) {
234
+ const accepted = [];
235
+ const rejected = [];
236
+ let acceptedCount = 0;
237
+ for (let i = 0; i < proposals.length; i++) {
238
+ const p = proposals[i];
239
+ const v = validateProposal(p, io);
240
+ if (v.ok) {
241
+ acceptedCount += 1;
242
+ if (acceptedCount > MAX_ACCEPTED) {
243
+ rejected.push({ index: i, slug: p && p.slug, reason: 'max_proposals_exceeded' });
244
+ } else {
245
+ accepted.push(p);
246
+ }
247
+ } else {
248
+ rejected.push({ index: i, slug: p && p.slug, reason: v.reason });
249
+ }
250
+ }
251
+ return { abstained: false, accepted, rejected };
252
+ }
253
+
254
+ // Dedup IO backed by the wiki adapter's query (lazy — only fires for an otherwise-valid proposal).
255
+ function makeIo(root) {
256
+ return {
257
+ pathExists(tier, slug) {
258
+ if (!slug || !TIERS.includes(tier)) return false;
259
+ try {
260
+ const res = wikiQuery(root, [slug], { tier, limit: 5000 });
261
+ const page = `${tier}/${slug}.md`;
262
+ return (res.hits || []).some((h) => h.file === page);
263
+ } catch {
264
+ return false;
265
+ }
266
+ },
267
+ titleExists(title, tier, slug) {
268
+ const want = normalizeTitle(title);
269
+ if (!want) return false;
270
+ try {
271
+ const res = wikiQuery(root, [String(title)], { limit: 5000 });
272
+ const ownPage = `${tier}/${slug}.md`;
273
+ return (res.hits || []).some((h) => {
274
+ const m = /^description:\s*(.*)$/i.exec(h.snippet || '');
275
+ return m && normalizeTitle(m[1]) === want && h.file !== ownPage;
276
+ });
277
+ } catch {
278
+ return false;
279
+ }
280
+ },
281
+ };
282
+ }
283
+
284
+ // Normalize any check/stage/commit input into { proposals[], abstain }.
285
+ function normalizeBatch(input) {
286
+ if (input && typeof input === 'object' && input.abstain === true) return { proposals: [], abstain: true };
287
+ if (Array.isArray(input)) return { proposals: input, abstain: input.length === 0 };
288
+ if (input && typeof input === 'object' && Array.isArray(input.proposals)) {
289
+ return { proposals: input.proposals, abstain: input.proposals.length === 0 };
290
+ }
291
+ return { proposals: [input], abstain: false }; // a bare single proposal
292
+ }
293
+
294
+ function isBatchInput(input) {
295
+ return Array.isArray(input) ||
296
+ (input && typeof input === 'object' && (Array.isArray(input.proposals) || input.abstain === true));
297
+ }
298
+
299
+ function readJson(file) {
300
+ try {
301
+ return JSON.parse(fs.readFileSync(file, 'utf8'));
302
+ } catch (err) {
303
+ fail(`cannot read JSON from "${file}": ${err.message}`);
304
+ return undefined;
305
+ }
306
+ }
307
+
308
+ function appendLine(file, obj) {
309
+ fs.appendFileSync(file, JSON.stringify(obj) + '\n');
310
+ }
311
+
312
+ function dreamDir(root) {
313
+ const dir = path.join(root, ...DREAM_DIR);
314
+ fs.mkdirSync(dir, { recursive: true });
315
+ return dir;
316
+ }
317
+
318
+ // ── subcommands ───────────────────────────────────────────────────────────────
319
+ function runCheck() {
320
+ const input = readJson(positionalFile());
321
+ const root = installRoot();
322
+ const io = makeIo(root);
323
+ if (isBatchInput(input)) {
324
+ const { proposals, abstain } = normalizeBatch(input);
325
+ if (abstain) return print({ abstained: true, accepted: [], rejected: [] });
326
+ return print(validateRun(proposals, io));
327
+ }
328
+ return print(validateProposal(input, io)); // single proposal → single Verdict
329
+ }
330
+
331
+ function runStage() {
332
+ const input = readJson(positionalFile());
333
+ const root = installRoot();
334
+ const { proposals, abstain } = normalizeBatch(input);
335
+ if (abstain) return print({ abstained: true, staged: 0 }); // restraint: write nothing
336
+
337
+ const res = validateRun(proposals, makeIo(root));
338
+ const dir = dreamDir(root);
339
+ const ts = new Date().toISOString();
340
+ const stagedPath = path.join(dir, STAGED_FILE);
341
+ // staged.jsonl — the validated-but-unapproved proposals, full content, one JSON line each. NEVER .md
342
+ // (recon walks all of .wrxn/ and prose-ingests *.md, so the audit trail must stay non-markdown).
343
+ for (const p of res.accepted) appendLine(stagedPath, { ts, op: 'stage', slug: p.slug, tier: p.tier, proposal: p });
344
+ // audit.jsonl — the append-only outcome log (accepted slugs + the rejection reasons).
345
+ appendLine(path.join(dir, AUDIT_FILE), { ts, op: 'stage', accepted: res.accepted.map((p) => p.slug), rejected: res.rejected });
346
+ return print({
347
+ abstained: false,
348
+ staged: res.accepted.length,
349
+ rejected: res.rejected.length,
350
+ stagedFile: res.accepted.length ? path.relative(root, stagedPath) : null,
351
+ });
352
+ }
353
+
354
+ // Normalize commit input into the operator-approved SLUG list (["slug-a",…] or { approved:[…] }).
355
+ function approvedSlugs(input) {
356
+ if (Array.isArray(input)) return input.map(String);
357
+ if (input && typeof input === 'object' && Array.isArray(input.approved)) return input.approved.map(String);
358
+ return [];
359
+ }
360
+
361
+ // Read staged.jsonl into a slug → staged-proposal map (last staged wins). Malformed lines are skipped.
362
+ function readStaged(root) {
363
+ const map = new Map();
364
+ let txt;
365
+ try {
366
+ txt = fs.readFileSync(path.join(root, ...DREAM_DIR, STAGED_FILE), 'utf8');
367
+ } catch {
368
+ return map; // no staged trail yet → nothing to commit by reference
369
+ }
370
+ for (const line of txt.split('\n')) {
371
+ const s = line.trim();
372
+ if (!s) continue;
373
+ try {
374
+ const rec = JSON.parse(s);
375
+ if (rec && rec.slug && rec.proposal) map.set(rec.slug, rec.proposal);
376
+ } catch {
377
+ /* skip a malformed audit line */
378
+ }
379
+ }
380
+ return map;
381
+ }
382
+
383
+ // commit BY REFERENCE: bind committed == staged == presented. For each operator-approved slug we look up
384
+ // its staged proposal and RE-RUN the full gate (validateProposal) at the write boundary — so a proposal
385
+ // the gate would reject can never reach recall, even if its slug is force-approved. A slug not staged, or
386
+ // one that fails re-validation (confidence, evidence, body H1, kind↔tier, secret-scan, negative filters,
387
+ // identity, dedup — all re-checked), is recorded skipped with the reason; the rest of the batch still writes.
388
+ function runCommit() {
389
+ const input = readJson(positionalFile());
390
+ const root = installRoot();
391
+ const approved = approvedSlugs(input);
392
+ const io = makeIo(root);
393
+ const staged = readStaged(root);
394
+ const written = [];
395
+ const skipped = [];
396
+ for (const slug of approved) {
397
+ const key = String(slug);
398
+ const p = staged.get(key);
399
+ if (!p) {
400
+ skipped.push({ slug: key, reason: 'not_staged' });
401
+ continue;
402
+ }
403
+ const v = validateProposal(p, io); // the re-gate — additive + dedup-skip + every quality/safety check
404
+ if (!v.ok) {
405
+ skipped.push({ slug: key, reason: v.reason });
406
+ continue;
407
+ }
408
+ try {
409
+ const r = wikiWritePage(root, p.tier, p.slug, p.title, p.body);
410
+ written.push({ slug: p.slug, tier: p.tier, file: r.written });
411
+ } catch (err) {
412
+ // wiki.cjs write-page does process.exit(2) on an existing page — catch the non-zero exit so a
413
+ // TOCTOU collision (or any single write failure) is recorded and the rest of the batch STILL writes.
414
+ const stderr = String((err && err.stderr) || '');
415
+ const reason = /already exists/i.test(stderr) ? 'duplicate_existing_path' : 'skipped_error';
416
+ skipped.push({ slug: key, reason });
417
+ }
418
+ }
419
+ appendLine(path.join(dreamDir(root), AUDIT_FILE), { ts: new Date().toISOString(), op: 'commit', written: written.map((w) => w.slug), skipped });
420
+ return print({ written, skipped });
421
+ }
422
+
423
+ // set-focus — write/overwrite the durable standing-focus slot. The LONE update-exception: it is the
424
+ // only op that overwrites a wiki page, and only ever `_slots/current-focus.md`. It is NOT a knowledge
425
+ // proposal (no gate, no dedup) — the skill drafts a short standing-focus statement, the operator
426
+ // confirms, then this writes it via the wiki --force path. DISJOINT from the continuity baton:
427
+ // set-focus never reads or writes `.wrxn/continuity/latest.md` (single-writer = the handoff skill).
428
+ function runSetFocus() {
429
+ const input = readJson(positionalFile());
430
+ const root = installRoot();
431
+ const obj = input && typeof input === 'object' && !Array.isArray(input) ? input : {};
432
+ const title = obj.title ? String(obj.title) : 'Current focus';
433
+ const body = obj.body;
434
+ if (typeof body !== 'string' || body.trim().length === 0) {
435
+ fail('set-focus needs a non-empty "body" — the standing-focus statement to pin');
436
+ }
437
+ // Gate the focus slot too (security M1): it is an ungated --force write, so run the same content-safety
438
+ // checks the knowledge gate runs — the anti-superstition negative filters + the credential secret-scan
439
+ // — over the focus title+body, and refuse a `--`-leading flag-injection value. Reject ⇒ nothing written.
440
+ const scan = `${title}\n${body}`;
441
+ const neg = negativeFilter(scan);
442
+ if (neg) fail(`set-focus rejected — the focus body trips a negative filter (${neg}); state durable standing context, not a transient note`);
443
+ const sec = secretScan(scan);
444
+ if (sec) fail(`set-focus rejected — the focus body contains a credential (${sec}); never pin a session secret`);
445
+ if (title.startsWith('--') || body.startsWith('--')) fail('set-focus rejected — a --leading title/body is refused (flag-injection guard)');
446
+ const r = wikiForceWritePage(root, FOCUS_TIER, FOCUS_SLUG, title, body);
447
+ appendLine(path.join(dreamDir(root), AUDIT_FILE), { ts: new Date().toISOString(), op: 'set-focus', file: r.written });
448
+ return print({ focus: r.written });
449
+ }
450
+
451
+ function main() {
452
+ const cmd = process.argv[2];
453
+ switch (cmd) {
454
+ case 'check':
455
+ return runCheck();
456
+ case 'stage':
457
+ return runStage();
458
+ case 'commit':
459
+ return runCommit();
460
+ case 'set-focus':
461
+ return runSetFocus();
462
+ default:
463
+ process.stdout.write('Usage: node .wrxn/dream.cjs <check|stage|commit|set-focus> <file.json> [--root <dir>]\n');
464
+ process.exit(cmd ? 2 : 0);
465
+ }
466
+ }
467
+
468
+ main();