session-orchestrator 5.5.0 → 5.7.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/.claude-plugin/marketplace.json +1 -1
- package/.claude-plugin/plugin.json +1 -1
- package/.codex-plugin/plugin.json +1 -1
- package/.cursor-plugin/plugin.json +1 -1
- package/CHANGELOG.md +46 -0
- package/README.md +27 -13
- package/docs/USER-GUIDE.md +1 -1
- package/docs/rule-authoring.md +10 -0
- package/docs/scope-collision-guard.md +5 -3
- package/docs/session-config-reference.md +1 -1
- package/docs/session-config-template.md +1 -1
- package/hooks/hooks-codex.json +1 -1
- package/hooks/hooks.json +1 -1
- package/hooks/on-stop.mjs +8 -2
- package/hooks/post-bash-write-verify.mjs +121 -7
- package/hooks/post-tool-batch-wave-signal.mjs +11 -4
- package/hooks/pre-bash-memory-propose-audit.mjs +13 -8
- package/hooks/pre-task-scope-disjoint.mjs +20 -35
- package/hooks/wave-scope-commit-guard.mjs +117 -20
- package/package.json +8 -3
- package/scripts/backfill-abandoned-sessions.mjs +5 -7
- package/scripts/backfill-learnings-from-vault.mjs +17 -6
- package/scripts/lib/dispatcher/rank.mjs +5 -1
- package/scripts/lib/events.mjs +62 -49
- package/scripts/lib/io.mjs +9 -4
- package/scripts/lib/learnings/candidates.mjs +7 -16
- package/scripts/lib/memory-proposals/store.mjs +9 -2
- package/scripts/lib/reconcile/rule-expiry-sweep.mjs +52 -0
- package/scripts/lib/scope-gate.mjs +8 -1
- package/scripts/lib/session-close-backfill.mjs +104 -13
- package/scripts/lib/validate/check-test-value-bans.mjs +9 -0
- package/scripts/lib/vault-mirror/process.mjs +388 -56
- 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-status/board-writer.mjs +6 -6
- package/scripts/lib/wave-transcript-tail.mjs +11 -2
- package/scripts/print-applicable-rules.mjs +1 -25
- package/scripts/release.mjs +4 -0
- package/scripts/validate-wave-scope.mjs +39 -4
- package/scripts/vault-mirror-prune.mjs +145 -0
- package/scripts/vault-mirror.mjs +77 -12
- package/skills/_shared/parallel-aware-auq.md +1 -1
- package/skills/claude-md-drift-check/checker.mjs +6 -16
- package/skills/vault-mirror/SKILL.md +27 -2
- package/skills/wave-executor/references/wave-loop-scope-manifest.md +3 -1
- 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,7 +5,7 @@
|
|
|
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';
|
|
8
|
+
import { readFileSync, writeFileSync, mkdirSync, existsSync, readdirSync, statSync } from 'node:fs';
|
|
9
9
|
import { join, resolve, sep } from 'node:path';
|
|
10
10
|
import { createRequire } from 'node:module';
|
|
11
11
|
import { randomUUID } from 'node:crypto';
|
|
@@ -16,6 +16,18 @@ 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
|
/**
|
|
@@ -763,6 +857,47 @@ function maskEntrySecrets(entry) {
|
|
|
763
857
|
|
|
764
858
|
// ── Core processing ───────────────────────────────────────────────────────────
|
|
765
859
|
|
|
860
|
+
/**
|
|
861
|
+
* The canonical note slug of a learning (extracted from processLearning for
|
|
862
|
+
* #1513 so the archive pass derives the SAME path). See the #725 D1 / #635
|
|
863
|
+
* notes inside.
|
|
864
|
+
*
|
|
865
|
+
* @param {object} entry normalised learning entry
|
|
866
|
+
* @param {'v1'|'v2'} schema
|
|
867
|
+
* @returns {string}
|
|
868
|
+
*/
|
|
869
|
+
function learningSlug(entry, schema) {
|
|
870
|
+
const entryId = entry.id;
|
|
871
|
+
let slugSource;
|
|
872
|
+
if (schema === 'v2') {
|
|
873
|
+
slugSource = entry.id;
|
|
874
|
+
} else if (typeof entry.subject === 'string') {
|
|
875
|
+
slugSource = entry.subject.trim().replace(/\s+/g, '-');
|
|
876
|
+
} else {
|
|
877
|
+
slugSource = entry.subject;
|
|
878
|
+
}
|
|
879
|
+
let slug;
|
|
880
|
+
if (typeof slugSource === 'string' && slugSource.length > 0) {
|
|
881
|
+
slug = subjectToSlug(slugSource);
|
|
882
|
+
} else {
|
|
883
|
+
slug = '';
|
|
884
|
+
}
|
|
885
|
+
if (!isValidSlug(slug)) {
|
|
886
|
+
slug = `learning-${uuidPrefix8(entryId)}`;
|
|
887
|
+
}
|
|
888
|
+
// #635: cap the slug so `<slug>.md` (plus a possible `-<uuid8>` disambig
|
|
889
|
+
// suffix) stays under the 255-byte filename limit. Normalized prose subjects
|
|
890
|
+
// can be arbitrarily long and previously aborted the whole mirror run with
|
|
891
|
+
// ENAMETOOLONG. 240 + 9 (disambig) + 3 (.md) = 252 — and every pre-existing
|
|
892
|
+
// vault note (max observed slug: 208 chars) keeps its identity untouched.
|
|
893
|
+
if (slug.length > 240) {
|
|
894
|
+
slug = slug.slice(0, 240).replace(/-+$/, '');
|
|
895
|
+
}
|
|
896
|
+
|
|
897
|
+
return slug;
|
|
898
|
+
}
|
|
899
|
+
|
|
900
|
+
|
|
766
901
|
export async function processLearning(rawEntry, _lineNum, ctx) {
|
|
767
902
|
const {
|
|
768
903
|
vaultDir,
|
|
@@ -806,34 +941,20 @@ export async function processLearning(rawEntry, _lineNum, ctx) {
|
|
|
806
941
|
// subject contract pinned by 15 tests in tests/unit/vault-mirror.test.mjs. The
|
|
807
942
|
// pre-map is the mechanism the brief itself names and yields the IDENTICAL slug
|
|
808
943
|
// 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
|
-
}
|
|
944
|
+
let slug = learningSlug(entry, schema);
|
|
834
945
|
|
|
835
946
|
// Generator + date source differ by schema
|
|
836
|
-
const
|
|
947
|
+
const baseGenerator = schema === 'v2' ? generateLearningNoteV2 : generateLearningNote;
|
|
948
|
+
// #1513: an expired learning (v1 `expires_at` before today) is never CREATED,
|
|
949
|
+
// and an existing note of it is re-rendered as `status: archived` +
|
|
950
|
+
// `archived-reason: expired` (the canonical-field diff sees the status change
|
|
951
|
+
// and writes it once; afterwards the note is a noop). v2 records carry no
|
|
952
|
+
// expiry. The pruner moves such notes out of the active zone.
|
|
953
|
+
const expiresDay = schema === 'v1' && typeof entry.expires_at === 'string' ? toDate(entry.expires_at) : '';
|
|
954
|
+
const expired = /^\d{4}-\d{2}-\d{2}$/.test(expiresDay) && expiresDay < todayOf(ctx);
|
|
955
|
+
const generator = expired
|
|
956
|
+
? (e, s, o) => markArchived(baseGenerator(e, s, o), 'expired')
|
|
957
|
+
: baseGenerator;
|
|
837
958
|
const dateSource = schema === 'v2' ? entry.first_seen : entry.created_at;
|
|
838
959
|
|
|
839
960
|
// #704: Build a noteExists predicate from the vault's 50-sessions index so that
|
|
@@ -861,10 +982,13 @@ export async function processLearning(rawEntry, _lineNum, ctx) {
|
|
|
861
982
|
|
|
862
983
|
// #660: namespace new writes under a per-repo subdirectory. #1389: `ctx.repoRoot`
|
|
863
984
|
// is optional — absent, resolveRepoNamespace falls back to process.cwd().
|
|
864
|
-
|
|
865
|
-
|
|
866
|
-
|
|
867
|
-
|
|
985
|
+
// #1513 review M2: a hyphen alias (`gotzendorferv2`) resolves to the
|
|
986
|
+
// canonical folder already in the vault (`gotzendorfer-v2`), so the mirror
|
|
987
|
+
// and the pruner agree on where a repo's notes live.
|
|
988
|
+
const repoNs = canonicalNamespace(
|
|
989
|
+
vaultDir,
|
|
990
|
+
resolveRepoNamespace({ vaultName: ctx?.vaultName ?? null, repoRoot: ctx?.repoRoot ?? null }),
|
|
991
|
+
);
|
|
868
992
|
// #725 D2: thread the resolved repo namespace into the learning frontmatter as
|
|
869
993
|
// `source-repo` for cross-repo attribution. repoNs is already sanitised +
|
|
870
994
|
// leak-guarded by resolveRepoNamespace, so it is safe to interpolate as-is. The
|
|
@@ -877,11 +1001,78 @@ export async function processLearning(rawEntry, _lineNum, ctx) {
|
|
|
877
1001
|
// so two records sharing a subject keep two stable notes instead of
|
|
878
1002
|
// overwriting each other on every run.
|
|
879
1003
|
const writer = { recordKey: learningSourceRecord(entry) };
|
|
1004
|
+
|
|
1005
|
+
/**
|
|
1006
|
+
* #1513: the two reasons a NEW learning note is not created. Consulted only
|
|
1007
|
+
* where a file would come into existence — updates of existing notes are
|
|
1008
|
+
* never refused here. Returns the emitted action, or null to proceed.
|
|
1009
|
+
*
|
|
1010
|
+
* - `skipped-expired`: the learning is past `expires_at`; a note for it
|
|
1011
|
+
* would be dead on arrival.
|
|
1012
|
+
* - `skipped-duplicate-insight`: a mirror-owned, non-archived note in this
|
|
1013
|
+
* namespace (or the flat legacy layout) already carries the same
|
|
1014
|
+
* normalised insight — the same learning under another slug or id.
|
|
1015
|
+
*/
|
|
1016
|
+
const refuseNewNote = (content) => {
|
|
1017
|
+
if (expired) {
|
|
1018
|
+
return emitEntryAction(_lineNum, ctx, {
|
|
1019
|
+
action: 'skipped-expired',
|
|
1020
|
+
path: null,
|
|
1021
|
+
id: entryId,
|
|
1022
|
+
meta: { reason: `expires:${expiresDay} < today:${todayOf(ctx)}` },
|
|
1023
|
+
});
|
|
1024
|
+
}
|
|
1025
|
+
const insightKey = noteInsightKey(content);
|
|
1026
|
+
if (insightKey) {
|
|
1027
|
+
const twin = getInsightIndex([targetDir], join(resolve(vaultDir), '40-learnings'), repoNs).get(insightKey);
|
|
1028
|
+
if (twin !== undefined) {
|
|
1029
|
+
return emitEntryAction(_lineNum, ctx, {
|
|
1030
|
+
action: 'skipped-duplicate-insight',
|
|
1031
|
+
path: twin,
|
|
1032
|
+
id: entryId,
|
|
1033
|
+
meta: { reason: 'same normalised insight as an existing note' },
|
|
1034
|
+
});
|
|
1035
|
+
}
|
|
1036
|
+
}
|
|
1037
|
+
return null;
|
|
1038
|
+
};
|
|
880
1039
|
const targetDir = join(resolve(vaultDir), '40-learnings', repoNs);
|
|
881
1040
|
if (!dryRun) mkdirSync(targetDir, { recursive: true });
|
|
882
1041
|
|
|
883
1042
|
let targetPath = join(targetDir, `${slug}.md`);
|
|
884
1043
|
|
|
1044
|
+
// #1513 LEGACY SLUG: until #725 D1 a v1 subject was slugged WITHOUT the
|
|
1045
|
+
// whitespace → hyphen pre-map ("3parallelimplagents…"); the canonical form
|
|
1046
|
+
// hyphenates ("3-parallel-impl-agents…"). When only the legacy variant of
|
|
1047
|
+
// THIS record's note exists, it IS the note: it is updated in place under
|
|
1048
|
+
// its own name instead of a second file being created beside it (the vault
|
|
1049
|
+
// carried 364 such twins). New notes always get the canonical name.
|
|
1050
|
+
if (schema === 'v1') {
|
|
1051
|
+
const legacySlug = legacyConcatSlug(entry.subject);
|
|
1052
|
+
const flatDir = join(resolve(vaultDir), '40-learnings');
|
|
1053
|
+
if (
|
|
1054
|
+
isValidSlug(legacySlug) &&
|
|
1055
|
+
legacySlug !== slug &&
|
|
1056
|
+
!existsSync(targetPath) &&
|
|
1057
|
+
!existsSync(join(flatDir, `${slug}.md`))
|
|
1058
|
+
) {
|
|
1059
|
+
for (const dir of [targetDir, flatDir]) {
|
|
1060
|
+
const legacyPath = join(dir, `${legacySlug}.md`);
|
|
1061
|
+
if (!existsSync(legacyPath)) continue;
|
|
1062
|
+
const legacyFm = parseFrontmatter(readFileSync(legacyPath, 'utf8'));
|
|
1063
|
+
if (
|
|
1064
|
+
notOurGenerator(legacyFm) === null &&
|
|
1065
|
+
legacyFm['id'] === legacySlug &&
|
|
1066
|
+
foreignOwnerReason(legacyFm, writer) === null
|
|
1067
|
+
) {
|
|
1068
|
+
slug = legacySlug;
|
|
1069
|
+
targetPath = join(targetDir, `${legacySlug}.md`);
|
|
1070
|
+
break;
|
|
1071
|
+
}
|
|
1072
|
+
}
|
|
1073
|
+
}
|
|
1074
|
+
}
|
|
1075
|
+
|
|
885
1076
|
// #660 IDEMPOTENCY DUAL-PROBE: before treating the namespaced path as absent,
|
|
886
1077
|
// also check the legacy flat path. If a note with the same slug already exists
|
|
887
1078
|
// in the flat layout (pre-namespace migration), treat it as existing to avoid
|
|
@@ -1035,6 +1226,10 @@ export async function processLearning(rawEntry, _lineNum, ctx) {
|
|
|
1035
1226
|
}
|
|
1036
1227
|
|
|
1037
1228
|
const content = generator(entry, slug, generatorOpts);
|
|
1229
|
+
if (!existsSync(targetPath)) {
|
|
1230
|
+
const refused = refuseNewNote(content);
|
|
1231
|
+
if (refused !== null) return refused;
|
|
1232
|
+
}
|
|
1038
1233
|
if (!dryRun) writeFileSync(targetPath, content, 'utf8');
|
|
1039
1234
|
return emitEntryAction(_lineNum, ctx, {
|
|
1040
1235
|
action: 'skipped-collision-resolved',
|
|
@@ -1072,6 +1267,8 @@ export async function processLearning(rawEntry, _lineNum, ctx) {
|
|
|
1072
1267
|
|
|
1073
1268
|
// File does not exist — create
|
|
1074
1269
|
const content = generator(entry, slug, generatorOpts);
|
|
1270
|
+
const refused = refuseNewNote(content);
|
|
1271
|
+
if (refused !== null) return refused;
|
|
1075
1272
|
if (!dryRun) writeFileSync(targetPath, content, 'utf8');
|
|
1076
1273
|
return emitEntryAction(_lineNum, ctx, {
|
|
1077
1274
|
action: 'created',
|
|
@@ -1084,6 +1281,140 @@ export async function processLearning(rawEntry, _lineNum, ctx) {
|
|
|
1084
1281
|
});
|
|
1085
1282
|
}
|
|
1086
1283
|
|
|
1284
|
+
/**
|
|
1285
|
+
* The note slugs a learning record can live under: the canonical slug and, for
|
|
1286
|
+
* v1, the pre-#725 legacy variant. Used by the CLI to keep the archive pass off
|
|
1287
|
+
* notes a LIVE record still owns (#1513).
|
|
1288
|
+
*
|
|
1289
|
+
* @param {object} rawEntry
|
|
1290
|
+
* @returns {string[]}
|
|
1291
|
+
*/
|
|
1292
|
+
export function learningNoteSlugs(rawEntry) {
|
|
1293
|
+
const entry = normalizeLearningEntry(rawEntry);
|
|
1294
|
+
if (!entry || entry.id === null || entry.id === undefined) return [];
|
|
1295
|
+
const schema = detectLearningSchema(entry);
|
|
1296
|
+
const slugs = [learningSlug(entry, schema)];
|
|
1297
|
+
if (schema === 'v1') {
|
|
1298
|
+
const legacy = legacyConcatSlug(entry.subject);
|
|
1299
|
+
if (isValidSlug(legacy) && !slugs.includes(legacy)) slugs.push(legacy);
|
|
1300
|
+
}
|
|
1301
|
+
return slugs;
|
|
1302
|
+
}
|
|
1303
|
+
|
|
1304
|
+
/**
|
|
1305
|
+
* Mark the existing note of a record that left `learnings.jsonl` (#1513).
|
|
1306
|
+
*
|
|
1307
|
+
* Input is one `learnings-archive.jsonl` record. Its note — canonical slug,
|
|
1308
|
+
* legacy slug, or the `-<uuid8>` disambiguation, in the namespace or the flat
|
|
1309
|
+
* legacy layout — gets `status: archived` + `archived-reason` (`expired` for an
|
|
1310
|
+
* `_archive_reason: expired` record, `superseded` for every other reason). The
|
|
1311
|
+
* note's bytes are otherwise kept; nothing is ever created. Ownership rules are
|
|
1312
|
+
* the same as for writes: mirror-owned, and a `source-record` naming another
|
|
1313
|
+
* record is left alone. Slugs in `ctx.liveLearningSlugs` (records still in the
|
|
1314
|
+
* live store) are skipped, so a re-learned subject never flip-flops.
|
|
1315
|
+
*
|
|
1316
|
+
* @returns {Promise<string>} the emitted action
|
|
1317
|
+
*/
|
|
1318
|
+
export async function processArchivedLearning(rawEntry, _lineNum, ctx) {
|
|
1319
|
+
const { vaultDir, dryRun } = ctx;
|
|
1320
|
+
const entry = maskEntrySecrets(normalizeLearningEntry(rawEntry));
|
|
1321
|
+
if (!entry || entry.id === null || entry.id === undefined) {
|
|
1322
|
+
throw new Error(`vault-mirror: archived learning entry missing required field 'id' (id=<no id>)`);
|
|
1323
|
+
}
|
|
1324
|
+
const reason = entry._archive_reason === 'expired' ? 'expired' : 'superseded';
|
|
1325
|
+
const slugs = learningNoteSlugs(entry);
|
|
1326
|
+
const live = ctx?.liveLearningSlugs instanceof Set ? ctx.liveLearningSlugs : new Set();
|
|
1327
|
+
if (slugs.some((s) => live.has(s))) {
|
|
1328
|
+
return emitEntryAction(_lineNum, ctx, {
|
|
1329
|
+
// `skipped-noop`, not a class of its own: this is the steady state of
|
|
1330
|
+
// every run, and noops stay out of the per-entry ledger (#1151).
|
|
1331
|
+
action: 'skipped-noop',
|
|
1332
|
+
path: null,
|
|
1333
|
+
id: entry.id,
|
|
1334
|
+
meta: { reason: 'archived record: a live record owns this note' },
|
|
1335
|
+
});
|
|
1336
|
+
}
|
|
1337
|
+
const repoNs = canonicalNamespace(
|
|
1338
|
+
vaultDir,
|
|
1339
|
+
resolveRepoNamespace({ vaultName: ctx?.vaultName ?? null, repoRoot: ctx?.repoRoot ?? null }),
|
|
1340
|
+
);
|
|
1341
|
+
const nsDir = join(resolve(vaultDir), '40-learnings', repoNs);
|
|
1342
|
+
const flatDir = join(resolve(vaultDir), '40-learnings');
|
|
1343
|
+
const writer = { recordKey: learningSourceRecord(entry) };
|
|
1344
|
+
const names = [...slugs, `${slugs[0]}-${uuidPrefix8(String(entry.id))}`];
|
|
1345
|
+
for (const dir of [nsDir, flatDir]) {
|
|
1346
|
+
for (const name of names) {
|
|
1347
|
+
const path = join(dir, `${name}.md`);
|
|
1348
|
+
if (!existsSync(path)) continue;
|
|
1349
|
+
const content = readFileSync(path, 'utf8');
|
|
1350
|
+
const fm = parseFrontmatter(content);
|
|
1351
|
+
if (notOurGenerator(fm) !== null || fm['id'] !== name) continue;
|
|
1352
|
+
if (foreignOwnerReason(fm, writer) !== null) continue;
|
|
1353
|
+
const next = markArchived(content, reason);
|
|
1354
|
+
if (next === content) {
|
|
1355
|
+
return emitEntryAction(_lineNum, ctx, { action: 'skipped-noop', path, id: name });
|
|
1356
|
+
}
|
|
1357
|
+
if (!dryRun) writeFileSync(path, next, 'utf8');
|
|
1358
|
+
return emitEntryAction(_lineNum, ctx, {
|
|
1359
|
+
action: 'updated',
|
|
1360
|
+
path,
|
|
1361
|
+
id: name,
|
|
1362
|
+
meta: { archived_reason: reason },
|
|
1363
|
+
});
|
|
1364
|
+
}
|
|
1365
|
+
}
|
|
1366
|
+
return emitEntryAction(_lineNum, ctx, {
|
|
1367
|
+
action: 'skipped-noop',
|
|
1368
|
+
path: null,
|
|
1369
|
+
id: entry.id,
|
|
1370
|
+
meta: { reason: 'archived record: no note in the vault' },
|
|
1371
|
+
});
|
|
1372
|
+
}
|
|
1373
|
+
|
|
1374
|
+
/**
|
|
1375
|
+
* Route a metrics-only session record (#1513): a note that already exists for
|
|
1376
|
+
* it is left exactly as it is (the pruner decides about the bestand), and
|
|
1377
|
+
* otherwise the record becomes one row in its month rollup. Idempotent: an
|
|
1378
|
+
* unchanged rollup is a `skipped-noop`.
|
|
1379
|
+
*/
|
|
1380
|
+
async function rollUpSession({ renderedBody, session_id, repoNs, narrativeChars, lineNum, ctx }) {
|
|
1381
|
+
const { vaultDir, dryRun, qualityMinNarrativeChars = 400 } = ctx;
|
|
1382
|
+
const reason = `narrative:${narrativeChars} < min:${qualityMinNarrativeChars}`;
|
|
1383
|
+
const root = resolve(vaultDir);
|
|
1384
|
+
const existingNote = [join(root, '50-sessions', repoNs, `${session_id}.md`), join(root, '50-sessions', `${session_id}.md`)].find(
|
|
1385
|
+
(p) => existsSync(p),
|
|
1386
|
+
);
|
|
1387
|
+
const row = rollupRowFromNote(renderedBody, { id: session_id });
|
|
1388
|
+
if (existingNote !== undefined || row === null) {
|
|
1389
|
+
return emitEntryAction(lineNum, ctx, { action: 'skipped-quality-low', path: null, id: session_id, meta: { reason } });
|
|
1390
|
+
}
|
|
1391
|
+
const month = row.date.slice(0, 7);
|
|
1392
|
+
const rollupPath = join(root, rollupRelPath(repoNs, month));
|
|
1393
|
+
let existing = null;
|
|
1394
|
+
if (existsSync(rollupPath)) {
|
|
1395
|
+
existing = readFileSync(rollupPath, 'utf8');
|
|
1396
|
+
const notOurs = notOurGenerator(parseFrontmatter(existing));
|
|
1397
|
+
if (notOurs !== null) {
|
|
1398
|
+
process.stderr.write(`SKIP ${notOurs}: ${toVaultRelative(rollupPath, vaultDir)}\n`);
|
|
1399
|
+
return emitEntryAction(lineNum, ctx, { action: 'skipped-handwritten', path: rollupPath, id: session_id });
|
|
1400
|
+
}
|
|
1401
|
+
}
|
|
1402
|
+
const next = renderRollup(existing, [row], { ns: repoNs, month });
|
|
1403
|
+
if (next === existing) {
|
|
1404
|
+
return emitEntryAction(lineNum, ctx, { action: 'skipped-noop', path: rollupPath, id: session_id, meta: { reason } });
|
|
1405
|
+
}
|
|
1406
|
+
if (!dryRun) {
|
|
1407
|
+
mkdirSync(join(root, '50-sessions', repoNs), { recursive: true });
|
|
1408
|
+
writeFileSync(rollupPath, next, 'utf8');
|
|
1409
|
+
}
|
|
1410
|
+
return emitEntryAction(lineNum, ctx, {
|
|
1411
|
+
action: 'skipped-metrics-only',
|
|
1412
|
+
path: rollupPath,
|
|
1413
|
+
id: session_id,
|
|
1414
|
+
meta: { reason },
|
|
1415
|
+
});
|
|
1416
|
+
}
|
|
1417
|
+
|
|
1087
1418
|
export async function processSession(rawEntry, _lineNum, ctx) {
|
|
1088
1419
|
const {
|
|
1089
1420
|
vaultDir,
|
|
@@ -1159,32 +1490,28 @@ export async function processSession(rawEntry, _lineNum, ctx) {
|
|
|
1159
1490
|
// owner-leaky repo's real name reached the vault through the session-note
|
|
1160
1491
|
// frontmatter even though the directory AND the learning `source-repo` field
|
|
1161
1492
|
// were already pseudonym-mapped/redacted (#732 leak-guard bypass).
|
|
1162
|
-
const repoNs =
|
|
1163
|
-
|
|
1164
|
-
repoRoot: ctx?.repoRoot ?? null,
|
|
1165
|
-
|
|
1493
|
+
const repoNs = canonicalNamespace(
|
|
1494
|
+
vaultDir,
|
|
1495
|
+
resolveRepoNamespace({ vaultName: ctx?.vaultName ?? null, repoRoot: ctx?.repoRoot ?? null }),
|
|
1496
|
+
);
|
|
1166
1497
|
|
|
1167
|
-
//
|
|
1168
|
-
//
|
|
1169
|
-
//
|
|
1170
|
-
//
|
|
1171
|
-
//
|
|
1172
|
-
//
|
|
1173
|
-
//
|
|
1174
|
-
//
|
|
1175
|
-
//
|
|
1498
|
+
// Narrative gate (#1513, replaces the PRD F1.2 rendered-length gate). A
|
|
1499
|
+
// session gets its own note only when its FREE-TEXT fields (`notes`,
|
|
1500
|
+
// `narrative`, `summary` — see retention.mjs) carry at least
|
|
1501
|
+
// `qualityMinNarrativeChars` characters. The old gate measured the rendered
|
|
1502
|
+
// body INCLUDING the wave/agent tables, so nearly every record passed: 2.728
|
|
1503
|
+
// of 3.003 vault session notes were tables with no information beyond git
|
|
1504
|
+
// (Jev audit 2026-10-03). A record below the gate becomes one row in the
|
|
1505
|
+
// month rollup `50-sessions/<ns>/_rollup-YYYY-MM.md` instead. `0` disables
|
|
1506
|
+
// the gate. Runs BEFORE --force; --force does NOT bypass it.
|
|
1507
|
+
//
|
|
1508
|
+
// The render still happens first: a generator `vault-mirror: …` validation
|
|
1509
|
+
// error must keep surfacing as `skipped-invalid`, and the rollup row is read
|
|
1510
|
+
// off the rendered note so the mirror and the pruner share one parser.
|
|
1176
1511
|
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;
|
|
1512
|
+
const narrativeChars = sessionNarrativeChars(entry);
|
|
1181
1513
|
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
|
-
});
|
|
1514
|
+
return rollUpSession({ renderedBody, session_id, repoNs, narrativeChars, lineNum: _lineNum, ctx });
|
|
1188
1515
|
}
|
|
1189
1516
|
|
|
1190
1517
|
// #660: namespace new writes under a per-repo subdirectory. repoNs was
|
|
@@ -1239,7 +1566,8 @@ export async function processSession(rawEntry, _lineNum, ctx) {
|
|
|
1239
1566
|
}
|
|
1240
1567
|
if (legacyMarked && !legacyForeign) {
|
|
1241
1568
|
const entryUpdated = toDate(entry.completed_at);
|
|
1242
|
-
|
|
1569
|
+
const legacyNarrativeStale = noteNarrativeChars(legacyContent) !== noteNarrativeChars(renderedBody);
|
|
1570
|
+
if (!force && !legacyStillLeaks && !legacyNarrativeStale && legacyFm['updated'] && legacyFm['updated'] >= entryUpdated) {
|
|
1243
1571
|
return emitEntryAction(_lineNum, ctx, { action: 'skipped-noop', path: legacyFlatPath, id: session_id });
|
|
1244
1572
|
}
|
|
1245
1573
|
// Updated date would advance, or the on-disk note still leaks — fall
|
|
@@ -1285,7 +1613,11 @@ export async function processSession(rawEntry, _lineNum, ctx) {
|
|
|
1285
1613
|
}
|
|
1286
1614
|
|
|
1287
1615
|
const entryUpdated = toDate(entry.completed_at);
|
|
1288
|
-
|
|
1616
|
+
// #1513 review H2: a note rendered before narrative/summary were printed
|
|
1617
|
+
// carries less free text than the record — heal it once, or the pruner
|
|
1618
|
+
// archives it as metrics-only and the mirror re-creates it every run.
|
|
1619
|
+
const narrativeStale = noteNarrativeChars(existingContent) !== noteNarrativeChars(renderedBody);
|
|
1620
|
+
if (!force && !existingLeaks && !narrativeStale && fm['updated'] && fm['updated'] >= entryUpdated) {
|
|
1289
1621
|
return emitEntryAction(_lineNum, ctx, { action: 'skipped-noop', path: targetPath, id: session_id });
|
|
1290
1622
|
}
|
|
1291
1623
|
if (!dryRun) writeFileSync(targetPath, renderedBody, 'utf8');
|