session-orchestrator 3.17.0 → 3.20.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/.claude-plugin/marketplace.json +1 -1
- package/.claude-plugin/plugin.json +1 -1
- package/.codex-plugin/plugin.json +1 -1
- package/.cursor/rules/030-wave-execution.mdc +17 -1
- package/CHANGELOG.md +185 -412
- package/README.md +12 -9
- package/SECURITY.md +190 -27
- package/agents/AGENTS.md +20 -3
- package/agents/code-implementer.md +6 -6
- package/agents/db-specialist.md +1 -1
- package/agents/qa-strategist.md +31 -6
- package/agents/schemas/qa-strategist.schema.json +27 -0
- package/agents/schemas/test-writer.schema.json +60 -2
- package/agents/security-reviewer.md +1 -1
- package/agents/session-reviewer.md +1 -1
- package/agents/test-writer.md +29 -10
- package/agents/ui-developer.md +1 -1
- package/commands/contract-version-bump.md +28 -0
- package/commands/portfolio.md +1 -1
- package/commands/session.md +6 -2
- package/docs/USER-GUIDE.md +9 -4
- package/docs/ci-setup.md +121 -7
- package/docs/codex-setup.md +1 -1
- package/docs/components.md +6 -6
- package/docs/cursor-setup.md +22 -9
- package/docs/events-schema.md +5 -1
- package/docs/instruction-delivery.md +794 -0
- package/docs/rule-authoring.md +58 -9
- package/docs/session-config-reference.md +245 -50
- package/docs/session-config-template.md +39 -26
- package/hooks/_lib/guard-source-loader.mjs +680 -0
- package/hooks/_lib/lock-bootstrap.mjs +21 -0
- package/hooks/_lib/vcs-create-matcher.mjs +119 -0
- package/hooks/config-protection.mjs +0 -0
- package/hooks/enforce-commands.mjs +226 -19
- package/hooks/enforce-scope.mjs +133 -9
- package/hooks/hooks-codex.json +1 -1
- package/hooks/hooks-cursor.json +11 -2
- package/hooks/hooks-pi.json +10 -0
- package/hooks/hooks.json +21 -1
- package/hooks/on-session-end.mjs +178 -18
- package/hooks/on-session-start.mjs +30 -4
- package/hooks/post-bash-write-verify.mjs +977 -0
- package/hooks/post-subagent-discovery-validator.mjs +256 -41
- package/hooks/pre-bash-destructive-guard.mjs +616 -164
- package/hooks/pre-bash-issue-budget.mjs +167 -0
- package/hooks/pre-bash-sessions-ledger-guard.mjs +1054 -0
- package/hooks/pre-bash-templates-first.mjs +96 -63
- package/hooks/subagent-telemetry.mjs +527 -37
- package/package.json +6 -3
- package/pi/prompts/contract-version-bump.md +12 -0
- package/rules/README.md +32 -0
- package/scripts/archive-closed-prds.mjs +12 -22
- package/scripts/autopilot-multi.mjs +103 -20
- package/scripts/backfill-abandoned-sessions.mjs +160 -4
- package/scripts/backfill-learnings-from-vault.mjs +967 -0
- package/scripts/check-doc-consistency.sh +17 -1
- package/scripts/emit-session.mjs +3 -40
- package/scripts/eval-session.mjs +50 -9
- package/scripts/fleet-instruction-scan.mjs +141 -0
- package/scripts/lib/autopilot/mr-draft.mjs +31 -1
- package/scripts/lib/autopilot/worktree-pipeline.mjs +113 -5
- package/scripts/lib/backlog-scan.mjs +39 -6
- package/scripts/lib/blocked-commands-policy.mjs +340 -0
- package/scripts/lib/ci-status-banner.mjs +75 -12
- package/scripts/lib/claude-md-budget-lint.mjs +283 -34
- package/scripts/lib/command-blocker.mjs +1273 -58
- package/scripts/lib/config/config-protection.mjs +2 -1
- package/scripts/lib/config/drift-check.mjs +9 -1
- package/scripts/lib/config/gitlab-portfolio.mjs +1 -1
- package/scripts/lib/config/issue-budget.mjs +123 -0
- package/scripts/lib/config/reconcile.mjs +21 -0
- package/scripts/lib/config/section-extractor.mjs +121 -1
- package/scripts/lib/config-schema.mjs +23 -3
- package/scripts/lib/config.mjs +17 -0
- package/scripts/lib/convergence-monitor.mjs +49 -3
- package/scripts/lib/description-surface.mjs +535 -0
- package/scripts/lib/dispatcher/enumerate.mjs +26 -40
- package/scripts/lib/ecosystem-wizard/config-writer.mjs +26 -24
- package/scripts/lib/ecosystem-wizard/wizard-prompt.mjs +1 -1
- package/scripts/lib/eval/engine.mjs +47 -5
- package/scripts/lib/events.mjs +59 -7
- package/scripts/lib/gates/gate-full.mjs +15 -3
- package/scripts/lib/gates/gate-helpers.mjs +132 -6
- package/scripts/lib/gitlab-ops/stale-mr-sweep.mjs +28 -8
- package/scripts/lib/gitlab-portfolio/aggregator.mjs +8 -2
- package/scripts/lib/gitlab-portfolio/cli.mjs +1 -1
- package/scripts/lib/handover-gate.mjs +7 -3
- package/scripts/lib/hardening.mjs +9 -9
- package/scripts/lib/harness-audit/categories/category4.mjs +9 -3
- package/scripts/lib/instruction-budget-guard.mjs +402 -51
- package/scripts/lib/io.mjs +345 -10
- package/scripts/lib/issue-budget.mjs +269 -0
- package/scripts/lib/issue-close-strip-labels.mjs +39 -9
- package/scripts/lib/label-scope.mjs +47 -0
- package/scripts/lib/learnings/affinity.mjs +434 -0
- package/scripts/lib/learnings/candidates.mjs +736 -0
- package/scripts/lib/learnings/expiry-sweep.mjs +408 -53
- package/scripts/lib/learnings/judgment.mjs +782 -0
- package/scripts/lib/learnings/kebab.mjs +128 -0
- package/scripts/lib/learnings/schema.mjs +43 -3
- package/scripts/lib/learnings/select.mjs +550 -0
- package/scripts/lib/lock-reaper.mjs +1 -2
- package/scripts/lib/memory-proposals/schema.mjs +36 -1
- package/scripts/lib/peer-discovery.mjs +645 -0
- package/scripts/lib/pi-hook-bridge.mjs +146 -17
- package/scripts/lib/product-repo-detect.mjs +9 -8
- package/scripts/lib/project-hygiene.mjs +432 -0
- package/scripts/lib/quality-gate.mjs +167 -0
- package/scripts/lib/recommendations-v0.mjs +1 -1
- package/scripts/lib/reconcile/eligibility.mjs +1 -1
- package/scripts/lib/reconcile/emitter.mjs +128 -24
- package/scripts/lib/reconcile/engine.mjs +156 -54
- package/scripts/lib/reconcile/idempotency.mjs +114 -14
- package/scripts/lib/reconcile/renderer.mjs +141 -25
- package/scripts/lib/reconcile/sanitize.mjs +518 -0
- package/scripts/lib/reconcile/writer.mjs +95 -1
- package/scripts/lib/reconcile-nudge-banner.mjs +65 -9
- package/scripts/lib/resource-probe/evaluate.mjs +70 -4
- package/scripts/lib/resource-probe.mjs +19 -0
- package/scripts/lib/rule-loader.mjs +6 -0
- package/scripts/lib/scope-baseline.mjs +564 -0
- package/scripts/lib/scope-gate.mjs +568 -145
- package/scripts/lib/session-close-backfill.mjs +63 -8
- package/scripts/lib/session-end/phase-skip.mjs +1 -0
- package/scripts/lib/session-id.mjs +221 -41
- package/scripts/lib/session-lock.mjs +304 -6
- package/scripts/lib/session-record-repair.mjs +551 -0
- package/scripts/lib/session-schema/constants.mjs +22 -3
- package/scripts/lib/session-schema/serializer.mjs +54 -0
- package/scripts/lib/session-schema/validator.mjs +16 -0
- package/scripts/lib/session-schema.mjs +1 -0
- package/scripts/lib/session-token-rollup.mjs +68 -6
- package/scripts/lib/sessions-integrity-banner.mjs +294 -0
- package/scripts/lib/sessions-staleness-banner.mjs +121 -12
- package/scripts/lib/skill-evolution/idempotency.mjs +135 -16
- package/scripts/lib/skill-evolution/mr-opener.mjs +9 -1
- package/scripts/lib/soul-resolve.mjs +12 -0
- package/scripts/lib/spiral-carryover.mjs +142 -30
- package/scripts/lib/state-md/mission-status.mjs +53 -3
- package/scripts/lib/subagents-schema.mjs +43 -9
- package/scripts/lib/test-runner/issue-reconcile.mjs +53 -13
- package/scripts/lib/tests-src-ratio.mjs +484 -0
- package/scripts/lib/tmux-layout/telemetry.mjs +43 -10
- package/scripts/lib/validate/check-agents.mjs +56 -0
- package/scripts/lib/validate/check-banner-parity.mjs +376 -0
- package/scripts/lib/validate/check-guard-requires-parity.mjs +1148 -0
- package/scripts/lib/validate/check-hooks-symmetry.mjs +244 -10
- package/scripts/lib/validate/check-learning-provenance.mjs +511 -0
- package/scripts/lib/validate/check-owner-leakage.mjs +3 -3
- package/scripts/lib/validate/check-rules.mjs +244 -36
- package/scripts/lib/validate/check-test-value-bans.mjs +782 -0
- package/scripts/lib/validate/check-unicode-safety.mjs +1 -0
- package/scripts/lib/validate/check-unwired-features.mjs +549 -0
- package/scripts/lib/validate-vendored-rules.mjs +10 -2
- package/scripts/lib/vault-archive.mjs +17 -2
- package/scripts/lib/vault-backfill/glab.mjs +8 -0
- package/scripts/lib/vault-mirror/process.mjs +30 -0
- package/scripts/lib/vault-mirror/render-sessions.mjs +293 -36
- package/scripts/lib/vcs-repo-spec.mjs +362 -0
- package/scripts/lib/wave-resource-gate.mjs +115 -11
- package/scripts/lib/worktree/listing.mjs +44 -7
- package/scripts/mcp-server.sh +17 -3
- package/scripts/measure-context-overhead.sh +151 -0
- package/scripts/memory-propose.mjs +72 -9
- package/scripts/print-applicable-rules.mjs +218 -16
- package/scripts/print-learnings-index.mjs +474 -0
- package/scripts/release.mjs +534 -0
- package/scripts/repair-invalid-sessions.mjs +209 -0
- package/scripts/run-quality-gate.mjs +123 -5
- package/scripts/sweep-expired-learnings.mjs +192 -32
- package/scripts/validate-plugin.mjs +21 -0
- package/scripts/validate-wave-scope.mjs +182 -17
- package/scripts/vault-integration-watcher.mjs +32 -10
- package/skills/_shared/config-reading.md +2 -2
- package/skills/bootstrap/fast-template.md +1 -1
- package/skills/brainstorm/soul.md +47 -1
- package/skills/claude-md-drift-check/checker.mjs +145 -28
- package/skills/contract-version-bump/SKILL.md +219 -0
- package/skills/discovery/SKILL.md +4 -4
- package/skills/discovery/issue-templates.md +11 -11
- package/skills/discovery/probes-audit.md +1 -1
- package/skills/discovery/probes-feature.md +1 -1
- package/skills/discovery/probes-session.md +26 -5
- package/skills/ecosystem-health/SKILL.md +1 -1
- package/skills/ecosystem-health/wizard.md +4 -4
- package/skills/evolve/SKILL.md +117 -18
- package/skills/gitlab-ops/SKILL.md +25 -12
- package/skills/gitlab-portfolio/SKILL.md +2 -2
- package/skills/grill/soul.md +44 -1
- package/skills/hook-development/SKILL.md +1 -1
- package/skills/mode-selector/SKILL.md +1 -1
- package/skills/npm-publish/SKILL.md +17 -1
- package/skills/plan/SKILL.md +5 -5
- package/skills/plan/mode-feature.md +4 -4
- package/skills/plan/mode-new.md +10 -10
- package/skills/plan/mode-retro.md +1 -1
- package/skills/plan/soul.md +46 -3
- package/skills/quality-gates/SKILL.md +1 -1
- package/skills/reconcile/SKILL.md +21 -4
- package/skills/session-end/SKILL.md +34 -36
- package/skills/session-end/discovery-scan.md +4 -2
- package/skills/session-end/drift-operations.md +4 -4
- package/skills/session-end/metrics-collection.md +13 -0
- package/skills/session-end/phase-3-2-docs-verification.md +1 -1
- package/skills/session-end/phase-3-6-tail.md +32 -2
- package/skills/session-end/plan-verification.md +6 -7
- package/skills/session-end/session-metrics-write.md +2 -0
- package/skills/session-end/vault-operations.md +1 -1
- package/skills/session-end/verification-checklist.md +1 -1
- package/skills/session-plan/SKILL.md +6 -2
- package/skills/session-plan/wave-template.md +2 -0
- package/skills/session-start/SKILL.md +75 -7
- package/skills/session-start/phase-4-5-resource-health.md +15 -2
- package/skills/session-start/soul.md +41 -1
- package/skills/test-runner/SKILL.md +2 -2
- package/skills/vault-sync/validator.mjs +108 -7
- package/skills/wave-executor/SKILL.md +6 -7
- package/skills/wave-executor/circuit-breaker.md +2 -0
- package/skills/wave-executor/wave-loop.md +198 -80
- package/templates/_shared/loop.md +4 -4
|
@@ -1,15 +1,29 @@
|
|
|
1
1
|
/**
|
|
2
|
-
* learnings/expiry-sweep.mjs — mechanical
|
|
3
|
-
*
|
|
4
|
-
* Epic #723 B4
|
|
5
|
-
*
|
|
6
|
-
*
|
|
7
|
-
*
|
|
8
|
-
*
|
|
9
|
-
*
|
|
10
|
-
*
|
|
11
|
-
*
|
|
12
|
-
*
|
|
2
|
+
* learnings/expiry-sweep.mjs — mechanical archive-safe writers for learnings.jsonl.
|
|
3
|
+
*
|
|
4
|
+
* Epic #723 B4 (`sweepExpiredLearnings`) + issue #1017 (`pruneLearnings`).
|
|
5
|
+
*
|
|
6
|
+
* Fleet audit found expired-resident learnings accumulating in 6+ repos
|
|
7
|
+
* (Vault: 70% expired) because nothing MECHANICALLY moves expired entries out
|
|
8
|
+
* of the active store — `memory-cleanup-soft-limit` and the `evolve`
|
|
9
|
+
* confidence-decay pass both operate on the live store but never relocate
|
|
10
|
+
* anything. This module is the missing mechanical sweep: it partitions
|
|
11
|
+
* `learnings.jsonl` into KEEP (still active, or too-recently expired to move
|
|
12
|
+
* yet) and ARCHIVE (expired past the grace window), appends the archive
|
|
13
|
+
* candidates to an append-only sidecar, then rewrites the store with only the
|
|
14
|
+
* KEEP set.
|
|
15
|
+
*
|
|
16
|
+
* #1017 — the SAME pipeline, one predicate apart. `/evolve`'s prune step
|
|
17
|
+
* ("remove entries where `expires_at` < now OR `confidence` <= 0", then
|
|
18
|
+
* consolidate duplicates, then rewrite the store) was specified as coordinator
|
|
19
|
+
* PROSE doing a read-modify-`>`-rewrite by hand, with no archive append at
|
|
20
|
+
* all. Measured consequence on the live corpus: 11 of 13 `learning-id`
|
|
21
|
+
* provenance pointers in rendered `.claude/rules/*.md` resolved to NOTHING —
|
|
22
|
+
* the ids were in neither the store, the archive, nor any `.bak-*` snapshot
|
|
23
|
+
* (85% dead pointers). {@link pruneLearnings} is that path routed through this
|
|
24
|
+
* file's pipeline instead: the two callers differ ONLY in how they partition
|
|
25
|
+
* (time-driven vs decision-driven); the crash-safe ordering, the KEEP-batch
|
|
26
|
+
* probe, and the `.bak` snapshot are one shared code path, never two.
|
|
13
27
|
*
|
|
14
28
|
* Design constraints (deliberate, do not "simplify" away):
|
|
15
29
|
* - NEVER deletes data. Archive is append-only; the store rewrite is the
|
|
@@ -50,9 +64,206 @@ import { mkdir, appendFile } from 'node:fs/promises';
|
|
|
50
64
|
import path from 'node:path';
|
|
51
65
|
import { readLearnings, rewriteLearnings } from './io.mjs';
|
|
52
66
|
|
|
67
|
+
/**
|
|
68
|
+
* Composite-key separator for the in-memory consolidation Map.
|
|
69
|
+
*
|
|
70
|
+
* Built via `String.fromCharCode(0)` rather than a literal NUL byte in this file.
|
|
71
|
+
* A single NUL makes a tracked text file classify as BINARY, and grep/ugrep then
|
|
72
|
+
* skip it SILENTLY — exit 1, no output, no warning. That removed an entire deny
|
|
73
|
+
* path from a security census once (see the recorded anti-pattern rule on NUL
|
|
74
|
+
* bytes and grep-based audits). The separator byte is unchanged at runtime.
|
|
75
|
+
*/
|
|
76
|
+
const KEY_SEP = String.fromCharCode(0);
|
|
77
|
+
|
|
53
78
|
const MS_PER_DAY = 86_400_000;
|
|
54
79
|
const DEFAULT_GRACE_DAYS = 14;
|
|
55
80
|
|
|
81
|
+
/**
|
|
82
|
+
* Canonical, key-order-independent JSON for a record — the identity of LAST
|
|
83
|
+
* RESORT, used only when a record carries no usable `id`.
|
|
84
|
+
*
|
|
85
|
+
* `JSON.stringify` is not sufficient here: two reads of the same record can
|
|
86
|
+
* differ in key ORDER (the store and the `--entries` sidecar are separate
|
|
87
|
+
* files, written by separate passes), and an order-sensitive fingerprint would
|
|
88
|
+
* read those as two different records.
|
|
89
|
+
*
|
|
90
|
+
* @param {unknown} value
|
|
91
|
+
* @returns {string}
|
|
92
|
+
*/
|
|
93
|
+
function stableStringify(value) {
|
|
94
|
+
if (Array.isArray(value)) return `[${value.map(stableStringify).join(',')}]`;
|
|
95
|
+
if (value !== null && typeof value === 'object') {
|
|
96
|
+
return `{${Object.keys(value)
|
|
97
|
+
.sort()
|
|
98
|
+
.map((k) => `${JSON.stringify(k)}:${stableStringify(value[k])}`)
|
|
99
|
+
.join(',')}}`;
|
|
100
|
+
}
|
|
101
|
+
// `undefined` has no JSON form — normalize it to the same token as null so a
|
|
102
|
+
// present-but-undefined key cannot make a record unfingerprintable.
|
|
103
|
+
return JSON.stringify(value) ?? 'null';
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
/**
|
|
107
|
+
* Reconciliation identity for {@link pruneLearnings} step (3): the `id` when
|
|
108
|
+
* the record carries a usable one, else a content fingerprint.
|
|
109
|
+
*
|
|
110
|
+
* The fallback is load-bearing, not a nicety. `id` IS a required schema field,
|
|
111
|
+
* but `validateLearning` only checks key PRESENCE — `id: ''`, `id: null` and
|
|
112
|
+
* `id: undefined` all pass it, and `readLearnings()` never rejects anything —
|
|
113
|
+
* so an id-less record can and does reach disk. Such a record can never appear
|
|
114
|
+
* in `keep` (which is built from `next` alone), so leaving it unreconciled
|
|
115
|
+
* meant the store rewrite dropped it with no archive line: gone from BOTH
|
|
116
|
+
* places, which is the exact #1017 data loss this module exists to prevent.
|
|
117
|
+
*
|
|
118
|
+
* @param {object} entry
|
|
119
|
+
* @returns {string}
|
|
120
|
+
*/
|
|
121
|
+
function reconcileKey(entry) {
|
|
122
|
+
const id = entry?.id;
|
|
123
|
+
return typeof id === 'string' && id.length > 0
|
|
124
|
+
? `id${KEY_SEP}${id}`
|
|
125
|
+
: `sig${KEY_SEP}${stableStringify(entry)}`;
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
/**
|
|
129
|
+
* Closed vocabulary for `_archive_reason` (#1017). Every record that leaves
|
|
130
|
+
* the active store carries exactly one of these, so the archive answers WHY a
|
|
131
|
+
* `learning-id` stopped resolving — not merely THAT it did.
|
|
132
|
+
*
|
|
133
|
+
* expired — `expires_at` elapsed (past the grace window, for the sweep).
|
|
134
|
+
* pruned — dropped by a decision: confidence decayed to <= 0, or the
|
|
135
|
+
* caller removed it from the next store generation.
|
|
136
|
+
* superseded — a duplicate `(type, subject)` lost to a higher-confidence
|
|
137
|
+
* twin. Carries `_superseded_by: <winning id>`.
|
|
138
|
+
* merged — folded into another record. Carries `_merged_into: <id>`.
|
|
139
|
+
*
|
|
140
|
+
* `superseded` / `merged` REQUIRE their tombstone pointer — an archive record
|
|
141
|
+
* that says "this was replaced" without naming the replacement recreates the
|
|
142
|
+
* dangling-pointer defect one level down. {@link normalizeArchiveVerdict}
|
|
143
|
+
* fails closed on a missing pointer, before anything touches disk.
|
|
144
|
+
*/
|
|
145
|
+
export const ARCHIVE_REASONS = Object.freeze(['expired', 'pruned', 'superseded', 'merged']);
|
|
146
|
+
|
|
147
|
+
/** Reason -> the tombstone field that reason MUST carry. */
|
|
148
|
+
const TOMBSTONE_FIELD = Object.freeze({
|
|
149
|
+
superseded: '_superseded_by',
|
|
150
|
+
merged: '_merged_into',
|
|
151
|
+
});
|
|
152
|
+
|
|
153
|
+
/** Reason -> the camelCase caller-facing alias for its tombstone pointer. */
|
|
154
|
+
const TOMBSTONE_ALIAS = Object.freeze({
|
|
155
|
+
superseded: 'supersededBy',
|
|
156
|
+
merged: 'mergedInto',
|
|
157
|
+
});
|
|
158
|
+
|
|
159
|
+
/**
|
|
160
|
+
* Normalize an archive verdict into `{reason, tombstone}` and fail closed on
|
|
161
|
+
* an unknown reason or a missing tombstone pointer.
|
|
162
|
+
*
|
|
163
|
+
* Accepts a bare reason string (`'pruned'`) or an object
|
|
164
|
+
* (`{reason: 'superseded', supersededBy: '<id>'}`; the underscore form
|
|
165
|
+
* `_superseded_by` is accepted too, so a caller may pass the on-disk shape).
|
|
166
|
+
*
|
|
167
|
+
* Callers MUST normalize while building the archive batch — i.e. BEFORE the
|
|
168
|
+
* first disk write — so a bad verdict aborts with the store and the archive
|
|
169
|
+
* both untouched.
|
|
170
|
+
*
|
|
171
|
+
* @param {string|{reason: string, supersededBy?: string, mergedInto?: string}} raw
|
|
172
|
+
* @returns {{reason: string, tombstone: object|null}}
|
|
173
|
+
*/
|
|
174
|
+
function normalizeArchiveVerdict(raw) {
|
|
175
|
+
const v = typeof raw === 'string' ? { reason: raw } : (raw ?? {});
|
|
176
|
+
const { reason } = v;
|
|
177
|
+
if (!ARCHIVE_REASONS.includes(reason)) {
|
|
178
|
+
throw new Error(
|
|
179
|
+
`archive reason must be one of ${ARCHIVE_REASONS.join('|')}, got: ${JSON.stringify(reason)}`
|
|
180
|
+
);
|
|
181
|
+
}
|
|
182
|
+
const field = TOMBSTONE_FIELD[reason];
|
|
183
|
+
if (!field) return { reason, tombstone: null };
|
|
184
|
+
const target = v[TOMBSTONE_ALIAS[reason]] ?? v[field];
|
|
185
|
+
if (typeof target !== 'string' || target.length === 0) {
|
|
186
|
+
throw new Error(`archive reason "${reason}" requires a non-empty ${field} pointer`);
|
|
187
|
+
}
|
|
188
|
+
return { reason, tombstone: { [field]: target } };
|
|
189
|
+
}
|
|
190
|
+
|
|
191
|
+
/** Stamp an archive record with its provenance tail. */
|
|
192
|
+
function tagArchiveRecord(entry, { reason, tombstone }, nowIso) {
|
|
193
|
+
return { ...entry, _archived_at: nowIso, _archive_reason: reason, ...(tombstone ?? {}) };
|
|
194
|
+
}
|
|
195
|
+
|
|
196
|
+
/** `{expired: 2, superseded: 1}` roll-up over a normalized archive batch. */
|
|
197
|
+
function countByReason(batch) {
|
|
198
|
+
const out = {};
|
|
199
|
+
for (const { verdict } of batch) {
|
|
200
|
+
out[verdict.reason] = (out[verdict.reason] ?? 0) + 1;
|
|
201
|
+
}
|
|
202
|
+
return out;
|
|
203
|
+
}
|
|
204
|
+
|
|
205
|
+
/** Resolve the `now` parameter (Date | epoch ms | undefined) to epoch ms. */
|
|
206
|
+
function resolveNowMs(now) {
|
|
207
|
+
if (now instanceof Date) return now.getTime();
|
|
208
|
+
if (typeof now === 'number') return now;
|
|
209
|
+
return Date.now();
|
|
210
|
+
}
|
|
211
|
+
|
|
212
|
+
/**
|
|
213
|
+
* THE shared write pipeline — the only place in this module that touches disk.
|
|
214
|
+
* Both public entry points funnel through it, so the crash-safe ordering, the
|
|
215
|
+
* KEEP-batch probe, and the `.bak` snapshot cannot drift apart between the
|
|
216
|
+
* time-driven and the decision-driven caller (#1017).
|
|
217
|
+
*
|
|
218
|
+
* Order is load-bearing (see the module header): probe the KEEP batch with
|
|
219
|
+
* zero disk writes, THEN append the archive, THEN rewrite the store.
|
|
220
|
+
*
|
|
221
|
+
* @param {object} opts
|
|
222
|
+
* @param {string} opts.filePath
|
|
223
|
+
* @param {string} opts.archivePath
|
|
224
|
+
* @param {object[]} opts.keep — the next store generation
|
|
225
|
+
* @param {{entry: object, verdict: {reason: string, tombstone: object|null}}[]} opts.archiveBatch
|
|
226
|
+
* @param {number} opts.nowMs
|
|
227
|
+
* @param {boolean} opts.dryRun
|
|
228
|
+
* @returns {Promise<{kept: number, archived: number, byReason: Record<string, number>}>}
|
|
229
|
+
*/
|
|
230
|
+
async function archiveThenRewrite({ filePath, archivePath, keep, archiveBatch, nowMs, dryRun }) {
|
|
231
|
+
const byReason = countByReason(archiveBatch);
|
|
232
|
+
if (dryRun) {
|
|
233
|
+
return { kept: keep.length, archived: archiveBatch.length, byReason };
|
|
234
|
+
}
|
|
235
|
+
|
|
236
|
+
// Validate the KEEP batch BEFORE the archive append (dry-run rewrite: throws
|
|
237
|
+
// on a bad record, writes nothing). Without this, an invalid-but-parseable
|
|
238
|
+
// KEEP record survives readLearnings() but blows up rewriteLearnings() later
|
|
239
|
+
// — AFTER the archive append already landed. On a repeated --apply run that
|
|
240
|
+
// duplicates the archive append every time (never de-duplicated on read)
|
|
241
|
+
// while the store is never actually pruned. Validating first means a bad
|
|
242
|
+
// record throws here, before anything on disk has been touched.
|
|
243
|
+
await rewriteLearnings(filePath, keep, { dryRun: true });
|
|
244
|
+
|
|
245
|
+
// Crash-safe ordering: archive append FIRST. A duplicate re-append after a
|
|
246
|
+
// crash is harmless (append-only, never de-duplicated on read); the reverse
|
|
247
|
+
// order risks losing an archive-worthy entry if the process dies after the
|
|
248
|
+
// store rewrite but before the archive write.
|
|
249
|
+
if (archiveBatch.length > 0) {
|
|
250
|
+
const nowIso = new Date(nowMs).toISOString();
|
|
251
|
+
const body =
|
|
252
|
+
archiveBatch
|
|
253
|
+
.map(({ entry, verdict }) => JSON.stringify(tagArchiveRecord(entry, verdict, nowIso)))
|
|
254
|
+
.join('\n') + '\n';
|
|
255
|
+
await mkdir(path.dirname(archivePath), { recursive: true });
|
|
256
|
+
await appendFile(archivePath, body, 'utf8');
|
|
257
|
+
}
|
|
258
|
+
|
|
259
|
+
// Re-validates (cheap, idempotent) and snapshots a `.bak-<ISO>` backup of
|
|
260
|
+
// the current store before the atomic rename (io.mjs #721). The KEEP batch
|
|
261
|
+
// already passed the dry-run probe above, so this call cannot throw here.
|
|
262
|
+
await rewriteLearnings(filePath, keep, { dryRun: false });
|
|
263
|
+
|
|
264
|
+
return { kept: keep.length, archived: archiveBatch.length, byReason };
|
|
265
|
+
}
|
|
266
|
+
|
|
56
267
|
/**
|
|
57
268
|
* Sweep expired learnings out of `filePath` into `archivePath`.
|
|
58
269
|
*
|
|
@@ -91,15 +302,15 @@ export async function sweepExpiredLearnings({
|
|
|
91
302
|
return { scanned: 0, kept: 0, archived: 0, dryRun, archivePath };
|
|
92
303
|
}
|
|
93
304
|
|
|
94
|
-
const nowMs =
|
|
95
|
-
now instanceof Date ? now.getTime() : typeof now === 'number' ? now : Date.now();
|
|
305
|
+
const nowMs = resolveNowMs(now);
|
|
96
306
|
const graceMs =
|
|
97
307
|
(Number.isFinite(graceDays) && graceDays >= 0 ? graceDays : DEFAULT_GRACE_DAYS) * MS_PER_DAY;
|
|
98
308
|
|
|
99
309
|
const { entries } = await readLearnings(filePath);
|
|
100
310
|
|
|
101
311
|
const keep = [];
|
|
102
|
-
const
|
|
312
|
+
const archiveBatch = [];
|
|
313
|
+
const expiredVerdict = normalizeArchiveVerdict('expired');
|
|
103
314
|
|
|
104
315
|
for (const entry of entries) {
|
|
105
316
|
const expiresMs = typeof entry?.expires_at === 'string' ? Date.parse(entry.expires_at) : NaN;
|
|
@@ -110,55 +321,199 @@ export async function sweepExpiredLearnings({
|
|
|
110
321
|
}
|
|
111
322
|
// Expired — but does it clear the grace window?
|
|
112
323
|
if (expiresMs + graceMs < nowMs) {
|
|
113
|
-
|
|
324
|
+
archiveBatch.push({ entry, verdict: expiredVerdict });
|
|
114
325
|
} else {
|
|
115
326
|
keep.push(entry);
|
|
116
327
|
}
|
|
117
328
|
}
|
|
118
329
|
|
|
119
|
-
|
|
120
|
-
|
|
121
|
-
|
|
122
|
-
|
|
123
|
-
|
|
124
|
-
|
|
125
|
-
|
|
126
|
-
|
|
330
|
+
const { kept, archived } = await archiveThenRewrite({
|
|
331
|
+
filePath,
|
|
332
|
+
archivePath,
|
|
333
|
+
keep,
|
|
334
|
+
archiveBatch,
|
|
335
|
+
nowMs,
|
|
336
|
+
dryRun,
|
|
337
|
+
});
|
|
338
|
+
|
|
339
|
+
// Result shape is pinned by the CLI (`scripts/sweep-expired-learnings.mjs`)
|
|
340
|
+
// and its JSON contract — deliberately WITHOUT `byReason`, which only the
|
|
341
|
+
// multi-reason prune path below can populate meaningfully.
|
|
342
|
+
return { scanned: entries.length, kept, archived, dryRun, archivePath };
|
|
343
|
+
}
|
|
344
|
+
|
|
345
|
+
/**
|
|
346
|
+
* Decision-driven sibling of {@link sweepExpiredLearnings} — the archive-safe
|
|
347
|
+
* replacement for `/evolve`'s prune + consolidate + rewrite steps (#1017).
|
|
348
|
+
*
|
|
349
|
+
* Same pipeline, different predicate. Every record that leaves the store is
|
|
350
|
+
* appended to the archive with a reason from {@link ARCHIVE_REASONS} first;
|
|
351
|
+
* the store rewrite goes through `rewriteLearnings()` (validation + `.bak`
|
|
352
|
+
* snapshot + atomic rename), never a `>` redirect.
|
|
353
|
+
*
|
|
354
|
+
* Three ways a record leaves the store, each routed to its own reason:
|
|
355
|
+
* 1. **prune predicate** — `expires_at` elapsed (`expired`) or
|
|
356
|
+
* `confidence <= 0` (`pruned`). This is `/evolve` SKILL.md Step 3.5(6) /
|
|
357
|
+
* Step 4.4(3) verbatim, minus the deletion.
|
|
358
|
+
* 2. **consolidation** — duplicate `(type, subject)` with a NON-EMPTY
|
|
359
|
+
* subject: the highest-confidence record wins, the losers are archived
|
|
360
|
+
* `superseded` with `_superseded_by: <winner id>`. Null/empty-subject
|
|
361
|
+
* records are NEVER collapsed (issue #284) — each is keyed by its `id`.
|
|
362
|
+
* 3. **caller drop** — a record present on disk but absent from `entries`
|
|
363
|
+
* (the caller's next store generation). This is the mechanical guarantee
|
|
364
|
+
* that makes the prose-driven caller safe by construction: whatever an
|
|
365
|
+
* LLM-authored next generation omits is tombstoned automatically rather
|
|
366
|
+
* than silently deleted. Reconciliation is by `id`, falling back to a
|
|
367
|
+
* content fingerprint for a record with no usable one, and it COUNTS
|
|
368
|
+
* rather than tests membership — see {@link reconcileKey}. Deliberate
|
|
369
|
+
* ceiling: an id-less record the caller MUTATED (rather than dropped)
|
|
370
|
+
* fingerprints as a drop, so it is tombstoned while the mutated copy
|
|
371
|
+
* stays in the store — a duplicate archive line, never a loss. Revisit if
|
|
372
|
+
* a caller ever needs to mutate id-less records in bulk; the fix is to
|
|
373
|
+
* stamp an `id` at the read funnel, not to loosen this loop.
|
|
374
|
+
*
|
|
375
|
+
* **No `graceDays` here, by design.** The grace window exists for two reasons
|
|
376
|
+
* (see the module header): TTL edge-noise, and "a window for /evolve's
|
|
377
|
+
* confidence-reinforcement pass to re-stamp `expires_at` before the entry is
|
|
378
|
+
* moved out from under it". On THIS path reason 2 is structurally already
|
|
379
|
+
* satisfied: `/evolve` applies its reinforcement (+0.15 and a fresh
|
|
380
|
+
* `expires_at`) in the SAME run, strictly before the prune step — so an entry
|
|
381
|
+
* still expired at prune time is one the analyzer just declined to reinforce,
|
|
382
|
+
* not one that is about to be. Carrying a grace window here would instead
|
|
383
|
+
* CHANGE `/evolve`'s documented prune semantics (entries the operator expects
|
|
384
|
+
* gone would linger). The ping-pong hazard the grace window guards against on
|
|
385
|
+
* the sweep is also cheap here: this path archives rather than deletes, so a
|
|
386
|
+
* later re-derivation costs a duplicate archive line, not data.
|
|
387
|
+
*
|
|
388
|
+
* @param {object} opts
|
|
389
|
+
* @param {string} opts.filePath - active learnings.jsonl
|
|
390
|
+
* @param {string} opts.archivePath - append-only archive sidecar
|
|
391
|
+
* @param {object[]} [opts.entries] - the caller's next store generation (post
|
|
392
|
+
* confidence-update / append). Defaults to the on-disk set, i.e. a pure
|
|
393
|
+
* prune+consolidate pass.
|
|
394
|
+
* @param {Date|number} [opts.now] - injectable clock
|
|
395
|
+
* @param {boolean} [opts.dryRun=true]
|
|
396
|
+
* @param {string|Function} [opts.dropReason='pruned'] - verdict for case 3
|
|
397
|
+
* above: a reason string, or `(entry) => reason|{reason, supersededBy, mergedInto}`
|
|
398
|
+
* for per-record routing (this is the seam a later `merged` producer uses).
|
|
399
|
+
* @returns {Promise<{scanned: number, kept: number, archived: number,
|
|
400
|
+
* byReason: Record<string, number>, dryRun: boolean, archivePath: string}>}
|
|
401
|
+
*/
|
|
402
|
+
export async function pruneLearnings({
|
|
403
|
+
filePath,
|
|
404
|
+
archivePath,
|
|
405
|
+
entries,
|
|
406
|
+
now,
|
|
407
|
+
dryRun = true,
|
|
408
|
+
dropReason = 'pruned',
|
|
409
|
+
} = {}) {
|
|
410
|
+
if (typeof filePath !== 'string' || filePath.length === 0) {
|
|
411
|
+
throw new Error('pruneLearnings: filePath is required');
|
|
412
|
+
}
|
|
413
|
+
if (typeof archivePath !== 'string' || archivePath.length === 0) {
|
|
414
|
+
throw new Error('pruneLearnings: archivePath is required');
|
|
415
|
+
}
|
|
416
|
+
if (entries !== undefined && !Array.isArray(entries)) {
|
|
417
|
+
throw new Error('pruneLearnings: entries must be an array when provided');
|
|
127
418
|
}
|
|
128
419
|
|
|
129
|
-
|
|
130
|
-
|
|
131
|
-
|
|
132
|
-
// — AFTER the archive append already landed. On a repeated --apply run that
|
|
133
|
-
// duplicates the archive append every time (never de-duplicated on read)
|
|
134
|
-
// while the store is never actually pruned. Validating first means a bad
|
|
135
|
-
// record throws here, before anything on disk has been touched.
|
|
136
|
-
await rewriteLearnings(filePath, keep, { dryRun: true });
|
|
420
|
+
const nowMs = resolveNowMs(now);
|
|
421
|
+
const { entries: current } = await readLearnings(filePath);
|
|
422
|
+
const next = entries ?? current;
|
|
137
423
|
|
|
138
|
-
|
|
139
|
-
|
|
140
|
-
// order risks losing an archive-worthy entry if the process dies after the
|
|
141
|
-
// store rewrite but before the archive write.
|
|
142
|
-
if (archiveCandidates.length > 0) {
|
|
143
|
-
const nowIso = new Date(nowMs).toISOString();
|
|
144
|
-
const body =
|
|
145
|
-
archiveCandidates
|
|
146
|
-
.map((e) => JSON.stringify({ ...e, _archived_at: nowIso, _archive_reason: 'expired' }))
|
|
147
|
-
.join('\n') + '\n';
|
|
148
|
-
await mkdir(path.dirname(archivePath), { recursive: true });
|
|
149
|
-
await appendFile(archivePath, body, 'utf8');
|
|
424
|
+
if (next.length === 0 && current.length === 0) {
|
|
425
|
+
return { scanned: 0, kept: 0, archived: 0, byReason: {}, dryRun, archivePath };
|
|
150
426
|
}
|
|
151
427
|
|
|
152
|
-
|
|
153
|
-
|
|
154
|
-
|
|
155
|
-
|
|
428
|
+
const resolveDrop = (entry) =>
|
|
429
|
+
normalizeArchiveVerdict(typeof dropReason === 'function' ? dropReason(entry) : dropReason);
|
|
430
|
+
|
|
431
|
+
const archiveBatch = [];
|
|
432
|
+
const survivors = [];
|
|
433
|
+
|
|
434
|
+
// (1) prune predicate — SKILL.md Step 3.5(6) / 4.4(3), routed by cause.
|
|
435
|
+
for (const entry of next) {
|
|
436
|
+
const expiresMs = typeof entry?.expires_at === 'string' ? Date.parse(entry.expires_at) : NaN;
|
|
437
|
+
if (Number.isFinite(expiresMs) && expiresMs < nowMs) {
|
|
438
|
+
archiveBatch.push({ entry, verdict: normalizeArchiveVerdict('expired') });
|
|
439
|
+
continue;
|
|
440
|
+
}
|
|
441
|
+
if (typeof entry?.confidence === 'number' && entry.confidence <= 0) {
|
|
442
|
+
archiveBatch.push({ entry, verdict: normalizeArchiveVerdict('pruned') });
|
|
443
|
+
continue;
|
|
444
|
+
}
|
|
445
|
+
survivors.push(entry);
|
|
446
|
+
}
|
|
447
|
+
|
|
448
|
+
// (2) consolidation — highest confidence wins per (type, non-empty subject).
|
|
449
|
+
const winners = new Map();
|
|
450
|
+
for (const entry of survivors) {
|
|
451
|
+
const subject = typeof entry?.subject === 'string' ? entry.subject.trim() : '';
|
|
452
|
+
if (subject.length === 0) continue; // #284: never collapse null/empty subjects
|
|
453
|
+
const key = `${entry?.type}${KEY_SEP}${subject}`;
|
|
454
|
+
const incumbent = winners.get(key);
|
|
455
|
+
const score = typeof entry?.confidence === 'number' ? entry.confidence : -Infinity;
|
|
456
|
+
const incumbentScore =
|
|
457
|
+
incumbent && typeof incumbent.confidence === 'number' ? incumbent.confidence : -Infinity;
|
|
458
|
+
if (!incumbent || score > incumbentScore) winners.set(key, entry);
|
|
459
|
+
}
|
|
460
|
+
const keep = [];
|
|
461
|
+
for (const entry of survivors) {
|
|
462
|
+
const subject = typeof entry?.subject === 'string' ? entry.subject.trim() : '';
|
|
463
|
+
if (subject.length === 0) {
|
|
464
|
+
keep.push(entry);
|
|
465
|
+
continue;
|
|
466
|
+
}
|
|
467
|
+
const winner = winners.get(`${entry?.type}${KEY_SEP}${subject}`);
|
|
468
|
+
if (winner === entry) {
|
|
469
|
+
keep.push(entry);
|
|
470
|
+
continue;
|
|
471
|
+
}
|
|
472
|
+
archiveBatch.push({
|
|
473
|
+
entry,
|
|
474
|
+
verdict: normalizeArchiveVerdict({ reason: 'superseded', supersededBy: String(winner?.id) }),
|
|
475
|
+
});
|
|
476
|
+
}
|
|
156
477
|
|
|
157
|
-
|
|
158
|
-
|
|
159
|
-
|
|
160
|
-
|
|
161
|
-
|
|
478
|
+
// (3) caller drops — on disk but absent from the next generation.
|
|
479
|
+
//
|
|
480
|
+
// Identity is {@link reconcileKey}: the `id` when the record has a usable
|
|
481
|
+
// one, else a content fingerprint. NOTHING is skipped here — a record this
|
|
482
|
+
// loop passes over is a record the store rewrite deletes without a tombstone,
|
|
483
|
+
// because `keep` is built from `next` alone (loops 1-2) and an on-disk record
|
|
484
|
+
// absent from `next` has no other way in. This loop IS the rescue; an early
|
|
485
|
+
// `continue` in it is a silent delete, not a no-op.
|
|
486
|
+
//
|
|
487
|
+
// COUNTS, not membership: `next` may legitimately carry fewer copies of a key
|
|
488
|
+
// than the store does (identical id-less records; a duplicate `id` on disk
|
|
489
|
+
// reconciled against one entry in `next`). Set-membership would skip every
|
|
490
|
+
// copy while `keep` holds only one — dropping the surplus with no archive
|
|
491
|
+
// line, the same hole one level down. A multiset archives exactly the surplus.
|
|
492
|
+
const nextKeyCounts = new Map();
|
|
493
|
+
for (const entry of next) {
|
|
494
|
+
const key = reconcileKey(entry);
|
|
495
|
+
nextKeyCounts.set(key, (nextKeyCounts.get(key) ?? 0) + 1);
|
|
496
|
+
}
|
|
497
|
+
let dropped = 0;
|
|
498
|
+
for (const entry of current) {
|
|
499
|
+
const key = reconcileKey(entry);
|
|
500
|
+
const remaining = nextKeyCounts.get(key) ?? 0;
|
|
501
|
+
if (remaining > 0) {
|
|
502
|
+
nextKeyCounts.set(key, remaining - 1);
|
|
503
|
+
continue;
|
|
504
|
+
}
|
|
505
|
+
archiveBatch.push({ entry, verdict: resolveDrop(entry) });
|
|
506
|
+
dropped += 1;
|
|
507
|
+
}
|
|
508
|
+
|
|
509
|
+
const { kept, archived, byReason } = await archiveThenRewrite({
|
|
510
|
+
filePath,
|
|
162
511
|
archivePath,
|
|
163
|
-
|
|
512
|
+
keep,
|
|
513
|
+
archiveBatch,
|
|
514
|
+
nowMs,
|
|
515
|
+
dryRun,
|
|
516
|
+
});
|
|
517
|
+
|
|
518
|
+
return { scanned: next.length + dropped, kept, archived, byReason, dryRun, archivePath };
|
|
164
519
|
}
|