@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.
@@ -123,5 +123,5 @@
123
123
  }
124
124
  },
125
125
  "name": "wr-architect",
126
- "version": "0.22.0"
126
+ "version": "0.22.1"
127
127
  }
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "wr-architect",
3
- "version": "0.22.0",
3
+ "version": "0.22.1",
4
4
  "description": "Architecture decision enforcement for AI coding agents",
5
5
  "author": {
6
6
  "name": "Windy Road Technology",
@@ -13,7 +13,9 @@ tool_name() {
13
13
 
14
14
  run_gate() {
15
15
  local output status
16
- output="$(printf '%s' "$INPUT" | "$SCRIPT_DIR/$1")"
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
- output="$(printf '%s' "$INPUT" | "$SCRIPT_DIR/$1")"
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|Skill)
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"
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@windyroad/architect",
3
- "version": "0.22.0-preview.1164",
3
+ "version": "0.22.1-preview.1173",
4
4
  "description": "Architecture decision enforcement for AI coding agents",
5
5
  "bin": {
6
6
  "windyroad-architect": "./bin/install.mjs"