@ijfw/memory-server 1.5.5 → 1.6.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.
Files changed (72) hide show
  1. package/bin/ijfw-dashboard +20 -1
  2. package/package.json +4 -3
  3. package/src/audit-roster.js +89 -12
  4. package/src/brain/tiered-llm.js +57 -7
  5. package/src/cross-orchestrator-cli.js +344 -4
  6. package/src/cross-project-search.js +39 -1
  7. package/src/dashboard-server.js +7 -1
  8. package/src/dream/runner.mjs +560 -8
  9. package/src/handlers/brain-handler.js +101 -1
  10. package/src/importers/discover.js +1 -1
  11. package/src/memory/bench-metrics.js +289 -0
  12. package/src/memory/benchmark.js +1 -1
  13. package/src/memory/search.js +53 -1
  14. package/src/orchestrator/plan-checker.js +1 -1
  15. package/src/profile/audit.js +671 -0
  16. package/src/profile/capture.js +871 -0
  17. package/src/profile/derive-dialectic.js +242 -0
  18. package/src/profile/derive-heuristic.js +733 -0
  19. package/src/profile/derive.js +156 -0
  20. package/src/profile/egress.js +306 -0
  21. package/src/profile/eval/build-real-probes.mjs +197 -0
  22. package/src/profile/eval/corpus-from-reddit.mjs +166 -0
  23. package/src/profile/eval/corpus-from-reddit.test.mjs +121 -0
  24. package/src/profile/eval/corpus-from-transcripts.mjs +264 -0
  25. package/src/profile/eval/gate-b-behavior.mjs +420 -0
  26. package/src/profile/eval/gate-b-decision-run.mjs +171 -0
  27. package/src/profile/eval/gate-b-decision-run.test.mjs +141 -0
  28. package/src/profile/eval/gate-b-run.mjs +417 -0
  29. package/src/profile/eval/gate-b-run.test.mjs +204 -0
  30. package/src/profile/eval/gate-c-capture.mjs +323 -0
  31. package/src/profile/eval/harness.mjs +551 -0
  32. package/src/profile/eval/instrument-validation.mjs +248 -0
  33. package/src/profile/eval/instrument-validation.test.mjs +125 -0
  34. package/src/profile/eval/multi-subject-harness.mjs +106 -0
  35. package/src/profile/eval/multi-subject-harness.test.mjs +99 -0
  36. package/src/profile/eval/personas.test.mjs +83 -0
  37. package/src/profile/eval/plumbing.test.mjs +69 -0
  38. package/src/profile/eval/prereg.mjs +130 -0
  39. package/src/profile/eval/prereg.test.mjs +78 -0
  40. package/src/profile/eval/real-corpus.test.mjs +103 -0
  41. package/src/profile/eval/real-personas.mjs +109 -0
  42. package/src/profile/eval/run-real-corpus-concurrent.mjs +407 -0
  43. package/src/profile/eval/run-real-corpus.mjs +358 -0
  44. package/src/profile/eval/slug-quality.mjs +464 -0
  45. package/src/profile/eval/stylometry-features.js +85 -0
  46. package/src/profile/eval/stylometry-reference.js +16 -0
  47. package/src/profile/eval/stylometry.js +224 -0
  48. package/src/profile/eval/stylometry.test.mjs +103 -0
  49. package/src/profile/eval/synthetic-personas.js +91 -0
  50. package/src/profile/eval/verifier-features.mjs +170 -0
  51. package/src/profile/eval/verifier-logreg.mjs +74 -0
  52. package/src/profile/eval/verifier-pair.mjs +122 -0
  53. package/src/profile/eval/verifier-reference.mjs +68 -0
  54. package/src/profile/eval/verifier-scorer.mjs +30 -0
  55. package/src/profile/eval/wrong-target-control.mjs +168 -0
  56. package/src/profile/eval/wrong-target-control.test.mjs +124 -0
  57. package/src/profile/exemplar-capture.js +232 -0
  58. package/src/profile/exemplar-retrieve.js +138 -0
  59. package/src/profile/exemplar-store.js +314 -0
  60. package/src/profile/lock.js +64 -0
  61. package/src/profile/merge.js +624 -0
  62. package/src/profile/path-policy.js +213 -0
  63. package/src/profile/precision-stamp.mjs +151 -0
  64. package/src/profile/render-brief.js +717 -0
  65. package/src/profile/schema.js +244 -0
  66. package/src/profile/sensitivity.js +249 -0
  67. package/src/profile/serve.js +345 -0
  68. package/src/profile/store.js +261 -0
  69. package/src/profile/telemetry.js +289 -0
  70. package/src/recovery/checkpoint.js +7 -1
  71. package/src/server.js +185 -14
  72. package/src/.registry-meta-key.pem +0 -3
@@ -34,7 +34,9 @@
34
34
  // - process.exit(0) on every code path -- the parent hook depends on
35
35
  // a clean exit even for cooldown skips.
36
36
 
37
- import { existsSync, mkdirSync, appendFileSync, readFileSync } from 'node:fs';
37
+ import {
38
+ existsSync, mkdirSync, appendFileSync, readFileSync, writeFileSync, renameSync,
39
+ } from 'node:fs';
38
40
  import { join, dirname } from 'node:path';
39
41
  import { fileURLToPath, pathToFileURL } from 'node:url';
40
42
  import { isOnCooldown, markCompleted } from './cooldown.js';
@@ -42,10 +44,31 @@ import { shouldRunNow } from './state-file.js';
42
44
  import { runStages } from './stage-runner.js';
43
45
  import { runDreamCycle } from '../brain/dream-pipeline.js';
44
46
  import { openDb } from '../memory/fts5.js';
47
+ import { deriveProfile } from '../profile/derive.js';
48
+ import { mergeAndWrite as profileMergeAndWrite } from '../profile/merge.js';
49
+ import { toDeriveMeta, computeIdentity } from '../profile/capture.js';
50
+ // S2 runtime wire: stamp `precision_eligible` on derived preference slugs so the
51
+ // serve-path snapshot gate (render-brief.js) is no longer dead code. This module
52
+ // imports eval/ — that is SAFE here because the dream/derive path is already
53
+ // LLM-capable (it imports derive.js). The zero-LLM SERVE path never imports this.
54
+ import { stampPrecisionEligible } from '../profile/precision-stamp.mjs';
55
+ import { readProfile as profileReadProfile } from '../profile/store.js';
45
56
 
46
57
  const __filename = fileURLToPath(import.meta.url);
47
58
  const __dirname = dirname(__filename);
48
59
 
60
+ // Is this module the process entry point, or merely IMPORTED (e.g. by the
61
+ // profile_derive stage test)? When imported, we must NOT parse argv, run the
62
+ // idle gate, or process.exit — we only expose the exported stage helpers. The
63
+ // CLI side effects below are all guarded by `isMain`.
64
+ const IS_MAIN = (() => {
65
+ try {
66
+ return process.argv[1] && pathToFileURL(process.argv[1]).href === import.meta.url;
67
+ } catch {
68
+ return false;
69
+ }
70
+ })();
71
+
49
72
  // ---------------------------------------------------------------------------
50
73
  // argv parsing
51
74
  // ---------------------------------------------------------------------------
@@ -65,9 +88,11 @@ for (let i = 0; i < argv.length; i++) {
65
88
  else if (a === '--session-id') opts.sessionId = argv[++i];
66
89
  }
67
90
 
68
- if (!opts.projectRoot) process.exit(0);
91
+ if (IS_MAIN && !opts.projectRoot) process.exit(0);
69
92
 
70
- const stateDir = join(opts.projectRoot, '.ijfw');
93
+ // When imported (not main) with no --project-root, fall back to cwd so the
94
+ // path joins below never see `null`; the CLI driver is gated on IS_MAIN anyway.
95
+ const stateDir = join(opts.projectRoot || process.cwd(), '.ijfw');
71
96
  const logDir = join(stateDir, 'logs');
72
97
 
73
98
  // ---------------------------------------------------------------------------
@@ -102,7 +127,7 @@ function log(line) {
102
127
  // ---------------------------------------------------------------------------
103
128
 
104
129
  const MIN_IDLE_MIN = Number(process.env.IJFW_DREAM_MIN_IDLE_MIN || 30);
105
- if (!shouldRunNow(opts.projectRoot, { min_idle_minutes: MIN_IDLE_MIN })) {
130
+ if (IS_MAIN && !shouldRunNow(opts.projectRoot, { min_idle_minutes: MIN_IDLE_MIN })) {
106
131
  log(`skip: idle gate <${MIN_IDLE_MIN}min since last run`);
107
132
  process.exit(0);
108
133
  }
@@ -110,11 +135,13 @@ if (!shouldRunNow(opts.projectRoot, { min_idle_minutes: MIN_IDLE_MIN })) {
110
135
  // strictly stricter (30 min) than the old 4h. We still consult it as a
111
136
  // belt-and-braces signal, but ONLY for visibility in the log; it does not
112
137
  // block the run.
113
- if (isOnCooldown(stateDir)) {
138
+ if (IS_MAIN && isOnCooldown(stateDir)) {
114
139
  log(`note: legacy 4h cooldown also active (host=${opts.host}, reason=${opts.reason}) — proceeding under idle gate`);
115
140
  }
116
141
 
117
- log(`start: host=${opts.host}, reason=${opts.reason}, project=${opts.projectRoot}`);
142
+ if (IS_MAIN) {
143
+ log(`start: host=${opts.host}, reason=${opts.reason}, project=${opts.projectRoot}`);
144
+ }
118
145
 
119
146
  // ---------------------------------------------------------------------------
120
147
  // Step 1: D1 tier-promotion (when available)
@@ -360,11 +387,502 @@ function safeJournalSummary() {
360
387
  }
361
388
  }
362
389
 
390
+ // ---------------------------------------------------------------------------
391
+ // Profile derivation (P3.1) — the cross-system profile bus SessionEnd stage.
392
+ // ---------------------------------------------------------------------------
393
+ //
394
+ // This is THE wire connecting capture -> derivation -> merge. At SessionEnd we
395
+ // read the project-local signal JSONLs that the capture layer wrote
396
+ // (<REPO_ROOT>/.ijfw/.session-style.jsonl + .session-feedback.jsonl), build the
397
+ // deriveHeuristic input, run the fallback ladder (deriveProfile: heuristic floor
398
+ // + optional local-LLM dialectic, NEVER silent cloud), and fold the resulting
399
+ // ProfileDelta into the ONE user-global profile via mergeAndWrite under the
400
+ // global profile lock.
401
+ //
402
+ // Best-effort + idempotent: a missing signal file yields a clean no-op delta
403
+ // (never throws); a failure is LOGGED (the audit flagged a swallowed-error
404
+ // class — we surface it) and the dream cycle continues.
405
+
406
+ /**
407
+ * Read a JSONL signal file into an array of parsed rows. Missing file -> [].
408
+ * Corrupt lines are skipped (the rest still parse). Never throws.
409
+ */
410
+ function readJsonlSignals(filePath) {
411
+ const rows = [];
412
+ try {
413
+ if (!existsSync(filePath)) return rows;
414
+ const raw = readFileSync(filePath, 'utf8');
415
+ for (const line of raw.split('\n')) {
416
+ const t = line.trim();
417
+ if (!t) continue;
418
+ try { rows.push(JSON.parse(t)); } catch { /* skip corrupt line */ }
419
+ }
420
+ } catch {
421
+ // unreadable file -> treat as no signal (best-effort)
422
+ }
423
+ return rows;
424
+ }
425
+
426
+ // ---------------------------------------------------------------------------
427
+ // FIX 1 (C1/C2/M3) — per-stream consumption CURSOR (one-fold-per-row).
428
+ //
429
+ // The capture layer only ever APPENDS to .session-style.jsonl /
430
+ // .session-feedback.jsonl. Before this fix, profileDeriveStage re-read the WHOLE
431
+ // file every dream cycle, so a row was folded again on cycle 2,3,…N: its
432
+ // evidence_count inflated (false "confirmed" at >=5) and the per-session
433
+ // anti-drift guarantees were defeated because the same row re-amplified. The fix
434
+ // is a persisted CURSOR (safer than truncation — no data loss on merge failure):
435
+ // each cycle folds ONLY rows newer than the cursor, and the cursor advances ONLY
436
+ // after mergeAndWrite succeeds. If the merge fails, the cursor does NOT move, so
437
+ // the rows are retried next cycle (idempotency holds because the re-read is then
438
+ // benign — the cursor makes a re-fold impossible once the merge has committed).
439
+ //
440
+ // Cursor key per row: a [ts, tiebreaker] pair. Rows are appended chronologically,
441
+ // so "newer than the cursor" = ts strictly greater, OR ts equal AND the
442
+ // tiebreaker sorts strictly after the cursor's tiebreaker. The tiebreaker is the
443
+ // session_id for style rows and a stable content hash for feedback rows (feedback
444
+ // rows carry no id), so two rows that share a ts are still consumed exactly once.
445
+ // ---------------------------------------------------------------------------
446
+
447
+ const CURSOR_FILE = '.profile-derive-cursor.json';
448
+
449
+ function cursorPath(ijfwDir) {
450
+ return join(ijfwDir, CURSOR_FILE);
451
+ }
452
+
453
+ function readCursor(ijfwDir) {
454
+ try {
455
+ const raw = readFileSync(cursorPath(ijfwDir), 'utf8');
456
+ const c = JSON.parse(raw);
457
+ if (c && typeof c === 'object') return c;
458
+ } catch {
459
+ // missing/corrupt cursor -> start from the beginning (fold everything once).
460
+ }
461
+ return {};
462
+ }
463
+
464
+ function writeCursor(ijfwDir, cursor) {
465
+ // Best-effort atomic-ish write: tmp + rename in the same dir. A failed cursor
466
+ // write must NOT throw out of the stage (the merge already committed); worst
467
+ // case the next cycle re-reads already-consumed rows — which is exactly the
468
+ // bug we are fixing, so we log it at the call site when it happens.
469
+ try {
470
+ if (!existsSync(ijfwDir)) mkdirSync(ijfwDir, { recursive: true });
471
+ const tmp = `${cursorPath(ijfwDir)}.tmp.${process.pid}`;
472
+ writeFileSync(tmp, JSON.stringify(cursor), 'utf8');
473
+ renameSync(tmp, cursorPath(ijfwDir)); // atomic replace within the same dir
474
+ return true;
475
+ } catch {
476
+ return false;
477
+ }
478
+ }
479
+
480
+ /** Numeric epoch for a row ts (ISO string or epoch number). NaN -> -Infinity. */
481
+ function rowTsMs(ts) {
482
+ if (typeof ts === 'number' && Number.isFinite(ts)) return ts;
483
+ const t = Date.parse(ts);
484
+ return Number.isFinite(t) ? t : -Infinity;
485
+ }
486
+
487
+ /** Stable, dependency-free content hash for a feedback row tiebreaker. */
488
+ function feedbackKey(row) {
489
+ const s = JSON.stringify([row && row.kind, row && row.phrase, row && row.context]);
490
+ let h = 5381;
491
+ for (let i = 0; i < s.length; i++) h = ((h << 5) + h + s.charCodeAt(i)) | 0;
492
+ return String(h >>> 0);
493
+ }
494
+
495
+ /** Per-row cursor tiebreaker for the S4 edit-delta stream (proposed+committed hash). */
496
+ function editKey(row) {
497
+ const s = JSON.stringify([
498
+ row && row.session_id, row && row.proposed_hash, row && row.committed_hash, row && row.outcome,
499
+ ]);
500
+ let h = 5381;
501
+ for (let i = 0; i < s.length; i++) h = ((h << 5) + h + s.charCodeAt(i)) | 0;
502
+ return String(h >>> 0);
503
+ }
504
+
505
+ /** Session id of a signal row, tolerating both wire spellings. */
506
+ function rowSessionId(row) {
507
+ return (row && (row.session_id || row.sessionId)) || null;
508
+ }
509
+
510
+ /**
511
+ * Harvest the GROUNDED preference gold from the currently-stored global profile:
512
+ * every preference/correction atom that carries an edit-delta citation (value.
513
+ * cited.committed_hash/proposed_hash). These are the user's diff-grounded "real"
514
+ * preferences and seed the precision gate's held-out gold so a feedback slug can
515
+ * corroborate against historical grounded evidence (not just this cycle's). Best-
516
+ * effort: any read error -> empty gold (the gate is fail-closed on empty gold).
517
+ * `readFn` is injectable for test isolation; defaults to the store reader.
518
+ */
519
+ function harvestGroundedGold(readFn) {
520
+ const read = typeof readFn === 'function' ? readFn : profileReadProfile;
521
+ const out = [];
522
+ try {
523
+ const r = read();
524
+ const dialectic = r && r.ok && r.profile && r.profile.global
525
+ ? r.profile.global.dialectic : null;
526
+ if (!Array.isArray(dialectic)) return out;
527
+ for (const inf of dialectic) {
528
+ if (!inf || (inf.kind !== 'preference' && inf.kind !== 'correction')) continue;
529
+ const v = inf.value;
530
+ const grounded = v && typeof v === 'object' && v.cited && typeof v.cited === 'object'
531
+ && (v.cited.committed_hash || v.cited.proposed_hash);
532
+ if (!grounded) continue;
533
+ // Use the same phrase a brief would render: value.phrase if present, else
534
+ // the subject with the scope prefix stripped (scopeKey::citedSlug).
535
+ let phrase = '';
536
+ if (typeof v.phrase === 'string' && v.phrase.trim()) phrase = v.phrase.trim();
537
+ else {
538
+ const subject = String(inf.subject || '');
539
+ const ci = subject.indexOf('::');
540
+ phrase = ci >= 0 ? subject.slice(ci + 2) : subject;
541
+ }
542
+ if (phrase) out.push(phrase);
543
+ }
544
+ } catch {
545
+ // best-effort: a corrupt/absent profile yields no extra gold (fail-closed).
546
+ }
547
+ return out;
548
+ }
549
+
550
+ /**
551
+ * Split `rows` into the UNCONSUMED tail (strictly after `cursor`) and the new
552
+ * cursor that covers ALL of `rows`. `keyOf(row)` returns the per-row tiebreaker.
553
+ * A row is unconsumed when its [ts,key] is strictly greater than the cursor's.
554
+ */
555
+ function splitByCursor(rows, cursor, keyOf) {
556
+ const curTs = cursor && Number.isFinite(rowTsMs(cursor.ts)) ? rowTsMs(cursor.ts) : -Infinity;
557
+ const curKey = cursor && typeof cursor.key === 'string' ? cursor.key : '';
558
+ const fresh = [];
559
+ let maxTs = curTs;
560
+ let maxKey = curKey;
561
+ for (const row of rows) {
562
+ const ts = rowTsMs(row && row.ts);
563
+ const key = String(keyOf(row) || '');
564
+ const isNew = ts > curTs || (ts === curTs && key > curKey);
565
+ if (isNew) fresh.push(row);
566
+ // The advanced cursor is the MAX [ts,key] over ALL rows present this cycle,
567
+ // so even an out-of-order older row never drags the cursor backward.
568
+ if (ts > maxTs || (ts === maxTs && key > maxKey)) { maxTs = ts; maxKey = key; }
569
+ }
570
+ const nextCursor = (Number.isFinite(maxTs) && maxTs > -Infinity)
571
+ ? { ts: new Date(maxTs).toISOString(), key: maxKey }
572
+ : (cursor && cursor.ts ? cursor : null);
573
+ return { fresh, nextCursor };
574
+ }
575
+
576
+ /**
577
+ * profileDeriveStage(params) — read signals -> deriveProfile -> mergeAndWrite.
578
+ * Exported so the integration test can exercise the read->derive->merge wire
579
+ * without spawning the whole runner. Returns the mergeAndWrite result shape
580
+ * `{ ok, code?, message? }`; { ok:true } on a clean no-op (no signal files).
581
+ *
582
+ * @param {object} params
583
+ * @param {string} params.projectRoot REPO_ROOT holding .ijfw/<signals>
584
+ * @param {string} [params.host]
585
+ * @param {string} [params.sessionId]
586
+ * @param {Function} [params.log] best-effort logger (failures LOGGED)
587
+ * @param {string} [params.lockPath] global-lock override (test isolation)
588
+ * @param {object} [params.env] env for the derive ladder
589
+ * @param {Function} [params._mergeAndWrite] inject the merge (tests)
590
+ * @param {Function} [params._localTransport] inject the dialectic local transport (tests)
591
+ */
592
+ export async function profileDeriveStage(params = {}) {
593
+ const root = params.projectRoot;
594
+ // DEFECT 1 fix (live-path): default `env` to the REAL process env when a caller
595
+ // omits it. The production dream entry (runDream's profile_derive stage) used to
596
+ // call this WITHOUT env, so computeIdentity ran over an empty env -> the
597
+ // ambiguous 'UNKNOWN' identity, which can NEVER equal the identity capture
598
+ // stamped from the real process.env -> EVERY style row was rejected as an
599
+ // identity-mismatch and the global profile was never written. Defaulting here
600
+ // makes any env-omitting caller resolve THIS machine's real identity (correct),
601
+ // while PRESERVING the security property: a row stamped by a different
602
+ // machine/user still won't match this machine's identity.
603
+ const env = params.env || process.env;
604
+ const logFn = typeof params.log === 'function' ? params.log : () => {};
605
+ const mergeFn = typeof params._mergeAndWrite === 'function'
606
+ ? params._mergeAndWrite : profileMergeAndWrite;
607
+ if (!root) {
608
+ logFn('profile_derive: no projectRoot -> skip');
609
+ return { ok: true, skipped: 'no-project-root' };
610
+ }
611
+
612
+ const ijfwDir = join(root, '.ijfw');
613
+ const stylePath = join(ijfwDir, '.session-style.jsonl');
614
+ const feedbackPath = join(ijfwDir, '.session-feedback.jsonl');
615
+ const editsPath = join(ijfwDir, '.session-edits.jsonl');
616
+
617
+ const allStyle = readJsonlSignals(stylePath); // RAW per-session wire records
618
+ const allFeedback = readJsonlSignals(feedbackPath); // {ts,kind,phrase,context}
619
+ const allEdits = readJsonlSignals(editsPath); // S4 edit-delta evidence rows
620
+
621
+ if (allStyle.length === 0 && allFeedback.length === 0 && allEdits.length === 0) {
622
+ // Clean no-op: no signals captured this cycle. Do not even touch the global
623
+ // profile — an empty delta would just be a needless lock+write.
624
+ logFn('profile_derive: no signals -> no-op');
625
+ return { ok: true, skipped: 'no-signals' };
626
+ }
627
+
628
+ // FIX 1 (C1/C2/M3) — CONSUME EACH ROW EXACTLY ONCE. Read the per-stream cursor
629
+ // and fold ONLY rows newer than it. The cursor is advanced (persisted) ONLY
630
+ // after the merge commits, below — on merge failure the rows are retried next
631
+ // cycle. This is what stops evidence_count inflating + anti-drift being
632
+ // defeated by re-folding the same append-only rows every dream cycle.
633
+ const cursor = readCursor(ijfwDir);
634
+ const styleSplit = splitByCursor(allStyle, cursor.style, (r) => (r && (r.session_id || r.sessionId)) || '');
635
+ const feedbackSplit = splitByCursor(allFeedback, cursor.feedback, feedbackKey);
636
+ const editsSplit = splitByCursor(allEdits, cursor.edits, editKey);
637
+ const rawStyle = styleSplit.fresh;
638
+ const rawFeedback = feedbackSplit.fresh;
639
+ const rawEdits = editsSplit.fresh;
640
+
641
+ if (rawStyle.length === 0 && rawFeedback.length === 0 && rawEdits.length === 0) {
642
+ // All rows already consumed by a prior (committed) cycle. No-op — and DO NOT
643
+ // re-fold. This is the load-bearing one-fold-per-row guarantee.
644
+ logFn('profile_derive: all rows already consumed (cursor up to date) -> no-op');
645
+ return { ok: true, skipped: 'cursor-up-to-date' };
646
+ }
647
+
648
+ // SEAM 2 + FIX 3 (HIGH-3) — FAIL-CLOSED global eligibility, identity-bound.
649
+ // The capture layer tags every wire record with eligibility gates the global
650
+ // merge MUST honor, or the design's security guarantees are dead on arrival:
651
+ // - global_eligible must be the STRICT boolean `true`. Absent / "false" / 0
652
+ // / any non-boolean => INELIGIBLE (fail-closed). A hand-written or injected
653
+ // JSONL row that omits or forges the flag must NOT reach the global merge.
654
+ // - identity must EQUAL computeIdentity({env}) of THIS machine. We never
655
+ // trust the row's self-asserted identity for cross-machine promotion; a
656
+ // foreign / missing / mismatched identity is excluded (a build agent's or
657
+ // another user's row can't poison the cross-machine profile).
658
+ // - profile_influenced === true => a profile brief was injected into that
659
+ // session, so re-deriving from it would let the profile reinforce itself.
660
+ // Every exclusion is COUNTED + LOGGED (the audit flagged a swallowed-error
661
+ // class — never silent).
662
+ const selfIdentity = computeIdentity({ env }).identity;
663
+ let excludedIneligible = 0;
664
+ let excludedIdentity = 0;
665
+ let excludedInfluenced = 0;
666
+ // SELF-CORROBORATION BARRIER (HIGH-2). Every session that had a profile brief
667
+ // injected (profile_influenced === true) is QUARANTINED: nothing derived from
668
+ // its output may re-corroborate the very preference it was primed with. The set
669
+ // is built from BOTH the style and edit streams (the two that carry the flag)
670
+ // so it is complete; feedback rows are then gated on it by session id below.
671
+ const quarantinedSessions = new Set();
672
+ const eligibleStyle = [];
673
+ for (const row of rawStyle) {
674
+ const r = row && typeof row === 'object' ? row : {};
675
+ if (r.profile_influenced === true) {
676
+ excludedInfluenced += 1;
677
+ const sid = rowSessionId(r);
678
+ if (sid) quarantinedSessions.add(sid);
679
+ continue;
680
+ }
681
+ if (r.global_eligible !== true) { excludedIneligible += 1; continue; } // fail-closed
682
+ if (r.identity !== selfIdentity) { excludedIdentity += 1; continue; } // identity-bound
683
+ eligibleStyle.push(r);
684
+ }
685
+
686
+ // EDIT-DELTA stream (S4 — the CORRECTION LOOP). The edit rows carry the SAME
687
+ // eligibility fields the style rows do (capture.js stamps profile_influenced /
688
+ // global_eligible / identity), so we gate them IDENTICALLY and fail-closed. An
689
+ // edit landed in a profile-influenced session is excluded (and quarantines that
690
+ // session) so an injected preference can never re-corroborate itself via a diff.
691
+ const eligibleEdits = [];
692
+ for (const row of rawEdits) {
693
+ const r = row && typeof row === 'object' ? row : {};
694
+ if (r.profile_influenced === true) {
695
+ excludedInfluenced += 1;
696
+ const sid = rowSessionId(r);
697
+ if (sid) quarantinedSessions.add(sid);
698
+ continue;
699
+ }
700
+ if (r.global_eligible !== true) { excludedIneligible += 1; continue; } // fail-closed
701
+ if (r.identity !== selfIdentity) { excludedIdentity += 1; continue; } // identity-bound
702
+ eligibleEdits.push(r);
703
+ }
704
+
705
+ // FEEDBACK stream. HIGH-2/MED-5 fix: feedback rows now carry session_id +
706
+ // profile_influenced (the pre-prompt writer stamps both). Gate them the SAME
707
+ // fail-closed way as style/edit so a CI/shared-runner or a profile-primed
708
+ // session can never promote: exclude a row that is itself profile_influenced OR
709
+ // whose session is in the quarantine set built above. A row WITHOUT a usable
710
+ // session id is treated as eligible only when it is not self-flagged (legacy
711
+ // wire shape) — the cross-session corroboration barrier (evidence_count >= 3)
712
+ // remains the backstop for any such legacy row.
713
+ let excludedFeedbackInfluenced = 0;
714
+ const eligibleFeedback = [];
715
+ for (const row of rawFeedback) {
716
+ const r = row && typeof row === 'object' ? row : {};
717
+ if (r.profile_influenced === true) { excludedFeedbackInfluenced += 1; continue; }
718
+ const sid = rowSessionId(r);
719
+ if (sid && quarantinedSessions.has(sid)) { excludedFeedbackInfluenced += 1; continue; }
720
+ eligibleFeedback.push(r);
721
+ }
722
+ const feedback = eligibleFeedback;
723
+
724
+ if (excludedIneligible > 0 || excludedIdentity > 0 || excludedInfluenced > 0
725
+ || excludedFeedbackInfluenced > 0) {
726
+ logFn(`profile_derive: excluded ${excludedIneligible} non-eligible (global_eligible!==true) + `
727
+ + `${excludedIdentity} identity-mismatch + ${excludedInfluenced} profile_influenced style/edit rows + `
728
+ + `${excludedFeedbackInfluenced} self-corroborating feedback rows (profile_influenced or quarantined session) `
729
+ + 'from the global merge');
730
+ }
731
+
732
+ // SEAM 1 (field-name mismatch) — the RAW wire records carry `emoji_rate` and
733
+ // `turn_cadence_s`; `deriveStyle` reads `emoji_per_msg` and
734
+ // `turn_cadence_per_min`. capture.js exports `toDeriveMeta` to bridge that
735
+ // unit/name gap. Passing the RAW row straight to derivation silently zeroes
736
+ // the emoji_use + energy axes (deriveStyle sees no signal for them). Map EVERY
737
+ // eligible row through toDeriveMeta so BOTH the heuristic floor (`metadata`,
738
+ // most-recent) AND the dialectic corroboration (`style` array) get the exact
739
+ // shape deriveStyle consumes: avg_msg_chars, emoji_per_msg, code_block_ratio,
740
+ // formality_markers, turn_cadence_per_min.
741
+ const style = eligibleStyle.map((r) => toDeriveMeta(r));
742
+
743
+ // The session ordinal feeds the merge's NON-ADJACENCY gate: it must be a
744
+ // monotonic, per-cycle index so 3 corroborations from 3 spread-out cycles carry
745
+ // 3 distinct (gap-bearing) ordinals, while 3 edits in ONE cycle share one
746
+ // ordinal (cannot self-confirm). Priority: an explicit caller ordinal (tests /
747
+ // a future scheduler that knows the true session index) wins; otherwise a
748
+ // persisted monotonic counter on the cursor. We STEP BY 2 (not 1) per cycle so
749
+ // auto-derived ordinals are NON-ADJACENT by construction: each dream cycle is a
750
+ // distinct SessionEnd separated from the next, so the merge's hasNonAdjacentSpread
751
+ // (which demands a gap > 1) correctly sees real spread rather than rejecting a
752
+ // genuine 3-cycle corroboration as a back-to-back burst. (A single cycle's edits
753
+ // still share ONE ordinal, so an in-session burst can never self-confirm.)
754
+ // Declared here (before advanceCursor) so the all-excluded no-op path records it.
755
+ const sessionOrdinal = Number.isFinite(Number(params.sessionOrdinal))
756
+ ? Number(params.sessionOrdinal)
757
+ : (Number.isFinite(Number(cursor.editOrdinal)) ? Number(cursor.editOrdinal) + 2 : 0);
758
+
759
+ // FIX 1 — advance BOTH stream cursors to cover every row READ this cycle (the
760
+ // splits computed nextCursor over ALL rows present, including excluded ones).
761
+ // Persisted only when called; called after the merge commits, OR on a terminal
762
+ // no-op where the fresh rows were genuinely consumed (all excluded). Returns
763
+ // the cursor-write success so a failed write is observable (logged by caller).
764
+ const advanceCursor = () => {
765
+ const next = { ...cursor };
766
+ if (styleSplit.nextCursor) next.style = styleSplit.nextCursor;
767
+ if (feedbackSplit.nextCursor) next.feedback = feedbackSplit.nextCursor;
768
+ if (editsSplit.nextCursor) next.edits = editsSplit.nextCursor;
769
+ // Persist the per-cycle ordinal ONLY when this cycle actually derived an
770
+ // ordinal AND it advanced the counter (so the next edit-bearing cycle gets a
771
+ // distinct, larger ordinal -> the merge's non-adjacency gate sees real spread,
772
+ // not a self-asserted burst). A caller-supplied ordinal is recorded as-is.
773
+ if (Number.isFinite(sessionOrdinal)) {
774
+ const prev = Number.isFinite(Number(cursor.editOrdinal)) ? Number(cursor.editOrdinal) : -Infinity;
775
+ next.editOrdinal = sessionOrdinal > prev ? sessionOrdinal : prev;
776
+ }
777
+ return writeCursor(ijfwDir, next);
778
+ };
779
+
780
+ if (style.length === 0 && feedback.length === 0 && eligibleEdits.length === 0) {
781
+ // Every fresh row was excluded (non-eligible / identity-mismatch / influenced)
782
+ // and there is no eligible feedback or edit — a clean no-op empty delta, never
783
+ // a throw. The excluded rows WERE consumed this cycle, so advance the cursor:
784
+ // they must not be re-examined (and re-logged) on every future cycle. No merge
785
+ // ran, so no global state changed — advancing here loses nothing.
786
+ if (!advanceCursor()) {
787
+ logFn('profile_derive: WARNING cursor write failed after all-excluded no-op (rows may be re-examined)');
788
+ }
789
+ logFn('profile_derive: all rows excluded and no eligible feedback/edits -> no-op');
790
+ return { ok: true, skipped: 'all-excluded' };
791
+ }
792
+
793
+ // The heuristic floor reads `metadata` for the CURRENT-session style fold; we
794
+ // use the MOST-RECENT eligible (mapped) row as that session's metadata (the
795
+ // full mapped `style` array feeds the dialectic's cross-session corroboration).
796
+ const metadata = style.length ? style[style.length - 1] : undefined;
797
+
798
+ // S4 — thread the edit-delta evidence + a monotonic session ordinal into the
799
+ // derive bundle. deriveHeuristic maps eligible edit-after rows to grounded
800
+ // `correction` inferences (deriveEditPreferences) and routes accept/edit-after
801
+ // into expertise (editOutcomes); the merge's admission gate then enforces
802
+ // non-adjacency (>= 3 spread-out sessions) + contradiction-flips-with-history.
803
+ // The ordinal is derived from the row ts ordering so "3 corroborations across
804
+ // 3 spread-out sessions" (confirm) is distinguishable from "3 edits back to
805
+ // back" (do NOT confirm) WITHOUT trusting a self-asserted ordinal.
806
+ const edits = eligibleEdits;
807
+
808
+ const signals = {
809
+ metadata,
810
+ style,
811
+ feedback,
812
+ edits,
813
+ sessionId: params.sessionId,
814
+ sessionOrdinal,
815
+ host: params.host,
816
+ };
817
+
818
+ let delta;
819
+ try {
820
+ delta = await deriveProfile(signals, {
821
+ env,
822
+ log: (m) => logFn(`profile_derive: ${m}`),
823
+ _localTransport: params._localTransport,
824
+ _cloudTransport: params._cloudTransport,
825
+ });
826
+ } catch (err) {
827
+ // deriveProfile is contracted never to throw, but defend anyway: surface +
828
+ // continue rather than crash the dream cycle.
829
+ logFn(`profile_derive: derive failed (${err && err.message ? err.message : err})`);
830
+ return { ok: false, code: 'EDERIVE', message: String(err && err.message ? err.message : err) };
831
+ }
832
+
833
+ // S2 PRECISION GATE (the dead-code fix) — STAMP `precision_eligible` on every
834
+ // derived preference/correction slug BEFORE it can be merged + served. The gold
835
+ // is the user's OWN edit-delta-grounded corrections (this cycle's delta + any
836
+ // already-confirmed grounded corrections on the stored profile), so a slug only
837
+ // clears when it semantically matches a diff-grounded preference. A noise slug
838
+ // ("not to deal with this garbage") matches nothing grounded -> stamped false ->
839
+ // can never reach the brief. Fail-closed + never throws (see precision-stamp).
840
+ try {
841
+ const grounded = harvestGroundedGold(params._readProfile);
842
+ stampPrecisionEligible(delta, { goldPhrases: grounded });
843
+ } catch (err) {
844
+ // A stamping failure must NOT crash the cycle; the snapshot gate is itself
845
+ // fail-closed (absent flag => held back), so an unstamped delta degrades to
846
+ // "nothing injects" — safe, not silently permissive.
847
+ logFn(`profile_derive: precision stamp degraded (${err && err.message ? err.message : err})`);
848
+ }
849
+
850
+ const mergeOpts = {};
851
+ if (params.lockPath) mergeOpts.lockPath = params.lockPath;
852
+ let res;
853
+ try {
854
+ res = await mergeFn(delta, mergeOpts);
855
+ } catch (err) {
856
+ logFn(`profile_derive: merge threw (${err && err.message ? err.message : err})`);
857
+ return { ok: false, code: 'EMERGE', message: String(err && err.message ? err.message : err) };
858
+ }
859
+ if (!res || !res.ok) {
860
+ // The audit found a swallowed-error class bug — do NOT repeat it. LOG the
861
+ // failure (with code) and surface it; the caller (stage runner) continues.
862
+ // FIX 1 — the cursor is NOT advanced on a failed merge, so the same rows are
863
+ // retried next cycle (no data loss; re-read is benign because nothing was
864
+ // committed). This is the whole reason a cursor is safer than truncation.
865
+ logFn(`profile_derive: merge failed code=${res && res.code} msg=${res && res.message}`);
866
+ return res || { ok: false, code: 'EMERGE' };
867
+ }
868
+
869
+ // FIX 1 — the merge COMMITTED. Advance the cursor so these rows are NEVER
870
+ // folded again on a subsequent cycle (the one-fold-per-row guarantee). A cursor
871
+ // write failure is logged but does not fail the stage — the merge already
872
+ // succeeded; worst case the next cycle re-reads (idempotency degrades to the
873
+ // old behaviour only for this window, and only if the FS write failed).
874
+ if (!advanceCursor()) {
875
+ logFn('profile_derive: WARNING cursor write failed after a committed merge (rows may be re-folded next cycle)');
876
+ }
877
+ logFn(`profile_derive: merged delta (style=${style.length} feedback=${feedback.length} inferences=${(delta.inferences || []).length})`);
878
+ return res;
879
+ }
880
+
363
881
  // ---------------------------------------------------------------------------
364
882
  // Driver
365
883
  // ---------------------------------------------------------------------------
366
884
 
367
- (async () => {
885
+ async function runDream() {
368
886
  try {
369
887
  // M4 hardening: lift each step into a stage-runner stage so a failure
370
888
  // in one stage logs + continues; downstream stages still execute.
@@ -404,6 +922,33 @@ function safeJournalSummary() {
404
922
  }
405
923
  },
406
924
  },
925
+ {
926
+ // P3.1 — the cross-system profile bus. Read SessionEnd signal JSONLs ->
927
+ // deriveProfile (heuristic floor + optional local dialectic) ->
928
+ // mergeAndWrite into the user-global profile under the global lock.
929
+ // Best-effort: a failure is recorded in `extras.error` (surfaced by the
930
+ // stage runner) and the cycle continues; it never throws out.
931
+ name: 'profile_derive',
932
+ run: async () => {
933
+ const res = await profileDeriveStage({
934
+ projectRoot: opts.projectRoot,
935
+ host: opts.host,
936
+ sessionId: opts.sessionId,
937
+ // DEFECT 1 fix: pass the REAL process env so the identity-bound
938
+ // eligibility gate computes THIS machine's identity (matching the
939
+ // identity capture stamped at flush time). profileDeriveStage now
940
+ // also defaults env to process.env when omitted, but we pass it
941
+ // explicitly here so the production path is self-documenting.
942
+ env: process.env,
943
+ log,
944
+ });
945
+ if (res && res.skipped) return { skipped: res.skipped };
946
+ if (!res || !res.ok) {
947
+ return { error: `profile merge failed: ${res && res.code ? res.code : 'unknown'}` };
948
+ }
949
+ return { ok: true };
950
+ },
951
+ },
407
952
  {
408
953
  name: 'mark_completed_legacy',
409
954
  run: async () => {
@@ -427,4 +972,11 @@ function safeJournalSummary() {
427
972
  } finally {
428
973
  process.exit(0);
429
974
  }
430
- })();
975
+ }
976
+
977
+ // Run the dream cycle ONLY when this module is the process entry point. When
978
+ // imported (e.g. by the profile_derive stage test), the exported helpers are
979
+ // available without triggering the CLI driver / process.exit.
980
+ if (IS_MAIN) {
981
+ runDream();
982
+ }