@windyroad/architect 0.22.0-preview.1164 → 0.22.1-preview.1173
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.
|
@@ -13,7 +13,9 @@ tool_name() {
|
|
|
13
13
|
|
|
14
14
|
run_gate() {
|
|
15
15
|
local output status
|
|
16
|
-
|
|
16
|
+
local child="$1"
|
|
17
|
+
shift
|
|
18
|
+
output="$(printf '%s' "$INPUT" | "$SCRIPT_DIR/$child" "$@")"
|
|
17
19
|
status=$?
|
|
18
20
|
[ -z "$output" ] || printf '%s\n' "$output"
|
|
19
21
|
[ "$status" -eq 0 ] || exit "$status"
|
|
@@ -22,7 +24,9 @@ run_gate() {
|
|
|
22
24
|
|
|
23
25
|
run_side_effect() {
|
|
24
26
|
local output status
|
|
25
|
-
|
|
27
|
+
local child="$1"
|
|
28
|
+
shift
|
|
29
|
+
output="$(printf '%s' "$INPUT" | "$SCRIPT_DIR/$child" "$@")"
|
|
26
30
|
status=$?
|
|
27
31
|
[ -z "$output" ] || printf '%s\n' "$output"
|
|
28
32
|
return "$status"
|
|
@@ -69,6 +73,9 @@ if messages:
|
|
|
69
73
|
;;
|
|
70
74
|
Bash)
|
|
71
75
|
run_gate architect-readme-pairing-check.sh
|
|
76
|
+
run_gate bash-write-dispatch.sh \
|
|
77
|
+
"$SCRIPT_DIR/architect-enforce-edit.sh" \
|
|
78
|
+
"$SCRIPT_DIR/architect-oversight-marker-discipline.sh"
|
|
72
79
|
;;
|
|
73
80
|
esac
|
|
74
81
|
;;
|
|
@@ -85,7 +92,13 @@ if messages:
|
|
|
85
92
|
run_side_effect architect-refresh-hash.sh || true
|
|
86
93
|
run_side_effect architect-compendium-update-entry.sh || true
|
|
87
94
|
;;
|
|
88
|
-
Bash
|
|
95
|
+
Bash)
|
|
96
|
+
run_side_effect architect-slide-marker.sh || true
|
|
97
|
+
run_side_effect bash-write-dispatch.sh --all \
|
|
98
|
+
"$SCRIPT_DIR/architect-refresh-hash.sh" \
|
|
99
|
+
"$SCRIPT_DIR/architect-compendium-update-entry.sh" || true
|
|
100
|
+
;;
|
|
101
|
+
Skill)
|
|
89
102
|
run_side_effect architect-slide-marker.sh || true
|
|
90
103
|
;;
|
|
91
104
|
esac
|
|
@@ -0,0 +1,232 @@
|
|
|
1
|
+
#!/bin/bash
|
|
2
|
+
# Route supported, explicit Bash write targets through existing Edit/Write hooks.
|
|
3
|
+
set -uo pipefail
|
|
4
|
+
|
|
5
|
+
run_all=0
|
|
6
|
+
if [ "${1:-}" = "--all" ]; then
|
|
7
|
+
run_all=1
|
|
8
|
+
shift
|
|
9
|
+
fi
|
|
10
|
+
[ "$#" -gt 0 ] || exit 0
|
|
11
|
+
|
|
12
|
+
input=$(cat)
|
|
13
|
+
events=$(INPUT="$input" python3 <<'PY' 2>/dev/null || true
|
|
14
|
+
import json
|
|
15
|
+
import os
|
|
16
|
+
from pathlib import Path
|
|
17
|
+
import re
|
|
18
|
+
|
|
19
|
+
# ponytail: this is a literal simple-command classifier, not a shell interpreter.
|
|
20
|
+
# Expansions, control structures and in-process writes need a shell AST/runtime
|
|
21
|
+
# mutation boundary; do not guess their targets or execute input to discover them.
|
|
22
|
+
TOKEN = re.compile(
|
|
23
|
+
r"(?P<space>[ \t\r]+|\\\n)|(?P<comment>#[^\n]*)|"
|
|
24
|
+
r"(?P<op><<-|<<|>>|&&|\|\||>&|<&|[|;&<>()\n])|"
|
|
25
|
+
r'(?P<word>(?:[^\s|;&<>()\x27\x22\\]+|\x27[^\x27]*\x27|\x22(?:\\.|[^\x22\\])*\x22|\\.)+)'
|
|
26
|
+
)
|
|
27
|
+
|
|
28
|
+
def literal_word(raw):
|
|
29
|
+
# shlex does not implement Bash's dollar/backtick escapes inside double quotes.
|
|
30
|
+
value, quote, index = [], None, 0
|
|
31
|
+
while index < len(raw):
|
|
32
|
+
char = raw[index]
|
|
33
|
+
if quote == chr(39):
|
|
34
|
+
if char == quote:
|
|
35
|
+
quote = None
|
|
36
|
+
else:
|
|
37
|
+
value.append(char)
|
|
38
|
+
elif char == "\\":
|
|
39
|
+
index += 1
|
|
40
|
+
escaped = raw[index]
|
|
41
|
+
if quote == chr(34) and escaped not in ("$", chr(96), chr(34), "\\", "\n"):
|
|
42
|
+
value.append("\\")
|
|
43
|
+
if escaped != "\n":
|
|
44
|
+
value.append(escaped)
|
|
45
|
+
elif char in (chr(39), chr(34)) and (quote is None or quote == char):
|
|
46
|
+
quote = char if quote is None else None
|
|
47
|
+
else:
|
|
48
|
+
if char in ("$", chr(96)) or quote is None and char in "*?[]{}~":
|
|
49
|
+
raise ValueError("unsupported expansion")
|
|
50
|
+
value.append(char)
|
|
51
|
+
index += 1
|
|
52
|
+
return "".join(value)
|
|
53
|
+
|
|
54
|
+
|
|
55
|
+
def tokenize(command):
|
|
56
|
+
tokens, pending = [], []
|
|
57
|
+
pos = 0
|
|
58
|
+
while pos < len(command):
|
|
59
|
+
match = TOKEN.match(command, pos)
|
|
60
|
+
if not match:
|
|
61
|
+
raise ValueError("unsupported shell syntax")
|
|
62
|
+
pos = match.end()
|
|
63
|
+
kind, raw = match.lastgroup, match.group()
|
|
64
|
+
if kind in {"space", "comment"}:
|
|
65
|
+
continue
|
|
66
|
+
token = {"kind": kind, "raw": raw, "start": match.start(), "end": pos}
|
|
67
|
+
if kind == "word":
|
|
68
|
+
token["value"] = literal_word(raw)
|
|
69
|
+
if tokens and tokens[-1]["raw"] in {"<<", "<<-"}:
|
|
70
|
+
pending.append((token, tokens[-1]["raw"] == "<<-"))
|
|
71
|
+
tokens.append(token)
|
|
72
|
+
if kind == "op" and raw == "\n":
|
|
73
|
+
for delimiter, strip_tabs in pending:
|
|
74
|
+
body = []
|
|
75
|
+
while True:
|
|
76
|
+
end = command.find("\n", pos)
|
|
77
|
+
end = len(command) if end == -1 else end + 1
|
|
78
|
+
line = command[pos:end]
|
|
79
|
+
pos = end
|
|
80
|
+
candidate = line.lstrip("\t") if strip_tabs else line
|
|
81
|
+
if candidate.rstrip("\n") == delimiter["value"]:
|
|
82
|
+
break
|
|
83
|
+
if not line or end == len(command) and not line.endswith("\n"):
|
|
84
|
+
raise ValueError("unterminated heredoc")
|
|
85
|
+
body.append(candidate)
|
|
86
|
+
content = "".join(body)
|
|
87
|
+
quoted = delimiter["raw"] != delimiter["value"]
|
|
88
|
+
delimiter["body"] = content if quoted or not any(
|
|
89
|
+
char in content for char in ("$", chr(96), "\\")
|
|
90
|
+
) else None
|
|
91
|
+
pending.clear()
|
|
92
|
+
if pending:
|
|
93
|
+
raise ValueError("unterminated heredoc")
|
|
94
|
+
return tokens
|
|
95
|
+
|
|
96
|
+
|
|
97
|
+
def classify(data):
|
|
98
|
+
if not isinstance(data, dict) or data.get("tool_name") != "Bash":
|
|
99
|
+
return []
|
|
100
|
+
tool = data.get("tool_input") or {}
|
|
101
|
+
if not isinstance(tool, dict):
|
|
102
|
+
return []
|
|
103
|
+
command = tool.get("command")
|
|
104
|
+
base = tool.get("workdir") or tool.get("cwd") or data.get("cwd") or os.environ.get("CLAUDE_PROJECT_DIR") or os.getcwd()
|
|
105
|
+
if not isinstance(command, str) or not isinstance(base, str) or not os.path.isabs(base):
|
|
106
|
+
return []
|
|
107
|
+
segments, current = [], []
|
|
108
|
+
for token in tokenize(command):
|
|
109
|
+
if token["kind"] == "op" and token["raw"] in {"(", ")", "&"}:
|
|
110
|
+
return []
|
|
111
|
+
if token["kind"] == "op" and token["raw"] in {"|", "&&", "||", ";", "\n"}:
|
|
112
|
+
if current:
|
|
113
|
+
segments.append((current, token["raw"]))
|
|
114
|
+
current = []
|
|
115
|
+
else:
|
|
116
|
+
current.append(token)
|
|
117
|
+
if current:
|
|
118
|
+
segments.append((current, None))
|
|
119
|
+
|
|
120
|
+
targets, pipe_content, previous_separator = [], None, None
|
|
121
|
+
reserved = {"if", "then", "else", "elif", "fi", "for", "while", "until",
|
|
122
|
+
"do", "done", "case", "esac", "function", "select", "in", "!", "[[", "]]"}
|
|
123
|
+
for parts, separator in segments:
|
|
124
|
+
words, redirects = [], []
|
|
125
|
+
index = 0
|
|
126
|
+
while index < len(parts):
|
|
127
|
+
token = parts[index]
|
|
128
|
+
if token["kind"] == "word":
|
|
129
|
+
words.append(token)
|
|
130
|
+
index += 1
|
|
131
|
+
continue
|
|
132
|
+
if token["raw"] not in {">", ">>", "<", "<<", "<<-", ">&", "<&"}:
|
|
133
|
+
return []
|
|
134
|
+
if index + 1 == len(parts) or parts[index + 1]["kind"] != "word":
|
|
135
|
+
return []
|
|
136
|
+
fd = "0" if token["raw"].startswith("<") else "1"
|
|
137
|
+
if words and words[-1]["raw"].isdigit() and words[-1]["end"] == token["start"]:
|
|
138
|
+
fd = words.pop()["value"]
|
|
139
|
+
redirects.append((token["raw"], fd, parts[index + 1]))
|
|
140
|
+
index += 2
|
|
141
|
+
args = [word["value"] for word in words]
|
|
142
|
+
name = Path(args[0]).name if args else ""
|
|
143
|
+
if name in reserved:
|
|
144
|
+
return []
|
|
145
|
+
if name == "cd":
|
|
146
|
+
if len(args) != 2 or args[1].startswith("-") or redirects or separator != "&&" or previous_separator == "|":
|
|
147
|
+
return []
|
|
148
|
+
base = os.path.normpath(os.path.join(base, args[1]))
|
|
149
|
+
pipe_content, previous_separator = None, separator
|
|
150
|
+
continue
|
|
151
|
+
|
|
152
|
+
stdin_content = pipe_content if previous_separator == "|" else None
|
|
153
|
+
for op, fd, target in redirects:
|
|
154
|
+
if fd == "0":
|
|
155
|
+
stdin_content = (target.get("body") if op in {"<<", "<<-"}
|
|
156
|
+
else "" if op == "<" and target["value"] == "/dev/null"
|
|
157
|
+
else None)
|
|
158
|
+
content = None
|
|
159
|
+
if name == "echo" and (len(args) == 1 or not args[1].startswith("-") or args[1] == "-n"):
|
|
160
|
+
no_newline = len(args) > 1 and args[1] == "-n"
|
|
161
|
+
operands = args[2:] if no_newline else args[1:]
|
|
162
|
+
# Repeated options and escape handling vary across echo implementations.
|
|
163
|
+
if not any("\\" in arg for arg in operands) and not (operands and operands[0].startswith("-")):
|
|
164
|
+
content = " ".join(operands) + ("" if no_newline else "\n")
|
|
165
|
+
elif name == "printf" and len(args) >= 2:
|
|
166
|
+
if args[1] in {"%s", "%s\\n"}:
|
|
167
|
+
suffix = "" if args[1] == "%s" else "\n"
|
|
168
|
+
content = "".join(arg + suffix for arg in (args[2:] or [""]))
|
|
169
|
+
elif len(args) == 2 and "%" not in args[1] and "\\" not in args[1]:
|
|
170
|
+
content = args[1]
|
|
171
|
+
elif (name == "cat" and len(args) == 1) or name == "tee":
|
|
172
|
+
content = stdin_content
|
|
173
|
+
|
|
174
|
+
def add_target(value, body):
|
|
175
|
+
if value and value not in {"-", "/dev/null"}:
|
|
176
|
+
targets.append((str((Path(base) / value).resolve(strict=False)), body))
|
|
177
|
+
|
|
178
|
+
stdout_redirected = False
|
|
179
|
+
last_stdout = max((i for i, (op, fd, _) in enumerate(redirects)
|
|
180
|
+
if fd == "1" and op in {">", ">>", ">&"}), default=-1)
|
|
181
|
+
for i, (op, fd, target) in enumerate(redirects):
|
|
182
|
+
if op in {">", ">>"}:
|
|
183
|
+
body = content if i == last_stdout else ""
|
|
184
|
+
add_target(target["value"], body if fd == "1" else None)
|
|
185
|
+
stdout_redirected |= fd == "1"
|
|
186
|
+
elif op == ">&":
|
|
187
|
+
stdout_redirected |= fd == "1"
|
|
188
|
+
if name == "tee":
|
|
189
|
+
options = True
|
|
190
|
+
for arg in args[1:]:
|
|
191
|
+
if options and arg == "--":
|
|
192
|
+
options = False
|
|
193
|
+
elif options and arg in {"-a", "--append", "-i", "--ignore-interrupts"}:
|
|
194
|
+
continue
|
|
195
|
+
elif options and arg.startswith("-"):
|
|
196
|
+
return []
|
|
197
|
+
else:
|
|
198
|
+
add_target(arg, content)
|
|
199
|
+
pipe_content = content if separator == "|" and not stdout_redirected else None
|
|
200
|
+
previous_separator = separator
|
|
201
|
+
return targets
|
|
202
|
+
|
|
203
|
+
|
|
204
|
+
try:
|
|
205
|
+
data = json.loads(os.environ["INPUT"])
|
|
206
|
+
targets = classify(data)
|
|
207
|
+
except (KeyError, ValueError, TypeError, OSError):
|
|
208
|
+
raise SystemExit
|
|
209
|
+
|
|
210
|
+
for target, content in targets:
|
|
211
|
+
event = dict(data)
|
|
212
|
+
event["tool_name"] = "Write"
|
|
213
|
+
event["tool_input"] = {"file_path": target}
|
|
214
|
+
if content is not None:
|
|
215
|
+
event["tool_input"]["content"] = content
|
|
216
|
+
print(json.dumps(event, separators=(",", ":")))
|
|
217
|
+
PY
|
|
218
|
+
)
|
|
219
|
+
|
|
220
|
+
[ -n "$events" ] || exit 0
|
|
221
|
+
while IFS= read -r event; do
|
|
222
|
+
[ -n "$event" ] || continue
|
|
223
|
+
for child in "$@"; do
|
|
224
|
+
output=$(printf '%s' "$event" | "$child")
|
|
225
|
+
status=$?
|
|
226
|
+
[ -z "$output" ] || printf '%s\n' "$output"
|
|
227
|
+
[ "$status" -eq 0 ] || exit "$status"
|
|
228
|
+
if [ "$run_all" -eq 0 ] && [ -n "$output" ]; then
|
|
229
|
+
exit 0
|
|
230
|
+
fi
|
|
231
|
+
done
|
|
232
|
+
done <<< "$events"
|