@event4u/agent-config 14.2.0 → 14.5.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 (117) hide show
  1. package/.claude-plugin/marketplace.json +1 -1
  2. package/CHANGELOG.md +33 -252
  3. package/agents/templates/.ai-council.yml.example +35 -0
  4. package/dist/agent-src/contexts/execution/roadmap-process-loop.md +83 -12
  5. package/dist/agent-src/rules/roadmap-progress-sync.md +48 -4
  6. package/dist/agent-src/scripts/resume_probe.ts +154 -9
  7. package/dist/agent-src/scripts/roadmap_gates.ts +210 -6
  8. package/dist/agent-src/skills/roadmap-management/SKILL.md +9 -4
  9. package/dist/agent-src/templates/agent-settings.md +70 -0
  10. package/dist/agent-src/templates/agents/agent-project-settings.example.yml +1 -1
  11. package/dist/agent-src/templates/scripts/telemetry/remote.ts +571 -0
  12. package/dist/agent-src/templates/scripts/telemetry/settings.ts +222 -44
  13. package/dist/cli/python/workspace_drive.js +8 -3
  14. package/dist/cli/python/workspace_drive.js.map +1 -1
  15. package/dist/cli/python/workspace_hosts.js +16 -12
  16. package/dist/cli/python/workspace_hosts.js.map +1 -1
  17. package/dist/cli/registry.js +2 -0
  18. package/dist/cli/registry.js.map +1 -1
  19. package/dist/cli-delegate/{chunk-F3YUF7SA.js → chunk-3UCJWMMP.js} +1 -1
  20. package/dist/cli-delegate/chunk-F7DQWYR5.js +69 -0
  21. package/dist/cli-delegate/{chunk-7PZ4FSZB.js → chunk-FBKL6JWT.js} +61 -2
  22. package/dist/cli-delegate/{chunk-MNWRDMR5.js → chunk-PKOBQADP.js} +1 -1
  23. package/dist/cli-delegate/cmd_conformance.js +26 -11
  24. package/dist/cli-delegate/cmd_doctor.js +2 -2
  25. package/dist/cli-delegate/cmd_handoff.js +3 -3
  26. package/dist/cli-delegate/cmd_route_audit.js +2 -2
  27. package/dist/cli-delegate/cmd_settings_set.js +17 -3
  28. package/dist/discovery/deprecation-report.md +1 -1
  29. package/dist/discovery/discovery-manifest.json +4 -4
  30. package/dist/discovery/discovery-manifest.json.sha256 +1 -1
  31. package/dist/discovery/discovery-manifest.summary.md +1 -1
  32. package/dist/discovery/dormancy-report.md +1 -1
  33. package/dist/discovery/orphan-report.md +1 -1
  34. package/dist/discovery/packs.json +2 -2
  35. package/dist/discovery/trust-report.md +1 -1
  36. package/dist/discovery/workspaces.json +2 -2
  37. package/dist/hooks/dispatch.js +3754 -1721
  38. package/dist/mcp/registry-manifest.json +2 -2
  39. package/dist/mcp/server.json +2 -2
  40. package/dist/mcp/server.mjs +1070 -408
  41. package/dist/scripts/_lib/host_launch.js +66 -0
  42. package/dist/scripts/_lib/host_launch.js.map +1 -0
  43. package/dist/server/io/yamlIO.js +175 -3
  44. package/dist/server/io/yamlIO.js.map +1 -1
  45. package/dist/server/routes/wizard.js +20 -1
  46. package/dist/server/routes/wizard.js.map +1 -1
  47. package/dist/shared/settingsConsent.js +25 -0
  48. package/dist/shared/settingsConsent.js.map +1 -1
  49. package/docs/contracts/settings-classes.md +11 -0
  50. package/docs/guidelines/agent-infra/roadmap-progress-mechanics.md +2 -1
  51. package/package.json +1 -1
  52. package/src/agent-src/scripts/resume_probe.ts +154 -9
  53. package/src/agent-src/scripts/roadmap_gates.ts +210 -6
  54. package/src/agent-src/templates/scripts/telemetry/remote.ts +571 -0
  55. package/src/agent-src/templates/scripts/telemetry/settings.ts +222 -44
  56. package/src/config/estate-count-budget.json +127 -0
  57. package/src/config/evaluator-budgets.json +4 -4
  58. package/src/config/gate-coverage.yml +32 -4
  59. package/src/config/gate-violation-baselines.json +63 -63
  60. package/src/config/gitignore-block.txt +5 -0
  61. package/src/config/hook-latency-budget.json +40 -1
  62. package/src/config/hook-token-budget.json +16 -0
  63. package/src/config/rule-stub-ceilings.json +11 -3
  64. package/src/scripts/_dispatch.bash +42 -1
  65. package/src/scripts/_lib/council_fallback_posture.ts +81 -0
  66. package/src/scripts/_lib/council_fallback_wiring.ts +126 -0
  67. package/src/scripts/_lib/council_settings_block.ts +152 -0
  68. package/src/scripts/_lib/headless_invocation.ts +285 -0
  69. package/src/scripts/_lib/host_launch.ts +72 -0
  70. package/src/scripts/_lib/map_to_object.ts +16 -0
  71. package/src/scripts/_lib/roadmap_checkboxes.ts +88 -0
  72. package/src/scripts/_lib/run_checkpoint.ts +409 -0
  73. package/src/scripts/_lib/session_register.ts +32 -1
  74. package/src/scripts/_lib/stdin.ts +35 -1
  75. package/src/scripts/_lib/turn_end_refusals.ts +12 -4
  76. package/src/scripts/_lib/unattended_guard.ts +371 -0
  77. package/src/scripts/ai_council/config.ts +27 -1
  78. package/src/scripts/ai_council/events_log.ts +12 -1
  79. package/src/scripts/ai_council/fallback_config.ts +114 -0
  80. package/src/scripts/ai_council/mid_flight_fallback.ts +295 -0
  81. package/src/scripts/ai_council/orchestrator.ts +251 -292
  82. package/src/scripts/ai_council/py_parity.ts +139 -0
  83. package/src/scripts/ai_council/response_render.ts +99 -0
  84. package/src/scripts/ai_council/spend_gate.ts +123 -0
  85. package/src/scripts/ai_council/transport_resolver.ts +81 -13
  86. package/src/scripts/bench_hook_injection.ts +118 -7
  87. package/src/scripts/bench_hook_latency.ts +442 -16
  88. package/src/scripts/check_estate_count.ts +924 -0
  89. package/src/scripts/compile_hook_manifest.ts +67 -0
  90. package/src/scripts/council_cli.ts +100 -125
  91. package/src/scripts/decision_memo.ts +325 -0
  92. package/src/scripts/hook_manifest.json +1 -0
  93. package/src/scripts/hook_manifest.yaml +172 -25
  94. package/src/scripts/hooks/concern_registry.ts +6 -0
  95. package/src/scripts/hooks/dispatch_hook.ts +183 -10
  96. package/src/scripts/hooks/dispatch_issues.ts +26 -0
  97. package/src/scripts/hooks/end_review_nudge_hook.ts +97 -24
  98. package/src/scripts/hooks/hook_stdin.ts +111 -5
  99. package/src/scripts/hooks/host_semantics.ts +28 -0
  100. package/src/scripts/hooks/injection_budget.ts +731 -0
  101. package/src/scripts/hooks/payload_stub.ts +446 -0
  102. package/src/scripts/hooks/run_continuation_hook.ts +683 -0
  103. package/src/scripts/hooks/session_eol_hook.ts +52 -0
  104. package/src/scripts/hooks/telemetry_usage_hook.ts +339 -0
  105. package/src/scripts/hooks/tool_result_bytes_hook.ts +27 -0
  106. package/src/scripts/hooks/turn_end_gate_hook.ts +45 -7
  107. package/src/scripts/hooks_doctor.ts +16 -2
  108. package/src/scripts/interruption_report.ts +330 -13
  109. package/src/scripts/lint_hook_manifest.ts +167 -0
  110. package/src/scripts/roadmap_progress_hook.ts +254 -46
  111. package/src/scripts/run_supervise.ts +623 -0
  112. package/src/scripts/session_register_hook.ts +146 -12
  113. package/src/scripts/sessions_cli.ts +18 -6
  114. package/src/scripts/telemetry_disclosure_hook.ts +244 -0
  115. package/src/server/io/yamlIO.ts +167 -3
  116. package/src/shared/settingsConsent.ts +49 -2
  117. package/dist/cli-delegate/chunk-MMJL4TIX.js +0 -18
@@ -0,0 +1,571 @@
1
+ /**
2
+ * Class-A usage record — schema, skill-name normalisation, salted hashes,
3
+ * and the JSONL appender (road-to-org-telemetry Phase 1).
4
+ *
5
+ * Class A is the package describing itself: a usage class, the skill that
6
+ * was invoked, the host, the package version, the active rule-loading tier,
7
+ * a pseudonymous user hash, a session hash, and an hour-bucketed timestamp.
8
+ * It carries zero bytes of project content.
9
+ *
10
+ * PII-EXCLUSION-BY-CONSTRUCTION — the point of this file, not a caution.
11
+ * `ClassARecord` has no field capable of holding free-form content: every
12
+ * member is a string drawn from a closed vocabulary, an id validated by the
13
+ * engagement redaction floor, or a hex digest. There is no `payload`, no
14
+ * `notes`, no `extra: unknown`. A type that cannot carry a prompt has no
15
+ * scrubber that can fail — the same principle `domain-safety-pii` § Surface 2
16
+ * applies to log streams, and the same one the artefact-engagement event
17
+ * already follows. Never widen this interface with a free-text member.
18
+ *
19
+ * Two host-supplied opaque tokens are deliberately hashed rather than
20
+ * recorded: the session id and the login name. The session id in particular
21
+ * is high-entropy and `check_secret_leak` correctly reads a raw one as a
22
+ * candidate credential (a trap `orchestration_record_hook.ts` recorded when
23
+ * it chose to generate its own id instead).
24
+ *
25
+ * PHASE BOUNDARY. This module writes to a local file and nothing else. The
26
+ * outbound flush is Phase 2 and is blocked on the `sink-choice` blocker;
27
+ * org-wide enablement is Phase 3 and is blocked on `dpo-signoff`. Nothing
28
+ * here opens a socket, and no caller in this phase may make it do so.
29
+ */
30
+ import * as crypto from 'node:crypto';
31
+ import * as fs from 'node:fs';
32
+ import * as path from 'node:path';
33
+
34
+ import { check_id_redaction, py_json_dumps_compact_sorted } from './engagement.js';
35
+
36
+ export const CLASS_A_SCHEMA_VERSION = 1;
37
+
38
+ /** Usage classes. Phase 5 adds the self-repair class; Phase 1 emits one. */
39
+ export const ALLOWED_RECORD_CLASSES = ['usage'] as const;
40
+
41
+ /**
42
+ * Discipline-profile values, the knob that actually decides which rule
43
+ * surfaces load.
44
+ *
45
+ * NOT `rule_loading_tier`. The settings template calls that one a "legacy
46
+ * knob — superseded by discipline_profile above", and
47
+ * `resolve_discipline_profile` confirms the precedence: an explicit
48
+ * `discipline_profile` wins outright, and the legacy key is consulted only
49
+ * when it is absent. Recording the legacy value would have described a knob
50
+ * that does not decide the thing the record implies it decides.
51
+ *
52
+ * `auto` is in the vocabulary because it is a value an install genuinely
53
+ * declares. Resolving it needs the session model id and the host capability
54
+ * table, neither of which a post-tool hook has, so it is recorded verbatim
55
+ * rather than guessed at.
56
+ */
57
+ export const ALLOWED_DISCIPLINE_PROFILES = ['off', 'essential', 'full', 'custom', 'auto'] as const;
58
+
59
+ /**
60
+ * Map a legacy `rule_loading_tier` onto a profile, exactly as
61
+ * `resolve_discipline_profile` does for the explicit-absent case. Unknown
62
+ * or absent → `null`, never the resolver's both-absent default: a record
63
+ * must not claim a declaration the install never made.
64
+ */
65
+ export function profile_from_legacy_tier(tier: string | null): string | null {
66
+ switch (tier) {
67
+ case 'minimal': return 'off';
68
+ case 'balanced': return 'essential';
69
+ case 'full': return 'full';
70
+ case 'custom': return 'custom';
71
+ default: return null;
72
+ }
73
+ }
74
+
75
+ /** Digest length, in hex chars — matches the tier-usage signal contract. */
76
+ export const HASH_HEX_LEN = 16;
77
+
78
+ export class ClassARecordError extends Error {
79
+ constructor(message: string) {
80
+ super(message);
81
+ this.name = 'ClassARecordError';
82
+ }
83
+ }
84
+
85
+ /**
86
+ * One Class-A usage record. Every field is structural; see the file header
87
+ * for why there is no free-form member and why that must stay true.
88
+ */
89
+ export interface ClassARecord {
90
+ schema_version: number;
91
+ /** Closed vocabulary — see `ALLOWED_RECORD_CLASSES`. */
92
+ record_class: string;
93
+ /** ISO-8601 UTC, HOUR resolution. Never an exact timestamp. */
94
+ ts_bucket: string;
95
+ /** Normalised skill id — see `normalise_skill_name`. */
96
+ skill: string;
97
+ /**
98
+ * Host id (`claude`, `augment`, …) — an id-shaped enum, never a path.
99
+ * `null` when the envelope carried none; the siblings in this tree
100
+ * record an absent host as absent rather than as a sentinel string,
101
+ * and `"unknown"` would be indistinguishable from a host so named.
102
+ */
103
+ host: string | null;
104
+ /** Installed package version, or `null` when the install did not report one. */
105
+ package_version: string | null;
106
+ /** Declared discipline profile, or `null` when the install declared none. */
107
+ discipline_profile: string | null;
108
+ /** The org whose pack enabled this install. */
109
+ org_id: string;
110
+ /** Salted digest of host + login. Never the login itself. */
111
+ user_hash: string;
112
+ /**
113
+ * Salted digest of the host session id, or `null` when the envelope
114
+ * carried none. Never the raw id, and never a digest of the empty
115
+ * string: that would be a valid, stable value, so every session-less
116
+ * record in an org would collapse into one apparent session — a
117
+ * fabricated fact wearing a hash's clothes.
118
+ */
119
+ session_hash: string | null;
120
+ }
121
+
122
+ /**
123
+ * Canonical skill id.
124
+ *
125
+ * Phase 0's third spike measured the host sending ONE skill under two
126
+ * spellings — `roadmap:process-full` (64 observations) and
127
+ * `roadmap-process-full` (22), likewise `roadmap:ai-council`. Left alone,
128
+ * per-skill counts split and the busiest skills undercount by roughly a
129
+ * quarter.
130
+ *
131
+ * The collapse direction is `:` → `-`, and it is chosen because it is
132
+ * TOTAL: every colon form has exactly one dash form, so the mapping is a
133
+ * function. The other direction is not — `brand-asset-generation` has no
134
+ * cluster and nothing in a hook can decide which dash of
135
+ * `roadmap-process-full` would have been a colon without carrying the whole
136
+ * command catalogue into the hot path.
137
+ *
138
+ * The two names that collide under this rule are the same skill, which is
139
+ * the entire reason the rule exists. Should a genuinely distinct skill ever
140
+ * be named exactly like another cluster's flattened form, the collision is
141
+ * a naming defect upstream of this function, not a defect here — and the
142
+ * duplicate name would already be ambiguous to a human reading the report.
143
+ *
144
+ * Throws on anything the engagement redaction floor rejects: a path, a file
145
+ * extension, embedded whitespace, or an over-long value. The caller drops
146
+ * the record rather than recording a name it could not validate.
147
+ */
148
+ export function normalise_skill_name(raw: unknown): string {
149
+ if (typeof raw !== 'string') {
150
+ throw new ClassARecordError('skill must be a string');
151
+ }
152
+ const collapsed = raw.trim().replace(/:/gu, '-');
153
+ check_id_redaction('skill', collapsed);
154
+ return collapsed;
155
+ }
156
+
157
+ /**
158
+ * ISO-8601 UTC truncated to the hour — `2026-08-18T21:00Z`.
159
+ *
160
+ * Hour buckets, never exact timestamps: the tier-usage signal contract
161
+ * names exact timestamps in its forbidden list precisely because they
162
+ * re-identify a single user's working pattern from an otherwise
163
+ * pseudonymous record set.
164
+ */
165
+ export function hour_bucket(d: Date): string {
166
+ const p = (n: number): string => String(n).padStart(2, '0');
167
+ return `${d.getUTCFullYear()}-${p(d.getUTCMonth() + 1)}-${p(d.getUTCDate())}`
168
+ + `T${p(d.getUTCHours())}:00Z`;
169
+ }
170
+
171
+ /**
172
+ * Salted digest, first `HASH_HEX_LEN` hex chars of a SHA-256.
173
+ *
174
+ * The salt is the org pack's, never this repository's — an unsalted hash of
175
+ * a login name is reversible by dictionary in seconds, so a salt-less digest
176
+ * would be an identifier wearing a hash's clothes. `read_remote_settings`
177
+ * refuses to activate without one, which is what makes that guarantee
178
+ * structural rather than a convention.
179
+ *
180
+ * Parts are joined on NUL, which cannot occur in a hostname, a login, or a
181
+ * session id — so no pair of distinct inputs can produce one joined string.
182
+ */
183
+ function _salted_digest(salt: string, parts: readonly string[]): string {
184
+ if (!salt) {
185
+ throw new ClassARecordError('salt must be non-empty — refusing to emit an unsalted digest');
186
+ }
187
+ const material = [salt, ...parts].join('\u0000');
188
+ return crypto.createHash('sha256').update(material, 'utf-8').digest('hex').slice(0, HASH_HEX_LEN);
189
+ }
190
+
191
+ /** Pseudonymous per-user id. Stable per (salt, machine, login). */
192
+ export function derive_user_hash(salt: string, hostname: string, username: string): string {
193
+ return _salted_digest(salt, ['user', hostname, username]);
194
+ }
195
+
196
+ /**
197
+ * Pseudonymous per-session id, or `null` when the host supplied none.
198
+ *
199
+ * Domain-separated from the user hash by the literal prefix, so the same
200
+ * salt cannot make the two digests collide or let one be derived from the
201
+ * other.
202
+ *
203
+ * An absent session id returns `null` rather than hashing the empty string.
204
+ * The empty-string digest is perfectly valid and perfectly stable, which is
205
+ * exactly the problem: every session-less record in one org would share it
206
+ * and read as a single busy session.
207
+ */
208
+ export function derive_session_hash(salt: string, session_id: string): string | null {
209
+ if (!session_id) {
210
+ return null;
211
+ }
212
+ return _salted_digest(salt, ['session', session_id]);
213
+ }
214
+
215
+ export interface BuildClassAInput {
216
+ skill: unknown;
217
+ host: string | null;
218
+ org_id: string;
219
+ salt: string;
220
+ hostname: string;
221
+ username: string;
222
+ session_id: string;
223
+ package_version?: string | null;
224
+ discipline_profile?: string | null;
225
+ record_class?: string;
226
+ now?: Date;
227
+ }
228
+
229
+ /**
230
+ * Build one validated Class-A usage record. Pure — no I/O and no ambient
231
+ * clock unless the caller omits `now`, so the mapping stays independently
232
+ * testable.
233
+ *
234
+ * Throws rather than returning a partial record: a record that failed
235
+ * validation is dropped by the caller, never written in a degraded shape.
236
+ */
237
+ export function build_class_a_record(input: BuildClassAInput): ClassARecord {
238
+ const skill = normalise_skill_name(input.skill);
239
+
240
+ const host = input.host ?? null;
241
+ if (host !== null) {
242
+ check_id_redaction('host', host);
243
+ }
244
+ check_id_redaction('org_id', input.org_id);
245
+
246
+ const record_class = input.record_class ?? 'usage';
247
+ if (!(ALLOWED_RECORD_CLASSES as readonly string[]).includes(record_class)) {
248
+ throw new ClassARecordError(
249
+ `record_class must be one of ${ALLOWED_RECORD_CLASSES.join(', ')}`,
250
+ );
251
+ }
252
+
253
+ const discipline_profile = input.discipline_profile ?? null;
254
+ if (
255
+ discipline_profile !== null
256
+ && !(ALLOWED_DISCIPLINE_PROFILES as readonly string[]).includes(discipline_profile)
257
+ ) {
258
+ throw new ClassARecordError(
259
+ `discipline_profile must be one of ${ALLOWED_DISCIPLINE_PROFILES.join(', ')} or null`,
260
+ );
261
+ }
262
+
263
+ const package_version = input.package_version ?? null;
264
+ if (package_version !== null) {
265
+ check_id_redaction('package_version', package_version);
266
+ }
267
+
268
+ return {
269
+ schema_version: CLASS_A_SCHEMA_VERSION,
270
+ record_class,
271
+ ts_bucket: hour_bucket(input.now ?? new Date()),
272
+ skill,
273
+ host,
274
+ package_version,
275
+ discipline_profile,
276
+ org_id: input.org_id,
277
+ user_hash: derive_user_hash(input.salt, input.hostname, input.username),
278
+ session_hash: derive_session_hash(input.salt, input.session_id),
279
+ };
280
+ }
281
+
282
+ // ── Retention — the growth budget for the local record log ──────────────
283
+ //
284
+ // Phase 1 shipped this file as append-only with no cap and no pruning, and
285
+ // `flush: never` endorses that as an indefinite steady state: one line per
286
+ // skill invocation, forever. Its own completion review raised it, and
287
+ // `scale-discipline` R-A7 makes it an obligation rather than a nicety —
288
+ // an append-only store declares a retention policy (TTL, pruning job,
289
+ // partition rotation, or archive path) or it is not finished.
290
+ //
291
+ // This is the TTL-plus-pruning shape, enforced by the only writer, so it
292
+ // cannot be bypassed by a caller that forgets to run a sweep.
293
+ //
294
+ // THE NUMBERS ARE MEASURED, NOT GUESSED. On the busiest machine this tree
295
+ // has observed, `agents/runtime/state/tool-result-census.jsonl` carries 24
296
+ // `Skill` tool events across 3.62 days — 6.6 per day — and a Class-A line
297
+ // serialises to 248–286 bytes (measured over four skill-name lengths), so
298
+ // call it 270. That machine therefore writes ≈ 637 KiB per year.
299
+ //
300
+ // The AGE cap is the policy that actually binds: at the observed rate 90
301
+ // days is ≈ 600 records ≈ 160 KiB, so it fires regularly and keeps the file
302
+ // small. The BYTE cap is a backstop for a rate this tree has not observed —
303
+ // a shared machine, a much heavier user — where it bounds the file at 2 MiB
304
+ // (≈ 7,700 records) regardless of dates. A cap chosen so generously that it
305
+ // can never fire is a policy on paper only, which is the failure this repo
306
+ // keeps finding in its own gates; 2 MiB is reached in about twelve days at
307
+ // 100× the observed rate and in about 3.2 years at 1×, and both of those are
308
+ // the point. (The 1× figure read "eight years" until the completion review
309
+ // did the division: 2,097,152 B ÷ (6.6/day × 270 B) ≈ 1,177 days. A number
310
+ // nobody checks is how a measured default quietly becomes a guessed one.)
311
+ //
312
+ // WHAT RETENTION COSTS UNDER `flush: never`. With no transport configured
313
+ // the local file is the only store, so a record evicted here is a record
314
+ // that is never sent anywhere. That is the intended trade — a growth budget
315
+ // is a decision to lose the oldest data rather than to keep all of it — but
316
+ // it is stated here rather than left for an operator to discover from a
317
+ // short file. An org that needs the full history configures a flush.
318
+
319
+ /** Days a record is kept before the age pass drops it. */
320
+ export const DEFAULT_RETENTION_MAX_AGE_DAYS = 90;
321
+
322
+ /** Hard ceiling on the log, in bytes, regardless of record age. */
323
+ export const DEFAULT_RETENTION_MAX_BYTES = 2 * 1024 * 1024;
324
+
325
+ /**
326
+ * How much of the byte cap a byte-triggered prune leaves behind.
327
+ *
328
+ * Hysteresis, not tidiness: pruning back to exactly the cap would leave the
329
+ * file at the trigger point, so the next append would prune again and every
330
+ * append after it would pay a full rewrite. At 0.75 a quarter of the cap
331
+ * must re-accumulate first.
332
+ */
333
+ export const RETENTION_KEEP_FRACTION = 0.75;
334
+
335
+ export interface RetentionPolicy {
336
+ readonly max_age_days: number;
337
+ readonly max_bytes: number;
338
+ }
339
+
340
+ export const DEFAULT_RETENTION_POLICY: RetentionPolicy = {
341
+ max_age_days: DEFAULT_RETENTION_MAX_AGE_DAYS,
342
+ max_bytes: DEFAULT_RETENTION_MAX_BYTES,
343
+ };
344
+
345
+ /**
346
+ * Epoch ms for a record line, or `null` when the line carries no readable
347
+ * `ts_bucket`.
348
+ *
349
+ * `null` is not an error path — see `enforce_retention` for why an undatable
350
+ * line is retained rather than dropped.
351
+ */
352
+ export function record_line_ms(line: string): number | null {
353
+ if (!line.trim()) {
354
+ return null;
355
+ }
356
+ let parsed: unknown;
357
+ try {
358
+ parsed = JSON.parse(line);
359
+ } catch {
360
+ return null;
361
+ }
362
+ if (typeof parsed !== 'object' || parsed === null) {
363
+ return null;
364
+ }
365
+ const bucket = (parsed as Record<string, unknown>)['ts_bucket'];
366
+ if (typeof bucket !== 'string') {
367
+ return null;
368
+ }
369
+ const ms = Date.parse(bucket);
370
+ return Number.isNaN(ms) ? null : ms;
371
+ }
372
+
373
+ /** Bytes of the log head read to find the oldest datable record. */
374
+ export const RETENTION_HEAD_WINDOW_BYTES = 8192;
375
+
376
+ /**
377
+ * Timestamp of the OLDEST DATABLE record, read from the head of the file.
378
+ *
379
+ * Not "the first line": a single undatable line at the head would otherwise
380
+ * answer the whole age question with `null` and disable the age policy for as
381
+ * long as that line survives — which, since `enforce_retention` deliberately
382
+ * keeps undatable lines, is until the byte cap evicts it. On a low-volume log
383
+ * that is years. Scanning the window instead means one corrupt head line
384
+ * costs nothing.
385
+ *
386
+ * `null` when the window holds no datable line at all. That case is bounded
387
+ * by the byte cap rather than by age, and it is a genuinely corrupt file
388
+ * rather than an ordinary one — the alternative, treating it as due, would
389
+ * read the whole file on every append forever.
390
+ *
391
+ * Only complete lines are considered: a final fragment with no newline may be
392
+ * a torn write, and half a record parses as nothing useful.
393
+ */
394
+ export function _oldest_datable_ms(log_path: string): number | null {
395
+ let fd: number;
396
+ try {
397
+ fd = fs.openSync(log_path, 'r');
398
+ } catch {
399
+ return null;
400
+ }
401
+ try {
402
+ const buf = Buffer.alloc(RETENTION_HEAD_WINDOW_BYTES);
403
+ const read = fs.readSync(fd, buf, 0, buf.length, 0);
404
+ if (read <= 0) {
405
+ return null;
406
+ }
407
+ const chunk = buf.subarray(0, read).toString('utf-8');
408
+ const lines = chunk.split('\n');
409
+ // Drop the trailing element: it is either empty (the window ended on a
410
+ // newline) or an incomplete line (the window cut mid-record).
411
+ lines.pop();
412
+ for (const line of lines) {
413
+ const ms = record_line_ms(line);
414
+ if (ms !== null) {
415
+ return ms;
416
+ }
417
+ }
418
+ return null;
419
+ } catch {
420
+ return null;
421
+ } finally {
422
+ fs.closeSync(fd);
423
+ }
424
+ }
425
+
426
+ /**
427
+ * Is a prune owed right now?
428
+ *
429
+ * Deliberately cheap, because it runs on every append: one `stat`, and — only
430
+ * when the byte cap has not already decided it — one bounded read of the
431
+ * first line. The file is append-only and written in chronological order, so
432
+ * the first line is the oldest record: if IT is inside the window, nothing in
433
+ * the file is outside it, and the whole age pass is answered without reading
434
+ * the rest.
435
+ */
436
+ export function retention_due(
437
+ log_path: string,
438
+ policy: RetentionPolicy = DEFAULT_RETENTION_POLICY,
439
+ now: Date = new Date(),
440
+ ): boolean {
441
+ let size: number;
442
+ try {
443
+ size = fs.statSync(log_path).size;
444
+ } catch {
445
+ return false;
446
+ }
447
+ if (size === 0) {
448
+ return false;
449
+ }
450
+ if (size > policy.max_bytes) {
451
+ return true;
452
+ }
453
+ const oldest = _oldest_datable_ms(log_path);
454
+ if (oldest === null) {
455
+ return false;
456
+ }
457
+ return oldest < now.getTime() - policy.max_age_days * 86_400_000;
458
+ }
459
+
460
+ export interface RetentionResult {
461
+ readonly kept: number;
462
+ readonly dropped_by_age: number;
463
+ readonly dropped_by_size: number;
464
+ }
465
+
466
+ /**
467
+ * Apply the retention policy to the log, in place.
468
+ *
469
+ * Age first, then bytes, because the age pass is the policy and the byte cap
470
+ * is the backstop: running bytes first could evict a record the age window
471
+ * still covers while leaving nothing for the age pass to do.
472
+ *
473
+ * AN UNDATABLE LINE IS KEPT BY THE AGE PASS. This file has exactly one
474
+ * writer, so a line without a readable `ts_bucket` is corruption rather than
475
+ * an old record, and deleting data on the strength of not being able to read
476
+ * it is the wrong default. It still counts toward the byte cap, so it is
477
+ * evicted from the oldest end eventually — bounded, but never silently
478
+ * discarded for being unparseable.
479
+ *
480
+ * The rewrite goes through a sibling temp file and a rename, so a crash
481
+ * mid-prune leaves the original log intact rather than a truncated one.
482
+ */
483
+ export function enforce_retention(
484
+ log_path: string,
485
+ policy: RetentionPolicy = DEFAULT_RETENTION_POLICY,
486
+ now: Date = new Date(),
487
+ ): RetentionResult {
488
+ let raw: string;
489
+ try {
490
+ raw = fs.readFileSync(log_path, 'utf-8');
491
+ } catch {
492
+ return { kept: 0, dropped_by_age: 0, dropped_by_size: 0 };
493
+ }
494
+ const lines = raw.split('\n').filter((l) => l.length > 0);
495
+ const total = lines.length;
496
+
497
+ const cutoff = now.getTime() - policy.max_age_days * 86_400_000;
498
+ const survived_age = lines.filter((l) => {
499
+ const ms = record_line_ms(l);
500
+ return ms === null || ms >= cutoff;
501
+ });
502
+ const dropped_by_age = total - survived_age.length;
503
+
504
+ // Byte cap: keep the NEWEST suffix that fits under the kept-fraction of
505
+ // the cap. Walking from the end is what makes "drop the oldest" true.
506
+ const budget = Math.floor(policy.max_bytes * RETENTION_KEEP_FRACTION);
507
+ let used = 0;
508
+ let start = survived_age.length;
509
+ for (let i = survived_age.length - 1; i >= 0; i -= 1) {
510
+ const cost = Buffer.byteLength(`${survived_age[i] as string}\n`);
511
+ if (used + cost > budget) {
512
+ break;
513
+ }
514
+ used += cost;
515
+ start = i;
516
+ }
517
+ const kept_lines = survived_age.slice(start);
518
+ const dropped_by_size = survived_age.length - kept_lines.length;
519
+
520
+ if (dropped_by_age === 0 && dropped_by_size === 0) {
521
+ return { kept: total, dropped_by_age: 0, dropped_by_size: 0 };
522
+ }
523
+
524
+ const payload = kept_lines.length === 0 ? '' : `${kept_lines.join('\n')}\n`;
525
+
526
+ // A unique temp name per prune, then one rename.
527
+ //
528
+ // The rename is atomic, so no reader ever sees a half-written log and a
529
+ // crash mid-prune leaves the original intact. What it does NOT give is
530
+ // mutual exclusion: two processes rooted at the same project can both
531
+ // read, both prune, and the later rename wins, losing the records the
532
+ // other appended in between. A fixed temp path made that worse — the two
533
+ // writers shared one scratch file — so the name carries the pid.
534
+ //
535
+ // Stated rather than solved: a lock is the fix, and it belongs with the
536
+ // Phase 2 transport, which introduces a second writer to the same file
537
+ // and is where the locking contract has to be decided anyway. Until then
538
+ // the exposure is bounded to records written inside one prune window on a
539
+ // log that only exceeded its budget.
540
+ const tmp = `${log_path}.retention.${process.pid}.tmp`;
541
+ fs.writeFileSync(tmp, payload, { encoding: 'utf-8' });
542
+ fs.renameSync(tmp, log_path);
543
+
544
+ return { kept: kept_lines.length, dropped_by_age, dropped_by_size };
545
+ }
546
+
547
+ /**
548
+ * Append one record as a JSONL line, then enforce the retention policy.
549
+ *
550
+ * Uses the engagement module's compact-sorted serializer so a Class-A line
551
+ * is byte-comparable with the records already in this tree rather than
552
+ * carrying a second JSON convention.
553
+ *
554
+ * Enforcement runs AFTER the append and only when `retention_due` says so,
555
+ * which makes the common append one extra `stat` plus one bounded read. It
556
+ * lives here rather than in a separate sweep because this is the only writer:
557
+ * a policy a caller has to remember to run is a policy that stops running.
558
+ */
559
+ export function append_class_a_record(
560
+ log_path: string,
561
+ record: ClassARecord,
562
+ policy: RetentionPolicy = DEFAULT_RETENTION_POLICY,
563
+ now: Date = new Date(),
564
+ ): void {
565
+ const payload = `${py_json_dumps_compact_sorted(record as unknown as Record<string, unknown>)}\n`;
566
+ fs.mkdirSync(path.dirname(log_path) || '.', { recursive: true });
567
+ fs.appendFileSync(log_path, payload, { encoding: 'utf-8' });
568
+ if (retention_due(log_path, policy, now)) {
569
+ enforce_retention(log_path, policy, now);
570
+ }
571
+ }