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.
Files changed (42) hide show
  1. package/.claude-plugin/marketplace.json +1 -1
  2. package/.claude-plugin/plugin.json +1 -1
  3. package/.codex-plugin/plugin.json +1 -1
  4. package/.cursor-plugin/plugin.json +1 -1
  5. package/CHANGELOG.md +26 -0
  6. package/README.md +7 -8
  7. package/docs/USER-GUIDE.md +1 -1
  8. package/docs/rule-authoring.md +10 -0
  9. package/docs/session-config-reference.md +1 -1
  10. package/docs/session-config-template.md +1 -1
  11. package/hooks/hooks-codex.json +1 -1
  12. package/hooks/hooks.json +1 -1
  13. package/hooks/wave-scope-commit-guard.mjs +41 -10
  14. package/package.json +1 -1
  15. package/scripts/backfill-abandoned-sessions.mjs +5 -7
  16. package/scripts/backfill-learnings-from-vault.mjs +17 -6
  17. package/scripts/lib/dispatcher/rank.mjs +5 -1
  18. package/scripts/lib/events.mjs +62 -49
  19. package/scripts/lib/io.mjs +9 -4
  20. package/scripts/lib/learnings/candidates.mjs +7 -16
  21. package/scripts/lib/memory-proposals/store.mjs +9 -2
  22. package/scripts/lib/reconcile/rule-expiry-sweep.mjs +52 -0
  23. package/scripts/lib/session-close-backfill.mjs +18 -0
  24. package/scripts/lib/validate/check-test-value-bans.mjs +9 -0
  25. package/scripts/lib/vault-mirror/process.mjs +388 -56
  26. package/scripts/lib/vault-mirror/prune.mjs +413 -0
  27. package/scripts/lib/vault-mirror/render-sessions.mjs +13 -5
  28. package/scripts/lib/vault-mirror/retention.mjs +388 -0
  29. package/scripts/lib/vault-status/board-writer.mjs +6 -6
  30. package/scripts/lib/wave-transcript-tail.mjs +11 -2
  31. package/scripts/print-applicable-rules.mjs +1 -25
  32. package/scripts/release.mjs +4 -0
  33. package/scripts/vault-mirror-prune.mjs +145 -0
  34. package/scripts/vault-mirror.mjs +77 -12
  35. package/skills/claude-md-drift-check/checker.mjs +6 -16
  36. package/skills/vault-mirror/SKILL.md +27 -2
  37. package/skills/wave-executor/references/wave-loop-scope-manifest.md +2 -0
  38. package/hooks/agent-teams-h3-test.sh +0 -362
  39. package/scripts/spikes/h3-agent-teams/preflight.sh +0 -53
  40. package/scripts/spikes/h3-agent-teams/run-h3.sh +0 -112
  41. package/scripts/spikes/h3-agent-teams/setup.sh +0 -137
  42. 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} falls back to the REAL `node:fs` per
666
- // MISSING method, so a 4-of-5 adapter would silently route the backup copy
667
- // to the real filesystem the moment anyone flips `backup` — a test that
668
- // believes itself hermetic would drop `.bak-<ISO>` files into the repo.
669
- // Forwarding the fifth method keeps the adapter total over io.mjs's
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
@@ -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
+ }