@gcunharodrigues/wrxn 0.13.2 → 0.14.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,93 @@
1
+ 'use strict';
2
+
3
+ // WRXN shared coalesced-sidecar helper (kernel #12 / S1). The common mechanism behind recall-surface's
4
+ // runtime STATE sidecars (.wrxn/reinforce.json, .wrxn/surfaced.json): read a JSON object map → let the
5
+ // caller mutate it → REWRITE (not append) the whole map back, but ONLY when it actually changed
6
+ // (coalesced: <= 1 write per real change, the file never grows on a no-op).
7
+ //
8
+ // Self-contained: ships into installs alongside the hooks — node stdlib ONLY (fs / path), NO kernel-lib
9
+ // or recon import. FAIL-OPEN SILENT: any fault (absent dir, malformed existing sidecar, unwritable path)
10
+ // is swallowed so the caller's primary effect always proceeds and nothing ever throws.
11
+
12
+ const fs = require('fs');
13
+ const path = require('path');
14
+
15
+ // secretScan — replicated here because each self-contained install-only module imports no shared
16
+ // kernel-lib (node stdlib only), exactly as it is duplicated across dream.cjs / sync.cjs. CASE-SENSITIVE:
17
+ // the token shapes are case-specific. A coalesced sidecar must never harden a credential onto disk.
18
+ const SECRET_PATTERNS = [
19
+ /AKIA[0-9A-Z]{16}/, // AWS access key id
20
+ /gh[pousr]_[A-Za-z0-9]{36}/, // GitHub token (ghp_/gho_/ghu_/ghs_/ghr_)
21
+ /npm_[A-Za-z0-9]{36}/, // npm automation token
22
+ /sk-[A-Za-z0-9]{20,}/, // OpenAI-style secret key
23
+ /-----BEGIN [A-Z ]*PRIVATE KEY-----[\s\S]*?-----END [A-Z ]*PRIVATE KEY-----/, // FULL PEM block (BEGIN…END) — MUST precede the header-only line so redaction consumes the body, not just the header
24
+ /-----BEGIN [A-Z ]*PRIVATE KEY-----/, // PEM private-key header (fallback: a lone/truncated header with no END)
25
+ /Bearer\s+[A-Za-z0-9\-._~+/]{16,}=*/i, // HTTP bearer / Authorization token (case-insensitive scheme)
26
+ // password=/pwd= assignment (=, :, quoted JSON/YAML). The keyword is EITHER fully quoted ("password":)
27
+ // OR bare (password=) — never a lone trailing quote, so a path ending in "passwd" used as a JSON key
28
+ // (e.g. "../../etc/passwd": …) is NOT misread as an assignment (it would falsely refuse a sidecar write).
29
+ /(?:["'](?:password|passwd|pwd)["']|(?:password|passwd|pwd))\s*[:=]\s*["']?[^\s"',;)&}{]+/i,
30
+ /[a-z][a-z0-9+.\-]+:\/\/[^\s:/@]+:[^\s/@]+@\S+/i, // URI connection string with inline creds (scheme://user:pass@host)
31
+ /eyJ[A-Za-z0-9_-]{5,}\.[A-Za-z0-9_-]{5,}\.[A-Za-z0-9_-]{5,}/, // JWT: base64url header(eyJ…).payload.signature
32
+ ];
33
+
34
+ function secretScan(text) {
35
+ const s = String(text || ''); // NOT lowercased — the token shapes are case-sensitive.
36
+ for (const re of SECRET_PATTERNS) if (re.test(s)) return 'contains_secret';
37
+ return null;
38
+ }
39
+
40
+ // redactSecrets — scrub known secret shapes OUT of free text before it is persisted, built on the SAME
41
+ // SECRET_PATTERNS as secretScan (one source of truth: when a shape is added, both detection and redaction
42
+ // follow). Every match is replaced with a fixed placeholder while the surrounding text is preserved
43
+ // (metadata-grade redaction, not a whole-value drop) — so a persisted prompt stays useful for analysis
44
+ // yet never hardens a credential onto disk. Global-flagged clones of the patterns so EVERY occurrence on a
45
+ // line is scrubbed, not just the first; String#replace resets a global regex's lastIndex per call, so
46
+ // reusing these module-level clones is safe. TOTAL: a non-string coerces (null/undefined → ''). The clone
47
+ // PRESERVES each pattern's own flags (e.g. /i) and only ADDS the global flag — so a case-insensitive shape
48
+ // scans and redacts identically (dropping the flags here would let detection and redaction diverge).
49
+ const SECRET_PATTERNS_GLOBAL = SECRET_PATTERNS.map((re) => new RegExp(re.source, re.flags.includes('g') ? re.flags : re.flags + 'g'));
50
+ const SECRET_PLACEHOLDER = '[redacted]';
51
+
52
+ function redactSecrets(text) {
53
+ let s = String(text == null ? '' : text); // NOT lowercased — the token shapes are case-sensitive.
54
+ for (const re of SECRET_PATTERNS_GLOBAL) s = s.replace(re, SECRET_PLACEHOLDER);
55
+ return s;
56
+ }
57
+
58
+ // Read the JSON object map at `file`, hand it to `mutate(map)`, and rewrite the file iff mutate signals
59
+ // a change (returns truthy). Returns true when a write happened, false otherwise (including every
60
+ // fail-open path). `mutate` mutates the map in place and returns whether it changed it. The fully
61
+ // serialized map is secret-scanned before the write: if any value would harden a credential onto disk,
62
+ // the write is REFUSED (fail-open — the sidecar stays as it was, the caller proceeds).
63
+ function coalesceSidecar(file, mutate) {
64
+ try {
65
+ let map = {};
66
+ let raw = null;
67
+ try {
68
+ raw = fs.readFileSync(file, 'utf8');
69
+ } catch {
70
+ raw = null; // absent → fresh map (normal, not a fault)
71
+ }
72
+ if (raw !== null) {
73
+ let parsed;
74
+ try {
75
+ parsed = JSON.parse(raw);
76
+ } catch {
77
+ return false; // malformed existing sidecar → skip silently, leave it untouched (never clobber)
78
+ }
79
+ if (!parsed || typeof parsed !== 'object' || Array.isArray(parsed)) return false; // not a map → skip
80
+ map = parsed;
81
+ }
82
+ if (!mutate(map)) return false; // nothing changed → coalesced no-op, no write
83
+ const body = JSON.stringify(map, null, 2) + '\n';
84
+ if (secretScan(body)) return false; // never harden a credential onto disk → refuse the write
85
+ fs.mkdirSync(path.dirname(file), { recursive: true });
86
+ fs.writeFileSync(file, body);
87
+ return true;
88
+ } catch {
89
+ return false; // best-effort: a sidecar fault must never alter or break the caller
90
+ }
91
+ }
92
+
93
+ module.exports = { coalesceSidecar, secretScan, redactSecrets };
@@ -276,11 +276,47 @@ function hasCurationDebt(root) {
276
276
  }
277
277
  }
278
278
 
279
+ // The starved-useful watchdog's EMISSION bar (kernel #15 / S4): the number of starved-useful pages at or
280
+ // above which the handoff carries the canary line. Distinct from the watchdog's MATH thresholds (R_HIGH /
281
+ // S_LOW live with the pure reward.starvedUseful) — this is the "how many before we bug the operator" bar.
282
+ // A PLACEHOLDER pending the lift gate (recorded with the gate verdict, like the decay half-life); it just
283
+ // keeps the canary quiet until a few pages have genuinely starved. Kept local so the engine never
284
+ // HARD-requires the reward sibling at module load (a missing sibling must not break constitution injection).
285
+ const STARVED_NUDGE_THRESHOLD = 3;
286
+
287
+ // The starved-useful WATCHDOG probe (kernel #15 / S4) — the sibling of hasCurationDebt for the learning
288
+ // moat. A CHEAP, fail-open, READ-ONLY read of the two STATE sidecars (.wrxn/reward.json per-page
289
+ // Beta-Bernoulli counts + .wrxn/surfaced.json per-session surfaced-log) → the pure reward.starvedUseful
290
+ // count (pages learned high-reward but rarely surfaced). The reward sibling is required LAZILY inside the
291
+ // try/catch so a (theoretically) absent sibling can never break the engine's constitution injection. Any
292
+ // fault (missing/corrupt sidecar, missing sibling) → 0 (no nudge, no throw). Only invoked when a handoff
293
+ // is actually firing, so the two extra reads are doubly bounded. CANARY: reads only — it never mutates a
294
+ // sidecar and never touches recall ranking or reward counts.
295
+ function starvedUsefulSignal(root) {
296
+ try {
297
+ const { starvedUseful } = require('./reward.cjs');
298
+ const readMap = (rel) => {
299
+ try {
300
+ const parsed = JSON.parse(fs.readFileSync(path.join(root, '.wrxn', rel), 'utf8'));
301
+ return parsed && typeof parsed === 'object' && !Array.isArray(parsed) ? parsed : {};
302
+ } catch {
303
+ return {}; // absent / malformed → empty (fail-open)
304
+ }
305
+ };
306
+ return starvedUseful(readMap('reward.json'), readMap('surfaced.json')).count;
307
+ } catch {
308
+ return 0; // any fault (incl. a missing reward sibling) → no signal, never a throw
309
+ }
310
+ }
311
+
279
312
  // The NON-BLOCKING forced-handoff directive (never refuses work — orders the agent to wrap up cleanly).
280
313
  // `hasDebt` (harvest-05) appends a harvest curation nudge AFTER the dream line — emitted ONLY when the
281
314
  // latest health-check found curation debt, so a clean knowledge set never sees it. Ordered after dream:
282
- // dream consolidates the session first, then harvest curates the enlarged knowledge set.
283
- function handoffDirective(consumed, pct, hasDebt) {
315
+ // dream consolidates the session first, then harvest curates the enlarged knowledge set. `starvedCount`
316
+ // (S4) appends ONE canary line LAST — emitted ONLY when the starved-useful count >= STARVED_NUDGE_THRESHOLD
317
+ // (below / omitted / garbage → silent, totality). The canary states the count and that it is (b)-pressure
318
+ // (data toward graduating a recon reward term); it is informational only and changes no recall/reward state.
319
+ function handoffDirective(consumed, pct, hasDebt, starvedCount) {
284
320
  const now = Math.round(consumed * 100);
285
321
  const thresh = Math.round(pct * 100);
286
322
  const lines = [
@@ -293,6 +329,10 @@ function handoffDirective(consumed, pct, hasDebt) {
293
329
  if (hasDebt) {
294
330
  lines.push(' Then (optional, only because the last health-check found curation debt): run the harvest skill to review the flagged near-dups / decay-candidates / malformed pages — a suggestion only; harvest never auto-deletes, every change is proposed for your confirmation.');
295
331
  }
332
+ const starved = Number(starvedCount);
333
+ if (Number.isFinite(starved) && starved >= STARVED_NUDGE_THRESHOLD) {
334
+ lines.push(` Watchdog (canary, informational): ${starved} starved-useful page(s) — learned high-reward but rarely surfaced, sitting below recon's floor where the kernel-side re-rank can't lift them. This is (b)-pressure: data toward graduating a recon reward term (option b). Informational only — nothing auto-changes; no recall or reward state is touched.`);
335
+ }
296
336
  return lines.join('\n');
297
337
  }
298
338
 
@@ -360,7 +400,7 @@ function compose(root, event) {
360
400
  const window = modelWindow(ev.cwd || root, process.env.HOME || os.homedir(), manifestText, ev.session_id, resident);
361
401
  const consumed = resident / window;
362
402
  const pct = resolveHandoffPct(manifestText);
363
- if (consumed >= pct) out.push(handoffDirective(consumed, pct, hasCurationDebt(root)));
403
+ if (consumed >= pct) out.push(handoffDirective(consumed, pct, hasCurationDebt(root), starvedUsefulSignal(root)));
364
404
  }
365
405
  }
366
406
 
@@ -411,5 +451,7 @@ module.exports = {
411
451
  modelWindow,
412
452
  resolveHandoffPct,
413
453
  hasCurationDebt,
454
+ starvedUsefulSignal,
414
455
  handoffDirective,
456
+ STARVED_NUDGE_THRESHOLD,
415
457
  };
@@ -50,8 +50,71 @@ function lintPage(text) {
50
50
  return null;
51
51
  }
52
52
 
53
+ // A page's identity is its `name:` frontmatter slug — it is the wikilink target ([[name]]), the
54
+ // filename, and the dedup key. Returns the slug, or null when the page has no readable name.
55
+ function pageName(text) {
56
+ const m = /^name\s*:\s*(.+)$/m.exec(String(text || ''));
57
+ return m ? m[1].trim() : null;
58
+ }
59
+
60
+ // The body is everything after the frontmatter block (where prose [[wikilinks]] live).
61
+ function pageBody(text) {
62
+ const src = String(text || '');
63
+ if (!src.startsWith('---')) return src;
64
+ const end = src.indexOf('\n---', 3);
65
+ return end < 0 ? '' : src.slice(end + 4);
66
+ }
67
+
68
+ // Wikilinks are written `[[slug]]` (Obsidian style), where slug equals a target page's `name:`. An
69
+ // optional `|alias` or `#anchor` is stripped to the bare target slug. Returns the distinct slugs.
70
+ // The inner class excludes BOTH `]` and `[`: a real `[[slug]]` target never contains `[`, and the
71
+ // unbounded `[^\]]+` form backtracked O(n²) on runs of `[`, hanging this Stop hook on a `[`-heavy
72
+ // page (ReDoS, S2 #29). Bounding the class is the root-cause fix — linear AND more correct.
73
+ function wikilinkTargets(body) {
74
+ const out = new Set();
75
+ const re = /\[\[([^\]\[]+)\]\]/g;
76
+ let m;
77
+ while ((m = re.exec(String(body || '')))) {
78
+ const slug = m[1].split('|')[0].split('#')[0].trim();
79
+ if (slug) out.add(slug);
80
+ }
81
+ return [...out];
82
+ }
83
+
84
+ // Corpus-level checks (need every page, so they run over the swept corpus, not per-page).
85
+ // `corpus` is an array of { ref, name, body }; `ref` is the human-readable `tier/file.md` label.
86
+ // Returns dead-wikilink findings: a [[slug]] whose slug matches no existing page name.
87
+ function deadLinkFindings(corpus) {
88
+ const names = new Set(corpus.map((p) => p.name).filter(Boolean));
89
+ const findings = [];
90
+ for (const page of corpus) {
91
+ for (const slug of wikilinkTargets(page.body)) {
92
+ if (!names.has(slug)) findings.push(`${page.ref} — dead wikilink [[${slug}]]`);
93
+ }
94
+ }
95
+ return findings;
96
+ }
97
+
98
+ // Duplicate page titles: two+ pages sharing the same `name:` identity slug — an identity collision
99
+ // (write-page even refuses to overwrite by slug), the tell that the pages should have been merged.
100
+ // One finding per colliding title, naming every page that claims it.
101
+ function duplicateTitleFindings(corpus) {
102
+ const byName = new Map();
103
+ for (const page of corpus) {
104
+ if (!page.name) continue; // a name-less page is already caught by the malformed check
105
+ if (!byName.has(page.name)) byName.set(page.name, []);
106
+ byName.get(page.name).push(page.ref);
107
+ }
108
+ const findings = [];
109
+ for (const [name, refs] of byName) {
110
+ if (refs.length > 1) findings.push(`duplicate title "${name}" — ${refs.join(', ')}`);
111
+ }
112
+ return findings;
113
+ }
114
+
53
115
  function sweep(root) {
54
116
  const flagged = [];
117
+ const corpus = [];
55
118
  for (const tier of TIERS) {
56
119
  const dir = path.join(root, '.wrxn', 'wiki', tier);
57
120
  let names;
@@ -67,6 +130,7 @@ function sweep(root) {
67
130
  } catch {
68
131
  continue;
69
132
  }
133
+ corpus.push({ ref: `${tier}/${name}`, name: pageName(text), body: pageBody(text) });
70
134
  const reason = lintPage(text);
71
135
  if (reason) {
72
136
  flagged.push(`${tier}/${name} — ${reason}`);
@@ -74,6 +138,11 @@ function sweep(root) {
74
138
  }
75
139
  }
76
140
  }
141
+ // Corpus-level checks run after every page is read (they need the full set of page names).
142
+ for (const f of [...deadLinkFindings(corpus), ...duplicateTitleFindings(corpus)]) {
143
+ flagged.push(f);
144
+ if (flagged.length >= MAX_FLAGGED) return flagged;
145
+ }
77
146
  return flagged;
78
147
  }
79
148
 
@@ -92,7 +161,7 @@ function main() {
92
161
 
93
162
  const ctx = [
94
163
  '<wiki-lint>',
95
- `${flagged.length} malformed wiki page(s) — fix the frontmatter (name / description / tier):`,
164
+ `${flagged.length} wiki integrity issue(s) — malformed frontmatter, dead [[wikilinks]], or duplicate page titles:`,
96
165
  ...flagged.map((f) => `- ${f}`),
97
166
  '</wiki-lint>',
98
167
  ].join('\n');
@@ -10,7 +10,8 @@
10
10
  "SessionEnd": [
11
11
  {
12
12
  "hooks": [
13
- { "type": "command", "command": "node \"$CLAUDE_PROJECT_DIR/.claude/hooks/memory-synth-spawn.cjs\"" }
13
+ { "type": "command", "command": "node \"$CLAUDE_PROJECT_DIR/.claude/hooks/memory-synth-spawn.cjs\"" },
14
+ { "type": "command", "command": "node \"$CLAUDE_PROJECT_DIR/.claude/hooks/session-end-reward.cjs\"" }
14
15
  ]
15
16
  }
16
17
  ],
@@ -19,7 +20,8 @@
19
20
  "hooks": [
20
21
  { "type": "command", "command": "node \"$CLAUDE_PROJECT_DIR/.claude/hooks/synapse-engine.cjs\"" },
21
22
  { "type": "command", "command": "node \"$CLAUDE_PROJECT_DIR/.claude/hooks/reference-detect.cjs\"" },
22
- { "type": "command", "command": "node \"$CLAUDE_PROJECT_DIR/.claude/hooks/recall-surface.cjs\"" }
23
+ { "type": "command", "command": "node \"$CLAUDE_PROJECT_DIR/.claude/hooks/recall-surface.cjs\"" },
24
+ { "type": "command", "command": "node \"$CLAUDE_PROJECT_DIR/.claude/hooks/emit-event.cjs\"" }
23
25
  ]
24
26
  }
25
27
  ],
@@ -50,6 +52,11 @@
50
52
  { "type": "command", "command": "node \"$CLAUDE_PROJECT_DIR/.claude/hooks/code-intel-push.cjs\"" },
51
53
  { "type": "command", "command": "node \"$CLAUDE_PROJECT_DIR/.claude/hooks/drift-detect.cjs\"" }
52
54
  ]
55
+ },
56
+ {
57
+ "hooks": [
58
+ { "type": "command", "command": "node \"$CLAUDE_PROJECT_DIR/.claude/hooks/emit-event.cjs\"" }
59
+ ]
53
60
  }
54
61
  ],
55
62
  "Stop": [
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.8", "serve"]
5
+ "args": ["-y", "recon-wrxn@6.0.0-wrxn.9", "serve"]
6
6
  }
7
7
  }
8
8
  }