session-orchestrator 3.19.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 (66) 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/CHANGELOG.md +80 -0
  5. package/README.md +9 -9
  6. package/commands/session.md +6 -2
  7. package/docs/USER-GUIDE.md +1 -1
  8. package/docs/instruction-delivery.md +350 -0
  9. package/docs/session-config-reference.md +1 -41
  10. package/docs/session-config-template.md +0 -23
  11. package/hooks/_lib/guard-source-loader.mjs +304 -91
  12. package/hooks/enforce-commands.mjs +216 -17
  13. package/hooks/enforce-scope.mjs +133 -9
  14. package/hooks/hooks-codex.json +1 -1
  15. package/hooks/hooks.json +1 -1
  16. package/hooks/on-session-start.mjs +7 -4
  17. package/hooks/pre-bash-destructive-guard.mjs +146 -59
  18. package/hooks/pre-bash-sessions-ledger-guard.mjs +493 -66
  19. package/package.json +2 -2
  20. package/scripts/backfill-learnings-from-vault.mjs +967 -0
  21. package/scripts/emit-session.mjs +3 -40
  22. package/scripts/lib/command-blocker.mjs +322 -62
  23. package/scripts/lib/hardening.mjs +9 -9
  24. package/scripts/lib/learnings/affinity.mjs +434 -0
  25. package/scripts/lib/learnings/candidates.mjs +736 -0
  26. package/scripts/lib/learnings/expiry-sweep.mjs +408 -53
  27. package/scripts/lib/learnings/judgment.mjs +782 -0
  28. package/scripts/lib/learnings/kebab.mjs +128 -0
  29. package/scripts/lib/learnings/select.mjs +550 -0
  30. package/scripts/lib/reconcile/emitter.mjs +107 -22
  31. package/scripts/lib/reconcile/engine.mjs +9 -15
  32. package/scripts/lib/reconcile/renderer.mjs +141 -25
  33. package/scripts/lib/reconcile/sanitize.mjs +518 -0
  34. package/scripts/lib/reconcile/writer.mjs +95 -1
  35. package/scripts/lib/scope-gate.mjs +194 -72
  36. package/scripts/lib/session-close-backfill.mjs +2 -2
  37. package/scripts/lib/session-record-repair.mjs +551 -0
  38. package/scripts/lib/session-schema/serializer.mjs +54 -0
  39. package/scripts/lib/session-schema.mjs +1 -0
  40. package/scripts/lib/session-token-rollup.mjs +68 -6
  41. package/scripts/lib/soul-resolve.mjs +12 -0
  42. package/scripts/lib/tmux-layout/telemetry.mjs +43 -10
  43. package/scripts/lib/validate/check-banner-parity.mjs +376 -0
  44. package/scripts/lib/validate/check-guard-requires-parity.mjs +1148 -0
  45. package/scripts/lib/validate/check-learning-provenance.mjs +511 -0
  46. package/scripts/lib/validate/check-owner-leakage.mjs +3 -3
  47. package/scripts/lib/validate/check-rules.mjs +31 -5
  48. package/scripts/lib/validate/check-unwired-features.mjs +549 -0
  49. package/scripts/print-applicable-rules.mjs +170 -7
  50. package/scripts/print-learnings-index.mjs +474 -0
  51. package/scripts/repair-invalid-sessions.mjs +209 -0
  52. package/scripts/sweep-expired-learnings.mjs +192 -32
  53. package/scripts/validate-plugin.mjs +21 -0
  54. package/skills/brainstorm/soul.md +47 -1
  55. package/skills/evolve/SKILL.md +116 -18
  56. package/skills/gitlab-ops/SKILL.md +5 -0
  57. package/skills/grill/soul.md +44 -1
  58. package/skills/plan/soul.md +46 -3
  59. package/skills/session-end/SKILL.md +1 -24
  60. package/skills/session-end/phase-3-6-tail.md +30 -1
  61. package/skills/session-end/plan-verification.md +1 -5
  62. package/skills/session-end/session-metrics-write.md +2 -0
  63. package/skills/session-start/SKILL.md +2 -0
  64. package/skills/session-start/soul.md +41 -1
  65. package/skills/wave-executor/SKILL.md +1 -5
  66. package/skills/wave-executor/wave-loop.md +36 -71
@@ -0,0 +1,128 @@
1
+ /**
2
+ * learnings/kebab.mjs — the slug primitive behind every `learning_key`, and
3
+ * {@link learningKeyOf}, the whole-key derivation built on it.
4
+ *
5
+ * `learning_key` is not a stored field. It is DERIVED as
6
+ * `` `${type}/${kebab(title || subject)}` `` and is the logical identity the
7
+ * whole dedupe/idempotency layer keys on (`reconcile/idempotency.mjs` calls it
8
+ * "THE logical dedupe key"). That makes this a contract, not a formatting
9
+ * helper: two callers that derive differently do not produce two ugly slugs,
10
+ * they FORK the key space — the same learning renders under two identities,
11
+ * dedupe stops firing, and both halves look correct in isolation.
12
+ *
13
+ * Before this module existed there were four copies of `kebab` plus one inline
14
+ * expression, none exported, none shared (two of the four landed on the same
15
+ * day, from two different authors, each of whom flagged the duplication in
16
+ * their own report). They agreed on every reachable input — verified, not
17
+ * assumed — so consolidating here changed no existing key. See the byte-identity
18
+ * proof pinned in `tests/scripts/lib/learnings/kebab.test.mjs`.
19
+ *
20
+ * Consolidating the primitive left the SECOND half of the problem open, and it
21
+ * is the half that bites: what each call site FED the primitive. Four sites
22
+ * derived `${type}/…` verbatim while the writer (`reconcile/emitter.mjs`) alone
23
+ * derived `${kebab(type)}/…` — a divergence invisible while every live `type`
24
+ * is kebab-identical, and silent in BOTH directions the moment one is not.
25
+ * {@link learningKeyOf} exists so there is one derivation to agree with rather
26
+ * than five to keep in sync.
27
+ *
28
+ * ## Why this file lives under `learnings/` and not under `reconcile/`
29
+ *
30
+ * Dependency direction, measured rather than presumed (2026-08-13, HEAD
31
+ * 5d59e62): `grep -rn "from '../learnings/" scripts/lib/reconcile/` reports 6
32
+ * import edges across 4 files (`eligibility.mjs`, `engine.mjs` ×2,
33
+ * `emitter.mjs` ×2, `renderer.mjs` — three of them pulling THIS module, three
34
+ * pulling `learnings/schema.mjs`), and the reverse grep
35
+ * `from '../reconcile/'` over `scripts/lib/learnings/` reports 0. A primitive
36
+ * shared by both packages therefore belongs on the `learnings/` side; placing
37
+ * it under `reconcile/` would invert an edge and introduce a cycle.
38
+ *
39
+ * Deliberately NOT re-exported from the `scripts/lib/learnings.mjs` barrel —
40
+ * `surface.mjs` and `affinity.mjs` set that precedent: consumers import the
41
+ * leaf directly, so the barrel stays the historical schema/io/filters surface.
42
+ *
43
+ * ## What this module is NOT
44
+ *
45
+ * Not a general-purpose slugifier. Vault note ids, tag segments, and rule
46
+ * filenames have their own slug rules with their own length caps and charset
47
+ * contracts (`vault-mirror/utils.mjs`, `vault-archive.mjs`). Do not route those
48
+ * through here — a shared slugifier across unrelated identity spaces is how a
49
+ * cap added for one consumer silently re-keys another.
50
+ *
51
+ * Pure, stdlib-only, no imports, no clock, no fs.
52
+ */
53
+
54
+ /**
55
+ * Slugify a string into the stable kebab-case token used for learning keys.
56
+ *
57
+ * Lowercases, collapses every run of non-`[a-z0-9]` characters into a single
58
+ * `-`, and trims leading/trailing `-`.
59
+ *
60
+ * Three properties callers depend on:
61
+ *
62
+ * 1. **Total** — never throws. Non-string input is coerced via `String()`,
63
+ * so `null`/`undefined`/numbers yield `"null"`/`"undefined"`/`"12345"`
64
+ * rather than a `TypeError`. Callers pass values typed `unknown` at
65
+ * their trust boundary; a throw there would abort a reconcile run.
66
+ * 2. **Lossy on non-ASCII** — German umlauts and em-dashes in the corpus are
67
+ * collapsed to `-` (`"Größe"` → `"gr-e"`), NOT transliterated. Ugly, and
68
+ * deliberately frozen: every stamped key in `.claude/rules/*.md` and in
69
+ * `.orchestrator/runtime/reconcile-candidates.jsonl` was minted this way.
70
+ * Adding transliteration would re-key the entire corpus.
71
+ * 3. **May return the empty string** — an all-symbol input (`"!!!"`) yields
72
+ * `""`. `reconcile/renderer.mjs::deriveSlug` branches on exactly that to
73
+ * fall back to its hash suffix, so an "always return something non-empty"
74
+ * change here would silently disable that branch.
75
+ *
76
+ * @param {unknown} s Value to slugify; coerced with `String()`.
77
+ * @returns {string} Kebab token; `''` when the input holds no `[a-z0-9]`.
78
+ */
79
+ export function kebab(s) {
80
+ return String(s)
81
+ .toLowerCase()
82
+ .replace(/[^a-z0-9]+/g, '-')
83
+ .replace(/^-+|-+$/g, '');
84
+ }
85
+
86
+ /**
87
+ * THE derivation of a learning's logical key: `` `${type}/${kebab(title || subject)}` ``.
88
+ *
89
+ * One rule, four decisions, each of which was a divergence between call sites
90
+ * before this function existed:
91
+ *
92
+ * 1. **The TYPE half is verbatim (trimmed), never kebab'd.** The subject half
93
+ * is prose and must be slugged; the type half is an enum token a reader
94
+ * parses BACK OUT of the key — `backfill-learnings-from-vault.mjs` uses
95
+ * `learning_key.split('/')[0]` as the reconstructed `type` and
96
+ * cross-checks it against the vault note's type. Kebabbing it would make
97
+ * that half lossy, which is precisely the fidelity downgrade the backfill
98
+ * labels `derived:learning-key-slug (original prose not recoverable)` for
99
+ * the subject half. Frontmatter safety does not need it either: the key
100
+ * becomes an unquoted `learning-key:` scalar, and `reconcile/renderer.mjs`
101
+ * already asserts `LEARNING_KEY_RE` (`/^[a-z0-9/-]+$/`) on the whole value
102
+ * before rendering — an unsafe type is REJECTED loudly there rather than
103
+ * silently re-keyed here.
104
+ * 2. **`title` wins over `subject`**, and a whitespace-only `title` falls
105
+ * through to `subject` rather than yielding an empty slug.
106
+ * 3. **`null`, not a throw, for an unkeyable record.** Readers scan corpora
107
+ * that contain shape-foreign lines; an unkeyable record simply does not
108
+ * participate in key resolution. Writers that need a string check for
109
+ * `null` and reject the record with their own auditable reason.
110
+ * 4. **An empty slug is unkeyable, not a key.** `kebab('!!!')` is `''`, and
111
+ * `` `anti-pattern/` `` is not an identity — it is a bucket every
112
+ * all-symbol-subject record of that type would collide in.
113
+ *
114
+ * @param {unknown} record A learning record (or anything; non-records yield `null`).
115
+ * @returns {string|null} `` `${type}/${slug}` ``, or `null` when unkeyable.
116
+ */
117
+ export function learningKeyOf(record) {
118
+ if (record === null || typeof record !== 'object' || Array.isArray(record)) return null;
119
+ const rec = /** @type {Record<string, unknown>} */ (record);
120
+ const type = typeof rec.type === 'string' ? rec.type.trim() : '';
121
+ const titleOrSubject =
122
+ (typeof rec.title === 'string' && rec.title.trim() !== '' ? rec.title : '') ||
123
+ (typeof rec.subject === 'string' && rec.subject.trim() !== '' ? rec.subject : '');
124
+ if (type === '' || titleOrSubject === '') return null;
125
+ const slug = kebab(titleOrSubject);
126
+ if (slug === '') return null;
127
+ return `${type}/${slug}`;
128
+ }
@@ -0,0 +1,550 @@
1
+ /**
2
+ * learnings/select.mjs — choose which learnings enter ONE dispatched agent's
3
+ * compact index, given that agent's declared file scope (#1014).
4
+ *
5
+ * ## The problem
6
+ *
7
+ * ~10² learnings accumulated across 233 sessions and a wave-agent receives ZERO
8
+ * of them: the only read paths are a coordinator banner, an autopilot call, and
9
+ * a nudge banner — none reaches a dispatched agent. This module is the selection
10
+ * half of closing that loop; a sibling CLI renders/injects the result.
11
+ *
12
+ * ## Why two tiers (the measured reason)
13
+ *
14
+ * Only a SMALL MINORITY of live learnings carry a non-empty `file_paths`. A
15
+ * purely scope-matched index is therefore EMPTY for most agents — the feature
16
+ * would ship and deliver nothing. So selection runs in two tiers with SPLIT
17
+ * budgets:
18
+ *
19
+ * (a) SCOPED — learnings whose `file_paths` relate to the agent's scope
20
+ * (see {@link SCOPE_MATCH_MIN_PATH_SCORE}), capped at `maxScoped`.
21
+ * (b) GLOBAL — top-scoring remaining learnings (the path-less majority),
22
+ * capped separately at `maxGlobal`.
23
+ *
24
+ * Snapshot behind that shape — a MEASURED-AT figure, not a standing fact; the
25
+ * corpus grows every session, so re-measure before citing it (PSA-006):
26
+ * **17 of 100 records carry `file_paths`; 17 of the 94 that pass the active gate
27
+ * = 18.1%, leaving ~82% path-less.** Measured 2026-08-13 @5d59e62 via
28
+ * `jq -s '[.[]|select(((.file_paths // .files // [])|length)>0)]|length'
29
+ * .orchestrator/metrics/learnings.jsonl`. The tier-(b) argument depends only on
30
+ * the minority/majority split, which has held across every re-measurement so
31
+ * far — not on the exact percentage.
32
+ *
33
+ * The caps are SPLIT, never shared: a single shared cap lets the global tier
34
+ * crowd out the per-agent signal that is #1014's whole point. The split is
35
+ * observable in the return value (`scopeMatched` / `globalCount`) so the ratio
36
+ * stays measurable in production.
37
+ *
38
+ * The irony that motivates tier (b): the single most relevant learning for
39
+ * building this very feature carries no `file_paths` — tier (a) alone drops it.
40
+ *
41
+ * ## Composition (this module re-implements nothing)
42
+ *
43
+ * - `affinity()` from `./affinity.mjs` — the frozen relatedness surface. The
44
+ * agent's scope descriptor `{file_paths, text}` and a learning record are
45
+ * the same shape to it.
46
+ * - `effectiveScore()` / `surfaceTopN()` from `./surface.mjs` — the reader,
47
+ * the active-filter, and the #670 time-decay ranking. There is no second
48
+ * reader here and no second decay implementation.
49
+ * - `sanitizeProse()` from `../reconcile/sanitize.mjs` — untrusted-text
50
+ * containment. Every line this module renders is AGENT-AUTHORED text bound
51
+ * for a dispatched agent's prompt, which is the identical threat model
52
+ * #1015 hardened for `.claude/rules/`. The primitives are imported, never
53
+ * re-implemented: a second copy is how this channel shipped raw beside the
54
+ * hardened one in the first place.
55
+ *
56
+ * NOT used: `filterByScope()` from `./filters.mjs`. Despite the name it filters
57
+ * the PRIVACY enum `['local','private','public']` (schema.mjs), not file scope.
58
+ * The file-scope axis lives in `file_paths[]`. This trap has misled readers
59
+ * before — do not "fix" it here.
60
+ *
61
+ * ## What this module owns
62
+ *
63
+ * Policy: thresholds, split caps, the char budget, tie-breaking, and the
64
+ * one-line rendering the budget is measured against. Ranking is ours precisely
65
+ * because `affinity()` reports `typeMatch` without folding it into its score.
66
+ * We deliberately apply NO same-type boost: a scope descriptor carries no
67
+ * `type`, so `typeMatch` is structurally always false on this axis.
68
+ *
69
+ * ## Budget
70
+ *
71
+ * {@link LEARNINGS_INDEX_MAX_CHARS} is a CODE CONSTANT with no
72
+ * `0 = unlimited` sentinel — that sentinel is the explicit upstream mistake
73
+ * #1014 exists to avoid. 2000 chars is 1.12% of the 178,095-byte per-agent
74
+ * prompt baseline measured during #1014 (a prompt measurement, not derivable
75
+ * from the tree — re-measure it before re-citing), and 0.92× the median
76
+ * `.claude/rules/` file: median 2,167 B over 29 files, re-verified
77
+ * 2026-08-13 @5d59e62 via
78
+ * `find .claude/rules -maxdepth 1 -name '*.md' -exec wc -c {} \; | sort -n`.
79
+ * Repo precedent for literal caps: `LOOP_MD_MAX_BYTES = 25_000`,
80
+ * `DEFAULT_MAX_LINE_CHARS = 400`, `MAX_TEXT_LEN = 256`.
81
+ *
82
+ * ## Contract
83
+ *
84
+ * 1. {@link selectLearnings} never throws. Hostile input yields
85
+ * {@link emptySelection} — this runs on the dispatch hot path and must
86
+ * never abort a wave (same posture as `affinity()` and `surfaceTopN()`).
87
+ * A record whose text forges the delivery wrapper is DROPPED and counted in
88
+ * `selection.rejected`, never rendered: the sanitiser's throw is caught
89
+ * per-entry so one hostile record costs one entry, not the whole index.
90
+ * 2. Zero matches yield an EMPTY selection: `text === ''`, no placeholder
91
+ * line. Callers rely on empty-means-inject-nothing.
92
+ * 3. `selection.text.length <= maxChars` always. An entry that does not fit
93
+ * is DROPPED (and `truncated` set), never emitted half-rendered.
94
+ * 4. Deterministic: same inputs → same ordering. Ties break by
95
+ * **score DESC, then `created_at` DESC, then `id` ASC**.
96
+ * 5. Expired and sub-floor entries are never selected.
97
+ */
98
+
99
+ import { affinity } from './affinity.mjs';
100
+ import {
101
+ INSIGHT_MAX_BYTES,
102
+ TITLE_MAX_BYTES,
103
+ sanitizeProse,
104
+ } from '../reconcile/sanitize.mjs';
105
+ import { DECAY_DEFAULTS, effectiveScore, surfaceTopN } from './surface.mjs';
106
+
107
+ // ---------------------------------------------------------------------------
108
+ // Constants — exported so a later wave can wire Session Config keys onto them
109
+ // without touching the logic below (config lookups are deliberately absent).
110
+ // ---------------------------------------------------------------------------
111
+
112
+ /**
113
+ * Hard character cap on the rendered index. NO `0 = unlimited` sentinel.
114
+ * 2000 = 1.12% of the measured 178,095 B per-agent prompt baseline.
115
+ */
116
+ export const LEARNINGS_INDEX_MAX_CHARS = 2000;
117
+
118
+ /**
119
+ * Per-entry line cap. 160 leaves room for a long subject without letting one
120
+ * entry eat the budget, and the split caps make the fit ARITHMETIC rather than
121
+ * empirical: a full index is at most `(8 + 4) × 160 + 11` newlines = 1,931 chars
122
+ * < {@link LEARNINGS_INDEX_MAX_CHARS}, so the two constants can never disagree.
123
+ * That bound is derived and cannot go stale; the observed mean line is the part
124
+ * that drifts — 163 B/entry, measured 2026-08-13 @5d59e62 over the live corpus
125
+ * (`selectLearningsFromFile` on `.orchestrator/metrics/learnings.jsonl`), up
126
+ * from the ~122 B seen when this cap was first set.
127
+ */
128
+ export const LEARNINGS_INDEX_MAX_LINE_CHARS = 160;
129
+
130
+ /** Split budgets — scoped signal can never be crowded out by the global tier. */
131
+ export const DEFAULT_MAX_SCOPED = 8;
132
+ export const DEFAULT_MAX_GLOBAL = 4;
133
+
134
+ /**
135
+ * Minimum `pathScore` for tier (a) membership.
136
+ *
137
+ * Calibrated against `affinity`'s segment-aware pair scores:
138
+ * - exact path → 1.0 (in)
139
+ * - directory prefix → 0.75 (in)
140
+ * - sibling in the same dir, depth 4 → 0.375 (in)
141
+ * - sibling in the same dir, depth 2 → 0.25 (in, exactly on the boundary)
142
+ * - cousin dirs (`scripts/lib/a` vs `scripts/hooks/b`) → 0.167 (out)
143
+ *
144
+ * Only dyadic ratios land exactly on the boundary, so `>=` is safe here; the
145
+ * excluded cases sit an order of magnitude below it.
146
+ *
147
+ * Deliberately NOT `sharedPaths.length === 0`: `sharedPaths` lists EXACT
148
+ * overlaps only, so a directory-prefix match scores 0.75 without appearing
149
+ * there. Using it as a proxy would silently drop the strongest partial matches.
150
+ */
151
+ export const SCOPE_MATCH_MIN_PATH_SCORE = 0.25;
152
+
153
+ /**
154
+ * Blend of relevance (affinity to this agent's scope) against quality
155
+ * (recency-decayed confidence). Relevance dominates — per-agent differentiation
156
+ * IS the acceptance criterion. Weight-normalized like `affinity()`, so the
157
+ * result stays in [0,1] for any non-negative pair.
158
+ */
159
+ export const SELECT_WEIGHTS = Object.freeze({ relevanceWeight: 0.7, qualityWeight: 0.3 });
160
+
161
+ /**
162
+ * How many active entries the file entry-point pulls before ranking.
163
+ * Ceiling: the live corpus is ~10² entries and scoring is O(pool × scopePaths);
164
+ * revisit if the corpus passes ~1,000 entries, where a pre-filter would pay off.
165
+ */
166
+ export const CANDIDATE_POOL_SIZE = 200;
167
+
168
+ /** Mirrors `surfaceTopN`'s default — entries at or below this are dropped. */
169
+ export const DEFAULT_CONFIDENCE_FLOOR = 0.3;
170
+
171
+ /**
172
+ * @typedef {{file_paths?: string[], text?: string}} AgentScope
173
+ * A dispatched agent's declared file scope plus its task text.
174
+ *
175
+ * @typedef {{entry: object, score: number, relevance: number, quality: number,
176
+ * pathScore: number, scoped: boolean, line: string}} SelectedLearning
177
+ *
178
+ * @typedef {{entries: object[], selected: SelectedLearning[], lines: string[],
179
+ * text: string, chars: number, scopeMatched: number,
180
+ * globalCount: number, candidates: number, truncated: boolean,
181
+ * rejected: number}} Selection
182
+ * `rejected` counts records dropped by the untrusted-text guard — surfaced so
183
+ * a drop is observable in the injection event rather than silent.
184
+ */
185
+
186
+ // ---------------------------------------------------------------------------
187
+ // Internals
188
+ // ---------------------------------------------------------------------------
189
+
190
+ /** True for a plain-ish object we may read properties off. */
191
+ function _isRecord(v) {
192
+ return v !== null && typeof v === 'object' && !Array.isArray(v);
193
+ }
194
+
195
+ /** Positive integer option, else the fallback. `0` is a legal cap (select none). */
196
+ function _capOpt(v, fallback) {
197
+ return Number.isInteger(v) && v >= 0 ? v : fallback;
198
+ }
199
+
200
+ /**
201
+ * The active gate: confidence strictly above the floor, and not expired.
202
+ *
203
+ * Deliberately re-stated rather than imported: `surfaceTopN` inlines this filter
204
+ * and exports no predicate, and {@link selectLearnings} must hold contract
205
+ * point 5 for callers that hand it raw entries. Idempotent on the
206
+ * {@link selectLearningsFromFile} path, where `surfaceTopN` already applied it.
207
+ */
208
+ function _isActive(entry, nowMs, confidenceFloor) {
209
+ if (typeof entry.confidence !== 'number' || entry.confidence <= confidenceFloor) return false;
210
+ if (typeof entry.expires_at === 'string') {
211
+ const expiresMs = Date.parse(entry.expires_at);
212
+ if (Number.isFinite(expiresMs) && expiresMs <= nowMs) return false;
213
+ }
214
+ return true;
215
+ }
216
+
217
+ /** Epoch ms from a Date | number | undefined clock option. */
218
+ function _resolveNowMs(now) {
219
+ if (now instanceof Date) return now.getTime();
220
+ if (typeof now === 'number' && Number.isFinite(now)) return now;
221
+ return Date.now();
222
+ }
223
+
224
+ /** Merge caller decay overrides over the conservative #670 defaults. */
225
+ function _resolveDecay(decayOpt) {
226
+ return {
227
+ enabled: decayOpt?.enabled ?? DECAY_DEFAULTS.enabled,
228
+ halfLifeDays: decayOpt?.halfLifeDays ?? DECAY_DEFAULTS.halfLifeDays,
229
+ floorFactor: decayOpt?.floorFactor ?? DECAY_DEFAULTS.floorFactor,
230
+ };
231
+ }
232
+
233
+ /** Date.parse or 0 — used only as a tiebreaker, never as a filter. */
234
+ function _createdMs(entry) {
235
+ const v = entry?.created_at;
236
+ if (typeof v !== 'string') return 0;
237
+ const ms = Date.parse(v);
238
+ return Number.isFinite(ms) ? ms : 0;
239
+ }
240
+
241
+ /**
242
+ * Total order over scored candidates (contract point 4):
243
+ * score DESC → created_at DESC → id ASC. Array.prototype.sort is stable
244
+ * (ES2019+), so fully-equal records keep input order.
245
+ */
246
+ function _compareCandidates(a, b) {
247
+ if (b.score !== a.score) return b.score - a.score;
248
+ const timeDiff = _createdMs(b.entry) - _createdMs(a.entry);
249
+ if (timeDiff !== 0) return timeDiff;
250
+ const aId = typeof a.entry.id === 'string' ? a.entry.id : '';
251
+ const bId = typeof b.entry.id === 'string' ? b.entry.id : '';
252
+ return aId < bId ? -1 : aId > bId ? 1 : 0;
253
+ }
254
+
255
+ // ---------------------------------------------------------------------------
256
+ // Public surface
257
+ // ---------------------------------------------------------------------------
258
+
259
+ /**
260
+ * The zero-value selection. Built fresh per call so no consumer can mutate a
261
+ * shared singleton. `text` is `''` — never a "no learnings found" placeholder.
262
+ *
263
+ * @returns {Selection}
264
+ */
265
+ export function emptySelection() {
266
+ return {
267
+ entries: [],
268
+ selected: [],
269
+ lines: [],
270
+ text: '',
271
+ chars: 0,
272
+ scopeMatched: 0,
273
+ globalCount: 0,
274
+ candidates: 0,
275
+ truncated: false,
276
+ rejected: 0,
277
+ };
278
+ }
279
+
280
+ /**
281
+ * Truncate to at most `maxUnits` UTF-16 code units, cutting on a CODE-POINT
282
+ * boundary.
283
+ *
284
+ * A plain `str.slice(0, n)` cuts between the two halves of a surrogate pair and
285
+ * emits a LONE SURROGATE (U+D800–U+DFFF) — an unpaired code unit that is not a
286
+ * valid character, renders as U+FFFD, and is delivered straight into an agent
287
+ * prompt. Any entry whose text carries an emoji or an astral-plane character can
288
+ * land exactly on that boundary. The sibling `sanitize.mjs` already cuts on a
289
+ * code-point boundary (`truncateToBytes`); that one measures BYTES, while this
290
+ * budget is measured in UTF-16 chars (`Selection.chars` vs `maxChars`), so the
291
+ * unit differs and the function cannot simply be reused.
292
+ *
293
+ * @param {string} str
294
+ * @param {number} maxUnits
295
+ * @returns {string}
296
+ */
297
+ function _sliceCodePoints(str, maxUnits) {
298
+ if (str.length <= maxUnits) return str;
299
+ let out = '';
300
+ for (const ch of str) {
301
+ if (out.length + ch.length > maxUnits) break;
302
+ out += ch;
303
+ }
304
+ return out;
305
+ }
306
+
307
+ /**
308
+ * Render ONE learning as a single index line: sanitised, whitespace-collapsed
309
+ * and capped.
310
+ *
311
+ * Collapsing whitespace is load-bearing, not cosmetic: a multi-line `insight`
312
+ * would otherwise break the one-line-per-entry shape the char budget is
313
+ * measured against — and, since the block's boundary recovery is line-based, a
314
+ * smuggled newline would also fabricate an extra entry.
315
+ *
316
+ * Untrusted (#1015): `type`, `subject` and `insight` are AGENT-AUTHORED and this
317
+ * line is delivered verbatim into a dispatched agent's prompt, so each field
318
+ * passes through {@link sanitizeProse} — dangerous invisibles (Unicode Tag
319
+ * block, bidi overrides, zero-width) and control characters stripped, the
320
+ * envelope marker neutralised, delivery-wrapper forgery REJECTED. There is
321
+ * deliberately no phrase blocklist: the corpus is full of legitimate imperative
322
+ * prose ("parse both readings and judge both, never pick one"), so a blocklist
323
+ * would be the guard that looks green and does not bite. Framing is the
324
+ * containment, and the block wrapper supplies it.
325
+ *
326
+ * @param {object} entry
327
+ * @param {{maxLineChars?: number}} [opts]
328
+ * @returns {string} the line, or '' when the entry carries no renderable text
329
+ * @throws {Error} (`reconcile-sanitize: …`) when a field forges the delivery
330
+ * wrapper. {@link selectLearnings} catches this per entry and drops the record;
331
+ * a direct caller must decide for itself.
332
+ */
333
+ export function renderIndexLine(entry, opts = {}) {
334
+ if (!_isRecord(entry)) return '';
335
+ const maxLineChars = _capOpt(opts.maxLineChars, LEARNINGS_INDEX_MAX_LINE_CHARS);
336
+ if (maxLineChars <= 0) return '';
337
+
338
+ // Sanitise BEFORE collapsing whitespace: stripping a zero-width character can
339
+ // leave adjacent spaces, and the collapse then normalises them away.
340
+ const clean = (v, maxBytes) =>
341
+ typeof v === 'string' && v !== ''
342
+ ? sanitizeProse(v, { field: 'learnings-index', maxBytes })
343
+ .replace(/\s+/g, ' ')
344
+ .trim()
345
+ : '';
346
+
347
+ const type = clean(entry.type, TITLE_MAX_BYTES);
348
+ const subject = clean(entry.subject, TITLE_MAX_BYTES);
349
+ const insight = clean(entry.insight, INSIGHT_MAX_BYTES);
350
+
351
+ const head = [type, subject].filter(Boolean).join('/');
352
+ if (!head && !insight) return '';
353
+
354
+ let line = `- ${head}${head && insight ? ': ' : ''}${insight}`;
355
+ if (line.length > maxLineChars) line = `${_sliceCodePoints(line, maxLineChars - 1)}…`;
356
+ return line;
357
+ }
358
+
359
+ /**
360
+ * Score one learning against an agent scope.
361
+ *
362
+ * `score` = weight-normalized blend of relevance (`affinity().score`) and
363
+ * quality (`effectiveScore()` — recency-decayed confidence). `typeMatch` is
364
+ * deliberately not folded in; see the module header.
365
+ *
366
+ * @param {object} entry
367
+ * @param {AgentScope} scope
368
+ * @param {{now?: Date|number, decay?: object, affinityOpts?: object}} [opts]
369
+ * @returns {{score: number, relevance: number, quality: number, pathScore: number}}
370
+ */
371
+ export function scoreLearning(entry, scope, opts = {}) {
372
+ const zero = { score: 0, relevance: 0, quality: 0, pathScore: 0 };
373
+ if (!_isRecord(entry)) return zero;
374
+
375
+ try {
376
+ const nowMs = _resolveNowMs(opts.now);
377
+ const decay = _resolveDecay(opts.decay);
378
+ const aff = affinity(scope, entry, opts.affinityOpts);
379
+ const quality = effectiveScore(entry, nowMs, decay);
380
+ const q = Number.isFinite(quality) ? Math.min(Math.max(quality, 0), 1) : 0;
381
+
382
+ const { relevanceWeight, qualityWeight } = SELECT_WEIGHTS;
383
+ const total = relevanceWeight + qualityWeight;
384
+ const score = total > 0 ? (relevanceWeight * aff.score + qualityWeight * q) / total : 0;
385
+
386
+ return {
387
+ score: Number.isFinite(score) ? score : 0,
388
+ relevance: aff.score,
389
+ quality: q,
390
+ pathScore: aff.pathScore,
391
+ };
392
+ } catch {
393
+ return zero;
394
+ }
395
+ }
396
+
397
+ /**
398
+ * Select the learnings that go into ONE agent's compact index.
399
+ *
400
+ * Two tiers with SPLIT budgets (see module header), then a greedy fill against
401
+ * the char cap in render order (scoped first, then global). An entry whose line
402
+ * does not fit is dropped and `truncated` is set — never emitted partially.
403
+ *
404
+ * @param {object[]} entries — candidate learnings (already read from disk)
405
+ * @param {AgentScope} scope — the agent's declared file scope + task text
406
+ * @param {object} [opts]
407
+ * @param {number} [opts.maxScoped=DEFAULT_MAX_SCOPED]
408
+ * @param {number} [opts.maxGlobal=DEFAULT_MAX_GLOBAL]
409
+ * @param {number} [opts.maxChars=LEARNINGS_INDEX_MAX_CHARS]
410
+ * @param {number} [opts.maxLineChars=LEARNINGS_INDEX_MAX_LINE_CHARS]
411
+ * @param {number} [opts.minPathScore=SCOPE_MATCH_MIN_PATH_SCORE]
412
+ * @param {number} [opts.confidenceFloor=DEFAULT_CONFIDENCE_FLOOR]
413
+ * @param {Date|number} [opts.now] — injectable clock
414
+ * @param {object} [opts.decay] — #670 decay tuning, forwarded to effectiveScore
415
+ * @param {object} [opts.affinityOpts] — forwarded to affinity()
416
+ * @returns {Selection}
417
+ */
418
+ export function selectLearnings(entries, scope, opts = {}) {
419
+ try {
420
+ if (!Array.isArray(entries) || entries.length === 0) return emptySelection();
421
+
422
+ const o = _isRecord(opts) ? opts : {};
423
+ const maxScoped = _capOpt(o.maxScoped, DEFAULT_MAX_SCOPED);
424
+ const maxGlobal = _capOpt(o.maxGlobal, DEFAULT_MAX_GLOBAL);
425
+ const maxChars = _capOpt(o.maxChars, LEARNINGS_INDEX_MAX_CHARS);
426
+ const maxLineChars = _capOpt(o.maxLineChars, LEARNINGS_INDEX_MAX_LINE_CHARS);
427
+ const minPathScore =
428
+ typeof o.minPathScore === 'number' && Number.isFinite(o.minPathScore)
429
+ ? o.minPathScore
430
+ : SCOPE_MATCH_MIN_PATH_SCORE;
431
+ const confidenceFloor =
432
+ typeof o.confidenceFloor === 'number' && Number.isFinite(o.confidenceFloor)
433
+ ? o.confidenceFloor
434
+ : DEFAULT_CONFIDENCE_FLOOR;
435
+ const nowMs = _resolveNowMs(o.now);
436
+ const scoreOpts = { now: nowMs, decay: o.decay, affinityOpts: o.affinityOpts };
437
+
438
+ /** @type {SelectedLearning[]} */
439
+ const scoped = [];
440
+ /** @type {SelectedLearning[]} */
441
+ const global = [];
442
+ let candidates = 0;
443
+ let rejected = 0;
444
+
445
+ for (const entry of entries) {
446
+ if (!_isRecord(entry)) continue;
447
+ if (!_isActive(entry, nowMs, confidenceFloor)) continue;
448
+ candidates++;
449
+
450
+ const s = scoreLearning(entry, scope, scoreOpts);
451
+ // Fail CLOSED per entry: a record whose text forges the delivery wrapper
452
+ // is dropped, not neutralised in place — with 100 candidates competing for
453
+ // 12 slots, dropping one costs nothing, while a partially-neutralised line
454
+ // would leave a forged boundary that a "the literal is gone" assertion
455
+ // reads as clean. The drop is counted, never silent (`selection.rejected`).
456
+ let line;
457
+ try {
458
+ line = renderIndexLine(entry, { maxLineChars });
459
+ } catch {
460
+ rejected++;
461
+ continue;
462
+ }
463
+ if (line.length === 0) continue;
464
+
465
+ const isScoped = s.pathScore >= minPathScore;
466
+ const cand = {
467
+ entry,
468
+ score: s.score,
469
+ relevance: s.relevance,
470
+ quality: s.quality,
471
+ pathScore: s.pathScore,
472
+ scoped: isScoped,
473
+ line,
474
+ };
475
+ (isScoped ? scoped : global).push(cand);
476
+ }
477
+
478
+ scoped.sort(_compareCandidates);
479
+ global.sort(_compareCandidates);
480
+
481
+ // Split caps: the global tier can never displace scoped signal.
482
+ const ordered = [...scoped.slice(0, maxScoped), ...global.slice(0, maxGlobal)];
483
+
484
+ /** @type {SelectedLearning[]} */
485
+ const selected = [];
486
+ const lines = [];
487
+ let chars = 0;
488
+ let truncated = scoped.length > maxScoped || global.length > maxGlobal;
489
+
490
+ for (const cand of ordered) {
491
+ const next = chars === 0 ? cand.line.length : chars + 1 + cand.line.length;
492
+ if (next > maxChars) {
493
+ truncated = true;
494
+ continue;
495
+ }
496
+ chars = next;
497
+ selected.push(cand);
498
+ lines.push(cand.line);
499
+ }
500
+
501
+ const text = lines.join('\n');
502
+ return {
503
+ entries: selected.map((c) => c.entry),
504
+ selected,
505
+ lines,
506
+ text,
507
+ chars: text.length,
508
+ scopeMatched: selected.filter((c) => c.scoped).length,
509
+ globalCount: selected.filter((c) => !c.scoped).length,
510
+ candidates,
511
+ truncated,
512
+ rejected,
513
+ };
514
+ } catch {
515
+ // Contract point 1 — a ranking primitive on the dispatch hot path must
516
+ // never abort a wave. Every reachable path above is already total.
517
+ return emptySelection();
518
+ }
519
+ }
520
+
521
+ /**
522
+ * File entry-point: read active learnings via `surfaceTopN` (the ONE reader —
523
+ * it owns the active-filter and the #670 decay ranking), then select.
524
+ *
525
+ * @param {string} filePath — absolute path to learnings.jsonl
526
+ * @param {AgentScope} scope
527
+ * @param {object} [opts] — everything {@link selectLearnings} accepts, plus
528
+ * `poolSize` (how many active entries to pull before ranking).
529
+ * @returns {Promise<Selection>} `emptySelection()` on a missing/unreadable file
530
+ */
531
+ export async function selectLearningsFromFile(filePath, scope, opts = {}) {
532
+ try {
533
+ const o = _isRecord(opts) ? opts : {};
534
+ const poolSize = _capOpt(o.poolSize, CANDIDATE_POOL_SIZE);
535
+ const nowMs = _resolveNowMs(o.now);
536
+ const confidenceFloor =
537
+ typeof o.confidenceFloor === 'number' && Number.isFinite(o.confidenceFloor)
538
+ ? o.confidenceFloor
539
+ : DEFAULT_CONFIDENCE_FLOOR;
540
+
541
+ const entries = await surfaceTopN(filePath, poolSize, {
542
+ now: nowMs,
543
+ confidenceFloor,
544
+ decay: o.decay,
545
+ });
546
+ return selectLearnings(entries, scope, { ...o, now: nowMs, confidenceFloor });
547
+ } catch {
548
+ return emptySelection();
549
+ }
550
+ }