@ethlete/agent-rules 0.1.0-next.4 → 0.1.0-next.5

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/CHANGELOG.md CHANGED
@@ -1,5 +1,21 @@
1
1
  # @ethlete/agent-rules
2
2
 
3
+ ## 0.1.0-next.5
4
+
5
+ ### Minor Changes
6
+
7
+ - [#3048](https://github.com/ethlete-io/ethdk/pull/3048) [`6e19999`](https://github.com/ethlete-io/ethdk/commit/6e199997f51b88aaa1860a56a6b96be057ba1205) Thanks [@github-actions](https://github.com/apps/github-actions)! - The `context-warning` hook now runs under Codex as well as Claude Code, registered in
8
+ `.codex/hooks.json` whenever the `codex` target is on.
9
+
10
+ - [#3049](https://github.com/ethlete-io/ethdk/pull/3049) [`a606dda`](https://github.com/ethlete-io/ethdk/commit/a606dda0695ac8cc4370816bf3ff1c0814436091) Thanks [@TomTomB](https://github.com/TomTomB)! - Add the `figma-export` skill, guiding agents through reconciling a component against a Figma "copy as CSS" export.
11
+
12
+ ### Patch Changes
13
+
14
+ - [#3048](https://github.com/ethlete-io/ethdk/pull/3048) [`fc56189`](https://github.com/ethlete-io/ethdk/commit/fc56189450a45f2b5819d40945a71205a6d67ba0) Thanks [@github-actions](https://github.com/apps/github-actions)! - The `query` skill now says to prefer `withArgs` over passing `args` to `execute()`, and when the imperative form is still right.
15
+
16
+ - [#3048](https://github.com/ethlete-io/ethdk/pull/3048) [`a16cc69`](https://github.com/ethlete-io/ethdk/commit/a16cc698a3cd3f14d0419d8f9710929fb41b4711) Thanks [@github-actions](https://github.com/apps/github-actions)! - Figma export skill: read an SVG frame as well as the CSS dump, with a `dump-figma-svg.py`
17
+ that prints its box tree and measures the auto-layout gaps.
18
+
3
19
  ## 0.1.0-next.4
4
20
 
5
21
  ### Minor Changes
package/README.md CHANGED
@@ -115,8 +115,8 @@ a repo without `@ethlete/query` never sees the query guide.
115
115
 
116
116
  ## Hooks (opt-in)
117
117
 
118
- Claude Code hooks run commands on the developer's machine, so none are emitted by
119
- default - opt in per hook in the config:
118
+ Hooks run commands on the developer's machine, so none are emitted by default - opt in
119
+ per hook in the config:
120
120
 
121
121
  ```json
122
122
  {
@@ -124,17 +124,31 @@ default - opt in per hook in the config:
124
124
  }
125
125
  ```
126
126
 
127
- `sync` writes the script to `.claude/hooks/ethlete/` and registers it in
128
- `.claude/settings.json` (your own entries are left untouched); removing the name from
129
- `hooks` unregisters and deletes it again.
127
+ They are emitted for whichever of the `claude` and `codex` targets is enabled:
128
+
129
+ | Target | Script | Registered in |
130
+ | -------- | ------------------------ | ----------------------- |
131
+ | `claude` | `.claude/hooks/ethlete/` | `.claude/settings.json` |
132
+ | `codex` | `.codex/hooks/ethlete/` | `.codex/hooks.json` |
133
+
134
+ Your own entries in those files are left untouched; removing the name from `hooks`
135
+ unregisters and deletes the script again. Codex only loads project-local hooks once the
136
+ `.codex/` layer is trusted, and honours `[features] hooks = false`.
130
137
 
131
138
  Available hooks:
132
139
 
133
- - **`context-warning`** - warns once per tier (and instructs Claude) when the session
134
- context crosses 70% / 85% of the token budget, recommending `/handoff`. The budget is
135
- capped at the 200k long-context pricing boundary: on 1M-window models every request
136
- past 200k input tokens bills the whole context at a premium rate, so the warnings
137
- fire at ~140k/~170k instead of deep into the expensive range.
140
+ - **`context-warning`** - warns once per tier (and instructs the agent) when the session
141
+ context crosses 70% / 85% of the token budget, recommending a handoff. Under Claude the
142
+ budget is capped at the 200k long-context pricing boundary: on 1M-window models every
143
+ request past 200k input tokens bills the whole context at a premium rate, so the
144
+ warnings fire at ~140k/~170k instead of deep into the expensive range. Codex has no
145
+ such boundary, so its budget is the model's own reported context window and the
146
+ warnings are pure occupancy.
147
+
148
+ Two things are Claude-only: the separate user-facing line (Codex documents only
149
+ `additionalContext`, so there the warning is folded into the text the model is told to
150
+ relay), and the auto-mode escalation that writes the handoff file unprompted - Codex's
151
+ `permission_mode` values are undocumented, so no value enables it.
138
152
 
139
153
  Hooks can be turned off per machine - see the local config below.
140
154
 
@@ -153,6 +167,9 @@ differ per developer, without touching any committed file:
153
167
  - **`disableHooks`** - `true` disables every generated hook; an array
154
168
  (`["context-warning"]`) just the named ones. The hook scripts read the file at
155
169
  runtime, so toggling takes effect on the next prompt - no `sync` needed.
170
+ - **`disableAutoHandoffSave`** - keeps the `context-warning` hook's tiered warnings but
171
+ drops the auto-mode escalation: at the critical tier it recommends `/handoff` instead
172
+ of saving the handoff file itself.
156
173
  - **`sdkSourcePath`** - a local `ethlete-sdk` checkout. The `sdk-source` and
157
174
  `sdk-local-build` skills read it when the agent needs the SDK's own sources, or has to
158
175
  build the SDK and install it here through a `file:` dependency. A relative path is
@@ -2,31 +2,45 @@
2
2
  """UserPromptSubmit hook: warn when the context window is getting large.
3
3
 
4
4
  Reads the hook input JSON from stdin, estimates the current context size from
5
- the last main-chain assistant message in the session transcript, and emits a
6
- warning (visible to both the user and Claude) when it crosses a threshold.
7
- Recommends the /handoff skill so work can continue in a fresh session.
5
+ the session transcript, and emits a warning (visible to both the user and the
6
+ agent) when it crosses a threshold. Recommends the handoff skill so work can
7
+ continue in a fresh session.
8
+
9
+ Runs under both Claude Code and Codex, selected by `--agent` on the command
10
+ line rather than sniffed from the payload — the generator writes the flag, so
11
+ the two never have to be told apart at runtime. The two differ in three ways
12
+ that matter here, all captured in AGENT_PROFILES:
13
+
14
+ * Transcript format. Claude writes one JSON object per message with
15
+ `message.usage`; Codex writes a rollout stream whose `token_count` events
16
+ carry a TokenUsageInfo. Codex's rollout format is explicitly not a stable
17
+ interface, so the parser searches each line for the usage object instead of
18
+ walking a fixed path.
19
+ * Context budget. Claude's budget is capped at the 200k long-context pricing
20
+ boundary: on models with a larger window, every request past that point
21
+ bills the entire context at the premium rate, which costs far more than
22
+ handing off into a fresh session ever would. Codex has no such boundary, so
23
+ its budget is just the window.
24
+ * How a handoff is invoked. Claude has /handoff and /clear; Codex has neither
25
+ and reads the skill from disk.
8
26
 
9
27
  At the critical tier, if the session's permission_mode is "auto", the
10
28
  instruction escalates from "recommend" to "just do it": auto mode already
11
- means the user wants Claude acting without stopping to ask, and clearing +
29
+ means the user wants the agent acting without stopping to ask, and clearing +
12
30
  resuming can't be triggered programmatically (no hook or tool can submit
13
31
  input to a running session), so the best available automation is to save the
14
- handoff file itself immediately rather than waiting for Claude to notice a
15
- natural stopping point.
16
-
17
- The thresholds are fractions of a token budget, and the budget is capped at the
18
- 200k long-context pricing boundary: on models with a larger window, every
19
- request past that point bills the entire context at the premium rate, which
20
- costs far more than handing off into a fresh session ever would.
32
+ handoff file itself immediately rather than waiting for a natural stopping
33
+ point. Codex's permission_mode value set is undocumented, so its profile lists
34
+ no auto modes and the escalation stays off there.
21
35
 
22
36
  Warns once per tier per session (state kept in a temp file); re-arms itself
23
- if the context shrinks again (e.g. after /compact).
37
+ if the context shrinks again (e.g. after a compaction).
24
38
 
25
39
  Can be disabled per machine via a gitignored ethlete-agents.config.local.json
26
40
  at the repo root: {"disableHooks": true} or {"disableHooks": ["context-warning"]}.
27
41
  To keep the tiered warnings but drop just the auto-mode auto-save escalation,
28
42
  use {"disableAutoHandoffSave": true} instead - the critical tier then falls
29
- back to recommending /handoff, same as non-auto mode.
43
+ back to recommending a handoff, same as non-auto mode.
30
44
 
31
45
  Fail-safe: any error exits 0 with no output — the hook must never block a prompt.
32
46
  """
@@ -64,10 +78,87 @@ CONTEXT_WINDOWS = (
64
78
  )
65
79
  DEFAULT_WINDOW = 200_000
66
80
 
67
-
68
- def load_local_config():
81
+ AGENT_PROFILES = {
82
+ "claude": {
83
+ "premium_boundary": PREMIUM_BOUNDARY,
84
+ # Claude's transcript reports no window, so it is resolved from the model id.
85
+ "default_window": None,
86
+ "auto_modes": ("auto",),
87
+ "emits_system_message": True,
88
+ "act_now": "Run /handoff now and continue in a fresh session.",
89
+ "act_later": "consider /handoff to continue in a fresh session",
90
+ "recommend": "recommend the user run /handoff to save state and start a fresh session",
91
+ "suggest": "suggest the user run /handoff to save state and start a fresh session",
92
+ "save_now": (
93
+ "run the handoff skill's save mode right now (finish only an in-flight atomic "
94
+ "edit first, nothing new). Then tell the user exactly which handoff file was "
95
+ "written and that they should run /clear, then '/handoff resume <slug>', to "
96
+ "continue — clearing and resuming can't be done programmatically, so this is "
97
+ "the one step still on them."
98
+ ),
99
+ },
100
+ "codex": {
101
+ "premium_boundary": None,
102
+ # Only reached if a rollout omits model_context_window; the CONTEXT_WINDOWS table
103
+ # holds Claude model ids and would never match a Codex one.
104
+ "default_window": 272_000,
105
+ # Codex's permission_mode values are undocumented; until one is confirmed to mean
106
+ # "never ask", no value enables the auto-save escalation.
107
+ "auto_modes": (),
108
+ # Only hookSpecificOutput.additionalContext is documented for Codex, so the
109
+ # user-facing line is folded into the model-facing text instead.
110
+ "emits_system_message": False,
111
+ "act_now": "Save a handoff now and continue in a fresh session.",
112
+ "act_later": "consider saving a handoff and continuing in a fresh session",
113
+ "recommend": (
114
+ "tell the user you are near the context limit, then follow "
115
+ ".agents/skills/ethlete-handoff/SKILL.md to save a handoff and have them start a "
116
+ "fresh session"
117
+ ),
118
+ "suggest": (
119
+ "suggest saving a handoff via .agents/skills/ethlete-handoff/SKILL.md and "
120
+ "continuing in a fresh session"
121
+ ),
122
+ "save_now": (
123
+ "follow .agents/skills/ethlete-handoff/SKILL.md and save a handoff right now "
124
+ "(finish only an in-flight atomic edit first, nothing new). Then tell the user "
125
+ "exactly which handoff file was written and that they should start a fresh "
126
+ "codex session and resume from it."
127
+ ),
128
+ },
129
+ }
130
+
131
+
132
+ def agent_name(argv):
133
+ """The --agent value, defaulting to claude — older registrations pass no flag."""
134
+ for index, arg in enumerate(argv):
135
+ if arg == "--agent" and index + 1 < len(argv):
136
+ return argv[index + 1]
137
+ if arg.startswith("--agent="):
138
+ return arg.split("=", 1)[1]
139
+ return "claude"
140
+
141
+
142
+ def repo_root(data):
143
+ """Repo root: the env var the agent sets, else derived from this script's own location.
144
+
145
+ The script always lives at <root>/.<agent>/hooks/ethlete/context-warning.py, so walking
146
+ four levels up works for any agent that has no project-dir variable of its own.
147
+ """
148
+ for variable in ("CLAUDE_PROJECT_DIR", "CODEX_PROJECT_DIR"):
149
+ value = os.environ.get(variable)
150
+ if value:
151
+ return value
152
+ here = os.path.abspath(__file__)
153
+ derived = os.path.dirname(os.path.dirname(os.path.dirname(os.path.dirname(here))))
154
+ if os.path.isfile(os.path.join(derived, LOCAL_CONFIG_FILE)):
155
+ return derived
156
+ cwd = data.get("cwd")
157
+ return cwd if isinstance(cwd, str) and cwd else derived
158
+
159
+
160
+ def load_local_config(root):
69
161
  """Parsed ethlete-agents.config.local.json at the repo root, or {} if missing/unreadable."""
70
- root = os.environ.get("CLAUDE_PROJECT_DIR")
71
162
  if not root:
72
163
  return {}
73
164
  try:
@@ -98,10 +189,11 @@ def window_for(model):
98
189
  return DEFAULT_WINDOW
99
190
 
100
191
 
101
- def context_state(transcript_path):
102
- """(tokens, model) from the last main-chain assistant message.
192
+ def claude_context_state(transcript_path):
193
+ """(tokens, model, window) from the last main-chain assistant message.
103
194
 
104
- tokens ≈ its total input tokens (fresh + cache read + cache creation).
195
+ tokens ≈ its total input tokens (fresh + cache read + cache creation). Claude reports no
196
+ window in the transcript, so it is resolved from the model id by the caller.
105
197
  """
106
198
  last_usage = None
107
199
  last_model = None
@@ -119,111 +211,151 @@ def context_state(transcript_path):
119
211
  last_usage = usage
120
212
  last_model = message.get("model") or last_model
121
213
  if not last_usage:
122
- return 0, last_model
214
+ return 0, last_model, None
123
215
  tokens = (
124
216
  last_usage.get("input_tokens", 0)
125
217
  + last_usage.get("cache_read_input_tokens", 0)
126
218
  + last_usage.get("cache_creation_input_tokens", 0)
127
219
  )
128
- return tokens, last_model
220
+ return tokens, last_model, None
221
+
222
+
223
+ def find_token_usage_info(node):
224
+ """The deepest TokenUsageInfo-shaped dict in a parsed rollout line, or None.
225
+
226
+ Codex documents its rollout format as unstable, so this searches for the shape
227
+ (a dict carrying `last_token_usage`) rather than walking a fixed key path.
228
+ """
229
+ if isinstance(node, dict):
230
+ if isinstance(node.get("last_token_usage"), dict):
231
+ return node
232
+ for value in node.values():
233
+ found = find_token_usage_info(value)
234
+ if found:
235
+ return found
236
+ elif isinstance(node, list):
237
+ for value in node:
238
+ found = find_token_usage_info(value)
239
+ if found:
240
+ return found
241
+ return None
242
+
243
+
244
+ def codex_context_state(transcript_path):
245
+ """(tokens, model, window) from the last token_count event in a Codex rollout.
246
+
247
+ tokens is the last request's `total_tokens` — Codex's own `tokens_in_context_window()`
248
+ is exactly that field, and `last_token_usage` is replaced per request while
249
+ `total_token_usage` accumulates across the whole session.
250
+ """
251
+ last_info = None
252
+ last_model = None
253
+ with open(transcript_path, encoding="utf-8") as f:
254
+ for line in f:
255
+ try:
256
+ obj = json.loads(line)
257
+ except json.JSONDecodeError:
258
+ continue
259
+ model = (obj.get("payload") or {}).get("model") if isinstance(obj.get("payload"), dict) else None
260
+ if isinstance(model, str) and model:
261
+ last_model = model
262
+ info = find_token_usage_info(obj)
263
+ if info:
264
+ last_info = info
265
+ if not last_info:
266
+ return 0, last_model, None
267
+ usage = last_info.get("last_token_usage") or {}
268
+ window = last_info.get("model_context_window")
269
+ return usage.get("total_tokens", 0), last_model, window if isinstance(window, int) else None
270
+
129
271
 
272
+ CONTEXT_READERS = {"claude": claude_context_state, "codex": codex_context_state}
130
273
 
131
- def messages(tier, tokens, budget, priced, auto_mode):
274
+
275
+ def messages(profile, tier, tokens, budget, priced, auto_mode):
132
276
  """(systemMessage, additionalContext) for a tier.
133
277
 
134
278
  priced: the budget is the pricing boundary, not the window — the reason to
135
279
  hand off is cost, not an imminent auto-compact.
136
- auto_mode: at the critical tier, escalates from "recommend /handoff" to
280
+ auto_mode: at the critical tier, escalates from "recommend a handoff" to
137
281
  "save it now" — see the module docstring for why.
138
282
  """
139
283
  k = f"~{tokens // 1000}k"
140
284
  pct = round(tokens / budget * 100)
141
285
  budget_k = f"{budget // 1000}k"
142
- if tier == 2 and auto_mode and priced:
143
- return (
144
- f"🔴 Context is at {k} tokens — about to cross the {budget_k} long-context "
145
- f"pricing boundary. Auto mode is active: saving a handoff now.",
146
- f"[context-warning hook] The context is at {k} tokens — {pct}% of the "
147
- f"{budget_k} long-context pricing boundary. Auto mode is active, so don't just "
148
- "recommend a handoff — run the handoff skill's save mode right now (finish only "
149
- "an in-flight atomic edit first, nothing new). Then tell the user exactly which "
150
- "handoff file was written and that they should run /clear, then "
151
- "'/handoff resume <slug>', to continue — clearing and resuming can't be done "
152
- "programmatically, so this is the one step still on them.",
153
- )
154
- if tier == 2 and auto_mode:
155
- return (
156
- f"🔴 Context is at {k} tokens ({pct}% of the {budget_k} window) — "
157
- f"auto-compact is imminent. Auto mode is active: saving a handoff now.",
158
- f"[context-warning hook] The context window is at {k} tokens — {pct}% of "
159
- f"this model's {budget_k} window (critical, ≥{int(CRITICAL_FRACTION * 100)}%). "
160
- "Auto mode is active, so don't just recommend a handoff — run the handoff "
161
- "skill's save mode right now (finish only an in-flight atomic edit first, "
162
- "nothing new). Then tell the user exactly which handoff file was written and "
163
- "that they should run /clear, then '/handoff resume <slug>', to continue — "
164
- "clearing and resuming can't be done programmatically, so this is the one step "
165
- "still on them.",
286
+
287
+ if priced:
288
+ approach = "about to cross" if tier == 2 else "approaching"
289
+ headline = f"Context is at {k} tokens — {approach} the {budget_k} long-context pricing boundary"
290
+ detail = (
291
+ f"The context is at {k} tokens — {pct}% of the {budget_k} long-context "
292
+ f"pricing boundary."
166
293
  )
167
- if tier == 2 and priced:
168
- return (
169
- f"🔴 Context is at {k} tokens — about to cross the {budget_k} long-context "
170
- f"pricing boundary, after which every request bills the whole context at the "
171
- f"premium rate. Run /handoff now and continue in a fresh session.",
172
- f"[context-warning hook] The context is at {k} tokens — {pct}% of the "
173
- f"{budget_k} long-context pricing boundary. Past it every request is billed at "
174
- "the premium rate. Finish only the immediate step, then recommend the user run "
175
- "/handoff to save state and start a fresh session. Do not start new sub-tasks.",
294
+ else:
295
+ headline = f"Context is at {k} tokens ({pct}% of the {budget_k} window)"
296
+ detail = (
297
+ f"The context window is at {k} tokens — {pct}% of this model's "
298
+ f"{budget_k} window"
176
299
  )
300
+
177
301
  if tier == 2:
178
- return (
179
- f"🔴 Context is at {k} tokens ({pct}% of the {budget_k} window) — "
180
- f"auto-compact is imminent. Run /handoff now and continue in a fresh session.",
181
- f"[context-warning hook] The context window is at {k} tokens — {pct}% of "
182
- f"this model's {budget_k} window (critical, ≥{int(CRITICAL_FRACTION * 100)}%). "
183
- "Finish only the immediate step, then recommend the user run /handoff to "
184
- "save state and start a fresh session. Do not start new sub-tasks.",
302
+ threshold = f" (critical, ≥{int(CRITICAL_FRACTION * 100)}%)"
303
+ detail = detail if priced else f"{detail}{threshold}."
304
+ pressure = (
305
+ "Past it every request is billed at the premium rate. "
306
+ if priced
307
+ else ""
185
308
  )
186
- if priced:
309
+ tail = "" if priced else " — auto-compact is imminent"
310
+ if auto_mode:
311
+ return (
312
+ f"🔴 {headline}{tail}. Auto mode is active: saving a handoff now.",
313
+ f"[context-warning hook] {detail} {pressure}Auto mode is active, so don't "
314
+ f"just recommend a handoff — {profile['save_now']}",
315
+ )
187
316
  return (
188
- f"🟡 Context is at {k} tokens, approaching the {budget_k} long-context "
189
- f"pricing boundary. At the next natural stopping point, consider /handoff "
190
- f"to continue in a fresh session.",
191
- f"[context-warning hook] The context is at {k} tokens — {pct}% of the "
192
- f"{budget_k} long-context pricing boundary, past which every request is "
193
- "billed at the premium rate. When the current task reaches a natural "
194
- "stopping point, suggest the user run /handoff to save state and start a "
195
- "fresh session. Keep working normally until then.",
317
+ f"🔴 {headline}{tail}. {profile['act_now']}",
318
+ f"[context-warning hook] {detail} {pressure}Finish only the immediate step, "
319
+ f"then {profile['recommend']}. Do not start new sub-tasks.",
196
320
  )
321
+
322
+ threshold = f" (≥{int(WARN_FRACTION * 100)}%)"
323
+ detail = detail if priced else f"{detail}{threshold}."
324
+ pressure = "Past it every request is billed at the premium rate. " if priced else ""
197
325
  return (
198
- f"🟡 Context is at {k} tokens ({pct}% of the {budget_k} window). At the next "
199
- f"natural stopping point, consider /handoff to continue in a fresh session.",
200
- f"[context-warning hook] The context window is at {k} tokens — {pct}% of "
201
- f"this model's {budget_k} window (≥{int(WARN_FRACTION * 100)}%). When the "
202
- "current task reaches a natural stopping point, suggest the user run "
203
- "/handoff to save state and start a fresh session. Keep working normally until then.",
326
+ f"🟡 {headline}. At the next natural stopping point, {profile['act_later']}.",
327
+ f"[context-warning hook] {detail} {pressure}When the current task reaches a "
328
+ f"natural stopping point, {profile['suggest']}. Keep working normally until then.",
204
329
  )
205
330
 
206
331
 
207
332
  def main():
208
- local_config = load_local_config()
209
- if disabled_locally(local_config):
333
+ agent = agent_name(sys.argv[1:])
334
+ profile = AGENT_PROFILES.get(agent)
335
+ if not profile:
210
336
  return
337
+
211
338
  data = json.load(sys.stdin)
339
+ local_config = load_local_config(repo_root(data))
340
+ if disabled_locally(local_config):
341
+ return
342
+
212
343
  transcript_path = data.get("transcript_path")
213
344
  session_id = data.get("session_id", "unknown")
214
- auto_mode = data.get("permission_mode") == "auto" and not auto_handoff_save_disabled(local_config)
345
+ auto_mode = data.get("permission_mode") in profile["auto_modes"] and not auto_handoff_save_disabled(local_config)
215
346
  if not transcript_path or not os.path.isfile(transcript_path):
216
347
  return
217
348
 
218
- tokens, model = context_state(transcript_path)
219
- window = window_for(model)
220
- budget = min(window, PREMIUM_BOUNDARY)
349
+ tokens, model, reported_window = CONTEXT_READERS[agent](transcript_path)
350
+ window = reported_window or profile["default_window"] or window_for(model)
351
+ boundary = profile["premium_boundary"]
352
+ budget = min(window, boundary) if boundary else window
221
353
  warn_tokens = int(budget * WARN_FRACTION)
222
354
  critical_tokens = int(budget * CRITICAL_FRACTION)
223
355
  tier = 2 if tokens >= critical_tokens else 1 if tokens >= warn_tokens else 0
224
356
 
225
357
  state_file = os.path.join(
226
- tempfile.gettempdir(), f"claude-context-warning-{session_id}"
358
+ tempfile.gettempdir(), f"{agent}-context-warning-{session_id}"
227
359
  )
228
360
  prev_tier = 0
229
361
  try:
@@ -243,21 +375,23 @@ def main():
243
375
  return # already warned at this tier (or context shrank — state re-armed above)
244
376
 
245
377
  system_message, additional_context = messages(
246
- tier, tokens, budget, budget < window, auto_mode
378
+ profile, tier, tokens, budget, budget < window, auto_mode
247
379
  )
248
380
 
249
- print(
250
- json.dumps(
251
- {
252
- "systemMessage": system_message,
253
- "suppressOutput": True,
254
- "hookSpecificOutput": {
255
- "hookEventName": "UserPromptSubmit",
256
- "additionalContext": additional_context,
257
- },
258
- }
259
- )
260
- )
381
+ if not profile["emits_system_message"]:
382
+ additional_context = f"{additional_context}\n\nTell the user: {system_message}"
383
+
384
+ payload = {
385
+ "hookSpecificOutput": {
386
+ "hookEventName": "UserPromptSubmit",
387
+ "additionalContext": additional_context,
388
+ }
389
+ }
390
+ if profile["emits_system_message"]:
391
+ payload["systemMessage"] = system_message
392
+ payload["suppressOutput"] = True
393
+
394
+ print(json.dumps(payload))
261
395
 
262
396
 
263
397
  if __name__ == "__main__":