@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 +2 -0
- package/manifest.json +30 -15
- package/migrations/004-retire-session-capture.cjs +106 -0
- package/package.json +2 -2
- package/payload/.claude/hooks/drift-detect.cjs +165 -0
- package/payload/.claude/hooks/recall-surface.cjs +90 -4
- package/payload/.claude/hooks/session-start.cjs +4 -23
- package/payload/.claude/hooks/synapse-engine.cjs +41 -4
- package/payload/.claude/settings.json +2 -9
- package/payload/.claude/skills/harvest/SKILL.md +210 -0
- package/payload/.claude/skills/sync/SKILL.md +106 -0
- package/payload/.wrxn/dream.cjs +43 -1
- package/payload/.wrxn/harvest.cjs +1190 -0
- package/payload/.wrxn/sync.cjs +508 -0
- package/payload/.wrxn/wiki.cjs +36 -6
- package/payload/.claude/hooks/session-end.cjs +0 -172
- package/payload/.claude/hooks/session-history.cjs +0 -76
- /package/payload/.wrxn/{wiki/sessions → harvest}/.gitkeep +0 -0
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.
|
|
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
|
|
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
|
-
|
|
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
|
|
7
|
-
//
|
|
8
|
-
//
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
]
|
|
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
|
],
|