@ijfw/memory-server 1.5.0 → 1.5.3

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 (71) hide show
  1. package/bin/ijfw-memorize +14 -7
  2. package/fixtures/team/book.json +6 -6
  3. package/fixtures/team/business.json +146 -20
  4. package/fixtures/team/content.json +6 -6
  5. package/fixtures/team/design.json +148 -20
  6. package/fixtures/team/mixed.json +206 -27
  7. package/fixtures/team/research.json +146 -20
  8. package/fixtures/team/software.json +148 -20
  9. package/package.json +8 -4
  10. package/src/brain/budget-guard.js +86 -0
  11. package/src/brain/citation-resolver.js +41 -0
  12. package/src/brain/context-injection.js +69 -0
  13. package/src/brain/discovery.js +83 -0
  14. package/src/brain/dream-pipeline.js +324 -0
  15. package/src/brain/dump-ingest.js +88 -0
  16. package/src/brain/entity-collapse.js +28 -0
  17. package/src/brain/export.js +112 -0
  18. package/src/brain/extractors/index.js +24 -0
  19. package/src/brain/extractors/markdown.js +27 -0
  20. package/src/brain/extractors/pdf.js +31 -0
  21. package/src/brain/extractors/transcript.js +38 -0
  22. package/src/brain/first-run-scan.js +61 -0
  23. package/src/brain/index.js +1 -0
  24. package/src/brain/layout-sentinel.js +29 -0
  25. package/src/brain/migrate-facts-internal-once.js +87 -0
  26. package/src/brain/path-guard.js +103 -0
  27. package/src/brain/paths.js +26 -0
  28. package/src/brain/promotion-suggester.js +41 -0
  29. package/src/brain/stub-detector.js +33 -0
  30. package/src/brain/tiered-llm.js +83 -0
  31. package/src/brain/wiki-compiler.js +144 -0
  32. package/src/brain/wiki-sentinels.js +45 -0
  33. package/src/brain/wiki-templates.js +94 -0
  34. package/src/cross-orchestrator-cli.js +336 -150
  35. package/src/cross-orchestrator.js +52 -3
  36. package/src/dashboard-server.js +1 -1
  37. package/src/dispatch/extension.js +1 -1
  38. package/src/dream/runner.mjs +21 -0
  39. package/src/extension-registry.js +2 -2
  40. package/src/handlers/brain-handler.js +319 -0
  41. package/src/hardware-signer.js +4 -2
  42. package/src/lib/ui-review-runner.js +48 -7
  43. package/src/memory/auto-linker.js +121 -2
  44. package/src/memory/benchmark.js +4 -3
  45. package/src/memory/layout-migrations/001-visible-layer.js +131 -0
  46. package/src/memory/layout-migrations/index.js +50 -0
  47. package/src/memory/migration-runner.js +37 -3
  48. package/src/memory/migrations/009-obsidian-backfill.js +50 -0
  49. package/src/memory/obsidian-parser.js +65 -2
  50. package/src/memory/reader.js +2 -1
  51. package/src/memory/search.js +190 -41
  52. package/src/memory/temporal.js +40 -1
  53. package/src/orchestrator/agents-md-blackboard.js +114 -1
  54. package/src/orchestrator/debug-trident-trigger.js +374 -0
  55. package/src/orchestrator/discipline-selector.js +276 -0
  56. package/src/orchestrator/merge-block-aware.js +15 -5
  57. package/src/orchestrator/post-done-runner.js +36 -8
  58. package/src/orchestrator/state-sdk.js +216 -10
  59. package/src/orchestrator/subagent-telemetry.js +19 -0
  60. package/src/orchestrator/wave-state.js +38 -0
  61. package/src/override-resolver.js +5 -3
  62. package/src/recovery/code-fixer.js +311 -6
  63. package/src/runtime-mediator.js +0 -1
  64. package/src/server.js +486 -132
  65. package/src/swarm-config.js +30 -22
  66. package/src/team/domain-templates/business.json +4 -1
  67. package/src/team/domain-templates/research.json +4 -1
  68. package/src/team/generator.js +162 -0
  69. package/src/update-apply.js +1 -1
  70. package/src/dashboard-charts.js +0 -239
  71. package/src/orchestrator/runtime-loop.js +0 -430
@@ -21,12 +21,22 @@
21
21
  * pattern — if the process crashes mid-fix the next run prunes the dangling
22
22
  * worktree and reports the survivor.
23
23
  *
24
+ * WIRE-UP (v1.5.1 C2): the consensus entry point `runConsensusFix` is invoked
25
+ * by `cross-orchestrator.js#runPhaseEConverge` when its caller passes
26
+ * `autoFix: true`. After a non-PASS convergence the orchestrator extracts the
27
+ * HIGH findings that 2+ lenses agreed on (`consensusHighFindings`) and runs
28
+ * the atomic per-finding fix loop over them — the "2+ lenses agree → fixer
29
+ * fires automatically" contract from the T27 brief. This module is therefore
30
+ * a live, reachable production path, not an orphan.
31
+ *
24
32
  * Zero new prod deps. ESM. Node ≥18.
25
33
  */
26
34
 
27
- import { existsSync } from 'node:fs';
35
+ import { existsSync, realpathSync } from 'node:fs';
28
36
  import { readFile, writeFile, mkdtemp, rm } from 'node:fs/promises';
29
- import { join, extname, relative, isAbsolute, resolve as resolvePath } from 'node:path';
37
+ import {
38
+ join, extname, relative, isAbsolute, resolve as resolvePath, dirname,
39
+ } from 'node:path';
30
40
  import { tmpdir } from 'node:os';
31
41
  import { execFile, spawnSync } from 'node:child_process';
32
42
  import { promisify } from 'node:util';
@@ -35,6 +45,82 @@ import { withRecoverySentinel } from '../lib/worktree-recovery.js';
35
45
 
36
46
  const execFileAsync = promisify(execFile);
37
47
 
48
+ /* ────────────────────── R5-1.10: auto-fix safety boundary ────────────────── */
49
+
50
+ /**
51
+ * Default ceiling on the number of distinct files a single `runConsensusFix`
52
+ * call may write. Auto-fix is opt-in, but even when enabled it must not be
53
+ * able to mass-rewrite a repository — past this cap the loop stops and
54
+ * reports the remainder rather than continuing to mutate. Callers can lower
55
+ * (never silently raise past sanity) this via `maxAutoFixFiles`.
56
+ */
57
+ export const DEFAULT_MAX_AUTOFIX_FILES = 10;
58
+ // Hard ceiling — a caller cannot pass `maxAutoFixFiles` above this.
59
+ const MAX_AUTOFIX_FILES_CEILING = 50;
60
+
61
+ /**
62
+ * isPathContained(filePath, projectRoot) — true iff `filePath` resolves to a
63
+ * location at or under `projectRoot`. Symlinks are resolved on BOTH sides
64
+ * (realpath) so a symlink inside the repo that points outside it is rejected.
65
+ *
66
+ * Mirrors the containment prior art in cross-project-search.js#isUnder /
67
+ * safeResolveProjectPath: realpath the root, realpath the entry (falling back
68
+ * to the un-resolved absolute when the entry doesn't exist yet — e.g. a fix
69
+ * that would create a file — and in that case realpath the parent dir), then
70
+ * a trailing-separator boundary check so `/repo-evil` is NOT inside `/repo`.
71
+ *
72
+ * Returns { ok, reason, canonical? }.
73
+ */
74
+ export function isPathContained(filePath, projectRoot) {
75
+ if (!filePath || typeof filePath !== 'string') {
76
+ return { ok: false, reason: 'no-file-path' };
77
+ }
78
+ if (!projectRoot || typeof projectRoot !== 'string') {
79
+ return { ok: false, reason: 'no-project-root' };
80
+ }
81
+ // Canonicalise the root. If it doesn't resolve, fall back to absolute.
82
+ let canonRoot;
83
+ try {
84
+ canonRoot = realpathSync(resolvePath(projectRoot));
85
+ } catch {
86
+ canonRoot = resolvePath(projectRoot);
87
+ }
88
+ // Canonicalise the target. The file may not exist yet (a creating fix), so
89
+ // realpath the deepest existing ancestor and re-join the missing tail.
90
+ const absTarget = isAbsolute(filePath)
91
+ ? filePath
92
+ : resolvePath(canonRoot, filePath);
93
+ let canonTarget;
94
+ try {
95
+ canonTarget = realpathSync(absTarget);
96
+ } catch {
97
+ let probe = absTarget;
98
+ const tail = [];
99
+ // Walk up until we hit something that exists (or the fs root).
100
+ while (probe && !existsSync(probe) && dirname(probe) !== probe) {
101
+ tail.unshift(probe.slice(dirname(probe).length).replace(/^[\\/]/, ''));
102
+ probe = dirname(probe);
103
+ }
104
+ let canonProbe;
105
+ try { canonProbe = realpathSync(probe); }
106
+ catch { canonProbe = resolvePath(probe); }
107
+ canonTarget = tail.length ? join(canonProbe, ...tail) : canonProbe;
108
+ }
109
+ // Boundary check with trailing separator so siblings can't impersonate.
110
+ const rel = relative(canonRoot, canonTarget);
111
+ const escapes = rel === '..'
112
+ || rel.startsWith(`..${'/'}`) || rel.startsWith(`..${'\\'}`)
113
+ || isAbsolute(rel);
114
+ if (escapes) {
115
+ return {
116
+ ok: false,
117
+ reason: `path escapes project root (${canonTarget} not under ${canonRoot})`,
118
+ canonical: canonTarget,
119
+ };
120
+ }
121
+ return { ok: true, reason: '', canonical: canonTarget };
122
+ }
123
+
38
124
  /* ────────────────────────────── status codes ────────────────────────────── */
39
125
 
40
126
  export const STATUS = Object.freeze({
@@ -46,6 +132,12 @@ export const STATUS = Object.freeze({
46
132
  FALLBACK_FAIL: 'FALLBACK_FAIL',
47
133
  TRIDENT_FAIL: 'TRIDENT_FAIL',
48
134
  COMMIT_FAIL: 'COMMIT_FAIL',
135
+ // R5-1.10 — finding's target file resolves outside the audited project
136
+ // root. The fixer refuses to touch it (path-containment guard).
137
+ OUT_OF_ROOT: 'OUT_OF_ROOT',
138
+ // R5-1.10 — the per-run change cap was reached; this finding (and any
139
+ // after it) was skipped without being applied.
140
+ CAP_REACHED: 'CAP_REACHED',
49
141
  });
50
142
 
51
143
  /* ────────────────────────────── logic-bug heuristic ─────────────────────── */
@@ -378,7 +470,7 @@ async function applyEdit(filePath, fix) {
378
470
  // Exactly-one occurrence guarantee — same rule the Edit tool uses.
379
471
  const occurrences = before.split(fix.old_string).length - 1;
380
472
  if (occurrences > 1 && !fix.replace_all) {
381
- return { ok: false, evidence: `old_string occurs ${occurrences}×; ambiguous`, before };
473
+ return { ok: false, evidence: `old_string occurs ${occurrences}x; ambiguous`, before };
382
474
  }
383
475
  after = fix.replace_all
384
476
  ? before.split(fix.old_string).join(fix.new_string)
@@ -446,9 +538,27 @@ export async function fixFinding({
446
538
  }
447
539
 
448
540
  // 2. confirm target exists + snapshot
541
+ const root = projectRoot || process.cwd();
449
542
  const filePath = isAbsolute(finding.file)
450
543
  ? finding.file
451
- : resolvePath(projectRoot || process.cwd(), finding.file);
544
+ : resolvePath(root, finding.file);
545
+
546
+ // R5-1.10 — PATH CONTAINMENT. Auto-fix mutates the working tree; it must
547
+ // only ever touch files inside the project root being audited. A finding
548
+ // whose `file` resolves outside the root (absolute escape, `../` traversal,
549
+ // or a symlink pointing out) is REFUSED before any read/write happens.
550
+ // This is checked before existsSync so an out-of-root path can't even be
551
+ // probed for existence.
552
+ const contained = isPathContained(filePath, root);
553
+ if (!contained.ok) {
554
+ return {
555
+ ...base,
556
+ status: STATUS.OUT_OF_ROOT,
557
+ tier_reached: 'n/a',
558
+ evidence: `auto-fix refused: ${contained.reason}`,
559
+ };
560
+ }
561
+
452
562
  if (!existsSync(filePath)) {
453
563
  return { ...base, status: STATUS.STALE, tier_reached: 'n/a',
454
564
  evidence: `target file does not exist: ${filePath}` };
@@ -583,9 +693,21 @@ export async function fixFinding({
583
693
  * the Trident step needs a stable commit range. Parallel fixes would shred
584
694
  * the atomicity guarantee.
585
695
  *
696
+ * R5-1.10 — CHANGE CAP. `opts.maxAutoFixFiles` (default
697
+ * DEFAULT_MAX_AUTOFIX_FILES = 10, hard-ceilinged at 50) bounds the number of
698
+ * DISTINCT files this batch may successfully apply a fix to. Once that many
699
+ * files have been touched, every remaining finding that targets a not-yet-
700
+ * seen file is short-circuited with status CAP_REACHED (no read, no write) —
701
+ * the loop stops mutating and reports the remainder instead of mass-
702
+ * rewriting the repo. Findings that re-target an already-fixed file are
703
+ * still allowed through (they don't grow the blast radius). Statuses that
704
+ * don't write a file (DEFERRED / STALE / OUT_OF_ROOT / *_FAIL) never count
705
+ * against the cap.
706
+ *
586
707
  * Returns { results: Array<fixFinding-record>, summary: { verified, deferred,
587
708
  * stale, verify_fail, syntax_fail, fallback_fail, trident_fail,
588
- * commit_fail } }.
709
+ * commit_fail, out_of_root, cap_reached }, capped: boolean,
710
+ * filesTouched: number, maxAutoFixFiles: number }.
589
711
  */
590
712
  export async function fixFindings(findings, opts = {}) {
591
713
  const results = [];
@@ -593,15 +715,198 @@ export async function fixFindings(findings, opts = {}) {
593
715
  verified: 0, deferred: 0, stale: 0,
594
716
  verify_fail: 0, syntax_fail: 0, fallback_fail: 0,
595
717
  trident_fail: 0, commit_fail: 0,
718
+ out_of_root: 0, cap_reached: 0,
596
719
  };
720
+
721
+ // Resolve the per-run change cap. Clamp to [1, MAX_AUTOFIX_FILES_CEILING];
722
+ // a non-positive or non-numeric value falls back to the default.
723
+ const reqCap = Number(opts.maxAutoFixFiles);
724
+ const cap = Number.isFinite(reqCap) && reqCap > 0
725
+ ? Math.min(Math.floor(reqCap), MAX_AUTOFIX_FILES_CEILING)
726
+ : DEFAULT_MAX_AUTOFIX_FILES;
727
+
728
+ // Distinct files this batch has successfully written to. A fix counts as
729
+ // "touching" a file only if it actually applied an edit — VERIFIED or any
730
+ // failure mode that happens AFTER applyEdit (VERIFY_FAIL/SYNTAX_FAIL/
731
+ // FALLBACK_FAIL/TRIDENT_FAIL/COMMIT_FAIL all roll the file back, but the
732
+ // file WAS written then reverted, so they still count toward blast radius).
733
+ const APPLIED = new Set([
734
+ STATUS.VERIFIED, STATUS.VERIFY_FAIL, STATUS.SYNTAX_FAIL,
735
+ STATUS.FALLBACK_FAIL, STATUS.TRIDENT_FAIL, STATUS.COMMIT_FAIL,
736
+ ]);
737
+ const filesTouched = new Set();
738
+ let capped = false;
739
+
597
740
  for (const finding of (findings || [])) {
741
+ const targetFile = finding && typeof finding.file === 'string'
742
+ ? finding.file : null;
743
+ const alreadyTouched = targetFile && filesTouched.has(targetFile);
744
+
745
+ // Cap gate: if we've hit the ceiling AND this finding would touch a NEW
746
+ // file, refuse it without reading/writing anything.
747
+ if (filesTouched.size >= cap && !alreadyTouched) {
748
+ capped = true;
749
+ results.push({
750
+ finding_id: finding?.finding_id || finding?.id || 'unknown',
751
+ file: targetFile,
752
+ status: STATUS.CAP_REACHED,
753
+ tier_reached: 'n/a',
754
+ evidence: `auto-fix change cap reached (${cap} files); skipped without applying`,
755
+ });
756
+ summary.cap_reached += 1;
757
+ continue;
758
+ }
759
+
598
760
  // eslint-disable-next-line no-await-in-loop -- sequential is the contract
599
761
  const r = await fixFinding({ ...opts, finding });
600
762
  results.push(r);
601
763
  const k = String(r.status || '').toLowerCase();
602
764
  if (k in summary) summary[k] += 1;
765
+
766
+ // Record blast radius: any status that got past applyEdit touched a file.
767
+ if (APPLIED.has(r.status) && r.file) {
768
+ filesTouched.add(r.file);
769
+ }
770
+ }
771
+ return {
772
+ results,
773
+ summary,
774
+ capped,
775
+ filesTouched: filesTouched.size,
776
+ maxAutoFixFiles: cap,
777
+ };
778
+ }
779
+
780
+ /* ────────────────────── consensus-HIGH extraction (T27 wire-up) ──────────── */
781
+
782
+ /**
783
+ * Normalise a finding's severity to a lowercase canonical token.
784
+ * Auditors emit `high` / `HIGH` / `High` / sometimes `severity: { level }`.
785
+ */
786
+ function _severityOf(finding) {
787
+ if (!finding || typeof finding !== 'object') return '';
788
+ const raw = finding.severity ?? finding.level ?? '';
789
+ if (raw && typeof raw === 'object') {
790
+ return String(raw.level || raw.severity || '').toLowerCase().trim();
791
+ }
792
+ return String(raw).toLowerCase().trim();
793
+ }
794
+
795
+ /**
796
+ * A stable identity key for cross-lens finding agreement. Two lenses "agree"
797
+ * on the same HIGH when their findings collapse to the same key. We use the
798
+ * file path + a normalised description prefix (whitespace-folded, lowercased,
799
+ * first 80 chars) — precise enough to cluster genuine duplicates, loose enough
800
+ * to survive trivial wording drift between lenses.
801
+ */
802
+ function _consensusKey(finding) {
803
+ const file = String(finding.file || finding.location || finding.path || '').trim();
804
+ const descSource =
805
+ finding.description || finding.issue || finding.text ||
806
+ finding.message || finding.finding || finding.detail || '';
807
+ const desc = String(descSource).toLowerCase().replace(/\s+/g, ' ').trim().slice(0, 80);
808
+ return `${file}::${desc}`;
809
+ }
810
+
811
+ /**
812
+ * consensusHighFindings(perIteration, opts) — given the `perIteration` array
813
+ * that `runPhaseEConverge` returns, extract the HIGH-severity findings on
814
+ * which `minLenses` (default 2) or more lenses agree.
815
+ *
816
+ * This is the bridge T27 was designed for: "when 2+ lenses agree on the same
817
+ * HIGH, the fixer fires automatically." The convergence loop produces
818
+ * `perIteration[*].lensResults[*].findings`; this collapses the final
819
+ * iteration's findings into per-lens-deduped consensus clusters.
820
+ *
821
+ * Returns Array<finding> — each carries `_consensusLenses` (the set of lens
822
+ * ids that flagged it) and `_consensusCount`. Only the LAST iteration is
823
+ * considered: convergence already re-evaluated earlier rounds, so the final
824
+ * round is the swarm's settled position.
825
+ */
826
+ export function consensusHighFindings(perIteration, opts = {}) {
827
+ const minLenses = Number.isInteger(opts.minLenses) && opts.minLenses > 0
828
+ ? opts.minLenses
829
+ : 2;
830
+ if (!Array.isArray(perIteration) || perIteration.length === 0) return [];
831
+ const last = perIteration[perIteration.length - 1];
832
+ if (!last || !Array.isArray(last.lensResults)) return [];
833
+
834
+ // key → { finding, lenses:Set }
835
+ const clusters = new Map();
836
+ for (const lr of last.lensResults) {
837
+ const lens = lr && lr.lens ? lr.lens : 'unknown';
838
+ const findings = Array.isArray(lr && lr.findings) ? lr.findings : [];
839
+ // De-dup within a single lens first so one lens flagging the same issue
840
+ // twice can't manufacture false consensus.
841
+ const seenThisLens = new Set();
842
+ for (const f of findings) {
843
+ if (_severityOf(f) !== 'high' && _severityOf(f) !== 'critical') continue;
844
+ const key = _consensusKey(f);
845
+ if (seenThisLens.has(key)) continue;
846
+ seenThisLens.add(key);
847
+ if (!clusters.has(key)) clusters.set(key, { finding: f, lenses: new Set() });
848
+ clusters.get(key).lenses.add(lens);
849
+ }
603
850
  }
604
- return { results, summary };
851
+
852
+ const out = [];
853
+ for (const { finding, lenses } of clusters.values()) {
854
+ if (lenses.size >= minLenses) {
855
+ out.push({ ...finding, _consensusLenses: [...lenses], _consensusCount: lenses.size });
856
+ }
857
+ }
858
+ return out;
859
+ }
860
+
861
+ /**
862
+ * runConsensusFix({ perIteration, projectRoot, dispatch, ...fixOpts }) —
863
+ * the T27 auto-fix entry point. Extracts consensus HIGHs from a completed
864
+ * `runPhaseEConverge` run and runs the per-finding atomic fixer loop over
865
+ * them.
866
+ *
867
+ * R5-1.10 SAFETY BOUNDARY — auto-fix mutates code, so two hard guards apply
868
+ * (both inherited from `fixFindings` / `fixFinding`, surfaced here):
869
+ * • Path containment — any finding whose target file resolves outside
870
+ * `projectRoot` is REFUSED (status OUT_OF_ROOT); the fixer can never
871
+ * write outside the audited project.
872
+ * • Change cap — `maxAutoFixFiles` (default 10, ceiling 50) bounds the
873
+ * distinct files a single run may touch; beyond it the loop STOPS and
874
+ * reports the remainder (status CAP_REACHED) instead of mass-rewriting.
875
+ * Pass `dryRun: true` for detect-only (reports what it WOULD fix, no edits).
876
+ *
877
+ * Returns:
878
+ * { triggered: false, reason } — nothing to fix
879
+ * { triggered: true, consensusCount, results, summary, capped,
880
+ * filesTouched, maxAutoFixFiles } — fixer ran
881
+ *
882
+ * `dispatch` is required so each fix's Trident re-verify can run.
883
+ */
884
+ export async function runConsensusFix({
885
+ perIteration,
886
+ projectRoot,
887
+ dispatch,
888
+ minLenses = 2,
889
+ ...fixOpts
890
+ } = {}) {
891
+ const consensus = consensusHighFindings(perIteration, { minLenses });
892
+ if (consensus.length === 0) {
893
+ return { triggered: false, reason: 'no consensus HIGH findings' };
894
+ }
895
+ const { results, summary, capped, filesTouched, maxAutoFixFiles } =
896
+ await fixFindings(consensus, {
897
+ ...fixOpts,
898
+ projectRoot,
899
+ dispatch,
900
+ });
901
+ return {
902
+ triggered: true,
903
+ consensusCount: consensus.length,
904
+ results,
905
+ summary,
906
+ capped,
907
+ filesTouched,
908
+ maxAutoFixFiles,
909
+ };
605
910
  }
606
911
 
607
912
  /* ────────────────────────────── test helpers ────────────────────────────── */
@@ -224,7 +224,6 @@ export function toolNameToActionTarget(toolName, args) {
224
224
  return { action: 'write', target: 'memory:write' };
225
225
  case 'ijfw_memory_recall':
226
226
  case 'ijfw_memory_search':
227
- case 'ijfw_memory_status':
228
227
  case 'ijfw_memory_prelude':
229
228
  case 'ijfw_cross_project_search':
230
229
  return { action: 'read', target: 'memory:read' };