session-orchestrator 3.19.0 → 3.21.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 (158) hide show
  1. package/.claude-plugin/marketplace.json +1 -1
  2. package/.claude-plugin/plugin.json +1 -1
  3. package/.codex-plugin/plugin.json +1 -1
  4. package/.cursor/rules/030-wave-execution.mdc +10 -8
  5. package/CHANGELOG.md +494 -0
  6. package/README.md +16 -11
  7. package/agents/analyst.md +1 -1
  8. package/agents/architect-reviewer.md +1 -1
  9. package/agents/code-implementer.md +4 -2
  10. package/agents/db-specialist.md +1 -1
  11. package/agents/dialectic-deriver.md +1 -1
  12. package/agents/docs-writer.md +1 -1
  13. package/agents/memory-proposal-collector.md +1 -1
  14. package/agents/qa-strategist.md +1 -1
  15. package/agents/security-reviewer.md +1 -1
  16. package/agents/session-reviewer.md +42 -1
  17. package/agents/skill-applied-judge.md +1 -1
  18. package/agents/test-writer.md +1 -1
  19. package/agents/ui-developer.md +1 -1
  20. package/agents/ux-evaluator.md +1 -1
  21. package/commands/release.md +60 -0
  22. package/commands/session.md +6 -2
  23. package/docs/USER-GUIDE.md +1 -1
  24. package/docs/instruction-delivery.md +350 -0
  25. package/docs/migration-v3.md +9 -6
  26. package/docs/persona-panel.md +3 -1
  27. package/docs/scope-collision-guard.md +167 -0
  28. package/docs/session-config-reference.md +1 -41
  29. package/docs/session-config-template.md +0 -23
  30. package/hooks/_lib/guard-source-loader.mjs +304 -91
  31. package/hooks/enforce-commands.mjs +216 -17
  32. package/hooks/enforce-scope.mjs +236 -12
  33. package/hooks/hooks-codex.json +1 -1
  34. package/hooks/hooks.json +11 -1
  35. package/hooks/on-session-end.mjs +52 -5
  36. package/hooks/on-session-start.mjs +7 -4
  37. package/hooks/on-stop.mjs +127 -12
  38. package/hooks/post-bash-write-verify.mjs +8 -32
  39. package/hooks/pre-bash-destructive-guard.mjs +146 -59
  40. package/hooks/pre-bash-sessions-ledger-guard.mjs +493 -66
  41. package/hooks/pre-task-scope-disjoint.mjs +1042 -0
  42. package/package.json +2 -2
  43. package/pi/prompts/release.md +12 -0
  44. package/scripts/autopilot.mjs +3 -1
  45. package/scripts/backfill-learnings-from-vault.mjs +967 -0
  46. package/scripts/emit-session.mjs +45 -40
  47. package/scripts/export-hw-learnings.mjs +61 -2
  48. package/scripts/lib/autopilot/worktree-pipeline.mjs +5 -5
  49. package/scripts/lib/backlog-scan.mjs +106 -15
  50. package/scripts/lib/build-live-signals.mjs +7 -3
  51. package/scripts/lib/ci-status-banner.mjs +207 -23
  52. package/scripts/lib/command-blocker.mjs +322 -62
  53. package/scripts/lib/git-config-drift.mjs +471 -0
  54. package/scripts/lib/hardening.mjs +9 -9
  55. package/scripts/lib/harness-audit/categories/category6.mjs +65 -12
  56. package/scripts/lib/io.mjs +193 -7
  57. package/scripts/lib/learnings/affinity.mjs +434 -0
  58. package/scripts/lib/learnings/candidates.mjs +736 -0
  59. package/scripts/lib/learnings/expiry-sweep.mjs +408 -53
  60. package/scripts/lib/learnings/judgment.mjs +782 -0
  61. package/scripts/lib/learnings/kebab.mjs +128 -0
  62. package/scripts/lib/learnings/select.mjs +704 -0
  63. package/scripts/lib/memory-cleanup-stamp.mjs +132 -8
  64. package/scripts/lib/mirror-issues-banner.mjs +266 -0
  65. package/scripts/lib/named-vault-resolver.mjs +105 -16
  66. package/scripts/lib/peer-cards/schema.mjs +6 -2
  67. package/scripts/lib/reconcile/emitter.mjs +107 -22
  68. package/scripts/lib/reconcile/engine.mjs +9 -15
  69. package/scripts/lib/reconcile/renderer.mjs +141 -25
  70. package/scripts/lib/reconcile/sanitize.mjs +518 -0
  71. package/scripts/lib/reconcile/writer.mjs +134 -1
  72. package/scripts/lib/redact-spans.mjs +89 -0
  73. package/scripts/lib/scope-baseline.mjs +77 -17
  74. package/scripts/lib/scope-gate.mjs +852 -72
  75. package/scripts/lib/secret-masker.mjs +262 -0
  76. package/scripts/lib/session-close-backfill.mjs +2 -2
  77. package/scripts/lib/session-lock.mjs +34 -10
  78. package/scripts/lib/session-record-repair.mjs +551 -0
  79. package/scripts/lib/session-registry.mjs +9 -1
  80. package/scripts/lib/session-schema/serializer.mjs +54 -0
  81. package/scripts/lib/session-schema.mjs +1 -0
  82. package/scripts/lib/session-token-rollup.mjs +68 -6
  83. package/scripts/lib/soul-resolve.mjs +12 -0
  84. package/scripts/lib/state-md/mission-status.mjs +21 -12
  85. package/scripts/lib/tmux-layout/telemetry.mjs +43 -10
  86. package/scripts/lib/tmux-layout/vcs-detector.mjs +108 -4
  87. package/scripts/lib/validate/check-agents.mjs +77 -5
  88. package/scripts/lib/validate/check-banner-parity.mjs +376 -0
  89. package/scripts/lib/validate/check-commands.mjs +2 -20
  90. package/scripts/lib/validate/check-doc-cli-commands.mjs +514 -0
  91. package/scripts/lib/validate/check-guard-requires-parity.mjs +1148 -0
  92. package/scripts/lib/validate/check-hooks-symmetry.mjs +18 -0
  93. package/scripts/lib/validate/check-learning-provenance.mjs +511 -0
  94. package/scripts/lib/validate/check-owner-leakage.mjs +188 -20
  95. package/scripts/lib/validate/check-rules.mjs +31 -5
  96. package/scripts/lib/validate/check-skills.mjs +191 -0
  97. package/scripts/lib/validate/check-test-git-config-target.mjs +665 -0
  98. package/scripts/lib/validate/check-unicode-safety.mjs +22 -2
  99. package/scripts/lib/validate/check-untracked-test-deps.mjs +925 -0
  100. package/scripts/lib/validate/check-unwired-features.mjs +757 -0
  101. package/scripts/lib/validate/check-vcs-repo-flag.mjs +965 -0
  102. package/scripts/lib/validate/frontmatter-block.mjs +61 -0
  103. package/scripts/lib/validate/tier-inference.mjs +46 -8
  104. package/scripts/lib/vault-mirror/namespace.mjs +146 -1
  105. package/scripts/lib/vault-mirror/process.mjs +264 -31
  106. package/scripts/lib/vault-mirror/render-sessions.mjs +115 -4
  107. package/scripts/lib/vault-status/board-writer.mjs +300 -56
  108. package/scripts/lib/vault-status/narrative-mirror.mjs +119 -5
  109. package/scripts/lib/vcs-repo-spec.mjs +500 -19
  110. package/scripts/print-applicable-rules.mjs +170 -7
  111. package/scripts/print-learnings-index.mjs +501 -0
  112. package/scripts/release.mjs +616 -61
  113. package/scripts/repair-invalid-sessions.mjs +209 -0
  114. package/scripts/site-numbers.mjs +1049 -0
  115. package/scripts/sweep-expired-learnings.mjs +192 -32
  116. package/scripts/validate-plugin.mjs +82 -0
  117. package/scripts/validate-wave-scope.mjs +281 -12
  118. package/scripts/vault-mirror.mjs +26 -1
  119. package/skills/_shared/monitor-patterns.md +24 -4
  120. package/skills/_shared/state-ownership.md +17 -0
  121. package/skills/brainstorm/soul.md +47 -1
  122. package/skills/claude-md-drift-check/SKILL.md +9 -1
  123. package/skills/debug/SKILL.md +4 -1
  124. package/skills/discovery/issue-templates.md +4 -4
  125. package/skills/discovery/probes-code.md +2 -2
  126. package/skills/discovery/probes-feature.md +6 -6
  127. package/skills/discovery/probes-infra.md +2 -2
  128. package/skills/discovery/probes-session.md +5 -5
  129. package/skills/dispatcher/SKILL.md +10 -1
  130. package/skills/evolve/SKILL.md +116 -18
  131. package/skills/frontmatter-guard/SKILL.md +9 -1
  132. package/skills/gitlab-ops/SKILL.md +54 -39
  133. package/skills/gitlab-portfolio/SKILL.md +10 -1
  134. package/skills/grill/soul.md +44 -1
  135. package/skills/memory-cleanup/SKILL.md +18 -5
  136. package/skills/npm-publish/SKILL.md +22 -50
  137. package/skills/persona-panel/SKILL.md +3 -1
  138. package/skills/plan/mode-new.md +23 -5
  139. package/skills/plan/soul.md +46 -3
  140. package/skills/repo-audit/SKILL.md +10 -1
  141. package/skills/session-end/SKILL.md +45 -26
  142. package/skills/session-end/metrics-collection.md +1 -1
  143. package/skills/session-end/phase-3-6-tail.md +30 -1
  144. package/skills/session-end/plan-verification.md +1 -5
  145. package/skills/session-end/session-metrics-write.md +6 -10
  146. package/skills/session-plan/SKILL.md +2 -2
  147. package/skills/session-plan/wave-template.md +1 -1
  148. package/skills/session-start/SKILL.md +15 -1
  149. package/skills/session-start/soul.md +41 -1
  150. package/skills/spinout/SKILL.md +5 -1
  151. package/skills/sunset-review/SKILL.md +11 -1
  152. package/skills/tmux-layout/SKILL.md +7 -2
  153. package/skills/vault-mirror/SKILL.md +10 -1
  154. package/skills/vault-sync/SKILL.md +10 -1
  155. package/skills/vault-sync/validator.mjs +55 -6
  156. package/skills/wave-executor/SKILL.md +1 -5
  157. package/skills/wave-executor/wave-loop.md +77 -82
  158. package/scripts/lib/mission-status-schema.mjs +0 -114
@@ -17,7 +17,16 @@
17
17
  */
18
18
 
19
19
  import { writeFile, rename, mkdir } from 'node:fs/promises';
20
- import { mkdirSync, writeFileSync, renameSync, readFileSync, existsSync, writeSync } from 'node:fs';
20
+ import {
21
+ mkdirSync,
22
+ writeFileSync,
23
+ renameSync,
24
+ readFileSync,
25
+ existsSync,
26
+ writeSync,
27
+ copyFileSync,
28
+ unlinkSync,
29
+ } from 'node:fs';
21
30
  import path, { dirname } from 'node:path';
22
31
  import { randomBytes, randomUUID } from 'node:crypto';
23
32
 
@@ -556,6 +565,26 @@ export async function writeJsonAtomic(filePath, value, opts = {}) {
556
565
  * used from any layer without violating the layering rule in
557
566
  * `scripts/lib/hardening.mjs`.
558
567
  *
568
+ * Implementation: this is the JSON-serializing façade over
569
+ * {@link atomicWriteWithBackup} (`backup: false`) — the two functions carried
570
+ * the identical `mkdir → tmp → write → rename → catch` body 60 lines apart,
571
+ * which is the very duplication `atomicWriteWithBackup` was introduced to end.
572
+ * Everything below the `JSON.stringify` is the delegate's; the wrapper exists
573
+ * for the serialization step and for NARROWING the envelope (see @returns).
574
+ *
575
+ * Two things are deliberately kept here rather than pushed into the delegate:
576
+ *
577
+ * - **The stringify stays inside a try.** `JSON.stringify` throws on a cycle
578
+ * (and on a BigInt). Hoisting it above the delegation call would turn that
579
+ * into an escaping TypeError, and 4 of the 9 caller files (`loop-guard`,
580
+ * `lock-bootstrap`, `file-lock`, `issue-budget`) invoke this without a
581
+ * try/catch of their own. A serialization failure reports as `fs-error`
582
+ * because that is what this helper has always returned for it.
583
+ * - **The success envelope is narrowed back to `{ ok: true }`.** The delegate
584
+ * also reports `path`/`bytes`/`backupPath`; `session-lock.mjs#writeOwnerProof`
585
+ * propagates THIS object verbatim on failure (`if (!w.ok) return w`), so the
586
+ * key set is part of a contract that reaches further than this file.
587
+ *
559
588
  * @param {string} filePath Target path; parent dirs created with mkdir -p semantics.
560
589
  * @param {*} data JSON-serializable value.
561
590
  * @param {object} [opts]
@@ -565,15 +594,172 @@ export async function writeJsonAtomic(filePath, value, opts = {}) {
565
594
  */
566
595
  export function writeJsonAtomicSync(filePath, data, opts = {}) {
567
596
  const { indent = 2, tmpPrefix = '.tmp' } = opts;
597
+
598
+ let body;
599
+ try {
600
+ body = JSON.stringify(data, null, indent) + '\n';
601
+ } catch (err) {
602
+ return { ok: false, reason: 'fs-error', error: err?.message ?? String(err) };
603
+ }
604
+
605
+ const res = atomicWriteWithBackup(filePath, body, { tmpPrefix });
606
+ return res.ok ? { ok: true } : res;
607
+ }
608
+
609
+ /**
610
+ * Content-agnostic sibling of {@link writeJsonAtomicSync}: atomically replace a
611
+ * file with an arbitrary string/Buffer body, optionally snapshotting the
612
+ * previous contents to a timestamped `.bak-<ISO>` sidecar first (issue #734).
613
+ *
614
+ * The repo carries the copy→tmp→rename idiom in ~25 hand-rolled places
615
+ * (`learnings/io.mjs#rewriteLearnings`, `session-record-repair.mjs`,
616
+ * `owner-interview.mjs`, `backfill-learnings.mjs`, …). Each spelling differs in
617
+ * small ways — some take the backup, some do not; some `mkdir -p`, some assume
618
+ * the directory exists — which is exactly how a crash-safety guarantee rots.
619
+ * This helper is the shared spelling; **validation policy stays with the
620
+ * callers** (this function never inspects `body`).
621
+ *
622
+ * Crash-safety: write `<dir>/<tmpPrefix>.<rand>`, then `renameSync` over the
623
+ * target. Same-filesystem rename is atomic on POSIX, so an observer sees either
624
+ * the previous contents or the new ones — never a half-written file. The tmp
625
+ * file is created as a SIBLING of the target on purpose: a tmp in `os.tmpdir()`
626
+ * may sit on a different filesystem, where `rename` degrades to a non-atomic
627
+ * copy (EXDEV).
628
+ *
629
+ * Backup (`backup: true`) copies the CURRENT file to `<filePath>.bak-<ISO>`
630
+ * before the rename, with `:`/`.` swapped for `-` so a lexical sort of the
631
+ * siblings is chronological (same convention as `learnings/io.mjs`). A
632
+ * first-time write has nothing to lose, so no backup is taken when the target
633
+ * does not exist. **Rotation is deliberately NOT done here** — how many
634
+ * snapshots a store is worth is a per-caller policy (`learnings/io.mjs` keeps
635
+ * 3; a re-derivable file wants 0), and a keep-N default baked into the
636
+ * primitive would silently unlink a caller's snapshots.
637
+ *
638
+ * Caller is responsible for path-confinement — this helper does NOT validate
639
+ * that `filePath` lives inside the project (mirrors {@link writeJsonAtomic} /
640
+ * {@link writeJsonAtomicSync}).
641
+ *
642
+ * On a FAILED write the tmp sibling is unlinked (best-effort) before the error
643
+ * envelope is returned. Without that, every failed write leaves a
644
+ * `<tmpPrefix>.<hex>` behind — the board writer's failure mode is a retry loop,
645
+ * so the litter accumulates in the operator's vault directory. The cleanup is
646
+ * attempted only when the write actually created the tmp file, and its own
647
+ * failure is swallowed: a leaked tmp is worse than a silent unlink miss, and
648
+ * neither may mask the original error.
649
+ *
650
+ * ── BV-004 ceiling + revisit trigger ────────────────────────────────────────
651
+ * TWO PRODUCTION CALL-SITES: `vault-status/board-writer.mjs#writeBoard`
652
+ * (`backup: false`) and {@link writeJsonAtomicSync}, which carries 12 further
653
+ * call-sites across 9 files behind it. That second one is the load-bearing
654
+ * evidence — the previous revision of this note recorded ONE call-site and
655
+ * concluded the signature was unproven, while the function with an identical
656
+ * body sat 60 lines above in this same file, unmigrated. The cheapest possible
657
+ * migration going unmade is not a neutral fact about a helper: it is the
658
+ * measurement that the helper is not paying for itself.
659
+ *
660
+ * What the second call-site does NOT prove: `writeJsonAtomicSync` passes
661
+ * `backup: false` and no `fs`, so the backup half and the injection seam still
662
+ * rest on tests plus one board-writer flag. REVISIT TRIGGER — when a sweep
663
+ * migrates the remaining hand-rolled sites, re-check before widening:
664
+ * (a) whether an `async` twin is needed rather than bolting a promise mode onto
665
+ * this one (three known sites are `fs/promises`), and (b) whether rotation
666
+ * belongs here after all (it does only if ≥2 migrated callers want the SAME
667
+ * keep-N). If NO caller ever passes `backup: true` in production, that half is
668
+ * still the part to shrink back.
669
+ *
670
+ * @param {string} filePath Target path; parent dirs created with mkdir -p semantics.
671
+ * @param {string|Buffer} body Bytes to write, verbatim. Never inspected.
672
+ * @param {object} [opts]
673
+ * @param {BufferEncoding} [opts.encoding='utf8'] Encoding for a string `body`.
674
+ * @param {boolean} [opts.backup=false] Snapshot the existing file to `.bak-<ISO>` first.
675
+ * @param {string} [opts.tmpPrefix='.tmp'] Tmp-file prefix (callers pick their domain prefix).
676
+ * @param {Date} [opts.now] Clock seam for the backup stamp (tests).
677
+ * @param {{ mkdirSync?: Function, writeFileSync?: Function, renameSync?: Function,
678
+ * copyFileSync?: Function, existsSync?: Function, unlinkSync?: Function }} [opts.fs]
679
+ * Injectable fs (tests). Omitted methods fall back to `node:fs` — EXCEPT on
680
+ * the backup path, which fails closed (see below).
681
+ * @returns {{ ok: true, path: string, bytes: number, backupPath: string|null }
682
+ * | { ok: false, reason: 'fs-error', error: string }}
683
+ */
684
+ export function atomicWriteWithBackup(filePath, body, opts = {}) {
685
+ const {
686
+ encoding = 'utf8',
687
+ backup = false,
688
+ tmpPrefix = '.tmp',
689
+ now = new Date(),
690
+ fs: injectedFs,
691
+ } = opts;
692
+
693
+ const fsMkdir = injectedFs?.mkdirSync ?? mkdirSync;
694
+ const fsWriteFile = injectedFs?.writeFileSync ?? writeFileSync;
695
+ const fsRename = injectedFs?.renameSync ?? renameSync;
696
+ const fsCopyFile = injectedFs?.copyFileSync ?? copyFileSync;
697
+ const fsExists = injectedFs?.existsSync ?? existsSync;
698
+ const fsUnlink = injectedFs?.unlinkSync ?? unlinkSync;
699
+
700
+ // ── Partial-adapter fail-closed, backup path only ──────────────────────────
701
+ //
702
+ // Per-method fallback to the real `node:fs` is the right default for the
703
+ // three ALWAYS-used methods: `board-writer.mjs#writeBoard` passes an fs object
704
+ // on EVERY call, including production, where `renameSync`/`copyFileSync` are
705
+ // present-but-`undefined` because nothing was injected. "An injected object
706
+ // must be total" would therefore reject the only real caller — the shape is
707
+ // not evidence of a fake.
708
+ //
709
+ // The backup path is different in kind. It runs ONLY under `backup: true`, and
710
+ // there a missing method routes a real `copyFileSync`/`existsSync` at the real
711
+ // filesystem while the write goes to the fake: a suite that believes itself
712
+ // hermetic drops `.bak-<ISO>` files into the repo, and nothing says so. Both
713
+ // methods are guarded, not just `copyFileSync` — a missing `existsSync` probes
714
+ // the real target and silently decides the backup branch from it, which is the
715
+ // same escape one step earlier.
716
+ if (backup && injectedFs) {
717
+ for (const method of ['existsSync', 'copyFileSync']) {
718
+ if (typeof injectedFs[method] !== 'function') {
719
+ return {
720
+ ok: false,
721
+ reason: 'fs-error',
722
+ error: `partial fs adapter: ${method} required for backup`,
723
+ };
724
+ }
725
+ }
726
+ }
727
+
728
+ let tmpFile = null;
729
+ let tmpCreated = false;
730
+
568
731
  try {
569
732
  const dir = dirname(filePath);
570
- mkdirSync(dir, { recursive: true });
571
- const tmpSuffix = randomBytes(6).toString('hex');
572
- const tmpFile = path.join(dir, `${tmpPrefix}.${tmpSuffix}`);
573
- writeFileSync(tmpFile, JSON.stringify(data, null, indent) + '\n', { encoding: 'utf8' });
574
- renameSync(tmpFile, filePath);
575
- return { ok: true };
733
+ fsMkdir(dir, { recursive: true });
734
+
735
+ let backupPath = null;
736
+ if (backup && fsExists(filePath)) {
737
+ const stamp = (now instanceof Date ? now : new Date()).toISOString().replace(/[:.]/g, '-');
738
+ backupPath = `${filePath}.bak-${stamp}`;
739
+ fsCopyFile(filePath, backupPath);
740
+ }
741
+
742
+ tmpFile = path.join(dir, `${tmpPrefix}.${randomBytes(6).toString('hex')}`);
743
+ fsWriteFile(tmpFile, body, encoding);
744
+ tmpCreated = true;
745
+ fsRename(tmpFile, filePath);
746
+
747
+ return {
748
+ ok: true,
749
+ path: filePath,
750
+ bytes: Buffer.isBuffer(body) ? body.length : Buffer.byteLength(String(body), encoding),
751
+ backupPath,
752
+ };
576
753
  } catch (err) {
754
+ // Only when the write got far enough to create it. The name carries 12 hex
755
+ // chars of entropy, so this cannot collide with a caller's real file.
756
+ if (tmpCreated) {
757
+ try {
758
+ fsUnlink(tmpFile);
759
+ } catch {
760
+ // Best-effort: never let cleanup replace the error the caller needs.
761
+ }
762
+ }
577
763
  return { ok: false, reason: 'fs-error', error: err?.message ?? String(err) };
578
764
  }
579
765
  }
@@ -0,0 +1,434 @@
1
+ /**
2
+ * learnings/affinity.mjs — pure relatedness primitive for learnings.
3
+ *
4
+ * ONE surface, two consumers:
5
+ * - scope→learning relevance (#1014): how relevant is this learning to a
6
+ * wave-agent's declared file scope + task title?
7
+ * - learning→learning similarity (#1016): which other learnings may
8
+ * duplicate or contradict this one?
9
+ *
10
+ * Both collapse into `affinity(a, b)` because both sides are read through the
11
+ * same {@link AffinityContext} union: a bag of file paths plus a bag of text.
12
+ * A scope descriptor `{file_paths, text}` and a learning record are, for the
13
+ * purpose of "how related are these two things", the same shape.
14
+ *
15
+ * ## What this module is
16
+ *
17
+ * A relatedness function. Given two things, how related are they? That is all.
18
+ *
19
+ * ## What this module is NOT (deliberate boundary)
20
+ *
21
+ * - No fs. Reading learnings.jsonl belongs to `learnings/io.mjs` / `surfaceTopN`.
22
+ * - No clock. Recency decay and confidence floors belong to
23
+ * `learnings/surface.mjs` — note {@link effectiveScore} there takes an
24
+ * explicit `nowMs`; that time axis stays OUT of this file so `affinity` is
25
+ * referentially transparent.
26
+ * - No top-K, no thresholds, no char caps, no formatting, no Session Config.
27
+ * Those decide WHICH things are chosen and HOW they are printed — policy,
28
+ * owned by the consumers.
29
+ *
30
+ * The one-line test: if removing it would change *which* things are chosen or
31
+ * *how they are printed*, it is policy and belongs to a consumer; if removing
32
+ * it would change *how related two things are*, it belongs here.
33
+ *
34
+ * ## Import graph (acyclic by construction)
35
+ *
36
+ * Exactly one sibling edge: `affinity.mjs → ./schema.mjs`, plus stdlib —
37
+ * and here not even stdlib. `schema.mjs` is a pure leaf that imports only
38
+ * `node:crypto` and is contractually forbidden from importing siblings, so no
39
+ * cycle is reachable. Never import `../learnings.mjs` from here.
40
+ *
41
+ * Dialect handling imports {@link normalizeDialects}, NOT `normalizeLearning`:
42
+ * the latter emits deduped `console.error` WARNs for a missing `schema_version`
43
+ * and for missing legacy fields (schema.mjs), which inside an N×M affinity loop
44
+ * over the corpus would spam stderr on every agent dispatch. `normalizeDialects`
45
+ * does the one thing needed here — legacy `files` read as `file_paths`.
46
+ *
47
+ * ## Contract
48
+ *
49
+ * 1. Every returned number is finite and in [0,1]. Never NaN/Infinity.
50
+ * 2. Symmetric: affinity(a,b).score === affinity(b,a).score. (#1016 halves an
51
+ * O(n²) pass on this.)
52
+ * 3. Deterministic and pure: no clock, no fs, no randomness, no network.
53
+ * 4. Never throws. Hostile input yields the all-zero result — a ranking
54
+ * primitive on the dispatch hot path must never abort a wave. (Matches the
55
+ * read-path convention of `surfaceTopN` returning [] on an unreadable file;
56
+ * deliberately NOT `validateLearning`'s throwing ValidationError.)
57
+ * 5. Path matching is segment-aware: exact > directory-prefix > shared
58
+ * ancestor, on `/`-split segments, case-SENSITIVE (Linux CI is the
59
+ * authority). Glob metacharacters are compared literally — this module
60
+ * never expands globs; the caller pre-expands.
61
+ * 6. Fields read: `file_paths[]` (+ legacy `files`), `type`, `subject`,
62
+ * `insight`, `evidence` (may legally be an array — not coerced), `title`,
63
+ * and the context-only `text`.
64
+ * Fields deliberately NOT read: `confidence`, `created_at`/`updated_at`/
65
+ * `expires_at`/`last_reinforced`, `scope`, `host_class`, `anonymized`,
66
+ * `source_session`, `id` — ranking/policy/privacy axes owned elsewhere.
67
+ */
68
+
69
+ import { normalizeDialects } from './schema.mjs';
70
+
71
+ /**
72
+ * @typedef {{file_paths?: string[], files?: string[], text?: string, type?: string}} AffinityContext
73
+ * The union both consumers pass. A raw learning record satisfies it as-is
74
+ * (it carries `file_paths`/`files` and `type`); a scope descriptor satisfies
75
+ * it with `{file_paths, text}`.
76
+ */
77
+
78
+ /**
79
+ * @typedef {{filePaths: string[], tokens: string[], type: string|null}} NormalizedContext
80
+ */
81
+
82
+ /**
83
+ * @typedef {{score: number, pathScore: number, tokenScore: number,
84
+ * typeMatch: boolean, sharedPaths: string[], sharedTokens: string[]}} AffinityResult
85
+ */
86
+
87
+ /** Blend weights + tokenizer floor. Callers may override per call via `opts`. */
88
+ export const AFFINITY_DEFAULTS = Object.freeze({
89
+ pathWeight: 0.6,
90
+ tokenWeight: 0.4,
91
+ minTokenLength: 3,
92
+ });
93
+
94
+ /**
95
+ * Per-pair path scores. The ORDER is the contract (exact > prefix > ancestor);
96
+ * the exact magnitudes are tuning. `PATH_ANCESTOR_MAX` is a strict upper bound
97
+ * never reached — the ancestor branch only runs when the shared prefix is
98
+ * shorter than both paths, so its ratio is always < 1 and its score < 0.5,
99
+ * keeping it strictly below `PATH_PREFIX`.
100
+ */
101
+ const PATH_EXACT = 1;
102
+ const PATH_PREFIX = 0.75;
103
+ const PATH_ANCESTOR_MAX = 0.5;
104
+
105
+ /** Cap on the reported `sharedTokens` — a diagnostic list, not a payload. */
106
+ const SHARED_TOKEN_CAP = 32;
107
+
108
+ /** Max nesting depth followed when tokenizing an array-valued field. */
109
+ const MAX_TEXT_DEPTH = 3;
110
+
111
+ /** Text-bearing fields read for tokens, in a fixed order (determinism). */
112
+ const TEXT_FIELDS = Object.freeze(['text', 'title', 'subject', 'insight', 'evidence']);
113
+
114
+ // ---------------------------------------------------------------------------
115
+ // Internals
116
+ // ---------------------------------------------------------------------------
117
+
118
+ /** Clamp to a finite [0,1]. Non-finite input (NaN from an empty division,
119
+ * Infinity from a bad weight) collapses to 0 rather than escaping. */
120
+ function _clamp01(n) {
121
+ if (typeof n !== 'number' || !Number.isFinite(n)) return 0;
122
+ if (n <= 0) return 0;
123
+ if (n >= 1) return 1;
124
+ return n;
125
+ }
126
+
127
+ /** True for a plain-ish object we may read properties off (not null, not array). */
128
+ function _isRecord(v) {
129
+ return v !== null && typeof v === 'object' && !Array.isArray(v);
130
+ }
131
+
132
+ /** The all-zero result. Built fresh per call so a consumer can never mutate a
133
+ * shared singleton out from under the next caller. */
134
+ function _emptyResult() {
135
+ return {
136
+ score: 0,
137
+ pathScore: 0,
138
+ tokenScore: 0,
139
+ typeMatch: false,
140
+ sharedPaths: [],
141
+ sharedTokens: [],
142
+ };
143
+ }
144
+
145
+ /**
146
+ * Canonicalize a repo-relative path for comparison: trim, strip leading `./`
147
+ * (repeatable), strip trailing `/`. Case is preserved — Linux CI is the
148
+ * authority, so `Scripts/` and `scripts/` are different paths.
149
+ * Returns '' for anything unusable.
150
+ */
151
+ function _normalizePath(p) {
152
+ if (typeof p !== 'string') return '';
153
+ let s = p.trim();
154
+ while (s.startsWith('./')) s = s.slice(2);
155
+ while (s.length > 1 && s.endsWith('/')) s = s.slice(0, -1);
156
+ return s;
157
+ }
158
+
159
+ /**
160
+ * Score one path pair on `/`-split segments.
161
+ *
162
+ * exact (1) > directory-prefix (0.75) > shared-ancestor (< 0.5, scaled by how
163
+ * much of the longer path the shared prefix covers) > unrelated (0).
164
+ *
165
+ * Segment-aware, never string-prefix: `scripts/lib/learn` is NOT a prefix of
166
+ * `scripts/lib/learnings/io.mjs`, it is a 2-segment shared ancestor.
167
+ */
168
+ function _pairScore(aNorm, bNorm) {
169
+ if (!aNorm || !bNorm) return 0;
170
+ if (aNorm === bNorm) return PATH_EXACT;
171
+
172
+ const aSeg = aNorm.split('/').filter(Boolean);
173
+ const bSeg = bNorm.split('/').filter(Boolean);
174
+ if (aSeg.length === 0 || bSeg.length === 0) return 0;
175
+
176
+ const min = Math.min(aSeg.length, bSeg.length);
177
+ let shared = 0;
178
+ while (shared < min && aSeg[shared] === bSeg[shared]) shared++;
179
+
180
+ if (shared === 0) return 0;
181
+ // Equality was handled above, so a full-shorter match means the shorter path
182
+ // is a strict directory prefix of the longer one.
183
+ if (shared === min) return PATH_PREFIX;
184
+ return PATH_ANCESTOR_MAX * (shared / Math.max(aSeg.length, bSeg.length));
185
+ }
186
+
187
+ /** Best score of `p` against any path in `others`. */
188
+ function _bestAgainst(p, others) {
189
+ let best = 0;
190
+ for (const q of others) {
191
+ const s = _pairScore(p, q);
192
+ if (s > best) best = s;
193
+ if (best === PATH_EXACT) break;
194
+ }
195
+ return best;
196
+ }
197
+
198
+ /**
199
+ * Aggregate two path lists into one [0,1] score.
200
+ *
201
+ * Mean-of-best-match in BOTH directions, averaged — symmetric by construction.
202
+ * A one-directional "mean over a of best in b" is the naive form and is NOT
203
+ * symmetric when the lists differ in size, which would break contract point 2.
204
+ *
205
+ * O(n·m): scopes are a handful of paths and the corpus is ~10² entries, so the
206
+ * product is trivial. Revisit if a caller ever passes a scope above ~200 paths.
207
+ */
208
+ function _pathScoreFromLists(aPaths, bPaths) {
209
+ if (aPaths.length === 0 || bPaths.length === 0) return 0;
210
+
211
+ let sumA = 0;
212
+ for (const p of aPaths) sumA += _bestAgainst(p, bPaths);
213
+ let sumB = 0;
214
+ for (const q of bPaths) sumB += _bestAgainst(q, aPaths);
215
+
216
+ return _clamp01((sumA / aPaths.length + sumB / bPaths.length) / 2);
217
+ }
218
+
219
+ /** Jaccard over token sets: |A∩B| / |A∪B|. Symmetric by construction. */
220
+ function _tokenScoreFromLists(aTokens, bTokens) {
221
+ const setA = new Set(aTokens);
222
+ const setB = new Set(bTokens);
223
+ if (setA.size === 0 || setB.size === 0) return 0;
224
+
225
+ let inter = 0;
226
+ for (const t of setA) if (setB.has(t)) inter++;
227
+ const union = setA.size + setB.size - inter;
228
+ if (union <= 0) return 0;
229
+ return _clamp01(inter / union);
230
+ }
231
+
232
+ /** Sorted, deduped intersection of two string lists. */
233
+ function _sharedSorted(a, b) {
234
+ const setB = new Set(b);
235
+ const out = new Set();
236
+ for (const v of a) if (setB.has(v)) out.add(v);
237
+ return [...out].sort();
238
+ }
239
+
240
+ /** Resolve caller opts over AFFINITY_DEFAULTS, rejecting non-finite/negative
241
+ * weights and non-integer token floors rather than propagating them. */
242
+ function _resolveOpts(opts) {
243
+ const o = _isRecord(opts) ? opts : {};
244
+ const weight = (v, fallback) =>
245
+ typeof v === 'number' && Number.isFinite(v) && v >= 0 ? v : fallback;
246
+ return {
247
+ pathWeight: weight(o.pathWeight, AFFINITY_DEFAULTS.pathWeight),
248
+ tokenWeight: weight(o.tokenWeight, AFFINITY_DEFAULTS.tokenWeight),
249
+ minTokenLength:
250
+ Number.isInteger(o.minTokenLength) && o.minTokenLength >= 1
251
+ ? o.minTokenLength
252
+ : AFFINITY_DEFAULTS.minTokenLength,
253
+ };
254
+ }
255
+
256
+ // ---------------------------------------------------------------------------
257
+ // Public surface
258
+ // ---------------------------------------------------------------------------
259
+
260
+ /**
261
+ * Split text into comparable lowercase tokens.
262
+ *
263
+ * Accepts a string, or an array of strings (legacy `evidence` may legally be an
264
+ * array and is deliberately not coerced upstream — see schema.mjs). Nested
265
+ * arrays are followed to {@link MAX_TEXT_DEPTH}. Anything else yields [].
266
+ *
267
+ * Tokens are lowercased, split on any non-alphanumeric run, filtered to
268
+ * `minTokenLength` or longer, and deduped in first-appearance order (stable,
269
+ * so equal inputs always produce an equal array).
270
+ *
271
+ * @param {unknown} text
272
+ * @param {{minTokenLength?: number}} [opts]
273
+ * @returns {string[]}
274
+ */
275
+ export function tokenize(text, opts) {
276
+ const { minTokenLength } = _resolveOpts(opts);
277
+ const out = [];
278
+ const seen = new Set();
279
+
280
+ const walk = (value, depth) => {
281
+ if (typeof value === 'string') {
282
+ for (const raw of value.toLowerCase().split(/[^a-z0-9]+/)) {
283
+ if (raw.length < minTokenLength || seen.has(raw)) continue;
284
+ seen.add(raw);
285
+ out.push(raw);
286
+ }
287
+ return;
288
+ }
289
+ if (Array.isArray(value) && depth < MAX_TEXT_DEPTH) {
290
+ for (const el of value) walk(el, depth + 1);
291
+ }
292
+ };
293
+
294
+ walk(text, 0);
295
+ return out;
296
+ }
297
+
298
+ /**
299
+ * Project any {@link AffinityContext}-ish input onto the normalized shape the
300
+ * scorers compare. Total: hostile input yields the empty context, never a throw.
301
+ *
302
+ * Legacy `files` is read as `file_paths` via {@link normalizeDialects}
303
+ * (`reserializeTimestamps: false` — this module never reads timestamps, so
304
+ * re-parsing them would be pure waste).
305
+ *
306
+ * @param {unknown} input
307
+ * @param {{minTokenLength?: number}} [opts] — tokenizer tuning; optional.
308
+ * @returns {NormalizedContext}
309
+ */
310
+ export function toAffinityContext(input, opts) {
311
+ if (!_isRecord(input)) return { filePaths: [], tokens: [], type: null };
312
+
313
+ try {
314
+ let record = input;
315
+ try {
316
+ record = normalizeDialects(input, { reserializeTimestamps: false });
317
+ } catch {
318
+ // A dialect quirk must never abort a ranking pass — fall back to the raw
319
+ // record and read `files` directly below.
320
+ record = input;
321
+ }
322
+ if (!_isRecord(record)) record = input;
323
+
324
+ const rawPaths = Array.isArray(record.file_paths)
325
+ ? record.file_paths
326
+ : Array.isArray(record.files)
327
+ ? record.files
328
+ : [];
329
+
330
+ const filePaths = [];
331
+ const seenPaths = new Set();
332
+ for (const p of rawPaths) {
333
+ const norm = _normalizePath(p);
334
+ if (norm.length === 0 || seenPaths.has(norm)) continue;
335
+ seenPaths.add(norm);
336
+ filePaths.push(norm);
337
+ }
338
+
339
+ const { minTokenLength } = _resolveOpts(opts);
340
+ const tokens = [];
341
+ const seenTokens = new Set();
342
+ for (const field of TEXT_FIELDS) {
343
+ for (const t of tokenize(record[field], { minTokenLength })) {
344
+ if (seenTokens.has(t)) continue;
345
+ seenTokens.add(t);
346
+ tokens.push(t);
347
+ }
348
+ }
349
+
350
+ const type =
351
+ typeof record.type === 'string' && record.type.trim().length > 0
352
+ ? record.type.trim()
353
+ : null;
354
+
355
+ return { filePaths, tokens, type };
356
+ } catch {
357
+ // Exotic shape (throwing getter, hostile Proxy). Every scorer downstream
358
+ // stays total because this is the ONLY place raw input is read.
359
+ return { filePaths: [], tokens: [], type: null };
360
+ }
361
+ }
362
+
363
+ /**
364
+ * File-path relatedness of two contexts, in [0,1].
365
+ * Segment-aware and symmetric — see {@link _pairScore} and
366
+ * {@link _pathScoreFromLists}.
367
+ *
368
+ * @param {unknown} a
369
+ * @param {unknown} b
370
+ * @returns {number}
371
+ */
372
+ export function pathAffinity(a, b) {
373
+ return _pathScoreFromLists(toAffinityContext(a).filePaths, toAffinityContext(b).filePaths);
374
+ }
375
+
376
+ /**
377
+ * Text relatedness of two contexts, in [0,1] (Jaccard over token sets).
378
+ *
379
+ * @param {unknown} a
380
+ * @param {unknown} b
381
+ * @returns {number}
382
+ */
383
+ export function tokenAffinity(a, b) {
384
+ return _tokenScoreFromLists(toAffinityContext(a).tokens, toAffinityContext(b).tokens);
385
+ }
386
+
387
+ /**
388
+ * The primitive both consumers call.
389
+ *
390
+ * `score` is the weight-normalized blend of `pathScore` and `tokenScore`:
391
+ * `(pw·path + tw·token) / (pw + tw)`, so it stays in [0,1] for ANY non-negative
392
+ * weight pair, not only ones that sum to 1.
393
+ *
394
+ * `typeMatch` is REPORTED, never folded into `score`. Whether a same-type pair
395
+ * deserves a boost is a ranking decision, and ranking is the consumer's.
396
+ *
397
+ * `sharedPaths` lists exactly-overlapping normalized paths only — a
398
+ * directory-prefix pair raises `pathScore` without appearing here.
399
+ *
400
+ * @param {unknown} a
401
+ * @param {unknown} b
402
+ * @param {{pathWeight?: number, tokenWeight?: number, minTokenLength?: number}} [opts]
403
+ * @returns {AffinityResult}
404
+ */
405
+ export function affinity(a, b, opts) {
406
+ try {
407
+ const { pathWeight, tokenWeight, minTokenLength } = _resolveOpts(opts);
408
+ const ctxA = toAffinityContext(a, { minTokenLength });
409
+ const ctxB = toAffinityContext(b, { minTokenLength });
410
+
411
+ const pathScore = _pathScoreFromLists(ctxA.filePaths, ctxB.filePaths);
412
+ const tokenScore = _tokenScoreFromLists(ctxA.tokens, ctxB.tokens);
413
+
414
+ const totalWeight = pathWeight + tokenWeight;
415
+ const score =
416
+ totalWeight > 0
417
+ ? _clamp01((pathWeight * pathScore + tokenWeight * tokenScore) / totalWeight)
418
+ : 0;
419
+
420
+ return {
421
+ score,
422
+ pathScore,
423
+ tokenScore,
424
+ typeMatch: ctxA.type !== null && ctxB.type !== null && ctxA.type === ctxB.type,
425
+ sharedPaths: _sharedSorted(ctxA.filePaths, ctxB.filePaths),
426
+ sharedTokens: _sharedSorted(ctxA.tokens, ctxB.tokens).slice(0, SHARED_TOKEN_CAP),
427
+ };
428
+ } catch {
429
+ // Last-resort net for an exotic input shape (getter that throws, Proxy).
430
+ // Contract point 4: this runs on the dispatch hot path and must never
431
+ // abort a wave. Every reachable path above is already total.
432
+ return _emptyResult();
433
+ }
434
+ }