@ethlete/agent-rules 0.1.0-next.31 → 0.1.0-next.32

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 +26 -0
  2. package/README.md +7 -1
  3. package/content/hooks/background-design-commands.py +140 -0
  4. package/content/hooks/builder-chat-words.py +74 -0
  5. package/content/hooks/context-warning.py +47 -19
  6. package/content/hooks/git-safety.py +181 -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 +5 -3
  11. package/content/skills/agent-sessions/agent-sessions.mjs +20 -4
  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 +199 -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 +20 -4
  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,31 @@
1
1
  # @ethlete/agent-rules
2
2
 
3
+ ## 0.1.0-next.32
4
+
5
+ ### Minor Changes
6
+
7
+ - 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`.
8
+ - 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.
9
+
10
+ ### Patch Changes
11
+
12
+ - 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.
13
+ - `ethlete-agents sync` and `et` write their JSON files (`.mcp.json`, design and update state) in the checkout's Prettier format.
14
+ - 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.
15
+ - 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.
16
+ - 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.
17
+ - The design-exploration skill tells parts to use `data-et-semantic-color="<type>"` instead of a picked `et-color--<name>` class.
18
+ - The design-exploration skill tells an agent to take every colour from the SDK surface and colour theming, never from its own tokens.
19
+ - 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.
20
+ - Fix the nx-layout rule to describe inferred lint and test targets instead of the removed `@nx/eslint:lint` and `@nx/jest:jest` executors.
21
+ - 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.
22
+ - Let `git stash list` and `git stash show` through the git-safety hook. Every writing stash subcommand stays refused, and the refusal names it.
23
+ - `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.
24
+ - The design skill now says visible UI is built only from a picked sketch, and the grilling skill says to decide technical questions yourself.
25
+ - The agent-sessions skill notes that `yarn commit` runs the related specs before it takes the index lock.
26
+ - `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.
27
+ - `et design sketch report` refuses a ready sketch. Add `--reopen` to move it back to in progress when the user asks for a change.
28
+
3
29
  ## 0.1.0-next.31
4
30
 
5
31
  ### 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,140 @@
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. Quoted text is ignored, so a commit message that mentions a
11
+ command does not trip the hook.
12
+
13
+ Can be disabled per machine via a gitignored ethlete-agents.config.local.json at the repo
14
+ root: {"disableHooks": true} or {"disableHooks": ["background-design-commands"]}.
15
+
16
+ Fail-safe: any error exits 0 with no output.
17
+ """
18
+
19
+ import json
20
+ import os
21
+ import re
22
+ import sys
23
+
24
+ HOOK_NAME = "background-design-commands"
25
+ LOCAL_CONFIG_FILE = "ethlete-agents.config.local.json"
26
+
27
+ 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|$|[;&|:])")),
34
+ ("--visible-change", re.compile(r"(?:^|\s)--visible-change(?=\s|=|$)")),
35
+ )
36
+
37
+ QUOTED = re.compile(r"\"(?:\\.|[^\"\\])*\"|'[^']*'")
38
+
39
+
40
+ def agent_name(argv):
41
+ for index, arg in enumerate(argv):
42
+ if arg == "--agent" and index + 1 < len(argv):
43
+ return argv[index + 1]
44
+ if arg.startswith("--agent="):
45
+ return arg.split("=", 1)[1]
46
+ return "claude"
47
+
48
+
49
+ def repo_root(data):
50
+ for variable in ("CLAUDE_PROJECT_DIR", "CODEX_PROJECT_DIR"):
51
+ value = os.environ.get(variable)
52
+ if value:
53
+ return value
54
+ here = os.path.abspath(__file__)
55
+ derived = os.path.dirname(os.path.dirname(os.path.dirname(os.path.dirname(here))))
56
+ if os.path.isfile(os.path.join(derived, LOCAL_CONFIG_FILE)):
57
+ return derived
58
+ cwd = data.get("cwd")
59
+ return cwd if isinstance(cwd, str) and cwd else derived
60
+
61
+
62
+ def load_local_config(root):
63
+ if not root:
64
+ return {}
65
+ try:
66
+ with open(os.path.join(root, LOCAL_CONFIG_FILE), encoding="utf-8") as f:
67
+ config = json.load(f)
68
+ except (OSError, ValueError, AttributeError):
69
+ return {}
70
+ return config if isinstance(config, dict) else {}
71
+
72
+
73
+ def disabled_locally(config):
74
+ disabled = config.get("disableHooks")
75
+ return disabled is True or (isinstance(disabled, list) and HOOK_NAME in disabled)
76
+
77
+
78
+ 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
84
+ return None
85
+
86
+
87
+ def reason_for(name):
88
+ return (
89
+ f"`{name}` takes minutes and blocks the session when it runs in the foreground. "
90
+ "Call Bash again with the same command and `run_in_background: true`, then read its output "
91
+ "when the completion notice arrives."
92
+ )
93
+
94
+
95
+ def main():
96
+ if agent_name(sys.argv[1:]) != "claude":
97
+ return
98
+
99
+ data = json.load(sys.stdin)
100
+
101
+ if data.get("tool_name") != "Bash":
102
+ return
103
+
104
+ tool_input = data.get("tool_input")
105
+
106
+ if not isinstance(tool_input, dict) or tool_input.get("run_in_background") is True:
107
+ return
108
+
109
+ command = tool_input.get("command")
110
+
111
+ if not isinstance(command, str):
112
+ return
113
+
114
+ if disabled_locally(load_local_config(repo_root(data))):
115
+ return
116
+
117
+ name = long_command_in(command)
118
+
119
+ if not name:
120
+ return
121
+
122
+ print(
123
+ json.dumps(
124
+ {
125
+ "hookSpecificOutput": {
126
+ "hookEventName": "PreToolUse",
127
+ "permissionDecision": "deny",
128
+ "permissionDecisionReason": reason_for(name),
129
+ }
130
+ }
131
+ )
132
+ )
133
+
134
+
135
+ if __name__ == "__main__":
136
+ try:
137
+ main()
138
+ except Exception:
139
+ pass
140
+ sys.exit(0)
@@ -0,0 +1,74 @@
1
+ #!/usr/bin/env python3
2
+ """Stop hook: logs how many chat words a builder session wrote in the turn that just ended.
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. It never blocks,
6
+ never prints and always exits 0.
7
+ """
8
+
9
+ import json
10
+ import os
11
+ import sys
12
+ from datetime import datetime, timezone
13
+
14
+ LOG_FILE = os.path.join(".claude", "builder-chat-words.jsonl")
15
+
16
+
17
+ def is_prompt(entry):
18
+ if entry.get("type") != "user":
19
+ return False
20
+ content = (entry.get("message") or {}).get("content")
21
+ if isinstance(content, str):
22
+ return True
23
+ return isinstance(content, list) and not any(
24
+ isinstance(block, dict) and block.get("type") == "tool_result" for block in content
25
+ )
26
+
27
+
28
+ def count_words(path):
29
+ words = 0
30
+ with open(path, encoding="utf-8") as handle:
31
+ for line in handle:
32
+ try:
33
+ entry = json.loads(line)
34
+ except ValueError:
35
+ continue
36
+ if not isinstance(entry, dict):
37
+ continue
38
+ if is_prompt(entry):
39
+ words = 0
40
+ elif entry.get("type") == "assistant":
41
+ content = (entry.get("message") or {}).get("content")
42
+ if isinstance(content, str):
43
+ words += len(content.split())
44
+ elif isinstance(content, list):
45
+ 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
49
+
50
+
51
+ def main():
52
+ if os.environ.get("ETHLETE_AGENT_ROLE") != "builder":
53
+ return
54
+ payload = json.load(sys.stdin)
55
+ path = payload.get("transcript_path")
56
+ if not path or not os.path.isfile(path):
57
+ return
58
+ root = os.environ.get("CLAUDE_PROJECT_DIR") or payload.get("cwd") or os.getcwd()
59
+ log = os.path.join(root, LOG_FILE)
60
+ os.makedirs(os.path.dirname(log), exist_ok=True)
61
+ record = {
62
+ "timestamp": datetime.now(timezone.utc).isoformat(),
63
+ "session_id": payload.get("session_id"),
64
+ "words": count_words(path),
65
+ }
66
+ with open(log, "a", encoding="utf-8") as handle:
67
+ handle.write(json.dumps(record) + "\n")
68
+
69
+
70
+ try:
71
+ main()
72
+ except Exception:
73
+ pass
74
+ 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:
@@ -0,0 +1,181 @@
1
+ #!/usr/bin/env python3
2
+ """PreToolUse hook: git safety in a checkout several agents share.
3
+
4
+ Denies, for a Bash call:
5
+ - `git add -A`, `git add --all` and `git add .` (they stage other sessions' work)
6
+ - every `git stash` that writes (bare, `push`, `save`, `pop`, `apply`, `drop`, `clear`, `branch`,
7
+ `create`, `store`); `git stash list` and `git stash show` stay allowed
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)
12
+
13
+ `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.
16
+
17
+ Can be disabled per machine via a gitignored ethlete-agents.config.local.json at the repo
18
+ root: {"disableHooks": true} or {"disableHooks": ["git-safety"]}.
19
+
20
+ Fail-safe: any error exits 0 with no output.
21
+ """
22
+
23
+ import json
24
+ import os
25
+ import re
26
+ import sys
27
+
28
+ HOOK_NAME = "git-safety"
29
+ LOCAL_CONFIG_FILE = "ethlete-agents.config.local.json"
30
+
31
+ MESSAGE_ARG = re.compile(
32
+ r"""(?:^|(?<=\s))(?:-[a-zA-Z]*m|--message)(?:=|\s*)(?:"(?:\\.|[^"\\])*"|'[^']*'|[^\s;&|]*)"""
33
+ )
34
+ SEGMENT_SPLIT = re.compile(r"[;&|\n]+")
35
+ GLOBAL_OPTS_WITH_VALUE = ("-C", "-c", "--git-dir", "--work-tree", "--namespace")
36
+ ADD_ALL = ("-A", "--all", ".")
37
+ GO_JSON = re.compile(r"(?:^|/)go\.json$")
38
+
39
+ def agent_name(argv):
40
+ for index, arg in enumerate(argv):
41
+ if arg == "--agent" and index + 1 < len(argv):
42
+ return argv[index + 1]
43
+ if arg.startswith("--agent="):
44
+ return arg.split("=", 1)[1]
45
+ return "claude"
46
+
47
+
48
+ def repo_root(data):
49
+ for variable in ("CLAUDE_PROJECT_DIR", "CODEX_PROJECT_DIR"):
50
+ value = os.environ.get(variable)
51
+ if value:
52
+ return value
53
+ here = os.path.abspath(__file__)
54
+ derived = os.path.dirname(os.path.dirname(os.path.dirname(os.path.dirname(here))))
55
+ if os.path.isfile(os.path.join(derived, LOCAL_CONFIG_FILE)):
56
+ return derived
57
+ cwd = data.get("cwd")
58
+ return cwd if isinstance(cwd, str) and cwd else derived
59
+
60
+
61
+ def load_local_config(root):
62
+ if not root:
63
+ return {}
64
+ try:
65
+ with open(os.path.join(root, LOCAL_CONFIG_FILE), encoding="utf-8") as f:
66
+ config = json.load(f)
67
+ except (OSError, ValueError, AttributeError):
68
+ return {}
69
+ return config if isinstance(config, dict) else {}
70
+
71
+
72
+ def disabled_locally(config):
73
+ disabled = config.get("disableHooks")
74
+ return disabled is True or (isinstance(disabled, list) and HOOK_NAME in disabled)
75
+
76
+
77
+ def git_calls_in(command):
78
+ """Yield (subcommand, args) for every command-position git call in the shell string."""
79
+ stripped = MESSAGE_ARG.sub(" ", command)
80
+ unquoted = stripped.replace('"', " ").replace("'", " ")
81
+ for segment in SEGMENT_SPLIT.split(unquoted):
82
+ words = segment.split()
83
+ index = 0
84
+ while index < len(words) and re.match(r"^[A-Za-z_][A-Za-z0-9_]*=", words[index]):
85
+ index += 1
86
+ if index < len(words) and (words[index] == "git" or words[index].endswith("/git")):
87
+ rest = words[index + 1 :]
88
+ while rest and rest[0].startswith("-"):
89
+ skip = 2 if rest[0] in GLOBAL_OPTS_WITH_VALUE else 1
90
+ rest = rest[skip:]
91
+ if rest:
92
+ yield rest[0], rest[1:]
93
+
94
+
95
+ STASH_READS = ("list", "show")
96
+
97
+
98
+ def stash_subcommand_of(args):
99
+ """The stash subcommand `args` run; a bare `git stash` or one starting with an option is `push`."""
100
+ return args[0] if args and not args[0].startswith("-") else "push"
101
+
102
+
103
+ def violation_in(command):
104
+ """(rule, allowed form) for the first forbidden git call, or None."""
105
+ for sub, args in git_calls_in(command):
106
+ if sub == "add":
107
+ if any(arg in ADD_ALL for arg in args):
108
+ return (
109
+ "`git add -A`, `--all` and `.` stage other sessions' work",
110
+ "stage named paths: `git add <path> ...`, or skip staging and use `git commit -m \"...\" -- <paths>`",
111
+ )
112
+ if sub == "stash" and stash_subcommand_of(args) not in STASH_READS:
113
+ return (
114
+ f"`git stash {stash_subcommand_of(args)}` hides or rewrites other sessions' uncommitted work",
115
+ "leave the working tree alone; commit your own paths with `git commit -m \"...\" -- <paths>`",
116
+ )
117
+ if sub == "commit":
118
+ if "--amend" in args:
119
+ return (
120
+ "`git commit --amend` rewrites a commit another session may have built on",
121
+ "make a new commit: `git commit -m \"...\" -- <paths>`",
122
+ )
123
+ if "--" not in args:
124
+ return (
125
+ "`git commit` without a pathspec commits everything staged, including other sessions' work",
126
+ "name the paths: `git commit -m \"...\" -- <paths>`",
127
+ )
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
+ return None
134
+
135
+
136
+ def reason_for(rule, allowed):
137
+ return f"Denied: {rule}. Allowed form: {allowed}."
138
+
139
+
140
+ def main():
141
+ if agent_name(sys.argv[1:]) != "claude":
142
+ return
143
+
144
+ data = json.load(sys.stdin)
145
+
146
+ if data.get("tool_name") != "Bash":
147
+ return
148
+
149
+ tool_input = data.get("tool_input")
150
+ command = tool_input.get("command") if isinstance(tool_input, dict) else None
151
+
152
+ if not isinstance(command, str):
153
+ return
154
+
155
+ if disabled_locally(load_local_config(repo_root(data))):
156
+ return
157
+
158
+ found = violation_in(command)
159
+
160
+ if not found:
161
+ return
162
+
163
+ print(
164
+ json.dumps(
165
+ {
166
+ "hookSpecificOutput": {
167
+ "hookEventName": "PreToolUse",
168
+ "permissionDecision": "deny",
169
+ "permissionDecisionReason": reason_for(*found),
170
+ }
171
+ }
172
+ )
173
+ )
174
+
175
+
176
+ if __name__ == "__main__":
177
+ try:
178
+ main()
179
+ except Exception:
180
+ pass
181
+ sys.exit(0)
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "e2e-progress",
3
- "version": "0.1.0-next.31",
3
+ "version": "0.1.0-next.32",
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
  }
@@ -31,8 +31,11 @@ libs/
31
31
  reason, such as size or build time, and name that reason to the user.
32
32
  Never put features, queries, shared UI or theme in the app project.
33
33
  - Do not add Nx `feature/ui/data-access/util` folders or `type:*` tags.
34
- - Every lib has `src/index.ts` and explicit `build` (`@nx/angular:ng-packagr-lite`), `lint`
35
- and `test` targets in `project.json`. Keep `plugins: []` in `nx.json`.
34
+ - Every lib has `src/index.ts` and an explicit `build` target in `project.json`
35
+ (`@nx/angular:ng-packagr-lite`). `lint` and `test` are inferred by the plugins in `nx.json`
36
+ (`@nx/eslint/plugin`, and `@nx/vite/plugin` or `@nx/jest/plugin` for the test runner), so
37
+ do not write `@nx/eslint:lint` or `@nx/jest:jest` executor targets by hand. Nx 24 has no
38
+ such executors. Run `npx nx show project <name>` to see the inferred targets.
36
39
  - Alias = path with slashes: `@acme/domain/admin`, `@acme/queries`, `@acme/types`,
37
40
  `@acme/uikit`, `@acme/env`. Project name = path with dashes (`domain-admin`).
38
41
  Component `prefix` = the repo abbreviation.
@@ -24,8 +24,8 @@ agent type carries the model and reasoning effort its own definition sets, and a
24
24
  inherits the parent's model.
25
25
 
26
26
  A subagent starts cold: it loads the full start context (about 50k tokens) before its first step,
27
- and measured subagents cost as much as the sessions that spawn them. Do a lookup of a few tool
28
- calls yourself. Give each subagent one large slice, not several small ones.
27
+ and measured subagents cost as much as the sessions that spawn them. A lookup or job of under about 10 tool
28
+ calls stays in the session. Give each subagent one large slice, not several small ones.
29
29
 
30
30
  A subagent has the same context budget as the session, measured on its own transcript, and the
31
31
  context-warning hook tells it to hand back at the critical tier. Size each prompt to fit: one
@@ -1,6 +1,6 @@
1
1
  ---
2
2
  name: agent-sessions
3
- description: Run agents in tmux sessions of one checkout, message them, wake idle ones, restart a session with a prompt, and claim paths so two agents do not overwrite each other. Read before you edit a shared file while another agent may be active, and when the user says "mailbox", "coordinate with the other agent", "claim a file", "hand off to the other agent", or "restart the session".
3
+ description: Run agents in tmux sessions of one checkout, message them, wake idle ones, restart a session with a prompt (also your own, to clear yourself and resume after a handoff), and claim paths so two agents do not overwrite each other. Read before you edit a shared file while another agent may be active, and when the user says "mailbox", "coordinate with the other agent", "claim a file", "hand off to the other agent", or "restart the session".
4
4
  kind: skill
5
5
  scope: both
6
6
  ---
@@ -18,7 +18,7 @@ the `ethlete-agents` package is a dependency, `npx ethlete-agents sessions <comm
18
18
  script, so you do not need `<skill-dir>`. The older name `npx ethlete-agents mailbox` works too.
19
19
 
20
20
  ```bash
21
- node <skill-dir>/agent-sessions.mjs run <name> <command...>
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
23
  node <skill-dir>/agent-sessions.mjs restart <name> [--after <seconds>] <prompt...>
24
24
  node <skill-dir>/agent-sessions.mjs down [<name>...]
@@ -45,7 +45,7 @@ Nothing runs until you start it, so a person who does not use this does nothing.
45
45
 
46
46
  ## Sessions
47
47
 
48
- `run <name> <command...>` starts the command in the `tmux` session `<name>@<repo>`, or attaches if it runs already. It also starts the dispatcher when none runs. Session names carry the repo name, so `codex` in two repos never collides. If that session has a terminal attached already, `run` starts `<name>-2`, then `<name>-3`, and so on. A detached session is attached again. So a second `claude` terminal gets its own agent.
48
+ `run <name> <command...>` starts the command in the `tmux` session `<name>@<repo>`, or attaches if it runs already. It also starts the dispatcher when none runs. Session names carry the repo name, so `codex` in two repos never collides. If that session has a terminal attached already, `run` starts `<name>-2`, then `<name>-3`, and so on. A detached session is attached again. `--role builder` sets `ETHLETE_AGENT_ROLE=builder` and, for `claude`, appends a system prompt that keeps the chat silent and sends reports to the grill session. `--dry-run` prints the command instead of starting it. So a second `claude` terminal gets its own agent.
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
 
@@ -78,4 +78,6 @@ If the prompt of the agent holds typed text, the dispatcher waits until that tex
78
78
  4. After each larger step, run `inbox` again. Most agents cannot wake on new output, so you must pull messages yourself.
79
79
  5. When you finish, run `release`. A claim also expires after 4 hours, or after `--ttl` hours.
80
80
 
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).
82
+
81
83
  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.