@ethlete/agent-rules 0.1.0-next.31 → 0.1.0-next.33
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 +50 -0
- package/README.md +7 -1
- package/content/hooks/background-design-commands.py +178 -0
- package/content/hooks/builder-chat-words.py +269 -0
- package/content/hooks/context-warning.py +47 -19
- package/content/hooks/git-safety.py +208 -0
- package/content/mods/plugins/e2e-progress/.claude-plugin/plugin.json +1 -1
- package/content/rules/nx-layout.md +5 -2
- package/content/rules/subagent-models.md +2 -2
- package/content/skills/agent-sessions/SKILL.md +9 -5
- package/content/skills/agent-sessions/agent-sessions.mjs +283 -34
- package/content/skills/design-exploration/SKILL.md +47 -547
- package/content/skills/design-exploration/reference-check-and-notes.md +92 -0
- package/content/skills/design-exploration/reference-flows-and-wireframes.md +78 -0
- package/content/skills/design-exploration/reference-inbox-and-mcp.md +54 -0
- package/content/skills/design-exploration/reference-order-and-design-system.md +124 -0
- package/content/skills/design-exploration/reference-products-and-sketches.md +211 -0
- package/content/skills/design-exploration/reference-shots.md +25 -0
- package/content/skills/git-commit/SKILL.md +10 -0
- package/content/skills/grilling/SKILL.md +1 -0
- package/content/skills/handoff/SKILL.md +29 -6
- package/package.json +1 -1
- package/src/index.js +2 -1
- package/src/index.js.map +1 -1
- package/src/lib/git-flow-repair.d.ts +1 -1
- package/src/lib/git-flow-start.d.ts +1 -1
- package/src/lib/migrate.js +2 -1
- package/src/lib/migrate.js.map +1 -1
- package/src/lib/output-style-command.d.ts +1 -1
- package/src/lib/prettier.d.ts +21 -0
- package/src/lib/prettier.js +51 -0
- package/src/lib/prettier.js.map +1 -0
- package/src/lib/sync.d.ts +1 -1
- package/src/lib/sync.js +9 -2
- package/src/lib/sync.js.map +1 -1
- package/src/lib/targets/hooks-shared.d.ts +3 -3
- package/src/lib/targets/hooks-shared.js +58 -20
- package/src/lib/targets/hooks-shared.js.map +1 -1
package/CHANGELOG.md
CHANGED
|
@@ -1,5 +1,55 @@
|
|
|
1
1
|
# @ethlete/agent-rules
|
|
2
2
|
|
|
3
|
+
## 0.1.0-next.33
|
|
4
|
+
|
|
5
|
+
### Minor Changes
|
|
6
|
+
|
|
7
|
+
- `agent-sessions restart --process` starts the agent as a new process in its tmux pane, so a plugin, settings or `PATH` change takes effect.
|
|
8
|
+
|
|
9
|
+
### Patch Changes
|
|
10
|
+
|
|
11
|
+
- The builder Stop hook now blocks a turn that ends with more than one line of chat text. `restart --process` keeps the session's role. Resuming a role handoff loads its role skill.
|
|
12
|
+
- The builder Stop hook now counts the final message of the turn from `last_assistant_message`. Claude Code calls the hook before that message reaches the transcript.
|
|
13
|
+
- `agent-sessions restart` treats a pane that shows "Waiting for" background agents as busy, and the skill says to mail the grill session when a self-restart is denied.
|
|
14
|
+
- `agent-sessions restart` accepts the paired builder or grill/docs session as target and logs each peer restart to `restart.log`.
|
|
15
|
+
- `agent-sessions restart` no longer hangs when the background-agent panel is open, and it tells the caller and `--notify` agents when it waits or gives up.
|
|
16
|
+
- `agent-sessions restart --process` keeps the tmux session of the agent, does not send the first prompt of `run` again, and answers the background work dialog of Claude Code with "exit and stop tasks".
|
|
17
|
+
- `agent-sessions restart --process` no longer passes a stored prompt twice, and `run` switches only the client of the calling tmux session.
|
|
18
|
+
- The design exploration skill documents `et design sketch covers` (ADR 0222).
|
|
19
|
+
- `et design sketch covers` on a multi-target sketch takes the part, `<layer>/<name>:<state>[@x,y,w,h]`, and refuses a bare state; compare matches a covered DIFF against that part.
|
|
20
|
+
- The `background-design-commands` hook matches long design commands only at command position, so a heredoc, an unquoted `echo` or a `git commit` message that names `design check` no longer gets denied.
|
|
21
|
+
- The builder Stop hook now parses each Bash command into its simple commands. A heredoc body or a quoted string that names `agent-sessions.mjs send` or `git commit` no longer counts as a report or a commit.
|
|
22
|
+
- The builder Stop hook also blocks a stop one time when the turn ran `git commit` or `git push` and sent no report with `SendMessage` or `agent-sessions send`.
|
|
23
|
+
- The design-exploration skill now describes build-ready, a current check record from `design check`, instead of the removed `design go` and `design accept` commands.
|
|
24
|
+
- The git-safety hook now allows a pathless `git commit` that concludes a merge, no longer denies commands that only mention `git commit` in a grep pattern or heredoc, and drops the obsolete `go.json` rule.
|
|
25
|
+
- Fix `agent-sessions restart` never restarting an idle session whose own chat text said "Waiting for": only the spinner line and the footer count as busy.
|
|
26
|
+
|
|
27
|
+
## 0.1.0-next.32
|
|
28
|
+
|
|
29
|
+
### Minor Changes
|
|
30
|
+
|
|
31
|
+
- Add the `background-design-commands` hook: it denies a Bash call that runs a long design command (`design check`, `compare`, `ready`, `sketch ready`, `harness`, `studio:e2e`, `--visible-change`) without `run_in_background`.
|
|
32
|
+
- Add the `git-safety` hook: it denies `git add -A`, `git stash`, `git commit --amend`, a `git commit` without a pathspec and any `go.json` in a commit.
|
|
33
|
+
|
|
34
|
+
### Patch Changes
|
|
35
|
+
|
|
36
|
+
- Add `--role builder` to the agent-sessions `run` command and a `builder-chat-words` Stop hook that logs the chat words of each builder turn.
|
|
37
|
+
- `ethlete-agents sync` and `et` write their JSON files (`.mcp.json`, design and update state) in the checkout's Prettier format.
|
|
38
|
+
- The git-commit skill asks for one commit per finished slice (code, specs, changeset and plan note together), one per sketch round or merge, and no history rewrites in a shared checkout.
|
|
39
|
+
- The design-exploration skill tells parts to use `data-et-auto-surface` and `data-et-surface-interactive` instead of a picked `et-surface--<name>` class.
|
|
40
|
+
- The design-exploration skill keeps its hard rules in SKILL.md and moves the command details into reference files, and the subagent rule says a job of under about 10 tool calls stays in the session.
|
|
41
|
+
- The design-exploration skill tells parts to use `data-et-semantic-color="<type>"` instead of a picked `et-color--<name>` class.
|
|
42
|
+
- The design-exploration skill tells an agent to take every colour from the SDK surface and colour theming, never from its own tokens.
|
|
43
|
+
- The `handoff` skill spawns no sub-agent from 70 %, and a self-restart no longer waits for sub-agents that report into a result file.
|
|
44
|
+
- Fix the nx-layout rule to describe inferred lint and test targets instead of the removed `@nx/eslint:lint` and `@nx/jest:jest` executors.
|
|
45
|
+
- The context-warning hook tells an agent in a tmux pane with the agent-sessions skill to save the handoff and run `restart` on its own session, instead of asking the user to clear. The skill description names self-restart.
|
|
46
|
+
- Let `git stash list` and `git stash show` through the git-safety hook. Every writing stash subcommand stays refused, and the refusal names it.
|
|
47
|
+
- `sync` keeps the existing order of hook events and entries in `.claude/settings.json` and only updates, appends or removes its own entries, so a second run is a no-op.
|
|
48
|
+
- The design skill now says visible UI is built only from a picked sketch, and the grilling skill says to decide technical questions yourself.
|
|
49
|
+
- The agent-sessions skill notes that `yarn commit` runs the related specs before it takes the index lock.
|
|
50
|
+
- `et design check` fails a frame-loaded design module that awaits at the top level (`TOP-LEVEL-AWAIT`), which can hang WebKit; a call variant may default-export a promise of its drawing instead.
|
|
51
|
+
- `et design sketch report` refuses a ready sketch. Add `--reopen` to move it back to in progress when the user asks for a change.
|
|
52
|
+
|
|
3
53
|
## 0.1.0-next.31
|
|
4
54
|
|
|
5
55
|
### Minor Changes
|
package/README.md
CHANGED
|
@@ -312,7 +312,13 @@ per hook in the config:
|
|
|
312
312
|
|
|
313
313
|
```json
|
|
314
314
|
{
|
|
315
|
-
"hooks": [
|
|
315
|
+
"hooks": [
|
|
316
|
+
"context-warning",
|
|
317
|
+
"subagent-model-policy",
|
|
318
|
+
"background-design-commands",
|
|
319
|
+
"git-safety",
|
|
320
|
+
"builder-chat-words"
|
|
321
|
+
]
|
|
316
322
|
}
|
|
317
323
|
```
|
|
318
324
|
|
|
@@ -0,0 +1,178 @@
|
|
|
1
|
+
#!/usr/bin/env python3
|
|
2
|
+
"""PreToolUse hook: long design commands must run in the background.
|
|
3
|
+
|
|
4
|
+
`design check`, `design compare`, `design ready`, `design sketch ready`, `design harness`,
|
|
5
|
+
the `studio:e2e` target and the `--visible-change` gate each take minutes. In the foreground
|
|
6
|
+
they block the whole session, so a Bash call that runs one without `run_in_background: true`
|
|
7
|
+
is denied and the agent repeats it in the background.
|
|
8
|
+
|
|
9
|
+
Matches the `yarn design ...`, `node dist/libs/cli/src/index.js design ...` and
|
|
10
|
+
`npx nx ... studio:e2e` forms at command position only. Quoted text, heredoc bodies and
|
|
11
|
+
arguments of other commands (`echo`, `git commit`, `grep`) are ignored, so free text that
|
|
12
|
+
mentions a command does not trip the hook.
|
|
13
|
+
|
|
14
|
+
Can be disabled per machine via a gitignored ethlete-agents.config.local.json at the repo
|
|
15
|
+
root: {"disableHooks": true} or {"disableHooks": ["background-design-commands"]}.
|
|
16
|
+
|
|
17
|
+
Fail-safe: any error exits 0 with no output.
|
|
18
|
+
"""
|
|
19
|
+
|
|
20
|
+
import json
|
|
21
|
+
import os
|
|
22
|
+
import re
|
|
23
|
+
import sys
|
|
24
|
+
|
|
25
|
+
HOOK_NAME = "background-design-commands"
|
|
26
|
+
LOCAL_CONFIG_FILE = "ethlete-agents.config.local.json"
|
|
27
|
+
|
|
28
|
+
LONG_COMMANDS = (
|
|
29
|
+
("design check", re.compile(r"\bdesign\s+check(?=\s|$)")),
|
|
30
|
+
("design compare", re.compile(r"\bdesign\s+compare(?=\s|$)")),
|
|
31
|
+
("design ready", re.compile(r"\bdesign\s+ready(?=\s|$)")),
|
|
32
|
+
("design sketch ready", re.compile(r"\bdesign\s+sketch\s+ready(?=\s|$)")),
|
|
33
|
+
("design harness", re.compile(r"\bdesign\s+harness(?=\s|$)")),
|
|
34
|
+
("studio:e2e", re.compile(r"studio:e2e(?=\s|$|:)")),
|
|
35
|
+
("--visible-change", re.compile(r"(?:^|\s)--visible-change(?=\s|=|$)")),
|
|
36
|
+
)
|
|
37
|
+
|
|
38
|
+
LAUNCHERS = frozenset(
|
|
39
|
+
("yarn", "npx", "pnpm", "pnpx", "npm", "node", "nx", "bun", "bunx", "et", "ethlete-agents", "bash", "sh", "zsh", "time", "env", "nohup", "timeout", "sudo", "exec", "xargs")
|
|
40
|
+
)
|
|
41
|
+
|
|
42
|
+
QUOTED = re.compile(r"\"(?:\\.|[^\"\\])*\"|'[^']*'")
|
|
43
|
+
HEREDOC = re.compile(r"<<-?\s*(?:\"(\w+)\"|'(\w+)'|\\?(\w+))")
|
|
44
|
+
SEPARATORS = re.compile(r"&&|\|\||[;&|\n(){}`]")
|
|
45
|
+
ASSIGNMENT = re.compile(r"^[A-Za-z_]\w*=")
|
|
46
|
+
|
|
47
|
+
|
|
48
|
+
def without_heredoc_bodies(command):
|
|
49
|
+
lines = command.split("\n")
|
|
50
|
+
kept = []
|
|
51
|
+
delimiter = None
|
|
52
|
+
for line in lines:
|
|
53
|
+
if delimiter is not None:
|
|
54
|
+
if line.strip() == delimiter:
|
|
55
|
+
delimiter = None
|
|
56
|
+
continue
|
|
57
|
+
kept.append(line)
|
|
58
|
+
match = HEREDOC.search(line)
|
|
59
|
+
if match:
|
|
60
|
+
delimiter = match.group(1) or match.group(2) or match.group(3)
|
|
61
|
+
return "\n".join(kept)
|
|
62
|
+
|
|
63
|
+
|
|
64
|
+
def command_segments(command):
|
|
65
|
+
"""The shell string split into simple commands, with heredoc bodies and quoted text removed."""
|
|
66
|
+
text = QUOTED.sub(" ", without_heredoc_bodies(command))
|
|
67
|
+
text = text.replace("$(", ";")
|
|
68
|
+
return [segment.strip() for segment in SEPARATORS.split(text) if segment.strip()]
|
|
69
|
+
|
|
70
|
+
|
|
71
|
+
def at_command_position(segment):
|
|
72
|
+
words = [word for word in segment.split() if not ASSIGNMENT.match(word)]
|
|
73
|
+
return bool(words) and os.path.basename(words[0]) in LAUNCHERS
|
|
74
|
+
|
|
75
|
+
|
|
76
|
+
def agent_name(argv):
|
|
77
|
+
for index, arg in enumerate(argv):
|
|
78
|
+
if arg == "--agent" and index + 1 < len(argv):
|
|
79
|
+
return argv[index + 1]
|
|
80
|
+
if arg.startswith("--agent="):
|
|
81
|
+
return arg.split("=", 1)[1]
|
|
82
|
+
return "claude"
|
|
83
|
+
|
|
84
|
+
|
|
85
|
+
def repo_root(data):
|
|
86
|
+
for variable in ("CLAUDE_PROJECT_DIR", "CODEX_PROJECT_DIR"):
|
|
87
|
+
value = os.environ.get(variable)
|
|
88
|
+
if value:
|
|
89
|
+
return value
|
|
90
|
+
here = os.path.abspath(__file__)
|
|
91
|
+
derived = os.path.dirname(os.path.dirname(os.path.dirname(os.path.dirname(here))))
|
|
92
|
+
if os.path.isfile(os.path.join(derived, LOCAL_CONFIG_FILE)):
|
|
93
|
+
return derived
|
|
94
|
+
cwd = data.get("cwd")
|
|
95
|
+
return cwd if isinstance(cwd, str) and cwd else derived
|
|
96
|
+
|
|
97
|
+
|
|
98
|
+
def load_local_config(root):
|
|
99
|
+
if not root:
|
|
100
|
+
return {}
|
|
101
|
+
try:
|
|
102
|
+
with open(os.path.join(root, LOCAL_CONFIG_FILE), encoding="utf-8") as f:
|
|
103
|
+
config = json.load(f)
|
|
104
|
+
except (OSError, ValueError, AttributeError):
|
|
105
|
+
return {}
|
|
106
|
+
return config if isinstance(config, dict) else {}
|
|
107
|
+
|
|
108
|
+
|
|
109
|
+
def disabled_locally(config):
|
|
110
|
+
disabled = config.get("disableHooks")
|
|
111
|
+
return disabled is True or (isinstance(disabled, list) and HOOK_NAME in disabled)
|
|
112
|
+
|
|
113
|
+
|
|
114
|
+
def long_command_in(command):
|
|
115
|
+
"""The name of the first long command the shell string runs at a command position, or None."""
|
|
116
|
+
for segment in command_segments(command):
|
|
117
|
+
if not at_command_position(segment):
|
|
118
|
+
continue
|
|
119
|
+
for name, pattern in LONG_COMMANDS:
|
|
120
|
+
if pattern.search(segment):
|
|
121
|
+
return name
|
|
122
|
+
return None
|
|
123
|
+
|
|
124
|
+
|
|
125
|
+
def reason_for(name):
|
|
126
|
+
return (
|
|
127
|
+
f"`{name}` takes minutes and blocks the session when it runs in the foreground. "
|
|
128
|
+
"Call Bash again with the same command and `run_in_background: true`, then read its output "
|
|
129
|
+
"when the completion notice arrives."
|
|
130
|
+
)
|
|
131
|
+
|
|
132
|
+
|
|
133
|
+
def main():
|
|
134
|
+
if agent_name(sys.argv[1:]) != "claude":
|
|
135
|
+
return
|
|
136
|
+
|
|
137
|
+
data = json.load(sys.stdin)
|
|
138
|
+
|
|
139
|
+
if data.get("tool_name") != "Bash":
|
|
140
|
+
return
|
|
141
|
+
|
|
142
|
+
tool_input = data.get("tool_input")
|
|
143
|
+
|
|
144
|
+
if not isinstance(tool_input, dict) or tool_input.get("run_in_background") is True:
|
|
145
|
+
return
|
|
146
|
+
|
|
147
|
+
command = tool_input.get("command")
|
|
148
|
+
|
|
149
|
+
if not isinstance(command, str):
|
|
150
|
+
return
|
|
151
|
+
|
|
152
|
+
if disabled_locally(load_local_config(repo_root(data))):
|
|
153
|
+
return
|
|
154
|
+
|
|
155
|
+
name = long_command_in(command)
|
|
156
|
+
|
|
157
|
+
if not name:
|
|
158
|
+
return
|
|
159
|
+
|
|
160
|
+
print(
|
|
161
|
+
json.dumps(
|
|
162
|
+
{
|
|
163
|
+
"hookSpecificOutput": {
|
|
164
|
+
"hookEventName": "PreToolUse",
|
|
165
|
+
"permissionDecision": "deny",
|
|
166
|
+
"permissionDecisionReason": reason_for(name),
|
|
167
|
+
}
|
|
168
|
+
}
|
|
169
|
+
)
|
|
170
|
+
)
|
|
171
|
+
|
|
172
|
+
|
|
173
|
+
if __name__ == "__main__":
|
|
174
|
+
try:
|
|
175
|
+
main()
|
|
176
|
+
except Exception:
|
|
177
|
+
pass
|
|
178
|
+
sys.exit(0)
|
|
@@ -0,0 +1,269 @@
|
|
|
1
|
+
#!/usr/bin/env python3
|
|
2
|
+
"""Stop hook: logs the chat text of a builder turn and blocks a turn that ends with more than one line.
|
|
3
|
+
|
|
4
|
+
Only runs in a session started with `--role builder` (ETHLETE_AGENT_ROLE=builder). Appends one
|
|
5
|
+
JSON line to .claude/builder-chat-words.jsonl: timestamp, session_id, words, lines, committed, reported, blocked.
|
|
6
|
+
It blocks the stop once (not again while stop_hook_active is set) when the turn's text blocks and
|
|
7
|
+
last_assistant_message hold more than one non-empty line, or when the turn ran `git commit` or `git push` and sent
|
|
8
|
+
no report (no SendMessage, no `agent-sessions send`). It always exits 0.
|
|
9
|
+
"""
|
|
10
|
+
|
|
11
|
+
import json
|
|
12
|
+
import os
|
|
13
|
+
import sys
|
|
14
|
+
from datetime import datetime, timezone
|
|
15
|
+
|
|
16
|
+
LOG_FILE = os.path.join(".claude", "builder-chat-words.jsonl")
|
|
17
|
+
BLOCK_REASON = (
|
|
18
|
+
"builder: no chat text. Send the report with SendMessage to the grill session; "
|
|
19
|
+
"end the turn with at most one line."
|
|
20
|
+
)
|
|
21
|
+
REPORT_REASON = (
|
|
22
|
+
"builder: this turn committed or pushed and sent no report. Send it to the grill session with SendMessage "
|
|
23
|
+
"or `agent-sessions.mjs send`, then end the turn."
|
|
24
|
+
)
|
|
25
|
+
SEPARATORS = ";&|()\n"
|
|
26
|
+
GIT_OPTIONS_WITH_VALUE = {"-C", "-c", "--git-dir", "--work-tree", "--namespace", "--exec-path"}
|
|
27
|
+
|
|
28
|
+
|
|
29
|
+
def is_prompt(entry):
|
|
30
|
+
if entry.get("type") != "user":
|
|
31
|
+
return False
|
|
32
|
+
content = (entry.get("message") or {}).get("content")
|
|
33
|
+
if isinstance(content, str):
|
|
34
|
+
return True
|
|
35
|
+
return isinstance(content, list) and not any(
|
|
36
|
+
isinstance(block, dict) and block.get("type") == "tool_result" for block in content
|
|
37
|
+
)
|
|
38
|
+
|
|
39
|
+
|
|
40
|
+
def turn_entries(path):
|
|
41
|
+
texts = []
|
|
42
|
+
tools = []
|
|
43
|
+
with open(path, encoding="utf-8") as handle:
|
|
44
|
+
for line in handle:
|
|
45
|
+
try:
|
|
46
|
+
entry = json.loads(line)
|
|
47
|
+
except ValueError:
|
|
48
|
+
continue
|
|
49
|
+
if not isinstance(entry, dict):
|
|
50
|
+
continue
|
|
51
|
+
if is_prompt(entry):
|
|
52
|
+
texts = []
|
|
53
|
+
tools = []
|
|
54
|
+
elif entry.get("type") == "assistant":
|
|
55
|
+
content = (entry.get("message") or {}).get("content")
|
|
56
|
+
if isinstance(content, str):
|
|
57
|
+
texts.append(content)
|
|
58
|
+
elif isinstance(content, list):
|
|
59
|
+
for block in content:
|
|
60
|
+
if not isinstance(block, dict):
|
|
61
|
+
continue
|
|
62
|
+
if block.get("type") == "text":
|
|
63
|
+
texts.append(str(block.get("text", "")))
|
|
64
|
+
elif block.get("type") == "tool_use":
|
|
65
|
+
tools.append(block)
|
|
66
|
+
return texts, tools
|
|
67
|
+
|
|
68
|
+
|
|
69
|
+
def bash_commands(tools):
|
|
70
|
+
for tool in tools:
|
|
71
|
+
if tool.get("name") == "Bash":
|
|
72
|
+
command = (tool.get("input") or {}).get("command")
|
|
73
|
+
if isinstance(command, str):
|
|
74
|
+
yield command
|
|
75
|
+
|
|
76
|
+
|
|
77
|
+
def read_heredoc_delimiter(command, index):
|
|
78
|
+
strip_tabs = command.startswith("-", index)
|
|
79
|
+
if strip_tabs:
|
|
80
|
+
index += 1
|
|
81
|
+
while index < len(command) and command[index] in " \t":
|
|
82
|
+
index += 1
|
|
83
|
+
delimiter = []
|
|
84
|
+
while index < len(command) and command[index] not in " \t" + SEPARATORS + "<>":
|
|
85
|
+
char = command[index]
|
|
86
|
+
if char in "'\"":
|
|
87
|
+
end = command.find(char, index + 1)
|
|
88
|
+
end = len(command) if end == -1 else end
|
|
89
|
+
delimiter.append(command[index + 1 : end])
|
|
90
|
+
index = end + 1
|
|
91
|
+
else:
|
|
92
|
+
if char != "\\":
|
|
93
|
+
delimiter.append(char)
|
|
94
|
+
index += 1
|
|
95
|
+
return "".join(delimiter), strip_tabs, index
|
|
96
|
+
|
|
97
|
+
|
|
98
|
+
def skip_heredoc_bodies(command, index, heredocs):
|
|
99
|
+
for delimiter, strip_tabs in heredocs:
|
|
100
|
+
while index < len(command):
|
|
101
|
+
end = command.find("\n", index)
|
|
102
|
+
end = len(command) if end == -1 else end
|
|
103
|
+
row = command[index:end]
|
|
104
|
+
index = end + 1
|
|
105
|
+
if (row.lstrip("\t") if strip_tabs else row) == delimiter:
|
|
106
|
+
break
|
|
107
|
+
return index
|
|
108
|
+
|
|
109
|
+
|
|
110
|
+
def simple_commands(command):
|
|
111
|
+
commands = [[]]
|
|
112
|
+
word = None
|
|
113
|
+
heredocs = []
|
|
114
|
+
index = 0
|
|
115
|
+
|
|
116
|
+
def end_word():
|
|
117
|
+
nonlocal word
|
|
118
|
+
if word is not None:
|
|
119
|
+
commands[-1].append(word)
|
|
120
|
+
word = None
|
|
121
|
+
|
|
122
|
+
def end_command():
|
|
123
|
+
end_word()
|
|
124
|
+
if commands[-1]:
|
|
125
|
+
commands.append([])
|
|
126
|
+
|
|
127
|
+
while index < len(command):
|
|
128
|
+
char = command[index]
|
|
129
|
+
if char == "\\":
|
|
130
|
+
if command.startswith("\n", index + 1):
|
|
131
|
+
index += 2
|
|
132
|
+
continue
|
|
133
|
+
word = (word or "") + command[index + 1 : index + 2]
|
|
134
|
+
index += 2
|
|
135
|
+
elif char == "'":
|
|
136
|
+
end = command.find("'", index + 1)
|
|
137
|
+
end = len(command) if end == -1 else end
|
|
138
|
+
word = (word or "") + command[index + 1 : end]
|
|
139
|
+
index = end + 1
|
|
140
|
+
elif char == '"':
|
|
141
|
+
index += 1
|
|
142
|
+
chars = []
|
|
143
|
+
while index < len(command) and command[index] != '"':
|
|
144
|
+
if command[index] == "\\" and index + 1 < len(command):
|
|
145
|
+
index += 1
|
|
146
|
+
chars.append(command[index])
|
|
147
|
+
index += 1
|
|
148
|
+
word = (word or "") + "".join(chars)
|
|
149
|
+
index += 1
|
|
150
|
+
elif char == "#" and word is None:
|
|
151
|
+
end = command.find("\n", index)
|
|
152
|
+
index = len(command) if end == -1 else end
|
|
153
|
+
elif command.startswith("<<<", index):
|
|
154
|
+
end_word()
|
|
155
|
+
index += 3
|
|
156
|
+
elif command.startswith("<<", index):
|
|
157
|
+
end_word()
|
|
158
|
+
delimiter, strip_tabs, index = read_heredoc_delimiter(command, index + 2)
|
|
159
|
+
heredocs.append((delimiter, strip_tabs))
|
|
160
|
+
elif char == "\n":
|
|
161
|
+
end_command()
|
|
162
|
+
index = skip_heredoc_bodies(command, index + 1, heredocs)
|
|
163
|
+
heredocs = []
|
|
164
|
+
elif char in SEPARATORS:
|
|
165
|
+
end_command()
|
|
166
|
+
index += 1
|
|
167
|
+
elif char in " \t":
|
|
168
|
+
end_word()
|
|
169
|
+
index += 1
|
|
170
|
+
else:
|
|
171
|
+
word = (word or "") + char
|
|
172
|
+
index += 1
|
|
173
|
+
end_command()
|
|
174
|
+
return [words for words in commands if words]
|
|
175
|
+
|
|
176
|
+
|
|
177
|
+
def command_words(words):
|
|
178
|
+
index = 0
|
|
179
|
+
while index < len(words) and "=" in words[index] and not words[index].startswith("-"):
|
|
180
|
+
index += 1
|
|
181
|
+
return words[index:]
|
|
182
|
+
|
|
183
|
+
|
|
184
|
+
def is_git_commit(words):
|
|
185
|
+
words = command_words(words)
|
|
186
|
+
if not words or os.path.basename(words[0]) != "git":
|
|
187
|
+
return False
|
|
188
|
+
index = 1
|
|
189
|
+
while index < len(words) and words[index].startswith("-"):
|
|
190
|
+
index += 2 if words[index] in GIT_OPTIONS_WITH_VALUE else 1
|
|
191
|
+
return index < len(words) and words[index] in ("commit", "push")
|
|
192
|
+
|
|
193
|
+
|
|
194
|
+
def is_report_send(words):
|
|
195
|
+
words = command_words(words)
|
|
196
|
+
if len(words) < 3:
|
|
197
|
+
return False
|
|
198
|
+
if words[0] == "yarn":
|
|
199
|
+
return words[1:3] == ["agents:sessions", "send"]
|
|
200
|
+
if os.path.basename(words[0]) != "node":
|
|
201
|
+
return False
|
|
202
|
+
scripts = [word for word in words[1:] if not word.startswith("-")]
|
|
203
|
+
return len(scripts) >= 2 and os.path.basename(scripts[0]) == "agent-sessions.mjs" and scripts[1] == "send"
|
|
204
|
+
|
|
205
|
+
|
|
206
|
+
def bash_simple_commands(tools):
|
|
207
|
+
for command in bash_commands(tools):
|
|
208
|
+
yield from simple_commands(command)
|
|
209
|
+
|
|
210
|
+
|
|
211
|
+
def has_commit(tools):
|
|
212
|
+
return any(is_git_commit(words) for words in bash_simple_commands(tools))
|
|
213
|
+
|
|
214
|
+
|
|
215
|
+
def has_report(tools):
|
|
216
|
+
return any(tool.get("name") == "SendMessage" for tool in tools) or any(
|
|
217
|
+
is_report_send(words) for words in bash_simple_commands(tools)
|
|
218
|
+
)
|
|
219
|
+
|
|
220
|
+
|
|
221
|
+
def with_final(texts, final):
|
|
222
|
+
if not isinstance(final, str) or not final.strip():
|
|
223
|
+
return texts
|
|
224
|
+
if texts and texts[-1].strip() == final.strip():
|
|
225
|
+
return texts
|
|
226
|
+
return [*texts, final]
|
|
227
|
+
|
|
228
|
+
|
|
229
|
+
def main():
|
|
230
|
+
if os.environ.get("ETHLETE_AGENT_ROLE") != "builder":
|
|
231
|
+
return
|
|
232
|
+
payload = json.load(sys.stdin)
|
|
233
|
+
path = payload.get("transcript_path")
|
|
234
|
+
final = payload.get("last_assistant_message")
|
|
235
|
+
has_transcript = bool(path) and os.path.isfile(path)
|
|
236
|
+
if not has_transcript and not isinstance(final, str):
|
|
237
|
+
return
|
|
238
|
+
root = os.environ.get("CLAUDE_PROJECT_DIR") or payload.get("cwd") or os.getcwd()
|
|
239
|
+
log = os.path.join(root, LOG_FILE)
|
|
240
|
+
os.makedirs(os.path.dirname(log), exist_ok=True)
|
|
241
|
+
# Claude Code calls the Stop hook before the final text block reaches the transcript.
|
|
242
|
+
texts, tools = turn_entries(path) if has_transcript else ([], [])
|
|
243
|
+
texts = with_final(texts, final)
|
|
244
|
+
lines = sum(1 for text in texts for row in text.splitlines() if row.strip())
|
|
245
|
+
committed = has_commit(tools)
|
|
246
|
+
reported = has_report(tools)
|
|
247
|
+
unreported = committed and not reported
|
|
248
|
+
blocked = (lines > 1 or unreported) and not payload.get("stop_hook_active")
|
|
249
|
+
record = {
|
|
250
|
+
"timestamp": datetime.now(timezone.utc).isoformat(),
|
|
251
|
+
"session_id": payload.get("session_id"),
|
|
252
|
+
"words": sum(len(text.split()) for text in texts),
|
|
253
|
+
"lines": lines,
|
|
254
|
+
"committed": committed,
|
|
255
|
+
"reported": reported,
|
|
256
|
+
"blocked": blocked,
|
|
257
|
+
}
|
|
258
|
+
with open(log, "a", encoding="utf-8") as handle:
|
|
259
|
+
handle.write(json.dumps(record) + "\n")
|
|
260
|
+
if blocked:
|
|
261
|
+
reason = REPORT_REASON if unreported else BLOCK_REASON
|
|
262
|
+
print(json.dumps({"decision": "block", "reason": reason}))
|
|
263
|
+
|
|
264
|
+
|
|
265
|
+
try:
|
|
266
|
+
main()
|
|
267
|
+
except Exception:
|
|
268
|
+
pass
|
|
269
|
+
sys.exit(0)
|
|
@@ -258,37 +258,65 @@ def handoff_skill(root):
|
|
|
258
258
|
|
|
259
259
|
|
|
260
260
|
SELF_RESTART = (
|
|
261
|
-
"you run in an agent-sessions tmux session, so do not hand this step to the user
|
|
262
|
-
"test
|
|
263
|
-
|
|
261
|
+
"you run in an agent-sessions tmux session, so do not hand this step to the user and never ask them to clear: "
|
|
262
|
+
"apply the {handoff} skill's save test and save the handoff if it calls for a file, then run "
|
|
263
|
+
'`node {script} restart {name} "/{handoff} resume"` yourself and end the turn - with no file, '
|
|
264
264
|
"restart with a prompt that names the next step"
|
|
265
265
|
)
|
|
266
|
+
SELF_RESTART_ACT_NOW = (
|
|
267
|
+
"Save the handoff, then run `node {script} restart {name} \"/{handoff} resume\"` yourself and end the turn. "
|
|
268
|
+
"Never ask the user to clear."
|
|
269
|
+
)
|
|
266
270
|
USER_DIRECTED_KEYS = ("recommend", "suggest", "save_now", "save_at_stop")
|
|
267
271
|
|
|
268
272
|
|
|
269
|
-
def
|
|
270
|
-
"""
|
|
271
|
-
|
|
272
|
-
|
|
273
|
-
|
|
274
|
-
|
|
275
|
-
|
|
276
|
-
|
|
277
|
-
|
|
273
|
+
def self_restart_target(root):
|
|
274
|
+
"""The agent-sessions script path and own session name, or None outside a tmux session `restart` can drive."""
|
|
275
|
+
pane = os.environ.get("TMUX_PANE")
|
|
276
|
+
if not pane or not root:
|
|
277
|
+
return None
|
|
278
|
+
script = next(
|
|
279
|
+
(
|
|
280
|
+
path
|
|
281
|
+
for path in (
|
|
282
|
+
os.path.join(".agents/skills", name, "agent-sessions.mjs")
|
|
283
|
+
for name in ("ethlete-agent-sessions", "agent-sessions")
|
|
284
|
+
)
|
|
285
|
+
if os.path.isfile(os.path.join(root, path))
|
|
286
|
+
),
|
|
287
|
+
None,
|
|
288
|
+
)
|
|
289
|
+
if not script:
|
|
290
|
+
return None
|
|
278
291
|
try:
|
|
279
292
|
session = subprocess.run(
|
|
280
|
-
["tmux", "display-message", "-p", "
|
|
293
|
+
["tmux", "display-message", "-p", "-t", pane, "#{session_name}"],
|
|
294
|
+
capture_output=True,
|
|
295
|
+
text=True,
|
|
296
|
+
timeout=2,
|
|
281
297
|
).stdout.strip()
|
|
282
298
|
except (OSError, subprocess.SubprocessError):
|
|
283
|
-
return
|
|
284
|
-
|
|
299
|
+
return None
|
|
300
|
+
if not session:
|
|
301
|
+
return None
|
|
302
|
+
slug = re.sub(r"[.:]", "-", os.path.basename(os.path.normpath(root)))
|
|
303
|
+
return {"script": script, "name": session.removesuffix("@" + slug)}
|
|
285
304
|
|
|
286
305
|
|
|
287
|
-
def resolve_profile(profile, skill, self_restart=
|
|
306
|
+
def resolve_profile(profile, skill, self_restart=None):
|
|
288
307
|
"""Fills the installed handoff skill's name into a profile's message fragments."""
|
|
289
|
-
resolved =
|
|
308
|
+
resolved = profile
|
|
309
|
+
if self_restart:
|
|
310
|
+
resolved = {
|
|
311
|
+
**profile,
|
|
312
|
+
**{key: SELF_RESTART for key in USER_DIRECTED_KEYS},
|
|
313
|
+
"act_now": SELF_RESTART_ACT_NOW,
|
|
314
|
+
}
|
|
315
|
+
values = {"handoff": skill, **(self_restart or {})}
|
|
290
316
|
return {
|
|
291
|
-
key:
|
|
317
|
+
key: re.sub(r"\{(handoff|script|name)\}", lambda m: values.get(m.group(1), m.group(0)), value)
|
|
318
|
+
if isinstance(value, str)
|
|
319
|
+
else value
|
|
292
320
|
for key, value in resolved.items()
|
|
293
321
|
}
|
|
294
322
|
|
|
@@ -851,7 +879,7 @@ def main():
|
|
|
851
879
|
if disabled_locally(local_config):
|
|
852
880
|
return
|
|
853
881
|
|
|
854
|
-
profile = resolve_profile(profile, handoff_skill(root), self_restart=
|
|
882
|
+
profile = resolve_profile(profile, handoff_skill(root), self_restart=self_restart_target(root))
|
|
855
883
|
|
|
856
884
|
claude_subagent = agent == "claude" and bool(data.get("agent_id"))
|
|
857
885
|
if claude_subagent:
|