session-orchestrator 5.5.0 → 5.7.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (51) 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 +46 -0
  6. package/README.md +27 -13
  7. package/docs/USER-GUIDE.md +1 -1
  8. package/docs/rule-authoring.md +10 -0
  9. package/docs/scope-collision-guard.md +5 -3
  10. package/docs/session-config-reference.md +1 -1
  11. package/docs/session-config-template.md +1 -1
  12. package/hooks/hooks-codex.json +1 -1
  13. package/hooks/hooks.json +1 -1
  14. package/hooks/on-stop.mjs +8 -2
  15. package/hooks/post-bash-write-verify.mjs +121 -7
  16. package/hooks/post-tool-batch-wave-signal.mjs +11 -4
  17. package/hooks/pre-bash-memory-propose-audit.mjs +13 -8
  18. package/hooks/pre-task-scope-disjoint.mjs +20 -35
  19. package/hooks/wave-scope-commit-guard.mjs +117 -20
  20. package/package.json +8 -3
  21. package/scripts/backfill-abandoned-sessions.mjs +5 -7
  22. package/scripts/backfill-learnings-from-vault.mjs +17 -6
  23. package/scripts/lib/dispatcher/rank.mjs +5 -1
  24. package/scripts/lib/events.mjs +62 -49
  25. package/scripts/lib/io.mjs +9 -4
  26. package/scripts/lib/learnings/candidates.mjs +7 -16
  27. package/scripts/lib/memory-proposals/store.mjs +9 -2
  28. package/scripts/lib/reconcile/rule-expiry-sweep.mjs +52 -0
  29. package/scripts/lib/scope-gate.mjs +8 -1
  30. package/scripts/lib/session-close-backfill.mjs +104 -13
  31. package/scripts/lib/validate/check-test-value-bans.mjs +9 -0
  32. package/scripts/lib/vault-mirror/process.mjs +388 -56
  33. package/scripts/lib/vault-mirror/prune.mjs +413 -0
  34. package/scripts/lib/vault-mirror/render-sessions.mjs +13 -5
  35. package/scripts/lib/vault-mirror/retention.mjs +388 -0
  36. package/scripts/lib/vault-status/board-writer.mjs +6 -6
  37. package/scripts/lib/wave-transcript-tail.mjs +11 -2
  38. package/scripts/print-applicable-rules.mjs +1 -25
  39. package/scripts/release.mjs +4 -0
  40. package/scripts/validate-wave-scope.mjs +39 -4
  41. package/scripts/vault-mirror-prune.mjs +145 -0
  42. package/scripts/vault-mirror.mjs +77 -12
  43. package/skills/_shared/parallel-aware-auq.md +1 -1
  44. package/skills/claude-md-drift-check/checker.mjs +6 -16
  45. package/skills/vault-mirror/SKILL.md +27 -2
  46. package/skills/wave-executor/references/wave-loop-scope-manifest.md +3 -1
  47. package/hooks/agent-teams-h3-test.sh +0 -362
  48. package/scripts/spikes/h3-agent-teams/preflight.sh +0 -53
  49. package/scripts/spikes/h3-agent-teams/run-h3.sh +0 -112
  50. package/scripts/spikes/h3-agent-teams/setup.sh +0 -137
  51. 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}[]. */
@@ -10,6 +10,7 @@
10
10
  * cat wave-scope.json | node scripts/validate-wave-scope.mjs
11
11
  * node scripts/validate-wave-scope.mjs --assert-subset <agent-filescope.json> < wave-scope.json
12
12
  * node scripts/validate-wave-scope.mjs --assert-disjoint <agent-scopes.json> < wave-scope.json
13
+ * node scripts/validate-wave-scope.mjs --assert-disjoint <agent-scopes.json> --no-manifest
13
14
  * node scripts/validate-wave-scope.mjs --union <agent-scopes.json> < wave-scope.json
14
15
  *
15
16
  * Flags:
@@ -38,6 +39,17 @@
38
39
  * Runs even when the manifest fails schema validation
39
40
  * (#1026.4): it reads only the sidecar, so schema
40
41
  * errors and collisions are reported together (exit 1).
42
+ * --no-manifest #1510 f. With --assert-disjoint ONLY: check the
43
+ * sidecar alone and read NO manifest (no stdin, no
44
+ * positional file) — `/plan` checks disjointness
45
+ * before any wave-scope.json exists and had to pipe
46
+ * a fabricated dummy. Stdout stays EMPTY (there is no
47
+ * manifest to echo); exit 0 = disjoint, 1 = collision.
48
+ * An explicit flag, never inferred from empty stdin:
49
+ * WITHOUT it, empty/missing stdin stays an error
50
+ * (fail-closed, #1083 class). Rejected (exit 1) with
51
+ * --union / --assert-subset / --expand-test-siblings
52
+ * or a positional path — those need a manifest.
41
53
  * --union <path> #1020. QUERY MODE. Read the same sidecar, compute
42
54
  * `expandTestSiblings(unionFileScopes(scopes), {role})`
43
55
  * using the MANIFEST'S OWN `role`, and print the
@@ -161,7 +173,8 @@ function flagValue(argv, i, flag) {
161
173
  *
162
174
  * @param {string[]} argv - full process.argv
163
175
  * @returns {{ assertSubset: string|null, expandTestSiblings: boolean,
164
- * assertDisjoint: string|null, union: string|null, positionals: string[] }}
176
+ * assertDisjoint: string|null, union: string|null, noManifest: boolean,
177
+ * positionals: string[] }}
165
178
  */
166
179
  function parseArgs(argv) {
167
180
  const positionals = [];
@@ -169,6 +182,7 @@ function parseArgs(argv) {
169
182
  let expandTestSiblings = false;
170
183
  let assertDisjoint = null;
171
184
  let union = null;
185
+ let noManifest = false;
172
186
  for (let i = 2; i < argv.length; i++) {
173
187
  const a = argv[i];
174
188
  if (a === '--expand-test-siblings') {
@@ -185,11 +199,27 @@ function parseArgs(argv) {
185
199
  } else if (a === '--union') {
186
200
  union = flagValue(argv, i, '--union');
187
201
  i++; // consume the value
202
+ } else if (a === '--no-manifest') {
203
+ noManifest = true;
188
204
  } else {
189
205
  positionals.push(a);
190
206
  }
191
207
  }
192
- return { assertSubset, expandTestSiblings, assertDisjoint, union, positionals };
208
+ if (noManifest) {
209
+ // #1510 f — every other mode reads the MANIFEST (`role`, `allowedPaths`), so
210
+ // combining it with "there is no manifest" is a contradiction, not a no-op.
211
+ if (assertDisjoint === null) die('--no-manifest requires --assert-disjoint <agent-scopes.json>', 1);
212
+ const needsManifest = [
213
+ union !== null && '--union',
214
+ assertSubset !== null && '--assert-subset',
215
+ expandTestSiblings && '--expand-test-siblings',
216
+ positionals.length > 0 && `a wave-scope.json path (${positionals[0]})`,
217
+ ].filter(Boolean);
218
+ if (needsManifest.length > 0) {
219
+ die(`--no-manifest cannot be combined with ${needsManifest.join(', ')}: that mode reads the manifest`, 1);
220
+ }
221
+ }
222
+ return { assertSubset, expandTestSiblings, assertDisjoint, union, noManifest, positionals };
193
223
  }
194
224
 
195
225
  /**
@@ -755,7 +785,12 @@ function validate(
755
785
  process.stdout.write(input.endsWith('\n') ? input : input + '\n');
756
786
  }
757
787
 
758
- const { assertSubset, expandTestSiblings, assertDisjoint, union, positionals } = parseArgs(
788
+ const { assertSubset, expandTestSiblings, assertDisjoint, union, noManifest, positionals } = parseArgs(
759
789
  process.argv,
760
790
  );
761
- validate(readInput(positionals[0]), assertSubset, expandTestSiblings, assertDisjoint, union);
791
+ if (noManifest) {
792
+ // #1510 f — sidecar-only: stdin is never read, so no dummy manifest is needed.
793
+ assertDisjointOrDie(assertDisjoint);
794
+ } else {
795
+ validate(readInput(positionals[0]), assertSubset, expandTestSiblings, assertDisjoint, union);
796
+ }