session-orchestrator 5.4.0 → 5.6.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 +115 -3
- package/README.md +30 -15
- 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 +19 -15
- 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 +77 -171
- package/hooks/post-bash-write-verify.mjs +219 -24
- package/hooks/post-edit-validate.mjs +15 -5
- package/hooks/post-tool-batch-wave-signal.mjs +41 -156
- package/hooks/pre-bash-memory-propose-audit.mjs +28 -11
- package/hooks/pre-bash-staging-fence.mjs +29 -8
- package/hooks/pre-task-scope-disjoint.mjs +603 -241
- package/hooks/wave-scope-commit-guard.mjs +235 -73
- package/package.json +9 -8
- 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 +201 -27
- package/scripts/lib/session-close-backfill.mjs +202 -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 +60 -6
- 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/parallel-aware-auq.md +1 -1
- 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 +14 -6
- 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
|
@@ -22,8 +22,10 @@
|
|
|
22
22
|
*
|
|
23
23
|
* Usage:
|
|
24
24
|
* node scripts/sweep-expired-learnings.mjs [--prune] [--dry-run|--apply] [--json]
|
|
25
|
-
* [--grace-days N] [--entries PATH] [--file PATH] [--archive PATH]
|
|
25
|
+
* [--grace-days N] [--snapshot PATH|--entries PATH] [--file PATH] [--archive PATH]
|
|
26
26
|
* [--appended N] [--boosted M] [--duration-ms D] [--skipped a,b] [--repo-root PATH]
|
|
27
|
+
* node scripts/sweep-expired-learnings.mjs --drop-malformed --line N [--line M ...]
|
|
28
|
+
* [--file PATH] [--json] [--apply --generation TOKEN --repo-root PATH [--accept-embedded]]
|
|
27
29
|
*
|
|
28
30
|
* Flags:
|
|
29
31
|
* --prune Decision-driven prune+consolidate+rewrite instead of the
|
|
@@ -34,9 +36,26 @@
|
|
|
34
36
|
* (default: human-readable one-liner)
|
|
35
37
|
* --grace-days N Days past expiry before archiving (default: 14).
|
|
36
38
|
* SWEEP ONLY — `--prune` has no grace window by design.
|
|
39
|
+
* --snapshot PATH PRUNE ONLY (#1486). Write the store's current records to
|
|
40
|
+
* PATH, line 1 a `{"_store_generation": "<token>",
|
|
41
|
+
* "_store_path": "<store>"}` header naming the exact store
|
|
42
|
+
* state and the store they came from. This is the sidecar
|
|
43
|
+
* to edit into the next generation. Writes nothing else;
|
|
44
|
+
* takes no --entries/--apply/telemetry flag. A PATH that
|
|
45
|
+
* would replace the --file store or the --archive sidecar
|
|
46
|
+
* exits 1 untouched. A store holding a malformed line is
|
|
47
|
+
* snapshotted with a stderr WARN (line numbers) and a
|
|
48
|
+
* `malformed` count: the sidecar carries records only, and
|
|
49
|
+
* the prune applying it keeps the store's malformed lines
|
|
50
|
+
* verbatim (#1489).
|
|
37
51
|
* --entries PATH JSONL sidecar holding the caller's next store generation.
|
|
38
52
|
* PRUNE ONLY. Must exist, parse cleanly, and hold at least
|
|
39
53
|
* one record — absent/malformed/empty all exit 1 untouched.
|
|
54
|
+
* Line 1 must be the `_store_generation` header written by
|
|
55
|
+
* --snapshot, else exit 1 untouched; a sidecar snapshotted
|
|
56
|
+
* from another store than --file exits 1 untouched; a
|
|
57
|
+
* store that changed since that snapshot exits 3
|
|
58
|
+
* untouched (#1486).
|
|
40
59
|
* A NEW record (its `id` not in the store) must pass strict
|
|
41
60
|
* `validateLearning()`, else exit 1 untouched (GH#69);
|
|
42
61
|
* records already in the store keep the tolerant path.
|
|
@@ -55,7 +74,28 @@
|
|
|
55
74
|
* --skipped a,b PRUNE ONLY (#1206). Comma-separated list of optional
|
|
56
75
|
* steps that ran but were themselves skipped this run
|
|
57
76
|
* (HR-105 — e.g. `skill-evolution-off`). Default none.
|
|
58
|
-
* --
|
|
77
|
+
* --drop-malformed Remove unparseable store lines (#1500) — the one sanctioned
|
|
78
|
+
* repair; the store keeps such lines verbatim through every
|
|
79
|
+
* rewrite. Takes --line (required), --generation, --file,
|
|
80
|
+
* --json, --apply, --repo-root, --accept-embedded; nothing
|
|
81
|
+
* else. A dry run (default) checks each line and prints its
|
|
82
|
+
* length, preview, any readable record it still carries
|
|
83
|
+
* (bom / embedded_record) and the store generation. --apply re-reads the store under its
|
|
84
|
+
* lock, copies it to <stem>.pre-drop-malformed.jsonl.bak-<ISO>
|
|
85
|
+
* beside it, rewrites it without those lines, and records
|
|
86
|
+
* one orchestrator.learnings.malformed_dropped event.
|
|
87
|
+
* --line N DROP-MALFORMED ONLY. 1-based store line (repeatable). Must
|
|
88
|
+
* be an unparseable line in the store as read, else exit 1.
|
|
89
|
+
* --generation TOKEN DROP-MALFORMED ONLY; required with --apply. The
|
|
90
|
+
* generation the dry run printed: line numbers move with
|
|
91
|
+
* every rewrite, so a changed store exits 3.
|
|
92
|
+
* --accept-embedded DROP-MALFORMED ONLY. Apply even to a line that still
|
|
93
|
+
* holds a readable record (behind a BOM, or fused after a
|
|
94
|
+
* torn record) — without it such a line exits 1.
|
|
95
|
+
* --repo-root PATH With --drop-malformed --apply: REQUIRED, the repo the
|
|
96
|
+
* event is pinned to; --file must lie inside it (judged
|
|
97
|
+
* on realpaths).
|
|
98
|
+
* Otherwise PRUNE ONLY (#1206). Repo root the
|
|
59
99
|
* `orchestrator.evolve.completed` record is pinned to.
|
|
60
100
|
* No default and NO process.cwd() fallback (#1119) — when
|
|
61
101
|
* omitted, no event is emitted at all (stderr WARN, exit
|
|
@@ -64,48 +104,104 @@
|
|
|
64
104
|
*
|
|
65
105
|
* Exit codes:
|
|
66
106
|
* 0 Success (including no-op when nothing is archive-eligible)
|
|
67
|
-
* 1 Usage/input error (bad flag/value, flag used in the wrong mode,
|
|
107
|
+
* 1 Usage/input error (bad flag/value, flag used in the wrong mode, a
|
|
108
|
+
* --drop-malformed --line that is not an unparseable line or, without
|
|
109
|
+
* --accept-embedded, still holds a readable record, an
|
|
68
110
|
* absent/malformed/empty `--entries` sidecar, or a NEW `--entries` record
|
|
69
|
-
* that fails strict schema validation
|
|
111
|
+
* that fails strict schema validation, or an `--entries` sidecar without
|
|
112
|
+
* its `_store_generation` header or snapshotted from another store than
|
|
113
|
+
* `--file` — dry run and apply alike; at `--snapshot`, a PATH that would
|
|
114
|
+
* replace a ledger)
|
|
70
115
|
* 2 Sweep/prune error (I/O or validation failure inside the lib)
|
|
116
|
+
* 3 store-generation-mismatch (#1486, #1500): the store changed after the
|
|
117
|
+
* --drop-malformed dry run that printed --generation, or after the
|
|
118
|
+
* `--entries` sidecar was snapshotted — nothing written. Drop: re-run the
|
|
119
|
+
* dry run, re-check its previews, apply with ITS generation. Prune: re-run
|
|
120
|
+
* `--snapshot` into a FRESH path, re-apply the edits to the records in
|
|
121
|
+
* that file, apply it. Never copy the new header onto the old sidecar:
|
|
122
|
+
* its records lack exactly the peer change the token exists to protect.
|
|
71
123
|
*/
|
|
72
124
|
|
|
73
|
-
import { existsSync } from 'node:fs';
|
|
125
|
+
import { existsSync, realpathSync, statSync } from 'node:fs';
|
|
126
|
+
import { mkdir, readFile, rename, writeFile } from 'node:fs/promises';
|
|
74
127
|
import path from 'node:path';
|
|
75
128
|
import { isDeepStrictEqual } from 'node:util';
|
|
76
|
-
import {
|
|
77
|
-
|
|
129
|
+
import {
|
|
130
|
+
sweepExpiredLearnings,
|
|
131
|
+
pruneLearnings,
|
|
132
|
+
dropMalformedLines,
|
|
133
|
+
MalformedLineRefusedError,
|
|
134
|
+
StoreGenerationMismatchError,
|
|
135
|
+
warnUnparseableLines,
|
|
136
|
+
} from './lib/learnings/expiry-sweep.mjs';
|
|
137
|
+
import { readLearningsSnapshot, parseLearningsText } from './lib/learnings/io.mjs';
|
|
78
138
|
import { validateLearning } from './lib/learnings/schema.mjs';
|
|
139
|
+
import { validatePathInsideProject } from './lib/path-utils.mjs';
|
|
79
140
|
import { emitEvolveCompleted } from './lib/learnings/evolve-telemetry.mjs';
|
|
80
141
|
|
|
81
142
|
const DEFAULT_FILE = '.orchestrator/metrics/learnings.jsonl';
|
|
82
143
|
const DEFAULT_ARCHIVE = '.orchestrator/metrics/learnings-archive.jsonl';
|
|
83
144
|
const DEFAULT_GRACE_DAYS = 14;
|
|
145
|
+
/** Key of the `--entries` sidecar's first line, written by `--snapshot` (#1486). */
|
|
146
|
+
const GENERATION_KEY = '_store_generation';
|
|
147
|
+
/** Header key naming the store the snapshot was read from (resolved path). */
|
|
148
|
+
const STORE_PATH_KEY = '_store_path';
|
|
149
|
+
/** Ledger record of one applied `--drop-malformed` (#1500). */
|
|
150
|
+
const DROP_MALFORMED_EVENT = 'orchestrator.learnings.malformed_dropped';
|
|
84
151
|
|
|
85
152
|
function printHelp() {
|
|
86
153
|
process.stdout.write(
|
|
87
|
-
`Usage: node scripts/sweep-expired-learnings.mjs [--prune] [--dry-run|--apply] [--json] [--grace-days N] [--entries PATH] [--file PATH] [--archive PATH]
|
|
154
|
+
`Usage: node scripts/sweep-expired-learnings.mjs [--prune] [--dry-run|--apply] [--json] [--grace-days N] [--snapshot PATH|--entries PATH] [--file PATH] [--archive PATH]
|
|
155
|
+
node scripts/sweep-expired-learnings.mjs --drop-malformed --line N [--line M ...] [--file PATH] [--json] [--apply --generation TOKEN --repo-root PATH [--accept-embedded]]
|
|
88
156
|
|
|
89
157
|
Modes:
|
|
90
158
|
(default) Expiry sweep — archive entries expired past the grace window
|
|
91
159
|
--prune Prune + consolidate + rewrite (issue #1017): archives
|
|
92
160
|
expired / zero-confidence / superseded / caller-dropped
|
|
93
161
|
records instead of deleting them
|
|
162
|
+
--drop-malformed Remove unparseable store lines (#1500) — never hand-edit
|
|
163
|
+
the store. The dry run (default) checks each --line and
|
|
164
|
+
prints its preview and the store generation; --apply with
|
|
165
|
+
--generation <that token> --repo-root <root> snapshots the
|
|
166
|
+
store beside it, rewrites it without those lines and
|
|
167
|
+
records one ${DROP_MALFORMED_EVENT} event
|
|
94
168
|
|
|
95
169
|
Options:
|
|
96
170
|
--dry-run Preview counts; write nothing (default)
|
|
97
171
|
--apply Perform the archive append + store rewrite
|
|
98
172
|
--json Emit a single machine-parseable JSON summary line
|
|
99
173
|
--grace-days N Days past expiry before archiving (default: ${DEFAULT_GRACE_DAYS}); sweep only
|
|
174
|
+
--snapshot PATH Prune only. Write the store's records to PATH, headed by a
|
|
175
|
+
${GENERATION_KEY} line; edit that file into the next
|
|
176
|
+
generation and pass it as --entries. Writes nothing else;
|
|
177
|
+
a PATH that is the --file store or --archive sidecar
|
|
178
|
+
exits 1. Malformed store lines are WARNed and counted,
|
|
179
|
+
never put in the sidecar; the prune keeps them verbatim
|
|
100
180
|
--entries PATH JSONL sidecar with the next store generation; prune only.
|
|
101
|
-
Must exist, parse cleanly,
|
|
102
|
-
|
|
181
|
+
Must exist, parse cleanly, start with the --snapshot
|
|
182
|
+
header of the same --file store, and hold >= 1 record; a
|
|
183
|
+
NEW record (id not in the store) must pass strict schema
|
|
103
184
|
validation, else exit 1 with nothing written
|
|
104
185
|
--file PATH Learnings store (default: ${DEFAULT_FILE})
|
|
105
186
|
--archive PATH Archive sidecar (default: ${DEFAULT_ARCHIVE})
|
|
187
|
+
--line N Drop-malformed only. 1-based store line, repeatable; must be
|
|
188
|
+
an unparseable line of the store as read, else exit 1
|
|
189
|
+
--generation TOKEN Drop-malformed only, required with --apply: the dry
|
|
190
|
+
run's generation; a store changed since exits 3
|
|
191
|
+
--accept-embedded Drop-malformed only: apply even to a line that still holds
|
|
192
|
+
a readable record (bom / embedded_record in the dry run)
|
|
193
|
+
--repo-root PATH Drop-malformed --apply: required, --file must lie inside it
|
|
106
194
|
|
|
107
|
-
Exit codes: 0 success 1 usage/input error (incl.
|
|
195
|
+
Exit codes: 0 success 1 usage/input error (incl. a --line that is not unparseable,
|
|
196
|
+
an invalid new --entries record,
|
|
197
|
+
a sidecar from another --file store, a --snapshot PATH that
|
|
198
|
+
would replace the store or the archive)
|
|
108
199
|
2 sweep/prune error
|
|
200
|
+
3 store-generation-mismatch: the store changed after the --entries
|
|
201
|
+
snapshot or the drop-malformed dry run; nothing written —
|
|
202
|
+
drop: re-run the dry run and apply with its generation;
|
|
203
|
+
prune: re-snapshot into a fresh path, re-apply the edits
|
|
204
|
+
there, re-run with that path
|
|
109
205
|
`
|
|
110
206
|
);
|
|
111
207
|
}
|
|
@@ -116,6 +212,16 @@ function usageError(message) {
|
|
|
116
212
|
process.exit(1);
|
|
117
213
|
}
|
|
118
214
|
|
|
215
|
+
/**
|
|
216
|
+
* The value of a PATH flag, or a usage error (exit 1) when it is missing. An
|
|
217
|
+
* absent value used to surface later as a lib TypeError — exit 2, which the
|
|
218
|
+
* exit-code contract reserves for a failure inside the sweep/prune itself.
|
|
219
|
+
*/
|
|
220
|
+
function pathValue(flag, raw) {
|
|
221
|
+
if (typeof raw !== 'string' || raw.length === 0) usageError(`${flag} requires a PATH`);
|
|
222
|
+
return raw;
|
|
223
|
+
}
|
|
224
|
+
|
|
119
225
|
function parseArgs(argv) {
|
|
120
226
|
const args = {
|
|
121
227
|
prune: false,
|
|
@@ -124,6 +230,7 @@ function parseArgs(argv) {
|
|
|
124
230
|
graceDays: DEFAULT_GRACE_DAYS,
|
|
125
231
|
graceDaysExplicit: false,
|
|
126
232
|
entries: null,
|
|
233
|
+
snapshot: null,
|
|
127
234
|
file: DEFAULT_FILE,
|
|
128
235
|
archive: DEFAULT_ARCHIVE,
|
|
129
236
|
appended: 0,
|
|
@@ -140,11 +247,34 @@ function parseArgs(argv) {
|
|
|
140
247
|
// fail-closed contract as `emitReconcileCompleted` / `express-path.mjs`.
|
|
141
248
|
repoRoot: null,
|
|
142
249
|
telemetryExplicit: false,
|
|
250
|
+
// The four /evolve counters alone — `--repo-root` is telemetry too, but it
|
|
251
|
+
// is also the event destination of `--drop-malformed`.
|
|
252
|
+
countersExplicit: false,
|
|
253
|
+
archiveExplicit: false,
|
|
254
|
+
dropMalformed: false,
|
|
255
|
+
lines: [],
|
|
256
|
+
generation: null,
|
|
257
|
+
acceptEmbedded: false,
|
|
143
258
|
};
|
|
144
259
|
for (let i = 0; i < argv.length; i++) {
|
|
145
260
|
const a = argv[i];
|
|
146
261
|
if (a === '--prune') {
|
|
147
262
|
args.prune = true;
|
|
263
|
+
} else if (a === '--drop-malformed') {
|
|
264
|
+
args.dropMalformed = true;
|
|
265
|
+
} else if (a === '--line') {
|
|
266
|
+
const raw = argv[++i];
|
|
267
|
+
const v = Number(raw);
|
|
268
|
+
if (!Number.isInteger(v) || v < 1) {
|
|
269
|
+
usageError(`--line requires a positive integer (a 1-based store line), got: ${raw}`);
|
|
270
|
+
}
|
|
271
|
+
args.lines.push(v);
|
|
272
|
+
} else if (a === '--generation') {
|
|
273
|
+
const raw = argv[++i];
|
|
274
|
+
if (typeof raw !== 'string' || raw.length === 0) usageError('--generation requires a TOKEN');
|
|
275
|
+
args.generation = raw;
|
|
276
|
+
} else if (a === '--accept-embedded') {
|
|
277
|
+
args.acceptEmbedded = true;
|
|
148
278
|
} else if (a === '--apply') {
|
|
149
279
|
args.dryRun = false;
|
|
150
280
|
} else if (a === '--dry-run') {
|
|
@@ -160,11 +290,14 @@ function parseArgs(argv) {
|
|
|
160
290
|
args.graceDays = v;
|
|
161
291
|
args.graceDaysExplicit = true;
|
|
162
292
|
} else if (a === '--entries') {
|
|
163
|
-
args.entries = argv[++i];
|
|
293
|
+
args.entries = pathValue(a, argv[++i]);
|
|
294
|
+
} else if (a === '--snapshot') {
|
|
295
|
+
args.snapshot = pathValue(a, argv[++i]);
|
|
164
296
|
} else if (a === '--file') {
|
|
165
|
-
args.file = argv[++i];
|
|
297
|
+
args.file = pathValue(a, argv[++i]);
|
|
166
298
|
} else if (a === '--archive') {
|
|
167
|
-
args.archive = argv[++i];
|
|
299
|
+
args.archive = pathValue(a, argv[++i]);
|
|
300
|
+
args.archiveExplicit = true;
|
|
168
301
|
} else if (a === '--appended') {
|
|
169
302
|
const raw = argv[++i];
|
|
170
303
|
const v = Number(raw);
|
|
@@ -173,6 +306,7 @@ function parseArgs(argv) {
|
|
|
173
306
|
}
|
|
174
307
|
args.appended = v;
|
|
175
308
|
args.telemetryExplicit = true;
|
|
309
|
+
args.countersExplicit = true;
|
|
176
310
|
} else if (a === '--boosted') {
|
|
177
311
|
const raw = argv[++i];
|
|
178
312
|
const v = Number(raw);
|
|
@@ -181,6 +315,7 @@ function parseArgs(argv) {
|
|
|
181
315
|
}
|
|
182
316
|
args.boosted = v;
|
|
183
317
|
args.telemetryExplicit = true;
|
|
318
|
+
args.countersExplicit = true;
|
|
184
319
|
} else if (a === '--duration-ms') {
|
|
185
320
|
const raw = argv[++i];
|
|
186
321
|
const v = Number(raw);
|
|
@@ -189,13 +324,15 @@ function parseArgs(argv) {
|
|
|
189
324
|
}
|
|
190
325
|
args.durationMs = v;
|
|
191
326
|
args.telemetryExplicit = true;
|
|
327
|
+
args.countersExplicit = true;
|
|
192
328
|
} else if (a === '--skipped') {
|
|
193
329
|
const raw = argv[++i];
|
|
194
330
|
args.skipped = typeof raw === 'string' ? raw.split(',').filter((s) => s.length > 0) : [];
|
|
195
331
|
args.telemetryExplicit = true;
|
|
332
|
+
args.countersExplicit = true;
|
|
196
333
|
} else if (a === '--repo-root') {
|
|
197
334
|
// Absolute on purpose: emitEvent refuses a relative root (#1468).
|
|
198
|
-
args.repoRoot = path.resolve(argv[++i]);
|
|
335
|
+
args.repoRoot = path.resolve(pathValue(a, argv[++i]));
|
|
199
336
|
args.telemetryExplicit = true;
|
|
200
337
|
} else if (a === '--help' || a === '-h') {
|
|
201
338
|
printHelp();
|
|
@@ -209,12 +346,61 @@ function parseArgs(argv) {
|
|
|
209
346
|
// that the prune path ignores would read as "the grace window applied" in a
|
|
210
347
|
// transcript, and an `--entries` the sweep ignores would read as "my next
|
|
211
348
|
// generation was written".
|
|
349
|
+
if (args.dropMalformed) {
|
|
350
|
+
// Its own mode: nothing of the sweep or the prune applies, and an
|
|
351
|
+
// `--archive` would read as "the dropped line was archived" — it never is.
|
|
352
|
+
if (
|
|
353
|
+
args.prune ||
|
|
354
|
+
args.graceDaysExplicit ||
|
|
355
|
+
args.archiveExplicit ||
|
|
356
|
+
args.countersExplicit ||
|
|
357
|
+
args.entries !== null ||
|
|
358
|
+
args.snapshot !== null
|
|
359
|
+
) {
|
|
360
|
+
usageError(
|
|
361
|
+
'--drop-malformed takes no --prune, --grace-days, --archive, --entries, --snapshot or ' +
|
|
362
|
+
'/evolve counter flag (a dropped line is never archived)'
|
|
363
|
+
);
|
|
364
|
+
}
|
|
365
|
+
if (args.lines.length === 0) usageError('--drop-malformed requires at least one --line N');
|
|
366
|
+
if (!args.dryRun) {
|
|
367
|
+
if (args.generation === null) {
|
|
368
|
+
usageError(
|
|
369
|
+
'--drop-malformed --apply requires --generation TOKEN — the generation its dry run printed ' +
|
|
370
|
+
'(line numbers move with every rewrite); nothing written'
|
|
371
|
+
);
|
|
372
|
+
}
|
|
373
|
+
if (args.repoRoot === null) {
|
|
374
|
+
usageError(
|
|
375
|
+
'--drop-malformed --apply requires --repo-root PATH — the event recording the drop is ' +
|
|
376
|
+
'pinned there (#1119: no cwd fallback); nothing written'
|
|
377
|
+
);
|
|
378
|
+
}
|
|
379
|
+
// Canonical, not lexical (#1500 F4): a root spelled through a symlink
|
|
380
|
+
// (macOS /var → /private/var) refused a store inside it. The file goes
|
|
381
|
+
// through resolveLedger (realpath when it exists), because
|
|
382
|
+
// canonicalizeRoot realpaths only the root.
|
|
383
|
+
if (!validatePathInsideProject(resolveLedger(args.file), args.repoRoot, { canonicalizeRoot: true }).ok) {
|
|
384
|
+
usageError(`--file ${args.file} lies outside --repo-root ${args.repoRoot} — nothing written`);
|
|
385
|
+
}
|
|
386
|
+
}
|
|
387
|
+
return args;
|
|
388
|
+
}
|
|
389
|
+
if (args.lines.length > 0 || args.generation !== null || args.acceptEmbedded) {
|
|
390
|
+
usageError('--line/--generation/--accept-embedded are only valid with --drop-malformed');
|
|
391
|
+
}
|
|
212
392
|
if (args.prune && args.graceDaysExplicit) {
|
|
213
393
|
usageError('--grace-days is not valid with --prune (the prune path has no grace window)');
|
|
214
394
|
}
|
|
215
395
|
if (!args.prune && args.entries !== null) {
|
|
216
396
|
usageError('--entries is only valid with --prune');
|
|
217
397
|
}
|
|
398
|
+
if (!args.prune && args.snapshot !== null) {
|
|
399
|
+
usageError('--snapshot is only valid with --prune');
|
|
400
|
+
}
|
|
401
|
+
if (args.snapshot !== null && (args.entries !== null || !args.dryRun || args.telemetryExplicit)) {
|
|
402
|
+
usageError('--snapshot writes only the sidecar — it takes no --entries, --apply, or telemetry flag');
|
|
403
|
+
}
|
|
218
404
|
if (!args.prune && args.telemetryExplicit) {
|
|
219
405
|
usageError(
|
|
220
406
|
'--appended/--boosted/--duration-ms/--skipped/--repo-root are only valid with --prune ' +
|
|
@@ -288,8 +474,28 @@ async function runSweep(args) {
|
|
|
288
474
|
* explicit `entries: []` written in CODE is a statement, and the lib keeps it
|
|
289
475
|
* expressible. Only the FILE is ambiguous, so only the file is guarded.
|
|
290
476
|
*
|
|
477
|
+
* A fourth guard (#1486): line 1 must be the `_store_generation` header that
|
|
478
|
+
* `--snapshot` wrote. The sidecar is derived minutes before the prune, in
|
|
479
|
+
* another process, so a record a peer appends in between is absent from it and
|
|
480
|
+
* would be archived `pruned`; the header lets `pruneLearnings()` refuse under
|
|
481
|
+
* the store lock instead. A headerless sidecar is refused, not trusted: a check
|
|
482
|
+
* that runs only when the token is present is bypassed by every producer that
|
|
483
|
+
* forgets it. The producers are prose, so every one must say `--snapshot`:
|
|
484
|
+
* `skills/evolve/references/evolve-analyze-mode.md` § 3.5, `skills/evolve/SKILL.md`
|
|
485
|
+
* § 4.4, and `.cursor/rules/060-evolve.mdc` (analyze Step 5 + review Step 3),
|
|
486
|
+
* which `scripts/cursor-install.mjs` installs into consumer repos. Census
|
|
487
|
+
* 2026-10-02: `rg --hidden -e --entries` over the tracked tree; plain `rg`
|
|
488
|
+
* skips `.cursor/`, which is how the first census missed the third producer.
|
|
489
|
+
*
|
|
490
|
+
* The header also names the store it was snapshotted from (`_store_path`);
|
|
491
|
+
* `runPrune()` refuses a sidecar applied to another `--file`. A header without
|
|
492
|
+
* that key is the earlier format of this same unreleased flag and is accepted
|
|
493
|
+
* on its token alone.
|
|
494
|
+
*
|
|
291
495
|
* @param {string} entriesPath
|
|
292
|
-
* @returns {Promise<object[]
|
|
496
|
+
* @returns {Promise<{entries: object[], generation: string, storePath: string|null}>}
|
|
497
|
+
* the validated, non-empty next generation, the store generation it was
|
|
498
|
+
* derived from, and the store it was snapshotted from (null: not recorded)
|
|
293
499
|
*/
|
|
294
500
|
async function loadEntriesSidecar(entriesPath) {
|
|
295
501
|
if (!existsSync(entriesPath)) {
|
|
@@ -298,12 +504,15 @@ async function loadEntriesSidecar(entriesPath) {
|
|
|
298
504
|
`next generation would archive the whole store)`
|
|
299
505
|
);
|
|
300
506
|
}
|
|
301
|
-
let
|
|
507
|
+
let raw;
|
|
302
508
|
try {
|
|
303
|
-
|
|
509
|
+
raw = await readFile(entriesPath, 'utf8');
|
|
304
510
|
} catch (err) {
|
|
305
511
|
usageError(`--entries sidecar unreadable: ${entriesPath}: ${err.message}`);
|
|
306
512
|
}
|
|
513
|
+
const firstLine = raw.split('\n').find((l) => l.length > 0) ?? '';
|
|
514
|
+
const header = parseHeader(firstLine);
|
|
515
|
+
const read = parseLearningsText(header === null ? raw : raw.slice(raw.indexOf(firstLine) + firstLine.length));
|
|
307
516
|
if (read.malformed.length > 0) {
|
|
308
517
|
usageError(
|
|
309
518
|
`refusing to prune — ${read.malformed.length} malformed line(s) in ${entriesPath}`
|
|
@@ -316,7 +525,172 @@ async function loadEntriesSidecar(entriesPath) {
|
|
|
316
525
|
`pure prune+consolidate pass)`
|
|
317
526
|
);
|
|
318
527
|
}
|
|
319
|
-
|
|
528
|
+
if (header === null) {
|
|
529
|
+
// The recovery names a FRESH path: snapshotting onto the --entries path
|
|
530
|
+
// would overwrite the decisions this run assembled there.
|
|
531
|
+
usageError(
|
|
532
|
+
`--entries sidecar has no "${GENERATION_KEY}" header line: ${entriesPath} (refusing ` +
|
|
533
|
+
`to prune — without it a record appended after the sidecar was derived would be ` +
|
|
534
|
+
`archived as pruned; run --prune --snapshot into a FRESH path, re-apply this run's ` +
|
|
535
|
+
`edits to the records in that file keeping its line 1, and pass it as --entries)`
|
|
536
|
+
);
|
|
537
|
+
}
|
|
538
|
+
return { entries: read.entries, generation: header.generation, storePath: header.storePath };
|
|
539
|
+
}
|
|
540
|
+
|
|
541
|
+
/**
|
|
542
|
+
* The header a sidecar's first line carries, or `null` when that line is not
|
|
543
|
+
* a `{"_store_generation": "<non-empty string>"}` header. `_store_path` may be
|
|
544
|
+
* absent (earlier format, `storePath: null`); present, it must be a non-empty
|
|
545
|
+
* string, else the whole header is refused — a mangled line 1 is not trusted.
|
|
546
|
+
*
|
|
547
|
+
* @param {string} line
|
|
548
|
+
* @returns {{generation: string, storePath: string|null}|null}
|
|
549
|
+
*/
|
|
550
|
+
function parseHeader(line) {
|
|
551
|
+
let parsed;
|
|
552
|
+
try {
|
|
553
|
+
parsed = JSON.parse(line);
|
|
554
|
+
} catch {
|
|
555
|
+
return null;
|
|
556
|
+
}
|
|
557
|
+
if (parsed === null || typeof parsed !== 'object') return null;
|
|
558
|
+
const isToken = (v) => typeof v === 'string' && v.length > 0;
|
|
559
|
+
const generation = parsed[GENERATION_KEY];
|
|
560
|
+
const storePath = parsed[STORE_PATH_KEY];
|
|
561
|
+
if (!isToken(generation) || (storePath !== undefined && !isToken(storePath))) return null;
|
|
562
|
+
return { generation, storePath: storePath ?? null };
|
|
563
|
+
}
|
|
564
|
+
|
|
565
|
+
/**
|
|
566
|
+
* A comparable key for the directory entry a write-then-rename onto `p`
|
|
567
|
+
* replaces: the nearest EXISTING ancestor directory by `dev`+`ino` (so a
|
|
568
|
+
* symlinked directory, a firmlink, or a differently-cased spelling of it is
|
|
569
|
+
* the same key), then the rest of the path case-folded. Case-folding the rest
|
|
570
|
+
* is what a case-insensitive volume (default APFS, NTFS) does to the name;
|
|
571
|
+
* on a case-sensitive volume it can only over-match — two names differing in
|
|
572
|
+
* case alone get refused, which costs a re-run with another name. Deliberate
|
|
573
|
+
* ceiling: the fold is `NFC` + `toLowerCase()`, so a name differing only by a
|
|
574
|
+
* fold outside that (`ß`/`SS`) is not caught — revisit if a ledger name ever
|
|
575
|
+
* leaves ASCII.
|
|
576
|
+
*
|
|
577
|
+
* @param {string} p
|
|
578
|
+
* @returns {string}
|
|
579
|
+
*/
|
|
580
|
+
function renameTargetKey(p) {
|
|
581
|
+
const abs = path.resolve(p);
|
|
582
|
+
const rest = [path.basename(abs)];
|
|
583
|
+
for (let dir = path.dirname(abs); ; ) {
|
|
584
|
+
try {
|
|
585
|
+
const { dev, ino } = statSync(dir, { bigint: true });
|
|
586
|
+
return `${dev}:${ino}/${rest.join('/').normalize('NFC').toLowerCase()}`;
|
|
587
|
+
} catch {
|
|
588
|
+
const parent = path.dirname(dir);
|
|
589
|
+
if (parent === dir) return abs.normalize('NFC').toLowerCase(); // not even the root stats
|
|
590
|
+
rest.unshift(path.basename(dir));
|
|
591
|
+
dir = parent;
|
|
592
|
+
}
|
|
593
|
+
}
|
|
594
|
+
}
|
|
595
|
+
|
|
596
|
+
/**
|
|
597
|
+
* A ledger path resolved to the file it names: through symlinks when it
|
|
598
|
+
* exists, else absolute as given.
|
|
599
|
+
*
|
|
600
|
+
* @param {string} p
|
|
601
|
+
* @returns {string}
|
|
602
|
+
*/
|
|
603
|
+
function resolveLedger(p) {
|
|
604
|
+
try {
|
|
605
|
+
return realpathSync(p);
|
|
606
|
+
} catch {
|
|
607
|
+
return path.resolve(p);
|
|
608
|
+
}
|
|
609
|
+
}
|
|
610
|
+
|
|
611
|
+
/**
|
|
612
|
+
* Exit 1 when `--snapshot` would replace the store or the archive. The rename
|
|
613
|
+
* lands the header as the store's line 1 with no `.bak` (and drops malformed
|
|
614
|
+
* lines), or replaces the append-only archive with the store's records —
|
|
615
|
+
* measured on 91b35d4b, both exit 0. The target is compared by
|
|
616
|
+
* {@link renameTargetKey} against the ledger's own entry and the file it
|
|
617
|
+
* resolves to. A path-string compare (44fc80e4) closed the symlinked-directory
|
|
618
|
+
* spelling but not letter case: on this host's case-insensitive APFS,
|
|
619
|
+
* `--snapshot <metrics>/Learnings.jsonl`, `METRICS/learnings.jsonl` and
|
|
620
|
+
* `LEARNINGS-ARCHIVE.jsonl` each replaced a ledger, exit 0 (measured
|
|
621
|
+
* 2026-10-02). Hard links need no check: rename replaces the directory entry,
|
|
622
|
+
* never the inode behind it.
|
|
623
|
+
*
|
|
624
|
+
* @param {ReturnType<typeof parseArgs>} args
|
|
625
|
+
*/
|
|
626
|
+
function refuseLedgerSnapshotTarget(args) {
|
|
627
|
+
const target = renameTargetKey(args.snapshot);
|
|
628
|
+
for (const [label, ledger] of [['store', args.file], ['archive', args.archive]]) {
|
|
629
|
+
if ([renameTargetKey(ledger), renameTargetKey(resolveLedger(ledger))].includes(target)) {
|
|
630
|
+
usageError(
|
|
631
|
+
`--snapshot ${args.snapshot} would replace the learnings ${label} ${ledger} (refusing — ` +
|
|
632
|
+
`nothing written; snapshot into a sidecar such as .orchestrator/tmp/learnings-next.jsonl)`
|
|
633
|
+
);
|
|
634
|
+
}
|
|
635
|
+
}
|
|
636
|
+
}
|
|
637
|
+
|
|
638
|
+
/**
|
|
639
|
+
* `--prune --snapshot PATH` (#1486): write the store's current records to PATH
|
|
640
|
+
* behind a `_store_generation` header, token and records from ONE read. This
|
|
641
|
+
* is the sidecar `/evolve` edits into its next generation; `--entries` later
|
|
642
|
+
* refuses it if the store moved in between. Write-then-rename, so a crash
|
|
643
|
+
* never leaves a header over a truncated record list.
|
|
644
|
+
*
|
|
645
|
+
* A store holding a malformed line is snapshotted with a WARN (its line
|
|
646
|
+
* numbers) and a `malformed` count in the summary. The sidecar holds records
|
|
647
|
+
* only — a malformed line is no record — and the prune applying it keeps the
|
|
648
|
+
* store's malformed lines verbatim at the end of the rewrite, never archived
|
|
649
|
+
* (`pruneLearnings()` reads them under the lock, #1489). Refusing here, as
|
|
650
|
+
* before 26746202 made the rewrite keep them, would block every `/evolve`
|
|
651
|
+
* write on that store with no sanctioned repair path.
|
|
652
|
+
*
|
|
653
|
+
* @param {ReturnType<typeof parseArgs>} args
|
|
654
|
+
*/
|
|
655
|
+
async function runSnapshot(args) {
|
|
656
|
+
refuseLedgerSnapshotTarget(args);
|
|
657
|
+
let snap;
|
|
658
|
+
try {
|
|
659
|
+
snap = await readLearningsSnapshot(args.file);
|
|
660
|
+
} catch (err) {
|
|
661
|
+
process.stderr.write(`sweep-expired-learnings: snapshot failed: ${err.message}\n`);
|
|
662
|
+
process.exit(2);
|
|
663
|
+
}
|
|
664
|
+
warnUnparseableLines(args.file, snap.malformed, snap.malformedLineNumbers);
|
|
665
|
+
try {
|
|
666
|
+
const lines = [
|
|
667
|
+
JSON.stringify({ [GENERATION_KEY]: snap.generation, [STORE_PATH_KEY]: resolveLedger(args.file) }),
|
|
668
|
+
...snap.entries.map((e) => JSON.stringify(e)),
|
|
669
|
+
];
|
|
670
|
+
await mkdir(path.dirname(args.snapshot), { recursive: true });
|
|
671
|
+
const tmp = `${args.snapshot}.tmp-${process.pid}`;
|
|
672
|
+
await writeFile(tmp, lines.join('\n') + '\n', 'utf8');
|
|
673
|
+
await rename(tmp, args.snapshot);
|
|
674
|
+
} catch (err) {
|
|
675
|
+
process.stderr.write(`sweep-expired-learnings: snapshot failed: ${err.message}\n`);
|
|
676
|
+
process.exit(2);
|
|
677
|
+
}
|
|
678
|
+
const summary = {
|
|
679
|
+
file: args.file,
|
|
680
|
+
snapshot: args.snapshot,
|
|
681
|
+
generation: snap.generation,
|
|
682
|
+
records: snap.entries.length,
|
|
683
|
+
// Present only when non-zero — the convention of the sweep/prune results.
|
|
684
|
+
...(snap.malformed.length > 0 ? { malformed: snap.malformed.length } : {}),
|
|
685
|
+
};
|
|
686
|
+
if (args.json) {
|
|
687
|
+
process.stdout.write(JSON.stringify(summary) + '\n');
|
|
688
|
+
} else {
|
|
689
|
+
process.stdout.write(
|
|
690
|
+
`sweep-expired-learnings: snapshot records=${summary.records} ` +
|
|
691
|
+
`generation=${summary.generation} -> ${summary.snapshot}\n`
|
|
692
|
+
);
|
|
693
|
+
}
|
|
320
694
|
}
|
|
321
695
|
|
|
322
696
|
/**
|
|
@@ -332,20 +706,29 @@ async function loadEntriesSidecar(entriesPath) {
|
|
|
332
706
|
* id-less subset, fine while such records stay rare; revisit if a store holds
|
|
333
707
|
* hundreds of them). Runs before any write, in dry run and apply alike.
|
|
334
708
|
*
|
|
709
|
+
* A failure is judged against the store's generation first: a peer that
|
|
710
|
+
* dropped a snapshotted legacy record turns it "new", and strict validation
|
|
711
|
+
* then reported exit 1 ("re-write the sidecar") for what is a stale snapshot —
|
|
712
|
+
* exit 3 (measured on 91b35d4b). The generation decides ONLY on a failure, so
|
|
713
|
+
* a stale sidecar that validates still reaches the authoritative re-read under
|
|
714
|
+
* the lock in `pruneLearnings()`; an unconditional comparison here pre-empted
|
|
715
|
+
* that check and left it untested (disabled, the suite stayed 32/32 green).
|
|
716
|
+
*
|
|
335
717
|
* @param {object[]} entries - the parsed `--entries` generation
|
|
336
|
-
* @param {string}
|
|
718
|
+
* @param {string} expectedGeneration - the sidecar's `_store_generation`
|
|
719
|
+
* @param {ReturnType<typeof parseArgs>} args
|
|
337
720
|
*/
|
|
338
|
-
async function rejectInvalidNewRecords(entries,
|
|
721
|
+
async function rejectInvalidNewRecords(entries, expectedGeneration, args) {
|
|
339
722
|
let store;
|
|
340
723
|
try {
|
|
341
|
-
|
|
724
|
+
store = await readLearningsSnapshot(args.file);
|
|
342
725
|
} catch (err) {
|
|
343
|
-
process.stderr.write(`sweep-expired-learnings: prune failed: cannot read ${
|
|
726
|
+
process.stderr.write(`sweep-expired-learnings: prune failed: cannot read ${args.file}: ${err.message}\n`);
|
|
344
727
|
process.exit(2);
|
|
345
728
|
}
|
|
346
729
|
const idOf = (e) => (typeof e?.id === 'string' && e.id.length > 0 ? e.id : null);
|
|
347
|
-
const storeIds = new Set(store.map(idOf).filter((id) => id !== null));
|
|
348
|
-
const storeIdless = store.filter((e) => idOf(e) === null);
|
|
730
|
+
const storeIds = new Set(store.entries.map(idOf).filter((id) => id !== null));
|
|
731
|
+
const storeIdless = store.entries.filter((e) => idOf(e) === null);
|
|
349
732
|
for (const entry of entries) {
|
|
350
733
|
const id = idOf(entry);
|
|
351
734
|
const known = id !== null ? storeIds.has(id) : storeIdless.some((s) => isDeepStrictEqual(s, entry));
|
|
@@ -353,29 +736,69 @@ async function rejectInvalidNewRecords(entries, filePath) {
|
|
|
353
736
|
try {
|
|
354
737
|
validateLearning(entry);
|
|
355
738
|
} catch (err) {
|
|
739
|
+
if (store.generation !== expectedGeneration) exitGenerationMismatch(args);
|
|
356
740
|
usageError(`--entries: new record ${id ?? '(no id)'} is invalid: ${err.message} — nothing written`);
|
|
357
741
|
}
|
|
358
742
|
}
|
|
359
743
|
}
|
|
360
744
|
|
|
745
|
+
/**
|
|
746
|
+
* Exit 3 for a stale `--entries` sidecar (#1486). The recovery names a FRESH
|
|
747
|
+
* snapshot path and prints no generation token: the current token copied onto
|
|
748
|
+
* this sidecar's line 1 would carry its stale records past the guard.
|
|
749
|
+
*
|
|
750
|
+
* @param {ReturnType<typeof parseArgs>} args
|
|
751
|
+
*/
|
|
752
|
+
function exitGenerationMismatch(args) {
|
|
753
|
+
process.stderr.write(
|
|
754
|
+
`sweep-expired-learnings: store-generation-mismatch: ${args.file} changed after ${args.entries} ` +
|
|
755
|
+
`was snapshotted — nothing written. Run --prune --snapshot into a FRESH path, re-apply this ` +
|
|
756
|
+
`run's edits to the records in that file, and pass it as --entries; never copy a newer ` +
|
|
757
|
+
`${GENERATION_KEY} header onto ${args.entries}\n`
|
|
758
|
+
);
|
|
759
|
+
process.exit(3);
|
|
760
|
+
}
|
|
761
|
+
|
|
361
762
|
/**
|
|
362
763
|
* Decision-driven prune + consolidate + rewrite (issue #1017).
|
|
363
764
|
*
|
|
364
765
|
* @param {ReturnType<typeof parseArgs>} args
|
|
365
766
|
*/
|
|
366
767
|
async function runPrune(args) {
|
|
367
|
-
|
|
368
|
-
|
|
768
|
+
if (args.snapshot !== null) {
|
|
769
|
+
await runSnapshot(args);
|
|
770
|
+
return;
|
|
771
|
+
}
|
|
772
|
+
const sidecar = args.entries === null ? undefined : await loadEntriesSidecar(args.entries);
|
|
773
|
+
if (sidecar !== undefined) {
|
|
774
|
+
// Before any generation comparison: a sidecar applied to another store can
|
|
775
|
+
// never match it, so exit 3 sent the documented retry — snapshot without
|
|
776
|
+
// that --file, apply with it — round the loop forever (measured
|
|
777
|
+
// 2026-10-02), its message naming the wrong file.
|
|
778
|
+
if (
|
|
779
|
+
sidecar.storePath !== null &&
|
|
780
|
+
renameTargetKey(resolveLedger(sidecar.storePath)) !== renameTargetKey(resolveLedger(args.file))
|
|
781
|
+
) {
|
|
782
|
+
usageError(
|
|
783
|
+
`--entries ${args.entries} was snapshotted from ${sidecar.storePath}, not from the --file ` +
|
|
784
|
+
`store ${args.file} (refusing — nothing written; pass the same --file/--archive to ` +
|
|
785
|
+
`--snapshot and to this call)`
|
|
786
|
+
);
|
|
787
|
+
}
|
|
788
|
+
await rejectInvalidNewRecords(sidecar.entries, sidecar.generation, args);
|
|
789
|
+
}
|
|
369
790
|
|
|
370
791
|
let result;
|
|
371
792
|
try {
|
|
372
793
|
result = await pruneLearnings({
|
|
373
794
|
filePath: args.file,
|
|
374
795
|
archivePath: args.archive,
|
|
375
|
-
entries,
|
|
796
|
+
entries: sidecar?.entries,
|
|
797
|
+
expectedGeneration: sidecar?.generation,
|
|
376
798
|
dryRun: args.dryRun,
|
|
377
799
|
});
|
|
378
800
|
} catch (err) {
|
|
801
|
+
if (err instanceof StoreGenerationMismatchError) exitGenerationMismatch(args);
|
|
379
802
|
process.stderr.write(`sweep-expired-learnings: prune failed: ${err.message}\n`);
|
|
380
803
|
process.exit(2);
|
|
381
804
|
}
|
|
@@ -421,8 +844,117 @@ async function runPrune(args) {
|
|
|
421
844
|
}
|
|
422
845
|
}
|
|
423
846
|
|
|
847
|
+
/**
|
|
848
|
+
* Record one applied `--drop-malformed` in the repo's ledger. Best-effort: the
|
|
849
|
+
* rewrite already happened and is authoritative, so a telemetry failure is
|
|
850
|
+
* reported on stderr and never changes the exit code. No line text goes in —
|
|
851
|
+
* the record travels over the optional webhook unredacted; the snapshot holds it.
|
|
852
|
+
*
|
|
853
|
+
* @param {ReturnType<typeof parseArgs>} args - `repoRoot` set, `file` inside it (parseArgs)
|
|
854
|
+
* @param {Awaited<ReturnType<typeof dropMalformedLines>>} result
|
|
855
|
+
*/
|
|
856
|
+
async function emitMalformedDropped(args, result) {
|
|
857
|
+
// Both sides canonical, as the parseArgs check judged them: a lexical
|
|
858
|
+
// relative() across /var vs /private/var reads `../../private/...`.
|
|
859
|
+
const root = resolveLedger(args.repoRoot);
|
|
860
|
+
const rel = (p) => path.relative(root, resolveLedger(p));
|
|
861
|
+
try {
|
|
862
|
+
const { emitEvent, sessionAttribution } = await import('./lib/events.mjs');
|
|
863
|
+
await emitEvent(
|
|
864
|
+
DROP_MALFORMED_EVENT,
|
|
865
|
+
{
|
|
866
|
+
file: rel(args.file),
|
|
867
|
+
dropped: result.dropped.length,
|
|
868
|
+
lines: result.dropped.map((d) => d.line),
|
|
869
|
+
remaining_malformed: result.remainingMalformed,
|
|
870
|
+
record_bearing: result.recordBearing,
|
|
871
|
+
snapshot: rel(result.snapshot),
|
|
872
|
+
source: 'sweep-expired-learnings-cli',
|
|
873
|
+
...sessionAttribution(args.repoRoot),
|
|
874
|
+
},
|
|
875
|
+
{ repoRoot: args.repoRoot },
|
|
876
|
+
);
|
|
877
|
+
} catch (err) {
|
|
878
|
+
process.stderr.write(`sweep-expired-learnings: ${DROP_MALFORMED_EVENT} not recorded: ${err?.message ?? err}\n`);
|
|
879
|
+
}
|
|
880
|
+
}
|
|
881
|
+
|
|
882
|
+
/**
|
|
883
|
+
* `--drop-malformed --line N` (#1500): remove unparseable store lines — the
|
|
884
|
+
* sanctioned repair the WARN and learning-patterns.md §f name. A dry run
|
|
885
|
+
* (default) checks each line and prints its preview plus the store generation;
|
|
886
|
+
* `--apply --generation <that token> --repo-root <root>` re-reads the store
|
|
887
|
+
* under its lock, refuses a changed store (exit 3) or a line that is not
|
|
888
|
+
* unparseable (exit 1), snapshots the store beside it, rewrites it, and
|
|
889
|
+
* records one {@link DROP_MALFORMED_EVENT}.
|
|
890
|
+
*
|
|
891
|
+
* @param {ReturnType<typeof parseArgs>} args
|
|
892
|
+
*/
|
|
893
|
+
async function runDropMalformed(args) {
|
|
894
|
+
let result;
|
|
895
|
+
try {
|
|
896
|
+
result = await dropMalformedLines({
|
|
897
|
+
filePath: args.file,
|
|
898
|
+
lines: args.lines,
|
|
899
|
+
expectedGeneration: args.generation ?? undefined,
|
|
900
|
+
acceptEmbedded: args.acceptEmbedded,
|
|
901
|
+
dryRun: args.dryRun,
|
|
902
|
+
});
|
|
903
|
+
} catch (err) {
|
|
904
|
+
if (err instanceof MalformedLineRefusedError) usageError(err.message);
|
|
905
|
+
if (err instanceof StoreGenerationMismatchError) {
|
|
906
|
+
process.stderr.write(
|
|
907
|
+
`sweep-expired-learnings: store-generation-mismatch: ${args.file} changed after the dry run ` +
|
|
908
|
+
`that printed --generation — nothing written. Its line numbers may name other lines now: ` +
|
|
909
|
+
`re-run the dry run, check the previews it prints, and apply with ITS generation\n`
|
|
910
|
+
);
|
|
911
|
+
process.exit(3);
|
|
912
|
+
}
|
|
913
|
+
process.stderr.write(`sweep-expired-learnings: drop-malformed failed: ${err.message}\n`);
|
|
914
|
+
process.exit(2);
|
|
915
|
+
}
|
|
916
|
+
|
|
917
|
+
if (!args.dryRun) await emitMalformedDropped(args, result);
|
|
918
|
+
|
|
919
|
+
const summary = {
|
|
920
|
+
file: args.file,
|
|
921
|
+
generation: result.generation,
|
|
922
|
+
dropped: result.dropped,
|
|
923
|
+
records: result.records,
|
|
924
|
+
remaining_malformed: result.remainingMalformed,
|
|
925
|
+
record_bearing: result.recordBearing,
|
|
926
|
+
dry_run: result.dryRun,
|
|
927
|
+
snapshot: result.snapshot,
|
|
928
|
+
};
|
|
929
|
+
if (args.json) {
|
|
930
|
+
process.stdout.write(JSON.stringify(summary) + '\n');
|
|
931
|
+
return;
|
|
932
|
+
}
|
|
933
|
+
// The flags say what the 80-char preview cannot show (#1500 F1).
|
|
934
|
+
const flags = (d) =>
|
|
935
|
+
(d.bom ? ' [a readable record behind a UTF-8 BOM]' : '') +
|
|
936
|
+
(d.embedded_record
|
|
937
|
+
? ` [a readable record (id ${d.embedded_record.id ?? 'none'}) at offset ${d.embedded_record.offset}]`
|
|
938
|
+
: '');
|
|
939
|
+
process.stdout.write(
|
|
940
|
+
`sweep-expired-learnings: drop-malformed lines=${JSON.stringify(summary.dropped.map((d) => d.line))} ` +
|
|
941
|
+
`records=${summary.records} remaining_malformed=${summary.remaining_malformed} ` +
|
|
942
|
+
`record_bearing=${summary.record_bearing} dry_run=${summary.dry_run} generation=${summary.generation}` +
|
|
943
|
+
(summary.snapshot ? ` snapshot=${summary.snapshot}` : '') +
|
|
944
|
+
'\n' +
|
|
945
|
+
summary.dropped.map((d) => ` line ${d.line} (${d.chars} chars): ${d.preview}${flags(d)}\n`).join('') +
|
|
946
|
+
(summary.dry_run && summary.record_bearing > 0
|
|
947
|
+
? ' --apply refuses a line that holds a readable record unless --accept-embedded is given\n'
|
|
948
|
+
: '')
|
|
949
|
+
);
|
|
950
|
+
}
|
|
951
|
+
|
|
424
952
|
async function main() {
|
|
425
953
|
const args = parseArgs(process.argv.slice(2));
|
|
954
|
+
if (args.dropMalformed) {
|
|
955
|
+
await runDropMalformed(args);
|
|
956
|
+
return;
|
|
957
|
+
}
|
|
426
958
|
if (args.prune) {
|
|
427
959
|
await runPrune(args);
|
|
428
960
|
return;
|