@gcunharodrigues/wrxn 0.5.0 → 0.7.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/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
@@ -23,6 +23,11 @@
23
23
  "class": "managed",
24
24
  "profile": "project"
25
25
  },
26
+ {
27
+ "path": ".claude/hooks/drift-detect.cjs",
28
+ "class": "managed",
29
+ "profile": "project"
30
+ },
26
31
  {
27
32
  "path": ".claude/hooks/enforce-managed-guard.cjs",
28
33
  "class": "managed",
@@ -58,16 +63,6 @@
58
63
  "class": "managed",
59
64
  "profile": "project"
60
65
  },
61
- {
62
- "path": ".claude/hooks/session-end.cjs",
63
- "class": "managed",
64
- "profile": "project"
65
- },
66
- {
67
- "path": ".claude/hooks/session-history.cjs",
68
- "class": "managed",
69
- "profile": "project"
70
- },
71
66
  {
72
67
  "path": ".claude/hooks/session-start.cjs",
73
68
  "class": "managed",
@@ -128,6 +123,11 @@
128
123
  "class": "managed",
129
124
  "profile": "project"
130
125
  },
126
+ {
127
+ "path": ".claude/skills/harvest/SKILL.md",
128
+ "class": "managed",
129
+ "profile": "project"
130
+ },
131
131
  {
132
132
  "path": ".claude/skills/improve-codebase-architecture/DEEPENING.md",
133
133
  "class": "managed",
@@ -293,6 +293,11 @@
293
293
  "class": "managed",
294
294
  "profile": "project"
295
295
  },
296
+ {
297
+ "path": ".claude/skills/sync/SKILL.md",
298
+ "class": "managed",
299
+ "profile": "project"
300
+ },
296
301
  {
297
302
  "path": ".claude/skills/tdd/SKILL.md",
298
303
  "class": "managed",
@@ -423,6 +428,21 @@
423
428
  "class": "state",
424
429
  "profile": "project"
425
430
  },
431
+ {
432
+ "path": ".wrxn/sync.cjs",
433
+ "class": "managed",
434
+ "profile": "project"
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
+ },
426
446
  {
427
447
  "path": ".wrxn/wiki.cjs",
428
448
  "class": "managed",
@@ -448,11 +468,6 @@
448
468
  "class": "state",
449
469
  "profile": "project"
450
470
  },
451
- {
452
- "path": ".wrxn/wiki/sessions/.gitkeep",
453
- "class": "state",
454
- "profile": "project"
455
- },
456
471
  {
457
472
  "path": ".wrxn/wiki/_rules/.gitkeep",
458
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.5.0",
3
+ "version": "0.7.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"
@@ -13,7 +13,7 @@
13
13
  "manifest.json"
14
14
  ],
15
15
  "scripts": {
16
- "test": "node --test"
16
+ "test": "node --test --require ./test/setup.cjs"
17
17
  },
18
18
  "dependencies": {
19
19
  "recon-wrxn": "6.0.0-wrxn.3"
@@ -0,0 +1,165 @@
1
+ #!/usr/bin/env node
2
+ 'use strict';
3
+
4
+ // WRXN drift-detect hook — reactive provenance-drift nudge (sync-07).
5
+ // PostToolUse (Edit|Write). When an edit touches a SOURCE file that downstream wiki docs declare
6
+ // `derived_from:`, it injects a <drift> nudge naming the affected doc(s) — so drift surfaces the moment
7
+ // the source moves, not only at the next batch `wrxn sync`.
8
+ //
9
+ // Self-contained: ships into installs, MUST NOT import the kernel lib OR recon (node stdlib only).
10
+ // Mechanical: a pure fs + string frontmatter scan, NO LLM call. Independent of sync-01 — it never reads
11
+ // a `synced_to:` watermark and never writes; detection NUDGE only.
12
+ //
13
+ // Fail-open: any fault (no install root, unreadable wiki, a corrupt page, a missing dir) emits {} and
14
+ // NEVER blocks the edit.
15
+ //
16
+ // Contract: PostToolUse event JSON on stdin → envelope JSON on stdout (exit 0).
17
+
18
+ const fs = require('fs');
19
+ const path = require('path');
20
+
21
+ function emit(envelope) {
22
+ process.stdout.write(JSON.stringify(envelope));
23
+ process.exit(0);
24
+ }
25
+
26
+ function findInstallRoot(startDir) {
27
+ let dir = startDir || process.env.CLAUDE_PROJECT_DIR || process.cwd();
28
+ for (let i = 0; i < 12; i++) {
29
+ if (fs.existsSync(path.join(dir, 'wrxn.install.json'))) return dir;
30
+ const up = path.dirname(dir);
31
+ if (up === dir) break;
32
+ dir = up;
33
+ }
34
+ return null;
35
+ }
36
+
37
+ // Normalize a path-ish value to an install-root-relative POSIX path. Drops a `#symbol` anchor
38
+ // (sync's `derived_from: path#symbol` form), then resolves relative/absolute/`./`-prefixed forms to
39
+ // the same canonical key so same-path and relative-path declarations both match. Returns '' on empty.
40
+ function relTo(root, p) {
41
+ const s = String(p == null ? '' : p).split('#')[0].trim();
42
+ if (!s) return '';
43
+ const abs = path.isAbsolute(s) ? s : path.resolve(root, s);
44
+ return path.relative(root, abs).split(path.sep).join('/');
45
+ }
46
+
47
+ function unquote(s) {
48
+ return String(s).trim().replace(/^["']|["']$/g, '').trim();
49
+ }
50
+
51
+ // Extract the frontmatter block (between the leading `---` fence and the next `---`). A page without a
52
+ // closed fence yields '' — its provenance is unreadable, so it simply contributes no declaration.
53
+ function frontmatter(content) {
54
+ const m = /^---\r?\n([\s\S]*?)\r?\n---/.exec(content);
55
+ return m ? m[1] : '';
56
+ }
57
+
58
+ // Parse the `derived_from:` declaration(s) from a page's frontmatter. Handles a scalar, an inline list
59
+ // (`[a, b]`), and a block list (`- item` lines). Returns the raw value strings (anchors intact).
60
+ function parseDerivedFrom(content) {
61
+ const fm = frontmatter(content);
62
+ if (!fm) return [];
63
+ const lines = fm.split(/\r?\n/);
64
+ const out = [];
65
+ for (let i = 0; i < lines.length; i++) {
66
+ const m = /^derived_from:\s*(.*)$/.exec(lines[i]);
67
+ if (!m) continue;
68
+ const val = m[1].trim();
69
+ if (val.startsWith('[')) {
70
+ // inline list: [a, b, c]
71
+ for (const part of val.replace(/^\[|\]$/g, '').split(',')) {
72
+ const v = unquote(part);
73
+ if (v) out.push(v);
74
+ }
75
+ } else if (val) {
76
+ out.push(unquote(val));
77
+ } else {
78
+ // block list: subsequent ` - item` lines until the first non-item line
79
+ for (let j = i + 1; j < lines.length; j++) {
80
+ const li = /^\s*-\s+(.*)$/.exec(lines[j]);
81
+ if (!li) break;
82
+ const v = unquote(li[1]);
83
+ if (v) out.push(v);
84
+ }
85
+ }
86
+ }
87
+ return out;
88
+ }
89
+
90
+ // Collect every .md page under <root>/.wrxn/wiki/ (recursively). A missing/unreadable dir yields [].
91
+ function collectDocs(wikiDir, acc) {
92
+ let entries;
93
+ try {
94
+ entries = fs.readdirSync(wikiDir, { withFileTypes: true });
95
+ } catch {
96
+ return acc; // missing/unreadable tree → no docs
97
+ }
98
+ for (const e of entries) {
99
+ const full = path.join(wikiDir, e.name);
100
+ if (e.isDirectory()) collectDocs(full, acc);
101
+ else if (e.isFile() && e.name.endsWith('.md')) acc.push(full);
102
+ }
103
+ return acc;
104
+ }
105
+
106
+ // The set of doc relpaths whose frontmatter declares `derived_from:` the edited path.
107
+ function affectedDocs(root, editedRel) {
108
+ const wikiDir = path.join(root, '.wrxn', 'wiki');
109
+ const hits = new Set();
110
+ for (const file of collectDocs(wikiDir, [])) {
111
+ let content;
112
+ try {
113
+ content = fs.readFileSync(file, 'utf8');
114
+ } catch {
115
+ continue; // skip an unreadable page (fail-open per-file)
116
+ }
117
+ const docRel = path.relative(root, file).split(path.sep).join('/');
118
+ if (docRel === editedRel) continue; // never flag the edited file against itself
119
+ for (const raw of parseDerivedFrom(content)) {
120
+ if (relTo(root, raw) === editedRel) {
121
+ hits.add(docRel);
122
+ break;
123
+ }
124
+ }
125
+ }
126
+ return [...hits].sort();
127
+ }
128
+
129
+ function main() {
130
+ let event = {};
131
+ try {
132
+ const stdin = fs.readFileSync(0, 'utf8');
133
+ if (stdin.trim()) event = JSON.parse(stdin);
134
+ } catch {
135
+ emit({});
136
+ }
137
+
138
+ const root = findInstallRoot();
139
+ if (!root) emit({});
140
+
141
+ const filePath = event.tool_input && event.tool_input.file_path;
142
+ if (!filePath || typeof filePath !== 'string') emit({}); // not a file-touching tool
143
+
144
+ const editedRel = relTo(root, filePath);
145
+ if (!editedRel) emit({});
146
+
147
+ const docs = affectedDocs(root, editedRel);
148
+ if (docs.length === 0) emit({}); // no downstream provenance → silent
149
+
150
+ const ctx = [
151
+ '<drift>',
152
+ `Edited ${editedRel} — ${docs.length} downstream doc(s) declare derived_from it and may now be stale:`,
153
+ ...docs.map((d) => ` - ${d}`),
154
+ 'Re-derive the affected doc(s), or run `wrxn sync` to confirm the drift set.',
155
+ '</drift>',
156
+ ].join('\n');
157
+
158
+ emit({ hookSpecificOutput: { hookEventName: 'PostToolUse', additionalContext: ctx } });
159
+ }
160
+
161
+ try {
162
+ main();
163
+ } catch {
164
+ emit({});
165
+ }
@@ -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
  ]
@@ -45,7 +37,8 @@
45
37
  {
46
38
  "matcher": "Edit|Write",
47
39
  "hooks": [
48
- { "type": "command", "command": "node \"$CLAUDE_PROJECT_DIR/.claude/hooks/code-intel-push.cjs\"" }
40
+ { "type": "command", "command": "node \"$CLAUDE_PROJECT_DIR/.claude/hooks/code-intel-push.cjs\"" },
41
+ { "type": "command", "command": "node \"$CLAUDE_PROJECT_DIR/.claude/hooks/drift-detect.cjs\"" }
49
42
  ]
50
43
  }
51
44
  ],