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.
- 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 +10 -8
- package/CHANGELOG.md +494 -0
- package/README.md +16 -11
- package/agents/analyst.md +1 -1
- package/agents/architect-reviewer.md +1 -1
- package/agents/code-implementer.md +4 -2
- package/agents/db-specialist.md +1 -1
- package/agents/dialectic-deriver.md +1 -1
- package/agents/docs-writer.md +1 -1
- package/agents/memory-proposal-collector.md +1 -1
- package/agents/qa-strategist.md +1 -1
- package/agents/security-reviewer.md +1 -1
- package/agents/session-reviewer.md +42 -1
- package/agents/skill-applied-judge.md +1 -1
- package/agents/test-writer.md +1 -1
- package/agents/ui-developer.md +1 -1
- package/agents/ux-evaluator.md +1 -1
- package/commands/release.md +60 -0
- package/commands/session.md +6 -2
- package/docs/USER-GUIDE.md +1 -1
- package/docs/instruction-delivery.md +350 -0
- package/docs/migration-v3.md +9 -6
- package/docs/persona-panel.md +3 -1
- package/docs/scope-collision-guard.md +167 -0
- package/docs/session-config-reference.md +1 -41
- package/docs/session-config-template.md +0 -23
- package/hooks/_lib/guard-source-loader.mjs +304 -91
- package/hooks/enforce-commands.mjs +216 -17
- package/hooks/enforce-scope.mjs +236 -12
- package/hooks/hooks-codex.json +1 -1
- package/hooks/hooks.json +11 -1
- package/hooks/on-session-end.mjs +52 -5
- package/hooks/on-session-start.mjs +7 -4
- package/hooks/on-stop.mjs +127 -12
- package/hooks/post-bash-write-verify.mjs +8 -32
- package/hooks/pre-bash-destructive-guard.mjs +146 -59
- package/hooks/pre-bash-sessions-ledger-guard.mjs +493 -66
- package/hooks/pre-task-scope-disjoint.mjs +1042 -0
- package/package.json +2 -2
- package/pi/prompts/release.md +12 -0
- package/scripts/autopilot.mjs +3 -1
- package/scripts/backfill-learnings-from-vault.mjs +967 -0
- package/scripts/emit-session.mjs +45 -40
- package/scripts/export-hw-learnings.mjs +61 -2
- package/scripts/lib/autopilot/worktree-pipeline.mjs +5 -5
- package/scripts/lib/backlog-scan.mjs +106 -15
- package/scripts/lib/build-live-signals.mjs +7 -3
- package/scripts/lib/ci-status-banner.mjs +207 -23
- package/scripts/lib/command-blocker.mjs +322 -62
- package/scripts/lib/git-config-drift.mjs +471 -0
- package/scripts/lib/hardening.mjs +9 -9
- package/scripts/lib/harness-audit/categories/category6.mjs +65 -12
- package/scripts/lib/io.mjs +193 -7
- 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/select.mjs +704 -0
- package/scripts/lib/memory-cleanup-stamp.mjs +132 -8
- package/scripts/lib/mirror-issues-banner.mjs +266 -0
- package/scripts/lib/named-vault-resolver.mjs +105 -16
- package/scripts/lib/peer-cards/schema.mjs +6 -2
- package/scripts/lib/reconcile/emitter.mjs +107 -22
- package/scripts/lib/reconcile/engine.mjs +9 -15
- package/scripts/lib/reconcile/renderer.mjs +141 -25
- package/scripts/lib/reconcile/sanitize.mjs +518 -0
- package/scripts/lib/reconcile/writer.mjs +134 -1
- package/scripts/lib/redact-spans.mjs +89 -0
- package/scripts/lib/scope-baseline.mjs +77 -17
- package/scripts/lib/scope-gate.mjs +852 -72
- package/scripts/lib/secret-masker.mjs +262 -0
- package/scripts/lib/session-close-backfill.mjs +2 -2
- package/scripts/lib/session-lock.mjs +34 -10
- package/scripts/lib/session-record-repair.mjs +551 -0
- package/scripts/lib/session-registry.mjs +9 -1
- package/scripts/lib/session-schema/serializer.mjs +54 -0
- package/scripts/lib/session-schema.mjs +1 -0
- package/scripts/lib/session-token-rollup.mjs +68 -6
- package/scripts/lib/soul-resolve.mjs +12 -0
- package/scripts/lib/state-md/mission-status.mjs +21 -12
- package/scripts/lib/tmux-layout/telemetry.mjs +43 -10
- package/scripts/lib/tmux-layout/vcs-detector.mjs +108 -4
- package/scripts/lib/validate/check-agents.mjs +77 -5
- package/scripts/lib/validate/check-banner-parity.mjs +376 -0
- package/scripts/lib/validate/check-commands.mjs +2 -20
- package/scripts/lib/validate/check-doc-cli-commands.mjs +514 -0
- package/scripts/lib/validate/check-guard-requires-parity.mjs +1148 -0
- package/scripts/lib/validate/check-hooks-symmetry.mjs +18 -0
- package/scripts/lib/validate/check-learning-provenance.mjs +511 -0
- package/scripts/lib/validate/check-owner-leakage.mjs +188 -20
- package/scripts/lib/validate/check-rules.mjs +31 -5
- package/scripts/lib/validate/check-skills.mjs +191 -0
- package/scripts/lib/validate/check-test-git-config-target.mjs +665 -0
- package/scripts/lib/validate/check-unicode-safety.mjs +22 -2
- package/scripts/lib/validate/check-untracked-test-deps.mjs +925 -0
- package/scripts/lib/validate/check-unwired-features.mjs +757 -0
- package/scripts/lib/validate/check-vcs-repo-flag.mjs +965 -0
- package/scripts/lib/validate/frontmatter-block.mjs +61 -0
- package/scripts/lib/validate/tier-inference.mjs +46 -8
- package/scripts/lib/vault-mirror/namespace.mjs +146 -1
- package/scripts/lib/vault-mirror/process.mjs +264 -31
- package/scripts/lib/vault-mirror/render-sessions.mjs +115 -4
- package/scripts/lib/vault-status/board-writer.mjs +300 -56
- package/scripts/lib/vault-status/narrative-mirror.mjs +119 -5
- package/scripts/lib/vcs-repo-spec.mjs +500 -19
- package/scripts/print-applicable-rules.mjs +170 -7
- package/scripts/print-learnings-index.mjs +501 -0
- package/scripts/release.mjs +616 -61
- package/scripts/repair-invalid-sessions.mjs +209 -0
- package/scripts/site-numbers.mjs +1049 -0
- package/scripts/sweep-expired-learnings.mjs +192 -32
- package/scripts/validate-plugin.mjs +82 -0
- package/scripts/validate-wave-scope.mjs +281 -12
- package/scripts/vault-mirror.mjs +26 -1
- package/skills/_shared/monitor-patterns.md +24 -4
- package/skills/_shared/state-ownership.md +17 -0
- package/skills/brainstorm/soul.md +47 -1
- package/skills/claude-md-drift-check/SKILL.md +9 -1
- package/skills/debug/SKILL.md +4 -1
- package/skills/discovery/issue-templates.md +4 -4
- package/skills/discovery/probes-code.md +2 -2
- package/skills/discovery/probes-feature.md +6 -6
- package/skills/discovery/probes-infra.md +2 -2
- package/skills/discovery/probes-session.md +5 -5
- package/skills/dispatcher/SKILL.md +10 -1
- package/skills/evolve/SKILL.md +116 -18
- package/skills/frontmatter-guard/SKILL.md +9 -1
- package/skills/gitlab-ops/SKILL.md +54 -39
- package/skills/gitlab-portfolio/SKILL.md +10 -1
- package/skills/grill/soul.md +44 -1
- package/skills/memory-cleanup/SKILL.md +18 -5
- package/skills/npm-publish/SKILL.md +22 -50
- package/skills/persona-panel/SKILL.md +3 -1
- package/skills/plan/mode-new.md +23 -5
- package/skills/plan/soul.md +46 -3
- package/skills/repo-audit/SKILL.md +10 -1
- package/skills/session-end/SKILL.md +45 -26
- package/skills/session-end/metrics-collection.md +1 -1
- package/skills/session-end/phase-3-6-tail.md +30 -1
- package/skills/session-end/plan-verification.md +1 -5
- package/skills/session-end/session-metrics-write.md +6 -10
- package/skills/session-plan/SKILL.md +2 -2
- package/skills/session-plan/wave-template.md +1 -1
- package/skills/session-start/SKILL.md +15 -1
- package/skills/session-start/soul.md +41 -1
- package/skills/spinout/SKILL.md +5 -1
- package/skills/sunset-review/SKILL.md +11 -1
- package/skills/tmux-layout/SKILL.md +7 -2
- package/skills/vault-mirror/SKILL.md +10 -1
- package/skills/vault-sync/SKILL.md +10 -1
- package/skills/vault-sync/validator.mjs +55 -6
- package/skills/wave-executor/SKILL.md +1 -5
- package/skills/wave-executor/wave-loop.md +77 -82
- package/scripts/lib/mission-status-schema.mjs +0 -114
package/scripts/lib/io.mjs
CHANGED
|
@@ -17,7 +17,16 @@
|
|
|
17
17
|
*/
|
|
18
18
|
|
|
19
19
|
import { writeFile, rename, mkdir } from 'node:fs/promises';
|
|
20
|
-
import {
|
|
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
|
-
|
|
571
|
-
|
|
572
|
-
|
|
573
|
-
|
|
574
|
-
|
|
575
|
-
|
|
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
|
+
}
|