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.
- package/.claude-plugin/marketplace.json +1 -1
- package/.claude-plugin/plugin.json +1 -1
- package/.codex-plugin/plugin.json +1 -1
- package/.cursor/rules/030-wave-execution.mdc +17 -1
- package/CHANGELOG.md +185 -412
- package/README.md +12 -9
- package/SECURITY.md +190 -27
- package/agents/AGENTS.md +20 -3
- package/agents/code-implementer.md +6 -6
- package/agents/db-specialist.md +1 -1
- package/agents/qa-strategist.md +31 -6
- package/agents/schemas/qa-strategist.schema.json +27 -0
- package/agents/schemas/test-writer.schema.json +60 -2
- package/agents/security-reviewer.md +1 -1
- package/agents/session-reviewer.md +1 -1
- package/agents/test-writer.md +29 -10
- package/agents/ui-developer.md +1 -1
- package/commands/contract-version-bump.md +28 -0
- package/commands/portfolio.md +1 -1
- package/commands/session.md +6 -2
- package/docs/USER-GUIDE.md +9 -4
- package/docs/ci-setup.md +121 -7
- package/docs/codex-setup.md +1 -1
- package/docs/components.md +6 -6
- package/docs/cursor-setup.md +22 -9
- package/docs/events-schema.md +5 -1
- package/docs/instruction-delivery.md +794 -0
- package/docs/rule-authoring.md +58 -9
- package/docs/session-config-reference.md +245 -50
- package/docs/session-config-template.md +39 -26
- package/hooks/_lib/guard-source-loader.mjs +680 -0
- package/hooks/_lib/lock-bootstrap.mjs +21 -0
- package/hooks/_lib/vcs-create-matcher.mjs +119 -0
- package/hooks/config-protection.mjs +0 -0
- package/hooks/enforce-commands.mjs +226 -19
- package/hooks/enforce-scope.mjs +133 -9
- package/hooks/hooks-codex.json +1 -1
- package/hooks/hooks-cursor.json +11 -2
- package/hooks/hooks-pi.json +10 -0
- package/hooks/hooks.json +21 -1
- package/hooks/on-session-end.mjs +178 -18
- package/hooks/on-session-start.mjs +30 -4
- package/hooks/post-bash-write-verify.mjs +977 -0
- package/hooks/post-subagent-discovery-validator.mjs +256 -41
- package/hooks/pre-bash-destructive-guard.mjs +616 -164
- package/hooks/pre-bash-issue-budget.mjs +167 -0
- package/hooks/pre-bash-sessions-ledger-guard.mjs +1054 -0
- package/hooks/pre-bash-templates-first.mjs +96 -63
- package/hooks/subagent-telemetry.mjs +527 -37
- package/package.json +6 -3
- package/pi/prompts/contract-version-bump.md +12 -0
- package/rules/README.md +32 -0
- package/scripts/archive-closed-prds.mjs +12 -22
- package/scripts/autopilot-multi.mjs +103 -20
- package/scripts/backfill-abandoned-sessions.mjs +160 -4
- package/scripts/backfill-learnings-from-vault.mjs +967 -0
- package/scripts/check-doc-consistency.sh +17 -1
- package/scripts/emit-session.mjs +3 -40
- package/scripts/eval-session.mjs +50 -9
- package/scripts/fleet-instruction-scan.mjs +141 -0
- package/scripts/lib/autopilot/mr-draft.mjs +31 -1
- package/scripts/lib/autopilot/worktree-pipeline.mjs +113 -5
- package/scripts/lib/backlog-scan.mjs +39 -6
- package/scripts/lib/blocked-commands-policy.mjs +340 -0
- package/scripts/lib/ci-status-banner.mjs +75 -12
- package/scripts/lib/claude-md-budget-lint.mjs +283 -34
- package/scripts/lib/command-blocker.mjs +1273 -58
- package/scripts/lib/config/config-protection.mjs +2 -1
- package/scripts/lib/config/drift-check.mjs +9 -1
- package/scripts/lib/config/gitlab-portfolio.mjs +1 -1
- package/scripts/lib/config/issue-budget.mjs +123 -0
- package/scripts/lib/config/reconcile.mjs +21 -0
- package/scripts/lib/config/section-extractor.mjs +121 -1
- package/scripts/lib/config-schema.mjs +23 -3
- package/scripts/lib/config.mjs +17 -0
- package/scripts/lib/convergence-monitor.mjs +49 -3
- package/scripts/lib/description-surface.mjs +535 -0
- package/scripts/lib/dispatcher/enumerate.mjs +26 -40
- package/scripts/lib/ecosystem-wizard/config-writer.mjs +26 -24
- package/scripts/lib/ecosystem-wizard/wizard-prompt.mjs +1 -1
- package/scripts/lib/eval/engine.mjs +47 -5
- package/scripts/lib/events.mjs +59 -7
- package/scripts/lib/gates/gate-full.mjs +15 -3
- package/scripts/lib/gates/gate-helpers.mjs +132 -6
- package/scripts/lib/gitlab-ops/stale-mr-sweep.mjs +28 -8
- package/scripts/lib/gitlab-portfolio/aggregator.mjs +8 -2
- package/scripts/lib/gitlab-portfolio/cli.mjs +1 -1
- package/scripts/lib/handover-gate.mjs +7 -3
- package/scripts/lib/hardening.mjs +9 -9
- package/scripts/lib/harness-audit/categories/category4.mjs +9 -3
- package/scripts/lib/instruction-budget-guard.mjs +402 -51
- package/scripts/lib/io.mjs +345 -10
- package/scripts/lib/issue-budget.mjs +269 -0
- package/scripts/lib/issue-close-strip-labels.mjs +39 -9
- package/scripts/lib/label-scope.mjs +47 -0
- package/scripts/lib/learnings/affinity.mjs +434 -0
- package/scripts/lib/learnings/candidates.mjs +736 -0
- package/scripts/lib/learnings/expiry-sweep.mjs +408 -53
- package/scripts/lib/learnings/judgment.mjs +782 -0
- package/scripts/lib/learnings/kebab.mjs +128 -0
- package/scripts/lib/learnings/schema.mjs +43 -3
- package/scripts/lib/learnings/select.mjs +550 -0
- package/scripts/lib/lock-reaper.mjs +1 -2
- package/scripts/lib/memory-proposals/schema.mjs +36 -1
- package/scripts/lib/peer-discovery.mjs +645 -0
- package/scripts/lib/pi-hook-bridge.mjs +146 -17
- package/scripts/lib/product-repo-detect.mjs +9 -8
- package/scripts/lib/project-hygiene.mjs +432 -0
- package/scripts/lib/quality-gate.mjs +167 -0
- package/scripts/lib/recommendations-v0.mjs +1 -1
- package/scripts/lib/reconcile/eligibility.mjs +1 -1
- package/scripts/lib/reconcile/emitter.mjs +128 -24
- package/scripts/lib/reconcile/engine.mjs +156 -54
- package/scripts/lib/reconcile/idempotency.mjs +114 -14
- package/scripts/lib/reconcile/renderer.mjs +141 -25
- package/scripts/lib/reconcile/sanitize.mjs +518 -0
- package/scripts/lib/reconcile/writer.mjs +95 -1
- package/scripts/lib/reconcile-nudge-banner.mjs +65 -9
- package/scripts/lib/resource-probe/evaluate.mjs +70 -4
- package/scripts/lib/resource-probe.mjs +19 -0
- package/scripts/lib/rule-loader.mjs +6 -0
- package/scripts/lib/scope-baseline.mjs +564 -0
- package/scripts/lib/scope-gate.mjs +568 -145
- package/scripts/lib/session-close-backfill.mjs +63 -8
- package/scripts/lib/session-end/phase-skip.mjs +1 -0
- package/scripts/lib/session-id.mjs +221 -41
- package/scripts/lib/session-lock.mjs +304 -6
- package/scripts/lib/session-record-repair.mjs +551 -0
- package/scripts/lib/session-schema/constants.mjs +22 -3
- package/scripts/lib/session-schema/serializer.mjs +54 -0
- package/scripts/lib/session-schema/validator.mjs +16 -0
- package/scripts/lib/session-schema.mjs +1 -0
- package/scripts/lib/session-token-rollup.mjs +68 -6
- package/scripts/lib/sessions-integrity-banner.mjs +294 -0
- package/scripts/lib/sessions-staleness-banner.mjs +121 -12
- package/scripts/lib/skill-evolution/idempotency.mjs +135 -16
- package/scripts/lib/skill-evolution/mr-opener.mjs +9 -1
- package/scripts/lib/soul-resolve.mjs +12 -0
- package/scripts/lib/spiral-carryover.mjs +142 -30
- package/scripts/lib/state-md/mission-status.mjs +53 -3
- package/scripts/lib/subagents-schema.mjs +43 -9
- package/scripts/lib/test-runner/issue-reconcile.mjs +53 -13
- package/scripts/lib/tests-src-ratio.mjs +484 -0
- package/scripts/lib/tmux-layout/telemetry.mjs +43 -10
- package/scripts/lib/validate/check-agents.mjs +56 -0
- package/scripts/lib/validate/check-banner-parity.mjs +376 -0
- package/scripts/lib/validate/check-guard-requires-parity.mjs +1148 -0
- package/scripts/lib/validate/check-hooks-symmetry.mjs +244 -10
- package/scripts/lib/validate/check-learning-provenance.mjs +511 -0
- package/scripts/lib/validate/check-owner-leakage.mjs +3 -3
- package/scripts/lib/validate/check-rules.mjs +244 -36
- package/scripts/lib/validate/check-test-value-bans.mjs +782 -0
- package/scripts/lib/validate/check-unicode-safety.mjs +1 -0
- package/scripts/lib/validate/check-unwired-features.mjs +549 -0
- package/scripts/lib/validate-vendored-rules.mjs +10 -2
- package/scripts/lib/vault-archive.mjs +17 -2
- package/scripts/lib/vault-backfill/glab.mjs +8 -0
- package/scripts/lib/vault-mirror/process.mjs +30 -0
- package/scripts/lib/vault-mirror/render-sessions.mjs +293 -36
- package/scripts/lib/vcs-repo-spec.mjs +362 -0
- package/scripts/lib/wave-resource-gate.mjs +115 -11
- package/scripts/lib/worktree/listing.mjs +44 -7
- package/scripts/mcp-server.sh +17 -3
- package/scripts/measure-context-overhead.sh +151 -0
- package/scripts/memory-propose.mjs +72 -9
- package/scripts/print-applicable-rules.mjs +218 -16
- package/scripts/print-learnings-index.mjs +474 -0
- package/scripts/release.mjs +534 -0
- package/scripts/repair-invalid-sessions.mjs +209 -0
- package/scripts/run-quality-gate.mjs +123 -5
- package/scripts/sweep-expired-learnings.mjs +192 -32
- package/scripts/validate-plugin.mjs +21 -0
- package/scripts/validate-wave-scope.mjs +182 -17
- package/scripts/vault-integration-watcher.mjs +32 -10
- package/skills/_shared/config-reading.md +2 -2
- package/skills/bootstrap/fast-template.md +1 -1
- package/skills/brainstorm/soul.md +47 -1
- package/skills/claude-md-drift-check/checker.mjs +145 -28
- package/skills/contract-version-bump/SKILL.md +219 -0
- package/skills/discovery/SKILL.md +4 -4
- package/skills/discovery/issue-templates.md +11 -11
- package/skills/discovery/probes-audit.md +1 -1
- package/skills/discovery/probes-feature.md +1 -1
- package/skills/discovery/probes-session.md +26 -5
- package/skills/ecosystem-health/SKILL.md +1 -1
- package/skills/ecosystem-health/wizard.md +4 -4
- package/skills/evolve/SKILL.md +117 -18
- package/skills/gitlab-ops/SKILL.md +25 -12
- package/skills/gitlab-portfolio/SKILL.md +2 -2
- package/skills/grill/soul.md +44 -1
- package/skills/hook-development/SKILL.md +1 -1
- package/skills/mode-selector/SKILL.md +1 -1
- package/skills/npm-publish/SKILL.md +17 -1
- package/skills/plan/SKILL.md +5 -5
- package/skills/plan/mode-feature.md +4 -4
- package/skills/plan/mode-new.md +10 -10
- package/skills/plan/mode-retro.md +1 -1
- package/skills/plan/soul.md +46 -3
- package/skills/quality-gates/SKILL.md +1 -1
- package/skills/reconcile/SKILL.md +21 -4
- package/skills/session-end/SKILL.md +34 -36
- package/skills/session-end/discovery-scan.md +4 -2
- package/skills/session-end/drift-operations.md +4 -4
- package/skills/session-end/metrics-collection.md +13 -0
- package/skills/session-end/phase-3-2-docs-verification.md +1 -1
- package/skills/session-end/phase-3-6-tail.md +32 -2
- package/skills/session-end/plan-verification.md +6 -7
- package/skills/session-end/session-metrics-write.md +2 -0
- package/skills/session-end/vault-operations.md +1 -1
- package/skills/session-end/verification-checklist.md +1 -1
- package/skills/session-plan/SKILL.md +6 -2
- package/skills/session-plan/wave-template.md +2 -0
- package/skills/session-start/SKILL.md +75 -7
- package/skills/session-start/phase-4-5-resource-health.md +15 -2
- package/skills/session-start/soul.md +41 -1
- package/skills/test-runner/SKILL.md +2 -2
- package/skills/vault-sync/validator.mjs +108 -7
- package/skills/wave-executor/SKILL.md +6 -7
- package/skills/wave-executor/circuit-breaker.md +2 -0
- package/skills/wave-executor/wave-loop.md +198 -80
- 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
|
|
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
|
|
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
|
|
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({
|
|
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
|
-
|
|
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
|
+
}
|