@gcunharodrigues/wrxn 0.6.0 → 0.7.1

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/lib/install.cjs CHANGED
@@ -84,6 +84,8 @@ function init(opts) {
84
84
 
85
85
  // recon-wrxn writes its index into a fixed `.recon-wrxn/` dir — keep it out of version control.
86
86
  ensureGitignoreLine(target, '.recon-wrxn/');
87
+ // the recall-surface hook writes per-install access-recency state here (harvest-08) — runtime, not committed.
88
+ ensureGitignoreLine(target, '.wrxn/reinforce.json');
87
89
 
88
90
  writeReceipt(target, { version, profile, laid, skipped, merged, brownfield });
89
91
 
package/manifest.json CHANGED
@@ -63,16 +63,6 @@
63
63
  "class": "managed",
64
64
  "profile": "project"
65
65
  },
66
- {
67
- "path": ".claude/hooks/session-end.cjs",
68
- "class": "managed",
69
- "profile": "project"
70
- },
71
- {
72
- "path": ".claude/hooks/session-history.cjs",
73
- "class": "managed",
74
- "profile": "project"
75
- },
76
66
  {
77
67
  "path": ".claude/hooks/session-start.cjs",
78
68
  "class": "managed",
@@ -133,6 +123,11 @@
133
123
  "class": "managed",
134
124
  "profile": "project"
135
125
  },
126
+ {
127
+ "path": ".claude/skills/harvest/SKILL.md",
128
+ "class": "managed",
129
+ "profile": "project"
130
+ },
136
131
  {
137
132
  "path": ".claude/skills/improve-codebase-architecture/DEEPENING.md",
138
133
  "class": "managed",
@@ -438,6 +433,16 @@
438
433
  "class": "managed",
439
434
  "profile": "project"
440
435
  },
436
+ {
437
+ "path": ".wrxn/harvest.cjs",
438
+ "class": "managed",
439
+ "profile": "project"
440
+ },
441
+ {
442
+ "path": ".wrxn/harvest/.gitkeep",
443
+ "class": "state",
444
+ "profile": "project"
445
+ },
441
446
  {
442
447
  "path": ".wrxn/wiki.cjs",
443
448
  "class": "managed",
@@ -463,11 +468,6 @@
463
468
  "class": "state",
464
469
  "profile": "project"
465
470
  },
466
- {
467
- "path": ".wrxn/wiki/sessions/.gitkeep",
468
- "class": "state",
469
- "profile": "project"
470
- },
471
471
  {
472
472
  "path": ".wrxn/wiki/_rules/.gitkeep",
473
473
  "class": "state",
@@ -0,0 +1,106 @@
1
+ 'use strict';
2
+
3
+ const fs = require('fs');
4
+ const path = require('path');
5
+
6
+ /**
7
+ * 004 — retire the session-capture subsystem (harvest-01).
8
+ *
9
+ * Phase 5 (harvest) drops the low-value mechanical session-capture layer: the `session-end` episodic
10
+ * breadcrumb writer, the `session-history` turn-trail recorder, and the `sessions` wiki tier they fed.
11
+ * The deliberate handoff (continuity baton) + dream consolidation are the close-out moment now — the
12
+ * automatic breadcrumb no longer earns its keep. The new payload no longer SHIPS the two hooks, but a
13
+ * pre-0.7.0 install still carries them: `wrxn update` overwrites a managed file in place, it never
14
+ * PRUNES one that was removed. So an existing install keeps the two hook files, a settings.json still
15
+ * wired for them, a populated `sessions` tier, and now-orphaned history scratch. up() sweeps all of it.
16
+ *
17
+ * Steps: (1) remove the two retired hook files; (2) unwire them from the install settings.json — drop
18
+ * the SessionEnd event whose only hook was session-end, and the session-history command from the
19
+ * UserPromptSubmit chain (synapse-engine + the rest are preserved); (3) remove the whole
20
+ * `.wrxn/wiki/sessions/` tier (dated pages + the gitkeep); (4) reap the now-orphaned
21
+ * `.wrxn/history/*.trail` (no writer/reader left) + `*.touched` markers. The `.wrxn/history/` dir
22
+ * itself STAYS — code-intel-push still writes `.touched` markers there.
23
+ *
24
+ * Defensive like 002/003: every step is existence-guarded and best-effort (force-rm ignores a missing
25
+ * file), a missing/clean target is a no-op, and a corrupt settings.json is left untouched (never
26
+ * clobber a hand-edited file — the other sweeps still run). Idempotent (a second run finds nothing to
27
+ * do) and never throws on an already-clean install. `version` 0.7.0 = the harvest release that carries
28
+ * the retirement (the same release whose payload stops shipping the two hooks).
29
+ */
30
+
31
+ const RETIRED_HOOKS = ['session-end.cjs', 'session-history.cjs'];
32
+
33
+ // Remove every hook command referencing `basename` across all settings events; drop any group left
34
+ // with no hooks, and any event left with no groups. Returns true iff the config changed. Preserves
35
+ // every other hook (synapse-engine, reference-detect, recall-surface, session-start, …).
36
+ function unwireHook(cfg, basename) {
37
+ const hooks = cfg && cfg.hooks;
38
+ if (!hooks || typeof hooks !== 'object') return false;
39
+ let changed = false;
40
+ for (const event of Object.keys(hooks)) {
41
+ const groups = hooks[event];
42
+ if (!Array.isArray(groups)) continue;
43
+ for (const group of groups) {
44
+ if (!group || !Array.isArray(group.hooks)) continue;
45
+ const before = group.hooks.length;
46
+ group.hooks = group.hooks.filter(
47
+ (h) => !(h && typeof h.command === 'string' && h.command.includes(basename)),
48
+ );
49
+ if (group.hooks.length !== before) changed = true;
50
+ }
51
+ const kept = groups.filter((g) => g && Array.isArray(g.hooks) && g.hooks.length > 0);
52
+ if (kept.length !== groups.length) {
53
+ changed = true;
54
+ if (kept.length === 0) delete hooks[event];
55
+ else hooks[event] = kept;
56
+ }
57
+ }
58
+ return changed;
59
+ }
60
+
61
+ module.exports = {
62
+ id: '004',
63
+ version: '0.7.0',
64
+ up(ctx) {
65
+ const target = ctx.target;
66
+
67
+ // 1. remove the two retired hook files (force = absent is a no-op)
68
+ for (const h of RETIRED_HOOKS) {
69
+ fs.rmSync(path.join(target, '.claude', 'hooks', h), { force: true });
70
+ }
71
+
72
+ // 2. unwire them from settings.json — only while still wired; a corrupt file is left untouched
73
+ const settingsPath = path.join(target, '.claude', 'settings.json');
74
+ if (fs.existsSync(settingsPath)) {
75
+ let cfg = null;
76
+ try {
77
+ cfg = JSON.parse(fs.readFileSync(settingsPath, 'utf8'));
78
+ } catch {
79
+ cfg = null; // hand-corrupted operator file → never clobber, never crash
80
+ }
81
+ if (cfg && typeof cfg === 'object') {
82
+ let changed = false;
83
+ for (const h of RETIRED_HOOKS) if (unwireHook(cfg, h)) changed = true;
84
+ if (changed) fs.writeFileSync(settingsPath, JSON.stringify(cfg, null, 2) + '\n');
85
+ }
86
+ }
87
+
88
+ // 3. sweep the retired `sessions` wiki tier (the whole dir: dated pages + the gitkeep)
89
+ fs.rmSync(path.join(target, '.wrxn', 'wiki', 'sessions'), { recursive: true, force: true });
90
+
91
+ // 4. reap orphaned history scratch — the *.trail (no writer/reader left) + *.touched markers.
92
+ // The `.wrxn/history/` dir itself stays: code-intel-push still records .touched markers there.
93
+ const histDir = path.join(target, '.wrxn', 'history');
94
+ let names = [];
95
+ try {
96
+ names = fs.readdirSync(histDir);
97
+ } catch {
98
+ names = []; // no history dir → nothing to reap
99
+ }
100
+ for (const n of names) {
101
+ if (n.endsWith('.trail') || n.endsWith('.touched')) {
102
+ fs.rmSync(path.join(histDir, n), { force: true });
103
+ }
104
+ }
105
+ },
106
+ };
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@gcunharodrigues/wrxn",
3
- "version": "0.6.0",
3
+ "version": "0.7.1",
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"
@@ -16,7 +16,7 @@
16
16
  "test": "node --test --require ./test/setup.cjs"
17
17
  },
18
18
  "dependencies": {
19
- "recon-wrxn": "6.0.0-wrxn.3"
19
+ "recon-wrxn": "6.0.0-wrxn.5"
20
20
  },
21
21
  "engines": {
22
22
  "node": ">=20"
@@ -36,6 +36,8 @@ const SEMANTIC_FLOOR = 0.4; // dense cosine floor (reused from P1.5)
36
36
  const PROSE_TYPES = new Set(['Page', 'Section']); // prose scope — drop code symbols
37
37
  const ENDPOINT_REL = path.join('.recon-wrxn', 'serve-endpoint.json');
38
38
  const FIND_PATH = '/api/tools/recon_find';
39
+ const REINFORCE_REL = path.join('.wrxn', 'reinforce.json'); // coalesced access-recency sidecar (STATE)
40
+ const WIKI_PREFIX = '.wrxn/wiki/'; // the wiki root — stripped to form the D1 join key
39
41
 
40
42
  function emit(envelope) {
41
43
  process.stdout.write(JSON.stringify(envelope));
@@ -122,14 +124,90 @@ function renderBlock(hits) {
122
124
  return block;
123
125
  }
124
126
 
127
+ // PURE: the prose hits that clear the gate, capped at TOP_N — exactly the hits decideRecall renders
128
+ // (and the pages reinforce stamps). Factored out so the IO shell can stamp the surfaced pages by path.
129
+ function qualifyingHits(hits) {
130
+ const list = Array.isArray(hits) ? hits : [];
131
+ return list.filter((h) => isProse(h) && qualifies(h)).slice(0, TOP_N);
132
+ }
133
+
125
134
  // PURE: prose-filter → gate → top-N → format. Returns the block string, or null (Abstain).
126
135
  function decideRecall(hits) {
127
- const list = Array.isArray(hits) ? hits : [];
128
- const qualified = list.filter((h) => isProse(h) && qualifies(h)).slice(0, TOP_N);
136
+ const qualified = qualifyingHits(hits);
129
137
  if (!qualified.length) return null;
130
138
  return renderBlock(qualified);
131
139
  }
132
140
 
141
+ // ── reinforce: the coalesced access-recency sidecar (harvest-08 / D2) ─────────────────
142
+ //
143
+ // When Recall actually surfaces prose pages, stamp each page's "last used" day into
144
+ // <root>/.wrxn/reinforce.json — a COMPACT MAP { "<wiki-rel-path>": "YYYY-MM-DD" }, NOT page frontmatter
145
+ // (no churn) and NOT an append log (no growth). recon harvest-07/D1 reads this sidecar to compute
146
+ // recency for decay-weighted retrieval; the join key MUST be the wiki-root-relative path on BOTH sides
147
+ // (a slug-vs-path mismatch silently breaks recency). COALESCED to <= 1 write per page per day: when
148
+ // every surfaced page already carries today's date the map is unchanged and NOTHING is written.
149
+ // BEST-EFFORT + NON-BLOCKING: this is a pure side effect of recall — any fault (absent dir, malformed
150
+ // existing sidecar, unwritable path) is swallowed so the surfacing always proceeds.
151
+
152
+ // The wiki-root-relative join key for a prose hit's file: tolerate a leading './', normalize separators,
153
+ // then strip the '.wrxn/wiki/' prefix → e.g. 'concepts/foo.md'. Returns null when the file is not under
154
+ // the wiki root (no join key — never stamped).
155
+ function wikiRelPath(file) {
156
+ const f = String(file || '').replace(/\\/g, '/').replace(/^\.\//, '');
157
+ const i = f.indexOf(WIKI_PREFIX);
158
+ if (i === -1) return null;
159
+ return f.slice(i + WIKI_PREFIX.length) || null;
160
+ }
161
+
162
+ // A day-granular UTC stamp (YYYY-MM-DD): the coalescing grain AND D1's recency value. Injectable clock
163
+ // (`now` = a Date/ms/iso, default real time) so day-granularity is deterministic under test.
164
+ function dayStamp(now) {
165
+ const d = now instanceof Date ? now : new Date(now == null ? Date.now() : now);
166
+ return d.toISOString().slice(0, 10);
167
+ }
168
+
169
+ // Stamp each surfaced prose hit's wiki-rel path → today into <root>/.wrxn/reinforce.json. Writes only
170
+ // when the map actually changes (coalesced). Wholly best-effort: never throws, never blocks recall.
171
+ function reinforce(root, hits, now) {
172
+ try {
173
+ const list = Array.isArray(hits) ? hits : [];
174
+ if (!root || !list.length) return;
175
+ const file = path.join(root, REINFORCE_REL);
176
+ let map = {};
177
+ let raw = null;
178
+ try {
179
+ raw = fs.readFileSync(file, 'utf8');
180
+ } catch {
181
+ raw = null; // absent → fresh map (normal, not a fault)
182
+ }
183
+ if (raw !== null) {
184
+ let parsed;
185
+ try {
186
+ parsed = JSON.parse(raw);
187
+ } catch {
188
+ return; // malformed existing sidecar → skip silently, leave it untouched (never clobber)
189
+ }
190
+ if (!parsed || typeof parsed !== 'object' || Array.isArray(parsed)) return; // not a map → skip
191
+ map = parsed;
192
+ }
193
+ const day = dayStamp(now);
194
+ let changed = false;
195
+ for (const h of list) {
196
+ const key = wikiRelPath(h && h.file);
197
+ if (!key) continue; // not under the wiki root → no D1 join key
198
+ if (map[key] !== day) {
199
+ map[key] = day;
200
+ changed = true;
201
+ }
202
+ }
203
+ if (!changed) return; // coalesced no-op → file stays byte-identical (<= 1 write/page/day)
204
+ fs.mkdirSync(path.dirname(file), { recursive: true });
205
+ fs.writeFileSync(file, JSON.stringify(map, null, 2) + '\n');
206
+ } catch {
207
+ /* best-effort: a reinforce fault must NEVER alter or break the recall surfacing */
208
+ }
209
+ }
210
+
133
211
  // ── the door (IO shell, injectable transport) ───────────────────────────────────────
134
212
 
135
213
  // A pid is alive unless process.kill(pid,0) throws ESRCH. EPERM means it exists (owned by another
@@ -233,7 +311,7 @@ function httpTransport({ port, path: reqPath, body, timeoutMs }) {
233
311
  // IO shell: discover the door, POST the prose query, gate the hits. Returns the block string or null.
234
312
  // `transport` is injected in tests; production uses httpTransport. Sends NO `type` (recon_find takes a
235
313
  // single NodeType, not an array) — prose scope is enforced by decideRecall's post-filter.
236
- async function recallFromDoor(root, prompt, { transport, timeoutMs } = {}) {
314
+ async function recallFromDoor(root, prompt, { transport, timeoutMs, now } = {}) {
237
315
  const door = discoverEndpoint(root);
238
316
  if (!door) return null; // not warm → Abstain (silent)
239
317
  const query = String(prompt || '').trim().slice(0, MAX_QUERY_CHARS);
@@ -256,7 +334,11 @@ async function recallFromDoor(root, prompt, { transport, timeoutMs } = {}) {
256
334
  } catch {
257
335
  return null; // malformed body → silent
258
336
  }
259
- return decideRecall(Array.isArray(parsed.hits) ? parsed.hits : []);
337
+ const hits = Array.isArray(parsed.hits) ? parsed.hits : [];
338
+ const block = decideRecall(hits);
339
+ // Side effect: stamp access-recency for the pages we actually surfaced (best-effort, never blocks).
340
+ if (block) reinforce(root, qualifyingHits(hits), now);
341
+ return block;
260
342
  }
261
343
 
262
344
  // ── entrypoint ──────────────────────────────────────────────────────────────────────
@@ -293,7 +375,11 @@ if (require.main === module) {
293
375
 
294
376
  module.exports = {
295
377
  decideRecall,
378
+ qualifyingHits,
296
379
  recallFromDoor,
380
+ reinforce,
381
+ wikiRelPath,
382
+ dayStamp,
297
383
  discoverEndpoint,
298
384
  httpTransport,
299
385
  pidAlive,
@@ -3,9 +3,9 @@
3
3
 
4
4
  // WRXN session-start hook — the orientation surface (wrxn-kernel-10).
5
5
  // SessionStart. Injects identity + resume as additionalContext so every new session opens
6
- // oriented. The resume gives the DELIBERATE handoff baton precedence over the automatic
7
- // episodic record: a baton at .wrxn/continuity/latest.md (single writer = the handoff skill)
8
- // wins; otherwise the most-recent dated session page is surfaced as the resume pointer.
6
+ // oriented. The resume surfaces the DELIBERATE handoff baton at .wrxn/continuity/latest.md (single
7
+ // writer = the handoff skill); absent a baton there is no prior handoff to resume. (The automatic
8
+ // episodic session-page fallback was retired with the session-capture subsystem in harvest-01.)
9
9
  //
10
10
  // Self-contained: ships into installs, MUST NOT import the kernel lib (node stdlib only).
11
11
  // Fail-open: any fault emits {} (no orientation) — the hook NEVER blocks a session opening.
@@ -62,20 +62,6 @@ function readBaton(root) {
62
62
  return readFileOr(path.join(root, '.wrxn', 'continuity', 'latest.md'), null);
63
63
  }
64
64
 
65
- // The automatic episodic record: the most-recent dated session page (sessions tier).
66
- function latestSessionPage(root) {
67
- const dir = path.join(root, '.wrxn', 'wiki', 'sessions');
68
- let names;
69
- try {
70
- names = fs.readdirSync(dir).filter((n) => n.endsWith('.md'));
71
- } catch {
72
- return null;
73
- }
74
- if (names.length === 0) return null;
75
- names.sort(); // dated `YYYY-MM-DD-…` slugs sort chronologically
76
- return names[names.length - 1];
77
- }
78
-
79
65
  function main() {
80
66
  let consumed = '';
81
67
  try {
@@ -94,12 +80,7 @@ function main() {
94
80
  if (baton && baton.trim()) {
95
81
  parts.push('', 'Resume — deliberate handoff baton (.wrxn/continuity/latest.md):', baton.trim());
96
82
  } else {
97
- const page = latestSessionPage(root);
98
- if (page) {
99
- parts.push('', `Resume — last session: .wrxn/wiki/sessions/${page}`);
100
- } else {
101
- parts.push('', 'Resume — fresh install, no prior session recorded.');
102
- }
83
+ parts.push('', 'Resume no prior handoff.');
103
84
  }
104
85
 
105
86
  emit({
@@ -247,18 +247,54 @@ function resolveHandoffPct(manifestText) {
247
247
  return Number.isFinite(m) && m > 0 ? m : 0.40;
248
248
  }
249
249
 
250
+ // A CHEAP, fail-open presence-probe for curation debt — the debt-gate on the handoff harvest nudge
251
+ // (harvest-05). It does NOT recompute health: no recon-door query, no scan of the knowledge tiers (that
252
+ // is harvest.cjs `check`, an operator-invoked command far too heavy for a per-prompt hook). It reads ONLY
253
+ // the single latest `.wrxn/harvest/<ts>.jsonl` report a prior `check` already wrote and asks "did the last
254
+ // health-check find real debt?". Report filenames are timestamp-derived (ISO with `:`/`.` → `-`), so the
255
+ // lexically-greatest name is the newest report. A real finding = any record EXCEPT the near_dup
256
+ // "unavailable" marker (a cold-door "couldn't check", not debt); an empty report / only-unavailable / no
257
+ // reports / missing dir all read as no-debt → silent. Any fault → false (never a spurious nudge, never a
258
+ // throw). Only invoked when a handoff is actually firing, so the one extra file read is doubly bounded.
259
+ function hasCurationDebt(root) {
260
+ try {
261
+ const dir = path.join(root, '.wrxn', 'harvest');
262
+ const reports = fs.readdirSync(dir).filter((n) => n.endsWith('.jsonl'));
263
+ if (!reports.length) return false;
264
+ const latest = reports.sort()[reports.length - 1];
265
+ const text = fs.readFileSync(path.join(dir, latest), 'utf8');
266
+ for (const line of text.split('\n')) {
267
+ const t = line.trim();
268
+ if (!t) continue;
269
+ let rec;
270
+ try { rec = JSON.parse(t); } catch { continue; } // a malformed line contributes nothing
271
+ if (rec && rec.type && rec.status !== 'unavailable') return true;
272
+ }
273
+ return false;
274
+ } catch {
275
+ return false; // missing dir / unreadable report / any fault → fail-open: no debt, no throw
276
+ }
277
+ }
278
+
250
279
  // The NON-BLOCKING forced-handoff directive (never refuses work — orders the agent to wrap up cleanly).
251
- function handoffDirective(consumed, pct) {
280
+ // `hasDebt` (harvest-05) appends a harvest curation nudge AFTER the dream line — emitted ONLY when the
281
+ // 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) {
252
284
  const now = Math.round(consumed * 100);
253
285
  const thresh = Math.round(pct * 100);
254
- return [
286
+ const lines = [
255
287
  '[HANDOFF REQUIRED]',
256
288
  ` Context is at ~${now}% of the model window (>= the ${thresh}% handoff threshold). NON-BLOCKING — do NOT stop work:`,
257
289
  ' 1. Finish the current request.',
258
290
  ' 2. Run the handoff skill to write the baton (a compact handoff document).',
259
291
  ' 3. Tell the operator to /clear and open a fresh session, where the baton injects on resume.',
260
292
  ' 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.',
261
- ].join('\n');
293
+ ];
294
+ if (hasDebt) {
295
+ 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.');
296
+ }
297
+ return lines.join('\n');
262
298
  }
263
299
 
264
300
  // ── assembly ────────────────────────────────────────────────────────────────────
@@ -325,7 +361,7 @@ function compose(root, event) {
325
361
  const window = modelWindow(ev.cwd || root, process.env.HOME || os.homedir(), manifestText, ev.session_id, resident);
326
362
  const consumed = resident / window;
327
363
  const pct = resolveHandoffPct(manifestText);
328
- if (consumed >= pct) out.push(handoffDirective(consumed, pct));
364
+ if (consumed >= pct) out.push(handoffDirective(consumed, pct, hasCurationDebt(root)));
329
365
  }
330
366
  }
331
367
 
@@ -375,5 +411,6 @@ module.exports = {
375
411
  readStatuslineWindow,
376
412
  modelWindow,
377
413
  resolveHandoffPct,
414
+ hasCurationDebt,
378
415
  handoffDirective,
379
416
  };
@@ -7,18 +7,10 @@
7
7
  ]
8
8
  }
9
9
  ],
10
- "SessionEnd": [
11
- {
12
- "hooks": [
13
- { "type": "command", "command": "node \"$CLAUDE_PROJECT_DIR/.claude/hooks/session-end.cjs\"" }
14
- ]
15
- }
16
- ],
17
10
  "UserPromptSubmit": [
18
11
  {
19
12
  "hooks": [
20
13
  { "type": "command", "command": "node \"$CLAUDE_PROJECT_DIR/.claude/hooks/synapse-engine.cjs\"" },
21
- { "type": "command", "command": "node \"$CLAUDE_PROJECT_DIR/.claude/hooks/session-history.cjs\"" },
22
14
  { "type": "command", "command": "node \"$CLAUDE_PROJECT_DIR/.claude/hooks/reference-detect.cjs\"" },
23
15
  { "type": "command", "command": "node \"$CLAUDE_PROJECT_DIR/.claude/hooks/recall-surface.cjs\"" }
24
16
  ]