@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.
Files changed (38) hide show
  1. package/CHANGELOG.md +50 -0
  2. package/README.md +7 -1
  3. package/content/hooks/background-design-commands.py +178 -0
  4. package/content/hooks/builder-chat-words.py +269 -0
  5. package/content/hooks/context-warning.py +47 -19
  6. package/content/hooks/git-safety.py +208 -0
  7. package/content/mods/plugins/e2e-progress/.claude-plugin/plugin.json +1 -1
  8. package/content/rules/nx-layout.md +5 -2
  9. package/content/rules/subagent-models.md +2 -2
  10. package/content/skills/agent-sessions/SKILL.md +9 -5
  11. package/content/skills/agent-sessions/agent-sessions.mjs +283 -34
  12. package/content/skills/design-exploration/SKILL.md +47 -547
  13. package/content/skills/design-exploration/reference-check-and-notes.md +92 -0
  14. package/content/skills/design-exploration/reference-flows-and-wireframes.md +78 -0
  15. package/content/skills/design-exploration/reference-inbox-and-mcp.md +54 -0
  16. package/content/skills/design-exploration/reference-order-and-design-system.md +124 -0
  17. package/content/skills/design-exploration/reference-products-and-sketches.md +211 -0
  18. package/content/skills/design-exploration/reference-shots.md +25 -0
  19. package/content/skills/git-commit/SKILL.md +10 -0
  20. package/content/skills/grilling/SKILL.md +1 -0
  21. package/content/skills/handoff/SKILL.md +29 -6
  22. package/package.json +1 -1
  23. package/src/index.js +2 -1
  24. package/src/index.js.map +1 -1
  25. package/src/lib/git-flow-repair.d.ts +1 -1
  26. package/src/lib/git-flow-start.d.ts +1 -1
  27. package/src/lib/migrate.js +2 -1
  28. package/src/lib/migrate.js.map +1 -1
  29. package/src/lib/output-style-command.d.ts +1 -1
  30. package/src/lib/prettier.d.ts +21 -0
  31. package/src/lib/prettier.js +51 -0
  32. package/src/lib/prettier.js.map +1 -0
  33. package/src/lib/sync.d.ts +1 -1
  34. package/src/lib/sync.js +9 -2
  35. package/src/lib/sync.js.map +1 -1
  36. package/src/lib/targets/hooks-shared.d.ts +3 -3
  37. package/src/lib/targets/hooks-shared.js +58 -20
  38. 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": ["context-warning", "subagent-model-policy"]
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: 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, "
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 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
- ):
277
- return False
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", "#S"], capture_output=True, text=True, timeout=2
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 False
284
- return session.endswith("@" + re.sub(r"[.:]", "-", os.path.basename(os.path.normpath(root))))
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=False):
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 = {**profile, **{key: SELF_RESTART for key in USER_DIRECTED_KEYS}} if self_restart else profile
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: value.replace("{handoff}", skill) if isinstance(value, str) else value
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=can_self_restart(root))
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: