session-orchestrator 5.3.0 → 5.5.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 (260) hide show
  1. package/.agents/skills/claude-md-drift-check/SKILL.md +1 -1
  2. package/.agents/skills/test-audit/SKILL.md +21 -0
  3. package/.agents/skills/test-audit/agents/openai.yaml +5 -0
  4. package/.claude-plugin/marketplace.json +1 -1
  5. package/.claude-plugin/plugin.json +1 -1
  6. package/.codex-plugin/plugin.json +1 -1
  7. package/.codex-plugin/skills/claude-md-drift-check/SKILL.md +1 -1
  8. package/.codex-plugin/skills/test-audit/SKILL.md +21 -0
  9. package/.codex-plugin/skills/test-audit/agents/openai.yaml +5 -0
  10. package/.cursor/commands/test-audit.md +14 -0
  11. package/.cursor/rules/060-evolve.mdc +28 -13
  12. package/.cursor/skills/claude-md-drift-check/SKILL.md +1 -1
  13. package/.cursor/skills/test-audit/SKILL.md +12 -0
  14. package/.cursor-plugin/plugin.json +1 -1
  15. package/.orchestrator/policy/blocked-commands.json +1 -1
  16. package/AGENTS.md +9 -31
  17. package/CHANGELOG.md +162 -0
  18. package/README.md +14 -10
  19. package/docs/agent-authoring.md +2 -2
  20. package/docs/baseline.md +10 -1
  21. package/docs/ci-setup.md +154 -4
  22. package/docs/components.md +11 -11
  23. package/docs/events-schema.md +25 -19
  24. package/docs/install.md +14 -2
  25. package/docs/migration-v4.md +24 -17
  26. package/docs/persona-panel.md +5 -4
  27. package/docs/plugin-architecture-v3.md +1 -1
  28. package/docs/rule-authoring.md +27 -12
  29. package/docs/scope-collision-guard.md +40 -16
  30. package/docs/session-config-reference.md +61 -12
  31. package/docs/session-config-template.md +11 -2
  32. package/docs/vault-docs-architecture.md +8 -4
  33. package/hooks/_lib/hook-import-set.json +42 -29
  34. package/hooks/_lib/lock-bootstrap.mjs +118 -26
  35. package/hooks/config-protection.mjs +1 -1
  36. package/hooks/enforce-commands.mjs +28 -13
  37. package/hooks/enforce-scope.mjs +201 -23
  38. package/hooks/hooks-codex.json +1 -1
  39. package/hooks/hooks-cursor.json +5 -0
  40. package/hooks/hooks-pi.json +5 -0
  41. package/hooks/hooks.json +6 -1
  42. package/hooks/on-session-start.mjs +57 -2
  43. package/hooks/on-stop.mjs +70 -168
  44. package/hooks/post-bash-write-verify.mjs +98 -17
  45. package/hooks/post-edit-validate.mjs +15 -5
  46. package/hooks/post-tool-batch-wave-signal.mjs +32 -152
  47. package/hooks/pre-bash-memory-propose-audit.mjs +71 -10
  48. package/hooks/pre-bash-sessions-ledger-guard.mjs +28 -2
  49. package/hooks/pre-bash-staging-fence.mjs +29 -8
  50. package/hooks/pre-edit-sessions-ledger-guard.mjs +162 -0
  51. package/hooks/pre-task-scope-disjoint.mjs +1143 -279
  52. package/hooks/subagent-telemetry.mjs +96 -19
  53. package/hooks/wave-scope-commit-guard.mjs +168 -72
  54. package/package.json +2 -6
  55. package/pi/prompts/test-audit.md +12 -0
  56. package/rules/README.md +6 -1
  57. package/rules/_index.md +3 -3
  58. package/rules/always-on/bash-harness-pitfalls.md +7 -1
  59. package/rules/opt-in-domain/prompt-caching.md +5 -1
  60. package/rules/opt-in-stack/backend-data.md +9 -1
  61. package/rules/opt-in-stack/backend.md +7 -1
  62. package/rules/opt-in-stack/frontend.md +6 -1
  63. package/rules/opt-in-stack/security-web.md +7 -1
  64. package/rules/opt-in-stack/swift.md +5 -1
  65. package/scripts/apply-session-learnings.mjs +513 -0
  66. package/scripts/archive-closed-prds.mjs +40 -12
  67. package/scripts/autopilot.mjs +57 -54
  68. package/scripts/backfill-abandoned-sessions.mjs +81 -6
  69. package/scripts/backfill-learnings-from-vault.mjs +49 -22
  70. package/scripts/baseline-archetypes.mjs +1 -1
  71. package/scripts/check-package-manager.mjs +62 -5
  72. package/scripts/codex-install.mjs +3 -0
  73. package/scripts/cursor-install.mjs +47 -6
  74. package/scripts/emit-session.mjs +298 -2
  75. package/scripts/export-hw-learnings.mjs +40 -10
  76. package/scripts/fleet-checkin.mjs +170 -0
  77. package/scripts/lib/agent-frontmatter.mjs +3 -3
  78. package/scripts/lib/auto-dream.mjs +7 -6
  79. package/scripts/lib/autopilot/flags.mjs +6 -1
  80. package/scripts/lib/autopilot/loop.mjs +32 -13
  81. package/scripts/lib/autopilot/stall-sampler.mjs +13 -9
  82. package/scripts/lib/baseline-archetypes.mjs +41 -22
  83. package/scripts/lib/blocked-commands-policy.mjs +40 -0
  84. package/scripts/lib/common.mjs +41 -0
  85. package/scripts/lib/config/host-paths.mjs +79 -0
  86. package/scripts/lib/config/persona-gate-wave.mjs +3 -3
  87. package/scripts/lib/config/reaper.mjs +7 -6
  88. package/scripts/lib/config/vault-integration.mjs +151 -16
  89. package/scripts/lib/config-schema.mjs +13 -0
  90. package/scripts/lib/config.mjs +75 -10
  91. package/scripts/lib/eval/engine.mjs +13 -0
  92. package/scripts/lib/events-retention-banner.mjs +393 -0
  93. package/scripts/lib/events-rotation.mjs +21 -6
  94. package/scripts/lib/events.mjs +72 -5
  95. package/scripts/lib/fleet-protocol.mjs +288 -0
  96. package/scripts/lib/gates/gate-baseline.mjs +13 -3
  97. package/scripts/lib/gates/gate-full.mjs +7 -2
  98. package/scripts/lib/gates/gate-helpers.mjs +93 -2
  99. package/scripts/lib/gates/gate-incremental.mjs +23 -22
  100. package/scripts/lib/gates/gate-per-file.mjs +16 -7
  101. package/scripts/lib/hardening.mjs +1 -0
  102. package/scripts/lib/harness-audit/categories/category4.mjs +8 -6
  103. package/scripts/lib/instruction-budget-guard.mjs +77 -11
  104. package/scripts/lib/learnings/expiry-sweep.mjs +420 -22
  105. package/scripts/lib/learnings/io.mjs +274 -37
  106. package/scripts/lib/learnings/schema.mjs +163 -15
  107. package/scripts/lib/learnings/sizing-subject.mjs +2 -2
  108. package/scripts/lib/lock-reaper.mjs +38 -14
  109. package/scripts/lib/maintenance-due-banner.mjs +23 -8
  110. package/scripts/lib/memory-proposals/schema.mjs +11 -5
  111. package/scripts/lib/named-baseline-resolver.mjs +53 -28
  112. package/scripts/lib/named-vault-resolver.mjs +147 -5
  113. package/scripts/lib/orphan-reaper/defaults.mjs +41 -0
  114. package/scripts/lib/orphan-reaper/no-follow-append.mjs +87 -0
  115. package/scripts/lib/orphan-reaper/ps-snapshot.mjs +181 -0
  116. package/scripts/lib/orphan-reaper/reaper-audit.mjs +323 -0
  117. package/scripts/lib/orphan-reaper/reaper-decide.mjs +395 -0
  118. package/scripts/lib/orphan-reaper/scan-throttle.mjs +144 -0
  119. package/scripts/lib/orphan-reaper/scan.mjs +510 -0
  120. package/scripts/lib/orphan-reaper/trigger.mjs +173 -0
  121. package/scripts/lib/orphan-reaper.mjs +47 -1315
  122. package/scripts/lib/owner-yaml.mjs +104 -6
  123. package/scripts/lib/persona-panel/catalog-loader.mjs +1 -1
  124. package/scripts/lib/platform.mjs +206 -2
  125. package/scripts/lib/plugin-update-banner.mjs +3 -1
  126. package/scripts/lib/process-group.mjs +134 -22
  127. package/scripts/lib/project-hygiene.mjs +21 -1
  128. package/scripts/lib/quality-gate.mjs +45 -5
  129. package/scripts/lib/resource-probe.mjs +1 -1
  130. package/scripts/lib/rule-loader.mjs +100 -62
  131. package/scripts/lib/rules-sync.mjs +27 -12
  132. package/scripts/lib/scope-baseline.mjs +119 -29
  133. package/scripts/lib/scope-echo.mjs +7 -0
  134. package/scripts/lib/scope-gate.mjs +194 -27
  135. package/scripts/lib/session-close-backfill.mjs +129 -4
  136. package/scripts/lib/session-end/tail-runner.mjs +10 -5
  137. package/scripts/lib/session-lock-shape.mjs +6 -3
  138. package/scripts/lib/session-lock.mjs +320 -67
  139. package/scripts/lib/session-record-repair.mjs +183 -26
  140. package/scripts/lib/session-registry.mjs +67 -17
  141. package/scripts/lib/session-schema/constants.mjs +30 -4
  142. package/scripts/lib/session-schema/validator.mjs +28 -1
  143. package/scripts/lib/session-start-probes.mjs +242 -68
  144. package/scripts/lib/session-token-rollup.mjs +252 -54
  145. package/scripts/lib/sessions-canonical.mjs +100 -9
  146. package/scripts/lib/sessions-staleness-banner.mjs +3 -1
  147. package/scripts/lib/sh-quote.mjs +29 -0
  148. package/scripts/lib/subagents-schema.mjs +30 -3
  149. package/scripts/lib/tail-window.mjs +27 -3
  150. package/scripts/lib/telemetry/pricing.mjs +121 -71
  151. package/scripts/lib/telemetry/sync.mjs +17 -0
  152. package/scripts/lib/tests-src-ratio.mjs +215 -23
  153. package/scripts/lib/tmux-layout/layouts.mjs +1 -11
  154. package/scripts/lib/tmux-layout/telemetry-stats.mjs +12 -0
  155. package/scripts/lib/tmux-layout/tmux-shell.mjs +2 -11
  156. package/scripts/lib/ux-grill/reconcile.mjs +1 -1
  157. package/scripts/lib/validate/check-agents.mjs +1 -1
  158. package/scripts/lib/validate/check-hooks-symmetry.mjs +5 -0
  159. package/scripts/lib/validate/check-learnings-shell-writes.mjs +214 -0
  160. package/scripts/lib/validate/check-test-value-bans.mjs +388 -27
  161. package/scripts/lib/validate/check-unicode-safety.mjs +1 -0
  162. package/scripts/lib/validate/check-unwired-features.mjs +1 -1
  163. package/scripts/lib/validate/check-vcs-repo-flag.mjs +1 -1
  164. package/scripts/lib/validate-vendored-rules.mjs +206 -81
  165. package/scripts/lib/vault-mirror/auto-commit.mjs +5 -3
  166. package/scripts/lib/vault-mirror/namespace.mjs +47 -26
  167. package/scripts/lib/vault-mirror/process.mjs +350 -62
  168. package/scripts/lib/vault-mirror/pseudonym-map.mjs +14 -5
  169. package/scripts/lib/vault-mirror/render-learnings.mjs +66 -3
  170. package/scripts/lib/vault-mirror/render-sessions.mjs +28 -4
  171. package/scripts/lib/vault-mirror/telemetry.mjs +25 -11
  172. package/scripts/lib/vault-status/board-writer.mjs +53 -12
  173. package/scripts/lib/vault-status/narrative-mirror.mjs +40 -9
  174. package/scripts/lib/vcs-repo-spec.mjs +8 -0
  175. package/scripts/lib/wave-executor/dispatch-common.mjs +10 -18
  176. package/scripts/lib/wave-executor/foreign-dispatch.mjs +1 -1
  177. package/scripts/lib/wave-executor/remote-dispatch.mjs +1 -1
  178. package/scripts/lib/wave-resource-gate.mjs +34 -8
  179. package/scripts/materialize-wave-scope.mjs +7 -4
  180. package/scripts/memory-propose.mjs +18 -5
  181. package/scripts/release-session-lock.mjs +2 -1
  182. package/scripts/release.mjs +11 -3
  183. package/scripts/repair-invalid-sessions.mjs +37 -4
  184. package/scripts/run-migrate-v2-cross-repo.mjs +171 -31
  185. package/scripts/run-quality-gate.mjs +331 -307
  186. package/scripts/self-update.mjs +229 -0
  187. package/scripts/site-numbers.mjs +163 -5
  188. package/scripts/sweep-expired-learnings.mjs +603 -20
  189. package/scripts/sweep-expired-rules.mjs +2 -1
  190. package/scripts/validate-plugin.mjs +7 -0
  191. package/scripts/validate-wave-scope.mjs +21 -2
  192. package/scripts/vault-backfill.mjs +50 -37
  193. package/scripts/vault-integration-watcher.mjs +2 -1
  194. package/scripts/vault-mirror.mjs +238 -75
  195. package/scripts/wave-scope-binding.mjs +2 -1
  196. package/skills/_shared/config-reading.md +3 -3
  197. package/skills/_shared/fleet-protocol.md +134 -0
  198. package/skills/_shared/state-ownership.md +41 -12
  199. package/skills/autopilot/SKILL.md +25 -11
  200. package/skills/bootstrap/SKILL.md +6 -4
  201. package/skills/bootstrap/_shared-template.md +97 -2
  202. package/skills/bootstrap/fast-template.md +4 -1
  203. package/skills/bootstrap/private-contract.md +2 -1
  204. package/skills/bootstrap/public-fallback.md +6 -3
  205. package/skills/bootstrap/references/bootstrap-refresh-lock-flow.md +5 -3
  206. package/skills/bootstrap/references/bootstrap-retroactive-flow.md +2 -1
  207. package/skills/bootstrap/standard-template.md +2 -0
  208. package/skills/claude-md-drift-check/SKILL.md +10 -10
  209. package/skills/claude-md-drift-check/checker.mjs +26 -18
  210. package/skills/discovery/probes/ssot-code-diff.mjs +12 -6
  211. package/skills/discovery/probes/vault-narrative-staleness.mjs +6 -2
  212. package/skills/discovery/probes/vault-staleness.mjs +9 -3
  213. package/skills/docs-orchestrator/SKILL.md +12 -5
  214. package/skills/docs-orchestrator/audience-mapping.md +5 -3
  215. package/skills/evolve/SKILL.md +13 -4
  216. package/skills/evolve/references/evolve-analyze-mode.md +42 -18
  217. package/skills/memory-cleanup/SKILL.md +1 -1
  218. package/skills/persona-panel/SKILL.md +2 -2
  219. package/skills/persona-panel/persona-format.md +2 -2
  220. package/skills/persona-panel/presets/designer-lens.md +1 -1
  221. package/skills/persona-panel/presets/engineer-lens.md +1 -1
  222. package/skills/persona-panel/presets/pm-lens.md +1 -1
  223. package/skills/plan/mode-retro.md +19 -4
  224. package/skills/quality-gates/SKILL.md +6 -4
  225. package/skills/remote-offload/SKILL.md +4 -2
  226. package/skills/session-end/learning-patterns.md +23 -13
  227. package/skills/session-end/metrics-collection.md +6 -7
  228. package/skills/session-end/phase-3-2-docs-verification.md +7 -6
  229. package/skills/session-end/plan-verification.md +25 -9
  230. package/skills/session-end/references/phase-3-documentation-updates.md +13 -3
  231. package/skills/session-end/references/phase-4a-worktree-cleanup.md +2 -0
  232. package/skills/session-end/references/session-summary-template.md +1 -1
  233. package/skills/session-end/session-metrics-write.md +65 -68
  234. package/skills/session-plan/SKILL.md +0 -3
  235. package/skills/session-start/SKILL.md +8 -2
  236. package/skills/session-start/references/operations-contract.md +6 -0
  237. package/skills/session-start/references/phase-1-2-session-lock.md +32 -7
  238. package/skills/session-start/references/phase-1-7-vault-status-board.md +1 -1
  239. package/skills/session-start/references/phase-4-ssot-environment-check.md +10 -3
  240. package/skills/test-audit/SKILL.md +152 -0
  241. package/skills/test-audit/references/campaign.md +111 -0
  242. package/skills/test-audit/references/mutate.sh +50 -0
  243. package/skills/vault-mirror/SKILL.md +21 -11
  244. package/skills/wave-executor/SKILL.md +2 -2
  245. package/skills/wave-executor/circuit-breaker.md +1 -1
  246. package/skills/wave-executor/references/wave-executor-state-init.md +3 -0
  247. package/skills/wave-executor/references/wave-loop-dispatch.md +11 -1
  248. package/skills/wave-executor/references/wave-loop-review.md +4 -6
  249. package/skills/wave-executor/references/wave-loop-scope-manifest.md +13 -5
  250. package/templates/personas/README.md +5 -5
  251. package/templates/personas/accounting-compliance.v1.md +1 -1
  252. package/templates/personas/accounting-tax-advisor.v1.md +1 -1
  253. package/templates/personas/buyer-p1-cto.v1.md +1 -1
  254. package/templates/personas/buyer-p2-kanzlei.v1.md +1 -1
  255. package/templates/personas/buyer-p3-build.v1.md +1 -1
  256. package/templates/personas/buyer-p4-tech-veto.v1.md +1 -1
  257. package/templates/personas/buyer-p5-solo.v1.md +1 -1
  258. package/templates/personas/buyer-p6-ld.v1.md +1 -1
  259. package/templates/personas/klima-ai-expert.v1.md +1 -1
  260. package/templates/personas/klima-physicist.v1.md +1 -1
@@ -8,14 +8,20 @@
8
8
  import {
9
9
  readFile,
10
10
  writeFile,
11
+ appendFile,
11
12
  mkdir,
12
13
  rename,
13
14
  copyFile,
14
15
  readdir,
15
16
  unlink,
17
+ open,
16
18
  } from 'node:fs/promises';
17
- import { existsSync } from 'node:fs';
19
+ import { existsSync, realpathSync } from 'node:fs';
20
+ import { AsyncLocalStorage } from 'node:async_hooks';
21
+ import { randomUUID } from 'node:crypto';
18
22
  import path from 'node:path';
23
+ import { withFileLock } from '../file-lock.mjs';
24
+ import { digestSha256 } from '../crypto-digest-utils.mjs';
19
25
  import {
20
26
  validateLearning,
21
27
  normalizeLearning,
@@ -24,6 +30,117 @@ import {
24
30
  ValidationError,
25
31
  } from './schema.mjs';
26
32
 
33
+ // ---------------------------------------------------------------------------
34
+ // Store lock (GitLab #1447 point 8)
35
+ // ---------------------------------------------------------------------------
36
+
37
+ /**
38
+ * How long a writer waits for the store lock before giving up — the
39
+ * `withFileLock` default. Every critical section here is one read plus one
40
+ * append or rewrite of a JSONL file (milliseconds), so ten seconds of
41
+ * contention means a stuck holder, not a slow one.
42
+ */
43
+ export const LEARNINGS_LOCK_TIMEOUT_MS = 10000;
44
+
45
+ /**
46
+ * Thrown when the learnings store lock cannot be acquired. Every writer takes
47
+ * the lock BEFORE it touches the store, so this error always means nothing was
48
+ * written. The message is one line and names the lock file.
49
+ */
50
+ export class LearningsLockError extends Error {
51
+ /**
52
+ * @param {string} lockPath
53
+ * @param {'timeout'|'fs-error'} reason
54
+ * @param {number} timeoutMs
55
+ * @param {string} [detail]
56
+ */
57
+ constructor(lockPath, reason, timeoutMs, detail) {
58
+ const why =
59
+ reason === 'timeout'
60
+ ? `held by another writer, waited ${timeoutMs} ms`
61
+ : `${reason}${detail ? `: ${detail}` : ''}`;
62
+ super(`learnings store lock ${lockPath} not acquired (${why}) — nothing written`);
63
+ this.name = 'LearningsLockError';
64
+ this.lockPath = lockPath;
65
+ this.reason = reason;
66
+ }
67
+ }
68
+
69
+ /**
70
+ * Store locks held by the CURRENT async call chain, so a nested writer inside a
71
+ * critical section (apply → prune → rewrite, sweep → rewrite, promote →
72
+ * rewrite) runs straight through instead of waiting on its own lock until the
73
+ * timeout. Keyed per async context, deliberately NOT per process: a concurrent
74
+ * call chain in the same process does not inherit the entry and waits like a
75
+ * second process would — which is exactly the interleaving the lock exists for.
76
+ * @type {AsyncLocalStorage<Map<string, {live: boolean}>>}
77
+ */
78
+ const heldStoreLocks = new AsyncLocalStorage();
79
+
80
+ /**
81
+ * `<store>.lock` beside the store. The directory is canonicalised so two
82
+ * spellings of one store (`/var` vs `/private/var`, a realpath'd repo root as
83
+ * `memory-proposals/sink.mjs` passes it) contend on ONE lock file. Creates the
84
+ * directory: the lock file needs it, and every caller is about to write there.
85
+ *
86
+ * @param {string} filePath
87
+ * @returns {Promise<string>}
88
+ */
89
+ async function resolveStoreLockPath(filePath) {
90
+ const dir = path.dirname(path.resolve(filePath));
91
+ await mkdir(dir, { recursive: true });
92
+ return path.join(realpathSync(dir), `${path.basename(filePath)}.lock`);
93
+ }
94
+
95
+ /**
96
+ * Run `fn` while holding the exclusive lock of the learnings store at
97
+ * `filePath`. Every read-modify-write of the store must run its WHOLE
98
+ * read → write sequence inside one call: a write outside the lock is silently
99
+ * lost when a rewriter that read before it renames its next generation over
100
+ * the file. `appendLearning()` and `rewriteLearnings()` take the lock
101
+ * themselves.
102
+ *
103
+ * Reentrant within one async call chain (see `heldStoreLocks`). Reentrancy
104
+ * prevents a deadlock, not a lost write: an `appendLearning()` issued INSIDE a
105
+ * read → rewrite section lands in the file that section is about to replace —
106
+ * fold such a record into the rewrite batch instead.
107
+ *
108
+ * Crash safety comes from `withFileLock`'s default `staleCheck: 'pid'`: a lock
109
+ * whose holder process is gone is overridden (WARN on stderr); a live holder or
110
+ * one on another host is waited for until `timeoutMs`.
111
+ *
112
+ * @template T
113
+ * @param {string} filePath — the learnings store (the lock file sits beside it)
114
+ * @param {() => (T | Promise<T>)} fn
115
+ * @param {{ timeoutMs?: number }} [opts]
116
+ * @returns {Promise<T>}
117
+ * @throws {LearningsLockError} when the lock is not acquired — `fn` never ran
118
+ */
119
+ export async function withLearningsLock(filePath, fn, { timeoutMs = LEARNINGS_LOCK_TIMEOUT_MS } = {}) {
120
+ const lockPath = await resolveStoreLockPath(filePath);
121
+ const held = heldStoreLocks.getStore();
122
+ if (held?.get(lockPath)?.live === true) return fn();
123
+
124
+ const token = { live: true };
125
+ const scope = new Map(held ?? []);
126
+ scope.set(lockPath, token);
127
+ let result;
128
+ try {
129
+ result = await withFileLock(lockPath, () => heldStoreLocks.run(scope, fn), {
130
+ timeoutMs,
131
+ // A per-acquisition holder makes the owner-guarded release delete only
132
+ // THIS acquisition's lock, never a successor's (#1285).
133
+ holder: `learnings-store:${process.pid}:${randomUUID()}`,
134
+ });
135
+ } finally {
136
+ // A continuation `fn` left running past its own return must not keep
137
+ // treating the released lock as held.
138
+ token.live = false;
139
+ }
140
+ if (!result.ok) throw new LearningsLockError(lockPath, result.reason, timeoutMs, result.error);
141
+ return result.value;
142
+ }
143
+
27
144
  // ---------------------------------------------------------------------------
28
145
  // Pre-write self-validation seam (issue #662)
29
146
  // ---------------------------------------------------------------------------
@@ -42,7 +159,7 @@ import {
42
159
  * append touches disk, so a bad write can never reach the file.
43
160
  *
44
161
  * @param {object} validated — already validated+normalized learning entry
45
- * @param {{ legacyTolerant?: boolean }} [opts] — GitLab #386. When `true`,
162
+ * @param {{ legacyTolerant?: boolean }} [opts] — EventDrop #386. When `true`,
46
163
  * a field that was ALREADY ABSENT on `validated` (e.g. a legacy record with
47
164
  * no `source_session`, tolerated by `readLearnings()`) stays tolerated after
48
165
  * the round-trip too — the re-validation call below runs in the same
@@ -80,8 +197,9 @@ function serializeLearningLineChecked(validated, { legacyTolerant = false } = {}
80
197
  // A key that existed on `validated` (present, even as `undefined`) but
81
198
  // vanished from `reparsed` was DROPPED by JSON.stringify — the exact
82
199
  // undefined/NaN/etc. corruption #662 exists to catch. A key that was
83
- // never on `validated` in the first place (the #386 legacy-field case)
84
- // cannot appear here, because we only iterate `validated`'s own keys.
200
+ // never on `validated` in the first place (the EventDrop #386
201
+ // legacy-field case) cannot appear here, because we only iterate
202
+ // `validated`'s own keys.
85
203
  const droppedKeys = Object.keys(validated).filter((k) => !(k in reparsed));
86
204
  if (droppedKeys.length > 0) {
87
205
  throw new ValidationError(
@@ -103,26 +221,69 @@ function serializeLearningLineChecked(validated, { legacyTolerant = false } = {}
103
221
  /**
104
222
  * Read all learnings from the given JSONL path. Returns normalized entries
105
223
  * (missing extended fields are defaulted). Malformed lines are skipped with
106
- * their raw text preserved in the result's `malformed` array.
224
+ * their raw text preserved in the result's `malformed` array, and their
225
+ * 1-based line numbers in `malformedLineNumbers` (same order).
107
226
  *
108
227
  * @param {string} filePath — absolute or project-relative path to learnings.jsonl
109
- * @returns {Promise<{entries: object[], malformed: string[]}>}
228
+ * @returns {Promise<{entries: object[], malformed: string[], malformedLineNumbers: number[]}>}
110
229
  */
111
230
  export async function readLearnings(filePath) {
112
- if (!existsSync(filePath)) return { entries: [], malformed: [] };
113
- const raw = await readFile(filePath, 'utf8');
114
- const lines = raw.split('\n').filter((l) => l.length > 0);
231
+ if (!existsSync(filePath)) return { entries: [], malformed: [], malformedLineNumbers: [] };
232
+ return parseLearningsText(await readFile(filePath, 'utf8'));
233
+ }
234
+
235
+ /**
236
+ * Read a learnings store together with its generation token (#1486), both
237
+ * from ONE read, so the token describes exactly the records returned.
238
+ *
239
+ * The token is `sha256:<hex>` over the file's full text: any append, rewrite
240
+ * or sweep changes it. An absent store reads as empty text — zero records
241
+ * either way, so the two states share a token.
242
+ *
243
+ * @param {string} filePath
244
+ * @returns {Promise<{entries: object[], malformed: string[], malformedLineNumbers: number[],
245
+ * generation: string}>}
246
+ */
247
+ export async function readLearningsSnapshot(filePath) {
248
+ const raw = existsSync(filePath) ? await readFile(filePath, 'utf8') : '';
249
+ return { ...parseLearningsText(raw), generation: `sha256:${digestSha256(raw)}` };
250
+ }
251
+
252
+ /**
253
+ * Parse learnings JSONL text — the line parser behind {@link readLearnings},
254
+ * exported for callers that must split a header off the text first.
255
+ *
256
+ * A blank line — empty, whitespace-only, or a lone `\r` from a CRLF blank line
257
+ * — carries no record and is skipped, never counted as malformed: malformed
258
+ * lines are kept verbatim by every rewrite and WARNed on every close, so a
259
+ * stray blank would otherwise be reported forever (#1489).
260
+ *
261
+ * A line that parses but is not a JSON object (`null`, `42`, `[]`, `"x"`) is
262
+ * malformed too (#1500): `normalizeLearning` turned it into a record of
263
+ * defaults alone, and every rewrite wrote that invented record back.
264
+ *
265
+ * @param {string} raw
266
+ * @returns {{entries: object[], malformed: string[], malformedLineNumbers: number[]}}
267
+ * `malformedLineNumbers[i]` is the 1-based line of `malformed[i]` in `raw`
268
+ */
269
+ export function parseLearningsText(raw) {
115
270
  const entries = [];
116
271
  const malformed = [];
117
- for (const line of lines) {
272
+ const malformedLineNumbers = [];
273
+ raw.split('\n').forEach((line, i) => {
274
+ if (line.trim().length === 0) return;
118
275
  try {
119
276
  const parsed = JSON.parse(line);
277
+ if (parsed === null || typeof parsed !== 'object' || Array.isArray(parsed)) {
278
+ throw new TypeError('not a JSON object');
279
+ }
120
280
  entries.push(normalizeLearning(parsed));
121
281
  } catch {
122
282
  malformed.push(line);
283
+ malformedLineNumbers.push(i + 1);
123
284
  }
124
- }
125
- return { entries, malformed };
285
+ });
286
+ return { entries, malformed, malformedLineNumbers };
126
287
  }
127
288
 
128
289
  // ---------------------------------------------------------------------------
@@ -143,11 +304,23 @@ export async function readLearnings(filePath) {
143
304
  * atomic on POSIX append. For very large insight/evidence fields that
144
305
  * might exceed that boundary, use rewriteLearnings() instead.
145
306
  *
307
+ * The append runs under the store lock ({@link withLearningsLock}): an append
308
+ * that landed between a rewriter's read and its rename was silently lost.
309
+ * Validation runs first, outside the lock — a rejected record never waits.
310
+ *
311
+ * A store whose last line is torn (a crash mid-append left no trailing
312
+ * newline) gets a `\n` written first, inside the same append: without it the
313
+ * new record fused onto the torn text into ONE unparseable line, and no reader
314
+ * ever saw the record (#1489).
315
+ *
146
316
  * @param {string} filePath
147
317
  * @param {object} entry
318
+ * @param {{ lockTimeoutMs?: number }} [opts] — store-lock wait, default
319
+ * {@link LEARNINGS_LOCK_TIMEOUT_MS}
148
320
  * @returns {Promise<object>} validated entry
321
+ * @throws {LearningsLockError} when the store lock is not acquired (nothing appended)
149
322
  */
150
- export async function appendLearning(filePath, entry) {
323
+ export async function appendLearning(filePath, entry, { lockTimeoutMs } = {}) {
151
324
  // Ensure created_at is set first — many writers omit it, and expires_at
152
325
  // derivation depends on it. Use ISO 8601 UTC.
153
326
  const createdAt =
@@ -173,12 +346,46 @@ export async function appendLearning(filePath, entry) {
173
346
  // parses back AND re-validates before any append touches disk. Throws
174
347
  // ValidationError on a non-round-tripping record — file is left untouched.
175
348
  const line = serializeLearningLineChecked(validated);
176
- await mkdir(path.dirname(filePath), { recursive: true });
177
- const { appendFile } = await import('node:fs/promises');
178
- await appendFile(filePath, line, 'utf8');
349
+ // The lock path resolution creates the parent directory. The torn-tail check
350
+ // reads inside the lock, so no other writer can change the tail in between.
351
+ await withLearningsLock(
352
+ filePath,
353
+ async () => {
354
+ const lead = (await endsWithoutNewline(filePath)) ? '\n' : '';
355
+ await appendFile(filePath, lead + line, 'utf8');
356
+ },
357
+ { timeoutMs: lockTimeoutMs }
358
+ );
179
359
  return validated;
180
360
  }
181
361
 
362
+ /**
363
+ * True when `filePath` is a non-empty file whose last byte is not `\n`. An
364
+ * absent file is `false` (the append creates it). Reads one byte, so the cost
365
+ * does not grow with the store.
366
+ *
367
+ * @param {string} filePath
368
+ * @returns {Promise<boolean>}
369
+ */
370
+ async function endsWithoutNewline(filePath) {
371
+ let fh;
372
+ try {
373
+ fh = await open(filePath, 'r');
374
+ } catch (err) {
375
+ if (err?.code === 'ENOENT') return false;
376
+ throw err;
377
+ }
378
+ try {
379
+ const { size } = await fh.stat();
380
+ if (size === 0) return false;
381
+ const buf = Buffer.alloc(1);
382
+ await fh.read(buf, 0, 1, size - 1);
383
+ return buf[0] !== 0x0a;
384
+ } finally {
385
+ await fh.close();
386
+ }
387
+ }
388
+
182
389
  /**
183
390
  * Number of `${basename}.bak-<ISO>` siblings retained after a backup rotation.
184
391
  * The atomic rewrite is destructive on a gitignored store (no VCS restore), so
@@ -281,7 +488,7 @@ async function rotateBackups(dir, baseName, keep = BACKUP_KEEP) {
281
488
  * - `backup` (default `true`): before the destructive rename, copy the current
282
489
  * file to `${filePath}.bak-<ISO>`, then rotate to keep only the newest
283
490
  * {@link BACKUP_KEEP}. Rotation is best-effort and never blocks the rewrite.
284
- * - `legacyTolerant` (default `true`, GitLab #386): this function is a
491
+ * - `legacyTolerant` (default `true`, EventDrop #386): this function is a
285
492
  * ROUND-TRIP writer — its usual caller (`sweepExpiredLearnings` /
286
493
  * `pruneLearnings` in `expiry-sweep.mjs`) reads the store with
287
494
  * `readLearnings()` first, and that reader already tolerates a legacy
@@ -298,18 +505,36 @@ async function rotateBackups(dir, baseName, keep = BACKUP_KEEP) {
298
505
  * round-trip (a value JSON.stringify drops or coerces, e.g. `undefined`/
299
506
  * `NaN`) is still caught by the #662 checked serializer regardless of this
300
507
  * flag — see {@link serializeLearningLineChecked}. Pass `false` to restore
301
- * the pre-#386 fully-strict behaviour.
508
+ * the fully-strict behaviour from before EventDrop #386.
509
+ * - `malformedLines` (#1489): raw text of store lines that did not parse —
510
+ * the `malformed` array {@link readLearnings} returns. Written back
511
+ * verbatim, one per line, AFTER the validated records. A round-trip writer
512
+ * that rewrites from parsed entries alone deletes every such line with no
513
+ * archive record (only the keep-3 `.bak` still holds it). OMITTED (the
514
+ * default), the store's OWN malformed lines are re-read under the lock and
515
+ * kept — so a caller that forgets this option no longer deletes them
516
+ * (`export-hw-learnings` did). Pass an array to write exactly those lines;
517
+ * `[]` is the explicit opt-out that drops them. Each entry must be a
518
+ * non-empty string without a newline; anything else throws a TypeError
519
+ * before any disk access, dry run included.
302
520
  *
303
521
  * @param {string} filePath
304
522
  * @param {object[]} entries
305
- * @param {{dryRun?: boolean, backup?: boolean, legacyTolerant?: boolean}} [opts]
523
+ * @param {{dryRun?: boolean, backup?: boolean, legacyTolerant?: boolean, malformedLines?: string[]}} [opts]
306
524
  * @returns {Promise<object[]>} validated entries (always returned, even dryRun)
307
525
  */
308
526
  export async function rewriteLearnings(
309
527
  filePath,
310
528
  entries,
311
- { dryRun = false, backup = true, legacyTolerant = true } = {}
529
+ { dryRun = false, backup = true, legacyTolerant = true, malformedLines } = {}
312
530
  ) {
531
+ if (
532
+ malformedLines !== undefined &&
533
+ (!Array.isArray(malformedLines) ||
534
+ malformedLines.some((l) => typeof l !== 'string' || l.length === 0 || l.includes('\n')))
535
+ ) {
536
+ throw new TypeError('rewriteLearnings: malformedLines must be an array of non-empty single-line strings');
537
+ }
313
538
  const validated = entries.map((e) =>
314
539
  validateLearning(
315
540
  {
@@ -330,25 +555,37 @@ export async function rewriteLearnings(
330
555
  // rewrite, no backup — and hand the validated entries back to the caller.
331
556
  if (dryRun) return validated;
332
557
 
333
- const body = lines.join('');
334
- await mkdir(path.dirname(filePath), { recursive: true });
558
+ // Under the store lock (#1447 point 8) so no append lands between the backup
559
+ // and the rename. The CALLER's read must sit inside the same lock for a
560
+ // read-modify-write to be safe — this call joins it reentrantly.
561
+ await withLearningsLock(filePath, async () => {
562
+ // Default: the store's own malformed lines as they stand right now, read
563
+ // under the lock that guards the rename below.
564
+ const kept =
565
+ malformedLines ??
566
+ (existsSync(filePath) ? parseLearningsText(await readFile(filePath, 'utf8')).malformed : []);
567
+ // Malformed lines last, each newline-terminated: a truncated final line
568
+ // otherwise fuses with the next append into one more unparseable line.
569
+ const body = lines.join('') + kept.map((l) => `${l}\n`).join('');
335
570
 
336
- // Backup-before-rewrite (#721): snapshot the current store to a timestamped
337
- // sidecar BEFORE the destructive rename, then prune to keep-N. Only meaningful
338
- // when the target already exists (a first-time write has nothing to lose).
339
- if (backup && existsSync(filePath)) {
340
- const ts = new Date().toISOString().replace(/[:.]/g, '-');
341
- await copyFile(filePath, `${filePath}.bak-${ts}`);
342
- try {
343
- await rotateBackups(path.dirname(filePath), path.basename(filePath));
344
- } catch {
345
- // Rotation is best-effort — a stale/undeletable sibling must never abort
346
- // the rewrite. The fresh backup above is already safely on disk.
571
+
572
+ // Backup-before-rewrite (#721): snapshot the current store to a timestamped
573
+ // sidecar BEFORE the destructive rename, then prune to keep-N. Only meaningful
574
+ // when the target already exists (a first-time write has nothing to lose).
575
+ if (backup && existsSync(filePath)) {
576
+ const ts = new Date().toISOString().replace(/[:.]/g, '-');
577
+ await copyFile(filePath, `${filePath}.bak-${ts}`);
578
+ try {
579
+ await rotateBackups(path.dirname(filePath), path.basename(filePath));
580
+ } catch {
581
+ // Rotation is best-effort — a stale/undeletable sibling must never abort
582
+ // the rewrite. The fresh backup above is already safely on disk.
583
+ }
347
584
  }
348
- }
349
585
 
350
- const tmp = `${filePath}.tmp-${process.pid}-${Date.now()}`;
351
- await writeFile(tmp, body, 'utf8');
352
- await rename(tmp, filePath);
586
+ const tmp = `${filePath}.tmp-${process.pid}-${Date.now()}`;
587
+ await writeFile(tmp, body, 'utf8');
588
+ await rename(tmp, filePath);
589
+ });
353
590
  return validated;
354
591
  }
@@ -230,17 +230,19 @@ export class ValidationError extends Error {
230
230
  * Throws ValidationError on contract violations. Does NOT mutate input.
231
231
  *
232
232
  * @param {object} entry — candidate learning
233
- * @param {{ legacyTolerant?: boolean }} [opts] — GitLab #386. When `true`, a
233
+ * @param {{ legacyTolerant?: boolean }} [opts] — EventDrop #386. When `true`, a
234
234
  * value `normalizeLearning()` already passes through UNCHECKED (or merely
235
235
  * DEFAULTED, never validated) on read is no longer rejected here either:
236
236
  * an out-of-enum `schema_version` (measured against the real EventDrop.at
237
237
  * store: 3 live records carry `schema_version: 2`), the
238
238
  * LEGACY_REQUIRED_FIELDS presence check, the `confidence` range/type check
239
239
  * when NO `confidence` key is present, and the `scope`/`host_class`/
240
- * `anonymized` shape checks (measured live: a record with `scope:
241
- * "src/components/landing/mobile-sticky-cta.tsx"` — a producer bug that
242
- * wrote a file path into the scope column, which `normalizeLearning` never
243
- * validates and therefore never rejects). A field that IS present keeps
240
+ * `anonymized`/`file_paths` shape checks (measured live: a record whose `scope` held a
241
+ * file path — e.g. a repo-relative component path such as
242
+ * `src/components/sample/sticky-cta.tsx` (the measured value is not
243
+ * reproduced here) — a producer bug that wrote a file path into the scope
244
+ * column, which `normalizeLearning` never validates and therefore never
245
+ * rejects). A field that IS present keeps
244
246
  * being validated regardless when its DATA TYPE can be silently corrupted
245
247
  * by a JSON round-trip (a present-but-malformed `confidence` still throws
246
248
  * — see io.mjs #662): this option relaxes "you must HAVE a valid value",
@@ -296,9 +298,11 @@ export function validateLearning(entry, { legacyTolerant = false } = {}) {
296
298
  // scope/host_class/anonymized: `normalizeLearning()` only DEFAULTS an
297
299
  // absent value (`d.scope ?? 'local'`, etc.) on read — it never validates a
298
300
  // PRESENT one, so a corrupt legacy value round-trips silently today
299
- // (measured live in production: a record with `scope:
300
- // "src/components/landing/mobile-sticky-cta.tsx"` — evidently a producer
301
- // bug that wrote a file path into the scope column). All three checks are
301
+ // (measured live in production: a record whose `scope` held a file path —
302
+ // e.g. a repo-relative component path such as
303
+ // `src/components/sample/sticky-cta.tsx` (the measured value is not
304
+ // reproduced here) — evidently a producer bug that wrote a file path into
305
+ // the scope column). All three checks are
302
306
  // therefore skipped under legacyTolerant. Unlike `confidence` (a number,
303
307
  // where `NaN`/`Infinity` are non-JSON-safe and silently become `null`
304
308
  // across a JSON round-trip — the #662 concern), a string/string-or-null/
@@ -319,6 +323,24 @@ export function validateLearning(entry, { legacyTolerant = false } = {}) {
319
323
  throw new ValidationError(`anonymized must be boolean, got: ${typeof anonymized}`);
320
324
  }
321
325
 
326
+ // file_paths (GitLab #1447): when present, an array of repo-relative path
327
+ // strings — a bare string (`file_paths: "src/x.ts"`) is the producer slip
328
+ // this rejects; every reader iterates it as a list. `null` reads as absent,
329
+ // as in migrateLegacyLearning. Skipped under legacyTolerant like the shape
330
+ // checks above: the read funnel passes the value through unchecked.
331
+ const filePaths = entry.file_paths;
332
+ if (!legacyTolerant && filePaths !== undefined && filePaths !== null) {
333
+ if (!Array.isArray(filePaths)) {
334
+ throw new ValidationError(`file_paths must be an array of repo-relative paths, got: ${typeof filePaths}`);
335
+ }
336
+ const badIndex = filePaths.findIndex((p) => !isRepoRelativePathEntry(p));
337
+ if (badIndex !== -1) {
338
+ throw new ValidationError(
339
+ `file_paths[${badIndex}] must be a non-empty repo-relative path (not absolute, no '..' segment), got: ${JSON.stringify(filePaths[badIndex])}`
340
+ );
341
+ }
342
+ }
343
+
322
344
  // Privacy contract — NEVER relaxed, legacyTolerant or not: an out-of-enum
323
345
  // `scope` (garbage or otherwise) is by definition not `'public'`, so this
324
346
  // pair only ever fires for a record that genuinely claims `scope: 'public'`
@@ -476,6 +498,85 @@ export function normalizeDialects(entry, { reserializeTimestamps = true } = {})
476
498
  return out;
477
499
  }
478
500
 
501
+ /**
502
+ * File extensions a path-like `scope` value must end in to be treated as a
503
+ * repo-relative file path (GitHub #69 / GitLab #1446). Deliberately a closed
504
+ * list: a looser "contains a slash and a dot" test would also swallow values
505
+ * such as `infrastructure/docker-compose` or `node.js`.
506
+ */
507
+ const PATH_SCOPE_EXT = /\.(mjs|cjs|js|jsx|ts|tsx|mts|cts|json|jsonl|md|mdx|ya?ml|toml|css|scss|html|vue|svelte|astro|py|rb|go|rs|swift|kt|java|sh|sql|prisma)$/i;
508
+
509
+ /**
510
+ * Strict predicate: is `s` a repo-relative file path (at least one directory
511
+ * segment, no `..`, no empty segment, a known source/doc extension)? Rejects
512
+ * canonical scopes, absolute and home-relative paths, Windows drive paths,
513
+ * URLs, and anything containing whitespace.
514
+ *
515
+ * @param {unknown} s — candidate `scope` value
516
+ * @returns {boolean}
517
+ */
518
+ function looksLikeRepoRelativePath(s) {
519
+ if (typeof s !== 'string' || VALID_SCOPES.includes(s) || s.length > 512) return false;
520
+ if (/\s/.test(s) || /^[/~]/.test(s) || /^[A-Za-z]:[\\/]/.test(s) || s.includes('://')) return false;
521
+ const segs = s.split('/');
522
+ if (segs.length < 2 || segs.includes('..') || segs.includes('')) return false;
523
+ return PATH_SCOPE_EXT.test(segs[segs.length - 1]);
524
+ }
525
+
526
+ /**
527
+ * Loose predicate for ONE `file_paths` entry (GitLab #1447): a non-empty string
528
+ * that is not absolute (`/`, `\`, `~`, a Windows drive), not a URL, and has no
529
+ * `..` segment. Deliberately looser than {@link looksLikeRepoRelativePath}:
530
+ * root-level files (`package.json`), directories (`docs/prd`) and globs
531
+ * (`skills/**`) are legitimate `file_paths` values.
532
+ *
533
+ * Exported because `scripts/memory-propose.mjs` gates `--file-paths` on the same
534
+ * rule: a proposal this predicate rejects would be queued, approved, and then
535
+ * refused by {@link validateLearning} at write time.
536
+ *
537
+ * @param {unknown} s — candidate entry
538
+ * @returns {boolean}
539
+ */
540
+ export function isRepoRelativePathEntry(s) {
541
+ if (typeof s !== 'string' || s.trim() === '') return false;
542
+ if (/^[/\\~]/.test(s) || /^[A-Za-z]:[\\/]/.test(s) || s.includes('://')) return false;
543
+ return !s.split(/[\\/]/).includes('..');
544
+ }
545
+
546
+ const LEADING_DOT_SLASH = /^(?:\.\/)+/;
547
+
548
+ /** Non-canonical STRING scopes {@link migrateLegacyLearning} coerces to `'local'`. */
549
+ const LOCAL_ALIAS_SCOPES = new Set(['vault-tools', 'deep-sessions', 'wave-executor', 'coordinator']);
550
+
551
+ /** Non-canonical STRING scopes {@link migrateLegacyLearning} coerces to `'private'`. */
552
+ const PRIVATE_ALIAS_SCOPES = new Set(['project', 'repo']);
553
+
554
+ /**
555
+ * Every word the migration reads as scope vocabulary. An array-scope entry
556
+ * equal to one of these is a scope word, never a path — the same reading the
557
+ * string branch gives it, so `['project']` is not turned into
558
+ * `file_paths: ['project']`.
559
+ */
560
+ const SCOPE_WORDS = new Set([...VALID_SCOPES, ...LOCAL_ALIAS_SCOPES, ...PRIVATE_ALIAS_SCOPES]);
561
+
562
+ /**
563
+ * The repo-relative paths a broken `scope` value carries, or `null` when it is
564
+ * not a path scope. A single string must pass the strict
565
+ * {@link looksLikeRepoRelativePath} (a lone string may be a scope word); an
566
+ * ARRAY is never scope vocabulary, so the loose per-entry predicate suffices
567
+ * there — an empty array yields `[]`. A leading `./` run is stripped from each
568
+ * path; case is kept (git paths are case-sensitive).
569
+ *
570
+ * @param {unknown} scope
571
+ * @returns {string[] | null}
572
+ */
573
+ function pathScopeEntries(scope) {
574
+ if (looksLikeRepoRelativePath(scope)) return [scope.replace(LEADING_DOT_SLASH, '')];
575
+ if (!Array.isArray(scope)) return null;
576
+ const entries = scope.map((e) => (typeof e === 'string' ? e.replace(LEADING_DOT_SLASH, '') : e));
577
+ return entries.every((e) => isRepoRelativePathEntry(e) && !SCOPE_WORDS.has(e)) ? entries : null;
578
+ }
579
+
479
580
  /**
480
581
  * Migrate a legacy learning record to the canonical schema_version:1 shape.
481
582
  * Idempotent — calling it on an already-canonical record is a safe no-op.
@@ -483,9 +584,32 @@ export function normalizeDialects(entry, { reserializeTimestamps = true } = {})
483
584
  * Alias precedence for insight: insight > description > recommendation > observation > lesson
484
585
  *
485
586
  * Producer dialects (`files`→`file_paths`, duplicate `session_id`, `last_seen`,
486
- * `next_review: null`) are normalized by {@link normalizeDialects} as the final
487
- * step. Timestamp FORMAT is deliberately NOT reformatted here (byte-exact
488
- * contract preserved); the read funnel + backfill canonicalize timestamps.
587
+ * `next_review: null`) are normalized by {@link normalizeDialects}. Timestamp
588
+ * FORMAT is deliberately NOT reformatted here (byte-exact contract preserved);
589
+ * the read funnel + backfill canonicalize timestamps.
590
+ *
591
+ * Broken-consumer-record coercions (GitHub #69 / GitLab #1446 — measured in a
592
+ * consumer store: 124 of 307 records failed strict validateLearning, 76 on
593
+ * `scope`, 48 on `schema_version`). Applied AFTER normalizeDialects, because
594
+ * coercing a path-like scope before the `files`→`file_paths` move would lose
595
+ * the legacy `files` data. Only this MIGRATION path coerces — the read funnel
596
+ * (normalizeLearning / normalizeDialects) stays pass-through by design:
597
+ * - path-like `scope` (a repo-relative file path a producer wrote into the
598
+ * scope column, see {@link looksLikeRepoRelativePath}) → appended to
599
+ * `file_paths` (copied, never mutated in place; created when absent/null;
600
+ * no duplicate) and `scope: 'private'`. A present non-array `file_paths`
601
+ * leaves the record untouched — it stays invalid and is counted. A
602
+ * leading `./` run is stripped first (GitLab #1447).
603
+ * - array-typed `scope` (GitLab #1447) → the same move when every entry is a
604
+ * repo-relative path ({@link isRepoRelativePathEntry}: root files, dirs and
605
+ * globs included); an empty array → `scope: 'private'`. An array holding an
606
+ * absolute path, a non-string or a scope word — canonical or one of the
607
+ * aliases below, e.g. `['project']` — stays untouched (invalid, counted);
608
+ * nothing is ever dropped.
609
+ * - `scope: 'project'` / `scope: 'repo'` → `'private'`.
610
+ * - `schema_version: '1'` (string) or `2` (number) → `1`.
611
+ * Each rule's output never re-matches that rule, so the whole function stays
612
+ * idempotent.
489
613
  *
490
614
  * The caller MUST still run validateLearning() on the result to confirm the
491
615
  * migrated record passes the full schema gate before writing.
@@ -534,8 +658,7 @@ export function migrateLegacyLearning(entry) {
534
658
  out.evidence = '';
535
659
  }
536
660
 
537
- const COERCIBLE_SCOPES = new Set(['vault-tools', 'deep-sessions', 'wave-executor', 'coordinator']);
538
- if (out.scope && !VALID_SCOPES.includes(out.scope) && COERCIBLE_SCOPES.has(out.scope)) {
661
+ if (out.scope && !VALID_SCOPES.includes(out.scope) && LOCAL_ALIAS_SCOPES.has(out.scope)) {
539
662
  out.scope = 'local';
540
663
  }
541
664
 
@@ -560,12 +683,37 @@ export function migrateLegacyLearning(entry) {
560
683
  out.schema_version = CURRENT_SCHEMA_VERSION;
561
684
  }
562
685
 
563
- // Normalize producer dialects last (files→file_paths, session_id/last_seen/
686
+ // Normalize producer dialects (files→file_paths, session_id/last_seen/
564
687
  // next_review reconciliation) — schema_version is preserved by normalizeDialects.
565
688
  // reserializeTimestamps:false keeps migrateLegacyLearning's byte-exact timestamp
566
689
  // contract (it does schema/alias migration, not timestamp reformatting). The
567
690
  // READ funnel (normalizeLearning) and the backfill canonicalize timestamp format.
568
- return normalizeDialects(out, { reserializeTimestamps: false });
691
+ const migrated = normalizeDialects(out, { reserializeTimestamps: false });
692
+
693
+ // Broken-consumer-record coercions (GitHub #69 / GitLab #1446, #1447) — AFTER
694
+ // normalizeDialects so a legacy `files` list has already become `file_paths`.
695
+ const pathEntries = pathScopeEntries(migrated.scope);
696
+ if (pathEntries !== null) {
697
+ const existing = migrated.file_paths;
698
+ if (existing === undefined || existing === null || Array.isArray(existing)) {
699
+ // Copy: normalizeDialects' shallow copy still shares the caller's array.
700
+ const filePaths = Array.isArray(existing) ? [...existing] : [];
701
+ for (const p of pathEntries) {
702
+ if (!filePaths.includes(p)) filePaths.push(p);
703
+ }
704
+ if (filePaths.length > 0) migrated.file_paths = filePaths;
705
+ migrated.scope = 'private';
706
+ }
707
+ // Present but not an array → leave untouched; the record stays invalid.
708
+ } else if (PRIVATE_ALIAS_SCOPES.has(migrated.scope)) {
709
+ migrated.scope = 'private';
710
+ }
711
+
712
+ if (migrated.schema_version === '1' || migrated.schema_version === 2) {
713
+ migrated.schema_version = CURRENT_SCHEMA_VERSION;
714
+ }
715
+
716
+ return migrated;
569
717
  }
570
718
 
571
719
  // Module-level dedupe sets for warnings (per-process).
@@ -9,8 +9,8 @@
9
9
  * 4-5. Before this module, the `effective-sizing` analyzer
10
10
  * (`skills/evolve/references/evolve-analyze-mode.md`) keyed its subject on
11
11
  * `session_type` alone (e.g. `deep-session-sizing`), so a 7-wave ultradeep
12
- * session and a 5-wave deep session landed on the SAME row — the over-delivery
13
- * median for one profile silently absorbed the other's.
12
+ * session and a 5-wave deep session landed on the SAME row — the agents-per-wave
13
+ * average for one profile silently absorbed the other's.
14
14
  *
15
15
  * Pure, stdlib-only, no imports, no clock, no fs.
16
16
  *