0xray 4.0.19 → 4.0.21

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.
Files changed (46) hide show
  1. package/.grok-plugin/plugin.json +1 -1
  2. package/.opencode/skills/orchestrator/SKILL.md +1 -1
  3. package/.xray/features.json +1 -1
  4. package/AGENTS-consumer.md +4 -4
  5. package/AGENTS.md +4 -4
  6. package/README.md +14 -14
  7. package/SKILLS.md +3 -3
  8. package/dist/AGENTS.md +4 -4
  9. package/dist/CHANGELOG.md +27 -6
  10. package/dist/README.md +14 -14
  11. package/dist/SKILLS.md +3 -3
  12. package/dist/cli/commands/validate.d.ts +1 -1
  13. package/dist/cli/commands/validate.js +7 -1
  14. package/dist/integrations/cursor/README.md +6 -5
  15. package/dist/integrations/cursor/hooks/after-file-edit.js +6 -3
  16. package/dist/integrations/cursor/hooks/after-file-edit.sh +3 -0
  17. package/dist/integrations/cursor/hooks/before-read-file.sh +3 -0
  18. package/dist/integrations/cursor/hooks/before-shell-execution.sh +3 -0
  19. package/dist/integrations/cursor/hooks/cursor-hook-utils.js +121 -20
  20. package/dist/integrations/cursor/hooks/cursor-usage-receipt.js +3 -0
  21. package/dist/integrations/cursor/hooks/hooks.json +13 -3
  22. package/dist/integrations/cursor/hooks/pre-compact.js +39 -22
  23. package/dist/integrations/cursor/hooks/pre-compact.sh +3 -0
  24. package/dist/integrations/cursor/hooks/pre-tool-use.js +20 -15
  25. package/dist/integrations/cursor/hooks/pre-tool-use.sh +3 -0
  26. package/dist/integrations/cursor/hooks/xray-cloud-hook.sh +63 -0
  27. package/dist/integrations/grok/hooks/grok-hook-utils.js +6 -3
  28. package/dist/integrations/hermes-agent/bridge.mjs +14 -0
  29. package/dist/integrations/hooks/station-hook-runtime.cjs +416 -37
  30. package/dist/integrations/hooks/station-hook-runtime.mjs +15 -0
  31. package/dist/integrations/hooks/station-memory-ingest.mjs +152 -4
  32. package/dist/integrations/openclaw/plugin/xray-pre-tool/package.json +1 -1
  33. package/dist/nucleus/delegation-gate.js +10 -11
  34. package/dist/nucleus/suit-temperament.d.ts +0 -5
  35. package/dist/nucleus/suit-temperament.js +18 -2
  36. package/dist/skills/orchestrator/SKILL.md +1 -1
  37. package/llms.txt +1 -1
  38. package/package.json +2 -2
  39. package/scripts/foundry/README.md +2 -2
  40. package/scripts/foundry/assert-packed-dist-cli.mjs +4 -0
  41. package/scripts/foundry/plant/skills/mill/SKILL.md +1 -1
  42. package/scripts/foundry/validate-release-docs.mjs +44 -0
  43. package/scripts/node/install-bridges.cjs +256 -10
  44. package/src/integrations/openclaw/plugin/xray-pre-tool/package.json +1 -1
  45. package/src/skills/orchestrator/SKILL.md +1 -1
  46. package/xray/features.json +1 -1
@@ -1,11 +1,22 @@
1
1
  /**
2
2
  * Station heat — compaction / host-swap card.
3
- * SSOT remains session-boot.json. STATION.md is the projection the model Reads.
3
+ * STATION.md is the pickup ticket. session-boot.json is the snapshot.
4
+ * Heat must not paint leftover boot extras onto the card.
4
5
  * Grok ignores SessionStart/UserPromptSubmit stdout; disk + AGENTS.md is the contract.
5
6
  * Heat writers merge stock fields; unknown keys and ## Durable / ## Seed survive.
6
7
  */
7
8
  const { execFileSync } = require("child_process");
8
- const { copyFileSync, existsSync, mkdirSync, readFileSync, writeFileSync } = require("fs");
9
+ const {
10
+ closeSync,
11
+ copyFileSync,
12
+ existsSync,
13
+ mkdirSync,
14
+ openSync,
15
+ readFileSync,
16
+ statSync,
17
+ unlinkSync,
18
+ writeFileSync,
19
+ } = require("fs");
9
20
  const { join } = require("path");
10
21
 
11
22
  const HOOKS_DIR = __dirname;
@@ -94,6 +105,63 @@ function readPlanLine(root) {
94
105
  return readGitSubject(root);
95
106
  }
96
107
 
108
+ function isStockTicket(value) {
109
+ const text = String(value || "").trim().toLowerCase();
110
+ return !text || text === "(none)" || text === "(none yet)";
111
+ }
112
+
113
+ function readStationTicketField(root, field) {
114
+ const md = readExistingStationMarkdown(root);
115
+ const re = field === "Plan" ? /^Plan:\s*(.*)$/im : /^Intent:\s*(.*)$/im;
116
+ const match = md.match(re);
117
+ if (!match) return null;
118
+ const value = clipIntent(match[1]);
119
+ if (!value || isStockTicket(value)) return null;
120
+ return value;
121
+ }
122
+
123
+ function bootHeadMoved(root, existing) {
124
+ const live = readGitBrief(root);
125
+ const bootHead = existing && existing.git && existing.git.head ? String(existing.git.head) : "";
126
+ return Boolean(live && live.head && bootHead && bootHead !== live.head);
127
+ }
128
+
129
+ /** Live ticket beats leftover boot. Extra spoken intent still wins. */
130
+ function resolveHeatIntent(root, extra, existing) {
131
+ const incoming = clipIntent(
132
+ extra.intent || extra.prompt || extra.userMessage || extra.user_prompt,
133
+ );
134
+ if (incoming) return { intent: incoming, rematch: true };
135
+ const card = readStationTicketField(root, "Intent");
136
+ const pickup = clipIntent(readNotesPickup(root));
137
+ const bootIntent = typeof existing.intent === "string" ? clipIntent(existing.intent) : null;
138
+ const cardIsBootEcho = Boolean(card && bootIntent && card === bootIntent);
139
+ if (pickup && cardIsBootEcho && pickup !== bootIntent) {
140
+ return { intent: pickup, rematch: true };
141
+ }
142
+ if (card && !cardIsBootEcho) return { intent: card, rematch: card !== bootIntent };
143
+ if (pickup && pickup !== bootIntent) return { intent: pickup, rematch: true };
144
+ if (card) return { intent: card, rematch: false };
145
+ if (bootIntent) return { intent: bootIntent, rematch: false };
146
+ const git = readGitSubject(root);
147
+ return { intent: git, rematch: Boolean(git) };
148
+ }
149
+
150
+ function resolveHeatPlan(root, extra, existing) {
151
+ const incoming = clipIntent(extra.plan || extra.planLine);
152
+ if (incoming) return incoming;
153
+ const card = readStationTicketField(root, "Plan");
154
+ const bootPlan = typeof existing.planLine === "string" ? clipIntent(existing.planLine) : null;
155
+ const live = readPlanLine(root);
156
+ const cardIsBootEcho = Boolean(card && bootPlan && card === bootPlan);
157
+ if (card && !cardIsBootEcho) return card;
158
+ if (bootHeadMoved(root, existing) && bootPlan && live === bootPlan) {
159
+ return readGitSubject(root) || card || live;
160
+ }
161
+ if (card) return card;
162
+ return live;
163
+ }
164
+
97
165
  function resolveRepertoireProviderModule(root) {
98
166
  const mr = readMemoryRoutingConfig(root);
99
167
  if (mr && typeof mr.module_path === "string" && mr.module_path) {
@@ -190,32 +258,44 @@ function readRepertoireWorking(root) {
190
258
  }
191
259
  }
192
260
 
261
+ function isKeywordDestName(name) {
262
+ const n = String(name || "").trim().toLowerCase();
263
+ if (!n) return true;
264
+ if (n.startsWith("repo-") || n.startsWith("bedrock-")) return true;
265
+ if (n === "test-expansion" || n === "dead-code-removal" || n === "station-heat") return true;
266
+ if (/^release-v?\d/.test(n) || /^stamp-/.test(n) || /^drop-stale-/.test(n)) return true;
267
+ if (/\b4-0-\d{2}\b/.test(n) && /npm|package-lock|release|stamp/.test(n)) return true;
268
+ return false;
269
+ }
270
+
271
+ function isGenericFieldObservedSignal(signal) {
272
+ const definition = String((signal && signal.definition) || "");
273
+ return /field-observed domain primitive/i.test(definition);
274
+ }
275
+
193
276
  function stationSafeSignals(names) {
194
277
  if (!Array.isArray(names)) return [];
195
278
  const out = [];
196
279
  for (const raw of names) {
197
280
  const name = String(raw || "").trim();
198
- if (!name) continue;
199
- if (name.toLowerCase().startsWith("bedrock-")) continue;
281
+ if (!name || isKeywordDestName(name)) continue;
200
282
  out.push(name);
201
283
  if (out.length >= 4) break;
202
284
  }
203
285
  return out;
204
286
  }
205
287
 
206
- /** Keep subject repo-* in the short list the card and working file hold. */
207
- function preferSubjectHits(names, cap = 8) {
288
+ /** Laws only. Hangar repo-* and git-slug keywords stay off the card. */
289
+ function preferLawHits(names, cap = 8) {
208
290
  if (!Array.isArray(names)) return [];
209
291
  const unique = [];
210
292
  for (const raw of names) {
211
293
  const name = String(raw || "").trim();
212
- if (!name || name.toLowerCase().startsWith("bedrock-")) continue;
294
+ if (!name || isKeywordDestName(name)) continue;
213
295
  if (unique.includes(name)) continue;
214
296
  unique.push(name);
215
297
  }
216
- const subject = unique.filter((name) => name.startsWith("repo-"));
217
- const rest = unique.filter((name) => !name.startsWith("repo-"));
218
- return [...subject, ...rest].slice(0, cap);
298
+ return unique.slice(0, cap);
219
299
  }
220
300
 
221
301
  function destSignalsPath(root) {
@@ -269,6 +349,52 @@ function signalNameSet(filePath) {
269
349
  return new Set(readSignalRecords(filePath).map((signal) => String(signal.name).trim()));
270
350
  }
271
351
 
352
+ function destLawNameSet(root) {
353
+ const names = new Set();
354
+ for (const fileName of ["curated_signals.json", "stack-overlay.json"]) {
355
+ for (const name of signalNameSet(repertoireDataFile(root, fileName))) {
356
+ if (!isKeywordDestName(name)) names.add(name);
357
+ }
358
+ }
359
+ for (const name of signalNameSet(destSignalsPath(root))) {
360
+ if (!isKeywordDestName(name)) names.add(name);
361
+ }
362
+ return names;
363
+ }
364
+
365
+ /** Drop hangar repo-* and git-slug keywords. Keep factory + stack laws. */
366
+ function pruneKeywordDest(root) {
367
+ const dest = destSignalsPath(root);
368
+ if (!dest || !existsSync(dest)) return { removed: 0, kept: 0 };
369
+ let data;
370
+ try {
371
+ data = JSON.parse(readFileSync(dest, "utf8"));
372
+ } catch {
373
+ return { removed: 0, kept: 0 };
374
+ }
375
+ if (!Array.isArray(data.signals)) return { removed: 0, kept: 0 };
376
+ const factory = signalNameSet(repertoireDataFile(root, "curated_signals.json"));
377
+ const stack = signalNameSet(repertoireDataFile(root, "stack-overlay.json"));
378
+ const kept = [];
379
+ let removed = 0;
380
+ for (const signal of data.signals) {
381
+ const name = String((signal && signal.name) || "").trim();
382
+ if (!name) continue;
383
+ const protectedLaw = factory.has(name) || stack.has(name);
384
+ if (!protectedLaw && (isKeywordDestName(name) || isGenericFieldObservedSignal(signal))) {
385
+ removed += 1;
386
+ continue;
387
+ }
388
+ kept.push(signal);
389
+ }
390
+ if (removed) {
391
+ data.signals = kept;
392
+ data.last_updated = new Date().toISOString();
393
+ writeFileSync(dest, `${JSON.stringify(data, null, 2)}\n`);
394
+ }
395
+ return { removed, kept: kept.length };
396
+ }
397
+
272
398
  function mergeMissingSignals(destPath, incoming) {
273
399
  if (!incoming.length || !existsSync(destPath)) return 0;
274
400
  let data;
@@ -285,7 +411,12 @@ function mergeMissingSignals(destPath, incoming) {
285
411
  for (const signal of incoming) {
286
412
  const name = String(signal.name).trim();
287
413
  if (!name || have.has(name)) continue;
288
- data.signals.push(signal);
414
+ data.signals.push({
415
+ ...signal,
416
+ name,
417
+ tags: Array.isArray(signal.tags) ? signal.tags : [],
418
+ definition: typeof signal.definition === "string" ? signal.definition : name,
419
+ });
289
420
  have.add(name);
290
421
  added += 1;
291
422
  }
@@ -293,7 +424,7 @@ function mergeMissingSignals(destPath, incoming) {
293
424
  return added;
294
425
  }
295
426
 
296
- /** Hydrate project dest from seed + stack + subject overlays. Never write the tarball. */
427
+ /** Hydrate project dest from seed + stack. Subject repo-* stay off dest. */
297
428
  function hydrateDestOnWake(root) {
298
429
  const dest = destSignalsPath(root);
299
430
  const seed = repertoireDataFile(root, "curated_signals.json");
@@ -302,10 +433,12 @@ function hydrateDestOnWake(root) {
302
433
  copyFileSync(seed, dest);
303
434
  }
304
435
  if (!existsSync(dest)) return { dest: null, added: 0, destCount: 0 };
305
- const added =
306
- mergeMissingSignals(dest, readSignalRecords(repertoireDataFile(root, "stack-overlay.json"))) +
307
- mergeMissingSignals(dest, readSignalRecords(repertoireDataFile(root, "subject-overlay.json")));
308
- return { dest, added, destCount: countCuratedSignals(dest) || 0 };
436
+ const added = mergeMissingSignals(
437
+ dest,
438
+ readSignalRecords(repertoireDataFile(root, "stack-overlay.json")),
439
+ );
440
+ const pruned = pruneKeywordDest(root);
441
+ return { dest, added, destCount: countCuratedSignals(dest) || 0, pruned };
309
442
  }
310
443
 
311
444
  function readNotesPickup(root) {
@@ -374,7 +507,138 @@ function gitCommitSubjects(root, args) {
374
507
  }
375
508
  }
376
509
 
377
- /** Every floor that heats. Dedup per HEAD. Cursor-only copy was the leftover. */
510
+ function destLockPath(root) {
511
+ return join(root, ".xray", "state", "repertoire", "dest.lock");
512
+ }
513
+
514
+ function sleepMs(ms) {
515
+ try {
516
+ const buf = new Int32Array(new SharedArrayBuffer(4));
517
+ Atomics.wait(buf, 0, 0, ms);
518
+ } catch {
519
+ /* lock retry is best-effort */
520
+ }
521
+ }
522
+
523
+ /** Parallel live contexts share one project copy. Exclusive create; stale after 60s. */
524
+ function withDestLock(root, fn) {
525
+ const lockPath = destLockPath(root);
526
+ try {
527
+ mkdirSync(join(root, ".xray", "state", "repertoire"), { recursive: true });
528
+ } catch (err) {
529
+ if (err && (err.code === "EACCES" || err.code === "EPERM")) return null;
530
+ throw err;
531
+ }
532
+ const started = Date.now();
533
+ while (Date.now() - started < 50000) {
534
+ try {
535
+ const fd = openSync(lockPath, "wx");
536
+ try {
537
+ writeFileSync(fd, `${JSON.stringify({ pid: process.pid, at: new Date().toISOString() })}\n`);
538
+ return fn();
539
+ } finally {
540
+ try {
541
+ closeSync(fd);
542
+ } catch {
543
+ /* leftover */
544
+ }
545
+ try {
546
+ unlinkSync(lockPath);
547
+ } catch {
548
+ /* leftover */
549
+ }
550
+ }
551
+ } catch (err) {
552
+ if (!err || err.code !== "EEXIST") return fn();
553
+ try {
554
+ const st = statSync(lockPath);
555
+ if (Date.now() - st.mtimeMs > 60000) unlinkSync(lockPath);
556
+ } catch {
557
+ /* leftover */
558
+ }
559
+ sleepMs(40);
560
+ }
561
+ }
562
+ return fn();
563
+ }
564
+
565
+ function slugPrimitiveName(raw) {
566
+ const trimmed = String(raw || "").trim();
567
+ if (!trimmed) return null;
568
+ const bare = trimmed.replace(/^@[^/]+\//, "");
569
+ const slug = bare
570
+ .toLowerCase()
571
+ .replace(/[^a-z0-9]+/g, "-")
572
+ .replace(/^-+|-+$/g, "");
573
+ if (!/^[a-z][a-z0-9-]{2,119}$/.test(slug)) return null;
574
+ if (/^phase-\d/.test(slug)) return null;
575
+ if (/^\d/.test(slug)) return null;
576
+ if (
577
+ slug === "criteria-selection-gap" ||
578
+ slug === "external-norm-smuggling-risk" ||
579
+ slug === "model-latent-geometry-as-true-invariant"
580
+ ) {
581
+ return null;
582
+ }
583
+ return slug;
584
+ }
585
+
586
+ function stripConventionalSubject(message) {
587
+ return String(message || "")
588
+ .replace(/^(feat|fix|chore|docs|test|refactor|perf|style|ci|build|revert)(\([^)]+\))?:\s*/i, "")
589
+ .trim();
590
+ }
591
+
592
+ /** Observe existing laws mentioned in git. Do not mint commit slugs. */
593
+ function patternsFromGit(root, commits) {
594
+ const text = (Array.isArray(commits) ? commits : [])
595
+ .map((row) => String((row && row.message) || ""))
596
+ .join("\n")
597
+ .toLowerCase();
598
+ const patterns = [];
599
+ if (!text.trim()) return patterns;
600
+ for (const name of destLawNameSet(root)) {
601
+ if (text.includes(name) || text.includes(name.replace(/-/g, " "))) {
602
+ patterns.push({ name, confidence: 0.7, description: "observe existing law from git" });
603
+ }
604
+ }
605
+ return patterns;
606
+ }
607
+
608
+ function latestSessionNewerThanGrow(root) {
609
+ const latest = join(root, "docs", "inference", "latest-session.json");
610
+ if (!existsSync(latest)) return false;
611
+ const working = readRepertoireWorking(root);
612
+ const grownAt = working && working.grow && (working.grow.at || working.updatedAt);
613
+ if (!grownAt) return true;
614
+ try {
615
+ return statSync(latest).mtimeMs > Date.parse(String(grownAt));
616
+ } catch {
617
+ return true;
618
+ }
619
+ }
620
+
621
+ /** Capture then grow. Skip-path for floors that already have a live card. */
622
+ function heatLiveMemory(root) {
623
+ try {
624
+ const mr = readMemoryRoutingConfig(root);
625
+ if (isExplicitMemoryRoutingOptOut(mr)) {
626
+ return { captured: null, grow: null };
627
+ }
628
+ const result = withDestLock(root, () => {
629
+ const captured = maybeCaptureSessionOnHeadMove(root);
630
+ const grow = captured || latestSessionNewerThanGrow(root) ? growDestOnWake(root) : null;
631
+ if (grow) persistRepertoireWorking(root, { grow });
632
+ return { captured, grow };
633
+ });
634
+ return result || { captured: null, grow: null };
635
+ } catch {
636
+ /* dest EACCES / unwritable heat root must not deny the host tool */
637
+ return { captured: null, grow: null };
638
+ }
639
+ }
640
+
641
+ /** Every floor that heats. Dedup per HEAD. Patterns come from git, not a leftover catalog. */
378
642
  function maybeCaptureSessionOnHeadMove(root) {
379
643
  const cfg = readSessionCaptureConfig(root);
380
644
  if (!cfg.enabled) return null;
@@ -393,18 +657,19 @@ function maybeCaptureSessionOnHeadMove(root) {
393
657
  if (commits.length < cfg.minCommits) return null;
394
658
  const sessionId = `session-${new Date().toISOString().slice(0, 10)}-${git.head}`;
395
659
  const approaches = commits.map((row) => row.message).filter(Boolean);
660
+ const span = {
661
+ from: stamp && stamp.head ? stamp.head : commits[commits.length - 1] ? commits[commits.length - 1].hash : git.head,
662
+ to: git.head,
663
+ };
396
664
  const session = {
397
665
  sessionId,
398
666
  timestamp: new Date().toISOString(),
399
- span: {
400
- from: stamp && stamp.head ? stamp.head : `HEAD~${cfg.lookback}`,
401
- to: git.head,
402
- },
667
+ span,
403
668
  problems: [],
404
669
  approaches,
405
670
  wrongTurns: [],
406
671
  solutions: [],
407
- patterns: [],
672
+ patterns: patternsFromGit(root, commits, span),
408
673
  matched_primitives: [],
409
674
  metrics: { commits: commits.length },
410
675
  };
@@ -476,6 +741,26 @@ function matchStationSignalsSync(root, intent) {
476
741
  }
477
742
  }
478
743
 
744
+ function growDestOnWake(root) {
745
+ const helper = join(HOOKS_DIR, "station-memory-ingest.mjs");
746
+ if (!existsSync(helper)) return null;
747
+ try {
748
+ const out = execFileSync(process.execPath, [helper, root, "--grow"], {
749
+ encoding: "utf8",
750
+ timeout: 45000,
751
+ stdio: ["ignore", "pipe", "pipe"],
752
+ env: { ...process.env, REPERTOIRE_FIELD_SYNC: "0", REPERTOIRE_DEST_LOCK: "held" },
753
+ });
754
+ const line = String(out).trim().split("\n").filter(Boolean).at(-1) || "{}";
755
+ const parsed = JSON.parse(line);
756
+ const pruned = pruneKeywordDest(root);
757
+ if (!parsed || typeof parsed !== "object") return { pruned };
758
+ return { ...parsed, pruned: pruned.removed, destCount: countCuratedSignals(destSignalsPath(root)) };
759
+ } catch {
760
+ return null;
761
+ }
762
+ }
763
+
479
764
  function ingestCompactFeedbackSync(root, sessionId, hookEvent, signals) {
480
765
  if (!signals.length) return;
481
766
  const helper = join(HOOKS_DIR, "station-memory-ingest.mjs");
@@ -501,6 +786,31 @@ function isCompactHook(extra) {
501
786
  return /compact/i.test(hook);
502
787
  }
503
788
 
789
+ function isCompactEventName(value) {
790
+ return /compact/i.test(String(value || ""));
791
+ }
792
+
793
+ /** Keep Compact boot fields after later preToolUse / HEAD rewrite. */
794
+ function retainCompactFields(existing, extra = {}) {
795
+ const incomingHook = extra && (extra.hookEvent || extra.source || extra.hook);
796
+ if (isCompactEventName(incomingHook) || (extra && extra.event_class)) {
797
+ const kept = {};
798
+ if (extra.hookEvent) kept.hookEvent = extra.hookEvent;
799
+ if (extra.conversation_id) kept.conversation_id = extra.conversation_id;
800
+ if (extra.generation_id) kept.generation_id = extra.generation_id;
801
+ if (extra.event_class) kept.event_class = extra.event_class;
802
+ return kept;
803
+ }
804
+ const prior = existing && typeof existing === "object" ? existing : {};
805
+ if (!isCompactEventName(prior.hookEvent) && !prior.event_class) return {};
806
+ const kept = {};
807
+ if (prior.hookEvent) kept.hookEvent = prior.hookEvent;
808
+ if (prior.conversation_id) kept.conversation_id = prior.conversation_id;
809
+ if (prior.generation_id) kept.generation_id = prior.generation_id;
810
+ if (prior.event_class) kept.event_class = prior.event_class;
811
+ return kept;
812
+ }
813
+
504
814
  function formatWorkingLine(working) {
505
815
  if (!working || typeof working !== "object") return null;
506
816
  const matched = stationSafeSignals(working.matchedSignals);
@@ -537,8 +847,23 @@ function buildRepertoireResume(root) {
537
847
  function applyStationHeat(root, host, extra = {}, existing = {}) {
538
848
  const mr = readMemoryRoutingConfig(root);
539
849
  const memoryOff = isExplicitMemoryRoutingOptOut(mr);
540
- const hydrate = memoryOff ? { dest: null, added: 0, destCount: 0 } : hydrateDestOnWake(root);
541
- const captured = maybeCaptureSessionOnHeadMove(root);
850
+ const live = memoryOff
851
+ ? { hydrate: { dest: null, added: 0, destCount: 0 }, captured: null, grow: null }
852
+ : withDestLock(root, () => {
853
+ const hydrateInner = hydrateDestOnWake(root);
854
+ const capturedInner = maybeCaptureSessionOnHeadMove(root);
855
+ const growInner = growDestOnWake(root);
856
+ return { hydrate: hydrateInner, captured: capturedInner, grow: growInner };
857
+ });
858
+ const hydrate = live.hydrate;
859
+ const captured = live.captured;
860
+ const grow = live.grow;
861
+ const destCountAfterGrow =
862
+ grow && typeof grow.after === "number"
863
+ ? grow.after
864
+ : existsSync(destSignalsPath(root))
865
+ ? countCuratedSignals(destSignalsPath(root)) || 0
866
+ : hydrate.destCount;
542
867
  const pickup = readNotesPickup(root);
543
868
  const approaches = readLatestSessionApproaches(root);
544
869
  const prevHost = typeof existing.host === "string" ? existing.host : null;
@@ -551,14 +876,12 @@ function applyStationHeat(root, host, extra = {}, existing = {}) {
551
876
  ? existing.hotSwap
552
877
  : null;
553
878
  const hotSwap = nextSwap || keptSwap;
554
- const incomingIntent = clipIntent(
555
- extra.intent || extra.prompt || extra.userMessage || extra.user_prompt,
556
- );
557
- const intent = incomingIntent || (typeof existing.intent === "string" ? existing.intent : null);
879
+ const resolved = resolveHeatIntent(root, extra, existing);
880
+ const intent = resolved.intent;
881
+ const rematch = resolved.rematch;
558
882
  const matchText = clipIntent([intent, pickup, approaches].filter(Boolean).join(" "));
559
- const rematch = Boolean(incomingIntent);
560
883
  const git = readGitBrief(root);
561
- const planLine = readPlanLine(root);
884
+ const planLine = resolveHeatPlan(root, extra, existing);
562
885
  const repertoireResume =
563
886
  typeof extra.repertoireResume === "string" ? extra.repertoireResume : buildRepertoireResume(root);
564
887
  const swapBit = hotSwap ? `hot-swap ${hotSwap.from} → ${hotSwap.to}` : `host ${host}`;
@@ -567,10 +890,13 @@ function applyStationHeat(root, host, extra = {}, existing = {}) {
567
890
  const planBit = planLine ? `plan: ${planLine}` : "plan: (none)";
568
891
  let matchedSignals = [];
569
892
  if (Array.isArray(extra.matchedSignals) && extra.matchedSignals.length) {
570
- matchedSignals = preferSubjectHits(extra.matchedSignals, 8);
893
+ matchedSignals = preferLawHits(extra.matchedSignals, 8);
571
894
  }
572
895
  const priorWorking = readRepertoireWorking(root);
573
- const destCount = hydrate.destCount || (existsSync(destSignalsPath(root)) ? countCuratedSignals(destSignalsPath(root)) : 0);
896
+ const destCount =
897
+ destCountAfterGrow ||
898
+ hydrate.destCount ||
899
+ (existsSync(destSignalsPath(root)) ? countCuratedSignals(destSignalsPath(root)) : 0);
574
900
  if (!memoryOff && !matchedSignals.length && matchText) {
575
901
  if (
576
902
  !rematch &&
@@ -580,10 +906,10 @@ function applyStationHeat(root, host, extra = {}, existing = {}) {
580
906
  Array.isArray(priorWorking.matchedSignals) &&
581
907
  priorWorking.matchedSignals.length
582
908
  ) {
583
- matchedSignals = preferSubjectHits(priorWorking.matchedSignals, 8);
909
+ matchedSignals = preferLawHits(priorWorking.matchedSignals, 8);
584
910
  } else {
585
- matchedSignals = preferSubjectHits(
586
- matchStationSignalsSync(root, rematch && incomingIntent ? incomingIntent : matchText),
911
+ matchedSignals = preferLawHits(
912
+ matchStationSignalsSync(root, rematch && intent ? intent : matchText),
587
913
  8,
588
914
  );
589
915
  }
@@ -591,23 +917,33 @@ function applyStationHeat(root, host, extra = {}, existing = {}) {
591
917
  if (!matchedSignals.length && priorWorking) {
592
918
  matchedSignals = stationSafeSignals(priorWorking.matchedSignals);
593
919
  }
920
+ const compactHold = retainCompactFields(existing, extra);
921
+ const nextHook = compactHold.hookEvent || extra.hookEvent || extra.source || null;
594
922
  const workingSnapshot = {
595
923
  host,
596
924
  intent,
597
925
  git,
598
926
  hotSwap,
599
- hookEvent: extra.hookEvent || extra.source || null,
600
927
  memoryRouting: repertoireResume.startsWith("Repertoire: on") ? "on" : "off",
601
928
  repertoireResume,
602
929
  destCount,
603
930
  };
931
+ if (nextHook) workingSnapshot.hookEvent = nextHook;
604
932
  if (pickup) workingSnapshot.pickup = pickup;
605
933
  if (matchText) workingSnapshot.matchText = matchText;
606
934
  if (captured) workingSnapshot.sessionCapture = captured;
935
+ if (grow && grow.skipped) workingSnapshot.grow = grow.skipped;
936
+ if (grow && typeof grow.after === "number") {
937
+ workingSnapshot.grow = {
938
+ before: grow.before,
939
+ after: grow.after,
940
+ imported: grow.imported,
941
+ observed: Array.isArray(grow.observed) ? grow.observed.length : 0,
942
+ };
943
+ }
607
944
  if (matchedSignals.length) workingSnapshot.matchedSignals = matchedSignals.slice(0, 8);
608
945
  const opProcNames = readOpProcNames(root);
609
946
  if (opProcNames.length) workingSnapshot.opProcNames = opProcNames;
610
- const working = persistRepertoireWorking(root, workingSnapshot);
611
947
  const pickupChanged = Boolean(pickup && (!priorWorking || priorWorking.pickup !== pickup));
612
948
  if (
613
949
  !memoryOff &&
@@ -621,6 +957,11 @@ function applyStationHeat(root, host, extra = {}, existing = {}) {
621
957
  matchedSignals,
622
958
  );
623
959
  }
960
+ if (!memoryOff) {
961
+ const latePrune = pruneKeywordDest(root);
962
+ if (latePrune.kept) workingSnapshot.destCount = latePrune.kept;
963
+ }
964
+ const working = persistRepertoireWorking(root, workingSnapshot);
624
965
  const workingLine = formatWorkingLine(working);
625
966
  const workingBit = workingLine ? workingLine : "working: (none)";
626
967
  const stationLine = `${swapBit}. ${intentBit}. ${planBit}. ${gitBit}. ${repertoireResume}. ${workingBit}`;
@@ -633,6 +974,7 @@ function applyStationHeat(root, host, extra = {}, existing = {}) {
633
974
  repertoireResume,
634
975
  workingLine,
635
976
  stationLine,
977
+ ...compactHold,
636
978
  };
637
979
  }
638
980
 
@@ -666,6 +1008,28 @@ function stripHoldNpm(line) {
666
1008
  .replace(/[ \t]+$/g, "");
667
1009
  }
668
1010
 
1011
+ /** Same-session skip must still rewrite when HEAD, organ count, or Hold npm diverges. */
1012
+ function stationBootNeedsRefresh(existing, root, host) {
1013
+ if (!existing || typeof existing !== "object") return true;
1014
+ if (host && existing.host && existing.host !== host) return true;
1015
+ if (!existing.suit_profile) return true;
1016
+ if (existing.workspaceRoot && existing.workspaceRoot !== root) return true;
1017
+ if (!existing.stationLine) return true;
1018
+ const liveGit = readGitBrief(root);
1019
+ const bootHead = existing.git && existing.git.head ? String(existing.git.head) : "";
1020
+ if (liveGit && liveGit.head && bootHead !== liveGit.head) return true;
1021
+ const liveResume = buildRepertoireResume(root);
1022
+ if (liveResume && existing.repertoireResume && liveResume !== existing.repertoireResume) return true;
1023
+ if (stationDurableHoldsNpm(root)) return true;
1024
+ const cardIntent = readStationTicketField(root, "Intent");
1025
+ const bootIntent = typeof existing.intent === "string" ? clipIntent(existing.intent) : null;
1026
+ if (cardIntent && bootIntent && cardIntent !== bootIntent) return true;
1027
+ const cardPlan = readStationTicketField(root, "Plan");
1028
+ const bootPlan = typeof existing.planLine === "string" ? clipIntent(existing.planLine) : null;
1029
+ if (cardPlan && bootPlan && cardPlan !== bootPlan) return true;
1030
+ return false;
1031
+ }
1032
+
669
1033
  function stationDurableHoldsNpm(root) {
670
1034
  return readExistingStationMarkdown(root)
671
1035
  .split(/\r?\n/)
@@ -793,14 +1157,29 @@ module.exports = {
793
1157
  readRepertoireWorking,
794
1158
  readOpProcNames,
795
1159
  hydrateDestOnWake,
1160
+ growDestOnWake,
1161
+ heatLiveMemory,
1162
+ withDestLock,
1163
+ slugPrimitiveName,
1164
+ patternsFromGit,
796
1165
  readNotesPickup,
797
1166
  maybeCaptureSessionOnHeadMove,
798
1167
  formatWorkingLine,
799
1168
  applyStationHeat,
1169
+ isStockTicket,
1170
+ readStationTicketField,
1171
+ resolveHeatIntent,
1172
+ resolveHeatPlan,
800
1173
  extractPreservedStationLines,
801
1174
  mergeStationMarkdown,
802
1175
  formatStationMarkdown,
803
1176
  writeStationMarkdown,
804
1177
  isHoldNpmLine,
805
1178
  stationDurableHoldsNpm,
1179
+ stationBootNeedsRefresh,
1180
+ isKeywordDestName,
1181
+ pruneKeywordDest,
1182
+ preferLawHits,
1183
+ retainCompactFields,
1184
+ destLawNameSet,
806
1185
  };
@@ -20,13 +20,28 @@ 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;
26
31
  export const applyStationHeat = impl.applyStationHeat;
32
+ export const isStockTicket = impl.isStockTicket;
33
+ export const readStationTicketField = impl.readStationTicketField;
34
+ export const resolveHeatIntent = impl.resolveHeatIntent;
35
+ export const resolveHeatPlan = impl.resolveHeatPlan;
27
36
  export const extractPreservedStationLines = impl.extractPreservedStationLines;
28
37
  export const mergeStationMarkdown = impl.mergeStationMarkdown;
29
38
  export const formatStationMarkdown = impl.formatStationMarkdown;
30
39
  export const writeStationMarkdown = impl.writeStationMarkdown;
31
40
  export const isHoldNpmLine = impl.isHoldNpmLine;
32
41
  export const stationDurableHoldsNpm = impl.stationDurableHoldsNpm;
42
+ export const stationBootNeedsRefresh = impl.stationBootNeedsRefresh;
43
+ export const isKeywordDestName = impl.isKeywordDestName;
44
+ export const pruneKeywordDest = impl.pruneKeywordDest;
45
+ export const preferLawHits = impl.preferLawHits;
46
+ export const retainCompactFields = impl.retainCompactFields;
47
+ export const destLawNameSet = impl.destLawNameSet;