session-orchestrator 5.3.0 → 5.4.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/navigator/SKILL.md +22 -0
- package/.agents/skills/navigator/agents/openai.yaml +5 -0
- 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/navigator/SKILL.md +22 -0
- package/.codex-plugin/skills/navigator/agents/openai.yaml +5 -0
- package/.codex-plugin/skills/test-audit/SKILL.md +21 -0
- package/.codex-plugin/skills/test-audit/agents/openai.yaml +5 -0
- package/.cursor/commands/navigator.md +15 -0
- package/.cursor/commands/test-audit.md +14 -0
- package/.cursor/rules/060-evolve.mdc +27 -12
- package/.cursor/skills/claude-md-drift-check/SKILL.md +1 -1
- package/.cursor/skills/navigator/SKILL.md +13 -0
- 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 +70 -0
- package/README.md +14 -10
- package/docs/agent-authoring.md +2 -2
- package/docs/baseline.md +10 -1
- package/docs/ci-setup.md +57 -4
- package/docs/components.md +12 -12
- package/docs/events-schema.md +11 -8
- package/docs/persona-panel.md +5 -4
- package/docs/rule-authoring.md +27 -12
- package/docs/scope-collision-guard.md +26 -4
- package/docs/session-config-reference.md +55 -6
- package/docs/session-config-template.md +11 -2
- package/hooks/_lib/hook-import-set.json +27 -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 +53 -0
- package/hooks/on-stop.mjs +3 -1
- package/hooks/post-tool-batch-wave-signal.mjs +3 -1
- package/hooks/pre-bash-memory-propose-audit.mjs +52 -3
- package/hooks/pre-bash-sessions-ledger-guard.mjs +28 -2
- package/hooks/pre-edit-sessions-ledger-guard.mjs +162 -0
- package/hooks/pre-task-scope-disjoint.mjs +559 -72
- package/hooks/subagent-telemetry.mjs +96 -19
- package/package.json +3 -3
- package/pi/prompts/navigator.md +12 -0
- 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 +492 -0
- package/scripts/archive-closed-prds.mjs +2 -1
- package/scripts/autopilot.mjs +49 -53
- package/scripts/backfill-abandoned-sessions.mjs +1 -1
- package/scripts/baseline-archetypes.mjs +1 -1
- package/scripts/check-package-manager.mjs +62 -5
- package/scripts/cursor-install.mjs +1 -4
- package/scripts/emit-session.mjs +296 -2
- package/scripts/export-hw-learnings.mjs +22 -3
- package/scripts/fleet-checkin.mjs +147 -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 +10 -6
- package/scripts/lib/baseline-archetypes.mjs +41 -22
- 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-schema.mjs +13 -0
- package/scripts/lib/config.mjs +71 -10
- package/scripts/lib/events.mjs +21 -2
- package/scripts/lib/fleet-protocol.mjs +270 -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/instruction-budget-guard.mjs +69 -11
- package/scripts/lib/learnings/expiry-sweep.mjs +34 -3
- package/scripts/lib/learnings/io.mjs +154 -26
- package/scripts/lib/learnings/schema.mjs +163 -15
- package/scripts/lib/learnings/sizing-subject.mjs +2 -2
- 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 +104 -5
- package/scripts/lib/orphan-reaper/defaults.mjs +41 -0
- package/scripts/lib/orphan-reaper/ps-snapshot.mjs +181 -0
- package/scripts/lib/orphan-reaper/reaper-audit.mjs +291 -0
- package/scripts/lib/orphan-reaper/reaper-decide.mjs +395 -0
- package/scripts/lib/orphan-reaper/scan-throttle.mjs +92 -0
- package/scripts/lib/orphan-reaper/scan.mjs +506 -0
- package/scripts/lib/orphan-reaper.mjs +43 -1315
- package/scripts/lib/owner-yaml.mjs +104 -6
- package/scripts/lib/persona-panel/catalog-loader.mjs +1 -1
- package/scripts/lib/process-group.mjs +62 -11
- 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 +3 -4
- package/scripts/lib/session-record-repair.mjs +183 -26
- package/scripts/lib/session-registry.mjs +27 -2
- package/scripts/lib/session-schema/constants.mjs +30 -4
- package/scripts/lib/session-schema/validator.mjs +28 -1
- package/scripts/lib/session-token-rollup.mjs +245 -52
- package/scripts/lib/sessions-canonical.mjs +100 -9
- package/scripts/lib/sh-quote.mjs +29 -0
- package/scripts/lib/subagents-schema.mjs +30 -3
- package/scripts/lib/telemetry/pricing.mjs +118 -71
- package/scripts/lib/tests-src-ratio.mjs +140 -23
- package/scripts/lib/tmux-layout/layouts.mjs +1 -11
- 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 +87 -14
- package/scripts/lib/validate/check-unicode-safety.mjs +1 -0
- package/scripts/lib/validate/check-unwired-features.mjs +1 -1
- package/scripts/lib/validate-vendored-rules.mjs +206 -81
- package/scripts/lib/vault-mirror/telemetry.mjs +8 -1
- package/scripts/lib/vault-status/board-writer.mjs +48 -11
- package/scripts/lib/vault-status/narrative-mirror.mjs +40 -9
- 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/memory-propose.mjs +18 -5
- package/scripts/release-session-lock.mjs +2 -1
- package/scripts/release.mjs +10 -3
- package/scripts/repair-invalid-sessions.mjs +37 -4
- package/scripts/run-migrate-v2-cross-repo.mjs +158 -30
- package/scripts/run-quality-gate.mjs +323 -302
- package/scripts/site-numbers.mjs +163 -5
- package/scripts/sweep-expired-learnings.mjs +56 -5
- package/scripts/sweep-expired-rules.mjs +2 -1
- package/scripts/validate-plugin.mjs +7 -0
- package/scripts/vault-integration-watcher.mjs +2 -1
- package/scripts/vault-mirror.mjs +72 -46
- package/scripts/wave-scope-binding.mjs +2 -1
- package/skills/_shared/config-reading.md +3 -3
- package/skills/_shared/fleet-protocol.md +131 -0
- package/skills/_shared/state-ownership.md +5 -0
- package/skills/autopilot/SKILL.md +25 -11
- package/skills/bootstrap/SKILL.md +1 -1
- package/skills/bootstrap/_shared-template.md +1 -1
- package/skills/bootstrap/fast-template.md +1 -1
- package/skills/bootstrap/private-contract.md +2 -1
- package/skills/bootstrap/public-fallback.md +6 -3
- package/skills/bootstrap/references/bootstrap-retroactive-flow.md +2 -1
- package/skills/claude-md-drift-check/SKILL.md +10 -10
- package/skills/claude-md-drift-check/checker.mjs +26 -18
- package/skills/evolve/SKILL.md +2 -2
- package/skills/evolve/references/evolve-analyze-mode.md +10 -6
- package/skills/memory-cleanup/SKILL.md +1 -1
- package/skills/navigator/SKILL.md +193 -0
- package/skills/navigator/references/account-check.sh +87 -0
- package/skills/navigator/references/fleet-decisions.mjs +231 -0
- package/skills/navigator/references/slot-check.sh +114 -0
- 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 +22 -12
- package/skills/session-end/metrics-collection.md +5 -6
- package/skills/session-end/references/phase-3-documentation-updates.md +2 -2
- 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 +22 -60
- package/skills/session-plan/SKILL.md +0 -3
- package/skills/session-start/SKILL.md +6 -0
- package/skills/session-start/references/operations-contract.md +4 -0
- 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 +2 -2
- 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/wave-executor/SKILL.md +1 -1
- package/skills/wave-executor/references/wave-executor-state-init.md +3 -0
- package/skills/wave-executor/references/wave-loop-dispatch.md +3 -1
- package/skills/wave-executor/references/wave-loop-review.md +3 -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
|
@@ -6,7 +6,7 @@
|
|
|
6
6
|
// Required env: TYPECHECK_CMD, TEST_CMD
|
|
7
7
|
// Optional env: FILES (comma-separated), SESSION_START_REF
|
|
8
8
|
|
|
9
|
-
import { runCheck, resolveTestFiles, extractErrorLinesJson } from './gate-helpers.mjs';
|
|
9
|
+
import { runCheck, resolveTestFiles, extractErrorLinesJson, commandKillFields } from './gate-helpers.mjs';
|
|
10
10
|
|
|
11
11
|
const typecheckCmd = process.env.TYPECHECK_CMD;
|
|
12
12
|
const testCmd = process.env.TEST_CMD;
|
|
@@ -49,41 +49,32 @@ if (!testCmd) {
|
|
|
49
49
|
|
|
50
50
|
const start = Date.now();
|
|
51
51
|
|
|
52
|
-
|
|
52
|
+
// A skipped test run spawned nothing: `{ status: 'skip' }`, no kill keys.
|
|
53
|
+
let testResult = { status: 'skip' };
|
|
53
54
|
let errors = [];
|
|
54
55
|
|
|
55
56
|
// --- typecheck (always runs unless cmd is "skip") ---
|
|
56
57
|
const tcResult = await runCheck(typecheckCmd, CHECK_OPTS);
|
|
57
|
-
|
|
58
|
-
if (tcStatus === 'fail') {
|
|
58
|
+
if (tcResult.status === 'fail') {
|
|
59
59
|
errors = errors.concat(extractErrorLinesJson(tcResult.output, /error TS\d+/));
|
|
60
60
|
}
|
|
61
61
|
|
|
62
62
|
// --- test (scoped to changed/specified files) ---
|
|
63
|
-
if (testCmd
|
|
64
|
-
testStatus = 'skip';
|
|
65
|
-
} else {
|
|
63
|
+
if (testCmd !== 'skip') {
|
|
66
64
|
const testFiles = await resolveTestFiles(files, sessionStartRef);
|
|
67
65
|
if (testFiles.length > 0) {
|
|
68
66
|
const fileArgs = testFiles.join(' ');
|
|
69
|
-
|
|
70
|
-
testStatus = testResult.status;
|
|
71
|
-
if (testStatus === 'fail') {
|
|
72
|
-
const testErrors = extractErrorLinesJson(testResult.output, /(fail|error|FAIL)/i);
|
|
73
|
-
errors = errors.concat(testErrors);
|
|
74
|
-
}
|
|
67
|
+
testResult = await runCheck(`${testCmd} -- ${fileArgs}`, CHECK_OPTS);
|
|
75
68
|
} else if (!files && !sessionStartRef) {
|
|
76
69
|
// No FILES or SESSION_START_REF supplied: run the full test suite
|
|
77
|
-
|
|
78
|
-
testStatus = testResult.status;
|
|
79
|
-
if (testStatus === 'fail') {
|
|
80
|
-
const testErrors = extractErrorLinesJson(testResult.output, /(fail|error|FAIL)/i);
|
|
81
|
-
errors = errors.concat(testErrors);
|
|
82
|
-
}
|
|
70
|
+
testResult = await runCheck(testCmd, CHECK_OPTS);
|
|
83
71
|
} else {
|
|
84
72
|
// Files/ref supplied but no test files found — skip
|
|
85
73
|
process.stderr.write('warn: No test files found for incremental run; skipping tests\n');
|
|
86
|
-
|
|
74
|
+
}
|
|
75
|
+
if (testResult.status === 'fail') {
|
|
76
|
+
const testErrors = extractErrorLinesJson(testResult.output, /(fail|error|FAIL)/i);
|
|
77
|
+
errors = errors.concat(testErrors);
|
|
87
78
|
}
|
|
88
79
|
}
|
|
89
80
|
|
|
@@ -92,8 +83,18 @@ const duration_seconds = Math.round((Date.now() - start) / 1000);
|
|
|
92
83
|
const result = {
|
|
93
84
|
variant: 'incremental',
|
|
94
85
|
duration_seconds,
|
|
95
|
-
|
|
96
|
-
|
|
86
|
+
// Each command object also carries its INNER kill ladder (`timed_out`,
|
|
87
|
+
// `kill_signals`, `survivors` pids) when `runCheck` spawned it — omitted for
|
|
88
|
+
// skip/stub (#1457). `run-quality-gate.mjs` joins them into its event.
|
|
89
|
+
typecheck: { status: tcResult.status, ...commandKillFields(tcResult) },
|
|
90
|
+
test: { status: testResult.status, ...commandKillFields(testResult) },
|
|
91
|
+
// Stub map, same shape as gate-full: a command `runCheck` recognised as a
|
|
92
|
+
// stub (`echo …`/`true`) spawned nothing, so `joinGateKillFields` may count
|
|
93
|
+
// it as measured instead of declaring the whole inner ladder unknown.
|
|
94
|
+
stubbed: {
|
|
95
|
+
...(tcResult.stubbed ? { typecheck: tcResult.stubbed } : {}),
|
|
96
|
+
...(testResult.stubbed ? { test: testResult.stubbed } : {}),
|
|
97
|
+
},
|
|
97
98
|
errors,
|
|
98
99
|
};
|
|
99
100
|
|
|
@@ -9,7 +9,7 @@
|
|
|
9
9
|
* Part of v3.2 shell-helper port migration (issue #218 / #317).
|
|
10
10
|
*/
|
|
11
11
|
|
|
12
|
-
import { runCheck, csvToJsonArray } from './gate-helpers.mjs';
|
|
12
|
+
import { runCheck, csvToJsonArray, commandKillFields } from './gate-helpers.mjs';
|
|
13
13
|
|
|
14
14
|
const typecheckCmd = process.env.TYPECHECK_CMD;
|
|
15
15
|
const testCmd = process.env.TEST_CMD;
|
|
@@ -59,20 +59,29 @@ if (files.length === 0) {
|
|
|
59
59
|
|
|
60
60
|
// Typecheck runs on the whole project, not per-file
|
|
61
61
|
const tcResult = await runCheck(typecheckCmd, CHECK_OPTS);
|
|
62
|
-
const tcStatus = tcResult.status;
|
|
63
62
|
|
|
64
|
-
|
|
63
|
+
// A skipped test run spawned nothing: `{ status: 'skip' }`, no kill keys.
|
|
64
|
+
let testResult = { status: 'skip' };
|
|
65
65
|
|
|
66
66
|
if (testCmd !== 'skip' && files.length > 0) {
|
|
67
67
|
const fileArgs = files.join(' ');
|
|
68
|
-
|
|
69
|
-
testStatus = testResult.status;
|
|
68
|
+
testResult = await runCheck(`${testCmd} -- ${fileArgs}`, CHECK_OPTS);
|
|
70
69
|
}
|
|
71
70
|
|
|
72
71
|
const result = {
|
|
73
72
|
variant: 'per-file',
|
|
74
|
-
|
|
75
|
-
|
|
73
|
+
// Each command object also carries its INNER kill ladder (`timed_out`,
|
|
74
|
+
// `kill_signals`, `survivors` pids) when `runCheck` spawned it — omitted for
|
|
75
|
+
// skip/stub (#1457). `run-quality-gate.mjs` joins them into its event.
|
|
76
|
+
typecheck: { status: tcResult.status, ...commandKillFields(tcResult) },
|
|
77
|
+
test: { status: testResult.status, ...commandKillFields(testResult) },
|
|
78
|
+
// Stub map, same shape as gate-full: a command `runCheck` recognised as a
|
|
79
|
+
// stub (`echo …`/`true`) spawned nothing, so `joinGateKillFields` may count
|
|
80
|
+
// it as measured instead of declaring the whole inner ladder unknown.
|
|
81
|
+
stubbed: {
|
|
82
|
+
...(tcResult.stubbed ? { typecheck: tcResult.stubbed } : {}),
|
|
83
|
+
...(testResult.stubbed ? { test: testResult.stubbed } : {}),
|
|
84
|
+
},
|
|
76
85
|
files,
|
|
77
86
|
};
|
|
78
87
|
|
|
@@ -47,6 +47,16 @@
|
|
|
47
47
|
* tier-gate conditionals `applyGates` already implements (the `context`
|
|
48
48
|
* param below is the exact mechanism rule-loader exposes for this).
|
|
49
49
|
*
|
|
50
|
+
* #1449: that membership now matches what Claude Code actually loads. The
|
|
51
|
+
* loader used to skip a leading provenance comment before `---`, so a rule
|
|
52
|
+
* Claude Code loads always-on (it reads frontmatter only from line 1) counted
|
|
53
|
+
* here as path-scoped, and every consumer's always-on total was short by the
|
|
54
|
+
* vendored rules carrying that header. Such a file now counts as always-on —
|
|
55
|
+
* its displaced frontmatter as body bytes, since that is how Claude Code
|
|
56
|
+
* delivers it — and is listed in `defects` and on its own banner line. The
|
|
57
|
+
* list is report-only: it is not an `overBudget` term, so a defect never turns
|
|
58
|
+
* a budget verdict red.
|
|
59
|
+
*
|
|
50
60
|
* Cross-references:
|
|
51
61
|
* - "2026-06-20 instruction-budget audit" (#668 / #687; archived in the private Meta-Vault)
|
|
52
62
|
* - scripts/lib/rule-loader.mjs (always-on classification + tier-gate SSOT)
|
|
@@ -79,7 +89,11 @@
|
|
|
79
89
|
import { existsSync, readdirSync, readFileSync } from 'node:fs';
|
|
80
90
|
import { basename, join } from 'node:path';
|
|
81
91
|
import { scanEventsBackwards } from './events.mjs';
|
|
82
|
-
import {
|
|
92
|
+
import {
|
|
93
|
+
FRONTMATTER_NOT_AT_TOP,
|
|
94
|
+
loadApplicableRules,
|
|
95
|
+
parseGlobsFrontmatter,
|
|
96
|
+
} from './rule-loader.mjs';
|
|
83
97
|
|
|
84
98
|
/** Default directive ceiling (operator-chosen growth ratchet just above the ~457 baseline). */
|
|
85
99
|
export const DEFAULT_CEILING = 480;
|
|
@@ -1055,12 +1069,18 @@ function measureRuleCorpora(rulesDir) {
|
|
|
1055
1069
|
* bySurface: { coordinator: number, wave: number, always: number,
|
|
1056
1070
|
* generated: {bytes: number, files: number},
|
|
1057
1071
|
* pathScoped: {bytes: number, files: number} },
|
|
1072
|
+
* defects: string[],
|
|
1058
1073
|
* }}
|
|
1059
|
-
* perFile is sorted DESC by count.
|
|
1074
|
+
* perFile is sorted DESC by count. `defects` (#1449) lists, sorted by name,
|
|
1075
|
+
* the rule files whose frontmatter does not start on line 1 — Claude Code
|
|
1076
|
+
* ignores it, so they load always-on and are counted as such. It is
|
|
1077
|
+
* independent of `context` (such a file has no tier to gate on) and never
|
|
1078
|
+
* feeds `overBudget`. On missing/unreadable dir →
|
|
1060
1079
|
* { totalDirectives: 0, totalBytes: 0, perFile: [], ceiling, byteCeiling,
|
|
1061
1080
|
* overDirectiveBudget: false, overByteBudget: false, overBudget: false,
|
|
1062
1081
|
* severity: 'ok', bySurface: { coordinator: 0, wave: 0, always: 0,
|
|
1063
|
-
* generated: {bytes:0,files:0}, pathScoped: {bytes:0,files:0} }
|
|
1082
|
+
* generated: {bytes:0,files:0}, pathScoped: {bytes:0,files:0} },
|
|
1083
|
+
* defects: [] }.
|
|
1064
1084
|
*
|
|
1065
1085
|
* #931a verdict rule — `overBudget` is the OR of the two axes
|
|
1066
1086
|
* (`overDirectiveBudget || overByteBudget`), NOT a per-axis severity split:
|
|
@@ -1165,6 +1185,7 @@ export function computeInstructionBudget(opts = {}) {
|
|
|
1165
1185
|
generated: { bytes: 0, files: 0 },
|
|
1166
1186
|
pathScoped: { bytes: 0, files: 0 },
|
|
1167
1187
|
},
|
|
1188
|
+
defects: [],
|
|
1168
1189
|
};
|
|
1169
1190
|
|
|
1170
1191
|
let allEntries;
|
|
@@ -1256,6 +1277,13 @@ export function computeInstructionBudget(opts = {}) {
|
|
|
1256
1277
|
const overPathScopedBudget = bySurface.pathScoped.bytes > pathScopedByteCeiling;
|
|
1257
1278
|
const overBudget = overDirectiveBudget || overByteBudget || overGeneratedBudget || overPathScopedBudget;
|
|
1258
1279
|
|
|
1280
|
+
// #1449: read off the untiered list — a defective file carries no meta, so
|
|
1281
|
+
// no tier gate drops it and `alwaysOnAll` holds every one of them.
|
|
1282
|
+
const defects = alwaysOnAll
|
|
1283
|
+
.filter((e) => e.defect === FRONTMATTER_NOT_AT_TOP)
|
|
1284
|
+
.map((e) => basename(e.path))
|
|
1285
|
+
.sort((a, b) => a.localeCompare(b));
|
|
1286
|
+
|
|
1259
1287
|
return {
|
|
1260
1288
|
totalDirectives,
|
|
1261
1289
|
totalBytes,
|
|
@@ -1271,6 +1299,7 @@ export function computeInstructionBudget(opts = {}) {
|
|
|
1271
1299
|
overBudget,
|
|
1272
1300
|
severity: overBudget ? 'warn' : 'ok',
|
|
1273
1301
|
bySurface,
|
|
1302
|
+
defects,
|
|
1274
1303
|
};
|
|
1275
1304
|
}
|
|
1276
1305
|
|
|
@@ -1351,6 +1380,21 @@ function formatNearPathScopedLine(budget, repoRoot) {
|
|
|
1351
1380
|
);
|
|
1352
1381
|
}
|
|
1353
1382
|
|
|
1383
|
+
/**
|
|
1384
|
+
* The #1449 defect line: rule files whose frontmatter Claude Code never reads.
|
|
1385
|
+
* Names every file — the fix is per file (move the text above `---` below the
|
|
1386
|
+
* closing fence), so a count alone would send the operator hunting.
|
|
1387
|
+
*
|
|
1388
|
+
* @param {string[]} defects - basenames from `computeInstructionBudget().defects`
|
|
1389
|
+
* @returns {string}
|
|
1390
|
+
*/
|
|
1391
|
+
function formatFrontmatterDefectLine(defects) {
|
|
1392
|
+
return (
|
|
1393
|
+
`⚠ ${defects.length} rule(s) carry frontmatter that Claude Code ignores ` +
|
|
1394
|
+
`(text before the opening ---): ${defects.join(', ')} — they load always-on.`
|
|
1395
|
+
);
|
|
1396
|
+
}
|
|
1397
|
+
|
|
1354
1398
|
/**
|
|
1355
1399
|
* Banner wrapper — session-start Phase 4 convention.
|
|
1356
1400
|
*
|
|
@@ -1367,7 +1411,9 @@ function formatNearPathScopedLine(budget, repoRoot) {
|
|
|
1367
1411
|
*
|
|
1368
1412
|
* The message names WHICH axis breached (#931a) — a banner that only said
|
|
1369
1413
|
* "over budget" would leave the operator guessing whether to prune bullets or
|
|
1370
|
-
* prose. It stays
|
|
1414
|
+
* prose. It stays short because it renders at every session start: the breach
|
|
1415
|
+
* block is three lines, plus one line per extra finding (near-threshold,
|
|
1416
|
+
* frontmatter defect).
|
|
1371
1417
|
*
|
|
1372
1418
|
* @param {object} [opts] forwarded to computeInstructionBudget.
|
|
1373
1419
|
* @param {string} [opts.repoRoot] project root for the config read.
|
|
@@ -1382,8 +1428,10 @@ function formatNearPathScopedLine(budget, repoRoot) {
|
|
|
1382
1428
|
* raises it at {@link PATH_SCOPED_NEAR_THRESHOLD} of its ceiling, before the
|
|
1383
1429
|
* breach: that band used to be silent, and the ceiling then surfaced only
|
|
1384
1430
|
* inside `writeApprovedRules` — after the operator had approved rules it
|
|
1385
|
-
* refused to write.
|
|
1386
|
-
*
|
|
1431
|
+
* refused to write. Since #1449 a rule whose frontmatter is not on line 1
|
|
1432
|
+
* raises it too (one line naming the files), whether or not any axis is
|
|
1433
|
+
* close. The RETURN SHAPE is unchanged (`{severity:'warn', message}` |
|
|
1434
|
+
* null), so `session-start-probes.mjs` needs no new vocabulary.
|
|
1387
1435
|
*/
|
|
1388
1436
|
export function checkInstructionBudget(opts = {}) {
|
|
1389
1437
|
let cfg;
|
|
@@ -1455,18 +1503,27 @@ export function checkInstructionBudget(opts = {}) {
|
|
|
1455
1503
|
budget.pathScopedByteCeiling > 0 &&
|
|
1456
1504
|
budget.bySurface.pathScoped.bytes >= budget.pathScopedByteCeiling * PATH_SCOPED_NEAR_THRESHOLD;
|
|
1457
1505
|
|
|
1458
|
-
|
|
1506
|
+
// #1449 — reported on its own line, never folded into `overBudget`.
|
|
1507
|
+
const defectLine =
|
|
1508
|
+
budget.defects.length > 0 ? formatFrontmatterDefectLine(budget.defects) : null;
|
|
1509
|
+
|
|
1510
|
+
if (!budget.overBudget && !nearPathScoped && !defectLine) return null;
|
|
1459
1511
|
|
|
1460
1512
|
const nearLine = nearPathScoped ? formatNearPathScopedLine(budget, opts.repoRoot) : null;
|
|
1461
1513
|
|
|
1462
|
-
//
|
|
1463
|
-
// same remedy as the breach clause below (consolidate — never raise).
|
|
1514
|
+
// Nothing breached: one block per finding — the near-threshold line carries
|
|
1515
|
+
// the same remedy as the breach clause below (consolidate — never raise).
|
|
1464
1516
|
if (!budget.overBudget) {
|
|
1465
1517
|
return {
|
|
1466
1518
|
severity: 'warn',
|
|
1467
1519
|
message: [
|
|
1468
|
-
|
|
1469
|
-
|
|
1520
|
+
...(nearLine
|
|
1521
|
+
? [
|
|
1522
|
+
`⚠ ${nearLine}.`,
|
|
1523
|
+
' Consolidate generated rules per docs/rule-authoring.md § Consolidated rules; never raise the ceiling.',
|
|
1524
|
+
]
|
|
1525
|
+
: []),
|
|
1526
|
+
...(defectLine ? [defectLine] : []),
|
|
1470
1527
|
].join('\n'),
|
|
1471
1528
|
};
|
|
1472
1529
|
}
|
|
@@ -1523,6 +1580,7 @@ export function checkInstructionBudget(opts = {}) {
|
|
|
1523
1580
|
...(nearLine ? [` ${nearLine}.`] : []),
|
|
1524
1581
|
` Top files: ${top}`,
|
|
1525
1582
|
' See the instruction-budget audit (#687; archived in the private Meta-Vault) for the prune/demote list.',
|
|
1583
|
+
...(defectLine ? [defectLine] : []),
|
|
1526
1584
|
].join('\n');
|
|
1527
1585
|
|
|
1528
1586
|
return { severity: 'warn', message };
|
|
@@ -62,7 +62,7 @@
|
|
|
62
62
|
import { existsSync } from 'node:fs';
|
|
63
63
|
import { mkdir, appendFile } from 'node:fs/promises';
|
|
64
64
|
import path from 'node:path';
|
|
65
|
-
import { readLearnings, rewriteLearnings } from './io.mjs';
|
|
65
|
+
import { readLearnings, rewriteLearnings, withLearningsLock } from './io.mjs';
|
|
66
66
|
|
|
67
67
|
/**
|
|
68
68
|
* Composite-key separator for the in-memory consolidation Map.
|
|
@@ -284,7 +284,22 @@ async function archiveThenRewrite({ filePath, archivePath, keep, archiveBatch, n
|
|
|
284
284
|
* `filePath` and `archivePath` are required; a missing/invalid `filePath`
|
|
285
285
|
* throws a plain `Error` (programmer error, not a runtime data condition).
|
|
286
286
|
*/
|
|
287
|
-
export async function sweepExpiredLearnings({
|
|
287
|
+
export async function sweepExpiredLearnings(opts = {}) {
|
|
288
|
+
const { filePath, dryRun = true } = opts;
|
|
289
|
+
// The read → rewrite runs under the store lock (#1447 point 8). A run that
|
|
290
|
+
// cannot write takes no lock: a dry run, an absent store (zeroed shape, see
|
|
291
|
+
// below), or an unusable `filePath` (the body owns that error).
|
|
292
|
+
if (dryRun || typeof filePath !== 'string' || !existsSync(filePath)) {
|
|
293
|
+
return sweepExpiredLearningsUnlocked(opts);
|
|
294
|
+
}
|
|
295
|
+
return withLearningsLock(filePath, () => sweepExpiredLearningsUnlocked(opts));
|
|
296
|
+
}
|
|
297
|
+
|
|
298
|
+
/**
|
|
299
|
+
* Body of {@link sweepExpiredLearnings}; the caller holds the store lock or
|
|
300
|
+
* runs a pass that cannot write.
|
|
301
|
+
*/
|
|
302
|
+
async function sweepExpiredLearningsUnlocked({
|
|
288
303
|
filePath,
|
|
289
304
|
archivePath,
|
|
290
305
|
now,
|
|
@@ -399,7 +414,23 @@ export async function sweepExpiredLearnings({
|
|
|
399
414
|
* @returns {Promise<{scanned: number, kept: number, archived: number,
|
|
400
415
|
* byReason: Record<string, number>, dryRun: boolean, archivePath: string}>}
|
|
401
416
|
*/
|
|
402
|
-
export async function pruneLearnings({
|
|
417
|
+
export async function pruneLearnings(opts = {}) {
|
|
418
|
+
const { filePath, dryRun = true } = opts;
|
|
419
|
+
// The read → archive → rewrite runs under the store lock (#1447 point 8): an
|
|
420
|
+
// append landing after the read below would otherwise vanish in the rename
|
|
421
|
+
// with no archive line. A dry run writes nothing and takes no lock; an
|
|
422
|
+
// unusable `filePath` goes straight to the body, which owns that error.
|
|
423
|
+
if (dryRun || typeof filePath !== 'string' || filePath.length === 0) {
|
|
424
|
+
return pruneLearningsUnlocked(opts);
|
|
425
|
+
}
|
|
426
|
+
return withLearningsLock(filePath, () => pruneLearningsUnlocked(opts));
|
|
427
|
+
}
|
|
428
|
+
|
|
429
|
+
/**
|
|
430
|
+
* Body of {@link pruneLearnings}; the caller holds the store lock or runs a
|
|
431
|
+
* dry run.
|
|
432
|
+
*/
|
|
433
|
+
async function pruneLearningsUnlocked({
|
|
403
434
|
filePath,
|
|
404
435
|
archivePath,
|
|
405
436
|
entries,
|
|
@@ -8,14 +8,18 @@
|
|
|
8
8
|
import {
|
|
9
9
|
readFile,
|
|
10
10
|
writeFile,
|
|
11
|
+
appendFile,
|
|
11
12
|
mkdir,
|
|
12
13
|
rename,
|
|
13
14
|
copyFile,
|
|
14
15
|
readdir,
|
|
15
16
|
unlink,
|
|
16
17
|
} from 'node:fs/promises';
|
|
17
|
-
import { existsSync } from 'node:fs';
|
|
18
|
+
import { existsSync, realpathSync } from 'node:fs';
|
|
19
|
+
import { AsyncLocalStorage } from 'node:async_hooks';
|
|
20
|
+
import { randomUUID } from 'node:crypto';
|
|
18
21
|
import path from 'node:path';
|
|
22
|
+
import { withFileLock } from '../file-lock.mjs';
|
|
19
23
|
import {
|
|
20
24
|
validateLearning,
|
|
21
25
|
normalizeLearning,
|
|
@@ -24,6 +28,117 @@ import {
|
|
|
24
28
|
ValidationError,
|
|
25
29
|
} from './schema.mjs';
|
|
26
30
|
|
|
31
|
+
// ---------------------------------------------------------------------------
|
|
32
|
+
// Store lock (GitLab #1447 point 8)
|
|
33
|
+
// ---------------------------------------------------------------------------
|
|
34
|
+
|
|
35
|
+
/**
|
|
36
|
+
* How long a writer waits for the store lock before giving up — the
|
|
37
|
+
* `withFileLock` default. Every critical section here is one read plus one
|
|
38
|
+
* append or rewrite of a JSONL file (milliseconds), so ten seconds of
|
|
39
|
+
* contention means a stuck holder, not a slow one.
|
|
40
|
+
*/
|
|
41
|
+
export const LEARNINGS_LOCK_TIMEOUT_MS = 10000;
|
|
42
|
+
|
|
43
|
+
/**
|
|
44
|
+
* Thrown when the learnings store lock cannot be acquired. Every writer takes
|
|
45
|
+
* the lock BEFORE it touches the store, so this error always means nothing was
|
|
46
|
+
* written. The message is one line and names the lock file.
|
|
47
|
+
*/
|
|
48
|
+
export class LearningsLockError extends Error {
|
|
49
|
+
/**
|
|
50
|
+
* @param {string} lockPath
|
|
51
|
+
* @param {'timeout'|'fs-error'} reason
|
|
52
|
+
* @param {number} timeoutMs
|
|
53
|
+
* @param {string} [detail]
|
|
54
|
+
*/
|
|
55
|
+
constructor(lockPath, reason, timeoutMs, detail) {
|
|
56
|
+
const why =
|
|
57
|
+
reason === 'timeout'
|
|
58
|
+
? `held by another writer, waited ${timeoutMs} ms`
|
|
59
|
+
: `${reason}${detail ? `: ${detail}` : ''}`;
|
|
60
|
+
super(`learnings store lock ${lockPath} not acquired (${why}) — nothing written`);
|
|
61
|
+
this.name = 'LearningsLockError';
|
|
62
|
+
this.lockPath = lockPath;
|
|
63
|
+
this.reason = reason;
|
|
64
|
+
}
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
/**
|
|
68
|
+
* Store locks held by the CURRENT async call chain, so a nested writer inside a
|
|
69
|
+
* critical section (apply → prune → rewrite, sweep → rewrite, promote →
|
|
70
|
+
* rewrite) runs straight through instead of waiting on its own lock until the
|
|
71
|
+
* timeout. Keyed per async context, deliberately NOT per process: a concurrent
|
|
72
|
+
* call chain in the same process does not inherit the entry and waits like a
|
|
73
|
+
* second process would — which is exactly the interleaving the lock exists for.
|
|
74
|
+
* @type {AsyncLocalStorage<Map<string, {live: boolean}>>}
|
|
75
|
+
*/
|
|
76
|
+
const heldStoreLocks = new AsyncLocalStorage();
|
|
77
|
+
|
|
78
|
+
/**
|
|
79
|
+
* `<store>.lock` beside the store. The directory is canonicalised so two
|
|
80
|
+
* spellings of one store (`/var` vs `/private/var`, a realpath'd repo root as
|
|
81
|
+
* `memory-proposals/sink.mjs` passes it) contend on ONE lock file. Creates the
|
|
82
|
+
* directory: the lock file needs it, and every caller is about to write there.
|
|
83
|
+
*
|
|
84
|
+
* @param {string} filePath
|
|
85
|
+
* @returns {Promise<string>}
|
|
86
|
+
*/
|
|
87
|
+
async function resolveStoreLockPath(filePath) {
|
|
88
|
+
const dir = path.dirname(path.resolve(filePath));
|
|
89
|
+
await mkdir(dir, { recursive: true });
|
|
90
|
+
return path.join(realpathSync(dir), `${path.basename(filePath)}.lock`);
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
/**
|
|
94
|
+
* Run `fn` while holding the exclusive lock of the learnings store at
|
|
95
|
+
* `filePath`. Every read-modify-write of the store must run its WHOLE
|
|
96
|
+
* read → write sequence inside one call: a write outside the lock is silently
|
|
97
|
+
* lost when a rewriter that read before it renames its next generation over
|
|
98
|
+
* the file. `appendLearning()` and `rewriteLearnings()` take the lock
|
|
99
|
+
* themselves.
|
|
100
|
+
*
|
|
101
|
+
* Reentrant within one async call chain (see `heldStoreLocks`). Reentrancy
|
|
102
|
+
* prevents a deadlock, not a lost write: an `appendLearning()` issued INSIDE a
|
|
103
|
+
* read → rewrite section lands in the file that section is about to replace —
|
|
104
|
+
* fold such a record into the rewrite batch instead.
|
|
105
|
+
*
|
|
106
|
+
* Crash safety comes from `withFileLock`'s default `staleCheck: 'pid'`: a lock
|
|
107
|
+
* whose holder process is gone is overridden (WARN on stderr); a live holder or
|
|
108
|
+
* one on another host is waited for until `timeoutMs`.
|
|
109
|
+
*
|
|
110
|
+
* @template T
|
|
111
|
+
* @param {string} filePath — the learnings store (the lock file sits beside it)
|
|
112
|
+
* @param {() => (T | Promise<T>)} fn
|
|
113
|
+
* @param {{ timeoutMs?: number }} [opts]
|
|
114
|
+
* @returns {Promise<T>}
|
|
115
|
+
* @throws {LearningsLockError} when the lock is not acquired — `fn` never ran
|
|
116
|
+
*/
|
|
117
|
+
export async function withLearningsLock(filePath, fn, { timeoutMs = LEARNINGS_LOCK_TIMEOUT_MS } = {}) {
|
|
118
|
+
const lockPath = await resolveStoreLockPath(filePath);
|
|
119
|
+
const held = heldStoreLocks.getStore();
|
|
120
|
+
if (held?.get(lockPath)?.live === true) return fn();
|
|
121
|
+
|
|
122
|
+
const token = { live: true };
|
|
123
|
+
const scope = new Map(held ?? []);
|
|
124
|
+
scope.set(lockPath, token);
|
|
125
|
+
let result;
|
|
126
|
+
try {
|
|
127
|
+
result = await withFileLock(lockPath, () => heldStoreLocks.run(scope, fn), {
|
|
128
|
+
timeoutMs,
|
|
129
|
+
// A per-acquisition holder makes the owner-guarded release delete only
|
|
130
|
+
// THIS acquisition's lock, never a successor's (#1285).
|
|
131
|
+
holder: `learnings-store:${process.pid}:${randomUUID()}`,
|
|
132
|
+
});
|
|
133
|
+
} finally {
|
|
134
|
+
// A continuation `fn` left running past its own return must not keep
|
|
135
|
+
// treating the released lock as held.
|
|
136
|
+
token.live = false;
|
|
137
|
+
}
|
|
138
|
+
if (!result.ok) throw new LearningsLockError(lockPath, result.reason, timeoutMs, result.error);
|
|
139
|
+
return result.value;
|
|
140
|
+
}
|
|
141
|
+
|
|
27
142
|
// ---------------------------------------------------------------------------
|
|
28
143
|
// Pre-write self-validation seam (issue #662)
|
|
29
144
|
// ---------------------------------------------------------------------------
|
|
@@ -42,7 +157,7 @@ import {
|
|
|
42
157
|
* append touches disk, so a bad write can never reach the file.
|
|
43
158
|
*
|
|
44
159
|
* @param {object} validated — already validated+normalized learning entry
|
|
45
|
-
* @param {{ legacyTolerant?: boolean }} [opts] —
|
|
160
|
+
* @param {{ legacyTolerant?: boolean }} [opts] — EventDrop #386. When `true`,
|
|
46
161
|
* a field that was ALREADY ABSENT on `validated` (e.g. a legacy record with
|
|
47
162
|
* no `source_session`, tolerated by `readLearnings()`) stays tolerated after
|
|
48
163
|
* the round-trip too — the re-validation call below runs in the same
|
|
@@ -80,8 +195,9 @@ function serializeLearningLineChecked(validated, { legacyTolerant = false } = {}
|
|
|
80
195
|
// A key that existed on `validated` (present, even as `undefined`) but
|
|
81
196
|
// vanished from `reparsed` was DROPPED by JSON.stringify — the exact
|
|
82
197
|
// undefined/NaN/etc. corruption #662 exists to catch. A key that was
|
|
83
|
-
// never on `validated` in the first place (the #386
|
|
84
|
-
// cannot appear here, because we only iterate
|
|
198
|
+
// never on `validated` in the first place (the EventDrop #386
|
|
199
|
+
// legacy-field case) cannot appear here, because we only iterate
|
|
200
|
+
// `validated`'s own keys.
|
|
85
201
|
const droppedKeys = Object.keys(validated).filter((k) => !(k in reparsed));
|
|
86
202
|
if (droppedKeys.length > 0) {
|
|
87
203
|
throw new ValidationError(
|
|
@@ -143,11 +259,18 @@ export async function readLearnings(filePath) {
|
|
|
143
259
|
* atomic on POSIX append. For very large insight/evidence fields that
|
|
144
260
|
* might exceed that boundary, use rewriteLearnings() instead.
|
|
145
261
|
*
|
|
262
|
+
* The append runs under the store lock ({@link withLearningsLock}): an append
|
|
263
|
+
* that landed between a rewriter's read and its rename was silently lost.
|
|
264
|
+
* Validation runs first, outside the lock — a rejected record never waits.
|
|
265
|
+
*
|
|
146
266
|
* @param {string} filePath
|
|
147
267
|
* @param {object} entry
|
|
268
|
+
* @param {{ lockTimeoutMs?: number }} [opts] — store-lock wait, default
|
|
269
|
+
* {@link LEARNINGS_LOCK_TIMEOUT_MS}
|
|
148
270
|
* @returns {Promise<object>} validated entry
|
|
271
|
+
* @throws {LearningsLockError} when the store lock is not acquired (nothing appended)
|
|
149
272
|
*/
|
|
150
|
-
export async function appendLearning(filePath, entry) {
|
|
273
|
+
export async function appendLearning(filePath, entry, { lockTimeoutMs } = {}) {
|
|
151
274
|
// Ensure created_at is set first — many writers omit it, and expires_at
|
|
152
275
|
// derivation depends on it. Use ISO 8601 UTC.
|
|
153
276
|
const createdAt =
|
|
@@ -173,9 +296,10 @@ export async function appendLearning(filePath, entry) {
|
|
|
173
296
|
// parses back AND re-validates before any append touches disk. Throws
|
|
174
297
|
// ValidationError on a non-round-tripping record — file is left untouched.
|
|
175
298
|
const line = serializeLearningLineChecked(validated);
|
|
176
|
-
|
|
177
|
-
|
|
178
|
-
|
|
299
|
+
// The lock path resolution creates the parent directory.
|
|
300
|
+
await withLearningsLock(filePath, () => appendFile(filePath, line, 'utf8'), {
|
|
301
|
+
timeoutMs: lockTimeoutMs,
|
|
302
|
+
});
|
|
179
303
|
return validated;
|
|
180
304
|
}
|
|
181
305
|
|
|
@@ -281,7 +405,7 @@ async function rotateBackups(dir, baseName, keep = BACKUP_KEEP) {
|
|
|
281
405
|
* - `backup` (default `true`): before the destructive rename, copy the current
|
|
282
406
|
* file to `${filePath}.bak-<ISO>`, then rotate to keep only the newest
|
|
283
407
|
* {@link BACKUP_KEEP}. Rotation is best-effort and never blocks the rewrite.
|
|
284
|
-
* - `legacyTolerant` (default `true`,
|
|
408
|
+
* - `legacyTolerant` (default `true`, EventDrop #386): this function is a
|
|
285
409
|
* ROUND-TRIP writer — its usual caller (`sweepExpiredLearnings` /
|
|
286
410
|
* `pruneLearnings` in `expiry-sweep.mjs`) reads the store with
|
|
287
411
|
* `readLearnings()` first, and that reader already tolerates a legacy
|
|
@@ -298,7 +422,7 @@ async function rotateBackups(dir, baseName, keep = BACKUP_KEEP) {
|
|
|
298
422
|
* round-trip (a value JSON.stringify drops or coerces, e.g. `undefined`/
|
|
299
423
|
* `NaN`) is still caught by the #662 checked serializer regardless of this
|
|
300
424
|
* flag — see {@link serializeLearningLineChecked}. Pass `false` to restore
|
|
301
|
-
* the
|
|
425
|
+
* the fully-strict behaviour from before EventDrop #386.
|
|
302
426
|
*
|
|
303
427
|
* @param {string} filePath
|
|
304
428
|
* @param {object[]} entries
|
|
@@ -331,24 +455,28 @@ export async function rewriteLearnings(
|
|
|
331
455
|
if (dryRun) return validated;
|
|
332
456
|
|
|
333
457
|
const body = lines.join('');
|
|
334
|
-
await mkdir(path.dirname(filePath), { recursive: true });
|
|
335
458
|
|
|
336
|
-
//
|
|
337
|
-
//
|
|
338
|
-
//
|
|
339
|
-
|
|
340
|
-
|
|
341
|
-
|
|
342
|
-
|
|
343
|
-
|
|
344
|
-
|
|
345
|
-
|
|
346
|
-
|
|
459
|
+
// Under the store lock (#1447 point 8) so no append lands between the backup
|
|
460
|
+
// and the rename. The CALLER's read must sit inside the same lock for a
|
|
461
|
+
// read-modify-write to be safe — this call joins it reentrantly.
|
|
462
|
+
await withLearningsLock(filePath, async () => {
|
|
463
|
+
// Backup-before-rewrite (#721): snapshot the current store to a timestamped
|
|
464
|
+
// sidecar BEFORE the destructive rename, then prune to keep-N. Only meaningful
|
|
465
|
+
// when the target already exists (a first-time write has nothing to lose).
|
|
466
|
+
if (backup && existsSync(filePath)) {
|
|
467
|
+
const ts = new Date().toISOString().replace(/[:.]/g, '-');
|
|
468
|
+
await copyFile(filePath, `${filePath}.bak-${ts}`);
|
|
469
|
+
try {
|
|
470
|
+
await rotateBackups(path.dirname(filePath), path.basename(filePath));
|
|
471
|
+
} catch {
|
|
472
|
+
// Rotation is best-effort — a stale/undeletable sibling must never abort
|
|
473
|
+
// the rewrite. The fresh backup above is already safely on disk.
|
|
474
|
+
}
|
|
347
475
|
}
|
|
348
|
-
}
|
|
349
476
|
|
|
350
|
-
|
|
351
|
-
|
|
352
|
-
|
|
477
|
+
const tmp = `${filePath}.tmp-${process.pid}-${Date.now()}`;
|
|
478
|
+
await writeFile(tmp, body, 'utf8');
|
|
479
|
+
await rename(tmp, filePath);
|
|
480
|
+
});
|
|
353
481
|
return validated;
|
|
354
482
|
}
|