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

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 (76) hide show
  1. package/CHANGELOG.md +26 -0
  2. package/README.md +192 -11
  3. package/content/git-hooks/post-checkout.sh +10 -0
  4. package/content/git-hooks/pre-push.sh +5 -0
  5. package/content/hooks/context-warning.py +259 -97
  6. package/content/skills/figma-export/SKILL.md +193 -0
  7. package/content/skills/figma-export/dump-figma-layers.py +83 -0
  8. package/content/skills/figma-export/dump-figma-svg.py +235 -0
  9. package/content/skills/figma-export/measure-template.mjs +87 -0
  10. package/content/skills/git-flow/SKILL.md +83 -0
  11. package/content/skills/handoff/SKILL.md +4 -0
  12. package/content/skills/query/SKILL.md +23 -13
  13. package/package.json +12 -1
  14. package/src/index.js +10 -5
  15. package/src/index.js.map +1 -1
  16. package/src/lib/config.d.ts +30 -2
  17. package/src/lib/config.js +20 -3
  18. package/src/lib/config.js.map +1 -1
  19. package/src/lib/git-flow/build.d.ts +35 -0
  20. package/src/lib/git-flow/build.js +24 -0
  21. package/src/lib/git-flow/build.js.map +1 -0
  22. package/src/lib/git-flow/config.d.ts +59 -0
  23. package/src/lib/git-flow/config.js +50 -0
  24. package/src/lib/git-flow/config.js.map +1 -0
  25. package/src/lib/git-flow/index.d.ts +5 -0
  26. package/src/lib/git-flow/index.js +9 -0
  27. package/src/lib/git-flow/index.js.map +1 -0
  28. package/src/lib/git-flow/parse.d.ts +49 -0
  29. package/src/lib/git-flow/parse.js +274 -0
  30. package/src/lib/git-flow/parse.js.map +1 -0
  31. package/src/lib/git-flow/start.d.ts +22 -0
  32. package/src/lib/git-flow/start.js +24 -0
  33. package/src/lib/git-flow/start.js.map +1 -0
  34. package/src/lib/git-flow/validate.d.ts +34 -0
  35. package/src/lib/git-flow/validate.js +72 -0
  36. package/src/lib/git-flow/validate.js.map +1 -0
  37. package/src/lib/git-flow-command.d.ts +4 -0
  38. package/src/lib/git-flow-command.js +157 -0
  39. package/src/lib/git-flow-command.js.map +1 -0
  40. package/src/lib/git-flow-repair.d.ts +17 -0
  41. package/src/lib/git-flow-repair.js +150 -0
  42. package/src/lib/git-flow-repair.js.map +1 -0
  43. package/src/lib/git-flow-start.d.ts +20 -0
  44. package/src/lib/git-flow-start.js +147 -0
  45. package/src/lib/git-flow-start.js.map +1 -0
  46. package/src/lib/git.d.ts +27 -0
  47. package/src/lib/git.js +49 -0
  48. package/src/lib/git.js.map +1 -0
  49. package/src/lib/gitlab.d.ts +35 -0
  50. package/src/lib/gitlab.js +98 -0
  51. package/src/lib/gitlab.js.map +1 -0
  52. package/src/lib/jira.d.ts +28 -0
  53. package/src/lib/jira.js +83 -0
  54. package/src/lib/jira.js.map +1 -0
  55. package/src/lib/owned-paths.js +19 -1
  56. package/src/lib/owned-paths.js.map +1 -1
  57. package/src/lib/plan.js +29 -5
  58. package/src/lib/plan.js.map +1 -1
  59. package/src/lib/prompt.d.ts +8 -0
  60. package/src/lib/prompt.js +27 -0
  61. package/src/lib/prompt.js.map +1 -0
  62. package/src/lib/render.d.ts +12 -1
  63. package/src/lib/render.js +23 -6
  64. package/src/lib/render.js.map +1 -1
  65. package/src/lib/targets/claude-hooks.d.ts +1 -23
  66. package/src/lib/targets/claude-hooks.js +15 -84
  67. package/src/lib/targets/claude-hooks.js.map +1 -1
  68. package/src/lib/targets/codex-hooks.d.ts +12 -0
  69. package/src/lib/targets/codex-hooks.js +33 -0
  70. package/src/lib/targets/codex-hooks.js.map +1 -0
  71. package/src/lib/targets/git-hooks.d.ts +25 -0
  72. package/src/lib/targets/git-hooks.js +70 -0
  73. package/src/lib/targets/git-hooks.js.map +1 -0
  74. package/src/lib/targets/hooks-shared.d.ts +38 -0
  75. package/src/lib/targets/hooks-shared.js +95 -0
  76. package/src/lib/targets/hooks-shared.js.map +1 -0
@@ -2,31 +2,47 @@
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 a slash command and /clear; Codex has
25
+ neither and reads the skill from disk. The skill's own name differs per repo
26
+ (`ethlete-handoff` where the generator installed it, `handoff` where the repo
27
+ ships its own copy), so it is resolved at runtime — see handoff_skill().
8
28
 
9
29
  At the critical tier, if the session's permission_mode is "auto", the
10
30
  instruction escalates from "recommend" to "just do it": auto mode already
11
- means the user wants Claude acting without stopping to ask, and clearing +
31
+ means the user wants the agent acting without stopping to ask, and clearing +
12
32
  resuming can't be triggered programmatically (no hook or tool can submit
13
33
  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.
34
+ handoff file itself immediately rather than waiting for a natural stopping
35
+ point. Codex's permission_mode value set is undocumented, so its profile lists
36
+ no auto modes and the escalation stays off there.
21
37
 
22
38
  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).
39
+ if the context shrinks again (e.g. after a compaction).
24
40
 
25
41
  Can be disabled per machine via a gitignored ethlete-agents.config.local.json
26
42
  at the repo root: {"disableHooks": true} or {"disableHooks": ["context-warning"]}.
27
43
  To keep the tiered warnings but drop just the auto-mode auto-save escalation,
28
44
  use {"disableAutoHandoffSave": true} instead - the critical tier then falls
29
- back to recommending /handoff, same as non-auto mode.
45
+ back to recommending a handoff, same as non-auto mode.
30
46
 
31
47
  Fail-safe: any error exits 0 with no output — the hook must never block a prompt.
32
48
  """
@@ -64,10 +80,110 @@ CONTEXT_WINDOWS = (
64
80
  )
65
81
  DEFAULT_WINDOW = 200_000
66
82
 
83
+ AGENT_PROFILES = {
84
+ "claude": {
85
+ "premium_boundary": PREMIUM_BOUNDARY,
86
+ # Claude's transcript reports no window, so it is resolved from the model id.
87
+ "default_window": None,
88
+ "auto_modes": ("auto",),
89
+ "emits_system_message": True,
90
+ "act_now": "Run /{handoff} now and continue in a fresh session.",
91
+ "act_later": "consider /{handoff} to continue in a fresh session",
92
+ "recommend": "recommend the user run /{handoff} to save state and start a fresh session",
93
+ "suggest": "suggest the user run /{handoff} to save state and start a fresh session",
94
+ "save_now": (
95
+ "run the handoff skill's save mode right now (finish only an in-flight atomic "
96
+ "edit first, nothing new). Then tell the user exactly which handoff file was "
97
+ "written and that they should run /clear, then '/{handoff} resume <slug>', to "
98
+ "continue — clearing and resuming can't be done programmatically, so this is "
99
+ "the one step still on them."
100
+ ),
101
+ },
102
+ "codex": {
103
+ "premium_boundary": None,
104
+ # Only reached if a rollout omits model_context_window; the CONTEXT_WINDOWS table
105
+ # holds Claude model ids and would never match a Codex one.
106
+ "default_window": 272_000,
107
+ # Codex's permission_mode values are undocumented; until one is confirmed to mean
108
+ # "never ask", no value enables the auto-save escalation.
109
+ "auto_modes": (),
110
+ # Only hookSpecificOutput.additionalContext is documented for Codex, so the
111
+ # user-facing line is folded into the model-facing text instead.
112
+ "emits_system_message": False,
113
+ "act_now": "Save a handoff now and continue in a fresh session.",
114
+ "act_later": "consider saving a handoff and continuing in a fresh session",
115
+ "recommend": (
116
+ "tell the user you are near the context limit, then follow "
117
+ ".agents/skills/{handoff}/SKILL.md to save a handoff and have them start a "
118
+ "fresh session"
119
+ ),
120
+ "suggest": (
121
+ "suggest saving a handoff via .agents/skills/{handoff}/SKILL.md and "
122
+ "continuing in a fresh session"
123
+ ),
124
+ "save_now": (
125
+ "follow .agents/skills/{handoff}/SKILL.md and save a handoff right now "
126
+ "(finish only an in-flight atomic edit first, nothing new). Then tell the user "
127
+ "exactly which handoff file was written and that they should start a fresh "
128
+ "codex session and resume from it."
129
+ ),
130
+ },
131
+ }
132
+
133
+
134
+ def handoff_skill(root):
135
+ """Name of the handoff skill installed in this repo.
136
+
137
+ The generator prefixes every skill it writes, so the command is /ethlete-handoff in a
138
+ consumer repo — but a repo that excludes the generated copy and ships its own (the SDK
139
+ itself) has a plain /handoff instead. Naming the wrong one sends the user to a slash
140
+ command that does not exist, so it is read off disk rather than assumed.
141
+ """
142
+ for name in ("ethlete-handoff", "handoff"):
143
+ for skills_dir in (".claude/skills", ".agents/skills"):
144
+ if os.path.isdir(os.path.join(root or ".", skills_dir, name)):
145
+ return name
146
+ return "ethlete-handoff"
147
+
148
+
149
+ def resolve_profile(profile, skill):
150
+ """Fills the installed handoff skill's name into a profile's message fragments."""
151
+ return {
152
+ key: value.replace("{handoff}", skill) if isinstance(value, str) else value
153
+ for key, value in profile.items()
154
+ }
155
+
156
+
157
+ def agent_name(argv):
158
+ """The --agent value, defaulting to claude — older registrations pass no flag."""
159
+ for index, arg in enumerate(argv):
160
+ if arg == "--agent" and index + 1 < len(argv):
161
+ return argv[index + 1]
162
+ if arg.startswith("--agent="):
163
+ return arg.split("=", 1)[1]
164
+ return "claude"
67
165
 
68
- def load_local_config():
166
+
167
+ def repo_root(data):
168
+ """Repo root: the env var the agent sets, else derived from this script's own location.
169
+
170
+ The script always lives at <root>/.<agent>/hooks/ethlete/context-warning.py, so walking
171
+ four levels up works for any agent that has no project-dir variable of its own.
172
+ """
173
+ for variable in ("CLAUDE_PROJECT_DIR", "CODEX_PROJECT_DIR"):
174
+ value = os.environ.get(variable)
175
+ if value:
176
+ return value
177
+ here = os.path.abspath(__file__)
178
+ derived = os.path.dirname(os.path.dirname(os.path.dirname(os.path.dirname(here))))
179
+ if os.path.isfile(os.path.join(derived, LOCAL_CONFIG_FILE)):
180
+ return derived
181
+ cwd = data.get("cwd")
182
+ return cwd if isinstance(cwd, str) and cwd else derived
183
+
184
+
185
+ def load_local_config(root):
69
186
  """Parsed ethlete-agents.config.local.json at the repo root, or {} if missing/unreadable."""
70
- root = os.environ.get("CLAUDE_PROJECT_DIR")
71
187
  if not root:
72
188
  return {}
73
189
  try:
@@ -98,10 +214,11 @@ def window_for(model):
98
214
  return DEFAULT_WINDOW
99
215
 
100
216
 
101
- def context_state(transcript_path):
102
- """(tokens, model) from the last main-chain assistant message.
217
+ def claude_context_state(transcript_path):
218
+ """(tokens, model, window) from the last main-chain assistant message.
103
219
 
104
- tokens ≈ its total input tokens (fresh + cache read + cache creation).
220
+ tokens ≈ its total input tokens (fresh + cache read + cache creation). Claude reports no
221
+ window in the transcript, so it is resolved from the model id by the caller.
105
222
  """
106
223
  last_usage = None
107
224
  last_model = None
@@ -119,111 +236,154 @@ def context_state(transcript_path):
119
236
  last_usage = usage
120
237
  last_model = message.get("model") or last_model
121
238
  if not last_usage:
122
- return 0, last_model
239
+ return 0, last_model, None
123
240
  tokens = (
124
241
  last_usage.get("input_tokens", 0)
125
242
  + last_usage.get("cache_read_input_tokens", 0)
126
243
  + last_usage.get("cache_creation_input_tokens", 0)
127
244
  )
128
- return tokens, last_model
245
+ return tokens, last_model, None
246
+
247
+
248
+ def find_token_usage_info(node):
249
+ """The deepest TokenUsageInfo-shaped dict in a parsed rollout line, or None.
250
+
251
+ Codex documents its rollout format as unstable, so this searches for the shape
252
+ (a dict carrying `last_token_usage`) rather than walking a fixed key path.
253
+ """
254
+ if isinstance(node, dict):
255
+ if isinstance(node.get("last_token_usage"), dict):
256
+ return node
257
+ for value in node.values():
258
+ found = find_token_usage_info(value)
259
+ if found:
260
+ return found
261
+ elif isinstance(node, list):
262
+ for value in node:
263
+ found = find_token_usage_info(value)
264
+ if found:
265
+ return found
266
+ return None
267
+
268
+
269
+ def codex_context_state(transcript_path):
270
+ """(tokens, model, window) from the last token_count event in a Codex rollout.
271
+
272
+ tokens is the last request's `total_tokens` — Codex's own `tokens_in_context_window()`
273
+ is exactly that field, and `last_token_usage` is replaced per request while
274
+ `total_token_usage` accumulates across the whole session.
275
+ """
276
+ last_info = None
277
+ last_model = None
278
+ with open(transcript_path, encoding="utf-8") as f:
279
+ for line in f:
280
+ try:
281
+ obj = json.loads(line)
282
+ except json.JSONDecodeError:
283
+ continue
284
+ model = (obj.get("payload") or {}).get("model") if isinstance(obj.get("payload"), dict) else None
285
+ if isinstance(model, str) and model:
286
+ last_model = model
287
+ info = find_token_usage_info(obj)
288
+ if info:
289
+ last_info = info
290
+ if not last_info:
291
+ return 0, last_model, None
292
+ usage = last_info.get("last_token_usage") or {}
293
+ window = last_info.get("model_context_window")
294
+ return usage.get("total_tokens", 0), last_model, window if isinstance(window, int) else None
295
+
129
296
 
297
+ CONTEXT_READERS = {"claude": claude_context_state, "codex": codex_context_state}
130
298
 
131
- def messages(tier, tokens, budget, priced, auto_mode):
299
+
300
+ def messages(profile, tier, tokens, budget, priced, auto_mode):
132
301
  """(systemMessage, additionalContext) for a tier.
133
302
 
134
303
  priced: the budget is the pricing boundary, not the window — the reason to
135
304
  hand off is cost, not an imminent auto-compact.
136
- auto_mode: at the critical tier, escalates from "recommend /handoff" to
305
+ auto_mode: at the critical tier, escalates from "recommend a handoff" to
137
306
  "save it now" — see the module docstring for why.
138
307
  """
139
308
  k = f"~{tokens // 1000}k"
140
309
  pct = round(tokens / budget * 100)
141
310
  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.",
311
+
312
+ if priced:
313
+ approach = "about to cross" if tier == 2 else "approaching"
314
+ headline = f"Context is at {k} tokens — {approach} the {budget_k} long-context pricing boundary"
315
+ detail = (
316
+ f"The context is at {k} tokens — {pct}% of the {budget_k} long-context "
317
+ f"pricing boundary."
153
318
  )
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.",
166
- )
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.",
319
+ else:
320
+ headline = f"Context is at {k} tokens ({pct}% of the {budget_k} window)"
321
+ detail = (
322
+ f"The context window is at {k} tokens — {pct}% of this model's "
323
+ f"{budget_k} window"
176
324
  )
325
+
177
326
  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.",
327
+ threshold = f" (critical, ≥{int(CRITICAL_FRACTION * 100)}%)"
328
+ detail = detail if priced else f"{detail}{threshold}."
329
+ pressure = (
330
+ "Past it every request is billed at the premium rate. "
331
+ if priced
332
+ else ""
185
333
  )
186
- if priced:
334
+ tail = "" if priced else " — auto-compact is imminent"
335
+ if auto_mode:
336
+ return (
337
+ f"🔴 {headline}{tail}. Auto mode is active: saving a handoff now.",
338
+ f"[context-warning hook] {detail} {pressure}Auto mode is active, so don't "
339
+ f"just recommend a handoff — {profile['save_now']}",
340
+ )
187
341
  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.",
342
+ f"🔴 {headline}{tail}. {profile['act_now']}",
343
+ f"[context-warning hook] {detail} {pressure}Finish only the immediate step, "
344
+ f"then {profile['recommend']}. Do not start new sub-tasks.",
196
345
  )
346
+
347
+ threshold = f" (≥{int(WARN_FRACTION * 100)}%)"
348
+ detail = detail if priced else f"{detail}{threshold}."
349
+ pressure = "Past it every request is billed at the premium rate. " if priced else ""
197
350
  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.",
351
+ f"🟡 {headline}. At the next natural stopping point, {profile['act_later']}.",
352
+ f"[context-warning hook] {detail} {pressure}When the current task reaches a "
353
+ f"natural stopping point, {profile['suggest']}. Keep working normally until then.",
204
354
  )
205
355
 
206
356
 
207
357
  def main():
208
- local_config = load_local_config()
209
- if disabled_locally(local_config):
358
+ agent = agent_name(sys.argv[1:])
359
+ profile = AGENT_PROFILES.get(agent)
360
+ if not profile:
210
361
  return
362
+
211
363
  data = json.load(sys.stdin)
364
+ root = repo_root(data)
365
+ local_config = load_local_config(root)
366
+ if disabled_locally(local_config):
367
+ return
368
+
369
+ profile = resolve_profile(profile, handoff_skill(root))
370
+
212
371
  transcript_path = data.get("transcript_path")
213
372
  session_id = data.get("session_id", "unknown")
214
- auto_mode = data.get("permission_mode") == "auto" and not auto_handoff_save_disabled(local_config)
373
+ auto_mode = data.get("permission_mode") in profile["auto_modes"] and not auto_handoff_save_disabled(local_config)
215
374
  if not transcript_path or not os.path.isfile(transcript_path):
216
375
  return
217
376
 
218
- tokens, model = context_state(transcript_path)
219
- window = window_for(model)
220
- budget = min(window, PREMIUM_BOUNDARY)
377
+ tokens, model, reported_window = CONTEXT_READERS[agent](transcript_path)
378
+ window = reported_window or profile["default_window"] or window_for(model)
379
+ boundary = profile["premium_boundary"]
380
+ budget = min(window, boundary) if boundary else window
221
381
  warn_tokens = int(budget * WARN_FRACTION)
222
382
  critical_tokens = int(budget * CRITICAL_FRACTION)
223
383
  tier = 2 if tokens >= critical_tokens else 1 if tokens >= warn_tokens else 0
224
384
 
225
385
  state_file = os.path.join(
226
- tempfile.gettempdir(), f"claude-context-warning-{session_id}"
386
+ tempfile.gettempdir(), f"{agent}-context-warning-{session_id}"
227
387
  )
228
388
  prev_tier = 0
229
389
  try:
@@ -243,21 +403,23 @@ def main():
243
403
  return # already warned at this tier (or context shrank — state re-armed above)
244
404
 
245
405
  system_message, additional_context = messages(
246
- tier, tokens, budget, budget < window, auto_mode
406
+ profile, tier, tokens, budget, budget < window, auto_mode
247
407
  )
248
408
 
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
- )
409
+ if not profile["emits_system_message"]:
410
+ additional_context = f"{additional_context}\n\nTell the user: {system_message}"
411
+
412
+ payload = {
413
+ "hookSpecificOutput": {
414
+ "hookEventName": "UserPromptSubmit",
415
+ "additionalContext": additional_context,
416
+ }
417
+ }
418
+ if profile["emits_system_message"]:
419
+ payload["systemMessage"] = system_message
420
+ payload["suppressOutput"] = True
421
+
422
+ print(json.dumps(payload))
261
423
 
262
424
 
263
425
  if __name__ == "__main__":
@@ -0,0 +1,193 @@
1
+ ---
2
+ name: figma-export
3
+ description: Reconcile a component against a Figma export - which exports to ask the designer for (an .svg frame and its "copy as CSS" dump), how to dump the geometry out of each, which of their numbers are authoritative, and how to measure the real rendered result against them headlessly. Use whenever a design export is dropped next to a component and the component has to be matched to it.
4
+ kind: skill
5
+ scope: both
6
+ ---
7
+
8
+ # Reconcile a component against a Figma export
9
+
10
+ Three things arrive from Figma, and each answers a different question:
11
+
12
+ | Export | Gives you | Cannot tell you |
13
+ | ------------------------- | ----------------------------------------------------------------------------- | ------------------------------------------- |
14
+ | **`.svg`** (Export frame) | Exact geometry with nesting, exact fills, and a picture once you rasterise it | Layer names, auto-layout intent, font sizes |
15
+ | **`.css`** (Copy as CSS) | Named layers, auto-layout properties, typography metrics, design-token names | Any hierarchy at all — the dump is flat |
16
+ | **`.png`** (a screenshot) | Figma's own blue measurement overlays, and what the designer chose to frame | Nothing machine-readable |
17
+
18
+ **Ask for the `.svg` and the `.css` together, and do not start until you have both.** The two
19
+ are complements, not alternatives: the SVG is the only export you can both look at and
20
+ measure, and the CSS is the only one that names layers and records type. A PNG earns its place
21
+ only when it is a _screenshot_ carrying dev-mode annotations — a PNG _render_ of the same frame
22
+ adds nothing the SVG does not. None of the three tells you the colours.
23
+
24
+ Say what you are missing and what it would settle, in one line — "I have the SVG; the `.css`
25
+ export would give me the font sizes and whether these cards Hug or Fill" — and wait. Every
26
+ number in your diff has to trace back to something in an export or to a token; a plausible
27
+ `17px` you inferred from a 12px outlined glyph is worse than an open question, because it
28
+ survives review as though it had been specified. If the pair genuinely cannot be produced, say
29
+ in the write-up which numbers are therefore guesses.
30
+
31
+ ## 1. Read the export before touching code
32
+
33
+ ### From an `.svg` — look at it, then measure it
34
+
35
+ Rasterise it and actually open the image. Figma outlines text on export, so this needs no
36
+ webfonts and matches the frame exactly:
37
+
38
+ ```bash
39
+ magick -density 144 <export.svg> <scratch>/frame.png # then read frame.png
40
+ ```
41
+
42
+ (If the render looks wrong, ImageMagick fell back to its own SVG renderer — check for
43
+ `RSVG` in `magick -list format | grep SVG`, or screenshot the file in Playwright instead.)
44
+
45
+ Then dump the geometry with {%resource:dump-figma-svg.py%} — every shape in document order,
46
+ indented by group nesting, with absolute coordinates:
47
+
48
+ ```bash
49
+ python3 dump-figma-svg.py <export.svg>
50
+ ```
51
+
52
+ It closes with two summaries worth reading first: repeated rect sizes, which say _one
53
+ component rendered N times_ where the CSS dump would show N unrelated frames; and the measured
54
+ gaps between rects that share a top edge, which is the auto-layout gap without having to
55
+ believe a label.
56
+
57
+ What the SVG does **not** carry:
58
+
59
+ - **Layer names.** Nothing is called `Card` or `Badge`; you match shapes to the picture.
60
+ - **Auto-layout intent.** `Hug` vs `Fixed` vs `Fill` is gone, so a width you read off is the
61
+ width _at this one size_, not a rule. Derive padding and gaps from the coordinates, and
62
+ treat a child width as content-driven until the picture or the CSS dump says otherwise.
63
+ - **Typography.** Outlined text is a `<path>`; the dumper labels the wide ones `text?` and
64
+ boxes them, which places a text run but tells you nothing about `font-size` or
65
+ `line-height`. If the designer exported with _Outline Text_ off, `<text>` nodes survive and
66
+ the dumper prints their font metrics and strings — take them when you get them.
67
+
68
+ Two numbers to read carefully: a **stroke is centred**, so a 32px circle with a 1px border
69
+ exports as `31 × 31` at `.5` offsets — add the stroke width back before comparing. And an
70
+ **outlined glyph box is the ink**, not the line box: a 17px/20px title measures ~12px tall,
71
+ the same cap-trim the CSS dump reports, so never match a text node's height.
72
+
73
+ ### From a `.css` dump — names, intent and type metrics
74
+
75
+ Never grep the raw file — its layout defeats naive parsing (see the traps below). Run
76
+ {%resource:dump-figma-layers.py%}:
77
+
78
+ ```bash
79
+ python3 dump-figma-layers.py <export.css> # every layer, artwork dropped
80
+ python3 dump-figma-layers.py <export.css> '^(Widget|Frame|Card)' # only what you name
81
+ ```
82
+
83
+ Then work out the frame's box model top-down — outer frame width and padding, then each
84
+ child's `width`/`gap`/`flex-grow` — and write the ladder down before you start editing, so you
85
+ can tell a deliberate design decision from a Figma artefact.
86
+
87
+ ### Whatever you have, look at the picture before you decide what to build
88
+
89
+ The CSS dump is a **flat** sequence of layers with no nesting whatsoever, so the tree is not in
90
+ it; the SVG has the tree but no names. The image is what disambiguates:
91
+
92
+ - **Hierarchy and reading order** — which layer contains which. The only other clue in the CSS
93
+ is `order:` inside a sibling run, which does not cross frames.
94
+ - **Repetition vs distinct layers.** Six blocks with identical declarations are one component
95
+ rendered six times. The CSS looks like six unrelated frames.
96
+ - **Multiple states in one board.** Frames are routinely laid out side by side as
97
+ default / hover / selected / empty, with a pink cursor marking the interaction. The CSS
98
+ gives you every state flattened together with nothing saying which is which, so a number
99
+ read blind may belong to a hover state you are not building.
100
+ - **Figma's own measurement overlays.** Blue annotations like `396 × 401 Hug` or `347 × 23`
101
+ are authoritative and frequently _absent_ from the CSS — `Hug` in particular tells you a
102
+ dimension is content-driven, which no declaration in the export records. These live only in
103
+ a canvas _screenshot_; a frame export of any format drops them.
104
+ - **What is decoration.** Cursors, callout arrows and section captions painted on the board
105
+ are not part of the component, but they do emit layers.
106
+
107
+ ### Traps in the `.css` export itself
108
+
109
+ - **Sub-comments carry the real properties.** Figma writes `/* Auto layout */`,
110
+ `/* Inside auto layout */`, `/* or 16px */` and token names like
111
+ `/* Brand/Chalk White/500 */` _between_ a layer's declarations. A parser that starts a new
112
+ layer at every comment reports frames with **zero properties** and swallows the geometry.
113
+ The dumper folds them in — it decides by the blank line that follows a real layer name, not
114
+ by the name, so component layers called `Profile/Badge` survive.
115
+ - **Frame labels lie.** A frame _named_ "Widget 636px" routinely has `width: 640px`, and a
116
+ label like "8 rows = 564px" often does not reconcile with the grid it sits in. Trust the
117
+ declarations, never the label.
118
+ - **Text heights are cap-trimmed.** With `leading-trim: both; text-edge: cap`, a 17px/20px
119
+ title reports `height: 12px`. Compare `font-size`, `line-height` and `letter-spacing`;
120
+ never compare a text node's height.
121
+ - **Absolute `left`/`top`** are canvas coordinates of the whole board. Only the _differences_
122
+ within one frame mean anything.
123
+
124
+ ## 2. Decide what the export is allowed to dictate
125
+
126
+ - **Authoritative:** geometry and typography metrics — widths, padding, gaps, `flex-grow`,
127
+ border radius, font size / weight / line-height / letter-spacing, and the breakpoints at
128
+ which the layout changes.
129
+ - **Never authoritative: colour.** Backgrounds, text, borders and interaction states resolve
130
+ from the surface and colour theming tokens — see {%skill:theming%}. A hex in the
131
+ export is information about the _designer's_ palette, not a value to paste. Where the
132
+ export's colour and the token disagree, keep the token and note the delta for the design
133
+ review; the export can be wrong about contrast in a way the tokens are not. This holds
134
+ hardest for an SVG, whose fills are exact and therefore tempting: an exact wrong answer is
135
+ still wrong.
136
+ - **Radius and spacing snap to the scale.** Round to the nearest token rather than emitting an
137
+ arbitrary value, and say so if the export's number is more than a step away.
138
+
139
+ ## 3. Ask before making a structural choice
140
+
141
+ Matching numbers is mechanical; the choices around them are not. Stop and ask when the export
142
+ implies:
143
+
144
+ - a **breakpoint strategy** — container queries with an exact ladder vs a retuned
145
+ `auto-fill`/`auto-fit` grid;
146
+ - **shared-component churn** — a new variant on a component other screens already use, vs a
147
+ local override;
148
+ - a **scroll region** — which part scrolls and what stays pinned;
149
+ - **fixed sizing** where the component is currently fluid, or the reverse;
150
+ - anything the export shows that the API does not yet return.
151
+
152
+ Colour deltas are informational — report them, do not block on them.
153
+
154
+ ## 4. Measure the real thing, don't eyeball it
155
+
156
+ A build passing proves nothing about geometry. Render the component's real markup against the
157
+ **production stylesheet** and read the computed boxes back. {%resource:measure-template.mjs%}
158
+ is a working starting point; copy it into a scratch directory with the compiled stylesheet
159
+ beside it:
160
+
161
+ ```bash
162
+ # whatever your build emits — the point is a real, fully compiled stylesheet
163
+ cp dist/apps/<app>/browser/styles-*.css <scratch>/styles.css
164
+ node <scratch>/measure-template.mjs
165
+ ```
166
+
167
+ Confirm the arbitrary utilities you wrote actually compiled — a typo in
168
+ `@min-[392px]` fails silently:
169
+
170
+ ```bash
171
+ grep -oE '@container \(width >= [0-9]+px\)|\.(h-15|rounded-sm)\{[^}]*\}' dist/apps/<app>/browser/styles-*.css | sort -u
172
+ ```
173
+
174
+ Harness gotchas, each of which will cost you an hour:
175
+
176
+ - **`page.setContent()` renders on `about:blank`, which blocks `file://` subresources** — the
177
+ stylesheet silently never loads. Write a real HTML file and `page.goto('file://…')`.
178
+ - **Never lay the probes out in a flex _row_.** They shrink, and container queries then report
179
+ results for a width the component would never see. Stack them in a column at explicit widths.
180
+ - **The harness has no webfonts** unless you copy the `@font-face` sources too. Text runs
181
+ ~1px wide of reality, so a label that wraps in the harness may well fit in the app. Check
182
+ before calling a wrap a defect.
183
+ - Playwright is CommonJS and unresolvable from a scratch directory — `createRequire` against
184
+ the repo root, as the template does. The same applies when driving a story:
185
+ {%skill:verify-in-storybook%}.
186
+
187
+ ## 5. Close the loop
188
+
189
+ Report the measured numbers next to the export's, per width — not "matches the design". Say
190
+ explicitly which parts of the export you deliberately did **not** implement and why (colour
191
+ kept as tokens, a label the product decided never to render, a field the API lacks). Then
192
+ delete the export files once their component is signed off, so the folder always shows only
193
+ what is still outstanding.