session-orchestrator 3.17.0 → 3.20.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (221) hide show
  1. package/.claude-plugin/marketplace.json +1 -1
  2. package/.claude-plugin/plugin.json +1 -1
  3. package/.codex-plugin/plugin.json +1 -1
  4. package/.cursor/rules/030-wave-execution.mdc +17 -1
  5. package/CHANGELOG.md +185 -412
  6. package/README.md +12 -9
  7. package/SECURITY.md +190 -27
  8. package/agents/AGENTS.md +20 -3
  9. package/agents/code-implementer.md +6 -6
  10. package/agents/db-specialist.md +1 -1
  11. package/agents/qa-strategist.md +31 -6
  12. package/agents/schemas/qa-strategist.schema.json +27 -0
  13. package/agents/schemas/test-writer.schema.json +60 -2
  14. package/agents/security-reviewer.md +1 -1
  15. package/agents/session-reviewer.md +1 -1
  16. package/agents/test-writer.md +29 -10
  17. package/agents/ui-developer.md +1 -1
  18. package/commands/contract-version-bump.md +28 -0
  19. package/commands/portfolio.md +1 -1
  20. package/commands/session.md +6 -2
  21. package/docs/USER-GUIDE.md +9 -4
  22. package/docs/ci-setup.md +121 -7
  23. package/docs/codex-setup.md +1 -1
  24. package/docs/components.md +6 -6
  25. package/docs/cursor-setup.md +22 -9
  26. package/docs/events-schema.md +5 -1
  27. package/docs/instruction-delivery.md +794 -0
  28. package/docs/rule-authoring.md +58 -9
  29. package/docs/session-config-reference.md +245 -50
  30. package/docs/session-config-template.md +39 -26
  31. package/hooks/_lib/guard-source-loader.mjs +680 -0
  32. package/hooks/_lib/lock-bootstrap.mjs +21 -0
  33. package/hooks/_lib/vcs-create-matcher.mjs +119 -0
  34. package/hooks/config-protection.mjs +0 -0
  35. package/hooks/enforce-commands.mjs +226 -19
  36. package/hooks/enforce-scope.mjs +133 -9
  37. package/hooks/hooks-codex.json +1 -1
  38. package/hooks/hooks-cursor.json +11 -2
  39. package/hooks/hooks-pi.json +10 -0
  40. package/hooks/hooks.json +21 -1
  41. package/hooks/on-session-end.mjs +178 -18
  42. package/hooks/on-session-start.mjs +30 -4
  43. package/hooks/post-bash-write-verify.mjs +977 -0
  44. package/hooks/post-subagent-discovery-validator.mjs +256 -41
  45. package/hooks/pre-bash-destructive-guard.mjs +616 -164
  46. package/hooks/pre-bash-issue-budget.mjs +167 -0
  47. package/hooks/pre-bash-sessions-ledger-guard.mjs +1054 -0
  48. package/hooks/pre-bash-templates-first.mjs +96 -63
  49. package/hooks/subagent-telemetry.mjs +527 -37
  50. package/package.json +6 -3
  51. package/pi/prompts/contract-version-bump.md +12 -0
  52. package/rules/README.md +32 -0
  53. package/scripts/archive-closed-prds.mjs +12 -22
  54. package/scripts/autopilot-multi.mjs +103 -20
  55. package/scripts/backfill-abandoned-sessions.mjs +160 -4
  56. package/scripts/backfill-learnings-from-vault.mjs +967 -0
  57. package/scripts/check-doc-consistency.sh +17 -1
  58. package/scripts/emit-session.mjs +3 -40
  59. package/scripts/eval-session.mjs +50 -9
  60. package/scripts/fleet-instruction-scan.mjs +141 -0
  61. package/scripts/lib/autopilot/mr-draft.mjs +31 -1
  62. package/scripts/lib/autopilot/worktree-pipeline.mjs +113 -5
  63. package/scripts/lib/backlog-scan.mjs +39 -6
  64. package/scripts/lib/blocked-commands-policy.mjs +340 -0
  65. package/scripts/lib/ci-status-banner.mjs +75 -12
  66. package/scripts/lib/claude-md-budget-lint.mjs +283 -34
  67. package/scripts/lib/command-blocker.mjs +1273 -58
  68. package/scripts/lib/config/config-protection.mjs +2 -1
  69. package/scripts/lib/config/drift-check.mjs +9 -1
  70. package/scripts/lib/config/gitlab-portfolio.mjs +1 -1
  71. package/scripts/lib/config/issue-budget.mjs +123 -0
  72. package/scripts/lib/config/reconcile.mjs +21 -0
  73. package/scripts/lib/config/section-extractor.mjs +121 -1
  74. package/scripts/lib/config-schema.mjs +23 -3
  75. package/scripts/lib/config.mjs +17 -0
  76. package/scripts/lib/convergence-monitor.mjs +49 -3
  77. package/scripts/lib/description-surface.mjs +535 -0
  78. package/scripts/lib/dispatcher/enumerate.mjs +26 -40
  79. package/scripts/lib/ecosystem-wizard/config-writer.mjs +26 -24
  80. package/scripts/lib/ecosystem-wizard/wizard-prompt.mjs +1 -1
  81. package/scripts/lib/eval/engine.mjs +47 -5
  82. package/scripts/lib/events.mjs +59 -7
  83. package/scripts/lib/gates/gate-full.mjs +15 -3
  84. package/scripts/lib/gates/gate-helpers.mjs +132 -6
  85. package/scripts/lib/gitlab-ops/stale-mr-sweep.mjs +28 -8
  86. package/scripts/lib/gitlab-portfolio/aggregator.mjs +8 -2
  87. package/scripts/lib/gitlab-portfolio/cli.mjs +1 -1
  88. package/scripts/lib/handover-gate.mjs +7 -3
  89. package/scripts/lib/hardening.mjs +9 -9
  90. package/scripts/lib/harness-audit/categories/category4.mjs +9 -3
  91. package/scripts/lib/instruction-budget-guard.mjs +402 -51
  92. package/scripts/lib/io.mjs +345 -10
  93. package/scripts/lib/issue-budget.mjs +269 -0
  94. package/scripts/lib/issue-close-strip-labels.mjs +39 -9
  95. package/scripts/lib/label-scope.mjs +47 -0
  96. package/scripts/lib/learnings/affinity.mjs +434 -0
  97. package/scripts/lib/learnings/candidates.mjs +736 -0
  98. package/scripts/lib/learnings/expiry-sweep.mjs +408 -53
  99. package/scripts/lib/learnings/judgment.mjs +782 -0
  100. package/scripts/lib/learnings/kebab.mjs +128 -0
  101. package/scripts/lib/learnings/schema.mjs +43 -3
  102. package/scripts/lib/learnings/select.mjs +550 -0
  103. package/scripts/lib/lock-reaper.mjs +1 -2
  104. package/scripts/lib/memory-proposals/schema.mjs +36 -1
  105. package/scripts/lib/peer-discovery.mjs +645 -0
  106. package/scripts/lib/pi-hook-bridge.mjs +146 -17
  107. package/scripts/lib/product-repo-detect.mjs +9 -8
  108. package/scripts/lib/project-hygiene.mjs +432 -0
  109. package/scripts/lib/quality-gate.mjs +167 -0
  110. package/scripts/lib/recommendations-v0.mjs +1 -1
  111. package/scripts/lib/reconcile/eligibility.mjs +1 -1
  112. package/scripts/lib/reconcile/emitter.mjs +128 -24
  113. package/scripts/lib/reconcile/engine.mjs +156 -54
  114. package/scripts/lib/reconcile/idempotency.mjs +114 -14
  115. package/scripts/lib/reconcile/renderer.mjs +141 -25
  116. package/scripts/lib/reconcile/sanitize.mjs +518 -0
  117. package/scripts/lib/reconcile/writer.mjs +95 -1
  118. package/scripts/lib/reconcile-nudge-banner.mjs +65 -9
  119. package/scripts/lib/resource-probe/evaluate.mjs +70 -4
  120. package/scripts/lib/resource-probe.mjs +19 -0
  121. package/scripts/lib/rule-loader.mjs +6 -0
  122. package/scripts/lib/scope-baseline.mjs +564 -0
  123. package/scripts/lib/scope-gate.mjs +568 -145
  124. package/scripts/lib/session-close-backfill.mjs +63 -8
  125. package/scripts/lib/session-end/phase-skip.mjs +1 -0
  126. package/scripts/lib/session-id.mjs +221 -41
  127. package/scripts/lib/session-lock.mjs +304 -6
  128. package/scripts/lib/session-record-repair.mjs +551 -0
  129. package/scripts/lib/session-schema/constants.mjs +22 -3
  130. package/scripts/lib/session-schema/serializer.mjs +54 -0
  131. package/scripts/lib/session-schema/validator.mjs +16 -0
  132. package/scripts/lib/session-schema.mjs +1 -0
  133. package/scripts/lib/session-token-rollup.mjs +68 -6
  134. package/scripts/lib/sessions-integrity-banner.mjs +294 -0
  135. package/scripts/lib/sessions-staleness-banner.mjs +121 -12
  136. package/scripts/lib/skill-evolution/idempotency.mjs +135 -16
  137. package/scripts/lib/skill-evolution/mr-opener.mjs +9 -1
  138. package/scripts/lib/soul-resolve.mjs +12 -0
  139. package/scripts/lib/spiral-carryover.mjs +142 -30
  140. package/scripts/lib/state-md/mission-status.mjs +53 -3
  141. package/scripts/lib/subagents-schema.mjs +43 -9
  142. package/scripts/lib/test-runner/issue-reconcile.mjs +53 -13
  143. package/scripts/lib/tests-src-ratio.mjs +484 -0
  144. package/scripts/lib/tmux-layout/telemetry.mjs +43 -10
  145. package/scripts/lib/validate/check-agents.mjs +56 -0
  146. package/scripts/lib/validate/check-banner-parity.mjs +376 -0
  147. package/scripts/lib/validate/check-guard-requires-parity.mjs +1148 -0
  148. package/scripts/lib/validate/check-hooks-symmetry.mjs +244 -10
  149. package/scripts/lib/validate/check-learning-provenance.mjs +511 -0
  150. package/scripts/lib/validate/check-owner-leakage.mjs +3 -3
  151. package/scripts/lib/validate/check-rules.mjs +244 -36
  152. package/scripts/lib/validate/check-test-value-bans.mjs +782 -0
  153. package/scripts/lib/validate/check-unicode-safety.mjs +1 -0
  154. package/scripts/lib/validate/check-unwired-features.mjs +549 -0
  155. package/scripts/lib/validate-vendored-rules.mjs +10 -2
  156. package/scripts/lib/vault-archive.mjs +17 -2
  157. package/scripts/lib/vault-backfill/glab.mjs +8 -0
  158. package/scripts/lib/vault-mirror/process.mjs +30 -0
  159. package/scripts/lib/vault-mirror/render-sessions.mjs +293 -36
  160. package/scripts/lib/vcs-repo-spec.mjs +362 -0
  161. package/scripts/lib/wave-resource-gate.mjs +115 -11
  162. package/scripts/lib/worktree/listing.mjs +44 -7
  163. package/scripts/mcp-server.sh +17 -3
  164. package/scripts/measure-context-overhead.sh +151 -0
  165. package/scripts/memory-propose.mjs +72 -9
  166. package/scripts/print-applicable-rules.mjs +218 -16
  167. package/scripts/print-learnings-index.mjs +474 -0
  168. package/scripts/release.mjs +534 -0
  169. package/scripts/repair-invalid-sessions.mjs +209 -0
  170. package/scripts/run-quality-gate.mjs +123 -5
  171. package/scripts/sweep-expired-learnings.mjs +192 -32
  172. package/scripts/validate-plugin.mjs +21 -0
  173. package/scripts/validate-wave-scope.mjs +182 -17
  174. package/scripts/vault-integration-watcher.mjs +32 -10
  175. package/skills/_shared/config-reading.md +2 -2
  176. package/skills/bootstrap/fast-template.md +1 -1
  177. package/skills/brainstorm/soul.md +47 -1
  178. package/skills/claude-md-drift-check/checker.mjs +145 -28
  179. package/skills/contract-version-bump/SKILL.md +219 -0
  180. package/skills/discovery/SKILL.md +4 -4
  181. package/skills/discovery/issue-templates.md +11 -11
  182. package/skills/discovery/probes-audit.md +1 -1
  183. package/skills/discovery/probes-feature.md +1 -1
  184. package/skills/discovery/probes-session.md +26 -5
  185. package/skills/ecosystem-health/SKILL.md +1 -1
  186. package/skills/ecosystem-health/wizard.md +4 -4
  187. package/skills/evolve/SKILL.md +117 -18
  188. package/skills/gitlab-ops/SKILL.md +25 -12
  189. package/skills/gitlab-portfolio/SKILL.md +2 -2
  190. package/skills/grill/soul.md +44 -1
  191. package/skills/hook-development/SKILL.md +1 -1
  192. package/skills/mode-selector/SKILL.md +1 -1
  193. package/skills/npm-publish/SKILL.md +17 -1
  194. package/skills/plan/SKILL.md +5 -5
  195. package/skills/plan/mode-feature.md +4 -4
  196. package/skills/plan/mode-new.md +10 -10
  197. package/skills/plan/mode-retro.md +1 -1
  198. package/skills/plan/soul.md +46 -3
  199. package/skills/quality-gates/SKILL.md +1 -1
  200. package/skills/reconcile/SKILL.md +21 -4
  201. package/skills/session-end/SKILL.md +34 -36
  202. package/skills/session-end/discovery-scan.md +4 -2
  203. package/skills/session-end/drift-operations.md +4 -4
  204. package/skills/session-end/metrics-collection.md +13 -0
  205. package/skills/session-end/phase-3-2-docs-verification.md +1 -1
  206. package/skills/session-end/phase-3-6-tail.md +32 -2
  207. package/skills/session-end/plan-verification.md +6 -7
  208. package/skills/session-end/session-metrics-write.md +2 -0
  209. package/skills/session-end/vault-operations.md +1 -1
  210. package/skills/session-end/verification-checklist.md +1 -1
  211. package/skills/session-plan/SKILL.md +6 -2
  212. package/skills/session-plan/wave-template.md +2 -0
  213. package/skills/session-start/SKILL.md +75 -7
  214. package/skills/session-start/phase-4-5-resource-health.md +15 -2
  215. package/skills/session-start/soul.md +41 -1
  216. package/skills/test-runner/SKILL.md +2 -2
  217. package/skills/vault-sync/validator.mjs +108 -7
  218. package/skills/wave-executor/SKILL.md +6 -7
  219. package/skills/wave-executor/circuit-breaker.md +2 -0
  220. package/skills/wave-executor/wave-loop.md +198 -80
  221. package/templates/_shared/loop.md +4 -4
@@ -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
+ }
@@ -70,7 +70,6 @@ import { readLock, isLockLive, isPidAliveOnHost, LOCK_PATH, DEFAULT_TTL_HOURS }
70
70
  import { emitEvent } from './events.mjs';
71
71
 
72
72
  const REAPED_ARCHIVE_SUBDIR = '.orchestrator/tmp/reaped-locks';
73
- const EVENTS_RELPATH = '.orchestrator/metrics/events.jsonl';
74
73
  const REAPED_EVENT = 'orchestrator.session.lock.reaped';
75
74
  const CURRENT_SESSION_RELPATH = '.orchestrator/current-session.json';
76
75
 
@@ -550,7 +549,7 @@ async function evaluateRepo(repoRoot, { nowMs, dryRun, currentSessionId, reapMod
550
549
  reap_mode: reapMode,
551
550
  current_session: currentSession,
552
551
  },
553
- { filePath: path.join(repoRoot, EVENTS_RELPATH) },
552
+ { repoRoot },
554
553
  );
555
554
  } catch {
556
555
  // Observability is best-effort — the archive-move already succeeded.
@@ -22,7 +22,11 @@
22
22
  * schema_version 1
23
23
  *
24
24
  * Optional fields:
25
- * proposed_by_agent string | undefined — identifier of the submitting agent
25
+ * proposed_by_agent string | undefined — identifier of the submitting agent
26
+ * file_paths string[] | undefined — repo-relative path(s) this
27
+ * proposal applies to (issue #900 C). Omitted entirely when empty/absent
28
+ * — never set to `[]`. Required, downstream, for a promoted learning to
29
+ * ever become /reconcile-eligible (see `reconcile/eligibility.mjs`).
26
30
  */
27
31
 
28
32
  import { randomUUID } from 'node:crypto';
@@ -86,6 +90,9 @@ const EVIDENCE_MAX = 5000;
86
90
  * @param {number} opts.confidence — [0, 1]
87
91
  * @param {string} opts.waveId — e.g. 'W2'
88
92
  * @param {string} [opts.proposedByAgent] — optional agent identifier
93
+ * @param {string[]} [opts.filePaths] — optional repo-relative path(s) this
94
+ * proposal applies to (issue #900 C). Set on the record ONLY when a
95
+ * non-empty array is supplied — mirrors the `proposedByAgent` pattern.
89
96
  * @returns {object} complete proposal record
90
97
  */
91
98
  export function createProposalRecord({
@@ -96,6 +103,7 @@ export function createProposalRecord({
96
103
  confidence,
97
104
  waveId,
98
105
  proposedByAgent,
106
+ filePaths,
99
107
  }) {
100
108
  const record = {
101
109
  id: randomUUID(),
@@ -113,6 +121,10 @@ export function createProposalRecord({
113
121
  record.proposed_by_agent = proposedByAgent;
114
122
  }
115
123
 
124
+ if (Array.isArray(filePaths) && filePaths.length > 0) {
125
+ record.file_paths = filePaths;
126
+ }
127
+
116
128
  return record;
117
129
  }
118
130
 
@@ -238,6 +250,29 @@ export function validateProposalRecord(record) {
238
250
  errors.push('proposed_by_agent must be a string when present');
239
251
  }
240
252
 
253
+ // file_paths (optional, but if present must be a non-empty array of
254
+ // non-empty, newline-free, glob-metacharacter-free strings) — issue #900 C,
255
+ // Q3-MED fix pass: a glob metacharacter (* ? [ ] { }) surviving this schema
256
+ // gate would later reach emitter.mjs::globsFromFilePaths verbatim for a
257
+ // top-level (dirname==='.') entry, effectively producing an always-on rule
258
+ // glob (e.g. `file_paths: ['**']`). `createProposalRecord` never sets an
259
+ // empty array (it omits the key), so a present-but-empty `[]` is itself a
260
+ // signal of a malformed/tampered record — reject it explicitly.
261
+ if ('file_paths' in record) {
262
+ const fp = record.file_paths;
263
+ const isValidArray =
264
+ Array.isArray(fp) &&
265
+ fp.length > 0 &&
266
+ fp.every(
267
+ (p) => typeof p === 'string' && p.length > 0 && !/[\r\n]/.test(p) && !/[*?[\]{}]/.test(p),
268
+ );
269
+ if (!isValidArray) {
270
+ errors.push(
271
+ 'file_paths must be a non-empty array of non-empty, newline-free, glob-metacharacter-free strings when present',
272
+ );
273
+ }
274
+ }
275
+
241
276
  if (errors.length > 0) {
242
277
  return { ok: false, errors };
243
278
  }