@ethlete/agent-rules 0.1.0-next.30 → 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.
- package/CHANGELOG.md +43 -0
- package/README.md +7 -1
- package/content/hooks/background-design-commands.py +140 -0
- package/content/hooks/builder-chat-words.py +74 -0
- package/content/hooks/context-warning.py +48 -19
- package/content/hooks/git-safety.py +181 -0
- package/content/mods/plugins/e2e-progress/.claude-plugin/plugin.json +1 -1
- package/content/rules/nx-layout.md +5 -2
- package/content/rules/subagent-models.md +2 -2
- package/content/skills/agent-sessions/SKILL.md +6 -4
- package/content/skills/agent-sessions/agent-sessions.mjs +32 -10
- package/content/skills/codex-subagent/SKILL.md +8 -8
- package/content/skills/codex-subagent/codex-agent.mjs +1 -1
- package/content/skills/design-exploration/SKILL.md +47 -517
- package/content/skills/design-exploration/reference-check-and-notes.md +92 -0
- package/content/skills/design-exploration/reference-flows-and-wireframes.md +78 -0
- package/content/skills/design-exploration/reference-inbox-and-mcp.md +54 -0
- package/content/skills/design-exploration/reference-order-and-design-system.md +124 -0
- package/content/skills/design-exploration/reference-products-and-sketches.md +199 -0
- package/content/skills/design-exploration/reference-shots.md +25 -0
- package/content/skills/git-commit/SKILL.md +10 -0
- package/content/skills/grilling/SKILL.md +1 -0
- package/content/skills/handoff/SKILL.md +20 -4
- package/package.json +1 -1
- package/src/index.js +2 -1
- package/src/index.js.map +1 -1
- package/src/lib/git-flow-repair.d.ts +1 -1
- package/src/lib/git-flow-start.d.ts +1 -1
- package/src/lib/migrate.js +2 -1
- package/src/lib/migrate.js.map +1 -1
- package/src/lib/output-style-command.d.ts +1 -1
- package/src/lib/prettier.d.ts +21 -0
- package/src/lib/prettier.js +51 -0
- package/src/lib/prettier.js.map +1 -0
- package/src/lib/sync.d.ts +1 -1
- package/src/lib/sync.js +9 -2
- package/src/lib/sync.js.map +1 -1
- package/src/lib/targets/hooks-shared.d.ts +3 -3
- package/src/lib/targets/hooks-shared.js +58 -20
- package/src/lib/targets/hooks-shared.js.map +1 -1
package/CHANGELOG.md
CHANGED
|
@@ -1,5 +1,48 @@
|
|
|
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
|
+
|
|
29
|
+
## 0.1.0-next.31
|
|
30
|
+
|
|
31
|
+
### Minor Changes
|
|
32
|
+
|
|
33
|
+
- The design-exploration skill now describes the `ethlete-design` MCP server: its tools by job, when to use them instead of the `et design` CLI, and the `sketch_report` and `sketch_ready` rules.
|
|
34
|
+
|
|
35
|
+
### Patch Changes
|
|
36
|
+
|
|
37
|
+
- The Codex subagent skill lists gpt-6-luna, gpt-6.1-terra (the new default), gpt-6.1-sol and gpt-6-astra. The context hook gives gpt-6.1 a 500k pricing boundary.
|
|
38
|
+
- `agent-sessions restart` types the prompt after the clear even when a message woke the fresh session. Only typed text in its prompt holds the prompt back.
|
|
39
|
+
- The design exploration skill lists `et design sketch target add`.
|
|
40
|
+
- `agent-sessions restart` no longer reads the Claude Code IDE context chip (`[⧉ In file]`) on the prompt line as a draft, so it types the resume prompt after the clear.
|
|
41
|
+
- `control(markup, name)` from `@ethlete/components/design/control` renames a control that is already marked, so a design reuses a control part under its own control name.
|
|
42
|
+
- CONTROL-STATE counts an interaction word only as the last word of a state name, drops modifier words from the element name, and asks a component without controls to declare them (Q164).
|
|
43
|
+
- `et design check`, `sketch check`, `sketch ready` and `design complete` fail interaction states on a part that declares no control (`CONTROL-STATE`). `design complete` hints a state that changes under 10 % of its frame.
|
|
44
|
+
- Design checks report a drawn button, link, input or control role without `data-et-control` as `CONTROL-PART-INPUT`: a hint on merged parts, an error when a sketch adds one.
|
|
45
|
+
|
|
3
46
|
## 0.1.0-next.30
|
|
4
47
|
|
|
5
48
|
### Minor Changes
|
package/README.md
CHANGED
|
@@ -312,7 +312,13 @@ per hook in the config:
|
|
|
312
312
|
|
|
313
313
|
```json
|
|
314
314
|
{
|
|
315
|
-
"hooks": [
|
|
315
|
+
"hooks": [
|
|
316
|
+
"context-warning",
|
|
317
|
+
"subagent-model-policy",
|
|
318
|
+
"background-design-commands",
|
|
319
|
+
"git-safety",
|
|
320
|
+
"builder-chat-words"
|
|
321
|
+
]
|
|
316
322
|
}
|
|
317
323
|
```
|
|
318
324
|
|
|
@@ -0,0 +1,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)
|
|
@@ -137,6 +137,7 @@ PREMIUM_BOUNDARY = 200_000
|
|
|
137
137
|
# Codex models whose long-context pricing starts above 272k input tokens. Models
|
|
138
138
|
# without that pricing rule use their reported context window instead.
|
|
139
139
|
CODEX_PREMIUM_BOUNDARIES = (
|
|
140
|
+
("gpt-6.1", 500_000),
|
|
140
141
|
("gpt-5.6", 272_000),
|
|
141
142
|
("gpt-5.5", 272_000),
|
|
142
143
|
("gpt-5.4-mini", None),
|
|
@@ -257,37 +258,65 @@ def handoff_skill(root):
|
|
|
257
258
|
|
|
258
259
|
|
|
259
260
|
SELF_RESTART = (
|
|
260
|
-
"you run in an agent-sessions tmux session, so do not hand this step to the user
|
|
261
|
-
"test
|
|
262
|
-
|
|
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, '
|
|
263
264
|
"restart with a prompt that names the next step"
|
|
264
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
|
+
)
|
|
265
270
|
USER_DIRECTED_KEYS = ("recommend", "suggest", "save_now", "save_at_stop")
|
|
266
271
|
|
|
267
272
|
|
|
268
|
-
def
|
|
269
|
-
"""
|
|
270
|
-
|
|
271
|
-
|
|
272
|
-
|
|
273
|
-
|
|
274
|
-
|
|
275
|
-
|
|
276
|
-
|
|
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
|
|
277
291
|
try:
|
|
278
292
|
session = subprocess.run(
|
|
279
|
-
["tmux", "display-message", "-p", "
|
|
293
|
+
["tmux", "display-message", "-p", "-t", pane, "#{session_name}"],
|
|
294
|
+
capture_output=True,
|
|
295
|
+
text=True,
|
|
296
|
+
timeout=2,
|
|
280
297
|
).stdout.strip()
|
|
281
298
|
except (OSError, subprocess.SubprocessError):
|
|
282
|
-
return
|
|
283
|
-
|
|
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)}
|
|
284
304
|
|
|
285
305
|
|
|
286
|
-
def resolve_profile(profile, skill, self_restart=
|
|
306
|
+
def resolve_profile(profile, skill, self_restart=None):
|
|
287
307
|
"""Fills the installed handoff skill's name into a profile's message fragments."""
|
|
288
|
-
resolved =
|
|
308
|
+
resolved = profile
|
|
309
|
+
if self_restart:
|
|
310
|
+
resolved = {
|
|
311
|
+
**profile,
|
|
312
|
+
**{key: SELF_RESTART for key in USER_DIRECTED_KEYS},
|
|
313
|
+
"act_now": SELF_RESTART_ACT_NOW,
|
|
314
|
+
}
|
|
315
|
+
values = {"handoff": skill, **(self_restart or {})}
|
|
289
316
|
return {
|
|
290
|
-
key:
|
|
317
|
+
key: re.sub(r"\{(handoff|script|name)\}", lambda m: values.get(m.group(1), m.group(0)), value)
|
|
318
|
+
if isinstance(value, str)
|
|
319
|
+
else value
|
|
291
320
|
for key, value in resolved.items()
|
|
292
321
|
}
|
|
293
322
|
|
|
@@ -850,7 +879,7 @@ def main():
|
|
|
850
879
|
if disabled_locally(local_config):
|
|
851
880
|
return
|
|
852
881
|
|
|
853
|
-
profile = resolve_profile(profile, handoff_skill(root), self_restart=
|
|
882
|
+
profile = resolve_profile(profile, handoff_skill(root), self_restart=self_restart_target(root))
|
|
854
883
|
|
|
855
884
|
claude_subagent = agent == "claude" and bool(data.get("agent_id"))
|
|
856
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)
|
|
@@ -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`
|
|
35
|
-
and `test`
|
|
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.
|
|
28
|
-
calls
|
|
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,11 +45,11 @@ 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
|
|
|
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. 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. 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.
|
|
53
53
|
|
|
54
54
|
`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
55
|
|
|
@@ -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.
|