@gcunharodrigues/wrxn 0.4.0 → 0.5.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 CHANGED
@@ -98,6 +98,11 @@
98
98
  "class": "managed",
99
99
  "profile": "project"
100
100
  },
101
+ {
102
+ "path": ".claude/skills/dream/SKILL.md",
103
+ "class": "managed",
104
+ "profile": "project"
105
+ },
101
106
  {
102
107
  "path": ".claude/skills/grill-me/SKILL.md",
103
108
  "class": "managed",
@@ -408,6 +413,16 @@
408
413
  "class": "state",
409
414
  "profile": "project"
410
415
  },
416
+ {
417
+ "path": ".wrxn/dream.cjs",
418
+ "class": "managed",
419
+ "profile": "project"
420
+ },
421
+ {
422
+ "path": ".wrxn/dream/.gitkeep",
423
+ "class": "state",
424
+ "profile": "project"
425
+ },
411
426
  {
412
427
  "path": ".wrxn/wiki.cjs",
413
428
  "class": "managed",
@@ -438,6 +453,16 @@
438
453
  "class": "state",
439
454
  "profile": "project"
440
455
  },
456
+ {
457
+ "path": ".wrxn/wiki/_rules/.gitkeep",
458
+ "class": "state",
459
+ "profile": "project"
460
+ },
461
+ {
462
+ "path": ".wrxn/wiki/_slots/.gitkeep",
463
+ "class": "state",
464
+ "profile": "project"
465
+ },
441
466
  {
442
467
  "path": "docs/agents/domain.md",
443
468
  "class": "seeded",
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@gcunharodrigues/wrxn",
3
- "version": "0.4.0",
3
+ "version": "0.5.0",
4
4
  "description": "WRXN Kernel — installable AI operating system. Two profiles (project | workspace), pull-based updates, managed/seeded/state file classes.",
5
5
  "bin": {
6
6
  "wrxn": "bin/wrxn.cjs"
@@ -257,6 +257,7 @@ function handoffDirective(consumed, pct) {
257
257
  ' 1. Finish the current request.',
258
258
  ' 2. Run the handoff skill to write the baton (a compact handoff document).',
259
259
  ' 3. Tell the operator to /clear and open a fresh session, where the baton injects on resume.',
260
+ ' Suggestion (optional, before step 2): run the dream skill to consolidate this session\'s durable learnings into wiki memory — a suggestion only; dream never auto-runs, it acts only when you invoke it.',
260
261
  ].join('\n');
261
262
  }
262
263
 
@@ -15,6 +15,9 @@
15
15
  const fs = require('fs');
16
16
  const path = require('path');
17
17
 
18
+ // The human-prose tiers only. The `_`-prefixed tiers (`_rules` and `_slots`) are machine-written by the
19
+ // dream adapter through wiki.cjs — they are deliberately OUTSIDE this human-prose frontmatter lint, not
20
+ // an omission (dream-03: no silent divergence).
18
21
  const TIERS = ['concepts', 'decisions', 'gotchas', 'sessions'];
19
22
  const REQUIRED_KEYS = ['name', 'description', 'tier'];
20
23
  const MAX_FLAGGED = 20;
@@ -0,0 +1,210 @@
1
+ ---
2
+ name: dream
3
+ description: Consolidate the live session into durable wiki memory — reflect on this conversation, draft evidence-backed Proposals (concept/decision/gotcha/rule), gate each through the dream adapter, and on your confirmation write net-new pages the Brain will recall next time. Use when someone says "dream", "consolidate this session", "save what we learned", or wants to capture durable memory before a handoff.
4
+ user-invocable: true
5
+ ---
6
+
7
+ # dream — session consolidation
8
+
9
+ dream turns what you learned in **this session** into durable wiki pages the **Brain** will recall in
10
+ future sessions. You **propose**; the deterministic adapter **judges**. Every page must quote the
11
+ session that justifies it, and **nothing is written without the operator's confirmation** — so a bad
12
+ proposal can never poison recall. "Bad memory is worse than no memory."
13
+
14
+ ## Indirection contract (MUST)
15
+
16
+ > Drive the adapters. NEVER write wiki files directly and NEVER re-implement the gate.
17
+
18
+ - Validate / stage / commit go through **`.wrxn/dream.cjs`** (the Validation gate + audit + writer).
19
+ - Wiki reads (to check what already exists, or to confirm recall) go through **`.wrxn/wiki.cjs`**.
20
+ - The skill is the **semantic** filter (you don't even draft junk); the adapter is the **mechanical**
21
+ backstop (it rejects what slips through). Run a proposal the gate rejected? It is never written.
22
+
23
+ ## The loop
24
+
25
+ 1. **Reflect** on the live conversation already in your context. Do NOT read transcripts or stored
26
+ session pages — reflect on what is in front of you, this session only.
27
+ 2. **Draft** candidate Proposals (see schema + rubric below), each grounded in a **verbatim quote**
28
+ from THIS session. If the session yields no durable insight, **abstain** — propose nothing.
29
+ 3. **check** the batch through the adapter; drop or fix anything it rejects (never carry a reject
30
+ forward).
31
+ 4. **stage** the validated batch — records it to the audit trail, outside the recalled wiki.
32
+ 5. **Present** the staged batch to the operator and wait for confirmation.
33
+ 6. **commit** only the operator-approved subset — net-new pages, additively, into their tiers.
34
+
35
+ If reflection surfaces nothing durable, or the gate rejects every proposal, **stop**: say so, stage
36
+ nothing, commit nothing. Restraint is a success, not a failure.
37
+
38
+ ## FAITHFULNESS — the most important rule
39
+
40
+ The wiki records *what happened in this project, this session* — not what you know about the topic in
41
+ general. You are not writing tutorials, documentation, or reference material. Every claim in every
42
+ page MUST trace to the session in front of you.
43
+
44
+ Do NOT:
45
+ - Invent dates, version numbers, commit hashes, author names, file paths, function names, line
46
+ numbers, or error codes that did not appear in the session.
47
+ - Add "When to use" / "Best practices" / "Alternatives" / "See also" sections that weren't grounded in
48
+ the session — those are reference-material patterns, not memory.
49
+ - Enumerate options that weren't actually considered, or expand a terse operator comment into an essay.
50
+ - Fabricate code or speculate about consequences the session itself didn't raise.
51
+ - **Write a session secret into a page.** Redact any credential (API key, token, private key) that
52
+ surfaced in the session — a durable page is recalled forever. The gate also rejects `contains_secret`,
53
+ but you are the first filter.
54
+
55
+ Do:
56
+ - Compress the session into well-titled pages with the right `kind`.
57
+ - **Preserve the operator's actual phrasing** for decisions and rules — it is load-bearing.
58
+ - Write each page at the length the session actually warrants — dense fact, no padding, no truncation.
59
+ - If the session yields no durable insight, **abstain**. Resist the urge to manufacture content.
60
+
61
+ ## What to propose — the `kind` rubric
62
+
63
+ Exactly one kind per Proposal; `tier` must agree with `kind`.
64
+
65
+ | kind | tier | propose when the session produced… |
66
+ |------------|--------------|-------------------------------------------------------------------------------|
67
+ | `decision` | `decisions` | a choice of X over Y, with its rationale and consequences (why the project is the way it is) |
68
+ | `gotcha` | `gotchas` | a reproducible pitfall / failure mode, its root cause, and the mitigation |
69
+ | `concept` | `concepts` | stable architecture or domain knowledge (synthesis, not a task chronology) |
70
+ | `rule` | `_rules` | an always/never project convention the session established — a standing rule, recalled like a concept (NOT a SYNAPSE always-on rule; see Boundaries) |
71
+
72
+ Two unrelated insights stay **two** pages — never merge them into one. Small pages, stable
73
+ kebab-case names (Karpathy LLM-wiki style). Cap a run at **≤ 5** proposals.
74
+
75
+ ## What NOT to propose — anti-superstition
76
+
77
+ Do not even draft these. A transient or false "memory", once recalled, hardens into a permanent false
78
+ constraint on every future session. (The adapter rejects them too — but you are the first filter.)
79
+
80
+ | Reject | Why |
81
+ |---------------------------------|----------------------------------------------------------------------|
82
+ | "tool X is broken" | A broad negative tool claim hardens into a permanent false refusal after the tool is fixed. |
83
+ | Transient env / setup failures | ENOENT, connection refused, timeouts, flaky/intermittent, rate-limits, a missing binary — stale false constraints, not durable truth. |
84
+ | Smoke / sanity / happy-path checks | Operational evidence, not reusable knowledge. |
85
+ | Release / version markers | A one-time event (a version bump, a changelog, an npm publish), not a lesson. |
86
+ | One-off task narratives | "Renamed a file", "fixed a typo", a trivial chore — episodic, already captured. |
87
+ | **wrxn itself** | Never memorialize wrxn's own routing / skills / synapse / hooks / constitution / adapters — the memory system must not pollute itself. |
88
+
89
+ ## Proposal schema
90
+
91
+ A Proposal is one JSON object. A run is a JSON **array** of them (the batch).
92
+
93
+ ```jsonc
94
+ {
95
+ "kind": "concept" | "decision" | "gotcha" | "rule", // pick one
96
+ "tier": "concepts" | "decisions" | "gotchas" | "_rules", // = f(kind); MUST agree
97
+ "slug": "kebab-case-page-name", // stable name
98
+ "title": "One-line page title",
99
+ "body": "# Title\n\n…markdown… ", // MUST start with '# '
100
+ "confidence": 0.0, // honest 0–1; the gate floor is 0.75
101
+ "rationale": "Why this is durable.",
102
+ "evidence": [ // >= 1, each a VERBATIM quote from THIS session
103
+ { "quote": "exact words from the session", "source": "file:line | commit | turn-N" } // source optional
104
+ ]
105
+ }
106
+ ```
107
+
108
+ ## Driving the adapter
109
+
110
+ Run from inside the install (the adapter walks up to `wrxn.install.json` to find the root — no
111
+ `--root` needed). Write each batch to a **throwaway temp file** (it is scratch input, not a wiki page;
112
+ only the adapter's own `.wrxn/dream/*.jsonl` audit files persist).
113
+
114
+ **1 — check** (the gate; PROPOSE, then let it JUDGE):
115
+
116
+ ```bash
117
+ node .wrxn/dream.cjs check /tmp/dream-batch.json
118
+ ```
119
+
120
+ A batch returns `{ abstained, accepted[], rejected[ {index, slug, reason} ] }`. Each `reason` is a
121
+ machine code — `confidence_below_threshold`, `missing_evidence`, `missing_rationale`,
122
+ `body_missing_h1`, `body_too_large`, `invalid_slug`, `missing_title`, `invalid_title`,
123
+ `unsupported_tier`, `kind_tier_mismatch`, `contains_secret`, `duplicate_existing_path`,
124
+ `duplicate_existing_title`, `max_proposals_exceeded`, or a `negative_filter_*`. Fix or drop every
125
+ rejected proposal; re-check until the batch is clean. If it returns `{ abstained: true }` (or every
126
+ proposal is rejected), **stop** — write nothing.
127
+
128
+ If the gate rejects a genuinely durable insight on a `negative_filter_*` **false positive** (e.g. a real
129
+ decision that merely mentions "transient", "synapse", or "release"), **rephrase** the page to drop the
130
+ transient/operational wording — state the durable decision, not the episodic event — then re-check.
131
+ Never write around the gate.
132
+
133
+ **2 — stage** (record the validated batch to the audit trail; nothing reaches the wiki yet):
134
+
135
+ ```bash
136
+ node .wrxn/dream.cjs stage /tmp/dream-batch.json
137
+ ```
138
+
139
+ **3 — present, then confirm.** Show the operator each staged proposal — its **tier/slug**, **title**,
140
+ **confidence**, the **verbatim evidence quote**, and the one-line rationale — and ask which to approve.
141
+ Never skip this step. If the operator approves none, you are done: commit nothing.
142
+
143
+ **4 — commit** (write ONLY the operator-approved subset, **by reference**). Build a JSON array of the
144
+ approved **slugs** — NOT a rebuilt Proposal array — and commit it:
145
+
146
+ ```bash
147
+ node .wrxn/dream.cjs commit /tmp/dream-approved.json # ["slug-a","slug-b"] (or {"approved":["slug-a",…]})
148
+ ```
149
+
150
+ `commit` reads `.wrxn/dream/staged.jsonl`, finds each approved slug's **staged** proposal, **re-runs the
151
+ gate** on it (confidence, evidence, body H1, kind↔tier, secret-scan, negative filters, identity, dedup —
152
+ everything `check` ran), and writes ONLY the ones that still pass — net-new pages, additively, via
153
+ `wiki.cjs`. It **dedup-skips** any whose path already exists (never clobbers a curated page); a slug not
154
+ staged (`not_staged`) or one that fails re-validation is recorded skipped with the reason. Returns
155
+ `{ written[], skipped[] }`. This binds *committed == staged == presented*: a proposal the gate would
156
+ reject can never be written, even if its slug is force-approved.
157
+
158
+ **5 — confirm recall (optional).** The committed pages are plain `.md` in the wiki, so the Brain
159
+ recalls them automatically next session. Spot-check with a wiki query:
160
+
161
+ ```bash
162
+ node .wrxn/wiki.cjs query "<a phrase from a page you just wrote>"
163
+ ```
164
+
165
+ ## Refreshing the focus slot
166
+
167
+ `_slots/current-focus.md` is the project's **durable standing focus** — a short statement of what the
168
+ project is centered on right now, recall-surfaced like any other page. It is the **lone updatable wiki
169
+ page**: every knowledge page is additive + dedup-skip, but the focus slot may be **overwritten in
170
+ place**.
171
+
172
+ This is **not** the knowledge-proposal loop — do not run a focus update through `check` / `stage` /
173
+ `commit` (those are for evidence-backed concept/decision/gotcha/rule pages). The slot has its **own op**:
174
+
175
+ 1. Draft a short standing-focus statement (a few lines of markdown, body starting with `# `).
176
+ 2. **Present it to the operator and wait for confirmation** — like every dream write.
177
+ 3. On approval, write it via the dedicated op — it overwrites the slot in place:
178
+
179
+ ```bash
180
+ node .wrxn/dream.cjs set-focus /tmp/dream-focus.json # { "title": "Current focus", "body": "# Current focus\n\n…" }
181
+ ```
182
+
183
+ The focus slot is **gated** too: `set-focus` runs the anti-superstition negative filters and the
184
+ credential secret-scan over the focus body and **refuses** (writing nothing) if either fires. Redact
185
+ secrets and pin durable standing context, not a transient note.
186
+
187
+ **Continuity doctrine — do not cross these wires.** The focus slot is **disjoint** from the handoff
188
+ **baton** (`.wrxn/continuity/latest.md`): different path, different writer. `set-focus` NEVER reads or
189
+ writes the baton, and the **handoff** skill remains its sole writer. The baton is ephemeral cross-session
190
+ resume; the focus slot is durable standing context. Keeping their paths and writers separate is the
191
+ structural fix that stops a deliberate handoff from being clobbered.
192
+
193
+ ## Boundaries
194
+
195
+ - **Current session only.** No transcript mining, no cross-session backlog.
196
+ - **Additive only, save one slot.** dream creates net-new knowledge pages; merging or refreshing an
197
+ existing page is out of scope (that is harvest, a later phase). The **lone exception** is the focus
198
+ slot `_slots/current-focus.md`, which `set-focus` overwrites in place (see *Refreshing the focus slot*).
199
+ - **Never autonomous.** dream is a deliberate, attended, operator-confirmed skill — never a background
200
+ run, never a write without confirmation.
201
+ - **`_rules` ≠ SYNAPSE.** A `rule` page is *recalled knowledge* — the Brain surfaces it like a concept
202
+ or gotcha. It is NOT a SYNAPSE always-on rule. Promoting a `_rules` page into SYNAPSE's curated
203
+ always-injected set (`.synapse/`) is a separate, deliberate act — **dream NEVER edits `.synapse/`**.
204
+
205
+ ## Source
206
+
207
+ WRXN Kernel issue dream-02. Adapter: `.wrxn/dream.cjs` (dream-01). Prompts adapted from
208
+ `akitaonrails/ai-memory` (`auto_improve` system prompt, the `batch_consolidate` FAITHFULNESS block,
209
+ the `kind` rubric, the `docs/auto-improvement-loop.md` negative-filter list). ADR 0003; PRD
210
+ `dream-prd`.
@@ -6,6 +6,8 @@ argument-hint: "What will the next session be used for?"
6
6
 
7
7
  Write a handoff document summarising the current conversation so a fresh agent can continue the work.
8
8
 
9
+ Before writing the baton, OFFER to run the `dream` skill to consolidate this session's durable learnings into wiki memory (concept/decision/gotcha/rule pages the Brain recalls next session). This is an offer only — run `dream` solely if the operator agrees; never auto-run it. `dream` writes additive wiki pages (and may refresh its own `_slots/current-focus.md`), while this skill remains the SOLE writer of `.wrxn/continuity/latest.md` — the two are disjoint, so neither clobbers the other.
10
+
9
11
  Save it to the install's continuity slot: `.wrxn/continuity/latest.md` (resolve the install root by walking up to the `wrxn.install.json` receipt; create the `.wrxn/continuity/` directory if absent). This slot is the deliberate, intent-carrying baton — the NEXT session's `session-start` hook injects its contents as the resume surface, taking precedence over the automatic episodic session page.
10
12
 
11
13
  CONTINUITY DOCTRINE: this skill is the SINGLE writer of `.wrxn/continuity/latest.md`. The automatic `session-end` hook writes ONLY dated session pages under `.wrxn/wiki/sessions/` and NEVER touches the baton — so a deliberate handoff is never clobbered by the automatic episodic record. Overwrite the previous baton (the latest deliberate handoff is the live one).
File without changes
@@ -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();
File without changes
File without changes
@@ -4,22 +4,29 @@
4
4
  // WRXN memory-wiki adapter — the install-local CLI over the file-based memory tiers.
5
5
  // Self-contained: this ships INTO an install and MUST NOT import the kernel lib (node stdlib only).
6
6
  //
7
- // Tiers live under <installRoot>/.wrxn/wiki/<tier>/ where tier ∈ {concepts, decisions, gotchas, sessions}.
7
+ // Tiers live under <installRoot>/.wrxn/wiki/<tier>/ where tier ∈ {concepts, decisions, gotchas, sessions, _rules, _slots}.
8
8
  // Each page is a plain markdown file. Empty tiers are the fresh-install default — every read path
9
9
  // must return cleanly (no crash) over an empty wiki.
10
10
  //
11
11
  // Subcommands:
12
12
  // query <text...> grep-style substring search → JSON {query, tier, total, hits[]}
13
13
  // recall <text...> alias of query (page-level recall; same substring engine)
14
- // write-page <tier> <slug> create <tier>/<slug>.md (refuses to overwrite); prints the path
14
+ // write-page <tier> <slug> create <tier>/<slug>.md (refuses to overwrite); prints the path.
15
+ // --force overwrites in place, but ONLY for the `_slots` focus slot.
15
16
  //
16
- // Flags: --tier <concepts|decisions|gotchas|sessions|all> (default all) · --limit <N> (default 20)
17
- // --root <dir> (override the install-root walk-up; mainly for tests)
17
+ // Flags: --tier <concepts|decisions|gotchas|sessions|_rules|_slots|all> (default all) · --limit <N> (default 20)
18
+ // --force (write-page only; overwrite the `_slots` slot in place) · --root <dir> (test override)
18
19
 
19
20
  const fs = require('fs');
20
21
  const path = require('path');
21
22
 
22
- const TIERS = ['concepts', 'decisions', 'gotchas', 'sessions'];
23
+ // `_rules` is the dream-written tier (durable always/never project conventions) — recalled like the
24
+ // prose tiers, but machine-written by the dream adapter (dream-03), hence the `_` prefix.
25
+ // `_slots` (dream-04) holds the durable standing-focus page (`_slots/current-focus.md`) — the LONE
26
+ // wiki page that may be overwritten in place, and only via `write-page --force`.
27
+ const TIERS = ['concepts', 'decisions', 'gotchas', 'sessions', '_rules', '_slots'];
28
+ // The one tier whose pages `--force` may overwrite — every other tier stays create-only / refuse-overwrite.
29
+ const OVERWRITABLE_TIER = '_slots';
23
30
 
24
31
  // ── install-root resolution (walk up to the wrxn.install.json receipt) ────────
25
32
  // Mirrors payload/.claude/hooks/enforce-managed-guard.cjs findInstallRoot.
@@ -83,7 +90,7 @@ function listPages(dir) {
83
90
  function runQuery() {
84
91
  const terms = positionals();
85
92
  if (terms.length === 0) {
86
- process.stdout.write('Usage: node .wrxn/wiki.cjs query <search-term...> [--tier all|concepts|decisions|gotchas|sessions] [--limit N]\n');
93
+ process.stdout.write('Usage: node .wrxn/wiki.cjs query <search-term...> [--tier all|concepts|decisions|gotchas|sessions|_rules|_slots] [--limit N]\n');
87
94
  process.exit(2);
88
95
  }
89
96
  const needle = terms.join(' ').toLowerCase();
@@ -115,20 +122,32 @@ function runQuery() {
115
122
  function runWritePage() {
116
123
  const [tier, slug] = positionals();
117
124
  if (!tier || !slug) {
118
- process.stdout.write('Usage: node .wrxn/wiki.cjs write-page <tier> <slug> [--description "..."] [--body "..."]\n');
125
+ process.stdout.write('Usage: node .wrxn/wiki.cjs write-page <tier> <slug> [--description "..."] [--body "..."] [--force]\n');
119
126
  process.exit(2);
120
127
  }
121
128
  if (!TIERS.includes(tier)) fail(`unknown tier "${tier}" — one of ${TIERS.join(', ')}`);
122
129
  if (!/^[a-z0-9][a-z0-9-]*$/.test(slug)) fail(`slug must be kebab-case ([a-z0-9-]): "${slug}"`);
123
130
 
131
+ // `--force` is the LONE overwrite-exception (dream-04): it overwrites a page in place, and ONLY for
132
+ // the `_slots` focus slot. Every normal write-page (no `--force`, any other tier) still refuses to
133
+ // clobber — so the wiki stays additive/curated and only the standing-focus slot may be updated.
134
+ const force = process.argv.includes('--force');
135
+ if (force && tier !== OVERWRITABLE_TIER) {
136
+ fail(`--force overwrite is only permitted for the ${OVERWRITABLE_TIER} focus slot (the lone update-exception), not "${tier}"`);
137
+ }
138
+
124
139
  const root = wikiRoot();
125
140
  const dir = path.join(root, tier);
126
141
  fs.mkdirSync(dir, { recursive: true });
127
142
  const dest = path.join(dir, `${slug}.md`);
128
- if (fs.existsSync(dest)) fail(`page already exists, refusing to overwrite: ${path.relative(root, dest)}`);
143
+ if (fs.existsSync(dest) && !force) fail(`page already exists, refusing to overwrite: ${path.relative(root, dest)}`);
129
144
 
130
145
  const description = flag('description') || '';
131
146
  const body = flag('body') || '';
147
+ // Prepend the `# <slug>` heading ONLY when the body does not already open with its own H1. The dream
148
+ // gate mandates every proposal body start with `# Title`, so an always-prepend would stack two H1s on
149
+ // committed pages (qa-finding dream-06). A heading-less or empty body still gets `# <slug>` (backward-compat).
150
+ const heading = body.trimStart().startsWith('# ') ? [] : [`# ${slug}`, ''];
132
151
  const page = [
133
152
  '---',
134
153
  `name: ${slug}`,
@@ -137,8 +156,7 @@ function runWritePage() {
137
156
  'source: wiki-cli-write-page',
138
157
  '---',
139
158
  '',
140
- `# ${slug}`,
141
- '',
159
+ ...heading,
142
160
  body,
143
161
  '',
144
162
  ].join('\n');