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.
- package/.claude-plugin/marketplace.json +1 -1
- package/.claude-plugin/plugin.json +1 -1
- package/.codex-plugin/plugin.json +1 -1
- package/.cursor/rules/030-wave-execution.mdc +17 -1
- package/CHANGELOG.md +185 -412
- package/README.md +12 -9
- package/SECURITY.md +190 -27
- package/agents/AGENTS.md +20 -3
- package/agents/code-implementer.md +6 -6
- package/agents/db-specialist.md +1 -1
- package/agents/qa-strategist.md +31 -6
- package/agents/schemas/qa-strategist.schema.json +27 -0
- package/agents/schemas/test-writer.schema.json +60 -2
- package/agents/security-reviewer.md +1 -1
- package/agents/session-reviewer.md +1 -1
- package/agents/test-writer.md +29 -10
- package/agents/ui-developer.md +1 -1
- package/commands/contract-version-bump.md +28 -0
- package/commands/portfolio.md +1 -1
- package/commands/session.md +6 -2
- package/docs/USER-GUIDE.md +9 -4
- package/docs/ci-setup.md +121 -7
- package/docs/codex-setup.md +1 -1
- package/docs/components.md +6 -6
- package/docs/cursor-setup.md +22 -9
- package/docs/events-schema.md +5 -1
- package/docs/instruction-delivery.md +794 -0
- package/docs/rule-authoring.md +58 -9
- package/docs/session-config-reference.md +245 -50
- package/docs/session-config-template.md +39 -26
- package/hooks/_lib/guard-source-loader.mjs +680 -0
- package/hooks/_lib/lock-bootstrap.mjs +21 -0
- package/hooks/_lib/vcs-create-matcher.mjs +119 -0
- package/hooks/config-protection.mjs +0 -0
- package/hooks/enforce-commands.mjs +226 -19
- package/hooks/enforce-scope.mjs +133 -9
- package/hooks/hooks-codex.json +1 -1
- package/hooks/hooks-cursor.json +11 -2
- package/hooks/hooks-pi.json +10 -0
- package/hooks/hooks.json +21 -1
- package/hooks/on-session-end.mjs +178 -18
- package/hooks/on-session-start.mjs +30 -4
- package/hooks/post-bash-write-verify.mjs +977 -0
- package/hooks/post-subagent-discovery-validator.mjs +256 -41
- package/hooks/pre-bash-destructive-guard.mjs +616 -164
- package/hooks/pre-bash-issue-budget.mjs +167 -0
- package/hooks/pre-bash-sessions-ledger-guard.mjs +1054 -0
- package/hooks/pre-bash-templates-first.mjs +96 -63
- package/hooks/subagent-telemetry.mjs +527 -37
- package/package.json +6 -3
- package/pi/prompts/contract-version-bump.md +12 -0
- package/rules/README.md +32 -0
- package/scripts/archive-closed-prds.mjs +12 -22
- package/scripts/autopilot-multi.mjs +103 -20
- package/scripts/backfill-abandoned-sessions.mjs +160 -4
- package/scripts/backfill-learnings-from-vault.mjs +967 -0
- package/scripts/check-doc-consistency.sh +17 -1
- package/scripts/emit-session.mjs +3 -40
- package/scripts/eval-session.mjs +50 -9
- package/scripts/fleet-instruction-scan.mjs +141 -0
- package/scripts/lib/autopilot/mr-draft.mjs +31 -1
- package/scripts/lib/autopilot/worktree-pipeline.mjs +113 -5
- package/scripts/lib/backlog-scan.mjs +39 -6
- package/scripts/lib/blocked-commands-policy.mjs +340 -0
- package/scripts/lib/ci-status-banner.mjs +75 -12
- package/scripts/lib/claude-md-budget-lint.mjs +283 -34
- package/scripts/lib/command-blocker.mjs +1273 -58
- package/scripts/lib/config/config-protection.mjs +2 -1
- package/scripts/lib/config/drift-check.mjs +9 -1
- package/scripts/lib/config/gitlab-portfolio.mjs +1 -1
- package/scripts/lib/config/issue-budget.mjs +123 -0
- package/scripts/lib/config/reconcile.mjs +21 -0
- package/scripts/lib/config/section-extractor.mjs +121 -1
- package/scripts/lib/config-schema.mjs +23 -3
- package/scripts/lib/config.mjs +17 -0
- package/scripts/lib/convergence-monitor.mjs +49 -3
- package/scripts/lib/description-surface.mjs +535 -0
- package/scripts/lib/dispatcher/enumerate.mjs +26 -40
- package/scripts/lib/ecosystem-wizard/config-writer.mjs +26 -24
- package/scripts/lib/ecosystem-wizard/wizard-prompt.mjs +1 -1
- package/scripts/lib/eval/engine.mjs +47 -5
- package/scripts/lib/events.mjs +59 -7
- package/scripts/lib/gates/gate-full.mjs +15 -3
- package/scripts/lib/gates/gate-helpers.mjs +132 -6
- package/scripts/lib/gitlab-ops/stale-mr-sweep.mjs +28 -8
- package/scripts/lib/gitlab-portfolio/aggregator.mjs +8 -2
- package/scripts/lib/gitlab-portfolio/cli.mjs +1 -1
- package/scripts/lib/handover-gate.mjs +7 -3
- package/scripts/lib/hardening.mjs +9 -9
- package/scripts/lib/harness-audit/categories/category4.mjs +9 -3
- package/scripts/lib/instruction-budget-guard.mjs +402 -51
- package/scripts/lib/io.mjs +345 -10
- package/scripts/lib/issue-budget.mjs +269 -0
- package/scripts/lib/issue-close-strip-labels.mjs +39 -9
- package/scripts/lib/label-scope.mjs +47 -0
- package/scripts/lib/learnings/affinity.mjs +434 -0
- package/scripts/lib/learnings/candidates.mjs +736 -0
- package/scripts/lib/learnings/expiry-sweep.mjs +408 -53
- package/scripts/lib/learnings/judgment.mjs +782 -0
- package/scripts/lib/learnings/kebab.mjs +128 -0
- package/scripts/lib/learnings/schema.mjs +43 -3
- package/scripts/lib/learnings/select.mjs +550 -0
- package/scripts/lib/lock-reaper.mjs +1 -2
- package/scripts/lib/memory-proposals/schema.mjs +36 -1
- package/scripts/lib/peer-discovery.mjs +645 -0
- package/scripts/lib/pi-hook-bridge.mjs +146 -17
- package/scripts/lib/product-repo-detect.mjs +9 -8
- package/scripts/lib/project-hygiene.mjs +432 -0
- package/scripts/lib/quality-gate.mjs +167 -0
- package/scripts/lib/recommendations-v0.mjs +1 -1
- package/scripts/lib/reconcile/eligibility.mjs +1 -1
- package/scripts/lib/reconcile/emitter.mjs +128 -24
- package/scripts/lib/reconcile/engine.mjs +156 -54
- package/scripts/lib/reconcile/idempotency.mjs +114 -14
- package/scripts/lib/reconcile/renderer.mjs +141 -25
- package/scripts/lib/reconcile/sanitize.mjs +518 -0
- package/scripts/lib/reconcile/writer.mjs +95 -1
- package/scripts/lib/reconcile-nudge-banner.mjs +65 -9
- package/scripts/lib/resource-probe/evaluate.mjs +70 -4
- package/scripts/lib/resource-probe.mjs +19 -0
- package/scripts/lib/rule-loader.mjs +6 -0
- package/scripts/lib/scope-baseline.mjs +564 -0
- package/scripts/lib/scope-gate.mjs +568 -145
- package/scripts/lib/session-close-backfill.mjs +63 -8
- package/scripts/lib/session-end/phase-skip.mjs +1 -0
- package/scripts/lib/session-id.mjs +221 -41
- package/scripts/lib/session-lock.mjs +304 -6
- package/scripts/lib/session-record-repair.mjs +551 -0
- package/scripts/lib/session-schema/constants.mjs +22 -3
- package/scripts/lib/session-schema/serializer.mjs +54 -0
- package/scripts/lib/session-schema/validator.mjs +16 -0
- package/scripts/lib/session-schema.mjs +1 -0
- package/scripts/lib/session-token-rollup.mjs +68 -6
- package/scripts/lib/sessions-integrity-banner.mjs +294 -0
- package/scripts/lib/sessions-staleness-banner.mjs +121 -12
- package/scripts/lib/skill-evolution/idempotency.mjs +135 -16
- package/scripts/lib/skill-evolution/mr-opener.mjs +9 -1
- package/scripts/lib/soul-resolve.mjs +12 -0
- package/scripts/lib/spiral-carryover.mjs +142 -30
- package/scripts/lib/state-md/mission-status.mjs +53 -3
- package/scripts/lib/subagents-schema.mjs +43 -9
- package/scripts/lib/test-runner/issue-reconcile.mjs +53 -13
- package/scripts/lib/tests-src-ratio.mjs +484 -0
- package/scripts/lib/tmux-layout/telemetry.mjs +43 -10
- package/scripts/lib/validate/check-agents.mjs +56 -0
- package/scripts/lib/validate/check-banner-parity.mjs +376 -0
- package/scripts/lib/validate/check-guard-requires-parity.mjs +1148 -0
- package/scripts/lib/validate/check-hooks-symmetry.mjs +244 -10
- package/scripts/lib/validate/check-learning-provenance.mjs +511 -0
- package/scripts/lib/validate/check-owner-leakage.mjs +3 -3
- package/scripts/lib/validate/check-rules.mjs +244 -36
- package/scripts/lib/validate/check-test-value-bans.mjs +782 -0
- package/scripts/lib/validate/check-unicode-safety.mjs +1 -0
- package/scripts/lib/validate/check-unwired-features.mjs +549 -0
- package/scripts/lib/validate-vendored-rules.mjs +10 -2
- package/scripts/lib/vault-archive.mjs +17 -2
- package/scripts/lib/vault-backfill/glab.mjs +8 -0
- package/scripts/lib/vault-mirror/process.mjs +30 -0
- package/scripts/lib/vault-mirror/render-sessions.mjs +293 -36
- package/scripts/lib/vcs-repo-spec.mjs +362 -0
- package/scripts/lib/wave-resource-gate.mjs +115 -11
- package/scripts/lib/worktree/listing.mjs +44 -7
- package/scripts/mcp-server.sh +17 -3
- package/scripts/measure-context-overhead.sh +151 -0
- package/scripts/memory-propose.mjs +72 -9
- package/scripts/print-applicable-rules.mjs +218 -16
- package/scripts/print-learnings-index.mjs +474 -0
- package/scripts/release.mjs +534 -0
- package/scripts/repair-invalid-sessions.mjs +209 -0
- package/scripts/run-quality-gate.mjs +123 -5
- package/scripts/sweep-expired-learnings.mjs +192 -32
- package/scripts/validate-plugin.mjs +21 -0
- package/scripts/validate-wave-scope.mjs +182 -17
- package/scripts/vault-integration-watcher.mjs +32 -10
- package/skills/_shared/config-reading.md +2 -2
- package/skills/bootstrap/fast-template.md +1 -1
- package/skills/brainstorm/soul.md +47 -1
- package/skills/claude-md-drift-check/checker.mjs +145 -28
- package/skills/contract-version-bump/SKILL.md +219 -0
- package/skills/discovery/SKILL.md +4 -4
- package/skills/discovery/issue-templates.md +11 -11
- package/skills/discovery/probes-audit.md +1 -1
- package/skills/discovery/probes-feature.md +1 -1
- package/skills/discovery/probes-session.md +26 -5
- package/skills/ecosystem-health/SKILL.md +1 -1
- package/skills/ecosystem-health/wizard.md +4 -4
- package/skills/evolve/SKILL.md +117 -18
- package/skills/gitlab-ops/SKILL.md +25 -12
- package/skills/gitlab-portfolio/SKILL.md +2 -2
- package/skills/grill/soul.md +44 -1
- package/skills/hook-development/SKILL.md +1 -1
- package/skills/mode-selector/SKILL.md +1 -1
- package/skills/npm-publish/SKILL.md +17 -1
- package/skills/plan/SKILL.md +5 -5
- package/skills/plan/mode-feature.md +4 -4
- package/skills/plan/mode-new.md +10 -10
- package/skills/plan/mode-retro.md +1 -1
- package/skills/plan/soul.md +46 -3
- package/skills/quality-gates/SKILL.md +1 -1
- package/skills/reconcile/SKILL.md +21 -4
- package/skills/session-end/SKILL.md +34 -36
- package/skills/session-end/discovery-scan.md +4 -2
- package/skills/session-end/drift-operations.md +4 -4
- package/skills/session-end/metrics-collection.md +13 -0
- package/skills/session-end/phase-3-2-docs-verification.md +1 -1
- package/skills/session-end/phase-3-6-tail.md +32 -2
- package/skills/session-end/plan-verification.md +6 -7
- package/skills/session-end/session-metrics-write.md +2 -0
- package/skills/session-end/vault-operations.md +1 -1
- package/skills/session-end/verification-checklist.md +1 -1
- package/skills/session-plan/SKILL.md +6 -2
- package/skills/session-plan/wave-template.md +2 -0
- package/skills/session-start/SKILL.md +75 -7
- package/skills/session-start/phase-4-5-resource-health.md +15 -2
- package/skills/session-start/soul.md +41 -1
- package/skills/test-runner/SKILL.md +2 -2
- package/skills/vault-sync/validator.mjs +108 -7
- package/skills/wave-executor/SKILL.md +6 -7
- package/skills/wave-executor/circuit-breaker.md +2 -0
- package/skills/wave-executor/wave-loop.md +198 -80
- 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
|
|
86
|
-
*
|
|
87
|
-
*
|
|
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';
|