@event4u/agent-config 15.0.0 → 16.0.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 (78) hide show
  1. package/.augment-plugin/marketplace.json +3 -3
  2. package/.augment-plugin/plugin.json +1 -1
  3. package/.claude-plugin/marketplace.json +1 -1
  4. package/CHANGELOG.md +142 -348
  5. package/dist/agent-src/commands/roadmap/create.md +3 -2
  6. package/dist/agent-src/contexts/communication/rules-auto/user-interaction-mechanics.md +50 -0
  7. package/dist/agent-src/rules/user-interaction.md +31 -30
  8. package/dist/agent-src/skills/error-handling-patterns/SKILL.md +9 -0
  9. package/dist/agent-src/templates/agents/agent-project-settings.example.yml +1 -1
  10. package/dist/cli-delegate/{chunk-HZ52BRGG.js → chunk-H32DGUJH.js} +14 -89
  11. package/dist/cli-delegate/chunk-NUHLI2RX.js +80 -0
  12. package/dist/cli-delegate/{chunk-TYSY33MD.js → chunk-QJWAUMJW.js} +12 -2
  13. package/dist/cli-delegate/{chunk-2KKXJW25.js → chunk-S3VFXMTM.js} +1 -1
  14. package/dist/cli-delegate/cmd_conformance.js +831 -272
  15. package/dist/cli-delegate/cmd_explain.js +4 -2
  16. package/dist/cli-delegate/cmd_handoff.js +2 -2
  17. package/dist/cli-delegate/cmd_packs_active.js +2 -1
  18. package/dist/cli-delegate/cmd_session_recycle.js +2 -2
  19. package/dist/cli-delegate/cmd_settings_set.js +13 -0
  20. package/dist/discovery/deprecation-report.md +1 -1
  21. package/dist/discovery/discovery-manifest.json +5 -5
  22. package/dist/discovery/discovery-manifest.json.sha256 +1 -1
  23. package/dist/discovery/discovery-manifest.summary.md +1 -1
  24. package/dist/discovery/dormancy-report.md +1 -1
  25. package/dist/discovery/orphan-report.md +1 -1
  26. package/dist/discovery/packs.json +2 -2
  27. package/dist/discovery/trust-report.md +1 -1
  28. package/dist/discovery/workspaces.json +2 -2
  29. package/dist/hooks/dispatch.js +1274 -571
  30. package/dist/install/install.mjs +1 -0
  31. package/dist/mcp/registry-manifest.json +2 -2
  32. package/dist/mcp/server.json +2 -2
  33. package/dist/mcp/server.mjs +1218 -632
  34. package/dist/scripts/_lib/installed_lock.js +1 -1
  35. package/dist/server/io/yamlIO.js +50 -0
  36. package/package.json +1 -1
  37. package/src/config/continuity-surface.json +7 -0
  38. package/src/config/gate-violation-baselines.json +3 -3
  39. package/src/config/host-injection-effect.json +1 -1
  40. package/src/config/preamble-payload-budget.json +12 -7
  41. package/src/config/preamble-payload-exceptions.json +28 -0
  42. package/src/scripts/_dispatch.bash +3 -1
  43. package/src/scripts/_lib/base_ref_payload.ts +315 -0
  44. package/src/scripts/_lib/dropped_decision.ts +142 -0
  45. package/src/scripts/_lib/installed_lock.ts +1 -1
  46. package/src/scripts/_lib/measured_payload_ceiling.ts +424 -0
  47. package/src/scripts/_lib/payload_catalogue_completeness.ts +211 -0
  48. package/src/scripts/_lib/release_findings_ingest.ts +324 -0
  49. package/src/scripts/_lib/release_tag_dispatch.ts +63 -0
  50. package/src/scripts/_lib/review_baseline.ts +254 -0
  51. package/src/scripts/_lib/review_skipped_record.ts +61 -1
  52. package/src/scripts/_lib/standing_bound_ratchet.ts +216 -103
  53. package/src/scripts/_lib/turn_end_refusals.ts +51 -4
  54. package/src/scripts/check_beta_review_markers.ts +35 -4
  55. package/src/scripts/check_finding_dispositions.ts +167 -16
  56. package/src/scripts/check_held_object_arrivals.ts +381 -0
  57. package/src/scripts/check_preamble_payload_budget.ts +399 -122
  58. package/src/scripts/check_release_published.ts +1 -1
  59. package/src/scripts/check_reply_consistency.ts +86 -17
  60. package/src/scripts/hook_manifest.json +1 -1
  61. package/src/scripts/hook_manifest.yaml +39 -3
  62. package/src/scripts/hooks/block_kernel_rule_writes.ts +12 -2
  63. package/src/scripts/hooks/concern_registry.ts +2 -0
  64. package/src/scripts/hooks/end_review_nudge_hook.ts +101 -21
  65. package/src/scripts/hooks/review_baseline_hook.ts +130 -0
  66. package/src/scripts/hooks/turn_end_gate_hook.ts +232 -14
  67. package/src/scripts/hooks_doctor.ts +4 -1
  68. package/src/scripts/install.ts +1 -1
  69. package/src/scripts/interruption_report.ts +8 -2
  70. package/src/scripts/janitor.ts +8 -1
  71. package/src/scripts/measure_turn_end_gate.ts +302 -5
  72. package/src/scripts/release.ts +52 -64
  73. package/src/scripts/release_drill.ts +193 -14
  74. package/src/scripts/release_env.ts +1 -1
  75. package/src/scripts/release_publication.ts +1 -1
  76. package/src/scripts/report_held_object_arrivals.ts +456 -0
  77. package/src/scripts/sync_agent_settings.ts +140 -18
  78. package/src/server/io/yamlIO.ts +48 -0
@@ -0,0 +1,456 @@
1
+ #!/usr/bin/env tsx
2
+ /**
3
+ * How many distinct consumed-inbox rounds raise a held object's subject.
4
+ *
5
+ * WHY A REPORTER AND NOT A GATE
6
+ * -----------------------------
7
+ * The count belongs on the held object as a human-authored line, so that a
8
+ * number arrives with a reason attached. This script derives the figure and
9
+ * prints it; it writes nothing, and the absence of any write is the contract.
10
+ * A generated counter would be a number nobody chose, and the first time it
11
+ * disagreed with the prose beside it the prose would be believed.
12
+ *
13
+ * THE UNIT IS A ROUND, NEVER A FILE
14
+ * ---------------------------------
15
+ * One round can carry a dozen revisions of the same argument. Counting files
16
+ * turns a single arrival into twelve, which inflates exactly the figure this
17
+ * practice exists to make trustworthy. So the unit is a top-level entry of the
18
+ * consumed-inbox tree: a directory is one round however many files it holds,
19
+ * and a loose file dropped at the top level is a round of one. Both are one
20
+ * arrival of the subject, and the report prints the split so a reader can tell
21
+ * which kind they are looking at.
22
+ *
23
+ * NOT-READABLE IS NOT ZERO
24
+ * ------------------------
25
+ * The consumed-inbox tree is gitignored and machine-local. A clone, a fresh
26
+ * worktree and CI all see no tree at all. Reporting that as "0 arrivals" would
27
+ * publish the strongest possible claim from the weakest possible evidence, so
28
+ * an unreachable tree exits 0 saying no prior rounds are readable, and prints
29
+ * no count at all.
30
+ *
31
+ * WHAT THE NUMBER IS NOT
32
+ * ----------------------
33
+ * It is a floor on recurrence over one machine's store, under one pattern. A
34
+ * narrower pattern reads lower and a broader one sweeps in incidental
35
+ * mentions, which is why the pattern and its provenance are printed beside
36
+ * every figure. Two counters written on different days under different
37
+ * patterns are not comparable, and this script's whole purpose is to make the
38
+ * pattern explicit so the next one can be.
39
+ *
40
+ * Exit: 0 always, except a usage error (2). It gates on nothing.
41
+ */
42
+
43
+ import * as fs from 'node:fs';
44
+ import * as path from 'node:path';
45
+ import { fileURLToPath, pathToFileURL } from 'node:url';
46
+
47
+ const _HERE = path.resolve(fileURLToPath(import.meta.url));
48
+ const REPO_ROOT = path.resolve(path.dirname(_HERE), '..', '..');
49
+
50
+ /** Where a held object lives, relative to a repository root. */
51
+ export const HELD_DIRS = ['agents/roadmaps/stubs', 'agents/roadmaps/later'] as const;
52
+
53
+ /** The consumed-inbox tree, relative to a repository root. */
54
+ export const INBOX_REL = 'agents/tmp.old';
55
+
56
+ /** A held object may declare the pattern its counter was measured under. */
57
+ const SUBJECT_MARKER = /<!--\s*arrival-subject:\s*(.+?)\s*-->/;
58
+
59
+ /** Biggest file the scanner will read. Above it the file is skipped and counted as skipped. */
60
+ const MAX_FILE_BYTES = 8 * 1024 * 1024;
61
+
62
+ export type TreeOrigin =
63
+ | 'flag'
64
+ | 'env'
65
+ | 'repo-root'
66
+ | 'main-checkout'
67
+ | 'project-dir'
68
+ | 'unreadable';
69
+
70
+ export interface TreeResolution {
71
+ /** Absolute path to the tree, or null when nothing readable was found. */
72
+ dir: string | null;
73
+ /** Which candidate answered — printed so a figure carries its provenance. */
74
+ origin: TreeOrigin;
75
+ /** Every candidate considered, in order, with whether it resolved. */
76
+ tried: ReadonlyArray<{ origin: TreeOrigin; dir: string; readable: boolean }>;
77
+ }
78
+
79
+ function isReadableDir(dir: string): boolean {
80
+ try {
81
+ return fs.statSync(dir).isDirectory();
82
+ } catch {
83
+ return false;
84
+ }
85
+ }
86
+
87
+ /**
88
+ * The main checkout behind a linked git worktree, or null.
89
+ *
90
+ * Read off the filesystem rather than by shelling out. In a linked worktree
91
+ * `.git` is a file whose one line points at `<main>/.git/worktrees/<name>`, so
92
+ * the main root is three levels above it. No subprocess, which matters because
93
+ * an inherited git directory has already broken gates in this tree once.
94
+ */
95
+ export function mainCheckoutOf(root: string): string | null {
96
+ const dotGit = path.join(root, '.git');
97
+ let raw: string;
98
+ try {
99
+ if (!fs.statSync(dotGit).isFile()) return null;
100
+ raw = fs.readFileSync(dotGit, 'utf8');
101
+ } catch {
102
+ return null;
103
+ }
104
+ const m = /^gitdir:\s*(.+)$/m.exec(raw);
105
+ if (m === null) return null;
106
+ const gitdir = path.resolve(root, m[1]?.trim() ?? '');
107
+ // <main>/.git/worktrees/<name> -> <main>
108
+ const worktrees = path.dirname(gitdir);
109
+ if (path.basename(worktrees) !== 'worktrees') return null;
110
+ return path.dirname(path.dirname(worktrees));
111
+ }
112
+
113
+ /**
114
+ * Find the consumed-inbox tree, reporting which candidate answered.
115
+ *
116
+ * AN EXPLICIT TREE OVERRIDES; IT DOES NOT COMPETE
117
+ * -----------------------------------------------
118
+ * A tree named on the command line or in the environment is the answer, even
119
+ * when it is unreadable. Treating it as the first of several candidates would
120
+ * mean that naming a path that does not exist silently reads some other tree
121
+ * instead — and on every machine where the real tree exists, that makes the
122
+ * unreadable branch impossible to reach. The one behaviour this reporter most
123
+ * needs to be able to demonstrate would be the one behaviour nobody could
124
+ * exercise, which is how a branch ends up shipping with unknown sensitivity.
125
+ *
126
+ * The implicit chain runs only when neither was given. Its order is this root,
127
+ * then the main checkout behind it, because a worktree carries no tree of its
128
+ * own and the honest answers there are either the main checkout's tree or none
129
+ * at all. The host-provided project directory comes last: it is known to point
130
+ * at the parent checkout even from inside a worktree, which is useful and is
131
+ * also a host's opinion rather than a fact about this repository.
132
+ */
133
+ export function resolveTree(opts: {
134
+ root: string;
135
+ flag?: string | undefined;
136
+ env?: NodeJS.ProcessEnv;
137
+ }): TreeResolution {
138
+ const env = opts.env ?? process.env;
139
+ const fromEnv = env['AGENT_CONFIG_INBOX_TREE'];
140
+ const explicit: { origin: TreeOrigin; dir: string } | null =
141
+ opts.flag !== undefined && opts.flag !== ''
142
+ ? { origin: 'flag', dir: path.resolve(opts.root, opts.flag) }
143
+ : fromEnv !== undefined && fromEnv !== ''
144
+ ? { origin: 'env', dir: path.resolve(opts.root, fromEnv) }
145
+ : null;
146
+ if (explicit !== null) {
147
+ const readable = isReadableDir(explicit.dir);
148
+ const tried = [{ ...explicit, readable }];
149
+ return readable
150
+ ? { dir: explicit.dir, origin: explicit.origin, tried }
151
+ : { dir: null, origin: 'unreadable', tried };
152
+ }
153
+
154
+ const candidates: Array<{ origin: TreeOrigin; dir: string }> = [];
155
+ candidates.push({ origin: 'repo-root', dir: path.join(opts.root, INBOX_REL) });
156
+ const main = mainCheckoutOf(opts.root);
157
+ if (main !== null && path.resolve(main) !== path.resolve(opts.root)) {
158
+ candidates.push({ origin: 'main-checkout', dir: path.join(main, INBOX_REL) });
159
+ }
160
+ const projectDir = env['CLAUDE_PROJECT_DIR'];
161
+ if (projectDir !== undefined && projectDir !== '') {
162
+ candidates.push({ origin: 'project-dir', dir: path.join(projectDir, INBOX_REL) });
163
+ }
164
+
165
+ const tried = candidates.map((c) => ({ ...c, readable: isReadableDir(c.dir) }));
166
+ const hit = tried.find((c) => c.readable);
167
+ return hit === undefined
168
+ ? { dir: null, origin: 'unreadable', tried }
169
+ : { dir: hit.dir, origin: hit.origin, tried };
170
+ }
171
+
172
+ export interface RoundMatch {
173
+ /** Top-level entry name — the round's codename. */
174
+ name: string;
175
+ /** `dir` = a round directory. `file` = a loose drop at the top level. */
176
+ kind: 'dir' | 'file';
177
+ }
178
+
179
+ export interface RoundCount {
180
+ matches: RoundMatch[];
181
+ /** Top-level entries examined — the denominator the count was taken over. */
182
+ examined: number;
183
+ /** Files the scanner could not read, so a low count is never silently a clean one. */
184
+ unreadable: number;
185
+ /** Files skipped for size. */
186
+ oversized: number;
187
+ }
188
+
189
+ function fileRaises(file: string, re: RegExp, tally: { unreadable: number; oversized: number }): boolean {
190
+ let size: number;
191
+ try {
192
+ const st = fs.statSync(file);
193
+ if (!st.isFile()) return false;
194
+ size = st.size;
195
+ } catch {
196
+ tally.unreadable += 1;
197
+ return false;
198
+ }
199
+ if (size > MAX_FILE_BYTES) {
200
+ tally.oversized += 1;
201
+ return false;
202
+ }
203
+ try {
204
+ return re.test(fs.readFileSync(file, 'utf8'));
205
+ } catch {
206
+ tally.unreadable += 1;
207
+ return false;
208
+ }
209
+ }
210
+
211
+ function dirRaises(dir: string, re: RegExp, tally: { unreadable: number; oversized: number }): boolean {
212
+ let entries: fs.Dirent[];
213
+ try {
214
+ entries = fs.readdirSync(dir, { withFileTypes: true });
215
+ } catch {
216
+ tally.unreadable += 1;
217
+ return false;
218
+ }
219
+ for (const e of entries) {
220
+ const full = path.join(dir, e.name);
221
+ if (e.isSymbolicLink()) continue;
222
+ if (e.isDirectory()) {
223
+ if (dirRaises(full, re, tally)) return true;
224
+ } else if (e.isFile()) {
225
+ if (fileRaises(full, re, tally)) return true;
226
+ }
227
+ }
228
+ return false;
229
+ }
230
+
231
+ /**
232
+ * Count distinct rounds whose contents match `re`.
233
+ *
234
+ * A round stops being examined the moment one of its files matches: the
235
+ * question is whether the round raised the subject, not how loudly.
236
+ */
237
+ export function countRounds(tree: string, re: RegExp): RoundCount {
238
+ let entries: fs.Dirent[];
239
+ try {
240
+ entries = fs.readdirSync(tree, { withFileTypes: true });
241
+ } catch {
242
+ return { matches: [], examined: 0, unreadable: 1, oversized: 0 };
243
+ }
244
+ const tally = { unreadable: 0, oversized: 0 };
245
+ const matches: RoundMatch[] = [];
246
+ let examined = 0;
247
+ for (const e of entries.slice().sort((a, b) => a.name.localeCompare(b.name))) {
248
+ if (e.name.startsWith('.')) continue;
249
+ if (e.isSymbolicLink()) continue;
250
+ const full = path.join(tree, e.name);
251
+ if (e.isDirectory()) {
252
+ examined += 1;
253
+ if (dirRaises(full, re, tally)) matches.push({ name: e.name, kind: 'dir' });
254
+ } else if (e.isFile()) {
255
+ examined += 1;
256
+ if (fileRaises(full, re, tally)) matches.push({ name: e.name, kind: 'file' });
257
+ }
258
+ }
259
+ return { matches, examined, unreadable: tally.unreadable, oversized: tally.oversized };
260
+ }
261
+
262
+ export interface HeldObject {
263
+ /** Repo-relative path. */
264
+ rel: string;
265
+ /** File stem. */
266
+ slug: string;
267
+ /** Declared subject pattern, when the object carries one. */
268
+ declared: string | null;
269
+ }
270
+
271
+ /** Locate a held object by path or by slug. */
272
+ export function findHeldObject(root: string, ref: string): HeldObject | null {
273
+ const direct = path.isAbsolute(ref) ? ref : path.join(root, ref);
274
+ const candidates = [direct];
275
+ const bare = path.basename(ref).replace(/\.md$/, '');
276
+ for (const d of HELD_DIRS) candidates.push(path.join(root, d, `${bare}.md`));
277
+ for (const c of candidates) {
278
+ let text: string;
279
+ try {
280
+ if (!fs.statSync(c).isFile()) continue;
281
+ text = fs.readFileSync(c, 'utf8');
282
+ } catch {
283
+ continue;
284
+ }
285
+ const m = SUBJECT_MARKER.exec(text);
286
+ return {
287
+ rel: path.relative(root, c),
288
+ slug: path.basename(c).replace(/\.md$/, ''),
289
+ declared: m === null ? null : (m[1] ?? null),
290
+ };
291
+ }
292
+ return null;
293
+ }
294
+
295
+ /**
296
+ * A pattern derived from a slug, used only when the object declares none.
297
+ *
298
+ * Deliberately weak, and printed as such. The recorded counters in this tree
299
+ * were taken under hand-written subject patterns, and a slug is a filename
300
+ * rather than a subject — so this reads as a hard floor and usually as zero.
301
+ * It exists so the tool answers for any object rather than refusing, and the
302
+ * output says which kind of pattern produced the figure.
303
+ */
304
+ export function slugPattern(slug: string): string {
305
+ return slug.replace(/^road-to-/, '').replace(/-/g, '[-_ .]');
306
+ }
307
+
308
+ type PatternOrigin = 'flag' | 'declared' | 'slug';
309
+
310
+ function buildPattern(
311
+ obj: HeldObject | null,
312
+ flag: string | undefined,
313
+ ): { source: string; origin: PatternOrigin } {
314
+ if (flag !== undefined && flag !== '') return { source: flag, origin: 'flag' };
315
+ if (obj !== null && obj.declared !== null) return { source: obj.declared, origin: 'declared' };
316
+ if (obj !== null) return { source: slugPattern(obj.slug), origin: 'slug' };
317
+ return { source: '', origin: 'slug' };
318
+ }
319
+
320
+ const PATTERN_NOTE: Record<PatternOrigin, string> = {
321
+ flag: 'given on the command line',
322
+ declared: 'declared on the object itself',
323
+ slug: 'derived from the slug — a hard floor, and usually the wrong question; pass --pattern',
324
+ };
325
+
326
+ const ORIGIN_NOTE: Record<TreeOrigin, string> = {
327
+ flag: '--tree',
328
+ env: 'AGENT_CONFIG_INBOX_TREE',
329
+ 'repo-root': 'this repository root',
330
+ 'main-checkout': "the main checkout behind this worktree",
331
+ 'project-dir': 'CLAUDE_PROJECT_DIR',
332
+ unreadable: 'nothing',
333
+ };
334
+
335
+ export interface ReportResult {
336
+ exit: number;
337
+ text: string;
338
+ }
339
+
340
+ export function report(argv: readonly string[], root: string): ReportResult {
341
+ const out: string[] = [];
342
+ const arg = (name: string): string | undefined => {
343
+ const i = argv.indexOf(name);
344
+ return i === -1 ? undefined : argv[i + 1];
345
+ };
346
+ // Only these flags consume the token after them. The earlier form treated
347
+ // EVERY `--`-prefixed token as value-taking, which silently ate the held
348
+ // object ref after `--list`, and let the documented short form `-i` through
349
+ // as a positional because it carries one dash rather than two. Both
350
+ // spellings are in the help text, so both have to parse.
351
+ const VALUE_FLAGS = new Set(['--pattern', '--tree', '--root']);
352
+ const positional = argv.filter(
353
+ (a, i) => !a.startsWith('-') && !VALUE_FLAGS.has(String(argv[i - 1] ?? '')),
354
+ );
355
+ const ref = positional[0];
356
+ if (ref === undefined) {
357
+ return { exit: 2, text: 'usage: report_held_object_arrivals <held-object> [--pattern RE] [--tree DIR] [--root DIR] [--ignore-case] [--list]\n' };
358
+ }
359
+
360
+ const obj = findHeldObject(root, ref);
361
+ if (obj === null) {
362
+ out.push(`held object not found: ${ref}`);
363
+ out.push(` looked in: ${HELD_DIRS.join(', ')}`);
364
+ return { exit: 2, text: `${out.join('\n')}\n` };
365
+ }
366
+
367
+ const { source, origin: patternOrigin } = buildPattern(obj, arg('--pattern'));
368
+ // Case-sensitive by default, so the tool reproduces a plain `grep -rl`.
369
+ // Every recorded counter in this tree was taken that way, and a figure
370
+ // that quietly reads two rounds higher than the line it is checking is
371
+ // worse than no figure: it looks like drift in the store rather than a
372
+ // difference in the question asked.
373
+ const fold = argv.includes('--ignore-case') || argv.includes('-i');
374
+ let re: RegExp;
375
+ try {
376
+ re = new RegExp(source, fold ? 'i' : '');
377
+ } catch (e) {
378
+ return { exit: 2, text: `bad --pattern: ${String(e)}\n` };
379
+ }
380
+
381
+ const tree = resolveTree({ root, flag: arg('--tree') });
382
+
383
+ out.push(`object: ${obj.rel}`);
384
+ out.push(`pattern: /${source}/${fold ? 'i' : ''} (${PATTERN_NOTE[patternOrigin]})`);
385
+
386
+ if (tree.dir === null) {
387
+ out.push('tree: unreadable');
388
+ for (const t of tree.tried) out.push(` · ${t.origin}: ${t.dir} — absent`);
389
+ out.push('');
390
+ out.push('no prior rounds readable — the consumed-inbox tree is gitignored and');
391
+ out.push('machine-local, so this environment cannot see it. This is NOT a count of');
392
+ out.push('zero arrivals: no reading was taken. Point --tree at the tree, or run');
393
+ out.push('where it lives.');
394
+ return { exit: 0, text: `${out.join('\n')}\n` };
395
+ }
396
+
397
+ const counted = countRounds(tree.dir, re);
398
+ // A tree that STATS as a directory but cannot be LISTED reaches here with
399
+ // examined 0 and unreadable 1. Printing "0 distinct round(s) of 0 examined"
400
+ // would be the zero-versus-unreadable conflation this tool exists to avoid,
401
+ // one layer in from the absent-tree branch above: `isReadableDir` passed, so
402
+ // resolution succeeded and only enumeration failed. Same message, because it
403
+ // is the same fact — no reading was taken.
404
+ if (counted.examined === 0 && counted.unreadable > 0) {
405
+ out.push(`tree: ${tree.dir} (via ${ORIGIN_NOTE[tree.origin]}, but not listable)`);
406
+ out.push('');
407
+ out.push('no prior rounds readable — the tree resolved but could not be enumerated');
408
+ out.push('(permissions, or a race with something rewriting it). This is NOT a count');
409
+ out.push('of zero arrivals: no reading was taken.');
410
+ return { exit: 0, text: `${out.join('\n')}\n` };
411
+ }
412
+ const dirs = counted.matches.filter((m) => m.kind === 'dir').length;
413
+ const files = counted.matches.length - dirs;
414
+ out.push(`tree: ${tree.dir} (via ${ORIGIN_NOTE[tree.origin]})`);
415
+ out.push('');
416
+ out.push(
417
+ `arrivals: ${String(counted.matches.length)} distinct round(s) of ${String(counted.examined)} examined` +
418
+ ` — ${String(dirs)} round director(ies), ${String(files)} loose drop(s)`,
419
+ );
420
+ if (counted.unreadable > 0) out.push(` note: ${String(counted.unreadable)} path(s) unreadable — the count is a floor`);
421
+ if (counted.oversized > 0) out.push(` note: ${String(counted.oversized)} file(s) skipped for size`);
422
+ out.push('');
423
+ out.push('The figure is a floor on recurrence over one machine\'s store under one');
424
+ out.push('pattern. Write it onto the object by hand, with this pattern and this');
425
+ out.push('denominator beside it, so the next reader can tell a measured count from a');
426
+ out.push('remembered one.');
427
+ if (argv.includes('--list') || counted.matches.length <= 40) {
428
+ out.push('');
429
+ out.push('rounds:');
430
+ for (const m of counted.matches) out.push(` · ${m.name}${m.kind === 'file' ? ' (loose drop)' : ''}`);
431
+ }
432
+ return { exit: 0, text: `${out.join('\n')}\n` };
433
+ }
434
+
435
+ export function main(argv: readonly string[]): number {
436
+ if (argv.includes('--help') || argv.includes('-h')) {
437
+ process.stdout.write(
438
+ 'usage: report_held_object_arrivals <held-object> [--pattern RE] [--tree DIR] [--root DIR] [--list]\n\n' +
439
+ ' <held-object> a path, or a bare slug resolved under the held-object directories\n' +
440
+ ' --pattern RE the subject pattern; overrides any the object declares\n' +
441
+ ' --tree DIR the consumed-inbox tree; otherwise resolved and reported\n' +
442
+ ' --ignore-case fold case; the default is a case-sensitive grep\n' +
443
+ ' --list list every matched round even when there are many\n',
444
+ );
445
+ return 0;
446
+ }
447
+ const i = argv.indexOf('--root');
448
+ const root = i === -1 ? REPO_ROOT : (argv[i + 1] ?? REPO_ROOT);
449
+ const res = report(argv, root);
450
+ process.stdout.write(res.text);
451
+ return res.exit;
452
+ }
453
+
454
+ if (process.argv[1] !== undefined && import.meta.url === pathToFileURL(path.resolve(process.argv[1])).href) {
455
+ process.exit(main(process.argv.slice(2)));
456
+ }
@@ -24,13 +24,22 @@
24
24
  * Usage:
25
25
  * sync_agent_settings # write (default)
26
26
  * sync_agent_settings --dry-run # show diff, no write
27
- * sync_agent_settings --check # exit 2 on drift (for CI)
27
+ * sync_agent_settings --check # exit 2 if a sync is needed (CI)
28
28
  * sync_agent_settings --profile balanced # use a specific profile
29
29
  * sync_agent_settings --path path/to/.agent-settings.yml
30
30
  *
31
31
  * Exit codes:
32
32
  * 0 — already in sync, or changes applied (or --dry-run ran cleanly)
33
- * 2 — drift detected under --check, or invalid arguments / missing files
33
+ * 2 — under --check, synchronization requires intervention; also invalid
34
+ * arguments, missing files, and a duplicate-key repair this tool
35
+ * refuses to perform.
36
+ *
37
+ * `--check` has two diagnostic cases and its stderr line already separates
38
+ * them, because the remedies differ: template drift ("drift detected"), and a
39
+ * duplicate-key repair with no drift ("duplicate keys need collapsing"). Both
40
+ * exit 2 — the target needs a write either way, and a green `--check` over a
41
+ * file the strict reader cannot parse would hide the breakage. A caller that
42
+ * needs the distinction reads the message, not the code.
34
43
  *
35
44
  * No behaviour changes vs. the retired Python implementation — historical quirks preserved (consumers pin the exact behaviour).
36
45
  */
@@ -441,6 +450,52 @@ function loadTemplate(p: string, profileValues: Record<string, string>): string
441
450
  return renderTemplate(fs.readFileSync(p, 'utf-8'), profileValues);
442
451
  }
443
452
 
453
+ /**
454
+ * The comment `mergeIntoTemplate` writes above its appended flat-key block.
455
+ *
456
+ * Duplicated here rather than imported: the writer holds it as a literal in
457
+ * `src/server/io/yamlIO.ts` and exports no constant, and that module belongs
458
+ * to the server tree this CLI does not otherwise depend on. Keep the two in
459
+ * step — a drift makes the sweep below silently stop matching.
460
+ */
461
+ const WIZARD_BLOCK_HEADER = '# Wizard-added keys (no template entry)';
462
+
463
+ /**
464
+ * Drop a wizard-block header the collapse emptied.
465
+ *
466
+ * The non-idempotent writer appended the header AND its keys on every save, so
467
+ * a collapse that removes the later block's key lines leaves that block's
468
+ * header behind with nothing under it — and the next append puts a fresh
469
+ * header below the orphan, one more per save. A header names the keys beneath
470
+ * it; with none beneath it, it names nothing and misleads the next reader.
471
+ *
472
+ * A header counts as emptied only when no key line stands between it and the
473
+ * next header or EOF. Blanks and further comments do not count: a user comment
474
+ * written between the header and its first key is part of the block, not a
475
+ * replacement for it.
476
+ */
477
+ function dropOrphanedWizardHeaders(lines: readonly string[]): string[] {
478
+ const drop = new Set<number>();
479
+ for (let i = 0; i < lines.length; i++) {
480
+ if (lines[i]?.trim() !== WIZARD_BLOCK_HEADER) continue;
481
+ let hasKey = false;
482
+ for (let j = i + 1; j < lines.length; j++) {
483
+ const l = lines[j];
484
+ if (l === undefined) continue;
485
+ if (l.trim() === WIZARD_BLOCK_HEADER) break;
486
+ if (l.trim() === '' || l.trimStart().startsWith('#')) continue;
487
+ hasKey = true;
488
+ break;
489
+ }
490
+ if (hasKey) continue;
491
+ drop.add(i);
492
+ // The writer emits the blank line above the header as part of the block,
493
+ // so removing the header alone widens the gap by one line per repair.
494
+ if (i > 0 && lines[i - 1]?.trim() === '') drop.add(i - 1);
495
+ }
496
+ return drop.size === 0 ? [...lines] : lines.filter((_, i) => !drop.has(i));
497
+ }
498
+
444
499
  export interface DuplicateRepair {
445
500
  /** The document with every safely-collapsible duplicate run reduced to one. */
446
501
  text: string;
@@ -482,6 +537,9 @@ export interface DuplicateRepair {
482
537
  * A multi-document stream (`---` / `...`) is not repaired either. The reader
483
538
  * takes a single document and rejects such a file whatever this pass does, so
484
539
  * collapsing across a boundary could only merge two documents' keys into one.
540
+ *
541
+ * When — and only when — something did collapse, a wizard-block header the
542
+ * collapse emptied is removed with it; see `dropOrphanedWizardHeaders`.
485
543
  */
486
544
  export function collapseDuplicateFlatKeys(text: string): DuplicateRepair {
487
545
  // Line endings are preserved: splitting on `\n` alone leaves a trailing
@@ -630,10 +688,53 @@ export function collapseDuplicateFlatKeys(text: string): DuplicateRepair {
630
688
  if (collapsed.length === 0) {
631
689
  return { text, collapsed, unsafe };
632
690
  }
633
- const kept = lines.filter((_, i) => !drop.has(i));
691
+ const kept = dropOrphanedWizardHeaders(lines.filter((_, i) => !drop.has(i)));
634
692
  return { text: kept.join(eol), collapsed, unsafe };
635
693
  }
636
694
 
695
+ /**
696
+ * The parse error an input still carries once its duplicate keys are excused,
697
+ * or null when the duplicates were its only problem.
698
+ *
699
+ * `uniqueKeys: false` is what excuses them: the duplicate-key rejection is the
700
+ * single error the collapse has a mandate to remove, so anything the parser
701
+ * still raises is an error the collapse was never licensed to touch.
702
+ */
703
+ function residualParseError(text: string): Error | null {
704
+ try {
705
+ parseYaml(text, { version: '1.1', uniqueKeys: false });
706
+ return null;
707
+ } catch (err) {
708
+ return err instanceof Error ? err : new Error(String(err));
709
+ }
710
+ }
711
+
712
+ /**
713
+ * What the parser reliably exposes about a failure, as one indented block.
714
+ *
715
+ * Available on every `YAMLParseError` observed from this parser: `message`,
716
+ * whose first line already carries `at line L, column C` plus the detail, and
717
+ * `code`, a stable identifier. `linePos` is populated too, but only while
718
+ * `prettyErrors` stays on (the library's own condition), so it is printed when
719
+ * present and never relied on. Nothing here is asserted beyond the parser's
720
+ * own first message line, which is derived in the test rather than hardcoded —
721
+ * pinning a format this module does not own would make a safety test fail on a
722
+ * dependency's wording change.
723
+ */
724
+ function formatParserDetail(err: Error): string {
725
+ const first = err.message.split('\n')[0] ?? err.message;
726
+ const parts: string[] = [` ${first}`];
727
+ const code = (err as { code?: unknown }).code;
728
+ const linePos = (err as { linePos?: { line: number; col: number }[] }).linePos;
729
+ const at = linePos?.[0];
730
+ if (typeof code === 'string' && at !== undefined) {
731
+ parts.push(` (${code} at line ${at.line}, column ${at.col})`);
732
+ } else if (typeof code === 'string') {
733
+ parts.push(` (${code})`);
734
+ }
735
+ return parts.join('\n') + '\n';
736
+ }
737
+
637
738
  function loadUserText(raw: string): Record<string, unknown> {
638
739
  const data = parseYaml(raw, { version: '1.1' });
639
740
  if (data === null || data === undefined) {
@@ -863,6 +964,42 @@ export function main(argv: readonly string[] = process.argv.slice(2)): number {
863
964
  return 2;
864
965
  }
865
966
  if (repair.collapsed.length > 0) {
967
+ // Refuse before anything else in this branch, including the notice below:
968
+ // in write mode that notice is past tense ("collapsed …"), so announcing
969
+ // it and then refusing describes a write that never happened.
970
+ //
971
+ // The collapse's safety rests on preserving the original's last-wins
972
+ // reading, and a document that is invalid for some FURTHER reason has no
973
+ // reading to preserve — the pass can then emit valid YAML carrying
974
+ // structure nobody authored, which every downstream check accepts. A
975
+ // stderr warning (what this branch used to do) is a weak control against a
976
+ // silent outcome; a refusal is loud and the operator can still recover.
977
+ // AI council, 2026-09-11, 2 of 2 seats, converged — including a rejection
978
+ // of any `--repair-anyway` escape: an operator who understands the file
979
+ // well enough to use it understands it well enough to fix the error first,
980
+ // and "read the diff" is not a trustworthy oracle for YAML semantics.
981
+ //
982
+ // Exit 2, per the taxonomy in this file's header: 2 is "this tool cannot
983
+ // proceed with this input", which is also what the sibling refusal above
984
+ // (duplicates it will not collapse) returns. 1 is spoken for — it is the
985
+ // mirrored `install.fail` exit and means a broken install asset.
986
+ //
987
+ // Nothing durable precedes this point: the only write in `main` is the
988
+ // single `writeFileSync` at the end, there is no temporary file and no
989
+ // rename, so the refusal leaves the target byte-for-byte unchanged.
990
+ const residual = residualParseError(rawText);
991
+ if (residual !== null) {
992
+ process.stderr.write(
993
+ `❌ ${target}: refusing the automatic duplicate repair — the file has ` +
994
+ `another YAML error besides its duplicate keys.\n` +
995
+ formatParserDetail(residual) +
996
+ ` Collapsing changes only WHETHER a file parses, never what it parses as, ` +
997
+ `and that promise is empty over a document with no valid reading.\n` +
998
+ ` Fix the syntax error by hand, then re-run to apply the duplicate repair.\n`,
999
+ );
1000
+ return 2;
1001
+ }
1002
+
866
1003
  // NOT gated on --quiet, and not on stdout. Both callers that matter run
867
1004
  // quiet — the release's `sync-agent-settings` step and the pre-release
868
1005
  // probe — so gating this would let a release rewrite the operator's own
@@ -878,21 +1015,6 @@ export function main(argv: readonly string[] = process.argv.slice(2)): number {
878
1015
  ? `collapsed ${repair.collapsed.length} duplicate key(s), last value kept`
879
1016
  : `would collapse ${repair.collapsed.length} duplicate key(s), keeping the last value (no write in this mode)`;
880
1017
  process.stderr.write(`🔧 ${target}: ${verb} — ${repair.collapsed.join(', ')}\n`);
881
-
882
- // The collapse promises to change only WHETHER the file parses, never what
883
- // it parses as — and that promise is only meaningful over an input that had
884
- // a reading to preserve. If the original was unparseable for some further
885
- // reason (an unterminated quote, say), the repaired file may parse to
886
- // structure no human wrote, and the next `git diff` shows keys nobody
887
- // added. Cheap to say, and the operator cannot recover it from the output.
888
- try {
889
- parseYaml(rawText, { version: '1.1', uniqueKeys: false });
890
- } catch {
891
- process.stderr.write(
892
- `⚠️ ${target}: the original had a syntax error beyond the duplicate keys, so the ` +
893
- `result may differ in structure, not only in duplicates — read the diff before trusting it.\n`,
894
- );
895
- }
896
1018
  }
897
1019
  const sourceText = repair.text;
898
1020