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,434 @@
1
+ /**
2
+ * learnings/affinity.mjs — pure relatedness primitive for learnings.
3
+ *
4
+ * ONE surface, two consumers:
5
+ * - scope→learning relevance (#1014): how relevant is this learning to a
6
+ * wave-agent's declared file scope + task title?
7
+ * - learning→learning similarity (#1016): which other learnings may
8
+ * duplicate or contradict this one?
9
+ *
10
+ * Both collapse into `affinity(a, b)` because both sides are read through the
11
+ * same {@link AffinityContext} union: a bag of file paths plus a bag of text.
12
+ * A scope descriptor `{file_paths, text}` and a learning record are, for the
13
+ * purpose of "how related are these two things", the same shape.
14
+ *
15
+ * ## What this module is
16
+ *
17
+ * A relatedness function. Given two things, how related are they? That is all.
18
+ *
19
+ * ## What this module is NOT (deliberate boundary)
20
+ *
21
+ * - No fs. Reading learnings.jsonl belongs to `learnings/io.mjs` / `surfaceTopN`.
22
+ * - No clock. Recency decay and confidence floors belong to
23
+ * `learnings/surface.mjs` — note {@link effectiveScore} there takes an
24
+ * explicit `nowMs`; that time axis stays OUT of this file so `affinity` is
25
+ * referentially transparent.
26
+ * - No top-K, no thresholds, no char caps, no formatting, no Session Config.
27
+ * Those decide WHICH things are chosen and HOW they are printed — policy,
28
+ * owned by the consumers.
29
+ *
30
+ * The one-line test: if removing it would change *which* things are chosen or
31
+ * *how they are printed*, it is policy and belongs to a consumer; if removing
32
+ * it would change *how related two things are*, it belongs here.
33
+ *
34
+ * ## Import graph (acyclic by construction)
35
+ *
36
+ * Exactly one sibling edge: `affinity.mjs → ./schema.mjs`, plus stdlib —
37
+ * and here not even stdlib. `schema.mjs` is a pure leaf that imports only
38
+ * `node:crypto` and is contractually forbidden from importing siblings, so no
39
+ * cycle is reachable. Never import `../learnings.mjs` from here.
40
+ *
41
+ * Dialect handling imports {@link normalizeDialects}, NOT `normalizeLearning`:
42
+ * the latter emits deduped `console.error` WARNs for a missing `schema_version`
43
+ * and for missing legacy fields (schema.mjs), which inside an N×M affinity loop
44
+ * over the corpus would spam stderr on every agent dispatch. `normalizeDialects`
45
+ * does the one thing needed here — legacy `files` read as `file_paths`.
46
+ *
47
+ * ## Contract
48
+ *
49
+ * 1. Every returned number is finite and in [0,1]. Never NaN/Infinity.
50
+ * 2. Symmetric: affinity(a,b).score === affinity(b,a).score. (#1016 halves an
51
+ * O(n²) pass on this.)
52
+ * 3. Deterministic and pure: no clock, no fs, no randomness, no network.
53
+ * 4. Never throws. Hostile input yields the all-zero result — a ranking
54
+ * primitive on the dispatch hot path must never abort a wave. (Matches the
55
+ * read-path convention of `surfaceTopN` returning [] on an unreadable file;
56
+ * deliberately NOT `validateLearning`'s throwing ValidationError.)
57
+ * 5. Path matching is segment-aware: exact > directory-prefix > shared
58
+ * ancestor, on `/`-split segments, case-SENSITIVE (Linux CI is the
59
+ * authority). Glob metacharacters are compared literally — this module
60
+ * never expands globs; the caller pre-expands.
61
+ * 6. Fields read: `file_paths[]` (+ legacy `files`), `type`, `subject`,
62
+ * `insight`, `evidence` (may legally be an array — not coerced), `title`,
63
+ * and the context-only `text`.
64
+ * Fields deliberately NOT read: `confidence`, `created_at`/`updated_at`/
65
+ * `expires_at`/`last_reinforced`, `scope`, `host_class`, `anonymized`,
66
+ * `source_session`, `id` — ranking/policy/privacy axes owned elsewhere.
67
+ */
68
+
69
+ import { normalizeDialects } from './schema.mjs';
70
+
71
+ /**
72
+ * @typedef {{file_paths?: string[], files?: string[], text?: string, type?: string}} AffinityContext
73
+ * The union both consumers pass. A raw learning record satisfies it as-is
74
+ * (it carries `file_paths`/`files` and `type`); a scope descriptor satisfies
75
+ * it with `{file_paths, text}`.
76
+ */
77
+
78
+ /**
79
+ * @typedef {{filePaths: string[], tokens: string[], type: string|null}} NormalizedContext
80
+ */
81
+
82
+ /**
83
+ * @typedef {{score: number, pathScore: number, tokenScore: number,
84
+ * typeMatch: boolean, sharedPaths: string[], sharedTokens: string[]}} AffinityResult
85
+ */
86
+
87
+ /** Blend weights + tokenizer floor. Callers may override per call via `opts`. */
88
+ export const AFFINITY_DEFAULTS = Object.freeze({
89
+ pathWeight: 0.6,
90
+ tokenWeight: 0.4,
91
+ minTokenLength: 3,
92
+ });
93
+
94
+ /**
95
+ * Per-pair path scores. The ORDER is the contract (exact > prefix > ancestor);
96
+ * the exact magnitudes are tuning. `PATH_ANCESTOR_MAX` is a strict upper bound
97
+ * never reached — the ancestor branch only runs when the shared prefix is
98
+ * shorter than both paths, so its ratio is always < 1 and its score < 0.5,
99
+ * keeping it strictly below `PATH_PREFIX`.
100
+ */
101
+ const PATH_EXACT = 1;
102
+ const PATH_PREFIX = 0.75;
103
+ const PATH_ANCESTOR_MAX = 0.5;
104
+
105
+ /** Cap on the reported `sharedTokens` — a diagnostic list, not a payload. */
106
+ const SHARED_TOKEN_CAP = 32;
107
+
108
+ /** Max nesting depth followed when tokenizing an array-valued field. */
109
+ const MAX_TEXT_DEPTH = 3;
110
+
111
+ /** Text-bearing fields read for tokens, in a fixed order (determinism). */
112
+ const TEXT_FIELDS = Object.freeze(['text', 'title', 'subject', 'insight', 'evidence']);
113
+
114
+ // ---------------------------------------------------------------------------
115
+ // Internals
116
+ // ---------------------------------------------------------------------------
117
+
118
+ /** Clamp to a finite [0,1]. Non-finite input (NaN from an empty division,
119
+ * Infinity from a bad weight) collapses to 0 rather than escaping. */
120
+ function _clamp01(n) {
121
+ if (typeof n !== 'number' || !Number.isFinite(n)) return 0;
122
+ if (n <= 0) return 0;
123
+ if (n >= 1) return 1;
124
+ return n;
125
+ }
126
+
127
+ /** True for a plain-ish object we may read properties off (not null, not array). */
128
+ function _isRecord(v) {
129
+ return v !== null && typeof v === 'object' && !Array.isArray(v);
130
+ }
131
+
132
+ /** The all-zero result. Built fresh per call so a consumer can never mutate a
133
+ * shared singleton out from under the next caller. */
134
+ function _emptyResult() {
135
+ return {
136
+ score: 0,
137
+ pathScore: 0,
138
+ tokenScore: 0,
139
+ typeMatch: false,
140
+ sharedPaths: [],
141
+ sharedTokens: [],
142
+ };
143
+ }
144
+
145
+ /**
146
+ * Canonicalize a repo-relative path for comparison: trim, strip leading `./`
147
+ * (repeatable), strip trailing `/`. Case is preserved — Linux CI is the
148
+ * authority, so `Scripts/` and `scripts/` are different paths.
149
+ * Returns '' for anything unusable.
150
+ */
151
+ function _normalizePath(p) {
152
+ if (typeof p !== 'string') return '';
153
+ let s = p.trim();
154
+ while (s.startsWith('./')) s = s.slice(2);
155
+ while (s.length > 1 && s.endsWith('/')) s = s.slice(0, -1);
156
+ return s;
157
+ }
158
+
159
+ /**
160
+ * Score one path pair on `/`-split segments.
161
+ *
162
+ * exact (1) > directory-prefix (0.75) > shared-ancestor (< 0.5, scaled by how
163
+ * much of the longer path the shared prefix covers) > unrelated (0).
164
+ *
165
+ * Segment-aware, never string-prefix: `scripts/lib/learn` is NOT a prefix of
166
+ * `scripts/lib/learnings/io.mjs`, it is a 2-segment shared ancestor.
167
+ */
168
+ function _pairScore(aNorm, bNorm) {
169
+ if (!aNorm || !bNorm) return 0;
170
+ if (aNorm === bNorm) return PATH_EXACT;
171
+
172
+ const aSeg = aNorm.split('/').filter(Boolean);
173
+ const bSeg = bNorm.split('/').filter(Boolean);
174
+ if (aSeg.length === 0 || bSeg.length === 0) return 0;
175
+
176
+ const min = Math.min(aSeg.length, bSeg.length);
177
+ let shared = 0;
178
+ while (shared < min && aSeg[shared] === bSeg[shared]) shared++;
179
+
180
+ if (shared === 0) return 0;
181
+ // Equality was handled above, so a full-shorter match means the shorter path
182
+ // is a strict directory prefix of the longer one.
183
+ if (shared === min) return PATH_PREFIX;
184
+ return PATH_ANCESTOR_MAX * (shared / Math.max(aSeg.length, bSeg.length));
185
+ }
186
+
187
+ /** Best score of `p` against any path in `others`. */
188
+ function _bestAgainst(p, others) {
189
+ let best = 0;
190
+ for (const q of others) {
191
+ const s = _pairScore(p, q);
192
+ if (s > best) best = s;
193
+ if (best === PATH_EXACT) break;
194
+ }
195
+ return best;
196
+ }
197
+
198
+ /**
199
+ * Aggregate two path lists into one [0,1] score.
200
+ *
201
+ * Mean-of-best-match in BOTH directions, averaged — symmetric by construction.
202
+ * A one-directional "mean over a of best in b" is the naive form and is NOT
203
+ * symmetric when the lists differ in size, which would break contract point 2.
204
+ *
205
+ * O(n·m): scopes are a handful of paths and the corpus is ~10² entries, so the
206
+ * product is trivial. Revisit if a caller ever passes a scope above ~200 paths.
207
+ */
208
+ function _pathScoreFromLists(aPaths, bPaths) {
209
+ if (aPaths.length === 0 || bPaths.length === 0) return 0;
210
+
211
+ let sumA = 0;
212
+ for (const p of aPaths) sumA += _bestAgainst(p, bPaths);
213
+ let sumB = 0;
214
+ for (const q of bPaths) sumB += _bestAgainst(q, aPaths);
215
+
216
+ return _clamp01((sumA / aPaths.length + sumB / bPaths.length) / 2);
217
+ }
218
+
219
+ /** Jaccard over token sets: |A∩B| / |A∪B|. Symmetric by construction. */
220
+ function _tokenScoreFromLists(aTokens, bTokens) {
221
+ const setA = new Set(aTokens);
222
+ const setB = new Set(bTokens);
223
+ if (setA.size === 0 || setB.size === 0) return 0;
224
+
225
+ let inter = 0;
226
+ for (const t of setA) if (setB.has(t)) inter++;
227
+ const union = setA.size + setB.size - inter;
228
+ if (union <= 0) return 0;
229
+ return _clamp01(inter / union);
230
+ }
231
+
232
+ /** Sorted, deduped intersection of two string lists. */
233
+ function _sharedSorted(a, b) {
234
+ const setB = new Set(b);
235
+ const out = new Set();
236
+ for (const v of a) if (setB.has(v)) out.add(v);
237
+ return [...out].sort();
238
+ }
239
+
240
+ /** Resolve caller opts over AFFINITY_DEFAULTS, rejecting non-finite/negative
241
+ * weights and non-integer token floors rather than propagating them. */
242
+ function _resolveOpts(opts) {
243
+ const o = _isRecord(opts) ? opts : {};
244
+ const weight = (v, fallback) =>
245
+ typeof v === 'number' && Number.isFinite(v) && v >= 0 ? v : fallback;
246
+ return {
247
+ pathWeight: weight(o.pathWeight, AFFINITY_DEFAULTS.pathWeight),
248
+ tokenWeight: weight(o.tokenWeight, AFFINITY_DEFAULTS.tokenWeight),
249
+ minTokenLength:
250
+ Number.isInteger(o.minTokenLength) && o.minTokenLength >= 1
251
+ ? o.minTokenLength
252
+ : AFFINITY_DEFAULTS.minTokenLength,
253
+ };
254
+ }
255
+
256
+ // ---------------------------------------------------------------------------
257
+ // Public surface
258
+ // ---------------------------------------------------------------------------
259
+
260
+ /**
261
+ * Split text into comparable lowercase tokens.
262
+ *
263
+ * Accepts a string, or an array of strings (legacy `evidence` may legally be an
264
+ * array and is deliberately not coerced upstream — see schema.mjs). Nested
265
+ * arrays are followed to {@link MAX_TEXT_DEPTH}. Anything else yields [].
266
+ *
267
+ * Tokens are lowercased, split on any non-alphanumeric run, filtered to
268
+ * `minTokenLength` or longer, and deduped in first-appearance order (stable,
269
+ * so equal inputs always produce an equal array).
270
+ *
271
+ * @param {unknown} text
272
+ * @param {{minTokenLength?: number}} [opts]
273
+ * @returns {string[]}
274
+ */
275
+ export function tokenize(text, opts) {
276
+ const { minTokenLength } = _resolveOpts(opts);
277
+ const out = [];
278
+ const seen = new Set();
279
+
280
+ const walk = (value, depth) => {
281
+ if (typeof value === 'string') {
282
+ for (const raw of value.toLowerCase().split(/[^a-z0-9]+/)) {
283
+ if (raw.length < minTokenLength || seen.has(raw)) continue;
284
+ seen.add(raw);
285
+ out.push(raw);
286
+ }
287
+ return;
288
+ }
289
+ if (Array.isArray(value) && depth < MAX_TEXT_DEPTH) {
290
+ for (const el of value) walk(el, depth + 1);
291
+ }
292
+ };
293
+
294
+ walk(text, 0);
295
+ return out;
296
+ }
297
+
298
+ /**
299
+ * Project any {@link AffinityContext}-ish input onto the normalized shape the
300
+ * scorers compare. Total: hostile input yields the empty context, never a throw.
301
+ *
302
+ * Legacy `files` is read as `file_paths` via {@link normalizeDialects}
303
+ * (`reserializeTimestamps: false` — this module never reads timestamps, so
304
+ * re-parsing them would be pure waste).
305
+ *
306
+ * @param {unknown} input
307
+ * @param {{minTokenLength?: number}} [opts] — tokenizer tuning; optional.
308
+ * @returns {NormalizedContext}
309
+ */
310
+ export function toAffinityContext(input, opts) {
311
+ if (!_isRecord(input)) return { filePaths: [], tokens: [], type: null };
312
+
313
+ try {
314
+ let record = input;
315
+ try {
316
+ record = normalizeDialects(input, { reserializeTimestamps: false });
317
+ } catch {
318
+ // A dialect quirk must never abort a ranking pass — fall back to the raw
319
+ // record and read `files` directly below.
320
+ record = input;
321
+ }
322
+ if (!_isRecord(record)) record = input;
323
+
324
+ const rawPaths = Array.isArray(record.file_paths)
325
+ ? record.file_paths
326
+ : Array.isArray(record.files)
327
+ ? record.files
328
+ : [];
329
+
330
+ const filePaths = [];
331
+ const seenPaths = new Set();
332
+ for (const p of rawPaths) {
333
+ const norm = _normalizePath(p);
334
+ if (norm.length === 0 || seenPaths.has(norm)) continue;
335
+ seenPaths.add(norm);
336
+ filePaths.push(norm);
337
+ }
338
+
339
+ const { minTokenLength } = _resolveOpts(opts);
340
+ const tokens = [];
341
+ const seenTokens = new Set();
342
+ for (const field of TEXT_FIELDS) {
343
+ for (const t of tokenize(record[field], { minTokenLength })) {
344
+ if (seenTokens.has(t)) continue;
345
+ seenTokens.add(t);
346
+ tokens.push(t);
347
+ }
348
+ }
349
+
350
+ const type =
351
+ typeof record.type === 'string' && record.type.trim().length > 0
352
+ ? record.type.trim()
353
+ : null;
354
+
355
+ return { filePaths, tokens, type };
356
+ } catch {
357
+ // Exotic shape (throwing getter, hostile Proxy). Every scorer downstream
358
+ // stays total because this is the ONLY place raw input is read.
359
+ return { filePaths: [], tokens: [], type: null };
360
+ }
361
+ }
362
+
363
+ /**
364
+ * File-path relatedness of two contexts, in [0,1].
365
+ * Segment-aware and symmetric — see {@link _pairScore} and
366
+ * {@link _pathScoreFromLists}.
367
+ *
368
+ * @param {unknown} a
369
+ * @param {unknown} b
370
+ * @returns {number}
371
+ */
372
+ export function pathAffinity(a, b) {
373
+ return _pathScoreFromLists(toAffinityContext(a).filePaths, toAffinityContext(b).filePaths);
374
+ }
375
+
376
+ /**
377
+ * Text relatedness of two contexts, in [0,1] (Jaccard over token sets).
378
+ *
379
+ * @param {unknown} a
380
+ * @param {unknown} b
381
+ * @returns {number}
382
+ */
383
+ export function tokenAffinity(a, b) {
384
+ return _tokenScoreFromLists(toAffinityContext(a).tokens, toAffinityContext(b).tokens);
385
+ }
386
+
387
+ /**
388
+ * The primitive both consumers call.
389
+ *
390
+ * `score` is the weight-normalized blend of `pathScore` and `tokenScore`:
391
+ * `(pw·path + tw·token) / (pw + tw)`, so it stays in [0,1] for ANY non-negative
392
+ * weight pair, not only ones that sum to 1.
393
+ *
394
+ * `typeMatch` is REPORTED, never folded into `score`. Whether a same-type pair
395
+ * deserves a boost is a ranking decision, and ranking is the consumer's.
396
+ *
397
+ * `sharedPaths` lists exactly-overlapping normalized paths only — a
398
+ * directory-prefix pair raises `pathScore` without appearing here.
399
+ *
400
+ * @param {unknown} a
401
+ * @param {unknown} b
402
+ * @param {{pathWeight?: number, tokenWeight?: number, minTokenLength?: number}} [opts]
403
+ * @returns {AffinityResult}
404
+ */
405
+ export function affinity(a, b, opts) {
406
+ try {
407
+ const { pathWeight, tokenWeight, minTokenLength } = _resolveOpts(opts);
408
+ const ctxA = toAffinityContext(a, { minTokenLength });
409
+ const ctxB = toAffinityContext(b, { minTokenLength });
410
+
411
+ const pathScore = _pathScoreFromLists(ctxA.filePaths, ctxB.filePaths);
412
+ const tokenScore = _tokenScoreFromLists(ctxA.tokens, ctxB.tokens);
413
+
414
+ const totalWeight = pathWeight + tokenWeight;
415
+ const score =
416
+ totalWeight > 0
417
+ ? _clamp01((pathWeight * pathScore + tokenWeight * tokenScore) / totalWeight)
418
+ : 0;
419
+
420
+ return {
421
+ score,
422
+ pathScore,
423
+ tokenScore,
424
+ typeMatch: ctxA.type !== null && ctxB.type !== null && ctxA.type === ctxB.type,
425
+ sharedPaths: _sharedSorted(ctxA.filePaths, ctxB.filePaths),
426
+ sharedTokens: _sharedSorted(ctxA.tokens, ctxB.tokens).slice(0, SHARED_TOKEN_CAP),
427
+ };
428
+ } catch {
429
+ // Last-resort net for an exotic input shape (getter that throws, Proxy).
430
+ // Contract point 4: this runs on the dispatch hot path and must never
431
+ // abort a wave. Every reachable path above is already total.
432
+ return _emptyResult();
433
+ }
434
+ }