@ethlete/agent-rules 0.1.0-next.3 → 0.1.0-next.31
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/AGENTS.md +28 -0
- package/CHANGELOG.md +480 -0
- package/CLAUDE.md +1 -0
- package/README.md +362 -29
- package/THIRD-PARTY-LICENSES.md +35 -0
- package/content/defaults.json +1 -1
- package/content/git-hooks/post-checkout.sh +10 -0
- package/content/git-hooks/pre-push.sh +5 -0
- package/content/hooks/context-warning.py +872 -110
- package/content/hooks/subagent-model-policy.py +194 -0
- package/content/mods/.claude-plugin/marketplace.json +11 -0
- package/content/mods/plugins/e2e-progress/.claude-plugin/plugin.json +6 -0
- package/content/mods/plugins/e2e-progress/hooks/hooks.json +1 -0
- package/content/mods/plugins/e2e-progress/hooks/register.tsx +442 -0
- package/content/mods/plugins/e2e-progress/tsconfig.json +3 -0
- package/content/mods/plugins/e2e-progress/types/index.d.ts +23 -0
- package/content/output-styles/ste-clarity.md +131 -0
- package/content/rules/agent-sessions-wake-up.md +11 -0
- package/content/rules/app-styling.md +66 -0
- package/content/rules/comments.md +4 -1
- package/content/rules/lint-and-format.md +7 -5
- package/content/rules/nx-layout.md +50 -0
- package/content/rules/reactive-state.md +1 -1
- package/content/rules/styling.md +9 -7
- package/content/rules/subagent-models.md +39 -0
- package/content/skills/agent-sessions/SKILL.md +81 -0
- package/content/skills/agent-sessions/agent-sessions.mjs +498 -0
- package/content/skills/angular-patterns/SKILL.md +30 -7
- package/content/skills/api-source/SKILL.md +156 -0
- package/content/skills/app-testing/SKILL.md +141 -0
- package/content/skills/codex-subagent/SKILL.md +90 -0
- package/content/skills/codex-subagent/codex-agent.mjs +229 -0
- package/content/skills/design-exploration/SKILL.md +687 -0
- package/content/skills/design-exploration/check-story.mjs +139 -0
- package/content/skills/design-exploration/shoot-template.mjs +61 -0
- package/content/skills/domain-modeling/ADR-FORMAT.md +47 -0
- package/content/skills/domain-modeling/CONTEXT-FORMAT.md +60 -0
- package/content/skills/domain-modeling/SKILL.md +80 -0
- package/content/skills/e2e-triage/SKILL.md +80 -0
- package/content/skills/figma-export/SKILL.md +191 -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-commit/SKILL.md +11 -7
- package/content/skills/git-flow/SKILL.md +87 -0
- package/content/skills/grill-with-docs/SKILL.md +13 -0
- package/content/skills/grilling/SKILL.md +51 -0
- package/content/skills/handoff/SKILL.md +125 -5
- package/content/skills/query/SKILL.md +165 -64
- package/content/skills/rxjs-signals/SKILL.md +33 -4
- package/content/skills/sdk-docs/SKILL.md +50 -14
- package/content/skills/sdk-local-build/SKILL.md +26 -8
- package/content/skills/sdk-local-build/sdk-local-baseline.mjs +64 -0
- package/content/skills/sdk-source/SKILL.md +37 -34
- package/content/skills/sdk-update/SKILL.md +103 -0
- package/content/skills/story-styling/SKILL.md +10 -8
- package/content/skills/styleguide/SKILL.md +12 -7
- package/content/skills/styleguide/assets.md +54 -0
- package/content/skills/styleguide/changesets.md +17 -0
- package/content/skills/styleguide/file-structure.md +73 -0
- package/content/skills/styleguide/lint-rule-lookup.md +51 -0
- package/content/skills/styleguide/storybook-structure.md +25 -0
- package/content/skills/theming/SKILL.md +50 -28
- package/content/skills/timetrack/SKILL.md +397 -0
- package/content/skills/verify-in-app/SKILL.md +107 -0
- package/migrations/app-styling-utilities.md +63 -0
- package/migrations/list-state-query-form.md +20 -0
- package/migrations/nx-layout.md +29 -0
- package/migrations/sdk-components-over-hand-built-ui.md +27 -0
- package/migrations/search-query-field.md +52 -0
- package/migrations.json +48 -0
- package/package.json +19 -3
- package/src/index.js +85 -30
- package/src/index.js.map +1 -1
- package/src/lib/cli-args.d.ts +9 -0
- package/src/lib/cli-args.js +48 -0
- package/src/lib/cli-args.js.map +1 -0
- package/src/lib/commitlint.d.ts +10 -0
- package/src/lib/commitlint.js +51 -0
- package/src/lib/commitlint.js.map +1 -0
- package/src/lib/config.d.ts +41 -5
- package/src/lib/config.js +117 -10
- package/src/lib/config.js.map +1 -1
- package/src/lib/filter.d.ts +2 -0
- package/src/lib/filter.js +4 -4
- package/src/lib/filter.js.map +1 -1
- package/src/lib/frontmatter.d.ts +2 -0
- package/src/lib/frontmatter.js +33 -5
- package/src/lib/frontmatter.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 +6 -0
- package/src/lib/git-flow/index.js +23 -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 +300 -0
- package/src/lib/git-flow/parse.js.map +1 -0
- package/src/lib/git-flow/rename.d.ts +24 -0
- package/src/lib/git-flow/rename.js +70 -0
- package/src/lib/git-flow/rename.js.map +1 -0
- package/src/lib/git-flow/start.d.ts +49 -0
- package/src/lib/git-flow/start.js +57 -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 +165 -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 +165 -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 +140 -0
- package/src/lib/git-flow-start.js.map +1 -0
- package/src/lib/git.d.ts +64 -0
- package/src/lib/git.js +131 -0
- package/src/lib/git.js.map +1 -0
- package/src/lib/gitlab.d.ts +35 -0
- package/src/lib/gitlab.js +116 -0
- package/src/lib/gitlab.js.map +1 -0
- package/src/lib/index.d.ts +2 -0
- package/src/lib/index.js +26 -11
- package/src/lib/index.js.map +1 -1
- package/src/lib/migrate.js +6 -1
- package/src/lib/migrate.js.map +1 -1
- package/src/lib/output-style-command.d.ts +3 -0
- package/src/lib/output-style-command.js +69 -0
- package/src/lib/output-style-command.js.map +1 -0
- package/src/lib/output-style.d.ts +38 -0
- package/src/lib/output-style.js +134 -0
- package/src/lib/output-style.js.map +1 -0
- package/src/lib/owned-paths.js +27 -3
- package/src/lib/owned-paths.js.map +1 -1
- package/src/lib/package-runner.d.ts +4 -0
- package/src/lib/package-runner.js +86 -0
- package/src/lib/package-runner.js.map +1 -0
- package/src/lib/plain-text.d.ts +9 -0
- package/src/lib/plain-text.js +22 -0
- package/src/lib/plain-text.js.map +1 -0
- package/src/lib/plan.d.ts +6 -6
- package/src/lib/plan.js +164 -40
- package/src/lib/plan.js.map +1 -1
- package/src/lib/prompt.d.ts +8 -0
- package/src/lib/prompt.js +35 -0
- package/src/lib/prompt.js.map +1 -0
- package/src/lib/render.d.ts +25 -2
- package/src/lib/render.js +43 -10
- package/src/lib/render.js.map +1 -1
- package/src/lib/sessions-command.d.ts +4 -0
- package/src/lib/sessions-command.js +16 -0
- package/src/lib/sessions-command.js.map +1 -0
- package/src/lib/sync.d.ts +0 -1
- package/src/lib/sync.js +30 -17
- package/src/lib/sync.js.map +1 -1
- package/src/lib/targets/agents-skills.js +1 -1
- package/src/lib/targets/agents-skills.js.map +1 -1
- package/src/lib/targets/claude-hooks.d.ts +3 -23
- package/src/lib/targets/claude-hooks.js +22 -86
- package/src/lib/targets/claude-hooks.js.map +1 -1
- package/src/lib/targets/claude-mods.d.ts +20 -0
- package/src/lib/targets/claude-mods.js +47 -0
- package/src/lib/targets/claude-mods.js.map +1 -0
- package/src/lib/targets/claude.js +2 -3
- package/src/lib/targets/claude.js.map +1 -1
- package/src/lib/targets/codex-hooks.d.ts +12 -0
- package/src/lib/targets/codex-hooks.js +35 -0
- package/src/lib/targets/codex-hooks.js.map +1 -0
- package/src/lib/targets/codex.js +4 -4
- package/src/lib/targets/codex.js.map +1 -1
- package/src/lib/targets/copilot.js +4 -4
- package/src/lib/targets/copilot.js.map +1 -1
- package/src/lib/targets/cursor.js +3 -3
- package/src/lib/targets/cursor.js.map +1 -1
- package/src/lib/targets/git-hooks.d.ts +25 -0
- package/src/lib/targets/git-hooks.js +71 -0
- package/src/lib/targets/git-hooks.js.map +1 -0
- package/src/lib/targets/hooks-shared.d.ts +60 -0
- package/src/lib/targets/hooks-shared.js +130 -0
- package/src/lib/targets/hooks-shared.js.map +1 -0
- package/src/lib/targets/mcp.d.ts +16 -0
- package/src/lib/targets/mcp.js +68 -0
- package/src/lib/targets/mcp.js.map +1 -0
- package/src/lib/targets/shared.d.ts +1 -9
- package/src/lib/targets/shared.js +4 -8
- package/src/lib/targets/shared.js.map +1 -1
- package/src/lib/timetrack-command.d.ts +11 -0
- package/src/lib/timetrack-command.js +1063 -0
- package/src/lib/timetrack-command.js.map +1 -0
- package/src/lib/timetrack-snapshot.d.ts +55 -0
- package/src/lib/timetrack-snapshot.js +120 -0
- package/src/lib/timetrack-snapshot.js.map +1 -0
- package/src/lib/timetrack.d.ts +561 -0
- package/src/lib/timetrack.js +279 -0
- package/src/lib/timetrack.js.map +1 -0
- package/content/skills/styleguide/STYLEGUIDE.md +0 -520
|
@@ -1,43 +1,151 @@
|
|
|
1
1
|
#!/usr/bin/env python3
|
|
2
|
-
"""
|
|
2
|
+
"""Context-budget hook: keep the agent aware of how much context it may still spend.
|
|
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
8
|
|
|
9
|
-
|
|
10
|
-
|
|
11
|
-
|
|
12
|
-
|
|
9
|
+
Registered on five events, because a warning the agent cannot see while it works
|
|
10
|
+
is not a warning:
|
|
11
|
+
|
|
12
|
+
* SessionStart - states the budget and how to scope work to it, before the
|
|
13
|
+
first request. Nothing else tells the model what its budget is.
|
|
14
|
+
* UserPromptSubmit - the tiered warning, plus a short reminder on every later
|
|
15
|
+
prompt.
|
|
16
|
+
* PostToolBatch - the same tiered warning, delivered mid-run. This is the only
|
|
17
|
+
event that fires between an agent's own model requests, so without it a long
|
|
18
|
+
autonomous run never learns its token count until the user types something.
|
|
19
|
+
* Stop - fires as the agent tries to end its turn. Its additionalContext is
|
|
20
|
+
delivered to the model and the conversation continues, so the decision to
|
|
21
|
+
finish or hand off happens without the user having to ask for it.
|
|
22
|
+
|
|
23
|
+
Inside a Claude sub-agent, PostToolBatch fires with `agent_id` set and the parent's
|
|
24
|
+
`transcript_path`, so the sub-agent's own transcript is read instead and it gets the
|
|
25
|
+
same tiers. Its end of turn arrives as SubagentStop, whose block `reason` becomes its
|
|
26
|
+
next instruction: at the critical tier, hand back a report with the exact remaining
|
|
27
|
+
steps, which is the only handoff a sub-agent has.
|
|
28
|
+
|
|
29
|
+
Runs under both Claude Code and Codex, selected by `--agent` on the command
|
|
30
|
+
line rather than sniffed from the payload - the generator writes the flag, so
|
|
31
|
+
the two never have to be told apart at runtime. Codex has only UserPromptSubmit;
|
|
32
|
+
the other three events are registered for Claude alone. The two differ in three
|
|
33
|
+
ways that matter here, all captured in AGENT_PROFILES:
|
|
34
|
+
|
|
35
|
+
* Transcript format. Claude writes one JSON object per message with
|
|
36
|
+
`message.usage`; Codex writes a rollout stream whose `token_count` events
|
|
37
|
+
carry a TokenUsageInfo. Codex's rollout format is explicitly not a stable
|
|
38
|
+
interface, so the parser searches each line for the usage object instead of
|
|
39
|
+
walking a fixed path.
|
|
40
|
+
* Sub-agents. Codex gives each sub-agent its own rollout and thread id while
|
|
41
|
+
retaining the root session id. Warnings identify that thread explicitly and
|
|
42
|
+
tell it to report back to its parent instead of making the main agent hand
|
|
43
|
+
off its session.
|
|
44
|
+
* Context budget. Claude's budget is capped at its 200k long-context pricing
|
|
45
|
+
boundary. Codex uses the model-specific pricing boundary where OpenAI
|
|
46
|
+
documents one, and the reported window otherwise. Crossing either boundary
|
|
47
|
+
costs far more than handing off into a fresh session ever would.
|
|
48
|
+
* How a handoff is invoked. Claude has a slash command and /clear; Codex has
|
|
49
|
+
neither and reads the skill from disk. The skill's own name differs per repo
|
|
50
|
+
(`ethlete-handoff` where the generator installed it, `handoff` where the repo
|
|
51
|
+
ships its own copy), so it is resolved at runtime - see handoff_skill().
|
|
52
|
+
|
|
53
|
+
At the critical tier, if the session's permission_mode is "auto", the
|
|
54
|
+
instruction escalates from "recommend" to "just do it": auto mode already
|
|
55
|
+
means the user wants the agent acting without stopping to ask, and clearing +
|
|
56
|
+
resuming can't be triggered programmatically (no hook or tool can submit
|
|
57
|
+
input to a running session), so the best available automation is to save the
|
|
58
|
+
handoff file itself immediately rather than waiting for a natural stopping
|
|
59
|
+
point. Codex's permission_mode value set is undocumented, so its profile lists
|
|
60
|
+
no auto modes and the escalation stays off there.
|
|
61
|
+
|
|
62
|
+
Neither the critical tier nor the Stop event forces a handoff. A session is
|
|
63
|
+
often a step or two from done when they fire, and some sessions hold reasoning
|
|
64
|
+
that no handoff file survives - crossing the boundary is then the cheaper
|
|
65
|
+
choice. So the Stop event offers three options and demands the agent name the
|
|
66
|
+
one it took: finish, hand off, or continue past the boundary for a reason it
|
|
67
|
+
states in one sentence. A third tier at 95% withdraws only the "finish" option,
|
|
68
|
+
because a budget that small covers no task.
|
|
69
|
+
|
|
70
|
+
Goes quiet while a handoff is being saved: from a prompt that requests save mode, or the
|
|
71
|
+
auto-mode save this hook ordered, until the next prompt; and for the rest of a turn in
|
|
72
|
+
which the agent invoked the handoff skill or wrote a handoff file. A save that is told to
|
|
73
|
+
choose between finishing and handing off again never ends. `resume` is not a save.
|
|
13
74
|
|
|
14
75
|
Warns once per tier per session (state kept in a temp file); re-arms itself
|
|
15
|
-
if the context shrinks again (e.g. after
|
|
76
|
+
if the context shrinks again (e.g. after a compaction). The Stop event keeps its
|
|
77
|
+
own per-tier counter, so crossing a tier mid-run costs at most one notice while
|
|
78
|
+
working and one demand at the end of the turn. Every prompt after the first
|
|
79
|
+
warning carries a short reminder instead: how much budget is left, and how to
|
|
80
|
+
work so that either ending stays cheap. One warning at 70% is stale by the time
|
|
81
|
+
it matters, and the agent cannot see its own token count.
|
|
16
82
|
|
|
17
83
|
Can be disabled per machine via a gitignored ethlete-agents.config.local.json
|
|
18
84
|
at the repo root: {"disableHooks": true} or {"disableHooks": ["context-warning"]}.
|
|
85
|
+
To keep the tiered warnings but drop just the auto-mode auto-save escalation,
|
|
86
|
+
use {"disableAutoHandoffSave": true} instead - the critical tier then falls
|
|
87
|
+
back to recommending a handoff, same as non-auto mode.
|
|
19
88
|
|
|
20
|
-
Fail-safe: any error exits 0 with no output
|
|
89
|
+
Fail-safe: any error exits 0 with no output - the hook must never block a prompt,
|
|
90
|
+
a tool batch or the end of a turn.
|
|
21
91
|
"""
|
|
22
92
|
|
|
23
93
|
import json
|
|
24
94
|
import os
|
|
95
|
+
import re
|
|
96
|
+
import subprocess
|
|
25
97
|
import sys
|
|
26
98
|
import tempfile
|
|
27
99
|
|
|
28
100
|
HOOK_NAME = "context-warning"
|
|
29
101
|
LOCAL_CONFIG_FILE = "ethlete-agents.config.local.json"
|
|
30
102
|
|
|
31
|
-
# Warn / critical fire at these fractions of the token budget.
|
|
103
|
+
# Warn / critical / final fire at these fractions of the token budget.
|
|
32
104
|
WARN_FRACTION = 0.70
|
|
33
105
|
CRITICAL_FRACTION = 0.85
|
|
106
|
+
FINAL_FRACTION = 0.95
|
|
107
|
+
|
|
108
|
+
# How to work once the budget is tight - repeated on every prompt from the first
|
|
109
|
+
# warning on, so it stays in view as the budget shrinks.
|
|
110
|
+
HANDOFF_MODE = (
|
|
111
|
+
"Work in slices that each end in a committable state. Send bulky reads, searches and "
|
|
112
|
+
"test runs to a sub-agent so their output stays out of this context. Write down each "
|
|
113
|
+
"decision and dead end as you reach it, in the commit message or the handoff file. "
|
|
114
|
+
"Start nothing you cannot finish or hand off inside the remaining budget."
|
|
115
|
+
)
|
|
116
|
+
|
|
117
|
+
# Said once at session start, before anything has been read - the only point at which the
|
|
118
|
+
# scope of the whole session is still open.
|
|
119
|
+
SESSION_BUDGET = (
|
|
120
|
+
"Scope the session to it. Send bulky reads, searches and test runs to a sub-agent so "
|
|
121
|
+
"their output never enters this context. Commit in slices. A task that plainly does not "
|
|
122
|
+
"fit should be split now, not abandoned at 90%."
|
|
123
|
+
)
|
|
124
|
+
|
|
125
|
+
# The escape from a handoff at the critical tier, with the test that keeps it honest.
|
|
126
|
+
FINISH_FIRST = (
|
|
127
|
+
"If you can name every step that is left, and they plainly fit the remaining budget, "
|
|
128
|
+
"finish the task, commit, and then apply the handoff skill's own save test - with "
|
|
129
|
+
"everything committed and no step left, write no handoff file and say so instead. "
|
|
130
|
+
"\"Nearly done\" means you can name the last steps now, not that the end feels close."
|
|
131
|
+
)
|
|
34
132
|
|
|
35
133
|
# On models with a window larger than this, tokens beyond it bill the whole
|
|
36
|
-
# context at the long-context premium rate
|
|
134
|
+
# context at the long-context premium rate - so the budget never exceeds it.
|
|
37
135
|
PREMIUM_BOUNDARY = 200_000
|
|
38
136
|
|
|
137
|
+
# Codex models whose long-context pricing starts above 272k input tokens. Models
|
|
138
|
+
# without that pricing rule use their reported context window instead.
|
|
139
|
+
CODEX_PREMIUM_BOUNDARIES = (
|
|
140
|
+
("gpt-6.1", 500_000),
|
|
141
|
+
("gpt-5.6", 272_000),
|
|
142
|
+
("gpt-5.5", 272_000),
|
|
143
|
+
("gpt-5.4-mini", None),
|
|
144
|
+
("gpt-5.4", 272_000),
|
|
145
|
+
)
|
|
146
|
+
|
|
39
147
|
# Context window (tokens) per model, matched by substring against the model id
|
|
40
|
-
# from the transcript
|
|
148
|
+
# from the transcript - first match wins. Edit these as model windows change;
|
|
41
149
|
# anything unmatched falls back to DEFAULT_WINDOW.
|
|
42
150
|
CONTEXT_WINDOWS = (
|
|
43
151
|
("opus-5", 1_000_000),
|
|
@@ -45,7 +153,7 @@ CONTEXT_WINDOWS = (
|
|
|
45
153
|
("sonnet-4-5", 1_000_000),
|
|
46
154
|
("sonnet-5", 1_000_000),
|
|
47
155
|
("fable-5", 1_000_000),
|
|
48
|
-
# Generic fallbacks
|
|
156
|
+
# Generic fallbacks - keep these last: the first substring match wins, so a bare
|
|
49
157
|
# "opus"/"sonnet" entry placed above would shadow every versioned entry below it.
|
|
50
158
|
("opus", 200_000),
|
|
51
159
|
("sonnet", 200_000),
|
|
@@ -53,20 +161,189 @@ CONTEXT_WINDOWS = (
|
|
|
53
161
|
)
|
|
54
162
|
DEFAULT_WINDOW = 200_000
|
|
55
163
|
|
|
164
|
+
# Bytes read from the end of a Claude transcript to find the newest usage report. Read on
|
|
165
|
+
# every tool batch, so a full parse of a multi-megabyte transcript would add latency to
|
|
166
|
+
# every model request. Grows by GROWTH_FACTOR until a usage report is found.
|
|
167
|
+
TAIL_BYTES = 256 * 1024
|
|
168
|
+
MAX_TAIL_BYTES = 16 * 1024 * 1024
|
|
169
|
+
GROWTH_FACTOR = 8
|
|
56
170
|
|
|
57
|
-
|
|
58
|
-
""
|
|
59
|
-
|
|
60
|
-
|
|
171
|
+
AGENT_PROFILES = {
|
|
172
|
+
"claude": {
|
|
173
|
+
"premium_boundary": PREMIUM_BOUNDARY,
|
|
174
|
+
"premium_boundaries": (),
|
|
175
|
+
# Claude's transcript reports no window, so it is resolved from the model id.
|
|
176
|
+
"default_window": None,
|
|
177
|
+
"auto_modes": ("auto",),
|
|
178
|
+
"emits_system_message": True,
|
|
179
|
+
"act_now": "Run /{handoff} now and continue in a fresh session.",
|
|
180
|
+
"act_later": "consider /{handoff} to continue in a fresh session",
|
|
181
|
+
"recommend": (
|
|
182
|
+
"first apply the handoff skill's save test yourself; if it calls for a file, "
|
|
183
|
+
"recommend the user run /{handoff} to save state and start a fresh session, "
|
|
184
|
+
"otherwise recommend /clear and say nothing is left to hand off, with a prompt to copy into the fresh session"
|
|
185
|
+
),
|
|
186
|
+
"suggest": (
|
|
187
|
+
"first apply the handoff skill's save test yourself; if it calls for a file, "
|
|
188
|
+
"suggest the user run /{handoff} to save state and start a fresh session, "
|
|
189
|
+
"otherwise suggest /clear and say nothing is left to hand off, with a prompt to copy into the fresh session"
|
|
190
|
+
),
|
|
191
|
+
"save_now": (
|
|
192
|
+
"run the handoff skill's save mode right now - finish only an in-flight atomic "
|
|
193
|
+
"edit first, nothing new. Apply its own test: with every change committed, no "
|
|
194
|
+
"step left and nothing running, write no file, say that instead, and recommend "
|
|
195
|
+
"/clear. If a file is warranted, tell the user exactly which one was written and "
|
|
196
|
+
"that they should run /clear, then '/{handoff} resume <slug>', to continue - "
|
|
197
|
+
"clearing and resuming can't be done programmatically, so this is the one step "
|
|
198
|
+
"still on them."
|
|
199
|
+
),
|
|
200
|
+
"save_at_stop": (
|
|
201
|
+
"save a handoff with the handoff skill, then tell the user which file was "
|
|
202
|
+
"written and that they should run /clear, then '/{handoff} resume <slug>'"
|
|
203
|
+
),
|
|
204
|
+
},
|
|
205
|
+
"codex": {
|
|
206
|
+
"premium_boundary": None,
|
|
207
|
+
"premium_boundaries": CODEX_PREMIUM_BOUNDARIES,
|
|
208
|
+
# Only reached if a rollout omits model_context_window; the CONTEXT_WINDOWS table
|
|
209
|
+
# holds Claude model ids and would never match a Codex one.
|
|
210
|
+
"default_window": 272_000,
|
|
211
|
+
# Codex's permission_mode values are undocumented; until one is confirmed to mean
|
|
212
|
+
# "never ask", no value enables the auto-save escalation.
|
|
213
|
+
"auto_modes": (),
|
|
214
|
+
# Only hookSpecificOutput.additionalContext is documented for Codex, so the
|
|
215
|
+
# user-facing line is folded into the model-facing text instead.
|
|
216
|
+
"emits_system_message": False,
|
|
217
|
+
"act_now": "Save a handoff now and continue in a fresh session.",
|
|
218
|
+
"act_later": "consider saving a handoff and continuing in a fresh session",
|
|
219
|
+
"recommend": (
|
|
220
|
+
"tell the user you are near the context limit, then follow "
|
|
221
|
+
".agents/skills/{handoff}/SKILL.md to save a handoff and have them start a "
|
|
222
|
+
"fresh session; when its save test calls for no file, only tell them to start a "
|
|
223
|
+
"fresh session"
|
|
224
|
+
),
|
|
225
|
+
"suggest": (
|
|
226
|
+
"first apply the save test in .agents/skills/{handoff}/SKILL.md yourself; if it "
|
|
227
|
+
"calls for a file, suggest saving a handoff and continuing in a fresh session, "
|
|
228
|
+
"otherwise suggest a fresh session and say nothing is left to hand off, with a prompt to copy into the fresh session"
|
|
229
|
+
),
|
|
230
|
+
"save_now": (
|
|
231
|
+
"follow .agents/skills/{handoff}/SKILL.md and save a handoff right now - finish "
|
|
232
|
+
"only an in-flight atomic edit first, nothing new. Apply its own test: with "
|
|
233
|
+
"every change committed, no step left and nothing running, write no file and say "
|
|
234
|
+
"that instead. If a file is warranted, tell the user exactly which one was "
|
|
235
|
+
"written and that they should start a fresh codex session and resume from it."
|
|
236
|
+
),
|
|
237
|
+
"save_at_stop": (
|
|
238
|
+
"save a handoff via .agents/skills/{handoff}/SKILL.md, then tell the user which "
|
|
239
|
+
"file was written and that a fresh session should resume from it"
|
|
240
|
+
),
|
|
241
|
+
},
|
|
242
|
+
}
|
|
243
|
+
|
|
244
|
+
|
|
245
|
+
def handoff_skill(root):
|
|
246
|
+
"""Name of the handoff skill installed in this repo.
|
|
247
|
+
|
|
248
|
+
The generator prefixes every skill it writes, so the command is /ethlete-handoff in a
|
|
249
|
+
consumer repo - but a repo that excludes the generated copy and ships its own (the SDK
|
|
250
|
+
itself) has a plain /handoff instead. Naming the wrong one sends the user to a slash
|
|
251
|
+
command that does not exist, so it is read off disk rather than assumed.
|
|
252
|
+
"""
|
|
253
|
+
for name in ("ethlete-handoff", "handoff"):
|
|
254
|
+
for skills_dir in (".claude/skills", ".agents/skills"):
|
|
255
|
+
if os.path.isdir(os.path.join(root or ".", skills_dir, name)):
|
|
256
|
+
return name
|
|
257
|
+
return "ethlete-handoff"
|
|
258
|
+
|
|
259
|
+
|
|
260
|
+
SELF_RESTART = (
|
|
261
|
+
"you run in an agent-sessions tmux session, so do not hand this step to the user: apply the {handoff} skill's save "
|
|
262
|
+
"test, save if it calls for a file, then follow its 'Clear and resume on your own' section "
|
|
263
|
+
"and restart this session yourself with the agent-sessions `restart` command - with no file, "
|
|
264
|
+
"restart with a prompt that names the next step"
|
|
265
|
+
)
|
|
266
|
+
USER_DIRECTED_KEYS = ("recommend", "suggest", "save_now", "save_at_stop")
|
|
267
|
+
|
|
268
|
+
|
|
269
|
+
def can_self_restart(root):
|
|
270
|
+
"""True only in a tmux session the agent-sessions `restart` command can find and drive."""
|
|
271
|
+
if not os.environ.get("TMUX") or not root:
|
|
272
|
+
return False
|
|
273
|
+
if not any(
|
|
274
|
+
os.path.isfile(os.path.join(root, ".agents/skills", name, "agent-sessions.mjs"))
|
|
275
|
+
for name in ("ethlete-agent-sessions", "agent-sessions")
|
|
276
|
+
):
|
|
61
277
|
return False
|
|
278
|
+
try:
|
|
279
|
+
session = subprocess.run(
|
|
280
|
+
["tmux", "display-message", "-p", "#S"], capture_output=True, text=True, timeout=2
|
|
281
|
+
).stdout.strip()
|
|
282
|
+
except (OSError, subprocess.SubprocessError):
|
|
283
|
+
return False
|
|
284
|
+
return session.endswith("@" + re.sub(r"[.:]", "-", os.path.basename(os.path.normpath(root))))
|
|
285
|
+
|
|
286
|
+
|
|
287
|
+
def resolve_profile(profile, skill, self_restart=False):
|
|
288
|
+
"""Fills the installed handoff skill's name into a profile's message fragments."""
|
|
289
|
+
resolved = {**profile, **{key: SELF_RESTART for key in USER_DIRECTED_KEYS}} if self_restart else profile
|
|
290
|
+
return {
|
|
291
|
+
key: value.replace("{handoff}", skill) if isinstance(value, str) else value
|
|
292
|
+
for key, value in resolved.items()
|
|
293
|
+
}
|
|
294
|
+
|
|
295
|
+
|
|
296
|
+
def agent_name(argv):
|
|
297
|
+
"""The --agent value, defaulting to claude - older registrations pass no flag."""
|
|
298
|
+
for index, arg in enumerate(argv):
|
|
299
|
+
if arg == "--agent" and index + 1 < len(argv):
|
|
300
|
+
return argv[index + 1]
|
|
301
|
+
if arg.startswith("--agent="):
|
|
302
|
+
return arg.split("=", 1)[1]
|
|
303
|
+
return "claude"
|
|
304
|
+
|
|
305
|
+
|
|
306
|
+
def repo_root(data):
|
|
307
|
+
"""Repo root: the env var the agent sets, else derived from this script's own location.
|
|
308
|
+
|
|
309
|
+
The script always lives at <root>/.<agent>/hooks/ethlete/context-warning.py, so walking
|
|
310
|
+
four levels up works for any agent that has no project-dir variable of its own.
|
|
311
|
+
"""
|
|
312
|
+
for variable in ("CLAUDE_PROJECT_DIR", "CODEX_PROJECT_DIR"):
|
|
313
|
+
value = os.environ.get(variable)
|
|
314
|
+
if value:
|
|
315
|
+
return value
|
|
316
|
+
here = os.path.abspath(__file__)
|
|
317
|
+
derived = os.path.dirname(os.path.dirname(os.path.dirname(os.path.dirname(here))))
|
|
318
|
+
if os.path.isfile(os.path.join(derived, LOCAL_CONFIG_FILE)):
|
|
319
|
+
return derived
|
|
320
|
+
cwd = data.get("cwd")
|
|
321
|
+
return cwd if isinstance(cwd, str) and cwd else derived
|
|
322
|
+
|
|
323
|
+
|
|
324
|
+
def load_local_config(root):
|
|
325
|
+
"""Parsed ethlete-agents.config.local.json at the repo root, or {} if missing/unreadable."""
|
|
326
|
+
if not root:
|
|
327
|
+
return {}
|
|
62
328
|
try:
|
|
63
329
|
with open(os.path.join(root, LOCAL_CONFIG_FILE), encoding="utf-8") as f:
|
|
64
|
-
|
|
330
|
+
config = json.load(f)
|
|
65
331
|
except (OSError, ValueError, AttributeError):
|
|
66
|
-
return
|
|
332
|
+
return {}
|
|
333
|
+
return config if isinstance(config, dict) else {}
|
|
334
|
+
|
|
335
|
+
|
|
336
|
+
def disabled_locally(config):
|
|
337
|
+
"""True when the local config disables this hook (or all hooks) on this machine."""
|
|
338
|
+
disabled = config.get("disableHooks")
|
|
67
339
|
return disabled is True or (isinstance(disabled, list) and HOOK_NAME in disabled)
|
|
68
340
|
|
|
69
341
|
|
|
342
|
+
def auto_handoff_save_disabled(config):
|
|
343
|
+
"""True when the local config opts out of the auto-mode critical-tier auto-save."""
|
|
344
|
+
return config.get("disableAutoHandoffSave") is True
|
|
345
|
+
|
|
346
|
+
|
|
70
347
|
def window_for(model):
|
|
71
348
|
"""Context window for a model id, by first substring match; DEFAULT_WINDOW otherwise."""
|
|
72
349
|
if model:
|
|
@@ -76,135 +353,620 @@ def window_for(model):
|
|
|
76
353
|
return DEFAULT_WINDOW
|
|
77
354
|
|
|
78
355
|
|
|
79
|
-
def
|
|
80
|
-
"""
|
|
356
|
+
def premium_boundary_for(profile, model):
|
|
357
|
+
"""Model-specific pricing boundary, falling back to the agent-wide value."""
|
|
358
|
+
if model:
|
|
359
|
+
for needle, boundary in profile["premium_boundaries"]:
|
|
360
|
+
if needle in model:
|
|
361
|
+
return boundary
|
|
362
|
+
return profile["premium_boundary"]
|
|
363
|
+
|
|
364
|
+
|
|
365
|
+
def tail_lines(path, limit):
|
|
366
|
+
"""(lines newest-first, whole file read) for the last `limit` bytes of a file.
|
|
367
|
+
|
|
368
|
+
A partial first line is dropped, so every returned line is complete.
|
|
369
|
+
"""
|
|
370
|
+
size = os.path.getsize(path)
|
|
371
|
+
start = max(size - limit, 0)
|
|
372
|
+
with open(path, "rb") as f:
|
|
373
|
+
f.seek(start)
|
|
374
|
+
chunk = f.read()
|
|
375
|
+
if start > 0:
|
|
376
|
+
newline = chunk.find(b"\n")
|
|
377
|
+
chunk = chunk[newline + 1 :] if newline != -1 else b""
|
|
378
|
+
return list(reversed(chunk.split(b"\n"))), start == 0
|
|
379
|
+
|
|
380
|
+
|
|
381
|
+
def claude_usage_in(lines, sidechain=False):
|
|
382
|
+
"""(usage, model) from the newest assistant message of the chain in `lines`, or (None, None).
|
|
383
|
+
|
|
384
|
+
A sub-agent's own transcript marks every entry `isSidechain`, so reading one needs `sidechain`.
|
|
385
|
+
"""
|
|
386
|
+
for line in lines:
|
|
387
|
+
if not line.strip():
|
|
388
|
+
continue
|
|
389
|
+
try:
|
|
390
|
+
obj = json.loads(line)
|
|
391
|
+
except (json.JSONDecodeError, UnicodeDecodeError):
|
|
392
|
+
continue
|
|
393
|
+
if obj.get("type") != "assistant" or bool(obj.get("isSidechain")) != sidechain:
|
|
394
|
+
continue
|
|
395
|
+
message = obj.get("message") or {}
|
|
396
|
+
usage = message.get("usage")
|
|
397
|
+
if usage:
|
|
398
|
+
return usage, message.get("model")
|
|
399
|
+
return None, None
|
|
400
|
+
|
|
401
|
+
|
|
402
|
+
def claude_context_state(transcript_path, sidechain=False):
|
|
403
|
+
"""(tokens, model, window, thread id, is sub-agent) from the main-chain assistant message.
|
|
404
|
+
|
|
405
|
+
tokens is its total input tokens (fresh + cache read + cache creation). Claude reports no
|
|
406
|
+
window in the transcript, so it is resolved from the model id by the caller. Only the tail
|
|
407
|
+
of the transcript is read, growing until a usage report is found - this runs on every tool
|
|
408
|
+
batch, and a transcript reaches tens of megabytes.
|
|
409
|
+
"""
|
|
410
|
+
limit = TAIL_BYTES
|
|
411
|
+
while True:
|
|
412
|
+
lines, complete = tail_lines(transcript_path, limit)
|
|
413
|
+
usage, model = claude_usage_in(lines, sidechain)
|
|
414
|
+
if usage:
|
|
415
|
+
tokens = (
|
|
416
|
+
usage.get("input_tokens", 0)
|
|
417
|
+
+ usage.get("cache_read_input_tokens", 0)
|
|
418
|
+
+ usage.get("cache_creation_input_tokens", 0)
|
|
419
|
+
)
|
|
420
|
+
return tokens, model, None, None, sidechain
|
|
421
|
+
if complete or limit >= MAX_TAIL_BYTES:
|
|
422
|
+
return 0, None, None, None, sidechain
|
|
423
|
+
limit *= GROWTH_FACTOR
|
|
424
|
+
|
|
425
|
+
|
|
426
|
+
def find_token_usage_info(node):
|
|
427
|
+
"""The deepest TokenUsageInfo-shaped dict in a parsed rollout line, or None.
|
|
428
|
+
|
|
429
|
+
Codex documents its rollout format as unstable, so this searches for the shape
|
|
430
|
+
(a dict carrying `last_token_usage`) rather than walking a fixed key path.
|
|
431
|
+
"""
|
|
432
|
+
if isinstance(node, dict):
|
|
433
|
+
if isinstance(node.get("last_token_usage"), dict):
|
|
434
|
+
return node
|
|
435
|
+
for value in node.values():
|
|
436
|
+
found = find_token_usage_info(value)
|
|
437
|
+
if found:
|
|
438
|
+
return found
|
|
439
|
+
elif isinstance(node, list):
|
|
440
|
+
for value in node:
|
|
441
|
+
found = find_token_usage_info(value)
|
|
442
|
+
if found:
|
|
443
|
+
return found
|
|
444
|
+
return None
|
|
445
|
+
|
|
81
446
|
|
|
82
|
-
|
|
447
|
+
def codex_context_state(transcript_path):
|
|
448
|
+
"""(tokens, model, window, thread id, is sub-agent) from a Codex rollout.
|
|
449
|
+
|
|
450
|
+
tokens is the last request's `total_tokens` - Codex's own `tokens_in_context_window()`
|
|
451
|
+
is exactly that field, and `last_token_usage` is replaced per request while
|
|
452
|
+
`total_token_usage` accumulates across the whole session.
|
|
83
453
|
"""
|
|
84
|
-
|
|
454
|
+
last_info = None
|
|
85
455
|
last_model = None
|
|
456
|
+
thread_id = None
|
|
457
|
+
is_subagent = False
|
|
86
458
|
with open(transcript_path, encoding="utf-8") as f:
|
|
87
459
|
for line in f:
|
|
88
460
|
try:
|
|
89
461
|
obj = json.loads(line)
|
|
90
462
|
except json.JSONDecodeError:
|
|
91
463
|
continue
|
|
92
|
-
if obj.get("type")
|
|
93
|
-
|
|
94
|
-
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
|
|
98
|
-
|
|
99
|
-
|
|
100
|
-
|
|
101
|
-
|
|
102
|
-
|
|
103
|
-
|
|
104
|
-
|
|
464
|
+
if obj.get("type") == "session_meta":
|
|
465
|
+
payload = obj.get("payload") or {}
|
|
466
|
+
source = payload.get("source")
|
|
467
|
+
thread_id = payload.get("id") or thread_id
|
|
468
|
+
is_subagent = payload.get("thread_source") == "subagent" or (
|
|
469
|
+
isinstance(source, dict) and isinstance(source.get("subagent"), dict)
|
|
470
|
+
)
|
|
471
|
+
model = (obj.get("payload") or {}).get("model") if isinstance(obj.get("payload"), dict) else None
|
|
472
|
+
if isinstance(model, str) and model:
|
|
473
|
+
last_model = model
|
|
474
|
+
info = find_token_usage_info(obj)
|
|
475
|
+
if info:
|
|
476
|
+
last_info = info
|
|
477
|
+
if not last_info:
|
|
478
|
+
return 0, last_model, None, thread_id, is_subagent
|
|
479
|
+
usage = last_info.get("last_token_usage") or {}
|
|
480
|
+
window = last_info.get("model_context_window")
|
|
481
|
+
return (
|
|
482
|
+
usage.get("total_tokens", 0),
|
|
483
|
+
last_model,
|
|
484
|
+
window if isinstance(window, int) else None,
|
|
485
|
+
thread_id,
|
|
486
|
+
is_subagent,
|
|
487
|
+
)
|
|
488
|
+
|
|
489
|
+
|
|
490
|
+
CONTEXT_READERS = {"claude": claude_context_state, "codex": codex_context_state}
|
|
491
|
+
|
|
492
|
+
|
|
493
|
+
HANDOFF_SKILLS = ("handoff", "ethlete-handoff")
|
|
494
|
+
COMMAND_PATTERN = re.compile(r"<command-name>/?([^<]*)</command-name>")
|
|
495
|
+
ARGS_PATTERN = re.compile(r"<command-args>([^<]*)</command-args>")
|
|
496
|
+
|
|
497
|
+
|
|
498
|
+
def is_handoff_save(skill, args):
|
|
499
|
+
return skill in HANDOFF_SKILLS and not (args or "").strip().lower().startswith("resume")
|
|
500
|
+
|
|
501
|
+
|
|
502
|
+
def handoff_save_requested(prompt):
|
|
503
|
+
"""True for a prompt that runs the handoff skill in save mode - a slash command or a plain one."""
|
|
504
|
+
if not isinstance(prompt, str):
|
|
505
|
+
return False
|
|
506
|
+
command = COMMAND_PATTERN.search(prompt)
|
|
507
|
+
if command:
|
|
508
|
+
args = ARGS_PATTERN.search(prompt)
|
|
509
|
+
return is_handoff_save(command.group(1).strip(), args.group(1) if args else "")
|
|
510
|
+
words = prompt.strip().split(None, 1)
|
|
511
|
+
return bool(words) and is_handoff_save(words[0].lstrip("/").lower(), words[1] if len(words) > 1 else "")
|
|
512
|
+
|
|
513
|
+
|
|
514
|
+
def is_turn_start(obj):
|
|
515
|
+
if obj.get("type") != "user" or obj.get("isSidechain") or obj.get("isMeta"):
|
|
516
|
+
return False
|
|
517
|
+
origin = obj.get("origin")
|
|
518
|
+
if isinstance(origin, dict):
|
|
519
|
+
return origin.get("kind") == "human"
|
|
520
|
+
content = (obj.get("message") or {}).get("content")
|
|
521
|
+
return isinstance(content, str)
|
|
522
|
+
|
|
523
|
+
|
|
524
|
+
def starts_handoff(block):
|
|
525
|
+
if not isinstance(block, dict) or block.get("type") != "tool_use":
|
|
526
|
+
return False
|
|
527
|
+
tool_input = block.get("input") or {}
|
|
528
|
+
if block.get("name") == "Skill":
|
|
529
|
+
return is_handoff_save(tool_input.get("skill"), tool_input.get("args"))
|
|
530
|
+
path = tool_input.get("file_path")
|
|
531
|
+
return isinstance(path, str) and path.endswith(".md") and "handoffs" in path.replace("\\", "/").split("/")
|
|
532
|
+
|
|
533
|
+
|
|
534
|
+
def subagent_transcript(data):
|
|
535
|
+
"""The transcript of the Claude sub-agent a hook fired in, or None when it cannot be found.
|
|
536
|
+
|
|
537
|
+
Inside a sub-agent, `transcript_path` is the parent's. Only SubagentStop carries
|
|
538
|
+
`agent_transcript_path`; every other event is resolved through the layout Claude Code writes,
|
|
539
|
+
`<session>.jsonl` next to `<session>/subagents/agent-<agent_id>.jsonl`.
|
|
540
|
+
"""
|
|
541
|
+
explicit = data.get("agent_transcript_path")
|
|
542
|
+
if explicit and os.path.isfile(explicit):
|
|
543
|
+
return explicit
|
|
544
|
+
parent = data.get("transcript_path")
|
|
545
|
+
agent_id = str(data.get("agent_id") or "")
|
|
546
|
+
if not parent or not agent_id:
|
|
547
|
+
return None
|
|
548
|
+
session = os.path.splitext(os.path.basename(parent))[0]
|
|
549
|
+
name = agent_id if agent_id.startswith("agent-") else f"agent-{agent_id}"
|
|
550
|
+
path = os.path.join(os.path.dirname(parent), session, "subagents", f"{name}.jsonl")
|
|
551
|
+
return path if os.path.isfile(path) else None
|
|
552
|
+
|
|
553
|
+
|
|
554
|
+
def handoff_under_way_in_turn(transcript_path):
|
|
555
|
+
"""True when the current Claude turn requested, invoked or wrote a handoff save."""
|
|
556
|
+
lines, _ = tail_lines(transcript_path, TAIL_BYTES)
|
|
557
|
+
for line in lines:
|
|
558
|
+
if not line.strip():
|
|
559
|
+
continue
|
|
560
|
+
try:
|
|
561
|
+
obj = json.loads(line)
|
|
562
|
+
except (json.JSONDecodeError, UnicodeDecodeError):
|
|
563
|
+
continue
|
|
564
|
+
if obj.get("isSidechain"):
|
|
565
|
+
continue
|
|
566
|
+
content = (obj.get("message") or {}).get("content")
|
|
567
|
+
if is_turn_start(obj):
|
|
568
|
+
return handoff_save_requested(content if isinstance(content, str) else None)
|
|
569
|
+
if obj.get("type") == "assistant" and isinstance(content, list) and any(map(starts_handoff, content)):
|
|
570
|
+
return True
|
|
571
|
+
return False
|
|
572
|
+
|
|
573
|
+
|
|
574
|
+
def headroom(tokens, budget):
|
|
575
|
+
"""How much budget is left, as a short human string."""
|
|
576
|
+
left = max(budget - tokens, 0)
|
|
577
|
+
return f"~{left // 1000}k tokens" if left >= 1000 else "under 1k tokens"
|
|
578
|
+
|
|
579
|
+
|
|
580
|
+
def limit_name(priced):
|
|
581
|
+
return "long-context pricing boundary" if priced else "context window"
|
|
582
|
+
|
|
583
|
+
|
|
584
|
+
def cost_of_crossing(priced):
|
|
585
|
+
"""What the user pays for one more token past the budget."""
|
|
586
|
+
if priced:
|
|
587
|
+
return "every later request is billed at the premium rate"
|
|
588
|
+
return "auto-compact fires and the detail of this session is summarised away"
|
|
589
|
+
|
|
590
|
+
|
|
591
|
+
def token_count(tokens):
|
|
592
|
+
"""A token count as a short label: 1M rather than 1000k."""
|
|
593
|
+
if tokens >= 1_000_000 and tokens % 1_000_000 == 0:
|
|
594
|
+
return f"{tokens // 1_000_000}M"
|
|
595
|
+
return f"{tokens // 1000}k"
|
|
596
|
+
|
|
597
|
+
|
|
598
|
+
def session_start_message(budget, window, model):
|
|
599
|
+
"""The one line said before the first request, naming the budget and how to fit it.
|
|
600
|
+
|
|
601
|
+
A session that starts fresh has no assistant message yet, so the model - and with it the
|
|
602
|
+
window - is unknown. The budget is not: it is the pricing boundary either way.
|
|
603
|
+
"""
|
|
604
|
+
budget_label = token_count(budget)
|
|
605
|
+
if not model:
|
|
606
|
+
frame = (
|
|
607
|
+
"past it a model with a larger window bills every request at the premium rate, and a "
|
|
608
|
+
f"{budget_label} model auto-compacts the session away"
|
|
609
|
+
)
|
|
610
|
+
elif budget < window:
|
|
611
|
+
frame = (
|
|
612
|
+
f"the long-context pricing boundary of this model's {token_count(window)} window; "
|
|
613
|
+
"past it every request is billed at the premium rate"
|
|
614
|
+
)
|
|
615
|
+
else:
|
|
616
|
+
frame = "this model's whole context window; past it auto-compact summarises the session away"
|
|
617
|
+
return (
|
|
618
|
+
f"[context-warning hook] Context budget for this session: {budget_label} tokens - {frame}. "
|
|
619
|
+
f"{SESSION_BUDGET} You will be warned at 70%, 85% and 95%."
|
|
105
620
|
)
|
|
106
|
-
return tokens, last_model
|
|
107
621
|
|
|
108
622
|
|
|
109
|
-
def messages(tier, tokens, budget, priced):
|
|
623
|
+
def messages(profile, tier, tokens, budget, priced, auto_mode):
|
|
110
624
|
"""(systemMessage, additionalContext) for a tier.
|
|
111
625
|
|
|
112
|
-
priced: the budget is the pricing boundary, not the window
|
|
626
|
+
priced: the budget is the pricing boundary, not the window - the reason to
|
|
113
627
|
hand off is cost, not an imminent auto-compact.
|
|
628
|
+
auto_mode: from the critical tier on, escalates from "recommend a handoff"
|
|
629
|
+
to "save it now" - see the module docstring for why.
|
|
114
630
|
"""
|
|
115
631
|
k = f"~{tokens // 1000}k"
|
|
116
632
|
pct = round(tokens / budget * 100)
|
|
117
633
|
budget_k = f"{budget // 1000}k"
|
|
118
|
-
|
|
119
|
-
|
|
120
|
-
|
|
121
|
-
|
|
122
|
-
|
|
123
|
-
|
|
124
|
-
f"
|
|
125
|
-
"
|
|
126
|
-
"/handoff to save state and start a fresh session. Do not start new sub-tasks.",
|
|
634
|
+
left = headroom(tokens, budget)
|
|
635
|
+
|
|
636
|
+
if priced:
|
|
637
|
+
approach = "about to cross" if tier >= 2 else "approaching"
|
|
638
|
+
headline = f"Context is at {k} tokens - {approach} the {budget_k} long-context pricing boundary"
|
|
639
|
+
detail = (
|
|
640
|
+
f"The context is at {k} tokens - {pct}% of the {budget_k} long-context "
|
|
641
|
+
f"pricing boundary, {left} left."
|
|
127
642
|
)
|
|
128
|
-
|
|
643
|
+
pressure = "Past it every request is billed at the premium rate. "
|
|
644
|
+
tail = ""
|
|
645
|
+
else:
|
|
646
|
+
headline = f"Context is at {k} tokens ({pct}% of the {budget_k} window)"
|
|
647
|
+
detail = (
|
|
648
|
+
f"The context window is at {k} tokens - {pct}% of this model's "
|
|
649
|
+
f"{budget_k} window, {left} left."
|
|
650
|
+
)
|
|
651
|
+
pressure = ""
|
|
652
|
+
tail = " - auto-compact is imminent" if tier >= 3 else " - auto-compact is close" if tier == 2 else ""
|
|
653
|
+
|
|
654
|
+
if tier >= 3:
|
|
655
|
+
gone = (
|
|
656
|
+
f"The finish-first choice from the last warning is gone: {left} does not "
|
|
657
|
+
f"cover another task."
|
|
658
|
+
)
|
|
659
|
+
if auto_mode:
|
|
660
|
+
return (
|
|
661
|
+
f"🔴 {headline}{tail}. Auto mode is active: saving a handoff now.",
|
|
662
|
+
f"[context-warning hook] {detail} {pressure}{gone} Auto mode is active, so "
|
|
663
|
+
f"{profile['save_now']}",
|
|
664
|
+
)
|
|
129
665
|
return (
|
|
130
|
-
f"🔴
|
|
131
|
-
f"
|
|
132
|
-
f"
|
|
133
|
-
f"this model's {budget_k} window (critical, ≥{int(CRITICAL_FRACTION * 100)}%). "
|
|
134
|
-
"Finish only the immediate step, then recommend the user run /handoff to "
|
|
135
|
-
"save state and start a fresh session. Do not start new sub-tasks.",
|
|
666
|
+
f"🔴 {headline}{tail}. {profile['act_now']}",
|
|
667
|
+
f"[context-warning hook] {detail} {pressure}{gone} Stop after the current tool "
|
|
668
|
+
f"call, then {profile['recommend']}.",
|
|
136
669
|
)
|
|
137
|
-
|
|
670
|
+
|
|
671
|
+
if tier == 2:
|
|
672
|
+
if auto_mode:
|
|
673
|
+
return (
|
|
674
|
+
f"🔴 {headline}{tail}. Auto mode is active: finishing the task, or saving a handoff.",
|
|
675
|
+
f"[context-warning hook] {detail} {pressure}Auto mode is active, so don't "
|
|
676
|
+
f"just recommend a handoff. Choose one of two, and tell the user which. "
|
|
677
|
+
f"{FINISH_FIRST} Otherwise {profile['save_now']}",
|
|
678
|
+
)
|
|
138
679
|
return (
|
|
139
|
-
f"
|
|
140
|
-
f"
|
|
141
|
-
f"
|
|
142
|
-
f"[
|
|
143
|
-
f"{budget_k} long-context pricing boundary, past which every request is "
|
|
144
|
-
"billed at the premium rate. When the current task reaches a natural "
|
|
145
|
-
"stopping point, suggest the user run /handoff to save state and start a "
|
|
146
|
-
"fresh session. Keep working normally until then.",
|
|
680
|
+
f"🔴 {headline}{tail}. {profile['act_now']}",
|
|
681
|
+
f"[context-warning hook] {detail} {pressure}Choose one of two, and tell the user "
|
|
682
|
+
f"which. {FINISH_FIRST} Otherwise finish only the immediate step, then "
|
|
683
|
+
f"{profile['recommend']}. Either way, start no new sub-tasks.",
|
|
147
684
|
)
|
|
685
|
+
|
|
686
|
+
return (
|
|
687
|
+
f"🟡 {headline}. At the next natural stopping point, {profile['act_later']}.",
|
|
688
|
+
f"[context-warning hook] {detail} {pressure}Handoff mode is active from here on. "
|
|
689
|
+
f"{HANDOFF_MODE} Keep working; when the current task reaches a natural stopping "
|
|
690
|
+
f"point, {profile['suggest']}.",
|
|
691
|
+
)
|
|
692
|
+
|
|
693
|
+
|
|
694
|
+
def stop_messages(profile, tier, tokens, budget, priced):
|
|
695
|
+
"""(systemMessage, additionalContext) for a turn that is about to end over budget.
|
|
696
|
+
|
|
697
|
+
The turn continues after this, so the agent acts on it without the user having to
|
|
698
|
+
send a message. It is the only point where the agent is between tasks and can still
|
|
699
|
+
spend tokens, so the choice is put here in full - including the option to cross the
|
|
700
|
+
boundary on purpose, which some sessions are worth.
|
|
701
|
+
"""
|
|
702
|
+
k = f"~{tokens // 1000}k"
|
|
703
|
+
pct = round(tokens / budget * 100)
|
|
704
|
+
budget_k = f"{budget // 1000}k"
|
|
705
|
+
left = headroom(tokens, budget)
|
|
706
|
+
options = [
|
|
707
|
+
f"Hand off: {profile['save_at_stop']}.",
|
|
708
|
+
(
|
|
709
|
+
"Cross the boundary on purpose: take this only for a reason you can name in one "
|
|
710
|
+
"sentence right now, for example the task is one step from done, or this session "
|
|
711
|
+
f"holds reasoning a handoff file would lose. Say the reason, say that "
|
|
712
|
+
f"{cost_of_crossing(priced)}, and keep working."
|
|
713
|
+
),
|
|
714
|
+
]
|
|
715
|
+
if tier < 3:
|
|
716
|
+
options.insert(0, f"Finish the task now: {FINISH_FIRST}")
|
|
717
|
+
numbered = " ".join(f"{index}. {text}" for index, text in enumerate(options, start=1))
|
|
718
|
+
withdrawn = (
|
|
719
|
+
""
|
|
720
|
+
if tier < 3
|
|
721
|
+
else f" The finish-first option is withdrawn at this tier: {left} does not cover another task."
|
|
722
|
+
)
|
|
723
|
+
return (
|
|
724
|
+
f"🔴 Turn ending at {k} tokens ({pct}% of the {budget_k} {limit_name(priced)}). "
|
|
725
|
+
f"Deciding whether to hand off.",
|
|
726
|
+
f"[context-warning hook] This turn is about to end at {k} tokens - {pct}% of the "
|
|
727
|
+
f"{budget_k} {limit_name(priced)}, {left} left. Past it {cost_of_crossing(priced)}."
|
|
728
|
+
f"{withdrawn} Do not end the turn without taking one of these, and tell the user "
|
|
729
|
+
f"which you took. {numbered}",
|
|
730
|
+
)
|
|
731
|
+
|
|
732
|
+
|
|
733
|
+
def reminder(tier, tokens, budget, priced):
|
|
734
|
+
"""The short note repeated on every prompt after a tier was already warned about.
|
|
735
|
+
|
|
736
|
+
A single warning at 70% is stale by the time the budget is actually tight, and the
|
|
737
|
+
agent has no way to read its own token count between prompts.
|
|
738
|
+
"""
|
|
739
|
+
stop = (
|
|
740
|
+
" You were already told to hand off. Do not take on new work."
|
|
741
|
+
if tier >= 2
|
|
742
|
+
else ""
|
|
743
|
+
)
|
|
744
|
+
return (
|
|
745
|
+
f"[context-warning hook] Handoff mode is active: {headroom(tokens, budget)} left of "
|
|
746
|
+
f"the {budget // 1000}k {limit_name(priced)}. {HANDOFF_MODE}{stop}"
|
|
747
|
+
)
|
|
748
|
+
|
|
749
|
+
|
|
750
|
+
SUBAGENT_HAND_BACK = (
|
|
751
|
+
"Finish only the atomic edit in flight - start nothing new. Commit what is committable, "
|
|
752
|
+
"then end your run with the hand-back report to the parent agent: what is done (with the "
|
|
753
|
+
"commit), the findings, and the exact remaining steps - files, commands, what 'done' means "
|
|
754
|
+
"- so a fresh sub-agent can carry them out. That report is your handoff; a sub-agent cannot "
|
|
755
|
+
"hand off to a new session."
|
|
756
|
+
)
|
|
757
|
+
|
|
758
|
+
|
|
759
|
+
def subagent_message(tier, tokens, budget, priced):
|
|
760
|
+
k = f"~{tokens // 1000}k"
|
|
761
|
+
pct = round(tokens / budget * 100)
|
|
762
|
+
budget_k = f"{budget // 1000}k"
|
|
763
|
+
next_step = (
|
|
764
|
+
SUBAGENT_HAND_BACK
|
|
765
|
+
if tier >= 2
|
|
766
|
+
else "Wrap up: keep reads and tool output small, take no detours, and plan to hand back "
|
|
767
|
+
"to the parent agent before 85%. If the task will not fit, finish the current slice and "
|
|
768
|
+
"hand back the rest as exact remaining steps."
|
|
769
|
+
)
|
|
770
|
+
return (
|
|
771
|
+
f"[context-warning hook] This warning applies to a sub-agent thread, not the "
|
|
772
|
+
f"parent/main agent. This sub-agent is at {k} tokens - {pct}% of its {budget_k} "
|
|
773
|
+
f"{limit_name(priced)}. {next_step} Do not create a user-facing session handoff or "
|
|
774
|
+
f"claim that the main agent's context is full."
|
|
775
|
+
)
|
|
776
|
+
|
|
777
|
+
|
|
778
|
+
def subagent_stop_message(tier, tokens, budget, priced):
|
|
779
|
+
return (
|
|
780
|
+
f"[context-warning hook] This sub-agent is at ~{tokens // 1000}k tokens - "
|
|
781
|
+
f"{round(tokens / budget * 100)}% of its {budget // 1000}k {limit_name(priced)}. "
|
|
782
|
+
f"Take on no further task work. {SUBAGENT_HAND_BACK} If your last message already is that "
|
|
783
|
+
f"report, repeat it unchanged and end."
|
|
784
|
+
)
|
|
785
|
+
|
|
786
|
+
|
|
787
|
+
def subagent_reminder(tokens, budget, priced):
|
|
148
788
|
return (
|
|
149
|
-
f"
|
|
150
|
-
f"
|
|
151
|
-
f"
|
|
152
|
-
f"
|
|
153
|
-
"current task reaches a natural stopping point, suggest the user run "
|
|
154
|
-
"/handoff to save state and start a fresh session. Keep working normally until then.",
|
|
789
|
+
f"[context-warning hook] Handoff mode is active for this sub-agent thread, not the "
|
|
790
|
+
f"parent/main agent: {headroom(tokens, budget)} left of its {budget // 1000}k "
|
|
791
|
+
f"{limit_name(priced)}. Keep findings compact and send them to the parent agent "
|
|
792
|
+
f"before the budget runs out. Do not create a user-facing session handoff."
|
|
155
793
|
)
|
|
156
794
|
|
|
157
795
|
|
|
796
|
+
def emit(event, additional_context, system_message=None):
|
|
797
|
+
payload = {
|
|
798
|
+
"hookSpecificOutput": {
|
|
799
|
+
"hookEventName": event,
|
|
800
|
+
"additionalContext": additional_context,
|
|
801
|
+
}
|
|
802
|
+
}
|
|
803
|
+
if system_message:
|
|
804
|
+
payload["systemMessage"] = system_message
|
|
805
|
+
payload["suppressOutput"] = True
|
|
806
|
+
print(json.dumps(payload))
|
|
807
|
+
|
|
808
|
+
|
|
809
|
+
def read_state(path):
|
|
810
|
+
"""{"tier": n, "stop_tier": n, "saving": bool} from the state file; zeroes when absent or unreadable."""
|
|
811
|
+
empty = {"tier": 0, "stop_tier": 0, "saving": False}
|
|
812
|
+
try:
|
|
813
|
+
with open(path, encoding="utf-8") as f:
|
|
814
|
+
raw = f.read().strip()
|
|
815
|
+
except OSError:
|
|
816
|
+
return empty
|
|
817
|
+
try:
|
|
818
|
+
state = json.loads(raw)
|
|
819
|
+
if isinstance(state, dict):
|
|
820
|
+
return {
|
|
821
|
+
"tier": int(state.get("tier", 0)),
|
|
822
|
+
"stop_tier": int(state.get("stop_tier", 0)),
|
|
823
|
+
"saving": state.get("saving") is True,
|
|
824
|
+
}
|
|
825
|
+
except (ValueError, TypeError):
|
|
826
|
+
pass
|
|
827
|
+
try:
|
|
828
|
+
return {**empty, "tier": int(raw or 0)}
|
|
829
|
+
except ValueError:
|
|
830
|
+
return empty
|
|
831
|
+
|
|
832
|
+
|
|
833
|
+
def write_state(path, state):
|
|
834
|
+
try:
|
|
835
|
+
with open(path, "w", encoding="utf-8") as f:
|
|
836
|
+
json.dump(state, f)
|
|
837
|
+
except OSError:
|
|
838
|
+
pass
|
|
839
|
+
|
|
840
|
+
|
|
158
841
|
def main():
|
|
159
|
-
|
|
842
|
+
agent = agent_name(sys.argv[1:])
|
|
843
|
+
profile = AGENT_PROFILES.get(agent)
|
|
844
|
+
if not profile:
|
|
160
845
|
return
|
|
846
|
+
|
|
161
847
|
data = json.load(sys.stdin)
|
|
162
|
-
|
|
163
|
-
|
|
164
|
-
|
|
848
|
+
event = data.get("hook_event_name") or "UserPromptSubmit"
|
|
849
|
+
root = repo_root(data)
|
|
850
|
+
local_config = load_local_config(root)
|
|
851
|
+
if disabled_locally(local_config):
|
|
165
852
|
return
|
|
166
853
|
|
|
167
|
-
|
|
168
|
-
window = window_for(model)
|
|
169
|
-
budget = min(window, PREMIUM_BOUNDARY)
|
|
170
|
-
warn_tokens = int(budget * WARN_FRACTION)
|
|
171
|
-
critical_tokens = int(budget * CRITICAL_FRACTION)
|
|
172
|
-
tier = 2 if tokens >= critical_tokens else 1 if tokens >= warn_tokens else 0
|
|
854
|
+
profile = resolve_profile(profile, handoff_skill(root), self_restart=can_self_restart(root))
|
|
173
855
|
|
|
174
|
-
|
|
175
|
-
|
|
176
|
-
|
|
177
|
-
|
|
178
|
-
|
|
179
|
-
|
|
180
|
-
|
|
181
|
-
|
|
182
|
-
|
|
856
|
+
claude_subagent = agent == "claude" and bool(data.get("agent_id"))
|
|
857
|
+
if claude_subagent:
|
|
858
|
+
if event not in ("PostToolBatch", "Stop", "SubagentStop"):
|
|
859
|
+
return
|
|
860
|
+
transcript_path = subagent_transcript(data)
|
|
861
|
+
session_id = f"{data.get('session_id', 'unknown')}-{data.get('agent_id')}"
|
|
862
|
+
else:
|
|
863
|
+
if event == "SubagentStop":
|
|
864
|
+
return
|
|
865
|
+
transcript_path = data.get("transcript_path")
|
|
866
|
+
session_id = data.get("session_id", "unknown")
|
|
867
|
+
auto_mode = data.get("permission_mode") in profile["auto_modes"] and not auto_handoff_save_disabled(local_config)
|
|
868
|
+
has_transcript = bool(transcript_path) and os.path.isfile(transcript_path)
|
|
183
869
|
|
|
184
|
-
|
|
185
|
-
|
|
186
|
-
|
|
187
|
-
|
|
188
|
-
|
|
189
|
-
|
|
870
|
+
# SessionStart fires before the first request, so it must not need a transcript to exist.
|
|
871
|
+
if not has_transcript and event != "SessionStart":
|
|
872
|
+
return
|
|
873
|
+
|
|
874
|
+
if has_transcript:
|
|
875
|
+
if claude_subagent:
|
|
876
|
+
tokens, model, reported_window, thread_id, is_subagent = claude_context_state(transcript_path, True)
|
|
877
|
+
else:
|
|
878
|
+
tokens, model, reported_window, thread_id, is_subagent = CONTEXT_READERS[agent](transcript_path)
|
|
879
|
+
else:
|
|
880
|
+
tokens, model, reported_window, thread_id, is_subagent = 0, None, None, None, False
|
|
881
|
+
window = reported_window or profile["default_window"] or window_for(model)
|
|
882
|
+
boundary = premium_boundary_for(profile, model)
|
|
883
|
+
budget = min(window, boundary) if boundary else window
|
|
884
|
+
priced = budget < window
|
|
885
|
+
|
|
886
|
+
if event == "SessionStart":
|
|
887
|
+
emit(event, session_start_message(budget, window, model))
|
|
888
|
+
return
|
|
889
|
+
|
|
890
|
+
tier = 0
|
|
891
|
+
for level, fraction in ((3, FINAL_FRACTION), (2, CRITICAL_FRACTION), (1, WARN_FRACTION)):
|
|
892
|
+
if tokens >= int(budget * fraction):
|
|
893
|
+
tier = level
|
|
894
|
+
break
|
|
895
|
+
|
|
896
|
+
state_id = thread_id or session_id
|
|
897
|
+
safe_state_id = "".join(char for char in str(state_id) if char.isalnum() or char in "-_")[:128]
|
|
898
|
+
state_file = os.path.join(tempfile.gettempdir(), f"{agent}-context-warning-{safe_state_id or 'unknown'}")
|
|
899
|
+
state = read_state(state_file)
|
|
900
|
+
prev_tier = state["tier"]
|
|
901
|
+
|
|
902
|
+
if event == "UserPromptSubmit":
|
|
903
|
+
saving = handoff_save_requested(data.get("prompt"))
|
|
904
|
+
if saving != state["saving"]:
|
|
905
|
+
state = {**state, "saving": saving}
|
|
906
|
+
write_state(state_file, state)
|
|
907
|
+
if saving:
|
|
908
|
+
return
|
|
909
|
+
elif tier and (
|
|
910
|
+
state["saving"] or (agent == "claude" and not is_subagent and handoff_under_way_in_turn(transcript_path))
|
|
911
|
+
):
|
|
912
|
+
return
|
|
913
|
+
|
|
914
|
+
if claude_subagent and event in ("Stop", "SubagentStop"):
|
|
915
|
+
# A sub-agent's end of turn ends its run, so the critical tier blocks it once per tier
|
|
916
|
+
# and the reason becomes its next instruction: hand back, do not start new work.
|
|
917
|
+
if tier < 2 or tier <= state["stop_tier"]:
|
|
918
|
+
return
|
|
919
|
+
write_state(state_file, {**state, "stop_tier": tier})
|
|
920
|
+
print(json.dumps({"decision": "block", "reason": subagent_stop_message(tier, tokens, budget, priced)}))
|
|
921
|
+
return
|
|
922
|
+
|
|
923
|
+
if event == "Stop":
|
|
924
|
+
# Below the critical tier the mid-run notice is enough; interrupting the end of a turn
|
|
925
|
+
# at 70% costs more than it saves.
|
|
926
|
+
if tier < 2 or tier <= state["stop_tier"] or is_subagent:
|
|
927
|
+
return
|
|
928
|
+
write_state(state_file, {**state, "stop_tier": tier})
|
|
929
|
+
system_message, additional_context = stop_messages(profile, tier, tokens, budget, priced)
|
|
930
|
+
if profile["emits_system_message"]:
|
|
931
|
+
emit(event, additional_context, system_message)
|
|
932
|
+
else:
|
|
933
|
+
emit(event, additional_context)
|
|
934
|
+
return
|
|
935
|
+
|
|
936
|
+
orders_save = auto_mode and tier >= 2 and tier > prev_tier and not is_subagent
|
|
937
|
+
if tier != prev_tier or orders_save:
|
|
938
|
+
write_state(
|
|
939
|
+
state_file,
|
|
940
|
+
{**state, "tier": tier, "stop_tier": min(state["stop_tier"], tier), "saving": state["saving"] or orders_save},
|
|
941
|
+
)
|
|
190
942
|
|
|
191
943
|
if tier <= prev_tier:
|
|
192
|
-
|
|
193
|
-
|
|
194
|
-
|
|
195
|
-
|
|
196
|
-
|
|
197
|
-
|
|
198
|
-
|
|
199
|
-
|
|
200
|
-
|
|
201
|
-
|
|
202
|
-
|
|
203
|
-
|
|
204
|
-
},
|
|
205
|
-
}
|
|
944
|
+
# Below the first threshold, or the context shrank - the state was re-armed above.
|
|
945
|
+
if tier == 0:
|
|
946
|
+
return
|
|
947
|
+
# Only a prompt repeats the reminder. On a tool batch it would be re-injected before
|
|
948
|
+
# every model request, which costs more context than it saves.
|
|
949
|
+
if event != "UserPromptSubmit":
|
|
950
|
+
return
|
|
951
|
+
emit(
|
|
952
|
+
event,
|
|
953
|
+
subagent_reminder(tokens, budget, priced)
|
|
954
|
+
if is_subagent
|
|
955
|
+
else reminder(tier, tokens, budget, priced),
|
|
206
956
|
)
|
|
957
|
+
return
|
|
958
|
+
|
|
959
|
+
if is_subagent:
|
|
960
|
+
emit(event, subagent_message(tier, tokens, budget, priced))
|
|
961
|
+
return
|
|
962
|
+
|
|
963
|
+
system_message, additional_context = messages(
|
|
964
|
+
profile, tier, tokens, budget, priced, auto_mode
|
|
207
965
|
)
|
|
966
|
+
if profile["emits_system_message"]:
|
|
967
|
+
emit(event, additional_context, system_message)
|
|
968
|
+
else:
|
|
969
|
+
emit(event, f"{additional_context}\n\nTell the user: {system_message}")
|
|
208
970
|
|
|
209
971
|
|
|
210
972
|
if __name__ == "__main__":
|