session-orchestrator 5.4.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.
- package/.claude-plugin/marketplace.json +1 -1
- package/.claude-plugin/plugin.json +1 -1
- package/.codex-plugin/plugin.json +1 -1
- package/.cursor/rules/060-evolve.mdc +5 -5
- package/.cursor-plugin/plugin.json +1 -1
- package/CHANGELOG.md +95 -3
- package/README.md +11 -11
- package/docs/ci-setup.md +120 -23
- package/docs/components.md +7 -7
- package/docs/events-schema.md +17 -14
- package/docs/install.md +14 -2
- package/docs/migration-v4.md +24 -17
- package/docs/plugin-architecture-v3.md +1 -1
- package/docs/scope-collision-guard.md +15 -13
- package/docs/session-config-reference.md +7 -7
- package/docs/vault-docs-architecture.md +8 -4
- package/hooks/_lib/hook-import-set.json +17 -8
- package/hooks/_lib/lock-bootstrap.mjs +118 -26
- package/hooks/config-protection.mjs +1 -1
- package/hooks/enforce-commands.mjs +28 -13
- package/hooks/enforce-scope.mjs +201 -23
- package/hooks/hooks-codex.json +1 -1
- package/hooks/hooks.json +1 -1
- package/hooks/on-session-start.mjs +4 -2
- package/hooks/on-stop.mjs +70 -170
- package/hooks/post-bash-write-verify.mjs +98 -17
- package/hooks/post-edit-validate.mjs +15 -5
- package/hooks/post-tool-batch-wave-signal.mjs +32 -154
- package/hooks/pre-bash-memory-propose-audit.mjs +19 -7
- package/hooks/pre-bash-staging-fence.mjs +29 -8
- package/hooks/pre-task-scope-disjoint.mjs +584 -207
- package/hooks/wave-scope-commit-guard.mjs +168 -72
- package/package.json +2 -6
- package/scripts/apply-session-learnings.mjs +35 -14
- package/scripts/archive-closed-prds.mjs +38 -11
- package/scripts/autopilot.mjs +8 -1
- package/scripts/backfill-abandoned-sessions.mjs +80 -5
- package/scripts/backfill-learnings-from-vault.mjs +49 -22
- package/scripts/codex-install.mjs +3 -0
- package/scripts/cursor-install.mjs +46 -2
- package/scripts/emit-session.mjs +5 -3
- package/scripts/export-hw-learnings.mjs +18 -7
- package/scripts/fleet-checkin.mjs +26 -3
- package/scripts/lib/autopilot/loop.mjs +25 -10
- package/scripts/lib/autopilot/stall-sampler.mjs +13 -9
- package/scripts/lib/blocked-commands-policy.mjs +40 -0
- package/scripts/lib/common.mjs +41 -0
- package/scripts/lib/config/vault-integration.mjs +151 -16
- package/scripts/lib/config.mjs +5 -1
- package/scripts/lib/eval/engine.mjs +13 -0
- package/scripts/lib/events-retention-banner.mjs +393 -0
- package/scripts/lib/events-rotation.mjs +21 -6
- package/scripts/lib/events.mjs +51 -3
- package/scripts/lib/fleet-protocol.mjs +20 -2
- package/scripts/lib/hardening.mjs +1 -0
- package/scripts/lib/harness-audit/categories/category4.mjs +8 -6
- package/scripts/lib/instruction-budget-guard.mjs +8 -0
- package/scripts/lib/learnings/expiry-sweep.mjs +387 -20
- package/scripts/lib/learnings/io.mjs +125 -16
- package/scripts/lib/lock-reaper.mjs +38 -14
- package/scripts/lib/maintenance-due-banner.mjs +23 -8
- package/scripts/lib/named-vault-resolver.mjs +48 -5
- package/scripts/lib/orphan-reaper/no-follow-append.mjs +87 -0
- package/scripts/lib/orphan-reaper/reaper-audit.mjs +101 -69
- package/scripts/lib/orphan-reaper/scan-throttle.mjs +79 -27
- package/scripts/lib/orphan-reaper/scan.mjs +4 -0
- package/scripts/lib/orphan-reaper/trigger.mjs +173 -0
- package/scripts/lib/orphan-reaper.mjs +5 -1
- package/scripts/lib/platform.mjs +206 -2
- package/scripts/lib/plugin-update-banner.mjs +3 -1
- package/scripts/lib/process-group.mjs +83 -22
- package/scripts/lib/scope-baseline.mjs +116 -25
- package/scripts/lib/scope-echo.mjs +7 -0
- package/scripts/lib/scope-gate.mjs +194 -27
- package/scripts/lib/session-close-backfill.mjs +129 -4
- package/scripts/lib/session-end/tail-runner.mjs +10 -5
- package/scripts/lib/session-lock-shape.mjs +6 -3
- package/scripts/lib/session-lock.mjs +320 -67
- package/scripts/lib/session-registry.mjs +41 -16
- package/scripts/lib/session-start-probes.mjs +242 -68
- package/scripts/lib/session-token-rollup.mjs +39 -34
- package/scripts/lib/sessions-staleness-banner.mjs +3 -1
- package/scripts/lib/tail-window.mjs +27 -3
- package/scripts/lib/telemetry/pricing.mjs +5 -2
- package/scripts/lib/telemetry/sync.mjs +17 -0
- package/scripts/lib/tests-src-ratio.mjs +77 -2
- package/scripts/lib/tmux-layout/telemetry-stats.mjs +12 -0
- package/scripts/lib/validate/check-test-value-bans.mjs +320 -32
- package/scripts/lib/validate/check-vcs-repo-flag.mjs +1 -1
- package/scripts/lib/vault-mirror/auto-commit.mjs +5 -3
- package/scripts/lib/vault-mirror/namespace.mjs +47 -26
- package/scripts/lib/vault-mirror/process.mjs +350 -62
- package/scripts/lib/vault-mirror/pseudonym-map.mjs +14 -5
- package/scripts/lib/vault-mirror/render-learnings.mjs +66 -3
- package/scripts/lib/vault-mirror/render-sessions.mjs +28 -4
- package/scripts/lib/vault-mirror/telemetry.mjs +18 -11
- package/scripts/lib/vault-status/board-writer.mjs +5 -1
- package/scripts/lib/vcs-repo-spec.mjs +8 -0
- package/scripts/materialize-wave-scope.mjs +7 -4
- package/scripts/release.mjs +1 -0
- package/scripts/run-migrate-v2-cross-repo.mjs +14 -2
- package/scripts/run-quality-gate.mjs +8 -5
- package/scripts/self-update.mjs +229 -0
- package/scripts/sweep-expired-learnings.mjs +560 -28
- package/scripts/validate-wave-scope.mjs +21 -2
- package/scripts/vault-backfill.mjs +50 -37
- package/scripts/vault-mirror.mjs +170 -33
- package/skills/_shared/fleet-protocol.md +74 -71
- package/skills/_shared/state-ownership.md +38 -14
- package/skills/autopilot/SKILL.md +1 -1
- package/skills/bootstrap/SKILL.md +5 -3
- package/skills/bootstrap/_shared-template.md +96 -1
- package/skills/bootstrap/fast-template.md +3 -0
- package/skills/bootstrap/references/bootstrap-refresh-lock-flow.md +5 -3
- package/skills/bootstrap/standard-template.md +2 -0
- package/skills/discovery/probes/ssot-code-diff.mjs +12 -6
- package/skills/discovery/probes/vault-narrative-staleness.mjs +6 -2
- package/skills/discovery/probes/vault-staleness.mjs +9 -3
- package/skills/docs-orchestrator/SKILL.md +12 -5
- package/skills/docs-orchestrator/audience-mapping.md +5 -3
- package/skills/evolve/SKILL.md +11 -2
- package/skills/evolve/references/evolve-analyze-mode.md +37 -17
- package/skills/session-end/learning-patterns.md +3 -3
- package/skills/session-end/metrics-collection.md +1 -1
- package/skills/session-end/phase-3-2-docs-verification.md +7 -6
- package/skills/session-end/plan-verification.md +25 -9
- package/skills/session-end/references/phase-3-documentation-updates.md +11 -1
- package/skills/session-end/session-metrics-write.md +45 -10
- package/skills/session-start/SKILL.md +3 -3
- package/skills/session-start/references/operations-contract.md +5 -3
- package/skills/session-start/references/phase-1-2-session-lock.md +32 -7
- package/skills/session-start/references/phase-4-ssot-environment-check.md +8 -1
- package/skills/vault-mirror/SKILL.md +21 -11
- package/skills/wave-executor/SKILL.md +1 -1
- package/skills/wave-executor/circuit-breaker.md +1 -1
- package/skills/wave-executor/references/wave-executor-state-init.md +1 -1
- package/skills/wave-executor/references/wave-loop-dispatch.md +8 -0
- package/skills/wave-executor/references/wave-loop-review.md +2 -2
- package/skills/wave-executor/references/wave-loop-scope-manifest.md +13 -5
- package/.agents/skills/navigator/SKILL.md +0 -22
- package/.agents/skills/navigator/agents/openai.yaml +0 -5
- package/.codex-plugin/skills/navigator/SKILL.md +0 -22
- package/.codex-plugin/skills/navigator/agents/openai.yaml +0 -5
- package/.cursor/commands/navigator.md +0 -15
- package/.cursor/skills/navigator/SKILL.md +0 -13
- package/pi/prompts/navigator.md +0 -12
- package/skills/navigator/SKILL.md +0 -193
- package/skills/navigator/references/account-check.sh +0 -87
- package/skills/navigator/references/fleet-decisions.mjs +0 -231
- package/skills/navigator/references/slot-check.sh +0 -114
|
@@ -30,6 +30,11 @@
|
|
|
30
30
|
* ONLY destructive step, and it reuses `rewriteLearnings()` from io.mjs
|
|
31
31
|
* (#721), which itself snapshots a `.bak-<ISO>` backup before the atomic
|
|
32
32
|
* rename — the same safety net that protects every other bulk rewrite.
|
|
33
|
+
* A store line that does not parse is carried through the rewrite
|
|
34
|
+
* verbatim (#1489): neither partition can judge it, and the rewrite is
|
|
35
|
+
* built from parsed records, so leaving it out deleted it with no archive
|
|
36
|
+
* record. Refusing the rewrite instead would stall the session-end sweep
|
|
37
|
+
* on every close until someone repaired the line by hand.
|
|
33
38
|
* - Grace period (default 14 days): an entry that JUST expired stays in the
|
|
34
39
|
* active store for `graceDays` more days. This absorbs two things: (a)
|
|
35
40
|
* TTL edge-noise near the boundary, and (b) a window for /evolve's
|
|
@@ -54,15 +59,82 @@
|
|
|
54
59
|
* (the incident that destroyed 107 live learnings via a bespoke writer
|
|
55
60
|
* with no backup/dry-run safety net).
|
|
56
61
|
*
|
|
62
|
+
* #1500 — {@link dropMalformedLines}, the one sanctioned removal of an
|
|
63
|
+
* unparseable store line: the lines the two writers above keep verbatim.
|
|
64
|
+
*
|
|
57
65
|
* Sibling-module import convention (learnings.mjs barrel doc): import
|
|
58
66
|
* directly from `./io.mjs`, never from `../learnings.mjs`, to preserve the
|
|
59
67
|
* acyclic dependency graph.
|
|
60
68
|
*/
|
|
61
69
|
|
|
62
|
-
import { existsSync } from 'node:fs';
|
|
63
|
-
import { mkdir, appendFile } from 'node:fs/promises';
|
|
70
|
+
import { constants as fsConstants, existsSync } from 'node:fs';
|
|
71
|
+
import { mkdir, appendFile, copyFile } from 'node:fs/promises';
|
|
64
72
|
import path from 'node:path';
|
|
65
|
-
import { readLearnings, rewriteLearnings, withLearningsLock } from './io.mjs';
|
|
73
|
+
import { readLearnings, readLearningsSnapshot, rewriteLearnings, withLearningsLock } from './io.mjs';
|
|
74
|
+
import { redactHomeDir } from '../common.mjs';
|
|
75
|
+
|
|
76
|
+
/**
|
|
77
|
+
* Thrown by {@link pruneLearnings} when `expectedGeneration` no longer matches
|
|
78
|
+
* the store (#1486): the caller's next generation was derived from a store
|
|
79
|
+
* state that has since changed, so every record added after that derivation
|
|
80
|
+
* would read as caller-dropped and be archived `pruned`. Nothing is written.
|
|
81
|
+
*/
|
|
82
|
+
export class StoreGenerationMismatchError extends Error {
|
|
83
|
+
/**
|
|
84
|
+
* @param {string} filePath
|
|
85
|
+
* @param {string} expected - token the caller derived its generation from
|
|
86
|
+
* @param {string} actual - token of the store as read under the lock
|
|
87
|
+
*/
|
|
88
|
+
constructor(filePath, expected, actual) {
|
|
89
|
+
// The tokens live on the object, never in the message: callers print the
|
|
90
|
+
// message, and the CURRENT token copied onto the stale sidecar's line 1 is
|
|
91
|
+
// exactly what gets that sidecar past this check — measured, the copy
|
|
92
|
+
// archived the peer's record `pruned`, exit 0.
|
|
93
|
+
super(`store-generation-mismatch: ${filePath} changed since the next generation was derived — nothing written`);
|
|
94
|
+
this.name = 'StoreGenerationMismatchError';
|
|
95
|
+
this.code = 'store-generation-mismatch';
|
|
96
|
+
this.filePath = filePath;
|
|
97
|
+
this.expected = expected;
|
|
98
|
+
this.actual = actual;
|
|
99
|
+
}
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
/**
|
|
103
|
+
* Thrown by {@link dropMalformedLines} when a requested line may not be
|
|
104
|
+
* dropped. Nothing is written. Two reasons:
|
|
105
|
+
* - `not-unparseable`: a record, a blank line, or past the end. The message
|
|
106
|
+
* names where the unparseable lines are NOW — every rewrite moves them to
|
|
107
|
+
* the end, so a number from an earlier WARN is often stale (#1500 F3).
|
|
108
|
+
* - `holds-a-record`: the line still carries a readable record (behind a
|
|
109
|
+
* BOM, or fused after a torn one) and `acceptEmbedded` was not given.
|
|
110
|
+
*/
|
|
111
|
+
export class MalformedLineRefusedError extends Error {
|
|
112
|
+
/**
|
|
113
|
+
* @param {string} filePath
|
|
114
|
+
* @param {number[]} lines - the requested lines that may not be dropped
|
|
115
|
+
* @param {{ reason?: 'not-unparseable'|'holds-a-record', unparseable?: number[] }} [opts]
|
|
116
|
+
* `unparseable`: the store's unparseable lines as read (for `not-unparseable`)
|
|
117
|
+
*/
|
|
118
|
+
constructor(filePath, lines, { reason = 'not-unparseable', unparseable = [] } = {}) {
|
|
119
|
+
const where = `line ${lines.join(', ')} of ${redactHomeDir(filePath)}`;
|
|
120
|
+
const now =
|
|
121
|
+
unparseable.length === 0
|
|
122
|
+
? 'none'
|
|
123
|
+
: unparseable.slice(0, 10).join(', ') + (unparseable.length > 10 ? `, +${unparseable.length - 10} more` : '');
|
|
124
|
+
super(
|
|
125
|
+
reason === 'holds-a-record'
|
|
126
|
+
? `drop-malformed refused: ${where} still holds a readable record (see bom / embedded_record ` +
|
|
127
|
+
`in the dry run) — dropping it loses that record; nothing written. Pass --accept-embedded ` +
|
|
128
|
+
`to drop it anyway (the pre-drop snapshot keeps the line)`
|
|
129
|
+
: `drop-malformed refused: ${where} is not an unparseable line (a record, a blank line, or ` +
|
|
130
|
+
`past the end) — nothing written; unparseable lines now: ${now}`
|
|
131
|
+
);
|
|
132
|
+
this.name = 'MalformedLineRefusedError';
|
|
133
|
+
this.code = 'malformed-line-refused';
|
|
134
|
+
this.reason = reason;
|
|
135
|
+
this.lines = lines;
|
|
136
|
+
}
|
|
137
|
+
}
|
|
66
138
|
|
|
67
139
|
/**
|
|
68
140
|
* Composite-key separator for the in-memory consolidation Map.
|
|
@@ -202,6 +274,65 @@ function countByReason(batch) {
|
|
|
202
274
|
return out;
|
|
203
275
|
}
|
|
204
276
|
|
|
277
|
+
/**
|
|
278
|
+
* Result field for the store's unparseable lines: `{malformed: n}` when the
|
|
279
|
+
* read held `n > 0`, else `{}` — so the pinned zero-case result shape (the
|
|
280
|
+
* CLI's `--json` contract) is unchanged, and an absent field always means a
|
|
281
|
+
* read that found none. A non-zero count also goes to stderr, because a
|
|
282
|
+
* caller that forwards only its own fields (the session-end tail runner, the
|
|
283
|
+
* CLI's human-readable line) would otherwise drop it on the floor.
|
|
284
|
+
*
|
|
285
|
+
* @param {string} filePath
|
|
286
|
+
* @param {string[]} malformed - raw text of the lines that did not parse
|
|
287
|
+
* @param {number[]} lineNumbers - their 1-based store lines (`malformedLineNumbers`)
|
|
288
|
+
* @returns {{malformed?: number}}
|
|
289
|
+
*/
|
|
290
|
+
function malformedField(filePath, malformed, lineNumbers) {
|
|
291
|
+
if (malformed.length === 0) return {};
|
|
292
|
+
warnUnparseableLines(filePath, malformed, lineNumbers);
|
|
293
|
+
return { malformed: malformed.length };
|
|
294
|
+
}
|
|
295
|
+
|
|
296
|
+
/**
|
|
297
|
+
* Store + line set already WARNed about in this process. One `/close` reads the
|
|
298
|
+
* same store twice in one process — the planner's dry run, then the apply — and
|
|
299
|
+
* printed the identical WARN twice. Grows by one key per distinct store+line
|
|
300
|
+
* set, so it stays tiny; revisit if a long-lived process ever sweeps many stores.
|
|
301
|
+
* @type {Set<string>}
|
|
302
|
+
*/
|
|
303
|
+
const warnedUnparseable = new Set();
|
|
304
|
+
|
|
305
|
+
/**
|
|
306
|
+
* Print the one stderr WARN for a store's unparseable lines — at most once per
|
|
307
|
+
* store and identical line set per process. The policy it states is the store
|
|
308
|
+
* policy since #1489: such lines are kept verbatim at the end of every rewrite,
|
|
309
|
+
* never archived, and never a reason to refuse a write.
|
|
310
|
+
*
|
|
311
|
+
* @param {string} filePath
|
|
312
|
+
* @param {string[]} malformed - raw text of the lines that did not parse
|
|
313
|
+
* @param {number[]} [lineNumbers] - their 1-based store lines; at most ten listed
|
|
314
|
+
*
|
|
315
|
+
* The text names no dry-run state: one WARN per process is shared by the
|
|
316
|
+
* planner's dry run and the apply that follows it in a /close, so a dry-run
|
|
317
|
+
* qualifier would be printed for — and contradict — the applied rewrite.
|
|
318
|
+
*/
|
|
319
|
+
export function warnUnparseableLines(filePath, malformed, lineNumbers = []) {
|
|
320
|
+
if (malformed.length === 0) return;
|
|
321
|
+
const key = `${path.resolve(filePath)}\0${malformed.join('\n')}`;
|
|
322
|
+
if (warnedUnparseable.has(key)) return;
|
|
323
|
+
warnedUnparseable.add(key);
|
|
324
|
+
const more = lineNumbers.length > 10 ? `, +${lineNumbers.length - 10} more` : '';
|
|
325
|
+
const where = lineNumbers.length > 0 ? ` (line(s) ${lineNumbers.slice(0, 10).join(', ')}${more})` : '';
|
|
326
|
+
console.error(
|
|
327
|
+
// The store path is absolute and lands in every /close transcript (#1490).
|
|
328
|
+
`[learnings] WARN: ${malformed.length} unparseable line(s) in ${redactHomeDir(filePath)}${where} kept verbatim, ` +
|
|
329
|
+
`never archived; report them to the operator — ` +
|
|
330
|
+
`never hand-edit the store. Once the operator decides, remove one with ` +
|
|
331
|
+
`\`node scripts/sweep-expired-learnings.mjs --drop-malformed --line N --file ${redactHomeDir(filePath)}\` ` +
|
|
332
|
+
`(dry run; it prints the --generation its --apply needs)`
|
|
333
|
+
);
|
|
334
|
+
}
|
|
335
|
+
|
|
205
336
|
/** Resolve the `now` parameter (Date | epoch ms | undefined) to epoch ms. */
|
|
206
337
|
function resolveNowMs(now) {
|
|
207
338
|
if (now instanceof Date) return now.getTime();
|
|
@@ -223,15 +354,32 @@ function resolveNowMs(now) {
|
|
|
223
354
|
* @param {string} opts.archivePath
|
|
224
355
|
* @param {object[]} opts.keep — the next store generation
|
|
225
356
|
* @param {{entry: object, verdict: {reason: string, tombstone: object|null}}[]} opts.archiveBatch
|
|
357
|
+
* @param {string[]} opts.malformed — unparseable store lines, written back
|
|
358
|
+
* verbatim after `keep` (see the module header)
|
|
359
|
+
* @param {number[]} opts.malformedLineNumbers — their 1-based store lines (WARN only)
|
|
226
360
|
* @param {number} opts.nowMs
|
|
227
361
|
* @param {boolean} opts.dryRun
|
|
228
|
-
* @returns {Promise<{kept: number, archived: number, byReason: Record<string, number
|
|
362
|
+
* @returns {Promise<{kept: number, archived: number, byReason: Record<string, number>,
|
|
363
|
+
* malformedField: {malformed?: number}}>}
|
|
229
364
|
*/
|
|
230
|
-
async function archiveThenRewrite({
|
|
365
|
+
async function archiveThenRewrite({
|
|
366
|
+
filePath,
|
|
367
|
+
archivePath,
|
|
368
|
+
keep,
|
|
369
|
+
archiveBatch,
|
|
370
|
+
malformed,
|
|
371
|
+
malformedLineNumbers,
|
|
372
|
+
nowMs,
|
|
373
|
+
dryRun,
|
|
374
|
+
}) {
|
|
231
375
|
const byReason = countByReason(archiveBatch);
|
|
232
|
-
|
|
233
|
-
|
|
234
|
-
|
|
376
|
+
const result = {
|
|
377
|
+
kept: keep.length,
|
|
378
|
+
archived: archiveBatch.length,
|
|
379
|
+
byReason,
|
|
380
|
+
malformedField: malformedField(filePath, malformed, malformedLineNumbers),
|
|
381
|
+
};
|
|
382
|
+
if (dryRun) return result;
|
|
235
383
|
|
|
236
384
|
// Validate the KEEP batch BEFORE the archive append (dry-run rewrite: throws
|
|
237
385
|
// on a bad record, writes nothing). Without this, an invalid-but-parseable
|
|
@@ -240,7 +388,7 @@ async function archiveThenRewrite({ filePath, archivePath, keep, archiveBatch, n
|
|
|
240
388
|
// duplicates the archive append every time (never de-duplicated on read)
|
|
241
389
|
// while the store is never actually pruned. Validating first means a bad
|
|
242
390
|
// record throws here, before anything on disk has been touched.
|
|
243
|
-
await rewriteLearnings(filePath, keep, { dryRun: true });
|
|
391
|
+
await rewriteLearnings(filePath, keep, { dryRun: true, malformedLines: malformed });
|
|
244
392
|
|
|
245
393
|
// Crash-safe ordering: archive append FIRST. A duplicate re-append after a
|
|
246
394
|
// crash is harmless (append-only, never de-duplicated on read); the reverse
|
|
@@ -259,9 +407,9 @@ async function archiveThenRewrite({ filePath, archivePath, keep, archiveBatch, n
|
|
|
259
407
|
// Re-validates (cheap, idempotent) and snapshots a `.bak-<ISO>` backup of
|
|
260
408
|
// the current store before the atomic rename (io.mjs #721). The KEEP batch
|
|
261
409
|
// already passed the dry-run probe above, so this call cannot throw here.
|
|
262
|
-
await rewriteLearnings(filePath, keep, { dryRun: false });
|
|
410
|
+
await rewriteLearnings(filePath, keep, { dryRun: false, malformedLines: malformed });
|
|
263
411
|
|
|
264
|
-
return
|
|
412
|
+
return result;
|
|
265
413
|
}
|
|
266
414
|
|
|
267
415
|
/**
|
|
@@ -279,7 +427,10 @@ async function archiveThenRewrite({ filePath, archivePath, keep, archiveBatch, n
|
|
|
279
427
|
* @param {Date|number} [opts.now] - injectable clock; defaults to `Date.now()`
|
|
280
428
|
* @param {boolean} [opts.dryRun=true] - when true, computes counts but writes nothing
|
|
281
429
|
* @param {number} [opts.graceDays=14] - days past expiry before an entry is archived
|
|
282
|
-
* @returns {Promise<{scanned: number, kept: number, archived: number, dryRun: boolean, archivePath: string
|
|
430
|
+
* @returns {Promise<{scanned: number, kept: number, archived: number, dryRun: boolean, archivePath: string,
|
|
431
|
+
* malformed?: number}>} `malformed` is present only when the store held
|
|
432
|
+
* unparseable lines: their count. They are kept verbatim at the end of the
|
|
433
|
+
* rewritten store, never archived, and not counted in `scanned`/`kept`.
|
|
283
434
|
* Never throws on a missing store — returns the zeroed shape instead. Both
|
|
284
435
|
* `filePath` and `archivePath` are required; a missing/invalid `filePath`
|
|
285
436
|
* throws a plain `Error` (programmer error, not a runtime data condition).
|
|
@@ -321,7 +472,7 @@ async function sweepExpiredLearningsUnlocked({
|
|
|
321
472
|
const graceMs =
|
|
322
473
|
(Number.isFinite(graceDays) && graceDays >= 0 ? graceDays : DEFAULT_GRACE_DAYS) * MS_PER_DAY;
|
|
323
474
|
|
|
324
|
-
const { entries } = await readLearnings(filePath);
|
|
475
|
+
const { entries, malformed, malformedLineNumbers } = await readLearnings(filePath);
|
|
325
476
|
|
|
326
477
|
const keep = [];
|
|
327
478
|
const archiveBatch = [];
|
|
@@ -342,11 +493,13 @@ async function sweepExpiredLearningsUnlocked({
|
|
|
342
493
|
}
|
|
343
494
|
}
|
|
344
495
|
|
|
345
|
-
const { kept, archived } = await archiveThenRewrite({
|
|
496
|
+
const { kept, archived, malformedField: malformedCount } = await archiveThenRewrite({
|
|
346
497
|
filePath,
|
|
347
498
|
archivePath,
|
|
348
499
|
keep,
|
|
349
500
|
archiveBatch,
|
|
501
|
+
malformed,
|
|
502
|
+
malformedLineNumbers,
|
|
350
503
|
nowMs,
|
|
351
504
|
dryRun,
|
|
352
505
|
});
|
|
@@ -354,7 +507,7 @@ async function sweepExpiredLearningsUnlocked({
|
|
|
354
507
|
// Result shape is pinned by the CLI (`scripts/sweep-expired-learnings.mjs`)
|
|
355
508
|
// and its JSON contract — deliberately WITHOUT `byReason`, which only the
|
|
356
509
|
// multi-reason prune path below can populate meaningfully.
|
|
357
|
-
return { scanned: entries.length, kept, archived, dryRun, archivePath };
|
|
510
|
+
return { scanned: entries.length, kept, archived, dryRun, archivePath, ...malformedCount };
|
|
358
511
|
}
|
|
359
512
|
|
|
360
513
|
/**
|
|
@@ -411,8 +564,19 @@ async function sweepExpiredLearningsUnlocked({
|
|
|
411
564
|
* @param {string|Function} [opts.dropReason='pruned'] - verdict for case 3
|
|
412
565
|
* above: a reason string, or `(entry) => reason|{reason, supersededBy, mergedInto}`
|
|
413
566
|
* for per-record routing (this is the seam a later `merged` producer uses).
|
|
567
|
+
* @param {string} [opts.expectedGeneration] - generation token
|
|
568
|
+
* (`readLearningsSnapshot().generation`) of the store state `entries` was
|
|
569
|
+
* derived from (#1486). Compared against the store as read here — under the
|
|
570
|
+
* lock on apply — and a mismatch throws {@link StoreGenerationMismatchError}
|
|
571
|
+
* before anything is written. Required whenever `entries` was derived in an
|
|
572
|
+
* earlier read, outside the lock (the `/evolve` sidecar); a caller that
|
|
573
|
+
* reads and prunes inside one `withLearningsLock` section may omit it.
|
|
414
574
|
* @returns {Promise<{scanned: number, kept: number, archived: number,
|
|
415
|
-
* byReason: Record<string, number>, dryRun: boolean, archivePath: string
|
|
575
|
+
* byReason: Record<string, number>, dryRun: boolean, archivePath: string,
|
|
576
|
+
* malformed?: number}>} `malformed` as in {@link sweepExpiredLearnings}: the
|
|
577
|
+
* store's unparseable lines, kept verbatim — no caller generation can carry
|
|
578
|
+
* them, so they are never read as caller drops.
|
|
579
|
+
* @throws {StoreGenerationMismatchError} when `expectedGeneration` is stale
|
|
416
580
|
*/
|
|
417
581
|
export async function pruneLearnings(opts = {}) {
|
|
418
582
|
const { filePath, dryRun = true } = opts;
|
|
@@ -437,6 +601,7 @@ async function pruneLearningsUnlocked({
|
|
|
437
601
|
now,
|
|
438
602
|
dryRun = true,
|
|
439
603
|
dropReason = 'pruned',
|
|
604
|
+
expectedGeneration,
|
|
440
605
|
} = {}) {
|
|
441
606
|
if (typeof filePath !== 'string' || filePath.length === 0) {
|
|
442
607
|
throw new Error('pruneLearnings: filePath is required');
|
|
@@ -447,13 +612,33 @@ async function pruneLearningsUnlocked({
|
|
|
447
612
|
if (entries !== undefined && !Array.isArray(entries)) {
|
|
448
613
|
throw new Error('pruneLearnings: entries must be an array when provided');
|
|
449
614
|
}
|
|
615
|
+
if (
|
|
616
|
+
expectedGeneration !== undefined &&
|
|
617
|
+
(typeof expectedGeneration !== 'string' || expectedGeneration.length === 0)
|
|
618
|
+
) {
|
|
619
|
+
throw new Error('pruneLearnings: expectedGeneration must be a non-empty string when provided');
|
|
620
|
+
}
|
|
450
621
|
|
|
451
622
|
const nowMs = resolveNowMs(now);
|
|
452
|
-
|
|
623
|
+
// Token and records come from ONE read, so the comparison judges exactly the
|
|
624
|
+
// records loop (3) reconciles against.
|
|
625
|
+
const { entries: current, malformed, malformedLineNumbers, generation } = await readLearningsSnapshot(filePath);
|
|
626
|
+
if (expectedGeneration !== undefined && generation !== expectedGeneration) {
|
|
627
|
+
throw new StoreGenerationMismatchError(filePath, expectedGeneration, generation);
|
|
628
|
+
}
|
|
453
629
|
const next = entries ?? current;
|
|
454
630
|
|
|
455
631
|
if (next.length === 0 && current.length === 0) {
|
|
456
|
-
|
|
632
|
+
// Nothing to write — the malformed lines, if any, stay where they are.
|
|
633
|
+
return {
|
|
634
|
+
scanned: 0,
|
|
635
|
+
kept: 0,
|
|
636
|
+
archived: 0,
|
|
637
|
+
byReason: {},
|
|
638
|
+
dryRun,
|
|
639
|
+
archivePath,
|
|
640
|
+
...malformedField(filePath, malformed, malformedLineNumbers),
|
|
641
|
+
};
|
|
457
642
|
}
|
|
458
643
|
|
|
459
644
|
const resolveDrop = (entry) =>
|
|
@@ -537,14 +722,196 @@ async function pruneLearningsUnlocked({
|
|
|
537
722
|
dropped += 1;
|
|
538
723
|
}
|
|
539
724
|
|
|
540
|
-
const { kept, archived, byReason } = await archiveThenRewrite({
|
|
725
|
+
const { kept, archived, byReason, malformedField: malformedCount } = await archiveThenRewrite({
|
|
541
726
|
filePath,
|
|
542
727
|
archivePath,
|
|
543
728
|
keep,
|
|
544
729
|
archiveBatch,
|
|
730
|
+
malformed,
|
|
731
|
+
malformedLineNumbers,
|
|
545
732
|
nowMs,
|
|
546
733
|
dryRun,
|
|
547
734
|
});
|
|
548
735
|
|
|
549
|
-
return {
|
|
736
|
+
return {
|
|
737
|
+
scanned: next.length + dropped,
|
|
738
|
+
kept,
|
|
739
|
+
archived,
|
|
740
|
+
byReason,
|
|
741
|
+
dryRun,
|
|
742
|
+
archivePath,
|
|
743
|
+
...malformedCount,
|
|
744
|
+
};
|
|
745
|
+
}
|
|
746
|
+
|
|
747
|
+
/** Characters of a dropped line shown in the dry-run preview. */
|
|
748
|
+
const DROP_PREVIEW_CHARS = 80;
|
|
749
|
+
|
|
750
|
+
/** U+FEFF — built, not typed: a literal BOM in source fails validate-plugin's unicode-safety check. */
|
|
751
|
+
const BOM = String.fromCharCode(0xfeff);
|
|
752
|
+
|
|
753
|
+
/**
|
|
754
|
+
* The readable record an unparseable line may still carry (#1500 F1) — the
|
|
755
|
+
* line counts as malformed, yet dropping it would delete a learning:
|
|
756
|
+
* - `bom`: the line is a JSON object behind a UTF-8 BOM (an editor's save);
|
|
757
|
+
* - `embedded_record`: a `{` after the line's start (after the BOM, if any)
|
|
758
|
+
* begins a suffix that parses as a JSON object — the #1489 fusion shape, a
|
|
759
|
+
* torn record with the next complete record appended to it. The smallest
|
|
760
|
+
* such offset wins; `id` is the record's id, or null.
|
|
761
|
+
*
|
|
762
|
+
* Ceiling (BV-004): one JSON.parse of the suffix per `{`, so quadratic in the
|
|
763
|
+
* line length — a few ms for the KB-sized lines a store holds; revisit if an
|
|
764
|
+
* unparseable line ever passes ~1 MB.
|
|
765
|
+
*
|
|
766
|
+
* @param {string} text - one unparseable store line
|
|
767
|
+
* @returns {{bom: boolean, embedded_record: {offset: number, id: string|null}|null}}
|
|
768
|
+
*/
|
|
769
|
+
function inspectMalformedLine(text) {
|
|
770
|
+
const asObject = (s) => {
|
|
771
|
+
try {
|
|
772
|
+
const v = JSON.parse(s);
|
|
773
|
+
return v !== null && typeof v === 'object' && !Array.isArray(v) ? v : null;
|
|
774
|
+
} catch {
|
|
775
|
+
return null;
|
|
776
|
+
}
|
|
777
|
+
};
|
|
778
|
+
const hasBom = text.startsWith(BOM);
|
|
779
|
+
const bom = hasBom && asObject(text.slice(1)) !== null;
|
|
780
|
+
let embedded = null;
|
|
781
|
+
for (let i = text.indexOf('{', hasBom ? 2 : 1); i !== -1; i = text.indexOf('{', i + 1)) {
|
|
782
|
+
const record = asObject(text.slice(i));
|
|
783
|
+
if (record !== null) {
|
|
784
|
+
embedded = { offset: i, id: typeof record.id === 'string' ? record.id : null };
|
|
785
|
+
break;
|
|
786
|
+
}
|
|
787
|
+
}
|
|
788
|
+
return { bom, embedded_record: embedded };
|
|
789
|
+
}
|
|
790
|
+
|
|
791
|
+
/**
|
|
792
|
+
* Where {@link dropMalformedLines} snapshots the store before its rewrite:
|
|
793
|
+
* `<stem>.pre-drop-malformed.jsonl.bak-<ISO>` beside it. The name is chosen
|
|
794
|
+
* twice over: `.orchestrator/metrics/*.jsonl.bak-*` gitignores it (here; in a
|
|
795
|
+
* consumer repo once bootstrap's `#store-lock-ignore` snippet has run), and it
|
|
796
|
+
* does NOT start with `<store>.bak`, so the keep-3 rotation of
|
|
797
|
+
* `rewriteLearnings()` never prunes it — the line's text outlives any number
|
|
798
|
+
* of later closes.
|
|
799
|
+
*
|
|
800
|
+
* @param {string} filePath
|
|
801
|
+
* @param {number} nowMs
|
|
802
|
+
* @returns {string}
|
|
803
|
+
*/
|
|
804
|
+
function dropSnapshotPath(filePath, nowMs) {
|
|
805
|
+
const base = path.basename(filePath);
|
|
806
|
+
const stem = base.endsWith('.jsonl') ? base.slice(0, -'.jsonl'.length) : base;
|
|
807
|
+
const ts = new Date(nowMs).toISOString().replace(/[:.]/g, '-');
|
|
808
|
+
return path.join(path.dirname(filePath), `${stem}.pre-drop-malformed.jsonl.bak-${ts}`);
|
|
809
|
+
}
|
|
810
|
+
|
|
811
|
+
/**
|
|
812
|
+
* Remove unparseable lines from a learnings store — the ONE sanctioned way
|
|
813
|
+
* (#1500). Every rewrite keeps such lines verbatim (#1489) and the WARN forbids
|
|
814
|
+
* hand edits, so without this the only removal was a library call.
|
|
815
|
+
*
|
|
816
|
+
* `lines` are 1-based store lines as the WARN or a dry run of this function
|
|
817
|
+
* reported them. Line numbers move: every rewrite puts the unparseable lines at
|
|
818
|
+
* the END of the store, so after the next /close sweep a number can name a
|
|
819
|
+
* different unparseable line. Hence an apply needs `expectedGeneration` — the
|
|
820
|
+
* `generation` a dry run returned — and the store read, the generation check,
|
|
821
|
+
* the line check, the snapshot and the rewrite all run under ONE store lock;
|
|
822
|
+
* read outside it, a record a peer appended meanwhile is lost to the rewrite.
|
|
823
|
+
*
|
|
824
|
+
* The rewrite goes through `rewriteLearnings()` (validation, `.bak-<ISO>`,
|
|
825
|
+
* atomic rename) after a full copy of the store to {@link dropSnapshotPath}.
|
|
826
|
+
*
|
|
827
|
+
* An unparseable line can still carry a readable record — fused after a torn
|
|
828
|
+
* one, or behind a BOM ({@link inspectMalformedLine}). The dry run flags it;
|
|
829
|
+
* an apply refuses such a line unless `acceptEmbedded` is set (#1500 F1): an
|
|
830
|
+
* 80-character preview hid a record at offset 250, and the apply deleted it.
|
|
831
|
+
*
|
|
832
|
+
* @param {object} opts
|
|
833
|
+
* @param {string} opts.filePath - the learnings store
|
|
834
|
+
* @param {number[]} opts.lines - 1-based lines to drop; each must be unparseable in the store as read
|
|
835
|
+
* @param {string} [opts.expectedGeneration] - generation the line numbers were read from;
|
|
836
|
+
* REQUIRED to apply, checked on a dry run when given
|
|
837
|
+
* @param {boolean} [opts.acceptEmbedded=false] - apply even to a line that still holds a readable record
|
|
838
|
+
* @param {boolean} [opts.dryRun=true] - check and validate, write nothing
|
|
839
|
+
* @param {Date|number} [opts.now] - injectable clock (snapshot name)
|
|
840
|
+
* @returns {Promise<{generation: string, dropped: {line: number, chars: number, preview: string,
|
|
841
|
+
* bom: boolean, embedded_record: {offset: number, id: string|null}|null}[], records: number,
|
|
842
|
+
* remainingMalformed: number, recordBearing: number, dryRun: boolean, snapshot: string|null}>}
|
|
843
|
+
* `generation` is the store's as read; `chars` the line's full length, `preview` its first
|
|
844
|
+
* {@link DROP_PREVIEW_CHARS} characters with the home dir as `~`; `recordBearing` counts the
|
|
845
|
+
* dropped lines that held a readable record
|
|
846
|
+
* @throws {StoreGenerationMismatchError} the store is not at `expectedGeneration` — nothing written
|
|
847
|
+
* @throws {MalformedLineRefusedError} a line is not unparseable, or (apply without
|
|
848
|
+
* `acceptEmbedded`) still holds a readable record — nothing written
|
|
849
|
+
*/
|
|
850
|
+
export async function dropMalformedLines({
|
|
851
|
+
filePath,
|
|
852
|
+
lines,
|
|
853
|
+
expectedGeneration,
|
|
854
|
+
acceptEmbedded = false,
|
|
855
|
+
dryRun = true,
|
|
856
|
+
now,
|
|
857
|
+
} = {}) {
|
|
858
|
+
if (typeof filePath !== 'string' || filePath.length === 0) {
|
|
859
|
+
throw new Error('dropMalformedLines: filePath is required');
|
|
860
|
+
}
|
|
861
|
+
if (!Array.isArray(lines) || lines.length === 0 || !lines.every((n) => Number.isInteger(n) && n > 0)) {
|
|
862
|
+
throw new Error('dropMalformedLines: lines must be a non-empty array of positive integers');
|
|
863
|
+
}
|
|
864
|
+
if (!dryRun && (typeof expectedGeneration !== 'string' || expectedGeneration.length === 0)) {
|
|
865
|
+
throw new Error('dropMalformedLines: expectedGeneration is required to apply');
|
|
866
|
+
}
|
|
867
|
+
const run = () => dropMalformedLinesUnlocked({ filePath, lines, expectedGeneration, acceptEmbedded, dryRun, now });
|
|
868
|
+
// An absent store has no line to drop — the body refuses without a lock.
|
|
869
|
+
return dryRun || !existsSync(filePath) ? run() : withLearningsLock(filePath, run);
|
|
870
|
+
}
|
|
871
|
+
|
|
872
|
+
/**
|
|
873
|
+
* Body of {@link dropMalformedLines}; the caller holds the store lock or runs
|
|
874
|
+
* a pass that cannot write.
|
|
875
|
+
*/
|
|
876
|
+
async function dropMalformedLinesUnlocked({ filePath, lines, expectedGeneration, acceptEmbedded, dryRun, now }) {
|
|
877
|
+
const snap = await readLearningsSnapshot(filePath);
|
|
878
|
+
if (expectedGeneration !== undefined && snap.generation !== expectedGeneration) {
|
|
879
|
+
throw new StoreGenerationMismatchError(filePath, expectedGeneration, snap.generation);
|
|
880
|
+
}
|
|
881
|
+
const wanted = [...new Set(lines)].sort((a, b) => a - b);
|
|
882
|
+
const notMalformed = wanted.filter((n) => !snap.malformedLineNumbers.includes(n));
|
|
883
|
+
if (notMalformed.length > 0) {
|
|
884
|
+
throw new MalformedLineRefusedError(filePath, notMalformed, { unparseable: snap.malformedLineNumbers });
|
|
885
|
+
}
|
|
886
|
+
|
|
887
|
+
const drop = new Set(wanted);
|
|
888
|
+
const keepMalformed = snap.malformed.filter((_, i) => !drop.has(snap.malformedLineNumbers[i]));
|
|
889
|
+
const dropped = wanted.map((line) => {
|
|
890
|
+
const text = snap.malformed[snap.malformedLineNumbers.indexOf(line)];
|
|
891
|
+
const cut = text.length > DROP_PREVIEW_CHARS ? `${text.slice(0, DROP_PREVIEW_CHARS)}…` : text;
|
|
892
|
+
return { line, chars: text.length, preview: redactHomeDir(cut), ...inspectMalformedLine(text) };
|
|
893
|
+
});
|
|
894
|
+
const recordBearing = dropped.filter((d) => d.bom || d.embedded_record !== null);
|
|
895
|
+
if (!dryRun && !acceptEmbedded && recordBearing.length > 0) {
|
|
896
|
+
throw new MalformedLineRefusedError(filePath, recordBearing.map((d) => d.line), { reason: 'holds-a-record' });
|
|
897
|
+
}
|
|
898
|
+
// Validate the next generation before anything touches disk, dry run too:
|
|
899
|
+
// a record the tolerant gate rejects throws here, before the snapshot.
|
|
900
|
+
await rewriteLearnings(filePath, snap.entries, { dryRun: true, malformedLines: keepMalformed });
|
|
901
|
+
const result = {
|
|
902
|
+
generation: snap.generation,
|
|
903
|
+
dropped,
|
|
904
|
+
records: snap.entries.length,
|
|
905
|
+
remainingMalformed: keepMalformed.length,
|
|
906
|
+
recordBearing: recordBearing.length,
|
|
907
|
+
dryRun,
|
|
908
|
+
snapshot: null,
|
|
909
|
+
};
|
|
910
|
+
if (dryRun) return result;
|
|
911
|
+
|
|
912
|
+
const snapshot = dropSnapshotPath(filePath, resolveNowMs(now));
|
|
913
|
+
// COPYFILE_EXCL: never overwrite an earlier snapshot.
|
|
914
|
+
await copyFile(filePath, snapshot, fsConstants.COPYFILE_EXCL);
|
|
915
|
+
await rewriteLearnings(filePath, snap.entries, { malformedLines: keepMalformed });
|
|
916
|
+
return { ...result, snapshot };
|
|
550
917
|
}
|