@event4u/agent-config 14.13.0 → 14.14.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 (101) 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 +116 -387
  5. package/dist/agent-src/commands/analyze/inbox.md +36 -0
  6. package/dist/agent-src/rules/code-comment-discipline.md +6 -0
  7. package/dist/agent-src/scripts/archive_completed_roadmaps.ts +6 -6
  8. package/dist/agent-src/scripts/guarded_baseline.ts +22 -7
  9. package/dist/agent-src/scripts/stubs_due.ts +118 -2
  10. package/dist/agent-src/scripts/update_roadmap_progress.ts +8 -8
  11. package/dist/agent-src/templates/agents/agent-project-settings.example.yml +1 -1
  12. package/dist/cli-delegate/cmd_conformance.js +38 -1
  13. package/dist/discovery/deprecation-report.md +1 -1
  14. package/dist/discovery/discovery-manifest.json +4 -4
  15. package/dist/discovery/discovery-manifest.json.sha256 +1 -1
  16. package/dist/discovery/discovery-manifest.summary.md +1 -1
  17. package/dist/discovery/dormancy-report.md +1 -1
  18. package/dist/discovery/orphan-report.md +1 -1
  19. package/dist/discovery/packs.json +2 -2
  20. package/dist/discovery/trust-report.md +1 -1
  21. package/dist/discovery/workspaces.json +2 -2
  22. package/dist/hooks/dispatch.js +1438 -711
  23. package/dist/mcp/registry-manifest.json +2 -2
  24. package/dist/mcp/server.json +2 -2
  25. package/dist/mcp/server.mjs +112 -17
  26. package/docs/guidelines/agent-infra/guarded-baseline.md +1 -1
  27. package/docs/guidelines/code-clarity.md +18 -0
  28. package/package.json +1 -1
  29. package/src/agent-src/scripts/archive_completed_roadmaps.ts +6 -6
  30. package/src/agent-src/scripts/guarded_baseline.ts +22 -7
  31. package/src/agent-src/scripts/stubs_due.ts +118 -2
  32. package/src/agent-src/scripts/update_roadmap_progress.ts +8 -8
  33. package/src/config/estate-count-budget.json +20 -1
  34. package/src/config/gate-coverage.yml +150 -0
  35. package/src/config/gate-violation-baselines.json +8 -3
  36. package/src/config/preamble-payload-budget.json +19 -11
  37. package/src/config/rule-stub-ceilings.json +14 -7
  38. package/src/scripts/_dispatch.bash +0 -4
  39. package/src/scripts/_lib/activation_ladder.ts +47 -3
  40. package/src/scripts/_lib/activation_receipt_producer.ts +341 -0
  41. package/src/scripts/_lib/adr_frontmatter.ts +26 -0
  42. package/src/scripts/_lib/candidate_pair_delta.ts +349 -0
  43. package/src/scripts/_lib/carrier_status.ts +118 -0
  44. package/src/scripts/_lib/cascade_stage_enumeration.ts +92 -0
  45. package/src/scripts/_lib/corpus_manifest.ts +726 -0
  46. package/src/scripts/_lib/delivery_arm_experiment.ts +18 -2
  47. package/src/scripts/_lib/estate_carrier_cases.ts +72 -0
  48. package/src/scripts/_lib/evaluation_cascade.ts +452 -0
  49. package/src/scripts/_lib/evolution_roi.ts +562 -0
  50. package/src/scripts/_lib/harness_evolution_guards.ts +10 -0
  51. package/src/scripts/_lib/lexical_shortlist.ts +215 -0
  52. package/src/scripts/_lib/llm_candidate_proposer.ts +461 -0
  53. package/src/scripts/_lib/llm_proposer_transport.ts +153 -0
  54. package/src/scripts/_lib/pathology_archive.ts +393 -0
  55. package/src/scripts/_lib/promotion_capability.ts +315 -0
  56. package/src/scripts/_lib/promotion_evidence.ts +414 -0
  57. package/src/scripts/_lib/promotion_review.ts +294 -0
  58. package/src/scripts/_lib/prompt_shape.ts +92 -0
  59. package/src/scripts/_lib/release_highlights.ts +70 -1
  60. package/src/scripts/_lib/release_material.ts +125 -2
  61. package/src/scripts/_lib/routing_corpus.ts +307 -0
  62. package/src/scripts/_lib/routing_index_input.ts +104 -0
  63. package/src/scripts/_lib/rule_injection.ts +22 -1
  64. package/src/scripts/_lib/semantic_noop.ts +167 -0
  65. package/src/scripts/activation_receipt.ts +258 -0
  66. package/src/scripts/ai_council/cross_exam.ts +128 -0
  67. package/src/scripts/ai_council/explain_route.ts +157 -0
  68. package/src/scripts/ai_council/information_gain.ts +293 -0
  69. package/src/scripts/ai_council/leakage_corpus.ts +385 -0
  70. package/src/scripts/ai_council/leakage_patterns.ts +453 -0
  71. package/src/scripts/ai_council/minority_retention.ts +171 -0
  72. package/src/scripts/ai_council/recouncil_savings.ts +299 -0
  73. package/src/scripts/ai_council/replay_route.ts +223 -0
  74. package/src/scripts/ai_council/reviewer_assignment.ts +279 -0
  75. package/src/scripts/ai_council/routing_training_row.ts +197 -0
  76. package/src/scripts/ai_council/synthesis_strategy.ts +159 -0
  77. package/src/scripts/check_adr_frontmatter.ts +4 -9
  78. package/src/scripts/check_estate_count.ts +205 -24
  79. package/src/scripts/check_release_highlights.ts +96 -13
  80. package/src/scripts/check_roadmap_trackable.ts +17 -2
  81. package/src/scripts/corpus_manifest.ts +235 -0
  82. package/src/scripts/evolution_lab.ts +181 -8
  83. package/src/scripts/hook_manifest.json +1 -1
  84. package/src/scripts/hook_manifest.yaml +28 -6
  85. package/src/scripts/hooks/comment_discipline_hook.ts +146 -0
  86. package/src/scripts/hooks/concern_registry.ts +2 -0
  87. package/src/scripts/hooks/turn_end_gate_hook.ts +9 -1
  88. package/src/scripts/language_mirror_hook.ts +14 -2
  89. package/src/scripts/lint_carrier_integrity.ts +820 -0
  90. package/src/scripts/lint_code_comments.ts +643 -0
  91. package/src/scripts/lint_plan_risk_register.ts +39 -19
  92. package/src/scripts/lint_promotion_paths.ts +779 -0
  93. package/src/scripts/llm_propose.ts +256 -0
  94. package/src/scripts/measure_delivery_sets.ts +361 -0
  95. package/src/scripts/measure_routing_signal.ts +307 -0
  96. package/src/scripts/model_rule_injection.ts +19 -3
  97. package/src/scripts/probe_council_retention.ts +495 -0
  98. package/src/scripts/release.ts +21 -37
  99. package/src/scripts/release_drill.ts +143 -0
  100. package/src/scripts/release_env.ts +35 -0
  101. package/src/scripts/release_publication.ts +84 -0
@@ -0,0 +1,495 @@
1
+ #!/usr/bin/env tsx
2
+ /**
3
+ * Read-only probe: is the declared council-artefact TTL actually honoured?
4
+ *
5
+ * `ai_council.session_retention_days` (default 7 — `ai_council/session.ts:81`)
6
+ * declares a retention window for the three council artefact directories:
7
+ *
8
+ * agents/runtime/council/questions/
9
+ * agents/runtime/council/responses/
10
+ * agents/runtime/council/sessions/
11
+ *
12
+ * Every pruner in the tree that could enforce that window is reached only by an
13
+ * explicit human command:
14
+ *
15
+ * - `prune_all_council_artifacts` (`ai_council/session.ts:468`) has exactly
16
+ * one caller in `src/`, the manual CLI `council_prune.ts:131`, which is
17
+ * bound to `task council-prune` and to no hook, workflow or scheduler.
18
+ * - `save()` (`ai_council/session.ts:506`), the only function that calls
19
+ * `prune_old_artifacts(QUESTIONS_DIR, …)` / `(RESPONSES_DIR, …)`
20
+ * (`:603-604`), has no caller in `src/` at all — `council_cli.ts` writes its
21
+ * own `--output` payload directly (`council_cli.ts:2807`) and never imports
22
+ * the session module.
23
+ * - `janitor.ts` declares `agents/runtime/council/responses` with
24
+ * `ttlDays: 7` (`janitor.ts:55-61`) but defaults to a dry-run report;
25
+ * deletion needs `--apply` (`janitor.ts:12-14`, `:329`), and it declares no
26
+ * entry for `questions/` or `sessions/` at all.
27
+ *
28
+ * So the TTL is a declaration, not a mechanism, and the retained corpus is
29
+ * "every artefact nobody deleted by hand". This probe measures that gap instead
30
+ * of asserting it: it reports how much of the corpus is past its declared TTL,
31
+ * and how much provider-attributed response text that over-TTL slice holds.
32
+ *
33
+ * THIS SCRIPT NEVER WRITES, UNLINKS, MOVES, TRUNCATES, OR MUTATES ANYTHING.
34
+ * It opens files for reading only. There is no `--apply`, no `--fix`, and no
35
+ * code path that calls a mutating `fs` function. The corpus is the measurement
36
+ * subject; destroying it would destroy the instrument.
37
+ *
38
+ * Usage:
39
+ * ./scripts-run src/scripts/probe_council_retention
40
+ * ./scripts-run src/scripts/probe_council_retention --root /path/to/project
41
+ * ./scripts-run src/scripts/probe_council_retention --retention-days 7 --json
42
+ *
43
+ * Exit code: 0 always. This is a report, never a gate — an over-TTL corpus is
44
+ * the finding, not a build failure.
45
+ */
46
+
47
+ import * as fs from 'node:fs';
48
+ import * as path from 'node:path';
49
+ import { fileURLToPath, pathToFileURL } from 'node:url';
50
+
51
+ import { resolve_project_root } from './_lib/agent_settings.js';
52
+
53
+ const _HERE = fileURLToPath(import.meta.url);
54
+
55
+ const PROG = 'probe_council_retention';
56
+
57
+ /** The three canonical council dirs, relative to the project root. */
58
+ const COUNCIL_DIRS: readonly string[] = ['responses', 'sessions', 'questions'];
59
+
60
+ /** Mirrors `DEFAULT_RETENTION_DAYS` (`ai_council/session.ts:81`). */
61
+ const DEFAULT_RETENTION_DAYS = 7;
62
+
63
+ const MS_PER_DAY = 86_400_000;
64
+
65
+ const _USAGE = `usage: ${PROG} [-h] [--root PATH] [--retention-days N] [--json]`;
66
+
67
+ const _HELP =
68
+ `${_USAGE}\n\n` +
69
+ 'Read-only report on whether the declared council-artefact TTL is honoured.\n' +
70
+ 'Never deletes or modifies anything.\n\n' +
71
+ 'options:\n' +
72
+ ' -h, --help show this help message and exit\n' +
73
+ ' --root PATH project root to probe (default: resolved like sibling scripts)\n' +
74
+ ` --retention-days N TTL in days (default: ${String(DEFAULT_RETENTION_DAYS)})\n` +
75
+ ' --json emit the report as JSON instead of text\n';
76
+
77
+ interface Args {
78
+ root: string | null;
79
+ retention_days: number;
80
+ json: boolean;
81
+ help: boolean;
82
+ }
83
+
84
+ /** One TTL-split pair of counts. */
85
+ interface Split {
86
+ within_ttl: number;
87
+ over_ttl: number;
88
+ }
89
+
90
+ interface DirReport {
91
+ /** Directory label (`responses` / `sessions` / `questions`). */
92
+ dir: string;
93
+ /** Absolute path probed. */
94
+ abs_path: string;
95
+ /** Whether the directory exists at all. */
96
+ exists: boolean;
97
+ /** Files found, recursively. */
98
+ files: number;
99
+ /** Files split by mtime against the TTL cutoff. */
100
+ files_split: Split;
101
+ /** JSON files carrying a `responses[]` array. */
102
+ json_with_responses: number;
103
+ /** Files that look like JSON but would not parse. */
104
+ unparseable: number;
105
+ /** Provider-attributed response bodies (provider + text, no error). */
106
+ attributed_bodies: number;
107
+ /** Attributed bodies split by their SOURCE FILE's mtime against the TTL. */
108
+ attributed_split: Split;
109
+ /** Attributed bodies per provider. */
110
+ by_provider: Record<string, number>;
111
+ /** Attributed bodies per provider, over-TTL only. */
112
+ by_provider_over_ttl: Record<string, number>;
113
+ /** Age in days of the oldest file found, or `null` when the dir is empty. */
114
+ oldest_age_days: number | null;
115
+ }
116
+
117
+ interface Report {
118
+ root: string;
119
+ root_origin: string;
120
+ retention_days: number;
121
+ generated_at_utc: string;
122
+ dirs: DirReport[];
123
+ totals: {
124
+ files: number;
125
+ files_over_ttl: number;
126
+ attributed_bodies: number;
127
+ attributed_bodies_over_ttl: number;
128
+ unparseable: number;
129
+ oldest_age_days: number | null;
130
+ };
131
+ /** `true` when nothing at all is past the declared window. */
132
+ ttl_honoured: boolean;
133
+ }
134
+
135
+ function _argError(message: string): never {
136
+ process.stderr.write(`${_USAGE}\n`);
137
+ process.stderr.write(`${PROG}: error: ${message}\n`);
138
+ // A malformed invocation is still not a gate failure for the caller's
139
+ // build, but it must not print a report that means nothing.
140
+ process.exitCode = 2;
141
+ throw new _ExitSignal(2);
142
+ }
143
+
144
+ class _ExitSignal extends Error {
145
+ constructor(readonly code: number) {
146
+ super(`exit ${code}`);
147
+ }
148
+ }
149
+
150
+ function _parseArgs(argv: readonly string[]): Args {
151
+ const args: Args = {
152
+ root: null,
153
+ retention_days: DEFAULT_RETENTION_DAYS,
154
+ json: false,
155
+ help: false,
156
+ };
157
+ for (let i = 0; i < argv.length; i += 1) {
158
+ const a = argv[i] as string;
159
+ if (a === '-h' || a === '--help') {
160
+ args.help = true;
161
+ } else if (a === '--json') {
162
+ args.json = true;
163
+ } else if (a === '--root') {
164
+ const v = argv[i + 1];
165
+ if (v === undefined || v.startsWith('-')) {
166
+ _argError('argument --root: expected one argument');
167
+ }
168
+ args.root = v;
169
+ i += 1;
170
+ } else if (a === '--retention-days') {
171
+ const v = argv[i + 1];
172
+ if (v === undefined || v.startsWith('-')) {
173
+ _argError('argument --retention-days: expected one argument');
174
+ }
175
+ const parsed = Number.parseInt(v, 10);
176
+ if (!Number.isFinite(parsed) || String(parsed) !== v.trim() || parsed < 0) {
177
+ _argError(`argument --retention-days: invalid non-negative int value: '${v}'`);
178
+ }
179
+ args.retention_days = parsed;
180
+ i += 1;
181
+ } else {
182
+ _argError(`unrecognized arguments: ${a}`);
183
+ }
184
+ }
185
+ return args;
186
+ }
187
+
188
+ function _isDir(p: string): boolean {
189
+ try {
190
+ return fs.statSync(p).isDirectory();
191
+ } catch {
192
+ return false;
193
+ }
194
+ }
195
+
196
+ /**
197
+ * Every FILE under `dir`, recursively, sorted for a stable report.
198
+ *
199
+ * Recursive rather than a flat `readdirSync`: `responses/` holds per-debate
200
+ * subdirectories (`<slug>.json/debate-round-N.json`), and a flat listing counts
201
+ * one entry where the corpus holds several artefacts.
202
+ */
203
+ function _walkFiles(dir: string): string[] {
204
+ const out: string[] = [];
205
+ if (!_isDir(dir)) {
206
+ return out;
207
+ }
208
+ let entries: string[];
209
+ try {
210
+ entries = fs.readdirSync(dir).sort();
211
+ } catch {
212
+ return out;
213
+ }
214
+ for (const name of entries) {
215
+ const full = path.join(dir, name);
216
+ let st: fs.Stats;
217
+ try {
218
+ st = fs.statSync(full);
219
+ } catch {
220
+ continue;
221
+ }
222
+ if (st.isFile()) {
223
+ out.push(full);
224
+ } else if (st.isDirectory()) {
225
+ out.push(..._walkFiles(full));
226
+ }
227
+ }
228
+ return out;
229
+ }
230
+
231
+ function _mtimeMs(p: string): number | null {
232
+ try {
233
+ return fs.statSync(p).mtimeMs;
234
+ } catch {
235
+ return null;
236
+ }
237
+ }
238
+
239
+ function _bump(map: Record<string, number>, key: string): void {
240
+ map[key] = (map[key] ?? 0) + 1;
241
+ }
242
+
243
+ /**
244
+ * A response body counts as provider-attributed when it names a provider,
245
+ * carries text, and records no error — the shape `recouncil_guard` and the
246
+ * leakage assembler both treat as real captured provider output.
247
+ */
248
+ function _isAttributed(entry: unknown): { attributed: boolean; provider: string } {
249
+ if (typeof entry !== 'object' || entry === null || Array.isArray(entry)) {
250
+ return { attributed: false, provider: '' };
251
+ }
252
+ const rec = entry as Record<string, unknown>;
253
+ const provider = typeof rec['provider'] === 'string' ? rec['provider'].trim() : '';
254
+ const text = typeof rec['text'] === 'string' ? rec['text'] : '';
255
+ const error = rec['error'];
256
+ const hasError = error !== null && error !== undefined && error !== '' && error !== false;
257
+ if (provider === '' || text.trim() === '' || hasError) {
258
+ return { attributed: false, provider };
259
+ }
260
+ return { attributed: true, provider };
261
+ }
262
+
263
+ function probeDir(root: string, label: string, cutoffMs: number, nowMs: number): DirReport {
264
+ const abs = path.join(root, 'agents', 'runtime', 'council', label);
265
+ const report: DirReport = {
266
+ dir: label,
267
+ abs_path: abs,
268
+ exists: _isDir(abs),
269
+ files: 0,
270
+ files_split: { within_ttl: 0, over_ttl: 0 },
271
+ json_with_responses: 0,
272
+ unparseable: 0,
273
+ attributed_bodies: 0,
274
+ attributed_split: { within_ttl: 0, over_ttl: 0 },
275
+ by_provider: {},
276
+ by_provider_over_ttl: {},
277
+ oldest_age_days: null,
278
+ };
279
+ if (!report.exists) {
280
+ return report;
281
+ }
282
+ const files = _walkFiles(abs);
283
+ report.files = files.length;
284
+ let oldestMs: number | null = null;
285
+ for (const f of files) {
286
+ const mtime = _mtimeMs(f);
287
+ const overTtl = mtime !== null && mtime < cutoffMs;
288
+ if (overTtl) {
289
+ report.files_split.over_ttl += 1;
290
+ } else {
291
+ report.files_split.within_ttl += 1;
292
+ }
293
+ if (mtime !== null && (oldestMs === null || mtime < oldestMs)) {
294
+ oldestMs = mtime;
295
+ }
296
+ // Only JSON-shaped artefacts can carry a `responses[]` array. A `.md`
297
+ // render is a sibling projection, counted as a file and nothing more.
298
+ if (!f.endsWith('.json')) {
299
+ continue;
300
+ }
301
+ let payload: unknown;
302
+ try {
303
+ payload = JSON.parse(fs.readFileSync(f, 'utf-8'));
304
+ } catch {
305
+ report.unparseable += 1;
306
+ continue;
307
+ }
308
+ if (typeof payload !== 'object' || payload === null || Array.isArray(payload)) {
309
+ continue;
310
+ }
311
+ const responses = (payload as Record<string, unknown>)['responses'];
312
+ if (!Array.isArray(responses)) {
313
+ continue;
314
+ }
315
+ report.json_with_responses += 1;
316
+ for (const entry of responses) {
317
+ const { attributed, provider } = _isAttributed(entry);
318
+ if (!attributed) {
319
+ continue;
320
+ }
321
+ report.attributed_bodies += 1;
322
+ _bump(report.by_provider, provider);
323
+ if (overTtl) {
324
+ report.attributed_split.over_ttl += 1;
325
+ _bump(report.by_provider_over_ttl, provider);
326
+ } else {
327
+ report.attributed_split.within_ttl += 1;
328
+ }
329
+ }
330
+ }
331
+ if (oldestMs !== null) {
332
+ report.oldest_age_days = Math.floor((nowMs - oldestMs) / MS_PER_DAY);
333
+ }
334
+ return report;
335
+ }
336
+
337
+ export function buildReport(opts: {
338
+ root: string;
339
+ root_origin: string;
340
+ retention_days: number;
341
+ now?: Date;
342
+ }): Report {
343
+ const now = opts.now ?? new Date();
344
+ const nowMs = now.getTime();
345
+ const cutoffMs = nowMs - opts.retention_days * MS_PER_DAY;
346
+ const dirs = COUNCIL_DIRS.map((label) => probeDir(opts.root, label, cutoffMs, nowMs));
347
+ const oldest = dirs
348
+ .map((d) => d.oldest_age_days)
349
+ .filter((v): v is number => v !== null)
350
+ .reduce<number | null>((acc, v) => (acc === null || v > acc ? v : acc), null);
351
+ const totals = {
352
+ files: dirs.reduce((s, d) => s + d.files, 0),
353
+ files_over_ttl: dirs.reduce((s, d) => s + d.files_split.over_ttl, 0),
354
+ attributed_bodies: dirs.reduce((s, d) => s + d.attributed_bodies, 0),
355
+ attributed_bodies_over_ttl: dirs.reduce((s, d) => s + d.attributed_split.over_ttl, 0),
356
+ unparseable: dirs.reduce((s, d) => s + d.unparseable, 0),
357
+ oldest_age_days: oldest,
358
+ };
359
+ return {
360
+ root: opts.root,
361
+ root_origin: opts.root_origin,
362
+ retention_days: opts.retention_days,
363
+ generated_at_utc: now.toISOString(),
364
+ dirs,
365
+ totals,
366
+ // A zero over-TTL count is the ONLY reading that shows the window being
367
+ // enforced; anything else means the declaration is not a mechanism.
368
+ ttl_honoured: totals.files_over_ttl === 0,
369
+ };
370
+ }
371
+
372
+ function _providerLines(map: Record<string, number>, indent: string): string {
373
+ const keys = Object.keys(map).sort((a, b) => {
374
+ const d = (map[b] as number) - (map[a] as number);
375
+ return d !== 0 ? d : a.localeCompare(b);
376
+ });
377
+ if (keys.length === 0) {
378
+ return `${indent}(none)\n`;
379
+ }
380
+ return keys.map((k) => `${indent}${k.padEnd(14)} ${String(map[k])}\n`).join('');
381
+ }
382
+
383
+ export function renderReport(r: Report): string {
384
+ const lines: string[] = [];
385
+ lines.push(`council retention probe — read-only, nothing was deleted`);
386
+ lines.push(`root ${r.root} (origin: ${r.root_origin})`);
387
+ lines.push(`retention_days ${String(r.retention_days)}`);
388
+ lines.push(`generated ${r.generated_at_utc}`);
389
+ lines.push('');
390
+ for (const d of r.dirs) {
391
+ lines.push(`── ${d.dir} ──`);
392
+ if (!d.exists) {
393
+ lines.push(` directory absent: ${d.abs_path}`);
394
+ lines.push('');
395
+ continue;
396
+ }
397
+ lines.push(` files ${String(d.files)}`);
398
+ lines.push(
399
+ ` within TTL / over TTL ${String(d.files_split.within_ttl)} / ${String(d.files_split.over_ttl)}`,
400
+ );
401
+ lines.push(` JSON records with responses[] ${String(d.json_with_responses)}`);
402
+ lines.push(` unparseable JSON ${String(d.unparseable)}`);
403
+ lines.push(` provider-attributed bodies ${String(d.attributed_bodies)}`);
404
+ lines.push(
405
+ ` within TTL / over TTL ${String(d.attributed_split.within_ttl)} / ${String(d.attributed_split.over_ttl)}`,
406
+ );
407
+ lines.push(' by provider (all):');
408
+ lines.push(_providerLines(d.by_provider, ' ').trimEnd());
409
+ lines.push(' by provider (over TTL only):');
410
+ lines.push(_providerLines(d.by_provider_over_ttl, ' ').trimEnd());
411
+ lines.push(
412
+ ` oldest file age (days) ${d.oldest_age_days === null ? 'n/a' : String(d.oldest_age_days)}`,
413
+ );
414
+ lines.push('');
415
+ }
416
+ lines.push('── totals ──');
417
+ lines.push(` files ${String(r.totals.files)}`);
418
+ lines.push(` files over TTL ${String(r.totals.files_over_ttl)}`);
419
+ lines.push(` attributed bodies ${String(r.totals.attributed_bodies)}`);
420
+ lines.push(` attributed bodies over TTL ${String(r.totals.attributed_bodies_over_ttl)}`);
421
+ lines.push(` unparseable JSON ${String(r.totals.unparseable)}`);
422
+ lines.push(
423
+ ` oldest artefact age (days) ${r.totals.oldest_age_days === null ? 'n/a' : String(r.totals.oldest_age_days)}`,
424
+ );
425
+ lines.push('');
426
+ lines.push(
427
+ r.ttl_honoured
428
+ ? `TTL HONOURED — 0 artefacts are older than ${String(r.retention_days)} days.`
429
+ : `TTL NOT HONOURED — ${String(r.totals.files_over_ttl)} of ${String(r.totals.files)} ` +
430
+ `artefacts are older than the declared ${String(r.retention_days)}-day window ` +
431
+ `(oldest ${r.totals.oldest_age_days === null ? 'n/a' : String(r.totals.oldest_age_days)} days). ` +
432
+ `Every pruner in the tree needs an explicit human command; nothing sweeps on its own.`,
433
+ );
434
+ return lines.join('\n') + '\n';
435
+ }
436
+
437
+ export function main(argv: readonly string[]): number {
438
+ let args: Args;
439
+ try {
440
+ args = _parseArgs(argv);
441
+ } catch (exc) {
442
+ if (exc instanceof _ExitSignal) {
443
+ // Arg errors already wrote usage + set exitCode; the report itself
444
+ // never fails, so the caller still sees a 0-shaped contract for the
445
+ // reporting path and a 2 only for a malformed invocation.
446
+ return exc.code;
447
+ }
448
+ throw exc;
449
+ }
450
+ if (args.help) {
451
+ process.stdout.write(_HELP);
452
+ return 0;
453
+ }
454
+ const [root, origin] = resolve_project_root(args.root);
455
+ const report = buildReport({
456
+ root,
457
+ root_origin: origin,
458
+ retention_days: args.retention_days,
459
+ });
460
+ if (args.json) {
461
+ process.stdout.write(JSON.stringify(report, null, 2) + '\n');
462
+ } else {
463
+ process.stdout.write(renderReport(report));
464
+ }
465
+ return 0;
466
+ }
467
+
468
+ function _isCliEntry(): boolean {
469
+ if (process.argv[1] === undefined) {
470
+ return false;
471
+ }
472
+ const argvUrl = pathToFileURL(path.resolve(process.argv[1])).href;
473
+ if (import.meta.url === argvUrl) {
474
+ return true;
475
+ }
476
+ // A symlinked invocation (e.g. via an installed `.augment/` projection,
477
+ // or macOS /var → /private/var temp dirs) makes the raw URLs differ:
478
+ // import.meta.url is the resolved real path while argv[1] keeps the
479
+ // symlink path. Compare realpaths so the entry guard still fires
480
+ // (without this the CLI silently no-ops when run through a symlink).
481
+ try {
482
+ const here = fs.realpathSync(fileURLToPath(import.meta.url));
483
+ const argv = fs.realpathSync(path.resolve(process.argv[1]));
484
+ return here === argv;
485
+ } catch {
486
+ return false;
487
+ }
488
+ }
489
+
490
+ if (_isCliEntry() || process.argv[1] === _HERE) {
491
+ process.exitCode = main(process.argv.slice(2));
492
+ }
493
+
494
+ export { COUNCIL_DIRS, DEFAULT_RETENTION_DAYS, PROG };
495
+ export type { Report, DirReport, Split };
@@ -126,10 +126,10 @@ import {
126
126
  } from './_lib/release_highlights.js';
127
127
  import {
128
128
  NEXT_SECTION_RE,
129
+ RELEASE_HEAD_DEFAULT,
129
130
  extract_changelog_section,
130
131
  pr_body_from_section,
131
132
  release_notes_from_section,
132
- tag_message_from_section,
133
133
  } from './_lib/release_material.js';
134
134
 
135
135
  // `__doc__.splitlines()[0]` in `_parse_args` — the argparse description. Kept
@@ -144,6 +144,8 @@ import {
144
144
  AUGMENT_MARKETPLACE_JSON,
145
145
  AUGMENT_PLUGIN_JSON,
146
146
  CHANGELOG,
147
+ _set_changelog_reader,
148
+ read_changelog_text,
147
149
  CalledProcessError,
148
150
  GH_PR_BODY_LIMIT,
149
151
  GH_RELEASE_NOTES_LIMIT,
@@ -188,6 +190,8 @@ import {
188
190
  _tag_exists_local,
189
191
  _tag_exists_remote,
190
192
  _target_from_branch,
193
+ create_and_push_annotated_tag,
194
+ guard_publication,
191
195
  die,
192
196
  gh,
193
197
  git,
@@ -269,24 +273,7 @@ const RELEASE_HEAD_SECTIONS: ReadonlyArray<string> = HEAD_LABELS;
269
273
  * (`_lib/changelog_eras.ts`, 250 lines per era), so an unbounded head would
270
274
  * quietly consume it.
271
275
  */
272
- export const RELEASE_HEAD_CAP_LINES = 10;
273
-
274
- /**
275
- * The fallback value of a head line the span does not substantiate.
276
- *
277
- * `_none_` and not a placeholder token: where nothing was derived it is the
278
- * **true** answer, and a release that genuinely changed no defaults should say
279
- * so rather than carry an unfilled marker.
280
- *
281
- * It is a fallback and no longer a blanket default. Writing `_none_` into
282
- * every field made the generator assert five things it had not checked, and
283
- * `check_release_highlights` rejected exactly that assertion the moment the
284
- * span contradicted it — which for this package is every release, because every
285
- * release touches `src/rules/` or `src/scripts/schemas/`. Substantiated labels
286
- * are now pre-filled from the span (`_derive_head_prefill`), so the tool states
287
- * only what it can support and `_none_` means what it says.
288
- */
289
- const HEAD_DEFAULT = '_none_';
276
+ export { RELEASE_HEAD_CAP_LINES } from './_lib/release_material.js';
290
277
 
291
278
  /**
292
279
  * Pre-fill values for the curated head from the release span.
@@ -313,20 +300,21 @@ function _derive_head_prefill(prev: string | null): Record<string, string> {
313
300
  /**
314
301
  * Render the curated head. Emitted by the generator on every release so it
315
302
  * cannot be forgotten; edited by the maintainer before merge.
303
+ *
304
+ * **The authoring instruction is NOT emitted here** (2026-09-01, roadmap
305
+ * `road-to-publication-integrity-hard-fail` § Phase 2, Option A). This writer
306
+ * used to append `CURATED_HEAD_INSTRUCTION`, nothing removed it at release
307
+ * time, and it published twice in `package/CHANGELOG.md`. The reminder now
308
+ * rides in the release-PR body's PR-only region — never published — see
309
+ * `pr_body_from_section` in `_lib/release_material.ts`.
316
310
  */
317
311
  export function render_release_head(
318
312
  filled: Readonly<Record<string, string>> = {},
319
313
  ): string[] {
320
- const lines: string[] = [
321
- '### Release highlights',
322
- '',
323
- '<!-- Curated head: fill before merge, keep it under ' +
324
- `${RELEASE_HEAD_CAP_LINES} lines, and leave \`${HEAD_DEFAULT}\` where it is ` +
325
- 'genuinely the answer. The generated log below is unchanged. -->',
326
- ];
314
+ const lines: string[] = ['### Release highlights', ''];
327
315
  for (const label of RELEASE_HEAD_SECTIONS) {
328
316
  const value = (filled[label] ?? '').trim();
329
- lines.push(`- **${label}:** ${value === '' ? HEAD_DEFAULT : value}`);
317
+ lines.push(`- **${label}:** ${value === '' ? RELEASE_HEAD_DEFAULT : value}`);
330
318
  }
331
319
  return lines;
332
320
  }
@@ -1179,6 +1167,7 @@ function _step(n: number, total: number, msg: string): void {
1179
1167
  process.stdout.write(`[${n}/${total}] ${msg}\n`);
1180
1168
  }
1181
1169
 
1170
+
1182
1171
  function execute(
1183
1172
  plan: Plan,
1184
1173
  opts: { wait_for_checks: boolean; dry_run: boolean; resume?: boolean; ci?: boolean },
@@ -1342,7 +1331,7 @@ function execute(
1342
1331
  // Derive the PR body from the CHANGELOG section step 2 just wrote —
1343
1332
  // the file is the single source; the in-memory plan is only its
1344
1333
  // author (release-truth Phase 1).
1345
- const section = extract_changelog_section(fs.readFileSync(CHANGELOG, 'utf-8'), plan.target);
1334
+ const section = extract_changelog_section(read_changelog_text(), plan.target);
1346
1335
  if (!section) {
1347
1336
  die(`CHANGELOG.md carries no section for ${plan.target} — cannot derive PR body`);
1348
1337
  }
@@ -1388,6 +1377,7 @@ function execute(
1388
1377
  _step(8, total, `Tag ${plan.target} already on ${REMOTE} — skip`);
1389
1378
  } else {
1390
1379
  _step(8, total, `Tag ${plan.target} exists locally — push only`);
1380
+ guard_publication(plan.target, 'tag push (resumed)');
1391
1381
  _push_tag(plan.target);
1392
1382
  }
1393
1383
  } else {
@@ -1398,15 +1388,7 @@ function execute(
1398
1388
  // tag replaces the previous lightweight one so tag metadata is a
1399
1389
  // fourth surface carrying the same single-source content.
1400
1390
  _step(8, total, `Tag merge commit (annotated, from merged CHANGELOG) and push ${plan.target}`);
1401
- const merged = extract_changelog_section(fs.readFileSync(CHANGELOG, 'utf-8'), plan.target);
1402
- if (!merged) {
1403
- die(
1404
- `CHANGELOG.md on ${MAIN_BRANCH} carries no section for ${plan.target} — ` +
1405
- 'refusing to tag a release whose changelog entry is missing',
1406
- );
1407
- }
1408
- run(['git', 'tag', '-a', plan.target, '-m', tag_message_from_section(merged!.body, plan.target)]);
1409
- _push_tag(plan.target);
1391
+ create_and_push_annotated_tag(plan.target);
1410
1392
  }
1411
1393
 
1412
1394
  // ─── 9. GitHub Release ──────────────────────────────────────────────────
@@ -1436,6 +1418,7 @@ function execute(
1436
1418
  GH_RELEASE_NOTES_LIMIT,
1437
1419
  '`CHANGELOG.md`',
1438
1420
  );
1421
+ guard_publication(plan.target, 'GitHub Release notes');
1439
1422
  gh(['release', 'create', plan.target, '--title', plan.target, '--notes', notes]);
1440
1423
 
1441
1424
  // ─── 9b. dispatch-chain the tag-triggered workflows (--ci only) ──────
@@ -2014,6 +1997,7 @@ export {
2014
1997
  // Drill seam (release_drill.ts) — the step machinery under a simulated
2015
1998
  // git/gh world, so orchestration bugs surface in vitest, not mid-release.
2016
1999
  execute,
2000
+ _set_changelog_reader,
2017
2001
  _set_exec_override,
2018
2002
  push_release_branch,
2019
2003
  merge_release_pr,