@ethlete/agent-rules 0.1.0-next.32 → 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 CHANGED
@@ -1,5 +1,29 @@
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
+
3
27
  ## 0.1.0-next.32
4
28
 
5
29
  ### Minor Changes
@@ -7,8 +7,9 @@ they block the whole session, so a Bash call that runs one without `run_in_backg
7
7
  is denied and the agent repeats it in the background.
8
8
 
9
9
  Matches the `yarn design ...`, `node dist/libs/cli/src/index.js design ...` and
10
- `npx nx ... studio:e2e` forms. Quoted text is ignored, so a commit message that mentions a
11
- command does not trip the hook.
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.
12
13
 
13
14
  Can be disabled per machine via a gitignored ethlete-agents.config.local.json at the repo
14
15
  root: {"disableHooks": true} or {"disableHooks": ["background-design-commands"]}.
@@ -25,16 +26,51 @@ HOOK_NAME = "background-design-commands"
25
26
  LOCAL_CONFIG_FILE = "ethlete-agents.config.local.json"
26
27
 
27
28
  LONG_COMMANDS = (
28
- ("design check", re.compile(r"(?:^|[\s;&|])design\s+check(?=\s|$|[;&|])")),
29
- ("design compare", re.compile(r"(?:^|[\s;&|])design\s+compare(?=\s|$|[;&|])")),
30
- ("design ready", re.compile(r"(?:^|[\s;&|])design\s+ready(?=\s|$|[;&|])")),
31
- ("design sketch ready", re.compile(r"(?:^|[\s;&|])design\s+sketch\s+ready(?=\s|$|[;&|])")),
32
- ("design harness", re.compile(r"(?:^|[\s;&|])design\s+harness(?=\s|$|[;&|])")),
33
- ("studio:e2e", re.compile(r"studio:e2e(?=\s|$|[;&|:])")),
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|$|:)")),
34
35
  ("--visible-change", re.compile(r"(?:^|\s)--visible-change(?=\s|=|$)")),
35
36
  )
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
+
37
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
38
74
 
39
75
 
40
76
  def agent_name(argv):
@@ -76,11 +112,13 @@ def disabled_locally(config):
76
112
 
77
113
 
78
114
  def long_command_in(command):
79
- """The name of the first long command the shell string runs, or None."""
80
- unquoted = QUOTED.sub(" ", command)
81
- for name, pattern in LONG_COMMANDS:
82
- if pattern.search(unquoted):
83
- return name
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
84
122
  return None
85
123
 
86
124
 
@@ -1,9 +1,11 @@
1
1
  #!/usr/bin/env python3
2
- """Stop hook: logs how many chat words a builder session wrote in the turn that just ended.
2
+ """Stop hook: logs the chat text of a builder turn and blocks a turn that ends with more than one line.
3
3
 
4
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. It never blocks,
6
- never prints and always exits 0.
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.
7
9
  """
8
10
 
9
11
  import json
@@ -12,6 +14,16 @@ import sys
12
14
  from datetime import datetime, timezone
13
15
 
14
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"}
15
27
 
16
28
 
17
29
  def is_prompt(entry):
@@ -25,8 +37,9 @@ def is_prompt(entry):
25
37
  )
26
38
 
27
39
 
28
- def count_words(path):
29
- words = 0
40
+ def turn_entries(path):
41
+ texts = []
42
+ tools = []
30
43
  with open(path, encoding="utf-8") as handle:
31
44
  for line in handle:
32
45
  try:
@@ -36,16 +49,181 @@ def count_words(path):
36
49
  if not isinstance(entry, dict):
37
50
  continue
38
51
  if is_prompt(entry):
39
- words = 0
52
+ texts = []
53
+ tools = []
40
54
  elif entry.get("type") == "assistant":
41
55
  content = (entry.get("message") or {}).get("content")
42
56
  if isinstance(content, str):
43
- words += len(content.split())
57
+ texts.append(content)
44
58
  elif isinstance(content, list):
45
59
  for block in content:
46
- if isinstance(block, dict) and block.get("type") == "text":
47
- words += len(str(block.get("text", "")).split())
48
- return words
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]
49
227
 
50
228
 
51
229
  def main():
@@ -53,18 +231,35 @@ def main():
53
231
  return
54
232
  payload = json.load(sys.stdin)
55
233
  path = payload.get("transcript_path")
56
- if not path or not os.path.isfile(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):
57
237
  return
58
238
  root = os.environ.get("CLAUDE_PROJECT_DIR") or payload.get("cwd") or os.getcwd()
59
239
  log = os.path.join(root, LOG_FILE)
60
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")
61
249
  record = {
62
250
  "timestamp": datetime.now(timezone.utc).isoformat(),
63
251
  "session_id": payload.get("session_id"),
64
- "words": count_words(path),
252
+ "words": sum(len(text.split()) for text in texts),
253
+ "lines": lines,
254
+ "committed": committed,
255
+ "reported": reported,
256
+ "blocked": blocked,
65
257
  }
66
258
  with open(log, "a", encoding="utf-8") as handle:
67
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}))
68
263
 
69
264
 
70
265
  try:
@@ -6,13 +6,12 @@ Denies, for a Bash call:
6
6
  - every `git stash` that writes (bare, `push`, `save`, `pop`, `apply`, `drop`, `clear`, `branch`,
7
7
  `create`, `store`); `git stash list` and `git stash show` stay allowed
8
8
  - `git commit --amend`
9
- - `git commit` without a ` -- <paths>` pathspec
10
- - any `git add` or `git commit` whose paths name an `.ethlete/**/go.json` (a go is granted
11
- in Studio, never committed by an agent)
9
+ - `git commit` without a ` -- <paths>` pathspec, except the commit that concludes a merge: while
10
+ `MERGE_HEAD` exists and every staged path changed between HEAD and MERGE_HEAD or is conflicted
12
11
 
13
12
  `git commit-tree` and `git update-index` stay allowed, so a merge commit built on a temporary
14
- GIT_INDEX_FILE works. Commit messages (`-m`, `--message`) and other quoted text are ignored for
15
- the command checks, so a message that mentions `git stash` does not trip the hook.
13
+ GIT_INDEX_FILE works. Only real command positions count: commit messages (`-m`, `--message`), quoted text and heredoc
14
+ bodies are ignored, so a grep pattern or message that mentions `git stash` does not trip the hook.
16
15
 
17
16
  Can be disabled per machine via a gitignored ethlete-agents.config.local.json at the repo
18
17
  root: {"disableHooks": true} or {"disableHooks": ["git-safety"]}.
@@ -23,6 +22,7 @@ Fail-safe: any error exits 0 with no output.
23
22
  import json
24
23
  import os
25
24
  import re
25
+ import subprocess
26
26
  import sys
27
27
 
28
28
  HOOK_NAME = "git-safety"
@@ -31,10 +31,11 @@ LOCAL_CONFIG_FILE = "ethlete-agents.config.local.json"
31
31
  MESSAGE_ARG = re.compile(
32
32
  r"""(?:^|(?<=\s))(?:-[a-zA-Z]*m|--message)(?:=|\s*)(?:"(?:\\.|[^"\\])*"|'[^']*'|[^\s;&|]*)"""
33
33
  )
34
- SEGMENT_SPLIT = re.compile(r"[;&|\n]+")
34
+ SEGMENT_SPLIT = re.compile(r"[;&|\n()`]+")
35
+ HEREDOC = re.compile(r"<<-?[ \t]*(['\"]?)(\w+)\1([^\n]*)\n(?:.*?\n)?[ \t]*\2[ \t]*(?=\n|$)", re.DOTALL)
36
+ QUOTED = re.compile(r"\"(?:\\.|[^\"\\])*\"|'[^']*'")
35
37
  GLOBAL_OPTS_WITH_VALUE = ("-C", "-c", "--git-dir", "--work-tree", "--namespace")
36
38
  ADD_ALL = ("-A", "--all", ".")
37
- GO_JSON = re.compile(r"(?:^|/)go\.json$")
38
39
 
39
40
  def agent_name(argv):
40
41
  for index, arg in enumerate(argv):
@@ -76,8 +77,8 @@ def disabled_locally(config):
76
77
 
77
78
  def git_calls_in(command):
78
79
  """Yield (subcommand, args) for every command-position git call in the shell string."""
79
- stripped = MESSAGE_ARG.sub(" ", command)
80
- unquoted = stripped.replace('"', " ").replace("'", " ")
80
+ stripped = MESSAGE_ARG.sub(" ", HEREDOC.sub(r"<<\3", command))
81
+ unquoted = QUOTED.sub(lambda m: re.sub(r"[\s;&|(`]", "_", m.group(0)[1:-1]), stripped)
81
82
  for segment in SEGMENT_SPLIT.split(unquoted):
82
83
  words = segment.split()
83
84
  index = 0
@@ -85,11 +86,14 @@ def git_calls_in(command):
85
86
  index += 1
86
87
  if index < len(words) and (words[index] == "git" or words[index].endswith("/git")):
87
88
  rest = words[index + 1 :]
89
+ directory = None
88
90
  while rest and rest[0].startswith("-"):
91
+ if rest[0] == "-C" and len(rest) > 1:
92
+ directory = rest[1]
89
93
  skip = 2 if rest[0] in GLOBAL_OPTS_WITH_VALUE else 1
90
94
  rest = rest[skip:]
91
95
  if rest:
92
- yield rest[0], rest[1:]
96
+ yield rest[0], rest[1:], directory
93
97
 
94
98
 
95
99
  STASH_READS = ("list", "show")
@@ -100,9 +104,33 @@ def stash_subcommand_of(args):
100
104
  return args[0] if args and not args[0].startswith("-") else "push"
101
105
 
102
106
 
103
- def violation_in(command):
107
+ def git_lines(cwd, *args):
108
+ result = subprocess.run(["git", *args], cwd=cwd, capture_output=True, text=True, timeout=10, check=True)
109
+ return {line for line in result.stdout.splitlines() if line}
110
+
111
+
112
+ def concludes_merge(cwd):
113
+ """True while MERGE_HEAD exists and every staged path belongs to the merge. Any error is False."""
114
+ try:
115
+ merge_head = git_lines(cwd, "rev-parse", "--git-path", "MERGE_HEAD")
116
+ if not merge_head or not os.path.isfile(os.path.join(cwd, next(iter(merge_head)))):
117
+ return False
118
+ staged = git_lines(cwd, "diff", "--cached", "--name-only")
119
+ merge_paths = git_lines(cwd, "diff", "--name-only", "HEAD", "MERGE_HEAD") | git_lines(
120
+ cwd, "diff", "--name-only", "--diff-filter=U"
121
+ )
122
+ return staged <= merge_paths
123
+ except Exception:
124
+ return False
125
+
126
+
127
+ def stages_everything(args):
128
+ return any(arg == "--all" or re.fullmatch(r"-[a-zA-Z]*a[a-zA-Z]*", arg) for arg in args if arg != "--")
129
+
130
+
131
+ def violation_in(command, cwd=None):
104
132
  """(rule, allowed form) for the first forbidden git call, or None."""
105
- for sub, args in git_calls_in(command):
133
+ for sub, args, directory in git_calls_in(command):
106
134
  if sub == "add":
107
135
  if any(arg in ADD_ALL for arg in args):
108
136
  return (
@@ -120,16 +148,14 @@ def violation_in(command):
120
148
  "`git commit --amend` rewrites a commit another session may have built on",
121
149
  "make a new commit: `git commit -m \"...\" -- <paths>`",
122
150
  )
123
- if "--" not in args:
151
+ if "--" not in args and not (
152
+ not stages_everything(args)
153
+ and concludes_merge(os.path.join(cwd or os.getcwd(), directory) if directory else cwd or os.getcwd())
154
+ ):
124
155
  return (
125
156
  "`git commit` without a pathspec commits everything staged, including other sessions' work",
126
157
  "name the paths: `git commit -m \"...\" -- <paths>`",
127
158
  )
128
- if sub in ("add", "commit") and any(GO_JSON.search(arg) for arg in args):
129
- return (
130
- "an agent never stages or commits a `go.json`; a go is granted in Studio",
131
- "leave `go.json` out of the paths",
132
- )
133
159
  return None
134
160
 
135
161
 
@@ -155,7 +181,8 @@ def main():
155
181
  if disabled_locally(load_local_config(repo_root(data))):
156
182
  return
157
183
 
158
- found = violation_in(command)
184
+ cwd = data.get("cwd")
185
+ found = violation_in(command, cwd if isinstance(cwd, str) and cwd else None)
159
186
 
160
187
  if not found:
161
188
  return
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "e2e-progress",
3
- "version": "0.1.0-next.32",
3
+ "version": "0.1.0-next.33",
4
4
  "description": "Live Playwright e2e progress from a run log, as a status line entry and a pane",
5
5
  "types": "./types/index.d.ts"
6
6
  }
@@ -20,7 +20,7 @@ script, so you do not need `<skill-dir>`. The older name `npx ethlete-agents mai
20
20
  ```bash
21
21
  node <skill-dir>/agent-sessions.mjs run <name> <command...> [--role builder] [--dry-run]
22
22
  node <skill-dir>/agent-sessions.mjs up <name>[=<command>]...
23
- node <skill-dir>/agent-sessions.mjs restart <name> [--after <seconds>] <prompt...>
23
+ node <skill-dir>/agent-sessions.mjs restart <name> [--after <seconds>] [--notify <agent,...>] [--process] [--role builder|none] <prompt...>
24
24
  node <skill-dir>/agent-sessions.mjs down [<name>...]
25
25
  node <skill-dir>/agent-sessions.mjs shell-install [<agent>...] [--file <path>] [--remove] [--dry-run]
26
26
  node <skill-dir>/agent-sessions.mjs shell-init [<agent>...]
@@ -49,7 +49,9 @@ Nothing runs until you start it, so a person who does not use this does nothing.
49
49
 
50
50
  `up codex=codex gemini=gemini` starts a detached `tmux` session per agent, named like its sender name, and runs the command in it. It also starts a session named `mailbox-dispatch` that runs `dispatch`. A running session is left alone. Attach to a session with `tmux attach -t <name>`. Detach with `Ctrl-b`, then `d`. The session and its agent keep running. Use `down` to stop the dispatcher and the named sessions.
51
51
 
52
- `restart <name> <prompt...>` types the clear command (`/clear`, or `/new` for a `codex` name) and then the prompt into the session `<name>@<repo>`, no sooner than `--after` seconds (default 5). It waits until that session ended its turn and its prompt holds no typed text, and gives up after 30 minutes. After the clear, a new turn does not hold the prompt back: a message can wake the fresh session, and Claude Code queues input typed during a turn. Only typed text in its prompt holds the prompt back. After 60 seconds of typed text, it saves the prompt to `.ethlete/restart.prompt.<session>.txt` and logs why in `.ethlete/restart.log`. The command returns at once, so end your turn after it. An agent uses it to clear itself and resume from its saved work state. It refuses any session other than the caller's own (from `$TMUX_PANE`), and the error names the caller's own sender name. Background commands of the old session still report into the new one.
52
+ `restart <name> <prompt...>` types the clear command (`/clear`, or `/new` for a `codex` name) and then the prompt into the session `<name>@<repo>`, no sooner than `--after` seconds (default 5). It waits until that session ended its turn and its prompt holds no typed text, and gives up after 30 minutes. After the clear, a new turn does not hold the prompt back: a message can wake the fresh session, and Claude Code queues input typed during a turn. Only typed text in its prompt holds the prompt back. The prompt is the text between the two rules of the input box, so the background-agent panel below the footer does not count as typed text. When the focus sits in that panel, it presses Up until the prompt has the focus and clears the line with C-u before it types. After 2 minutes of typed text, and when it gives up, it sends one mailbox line to `<name>` and to each `--notify` agent. After 60 seconds of typed text, it saves the prompt to `.ethlete/restart.prompt.<session>.txt` and logs why in `.ethlete/restart.log`. The command returns at once, so end your turn after it. An agent uses it to clear itself and resume from its saved work state. It refuses any session other than the caller's own (from `$TMUX_PANE`) and the paired session (the builder session, started with `run --role builder`, and any session that is not one), and the error names the caller's own sender name. A restart of the paired session is logged to `restart.log` as `peer restart: <caller> restarted <target>`. Background commands of the old session still report into the new one. A pane counts as busy only when "esc to interrupt" or "Waiting for" shows in the spinner line right above the input box or in the footer below it (a turn that waits on background agents is busy). A chat line that starts with `●` or `⎿` never counts, so an agent that ends its turn with its own "Waiting for ..." text is idle. If the permission system denies your own `restart` (the command never runs), send one mailbox line to the grill/docs session, for example `send <you> --to <grill> 'restart denied, please /clear my pane and type: <prompt>'`. That session drives your pane: it types into it only when the pane is idle by the rule above.
53
+
54
+ `restart --process` ends the agent process instead of clearing it: it types `/exit` (`/quit` for a `codex` name) and starts the agent command again in the same tmux pane, through a login shell, with the prompt as its first message. A `/clear` keeps the old process, so it never picks up a change to an installed plugin, a settings file, an MCP server or `PATH`; after such a change use `--process`. The agent command is the one `run` started, without its prompt, and the pane's start command for an older session. It starts with `command`, so the shell function of the same name does not start a new session. If the exit opens the background work dialog, the restart selects "exit and stop tasks" and writes the stopped jobs to `restart.log` and to a mailbox notice. The new process keeps the role: the session's own role, else `builder` from a prompt that resumes a `builder-*` handoff, else the one `--role` names. A builder process gets `ETHLETE_AGENT_ROLE=builder` and, for `claude`, the builder system prompt once. A session with no role next to another agent session in the repo, none of them a builder, is refused unless the prompt resumes a `builder-*` or `doc-agent-*` handoff or `--role` is passed (`--role none` restarts it without a role).
53
55
 
54
56
  `shell-install` writes one shell function per agent into `~/.zshrc` or `~/.bashrc`, between two marker lines. A second run replaces the block, and `--remove` deletes it. Use `--file` for another startup file and `--dry-run` to preview. After a new terminal opens, typing `claude` or `codex` in a repo with this skill runs the agent through `run`. Elsewhere the function starts the plain command. Run `shell-install` only when the user asks: it changes a file the user owns. `shell-init` prints the same functions without writing a file.
55
57
 
@@ -78,6 +80,6 @@ If the prompt of the agent holds typed text, the dispatcher waits until that tex
78
80
  4. After each larger step, run `inbox` again. Most agents cannot wake on new output, so you must pull messages yourself.
79
81
  5. When you finish, run `release`. A claim also expires after 4 hours, or after `--ttl` hours.
80
82
 
81
- In this repo commit with `yarn commit -m "..." -- <paths>`: it runs the related specs first in a throwaway worktree of HEAD plus those paths, before it takes `.git/index.lock`, and the pre-commit hook then skips them for that exact tree (a plain `git commit` still runs them in the hook).
83
+ In this repo commit with `yarn commit -m "..." -- <paths>`: it adds untracked paths and waits for `.git/index.lock`. Specs, lint and typecheck run in CI, not in the hook.
82
84
 
83
85
  A claim is advisory, not a lock. `--force` claims over another agent: use it only after the holder agreed. Silence is not approval. Acknowledge each handoff in words. No agent starts automatically. The user starts each one.
@@ -36,8 +36,10 @@ const DRAFT_RETRY_MS = 2000;
36
36
  const RESTART_AFTER_SECONDS = 5;
37
37
  const RESTART_CLEAR_MS = 2500;
38
38
  const RESTART_POLL_MS = 2000;
39
- const RESTART_WAIT_MS = 30 * 60 * 1000;
39
+ const RESTART_WAIT_MS = Number(process.env.AGENT_RESTART_WAIT_MS ?? 30 * 60 * 1000);
40
+ const RESTART_DRAFT_NOTICE_MS = Number(process.env.AGENT_RESTART_DRAFT_NOTICE_MS ?? 2 * 60 * 1000);
40
41
  const RESTART_CLEAR_WAIT_MS = Number(process.env.AGENT_RESTART_CLEAR_WAIT_MS ?? 60 * 1000);
42
+ const RESTART_EXIT_WAIT_MS = Number(process.env.AGENT_RESTART_EXIT_WAIT_MS ?? 30 * 1000);
41
43
  const repoSlug = basename(root).replace(/[.:]/g, '-');
42
44
  const sessionFor = (agent) => `${agent}@${repoSlug}`;
43
45
  const DISPATCH_SESSION = process.env.AGENT_MAILBOX_DISPATCH_SESSION ?? `mailbox-dispatch@${repoSlug}`;
@@ -62,6 +64,8 @@ const afterSeconds = takeFlag(args, '--after', true);
62
64
  const now = takeFlag(args, '--now', false);
63
65
  const dryRun = takeFlag(args, '--dry-run', false);
64
66
  const role = takeFlag(args, '--role', true);
67
+ const notify = takeFlag(args, '--notify', true);
68
+ const newProcess = takeFlag(args, '--process', false);
65
69
  const [command, sender, ...words] = args;
66
70
 
67
71
  const help = `Shared agent mailbox: ${mailbox}
@@ -81,8 +85,12 @@ Run: node ${script} run <name> <command...> [--role builder] [--dry-run]
81
85
  Install: node ${script} shell-install [<agent>...] [--file <path>] [--remove] [--dry-run]
82
86
  (write the shell functions into ~/.zshrc or ~/.bashrc)
83
87
  Shell: node ${script} shell-init [<agent>...] (print shell functions that wrap each agent with run; default: claude codex)
84
- Restart: node ${script} restart <name> [--after <seconds>] <prompt...>
85
- (type the clear command and then the prompt into the tmux session of <name>)
88
+ Restart: node ${script} restart <name> [--after <seconds>] [--notify <agent,...>] [--process] [--role builder|none] <prompt...>
89
+ (type the clear command and then the prompt into the tmux session of <name>;
90
+ a long wait on a draft and a give-up go to <name> and each --notify agent;
91
+ --process ends the agent and starts it again in the same pane with the prompt;
92
+ it keeps the role, else takes it from a builder-* handoff name, and refuses
93
+ a session with no role while another agent session runs in the repo)
86
94
  Down: node ${script} down [<name>...] (stop dispatch; stop the named sessions too)
87
95
  Dispatch: node ${script} dispatch (wake the tmux session named <agent> for each message sent --to <agent>)
88
96
 
@@ -102,9 +110,75 @@ const BUILDER_PROMPT =
102
110
  const withRole = (words) =>
103
111
  role === 'builder' && basename(words[0]) === 'claude' ? [...words, '--append-system-prompt', BUILDER_PROMPT] : words;
104
112
  const sessionExists = (name) => tmux('has-session', '-t', `=${name}`).status === 0;
113
+ const ROLE_OPTION = '@ethlete_role';
114
+ const COMMAND_OPTION = '@ethlete_command';
115
+ const agentCommandOf = (session) =>
116
+ tmux('show-options', '-v', '-t', `=${session}:`, COMMAND_OPTION).stdout.trim() ||
117
+ tmux('display-message', '-p', '-t', `=${session}:`, '#{pane_start_command}')
118
+ .stdout.trim()
119
+ .replace(/^"(.*)"$/, '$1');
120
+ const roleOf = (session) => tmux('show-options', '-v', '-t', `=${session}:`, ROLE_OPTION).stdout.trim() || null;
121
+ const arePaired = (a, b) => (roleOf(a) === 'builder') !== (roleOf(b) === 'builder');
105
122
  const ONE_SHOT_FLAGS = new Set(['-p', '--print', '-v', '--version', '-h', '--help']);
106
123
  const runsInSession = (agentArgs) =>
107
124
  Boolean(process.stdin.isTTY && process.stdout.isTTY) && !agentArgs.some((arg) => ONE_SHOT_FLAGS.has(arg));
125
+ const TEXT_VALUE_FLAGS = new Set(['--append-system-prompt', '--system-prompt']);
126
+ const withoutPrompt = (words) => {
127
+ const separator = words.indexOf('--');
128
+ if (separator > 0) return words.slice(0, separator);
129
+ const [previous, last] = words.slice(-2);
130
+ const isPrompt =
131
+ words.length > 1 &&
132
+ !last.startsWith('-') &&
133
+ (!previous.startsWith('-') || (/\s/.test(last) && !TEXT_VALUE_FLAGS.has(previous)));
134
+ return isPrompt ? words.slice(0, -1) : words;
135
+ };
136
+ const SHELL_WORD = /(?:'[^']*'|\\.|[^\s'\\])+/g;
137
+ const ENV_WORD = /^[A-Za-z_][A-Za-z0-9_]*=/;
138
+ const unquoteWord = (word) => word.replace(/'([^']*)'|\\(.)/g, (_, quoted, escaped) => quoted ?? escaped);
139
+ const commandWithoutPrompt = (command) => {
140
+ const tokens = [...command.matchAll(SHELL_WORD)];
141
+ const program = tokens.findIndex(([word]) => !ENV_WORD.test(word));
142
+ if (program < 0) return command;
143
+ const words = tokens.slice(program).map(([word]) => unquoteWord(word));
144
+ const kept = withoutPrompt(words).length;
145
+ return kept === words.length ? command : command.slice(0, tokens[program + kept].index).trimEnd();
146
+ };
147
+ const HANDOFF_ROLE = /^\/handoff\s+resume\s+(builder|doc-agent)-/;
148
+ const handoffRoleOf = (prompt) => prompt.match(HANDOFF_ROLE)?.[1] ?? null;
149
+ const restartRoleOf = (session, prompt) => {
150
+ if (role === 'none') return null;
151
+ const found = role ?? roleOf(session) ?? handoffRoleOf(prompt);
152
+ return found === 'doc-agent' ? null : found;
153
+ };
154
+ const hasUnknownRole = (session, prompt) => {
155
+ if (role !== undefined || roleOf(session) || handoffRoleOf(prompt)) return false;
156
+ const peers = tmux('list-sessions', '-F', '#{session_name}')
157
+ .stdout.split('\n')
158
+ .filter((name) => name.endsWith(`@${repoSlug}`) && name !== session && name !== DISPATCH_SESSION);
159
+ return peers.length > 0 && !peers.some((name) => roleOf(name) === 'builder');
160
+ };
161
+ const commandWithRole = (command, agentRole) => {
162
+ const tokens = [...command.matchAll(SHELL_WORD)].map(([word]) => word);
163
+ const program = tokens.findIndex((word) => !ENV_WORD.test(word));
164
+ const env = program < 0 ? tokens : tokens.slice(0, program);
165
+ const words = tokens.slice(env.length).map(unquoteWord);
166
+ const kept = words.filter(
167
+ (word, index) =>
168
+ !(word === '--append-system-prompt' && words[index + 1] === BUILDER_PROMPT) &&
169
+ !(word === BUILDER_PROMPT && words[index - 1] === '--append-system-prompt'),
170
+ );
171
+ const rule =
172
+ agentRole === 'builder' && basename(kept[0] ?? '') === 'claude' ? ['--append-system-prompt', BUILDER_PROMPT] : [];
173
+ return [
174
+ ...env.filter((word) => !word.startsWith('ETHLETE_AGENT_ROLE=')),
175
+ `ETHLETE_AGENT_ROLE=${agentRole}`,
176
+ ...[...kept, ...rule].map(quote),
177
+ ].join(' ');
178
+ };
179
+ const bypassingShellFunction = (base) => base.replace(/^((?:[A-Za-z_][A-Za-z0-9_]*=\S*\s+)*)/, '$1command ');
180
+ const EXIT_DIALOG_JOBS = /will stop when you exit/i;
181
+ const EXIT_AND_STOP = /exit and stop/i;
108
182
 
109
183
  /**
110
184
  * Reads the last prompt line (`❯` for Claude Code, `›` for Codex) of the pane. Dim text is the empty
@@ -112,14 +186,31 @@ const runsInSession = (agentArgs) =>
112
186
  * wake-up line from answering a permission dialog.
113
187
  */
114
188
  const SGR = `${String.fromCharCode(27)}\\[([0-9;]*)m`;
115
- const busyLine = (session) =>
116
- (tmux('capture-pane', '-p', '-t', `=${session}:`).stdout ?? '')
117
- .split('\n')
118
- .find((line) => /esc to inte/i.test(line)) ?? null;
189
+ const BUSY_TEXT = /esc to interrupt|Waiting for/i;
190
+ const isTranscript = (line) => /^\s*[●⎿]/.test(line);
191
+ /**
192
+ * The line that shows a running turn: the spinner line right above the input box, or a footer line below it.
193
+ * A transcript line (`●`, `⎿`) never counts, since an agent can end its turn with its own "Waiting for ..." text.
194
+ */
195
+ const busyLineOf = (lines) => {
196
+ const bottom = lastRuleIndex(lines);
197
+ const top = lines.slice(0, bottom).findLastIndex(isRule);
198
+ if (bottom === -1 || top === -1) return lines.find((line) => BUSY_TEXT.test(line) && !isTranscript(line)) ?? null;
199
+ const spinner = lines.slice(0, top).findLast((line) => line.trim() !== '') ?? '';
200
+ return [spinner, ...lines.slice(bottom + 1)].find((line) => BUSY_TEXT.test(line) && !isTranscript(line)) ?? null;
201
+ };
202
+ const busyLine = (session) => busyLineOf((tmux('capture-pane', '-p', '-t', `=${session}:`).stdout ?? '').split('\n'));
119
203
  const restartLog = (message) => {
120
204
  mkdirSync(dirname(mailbox), { recursive: true });
121
205
  appendFileSync(join(dirname(mailbox), 'restart.log'), `${new Date().toISOString()} ${message}\n`);
122
206
  };
207
+ const restartNotice = (message) => {
208
+ restartLog(message);
209
+ [sender, ...(notify ?? '').split(',')]
210
+ .map((agent) => agent.trim())
211
+ .filter(Boolean)
212
+ .forEach((to) => append({ sender: 'restart', to, message }));
213
+ };
123
214
 
124
215
  /**
125
216
  * Waits until the agent ended its turn and nobody types in its prompt, twice in a row; `false` after the time limit.
@@ -130,12 +221,19 @@ const waitForQuiet = (session, limitMs = RESTART_WAIT_MS, allowBusy = false) =>
130
221
  const end = Date.now() + limitMs;
131
222
  let quiet = 0;
132
223
  let reason = '';
224
+ let draftSince = null;
225
+ let told = false;
133
226
  while (Date.now() < end) {
134
227
  const busy = allowBusy ? null : busyLine(session);
135
228
  const draft = busy === null ? draftLine(session) : null;
136
229
  const next = busy !== null ? `busy: ${busy.trim()}` : draft !== null ? `draft: ${draft}` : '';
137
230
  if (next !== reason) restartLog(`${session} ${next ? `waits, ${next}` : 'is quiet'}`);
138
231
  reason = next;
232
+ draftSince = draft === null ? null : (draftSince ?? Date.now());
233
+ if (!told && draftSince !== null && Date.now() - draftSince >= RESTART_DRAFT_NOTICE_MS) {
234
+ told = true;
235
+ restartNotice(`restart of ${session} waits ${Math.round((Date.now() - draftSince) / 1000)}s, draft: ${draft}`);
236
+ }
139
237
  quiet = next ? 0 : quiet + 1;
140
238
  if (quiet === 2) return true;
141
239
  sleep(RESTART_POLL_MS);
@@ -145,15 +243,29 @@ const waitForQuiet = (session, limitMs = RESTART_WAIT_MS, allowBusy = false) =>
145
243
 
146
244
  /** The IDE context chip (`[⧉ In file.ts]`, `[⧉ 3 lines selected]`) shares the prompt line and survives `/clear`. */
147
245
  const IDE_CHIP = /\[⧉ [^\]]*\]/g;
148
- const draftLine = (session) => {
149
- const pane = tmux('capture-pane', '-p', '-e', '-t', `=${session}:`).stdout ?? '';
150
- const prompt = pane
151
- .split('\n')
152
- .reverse()
153
- .find((line) => /^\s*[❯›]/.test(line.replace(new RegExp(SGR, 'g'), '')));
154
- if (!prompt) return null;
246
+ const plain = (line) => line.replace(new RegExp(SGR, 'g'), '');
247
+ const isRule = (line) => /^─{10,}$/.test(plain(line).trim());
248
+ const isPromptMark = (line) => /^\s*[❯›]/.test(plain(line));
249
+ /** A row of the background-agent panel below the footer (`❯ ⏺ main`, ` ◯ general-purpose …`). */
250
+ const isPanelRow = (line) => /^\s*❯?\s*[⏺◯]/.test(plain(line));
251
+ const paneLines = (session) => (tmux('capture-pane', '-p', '-e', '-t', `=${session}:`).stdout ?? '').split('\n');
252
+ const lastRuleIndex = (lines) => lines.findLastIndex(isRule);
253
+ /**
254
+ * The prompt is the text between the last two rules of the input box. A `❯` line below the last rule that is not a
255
+ * panel row is a menu (`❯ 1. Yes`) and wins, which keeps a wake-up line from answering a permission dialog.
256
+ */
257
+ const promptLines = (lines) => {
258
+ const below = lastRuleIndex(lines);
259
+ const menu = lines.slice(below + 1).findLast((line) => isPromptMark(line) && !isPanelRow(line));
260
+ if (menu) return [menu];
261
+ const above = lines.slice(0, below).findLastIndex(isRule);
262
+ if (below !== -1 && above !== -1) return lines.slice(above + 1, below);
263
+ const prompt = lines.findLast((line) => isPromptMark(line) && !isPanelRow(line));
264
+ return prompt ? [prompt] : [];
265
+ };
266
+ const withoutDim = (line) => {
155
267
  let dim = false;
156
- const visible = prompt
268
+ return line
157
269
  .split(new RegExp(`(${SGR.replace('(', '(?:')})`))
158
270
  .map((part) => {
159
271
  const codes = part.match(new RegExp(`^${SGR}$`))?.[1].split(';');
@@ -165,12 +277,35 @@ const draftLine = (session) => {
165
277
  return '';
166
278
  })
167
279
  .join('');
168
- const text = visible
280
+ };
281
+ const draftLine = (session) => {
282
+ const text = promptLines(paneLines(session))
283
+ .map(withoutDim)
284
+ .join('\n')
169
285
  .replace(/^\s*[❯›]/, '')
170
286
  .replace(IDE_CHIP, '')
171
287
  .trim();
172
288
  return text.length > 0 ? text : null;
173
289
  };
290
+ const panelFocused = (lines) => lines.slice(lastRuleIndex(lines) + 1).some((line) => /^\s*❯\s*[⏺◯]/.test(plain(line)));
291
+ const PANEL_UP_LIMIT = 10;
292
+ /**
293
+ * Keys typed while the focus sits in the background-agent panel go into the panel. Up moves the focus back to the
294
+ * prompt, and an Up too many recalls history, so C-u clears the line after. `false` if the focus did not leave.
295
+ */
296
+ const leavePanel = (session) => {
297
+ if (!panelFocused(paneLines(session))) return true;
298
+ for (let press = 0; press < PANEL_UP_LIMIT; press++) {
299
+ tmux('send-keys', '-t', `=${session}:`, 'Up');
300
+ sleep(ENTER_DELAY_MS);
301
+ if (!panelFocused(paneLines(session))) {
302
+ tmux('send-keys', '-t', `=${session}:`, 'C-u');
303
+ sleep(ENTER_DELAY_MS);
304
+ return true;
305
+ }
306
+ }
307
+ return false;
308
+ };
174
309
  const hasDraft = (session) => draftLine(session) !== null;
175
310
  const isAttached = (name) => tmux('list-clients', '-t', `=${name}`).stdout.trim() !== '';
176
311
  const freeSessionFor = (agent) => {
@@ -295,14 +430,38 @@ if (command === 'send' && sender) {
295
430
  if (!sessionExists(DISPATCH_SESSION))
296
431
  startSession(DISPATCH_SESSION, `${withEnv}node ${quote(script)} dispatch`, true);
297
432
  const session = freeSessionFor(sender);
298
- const shellCommand = `${role ? `ETHLETE_AGENT_ROLE=${role} ` : ''}${withRole(words).map(quote).join(' ')}`;
433
+ const roleEnv = role ? `ETHLETE_AGENT_ROLE=${role} ` : '';
434
+ const shellCommand = `${roleEnv}${withRole(words).map(quote).join(' ')}`;
435
+ const baseCommand = `${roleEnv}${withRole(withoutPrompt(words)).map(quote).join(' ')}`;
299
436
  if (process.env.TMUX) {
300
- if (!sessionExists(session)) tmux('new-session', '-d', '-s', session, '-c', process.cwd(), shellCommand);
301
- process.exitCode = spawnSync('tmux', ['switch-client', '-t', `=${session}`], { stdio: 'inherit' }).status ?? 1;
437
+ if (!sessionExists(session)) {
438
+ tmux('new-session', '-d', '-s', session, '-c', process.cwd(), shellCommand);
439
+ tmux('set-option', '-t', `=${session}:`, COMMAND_OPTION, baseCommand);
440
+ if (role) tmux('set-option', '-t', `=${session}:`, ROLE_OPTION, role);
441
+ }
442
+ const callerSession = tmux(
443
+ 'display-message',
444
+ '-p',
445
+ '-t',
446
+ process.env.TMUX_PANE ?? '',
447
+ '#{session_id}',
448
+ ).stdout.trim();
449
+ const [callerClient] = callerSession
450
+ ? tmux('list-clients', '-t', callerSession, '-F', '#{client_tty}').stdout.split('\n').filter(Boolean)
451
+ : [];
452
+ process.exitCode = callerClient
453
+ ? (spawnSync('tmux', ['switch-client', '-c', callerClient, '-t', `=${session}`], { stdio: 'inherit' }).status ??
454
+ 1)
455
+ : 0;
302
456
  } else {
303
- process.exitCode =
304
- spawnSync('tmux', ['new-session', '-A', '-s', session, '-c', process.cwd(), shellCommand], { stdio: 'inherit' })
305
- .status ?? 1;
457
+ const created = sessionExists(session)
458
+ ? []
459
+ : ['new-session', '-d', '-s', session, '-c', process.cwd(), shellCommand];
460
+ if (created.length && tmux(...created).status === 0) {
461
+ tmux('set-option', '-t', `=${session}:`, COMMAND_OPTION, baseCommand);
462
+ if (role) tmux('set-option', '-t', `=${session}:`, ROLE_OPTION, role);
463
+ }
464
+ process.exitCode = spawnSync('tmux', ['attach-session', '-t', `=${session}`], { stdio: 'inherit' }).status ?? 1;
306
465
  }
307
466
  } else if (command === 'shell-init' || command === 'shell-install') {
308
467
  const agents = [sender, ...words].filter(Boolean);
@@ -360,30 +519,96 @@ if (command === 'send' && sender) {
360
519
  if (!session) {
361
520
  process.stderr.write(`No tmux session for ${sender}.\n`);
362
521
  process.exitCode = 1;
363
- } else if (own && own !== session) {
522
+ } else if (own && own !== session && !arePaired(own, session)) {
364
523
  process.stderr.write(
365
524
  `Refused: ${session} is not your session. You run in ${own}, so pass ${own.replace(`@${repoSlug}`, '')}.\n`,
366
525
  );
367
526
  process.exitCode = 1;
527
+ } else if (role !== undefined && role !== 'builder' && role !== 'none') {
528
+ process.stderr.write(`Unknown role ${role}. Pass --role builder or --role none.\n`);
529
+ process.exitCode = 1;
530
+ } else if (newProcess && hasUnknownRole(session, words.join(' '))) {
531
+ process.stderr.write(
532
+ `Refused: ${session} has no role and another agent session runs in this repo, so the new process could lose the builder rule. Resume a builder-* or doc-agent-* handoff, or pass --role builder or --role none.\n`,
533
+ );
534
+ process.exitCode = 1;
368
535
  } else if (now) {
369
536
  const type = (...keys) => tmux('send-keys', '-t', `=${session}:`, ...keys);
370
537
  sleep(Number(afterSeconds ?? RESTART_AFTER_SECONDS) * 1000);
371
538
  if (!waitForQuiet(session)) {
539
+ restartNotice(`restart of ${session} gave up after ${RESTART_WAIT_MS / 1000}s, reason in restart.log`);
372
540
  process.exitCode = 1;
541
+ } else if (!leavePanel(session)) {
542
+ restartNotice(
543
+ `restart of ${session} gave up, the focus did not leave the agent panel after ${PANEL_UP_LIMIT} Up`,
544
+ );
545
+ process.exitCode = 1;
546
+ } else if (newProcess) {
547
+ const pane = `=${session}:`;
548
+ const restartRole = restartRoleOf(session, words.join(' '));
549
+ const stored = commandWithoutPrompt(agentCommandOf(session));
550
+ const base = restartRole ? commandWithRole(stored, restartRole) : stored;
551
+ if (restartRole) tmux('set-option', '-t', pane, ROLE_OPTION, restartRole);
552
+ tmux('set-option', '-p', '-t', pane, 'remain-on-exit', 'on');
553
+ type('-l', sender.startsWith('codex') ? '/quit' : '/exit');
554
+ sleep(ENTER_DELAY_MS);
555
+ type('Enter');
556
+ const end = Date.now() + RESTART_EXIT_WAIT_MS;
557
+ const dead = () => tmux('display-message', '-p', '-t', pane, '#{pane_dead}').stdout.trim() === '1';
558
+ const answerExitDialog = () => {
559
+ const lines = (tmux('capture-pane', '-p', '-t', pane).stdout ?? '').split('\n');
560
+ const start = lines.findIndex((line) => EXIT_DIALOG_JOBS.test(line));
561
+ const stop = lines.findIndex((line, index) => index > start && EXIT_AND_STOP.test(line));
562
+ if (start < 0 || stop < 0) return false;
563
+ const selected = lines.findIndex((line, index) => index > start && line.trim().startsWith('❯'));
564
+ const jobs = lines
565
+ .slice(start + 1, stop)
566
+ .map((line) => line.trim())
567
+ .filter(Boolean);
568
+ restartNotice(
569
+ `restart of ${session} stopped its background work, start it again: ${jobs.join('; ') || '(none listed)'}`,
570
+ );
571
+ const between = (from, to) => lines.slice(from, to).filter((line) => line.trim()).length;
572
+ const [key, steps] = selected > stop ? ['Up', between(stop, selected)] : ['Down', between(selected, stop)];
573
+ for (let step = 0; selected >= 0 && step < steps; step++) type(key);
574
+ type('Enter');
575
+ return true;
576
+ };
577
+ let answered = false;
578
+ while (!dead() && Date.now() < end) {
579
+ if (!answered) answered = answerExitDialog();
580
+ sleep(RESTART_POLL_MS / 4);
581
+ }
582
+ if (!dead()) restartLog(`${session} did not exit ${RESTART_EXIT_WAIT_MS / 1000}s after the exit command, killed`);
583
+ const shell = process.env.SHELL || 'sh';
584
+ tmux(
585
+ 'respawn-pane',
586
+ '-k',
587
+ '-t',
588
+ pane,
589
+ '-c',
590
+ root,
591
+ shell,
592
+ '-lic',
593
+ `${bypassingShellFunction(base)} ${quote(words.join(' '))}`,
594
+ );
595
+ tmux('set-option', '-p', '-t', pane, 'remain-on-exit', 'off');
596
+ tmux('set-option', '-t', pane, COMMAND_OPTION, base);
597
+ restartLog(`${session} started a new process: ${base}`);
373
598
  } else {
374
599
  type('-l', sender.startsWith('codex') ? '/new' : '/clear');
375
600
  sleep(ENTER_DELAY_MS);
376
601
  type('Enter');
377
602
  sleep(RESTART_CLEAR_MS);
378
- if (waitForQuiet(session, RESTART_CLEAR_WAIT_MS, true)) {
603
+ if (waitForQuiet(session, RESTART_CLEAR_WAIT_MS, true) && leavePanel(session)) {
379
604
  type('-l', words.join(' '));
380
605
  sleep(ENTER_DELAY_MS);
381
606
  type('Enter');
382
607
  } else {
383
608
  const saved = join(dirname(mailbox), `restart.prompt.${session}.txt`);
384
609
  writeFileSync(saved, `${words.join(' ')}\n`);
385
- restartLog(
386
- `${session} prompt not empty ${RESTART_CLEAR_WAIT_MS / 1000}s after the clear, prompt not typed, saved to ${saved}`,
610
+ restartNotice(
611
+ `restart of ${session} gave up: prompt not empty ${RESTART_CLEAR_WAIT_MS / 1000}s after the clear, prompt not typed, saved to ${saved}`,
387
612
  );
388
613
  tmux(
389
614
  'display-message',
@@ -395,13 +620,20 @@ if (command === 'send' && sender) {
395
620
  }
396
621
  }
397
622
  } else {
623
+ if (own && own !== session) restartLog(`peer restart: ${own} restarted ${session}`);
398
624
  const delay = Number(afterSeconds ?? RESTART_AFTER_SECONDS);
399
- spawn(process.execPath, [script, 'restart', sender, '--now', '--after', String(delay), ...words], {
400
- detached: true,
401
- stdio: 'ignore',
402
- }).unref();
625
+ const forward = notify ? ['--notify', notify] : [];
626
+ const mode = [...(newProcess ? ['--process'] : []), ...(role ? ['--role', role] : [])];
627
+ spawn(
628
+ process.execPath,
629
+ [script, 'restart', sender, '--now', '--after', String(delay), ...forward, ...mode, ...words],
630
+ {
631
+ detached: true,
632
+ stdio: 'ignore',
633
+ },
634
+ ).unref();
403
635
  process.stdout.write(
404
- `Restarting ${session} once its turn ended and its prompt is empty, ${delay} seconds at the earliest. End your turn now.\n`,
636
+ `${newProcess ? 'Starting a new process in' : 'Restarting'} ${session} once its turn ended and its prompt is empty, ${delay} seconds at the earliest. End your turn now.\n`,
405
637
  );
406
638
  }
407
639
  } else if (command === 'down') {
@@ -427,6 +659,7 @@ if (command === 'send' && sender) {
427
659
  return;
428
660
  }
429
661
  waiting.delete(agent);
662
+ if (!leavePanel(session)) return process.stderr.write(`The focus of ${session} stays in the agent panel.\n`);
430
663
  spawnSync('tmux', ['send-keys', '-t', `=${session}:`, '-l', text]);
431
664
  sleep(ENTER_DELAY_MS);
432
665
  spawnSync('tmux', ['send-keys', '-t', `=${session}:`, 'Enter']);
@@ -92,8 +92,8 @@ product. They take no picks and no new variants.
92
92
  - Mark a sketch `ready` only when no change to it waits on anything; otherwise `sketch hold`.
93
93
  - Every write command takes `--by <model id>`. **Never guess your id**: use the one your prompt names, or ask
94
94
  the coordinator or the user before you write.
95
- - Run `et design accept` only after the user approved a direct edit. Remove a part only with
96
- `et design part remove`, never `git rm`.
95
+ - A direct edit of a merged part is never accepted by hand: `design check` opens an old/new edit sketch and the
96
+ user picks in Studio. Remove a part only with `et design part remove`, never `git rm`.
97
97
  - Studio takes no new calls: never run `et design new`. New work goes to a product.
98
98
  - Share code between variants in the sketch's `shared/` folder only; no shared parameter or branch for one
99
99
  variant. An asset is one file in `<product>/assets/`, never inlined.
@@ -51,6 +51,19 @@ et design sketch reject <layer>/<name> --variant b [--reason <text>] --by <model
51
51
  et design sketch reopen <layer>/<name> --variant b [--reason <text>] --by <model id> # only when the user takes b back; the rejection stays in the history
52
52
  ```
53
53
 
54
+ When the app shows UI the design does not draw, open the sketch at once with the built look as variant a, and
55
+ name the compare DIFFs it explains:
56
+
57
+ ```bash
58
+ et design sketch covers <layer>/<name> <state> <state>@<x>,<y>,<w>,<h>… --by <model id> # no state clears them
59
+ ```
60
+
61
+ While the sketch is open, `compare` lists each covered DIFF with the sketch name and passes it. A bare state
62
+ covers its whole DIFF; `@x,y,w,h` covers only differing pixels inside that box of the state's picture (the
63
+ diff PNG compare writes), and never a size change. A sketch with several targets needs the part in front,
64
+ `<layer>/<name>:<state>[@x,y,w,h]`, and refuses a bare state (exit 2); a one-target sketch takes both forms. Any other DIFF on the part still fails. After the pick the
65
+ cover ends, and the part must compare green again.
66
+
54
67
  One change across several parts (an alias removal, a token rename) is one sketch with several targets: each
55
68
  variant holds every target, and `ready` compares them all. `ready` and `preview` also name every part a `shared/`
56
69
  edit reaches.
@@ -161,8 +174,7 @@ several variants at once; with `--round` they all start the new round. A new rou
161
174
  variant and mark the sketch `ready` again. `sketch new --round <title> --note <text>` names
162
175
  round 1 and sets its note; `--round` without `--note` fails. `sketch ready` refuses while the running round has no note; set it with `sketch round`.
163
176
  `sketch ready` also shoots every state of every variant and of the product's part: when not one pixel differs
164
- in any of them, it merges the sketch itself and logs it as an `auto` entry in the product's `accepted.json`
165
- ("No visible change in any state: merged automatically."). Any difference or failed shot leaves it `ready`.
177
+ in any of them, it merges the sketch itself. Any difference or failed shot leaves it `ready`.
166
178
  A problem the product already had before the merge does not block it (the check prints it as a note); only a problem the merge adds does.
167
179
  Run `sketch ready` again on a `ready` sketch to repeat the check and the auto merge.
168
180
 
@@ -194,6 +206,6 @@ in the product since the sketch was created, the merge refuses: bring each varia
194
206
  current shape. The user's reason is optional: with one, the merge sets the note from it; without
195
207
  one, the part keeps the `note` the picked variant file holds, so write that note while you draw.
196
208
 
197
- After the user approves a direct edit of a merged part (no sketch), run `et design accept <layer>/<name> --product <p> --by <model id> --note <why>` so `design check` stops warning `changed outside a sketch`. Never accept an edit the user did not approve.
209
+ A part is build-ready when `design check` writes a current check record with no blockers (`ready.json`, tool-written, committed); Studio shows "Baubereit", "Nicht baubereit (n)" or "Wird geprüft". A direct edit of a merged part outside a sketch opens an old/new edit sketch that the user picks in Studio; there is no go and no accept command. `design check` warns `LEFTOVER-FILE` for a leftover gate file of the old go and accept flow and names the file to delete.
198
210
 
199
211
  To drop a merged part the user no longer wants (folded back into its host, say), run `et design part remove <layer>/<name> --product <p> --by <model id>`: it deletes the folder, its `merged.json` entry and its tsconfig path, and resolves its comments. It refuses while another part imports it or an open sketch targets it. Never `git rm` a part by hand.
@@ -196,6 +196,10 @@ before. Then run, with the slug of the file you saved:
196
196
  node <sessions-skill-dir>/agent-sessions.mjs restart <name> '<resume command> <slug> [<text after the slug>]'
197
197
  ```
198
198
 
199
+ Add `--process` after `restart` when the session changed a plugin, a settings file, an MCP server or `PATH`.
200
+ A plain restart keeps the old process, so the change reaches only a new one.
201
+ Before `--process`, write the command line of each background job of the session into the handoff file. The exit stops these jobs, so the new process starts them again.
202
+
199
203
  If the user wrote text after the slug of the save command, append it to the prompt word for word.
200
204
  Escape each `'` in it as `'\''`. The restart drops anything you leave out, and the fresh session has
201
205
  no other way to see it.
@@ -227,8 +231,11 @@ first line of the handoff file: `Restarted because: <one line>`. End the turn wi
227
231
  the constraints and the dead ends, so do not re-read the plan files it summarises, and do
228
232
  not survey the directory. Open a source file when you are going to edit it, and load a
229
233
  focused guidance file only when the work needs one.
230
- 5. Continue with the **Next steps** section. Don't redo work listed under
234
+ 5. If the handoff name starts with a role prefix (`builder-`, `doc-agent-`) and a skill
235
+ of that name exists (`builder-agent`, `doc-agent`), load that skill now. Do it yourself;
236
+ do not wait for a line in the handoff that asks for it.
237
+ 6. Continue with the **Next steps** section. Don't redo work listed under
231
238
  _Done_; don't re-open questions under _Decisions_.
232
- 6. When every next step is complete, decide whether the handoff can be removed. Delete it
239
+ 7. When every next step is complete, decide whether the handoff can be removed. Delete it
233
240
  only if this workflow created it and the save workflow or user authorized cleanup.
234
241
  Otherwise report that it is no longer needed and let the user decide.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@ethlete/agent-rules",
3
- "version": "0.1.0-next.32",
3
+ "version": "0.1.0-next.33",
4
4
  "repository": {
5
5
  "type": "git",
6
6
  "url": "https://github.com/ethlete-io/ethdk",