@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.
Files changed (116) hide show
  1. package/.claude-plugin/marketplace.json +1 -1
  2. package/CHANGELOG.md +286 -218
  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 +3684 -1719
  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 +120 -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 +100 -6
  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/injection_budget.ts +595 -0
  100. package/src/scripts/hooks/payload_stub.ts +446 -0
  101. package/src/scripts/hooks/run_continuation_hook.ts +627 -0
  102. package/src/scripts/hooks/session_eol_hook.ts +52 -0
  103. package/src/scripts/hooks/telemetry_usage_hook.ts +339 -0
  104. package/src/scripts/hooks/tool_result_bytes_hook.ts +27 -0
  105. package/src/scripts/hooks/turn_end_gate_hook.ts +45 -7
  106. package/src/scripts/hooks_doctor.ts +16 -2
  107. package/src/scripts/interruption_report.ts +330 -13
  108. package/src/scripts/lint_hook_manifest.ts +167 -0
  109. package/src/scripts/roadmap_progress_hook.ts +254 -46
  110. package/src/scripts/run_supervise.ts +623 -0
  111. package/src/scripts/session_register_hook.ts +108 -8
  112. package/src/scripts/sessions_cli.ts +18 -6
  113. package/src/scripts/telemetry_disclosure_hook.ts +244 -0
  114. package/src/server/io/yamlIO.ts +167 -3
  115. package/src/shared/settingsConsent.ts +49 -2
  116. package/dist/cli-delegate/chunk-MMJL4TIX.js +0 -18
@@ -32,6 +32,7 @@
32
32
  import { spawnSync } from "node:child_process";
33
33
  import fs from "node:fs";
34
34
  import path from "node:path";
35
+ import * as tty from "node:tty";
35
36
  import { fileURLToPath, pathToFileURL } from "node:url";
36
37
  import { parse as parseYaml } from "yaml";
37
38
 
@@ -42,12 +43,15 @@ import {
42
43
  is_replay_mode,
43
44
  } from "./state_io.js";
44
45
  import { log_dispatch_issue, fix_hint } from "./dispatch_issues.js";
46
+ import { shapeAndRecord, type ConcernMessage } from "./injection_budget.js";
45
47
  import { CONCERN_REGISTRY, type ConcernMain } from "./concern_registry.js";
46
48
  import {
47
49
  setHookStdinOverride,
48
50
  clearHookStdinOverride,
51
+ readFd0ToEnd,
49
52
  } from "./hook_stdin.js";
50
53
  import { emitFor, type Severity } from "./host_semantics.js";
54
+ import { _concern_body_classes, planPayloadShapes } from "./payload_stub.js";
51
55
  import { resolveSessionRole, type SessionRole } from "../_lib/session_role.js";
52
56
 
53
57
  // Free-form JSON values flow through every helper here; a documented
@@ -174,8 +178,58 @@ export function _parse_concern_stdout(stdout_text: string): JsonObject {
174
178
  * always ships the `yaml` package, so this mirrors the PyYAML-present
175
179
  * path (`yaml.safe_load(text) or {}`); version 1.1 matches PyYAML.safe_load.
176
180
  */
181
+ /**
182
+ * Cheap content fingerprint of a manifest source — FNV-1a 32-bit plus the byte
183
+ * length, hex.
184
+ *
185
+ * Deliberately NOT a crypto hash: `require('node:crypto')` costs 8 ms of
186
+ * process start, which is most of what the precompiled manifest exists to
187
+ * save. This runs in about 0.2 ms over 61 kB and only has to detect an edited
188
+ * source, not resist an adversary — a wrong answer costs the slow path, never
189
+ * correctness.
190
+ */
191
+ export function _manifest_fingerprint(text: string): string {
192
+ let h = 0x811c9dc5;
193
+ for (let i = 0; i < text.length; i += 1) {
194
+ h ^= text.charCodeAt(i);
195
+ h = Math.imul(h, 0x01000193) >>> 0;
196
+ }
197
+ return `${h.toString(16)}:${String(text.length)}`;
198
+ }
199
+
177
200
  export function _load_yaml(p: string): JsonObject {
201
+ // Precompiled fast path. The manifest is ~61 kB of YAML and is parsed on
202
+ // EVERY dispatch, which measured 12 ms of a ~103 ms dispatch (plus 8 ms to
203
+ // load the `yaml` module itself). The sibling `.json` is the same data with
204
+ // comments stripped — 14.7 kB — and JSON.parse of it is sub-millisecond.
205
+ //
206
+ // Freshness is decided by the SOURCE CONTENT, not by mtime. The first version
207
+ // of this compared mtimes and that was a measured defect, not a theoretical
208
+ // one: on a fresh `actions/checkout` both files get the checkout timestamp in
209
+ // whatever order git wrote them, so whether the optimisation applied at all
210
+ // was a coin flip. It won on the PR (p95 129 ms) and lost on the trunk
211
+ // (p95 186 ms) for the same commit. A fingerprint is deterministic wherever
212
+ // the tree came from.
213
+ //
214
+ // Reading the YAML unconditionally costs ~0 ms (it is the PARSE that is
215
+ // expensive), so the fast path still skips ~20 ms.
178
216
  const text = fs.readFileSync(p, "utf-8");
217
+ const compiled = p.replace(/\.ya?ml$/u, ".json");
218
+ if (compiled !== p) {
219
+ try {
220
+ const raw = JSON.parse(fs.readFileSync(compiled, "utf-8")) as JsonValue;
221
+ if (
222
+ _isObject(raw) &&
223
+ raw["fingerprint"] === _manifest_fingerprint(text) &&
224
+ _isObject(raw["manifest"])
225
+ ) {
226
+ return raw["manifest"];
227
+ }
228
+ } catch {
229
+ // Missing, unreadable or malformed → fall through to the YAML source.
230
+ // Never fail the dispatch on the optimisation.
231
+ }
232
+ }
179
233
  const data = parseYaml(text, { version: "1.1" }) as JsonValue;
180
234
  return _isObject(data) ? data : {};
181
235
  }
@@ -665,6 +719,31 @@ function _resolve_tsx_invocation(
665
719
  * fail-open; the latency budget gate (hook-latency-budget.json) is the
666
720
  * standing regression net.
667
721
  */
722
+ /**
723
+ * ## Why this still re-serialises the envelope per concern (step 1.1, MEASURED NULL)
724
+ *
725
+ * `setHookStdinOverride` takes text, so every concern re-stringifies the whole
726
+ * envelope — tool result included. On `post_tool_use` with the claude chain
727
+ * bound that is eleven serialisations of the same payload per tool call, which
728
+ * `road-to-per-turn-hook-economy` D-2 named as a cost worth removing.
729
+ *
730
+ * It was removed, measured against a pre-registered bar, and **put back**. Two
731
+ * A/B runs on one machine, 2 MB payload, bundle arm, `post_tool_use` p50:
732
+ * 166 → 135 ms (−18.7 %) at n=15, then 139 → 143 ms (**+2.9 %**) at n=25. The
733
+ * runs disagree in SIGN, and the within-arm spread (p95 190–228 ms on the same
734
+ * arm) is larger than the effect. That is the pre-registration's kill outcome:
735
+ * under 5 %, publish the null and stop the phase.
736
+ *
737
+ * So the hoist is not here, deliberately, and this comment is the reason —
738
+ * without it the next reader re-derives an "obvious" optimisation that has
739
+ * already been tried and did not pay.
740
+ *
741
+ * One property to preserve if anyone re-attempts it: share the **serialised
742
+ * string**, never a parsed object. The per-concern re-serialisation is
743
+ * accidentally an isolation boundary (risk 1), and a string cannot be mutated by
744
+ * one concern for the next. Pinned by
745
+ * `tests/scripts/hooks/dispatch_envelope_isolation.test.ts`.
746
+ */
668
747
  function _run_concern_inproc(
669
748
  main_fn: ConcernMain,
670
749
  concern: ConcernDef,
@@ -911,10 +990,18 @@ function _write_feedback(
911
990
  "decision",
912
991
  "reason",
913
992
  "duration_ms",
993
+ "payload_bodies",
994
+ "payload_stubs",
914
995
  ]);
915
996
  const summary: JsonObject = {
916
997
  schema_version: 1,
917
998
  session_id,
999
+ // Run-level roll-up of step 2.1's counter: how many payload bodies this
1000
+ // dispatch omitted across the whole chain. A number, never a body.
1001
+ payload_stubs_served: entries.reduce(
1002
+ (n, e) => n + (typeof e["payload_stubs"] === "number" ? e["payload_stubs"] : 0),
1003
+ 0,
1004
+ ),
918
1005
  platform: (envelope["platform"] ?? null) as JsonValue,
919
1006
  event: (envelope["event"] ?? null) as JsonValue,
920
1007
  native_event: (envelope["native_event"] || "") as JsonValue,
@@ -1116,7 +1203,34 @@ export function main(argv?: string[]): number {
1116
1203
  }
1117
1204
  }
1118
1205
 
1119
- const payload_text = process.stdin.isTTY ? "" : _readStdin();
1206
+ // `tty.isatty(0)`, NOT `process.stdin.isTTY`: reading the latter constructs
1207
+ // the stdin stream and puts fd 0 into non-blocking mode, after which the read
1208
+ // below throws EAGAIN on any payload above the pipe buffer and silently
1209
+ // yields an EMPTY envelope. That is a measured guard bypass, not a theory —
1210
+ // see readFd0ToEnd's header in hooks/hook_stdin.ts.
1211
+ const payload_text = tty.isatty(0) ? "" : _readStdin();
1212
+ if (_stdin_read_failed !== null) {
1213
+ // NEVER silent: an empty envelope reached from a failed read means every
1214
+ // guard on this event evaluated nothing. The exit code is unchanged (see
1215
+ // `_stdin_read_failed`), so this line and the dispatch issue are the only
1216
+ // signal that the chain ran blind.
1217
+ process.stderr.write(
1218
+ `dispatch_hook: STDIN READ FAILED (${_stdin_read_failed}) — the concern ` +
1219
+ `chain for '${args.event}' ran against an EMPTY payload and could not ` +
1220
+ `evaluate anything. This is not an empty stdin.\n`,
1221
+ );
1222
+ try {
1223
+ log_dispatch_issue(
1224
+ process.cwd(),
1225
+ "dispatcher",
1226
+ "execution_failed",
1227
+ `stdin read failed on ${args.event}: ${_stdin_read_failed} — chain ran with an empty payload`,
1228
+ fix_hint(),
1229
+ );
1230
+ } catch {
1231
+ // observability never breaks the hook
1232
+ }
1233
+ }
1120
1234
  _maybe_capture_payload(args, payload_text);
1121
1235
  const session_role = resolveSessionRole(process.env);
1122
1236
  const refusal_retry = _is_refusal_retry(args.event, payload_text);
@@ -1156,17 +1270,42 @@ export function main(argv?: string[]): number {
1156
1270
  // feedback_entries on purpose: that record is written to disk with a
1157
1271
  // fixed-field schema (PII-exclusion-by-construction), and a concern's raw
1158
1272
  // stderr is free-form content. This array is in-memory only.
1159
- const concern_messages: Array<{ rc: number; text: string }> = [];
1273
+ const concern_messages: ConcernMessage[] = [];
1160
1274
  // Per-concern `tools:` filter (see _concern_matches_tool). Applied here so it
1161
1275
  // is one place, after the envelope exists and before any concern is spawned.
1162
1276
  const tool_name = _payload_tool_name(envelope);
1163
- for (const concern of concerns) {
1277
+ // Payload opt-in (step 2.1). There are at most FOUR distinct envelope shapes
1278
+ // per dispatch (keep neither / input / result / both), so they are memoised
1279
+ // by keep-set: concerns sharing a declaration share one clone, and building
1280
+ // one per concern would re-pay the allocation the stub exists to avoid.
1281
+ // `present` is the set of body classes this event actually carries — empty on
1282
+ // every non-tool event, which is why those pay nothing at all.
1283
+ // Payload opt-in (step 2.1). One planner owns the whole decision — which
1284
+ // classes are present, which any concern loses, the single measurement pass,
1285
+ // and the at-most-four envelope shapes — so the loop below reads a shape
1286
+ // rather than re-deriving one. Rationale and cost model: payload_stub.ts.
1287
+ const keep_by_concern = concerns.map(
1288
+ (c) => [c, _concern_body_classes(c)] as const,
1289
+ );
1290
+ const shapes = planPayloadShapes(
1291
+ envelope,
1292
+ keep_by_concern.map(([, keep]) => keep),
1293
+ );
1294
+ for (const [concern, keep_classes] of keep_by_concern) {
1164
1295
  if (!_concern_matches_tool(concern, tool_name)) {
1165
1296
  continue;
1166
1297
  }
1298
+ const concern_envelope = shapes.shapeFor(keep_classes);
1299
+ const stubs_served = shapes.stubsFor(keep_classes);
1300
+ // What was actually SERVED, not what was declared. Recording the
1301
+ // declaration made the record wrong in both directions on a body-less
1302
+ // event: a blocking `stop` concern read "input,result" for bodies it can
1303
+ // never receive, and an advisory one read "none" while the full envelope
1304
+ // went to it untouched.
1305
+ const served_classes = shapes.servedBy(keep_classes);
1167
1306
  const concern_started = _now_iso();
1168
1307
  const { rc: rawRcResult, stderr: stderr_text, stdout: stdout_text, duration_ms } =
1169
- _run_concern(concern, envelope);
1308
+ _run_concern(concern, concern_envelope);
1170
1309
  let rc = rawRcResult;
1171
1310
  const raw_rc = rc;
1172
1311
  if (rc >= 3) {
@@ -1217,7 +1356,7 @@ export function main(argv?: string[]): number {
1217
1356
  : "";
1218
1357
  const message = [stated, extra].filter(Boolean).join("\n\n");
1219
1358
  if (message) {
1220
- concern_messages.push({ rc, text: message });
1359
+ concern_messages.push({ rc, text: message, def: concern });
1221
1360
  }
1222
1361
  if (
1223
1362
  args.event === "session_start" &&
@@ -1238,6 +1377,15 @@ export function main(argv?: string[]): number {
1238
1377
  started_at: concern_started,
1239
1378
  completed_at: _now_iso(),
1240
1379
  fail_closed: Boolean(concern["fail_closed"]),
1380
+ // Step 2.1's counter — an EXPOSURE denominator, not a detector, and the
1381
+ // review was right to refuse the stronger reading: these numbers are a
1382
+ // function of the declaration and the payload shape, so they say how
1383
+ // often a concern ran without a body, never whether it wanted one. The
1384
+ // detector for the wanting-one case is the source-derived check in
1385
+ // `lint_hook_manifest`, at authoring time, where it is decidable.
1386
+ // A sorted class list and an integer — nothing that can hold a body.
1387
+ payload_bodies: served_classes.join(",") || "none",
1388
+ payload_stubs: stubs_served,
1241
1389
  });
1242
1390
  }
1243
1391
  const final_rc = _reduce(rcs);
@@ -1253,9 +1401,13 @@ export function main(argv?: string[]): number {
1253
1401
  // disagree: on Claude Code exit 1 does not block and exit 2 does, which
1254
1402
  // inverted every verdict (see host_semantics.ts for the documented mapping).
1255
1403
  // Unverified platforms keep the legacy pass-through byte-for-byte.
1256
- const decidingReasons = concern_messages
1257
- .filter((m) => m.rc === final_rc)
1258
- .map((m) => m.text);
1404
+ // Emission shaping (nudge exclusivity + per-turn byte ceiling) also owns the
1405
+ // deciding-severity filter this line used to apply. See injection_budget.ts.
1406
+ const decidingReasons = shapeAndRecord(
1407
+ { packageRoot: REPO_ROOT, envelope, platform: args.platform, event: args.event },
1408
+ concern_messages,
1409
+ final_rc,
1410
+ );
1259
1411
  const emission = emitFor(
1260
1412
  args.platform,
1261
1413
  args.event,
@@ -1285,10 +1437,31 @@ function _sortedRepr(s: ReadonlySet<string>): string {
1285
1437
  return `[${items.map((x) => `'${x}'`).join(", ")}]`;
1286
1438
  }
1287
1439
 
1440
+ /**
1441
+ * Set when the stdin read FAILED, as distinct from stdin being genuinely empty.
1442
+ *
1443
+ * The two were indistinguishable, and that was the second half of the bypass
1444
+ * F-1 records: removing the EAGAIN *trigger* still left `catch { return "" }`
1445
+ * converting any residual failure — an exhausted retry budget, `EIO`, `EBADF` —
1446
+ * into "no input", after which the whole chain runs with no `tool_name` and the
1447
+ * dispatcher exits 0. For a `fail_closed: true`, `severity: blocking` guard that
1448
+ * is an allow, emitted silently. Found by the R2 review.
1449
+ *
1450
+ * The failure is now LOUD (stderr + a dispatch issue) while the allow/deny
1451
+ * decision is unchanged. Turning a failed read into a DENY is a policy change on
1452
+ * a security surface, so it is filed as `b-stdin-read-failure-policy` rather than
1453
+ * improvised here: an unreadable payload on a block-capable event arguably must
1454
+ * refuse, and that call is the maintainer's.
1455
+ */
1456
+ let _stdin_read_failed: string | null = null;
1457
+
1288
1458
  function _readStdin(): string {
1289
1459
  try {
1290
- return fs.readFileSync(0, "utf-8");
1291
- } catch {
1460
+ // Reads to EOF and retries on EAGAIN rather than treating it as empty
1461
+ // input; shared with the concern-side reader so both paths cannot drift.
1462
+ return readFd0ToEnd();
1463
+ } catch (exc) {
1464
+ _stdin_read_failed = exc instanceof Error ? exc.message : String(exc);
1292
1465
  return "";
1293
1466
  }
1294
1467
  }
@@ -42,6 +42,32 @@ export const VALID_ISSUE: ReadonlySet<string> = new Set([
42
42
  "script_not_found",
43
43
  "permission_denied",
44
44
  "execution_failed",
45
+ // road-to-standing-context-40k Phase 4. The four codes above all mean "a
46
+ // concern tried to run and could not". These two mean the opposite — the
47
+ // concern ran, produced its line, and the DISPATCHER decided not to emit it.
48
+ // They share this log because it is already the place a reader looks to find
49
+ // out why an expected hook effect never appeared, and a suppressed advisory
50
+ // is exactly that question. The distinct codes keep the two classes
51
+ // separable: `hooks_doctor` surfaces the whole log, and a budget drop must
52
+ // not read as a broken hook.
53
+ "injection_budget_drop",
54
+ "nudge_interference_drop",
55
+ ]);
56
+
57
+ /**
58
+ * The subset of `VALID_ISSUE` that records a POLICY OUTCOME rather than a
59
+ * concern failure — the concern ran correctly and the dispatcher chose not to
60
+ * emit it.
61
+ *
62
+ * Exported so the diagnostic consumers can tell the two classes apart instead of
63
+ * inferring it from a name. `hooks_doctor` filters these out of its
64
+ * "hooks tried to fire but couldn't" view: that view's CTA is a reinstall, which
65
+ * fixes nothing here, and these fire as routine per-dispatch traffic that would
66
+ * otherwise push a real failure out of the last-20 window.
67
+ */
68
+ export const POLICY_OUTCOME_ISSUES: ReadonlySet<string> = new Set([
69
+ "injection_budget_drop",
70
+ "nudge_interference_drop",
45
71
  ]);
46
72
 
47
73
  export interface DispatchIssueEntry {
@@ -25,8 +25,8 @@
25
25
  *
26
26
  * `git diff --numstat HEAD` at `workspace_root`: the working tree vs the
27
27
  * last commit, TRACKED files only, PLUS every untracked non-doc file
28
- * (`git ls-files --others --exclude-standard`, one `git diff --numstat
29
- * --no-index /dev/null <file>` per file — see "Untracked files" below;
28
+ * (`git ls-files --others --exclude-standard`, each one READ from the
29
+ * filesystem rather than diffed in a subprocess — see "Untracked files" below;
30
30
  * fixes a review finding, F6, that a brand-new file was previously invisible
31
31
  * to this count entirely). Chosen over a session-start SHA/timestamp
32
32
  * baseline because no such baseline exists anywhere a `stop` concern can
@@ -52,10 +52,15 @@
52
52
  * that authored a brand-new 300-line module was previously measured as a
53
53
  * 0-line mutation. `untrackedNonDocFiles` lists the candidates
54
54
  * (`git ls-files --others --exclude-standard`, filtered by the same
55
- * `isDocPath` carve-out); `untrackedFileLineCount` sizes each one via
56
- * `git diff --no-index /dev/null <file>` (the only way to diff a path git's
57
- * index has never seen). Past `UNTRACKED_FILE_CAP` untracked non-doc files
58
- * this hook refuses to spawn one subprocess per file — an unbounded loop is
55
+ * `isDocPath` carve-out); `untrackedFileLineCount` sizes each one by READING
56
+ * IT — an untracked file's diff against nothing is its own content, so the
57
+ * count needs no git. It used to spawn `git diff --no-index /dev/null <file>`
58
+ * per file, on the Stop slot; step 3.2 of `road-to-per-turn-hook-economy`
59
+ * removed that process and preserved both edges that could otherwise move a
60
+ * threshold decision silently (a binary file counts 0, an unterminated last
61
+ * line still counts). The CAP is unchanged and still the outer bound: past
62
+ * `UNTRACKED_FILE_CAP` untracked non-doc files
63
+ * this hook refuses to read them all — an unbounded loop is
59
64
  * worse than an imprecise answer — and instead reports a line count
60
65
  * GUARANTEED to be over `MUTATION_LINE_THRESHOLD`: "many new files this
61
66
  * session" is itself sufficient signal to fire without the exact count.
@@ -224,10 +229,14 @@ import { atomic_write_json, is_replay_mode } from './state_io.js';
224
229
  export const MUTATION_LINE_THRESHOLD = 50;
225
230
 
226
231
  /**
227
- * Untracked non-doc files past which this hook refuses to spawn one
228
- * `git diff --no-index` subprocess per file and instead reports the
229
- * mutation as certainly over threshold (F6, review — see the file header's
230
- * "Untracked files" section).
232
+ * Untracked non-doc files past which this hook refuses to size them at all and
233
+ * instead reports the mutation as certainly over threshold (F6, review — see the
234
+ * file header's "Untracked files" section).
235
+ *
236
+ * It bounds the file COUNT. The per-file byte bound is separate and lives in
237
+ * `untrackedFileLineCount`: since step 3.2 replaced the per-file subprocess with
238
+ * a read, an unbounded read would move an arbitrarily large file INTO this
239
+ * process, which the subprocess never did.
231
240
  */
232
241
  export const UNTRACKED_FILE_CAP = 20;
233
242
 
@@ -366,21 +375,84 @@ export function untrackedNonDocFiles(cwd: string): string[] {
366
375
  }
367
376
 
368
377
  /**
369
- * Added+deleted line count for ONE untracked file, via
370
- * `git diff --numstat --no-index /dev/null <relPath>` — the only way to
371
- * diff a path git's index has never seen. `--no-index` exits `1` whenever a
372
- * diff exists (by design, unlike `gitNumstatRows`'s tracked-diff contract,
373
- * where a nonzero status means failure) — only the parsed numstat rows
374
- * matter here, never the exit code. Fails closed to `0`, never throws.
378
+ * Leading bytes probed for a NUL when deciding whether an untracked file is
379
+ * binary. git uses its own leading-block heuristic for the same call; the point
380
+ * of matching the size is that both agree on which files count as binary, not
381
+ * that either is exhaustive.
382
+ */
383
+ export const BINARY_PROBE_BYTES = 8000;
384
+
385
+ /**
386
+ * Per-file read ceiling for the untracked-file line count.
387
+ *
388
+ * `UNTRACKED_FILE_CAP` bounds how MANY files are sized; nothing bounded how
389
+ * LARGE one of them may be, and that gap arrived with step 3.2: the
390
+ * `git diff --no-index` subprocess it replaced never materialised file content in
391
+ * the hook process, while a `readFileSync` does. One untracked-but-not-ignored
392
+ * dataset or media file would then cost a full allocation plus an O(n) byte scan
393
+ * at every turn end, and past ~2 GiB the read throws and silently counts 0.
394
+ * Found by the R2 review.
395
+ *
396
+ * 8 MiB is far above any hand-written source file and far below a size worth
397
+ * allocating on the Stop path. A file over it is counted as
398
+ * `MUTATION_LINE_THRESHOLD + 1` rather than 0: "a huge new file appeared" is a
399
+ * mutation, and the old code's answer for an unreadable file — zero — is the
400
+ * direction that hides one.
401
+ */
402
+ export const UNTRACKED_FILE_READ_CAP_BYTES = 8 * 1024 * 1024;
403
+
404
+ /**
405
+ * Added line count for ONE untracked file, read from the filesystem.
406
+ *
407
+ * **No subprocess (road-to-per-turn-hook-economy step 3.2 / D-3).** This used to
408
+ * run `git diff --numstat --no-index /dev/null <relPath>`, one spawn per
409
+ * untracked non-doc file, on the Stop slot — so a workspace with nineteen new
410
+ * files paid nineteen `git` process starts at every turn end. An untracked
411
+ * file's diff against nothing is just its own content, so the count needs no
412
+ * git at all.
413
+ *
414
+ * Behaviour is preserved rather than improved, in both directions that could
415
+ * silently change a threshold decision: a binary file still counts 0 (numstat
416
+ * printed a dash per side and `parseNumstat` mapped that to 0), and a file whose
417
+ * last line lacks a trailing newline still counts that line.
418
+ *
419
+ * The remaining bound is unchanged and lives one level up:
420
+ * `totalNonDocMutatedLinesWithMeasure` still refuses to read more than
421
+ * `UNTRACKED_FILE_CAP` files and returns a labelled `capped_approximation`
422
+ * instead — "many new files this session" is signal enough without an exact
423
+ * count. What 3.2 removes is the per-file *process*, not the cap.
424
+ *
425
+ * Fails closed to `0` and never throws, exactly as the spawn version did.
375
426
  */
376
427
  export function untrackedFileLineCount(cwd: string, relPath: string): number {
377
428
  try {
378
- const r = spawnSync('git', ['diff', '--numstat', '--no-index', '/dev/null', relPath], {
379
- cwd,
380
- encoding: 'utf8',
381
- maxBuffer: 16 * 1024 * 1024,
382
- });
383
- return parseNumstat(r.stdout ?? '').reduce((sum, row) => sum + row.added + row.deleted, 0);
429
+ const abs = path.resolve(cwd, relPath);
430
+ // Size before read: the point of the cap is not to allocate the file.
431
+ const size = fs.statSync(abs).size;
432
+ if (size === 0) return 0;
433
+ if (size > UNTRACKED_FILE_READ_CAP_BYTES) {
434
+ return MUTATION_LINE_THRESHOLD + 1;
435
+ }
436
+ const buf = fs.readFileSync(abs);
437
+ if (buf.length === 0) return 0;
438
+ // Binary detection, matching what this function replaced rather than
439
+ // improving on it: numstat prints a dash for each side of a binary file
440
+ // and `parseNumstat` maps a dash to 0, so a binary file contributed 0
441
+ // lines before and must contribute 0 now. Counting newline bytes in
442
+ // binary content would invent mutation volume out of image data. git's
443
+ // own heuristic is a NUL byte in the leading block, so the same block
444
+ // size is used here and the two agree on the same files.
445
+ const probe = buf.subarray(0, Math.min(buf.length, BINARY_PROBE_BYTES));
446
+ if (probe.includes(0)) return 0;
447
+ // The replaced spawn reported every line of the file as added, INCLUDING
448
+ // a final line with no trailing newline. Counting newline bytes alone
449
+ // would be one short on exactly the files an editor leaves unterminated.
450
+ let lines = 0;
451
+ for (const byte of buf) {
452
+ if (byte === 0x0a) lines += 1;
453
+ }
454
+ if (buf[buf.length - 1] !== 0x0a) lines += 1;
455
+ return lines;
384
456
  } catch {
385
457
  return 0;
386
458
  }
@@ -399,8 +471,9 @@ export interface MutationMeasurement {
399
471
  * HEAD`) and untracked (`git ls-files --others --exclude-standard`) files —
400
472
  * see the file header's "Untracked files (F6)" section for why both sources
401
473
  * are required. Past `UNTRACKED_FILE_CAP` untracked non-doc files this
402
- * function refuses to spawn one `git diff --no-index` subprocess per file
403
- * (an unbounded loop) and instead returns a value GUARANTEED to be over
474
+ * function refuses to size them individually at all (what used to be an
475
+ * unbounded spawn loop, and is now an unbounded read loop) and instead returns a
476
+ * value GUARANTEED to be over
404
477
  * `MUTATION_LINE_THRESHOLD` — "many new files this session" is itself
405
478
  * sufficient signal, no exact count needed. `measure` records WHICH of
406
479
  * those two shapes the returned `lines` value is, so a consumer (the
@@ -12,13 +12,17 @@
12
12
  * Contract:
13
13
  * - Standalone invocation (a concern run directly as its own process —
14
14
  * the historical path, still supported): no override is set, and
15
- * `readHookStdin()` falls through to `fs.readFileSync(0, 'utf-8')`.
15
+ * `readHookStdin()` falls through to a retrying fd-0 read (see
16
+ * `readFd0ToEnd` — a plain `fs.readFileSync(0)` here was a measured guard
17
+ * bypass on large payloads).
16
18
  * - In-process invocation: the dispatcher calls
17
19
  * `setHookStdinOverride(raw)` before the concern's `main()` and
18
20
  * `clearHookStdinOverride()` after. Nesting is not supported (concerns
19
21
  * never dispatch concerns).
20
22
  */
21
- import * as fs from 'node:fs';
23
+ import * as tty from 'node:tty';
24
+
25
+ import { readStdinText } from '../_lib/stdin.js';
22
26
 
23
27
  let _override: string | null = null;
24
28
 
@@ -30,12 +34,114 @@ export function clearHookStdinOverride(): void {
30
34
  _override = null;
31
35
  }
32
36
 
33
- /** TTY-safe stdin read: injected override first, else fd 0, else "". */
37
+ /**
38
+ * Read fd 0 to EOF, correctly, for a payload of any size.
39
+ *
40
+ * Delegates to `_lib/stdin.readStdinText`, which already carries this exact
41
+ * defect's history (EAGAIN on a pipe larger than its buffer, measured 2026-08-04
42
+ * on a CI diff), a bounded retry budget, and the contract that matters here:
43
+ * **it never substitutes an empty string for a failed read.** A second
44
+ * implementation in this file would have been a worse copy of an audited one.
45
+ *
46
+ * ## The hook-side half of the defect (measured 2026-08-18)
47
+ *
48
+ * Knowing the read is fragile was not enough, because the *trigger* lived
49
+ * somewhere else. `dispatch_hook` guarded the read with `process.stdin.isTTY`,
50
+ * and merely READING that property lazily constructs the stdin stream, which
51
+ * puts fd 0 into non-blocking mode. So the guard is what made its own read fail:
52
+ *
53
+ * 1. `process.stdin.isTTY` → fd 0 becomes non-blocking.
54
+ * 2. `fs.readFileSync(0)` → `EAGAIN` once the payload exceeds the pipe buffer.
55
+ * 3. `catch { return '' }` → "no input", and the dispatcher proceeds against an
56
+ * EMPTY envelope and exits 0.
57
+ *
58
+ * Reproduced on the shipped bundle: a `PreToolUse` payload carrying
59
+ * `git commit --no-verify` was DENIED small and ALLOWED once padded to 300 KB,
60
+ * because `block-no-verify` never saw a command. Padding is not exotic — a
61
+ * `PostToolUse` `tool_response` holding a large file read reaches these sizes
62
+ * routinely.
63
+ *
64
+ * Hence both halves, since either alone is a single point of failure:
65
+ * **`tty.isatty(0)`** answers the TTY question without constructing the stream,
66
+ * and **the retrying reader** holds even if something else in the process touched
67
+ * `process.stdin` first. Pinned by
68
+ * `tests/scripts/hooks/dispatch_large_payload_guard.test.ts`, which fails on the
69
+ * pre-fix bundle.
70
+ */
71
+ /**
72
+ * Wait for the FIRST byte, in ms, before concluding fd 0 is idle. See
73
+ * {@link readFd0ToEnd} for why an idle fd 0 must be cheap and a busy one must
74
+ * not be truncated.
75
+ */
76
+ export const HOOK_FIRST_BYTE_TIMEOUT_MS = 25;
77
+
78
+ /**
79
+ * Read fd 0 to EOF for a payload of any size, without stalling when there is no
80
+ * payload at all.
81
+ *
82
+ * ## Two coupled properties, and it took three attempts to see that they were coupled
83
+ *
84
+ * 1. **A large payload must not truncate.** `fs.readFileSync(0)` returned `''`
85
+ * once the payload exceeded the pipe buffer, and the caller's
86
+ * `catch { return '' }` made that indistinguishable from empty input. That is
87
+ * the guard bypass F-1 records: a `PreToolUse` payload carrying
88
+ * `git commit --no-verify` was DENIED small and ALLOWED once padded to 300 KB.
89
+ * 2. **An idle fd 0 must be cheap.** A concern also runs with fd 0 simply open
90
+ * and never written — a child that inherits stdin, or a test that calls
91
+ * `main()` in-process under vitest. A read that blocks there hangs the caller
92
+ * outright.
93
+ *
94
+ * The old code satisfied (2) **by accident, through the very bug that broke (1)**:
95
+ * reading `process.stdin.isTTY` constructs the stdin stream, which puts fd 0 into
96
+ * non-blocking mode, so the following `readFileSync` failed fast with EAGAIN on an
97
+ * idle fd — and equally on a large one. Removing the probe fixed (1) and broke (2)
98
+ * in the worst way: `fs.readSync` on a blocking, open, unwritten fd does not
99
+ * return EAGAIN, it BLOCKS, so no retry budget can bound it. Measured: a whole
100
+ * test file that calls a concern in-process never finished, and three CI shards
101
+ * hung for over an hour.
102
+ *
103
+ * So the non-blocking mode is now **deliberate** rather than a side effect, and
104
+ * the retry loop is what makes it safe:
105
+ *
106
+ * - Touch `process.stdin` on purpose to put fd 0 in non-blocking mode, so a read
107
+ * can never block. This is the one place in the tree that wants that effect;
108
+ * everywhere else it is the hazard `dispatch_hook` documents.
109
+ * - Read through `_lib/stdin.readStdinText`, which retries on EAGAIN instead of
110
+ * reporting it as empty input — that is what fixes (1).
111
+ * - Cap the wait for the FIRST byte, and cap it SHORT. The host writes the
112
+ * payload before the child can run, so the first read either returns data or
113
+ * means nothing is coming; the 25 ms exists only to absorb a scheduling race
114
+ * between the parent's first write and the child's first read, not to wait for
115
+ * a slow writer. It was 500 ms for one iteration and that was already too
116
+ * expensive — 7 in-process concern calls in one test file paid 3.5 s of pure
117
+ * waiting. Once any byte HAS arrived the full ~10 s budget applies, so a
118
+ * multi-megabyte payload is unaffected.
119
+ *
120
+ * Both directions are pinned by
121
+ * `tests/scripts/hooks/dispatch_large_payload_guard.test.ts`, which fails on the
122
+ * pre-fix bundle for (1) and times out for (2) — fixing either alone reintroduces
123
+ * the other.
124
+ */
125
+ export function readFd0ToEnd(): string {
126
+ // Deliberate: constructing the stream sets O_NONBLOCK on fd 0, which is the
127
+ // only portable way to guarantee the reads below cannot block. Node exposes
128
+ // no direct O_NONBLOCK call for an existing descriptor.
129
+ void process.stdin;
130
+ return readStdinText(0, { firstByteTimeoutMs: HOOK_FIRST_BYTE_TIMEOUT_MS });
131
+ }
132
+
133
+ /**
134
+ * TTY-safe stdin read: injected override first, else fd 0, else "".
135
+ *
136
+ * `tty.isatty(0)` rather than `process.stdin.isTTY`, and `readFd0ToEnd` rather
137
+ * than `fs.readFileSync(0)` — see `readFd0ToEnd`'s header for the silent
138
+ * large-payload truncation both halves prevent.
139
+ */
34
140
  export function readHookStdin(): string {
35
141
  if (_override !== null) return _override;
36
142
  try {
37
- if (process.stdin.isTTY) return '';
38
- return fs.readFileSync(0, 'utf-8');
143
+ if (tty.isatty(0)) return '';
144
+ return readFd0ToEnd();
39
145
  } catch {
40
146
  return '';
41
147
  }