@ijfw/memory-server 1.5.6 → 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
@@ -450,6 +450,26 @@ function parseArgsInner(args) {
450
450
  return { cmd: 'recover', sub: args[1] || 'status' };
451
451
  }
452
452
 
453
+ // v1.6.0 CLI-honesty — top-level aliases so the documented bare verbs route
454
+ // instead of returning "Unknown command". `checkpoint` forwards to
455
+ // `memory checkpoint`; `worktree` forwards to `swarm worktree`. The canonical
456
+ // namespaced forms still work; these are thin shortcuts (see command-registry).
457
+ if (args[0] === 'checkpoint') {
458
+ return { cmd: 'memory-checkpoint', label: args[1] || 'manual' };
459
+ }
460
+ if (args[0] === 'worktree') {
461
+ return { cmd: 'worktree', sub: args[1] || 'list', rest: args.slice(2) };
462
+ }
463
+
464
+ // v1.6.0 Profile-bus learning control. Subcommands:
465
+ // on | off -> set inject = on | off
466
+ // status | show -> print flags + a summary of what was inferred
467
+ // forget [id|pattern] -> delete inferences (right-to-be-forgotten)
468
+ // share-sensitive on|off-> toggle the med/high-sensitivity host allowlist
469
+ if (args[0] === 'personalize') {
470
+ return { cmd: 'personalize', sub: args[1] || 'status', rest: args.slice(2) };
471
+ }
472
+
453
473
  if (args[0] === 'receipt') {
454
474
  return { cmd: 'receipt', sub: args[1] || 'last' };
455
475
  }
@@ -563,8 +583,247 @@ Related:
563
583
  `.trim());
564
584
  }
565
585
 
586
+ // v1.6.0 CLI-honesty — "did you mean?" engine. Two suggestion sources:
587
+ // (1) a curated map of documented-but-bare verbs to their real home
588
+ // (subcommands of another verb, or "not available in this release"),
589
+ // (2) nearest routed verb by Levenshtein edit-distance over the known set.
590
+ // The curated map wins; edit-distance is the fallback. The goal: NO advertised
591
+ // verb ever returns a bare "Unknown command" — every miss is guided.
592
+
593
+ // Curated redirects for verbs users find in old docs / muscle memory. `to` is a
594
+ // suggested command; `note` (optional) explains availability.
595
+ const VERB_REDIRECTS = Object.freeze({
596
+ checkpoint: { to: 'ijfw checkpoint <label> (or: ijfw memory checkpoint <label>)' },
597
+ worktree: { to: 'ijfw worktree <create|list|integrate|cleanup> (or: ijfw swarm worktree …)' },
598
+ 'worktree-drain': { to: 'ijfw swarm worktree cleanup <task-id>', note: 'there is no bulk "drain"; clean up worktrees per task.' },
599
+ 'wave-status': { to: 'ijfw swarm status', note: 'swarm progress (waves, ready/blocked) is shown by `ijfw swarm status`.' },
600
+ on: { to: 'ijfw install (to set up IJFW) · ijfw personalize on (to enable profile injection)', note: '`on` is not a verb. `off` removes IJFW; it has no symmetric reinstall toggle.' },
601
+ marketplace: { to: null, note: 'not available in this release. Install via `ijfw install` or your agent\'s native plugin marketplace UI.' },
602
+ cluster: { to: null, note: 'multi-machine cluster mode is a design milestone, not shipped in this release.' },
603
+ consent: { to: 'ijfw personalize status', note: 'memory/profile consent is managed via `ijfw personalize` and the memory-consent skill.' },
604
+ });
605
+
606
+ // Every name the orchestrator can actually dispatch (canonical + registry
607
+ // aliases + the bare top-level verbs the parser recognizes). Used as the
608
+ // edit-distance candidate pool.
609
+ function knownVerbNames() {
610
+ const names = new Set();
611
+ for (const e of COMMAND_REGISTRY) {
612
+ if (e.status === 'deprecated') continue;
613
+ names.add(e.name);
614
+ for (const a of (e.aliases || [])) names.add(a);
615
+ }
616
+ // Bare verbs the parser handles that may not be registry entries.
617
+ for (const v of ['status', 'help', 'env', 'recover', 'receipt', 'personalize',
618
+ 'checkpoint', 'worktree', 'memory', 'swarm', 'team',
619
+ 'blackboard', 'codex', 'insight', 'cross']) names.add(v);
620
+ // Drop flag-style + alias-only noise that would never be a useful suggestion.
621
+ names.delete('--purge-receipts');
622
+ return [...names];
623
+ }
624
+
625
+ // Classic iterative Levenshtein. Bounded inputs (CLI verbs), O(n*m) is fine.
626
+ function editDistance(a, b) {
627
+ a = String(a); b = String(b);
628
+ const m = a.length, n = b.length;
629
+ if (m === 0) return n;
630
+ if (n === 0) return m;
631
+ let prev = Array.from({ length: n + 1 }, (_, j) => j);
632
+ let cur = Array.from({ length: n + 1 });
633
+ for (let i = 1; i <= m; i++) {
634
+ cur[0] = i;
635
+ for (let j = 1; j <= n; j++) {
636
+ const cost = a[i - 1] === b[j - 1] ? 0 : 1;
637
+ cur[j] = Math.min(prev[j] + 1, cur[j - 1] + 1, prev[j - 1] + cost);
638
+ }
639
+ [prev, cur] = [cur, prev];
640
+ }
641
+ return prev[n];
642
+ }
643
+
644
+ // Suggest the single nearest routed verb (prefix match preferred, then edit
645
+ // distance). Returns null when nothing is close enough to be helpful.
646
+ export function nearestVerb(raw, candidates = knownVerbNames()) {
647
+ const q = String(raw || '').toLowerCase();
648
+ if (!q) return null;
649
+ // Prefix / containment match first (cheap + high-signal).
650
+ const pref = candidates.filter(c => c.startsWith(q) || q.startsWith(c));
651
+ if (pref.length) {
652
+ return pref.sort((a, b) => Math.abs(a.length - q.length) - Math.abs(b.length - q.length))[0];
653
+ }
654
+ let best = null, bestD = Infinity;
655
+ for (const c of candidates) {
656
+ const d = editDistance(q, c.toLowerCase());
657
+ if (d < bestD) { bestD = d; best = c; }
658
+ }
659
+ // Only suggest when reasonably close: <= 2 edits, or <= 40% of the longer.
660
+ const tol = Math.max(2, Math.floor(0.4 * Math.max(q.length, (best || '').length)));
661
+ return bestD <= tol ? best : null;
662
+ }
663
+
664
+ // ---------------------------------------------------------------------------
665
+ // Profile-bus learning control — `ijfw personalize` (v1.6.0)
666
+ // ---------------------------------------------------------------------------
667
+ // MOAT NOTE: every profile module touched here (serve.js, audit.js,
668
+ // sensitivity.js) is ZERO-LLM by construction (the P4.5 import-graph guard
669
+ // proves serve.js never reaches the LLM tier). We import them DYNAMICALLY so
670
+ // the top-of-file static import graph of this CLI stays clean and the
671
+ // profile-moat-guard's transitive-import walk is never widened.
672
+
673
+ const PERSONALIZE_CONSENT_VERSION = '1.6.0';
674
+
675
+ function printPersonalizeHelp() {
676
+ console.log(`
677
+ ijfw personalize -- control the profile-bus learning feature
678
+
679
+ What it does: IJFW can learn your low-sensitivity interaction STYLE (verbosity,
680
+ formality, code-vs-prose) locally and, only if you allow it, include a short
681
+ brief in prompts so agents match your style. Capture is always local and never
682
+ includes raw text. Injection is OFF until you turn it on.
683
+
684
+ Usage:
685
+ ijfw personalize status Show current flags + a summary of what was
686
+ inferred (alias: ijfw personalize show).
687
+ ijfw personalize on Enable injecting the learned style brief.
688
+ ijfw personalize off Disable injection (capture continues locally).
689
+ ijfw personalize forget [pattern] Delete inferences. No pattern = forget ALL.
690
+ ijfw personalize share-sensitive on|off
691
+ Allow/deny med+high-sensitivity prefs to
692
+ leave to allowlisted hosts. Default off.
693
+
694
+ Hard override: set IJFW_PROFILE_KILL=1 to force-disable ALL injection
695
+ regardless of these settings.
696
+ `.trim());
697
+ }
698
+
699
+ async function cmdPersonalize(sub, rest = []) {
700
+ const settings = resolveProfileSettings();
701
+ const killed = isTruthyEnv(process.env.IJFW_PROFILE_KILL);
702
+
703
+ if (sub === 'on' || sub === 'off') {
704
+ const next = writeProfileSettings({
705
+ inject: sub,
706
+ // First explicit on/off resolves the "ask" consent permanently.
707
+ inject_consent_version: PERSONALIZE_CONSENT_VERSION,
708
+ });
709
+ if (sub === 'on') {
710
+ console.log('Profile injection ENABLED. Your learned low-sensitivity style brief will be added to prompts.');
711
+ if (killed) console.log('Note: IJFW_PROFILE_KILL is set, so injection stays OFF until you unset it.');
712
+ console.log('Turn it off anytime with: ijfw personalize off');
713
+ } else {
714
+ console.log('Profile injection DISABLED. Capture continues locally; nothing is added to prompts.');
715
+ }
716
+ console.log(`(profile.inject = ${next.inject})`);
717
+ return;
718
+ }
719
+
720
+ if (sub === 'share-sensitive') {
721
+ const val = rest[0];
722
+ if (val !== 'on' && val !== 'off') {
723
+ console.error('Usage: ijfw personalize share-sensitive on|off');
724
+ process.exit(1);
725
+ }
726
+ const enable = val === 'on';
727
+ writeProfileSettings({ share_sensitive: enable });
728
+ // The serve-path allowlist is the SECOND gate (per-host). Toggling on writes
729
+ // a `*` wildcard line; toggling off removes it. This is the auditable file
730
+ // sensitivity.js reads (share-hosts.txt). We import its path helper lazily.
731
+ try {
732
+ const { shareHostsFilePath } = await import('./profile/sensitivity.js');
733
+ const p = shareHostsFilePath();
734
+ if (enable) {
735
+ mkdirSync(dirname(p), { recursive: true, mode: 0o700 });
736
+ writeAtomic(p, '# Hosts allowed to receive medium/high-sensitivity profile fields.\n# Managed by `ijfw personalize share-sensitive`. `*` = all hosts.\n*\n', { mode: 0o600 });
737
+ console.log('Sensitive sharing ENABLED for all hosts (wildcard allowlist written).');
738
+ console.log(`Allowlist: ${p} — edit it to restrict to specific hosts.`);
739
+ } else {
740
+ try { if (existsSync(p)) rmSync(p, { force: true }); } catch { /* */ }
741
+ console.log('Sensitive sharing DISABLED. Only low-sensitivity style can ever be injected.');
742
+ }
743
+ } catch (e) {
744
+ console.error(`Wrote the flag, but could not update the host allowlist: ${e.message}`);
745
+ process.exit(1);
746
+ }
747
+ return;
748
+ }
749
+
750
+ if (sub === 'forget') {
751
+ const pattern = rest[0];
752
+ try {
753
+ const { forgetAndWrite } = await import('./profile/audit.js');
754
+ // No pattern => forget everything (a bare `*` matcher is rejected by the
755
+ // ReDoS guard, so we pass a catch-all regex the validator accepts).
756
+ const arg = pattern && pattern.trim() ? pattern.trim() : /.*/;
757
+ const r = await forgetAndWrite(arg);
758
+ if (!r || !r.ok) {
759
+ console.error(`Forget failed: ${(r && r.message) || (r && r.code) || 'unknown error'}`);
760
+ process.exit(1);
761
+ }
762
+ const n = (r.removed || []).length;
763
+ console.log(`Forgot ${n} inference${n === 1 ? '' : 's'}${pattern ? ` matching "${pattern}"` : ' (all)'}.`);
764
+ if (r.egressRemoved) console.log(`Also cleared ${r.egressRemoved} egress-log entr${r.egressRemoved === 1 ? 'y' : 'ies'}.`);
765
+ if (n === 0) console.log('Nothing matched — the profile may already be empty.');
766
+ } catch (e) {
767
+ console.error(`Forget failed: ${e.message}`);
768
+ process.exit(1);
769
+ }
770
+ return;
771
+ }
772
+
773
+ if (sub === 'status' || sub === 'show') {
774
+ const effective = killed ? 'off (forced by IJFW_PROFILE_KILL)' : settings.inject;
775
+ console.log('IJFW personalize -- profile-bus status');
776
+ console.log('');
777
+ console.log(` capture ${settings.capture} (local style metadata; never raw text)`);
778
+ console.log(` inject ${settings.inject}${settings.inject === 'ask' ? ' (default — not injecting until you run: ijfw personalize on)' : ''}`);
779
+ console.log(` effective inject ${effective}`);
780
+ console.log(` share_sensitive ${settings.share_sensitive} (med/high prefs ${settings.share_sensitive ? 'MAY' : 'will NOT'} leave)`);
781
+ console.log(` consent ${settings.inject_consent_version ? `resolved (v${settings.inject_consent_version})` : 'not yet asked'}`);
782
+ console.log('');
783
+ // Summary of what was inferred (zero-LLM audit path).
784
+ try {
785
+ const { profileGet } = await import('./profile/serve.js');
786
+ const r = profileGet({ context: { host: 'cli-personalize' }, env: process.env });
787
+ const styleN = r && r.profile ? Object.keys(r.profile.style || {}).length : 0;
788
+ const expN = r && r.profile ? Object.keys(r.profile.expertise || {}).length : 0;
789
+ const infN = r && r.profile ? (r.profile.inferences || []).length : 0;
790
+ console.log(` Learned so far: ${styleN} style ${styleN === 1 ? 'axis' : 'axes'}, ${expN} expertise domain${expN === 1 ? '' : 's'}, ${infN} inference${infN === 1 ? '' : 's'}.`);
791
+ if (styleN + expN + infN === 0) {
792
+ console.log(' (Nothing confirmed yet — keep using IJFW and a style profile builds over sessions.)');
793
+ } else {
794
+ console.log(' Inspect everything with: ijfw_brain { "verb": "profile.audit" } (or via the memory-audit skill).');
795
+ console.log(' Delete anything with: ijfw personalize forget [pattern]');
796
+ }
797
+ } catch (e) {
798
+ console.log(` (Profile summary unavailable: ${e.message})`);
799
+ }
800
+ return;
801
+ }
802
+
803
+ if (sub === '--help' || sub === '-h' || sub === 'help') {
804
+ printPersonalizeHelp();
805
+ return;
806
+ }
807
+
808
+ console.error(`Unknown personalize subcommand: ${sub}`);
809
+ console.error('');
810
+ printPersonalizeHelp();
811
+ process.exit(1);
812
+ }
813
+
566
814
  function printUnknownCommand(raw) {
567
815
  console.error(`Unknown command: ${raw}`);
816
+ const key = String(raw || '').toLowerCase();
817
+ const redirect = Object.prototype.hasOwnProperty.call(VERB_REDIRECTS, key)
818
+ ? VERB_REDIRECTS[key]
819
+ : null;
820
+ if (redirect) {
821
+ if (redirect.to) console.error(`Did you mean: ${redirect.to}`);
822
+ if (redirect.note) console.error(`Note: ${redirect.note}`);
823
+ } else {
824
+ const near = nearestVerb(key);
825
+ if (near) console.error(`Did you mean: ijfw ${near}`);
826
+ }
568
827
  console.error('Run `ijfw --help` for the user-facing command list, or `ijfw commands` for the full surface.');
569
828
  }
570
829
 
@@ -1521,6 +1780,70 @@ function cmpSemver(a, b) {
1521
1780
  function readState() { return readJsonSafe(join(ijfwHome(), 'state.json')) || {}; }
1522
1781
  function readSettings() { return readJsonSafe(join(ijfwHome(), 'settings.json')) || {}; }
1523
1782
 
1783
+ // ---------------------------------------------------------------------------
1784
+ // Profile-bus settings (v1.6.0) — consent + control surface.
1785
+ // ---------------------------------------------------------------------------
1786
+ // The `profile` block predates 1.6 in some installs; resolveProfileSettings()
1787
+ // backfills the documented defaults on read so the rest of the CLI + the hooks
1788
+ // never have to defend against a missing block. Defaults (design-locked):
1789
+ // capture = "on" (local, low-sensitivity metadata only; never raw)
1790
+ // inject = "ask" (do NOT silently inject — ask once, default off)
1791
+ // share_sensitive= false (med/high prefs require an explicit opt-in)
1792
+ const PROFILE_DEFAULTS = Object.freeze({
1793
+ capture: 'on',
1794
+ inject: 'ask',
1795
+ share_sensitive: false,
1796
+ inject_consent_version: null,
1797
+ });
1798
+
1799
+ function resolveProfileSettings(settings = readSettings()) {
1800
+ const p = (settings && typeof settings.profile === 'object' && settings.profile) || {};
1801
+ return {
1802
+ capture: p.capture === 'off' ? 'off' : 'on',
1803
+ inject: (p.inject === 'on' || p.inject === 'off') ? p.inject : 'ask',
1804
+ share_sensitive: p.share_sensitive === true,
1805
+ inject_consent_version: typeof p.inject_consent_version === 'string'
1806
+ ? p.inject_consent_version : null,
1807
+ };
1808
+ }
1809
+
1810
+ // resolveProfileInject(opts) -> 'on' | 'off' | 'ask'.
1811
+ // The single decision the inject GATE consults. The hard kill-switch
1812
+ // (IJFW_PROFILE_KILL) ALWAYS wins and forces 'off' — capture is never gated by
1813
+ // it, only injection. Otherwise the settings block decides.
1814
+ function isTruthyEnv(v) {
1815
+ if (v === undefined || v === null) return false;
1816
+ const s = String(v).trim().toLowerCase();
1817
+ return s !== '' && s !== '0' && s !== 'false' && s !== 'no' && s !== 'off';
1818
+ }
1819
+ export function resolveProfileInject({ settings, env = process.env } = {}) {
1820
+ if (isTruthyEnv(env.IJFW_PROFILE_KILL)) return 'off';
1821
+ return resolveProfileSettings(settings || readSettings()).inject;
1822
+ }
1823
+
1824
+ // Persist the resolved `profile` block under the same single-writer lock the
1825
+ // state file uses, so a concurrent `ijfw personalize` and an `ijfw update`
1826
+ // can't clobber each other's settings.json. Merges into the existing file
1827
+ // (never drops unrelated keys) and writes atomically with 0600.
1828
+ function writeProfileSettings(patch) {
1829
+ const path = join(ijfwHome(), 'settings.json');
1830
+ const apply = () => {
1831
+ const cur = readJsonSafe(path) || {};
1832
+ const profile = { ...PROFILE_DEFAULTS, ...resolveProfileSettings(cur), ...patch };
1833
+ const next = { ...cur, profile };
1834
+ writeAtomic(path, JSON.stringify(next, null, 2) + '\n', { mode: 0o600 });
1835
+ return profile;
1836
+ };
1837
+ try {
1838
+ return withStateLockSync(apply);
1839
+ } catch {
1840
+ // Lock acquisition failed (e.g. EACCES on the lock dir) — fall back to a
1841
+ // best-effort direct write rather than refusing to persist the user's
1842
+ // explicit consent choice.
1843
+ return apply();
1844
+ }
1845
+ }
1846
+
1524
1847
  // v1.5.0 audit M11 (F-REL-1): writeStateFields was best-effort — readState +
1525
1848
  // merge + writeAtomic was a TOCTOU window where a parallel `ijfw update`
1526
1849
  // completion could clobber another writer's `last_applied_version`. If the
@@ -2588,6 +2911,13 @@ if (isMainModule) {
2588
2911
  cmdEnv();
2589
2912
  } else if (parsed.cmd === 'recover') {
2590
2913
  cmdRecover(parsed.sub);
2914
+ } else if (parsed.cmd === 'personalize') {
2915
+ cmdPersonalize(parsed.sub, parsed.rest || []).catch(err => { console.error(err.message); process.exit(1); });
2916
+ } else if (parsed.cmd === 'worktree') {
2917
+ // Top-level alias forwarding to `swarm worktree`. cmdSwarmWorktree reads its
2918
+ // own sub + args from the parsed tail (not process.argv) so the alias and
2919
+ // the canonical `swarm worktree` path stay identical.
2920
+ cmdSwarmWorktree(parsed.sub, parsed.rest || []);
2591
2921
  } else {
2592
2922
  // v1.5.1 W1.D+E: clean unknown-command message; no stale usage dump.
2593
2923
  printUnknownCommand(parsed.raw);
@@ -2652,12 +2982,22 @@ function cmdPreflight() {
2652
2982
  process.exit(res.status ?? 1);
2653
2983
  }
2654
2984
  function cmdDashboard(sub) {
2655
- const script = findCliAsset('scripts', 'dashboard', 'bin.js');
2656
- if (!script) {
2657
- console.error('dashboard/bin.js not found. Run `ijfw-install` to deploy ~/.ijfw/, or set IJFW_HOME to your IJFW tree.');
2985
+ // `ijfw dashboard start|stop|status` is the localhost SSE web dashboard
2986
+ // documented in the README (binds 127.0.0.1:37891, port-walks to 37900).
2987
+ // It is served by mcp-server/bin/ijfw-dashboard, NOT scripts/dashboard/bin.js
2988
+ // (that one is the terminal text digest, surfaced via `ijfw status`). Routing
2989
+ // `dashboard` at the text renderer silently no-op'd start/stop/status: the
2990
+ // web server never bound and stop/status never reported true state.
2991
+ const launcher = findCliAsset('mcp-server', 'bin', 'ijfw-dashboard');
2992
+ if (!launcher) {
2993
+ console.error('Dashboard server not found. Run `ijfw-install` to deploy ~/.ijfw/, or set IJFW_HOME to your IJFW tree.');
2658
2994
  process.exit(1);
2659
2995
  }
2660
- const res = spawnSync(process.execPath, [script, sub], { stdio: 'inherit' });
2996
+ // Forward the verb plus any passthrough flags (--no-open, --port N) supplied
2997
+ // after `dashboard` on the command line, so the launcher's own argv handling
2998
+ // stays authoritative. argv = [node, cli, 'dashboard', <sub>, ...flags].
2999
+ const passthrough = process.argv.slice(4);
3000
+ const res = spawnSync(process.execPath, [launcher, sub, ...passthrough], { stdio: 'inherit' });
2661
3001
  process.exit(res.status ?? 1);
2662
3002
  }
2663
3003
 
@@ -23,10 +23,37 @@
23
23
  // the only prior validation and was insufficient.
24
24
 
25
25
  import { basename, resolve, join } from 'node:path';
26
- import { realpathSync, statSync } from 'node:fs';
26
+ import { realpathSync, statSync, readFileSync } from 'node:fs';
27
27
  import { homedir } from 'node:os';
28
28
  import { searchCorpus } from './search-bm25.js';
29
29
 
30
+ // --- Tenant isolation (P4) ----------------------------------------------------
31
+ // cross-project search must not surface one tenant's memory in another tenant's
32
+ // session (e.g. a contractor running two companies on one machine). Isolation is
33
+ // OPT-IN and migration-free: a project declares its tenant in `.ijfw/tenant`
34
+ // (first non-empty line). Absent => DEFAULT_TENANT, so single-tenant users see
35
+ // exactly the prior behavior (every project is 'default', so all match).
36
+
37
+ export const DEFAULT_TENANT = 'default';
38
+
39
+ /** Read a project's declared tenant from `<path>/.ijfw/tenant`.
40
+ * Returns the first non-empty trimmed line, or DEFAULT_TENANT when the file is
41
+ * absent/unreadable/empty. Never throws. */
42
+ export function resolveProjectTenant(canonicalProjectPath) {
43
+ try {
44
+ const raw = readFileSync(join(canonicalProjectPath, '.ijfw', 'tenant'), 'utf8');
45
+ const line = raw.split('\n').map((s) => s.trim()).find((s) => s.length > 0);
46
+ return line || DEFAULT_TENANT;
47
+ } catch {
48
+ return DEFAULT_TENANT;
49
+ }
50
+ }
51
+
52
+ function normTenant(t) {
53
+ const s = (t == null ? '' : String(t)).trim();
54
+ return s.length ? s : DEFAULT_TENANT;
55
+ }
56
+
30
57
  // v1.5.0 audit MED #10 (memory-engine.md F-SPD-2): per-project corpus
31
58
  // cache keyed on (canonicalProjectPath, signature) where `signature` is
32
59
  // the joined mtimes of the project's memory files. As long as nothing
@@ -182,12 +209,23 @@ export function buildCorpus(projects, readProjectMemory, opts = {}) {
182
209
  const allowedRoots = Array.isArray(opts.allowedRoots) && opts.allowedRoots.length
183
210
  ? opts.allowedRoots
184
211
  : defaultAllowedRoots();
212
+ // Tenant gate (P4): only surface projects in the CALLER's tenant. Caller tenant
213
+ // defaults to 'default'; candidate tenant is read from each project's
214
+ // .ijfw/tenant (injectable for tests via opts.resolveTenant). Default==default
215
+ // means no behavior change until a user opts in by declaring tenants.
216
+ const callerTenant = normTenant(opts.tenant);
217
+ const tenantOf = typeof opts.resolveTenant === 'function'
218
+ ? (p) => normTenant(opts.resolveTenant(p))
219
+ : resolveProjectTenant;
185
220
  const docs = [];
186
221
  for (const entry of projects) {
187
222
  if (!entry || typeof entry !== 'object') continue;
188
223
  const canonical = safeResolveProjectPath(entry.path, allowedRoots);
189
224
  if (canonical === null) continue; // skipped (symlink-escape or missing)
190
225
 
226
+ // Tenant isolation: skip projects that belong to a different tenant.
227
+ if (tenantOf(canonical) !== callerTenant) continue;
228
+
191
229
  // v1.5.0 audit MED #10: try the mtime cache before re-reading.
192
230
  // The cache is opt-out via opts.useCache=false (tests / consistency runs).
193
231
  const useCache = opts.useCache !== false;
@@ -1481,7 +1481,13 @@ if (process.argv.includes('--daemon')) {
1481
1481
  const pidFile = process.env.IJFW_PID_FILE || join(homedir(), '.ijfw', 'dashboard.pid');
1482
1482
  const portFile = process.env.IJFW_PORT_FILE || join(homedir(), '.ijfw', 'dashboard.port');
1483
1483
 
1484
- startServer().then(({ port }) => {
1484
+ // Optional preferred-port override (forwarded by the launcher's `--port N`).
1485
+ // Unset = default 37891-37900 walk. Invalid values fall back to the default.
1486
+ const startOpts = {};
1487
+ const envPort = Number.parseInt(process.env.IJFW_DASHBOARD_PORT || '', 10);
1488
+ if (Number.isInteger(envPort) && envPort > 0 && envPort < 65536) startOpts.port = envPort;
1489
+
1490
+ startServer(startOpts).then(({ port }) => {
1485
1491
  const ijfwDir = dirname(pidFile);
1486
1492
  mkdirSync(ijfwDir, { recursive: true });
1487
1493
  // PID file: plain write (single writer; pid is meaningless mid-write)