session-orchestrator 5.6.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 +26 -0
- package/README.md +7 -8
- package/docs/USER-GUIDE.md +1 -1
- package/docs/rule-authoring.md +10 -0
- 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/wave-scope-commit-guard.mjs +41 -10
- package/package.json +1 -1
- 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/session-close-backfill.mjs +18 -0
- 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/vault-mirror-prune.mjs +145 -0
- package/scripts/vault-mirror.mjs +77 -12
- 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 +2 -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
|
@@ -0,0 +1,413 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* prune.mjs — plan and apply the retention rules to the EXISTING mirror zones
|
|
3
|
+
* (`40-learnings/`, `50-sessions/`) of a vault. Issue #1513.
|
|
4
|
+
*
|
|
5
|
+
* The mirror (`process.mjs`) stops producing ballast from now on; this module
|
|
6
|
+
* cleans up what it produced before. Same rules, same module for them:
|
|
7
|
+
* `./retention.mjs`.
|
|
8
|
+
*
|
|
9
|
+
* ## What it decides, per mirror-owned note (one reason per note)
|
|
10
|
+
*
|
|
11
|
+
* | reason | zone | action | rule |
|
|
12
|
+
* |------------------------|-----------|---------|------|
|
|
13
|
+
* | `duplicate` | both | archive | same normalised insight (learnings), or the same note id already at the relocation target; the canonical copy is kept |
|
|
14
|
+
* | `superseded` | learnings | archive | same slug modulo hyphens (pre-#725 / post-#725 slug of one subject) with a different insight — the newest `updated` is kept; or the mirror already marked it `archived-reason: superseded` |
|
|
15
|
+
* | `expired` | learnings | archive | frontmatter `expires:` before today, or already marked `archived-reason: expired` |
|
|
16
|
+
* | `metrics-only-session` | sessions | archive | `## Notes` free text below `min-narrative-chars`; its row is written to the month rollup FIRST |
|
|
17
|
+
* | `namespace-alias` | both | move | the namespace folder is an alias of another (explicit `--alias`, or the same name modulo hyphens where only the other one has hyphens) |
|
|
18
|
+
* | `flat-relocate` | both | move | a flat legacy note (no namespace folder) whose `source-repo` names its namespace |
|
|
19
|
+
*
|
|
20
|
+
* Expiry is decided FIRST: an expired or already archived note is never the
|
|
21
|
+
* kept copy of a group (#1513 review H1). Among the live copies the canonical
|
|
22
|
+
* one is, in this order: in its own namespace folder > in any namespace
|
|
23
|
+
* folder > `status: verified` > later `expires` > carries `source-record` >
|
|
24
|
+
* more hyphens in the file name (the post-#725 slug) > newer `updated` > path
|
|
25
|
+
* order. Placeholder and very short insights never form a group (M4).
|
|
26
|
+
*
|
|
27
|
+
* ## What it never does
|
|
28
|
+
*
|
|
29
|
+
* - Touch a note without `_generator: session-orchestrator-vault-mirror@1`
|
|
30
|
+
* (hand-written notes), a rollup, or anything outside the two zones.
|
|
31
|
+
* - Delete. `archive` moves the note to `90-archive/mirror/<zone>/<same path>`
|
|
32
|
+
* with `status: archived` + `archived-reason`; `move` renames inside the zone.
|
|
33
|
+
* Git sees renames; history stays.
|
|
34
|
+
* - Write anything in plan mode. {@link applyPrune} is the only write path.
|
|
35
|
+
*
|
|
36
|
+
* Idempotent: after an apply, the archived notes are out of the zones, the
|
|
37
|
+
* moved notes sit where the plan computes them, and the rollups already hold
|
|
38
|
+
* their rows — a second plan is empty.
|
|
39
|
+
*
|
|
40
|
+
* @module scripts/lib/vault-mirror/prune
|
|
41
|
+
*/
|
|
42
|
+
|
|
43
|
+
import {
|
|
44
|
+
existsSync,
|
|
45
|
+
lstatSync,
|
|
46
|
+
mkdirSync,
|
|
47
|
+
readFileSync,
|
|
48
|
+
readdirSync,
|
|
49
|
+
realpathSync,
|
|
50
|
+
renameSync,
|
|
51
|
+
unlinkSync,
|
|
52
|
+
writeFileSync,
|
|
53
|
+
} from 'node:fs';
|
|
54
|
+
import { createHash } from 'node:crypto';
|
|
55
|
+
import { basename, dirname, join, resolve, sep } from 'node:path';
|
|
56
|
+
|
|
57
|
+
import { parseFrontmatter, subjectToSlug } from './utils.mjs';
|
|
58
|
+
import {
|
|
59
|
+
GENERATOR_MARKER,
|
|
60
|
+
ROLLUP_PREFIX,
|
|
61
|
+
archivePathFor,
|
|
62
|
+
canonicalAmong,
|
|
63
|
+
markArchived,
|
|
64
|
+
noteInsightKey,
|
|
65
|
+
noteNarrativeChars,
|
|
66
|
+
renderRollup,
|
|
67
|
+
rollupRelPath,
|
|
68
|
+
rollupRowFromNote,
|
|
69
|
+
} from './retention.mjs';
|
|
70
|
+
|
|
71
|
+
export const ZONES = Object.freeze(['40-learnings', '50-sessions']);
|
|
72
|
+
export const MANIFEST_SCHEMA = 'vault-mirror-prune/1';
|
|
73
|
+
|
|
74
|
+
const dehyphen = (s) => String(s).replace(/-/g, '');
|
|
75
|
+
const hyphens = (s) => (String(s).match(/-/g) || []).length;
|
|
76
|
+
|
|
77
|
+
/**
|
|
78
|
+
* Read every mirror-owned note of both zones. Depth: `<zone>/<file>` (flat) or
|
|
79
|
+
* `<zone>/<ns>/<file>`; anything deeper is not a mirror layout and is ignored.
|
|
80
|
+
*/
|
|
81
|
+
function readZoneNotes(vaultDir) {
|
|
82
|
+
const notes = [];
|
|
83
|
+
let handwritten = 0;
|
|
84
|
+
for (const zone of ZONES) {
|
|
85
|
+
const zoneAbs = join(vaultDir, zone);
|
|
86
|
+
let top;
|
|
87
|
+
try {
|
|
88
|
+
top = readdirSync(zoneAbs, { withFileTypes: true });
|
|
89
|
+
} catch {
|
|
90
|
+
continue;
|
|
91
|
+
}
|
|
92
|
+
const files = [];
|
|
93
|
+
for (const ent of top) {
|
|
94
|
+
if (ent.isFile() && ent.name.endsWith('.md')) files.push({ dirNs: '', name: ent.name });
|
|
95
|
+
else if (ent.isDirectory()) {
|
|
96
|
+
let inner;
|
|
97
|
+
try {
|
|
98
|
+
inner = readdirSync(join(zoneAbs, ent.name), { withFileTypes: true });
|
|
99
|
+
} catch {
|
|
100
|
+
continue;
|
|
101
|
+
}
|
|
102
|
+
for (const f of inner) if (f.isFile() && f.name.endsWith('.md')) files.push({ dirNs: ent.name, name: f.name });
|
|
103
|
+
}
|
|
104
|
+
}
|
|
105
|
+
for (const { dirNs, name } of files) {
|
|
106
|
+
const rel = dirNs ? `${zone}/${dirNs}/${name}` : `${zone}/${name}`;
|
|
107
|
+
if (name.startsWith(ROLLUP_PREFIX)) continue;
|
|
108
|
+
let content;
|
|
109
|
+
try {
|
|
110
|
+
content = readFileSync(join(vaultDir, rel), 'utf8');
|
|
111
|
+
} catch {
|
|
112
|
+
continue;
|
|
113
|
+
}
|
|
114
|
+
const fm = parseFrontmatter(content);
|
|
115
|
+
if (!fm || fm['_generator'] !== GENERATOR_MARKER) {
|
|
116
|
+
handwritten += 1;
|
|
117
|
+
continue;
|
|
118
|
+
}
|
|
119
|
+
notes.push({ rel, zone, dirNs, name, base: name.slice(0, -3), fm, content });
|
|
120
|
+
}
|
|
121
|
+
}
|
|
122
|
+
return { notes, handwritten };
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
/**
|
|
126
|
+
* Namespace alias resolver: explicit map first, then the hyphen rule — `ns`
|
|
127
|
+
* maps to a sibling folder (in either zone) with the same name modulo hyphens
|
|
128
|
+
* when `ns` has fewer hyphens (the directory-name fallback of the namespace
|
|
129
|
+
* resolver drops them; the remote-derived name keeps them).
|
|
130
|
+
*/
|
|
131
|
+
function makeAliasResolver(notes, explicit) {
|
|
132
|
+
const present = new Set(notes.map((n) => n.dirNs).filter(Boolean));
|
|
133
|
+
for (const to of Object.values(explicit)) present.add(to);
|
|
134
|
+
// The hyphen rule is the SAME function the mirror applies at write time
|
|
135
|
+
// (`canonicalAmong`, #1513 review M2), so both agree on the folder.
|
|
136
|
+
const resolveNs = (ns) => {
|
|
137
|
+
if (!ns) return ns;
|
|
138
|
+
if (Object.hasOwn(explicit, ns)) return explicit[ns];
|
|
139
|
+
return canonicalAmong(present, ns);
|
|
140
|
+
};
|
|
141
|
+
const used = {};
|
|
142
|
+
for (const ns of present) if (resolveNs(ns) !== ns) used[ns] = resolveNs(ns);
|
|
143
|
+
return { resolveNs, aliases: used };
|
|
144
|
+
}
|
|
145
|
+
|
|
146
|
+
/**
|
|
147
|
+
* Which copy of a group stays. Callers pass LIVE notes only (not expired, not
|
|
148
|
+
* archived — #1513 review H1), so rank never has to weigh a dead copy.
|
|
149
|
+
*/
|
|
150
|
+
function canonicalRank(a, b) {
|
|
151
|
+
const own = (n) => (n.dirNs !== '' && n.dirNs === n.effNs ? 1 : 0);
|
|
152
|
+
const inNs = (n) => (n.dirNs !== '' ? 1 : 0);
|
|
153
|
+
const verified = (n) => (n.fm.status === 'verified' ? 1 : 0);
|
|
154
|
+
const rec = (n) => (n.fm['source-record'] ? 1 : 0);
|
|
155
|
+
return (
|
|
156
|
+
own(b) - own(a) ||
|
|
157
|
+
inNs(b) - inNs(a) ||
|
|
158
|
+
verified(b) - verified(a) ||
|
|
159
|
+
String(b.fm.expires ?? '9999').localeCompare(String(a.fm.expires ?? '9999')) ||
|
|
160
|
+
rec(b) - rec(a) ||
|
|
161
|
+
hyphens(b.base) - hyphens(a.base) ||
|
|
162
|
+
String(b.fm.updated ?? '').localeCompare(String(a.fm.updated ?? '')) ||
|
|
163
|
+
a.rel.localeCompare(b.rel)
|
|
164
|
+
);
|
|
165
|
+
}
|
|
166
|
+
|
|
167
|
+
/**
|
|
168
|
+
* Plan the prune. Reads only.
|
|
169
|
+
*
|
|
170
|
+
* @param {object} opts
|
|
171
|
+
* @param {string} opts.vaultDir absolute vault root
|
|
172
|
+
* @param {string} opts.today `YYYY-MM-DD`
|
|
173
|
+
* @param {number} [opts.minNarrativeChars=400]
|
|
174
|
+
* @param {Record<string,string>} [opts.aliases] explicit namespace aliases
|
|
175
|
+
* @returns {{actions: object[], counts: Record<string, number>, aliases: Record<string,string>, handwritten: number, rollups: Record<string, object[]>}}
|
|
176
|
+
*/
|
|
177
|
+
export function planPrune({ vaultDir, today, minNarrativeChars = 400, aliases = {} }) {
|
|
178
|
+
if (typeof vaultDir !== 'string' || vaultDir.length === 0) throw new TypeError('planPrune: vaultDir is required');
|
|
179
|
+
if (!/^\d{4}-\d{2}-\d{2}$/.test(String(today))) throw new TypeError(`planPrune: today must be YYYY-MM-DD, got ${today}`);
|
|
180
|
+
const root = resolve(vaultDir);
|
|
181
|
+
const { notes, handwritten } = readZoneNotes(root);
|
|
182
|
+
const { resolveNs, aliases: usedAliases } = makeAliasResolver(notes, aliases);
|
|
183
|
+
|
|
184
|
+
for (const n of notes) {
|
|
185
|
+
const sourceRepo = n.fm['source-repo'] ? subjectToSlug(n.fm['source-repo']) : '';
|
|
186
|
+
n.effNs = n.dirNs ? resolveNs(n.dirNs) : resolveNs(sourceRepo);
|
|
187
|
+
}
|
|
188
|
+
|
|
189
|
+
/** @type {Map<string, {reason: string, keep?: string}>} */
|
|
190
|
+
const decided = new Map();
|
|
191
|
+
const decide = (n, reason, keep) => {
|
|
192
|
+
if (!decided.has(n.rel)) decided.set(n.rel, keep ? { reason, keep } : { reason });
|
|
193
|
+
};
|
|
194
|
+
|
|
195
|
+
// ── learnings: expired FIRST, then duplicate / superseded among the living ─
|
|
196
|
+
// #1513 review H1: deciding duplicates first let a group keep its EXPIRED
|
|
197
|
+
// copy and archive the valid one as its duplicate — the expiry pass then
|
|
198
|
+
// archived the keeper too, leaving no active copy at all (350 groups in the
|
|
199
|
+
// Meta-Vault, 34 of them with a live copy). A dead copy is never a keeper.
|
|
200
|
+
const learnings = notes.filter((n) => n.zone === '40-learnings' && n.fm.type === 'learning');
|
|
201
|
+
const live = [];
|
|
202
|
+
for (const n of learnings) {
|
|
203
|
+
const marked = n.fm.status === 'archived' ? n.fm['archived-reason'] : '';
|
|
204
|
+
if (marked === 'superseded' || marked === 'expired') decide(n, marked);
|
|
205
|
+
else if (n.fm.status === 'archived') decide(n, 'superseded');
|
|
206
|
+
else if (/^\d{4}-\d{2}-\d{2}/.test(String(n.fm.expires ?? '')) && String(n.fm.expires).slice(0, 10) < today) {
|
|
207
|
+
decide(n, 'expired');
|
|
208
|
+
} else live.push(n);
|
|
209
|
+
}
|
|
210
|
+
|
|
211
|
+
const byInsight = new Map();
|
|
212
|
+
for (const n of live) {
|
|
213
|
+
n.insightKey = noteInsightKey(n.content);
|
|
214
|
+
if (!n.insightKey) continue;
|
|
215
|
+
if (!byInsight.has(n.insightKey)) byInsight.set(n.insightKey, []);
|
|
216
|
+
byInsight.get(n.insightKey).push(n);
|
|
217
|
+
}
|
|
218
|
+
for (const group of byInsight.values()) {
|
|
219
|
+
if (group.length < 2) continue;
|
|
220
|
+
const buckets = new Map();
|
|
221
|
+
for (const n of group) {
|
|
222
|
+
if (!buckets.has(n.effNs)) buckets.set(n.effNs, []);
|
|
223
|
+
buckets.get(n.effNs).push(n);
|
|
224
|
+
}
|
|
225
|
+
const named = [...buckets.keys()].filter(Boolean).sort();
|
|
226
|
+
for (const ns of named) {
|
|
227
|
+
const sorted = buckets.get(ns).sort(canonicalRank);
|
|
228
|
+
for (const n of sorted.slice(1)) decide(n, 'duplicate', sorted[0].rel);
|
|
229
|
+
}
|
|
230
|
+
const unnamed = (buckets.get('') ?? []).sort(canonicalRank);
|
|
231
|
+
if (named.length > 0) {
|
|
232
|
+
const keeper = buckets.get(named[0]).sort(canonicalRank)[0];
|
|
233
|
+
for (const n of unnamed) decide(n, 'duplicate', keeper.rel);
|
|
234
|
+
} else {
|
|
235
|
+
for (const n of unnamed.slice(1)) decide(n, 'duplicate', unnamed[0].rel);
|
|
236
|
+
}
|
|
237
|
+
}
|
|
238
|
+
|
|
239
|
+
const bySlug = new Map();
|
|
240
|
+
for (const n of live) {
|
|
241
|
+
if (decided.has(n.rel)) continue;
|
|
242
|
+
const k = `${n.effNs}\0${dehyphen(n.base)}`;
|
|
243
|
+
if (!bySlug.has(k)) bySlug.set(k, []);
|
|
244
|
+
bySlug.get(k).push(n);
|
|
245
|
+
}
|
|
246
|
+
for (const group of bySlug.values()) {
|
|
247
|
+
if (group.length < 2) continue;
|
|
248
|
+
const sorted = group.sort(
|
|
249
|
+
(a, b) => String(b.fm.updated ?? '').localeCompare(String(a.fm.updated ?? '')) || canonicalRank(a, b),
|
|
250
|
+
);
|
|
251
|
+
for (const n of sorted.slice(1)) decide(n, 'superseded', sorted[0].rel);
|
|
252
|
+
}
|
|
253
|
+
|
|
254
|
+
// ── sessions: metrics-only → rollup + archive ──────────────────────────────
|
|
255
|
+
/** @type {Record<string, object[]>} rollup rel path → rows */
|
|
256
|
+
const rollups = {};
|
|
257
|
+
const sessions = notes.filter((n) => n.zone === '50-sessions' && n.fm.type === 'session');
|
|
258
|
+
const unparseable = new Set();
|
|
259
|
+
for (const n of sessions) {
|
|
260
|
+
if (noteNarrativeChars(n.content) >= minNarrativeChars) continue;
|
|
261
|
+
const row = rollupRowFromNote(n.content, { id: n.base });
|
|
262
|
+
if (row === null) {
|
|
263
|
+
unparseable.add(n.rel);
|
|
264
|
+
continue;
|
|
265
|
+
}
|
|
266
|
+
const rollup = rollupRelPath(n.effNs, row.date.slice(0, 7));
|
|
267
|
+
(rollups[rollup] ??= []).push(row);
|
|
268
|
+
decided.set(n.rel, { reason: 'metrics-only-session', rollup });
|
|
269
|
+
}
|
|
270
|
+
|
|
271
|
+
// ── relocation of the survivors: alias folder / flat legacy layout ─────────
|
|
272
|
+
const existingRel = new Set(notes.map((n) => n.rel));
|
|
273
|
+
const claimed = new Set();
|
|
274
|
+
const actions = [];
|
|
275
|
+
for (const n of notes) {
|
|
276
|
+
const d = decided.get(n.rel);
|
|
277
|
+
if (d) {
|
|
278
|
+
actions.push({ path: n.rel, reason: d.reason, action: 'archive', target: archivePathFor(n.rel), ...(d.keep ? { keep: d.keep } : {}), ...(d.rollup ? { rollup: d.rollup } : {}) });
|
|
279
|
+
continue;
|
|
280
|
+
}
|
|
281
|
+
if (unparseable.has(n.rel)) {
|
|
282
|
+
actions.push({ path: n.rel, reason: 'metrics-only-session', action: 'keep', note: 'no date or id to roll up' });
|
|
283
|
+
continue;
|
|
284
|
+
}
|
|
285
|
+
if (!n.effNs || n.effNs === n.dirNs) continue;
|
|
286
|
+
const reason = n.dirNs ? 'namespace-alias' : 'flat-relocate';
|
|
287
|
+
const target = `${n.zone}/${n.effNs}/${n.name}`;
|
|
288
|
+
if (claimed.has(target) || existingRel.has(target)) {
|
|
289
|
+
const twin = notes.find((m) => m.rel === target);
|
|
290
|
+
if (twin && twin.fm.id === n.fm.id && !decided.has(twin.rel)) {
|
|
291
|
+
actions.push({ path: n.rel, reason: 'duplicate', action: 'archive', target: archivePathFor(n.rel), keep: target });
|
|
292
|
+
} else {
|
|
293
|
+
actions.push({ path: n.rel, reason, action: 'keep', note: `relocation target taken: ${target}` });
|
|
294
|
+
}
|
|
295
|
+
continue;
|
|
296
|
+
}
|
|
297
|
+
claimed.add(target);
|
|
298
|
+
actions.push({ path: n.rel, reason, action: 'move', target });
|
|
299
|
+
}
|
|
300
|
+
|
|
301
|
+
actions.sort((a, b) => a.path.localeCompare(b.path));
|
|
302
|
+
const counts = {};
|
|
303
|
+
for (const a of actions) {
|
|
304
|
+
const k = `${a.reason}:${a.action}`;
|
|
305
|
+
counts[k] = (counts[k] ?? 0) + 1;
|
|
306
|
+
}
|
|
307
|
+
return { actions, counts, aliases: usedAliases, handwritten, rollups };
|
|
308
|
+
}
|
|
309
|
+
|
|
310
|
+
/**
|
|
311
|
+
* Refuse a path that leaves the vault — lexically, or through a symlinked
|
|
312
|
+
* directory on the way (#1513 review N1: `90-archive` as a symlink would have
|
|
313
|
+
* turned the archive move into a write anywhere on the host). The nearest
|
|
314
|
+
* EXISTING ancestor is realpath'd and must sit inside the realpath'd root.
|
|
315
|
+
*/
|
|
316
|
+
function assertInside(root, rel) {
|
|
317
|
+
const abs = resolve(root, rel);
|
|
318
|
+
if (!abs.startsWith(`${root}${sep}`)) throw new Error(`refusing ${rel}: outside the vault`);
|
|
319
|
+
const realRoot = realpathSync(root);
|
|
320
|
+
let probe = dirname(abs);
|
|
321
|
+
while (!existsSync(probe) && probe.startsWith(`${root}${sep}`)) probe = dirname(probe);
|
|
322
|
+
const realProbe = realpathSync(probe);
|
|
323
|
+
if (realProbe !== realRoot && !realProbe.startsWith(`${realRoot}${sep}`)) {
|
|
324
|
+
throw new Error(`refusing ${rel}: resolves outside the vault through a symlink`);
|
|
325
|
+
}
|
|
326
|
+
return abs;
|
|
327
|
+
}
|
|
328
|
+
|
|
329
|
+
function assertRegularFile(abs, rel) {
|
|
330
|
+
const st = lstatSync(abs);
|
|
331
|
+
if (st.isSymbolicLink() || !st.isFile()) throw new Error(`refusing ${rel}: not a regular file`);
|
|
332
|
+
}
|
|
333
|
+
|
|
334
|
+
/**
|
|
335
|
+
* Apply a plan. THE ONLY WRITE PATH. Rollups first, so a metrics-only session
|
|
336
|
+
* is archived only after its row exists. Re-checks the generator marker of
|
|
337
|
+
* every note right before touching it (the vault may have moved since the
|
|
338
|
+
* plan). Per-path failures are collected, never thrown.
|
|
339
|
+
*
|
|
340
|
+
* @param {ReturnType<typeof planPrune>} plan
|
|
341
|
+
* @param {{vaultDir: string}} ctx
|
|
342
|
+
* @returns {{archived: number, moved: number, rollupsWritten: number, suffixed: Array<{path: string, target: string, written: string}>, errors: Array<{path: string, error: string}>}}
|
|
343
|
+
*/
|
|
344
|
+
export function applyPrune(plan, { vaultDir }) {
|
|
345
|
+
const root = resolve(vaultDir);
|
|
346
|
+
const errors = [];
|
|
347
|
+
let archived = 0;
|
|
348
|
+
let moved = 0;
|
|
349
|
+
let rollupsWritten = 0;
|
|
350
|
+
/** @type {Array<{path: string, target: string, written: string}>} */
|
|
351
|
+
const suffixed = [];
|
|
352
|
+
const failedRollups = new Set();
|
|
353
|
+
|
|
354
|
+
for (const [rel, rows] of Object.entries(plan.rollups ?? {})) {
|
|
355
|
+
try {
|
|
356
|
+
const abs = assertInside(root, rel);
|
|
357
|
+
let existing = null;
|
|
358
|
+
if (existsSync(abs)) {
|
|
359
|
+
assertRegularFile(abs, rel);
|
|
360
|
+
existing = readFileSync(abs, 'utf8');
|
|
361
|
+
if (parseFrontmatter(existing)?.['_generator'] !== GENERATOR_MARKER) throw new Error('rollup exists and is not mirror-owned');
|
|
362
|
+
}
|
|
363
|
+
const ns = rel.split('/').length === 3 ? rel.split('/')[1] : '';
|
|
364
|
+
const month = rel.match(/_rollup-(\d{4}-\d{2})\.md$/)[1];
|
|
365
|
+
const next = renderRollup(existing, rows, { ns, month });
|
|
366
|
+
if (next !== existing) {
|
|
367
|
+
mkdirSync(dirname(abs), { recursive: true });
|
|
368
|
+
writeFileSync(abs, next, 'utf8');
|
|
369
|
+
rollupsWritten += 1;
|
|
370
|
+
}
|
|
371
|
+
} catch (err) {
|
|
372
|
+
failedRollups.add(rel);
|
|
373
|
+
errors.push({ path: rel, error: err?.message ?? String(err) });
|
|
374
|
+
}
|
|
375
|
+
}
|
|
376
|
+
|
|
377
|
+
for (const a of plan.actions ?? []) {
|
|
378
|
+
if (a.action !== 'archive' && a.action !== 'move') continue;
|
|
379
|
+
try {
|
|
380
|
+
if (a.rollup && failedRollups.has(a.rollup)) throw new Error(`rollup ${a.rollup} was not written`);
|
|
381
|
+
const src = assertInside(root, a.path);
|
|
382
|
+
const dst = assertInside(root, a.target);
|
|
383
|
+
assertRegularFile(src, a.path);
|
|
384
|
+
const content = readFileSync(src, 'utf8');
|
|
385
|
+
if (parseFrontmatter(content)?.['_generator'] !== GENERATOR_MARKER) throw new Error('no longer mirror-owned');
|
|
386
|
+
mkdirSync(dirname(dst), { recursive: true });
|
|
387
|
+
if (a.action === 'move') {
|
|
388
|
+
if (existsSync(dst)) throw new Error(`target exists: ${a.target}`);
|
|
389
|
+
renameSync(src, dst);
|
|
390
|
+
moved += 1;
|
|
391
|
+
continue;
|
|
392
|
+
}
|
|
393
|
+
const next = markArchived(content, a.reason);
|
|
394
|
+
let finalDst = dst;
|
|
395
|
+
if (existsSync(dst) && readFileSync(dst, 'utf8') !== next) {
|
|
396
|
+
// #1513 review N4: an occupied target with OTHER content (an earlier
|
|
397
|
+
// archive of a namesake) is never overwritten and never a permanent
|
|
398
|
+
// error: the note goes beside it, suffixed by its content hash, and
|
|
399
|
+
// the apply result names it. Same content → same suffix → idempotent.
|
|
400
|
+
const hash8 = createHash('sha256').update(next).digest('hex').slice(0, 8);
|
|
401
|
+
finalDst = join(dirname(dst), `${basename(dst, '.md')}.dup-${hash8}.md`);
|
|
402
|
+
suffixed.push({ path: a.path, target: a.target, written: `${dirname(a.target)}/${basename(finalDst)}` });
|
|
403
|
+
}
|
|
404
|
+
if (!existsSync(finalDst)) writeFileSync(finalDst, next, { encoding: 'utf8', flag: 'wx' });
|
|
405
|
+
else if (readFileSync(finalDst, 'utf8') !== next) throw new Error(`archive target exists with other content: ${finalDst}`);
|
|
406
|
+
unlinkSync(src);
|
|
407
|
+
archived += 1;
|
|
408
|
+
} catch (err) {
|
|
409
|
+
errors.push({ path: a.path, error: err?.message ?? String(err) });
|
|
410
|
+
}
|
|
411
|
+
}
|
|
412
|
+
return { archived, moved, rollupsWritten, suffixed, errors };
|
|
413
|
+
}
|
|
@@ -17,6 +17,7 @@
|
|
|
17
17
|
|
|
18
18
|
import { toDate, buildTag, slugifyIdSafe } from './utils.mjs';
|
|
19
19
|
import { sourceRecordValue, sourceRecordLine } from './render-learnings.mjs';
|
|
20
|
+
import { sessionNarrativeText } from './retention.mjs';
|
|
20
21
|
|
|
21
22
|
const GENERATOR_MARKER = 'session-orchestrator-vault-mirror@1';
|
|
22
23
|
|
|
@@ -425,6 +426,9 @@ export function generateSessionNote(entry, options = {}) {
|
|
|
425
426
|
const created = toDate(started_at);
|
|
426
427
|
const updated = toDate(completed_at);
|
|
427
428
|
const durationLabel = renderDuration(duration_seconds);
|
|
429
|
+
// #1513 review H2: v1 printed no free text at all; same block as v2/v3.
|
|
430
|
+
const v1Narrative = sessionNarrativeText(entry);
|
|
431
|
+
const v1NotesBlock = v1Narrative ? `\n## Notes\n\n${v1Narrative}\n` : '';
|
|
428
432
|
// #M1: every `effectiveness` sub-field is OPTIONAL (validator.mjs
|
|
429
433
|
// `_validateOptionalFields` shape-checks the object, never its members), so a
|
|
430
434
|
// raw interpolation writes the literal string `undefined` into a note a human
|
|
@@ -564,7 +568,7 @@ ${waveRows}
|
|
|
564
568
|
## Agent summary
|
|
565
569
|
|
|
566
570
|
- Complete: ${complete} · Partial: ${partial} · Failed: ${failed} · Spiral: ${spiral}
|
|
567
|
-
`;
|
|
571
|
+
${v1NotesBlock}`;
|
|
568
572
|
}
|
|
569
573
|
|
|
570
574
|
export function generateSessionNoteV2(entry, options = {}) {
|
|
@@ -580,7 +584,7 @@ export function generateSessionNoteV2(entry, options = {}) {
|
|
|
580
584
|
throw new Error(`vault-mirror: session entry missing nested field 'effectiveness' (session_id=${entry.session_id})`);
|
|
581
585
|
}
|
|
582
586
|
|
|
583
|
-
const { session_id, session_type, started_at, completed_at, duration_seconds, branch, planned_issues, waves, files_changed, issues_closed, issues_created, effectiveness
|
|
587
|
+
const { session_id, session_type, started_at, completed_at, duration_seconds, branch, planned_issues, waves, files_changed, issues_closed, issues_created, effectiveness } = entry;
|
|
584
588
|
|
|
585
589
|
const created = toDate(started_at);
|
|
586
590
|
const updated = toDate(completed_at);
|
|
@@ -616,7 +620,9 @@ export function generateSessionNoteV2(entry, options = {}) {
|
|
|
616
620
|
const closedList = Array.isArray(issues_closed) && issues_closed.length ? issues_closed.join(', ') : '—';
|
|
617
621
|
const createdList = Array.isArray(issues_created) && issues_created.length ? issues_created.join(', ') : '—';
|
|
618
622
|
const branchLine = branch ? ` · **Branch:** ${branch}` : '';
|
|
619
|
-
|
|
623
|
+
// #1513 review H2: notes + narrative + summary, the fields the gate counts.
|
|
624
|
+
const narrativeText = sessionNarrativeText(entry);
|
|
625
|
+
const notesBlock = narrativeText ? `\n## Notes\n\n${narrativeText}\n` : '';
|
|
620
626
|
|
|
621
627
|
// #732: emit `source-repo` (leak-guarded/pseudonym-mapped) instead of the
|
|
622
628
|
// legacy raw `repo` field — see generateSessionNote for the full rationale.
|
|
@@ -680,7 +686,7 @@ export function generateSessionNoteV3(entry, options = {}) {
|
|
|
680
686
|
session_id, session_type, platform, branch, started_at, completed_at,
|
|
681
687
|
duration_minutes, duration_seconds, waves, agents_dispatched, agent_summary,
|
|
682
688
|
planned_issues, effectiveness, commits, issues_closed, issues_created,
|
|
683
|
-
follow_ups_filed, tests_total_pre, tests_total_post, tests_added,
|
|
689
|
+
follow_ups_filed, tests_total_pre, tests_total_post, tests_added,
|
|
684
690
|
} = entry;
|
|
685
691
|
|
|
686
692
|
const created = toDate(started_at);
|
|
@@ -741,7 +747,9 @@ export function generateSessionNoteV3(entry, options = {}) {
|
|
|
741
747
|
|
|
742
748
|
const platformBullet = platform === null || platform === undefined || platform === '' ? '' : ` · **Platform:** ${platform}`;
|
|
743
749
|
const branchLine = branch ? ` · **Branch:** ${branch}` : '';
|
|
744
|
-
|
|
750
|
+
// #1513 review H2: notes + narrative + summary, the fields the gate counts.
|
|
751
|
+
const narrativeText = sessionNarrativeText(entry);
|
|
752
|
+
const notesBlock = narrativeText ? `\n## Notes\n\n${narrativeText}\n` : '';
|
|
745
753
|
|
|
746
754
|
// #732: emit `source-repo` (leak-guarded/pseudonym-mapped) instead of the
|
|
747
755
|
// legacy raw `repo` field — see generateSessionNote for the full rationale.
|