session-orchestrator 5.3.0 → 5.5.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (260) hide show
  1. package/.agents/skills/claude-md-drift-check/SKILL.md +1 -1
  2. package/.agents/skills/test-audit/SKILL.md +21 -0
  3. package/.agents/skills/test-audit/agents/openai.yaml +5 -0
  4. package/.claude-plugin/marketplace.json +1 -1
  5. package/.claude-plugin/plugin.json +1 -1
  6. package/.codex-plugin/plugin.json +1 -1
  7. package/.codex-plugin/skills/claude-md-drift-check/SKILL.md +1 -1
  8. package/.codex-plugin/skills/test-audit/SKILL.md +21 -0
  9. package/.codex-plugin/skills/test-audit/agents/openai.yaml +5 -0
  10. package/.cursor/commands/test-audit.md +14 -0
  11. package/.cursor/rules/060-evolve.mdc +28 -13
  12. package/.cursor/skills/claude-md-drift-check/SKILL.md +1 -1
  13. package/.cursor/skills/test-audit/SKILL.md +12 -0
  14. package/.cursor-plugin/plugin.json +1 -1
  15. package/.orchestrator/policy/blocked-commands.json +1 -1
  16. package/AGENTS.md +9 -31
  17. package/CHANGELOG.md +162 -0
  18. package/README.md +14 -10
  19. package/docs/agent-authoring.md +2 -2
  20. package/docs/baseline.md +10 -1
  21. package/docs/ci-setup.md +154 -4
  22. package/docs/components.md +11 -11
  23. package/docs/events-schema.md +25 -19
  24. package/docs/install.md +14 -2
  25. package/docs/migration-v4.md +24 -17
  26. package/docs/persona-panel.md +5 -4
  27. package/docs/plugin-architecture-v3.md +1 -1
  28. package/docs/rule-authoring.md +27 -12
  29. package/docs/scope-collision-guard.md +40 -16
  30. package/docs/session-config-reference.md +61 -12
  31. package/docs/session-config-template.md +11 -2
  32. package/docs/vault-docs-architecture.md +8 -4
  33. package/hooks/_lib/hook-import-set.json +42 -29
  34. package/hooks/_lib/lock-bootstrap.mjs +118 -26
  35. package/hooks/config-protection.mjs +1 -1
  36. package/hooks/enforce-commands.mjs +28 -13
  37. package/hooks/enforce-scope.mjs +201 -23
  38. package/hooks/hooks-codex.json +1 -1
  39. package/hooks/hooks-cursor.json +5 -0
  40. package/hooks/hooks-pi.json +5 -0
  41. package/hooks/hooks.json +6 -1
  42. package/hooks/on-session-start.mjs +57 -2
  43. package/hooks/on-stop.mjs +70 -168
  44. package/hooks/post-bash-write-verify.mjs +98 -17
  45. package/hooks/post-edit-validate.mjs +15 -5
  46. package/hooks/post-tool-batch-wave-signal.mjs +32 -152
  47. package/hooks/pre-bash-memory-propose-audit.mjs +71 -10
  48. package/hooks/pre-bash-sessions-ledger-guard.mjs +28 -2
  49. package/hooks/pre-bash-staging-fence.mjs +29 -8
  50. package/hooks/pre-edit-sessions-ledger-guard.mjs +162 -0
  51. package/hooks/pre-task-scope-disjoint.mjs +1143 -279
  52. package/hooks/subagent-telemetry.mjs +96 -19
  53. package/hooks/wave-scope-commit-guard.mjs +168 -72
  54. package/package.json +2 -6
  55. package/pi/prompts/test-audit.md +12 -0
  56. package/rules/README.md +6 -1
  57. package/rules/_index.md +3 -3
  58. package/rules/always-on/bash-harness-pitfalls.md +7 -1
  59. package/rules/opt-in-domain/prompt-caching.md +5 -1
  60. package/rules/opt-in-stack/backend-data.md +9 -1
  61. package/rules/opt-in-stack/backend.md +7 -1
  62. package/rules/opt-in-stack/frontend.md +6 -1
  63. package/rules/opt-in-stack/security-web.md +7 -1
  64. package/rules/opt-in-stack/swift.md +5 -1
  65. package/scripts/apply-session-learnings.mjs +513 -0
  66. package/scripts/archive-closed-prds.mjs +40 -12
  67. package/scripts/autopilot.mjs +57 -54
  68. package/scripts/backfill-abandoned-sessions.mjs +81 -6
  69. package/scripts/backfill-learnings-from-vault.mjs +49 -22
  70. package/scripts/baseline-archetypes.mjs +1 -1
  71. package/scripts/check-package-manager.mjs +62 -5
  72. package/scripts/codex-install.mjs +3 -0
  73. package/scripts/cursor-install.mjs +47 -6
  74. package/scripts/emit-session.mjs +298 -2
  75. package/scripts/export-hw-learnings.mjs +40 -10
  76. package/scripts/fleet-checkin.mjs +170 -0
  77. package/scripts/lib/agent-frontmatter.mjs +3 -3
  78. package/scripts/lib/auto-dream.mjs +7 -6
  79. package/scripts/lib/autopilot/flags.mjs +6 -1
  80. package/scripts/lib/autopilot/loop.mjs +32 -13
  81. package/scripts/lib/autopilot/stall-sampler.mjs +13 -9
  82. package/scripts/lib/baseline-archetypes.mjs +41 -22
  83. package/scripts/lib/blocked-commands-policy.mjs +40 -0
  84. package/scripts/lib/common.mjs +41 -0
  85. package/scripts/lib/config/host-paths.mjs +79 -0
  86. package/scripts/lib/config/persona-gate-wave.mjs +3 -3
  87. package/scripts/lib/config/reaper.mjs +7 -6
  88. package/scripts/lib/config/vault-integration.mjs +151 -16
  89. package/scripts/lib/config-schema.mjs +13 -0
  90. package/scripts/lib/config.mjs +75 -10
  91. package/scripts/lib/eval/engine.mjs +13 -0
  92. package/scripts/lib/events-retention-banner.mjs +393 -0
  93. package/scripts/lib/events-rotation.mjs +21 -6
  94. package/scripts/lib/events.mjs +72 -5
  95. package/scripts/lib/fleet-protocol.mjs +288 -0
  96. package/scripts/lib/gates/gate-baseline.mjs +13 -3
  97. package/scripts/lib/gates/gate-full.mjs +7 -2
  98. package/scripts/lib/gates/gate-helpers.mjs +93 -2
  99. package/scripts/lib/gates/gate-incremental.mjs +23 -22
  100. package/scripts/lib/gates/gate-per-file.mjs +16 -7
  101. package/scripts/lib/hardening.mjs +1 -0
  102. package/scripts/lib/harness-audit/categories/category4.mjs +8 -6
  103. package/scripts/lib/instruction-budget-guard.mjs +77 -11
  104. package/scripts/lib/learnings/expiry-sweep.mjs +420 -22
  105. package/scripts/lib/learnings/io.mjs +274 -37
  106. package/scripts/lib/learnings/schema.mjs +163 -15
  107. package/scripts/lib/learnings/sizing-subject.mjs +2 -2
  108. package/scripts/lib/lock-reaper.mjs +38 -14
  109. package/scripts/lib/maintenance-due-banner.mjs +23 -8
  110. package/scripts/lib/memory-proposals/schema.mjs +11 -5
  111. package/scripts/lib/named-baseline-resolver.mjs +53 -28
  112. package/scripts/lib/named-vault-resolver.mjs +147 -5
  113. package/scripts/lib/orphan-reaper/defaults.mjs +41 -0
  114. package/scripts/lib/orphan-reaper/no-follow-append.mjs +87 -0
  115. package/scripts/lib/orphan-reaper/ps-snapshot.mjs +181 -0
  116. package/scripts/lib/orphan-reaper/reaper-audit.mjs +323 -0
  117. package/scripts/lib/orphan-reaper/reaper-decide.mjs +395 -0
  118. package/scripts/lib/orphan-reaper/scan-throttle.mjs +144 -0
  119. package/scripts/lib/orphan-reaper/scan.mjs +510 -0
  120. package/scripts/lib/orphan-reaper/trigger.mjs +173 -0
  121. package/scripts/lib/orphan-reaper.mjs +47 -1315
  122. package/scripts/lib/owner-yaml.mjs +104 -6
  123. package/scripts/lib/persona-panel/catalog-loader.mjs +1 -1
  124. package/scripts/lib/platform.mjs +206 -2
  125. package/scripts/lib/plugin-update-banner.mjs +3 -1
  126. package/scripts/lib/process-group.mjs +134 -22
  127. package/scripts/lib/project-hygiene.mjs +21 -1
  128. package/scripts/lib/quality-gate.mjs +45 -5
  129. package/scripts/lib/resource-probe.mjs +1 -1
  130. package/scripts/lib/rule-loader.mjs +100 -62
  131. package/scripts/lib/rules-sync.mjs +27 -12
  132. package/scripts/lib/scope-baseline.mjs +119 -29
  133. package/scripts/lib/scope-echo.mjs +7 -0
  134. package/scripts/lib/scope-gate.mjs +194 -27
  135. package/scripts/lib/session-close-backfill.mjs +129 -4
  136. package/scripts/lib/session-end/tail-runner.mjs +10 -5
  137. package/scripts/lib/session-lock-shape.mjs +6 -3
  138. package/scripts/lib/session-lock.mjs +320 -67
  139. package/scripts/lib/session-record-repair.mjs +183 -26
  140. package/scripts/lib/session-registry.mjs +67 -17
  141. package/scripts/lib/session-schema/constants.mjs +30 -4
  142. package/scripts/lib/session-schema/validator.mjs +28 -1
  143. package/scripts/lib/session-start-probes.mjs +242 -68
  144. package/scripts/lib/session-token-rollup.mjs +252 -54
  145. package/scripts/lib/sessions-canonical.mjs +100 -9
  146. package/scripts/lib/sessions-staleness-banner.mjs +3 -1
  147. package/scripts/lib/sh-quote.mjs +29 -0
  148. package/scripts/lib/subagents-schema.mjs +30 -3
  149. package/scripts/lib/tail-window.mjs +27 -3
  150. package/scripts/lib/telemetry/pricing.mjs +121 -71
  151. package/scripts/lib/telemetry/sync.mjs +17 -0
  152. package/scripts/lib/tests-src-ratio.mjs +215 -23
  153. package/scripts/lib/tmux-layout/layouts.mjs +1 -11
  154. package/scripts/lib/tmux-layout/telemetry-stats.mjs +12 -0
  155. package/scripts/lib/tmux-layout/tmux-shell.mjs +2 -11
  156. package/scripts/lib/ux-grill/reconcile.mjs +1 -1
  157. package/scripts/lib/validate/check-agents.mjs +1 -1
  158. package/scripts/lib/validate/check-hooks-symmetry.mjs +5 -0
  159. package/scripts/lib/validate/check-learnings-shell-writes.mjs +214 -0
  160. package/scripts/lib/validate/check-test-value-bans.mjs +388 -27
  161. package/scripts/lib/validate/check-unicode-safety.mjs +1 -0
  162. package/scripts/lib/validate/check-unwired-features.mjs +1 -1
  163. package/scripts/lib/validate/check-vcs-repo-flag.mjs +1 -1
  164. package/scripts/lib/validate-vendored-rules.mjs +206 -81
  165. package/scripts/lib/vault-mirror/auto-commit.mjs +5 -3
  166. package/scripts/lib/vault-mirror/namespace.mjs +47 -26
  167. package/scripts/lib/vault-mirror/process.mjs +350 -62
  168. package/scripts/lib/vault-mirror/pseudonym-map.mjs +14 -5
  169. package/scripts/lib/vault-mirror/render-learnings.mjs +66 -3
  170. package/scripts/lib/vault-mirror/render-sessions.mjs +28 -4
  171. package/scripts/lib/vault-mirror/telemetry.mjs +25 -11
  172. package/scripts/lib/vault-status/board-writer.mjs +53 -12
  173. package/scripts/lib/vault-status/narrative-mirror.mjs +40 -9
  174. package/scripts/lib/vcs-repo-spec.mjs +8 -0
  175. package/scripts/lib/wave-executor/dispatch-common.mjs +10 -18
  176. package/scripts/lib/wave-executor/foreign-dispatch.mjs +1 -1
  177. package/scripts/lib/wave-executor/remote-dispatch.mjs +1 -1
  178. package/scripts/lib/wave-resource-gate.mjs +34 -8
  179. package/scripts/materialize-wave-scope.mjs +7 -4
  180. package/scripts/memory-propose.mjs +18 -5
  181. package/scripts/release-session-lock.mjs +2 -1
  182. package/scripts/release.mjs +11 -3
  183. package/scripts/repair-invalid-sessions.mjs +37 -4
  184. package/scripts/run-migrate-v2-cross-repo.mjs +171 -31
  185. package/scripts/run-quality-gate.mjs +331 -307
  186. package/scripts/self-update.mjs +229 -0
  187. package/scripts/site-numbers.mjs +163 -5
  188. package/scripts/sweep-expired-learnings.mjs +603 -20
  189. package/scripts/sweep-expired-rules.mjs +2 -1
  190. package/scripts/validate-plugin.mjs +7 -0
  191. package/scripts/validate-wave-scope.mjs +21 -2
  192. package/scripts/vault-backfill.mjs +50 -37
  193. package/scripts/vault-integration-watcher.mjs +2 -1
  194. package/scripts/vault-mirror.mjs +238 -75
  195. package/scripts/wave-scope-binding.mjs +2 -1
  196. package/skills/_shared/config-reading.md +3 -3
  197. package/skills/_shared/fleet-protocol.md +134 -0
  198. package/skills/_shared/state-ownership.md +41 -12
  199. package/skills/autopilot/SKILL.md +25 -11
  200. package/skills/bootstrap/SKILL.md +6 -4
  201. package/skills/bootstrap/_shared-template.md +97 -2
  202. package/skills/bootstrap/fast-template.md +4 -1
  203. package/skills/bootstrap/private-contract.md +2 -1
  204. package/skills/bootstrap/public-fallback.md +6 -3
  205. package/skills/bootstrap/references/bootstrap-refresh-lock-flow.md +5 -3
  206. package/skills/bootstrap/references/bootstrap-retroactive-flow.md +2 -1
  207. package/skills/bootstrap/standard-template.md +2 -0
  208. package/skills/claude-md-drift-check/SKILL.md +10 -10
  209. package/skills/claude-md-drift-check/checker.mjs +26 -18
  210. package/skills/discovery/probes/ssot-code-diff.mjs +12 -6
  211. package/skills/discovery/probes/vault-narrative-staleness.mjs +6 -2
  212. package/skills/discovery/probes/vault-staleness.mjs +9 -3
  213. package/skills/docs-orchestrator/SKILL.md +12 -5
  214. package/skills/docs-orchestrator/audience-mapping.md +5 -3
  215. package/skills/evolve/SKILL.md +13 -4
  216. package/skills/evolve/references/evolve-analyze-mode.md +42 -18
  217. package/skills/memory-cleanup/SKILL.md +1 -1
  218. package/skills/persona-panel/SKILL.md +2 -2
  219. package/skills/persona-panel/persona-format.md +2 -2
  220. package/skills/persona-panel/presets/designer-lens.md +1 -1
  221. package/skills/persona-panel/presets/engineer-lens.md +1 -1
  222. package/skills/persona-panel/presets/pm-lens.md +1 -1
  223. package/skills/plan/mode-retro.md +19 -4
  224. package/skills/quality-gates/SKILL.md +6 -4
  225. package/skills/remote-offload/SKILL.md +4 -2
  226. package/skills/session-end/learning-patterns.md +23 -13
  227. package/skills/session-end/metrics-collection.md +6 -7
  228. package/skills/session-end/phase-3-2-docs-verification.md +7 -6
  229. package/skills/session-end/plan-verification.md +25 -9
  230. package/skills/session-end/references/phase-3-documentation-updates.md +13 -3
  231. package/skills/session-end/references/phase-4a-worktree-cleanup.md +2 -0
  232. package/skills/session-end/references/session-summary-template.md +1 -1
  233. package/skills/session-end/session-metrics-write.md +65 -68
  234. package/skills/session-plan/SKILL.md +0 -3
  235. package/skills/session-start/SKILL.md +8 -2
  236. package/skills/session-start/references/operations-contract.md +6 -0
  237. package/skills/session-start/references/phase-1-2-session-lock.md +32 -7
  238. package/skills/session-start/references/phase-1-7-vault-status-board.md +1 -1
  239. package/skills/session-start/references/phase-4-ssot-environment-check.md +10 -3
  240. package/skills/test-audit/SKILL.md +152 -0
  241. package/skills/test-audit/references/campaign.md +111 -0
  242. package/skills/test-audit/references/mutate.sh +50 -0
  243. package/skills/vault-mirror/SKILL.md +21 -11
  244. package/skills/wave-executor/SKILL.md +2 -2
  245. package/skills/wave-executor/circuit-breaker.md +1 -1
  246. package/skills/wave-executor/references/wave-executor-state-init.md +3 -0
  247. package/skills/wave-executor/references/wave-loop-dispatch.md +11 -1
  248. package/skills/wave-executor/references/wave-loop-review.md +4 -6
  249. package/skills/wave-executor/references/wave-loop-scope-manifest.md +13 -5
  250. package/templates/personas/README.md +5 -5
  251. package/templates/personas/accounting-compliance.v1.md +1 -1
  252. package/templates/personas/accounting-tax-advisor.v1.md +1 -1
  253. package/templates/personas/buyer-p1-cto.v1.md +1 -1
  254. package/templates/personas/buyer-p2-kanzlei.v1.md +1 -1
  255. package/templates/personas/buyer-p3-build.v1.md +1 -1
  256. package/templates/personas/buyer-p4-tech-veto.v1.md +1 -1
  257. package/templates/personas/buyer-p5-solo.v1.md +1 -1
  258. package/templates/personas/buyer-p6-ld.v1.md +1 -1
  259. package/templates/personas/klima-ai-expert.v1.md +1 -1
  260. package/templates/personas/klima-physicist.v1.md +1 -1
@@ -123,6 +123,14 @@ async function applyDecisionRules(measurements, opts) {
123
123
  * declares a remote host that accepts this wave role, route the wave to that
124
124
  * host at its full planned agent count instead of shrinking it.
125
125
  *
126
+ * `offload-first: true` in Session Config (#1465) widens that trigger to a
127
+ * `proceed` verdict too: an offloadable wave role goes to a ready host even when
128
+ * the local host has headroom. Host selection, the witness contract and the
129
+ * HR-004 cap below are identical on both paths. With the switch on, a wave that
130
+ * stays local anyway (no host declared, none ready) carries a named
131
+ * `offload-first:` reason, so the switch never fails silently; a role absent
132
+ * from OFFLOADABLE_WAVE_ROLES stays local without one.
133
+ *
126
134
  * The gate does NOT probe the network — a placement decision must stay a pure
127
135
  * function of its inputs. The caller supplies a readiness WITNESS:
128
136
  * - `opts.remoteReady` — `{ [alias]: boolean }`, e.g. built from the
@@ -140,13 +148,24 @@ async function applyDecisionRules(measurements, opts) {
140
148
  * @returns {Promise<{decision: string, agents: number, reasons: string[], measurements: object, host?: string}>}
141
149
  */
142
150
  async function applyOffloadDecision(result, opts) {
143
- if (result.decision !== 'reduce' && result.decision !== 'coordinator-direct') return result;
151
+ const offloadFirst = opts.config?.['offload-first'] === true;
152
+ if (!offloadFirst && result.decision !== 'reduce' && result.decision !== 'coordinator-direct') {
153
+ return result;
154
+ }
144
155
 
145
156
  const { config, plannedAgents, waveRole, remoteReady, probeFn = null } = opts;
157
+ const role = String(waveRole ?? '').trim().toLowerCase();
146
158
  const hosts = config?.['remote-hosts'];
147
- if (!Array.isArray(hosts) || hosts.length === 0) return result;
159
+ if (!Array.isArray(hosts) || hosts.length === 0) {
160
+ // Only an offloadable role could have been placed, so only it gets the note.
161
+ return offloadFirst && OFFLOADABLE_WAVE_ROLES[role] !== undefined
162
+ ? {
163
+ ...result,
164
+ reasons: [...result.reasons, 'offload-first: true but no remote-hosts declared — staying local'],
165
+ }
166
+ : result;
167
+ }
148
168
 
149
- const role = String(waveRole ?? '').trim().toLowerCase();
150
169
  // No isNeverForeignRole() check here (D8 #1204 LOW-1): the invariant is
151
170
  // enforced STRUCTURALLY by this mapping table, not by an extra guard.
152
171
  // NEVER_FOREIGN_ROLES {impl-core, security-review, migration, release,
@@ -186,9 +205,10 @@ async function applyOffloadDecision(result, opts) {
186
205
  }
187
206
  }
188
207
  if (host === undefined) {
189
- return probeFailures.length === 0
190
- ? result
191
- : { ...result, reasons: [...result.reasons, ...probeFailures] };
208
+ const notes = offloadFirst
209
+ ? [...probeFailures, `offload-first: no ready host for '${role}' — staying local`]
210
+ : probeFailures;
211
+ return notes.length === 0 ? result : { ...result, reasons: [...result.reasons, ...notes] };
192
212
  }
193
213
 
194
214
  // The wave runs at its planned size again — but never above the HR-004 static
@@ -207,7 +227,9 @@ async function applyOffloadDecision(result, opts) {
207
227
  host: host.alias,
208
228
  reasons: [
209
229
  ...result.reasons,
210
- `offload: ${role} routed to host '${host.alias}' instead of reducing to ${result.agents}`,
230
+ result.decision === 'proceed'
231
+ ? `offload-first: ${role} routed to host '${host.alias}' (verdict was proceed)`
232
+ : `offload: ${role} routed to host '${host.alias}' instead of reducing to ${result.agents}`,
211
233
  ],
212
234
  measurements: result.measurements,
213
235
  };
@@ -383,14 +405,18 @@ export async function evaluateWaveResourceGate(opts) {
383
405
  }
384
406
 
385
407
  // Rule 2: probe the system (or use override for tests).
408
+ // A failed probe yields `proceed`. Under `offload-first: true` (#1465) that
409
+ // result still goes through placement: routing to a witnessed-ready host needs
410
+ // no local measurement, so a broken probe must not silently disable the switch.
386
411
  const measured = await extractMeasurements(opts);
387
412
  if ('probeFailed' in measured) {
388
- return {
413
+ const failed = {
389
414
  decision: 'proceed',
390
415
  agents: plannedAgents,
391
416
  reasons: ['probe failed (ignored)'],
392
417
  measurements: {},
393
418
  };
419
+ return config['offload-first'] === true ? applyOffloadDecision(failed, opts) : failed;
394
420
  }
395
421
 
396
422
  // Rules 3-8: apply decision rule sequence.
@@ -26,7 +26,7 @@ import { existsSync, readdirSync, readFileSync, unlinkSync } from 'node:fs';
26
26
  import { dirname, resolve } from 'node:path';
27
27
  import { writeJsonAtomicSync } from './lib/io.mjs';
28
28
  import { isMainModule } from './lib/is-main-module.mjs';
29
- import { isPeerRecordId } from './lib/scope-gate.mjs';
29
+ import { COORDINATOR_RECORD_ID, isCoordinatorRecordId, isPeerRecordId } from './lib/scope-gate.mjs';
30
30
  import { SCOPE_MATERIALIZED_EVENT, scopeDigest } from './lib/scope-echo.mjs';
31
31
  import {
32
32
  MANIFEST_SESSION_KEYS,
@@ -214,8 +214,8 @@ export function validateScopeRecords(value) {
214
214
  throw new InputError(`record #${recordIndex} has duplicate id (case-insensitive): ${record.id}`);
215
215
  }
216
216
  seenIds.add(idKey);
217
- if (idKey === 'coordinator') {
218
- if (record.id !== 'coordinator') {
217
+ if (idKey === COORDINATOR_RECORD_ID) {
218
+ if (!isCoordinatorRecordId(record.id)) {
219
219
  throw new InputError(`record #${recordIndex} id must be exactly coordinator (lowercase)`);
220
220
  }
221
221
  coordinatorCount++;
@@ -537,7 +537,10 @@ export function transportObservable(pluginRoot = resolve(import.meta.dirname, '.
537
537
  async function emitScopeMaterialized({ stateDir, wave, records }) {
538
538
  try {
539
539
  const repoRoot = dirname(resolve(stateDir));
540
- const agentRecords = records.filter((r) => !isPeerRecordId(r.id));
540
+ // Dispatched agents only: a peer's record gets no per-agent file, and the
541
+ // coordinator's is a declaration no dispatch carries — counted, its empty
542
+ // `files` (the common case) read as "two agents share one scope" (#1493.2).
543
+ const agentRecords = records.filter((r) => !isPeerRecordId(r.id) && !isCoordinatorRecordId(r.id));
541
544
  const digests = new Set();
542
545
  for (const record of agentRecords) {
543
546
  if (Array.isArray(record.files) && record.files.length > 0) digests.add(scopeDigest(record.files));
@@ -35,6 +35,7 @@
35
35
  * Related modules:
36
36
  * scripts/lib/memory-proposals/schema.mjs — createProposalRecord, PROPOSAL_TYPES
37
37
  * scripts/lib/memory-proposals/store.mjs — appendProposal
38
+ * scripts/lib/learnings/schema.mjs — isRepoRelativePathEntry (--file-paths)
38
39
  * scripts/lib/state-md.mjs — resolveStateMdPath, parseStateMd
39
40
  * scripts/parse-config.mjs — Session Config subprocess
40
41
  */
@@ -42,9 +43,10 @@
42
43
  import { parseArgs } from 'node:util';
43
44
  import { readFileSync, existsSync } from 'node:fs';
44
45
  import { spawnSync } from 'node:child_process';
45
- import { dirname, join, isAbsolute } from 'node:path';
46
+ import { dirname, join } from 'node:path';
46
47
  import { fileURLToPath } from 'node:url';
47
48
 
49
+ import { isRepoRelativePathEntry } from './lib/learnings/schema.mjs';
48
50
  import { isWaveAgentContext, WAVE_AGENT_ENV_VAR, WAVE_AGENT_ENV_VALUE } from './lib/wave-context.mjs';
49
51
  import { readProcessLocalSessionIds, manifestSessionBinding } from './lib/session-identity/own-session.mjs';
50
52
  import { readLockDetailed } from './lib/session-lock.mjs';
@@ -197,6 +199,14 @@ if (confidenceRaw !== undefined) {
197
199
  // (Step 6) — so a malformed --file-paths value produces the same exit-4
198
200
  // argv-error contract as every other required/optional flag, never a
199
201
  // downstream schema-validation surprise.
202
+ //
203
+ // The repo-relative rule is `isRepoRelativePathEntry` from learnings/schema.mjs
204
+ // — the predicate strict validateLearning() applies to `file_paths` at write
205
+ // time. A looser rule here (it was `path.isAbsolute`, blind to `~/x`, `C:\x`,
206
+ // UNC and URL entries) queued proposals that the operator approved and
207
+ // appendLearning() then refused, so the learning never landed. The checks below
208
+ // it (newline, glob metacharacters, count, length) are this CLI's own, stricter
209
+ // additions.
200
210
 
201
211
  const FILE_PATHS_MAX_COUNT = 20;
202
212
  const FILE_PATH_MAX_CHARS = 256;
@@ -219,10 +229,13 @@ if (filePaths !== undefined) {
219
229
  );
220
230
  }
221
231
  for (const p of filePaths) {
222
- if (isAbsolute(p)) {
223
- argErrors.push(`--file-paths must be repo-relative — absolute path rejected: "${p}"`);
224
- } else if (p.split(/[\\/]/).includes('..')) {
225
- argErrors.push(`--file-paths must not contain ".." path segments: "${p}"`);
232
+ if (!isRepoRelativePathEntry(p)) {
233
+ // The `..` probe only picks the message; the predicate decides.
234
+ argErrors.push(
235
+ p.split(/[\\/]/).includes('..')
236
+ ? `--file-paths must not contain ".." path segments: "${p}"`
237
+ : `--file-paths must be repo-relative — absolute path rejected (leading /, \\ or ~, Windows drive, UNC or URL): "${p}"`,
238
+ );
226
239
  } else if (/[\r\n]/.test(p)) {
227
240
  argErrors.push(`--file-paths entries must not contain newline characters: "${p}"`);
228
241
  } else if (/[*?[\]{}]/.test(p)) {
@@ -43,6 +43,7 @@
43
43
  */
44
44
 
45
45
  import { parseArgs } from 'node:util';
46
+ import path from 'node:path';
46
47
 
47
48
  import { readLockDetailed, loadOwnerProof, release } from './lib/session-lock.mjs';
48
49
  import { LOCK_RELEASED_EVENT } from './lib/session-transition.mjs';
@@ -174,7 +175,7 @@ export async function main({ argv = process.argv.slice(2) } = {}) {
174
175
  return 1;
175
176
  }
176
177
 
177
- const repoRoot = values['repo-root'] ?? process.cwd();
178
+ const repoRoot = path.resolve(values['repo-root'] ?? process.cwd());
178
179
 
179
180
  const emitResult = async (result) => {
180
181
  if (values.json) process.stdout.write(JSON.stringify(result, null, 2) + '\n');
@@ -1303,14 +1303,21 @@ export function evaluatePublishReceipt(result, target) {
1303
1303
  * failure remains visible, but none invalidates the already irreversible npm
1304
1304
  * publish; callers must reconcile rather than retry `--publish`.
1305
1305
  *
1306
+ * `npm view <pkg> version` resolves the `latest` dist-tag (and `npm view` fetches
1307
+ * with `preferOnline`, so no local cache masks the answer): the poll returns
1308
+ * `verified` on the first attempt whose `dist-tags.latest` equals `target`.
1309
+ * Default budget: 12 attempts, 15 s apart = 165 s between the first and last
1310
+ * poll. npm's registry needs ~2 min to serve a fresh `latest` (#1440 C2); the
1311
+ * previous 5 x 3 s = 12 s budget reported `timeout` on every real publish.
1312
+ *
1306
1313
  * @param {string} repoRoot
1307
1314
  * @param {string} target
1308
1315
  * @param {{attempts?: number, delaySeconds?: number, runImpl?: Function, waitImpl?: Function}} [deps]
1309
1316
  * @returns {{ok: boolean, kind: 'verified'|'timeout'|'query-failed'|'wait-failed', attempts: number, detail: string}}
1310
1317
  */
1311
1318
  export function waitForRegistryPropagation(repoRoot, target, deps = {}) {
1312
- const attempts = deps.attempts ?? 5;
1313
- const delaySeconds = deps.delaySeconds ?? 3;
1319
+ const attempts = deps.attempts ?? 12;
1320
+ const delaySeconds = deps.delaySeconds ?? 15;
1314
1321
  const runImpl = deps.runImpl ?? run;
1315
1322
  const waitImpl = deps.waitImpl ?? (() => runImpl('sleep', [String(delaySeconds)], { cwd: repoRoot }));
1316
1323
 
@@ -1674,6 +1681,7 @@ export function printPublishOutcome(outcome, target, io = {}) {
1674
1681
  log('\nPost-release checklist (manual):');
1675
1682
  log(' 1. Rotate/delete the npm token: https://www.npmjs.com/settings/<user>/tokens');
1676
1683
  log(' 2. pi.dev gallery indexes asynchronously — do not block on it.');
1684
+ log(' 3. Install the release on this host: npm run update:local -- --skip-pull, then restart the harness.');
1677
1685
  return 0;
1678
1686
  }
1679
1687
 
@@ -1894,7 +1902,7 @@ async function main() {
1894
1902
  console.log(`Rewrote ${changed.length} surface file(s) to ${target}:`);
1895
1903
  for (const f of changed) console.log(` ${f}`);
1896
1904
  console.log(' package-lock.json (via npm install --package-lock-only)');
1897
- console.log(' site/index.html cells + site/_census.json re-stamped (scripts/site-numbers.mjs --write) — commit BOTH');
1905
+ console.log(' site/ page cells + site/_census.json + site/sitemap.xml lastmod re-stamped (scripts/site-numbers.mjs --write) — commit ALL of them');
1898
1906
  console.log('\nEditorial TODOs (enforced by --check):');
1899
1907
  console.log(` 1. CHANGELOG.md — write the "## [${target}] - YYYY-MM-DD" entry, fold [Unreleased].`);
1900
1908
  console.log(' 2. README.md — rewrite the "Recent highlights" section content.');
@@ -18,7 +18,11 @@
18
18
  * over the target (atomic), and the result is then re-verified against BOTH
19
19
  * `validateSession` and `checkSessionsIntegrity`. A failed verification restores
20
20
  * the backup byte-identically and exits 3 — the ledger is never left in a state
21
- * worse than the one this CLI found.
21
+ * worse than the one this CLI found. Records with no defensible repair (a
22
+ * timestamp that is not ISO-8601 at all, a waves entry that is not an object)
23
+ * are kept byte-identical and do NOT trigger that rollback (#1442): the repair
24
+ * of every other line is kept, and the run exits 4 so a caller can tell "clean"
25
+ * from "clean except N lines nobody can repair".
22
26
  *
23
27
  * TARGET CONSTRAINT (MED-3): `--file` MUST resolve inside
24
28
  * `<repo-root>/.orchestrator/metrics/`. Any target outside it — including a
@@ -32,6 +36,10 @@
32
36
  * 1 — user/input error (bad flag, unknown argument)
33
37
  * 2 — system error (unreadable/unwritable ledger, I/O failure)
34
38
  * 3 — post-verification failed; the backup was restored and nothing was kept
39
+ * 4 — completed, but N unrepairable lines remain (apply: every repairable
40
+ * line was fixed and verified, the N were kept byte-identical; dry-run:
41
+ * the N lines that WOULD remain are listed). Checked after 3: a
42
+ * rolled-back run is always 3, never 4.
35
43
  */
36
44
 
37
45
  import fs from 'node:fs';
@@ -53,7 +61,9 @@ const USAGE =
53
61
  ');\n' +
54
62
  ' must resolve inside <repo-root>/.orchestrator/metrics/\n' +
55
63
  ' --repo-root project root (default: resolved project dir)\n' +
56
- 'Exit codes: 0 completed, 1 arg error, 2 system error, 3 post-verification failed\n';
64
+ 'Exit codes: 0 completed, 1 arg error, 2 system error, 3 post-verification failed\n' +
65
+ ' (backup restored), 4 unrepairable remain (N lines kept byte-identical;\n' +
66
+ ' every repairable line fixed — dry-run: would remain)\n';
57
67
 
58
68
  /**
59
69
  * Resolve `--file` and REFUSE any target outside
@@ -127,7 +137,10 @@ export function renderHuman(s) {
127
137
  }
128
138
  if (s.backup_path) lines.push(` backup: ${s.backup_path}`);
129
139
  if (s.errors.length > 0) {
130
- lines.push(` errors: ${s.errors.length} (original line kept)`);
140
+ // #1442: name every line that stays invalid — in dry-run BEFORE anything is
141
+ // written, so the operator knows the remainder --apply will leave behind.
142
+ const verb = s.mode === 'apply' ? 'remain' : 'would remain';
143
+ lines.push(` unrepairable: ${s.errors.length} (${verb} invalid; original line kept byte-identical)`);
131
144
  for (const e of s.errors) lines.push(` line ${e.line} ${e.session_id ?? '<no session_id>'}: ${e.error}`);
132
145
  }
133
146
  if (s.post_verify && s.post_verify.integrity !== 'clean' && s.post_verify.integrity !== 'skipped-not-canonical-path') {
@@ -135,10 +148,30 @@ export function renderHuman(s) {
135
148
  }
136
149
  if (s.ok === false) {
137
150
  lines.push(' POST-VERIFICATION FAILED — backup restored, ledger unchanged.');
151
+ } else if (s.errors.length > 0) {
152
+ lines.push(
153
+ s.mode === 'apply'
154
+ ? ` Repairable lines fixed and verified; ${s.errors.length} unrepairable line(s) left as they were (exit 4).`
155
+ : ` --apply would fix every repairable line and leave ${s.errors.length} unrepairable line(s) as they are (exit 4).`
156
+ );
138
157
  }
139
158
  return lines.join('\n') + '\n';
140
159
  }
141
160
 
161
+ /**
162
+ * Map a `repairLedger` summary to the exit-code contract in the header.
163
+ * Rollback (3) wins over an unrepairable remainder (4): a restored ledger kept
164
+ * nothing, so "N remain" would describe a write that did not happen.
165
+ *
166
+ * @param {object} summary
167
+ * @returns {0|3|4}
168
+ */
169
+ export function exitCodeFor(summary) {
170
+ if (summary.ok === false) return 3;
171
+ if (summary.unrepairable > 0) return 4;
172
+ return 0;
173
+ }
174
+
142
175
  async function main() {
143
176
  let parsed;
144
177
  try {
@@ -197,7 +230,7 @@ async function main() {
197
230
  } else {
198
231
  process.stdout.write(renderHuman(summary));
199
232
  }
200
- process.exit(summary.ok === false ? 3 : 0);
233
+ process.exit(exitCodeFor(summary));
201
234
  }
202
235
 
203
236
  const isDirectRun =isMainModule(import.meta.url);
@@ -3,7 +3,12 @@
3
3
  * run-migrate-v2-cross-repo.mjs — cross-repo Migrate-CLI v2 runner.
4
4
  *
5
5
  * Walks a list of repos, applies the v2 migration to each repo's learnings.jsonl,
6
- * and reports pre/post invalid-rate per repo.
6
+ * and reports pre/post invalid-rate per repo — plus, per repo and in the
7
+ * aggregate, the count of invalid records per validation error class before and
8
+ * after migration (`errorClassesPre` / `errorClassesPost`, GitHub #69 / GitLab
9
+ * #1446). An error class is the validateLearning message up to `, got:`
10
+ * (e.g. `scope must be one of local|private|public`), so a dry run shows which
11
+ * failure kinds the migration fixes and which remain.
7
12
  *
8
13
  * Usage:
9
14
  * node scripts/run-migrate-v2-cross-repo.mjs [--repos <comma-list>] [--apply] [--json] [--out <path>]
@@ -11,15 +16,20 @@
11
16
  * Flags:
12
17
  * --repos <comma-list> Comma-separated repo paths (absolute or ~-prefixed).
13
18
  * When omitted, uses the hardcoded ROLLOUT_REPOS list.
14
- * --apply Write migrated records back to each file (atomic).
15
- * DEFAULT is dry-run (no writes).
19
+ * --apply Write migrated records back to each file (atomic),
20
+ * under that store's lock (`withLearningsLock`, the
21
+ * `<store>.lock` every learnings writer takes), so a
22
+ * live session appending in that repo is never lost.
23
+ * DEFAULT is dry-run (no writes, no lock).
16
24
  * --json Output machine-readable JSON instead of Markdown table.
17
25
  * --out <path> Write output to file instead of stdout.
18
26
  *
19
27
  * Exit codes:
20
28
  * 0 Success (including repos with no learnings.jsonl — gracefully skipped)
21
29
  * 1 Input/argument error
22
- * 2 I/O error
30
+ * 2 I/O error — incl. a store lock not acquired under --apply: that repo
31
+ * is left unwritten and reported `status: 'error'`, the run continues
32
+ * with the next repo, and exits 2 at the end
23
33
  */
24
34
 
25
35
  import { existsSync, readFileSync, writeFileSync, copyFileSync, renameSync } from 'node:fs';
@@ -30,6 +40,7 @@ import {
30
40
  migrateLegacyLearning,
31
41
  validateLearning,
32
42
  } from './lib/learnings.mjs';
43
+ import { LearningsLockError, withLearningsLock } from './lib/learnings/io.mjs';
33
44
  import { getCrossRepoProjects, getConfinementRoot } from './lib/config/cross-repo.mjs';
34
45
  import { validatePathInsideProject } from './lib/path-utils.mjs';
35
46
 
@@ -135,47 +146,102 @@ if (reposArg) {
135
146
  * @property {number} invalidPost - invalid records after migration
136
147
  * @property {number} fixedByV2 - records that became valid after migration
137
148
  * @property {number} malformed - JSON-parse failures (preserved, not counted in invalid)
149
+ * @property {Record<string, number>} errorClassesPre - invalid-pre count per error class
150
+ * @property {Record<string, number>} errorClassesPost - invalid-post count per error class
138
151
  * @property {string|null} error - error message if status=error
139
152
  */
140
153
 
154
+ /**
155
+ * Reduce a validateLearning error message to its class: the text before the
156
+ * record-specific `, got: <value>` tail. Messages without that tail (e.g.
157
+ * `learning missing required field: subject`) are their own class.
158
+ *
159
+ * @param {unknown} err
160
+ * @returns {string}
161
+ */
162
+ function errorClassOf(err) {
163
+ const message = err instanceof Error ? err.message : String(err);
164
+ const idx = message.indexOf(', got:');
165
+ return idx === -1 ? message : message.slice(0, idx);
166
+ }
167
+
168
+ /**
169
+ * @param {Record<string, number>} counts
170
+ * @param {string} cls
171
+ */
172
+ function bump(counts, cls) {
173
+ counts[cls] = (counts[cls] ?? 0) + 1;
174
+ }
175
+
176
+ /**
177
+ * A RepoResult that counted nothing.
178
+ *
179
+ * @param {string} repoPath
180
+ * @param {string} status
181
+ * @param {string|null} error
182
+ * @returns {RepoResult}
183
+ */
184
+ function emptyResult(repoPath, status, error) {
185
+ return {
186
+ repo: repoPath,
187
+ status,
188
+ total: 0,
189
+ invalidPre: 0,
190
+ invalidPost: 0,
191
+ fixedByV2: 0,
192
+ malformed: 0,
193
+ errorClassesPre: {},
194
+ errorClassesPost: {},
195
+ error,
196
+ };
197
+ }
198
+
199
+ /** Repos whose store lock was not acquired under --apply (nothing written there). */
200
+ const lockFailedRepos = [];
201
+
141
202
  /**
142
203
  * Process a single repo. Returns a RepoResult.
143
204
  *
205
+ * Under --apply the WHOLE read → backup → rename runs inside the store lock:
206
+ * these are other repos, where a live session may append between this read
207
+ * and the rename — outside the lock that record would be silently replaced.
208
+ * A dry run writes nothing and takes no lock.
209
+ *
144
210
  * @param {string} repoPath - resolved absolute path to the repo
145
211
  * @param {boolean} apply - whether to write changes back
146
- * @returns {RepoResult}
212
+ * @returns {Promise<RepoResult>}
147
213
  */
148
- function processRepo(repoPath, apply) {
214
+ async function processRepo(repoPath, apply) {
149
215
  const learningsPath = join(repoPath, LEARNINGS_REL);
150
216
 
151
- if (!existsSync(learningsPath)) {
152
- return {
153
- repo: repoPath,
154
- status: 'skipped',
155
- total: 0,
156
- invalidPre: 0,
157
- invalidPost: 0,
158
- fixedByV2: 0,
159
- malformed: 0,
160
- error: null,
161
- };
217
+ if (!existsSync(learningsPath)) return emptyResult(repoPath, 'skipped', null);
218
+ if (!apply) return migrateStore(repoPath, learningsPath, false);
219
+
220
+ try {
221
+ return await withLearningsLock(learningsPath, () => migrateStore(repoPath, learningsPath, true));
222
+ } catch (err) {
223
+ if (!(err instanceof LearningsLockError)) throw err;
224
+ lockFailedRepos.push(repoPath);
225
+ return emptyResult(repoPath, 'error', err.message);
162
226
  }
227
+ }
163
228
 
229
+ /**
230
+ * Read, migrate and (when `apply`) atomically rewrite one store. Under --apply
231
+ * the caller holds the store lock.
232
+ *
233
+ * @param {string} repoPath
234
+ * @param {string} learningsPath
235
+ * @param {boolean} apply
236
+ * @returns {RepoResult}
237
+ */
238
+ function migrateStore(repoPath, learningsPath, apply) {
164
239
  // Read
165
240
  let raw;
166
241
  try {
167
242
  raw = readFileSync(learningsPath, 'utf8');
168
243
  } catch (err) {
169
- return {
170
- repo: repoPath,
171
- status: 'error',
172
- total: 0,
173
- invalidPre: 0,
174
- invalidPost: 0,
175
- fixedByV2: 0,
176
- malformed: 0,
177
- error: `read failed: ${err.message}`,
178
- };
244
+ return emptyResult(repoPath, 'error', `read failed: ${err.message}`);
179
245
  }
180
246
 
181
247
  const lines = raw.split('\n').filter((l) => l.trim().length > 0);
@@ -184,6 +250,10 @@ function processRepo(repoPath, apply) {
184
250
  let invalidPre = 0;
185
251
  let invalidPost = 0;
186
252
  let fixedByV2 = 0;
253
+ /** @type {Record<string, number>} */
254
+ const errorClassesPre = {};
255
+ /** @type {Record<string, number>} */
256
+ const errorClassesPost = {};
187
257
  const outputLines = [];
188
258
 
189
259
  for (const line of lines) {
@@ -201,9 +271,10 @@ function processRepo(repoPath, apply) {
201
271
  let wasValidPre = true;
202
272
  try {
203
273
  validateLearning({ ...parsed, schema_version: parsed.schema_version ?? 1 });
204
- } catch {
274
+ } catch (err) {
205
275
  wasValidPre = false;
206
276
  invalidPre++;
277
+ bump(errorClassesPre, errorClassOf(err));
207
278
  }
208
279
 
209
280
  // Migrate
@@ -217,9 +288,10 @@ function processRepo(repoPath, apply) {
217
288
  ...migrated,
218
289
  schema_version: migrated.schema_version ?? 1,
219
290
  });
220
- } catch {
291
+ } catch (err) {
221
292
  isValidPost = false;
222
293
  invalidPost++;
294
+ bump(errorClassesPost, errorClassOf(err));
223
295
  }
224
296
 
225
297
  if (!wasValidPre && isValidPost) {
@@ -259,6 +331,8 @@ function processRepo(repoPath, apply) {
259
331
  invalidPost,
260
332
  fixedByV2,
261
333
  malformed,
334
+ errorClassesPre,
335
+ errorClassesPost,
262
336
  error: `write failed: ${err.message}`,
263
337
  };
264
338
  }
@@ -272,6 +346,8 @@ function processRepo(repoPath, apply) {
272
346
  invalidPost,
273
347
  fixedByV2,
274
348
  malformed,
349
+ errorClassesPre,
350
+ errorClassesPost,
275
351
  error: null,
276
352
  };
277
353
  }
@@ -280,7 +356,10 @@ function processRepo(repoPath, apply) {
280
356
  // Run across all repos
281
357
  // ---------------------------------------------------------------------------
282
358
 
283
- const results = repos.map((r) => processRepo(r, applyFlag));
359
+ // Sequential: one store lock at a time, and a lock failure in one repo never
360
+ // stops the next.
361
+ const results = [];
362
+ for (const r of repos) results.push(await processRepo(r, applyFlag));
284
363
 
285
364
  // ---------------------------------------------------------------------------
286
365
  // Aggregate
@@ -294,6 +373,12 @@ const aggregate = results.reduce(
294
373
  acc.totalMalformed += r.malformed;
295
374
  acc.totalReposProcessed += r.status !== 'skipped' ? 1 : 0;
296
375
  acc.totalReposSkipped += r.status === 'skipped' ? 1 : 0;
376
+ for (const [cls, n] of Object.entries(r.errorClassesPre)) {
377
+ acc.errorClassesPre[cls] = (acc.errorClassesPre[cls] ?? 0) + n;
378
+ }
379
+ for (const [cls, n] of Object.entries(r.errorClassesPost)) {
380
+ acc.errorClassesPost[cls] = (acc.errorClassesPost[cls] ?? 0) + n;
381
+ }
297
382
  return acc;
298
383
  },
299
384
  {
@@ -303,6 +388,10 @@ const aggregate = results.reduce(
303
388
  totalMalformed: 0,
304
389
  totalReposProcessed: 0,
305
390
  totalReposSkipped: 0,
391
+ /** @type {Record<string, number>} */
392
+ errorClassesPre: {},
393
+ /** @type {Record<string, number>} */
394
+ errorClassesPost: {},
306
395
  }
307
396
  );
308
397
 
@@ -319,6 +408,26 @@ function fmtPct(count, total) {
319
408
  return `${count} (${((count / total) * 100).toFixed(1)}%)`;
320
409
  }
321
410
 
411
+ /**
412
+ * Markdown table of invalid-record counts per error class, pre vs post
413
+ * migration. Classes are the union of both sides, sorted by pre count desc.
414
+ *
415
+ * @param {Record<string, number>} pre
416
+ * @param {Record<string, number>} post
417
+ * @returns {string[]}
418
+ */
419
+ function errorClassTable(pre, post) {
420
+ const classes = [...new Set([...Object.keys(pre), ...Object.keys(post)])].sort(
421
+ (a, b) => (pre[b] ?? 0) - (pre[a] ?? 0) || a.localeCompare(b)
422
+ );
423
+ if (classes.length === 0) return ['_No validation errors._'];
424
+ const rows = ['| Error class | Invalid pre | Invalid post |', '|-------------|-------------|--------------|'];
425
+ for (const cls of classes) {
426
+ rows.push(`| ${cls.replaceAll('|', '\\|')} | ${pre[cls] ?? 0} | ${post[cls] ?? 0} |`);
427
+ }
428
+ return rows;
429
+ }
430
+
322
431
  function buildMarkdown(results, aggregate, mode) {
323
432
  const lines = [];
324
433
  lines.push(`# Cross-Repo Migrate-CLI v2 — ${mode} report`);
@@ -345,6 +454,17 @@ function buildMarkdown(results, aggregate, mode) {
345
454
  lines.push(`- Repos processed: **${aggregate.totalReposProcessed}**`);
346
455
  lines.push(`- Repos skipped (no learnings.jsonl): **${aggregate.totalReposSkipped}**`);
347
456
  lines.push('');
457
+ lines.push('## Error classes (all repos)');
458
+ lines.push('');
459
+ lines.push(...errorClassTable(aggregate.errorClassesPre, aggregate.errorClassesPost));
460
+ lines.push('');
461
+ for (const r of results) {
462
+ if (r.status === 'skipped') continue;
463
+ lines.push(`### ${repoName(r.repo)}`);
464
+ lines.push('');
465
+ lines.push(...errorClassTable(r.errorClassesPre, r.errorClassesPost));
466
+ lines.push('');
467
+ }
348
468
  return lines.join('\n');
349
469
  }
350
470
 
@@ -382,4 +502,24 @@ process.stderr.write(
382
502
  `${aggregate.totalStillInvalidPost} still-invalid\n`
383
503
  );
384
504
 
385
- process.exit(0);
505
+ let exitCode = 0;
506
+ if (lockFailedRepos.length > 0) {
507
+ process.stderr.write(
508
+ `run-migrate-v2: ERROR learnings store lock not acquired, nothing written in ${lockFailedRepos.length} repo(s): ` +
509
+ `${lockFailedRepos.join(', ')}\n`
510
+ );
511
+ exitCode = 2;
512
+ }
513
+
514
+ // #1487.15: every other `status: 'error'` (read failed / write failed) is an
515
+ // I/O error too — exit 2 as the header and --help document, never 0.
516
+ const ioFailed = results.filter((r) => r.status === 'error' && !lockFailedRepos.includes(r.repo));
517
+ if (ioFailed.length > 0) {
518
+ process.stderr.write(
519
+ `run-migrate-v2: ERROR I/O error in ${ioFailed.length} repo(s): ` +
520
+ `${ioFailed.map((r) => `${r.repo} (${r.error})`).join(', ')}\n`
521
+ );
522
+ exitCode = 2;
523
+ }
524
+
525
+ process.exit(exitCode);