session-orchestrator 5.3.0 → 5.5.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/.agents/skills/claude-md-drift-check/SKILL.md +1 -1
- package/.agents/skills/test-audit/SKILL.md +21 -0
- package/.agents/skills/test-audit/agents/openai.yaml +5 -0
- package/.claude-plugin/marketplace.json +1 -1
- package/.claude-plugin/plugin.json +1 -1
- package/.codex-plugin/plugin.json +1 -1
- package/.codex-plugin/skills/claude-md-drift-check/SKILL.md +1 -1
- package/.codex-plugin/skills/test-audit/SKILL.md +21 -0
- package/.codex-plugin/skills/test-audit/agents/openai.yaml +5 -0
- package/.cursor/commands/test-audit.md +14 -0
- package/.cursor/rules/060-evolve.mdc +28 -13
- package/.cursor/skills/claude-md-drift-check/SKILL.md +1 -1
- package/.cursor/skills/test-audit/SKILL.md +12 -0
- package/.cursor-plugin/plugin.json +1 -1
- package/.orchestrator/policy/blocked-commands.json +1 -1
- package/AGENTS.md +9 -31
- package/CHANGELOG.md +162 -0
- package/README.md +14 -10
- package/docs/agent-authoring.md +2 -2
- package/docs/baseline.md +10 -1
- package/docs/ci-setup.md +154 -4
- package/docs/components.md +11 -11
- package/docs/events-schema.md +25 -19
- package/docs/install.md +14 -2
- package/docs/migration-v4.md +24 -17
- package/docs/persona-panel.md +5 -4
- package/docs/plugin-architecture-v3.md +1 -1
- package/docs/rule-authoring.md +27 -12
- package/docs/scope-collision-guard.md +40 -16
- package/docs/session-config-reference.md +61 -12
- package/docs/session-config-template.md +11 -2
- package/docs/vault-docs-architecture.md +8 -4
- package/hooks/_lib/hook-import-set.json +42 -29
- 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-cursor.json +5 -0
- package/hooks/hooks-pi.json +5 -0
- package/hooks/hooks.json +6 -1
- package/hooks/on-session-start.mjs +57 -2
- package/hooks/on-stop.mjs +70 -168
- 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 -152
- package/hooks/pre-bash-memory-propose-audit.mjs +71 -10
- package/hooks/pre-bash-sessions-ledger-guard.mjs +28 -2
- package/hooks/pre-bash-staging-fence.mjs +29 -8
- package/hooks/pre-edit-sessions-ledger-guard.mjs +162 -0
- package/hooks/pre-task-scope-disjoint.mjs +1143 -279
- package/hooks/subagent-telemetry.mjs +96 -19
- package/hooks/wave-scope-commit-guard.mjs +168 -72
- package/package.json +2 -6
- package/pi/prompts/test-audit.md +12 -0
- package/rules/README.md +6 -1
- package/rules/_index.md +3 -3
- package/rules/always-on/bash-harness-pitfalls.md +7 -1
- package/rules/opt-in-domain/prompt-caching.md +5 -1
- package/rules/opt-in-stack/backend-data.md +9 -1
- package/rules/opt-in-stack/backend.md +7 -1
- package/rules/opt-in-stack/frontend.md +6 -1
- package/rules/opt-in-stack/security-web.md +7 -1
- package/rules/opt-in-stack/swift.md +5 -1
- package/scripts/apply-session-learnings.mjs +513 -0
- package/scripts/archive-closed-prds.mjs +40 -12
- package/scripts/autopilot.mjs +57 -54
- package/scripts/backfill-abandoned-sessions.mjs +81 -6
- package/scripts/backfill-learnings-from-vault.mjs +49 -22
- package/scripts/baseline-archetypes.mjs +1 -1
- package/scripts/check-package-manager.mjs +62 -5
- package/scripts/codex-install.mjs +3 -0
- package/scripts/cursor-install.mjs +47 -6
- package/scripts/emit-session.mjs +298 -2
- package/scripts/export-hw-learnings.mjs +40 -10
- package/scripts/fleet-checkin.mjs +170 -0
- package/scripts/lib/agent-frontmatter.mjs +3 -3
- package/scripts/lib/auto-dream.mjs +7 -6
- package/scripts/lib/autopilot/flags.mjs +6 -1
- package/scripts/lib/autopilot/loop.mjs +32 -13
- package/scripts/lib/autopilot/stall-sampler.mjs +13 -9
- package/scripts/lib/baseline-archetypes.mjs +41 -22
- package/scripts/lib/blocked-commands-policy.mjs +40 -0
- package/scripts/lib/common.mjs +41 -0
- package/scripts/lib/config/host-paths.mjs +79 -0
- package/scripts/lib/config/persona-gate-wave.mjs +3 -3
- package/scripts/lib/config/reaper.mjs +7 -6
- package/scripts/lib/config/vault-integration.mjs +151 -16
- package/scripts/lib/config-schema.mjs +13 -0
- package/scripts/lib/config.mjs +75 -10
- 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 +72 -5
- package/scripts/lib/fleet-protocol.mjs +288 -0
- package/scripts/lib/gates/gate-baseline.mjs +13 -3
- package/scripts/lib/gates/gate-full.mjs +7 -2
- package/scripts/lib/gates/gate-helpers.mjs +93 -2
- package/scripts/lib/gates/gate-incremental.mjs +23 -22
- package/scripts/lib/gates/gate-per-file.mjs +16 -7
- package/scripts/lib/hardening.mjs +1 -0
- package/scripts/lib/harness-audit/categories/category4.mjs +8 -6
- package/scripts/lib/instruction-budget-guard.mjs +77 -11
- package/scripts/lib/learnings/expiry-sweep.mjs +420 -22
- package/scripts/lib/learnings/io.mjs +274 -37
- package/scripts/lib/learnings/schema.mjs +163 -15
- package/scripts/lib/learnings/sizing-subject.mjs +2 -2
- package/scripts/lib/lock-reaper.mjs +38 -14
- package/scripts/lib/maintenance-due-banner.mjs +23 -8
- package/scripts/lib/memory-proposals/schema.mjs +11 -5
- package/scripts/lib/named-baseline-resolver.mjs +53 -28
- package/scripts/lib/named-vault-resolver.mjs +147 -5
- package/scripts/lib/orphan-reaper/defaults.mjs +41 -0
- package/scripts/lib/orphan-reaper/no-follow-append.mjs +87 -0
- package/scripts/lib/orphan-reaper/ps-snapshot.mjs +181 -0
- package/scripts/lib/orphan-reaper/reaper-audit.mjs +323 -0
- package/scripts/lib/orphan-reaper/reaper-decide.mjs +395 -0
- package/scripts/lib/orphan-reaper/scan-throttle.mjs +144 -0
- package/scripts/lib/orphan-reaper/scan.mjs +510 -0
- package/scripts/lib/orphan-reaper/trigger.mjs +173 -0
- package/scripts/lib/orphan-reaper.mjs +47 -1315
- package/scripts/lib/owner-yaml.mjs +104 -6
- package/scripts/lib/persona-panel/catalog-loader.mjs +1 -1
- package/scripts/lib/platform.mjs +206 -2
- package/scripts/lib/plugin-update-banner.mjs +3 -1
- package/scripts/lib/process-group.mjs +134 -22
- package/scripts/lib/project-hygiene.mjs +21 -1
- package/scripts/lib/quality-gate.mjs +45 -5
- package/scripts/lib/resource-probe.mjs +1 -1
- package/scripts/lib/rule-loader.mjs +100 -62
- package/scripts/lib/rules-sync.mjs +27 -12
- package/scripts/lib/scope-baseline.mjs +119 -29
- 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-record-repair.mjs +183 -26
- package/scripts/lib/session-registry.mjs +67 -17
- package/scripts/lib/session-schema/constants.mjs +30 -4
- package/scripts/lib/session-schema/validator.mjs +28 -1
- package/scripts/lib/session-start-probes.mjs +242 -68
- package/scripts/lib/session-token-rollup.mjs +252 -54
- package/scripts/lib/sessions-canonical.mjs +100 -9
- package/scripts/lib/sessions-staleness-banner.mjs +3 -1
- package/scripts/lib/sh-quote.mjs +29 -0
- package/scripts/lib/subagents-schema.mjs +30 -3
- package/scripts/lib/tail-window.mjs +27 -3
- package/scripts/lib/telemetry/pricing.mjs +121 -71
- package/scripts/lib/telemetry/sync.mjs +17 -0
- package/scripts/lib/tests-src-ratio.mjs +215 -23
- package/scripts/lib/tmux-layout/layouts.mjs +1 -11
- package/scripts/lib/tmux-layout/telemetry-stats.mjs +12 -0
- package/scripts/lib/tmux-layout/tmux-shell.mjs +2 -11
- package/scripts/lib/ux-grill/reconcile.mjs +1 -1
- package/scripts/lib/validate/check-agents.mjs +1 -1
- package/scripts/lib/validate/check-hooks-symmetry.mjs +5 -0
- package/scripts/lib/validate/check-learnings-shell-writes.mjs +214 -0
- package/scripts/lib/validate/check-test-value-bans.mjs +388 -27
- package/scripts/lib/validate/check-unicode-safety.mjs +1 -0
- package/scripts/lib/validate/check-unwired-features.mjs +1 -1
- package/scripts/lib/validate/check-vcs-repo-flag.mjs +1 -1
- package/scripts/lib/validate-vendored-rules.mjs +206 -81
- 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 +25 -11
- package/scripts/lib/vault-status/board-writer.mjs +53 -12
- package/scripts/lib/vault-status/narrative-mirror.mjs +40 -9
- package/scripts/lib/vcs-repo-spec.mjs +8 -0
- package/scripts/lib/wave-executor/dispatch-common.mjs +10 -18
- package/scripts/lib/wave-executor/foreign-dispatch.mjs +1 -1
- package/scripts/lib/wave-executor/remote-dispatch.mjs +1 -1
- package/scripts/lib/wave-resource-gate.mjs +34 -8
- package/scripts/materialize-wave-scope.mjs +7 -4
- package/scripts/memory-propose.mjs +18 -5
- package/scripts/release-session-lock.mjs +2 -1
- package/scripts/release.mjs +11 -3
- package/scripts/repair-invalid-sessions.mjs +37 -4
- package/scripts/run-migrate-v2-cross-repo.mjs +171 -31
- package/scripts/run-quality-gate.mjs +331 -307
- package/scripts/self-update.mjs +229 -0
- package/scripts/site-numbers.mjs +163 -5
- package/scripts/sweep-expired-learnings.mjs +603 -20
- package/scripts/sweep-expired-rules.mjs +2 -1
- package/scripts/validate-plugin.mjs +7 -0
- package/scripts/validate-wave-scope.mjs +21 -2
- package/scripts/vault-backfill.mjs +50 -37
- package/scripts/vault-integration-watcher.mjs +2 -1
- package/scripts/vault-mirror.mjs +238 -75
- package/scripts/wave-scope-binding.mjs +2 -1
- package/skills/_shared/config-reading.md +3 -3
- package/skills/_shared/fleet-protocol.md +134 -0
- package/skills/_shared/state-ownership.md +41 -12
- package/skills/autopilot/SKILL.md +25 -11
- package/skills/bootstrap/SKILL.md +6 -4
- package/skills/bootstrap/_shared-template.md +97 -2
- package/skills/bootstrap/fast-template.md +4 -1
- package/skills/bootstrap/private-contract.md +2 -1
- package/skills/bootstrap/public-fallback.md +6 -3
- package/skills/bootstrap/references/bootstrap-refresh-lock-flow.md +5 -3
- package/skills/bootstrap/references/bootstrap-retroactive-flow.md +2 -1
- package/skills/bootstrap/standard-template.md +2 -0
- package/skills/claude-md-drift-check/SKILL.md +10 -10
- package/skills/claude-md-drift-check/checker.mjs +26 -18
- 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 +13 -4
- package/skills/evolve/references/evolve-analyze-mode.md +42 -18
- package/skills/memory-cleanup/SKILL.md +1 -1
- package/skills/persona-panel/SKILL.md +2 -2
- package/skills/persona-panel/persona-format.md +2 -2
- package/skills/persona-panel/presets/designer-lens.md +1 -1
- package/skills/persona-panel/presets/engineer-lens.md +1 -1
- package/skills/persona-panel/presets/pm-lens.md +1 -1
- package/skills/plan/mode-retro.md +19 -4
- package/skills/quality-gates/SKILL.md +6 -4
- package/skills/remote-offload/SKILL.md +4 -2
- package/skills/session-end/learning-patterns.md +23 -13
- package/skills/session-end/metrics-collection.md +6 -7
- 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 +13 -3
- package/skills/session-end/references/phase-4a-worktree-cleanup.md +2 -0
- package/skills/session-end/references/session-summary-template.md +1 -1
- package/skills/session-end/session-metrics-write.md +65 -68
- package/skills/session-plan/SKILL.md +0 -3
- package/skills/session-start/SKILL.md +8 -2
- package/skills/session-start/references/operations-contract.md +6 -0
- package/skills/session-start/references/phase-1-2-session-lock.md +32 -7
- package/skills/session-start/references/phase-1-7-vault-status-board.md +1 -1
- package/skills/session-start/references/phase-4-ssot-environment-check.md +10 -3
- package/skills/test-audit/SKILL.md +152 -0
- package/skills/test-audit/references/campaign.md +111 -0
- package/skills/test-audit/references/mutate.sh +50 -0
- package/skills/vault-mirror/SKILL.md +21 -11
- package/skills/wave-executor/SKILL.md +2 -2
- package/skills/wave-executor/circuit-breaker.md +1 -1
- package/skills/wave-executor/references/wave-executor-state-init.md +3 -0
- package/skills/wave-executor/references/wave-loop-dispatch.md +11 -1
- package/skills/wave-executor/references/wave-loop-review.md +4 -6
- package/skills/wave-executor/references/wave-loop-scope-manifest.md +13 -5
- package/templates/personas/README.md +5 -5
- package/templates/personas/accounting-compliance.v1.md +1 -1
- package/templates/personas/accounting-tax-advisor.v1.md +1 -1
- package/templates/personas/buyer-p1-cto.v1.md +1 -1
- package/templates/personas/buyer-p2-kanzlei.v1.md +1 -1
- package/templates/personas/buyer-p3-build.v1.md +1 -1
- package/templates/personas/buyer-p4-tech-veto.v1.md +1 -1
- package/templates/personas/buyer-p5-solo.v1.md +1 -1
- package/templates/personas/buyer-p6-ld.v1.md +1 -1
- package/templates/personas/klima-ai-expert.v1.md +1 -1
- package/templates/personas/klima-physicist.v1.md +1 -1
|
@@ -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,29 @@
|
|
|
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).
|
|
59
|
+
* A NEW record (its `id` not in the store) must pass strict
|
|
60
|
+
* `validateLearning()`, else exit 1 untouched (GH#69);
|
|
61
|
+
* records already in the store keep the tolerant path.
|
|
40
62
|
* Omitted ⇒ a pure prune+consolidate pass over the on-disk
|
|
41
63
|
* store.
|
|
42
64
|
* --file PATH Learnings store (default: .orchestrator/metrics/learnings.jsonl)
|
|
@@ -52,7 +74,28 @@
|
|
|
52
74
|
* --skipped a,b PRUNE ONLY (#1206). Comma-separated list of optional
|
|
53
75
|
* steps that ran but were themselves skipped this run
|
|
54
76
|
* (HR-105 — e.g. `skill-evolution-off`). Default none.
|
|
55
|
-
* --
|
|
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
|
|
56
99
|
* `orchestrator.evolve.completed` record is pinned to.
|
|
57
100
|
* No default and NO process.cwd() fallback (#1119) — when
|
|
58
101
|
* omitted, no event is emitted at all (stderr WARN, exit
|
|
@@ -61,41 +104,104 @@
|
|
|
61
104
|
*
|
|
62
105
|
* Exit codes:
|
|
63
106
|
* 0 Success (including no-op when nothing is archive-eligible)
|
|
64
|
-
* 1 Usage/input error (bad flag/value, flag used in the wrong mode,
|
|
65
|
-
*
|
|
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
|
|
110
|
+
* absent/malformed/empty `--entries` sidecar, or a NEW `--entries` record
|
|
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)
|
|
66
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.
|
|
67
123
|
*/
|
|
68
124
|
|
|
69
|
-
import { existsSync } from 'node:fs';
|
|
70
|
-
import {
|
|
71
|
-
import
|
|
125
|
+
import { existsSync, realpathSync, statSync } from 'node:fs';
|
|
126
|
+
import { mkdir, readFile, rename, writeFile } from 'node:fs/promises';
|
|
127
|
+
import path from 'node:path';
|
|
128
|
+
import { isDeepStrictEqual } from 'node:util';
|
|
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';
|
|
138
|
+
import { validateLearning } from './lib/learnings/schema.mjs';
|
|
139
|
+
import { validatePathInsideProject } from './lib/path-utils.mjs';
|
|
72
140
|
import { emitEvolveCompleted } from './lib/learnings/evolve-telemetry.mjs';
|
|
73
141
|
|
|
74
142
|
const DEFAULT_FILE = '.orchestrator/metrics/learnings.jsonl';
|
|
75
143
|
const DEFAULT_ARCHIVE = '.orchestrator/metrics/learnings-archive.jsonl';
|
|
76
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';
|
|
77
151
|
|
|
78
152
|
function printHelp() {
|
|
79
153
|
process.stdout.write(
|
|
80
|
-
`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]]
|
|
81
156
|
|
|
82
157
|
Modes:
|
|
83
158
|
(default) Expiry sweep — archive entries expired past the grace window
|
|
84
159
|
--prune Prune + consolidate + rewrite (issue #1017): archives
|
|
85
160
|
expired / zero-confidence / superseded / caller-dropped
|
|
86
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
|
|
87
168
|
|
|
88
169
|
Options:
|
|
89
170
|
--dry-run Preview counts; write nothing (default)
|
|
90
171
|
--apply Perform the archive append + store rewrite
|
|
91
172
|
--json Emit a single machine-parseable JSON summary line
|
|
92
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
|
|
93
180
|
--entries PATH JSONL sidecar with the next store generation; prune only.
|
|
94
|
-
Must exist, parse cleanly,
|
|
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
|
|
184
|
+
validation, else exit 1 with nothing written
|
|
95
185
|
--file PATH Learnings store (default: ${DEFAULT_FILE})
|
|
96
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
|
|
97
194
|
|
|
98
|
-
Exit codes: 0 success 1 usage/input error
|
|
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)
|
|
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
|
|
99
205
|
`
|
|
100
206
|
);
|
|
101
207
|
}
|
|
@@ -106,6 +212,16 @@ function usageError(message) {
|
|
|
106
212
|
process.exit(1);
|
|
107
213
|
}
|
|
108
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
|
+
|
|
109
225
|
function parseArgs(argv) {
|
|
110
226
|
const args = {
|
|
111
227
|
prune: false,
|
|
@@ -114,6 +230,7 @@ function parseArgs(argv) {
|
|
|
114
230
|
graceDays: DEFAULT_GRACE_DAYS,
|
|
115
231
|
graceDaysExplicit: false,
|
|
116
232
|
entries: null,
|
|
233
|
+
snapshot: null,
|
|
117
234
|
file: DEFAULT_FILE,
|
|
118
235
|
archive: DEFAULT_ARCHIVE,
|
|
119
236
|
appended: 0,
|
|
@@ -130,11 +247,34 @@ function parseArgs(argv) {
|
|
|
130
247
|
// fail-closed contract as `emitReconcileCompleted` / `express-path.mjs`.
|
|
131
248
|
repoRoot: null,
|
|
132
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,
|
|
133
258
|
};
|
|
134
259
|
for (let i = 0; i < argv.length; i++) {
|
|
135
260
|
const a = argv[i];
|
|
136
261
|
if (a === '--prune') {
|
|
137
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;
|
|
138
278
|
} else if (a === '--apply') {
|
|
139
279
|
args.dryRun = false;
|
|
140
280
|
} else if (a === '--dry-run') {
|
|
@@ -150,11 +290,14 @@ function parseArgs(argv) {
|
|
|
150
290
|
args.graceDays = v;
|
|
151
291
|
args.graceDaysExplicit = true;
|
|
152
292
|
} else if (a === '--entries') {
|
|
153
|
-
args.entries = argv[++i];
|
|
293
|
+
args.entries = pathValue(a, argv[++i]);
|
|
294
|
+
} else if (a === '--snapshot') {
|
|
295
|
+
args.snapshot = pathValue(a, argv[++i]);
|
|
154
296
|
} else if (a === '--file') {
|
|
155
|
-
args.file = argv[++i];
|
|
297
|
+
args.file = pathValue(a, argv[++i]);
|
|
156
298
|
} else if (a === '--archive') {
|
|
157
|
-
args.archive = argv[++i];
|
|
299
|
+
args.archive = pathValue(a, argv[++i]);
|
|
300
|
+
args.archiveExplicit = true;
|
|
158
301
|
} else if (a === '--appended') {
|
|
159
302
|
const raw = argv[++i];
|
|
160
303
|
const v = Number(raw);
|
|
@@ -163,6 +306,7 @@ function parseArgs(argv) {
|
|
|
163
306
|
}
|
|
164
307
|
args.appended = v;
|
|
165
308
|
args.telemetryExplicit = true;
|
|
309
|
+
args.countersExplicit = true;
|
|
166
310
|
} else if (a === '--boosted') {
|
|
167
311
|
const raw = argv[++i];
|
|
168
312
|
const v = Number(raw);
|
|
@@ -171,6 +315,7 @@ function parseArgs(argv) {
|
|
|
171
315
|
}
|
|
172
316
|
args.boosted = v;
|
|
173
317
|
args.telemetryExplicit = true;
|
|
318
|
+
args.countersExplicit = true;
|
|
174
319
|
} else if (a === '--duration-ms') {
|
|
175
320
|
const raw = argv[++i];
|
|
176
321
|
const v = Number(raw);
|
|
@@ -179,12 +324,15 @@ function parseArgs(argv) {
|
|
|
179
324
|
}
|
|
180
325
|
args.durationMs = v;
|
|
181
326
|
args.telemetryExplicit = true;
|
|
327
|
+
args.countersExplicit = true;
|
|
182
328
|
} else if (a === '--skipped') {
|
|
183
329
|
const raw = argv[++i];
|
|
184
330
|
args.skipped = typeof raw === 'string' ? raw.split(',').filter((s) => s.length > 0) : [];
|
|
185
331
|
args.telemetryExplicit = true;
|
|
332
|
+
args.countersExplicit = true;
|
|
186
333
|
} else if (a === '--repo-root') {
|
|
187
|
-
|
|
334
|
+
// Absolute on purpose: emitEvent refuses a relative root (#1468).
|
|
335
|
+
args.repoRoot = path.resolve(pathValue(a, argv[++i]));
|
|
188
336
|
args.telemetryExplicit = true;
|
|
189
337
|
} else if (a === '--help' || a === '-h') {
|
|
190
338
|
printHelp();
|
|
@@ -198,12 +346,61 @@ function parseArgs(argv) {
|
|
|
198
346
|
// that the prune path ignores would read as "the grace window applied" in a
|
|
199
347
|
// transcript, and an `--entries` the sweep ignores would read as "my next
|
|
200
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
|
+
}
|
|
201
392
|
if (args.prune && args.graceDaysExplicit) {
|
|
202
393
|
usageError('--grace-days is not valid with --prune (the prune path has no grace window)');
|
|
203
394
|
}
|
|
204
395
|
if (!args.prune && args.entries !== null) {
|
|
205
396
|
usageError('--entries is only valid with --prune');
|
|
206
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
|
+
}
|
|
207
404
|
if (!args.prune && args.telemetryExplicit) {
|
|
208
405
|
usageError(
|
|
209
406
|
'--appended/--boosted/--duration-ms/--skipped/--repo-root are only valid with --prune ' +
|
|
@@ -277,8 +474,28 @@ async function runSweep(args) {
|
|
|
277
474
|
* explicit `entries: []` written in CODE is a statement, and the lib keeps it
|
|
278
475
|
* expressible. Only the FILE is ambiguous, so only the file is guarded.
|
|
279
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
|
+
*
|
|
280
495
|
* @param {string} entriesPath
|
|
281
|
-
* @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)
|
|
282
499
|
*/
|
|
283
500
|
async function loadEntriesSidecar(entriesPath) {
|
|
284
501
|
if (!existsSync(entriesPath)) {
|
|
@@ -287,12 +504,15 @@ async function loadEntriesSidecar(entriesPath) {
|
|
|
287
504
|
`next generation would archive the whole store)`
|
|
288
505
|
);
|
|
289
506
|
}
|
|
290
|
-
let
|
|
507
|
+
let raw;
|
|
291
508
|
try {
|
|
292
|
-
|
|
509
|
+
raw = await readFile(entriesPath, 'utf8');
|
|
293
510
|
} catch (err) {
|
|
294
511
|
usageError(`--entries sidecar unreadable: ${entriesPath}: ${err.message}`);
|
|
295
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));
|
|
296
516
|
if (read.malformed.length > 0) {
|
|
297
517
|
usageError(
|
|
298
518
|
`refusing to prune — ${read.malformed.length} malformed line(s) in ${entriesPath}`
|
|
@@ -305,7 +525,238 @@ async function loadEntriesSidecar(entriesPath) {
|
|
|
305
525
|
`pure prune+consolidate pass)`
|
|
306
526
|
);
|
|
307
527
|
}
|
|
308
|
-
|
|
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
|
+
}
|
|
694
|
+
}
|
|
695
|
+
|
|
696
|
+
/**
|
|
697
|
+
* Strict-validate every `--entries` record the store does not already hold.
|
|
698
|
+
*
|
|
699
|
+
* `pruneLearnings()` rewrites through `rewriteLearnings()`, which runs
|
|
700
|
+
* `legacyTolerant` so records ALREADY in the store (legacy shapes, EventDrop
|
|
701
|
+
* #386) survive a round-trip. Without this gate the same tolerance admitted
|
|
702
|
+
* NEW records too — a sidecar record with `scope: "src/app/x.tsx"` and no
|
|
703
|
+
* `created_at`/`expires_at` was written (GH#69). New = its `id` is absent from
|
|
704
|
+
* the store; an id-less record counts as known only when an id-less store
|
|
705
|
+
* record is deep-equal to it (the carry-through case — linear scan over the
|
|
706
|
+
* id-less subset, fine while such records stay rare; revisit if a store holds
|
|
707
|
+
* hundreds of them). Runs before any write, in dry run and apply alike.
|
|
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
|
+
*
|
|
717
|
+
* @param {object[]} entries - the parsed `--entries` generation
|
|
718
|
+
* @param {string} expectedGeneration - the sidecar's `_store_generation`
|
|
719
|
+
* @param {ReturnType<typeof parseArgs>} args
|
|
720
|
+
*/
|
|
721
|
+
async function rejectInvalidNewRecords(entries, expectedGeneration, args) {
|
|
722
|
+
let store;
|
|
723
|
+
try {
|
|
724
|
+
store = await readLearningsSnapshot(args.file);
|
|
725
|
+
} catch (err) {
|
|
726
|
+
process.stderr.write(`sweep-expired-learnings: prune failed: cannot read ${args.file}: ${err.message}\n`);
|
|
727
|
+
process.exit(2);
|
|
728
|
+
}
|
|
729
|
+
const idOf = (e) => (typeof e?.id === 'string' && e.id.length > 0 ? e.id : null);
|
|
730
|
+
const storeIds = new Set(store.entries.map(idOf).filter((id) => id !== null));
|
|
731
|
+
const storeIdless = store.entries.filter((e) => idOf(e) === null);
|
|
732
|
+
for (const entry of entries) {
|
|
733
|
+
const id = idOf(entry);
|
|
734
|
+
const known = id !== null ? storeIds.has(id) : storeIdless.some((s) => isDeepStrictEqual(s, entry));
|
|
735
|
+
if (known) continue;
|
|
736
|
+
try {
|
|
737
|
+
validateLearning(entry);
|
|
738
|
+
} catch (err) {
|
|
739
|
+
if (store.generation !== expectedGeneration) exitGenerationMismatch(args);
|
|
740
|
+
usageError(`--entries: new record ${id ?? '(no id)'} is invalid: ${err.message} — nothing written`);
|
|
741
|
+
}
|
|
742
|
+
}
|
|
743
|
+
}
|
|
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);
|
|
309
760
|
}
|
|
310
761
|
|
|
311
762
|
/**
|
|
@@ -314,17 +765,40 @@ async function loadEntriesSidecar(entriesPath) {
|
|
|
314
765
|
* @param {ReturnType<typeof parseArgs>} args
|
|
315
766
|
*/
|
|
316
767
|
async function runPrune(args) {
|
|
317
|
-
|
|
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
|
+
}
|
|
318
790
|
|
|
319
791
|
let result;
|
|
320
792
|
try {
|
|
321
793
|
result = await pruneLearnings({
|
|
322
794
|
filePath: args.file,
|
|
323
795
|
archivePath: args.archive,
|
|
324
|
-
entries,
|
|
796
|
+
entries: sidecar?.entries,
|
|
797
|
+
expectedGeneration: sidecar?.generation,
|
|
325
798
|
dryRun: args.dryRun,
|
|
326
799
|
});
|
|
327
800
|
} catch (err) {
|
|
801
|
+
if (err instanceof StoreGenerationMismatchError) exitGenerationMismatch(args);
|
|
328
802
|
process.stderr.write(`sweep-expired-learnings: prune failed: ${err.message}\n`);
|
|
329
803
|
process.exit(2);
|
|
330
804
|
}
|
|
@@ -370,8 +844,117 @@ async function runPrune(args) {
|
|
|
370
844
|
}
|
|
371
845
|
}
|
|
372
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
|
+
|
|
373
952
|
async function main() {
|
|
374
953
|
const args = parseArgs(process.argv.slice(2));
|
|
954
|
+
if (args.dropMalformed) {
|
|
955
|
+
await runDropMalformed(args);
|
|
956
|
+
return;
|
|
957
|
+
}
|
|
375
958
|
if (args.prune) {
|
|
376
959
|
await runPrune(args);
|
|
377
960
|
return;
|