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,388 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* retention.mjs — the retention rules shared by the vault mirror
|
|
3
|
+
* (`process.mjs`, write side) and the bestand pruner
|
|
4
|
+
* (`scripts/vault-mirror-prune.mjs`, cleanup side). Issue #1513.
|
|
5
|
+
*
|
|
6
|
+
* One module, so the mirror and the pruner cannot disagree about what counts
|
|
7
|
+
* as narrative, as the same insight, as an archived note, or as a rollup row.
|
|
8
|
+
* String functions plus one read-only directory probe
|
|
9
|
+
* ({@link canonicalNamespace}); no clock (callers pass `today`).
|
|
10
|
+
*
|
|
11
|
+
* ## The rules (deterministic, documented in skills/vault-mirror/SKILL.md)
|
|
12
|
+
*
|
|
13
|
+
* - **Narrative gate (sessions).** A session record earns its own note only
|
|
14
|
+
* when its FREE-TEXT fields (`notes`, `narrative`, `summary`; strings, or
|
|
15
|
+
* arrays of strings) carry at least `min-narrative-chars` characters after
|
|
16
|
+
* whitespace collapse. Tables (waves, agents, files, effectiveness) are not
|
|
17
|
+
* narrative: they live in `sessions.jsonl` and in git. Calibrated against the
|
|
18
|
+
* Jev audit 2026-10-03 (3.003 notes): free text under 300 chars held 1 of
|
|
19
|
+
* 2.802 notes scored >= 2.5 "information beyond git"; from 800 chars on, 18 of
|
|
20
|
+
* 55. A record below the gate becomes ONE row in the month rollup
|
|
21
|
+
* `50-sessions/<ns>/_rollup-YYYY-MM.md`.
|
|
22
|
+
* - **Same insight (learnings).** Two notes carry the same learning when their
|
|
23
|
+
* `## Insight` sections are equal after {@link normalizeInsight}.
|
|
24
|
+
* - **Archived, not deleted.** A note that leaves the active set keeps its
|
|
25
|
+
* bytes; only `status:` becomes `archived` and an `archived-reason:` line
|
|
26
|
+
* names why. `archived` (not `expired`/`superseded`) because the vault
|
|
27
|
+
* frontmatter status enum (SSOT projects-baseline, mirrored in
|
|
28
|
+
* skills/vault-sync/validator.mjs) has no such values — a status outside it
|
|
29
|
+
* fails vault-sync in every vault whose mirror zones are not excluded.
|
|
30
|
+
*
|
|
31
|
+
* @module scripts/lib/vault-mirror/retention
|
|
32
|
+
*/
|
|
33
|
+
|
|
34
|
+
import { readdirSync } from 'node:fs';
|
|
35
|
+
import { join } from 'node:path';
|
|
36
|
+
|
|
37
|
+
import { subjectToSlug } from './utils.mjs';
|
|
38
|
+
|
|
39
|
+
/** The generator marker every mirror-written note carries. */
|
|
40
|
+
export const GENERATOR_MARKER = 'session-orchestrator-vault-mirror@1';
|
|
41
|
+
|
|
42
|
+
/** Reasons a note leaves the active zones. Closed set; the manifest uses it. */
|
|
43
|
+
export const ARCHIVE_REASONS = Object.freeze([
|
|
44
|
+
'expired',
|
|
45
|
+
'superseded',
|
|
46
|
+
'duplicate',
|
|
47
|
+
'metrics-only-session',
|
|
48
|
+
]);
|
|
49
|
+
|
|
50
|
+
/** Record fields that carry session free text (narrative), in render order. */
|
|
51
|
+
export const SESSION_NARRATIVE_FIELDS = Object.freeze(['notes', 'narrative', 'summary']);
|
|
52
|
+
|
|
53
|
+
/** Vault-relative archive root; the zone path is appended verbatim. */
|
|
54
|
+
export const ARCHIVE_ROOT = '90-archive/mirror';
|
|
55
|
+
|
|
56
|
+
/** Rollup filename prefix — `_` keeps it out of the session-note namespace. */
|
|
57
|
+
export const ROLLUP_PREFIX = '_rollup-';
|
|
58
|
+
|
|
59
|
+
const collapse = (s) => String(s).replace(/\s+/g, ' ').trim();
|
|
60
|
+
|
|
61
|
+
/**
|
|
62
|
+
* Narrative character count of a session RECORD (the gate's measure).
|
|
63
|
+
*
|
|
64
|
+
* @param {object} entry sessions.jsonl record
|
|
65
|
+
* @returns {number}
|
|
66
|
+
*/
|
|
67
|
+
/**
|
|
68
|
+
* The session free text as it is RENDERED into the note's `## Notes` section:
|
|
69
|
+
* every narrative field (strings and string-array items), in
|
|
70
|
+
* {@link SESSION_NARRATIVE_FIELDS} order, blank-line separated. One function
|
|
71
|
+
* for the renderer and the gate, so a record that passes the gate always
|
|
72
|
+
* renders a note the pruner measures at least as long (#1513 review H2: the
|
|
73
|
+
* gate counted `narrative`/`summary`, the renderer printed only `notes`, and
|
|
74
|
+
* the pruner archived the empty note the mirror then re-created).
|
|
75
|
+
*
|
|
76
|
+
* @param {object} entry
|
|
77
|
+
* @returns {string} '' when the record carries no free text
|
|
78
|
+
*/
|
|
79
|
+
export function sessionNarrativeText(entry) {
|
|
80
|
+
if (!entry || typeof entry !== 'object') return '';
|
|
81
|
+
const parts = [];
|
|
82
|
+
for (const field of SESSION_NARRATIVE_FIELDS) {
|
|
83
|
+
const v = entry[field];
|
|
84
|
+
const items = typeof v === 'string' ? [v] : Array.isArray(v) ? v.filter((x) => typeof x === 'string') : [];
|
|
85
|
+
for (const item of items) if (item.trim()) parts.push(item.trim());
|
|
86
|
+
}
|
|
87
|
+
return parts.join('\n\n');
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
export function sessionNarrativeChars(entry) {
|
|
91
|
+
if (!entry || typeof entry !== 'object') return 0;
|
|
92
|
+
let total = 0;
|
|
93
|
+
for (const field of SESSION_NARRATIVE_FIELDS) {
|
|
94
|
+
const v = entry[field];
|
|
95
|
+
if (typeof v === 'string') total += collapse(v).length;
|
|
96
|
+
else if (Array.isArray(v)) {
|
|
97
|
+
for (const item of v) if (typeof item === 'string') total += collapse(item).length;
|
|
98
|
+
}
|
|
99
|
+
}
|
|
100
|
+
return total;
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
/**
|
|
104
|
+
* Narrative character count of a rendered/on-disk session NOTE: the `## Notes`
|
|
105
|
+
* section, which is where the renderers put the record's `notes` field. The
|
|
106
|
+
* pruner uses this; it agrees with {@link sessionNarrativeChars} for every note
|
|
107
|
+
* the v2/v3 renderers wrote.
|
|
108
|
+
*
|
|
109
|
+
* @param {string} content
|
|
110
|
+
* @returns {number}
|
|
111
|
+
*/
|
|
112
|
+
export function noteNarrativeChars(content) {
|
|
113
|
+
const text = section(content, 'Notes');
|
|
114
|
+
return text === null ? 0 : collapse(text).length;
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
/**
|
|
118
|
+
* Body of a `## <heading>` section up to the next `## ` heading, trimmed.
|
|
119
|
+
*
|
|
120
|
+
* @param {string} content
|
|
121
|
+
* @param {string} heading
|
|
122
|
+
* @returns {string|null} null when the heading is absent
|
|
123
|
+
*/
|
|
124
|
+
export function section(content, heading) {
|
|
125
|
+
const lines = String(content ?? '').split('\n');
|
|
126
|
+
const start = lines.findIndex((l) => l.trimEnd() === `## ${heading}`);
|
|
127
|
+
if (start === -1) return null;
|
|
128
|
+
const out = [];
|
|
129
|
+
for (let i = start + 1; i < lines.length; i += 1) {
|
|
130
|
+
if (lines[i].startsWith('## ')) break;
|
|
131
|
+
out.push(lines[i]);
|
|
132
|
+
}
|
|
133
|
+
return out.join('\n').trim();
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
/**
|
|
137
|
+
* Normalise insight text for equality: case, whitespace, trailing punctuation
|
|
138
|
+
* and typographic quotes/dashes are not content.
|
|
139
|
+
*
|
|
140
|
+
* @param {string} text
|
|
141
|
+
* @returns {string}
|
|
142
|
+
*/
|
|
143
|
+
export function normalizeInsight(text) {
|
|
144
|
+
return collapse(
|
|
145
|
+
String(text ?? '')
|
|
146
|
+
.toLowerCase()
|
|
147
|
+
.replace(/[‘’]/g, "'")
|
|
148
|
+
.replace(/[“”]/g, '"')
|
|
149
|
+
.replace(/[–—]/g, '-'),
|
|
150
|
+
).replace(/[\s.;:!,]+$/, '');
|
|
151
|
+
}
|
|
152
|
+
|
|
153
|
+
/**
|
|
154
|
+
* Insight texts that are NOT a learning but a placeholder a tool wrote — equal
|
|
155
|
+
* placeholders say nothing about two notes carrying the same learning (#1513
|
|
156
|
+
* review M4). Matched against the normalised text.
|
|
157
|
+
*/
|
|
158
|
+
const PLACEHOLDER_INSIGHTS = Object.freeze([
|
|
159
|
+
/^\(legacy record\b/,
|
|
160
|
+
/^\(none recorded\)$/,
|
|
161
|
+
/insight backfilled during/,
|
|
162
|
+
/^\(no insight\b/,
|
|
163
|
+
/^(?:n\/a|tbd|todo|unknown|none)$/,
|
|
164
|
+
]);
|
|
165
|
+
|
|
166
|
+
/** Shorter normalised insights are too generic to identify one learning. */
|
|
167
|
+
export const MIN_INSIGHT_KEY_CHARS = 40;
|
|
168
|
+
|
|
169
|
+
/**
|
|
170
|
+
* The dedupe key of an insight text: normalised, or `''` (= never a
|
|
171
|
+
* duplicate) for placeholders and texts under {@link MIN_INSIGHT_KEY_CHARS}.
|
|
172
|
+
*
|
|
173
|
+
* @param {string} text
|
|
174
|
+
* @returns {string}
|
|
175
|
+
*/
|
|
176
|
+
export function insightDedupeKey(text) {
|
|
177
|
+
const key = normalizeInsight(text);
|
|
178
|
+
if (key.length < MIN_INSIGHT_KEY_CHARS) return '';
|
|
179
|
+
return PLACEHOLDER_INSIGHTS.some((re) => re.test(key)) ? '' : key;
|
|
180
|
+
}
|
|
181
|
+
|
|
182
|
+
/** Dedupe key of a learning note's `## Insight`, or `''` (see {@link insightDedupeKey}). */
|
|
183
|
+
export function noteInsightKey(content) {
|
|
184
|
+
return insightDedupeKey(section(content, 'Insight') ?? '');
|
|
185
|
+
}
|
|
186
|
+
|
|
187
|
+
const dehyphen = (s) => String(s).replace(/-/g, '');
|
|
188
|
+
const hyphenCount = (s) => (String(s).match(/-/g) || []).length;
|
|
189
|
+
|
|
190
|
+
/**
|
|
191
|
+
* The hyphen-alias rule, shared by the mirror and the pruner (#1513 review
|
|
192
|
+
* M2): among namespace folders that are equal modulo hyphens, the one with the
|
|
193
|
+
* most hyphens is canonical (ties: lexicographic). The repo-identity fallback
|
|
194
|
+
* that drops hyphens (`GotzendorferV2` → `gotzendorferv2`) and the
|
|
195
|
+
* remote-derived name (`gotzendorfer-v2`) thereby land in one folder.
|
|
196
|
+
*
|
|
197
|
+
* @param {Iterable<string>} folders namespace folder names present
|
|
198
|
+
* @param {string} ns
|
|
199
|
+
* @returns {string} the canonical folder for `ns` (`ns` itself when none)
|
|
200
|
+
*/
|
|
201
|
+
export function canonicalAmong(folders, ns) {
|
|
202
|
+
if (!ns) return ns;
|
|
203
|
+
let best = ns;
|
|
204
|
+
for (const f of folders) {
|
|
205
|
+
if (f === best || dehyphen(f) !== dehyphen(ns)) continue;
|
|
206
|
+
const better = hyphenCount(f) > hyphenCount(best) || (hyphenCount(f) === hyphenCount(best) && f < best);
|
|
207
|
+
if (better) best = f;
|
|
208
|
+
}
|
|
209
|
+
return hyphenCount(best) > hyphenCount(ns) ? best : ns;
|
|
210
|
+
}
|
|
211
|
+
|
|
212
|
+
/**
|
|
213
|
+
* {@link canonicalAmong} over the namespace folders that exist in the vault's
|
|
214
|
+
* two mirror zones. Read-only; an unreadable zone contributes nothing.
|
|
215
|
+
*
|
|
216
|
+
* @param {string} vaultDir
|
|
217
|
+
* @param {string} ns
|
|
218
|
+
* @returns {string}
|
|
219
|
+
*/
|
|
220
|
+
export function canonicalNamespace(vaultDir, ns) {
|
|
221
|
+
if (!ns) return ns;
|
|
222
|
+
const folders = new Set();
|
|
223
|
+
for (const zone of ['40-learnings', '50-sessions']) {
|
|
224
|
+
try {
|
|
225
|
+
for (const ent of readdirSync(join(vaultDir, zone), { withFileTypes: true })) {
|
|
226
|
+
if (ent.isDirectory()) folders.add(ent.name);
|
|
227
|
+
}
|
|
228
|
+
} catch {
|
|
229
|
+
/* zone absent */
|
|
230
|
+
}
|
|
231
|
+
}
|
|
232
|
+
return canonicalAmong(folders, ns);
|
|
233
|
+
}
|
|
234
|
+
|
|
235
|
+
/**
|
|
236
|
+
* The pre-#725 slug of a v1 subject: `subjectToSlug` WITHOUT the whitespace →
|
|
237
|
+
* hyphen pre-map, i.e. every space dropped ("3 parallel impl" → "3parallelimpl").
|
|
238
|
+
* The mirror wrote this form until #725 D1; the current form hyphenates. Used
|
|
239
|
+
* only to RECOGNISE an existing note, never to name a new one.
|
|
240
|
+
*
|
|
241
|
+
* @param {unknown} subject
|
|
242
|
+
* @returns {string} '' when the subject yields no slug
|
|
243
|
+
*/
|
|
244
|
+
export function legacyConcatSlug(subject) {
|
|
245
|
+
if (typeof subject !== 'string' || subject.length === 0) return '';
|
|
246
|
+
return subjectToSlug(subject);
|
|
247
|
+
}
|
|
248
|
+
|
|
249
|
+
/**
|
|
250
|
+
* Set a note's frontmatter to archived with a reason. Idempotent: an already
|
|
251
|
+
* archived note with the same reason comes back byte-identical. Only the first
|
|
252
|
+
* frontmatter block is touched; the body stays verbatim.
|
|
253
|
+
*
|
|
254
|
+
* @param {string} content
|
|
255
|
+
* @param {string} reason one of {@link ARCHIVE_REASONS}
|
|
256
|
+
* @returns {string}
|
|
257
|
+
*/
|
|
258
|
+
export function markArchived(content, reason) {
|
|
259
|
+
const text = String(content ?? '');
|
|
260
|
+
if (!text.startsWith('---\n')) return text;
|
|
261
|
+
const end = text.indexOf('\n---', 3);
|
|
262
|
+
if (end === -1) return text;
|
|
263
|
+
const fmLines = text.slice(4, end).split('\n');
|
|
264
|
+
const rest = text.slice(end);
|
|
265
|
+
let sawStatus = false;
|
|
266
|
+
const out = [];
|
|
267
|
+
for (const line of fmLines) {
|
|
268
|
+
if (/^archived-reason:/.test(line)) continue;
|
|
269
|
+
if (/^status:/.test(line)) {
|
|
270
|
+
out.push('status: archived');
|
|
271
|
+
out.push(`archived-reason: ${reason}`);
|
|
272
|
+
sawStatus = true;
|
|
273
|
+
continue;
|
|
274
|
+
}
|
|
275
|
+
if (/^tags:\s*\[/.test(line)) {
|
|
276
|
+
out.push(line.replace(/\bstatus\/[a-z0-9-]+/, 'status/archived'));
|
|
277
|
+
continue;
|
|
278
|
+
}
|
|
279
|
+
out.push(line);
|
|
280
|
+
}
|
|
281
|
+
if (!sawStatus) {
|
|
282
|
+
const genIdx = out.findIndex((l) => l.startsWith('_generator:'));
|
|
283
|
+
const at = genIdx === -1 ? out.length : genIdx;
|
|
284
|
+
out.splice(at, 0, 'status: archived', `archived-reason: ${reason}`);
|
|
285
|
+
}
|
|
286
|
+
return `---\n${out.join('\n')}${rest}`;
|
|
287
|
+
}
|
|
288
|
+
|
|
289
|
+
/**
|
|
290
|
+
* Archive path for a vault-relative note path: the zone path is kept verbatim
|
|
291
|
+
* under {@link ARCHIVE_ROOT}, so `git log --follow` and the folder shape match.
|
|
292
|
+
*
|
|
293
|
+
* @param {string} relPath e.g. `40-learnings/repo/x.md`
|
|
294
|
+
* @returns {string}
|
|
295
|
+
*/
|
|
296
|
+
export function archivePathFor(relPath) {
|
|
297
|
+
return `${ARCHIVE_ROOT}/${relPath}`;
|
|
298
|
+
}
|
|
299
|
+
|
|
300
|
+
// ── Session month rollup ──────────────────────────────────────────────────────
|
|
301
|
+
|
|
302
|
+
const ROW_RE = /^\| (\d{4}-\d{2}-\d{2}) \| `([^`]+)` \| ([^|]*) \| ([^|]*) \| ([^|]*) \| ([^|]*) \|$/;
|
|
303
|
+
|
|
304
|
+
/**
|
|
305
|
+
* Derive one rollup row from a rendered session note (all three renderer
|
|
306
|
+
* generations print the same `**Type:**` / `**Waves:**` bullets). Reading the
|
|
307
|
+
* RENDERED note means the mirror and the pruner share one parser.
|
|
308
|
+
*
|
|
309
|
+
* @param {string} content rendered or on-disk session note
|
|
310
|
+
* @param {{id?: string}} [opts] id override (defaults to frontmatter `id`)
|
|
311
|
+
* @returns {{date: string, id: string, type: string, waves: string, agents: string, files: string}|null}
|
|
312
|
+
* null when the note carries no usable date or id
|
|
313
|
+
*/
|
|
314
|
+
export function rollupRowFromNote(content, opts = {}) {
|
|
315
|
+
const text = String(content ?? '');
|
|
316
|
+
const fm = (key) => (text.match(new RegExp(`^${key}:\\s*"?([^"\\n]+)"?\\s*$`, 'm')) || [])[1]?.trim() ?? '';
|
|
317
|
+
const id = opts.id ?? fm('id');
|
|
318
|
+
const date = (fm('updated') || fm('created')).slice(0, 10);
|
|
319
|
+
if (!id || !/^\d{4}-\d{2}-\d{2}$/.test(date)) return null;
|
|
320
|
+
const pick = (re) => ((text.match(re) || [])[1] ?? '?').trim().replace(/\|/g, '/');
|
|
321
|
+
return {
|
|
322
|
+
date,
|
|
323
|
+
id,
|
|
324
|
+
type: pick(/\*\*Type:\*\*\s*([^·\n]+)/),
|
|
325
|
+
waves: pick(/\*\*Waves:\*\*\s*([^·\n]+)/),
|
|
326
|
+
agents: pick(/\*\*Agents:\*\*\s*([^·\n]+)/),
|
|
327
|
+
files: pick(/\*\*Files changed:\*\*\s*([^·\n]+)/),
|
|
328
|
+
};
|
|
329
|
+
}
|
|
330
|
+
|
|
331
|
+
/** Vault-relative rollup path for a namespace (`''` = flat) and a `YYYY-MM`. */
|
|
332
|
+
export function rollupRelPath(ns, month) {
|
|
333
|
+
return ns ? `50-sessions/${ns}/${ROLLUP_PREFIX}${month}.md` : `50-sessions/${ROLLUP_PREFIX}${month}.md`;
|
|
334
|
+
}
|
|
335
|
+
|
|
336
|
+
/** Parse the rows of an existing rollup note. */
|
|
337
|
+
export function parseRollupRows(content) {
|
|
338
|
+
const rows = [];
|
|
339
|
+
for (const line of String(content ?? '').split('\n')) {
|
|
340
|
+
const m = ROW_RE.exec(line);
|
|
341
|
+
if (m) rows.push({ date: m[1], id: m[2], type: m[3].trim(), waves: m[4].trim(), agents: m[5].trim(), files: m[6].trim() });
|
|
342
|
+
}
|
|
343
|
+
return rows;
|
|
344
|
+
}
|
|
345
|
+
|
|
346
|
+
/**
|
|
347
|
+
* Merge rows into a month rollup and render it. Deterministic in its inputs
|
|
348
|
+
* (rows sorted by date, then id; created/updated from the row dates), so the
|
|
349
|
+
* same row set always renders the same bytes — the idempotency the mirror's
|
|
350
|
+
* no-op check and the pruner's re-run rely on.
|
|
351
|
+
*
|
|
352
|
+
* @param {string|null} existing current rollup content, or null
|
|
353
|
+
* @param {Array<object>} rows rows to add (a row with a known id replaces it)
|
|
354
|
+
* @param {{ns: string, month: string}} where
|
|
355
|
+
* @returns {string}
|
|
356
|
+
*/
|
|
357
|
+
export function renderRollup(existing, rows, { ns, month }) {
|
|
358
|
+
const byId = new Map();
|
|
359
|
+
for (const r of parseRollupRows(existing)) byId.set(r.id, r);
|
|
360
|
+
for (const r of rows) byId.set(r.id, r);
|
|
361
|
+
const all = [...byId.values()].sort((a, b) => (a.date === b.date ? a.id.localeCompare(b.id) : a.date.localeCompare(b.date)));
|
|
362
|
+
const label = ns || 'unsorted';
|
|
363
|
+
const idSlug = subjectToSlug(`${label}-sessions-rollup-${month}`) || `sessions-rollup-${month}`;
|
|
364
|
+
const created = all.length > 0 ? all[0].date : `${month}-01`;
|
|
365
|
+
const updated = all.length > 0 ? all[all.length - 1].date : `${month}-01`;
|
|
366
|
+
const body = all
|
|
367
|
+
.map((r) => `| ${r.date} | \`${r.id}\` | ${r.type} | ${r.waves} | ${r.agents} | ${r.files} |`)
|
|
368
|
+
.join('\n');
|
|
369
|
+
return `---
|
|
370
|
+
id: ${idSlug}
|
|
371
|
+
type: session
|
|
372
|
+
title: "Sessions ${month} (${label}, metrics only)"
|
|
373
|
+
status: verified
|
|
374
|
+
created: ${created}
|
|
375
|
+
updated: ${updated}
|
|
376
|
+
tags: [session/rollup]
|
|
377
|
+
${ns ? `source-repo: ${ns}\n` : ''}_generator: ${GENERATOR_MARKER}
|
|
378
|
+
---
|
|
379
|
+
|
|
380
|
+
# Sessions ${month} (${label}, metrics only)
|
|
381
|
+
|
|
382
|
+
Sessions without narrative (free text below the vault-mirror narrative gate). The full records live in \`.orchestrator/metrics/sessions.jsonl\` of the repo; archived notes, if any, under \`${ARCHIVE_ROOT}/50-sessions/\`.
|
|
383
|
+
|
|
384
|
+
| Date | Session | Type | Waves | Agents | Files |
|
|
385
|
+
|---|---|---|---|---|---|
|
|
386
|
+
${body}
|
|
387
|
+
`;
|
|
388
|
+
}
|
|
@@ -662,12 +662,12 @@ export function writeBoard(opts) {
|
|
|
662
662
|
// which is precisely the surface the `_overview.md` refusal above protects.
|
|
663
663
|
//
|
|
664
664
|
// `copyFileSync` is forwarded even though `backup: false` makes it inert
|
|
665
|
-
// today: {@link atomicWriteWithBackup}
|
|
666
|
-
//
|
|
667
|
-
//
|
|
668
|
-
//
|
|
669
|
-
// Forwarding the fifth method keeps the adapter total over
|
|
670
|
-
// injectable surface.
|
|
665
|
+
// today: {@link atomicWriteWithBackup} fails CLOSED under `backup: true` when
|
|
666
|
+
// an injected adapter lacks `existsSync` or `copyFileSync` (it returns
|
|
667
|
+
// `{ ok: false, reason: 'fs-error' }` before touching any filesystem), so a
|
|
668
|
+
// 4-of-5 adapter would turn into a failed board write the moment anyone
|
|
669
|
+
// flips `backup`. Forwarding the fifth method keeps the adapter total over
|
|
670
|
+
// io.mjs's injectable surface so that flip stays a one-line change.
|
|
671
671
|
const result = atomicWriteWithBackup(outputPath, content, {
|
|
672
672
|
tmpPrefix: '.active-sessions',
|
|
673
673
|
fs: {
|
|
@@ -734,12 +734,18 @@ function mayCarrySignal(line) {
|
|
|
734
734
|
* the real on-disk location of the coordinator's own transcript, sibling of
|
|
735
735
|
* the `subagents/` directory this function reads from.
|
|
736
736
|
*
|
|
737
|
+
* A missing sidecar (ENOENT) is the normal not-yet-written case and stays
|
|
738
|
+
* silent; any other failure (malformed JSON, EACCES, EISDIR) is reported via
|
|
739
|
+
* `note()` so an `'unknown'` agent type is traceable to its cause (#1216).
|
|
740
|
+
*
|
|
741
|
+
* Exported for tests only.
|
|
742
|
+
*
|
|
737
743
|
* @param {string} projectsDir
|
|
738
744
|
* @param {string} sessionId
|
|
739
745
|
* @param {string} agentId
|
|
740
746
|
* @returns {string}
|
|
741
747
|
*/
|
|
742
|
-
function readAgentType(projectsDir, sessionId, agentId) {
|
|
748
|
+
export function readAgentType(projectsDir, sessionId, agentId) {
|
|
743
749
|
const sidecar = resolveSubagentSidecar({
|
|
744
750
|
transcriptPath: join(projectsDir, `${sessionId}.jsonl`),
|
|
745
751
|
agentId,
|
|
@@ -749,7 +755,10 @@ function readAgentType(projectsDir, sessionId, agentId) {
|
|
|
749
755
|
const meta = JSON.parse(readFileSync(sidecar.meta, 'utf8'));
|
|
750
756
|
const t = meta?.agentType;
|
|
751
757
|
return typeof t === 'string' && t ? t : 'unknown';
|
|
752
|
-
} catch {
|
|
758
|
+
} catch (err) {
|
|
759
|
+
if (err?.code !== 'ENOENT') {
|
|
760
|
+
note(`agent-type read failed for ${sidecar.meta}: ${String(err?.message ?? err)}`);
|
|
761
|
+
}
|
|
753
762
|
return 'unknown';
|
|
754
763
|
}
|
|
755
764
|
}
|
|
@@ -98,9 +98,9 @@
|
|
|
98
98
|
import { parseArgs } from 'node:util';
|
|
99
99
|
import { readFileSync } from 'node:fs';
|
|
100
100
|
import { join, relative } from 'node:path';
|
|
101
|
-
import { createHash } from 'node:crypto';
|
|
102
101
|
|
|
103
102
|
import { findProjectRoot } from './lib/common.mjs';
|
|
103
|
+
import { deriveFenceToken } from './lib/reconcile/sanitize.mjs';
|
|
104
104
|
import { loadApplicableRules } from './lib/rule-loader.mjs';
|
|
105
105
|
import { readHostClass } from './lib/autopilot/telemetry.mjs';
|
|
106
106
|
import { resolveStateArtifactPath, resolveStateMdPath } from './lib/state-md.mjs';
|
|
@@ -218,30 +218,6 @@ function neutraliseWrapperForgeries(text) {
|
|
|
218
218
|
return out;
|
|
219
219
|
}
|
|
220
220
|
|
|
221
|
-
/**
|
|
222
|
-
* Derive this block's fence token from its own payload.
|
|
223
|
-
*
|
|
224
|
-
* Deterministic by construction (same input → same token), so the CLI's stdout
|
|
225
|
-
* stays reproducible. The re-derivation loop makes the absence guarantee
|
|
226
|
-
* STRUCTURAL rather than probabilistic: a token that literally occurred in the
|
|
227
|
-
* payload would be forgeable in a closing tag, so we re-hash with a counter
|
|
228
|
-
* until it does not occur. Each iteration is a fresh 32-bit draw against a
|
|
229
|
-
* fixed payload, so termination is immediate in practice; the cap exists only
|
|
230
|
-
* so a pathological input cannot spin, and its fallback (the full 64-hex
|
|
231
|
-
* digest, which no realistic rule body contains) still satisfies the guarantee.
|
|
232
|
-
*
|
|
233
|
-
* @param {string} payload - the concatenated rule bodies this token must fence
|
|
234
|
-
* @returns {string} a hex token provably absent from `payload`
|
|
235
|
-
*/
|
|
236
|
-
function deriveFenceToken(payload) {
|
|
237
|
-
const digest = (salt) => createHash('sha256').update(`${salt}\n${payload}`).digest('hex');
|
|
238
|
-
for (let salt = 0; salt < 64; salt++) {
|
|
239
|
-
const token = digest(salt).slice(0, 8);
|
|
240
|
-
if (!payload.includes(token)) return token;
|
|
241
|
-
}
|
|
242
|
-
return digest(64);
|
|
243
|
-
}
|
|
244
|
-
|
|
245
221
|
/**
|
|
246
222
|
* Render a rule's `src` attribute: repo-relative (an absolute path would print
|
|
247
223
|
* the operator's home directory into every agent prompt) and reduced to a
|
package/scripts/release.mjs
CHANGED
|
@@ -387,6 +387,10 @@ const INTENTIONAL_TEST_ASSET_PATHS = new Set([
|
|
|
387
387
|
// `scripts/lib/locks/index.mjs` is the one-cycle deprecation shim (5.2.0 CHANGELOG, Changed): its
|
|
388
388
|
// runtime warning names the version it was deprecated IN, so it legitimately carries the previous
|
|
389
389
|
// release literal until its 6.0.0 removal — version history by construction, not drift.
|
|
390
|
+
// `site/_census.json` is deliberately NOT allowlisted: it carries a `"version"` field that
|
|
391
|
+
// `--set-version` re-stamps (scripts/site-numbers.mjs --write, see the post-bump hint in main()),
|
|
392
|
+
// so leaving it under the drift sweep makes the sweep double as its staleness gate — a census
|
|
393
|
+
// not re-stamped after a bump fails `--check` instead of shipping the previous version.
|
|
390
394
|
export const HISTORY_ALLOWLIST = /^(CHANGELOG\.md|README\.md|scripts\/lib\/locks\/index\.mjs|CLAUDE\.md|AGENTS\.md|CONTRIBUTING\.md|NOTICE|\.husky\/pre-push|docs\/|tests\/|skills\/npm-publish\/|skills\/architecture\/references\/domain-model\.md|skills\/autopilot\/SKILL\.md|scripts\/release\.mjs|\.orchestrator\/|site\/leaderboard\.json|site\/guide\/index\.html|site\/llms-full\.txt|skills\/release\/SKILL\.md|marketing\/remotion\/(?:README\.md|campaign\.json|render-receipt\.json|src\/ReleaseFilm\.tsx)$)/;
|
|
391
395
|
|
|
392
396
|
/** Pure check over packed-entry lines. Returns violations: {name, line}[]. */
|
|
@@ -0,0 +1,145 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
/**
|
|
3
|
+
* vault-mirror-prune.mjs — CLI for the mirror-zone retention prune (#1513).
|
|
4
|
+
*
|
|
5
|
+
* Applies the vault-mirror retention rules to the notes the mirror wrote
|
|
6
|
+
* BEFORE those rules existed: expired / superseded / duplicate learnings,
|
|
7
|
+
* metrics-only session notes (rolled up first), namespace-alias folders and
|
|
8
|
+
* flat legacy notes. All decisions live in `scripts/lib/vault-mirror/prune.mjs`
|
|
9
|
+
* (rules table in its header) and `retention.mjs`; this file is argv parsing,
|
|
10
|
+
* the manifest, output and exit codes.
|
|
11
|
+
*
|
|
12
|
+
* SAFETY: dry run is the DEFAULT and writes nothing — not even the manifest
|
|
13
|
+
* unless `--manifest` names a path. `--apply` moves files (archive → under
|
|
14
|
+
* `90-archive/mirror/`), never deletes a note's content, never touches a note
|
|
15
|
+
* without the mirror's `_generator` marker.
|
|
16
|
+
*
|
|
17
|
+
* Usage:
|
|
18
|
+
* node scripts/vault-mirror-prune.mjs --vault-dir PATH [--apply]
|
|
19
|
+
* [--manifest PATH] [--now ISO] [--min-narrative-chars N]
|
|
20
|
+
* [--alias FROM=TO ...] [--json]
|
|
21
|
+
*
|
|
22
|
+
* Exit codes: 0 success · 1 usage error · 2 apply finished with per-path errors
|
|
23
|
+
*/
|
|
24
|
+
|
|
25
|
+
import { writeFileSync } from 'node:fs';
|
|
26
|
+
import { resolve } from 'node:path';
|
|
27
|
+
|
|
28
|
+
import { expandTilde, redactHomeDir } from './lib/common.mjs';
|
|
29
|
+
import { isMainModule } from './lib/is-main-module.mjs';
|
|
30
|
+
import { MANIFEST_SCHEMA, applyPrune, planPrune } from './lib/vault-mirror/prune.mjs';
|
|
31
|
+
|
|
32
|
+
const HELP = `Usage: node scripts/vault-mirror-prune.mjs --vault-dir PATH [--apply] [--manifest PATH]
|
|
33
|
+
[--now ISO] [--min-narrative-chars N] [--alias FROM=TO ...] [--json]
|
|
34
|
+
|
|
35
|
+
Plans (default) or applies the vault-mirror retention rules to 40-learnings/
|
|
36
|
+
and 50-sessions/ of a vault. Only notes carrying the vault-mirror _generator
|
|
37
|
+
marker are considered; hand-written notes are never touched.
|
|
38
|
+
|
|
39
|
+
Options:
|
|
40
|
+
--vault-dir PATH Vault root (required; no host-local default on purpose)
|
|
41
|
+
--apply Write: rollups, then archive moves to 90-archive/mirror/, then renames
|
|
42
|
+
--manifest PATH Write the JSON manifest (path, reason, action, target) there
|
|
43
|
+
--now ISO Injected clock for the expiry check (default: now)
|
|
44
|
+
--min-narrative-chars N Session narrative gate (default 400, as vault-mirror.quality)
|
|
45
|
+
--alias FROM=TO Explicit namespace alias (repeatable); the hyphen rule applies without it
|
|
46
|
+
--json Print the summary as one JSON line
|
|
47
|
+
|
|
48
|
+
Exit codes: 0 success 1 usage error 2 apply finished with per-path errors
|
|
49
|
+
`;
|
|
50
|
+
|
|
51
|
+
function usageError(msg) {
|
|
52
|
+
process.stderr.write(`vault-mirror-prune: ${msg}\n`);
|
|
53
|
+
process.exit(1);
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
export function parseArgs(argv) {
|
|
57
|
+
const args = { vaultDir: null, apply: false, manifest: null, now: null, minNarrativeChars: 400, aliases: {}, json: false };
|
|
58
|
+
for (let i = 0; i < argv.length; i += 1) {
|
|
59
|
+
const a = argv[i];
|
|
60
|
+
const value = () => {
|
|
61
|
+
const v = argv[i + 1];
|
|
62
|
+
if (v === undefined || v.startsWith('--')) usageError(`${a} needs a value`);
|
|
63
|
+
i += 1;
|
|
64
|
+
return v;
|
|
65
|
+
};
|
|
66
|
+
if (a === '--help' || a === '-h') {
|
|
67
|
+
process.stdout.write(HELP);
|
|
68
|
+
process.exit(0);
|
|
69
|
+
} else if (a === '--vault-dir') args.vaultDir = value();
|
|
70
|
+
else if (a === '--apply') args.apply = true;
|
|
71
|
+
else if (a === '--dry-run') args.apply = false;
|
|
72
|
+
else if (a === '--manifest') args.manifest = value();
|
|
73
|
+
else if (a === '--now') {
|
|
74
|
+
args.now = value();
|
|
75
|
+
if (!Number.isFinite(Date.parse(args.now))) usageError(`--now is not a date: ${args.now}`);
|
|
76
|
+
} else if (a === '--min-narrative-chars') {
|
|
77
|
+
const raw = value();
|
|
78
|
+
if (!/^\d+$/.test(raw)) usageError(`--min-narrative-chars must be a non-negative integer, got ${raw}`);
|
|
79
|
+
args.minNarrativeChars = Number(raw);
|
|
80
|
+
} else if (a === '--alias') {
|
|
81
|
+
const m = /^([a-z0-9][a-z0-9-]*)=([a-z0-9][a-z0-9-]*)$/.exec(value());
|
|
82
|
+
if (!m) usageError('--alias expects FROM=TO with lowercase kebab namespaces');
|
|
83
|
+
args.aliases[m[1]] = m[2];
|
|
84
|
+
} else if (a === '--json') args.json = true;
|
|
85
|
+
else usageError(`unknown argument ${a}`);
|
|
86
|
+
}
|
|
87
|
+
if (!args.vaultDir) usageError('--vault-dir is required');
|
|
88
|
+
return args;
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
export function main(argv = process.argv.slice(2)) {
|
|
92
|
+
const args = parseArgs(argv);
|
|
93
|
+
const vaultDir = resolve(expandTilde(args.vaultDir));
|
|
94
|
+
const today = new Date(args.now ? Date.parse(args.now) : Date.now()).toISOString().slice(0, 10);
|
|
95
|
+
const plan = planPrune({ vaultDir, today, minNarrativeChars: args.minNarrativeChars, aliases: args.aliases });
|
|
96
|
+
|
|
97
|
+
const manifest = {
|
|
98
|
+
schema: MANIFEST_SCHEMA,
|
|
99
|
+
generated_at: new Date().toISOString(),
|
|
100
|
+
today,
|
|
101
|
+
dry_run: !args.apply,
|
|
102
|
+
min_narrative_chars: args.minNarrativeChars,
|
|
103
|
+
aliases: plan.aliases,
|
|
104
|
+
skipped_handwritten: plan.handwritten,
|
|
105
|
+
counts: plan.counts,
|
|
106
|
+
rollups: Object.fromEntries(Object.entries(plan.rollups).map(([k, rows]) => [k, rows.length])),
|
|
107
|
+
actions: plan.actions,
|
|
108
|
+
};
|
|
109
|
+
let applied = null;
|
|
110
|
+
if (args.apply) {
|
|
111
|
+
applied = applyPrune(plan, { vaultDir });
|
|
112
|
+
// The apply result belongs in the manifest: notes written beside an
|
|
113
|
+
// occupied archive target (`suffixed`) and per-path errors.
|
|
114
|
+
manifest.applied = applied;
|
|
115
|
+
}
|
|
116
|
+
if (args.manifest) writeFileSync(resolve(expandTilde(args.manifest)), JSON.stringify(manifest, null, 2) + '\n', 'utf8');
|
|
117
|
+
|
|
118
|
+
const summary = {
|
|
119
|
+
dry_run: !args.apply,
|
|
120
|
+
counts: plan.counts,
|
|
121
|
+
rollups: Object.keys(plan.rollups).length,
|
|
122
|
+
aliases: plan.aliases,
|
|
123
|
+
skipped_handwritten: plan.handwritten,
|
|
124
|
+
...(applied ? { applied: { ...applied, suffixed: applied.suffixed.length, errors: applied.errors.length } } : {}),
|
|
125
|
+
};
|
|
126
|
+
if (args.json) process.stdout.write(JSON.stringify(summary) + '\n');
|
|
127
|
+
else {
|
|
128
|
+
process.stdout.write(`vault-mirror-prune ${args.apply ? 'APPLY' : 'dry run'} (today ${today})\n`);
|
|
129
|
+
for (const [k, n] of Object.entries(plan.counts).sort()) process.stdout.write(` ${k.padEnd(36)} ${n}\n`);
|
|
130
|
+
process.stdout.write(` rollup notes ${Object.keys(plan.rollups).length}\n`);
|
|
131
|
+
for (const [from, to] of Object.entries(plan.aliases)) process.stdout.write(` alias ${from} -> ${to}\n`);
|
|
132
|
+
if (applied) {
|
|
133
|
+
process.stdout.write(` applied: archived ${applied.archived}, moved ${applied.moved}, rollups written ${applied.rollupsWritten}, suffixed ${applied.suffixed.length}, errors ${applied.errors.length}\n`);
|
|
134
|
+
}
|
|
135
|
+
}
|
|
136
|
+
if (applied && applied.errors.length > 0) {
|
|
137
|
+
for (const e of applied.errors.slice(0, 20)) process.stderr.write(`vault-mirror-prune: ${e.path}: ${redactHomeDir(e.error)}\n`);
|
|
138
|
+
return 2;
|
|
139
|
+
}
|
|
140
|
+
return 0;
|
|
141
|
+
}
|
|
142
|
+
|
|
143
|
+
if (isMainModule(import.meta.url)) {
|
|
144
|
+
process.exit(main());
|
|
145
|
+
}
|