@ijfw/memory-server 1.5.6 → 1.6.1

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 (73) 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 +390 -4
  6. package/src/cross-project-search.js +39 -1
  7. package/src/dashboard-server.js +23 -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/model-refresh.js +4 -2
  15. package/src/orchestrator/plan-checker.js +1 -1
  16. package/src/profile/audit.js +671 -0
  17. package/src/profile/capture.js +871 -0
  18. package/src/profile/derive-dialectic.js +242 -0
  19. package/src/profile/derive-heuristic.js +733 -0
  20. package/src/profile/derive.js +156 -0
  21. package/src/profile/egress.js +306 -0
  22. package/src/profile/eval/build-real-probes.mjs +197 -0
  23. package/src/profile/eval/corpus-from-reddit.mjs +166 -0
  24. package/src/profile/eval/corpus-from-reddit.test.mjs +121 -0
  25. package/src/profile/eval/corpus-from-transcripts.mjs +264 -0
  26. package/src/profile/eval/gate-b-behavior.mjs +420 -0
  27. package/src/profile/eval/gate-b-decision-run.mjs +171 -0
  28. package/src/profile/eval/gate-b-decision-run.test.mjs +141 -0
  29. package/src/profile/eval/gate-b-run.mjs +417 -0
  30. package/src/profile/eval/gate-b-run.test.mjs +204 -0
  31. package/src/profile/eval/gate-c-capture.mjs +323 -0
  32. package/src/profile/eval/harness.mjs +551 -0
  33. package/src/profile/eval/instrument-validation.mjs +248 -0
  34. package/src/profile/eval/instrument-validation.test.mjs +125 -0
  35. package/src/profile/eval/multi-subject-harness.mjs +106 -0
  36. package/src/profile/eval/multi-subject-harness.test.mjs +99 -0
  37. package/src/profile/eval/personas.test.mjs +83 -0
  38. package/src/profile/eval/plumbing.test.mjs +69 -0
  39. package/src/profile/eval/prereg.mjs +130 -0
  40. package/src/profile/eval/prereg.test.mjs +78 -0
  41. package/src/profile/eval/real-corpus.test.mjs +103 -0
  42. package/src/profile/eval/real-personas.mjs +109 -0
  43. package/src/profile/eval/run-real-corpus-concurrent.mjs +407 -0
  44. package/src/profile/eval/run-real-corpus.mjs +358 -0
  45. package/src/profile/eval/slug-quality.mjs +464 -0
  46. package/src/profile/eval/stylometry-features.js +85 -0
  47. package/src/profile/eval/stylometry-reference.js +16 -0
  48. package/src/profile/eval/stylometry.js +224 -0
  49. package/src/profile/eval/stylometry.test.mjs +103 -0
  50. package/src/profile/eval/synthetic-personas.js +91 -0
  51. package/src/profile/eval/verifier-features.mjs +170 -0
  52. package/src/profile/eval/verifier-logreg.mjs +74 -0
  53. package/src/profile/eval/verifier-pair.mjs +122 -0
  54. package/src/profile/eval/verifier-reference.mjs +68 -0
  55. package/src/profile/eval/verifier-scorer.mjs +30 -0
  56. package/src/profile/eval/wrong-target-control.mjs +168 -0
  57. package/src/profile/eval/wrong-target-control.test.mjs +124 -0
  58. package/src/profile/exemplar-capture.js +232 -0
  59. package/src/profile/exemplar-retrieve.js +138 -0
  60. package/src/profile/exemplar-store.js +314 -0
  61. package/src/profile/lock.js +64 -0
  62. package/src/profile/merge.js +624 -0
  63. package/src/profile/path-policy.js +213 -0
  64. package/src/profile/precision-stamp.mjs +151 -0
  65. package/src/profile/render-brief.js +717 -0
  66. package/src/profile/schema.js +244 -0
  67. package/src/profile/sensitivity.js +249 -0
  68. package/src/profile/serve.js +345 -0
  69. package/src/profile/store.js +261 -0
  70. package/src/profile/telemetry.js +289 -0
  71. package/src/recovery/checkpoint.js +7 -1
  72. package/src/server.js +194 -16
  73. package/src/.registry-meta-key.pem +0 -3
@@ -319,6 +319,10 @@ function parseArgsInner(args) {
319
319
  return { cmd: 'doctor' };
320
320
  }
321
321
 
322
+ if (args[0] === 'init') {
323
+ return { cmd: 'init', force: args.includes('--force') };
324
+ }
325
+
322
326
  if (args[0] === 'update') {
323
327
  const opts = { cmd: 'update' };
324
328
  for (let i = 1; i < args.length; i++) {
@@ -450,6 +454,26 @@ function parseArgsInner(args) {
450
454
  return { cmd: 'recover', sub: args[1] || 'status' };
451
455
  }
452
456
 
457
+ // v1.6.0 CLI-honesty — top-level aliases so the documented bare verbs route
458
+ // instead of returning "Unknown command". `checkpoint` forwards to
459
+ // `memory checkpoint`; `worktree` forwards to `swarm worktree`. The canonical
460
+ // namespaced forms still work; these are thin shortcuts (see command-registry).
461
+ if (args[0] === 'checkpoint') {
462
+ return { cmd: 'memory-checkpoint', label: args[1] || 'manual' };
463
+ }
464
+ if (args[0] === 'worktree') {
465
+ return { cmd: 'worktree', sub: args[1] || 'list', rest: args.slice(2) };
466
+ }
467
+
468
+ // v1.6.0 Profile-bus learning control. Subcommands:
469
+ // on | off -> set inject = on | off
470
+ // status | show -> print flags + a summary of what was inferred
471
+ // forget [id|pattern] -> delete inferences (right-to-be-forgotten)
472
+ // share-sensitive on|off-> toggle the med/high-sensitivity host allowlist
473
+ if (args[0] === 'personalize') {
474
+ return { cmd: 'personalize', sub: args[1] || 'status', rest: args.slice(2) };
475
+ }
476
+
453
477
  if (args[0] === 'receipt') {
454
478
  return { cmd: 'receipt', sub: args[1] || 'last' };
455
479
  }
@@ -563,8 +587,247 @@ Related:
563
587
  `.trim());
564
588
  }
565
589
 
590
+ // v1.6.0 CLI-honesty — "did you mean?" engine. Two suggestion sources:
591
+ // (1) a curated map of documented-but-bare verbs to their real home
592
+ // (subcommands of another verb, or "not available in this release"),
593
+ // (2) nearest routed verb by Levenshtein edit-distance over the known set.
594
+ // The curated map wins; edit-distance is the fallback. The goal: NO advertised
595
+ // verb ever returns a bare "Unknown command" — every miss is guided.
596
+
597
+ // Curated redirects for verbs users find in old docs / muscle memory. `to` is a
598
+ // suggested command; `note` (optional) explains availability.
599
+ const VERB_REDIRECTS = Object.freeze({
600
+ checkpoint: { to: 'ijfw checkpoint <label> (or: ijfw memory checkpoint <label>)' },
601
+ worktree: { to: 'ijfw worktree <create|list|integrate|cleanup> (or: ijfw swarm worktree …)' },
602
+ 'worktree-drain': { to: 'ijfw swarm worktree cleanup <task-id>', note: 'there is no bulk "drain"; clean up worktrees per task.' },
603
+ 'wave-status': { to: 'ijfw swarm status', note: 'swarm progress (waves, ready/blocked) is shown by `ijfw swarm status`.' },
604
+ 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.' },
605
+ marketplace: { to: null, note: 'not available in this release. Install via `ijfw install` or your agent\'s native plugin marketplace UI.' },
606
+ cluster: { to: null, note: 'multi-machine cluster mode is a design milestone, not shipped in this release.' },
607
+ consent: { to: 'ijfw personalize status', note: 'memory/profile consent is managed via `ijfw personalize` and the memory-consent skill.' },
608
+ });
609
+
610
+ // Every name the orchestrator can actually dispatch (canonical + registry
611
+ // aliases + the bare top-level verbs the parser recognizes). Used as the
612
+ // edit-distance candidate pool.
613
+ function knownVerbNames() {
614
+ const names = new Set();
615
+ for (const e of COMMAND_REGISTRY) {
616
+ if (e.status === 'deprecated') continue;
617
+ names.add(e.name);
618
+ for (const a of (e.aliases || [])) names.add(a);
619
+ }
620
+ // Bare verbs the parser handles that may not be registry entries.
621
+ for (const v of ['status', 'help', 'env', 'recover', 'receipt', 'personalize',
622
+ 'checkpoint', 'worktree', 'memory', 'swarm', 'team',
623
+ 'blackboard', 'codex', 'insight', 'cross']) names.add(v);
624
+ // Drop flag-style + alias-only noise that would never be a useful suggestion.
625
+ names.delete('--purge-receipts');
626
+ return [...names];
627
+ }
628
+
629
+ // Classic iterative Levenshtein. Bounded inputs (CLI verbs), O(n*m) is fine.
630
+ function editDistance(a, b) {
631
+ a = String(a); b = String(b);
632
+ const m = a.length, n = b.length;
633
+ if (m === 0) return n;
634
+ if (n === 0) return m;
635
+ let prev = Array.from({ length: n + 1 }, (_, j) => j);
636
+ let cur = Array.from({ length: n + 1 });
637
+ for (let i = 1; i <= m; i++) {
638
+ cur[0] = i;
639
+ for (let j = 1; j <= n; j++) {
640
+ const cost = a[i - 1] === b[j - 1] ? 0 : 1;
641
+ cur[j] = Math.min(prev[j] + 1, cur[j - 1] + 1, prev[j - 1] + cost);
642
+ }
643
+ [prev, cur] = [cur, prev];
644
+ }
645
+ return prev[n];
646
+ }
647
+
648
+ // Suggest the single nearest routed verb (prefix match preferred, then edit
649
+ // distance). Returns null when nothing is close enough to be helpful.
650
+ export function nearestVerb(raw, candidates = knownVerbNames()) {
651
+ const q = String(raw || '').toLowerCase();
652
+ if (!q) return null;
653
+ // Prefix / containment match first (cheap + high-signal).
654
+ const pref = candidates.filter(c => c.startsWith(q) || q.startsWith(c));
655
+ if (pref.length) {
656
+ return pref.sort((a, b) => Math.abs(a.length - q.length) - Math.abs(b.length - q.length))[0];
657
+ }
658
+ let best = null, bestD = Infinity;
659
+ for (const c of candidates) {
660
+ const d = editDistance(q, c.toLowerCase());
661
+ if (d < bestD) { bestD = d; best = c; }
662
+ }
663
+ // Only suggest when reasonably close: <= 2 edits, or <= 40% of the longer.
664
+ const tol = Math.max(2, Math.floor(0.4 * Math.max(q.length, (best || '').length)));
665
+ return bestD <= tol ? best : null;
666
+ }
667
+
668
+ // ---------------------------------------------------------------------------
669
+ // Profile-bus learning control — `ijfw personalize` (v1.6.0)
670
+ // ---------------------------------------------------------------------------
671
+ // MOAT NOTE: every profile module touched here (serve.js, audit.js,
672
+ // sensitivity.js) is ZERO-LLM by construction (the P4.5 import-graph guard
673
+ // proves serve.js never reaches the LLM tier). We import them DYNAMICALLY so
674
+ // the top-of-file static import graph of this CLI stays clean and the
675
+ // profile-moat-guard's transitive-import walk is never widened.
676
+
677
+ const PERSONALIZE_CONSENT_VERSION = '1.6.0';
678
+
679
+ function printPersonalizeHelp() {
680
+ console.log(`
681
+ ijfw personalize -- control the profile-bus learning feature
682
+
683
+ What it does: IJFW can learn your low-sensitivity interaction STYLE (verbosity,
684
+ formality, code-vs-prose) locally and, only if you allow it, include a short
685
+ brief in prompts so agents match your style. Capture is always local and never
686
+ includes raw text. Injection is OFF until you turn it on.
687
+
688
+ Usage:
689
+ ijfw personalize status Show current flags + a summary of what was
690
+ inferred (alias: ijfw personalize show).
691
+ ijfw personalize on Enable injecting the learned style brief.
692
+ ijfw personalize off Disable injection (capture continues locally).
693
+ ijfw personalize forget [pattern] Delete inferences. No pattern = forget ALL.
694
+ ijfw personalize share-sensitive on|off
695
+ Allow/deny med+high-sensitivity prefs to
696
+ leave to allowlisted hosts. Default off.
697
+
698
+ Hard override: set IJFW_PROFILE_KILL=1 to force-disable ALL injection
699
+ regardless of these settings.
700
+ `.trim());
701
+ }
702
+
703
+ async function cmdPersonalize(sub, rest = []) {
704
+ const settings = resolveProfileSettings();
705
+ const killed = isTruthyEnv(process.env.IJFW_PROFILE_KILL);
706
+
707
+ if (sub === 'on' || sub === 'off') {
708
+ const next = writeProfileSettings({
709
+ inject: sub,
710
+ // First explicit on/off resolves the "ask" consent permanently.
711
+ inject_consent_version: PERSONALIZE_CONSENT_VERSION,
712
+ });
713
+ if (sub === 'on') {
714
+ console.log('Profile injection ENABLED. Your learned low-sensitivity style brief will be added to prompts.');
715
+ if (killed) console.log('Note: IJFW_PROFILE_KILL is set, so injection stays OFF until you unset it.');
716
+ console.log('Turn it off anytime with: ijfw personalize off');
717
+ } else {
718
+ console.log('Profile injection DISABLED. Capture continues locally; nothing is added to prompts.');
719
+ }
720
+ console.log(`(profile.inject = ${next.inject})`);
721
+ return;
722
+ }
723
+
724
+ if (sub === 'share-sensitive') {
725
+ const val = rest[0];
726
+ if (val !== 'on' && val !== 'off') {
727
+ console.error('Usage: ijfw personalize share-sensitive on|off');
728
+ process.exit(1);
729
+ }
730
+ const enable = val === 'on';
731
+ writeProfileSettings({ share_sensitive: enable });
732
+ // The serve-path allowlist is the SECOND gate (per-host). Toggling on writes
733
+ // a `*` wildcard line; toggling off removes it. This is the auditable file
734
+ // sensitivity.js reads (share-hosts.txt). We import its path helper lazily.
735
+ try {
736
+ const { shareHostsFilePath } = await import('./profile/sensitivity.js');
737
+ const p = shareHostsFilePath();
738
+ if (enable) {
739
+ mkdirSync(dirname(p), { recursive: true, mode: 0o700 });
740
+ writeAtomic(p, '# Hosts allowed to receive medium/high-sensitivity profile fields.\n# Managed by `ijfw personalize share-sensitive`. `*` = all hosts.\n*\n', { mode: 0o600 });
741
+ console.log('Sensitive sharing ENABLED for all hosts (wildcard allowlist written).');
742
+ console.log(`Allowlist: ${p} — edit it to restrict to specific hosts.`);
743
+ } else {
744
+ try { if (existsSync(p)) rmSync(p, { force: true }); } catch { /* */ }
745
+ console.log('Sensitive sharing DISABLED. Only low-sensitivity style can ever be injected.');
746
+ }
747
+ } catch (e) {
748
+ console.error(`Wrote the flag, but could not update the host allowlist: ${e.message}`);
749
+ process.exit(1);
750
+ }
751
+ return;
752
+ }
753
+
754
+ if (sub === 'forget') {
755
+ const pattern = rest[0];
756
+ try {
757
+ const { forgetAndWrite } = await import('./profile/audit.js');
758
+ // No pattern => forget everything (a bare `*` matcher is rejected by the
759
+ // ReDoS guard, so we pass a catch-all regex the validator accepts).
760
+ const arg = pattern && pattern.trim() ? pattern.trim() : /.*/;
761
+ const r = await forgetAndWrite(arg);
762
+ if (!r || !r.ok) {
763
+ console.error(`Forget failed: ${(r && r.message) || (r && r.code) || 'unknown error'}`);
764
+ process.exit(1);
765
+ }
766
+ const n = (r.removed || []).length;
767
+ console.log(`Forgot ${n} inference${n === 1 ? '' : 's'}${pattern ? ` matching "${pattern}"` : ' (all)'}.`);
768
+ if (r.egressRemoved) console.log(`Also cleared ${r.egressRemoved} egress-log entr${r.egressRemoved === 1 ? 'y' : 'ies'}.`);
769
+ if (n === 0) console.log('Nothing matched — the profile may already be empty.');
770
+ } catch (e) {
771
+ console.error(`Forget failed: ${e.message}`);
772
+ process.exit(1);
773
+ }
774
+ return;
775
+ }
776
+
777
+ if (sub === 'status' || sub === 'show') {
778
+ const effective = killed ? 'off (forced by IJFW_PROFILE_KILL)' : settings.inject;
779
+ console.log('IJFW personalize -- profile-bus status');
780
+ console.log('');
781
+ console.log(` capture ${settings.capture} (local style metadata; never raw text)`);
782
+ console.log(` inject ${settings.inject}${settings.inject === 'ask' ? ' (default — not injecting until you run: ijfw personalize on)' : ''}`);
783
+ console.log(` effective inject ${effective}`);
784
+ console.log(` share_sensitive ${settings.share_sensitive} (med/high prefs ${settings.share_sensitive ? 'MAY' : 'will NOT'} leave)`);
785
+ console.log(` consent ${settings.inject_consent_version ? `resolved (v${settings.inject_consent_version})` : 'not yet asked'}`);
786
+ console.log('');
787
+ // Summary of what was inferred (zero-LLM audit path).
788
+ try {
789
+ const { profileGet } = await import('./profile/serve.js');
790
+ const r = profileGet({ context: { host: 'cli-personalize' }, env: process.env });
791
+ const styleN = r && r.profile ? Object.keys(r.profile.style || {}).length : 0;
792
+ const expN = r && r.profile ? Object.keys(r.profile.expertise || {}).length : 0;
793
+ const infN = r && r.profile ? (r.profile.inferences || []).length : 0;
794
+ console.log(` Learned so far: ${styleN} style ${styleN === 1 ? 'axis' : 'axes'}, ${expN} expertise domain${expN === 1 ? '' : 's'}, ${infN} inference${infN === 1 ? '' : 's'}.`);
795
+ if (styleN + expN + infN === 0) {
796
+ console.log(' (Nothing confirmed yet — keep using IJFW and a style profile builds over sessions.)');
797
+ } else {
798
+ console.log(' Inspect everything with: ijfw_brain { "verb": "profile.audit" } (or via the memory-audit skill).');
799
+ console.log(' Delete anything with: ijfw personalize forget [pattern]');
800
+ }
801
+ } catch (e) {
802
+ console.log(` (Profile summary unavailable: ${e.message})`);
803
+ }
804
+ return;
805
+ }
806
+
807
+ if (sub === '--help' || sub === '-h' || sub === 'help') {
808
+ printPersonalizeHelp();
809
+ return;
810
+ }
811
+
812
+ console.error(`Unknown personalize subcommand: ${sub}`);
813
+ console.error('');
814
+ printPersonalizeHelp();
815
+ process.exit(1);
816
+ }
817
+
566
818
  function printUnknownCommand(raw) {
567
819
  console.error(`Unknown command: ${raw}`);
820
+ const key = String(raw || '').toLowerCase();
821
+ const redirect = Object.prototype.hasOwnProperty.call(VERB_REDIRECTS, key)
822
+ ? VERB_REDIRECTS[key]
823
+ : null;
824
+ if (redirect) {
825
+ if (redirect.to) console.error(`Did you mean: ${redirect.to}`);
826
+ if (redirect.note) console.error(`Note: ${redirect.note}`);
827
+ } else {
828
+ const near = nearestVerb(key);
829
+ if (near) console.error(`Did you mean: ijfw ${near}`);
830
+ }
568
831
  console.error('Run `ijfw --help` for the user-facing command list, or `ijfw commands` for the full surface.');
569
832
  }
570
833
 
@@ -1521,6 +1784,70 @@ function cmpSemver(a, b) {
1521
1784
  function readState() { return readJsonSafe(join(ijfwHome(), 'state.json')) || {}; }
1522
1785
  function readSettings() { return readJsonSafe(join(ijfwHome(), 'settings.json')) || {}; }
1523
1786
 
1787
+ // ---------------------------------------------------------------------------
1788
+ // Profile-bus settings (v1.6.0) — consent + control surface.
1789
+ // ---------------------------------------------------------------------------
1790
+ // The `profile` block predates 1.6 in some installs; resolveProfileSettings()
1791
+ // backfills the documented defaults on read so the rest of the CLI + the hooks
1792
+ // never have to defend against a missing block. Defaults (design-locked):
1793
+ // capture = "on" (local, low-sensitivity metadata only; never raw)
1794
+ // inject = "ask" (do NOT silently inject — ask once, default off)
1795
+ // share_sensitive= false (med/high prefs require an explicit opt-in)
1796
+ const PROFILE_DEFAULTS = Object.freeze({
1797
+ capture: 'on',
1798
+ inject: 'ask',
1799
+ share_sensitive: false,
1800
+ inject_consent_version: null,
1801
+ });
1802
+
1803
+ function resolveProfileSettings(settings = readSettings()) {
1804
+ const p = (settings && typeof settings.profile === 'object' && settings.profile) || {};
1805
+ return {
1806
+ capture: p.capture === 'off' ? 'off' : 'on',
1807
+ inject: (p.inject === 'on' || p.inject === 'off') ? p.inject : 'ask',
1808
+ share_sensitive: p.share_sensitive === true,
1809
+ inject_consent_version: typeof p.inject_consent_version === 'string'
1810
+ ? p.inject_consent_version : null,
1811
+ };
1812
+ }
1813
+
1814
+ // resolveProfileInject(opts) -> 'on' | 'off' | 'ask'.
1815
+ // The single decision the inject GATE consults. The hard kill-switch
1816
+ // (IJFW_PROFILE_KILL) ALWAYS wins and forces 'off' — capture is never gated by
1817
+ // it, only injection. Otherwise the settings block decides.
1818
+ function isTruthyEnv(v) {
1819
+ if (v === undefined || v === null) return false;
1820
+ const s = String(v).trim().toLowerCase();
1821
+ return s !== '' && s !== '0' && s !== 'false' && s !== 'no' && s !== 'off';
1822
+ }
1823
+ export function resolveProfileInject({ settings, env = process.env } = {}) {
1824
+ if (isTruthyEnv(env.IJFW_PROFILE_KILL)) return 'off';
1825
+ return resolveProfileSettings(settings || readSettings()).inject;
1826
+ }
1827
+
1828
+ // Persist the resolved `profile` block under the same single-writer lock the
1829
+ // state file uses, so a concurrent `ijfw personalize` and an `ijfw update`
1830
+ // can't clobber each other's settings.json. Merges into the existing file
1831
+ // (never drops unrelated keys) and writes atomically with 0600.
1832
+ function writeProfileSettings(patch) {
1833
+ const path = join(ijfwHome(), 'settings.json');
1834
+ const apply = () => {
1835
+ const cur = readJsonSafe(path) || {};
1836
+ const profile = { ...PROFILE_DEFAULTS, ...resolveProfileSettings(cur), ...patch };
1837
+ const next = { ...cur, profile };
1838
+ writeAtomic(path, JSON.stringify(next, null, 2) + '\n', { mode: 0o600 });
1839
+ return profile;
1840
+ };
1841
+ try {
1842
+ return withStateLockSync(apply);
1843
+ } catch {
1844
+ // Lock acquisition failed (e.g. EACCES on the lock dir) — fall back to a
1845
+ // best-effort direct write rather than refusing to persist the user's
1846
+ // explicit consent choice.
1847
+ return apply();
1848
+ }
1849
+ }
1850
+
1524
1851
  // v1.5.0 audit M11 (F-REL-1): writeStateFields was best-effort — readState +
1525
1852
  // merge + writeAtomic was a TOCTOU window where a parallel `ijfw update`
1526
1853
  // completion could clobber another writer's `last_applied_version`. If the
@@ -2542,6 +2869,8 @@ if (isMainModule) {
2542
2869
  cmdImport(parsed).catch(err => { console.error(err.message); process.exit(1); });
2543
2870
  } else if (parsed.cmd === 'doctor') {
2544
2871
  cmdDoctor(parsed);
2872
+ } else if (parsed.cmd === 'init') {
2873
+ cmdInit(parsed);
2545
2874
  } else if (parsed.cmd === 'update') {
2546
2875
  cmdUpdate(parsed);
2547
2876
  } else if (parsed.cmd === 'version') {
@@ -2588,6 +2917,13 @@ if (isMainModule) {
2588
2917
  cmdEnv();
2589
2918
  } else if (parsed.cmd === 'recover') {
2590
2919
  cmdRecover(parsed.sub);
2920
+ } else if (parsed.cmd === 'personalize') {
2921
+ cmdPersonalize(parsed.sub, parsed.rest || []).catch(err => { console.error(err.message); process.exit(1); });
2922
+ } else if (parsed.cmd === 'worktree') {
2923
+ // Top-level alias forwarding to `swarm worktree`. cmdSwarmWorktree reads its
2924
+ // own sub + args from the parsed tail (not process.argv) so the alias and
2925
+ // the canonical `swarm worktree` path stay identical.
2926
+ cmdSwarmWorktree(parsed.sub, parsed.rest || []);
2591
2927
  } else {
2592
2928
  // v1.5.1 W1.D+E: clean unknown-command message; no stale usage dump.
2593
2929
  printUnknownCommand(parsed.raw);
@@ -2624,6 +2960,46 @@ function findCliAsset(...rel) {
2624
2960
  ].filter(Boolean);
2625
2961
  return candidates.find(p => existsSync(p)) || null;
2626
2962
  }
2963
+ // `ijfw init` -- explicitly bless the current folder for codebase indexing.
2964
+ // The indexer (scripts/build-codebase-index.sh) refuses any folder that has no
2965
+ // project marker (issue #16). For a plain working folder with no .git/package.json
2966
+ // etc, this drops a .ijfw/project marker so the indexer will index it. It will
2967
+ // NOT bless the home directory or filesystem root -- that is the whole point of
2968
+ // the guard.
2969
+ function cmdInit(parsed = {}) {
2970
+ const cwd = process.cwd();
2971
+ let phys;
2972
+ try { phys = realpathSync(cwd); } catch { phys = resolve(cwd); }
2973
+ let homePhys;
2974
+ try { homePhys = realpathSync(homedir()); } catch { homePhys = homedir(); }
2975
+ if (phys === '/' || phys === homePhys) {
2976
+ console.error('ijfw init: refusing to bless your home directory or the filesystem root for indexing.');
2977
+ console.error('Run `ijfw init` from inside an actual project folder.');
2978
+ process.exit(1);
2979
+ }
2980
+ const marker = join(cwd, '.ijfw', 'project');
2981
+ try {
2982
+ mkdirSync(dirname(marker), { recursive: true });
2983
+ if (existsSync(marker) && !parsed.force) {
2984
+ console.log(`This folder is already initialised for IJFW indexing (${marker}).`);
2985
+ process.exit(0);
2986
+ }
2987
+ const stamp = new Date().toISOString();
2988
+ writeFileSync(
2989
+ marker,
2990
+ `# IJFW project marker\n` +
2991
+ `# Created by \`ijfw init\`. This folder is approved for codebase indexing.\n` +
2992
+ `# Safe to commit. Delete this file to stop IJFW indexing this folder.\n` +
2993
+ `created_at: ${stamp}\n`,
2994
+ { mode: 0o644 }
2995
+ );
2996
+ console.log('IJFW initialised. This folder is now approved for codebase indexing.');
2997
+ console.log(`Marker: ${marker}`);
2998
+ } catch (err) {
2999
+ console.error(`ijfw init: could not write marker -- ${err.message}`);
3000
+ process.exit(1);
3001
+ }
3002
+ }
2627
3003
  function cmdInstall() {
2628
3004
  const script = findCliAsset('scripts', 'install.sh');
2629
3005
  if (!script) {
@@ -2652,12 +3028,22 @@ function cmdPreflight() {
2652
3028
  process.exit(res.status ?? 1);
2653
3029
  }
2654
3030
  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.');
3031
+ // `ijfw dashboard start|stop|status` is the localhost SSE web dashboard
3032
+ // documented in the README (binds 127.0.0.1:37891, port-walks to 37900).
3033
+ // It is served by mcp-server/bin/ijfw-dashboard, NOT scripts/dashboard/bin.js
3034
+ // (that one is the terminal text digest, surfaced via `ijfw status`). Routing
3035
+ // `dashboard` at the text renderer silently no-op'd start/stop/status: the
3036
+ // web server never bound and stop/status never reported true state.
3037
+ const launcher = findCliAsset('mcp-server', 'bin', 'ijfw-dashboard');
3038
+ if (!launcher) {
3039
+ console.error('Dashboard server not found. Run `ijfw-install` to deploy ~/.ijfw/, or set IJFW_HOME to your IJFW tree.');
2658
3040
  process.exit(1);
2659
3041
  }
2660
- const res = spawnSync(process.execPath, [script, sub], { stdio: 'inherit' });
3042
+ // Forward the verb plus any passthrough flags (--no-open, --port N) supplied
3043
+ // after `dashboard` on the command line, so the launcher's own argv handling
3044
+ // stays authoritative. argv = [node, cli, 'dashboard', <sub>, ...flags].
3045
+ const passthrough = process.argv.slice(4);
3046
+ const res = spawnSync(process.execPath, [launcher, sub, ...passthrough], { stdio: 'inherit' });
2661
3047
  process.exit(res.status ?? 1);
2662
3048
  }
2663
3049
 
@@ -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;
@@ -174,10 +174,26 @@ function requireLocalhost(req, res) {
174
174
  return false;
175
175
  }
176
176
 
177
+ // CSRF guard: reject cross-origin browser requests to the data API. Browsers
178
+ // stamp Sec-Fetch-Site; the dashboard's own page is 'same-origin', direct tools
179
+ // (curl, address bar) send 'none'/nothing. Only same-machine cross-origin pages
180
+ // hit 'cross-site'/'same-site' -- block those on /api.
181
+ function rejectCrossSiteApi(req, res, path) {
182
+ if (!path.startsWith('/api')) return false;
183
+ const sfs = req.headers['sec-fetch-site'];
184
+ if (sfs === 'cross-site' || sfs === 'same-site') {
185
+ res.writeHead(403, { 'Content-Type': 'application/json' });
186
+ res.end('{"error":"cross-origin request rejected"}');
187
+ return true;
188
+ }
189
+ return false;
190
+ }
191
+
177
192
  // ---------- simple router ----------
178
193
  function route(req, res, routes) {
179
194
  const url = new URL(req.url, 'http://localhost');
180
195
  const path = url.pathname;
196
+ if (rejectCrossSiteApi(req, res, path)) return;
181
197
  for (const [pattern, handler] of routes) {
182
198
  if (typeof pattern === 'string' ? path === pattern : pattern.test(path)) {
183
199
  handler(req, res, url);
@@ -1481,7 +1497,13 @@ if (process.argv.includes('--daemon')) {
1481
1497
  const pidFile = process.env.IJFW_PID_FILE || join(homedir(), '.ijfw', 'dashboard.pid');
1482
1498
  const portFile = process.env.IJFW_PORT_FILE || join(homedir(), '.ijfw', 'dashboard.port');
1483
1499
 
1484
- startServer().then(({ port }) => {
1500
+ // Optional preferred-port override (forwarded by the launcher's `--port N`).
1501
+ // Unset = default 37891-37900 walk. Invalid values fall back to the default.
1502
+ const startOpts = {};
1503
+ const envPort = Number.parseInt(process.env.IJFW_DASHBOARD_PORT || '', 10);
1504
+ if (Number.isInteger(envPort) && envPort > 0 && envPort < 65536) startOpts.port = envPort;
1505
+
1506
+ startServer(startOpts).then(({ port }) => {
1485
1507
  const ijfwDir = dirname(pidFile);
1486
1508
  mkdirSync(ijfwDir, { recursive: true });
1487
1509
  // PID file: plain write (single writer; pid is meaningless mid-write)