session-orchestrator 5.5.0 → 5.7.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 (51) 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-plugin/plugin.json +1 -1
  5. package/CHANGELOG.md +46 -0
  6. package/README.md +27 -13
  7. package/docs/USER-GUIDE.md +1 -1
  8. package/docs/rule-authoring.md +10 -0
  9. package/docs/scope-collision-guard.md +5 -3
  10. package/docs/session-config-reference.md +1 -1
  11. package/docs/session-config-template.md +1 -1
  12. package/hooks/hooks-codex.json +1 -1
  13. package/hooks/hooks.json +1 -1
  14. package/hooks/on-stop.mjs +8 -2
  15. package/hooks/post-bash-write-verify.mjs +121 -7
  16. package/hooks/post-tool-batch-wave-signal.mjs +11 -4
  17. package/hooks/pre-bash-memory-propose-audit.mjs +13 -8
  18. package/hooks/pre-task-scope-disjoint.mjs +20 -35
  19. package/hooks/wave-scope-commit-guard.mjs +117 -20
  20. package/package.json +8 -3
  21. package/scripts/backfill-abandoned-sessions.mjs +5 -7
  22. package/scripts/backfill-learnings-from-vault.mjs +17 -6
  23. package/scripts/lib/dispatcher/rank.mjs +5 -1
  24. package/scripts/lib/events.mjs +62 -49
  25. package/scripts/lib/io.mjs +9 -4
  26. package/scripts/lib/learnings/candidates.mjs +7 -16
  27. package/scripts/lib/memory-proposals/store.mjs +9 -2
  28. package/scripts/lib/reconcile/rule-expiry-sweep.mjs +52 -0
  29. package/scripts/lib/scope-gate.mjs +8 -1
  30. package/scripts/lib/session-close-backfill.mjs +104 -13
  31. package/scripts/lib/validate/check-test-value-bans.mjs +9 -0
  32. package/scripts/lib/vault-mirror/process.mjs +388 -56
  33. package/scripts/lib/vault-mirror/prune.mjs +413 -0
  34. package/scripts/lib/vault-mirror/render-sessions.mjs +13 -5
  35. package/scripts/lib/vault-mirror/retention.mjs +388 -0
  36. package/scripts/lib/vault-status/board-writer.mjs +6 -6
  37. package/scripts/lib/wave-transcript-tail.mjs +11 -2
  38. package/scripts/print-applicable-rules.mjs +1 -25
  39. package/scripts/release.mjs +4 -0
  40. package/scripts/validate-wave-scope.mjs +39 -4
  41. package/scripts/vault-mirror-prune.mjs +145 -0
  42. package/scripts/vault-mirror.mjs +77 -12
  43. package/skills/_shared/parallel-aware-auq.md +1 -1
  44. package/skills/claude-md-drift-check/checker.mjs +6 -16
  45. package/skills/vault-mirror/SKILL.md +27 -2
  46. package/skills/wave-executor/references/wave-loop-scope-manifest.md +3 -1
  47. package/hooks/agent-teams-h3-test.sh +0 -362
  48. package/scripts/spikes/h3-agent-teams/preflight.sh +0 -53
  49. package/scripts/spikes/h3-agent-teams/run-h3.sh +0 -112
  50. package/scripts/spikes/h3-agent-teams/setup.sh +0 -137
  51. package/scripts/spikes/h3-agent-teams/toggle.sh +0 -38
@@ -0,0 +1,413 @@
1
+ /**
2
+ * prune.mjs — plan and apply the retention rules to the EXISTING mirror zones
3
+ * (`40-learnings/`, `50-sessions/`) of a vault. Issue #1513.
4
+ *
5
+ * The mirror (`process.mjs`) stops producing ballast from now on; this module
6
+ * cleans up what it produced before. Same rules, same module for them:
7
+ * `./retention.mjs`.
8
+ *
9
+ * ## What it decides, per mirror-owned note (one reason per note)
10
+ *
11
+ * | reason | zone | action | rule |
12
+ * |------------------------|-----------|---------|------|
13
+ * | `duplicate` | both | archive | same normalised insight (learnings), or the same note id already at the relocation target; the canonical copy is kept |
14
+ * | `superseded` | learnings | archive | same slug modulo hyphens (pre-#725 / post-#725 slug of one subject) with a different insight — the newest `updated` is kept; or the mirror already marked it `archived-reason: superseded` |
15
+ * | `expired` | learnings | archive | frontmatter `expires:` before today, or already marked `archived-reason: expired` |
16
+ * | `metrics-only-session` | sessions | archive | `## Notes` free text below `min-narrative-chars`; its row is written to the month rollup FIRST |
17
+ * | `namespace-alias` | both | move | the namespace folder is an alias of another (explicit `--alias`, or the same name modulo hyphens where only the other one has hyphens) |
18
+ * | `flat-relocate` | both | move | a flat legacy note (no namespace folder) whose `source-repo` names its namespace |
19
+ *
20
+ * Expiry is decided FIRST: an expired or already archived note is never the
21
+ * kept copy of a group (#1513 review H1). Among the live copies the canonical
22
+ * one is, in this order: in its own namespace folder > in any namespace
23
+ * folder > `status: verified` > later `expires` > carries `source-record` >
24
+ * more hyphens in the file name (the post-#725 slug) > newer `updated` > path
25
+ * order. Placeholder and very short insights never form a group (M4).
26
+ *
27
+ * ## What it never does
28
+ *
29
+ * - Touch a note without `_generator: session-orchestrator-vault-mirror@1`
30
+ * (hand-written notes), a rollup, or anything outside the two zones.
31
+ * - Delete. `archive` moves the note to `90-archive/mirror/<zone>/<same path>`
32
+ * with `status: archived` + `archived-reason`; `move` renames inside the zone.
33
+ * Git sees renames; history stays.
34
+ * - Write anything in plan mode. {@link applyPrune} is the only write path.
35
+ *
36
+ * Idempotent: after an apply, the archived notes are out of the zones, the
37
+ * moved notes sit where the plan computes them, and the rollups already hold
38
+ * their rows — a second plan is empty.
39
+ *
40
+ * @module scripts/lib/vault-mirror/prune
41
+ */
42
+
43
+ import {
44
+ existsSync,
45
+ lstatSync,
46
+ mkdirSync,
47
+ readFileSync,
48
+ readdirSync,
49
+ realpathSync,
50
+ renameSync,
51
+ unlinkSync,
52
+ writeFileSync,
53
+ } from 'node:fs';
54
+ import { createHash } from 'node:crypto';
55
+ import { basename, dirname, join, resolve, sep } from 'node:path';
56
+
57
+ import { parseFrontmatter, subjectToSlug } from './utils.mjs';
58
+ import {
59
+ GENERATOR_MARKER,
60
+ ROLLUP_PREFIX,
61
+ archivePathFor,
62
+ canonicalAmong,
63
+ markArchived,
64
+ noteInsightKey,
65
+ noteNarrativeChars,
66
+ renderRollup,
67
+ rollupRelPath,
68
+ rollupRowFromNote,
69
+ } from './retention.mjs';
70
+
71
+ export const ZONES = Object.freeze(['40-learnings', '50-sessions']);
72
+ export const MANIFEST_SCHEMA = 'vault-mirror-prune/1';
73
+
74
+ const dehyphen = (s) => String(s).replace(/-/g, '');
75
+ const hyphens = (s) => (String(s).match(/-/g) || []).length;
76
+
77
+ /**
78
+ * Read every mirror-owned note of both zones. Depth: `<zone>/<file>` (flat) or
79
+ * `<zone>/<ns>/<file>`; anything deeper is not a mirror layout and is ignored.
80
+ */
81
+ function readZoneNotes(vaultDir) {
82
+ const notes = [];
83
+ let handwritten = 0;
84
+ for (const zone of ZONES) {
85
+ const zoneAbs = join(vaultDir, zone);
86
+ let top;
87
+ try {
88
+ top = readdirSync(zoneAbs, { withFileTypes: true });
89
+ } catch {
90
+ continue;
91
+ }
92
+ const files = [];
93
+ for (const ent of top) {
94
+ if (ent.isFile() && ent.name.endsWith('.md')) files.push({ dirNs: '', name: ent.name });
95
+ else if (ent.isDirectory()) {
96
+ let inner;
97
+ try {
98
+ inner = readdirSync(join(zoneAbs, ent.name), { withFileTypes: true });
99
+ } catch {
100
+ continue;
101
+ }
102
+ for (const f of inner) if (f.isFile() && f.name.endsWith('.md')) files.push({ dirNs: ent.name, name: f.name });
103
+ }
104
+ }
105
+ for (const { dirNs, name } of files) {
106
+ const rel = dirNs ? `${zone}/${dirNs}/${name}` : `${zone}/${name}`;
107
+ if (name.startsWith(ROLLUP_PREFIX)) continue;
108
+ let content;
109
+ try {
110
+ content = readFileSync(join(vaultDir, rel), 'utf8');
111
+ } catch {
112
+ continue;
113
+ }
114
+ const fm = parseFrontmatter(content);
115
+ if (!fm || fm['_generator'] !== GENERATOR_MARKER) {
116
+ handwritten += 1;
117
+ continue;
118
+ }
119
+ notes.push({ rel, zone, dirNs, name, base: name.slice(0, -3), fm, content });
120
+ }
121
+ }
122
+ return { notes, handwritten };
123
+ }
124
+
125
+ /**
126
+ * Namespace alias resolver: explicit map first, then the hyphen rule — `ns`
127
+ * maps to a sibling folder (in either zone) with the same name modulo hyphens
128
+ * when `ns` has fewer hyphens (the directory-name fallback of the namespace
129
+ * resolver drops them; the remote-derived name keeps them).
130
+ */
131
+ function makeAliasResolver(notes, explicit) {
132
+ const present = new Set(notes.map((n) => n.dirNs).filter(Boolean));
133
+ for (const to of Object.values(explicit)) present.add(to);
134
+ // The hyphen rule is the SAME function the mirror applies at write time
135
+ // (`canonicalAmong`, #1513 review M2), so both agree on the folder.
136
+ const resolveNs = (ns) => {
137
+ if (!ns) return ns;
138
+ if (Object.hasOwn(explicit, ns)) return explicit[ns];
139
+ return canonicalAmong(present, ns);
140
+ };
141
+ const used = {};
142
+ for (const ns of present) if (resolveNs(ns) !== ns) used[ns] = resolveNs(ns);
143
+ return { resolveNs, aliases: used };
144
+ }
145
+
146
+ /**
147
+ * Which copy of a group stays. Callers pass LIVE notes only (not expired, not
148
+ * archived — #1513 review H1), so rank never has to weigh a dead copy.
149
+ */
150
+ function canonicalRank(a, b) {
151
+ const own = (n) => (n.dirNs !== '' && n.dirNs === n.effNs ? 1 : 0);
152
+ const inNs = (n) => (n.dirNs !== '' ? 1 : 0);
153
+ const verified = (n) => (n.fm.status === 'verified' ? 1 : 0);
154
+ const rec = (n) => (n.fm['source-record'] ? 1 : 0);
155
+ return (
156
+ own(b) - own(a) ||
157
+ inNs(b) - inNs(a) ||
158
+ verified(b) - verified(a) ||
159
+ String(b.fm.expires ?? '9999').localeCompare(String(a.fm.expires ?? '9999')) ||
160
+ rec(b) - rec(a) ||
161
+ hyphens(b.base) - hyphens(a.base) ||
162
+ String(b.fm.updated ?? '').localeCompare(String(a.fm.updated ?? '')) ||
163
+ a.rel.localeCompare(b.rel)
164
+ );
165
+ }
166
+
167
+ /**
168
+ * Plan the prune. Reads only.
169
+ *
170
+ * @param {object} opts
171
+ * @param {string} opts.vaultDir absolute vault root
172
+ * @param {string} opts.today `YYYY-MM-DD`
173
+ * @param {number} [opts.minNarrativeChars=400]
174
+ * @param {Record<string,string>} [opts.aliases] explicit namespace aliases
175
+ * @returns {{actions: object[], counts: Record<string, number>, aliases: Record<string,string>, handwritten: number, rollups: Record<string, object[]>}}
176
+ */
177
+ export function planPrune({ vaultDir, today, minNarrativeChars = 400, aliases = {} }) {
178
+ if (typeof vaultDir !== 'string' || vaultDir.length === 0) throw new TypeError('planPrune: vaultDir is required');
179
+ if (!/^\d{4}-\d{2}-\d{2}$/.test(String(today))) throw new TypeError(`planPrune: today must be YYYY-MM-DD, got ${today}`);
180
+ const root = resolve(vaultDir);
181
+ const { notes, handwritten } = readZoneNotes(root);
182
+ const { resolveNs, aliases: usedAliases } = makeAliasResolver(notes, aliases);
183
+
184
+ for (const n of notes) {
185
+ const sourceRepo = n.fm['source-repo'] ? subjectToSlug(n.fm['source-repo']) : '';
186
+ n.effNs = n.dirNs ? resolveNs(n.dirNs) : resolveNs(sourceRepo);
187
+ }
188
+
189
+ /** @type {Map<string, {reason: string, keep?: string}>} */
190
+ const decided = new Map();
191
+ const decide = (n, reason, keep) => {
192
+ if (!decided.has(n.rel)) decided.set(n.rel, keep ? { reason, keep } : { reason });
193
+ };
194
+
195
+ // ── learnings: expired FIRST, then duplicate / superseded among the living ─
196
+ // #1513 review H1: deciding duplicates first let a group keep its EXPIRED
197
+ // copy and archive the valid one as its duplicate — the expiry pass then
198
+ // archived the keeper too, leaving no active copy at all (350 groups in the
199
+ // Meta-Vault, 34 of them with a live copy). A dead copy is never a keeper.
200
+ const learnings = notes.filter((n) => n.zone === '40-learnings' && n.fm.type === 'learning');
201
+ const live = [];
202
+ for (const n of learnings) {
203
+ const marked = n.fm.status === 'archived' ? n.fm['archived-reason'] : '';
204
+ if (marked === 'superseded' || marked === 'expired') decide(n, marked);
205
+ else if (n.fm.status === 'archived') decide(n, 'superseded');
206
+ else if (/^\d{4}-\d{2}-\d{2}/.test(String(n.fm.expires ?? '')) && String(n.fm.expires).slice(0, 10) < today) {
207
+ decide(n, 'expired');
208
+ } else live.push(n);
209
+ }
210
+
211
+ const byInsight = new Map();
212
+ for (const n of live) {
213
+ n.insightKey = noteInsightKey(n.content);
214
+ if (!n.insightKey) continue;
215
+ if (!byInsight.has(n.insightKey)) byInsight.set(n.insightKey, []);
216
+ byInsight.get(n.insightKey).push(n);
217
+ }
218
+ for (const group of byInsight.values()) {
219
+ if (group.length < 2) continue;
220
+ const buckets = new Map();
221
+ for (const n of group) {
222
+ if (!buckets.has(n.effNs)) buckets.set(n.effNs, []);
223
+ buckets.get(n.effNs).push(n);
224
+ }
225
+ const named = [...buckets.keys()].filter(Boolean).sort();
226
+ for (const ns of named) {
227
+ const sorted = buckets.get(ns).sort(canonicalRank);
228
+ for (const n of sorted.slice(1)) decide(n, 'duplicate', sorted[0].rel);
229
+ }
230
+ const unnamed = (buckets.get('') ?? []).sort(canonicalRank);
231
+ if (named.length > 0) {
232
+ const keeper = buckets.get(named[0]).sort(canonicalRank)[0];
233
+ for (const n of unnamed) decide(n, 'duplicate', keeper.rel);
234
+ } else {
235
+ for (const n of unnamed.slice(1)) decide(n, 'duplicate', unnamed[0].rel);
236
+ }
237
+ }
238
+
239
+ const bySlug = new Map();
240
+ for (const n of live) {
241
+ if (decided.has(n.rel)) continue;
242
+ const k = `${n.effNs}\0${dehyphen(n.base)}`;
243
+ if (!bySlug.has(k)) bySlug.set(k, []);
244
+ bySlug.get(k).push(n);
245
+ }
246
+ for (const group of bySlug.values()) {
247
+ if (group.length < 2) continue;
248
+ const sorted = group.sort(
249
+ (a, b) => String(b.fm.updated ?? '').localeCompare(String(a.fm.updated ?? '')) || canonicalRank(a, b),
250
+ );
251
+ for (const n of sorted.slice(1)) decide(n, 'superseded', sorted[0].rel);
252
+ }
253
+
254
+ // ── sessions: metrics-only → rollup + archive ──────────────────────────────
255
+ /** @type {Record<string, object[]>} rollup rel path → rows */
256
+ const rollups = {};
257
+ const sessions = notes.filter((n) => n.zone === '50-sessions' && n.fm.type === 'session');
258
+ const unparseable = new Set();
259
+ for (const n of sessions) {
260
+ if (noteNarrativeChars(n.content) >= minNarrativeChars) continue;
261
+ const row = rollupRowFromNote(n.content, { id: n.base });
262
+ if (row === null) {
263
+ unparseable.add(n.rel);
264
+ continue;
265
+ }
266
+ const rollup = rollupRelPath(n.effNs, row.date.slice(0, 7));
267
+ (rollups[rollup] ??= []).push(row);
268
+ decided.set(n.rel, { reason: 'metrics-only-session', rollup });
269
+ }
270
+
271
+ // ── relocation of the survivors: alias folder / flat legacy layout ─────────
272
+ const existingRel = new Set(notes.map((n) => n.rel));
273
+ const claimed = new Set();
274
+ const actions = [];
275
+ for (const n of notes) {
276
+ const d = decided.get(n.rel);
277
+ if (d) {
278
+ actions.push({ path: n.rel, reason: d.reason, action: 'archive', target: archivePathFor(n.rel), ...(d.keep ? { keep: d.keep } : {}), ...(d.rollup ? { rollup: d.rollup } : {}) });
279
+ continue;
280
+ }
281
+ if (unparseable.has(n.rel)) {
282
+ actions.push({ path: n.rel, reason: 'metrics-only-session', action: 'keep', note: 'no date or id to roll up' });
283
+ continue;
284
+ }
285
+ if (!n.effNs || n.effNs === n.dirNs) continue;
286
+ const reason = n.dirNs ? 'namespace-alias' : 'flat-relocate';
287
+ const target = `${n.zone}/${n.effNs}/${n.name}`;
288
+ if (claimed.has(target) || existingRel.has(target)) {
289
+ const twin = notes.find((m) => m.rel === target);
290
+ if (twin && twin.fm.id === n.fm.id && !decided.has(twin.rel)) {
291
+ actions.push({ path: n.rel, reason: 'duplicate', action: 'archive', target: archivePathFor(n.rel), keep: target });
292
+ } else {
293
+ actions.push({ path: n.rel, reason, action: 'keep', note: `relocation target taken: ${target}` });
294
+ }
295
+ continue;
296
+ }
297
+ claimed.add(target);
298
+ actions.push({ path: n.rel, reason, action: 'move', target });
299
+ }
300
+
301
+ actions.sort((a, b) => a.path.localeCompare(b.path));
302
+ const counts = {};
303
+ for (const a of actions) {
304
+ const k = `${a.reason}:${a.action}`;
305
+ counts[k] = (counts[k] ?? 0) + 1;
306
+ }
307
+ return { actions, counts, aliases: usedAliases, handwritten, rollups };
308
+ }
309
+
310
+ /**
311
+ * Refuse a path that leaves the vault — lexically, or through a symlinked
312
+ * directory on the way (#1513 review N1: `90-archive` as a symlink would have
313
+ * turned the archive move into a write anywhere on the host). The nearest
314
+ * EXISTING ancestor is realpath'd and must sit inside the realpath'd root.
315
+ */
316
+ function assertInside(root, rel) {
317
+ const abs = resolve(root, rel);
318
+ if (!abs.startsWith(`${root}${sep}`)) throw new Error(`refusing ${rel}: outside the vault`);
319
+ const realRoot = realpathSync(root);
320
+ let probe = dirname(abs);
321
+ while (!existsSync(probe) && probe.startsWith(`${root}${sep}`)) probe = dirname(probe);
322
+ const realProbe = realpathSync(probe);
323
+ if (realProbe !== realRoot && !realProbe.startsWith(`${realRoot}${sep}`)) {
324
+ throw new Error(`refusing ${rel}: resolves outside the vault through a symlink`);
325
+ }
326
+ return abs;
327
+ }
328
+
329
+ function assertRegularFile(abs, rel) {
330
+ const st = lstatSync(abs);
331
+ if (st.isSymbolicLink() || !st.isFile()) throw new Error(`refusing ${rel}: not a regular file`);
332
+ }
333
+
334
+ /**
335
+ * Apply a plan. THE ONLY WRITE PATH. Rollups first, so a metrics-only session
336
+ * is archived only after its row exists. Re-checks the generator marker of
337
+ * every note right before touching it (the vault may have moved since the
338
+ * plan). Per-path failures are collected, never thrown.
339
+ *
340
+ * @param {ReturnType<typeof planPrune>} plan
341
+ * @param {{vaultDir: string}} ctx
342
+ * @returns {{archived: number, moved: number, rollupsWritten: number, suffixed: Array<{path: string, target: string, written: string}>, errors: Array<{path: string, error: string}>}}
343
+ */
344
+ export function applyPrune(plan, { vaultDir }) {
345
+ const root = resolve(vaultDir);
346
+ const errors = [];
347
+ let archived = 0;
348
+ let moved = 0;
349
+ let rollupsWritten = 0;
350
+ /** @type {Array<{path: string, target: string, written: string}>} */
351
+ const suffixed = [];
352
+ const failedRollups = new Set();
353
+
354
+ for (const [rel, rows] of Object.entries(plan.rollups ?? {})) {
355
+ try {
356
+ const abs = assertInside(root, rel);
357
+ let existing = null;
358
+ if (existsSync(abs)) {
359
+ assertRegularFile(abs, rel);
360
+ existing = readFileSync(abs, 'utf8');
361
+ if (parseFrontmatter(existing)?.['_generator'] !== GENERATOR_MARKER) throw new Error('rollup exists and is not mirror-owned');
362
+ }
363
+ const ns = rel.split('/').length === 3 ? rel.split('/')[1] : '';
364
+ const month = rel.match(/_rollup-(\d{4}-\d{2})\.md$/)[1];
365
+ const next = renderRollup(existing, rows, { ns, month });
366
+ if (next !== existing) {
367
+ mkdirSync(dirname(abs), { recursive: true });
368
+ writeFileSync(abs, next, 'utf8');
369
+ rollupsWritten += 1;
370
+ }
371
+ } catch (err) {
372
+ failedRollups.add(rel);
373
+ errors.push({ path: rel, error: err?.message ?? String(err) });
374
+ }
375
+ }
376
+
377
+ for (const a of plan.actions ?? []) {
378
+ if (a.action !== 'archive' && a.action !== 'move') continue;
379
+ try {
380
+ if (a.rollup && failedRollups.has(a.rollup)) throw new Error(`rollup ${a.rollup} was not written`);
381
+ const src = assertInside(root, a.path);
382
+ const dst = assertInside(root, a.target);
383
+ assertRegularFile(src, a.path);
384
+ const content = readFileSync(src, 'utf8');
385
+ if (parseFrontmatter(content)?.['_generator'] !== GENERATOR_MARKER) throw new Error('no longer mirror-owned');
386
+ mkdirSync(dirname(dst), { recursive: true });
387
+ if (a.action === 'move') {
388
+ if (existsSync(dst)) throw new Error(`target exists: ${a.target}`);
389
+ renameSync(src, dst);
390
+ moved += 1;
391
+ continue;
392
+ }
393
+ const next = markArchived(content, a.reason);
394
+ let finalDst = dst;
395
+ if (existsSync(dst) && readFileSync(dst, 'utf8') !== next) {
396
+ // #1513 review N4: an occupied target with OTHER content (an earlier
397
+ // archive of a namesake) is never overwritten and never a permanent
398
+ // error: the note goes beside it, suffixed by its content hash, and
399
+ // the apply result names it. Same content → same suffix → idempotent.
400
+ const hash8 = createHash('sha256').update(next).digest('hex').slice(0, 8);
401
+ finalDst = join(dirname(dst), `${basename(dst, '.md')}.dup-${hash8}.md`);
402
+ suffixed.push({ path: a.path, target: a.target, written: `${dirname(a.target)}/${basename(finalDst)}` });
403
+ }
404
+ if (!existsSync(finalDst)) writeFileSync(finalDst, next, { encoding: 'utf8', flag: 'wx' });
405
+ else if (readFileSync(finalDst, 'utf8') !== next) throw new Error(`archive target exists with other content: ${finalDst}`);
406
+ unlinkSync(src);
407
+ archived += 1;
408
+ } catch (err) {
409
+ errors.push({ path: a.path, error: err?.message ?? String(err) });
410
+ }
411
+ }
412
+ return { archived, moved, rollupsWritten, suffixed, errors };
413
+ }
@@ -17,6 +17,7 @@
17
17
 
18
18
  import { toDate, buildTag, slugifyIdSafe } from './utils.mjs';
19
19
  import { sourceRecordValue, sourceRecordLine } from './render-learnings.mjs';
20
+ import { sessionNarrativeText } from './retention.mjs';
20
21
 
21
22
  const GENERATOR_MARKER = 'session-orchestrator-vault-mirror@1';
22
23
 
@@ -425,6 +426,9 @@ export function generateSessionNote(entry, options = {}) {
425
426
  const created = toDate(started_at);
426
427
  const updated = toDate(completed_at);
427
428
  const durationLabel = renderDuration(duration_seconds);
429
+ // #1513 review H2: v1 printed no free text at all; same block as v2/v3.
430
+ const v1Narrative = sessionNarrativeText(entry);
431
+ const v1NotesBlock = v1Narrative ? `\n## Notes\n\n${v1Narrative}\n` : '';
428
432
  // #M1: every `effectiveness` sub-field is OPTIONAL (validator.mjs
429
433
  // `_validateOptionalFields` shape-checks the object, never its members), so a
430
434
  // raw interpolation writes the literal string `undefined` into a note a human
@@ -564,7 +568,7 @@ ${waveRows}
564
568
  ## Agent summary
565
569
 
566
570
  - Complete: ${complete} · Partial: ${partial} · Failed: ${failed} · Spiral: ${spiral}
567
- `;
571
+ ${v1NotesBlock}`;
568
572
  }
569
573
 
570
574
  export function generateSessionNoteV2(entry, options = {}) {
@@ -580,7 +584,7 @@ export function generateSessionNoteV2(entry, options = {}) {
580
584
  throw new Error(`vault-mirror: session entry missing nested field 'effectiveness' (session_id=${entry.session_id})`);
581
585
  }
582
586
 
583
- const { session_id, session_type, started_at, completed_at, duration_seconds, branch, planned_issues, waves, files_changed, issues_closed, issues_created, effectiveness, notes } = entry;
587
+ const { session_id, session_type, started_at, completed_at, duration_seconds, branch, planned_issues, waves, files_changed, issues_closed, issues_created, effectiveness } = entry;
584
588
 
585
589
  const created = toDate(started_at);
586
590
  const updated = toDate(completed_at);
@@ -616,7 +620,9 @@ export function generateSessionNoteV2(entry, options = {}) {
616
620
  const closedList = Array.isArray(issues_closed) && issues_closed.length ? issues_closed.join(', ') : '—';
617
621
  const createdList = Array.isArray(issues_created) && issues_created.length ? issues_created.join(', ') : '—';
618
622
  const branchLine = branch ? ` · **Branch:** ${branch}` : '';
619
- const notesBlock = notes ? `\n## Notes\n\n${notes}\n` : '';
623
+ // #1513 review H2: notes + narrative + summary, the fields the gate counts.
624
+ const narrativeText = sessionNarrativeText(entry);
625
+ const notesBlock = narrativeText ? `\n## Notes\n\n${narrativeText}\n` : '';
620
626
 
621
627
  // #732: emit `source-repo` (leak-guarded/pseudonym-mapped) instead of the
622
628
  // legacy raw `repo` field — see generateSessionNote for the full rationale.
@@ -680,7 +686,7 @@ export function generateSessionNoteV3(entry, options = {}) {
680
686
  session_id, session_type, platform, branch, started_at, completed_at,
681
687
  duration_minutes, duration_seconds, waves, agents_dispatched, agent_summary,
682
688
  planned_issues, effectiveness, commits, issues_closed, issues_created,
683
- follow_ups_filed, tests_total_pre, tests_total_post, tests_added, notes,
689
+ follow_ups_filed, tests_total_pre, tests_total_post, tests_added,
684
690
  } = entry;
685
691
 
686
692
  const created = toDate(started_at);
@@ -741,7 +747,9 @@ export function generateSessionNoteV3(entry, options = {}) {
741
747
 
742
748
  const platformBullet = platform === null || platform === undefined || platform === '' ? '' : ` · **Platform:** ${platform}`;
743
749
  const branchLine = branch ? ` · **Branch:** ${branch}` : '';
744
- const notesBlock = notes ? `\n## Notes\n\n${notes}\n` : '';
750
+ // #1513 review H2: notes + narrative + summary, the fields the gate counts.
751
+ const narrativeText = sessionNarrativeText(entry);
752
+ const notesBlock = narrativeText ? `\n## Notes\n\n${narrativeText}\n` : '';
745
753
 
746
754
  // #732: emit `source-repo` (leak-guarded/pseudonym-mapped) instead of the
747
755
  // legacy raw `repo` field — see generateSessionNote for the full rationale.