session-orchestrator 5.6.0 → 5.8.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 (207) hide show
  1. package/.agents/skills/architecture/SKILL.md +3 -0
  2. package/.agents/skills/architecture/agents/openai.yaml +5 -0
  3. package/.agents/skills/claude-md-drift-check/SKILL.md +1 -0
  4. package/.agents/skills/eval/SKILL.md +1 -1
  5. package/.agents/skills/frontmatter-guard/SKILL.md +1 -0
  6. package/.agents/skills/gitlab-portfolio/SKILL.md +1 -0
  7. package/.agents/skills/hook-development/SKILL.md +1 -0
  8. package/.agents/skills/mcp-builder/SKILL.md +1 -0
  9. package/.agents/skills/tmux-layout/SKILL.md +3 -0
  10. package/.agents/skills/tmux-layout/agents/openai.yaml +5 -0
  11. package/.agents/skills/vault-mirror/SKILL.md +1 -0
  12. package/.agents/skills/vault-sync/SKILL.md +1 -0
  13. package/.agents/skills/write-executable-plan/SKILL.md +3 -0
  14. package/.agents/skills/write-executable-plan/agents/openai.yaml +5 -0
  15. package/.claude-plugin/marketplace.json +1 -1
  16. package/.claude-plugin/plugin.json +1 -1
  17. package/.codex-plugin/plugin.json +1 -1
  18. package/.codex-plugin/skills/architecture/SKILL.md +3 -0
  19. package/.codex-plugin/skills/architecture/agents/openai.yaml +5 -0
  20. package/.codex-plugin/skills/claude-md-drift-check/SKILL.md +1 -0
  21. package/.codex-plugin/skills/eval/SKILL.md +1 -1
  22. package/.codex-plugin/skills/frontmatter-guard/SKILL.md +1 -0
  23. package/.codex-plugin/skills/gitlab-portfolio/SKILL.md +1 -0
  24. package/.codex-plugin/skills/hook-development/SKILL.md +1 -0
  25. package/.codex-plugin/skills/mcp-builder/SKILL.md +1 -0
  26. package/.codex-plugin/skills/tmux-layout/SKILL.md +3 -0
  27. package/.codex-plugin/skills/tmux-layout/agents/openai.yaml +5 -0
  28. package/.codex-plugin/skills/vault-mirror/SKILL.md +1 -0
  29. package/.codex-plugin/skills/vault-sync/SKILL.md +1 -0
  30. package/.codex-plugin/skills/write-executable-plan/SKILL.md +3 -0
  31. package/.codex-plugin/skills/write-executable-plan/agents/openai.yaml +5 -0
  32. package/.cursor/commands/architecture.md +13 -0
  33. package/.cursor/commands/eval.md +1 -1
  34. package/.cursor/commands/tmux-layout.md +13 -0
  35. package/.cursor/commands/write-executable-plan.md +13 -0
  36. package/.cursor/skills/architecture/SKILL.md +0 -1
  37. package/.cursor/skills/eval/SKILL.md +1 -1
  38. package/.cursor/skills/tmux-layout/SKILL.md +0 -1
  39. package/.cursor/skills/write-executable-plan/SKILL.md +0 -1
  40. package/.cursor-plugin/plugin.json +1 -1
  41. package/CHANGELOG.md +75 -0
  42. package/README.md +10 -10
  43. package/agents/eval-judge.md +5 -5
  44. package/docs/USER-GUIDE.md +1 -1
  45. package/docs/baseline.md +4 -3
  46. package/docs/codex-setup.md +6 -4
  47. package/docs/components.md +5 -4
  48. package/docs/events-schema.md +4 -3
  49. package/docs/github-mirror-protection.md +3 -2
  50. package/docs/install.md +39 -3
  51. package/docs/pi-setup.md +1 -1
  52. package/docs/rule-authoring.md +12 -2
  53. package/docs/session-config-reference.md +8 -6
  54. package/docs/session-config-template.md +4 -3
  55. package/evals/README.md +60 -0
  56. package/evals/instruction-ablation/README.md +147 -0
  57. package/evals/instruction-ablation/cases/psa-003-foreign-work.json +34 -0
  58. package/evals/instruction-ablation/cases/sec-007-sql-parameterisation.json +32 -0
  59. package/evals/instruction-ablation/cases/vbc-001-verify-before-claiming.json +41 -0
  60. package/evals/instruction-ablation/run.mjs +336 -0
  61. package/evals/psa-003-foreign-work/case.yaml +16 -0
  62. package/evals/psa-003-foreign-work/fixture.sh +12 -0
  63. package/evals/psa-003-foreign-work/graders/foreign-file-intact.md +7 -0
  64. package/evals/psa-003-foreign-work/graders/names-foreign-work.md +6 -0
  65. package/evals/psa-003-foreign-work/graders/no-destructive-git.md +8 -0
  66. package/evals/psa-003-foreign-work/graders/ran-git.md +7 -0
  67. package/evals/psa-003-foreign-work/prompt.md +6 -0
  68. package/hooks/_lib/hook-import-set.json +10 -2
  69. package/hooks/hooks-codex.json +1 -1
  70. package/hooks/hooks.json +1 -1
  71. package/hooks/post-tool-batch-wave-signal.mjs +1 -0
  72. package/hooks/post-tooluse-frontend-slop.mjs +11 -1
  73. package/hooks/pre-task-scope-disjoint.mjs +8 -6
  74. package/hooks/wave-scope-commit-guard.mjs +41 -10
  75. package/monitors/monitors.json +1 -1
  76. package/package.json +3 -1
  77. package/pi/prompts/architecture.md +11 -0
  78. package/pi/prompts/eval.md +1 -1
  79. package/pi/prompts/tmux-layout.md +11 -0
  80. package/pi/prompts/write-executable-plan.md +11 -0
  81. package/scripts/backfill-abandoned-sessions.mjs +6 -8
  82. package/scripts/backfill-learnings-from-vault.mjs +17 -6
  83. package/scripts/backfill-sessions.mjs +9 -7
  84. package/scripts/codex-install.mjs +108 -23
  85. package/scripts/emit-session.mjs +15 -2
  86. package/scripts/fleet-auflagen.mjs +54 -0
  87. package/scripts/generate-agents-skills.mjs +9 -16
  88. package/scripts/generate-codex-skills.mjs +8 -6
  89. package/scripts/generate-cursor-adapter.mjs +9 -74
  90. package/scripts/generate-pi-prompts.mjs +8 -74
  91. package/scripts/github-protection-audit.mjs +8 -0
  92. package/scripts/lib/auto-dialectic.mjs +10 -9
  93. package/scripts/lib/auto-dream.mjs +8 -10
  94. package/scripts/lib/autopilot/telemetry.mjs +12 -6
  95. package/scripts/lib/bootstrap-lock-refresh.mjs +8 -12
  96. package/scripts/lib/config/drift-check.mjs +1 -1
  97. package/scripts/lib/config/frontend-slop-hook.mjs +6 -0
  98. package/scripts/lib/config.mjs +5 -1
  99. package/scripts/lib/convergence-monitor.mjs +5 -3
  100. package/scripts/lib/dispatcher/rank.mjs +5 -1
  101. package/scripts/lib/ecosystem-health.mjs +5 -3
  102. package/scripts/lib/eval/engine.mjs +61 -27
  103. package/scripts/lib/eval/judge.mjs +14 -8
  104. package/scripts/lib/eval/session-resolve.mjs +7 -2
  105. package/scripts/lib/events-retention-banner.mjs +5 -2
  106. package/scripts/lib/events.mjs +81 -54
  107. package/scripts/lib/fleet-protocol.mjs +98 -1
  108. package/scripts/lib/instruction-budget-guard.mjs +25 -18
  109. package/scripts/lib/io.mjs +181 -39
  110. package/scripts/lib/learnings/candidates.mjs +14 -44
  111. package/scripts/lib/learnings/io.mjs +7 -2
  112. package/scripts/lib/learnings/judgment.mjs +5 -8
  113. package/scripts/lib/learnings/select.mjs +3 -3
  114. package/scripts/lib/maintenance-due-banner.mjs +2 -2
  115. package/scripts/lib/memory-proposals/sink.mjs +5 -6
  116. package/scripts/lib/memory-proposals/store.mjs +12 -7
  117. package/scripts/lib/mirror-issues-banner.mjs +11 -1
  118. package/scripts/lib/native-command.mjs +48 -0
  119. package/scripts/lib/orphan-reaper/scan.mjs +29 -3
  120. package/scripts/lib/peer-cards/writer.mjs +6 -18
  121. package/scripts/lib/platform.mjs +113 -4
  122. package/scripts/lib/plugin-package-stage.mjs +169 -0
  123. package/scripts/lib/plugin-surface.mjs +121 -0
  124. package/scripts/lib/quality-gate.mjs +30 -25
  125. package/scripts/lib/reconcile/idempotency.mjs +9 -10
  126. package/scripts/lib/reconcile/rule-expiry-sweep.mjs +97 -8
  127. package/scripts/lib/reconcile/writer.mjs +11 -17
  128. package/scripts/lib/rule-loader.mjs +32 -6
  129. package/scripts/lib/scope-echo.mjs +12 -2
  130. package/scripts/lib/scope-gate.mjs +2 -1
  131. package/scripts/lib/session-close-backfill.mjs +21 -4
  132. package/scripts/lib/session-id.mjs +2 -1
  133. package/scripts/lib/session-record-repair.mjs +9 -13
  134. package/scripts/lib/session-schema/constants.mjs +20 -5
  135. package/scripts/lib/session-start-probes.mjs +2 -2
  136. package/scripts/lib/sessions-staleness-banner.mjs +2 -3
  137. package/scripts/lib/skill-evolution/engine.mjs +6 -9
  138. package/scripts/lib/skill-evolution/idempotency.mjs +8 -10
  139. package/scripts/lib/slopcheck.mjs +9 -7
  140. package/scripts/lib/sunset/walker.mjs +3 -3
  141. package/scripts/lib/telemetry/sync.mjs +3 -1
  142. package/scripts/lib/telemetry-flush-health-banner.mjs +2 -2
  143. package/scripts/lib/test-sibling-coverage.mjs +167 -0
  144. package/scripts/lib/tmux-layout/telemetry-stats.mjs +5 -7
  145. package/scripts/lib/tmux-layout/telemetry.mjs +8 -10
  146. package/scripts/lib/user-invocable-skills.mjs +37 -10
  147. package/scripts/lib/ux-grill/run-record.mjs +5 -3
  148. package/scripts/lib/validate/check-banner-parity.mjs +1 -0
  149. package/scripts/lib/validate/check-skills.mjs +12 -2
  150. package/scripts/lib/validate/check-test-fixture-shapes.mjs +3 -3
  151. package/scripts/lib/validate/check-test-value-bans.mjs +12 -2
  152. package/scripts/lib/validate/check-unicode-safety.mjs +1 -1
  153. package/scripts/lib/validate/check-unwired-features.mjs +1 -1
  154. package/scripts/lib/vault-archive.mjs +2 -4
  155. package/scripts/lib/vault-mirror/process.mjs +585 -72
  156. package/scripts/lib/vault-mirror/prune.mjs +413 -0
  157. package/scripts/lib/vault-mirror/render-sessions.mjs +13 -5
  158. package/scripts/lib/vault-mirror/retention.mjs +388 -0
  159. package/scripts/lib/vault-mirror/utils.mjs +16 -1
  160. package/scripts/lib/vault-status/board-writer.mjs +6 -6
  161. package/scripts/lib/vault-sync-baseline.mjs +9 -8
  162. package/scripts/lib/wave-transcript-tail.mjs +19 -6
  163. package/scripts/lib/worktree/meta.mjs +7 -3
  164. package/scripts/mcp-server.mjs +158 -0
  165. package/scripts/migrate-sessions-jsonl.mjs +7 -5
  166. package/scripts/migrate-vault-paths.mjs +7 -4
  167. package/scripts/print-applicable-rules.mjs +1 -25
  168. package/scripts/print-learnings-index.mjs +2 -1
  169. package/scripts/release.mjs +4 -0
  170. package/scripts/relocate-vault-corpus.mjs +5 -3
  171. package/scripts/run-migrate-v2-cross-repo.mjs +9 -7
  172. package/scripts/self-update.mjs +179 -39
  173. package/scripts/site-numbers.mjs +4 -3
  174. package/scripts/sweep-expired-learnings.mjs +6 -4
  175. package/scripts/sync-vault-schema.mjs +3 -7
  176. package/scripts/vault-mirror-prune.mjs +145 -0
  177. package/scripts/vault-mirror.mjs +77 -12
  178. package/scripts/windows-setup.mjs +45 -0
  179. package/skills/_shared/fleet-protocol.md +8 -0
  180. package/skills/architecture/SKILL.md +1 -0
  181. package/skills/bootstrap/_shared-template.md +5 -2
  182. package/skills/claude-md-drift-check/SKILL.md +4 -1
  183. package/skills/claude-md-drift-check/checker.mjs +89 -26
  184. package/skills/discovery/probes/ssot-code-diff.mjs +3 -3
  185. package/skills/eval/SKILL.md +12 -13
  186. package/skills/eval/rubric-v3.md +521 -0
  187. package/skills/frontmatter-guard/SKILL.md +1 -0
  188. package/skills/gitlab-portfolio/SKILL.md +1 -0
  189. package/skills/hook-development/SKILL.md +1 -0
  190. package/skills/mcp-builder/SKILL.md +1 -0
  191. package/skills/repo-audit/SKILL.md +2 -2
  192. package/skills/session-end/SKILL.md +32 -1
  193. package/skills/session-end/vault-operations.md +12 -2
  194. package/skills/session-start/SKILL.md +2 -2
  195. package/skills/session-start/phase-7-1-premise-check.md +2 -1
  196. package/skills/session-start/references/operations-contract.md +4 -1
  197. package/skills/tmux-layout/SKILL.md +1 -0
  198. package/skills/vault-mirror/SKILL.md +28 -2
  199. package/skills/vault-sync/SKILL.md +6 -6
  200. package/skills/vault-sync/validator.sh +29 -10
  201. package/skills/wave-executor/references/wave-loop-scope-manifest.md +5 -3
  202. package/skills/write-executable-plan/SKILL.md +1 -0
  203. package/hooks/agent-teams-h3-test.sh +0 -362
  204. package/scripts/spikes/h3-agent-teams/preflight.sh +0 -53
  205. package/scripts/spikes/h3-agent-teams/run-h3.sh +0 -112
  206. package/scripts/spikes/h3-agent-teams/setup.sh +0 -137
  207. package/scripts/spikes/h3-agent-teams/toggle.sh +0 -38
@@ -5,17 +5,29 @@
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';
9
- import { join, resolve, sep } from 'node:path';
8
+ import { readFileSync, writeFileSync, mkdirSync, existsSync, readdirSync, statSync } from 'node:fs';
9
+ import { basename, join, resolve, sep } from 'node:path';
10
10
  import { createRequire } from 'node:module';
11
11
  import { randomUUID } from 'node:crypto';
12
12
  import { createSecretValueMasker } from '../secret-masker.mjs';
13
- import { subjectToSlug, isValidSlug, uuidPrefix8, toDate, parseFrontmatter } from './utils.mjs';
13
+ import { subjectToSlug, isValidSlug, uuidPrefix8, recordKeySuffix8, toDate, parseFrontmatter } from './utils.mjs';
14
14
  import { isRealSession } from '../session-schema/filters.mjs';
15
15
  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
  /**
@@ -329,6 +423,44 @@ function notOurGenerator(fm) {
329
423
  return null;
330
424
  }
331
425
 
426
+ /**
427
+ * The disambiguation slug a record uses under `slug` in `dir` (#1506.1). An
428
+ * EXISTING `<slug>-<uuid8>` note that is ours and owned by this record keeps
429
+ * its name — the owner decision was "no mass rename"; every other case gets the
430
+ * full-record-key hash suffix ({@link recordKeySuffix8}), so non-UUID ids that
431
+ * share their first 8 chars no longer land on one file.
432
+ *
433
+ * @param {string} dir - the namespace folder.
434
+ * @param {string} slug - the canonical note slug.
435
+ * @param {{ legacyId: string, recordKey: string }} keys - `legacyId` is what the
436
+ * pre-#1506 suffix was derived from (the raw id); `recordKey` the full key.
437
+ * @returns {{ slug: string, exists: boolean|null }} `exists: true` when the
438
+ * legacy name was chosen (it was just probed); `null` when not yet probed.
439
+ */
440
+ function disambigSlugFor(dir, slug, { legacyId, recordKey }) {
441
+ const legacy = `${slug}-${uuidPrefix8(legacyId)}`;
442
+ const hashed = `${slug}-${recordKeySuffix8(recordKey)}`;
443
+ const legacyPath = join(dir, `${legacy}.md`);
444
+ if (legacy !== hashed && existsSync(legacyPath)) {
445
+ const fm = parseFrontmatter(readFileSync(legacyPath, 'utf8'));
446
+ if (notOurGenerator(fm) === null && foreignOwnerReason(fm, { recordKey }) === null) {
447
+ return { slug: legacy, exists: true };
448
+ }
449
+ }
450
+ return { slug: hashed, exists: null };
451
+ }
452
+
453
+ /**
454
+ * Replace the frontmatter `id:` line of a note — used when a displaced note
455
+ * moves to its disambiguated name (#1506.2), whose `id` must match the file.
456
+ * @param {string} content
457
+ * @param {string} id
458
+ * @returns {string}
459
+ */
460
+ function withNoteId(content, id) {
461
+ return content.replace(/^id: .*$/m, `id: ${id}`);
462
+ }
463
+
332
464
  /**
333
465
  * Emit `skipped-foreign-owner` for a note the writer must not touch: one stderr
334
466
  * `SKIP` line (vault-relative path, like every other SKIP line) and the stdout
@@ -763,6 +895,47 @@ function maskEntrySecrets(entry) {
763
895
 
764
896
  // ── Core processing ───────────────────────────────────────────────────────────
765
897
 
898
+ /**
899
+ * The canonical note slug of a learning (extracted from processLearning for
900
+ * #1513 so the archive pass derives the SAME path). See the #725 D1 / #635
901
+ * notes inside.
902
+ *
903
+ * @param {object} entry normalised learning entry
904
+ * @param {'v1'|'v2'} schema
905
+ * @returns {string}
906
+ */
907
+ function learningSlug(entry, schema) {
908
+ const entryId = entry.id;
909
+ let slugSource;
910
+ if (schema === 'v2') {
911
+ slugSource = entry.id;
912
+ } else if (typeof entry.subject === 'string') {
913
+ slugSource = entry.subject.trim().replace(/\s+/g, '-');
914
+ } else {
915
+ slugSource = entry.subject;
916
+ }
917
+ let slug;
918
+ if (typeof slugSource === 'string' && slugSource.length > 0) {
919
+ slug = subjectToSlug(slugSource);
920
+ } else {
921
+ slug = '';
922
+ }
923
+ if (!isValidSlug(slug)) {
924
+ slug = `learning-${uuidPrefix8(entryId)}`;
925
+ }
926
+ // #635: cap the slug so `<slug>.md` (plus a possible `-<uuid8>` disambig
927
+ // suffix) stays under the 255-byte filename limit. Normalized prose subjects
928
+ // can be arbitrarily long and previously aborted the whole mirror run with
929
+ // ENAMETOOLONG. 240 + 9 (disambig) + 3 (.md) = 252 — and every pre-existing
930
+ // vault note (max observed slug: 208 chars) keeps its identity untouched.
931
+ if (slug.length > 240) {
932
+ slug = slug.slice(0, 240).replace(/-+$/, '');
933
+ }
934
+
935
+ return slug;
936
+ }
937
+
938
+
766
939
  export async function processLearning(rawEntry, _lineNum, ctx) {
767
940
  const {
768
941
  vaultDir,
@@ -806,34 +979,20 @@ export async function processLearning(rawEntry, _lineNum, ctx) {
806
979
  // subject contract pinned by 15 tests in tests/unit/vault-mirror.test.mjs. The
807
980
  // pre-map is the mechanism the brief itself names and yields the IDENTICAL slug
808
981
  // 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
- }
982
+ let slug = learningSlug(entry, schema);
834
983
 
835
984
  // Generator + date source differ by schema
836
- const generator = schema === 'v2' ? generateLearningNoteV2 : generateLearningNote;
985
+ const baseGenerator = schema === 'v2' ? generateLearningNoteV2 : generateLearningNote;
986
+ // #1513: an expired learning (v1 `expires_at` before today) is never CREATED,
987
+ // and an existing note of it is re-rendered as `status: archived` +
988
+ // `archived-reason: expired` (the canonical-field diff sees the status change
989
+ // and writes it once; afterwards the note is a noop). v2 records carry no
990
+ // expiry. The pruner moves such notes out of the active zone.
991
+ const expiresDay = schema === 'v1' && typeof entry.expires_at === 'string' ? toDate(entry.expires_at) : '';
992
+ const expired = /^\d{4}-\d{2}-\d{2}$/.test(expiresDay) && expiresDay < todayOf(ctx);
993
+ const generator = expired
994
+ ? (e, s, o) => markArchived(baseGenerator(e, s, o), 'expired')
995
+ : baseGenerator;
837
996
  const dateSource = schema === 'v2' ? entry.first_seen : entry.created_at;
838
997
 
839
998
  // #704: Build a noteExists predicate from the vault's 50-sessions index so that
@@ -861,10 +1020,13 @@ export async function processLearning(rawEntry, _lineNum, ctx) {
861
1020
 
862
1021
  // #660: namespace new writes under a per-repo subdirectory. #1389: `ctx.repoRoot`
863
1022
  // is optional — absent, resolveRepoNamespace falls back to process.cwd().
864
- const repoNs = resolveRepoNamespace({
865
- vaultName: ctx?.vaultName ?? null,
866
- repoRoot: ctx?.repoRoot ?? null,
867
- });
1023
+ // #1513 review M2: a hyphen alias (`gotzendorferv2`) resolves to the
1024
+ // canonical folder already in the vault (`gotzendorfer-v2`), so the mirror
1025
+ // and the pruner agree on where a repo's notes live.
1026
+ const repoNs = canonicalNamespace(
1027
+ vaultDir,
1028
+ resolveRepoNamespace({ vaultName: ctx?.vaultName ?? null, repoRoot: ctx?.repoRoot ?? null }),
1029
+ );
868
1030
  // #725 D2: thread the resolved repo namespace into the learning frontmatter as
869
1031
  // `source-repo` for cross-repo attribution. repoNs is already sanitised +
870
1032
  // leak-guarded by resolveRepoNamespace, so it is safe to interpolate as-is. The
@@ -877,11 +1039,78 @@ export async function processLearning(rawEntry, _lineNum, ctx) {
877
1039
  // so two records sharing a subject keep two stable notes instead of
878
1040
  // overwriting each other on every run.
879
1041
  const writer = { recordKey: learningSourceRecord(entry) };
1042
+
1043
+ /**
1044
+ * #1513: the two reasons a NEW learning note is not created. Consulted only
1045
+ * where a file would come into existence — updates of existing notes are
1046
+ * never refused here. Returns the emitted action, or null to proceed.
1047
+ *
1048
+ * - `skipped-expired`: the learning is past `expires_at`; a note for it
1049
+ * would be dead on arrival.
1050
+ * - `skipped-duplicate-insight`: a mirror-owned, non-archived note in this
1051
+ * namespace (or the flat legacy layout) already carries the same
1052
+ * normalised insight — the same learning under another slug or id.
1053
+ */
1054
+ const refuseNewNote = (content) => {
1055
+ if (expired) {
1056
+ return emitEntryAction(_lineNum, ctx, {
1057
+ action: 'skipped-expired',
1058
+ path: null,
1059
+ id: entryId,
1060
+ meta: { reason: `expires:${expiresDay} < today:${todayOf(ctx)}` },
1061
+ });
1062
+ }
1063
+ const insightKey = noteInsightKey(content);
1064
+ if (insightKey) {
1065
+ const twin = getInsightIndex([targetDir], join(resolve(vaultDir), '40-learnings'), repoNs).get(insightKey);
1066
+ if (twin !== undefined) {
1067
+ return emitEntryAction(_lineNum, ctx, {
1068
+ action: 'skipped-duplicate-insight',
1069
+ path: twin,
1070
+ id: entryId,
1071
+ meta: { reason: 'same normalised insight as an existing note' },
1072
+ });
1073
+ }
1074
+ }
1075
+ return null;
1076
+ };
880
1077
  const targetDir = join(resolve(vaultDir), '40-learnings', repoNs);
881
1078
  if (!dryRun) mkdirSync(targetDir, { recursive: true });
882
1079
 
883
1080
  let targetPath = join(targetDir, `${slug}.md`);
884
1081
 
1082
+ // #1513 LEGACY SLUG: until #725 D1 a v1 subject was slugged WITHOUT the
1083
+ // whitespace → hyphen pre-map ("3parallelimplagents…"); the canonical form
1084
+ // hyphenates ("3-parallel-impl-agents…"). When only the legacy variant of
1085
+ // THIS record's note exists, it IS the note: it is updated in place under
1086
+ // its own name instead of a second file being created beside it (the vault
1087
+ // carried 364 such twins). New notes always get the canonical name.
1088
+ if (schema === 'v1') {
1089
+ const legacySlug = legacyConcatSlug(entry.subject);
1090
+ const flatDir = join(resolve(vaultDir), '40-learnings');
1091
+ if (
1092
+ isValidSlug(legacySlug) &&
1093
+ legacySlug !== slug &&
1094
+ !existsSync(targetPath) &&
1095
+ !existsSync(join(flatDir, `${slug}.md`))
1096
+ ) {
1097
+ for (const dir of [targetDir, flatDir]) {
1098
+ const legacyPath = join(dir, `${legacySlug}.md`);
1099
+ if (!existsSync(legacyPath)) continue;
1100
+ const legacyFm = parseFrontmatter(readFileSync(legacyPath, 'utf8'));
1101
+ if (
1102
+ notOurGenerator(legacyFm) === null &&
1103
+ legacyFm['id'] === legacySlug &&
1104
+ foreignOwnerReason(legacyFm, writer) === null
1105
+ ) {
1106
+ slug = legacySlug;
1107
+ targetPath = join(targetDir, `${legacySlug}.md`);
1108
+ break;
1109
+ }
1110
+ }
1111
+ }
1112
+ }
1113
+
885
1114
  // #660 IDEMPOTENCY DUAL-PROBE: before treating the namespaced path as absent,
886
1115
  // also check the legacy flat path. If a note with the same slug already exists
887
1116
  // in the flat layout (pre-namespace migration), treat it as existing to avoid
@@ -963,26 +1192,58 @@ export async function processLearning(rawEntry, _lineNum, ctx) {
963
1192
  // that note is masked in place first — never by rewriting it from THIS
964
1193
  // record, which would be the #1503 overwrite triggered by a secret.
965
1194
  //
966
- // BV-004 ceiling: (1) the main `<slug>.md` keeps the FIRST record that
967
- // claimed it — after a re-learn it goes stale while the current record lives
968
- // at `<slug>-<uuid8>.md`; (2) disambiguation is ONE level deep — a
969
- // `-<uuid8>` file owned by yet another record is skipped-foreign-owner.
970
- // Fine while re-learns are rare (fleet census at review time: 39 slugs in 12
971
- // repos). Revisit-Trigger: a stale main note is read as current, or a
972
- // skipped-foreign-owner is reported on a `-<uuid8>` path.
1195
+ // #1506.2: the NEWEST record owns the main `<slug>.md` — a re-learned
1196
+ // record (strictly later `created` day) takes it over, and the displaced
1197
+ // note moves, bytes kept but `id` renamed, to its own record's
1198
+ // disambiguation path; never lost, never overwritten. #1506.1: a NEW
1199
+ // disambiguation file is suffixed with a hash of the full record key, so
1200
+ // non-UUID ids sharing a prefix no longer collide; existing `-<uuid8>`
1201
+ // files keep their names.
1202
+ //
1203
+ // BV-004 ceiling: "newest" is compared at DAY granularity (`created` is a
1204
+ // date) — a same-day re-learn keeps the current owner (stable, no churn).
1205
+ // Revisit-Trigger: a same-day re-learn is reported as a stale main note.
1206
+ //
1207
+ // BV-004 ceiling: the takeover ignores repo provenance — a NEWER record of
1208
+ // another repo mirrored into this namespace (the #1503 shape) also takes the
1209
+ // main note, and `source-repo` cannot tell it apart (both carry the writer's
1210
+ // namespace). Nothing is lost (the displaced note moves, never overwritten);
1211
+ // the CLI's `source-repo-mismatch` refusal is what keeps foreign records out.
1212
+ // Revisit-Trigger: a takeover whose displaced note belongs to this repo's
1213
+ // live store, or any mirror run accepting a foreign `--source` again.
973
1214
  const recordMismatch = foreignOwnerReason(fm, writer);
974
1215
  const routedByRecord = recordMismatch !== null;
975
1216
  if (routedByRecord && maskerWouldChange(existingContent)) {
976
1217
  healedForeignLeak = maskForeignNoteInPlace(targetPath, existingContent, ctx);
977
1218
  }
978
1219
 
1220
+ const entryDay = toDate(dateSource);
1221
+ if (
1222
+ routedByRecord &&
1223
+ fm['id'] === slug &&
1224
+ !expired &&
1225
+ /^\d{4}-\d{2}-\d{2}$/.test(entryDay) &&
1226
+ typeof fm['created'] === 'string' &&
1227
+ entryDay > fm['created']
1228
+ ) {
1229
+ const takenOver = takeOverMainNote({
1230
+ fm, slug, targetDir, targetPath, entry, entryId, writer, generator, generatorOpts,
1231
+ healedForeignLeak, lineNum: _lineNum, ctx,
1232
+ });
1233
+ if (takenOver !== null) return takenOver;
1234
+ }
1235
+
979
1236
  if (fm['id'] !== slug || routedByRecord) {
980
1237
  // Different id (or different record) → collision: disambiguate
981
- const disambigSlug = `${slug}-${uuidPrefix8(entryId)}`;
1238
+ const picked = disambigSlugFor(targetDir, slug, {
1239
+ legacyId: String(entryId),
1240
+ recordKey: writer.recordKey ?? String(entryId),
1241
+ });
1242
+ const disambigSlug = picked.slug;
982
1243
  targetPath = join(targetDir, `${disambigSlug}.md`);
983
1244
  slug = disambigSlug;
984
1245
 
985
- if (existsSync(targetPath)) {
1246
+ if (picked.exists ?? existsSync(targetPath)) {
986
1247
  // Still exists with disambig — check if it's ours
987
1248
  const disambigContent = readFileSync(targetPath, 'utf8');
988
1249
  const disambigFm = parseFrontmatter(disambigContent);
@@ -1035,6 +1296,10 @@ export async function processLearning(rawEntry, _lineNum, ctx) {
1035
1296
  }
1036
1297
 
1037
1298
  const content = generator(entry, slug, generatorOpts);
1299
+ if (!existsSync(targetPath)) {
1300
+ const refused = refuseNewNote(content);
1301
+ if (refused !== null) return refused;
1302
+ }
1038
1303
  if (!dryRun) writeFileSync(targetPath, content, 'utf8');
1039
1304
  return emitEntryAction(_lineNum, ctx, {
1040
1305
  action: 'skipped-collision-resolved',
@@ -1046,32 +1311,61 @@ export async function processLearning(rawEntry, _lineNum, ctx) {
1046
1311
  });
1047
1312
  }
1048
1313
 
1314
+ // #1506.2: the main note is this record's — a live disambiguation copy of
1315
+ // the same record (left by an interrupted takeover) is superseded.
1316
+ const stale = fm['source-record'] === writer.recordKey
1317
+ ? staleOwnDisambig(targetDir, slug, entryId, writer)
1318
+ : null;
1319
+
1049
1320
  // Same id: check if updated would advance (unless --force overrides).
1050
1321
  // Even when the date has not advanced, content may have changed (confidence,
1051
1322
  // insight, expires_at, etc.) — compare canonical fields before skipping.
1052
1323
  const entryUpdated = toDate(dateSource);
1324
+ let mainUnchanged = false;
1053
1325
  if (!force && fm['updated'] && fm['updated'] >= entryUpdated) {
1054
1326
  const candidateContent = generator(entry, slug, generatorOpts);
1055
1327
  if (learningContentMatches(existingContent, candidateContent)) {
1056
1328
  // #1028 residue 1: probe the on-disk content against the CURRENT masker
1057
1329
  // before trusting the five-field match — see maskerWouldChange above.
1058
- const stillLeaks = maskerWouldChange(existingContent);
1059
- if (!stillLeaks) {
1060
- return emitEntryAction(_lineNum, ctx, { action: 'skipped-noop', path: targetPath, id: slug });
1061
- }
1330
+ mainUnchanged = !maskerWouldChange(existingContent);
1062
1331
  }
1063
1332
  // Content differs, or the on-disk note still leaks under the current env —
1064
1333
  // fall through to overwrite (same path as date-advance branch).
1065
1334
  }
1066
1335
 
1336
+ // #1519.4: archive the stale copy AND refresh the main note in the SAME run
1337
+ // (previously the stale branch returned first, so a simultaneous record
1338
+ // change reached the main note one run later). Stale copy first: a crash
1339
+ // between the two writes leaves an archived copy beside a not-yet-refreshed
1340
+ // main note, which the next run's content diff completes.
1341
+ if (stale !== null && !dryRun) writeFileSync(stale.path, stale.next, 'utf8');
1342
+ if (mainUnchanged) {
1343
+ if (stale !== null) {
1344
+ return emitEntryAction(_lineNum, ctx, {
1345
+ action: 'updated',
1346
+ path: stale.path,
1347
+ id: basename(stale.path, '.md'),
1348
+ meta: { archived_reason: 'superseded' },
1349
+ });
1350
+ }
1351
+ return emitEntryAction(_lineNum, ctx, { action: 'skipped-noop', path: targetPath, id: slug });
1352
+ }
1353
+
1067
1354
  // Overwrite with advanced updated date (or forced re-render)
1068
1355
  const content = generator(entry, slug, generatorOpts);
1069
1356
  if (!dryRun) writeFileSync(targetPath, content, 'utf8');
1070
- return emitEntryAction(_lineNum, ctx, { action: 'updated', path: targetPath, id: slug });
1357
+ return emitEntryAction(_lineNum, ctx, {
1358
+ action: 'updated',
1359
+ path: targetPath,
1360
+ id: slug,
1361
+ ...(stale !== null ? { meta: { superseded_id: basename(stale.path, '.md') } } : {}),
1362
+ });
1071
1363
  }
1072
1364
 
1073
1365
  // File does not exist — create
1074
1366
  const content = generator(entry, slug, generatorOpts);
1367
+ const refused = refuseNewNote(content);
1368
+ if (refused !== null) return refused;
1075
1369
  if (!dryRun) writeFileSync(targetPath, content, 'utf8');
1076
1370
  return emitEntryAction(_lineNum, ctx, {
1077
1371
  action: 'created',
@@ -1084,6 +1378,224 @@ export async function processLearning(rawEntry, _lineNum, ctx) {
1084
1378
  });
1085
1379
  }
1086
1380
 
1381
+ /**
1382
+ * The writer's own disambiguation note when it is stale (#1506.2): the writer's
1383
+ * record owns the main note, so a mirror-owned, not-yet-archived note of the
1384
+ * SAME record (explicit `source-record` match) at its disambiguation name is a
1385
+ * second live copy — it is marked `archived` / `superseded`. Consulted by the
1386
+ * takeover and on every run where the main note is already ours, so a crash
1387
+ * between a takeover's writes heals on the next run.
1388
+ *
1389
+ * @returns {{ path: string, next: string }|null} the rewrite, or null.
1390
+ */
1391
+ function staleOwnDisambig(targetDir, slug, entryId, writer) {
1392
+ if (typeof writer.recordKey !== 'string') return null;
1393
+ const own = disambigSlugFor(targetDir, slug, { legacyId: String(entryId), recordKey: writer.recordKey });
1394
+ const path = join(targetDir, `${own.slug}.md`);
1395
+ if (!(own.exists ?? existsSync(path))) return null;
1396
+ const content = readFileSync(path, 'utf8');
1397
+ const fm = parseFrontmatter(content);
1398
+ if (notOurGenerator(fm) !== null || fm['source-record'] !== writer.recordKey || fm['id'] !== own.slug) return null;
1399
+ const next = markArchived(content, 'superseded');
1400
+ return next === content ? null : { path, next };
1401
+ }
1402
+
1403
+ /**
1404
+ * #1506.2 takeover: the writer's record is newer than the record that owns the
1405
+ * main note, so the writer claims `<slug>.md`. The displaced note moves to its
1406
+ * own record's disambiguation path (`id` renamed, bytes otherwise kept); a
1407
+ * stale disambiguation note of the writer's own record (written while the older
1408
+ * record held the main note) is marked `archived` / `superseded`.
1409
+ *
1410
+ * Returns `null` — leaving the caller on the plain disambiguation route — when
1411
+ * the displaced record's target path is occupied by a note that is not ours or
1412
+ * belongs to yet another record: a takeover must never overwrite anything.
1413
+ *
1414
+ * @returns {string|null} the emitted action, or null.
1415
+ */
1416
+ function takeOverMainNote({
1417
+ fm, slug, targetDir, targetPath, entry, entryId, writer, generator, generatorOpts, healedForeignLeak, lineNum, ctx,
1418
+ }) {
1419
+ const { dryRun } = ctx;
1420
+ const displacedKey = fm['source-record'];
1421
+ const displaced = disambigSlugFor(targetDir, slug, { legacyId: displacedKey, recordKey: displacedKey });
1422
+ const displacedPath = join(targetDir, `${displaced.slug}.md`);
1423
+ if (displaced.exists !== true && existsSync(displacedPath)) {
1424
+ const dfm = parseFrontmatter(readFileSync(displacedPath, 'utf8'));
1425
+ if (notOurGenerator(dfm) !== null || dfm['source-record'] !== displacedKey) return null;
1426
+ }
1427
+
1428
+ const ownStale = staleOwnDisambig(targetDir, slug, entryId, writer);
1429
+
1430
+ if (!dryRun) {
1431
+ // Re-read: an in-place leak heal above may have rewritten the note.
1432
+ const current = readFileSync(targetPath, 'utf8');
1433
+ // Rewritten even when it exists (and is ours, checked above): a stale copy
1434
+ // there may carry `status: archived` from an earlier archive pass.
1435
+ writeFileSync(displacedPath, withNoteId(current, displaced.slug), 'utf8');
1436
+ // Before the main note: a crash between the two writes must leave the
1437
+ // takeover re-runnable (the main note still foreign), never a main note of
1438
+ // ours beside a stale live disambiguation note of the same record.
1439
+ if (ownStale !== null && ownStale.path !== displacedPath) writeFileSync(ownStale.path, ownStale.next, 'utf8');
1440
+ writeFileSync(targetPath, generator(entry, slug, generatorOpts), 'utf8');
1441
+ }
1442
+ return emitEntryAction(lineNum, ctx, {
1443
+ action: 'updated',
1444
+ path: targetPath,
1445
+ id: slug,
1446
+ meta: {
1447
+ reason: 'newer record owns the main note',
1448
+ displaced_id: displaced.slug,
1449
+ ...(healedForeignLeak ? { healed_leak: true } : {}),
1450
+ },
1451
+ });
1452
+ }
1453
+
1454
+ /**
1455
+ * The note slugs a learning record can live under: the canonical slug, for v1
1456
+ * the pre-#725 legacy variant, and its two disambiguation names — the
1457
+ * pre-#1506 `-<uuid8>` and the full-key `-<hash8>` (#1506.1). Used by the CLI
1458
+ * to keep the archive pass off notes a LIVE record still owns (#1513). The
1459
+ * disambiguation names are record-specific, so a record displaced from the
1460
+ * main note by a re-learn (#1506.2) is still archived once it leaves the store.
1461
+ *
1462
+ * @param {object} rawEntry
1463
+ * @returns {string[]}
1464
+ */
1465
+ export function learningNoteSlugs(rawEntry) {
1466
+ const entry = normalizeLearningEntry(rawEntry);
1467
+ if (!entry || entry.id === null || entry.id === undefined) return [];
1468
+ const schema = detectLearningSchema(entry);
1469
+ const slugs = [learningSlug(entry, schema)];
1470
+ if (schema === 'v1') {
1471
+ const legacy = legacyConcatSlug(entry.subject);
1472
+ if (isValidSlug(legacy) && !slugs.includes(legacy)) slugs.push(legacy);
1473
+ }
1474
+ const id = String(entry.id);
1475
+ for (const name of [`${slugs[0]}-${uuidPrefix8(id)}`, `${slugs[0]}-${recordKeySuffix8(learningSourceRecord(entry) ?? id)}`]) {
1476
+ if (!slugs.includes(name)) slugs.push(name);
1477
+ }
1478
+ return slugs;
1479
+ }
1480
+
1481
+ /**
1482
+ * Mark the existing note of a record that left `learnings.jsonl` (#1513).
1483
+ *
1484
+ * Input is one `learnings-archive.jsonl` record. Its note — canonical slug,
1485
+ * legacy slug, or a `-<uuid8>`/`-<hash8>` disambiguation, in the namespace or the flat
1486
+ * legacy layout — gets `status: archived` + `archived-reason` (`expired` for an
1487
+ * `_archive_reason: expired` record, `superseded` for every other reason). The
1488
+ * note's bytes are otherwise kept; nothing is ever created. Ownership rules are
1489
+ * the same as for writes: mirror-owned, and a `source-record` naming another
1490
+ * record is left alone. Names in `ctx.liveLearningSlugs` (records still in the
1491
+ * live store) are skipped, so a re-learned subject never flip-flops.
1492
+ *
1493
+ * @returns {Promise<string>} the emitted action
1494
+ */
1495
+ export async function processArchivedLearning(rawEntry, _lineNum, ctx) {
1496
+ const { vaultDir, dryRun } = ctx;
1497
+ const entry = maskEntrySecrets(normalizeLearningEntry(rawEntry));
1498
+ if (!entry || entry.id === null || entry.id === undefined) {
1499
+ throw new Error(`vault-mirror: archived learning entry missing required field 'id' (id=<no id>)`);
1500
+ }
1501
+ const reason = entry._archive_reason === 'expired' ? 'expired' : 'superseded';
1502
+ const live = ctx?.liveLearningSlugs instanceof Set ? ctx.liveLearningSlugs : new Set();
1503
+ // Per name, not per record (#1506.2): a live re-learn owns the canonical
1504
+ // slug, but the displaced note at THIS record's disambiguation name is still
1505
+ // this record's to archive.
1506
+ const allNames = learningNoteSlugs(entry);
1507
+ const names = allNames.filter((s) => !live.has(s));
1508
+ const liveOwned = () =>
1509
+ emitEntryAction(_lineNum, ctx, {
1510
+ // `skipped-noop`, not a class of its own: this is the steady state of
1511
+ // every run, and noops stay out of the per-entry ledger (#1151).
1512
+ action: 'skipped-noop',
1513
+ path: null,
1514
+ id: entry.id,
1515
+ meta: { reason: 'archived record: a live record owns this note' },
1516
+ });
1517
+ if (names.length === 0) return liveOwned();
1518
+ const repoNs = canonicalNamespace(
1519
+ vaultDir,
1520
+ resolveRepoNamespace({ vaultName: ctx?.vaultName ?? null, repoRoot: ctx?.repoRoot ?? null }),
1521
+ );
1522
+ const nsDir = join(resolve(vaultDir), '40-learnings', repoNs);
1523
+ const flatDir = join(resolve(vaultDir), '40-learnings');
1524
+ const writer = { recordKey: learningSourceRecord(entry) };
1525
+ for (const dir of [nsDir, flatDir]) {
1526
+ for (const name of names) {
1527
+ const path = join(dir, `${name}.md`);
1528
+ if (!existsSync(path)) continue;
1529
+ const content = readFileSync(path, 'utf8');
1530
+ const fm = parseFrontmatter(content);
1531
+ if (notOurGenerator(fm) !== null || fm['id'] !== name) continue;
1532
+ if (foreignOwnerReason(fm, writer) !== null) continue;
1533
+ const next = markArchived(content, reason);
1534
+ if (next === content) {
1535
+ return emitEntryAction(_lineNum, ctx, { action: 'skipped-noop', path, id: name });
1536
+ }
1537
+ if (!dryRun) writeFileSync(path, next, 'utf8');
1538
+ return emitEntryAction(_lineNum, ctx, {
1539
+ action: 'updated',
1540
+ path,
1541
+ id: name,
1542
+ meta: { archived_reason: reason },
1543
+ });
1544
+ }
1545
+ }
1546
+ if (names.length < allNames.length) return liveOwned();
1547
+ return emitEntryAction(_lineNum, ctx, {
1548
+ action: 'skipped-noop',
1549
+ path: null,
1550
+ id: entry.id,
1551
+ meta: { reason: 'archived record: no note in the vault' },
1552
+ });
1553
+ }
1554
+
1555
+ /**
1556
+ * Route a metrics-only session record (#1513): a note that already exists for
1557
+ * it is left exactly as it is (the pruner decides about the bestand), and
1558
+ * otherwise the record becomes one row in its month rollup. Idempotent: an
1559
+ * unchanged rollup is a `skipped-noop`.
1560
+ */
1561
+ async function rollUpSession({ renderedBody, session_id, repoNs, narrativeChars, lineNum, ctx }) {
1562
+ const { vaultDir, dryRun, qualityMinNarrativeChars = 400 } = ctx;
1563
+ const reason = `narrative:${narrativeChars} < min:${qualityMinNarrativeChars}`;
1564
+ const root = resolve(vaultDir);
1565
+ const existingNote = [join(root, '50-sessions', repoNs, `${session_id}.md`), join(root, '50-sessions', `${session_id}.md`)].find(
1566
+ (p) => existsSync(p),
1567
+ );
1568
+ const row = rollupRowFromNote(renderedBody, { id: session_id });
1569
+ if (existingNote !== undefined || row === null) {
1570
+ return emitEntryAction(lineNum, ctx, { action: 'skipped-quality-low', path: null, id: session_id, meta: { reason } });
1571
+ }
1572
+ const month = row.date.slice(0, 7);
1573
+ const rollupPath = join(root, rollupRelPath(repoNs, month));
1574
+ let existing = null;
1575
+ if (existsSync(rollupPath)) {
1576
+ existing = readFileSync(rollupPath, 'utf8');
1577
+ const notOurs = notOurGenerator(parseFrontmatter(existing));
1578
+ if (notOurs !== null) {
1579
+ process.stderr.write(`SKIP ${notOurs}: ${toVaultRelative(rollupPath, vaultDir)}\n`);
1580
+ return emitEntryAction(lineNum, ctx, { action: 'skipped-handwritten', path: rollupPath, id: session_id });
1581
+ }
1582
+ }
1583
+ const next = renderRollup(existing, [row], { ns: repoNs, month });
1584
+ if (next === existing) {
1585
+ return emitEntryAction(lineNum, ctx, { action: 'skipped-noop', path: rollupPath, id: session_id, meta: { reason } });
1586
+ }
1587
+ if (!dryRun) {
1588
+ mkdirSync(join(root, '50-sessions', repoNs), { recursive: true });
1589
+ writeFileSync(rollupPath, next, 'utf8');
1590
+ }
1591
+ return emitEntryAction(lineNum, ctx, {
1592
+ action: 'skipped-metrics-only',
1593
+ path: rollupPath,
1594
+ id: session_id,
1595
+ meta: { reason },
1596
+ });
1597
+ }
1598
+
1087
1599
  export async function processSession(rawEntry, _lineNum, ctx) {
1088
1600
  const {
1089
1601
  vaultDir,
@@ -1159,32 +1671,28 @@ export async function processSession(rawEntry, _lineNum, ctx) {
1159
1671
  // owner-leaky repo's real name reached the vault through the session-note
1160
1672
  // frontmatter even though the directory AND the learning `source-repo` field
1161
1673
  // were already pseudonym-mapped/redacted (#732 leak-guard bypass).
1162
- const repoNs = resolveRepoNamespace({
1163
- vaultName: ctx?.vaultName ?? null,
1164
- repoRoot: ctx?.repoRoot ?? null,
1165
- });
1674
+ const repoNs = canonicalNamespace(
1675
+ vaultDir,
1676
+ resolveRepoNamespace({ vaultName: ctx?.vaultName ?? null, repoRoot: ctx?.repoRoot ?? null }),
1677
+ );
1166
1678
 
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.
1679
+ // Narrative gate (#1513, replaces the PRD F1.2 rendered-length gate). A
1680
+ // session gets its own note only when its FREE-TEXT fields (`notes`,
1681
+ // `narrative`, `summary` — see retention.mjs) carry at least
1682
+ // `qualityMinNarrativeChars` characters. The old gate measured the rendered
1683
+ // body INCLUDING the wave/agent tables, so nearly every record passed: 2.728
1684
+ // of 3.003 vault session notes were tables with no information beyond git
1685
+ // (Jev audit 2026-10-03). A record below the gate becomes one row in the
1686
+ // month rollup `50-sessions/<ns>/_rollup-YYYY-MM.md` instead. `0` disables
1687
+ // the gate. Runs BEFORE --force; --force does NOT bypass it.
1688
+ //
1689
+ // The render still happens first: a generator `vault-mirror: …` validation
1690
+ // error must keep surfacing as `skipped-invalid`, and the rollup row is read
1691
+ // off the rendered note so the mirror and the pruner share one parser.
1176
1692
  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;
1693
+ const narrativeChars = sessionNarrativeChars(entry);
1181
1694
  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
- });
1695
+ return rollUpSession({ renderedBody, session_id, repoNs, narrativeChars, lineNum: _lineNum, ctx });
1188
1696
  }
1189
1697
 
1190
1698
  // #660: namespace new writes under a per-repo subdirectory. repoNs was
@@ -1239,7 +1747,8 @@ export async function processSession(rawEntry, _lineNum, ctx) {
1239
1747
  }
1240
1748
  if (legacyMarked && !legacyForeign) {
1241
1749
  const entryUpdated = toDate(entry.completed_at);
1242
- if (!force && !legacyStillLeaks && legacyFm['updated'] && legacyFm['updated'] >= entryUpdated) {
1750
+ const legacyNarrativeStale = noteNarrativeChars(legacyContent) !== noteNarrativeChars(renderedBody);
1751
+ if (!force && !legacyStillLeaks && !legacyNarrativeStale && legacyFm['updated'] && legacyFm['updated'] >= entryUpdated) {
1243
1752
  return emitEntryAction(_lineNum, ctx, { action: 'skipped-noop', path: legacyFlatPath, id: session_id });
1244
1753
  }
1245
1754
  // Updated date would advance, or the on-disk note still leaks — fall
@@ -1285,7 +1794,11 @@ export async function processSession(rawEntry, _lineNum, ctx) {
1285
1794
  }
1286
1795
 
1287
1796
  const entryUpdated = toDate(entry.completed_at);
1288
- if (!force && !existingLeaks && fm['updated'] && fm['updated'] >= entryUpdated) {
1797
+ // #1513 review H2: a note rendered before narrative/summary were printed
1798
+ // carries less free text than the record — heal it once, or the pruner
1799
+ // archives it as metrics-only and the mirror re-creates it every run.
1800
+ const narrativeStale = noteNarrativeChars(existingContent) !== noteNarrativeChars(renderedBody);
1801
+ if (!force && !existingLeaks && !narrativeStale && fm['updated'] && fm['updated'] >= entryUpdated) {
1289
1802
  return emitEntryAction(_lineNum, ctx, { action: 'skipped-noop', path: targetPath, id: session_id });
1290
1803
  }
1291
1804
  if (!dryRun) writeFileSync(targetPath, renderedBody, 'utf8');