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
|
@@ -8,14 +8,20 @@
|
|
|
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,
|
|
17
|
+
open,
|
|
16
18
|
} from 'node:fs/promises';
|
|
17
|
-
import { existsSync } from 'node:fs';
|
|
19
|
+
import { existsSync, realpathSync } from 'node:fs';
|
|
20
|
+
import { AsyncLocalStorage } from 'node:async_hooks';
|
|
21
|
+
import { randomUUID } from 'node:crypto';
|
|
18
22
|
import path from 'node:path';
|
|
23
|
+
import { withFileLock } from '../file-lock.mjs';
|
|
24
|
+
import { digestSha256 } from '../crypto-digest-utils.mjs';
|
|
19
25
|
import {
|
|
20
26
|
validateLearning,
|
|
21
27
|
normalizeLearning,
|
|
@@ -24,6 +30,117 @@ import {
|
|
|
24
30
|
ValidationError,
|
|
25
31
|
} from './schema.mjs';
|
|
26
32
|
|
|
33
|
+
// ---------------------------------------------------------------------------
|
|
34
|
+
// Store lock (GitLab #1447 point 8)
|
|
35
|
+
// ---------------------------------------------------------------------------
|
|
36
|
+
|
|
37
|
+
/**
|
|
38
|
+
* How long a writer waits for the store lock before giving up — the
|
|
39
|
+
* `withFileLock` default. Every critical section here is one read plus one
|
|
40
|
+
* append or rewrite of a JSONL file (milliseconds), so ten seconds of
|
|
41
|
+
* contention means a stuck holder, not a slow one.
|
|
42
|
+
*/
|
|
43
|
+
export const LEARNINGS_LOCK_TIMEOUT_MS = 10000;
|
|
44
|
+
|
|
45
|
+
/**
|
|
46
|
+
* Thrown when the learnings store lock cannot be acquired. Every writer takes
|
|
47
|
+
* the lock BEFORE it touches the store, so this error always means nothing was
|
|
48
|
+
* written. The message is one line and names the lock file.
|
|
49
|
+
*/
|
|
50
|
+
export class LearningsLockError extends Error {
|
|
51
|
+
/**
|
|
52
|
+
* @param {string} lockPath
|
|
53
|
+
* @param {'timeout'|'fs-error'} reason
|
|
54
|
+
* @param {number} timeoutMs
|
|
55
|
+
* @param {string} [detail]
|
|
56
|
+
*/
|
|
57
|
+
constructor(lockPath, reason, timeoutMs, detail) {
|
|
58
|
+
const why =
|
|
59
|
+
reason === 'timeout'
|
|
60
|
+
? `held by another writer, waited ${timeoutMs} ms`
|
|
61
|
+
: `${reason}${detail ? `: ${detail}` : ''}`;
|
|
62
|
+
super(`learnings store lock ${lockPath} not acquired (${why}) — nothing written`);
|
|
63
|
+
this.name = 'LearningsLockError';
|
|
64
|
+
this.lockPath = lockPath;
|
|
65
|
+
this.reason = reason;
|
|
66
|
+
}
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
/**
|
|
70
|
+
* Store locks held by the CURRENT async call chain, so a nested writer inside a
|
|
71
|
+
* critical section (apply → prune → rewrite, sweep → rewrite, promote →
|
|
72
|
+
* rewrite) runs straight through instead of waiting on its own lock until the
|
|
73
|
+
* timeout. Keyed per async context, deliberately NOT per process: a concurrent
|
|
74
|
+
* call chain in the same process does not inherit the entry and waits like a
|
|
75
|
+
* second process would — which is exactly the interleaving the lock exists for.
|
|
76
|
+
* @type {AsyncLocalStorage<Map<string, {live: boolean}>>}
|
|
77
|
+
*/
|
|
78
|
+
const heldStoreLocks = new AsyncLocalStorage();
|
|
79
|
+
|
|
80
|
+
/**
|
|
81
|
+
* `<store>.lock` beside the store. The directory is canonicalised so two
|
|
82
|
+
* spellings of one store (`/var` vs `/private/var`, a realpath'd repo root as
|
|
83
|
+
* `memory-proposals/sink.mjs` passes it) contend on ONE lock file. Creates the
|
|
84
|
+
* directory: the lock file needs it, and every caller is about to write there.
|
|
85
|
+
*
|
|
86
|
+
* @param {string} filePath
|
|
87
|
+
* @returns {Promise<string>}
|
|
88
|
+
*/
|
|
89
|
+
async function resolveStoreLockPath(filePath) {
|
|
90
|
+
const dir = path.dirname(path.resolve(filePath));
|
|
91
|
+
await mkdir(dir, { recursive: true });
|
|
92
|
+
return path.join(realpathSync(dir), `${path.basename(filePath)}.lock`);
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
/**
|
|
96
|
+
* Run `fn` while holding the exclusive lock of the learnings store at
|
|
97
|
+
* `filePath`. Every read-modify-write of the store must run its WHOLE
|
|
98
|
+
* read → write sequence inside one call: a write outside the lock is silently
|
|
99
|
+
* lost when a rewriter that read before it renames its next generation over
|
|
100
|
+
* the file. `appendLearning()` and `rewriteLearnings()` take the lock
|
|
101
|
+
* themselves.
|
|
102
|
+
*
|
|
103
|
+
* Reentrant within one async call chain (see `heldStoreLocks`). Reentrancy
|
|
104
|
+
* prevents a deadlock, not a lost write: an `appendLearning()` issued INSIDE a
|
|
105
|
+
* read → rewrite section lands in the file that section is about to replace —
|
|
106
|
+
* fold such a record into the rewrite batch instead.
|
|
107
|
+
*
|
|
108
|
+
* Crash safety comes from `withFileLock`'s default `staleCheck: 'pid'`: a lock
|
|
109
|
+
* whose holder process is gone is overridden (WARN on stderr); a live holder or
|
|
110
|
+
* one on another host is waited for until `timeoutMs`.
|
|
111
|
+
*
|
|
112
|
+
* @template T
|
|
113
|
+
* @param {string} filePath — the learnings store (the lock file sits beside it)
|
|
114
|
+
* @param {() => (T | Promise<T>)} fn
|
|
115
|
+
* @param {{ timeoutMs?: number }} [opts]
|
|
116
|
+
* @returns {Promise<T>}
|
|
117
|
+
* @throws {LearningsLockError} when the lock is not acquired — `fn` never ran
|
|
118
|
+
*/
|
|
119
|
+
export async function withLearningsLock(filePath, fn, { timeoutMs = LEARNINGS_LOCK_TIMEOUT_MS } = {}) {
|
|
120
|
+
const lockPath = await resolveStoreLockPath(filePath);
|
|
121
|
+
const held = heldStoreLocks.getStore();
|
|
122
|
+
if (held?.get(lockPath)?.live === true) return fn();
|
|
123
|
+
|
|
124
|
+
const token = { live: true };
|
|
125
|
+
const scope = new Map(held ?? []);
|
|
126
|
+
scope.set(lockPath, token);
|
|
127
|
+
let result;
|
|
128
|
+
try {
|
|
129
|
+
result = await withFileLock(lockPath, () => heldStoreLocks.run(scope, fn), {
|
|
130
|
+
timeoutMs,
|
|
131
|
+
// A per-acquisition holder makes the owner-guarded release delete only
|
|
132
|
+
// THIS acquisition's lock, never a successor's (#1285).
|
|
133
|
+
holder: `learnings-store:${process.pid}:${randomUUID()}`,
|
|
134
|
+
});
|
|
135
|
+
} finally {
|
|
136
|
+
// A continuation `fn` left running past its own return must not keep
|
|
137
|
+
// treating the released lock as held.
|
|
138
|
+
token.live = false;
|
|
139
|
+
}
|
|
140
|
+
if (!result.ok) throw new LearningsLockError(lockPath, result.reason, timeoutMs, result.error);
|
|
141
|
+
return result.value;
|
|
142
|
+
}
|
|
143
|
+
|
|
27
144
|
// ---------------------------------------------------------------------------
|
|
28
145
|
// Pre-write self-validation seam (issue #662)
|
|
29
146
|
// ---------------------------------------------------------------------------
|
|
@@ -42,7 +159,7 @@ import {
|
|
|
42
159
|
* append touches disk, so a bad write can never reach the file.
|
|
43
160
|
*
|
|
44
161
|
* @param {object} validated — already validated+normalized learning entry
|
|
45
|
-
* @param {{ legacyTolerant?: boolean }} [opts] —
|
|
162
|
+
* @param {{ legacyTolerant?: boolean }} [opts] — EventDrop #386. When `true`,
|
|
46
163
|
* a field that was ALREADY ABSENT on `validated` (e.g. a legacy record with
|
|
47
164
|
* no `source_session`, tolerated by `readLearnings()`) stays tolerated after
|
|
48
165
|
* the round-trip too — the re-validation call below runs in the same
|
|
@@ -80,8 +197,9 @@ function serializeLearningLineChecked(validated, { legacyTolerant = false } = {}
|
|
|
80
197
|
// A key that existed on `validated` (present, even as `undefined`) but
|
|
81
198
|
// vanished from `reparsed` was DROPPED by JSON.stringify — the exact
|
|
82
199
|
// 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
|
|
200
|
+
// never on `validated` in the first place (the EventDrop #386
|
|
201
|
+
// legacy-field case) cannot appear here, because we only iterate
|
|
202
|
+
// `validated`'s own keys.
|
|
85
203
|
const droppedKeys = Object.keys(validated).filter((k) => !(k in reparsed));
|
|
86
204
|
if (droppedKeys.length > 0) {
|
|
87
205
|
throw new ValidationError(
|
|
@@ -103,26 +221,69 @@ function serializeLearningLineChecked(validated, { legacyTolerant = false } = {}
|
|
|
103
221
|
/**
|
|
104
222
|
* Read all learnings from the given JSONL path. Returns normalized entries
|
|
105
223
|
* (missing extended fields are defaulted). Malformed lines are skipped with
|
|
106
|
-
* their raw text preserved in the result's `malformed` array
|
|
224
|
+
* their raw text preserved in the result's `malformed` array, and their
|
|
225
|
+
* 1-based line numbers in `malformedLineNumbers` (same order).
|
|
107
226
|
*
|
|
108
227
|
* @param {string} filePath — absolute or project-relative path to learnings.jsonl
|
|
109
|
-
* @returns {Promise<{entries: object[], malformed: string[]}>}
|
|
228
|
+
* @returns {Promise<{entries: object[], malformed: string[], malformedLineNumbers: number[]}>}
|
|
110
229
|
*/
|
|
111
230
|
export async function readLearnings(filePath) {
|
|
112
|
-
if (!existsSync(filePath)) return { entries: [], malformed: [] };
|
|
113
|
-
|
|
114
|
-
|
|
231
|
+
if (!existsSync(filePath)) return { entries: [], malformed: [], malformedLineNumbers: [] };
|
|
232
|
+
return parseLearningsText(await readFile(filePath, 'utf8'));
|
|
233
|
+
}
|
|
234
|
+
|
|
235
|
+
/**
|
|
236
|
+
* Read a learnings store together with its generation token (#1486), both
|
|
237
|
+
* from ONE read, so the token describes exactly the records returned.
|
|
238
|
+
*
|
|
239
|
+
* The token is `sha256:<hex>` over the file's full text: any append, rewrite
|
|
240
|
+
* or sweep changes it. An absent store reads as empty text — zero records
|
|
241
|
+
* either way, so the two states share a token.
|
|
242
|
+
*
|
|
243
|
+
* @param {string} filePath
|
|
244
|
+
* @returns {Promise<{entries: object[], malformed: string[], malformedLineNumbers: number[],
|
|
245
|
+
* generation: string}>}
|
|
246
|
+
*/
|
|
247
|
+
export async function readLearningsSnapshot(filePath) {
|
|
248
|
+
const raw = existsSync(filePath) ? await readFile(filePath, 'utf8') : '';
|
|
249
|
+
return { ...parseLearningsText(raw), generation: `sha256:${digestSha256(raw)}` };
|
|
250
|
+
}
|
|
251
|
+
|
|
252
|
+
/**
|
|
253
|
+
* Parse learnings JSONL text — the line parser behind {@link readLearnings},
|
|
254
|
+
* exported for callers that must split a header off the text first.
|
|
255
|
+
*
|
|
256
|
+
* A blank line — empty, whitespace-only, or a lone `\r` from a CRLF blank line
|
|
257
|
+
* — carries no record and is skipped, never counted as malformed: malformed
|
|
258
|
+
* lines are kept verbatim by every rewrite and WARNed on every close, so a
|
|
259
|
+
* stray blank would otherwise be reported forever (#1489).
|
|
260
|
+
*
|
|
261
|
+
* A line that parses but is not a JSON object (`null`, `42`, `[]`, `"x"`) is
|
|
262
|
+
* malformed too (#1500): `normalizeLearning` turned it into a record of
|
|
263
|
+
* defaults alone, and every rewrite wrote that invented record back.
|
|
264
|
+
*
|
|
265
|
+
* @param {string} raw
|
|
266
|
+
* @returns {{entries: object[], malformed: string[], malformedLineNumbers: number[]}}
|
|
267
|
+
* `malformedLineNumbers[i]` is the 1-based line of `malformed[i]` in `raw`
|
|
268
|
+
*/
|
|
269
|
+
export function parseLearningsText(raw) {
|
|
115
270
|
const entries = [];
|
|
116
271
|
const malformed = [];
|
|
117
|
-
|
|
272
|
+
const malformedLineNumbers = [];
|
|
273
|
+
raw.split('\n').forEach((line, i) => {
|
|
274
|
+
if (line.trim().length === 0) return;
|
|
118
275
|
try {
|
|
119
276
|
const parsed = JSON.parse(line);
|
|
277
|
+
if (parsed === null || typeof parsed !== 'object' || Array.isArray(parsed)) {
|
|
278
|
+
throw new TypeError('not a JSON object');
|
|
279
|
+
}
|
|
120
280
|
entries.push(normalizeLearning(parsed));
|
|
121
281
|
} catch {
|
|
122
282
|
malformed.push(line);
|
|
283
|
+
malformedLineNumbers.push(i + 1);
|
|
123
284
|
}
|
|
124
|
-
}
|
|
125
|
-
return { entries, malformed };
|
|
285
|
+
});
|
|
286
|
+
return { entries, malformed, malformedLineNumbers };
|
|
126
287
|
}
|
|
127
288
|
|
|
128
289
|
// ---------------------------------------------------------------------------
|
|
@@ -143,11 +304,23 @@ export async function readLearnings(filePath) {
|
|
|
143
304
|
* atomic on POSIX append. For very large insight/evidence fields that
|
|
144
305
|
* might exceed that boundary, use rewriteLearnings() instead.
|
|
145
306
|
*
|
|
307
|
+
* The append runs under the store lock ({@link withLearningsLock}): an append
|
|
308
|
+
* that landed between a rewriter's read and its rename was silently lost.
|
|
309
|
+
* Validation runs first, outside the lock — a rejected record never waits.
|
|
310
|
+
*
|
|
311
|
+
* A store whose last line is torn (a crash mid-append left no trailing
|
|
312
|
+
* newline) gets a `\n` written first, inside the same append: without it the
|
|
313
|
+
* new record fused onto the torn text into ONE unparseable line, and no reader
|
|
314
|
+
* ever saw the record (#1489).
|
|
315
|
+
*
|
|
146
316
|
* @param {string} filePath
|
|
147
317
|
* @param {object} entry
|
|
318
|
+
* @param {{ lockTimeoutMs?: number }} [opts] — store-lock wait, default
|
|
319
|
+
* {@link LEARNINGS_LOCK_TIMEOUT_MS}
|
|
148
320
|
* @returns {Promise<object>} validated entry
|
|
321
|
+
* @throws {LearningsLockError} when the store lock is not acquired (nothing appended)
|
|
149
322
|
*/
|
|
150
|
-
export async function appendLearning(filePath, entry) {
|
|
323
|
+
export async function appendLearning(filePath, entry, { lockTimeoutMs } = {}) {
|
|
151
324
|
// Ensure created_at is set first — many writers omit it, and expires_at
|
|
152
325
|
// derivation depends on it. Use ISO 8601 UTC.
|
|
153
326
|
const createdAt =
|
|
@@ -173,12 +346,46 @@ export async function appendLearning(filePath, entry) {
|
|
|
173
346
|
// parses back AND re-validates before any append touches disk. Throws
|
|
174
347
|
// ValidationError on a non-round-tripping record — file is left untouched.
|
|
175
348
|
const line = serializeLearningLineChecked(validated);
|
|
176
|
-
|
|
177
|
-
|
|
178
|
-
await
|
|
349
|
+
// The lock path resolution creates the parent directory. The torn-tail check
|
|
350
|
+
// reads inside the lock, so no other writer can change the tail in between.
|
|
351
|
+
await withLearningsLock(
|
|
352
|
+
filePath,
|
|
353
|
+
async () => {
|
|
354
|
+
const lead = (await endsWithoutNewline(filePath)) ? '\n' : '';
|
|
355
|
+
await appendFile(filePath, lead + line, 'utf8');
|
|
356
|
+
},
|
|
357
|
+
{ timeoutMs: lockTimeoutMs }
|
|
358
|
+
);
|
|
179
359
|
return validated;
|
|
180
360
|
}
|
|
181
361
|
|
|
362
|
+
/**
|
|
363
|
+
* True when `filePath` is a non-empty file whose last byte is not `\n`. An
|
|
364
|
+
* absent file is `false` (the append creates it). Reads one byte, so the cost
|
|
365
|
+
* does not grow with the store.
|
|
366
|
+
*
|
|
367
|
+
* @param {string} filePath
|
|
368
|
+
* @returns {Promise<boolean>}
|
|
369
|
+
*/
|
|
370
|
+
async function endsWithoutNewline(filePath) {
|
|
371
|
+
let fh;
|
|
372
|
+
try {
|
|
373
|
+
fh = await open(filePath, 'r');
|
|
374
|
+
} catch (err) {
|
|
375
|
+
if (err?.code === 'ENOENT') return false;
|
|
376
|
+
throw err;
|
|
377
|
+
}
|
|
378
|
+
try {
|
|
379
|
+
const { size } = await fh.stat();
|
|
380
|
+
if (size === 0) return false;
|
|
381
|
+
const buf = Buffer.alloc(1);
|
|
382
|
+
await fh.read(buf, 0, 1, size - 1);
|
|
383
|
+
return buf[0] !== 0x0a;
|
|
384
|
+
} finally {
|
|
385
|
+
await fh.close();
|
|
386
|
+
}
|
|
387
|
+
}
|
|
388
|
+
|
|
182
389
|
/**
|
|
183
390
|
* Number of `${basename}.bak-<ISO>` siblings retained after a backup rotation.
|
|
184
391
|
* The atomic rewrite is destructive on a gitignored store (no VCS restore), so
|
|
@@ -281,7 +488,7 @@ async function rotateBackups(dir, baseName, keep = BACKUP_KEEP) {
|
|
|
281
488
|
* - `backup` (default `true`): before the destructive rename, copy the current
|
|
282
489
|
* file to `${filePath}.bak-<ISO>`, then rotate to keep only the newest
|
|
283
490
|
* {@link BACKUP_KEEP}. Rotation is best-effort and never blocks the rewrite.
|
|
284
|
-
* - `legacyTolerant` (default `true`,
|
|
491
|
+
* - `legacyTolerant` (default `true`, EventDrop #386): this function is a
|
|
285
492
|
* ROUND-TRIP writer — its usual caller (`sweepExpiredLearnings` /
|
|
286
493
|
* `pruneLearnings` in `expiry-sweep.mjs`) reads the store with
|
|
287
494
|
* `readLearnings()` first, and that reader already tolerates a legacy
|
|
@@ -298,18 +505,36 @@ async function rotateBackups(dir, baseName, keep = BACKUP_KEEP) {
|
|
|
298
505
|
* round-trip (a value JSON.stringify drops or coerces, e.g. `undefined`/
|
|
299
506
|
* `NaN`) is still caught by the #662 checked serializer regardless of this
|
|
300
507
|
* flag — see {@link serializeLearningLineChecked}. Pass `false` to restore
|
|
301
|
-
* the
|
|
508
|
+
* the fully-strict behaviour from before EventDrop #386.
|
|
509
|
+
* - `malformedLines` (#1489): raw text of store lines that did not parse —
|
|
510
|
+
* the `malformed` array {@link readLearnings} returns. Written back
|
|
511
|
+
* verbatim, one per line, AFTER the validated records. A round-trip writer
|
|
512
|
+
* that rewrites from parsed entries alone deletes every such line with no
|
|
513
|
+
* archive record (only the keep-3 `.bak` still holds it). OMITTED (the
|
|
514
|
+
* default), the store's OWN malformed lines are re-read under the lock and
|
|
515
|
+
* kept — so a caller that forgets this option no longer deletes them
|
|
516
|
+
* (`export-hw-learnings` did). Pass an array to write exactly those lines;
|
|
517
|
+
* `[]` is the explicit opt-out that drops them. Each entry must be a
|
|
518
|
+
* non-empty string without a newline; anything else throws a TypeError
|
|
519
|
+
* before any disk access, dry run included.
|
|
302
520
|
*
|
|
303
521
|
* @param {string} filePath
|
|
304
522
|
* @param {object[]} entries
|
|
305
|
-
* @param {{dryRun?: boolean, backup?: boolean, legacyTolerant?: boolean}} [opts]
|
|
523
|
+
* @param {{dryRun?: boolean, backup?: boolean, legacyTolerant?: boolean, malformedLines?: string[]}} [opts]
|
|
306
524
|
* @returns {Promise<object[]>} validated entries (always returned, even dryRun)
|
|
307
525
|
*/
|
|
308
526
|
export async function rewriteLearnings(
|
|
309
527
|
filePath,
|
|
310
528
|
entries,
|
|
311
|
-
{ dryRun = false, backup = true, legacyTolerant = true } = {}
|
|
529
|
+
{ dryRun = false, backup = true, legacyTolerant = true, malformedLines } = {}
|
|
312
530
|
) {
|
|
531
|
+
if (
|
|
532
|
+
malformedLines !== undefined &&
|
|
533
|
+
(!Array.isArray(malformedLines) ||
|
|
534
|
+
malformedLines.some((l) => typeof l !== 'string' || l.length === 0 || l.includes('\n')))
|
|
535
|
+
) {
|
|
536
|
+
throw new TypeError('rewriteLearnings: malformedLines must be an array of non-empty single-line strings');
|
|
537
|
+
}
|
|
313
538
|
const validated = entries.map((e) =>
|
|
314
539
|
validateLearning(
|
|
315
540
|
{
|
|
@@ -330,25 +555,37 @@ export async function rewriteLearnings(
|
|
|
330
555
|
// rewrite, no backup — and hand the validated entries back to the caller.
|
|
331
556
|
if (dryRun) return validated;
|
|
332
557
|
|
|
333
|
-
|
|
334
|
-
|
|
558
|
+
// Under the store lock (#1447 point 8) so no append lands between the backup
|
|
559
|
+
// and the rename. The CALLER's read must sit inside the same lock for a
|
|
560
|
+
// read-modify-write to be safe — this call joins it reentrantly.
|
|
561
|
+
await withLearningsLock(filePath, async () => {
|
|
562
|
+
// Default: the store's own malformed lines as they stand right now, read
|
|
563
|
+
// under the lock that guards the rename below.
|
|
564
|
+
const kept =
|
|
565
|
+
malformedLines ??
|
|
566
|
+
(existsSync(filePath) ? parseLearningsText(await readFile(filePath, 'utf8')).malformed : []);
|
|
567
|
+
// Malformed lines last, each newline-terminated: a truncated final line
|
|
568
|
+
// otherwise fuses with the next append into one more unparseable line.
|
|
569
|
+
const body = lines.join('') + kept.map((l) => `${l}\n`).join('');
|
|
335
570
|
|
|
336
|
-
|
|
337
|
-
|
|
338
|
-
|
|
339
|
-
|
|
340
|
-
|
|
341
|
-
|
|
342
|
-
|
|
343
|
-
|
|
344
|
-
|
|
345
|
-
|
|
346
|
-
|
|
571
|
+
|
|
572
|
+
// Backup-before-rewrite (#721): snapshot the current store to a timestamped
|
|
573
|
+
// sidecar BEFORE the destructive rename, then prune to keep-N. Only meaningful
|
|
574
|
+
// when the target already exists (a first-time write has nothing to lose).
|
|
575
|
+
if (backup && existsSync(filePath)) {
|
|
576
|
+
const ts = new Date().toISOString().replace(/[:.]/g, '-');
|
|
577
|
+
await copyFile(filePath, `${filePath}.bak-${ts}`);
|
|
578
|
+
try {
|
|
579
|
+
await rotateBackups(path.dirname(filePath), path.basename(filePath));
|
|
580
|
+
} catch {
|
|
581
|
+
// Rotation is best-effort — a stale/undeletable sibling must never abort
|
|
582
|
+
// the rewrite. The fresh backup above is already safely on disk.
|
|
583
|
+
}
|
|
347
584
|
}
|
|
348
|
-
}
|
|
349
585
|
|
|
350
|
-
|
|
351
|
-
|
|
352
|
-
|
|
586
|
+
const tmp = `${filePath}.tmp-${process.pid}-${Date.now()}`;
|
|
587
|
+
await writeFile(tmp, body, 'utf8');
|
|
588
|
+
await rename(tmp, filePath);
|
|
589
|
+
});
|
|
353
590
|
return validated;
|
|
354
591
|
}
|
|
@@ -230,17 +230,19 @@ export class ValidationError extends Error {
|
|
|
230
230
|
* Throws ValidationError on contract violations. Does NOT mutate input.
|
|
231
231
|
*
|
|
232
232
|
* @param {object} entry — candidate learning
|
|
233
|
-
* @param {{ legacyTolerant?: boolean }} [opts] —
|
|
233
|
+
* @param {{ legacyTolerant?: boolean }} [opts] — EventDrop #386. When `true`, a
|
|
234
234
|
* value `normalizeLearning()` already passes through UNCHECKED (or merely
|
|
235
235
|
* DEFAULTED, never validated) on read is no longer rejected here either:
|
|
236
236
|
* an out-of-enum `schema_version` (measured against the real EventDrop.at
|
|
237
237
|
* store: 3 live records carry `schema_version: 2`), the
|
|
238
238
|
* LEGACY_REQUIRED_FIELDS presence check, the `confidence` range/type check
|
|
239
239
|
* when NO `confidence` key is present, and the `scope`/`host_class`/
|
|
240
|
-
* `anonymized` shape checks (measured live: a record
|
|
241
|
-
*
|
|
242
|
-
*
|
|
243
|
-
*
|
|
240
|
+
* `anonymized`/`file_paths` shape checks (measured live: a record whose `scope` held a
|
|
241
|
+
* file path — e.g. a repo-relative component path such as
|
|
242
|
+
* `src/components/sample/sticky-cta.tsx` (the measured value is not
|
|
243
|
+
* reproduced here) — a producer bug that wrote a file path into the scope
|
|
244
|
+
* column, which `normalizeLearning` never validates and therefore never
|
|
245
|
+
* rejects). A field that IS present keeps
|
|
244
246
|
* being validated regardless when its DATA TYPE can be silently corrupted
|
|
245
247
|
* by a JSON round-trip (a present-but-malformed `confidence` still throws
|
|
246
248
|
* — see io.mjs #662): this option relaxes "you must HAVE a valid value",
|
|
@@ -296,9 +298,11 @@ export function validateLearning(entry, { legacyTolerant = false } = {}) {
|
|
|
296
298
|
// scope/host_class/anonymized: `normalizeLearning()` only DEFAULTS an
|
|
297
299
|
// absent value (`d.scope ?? 'local'`, etc.) on read — it never validates a
|
|
298
300
|
// PRESENT one, so a corrupt legacy value round-trips silently today
|
|
299
|
-
// (measured live in production: a record
|
|
300
|
-
//
|
|
301
|
-
//
|
|
301
|
+
// (measured live in production: a record whose `scope` held a file path —
|
|
302
|
+
// e.g. a repo-relative component path such as
|
|
303
|
+
// `src/components/sample/sticky-cta.tsx` (the measured value is not
|
|
304
|
+
// reproduced here) — evidently a producer bug that wrote a file path into
|
|
305
|
+
// the scope column). All three checks are
|
|
302
306
|
// therefore skipped under legacyTolerant. Unlike `confidence` (a number,
|
|
303
307
|
// where `NaN`/`Infinity` are non-JSON-safe and silently become `null`
|
|
304
308
|
// across a JSON round-trip — the #662 concern), a string/string-or-null/
|
|
@@ -319,6 +323,24 @@ export function validateLearning(entry, { legacyTolerant = false } = {}) {
|
|
|
319
323
|
throw new ValidationError(`anonymized must be boolean, got: ${typeof anonymized}`);
|
|
320
324
|
}
|
|
321
325
|
|
|
326
|
+
// file_paths (GitLab #1447): when present, an array of repo-relative path
|
|
327
|
+
// strings — a bare string (`file_paths: "src/x.ts"`) is the producer slip
|
|
328
|
+
// this rejects; every reader iterates it as a list. `null` reads as absent,
|
|
329
|
+
// as in migrateLegacyLearning. Skipped under legacyTolerant like the shape
|
|
330
|
+
// checks above: the read funnel passes the value through unchecked.
|
|
331
|
+
const filePaths = entry.file_paths;
|
|
332
|
+
if (!legacyTolerant && filePaths !== undefined && filePaths !== null) {
|
|
333
|
+
if (!Array.isArray(filePaths)) {
|
|
334
|
+
throw new ValidationError(`file_paths must be an array of repo-relative paths, got: ${typeof filePaths}`);
|
|
335
|
+
}
|
|
336
|
+
const badIndex = filePaths.findIndex((p) => !isRepoRelativePathEntry(p));
|
|
337
|
+
if (badIndex !== -1) {
|
|
338
|
+
throw new ValidationError(
|
|
339
|
+
`file_paths[${badIndex}] must be a non-empty repo-relative path (not absolute, no '..' segment), got: ${JSON.stringify(filePaths[badIndex])}`
|
|
340
|
+
);
|
|
341
|
+
}
|
|
342
|
+
}
|
|
343
|
+
|
|
322
344
|
// Privacy contract — NEVER relaxed, legacyTolerant or not: an out-of-enum
|
|
323
345
|
// `scope` (garbage or otherwise) is by definition not `'public'`, so this
|
|
324
346
|
// pair only ever fires for a record that genuinely claims `scope: 'public'`
|
|
@@ -476,6 +498,85 @@ export function normalizeDialects(entry, { reserializeTimestamps = true } = {})
|
|
|
476
498
|
return out;
|
|
477
499
|
}
|
|
478
500
|
|
|
501
|
+
/**
|
|
502
|
+
* File extensions a path-like `scope` value must end in to be treated as a
|
|
503
|
+
* repo-relative file path (GitHub #69 / GitLab #1446). Deliberately a closed
|
|
504
|
+
* list: a looser "contains a slash and a dot" test would also swallow values
|
|
505
|
+
* such as `infrastructure/docker-compose` or `node.js`.
|
|
506
|
+
*/
|
|
507
|
+
const PATH_SCOPE_EXT = /\.(mjs|cjs|js|jsx|ts|tsx|mts|cts|json|jsonl|md|mdx|ya?ml|toml|css|scss|html|vue|svelte|astro|py|rb|go|rs|swift|kt|java|sh|sql|prisma)$/i;
|
|
508
|
+
|
|
509
|
+
/**
|
|
510
|
+
* Strict predicate: is `s` a repo-relative file path (at least one directory
|
|
511
|
+
* segment, no `..`, no empty segment, a known source/doc extension)? Rejects
|
|
512
|
+
* canonical scopes, absolute and home-relative paths, Windows drive paths,
|
|
513
|
+
* URLs, and anything containing whitespace.
|
|
514
|
+
*
|
|
515
|
+
* @param {unknown} s — candidate `scope` value
|
|
516
|
+
* @returns {boolean}
|
|
517
|
+
*/
|
|
518
|
+
function looksLikeRepoRelativePath(s) {
|
|
519
|
+
if (typeof s !== 'string' || VALID_SCOPES.includes(s) || s.length > 512) return false;
|
|
520
|
+
if (/\s/.test(s) || /^[/~]/.test(s) || /^[A-Za-z]:[\\/]/.test(s) || s.includes('://')) return false;
|
|
521
|
+
const segs = s.split('/');
|
|
522
|
+
if (segs.length < 2 || segs.includes('..') || segs.includes('')) return false;
|
|
523
|
+
return PATH_SCOPE_EXT.test(segs[segs.length - 1]);
|
|
524
|
+
}
|
|
525
|
+
|
|
526
|
+
/**
|
|
527
|
+
* Loose predicate for ONE `file_paths` entry (GitLab #1447): a non-empty string
|
|
528
|
+
* that is not absolute (`/`, `\`, `~`, a Windows drive), not a URL, and has no
|
|
529
|
+
* `..` segment. Deliberately looser than {@link looksLikeRepoRelativePath}:
|
|
530
|
+
* root-level files (`package.json`), directories (`docs/prd`) and globs
|
|
531
|
+
* (`skills/**`) are legitimate `file_paths` values.
|
|
532
|
+
*
|
|
533
|
+
* Exported because `scripts/memory-propose.mjs` gates `--file-paths` on the same
|
|
534
|
+
* rule: a proposal this predicate rejects would be queued, approved, and then
|
|
535
|
+
* refused by {@link validateLearning} at write time.
|
|
536
|
+
*
|
|
537
|
+
* @param {unknown} s — candidate entry
|
|
538
|
+
* @returns {boolean}
|
|
539
|
+
*/
|
|
540
|
+
export function isRepoRelativePathEntry(s) {
|
|
541
|
+
if (typeof s !== 'string' || s.trim() === '') return false;
|
|
542
|
+
if (/^[/\\~]/.test(s) || /^[A-Za-z]:[\\/]/.test(s) || s.includes('://')) return false;
|
|
543
|
+
return !s.split(/[\\/]/).includes('..');
|
|
544
|
+
}
|
|
545
|
+
|
|
546
|
+
const LEADING_DOT_SLASH = /^(?:\.\/)+/;
|
|
547
|
+
|
|
548
|
+
/** Non-canonical STRING scopes {@link migrateLegacyLearning} coerces to `'local'`. */
|
|
549
|
+
const LOCAL_ALIAS_SCOPES = new Set(['vault-tools', 'deep-sessions', 'wave-executor', 'coordinator']);
|
|
550
|
+
|
|
551
|
+
/** Non-canonical STRING scopes {@link migrateLegacyLearning} coerces to `'private'`. */
|
|
552
|
+
const PRIVATE_ALIAS_SCOPES = new Set(['project', 'repo']);
|
|
553
|
+
|
|
554
|
+
/**
|
|
555
|
+
* Every word the migration reads as scope vocabulary. An array-scope entry
|
|
556
|
+
* equal to one of these is a scope word, never a path — the same reading the
|
|
557
|
+
* string branch gives it, so `['project']` is not turned into
|
|
558
|
+
* `file_paths: ['project']`.
|
|
559
|
+
*/
|
|
560
|
+
const SCOPE_WORDS = new Set([...VALID_SCOPES, ...LOCAL_ALIAS_SCOPES, ...PRIVATE_ALIAS_SCOPES]);
|
|
561
|
+
|
|
562
|
+
/**
|
|
563
|
+
* The repo-relative paths a broken `scope` value carries, or `null` when it is
|
|
564
|
+
* not a path scope. A single string must pass the strict
|
|
565
|
+
* {@link looksLikeRepoRelativePath} (a lone string may be a scope word); an
|
|
566
|
+
* ARRAY is never scope vocabulary, so the loose per-entry predicate suffices
|
|
567
|
+
* there — an empty array yields `[]`. A leading `./` run is stripped from each
|
|
568
|
+
* path; case is kept (git paths are case-sensitive).
|
|
569
|
+
*
|
|
570
|
+
* @param {unknown} scope
|
|
571
|
+
* @returns {string[] | null}
|
|
572
|
+
*/
|
|
573
|
+
function pathScopeEntries(scope) {
|
|
574
|
+
if (looksLikeRepoRelativePath(scope)) return [scope.replace(LEADING_DOT_SLASH, '')];
|
|
575
|
+
if (!Array.isArray(scope)) return null;
|
|
576
|
+
const entries = scope.map((e) => (typeof e === 'string' ? e.replace(LEADING_DOT_SLASH, '') : e));
|
|
577
|
+
return entries.every((e) => isRepoRelativePathEntry(e) && !SCOPE_WORDS.has(e)) ? entries : null;
|
|
578
|
+
}
|
|
579
|
+
|
|
479
580
|
/**
|
|
480
581
|
* Migrate a legacy learning record to the canonical schema_version:1 shape.
|
|
481
582
|
* Idempotent — calling it on an already-canonical record is a safe no-op.
|
|
@@ -483,9 +584,32 @@ export function normalizeDialects(entry, { reserializeTimestamps = true } = {})
|
|
|
483
584
|
* Alias precedence for insight: insight > description > recommendation > observation > lesson
|
|
484
585
|
*
|
|
485
586
|
* Producer dialects (`files`→`file_paths`, duplicate `session_id`, `last_seen`,
|
|
486
|
-
* `next_review: null`) are normalized by {@link normalizeDialects}
|
|
487
|
-
*
|
|
488
|
-
*
|
|
587
|
+
* `next_review: null`) are normalized by {@link normalizeDialects}. Timestamp
|
|
588
|
+
* FORMAT is deliberately NOT reformatted here (byte-exact contract preserved);
|
|
589
|
+
* the read funnel + backfill canonicalize timestamps.
|
|
590
|
+
*
|
|
591
|
+
* Broken-consumer-record coercions (GitHub #69 / GitLab #1446 — measured in a
|
|
592
|
+
* consumer store: 124 of 307 records failed strict validateLearning, 76 on
|
|
593
|
+
* `scope`, 48 on `schema_version`). Applied AFTER normalizeDialects, because
|
|
594
|
+
* coercing a path-like scope before the `files`→`file_paths` move would lose
|
|
595
|
+
* the legacy `files` data. Only this MIGRATION path coerces — the read funnel
|
|
596
|
+
* (normalizeLearning / normalizeDialects) stays pass-through by design:
|
|
597
|
+
* - path-like `scope` (a repo-relative file path a producer wrote into the
|
|
598
|
+
* scope column, see {@link looksLikeRepoRelativePath}) → appended to
|
|
599
|
+
* `file_paths` (copied, never mutated in place; created when absent/null;
|
|
600
|
+
* no duplicate) and `scope: 'private'`. A present non-array `file_paths`
|
|
601
|
+
* leaves the record untouched — it stays invalid and is counted. A
|
|
602
|
+
* leading `./` run is stripped first (GitLab #1447).
|
|
603
|
+
* - array-typed `scope` (GitLab #1447) → the same move when every entry is a
|
|
604
|
+
* repo-relative path ({@link isRepoRelativePathEntry}: root files, dirs and
|
|
605
|
+
* globs included); an empty array → `scope: 'private'`. An array holding an
|
|
606
|
+
* absolute path, a non-string or a scope word — canonical or one of the
|
|
607
|
+
* aliases below, e.g. `['project']` — stays untouched (invalid, counted);
|
|
608
|
+
* nothing is ever dropped.
|
|
609
|
+
* - `scope: 'project'` / `scope: 'repo'` → `'private'`.
|
|
610
|
+
* - `schema_version: '1'` (string) or `2` (number) → `1`.
|
|
611
|
+
* Each rule's output never re-matches that rule, so the whole function stays
|
|
612
|
+
* idempotent.
|
|
489
613
|
*
|
|
490
614
|
* The caller MUST still run validateLearning() on the result to confirm the
|
|
491
615
|
* migrated record passes the full schema gate before writing.
|
|
@@ -534,8 +658,7 @@ export function migrateLegacyLearning(entry) {
|
|
|
534
658
|
out.evidence = '';
|
|
535
659
|
}
|
|
536
660
|
|
|
537
|
-
|
|
538
|
-
if (out.scope && !VALID_SCOPES.includes(out.scope) && COERCIBLE_SCOPES.has(out.scope)) {
|
|
661
|
+
if (out.scope && !VALID_SCOPES.includes(out.scope) && LOCAL_ALIAS_SCOPES.has(out.scope)) {
|
|
539
662
|
out.scope = 'local';
|
|
540
663
|
}
|
|
541
664
|
|
|
@@ -560,12 +683,37 @@ export function migrateLegacyLearning(entry) {
|
|
|
560
683
|
out.schema_version = CURRENT_SCHEMA_VERSION;
|
|
561
684
|
}
|
|
562
685
|
|
|
563
|
-
// Normalize producer dialects
|
|
686
|
+
// Normalize producer dialects (files→file_paths, session_id/last_seen/
|
|
564
687
|
// next_review reconciliation) — schema_version is preserved by normalizeDialects.
|
|
565
688
|
// reserializeTimestamps:false keeps migrateLegacyLearning's byte-exact timestamp
|
|
566
689
|
// contract (it does schema/alias migration, not timestamp reformatting). The
|
|
567
690
|
// READ funnel (normalizeLearning) and the backfill canonicalize timestamp format.
|
|
568
|
-
|
|
691
|
+
const migrated = normalizeDialects(out, { reserializeTimestamps: false });
|
|
692
|
+
|
|
693
|
+
// Broken-consumer-record coercions (GitHub #69 / GitLab #1446, #1447) — AFTER
|
|
694
|
+
// normalizeDialects so a legacy `files` list has already become `file_paths`.
|
|
695
|
+
const pathEntries = pathScopeEntries(migrated.scope);
|
|
696
|
+
if (pathEntries !== null) {
|
|
697
|
+
const existing = migrated.file_paths;
|
|
698
|
+
if (existing === undefined || existing === null || Array.isArray(existing)) {
|
|
699
|
+
// Copy: normalizeDialects' shallow copy still shares the caller's array.
|
|
700
|
+
const filePaths = Array.isArray(existing) ? [...existing] : [];
|
|
701
|
+
for (const p of pathEntries) {
|
|
702
|
+
if (!filePaths.includes(p)) filePaths.push(p);
|
|
703
|
+
}
|
|
704
|
+
if (filePaths.length > 0) migrated.file_paths = filePaths;
|
|
705
|
+
migrated.scope = 'private';
|
|
706
|
+
}
|
|
707
|
+
// Present but not an array → leave untouched; the record stays invalid.
|
|
708
|
+
} else if (PRIVATE_ALIAS_SCOPES.has(migrated.scope)) {
|
|
709
|
+
migrated.scope = 'private';
|
|
710
|
+
}
|
|
711
|
+
|
|
712
|
+
if (migrated.schema_version === '1' || migrated.schema_version === 2) {
|
|
713
|
+
migrated.schema_version = CURRENT_SCHEMA_VERSION;
|
|
714
|
+
}
|
|
715
|
+
|
|
716
|
+
return migrated;
|
|
569
717
|
}
|
|
570
718
|
|
|
571
719
|
// Module-level dedupe sets for warnings (per-process).
|
|
@@ -9,8 +9,8 @@
|
|
|
9
9
|
* 4-5. Before this module, the `effective-sizing` analyzer
|
|
10
10
|
* (`skills/evolve/references/evolve-analyze-mode.md`) keyed its subject on
|
|
11
11
|
* `session_type` alone (e.g. `deep-session-sizing`), so a 7-wave ultradeep
|
|
12
|
-
* session and a 5-wave deep session landed on the SAME row — the
|
|
13
|
-
*
|
|
12
|
+
* session and a 5-wave deep session landed on the SAME row — the agents-per-wave
|
|
13
|
+
* average for one profile silently absorbed the other's.
|
|
14
14
|
*
|
|
15
15
|
* Pure, stdlib-only, no imports, no clock, no fs.
|
|
16
16
|
*
|