@event4u/agent-config 16.1.0 → 16.2.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 (217) hide show
  1. package/.augment-plugin/marketplace.json +3 -3
  2. package/.augment-plugin/plugin.json +1 -1
  3. package/.claude-plugin/marketplace.json +1 -1
  4. package/CHANGELOG.md +237 -248
  5. package/dist/agent-src/commands/roadmap/process-full.md +1 -1
  6. package/dist/agent-src/commands/work.md +4 -0
  7. package/dist/agent-src/contexts/execution/autonomy-mechanics.md +4 -8
  8. package/dist/agent-src/rules/ui-audit-gate.md +1 -2
  9. package/dist/agent-src/skills/accessibility-auditor/data/aria-patterns.csv +1 -1
  10. package/dist/agent-src/skills/accessibility-auditor/data/manifest.json +2 -2
  11. package/dist/agent-src/skills/design-review/SKILL.md +30 -4
  12. package/dist/agent-src/skills/design-review/references/verification-automation.md +32 -0
  13. package/dist/agent-src/skills/existing-ui-audit/SKILL.md +16 -1
  14. package/dist/agent-src/skills/existing-ui-audit/references/component-taxonomy-detection.md +56 -0
  15. package/dist/agent-src/skills/markitdown/SKILL.md +4 -0
  16. package/dist/agent-src/skills/playwright-testing/SKILL.md +30 -6
  17. package/dist/agent-src/skills/ui-component-architect/SKILL.md +23 -0
  18. package/dist/agent-src/templates/agents/agent-project-settings.example.yml +1 -1
  19. package/dist/agent-src/templates/roadmaps.md +30 -0
  20. package/dist/agent-src/templates/scripts/work_engine/directives/ui/app_spec.ts +10 -4
  21. package/dist/agent-src/templates/scripts/work_engine/directives/ui/apply.ts +260 -21
  22. package/dist/agent-src/templates/scripts/work_engine/directives/ui/audit.ts +22 -3
  23. package/dist/agent-src/templates/scripts/work_engine/directives/ui/scaffold.ts +6 -1
  24. package/dist/agent-src/templates/scripts/work_engine/state.ts +57 -7
  25. package/dist/agent-src/templates/scripts/work_engine/taxonomy/detect.ts +500 -0
  26. package/dist/agent-src/templates/scripts/work_engine/taxonomy/place.ts +183 -0
  27. package/dist/cli/python/knowledge_ingest.js +1 -1
  28. package/dist/cli/python/workspace_drive.js +1 -1
  29. package/dist/cli-delegate/{chunk-S3VFXMTM.js → chunk-4XFP2Y3E.js} +1 -1
  30. package/dist/cli-delegate/{chunk-HDVVGPQW.js → chunk-7XGBX6ZH.js} +471 -387
  31. package/dist/cli-delegate/{chunk-GJCBB2UG.js → chunk-FP36SWJD.js} +432 -144
  32. package/dist/cli-delegate/{chunk-UNCRJBT3.js → chunk-IGD6ERX6.js} +31 -0
  33. package/dist/cli-delegate/{chunk-FXYUYU4N.js → chunk-JNMVLWLR.js} +42 -1
  34. package/dist/cli-delegate/{chunk-QJWAUMJW.js → chunk-YJ72UIAK.js} +730 -15
  35. package/dist/cli-delegate/cmd_conformance.js +902 -782
  36. package/dist/cli-delegate/cmd_doctor.js +2 -2
  37. package/dist/cli-delegate/cmd_export.js +2 -2
  38. package/dist/cli-delegate/cmd_handoff.js +4 -4
  39. package/dist/cli-delegate/cmd_memory_get.js +1 -1
  40. package/dist/cli-delegate/cmd_prune.js +2 -2
  41. package/dist/cli-delegate/cmd_route_audit.js +1 -1
  42. package/dist/cli-delegate/cmd_session_recycle.js +2 -2
  43. package/dist/cli-delegate/cmd_settings_migrate.js +2 -2
  44. package/dist/cli-delegate/cmd_settings_set.js +1 -1
  45. package/dist/cli-delegate/cmd_sync.js +2 -2
  46. package/dist/cli-delegate/cmd_uninstall.js +2 -2
  47. package/dist/cli-delegate/cmd_validate.js +2 -2
  48. package/dist/discovery/deprecation-report.md +1 -1
  49. package/dist/discovery/discovery-manifest.json +10 -10
  50. package/dist/discovery/discovery-manifest.json.sha256 +1 -1
  51. package/dist/discovery/discovery-manifest.summary.md +1 -1
  52. package/dist/discovery/dormancy-report.md +1 -1
  53. package/dist/discovery/orphan-report.md +1 -1
  54. package/dist/discovery/packs.json +2 -2
  55. package/dist/discovery/trust-report.md +1 -1
  56. package/dist/discovery/workspaces.json +2 -2
  57. package/dist/hooks/dispatch.js +6666 -2581
  58. package/dist/hooks/dispatch.sha256 +1 -0
  59. package/dist/install/install.mjs +848 -733
  60. package/dist/mcp/registry-manifest.json +2 -2
  61. package/dist/mcp/server.json +2 -2
  62. package/dist/mcp/server.mjs +2747 -1510
  63. package/dist/server/schemas/settings.js +23 -0
  64. package/dist/ui/assets/index-Cwq4d0hX.js +23 -0
  65. package/dist/ui/assets/index-Cyjy8bWO.css +1 -0
  66. package/dist/ui/index.html +2 -2
  67. package/docs/contracts/settings-classes.md +6 -4
  68. package/package.json +4 -4
  69. package/src/agent-src/templates/scripts/work_engine/directives/ui/app_spec.ts +10 -4
  70. package/src/agent-src/templates/scripts/work_engine/directives/ui/apply.ts +260 -21
  71. package/src/agent-src/templates/scripts/work_engine/directives/ui/audit.ts +22 -3
  72. package/src/agent-src/templates/scripts/work_engine/directives/ui/scaffold.ts +6 -1
  73. package/src/agent-src/templates/scripts/work_engine/state.ts +57 -7
  74. package/src/agent-src/templates/scripts/work_engine/taxonomy/detect.ts +500 -0
  75. package/src/agent-src/templates/scripts/work_engine/taxonomy/place.ts +183 -0
  76. package/src/config/agent-settings.template.yml +29 -0
  77. package/src/config/ci-local-parity.yml +18 -0
  78. package/src/config/continuity-surface.json +2 -2
  79. package/src/config/gate-coverage.yml +234 -3
  80. package/src/config/gate-violation-baselines.json +7 -3
  81. package/src/config/hook-latency-budget.json +21 -0
  82. package/src/config/hook-token-budget.json +2 -2
  83. package/src/config/metric-registry.yml +114 -0
  84. package/src/config/placeholder-drift-budget.json +10 -0
  85. package/src/config/routing-coverage-seed.json +18 -4
  86. package/src/scripts/_cli/cmd_doctor.ts +5 -19
  87. package/src/scripts/_cli/doctor_execution.ts +100 -17
  88. package/src/scripts/_cli/doctor_network_posture.ts +226 -0
  89. package/src/scripts/_lib/body_portable.ts +102 -0
  90. package/src/scripts/_lib/cc_transcript.ts +132 -1
  91. package/src/scripts/_lib/concern_sla_window.ts +200 -0
  92. package/src/scripts/_lib/forge_protection.ts +114 -0
  93. package/src/scripts/_lib/forge_reader.ts +558 -0
  94. package/src/scripts/_lib/host_env_write.ts +111 -0
  95. package/src/scripts/_lib/llm_proposer_transport.ts +14 -4
  96. package/src/scripts/_lib/obligations.ts +119 -8
  97. package/src/scripts/_lib/probe_inputs.ts +271 -0
  98. package/src/scripts/_lib/read_surface_scan.ts +322 -0
  99. package/src/scripts/_lib/release_highlights.ts +2 -1
  100. package/src/scripts/_lib/retrieval_sanitize.ts +77 -16
  101. package/src/scripts/_lib/shell_write_shapes.ts +108 -0
  102. package/src/scripts/_lib/structural_hiding.ts +622 -0
  103. package/src/scripts/_lib/turn_end_refusals.ts +525 -4
  104. package/src/scripts/_lib/turn_end_transcript.ts +216 -0
  105. package/src/scripts/_lib/verification_command.ts +324 -0
  106. package/src/scripts/_lib/verification_evidence.ts +647 -0
  107. package/src/scripts/_lib/verify_clause.ts +280 -0
  108. package/src/scripts/ai_team/team_dispatch.ts +4 -0
  109. package/src/scripts/before_complete_hook.ts +340 -23
  110. package/src/scripts/bench_hook_latency.ts +412 -5
  111. package/src/scripts/cache_realization_report.ts +61 -1
  112. package/src/scripts/check_argument_hint.ts +287 -0
  113. package/src/scripts/check_claims.ts +125 -1
  114. package/src/scripts/check_enforcement_coverage.ts +38 -9
  115. package/src/scripts/check_enforcement_matrix.ts +245 -3
  116. package/src/scripts/check_finding_dispositions.ts +22 -3
  117. package/src/scripts/check_generator_sync.ts +71 -0
  118. package/src/scripts/check_host_docs_digest.ts +786 -0
  119. package/src/scripts/check_host_format_column.ts +559 -0
  120. package/src/scripts/check_input_references.ts +228 -0
  121. package/src/scripts/check_inputs_parity.ts +235 -0
  122. package/src/scripts/check_kernel_edit_ratified.ts +47 -5
  123. package/src/scripts/check_kill_switch_table.ts +294 -0
  124. package/src/scripts/check_placeholder_drift.ts +221 -0
  125. package/src/scripts/check_read_surface_coverage.ts +515 -0
  126. package/src/scripts/check_release_holds.ts +2 -1
  127. package/src/scripts/check_routing_coverage.ts +378 -11
  128. package/src/scripts/check_static_parity.ts +17 -0
  129. package/src/scripts/ci_settle.ts +145 -19
  130. package/src/scripts/closure_scan.ts +115 -1
  131. package/src/scripts/compile_hook_manifest.ts +60 -16
  132. package/src/scripts/external_sources_denylist.json +29 -4
  133. package/src/scripts/hook_manifest.json +1 -1
  134. package/src/scripts/hook_manifest.yaml +42 -5
  135. package/src/scripts/hooks/block_config_weakening.ts +229 -4
  136. package/src/scripts/hooks/block_kernel_rule_writes.ts +9 -78
  137. package/src/scripts/hooks/block_plumbing_writes.ts +314 -0
  138. package/src/scripts/hooks/bundle_integrity.ts +291 -0
  139. package/src/scripts/hooks/chain_nudge_hook.ts +1 -2
  140. package/src/scripts/hooks/code_graph_context_hook.ts +1 -2
  141. package/src/scripts/hooks/comment_discipline_hook.ts +1 -3
  142. package/src/scripts/hooks/concern_registry.ts +2 -0
  143. package/src/scripts/hooks/concern_timings.ts +94 -0
  144. package/src/scripts/hooks/delegation_nudge_hook.ts +1 -2
  145. package/src/scripts/hooks/design_pass_hook.ts +100 -9
  146. package/src/scripts/hooks/design_slop_hook.ts +1 -2
  147. package/src/scripts/hooks/dispatch_hook.ts +25 -19
  148. package/src/scripts/hooks/edit_shape_hook.ts +1 -2
  149. package/src/scripts/hooks/end_review_nudge_hook.ts +1 -1
  150. package/src/scripts/hooks/evidence_independence.ts +1 -2
  151. package/src/scripts/hooks/exit_codes.ts +128 -0
  152. package/src/scripts/hooks/host_lowering.json +1 -0
  153. package/src/scripts/hooks/host_lowering.ts +128 -3
  154. package/src/scripts/hooks/host_lowering.yaml +262 -46
  155. package/src/scripts/hooks/host_semantics.ts +30 -2
  156. package/src/scripts/hooks/injection_budget.ts +9 -2
  157. package/src/scripts/hooks/interruption_ledger_hook.ts +1 -2
  158. package/src/scripts/hooks/journal_record_hook.ts +1 -2
  159. package/src/scripts/hooks/memory_recall_hook.ts +1 -2
  160. package/src/scripts/hooks/obligation_settle_hook.ts +151 -5
  161. package/src/scripts/hooks/one_question_per_ask_hook.ts +25 -4
  162. package/src/scripts/hooks/orchestration_record_hook.ts +1 -2
  163. package/src/scripts/hooks/permission_gate.ts +1 -2
  164. package/src/scripts/hooks/push_settle_hook.ts +1 -3
  165. package/src/scripts/hooks/replay_hook.ts +2 -1
  166. package/src/scripts/hooks/reread_guard_hook.ts +1 -2
  167. package/src/scripts/hooks/review_baseline_hook.ts +1 -2
  168. package/src/scripts/hooks/rtk_wrap_hook.ts +1 -2
  169. package/src/scripts/hooks/rule_inject_hook.ts +8 -5
  170. package/src/scripts/hooks/run_continuation_hook.ts +13 -15
  171. package/src/scripts/hooks/skill_route_hook.ts +1 -2
  172. package/src/scripts/hooks/source_first_gate_hook.ts +1 -2
  173. package/src/scripts/hooks/spawn_guard_shadow_hook.ts +1 -2
  174. package/src/scripts/hooks/subagent_ledger_hook.ts +1 -2
  175. package/src/scripts/hooks/suggestion_capture_hook.ts +1 -1
  176. package/src/scripts/hooks/table_fingerprint.ts +49 -0
  177. package/src/scripts/hooks/telemetry_flush_hook.ts +1 -2
  178. package/src/scripts/hooks/telemetry_usage_hook.ts +1 -2
  179. package/src/scripts/hooks/tool_result_bytes_hook.ts +93 -9
  180. package/src/scripts/hooks/turn_end_gate_hook.ts +337 -324
  181. package/src/scripts/hooks/ui_route_nudge_hook.ts +1 -2
  182. package/src/scripts/install.ts +3 -4
  183. package/src/scripts/lint_deny_text.ts +419 -0
  184. package/src/scripts/lint_exit_codes.ts +262 -0
  185. package/src/scripts/lint_hook_manifest.ts +55 -0
  186. package/src/scripts/lint_roadmap_blockers.ts +78 -12
  187. package/src/scripts/lint_skill_descriptions.ts +1 -1
  188. package/src/scripts/lint_skill_scripts_readonly.ts +1 -1
  189. package/src/scripts/mcp_server/prompts.ts +142 -1
  190. package/src/scripts/measure_skill_ranker_baseline.ts +305 -51
  191. package/src/scripts/measure_turn_end_gate.ts +193 -0
  192. package/src/scripts/memory_lookup.ts +1 -1
  193. package/src/scripts/prepack-check.mjs +53 -0
  194. package/src/scripts/release_publication.ts +13 -1
  195. package/src/scripts/report_invocation_surface.ts +383 -0
  196. package/src/scripts/report_obligation_carriers.ts +699 -0
  197. package/src/scripts/report_obligation_writer_split.ts +236 -0
  198. package/src/scripts/report_skill_menu_census.ts +208 -0
  199. package/src/scripts/report_standing_payload_by_host.ts +1 -1
  200. package/src/scripts/roadmap_verify_share.ts +213 -0
  201. package/src/scripts/schemas/command.schema.json +44 -0
  202. package/src/scripts/schemas/review-findings.schema.json +4 -0
  203. package/src/scripts/schemas/skill.schema.json +44 -0
  204. package/src/scripts/self_review_gate.ts +359 -10
  205. package/src/scripts/semantic_parity_census.ts +314 -0
  206. package/src/scripts/session_eol_report.ts +54 -1
  207. package/src/scripts/sweep_dead_scan_roots.ts +1 -1
  208. package/src/scripts/trigger_eval_grandfather.json +0 -2
  209. package/src/scripts/trigger_eval_rotation.ts +122 -27
  210. package/src/scripts/ui_conformance_probe.ts +156 -4
  211. package/src/scripts/update_prices.ts +85 -3
  212. package/src/scripts/write_bundle_digest.mjs +74 -0
  213. package/src/server/schemas/settings.ts +31 -0
  214. package/src/templates/consumer-settings/ONBOARDING.md +1 -0
  215. package/dist/ui/assets/index-DH1xc62v.js +0 -29
  216. package/dist/ui/assets/index-jSfMW8_t.css +0 -1
  217. /package/dist/cli-delegate/{chunk-YUM5KURG.js → chunk-7RQP4UDH.js} +0 -0
@@ -0,0 +1,558 @@
1
+ /**
2
+ * The live forge read behind `doctor --json`'s `forge_protection` block.
3
+ *
4
+ * `road-to-adversarial-verification-and-long-runs` AC-5. Phase 3.2 shipped a
5
+ * correct pure mapper (`forge_protection.ts`) and no production caller, so
6
+ * `agent-config doctor --json` — the command the criterion names — reported five
7
+ * `unread` rows while the forge satisfied all five. The rows were established by
8
+ * a human running `gh api` by hand and reading the mapper's output in a test.
9
+ * That is the exact substitution this roadmap's Phase 3 exists to forbid: a
10
+ * mechanical check replaced by a person's reading of the same evidence.
11
+ *
12
+ * **This IS a reversal of Phase 3.2's recorded decision, and the roadmap carries
13
+ * the supersession.** An earlier draft of this header argued the opposite — that
14
+ * `decision-revisit-gate`'s mechanism-match test fails, so no lock applies — and
15
+ * a 2/2 convergent council pass refuted it: that note recorded TWO boundaries,
16
+ * `doctor` does not reach the network AND the reading is injected, and moving
17
+ * `gh api` in here reverses both. The refuted reasoning is corrected rather than
18
+ * left standing, for the reason the sibling docblock gives: a module header is
19
+ * what the next reader meets first, so it is where a dead argument gets reused.
20
+ *
21
+ * What survives is the narrower AVAILABILITY property the supersession rests on:
22
+ * every failure path returns {@link UNREAD_FORGE}, so offline the rows, their
23
+ * `source` templates and the action lines are what Phase 3.2 printed — no error,
24
+ * no hang. Not the whole document: the block gained a top-level `repository`
25
+ * key, `null` on every such path.
26
+ *
27
+ * **The degradation is the load-bearing claim, so it is tested rather than
28
+ * asserted.** Six distinct failures — no repository, a dead repo record, a dead
29
+ * ruleset detail, a dead environments call, a non-boolean flag, a throwing API —
30
+ * each land on `unread` for the rows they touch and leave the others alone. Two
31
+ * of those directions have a more obvious wrong implementation than the right
32
+ * one, and both are pinned: a PARTIAL ruleset list (understates protection in
33
+ * one direction, overstates it in the other), and ALL-OR-NOTHING across surfaces
34
+ * that do not depend on each other (a dead environments call says nothing about
35
+ * whether a ruleset protects the branch).
36
+ *
37
+ * The network lives here; the judgement lives in `forge_protection.ts`. Both
38
+ * polarities stay testable offline because the API is injected.
39
+ */
40
+
41
+ import { spawnSync } from 'node:child_process';
42
+
43
+ import {
44
+ UNREAD_FORGE,
45
+ deployRestrictedFrom,
46
+ type EnvironmentPolicy,
47
+ type ForgeReading,
48
+ } from './forge_protection.js';
49
+ import type { RulesetDetail } from './platform_anchor.js';
50
+
51
+ /**
52
+ * One forge API call. `null` on ANY failure — exit status, empty output,
53
+ * unparseable JSON, timeout, missing binary, missing credentials.
54
+ *
55
+ * Injected rather than imported so every failure above is reachable from a test
56
+ * without a network, which is what makes the degradation claim checkable.
57
+ */
58
+ export interface ForgeApi {
59
+ get(apiPath: string, paginate?: boolean): unknown | null;
60
+ }
61
+
62
+ /**
63
+ * Per-call ceiling.
64
+ *
65
+ * A diagnostic that hangs is worse than one that reports `unread`: the reader
66
+ * learns nothing either way, and only one of the two costs them the terminal.
67
+ * Ten seconds is a stated default rather than a measured optimum — it is
68
+ * generous for a `gh api` round trip and short enough that five of them stay
69
+ * inside the patience budget of a command people run to get unstuck.
70
+ */
71
+ export const FORGE_CALL_TIMEOUT_MS = 10_000;
72
+
73
+ /** Ceiling on the one `git` spawn, which sits inside the whole-read budget. */
74
+ export const GIT_REMOTE_TIMEOUT_MS = 2_000;
75
+
76
+ /**
77
+ * Ceiling on the WHOLE read, not on one call.
78
+ *
79
+ * The call count is `3 + one per ruleset + one per environment carrying custom
80
+ * branch policies` — it is NOT a fixed five, and sizing the budget against a
81
+ * fixed five was the defect an independent review named. On a repository with
82
+ * many rulesets the budget is what stops the read, and the rows it did not
83
+ * reach report `unread`, which is the honest answer rather than a wrong one.
84
+ *
85
+ * Fifteen seconds is a stated default, not a measured optimum. It is enforced
86
+ * as a real ceiling rather than only at admission: {@link budgetOf} returns the
87
+ * milliseconds left, {@link liveForgeApi} takes that as its `timeoutFor` and
88
+ * caps each spawn at `min(FORGE_CALL_TIMEOUT_MS, remaining)`, and
89
+ * {@link withDeadline} refuses to dispatch once it reaches zero. So a call
90
+ * admitted near the deadline cannot run past it.
91
+ */
92
+ export const FORGE_TOTAL_BUDGET_MS = 15_000;
93
+
94
+ /**
95
+ * Environment switches that skip the read entirely, before any process spawns.
96
+ *
97
+ * Both are read as the literal `1`, which is the contract `cmd_versions.ts` and
98
+ * `cmd_update.ts` already use for `AGENT_CONFIG_OFFLINE`. Accepting any
99
+ * non-empty value here would give one variable two opposite meanings inside one
100
+ * binary — `AGENT_CONFIG_OFFLINE=0` would mean online to `update` and offline
101
+ * to `doctor` — and the surprising direction is the one a user reads as "off".
102
+ */
103
+ export const NO_FORGE_ENV = ['AGENT_CONFIG_DOCTOR_NO_FORGE', 'AGENT_CONFIG_OFFLINE'] as const;
104
+
105
+ /** `true` when a switch in {@link NO_FORGE_ENV} is set to the literal `1`. */
106
+ export function forgeReadDisabled(env: Readonly<Record<string, string | undefined>>): boolean {
107
+ return NO_FORGE_ENV.some((k) => env[k] === '1');
108
+ }
109
+
110
+ function asObject(v: unknown): Record<string, unknown> | null {
111
+ return v !== null && typeof v === 'object' && !Array.isArray(v)
112
+ ? (v as Record<string, unknown>)
113
+ : null;
114
+ }
115
+
116
+ /** What a spawn returns, narrowed to the two fields this module reads. */
117
+ export interface SpawnResult {
118
+ readonly status: number | null;
119
+ readonly stdout?: string | undefined;
120
+ }
121
+
122
+ /** A subprocess runner. Injected so every failure branch is testable offline. */
123
+ export type Runner = (
124
+ cmd: string,
125
+ args: readonly string[],
126
+ timeoutMs: number,
127
+ cwd?: string,
128
+ ) => SpawnResult;
129
+
130
+ const defaultRunner: Runner = (cmd, args, timeoutMs, cwd) =>
131
+ spawnSync(cmd, [...args], {
132
+ encoding: 'utf8',
133
+ timeout: timeoutMs,
134
+ maxBuffer: 32 * 1024 * 1024,
135
+ ...(cwd === undefined ? {} : { cwd }),
136
+ });
137
+
138
+ /**
139
+ * The `timeout` for one call, given the milliseconds left of the whole budget.
140
+ *
141
+ * **Two Node behaviours meet here, and both bite in the same place.**
142
+ * `spawnSync`'s `timeout` must be an INTEGER — `budgetOf` reads
143
+ * `performance.now()`, which is fractional, so an unfloored value threw
144
+ * `ERR_OUT_OF_RANGE`, was swallowed, and the read degraded with no subprocess
145
+ * started. And `timeout: 0` installs NO kill timer at all, so flooring alone
146
+ * turned a sub-millisecond remainder into an UNBOUNDED spawn — the ceiling
147
+ * failing open in exactly the budget-exhaustion case it exists for, which is
148
+ * the worse of the two directions.
149
+ *
150
+ * So the floor is 1 ms, not 0: a call admitted with a sliver of budget left is
151
+ * started and killed almost immediately, which lands on `unread` the way every
152
+ * other failure does. Never `0`, never fractional.
153
+ */
154
+ export function callTimeout(remainingMs: number): number {
155
+ return Math.max(1, Math.floor(Math.min(FORGE_CALL_TIMEOUT_MS, remainingMs)));
156
+ }
157
+
158
+ /** The repository to read, and the host it lives on. */
159
+ export interface ForgeTarget {
160
+ readonly host: string;
161
+ readonly slug: string;
162
+ }
163
+
164
+ /**
165
+ * The live `gh api` caller.
166
+ *
167
+ * `timeoutFor` returns the ceiling for THIS call, so the whole-read budget can
168
+ * shorten it. A fixed per-call timeout is what let a call admitted just inside
169
+ * the budget run well past it — the budget checked admission and nothing
170
+ * checked duration.
171
+ *
172
+ * `--paginate --slurp` needs `gh` 2.43 or newer. On an older client the call
173
+ * fails and the rulesets read blanks, which lands on `unread` rather than on a
174
+ * wrong answer — the degradation this module guarantees, reached by a cause it
175
+ * cannot name. Stated here because nothing else in the diff documents it.
176
+ */
177
+ export function liveForgeApi(
178
+ timeoutFor: () => number = () => FORGE_CALL_TIMEOUT_MS,
179
+ run: Runner = defaultRunner,
180
+ where: { readonly host?: string; readonly cwd?: string } = {},
181
+ ): ForgeApi {
182
+ // **`gh` is addressed at the host and directory the SLUG came from.** The
183
+ // slug is resolved with `cwd: root`, and a `gh` spawned with neither
184
+ // inherits the process cwd and gh's default host — so with `--project`,
185
+ // `--root` or `AGENT_CONFIG_PROJECT_ROOT` pointing elsewhere, or with a
186
+ // GitHub Enterprise remote, the two halves of the read addressed two
187
+ // different repositories and a same-named repo elsewhere produced a
188
+ // confident verdict about somebody else's project.
189
+ const hostArgs =
190
+ where.host === undefined || where.host.toLowerCase() === 'github.com'
191
+ ? []
192
+ : ['--hostname', where.host];
193
+ return {
194
+ get(apiPath: string, paginate = false): unknown | null {
195
+ const budget = timeoutFor();
196
+ if (budget <= 0) return null;
197
+ const args = paginate
198
+ ? ['api', '--paginate', '--slurp', ...hostArgs, apiPath]
199
+ : ['api', ...hostArgs, apiPath];
200
+ let r: SpawnResult;
201
+ try {
202
+ r = run('gh', args, callTimeout(budget), where.cwd);
203
+ } catch {
204
+ return null;
205
+ }
206
+ if (r.status !== 0 || typeof r.stdout !== 'string' || r.stdout.trim() === '') {
207
+ return null;
208
+ }
209
+ try {
210
+ return JSON.parse(r.stdout) as unknown;
211
+ } catch {
212
+ return null;
213
+ }
214
+ },
215
+ };
216
+ }
217
+
218
+ /**
219
+ * `owner/repo` from a git remote URL, or `null`.
220
+ *
221
+ * **Host-gated on purpose.** `gh api` speaks GitHub, so attempting it against a
222
+ * GitLab or Bitbucket remote buys a doomed subprocess on every `doctor` run and
223
+ * returns the same `unread` the skip returns for free. The gate is the literal
224
+ * `github` in the host, which covers `github.com` and the usual GitHub
225
+ * Enterprise naming. A GHES install on a host that does not carry the vendor
226
+ * name is skipped and reports `unread` — the safe degradation, and stated here
227
+ * rather than left for a reader to discover from a blank row.
228
+ */
229
+ export function resolveForgeRepo(originUrl: string | null): ForgeTarget | null {
230
+ if (originUrl === null || originUrl.trim() === '') return null;
231
+ const url = originUrl.trim();
232
+ const m = /^(?:[A-Za-z][A-Za-z0-9+.-]*:\/\/)?(?:[^@/]+@)?([^/:]+)[:/]([^/:]+\/[^/]+?)(?:\.git)?$/.exec(
233
+ url,
234
+ );
235
+ const host = m?.[1];
236
+ const slug = m?.[2];
237
+ if (host === undefined || slug === undefined) return null;
238
+ if (!host.toLowerCase().includes('github')) return null;
239
+ // The slug is interpolated into an API path and used as a replacement
240
+ // string, so its character set is checked rather than assumed. The previous
241
+ // pattern admitted `$`, `?`, `#` and `..` — one of those is a path
242
+ // traversal and another is a `String.replace` control sequence.
243
+ if (!/^[A-Za-z0-9._-]+\/[A-Za-z0-9._-]+$/.test(slug)) return null;
244
+ if (slug.split('/').some((seg) => seg === '.' || seg === '..')) return null;
245
+ return { host, slug };
246
+ }
247
+
248
+ /**
249
+ * The repository's `origin` URL, or `null`.
250
+ *
251
+ * Timed out like every other spawn here. It was the one subprocess in the
252
+ * module with no ceiling and it ran before the budget was even constructed, so
253
+ * a wedged `git` sat entirely outside a read that advertised a total ceiling.
254
+ */
255
+ export function originUrl(
256
+ root: string,
257
+ run: Runner = defaultRunner,
258
+ timeoutMs = GIT_REMOTE_TIMEOUT_MS,
259
+ ): string | null {
260
+ let r: SpawnResult;
261
+ try {
262
+ // `ls-remote --get-url` APPLIES `url.<base>.insteadOf` rewrites and still
263
+ // makes no network call; `remote get-url` returns the configured URL
264
+ // verbatim. The difference matters in the unsafe direction: a
265
+ // github.com-shaped URL rewritten onto another host would otherwise be
266
+ // accepted and the read attributed to the wrong repository — which is a
267
+ // condition the Phase 3.2 amendment names as reversing it.
268
+ r = run('git', ['ls-remote', '--get-url', 'origin'], timeoutMs, root);
269
+ } catch {
270
+ return null;
271
+ }
272
+ if (r.status !== 0 || typeof r.stdout !== 'string') return null;
273
+ const out = r.stdout.trim();
274
+ // With no such remote, `ls-remote --get-url` echoes the name back.
275
+ return out === '' || out === 'origin' ? null : out;
276
+ }
277
+
278
+ /**
279
+ * Every ruleset, with its detail, or `null`.
280
+ *
281
+ * The listing endpoint returns summaries without `rules`, so each is fetched
282
+ * individually — and a single failed detail read blanks the WHOLE list rather
283
+ * than returning what was collected. Same contract as the sibling anchor
284
+ * source, for the same reason it states: a subset understates the effective
285
+ * protection in one direction and overstates it in the other, and neither is a
286
+ * verdict worth recording. `--paginate --slurp` because the listing pages at 30
287
+ * and a silently truncated reading is that same partial verdict wearing a
288
+ * different cause.
289
+ */
290
+ function readRulesets(repo: string, api: ForgeApi): RulesetDetail[] | null {
291
+ // One flattener for the one `--paginate --slurp` contract. This read grew
292
+ // its own inline version first, which left two implementations of the same
293
+ // page shape free to drift apart.
294
+ const list = slurped(api.get(`repos/${repo}/rulesets`, true), 'rulesets');
295
+ if (list === null) return null;
296
+ const out: RulesetDetail[] = [];
297
+ for (const entry of list) {
298
+ const id = asObject(entry)?.['id'];
299
+ if (typeof id !== 'number') return null;
300
+ const detail = api.get(`repos/${repo}/rulesets/${String(id)}`);
301
+ if (asObject(detail) === null) return null;
302
+ out.push(detail as RulesetDetail);
303
+ }
304
+ return out;
305
+ }
306
+
307
+ /**
308
+ * Flatten a `--paginate --slurp` payload down to one list.
309
+ *
310
+ * `--slurp` yields an array of PAGES. Each page is either a bare array (a list
311
+ * endpoint) or an object carrying the list under `key` (`environments`,
312
+ * `branch_policies`). `null` when the shape is not one of those — never a
313
+ * partial list, for the reason {@link readRulesets} states.
314
+ */
315
+ function slurped(payload: unknown, key: string): unknown[] | null {
316
+ if (Array.isArray(payload)) {
317
+ // An array of pages, each itself an array — a list endpoint under
318
+ // `--slurp`.
319
+ if (payload.length > 0 && payload.every((p) => Array.isArray(p))) {
320
+ return (payload as unknown[][]).flat();
321
+ }
322
+ // An array of pages, each an object carrying the list under `key`.
323
+ if (payload.length > 0 && payload.every((p) => Array.isArray(asObject(p)?.[key]))) {
324
+ return payload.flatMap((p) => asObject(p)?.[key] as unknown[]);
325
+ }
326
+ // A flat list of items — what a list endpoint returns UNPAGINATED, and
327
+ // what `gh` emits when a single page is not wrapped. Kept because
328
+ // collapsing it into the page shapes above was what broke the flat
329
+ // case when the two flatteners were merged.
330
+ return payload;
331
+ }
332
+ const listed = asObject(payload)?.[key];
333
+ return Array.isArray(listed) ? [...listed] : null;
334
+ }
335
+
336
+ /**
337
+ * Whether every environment restricts its deployment branches, or `null`.
338
+ *
339
+ * The custom-policy NAMES live behind a second call per environment, and a
340
+ * policy pattern of `*` restricts nothing whatever the flag says. A failed
341
+ * names read therefore makes the whole row `unread`, and that is a CORRECTION:
342
+ * the first draft fell back to trusting the flag, which `deployRestrictedFrom`
343
+ * calls the guarantee that WAS NOT CHECKED — the weaker reading, not the
344
+ * narrower one. An independent review followed the consequence through: an
345
+ * environment whose real policy is `*` would then report `satisfied`, which is
346
+ * the overstatement direction this module says it never takes, and it would be
347
+ * indistinguishable in the output from a genuine restriction. A read that
348
+ * failed is a read nobody made, so it lands where every other unmade read
349
+ * lands.
350
+ *
351
+ * The environment name is URL-ENCODED into the path. GitHub admits spaces and
352
+ * other path-unsafe characters in an environment name, and an unencoded name
353
+ * is exactly how the failed-read branch above gets reached by accident.
354
+ */
355
+ function readDeployRestricted(repo: string, api: ForgeApi): boolean | null {
356
+ // PAGINATED. Unpaginated, a repository with more environments than one page
357
+ // returns a subset, `deployRestrictedFrom`'s `every()` runs over what it can
358
+ // see, and an unlisted unrestricted environment reads as `satisfied` — the
359
+ // overstatement direction this module says it never takes. `readRulesets`
360
+ // guards the identical hazard one function up.
361
+ const envsRaw = slurped(api.get(`repos/${repo}/environments`, true), 'environments');
362
+ if (envsRaw === null) return null;
363
+ const envs: EnvironmentPolicy[] = [];
364
+ const patternsByEnv: Record<string, readonly string[]> = {};
365
+ for (const raw of envsRaw) {
366
+ const env = asObject(raw);
367
+ const name = env?.['name'];
368
+ if (typeof name !== 'string') return null;
369
+ const policy = asObject(env?.['deployment_branch_policy']);
370
+ // A flag PRESENT but not a boolean makes the whole row `unread`. The
371
+ // first version omitted it, and the comment justified that with
372
+ // "`deployRestrictedFrom` distinguishes absent from false" — which is
373
+ // simply wrong: that function tests `=== true` and `!== true`, so
374
+ // absent, `undefined` and `false` collapse identically. The effect was
375
+ // the row rendering "at least one environment accepts a deployment from
376
+ // any branch" — a positive claim about the forge built out of data
377
+ // nobody could parse. Every sibling unparseable input here already
378
+ // takes the null route: a non-string environment name, a non-numeric
379
+ // ruleset id, a non-boolean `allow_auto_merge`, a non-string
380
+ // branch-policy name. An ABSENT flag is a different input and stays
381
+ // absent.
382
+ for (const k of ['protected_branches', 'custom_branch_policies'] as const) {
383
+ const v = policy?.[k];
384
+ if (v !== undefined && typeof v !== 'boolean') return null;
385
+ }
386
+ envs.push({
387
+ name,
388
+ deployment_branch_policy:
389
+ policy === null
390
+ ? null
391
+ : {
392
+ ...(typeof policy['protected_branches'] === 'boolean'
393
+ ? { protected_branches: policy['protected_branches'] }
394
+ : {}),
395
+ ...(typeof policy['custom_branch_policies'] === 'boolean'
396
+ ? { custom_branch_policies: policy['custom_branch_policies'] }
397
+ : {}),
398
+ },
399
+ });
400
+ if (policy?.['custom_branch_policies'] !== true) continue;
401
+ // `protected_branches` already decides the row for this environment —
402
+ // `deployRestrictedFrom` short-circuits on it before consulting the
403
+ // pattern map — so the extra call would spend budget on an answer that
404
+ // cannot change the verdict, and degrade the row to `unread` if it
405
+ // failed.
406
+ if (policy['protected_branches'] === true) continue;
407
+ const names = api.get(
408
+ `repos/${repo}/environments/${encodeURIComponent(name)}/deployment-branch-policies`,
409
+ true,
410
+ );
411
+ const listed = slurped(names, 'branch_policies');
412
+ // A read that failed is a read nobody made — `unread`, never a trusted
413
+ // flag. Trusting it here is how a wildcard policy reports `satisfied`.
414
+ if (listed === null) return null;
415
+ const patterns = listed.map((p) => asObject(p)?.['name']);
416
+ // A non-string pattern name makes the row `unread`, not `false`.
417
+ // Filtering it away silently left an EMPTY pattern list, which
418
+ // `deployRestrictedFrom` maps to `false` — so the row would render
419
+ // "at least one environment accepts a deployment from any branch",
420
+ // a positive claim about the forge derived from data nobody could
421
+ // parse. Every sibling field here already takes the null route: a
422
+ // non-string environment name, a non-numeric ruleset id, a non-boolean
423
+ // `allow_auto_merge`. A confirmed-empty list is a different input and
424
+ // keeps its own `false`.
425
+ if (patterns.some((n) => typeof n !== 'string')) return null;
426
+ patternsByEnv[name] = patterns as string[];
427
+ }
428
+ return deployRestrictedFrom(envs, patternsByEnv);
429
+ }
430
+
431
+ /**
432
+ * Read the four fields the mapper consumes.
433
+ *
434
+ * The repository record is first and GATES the rest: it carries two of the four
435
+ * fields, and the other two are meaningless without the default branch it
436
+ * names. A reader that kept going after it failed would spend calls on answers
437
+ * it could not use, so this one returns immediately — asserted by a test over
438
+ * the call list rather than over the returned value, since both shapes return
439
+ * the same reading.
440
+ */
441
+ export function readForge(repo: string, api: ForgeApi): ForgeReading {
442
+ const record = asObject(api.get(`repos/${repo}`));
443
+ if (record === null) return UNREAD_FORGE;
444
+ const branch = record['default_branch'];
445
+ const autoMerge = record['allow_auto_merge'];
446
+ return {
447
+ defaultBranch: typeof branch === 'string' && branch !== '' ? branch : null,
448
+ // Never coerced: a flag the payload did not carry as a boolean is a flag
449
+ // nobody read, and `unread` is the state reserved for exactly that.
450
+ allowAutoMerge: typeof autoMerge === 'boolean' ? autoMerge : null,
451
+ rulesets: readRulesets(repo, api),
452
+ deployRestricted: readDeployRestricted(repo, api),
453
+ };
454
+ }
455
+
456
+ /**
457
+ * Wrap an API in a whole-command budget.
458
+ *
459
+ * **Per-call timeouts bound each call and nothing bounds their sum.** Five
460
+ * calls at a ten-second ceiling can take fifty seconds without any one of them
461
+ * misbehaving, so the advertised ceiling is not the one a user experiences.
462
+ * This adds the missing deadline, and it does so by returning `null` once the
463
+ * budget is spent — which routes straight into the `unread` degradation the
464
+ * rest of this module already guarantees. Running out of time therefore reads
465
+ * as *nobody looked*, never as *the forge said no*; that direction is the one
466
+ * that matters and it is pinned by a test.
467
+ */
468
+ export function withDeadline(api: ForgeApi, remaining: () => number): ForgeApi {
469
+ return {
470
+ get(apiPath: string, paginate?: boolean): unknown | null {
471
+ return remaining() <= 0 ? null : api.get(apiPath, paginate);
472
+ },
473
+ };
474
+ }
475
+
476
+ /**
477
+ * Milliseconds left of a budget of `ms`, never below zero.
478
+ *
479
+ * The clock is `performance.now`, which is monotonic. `Date.now` is wall-clock
480
+ * and steppable: an NTP correction backwards silently extends the budget past
481
+ * its stated ceiling and one forwards expires it early. The word was in the
482
+ * first draft's comment while the implementation used the steppable clock.
483
+ */
484
+ export function budgetOf(
485
+ ms: number,
486
+ now: () => number = () => performance.now(),
487
+ ): () => number {
488
+ const until = now() + ms;
489
+ return () => Math.max(0, until - now());
490
+ }
491
+
492
+ /** What {@link forgeReadingFor} needs. Every field injectable, so no test spawns. */
493
+ export interface ForgeReadRequest {
494
+ readonly env: Readonly<Record<string, string | undefined>>;
495
+ /**
496
+ * Resolve the repository — a THUNK rather than a value, so the opt-out can
497
+ * run before it. Resolving reads the git remote, which is a subprocess; an
498
+ * opt-out that only skipped the API calls would still spawn `git` to work
499
+ * out a repository it was never going to use, and "this switch makes no
500
+ * network calls" would be true of the HTTP traffic and false of the claim
501
+ * a reader takes from it.
502
+ */
503
+ readonly resolveRepo: () => ForgeTarget | null;
504
+ /**
505
+ * Build the API for the resolved target.
506
+ *
507
+ * A FACTORY rather than a ready API, so the host and working directory the
508
+ * slug came from necessarily reach `gh`. Handing in a pre-built client was
509
+ * how the two halves of the read came to address different repositories.
510
+ */
511
+ readonly apiFor: (target: ForgeTarget) => ForgeApi;
512
+ /** Milliseconds left. Defaults to a {@link FORGE_TOTAL_BUDGET_MS} budget. */
513
+ readonly remaining?: () => number;
514
+ }
515
+
516
+ /**
517
+ * A reading plus the repository it came from.
518
+ *
519
+ * **The slug travels with the rows, and leaving it out was the review's one
520
+ * high finding.** The moment the read went live, *which repository* stopped
521
+ * being obvious: `origin` may point at a fork, a mirror, or — in a consumer
522
+ * install — somebody else's project entirely, and five rows reading `satisfied`
523
+ * say nothing about whose branch is protected. For a block whose own header
524
+ * calls an unstated provenance the guess it exists to refuse, that was the one
525
+ * fact the provenance omitted. `null` when nothing was read.
526
+ */
527
+ export interface ForgeRead {
528
+ readonly repo: string | null;
529
+ readonly reading: ForgeReading;
530
+ }
531
+
532
+ /**
533
+ * The production entry: read the forge, or return the unread reading.
534
+ *
535
+ * Three skips happen BEFORE any process spawns — an opt-out switch, the general
536
+ * offline switch, and an unresolvable repository — and each is asserted on the
537
+ * CALL LIST rather than on the return value, because a reader that queried and
538
+ * then discarded would satisfy an output-only assertion while still paying the
539
+ * latency the opt-out exists to avoid.
540
+ *
541
+ * The `catch` is not defensive padding. `doctor` is the command people run when
542
+ * something is already wrong; a diagnostic that dies because its own optional
543
+ * read threw is strictly worse than one reporting `unread`.
544
+ */
545
+ export function forgeReadingFor(req: ForgeReadRequest): ForgeRead {
546
+ if (forgeReadDisabled(req.env)) return { repo: null, reading: UNREAD_FORGE };
547
+ // The budget is built BEFORE the repository is resolved, because resolving
548
+ // spawns `git` and a budget that starts after it does not bound it.
549
+ const remaining = req.remaining ?? budgetOf(FORGE_TOTAL_BUDGET_MS);
550
+ try {
551
+ const target = req.resolveRepo();
552
+ if (target === null) return { repo: null, reading: UNREAD_FORGE };
553
+ const reading = readForge(target.slug, withDeadline(req.apiFor(target), remaining));
554
+ return { repo: target.slug, reading };
555
+ } catch {
556
+ return { repo: null, reading: UNREAD_FORGE };
557
+ }
558
+ }
@@ -0,0 +1,111 @@
1
+ /**
2
+ * The only host environment variables this package will ever write.
3
+ *
4
+ * WHY ITS OWN MODULE. One place owns the question "which host variable may this
5
+ * package write", and the answer is a two-row allow table rather than a rule
6
+ * applied at a call site. `install.ts` imports it; nothing else writes host env.
7
+ * The move out of `install.ts` also keeps that file, already far over its size
8
+ * cap, from paying for this feature — the same reason the doctor's
9
+ * network-posture block moved into its own module.
10
+ *
11
+ * THE GUARANTEE IS THE SHAPE, not a check. Both traffic variables —
12
+ * `CLAUDE_CODE_DISABLE_NONESSENTIAL_TRAFFIC` and `DISABLE_AUTOUPDATER` — reach
13
+ * the host's update-disabled resolver, the blanket one as its THIRD RUNG
14
+ * (measured on Claude Code 2.1.284, `docs/setup/host-traffic-environment.md`).
15
+ * A writer able to express either could disable a consumer's security updates.
16
+ * Owner-decided 2026-09-30 that it never may, and an allow table of two holds
17
+ * that where a deny list of two would have to stay complete as the host grows
18
+ * more variables.
19
+ *
20
+ * Both writable entries are request SIZE caps. Neither suppresses a call, an
21
+ * update check or a download — they bound how big a request is, never how many.
22
+ */
23
+
24
+ import * as fs from 'node:fs';
25
+ import * as path from 'node:path';
26
+
27
+ import * as YAML from 'yaml';
28
+
29
+ /** The allow table. Two rows, and adding a third is a governance change. */
30
+ export const WRITABLE_HOST_ENV: readonly { readonly variable: string; readonly settingsKey: string }[] = [
31
+ { variable: 'BASH_MAX_OUTPUT_LENGTH', settingsKey: 'bash_max_output_length' },
32
+ { variable: 'MAX_MCP_OUTPUT_TOKENS', settingsKey: 'max_mcp_output_tokens' },
33
+ ];
34
+
35
+ /** Where a project's settings document lives, canonical first. */
36
+ const SETTINGS_CANDIDATES: readonly string[][] = [
37
+ ['agents', 'settings', '.agent-settings.yml'],
38
+ ['.agent-settings.yml'],
39
+ ];
40
+
41
+ /**
42
+ * The project settings document, or `null` when there is none to read.
43
+ *
44
+ * `null` on an absent or unparseable file rather than `{}`: the caller reads a
45
+ * key whose ABSENCE means "write nothing", and collapsing an unreadable file to
46
+ * an empty object would give the same answer as a readable file with the key
47
+ * unset. Both write nothing, but only one of them is a fact.
48
+ */
49
+ export function readSettingsDoc(projectRoot: string): unknown | null {
50
+ for (const parts of SETTINGS_CANDIDATES) {
51
+ const p = path.join(projectRoot, ...parts);
52
+ let text: string;
53
+ try {
54
+ text = fs.readFileSync(p, 'utf-8');
55
+ } catch {
56
+ continue;
57
+ }
58
+ let doc: unknown;
59
+ try {
60
+ doc = YAML.parse(text, { version: '1.1' });
61
+ } catch {
62
+ return null;
63
+ }
64
+ return doc === null || doc === undefined || typeof doc !== 'object' ? null : doc;
65
+ }
66
+ return null;
67
+ }
68
+
69
+ /**
70
+ * The `env` block for the host settings file, or `null` when nothing is set.
71
+ *
72
+ * `null` rather than `{}`: an empty object would still be merged into the
73
+ * consumer's file, leaving an `env: {}` nobody asked for and making "this
74
+ * package wrote nothing" indistinguishable from "this package wrote a blank".
75
+ *
76
+ * A non-integer or non-positive value is IGNORED rather than written. The schema
77
+ * already refuses it; this branch is for a hand-edited settings file, where
78
+ * writing a value the host would silently reinterpret is worse than writing none.
79
+ */
80
+ export function hostEnvBlock(settings: unknown): Record<string, string> | null {
81
+ const root = settings as Record<string, unknown> | null | undefined;
82
+ const he = root?.['host_environment'] as Record<string, unknown> | undefined;
83
+ const caps = he?.['request_size_caps'] as Record<string, unknown> | undefined;
84
+ if (caps === undefined) return null;
85
+ const out: Record<string, string> = {};
86
+ for (const { variable, settingsKey } of WRITABLE_HOST_ENV) {
87
+ const v = caps[settingsKey];
88
+ if (typeof v === 'number' && Number.isInteger(v) && v > 0) out[variable] = String(v);
89
+ }
90
+ return Object.keys(out).length === 0 ? null : out;
91
+ }
92
+
93
+ /**
94
+ * The host bridge object with the `env` block merged in, if there is one.
95
+ *
96
+ * Takes the bridge and returns it rather than being called for its side effect,
97
+ * so the caller is one expression and `install.ts` — already far over its size
98
+ * cap — pays one line for this feature instead of six. `announce` is injected
99
+ * rather than imported: this module has no opinion about how a caller reports,
100
+ * and importing the installer's logger would make a leaf depend on its consumer.
101
+ */
102
+ export function withHostEnv(
103
+ bridge: Record<string, unknown>,
104
+ projectRoot: string,
105
+ announce: (msg: string) => void,
106
+ ): Record<string, unknown> {
107
+ const env = hostEnvBlock(readSettingsDoc(projectRoot));
108
+ if (env === null) return bridge;
109
+ announce(`.claude/settings.json: wrote ${Object.keys(env).join(', ')} (request-size caps)`);
110
+ return { ...bridge, env };
111
+ }