0xray 4.0.19 → 4.0.20

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.
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "0xray",
3
- "version": "4.0.19",
3
+ "version": "4.0.20",
4
4
  "description": "0xRay suit for Grok, Hermes, OpenClaw, OpenCode: mill+inspect, Codex, 7 MCP servers. Costume 45/42 is opt-in foundry, not the default wear.",
5
5
  "author": {
6
6
  "name": "0xRay AI",
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "$schema": "./features.schema.json",
3
- "version": "4.0.19",
3
+ "version": "4.0.20",
4
4
  "description": "0xRay Framework - Unified Feature Configuration",
5
5
  "suit_temperament": {
6
6
  "profile": "auto",
package/dist/CHANGELOG.md CHANGED
@@ -7,6 +7,8 @@ The format is based on [Conventional Commits](https://www.conventionalcommits.or
7
7
  ## [Unreleased]
8
8
 
9
9
  ### ✨ Features
10
+ - **Cleanup is memory** — `applyStationHeat` grows the project copy when Repertoire is on: kernel sessions, sibling `repo-*` names, and NOTES/CLEANSE diary heat. Groover field stays off. Opt-out still skips seed and grow. Station stays the pickup card. Not a new MCP/skill.
11
+ - **Live context memory** — capture then grow. Session `patterns` come from real git (commit slugs + structural names), not a leftover catalog. Cursor skip / afterFileEdit, Grok heat, Hermes pre-process, and OpenClaw pre-tool share one dest lock so parallel seats write one project copy. Not a new MCP/skill.
10
12
  - **Vendored Repertoire 0.2.5** — `vendor/@0xray/repertoire` is the published 0.2.5 package (overlays included). Wear replaces an older leftover in `node_modules`. A newer user-installed copy is left alone. Boot reads worn `node_modules`, then vendor, then a sibling checkout last. Repertoire stays optional (`enabled: false` + `provider: repertoire`); Station is the memory when it is off.
11
13
  - **Cursor fifth wear** — `install-bridges.cjs` fastens project `.cursor/hooks.json` (preToolUse / preCompact / afterFileEdit) so Cursor Cloud heats Station and gates tools. Leaves an existing file alone. Not a fifth chat TUI. No new MCP/skill surface. Pack gate (`REQUIRED_PACK_PATHS`) now requires `dist/integrations/cursor/hooks/{hooks.json,pre-tool-use.js,pre-compact.js,after-file-edit.js,cursor-hook-utils.js,cursor-usage-receipt.js}` so a published tarball cannot ship a dead consumer command. `preToolUse` / `afterFileEdit` rewrite the card when git HEAD or Repertoire count diverges — Ticket / Durable / Seed stay. OP-PROC stays in the hook, not on Station.
12
14
 
@@ -35,6 +37,17 @@ The format is based on [Conventional Commits](https://www.conventionalcommits.or
35
37
 
36
38
  ---
37
39
 
40
+ ## [4.0.20] - 2026-09-22
41
+
42
+ ### 🔄 Changes
43
+
44
+ ### 🐛 Bug Fixes
45
+ - fix: rewrite session-boot when HEAD moves — first-wins skip froze metal (same session still heats dest)
46
+ - fix: grow dest from live git on every floor (a7685eb09)
47
+ - fix: heat grows the project copy — cleanup is memory (be10c82eb)
48
+
49
+ ---
50
+
38
51
  ## [4.0.19] - 2026-09-22
39
52
 
40
53
  ### ✨ Features
@@ -6,6 +6,7 @@
6
6
 
7
7
  import {
8
8
  appendHookActivity,
9
+ cursorHeatRoots,
9
10
  cursorSessionId,
10
11
  cursorWorkspaceRoot,
11
12
  ensureCursorSessionBoot,
@@ -17,9 +18,11 @@ async function main() {
17
18
  try {
18
19
  const event = await readStdinJson();
19
20
  const eventRoot = cursorWorkspaceRoot(event);
20
- const bootPath = ensureCursorSessionBoot(eventRoot, '0xray/cursor-after-file-edit-boot', {
21
- sessionId: cursorSessionId(event),
22
- });
21
+ const sessionId = cursorSessionId(event);
22
+ let bootPath = null;
23
+ for (const root of cursorHeatRoots(event)) {
24
+ bootPath = ensureCursorSessionBoot(root, '0xray/cursor-after-file-edit-boot', { sessionId });
25
+ }
23
26
  appendHookActivity(eventRoot, 'cursor-after-file-edit', 'session-boot', 'info', {
24
27
  bootPath,
25
28
  file: event.file_path || event.path || null,
@@ -23,13 +23,12 @@ import {
23
23
  } from '../../hooks/delegation-gate-runtime.mjs';
24
24
  import { appendHookActivity } from '../../grok/hooks/grok-hook-activity.js';
25
25
  import {
26
- buildRepertoireResume,
26
+ heatLiveMemory,
27
27
  maybeCaptureSessionOnHeadMove,
28
- readGitBrief,
29
- stationDurableHoldsNpm,
28
+ stationBootNeedsRefresh,
30
29
  } from '../../hooks/station-hook-runtime.mjs';
31
30
 
32
- export { maybeCaptureSessionOnHeadMove };
31
+ export { heatLiveMemory, maybeCaptureSessionOnHeadMove };
33
32
 
34
33
  export const CURSOR_HOST = 'cursor';
35
34
  export const EVENT_CLASS_HOST = 'cursor-host-precompact';
@@ -77,6 +76,40 @@ export function cursorWorkspaceRoot(event = {}) {
77
76
  );
78
77
  }
79
78
 
79
+ function addHeatRoot(out, seen, value) {
80
+ if (!value) return;
81
+ const resolved = path.resolve(String(value));
82
+ if (seen.has(resolved)) return;
83
+ seen.add(resolved);
84
+ out.push(resolved);
85
+ }
86
+
87
+ /** Every live context that already has a Station card — cwd plus mill when they differ. */
88
+ export function cursorHeatRoots(event = {}) {
89
+ const seen = new Set();
90
+ const out = [];
91
+ addHeatRoot(out, seen, event.cwd);
92
+ addHeatRoot(out, seen, event.workspaceRoot);
93
+ const listed = event.workspace_roots || event.workspaceRoots;
94
+ if (Array.isArray(listed)) {
95
+ for (const row of listed) {
96
+ if (typeof row === 'string') addHeatRoot(out, seen, row);
97
+ else if (row && typeof row === 'object' && row.path) addHeatRoot(out, seen, row.path);
98
+ }
99
+ }
100
+ addHeatRoot(out, seen, process.env.CURSOR_PROJECT_DIR);
101
+ const mill = process.env.XRAY_AI_PATH;
102
+ if (mill) {
103
+ const resolved = path.resolve(String(mill));
104
+ const millHasCard =
105
+ fs.existsSync(path.join(resolved, '.xray', 'state', 'STATION.md')) ||
106
+ fs.existsSync(path.join(resolved, '.xray', 'features.json'));
107
+ if (millHasCard) addHeatRoot(out, seen, resolved);
108
+ }
109
+ if (out.length === 0) addHeatRoot(out, seen, cursorWorkspaceRoot(event));
110
+ return out;
111
+ }
112
+
80
113
  export function cursorSessionId(event = {}) {
81
114
  return (
82
115
  event.conversation_id ||
@@ -141,18 +174,7 @@ export function classifyPreCompactEvent(event = {}, argv = process.argv) {
141
174
  }
142
175
 
143
176
  export function cursorBootNeedsRefresh(existing, root) {
144
- if (!existing || typeof existing !== 'object') return true;
145
- if (existing.host !== CURSOR_HOST) return true;
146
- if (!existing.suit_profile) return true;
147
- if (existing.workspaceRoot && existing.workspaceRoot !== root) return true;
148
- if (!existing.stationLine) return true;
149
- const liveGit = readGitBrief(root);
150
- const bootHead = existing.git && existing.git.head ? String(existing.git.head) : '';
151
- if (liveGit && liveGit.head && bootHead !== liveGit.head) return true;
152
- const liveResume = buildRepertoireResume(root);
153
- if (liveResume && existing.repertoireResume && liveResume !== existing.repertoireResume) return true;
154
- if (stationDurableHoldsNpm(root)) return true;
155
- return false;
177
+ return stationBootNeedsRefresh(existing, root, CURSOR_HOST);
156
178
  }
157
179
 
158
180
  export function ensureCursorSessionBoot(root, source = '0xray/cursor-pre-tool-use-boot', extra = {}) {
@@ -161,7 +183,7 @@ export function ensureCursorSessionBoot(root, source = '0xray/cursor-pre-tool-us
161
183
  try {
162
184
  const existing = JSON.parse(fs.readFileSync(bootPath, 'utf8'));
163
185
  if (!cursorBootNeedsRefresh(existing, root)) {
164
- maybeCaptureSessionOnHeadMove(root);
186
+ heatLiveMemory(root);
165
187
  return bootPath;
166
188
  }
167
189
  } catch {
@@ -172,9 +194,7 @@ export function ensureCursorSessionBoot(root, source = '0xray/cursor-pre-tool-us
172
194
  host: CURSOR_HOST,
173
195
  ...extra,
174
196
  });
175
- const written = writeSessionBoot(root, payload) || bootPath;
176
- maybeCaptureSessionOnHeadMove(root);
177
- return written;
197
+ return writeSessionBoot(root, payload) || bootPath;
178
198
  }
179
199
 
180
200
  export function writeCursorPrecompactReceipt(root, fields) {
@@ -9,6 +9,7 @@ import {
9
9
  appendHookActivity,
10
10
  buildSessionBootPayload,
11
11
  classifyPreCompactEvent,
12
+ cursorHeatRoots,
12
13
  cursorSessionId,
13
14
  cursorWorkspaceRoot,
14
15
  readStdinJson,
@@ -40,33 +41,40 @@ async function main() {
40
41
  const eventRoot = cursorWorkspaceRoot(event);
41
42
  const sessionId = cursorSessionId(event);
42
43
  const intent = extractIntent(event);
43
- const payload = buildSessionBootPayload(eventRoot, '0xray/cursor-compact', {
44
- host: 'cursor',
45
- hookEvent: 'pre_compact',
46
- event_class: eventClass,
47
- sessionId,
48
- ...(intent ? { intent } : {}),
49
- });
50
- const bootPath = writeSessionBoot(eventRoot, payload);
51
- const receiptPath = writeCursorPrecompactReceipt(eventRoot, {
52
- event_class: eventClass,
53
- hookEvent: 'pre_compact',
54
- trigger: event.trigger || 'synthetic',
55
- sessionId,
56
- bootPath,
57
- timestamp: new Date().toISOString(),
58
- });
59
- const usagePath = writeCursorUsageReceipt(eventRoot, {
60
- eventClass,
61
- sessionId,
62
- usage: hostUsageFromPreCompactEvent(event),
63
- });
44
+ let bootPath = null;
45
+ let receiptPath = null;
46
+ let usagePath = null;
47
+ let stationLine = null;
48
+ for (const root of cursorHeatRoots(event)) {
49
+ const payload = buildSessionBootPayload(root, '0xray/cursor-compact', {
50
+ host: 'cursor',
51
+ hookEvent: 'pre_compact',
52
+ event_class: eventClass,
53
+ sessionId,
54
+ ...(intent ? { intent } : {}),
55
+ });
56
+ bootPath = writeSessionBoot(root, payload);
57
+ stationLine = payload.stationLine;
58
+ receiptPath = writeCursorPrecompactReceipt(root, {
59
+ event_class: eventClass,
60
+ hookEvent: 'pre_compact',
61
+ trigger: event.trigger || 'synthetic',
62
+ sessionId,
63
+ bootPath,
64
+ timestamp: new Date().toISOString(),
65
+ });
66
+ usagePath = writeCursorUsageReceipt(root, {
67
+ eventClass,
68
+ sessionId,
69
+ usage: hostUsageFromPreCompactEvent(event),
70
+ });
71
+ }
64
72
  appendHookActivity(eventRoot, 'cursor-pre-compact', 'station-written', 'success', {
65
73
  bootPath,
66
74
  receiptPath,
67
75
  usagePath,
68
76
  event_class: eventClass,
69
- stationLine: payload.stationLine,
77
+ stationLine,
70
78
  });
71
79
  console.log(
72
80
  JSON.stringify({
@@ -9,6 +9,7 @@ import {
9
9
  appendHookActivity,
10
10
  checkCodexPatterns,
11
11
  checkFullTestSuite,
12
+ cursorHeatRoots,
12
13
  cursorSessionId,
13
14
  cursorToolContext,
14
15
  cursorWorkspaceRoot,
@@ -47,16 +48,16 @@ async function main() {
47
48
  try {
48
49
  const event = await readStdinJson();
49
50
  const eventRoot = cursorWorkspaceRoot(event);
50
- ensureCursorSessionBoot(eventRoot, '0xray/cursor-pre-tool-use-boot', {
51
- sessionId: cursorSessionId(event),
52
- });
51
+ const sessionId = cursorSessionId(event);
52
+ for (const root of cursorHeatRoots(event)) {
53
+ ensureCursorSessionBoot(root, '0xray/cursor-pre-tool-use-boot', { sessionId });
54
+ }
53
55
 
54
56
  const features = loadFeatures(eventRoot);
55
57
  const gateFeatures = loadDelegationGateFeatures(eventRoot, 'cursor');
56
58
  const ctx = cursorToolContext(event);
57
59
  toolName = ctx.toolName;
58
60
  const { content, cmd, toolInput } = ctx;
59
- const sessionId = cursorSessionId(event);
60
61
 
61
62
  const gateBlock = evaluatePreToolGate(toolName, toolInput, {
62
63
  projectRoot: eventRoot,
@@ -306,10 +306,12 @@ export function buildSessionBootPayload(root, source = '0xray/grok-session-start
306
306
  ...(conferPending ? { conferPending: true, conferTrigger: 'analyze-complexity at synthesis checkpoint' } : {}),
307
307
  ...(userAsideBoot ?? {}),
308
308
  sessionId,
309
- timestamp: new Date().toISOString(),
310
309
  source,
311
310
  ...extra,
312
311
  ...heat,
312
+ hook: source,
313
+ source,
314
+ timestamp: new Date().toISOString(),
313
315
  };
314
316
  }
315
317
 
@@ -468,6 +468,20 @@ async function handlePreProcess(input, projectRoot, logDir) {
468
468
  const { tool, args } = input;
469
469
  const startTime = Date.now();
470
470
 
471
+ try {
472
+ const gate = await import('../hooks/delegation-gate-runtime.mjs');
473
+ if (typeof gate.maybeHeatHostStation === 'function') {
474
+ gate.maybeHeatHostStation(projectRoot, 'hermes', {
475
+ source: '0xray/hermes-pre-process',
476
+ sessionId: input.sessionId || 'hermes',
477
+ hookEvent: 'pre_tool_use',
478
+ ...(input.intent ? { intent: input.intent } : {}),
479
+ });
480
+ }
481
+ } catch {
482
+ /* live memory is best-effort — quality gate still runs */
483
+ }
484
+
471
485
  logToolEvent(logDir, "start", tool, args);
472
486
  logToActivity(logDir, `pre-process: tool=${tool}`);
473
487
 
@@ -5,7 +5,17 @@
5
5
  * Heat writers merge stock fields; unknown keys and ## Durable / ## Seed survive.
6
6
  */
7
7
  const { execFileSync } = require("child_process");
8
- const { copyFileSync, existsSync, mkdirSync, readFileSync, writeFileSync } = require("fs");
8
+ const {
9
+ closeSync,
10
+ copyFileSync,
11
+ existsSync,
12
+ mkdirSync,
13
+ openSync,
14
+ readFileSync,
15
+ statSync,
16
+ unlinkSync,
17
+ writeFileSync,
18
+ } = require("fs");
9
19
  const { join } = require("path");
10
20
 
11
21
  const HOOKS_DIR = __dirname;
@@ -285,7 +295,12 @@ function mergeMissingSignals(destPath, incoming) {
285
295
  for (const signal of incoming) {
286
296
  const name = String(signal.name).trim();
287
297
  if (!name || have.has(name)) continue;
288
- data.signals.push(signal);
298
+ data.signals.push({
299
+ ...signal,
300
+ name,
301
+ tags: Array.isArray(signal.tags) ? signal.tags : [],
302
+ definition: typeof signal.definition === "string" ? signal.definition : name,
303
+ });
289
304
  have.add(name);
290
305
  added += 1;
291
306
  }
@@ -374,7 +389,151 @@ function gitCommitSubjects(root, args) {
374
389
  }
375
390
  }
376
391
 
377
- /** Every floor that heats. Dedup per HEAD. Cursor-only copy was the leftover. */
392
+ function destLockPath(root) {
393
+ return join(root, ".xray", "state", "repertoire", "dest.lock");
394
+ }
395
+
396
+ function sleepMs(ms) {
397
+ try {
398
+ const buf = new Int32Array(new SharedArrayBuffer(4));
399
+ Atomics.wait(buf, 0, 0, ms);
400
+ } catch {
401
+ /* lock retry is best-effort */
402
+ }
403
+ }
404
+
405
+ /** Parallel live contexts share one project copy. Exclusive create; stale after 60s. */
406
+ function withDestLock(root, fn) {
407
+ const lockPath = destLockPath(root);
408
+ mkdirSync(join(root, ".xray", "state", "repertoire"), { recursive: true });
409
+ const started = Date.now();
410
+ while (Date.now() - started < 50000) {
411
+ try {
412
+ const fd = openSync(lockPath, "wx");
413
+ try {
414
+ writeFileSync(fd, `${JSON.stringify({ pid: process.pid, at: new Date().toISOString() })}\n`);
415
+ return fn();
416
+ } finally {
417
+ try {
418
+ closeSync(fd);
419
+ } catch {
420
+ /* leftover */
421
+ }
422
+ try {
423
+ unlinkSync(lockPath);
424
+ } catch {
425
+ /* leftover */
426
+ }
427
+ }
428
+ } catch (err) {
429
+ if (!err || err.code !== "EEXIST") return fn();
430
+ try {
431
+ const st = statSync(lockPath);
432
+ if (Date.now() - st.mtimeMs > 60000) unlinkSync(lockPath);
433
+ } catch {
434
+ /* leftover */
435
+ }
436
+ sleepMs(40);
437
+ }
438
+ }
439
+ return fn();
440
+ }
441
+
442
+ function slugPrimitiveName(raw) {
443
+ const trimmed = String(raw || "").trim();
444
+ if (!trimmed) return null;
445
+ const bare = trimmed.replace(/^@[^/]+\//, "");
446
+ const slug = bare
447
+ .toLowerCase()
448
+ .replace(/[^a-z0-9]+/g, "-")
449
+ .replace(/^-+|-+$/g, "");
450
+ if (!/^[a-z][a-z0-9-]{2,119}$/.test(slug)) return null;
451
+ if (/^phase-\d/.test(slug)) return null;
452
+ if (/^\d/.test(slug)) return null;
453
+ if (
454
+ slug === "criteria-selection-gap" ||
455
+ slug === "external-norm-smuggling-risk" ||
456
+ slug === "model-latent-geometry-as-true-invariant"
457
+ ) {
458
+ return null;
459
+ }
460
+ return slug;
461
+ }
462
+
463
+ function stripConventionalSubject(message) {
464
+ return String(message || "")
465
+ .replace(/^(feat|fix|chore|docs|test|refactor|perf|style|ci|build|revert)(\([^)]+\))?:\s*/i, "")
466
+ .trim();
467
+ }
468
+
469
+ /** Real git → dest names. Not a leftover catalog. Kernel structural names when the diff shows them. */
470
+ function patternsFromGit(root, commits, span) {
471
+ const patterns = [];
472
+ const seen = new Set();
473
+ const add = (raw, confidence, description) => {
474
+ const slug = slugPrimitiveName(raw);
475
+ if (!slug || seen.has(slug)) return;
476
+ seen.add(slug);
477
+ patterns.push({ name: slug, confidence, description: description || String(raw) });
478
+ };
479
+ for (const row of commits) {
480
+ add(stripConventionalSubject(row.message), 0.55, row.message);
481
+ }
482
+ try {
483
+ const from = span && span.from ? String(span.from) : "";
484
+ const to = span && span.to ? String(span.to) : "HEAD";
485
+ if (from && !from.startsWith("HEAD~")) {
486
+ const out = execFileSync("git", ["diff", "--name-status", `${from}..${to}`], {
487
+ cwd: root,
488
+ encoding: "utf8",
489
+ timeout: 4000,
490
+ stdio: ["ignore", "pipe", "pipe"],
491
+ });
492
+ const lines = String(out).split("\n").filter(Boolean);
493
+ if (lines.some((line) => /\.(test|spec)\.[jt]sx?|__tests__|\/tests\//.test(line))) {
494
+ add("test-expansion", 0.55, "git test files");
495
+ }
496
+ if (lines.some((line) => /^D[\t ]/.test(line))) {
497
+ add("dead-code-removal", 0.55, "git deletions");
498
+ }
499
+ if (lines.some((line) => /hooks\/station|session-capture|semantic-patterns/.test(line))) {
500
+ add("station-heat", 0.55, "heat path");
501
+ }
502
+ }
503
+ } catch {
504
+ /* structural names are extra — commit slugs still grow dest */
505
+ }
506
+ return patterns;
507
+ }
508
+
509
+ function latestSessionNewerThanGrow(root) {
510
+ const latest = join(root, "docs", "inference", "latest-session.json");
511
+ if (!existsSync(latest)) return false;
512
+ const working = readRepertoireWorking(root);
513
+ const grownAt = working && working.grow && (working.grow.at || working.updatedAt);
514
+ if (!grownAt) return true;
515
+ try {
516
+ return statSync(latest).mtimeMs > Date.parse(String(grownAt));
517
+ } catch {
518
+ return true;
519
+ }
520
+ }
521
+
522
+ /** Capture then grow. Skip-path for floors that already have a live card. */
523
+ function heatLiveMemory(root) {
524
+ const mr = readMemoryRoutingConfig(root);
525
+ if (isExplicitMemoryRoutingOptOut(mr)) {
526
+ return { captured: null, grow: null };
527
+ }
528
+ return withDestLock(root, () => {
529
+ const captured = maybeCaptureSessionOnHeadMove(root);
530
+ const grow = captured || latestSessionNewerThanGrow(root) ? growDestOnWake(root) : null;
531
+ if (grow) persistRepertoireWorking(root, { grow });
532
+ return { captured, grow };
533
+ });
534
+ }
535
+
536
+ /** Every floor that heats. Dedup per HEAD. Patterns come from git, not a leftover catalog. */
378
537
  function maybeCaptureSessionOnHeadMove(root) {
379
538
  const cfg = readSessionCaptureConfig(root);
380
539
  if (!cfg.enabled) return null;
@@ -393,18 +552,19 @@ function maybeCaptureSessionOnHeadMove(root) {
393
552
  if (commits.length < cfg.minCommits) return null;
394
553
  const sessionId = `session-${new Date().toISOString().slice(0, 10)}-${git.head}`;
395
554
  const approaches = commits.map((row) => row.message).filter(Boolean);
555
+ const span = {
556
+ from: stamp && stamp.head ? stamp.head : commits[commits.length - 1] ? commits[commits.length - 1].hash : git.head,
557
+ to: git.head,
558
+ };
396
559
  const session = {
397
560
  sessionId,
398
561
  timestamp: new Date().toISOString(),
399
- span: {
400
- from: stamp && stamp.head ? stamp.head : `HEAD~${cfg.lookback}`,
401
- to: git.head,
402
- },
562
+ span,
403
563
  problems: [],
404
564
  approaches,
405
565
  wrongTurns: [],
406
566
  solutions: [],
407
- patterns: [],
567
+ patterns: patternsFromGit(root, commits, span),
408
568
  matched_primitives: [],
409
569
  metrics: { commits: commits.length },
410
570
  };
@@ -476,6 +636,24 @@ function matchStationSignalsSync(root, intent) {
476
636
  }
477
637
  }
478
638
 
639
+ function growDestOnWake(root) {
640
+ const helper = join(HOOKS_DIR, "station-memory-ingest.mjs");
641
+ if (!existsSync(helper)) return null;
642
+ try {
643
+ const out = execFileSync(process.execPath, [helper, root, "--grow"], {
644
+ encoding: "utf8",
645
+ timeout: 45000,
646
+ stdio: ["ignore", "pipe", "pipe"],
647
+ env: { ...process.env, REPERTOIRE_FIELD_SYNC: "0", REPERTOIRE_DEST_LOCK: "held" },
648
+ });
649
+ const line = String(out).trim().split("\n").filter(Boolean).at(-1) || "{}";
650
+ const parsed = JSON.parse(line);
651
+ return parsed && typeof parsed === "object" ? parsed : null;
652
+ } catch {
653
+ return null;
654
+ }
655
+ }
656
+
479
657
  function ingestCompactFeedbackSync(root, sessionId, hookEvent, signals) {
480
658
  if (!signals.length) return;
481
659
  const helper = join(HOOKS_DIR, "station-memory-ingest.mjs");
@@ -537,8 +715,23 @@ function buildRepertoireResume(root) {
537
715
  function applyStationHeat(root, host, extra = {}, existing = {}) {
538
716
  const mr = readMemoryRoutingConfig(root);
539
717
  const memoryOff = isExplicitMemoryRoutingOptOut(mr);
540
- const hydrate = memoryOff ? { dest: null, added: 0, destCount: 0 } : hydrateDestOnWake(root);
541
- const captured = maybeCaptureSessionOnHeadMove(root);
718
+ const live = memoryOff
719
+ ? { hydrate: { dest: null, added: 0, destCount: 0 }, captured: null, grow: null }
720
+ : withDestLock(root, () => {
721
+ const hydrateInner = hydrateDestOnWake(root);
722
+ const capturedInner = maybeCaptureSessionOnHeadMove(root);
723
+ const growInner = growDestOnWake(root);
724
+ return { hydrate: hydrateInner, captured: capturedInner, grow: growInner };
725
+ });
726
+ const hydrate = live.hydrate;
727
+ const captured = live.captured;
728
+ const grow = live.grow;
729
+ const destCountAfterGrow =
730
+ grow && typeof grow.after === "number"
731
+ ? grow.after
732
+ : existsSync(destSignalsPath(root))
733
+ ? countCuratedSignals(destSignalsPath(root)) || 0
734
+ : hydrate.destCount;
542
735
  const pickup = readNotesPickup(root);
543
736
  const approaches = readLatestSessionApproaches(root);
544
737
  const prevHost = typeof existing.host === "string" ? existing.host : null;
@@ -570,7 +763,10 @@ function applyStationHeat(root, host, extra = {}, existing = {}) {
570
763
  matchedSignals = preferSubjectHits(extra.matchedSignals, 8);
571
764
  }
572
765
  const priorWorking = readRepertoireWorking(root);
573
- const destCount = hydrate.destCount || (existsSync(destSignalsPath(root)) ? countCuratedSignals(destSignalsPath(root)) : 0);
766
+ const destCount =
767
+ destCountAfterGrow ||
768
+ hydrate.destCount ||
769
+ (existsSync(destSignalsPath(root)) ? countCuratedSignals(destSignalsPath(root)) : 0);
574
770
  if (!memoryOff && !matchedSignals.length && matchText) {
575
771
  if (
576
772
  !rematch &&
@@ -604,6 +800,15 @@ function applyStationHeat(root, host, extra = {}, existing = {}) {
604
800
  if (pickup) workingSnapshot.pickup = pickup;
605
801
  if (matchText) workingSnapshot.matchText = matchText;
606
802
  if (captured) workingSnapshot.sessionCapture = captured;
803
+ if (grow && grow.skipped) workingSnapshot.grow = grow.skipped;
804
+ if (grow && typeof grow.after === "number") {
805
+ workingSnapshot.grow = {
806
+ before: grow.before,
807
+ after: grow.after,
808
+ imported: grow.imported,
809
+ observed: Array.isArray(grow.observed) ? grow.observed.length : 0,
810
+ };
811
+ }
607
812
  if (matchedSignals.length) workingSnapshot.matchedSignals = matchedSignals.slice(0, 8);
608
813
  const opProcNames = readOpProcNames(root);
609
814
  if (opProcNames.length) workingSnapshot.opProcNames = opProcNames;
@@ -666,6 +871,22 @@ function stripHoldNpm(line) {
666
871
  .replace(/[ \t]+$/g, "");
667
872
  }
668
873
 
874
+ /** Same-session skip must still rewrite when HEAD, organ count, or Hold npm diverges. */
875
+ function stationBootNeedsRefresh(existing, root, host) {
876
+ if (!existing || typeof existing !== "object") return true;
877
+ if (host && existing.host && existing.host !== host) return true;
878
+ if (!existing.suit_profile) return true;
879
+ if (existing.workspaceRoot && existing.workspaceRoot !== root) return true;
880
+ if (!existing.stationLine) return true;
881
+ const liveGit = readGitBrief(root);
882
+ const bootHead = existing.git && existing.git.head ? String(existing.git.head) : "";
883
+ if (liveGit && liveGit.head && bootHead !== liveGit.head) return true;
884
+ const liveResume = buildRepertoireResume(root);
885
+ if (liveResume && existing.repertoireResume && liveResume !== existing.repertoireResume) return true;
886
+ if (stationDurableHoldsNpm(root)) return true;
887
+ return false;
888
+ }
889
+
669
890
  function stationDurableHoldsNpm(root) {
670
891
  return readExistingStationMarkdown(root)
671
892
  .split(/\r?\n/)
@@ -793,6 +1014,11 @@ module.exports = {
793
1014
  readRepertoireWorking,
794
1015
  readOpProcNames,
795
1016
  hydrateDestOnWake,
1017
+ growDestOnWake,
1018
+ heatLiveMemory,
1019
+ withDestLock,
1020
+ slugPrimitiveName,
1021
+ patternsFromGit,
796
1022
  readNotesPickup,
797
1023
  maybeCaptureSessionOnHeadMove,
798
1024
  formatWorkingLine,
@@ -803,4 +1029,5 @@ module.exports = {
803
1029
  writeStationMarkdown,
804
1030
  isHoldNpmLine,
805
1031
  stationDurableHoldsNpm,
1032
+ stationBootNeedsRefresh,
806
1033
  };
@@ -20,6 +20,11 @@ export const persistRepertoireWorking = impl.persistRepertoireWorking;
20
20
  export const readRepertoireWorking = impl.readRepertoireWorking;
21
21
  export const readOpProcNames = impl.readOpProcNames;
22
22
  export const hydrateDestOnWake = impl.hydrateDestOnWake;
23
+ export const growDestOnWake = impl.growDestOnWake;
24
+ export const heatLiveMemory = impl.heatLiveMemory;
25
+ export const withDestLock = impl.withDestLock;
26
+ export const slugPrimitiveName = impl.slugPrimitiveName;
27
+ export const patternsFromGit = impl.patternsFromGit;
23
28
  export const readNotesPickup = impl.readNotesPickup;
24
29
  export const maybeCaptureSessionOnHeadMove = impl.maybeCaptureSessionOnHeadMove;
25
30
  export const formatWorkingLine = impl.formatWorkingLine;
@@ -30,3 +35,4 @@ export const formatStationMarkdown = impl.formatStationMarkdown;
30
35
  export const writeStationMarkdown = impl.writeStationMarkdown;
31
36
  export const isHoldNpmLine = impl.isHoldNpmLine;
32
37
  export const stationDurableHoldsNpm = impl.stationDurableHoldsNpm;
38
+ export const stationBootNeedsRefresh = impl.stationBootNeedsRefresh;
@@ -1,12 +1,161 @@
1
1
  #!/usr/bin/env node
2
2
  /**
3
- * Compact ingest — persist matched signals back into the organ (project-local copy).
3
+ * Station organ write — compact ingest + wake grow.
4
+ * Grow uses the worn RepertoireService. Groover field stays off.
4
5
  */
5
- import { existsSync, readFileSync } from 'node:fs';
6
+ import { existsSync, readFileSync, writeFileSync } from 'node:fs';
6
7
  import { join } from 'node:path';
8
+ import { pathToFileURL } from 'node:url';
7
9
 
8
10
  const root = process.argv[2];
9
- const sessionId = process.argv[3] || 'station';
11
+ const mode = process.argv[3] || 'station';
12
+
13
+ function readRouting(projectRoot) {
14
+ let routing = { enabled: false, provider: 'null' };
15
+ const featuresPath = join(projectRoot, '.xray', 'features.json');
16
+ if (existsSync(featuresPath)) {
17
+ try {
18
+ routing = JSON.parse(readFileSync(featuresPath, 'utf8')).memory_routing || routing;
19
+ } catch {
20
+ /* leftover */
21
+ }
22
+ }
23
+ return routing;
24
+ }
25
+
26
+ function isExplicitOptOut(routing) {
27
+ return routing && routing.enabled === false && routing.provider === 'repertoire';
28
+ }
29
+
30
+ function organServiceCandidates(projectRoot, routing) {
31
+ const configured = routing.module_path
32
+ ? routing.module_path.startsWith('/') || /^[A-Za-z]:[\\/]/.test(routing.module_path)
33
+ ? routing.module_path
34
+ : join(projectRoot, routing.module_path)
35
+ : null;
36
+ const fromProvider = configured
37
+ ? configured.replace(/provider\/memory-routing-provider\.js$/, 'RepertoireService.js')
38
+ : null;
39
+ const raw = [
40
+ fromProvider,
41
+ join(projectRoot, 'node_modules', '@0xray', 'repertoire', 'dist', 'RepertoireService.js'),
42
+ join(projectRoot, 'vendor', '@0xray', 'repertoire', 'dist', 'RepertoireService.js'),
43
+ join(projectRoot, '..', 'repertoire', 'dist', 'RepertoireService.js'),
44
+ ].filter(Boolean);
45
+ const seen = new Set();
46
+ const out = [];
47
+ for (const dest of raw) {
48
+ if (seen.has(dest) || !existsSync(dest)) continue;
49
+ seen.add(dest);
50
+ out.push(dest);
51
+ }
52
+ return out;
53
+ }
54
+
55
+ function ensureDestShape(destSignals) {
56
+ if (!existsSync(destSignals)) return;
57
+ try {
58
+ const data = JSON.parse(readFileSync(destSignals, 'utf8'));
59
+ if (!Array.isArray(data.signals)) return;
60
+ let dirty = false;
61
+ for (const signal of data.signals) {
62
+ if (!signal) continue;
63
+ if (!Array.isArray(signal.tags)) {
64
+ signal.tags = [];
65
+ dirty = true;
66
+ }
67
+ if (typeof signal.definition !== 'string') {
68
+ signal.definition = String(signal.name || '');
69
+ dirty = true;
70
+ }
71
+ }
72
+ if (dirty) writeFileSync(destSignals, `${JSON.stringify(data, null, 2)}\n`);
73
+ } catch {
74
+ /* leftover */
75
+ }
76
+ }
77
+
78
+ function readCleanupDiary(projectRoot) {
79
+ const files = [
80
+ join(projectRoot, '.xray', 'state', 'NOTES.md'),
81
+ join(projectRoot, '.xray', 'state', 'CLEANSE-LIST.md'),
82
+ join(projectRoot, 'docs', 'archive', 'README.md'),
83
+ ];
84
+ const parts = [];
85
+ const sources = [];
86
+ for (const dest of files) {
87
+ if (!existsSync(dest)) continue;
88
+ try {
89
+ parts.push(readFileSync(dest, 'utf8'));
90
+ sources.push(dest);
91
+ } catch {
92
+ /* leftover */
93
+ }
94
+ }
95
+ return { text: parts.join('\n'), sources };
96
+ }
97
+
98
+ async function growDest(projectRoot) {
99
+ const routing = readRouting(projectRoot);
100
+ if (isExplicitOptOut(routing)) {
101
+ process.stdout.write(`${JSON.stringify({ skipped: 'opt-out' })}\n`);
102
+ return;
103
+ }
104
+ for (const dest of organServiceCandidates(projectRoot, routing)) {
105
+ try {
106
+ const mod = await import(pathToFileURL(dest).href);
107
+ const RepertoireService = mod.RepertoireService;
108
+ if (typeof RepertoireService !== 'function') continue;
109
+ const pathsMod = await import(pathToFileURL(dest.replace(/RepertoireService\.js$/, 'paths.js')).href);
110
+ const destSignals = join(projectRoot, '.xray', 'state', 'repertoire', 'curated_signals.json');
111
+ ensureDestShape(destSignals);
112
+ const service = new RepertoireService({
113
+ projectRoot,
114
+ signalsPath: destSignals,
115
+ syncXray: false,
116
+ syncField: false,
117
+ });
118
+ ensureDestShape(destSignals);
119
+ const before = service.signalsManager.load().signals.length;
120
+ const sessions = service.syncXrayMemory();
121
+ const siblings = service.syncWorkspaceRepos();
122
+ const kernel =
123
+ typeof pathsMod.collectKernelDiaryText === 'function'
124
+ ? pathsMod.collectKernelDiaryText(projectRoot)
125
+ : { text: '', sources: [] };
126
+ const cleanup = readCleanupDiary(projectRoot);
127
+ const diary = service.heatKernelDiary({
128
+ text: [kernel.text, cleanup.text].filter(Boolean).join('\n'),
129
+ sources: [...(kernel.sources || []), ...cleanup.sources],
130
+ });
131
+ const after = service.signalsManager.load().signals.length;
132
+ process.stdout.write(
133
+ `${JSON.stringify({
134
+ before,
135
+ after,
136
+ imported: sessions.imported,
137
+ observed: siblings.observed,
138
+ heated: diary.heated,
139
+ })}\n`,
140
+ );
141
+ return;
142
+ } catch {
143
+ /* try next organ */
144
+ }
145
+ }
146
+ process.stdout.write(`${JSON.stringify({ skipped: 'no-organ' })}\n`);
147
+ }
148
+
149
+ if (!root) {
150
+ process.exit(0);
151
+ }
152
+
153
+ if (mode === '--grow') {
154
+ await growDest(root);
155
+ process.exit(0);
156
+ }
157
+
158
+ const sessionId = mode;
10
159
  const hookEvent = process.argv[4] || 'post_compact';
11
160
  let signals = [];
12
161
  try {
@@ -14,7 +163,7 @@ try {
14
163
  } catch {
15
164
  signals = [];
16
165
  }
17
- if (!root || !Array.isArray(signals) || signals.length === 0) {
166
+ if (!Array.isArray(signals) || signals.length === 0) {
18
167
  process.exit(0);
19
168
  }
20
169
 
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "xray-pre-tool",
3
- "version": "4.0.19",
3
+ "version": "4.0.20",
4
4
  "private": true,
5
5
  "type": "module",
6
6
  "description": "0xRay constitution + temperament PreToolUse for OpenClaw",
@@ -462,17 +462,16 @@ export function evaluateConstitutionGate(toolName, toolInput, ctx) {
462
462
  return { allow: true };
463
463
  }
464
464
  export function evaluatePreToolGate(toolName, toolInput, ctx) {
465
- if (ctx.host === 'openclaw') {
466
- try {
467
- maybeHeatHostStation(ctx.projectRoot, 'openclaw', {
468
- source: '0xray/openclaw-pre-tool',
469
- sessionId: ctx.sessionId || 'openclaw',
470
- hookEvent: ctx.hookEvent || 'session_start',
471
- });
472
- }
473
- catch {
474
- /* station heat is best-effort — constitution still runs */
475
- }
465
+ const host = ctx.host || 'generic';
466
+ try {
467
+ maybeHeatHostStation(ctx.projectRoot, host, {
468
+ source: `0xray/${host}-pre-tool`,
469
+ sessionId: ctx.sessionId || host,
470
+ hookEvent: ctx.hookEvent || 'pre_tool_use',
471
+ });
472
+ }
473
+ catch {
474
+ /* station heat is best-effort — constitution still runs */
476
475
  }
477
476
  const constitution = evaluateConstitutionGate(toolName, toolInput, ctx);
478
477
  if (!constitution.allow)
@@ -111,7 +111,11 @@ export function maybeHeatHostStation(projectRoot, host, extra = {}) {
111
111
  const existingSessionId = concreteSessionId(existing.sessionId);
112
112
  const sameHost = existing.host === host;
113
113
  const sameSession = existingSessionId !== null && sessionId !== null && existingSessionId === sessionId;
114
- if (sameHost && sameSession && fs.existsSync(cardPath)) {
114
+ if (sameHost &&
115
+ sameSession &&
116
+ fs.existsSync(cardPath) &&
117
+ !stationHeat.stationBootNeedsRefresh(existing, projectRoot, host)) {
118
+ stationHeat.heatLiveMemory(projectRoot);
115
119
  return bootPath;
116
120
  }
117
121
  return writeSuitSessionBoot(projectRoot, host, extra);
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "0xray",
3
- "version": "4.0.19",
3
+ "version": "4.0.20",
4
4
  "description": "Multi-agent orchestration and Codex governance for OpenCode, Hermes, Grok Build, and OpenClaw",
5
5
  "readme": "README.md",
6
6
  "license": "MIT",
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "xray-pre-tool",
3
- "version": "4.0.19",
3
+ "version": "4.0.20",
4
4
  "private": true,
5
5
  "type": "module",
6
6
  "description": "0xRay constitution + temperament PreToolUse for OpenClaw",
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "$schema": "./features.schema.json",
3
- "version": "4.0.19",
3
+ "version": "4.0.20",
4
4
  "description": "0xRay Framework - Unified Feature Configuration",
5
5
  "suit_temperament": {
6
6
  "profile": "auto",