@event4u/agent-config 14.1.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 +291 -181
- 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 +3773 -1731
- 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 +22 -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/skill_catalogue_series.ts +91 -0
- 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/dispatch_r2_reviewer.ts +169 -15
- 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/skill_route_hook.ts +172 -11
- 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/install-hooks.sh +26 -0
- 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
|
@@ -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
|
-
|
|
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:
|
|
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
|
-
|
|
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,
|
|
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
|
-
|
|
1257
|
-
|
|
1258
|
-
|
|
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
|
-
|
|
1291
|
-
|
|
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
|
|
29
|
-
*
|
|
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
|
|
56
|
-
*
|
|
57
|
-
*
|
|
58
|
-
*
|
|
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
|
|
228
|
-
*
|
|
229
|
-
*
|
|
230
|
-
*
|
|
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
|
-
*
|
|
370
|
-
*
|
|
371
|
-
*
|
|
372
|
-
*
|
|
373
|
-
|
|
374
|
-
|
|
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
|
|
379
|
-
|
|
380
|
-
|
|
381
|
-
|
|
382
|
-
|
|
383
|
-
|
|
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
|
|
403
|
-
*
|
|
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
|
|
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
|
|
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
|
-
/**
|
|
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 (
|
|
38
|
-
return
|
|
143
|
+
if (tty.isatty(0)) return '';
|
|
144
|
+
return readFd0ToEnd();
|
|
39
145
|
} catch {
|
|
40
146
|
return '';
|
|
41
147
|
}
|