@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
@@ -54,6 +54,8 @@ import {
54
54
  scanEolSlice,
55
55
  type EolCounters,
56
56
  } from '../_lib/session_eol.js';
57
+ import { buildCheckpoint, writeCheckpoint } from '../_lib/run_checkpoint.js';
58
+ import { read_claimed_slug } from '../session_register_hook.js';
57
59
  import { unwrap, type JsonObject, type JsonValue } from './envelope.js';
58
60
  import { readHookStdin } from './hook_stdin.js';
59
61
  import { atomic_write_json, is_replay_mode } from './state_io.js';
@@ -107,6 +109,23 @@ export function deriveSessionKey(envelope: JsonObject, payload: JsonObject): str
107
109
  );
108
110
  }
109
111
 
112
+ /**
113
+ * The RAW session id, not the hashed key.
114
+ *
115
+ * `read_claimed_slug` keys the per-session claim file off the id the host
116
+ * exported, so handing it `deriveSessionKey`'s hash would look up a file that
117
+ * cannot exist and silently report "no contract" for every claimed run.
118
+ * Falls back to the envelope, which is where the dispatcher puts it, and
119
+ * deliberately does NOT fall back to the transcript path: that is a filename,
120
+ * not an identity, and `sessions:claim` never wrote a claim under one.
121
+ */
122
+ export function payloadSessionId(payload: JsonObject, envelope: JsonObject): string {
123
+ return (
124
+ str(payload['session_id'] as JsonValue | undefined) ||
125
+ str(envelope['session_id'] as JsonValue | undefined)
126
+ ).trim();
127
+ }
128
+
110
129
  /** Load prior per-session state; any read/shape error reads as "fresh session". */
111
130
  export function readState(file: string): SessionEolState {
112
131
  try {
@@ -337,6 +356,39 @@ export function main(): number {
337
356
 
338
357
  writeContextFill(workspaceRoot, tokens, threshold, now);
339
358
 
359
+ // ── UOTL Phase 6.1 — the deterministic half of the handoff ───────
360
+ //
361
+ // Above the threshold AND inside a running contract, leave a derived
362
+ // checkpoint behind. Two properties the advisory alone cannot give:
363
+ //
364
+ // · It costs no judgement. Every field is recomputed from the
365
+ // roadmap on disk, so a dying session produces it correctly even
366
+ // when it has no context left to summarise anything.
367
+ // · It is re-verifiable. A resumed run recomputes the same fields
368
+ // and can name WHICH claim went stale rather than trusting the
369
+ // record, which is the deliberate departure from resuming by
370
+ // bookkeeping (Phase 3.2).
371
+ //
372
+ // "Inside a running contract" is the same carrier `run-continuation`
373
+ // uses — a `sessions:claim` for this session — and no second one is
374
+ // invented. Outside a contract this is silent: a checkpoint for a
375
+ // conversational session names work nobody is executing.
376
+ //
377
+ // Best-effort throughout. A checkpoint is a recovery aid, and a
378
+ // recovery aid that can fail a Stop is a liability.
379
+ const checkpointRunId = payloadSessionId(payload, envelope);
380
+ if (shouldAdvise && checkpointRunId !== '') {
381
+ try {
382
+ const slug = read_claimed_slug(workspaceRoot, checkpointRunId);
383
+ if (slug !== null) {
384
+ const cp = buildCheckpoint(workspaceRoot, eolSessionKey(checkpointRunId), slug);
385
+ if (cp !== null) writeCheckpoint(workspaceRoot, cp);
386
+ }
387
+ } catch {
388
+ // never block the Stop path
389
+ }
390
+ }
391
+
340
392
  if (shouldAdvise && threshold !== null && tokens !== null) {
341
393
  process.stdout.write(
342
394
  `${JSON.stringify({
@@ -0,0 +1,339 @@
1
+ #!/usr/bin/env tsx
2
+ /**
3
+ * Deterministic PostToolUse Class-A usage capture
4
+ * (road-to-org-telemetry Phase 1, step 1.2).
5
+ *
6
+ * Why a hook rather than the existing collector. `skill_usage_collect.ts`
7
+ * scans the maintainer's own transcript directory and derives its project
8
+ * slug from this repository, so a consumer session is invisible to it by
9
+ * construction — the roadmap's Context table records that as still true
10
+ * after 479 commits. Phase 0's third spike then measured the size of the
11
+ * gap on the set the collector DOES read: **0 of 89** invocations detected,
12
+ * against 163 of 164 across every worktree slug. The zero in the usage
13
+ * report is an instrumentation artefact, not an adoption measurement.
14
+ *
15
+ * Phase 0's first spike settled the event this concern binds to:
16
+ * `post_tool_use` with `tool_name === "Skill"`, the skill name at
17
+ * `tool_input.skill` — present in 164 of 164 real invocations across 14,171
18
+ * live records. The transcript-scan fallback the roadmap pre-registered does
19
+ * not fire.
20
+ *
21
+ * DEFAULT-OFF, AND THE SWITCH IS NOT `enabled`. `read_remote_settings`
22
+ * activates only when `enabled` is true AND the org pack supplied an
23
+ * endpoint, an org id, and a salt. An external clone of this repository
24
+ * carries the key names and no values, so copying the tree cannot reach the
25
+ * write path. When inactive this concern performs zero telemetry file
26
+ * operations — no log write, no directory creation — and zero network calls.
27
+ * (It does read `.agent-settings.yml`, once per process and memoised: the
28
+ * settings file is how it learns it is off, the same as the artefact-
29
+ * engagement surface it mirrors.)
30
+ *
31
+ * NO OUTBOUND CALL EXISTS HERE, DELIBERATELY. Transport is Phase 2 and is
32
+ * blocked on `sink-choice`; org-wide enablement is Phase 3 and is blocked on
33
+ * `dpo-signoff`. This concern appends to a local JSONL file and stops.
34
+ *
35
+ * Exit code is ALWAYS 0 — malformed envelope, unreadable settings, failed
36
+ * write, anything. A hook `warn` (exit 2) is read as a hard BLOCK on this
37
+ * host (recorded trap), and a telemetry concern has nothing to say to the
38
+ * model, so it never warns either.
39
+ */
40
+ import * as os from 'node:os';
41
+ import * as path from 'node:path';
42
+ import { fileURLToPath, pathToFileURL } from 'node:url';
43
+ import * as fs from 'node:fs';
44
+
45
+ import {
46
+ read_remote_settings,
47
+ type RemoteTelemetrySettings,
48
+ } from '../../agent-src/templates/scripts/telemetry/settings.js';
49
+ import {
50
+ append_class_a_record,
51
+ build_class_a_record,
52
+ profile_from_legacy_tier,
53
+ } from '../../agent-src/templates/scripts/telemetry/remote.js';
54
+ import { is_replay_mode } from './state_io.js';
55
+ import { readHookStdin } from './hook_stdin.js';
56
+
57
+ const EXIT_ALLOW = 0;
58
+
59
+ /** The tool name Phase 0 spike 1 confirmed for a skill invocation. */
60
+ export const SKILL_TOOL_NAME = 'Skill';
61
+
62
+ const SETTINGS_FILENAME = '.agent-settings.yml';
63
+
64
+ type JsonValue = string | number | boolean | null | JsonValue[] | { [k: string]: JsonValue };
65
+ type JsonObject = { [k: string]: JsonValue };
66
+
67
+ function isObject(v: unknown): v is JsonObject {
68
+ return typeof v === 'object' && v !== null && !Array.isArray(v);
69
+ }
70
+
71
+ /** Unwrap the dispatcher envelope down to the platform-native payload. */
72
+ export function unwrapPayload(envelope: JsonObject): JsonObject {
73
+ const inner = envelope['payload'];
74
+ return isObject(inner) ? inner : envelope;
75
+ }
76
+
77
+ /** Best-effort read of the tool name across host key variants. */
78
+ export function extractToolName(payload: JsonObject): string | null {
79
+ const v = payload['tool_name'] ?? payload['toolName'] ?? payload['tool'];
80
+ return typeof v === 'string' && v ? v : null;
81
+ }
82
+
83
+ /**
84
+ * The invoked skill's name, off `tool_input.skill`.
85
+ *
86
+ * Spike 1 observed this key on 164 of 164 real invocations; the camelCase
87
+ * variant is accepted for the same reason `extractToolName` accepts one —
88
+ * host key casing has varied across versions in this tree before.
89
+ */
90
+ export function extractSkillName(payload: JsonObject): string | null {
91
+ const input = payload['tool_input'] ?? payload['toolInput'];
92
+ if (!isObject(input)) return null;
93
+ const v = input['skill'] ?? input['skill_name'] ?? input['name'];
94
+ return typeof v === 'string' && v ? v : null;
95
+ }
96
+
97
+ /** Host id off the dispatcher envelope (`claude`, `augment`, …). */
98
+ export function extractHost(envelope: JsonObject): string | null {
99
+ const v = envelope['platform'] ?? envelope['host'];
100
+ return typeof v === 'string' && v ? v : null;
101
+ }
102
+
103
+ /**
104
+ * The host session id. Hashed before it is recorded, never stored raw —
105
+ * it is a high-entropy opaque token that `check_secret_leak` reads as a
106
+ * candidate credential.
107
+ */
108
+ export function extractSessionId(envelope: JsonObject, payload: JsonObject): string {
109
+ for (const source of [envelope, payload]) {
110
+ const v = source['session_id'] ?? source['sessionId'];
111
+ if (typeof v === 'string' && v) return v;
112
+ }
113
+ return '';
114
+ }
115
+
116
+ /**
117
+ * Read one top-level scalar out of the settings text.
118
+ *
119
+ * Deliberately a raw line scan rather than a YAML parse: this runs on the
120
+ * post-tool hot path, both values wanted here are top-level scalars from
121
+ * closed enums, and `build_class_a_record` rejects anything outside those
122
+ * enums — so a mis-parse produces a dropped field, never a wrong one.
123
+ *
124
+ * Accepts a trailing inline comment, which this file's own settings
125
+ * template uses heavily (`discipline_profile: essential # ~3.3x kernel
126
+ * tokens`); rejecting it would have silently reported "not declared" for a
127
+ * perfectly ordinary line.
128
+ */
129
+ function _scalar(settingsText: string, key: string): string | null {
130
+ const re = new RegExp(`^${key}:[ \\t]*([^#\\r\\n]+?)[ \\t]*(?:#.*)?$`, 'u');
131
+ for (const line of settingsText.split('\n')) {
132
+ const m = re.exec(line);
133
+ if (m && m[1]) {
134
+ const value = m[1].trim().replace(/^["']|["']$/gu, '');
135
+ if (!value) return null;
136
+ // The shipped template carries a placeholder until `install`
137
+ // fills it in; that is "not yet resolved", not a value.
138
+ if (value.startsWith('__')) return null;
139
+ return value;
140
+ }
141
+ }
142
+ return null;
143
+ }
144
+
145
+ /**
146
+ * The DECLARED discipline profile — the knob that actually decides which
147
+ * rule surfaces load.
148
+ *
149
+ * The first version of this function read `rule_loading_tier`, and that was
150
+ * wrong: the settings template calls that key a "legacy knob — superseded by
151
+ * discipline_profile above", and `resolve_discipline_profile` confirms the
152
+ * precedence — explicit `discipline_profile` wins outright and the legacy
153
+ * key is consulted only in its absence. So a default install would have had
154
+ * a knob recorded that does not decide the thing the field implies.
155
+ *
156
+ * The fallback mirrors the resolver's own legacy mapping, which needs no
157
+ * model id and is therefore safe to reproduce here. What is NOT reproduced
158
+ * is the resolver's both-absent default (`essential`): recording it would
159
+ * claim a declaration the install never made. Absent stays `null`, and a
160
+ * reader applies the documented default themselves.
161
+ */
162
+ export function extractDisciplineProfile(settingsText: string): string | null {
163
+ const explicit = _scalar(settingsText, 'discipline_profile');
164
+ if (explicit !== null) return explicit;
165
+ return profile_from_legacy_tier(_scalar(settingsText, 'rule_loading_tier'));
166
+ }
167
+
168
+ /**
169
+ * Installed package version, from the env the installer sets. Never
170
+ * fabricated: an install that did not report one records `null`.
171
+ */
172
+ export function extractPackageVersion(env: NodeJS.ProcessEnv): string | null {
173
+ const v = env['AGENT_CONFIG_PACKAGE_VERSION'];
174
+ return typeof v === 'string' && v.trim() ? v.trim() : null;
175
+ }
176
+
177
+ interface SettingsRead {
178
+ settings: RemoteTelemetrySettings;
179
+ text: string;
180
+ /**
181
+ * The directory the settings file was found in — the project root, and
182
+ * the base a relative `output.path` resolves against. NOT the session
183
+ * cwd: a session started in a subdirectory would otherwise scatter one
184
+ * log file per directory it happened to start in.
185
+ */
186
+ root: string;
187
+ }
188
+
189
+ /**
190
+ * Read + memoise the settings for one process. The in-process dispatcher
191
+ * runs every concern in one `node`, so without this the file would be read
192
+ * once per concern per tool call.
193
+ */
194
+ const _settingsCache = new Map<string, SettingsRead>();
195
+
196
+ /**
197
+ * Walk up from `start` to the first directory holding a settings file.
198
+ *
199
+ * A host may report a session `cwd` inside a subdirectory of the project,
200
+ * and reading only that directory made the concern silently inactive on a
201
+ * legitimately enabled install — a failure with no message, which is the
202
+ * worst shape for a default-off surface. Bounded by the filesystem root, so
203
+ * it terminates on every path; no hit returns `start` unchanged, and the
204
+ * settings read then fails closed exactly as before.
205
+ */
206
+ export function resolveSettingsPath(start: string): string {
207
+ let dir = path.resolve(start);
208
+ for (;;) {
209
+ const candidate = path.join(dir, SETTINGS_FILENAME);
210
+ try {
211
+ if (fs.statSync(candidate).isFile()) return candidate;
212
+ } catch {
213
+ // Not here — keep walking.
214
+ }
215
+ const parent = path.dirname(dir);
216
+ if (parent === dir) return path.join(path.resolve(start), SETTINGS_FILENAME);
217
+ dir = parent;
218
+ }
219
+ }
220
+
221
+ export function readSettingsFor(consumerRoot: string): SettingsRead {
222
+ const p = resolveSettingsPath(consumerRoot);
223
+ const cached = _settingsCache.get(p);
224
+ if (cached !== undefined) return cached;
225
+
226
+ let text = '';
227
+ try {
228
+ text = fs.readFileSync(p, 'utf-8');
229
+ } catch {
230
+ text = '';
231
+ }
232
+ const read: SettingsRead = { settings: read_remote_settings(p), text, root: path.dirname(p) };
233
+ _settingsCache.set(p, read);
234
+ return read;
235
+ }
236
+
237
+ /** Test seam — the cache is process-global and tests write real files. */
238
+ export function _resetSettingsCache(): void {
239
+ _settingsCache.clear();
240
+ }
241
+
242
+ function processEnvelope(envelope: JsonValue, consumer_root: string): number {
243
+ try {
244
+ if (!isObject(envelope)) return EXIT_ALLOW;
245
+
246
+ const payload = unwrapPayload(envelope);
247
+ if (extractToolName(payload) !== SKILL_TOOL_NAME) return EXIT_ALLOW;
248
+
249
+ const { settings, text, root } = readSettingsFor(consumer_root);
250
+ if (!settings.active) return EXIT_ALLOW;
251
+ if (is_replay_mode()) return EXIT_ALLOW;
252
+
253
+ const skill = extractSkillName(payload);
254
+ if (skill === null) return EXIT_ALLOW;
255
+
256
+ const record = build_class_a_record({
257
+ skill,
258
+ host: extractHost(envelope),
259
+ org_id: settings.org_id,
260
+ salt: settings.salt,
261
+ hostname: os.hostname(),
262
+ username: os.userInfo().username,
263
+ session_id: extractSessionId(envelope, payload),
264
+ package_version: extractPackageVersion(process.env),
265
+ discipline_profile: extractDisciplineProfile(text),
266
+ });
267
+
268
+ // Relative to the PROJECT root (the directory holding the settings
269
+ // file), never to the session cwd — otherwise a session started in a
270
+ // subdirectory writes its own stray log there.
271
+ const logPath = path.isAbsolute(settings.log_path)
272
+ ? settings.log_path
273
+ : path.join(root, settings.log_path);
274
+ // The growth budget travels from the settings the install actually
275
+ // declared, not from the appender's own defaults — a retention key
276
+ // the write path never reads would be a setting that decorates
277
+ // rather than decides.
278
+ append_class_a_record(logPath, record, {
279
+ max_age_days: settings.retention_max_age_days,
280
+ max_bytes: settings.retention_max_bytes,
281
+ });
282
+ } catch {
283
+ // Malformed payload, unreadable disk, a rejected id — never block
284
+ // the tool call, and never write a degraded record.
285
+ return EXIT_ALLOW;
286
+ }
287
+ return EXIT_ALLOW;
288
+ }
289
+
290
+ export function run(stdin_text: string, options: { consumer_root: string }): number {
291
+ let envelope: JsonValue;
292
+ try {
293
+ const raw = stdin_text.trim();
294
+ if (!raw) return EXIT_ALLOW;
295
+ envelope = JSON.parse(raw) as JsonValue;
296
+ } catch {
297
+ return EXIT_ALLOW;
298
+ }
299
+ return processEnvelope(envelope, options.consumer_root);
300
+ }
301
+
302
+ export function _resolveRoot(envelope: JsonValue): string {
303
+ if (isObject(envelope)) {
304
+ const cwd = envelope['cwd'];
305
+ if (typeof cwd === 'string' && cwd) return cwd;
306
+ const pr = envelope['workspace_root'] ?? envelope['project_root'];
307
+ if (typeof pr === 'string' && pr) return pr;
308
+ }
309
+ return process.cwd();
310
+ }
311
+
312
+ export function main(): number {
313
+ const raw = readHookStdin();
314
+ let envelope: JsonValue = {};
315
+ try {
316
+ envelope = raw.trim() ? (JSON.parse(raw) as JsonValue) : {};
317
+ } catch {
318
+ return EXIT_ALLOW;
319
+ }
320
+ return processEnvelope(envelope, _resolveRoot(envelope));
321
+ }
322
+
323
+ // Bundle-safety: never auto-run when inlined into an esbuild bundle, where
324
+ // every module shares the bundle's `import.meta.url` (see cmd_migrate.ts).
325
+ declare const __AGENT_CONFIG_BUNDLE__: boolean | undefined;
326
+ function _isCliEntry(): boolean {
327
+ if (typeof __AGENT_CONFIG_BUNDLE__ !== 'undefined' && __AGENT_CONFIG_BUNDLE__) {
328
+ return false;
329
+ }
330
+ if (process.argv[1] === undefined) return false;
331
+ const argvUrl = pathToFileURL(path.resolve(process.argv[1])).href;
332
+ if (import.meta.url === argvUrl) return true;
333
+ try {
334
+ return fs.realpathSync(fileURLToPath(import.meta.url)) === fs.realpathSync(path.resolve(process.argv[1]));
335
+ } catch {
336
+ return false;
337
+ }
338
+ }
339
+ if (_isCliEntry()) process.exit(main());
@@ -38,6 +38,15 @@
38
38
  * The result is measured, never read: `_resultBytes` computes a length and
39
39
  * discards the value in the same expression.
40
40
  *
41
+ * payload-bodies-waiver: result — measured, never read. This concern
42
+ * deliberately declares NO `needs_payload_bodies` in `hook_manifest.yaml`: the
43
+ * dispatcher omits the body and passes its exact UTF-8 byte length in the stub,
44
+ * which is the only thing this file ever wanted. Declaring `result` here would
45
+ * silently restore the 2 MB payload for the one concern the omission was built
46
+ * for (`road-to-per-turn-hook-economy` step 2.2), and the census would look
47
+ * identical while the cost came back. The `RESULT_KEYS` literals below are the
48
+ * key NAMES this concern looks under, not a read of the body.
49
+ *
41
50
  * ── Always exit 0 ─────────────────────────────────────────────────────
42
51
  *
43
52
  * A `warn` (exit 2) is read as a hard BLOCK on this host, and this concern
@@ -62,6 +71,7 @@ import { fileURLToPath, pathToFileURL } from 'node:url';
62
71
 
63
72
  import { is_replay_mode } from './state_io.js';
64
73
  import { readHookStdin } from './hook_stdin.js';
74
+ import { stubbedBytes } from './payload_stub.js';
65
75
 
66
76
  const EXIT_ALLOW = 0;
67
77
 
@@ -98,11 +108,28 @@ const RESULT_KEYS = ['tool_response', 'toolResponse', 'tool_result', 'toolUseRes
98
108
  *
99
109
  * Bytes, not characters: a multibyte result costs what it costs on the
100
110
  * wire, and `String.length` would under-report it.
111
+ *
112
+ * ## The dispatcher may omit the body, and then it passes the length instead
113
+ *
114
+ * `road-to-per-turn-hook-economy` step 2.2. This concern declares no
115
+ * `needs_payload_bodies` in `hook_manifest.yaml` — its own header says the
116
+ * result is "measured, never read", and that is exactly the concern the
117
+ * payload opt-in exists for. So the dispatcher replaces the body with a stub
118
+ * carrying its UTF-8 byte length, computed the same way this function
119
+ * computes it, and this function reads it back.
120
+ *
121
+ * Without the stub branch the census would keep filling — with the ~120-byte
122
+ * length of the STUB, silently, forever. An instrument that reports a wrong
123
+ * number is worse than one that reports none, which is why the branch is
124
+ * first and why `stubbedBytes` distinguishes "omitted, this many bytes" from
125
+ * "present, measure it".
101
126
  */
102
127
  export function _resultBytes(payload: JsonObject): number | null {
103
128
  for (const key of RESULT_KEYS) {
104
129
  const v = payload[key];
105
130
  if (v === undefined || v === null) continue;
131
+ const omitted = stubbedBytes(v);
132
+ if (omitted !== undefined) return omitted;
106
133
  if (typeof v === 'string') return Buffer.byteLength(v, 'utf8');
107
134
  try {
108
135
  return Buffer.byteLength(JSON.stringify(v), 'utf8');
@@ -9,7 +9,7 @@
9
9
  * is deliberately not another reminder. It is a check at the point of
10
10
  * delivery that can say no.
11
11
  *
12
- * Three detectors ride on one guard, because building the unsafe part twice
12
+ * FOUR detectors ride on one guard, because building the unsafe part twice
13
13
  * is how a second detector becomes a second outage:
14
14
  *
15
15
  * A — promissory closing (FC-5, 20 measured occurrences)
@@ -20,6 +20,33 @@
20
20
  * prose, which is why `readTranscriptTail` collects
21
21
  * them: `_messageText` keeps `type === 'text'` blocks
22
22
  * only, so tool activity is invisible to A and B.)
23
+ * D — completion claim (a claim of done carrying no fresh evidence; landed
24
+ * under conformance round 7 § Phase 1.)
25
+ *
26
+ * TWO of the four are CONDITIONAL, and saying "unconditional" here would be the
27
+ * same stale-header defect this block corrects below. A and D are the
28
+ * completion-adjacent pair an open subagent dispatch excuses, so `main()` runs
29
+ * them only when `dispatchOpen` is false; B and C run on every turn-end. That
30
+ * narrowing is deliberate (Phase 3 Step 2, narrowed again by R2 round 2) and is
31
+ * the third allow path, alongside the two re-entrancy layers — see the
32
+ * per-detector list in `main()`.
33
+ *
34
+ * This block said "Three" and listed A/B/C until 2026-08-18, while `DetectorId`
35
+ * below has carried four since round 7. `DETECTOR_IDS` in
36
+ * `_lib/turn_end_refusals.ts` is read off that union rather than off this
37
+ * comment for exactly that reason, and the count is corrected here rather than
38
+ * left as a stale header the next reader has to disbelieve.
39
+ *
40
+ * ## Removal condition
41
+ *
42
+ * This is a BLOCKING concern, so it owes one — see
43
+ * `docs/contracts/turn-end-detector-demotion.md` for the pre-registered
44
+ * per-detector bars, their sample floors, and the reason a crossed bar
45
+ * authorises a staged study rather than a demotion. Two things that contract
46
+ * says about the guard below are worth meeting here: the re-entrancy layers cap
47
+ * a turn at ONE refusal, which makes a re-refusal share unobservable and the
48
+ * three-strikes non-termination valve unreachable. Both are properties of this
49
+ * design, not gaps in the counters.
23
50
  *
24
51
  * ## Re-entrancy — the shape, stated before registration
25
52
  *
@@ -997,10 +1024,18 @@ export function main(): number {
997
1024
  );
998
1025
  if (alreadyRefusedTurn(workspaceRoot, sessionKey, turnOrdinal)) return EXIT_ALLOW;
999
1026
 
1000
- // Every detector runs on every turn-end. The gating is INSIDE each one —
1001
- // no promise, no pin mismatch, no unverified edit ⇒ no finding ⇒ the turn
1002
- // ends. That is the whole of "fires when it is warranted"; there is no
1003
- // second, configurable notion of warranted layered on top of it.
1027
+ // B and C run on every turn-end; A and D run only when no dispatch is open
1028
+ // (the ternaries below). For the detectors that DO run, the gating is INSIDE
1029
+ // each one — no promise, no pin mismatch, no unverified edit, no unsettled
1030
+ // completion claim ⇒ no finding ⇒ the turn ends. That is the whole of "fires
1031
+ // when it is warranted"; there is no second, configurable notion of warranted
1032
+ // layered on top of it.
1033
+ //
1034
+ // This said "Every detector runs on every turn-end" and enumerated three of
1035
+ // four, three lines above the two `dispatchOpen` ternaries that refute it.
1036
+ // Corrected 2026-08-19 with the header block at the top of this file: a reader
1037
+ // arriving here met the false claim first, and the D comment below deferred to
1038
+ // "the note at the top of this loop" — which was this sentence.
1004
1039
  const findings: Finding[] = [];
1005
1040
  for (const f of [
1006
1041
  // A and D are the completion-adjacent pair a pending dispatch excuses
@@ -1009,8 +1044,11 @@ export function main(): number {
1009
1044
  dispatchOpen ? null : detectPromissory(lastAssistant),
1010
1045
  detectLanguage(lastAssistant, readLanguagePin(workspaceRoot)),
1011
1046
  detectUnverifiedEdit(toolCalls),
1012
- // Round 7 § Phase 1 — detector D, in the same unconditional list as the
1013
- // other three. It shipped with its own settings flag one commit earlier
1047
+ // Round 7 § Phase 1 — detector D. It is NOT unconditional, and this
1048
+ // comment said it was while sitting one line above the `dispatchOpen`
1049
+ // ternary that conditions it: A and D are both excused by an open
1050
+ // dispatch, per the note at the top of this loop. Corrected 2026-08-18.
1051
+ // It shipped with its own settings flag one commit earlier
1014
1052
  // and that flag is gone: `hooks.turn_end_gate.*` was deleted on
1015
1053
  // 2026-08-12 because a default-off safety gate is an absent one. Its
1016
1054
  // gating is where the comment above says gating belongs — inside the
@@ -31,7 +31,10 @@ import fs from "node:fs";
31
31
  import path from "node:path";
32
32
  import { fileURLToPath, pathToFileURL } from "node:url";
33
33
 
34
- import { read_dispatch_issues } from "./hooks/dispatch_issues.js";
34
+ import {
35
+ POLICY_OUTCOME_ISSUES,
36
+ read_dispatch_issues,
37
+ } from "./hooks/dispatch_issues.js";
35
38
  import { type JsonObject, type JsonValue, _load_yaml } from "./hooks/dispatch_hook.js";
36
39
  import * as hooks_status from "./hooks_status.js";
37
40
  import {
@@ -240,9 +243,20 @@ export function collect(
240
243
 
241
244
  // Phase 1 of road-to-hooks-actually-fire-in-consumers: surface
242
245
  // the dispatch-issues log so users see hooks that tried and failed.
246
+ //
247
+ // POLICY OUTCOMES ARE FILTERED OUT, and the filter is the point: this view
248
+ // means "a hook tried to fire and couldn't", and its CTA is a reinstall. The
249
+ // two emission-shaping codes mean the opposite — the concern ran correctly and
250
+ // the dispatcher chose not to emit it — so they are neither a failure nor
251
+ // fixable by reinstalling. They fire as a routine per-dispatch outcome, so
252
+ // leaving them in would let normal traffic push a real `script_not_found` out
253
+ // of the last-20 window. They stay in the log, which is where the "why did
254
+ // that advisory not appear" question is answered. (R2 review, 2026-08-19.)
243
255
  let issues: JsonObject[] = [];
244
256
  try {
245
- issues = read_dispatch_issues(REPO_ROOT).slice(-20) as unknown as JsonObject[];
257
+ issues = read_dispatch_issues(REPO_ROOT)
258
+ .filter((e) => !POLICY_OUTCOME_ISSUES.has(String(e["issue"])))
259
+ .slice(-20) as unknown as JsonObject[];
246
260
  } catch {
247
261
  issues = [];
248
262
  }