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
@@ -5,7 +5,7 @@
5
5
  * Both functions write to the vault dir and emit JSON action lines to stdout.
6
6
  */
7
7
 
8
- import { readFileSync, writeFileSync, mkdirSync, existsSync, readdirSync } from 'node:fs';
8
+ import { readFileSync, writeFileSync, mkdirSync, existsSync, readdirSync, statSync } from 'node:fs';
9
9
  import { join, resolve, sep } from 'node:path';
10
10
  import { createRequire } from 'node:module';
11
11
  import { randomUUID } from 'node:crypto';
@@ -16,6 +16,18 @@ import { resolveRepoNamespace } from './namespace.mjs';
16
16
  import { detectLearningSchema, normalizeLearningEntry, generateLearningNote, generateLearningNoteV2, learningSourceRecord } from './render-learnings.mjs';
17
17
  import { detectSessionSchema, normalizeSessionEntry, generateSessionNote, generateSessionNoteV2, generateSessionNoteV3, sessionSourceRecord } from './render-sessions.mjs';
18
18
  import { emitMirrorEvent } from './telemetry.mjs';
19
+ import {
20
+ ARCHIVE_ROOT,
21
+ canonicalNamespace,
22
+ legacyConcatSlug,
23
+ noteNarrativeChars,
24
+ markArchived,
25
+ noteInsightKey,
26
+ renderRollup,
27
+ rollupRelPath,
28
+ rollupRowFromNote,
29
+ sessionNarrativeChars,
30
+ } from './retention.mjs';
19
31
 
20
32
  const GENERATOR_MARKER = 'session-orchestrator-vault-mirror@1';
21
33
 
@@ -64,10 +76,92 @@ function getSessionNoteSet(vaultDir) {
64
76
  }
65
77
 
66
78
  walk(sessionsDir);
79
+ // #1513: session notes the pruner moved to the archive keep their basename,
80
+ // and Obsidian resolves a wiki-link by basename — so a learning whose source
81
+ // session was archived still links. Without this walk every such learning
82
+ // would re-render its `source_session` as plain text and churn on each run.
83
+ walk(join(resolvedVault, ARCHIVE_ROOT, '50-sessions'));
67
84
  _sessionNoteSets.set(resolvedVault, knownSessions);
68
85
  return knownSessions;
69
86
  }
70
87
 
88
+ // ── Insight index for write-time dedupe (#1513) ───────────────────────────────
89
+
90
+ /**
91
+ * Cache: `<dir>\0<dir>` → { stamp, index }. The stamp is the two directories'
92
+ * mtimes, which change whenever a file is added or removed — including by this
93
+ * process's own creates — so the index is rebuilt exactly when its population
94
+ * changed and reused otherwise (one read of the namespace per run, not per
95
+ * record).
96
+ *
97
+ * @type {Map<string, {stamp: string, index: Map<string, string>}>}
98
+ */
99
+ const _insightIndexes = new Map();
100
+
101
+ function dirStamp(dir) {
102
+ try {
103
+ return String(statSync(dir).mtimeMs);
104
+ } catch {
105
+ return 'absent';
106
+ }
107
+ }
108
+
109
+ /**
110
+ * normalised insight → absolute path, over the mirror-owned, NOT archived
111
+ * learning notes directly in `dirs` (non-recursive), plus those in `flatDir`
112
+ * whose `source-repo` is `ns`. Read-only.
113
+ *
114
+ * @param {string[]} dirs
115
+ * @param {string|null} [flatDir]
116
+ * @param {string|null} [ns]
117
+ * @returns {Map<string, string>}
118
+ */
119
+ function getInsightIndex(dirs, flatDir = null, ns = null) {
120
+ const key = [...dirs, flatDir ?? '', ns ?? ''].join('\0');
121
+ const stamp = [...dirs, ...(flatDir ? [flatDir] : [])].map(dirStamp).join('\0');
122
+ const cached = _insightIndexes.get(key);
123
+ if (cached && cached.stamp === stamp) return cached.index;
124
+ const index = new Map();
125
+ // #1513 review M3: a flat legacy note counts only when its `source-repo` is
126
+ // this namespace — a flat note of another repo, or one without attribution,
127
+ // must not refuse a genuine new learning of this repo.
128
+ const sources = [...dirs.map((d) => ({ dir: d, onlyRepo: null })), ...(flatDir ? [{ dir: flatDir, onlyRepo: ns }] : [])];
129
+ for (const { dir, onlyRepo } of sources) {
130
+ let names;
131
+ try {
132
+ names = readdirSync(dir, { withFileTypes: true });
133
+ } catch {
134
+ continue;
135
+ }
136
+ for (const ent of names) {
137
+ if (!ent.isFile() || !ent.name.endsWith('.md')) continue;
138
+ const abs = join(dir, ent.name);
139
+ let content;
140
+ try {
141
+ content = readFileSync(abs, 'utf8');
142
+ } catch {
143
+ continue;
144
+ }
145
+ const fm = parseFrontmatter(content);
146
+ if (!fm || fm['_generator'] !== GENERATOR_MARKER || fm['type'] !== 'learning') continue;
147
+ if (fm['status'] === 'archived') continue;
148
+ if (onlyRepo !== null && fm['source-repo'] !== onlyRepo) continue;
149
+ const insightKey = noteInsightKey(content);
150
+ if (insightKey && !index.has(insightKey)) index.set(insightKey, abs);
151
+ }
152
+ }
153
+ _insightIndexes.set(key, { stamp, index });
154
+ return index;
155
+ }
156
+
157
+ /** Today as `YYYY-MM-DD` from the injectable `ctx.now` (ms, ISO or Date). */
158
+ function todayOf(ctx) {
159
+ const now = ctx?.now;
160
+ const ms =
161
+ now instanceof Date ? now.getTime() : typeof now === 'number' ? now : typeof now === 'string' ? Date.parse(now) : NaN;
162
+ return new Date(Number.isFinite(ms) ? ms : Date.now()).toISOString().slice(0, 10);
163
+ }
164
+
71
165
  // ── Content diff helper ───────────────────────────────────────────────────────
72
166
 
73
167
  /**
@@ -763,6 +857,47 @@ function maskEntrySecrets(entry) {
763
857
 
764
858
  // ── Core processing ───────────────────────────────────────────────────────────
765
859
 
860
+ /**
861
+ * The canonical note slug of a learning (extracted from processLearning for
862
+ * #1513 so the archive pass derives the SAME path). See the #725 D1 / #635
863
+ * notes inside.
864
+ *
865
+ * @param {object} entry normalised learning entry
866
+ * @param {'v1'|'v2'} schema
867
+ * @returns {string}
868
+ */
869
+ function learningSlug(entry, schema) {
870
+ const entryId = entry.id;
871
+ let slugSource;
872
+ if (schema === 'v2') {
873
+ slugSource = entry.id;
874
+ } else if (typeof entry.subject === 'string') {
875
+ slugSource = entry.subject.trim().replace(/\s+/g, '-');
876
+ } else {
877
+ slugSource = entry.subject;
878
+ }
879
+ let slug;
880
+ if (typeof slugSource === 'string' && slugSource.length > 0) {
881
+ slug = subjectToSlug(slugSource);
882
+ } else {
883
+ slug = '';
884
+ }
885
+ if (!isValidSlug(slug)) {
886
+ slug = `learning-${uuidPrefix8(entryId)}`;
887
+ }
888
+ // #635: cap the slug so `<slug>.md` (plus a possible `-<uuid8>` disambig
889
+ // suffix) stays under the 255-byte filename limit. Normalized prose subjects
890
+ // can be arbitrarily long and previously aborted the whole mirror run with
891
+ // ENAMETOOLONG. 240 + 9 (disambig) + 3 (.md) = 252 — and every pre-existing
892
+ // vault note (max observed slug: 208 chars) keeps its identity untouched.
893
+ if (slug.length > 240) {
894
+ slug = slug.slice(0, 240).replace(/-+$/, '');
895
+ }
896
+
897
+ return slug;
898
+ }
899
+
900
+
766
901
  export async function processLearning(rawEntry, _lineNum, ctx) {
767
902
  const {
768
903
  vaultDir,
@@ -806,34 +941,20 @@ export async function processLearning(rawEntry, _lineNum, ctx) {
806
941
  // subject contract pinned by 15 tests in tests/unit/vault-mirror.test.mjs. The
807
942
  // pre-map is the mechanism the brief itself names and yields the IDENTICAL slug
808
943
  // for real data (kebab id derived from the same subject); see report.
809
- let slugSource;
810
- if (schema === 'v2') {
811
- slugSource = entry.id;
812
- } else if (typeof entry.subject === 'string') {
813
- slugSource = entry.subject.trim().replace(/\s+/g, '-');
814
- } else {
815
- slugSource = entry.subject;
816
- }
817
- let slug;
818
- if (typeof slugSource === 'string' && slugSource.length > 0) {
819
- slug = subjectToSlug(slugSource);
820
- } else {
821
- slug = '';
822
- }
823
- if (!isValidSlug(slug)) {
824
- slug = `learning-${uuidPrefix8(entryId)}`;
825
- }
826
- // #635: cap the slug so `<slug>.md` (plus a possible `-<uuid8>` disambig
827
- // suffix) stays under the 255-byte filename limit. Normalized prose subjects
828
- // can be arbitrarily long and previously aborted the whole mirror run with
829
- // ENAMETOOLONG. 240 + 9 (disambig) + 3 (.md) = 252 — and every pre-existing
830
- // vault note (max observed slug: 208 chars) keeps its identity untouched.
831
- if (slug.length > 240) {
832
- slug = slug.slice(0, 240).replace(/-+$/, '');
833
- }
944
+ let slug = learningSlug(entry, schema);
834
945
 
835
946
  // Generator + date source differ by schema
836
- const generator = schema === 'v2' ? generateLearningNoteV2 : generateLearningNote;
947
+ const baseGenerator = schema === 'v2' ? generateLearningNoteV2 : generateLearningNote;
948
+ // #1513: an expired learning (v1 `expires_at` before today) is never CREATED,
949
+ // and an existing note of it is re-rendered as `status: archived` +
950
+ // `archived-reason: expired` (the canonical-field diff sees the status change
951
+ // and writes it once; afterwards the note is a noop). v2 records carry no
952
+ // expiry. The pruner moves such notes out of the active zone.
953
+ const expiresDay = schema === 'v1' && typeof entry.expires_at === 'string' ? toDate(entry.expires_at) : '';
954
+ const expired = /^\d{4}-\d{2}-\d{2}$/.test(expiresDay) && expiresDay < todayOf(ctx);
955
+ const generator = expired
956
+ ? (e, s, o) => markArchived(baseGenerator(e, s, o), 'expired')
957
+ : baseGenerator;
837
958
  const dateSource = schema === 'v2' ? entry.first_seen : entry.created_at;
838
959
 
839
960
  // #704: Build a noteExists predicate from the vault's 50-sessions index so that
@@ -861,10 +982,13 @@ export async function processLearning(rawEntry, _lineNum, ctx) {
861
982
 
862
983
  // #660: namespace new writes under a per-repo subdirectory. #1389: `ctx.repoRoot`
863
984
  // is optional — absent, resolveRepoNamespace falls back to process.cwd().
864
- const repoNs = resolveRepoNamespace({
865
- vaultName: ctx?.vaultName ?? null,
866
- repoRoot: ctx?.repoRoot ?? null,
867
- });
985
+ // #1513 review M2: a hyphen alias (`gotzendorferv2`) resolves to the
986
+ // canonical folder already in the vault (`gotzendorfer-v2`), so the mirror
987
+ // and the pruner agree on where a repo's notes live.
988
+ const repoNs = canonicalNamespace(
989
+ vaultDir,
990
+ resolveRepoNamespace({ vaultName: ctx?.vaultName ?? null, repoRoot: ctx?.repoRoot ?? null }),
991
+ );
868
992
  // #725 D2: thread the resolved repo namespace into the learning frontmatter as
869
993
  // `source-repo` for cross-repo attribution. repoNs is already sanitised +
870
994
  // leak-guarded by resolveRepoNamespace, so it is safe to interpolate as-is. The
@@ -877,11 +1001,78 @@ export async function processLearning(rawEntry, _lineNum, ctx) {
877
1001
  // so two records sharing a subject keep two stable notes instead of
878
1002
  // overwriting each other on every run.
879
1003
  const writer = { recordKey: learningSourceRecord(entry) };
1004
+
1005
+ /**
1006
+ * #1513: the two reasons a NEW learning note is not created. Consulted only
1007
+ * where a file would come into existence — updates of existing notes are
1008
+ * never refused here. Returns the emitted action, or null to proceed.
1009
+ *
1010
+ * - `skipped-expired`: the learning is past `expires_at`; a note for it
1011
+ * would be dead on arrival.
1012
+ * - `skipped-duplicate-insight`: a mirror-owned, non-archived note in this
1013
+ * namespace (or the flat legacy layout) already carries the same
1014
+ * normalised insight — the same learning under another slug or id.
1015
+ */
1016
+ const refuseNewNote = (content) => {
1017
+ if (expired) {
1018
+ return emitEntryAction(_lineNum, ctx, {
1019
+ action: 'skipped-expired',
1020
+ path: null,
1021
+ id: entryId,
1022
+ meta: { reason: `expires:${expiresDay} < today:${todayOf(ctx)}` },
1023
+ });
1024
+ }
1025
+ const insightKey = noteInsightKey(content);
1026
+ if (insightKey) {
1027
+ const twin = getInsightIndex([targetDir], join(resolve(vaultDir), '40-learnings'), repoNs).get(insightKey);
1028
+ if (twin !== undefined) {
1029
+ return emitEntryAction(_lineNum, ctx, {
1030
+ action: 'skipped-duplicate-insight',
1031
+ path: twin,
1032
+ id: entryId,
1033
+ meta: { reason: 'same normalised insight as an existing note' },
1034
+ });
1035
+ }
1036
+ }
1037
+ return null;
1038
+ };
880
1039
  const targetDir = join(resolve(vaultDir), '40-learnings', repoNs);
881
1040
  if (!dryRun) mkdirSync(targetDir, { recursive: true });
882
1041
 
883
1042
  let targetPath = join(targetDir, `${slug}.md`);
884
1043
 
1044
+ // #1513 LEGACY SLUG: until #725 D1 a v1 subject was slugged WITHOUT the
1045
+ // whitespace → hyphen pre-map ("3parallelimplagents…"); the canonical form
1046
+ // hyphenates ("3-parallel-impl-agents…"). When only the legacy variant of
1047
+ // THIS record's note exists, it IS the note: it is updated in place under
1048
+ // its own name instead of a second file being created beside it (the vault
1049
+ // carried 364 such twins). New notes always get the canonical name.
1050
+ if (schema === 'v1') {
1051
+ const legacySlug = legacyConcatSlug(entry.subject);
1052
+ const flatDir = join(resolve(vaultDir), '40-learnings');
1053
+ if (
1054
+ isValidSlug(legacySlug) &&
1055
+ legacySlug !== slug &&
1056
+ !existsSync(targetPath) &&
1057
+ !existsSync(join(flatDir, `${slug}.md`))
1058
+ ) {
1059
+ for (const dir of [targetDir, flatDir]) {
1060
+ const legacyPath = join(dir, `${legacySlug}.md`);
1061
+ if (!existsSync(legacyPath)) continue;
1062
+ const legacyFm = parseFrontmatter(readFileSync(legacyPath, 'utf8'));
1063
+ if (
1064
+ notOurGenerator(legacyFm) === null &&
1065
+ legacyFm['id'] === legacySlug &&
1066
+ foreignOwnerReason(legacyFm, writer) === null
1067
+ ) {
1068
+ slug = legacySlug;
1069
+ targetPath = join(targetDir, `${legacySlug}.md`);
1070
+ break;
1071
+ }
1072
+ }
1073
+ }
1074
+ }
1075
+
885
1076
  // #660 IDEMPOTENCY DUAL-PROBE: before treating the namespaced path as absent,
886
1077
  // also check the legacy flat path. If a note with the same slug already exists
887
1078
  // in the flat layout (pre-namespace migration), treat it as existing to avoid
@@ -1035,6 +1226,10 @@ export async function processLearning(rawEntry, _lineNum, ctx) {
1035
1226
  }
1036
1227
 
1037
1228
  const content = generator(entry, slug, generatorOpts);
1229
+ if (!existsSync(targetPath)) {
1230
+ const refused = refuseNewNote(content);
1231
+ if (refused !== null) return refused;
1232
+ }
1038
1233
  if (!dryRun) writeFileSync(targetPath, content, 'utf8');
1039
1234
  return emitEntryAction(_lineNum, ctx, {
1040
1235
  action: 'skipped-collision-resolved',
@@ -1072,6 +1267,8 @@ export async function processLearning(rawEntry, _lineNum, ctx) {
1072
1267
 
1073
1268
  // File does not exist — create
1074
1269
  const content = generator(entry, slug, generatorOpts);
1270
+ const refused = refuseNewNote(content);
1271
+ if (refused !== null) return refused;
1075
1272
  if (!dryRun) writeFileSync(targetPath, content, 'utf8');
1076
1273
  return emitEntryAction(_lineNum, ctx, {
1077
1274
  action: 'created',
@@ -1084,6 +1281,140 @@ export async function processLearning(rawEntry, _lineNum, ctx) {
1084
1281
  });
1085
1282
  }
1086
1283
 
1284
+ /**
1285
+ * The note slugs a learning record can live under: the canonical slug and, for
1286
+ * v1, the pre-#725 legacy variant. Used by the CLI to keep the archive pass off
1287
+ * notes a LIVE record still owns (#1513).
1288
+ *
1289
+ * @param {object} rawEntry
1290
+ * @returns {string[]}
1291
+ */
1292
+ export function learningNoteSlugs(rawEntry) {
1293
+ const entry = normalizeLearningEntry(rawEntry);
1294
+ if (!entry || entry.id === null || entry.id === undefined) return [];
1295
+ const schema = detectLearningSchema(entry);
1296
+ const slugs = [learningSlug(entry, schema)];
1297
+ if (schema === 'v1') {
1298
+ const legacy = legacyConcatSlug(entry.subject);
1299
+ if (isValidSlug(legacy) && !slugs.includes(legacy)) slugs.push(legacy);
1300
+ }
1301
+ return slugs;
1302
+ }
1303
+
1304
+ /**
1305
+ * Mark the existing note of a record that left `learnings.jsonl` (#1513).
1306
+ *
1307
+ * Input is one `learnings-archive.jsonl` record. Its note — canonical slug,
1308
+ * legacy slug, or the `-<uuid8>` disambiguation, in the namespace or the flat
1309
+ * legacy layout — gets `status: archived` + `archived-reason` (`expired` for an
1310
+ * `_archive_reason: expired` record, `superseded` for every other reason). The
1311
+ * note's bytes are otherwise kept; nothing is ever created. Ownership rules are
1312
+ * the same as for writes: mirror-owned, and a `source-record` naming another
1313
+ * record is left alone. Slugs in `ctx.liveLearningSlugs` (records still in the
1314
+ * live store) are skipped, so a re-learned subject never flip-flops.
1315
+ *
1316
+ * @returns {Promise<string>} the emitted action
1317
+ */
1318
+ export async function processArchivedLearning(rawEntry, _lineNum, ctx) {
1319
+ const { vaultDir, dryRun } = ctx;
1320
+ const entry = maskEntrySecrets(normalizeLearningEntry(rawEntry));
1321
+ if (!entry || entry.id === null || entry.id === undefined) {
1322
+ throw new Error(`vault-mirror: archived learning entry missing required field 'id' (id=<no id>)`);
1323
+ }
1324
+ const reason = entry._archive_reason === 'expired' ? 'expired' : 'superseded';
1325
+ const slugs = learningNoteSlugs(entry);
1326
+ const live = ctx?.liveLearningSlugs instanceof Set ? ctx.liveLearningSlugs : new Set();
1327
+ if (slugs.some((s) => live.has(s))) {
1328
+ return emitEntryAction(_lineNum, ctx, {
1329
+ // `skipped-noop`, not a class of its own: this is the steady state of
1330
+ // every run, and noops stay out of the per-entry ledger (#1151).
1331
+ action: 'skipped-noop',
1332
+ path: null,
1333
+ id: entry.id,
1334
+ meta: { reason: 'archived record: a live record owns this note' },
1335
+ });
1336
+ }
1337
+ const repoNs = canonicalNamespace(
1338
+ vaultDir,
1339
+ resolveRepoNamespace({ vaultName: ctx?.vaultName ?? null, repoRoot: ctx?.repoRoot ?? null }),
1340
+ );
1341
+ const nsDir = join(resolve(vaultDir), '40-learnings', repoNs);
1342
+ const flatDir = join(resolve(vaultDir), '40-learnings');
1343
+ const writer = { recordKey: learningSourceRecord(entry) };
1344
+ const names = [...slugs, `${slugs[0]}-${uuidPrefix8(String(entry.id))}`];
1345
+ for (const dir of [nsDir, flatDir]) {
1346
+ for (const name of names) {
1347
+ const path = join(dir, `${name}.md`);
1348
+ if (!existsSync(path)) continue;
1349
+ const content = readFileSync(path, 'utf8');
1350
+ const fm = parseFrontmatter(content);
1351
+ if (notOurGenerator(fm) !== null || fm['id'] !== name) continue;
1352
+ if (foreignOwnerReason(fm, writer) !== null) continue;
1353
+ const next = markArchived(content, reason);
1354
+ if (next === content) {
1355
+ return emitEntryAction(_lineNum, ctx, { action: 'skipped-noop', path, id: name });
1356
+ }
1357
+ if (!dryRun) writeFileSync(path, next, 'utf8');
1358
+ return emitEntryAction(_lineNum, ctx, {
1359
+ action: 'updated',
1360
+ path,
1361
+ id: name,
1362
+ meta: { archived_reason: reason },
1363
+ });
1364
+ }
1365
+ }
1366
+ return emitEntryAction(_lineNum, ctx, {
1367
+ action: 'skipped-noop',
1368
+ path: null,
1369
+ id: entry.id,
1370
+ meta: { reason: 'archived record: no note in the vault' },
1371
+ });
1372
+ }
1373
+
1374
+ /**
1375
+ * Route a metrics-only session record (#1513): a note that already exists for
1376
+ * it is left exactly as it is (the pruner decides about the bestand), and
1377
+ * otherwise the record becomes one row in its month rollup. Idempotent: an
1378
+ * unchanged rollup is a `skipped-noop`.
1379
+ */
1380
+ async function rollUpSession({ renderedBody, session_id, repoNs, narrativeChars, lineNum, ctx }) {
1381
+ const { vaultDir, dryRun, qualityMinNarrativeChars = 400 } = ctx;
1382
+ const reason = `narrative:${narrativeChars} < min:${qualityMinNarrativeChars}`;
1383
+ const root = resolve(vaultDir);
1384
+ const existingNote = [join(root, '50-sessions', repoNs, `${session_id}.md`), join(root, '50-sessions', `${session_id}.md`)].find(
1385
+ (p) => existsSync(p),
1386
+ );
1387
+ const row = rollupRowFromNote(renderedBody, { id: session_id });
1388
+ if (existingNote !== undefined || row === null) {
1389
+ return emitEntryAction(lineNum, ctx, { action: 'skipped-quality-low', path: null, id: session_id, meta: { reason } });
1390
+ }
1391
+ const month = row.date.slice(0, 7);
1392
+ const rollupPath = join(root, rollupRelPath(repoNs, month));
1393
+ let existing = null;
1394
+ if (existsSync(rollupPath)) {
1395
+ existing = readFileSync(rollupPath, 'utf8');
1396
+ const notOurs = notOurGenerator(parseFrontmatter(existing));
1397
+ if (notOurs !== null) {
1398
+ process.stderr.write(`SKIP ${notOurs}: ${toVaultRelative(rollupPath, vaultDir)}\n`);
1399
+ return emitEntryAction(lineNum, ctx, { action: 'skipped-handwritten', path: rollupPath, id: session_id });
1400
+ }
1401
+ }
1402
+ const next = renderRollup(existing, [row], { ns: repoNs, month });
1403
+ if (next === existing) {
1404
+ return emitEntryAction(lineNum, ctx, { action: 'skipped-noop', path: rollupPath, id: session_id, meta: { reason } });
1405
+ }
1406
+ if (!dryRun) {
1407
+ mkdirSync(join(root, '50-sessions', repoNs), { recursive: true });
1408
+ writeFileSync(rollupPath, next, 'utf8');
1409
+ }
1410
+ return emitEntryAction(lineNum, ctx, {
1411
+ action: 'skipped-metrics-only',
1412
+ path: rollupPath,
1413
+ id: session_id,
1414
+ meta: { reason },
1415
+ });
1416
+ }
1417
+
1087
1418
  export async function processSession(rawEntry, _lineNum, ctx) {
1088
1419
  const {
1089
1420
  vaultDir,
@@ -1159,32 +1490,28 @@ export async function processSession(rawEntry, _lineNum, ctx) {
1159
1490
  // owner-leaky repo's real name reached the vault through the session-note
1160
1491
  // frontmatter even though the directory AND the learning `source-repo` field
1161
1492
  // were already pseudonym-mapped/redacted (#732 leak-guard bypass).
1162
- const repoNs = resolveRepoNamespace({
1163
- vaultName: ctx?.vaultName ?? null,
1164
- repoRoot: ctx?.repoRoot ?? null,
1165
- });
1493
+ const repoNs = canonicalNamespace(
1494
+ vaultDir,
1495
+ resolveRepoNamespace({ vaultName: ctx?.vaultName ?? null, repoRoot: ctx?.repoRoot ?? null }),
1496
+ );
1166
1497
 
1167
- // Quality gate (PRD F1.2): skip sessions whose rendered narrative is too short.
1168
- // Measure on the rendered markdown body so the check is schema-agnostic across
1169
- // v1 and v2 producers. The render is cheap and idempotent; we reuse the result
1170
- // below instead of calling generator() a second time.
1171
- // Runs BEFORE the --force branch so --force does NOT bypass the quality filter.
1172
- // If the generator throws a `vault-mirror: …` validation error (missing
1173
- // required field), it propagates up to vault-mirror.mjs which classifies it
1174
- // as `skipped-invalid` rather than `skipped-quality-low` — semantically more
1175
- // accurate for the metrics summary in session-end Phase 3.7.
1498
+ // Narrative gate (#1513, replaces the PRD F1.2 rendered-length gate). A
1499
+ // session gets its own note only when its FREE-TEXT fields (`notes`,
1500
+ // `narrative`, `summary` — see retention.mjs) carry at least
1501
+ // `qualityMinNarrativeChars` characters. The old gate measured the rendered
1502
+ // body INCLUDING the wave/agent tables, so nearly every record passed: 2.728
1503
+ // of 3.003 vault session notes were tables with no information beyond git
1504
+ // (Jev audit 2026-10-03). A record below the gate becomes one row in the
1505
+ // month rollup `50-sessions/<ns>/_rollup-YYYY-MM.md` instead. `0` disables
1506
+ // the gate. Runs BEFORE --force; --force does NOT bypass it.
1507
+ //
1508
+ // The render still happens first: a generator `vault-mirror: …` validation
1509
+ // error must keep surfacing as `skipped-invalid`, and the rollup row is read
1510
+ // off the rendered note so the mirror and the pruner share one parser.
1176
1511
  const renderedBody = generator(entry, { repoNs });
1177
- // Strip YAML frontmatter (lines between the first two `---` markers) so we
1178
- // measure only narrative content, not boilerplate.
1179
- const narrativeBody = renderedBody.replace(/^---[\s\S]*?---/m, '').trim();
1180
- const narrativeChars = narrativeBody.length;
1512
+ const narrativeChars = sessionNarrativeChars(entry);
1181
1513
  if (narrativeChars < qualityMinNarrativeChars) {
1182
- return emitEntryAction(_lineNum, ctx, {
1183
- action: 'skipped-quality-low',
1184
- path: null,
1185
- id: session_id,
1186
- meta: { reason: `narrative:${narrativeChars} < min:${qualityMinNarrativeChars}` },
1187
- });
1514
+ return rollUpSession({ renderedBody, session_id, repoNs, narrativeChars, lineNum: _lineNum, ctx });
1188
1515
  }
1189
1516
 
1190
1517
  // #660: namespace new writes under a per-repo subdirectory. repoNs was
@@ -1239,7 +1566,8 @@ export async function processSession(rawEntry, _lineNum, ctx) {
1239
1566
  }
1240
1567
  if (legacyMarked && !legacyForeign) {
1241
1568
  const entryUpdated = toDate(entry.completed_at);
1242
- if (!force && !legacyStillLeaks && legacyFm['updated'] && legacyFm['updated'] >= entryUpdated) {
1569
+ const legacyNarrativeStale = noteNarrativeChars(legacyContent) !== noteNarrativeChars(renderedBody);
1570
+ if (!force && !legacyStillLeaks && !legacyNarrativeStale && legacyFm['updated'] && legacyFm['updated'] >= entryUpdated) {
1243
1571
  return emitEntryAction(_lineNum, ctx, { action: 'skipped-noop', path: legacyFlatPath, id: session_id });
1244
1572
  }
1245
1573
  // Updated date would advance, or the on-disk note still leaks — fall
@@ -1285,7 +1613,11 @@ export async function processSession(rawEntry, _lineNum, ctx) {
1285
1613
  }
1286
1614
 
1287
1615
  const entryUpdated = toDate(entry.completed_at);
1288
- if (!force && !existingLeaks && fm['updated'] && fm['updated'] >= entryUpdated) {
1616
+ // #1513 review H2: a note rendered before narrative/summary were printed
1617
+ // carries less free text than the record — heal it once, or the pruner
1618
+ // archives it as metrics-only and the mirror re-creates it every run.
1619
+ const narrativeStale = noteNarrativeChars(existingContent) !== noteNarrativeChars(renderedBody);
1620
+ if (!force && !existingLeaks && !narrativeStale && fm['updated'] && fm['updated'] >= entryUpdated) {
1289
1621
  return emitEntryAction(_lineNum, ctx, { action: 'skipped-noop', path: targetPath, id: session_id });
1290
1622
  }
1291
1623
  if (!dryRun) writeFileSync(targetPath, renderedBody, 'utf8');