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
|
@@ -5,17 +5,29 @@
|
|
|
5
5
|
* Both functions write to the vault dir and emit JSON action lines to stdout.
|
|
6
6
|
*/
|
|
7
7
|
|
|
8
|
-
import { readFileSync, writeFileSync, mkdirSync, existsSync, readdirSync } from 'node:fs';
|
|
9
|
-
import { join, resolve, sep } from 'node:path';
|
|
8
|
+
import { readFileSync, writeFileSync, mkdirSync, existsSync, readdirSync, statSync } from 'node:fs';
|
|
9
|
+
import { basename, join, resolve, sep } from 'node:path';
|
|
10
10
|
import { createRequire } from 'node:module';
|
|
11
11
|
import { randomUUID } from 'node:crypto';
|
|
12
12
|
import { createSecretValueMasker } from '../secret-masker.mjs';
|
|
13
|
-
import { subjectToSlug, isValidSlug, uuidPrefix8, toDate, parseFrontmatter } from './utils.mjs';
|
|
13
|
+
import { subjectToSlug, isValidSlug, uuidPrefix8, recordKeySuffix8, toDate, parseFrontmatter } from './utils.mjs';
|
|
14
14
|
import { isRealSession } from '../session-schema/filters.mjs';
|
|
15
15
|
import { resolveRepoNamespace } from './namespace.mjs';
|
|
16
16
|
import { detectLearningSchema, normalizeLearningEntry, generateLearningNote, generateLearningNoteV2, learningSourceRecord } from './render-learnings.mjs';
|
|
17
17
|
import { detectSessionSchema, normalizeSessionEntry, generateSessionNote, generateSessionNoteV2, generateSessionNoteV3, sessionSourceRecord } from './render-sessions.mjs';
|
|
18
18
|
import { emitMirrorEvent } from './telemetry.mjs';
|
|
19
|
+
import {
|
|
20
|
+
ARCHIVE_ROOT,
|
|
21
|
+
canonicalNamespace,
|
|
22
|
+
legacyConcatSlug,
|
|
23
|
+
noteNarrativeChars,
|
|
24
|
+
markArchived,
|
|
25
|
+
noteInsightKey,
|
|
26
|
+
renderRollup,
|
|
27
|
+
rollupRelPath,
|
|
28
|
+
rollupRowFromNote,
|
|
29
|
+
sessionNarrativeChars,
|
|
30
|
+
} from './retention.mjs';
|
|
19
31
|
|
|
20
32
|
const GENERATOR_MARKER = 'session-orchestrator-vault-mirror@1';
|
|
21
33
|
|
|
@@ -64,10 +76,92 @@ function getSessionNoteSet(vaultDir) {
|
|
|
64
76
|
}
|
|
65
77
|
|
|
66
78
|
walk(sessionsDir);
|
|
79
|
+
// #1513: session notes the pruner moved to the archive keep their basename,
|
|
80
|
+
// and Obsidian resolves a wiki-link by basename — so a learning whose source
|
|
81
|
+
// session was archived still links. Without this walk every such learning
|
|
82
|
+
// would re-render its `source_session` as plain text and churn on each run.
|
|
83
|
+
walk(join(resolvedVault, ARCHIVE_ROOT, '50-sessions'));
|
|
67
84
|
_sessionNoteSets.set(resolvedVault, knownSessions);
|
|
68
85
|
return knownSessions;
|
|
69
86
|
}
|
|
70
87
|
|
|
88
|
+
// ── Insight index for write-time dedupe (#1513) ───────────────────────────────
|
|
89
|
+
|
|
90
|
+
/**
|
|
91
|
+
* Cache: `<dir>\0<dir>` → { stamp, index }. The stamp is the two directories'
|
|
92
|
+
* mtimes, which change whenever a file is added or removed — including by this
|
|
93
|
+
* process's own creates — so the index is rebuilt exactly when its population
|
|
94
|
+
* changed and reused otherwise (one read of the namespace per run, not per
|
|
95
|
+
* record).
|
|
96
|
+
*
|
|
97
|
+
* @type {Map<string, {stamp: string, index: Map<string, string>}>}
|
|
98
|
+
*/
|
|
99
|
+
const _insightIndexes = new Map();
|
|
100
|
+
|
|
101
|
+
function dirStamp(dir) {
|
|
102
|
+
try {
|
|
103
|
+
return String(statSync(dir).mtimeMs);
|
|
104
|
+
} catch {
|
|
105
|
+
return 'absent';
|
|
106
|
+
}
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
/**
|
|
110
|
+
* normalised insight → absolute path, over the mirror-owned, NOT archived
|
|
111
|
+
* learning notes directly in `dirs` (non-recursive), plus those in `flatDir`
|
|
112
|
+
* whose `source-repo` is `ns`. Read-only.
|
|
113
|
+
*
|
|
114
|
+
* @param {string[]} dirs
|
|
115
|
+
* @param {string|null} [flatDir]
|
|
116
|
+
* @param {string|null} [ns]
|
|
117
|
+
* @returns {Map<string, string>}
|
|
118
|
+
*/
|
|
119
|
+
function getInsightIndex(dirs, flatDir = null, ns = null) {
|
|
120
|
+
const key = [...dirs, flatDir ?? '', ns ?? ''].join('\0');
|
|
121
|
+
const stamp = [...dirs, ...(flatDir ? [flatDir] : [])].map(dirStamp).join('\0');
|
|
122
|
+
const cached = _insightIndexes.get(key);
|
|
123
|
+
if (cached && cached.stamp === stamp) return cached.index;
|
|
124
|
+
const index = new Map();
|
|
125
|
+
// #1513 review M3: a flat legacy note counts only when its `source-repo` is
|
|
126
|
+
// this namespace — a flat note of another repo, or one without attribution,
|
|
127
|
+
// must not refuse a genuine new learning of this repo.
|
|
128
|
+
const sources = [...dirs.map((d) => ({ dir: d, onlyRepo: null })), ...(flatDir ? [{ dir: flatDir, onlyRepo: ns }] : [])];
|
|
129
|
+
for (const { dir, onlyRepo } of sources) {
|
|
130
|
+
let names;
|
|
131
|
+
try {
|
|
132
|
+
names = readdirSync(dir, { withFileTypes: true });
|
|
133
|
+
} catch {
|
|
134
|
+
continue;
|
|
135
|
+
}
|
|
136
|
+
for (const ent of names) {
|
|
137
|
+
if (!ent.isFile() || !ent.name.endsWith('.md')) continue;
|
|
138
|
+
const abs = join(dir, ent.name);
|
|
139
|
+
let content;
|
|
140
|
+
try {
|
|
141
|
+
content = readFileSync(abs, 'utf8');
|
|
142
|
+
} catch {
|
|
143
|
+
continue;
|
|
144
|
+
}
|
|
145
|
+
const fm = parseFrontmatter(content);
|
|
146
|
+
if (!fm || fm['_generator'] !== GENERATOR_MARKER || fm['type'] !== 'learning') continue;
|
|
147
|
+
if (fm['status'] === 'archived') continue;
|
|
148
|
+
if (onlyRepo !== null && fm['source-repo'] !== onlyRepo) continue;
|
|
149
|
+
const insightKey = noteInsightKey(content);
|
|
150
|
+
if (insightKey && !index.has(insightKey)) index.set(insightKey, abs);
|
|
151
|
+
}
|
|
152
|
+
}
|
|
153
|
+
_insightIndexes.set(key, { stamp, index });
|
|
154
|
+
return index;
|
|
155
|
+
}
|
|
156
|
+
|
|
157
|
+
/** Today as `YYYY-MM-DD` from the injectable `ctx.now` (ms, ISO or Date). */
|
|
158
|
+
function todayOf(ctx) {
|
|
159
|
+
const now = ctx?.now;
|
|
160
|
+
const ms =
|
|
161
|
+
now instanceof Date ? now.getTime() : typeof now === 'number' ? now : typeof now === 'string' ? Date.parse(now) : NaN;
|
|
162
|
+
return new Date(Number.isFinite(ms) ? ms : Date.now()).toISOString().slice(0, 10);
|
|
163
|
+
}
|
|
164
|
+
|
|
71
165
|
// ── Content diff helper ───────────────────────────────────────────────────────
|
|
72
166
|
|
|
73
167
|
/**
|
|
@@ -329,6 +423,44 @@ function notOurGenerator(fm) {
|
|
|
329
423
|
return null;
|
|
330
424
|
}
|
|
331
425
|
|
|
426
|
+
/**
|
|
427
|
+
* The disambiguation slug a record uses under `slug` in `dir` (#1506.1). An
|
|
428
|
+
* EXISTING `<slug>-<uuid8>` note that is ours and owned by this record keeps
|
|
429
|
+
* its name — the owner decision was "no mass rename"; every other case gets the
|
|
430
|
+
* full-record-key hash suffix ({@link recordKeySuffix8}), so non-UUID ids that
|
|
431
|
+
* share their first 8 chars no longer land on one file.
|
|
432
|
+
*
|
|
433
|
+
* @param {string} dir - the namespace folder.
|
|
434
|
+
* @param {string} slug - the canonical note slug.
|
|
435
|
+
* @param {{ legacyId: string, recordKey: string }} keys - `legacyId` is what the
|
|
436
|
+
* pre-#1506 suffix was derived from (the raw id); `recordKey` the full key.
|
|
437
|
+
* @returns {{ slug: string, exists: boolean|null }} `exists: true` when the
|
|
438
|
+
* legacy name was chosen (it was just probed); `null` when not yet probed.
|
|
439
|
+
*/
|
|
440
|
+
function disambigSlugFor(dir, slug, { legacyId, recordKey }) {
|
|
441
|
+
const legacy = `${slug}-${uuidPrefix8(legacyId)}`;
|
|
442
|
+
const hashed = `${slug}-${recordKeySuffix8(recordKey)}`;
|
|
443
|
+
const legacyPath = join(dir, `${legacy}.md`);
|
|
444
|
+
if (legacy !== hashed && existsSync(legacyPath)) {
|
|
445
|
+
const fm = parseFrontmatter(readFileSync(legacyPath, 'utf8'));
|
|
446
|
+
if (notOurGenerator(fm) === null && foreignOwnerReason(fm, { recordKey }) === null) {
|
|
447
|
+
return { slug: legacy, exists: true };
|
|
448
|
+
}
|
|
449
|
+
}
|
|
450
|
+
return { slug: hashed, exists: null };
|
|
451
|
+
}
|
|
452
|
+
|
|
453
|
+
/**
|
|
454
|
+
* Replace the frontmatter `id:` line of a note — used when a displaced note
|
|
455
|
+
* moves to its disambiguated name (#1506.2), whose `id` must match the file.
|
|
456
|
+
* @param {string} content
|
|
457
|
+
* @param {string} id
|
|
458
|
+
* @returns {string}
|
|
459
|
+
*/
|
|
460
|
+
function withNoteId(content, id) {
|
|
461
|
+
return content.replace(/^id: .*$/m, `id: ${id}`);
|
|
462
|
+
}
|
|
463
|
+
|
|
332
464
|
/**
|
|
333
465
|
* Emit `skipped-foreign-owner` for a note the writer must not touch: one stderr
|
|
334
466
|
* `SKIP` line (vault-relative path, like every other SKIP line) and the stdout
|
|
@@ -763,6 +895,47 @@ function maskEntrySecrets(entry) {
|
|
|
763
895
|
|
|
764
896
|
// ── Core processing ───────────────────────────────────────────────────────────
|
|
765
897
|
|
|
898
|
+
/**
|
|
899
|
+
* The canonical note slug of a learning (extracted from processLearning for
|
|
900
|
+
* #1513 so the archive pass derives the SAME path). See the #725 D1 / #635
|
|
901
|
+
* notes inside.
|
|
902
|
+
*
|
|
903
|
+
* @param {object} entry normalised learning entry
|
|
904
|
+
* @param {'v1'|'v2'} schema
|
|
905
|
+
* @returns {string}
|
|
906
|
+
*/
|
|
907
|
+
function learningSlug(entry, schema) {
|
|
908
|
+
const entryId = entry.id;
|
|
909
|
+
let slugSource;
|
|
910
|
+
if (schema === 'v2') {
|
|
911
|
+
slugSource = entry.id;
|
|
912
|
+
} else if (typeof entry.subject === 'string') {
|
|
913
|
+
slugSource = entry.subject.trim().replace(/\s+/g, '-');
|
|
914
|
+
} else {
|
|
915
|
+
slugSource = entry.subject;
|
|
916
|
+
}
|
|
917
|
+
let slug;
|
|
918
|
+
if (typeof slugSource === 'string' && slugSource.length > 0) {
|
|
919
|
+
slug = subjectToSlug(slugSource);
|
|
920
|
+
} else {
|
|
921
|
+
slug = '';
|
|
922
|
+
}
|
|
923
|
+
if (!isValidSlug(slug)) {
|
|
924
|
+
slug = `learning-${uuidPrefix8(entryId)}`;
|
|
925
|
+
}
|
|
926
|
+
// #635: cap the slug so `<slug>.md` (plus a possible `-<uuid8>` disambig
|
|
927
|
+
// suffix) stays under the 255-byte filename limit. Normalized prose subjects
|
|
928
|
+
// can be arbitrarily long and previously aborted the whole mirror run with
|
|
929
|
+
// ENAMETOOLONG. 240 + 9 (disambig) + 3 (.md) = 252 — and every pre-existing
|
|
930
|
+
// vault note (max observed slug: 208 chars) keeps its identity untouched.
|
|
931
|
+
if (slug.length > 240) {
|
|
932
|
+
slug = slug.slice(0, 240).replace(/-+$/, '');
|
|
933
|
+
}
|
|
934
|
+
|
|
935
|
+
return slug;
|
|
936
|
+
}
|
|
937
|
+
|
|
938
|
+
|
|
766
939
|
export async function processLearning(rawEntry, _lineNum, ctx) {
|
|
767
940
|
const {
|
|
768
941
|
vaultDir,
|
|
@@ -806,34 +979,20 @@ export async function processLearning(rawEntry, _lineNum, ctx) {
|
|
|
806
979
|
// subject contract pinned by 15 tests in tests/unit/vault-mirror.test.mjs. The
|
|
807
980
|
// pre-map is the mechanism the brief itself names and yields the IDENTICAL slug
|
|
808
981
|
// for real data (kebab id derived from the same subject); see report.
|
|
809
|
-
let
|
|
810
|
-
if (schema === 'v2') {
|
|
811
|
-
slugSource = entry.id;
|
|
812
|
-
} else if (typeof entry.subject === 'string') {
|
|
813
|
-
slugSource = entry.subject.trim().replace(/\s+/g, '-');
|
|
814
|
-
} else {
|
|
815
|
-
slugSource = entry.subject;
|
|
816
|
-
}
|
|
817
|
-
let slug;
|
|
818
|
-
if (typeof slugSource === 'string' && slugSource.length > 0) {
|
|
819
|
-
slug = subjectToSlug(slugSource);
|
|
820
|
-
} else {
|
|
821
|
-
slug = '';
|
|
822
|
-
}
|
|
823
|
-
if (!isValidSlug(slug)) {
|
|
824
|
-
slug = `learning-${uuidPrefix8(entryId)}`;
|
|
825
|
-
}
|
|
826
|
-
// #635: cap the slug so `<slug>.md` (plus a possible `-<uuid8>` disambig
|
|
827
|
-
// suffix) stays under the 255-byte filename limit. Normalized prose subjects
|
|
828
|
-
// can be arbitrarily long and previously aborted the whole mirror run with
|
|
829
|
-
// ENAMETOOLONG. 240 + 9 (disambig) + 3 (.md) = 252 — and every pre-existing
|
|
830
|
-
// vault note (max observed slug: 208 chars) keeps its identity untouched.
|
|
831
|
-
if (slug.length > 240) {
|
|
832
|
-
slug = slug.slice(0, 240).replace(/-+$/, '');
|
|
833
|
-
}
|
|
982
|
+
let slug = learningSlug(entry, schema);
|
|
834
983
|
|
|
835
984
|
// Generator + date source differ by schema
|
|
836
|
-
const
|
|
985
|
+
const baseGenerator = schema === 'v2' ? generateLearningNoteV2 : generateLearningNote;
|
|
986
|
+
// #1513: an expired learning (v1 `expires_at` before today) is never CREATED,
|
|
987
|
+
// and an existing note of it is re-rendered as `status: archived` +
|
|
988
|
+
// `archived-reason: expired` (the canonical-field diff sees the status change
|
|
989
|
+
// and writes it once; afterwards the note is a noop). v2 records carry no
|
|
990
|
+
// expiry. The pruner moves such notes out of the active zone.
|
|
991
|
+
const expiresDay = schema === 'v1' && typeof entry.expires_at === 'string' ? toDate(entry.expires_at) : '';
|
|
992
|
+
const expired = /^\d{4}-\d{2}-\d{2}$/.test(expiresDay) && expiresDay < todayOf(ctx);
|
|
993
|
+
const generator = expired
|
|
994
|
+
? (e, s, o) => markArchived(baseGenerator(e, s, o), 'expired')
|
|
995
|
+
: baseGenerator;
|
|
837
996
|
const dateSource = schema === 'v2' ? entry.first_seen : entry.created_at;
|
|
838
997
|
|
|
839
998
|
// #704: Build a noteExists predicate from the vault's 50-sessions index so that
|
|
@@ -861,10 +1020,13 @@ export async function processLearning(rawEntry, _lineNum, ctx) {
|
|
|
861
1020
|
|
|
862
1021
|
// #660: namespace new writes under a per-repo subdirectory. #1389: `ctx.repoRoot`
|
|
863
1022
|
// is optional — absent, resolveRepoNamespace falls back to process.cwd().
|
|
864
|
-
|
|
865
|
-
|
|
866
|
-
|
|
867
|
-
|
|
1023
|
+
// #1513 review M2: a hyphen alias (`gotzendorferv2`) resolves to the
|
|
1024
|
+
// canonical folder already in the vault (`gotzendorfer-v2`), so the mirror
|
|
1025
|
+
// and the pruner agree on where a repo's notes live.
|
|
1026
|
+
const repoNs = canonicalNamespace(
|
|
1027
|
+
vaultDir,
|
|
1028
|
+
resolveRepoNamespace({ vaultName: ctx?.vaultName ?? null, repoRoot: ctx?.repoRoot ?? null }),
|
|
1029
|
+
);
|
|
868
1030
|
// #725 D2: thread the resolved repo namespace into the learning frontmatter as
|
|
869
1031
|
// `source-repo` for cross-repo attribution. repoNs is already sanitised +
|
|
870
1032
|
// leak-guarded by resolveRepoNamespace, so it is safe to interpolate as-is. The
|
|
@@ -877,11 +1039,78 @@ export async function processLearning(rawEntry, _lineNum, ctx) {
|
|
|
877
1039
|
// so two records sharing a subject keep two stable notes instead of
|
|
878
1040
|
// overwriting each other on every run.
|
|
879
1041
|
const writer = { recordKey: learningSourceRecord(entry) };
|
|
1042
|
+
|
|
1043
|
+
/**
|
|
1044
|
+
* #1513: the two reasons a NEW learning note is not created. Consulted only
|
|
1045
|
+
* where a file would come into existence — updates of existing notes are
|
|
1046
|
+
* never refused here. Returns the emitted action, or null to proceed.
|
|
1047
|
+
*
|
|
1048
|
+
* - `skipped-expired`: the learning is past `expires_at`; a note for it
|
|
1049
|
+
* would be dead on arrival.
|
|
1050
|
+
* - `skipped-duplicate-insight`: a mirror-owned, non-archived note in this
|
|
1051
|
+
* namespace (or the flat legacy layout) already carries the same
|
|
1052
|
+
* normalised insight — the same learning under another slug or id.
|
|
1053
|
+
*/
|
|
1054
|
+
const refuseNewNote = (content) => {
|
|
1055
|
+
if (expired) {
|
|
1056
|
+
return emitEntryAction(_lineNum, ctx, {
|
|
1057
|
+
action: 'skipped-expired',
|
|
1058
|
+
path: null,
|
|
1059
|
+
id: entryId,
|
|
1060
|
+
meta: { reason: `expires:${expiresDay} < today:${todayOf(ctx)}` },
|
|
1061
|
+
});
|
|
1062
|
+
}
|
|
1063
|
+
const insightKey = noteInsightKey(content);
|
|
1064
|
+
if (insightKey) {
|
|
1065
|
+
const twin = getInsightIndex([targetDir], join(resolve(vaultDir), '40-learnings'), repoNs).get(insightKey);
|
|
1066
|
+
if (twin !== undefined) {
|
|
1067
|
+
return emitEntryAction(_lineNum, ctx, {
|
|
1068
|
+
action: 'skipped-duplicate-insight',
|
|
1069
|
+
path: twin,
|
|
1070
|
+
id: entryId,
|
|
1071
|
+
meta: { reason: 'same normalised insight as an existing note' },
|
|
1072
|
+
});
|
|
1073
|
+
}
|
|
1074
|
+
}
|
|
1075
|
+
return null;
|
|
1076
|
+
};
|
|
880
1077
|
const targetDir = join(resolve(vaultDir), '40-learnings', repoNs);
|
|
881
1078
|
if (!dryRun) mkdirSync(targetDir, { recursive: true });
|
|
882
1079
|
|
|
883
1080
|
let targetPath = join(targetDir, `${slug}.md`);
|
|
884
1081
|
|
|
1082
|
+
// #1513 LEGACY SLUG: until #725 D1 a v1 subject was slugged WITHOUT the
|
|
1083
|
+
// whitespace → hyphen pre-map ("3parallelimplagents…"); the canonical form
|
|
1084
|
+
// hyphenates ("3-parallel-impl-agents…"). When only the legacy variant of
|
|
1085
|
+
// THIS record's note exists, it IS the note: it is updated in place under
|
|
1086
|
+
// its own name instead of a second file being created beside it (the vault
|
|
1087
|
+
// carried 364 such twins). New notes always get the canonical name.
|
|
1088
|
+
if (schema === 'v1') {
|
|
1089
|
+
const legacySlug = legacyConcatSlug(entry.subject);
|
|
1090
|
+
const flatDir = join(resolve(vaultDir), '40-learnings');
|
|
1091
|
+
if (
|
|
1092
|
+
isValidSlug(legacySlug) &&
|
|
1093
|
+
legacySlug !== slug &&
|
|
1094
|
+
!existsSync(targetPath) &&
|
|
1095
|
+
!existsSync(join(flatDir, `${slug}.md`))
|
|
1096
|
+
) {
|
|
1097
|
+
for (const dir of [targetDir, flatDir]) {
|
|
1098
|
+
const legacyPath = join(dir, `${legacySlug}.md`);
|
|
1099
|
+
if (!existsSync(legacyPath)) continue;
|
|
1100
|
+
const legacyFm = parseFrontmatter(readFileSync(legacyPath, 'utf8'));
|
|
1101
|
+
if (
|
|
1102
|
+
notOurGenerator(legacyFm) === null &&
|
|
1103
|
+
legacyFm['id'] === legacySlug &&
|
|
1104
|
+
foreignOwnerReason(legacyFm, writer) === null
|
|
1105
|
+
) {
|
|
1106
|
+
slug = legacySlug;
|
|
1107
|
+
targetPath = join(targetDir, `${legacySlug}.md`);
|
|
1108
|
+
break;
|
|
1109
|
+
}
|
|
1110
|
+
}
|
|
1111
|
+
}
|
|
1112
|
+
}
|
|
1113
|
+
|
|
885
1114
|
// #660 IDEMPOTENCY DUAL-PROBE: before treating the namespaced path as absent,
|
|
886
1115
|
// also check the legacy flat path. If a note with the same slug already exists
|
|
887
1116
|
// in the flat layout (pre-namespace migration), treat it as existing to avoid
|
|
@@ -963,26 +1192,58 @@ export async function processLearning(rawEntry, _lineNum, ctx) {
|
|
|
963
1192
|
// that note is masked in place first — never by rewriting it from THIS
|
|
964
1193
|
// record, which would be the #1503 overwrite triggered by a secret.
|
|
965
1194
|
//
|
|
966
|
-
//
|
|
967
|
-
//
|
|
968
|
-
//
|
|
969
|
-
//
|
|
970
|
-
//
|
|
971
|
-
//
|
|
972
|
-
//
|
|
1195
|
+
// #1506.2: the NEWEST record owns the main `<slug>.md` — a re-learned
|
|
1196
|
+
// record (strictly later `created` day) takes it over, and the displaced
|
|
1197
|
+
// note moves, bytes kept but `id` renamed, to its own record's
|
|
1198
|
+
// disambiguation path; never lost, never overwritten. #1506.1: a NEW
|
|
1199
|
+
// disambiguation file is suffixed with a hash of the full record key, so
|
|
1200
|
+
// non-UUID ids sharing a prefix no longer collide; existing `-<uuid8>`
|
|
1201
|
+
// files keep their names.
|
|
1202
|
+
//
|
|
1203
|
+
// BV-004 ceiling: "newest" is compared at DAY granularity (`created` is a
|
|
1204
|
+
// date) — a same-day re-learn keeps the current owner (stable, no churn).
|
|
1205
|
+
// Revisit-Trigger: a same-day re-learn is reported as a stale main note.
|
|
1206
|
+
//
|
|
1207
|
+
// BV-004 ceiling: the takeover ignores repo provenance — a NEWER record of
|
|
1208
|
+
// another repo mirrored into this namespace (the #1503 shape) also takes the
|
|
1209
|
+
// main note, and `source-repo` cannot tell it apart (both carry the writer's
|
|
1210
|
+
// namespace). Nothing is lost (the displaced note moves, never overwritten);
|
|
1211
|
+
// the CLI's `source-repo-mismatch` refusal is what keeps foreign records out.
|
|
1212
|
+
// Revisit-Trigger: a takeover whose displaced note belongs to this repo's
|
|
1213
|
+
// live store, or any mirror run accepting a foreign `--source` again.
|
|
973
1214
|
const recordMismatch = foreignOwnerReason(fm, writer);
|
|
974
1215
|
const routedByRecord = recordMismatch !== null;
|
|
975
1216
|
if (routedByRecord && maskerWouldChange(existingContent)) {
|
|
976
1217
|
healedForeignLeak = maskForeignNoteInPlace(targetPath, existingContent, ctx);
|
|
977
1218
|
}
|
|
978
1219
|
|
|
1220
|
+
const entryDay = toDate(dateSource);
|
|
1221
|
+
if (
|
|
1222
|
+
routedByRecord &&
|
|
1223
|
+
fm['id'] === slug &&
|
|
1224
|
+
!expired &&
|
|
1225
|
+
/^\d{4}-\d{2}-\d{2}$/.test(entryDay) &&
|
|
1226
|
+
typeof fm['created'] === 'string' &&
|
|
1227
|
+
entryDay > fm['created']
|
|
1228
|
+
) {
|
|
1229
|
+
const takenOver = takeOverMainNote({
|
|
1230
|
+
fm, slug, targetDir, targetPath, entry, entryId, writer, generator, generatorOpts,
|
|
1231
|
+
healedForeignLeak, lineNum: _lineNum, ctx,
|
|
1232
|
+
});
|
|
1233
|
+
if (takenOver !== null) return takenOver;
|
|
1234
|
+
}
|
|
1235
|
+
|
|
979
1236
|
if (fm['id'] !== slug || routedByRecord) {
|
|
980
1237
|
// Different id (or different record) → collision: disambiguate
|
|
981
|
-
const
|
|
1238
|
+
const picked = disambigSlugFor(targetDir, slug, {
|
|
1239
|
+
legacyId: String(entryId),
|
|
1240
|
+
recordKey: writer.recordKey ?? String(entryId),
|
|
1241
|
+
});
|
|
1242
|
+
const disambigSlug = picked.slug;
|
|
982
1243
|
targetPath = join(targetDir, `${disambigSlug}.md`);
|
|
983
1244
|
slug = disambigSlug;
|
|
984
1245
|
|
|
985
|
-
if (existsSync(targetPath)) {
|
|
1246
|
+
if (picked.exists ?? existsSync(targetPath)) {
|
|
986
1247
|
// Still exists with disambig — check if it's ours
|
|
987
1248
|
const disambigContent = readFileSync(targetPath, 'utf8');
|
|
988
1249
|
const disambigFm = parseFrontmatter(disambigContent);
|
|
@@ -1035,6 +1296,10 @@ export async function processLearning(rawEntry, _lineNum, ctx) {
|
|
|
1035
1296
|
}
|
|
1036
1297
|
|
|
1037
1298
|
const content = generator(entry, slug, generatorOpts);
|
|
1299
|
+
if (!existsSync(targetPath)) {
|
|
1300
|
+
const refused = refuseNewNote(content);
|
|
1301
|
+
if (refused !== null) return refused;
|
|
1302
|
+
}
|
|
1038
1303
|
if (!dryRun) writeFileSync(targetPath, content, 'utf8');
|
|
1039
1304
|
return emitEntryAction(_lineNum, ctx, {
|
|
1040
1305
|
action: 'skipped-collision-resolved',
|
|
@@ -1046,32 +1311,61 @@ export async function processLearning(rawEntry, _lineNum, ctx) {
|
|
|
1046
1311
|
});
|
|
1047
1312
|
}
|
|
1048
1313
|
|
|
1314
|
+
// #1506.2: the main note is this record's — a live disambiguation copy of
|
|
1315
|
+
// the same record (left by an interrupted takeover) is superseded.
|
|
1316
|
+
const stale = fm['source-record'] === writer.recordKey
|
|
1317
|
+
? staleOwnDisambig(targetDir, slug, entryId, writer)
|
|
1318
|
+
: null;
|
|
1319
|
+
|
|
1049
1320
|
// Same id: check if updated would advance (unless --force overrides).
|
|
1050
1321
|
// Even when the date has not advanced, content may have changed (confidence,
|
|
1051
1322
|
// insight, expires_at, etc.) — compare canonical fields before skipping.
|
|
1052
1323
|
const entryUpdated = toDate(dateSource);
|
|
1324
|
+
let mainUnchanged = false;
|
|
1053
1325
|
if (!force && fm['updated'] && fm['updated'] >= entryUpdated) {
|
|
1054
1326
|
const candidateContent = generator(entry, slug, generatorOpts);
|
|
1055
1327
|
if (learningContentMatches(existingContent, candidateContent)) {
|
|
1056
1328
|
// #1028 residue 1: probe the on-disk content against the CURRENT masker
|
|
1057
1329
|
// before trusting the five-field match — see maskerWouldChange above.
|
|
1058
|
-
|
|
1059
|
-
if (!stillLeaks) {
|
|
1060
|
-
return emitEntryAction(_lineNum, ctx, { action: 'skipped-noop', path: targetPath, id: slug });
|
|
1061
|
-
}
|
|
1330
|
+
mainUnchanged = !maskerWouldChange(existingContent);
|
|
1062
1331
|
}
|
|
1063
1332
|
// Content differs, or the on-disk note still leaks under the current env —
|
|
1064
1333
|
// fall through to overwrite (same path as date-advance branch).
|
|
1065
1334
|
}
|
|
1066
1335
|
|
|
1336
|
+
// #1519.4: archive the stale copy AND refresh the main note in the SAME run
|
|
1337
|
+
// (previously the stale branch returned first, so a simultaneous record
|
|
1338
|
+
// change reached the main note one run later). Stale copy first: a crash
|
|
1339
|
+
// between the two writes leaves an archived copy beside a not-yet-refreshed
|
|
1340
|
+
// main note, which the next run's content diff completes.
|
|
1341
|
+
if (stale !== null && !dryRun) writeFileSync(stale.path, stale.next, 'utf8');
|
|
1342
|
+
if (mainUnchanged) {
|
|
1343
|
+
if (stale !== null) {
|
|
1344
|
+
return emitEntryAction(_lineNum, ctx, {
|
|
1345
|
+
action: 'updated',
|
|
1346
|
+
path: stale.path,
|
|
1347
|
+
id: basename(stale.path, '.md'),
|
|
1348
|
+
meta: { archived_reason: 'superseded' },
|
|
1349
|
+
});
|
|
1350
|
+
}
|
|
1351
|
+
return emitEntryAction(_lineNum, ctx, { action: 'skipped-noop', path: targetPath, id: slug });
|
|
1352
|
+
}
|
|
1353
|
+
|
|
1067
1354
|
// Overwrite with advanced updated date (or forced re-render)
|
|
1068
1355
|
const content = generator(entry, slug, generatorOpts);
|
|
1069
1356
|
if (!dryRun) writeFileSync(targetPath, content, 'utf8');
|
|
1070
|
-
return emitEntryAction(_lineNum, ctx, {
|
|
1357
|
+
return emitEntryAction(_lineNum, ctx, {
|
|
1358
|
+
action: 'updated',
|
|
1359
|
+
path: targetPath,
|
|
1360
|
+
id: slug,
|
|
1361
|
+
...(stale !== null ? { meta: { superseded_id: basename(stale.path, '.md') } } : {}),
|
|
1362
|
+
});
|
|
1071
1363
|
}
|
|
1072
1364
|
|
|
1073
1365
|
// File does not exist — create
|
|
1074
1366
|
const content = generator(entry, slug, generatorOpts);
|
|
1367
|
+
const refused = refuseNewNote(content);
|
|
1368
|
+
if (refused !== null) return refused;
|
|
1075
1369
|
if (!dryRun) writeFileSync(targetPath, content, 'utf8');
|
|
1076
1370
|
return emitEntryAction(_lineNum, ctx, {
|
|
1077
1371
|
action: 'created',
|
|
@@ -1084,6 +1378,224 @@ export async function processLearning(rawEntry, _lineNum, ctx) {
|
|
|
1084
1378
|
});
|
|
1085
1379
|
}
|
|
1086
1380
|
|
|
1381
|
+
/**
|
|
1382
|
+
* The writer's own disambiguation note when it is stale (#1506.2): the writer's
|
|
1383
|
+
* record owns the main note, so a mirror-owned, not-yet-archived note of the
|
|
1384
|
+
* SAME record (explicit `source-record` match) at its disambiguation name is a
|
|
1385
|
+
* second live copy — it is marked `archived` / `superseded`. Consulted by the
|
|
1386
|
+
* takeover and on every run where the main note is already ours, so a crash
|
|
1387
|
+
* between a takeover's writes heals on the next run.
|
|
1388
|
+
*
|
|
1389
|
+
* @returns {{ path: string, next: string }|null} the rewrite, or null.
|
|
1390
|
+
*/
|
|
1391
|
+
function staleOwnDisambig(targetDir, slug, entryId, writer) {
|
|
1392
|
+
if (typeof writer.recordKey !== 'string') return null;
|
|
1393
|
+
const own = disambigSlugFor(targetDir, slug, { legacyId: String(entryId), recordKey: writer.recordKey });
|
|
1394
|
+
const path = join(targetDir, `${own.slug}.md`);
|
|
1395
|
+
if (!(own.exists ?? existsSync(path))) return null;
|
|
1396
|
+
const content = readFileSync(path, 'utf8');
|
|
1397
|
+
const fm = parseFrontmatter(content);
|
|
1398
|
+
if (notOurGenerator(fm) !== null || fm['source-record'] !== writer.recordKey || fm['id'] !== own.slug) return null;
|
|
1399
|
+
const next = markArchived(content, 'superseded');
|
|
1400
|
+
return next === content ? null : { path, next };
|
|
1401
|
+
}
|
|
1402
|
+
|
|
1403
|
+
/**
|
|
1404
|
+
* #1506.2 takeover: the writer's record is newer than the record that owns the
|
|
1405
|
+
* main note, so the writer claims `<slug>.md`. The displaced note moves to its
|
|
1406
|
+
* own record's disambiguation path (`id` renamed, bytes otherwise kept); a
|
|
1407
|
+
* stale disambiguation note of the writer's own record (written while the older
|
|
1408
|
+
* record held the main note) is marked `archived` / `superseded`.
|
|
1409
|
+
*
|
|
1410
|
+
* Returns `null` — leaving the caller on the plain disambiguation route — when
|
|
1411
|
+
* the displaced record's target path is occupied by a note that is not ours or
|
|
1412
|
+
* belongs to yet another record: a takeover must never overwrite anything.
|
|
1413
|
+
*
|
|
1414
|
+
* @returns {string|null} the emitted action, or null.
|
|
1415
|
+
*/
|
|
1416
|
+
function takeOverMainNote({
|
|
1417
|
+
fm, slug, targetDir, targetPath, entry, entryId, writer, generator, generatorOpts, healedForeignLeak, lineNum, ctx,
|
|
1418
|
+
}) {
|
|
1419
|
+
const { dryRun } = ctx;
|
|
1420
|
+
const displacedKey = fm['source-record'];
|
|
1421
|
+
const displaced = disambigSlugFor(targetDir, slug, { legacyId: displacedKey, recordKey: displacedKey });
|
|
1422
|
+
const displacedPath = join(targetDir, `${displaced.slug}.md`);
|
|
1423
|
+
if (displaced.exists !== true && existsSync(displacedPath)) {
|
|
1424
|
+
const dfm = parseFrontmatter(readFileSync(displacedPath, 'utf8'));
|
|
1425
|
+
if (notOurGenerator(dfm) !== null || dfm['source-record'] !== displacedKey) return null;
|
|
1426
|
+
}
|
|
1427
|
+
|
|
1428
|
+
const ownStale = staleOwnDisambig(targetDir, slug, entryId, writer);
|
|
1429
|
+
|
|
1430
|
+
if (!dryRun) {
|
|
1431
|
+
// Re-read: an in-place leak heal above may have rewritten the note.
|
|
1432
|
+
const current = readFileSync(targetPath, 'utf8');
|
|
1433
|
+
// Rewritten even when it exists (and is ours, checked above): a stale copy
|
|
1434
|
+
// there may carry `status: archived` from an earlier archive pass.
|
|
1435
|
+
writeFileSync(displacedPath, withNoteId(current, displaced.slug), 'utf8');
|
|
1436
|
+
// Before the main note: a crash between the two writes must leave the
|
|
1437
|
+
// takeover re-runnable (the main note still foreign), never a main note of
|
|
1438
|
+
// ours beside a stale live disambiguation note of the same record.
|
|
1439
|
+
if (ownStale !== null && ownStale.path !== displacedPath) writeFileSync(ownStale.path, ownStale.next, 'utf8');
|
|
1440
|
+
writeFileSync(targetPath, generator(entry, slug, generatorOpts), 'utf8');
|
|
1441
|
+
}
|
|
1442
|
+
return emitEntryAction(lineNum, ctx, {
|
|
1443
|
+
action: 'updated',
|
|
1444
|
+
path: targetPath,
|
|
1445
|
+
id: slug,
|
|
1446
|
+
meta: {
|
|
1447
|
+
reason: 'newer record owns the main note',
|
|
1448
|
+
displaced_id: displaced.slug,
|
|
1449
|
+
...(healedForeignLeak ? { healed_leak: true } : {}),
|
|
1450
|
+
},
|
|
1451
|
+
});
|
|
1452
|
+
}
|
|
1453
|
+
|
|
1454
|
+
/**
|
|
1455
|
+
* The note slugs a learning record can live under: the canonical slug, for v1
|
|
1456
|
+
* the pre-#725 legacy variant, and its two disambiguation names — the
|
|
1457
|
+
* pre-#1506 `-<uuid8>` and the full-key `-<hash8>` (#1506.1). Used by the CLI
|
|
1458
|
+
* to keep the archive pass off notes a LIVE record still owns (#1513). The
|
|
1459
|
+
* disambiguation names are record-specific, so a record displaced from the
|
|
1460
|
+
* main note by a re-learn (#1506.2) is still archived once it leaves the store.
|
|
1461
|
+
*
|
|
1462
|
+
* @param {object} rawEntry
|
|
1463
|
+
* @returns {string[]}
|
|
1464
|
+
*/
|
|
1465
|
+
export function learningNoteSlugs(rawEntry) {
|
|
1466
|
+
const entry = normalizeLearningEntry(rawEntry);
|
|
1467
|
+
if (!entry || entry.id === null || entry.id === undefined) return [];
|
|
1468
|
+
const schema = detectLearningSchema(entry);
|
|
1469
|
+
const slugs = [learningSlug(entry, schema)];
|
|
1470
|
+
if (schema === 'v1') {
|
|
1471
|
+
const legacy = legacyConcatSlug(entry.subject);
|
|
1472
|
+
if (isValidSlug(legacy) && !slugs.includes(legacy)) slugs.push(legacy);
|
|
1473
|
+
}
|
|
1474
|
+
const id = String(entry.id);
|
|
1475
|
+
for (const name of [`${slugs[0]}-${uuidPrefix8(id)}`, `${slugs[0]}-${recordKeySuffix8(learningSourceRecord(entry) ?? id)}`]) {
|
|
1476
|
+
if (!slugs.includes(name)) slugs.push(name);
|
|
1477
|
+
}
|
|
1478
|
+
return slugs;
|
|
1479
|
+
}
|
|
1480
|
+
|
|
1481
|
+
/**
|
|
1482
|
+
* Mark the existing note of a record that left `learnings.jsonl` (#1513).
|
|
1483
|
+
*
|
|
1484
|
+
* Input is one `learnings-archive.jsonl` record. Its note — canonical slug,
|
|
1485
|
+
* legacy slug, or a `-<uuid8>`/`-<hash8>` disambiguation, in the namespace or the flat
|
|
1486
|
+
* legacy layout — gets `status: archived` + `archived-reason` (`expired` for an
|
|
1487
|
+
* `_archive_reason: expired` record, `superseded` for every other reason). The
|
|
1488
|
+
* note's bytes are otherwise kept; nothing is ever created. Ownership rules are
|
|
1489
|
+
* the same as for writes: mirror-owned, and a `source-record` naming another
|
|
1490
|
+
* record is left alone. Names in `ctx.liveLearningSlugs` (records still in the
|
|
1491
|
+
* live store) are skipped, so a re-learned subject never flip-flops.
|
|
1492
|
+
*
|
|
1493
|
+
* @returns {Promise<string>} the emitted action
|
|
1494
|
+
*/
|
|
1495
|
+
export async function processArchivedLearning(rawEntry, _lineNum, ctx) {
|
|
1496
|
+
const { vaultDir, dryRun } = ctx;
|
|
1497
|
+
const entry = maskEntrySecrets(normalizeLearningEntry(rawEntry));
|
|
1498
|
+
if (!entry || entry.id === null || entry.id === undefined) {
|
|
1499
|
+
throw new Error(`vault-mirror: archived learning entry missing required field 'id' (id=<no id>)`);
|
|
1500
|
+
}
|
|
1501
|
+
const reason = entry._archive_reason === 'expired' ? 'expired' : 'superseded';
|
|
1502
|
+
const live = ctx?.liveLearningSlugs instanceof Set ? ctx.liveLearningSlugs : new Set();
|
|
1503
|
+
// Per name, not per record (#1506.2): a live re-learn owns the canonical
|
|
1504
|
+
// slug, but the displaced note at THIS record's disambiguation name is still
|
|
1505
|
+
// this record's to archive.
|
|
1506
|
+
const allNames = learningNoteSlugs(entry);
|
|
1507
|
+
const names = allNames.filter((s) => !live.has(s));
|
|
1508
|
+
const liveOwned = () =>
|
|
1509
|
+
emitEntryAction(_lineNum, ctx, {
|
|
1510
|
+
// `skipped-noop`, not a class of its own: this is the steady state of
|
|
1511
|
+
// every run, and noops stay out of the per-entry ledger (#1151).
|
|
1512
|
+
action: 'skipped-noop',
|
|
1513
|
+
path: null,
|
|
1514
|
+
id: entry.id,
|
|
1515
|
+
meta: { reason: 'archived record: a live record owns this note' },
|
|
1516
|
+
});
|
|
1517
|
+
if (names.length === 0) return liveOwned();
|
|
1518
|
+
const repoNs = canonicalNamespace(
|
|
1519
|
+
vaultDir,
|
|
1520
|
+
resolveRepoNamespace({ vaultName: ctx?.vaultName ?? null, repoRoot: ctx?.repoRoot ?? null }),
|
|
1521
|
+
);
|
|
1522
|
+
const nsDir = join(resolve(vaultDir), '40-learnings', repoNs);
|
|
1523
|
+
const flatDir = join(resolve(vaultDir), '40-learnings');
|
|
1524
|
+
const writer = { recordKey: learningSourceRecord(entry) };
|
|
1525
|
+
for (const dir of [nsDir, flatDir]) {
|
|
1526
|
+
for (const name of names) {
|
|
1527
|
+
const path = join(dir, `${name}.md`);
|
|
1528
|
+
if (!existsSync(path)) continue;
|
|
1529
|
+
const content = readFileSync(path, 'utf8');
|
|
1530
|
+
const fm = parseFrontmatter(content);
|
|
1531
|
+
if (notOurGenerator(fm) !== null || fm['id'] !== name) continue;
|
|
1532
|
+
if (foreignOwnerReason(fm, writer) !== null) continue;
|
|
1533
|
+
const next = markArchived(content, reason);
|
|
1534
|
+
if (next === content) {
|
|
1535
|
+
return emitEntryAction(_lineNum, ctx, { action: 'skipped-noop', path, id: name });
|
|
1536
|
+
}
|
|
1537
|
+
if (!dryRun) writeFileSync(path, next, 'utf8');
|
|
1538
|
+
return emitEntryAction(_lineNum, ctx, {
|
|
1539
|
+
action: 'updated',
|
|
1540
|
+
path,
|
|
1541
|
+
id: name,
|
|
1542
|
+
meta: { archived_reason: reason },
|
|
1543
|
+
});
|
|
1544
|
+
}
|
|
1545
|
+
}
|
|
1546
|
+
if (names.length < allNames.length) return liveOwned();
|
|
1547
|
+
return emitEntryAction(_lineNum, ctx, {
|
|
1548
|
+
action: 'skipped-noop',
|
|
1549
|
+
path: null,
|
|
1550
|
+
id: entry.id,
|
|
1551
|
+
meta: { reason: 'archived record: no note in the vault' },
|
|
1552
|
+
});
|
|
1553
|
+
}
|
|
1554
|
+
|
|
1555
|
+
/**
|
|
1556
|
+
* Route a metrics-only session record (#1513): a note that already exists for
|
|
1557
|
+
* it is left exactly as it is (the pruner decides about the bestand), and
|
|
1558
|
+
* otherwise the record becomes one row in its month rollup. Idempotent: an
|
|
1559
|
+
* unchanged rollup is a `skipped-noop`.
|
|
1560
|
+
*/
|
|
1561
|
+
async function rollUpSession({ renderedBody, session_id, repoNs, narrativeChars, lineNum, ctx }) {
|
|
1562
|
+
const { vaultDir, dryRun, qualityMinNarrativeChars = 400 } = ctx;
|
|
1563
|
+
const reason = `narrative:${narrativeChars} < min:${qualityMinNarrativeChars}`;
|
|
1564
|
+
const root = resolve(vaultDir);
|
|
1565
|
+
const existingNote = [join(root, '50-sessions', repoNs, `${session_id}.md`), join(root, '50-sessions', `${session_id}.md`)].find(
|
|
1566
|
+
(p) => existsSync(p),
|
|
1567
|
+
);
|
|
1568
|
+
const row = rollupRowFromNote(renderedBody, { id: session_id });
|
|
1569
|
+
if (existingNote !== undefined || row === null) {
|
|
1570
|
+
return emitEntryAction(lineNum, ctx, { action: 'skipped-quality-low', path: null, id: session_id, meta: { reason } });
|
|
1571
|
+
}
|
|
1572
|
+
const month = row.date.slice(0, 7);
|
|
1573
|
+
const rollupPath = join(root, rollupRelPath(repoNs, month));
|
|
1574
|
+
let existing = null;
|
|
1575
|
+
if (existsSync(rollupPath)) {
|
|
1576
|
+
existing = readFileSync(rollupPath, 'utf8');
|
|
1577
|
+
const notOurs = notOurGenerator(parseFrontmatter(existing));
|
|
1578
|
+
if (notOurs !== null) {
|
|
1579
|
+
process.stderr.write(`SKIP ${notOurs}: ${toVaultRelative(rollupPath, vaultDir)}\n`);
|
|
1580
|
+
return emitEntryAction(lineNum, ctx, { action: 'skipped-handwritten', path: rollupPath, id: session_id });
|
|
1581
|
+
}
|
|
1582
|
+
}
|
|
1583
|
+
const next = renderRollup(existing, [row], { ns: repoNs, month });
|
|
1584
|
+
if (next === existing) {
|
|
1585
|
+
return emitEntryAction(lineNum, ctx, { action: 'skipped-noop', path: rollupPath, id: session_id, meta: { reason } });
|
|
1586
|
+
}
|
|
1587
|
+
if (!dryRun) {
|
|
1588
|
+
mkdirSync(join(root, '50-sessions', repoNs), { recursive: true });
|
|
1589
|
+
writeFileSync(rollupPath, next, 'utf8');
|
|
1590
|
+
}
|
|
1591
|
+
return emitEntryAction(lineNum, ctx, {
|
|
1592
|
+
action: 'skipped-metrics-only',
|
|
1593
|
+
path: rollupPath,
|
|
1594
|
+
id: session_id,
|
|
1595
|
+
meta: { reason },
|
|
1596
|
+
});
|
|
1597
|
+
}
|
|
1598
|
+
|
|
1087
1599
|
export async function processSession(rawEntry, _lineNum, ctx) {
|
|
1088
1600
|
const {
|
|
1089
1601
|
vaultDir,
|
|
@@ -1159,32 +1671,28 @@ export async function processSession(rawEntry, _lineNum, ctx) {
|
|
|
1159
1671
|
// owner-leaky repo's real name reached the vault through the session-note
|
|
1160
1672
|
// frontmatter even though the directory AND the learning `source-repo` field
|
|
1161
1673
|
// were already pseudonym-mapped/redacted (#732 leak-guard bypass).
|
|
1162
|
-
const repoNs =
|
|
1163
|
-
|
|
1164
|
-
repoRoot: ctx?.repoRoot ?? null,
|
|
1165
|
-
|
|
1674
|
+
const repoNs = canonicalNamespace(
|
|
1675
|
+
vaultDir,
|
|
1676
|
+
resolveRepoNamespace({ vaultName: ctx?.vaultName ?? null, repoRoot: ctx?.repoRoot ?? null }),
|
|
1677
|
+
);
|
|
1166
1678
|
|
|
1167
|
-
//
|
|
1168
|
-
//
|
|
1169
|
-
//
|
|
1170
|
-
//
|
|
1171
|
-
//
|
|
1172
|
-
//
|
|
1173
|
-
//
|
|
1174
|
-
//
|
|
1175
|
-
//
|
|
1679
|
+
// Narrative gate (#1513, replaces the PRD F1.2 rendered-length gate). A
|
|
1680
|
+
// session gets its own note only when its FREE-TEXT fields (`notes`,
|
|
1681
|
+
// `narrative`, `summary` — see retention.mjs) carry at least
|
|
1682
|
+
// `qualityMinNarrativeChars` characters. The old gate measured the rendered
|
|
1683
|
+
// body INCLUDING the wave/agent tables, so nearly every record passed: 2.728
|
|
1684
|
+
// of 3.003 vault session notes were tables with no information beyond git
|
|
1685
|
+
// (Jev audit 2026-10-03). A record below the gate becomes one row in the
|
|
1686
|
+
// month rollup `50-sessions/<ns>/_rollup-YYYY-MM.md` instead. `0` disables
|
|
1687
|
+
// the gate. Runs BEFORE --force; --force does NOT bypass it.
|
|
1688
|
+
//
|
|
1689
|
+
// The render still happens first: a generator `vault-mirror: …` validation
|
|
1690
|
+
// error must keep surfacing as `skipped-invalid`, and the rollup row is read
|
|
1691
|
+
// off the rendered note so the mirror and the pruner share one parser.
|
|
1176
1692
|
const renderedBody = generator(entry, { repoNs });
|
|
1177
|
-
|
|
1178
|
-
// measure only narrative content, not boilerplate.
|
|
1179
|
-
const narrativeBody = renderedBody.replace(/^---[\s\S]*?---/m, '').trim();
|
|
1180
|
-
const narrativeChars = narrativeBody.length;
|
|
1693
|
+
const narrativeChars = sessionNarrativeChars(entry);
|
|
1181
1694
|
if (narrativeChars < qualityMinNarrativeChars) {
|
|
1182
|
-
return
|
|
1183
|
-
action: 'skipped-quality-low',
|
|
1184
|
-
path: null,
|
|
1185
|
-
id: session_id,
|
|
1186
|
-
meta: { reason: `narrative:${narrativeChars} < min:${qualityMinNarrativeChars}` },
|
|
1187
|
-
});
|
|
1695
|
+
return rollUpSession({ renderedBody, session_id, repoNs, narrativeChars, lineNum: _lineNum, ctx });
|
|
1188
1696
|
}
|
|
1189
1697
|
|
|
1190
1698
|
// #660: namespace new writes under a per-repo subdirectory. repoNs was
|
|
@@ -1239,7 +1747,8 @@ export async function processSession(rawEntry, _lineNum, ctx) {
|
|
|
1239
1747
|
}
|
|
1240
1748
|
if (legacyMarked && !legacyForeign) {
|
|
1241
1749
|
const entryUpdated = toDate(entry.completed_at);
|
|
1242
|
-
|
|
1750
|
+
const legacyNarrativeStale = noteNarrativeChars(legacyContent) !== noteNarrativeChars(renderedBody);
|
|
1751
|
+
if (!force && !legacyStillLeaks && !legacyNarrativeStale && legacyFm['updated'] && legacyFm['updated'] >= entryUpdated) {
|
|
1243
1752
|
return emitEntryAction(_lineNum, ctx, { action: 'skipped-noop', path: legacyFlatPath, id: session_id });
|
|
1244
1753
|
}
|
|
1245
1754
|
// Updated date would advance, or the on-disk note still leaks — fall
|
|
@@ -1285,7 +1794,11 @@ export async function processSession(rawEntry, _lineNum, ctx) {
|
|
|
1285
1794
|
}
|
|
1286
1795
|
|
|
1287
1796
|
const entryUpdated = toDate(entry.completed_at);
|
|
1288
|
-
|
|
1797
|
+
// #1513 review H2: a note rendered before narrative/summary were printed
|
|
1798
|
+
// carries less free text than the record — heal it once, or the pruner
|
|
1799
|
+
// archives it as metrics-only and the mirror re-creates it every run.
|
|
1800
|
+
const narrativeStale = noteNarrativeChars(existingContent) !== noteNarrativeChars(renderedBody);
|
|
1801
|
+
if (!force && !existingLeaks && !narrativeStale && fm['updated'] && fm['updated'] >= entryUpdated) {
|
|
1289
1802
|
return emitEntryAction(_lineNum, ctx, { action: 'skipped-noop', path: targetPath, id: session_id });
|
|
1290
1803
|
}
|
|
1291
1804
|
if (!dryRun) writeFileSync(targetPath, renderedBody, 'utf8');
|