session-orchestrator 3.22.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 (268) 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 +8 -0
  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/050-plan.mdc +2 -2
  38. package/.cursor/rules/070-gitlab-ops.mdc +73 -57
  39. package/.cursor/rules/080-ecosystem-health.mdc +7 -7
  40. package/.cursor/skills/architecture/SKILL.md +13 -0
  41. package/.cursor/skills/autopilot/SKILL.md +12 -0
  42. package/.cursor/skills/bootstrap/SKILL.md +12 -0
  43. package/.cursor/skills/brainstorm/SKILL.md +13 -0
  44. package/.cursor/skills/claude-md-drift-check/SKILL.md +13 -0
  45. package/.cursor/skills/contract-version-bump/SKILL.md +12 -0
  46. package/.cursor/skills/convergence-monitoring/SKILL.md +12 -0
  47. package/.cursor/skills/daily/SKILL.md +12 -0
  48. package/.cursor/skills/debug/SKILL.md +13 -0
  49. package/.cursor/skills/discovery/SKILL.md +13 -0
  50. package/.cursor/skills/dispatcher/SKILL.md +13 -0
  51. package/.cursor/skills/docs-orchestrator/SKILL.md +13 -0
  52. package/.cursor/skills/domain-model/SKILL.md +13 -0
  53. package/.cursor/skills/ecosystem-health/SKILL.md +13 -0
  54. package/.cursor/skills/eli5/SKILL.md +13 -0
  55. package/.cursor/skills/eval/SKILL.md +12 -0
  56. package/.cursor/skills/evolve/SKILL.md +13 -0
  57. package/.cursor/skills/frontmatter-guard/SKILL.md +13 -0
  58. package/.cursor/skills/gitlab-ops/SKILL.md +13 -0
  59. package/.cursor/skills/gitlab-portfolio/SKILL.md +13 -0
  60. package/.cursor/skills/grill/SKILL.md +13 -0
  61. package/.cursor/skills/hook-development/SKILL.md +13 -0
  62. package/.cursor/skills/journey-audit/SKILL.md +13 -0
  63. package/.cursor/skills/mcp-builder/SKILL.md +13 -0
  64. package/.cursor/skills/memory-cleanup/SKILL.md +12 -0
  65. package/.cursor/skills/mode-selector/SKILL.md +13 -0
  66. package/.cursor/skills/npm-publish/SKILL.md +12 -0
  67. package/.cursor/skills/peekaboo-driver/SKILL.md +13 -0
  68. package/.cursor/skills/persona-panel/SKILL.md +12 -0
  69. package/.cursor/skills/plan/SKILL.md +13 -0
  70. package/.cursor/skills/playwright-driver/SKILL.md +13 -0
  71. package/.cursor/skills/quality-gates/SKILL.md +13 -0
  72. package/.cursor/skills/reconcile/SKILL.md +12 -0
  73. package/.cursor/skills/repo-audit/SKILL.md +13 -0
  74. package/.cursor/skills/session-end/SKILL.md +13 -0
  75. package/.cursor/skills/session-plan/SKILL.md +13 -0
  76. package/.cursor/skills/session-start/SKILL.md +13 -0
  77. package/.cursor/skills/skill-creator/SKILL.md +13 -0
  78. package/.cursor/skills/spinout/SKILL.md +12 -0
  79. package/.cursor/skills/sunset-review/SKILL.md +13 -0
  80. package/.cursor/skills/test-runner/SKILL.md +13 -0
  81. package/.cursor/skills/tmux-layout/SKILL.md +13 -0
  82. package/.cursor/skills/ubiquitous-language/SKILL.md +13 -0
  83. package/.cursor/skills/using-orchestrator/SKILL.md +13 -0
  84. package/.cursor/skills/vault-mirror/SKILL.md +13 -0
  85. package/.cursor/skills/vault-sync/SKILL.md +13 -0
  86. package/.cursor/skills/wave-executor/SKILL.md +13 -0
  87. package/.cursor/skills/write-executable-plan/SKILL.md +13 -0
  88. package/.mcp.json +4 -1
  89. package/CHANGELOG.md +168 -0
  90. package/README.md +18 -15
  91. package/agents/AGENTS.md +23 -4
  92. package/agents/code-implementer.md +2 -1
  93. package/agents/db-specialist.md +2 -1
  94. package/agents/docs-writer.md +3 -1
  95. package/agents/eval-judge.md +1 -1
  96. package/agents/session-reviewer.md +7 -1
  97. package/agents/test-writer.md +2 -1
  98. package/agents/ui-developer.md +2 -1
  99. package/commands/bootstrap.md +2 -2
  100. package/commands/close.md +3 -1
  101. package/commands/go.md +1 -1
  102. package/commands/journey-audit.md +43 -0
  103. package/docs/USER-GUIDE.md +2 -2
  104. package/docs/ci-setup.md +14 -0
  105. package/docs/codex-setup.md +64 -0
  106. package/docs/components.md +6 -6
  107. package/docs/cursor-setup.md +26 -47
  108. package/docs/events-schema.md +76 -4
  109. package/docs/github-mirror-protection.md +197 -0
  110. package/docs/pi-setup.md +2 -0
  111. package/docs/rule-authoring.md +3 -1
  112. package/docs/scope-collision-guard.md +49 -2
  113. package/docs/session-config-reference.md +26 -4
  114. package/docs/session-config-template.md +4 -3
  115. package/docs/telemetry.md +22 -0
  116. package/hooks/_lib/lock-bootstrap.mjs +8 -4
  117. package/hooks/_lib/vcs-create-matcher.mjs +397 -38
  118. package/hooks/enforce-scope.mjs +64 -0
  119. package/hooks/hooks-codex.json +1 -1
  120. package/hooks/hooks-cursor.json +201 -20
  121. package/hooks/hooks-pi.json +1 -1
  122. package/hooks/hooks.json +2 -2
  123. package/hooks/on-session-end.mjs +211 -10
  124. package/hooks/on-session-start.mjs +214 -11
  125. package/hooks/on-stop.mjs +48 -9
  126. package/hooks/post-subagent-discovery-validator.mjs +34 -3
  127. package/hooks/post-tool-batch-wave-signal.mjs +11 -2
  128. package/hooks/pre-bash-issue-budget.mjs +117 -4
  129. package/hooks/pre-bash-sessions-ledger-guard.mjs +159 -0
  130. package/hooks/pre-bash-staging-fence.mjs +4 -0
  131. package/hooks/pre-task-scope-disjoint.mjs +368 -35
  132. package/hooks/skill-invocation-telemetry.mjs +21 -10
  133. package/monitors/monitors.json +6 -0
  134. package/package.json +1 -1
  135. package/pi/prompts/journey-audit.md +12 -0
  136. package/rules/_index.md +9 -1
  137. package/rules/always-on/ask-via-tool.md +62 -0
  138. package/rules/always-on/bash-harness-pitfalls.md +168 -0
  139. package/rules/always-on/build-value.md +47 -0
  140. package/rules/always-on/cross-session-messaging.md +59 -0
  141. package/rules/always-on/loop-and-monitor.md +221 -0
  142. package/rules/always-on/parallel-sessions.md +142 -12
  143. package/rules/always-on/receiving-review.md +108 -0
  144. package/rules/always-on/test-value.md +40 -0
  145. package/rules/always-on/verification-before-completion.md +77 -0
  146. package/scripts/archive-closed-prds.mjs +258 -18
  147. package/scripts/autopilot.mjs +5 -0
  148. package/scripts/backfill-evidence-digest.mjs +376 -0
  149. package/scripts/cursor-install.mjs +89 -48
  150. package/scripts/export-hw-learnings.mjs +143 -2
  151. package/scripts/express-path.mjs +299 -0
  152. package/scripts/generate-cursor-adapter.mjs +253 -0
  153. package/scripts/github-protection-audit.mjs +358 -0
  154. package/scripts/lib/autopilot/worktree-pipeline.mjs +240 -16
  155. package/scripts/lib/build-live-signals.mjs +24 -5
  156. package/scripts/lib/ci-status-banner.mjs +158 -11
  157. package/scripts/lib/command-blocker.mjs +70 -0
  158. package/scripts/lib/config/reconcile.mjs +79 -4
  159. package/scripts/lib/config/section-extractor.mjs +235 -36
  160. package/scripts/lib/config-schema.mjs +9 -1
  161. package/scripts/lib/config.mjs +57 -6
  162. package/scripts/lib/convergence-monitor.mjs +13 -2
  163. package/scripts/lib/cursor-hook-bridge.mjs +443 -0
  164. package/scripts/lib/dispatcher/cli.mjs +2 -2
  165. package/scripts/lib/express-path.mjs +327 -0
  166. package/scripts/lib/file-lock.mjs +22 -4
  167. package/scripts/lib/gates/gate-full.mjs +81 -8
  168. package/scripts/lib/gates/gate-helpers.mjs +76 -15
  169. package/scripts/lib/git-config-drift.mjs +134 -5
  170. package/scripts/lib/host-identity.mjs +247 -2
  171. package/scripts/lib/instruction-budget-guard.mjs +31 -1
  172. package/scripts/lib/issue-budget.mjs +229 -30
  173. package/scripts/lib/learnings/io.mjs +55 -10
  174. package/scripts/lib/learnings/schema.mjs +95 -28
  175. package/scripts/lib/lock-reaper.mjs +7 -1
  176. package/scripts/lib/locks/staging-fence-lock.mjs +5 -1
  177. package/scripts/lib/locks/state-md-lock.mjs +8 -1
  178. package/scripts/lib/memory-banner.mjs +5 -2
  179. package/scripts/lib/memory-paths.mjs +15 -6
  180. package/scripts/lib/mode-selector/scoring.mjs +53 -6
  181. package/scripts/lib/platform.mjs +72 -9
  182. package/scripts/lib/plugin-root.mjs +143 -19
  183. package/scripts/lib/project-hygiene.mjs +43 -3
  184. package/scripts/lib/quality-gate.mjs +271 -13
  185. package/scripts/lib/reconcile/emitter.mjs +87 -19
  186. package/scripts/lib/reconcile/engine.mjs +281 -13
  187. package/scripts/lib/reconcile/idempotency.mjs +102 -1
  188. package/scripts/lib/reconcile/renderer.mjs +148 -3
  189. package/scripts/lib/reconcile/sanitize.mjs +40 -17
  190. package/scripts/lib/reconcile/writer.mjs +415 -84
  191. package/scripts/lib/rule-loader.mjs +37 -2
  192. package/scripts/lib/rules-sync.mjs +51 -8
  193. package/scripts/lib/scope-gate.mjs +90 -0
  194. package/scripts/lib/session-close-backfill.mjs +369 -28
  195. package/scripts/lib/session-discovery.mjs +13 -3
  196. package/scripts/lib/session-end/phase-skip.mjs +37 -4
  197. package/scripts/lib/session-end/worktree-cleanup.mjs +154 -7
  198. package/scripts/lib/session-id.mjs +30 -14
  199. package/scripts/lib/session-identity/own-session.mjs +159 -0
  200. package/scripts/lib/session-lock.mjs +85 -30
  201. package/scripts/lib/session-schema/normalizer.mjs +70 -3
  202. package/scripts/lib/session-schema/validator.mjs +40 -0
  203. package/scripts/lib/session-start-probes.mjs +608 -0
  204. package/scripts/lib/session-transition.mjs +277 -0
  205. package/scripts/lib/sessions-staleness-banner.mjs +124 -57
  206. package/scripts/lib/spiral-carryover.mjs +90 -9
  207. package/scripts/lib/state-md/frontmatter-mutators.mjs +41 -8
  208. package/scripts/lib/state-md/mission-status.mjs +350 -52
  209. package/scripts/lib/state-md/yaml-parser.mjs +145 -16
  210. package/scripts/lib/state-md.mjs +12 -2
  211. package/scripts/lib/telemetry/sync.mjs +46 -8
  212. package/scripts/lib/validate/check-agents.mjs +66 -0
  213. package/scripts/lib/validate/check-cursor-adapter.mjs +102 -0
  214. package/scripts/lib/validate/check-dead-bridge.mjs +24 -2
  215. package/scripts/lib/validate/check-doc-cli-commands.mjs +16 -32
  216. package/scripts/lib/validate/check-hooks-symmetry.mjs +29 -63
  217. package/scripts/lib/validate/check-playwright-mcp-canary.mjs +13 -22
  218. package/scripts/lib/validate/check-plugin-monitors.mjs +10 -4
  219. package/scripts/lib/validate/check-test-value-bans.mjs +165 -17
  220. package/scripts/lib/validate/check-unwired-features.mjs +340 -32
  221. package/scripts/lib/validate/repo-files.mjs +275 -0
  222. package/scripts/lib/validate-vendored-rules.mjs +229 -7
  223. package/scripts/lib/vault-mirror/process.mjs +99 -43
  224. package/scripts/lib/vault-mirror/telemetry.mjs +210 -0
  225. package/scripts/lib/vault-staleness-banner.mjs +76 -6
  226. package/scripts/lib/vault-status/board-writer.mjs +211 -10
  227. package/scripts/lib/vault-status/narrative-mirror.mjs +188 -8
  228. package/scripts/lib/wave-executor/foreign-dispatch.mjs +832 -0
  229. package/scripts/lib/wave-transcript-tail.mjs +869 -0
  230. package/scripts/materialize-wave-scope.mjs +209 -12
  231. package/scripts/mcp-server.sh +11 -2
  232. package/scripts/parse-config.mjs +65 -0
  233. package/scripts/token-audit.sh +9 -2
  234. package/scripts/validate-plugin.mjs +3 -0
  235. package/scripts/validate-wave-scope.mjs +67 -0
  236. package/scripts/vault-mirror.mjs +203 -34
  237. package/skills/_shared/monitor-patterns.md +31 -5
  238. package/skills/_shared/parallel-aware-auq.md +1 -1
  239. package/skills/_shared/parallel-aware-preamble.md +4 -2
  240. package/skills/_shared/platform-tools.md +11 -5
  241. package/skills/_shared/state-ownership.md +29 -2
  242. package/skills/autopilot/SKILL.md +5 -1
  243. package/skills/bootstrap/SKILL.md +3 -3
  244. package/skills/bootstrap/_shared-template.md +18 -10
  245. package/skills/bootstrap/deep-template.md +10 -6
  246. package/skills/bootstrap/fast-template.md +15 -8
  247. package/skills/bootstrap/standard-template.md +10 -6
  248. package/skills/claude-md-drift-check/checker.mjs +39 -11
  249. package/skills/dispatcher/SKILL.md +1 -1
  250. package/skills/journey-audit/SKILL.md +269 -0
  251. package/skills/peekaboo-driver/SKILL.md +15 -3
  252. package/skills/persona-panel/SKILL.md +1 -1
  253. package/skills/reconcile/SKILL.md +41 -1
  254. package/skills/session-end/SKILL.md +17 -4
  255. package/skills/session-end/metrics-collection.md +7 -4
  256. package/skills/session-end/phase-3-6-tail.md +11 -3
  257. package/skills/session-end/phase-3-7a-recommendations.md +16 -2
  258. package/skills/session-plan/SKILL.md +6 -1
  259. package/skills/session-plan/wave-template.md +1 -0
  260. package/skills/session-start/SKILL.md +30 -16
  261. package/skills/session-start/phase-7-5-mode-selector.md +15 -3
  262. package/skills/session-start/phase-8-5-express-path.md +77 -12
  263. package/skills/vault-sync/validator.mjs +31 -0
  264. package/skills/wave-executor/SKILL.md +4 -2
  265. package/skills/wave-executor/circuit-breaker.md +34 -9
  266. package/skills/wave-executor/wave-loop.md +102 -19
  267. package/templates/_shared/journey-manifest.md +110 -0
  268. package/templates/_shared/rules/parallel-sessions.md +0 -77
@@ -27,16 +27,59 @@
27
27
  * Stdlib only — the hook path must stay cheap enough to run on every Bash call.
28
28
  */
29
29
 
30
- import { existsSync, readFileSync } from 'node:fs';
30
+ import { digestSha256Short } from './crypto-digest-utils.mjs';
31
+ import { existsSync, readFileSync, readdirSync, statSync, unlinkSync } from 'node:fs';
31
32
  import path from 'node:path';
32
33
 
33
34
  import { writeJsonAtomicSync } from './io.mjs';
34
35
  import { resolveInstructionFile } from './common.mjs';
35
36
  import { _parseIssueBudget } from './config/issue-budget.mjs';
36
37
 
37
- /** Runtime counter file, relative to the repo root. */
38
+ /**
39
+ * Legacy single-slot counter file, relative to the repo root.
40
+ *
41
+ * Kept as the path for IDENTITY-LESS callers only, and as the one-time
42
+ * migration source for a session whose spend was recorded before #1141.
43
+ */
38
44
  export const BUDGET_STATE_REL = '.orchestrator/runtime/issue-budget.json';
39
45
 
46
+ /** Directory holding the per-session counter files, relative to the repo root. */
47
+ export const BUDGET_STATE_DIR_REL = '.orchestrator/runtime/issue-budget';
48
+
49
+ /**
50
+ * Relative path of the counter file for one accounting session (#1141).
51
+ *
52
+ * WHY per session and not one file: the counter used to be ONE slot per
53
+ * WORKING COPY, keyed by whichever `sessionId` happened to write last, and
54
+ * `readBudgetState` zeroes the state whenever the file's `sessionId` differs
55
+ * from the reader's. Two concurrent sessions in one working copy therefore
56
+ * alternately reset each other's counter and BOTH ran with the cap silently
57
+ * off — measured 2026-08-23, where the live file was owned by a session that
58
+ * had started 11 h before the one reading it. Session identity belongs in the
59
+ * FILE NAME, not in a field the next writer overwrites.
60
+ *
61
+ * The name is a truncated SHA-256 rather than the id itself because session
62
+ * ids are operator/host-supplied strings: a semantic id contains `/`-free but
63
+ * unbounded text, and a raw id is a UUID. Hashing gives a fixed-length,
64
+ * filesystem-safe, path-traversal-free name for both shapes. 16 hex chars
65
+ * (64 bits) is far beyond the handful of sessions that ever share one working
66
+ * copy; revisit only if a repo ever needs the id to be readable from the name
67
+ * (it never has — every reader already knows which session it is).
68
+ *
69
+ * An identity-less caller (`null`/empty) keeps the legacy flat path: it never
70
+ * reads and never persists (see `readBudgetState` / `chargeIssueBudget`), so
71
+ * it needs a stable path only to NAME the store in messages.
72
+ *
73
+ * @param {string|null|undefined} sessionId accounting session key
74
+ * @returns {string} repo-relative path
75
+ */
76
+ export function budgetStateRel(sessionId) {
77
+ const key = typeof sessionId === 'string' && sessionId.length > 0 ? sessionId : null;
78
+ if (key === null) return BUDGET_STATE_REL;
79
+ const digest = digestSha256Short(key, { length: 16 });
80
+ return `${BUDGET_STATE_DIR_REL}/${digest}.json`;
81
+ }
82
+
40
83
  /**
41
84
  * Resolve the accounting key for a native session id.
42
85
  *
@@ -126,12 +169,55 @@ export function loadIssueBudgetConfig(repoRoot) {
126
169
  }
127
170
 
128
171
  /**
129
- * Absolute path of the runtime counter file for a repo.
172
+ * Absolute path of the runtime counter file for one session in a repo.
173
+ *
130
174
  * @param {string} repoRoot
175
+ * @param {string|null} [sessionId] accounting session key; omitted/empty
176
+ * yields the legacy identity-less flat path.
131
177
  * @returns {string}
132
178
  */
133
- export function budgetStatePath(repoRoot) {
134
- return path.join(repoRoot, BUDGET_STATE_REL);
179
+ export function budgetStatePath(repoRoot, sessionId = null) {
180
+ return path.join(repoRoot, budgetStateRel(sessionId));
181
+ }
182
+
183
+ /**
184
+ * Coerce a parsed counter file into a state object, or `null` when it does not
185
+ * belong to `accountingSessionId`.
186
+ *
187
+ * The owner check survives the move to per-session files: the file NAME now
188
+ * carries identity, but a hand-edited, hash-colliding or hand-copied file must
189
+ * still not hand its spend to a different session.
190
+ *
191
+ * @param {unknown} data
192
+ * @param {string} accountingSessionId
193
+ * @returns {{ sessionId: string, count: number, exempt: number, overflow: object[] }|null}
194
+ */
195
+ function _coerceState(data, accountingSessionId) {
196
+ if (!data || typeof data !== 'object' || Array.isArray(data)) return null;
197
+ if (data.sessionId !== accountingSessionId) return null;
198
+ return {
199
+ sessionId: accountingSessionId,
200
+ count: Number.isInteger(data.count) && data.count >= 0 ? data.count : 0,
201
+ exempt: Number.isInteger(data.exempt) && data.exempt >= 0 ? data.exempt : 0,
202
+ overflow: Array.isArray(data.overflow) ? data.overflow : [],
203
+ };
204
+ }
205
+
206
+ /**
207
+ * Read a counter file and coerce it, swallowing every I/O and parse error
208
+ * (fail-open: an unreadable ledger must never block a creation).
209
+ *
210
+ * @param {string} file
211
+ * @param {string} accountingSessionId
212
+ * @returns {object|null}
213
+ */
214
+ function _readStateFile(file, accountingSessionId) {
215
+ if (!existsSync(file)) return null;
216
+ try {
217
+ return _coerceState(JSON.parse(readFileSync(file, 'utf8')), accountingSessionId);
218
+ } catch {
219
+ return null;
220
+ }
135
221
  }
136
222
 
137
223
  /**
@@ -144,10 +230,18 @@ export function budgetStatePath(repoRoot) {
144
230
  * but avoiding cross-session budget and overflow attribution wins over a
145
231
  * continuity guess without a verified native identity.
146
232
  *
147
- * The counter file is SHARED across invocations, so this read-side isolation is
148
- * only half the contract: an identity-less charge must also never PERSIST its
149
- * fresh state, or it silently zeroes a live session's count and deletes its
150
- * parked overflow records. `chargeIssueBudget` enforces that write-side half.
233
+ * The counter file is per session since #1141, but an identity-less charge
234
+ * still must never PERSIST its fresh state it would land on the shared
235
+ * legacy path and, before the split, silently zeroed a live session's count
236
+ * and deleted its parked overflow records. `chargeIssueBudget` enforces that
237
+ * write-side half.
238
+ *
239
+ * MIGRATION (one-time, read-only): a session that started before the per-session
240
+ * split has its spend in the legacy flat file. When no per-session file exists
241
+ * yet and the legacy file still names THIS session, seed from it — otherwise the
242
+ * split itself would hand every in-flight session a fresh cap, which is the very
243
+ * failure it exists to remove. The legacy file is never written back; the first
244
+ * charge after the seed persists to the per-session path.
151
245
  *
152
246
  * @param {string} repoRoot
153
247
  * @param {string|null} sessionId
@@ -159,33 +253,33 @@ export function readBudgetState(repoRoot, sessionId) {
159
253
  const fresh = { sessionId: accountingSessionId, count: 0, exempt: 0, overflow: [] };
160
254
  if (accountingSessionId === null) return fresh;
161
255
 
162
- const file = budgetStatePath(repoRoot);
163
- if (!existsSync(file)) return fresh;
164
- try {
165
- const data = JSON.parse(readFileSync(file, 'utf8'));
166
- if (!data || typeof data !== 'object') return fresh;
167
- if (data.sessionId !== accountingSessionId) return fresh;
168
- return {
169
- sessionId: accountingSessionId,
170
- count: Number.isInteger(data.count) && data.count >= 0 ? data.count : 0,
171
- exempt: Number.isInteger(data.exempt) && data.exempt >= 0 ? data.exempt : 0,
172
- overflow: Array.isArray(data.overflow) ? data.overflow : [],
173
- };
174
- } catch {
175
- return fresh;
176
- }
256
+ const ownFile = budgetStatePath(repoRoot, accountingSessionId);
257
+ const own = _readStateFile(ownFile, accountingSessionId);
258
+ if (own) return own;
259
+ // A present-but-unusable own file is a fail-open fresh state, NOT a reason to
260
+ // fall back to the legacy slot the migration seed applies only before the
261
+ // session has a file of its own.
262
+ if (existsSync(ownFile)) return fresh;
263
+
264
+ return _readStateFile(path.join(repoRoot, BUDGET_STATE_REL), accountingSessionId) ?? fresh;
177
265
  }
178
266
 
179
267
  /**
180
- * Persist the counter file. Best-effort: a write failure never blocks a
181
- * creation (fail-open), it only means the count is under-reported.
268
+ * Persist the counter file for `state.sessionId`. Best-effort: a write failure
269
+ * never blocks a creation (fail-open), it only means the count is
270
+ * under-reported.
271
+ *
272
+ * The target path is derived from `state.sessionId`, so a state object can only
273
+ * ever be written into its OWN session's slot. `writeJsonAtomicSync` mkdir -p's
274
+ * the containing directory (`io.mjs#atomicWriteWithBackup`), which is what
275
+ * creates `.orchestrator/runtime/issue-budget/` on first use.
182
276
  *
183
277
  * @param {string} repoRoot
184
278
  * @param {object} state
185
279
  * @returns {boolean} true on success
186
280
  */
187
281
  export function writeBudgetState(repoRoot, state) {
188
- const res = writeJsonAtomicSync(budgetStatePath(repoRoot), state, {
282
+ const res = writeJsonAtomicSync(budgetStatePath(repoRoot, state?.sessionId ?? null), state, {
189
283
  tmpPrefix: '.issue-budget',
190
284
  });
191
285
  return res.ok === true;
@@ -236,7 +330,12 @@ export function chargeIssueBudget({
236
330
  const max = cfg['max-per-session'];
237
331
  const mode = cfg.mode;
238
332
  const overflowSink = cfg.overflow;
239
- const overflowPath = budgetStatePath(repoRoot);
333
+ const accountingSessionId =
334
+ typeof sessionId === 'string' && sessionId.length > 0 ? sessionId : null;
335
+ // Per-session store (#1141) — the path an operator or session-end must open
336
+ // to find THIS session's parked overflow, so the verdict has to name the
337
+ // session's own slot, not the directory or the legacy flat file.
338
+ const overflowPath = budgetStatePath(repoRoot, accountingSessionId);
240
339
 
241
340
  const base = { max, mode, overflowSink, overflowPath };
242
341
 
@@ -244,8 +343,6 @@ export function chargeIssueBudget({
244
343
  return { ...base, decision: 'off', count: 0, overflowCount: 0, reason: null };
245
344
  }
246
345
 
247
- const accountingSessionId =
248
- typeof sessionId === 'string' && sessionId.length > 0 ? sessionId : null;
249
346
  const state = readBudgetState(repoRoot, accountingSessionId);
250
347
  state.sessionId = accountingSessionId;
251
348
 
@@ -321,3 +418,105 @@ export function formatBlockReason(v) {
321
418
  `\`mode: warn\` reports without blocking, \`mode: off\` disables the gate.`,
322
419
  ].join('\n');
323
420
  }
421
+
422
+ /**
423
+ * Default age past which a per-session counter file is reaped (#1151).
424
+ *
425
+ * BV-004 ceiling: 14 days is a "nobody will ever drain this now" horizon, not a
426
+ * measured retention requirement. A session's overflow is drained at ITS OWN
427
+ * close (session-end Phase 5 Step 3b), so a file still carrying undrained
428
+ * overflow two weeks later belongs to a session that ended without closing —
429
+ * its parked items are already unreachable by the drain, which only ever reads
430
+ * the CURRENT session's file. Revisit if a triage workflow ever reads a foreign
431
+ * session's overflow after the fact.
432
+ */
433
+ export const BUDGET_REAP_MAX_AGE_DAYS = 14;
434
+
435
+ /**
436
+ * Recognised per-session counter file name — the 16-hex digest `budgetStateRel`
437
+ * emits, plus `.json`. Anything else in the directory is left alone: the reaper
438
+ * must only ever remove files it can prove it wrote itself.
439
+ */
440
+ const BUDGET_FILE_NAME_RE = /^[0-9a-f]{16}\.json$/;
441
+
442
+ /**
443
+ * Remove stale per-session counter files (#1151).
444
+ *
445
+ * WHY: the per-session split (#1141) writes one file per accounting session
446
+ * into `.orchestrator/runtime/issue-budget/` and NOTHING ever removed them —
447
+ * there was not a single unlink site for that directory in the repo. Every
448
+ * session in a working copy therefore left a permanent 100-200 byte artefact
449
+ * behind. This is the missing other half of the file's lifecycle, owned by
450
+ * session-end Phase 5 Step 3b (the drain runs first, this sweeps after).
451
+ *
452
+ * Age is judged by MTIME, and the CURRENT session's file is exempt regardless
453
+ * of age. Both halves are safety, not convenience: a parallel session in the
454
+ * same working copy (PSA-001) owns a file this process did not write, and its
455
+ * freshly-touched mtime is the only evidence available here that the owner is
456
+ * still alive — the same age-gate discipline `session-registry.mjs`
457
+ * `sweepZombies` uses for in-flight claim files.
458
+ *
459
+ * Best-effort by contract: every failure path is swallowed and the file counted
460
+ * as kept. A reaper that throws would abort the session close it runs inside,
461
+ * which is a strictly worse outcome than an un-reaped counter file.
462
+ *
463
+ * @param {{
464
+ * repoRoot: string,
465
+ * sessionId?: string|null,
466
+ * maxAgeDays?: number,
467
+ * now?: number,
468
+ * }} opts `sessionId` is the accounting session id whose file must survive;
469
+ * `now` is an epoch-ms injection point for tests.
470
+ * @returns {{ removed: string[], kept: string[] }} absolute paths, so a caller
471
+ * gets both a count (`.length`) and an audit trail.
472
+ */
473
+ export function reapStaleBudgetFiles({
474
+ repoRoot,
475
+ sessionId = null,
476
+ maxAgeDays = BUDGET_REAP_MAX_AGE_DAYS,
477
+ now = Date.now(),
478
+ } = {}) {
479
+ const removed = [];
480
+ const kept = [];
481
+ try {
482
+ const dir = path.join(repoRoot, BUDGET_STATE_DIR_REL);
483
+ const cutoff = now - maxAgeDays * 24 * 60 * 60 * 1000;
484
+ const ownFile =
485
+ typeof sessionId === 'string' && sessionId.length > 0
486
+ ? budgetStatePath(repoRoot, sessionId)
487
+ : null;
488
+
489
+ let names;
490
+ try {
491
+ names = readdirSync(dir);
492
+ } catch {
493
+ // No directory yet (or unreadable) — nothing to reap, not an error.
494
+ return { removed, kept };
495
+ }
496
+
497
+ for (const name of names) {
498
+ // Unrecognised entries are not part of this reaper's population at all:
499
+ // neither removed nor reported as kept.
500
+ if (!BUDGET_FILE_NAME_RE.test(name)) continue;
501
+ const file = path.join(dir, name);
502
+ if (file === ownFile) {
503
+ kept.push(file);
504
+ continue;
505
+ }
506
+ try {
507
+ if (statSync(file).mtimeMs >= cutoff) {
508
+ kept.push(file);
509
+ continue;
510
+ }
511
+ unlinkSync(file);
512
+ removed.push(file);
513
+ } catch {
514
+ // Vanished under us (a peer's own reaper), or permission-denied.
515
+ kept.push(file);
516
+ }
517
+ }
518
+ } catch {
519
+ // Unreachable in practice; the contract is "never throws", not "never fails".
520
+ }
521
+ return { removed, kept };
522
+ }
@@ -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) {