session-orchestrator 5.6.0 → 5.8.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/architecture/SKILL.md +3 -0
- package/.agents/skills/architecture/agents/openai.yaml +5 -0
- package/.agents/skills/claude-md-drift-check/SKILL.md +1 -0
- package/.agents/skills/eval/SKILL.md +1 -1
- package/.agents/skills/frontmatter-guard/SKILL.md +1 -0
- package/.agents/skills/gitlab-portfolio/SKILL.md +1 -0
- package/.agents/skills/hook-development/SKILL.md +1 -0
- package/.agents/skills/mcp-builder/SKILL.md +1 -0
- package/.agents/skills/tmux-layout/SKILL.md +3 -0
- package/.agents/skills/tmux-layout/agents/openai.yaml +5 -0
- package/.agents/skills/vault-mirror/SKILL.md +1 -0
- package/.agents/skills/vault-sync/SKILL.md +1 -0
- package/.agents/skills/write-executable-plan/SKILL.md +3 -0
- package/.agents/skills/write-executable-plan/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/architecture/SKILL.md +3 -0
- package/.codex-plugin/skills/architecture/agents/openai.yaml +5 -0
- package/.codex-plugin/skills/claude-md-drift-check/SKILL.md +1 -0
- package/.codex-plugin/skills/eval/SKILL.md +1 -1
- package/.codex-plugin/skills/frontmatter-guard/SKILL.md +1 -0
- package/.codex-plugin/skills/gitlab-portfolio/SKILL.md +1 -0
- package/.codex-plugin/skills/hook-development/SKILL.md +1 -0
- package/.codex-plugin/skills/mcp-builder/SKILL.md +1 -0
- package/.codex-plugin/skills/tmux-layout/SKILL.md +3 -0
- package/.codex-plugin/skills/tmux-layout/agents/openai.yaml +5 -0
- package/.codex-plugin/skills/vault-mirror/SKILL.md +1 -0
- package/.codex-plugin/skills/vault-sync/SKILL.md +1 -0
- package/.codex-plugin/skills/write-executable-plan/SKILL.md +3 -0
- package/.codex-plugin/skills/write-executable-plan/agents/openai.yaml +5 -0
- package/.cursor/commands/architecture.md +13 -0
- package/.cursor/commands/eval.md +1 -1
- package/.cursor/commands/tmux-layout.md +13 -0
- package/.cursor/commands/write-executable-plan.md +13 -0
- package/.cursor/skills/architecture/SKILL.md +0 -1
- package/.cursor/skills/eval/SKILL.md +1 -1
- package/.cursor/skills/tmux-layout/SKILL.md +0 -1
- package/.cursor/skills/write-executable-plan/SKILL.md +0 -1
- package/.cursor-plugin/plugin.json +1 -1
- package/CHANGELOG.md +75 -0
- package/README.md +10 -10
- package/agents/eval-judge.md +5 -5
- package/docs/USER-GUIDE.md +1 -1
- package/docs/baseline.md +4 -3
- package/docs/codex-setup.md +6 -4
- package/docs/components.md +5 -4
- package/docs/events-schema.md +4 -3
- package/docs/github-mirror-protection.md +3 -2
- package/docs/install.md +39 -3
- package/docs/pi-setup.md +1 -1
- package/docs/rule-authoring.md +12 -2
- package/docs/session-config-reference.md +8 -6
- package/docs/session-config-template.md +4 -3
- package/evals/README.md +60 -0
- package/evals/instruction-ablation/README.md +147 -0
- package/evals/instruction-ablation/cases/psa-003-foreign-work.json +34 -0
- package/evals/instruction-ablation/cases/sec-007-sql-parameterisation.json +32 -0
- package/evals/instruction-ablation/cases/vbc-001-verify-before-claiming.json +41 -0
- package/evals/instruction-ablation/run.mjs +336 -0
- package/evals/psa-003-foreign-work/case.yaml +16 -0
- package/evals/psa-003-foreign-work/fixture.sh +12 -0
- package/evals/psa-003-foreign-work/graders/foreign-file-intact.md +7 -0
- package/evals/psa-003-foreign-work/graders/names-foreign-work.md +6 -0
- package/evals/psa-003-foreign-work/graders/no-destructive-git.md +8 -0
- package/evals/psa-003-foreign-work/graders/ran-git.md +7 -0
- package/evals/psa-003-foreign-work/prompt.md +6 -0
- package/hooks/_lib/hook-import-set.json +10 -2
- package/hooks/hooks-codex.json +1 -1
- package/hooks/hooks.json +1 -1
- package/hooks/post-tool-batch-wave-signal.mjs +1 -0
- package/hooks/post-tooluse-frontend-slop.mjs +11 -1
- package/hooks/pre-task-scope-disjoint.mjs +8 -6
- package/hooks/wave-scope-commit-guard.mjs +41 -10
- package/monitors/monitors.json +1 -1
- package/package.json +3 -1
- package/pi/prompts/architecture.md +11 -0
- package/pi/prompts/eval.md +1 -1
- package/pi/prompts/tmux-layout.md +11 -0
- package/pi/prompts/write-executable-plan.md +11 -0
- package/scripts/backfill-abandoned-sessions.mjs +6 -8
- package/scripts/backfill-learnings-from-vault.mjs +17 -6
- package/scripts/backfill-sessions.mjs +9 -7
- package/scripts/codex-install.mjs +108 -23
- package/scripts/emit-session.mjs +15 -2
- package/scripts/fleet-auflagen.mjs +54 -0
- package/scripts/generate-agents-skills.mjs +9 -16
- package/scripts/generate-codex-skills.mjs +8 -6
- package/scripts/generate-cursor-adapter.mjs +9 -74
- package/scripts/generate-pi-prompts.mjs +8 -74
- package/scripts/github-protection-audit.mjs +8 -0
- package/scripts/lib/auto-dialectic.mjs +10 -9
- package/scripts/lib/auto-dream.mjs +8 -10
- package/scripts/lib/autopilot/telemetry.mjs +12 -6
- package/scripts/lib/bootstrap-lock-refresh.mjs +8 -12
- package/scripts/lib/config/drift-check.mjs +1 -1
- package/scripts/lib/config/frontend-slop-hook.mjs +6 -0
- package/scripts/lib/config.mjs +5 -1
- package/scripts/lib/convergence-monitor.mjs +5 -3
- package/scripts/lib/dispatcher/rank.mjs +5 -1
- package/scripts/lib/ecosystem-health.mjs +5 -3
- package/scripts/lib/eval/engine.mjs +61 -27
- package/scripts/lib/eval/judge.mjs +14 -8
- package/scripts/lib/eval/session-resolve.mjs +7 -2
- package/scripts/lib/events-retention-banner.mjs +5 -2
- package/scripts/lib/events.mjs +81 -54
- package/scripts/lib/fleet-protocol.mjs +98 -1
- package/scripts/lib/instruction-budget-guard.mjs +25 -18
- package/scripts/lib/io.mjs +181 -39
- package/scripts/lib/learnings/candidates.mjs +14 -44
- package/scripts/lib/learnings/io.mjs +7 -2
- package/scripts/lib/learnings/judgment.mjs +5 -8
- package/scripts/lib/learnings/select.mjs +3 -3
- package/scripts/lib/maintenance-due-banner.mjs +2 -2
- package/scripts/lib/memory-proposals/sink.mjs +5 -6
- package/scripts/lib/memory-proposals/store.mjs +12 -7
- package/scripts/lib/mirror-issues-banner.mjs +11 -1
- package/scripts/lib/native-command.mjs +48 -0
- package/scripts/lib/orphan-reaper/scan.mjs +29 -3
- package/scripts/lib/peer-cards/writer.mjs +6 -18
- package/scripts/lib/platform.mjs +113 -4
- package/scripts/lib/plugin-package-stage.mjs +169 -0
- package/scripts/lib/plugin-surface.mjs +121 -0
- package/scripts/lib/quality-gate.mjs +30 -25
- package/scripts/lib/reconcile/idempotency.mjs +9 -10
- package/scripts/lib/reconcile/rule-expiry-sweep.mjs +97 -8
- package/scripts/lib/reconcile/writer.mjs +11 -17
- package/scripts/lib/rule-loader.mjs +32 -6
- package/scripts/lib/scope-echo.mjs +12 -2
- package/scripts/lib/scope-gate.mjs +2 -1
- package/scripts/lib/session-close-backfill.mjs +21 -4
- package/scripts/lib/session-id.mjs +2 -1
- package/scripts/lib/session-record-repair.mjs +9 -13
- package/scripts/lib/session-schema/constants.mjs +20 -5
- package/scripts/lib/session-start-probes.mjs +2 -2
- package/scripts/lib/sessions-staleness-banner.mjs +2 -3
- package/scripts/lib/skill-evolution/engine.mjs +6 -9
- package/scripts/lib/skill-evolution/idempotency.mjs +8 -10
- package/scripts/lib/slopcheck.mjs +9 -7
- package/scripts/lib/sunset/walker.mjs +3 -3
- package/scripts/lib/telemetry/sync.mjs +3 -1
- package/scripts/lib/telemetry-flush-health-banner.mjs +2 -2
- package/scripts/lib/test-sibling-coverage.mjs +167 -0
- package/scripts/lib/tmux-layout/telemetry-stats.mjs +5 -7
- package/scripts/lib/tmux-layout/telemetry.mjs +8 -10
- package/scripts/lib/user-invocable-skills.mjs +37 -10
- package/scripts/lib/ux-grill/run-record.mjs +5 -3
- package/scripts/lib/validate/check-banner-parity.mjs +1 -0
- package/scripts/lib/validate/check-skills.mjs +12 -2
- package/scripts/lib/validate/check-test-fixture-shapes.mjs +3 -3
- package/scripts/lib/validate/check-test-value-bans.mjs +12 -2
- package/scripts/lib/validate/check-unicode-safety.mjs +1 -1
- package/scripts/lib/validate/check-unwired-features.mjs +1 -1
- package/scripts/lib/vault-archive.mjs +2 -4
- package/scripts/lib/vault-mirror/process.mjs +585 -72
- package/scripts/lib/vault-mirror/prune.mjs +413 -0
- package/scripts/lib/vault-mirror/render-sessions.mjs +13 -5
- package/scripts/lib/vault-mirror/retention.mjs +388 -0
- package/scripts/lib/vault-mirror/utils.mjs +16 -1
- package/scripts/lib/vault-status/board-writer.mjs +6 -6
- package/scripts/lib/vault-sync-baseline.mjs +9 -8
- package/scripts/lib/wave-transcript-tail.mjs +19 -6
- package/scripts/lib/worktree/meta.mjs +7 -3
- package/scripts/mcp-server.mjs +158 -0
- package/scripts/migrate-sessions-jsonl.mjs +7 -5
- package/scripts/migrate-vault-paths.mjs +7 -4
- package/scripts/print-applicable-rules.mjs +1 -25
- package/scripts/print-learnings-index.mjs +2 -1
- package/scripts/release.mjs +4 -0
- package/scripts/relocate-vault-corpus.mjs +5 -3
- package/scripts/run-migrate-v2-cross-repo.mjs +9 -7
- package/scripts/self-update.mjs +179 -39
- package/scripts/site-numbers.mjs +4 -3
- package/scripts/sweep-expired-learnings.mjs +6 -4
- package/scripts/sync-vault-schema.mjs +3 -7
- package/scripts/vault-mirror-prune.mjs +145 -0
- package/scripts/vault-mirror.mjs +77 -12
- package/scripts/windows-setup.mjs +45 -0
- package/skills/_shared/fleet-protocol.md +8 -0
- package/skills/architecture/SKILL.md +1 -0
- package/skills/bootstrap/_shared-template.md +5 -2
- package/skills/claude-md-drift-check/SKILL.md +4 -1
- package/skills/claude-md-drift-check/checker.mjs +89 -26
- package/skills/discovery/probes/ssot-code-diff.mjs +3 -3
- package/skills/eval/SKILL.md +12 -13
- package/skills/eval/rubric-v3.md +521 -0
- package/skills/frontmatter-guard/SKILL.md +1 -0
- package/skills/gitlab-portfolio/SKILL.md +1 -0
- package/skills/hook-development/SKILL.md +1 -0
- package/skills/mcp-builder/SKILL.md +1 -0
- package/skills/repo-audit/SKILL.md +2 -2
- package/skills/session-end/SKILL.md +32 -1
- package/skills/session-end/vault-operations.md +12 -2
- package/skills/session-start/SKILL.md +2 -2
- package/skills/session-start/phase-7-1-premise-check.md +2 -1
- package/skills/session-start/references/operations-contract.md +4 -1
- package/skills/tmux-layout/SKILL.md +1 -0
- package/skills/vault-mirror/SKILL.md +28 -2
- package/skills/vault-sync/SKILL.md +6 -6
- package/skills/vault-sync/validator.sh +29 -10
- package/skills/wave-executor/references/wave-loop-scope-manifest.md +5 -3
- package/skills/write-executable-plan/SKILL.md +1 -0
- package/hooks/agent-teams-h3-test.sh +0 -362
- package/scripts/spikes/h3-agent-teams/preflight.sh +0 -53
- package/scripts/spikes/h3-agent-teams/run-h3.sh +0 -112
- package/scripts/spikes/h3-agent-teams/setup.sh +0 -137
- package/scripts/spikes/h3-agent-teams/toggle.sh +0 -38
|
@@ -21,12 +21,18 @@
|
|
|
21
21
|
* expired lease is `none`, a lease that cannot be read or validated is
|
|
22
22
|
* `unreadable`, and neither is ever `active`.
|
|
23
23
|
*
|
|
24
|
+
* An auflagen file is keyed by the semantic session id, which collides across
|
|
25
|
+
* repos (`main-<date>-session-N`), so it is only accepted when its mandatory
|
|
26
|
+
* `repo` field resolves to the reader's own git common dir ({@link readAuflagen},
|
|
27
|
+
* #1520 / navigator#190).
|
|
28
|
+
*
|
|
24
29
|
* Session ids become file names, so {@link isSafeSessionId} is the security
|
|
25
30
|
* boundary between check-in input and the filesystem: every path builder that
|
|
26
31
|
* takes a session id refuses an unsafe one with a TypeError.
|
|
27
32
|
*/
|
|
28
33
|
|
|
29
|
-
import {
|
|
34
|
+
import { execFileSync } from 'node:child_process';
|
|
35
|
+
import fsSync, { promises as fs } from 'node:fs';
|
|
30
36
|
import os from 'node:os';
|
|
31
37
|
import path from 'node:path';
|
|
32
38
|
|
|
@@ -286,3 +292,94 @@ export async function readNavigatorLease({ now = Date.now(), env, home } = {}) {
|
|
|
286
292
|
if (Date.parse(lease.laeuft_ab) <= now) return { state: 'none' };
|
|
287
293
|
return { state: 'active', lease };
|
|
288
294
|
}
|
|
295
|
+
|
|
296
|
+
/** Timeout for the `git rev-parse` calls in {@link readAuflagen}. */
|
|
297
|
+
const GIT_TIMEOUT_MS = 5_000;
|
|
298
|
+
|
|
299
|
+
/**
|
|
300
|
+
* Absolute, realpath'd git common dir of `dir` — the same value for a main tree,
|
|
301
|
+
* any of its linked worktrees, and the `.git` directory itself — or null when
|
|
302
|
+
* `dir` is not inside a git repository (or git is unavailable).
|
|
303
|
+
* @param {string} dir
|
|
304
|
+
* @returns {string|null}
|
|
305
|
+
*/
|
|
306
|
+
function gitCommonDir(dir) {
|
|
307
|
+
try {
|
|
308
|
+
const out = execFileSync('git', ['-C', dir, 'rev-parse', '--path-format=absolute', '--git-common-dir'], {
|
|
309
|
+
env: gitEnv(),
|
|
310
|
+
encoding: 'utf8',
|
|
311
|
+
stdio: ['ignore', 'pipe', 'ignore'],
|
|
312
|
+
timeout: GIT_TIMEOUT_MS,
|
|
313
|
+
}).trim();
|
|
314
|
+
if (!out) return null;
|
|
315
|
+
return realpathSyncSafe(out);
|
|
316
|
+
} catch {
|
|
317
|
+
return null;
|
|
318
|
+
}
|
|
319
|
+
}
|
|
320
|
+
|
|
321
|
+
/**
|
|
322
|
+
* The only environment variables forwarded to `git`. An inherited `GIT_DIR`
|
|
323
|
+
* outranks `-C`, so both sides of the repo comparison would resolve to the
|
|
324
|
+
* same directory and a foreign repo's Auflagen would be ACCEPTED. Same
|
|
325
|
+
* allowlist as `git-config-drift.mjs` / `check-banner-parity.mjs`.
|
|
326
|
+
*/
|
|
327
|
+
const GIT_ENV_ALLOWLIST = Object.freeze(['PATH', 'HOME', 'LANG', 'LC_ALL', 'TMPDIR', 'TZ']);
|
|
328
|
+
|
|
329
|
+
function gitEnv() {
|
|
330
|
+
/** @type {Record<string, string>} */
|
|
331
|
+
const env = {};
|
|
332
|
+
for (const k of GIT_ENV_ALLOWLIST) if (process.env[k] !== undefined) env[k] = /** @type {string} */ (process.env[k]);
|
|
333
|
+
return env;
|
|
334
|
+
}
|
|
335
|
+
|
|
336
|
+
/** @param {string} p */
|
|
337
|
+
function realpathSyncSafe(p) {
|
|
338
|
+
try {
|
|
339
|
+
return fsSync.realpathSync(p);
|
|
340
|
+
} catch {
|
|
341
|
+
return path.resolve(p);
|
|
342
|
+
}
|
|
343
|
+
}
|
|
344
|
+
|
|
345
|
+
/**
|
|
346
|
+
* Read this session's conditions file and accept it only when it belongs to the
|
|
347
|
+
* reader's repository. Never throws; fail-closed.
|
|
348
|
+
*
|
|
349
|
+
* The file's mandatory `repo` field (an absolute path to the target repo's
|
|
350
|
+
* toplevel or its git common dir, written by the navigator) and `repoRoot` are
|
|
351
|
+
* both resolved to their git common dir, so a session in a linked worktree
|
|
352
|
+
* matches a file naming the main tree. Every `rejected` result means: behave as
|
|
353
|
+
* if no file existed (Standard-Auflagen apply).
|
|
354
|
+
*
|
|
355
|
+
* @param {string} sessionId
|
|
356
|
+
* @param {{ repoRoot?: string, env?: Record<string, string|undefined>, home?: string }} [opts]
|
|
357
|
+
* @returns {Promise<
|
|
358
|
+
* | { state: 'absent' }
|
|
359
|
+
* | { state: 'accepted', auflagen: Record<string, unknown> }
|
|
360
|
+
* | { state: 'rejected', reason: 'unsafe-session-id'|'unreadable'|'invalid-json'|'repo-missing'|'repo-unresolved'|'repo-mismatch' }
|
|
361
|
+
* >}
|
|
362
|
+
*/
|
|
363
|
+
export async function readAuflagen(sessionId, { repoRoot = process.cwd(), env, home } = {}) {
|
|
364
|
+
if (!isSafeSessionId(sessionId)) return { state: 'rejected', reason: 'unsafe-session-id' };
|
|
365
|
+
let raw;
|
|
366
|
+
try {
|
|
367
|
+
raw = await fs.readFile(auflagenPath(sessionId, { env, home }), 'utf8');
|
|
368
|
+
} catch (err) {
|
|
369
|
+
if (err && /** @type {NodeJS.ErrnoException} */ (err).code === 'ENOENT') return { state: 'absent' };
|
|
370
|
+
return { state: 'rejected', reason: 'unreadable' };
|
|
371
|
+
}
|
|
372
|
+
let auflagen;
|
|
373
|
+
try {
|
|
374
|
+
auflagen = JSON.parse(raw);
|
|
375
|
+
} catch {
|
|
376
|
+
return { state: 'rejected', reason: 'invalid-json' };
|
|
377
|
+
}
|
|
378
|
+
if (!isPlainObject(auflagen)) return { state: 'rejected', reason: 'invalid-json' };
|
|
379
|
+
const fileRepo = auflagen.repo;
|
|
380
|
+
if (typeof fileRepo !== 'string' || !path.isAbsolute(fileRepo)) return { state: 'rejected', reason: 'repo-missing' };
|
|
381
|
+
const own = gitCommonDir(repoRoot);
|
|
382
|
+
if (!own) return { state: 'rejected', reason: 'repo-unresolved' };
|
|
383
|
+
if (gitCommonDir(fileRepo) !== own) return { state: 'rejected', reason: 'repo-mismatch' };
|
|
384
|
+
return { state: 'accepted', auflagen };
|
|
385
|
+
}
|
|
@@ -88,11 +88,13 @@
|
|
|
88
88
|
|
|
89
89
|
import { existsSync, readdirSync, readFileSync } from 'node:fs';
|
|
90
90
|
import { basename, join } from 'node:path';
|
|
91
|
-
import { scanEventsBackwards } from './events.mjs';
|
|
91
|
+
import { eventsFilePath, scanEventsBackwards } from './events.mjs';
|
|
92
92
|
import {
|
|
93
93
|
FRONTMATTER_NOT_AT_TOP,
|
|
94
94
|
loadApplicableRules,
|
|
95
|
+
isRuleExpired,
|
|
95
96
|
parseGlobsFrontmatter,
|
|
97
|
+
ruleExpiryInstantMs,
|
|
96
98
|
} from './rule-loader.mjs';
|
|
97
99
|
|
|
98
100
|
/** Default directive ceiling (operator-chosen growth ratchet just above the ~457 baseline). */
|
|
@@ -846,11 +848,11 @@ const MS_PER_DAY = 86_400_000;
|
|
|
846
848
|
* declares no expiry or an unparseable one.
|
|
847
849
|
*
|
|
848
850
|
* Negative ⇒ already expired. The comparison basis is deliberately the SAME one
|
|
849
|
-
* `rule-loader.mjs` `applyGates` uses — `
|
|
850
|
-
*
|
|
851
|
-
* loader already filters it out of every
|
|
852
|
-
* definition of the same word. `
|
|
853
|
-
*
|
|
851
|
+
* `rule-loader.mjs` `applyGates` uses — `ruleExpiryInstantMs`, where a bare
|
|
852
|
+
* `YYYY-MM-DD` is INCLUSIVE and expires at the FOLLOWING UTC midnight (#1521) —
|
|
853
|
+
* so "expired" here means exactly "the loader already filters it out of every
|
|
854
|
+
* wave's rule set", never a second definition of the same word. `0` therefore
|
|
855
|
+
* means "last day: still loads today", `-1` "dropped at the last midnight".
|
|
854
856
|
*
|
|
855
857
|
* An unparseable date returns `null` (fail-OPEN, matching the loader, which
|
|
856
858
|
* warns and ignores the expiry rather than excluding the file) — the malformed
|
|
@@ -863,17 +865,18 @@ export function daysUntilGeneratedRuleExpiry(opts = {}) {
|
|
|
863
865
|
const meta = opts.meta;
|
|
864
866
|
if (!meta || typeof meta !== 'object') return null;
|
|
865
867
|
if (!Object.prototype.hasOwnProperty.call(meta, 'expires-at')) return null;
|
|
866
|
-
const expiresAt =
|
|
867
|
-
if (
|
|
868
|
+
const expiresAt = ruleExpiryInstantMs(meta['expires-at']);
|
|
869
|
+
if (expiresAt === null) return null;
|
|
868
870
|
|
|
869
|
-
|
|
870
|
-
|
|
871
|
-
if (raw instanceof Date) now = raw.getTime();
|
|
872
|
-
else if (typeof raw === 'number' && Number.isFinite(raw)) now = raw;
|
|
873
|
-
else if (typeof raw === 'string' && Number.isFinite(Date.parse(raw))) now = Date.parse(raw);
|
|
874
|
-
else now = Date.now();
|
|
871
|
+
return Math.floor((expiresAt - nowMs(opts.now)) / MS_PER_DAY);
|
|
872
|
+
}
|
|
875
873
|
|
|
876
|
-
|
|
874
|
+
/** `now` as epoch ms: Date, number or parseable string; anything else → the process clock. */
|
|
875
|
+
function nowMs(raw) {
|
|
876
|
+
if (raw instanceof Date) return raw.getTime();
|
|
877
|
+
if (typeof raw === 'number' && Number.isFinite(raw)) return raw;
|
|
878
|
+
if (typeof raw === 'string' && Number.isFinite(Date.parse(raw))) return Date.parse(raw);
|
|
879
|
+
return Date.now();
|
|
877
880
|
}
|
|
878
881
|
|
|
879
882
|
/**
|
|
@@ -888,8 +891,12 @@ export function daysUntilGeneratedRuleExpiry(opts = {}) {
|
|
|
888
891
|
* @returns {boolean}
|
|
889
892
|
*/
|
|
890
893
|
export function isExpiredGeneratedRule(opts = {}) {
|
|
891
|
-
const
|
|
892
|
-
|
|
894
|
+
const meta = opts.meta;
|
|
895
|
+
if (!meta || typeof meta !== 'object') return false;
|
|
896
|
+
if (!Object.prototype.hasOwnProperty.call(meta, 'expires-at')) return false;
|
|
897
|
+
// The loader's own predicate, not `days < 0`: flooring whole days would call
|
|
898
|
+
// the rule live for the first millisecond after the boundary (#1521).
|
|
899
|
+
return isRuleExpired(meta['expires-at'], nowMs(opts.now));
|
|
893
900
|
}
|
|
894
901
|
|
|
895
902
|
/**
|
|
@@ -1335,7 +1342,7 @@ const RECONCILE_SCAN_BUDGET_MS = 150;
|
|
|
1335
1342
|
*/
|
|
1336
1343
|
function readLastReconcileCapped(repoRoot) {
|
|
1337
1344
|
if (typeof repoRoot !== 'string' || repoRoot === '') return null;
|
|
1338
|
-
const filePath =
|
|
1345
|
+
const filePath = eventsFilePath(repoRoot); // #1514 point 1: the repo's one ledger
|
|
1339
1346
|
let capped = null;
|
|
1340
1347
|
let scan;
|
|
1341
1348
|
try {
|
package/scripts/lib/io.mjs
CHANGED
|
@@ -16,7 +16,7 @@
|
|
|
16
16
|
* #457; writeJsonAtomicSync extracted for #558 M1.
|
|
17
17
|
*/
|
|
18
18
|
|
|
19
|
-
import { writeFile, rename, mkdir } from 'node:fs/promises';
|
|
19
|
+
import { writeFile, rename, mkdir, copyFile, unlink } from 'node:fs/promises';
|
|
20
20
|
import {
|
|
21
21
|
mkdirSync,
|
|
22
22
|
writeFileSync,
|
|
@@ -888,7 +888,10 @@ export async function writeJsonAtomic(filePath, value, opts = {}) {
|
|
|
888
888
|
* @param {object} [opts]
|
|
889
889
|
* @param {number} [opts.indent=2] JSON.stringify indent.
|
|
890
890
|
* @param {string} [opts.tmpPrefix='.tmp'] Tmp-file prefix (callers pick their domain prefix).
|
|
891
|
-
* @returns {{ ok: true } | { ok: false, reason: 'fs-error', error: string }}
|
|
891
|
+
* @returns {{ ok: true } | { ok: false, reason: 'fs-error', error: string, code: string|null }}
|
|
892
|
+
* `code` is the failing call's `err.code` (`'ENOSPC'`, `'EACCES'`, …), or `null`
|
|
893
|
+
* when the error carried none (a serialization TypeError, the partial-adapter
|
|
894
|
+
* refusal). Additive (#1032): `error` keeps its exact text.
|
|
892
895
|
*/
|
|
893
896
|
export function writeJsonAtomicSync(filePath, data, opts = {}) {
|
|
894
897
|
const { indent = 2, tmpPrefix = '.tmp' } = opts;
|
|
@@ -897,7 +900,7 @@ export function writeJsonAtomicSync(filePath, data, opts = {}) {
|
|
|
897
900
|
try {
|
|
898
901
|
body = JSON.stringify(data, null, indent) + '\n';
|
|
899
902
|
} catch (err) {
|
|
900
|
-
return
|
|
903
|
+
return _fsErrorEnvelope(err);
|
|
901
904
|
}
|
|
902
905
|
|
|
903
906
|
const res = atomicWriteWithBackup(filePath, body, { tmpPrefix });
|
|
@@ -941,26 +944,32 @@ export function writeJsonAtomicSync(filePath, data, opts = {}) {
|
|
|
941
944
|
* envelope is returned. Without that, every failed write leaves a
|
|
942
945
|
* `<tmpPrefix>.<hex>` behind — the board writer's failure mode is a retry loop,
|
|
943
946
|
* so the litter accumulates in the operator's vault directory. The cleanup is
|
|
944
|
-
* attempted
|
|
945
|
-
*
|
|
946
|
-
* neither may mask the original
|
|
947
|
+
* attempted once the tmp path has been chosen — including a write that created
|
|
948
|
+
* the file and then failed mid-way — and its own failure is swallowed: a leaked
|
|
949
|
+
* tmp is worse than a silent unlink miss, and neither may mask the original
|
|
950
|
+
* error.
|
|
947
951
|
*
|
|
948
952
|
* ── BV-004 ceiling + revisit trigger ────────────────────────────────────────
|
|
949
|
-
*
|
|
950
|
-
*
|
|
951
|
-
*
|
|
953
|
+
* THREE PRODUCTION CALL-SITES (re-measured 2026-10-03 @ 0e0c4b08 with
|
|
954
|
+
* `rg -n "atomicWriteWithBackup\(" scripts hooks skills --glob '*.mjs'`):
|
|
955
|
+
* `vault-status/board-writer.mjs#writeBoard` (`backup: false`),
|
|
956
|
+
* `reconcile/rule-expiry-sweep.mjs` (no `backup`, i.e. `false`) and
|
|
957
|
+
* {@link writeJsonAtomicSync}, which carries 14 further call-sites across 11
|
|
958
|
+
* files behind it (`rg -n "writeJsonAtomicSync\("` over the same scope, minus
|
|
959
|
+
* the definition and `host-identity.mjs`'s private `_writeJsonAtomicSync`
|
|
960
|
+
* copy, which does NOT route through this helper). That last one is the load-bearing
|
|
952
961
|
* evidence — the previous revision of this note recorded ONE call-site and
|
|
953
962
|
* concluded the signature was unproven, while the function with an identical
|
|
954
963
|
* body sat 60 lines above in this same file, unmigrated. The cheapest possible
|
|
955
964
|
* migration going unmade is not a neutral fact about a helper: it is the
|
|
956
965
|
* measurement that the helper is not paying for itself.
|
|
957
966
|
*
|
|
958
|
-
* What the
|
|
967
|
+
* What the `writeJsonAtomicSync` call-site does NOT prove: it passes
|
|
959
968
|
* `backup: false` and no `fs`, so the backup half and the injection seam still
|
|
960
969
|
* rest on tests plus one board-writer flag. REVISIT TRIGGER — when a sweep
|
|
961
970
|
* migrates the remaining hand-rolled sites, re-check before widening:
|
|
962
|
-
* (a)
|
|
963
|
-
*
|
|
971
|
+
* (a) answered (#1032): the async twin is {@link atomicWriteText}, not a
|
|
972
|
+
* promise mode on this function; and (b) whether rotation
|
|
964
973
|
* belongs here after all (it does only if ≥2 migrated callers want the SAME
|
|
965
974
|
* keep-N). If NO caller ever passes `backup: true` in production, that half is
|
|
966
975
|
* still the part to shrink back.
|
|
@@ -977,7 +986,9 @@ export function writeJsonAtomicSync(filePath, data, opts = {}) {
|
|
|
977
986
|
* Injectable fs (tests). Omitted methods fall back to `node:fs` — EXCEPT on
|
|
978
987
|
* the backup path, which fails closed (see below).
|
|
979
988
|
* @returns {{ ok: true, path: string, bytes: number, backupPath: string|null }
|
|
980
|
-
* | { ok: false, reason: 'fs-error', error: string }}
|
|
989
|
+
* | { ok: false, reason: 'fs-error', error: string, code: string|null }}
|
|
990
|
+
* `code` is the failing call's `err.code`, or `null` when there was none
|
|
991
|
+
* (additive, #1032 — a caller that must re-throw with the same code can now).
|
|
981
992
|
*/
|
|
982
993
|
export function atomicWriteWithBackup(filePath, body, opts = {}) {
|
|
983
994
|
const {
|
|
@@ -1011,20 +1022,15 @@ export function atomicWriteWithBackup(filePath, body, opts = {}) {
|
|
|
1011
1022
|
// methods are guarded, not just `copyFileSync` — a missing `existsSync` probes
|
|
1012
1023
|
// the real target and silently decides the backup branch from it, which is the
|
|
1013
1024
|
// same escape one step earlier.
|
|
1014
|
-
|
|
1015
|
-
|
|
1016
|
-
if (typeof injectedFs[method] !== 'function') {
|
|
1017
|
-
return {
|
|
1018
|
-
ok: false,
|
|
1019
|
-
reason: 'fs-error',
|
|
1020
|
-
error: `partial fs adapter: ${method} required for backup`,
|
|
1021
|
-
};
|
|
1022
|
-
}
|
|
1023
|
-
}
|
|
1024
|
-
}
|
|
1025
|
+
const refusal = _partialAdapterRefusal(backup, injectedFs, ['existsSync', 'copyFileSync']);
|
|
1026
|
+
if (refusal) return refusal;
|
|
1025
1027
|
|
|
1028
|
+
// Assigned BEFORE the write, and cleanup keys on it rather than on the write
|
|
1029
|
+
// having returned: `writeFileSync` can create the tmp file and THEN throw
|
|
1030
|
+
// (ENOSPC / EIO mid-write), and a flag set only after it returned leaked
|
|
1031
|
+
// exactly that file (#1032). Unlinking a tmp that was never created throws
|
|
1032
|
+
// ENOENT, which the cleanup swallows.
|
|
1026
1033
|
let tmpFile = null;
|
|
1027
|
-
let tmpCreated = false;
|
|
1028
1034
|
|
|
1029
1035
|
try {
|
|
1030
1036
|
const dir = dirname(filePath);
|
|
@@ -1032,34 +1038,170 @@ export function atomicWriteWithBackup(filePath, body, opts = {}) {
|
|
|
1032
1038
|
|
|
1033
1039
|
let backupPath = null;
|
|
1034
1040
|
if (backup && fsExists(filePath)) {
|
|
1035
|
-
|
|
1036
|
-
backupPath = `${filePath}.bak-${stamp}`;
|
|
1041
|
+
backupPath = _backupPathFor(filePath, now);
|
|
1037
1042
|
fsCopyFile(filePath, backupPath);
|
|
1038
1043
|
}
|
|
1039
1044
|
|
|
1040
|
-
tmpFile =
|
|
1045
|
+
tmpFile = _tmpPathFor(dir, tmpPrefix);
|
|
1041
1046
|
fsWriteFile(tmpFile, body, encoding);
|
|
1042
|
-
tmpCreated = true;
|
|
1043
1047
|
fsRename(tmpFile, filePath);
|
|
1044
1048
|
|
|
1045
|
-
return {
|
|
1046
|
-
ok: true,
|
|
1047
|
-
path: filePath,
|
|
1048
|
-
bytes: Buffer.isBuffer(body) ? body.length : Buffer.byteLength(String(body), encoding),
|
|
1049
|
-
backupPath,
|
|
1050
|
-
};
|
|
1049
|
+
return { ok: true, path: filePath, bytes: _byteLength(body, encoding), backupPath };
|
|
1051
1050
|
} catch (err) {
|
|
1052
|
-
//
|
|
1053
|
-
//
|
|
1054
|
-
if (
|
|
1051
|
+
// The name carries 12 hex chars of entropy, so this cannot collide with a
|
|
1052
|
+
// caller's real file.
|
|
1053
|
+
if (tmpFile !== null) {
|
|
1055
1054
|
try {
|
|
1056
1055
|
fsUnlink(tmpFile);
|
|
1057
1056
|
} catch {
|
|
1058
1057
|
// Best-effort: never let cleanup replace the error the caller needs.
|
|
1059
1058
|
}
|
|
1060
1059
|
}
|
|
1061
|
-
return
|
|
1060
|
+
return _fsErrorEnvelope(err);
|
|
1061
|
+
}
|
|
1062
|
+
}
|
|
1063
|
+
|
|
1064
|
+
/**
|
|
1065
|
+
* Async twin of {@link atomicWriteWithBackup} (#1032): same tmp naming
|
|
1066
|
+
* (`<dir>/<tmpPrefix>.<12 hex>`), same backup stamp, SAME envelope — and like
|
|
1067
|
+
* the sync helper it RESOLVES on failure and never rejects. A `promise` mode on
|
|
1068
|
+
* the sync function was rejected on purpose: one contract with two failure
|
|
1069
|
+
* directions (`ok:false` vs. reject) is the bug class this envelope exists to
|
|
1070
|
+
* end. Callers whose contract is to throw re-raise via {@link envelopeToError},
|
|
1071
|
+
* which keeps the message and `err.code`.
|
|
1072
|
+
*
|
|
1073
|
+
* Shared with the sync helper: tmp/backup naming, byte count, the error
|
|
1074
|
+
* envelope and the partial-adapter refusal. The five-step flow itself is
|
|
1075
|
+
* spelled twice — sync calls and awaited calls cannot share one body without a
|
|
1076
|
+
* generator driver, which would put an indirection on the hook-hot sync path
|
|
1077
|
+
* for ~15 lines of saved control flow.
|
|
1078
|
+
*
|
|
1079
|
+
* Backup differs in one detail: it copies and treats `ENOENT` as "first write,
|
|
1080
|
+
* nothing to snapshot" instead of probing `existsSync` first — the same result
|
|
1081
|
+
* without a check-then-act window.
|
|
1082
|
+
*
|
|
1083
|
+
* @param {string} filePath Target path; parent dirs created with mkdir -p semantics.
|
|
1084
|
+
* @param {string|Buffer} body Bytes to write, verbatim. Never inspected.
|
|
1085
|
+
* @param {object} [opts]
|
|
1086
|
+
* @param {BufferEncoding} [opts.encoding='utf8'] Encoding for a string `body`.
|
|
1087
|
+
* @param {boolean} [opts.backup=false] Snapshot the existing file to `.bak-<ISO>` first.
|
|
1088
|
+
* @param {string} [opts.tmpPrefix='.tmp'] Tmp-file prefix (callers pick their domain prefix).
|
|
1089
|
+
* @param {Date} [opts.now] Clock seam for the backup stamp (tests).
|
|
1090
|
+
* @param {{ mkdir?: Function, writeFile?: Function, rename?: Function,
|
|
1091
|
+
* copyFile?: Function, unlink?: Function }} [opts.fs]
|
|
1092
|
+
* Injectable `fs/promises`-shaped object (tests). Omitted methods fall back to
|
|
1093
|
+
* `node:fs/promises` — except `copyFile` under `backup: true`, which fails closed.
|
|
1094
|
+
* @returns {Promise<{ ok: true, path: string, bytes: number, backupPath: string|null }
|
|
1095
|
+
* | { ok: false, reason: 'fs-error', error: string, code: string|null }>}
|
|
1096
|
+
*/
|
|
1097
|
+
export async function atomicWriteText(filePath, body, opts = {}) {
|
|
1098
|
+
const {
|
|
1099
|
+
encoding = 'utf8',
|
|
1100
|
+
backup = false,
|
|
1101
|
+
tmpPrefix = '.tmp',
|
|
1102
|
+
now = new Date(),
|
|
1103
|
+
fs: injectedFs,
|
|
1104
|
+
} = opts;
|
|
1105
|
+
|
|
1106
|
+
const fsMkdir = injectedFs?.mkdir ?? mkdir;
|
|
1107
|
+
const fsWriteFile = injectedFs?.writeFile ?? writeFile;
|
|
1108
|
+
const fsRename = injectedFs?.rename ?? rename;
|
|
1109
|
+
const fsCopyFile = injectedFs?.copyFile ?? copyFile;
|
|
1110
|
+
const fsUnlink = injectedFs?.unlink ?? unlink;
|
|
1111
|
+
|
|
1112
|
+
const refusal = _partialAdapterRefusal(backup, injectedFs, ['copyFile']);
|
|
1113
|
+
if (refusal) return refusal;
|
|
1114
|
+
|
|
1115
|
+
let tmpFile = null;
|
|
1116
|
+
|
|
1117
|
+
try {
|
|
1118
|
+
const dir = dirname(filePath);
|
|
1119
|
+
await fsMkdir(dir, { recursive: true });
|
|
1120
|
+
|
|
1121
|
+
let backupPath = null;
|
|
1122
|
+
if (backup) {
|
|
1123
|
+
const candidate = _backupPathFor(filePath, now);
|
|
1124
|
+
try {
|
|
1125
|
+
await fsCopyFile(filePath, candidate);
|
|
1126
|
+
backupPath = candidate;
|
|
1127
|
+
} catch (err) {
|
|
1128
|
+
if (err?.code !== 'ENOENT') throw err;
|
|
1129
|
+
}
|
|
1130
|
+
}
|
|
1131
|
+
|
|
1132
|
+
tmpFile = _tmpPathFor(dir, tmpPrefix);
|
|
1133
|
+
await fsWriteFile(tmpFile, body, encoding);
|
|
1134
|
+
await fsRename(tmpFile, filePath);
|
|
1135
|
+
|
|
1136
|
+
return { ok: true, path: filePath, bytes: _byteLength(body, encoding), backupPath };
|
|
1137
|
+
} catch (err) {
|
|
1138
|
+
if (tmpFile !== null) {
|
|
1139
|
+
try {
|
|
1140
|
+
await fsUnlink(tmpFile);
|
|
1141
|
+
} catch {
|
|
1142
|
+
// Best-effort: never let cleanup replace the error the caller needs.
|
|
1143
|
+
}
|
|
1144
|
+
}
|
|
1145
|
+
return _fsErrorEnvelope(err);
|
|
1146
|
+
}
|
|
1147
|
+
}
|
|
1148
|
+
|
|
1149
|
+
/**
|
|
1150
|
+
* Re-raise a failed {@link atomicWriteText} / {@link atomicWriteWithBackup}
|
|
1151
|
+
* envelope as an Error carrying the original message and `code`, for call-sites
|
|
1152
|
+
* whose own contract is to throw (#1032).
|
|
1153
|
+
*
|
|
1154
|
+
* @param {{ error: string, code?: string|null }} envelope
|
|
1155
|
+
* @returns {Error & { code?: string }}
|
|
1156
|
+
*/
|
|
1157
|
+
export function envelopeToError(envelope) {
|
|
1158
|
+
const err = new Error(envelope.error);
|
|
1159
|
+
if (envelope.code) err.code = envelope.code;
|
|
1160
|
+
return err;
|
|
1161
|
+
}
|
|
1162
|
+
|
|
1163
|
+
/** @returns {{ ok: false, reason: 'fs-error', error: string, code: string|null }} */
|
|
1164
|
+
function _fsErrorEnvelope(err) {
|
|
1165
|
+
return {
|
|
1166
|
+
ok: false,
|
|
1167
|
+
reason: 'fs-error',
|
|
1168
|
+
error: err?.message ?? String(err),
|
|
1169
|
+
code: typeof err?.code === 'string' ? err.code : null,
|
|
1170
|
+
};
|
|
1171
|
+
}
|
|
1172
|
+
|
|
1173
|
+
/**
|
|
1174
|
+
* Backup-path fail-closed for a partial injected adapter (see the rationale in
|
|
1175
|
+
* {@link atomicWriteWithBackup}). Returns the refusal envelope, or null.
|
|
1176
|
+
*/
|
|
1177
|
+
function _partialAdapterRefusal(backup, injectedFs, methods) {
|
|
1178
|
+
if (!backup || !injectedFs) return null;
|
|
1179
|
+
for (const method of methods) {
|
|
1180
|
+
if (typeof injectedFs[method] !== 'function') {
|
|
1181
|
+
return {
|
|
1182
|
+
ok: false,
|
|
1183
|
+
reason: 'fs-error',
|
|
1184
|
+
error: `partial fs adapter: ${method} required for backup`,
|
|
1185
|
+
code: null,
|
|
1186
|
+
};
|
|
1187
|
+
}
|
|
1062
1188
|
}
|
|
1189
|
+
return null;
|
|
1190
|
+
}
|
|
1191
|
+
|
|
1192
|
+
/** `<filePath>.bak-<ISO>` with `:`/`.` → `-`, so a lexical sort is chronological. */
|
|
1193
|
+
function _backupPathFor(filePath, now) {
|
|
1194
|
+
const stamp = (now instanceof Date ? now : new Date()).toISOString().replace(/[:.]/g, '-');
|
|
1195
|
+
return `${filePath}.bak-${stamp}`;
|
|
1196
|
+
}
|
|
1197
|
+
|
|
1198
|
+
/** Sibling tmp path (same filesystem → atomic rename): `<dir>/<tmpPrefix>.<12 hex>`. */
|
|
1199
|
+
function _tmpPathFor(dir, tmpPrefix) {
|
|
1200
|
+
return path.join(dir, `${tmpPrefix}.${randomBytes(6).toString('hex')}`);
|
|
1201
|
+
}
|
|
1202
|
+
|
|
1203
|
+
function _byteLength(body, encoding) {
|
|
1204
|
+
return Buffer.isBuffer(body) ? body.length : Buffer.byteLength(String(body), encoding);
|
|
1063
1205
|
}
|
|
1064
1206
|
|
|
1065
1207
|
/**
|
|
@@ -48,11 +48,11 @@
|
|
|
48
48
|
* re-derived (not re-cited) if a threshold is ever revisited.
|
|
49
49
|
*
|
|
50
50
|
* **Stage 0 — corpus prep (once per run).**
|
|
51
|
-
* 1.
|
|
52
|
-
* `
|
|
53
|
-
*
|
|
54
|
-
*
|
|
55
|
-
*
|
|
51
|
+
* 1. Canonicalise producer dialects (`files` → `file_paths`) and type
|
|
52
|
+
* aliases (`gotcha` → `anti-pattern`): the caller reads through the
|
|
53
|
+
* existing funnel (`io.mjs::readLearnings` → `schema.mjs::normalizeLearning`),
|
|
54
|
+
* and the entry point re-applies {@link normalizeDialects} so raw
|
|
55
|
+
* in-memory records score the same shape.
|
|
56
56
|
* 2. Drop expired records — they may neither seed a pool nor join one.
|
|
57
57
|
* 3. Derive `learning_key` = `` `${type}/${kebab(title || subject)}` `` using
|
|
58
58
|
* the SHARED {@link kebab}. A divergent kebab does not produce an ugly
|
|
@@ -184,8 +184,7 @@
|
|
|
184
184
|
*/
|
|
185
185
|
|
|
186
186
|
import { tokenize } from './affinity.mjs';
|
|
187
|
-
import {
|
|
188
|
-
import { kebab } from './kebab.mjs';
|
|
187
|
+
import { learningKeyOf } from './kebab.mjs';
|
|
189
188
|
import { normalizeDialects } from './schema.mjs';
|
|
190
189
|
|
|
191
190
|
// ---------------------------------------------------------------------------
|
|
@@ -218,7 +217,7 @@ export const PATH_BOOST_EXACT = 0.05;
|
|
|
218
217
|
export const PATH_BOOST_DIR = 0.025;
|
|
219
218
|
|
|
220
219
|
/** How many leading directory segments define the directory-prefix boost. */
|
|
221
|
-
|
|
220
|
+
const DIR_PREFIX_SEGMENTS = 2;
|
|
222
221
|
|
|
223
222
|
/**
|
|
224
223
|
* Documented ceiling for consumers (accepted failure mode 2): a pool graph walk
|
|
@@ -229,7 +228,7 @@ export const DIR_PREFIX_SEGMENTS = 2;
|
|
|
229
228
|
export const MAX_POOL_HOPS = 2;
|
|
230
229
|
|
|
231
230
|
/** Token length floor — tokens shorter than this are dropped before stoplisting. */
|
|
232
|
-
|
|
231
|
+
const MIN_TOKEN_LENGTH = 3;
|
|
233
232
|
|
|
234
233
|
/** Cap on the reported `sharedTokens` — a diagnostic for the judge, not a payload. */
|
|
235
234
|
const SHARED_TOKEN_CAP = 8;
|
|
@@ -441,27 +440,18 @@ export function emptyPools() {
|
|
|
441
440
|
/**
|
|
442
441
|
* Logical identity of a learning: `` `${type}/${kebab(title || subject)}` ``.
|
|
443
442
|
*
|
|
444
|
-
* Returns `null` when either half is unusable
|
|
445
|
-
*
|
|
446
|
-
*
|
|
443
|
+
* Returns `null` when either half is unusable (non-record input, blank
|
|
444
|
+
* type/title/subject, or an all-symbol title that kebabs to '') — delegates to
|
|
445
|
+
* the shared `kebab.mjs::learningKeyOf` (#1021) so the key space cannot fork
|
|
446
|
+
* from the provenance checker's. An unkeyable record simply does not
|
|
447
|
+
* participate in the exact-key pass. It still seeds and
|
|
447
448
|
* joins pools: a missing title is not a reason to hide a learning from dedupe.
|
|
448
449
|
*
|
|
449
450
|
* @param {unknown} record
|
|
450
451
|
* @returns {string|null}
|
|
451
452
|
*/
|
|
452
453
|
export function learningKey(record) {
|
|
453
|
-
|
|
454
|
-
const type = typeof record.type === 'string' ? record.type.trim() : '';
|
|
455
|
-
const titleOrSubject =
|
|
456
|
-
(typeof record.title === 'string' && record.title.trim() !== '' ? record.title : '') ||
|
|
457
|
-
(typeof record.subject === 'string' && record.subject.trim() !== '' ? record.subject : '');
|
|
458
|
-
if (type === '' || titleOrSubject === '') return null;
|
|
459
|
-
const slug = kebab(titleOrSubject);
|
|
460
|
-
// `kebab` may legally return '' (all-symbol input) — an empty slug is not an
|
|
461
|
-
// identity, so such a record is unkeyable rather than colliding with every
|
|
462
|
-
// other empty-slug record of the same type.
|
|
463
|
-
if (slug === '') return null;
|
|
464
|
-
return `${type}/${slug}`;
|
|
454
|
+
return learningKeyOf(record);
|
|
465
455
|
}
|
|
466
456
|
|
|
467
457
|
/**
|
|
@@ -714,23 +704,3 @@ export function buildCandidatePools(records, opts = {}) {
|
|
|
714
704
|
return emptyPools();
|
|
715
705
|
}
|
|
716
706
|
}
|
|
717
|
-
|
|
718
|
-
/**
|
|
719
|
-
* File entry-point: read through the existing funnel (`readLearnings` →
|
|
720
|
-
* `normalizeLearning`, so dialects and type aliases are canonical), then pool.
|
|
721
|
-
*
|
|
722
|
-
* There is no second reader here on purpose — a private read path would be a
|
|
723
|
-
* second place for the dialect normalization to drift out of.
|
|
724
|
-
*
|
|
725
|
-
* @param {string} filePath — absolute path to learnings.jsonl
|
|
726
|
-
* @param {object} [opts] — everything {@link buildCandidatePools} accepts
|
|
727
|
-
* @returns {Promise<CandidatePools>} {@link emptyPools} on a missing/unreadable file
|
|
728
|
-
*/
|
|
729
|
-
export async function buildCandidatePoolsFromFile(filePath, opts = {}) {
|
|
730
|
-
try {
|
|
731
|
-
const { entries } = await readLearnings(filePath);
|
|
732
|
-
return buildCandidatePools(entries, opts);
|
|
733
|
-
} catch {
|
|
734
|
-
return emptyPools();
|
|
735
|
-
}
|
|
736
|
-
}
|
|
@@ -262,6 +262,11 @@ export async function readLearningsSnapshot(filePath) {
|
|
|
262
262
|
* malformed too (#1500): `normalizeLearning` turned it into a record of
|
|
263
263
|
* defaults alone, and every rewrite wrote that invented record back.
|
|
264
264
|
*
|
|
265
|
+
* A UTF-8 BOM (U+FEFF) in front of the FIRST line — an editor's save — is
|
|
266
|
+
* stripped before parsing, so that line is an entry, not malformed (#1506.5b).
|
|
267
|
+
* A BOM inside the file (line 2+) stays malformed: that is a concatenation
|
|
268
|
+
* artefact the drop-malformed preview reports as `bom`.
|
|
269
|
+
*
|
|
265
270
|
* @param {string} raw
|
|
266
271
|
* @returns {{entries: object[], malformed: string[], malformedLineNumbers: number[]}}
|
|
267
272
|
* `malformedLineNumbers[i]` is the 1-based line of `malformed[i]` in `raw`
|
|
@@ -273,7 +278,7 @@ export function parseLearningsText(raw) {
|
|
|
273
278
|
raw.split('\n').forEach((line, i) => {
|
|
274
279
|
if (line.trim().length === 0) return;
|
|
275
280
|
try {
|
|
276
|
-
const parsed = JSON.parse(line);
|
|
281
|
+
const parsed = JSON.parse(i === 0 && line.charCodeAt(0) === 0xfeff ? line.slice(1) : line);
|
|
277
282
|
if (parsed === null || typeof parsed !== 'object' || Array.isArray(parsed)) {
|
|
278
283
|
throw new TypeError('not a JSON object');
|
|
279
284
|
}
|
|
@@ -414,7 +419,7 @@ const BACKUP_KEEP = 3;
|
|
|
414
419
|
* @param {string} fileName — sibling filename to classify
|
|
415
420
|
* @returns {string|null} the suffix after the delimiter, or `null`
|
|
416
421
|
*/
|
|
417
|
-
|
|
422
|
+
function backupSuffixOf(baseName, fileName) {
|
|
418
423
|
const stem = `${baseName}.bak`;
|
|
419
424
|
if (!fileName.startsWith(stem)) return null;
|
|
420
425
|
const delim = fileName[stem.length];
|