@event4u/agent-config 14.2.0 → 14.4.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/.claude-plugin/marketplace.json +1 -1
- package/CHANGELOG.md +286 -218
- package/agents/templates/.ai-council.yml.example +35 -0
- package/dist/agent-src/contexts/execution/roadmap-process-loop.md +83 -12
- package/dist/agent-src/rules/roadmap-progress-sync.md +48 -4
- package/dist/agent-src/scripts/resume_probe.ts +154 -9
- package/dist/agent-src/scripts/roadmap_gates.ts +210 -6
- package/dist/agent-src/skills/roadmap-management/SKILL.md +9 -4
- package/dist/agent-src/templates/agent-settings.md +70 -0
- package/dist/agent-src/templates/agents/agent-project-settings.example.yml +1 -1
- package/dist/agent-src/templates/scripts/telemetry/remote.ts +571 -0
- package/dist/agent-src/templates/scripts/telemetry/settings.ts +222 -44
- package/dist/cli/python/workspace_drive.js +8 -3
- package/dist/cli/python/workspace_drive.js.map +1 -1
- package/dist/cli/python/workspace_hosts.js +16 -12
- package/dist/cli/python/workspace_hosts.js.map +1 -1
- package/dist/cli/registry.js +2 -0
- package/dist/cli/registry.js.map +1 -1
- package/dist/cli-delegate/{chunk-F3YUF7SA.js → chunk-3UCJWMMP.js} +1 -1
- package/dist/cli-delegate/chunk-F7DQWYR5.js +69 -0
- package/dist/cli-delegate/{chunk-7PZ4FSZB.js → chunk-FBKL6JWT.js} +61 -2
- package/dist/cli-delegate/{chunk-MNWRDMR5.js → chunk-PKOBQADP.js} +1 -1
- package/dist/cli-delegate/cmd_conformance.js +26 -11
- package/dist/cli-delegate/cmd_doctor.js +2 -2
- package/dist/cli-delegate/cmd_handoff.js +3 -3
- package/dist/cli-delegate/cmd_route_audit.js +2 -2
- package/dist/cli-delegate/cmd_settings_set.js +17 -3
- package/dist/discovery/deprecation-report.md +1 -1
- package/dist/discovery/discovery-manifest.json +4 -4
- package/dist/discovery/discovery-manifest.json.sha256 +1 -1
- package/dist/discovery/discovery-manifest.summary.md +1 -1
- package/dist/discovery/dormancy-report.md +1 -1
- package/dist/discovery/orphan-report.md +1 -1
- package/dist/discovery/packs.json +2 -2
- package/dist/discovery/trust-report.md +1 -1
- package/dist/discovery/workspaces.json +2 -2
- package/dist/hooks/dispatch.js +3684 -1719
- package/dist/mcp/registry-manifest.json +2 -2
- package/dist/mcp/server.json +2 -2
- package/dist/mcp/server.mjs +1070 -408
- package/dist/scripts/_lib/host_launch.js +66 -0
- package/dist/scripts/_lib/host_launch.js.map +1 -0
- package/dist/server/io/yamlIO.js +175 -3
- package/dist/server/io/yamlIO.js.map +1 -1
- package/dist/server/routes/wizard.js +20 -1
- package/dist/server/routes/wizard.js.map +1 -1
- package/dist/shared/settingsConsent.js +25 -0
- package/dist/shared/settingsConsent.js.map +1 -1
- package/docs/contracts/settings-classes.md +11 -0
- package/docs/guidelines/agent-infra/roadmap-progress-mechanics.md +2 -1
- package/package.json +1 -1
- package/src/agent-src/scripts/resume_probe.ts +154 -9
- package/src/agent-src/scripts/roadmap_gates.ts +210 -6
- package/src/agent-src/templates/scripts/telemetry/remote.ts +571 -0
- package/src/agent-src/templates/scripts/telemetry/settings.ts +222 -44
- package/src/config/estate-count-budget.json +120 -0
- package/src/config/evaluator-budgets.json +4 -4
- package/src/config/gate-coverage.yml +32 -4
- package/src/config/gate-violation-baselines.json +63 -63
- package/src/config/gitignore-block.txt +5 -0
- package/src/config/hook-latency-budget.json +40 -1
- package/src/config/hook-token-budget.json +16 -0
- package/src/config/rule-stub-ceilings.json +11 -3
- package/src/scripts/_dispatch.bash +42 -1
- package/src/scripts/_lib/council_fallback_posture.ts +81 -0
- package/src/scripts/_lib/council_fallback_wiring.ts +126 -0
- package/src/scripts/_lib/council_settings_block.ts +152 -0
- package/src/scripts/_lib/headless_invocation.ts +285 -0
- package/src/scripts/_lib/host_launch.ts +72 -0
- package/src/scripts/_lib/map_to_object.ts +16 -0
- package/src/scripts/_lib/roadmap_checkboxes.ts +88 -0
- package/src/scripts/_lib/run_checkpoint.ts +409 -0
- package/src/scripts/_lib/session_register.ts +32 -1
- package/src/scripts/_lib/stdin.ts +35 -1
- package/src/scripts/_lib/turn_end_refusals.ts +12 -4
- package/src/scripts/_lib/unattended_guard.ts +371 -0
- package/src/scripts/ai_council/config.ts +27 -1
- package/src/scripts/ai_council/events_log.ts +12 -1
- package/src/scripts/ai_council/fallback_config.ts +114 -0
- package/src/scripts/ai_council/mid_flight_fallback.ts +295 -0
- package/src/scripts/ai_council/orchestrator.ts +251 -292
- package/src/scripts/ai_council/py_parity.ts +139 -0
- package/src/scripts/ai_council/response_render.ts +99 -0
- package/src/scripts/ai_council/spend_gate.ts +123 -0
- package/src/scripts/ai_council/transport_resolver.ts +81 -13
- package/src/scripts/bench_hook_injection.ts +100 -6
- package/src/scripts/bench_hook_latency.ts +442 -16
- package/src/scripts/check_estate_count.ts +924 -0
- package/src/scripts/compile_hook_manifest.ts +67 -0
- package/src/scripts/council_cli.ts +100 -125
- package/src/scripts/decision_memo.ts +325 -0
- package/src/scripts/hook_manifest.json +1 -0
- package/src/scripts/hook_manifest.yaml +172 -25
- package/src/scripts/hooks/concern_registry.ts +6 -0
- package/src/scripts/hooks/dispatch_hook.ts +183 -10
- package/src/scripts/hooks/dispatch_issues.ts +26 -0
- package/src/scripts/hooks/end_review_nudge_hook.ts +97 -24
- package/src/scripts/hooks/hook_stdin.ts +111 -5
- package/src/scripts/hooks/injection_budget.ts +595 -0
- package/src/scripts/hooks/payload_stub.ts +446 -0
- package/src/scripts/hooks/run_continuation_hook.ts +627 -0
- package/src/scripts/hooks/session_eol_hook.ts +52 -0
- package/src/scripts/hooks/telemetry_usage_hook.ts +339 -0
- package/src/scripts/hooks/tool_result_bytes_hook.ts +27 -0
- package/src/scripts/hooks/turn_end_gate_hook.ts +45 -7
- package/src/scripts/hooks_doctor.ts +16 -2
- package/src/scripts/interruption_report.ts +330 -13
- package/src/scripts/lint_hook_manifest.ts +167 -0
- package/src/scripts/roadmap_progress_hook.ts +254 -46
- package/src/scripts/run_supervise.ts +623 -0
- package/src/scripts/session_register_hook.ts +108 -8
- package/src/scripts/sessions_cli.ts +18 -6
- package/src/scripts/telemetry_disclosure_hook.ts +244 -0
- package/src/server/io/yamlIO.ts +167 -3
- package/src/shared/settingsConsent.ts +49 -2
- 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
|
+
}
|