session-orchestrator 3.19.0 → 3.21.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (158) hide show
  1. package/.claude-plugin/marketplace.json +1 -1
  2. package/.claude-plugin/plugin.json +1 -1
  3. package/.codex-plugin/plugin.json +1 -1
  4. package/.cursor/rules/030-wave-execution.mdc +10 -8
  5. package/CHANGELOG.md +494 -0
  6. package/README.md +16 -11
  7. package/agents/analyst.md +1 -1
  8. package/agents/architect-reviewer.md +1 -1
  9. package/agents/code-implementer.md +4 -2
  10. package/agents/db-specialist.md +1 -1
  11. package/agents/dialectic-deriver.md +1 -1
  12. package/agents/docs-writer.md +1 -1
  13. package/agents/memory-proposal-collector.md +1 -1
  14. package/agents/qa-strategist.md +1 -1
  15. package/agents/security-reviewer.md +1 -1
  16. package/agents/session-reviewer.md +42 -1
  17. package/agents/skill-applied-judge.md +1 -1
  18. package/agents/test-writer.md +1 -1
  19. package/agents/ui-developer.md +1 -1
  20. package/agents/ux-evaluator.md +1 -1
  21. package/commands/release.md +60 -0
  22. package/commands/session.md +6 -2
  23. package/docs/USER-GUIDE.md +1 -1
  24. package/docs/instruction-delivery.md +350 -0
  25. package/docs/migration-v3.md +9 -6
  26. package/docs/persona-panel.md +3 -1
  27. package/docs/scope-collision-guard.md +167 -0
  28. package/docs/session-config-reference.md +1 -41
  29. package/docs/session-config-template.md +0 -23
  30. package/hooks/_lib/guard-source-loader.mjs +304 -91
  31. package/hooks/enforce-commands.mjs +216 -17
  32. package/hooks/enforce-scope.mjs +236 -12
  33. package/hooks/hooks-codex.json +1 -1
  34. package/hooks/hooks.json +11 -1
  35. package/hooks/on-session-end.mjs +52 -5
  36. package/hooks/on-session-start.mjs +7 -4
  37. package/hooks/on-stop.mjs +127 -12
  38. package/hooks/post-bash-write-verify.mjs +8 -32
  39. package/hooks/pre-bash-destructive-guard.mjs +146 -59
  40. package/hooks/pre-bash-sessions-ledger-guard.mjs +493 -66
  41. package/hooks/pre-task-scope-disjoint.mjs +1042 -0
  42. package/package.json +2 -2
  43. package/pi/prompts/release.md +12 -0
  44. package/scripts/autopilot.mjs +3 -1
  45. package/scripts/backfill-learnings-from-vault.mjs +967 -0
  46. package/scripts/emit-session.mjs +45 -40
  47. package/scripts/export-hw-learnings.mjs +61 -2
  48. package/scripts/lib/autopilot/worktree-pipeline.mjs +5 -5
  49. package/scripts/lib/backlog-scan.mjs +106 -15
  50. package/scripts/lib/build-live-signals.mjs +7 -3
  51. package/scripts/lib/ci-status-banner.mjs +207 -23
  52. package/scripts/lib/command-blocker.mjs +322 -62
  53. package/scripts/lib/git-config-drift.mjs +471 -0
  54. package/scripts/lib/hardening.mjs +9 -9
  55. package/scripts/lib/harness-audit/categories/category6.mjs +65 -12
  56. package/scripts/lib/io.mjs +193 -7
  57. package/scripts/lib/learnings/affinity.mjs +434 -0
  58. package/scripts/lib/learnings/candidates.mjs +736 -0
  59. package/scripts/lib/learnings/expiry-sweep.mjs +408 -53
  60. package/scripts/lib/learnings/judgment.mjs +782 -0
  61. package/scripts/lib/learnings/kebab.mjs +128 -0
  62. package/scripts/lib/learnings/select.mjs +704 -0
  63. package/scripts/lib/memory-cleanup-stamp.mjs +132 -8
  64. package/scripts/lib/mirror-issues-banner.mjs +266 -0
  65. package/scripts/lib/named-vault-resolver.mjs +105 -16
  66. package/scripts/lib/peer-cards/schema.mjs +6 -2
  67. package/scripts/lib/reconcile/emitter.mjs +107 -22
  68. package/scripts/lib/reconcile/engine.mjs +9 -15
  69. package/scripts/lib/reconcile/renderer.mjs +141 -25
  70. package/scripts/lib/reconcile/sanitize.mjs +518 -0
  71. package/scripts/lib/reconcile/writer.mjs +134 -1
  72. package/scripts/lib/redact-spans.mjs +89 -0
  73. package/scripts/lib/scope-baseline.mjs +77 -17
  74. package/scripts/lib/scope-gate.mjs +852 -72
  75. package/scripts/lib/secret-masker.mjs +262 -0
  76. package/scripts/lib/session-close-backfill.mjs +2 -2
  77. package/scripts/lib/session-lock.mjs +34 -10
  78. package/scripts/lib/session-record-repair.mjs +551 -0
  79. package/scripts/lib/session-registry.mjs +9 -1
  80. package/scripts/lib/session-schema/serializer.mjs +54 -0
  81. package/scripts/lib/session-schema.mjs +1 -0
  82. package/scripts/lib/session-token-rollup.mjs +68 -6
  83. package/scripts/lib/soul-resolve.mjs +12 -0
  84. package/scripts/lib/state-md/mission-status.mjs +21 -12
  85. package/scripts/lib/tmux-layout/telemetry.mjs +43 -10
  86. package/scripts/lib/tmux-layout/vcs-detector.mjs +108 -4
  87. package/scripts/lib/validate/check-agents.mjs +77 -5
  88. package/scripts/lib/validate/check-banner-parity.mjs +376 -0
  89. package/scripts/lib/validate/check-commands.mjs +2 -20
  90. package/scripts/lib/validate/check-doc-cli-commands.mjs +514 -0
  91. package/scripts/lib/validate/check-guard-requires-parity.mjs +1148 -0
  92. package/scripts/lib/validate/check-hooks-symmetry.mjs +18 -0
  93. package/scripts/lib/validate/check-learning-provenance.mjs +511 -0
  94. package/scripts/lib/validate/check-owner-leakage.mjs +188 -20
  95. package/scripts/lib/validate/check-rules.mjs +31 -5
  96. package/scripts/lib/validate/check-skills.mjs +191 -0
  97. package/scripts/lib/validate/check-test-git-config-target.mjs +665 -0
  98. package/scripts/lib/validate/check-unicode-safety.mjs +22 -2
  99. package/scripts/lib/validate/check-untracked-test-deps.mjs +925 -0
  100. package/scripts/lib/validate/check-unwired-features.mjs +757 -0
  101. package/scripts/lib/validate/check-vcs-repo-flag.mjs +965 -0
  102. package/scripts/lib/validate/frontmatter-block.mjs +61 -0
  103. package/scripts/lib/validate/tier-inference.mjs +46 -8
  104. package/scripts/lib/vault-mirror/namespace.mjs +146 -1
  105. package/scripts/lib/vault-mirror/process.mjs +264 -31
  106. package/scripts/lib/vault-mirror/render-sessions.mjs +115 -4
  107. package/scripts/lib/vault-status/board-writer.mjs +300 -56
  108. package/scripts/lib/vault-status/narrative-mirror.mjs +119 -5
  109. package/scripts/lib/vcs-repo-spec.mjs +500 -19
  110. package/scripts/print-applicable-rules.mjs +170 -7
  111. package/scripts/print-learnings-index.mjs +501 -0
  112. package/scripts/release.mjs +616 -61
  113. package/scripts/repair-invalid-sessions.mjs +209 -0
  114. package/scripts/site-numbers.mjs +1049 -0
  115. package/scripts/sweep-expired-learnings.mjs +192 -32
  116. package/scripts/validate-plugin.mjs +82 -0
  117. package/scripts/validate-wave-scope.mjs +281 -12
  118. package/scripts/vault-mirror.mjs +26 -1
  119. package/skills/_shared/monitor-patterns.md +24 -4
  120. package/skills/_shared/state-ownership.md +17 -0
  121. package/skills/brainstorm/soul.md +47 -1
  122. package/skills/claude-md-drift-check/SKILL.md +9 -1
  123. package/skills/debug/SKILL.md +4 -1
  124. package/skills/discovery/issue-templates.md +4 -4
  125. package/skills/discovery/probes-code.md +2 -2
  126. package/skills/discovery/probes-feature.md +6 -6
  127. package/skills/discovery/probes-infra.md +2 -2
  128. package/skills/discovery/probes-session.md +5 -5
  129. package/skills/dispatcher/SKILL.md +10 -1
  130. package/skills/evolve/SKILL.md +116 -18
  131. package/skills/frontmatter-guard/SKILL.md +9 -1
  132. package/skills/gitlab-ops/SKILL.md +54 -39
  133. package/skills/gitlab-portfolio/SKILL.md +10 -1
  134. package/skills/grill/soul.md +44 -1
  135. package/skills/memory-cleanup/SKILL.md +18 -5
  136. package/skills/npm-publish/SKILL.md +22 -50
  137. package/skills/persona-panel/SKILL.md +3 -1
  138. package/skills/plan/mode-new.md +23 -5
  139. package/skills/plan/soul.md +46 -3
  140. package/skills/repo-audit/SKILL.md +10 -1
  141. package/skills/session-end/SKILL.md +45 -26
  142. package/skills/session-end/metrics-collection.md +1 -1
  143. package/skills/session-end/phase-3-6-tail.md +30 -1
  144. package/skills/session-end/plan-verification.md +1 -5
  145. package/skills/session-end/session-metrics-write.md +6 -10
  146. package/skills/session-plan/SKILL.md +2 -2
  147. package/skills/session-plan/wave-template.md +1 -1
  148. package/skills/session-start/SKILL.md +15 -1
  149. package/skills/session-start/soul.md +41 -1
  150. package/skills/spinout/SKILL.md +5 -1
  151. package/skills/sunset-review/SKILL.md +11 -1
  152. package/skills/tmux-layout/SKILL.md +7 -2
  153. package/skills/vault-mirror/SKILL.md +10 -1
  154. package/skills/vault-sync/SKILL.md +10 -1
  155. package/skills/vault-sync/validator.mjs +55 -6
  156. package/skills/wave-executor/SKILL.md +1 -5
  157. package/skills/wave-executor/wave-loop.md +77 -82
  158. package/scripts/lib/mission-status-schema.mjs +0 -114
@@ -0,0 +1,704 @@
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
+ * - `extractProvenance()` from `../validate/check-learning-provenance.mjs` and
56
+ * `learningKeyOf()` from `./kebab.mjs` — the already-delivered filter below.
57
+ * Both readers already exist; a second provenance parser here would be the
58
+ * "same fact in two copies" defect this repo keeps paying for.
59
+ *
60
+ * ## Already-delivered filter (#1019)
61
+ *
62
+ * `/reconcile` converts a learning into a `.claude/rules/*.md` file, and Claude
63
+ * Code delivers every such file to every dispatched agent NATIVELY, in full.
64
+ * Measured first-person 2026-08-15 @fd73548+wave-2: a wave subagent's context
65
+ * carried all 29 of `.claude/rules/*.md` — `alwaysApply: false` and `globs:`
66
+ * notwithstanding, because `rule-loader.mjs` (the only code that understands
67
+ * that frontmatter) does not run on the delivery path
68
+ * (`docs/instruction-delivery.md` §1/§1.1). So a rule-derived learning that also
69
+ * enters this index arrives TWICE, and the second copy costs a slot in a
70
+ * 2000-char budget — displacing a learning the agent would otherwise never see.
71
+ *
72
+ * The filter therefore runs BEFORE the split caps, not after: dropping a
73
+ * duplicate after the Top-N cut would remove the line without freeing its slot,
74
+ * which is the whole harm. Two axes, mirroring the provenance checker's own
75
+ * `dangling` / `superseded` split: the rule's `learning-id` (exact record) and
76
+ * its `learning-key` (logical identity, stable across a re-minted UUID — the
77
+ * state any id backfill lands in). 13 of 29 rules carry provenance and all 13
78
+ * resolve by id today, so the key axis is currently inert by measurement, not
79
+ * by design.
80
+ *
81
+ * SILENT NO-OP is a hard requirement: no rules directory, no `.md` files, or no
82
+ * provenance block anywhere ⇒ empty sets ⇒ byte-identical output. A repo without
83
+ * `/reconcile` must never see FEWER learnings because this filter exists.
84
+ *
85
+ * NOT used: `filterByScope()` from `./filters.mjs`. Despite the name it filters
86
+ * the PRIVACY enum `['local','private','public']` (schema.mjs), not file scope.
87
+ * The file-scope axis lives in `file_paths[]`. This trap has misled readers
88
+ * before — do not "fix" it here.
89
+ *
90
+ * ## What this module owns
91
+ *
92
+ * Policy: thresholds, split caps, the char budget, tie-breaking, and the
93
+ * one-line rendering the budget is measured against. Ranking is ours precisely
94
+ * because `affinity()` reports `typeMatch` without folding it into its score.
95
+ * We deliberately apply NO same-type boost: a scope descriptor carries no
96
+ * `type`, so `typeMatch` is structurally always false on this axis.
97
+ *
98
+ * ## Budget
99
+ *
100
+ * {@link LEARNINGS_INDEX_MAX_CHARS} is a CODE CONSTANT with no
101
+ * `0 = unlimited` sentinel — that sentinel is the explicit upstream mistake
102
+ * #1014 exists to avoid. 2000 chars is 1.12% of the 178,095-byte per-agent
103
+ * prompt baseline measured during #1014 (a prompt measurement, not derivable
104
+ * from the tree — re-measure it before re-citing), and 0.92× the median
105
+ * `.claude/rules/` file: median 2,167 B over 29 files, re-verified
106
+ * 2026-08-13 @5d59e62 via
107
+ * `find .claude/rules -maxdepth 1 -name '*.md' -exec wc -c {} \; | sort -n`.
108
+ * Repo precedent for literal caps: `LOOP_MD_MAX_BYTES = 25_000`,
109
+ * `DEFAULT_MAX_LINE_CHARS = 400`, `MAX_TEXT_LEN = 256`.
110
+ *
111
+ * ## Contract
112
+ *
113
+ * 1. {@link selectLearnings} never throws. Hostile input yields
114
+ * {@link emptySelection} — this runs on the dispatch hot path and must
115
+ * never abort a wave (same posture as `affinity()` and `surfaceTopN()`).
116
+ * A record whose text forges the delivery wrapper is DROPPED and counted in
117
+ * `selection.rejected`, never rendered: the sanitiser's throw is caught
118
+ * per-entry so one hostile record costs one entry, not the whole index.
119
+ * 2. Zero matches yield an EMPTY selection: `text === ''`, no placeholder
120
+ * line. Callers rely on empty-means-inject-nothing.
121
+ * 3. `selection.text.length <= maxChars` always. An entry that does not fit
122
+ * is DROPPED (and `truncated` set), never emitted half-rendered.
123
+ * 4. Deterministic: same inputs → same ordering. Ties break by
124
+ * **score DESC, then `created_at` DESC, then `id` ASC**.
125
+ * 5. Expired and sub-floor entries are never selected.
126
+ */
127
+
128
+ import { readFileSync, readdirSync } from 'node:fs';
129
+ import { join } from 'node:path';
130
+
131
+ import { affinity } from './affinity.mjs';
132
+ import {
133
+ INSIGHT_MAX_BYTES,
134
+ TITLE_MAX_BYTES,
135
+ sanitizeProse,
136
+ } from '../reconcile/sanitize.mjs';
137
+ import { learningKeyOf } from './kebab.mjs';
138
+ import { extractProvenance } from '../validate/check-learning-provenance.mjs';
139
+ import { DECAY_DEFAULTS, effectiveScore, surfaceTopN } from './surface.mjs';
140
+
141
+ // ---------------------------------------------------------------------------
142
+ // Constants — exported so a later wave can wire Session Config keys onto them
143
+ // without touching the logic below (config lookups are deliberately absent).
144
+ // ---------------------------------------------------------------------------
145
+
146
+ /**
147
+ * Hard character cap on the rendered index. NO `0 = unlimited` sentinel.
148
+ * 2000 = 1.12% of the measured 178,095 B per-agent prompt baseline.
149
+ */
150
+ export const LEARNINGS_INDEX_MAX_CHARS = 2000;
151
+
152
+ /**
153
+ * Per-entry line cap. 160 leaves room for a long subject without letting one
154
+ * entry eat the budget, and the split caps make the fit ARITHMETIC rather than
155
+ * empirical: a full index is at most `(8 + 4) × 160 + 11` newlines = 1,931 chars
156
+ * < {@link LEARNINGS_INDEX_MAX_CHARS}, so the two constants can never disagree.
157
+ * That bound is derived and cannot go stale; the observed mean line is the part
158
+ * that drifts — 163 B/entry, measured 2026-08-13 @5d59e62 over the live corpus
159
+ * (`selectLearningsFromFile` on `.orchestrator/metrics/learnings.jsonl`), up
160
+ * from the ~122 B seen when this cap was first set.
161
+ */
162
+ export const LEARNINGS_INDEX_MAX_LINE_CHARS = 160;
163
+
164
+ /** Split budgets — scoped signal can never be crowded out by the global tier. */
165
+ export const DEFAULT_MAX_SCOPED = 8;
166
+ export const DEFAULT_MAX_GLOBAL = 4;
167
+
168
+ /**
169
+ * Minimum `pathScore` for tier (a) membership.
170
+ *
171
+ * Calibrated against `affinity`'s segment-aware pair scores:
172
+ * - exact path → 1.0 (in)
173
+ * - directory prefix → 0.75 (in)
174
+ * - sibling in the same dir, depth 4 → 0.375 (in)
175
+ * - sibling in the same dir, depth 2 → 0.25 (in, exactly on the boundary)
176
+ * - cousin dirs (`scripts/lib/a` vs `scripts/hooks/b`) → 0.167 (out)
177
+ *
178
+ * Only dyadic ratios land exactly on the boundary, so `>=` is safe here; the
179
+ * excluded cases sit an order of magnitude below it.
180
+ *
181
+ * Deliberately NOT `sharedPaths.length === 0`: `sharedPaths` lists EXACT
182
+ * overlaps only, so a directory-prefix match scores 0.75 without appearing
183
+ * there. Using it as a proxy would silently drop the strongest partial matches.
184
+ */
185
+ export const SCOPE_MATCH_MIN_PATH_SCORE = 0.25;
186
+
187
+ /**
188
+ * Blend of relevance (affinity to this agent's scope) against quality
189
+ * (recency-decayed confidence). Relevance dominates — per-agent differentiation
190
+ * IS the acceptance criterion. Weight-normalized like `affinity()`, so the
191
+ * result stays in [0,1] for any non-negative pair.
192
+ */
193
+ export const SELECT_WEIGHTS = Object.freeze({ relevanceWeight: 0.7, qualityWeight: 0.3 });
194
+
195
+ /**
196
+ * How many active entries the file entry-point pulls before ranking.
197
+ * Ceiling: the live corpus is ~10² entries and scoring is O(pool × scopePaths);
198
+ * revisit if the corpus passes ~1,000 entries, where a pre-filter would pay off.
199
+ */
200
+ export const CANDIDATE_POOL_SIZE = 200;
201
+
202
+ /** Mirrors `surfaceTopN`'s default — entries at or below this are dropped. */
203
+ export const DEFAULT_CONFIDENCE_FLOOR = 0.3;
204
+
205
+ /**
206
+ * @typedef {{file_paths?: string[], text?: string}} AgentScope
207
+ * A dispatched agent's declared file scope plus its task text.
208
+ *
209
+ * @typedef {{entry: object, score: number, relevance: number, quality: number,
210
+ * pathScore: number, scoped: boolean, line: string}} SelectedLearning
211
+ *
212
+ * @typedef {{entries: object[], selected: SelectedLearning[], lines: string[],
213
+ * text: string, chars: number, scopeMatched: number,
214
+ * globalCount: number, candidates: number, truncated: boolean,
215
+ * rejected: number, deliveredFiltered: number}} Selection
216
+ * `rejected` counts records dropped by the untrusted-text guard — surfaced so
217
+ * a drop is observable in the injection event rather than silent.
218
+ * `deliveredFiltered` counts records dropped because a `.claude/rules/*.md`
219
+ * file already delivers them natively (#1019) — same reason, and it is the
220
+ * only way to tell "the filter bit" from "the corpus has no such learning".
221
+ *
222
+ * @typedef {{ids: Set<string>, keys: Set<string>}} DeliveredProvenance
223
+ * Learning ids and logical keys already delivered as `.claude/rules/*.md`.
224
+ */
225
+
226
+ // ---------------------------------------------------------------------------
227
+ // Internals
228
+ // ---------------------------------------------------------------------------
229
+
230
+ /** True for a plain-ish object we may read properties off. */
231
+ function _isRecord(v) {
232
+ return v !== null && typeof v === 'object' && !Array.isArray(v);
233
+ }
234
+
235
+ /** Positive integer option, else the fallback. `0` is a legal cap (select none). */
236
+ function _capOpt(v, fallback) {
237
+ return Number.isInteger(v) && v >= 0 ? v : fallback;
238
+ }
239
+
240
+ /**
241
+ * The active gate: confidence strictly above the floor, and not expired.
242
+ *
243
+ * Deliberately re-stated rather than imported: `surfaceTopN` inlines this filter
244
+ * and exports no predicate, and {@link selectLearnings} must hold contract
245
+ * point 5 for callers that hand it raw entries. Idempotent on the
246
+ * {@link selectLearningsFromFile} path, where `surfaceTopN` already applied it.
247
+ */
248
+ function _isActive(entry, nowMs, confidenceFloor) {
249
+ if (typeof entry.confidence !== 'number' || entry.confidence <= confidenceFloor) return false;
250
+ if (typeof entry.expires_at === 'string') {
251
+ const expiresMs = Date.parse(entry.expires_at);
252
+ if (Number.isFinite(expiresMs) && expiresMs <= nowMs) return false;
253
+ }
254
+ return true;
255
+ }
256
+
257
+ /**
258
+ * Normalize a caller-supplied delivered-provenance option into two Sets, or
259
+ * `null` when there is nothing to filter against.
260
+ *
261
+ * `null` (not empty Sets) is the no-filter signal so the hot loop can skip the
262
+ * per-entry `learningKeyOf()` call entirely — and so the SILENT NO-OP guarantee
263
+ * is one explicit branch rather than an emergent property of empty membership.
264
+ *
265
+ * @param {unknown} v `{ids, keys}` with Sets or arrays; anything else ⇒ `null`
266
+ * @returns {DeliveredProvenance|null}
267
+ */
268
+ function _resolveDelivered(v) {
269
+ if (!_isRecord(v)) return null;
270
+ const toSet = (x) => (x instanceof Set ? x : Array.isArray(x) ? new Set(x) : new Set());
271
+ const ids = toSet(v.ids);
272
+ const keys = toSet(v.keys);
273
+ return ids.size === 0 && keys.size === 0 ? null : { ids, keys };
274
+ }
275
+
276
+ /**
277
+ * True when this learning already reaches the agent as a natively-delivered
278
+ * `.claude/rules/*.md` file.
279
+ *
280
+ * Id first (exact record), then the logical key — a rule whose `learning-id`
281
+ * was re-minted by a backfill still delivers the same content, which is the
282
+ * `superseded-learning-id` state `check-learning-provenance.mjs` names.
283
+ *
284
+ * @param {object} entry
285
+ * @param {DeliveredProvenance} delivered
286
+ * @returns {boolean}
287
+ */
288
+ function _isDelivered(entry, delivered) {
289
+ if (typeof entry.id === 'string' && entry.id !== '' && delivered.ids.has(entry.id)) return true;
290
+ const key = learningKeyOf(entry); // total: a shape-foreign entry yields null
291
+ return key !== null && delivered.keys.has(key);
292
+ }
293
+
294
+ /** Epoch ms from a Date | number | undefined clock option. */
295
+ function _resolveNowMs(now) {
296
+ if (now instanceof Date) return now.getTime();
297
+ if (typeof now === 'number' && Number.isFinite(now)) return now;
298
+ return Date.now();
299
+ }
300
+
301
+ /** Merge caller decay overrides over the conservative #670 defaults. */
302
+ function _resolveDecay(decayOpt) {
303
+ return {
304
+ enabled: decayOpt?.enabled ?? DECAY_DEFAULTS.enabled,
305
+ halfLifeDays: decayOpt?.halfLifeDays ?? DECAY_DEFAULTS.halfLifeDays,
306
+ floorFactor: decayOpt?.floorFactor ?? DECAY_DEFAULTS.floorFactor,
307
+ };
308
+ }
309
+
310
+ /** Date.parse or 0 — used only as a tiebreaker, never as a filter. */
311
+ function _createdMs(entry) {
312
+ const v = entry?.created_at;
313
+ if (typeof v !== 'string') return 0;
314
+ const ms = Date.parse(v);
315
+ return Number.isFinite(ms) ? ms : 0;
316
+ }
317
+
318
+ /**
319
+ * Total order over scored candidates (contract point 4):
320
+ * score DESC → created_at DESC → id ASC. Array.prototype.sort is stable
321
+ * (ES2019+), so fully-equal records keep input order.
322
+ */
323
+ function _compareCandidates(a, b) {
324
+ if (b.score !== a.score) return b.score - a.score;
325
+ const timeDiff = _createdMs(b.entry) - _createdMs(a.entry);
326
+ if (timeDiff !== 0) return timeDiff;
327
+ const aId = typeof a.entry.id === 'string' ? a.entry.id : '';
328
+ const bId = typeof b.entry.id === 'string' ? b.entry.id : '';
329
+ return aId < bId ? -1 : aId > bId ? 1 : 0;
330
+ }
331
+
332
+ // ---------------------------------------------------------------------------
333
+ // Public surface
334
+ // ---------------------------------------------------------------------------
335
+
336
+ /**
337
+ * The zero-value selection. Built fresh per call so no consumer can mutate a
338
+ * shared singleton. `text` is `''` — never a "no learnings found" placeholder.
339
+ *
340
+ * @returns {Selection}
341
+ */
342
+ export function emptySelection() {
343
+ return {
344
+ entries: [],
345
+ selected: [],
346
+ lines: [],
347
+ text: '',
348
+ chars: 0,
349
+ scopeMatched: 0,
350
+ globalCount: 0,
351
+ candidates: 0,
352
+ truncated: false,
353
+ rejected: 0,
354
+ deliveredFiltered: 0,
355
+ };
356
+ }
357
+
358
+ /**
359
+ * Read the provenance pointers of every `.claude/rules/*.md` — the set of
360
+ * learnings the agent already receives natively (#1019).
361
+ *
362
+ * Reuses `extractProvenance()` rather than re-deriving the block format. Total
363
+ * by construction: an absent, unreadable or provenance-free directory yields
364
+ * empty sets, which {@link selectLearnings} reads as "no filter" (SILENT NO-OP).
365
+ *
366
+ * Read discipline mirrors `check-learning-provenance.mjs`: `readFileSync`, never
367
+ * a `grep` spawn — one NUL byte makes a text file invisible to a grep-based
368
+ * audit, and a silently-skipped rule file reads exactly like a rule with no
369
+ * provenance.
370
+ *
371
+ * Ceiling: one synchronous read per rule file, linear in the corpus (29 files /
372
+ * ~170 KB today). Revisit — cache per process or read async — if
373
+ * `.claude/rules/` passes a few hundred files.
374
+ *
375
+ * @param {string} rulesDir absolute path to the `.claude/rules` directory
376
+ * @returns {DeliveredProvenance}
377
+ */
378
+ export function readDeliveredProvenance(rulesDir) {
379
+ /** @type {Set<string>} */
380
+ const ids = new Set();
381
+ /** @type {Set<string>} */
382
+ const keys = new Set();
383
+ if (typeof rulesDir !== 'string' || rulesDir.trim() === '') return { ids, keys };
384
+
385
+ /** @type {string[]} */
386
+ let names;
387
+ try {
388
+ names = readdirSync(rulesDir);
389
+ } catch {
390
+ return { ids, keys }; // no rules directory ⇒ nothing is natively delivered
391
+ }
392
+
393
+ for (const name of names) {
394
+ if (!name.endsWith('.md')) continue;
395
+ let body;
396
+ try {
397
+ body = readFileSync(join(rulesDir, name), 'utf8');
398
+ } catch {
399
+ continue; // one unreadable rule must not cost the whole census
400
+ }
401
+ const { id, key } = extractProvenance(body);
402
+ if (id) ids.add(id);
403
+ if (key) keys.add(key);
404
+ }
405
+ return { ids, keys };
406
+ }
407
+
408
+ /**
409
+ * Truncate to at most `maxUnits` UTF-16 code units, cutting on a CODE-POINT
410
+ * boundary.
411
+ *
412
+ * A plain `str.slice(0, n)` cuts between the two halves of a surrogate pair and
413
+ * emits a LONE SURROGATE (U+D800–U+DFFF) — an unpaired code unit that is not a
414
+ * valid character, renders as U+FFFD, and is delivered straight into an agent
415
+ * prompt. Any entry whose text carries an emoji or an astral-plane character can
416
+ * land exactly on that boundary. The sibling `sanitize.mjs` already cuts on a
417
+ * code-point boundary (`truncateToBytes`); that one measures BYTES, while this
418
+ * budget is measured in UTF-16 chars (`Selection.chars` vs `maxChars`), so the
419
+ * unit differs and the function cannot simply be reused.
420
+ *
421
+ * @param {string} str
422
+ * @param {number} maxUnits
423
+ * @returns {string}
424
+ */
425
+ function _sliceCodePoints(str, maxUnits) {
426
+ if (str.length <= maxUnits) return str;
427
+ let out = '';
428
+ for (const ch of str) {
429
+ if (out.length + ch.length > maxUnits) break;
430
+ out += ch;
431
+ }
432
+ return out;
433
+ }
434
+
435
+ /**
436
+ * Render ONE learning as a single index line: sanitised, whitespace-collapsed
437
+ * and capped.
438
+ *
439
+ * Collapsing whitespace is load-bearing, not cosmetic: a multi-line `insight`
440
+ * would otherwise break the one-line-per-entry shape the char budget is
441
+ * measured against — and, since the block's boundary recovery is line-based, a
442
+ * smuggled newline would also fabricate an extra entry.
443
+ *
444
+ * Untrusted (#1015): `type`, `subject` and `insight` are AGENT-AUTHORED and this
445
+ * line is delivered verbatim into a dispatched agent's prompt, so each field
446
+ * passes through {@link sanitizeProse} — dangerous invisibles (Unicode Tag
447
+ * block, bidi overrides, zero-width) and control characters stripped, the
448
+ * envelope marker neutralised, delivery-wrapper forgery REJECTED. There is
449
+ * deliberately no phrase blocklist: the corpus is full of legitimate imperative
450
+ * prose ("parse both readings and judge both, never pick one"), so a blocklist
451
+ * would be the guard that looks green and does not bite. Framing is the
452
+ * containment, and the block wrapper supplies it.
453
+ *
454
+ * @param {object} entry
455
+ * @param {{maxLineChars?: number}} [opts]
456
+ * @returns {string} the line, or '' when the entry carries no renderable text
457
+ * @throws {Error} (`reconcile-sanitize: …`) when a field forges the delivery
458
+ * wrapper. {@link selectLearnings} catches this per entry and drops the record;
459
+ * a direct caller must decide for itself.
460
+ */
461
+ export function renderIndexLine(entry, opts = {}) {
462
+ if (!_isRecord(entry)) return '';
463
+ const maxLineChars = _capOpt(opts.maxLineChars, LEARNINGS_INDEX_MAX_LINE_CHARS);
464
+ if (maxLineChars <= 0) return '';
465
+
466
+ // Sanitise BEFORE collapsing whitespace: stripping a zero-width character can
467
+ // leave adjacent spaces, and the collapse then normalises them away.
468
+ const clean = (v, maxBytes) =>
469
+ typeof v === 'string' && v !== ''
470
+ ? sanitizeProse(v, { field: 'learnings-index', maxBytes })
471
+ .replace(/\s+/g, ' ')
472
+ .trim()
473
+ : '';
474
+
475
+ const type = clean(entry.type, TITLE_MAX_BYTES);
476
+ const subject = clean(entry.subject, TITLE_MAX_BYTES);
477
+ const insight = clean(entry.insight, INSIGHT_MAX_BYTES);
478
+
479
+ const head = [type, subject].filter(Boolean).join('/');
480
+ if (!head && !insight) return '';
481
+
482
+ let line = `- ${head}${head && insight ? ': ' : ''}${insight}`;
483
+ if (line.length > maxLineChars) line = `${_sliceCodePoints(line, maxLineChars - 1)}…`;
484
+ return line;
485
+ }
486
+
487
+ /**
488
+ * Score one learning against an agent scope.
489
+ *
490
+ * `score` = weight-normalized blend of relevance (`affinity().score`) and
491
+ * quality (`effectiveScore()` — recency-decayed confidence). `typeMatch` is
492
+ * deliberately not folded in; see the module header.
493
+ *
494
+ * @param {object} entry
495
+ * @param {AgentScope} scope
496
+ * @param {{now?: Date|number, decay?: object, affinityOpts?: object}} [opts]
497
+ * @returns {{score: number, relevance: number, quality: number, pathScore: number}}
498
+ */
499
+ export function scoreLearning(entry, scope, opts = {}) {
500
+ const zero = { score: 0, relevance: 0, quality: 0, pathScore: 0 };
501
+ if (!_isRecord(entry)) return zero;
502
+
503
+ try {
504
+ const nowMs = _resolveNowMs(opts.now);
505
+ const decay = _resolveDecay(opts.decay);
506
+ const aff = affinity(scope, entry, opts.affinityOpts);
507
+ const quality = effectiveScore(entry, nowMs, decay);
508
+ const q = Number.isFinite(quality) ? Math.min(Math.max(quality, 0), 1) : 0;
509
+
510
+ const { relevanceWeight, qualityWeight } = SELECT_WEIGHTS;
511
+ const total = relevanceWeight + qualityWeight;
512
+ const score = total > 0 ? (relevanceWeight * aff.score + qualityWeight * q) / total : 0;
513
+
514
+ return {
515
+ score: Number.isFinite(score) ? score : 0,
516
+ relevance: aff.score,
517
+ quality: q,
518
+ pathScore: aff.pathScore,
519
+ };
520
+ } catch {
521
+ return zero;
522
+ }
523
+ }
524
+
525
+ /**
526
+ * Select the learnings that go into ONE agent's compact index.
527
+ *
528
+ * Two tiers with SPLIT budgets (see module header), then a greedy fill against
529
+ * the char cap in render order (scoped first, then global). An entry whose line
530
+ * does not fit is dropped and `truncated` is set — never emitted partially.
531
+ *
532
+ * @param {object[]} entries — candidate learnings (already read from disk)
533
+ * @param {AgentScope} scope — the agent's declared file scope + task text
534
+ * @param {object} [opts]
535
+ * @param {number} [opts.maxScoped=DEFAULT_MAX_SCOPED]
536
+ * @param {number} [opts.maxGlobal=DEFAULT_MAX_GLOBAL]
537
+ * @param {number} [opts.maxChars=LEARNINGS_INDEX_MAX_CHARS]
538
+ * @param {number} [opts.maxLineChars=LEARNINGS_INDEX_MAX_LINE_CHARS]
539
+ * @param {number} [opts.minPathScore=SCOPE_MATCH_MIN_PATH_SCORE]
540
+ * @param {number} [opts.confidenceFloor=DEFAULT_CONFIDENCE_FLOOR]
541
+ * @param {Date|number} [opts.now] — injectable clock
542
+ * @param {object} [opts.decay] — #670 decay tuning, forwarded to effectiveScore
543
+ * @param {object} [opts.affinityOpts] — forwarded to affinity()
544
+ * @param {DeliveredProvenance} [opts.delivered] — learnings already delivered
545
+ * natively as `.claude/rules/*.md` (#1019); omitted/empty ⇒ no filtering, and
546
+ * the selection is byte-identical to the pre-filter one
547
+ * @returns {Selection}
548
+ */
549
+ export function selectLearnings(entries, scope, opts = {}) {
550
+ try {
551
+ if (!Array.isArray(entries) || entries.length === 0) return emptySelection();
552
+
553
+ const o = _isRecord(opts) ? opts : {};
554
+ const maxScoped = _capOpt(o.maxScoped, DEFAULT_MAX_SCOPED);
555
+ const maxGlobal = _capOpt(o.maxGlobal, DEFAULT_MAX_GLOBAL);
556
+ const maxChars = _capOpt(o.maxChars, LEARNINGS_INDEX_MAX_CHARS);
557
+ const maxLineChars = _capOpt(o.maxLineChars, LEARNINGS_INDEX_MAX_LINE_CHARS);
558
+ const minPathScore =
559
+ typeof o.minPathScore === 'number' && Number.isFinite(o.minPathScore)
560
+ ? o.minPathScore
561
+ : SCOPE_MATCH_MIN_PATH_SCORE;
562
+ const confidenceFloor =
563
+ typeof o.confidenceFloor === 'number' && Number.isFinite(o.confidenceFloor)
564
+ ? o.confidenceFloor
565
+ : DEFAULT_CONFIDENCE_FLOOR;
566
+ const nowMs = _resolveNowMs(o.now);
567
+ const delivered = _resolveDelivered(o.delivered);
568
+ const scoreOpts = { now: nowMs, decay: o.decay, affinityOpts: o.affinityOpts };
569
+
570
+ /** @type {SelectedLearning[]} */
571
+ const scoped = [];
572
+ /** @type {SelectedLearning[]} */
573
+ const global = [];
574
+ let candidates = 0;
575
+ let rejected = 0;
576
+ let deliveredFiltered = 0;
577
+
578
+ for (const entry of entries) {
579
+ if (!_isRecord(entry)) continue;
580
+ if (!_isActive(entry, nowMs, confidenceFloor)) continue;
581
+ candidates++;
582
+
583
+ // #1019 — BEFORE the split caps below, never after. This entry already
584
+ // reaches the agent in full as a `.claude/rules/*.md` file; dropping it
585
+ // here frees its slot for a learning the agent would otherwise never see,
586
+ // whereas dropping it after the Top-N cut would only shorten the index.
587
+ // `candidates` still counts it: the pool it was drawn from is unchanged.
588
+ if (delivered !== null && _isDelivered(entry, delivered)) {
589
+ deliveredFiltered++;
590
+ continue;
591
+ }
592
+
593
+ const s = scoreLearning(entry, scope, scoreOpts);
594
+ // Fail CLOSED per entry: a record whose text forges the delivery wrapper
595
+ // is dropped, not neutralised in place — with 100 candidates competing for
596
+ // 12 slots, dropping one costs nothing, while a partially-neutralised line
597
+ // would leave a forged boundary that a "the literal is gone" assertion
598
+ // reads as clean. The drop is counted, never silent (`selection.rejected`).
599
+ let line;
600
+ try {
601
+ line = renderIndexLine(entry, { maxLineChars });
602
+ } catch {
603
+ rejected++;
604
+ continue;
605
+ }
606
+ if (line.length === 0) continue;
607
+
608
+ const isScoped = s.pathScore >= minPathScore;
609
+ const cand = {
610
+ entry,
611
+ score: s.score,
612
+ relevance: s.relevance,
613
+ quality: s.quality,
614
+ pathScore: s.pathScore,
615
+ scoped: isScoped,
616
+ line,
617
+ };
618
+ (isScoped ? scoped : global).push(cand);
619
+ }
620
+
621
+ scoped.sort(_compareCandidates);
622
+ global.sort(_compareCandidates);
623
+
624
+ // Split caps: the global tier can never displace scoped signal.
625
+ const ordered = [...scoped.slice(0, maxScoped), ...global.slice(0, maxGlobal)];
626
+
627
+ /** @type {SelectedLearning[]} */
628
+ const selected = [];
629
+ const lines = [];
630
+ let chars = 0;
631
+ let truncated = scoped.length > maxScoped || global.length > maxGlobal;
632
+
633
+ for (const cand of ordered) {
634
+ const next = chars === 0 ? cand.line.length : chars + 1 + cand.line.length;
635
+ if (next > maxChars) {
636
+ truncated = true;
637
+ continue;
638
+ }
639
+ chars = next;
640
+ selected.push(cand);
641
+ lines.push(cand.line);
642
+ }
643
+
644
+ const text = lines.join('\n');
645
+ return {
646
+ entries: selected.map((c) => c.entry),
647
+ selected,
648
+ lines,
649
+ text,
650
+ chars: text.length,
651
+ scopeMatched: selected.filter((c) => c.scoped).length,
652
+ globalCount: selected.filter((c) => !c.scoped).length,
653
+ candidates,
654
+ truncated,
655
+ rejected,
656
+ deliveredFiltered,
657
+ };
658
+ } catch {
659
+ // Contract point 1 — a ranking primitive on the dispatch hot path must
660
+ // never abort a wave. Every reachable path above is already total.
661
+ return emptySelection();
662
+ }
663
+ }
664
+
665
+ /**
666
+ * File entry-point: read active learnings via `surfaceTopN` (the ONE reader —
667
+ * it owns the active-filter and the #670 decay ranking), then select.
668
+ *
669
+ * @param {string} filePath — absolute path to learnings.jsonl
670
+ * @param {AgentScope} scope
671
+ * @param {object} [opts] — everything {@link selectLearnings} accepts, plus
672
+ * `poolSize` (how many active entries to pull before ranking) and `rulesDir`
673
+ * (absolute `.claude/rules` path; read via {@link readDeliveredProvenance} into
674
+ * the `delivered` filter). `rulesDir` is EXPLICIT rather than derived from
675
+ * `filePath`: guessing a repo root from a metrics path is the hand-maintained
676
+ * fact this repo keeps getting wrong. Omit it and nothing is filtered.
677
+ * @returns {Promise<Selection>} `emptySelection()` on a missing/unreadable file
678
+ */
679
+ export async function selectLearningsFromFile(filePath, scope, opts = {}) {
680
+ try {
681
+ const o = _isRecord(opts) ? opts : {};
682
+ const poolSize = _capOpt(o.poolSize, CANDIDATE_POOL_SIZE);
683
+ const nowMs = _resolveNowMs(o.now);
684
+ const confidenceFloor =
685
+ typeof o.confidenceFloor === 'number' && Number.isFinite(o.confidenceFloor)
686
+ ? o.confidenceFloor
687
+ : DEFAULT_CONFIDENCE_FLOOR;
688
+
689
+ const delivered = _isRecord(o.delivered)
690
+ ? o.delivered
691
+ : typeof o.rulesDir === 'string' && o.rulesDir.trim() !== ''
692
+ ? readDeliveredProvenance(o.rulesDir)
693
+ : undefined;
694
+
695
+ const entries = await surfaceTopN(filePath, poolSize, {
696
+ now: nowMs,
697
+ confidenceFloor,
698
+ decay: o.decay,
699
+ });
700
+ return selectLearnings(entries, scope, { ...o, now: nowMs, confidenceFloor, delivered });
701
+ } catch {
702
+ return emptySelection();
703
+ }
704
+ }