@gcunharodrigues/wrxn 0.3.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.
@@ -1,43 +1,48 @@
1
1
  #!/usr/bin/env node
2
2
  'use strict';
3
3
 
4
- // WRXN recall-surface hook — per-prompt recall nudge (wrxn-kernel-11).
5
- // UserPromptSubmit. The symmetric RECALL half of reference-detect's CAPTURE: on each prompt it
6
- // matches the prompt's SALIENT terms against the wiki knowledge tiers (concepts/decisions/gotchas)
7
- // and, ONLY when a page matches, injects a <recall-surface> nudge "you already have a page on X,
8
- // recall it before re-deriving / re-asking the operator". Gated → silent on non-matching prompts.
4
+ // WRXN recall-surface hook — proactive PROSE Recall via the warm Brain door (recon-brain-recall-04).
5
+ // UserPromptSubmit. Replaces the old wiki-substring engine: on each prompt it discovers recon-wrxn's
6
+ // warm serve door, POSTs a prose-scoped hybrid query, and ONLY when a hit clears the relevance gate
7
+ // injects a compact <recall-surface> block. Implements ADR 0002.
9
8
  //
10
- // Self-contained: ships into installs, MUST NOT import the kernel lib (node stdlib only). The kernel
11
- // wiki engine is substring (not BM25) recall here is a deliberately simple distinct-salient-term
12
- // count, ranked, top-N. Fail-open: any fault emits {} the hook NEVER blocks a prompt.
9
+ // The gate (per arm, NEVER the fused RRF score): a prose hit qualifies on the semantic cosine FLOOR
10
+ // (>= 0.4) OR on CONSENSUS (it surfaced in both the BM25 and the dense arm). Nothing clears Abstain.
11
+ // Prose only hits are post-filtered to Page/Section, so code symbols never surface here (they stay
12
+ // on the agent's on-demand recon_* / `wrxn brain query` path).
13
+ //
14
+ // Self-contained: ships into installs, MUST NOT import the kernel lib or recon — node stdlib ONLY
15
+ // (http / fs / path). Fail-open SILENT: a cold/missing/dead door, a slow door, a non-200, malformed
16
+ // JSON, or ANY fault emits {} — the hook NEVER blocks a prompt and never delays it past the hard
17
+ // client timeout. There is NO substring fallback (a weak fallback can itself harm — ADR 0002).
13
18
  //
14
19
  // Contract: UserPromptSubmit event JSON on stdin → envelope JSON on stdout (exit 0).
20
+ // inject → { "hookSpecificOutput": { "hookEventName": "UserPromptSubmit", "additionalContext": "<recall-surface>…" } }
21
+ // abstain → {}
15
22
 
16
23
  const fs = require('fs');
24
+ const http = require('http');
17
25
  const path = require('path');
18
26
 
19
- const TIERS = ['concepts', 'decisions', 'gotchas']; // knowledge tiers; sessions (episodic) excluded
20
- const TOP_N = 2;
21
- const MIN_PROMPT_LEN = 8; // skip trivial prompts ("ok", "yes")
22
- // A page must share >=2 DISTINCT salient terms with the prompt to surface — the substring engine has
23
- // no BM25 score, so a single shared common word is too weak a signal (anti-noise). Tradeoff: a genuine
24
- // single-strong-term recall is silenced (fail-silent a missed nudge is safer than a false one).
25
- const MIN_DISTINCT = 2;
26
- const MAX_BLOCK_CHARS = 600;
27
-
28
- // Drop stopwords + short tokens so common words don't match common page words (anti-noise).
29
- const STOPWORDS = new Set(['about', 'after', 'again', 'against', 'because', 'before', 'being', 'between',
30
- 'could', 'does', 'doing', 'down', 'during', 'each', 'from', 'further', 'have', 'having', 'here', 'how',
31
- 'into', 'just', 'like', 'more', 'most', 'only', 'other', 'over', 'same', 'should', 'some', 'such',
32
- 'than', 'that', 'their', 'them', 'then', 'there', 'these', 'they', 'this', 'those', 'through', 'under',
33
- 'until', 'very', 'want', 'what', 'when', 'where', 'which', 'while', 'with', 'would', 'your', 'today',
34
- 'tell', 'show', 'give', 'make', 'need', 'know', 'help', 'please', 'thing', 'stuff', 'really', 'going']);
27
+ const MIN_PROMPT_LEN = 8; // skip trivial prompts ("ok", "yes")
28
+ const MAX_QUERY_CHARS = 512; // trim the prompt before querying the door
29
+ const FETCH_LIMIT = 15; // ask the door WIDE — fetch is decoupled from inject so prose ranked
30
+ // below the whole-brain code hits is not truncated before we filter
31
+ const TIMEOUT_MS = 150; // hard client budget never delay a prompt past this
32
+ const MAX_RESPONSE_BYTES = 256 * 1024; // hard cap on an accumulated door response body (anti-flood)
33
+ const TOP_N = 3; // inject at most 3 hits (the wide fetch is post-filtered down to this)
34
+ const MAX_BLOCK_CHARS = 600; // injection size cap (ADR 0002: inject little, high-signal)
35
+ const SEMANTIC_FLOOR = 0.4; // dense cosine floor (reused from P1.5)
36
+ const PROSE_TYPES = new Set(['Page', 'Section']); // prose scope drop code symbols
37
+ const ENDPOINT_REL = path.join('.recon-wrxn', 'serve-endpoint.json');
38
+ const FIND_PATH = '/api/tools/recon_find';
35
39
 
36
40
  function emit(envelope) {
37
41
  process.stdout.write(JSON.stringify(envelope));
38
42
  process.exit(0);
39
43
  }
40
44
 
45
+ // Walk up from cwd / CLAUDE_PROJECT_DIR to the install root carrying wrxn.install.json.
41
46
  function findInstallRoot(startDir) {
42
47
  let dir = startDir || process.env.CLAUDE_PROJECT_DIR || process.cwd();
43
48
  for (let i = 0; i < 12; i++) {
@@ -49,79 +54,254 @@ function findInstallRoot(startDir) {
49
54
  return null;
50
55
  }
51
56
 
52
- // De-duped salient content tokens (lowercased, >=4 chars, non-stopword).
53
- function salientTerms(prompt) {
54
- const seen = new Set();
55
- for (const tok of (String(prompt || '').toLowerCase().match(/[a-z][a-z0-9]{3,}/g) || [])) {
56
- if (!STOPWORDS.has(tok)) seen.add(tok);
57
+ // ── the gate (PURE) ────────────────────────────────────────────────────────────────
58
+
59
+ function isProse(hit) {
60
+ return !!hit && PROSE_TYPES.has(hit.type);
61
+ }
62
+
63
+ // Consensus = the hit surfaced in BOTH the BM25 and the dense arm (the find response's `sources`
64
+ // provenance). A consensus hit qualifies even below the cosine floor.
65
+ function hasConsensus(hit) {
66
+ const s = hit && hit.sources;
67
+ return Array.isArray(s) && s.includes('bm25') && s.includes('semantic');
68
+ }
69
+
70
+ // Qualify on the PER-ARM signal only: the semantic cosine floor OR consensus. NEVER the fused
71
+ // `score` (RRF is a rank-based consensus, not a relevance magnitude — ADR 0002). The floor clause
72
+ // requires the dense arm to actually be PRESENT in `sources` (not just a stray semanticScore) —
73
+ // today these co-occur, but this is a defense against a future producer emitting a score without the
74
+ // 'semantic' tag. An absent/NaN semanticScore can never clear the floor; only consensus rescues it.
75
+ function qualifies(hit) {
76
+ const sem = Number(hit && hit.semanticScore);
77
+ const s = hit && hit.sources;
78
+ const hasSemantic = Array.isArray(s) && s.includes('semantic');
79
+ const floorOk = Number.isFinite(sem) && sem >= SEMANTIC_FLOOR && hasSemantic;
80
+ return floorOk || hasConsensus(hit);
81
+ }
82
+
83
+ function slugify(s) {
84
+ return String(s || '')
85
+ .toLowerCase()
86
+ .replace(/[^a-z0-9]+/g, '-')
87
+ .replace(/^-+|-+$/g, '')
88
+ .slice(0, 48);
89
+ }
90
+
91
+ // A stable slug for a hit: the prose file's basename (sans extension), else a slugified name.
92
+ function slugOf(hit) {
93
+ if (hit.file) {
94
+ const base = path.basename(String(hit.file)).replace(/\.[^.]+$/, '');
95
+ if (base) return base.slice(0, 48);
57
96
  }
58
- return [...seen];
59
- }
60
-
61
- // Score each wiki page by the count of DISTINCT salient terms it contains; keep pages with >=1.
62
- function recall(root, terms) {
63
- const hits = [];
64
- for (const tier of TIERS) {
65
- const dir = path.join(root, '.wrxn', 'wiki', tier);
66
- let names;
67
- try {
68
- names = fs.readdirSync(dir).filter((n) => n.endsWith('.md'));
69
- } catch {
70
- continue; // tier absent skip
71
- }
72
- for (const name of names) {
73
- let text;
74
- try {
75
- text = fs.readFileSync(path.join(dir, name), 'utf8').toLowerCase();
76
- } catch {
77
- continue;
78
- }
79
- const matched = terms.filter((t) => text.includes(t)).length;
80
- if (matched >= MIN_DISTINCT) hits.push({ slug: name.replace(/\.md$/, ''), tier, score: matched });
81
- }
97
+ return slugify(hit.name) || 'untitled';
98
+ }
99
+
100
+ // The one-line descriptor. NOTE: a recon FindHit carries NO body text only name/file/line/scores
101
+ // so the snippet is the hit's NAME (the page title / section heading), the most descriptive line
102
+ // available. (Follow-up: if the door later surfaces a per-hit text excerpt, prefer it here.)
103
+ function snippetOf(hit) {
104
+ const s = String(hit.name || hit.file || '').replace(/\s+/g, ' ').trim();
105
+ return s.length > 100 ? s.slice(0, 99) + '…' : s;
106
+ }
107
+
108
+ // Render the qualifying hits into the <recall-surface> block, guaranteed <= MAX_BLOCK_CHARS and
109
+ // always closed. Drops trailing bullets first, then hard-truncates the last line if it still overflows.
110
+ function renderBlock(hits) {
111
+ const head = '<recall-surface>';
112
+ const intro = 'Knowledge already in your Brain, recalled for this prompt — read it before re-deriving or re-asking the operator:';
113
+ const foot = '</recall-surface>';
114
+ const bullets = hits.map((h) => `- ${slugOf(h)} — ${snippetOf(h)}`);
115
+ const assemble = (bs) => [head, intro, ...bs, foot].join('\n');
116
+ const kept = bullets.slice();
117
+ while (kept.length > 1 && assemble(kept).length > MAX_BLOCK_CHARS) kept.pop();
118
+ let block = assemble(kept);
119
+ if (block.length > MAX_BLOCK_CHARS) {
120
+ block = block.slice(0, MAX_BLOCK_CHARS - foot.length - 1).replace(/\s+\S*$/, '').trimEnd() + '\n' + foot;
121
+ }
122
+ return block;
123
+ }
124
+
125
+ // PURE: prose-filter → gate → top-N → format. Returns the block string, or null (Abstain).
126
+ function decideRecall(hits) {
127
+ const list = Array.isArray(hits) ? hits : [];
128
+ const qualified = list.filter((h) => isProse(h) && qualifies(h)).slice(0, TOP_N);
129
+ if (!qualified.length) return null;
130
+ return renderBlock(qualified);
131
+ }
132
+
133
+ // ── the door (IO shell, injectable transport) ───────────────────────────────────────
134
+
135
+ // A pid is alive unless process.kill(pid,0) throws ESRCH. EPERM means it exists (owned by another
136
+ // user) — still alive. Mirrors the cross-repo discovery contract.
137
+ function pidAlive(pid) {
138
+ try {
139
+ process.kill(pid, 0);
140
+ return true;
141
+ } catch (e) {
142
+ return !!e && e.code === 'EPERM';
143
+ }
144
+ }
145
+
146
+ // Refuse a discovery file another user could have planted, or that is group/world-writable — trusting
147
+ // it would let a hostile workspace point the door host/port at an exfil/injection sink (the hook feeds
148
+ // the door's response into the prompt context). lstat (not stat) so a symlink's OWN ownership/mode is
149
+ // judged. A platform without getuid skips the uid check but still enforces the mode check. Any fault →
150
+ // not trusted (treated as not-warm).
151
+ function endpointTrusted(file) {
152
+ let st;
153
+ try {
154
+ st = fs.lstatSync(file);
155
+ } catch {
156
+ return false;
82
157
  }
83
- // Highest distinct-term count first; ties broken by slug for determinism.
84
- hits.sort((a, b) => b.score - a.score || a.slug.localeCompare(b.slug));
85
- return hits.slice(0, TOP_N);
158
+ if (typeof process.getuid === 'function' && st.uid !== process.getuid()) return false; // foreign owner
159
+ if ((st.mode & 0o022) !== 0) return false; // group/world-writable
160
+ return true;
86
161
  }
87
162
 
88
- function block(hits) {
89
- const lines = [
90
- '<recall-surface>',
91
- 'You already have captured page(s) on this topic — READ before answering (do not re-derive, do',
92
- 'not ask the operator to re-explain). Recall with: node .wrxn/wiki.cjs recall "<slug>"',
93
- ...hits.map((h) => `- ${h.slug} (${h.tier})`),
94
- '</recall-surface>',
95
- ].join('\n');
96
- return lines.length <= MAX_BLOCK_CHARS ? lines : `${lines.slice(0, MAX_BLOCK_CHARS - 18)}\n</recall-surface>`;
163
+ // Discover the warm serve door from <root>/.recon-wrxn/serve-endpoint.json = {pid,port}. Returns
164
+ // {pid,port} only when the file is well-owned (not planted), present, well-formed, and the pid is
165
+ // alive — else null (not warm).
166
+ function discoverEndpoint(root) {
167
+ const file = path.join(root, ENDPOINT_REL);
168
+ if (!endpointTrusted(file)) return null; // absent, foreign-owned, or loose perms → not warm
169
+ let raw;
170
+ try {
171
+ raw = fs.readFileSync(file, 'utf8');
172
+ } catch {
173
+ return null; // absent (race)
174
+ }
175
+ let obj;
176
+ try {
177
+ obj = JSON.parse(raw);
178
+ } catch {
179
+ return null; // malformed
180
+ }
181
+ const pid = Number(obj && obj.pid);
182
+ const port = Number(obj && obj.port);
183
+ if (!Number.isInteger(pid) || pid <= 0) return null;
184
+ if (!Number.isInteger(port) || port <= 0) return null;
185
+ if (!pidAlive(pid)) return null; // dead pid → not warm
186
+ return { pid, port };
187
+ }
188
+
189
+ // Default transport: a real POST over http with a hard timeout. Resolves {statusCode, body}; rejects
190
+ // on socket error or timeout. Injectable so unit tests never touch the network (mirrors connect.cjs).
191
+ function httpTransport({ port, path: reqPath, body, timeoutMs }) {
192
+ return new Promise((resolve, reject) => {
193
+ const payload = Buffer.from(JSON.stringify(body));
194
+ const deadline = timeoutMs || TIMEOUT_MS;
195
+ let settled = false;
196
+ let wall = null;
197
+ const done = (fn, arg) => {
198
+ if (settled) return;
199
+ settled = true;
200
+ if (wall) clearTimeout(wall);
201
+ fn(arg);
202
+ };
203
+ const req = http.request(
204
+ {
205
+ host: '127.0.0.1',
206
+ port,
207
+ path: reqPath,
208
+ method: 'POST',
209
+ headers: { 'Content-Type': 'application/json', 'Content-Length': payload.length },
210
+ },
211
+ (res) => {
212
+ const chunks = [];
213
+ let total = 0;
214
+ res.on('data', (c) => {
215
+ total += c.length;
216
+ if (total > MAX_RESPONSE_BYTES) { req.destroy(new Error('recall door response too large')); return; }
217
+ chunks.push(c);
218
+ });
219
+ res.on('end', () => done(resolve, { statusCode: res.statusCode, body: Buffer.concat(chunks).toString('utf8') }));
220
+ res.on('error', (e) => done(reject, e));
221
+ }
222
+ );
223
+ req.on('error', (e) => done(reject, e));
224
+ // Idle timeout (no bytes for `deadline`) AND an independent wall-clock: the latter bounds a trickle
225
+ // attacker that dribbles bytes to keep the idle timer from ever firing past the hook budget.
226
+ req.setTimeout(deadline, () => req.destroy(new Error('recall door timeout')));
227
+ wall = setTimeout(() => req.destroy(new Error('recall door wall-clock timeout')), deadline);
228
+ req.write(payload);
229
+ req.end();
230
+ });
97
231
  }
98
232
 
99
- function main() {
233
+ // IO shell: discover the door, POST the prose query, gate the hits. Returns the block string or null.
234
+ // `transport` is injected in tests; production uses httpTransport. Sends NO `type` (recon_find takes a
235
+ // single NodeType, not an array) — prose scope is enforced by decideRecall's post-filter.
236
+ async function recallFromDoor(root, prompt, { transport, timeoutMs } = {}) {
237
+ const door = discoverEndpoint(root);
238
+ if (!door) return null; // not warm → Abstain (silent)
239
+ const query = String(prompt || '').trim().slice(0, MAX_QUERY_CHARS);
240
+ if (!query) return null;
241
+ let resp;
242
+ try {
243
+ resp = await (transport || httpTransport)({
244
+ port: door.port,
245
+ path: FIND_PATH,
246
+ body: { query, limit: FETCH_LIMIT },
247
+ timeoutMs: timeoutMs || TIMEOUT_MS,
248
+ });
249
+ } catch {
250
+ return null; // timeout / connection refused / abort → silent
251
+ }
252
+ if (!resp || resp.statusCode !== 200) return null;
253
+ let parsed;
254
+ try {
255
+ parsed = JSON.parse(resp.body);
256
+ } catch {
257
+ return null; // malformed body → silent
258
+ }
259
+ return decideRecall(Array.isArray(parsed.hits) ? parsed.hits : []);
260
+ }
261
+
262
+ // ── entrypoint ──────────────────────────────────────────────────────────────────────
263
+
264
+ async function main() {
100
265
  let event = {};
101
266
  try {
102
267
  const stdin = fs.readFileSync(0, 'utf8');
103
268
  if (stdin.trim()) event = JSON.parse(stdin);
104
269
  } catch {
105
- emit({});
270
+ return emit({});
106
271
  }
107
272
 
108
- const root = findInstallRoot();
109
- if (!root) emit({});
273
+ const root = findInstallRoot(event.cwd);
274
+ if (!root) return emit({});
110
275
 
111
276
  const prompt = typeof event.prompt === 'string' ? event.prompt : '';
112
- if (prompt.trim().length < MIN_PROMPT_LEN) emit({});
113
-
114
- const terms = salientTerms(prompt);
115
- if (!terms.length) emit({});
277
+ if (prompt.trim().length < MIN_PROMPT_LEN) return emit({});
116
278
 
117
- const hits = recall(root, terms);
118
- if (!hits.length) emit({}); // no captured page matched → silent (the gate)
279
+ let block = null;
280
+ try {
281
+ block = await recallFromDoor(root, prompt.trim());
282
+ } catch {
283
+ return emit({});
284
+ }
285
+ if (!block) return emit({}); // nothing cleared the gate → Abstain
119
286
 
120
- emit({ hookSpecificOutput: { hookEventName: 'UserPromptSubmit', additionalContext: block(hits) } });
287
+ return emit({ hookSpecificOutput: { hookEventName: 'UserPromptSubmit', additionalContext: block } });
121
288
  }
122
289
 
123
- try {
124
- main();
125
- } catch {
126
- emit({});
290
+ if (require.main === module) {
291
+ main().catch(() => emit({}));
127
292
  }
293
+
294
+ module.exports = {
295
+ decideRecall,
296
+ recallFromDoor,
297
+ discoverEndpoint,
298
+ httpTransport,
299
+ pidAlive,
300
+ isProse,
301
+ hasConsensus,
302
+ qualifies,
303
+ renderBlock,
304
+ findInstallRoot,
305
+ SEMANTIC_FLOOR,
306
+ PROSE_TYPES,
307
+ };
@@ -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).
package/payload/.mcp.json CHANGED
@@ -2,7 +2,7 @@
2
2
  "mcpServers": {
3
3
  "recon-wrxn": {
4
4
  "command": "npx",
5
- "args": ["-y", "recon-wrxn@6.0.0-wrxn.2", "serve"]
5
+ "args": ["-y", "recon-wrxn@6.0.0-wrxn.3", "serve"]
6
6
  }
7
7
  }
8
8
  }