session-orchestrator 5.5.0 → 5.7.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/.claude-plugin/marketplace.json +1 -1
- package/.claude-plugin/plugin.json +1 -1
- package/.codex-plugin/plugin.json +1 -1
- package/.cursor-plugin/plugin.json +1 -1
- package/CHANGELOG.md +46 -0
- package/README.md +27 -13
- package/docs/USER-GUIDE.md +1 -1
- package/docs/rule-authoring.md +10 -0
- package/docs/scope-collision-guard.md +5 -3
- package/docs/session-config-reference.md +1 -1
- package/docs/session-config-template.md +1 -1
- package/hooks/hooks-codex.json +1 -1
- package/hooks/hooks.json +1 -1
- package/hooks/on-stop.mjs +8 -2
- package/hooks/post-bash-write-verify.mjs +121 -7
- package/hooks/post-tool-batch-wave-signal.mjs +11 -4
- package/hooks/pre-bash-memory-propose-audit.mjs +13 -8
- package/hooks/pre-task-scope-disjoint.mjs +20 -35
- package/hooks/wave-scope-commit-guard.mjs +117 -20
- package/package.json +8 -3
- package/scripts/backfill-abandoned-sessions.mjs +5 -7
- package/scripts/backfill-learnings-from-vault.mjs +17 -6
- package/scripts/lib/dispatcher/rank.mjs +5 -1
- package/scripts/lib/events.mjs +62 -49
- package/scripts/lib/io.mjs +9 -4
- package/scripts/lib/learnings/candidates.mjs +7 -16
- package/scripts/lib/memory-proposals/store.mjs +9 -2
- package/scripts/lib/reconcile/rule-expiry-sweep.mjs +52 -0
- package/scripts/lib/scope-gate.mjs +8 -1
- package/scripts/lib/session-close-backfill.mjs +104 -13
- package/scripts/lib/validate/check-test-value-bans.mjs +9 -0
- package/scripts/lib/vault-mirror/process.mjs +388 -56
- package/scripts/lib/vault-mirror/prune.mjs +413 -0
- package/scripts/lib/vault-mirror/render-sessions.mjs +13 -5
- package/scripts/lib/vault-mirror/retention.mjs +388 -0
- package/scripts/lib/vault-status/board-writer.mjs +6 -6
- package/scripts/lib/wave-transcript-tail.mjs +11 -2
- package/scripts/print-applicable-rules.mjs +1 -25
- package/scripts/release.mjs +4 -0
- package/scripts/validate-wave-scope.mjs +39 -4
- package/scripts/vault-mirror-prune.mjs +145 -0
- package/scripts/vault-mirror.mjs +77 -12
- package/skills/_shared/parallel-aware-auq.md +1 -1
- package/skills/claude-md-drift-check/checker.mjs +6 -16
- package/skills/vault-mirror/SKILL.md +27 -2
- package/skills/wave-executor/references/wave-loop-scope-manifest.md +3 -1
- package/hooks/agent-teams-h3-test.sh +0 -362
- package/scripts/spikes/h3-agent-teams/preflight.sh +0 -53
- package/scripts/spikes/h3-agent-teams/run-h3.sh +0 -112
- package/scripts/spikes/h3-agent-teams/setup.sh +0 -137
- package/scripts/spikes/h3-agent-teams/toggle.sh +0 -38
|
@@ -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}[]. */
|
|
@@ -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,
|
|
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
|
-
|
|
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
|
-
|
|
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
|
+
}
|