@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.
- package/CHANGELOG.md +26 -0
- package/README.md +192 -11
- package/content/git-hooks/post-checkout.sh +10 -0
- package/content/git-hooks/pre-push.sh +5 -0
- package/content/hooks/context-warning.py +259 -97
- package/content/skills/figma-export/SKILL.md +193 -0
- package/content/skills/figma-export/dump-figma-layers.py +83 -0
- package/content/skills/figma-export/dump-figma-svg.py +235 -0
- package/content/skills/figma-export/measure-template.mjs +87 -0
- package/content/skills/git-flow/SKILL.md +83 -0
- package/content/skills/handoff/SKILL.md +4 -0
- package/content/skills/query/SKILL.md +23 -13
- package/package.json +12 -1
- package/src/index.js +10 -5
- package/src/index.js.map +1 -1
- package/src/lib/config.d.ts +30 -2
- package/src/lib/config.js +20 -3
- package/src/lib/config.js.map +1 -1
- package/src/lib/git-flow/build.d.ts +35 -0
- package/src/lib/git-flow/build.js +24 -0
- package/src/lib/git-flow/build.js.map +1 -0
- package/src/lib/git-flow/config.d.ts +59 -0
- package/src/lib/git-flow/config.js +50 -0
- package/src/lib/git-flow/config.js.map +1 -0
- package/src/lib/git-flow/index.d.ts +5 -0
- package/src/lib/git-flow/index.js +9 -0
- package/src/lib/git-flow/index.js.map +1 -0
- package/src/lib/git-flow/parse.d.ts +49 -0
- package/src/lib/git-flow/parse.js +274 -0
- package/src/lib/git-flow/parse.js.map +1 -0
- package/src/lib/git-flow/start.d.ts +22 -0
- package/src/lib/git-flow/start.js +24 -0
- package/src/lib/git-flow/start.js.map +1 -0
- package/src/lib/git-flow/validate.d.ts +34 -0
- package/src/lib/git-flow/validate.js +72 -0
- package/src/lib/git-flow/validate.js.map +1 -0
- package/src/lib/git-flow-command.d.ts +4 -0
- package/src/lib/git-flow-command.js +157 -0
- package/src/lib/git-flow-command.js.map +1 -0
- package/src/lib/git-flow-repair.d.ts +17 -0
- package/src/lib/git-flow-repair.js +150 -0
- package/src/lib/git-flow-repair.js.map +1 -0
- package/src/lib/git-flow-start.d.ts +20 -0
- package/src/lib/git-flow-start.js +147 -0
- package/src/lib/git-flow-start.js.map +1 -0
- package/src/lib/git.d.ts +27 -0
- package/src/lib/git.js +49 -0
- package/src/lib/git.js.map +1 -0
- package/src/lib/gitlab.d.ts +35 -0
- package/src/lib/gitlab.js +98 -0
- package/src/lib/gitlab.js.map +1 -0
- package/src/lib/jira.d.ts +28 -0
- package/src/lib/jira.js +83 -0
- package/src/lib/jira.js.map +1 -0
- package/src/lib/owned-paths.js +19 -1
- package/src/lib/owned-paths.js.map +1 -1
- package/src/lib/plan.js +29 -5
- package/src/lib/plan.js.map +1 -1
- package/src/lib/prompt.d.ts +8 -0
- package/src/lib/prompt.js +27 -0
- package/src/lib/prompt.js.map +1 -0
- package/src/lib/render.d.ts +12 -1
- package/src/lib/render.js +23 -6
- package/src/lib/render.js.map +1 -1
- package/src/lib/targets/claude-hooks.d.ts +1 -23
- package/src/lib/targets/claude-hooks.js +15 -84
- package/src/lib/targets/claude-hooks.js.map +1 -1
- package/src/lib/targets/codex-hooks.d.ts +12 -0
- package/src/lib/targets/codex-hooks.js +33 -0
- package/src/lib/targets/codex-hooks.js.map +1 -0
- package/src/lib/targets/git-hooks.d.ts +25 -0
- package/src/lib/targets/git-hooks.js +70 -0
- package/src/lib/targets/git-hooks.js.map +1 -0
- package/src/lib/targets/hooks-shared.d.ts +38 -0
- package/src/lib/targets/hooks-shared.js +95 -0
- 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
|
|
6
|
-
|
|
7
|
-
|
|
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
|
|
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
|
|
15
|
-
|
|
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
|
|
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
|
|
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
|
-
|
|
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
|
|
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
|
-
|
|
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
|
|
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
|
-
|
|
143
|
-
|
|
144
|
-
|
|
145
|
-
|
|
146
|
-
|
|
147
|
-
f"
|
|
148
|
-
"
|
|
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
|
-
|
|
155
|
-
|
|
156
|
-
|
|
157
|
-
f"
|
|
158
|
-
f"
|
|
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
|
-
|
|
179
|
-
|
|
180
|
-
|
|
181
|
-
|
|
182
|
-
|
|
183
|
-
|
|
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
|
-
|
|
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"
|
|
189
|
-
f"
|
|
190
|
-
f"
|
|
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"🟡
|
|
199
|
-
f"
|
|
200
|
-
f"
|
|
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
|
-
|
|
209
|
-
|
|
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")
|
|
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 =
|
|
219
|
-
window = window_for(model)
|
|
220
|
-
|
|
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"
|
|
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
|
-
|
|
250
|
-
|
|
251
|
-
|
|
252
|
-
|
|
253
|
-
|
|
254
|
-
|
|
255
|
-
|
|
256
|
-
|
|
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.
|