session-orchestrator 3.17.0 → 3.20.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 (221) 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/rules/030-wave-execution.mdc +17 -1
  5. package/CHANGELOG.md +185 -412
  6. package/README.md +12 -9
  7. package/SECURITY.md +190 -27
  8. package/agents/AGENTS.md +20 -3
  9. package/agents/code-implementer.md +6 -6
  10. package/agents/db-specialist.md +1 -1
  11. package/agents/qa-strategist.md +31 -6
  12. package/agents/schemas/qa-strategist.schema.json +27 -0
  13. package/agents/schemas/test-writer.schema.json +60 -2
  14. package/agents/security-reviewer.md +1 -1
  15. package/agents/session-reviewer.md +1 -1
  16. package/agents/test-writer.md +29 -10
  17. package/agents/ui-developer.md +1 -1
  18. package/commands/contract-version-bump.md +28 -0
  19. package/commands/portfolio.md +1 -1
  20. package/commands/session.md +6 -2
  21. package/docs/USER-GUIDE.md +9 -4
  22. package/docs/ci-setup.md +121 -7
  23. package/docs/codex-setup.md +1 -1
  24. package/docs/components.md +6 -6
  25. package/docs/cursor-setup.md +22 -9
  26. package/docs/events-schema.md +5 -1
  27. package/docs/instruction-delivery.md +794 -0
  28. package/docs/rule-authoring.md +58 -9
  29. package/docs/session-config-reference.md +245 -50
  30. package/docs/session-config-template.md +39 -26
  31. package/hooks/_lib/guard-source-loader.mjs +680 -0
  32. package/hooks/_lib/lock-bootstrap.mjs +21 -0
  33. package/hooks/_lib/vcs-create-matcher.mjs +119 -0
  34. package/hooks/config-protection.mjs +0 -0
  35. package/hooks/enforce-commands.mjs +226 -19
  36. package/hooks/enforce-scope.mjs +133 -9
  37. package/hooks/hooks-codex.json +1 -1
  38. package/hooks/hooks-cursor.json +11 -2
  39. package/hooks/hooks-pi.json +10 -0
  40. package/hooks/hooks.json +21 -1
  41. package/hooks/on-session-end.mjs +178 -18
  42. package/hooks/on-session-start.mjs +30 -4
  43. package/hooks/post-bash-write-verify.mjs +977 -0
  44. package/hooks/post-subagent-discovery-validator.mjs +256 -41
  45. package/hooks/pre-bash-destructive-guard.mjs +616 -164
  46. package/hooks/pre-bash-issue-budget.mjs +167 -0
  47. package/hooks/pre-bash-sessions-ledger-guard.mjs +1054 -0
  48. package/hooks/pre-bash-templates-first.mjs +96 -63
  49. package/hooks/subagent-telemetry.mjs +527 -37
  50. package/package.json +6 -3
  51. package/pi/prompts/contract-version-bump.md +12 -0
  52. package/rules/README.md +32 -0
  53. package/scripts/archive-closed-prds.mjs +12 -22
  54. package/scripts/autopilot-multi.mjs +103 -20
  55. package/scripts/backfill-abandoned-sessions.mjs +160 -4
  56. package/scripts/backfill-learnings-from-vault.mjs +967 -0
  57. package/scripts/check-doc-consistency.sh +17 -1
  58. package/scripts/emit-session.mjs +3 -40
  59. package/scripts/eval-session.mjs +50 -9
  60. package/scripts/fleet-instruction-scan.mjs +141 -0
  61. package/scripts/lib/autopilot/mr-draft.mjs +31 -1
  62. package/scripts/lib/autopilot/worktree-pipeline.mjs +113 -5
  63. package/scripts/lib/backlog-scan.mjs +39 -6
  64. package/scripts/lib/blocked-commands-policy.mjs +340 -0
  65. package/scripts/lib/ci-status-banner.mjs +75 -12
  66. package/scripts/lib/claude-md-budget-lint.mjs +283 -34
  67. package/scripts/lib/command-blocker.mjs +1273 -58
  68. package/scripts/lib/config/config-protection.mjs +2 -1
  69. package/scripts/lib/config/drift-check.mjs +9 -1
  70. package/scripts/lib/config/gitlab-portfolio.mjs +1 -1
  71. package/scripts/lib/config/issue-budget.mjs +123 -0
  72. package/scripts/lib/config/reconcile.mjs +21 -0
  73. package/scripts/lib/config/section-extractor.mjs +121 -1
  74. package/scripts/lib/config-schema.mjs +23 -3
  75. package/scripts/lib/config.mjs +17 -0
  76. package/scripts/lib/convergence-monitor.mjs +49 -3
  77. package/scripts/lib/description-surface.mjs +535 -0
  78. package/scripts/lib/dispatcher/enumerate.mjs +26 -40
  79. package/scripts/lib/ecosystem-wizard/config-writer.mjs +26 -24
  80. package/scripts/lib/ecosystem-wizard/wizard-prompt.mjs +1 -1
  81. package/scripts/lib/eval/engine.mjs +47 -5
  82. package/scripts/lib/events.mjs +59 -7
  83. package/scripts/lib/gates/gate-full.mjs +15 -3
  84. package/scripts/lib/gates/gate-helpers.mjs +132 -6
  85. package/scripts/lib/gitlab-ops/stale-mr-sweep.mjs +28 -8
  86. package/scripts/lib/gitlab-portfolio/aggregator.mjs +8 -2
  87. package/scripts/lib/gitlab-portfolio/cli.mjs +1 -1
  88. package/scripts/lib/handover-gate.mjs +7 -3
  89. package/scripts/lib/hardening.mjs +9 -9
  90. package/scripts/lib/harness-audit/categories/category4.mjs +9 -3
  91. package/scripts/lib/instruction-budget-guard.mjs +402 -51
  92. package/scripts/lib/io.mjs +345 -10
  93. package/scripts/lib/issue-budget.mjs +269 -0
  94. package/scripts/lib/issue-close-strip-labels.mjs +39 -9
  95. package/scripts/lib/label-scope.mjs +47 -0
  96. package/scripts/lib/learnings/affinity.mjs +434 -0
  97. package/scripts/lib/learnings/candidates.mjs +736 -0
  98. package/scripts/lib/learnings/expiry-sweep.mjs +408 -53
  99. package/scripts/lib/learnings/judgment.mjs +782 -0
  100. package/scripts/lib/learnings/kebab.mjs +128 -0
  101. package/scripts/lib/learnings/schema.mjs +43 -3
  102. package/scripts/lib/learnings/select.mjs +550 -0
  103. package/scripts/lib/lock-reaper.mjs +1 -2
  104. package/scripts/lib/memory-proposals/schema.mjs +36 -1
  105. package/scripts/lib/peer-discovery.mjs +645 -0
  106. package/scripts/lib/pi-hook-bridge.mjs +146 -17
  107. package/scripts/lib/product-repo-detect.mjs +9 -8
  108. package/scripts/lib/project-hygiene.mjs +432 -0
  109. package/scripts/lib/quality-gate.mjs +167 -0
  110. package/scripts/lib/recommendations-v0.mjs +1 -1
  111. package/scripts/lib/reconcile/eligibility.mjs +1 -1
  112. package/scripts/lib/reconcile/emitter.mjs +128 -24
  113. package/scripts/lib/reconcile/engine.mjs +156 -54
  114. package/scripts/lib/reconcile/idempotency.mjs +114 -14
  115. package/scripts/lib/reconcile/renderer.mjs +141 -25
  116. package/scripts/lib/reconcile/sanitize.mjs +518 -0
  117. package/scripts/lib/reconcile/writer.mjs +95 -1
  118. package/scripts/lib/reconcile-nudge-banner.mjs +65 -9
  119. package/scripts/lib/resource-probe/evaluate.mjs +70 -4
  120. package/scripts/lib/resource-probe.mjs +19 -0
  121. package/scripts/lib/rule-loader.mjs +6 -0
  122. package/scripts/lib/scope-baseline.mjs +564 -0
  123. package/scripts/lib/scope-gate.mjs +568 -145
  124. package/scripts/lib/session-close-backfill.mjs +63 -8
  125. package/scripts/lib/session-end/phase-skip.mjs +1 -0
  126. package/scripts/lib/session-id.mjs +221 -41
  127. package/scripts/lib/session-lock.mjs +304 -6
  128. package/scripts/lib/session-record-repair.mjs +551 -0
  129. package/scripts/lib/session-schema/constants.mjs +22 -3
  130. package/scripts/lib/session-schema/serializer.mjs +54 -0
  131. package/scripts/lib/session-schema/validator.mjs +16 -0
  132. package/scripts/lib/session-schema.mjs +1 -0
  133. package/scripts/lib/session-token-rollup.mjs +68 -6
  134. package/scripts/lib/sessions-integrity-banner.mjs +294 -0
  135. package/scripts/lib/sessions-staleness-banner.mjs +121 -12
  136. package/scripts/lib/skill-evolution/idempotency.mjs +135 -16
  137. package/scripts/lib/skill-evolution/mr-opener.mjs +9 -1
  138. package/scripts/lib/soul-resolve.mjs +12 -0
  139. package/scripts/lib/spiral-carryover.mjs +142 -30
  140. package/scripts/lib/state-md/mission-status.mjs +53 -3
  141. package/scripts/lib/subagents-schema.mjs +43 -9
  142. package/scripts/lib/test-runner/issue-reconcile.mjs +53 -13
  143. package/scripts/lib/tests-src-ratio.mjs +484 -0
  144. package/scripts/lib/tmux-layout/telemetry.mjs +43 -10
  145. package/scripts/lib/validate/check-agents.mjs +56 -0
  146. package/scripts/lib/validate/check-banner-parity.mjs +376 -0
  147. package/scripts/lib/validate/check-guard-requires-parity.mjs +1148 -0
  148. package/scripts/lib/validate/check-hooks-symmetry.mjs +244 -10
  149. package/scripts/lib/validate/check-learning-provenance.mjs +511 -0
  150. package/scripts/lib/validate/check-owner-leakage.mjs +3 -3
  151. package/scripts/lib/validate/check-rules.mjs +244 -36
  152. package/scripts/lib/validate/check-test-value-bans.mjs +782 -0
  153. package/scripts/lib/validate/check-unicode-safety.mjs +1 -0
  154. package/scripts/lib/validate/check-unwired-features.mjs +549 -0
  155. package/scripts/lib/validate-vendored-rules.mjs +10 -2
  156. package/scripts/lib/vault-archive.mjs +17 -2
  157. package/scripts/lib/vault-backfill/glab.mjs +8 -0
  158. package/scripts/lib/vault-mirror/process.mjs +30 -0
  159. package/scripts/lib/vault-mirror/render-sessions.mjs +293 -36
  160. package/scripts/lib/vcs-repo-spec.mjs +362 -0
  161. package/scripts/lib/wave-resource-gate.mjs +115 -11
  162. package/scripts/lib/worktree/listing.mjs +44 -7
  163. package/scripts/mcp-server.sh +17 -3
  164. package/scripts/measure-context-overhead.sh +151 -0
  165. package/scripts/memory-propose.mjs +72 -9
  166. package/scripts/print-applicable-rules.mjs +218 -16
  167. package/scripts/print-learnings-index.mjs +474 -0
  168. package/scripts/release.mjs +534 -0
  169. package/scripts/repair-invalid-sessions.mjs +209 -0
  170. package/scripts/run-quality-gate.mjs +123 -5
  171. package/scripts/sweep-expired-learnings.mjs +192 -32
  172. package/scripts/validate-plugin.mjs +21 -0
  173. package/scripts/validate-wave-scope.mjs +182 -17
  174. package/scripts/vault-integration-watcher.mjs +32 -10
  175. package/skills/_shared/config-reading.md +2 -2
  176. package/skills/bootstrap/fast-template.md +1 -1
  177. package/skills/brainstorm/soul.md +47 -1
  178. package/skills/claude-md-drift-check/checker.mjs +145 -28
  179. package/skills/contract-version-bump/SKILL.md +219 -0
  180. package/skills/discovery/SKILL.md +4 -4
  181. package/skills/discovery/issue-templates.md +11 -11
  182. package/skills/discovery/probes-audit.md +1 -1
  183. package/skills/discovery/probes-feature.md +1 -1
  184. package/skills/discovery/probes-session.md +26 -5
  185. package/skills/ecosystem-health/SKILL.md +1 -1
  186. package/skills/ecosystem-health/wizard.md +4 -4
  187. package/skills/evolve/SKILL.md +117 -18
  188. package/skills/gitlab-ops/SKILL.md +25 -12
  189. package/skills/gitlab-portfolio/SKILL.md +2 -2
  190. package/skills/grill/soul.md +44 -1
  191. package/skills/hook-development/SKILL.md +1 -1
  192. package/skills/mode-selector/SKILL.md +1 -1
  193. package/skills/npm-publish/SKILL.md +17 -1
  194. package/skills/plan/SKILL.md +5 -5
  195. package/skills/plan/mode-feature.md +4 -4
  196. package/skills/plan/mode-new.md +10 -10
  197. package/skills/plan/mode-retro.md +1 -1
  198. package/skills/plan/soul.md +46 -3
  199. package/skills/quality-gates/SKILL.md +1 -1
  200. package/skills/reconcile/SKILL.md +21 -4
  201. package/skills/session-end/SKILL.md +34 -36
  202. package/skills/session-end/discovery-scan.md +4 -2
  203. package/skills/session-end/drift-operations.md +4 -4
  204. package/skills/session-end/metrics-collection.md +13 -0
  205. package/skills/session-end/phase-3-2-docs-verification.md +1 -1
  206. package/skills/session-end/phase-3-6-tail.md +32 -2
  207. package/skills/session-end/plan-verification.md +6 -7
  208. package/skills/session-end/session-metrics-write.md +2 -0
  209. package/skills/session-end/vault-operations.md +1 -1
  210. package/skills/session-end/verification-checklist.md +1 -1
  211. package/skills/session-plan/SKILL.md +6 -2
  212. package/skills/session-plan/wave-template.md +2 -0
  213. package/skills/session-start/SKILL.md +75 -7
  214. package/skills/session-start/phase-4-5-resource-health.md +15 -2
  215. package/skills/session-start/soul.md +41 -1
  216. package/skills/test-runner/SKILL.md +2 -2
  217. package/skills/vault-sync/validator.mjs +108 -7
  218. package/skills/wave-executor/SKILL.md +6 -7
  219. package/skills/wave-executor/circuit-breaker.md +2 -0
  220. package/skills/wave-executor/wave-loop.md +198 -80
  221. package/templates/_shared/loop.md +4 -4
@@ -0,0 +1,551 @@
1
+ /**
2
+ * session-record-repair.mjs — in-place repair of schema-invalid session ledger
3
+ * records (GitLab #1004).
4
+ *
5
+ * `.orchestrator/metrics/sessions.jsonl` accumulated records that fail the
6
+ * repo's OWN `validateSession()` because they were appended by the coordinator
7
+ * from a Markdown template instead of through `scripts/emit-session.mjs` (see
8
+ * `scripts/lib/sessions-integrity-banner.mjs` for the visibility half of the
9
+ * same defect). Measured at HEAD 1be450a on 2026-08-05: 33 of 216 records fail
10
+ * `validateSession`, and 5 of those are additionally DROPPED by vault-mirror —
11
+ * those sessions have no vault note at all.
12
+ *
13
+ * ── WHY REWRITE-IN-PLACE, NOT APPEND+TOMBSTONE ───────────────────────────────
14
+ * Downstream readers count LINES, not session_ids: `memory-banner`'s
15
+ * `sessionsEver = countJsonlLines(...)` and the integrity banner both iterate
16
+ * lines. Appending a corrected copy of every broken record would inflate the
17
+ * session count by 33 and make every historical metric wrong in a NEW way.
18
+ * So each defective line is replaced positionally, line order is preserved,
19
+ * and the file is swapped atomically (tmp + rename).
20
+ *
21
+ * ── WHAT THIS MODULE WILL NOT DO ─────────────────────────────────────────────
22
+ * - It never SYNTHESIZES history. A missing `waves` becomes `[]`, never a
23
+ * plausible-looking list of wave objects — `_validateWaves` passes an empty
24
+ * array vacuously, and inventing wave entries would fabricate a record of
25
+ * work that may never have happened.
26
+ * - It never INFERS `completed_at` from a neighbouring record. The ledger is
27
+ * NOT chronologically ordered (live lines 86-89 interleave two sessions), so
28
+ * "the next record's start" is not this record's end. A missing
29
+ * `completed_at` becomes `started_at` verbatim: duration 0, transparently
30
+ * unknown, and flagged in `_backfill_incomplete_fields`.
31
+ * - It never DEDUPES. `main-2026-05-11-deep-1` legitimately occupies three
32
+ * lines (two overlapping close attempts plus a differently-shaped third).
33
+ * Collapsing them would silently delete two sessions' worth of line count.
34
+ * - It never re-serializes a record that ALREADY passes validation — a passing
35
+ * line is emitted byte-for-byte, so the diff contains only real repairs and
36
+ * no key-order churn.
37
+ *
38
+ * ── PROVENANCE ───────────────────────────────────────────────────────────────
39
+ * Every repaired record carries `_backfill_source` + `_backfill_incomplete_fields`
40
+ * (both already schema-accepted by `_validateOptionalFields`, so no schema bump
41
+ * is needed). The incomplete-fields list is the EXACT set of fields this record
42
+ * had defaulted, which is what lets a downstream consumer tell a measured zero
43
+ * apart from a repaired-to-zero.
44
+ *
45
+ * Plain Node ESM. Named exports. DI-friendly via `deps`.
46
+ *
47
+ * Cross-references:
48
+ * - `scripts/lib/session-schema/validator.mjs` — `validateSession` (the gate).
49
+ * - `scripts/lib/session-schema/serializer.mjs` — `serializeSessionLineChecked` (round-trip proof).
50
+ * - `scripts/lib/sessions-integrity-banner.mjs` — `checkSessionsIntegrity`.
51
+ * - `scripts/repair-invalid-sessions.mjs` — the CLI driver.
52
+ */
53
+
54
+ import fs from 'node:fs';
55
+ import path from 'node:path';
56
+
57
+ import { validateSession as defaultValidateSession } from './session-schema/validator.mjs';
58
+ import { serializeSessionLineChecked as defaultSerialize } from './session-schema.mjs';
59
+ import { checkSessionsIntegrity as defaultCheckIntegrity } from './sessions-integrity-banner.mjs';
60
+
61
+ // ---------------------------------------------------------------------------
62
+ // Constants
63
+ // ---------------------------------------------------------------------------
64
+
65
+ /** Value written to `_backfill_source` on every record this module repairs. */
66
+ export const REPAIR_SOURCE = 'repair-invalid-sessions/1004';
67
+
68
+ /** Ledger path (relative to repoRoot) that `checkSessionsIntegrity` inspects. */
69
+ export const CANONICAL_LEDGER_REL = path.join('.orchestrator', 'metrics', 'sessions.jsonl');
70
+
71
+ /** The four counters `_validateAgentSummary` requires on `agent_summary`. */
72
+ const AGENT_SUMMARY_FIELDS = Object.freeze(['complete', 'partial', 'failed', 'spiral']);
73
+
74
+ /**
75
+ * Stable ordering for `_backfill_incomplete_fields`. Deterministic output is
76
+ * load-bearing for the idempotency guarantee: run 2 must produce a
77
+ * byte-identical file, which a Set's insertion order would not guarantee across
78
+ * differently-shaped inputs.
79
+ */
80
+ const INCOMPLETE_FIELD_ORDER = Object.freeze([
81
+ 'completed_at',
82
+ 'total_waves',
83
+ 'waves',
84
+ 'waves[].wave',
85
+ 'agent_summary',
86
+ 'agent_summary.complete',
87
+ 'agent_summary.partial',
88
+ 'agent_summary.failed',
89
+ 'agent_summary.spiral',
90
+ 'total_agents',
91
+ 'total_files_changed',
92
+ ]);
93
+
94
+ // ---------------------------------------------------------------------------
95
+ // Helpers
96
+ // ---------------------------------------------------------------------------
97
+
98
+ function isPlainObject(v) {
99
+ return v !== null && typeof v === 'object' && !Array.isArray(v);
100
+ }
101
+
102
+ /** Non-negative finite number — the shape every count field must satisfy. */
103
+ function isCount(v) {
104
+ return typeof v === 'number' && Number.isFinite(v) && v >= 0;
105
+ }
106
+
107
+ function orderIncompleteFields(fields) {
108
+ const rank = (f) => {
109
+ const i = INCOMPLETE_FIELD_ORDER.indexOf(f);
110
+ return i === -1 ? INCOMPLETE_FIELD_ORDER.length : i;
111
+ };
112
+ return [...fields].sort((a, b) => rank(a) - rank(b) || (a < b ? -1 : a > b ? 1 : 0));
113
+ }
114
+
115
+ /** Compact ISO stamp for backup filenames: `20260805T091500Z`. */
116
+ export function backupStamp(date = new Date()) {
117
+ return date.toISOString().replace(/[-:]/g, '').replace(/\.\d{3}Z$/, 'Z');
118
+ }
119
+
120
+ // ---------------------------------------------------------------------------
121
+ // Record repair
122
+ // ---------------------------------------------------------------------------
123
+
124
+ /**
125
+ * Repair every defect class present on ONE session record.
126
+ *
127
+ * Applies ALL rules, not just the first — `validateSession` throws on its first
128
+ * violation, so a first-error-only repair would need N passes for an N-defect
129
+ * record (live line 85 carries six). The input is never mutated.
130
+ *
131
+ * Defaults chosen so that the repaired value is either derivable from the
132
+ * record itself or transparently zero/empty; nothing is invented. See the
133
+ * module docblock for the three things this deliberately refuses to do.
134
+ *
135
+ * @param {object} record — a parsed sessions.jsonl record
136
+ * @returns {{record: object, defects: string[], incompleteFields: string[], changed: boolean}}
137
+ */
138
+ export function repairRecord(record) {
139
+ if (!isPlainObject(record)) {
140
+ return { record, defects: [], incompleteFields: [], changed: false };
141
+ }
142
+
143
+ const out = { ...record };
144
+ const defects = [];
145
+ const incomplete = new Set();
146
+
147
+ // -- waves ----------------------------------------------------------------
148
+ // A NUMBER here is not garbage: it IS the wave count, written by an older
149
+ // template that put the count where the array belongs. Capture it before
150
+ // discarding, or the `total_waves` repair below loses real data.
151
+ let wavesNumber = null;
152
+ if (!Array.isArray(out.waves)) {
153
+ if (isCount(out.waves)) {
154
+ wavesNumber = out.waves;
155
+ defects.push('waves_number');
156
+ } else if (out.waves === undefined || out.waves === null) {
157
+ defects.push('waves_absent');
158
+ } else {
159
+ defects.push('waves_not_array');
160
+ }
161
+ out.waves = [];
162
+ incomplete.add('waves');
163
+ }
164
+
165
+ // -- waves[i].wave --------------------------------------------------------
166
+ // `_validateWaves` requires `wave >= 1` on every entry. Older shapes either
167
+ // omit it (the number lived in `wave_number`) or start at 0 (a coordinator-
168
+ // direct "wave 0"). Renumber the WHOLE array sequentially — patching only the
169
+ // offending entries would produce duplicate wave numbers on the 0-based shape.
170
+ // Bail out when any entry is not an object: that is a shape this module has
171
+ // no defensible default for, so the record falls through to the error bucket
172
+ // rather than being silently mangled.
173
+ if (out.waves.length > 0 && out.waves.every(isPlainObject)) {
174
+ const needsRenumber = out.waves.some((w) => !isCount(w.wave) || w.wave < 1);
175
+ if (needsRenumber) {
176
+ out.waves = out.waves.map((w, i) => ({ ...w, wave: i + 1 }));
177
+ defects.push('wave_index_invalid');
178
+ incomplete.add('waves[].wave');
179
+ }
180
+ }
181
+
182
+ // -- total_waves ----------------------------------------------------------
183
+ if (!isCount(out.total_waves)) {
184
+ out.total_waves = wavesNumber !== null ? wavesNumber : out.waves.length;
185
+ defects.push('total_waves_missing');
186
+ incomplete.add('total_waves');
187
+ }
188
+
189
+ // -- agent_summary --------------------------------------------------------
190
+ if (!isPlainObject(out.agent_summary)) {
191
+ out.agent_summary = { complete: 0, partial: 0, failed: 0, spiral: 0 };
192
+ defects.push('agent_summary_absent');
193
+ incomplete.add('agent_summary');
194
+ } else {
195
+ const missing = AGENT_SUMMARY_FIELDS.filter((f) => !isCount(out.agent_summary[f]));
196
+ if (missing.length > 0) {
197
+ out.agent_summary = { ...out.agent_summary };
198
+ for (const f of missing) {
199
+ out.agent_summary[f] = 0;
200
+ incomplete.add(`agent_summary.${f}`);
201
+ }
202
+ defects.push(
203
+ missing.length === 1 && missing[0] === 'spiral'
204
+ ? 'agent_summary_spiral_missing'
205
+ : 'agent_summary_field_missing'
206
+ );
207
+ }
208
+ }
209
+
210
+ // -- total_agents ---------------------------------------------------------
211
+ if (!isCount(out.total_agents)) {
212
+ // Prefer the record's own evidence: an agent_summary PRESENT in the
213
+ // original sums to the real agent count (live line 71 sums to 30 where
214
+ // waves.length is 5 — W2/A4 review finding). Fall back to waves.length
215
+ // only when the summary was absent or sums to 0 (no signal).
216
+ const summarySum = isPlainObject(record.agent_summary)
217
+ ? AGENT_SUMMARY_FIELDS.reduce(
218
+ (n, f) => n + (isCount(record.agent_summary[f]) ? record.agent_summary[f] : 0),
219
+ 0
220
+ )
221
+ : 0;
222
+ out.total_agents = summarySum > 0 ? summarySum : out.waves.length;
223
+ defects.push('total_agents_missing');
224
+ incomplete.add('total_agents');
225
+ }
226
+
227
+ // -- total_files_changed --------------------------------------------------
228
+ if (!isCount(out.total_files_changed)) {
229
+ out.total_files_changed = 0;
230
+ defects.push('total_files_changed_missing');
231
+ incomplete.add('total_files_changed');
232
+ }
233
+
234
+ // -- completed_at ---------------------------------------------------------
235
+ // Legacy `ended_at` is the same fact under its old name (emit-session.mjs
236
+ // aliasLegacyEndedAt precedent) — prefer it when parseable and monotonic,
237
+ // so the repaired record cannot disagree with its own ended_at (live line
238
+ // 85 — W2/A4 review finding). Else `started_at` verbatim → duration 0,
239
+ // i.e. "unknown", and flagged as such. NEVER the next record's timestamp:
240
+ // the ledger is not chronologically ordered (see the module docblock).
241
+ if (typeof out.completed_at !== 'string' && typeof out.started_at === 'string') {
242
+ const endedMs = typeof out.ended_at === 'string' ? Date.parse(out.ended_at) : NaN;
243
+ const startedMs = Date.parse(out.started_at);
244
+ out.completed_at =
245
+ Number.isFinite(endedMs) && Number.isFinite(startedMs) && endedMs >= startedMs
246
+ ? out.ended_at
247
+ : out.started_at;
248
+ defects.push('completed_at_missing');
249
+ incomplete.add('completed_at');
250
+ }
251
+
252
+ if (defects.length === 0) {
253
+ return { record, defects: [], incompleteFields: [], changed: false };
254
+ }
255
+
256
+ // -- provenance (additive, already schema-accepted) ------------------------
257
+ const prior = Array.isArray(record._backfill_incomplete_fields)
258
+ ? record._backfill_incomplete_fields.filter((f) => typeof f === 'string')
259
+ : [];
260
+ const merged = orderIncompleteFields(incomplete);
261
+ out._backfill_source = REPAIR_SOURCE;
262
+ out._backfill_incomplete_fields = [...new Set([...prior, ...merged])];
263
+
264
+ return { record: out, defects, incompleteFields: merged, changed: true };
265
+ }
266
+
267
+ // ---------------------------------------------------------------------------
268
+ // Ledger repair
269
+ // ---------------------------------------------------------------------------
270
+
271
+ /**
272
+ * Repair one ledger line. Pure — decides the OUTPUT line for one INPUT line.
273
+ *
274
+ * Outcomes:
275
+ * - `blank` — whitespace-only/empty segment, emitted unchanged.
276
+ * - `unparseable` — `JSON.parse` threw; emitted unchanged. File corruption is
277
+ * a different problem and the integrity banner skips these
278
+ * too; silently "fixing" bytes we cannot read is worse.
279
+ * - `valid` — passed `validateSession`; emitted BYTE-FOR-BYTE.
280
+ * - `repaired` — defects fixed, gated, re-serialized.
281
+ * - `error` — still invalid after repair; ORIGINAL emitted unchanged.
282
+ *
283
+ * @param {string} line
284
+ * @param {{validateSession?: Function, serialize?: Function}} [deps]
285
+ * @returns {{line: string, status: string, sessionId: string|null, defects: string[], incompleteFields: string[], error: string|null, record: object|null}}
286
+ */
287
+ export function repairLine(line, deps = {}) {
288
+ const { validateSession = defaultValidateSession, serialize = defaultSerialize } = deps;
289
+ const base = { line, status: 'valid', sessionId: null, defects: [], incompleteFields: [], error: null, record: null };
290
+
291
+ if (line.trim().length === 0) return { ...base, status: 'blank' };
292
+
293
+ let record;
294
+ try {
295
+ record = JSON.parse(line);
296
+ } catch (err) {
297
+ return { ...base, status: 'unparseable', error: err?.message ?? String(err) };
298
+ }
299
+ if (!isPlainObject(record)) {
300
+ return { ...base, status: 'unparseable', error: 'line is not a JSON object' };
301
+ }
302
+
303
+ const sessionId = typeof record.session_id === 'string' ? record.session_id : null;
304
+
305
+ try {
306
+ validateSession(record);
307
+ // Already valid — emit the ORIGINAL bytes. Re-serializing a passing record
308
+ // would rewrite key order for no benefit and bury the real repairs in noise.
309
+ return { ...base, status: 'valid', sessionId, record };
310
+ } catch {
311
+ /* fall through to repair */
312
+ }
313
+
314
+ const { record: repaired, defects, incompleteFields } = repairRecord(record);
315
+ try {
316
+ // GATE ONLY — the return value is deliberately discarded. `validateSession`
317
+ // stamps `schema_version: 2` on its output, and live line 152 has no
318
+ // `schema_version` key at all; serializing its return would invent a version
319
+ // claim for a record that never made one. Serialize the repaired INPUT.
320
+ validateSession(repaired);
321
+ const serialized = serialize(repaired);
322
+ return {
323
+ line: serialized.endsWith('\n') ? serialized.slice(0, -1) : serialized,
324
+ status: 'repaired',
325
+ sessionId,
326
+ defects,
327
+ incompleteFields,
328
+ error: null,
329
+ record: repaired,
330
+ };
331
+ } catch (err) {
332
+ return {
333
+ ...base,
334
+ status: 'error',
335
+ sessionId,
336
+ defects,
337
+ error: err?.message ?? String(err),
338
+ };
339
+ }
340
+ }
341
+
342
+ /**
343
+ * Compute the repaired ledger text plus a summary, WITHOUT touching disk.
344
+ *
345
+ * Splitting on `\n` and rejoining preserves the trailing-newline shape and any
346
+ * blank segments exactly, so a file with no repairs round-trips byte-identically.
347
+ *
348
+ * @param {string} raw — full ledger contents
349
+ * @param {object} [deps]
350
+ * @returns {{text: string, summary: object, lines: object[]}}
351
+ */
352
+ export function repairText(raw, deps = {}) {
353
+ const parts = raw.split('\n');
354
+ const results = parts.map((p) => repairLine(p, deps));
355
+ const text = results.map((r) => r.line).join('\n');
356
+
357
+ const defectsByClass = {};
358
+ const errors = [];
359
+ const idCounts = new Map();
360
+ let total = 0;
361
+ let unparseable = 0;
362
+ let invalidBefore = 0;
363
+ let repaired = 0;
364
+
365
+ for (let i = 0; i < results.length; i++) {
366
+ const r = results[i];
367
+ if (r.status === 'blank') continue;
368
+ total += 1;
369
+ if (r.sessionId) idCounts.set(r.sessionId, (idCounts.get(r.sessionId) ?? 0) + 1);
370
+ if (r.status === 'unparseable') {
371
+ unparseable += 1;
372
+ continue;
373
+ }
374
+ if (r.status === 'valid') continue;
375
+ invalidBefore += 1;
376
+ for (const d of r.defects) defectsByClass[d] = (defectsByClass[d] ?? 0) + 1;
377
+ if (r.status === 'repaired') {
378
+ repaired += 1;
379
+ } else {
380
+ errors.push({ line: i + 1, session_id: r.sessionId, error: r.error });
381
+ }
382
+ }
383
+
384
+ const duplicates = [...idCounts.entries()]
385
+ .filter(([, n]) => n > 1)
386
+ .map(([session_id, count]) => ({ session_id, count }))
387
+ .sort((a, b) => (a.session_id < b.session_id ? -1 : 1));
388
+
389
+ return {
390
+ text,
391
+ lines: results,
392
+ summary: {
393
+ total,
394
+ unparseable,
395
+ invalid_before: invalidBefore,
396
+ repaired,
397
+ // Projected: every line that was invalid and did NOT get repaired.
398
+ invalid_after: invalidBefore - repaired,
399
+ duplicate_ids_observed: duplicates,
400
+ defects_by_class: defectsByClass,
401
+ errors,
402
+ },
403
+ };
404
+ }
405
+
406
+ /**
407
+ * Re-read a written ledger and prove it is actually clean.
408
+ *
409
+ * Two independent probes, because they measure different populations (see the
410
+ * integrity banner's docblock): `validateSession` is the write-path schema,
411
+ * `checkSessionsIntegrity` additionally exercises the REAL vault-mirror render
412
+ * path — a record can pass the first and still be dropped by the second.
413
+ *
414
+ * The integrity probe reads `<repoRoot>/.orchestrator/metrics/sessions.jsonl`
415
+ * by construction, so it is only meaningful when `file` IS that path; against
416
+ * any other target it is reported as skipped rather than quietly measuring the
417
+ * wrong file.
418
+ *
419
+ * @returns {{ok: boolean, invalid_after: number, invalid_lines: object[], integrity: string|object}}
420
+ */
421
+ export function verifyWritten({ file, repoRoot, deps = {} }) {
422
+ const {
423
+ readFileSync = fs.readFileSync,
424
+ validateSession = defaultValidateSession,
425
+ checkIntegrity = defaultCheckIntegrity,
426
+ } = deps;
427
+
428
+ const raw = readFileSync(file, 'utf8');
429
+ const invalidLines = [];
430
+ const parts = raw.split('\n');
431
+ for (let i = 0; i < parts.length; i++) {
432
+ if (parts[i].trim().length === 0) continue;
433
+ let record;
434
+ try {
435
+ record = JSON.parse(parts[i]);
436
+ } catch {
437
+ continue; // unparseable lines are passed through by design
438
+ }
439
+ if (!isPlainObject(record)) continue;
440
+ try {
441
+ validateSession(record);
442
+ } catch (err) {
443
+ invalidLines.push({
444
+ line: i + 1,
445
+ session_id: typeof record.session_id === 'string' ? record.session_id : null,
446
+ error: err?.message ?? String(err),
447
+ });
448
+ }
449
+ }
450
+
451
+ let integrity = 'skipped-not-canonical-path';
452
+ if (typeof repoRoot === 'string' && repoRoot.length > 0) {
453
+ const canonical = path.join(repoRoot, CANONICAL_LEDGER_REL);
454
+ if (path.resolve(file) === path.resolve(canonical)) {
455
+ const banner = checkIntegrity({ repoRoot });
456
+ integrity = banner === null ? 'clean' : banner;
457
+ }
458
+ }
459
+
460
+ const ok = invalidLines.length === 0 && (integrity === 'clean' || integrity === 'skipped-not-canonical-path');
461
+ return { ok, invalid_after: invalidLines.length, invalid_lines: invalidLines, integrity };
462
+ }
463
+
464
+ /**
465
+ * Repair a sessions ledger end-to-end.
466
+ *
467
+ * Dry-run (the default) reads and computes only — no backup, no tmp file, no
468
+ * write. Apply copies the original to `<file>.bak-<stamp>` FIRST, writes
469
+ * `<file>.tmp-<pid>` in the same directory and renames it over the target
470
+ * (atomic within a filesystem), then re-verifies the written file. A failed
471
+ * verification restores the backup byte-identically and reports `ok: false` —
472
+ * the caller maps that to exit 3.
473
+ *
474
+ * Never throws for a defective RECORD (those land in `summary.errors`); a
475
+ * genuine I/O failure DOES throw and is the caller's exit-2 case.
476
+ *
477
+ * @param {object} args
478
+ * @param {string} args.file ledger path
479
+ * @param {string} [args.repoRoot] enables the integrity post-probe
480
+ * @param {boolean} [args.apply=false] write (default: dry-run)
481
+ * @param {boolean} [args.backup=true] take a `.bak-<stamp>` under --apply
482
+ * @param {Date} [args.now] backup-stamp seam
483
+ * @param {object} [args.deps] DI: fs fns, validateSession, serialize, checkIntegrity
484
+ * @returns {object} summary
485
+ */
486
+ export function repairLedger({ file, repoRoot = null, apply = false, backup = true, now = new Date(), deps = {} }) {
487
+ const {
488
+ readFileSync = fs.readFileSync,
489
+ writeFileSync = fs.writeFileSync,
490
+ renameSync = fs.renameSync,
491
+ copyFileSync = fs.copyFileSync,
492
+ unlinkSync = fs.unlinkSync,
493
+ existsSync = fs.existsSync,
494
+ } = deps;
495
+
496
+ const raw = readFileSync(file, 'utf8');
497
+ const { text, summary: base } = repairText(raw, deps);
498
+
499
+ const summary = {
500
+ mode: apply ? 'apply' : 'dry-run',
501
+ file,
502
+ ...base,
503
+ backup_path: null,
504
+ post_verify: null,
505
+ ok: true,
506
+ };
507
+
508
+ if (!apply) return summary;
509
+
510
+ // -- backup FIRST (unconditional unless explicitly opted out) --------------
511
+ let backupPath = null;
512
+ if (backup) {
513
+ backupPath = `${file}.bak-${backupStamp(now)}`;
514
+ copyFileSync(file, backupPath);
515
+ summary.backup_path = backupPath;
516
+ }
517
+
518
+ // -- atomic swap ----------------------------------------------------------
519
+ const tmpPath = `${file}.tmp-${process.pid}`;
520
+ try {
521
+ writeFileSync(tmpPath, text, 'utf8');
522
+ renameSync(tmpPath, file);
523
+ } catch (err) {
524
+ try {
525
+ if (existsSync(tmpPath)) unlinkSync(tmpPath);
526
+ } catch {
527
+ /* best-effort cleanup */
528
+ }
529
+ throw err;
530
+ }
531
+
532
+ // -- post-verification ----------------------------------------------------
533
+ const verdict = verifyWritten({ file, repoRoot, deps });
534
+ summary.post_verify = verdict;
535
+ summary.invalid_after = verdict.invalid_after;
536
+
537
+ if (!verdict.ok) {
538
+ // Restore byte-identically. The backup is preferred (it is the on-disk
539
+ // artefact an operator can inspect); `raw` is the in-memory fallback for
540
+ // `--no-backup`, and both are the same bytes.
541
+ if (backupPath) {
542
+ copyFileSync(backupPath, file);
543
+ } else {
544
+ writeFileSync(file, raw, 'utf8');
545
+ }
546
+ summary.ok = false;
547
+ summary.restored = true;
548
+ }
549
+
550
+ return summary;
551
+ }
@@ -82,9 +82,17 @@ export const REQUIRED_FIELDS = Object.freeze([
82
82
  export const AGENT_SUMMARY_FIELDS = Object.freeze(['complete', 'partial', 'failed', 'spiral']);
83
83
 
84
84
  /**
85
- * Optional additive fields introduced for the remote-agent substrate (ADR-364 thin-slice).
86
- * These are NOT in REQUIRED_FIELDS older entries lacking them validate cleanly.
87
- * Validator: see `_validateOptionalFields` in validator.mjs.
85
+ * Optional fields declared here so a field's status is STATED, never inferred
86
+ * from the presence of an `if` in the validator. Seeded by the remote-agent
87
+ * substrate (ADR-364 thin-slice) and grown additively since (#644, #724, #773,
88
+ * #964). These are NOT in REQUIRED_FIELDS — older entries lacking them validate
89
+ * cleanly. Validator: see `_validateOptionalFields` in validator.mjs.
90
+ *
91
+ * NOT YET EXHAUSTIVE. `_validateOptionalFields` additionally shape-checks
92
+ * `discovery_stats`, `review_stats`, `platform`, `branch`, `base_branch`,
93
+ * `notes`, `duration_seconds`, `issues_closed` and `issues_created` without
94
+ * listing them here. Treat membership as "declared optional", never absence as
95
+ * "not a known field" — see the #964 follow-up note in the session report.
88
96
  */
89
97
  export const OPTIONAL_FIELDS = Object.freeze([
90
98
  'agent_identity',
@@ -112,4 +120,15 @@ export const OPTIONAL_FIELDS = Object.freeze([
112
120
  'open_questions_asked',
113
121
  'open_questions_answered',
114
122
  'open_questions_deferred',
123
+ // #964 — `effectiveness` was shape-checked by `_validateOptionalFields` while
124
+ // appearing in NEITHER list, so its status could only be inferred from an
125
+ // `if`. It is OPTIONAL on the write path and stays that way: making it
126
+ // required would retroactively invalidate the 10 existing records that lack
127
+ // it, plus every `abandoned` stub the SessionEnd backfill (#724 C1) will ever
128
+ // write. It is REQUIRED by the vault-mirror v1 renderer
129
+ // (`RENDERABLE_SESSION_FIELDS_V1`, scripts/lib/vault-mirror/render-sessions.mjs)
130
+ // — that is a strictly stronger, deliberately separate contract: "renderable
131
+ // into a note a human reads" ⊃ "schema-valid". A record missing it is a clean
132
+ // vault-mirror skip, NOT a malformed record.
133
+ 'effectiveness',
115
134
  ]);
@@ -0,0 +1,54 @@
1
+ /**
2
+ * session-schema/serializer.mjs — checked JSONL serialization for session records.
3
+ *
4
+ * Leaf module for the session-schema library. It imports the validator directly
5
+ * so the serializer can be used by library consumers without depending on the
6
+ * parent barrel or the CLI entry point.
7
+ */
8
+
9
+ import { ValidationError, validateSession } from './validator.mjs';
10
+
11
+ function normalizeCaughtValue(value) {
12
+ try {
13
+ if (value instanceof Error) {
14
+ return typeof value.message === 'string' ? value.message : String(value.message);
15
+ }
16
+ return String(value);
17
+ } catch {
18
+ return 'unknown thrown value';
19
+ }
20
+ }
21
+
22
+ /**
23
+ * Serialize a session record to one JSONL line and prove that it round-trips.
24
+ *
25
+ * JSON.stringify may silently drop values such as `undefined`, `NaN`, or
26
+ * `Infinity`; the parsed-back value is therefore validated before the line is
27
+ * returned. The validator's return value is deliberately discarded because it
28
+ * may stamp `schema_version: 2` onto an otherwise unversioned input.
29
+ *
30
+ * @param {unknown} input — session record to serialize
31
+ * @returns {string} the verified JSONL line (newline-terminated)
32
+ * @throws {ValidationError} when serialization or round-trip validation fails
33
+ */
34
+ export function serializeSessionLineChecked(input) {
35
+ let line;
36
+ try {
37
+ line = JSON.stringify(input);
38
+ } catch (err) {
39
+ throw new ValidationError(`session is not JSON-serializable: ${normalizeCaughtValue(err)}`);
40
+ }
41
+ if (typeof line !== 'string' || line.length === 0) {
42
+ throw new ValidationError('session serialized to an empty line');
43
+ }
44
+ let reparsed;
45
+ try {
46
+ reparsed = JSON.parse(line);
47
+ } catch (err) {
48
+ throw new ValidationError(
49
+ `serialized session line does not parse back as JSON: ${normalizeCaughtValue(err)}`
50
+ );
51
+ }
52
+ validateSession(reparsed);
53
+ return line + '\n';
54
+ }
@@ -105,6 +105,22 @@ function _validateSchemaVersion(entry) {
105
105
  }
106
106
  }
107
107
 
108
+ /**
109
+ * Required-field gate for the WRITE path.
110
+ *
111
+ * #964 — the predicate is KEY PRESENCE (`field in entry`), deliberately NOT
112
+ * value presence. An explicit `{ total_waves: null }` passes here and is then
113
+ * rejected by the typed sub-validator below with a message that names the type
114
+ * violation, which is the more useful diagnostic. The vault-mirror renderers
115
+ * use the OTHER predicate (`entry[field] === null || undefined`) because a null
116
+ * value is unrenderable even though it is well-formed. The two predicates agree
117
+ * on every live record but are not the same test — do not "unify" them without
118
+ * deciding which failure each side is supposed to report.
119
+ *
120
+ * The renderers' field LISTS are likewise a separate, strictly stronger
121
+ * contract; the superset relationship between them is enforced mechanically in
122
+ * tests/lib/vault-mirror/render-sessions.test.mjs, not by prose here.
123
+ */
108
124
  function _validateRequiredFields(entry) {
109
125
  for (const field of REQUIRED_FIELDS) {
110
126
  if (!(field in entry)) {
@@ -66,6 +66,7 @@
66
66
 
67
67
  export { CURRENT_SESSION_SCHEMA_VERSION, SESSION_KEY_ALIASES } from './session-schema/constants.mjs';
68
68
  export { ValidationError, validateSession } from './session-schema/validator.mjs';
69
+ export { serializeSessionLineChecked } from './session-schema/serializer.mjs';
69
70
  export { normalizeSession } from './session-schema/normalizer.mjs';
70
71
  export { clampTimestampsMonotonic } from './session-schema/timestamps.mjs';
71
72
  export { aliasLegacyEndedAt } from './session-schema/aliases.mjs';