@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.
- package/manifest.json +40 -0
- package/package.json +2 -2
- package/payload/.claude/hooks/drift-detect.cjs +165 -0
- package/payload/.claude/hooks/synapse-engine.cjs +1 -0
- package/payload/.claude/hooks/wiki-lint.cjs +3 -0
- package/payload/.claude/settings.json +2 -1
- package/payload/.claude/skills/dream/SKILL.md +210 -0
- package/payload/.claude/skills/handoff/SKILL.md +2 -0
- package/payload/.claude/skills/sync/SKILL.md +106 -0
- package/payload/.wrxn/dream/.gitkeep +0 -0
- package/payload/.wrxn/dream.cjs +468 -0
- package/payload/.wrxn/sync.cjs +508 -0
- package/payload/.wrxn/wiki/_rules/.gitkeep +0 -0
- package/payload/.wrxn/wiki/_slots/.gitkeep +0 -0
- package/payload/.wrxn/wiki.cjs +31 -10
|
@@ -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();
|