session-orchestrator 3.21.0 → 3.23.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 (345) hide show
  1. package/.claude-plugin/marketplace.json +1 -1
  2. package/.claude-plugin/plugin.json +1 -1
  3. package/.codex-plugin/plugin.json +1 -1
  4. package/.cursor/commands/autopilot-multi.md +14 -0
  5. package/.cursor/commands/autopilot.md +14 -0
  6. package/.cursor/commands/bootstrap.md +14 -0
  7. package/.cursor/commands/brainstorm.md +14 -0
  8. package/.cursor/commands/close.md +13 -0
  9. package/.cursor/commands/contract-version-bump.md +14 -0
  10. package/.cursor/commands/debug.md +14 -0
  11. package/.cursor/commands/discovery.md +14 -0
  12. package/.cursor/commands/dispatcher.md +14 -0
  13. package/.cursor/commands/eli5.md +14 -0
  14. package/.cursor/commands/eval.md +14 -0
  15. package/.cursor/commands/evolve.md +14 -0
  16. package/.cursor/commands/go.md +14 -0
  17. package/.cursor/commands/grill.md +14 -0
  18. package/.cursor/commands/harness-audit.md +13 -0
  19. package/.cursor/commands/journey-audit.md +14 -0
  20. package/.cursor/commands/memory-cleanup.md +14 -0
  21. package/.cursor/commands/persona-panel.md +14 -0
  22. package/.cursor/commands/plan.md +14 -0
  23. package/.cursor/commands/portfolio.md +14 -0
  24. package/.cursor/commands/reconcile.md +14 -0
  25. package/.cursor/commands/release.md +14 -0
  26. package/.cursor/commands/repo-audit.md +13 -0
  27. package/.cursor/commands/session.md +14 -0
  28. package/.cursor/commands/spinout.md +14 -0
  29. package/.cursor/commands/sunset-review.md +14 -0
  30. package/.cursor/commands/templates-ack.md +14 -0
  31. package/.cursor/commands/test.md +14 -0
  32. package/.cursor/hooks.json +60 -0
  33. package/.cursor/rules/000-session-orchestrator.mdc +11 -2
  34. package/.cursor/rules/010-session-workflow.mdc +9 -1
  35. package/.cursor/rules/020-quality-gates.mdc +1 -1
  36. package/.cursor/rules/030-wave-execution.mdc +1 -1
  37. package/.cursor/rules/040-discovery.mdc +6 -6
  38. package/.cursor/rules/050-plan.mdc +10 -10
  39. package/.cursor/rules/070-gitlab-ops.mdc +73 -57
  40. package/.cursor/rules/080-ecosystem-health.mdc +7 -7
  41. package/.cursor/skills/architecture/SKILL.md +13 -0
  42. package/.cursor/skills/autopilot/SKILL.md +12 -0
  43. package/.cursor/skills/bootstrap/SKILL.md +12 -0
  44. package/.cursor/skills/brainstorm/SKILL.md +13 -0
  45. package/.cursor/skills/claude-md-drift-check/SKILL.md +13 -0
  46. package/.cursor/skills/contract-version-bump/SKILL.md +12 -0
  47. package/.cursor/skills/convergence-monitoring/SKILL.md +12 -0
  48. package/.cursor/skills/daily/SKILL.md +12 -0
  49. package/.cursor/skills/debug/SKILL.md +13 -0
  50. package/.cursor/skills/discovery/SKILL.md +13 -0
  51. package/.cursor/skills/dispatcher/SKILL.md +13 -0
  52. package/.cursor/skills/docs-orchestrator/SKILL.md +13 -0
  53. package/.cursor/skills/domain-model/SKILL.md +13 -0
  54. package/.cursor/skills/ecosystem-health/SKILL.md +13 -0
  55. package/.cursor/skills/eli5/SKILL.md +13 -0
  56. package/.cursor/skills/eval/SKILL.md +12 -0
  57. package/.cursor/skills/evolve/SKILL.md +13 -0
  58. package/.cursor/skills/frontmatter-guard/SKILL.md +13 -0
  59. package/.cursor/skills/gitlab-ops/SKILL.md +13 -0
  60. package/.cursor/skills/gitlab-portfolio/SKILL.md +13 -0
  61. package/.cursor/skills/grill/SKILL.md +13 -0
  62. package/.cursor/skills/hook-development/SKILL.md +13 -0
  63. package/.cursor/skills/journey-audit/SKILL.md +13 -0
  64. package/.cursor/skills/mcp-builder/SKILL.md +13 -0
  65. package/.cursor/skills/memory-cleanup/SKILL.md +12 -0
  66. package/.cursor/skills/mode-selector/SKILL.md +13 -0
  67. package/.cursor/skills/npm-publish/SKILL.md +12 -0
  68. package/.cursor/skills/peekaboo-driver/SKILL.md +13 -0
  69. package/.cursor/skills/persona-panel/SKILL.md +12 -0
  70. package/.cursor/skills/plan/SKILL.md +13 -0
  71. package/.cursor/skills/playwright-driver/SKILL.md +13 -0
  72. package/.cursor/skills/quality-gates/SKILL.md +13 -0
  73. package/.cursor/skills/reconcile/SKILL.md +12 -0
  74. package/.cursor/skills/repo-audit/SKILL.md +13 -0
  75. package/.cursor/skills/session-end/SKILL.md +13 -0
  76. package/.cursor/skills/session-plan/SKILL.md +13 -0
  77. package/.cursor/skills/session-start/SKILL.md +13 -0
  78. package/.cursor/skills/skill-creator/SKILL.md +13 -0
  79. package/.cursor/skills/spinout/SKILL.md +12 -0
  80. package/.cursor/skills/sunset-review/SKILL.md +13 -0
  81. package/.cursor/skills/test-runner/SKILL.md +13 -0
  82. package/.cursor/skills/tmux-layout/SKILL.md +13 -0
  83. package/.cursor/skills/ubiquitous-language/SKILL.md +13 -0
  84. package/.cursor/skills/using-orchestrator/SKILL.md +13 -0
  85. package/.cursor/skills/vault-mirror/SKILL.md +13 -0
  86. package/.cursor/skills/vault-sync/SKILL.md +13 -0
  87. package/.cursor/skills/wave-executor/SKILL.md +13 -0
  88. package/.cursor/skills/write-executable-plan/SKILL.md +13 -0
  89. package/.mcp.json +4 -1
  90. package/CHANGELOG.md +269 -0
  91. package/README.md +18 -15
  92. package/agents/AGENTS.md +23 -4
  93. package/agents/code-implementer.md +2 -1
  94. package/agents/db-specialist.md +2 -1
  95. package/agents/docs-writer.md +3 -1
  96. package/agents/eval-judge.md +1 -1
  97. package/agents/memory-proposal-collector.md +6 -4
  98. package/agents/session-reviewer.md +7 -1
  99. package/agents/test-writer.md +2 -1
  100. package/agents/ui-developer.md +2 -1
  101. package/commands/bootstrap.md +2 -2
  102. package/commands/close.md +3 -1
  103. package/commands/eli5.md +33 -0
  104. package/commands/go.md +1 -1
  105. package/commands/journey-audit.md +43 -0
  106. package/commands/release.md +5 -3
  107. package/commands/test.md +2 -2
  108. package/docs/USER-GUIDE.md +2 -2
  109. package/docs/ci-setup.md +14 -0
  110. package/docs/codex-setup.md +64 -0
  111. package/docs/components.md +7 -6
  112. package/docs/cursor-setup.md +26 -47
  113. package/docs/events-schema.md +76 -4
  114. package/docs/github-mirror-protection.md +197 -0
  115. package/docs/pi-setup.md +2 -0
  116. package/docs/rule-authoring.md +3 -1
  117. package/docs/scope-collision-guard.md +52 -5
  118. package/docs/session-config-reference.md +57 -12
  119. package/docs/session-config-template.md +4 -3
  120. package/docs/telemetry.md +22 -0
  121. package/hooks/_lib/lock-bootstrap.mjs +26 -16
  122. package/hooks/_lib/vcs-create-matcher.mjs +397 -38
  123. package/hooks/enforce-scope.mjs +64 -0
  124. package/hooks/hooks-codex.json +1 -1
  125. package/hooks/hooks-cursor.json +201 -20
  126. package/hooks/hooks-pi.json +1 -1
  127. package/hooks/hooks.json +12 -2
  128. package/hooks/on-session-end.mjs +237 -104
  129. package/hooks/on-session-start.mjs +404 -110
  130. package/hooks/on-stop.mjs +48 -9
  131. package/hooks/post-subagent-discovery-validator.mjs +34 -3
  132. package/hooks/post-tool-batch-wave-signal.mjs +11 -2
  133. package/hooks/pre-auq-clarity.mjs +787 -0
  134. package/hooks/pre-bash-issue-budget.mjs +133 -21
  135. package/hooks/pre-bash-sessions-ledger-guard.mjs +159 -0
  136. package/hooks/pre-bash-staging-fence.mjs +4 -0
  137. package/hooks/pre-task-scope-disjoint.mjs +368 -35
  138. package/hooks/skill-invocation-telemetry.mjs +21 -10
  139. package/monitors/monitors.json +6 -0
  140. package/package.json +3 -1
  141. package/pi/prompts/eli5.md +12 -0
  142. package/pi/prompts/journey-audit.md +12 -0
  143. package/rules/_index.md +9 -1
  144. package/rules/always-on/ask-via-tool.md +62 -0
  145. package/rules/always-on/bash-harness-pitfalls.md +168 -0
  146. package/rules/always-on/build-value.md +47 -0
  147. package/rules/always-on/cross-session-messaging.md +59 -0
  148. package/rules/always-on/loop-and-monitor.md +221 -0
  149. package/rules/always-on/parallel-sessions.md +142 -12
  150. package/rules/always-on/receiving-review.md +108 -0
  151. package/rules/always-on/test-value.md +40 -0
  152. package/rules/always-on/verification-before-completion.md +77 -0
  153. package/scripts/archive-closed-prds.mjs +258 -18
  154. package/scripts/auq-audit.mjs +825 -0
  155. package/scripts/autopilot.mjs +12 -8
  156. package/scripts/backfill-evidence-digest.mjs +376 -0
  157. package/scripts/cursor-install.mjs +89 -48
  158. package/scripts/export-hw-learnings.mjs +143 -2
  159. package/scripts/express-path.mjs +299 -0
  160. package/scripts/generate-cursor-adapter.mjs +253 -0
  161. package/scripts/github-protection-audit.mjs +358 -0
  162. package/scripts/lib/auq/clarity.mjs +1314 -0
  163. package/scripts/lib/auq/parse.mjs +1006 -0
  164. package/scripts/lib/auq/schema.mjs +1457 -0
  165. package/scripts/lib/autopilot/worktree-pipeline.mjs +240 -16
  166. package/scripts/lib/build-live-signals.mjs +24 -5
  167. package/scripts/lib/ci-status-banner.mjs +221 -68
  168. package/scripts/lib/command-blocker.mjs +70 -0
  169. package/scripts/lib/config/dispatcher-autonomy-capture.mjs +32 -9
  170. package/scripts/lib/config/reconcile.mjs +79 -4
  171. package/scripts/lib/config/section-extractor.mjs +235 -36
  172. package/scripts/lib/config/vault-integration.mjs +12 -1
  173. package/scripts/lib/config-schema.mjs +9 -1
  174. package/scripts/lib/config.mjs +57 -6
  175. package/scripts/lib/convergence-monitor.mjs +13 -2
  176. package/scripts/lib/cursor-hook-bridge.mjs +443 -0
  177. package/scripts/lib/dispatcher/cli.mjs +2 -2
  178. package/scripts/lib/dispatcher/rank.mjs +4 -7
  179. package/scripts/lib/express-path.mjs +327 -0
  180. package/scripts/lib/file-lock.mjs +22 -4
  181. package/scripts/lib/gates/gate-full.mjs +84 -11
  182. package/scripts/lib/gates/gate-helpers.mjs +93 -21
  183. package/scripts/lib/git-config-drift.mjs +134 -5
  184. package/scripts/lib/host-identity.mjs +247 -2
  185. package/scripts/lib/instruction-budget-guard.mjs +31 -1
  186. package/scripts/lib/io.mjs +239 -0
  187. package/scripts/lib/issue-budget.mjs +284 -31
  188. package/scripts/lib/learnings/io.mjs +55 -10
  189. package/scripts/lib/learnings/schema.mjs +95 -28
  190. package/scripts/lib/lock-reaper.mjs +7 -1
  191. package/scripts/lib/locks/staging-fence-lock.mjs +5 -1
  192. package/scripts/lib/locks/state-md-lock.mjs +8 -1
  193. package/scripts/lib/memory-banner.mjs +5 -2
  194. package/scripts/lib/memory-paths.mjs +15 -6
  195. package/scripts/lib/mode-selector/scoring.mjs +53 -6
  196. package/scripts/lib/owner-interview.mjs +78 -32
  197. package/scripts/lib/peer-discovery.mjs +73 -22
  198. package/scripts/lib/platform.mjs +72 -9
  199. package/scripts/lib/plugin-root.mjs +143 -19
  200. package/scripts/lib/project-hygiene.mjs +104 -4
  201. package/scripts/lib/quality-gate.mjs +271 -13
  202. package/scripts/lib/reconcile/emitter.mjs +87 -19
  203. package/scripts/lib/reconcile/engine.mjs +281 -13
  204. package/scripts/lib/reconcile/idempotency.mjs +102 -1
  205. package/scripts/lib/reconcile/renderer.mjs +165 -7
  206. package/scripts/lib/reconcile/sanitize.mjs +40 -17
  207. package/scripts/lib/reconcile/writer.mjs +415 -84
  208. package/scripts/lib/resource-probe/evaluate.mjs +330 -149
  209. package/scripts/lib/resource-probe/probe-platform.mjs +35 -0
  210. package/scripts/lib/resource-probe.mjs +18 -2
  211. package/scripts/lib/rule-loader.mjs +37 -2
  212. package/scripts/lib/rules-sync.mjs +51 -8
  213. package/scripts/lib/scope-gate.mjs +90 -0
  214. package/scripts/lib/session-close-backfill.mjs +369 -28
  215. package/scripts/lib/session-discovery.mjs +13 -3
  216. package/scripts/lib/session-end/phase-skip.mjs +37 -4
  217. package/scripts/lib/session-end/worktree-cleanup.mjs +154 -7
  218. package/scripts/lib/session-id.mjs +30 -14
  219. package/scripts/lib/session-identity/own-session.mjs +159 -0
  220. package/scripts/lib/session-lock.mjs +85 -30
  221. package/scripts/lib/session-schema/normalizer.mjs +70 -3
  222. package/scripts/lib/session-schema/validator.mjs +40 -0
  223. package/scripts/lib/session-start-probes.mjs +608 -0
  224. package/scripts/lib/session-transition.mjs +277 -0
  225. package/scripts/lib/sessions-staleness-banner.mjs +124 -57
  226. package/scripts/lib/spiral-carryover.mjs +111 -9
  227. package/scripts/lib/state-md/frontmatter-mutators.mjs +41 -8
  228. package/scripts/lib/state-md/mission-status.mjs +467 -72
  229. package/scripts/lib/state-md/yaml-parser.mjs +145 -16
  230. package/scripts/lib/state-md.mjs +12 -2
  231. package/scripts/lib/telemetry/sync.mjs +46 -8
  232. package/scripts/lib/validate/check-agents.mjs +66 -0
  233. package/scripts/lib/validate/check-auq-clarity.mjs +274 -0
  234. package/scripts/lib/validate/check-cursor-adapter.mjs +102 -0
  235. package/scripts/lib/validate/check-dead-bridge.mjs +24 -2
  236. package/scripts/lib/validate/check-doc-cli-commands.mjs +16 -32
  237. package/scripts/lib/validate/check-hooks-symmetry.mjs +59 -63
  238. package/scripts/lib/validate/check-playwright-mcp-canary.mjs +13 -22
  239. package/scripts/lib/validate/check-plugin-monitors.mjs +10 -4
  240. package/scripts/lib/validate/check-rules.mjs +153 -9
  241. package/scripts/lib/validate/check-test-value-bans.mjs +165 -17
  242. package/scripts/lib/validate/check-unwired-features.mjs +340 -32
  243. package/scripts/lib/validate/repo-files.mjs +275 -0
  244. package/scripts/lib/validate-vendored-rules.mjs +229 -7
  245. package/scripts/lib/vault-backfill/glab.mjs +91 -58
  246. package/scripts/lib/vault-backfill/manifest.mjs +28 -8
  247. package/scripts/lib/vault-mirror/process.mjs +99 -43
  248. package/scripts/lib/vault-mirror/telemetry.mjs +210 -0
  249. package/scripts/lib/vault-staleness-banner.mjs +76 -6
  250. package/scripts/lib/vault-status/board-writer.mjs +211 -10
  251. package/scripts/lib/vault-status/narrative-mirror.mjs +188 -8
  252. package/scripts/lib/vcs-repo-spec.mjs +182 -13
  253. package/scripts/lib/wave-executor/foreign-dispatch.mjs +832 -0
  254. package/scripts/lib/wave-resource-gate.mjs +67 -73
  255. package/scripts/lib/wave-transcript-tail.mjs +869 -0
  256. package/scripts/materialize-wave-scope.mjs +478 -0
  257. package/scripts/mcp-server.sh +11 -2
  258. package/scripts/parse-config.mjs +65 -0
  259. package/scripts/release.mjs +443 -122
  260. package/scripts/run-quality-gate.mjs +14 -0
  261. package/scripts/token-audit.sh +9 -2
  262. package/scripts/validate-plugin.mjs +6 -0
  263. package/scripts/validate-wave-scope.mjs +73 -1
  264. package/scripts/vault-backfill.mjs +32 -5
  265. package/scripts/vault-mirror.mjs +203 -34
  266. package/skills/_shared/monitor-patterns.md +31 -5
  267. package/skills/_shared/parallel-aware-auq.md +31 -25
  268. package/skills/_shared/parallel-aware-preamble.md +35 -4
  269. package/skills/_shared/platform-tools.md +11 -5
  270. package/skills/_shared/state-ownership.md +61 -8
  271. package/skills/autopilot/SKILL.md +5 -1
  272. package/skills/bootstrap/SKILL.md +5 -4
  273. package/skills/bootstrap/_shared-template.md +18 -10
  274. package/skills/bootstrap/deep-template.md +10 -6
  275. package/skills/bootstrap/fast-template.md +15 -8
  276. package/skills/bootstrap/standard-template.md +10 -6
  277. package/skills/brainstorm/SKILL.md +18 -18
  278. package/skills/brainstorm/soul.md +12 -0
  279. package/skills/claude-md-drift-check/checker.mjs +39 -11
  280. package/skills/discovery/SKILL.md +28 -24
  281. package/skills/dispatcher/SKILL.md +1 -1
  282. package/skills/eli5/SKILL.md +43 -0
  283. package/skills/evolve/SKILL.md +8 -9
  284. package/skills/gitlab-ops/SKILL.md +30 -26
  285. package/skills/grill/SKILL.md +6 -6
  286. package/skills/grill/soul.md +16 -0
  287. package/skills/journey-audit/SKILL.md +269 -0
  288. package/skills/memory-cleanup/SKILL.md +2 -2
  289. package/skills/npm-publish/SKILL.md +4 -4
  290. package/skills/peekaboo-driver/SKILL.md +18 -6
  291. package/skills/persona-panel/SKILL.md +1 -1
  292. package/skills/plan/SKILL.md +18 -16
  293. package/skills/plan/mode-feature.md +1 -1
  294. package/skills/plan/mode-new.md +35 -23
  295. package/skills/plan/soul.md +12 -0
  296. package/skills/reconcile/SKILL.md +44 -4
  297. package/skills/session-end/SKILL.md +70 -24
  298. package/skills/session-end/metrics-collection.md +7 -4
  299. package/skills/session-end/phase-3-6-tail.md +48 -5
  300. package/skills/session-end/phase-3-7a-recommendations.md +16 -2
  301. package/skills/session-plan/SKILL.md +6 -1
  302. package/skills/session-plan/wave-template.md +1 -0
  303. package/skills/session-start/SKILL.md +96 -48
  304. package/skills/session-start/phase-2-5-docs-planning.md +8 -8
  305. package/skills/session-start/phase-4-5-resource-health.md +82 -19
  306. package/skills/session-start/phase-7-5-mode-selector.md +15 -3
  307. package/skills/session-start/phase-8-5-express-path.md +77 -12
  308. package/skills/session-start/soul.md +110 -0
  309. package/skills/test-runner/SKILL.md +2 -2
  310. package/skills/using-orchestrator/SKILL.md +1 -1
  311. package/skills/vault-sync/validator.mjs +31 -0
  312. package/skills/wave-executor/SKILL.md +4 -2
  313. package/skills/wave-executor/circuit-breaker.md +34 -9
  314. package/skills/wave-executor/wave-loop.md +129 -24
  315. package/skills/write-executable-plan/SKILL.md +6 -6
  316. package/templates/_shared/journey-manifest.md +110 -0
  317. package/scripts/tests/fixtures/fetch-baseline/sample-rule.md +0 -8
  318. package/skills/vault-sync/tests/fixtures/archive-test-vault/90-archive/bad-archived.md +0 -8
  319. package/skills/vault-sync/tests/fixtures/archive-test-vault/_meta/.gitkeep +0 -0
  320. package/skills/vault-sync/tests/fixtures/archive-test-vault/live-note.md +0 -8
  321. package/skills/vault-sync/tests/fixtures/broken-frontmatter-vault/_meta/.gitkeep +0 -0
  322. package/skills/vault-sync/tests/fixtures/broken-frontmatter-vault/bad-type.md +0 -8
  323. package/skills/vault-sync/tests/fixtures/broken-frontmatter-vault/good-note.md +0 -8
  324. package/skills/vault-sync/tests/fixtures/clean-vault/.obsidian/config.md +0 -8
  325. package/skills/vault-sync/tests/fixtures/clean-vault/01-projects/foo/projects-baseline.md +0 -10
  326. package/skills/vault-sync/tests/fixtures/clean-vault/03-daily/daily-2026-04-13.md +0 -8
  327. package/skills/vault-sync/tests/fixtures/clean-vault/README.md +0 -3
  328. package/skills/vault-sync/tests/fixtures/clean-vault/hello-world.md +0 -11
  329. package/skills/vault-sync/tests/fixtures/dangling-link-vault/_meta/.gitkeep +0 -0
  330. package/skills/vault-sync/tests/fixtures/dangling-link-vault/has-dangling.md +0 -9
  331. package/skills/vault-sync/tests/fixtures/dangling-link-vault/real-target.md +0 -8
  332. package/skills/vault-sync/tests/fixtures/empty-vault/_meta/.gitkeep +0 -0
  333. package/skills/vault-sync/tests/fixtures/missing-field-vault/_meta/.gitkeep +0 -0
  334. package/skills/vault-sync/tests/fixtures/missing-field-vault/missing-id.md +0 -7
  335. package/skills/vault-sync/tests/fixtures/nested-tag-vault/03-daily/daily-2026-04-13.md +0 -9
  336. package/skills/vault-sync/tests/fixtures/nested-tag-vault/_meta/.gitkeep +0 -0
  337. package/skills/vault-sync/tests/fixtures/nested-tag-vault/nested-tags-note.md +0 -11
  338. package/skills/vault-sync/tests/fixtures/no-frontmatter-vault/README.md +0 -3
  339. package/skills/vault-sync/tests/fixtures/no-frontmatter-vault/_MOC.md +0 -3
  340. package/skills/vault-sync/tests/fixtures/no-frontmatter-vault/_meta/.gitkeep +0 -0
  341. package/skills/vault-sync/tests/fixtures/with-moc-vault/_MOC.md +0 -11
  342. package/skills/vault-sync/tests/fixtures/with-moc-vault/_meta/.gitkeep +0 -0
  343. package/skills/vault-sync/tests/fixtures/with-moc-vault/hello-world.md +0 -11
  344. package/skills/vault-sync/tests/schema-drift.test.mjs +0 -133
  345. package/templates/_shared/rules/parallel-sessions.md +0 -77
@@ -42,10 +42,20 @@ import {
42
42
  * append touches disk, so a bad write can never reach the file.
43
43
  *
44
44
  * @param {object} validated — already validated+normalized learning entry
45
+ * @param {{ legacyTolerant?: boolean }} [opts] — GitLab #386. When `true`,
46
+ * a field that was ALREADY ABSENT on `validated` (e.g. a legacy record with
47
+ * no `source_session`, tolerated by `readLearnings()`) stays tolerated after
48
+ * the round-trip too — the re-validation call below runs in the same
49
+ * tolerant mode. This does NOT weaken the #662 guarantee: a key that WAS
50
+ * present on `validated` (even `undefined`) and is no longer a key on the
51
+ * reparsed object is genuine JSON.stringify corruption, detected by the
52
+ * dedicated `droppedKeys` check below and thrown regardless of
53
+ * `legacyTolerant`. Default `false` — `appendLearning`'s single-record path
54
+ * calls this with no options and is unaffected.
45
55
  * @returns {string} the verified JSONL line (newline-terminated)
46
56
  * @throws {ValidationError} when the serialized line does not round-trip
47
57
  */
48
- function serializeLearningLineChecked(validated) {
58
+ function serializeLearningLineChecked(validated, { legacyTolerant = false } = {}) {
49
59
  let line;
50
60
  try {
51
61
  line = JSON.stringify(validated);
@@ -66,9 +76,23 @@ function serializeLearningLineChecked(validated) {
66
76
  `serialized learning line does not parse back as JSON: ${err.message}`
67
77
  );
68
78
  }
79
+ if (legacyTolerant) {
80
+ // A key that existed on `validated` (present, even as `undefined`) but
81
+ // vanished from `reparsed` was DROPPED by JSON.stringify — the exact
82
+ // 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.
85
+ const droppedKeys = Object.keys(validated).filter((k) => !(k in reparsed));
86
+ if (droppedKeys.length > 0) {
87
+ throw new ValidationError(
88
+ `learning lost field(s) during JSON round-trip serialization ` +
89
+ `(non-serializable value?): ${droppedKeys.join(', ')}`
90
+ );
91
+ }
92
+ }
69
93
  // Re-validate the round-tripped shape — catches required fields that were
70
- // present as `undefined`/`NaN` before stringify but vanished after.
71
- validateLearning(reparsed);
94
+ // present as `undefined`/`NaN` before stringify but vanished/coerced after.
95
+ validateLearning(reparsed, { legacyTolerant });
72
96
  return line + '\n';
73
97
  }
74
98
 
@@ -203,29 +227,50 @@ async function rotateBackups(dir, baseName, keep = BACKUP_KEEP) {
203
227
  * - `backup` (default `true`): before the destructive rename, copy the current
204
228
  * file to `${filePath}.bak-<ISO>`, then rotate to keep only the newest
205
229
  * {@link BACKUP_KEEP}. Rotation is best-effort and never blocks the rewrite.
230
+ * - `legacyTolerant` (default `true`, GitLab #386): this function is a
231
+ * ROUND-TRIP writer — its usual caller (`sweepExpiredLearnings` /
232
+ * `pruneLearnings` in `expiry-sweep.mjs`) reads the store with
233
+ * `readLearnings()` first, and that reader already tolerates a legacy
234
+ * record missing e.g. `source_session` (WARN, pass through unchanged — see
235
+ * `normalizeLearning`). Before this option existed, `rewriteLearnings()`
236
+ * re-validated with the SAME strict gate `appendLearning()` uses for a
237
+ * brand-new single record, so re-writing the unchanged KEEP batch of a
238
+ * mechanical sweep could throw on data the reader itself had just accepted
239
+ * — `sweep-expired-learnings --apply` failed on ANY store holding one such
240
+ * record, even though the sweep never touches that record's fields. The
241
+ * default is `true` precisely because the sweep/prune call sites cannot be
242
+ * changed to opt in explicitly without touching `expiry-sweep.mjs`, which
243
+ * passes no `legacyTolerant`; every field that genuinely CANNOT survive a
244
+ * round-trip (a value JSON.stringify drops or coerces, e.g. `undefined`/
245
+ * `NaN`) is still caught by the #662 checked serializer regardless of this
246
+ * flag — see {@link serializeLearningLineChecked}. Pass `false` to restore
247
+ * the pre-#386 fully-strict behaviour.
206
248
  *
207
249
  * @param {string} filePath
208
250
  * @param {object[]} entries
209
- * @param {{dryRun?: boolean, backup?: boolean}} [opts]
251
+ * @param {{dryRun?: boolean, backup?: boolean, legacyTolerant?: boolean}} [opts]
210
252
  * @returns {Promise<object[]>} validated entries (always returned, even dryRun)
211
253
  */
212
254
  export async function rewriteLearnings(
213
255
  filePath,
214
256
  entries,
215
- { dryRun = false, backup = true } = {}
257
+ { dryRun = false, backup = true, legacyTolerant = true } = {}
216
258
  ) {
217
259
  const validated = entries.map((e) =>
218
- validateLearning({
219
- ...e,
220
- schema_version: e?.schema_version ?? CURRENT_SCHEMA_VERSION,
221
- })
260
+ validateLearning(
261
+ {
262
+ ...e,
263
+ schema_version: e?.schema_version ?? CURRENT_SCHEMA_VERSION,
264
+ },
265
+ { legacyTolerant }
266
+ )
222
267
  );
223
268
  // Pre-write round-trip self-validation (#662): serialize ALL entries through
224
269
  // the checked serializer before touching disk — a single bad entry throws
225
270
  // ValidationError and the file is left untouched (atomicity preserved because
226
271
  // we validate the full batch first, then write once). This runs even under
227
272
  // dryRun, so an invalid entry is still rejected on a dry probe.
228
- const lines = validated.map((e) => serializeLearningLineChecked(e));
273
+ const lines = validated.map((e) => serializeLearningLineChecked(e, { legacyTolerant }));
229
274
 
230
275
  // dryRun (#721): validation has run; deliberately do NOT touch disk — no
231
276
  // rewrite, no backup — and hand the validated entries back to the caller.
@@ -59,7 +59,7 @@ export const CURRENT_ANONYMIZATION_VERSION = 1;
59
59
 
60
60
  /**
61
61
  * Type capability registry (Epic #723 I1, issue #733 Teil b) — the single
62
- * source of truth for every learning `type`'s TTL policy AND its two
62
+ * source of truth for every learning `type`'s TTL policy AND its three
63
63
  * cross-module capability axes. Before this registry existed, three modules
64
64
  * independently hand-maintained overlapping type lists that drifted out of
65
65
  * sync (`memory-proposals/schema.mjs` PROPOSAL_TYPES, `reconcile/eligibility.mjs`
@@ -76,39 +76,54 @@ export const CURRENT_ANONYMIZATION_VERSION = 1;
76
76
  * - ruleConvertible: may appear in `reconcile/eligibility.mjs` CONVERT_TYPES
77
77
  * (i.e. the reconcile engine may convert this type into a
78
78
  * conditional `.claude/rules/*.md` rule proposal).
79
+ * - hostScoped: this type's content is genuinely chip/OS-specific, so
80
+ * `reconcile/emitter.mjs` may copy a record's `host_class`
81
+ * through as the emitted rule's `host-class` activation
82
+ * axis (issue #1090; derived set: that module's
83
+ * HOST_SPECIFIC_TYPES). `false` for every general finding,
84
+ * whose `host_class` merely records the machine it was
85
+ * authored on and must never gate the rule.
86
+ *
87
+ * Every entry declares every axis explicitly — an omitted flag would read as
88
+ * `undefined` (falsy) and silently opt a new type out of a capability without
89
+ * anyone deciding that. Guarded by a test in
90
+ * `tests/lib/learnings-schema-normalization.test.mjs`.
79
91
  */
80
92
  export const LEARNING_TYPE_REGISTRY = Object.freeze({
81
- 'mode-selector-accuracy': Object.freeze({ ttlDays: 30, agentProposable: true, ruleConvertible: false }),
82
- 'hardware-pattern': Object.freeze({ ttlDays: 60, agentProposable: true, ruleConvertible: false }),
83
- 'fragile-file': Object.freeze({ ttlDays: 45, agentProposable: true, ruleConvertible: true }),
84
- 'effective-sizing': Object.freeze({ ttlDays: 45, agentProposable: true, ruleConvertible: false }),
85
- 'recurring-issue': Object.freeze({ ttlDays: 45, agentProposable: true, ruleConvertible: true }),
93
+ 'mode-selector-accuracy': Object.freeze({ ttlDays: 30, agentProposable: true, ruleConvertible: false, hostScoped: false }),
94
+ // hardware-pattern is the ONLY hostScoped type today: its content IS the
95
+ // chip/OS, so gating the emitted rule by host-class is faithful rather than
96
+ // an accidental one-machine restriction (#1090).
97
+ 'hardware-pattern': Object.freeze({ ttlDays: 60, agentProposable: true, ruleConvertible: false, hostScoped: true }),
98
+ 'fragile-file': Object.freeze({ ttlDays: 45, agentProposable: true, ruleConvertible: true, hostScoped: false }),
99
+ 'effective-sizing': Object.freeze({ ttlDays: 45, agentProposable: true, ruleConvertible: false, hostScoped: false }),
100
+ 'recurring-issue': Object.freeze({ ttlDays: 45, agentProposable: true, ruleConvertible: true, hostScoped: false }),
86
101
  // workflow-pattern / proven-pattern: flipped ruleConvertible false->true
87
102
  // (issue #900) — the real corpus census showed a large volume of live
88
103
  // `workflow-pattern`/`proven-pattern` records (post type-alias-normalization,
89
104
  // see LEARNING_TYPE_ALIASES below) that carried usable file_paths but were
90
105
  // structurally unconvertible before this flip.
91
- 'workflow-pattern': Object.freeze({ ttlDays: 90, agentProposable: true, ruleConvertible: true }),
92
- 'proven-pattern': Object.freeze({ ttlDays: 90, agentProposable: true, ruleConvertible: true }),
93
- 'anti-pattern': Object.freeze({ ttlDays: 90, agentProposable: true, ruleConvertible: true }),
94
- 'autopilot-effectiveness': Object.freeze({ ttlDays: 90, agentProposable: true, ruleConvertible: false }),
106
+ 'workflow-pattern': Object.freeze({ ttlDays: 90, agentProposable: true, ruleConvertible: true, hostScoped: false }),
107
+ 'proven-pattern': Object.freeze({ ttlDays: 90, agentProposable: true, ruleConvertible: true, hostScoped: false }),
108
+ 'anti-pattern': Object.freeze({ ttlDays: 90, agentProposable: true, ruleConvertible: true, hostScoped: false }),
109
+ 'autopilot-effectiveness': Object.freeze({ ttlDays: 90, agentProposable: true, ruleConvertible: false, hostScoped: false }),
95
110
  // autonomy-verdict (#683): repo/scope readiness synthesis from autopilot
96
111
  // effectiveness plus skill-judge signals. 90d matches the operational
97
112
  // autopilot-effectiveness horizon it depends on. Analyzer-only — never
98
113
  // agent-proposable.
99
- 'autonomy-verdict': Object.freeze({ ttlDays: 90, agentProposable: false, ruleConvertible: false }),
114
+ 'autonomy-verdict': Object.freeze({ ttlDays: 90, agentProposable: false, ruleConvertible: false, hostScoped: false }),
100
115
  // domain-regression (#638): a sidecar-sourced regression flag (metric baseline→recent
101
116
  // delta) surfaced via /evolve extra-sources. 60d aligns with the moderate-decay tier
102
117
  // (hardware-pattern / default) — a regression signal should age out if it stops recurring.
103
- 'domain-regression': Object.freeze({ ttlDays: 60, agentProposable: true, ruleConvertible: false }),
104
- 'convention': Object.freeze({ ttlDays: 90, agentProposable: true, ruleConvertible: true }),
105
- 'architecture-pattern': Object.freeze({ ttlDays: 90, agentProposable: true, ruleConvertible: true }),
106
- 'design-pattern': Object.freeze({ ttlDays: 90, agentProposable: true, ruleConvertible: true }),
118
+ 'domain-regression': Object.freeze({ ttlDays: 60, agentProposable: true, ruleConvertible: false, hostScoped: false }),
119
+ 'convention': Object.freeze({ ttlDays: 90, agentProposable: true, ruleConvertible: true, hostScoped: false }),
120
+ 'architecture-pattern': Object.freeze({ ttlDays: 90, agentProposable: true, ruleConvertible: true, hostScoped: false }),
121
+ 'design-pattern': Object.freeze({ ttlDays: 90, agentProposable: true, ruleConvertible: true, hostScoped: false }),
107
122
  // fragile-pattern / stagnation-class-frequency: 0 live instances as of
108
123
  // 2026-07-02 (see reconcile/eligibility.mjs census) — CONVERT-eligible but
109
124
  // NOT agent-proposable (analyzer-synthesized classes, not agent-observed).
110
- 'fragile-pattern': Object.freeze({ ttlDays: 45, agentProposable: false, ruleConvertible: true }),
111
- 'stagnation-class-frequency': Object.freeze({ ttlDays: 60, agentProposable: false, ruleConvertible: true }),
125
+ 'fragile-pattern': Object.freeze({ ttlDays: 45, agentProposable: false, ruleConvertible: true, hostScoped: false }),
126
+ 'stagnation-class-frequency': Object.freeze({ ttlDays: 60, agentProposable: false, ruleConvertible: true, hostScoped: false }),
112
127
  });
113
128
 
114
129
  /**
@@ -215,47 +230,99 @@ export class ValidationError extends Error {
215
230
  * Throws ValidationError on contract violations. Does NOT mutate input.
216
231
  *
217
232
  * @param {object} entry — candidate learning
233
+ * @param {{ legacyTolerant?: boolean }} [opts] — GitLab #386. When `true`, a
234
+ * value `normalizeLearning()` already passes through UNCHECKED (or merely
235
+ * DEFAULTED, never validated) on read is no longer rejected here either:
236
+ * an out-of-enum `schema_version` (measured against the real EventDrop.at
237
+ * store: 3 live records carry `schema_version: 2`), the
238
+ * LEGACY_REQUIRED_FIELDS presence check, the `confidence` range/type check
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
244
+ * being validated regardless when its DATA TYPE can be silently corrupted
245
+ * by a JSON round-trip (a present-but-malformed `confidence` still throws
246
+ * — see io.mjs #662): this option relaxes "you must HAVE a valid value",
247
+ * never "if a round-trip could have silently mangled it, that's fine too".
248
+ * The privacy CONTRACT (`scope: 'public'` requiring `anonymized`/
249
+ * `host_class` to be consistent) is NEVER relaxed — it only ever fires for
250
+ * a record that genuinely claims `scope: 'public'`, which is exactly the
251
+ * case a round-trip must not silently launder. Default `false` — every
252
+ * existing direct caller (including `appendLearning`'s single-record write
253
+ * path) is unaffected.
218
254
  * @returns {object} normalized entry with scope/host_class/anonymized defaulted
219
255
  */
220
- export function validateLearning(entry) {
256
+ export function validateLearning(entry, { legacyTolerant = false } = {}) {
221
257
  if (!entry || typeof entry !== 'object') {
222
258
  throw new ValidationError('learning must be an object');
223
259
  }
224
260
 
225
261
  // schema_version: 0 (implicit/legacy), 1 (current). Both accepted.
262
+ // `normalizeLearning()` never validates this value on read (any value
263
+ // passes through as-is), so under legacyTolerant an ABSENT schema_version
264
+ // (defaults to the valid `0`) or one already present with SOME value is
265
+ // both fine — only a truly missing-AND-invalid combination cannot occur
266
+ // (missing defaults to 0, which is always valid), so in practice this skips
267
+ // the check whenever legacyTolerant is set and a non-legacy value survived
268
+ // a prior read (e.g. `schema_version: 2`, measured live in production).
226
269
  const schemaVersion = entry.schema_version ?? 0;
227
- if (schemaVersion !== 0 && schemaVersion !== 1) {
270
+ if (!legacyTolerant && schemaVersion !== 0 && schemaVersion !== 1) {
228
271
  throw new ValidationError(
229
272
  `schema_version must be 0 (legacy) or 1, got: ${schemaVersion}`
230
273
  );
231
274
  }
232
275
 
233
- for (const field of LEGACY_REQUIRED_FIELDS) {
234
- if (!(field in entry)) {
235
- throw new ValidationError(`learning missing required field: ${field}`);
276
+ if (!legacyTolerant) {
277
+ for (const field of LEGACY_REQUIRED_FIELDS) {
278
+ if (!(field in entry)) {
279
+ throw new ValidationError(`learning missing required field: ${field}`);
280
+ }
236
281
  }
237
282
  }
238
283
 
239
- if (typeof entry.confidence !== 'number' || entry.confidence < 0 || entry.confidence > 1) {
240
- throw new ValidationError(`confidence must be a number in [0, 1], got: ${entry.confidence}`);
284
+ // confidence carries its own type/range check in addition to the presence
285
+ // loop above, so it needs its own legacyTolerant gate: skip ONLY when the
286
+ // key is genuinely absent (mirrors the presence loop); a present-but-bad
287
+ // value (wrong type, out of range, or `null` after a JSON round-trip
288
+ // coerced a non-serializable value — see io.mjs #662) still throws under
289
+ // legacyTolerant, same as under strict mode.
290
+ if (!legacyTolerant || 'confidence' in entry) {
291
+ if (typeof entry.confidence !== 'number' || entry.confidence < 0 || entry.confidence > 1) {
292
+ throw new ValidationError(`confidence must be a number in [0, 1], got: ${entry.confidence}`);
293
+ }
241
294
  }
242
295
 
296
+ // scope/host_class/anonymized: `normalizeLearning()` only DEFAULTS an
297
+ // absent value (`d.scope ?? 'local'`, etc.) on read — it never validates a
298
+ // 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
302
+ // therefore skipped under legacyTolerant. Unlike `confidence` (a number,
303
+ // where `NaN`/`Infinity` are non-JSON-safe and silently become `null`
304
+ // across a JSON round-trip — the #662 concern), a string/string-or-null/
305
+ // boolean value round-trips through JSON byte-for-byte, so there is no
306
+ // corresponding round-trip-corruption risk to guard against here.
243
307
  const scope = entry.scope ?? 'local';
244
- if (!VALID_SCOPES.includes(scope)) {
308
+ if (!legacyTolerant && !VALID_SCOPES.includes(scope)) {
245
309
  throw new ValidationError(`scope must be one of ${VALID_SCOPES.join('|')}, got: ${scope}`);
246
310
  }
247
311
 
248
312
  const hostClass = entry.host_class ?? null;
249
- if (hostClass !== null && typeof hostClass !== 'string') {
313
+ if (!legacyTolerant && hostClass !== null && typeof hostClass !== 'string') {
250
314
  throw new ValidationError(`host_class must be string or null, got: ${typeof hostClass}`);
251
315
  }
252
316
 
253
317
  const anonymized = entry.anonymized ?? false;
254
- if (typeof anonymized !== 'boolean') {
318
+ if (!legacyTolerant && typeof anonymized !== 'boolean') {
255
319
  throw new ValidationError(`anonymized must be boolean, got: ${typeof anonymized}`);
256
320
  }
257
321
 
258
- // Privacy contract
322
+ // Privacy contract — NEVER relaxed, legacyTolerant or not: an out-of-enum
323
+ // `scope` (garbage or otherwise) is by definition not `'public'`, so this
324
+ // pair only ever fires for a record that genuinely claims `scope: 'public'`
325
+ // — exactly the case a round-trip must not silently launder.
259
326
  if (scope === 'public' && !anonymized) {
260
327
  throw new ValidationError(
261
328
  'scope=public requires anonymized=true (privacy contract violation)'
@@ -68,6 +68,7 @@ import path from 'node:path';
68
68
  import { enumerateCandidates } from './dispatcher/enumerate.mjs';
69
69
  import { readLock, isLockLive, isPidAliveOnHost, LOCK_PATH, DEFAULT_TTL_HOURS } from './session-lock.mjs';
70
70
  import { emitEvent } from './events.mjs';
71
+ import { hostnamesMatch, lockHostCandidate } from './host-identity.mjs';
71
72
 
72
73
  const REAPED_ARCHIVE_SUBDIR = '.orchestrator/tmp/reaped-locks';
73
74
  const REAPED_EVENT = 'orchestrator.session.lock.reaped';
@@ -463,7 +464,12 @@ async function evaluateRepo(repoRoot, { nowMs, dryRun, currentSessionId, reapMod
463
464
  };
464
465
  }
465
466
 
466
- const ownHost = lock.host === D.hostname();
467
+ // #1072: alias-aware host identity. A raw comparison classified this
468
+ // machine's OWN orphaned lock as cross-host after a hostname flip, so it was
469
+ // never reaped and every subsequent session needed operator intervention.
470
+ // The invariant is unchanged for a genuinely foreign host: its name was never
471
+ // written into this machine's self-alias ledger, so it can never match.
472
+ const ownHost = hostnamesMatch(lockHostCandidate(lock), D.hostname());
467
473
 
468
474
  // Invariant (c): cross-host leases are NEVER auto-reaped — only listed.
469
475
  if (!ownHost) {
@@ -28,6 +28,7 @@ import path from 'node:path';
28
28
  import crypto from 'node:crypto';
29
29
 
30
30
  import { tryAcquireFileLock } from '../file-lock.mjs';
31
+ import { hostnamesMatch, lockHostCandidate, stableHostname } from '../host-identity.mjs';
31
32
  import { nowIso, delay, parseLockBody } from './lock-body.mjs';
32
33
 
33
34
  // ---------------------------------------------------------------------------
@@ -67,7 +68,9 @@ function stagingFenceLockPathFor(repoRoot) {
67
68
  function buildStagingFenceLockBody({ holder }) {
68
69
  return {
69
70
  pid: process.pid,
71
+ // `host` raw + `host_id` normalised (#1072) — see buildStateLockBody.
70
72
  host: os.hostname(),
73
+ host_id: stableHostname(),
71
74
  acquiredAt: nowIso(),
72
75
  holder: typeof holder === 'string' && holder.length > 0 ? holder : `pid-${process.pid}`,
73
76
  };
@@ -178,7 +181,8 @@ export function releaseStagingFenceLock({ repoRoot, holder } = {}) {
178
181
 
179
182
  const ownerMatch = typeof holder === 'string' && holder.length > 0
180
183
  ? lock.holder === holder
181
- : lock.pid === process.pid && lock.host === os.hostname();
184
+ // #1072: alias-aware host identity, not a raw os.hostname() comparison.
185
+ : lock.pid === process.pid && hostnamesMatch(lockHostCandidate(lock), os.hostname());
182
186
 
183
187
  if (!ownerMatch) {
184
188
  return { ok: false, reason: 'not-owner' };
@@ -41,6 +41,7 @@ import crypto from 'node:crypto';
41
41
 
42
42
  import { _parseStateMdLock } from '../config/state-md-lock.mjs';
43
43
  import { tryAcquireFileLock } from '../file-lock.mjs';
44
+ import { hostnamesMatch, lockHostCandidate, stableHostname } from '../host-identity.mjs';
44
45
  import { nowIso, delay, parseLockBody } from './lock-body.mjs';
45
46
 
46
47
  // ---------------------------------------------------------------------------
@@ -78,7 +79,11 @@ function stateLockPathFor(repoRoot) {
78
79
  function buildStateLockBody({ holder }) {
79
80
  return {
80
81
  pid: process.pid,
82
+ // `host` raw + `host_id` normalised (#1072). The written body comes from
83
+ // tryAcquireFileLock, which carries the same pair — this keeps the shape
84
+ // declared here truthful about what lands on disk.
81
85
  host: os.hostname(),
86
+ host_id: stableHostname(),
82
87
  acquiredAt: nowIso(),
83
88
  holder: typeof holder === 'string' && holder.length > 0 ? holder : `pid-${process.pid}`,
84
89
  };
@@ -224,7 +229,9 @@ export function releaseStateLock({ repoRoot, sessionId, holder } = {}) {
224
229
  const expectedHolder = holder ?? sessionId ?? null;
225
230
  const ownerMatch = expectedHolder !== null
226
231
  ? lock.holder === expectedHolder
227
- : lock.pid === process.pid && lock.host === os.hostname();
232
+ // #1072: alias-aware host identity a raw comparison strands the lock as
233
+ // 'not-owner' once os.hostname() flips spelling mid-session.
234
+ : lock.pid === process.pid && hostnamesMatch(lockHostCandidate(lock), os.hostname());
228
235
 
229
236
  if (!ownerMatch) {
230
237
  return { ok: false, reason: 'not-owner' };
@@ -308,7 +308,7 @@ async function countMemoryFiles(dir) {
308
308
  *
309
309
  * @param {object} args
310
310
  * @param {string} args.repoRoot — absolute path
311
- * @param {string} [args.memoryDir] — defaults to `resolveMemoryDir()`
311
+ * @param {string} [args.memoryDir] — defaults to `resolveMemoryDir(repoRoot)`
312
312
  * @param {string} [args.learningsPath] — defaults to `<repoRoot>/.orchestrator/metrics/learnings.jsonl`
313
313
  * @param {Date} [args.now] — injectable clock for tests
314
314
  * @param {{enabled?: boolean, 'half-life-days'?: number, 'floor-factor'?: number}} [args.evolveDecay]
@@ -326,9 +326,12 @@ export async function readBannerInputs({ repoRoot, memoryDir, learningsPath, now
326
326
  if (typeof repoRoot !== 'string' || repoRoot.length === 0) {
327
327
  throw new TypeError('readBannerInputs: repoRoot is required (absolute path)');
328
328
  }
329
+ // #1071: bind the memory dir to the SAME root as learnings/sessions/peer-cards
330
+ // below. Called with no argument it derived from the ambient `process.cwd()`,
331
+ // which reported `0 memory files` beside a 245-session count from `repoRoot`.
329
332
  const memDir = typeof memoryDir === 'string' && memoryDir.length > 0
330
333
  ? memoryDir
331
- : resolveMemoryDir();
334
+ : resolveMemoryDir(repoRoot);
332
335
  const learningsFile = typeof learningsPath === 'string' && learningsPath.length > 0
333
336
  ? learningsPath
334
337
  : path.join(repoRoot, '.orchestrator', 'metrics', 'learnings.jsonl');
@@ -15,17 +15,26 @@ import os from 'node:os';
15
15
  /**
16
16
  * Resolve the project-specific memory directory used by the Claude Code harness.
17
17
  *
18
- * Mirrors the harness convention: `~/.claude/projects/<encoded-cwd>/memory/`
19
- * where `<encoded-cwd>` is the cwd with BOTH `/` AND `.` replaced by `-`. The
20
- * dot replacement matters for users with a trailing-`.` in their home dir
21
- * (e.g. `/Users/<owner>.`) — without it the resolved path diverges from
18
+ * Mirrors the harness convention: `~/.claude/projects/<encoded-root>/memory/`
19
+ * where `<encoded-root>` is the project root with BOTH `/` AND `.` replaced by
20
+ * `-`. The dot replacement matters for users with a trailing-`.` in their home
21
+ * dir (e.g. `/Users/<owner>.`) — without it the resolved path diverges from
22
22
  * what the harness actually wrote.
23
23
  *
24
24
  * Verified empirically against `~/.claude/projects/` directory naming.
25
25
  *
26
+ * `repoRoot` is explicit (#1071) because every OTHER banner input is already
27
+ * bound to it: with the memory dir alone derived from the ambient `process.cwd()`,
28
+ * a caller reading sessions/learnings/peer-cards out of the primary checkout
29
+ * reported `0 memory files` whenever the process ran from a worktree or a
30
+ * subdirectory. Defaulted, so existing zero-arg callers keep their behaviour.
31
+ *
32
+ * @param {string} [repoRoot] — absolute project root; defaults to `process.cwd()`.
26
33
  * @returns {string} Absolute path to the memory directory (not guaranteed to exist).
27
34
  */
28
- export function resolveMemoryDir() {
29
- const encoded = process.cwd().replaceAll('/', '-').replaceAll('.', '-');
35
+ export function resolveMemoryDir(repoRoot = process.cwd()) {
36
+ const root =
37
+ typeof repoRoot === 'string' && repoRoot.length > 0 ? repoRoot : process.cwd();
38
+ const encoded = root.replaceAll('/', '-').replaceAll('.', '-');
30
39
  return path.join(os.homedir(), '.claude', 'projects', encoded, 'memory');
31
40
  }
@@ -82,6 +82,57 @@ export function safeBootstrapLock(signals) {
82
82
  : null;
83
83
  }
84
84
 
85
+ /**
86
+ * Read a session record's completion rate, tolerating both the shape
87
+ * `sessions.jsonl` actually writes and the legacy flat one.
88
+ *
89
+ * Production records nest it: `{effectiveness: {completion_rate: 0.77, …}}`.
90
+ * The top-level `completion_rate` key is ABSENT — not present-but-null, which
91
+ * an earlier revision of this comment claimed. Measured 2026-08-28 @ 7daa3d2
92
+ * over `.orchestrator/metrics/sessions.jsonl` (281 records):
93
+ * `jq -s '{hasTopLevelKey: ([.[]|select(has("completion_rate"))]|length), …}'`
94
+ * → `hasTopLevelKey: 0`, `nested: 182`, and 99 records carrying NO completion
95
+ * rate in either shape. Reading only the flat field therefore made every
96
+ * average 0 in production — see #1071.
97
+ *
98
+ * Precedence mirrors `completionOf()` in
99
+ * `scripts/lib/evolve/autopilot-effectiveness.mjs`: nested canonical fields
100
+ * first, flat legacy fields as a backward-compatible fallback for
101
+ * hand-written/pre-nesting records.
102
+ *
103
+ * @param {unknown} session
104
+ * @returns {number} completion rate, or 0 when unknown
105
+ */
106
+ function completionRateOf(session) {
107
+ if (session === null || typeof session !== 'object') return 0;
108
+ const eff = (session.effectiveness !== null && typeof session.effectiveness === 'object')
109
+ ? session.effectiveness
110
+ : {};
111
+ const raw =
112
+ eff.completion_rate ?? eff.completion_ratio ?? session.completion_rate ?? session.completion_ratio;
113
+ return typeof raw === 'number' && !Number.isNaN(raw) ? raw : 0;
114
+ }
115
+
116
+ /**
117
+ * Mean completion rate across a session slice. Unknown values contribute 0 and
118
+ * the divisor stays the slice length — an incomplete slice can never inflate
119
+ * the average past the >= 0.9 bonus threshold.
120
+ *
121
+ * The divisor is the load-bearing half, not the numerator: 99 of the 281
122
+ * records in production carry no completion rate in EITHER shape (measured
123
+ * 2026-08-28 @ 7daa3d2), so a divisor of "known values only" would let a single
124
+ * 1.0 session beside two unknowns score a perfect 1.0 and hand out the trend
125
+ * bonus on one data point. Pinned by the divisor test in
126
+ * `tests/lib/mode-selector/scoring.test.mjs`.
127
+ *
128
+ * @param {any[]} sessions
129
+ * @returns {number}
130
+ */
131
+ function avgCompletionRate(sessions) {
132
+ if (sessions.length === 0) return 0;
133
+ return sessions.reduce((sum, s) => sum + completionRateOf(s), 0) / sessions.length;
134
+ }
135
+
85
136
  // ---------------------------------------------------------------------------
86
137
  // Public API
87
138
  // ---------------------------------------------------------------------------
@@ -107,9 +158,7 @@ export function computeDelta(candidateMode, signals) {
107
158
  (s) => typeof s === 'object' && s !== null && s.session_type === candidateMode,
108
159
  );
109
160
  if (allMatch) {
110
- const avgCompletion =
111
- last3.reduce((sum, s) => sum + (typeof s.completion_rate === 'number' ? s.completion_rate : 0), 0) /
112
- 3;
161
+ const avgCompletion = avgCompletionRate(last3);
113
162
  trendBonus = avgCompletion >= 0.9 ? 0.15 : 0.075;
114
163
  }
115
164
  }
@@ -151,9 +200,7 @@ export function computeDelta(candidateMode, signals) {
151
200
  (s) => typeof s === 'object' && s !== null && s.session_type === otherMode,
152
201
  );
153
202
  if (allOther) {
154
- const avgCompletion =
155
- last3.reduce((sum, s) => sum + (typeof s.completion_rate === 'number' ? s.completion_rate : 0), 0) /
156
- 3;
203
+ const avgCompletion = avgCompletionRate(last3);
157
204
  if (avgCompletion >= 0.9) {
158
205
  penalty += 0.10;
159
206
  break; // only one other dominant mode possible