session-orchestrator 3.17.0 → 3.20.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 (221) 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 +17 -1
  5. package/CHANGELOG.md +185 -412
  6. package/README.md +12 -9
  7. package/SECURITY.md +190 -27
  8. package/agents/AGENTS.md +20 -3
  9. package/agents/code-implementer.md +6 -6
  10. package/agents/db-specialist.md +1 -1
  11. package/agents/qa-strategist.md +31 -6
  12. package/agents/schemas/qa-strategist.schema.json +27 -0
  13. package/agents/schemas/test-writer.schema.json +60 -2
  14. package/agents/security-reviewer.md +1 -1
  15. package/agents/session-reviewer.md +1 -1
  16. package/agents/test-writer.md +29 -10
  17. package/agents/ui-developer.md +1 -1
  18. package/commands/contract-version-bump.md +28 -0
  19. package/commands/portfolio.md +1 -1
  20. package/commands/session.md +6 -2
  21. package/docs/USER-GUIDE.md +9 -4
  22. package/docs/ci-setup.md +121 -7
  23. package/docs/codex-setup.md +1 -1
  24. package/docs/components.md +6 -6
  25. package/docs/cursor-setup.md +22 -9
  26. package/docs/events-schema.md +5 -1
  27. package/docs/instruction-delivery.md +794 -0
  28. package/docs/rule-authoring.md +58 -9
  29. package/docs/session-config-reference.md +245 -50
  30. package/docs/session-config-template.md +39 -26
  31. package/hooks/_lib/guard-source-loader.mjs +680 -0
  32. package/hooks/_lib/lock-bootstrap.mjs +21 -0
  33. package/hooks/_lib/vcs-create-matcher.mjs +119 -0
  34. package/hooks/config-protection.mjs +0 -0
  35. package/hooks/enforce-commands.mjs +226 -19
  36. package/hooks/enforce-scope.mjs +133 -9
  37. package/hooks/hooks-codex.json +1 -1
  38. package/hooks/hooks-cursor.json +11 -2
  39. package/hooks/hooks-pi.json +10 -0
  40. package/hooks/hooks.json +21 -1
  41. package/hooks/on-session-end.mjs +178 -18
  42. package/hooks/on-session-start.mjs +30 -4
  43. package/hooks/post-bash-write-verify.mjs +977 -0
  44. package/hooks/post-subagent-discovery-validator.mjs +256 -41
  45. package/hooks/pre-bash-destructive-guard.mjs +616 -164
  46. package/hooks/pre-bash-issue-budget.mjs +167 -0
  47. package/hooks/pre-bash-sessions-ledger-guard.mjs +1054 -0
  48. package/hooks/pre-bash-templates-first.mjs +96 -63
  49. package/hooks/subagent-telemetry.mjs +527 -37
  50. package/package.json +6 -3
  51. package/pi/prompts/contract-version-bump.md +12 -0
  52. package/rules/README.md +32 -0
  53. package/scripts/archive-closed-prds.mjs +12 -22
  54. package/scripts/autopilot-multi.mjs +103 -20
  55. package/scripts/backfill-abandoned-sessions.mjs +160 -4
  56. package/scripts/backfill-learnings-from-vault.mjs +967 -0
  57. package/scripts/check-doc-consistency.sh +17 -1
  58. package/scripts/emit-session.mjs +3 -40
  59. package/scripts/eval-session.mjs +50 -9
  60. package/scripts/fleet-instruction-scan.mjs +141 -0
  61. package/scripts/lib/autopilot/mr-draft.mjs +31 -1
  62. package/scripts/lib/autopilot/worktree-pipeline.mjs +113 -5
  63. package/scripts/lib/backlog-scan.mjs +39 -6
  64. package/scripts/lib/blocked-commands-policy.mjs +340 -0
  65. package/scripts/lib/ci-status-banner.mjs +75 -12
  66. package/scripts/lib/claude-md-budget-lint.mjs +283 -34
  67. package/scripts/lib/command-blocker.mjs +1273 -58
  68. package/scripts/lib/config/config-protection.mjs +2 -1
  69. package/scripts/lib/config/drift-check.mjs +9 -1
  70. package/scripts/lib/config/gitlab-portfolio.mjs +1 -1
  71. package/scripts/lib/config/issue-budget.mjs +123 -0
  72. package/scripts/lib/config/reconcile.mjs +21 -0
  73. package/scripts/lib/config/section-extractor.mjs +121 -1
  74. package/scripts/lib/config-schema.mjs +23 -3
  75. package/scripts/lib/config.mjs +17 -0
  76. package/scripts/lib/convergence-monitor.mjs +49 -3
  77. package/scripts/lib/description-surface.mjs +535 -0
  78. package/scripts/lib/dispatcher/enumerate.mjs +26 -40
  79. package/scripts/lib/ecosystem-wizard/config-writer.mjs +26 -24
  80. package/scripts/lib/ecosystem-wizard/wizard-prompt.mjs +1 -1
  81. package/scripts/lib/eval/engine.mjs +47 -5
  82. package/scripts/lib/events.mjs +59 -7
  83. package/scripts/lib/gates/gate-full.mjs +15 -3
  84. package/scripts/lib/gates/gate-helpers.mjs +132 -6
  85. package/scripts/lib/gitlab-ops/stale-mr-sweep.mjs +28 -8
  86. package/scripts/lib/gitlab-portfolio/aggregator.mjs +8 -2
  87. package/scripts/lib/gitlab-portfolio/cli.mjs +1 -1
  88. package/scripts/lib/handover-gate.mjs +7 -3
  89. package/scripts/lib/hardening.mjs +9 -9
  90. package/scripts/lib/harness-audit/categories/category4.mjs +9 -3
  91. package/scripts/lib/instruction-budget-guard.mjs +402 -51
  92. package/scripts/lib/io.mjs +345 -10
  93. package/scripts/lib/issue-budget.mjs +269 -0
  94. package/scripts/lib/issue-close-strip-labels.mjs +39 -9
  95. package/scripts/lib/label-scope.mjs +47 -0
  96. package/scripts/lib/learnings/affinity.mjs +434 -0
  97. package/scripts/lib/learnings/candidates.mjs +736 -0
  98. package/scripts/lib/learnings/expiry-sweep.mjs +408 -53
  99. package/scripts/lib/learnings/judgment.mjs +782 -0
  100. package/scripts/lib/learnings/kebab.mjs +128 -0
  101. package/scripts/lib/learnings/schema.mjs +43 -3
  102. package/scripts/lib/learnings/select.mjs +550 -0
  103. package/scripts/lib/lock-reaper.mjs +1 -2
  104. package/scripts/lib/memory-proposals/schema.mjs +36 -1
  105. package/scripts/lib/peer-discovery.mjs +645 -0
  106. package/scripts/lib/pi-hook-bridge.mjs +146 -17
  107. package/scripts/lib/product-repo-detect.mjs +9 -8
  108. package/scripts/lib/project-hygiene.mjs +432 -0
  109. package/scripts/lib/quality-gate.mjs +167 -0
  110. package/scripts/lib/recommendations-v0.mjs +1 -1
  111. package/scripts/lib/reconcile/eligibility.mjs +1 -1
  112. package/scripts/lib/reconcile/emitter.mjs +128 -24
  113. package/scripts/lib/reconcile/engine.mjs +156 -54
  114. package/scripts/lib/reconcile/idempotency.mjs +114 -14
  115. package/scripts/lib/reconcile/renderer.mjs +141 -25
  116. package/scripts/lib/reconcile/sanitize.mjs +518 -0
  117. package/scripts/lib/reconcile/writer.mjs +95 -1
  118. package/scripts/lib/reconcile-nudge-banner.mjs +65 -9
  119. package/scripts/lib/resource-probe/evaluate.mjs +70 -4
  120. package/scripts/lib/resource-probe.mjs +19 -0
  121. package/scripts/lib/rule-loader.mjs +6 -0
  122. package/scripts/lib/scope-baseline.mjs +564 -0
  123. package/scripts/lib/scope-gate.mjs +568 -145
  124. package/scripts/lib/session-close-backfill.mjs +63 -8
  125. package/scripts/lib/session-end/phase-skip.mjs +1 -0
  126. package/scripts/lib/session-id.mjs +221 -41
  127. package/scripts/lib/session-lock.mjs +304 -6
  128. package/scripts/lib/session-record-repair.mjs +551 -0
  129. package/scripts/lib/session-schema/constants.mjs +22 -3
  130. package/scripts/lib/session-schema/serializer.mjs +54 -0
  131. package/scripts/lib/session-schema/validator.mjs +16 -0
  132. package/scripts/lib/session-schema.mjs +1 -0
  133. package/scripts/lib/session-token-rollup.mjs +68 -6
  134. package/scripts/lib/sessions-integrity-banner.mjs +294 -0
  135. package/scripts/lib/sessions-staleness-banner.mjs +121 -12
  136. package/scripts/lib/skill-evolution/idempotency.mjs +135 -16
  137. package/scripts/lib/skill-evolution/mr-opener.mjs +9 -1
  138. package/scripts/lib/soul-resolve.mjs +12 -0
  139. package/scripts/lib/spiral-carryover.mjs +142 -30
  140. package/scripts/lib/state-md/mission-status.mjs +53 -3
  141. package/scripts/lib/subagents-schema.mjs +43 -9
  142. package/scripts/lib/test-runner/issue-reconcile.mjs +53 -13
  143. package/scripts/lib/tests-src-ratio.mjs +484 -0
  144. package/scripts/lib/tmux-layout/telemetry.mjs +43 -10
  145. package/scripts/lib/validate/check-agents.mjs +56 -0
  146. package/scripts/lib/validate/check-banner-parity.mjs +376 -0
  147. package/scripts/lib/validate/check-guard-requires-parity.mjs +1148 -0
  148. package/scripts/lib/validate/check-hooks-symmetry.mjs +244 -10
  149. package/scripts/lib/validate/check-learning-provenance.mjs +511 -0
  150. package/scripts/lib/validate/check-owner-leakage.mjs +3 -3
  151. package/scripts/lib/validate/check-rules.mjs +244 -36
  152. package/scripts/lib/validate/check-test-value-bans.mjs +782 -0
  153. package/scripts/lib/validate/check-unicode-safety.mjs +1 -0
  154. package/scripts/lib/validate/check-unwired-features.mjs +549 -0
  155. package/scripts/lib/validate-vendored-rules.mjs +10 -2
  156. package/scripts/lib/vault-archive.mjs +17 -2
  157. package/scripts/lib/vault-backfill/glab.mjs +8 -0
  158. package/scripts/lib/vault-mirror/process.mjs +30 -0
  159. package/scripts/lib/vault-mirror/render-sessions.mjs +293 -36
  160. package/scripts/lib/vcs-repo-spec.mjs +362 -0
  161. package/scripts/lib/wave-resource-gate.mjs +115 -11
  162. package/scripts/lib/worktree/listing.mjs +44 -7
  163. package/scripts/mcp-server.sh +17 -3
  164. package/scripts/measure-context-overhead.sh +151 -0
  165. package/scripts/memory-propose.mjs +72 -9
  166. package/scripts/print-applicable-rules.mjs +218 -16
  167. package/scripts/print-learnings-index.mjs +474 -0
  168. package/scripts/release.mjs +534 -0
  169. package/scripts/repair-invalid-sessions.mjs +209 -0
  170. package/scripts/run-quality-gate.mjs +123 -5
  171. package/scripts/sweep-expired-learnings.mjs +192 -32
  172. package/scripts/validate-plugin.mjs +21 -0
  173. package/scripts/validate-wave-scope.mjs +182 -17
  174. package/scripts/vault-integration-watcher.mjs +32 -10
  175. package/skills/_shared/config-reading.md +2 -2
  176. package/skills/bootstrap/fast-template.md +1 -1
  177. package/skills/brainstorm/soul.md +47 -1
  178. package/skills/claude-md-drift-check/checker.mjs +145 -28
  179. package/skills/contract-version-bump/SKILL.md +219 -0
  180. package/skills/discovery/SKILL.md +4 -4
  181. package/skills/discovery/issue-templates.md +11 -11
  182. package/skills/discovery/probes-audit.md +1 -1
  183. package/skills/discovery/probes-feature.md +1 -1
  184. package/skills/discovery/probes-session.md +26 -5
  185. package/skills/ecosystem-health/SKILL.md +1 -1
  186. package/skills/ecosystem-health/wizard.md +4 -4
  187. package/skills/evolve/SKILL.md +117 -18
  188. package/skills/gitlab-ops/SKILL.md +25 -12
  189. package/skills/gitlab-portfolio/SKILL.md +2 -2
  190. package/skills/grill/soul.md +44 -1
  191. package/skills/hook-development/SKILL.md +1 -1
  192. package/skills/mode-selector/SKILL.md +1 -1
  193. package/skills/npm-publish/SKILL.md +17 -1
  194. package/skills/plan/SKILL.md +5 -5
  195. package/skills/plan/mode-feature.md +4 -4
  196. package/skills/plan/mode-new.md +10 -10
  197. package/skills/plan/mode-retro.md +1 -1
  198. package/skills/plan/soul.md +46 -3
  199. package/skills/quality-gates/SKILL.md +1 -1
  200. package/skills/reconcile/SKILL.md +21 -4
  201. package/skills/session-end/SKILL.md +34 -36
  202. package/skills/session-end/discovery-scan.md +4 -2
  203. package/skills/session-end/drift-operations.md +4 -4
  204. package/skills/session-end/metrics-collection.md +13 -0
  205. package/skills/session-end/phase-3-2-docs-verification.md +1 -1
  206. package/skills/session-end/phase-3-6-tail.md +32 -2
  207. package/skills/session-end/plan-verification.md +6 -7
  208. package/skills/session-end/session-metrics-write.md +2 -0
  209. package/skills/session-end/vault-operations.md +1 -1
  210. package/skills/session-end/verification-checklist.md +1 -1
  211. package/skills/session-plan/SKILL.md +6 -2
  212. package/skills/session-plan/wave-template.md +2 -0
  213. package/skills/session-start/SKILL.md +75 -7
  214. package/skills/session-start/phase-4-5-resource-health.md +15 -2
  215. package/skills/session-start/soul.md +41 -1
  216. package/skills/test-runner/SKILL.md +2 -2
  217. package/skills/vault-sync/validator.mjs +108 -7
  218. package/skills/wave-executor/SKILL.md +6 -7
  219. package/skills/wave-executor/circuit-breaker.md +2 -0
  220. package/skills/wave-executor/wave-loop.md +198 -80
  221. package/templates/_shared/loop.md +4 -4
@@ -14,6 +14,11 @@
14
14
  * - Never throws — all errors are caught and returned as `{ stripped: [],
15
15
  * error: string }` so callers can log and proceed with close unblocked.
16
16
  * - Supports GitLab (`glab`) and GitHub (`gh`). VCS is a required param.
17
+ * - Every `glab`/`gh` spawn is pinned to the resolved repo via `-R <spec>`
18
+ * (#839) — see `scripts/lib/vcs-repo-spec.mjs` for why: a bare spawn
19
+ * falls back to the ambient `GITLAB_HOST`/`GH_HOST`, which can silently
20
+ * resolve to the wrong GitLab instance on a multi-host machine, turning
21
+ * this whole module into a silent no-op.
17
22
  *
18
23
  * Usage (from session-end Phase 5 issue-close loop):
19
24
  *
@@ -28,6 +33,7 @@
28
33
  */
29
34
 
30
35
  import { execFileSync } from 'node:child_process';
36
+ import { resolveRepoSpec } from './vcs-repo-spec.mjs';
31
37
 
32
38
  /** Regex that matches any `status:*` label name. */
33
39
  const STATUS_LABEL_RE = /^status:/;
@@ -57,15 +63,19 @@ function runCli(cmd, args) {
57
63
  /**
58
64
  * Fetch the label names currently applied to an issue.
59
65
  *
60
- * @param {{ issueId: number | string, vcs: 'gitlab' | 'github' }} opts
66
+ * @param {{ issueId: number | string, vcs: 'gitlab' | 'github', spec?: string }} opts
67
+ * `spec` is the resolved `-R`/`--repo` host-pinning value (#839); omitted
68
+ * (undefined) means no `-R` is appended — never emit `-R undefined`.
61
69
  * @returns {{ ok: boolean, labels: string[], stderr: string }}
62
70
  */
63
- function fetchLabels({ issueId, vcs }) {
71
+ function fetchLabels({ issueId, vcs, spec }) {
64
72
  const id = String(issueId);
65
73
 
66
74
  if (vcs === 'github') {
67
75
  // `gh issue view <NUMBER> --json labels` returns { labels: [{name, ...}] }
68
- const res = runCli('gh', ['issue', 'view', id, '--json', 'labels']);
76
+ const args = ['issue', 'view', id, '--json', 'labels'];
77
+ if (spec) args.push('-R', spec);
78
+ const res = runCli('gh', args);
69
79
  if (!res.ok) return { ok: false, labels: [], stderr: res.stderr };
70
80
  let parsed;
71
81
  try {
@@ -81,7 +91,9 @@ function fetchLabels({ issueId, vcs }) {
81
91
 
82
92
  // Default: gitlab via glab.
83
93
  // `glab issue view <IID> --output json` returns an issue object with a `labels` array.
84
- const res = runCli('glab', ['issue', 'view', id, '--output', 'json']);
94
+ const args = ['issue', 'view', id, '--output', 'json'];
95
+ if (spec) args.push('-R', spec);
96
+ const res = runCli('glab', args);
85
97
  if (!res.ok) return { ok: false, labels: [], stderr: res.stderr };
86
98
  let parsed;
87
99
  try {
@@ -89,7 +101,7 @@ function fetchLabels({ issueId, vcs }) {
89
101
  } catch (e) {
90
102
  return { ok: false, labels: [], stderr: `JSON parse failed: ${e && e.message ? e.message : String(e)}` };
91
103
  }
92
- // GitLab JSON shape: { labels: ["status:ready", "priority:high", ...] }
104
+ // GitLab JSON shape: { labels: ["status:ready", "priority::high", ...] }
93
105
  const labels = Array.isArray(parsed?.labels)
94
106
  ? parsed.labels.map((l) => (typeof l === 'string' ? l : String(l))).filter(Boolean)
95
107
  : [];
@@ -105,11 +117,22 @@ function fetchLabels({ issueId, vcs }) {
105
117
  *
106
118
  * @param {{
107
119
  * issueId: number | string,
108
- * vcs?: 'gitlab' | 'github'
120
+ * vcs?: 'gitlab' | 'github',
121
+ * repoRoot?: string,
122
+ * resolveRepoSpecFn?: (opts: { repoRoot: string, vcs: 'gitlab' | 'github' }) => string | undefined
109
123
  * }} opts
124
+ * `repoRoot` defaults to `process.cwd()`. `resolveRepoSpecFn` is the
125
+ * injectable seam for the `-R`/`--repo` host-pinning resolution (#839) —
126
+ * defaults to the real `resolveRepoSpec` (shells out to `git remote
127
+ * get-url`); tests inject a stub instead of shelling out.
110
128
  * @returns {Promise<{ stripped: string[], error?: string }>}
111
129
  */
112
- export async function stripStatusLabels({ issueId, vcs = 'gitlab' } = {}) {
130
+ export async function stripStatusLabels({
131
+ issueId,
132
+ vcs = 'gitlab',
133
+ repoRoot = process.cwd(),
134
+ resolveRepoSpecFn = resolveRepoSpec,
135
+ } = {}) {
113
136
  try {
114
137
  const id = String(issueId ?? '').trim();
115
138
  if (!id || id === 'undefined' || id === 'null') {
@@ -118,8 +141,12 @@ export async function stripStatusLabels({ issueId, vcs = 'gitlab' } = {}) {
118
141
 
119
142
  const vcsResolved = vcs === 'github' ? 'github' : 'gitlab';
120
143
 
144
+ // Resolve the -R/--repo host-pinning spec ONCE, reused by both the fetch
145
+ // and the strip call below (#839). undefined ⇒ no -R is appended anywhere.
146
+ const spec = resolveRepoSpecFn({ repoRoot, vcs: vcsResolved });
147
+
121
148
  // Step 1: fetch current labels.
122
- const { ok, labels, stderr } = fetchLabels({ issueId: id, vcs: vcsResolved });
149
+ const { ok, labels, stderr } = fetchLabels({ issueId: id, vcs: vcsResolved, spec });
123
150
  if (!ok) {
124
151
  return { stripped: [], error: `failed to fetch labels: ${stderr}` };
125
152
  }
@@ -139,11 +166,14 @@ export async function stripStatusLabels({ issueId, vcs = 'gitlab' } = {}) {
139
166
  for (const label of toStrip) {
140
167
  args.push('--remove-label', label);
141
168
  }
169
+ if (spec) args.push('-R', spec);
142
170
  stripRes = runCli('gh', args);
143
171
  } else {
144
172
  // `glab issue update <IID> --unlabel label1,label2,...`
145
173
  // glab accepts comma-separated list in a single --unlabel flag value.
146
- stripRes = runCli('glab', ['issue', 'update', id, '--unlabel', toStrip.join(',')]);
174
+ const args = ['issue', 'update', id, '--unlabel', toStrip.join(',')];
175
+ if (spec) args.push('-R', spec);
176
+ stripRes = runCli('glab', args);
147
177
  }
148
178
 
149
179
  if (!stripRes.ok) {
@@ -0,0 +1,47 @@
1
+ /**
2
+ * label-scope.mjs — scope-tolerant label comparison.
3
+ *
4
+ * The canonical priority-label spelling is the GitLab SCOPED form
5
+ * `priority::<level>`. Producers were migrated to it because the instance had
6
+ * drifted to 416 scoped / 249 unscoped / 7 bare labels, and a data migration
7
+ * that is not preceded by a producer migration is undone within a day.
8
+ *
9
+ * The DATA migration is a separate, later step, so for now both spellings
10
+ * coexist on real issues. Every consumer that MATCHES a label therefore
11
+ * compares through `normalizeLabel()`, which collapses the scope separator:
12
+ * `priority::high` and `priority:high` are the same label for matching
13
+ * purposes, while the emitted/default spelling stays canonical.
14
+ *
15
+ * Deliberately NOT applied to label WRITES — those always emit the canonical
16
+ * scoped form.
17
+ *
18
+ * Stdlib-free leaf module.
19
+ */
20
+
21
+ /**
22
+ * Collapse a scoped label's double colon to a single colon and lowercase it,
23
+ * yielding a comparison key that is identical for `key::value` and `key:value`.
24
+ *
25
+ * @param {unknown} label
26
+ * @returns {string} normalized comparison key ('' for non-strings)
27
+ */
28
+ export function normalizeLabel(label) {
29
+ if (typeof label !== 'string') return '';
30
+ return label.trim().toLowerCase().replace(/::/g, ':');
31
+ }
32
+
33
+ /**
34
+ * Build a Set of normalized comparison keys from a label list.
35
+ *
36
+ * @param {unknown} labels
37
+ * @returns {Set<string>}
38
+ */
39
+ export function normalizedLabelSet(labels) {
40
+ const set = new Set();
41
+ if (!Array.isArray(labels)) return set;
42
+ for (const l of labels) {
43
+ const key = normalizeLabel(l);
44
+ if (key) set.add(key);
45
+ }
46
+ return set;
47
+ }
@@ -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
+ }