@gcunharodrigues/wrxn 0.1.0 → 0.2.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/bin/wrxn.cjs +59 -1
- package/lib/install.cjs +76 -5
- package/lib/statusline.cjs +97 -0
- package/lib/update.cjs +15 -3
- package/manifest.json +11 -1
- package/migrations/001-recon-to-recon-wrxn.cjs +79 -0
- package/package.json +4 -1
- package/payload/.claude/hooks/code-intel-push.cjs +9 -8
- package/payload/.claude/hooks/synapse-engine.cjs +41 -14
- package/payload/.mcp.json +8 -0
- package/payload/.recon-wrxn.json +6 -0
- package/payload/docs/statusline-sidecar.sh +30 -0
- package/payload/.recon.json +0 -3
package/bin/wrxn.cjs
CHANGED
|
@@ -3,6 +3,7 @@
|
|
|
3
3
|
|
|
4
4
|
const fs = require('fs');
|
|
5
5
|
const path = require('path');
|
|
6
|
+
const os = require('os');
|
|
6
7
|
|
|
7
8
|
const { init } = require('../lib/install.cjs');
|
|
8
9
|
const { update } = require('../lib/update.cjs');
|
|
@@ -10,6 +11,7 @@ const worktree = require('../lib/worktree.cjs');
|
|
|
10
11
|
const executor = require('../lib/executor.cjs');
|
|
11
12
|
const onboard = require('../lib/onboard.cjs');
|
|
12
13
|
const connect = require('../lib/connect.cjs');
|
|
14
|
+
const statusline = require('../lib/statusline.cjs');
|
|
13
15
|
|
|
14
16
|
const PKG_ROOT = path.join(__dirname, '..');
|
|
15
17
|
|
|
@@ -103,6 +105,13 @@ Usage:
|
|
|
103
105
|
list print all registered connections (agent-readable JSON)
|
|
104
106
|
get <name> print one connection by name
|
|
105
107
|
|
|
108
|
+
wrxn statusline [--inject [--path <script>]]
|
|
109
|
+
SYNAPSE live-window writer. With no flag: report whether a statusline
|
|
110
|
+
is configured (~/.claude/settings.json) + print the marker-bounded
|
|
111
|
+
sidecar block + how to enable. With --inject: append the block to the
|
|
112
|
+
resolved (or --path) statusline script, idempotently (append-only,
|
|
113
|
+
never overwrites). init NEVER touches your statusline.
|
|
114
|
+
|
|
106
115
|
wrxn onboard [--root <dir>] scaffold the Day-1 operator file set under context/ from a filled
|
|
107
116
|
aios-intake.md (the deterministic half of the onboard skill;
|
|
108
117
|
workspace installs only). Idempotent.
|
|
@@ -148,10 +157,19 @@ function main(argv) {
|
|
|
148
157
|
for (const f of report.skipped) {
|
|
149
158
|
process.stdout.write(` skipped [${f.class}] ${f.path} (${f.collision ? 'collision — existing file preserved' : 'exists'})\n`);
|
|
150
159
|
}
|
|
151
|
-
|
|
160
|
+
for (const f of report.merged || []) {
|
|
161
|
+
process.stdout.write(` merged [${f.class}] ${f.path} (recon-wrxn server added to your existing config)\n`);
|
|
162
|
+
}
|
|
163
|
+
process.stdout.write(`${report.laid.length} laid, ${report.skipped.length} unchanged${report.merged && report.merged.length ? `, ${report.merged.length} merged` : ''}.\n`);
|
|
152
164
|
if (report.brownfield) {
|
|
153
165
|
process.stdout.write(`brownfield install — ${report.collisions.length} existing file(s) preserved (never overwritten): ${report.collisions.map((c) => c.path).join(', ')}\n`);
|
|
154
166
|
}
|
|
167
|
+
if (report.adoptHint) {
|
|
168
|
+
process.stdout.write(`${report.adoptHint}\n`);
|
|
169
|
+
}
|
|
170
|
+
if (report.statuslineHint) {
|
|
171
|
+
process.stdout.write(`${report.statuslineHint}\n`);
|
|
172
|
+
}
|
|
155
173
|
return 0;
|
|
156
174
|
}
|
|
157
175
|
|
|
@@ -335,6 +353,46 @@ function main(argv) {
|
|
|
335
353
|
}
|
|
336
354
|
}
|
|
337
355
|
|
|
356
|
+
if (cmd === 'statusline') {
|
|
357
|
+
const home = process.env.HOME || os.homedir();
|
|
358
|
+
const detection = statusline.detectStatusLine(home);
|
|
359
|
+
|
|
360
|
+
// --inject: append the sidecar block to the resolved (or --path) statusline script, idempotently.
|
|
361
|
+
if (args.flags.inject) {
|
|
362
|
+
const target = args.flags.path || detection.scriptPath;
|
|
363
|
+
if (!target) {
|
|
364
|
+
process.stderr.write('wrxn: no statusline script to inject into — pass --path <script>, or configure statusLine in ~/.claude/settings.json first\n');
|
|
365
|
+
return 2;
|
|
366
|
+
}
|
|
367
|
+
try {
|
|
368
|
+
const r = statusline.injectSnippet(target);
|
|
369
|
+
process.stdout.write(r.injected
|
|
370
|
+
? `wrxn sidecar appended to ${r.path}\n`
|
|
371
|
+
: `wrxn sidecar already present in ${r.path} — no change\n`);
|
|
372
|
+
return 0;
|
|
373
|
+
} catch (err) {
|
|
374
|
+
process.stderr.write(`wrxn: ${err.message}\n`);
|
|
375
|
+
return 2;
|
|
376
|
+
}
|
|
377
|
+
}
|
|
378
|
+
|
|
379
|
+
// Default: report detection + print the snippet + how to enable.
|
|
380
|
+
if (detection.configured) {
|
|
381
|
+
process.stdout.write(`statusline detected: ${detection.command}\n`);
|
|
382
|
+
process.stdout.write(detection.scriptPath
|
|
383
|
+
? ` script: ${detection.scriptPath}\n`
|
|
384
|
+
: ' (not a bash <path> command — cannot auto-resolve a script to inject into)\n');
|
|
385
|
+
} else {
|
|
386
|
+
process.stdout.write('no statusline configured in ~/.claude/settings.json\n');
|
|
387
|
+
}
|
|
388
|
+
process.stdout.write('\nThe SYNAPSE live-window block (host statusline must read stdin into $input and set $session_id):\n\n');
|
|
389
|
+
process.stdout.write(statusline.snippet() + '\n');
|
|
390
|
+
process.stdout.write(detection.scriptPath
|
|
391
|
+
? `Enable: wrxn statusline --inject (appends idempotently to ${detection.scriptPath})\n`
|
|
392
|
+
: 'Enable: wrxn statusline --inject --path <your-statusline-script>\n');
|
|
393
|
+
return 0;
|
|
394
|
+
}
|
|
395
|
+
|
|
338
396
|
process.stderr.write(`wrxn: unknown command "${cmd}"\n\n${USAGE}\n`);
|
|
339
397
|
return 2;
|
|
340
398
|
}
|
package/lib/install.cjs
CHANGED
|
@@ -4,8 +4,10 @@ const fs = require('fs');
|
|
|
4
4
|
const path = require('path');
|
|
5
5
|
|
|
6
6
|
const { loadManifest, inProfile } = require('./manifest.cjs');
|
|
7
|
+
const { STATUSLINE_HINT } = require('./statusline.cjs');
|
|
7
8
|
|
|
8
9
|
const RECEIPT = 'wrxn.install.json';
|
|
10
|
+
const MCP_PATH = '.mcp.json';
|
|
9
11
|
|
|
10
12
|
/**
|
|
11
13
|
* Lay the kernel payload into a target root, governed by the file-class manifest.
|
|
@@ -33,8 +35,13 @@ function init(opts) {
|
|
|
33
35
|
|
|
34
36
|
const laid = [];
|
|
35
37
|
const skipped = [];
|
|
38
|
+
const merged = [];
|
|
36
39
|
|
|
37
40
|
const version = packageVersion(pkgRoot);
|
|
41
|
+
// Did the target hold project content BEFORE we laid anything? Drives the adopt-hint: a non-empty
|
|
42
|
+
// repo has existing code worth priming the recon-wrxn index over now (.git and our own receipt
|
|
43
|
+
// don't count). Captured up front so a re-init's own laid files don't read as "pre-existing".
|
|
44
|
+
const wasNonEmpty = dirHasContent(target);
|
|
38
45
|
// What a PRIOR wrxn install laid here — used to tell a re-init skip (wrxn's own file) apart from a
|
|
39
46
|
// BROWNFIELD collision (a pre-existing PROJECT file that happens to clash with a payload path).
|
|
40
47
|
const priorPaths = priorReceiptPaths(target);
|
|
@@ -55,6 +62,14 @@ function init(opts) {
|
|
|
55
62
|
// A collision = the file existed but was NOT laid by a prior wrxn install (it is the operator's
|
|
56
63
|
// own pre-existing project file). A re-init skip of wrxn's own file is `exists`, not a collision.
|
|
57
64
|
const collision = !priorPaths.has(entry.path);
|
|
65
|
+
// .mcp.json is the one payload file we MERGE rather than preserve on a brownfield collision: the
|
|
66
|
+
// operator's other MCP servers must survive while gaining the recon-wrxn server. A re-init
|
|
67
|
+
// (not a collision) is left as a plain `exists` skip. A malformed operator file is preserved
|
|
68
|
+
// untouched (we never clobber or crash) and reported as an ordinary collision.
|
|
69
|
+
if (entry.path === MCP_PATH && collision && mergeMcpServer(src, dest)) {
|
|
70
|
+
merged.push({ path: entry.path, class: entry.class });
|
|
71
|
+
continue;
|
|
72
|
+
}
|
|
58
73
|
skipped.push({ path: entry.path, class: entry.class, reason: collision ? 'collision' : 'exists', collision });
|
|
59
74
|
continue;
|
|
60
75
|
}
|
|
@@ -67,9 +82,65 @@ function init(opts) {
|
|
|
67
82
|
const collisions = skipped.filter((s) => s.collision);
|
|
68
83
|
const brownfield = collisions.length > 0;
|
|
69
84
|
|
|
70
|
-
|
|
85
|
+
// recon-wrxn writes its index into a fixed `.recon-wrxn/` dir — keep it out of version control.
|
|
86
|
+
ensureGitignoreLine(target, '.recon-wrxn/');
|
|
71
87
|
|
|
72
|
-
|
|
88
|
+
writeReceipt(target, { version, profile, laid, skipped, merged, brownfield });
|
|
89
|
+
|
|
90
|
+
// No synchronous index here (AC-5): `recon-wrxn serve` auto-indexes lazily on first use. On a
|
|
91
|
+
// non-empty repo we only HINT that the operator can prime it now with `recon-wrxn index`.
|
|
92
|
+
const adoptHint = wasNonEmpty
|
|
93
|
+
? 'recon-wrxn indexes lazily on first use — run `recon-wrxn index` to prime it over your existing code now.'
|
|
94
|
+
: null;
|
|
95
|
+
|
|
96
|
+
// init NEVER touches a statusline (that would risk overwriting the operator's). It only surfaces
|
|
97
|
+
// the opt-in path so installs discover the SYNAPSE live-window writer.
|
|
98
|
+
return { profile, laid, skipped, merged, collisions, brownfield, adoptHint, statuslineHint: STATUSLINE_HINT, receipt: path.join(target, RECEIPT) };
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
/** Does the target hold content other than `.git` and wrxn's own receipt? (Missing dir → empty.) */
|
|
102
|
+
function dirHasContent(target) {
|
|
103
|
+
let entries;
|
|
104
|
+
try {
|
|
105
|
+
entries = fs.readdirSync(target);
|
|
106
|
+
} catch {
|
|
107
|
+
return false;
|
|
108
|
+
}
|
|
109
|
+
return entries.some((e) => e !== '.git' && e !== RECEIPT);
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
/** Append `line` to `<target>/.gitignore` (create if absent), exactly once — idempotent. */
|
|
113
|
+
function ensureGitignoreLine(target, line) {
|
|
114
|
+
const giPath = path.join(target, '.gitignore');
|
|
115
|
+
let body = '';
|
|
116
|
+
try {
|
|
117
|
+
body = fs.readFileSync(giPath, 'utf8');
|
|
118
|
+
} catch {
|
|
119
|
+
body = '';
|
|
120
|
+
}
|
|
121
|
+
if (body.split('\n').some((l) => l.trim() === line)) return; // already ignored
|
|
122
|
+
const prefix = body.length && !body.endsWith('\n') ? '\n' : '';
|
|
123
|
+
fs.writeFileSync(giPath, body + prefix + line + '\n');
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
/**
|
|
127
|
+
* Merge the recon-wrxn server key from the payload `.mcp.json` into an operator's existing one.
|
|
128
|
+
* Returns true on a successful merge, false when the operator file is not parseable JSON (in which
|
|
129
|
+
* case the caller preserves it untouched — wrxn never clobbers or crashes on a hand-written config).
|
|
130
|
+
*/
|
|
131
|
+
function mergeMcpServer(src, dest) {
|
|
132
|
+
let operator;
|
|
133
|
+
try {
|
|
134
|
+
operator = JSON.parse(fs.readFileSync(dest, 'utf8'));
|
|
135
|
+
} catch {
|
|
136
|
+
return false; // unparseable operator file → preserve as a plain collision, don't merge
|
|
137
|
+
}
|
|
138
|
+
if (!operator || typeof operator !== 'object') return false;
|
|
139
|
+
const payload = JSON.parse(fs.readFileSync(src, 'utf8'));
|
|
140
|
+
operator.mcpServers = operator.mcpServers || {};
|
|
141
|
+
operator.mcpServers['recon-wrxn'] = payload.mcpServers['recon-wrxn'];
|
|
142
|
+
fs.writeFileSync(dest, JSON.stringify(operator, null, 2) + '\n');
|
|
143
|
+
return true;
|
|
73
144
|
}
|
|
74
145
|
|
|
75
146
|
/** Paths a prior wrxn install recorded in the receipt (empty when there is no prior install). */
|
|
@@ -97,9 +168,9 @@ function writeReceipt(target, data) {
|
|
|
97
168
|
existing.kernelVersion = data.version;
|
|
98
169
|
existing.profile = data.profile;
|
|
99
170
|
existing.brownfield = !!data.brownfield;
|
|
100
|
-
existing.files = [...data.laid, ...data.skipped].map((f) => ({ path: f.path, class: f.class }));
|
|
101
|
-
existing.installs.push({ laidCount: data.laid.length, skippedCount: data.skipped.length });
|
|
171
|
+
existing.files = [...data.laid, ...data.skipped, ...(data.merged || [])].map((f) => ({ path: f.path, class: f.class }));
|
|
172
|
+
existing.installs.push({ laidCount: data.laid.length, skippedCount: data.skipped.length, mergedCount: (data.merged || []).length });
|
|
102
173
|
fs.writeFileSync(receiptPath, JSON.stringify(existing, null, 2) + '\n');
|
|
103
174
|
}
|
|
104
175
|
|
|
105
|
-
module.exports = { init, RECEIPT, packageVersion };
|
|
176
|
+
module.exports = { init, RECEIPT, MCP_PATH, packageVersion, mergeMcpServer };
|
|
@@ -0,0 +1,97 @@
|
|
|
1
|
+
'use strict';
|
|
2
|
+
|
|
3
|
+
const fs = require('fs');
|
|
4
|
+
const path = require('path');
|
|
5
|
+
|
|
6
|
+
// The sidecar block is marker-bounded so injection is idempotent: the START marker's presence in a
|
|
7
|
+
// host statusline means the block is already there. The canonical text lives in the managed payload
|
|
8
|
+
// doc (single source of truth) — snippet() extracts the marker region from it, so the doc and the
|
|
9
|
+
// injected block can never drift.
|
|
10
|
+
const MARKER_START = '# >>> wrxn sidecar >>>';
|
|
11
|
+
const MARKER_END = '# <<< wrxn sidecar <<<';
|
|
12
|
+
|
|
13
|
+
// The one-line adopt-hint `wrxn init` surfaces. init must NEVER modify a statusline; it only points
|
|
14
|
+
// the operator at the opt-in command.
|
|
15
|
+
const STATUSLINE_HINT = 'SYNAPSE live-window: run `wrxn statusline` to enable';
|
|
16
|
+
|
|
17
|
+
// The canonical doc inside the package payload (ships in dev and in the installed package alike).
|
|
18
|
+
const DOC_PATH = path.join(__dirname, '..', 'payload', 'docs', 'statusline-sidecar.sh');
|
|
19
|
+
|
|
20
|
+
/** The marker-bounded sidecar block, extracted from the canonical payload doc. */
|
|
21
|
+
function snippet(docPath) {
|
|
22
|
+
const text = fs.readFileSync(docPath || DOC_PATH, 'utf8');
|
|
23
|
+
const start = text.indexOf(MARKER_START);
|
|
24
|
+
const end = text.indexOf(MARKER_END);
|
|
25
|
+
if (start < 0 || end < 0 || end < start) {
|
|
26
|
+
throw new Error('statusline doc is missing its sidecar markers');
|
|
27
|
+
}
|
|
28
|
+
return text.slice(start, end + MARKER_END.length) + '\n';
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
/**
|
|
32
|
+
* Resolve the statusline script path from a settings `statusLine.command`.
|
|
33
|
+
* Returns a path for a bare `<path>` or a `bash <path>` / `sh <path>` command; null for any other
|
|
34
|
+
* shape (an inline command, a node script, a multi-token bare command) — we only auto-inject into a
|
|
35
|
+
* shell script we can unambiguously identify.
|
|
36
|
+
*/
|
|
37
|
+
function resolveScriptPath(command, home) {
|
|
38
|
+
const parts = command.split(/\s+/).filter(Boolean);
|
|
39
|
+
let candidate = null;
|
|
40
|
+
if ((parts[0] === 'bash' || parts[0] === 'sh') && parts.length === 2) {
|
|
41
|
+
candidate = parts[1];
|
|
42
|
+
} else if (parts.length === 1) {
|
|
43
|
+
candidate = parts[0];
|
|
44
|
+
} else {
|
|
45
|
+
return null;
|
|
46
|
+
}
|
|
47
|
+
if (candidate.startsWith('~/')) candidate = path.join(home, candidate.slice(2));
|
|
48
|
+
return candidate;
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
/**
|
|
52
|
+
* Inspect `<home>/.claude/settings.json` for a configured statusline.
|
|
53
|
+
* @returns {{ configured: boolean, command: string|null, scriptPath: string|null }}
|
|
54
|
+
*/
|
|
55
|
+
function detectStatusLine(home) {
|
|
56
|
+
let settings;
|
|
57
|
+
try {
|
|
58
|
+
settings = JSON.parse(fs.readFileSync(path.join(home, '.claude', 'settings.json'), 'utf8'));
|
|
59
|
+
} catch {
|
|
60
|
+
return { configured: false, command: null, scriptPath: null };
|
|
61
|
+
}
|
|
62
|
+
const sl = settings && settings.statusLine;
|
|
63
|
+
const command = sl && typeof sl.command === 'string' && sl.command.trim() ? sl.command.trim() : null;
|
|
64
|
+
if (!command) return { configured: false, command: null, scriptPath: null };
|
|
65
|
+
return { configured: true, command, scriptPath: resolveScriptPath(command, home) };
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
/**
|
|
69
|
+
* Append the sidecar block to an existing statusline script — APPEND-ONLY and idempotent.
|
|
70
|
+
* No-op if the marker is already present; never rewrites or reorders existing content. The script
|
|
71
|
+
* must already exist (we never conjure a statusline — that would risk shadowing the operator's own).
|
|
72
|
+
* @returns {{ injected: boolean, reason?: string, path: string }}
|
|
73
|
+
*/
|
|
74
|
+
function injectSnippet(scriptPath, docPath) {
|
|
75
|
+
if (!scriptPath) throw new Error('injectSnippet requires a script path');
|
|
76
|
+
if (!fs.existsSync(scriptPath)) {
|
|
77
|
+
throw new Error(`statusline script not found: ${scriptPath} — create it (or configure a statusline) first`);
|
|
78
|
+
}
|
|
79
|
+
const current = fs.readFileSync(scriptPath, 'utf8');
|
|
80
|
+
if (current.includes(MARKER_START)) {
|
|
81
|
+
return { injected: false, reason: 'already-present', path: scriptPath };
|
|
82
|
+
}
|
|
83
|
+
const sep = current.length && !current.endsWith('\n') ? '\n' : '';
|
|
84
|
+
fs.appendFileSync(scriptPath, `${sep}\n${snippet(docPath)}`);
|
|
85
|
+
return { injected: true, path: scriptPath };
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
module.exports = {
|
|
89
|
+
snippet,
|
|
90
|
+
detectStatusLine,
|
|
91
|
+
injectSnippet,
|
|
92
|
+
resolveScriptPath,
|
|
93
|
+
MARKER_START,
|
|
94
|
+
MARKER_END,
|
|
95
|
+
STATUSLINE_HINT,
|
|
96
|
+
DOC_PATH,
|
|
97
|
+
};
|
package/lib/update.cjs
CHANGED
|
@@ -4,7 +4,7 @@ const fs = require('fs');
|
|
|
4
4
|
const path = require('path');
|
|
5
5
|
|
|
6
6
|
const { loadManifest, inProfile } = require('./manifest.cjs');
|
|
7
|
-
const { RECEIPT, packageVersion } = require('./install.cjs');
|
|
7
|
+
const { RECEIPT, MCP_PATH, packageVersion, mergeMcpServer } = require('./install.cjs');
|
|
8
8
|
const { compareVersions } = require('./semver.cjs');
|
|
9
9
|
const { runMigrations } = require('./migrate.cjs');
|
|
10
10
|
|
|
@@ -49,8 +49,20 @@ function update(opts) {
|
|
|
49
49
|
}
|
|
50
50
|
|
|
51
51
|
if (entry.class === 'managed') {
|
|
52
|
-
|
|
53
|
-
|
|
52
|
+
// .mcp.json is managed but operator-shared: an update MUST NOT clobber the operator's other MCP
|
|
53
|
+
// servers (finding N2). When the dest already exists, MERGE just the recon-wrxn key in (same
|
|
54
|
+
// contract as init). A malformed operator file can't be merged → preserve it untouched (never
|
|
55
|
+
// crash, never clobber a hand-written config); an absent dest falls through to a plain lay.
|
|
56
|
+
if (entry.path === MCP_PATH && fs.existsSync(dest)) {
|
|
57
|
+
if (mergeMcpServer(src, dest)) {
|
|
58
|
+
updated.push({ path: entry.path, class: entry.class, merged: true });
|
|
59
|
+
} else {
|
|
60
|
+
preserved.push({ path: entry.path, class: entry.class, reason: 'unparseable-preserved' });
|
|
61
|
+
}
|
|
62
|
+
} else {
|
|
63
|
+
lay(src, dest);
|
|
64
|
+
updated.push({ path: entry.path, class: entry.class });
|
|
65
|
+
}
|
|
54
66
|
} else if (!fs.existsSync(dest)) {
|
|
55
67
|
// seeded/state that did not exist in the prior version → lay it once now
|
|
56
68
|
lay(src, dest);
|
package/manifest.json
CHANGED
|
@@ -359,7 +359,12 @@
|
|
|
359
359
|
"profile": "workspace"
|
|
360
360
|
},
|
|
361
361
|
{
|
|
362
|
-
"path": ".
|
|
362
|
+
"path": ".mcp.json",
|
|
363
|
+
"class": "managed",
|
|
364
|
+
"profile": "project"
|
|
365
|
+
},
|
|
366
|
+
{
|
|
367
|
+
"path": ".recon-wrxn.json",
|
|
363
368
|
"class": "seeded",
|
|
364
369
|
"profile": "project"
|
|
365
370
|
},
|
|
@@ -438,6 +443,11 @@
|
|
|
438
443
|
"class": "seeded",
|
|
439
444
|
"profile": "project"
|
|
440
445
|
},
|
|
446
|
+
{
|
|
447
|
+
"path": "docs/statusline-sidecar.sh",
|
|
448
|
+
"class": "managed",
|
|
449
|
+
"profile": "project"
|
|
450
|
+
},
|
|
441
451
|
{
|
|
442
452
|
"path": "docs/workspace/operator-layer.md",
|
|
443
453
|
"class": "seeded",
|
|
@@ -0,0 +1,79 @@
|
|
|
1
|
+
'use strict';
|
|
2
|
+
|
|
3
|
+
const fs = require('fs');
|
|
4
|
+
const path = require('path');
|
|
5
|
+
|
|
6
|
+
/**
|
|
7
|
+
* R4 — rebrand an existing install from the legacy `recon` to `recon-wrxn`, in place, preserving the
|
|
8
|
+
* costly index (recon-wrxn-04). The ENTIRE migration is gated on the legacy `.recon/` index dir: a
|
|
9
|
+
* fresh (or already-migrated) install has no `.recon/`, so this is a complete no-op — which also makes
|
|
10
|
+
* it idempotent (a second run sees `.recon/` already renamed away). Runs via `wrxn update` once the
|
|
11
|
+
* install reaches 0.2.0; the file-class update has already laid the rebranded payload before this runs.
|
|
12
|
+
*/
|
|
13
|
+
module.exports = {
|
|
14
|
+
id: '001',
|
|
15
|
+
version: '0.2.0',
|
|
16
|
+
up(ctx) {
|
|
17
|
+
const target = ctx.target;
|
|
18
|
+
const legacyDir = path.join(target, '.recon');
|
|
19
|
+
if (!fs.existsSync(legacyDir)) return; // gate: no legacy index → nothing to migrate
|
|
20
|
+
|
|
21
|
+
// 1. Rename the index dir, preserving the index (storage format unchanged per R1). If the new dir
|
|
22
|
+
// already exists (operator ran recon-wrxn before updating), it is authoritative — discard the
|
|
23
|
+
// legacy `.recon/` rather than leave it behind: it is a disposable cache that would otherwise
|
|
24
|
+
// linger no-longer-gitignored, and this run-once migration would never reclaim it.
|
|
25
|
+
const newDir = path.join(target, '.recon-wrxn');
|
|
26
|
+
if (!fs.existsSync(newDir)) {
|
|
27
|
+
fs.renameSync(legacyDir, newDir);
|
|
28
|
+
} else {
|
|
29
|
+
fs.rmSync(legacyDir, { recursive: true, force: true });
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
// 2. Rename the config (content unchanged — the operator's recon config carries over). The seeded
|
|
33
|
+
// `.recon-wrxn.json` template laid by `wrxn update` is overwritten so the operator's content wins.
|
|
34
|
+
const legacyCfg = path.join(target, '.recon.json');
|
|
35
|
+
if (fs.existsSync(legacyCfg)) {
|
|
36
|
+
fs.renameSync(legacyCfg, path.join(target, '.recon-wrxn.json'));
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
// 3. Drop the stale `recon` server key from .mcp.json (the `recon-wrxn` server was already merged in
|
|
40
|
+
// by `wrxn update`). Only touch a file that exists and parses; never crash on a malformed config,
|
|
41
|
+
// never remove any other server key.
|
|
42
|
+
const mcpPath = path.join(target, '.mcp.json');
|
|
43
|
+
if (fs.existsSync(mcpPath)) {
|
|
44
|
+
let mcp = null;
|
|
45
|
+
try {
|
|
46
|
+
mcp = JSON.parse(fs.readFileSync(mcpPath, 'utf8'));
|
|
47
|
+
} catch {
|
|
48
|
+
mcp = null; // malformed operator config → leave it alone
|
|
49
|
+
}
|
|
50
|
+
if (mcp && mcp.mcpServers && Object.prototype.hasOwnProperty.call(mcp.mcpServers, 'recon')) {
|
|
51
|
+
delete mcp.mcpServers.recon;
|
|
52
|
+
fs.writeFileSync(mcpPath, JSON.stringify(mcp, null, 2) + '\n');
|
|
53
|
+
}
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
// 4. .gitignore: replace the stale `.recon/` line(s) with a single `.recon-wrxn/`; drop any extra
|
|
57
|
+
// stale lines and never emit a duplicate (whether `.recon-wrxn/` pre-existed or `.recon/` repeats).
|
|
58
|
+
const giPath = path.join(target, '.gitignore');
|
|
59
|
+
if (fs.existsSync(giPath)) {
|
|
60
|
+
const lines = fs.readFileSync(giPath, 'utf8').split('\n');
|
|
61
|
+
let haveNew = lines.some((l) => l.trim() === '.recon-wrxn/');
|
|
62
|
+
const out = [];
|
|
63
|
+
for (const l of lines) {
|
|
64
|
+
if (l.trim() === '.recon/') {
|
|
65
|
+
if (!haveNew) { out.push('.recon-wrxn/'); haveNew = true; }
|
|
66
|
+
} else {
|
|
67
|
+
out.push(l);
|
|
68
|
+
}
|
|
69
|
+
}
|
|
70
|
+
fs.writeFileSync(giPath, out.join('\n'));
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
// 5. Remove the vendored legacy recon (superseded by the npm dependency; only WRXN-OS has it).
|
|
74
|
+
const vendor = path.join(target, 'vendor', 'recon-aiox');
|
|
75
|
+
if (fs.existsSync(vendor)) {
|
|
76
|
+
fs.rmSync(vendor, { recursive: true, force: true });
|
|
77
|
+
}
|
|
78
|
+
},
|
|
79
|
+
};
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@gcunharodrigues/wrxn",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.2.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"
|
|
@@ -15,6 +15,9 @@
|
|
|
15
15
|
"scripts": {
|
|
16
16
|
"test": "node --test"
|
|
17
17
|
},
|
|
18
|
+
"dependencies": {
|
|
19
|
+
"recon-wrxn": "6.0.0-wrxn.1"
|
|
20
|
+
},
|
|
18
21
|
"engines": {
|
|
19
22
|
"node": ">=20"
|
|
20
23
|
},
|
|
@@ -1,10 +1,10 @@
|
|
|
1
1
|
#!/usr/bin/env node
|
|
2
2
|
'use strict';
|
|
3
3
|
|
|
4
|
-
// WRXN code-intel-push hook — first-touch code-intel / recon-freshness nudge (wrxn-kernel-11).
|
|
4
|
+
// WRXN code-intel-push hook — first-touch code-intel / recon-wrxn-freshness nudge (wrxn-kernel-11).
|
|
5
5
|
// PostToolUse (Edit|Write). On the FIRST touch of a code file this session it injects a <code-intel>
|
|
6
|
-
// nudge: where a recon
|
|
7
|
-
//
|
|
6
|
+
// nudge: where a recon-wrxn index exists it points at the mcp__recon-wrxn__* tools; absent an index
|
|
7
|
+
// it nudges to prime one. First-touch-GATED per session+file (a touched-list under
|
|
8
8
|
// .wrxn/history/<sid>.touched) so a repeat edit of the same file is silent — no per-edit spam.
|
|
9
9
|
//
|
|
10
10
|
// Self-contained: ships into installs, MUST NOT import the kernel lib (node stdlib only).
|
|
@@ -60,13 +60,14 @@ function isFirstTouch(root, sid, relPath) {
|
|
|
60
60
|
}
|
|
61
61
|
}
|
|
62
62
|
|
|
63
|
-
// A short freshness note
|
|
63
|
+
// A short freshness note pointing at the recon-wrxn MCP server (mcp__recon-wrxn__* tools), whose
|
|
64
|
+
// index lives in the fixed `.recon-wrxn/` dir and auto-builds lazily on first query.
|
|
64
65
|
function freshnessNote(root) {
|
|
65
|
-
const
|
|
66
|
-
if (!fs.existsSync(
|
|
67
|
-
return 'No recon
|
|
66
|
+
const indexDir = path.join(root, '.recon-wrxn');
|
|
67
|
+
if (!fs.existsSync(indexDir)) {
|
|
68
|
+
return 'No recon-wrxn index (.recon-wrxn/ absent) — query via the mcp__recon-wrxn__* tools to auto-index, or run `recon-wrxn index` to prime it now.';
|
|
68
69
|
}
|
|
69
|
-
return 'recon
|
|
70
|
+
return 'recon-wrxn index present (.recon-wrxn/) — query code connections via the mcp__recon-wrxn__* tools; it re-indexes live as you edit.';
|
|
70
71
|
}
|
|
71
72
|
|
|
72
73
|
function main() {
|
|
@@ -179,26 +179,49 @@ function readResidentTokens(transcriptPath) {
|
|
|
179
179
|
}
|
|
180
180
|
}
|
|
181
181
|
|
|
182
|
-
//
|
|
183
|
-
//
|
|
184
|
-
//
|
|
185
|
-
//
|
|
186
|
-
//
|
|
187
|
-
|
|
188
|
-
|
|
189
|
-
|
|
190
|
-
|
|
182
|
+
// The LIVE window for a session, published by the statusline. UserPromptSubmit hooks receive no
|
|
183
|
+
// model/context-window data, but the statusline payload carries context_window.context_window_size
|
|
184
|
+
// (resolved by Claude Code, refreshed every render — so it tracks a mid-session /model switch). The
|
|
185
|
+
// statusline writes it to a session-scoped /tmp sidecar; we read it back here by session_id.
|
|
186
|
+
// See statusline.sh and memory `handoff-window-defaults-200k`. Returns a positive number or null.
|
|
187
|
+
function readStatuslineWindow(sessionId) {
|
|
188
|
+
if (!sessionId) return null;
|
|
189
|
+
try {
|
|
190
|
+
const p = path.join(os.tmpdir(), `claude-statusline-ctx-${sessionId}.json`);
|
|
191
|
+
const o = JSON.parse(fs.readFileSync(p, 'utf8'));
|
|
192
|
+
const w = Number(o && o.context_window_size);
|
|
193
|
+
return Number.isFinite(w) && w > 0 ? w : null;
|
|
194
|
+
} catch {
|
|
195
|
+
return null;
|
|
196
|
+
}
|
|
197
|
+
}
|
|
198
|
+
|
|
199
|
+
// Model context window, resolved by an explicit precedence (issue 29 + dynamic statusline bridge).
|
|
200
|
+
// On [1m] sessions lastModelUsage is often EMPTY and the transcript model id lacks the [1m] tag, and
|
|
201
|
+
// the hook payload carries no model — so we lean on the statusline (which DOES know the live window):
|
|
202
|
+
// 1. env WRXN_CONTEXT_WINDOW — a positive finite number wins unconditionally (manual force).
|
|
203
|
+
// 2. statusline sidecar — the live per-session window; tracks mid-session model switches.
|
|
204
|
+
// 3. manifest CONTEXT_WINDOW — a positive finite value (when manifestText is supplied).
|
|
205
|
+
// 4. ~/.claude.json lastModelUsage KEYS — a [1m] tag ⇒ 1,000,000 (auto-detect, when present).
|
|
206
|
+
// 5. self-correcting net — resident already past the 200k default ⇒ window is necessarily larger.
|
|
207
|
+
// 6. fallback 200,000.
|
|
208
|
+
// homeDir/manifestText/sessionId/resident overrides keep it testable.
|
|
209
|
+
function modelWindow(cwd, homeDir, manifestText, sessionId, resident) {
|
|
191
210
|
// 1. explicit env override.
|
|
192
211
|
const envWin = Number(process.env.WRXN_CONTEXT_WINDOW);
|
|
193
212
|
if (Number.isFinite(envWin) && envWin > 0) return envWin;
|
|
194
213
|
|
|
195
|
-
// 2.
|
|
214
|
+
// 2. statusline sidecar — the live, authoritative window (dynamic across /model switches).
|
|
215
|
+
const scWin = readStatuslineWindow(sessionId);
|
|
216
|
+
if (scWin) return scWin;
|
|
217
|
+
|
|
218
|
+
// 3. manifest CONTEXT_WINDOW (the engine already reads scalar manifest values).
|
|
196
219
|
if (manifestText != null) {
|
|
197
220
|
const manWin = Number(manifestValue(manifestText, 'CONTEXT_WINDOW'));
|
|
198
221
|
if (Number.isFinite(manWin) && manWin > 0) return manWin;
|
|
199
222
|
}
|
|
200
223
|
|
|
201
|
-
//
|
|
224
|
+
// 4. lastModelUsage [1m] auto-detect.
|
|
202
225
|
try {
|
|
203
226
|
const home = homeDir || process.env.HOME || os.homedir();
|
|
204
227
|
const cfg = JSON.parse(fs.readFileSync(path.join(home, '.claude.json'), 'utf8'));
|
|
@@ -206,10 +229,13 @@ function modelWindow(cwd, homeDir, manifestText) {
|
|
|
206
229
|
const keys = Object.keys(proj.lastModelUsage || {});
|
|
207
230
|
if (keys.some((k) => /\[1m\]/i.test(k))) return 1000000;
|
|
208
231
|
} catch {
|
|
209
|
-
// fall through
|
|
232
|
+
// fall through.
|
|
210
233
|
}
|
|
211
234
|
|
|
212
|
-
//
|
|
235
|
+
// 5. self-correcting net: resident past the 200k default ⇒ a larger (1M) window.
|
|
236
|
+
if (Number.isFinite(resident) && resident > 200000) return 1000000;
|
|
237
|
+
|
|
238
|
+
// 6. fallback.
|
|
213
239
|
return 200000;
|
|
214
240
|
}
|
|
215
241
|
|
|
@@ -295,7 +321,7 @@ function compose(root, event) {
|
|
|
295
321
|
if (ev.transcript_path) {
|
|
296
322
|
const resident = readResidentTokens(ev.transcript_path);
|
|
297
323
|
if (resident != null) {
|
|
298
|
-
const window = modelWindow(ev.cwd || root, process.env.HOME || os.homedir(), manifestText);
|
|
324
|
+
const window = modelWindow(ev.cwd || root, process.env.HOME || os.homedir(), manifestText, ev.session_id, resident);
|
|
299
325
|
const consumed = resident / window;
|
|
300
326
|
const pct = resolveHandoffPct(manifestText);
|
|
301
327
|
if (consumed >= pct) out.push(handoffDirective(consumed, pct));
|
|
@@ -345,6 +371,7 @@ module.exports = {
|
|
|
345
371
|
compose,
|
|
346
372
|
findInstallRoot,
|
|
347
373
|
readResidentTokens,
|
|
374
|
+
readStatuslineWindow,
|
|
348
375
|
modelWindow,
|
|
349
376
|
resolveHandoffPct,
|
|
350
377
|
handoffDirective,
|
|
@@ -0,0 +1,30 @@
|
|
|
1
|
+
#!/usr/bin/env bash
|
|
2
|
+
# WRXN SYNAPSE statusline sidecar — the live context-window writer.
|
|
3
|
+
#
|
|
4
|
+
# Why: UserPromptSubmit hooks (where SYNAPSE runs) receive no model/context-window data, so the
|
|
5
|
+
# handoff math cannot tell a 200k session from a 1M one. The statusline IS handed the live window
|
|
6
|
+
# (context_window.context_window_size) on stdin every render — so we publish it to a session-scoped
|
|
7
|
+
# temp file that the synapse-engine hook reads back (readStatuslineWindow → .context_window_size).
|
|
8
|
+
# The temp dir MUST match the reader's os.tmpdir() — both honor $TMPDIR, falling back to /tmp.
|
|
9
|
+
#
|
|
10
|
+
# How to enable: paste the marker-bounded block below into your Claude Code statusline script, OR run
|
|
11
|
+
# wrxn statusline --inject [--path <your-statusline-script>]
|
|
12
|
+
# which appends it idempotently. It NEVER overwrites your statusline — append-only, marker-guarded.
|
|
13
|
+
#
|
|
14
|
+
# Assumptions the block makes about the host statusline:
|
|
15
|
+
# - $input the raw statusline stdin JSON (most statuslines do `input=$(cat)` at the top).
|
|
16
|
+
# - $session_id the session id (e.g. `session_id=$(echo "$input" | jq -r '.session_id')`).
|
|
17
|
+
# If your statusline lacks these, set them above the block. The block fails safe regardless: the write
|
|
18
|
+
# is guarded by `2>/dev/null || true`, so a missing tool or var can never break your statusline render.
|
|
19
|
+
|
|
20
|
+
# >>> wrxn sidecar >>>
|
|
21
|
+
# UserPromptSubmit hooks receive NO model/context-window data, so SYNAPSE's handoff math can't tell
|
|
22
|
+
# a 200k session from a 1M one. Publish the live window to a session-scoped file the hook reads.
|
|
23
|
+
# Refreshed every render → tracks a mid-session /model switch.
|
|
24
|
+
if [[ -n "$session_id" ]]; then
|
|
25
|
+
cw_size=$(echo "$input" | jq -r '.context_window.context_window_size // empty')
|
|
26
|
+
[[ -z "$cw_size" || "$cw_size" == "null" ]] && { [[ "$(echo "$input" | jq -r '.model.id // ""')" == *"[1m]"* ]] && cw_size=1000000 || cw_size=200000; }
|
|
27
|
+
printf '{"context_window_size":%s,"model_id":"%s"}\n' "$cw_size" "$(echo "$input" | jq -r '.model.id // ""')" \
|
|
28
|
+
> "${TMPDIR:-/tmp}/claude-statusline-ctx-${session_id}.json" 2>/dev/null || true
|
|
29
|
+
fi
|
|
30
|
+
# <<< wrxn sidecar <<<
|
package/payload/.recon.json
DELETED