oh-my-customcode 1.1.58 → 1.1.60
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/dist/cli/index.js +1 -1
- package/dist/index.js +1 -1
- package/package.json +1 -1
- package/templates/.claude/agents/mgr-gitnerd.md +21 -0
- package/templates/.claude/hooks/scripts/r007-r008-drift-advisor.sh +27 -2
- package/templates/.claude/hooks/scripts/session-autofix.sh +58 -10
- package/templates/.claude/hooks/scripts/stuck-detector.sh +441 -38
- package/templates/.claude/rules/MUST-agent-identification.md +3 -0
- package/templates/.claude/rules/MUST-orchestrator-coordination.md +27 -1
- package/templates/.claude/rules/MUST-permissions.md +7 -0
- package/templates/.claude/skills/fsd/SKILL.md +24 -0
- package/templates/.claude/skills/pipeline/workflows/auto-dev.yaml +46 -0
- package/templates/manifest.json +2 -2
- package/templates/workflows/auto-dev.yaml +46 -0
package/dist/cli/index.js
CHANGED
package/dist/index.js
CHANGED
package/package.json
CHANGED
|
@@ -50,6 +50,27 @@ Types: feat, fix, docs, style, refactor, test, chore
|
|
|
50
50
|
- ALWAYS check `git reflog` before declaring work lost — most destructive ops are recoverable for 30 days
|
|
51
51
|
- Reference: R001 Destructive Git Commands section, #1146 (v0.136.0 working tree loss incident)
|
|
52
52
|
|
|
53
|
+
## Commit Timeout Budget (#1645)
|
|
54
|
+
|
|
55
|
+
`.husky/pre-commit` 는 **메인 워크트리**에서 typecheck + `bun run lint` + `bun test --coverage`
|
|
56
|
+
전체 스위트(실측 약 **165초**) + 커버리지 임계값 + CLAUDE.md 카운트 검증까지 순차 실행한다.
|
|
57
|
+
Bash 도구 기본 타임아웃은 **120000ms(2분)** 이라 `git commit` 이 훅 실행 도중
|
|
58
|
+
**exit 143(SIGTERM)** 으로 끊긴다.
|
|
59
|
+
|
|
60
|
+
| 상황 | Bash `timeout` |
|
|
61
|
+
|---|---|
|
|
62
|
+
| 메인 워크트리에서 `git commit` | **≥ 400000** (약 6.7분) |
|
|
63
|
+
| git worktree 에서 `git commit` | 기본값으로 충분 (`.husky/pre-commit` **7-12행**이 `[ -f .git ]` 로 분기해 `bun run typecheck` 만 실행하고 `exit 0` — 전체 스위트는 CI 담당) |
|
|
64
|
+
| `git push` / `gh pr` 등 훅 없는 명령 | 기본값 |
|
|
65
|
+
|
|
66
|
+
**금지**: 타임아웃 회피 목적의 `--no-verify` 사용. 품질 게이트 우회는 상시 금지이며
|
|
67
|
+
오케스트레이터의 사전 승인이 있을 때만 예외다(R010 「품질 게이트 우회 금지」).
|
|
68
|
+
훅이 차단하면 우회하지 말고 **차단 사실과 원인을 보고하고 대기**한다.
|
|
69
|
+
|
|
70
|
+
**타임아웃으로 끊긴 경우**: exit 143 은 "커밋 실패"의 증거가 아니다 — 훅이 통과한 뒤
|
|
71
|
+
커밋이 성사됐을 수도 있다. 재시도 전 `git log -1 --format=%H%n%s` 로 **실제 HEAD 를 실측**하라
|
|
72
|
+
(R020 「Failure/Interrupt Report ≠ Actual Failure」).
|
|
73
|
+
|
|
53
74
|
## Push Rules (R016)
|
|
54
75
|
|
|
55
76
|
All pushes require prior mgr-sauron:watch verification. If sauron was not run, REFUSE the push.
|
|
@@ -304,8 +304,33 @@ if [ -n "$turn_uuid" ] && [ -f "$MARKER_FILE" ]; then
|
|
|
304
304
|
fi
|
|
305
305
|
fi
|
|
306
306
|
|
|
307
|
-
|
|
308
|
-
|
|
307
|
+
# ── 위반 문구 조립 (#1643 — 판정 로직 불변, 레이블 의미를 문구 자체가 드러내도록 정정) ──
|
|
308
|
+
# 이전 문구 "R007 헤더=%s, R008 접두사=%s"의 %s는 위반 "건수"(0=컴플라이언트)였는데, 레이블
|
|
309
|
+
# "헤더"/"접두사"가 "개수"로 오독되어 "R007 헤더=0"이 "헤더가 없다"로 잘못 읽혔다(#1643 진단
|
|
310
|
+
# 확정 — 실제로는 헤더가 정상 존재하는 컴플라이언트 상태였다). 값이 위반 건수라는 것을 문구
|
|
311
|
+
# 자체가 드러내도록 "누락 N건"으로 표현하고, 위반이 없는 차원은 문구에서 아예 제외한다 — 0건인
|
|
312
|
+
# 차원을 언급하면 그 차원도 위반처럼 오독될 수 있기 때문이다.
|
|
313
|
+
violation_desc=""
|
|
314
|
+
instruction=""
|
|
315
|
+
if [ "$r007_violations" -gt 0 ]; then
|
|
316
|
+
violation_desc="R007 에이전트 식별 헤더 누락 ${r007_violations}건"
|
|
317
|
+
instruction="이번 응답은 ┌─ Agent: 헤더로 시작하십시오."
|
|
318
|
+
fi
|
|
319
|
+
if [ "$r008_violations" -gt 0 ]; then
|
|
320
|
+
if [ -n "$violation_desc" ]; then
|
|
321
|
+
violation_desc="${violation_desc}, R008 도구 식별 접두사 누락 ${r008_violations}건"
|
|
322
|
+
instruction="이번 응답은 ┌─ Agent: 헤더로 시작하고, 모든 도구 호출에 [agent][model] → Tool: 접두사를 포함하십시오."
|
|
323
|
+
else
|
|
324
|
+
violation_desc="R008 도구 식별 접두사 누락 ${r008_violations}건"
|
|
325
|
+
instruction="모든 도구 호출에 [agent][model] → Tool: 접두사를 포함하십시오."
|
|
326
|
+
fi
|
|
327
|
+
fi
|
|
328
|
+
|
|
329
|
+
if [ -n "$violation_desc" ]; then
|
|
330
|
+
advisory_text="[R007/R008 Advisory] 직전 응답에서 ${violation_desc} 감지. ${instruction}"
|
|
331
|
+
else
|
|
332
|
+
advisory_text="[R007/R008 Advisory]"
|
|
333
|
+
fi
|
|
309
334
|
|
|
310
335
|
if [ "$r008_reverse" -gt 0 ]; then
|
|
311
336
|
advisory_text="${advisory_text} [R009 Self-Check #6] 직전 턴은 도구 호출 ${r008_reverse}건을 예고하고도 tool_use 블록 없이 종료했습니다 — 예고한 호출을 지금 실행하거나, 실행하지 않기로 한 사유를 명시하십시오."
|
|
@@ -96,18 +96,66 @@ if [ -d "wiki" ]; then
|
|
|
96
96
|
fi
|
|
97
97
|
|
|
98
98
|
# ─── Check 6: Broken skill references (lightweight) ───
|
|
99
|
+
# Parses BOTH frontmatter forms (#1640):
|
|
100
|
+
# inline : skills: [a, b]
|
|
101
|
+
# block : skills:
|
|
102
|
+
# - a
|
|
103
|
+
# - b
|
|
104
|
+
# Emits one skill name per line. Scans the YAML frontmatter only (stops at the
|
|
105
|
+
# closing "---"), so a "skills:" line in the agent body is never parsed.
|
|
106
|
+
parse_agent_skills() {
|
|
107
|
+
awk '
|
|
108
|
+
BEGIN {
|
|
109
|
+
SQ = sprintf("%c", 39); DQ = sprintf("%c", 34)
|
|
110
|
+
infm = 0; inskills = 0
|
|
111
|
+
}
|
|
112
|
+
function clean(s) {
|
|
113
|
+
sub(/[[:space:]]*#.*$/, "", s)
|
|
114
|
+
gsub(SQ, "", s); gsub(DQ, "", s)
|
|
115
|
+
gsub(/[[:space:]]/, "", s)
|
|
116
|
+
return s
|
|
117
|
+
}
|
|
118
|
+
NR == 1 { if ($0 ~ /^---[[:space:]]*$/) infm = 1; next }
|
|
119
|
+
infm == 0 { exit }
|
|
120
|
+
/^---[[:space:]]*$/ { exit }
|
|
121
|
+
inskills == 1 {
|
|
122
|
+
if ($0 ~ /^[[:space:]]+-[[:space:]]*/) {
|
|
123
|
+
item = $0
|
|
124
|
+
sub(/^[[:space:]]+-[[:space:]]*/, "", item)
|
|
125
|
+
item = clean(item)
|
|
126
|
+
if (item != "") print item
|
|
127
|
+
next
|
|
128
|
+
}
|
|
129
|
+
if ($0 ~ /^[[:space:]]*$/) next
|
|
130
|
+
inskills = 0
|
|
131
|
+
}
|
|
132
|
+
/^skills:/ {
|
|
133
|
+
rest = $0
|
|
134
|
+
sub(/^skills:[[:space:]]*/, "", rest)
|
|
135
|
+
sub(/[[:space:]]*#.*$/, "", rest)
|
|
136
|
+
if (rest ~ /^\[/) {
|
|
137
|
+
sub(/^\[/, "", rest); sub(/\][[:space:]]*$/, "", rest)
|
|
138
|
+
n = split(rest, arr, ",")
|
|
139
|
+
for (i = 1; i <= n; i++) {
|
|
140
|
+
s = clean(arr[i])
|
|
141
|
+
if (s != "") print s
|
|
142
|
+
}
|
|
143
|
+
} else if (rest == "") {
|
|
144
|
+
inskills = 1
|
|
145
|
+
}
|
|
146
|
+
next
|
|
147
|
+
}
|
|
148
|
+
' "$1"
|
|
149
|
+
}
|
|
150
|
+
|
|
99
151
|
broken_refs=0
|
|
100
152
|
for agent in .claude/agents/*.md; do
|
|
101
|
-
|
|
102
|
-
|
|
103
|
-
|
|
104
|
-
|
|
105
|
-
|
|
106
|
-
|
|
107
|
-
broken_refs=$((broken_refs + 1))
|
|
108
|
-
fi
|
|
109
|
-
done
|
|
110
|
-
fi
|
|
153
|
+
[ -f "$agent" ] || continue
|
|
154
|
+
while IFS= read -r skill; do
|
|
155
|
+
if [ -n "$skill" ] && [ ! -f ".claude/skills/${skill}/SKILL.md" ]; then
|
|
156
|
+
broken_refs=$((broken_refs + 1))
|
|
157
|
+
fi
|
|
158
|
+
done <<< "$(parse_agent_skills "$agent")"
|
|
111
159
|
done
|
|
112
160
|
if [ "$broken_refs" -gt 0 ]; then
|
|
113
161
|
add_issue "broken-refs:${broken_refs} broken skill reference(s) in agent frontmatter"
|
|
@@ -38,20 +38,96 @@ is_readonly_single_command() {
|
|
|
38
38
|
return 0
|
|
39
39
|
fi
|
|
40
40
|
|
|
41
|
+
# A NEWLINE is a statement separator, so this function — which classifies a
|
|
42
|
+
# SINGLE statement — must never see one: "read -ra" below stops at the first
|
|
43
|
+
# newline, so "git status\nrm -rf build" used to be judged on "git status"
|
|
44
|
+
# alone and came back read-only (#1647 M-5). Callers split on newlines
|
|
45
|
+
# (Step 6); anything still multi-line here is ambiguous => write.
|
|
46
|
+
case "$cmd" in
|
|
47
|
+
*$'\n'*)
|
|
48
|
+
echo "false"
|
|
49
|
+
return 0
|
|
50
|
+
;;
|
|
51
|
+
esac
|
|
52
|
+
|
|
41
53
|
local parts=()
|
|
42
54
|
read -ra parts <<< "$cmd"
|
|
43
55
|
local w1="${parts[0]:-}"
|
|
44
56
|
local w2="${parts[1]:-}"
|
|
45
57
|
local w3="${parts[2]:-}"
|
|
46
58
|
|
|
59
|
+
# Variable assignment prefix: "VAR=value" or "VAR=value cmd args".
|
|
60
|
+
# A bare assignment has no side effect outside the shell; when followed by a
|
|
61
|
+
# command, classify that command instead. Command substitutions inside the
|
|
62
|
+
# RHS were already validated and replaced by is_readonly_bash_command (#1641).
|
|
63
|
+
#
|
|
64
|
+
# EXCEPTION (#1647 L-1): a few variables change WHICH program runs, or how it
|
|
65
|
+
# is loaded, so the trailing command's NAME no longer describes what the
|
|
66
|
+
# command does — "LD_PRELOAD=./evil.so ls" is not "ls", and "PATH=/tmp ls"
|
|
67
|
+
# runs an arbitrary /tmp/ls. Assignments to those names are writes whether or
|
|
68
|
+
# not a command follows them; an ordinary "FOO=1 ls" still defers to "ls".
|
|
69
|
+
#
|
|
70
|
+
# The git entry is the GLOB "GIT_*", not the three names GIT_DIR /
|
|
71
|
+
# GIT_WORK_TREE / GIT_INDEX_FILE it replaced: git has many more variables
|
|
72
|
+
# that run an arbitrary program (GIT_EXTERNAL_DIFF, GIT_PAGER, GIT_EDITOR,
|
|
73
|
+
# GIT_SSH, GIT_*_COMMAND, ...) or relocate the repository, and enumerating
|
|
74
|
+
# them one at a time keeps losing that race. GLOBIGNORE (changes which files
|
|
75
|
+
# a glob expands to) and BASH_XTRACEFD (sends trace output to an arbitrary
|
|
76
|
+
# descriptor, i.e. a file) are writes for the same reason.
|
|
77
|
+
case "$w1" in
|
|
78
|
+
[A-Za-z_]*=*)
|
|
79
|
+
case "${w1%%=*}" in
|
|
80
|
+
PATH|LD_*|DYLD_*|BASH_ENV|ENV|IFS|PROMPT_COMMAND|SHELLOPTS|BASHOPTS|GLOBIGNORE|BASH_XTRACEFD|GIT_*)
|
|
81
|
+
echo "false"
|
|
82
|
+
return 0
|
|
83
|
+
;;
|
|
84
|
+
esac
|
|
85
|
+
if [ "${#parts[@]}" -le 1 ]; then
|
|
86
|
+
echo "true"
|
|
87
|
+
return 0
|
|
88
|
+
fi
|
|
89
|
+
local rest_cmd="${cmd#*"$w1"}"
|
|
90
|
+
rest_cmd="$(printf '%s' "$rest_cmd" | sed -e 's/^[[:space:]]*//')"
|
|
91
|
+
is_readonly_single_command "$rest_cmd"
|
|
92
|
+
return 0
|
|
93
|
+
;;
|
|
94
|
+
esac
|
|
95
|
+
|
|
47
96
|
case "$w1" in
|
|
48
|
-
ls|cat|head|tail|grep|rg|wc|jq|md5|md5sum|type|
|
|
97
|
+
ls|cat|head|tail|grep|rg|wc|jq|md5|md5sum|type|which|echo|printf|pwd|date)
|
|
49
98
|
echo "true"
|
|
50
99
|
return 0
|
|
51
100
|
;;
|
|
101
|
+
command)
|
|
102
|
+
# "command" used to sit in the whitelist above, which made
|
|
103
|
+
# "command rm -rf x" read-only (#1647 L-5) — it is a PREFIX around an
|
|
104
|
+
# arbitrary program, not a program. Only the lookup forms ("command -v",
|
|
105
|
+
# "command -V") are read-only. "builtin" and "exec" are deliberately
|
|
106
|
+
# absent from the whitelist for the same reason: they fall through to the
|
|
107
|
+
# "*)" write default below.
|
|
108
|
+
case "$w2" in
|
|
109
|
+
-v|-V)
|
|
110
|
+
echo "true"
|
|
111
|
+
;;
|
|
112
|
+
*)
|
|
113
|
+
echo "false"
|
|
114
|
+
;;
|
|
115
|
+
esac
|
|
116
|
+
return 0
|
|
117
|
+
;;
|
|
52
118
|
find)
|
|
119
|
+
# Writing actions (#1647 L-5): -delete/-exec/-execdir remove or run
|
|
120
|
+
# things, -ok/-okdir are the interactive forms of -exec, and
|
|
121
|
+
# -fprint/-fprint0/-fprintf/-fls all CREATE a file. Each pattern is
|
|
122
|
+
# wrapped in "*" on BOTH sides, so it matches the fragment ANYWHERE in
|
|
123
|
+
# the command, not just as an option prefix: that is how the "dir"/"0"/
|
|
124
|
+
# "f" variants ("-execdir", "-fprint0", "-okdir") are covered by a single
|
|
125
|
+
# entry, and it also means an operand that merely CONTAINS the fragment
|
|
126
|
+
# (e.g. "find . -name '*-exec*'") is classified as a write. That
|
|
127
|
+
# over-match is the safe direction — a write verdict only ever tightens
|
|
128
|
+
# blocking.
|
|
53
129
|
case "$cmd" in
|
|
54
|
-
*-delete*|*-exec*|*-
|
|
130
|
+
*-delete*|*-exec*|*-fprint*|*-fls*|*-ok*)
|
|
55
131
|
echo "false"
|
|
56
132
|
;;
|
|
57
133
|
*)
|
|
@@ -77,7 +153,7 @@ is_readonly_single_command() {
|
|
|
77
153
|
*) ok="false" ;;
|
|
78
154
|
esac
|
|
79
155
|
done
|
|
80
|
-
|
|
156
|
+
printf '%s\n' "$ok"
|
|
81
157
|
;;
|
|
82
158
|
*)
|
|
83
159
|
# fetch and all other subcommands (checkout/merge/rebase/push/...) => write
|
|
@@ -116,30 +192,261 @@ is_readonly_single_command() {
|
|
|
116
192
|
esac
|
|
117
193
|
}
|
|
118
194
|
|
|
195
|
+
# Remove heredoc BODIES ("<<DELIM", "<<-'DELIM'") from a command, keeping the
|
|
196
|
+
# header line. A heredoc body is DATA, not statements: without this, the
|
|
197
|
+
# newline splitting in Step 6 (#1647 M-5) would classify every data line as a
|
|
198
|
+
# command and turn "cat <<'EOF' ... EOF" — read-only before #1647 — into a
|
|
199
|
+
# write. Herestrings ("<<<") are single-line and are NOT heredocs. An
|
|
200
|
+
# UNTERMINATED heredoc is unparseable, so a sentinel word is emitted instead
|
|
201
|
+
# and the caller classifies the command as a write. The same sentinel route is
|
|
202
|
+
# taken when the "<<" turns out to sit INSIDE a quoted string (see below).
|
|
203
|
+
_strip_heredoc_bodies() {
|
|
204
|
+
local text="$1"
|
|
205
|
+
local out="" line tok delim="" in_body=0
|
|
206
|
+
local before_op dq sq
|
|
207
|
+
while IFS= read -r line; do
|
|
208
|
+
if [ "$in_body" -eq 1 ]; then
|
|
209
|
+
# "<<-" allows leading tabs before the terminator; accept leading
|
|
210
|
+
# whitespace for either form.
|
|
211
|
+
tok="$(printf '%s' "$line" | sed -e 's/^[[:space:]]*//' -e 's/[[:space:]]*$//')"
|
|
212
|
+
if [ "$tok" = "$delim" ]; then
|
|
213
|
+
in_body=0
|
|
214
|
+
fi
|
|
215
|
+
continue
|
|
216
|
+
fi
|
|
217
|
+
out="${out}${line}"$'\n'
|
|
218
|
+
# A DELIMITER FORGERY guard: the extraction below has no idea whether the
|
|
219
|
+
# "<<" it finds is shell syntax or literal text inside a quoted string, so
|
|
220
|
+
# `echo "a << EOF"` followed by `rm -rf x` and a line reading `EOF` used to
|
|
221
|
+
# hand the rm to the heredoc BODY — where it was dropped as data — and the
|
|
222
|
+
# command came back read-only. Quote state is not tracked here, so rather
|
|
223
|
+
# than guess, count the quotes preceding the "<<": an ODD number of " or '
|
|
224
|
+
# means the "<<" is quoted, the command is unparseable by this function,
|
|
225
|
+
# and the sentinel makes the CALLER classify the whole multi-line command
|
|
226
|
+
# as a write. "${line%<<*}" strips the shortest matching suffix, i.e. it
|
|
227
|
+
# yields the text before the LAST "<<" — the same occurrence the greedy
|
|
228
|
+
# extraction regex below settles on.
|
|
229
|
+
if [ "$line" != "${line%<<*}" ]; then
|
|
230
|
+
before_op="${line%<<*}"
|
|
231
|
+
dq="$(printf '%s' "$before_op" | tr -cd '"' | wc -c | tr -d '[:space:]')"
|
|
232
|
+
sq="$(printf '%s' "$before_op" | tr -cd "'" | wc -c | tr -d '[:space:]')"
|
|
233
|
+
if [ $((dq % 2)) -eq 1 ] || [ $((sq % 2)) -eq 1 ]; then
|
|
234
|
+
printf '%s' "${out}__QUOTED_HEREDOC_OPENER__"$'\n'
|
|
235
|
+
return 0
|
|
236
|
+
fi
|
|
237
|
+
fi
|
|
238
|
+
delim="$(printf '%s' "$line" \
|
|
239
|
+
| sed -n -E "s/^.*[^<]<<-?[[:space:]]*[\"']?([A-Za-z_][A-Za-z0-9_]*)[\"']?.*\$/\\1/p")"
|
|
240
|
+
if [ -n "$delim" ]; then
|
|
241
|
+
in_body=1
|
|
242
|
+
fi
|
|
243
|
+
done <<< "$text"
|
|
244
|
+
if [ "$in_body" -eq 1 ]; then
|
|
245
|
+
out="${out}__UNTERMINATED_HEREDOC__"$'\n'
|
|
246
|
+
fi
|
|
247
|
+
printf '%s' "$out"
|
|
248
|
+
}
|
|
249
|
+
|
|
250
|
+
# Maximum recursion depth for nested command substitution / loop bodies.
|
|
251
|
+
_RO_MAX_DEPTH=5
|
|
252
|
+
# Maximum command length that is parsed at all (see Step 0 below).
|
|
253
|
+
_RO_MAX_COMMAND_LEN=4000
|
|
254
|
+
|
|
119
255
|
is_readonly_bash_command() {
|
|
120
256
|
local cmd="$1"
|
|
257
|
+
local depth="${2:-0}"
|
|
258
|
+
|
|
259
|
+
if [ "$depth" -gt "$_RO_MAX_DEPTH" ]; then
|
|
260
|
+
echo "false"
|
|
261
|
+
return 0
|
|
262
|
+
fi
|
|
263
|
+
|
|
264
|
+
# --- Step 0: length cap (M-4). The "$(...)" balance scan below walks the
|
|
265
|
+
# remainder of the command ONE CHARACTER AT A TIME and rebuilds "$inner" on
|
|
266
|
+
# every iteration, which is O(n^2) in the substitution's length (measured:
|
|
267
|
+
# 5 KB => 0.9 s, 20 KB => 13.6 s of latency on a PostToolUse hook).
|
|
268
|
+
# "$raw_command" is the only unbounded field left after the cap on
|
|
269
|
+
# target_key, so cap it here. Nothing legitimately read-only is 4 KB long;
|
|
270
|
+
# anything over the cap is classified as a write, i.e. the conservative side
|
|
271
|
+
# (a read-only classification only ever RELAXES blocking).
|
|
272
|
+
if [ "${#cmd}" -gt "$_RO_MAX_COMMAND_LEN" ]; then
|
|
273
|
+
echo "false"
|
|
274
|
+
return 0
|
|
275
|
+
fi
|
|
276
|
+
|
|
121
277
|
cmd="$(printf '%s' "$cmd" | sed -e 's/^[[:space:]]*//' -e 's/[[:space:]]*$//')"
|
|
122
278
|
if [ -z "$cmd" ]; then
|
|
123
279
|
echo "false"
|
|
124
280
|
return 0
|
|
125
281
|
fi
|
|
126
282
|
|
|
127
|
-
#
|
|
128
|
-
# "||" (conditional-OR — intentionally NOT decomposed into segments, since
|
|
129
|
-
# its branch that actually runs depends on the first segment's exit code)
|
|
130
|
-
# => always treated as write.
|
|
283
|
+
# --- Step 1: process substitution "<(...)" stays ambiguous => write. ---
|
|
131
284
|
case "$cmd" in
|
|
132
|
-
*'
|
|
285
|
+
*'<('*)
|
|
133
286
|
echo "false"
|
|
134
287
|
return 0
|
|
135
288
|
;;
|
|
136
289
|
esac
|
|
137
290
|
|
|
138
|
-
#
|
|
139
|
-
#
|
|
140
|
-
#
|
|
141
|
-
#
|
|
142
|
-
|
|
291
|
+
# --- Step 2: command substitution "$(...)" / backticks.
|
|
292
|
+
# Rather than treating them as an automatic write (#1641), extract the inner
|
|
293
|
+
# command, classify it RECURSIVELY, and replace the substitution with the
|
|
294
|
+
# inert placeholder "SUBST". A write inside the substitution (e.g.
|
|
295
|
+
# "$(rm -rf /tmp/x)", "$(git push)") still makes the whole command a write.
|
|
296
|
+
while :; do
|
|
297
|
+
case "$cmd" in
|
|
298
|
+
*'$('*)
|
|
299
|
+
local before="${cmd%%'$('*}"
|
|
300
|
+
local after="${cmd#*'$('}"
|
|
301
|
+
# Balance-aware scan for the matching ")".
|
|
302
|
+
local inner="" rest="" d=1 i ch
|
|
303
|
+
for ((i = 0; i < ${#after}; i++)); do
|
|
304
|
+
ch="${after:$i:1}"
|
|
305
|
+
if [ "$ch" = "(" ]; then
|
|
306
|
+
d=$((d + 1))
|
|
307
|
+
elif [ "$ch" = ")" ]; then
|
|
308
|
+
d=$((d - 1))
|
|
309
|
+
if [ "$d" -eq 0 ]; then
|
|
310
|
+
rest="${after:$((i + 1))}"
|
|
311
|
+
break
|
|
312
|
+
fi
|
|
313
|
+
fi
|
|
314
|
+
inner="${inner}${ch}"
|
|
315
|
+
done
|
|
316
|
+
if [ "$d" -ne 0 ]; then
|
|
317
|
+
# Unbalanced => unparseable => conservative write.
|
|
318
|
+
echo "false"
|
|
319
|
+
return 0
|
|
320
|
+
fi
|
|
321
|
+
if [ "$(is_readonly_bash_command "$inner" $((depth + 1)))" != "true" ]; then
|
|
322
|
+
echo "false"
|
|
323
|
+
return 0
|
|
324
|
+
fi
|
|
325
|
+
cmd="${before}SUBST${rest}"
|
|
326
|
+
;;
|
|
327
|
+
*'`'*)
|
|
328
|
+
# Backticks do not nest; take the text between the first pair.
|
|
329
|
+
local b_before="${cmd%%'`'*}"
|
|
330
|
+
local b_after="${cmd#*'`'}"
|
|
331
|
+
case "$b_after" in
|
|
332
|
+
*'`'*) ;;
|
|
333
|
+
*) echo "false"; return 0 ;;
|
|
334
|
+
esac
|
|
335
|
+
local b_inner="${b_after%%'`'*}"
|
|
336
|
+
local b_rest="${b_after#*'`'}"
|
|
337
|
+
if [ "$(is_readonly_bash_command "$b_inner" $((depth + 1)))" != "true" ]; then
|
|
338
|
+
echo "false"
|
|
339
|
+
return 0
|
|
340
|
+
fi
|
|
341
|
+
cmd="${b_before}SUBST${b_rest}"
|
|
342
|
+
;;
|
|
343
|
+
*)
|
|
344
|
+
break
|
|
345
|
+
;;
|
|
346
|
+
esac
|
|
347
|
+
done
|
|
348
|
+
|
|
349
|
+
# --- Step 3: strip harmless redirections BEFORE the ">" write check.
|
|
350
|
+
# "2>&1", ">/dev/null", "2>/dev/null", "1>/dev/null", "&>/dev/null" produce no
|
|
351
|
+
# observable side effect. Any OTHER ">" (a real file write) remains a write.
|
|
352
|
+
# ">&N" is stripped ONLY for N in {1,2} (stdout/stderr). An arbitrary
|
|
353
|
+
# descriptor writes wherever that fd was opened, so "cat x >&3" stays a write
|
|
354
|
+
# (#1647 L-2). The trailing "[^0-9]" / "$" guard stops ">&10" from being read
|
|
355
|
+
# as ">&1" plus a stray "0"; the two expressions differ only in whether the
|
|
356
|
+
# redirection is followed by another character or ends the command.
|
|
357
|
+
local stripped
|
|
358
|
+
stripped="$(printf '%s' "$cmd" \
|
|
359
|
+
| sed -E 's/[0-9]?>&[12]([^0-9])/ \1/g; s/[0-9]?>&[12]$/ /; s#[0-9]?&?>[[:space:]]*/dev/null# #g')"
|
|
360
|
+
case "$stripped" in
|
|
361
|
+
*'>'*)
|
|
362
|
+
echo "false"
|
|
363
|
+
return 0
|
|
364
|
+
;;
|
|
365
|
+
esac
|
|
366
|
+
cmd="$stripped"
|
|
367
|
+
|
|
368
|
+
# --- Step 4: "||" is still NOT decomposed (which branch runs depends on the
|
|
369
|
+
# first segment's exit code) => write. Preserved from #1629.
|
|
370
|
+
case "$cmd" in
|
|
371
|
+
*'||'*)
|
|
372
|
+
echo "false"
|
|
373
|
+
return 0
|
|
374
|
+
;;
|
|
375
|
+
esac
|
|
376
|
+
|
|
377
|
+
# --- Step 5: for / while loops (#1641).
|
|
378
|
+
# Normalize ";do" / ";done" spacing, then classify the header and the
|
|
379
|
+
# "do ... done" body separately. Both must be read-only.
|
|
380
|
+
case "$cmd" in
|
|
381
|
+
for\ *|while\ *)
|
|
382
|
+
local norm
|
|
383
|
+
norm="$(printf '%s' "$cmd" | sed -E 's/;[[:space:]]*do([[:space:]]|$)/ ; do /g; s/;[[:space:]]*done/ ; done/g')"
|
|
384
|
+
case "$norm" in
|
|
385
|
+
*' do '*'done'*) ;;
|
|
386
|
+
*) echo "false"; return 0 ;;
|
|
387
|
+
esac
|
|
388
|
+
local head="${norm%% do *}"
|
|
389
|
+
local tail_part="${norm#* do }"
|
|
390
|
+
local body="${tail_part%done*}"
|
|
391
|
+
head="$(printf '%s' "$head" | sed -e 's/[[:space:]]*;[[:space:]]*$//')"
|
|
392
|
+
body="$(printf '%s' "$body" | sed -e 's/[[:space:]]*;[[:space:]]*$//')"
|
|
393
|
+
|
|
394
|
+
case "$head" in
|
|
395
|
+
# "for VAR in <words>" — pure iteration, no side effect. The <words>
|
|
396
|
+
# already had any substitution validated in Step 2.
|
|
397
|
+
for\ [A-Za-z_]*\ in\ *)
|
|
398
|
+
;;
|
|
399
|
+
while\ *)
|
|
400
|
+
# The while CONDITION is a real command — classify it.
|
|
401
|
+
local wcond="${head#while }"
|
|
402
|
+
if [ "$(is_readonly_bash_command "$wcond" $((depth + 1)))" != "true" ]; then
|
|
403
|
+
echo "false"
|
|
404
|
+
return 0
|
|
405
|
+
fi
|
|
406
|
+
;;
|
|
407
|
+
*)
|
|
408
|
+
echo "false"
|
|
409
|
+
return 0
|
|
410
|
+
;;
|
|
411
|
+
esac
|
|
412
|
+
|
|
413
|
+
is_readonly_bash_command "$body" $((depth + 1))
|
|
414
|
+
return 0
|
|
415
|
+
;;
|
|
416
|
+
esac
|
|
417
|
+
|
|
418
|
+
# --- Step 6: compound command — split on "&&", ";", a single "|", or a
|
|
419
|
+
# NEWLINE and require EVERY segment to be read-only (#1629, #1647 M-5).
|
|
420
|
+
# A newline separates statements exactly like ";"; before #1647 it was not a
|
|
421
|
+
# separator at all, so a multi-line command fell through to
|
|
422
|
+
# is_readonly_single_command, whose "read -ra" only ever saw the FIRST line
|
|
423
|
+
# ("git status\nrm -rf build" => read-only).
|
|
424
|
+
#
|
|
425
|
+
# Order matters: the ">" (Step 3) and "||" (Step 4) checks above still ran
|
|
426
|
+
# against the FULL text including any heredoc body, so those verdicts are
|
|
427
|
+
# unchanged. Heredoc bodies are dropped only here, right before the split.
|
|
428
|
+
# CR / CRLF are newlines too.
|
|
429
|
+
cmd="${cmd//$'\r\n'/$'\n'}"
|
|
430
|
+
cmd="${cmd//$'\r'/$'\n'}"
|
|
431
|
+
if [[ "$cmd" == *$'\n'* ]]; then
|
|
432
|
+
# A heredoc body needs a following line, so only a MULTI-LINE command can
|
|
433
|
+
# have one — checking "<<" on a single-line command would misread a literal
|
|
434
|
+
# "<<" (e.g. `echo "a << b"`) as an opener.
|
|
435
|
+
case "$cmd" in
|
|
436
|
+
*'<<'*)
|
|
437
|
+
cmd="$(_strip_heredoc_bodies "$cmd")"
|
|
438
|
+
;;
|
|
439
|
+
esac
|
|
440
|
+
# A BLANK line is not a statement, so drop blank lines before splitting;
|
|
441
|
+
# otherwise they would look like an empty — i.e. ambiguous — segment. A
|
|
442
|
+
# genuinely empty segment from a trailing ";" or "&&" still means write.
|
|
443
|
+
cmd="$(printf '%s' "$cmd" | sed -e '/^[[:space:]]*$/d')"
|
|
444
|
+
if [ -z "$cmd" ]; then
|
|
445
|
+
echo "false"
|
|
446
|
+
return 0
|
|
447
|
+
fi
|
|
448
|
+
fi
|
|
449
|
+
if [[ "$cmd" == *'&&'* || "$cmd" == *';'* || "$cmd" == *'|'* || "$cmd" == *$'\n'* ]]; then
|
|
143
450
|
local normalized="$cmd"
|
|
144
451
|
normalized="${normalized//&&/$'\n'}"
|
|
145
452
|
normalized="${normalized//;/$'\n'}"
|
|
@@ -167,15 +474,81 @@ input=$(cat)
|
|
|
167
474
|
|
|
168
475
|
# Extract tool info
|
|
169
476
|
tool_name=$(printf '%s' "$input" | jq -r '.tool_name // "unknown"')
|
|
170
|
-
#
|
|
171
|
-
#
|
|
172
|
-
#
|
|
173
|
-
#
|
|
174
|
-
|
|
477
|
+
# Bash commands are NOT files. Feeding tool_input.command into file_path made
|
|
478
|
+
# the hard-block message report a command fragment as a "file" and let Check 1
|
|
479
|
+
# ("Same file ... edited") fire on Bash at all (#1641). Keep the command text in
|
|
480
|
+
# a SEPARATE field (target_key) so tool+target repetition detection (Check 3 /
|
|
481
|
+
# Signal 3) still works, while the file-oriented checks see nothing for Bash.
|
|
482
|
+
#
|
|
483
|
+
# 1000 (was 300, originally 120): a short cutoff let two genuinely different
|
|
484
|
+
# long Bash commands collide on their shared prefix, causing a false-positive
|
|
485
|
+
# same-path/same-tool+target hard-block (#1629). Truncation alone can never
|
|
486
|
+
# remove that collision class, so truncate_key() ALSO appends the ORIGINAL
|
|
487
|
+
# length: two values that share the capped prefix but differ in total length
|
|
488
|
+
# no longer produce the same key (M-3).
|
|
489
|
+
TARGET_KEY_CAP=1000
|
|
490
|
+
|
|
491
|
+
truncate_key() {
|
|
492
|
+
local v="$1"
|
|
493
|
+
if [ "${#v}" -gt "$TARGET_KEY_CAP" ]; then
|
|
494
|
+
printf '%s' "${v:0:$TARGET_KEY_CAP}#len=${#v}"
|
|
495
|
+
else
|
|
496
|
+
printf '%s' "$v"
|
|
497
|
+
fi
|
|
498
|
+
}
|
|
499
|
+
|
|
500
|
+
if [ "$tool_name" = "Bash" ]; then
|
|
501
|
+
file_path=""
|
|
502
|
+
# ".tool_input.file_path" is a defensive fallback only: the real Bash tool has
|
|
503
|
+
# no file_path parameter, but a caller (or a test fixture) may still supply one
|
|
504
|
+
# as the target identifier. The command text wins whenever it is present.
|
|
505
|
+
target_key=$(truncate_key "$(printf '%s' "$input" | jq -r '.tool_input.command // .tool_input.file_path // ""')")
|
|
506
|
+
else
|
|
507
|
+
file_path_full=$(printf '%s' "$input" | jq -r '.tool_input.file_path // ""')
|
|
508
|
+
# file_path is display/guard only (basename in the messages); target_key is
|
|
509
|
+
# the value matched against history, so only it carries the length tag.
|
|
510
|
+
# "|| true": same broken-pipe class as output_preview below (#1647 M-6) —
|
|
511
|
+
# an over-cap file_path would make head close the pipe and take the hook
|
|
512
|
+
# down through pipefail. The captured bytes are unaffected.
|
|
513
|
+
file_path=$(printf '%s' "$file_path_full" | head -c "$TARGET_KEY_CAP" || true)
|
|
514
|
+
target_key=$(truncate_key "$file_path_full")
|
|
515
|
+
fi
|
|
175
516
|
is_error=$(printf '%s' "$input" | jq -r '.tool_output.is_error // false')
|
|
176
|
-
|
|
517
|
+
# "head -c 200" closes the pipe after 200 bytes. Once tool_output.output is
|
|
518
|
+
# larger than the pipe buffer (~64 KB), jq keeps writing, dies of SIGPIPE
|
|
519
|
+
# (exit 141), and "set -o pipefail" + "set -e" aborted the ENTIRE hook before
|
|
520
|
+
# the history entry was ever appended — a 300 KB tool output silently erased
|
|
521
|
+
# the record it was supposed to create (#1647 M-6). Two independent guards:
|
|
522
|
+
# (a) slice inside jq so nothing large is piped at all, and (b) "|| true" so a
|
|
523
|
+
# broken pipe can never again take the hook down. The byte cap is still
|
|
524
|
+
# enforced by head (jq slices by codepoint, head by byte, and 200 codepoints
|
|
525
|
+
# are always >= 200 bytes, so the resulting bytes are unchanged).
|
|
526
|
+
output_preview=$(printf '%s' "$input" \
|
|
527
|
+
| jq -r '(.tool_output.output // "" | tostring)[0:200]' \
|
|
528
|
+
| head -c 200 || true)
|
|
177
529
|
raw_command=$(printf '%s' "$input" | jq -r '.tool_input.command // ""')
|
|
178
530
|
|
|
531
|
+
# Distinguish "the same file edited 3 times with DIFFERENT content" (a normal
|
|
532
|
+
# incremental-edit workflow — the #1641 false positive) from "the same edit
|
|
533
|
+
# repeated 3 times" (a genuine stuck loop). Only the latter should hard-block.
|
|
534
|
+
# Alphanumerics-only + length cap: deterministic, platform-independent (no
|
|
535
|
+
# md5sum, absent on macOS) and safe to embed in a grep pattern.
|
|
536
|
+
edit_hash=""
|
|
537
|
+
if [ "$tool_name" = "Edit" ] || [ "$tool_name" = "Write" ]; then
|
|
538
|
+
edit_hash=$(printf '%s' "$input" \
|
|
539
|
+
| jq -r '(.tool_input.old_string // .tool_input.content // .tool_input.new_string // "")
|
|
540
|
+
| gsub("[^A-Za-z0-9]"; "") | .[0:120]')
|
|
541
|
+
fi
|
|
542
|
+
|
|
543
|
+
# History entries are written by jq, so their "path" values are JSON-ENCODED.
|
|
544
|
+
# Matching them requires the SAME encoding, not the raw text: a raw value
|
|
545
|
+
# containing a quote (common in Bash commands) can never match the encoded
|
|
546
|
+
# form. The previous BRE escaping was doubly broken — in POSIX BRE "( ) + ? { |"
|
|
547
|
+
# are literals, so escaping them turned them INTO operators and silently
|
|
548
|
+
# stopped matching. Encode once here and match with grep -F (fixed string).
|
|
549
|
+
target_key_json=$(jq -n --arg v "$target_key" '$v')
|
|
550
|
+
path_match="\"path\":${target_key_json}"
|
|
551
|
+
|
|
179
552
|
is_readonly="false"
|
|
180
553
|
if [ "$tool_name" = "Bash" ]; then
|
|
181
554
|
is_readonly=$(is_readonly_bash_command "$raw_command")
|
|
@@ -190,18 +563,22 @@ timestamp=$(date -u +%Y-%m-%dT%H:%M:%SZ)
|
|
|
190
563
|
# Generate error hash for deduplication (first 50 chars of error)
|
|
191
564
|
error_hash=""
|
|
192
565
|
if [ "$is_error" = "true" ]; then
|
|
193
|
-
error_hash=$(
|
|
566
|
+
error_hash=$(printf '%s' "$output_preview" | head -c 50 | md5sum 2>/dev/null | cut -d' ' -f1 || printf '%s\n' "unknown")
|
|
194
567
|
fi
|
|
195
568
|
|
|
569
|
+
# "path" holds $target_key — a file path for file tools, the command text for
|
|
570
|
+
# Bash (#1641). Keeping Bash commands in this field preserves Check 3 / Signal 3
|
|
571
|
+
# repetition detection while file_path stays empty for Bash.
|
|
196
572
|
entry=$(jq -cn \
|
|
197
573
|
--arg ts "$timestamp" \
|
|
198
574
|
--arg tool "$tool_name" \
|
|
199
|
-
--arg path "$
|
|
575
|
+
--arg path "$target_key" \
|
|
200
576
|
--arg err "$is_error" \
|
|
201
577
|
--arg hash "$error_hash" \
|
|
578
|
+
--arg ehash "$edit_hash" \
|
|
202
579
|
--arg preview "$output_preview" \
|
|
203
580
|
--arg readonly "$is_readonly" \
|
|
204
|
-
'{timestamp: $ts, tool: $tool, path: $path, is_error: $err, error_hash: $hash, preview: $preview, readonly: $readonly}')
|
|
581
|
+
'{timestamp: $ts, tool: $tool, path: $path, is_error: $err, error_hash: $hash, edit_hash: $ehash, preview: $preview, readonly: $readonly}')
|
|
205
582
|
|
|
206
583
|
printf '%s\n' "$entry" >> "$HISTORY_FILE"
|
|
207
584
|
|
|
@@ -237,7 +614,7 @@ recovery=""
|
|
|
237
614
|
|
|
238
615
|
# Signal 1: Repeated error (same error_hash 3+ times in last 10 entries)
|
|
239
616
|
if [ "$is_error" = "true" ] && [ -n "$error_hash" ]; then
|
|
240
|
-
error_repeat=$(tail -10 "$HISTORY_FILE" | grep -c "\"error_hash\":\"${error_hash}\"" 2>/dev/null ||
|
|
617
|
+
error_repeat=$(tail -10 "$HISTORY_FILE" | grep -c "\"error_hash\":\"${error_hash}\"" 2>/dev/null || printf '%s\n' "0")
|
|
241
618
|
if [ "$error_repeat" -ge 3 ]; then
|
|
242
619
|
stuck_detected=true
|
|
243
620
|
signal_type="Repeated error"
|
|
@@ -251,12 +628,12 @@ fi
|
|
|
251
628
|
# Signal 2: Edit loop (same file edited 3+ times in last 8 entries)
|
|
252
629
|
if [ "$stuck_detected" = false ] && { [ "$tool_name" = "Edit" ] || [ "$tool_name" = "Write" ]; }; then
|
|
253
630
|
if [ -n "$file_path" ]; then
|
|
254
|
-
|
|
255
|
-
|
|
631
|
+
edit_repeat=$(tail -8 "$HISTORY_FILE" | grep -cF -e "$path_match" 2>/dev/null || true)
|
|
632
|
+
[ -n "$edit_repeat" ] || edit_repeat=0
|
|
256
633
|
if [ "$edit_repeat" -ge 3 ]; then
|
|
257
634
|
stuck_detected=true
|
|
258
635
|
signal_type="Edit loop"
|
|
259
|
-
pattern_desc="$(basename "$file_path") edited ${edit_repeat} times in last 8 calls"
|
|
636
|
+
pattern_desc="$(basename -- "$file_path") edited ${edit_repeat} times in last 8 calls"
|
|
260
637
|
occurrence_count=$edit_repeat
|
|
261
638
|
threshold=3
|
|
262
639
|
recovery="Try a different file or approach instead of re-editing"
|
|
@@ -271,7 +648,7 @@ fi
|
|
|
271
648
|
# calls (git status / gh view / ls / ...) doesn't inflate the "same tool
|
|
272
649
|
# called N times" advisory.
|
|
273
650
|
if [ "$stuck_detected" = false ] && [ "$is_readonly" != "true" ]; then
|
|
274
|
-
tool_repeat=$(tail -8 "$HISTORY_FILE" | grep -v "\"readonly\":\"true\"" | grep -c "\"tool\":\"${tool_name}\"" 2>/dev/null ||
|
|
651
|
+
tool_repeat=$(tail -8 "$HISTORY_FILE" | grep -v "\"readonly\":\"true\"" | grep -c "\"tool\":\"${tool_name}\"" 2>/dev/null || printf '%s\n' "0")
|
|
275
652
|
if [ "$tool_repeat" -ge 5 ]; then
|
|
276
653
|
stuck_detected=true
|
|
277
654
|
signal_type="Tool loop"
|
|
@@ -302,38 +679,64 @@ hard_block_reason=""
|
|
|
302
679
|
|
|
303
680
|
if [ -f "$HISTORY_FILE" ]; then
|
|
304
681
|
last_n=$(tail -"$HARD_BLOCK_THRESHOLD" "$HISTORY_FILE" 2>/dev/null)
|
|
305
|
-
last_n_count=$(
|
|
682
|
+
last_n_count=$(printf '%s\n' "$last_n" | wc -l | tr -d ' ')
|
|
306
683
|
|
|
307
684
|
if [ "$last_n_count" -ge "$HARD_BLOCK_THRESHOLD" ]; then
|
|
308
|
-
# Check 1: Same file edited HARD_BLOCK_THRESHOLD+
|
|
685
|
+
# Check 1: Same file edited with the SAME content HARD_BLOCK_THRESHOLD+
|
|
686
|
+
# times consecutively. Narrowed in #1641: three DIFFERENT edits to one file
|
|
687
|
+
# is a normal incremental workflow (observed repeatedly on wiki/rule/doc
|
|
688
|
+
# files), not a stuck loop — only an IDENTICAL repeated edit blocks. Both
|
|
689
|
+
# the path AND the edit_hash must match.
|
|
309
690
|
# (skip when current call is a read-only Bash command — repeated read-only
|
|
310
691
|
# polling of the same target is not a stuck-loop signal; see #1625)
|
|
692
|
+
# Bash never reaches here — file_path is empty for Bash (#1641), and
|
|
693
|
+
# edit_hash is empty too; repeated Bash commands are handled by Check 3
|
|
694
|
+
# via target_key instead.
|
|
311
695
|
if [ "$is_readonly" != "true" ] && [ -n "$file_path" ]; then
|
|
312
|
-
|
|
313
|
-
|
|
696
|
+
consecutive_file=$(printf '%s\n' "$last_n" \
|
|
697
|
+
| grep -F -e "$path_match" \
|
|
698
|
+
| grep -cF -e "\"edit_hash\":\"${edit_hash}\"" 2>/dev/null || true)
|
|
699
|
+
[ -n "$consecutive_file" ] || consecutive_file=0
|
|
314
700
|
if [ "$consecutive_file" -ge "$HARD_BLOCK_THRESHOLD" ]; then
|
|
315
701
|
hard_block=true
|
|
316
|
-
hard_block_reason="Same file ($(basename "$file_path"))
|
|
702
|
+
hard_block_reason="Same file ($(basename -- "$file_path")) received the identical edit ${consecutive_file} consecutive times"
|
|
317
703
|
fi
|
|
318
704
|
fi
|
|
319
705
|
|
|
320
706
|
# Check 2: Same error repeated HARD_BLOCK_THRESHOLD+ times consecutively
|
|
321
707
|
if [ "$hard_block" = false ] && [ "$is_error" = "true" ] && [ -n "$error_hash" ]; then
|
|
322
|
-
consecutive_error=$(
|
|
708
|
+
consecutive_error=$(printf '%s\n' "$last_n" | grep -c "\"error_hash\":\"${error_hash}\"" 2>/dev/null || printf '%s\n' "0")
|
|
323
709
|
if [ "$consecutive_error" -ge "$HARD_BLOCK_THRESHOLD" ]; then
|
|
324
710
|
hard_block=true
|
|
325
711
|
hard_block_reason="Same error repeated ${consecutive_error} consecutive times"
|
|
326
712
|
fi
|
|
327
713
|
fi
|
|
328
714
|
|
|
329
|
-
# Check 3: Same tool+target combination HARD_BLOCK_THRESHOLD+ times
|
|
715
|
+
# Check 3: Same tool+target combination HARD_BLOCK_THRESHOLD+ times
|
|
716
|
+
# consecutively. Uses target_key (a file path for file tools, the command
|
|
717
|
+
# text for Bash) so repeated identical Bash commands are still blocked
|
|
718
|
+
# after file_path was emptied for Bash (#1641).
|
|
330
719
|
# (skip when current call is a read-only Bash command — see Check 1 note)
|
|
331
|
-
|
|
332
|
-
|
|
333
|
-
|
|
720
|
+
# The edit_hash filter mirrors Check 1's #1641 narrowing: without it this
|
|
721
|
+
# check re-creates the very false positive Check 1 was narrowed to remove
|
|
722
|
+
# (tool=Edit + same path fires at 3 regardless of content). For Bash the
|
|
723
|
+
# filter is a no-op — every Bash history entry carries edit_hash "".
|
|
724
|
+
if [ "$hard_block" = false ] && [ "$is_readonly" != "true" ] && [ -n "$target_key" ]; then
|
|
725
|
+
consecutive_tool_target=$(printf '%s\n' "$last_n" \
|
|
726
|
+
| grep -F -e "\"tool\":\"${tool_name}\"" \
|
|
727
|
+
| grep -F -e "$path_match" \
|
|
728
|
+
| grep -cF -e "\"edit_hash\":\"${edit_hash}\"" 2>/dev/null || true)
|
|
729
|
+
[ -n "$consecutive_tool_target" ] || consecutive_tool_target=0
|
|
334
730
|
if [ "$consecutive_tool_target" -ge "$HARD_BLOCK_THRESHOLD" ]; then
|
|
335
731
|
hard_block=true
|
|
336
|
-
|
|
732
|
+
# Do NOT call basename() on a Bash target: a command is not a path, and
|
|
733
|
+
# basename() on command text produced the misleading "Same file (pr)"
|
|
734
|
+
# style message reported in #1641.
|
|
735
|
+
if [ "$tool_name" = "Bash" ]; then
|
|
736
|
+
hard_block_reason="Identical Bash command repeated ${consecutive_tool_target} times: $(printf '%s' "$target_key" | head -c 60)"
|
|
737
|
+
else
|
|
738
|
+
hard_block_reason="${tool_name} called on $(basename -- "$target_key") ${consecutive_tool_target} consecutive times"
|
|
739
|
+
fi
|
|
337
740
|
fi
|
|
338
741
|
fi
|
|
339
742
|
fi
|
|
@@ -117,6 +117,9 @@ Reference issue: #1096.
|
|
|
117
117
|
| Anti-pattern | Required |
|
|
118
118
|
|--------------|----------|
|
|
119
119
|
| 도구 결과 수신 후 응답 텍스트 없이 turn 종료 ("(no content)") | 최소 1줄 진행 상태 + R007 헤더 출력 후 종료/계속 |
|
|
120
|
+
| 백그라운드 에이전트 완료 통지·task-notification 등 **system-reminder 통지 수신 후** 도구만 호출하고 텍스트 없이 turn 종료 | 통지 처리 턴에도 헤더 + 상태 1줄 출력 후 도구 호출 |
|
|
121
|
+
|
|
122
|
+
v1.1.58 세션 트랜스크립트 실측 — 응답 22건 중 3건이 텍스트 블록 없이 tool_use만 있었고 전부 통지 수신 직후 턴이었다(#1646).
|
|
120
123
|
|
|
121
124
|
<!-- Reference issues: #1188 item #2, #1198 item #2, #1409. -->
|
|
122
125
|
|
|
@@ -338,6 +338,22 @@ Origin: #1563 찐빠 #3 — `gh issue edit --assignee`가 gh 2.86.0에 없는
|
|
|
338
338
|
|
|
339
339
|
Origin: #1584 #5 (v1.1.45 세션 — 커밋 위임서에 `develop @ 96ef8f85`로 적었으나 실측은 `f6d3f518`; #1572 머지 후 pull 미반영). R017 「메모리 TODO를 위임 전제로 쓸 때」의 **세션 내 축소판** — 스냅샷의 수명이 세션 간이 아니라 **턴 간**이라는 차이만 있다.
|
|
340
340
|
|
|
341
|
+
**사전 실측 리터럴 (Origin: #1646)**: 저장소 상태를 기재한 위임서를 작성하기 **직전에** 아래 3줄을 그대로 실행한다.
|
|
342
|
+
|
|
343
|
+
```
|
|
344
|
+
git rev-parse --abbrev-ref HEAD
|
|
345
|
+
git rev-parse --short HEAD
|
|
346
|
+
git branch --list
|
|
347
|
+
```
|
|
348
|
+
|
|
349
|
+
이 3줄의 **출력 원문**을 위임서 '사전 실측' 절에 그대로 붙여 넣고, 브랜치 존재·SHA를 예측형으로 적지 않는다 — 도구의 부수효과(예: `gh pr merge --delete-branch`가 체크아웃된 브랜치를 base로 전환·pull·삭제하는 부수효과)를 모른 채 이전 관측을 그대로 옮기면 상태가 어긋난다.
|
|
350
|
+
|
|
351
|
+
| Anti-pattern | Required |
|
|
352
|
+
|--------------|----------|
|
|
353
|
+
| `gh pr merge --delete-branch` 등 부수효과가 있는 명령 실행 후, 그 이전에 관측한 브랜치/SHA를 위임서에 그대로 기재 | 위임 직전 위 3줄을 재실행하고 출력 원문을 위임서에 붙여 넣음 |
|
|
354
|
+
|
|
355
|
+
Origin: #1646 (v1.1.58 세션 — 커밋 위임서 `12e74a76` vs 실제 `e101a0b4`, 정리 위임서 '로컬 브랜치 2개 존재' vs 실제 부재; 원인은 `gh pr merge --delete-branch`가 체크아웃된 브랜치를 base로 전환·pull·삭제하는 부수효과였음 — 도구 부수효과를 모른 채 상태를 예측한 사례).
|
|
356
|
+
|
|
341
357
|
#### 참인 전제 ≠ 참인 함의 — 브랜치 전환 위임 (Origin: #1595 #2)
|
|
342
358
|
|
|
343
359
|
uncommitted 변경이 있는 상태의 브랜치 전환을 위임할 때, 위임서에 **전환의 결과를 확정형으로 예측해 적지 않는다**. "대상 브랜치에 그 파일이 없다"는 **사실**에서 "전환해도 안전하다"는 **함의**는 도출되지 않는다 — 파일이 **없기 때문에** checkout이 그 파일을 삭제해야 하고, modified 상태면 거부된다.
|
|
@@ -428,7 +444,17 @@ The Agent tool defaults to `mode: "acceptEdits"`, which overrides agent frontmat
|
|
|
428
444
|
### Self-Check
|
|
429
445
|
|
|
430
446
|
Before spawning any agent:
|
|
431
|
-
1.
|
|
447
|
+
1. **유효 permission mode 확인** — per-call `mode` 파라미터는 v2.1.212+ 에서 무시되고,
|
|
448
|
+
**프로젝트 scope `permissions.defaultMode` 는 v2.1.257+ 에서도 무시된다(#1644)**.
|
|
449
|
+
무인 실행 전 실측할 것: `jq -r '.permissions.defaultMode // "unset"' ~/.claude/settings.json`
|
|
450
|
+
(user scope) — 이 값 또는 `--permission-mode` 실행 플래그만이 유효하다.
|
|
451
|
+
bypassPermissions 가 아니면 프롬프트 발생을 전제로 계획한다.
|
|
452
|
+
하위 호환을 위해 per-call `mode: "bypassPermissions"` 는 계속 포함하되,
|
|
453
|
+
**그 존재를 무인 실행의 증거로 삼지 않는다**(R020 "attempt ≠ outcome").
|
|
454
|
+
실측(2026-09-03, `claude -p --debug-file`): `[WARN] settings defaultMode "bypassPermissions"
|
|
455
|
+
ignored — only policy/user/flag settings may grant bypass mode (projectSettings and
|
|
456
|
+
localSettings are repo-controllable)` — 무시 동작이 직접 실증되었다. 프로젝트 settings에는
|
|
457
|
+
`permissions._comment_defaultMode` 안내 키가 추가되었다(v1.1.59).
|
|
432
458
|
2. Is this a new skill that spawns agents? → Add Permission Mode section
|
|
433
459
|
|
|
434
460
|
### Common Violation
|
|
@@ -133,3 +133,10 @@ Origin: #1582. Cross-ref: R018(Member TaskUpdate Discipline 대체 규약), R020
|
|
|
133
133
|
> Canonical source: R010 (MUST-orchestrator-coordination.md) "Universal bypassPermissions" owns the full requirement, rationale, self-check, and version history. Core rule: always pass `mode: "bypassPermissions"` explicitly on every Agent tool call — the Agent tool's default `mode` (`acceptEdits`) overrides agent frontmatter `permissionMode` and causes prompts during unattended execution. Skills that spawn agents MUST include this in their Agent tool call instructions. See R010 for details.
|
|
134
134
|
|
|
135
135
|
> **v2.1.212+**: CC가 Agent(구 Task) tool의 `mode` 파라미터를 deprecated 처리했습니다(이제 무시) — subagent는 부모 세션의 permission mode를 기본 상속합니다. 위 canonical 요약의 default `mode`(`acceptEdits`)가 frontmatter `permissionMode`를 override한다는 서술 및 항상 `mode: "bypassPermissions"`를 넘기라는 요건은 이 버전부터 stale이며(파라미터가 무시됨), 무인 실행의 실질 게이트는 부모 세션의 permission mode입니다. 요건 재조정은 R010 "Universal bypassPermissions"가 canonical — R002는 이 flag만 유지합니다.
|
|
136
|
+
|
|
137
|
+
> **v2.1.257+**: 프로젝트 scope `permissions.defaultMode` 가 무시됩니다(user/managed scope 또는
|
|
138
|
+
> `--permission-mode` 플래그만 유효). 위 v2.1.212 노트가 "무인 실행의 실질 게이트는 부모 세션의
|
|
139
|
+
> permission mode"라고 정정했는데, **그 부모 세션 모드를 프로젝트 settings로 지정하는 경로가 이
|
|
140
|
+
> 버전에서 끊겼습니다** — 이 파일 상단 「Deny Rule Glob Patterns」의 v2.1.214 노트(allow 규칙만
|
|
141
|
+
> `<cwd>`로 좁아진 비대칭)와 같은 계열의 **project-scope 축소** 흐름입니다. Canonical owner 는
|
|
142
|
+
> R010 "Universal bypassPermissions" — 상세와 실측 절차는 그쪽을 참조합니다. Origin: #1644.
|
|
@@ -69,6 +69,14 @@ All PR merge operations are delegated to **mgr-gitnerd** (R010). After merging,
|
|
|
69
69
|
|
|
70
70
|
Do NOT merge PRs that require user approval for architectural decisions. Surface them with a short summary and wait.
|
|
71
71
|
|
|
72
|
+
### `.claude/hooks/**` 이슈 — 무인 루프 분리 스코프
|
|
73
|
+
|
|
74
|
+
FSD는 사용자가 실시간 응답할 수 없는 무인 루프이므로, scope-selection의 R010 approval-required
|
|
75
|
+
path pre-check(`auto-dev.yaml`)가 `.claude/hooks/**` 대상 이슈를 발견하면 매 이터레이션마다
|
|
76
|
+
즉시 승인을 요청하지 않는다. 대신 그 이슈를 이번 스코프에서 **분리·이월**하고 나머지 적격 이슈로
|
|
77
|
+
계속 진행하며, `/homework` 반복 경계(게이트)에서 이월된 hooks 이슈들을 **1회 묶어** 승인 질문으로
|
|
78
|
+
제시한다. 승인을 받으면 R015 directive persistence로 세션 내 동일 카테고리에 지속 적용한다.
|
|
79
|
+
|
|
72
80
|
### Iteration Flow (per iteration)
|
|
73
81
|
|
|
74
82
|
```
|
|
@@ -169,6 +177,22 @@ If any of those underlying skills evolve, FSD automatically benefits — its onl
|
|
|
169
177
|
|
|
170
178
|
When this skill delegates work via Agent tool calls, ALL Agent tool calls MUST include `mode: "bypassPermissions"` per R010 Universal bypassPermissions.
|
|
171
179
|
|
|
180
|
+
⚠ **그 파라미터는 CC v2.1.212+ 에서 무시되며, 프로젝트 scope `permissions.defaultMode` 역시
|
|
181
|
+
CC v2.1.257+ 에서 무시된다(#1644).** 즉 위 지시를 지켰다는 사실은 **무인 실행의 증거가 아니다**.
|
|
182
|
+
자율 루프 진입 전에 유효 모드를 실측한다:
|
|
183
|
+
|
|
184
|
+
```bash
|
|
185
|
+
jq -r '.permissions.defaultMode // "unset"' ~/.claude/settings.json 2>/dev/null || echo unset
|
|
186
|
+
```
|
|
187
|
+
|
|
188
|
+
`bypassPermissions` 가 아니면 루프 도중 permission 프롬프트로 정지할 수 있으므로,
|
|
189
|
+
`--permission-mode bypassPermissions` 로 재시작하거나 사람이 지켜보는 실행임을 전제한다.
|
|
190
|
+
(파이프라인 쪽 배선은 `pipeline auto-dev` 의 pre-triage Phase 0.5 가 담당한다.)
|
|
191
|
+
|
|
192
|
+
⚠ **커밋 위임 타임아웃 (#1645)**: 메인 워크트리의 `.husky/pre-commit` 이 전체 테스트
|
|
193
|
+
스위트(약 165초)를 돌리므로, `git commit` 을 위임할 때는 Bash `timeout: 400000` 을 명시한다.
|
|
194
|
+
기본값 120000ms 로는 exit 143(SIGTERM)으로 끊긴다. `--no-verify` 우회는 금지(R010).
|
|
195
|
+
|
|
172
196
|
## Artifact Output
|
|
173
197
|
|
|
174
198
|
Artifacts from each iteration follow the conventions of the constituent skills:
|
|
@@ -54,6 +54,30 @@ steps:
|
|
|
54
54
|
"[pre-triage] WARNING: Issue #N references vX.Y.Z (not in git tags) — body may be stale"
|
|
55
55
|
- Output all warnings as advisory log; do NOT halt
|
|
56
56
|
|
|
57
|
+
Phase 0.5 — Measure the EFFECTIVE permission mode (advisory, do NOT halt).
|
|
58
|
+
|
|
59
|
+
Why: CC v2.1.257 stopped honoring `permissions.defaultMode` declared in PROJECT scope
|
|
60
|
+
(`.claude/settings.json` / `.claude/settings.local.json`). Only user/managed scope or an
|
|
61
|
+
explicit `--permission-mode` flag takes effect. R010 「Universal bypassPermissions」 assumes
|
|
62
|
+
the parent session runs unattended under bypassPermissions; if that assumption is silently
|
|
63
|
+
false, this pipeline stalls on a permission prompt mid-run with no diagnostic (#1644).
|
|
64
|
+
|
|
65
|
+
1. Read the user-scope setting (project scope is NOT authoritative on v2.1.257+):
|
|
66
|
+
user_mode=$(jq -r '.permissions.defaultMode // "unset"' ~/.claude/settings.json 2>/dev/null || echo "unset")
|
|
67
|
+
|
|
68
|
+
2. The `--permission-mode` launch flag cannot be read from inside the session. Treat it as
|
|
69
|
+
UNKNOWN — never infer that it was passed.
|
|
70
|
+
|
|
71
|
+
3. Report and, when not bypassPermissions, warn:
|
|
72
|
+
- user_mode == "bypassPermissions":
|
|
73
|
+
report: "[pre-triage] effective permission mode: bypassPermissions (user scope)"
|
|
74
|
+
- otherwise:
|
|
75
|
+
stderr warning: "[pre-triage] WARNING: effective permission mode is <user_mode>; unattended run may prompt. Project-scope permissions.defaultMode is IGNORED on CC v2.1.257+ (#1644). To run unattended, launch with --permission-mode bypassPermissions or set it in user-scope ~/.claude/settings.json."
|
|
76
|
+
- Do NOT halt (advisory only — a prompted run still completes with a human present).
|
|
77
|
+
|
|
78
|
+
4. Do NOT read, echo, or modify any credential material while checking settings — read the
|
|
79
|
+
single `permissions.defaultMode` field only (R001).
|
|
80
|
+
|
|
57
81
|
Phase 1 — Ensure required labels exist, then scan issues.
|
|
58
82
|
|
|
59
83
|
1. Create labels if missing (idempotent):
|
|
@@ -140,6 +164,13 @@ steps:
|
|
|
140
164
|
If approval was already granted THIS session for the same category+target, do not
|
|
141
165
|
re-request (R015 directive persistence).
|
|
142
166
|
|
|
167
|
+
무인 실행(`/fsd` 등 사용자가 실시간 응답할 수 없는 루프)에서는 `.claude/hooks/**` 대상 이슈를
|
|
168
|
+
이번 스코프에서 **분리·이월**하고 나머지 적격 이슈로 진행하며, 반복 경계(homework 게이트)에서
|
|
169
|
+
승인 질문을 **1회** 묶어 제시한다. 승인을 받으면 R015 directive persistence로 세션 내 동일
|
|
170
|
+
카테고리에 지속 적용. 유인 모드에서는 기존대로 즉시 승인 요청. 무인 판별 신호는 "오케스트레이터가
|
|
171
|
+
`/fsd`로 진입했거나 세션 지침이 autonomous임을 명시한 경우"로 판단한다 — 결정론적 판별 신호
|
|
172
|
+
확립은 후속 과제로 남긴다.
|
|
173
|
+
|
|
143
174
|
Output approval_required_paths (list, empty if none) as pipeline state.
|
|
144
175
|
|
|
145
176
|
Assign scoped issues to milestone.
|
|
@@ -170,6 +201,10 @@ steps:
|
|
|
170
201
|
- plan step: skip release-plan skill; single-response plan instead
|
|
171
202
|
- deep-plan step: skip deep-plan skill; single-response implementation notes instead
|
|
172
203
|
- deep-verify step: skip deep-verify skill; perform self-review checklist instead
|
|
204
|
+
— 단, 변경 집합에 `.claude/rules/**`(또는 agents/skills frontmatter 등 구조 표면)가
|
|
205
|
+
포함되면 self-review로 대체하지 않고 mgr-sauron R017 검증을 **단일 목표 위임으로 필수
|
|
206
|
+
실행**한다(Tier 2 lite의 R017 조항과 동일 원칙; Origin: v1.1.58 세션 — docs-only 티어였으나
|
|
207
|
+
sauron이 R002/R010 모순 advisory 1건을 포착).
|
|
173
208
|
- Log: "[compression-mode] docs-only compression activated (scope={n}, all docs/yaml labels)"
|
|
174
209
|
- ⚠ Analysis artifacts only. Every step's state-change side effect (milestone create/assign,
|
|
175
210
|
label add/remove, issue assign/comment/close) still runs in full — see "Cross-tier —
|
|
@@ -297,6 +332,14 @@ steps:
|
|
|
297
332
|
develop-bound commit auto-closes the issue on push — BEFORE release/tag/publish (observed v1.1.38,
|
|
298
333
|
07:49:26Z). Close keywords belong ONLY in the release-stage PR body (see release step 3.b) — auto-tag.yml
|
|
299
334
|
greps the PR body, not commit messages, to decide which issues to close.
|
|
335
|
+
⚠ Bash timeout (#1645): the main-worktree `.husky/pre-commit` runs typecheck + lint +
|
|
336
|
+
the FULL `bun test --coverage` suite (~165s measured) before the commit lands. The Bash
|
|
337
|
+
tool's default timeout is 120000ms, so a `git commit` delegation with the default budget
|
|
338
|
+
is killed mid-hook with exit 143 (SIGTERM). Delegate `git commit` with an explicit
|
|
339
|
+
`timeout: 400000` (≈6.7 min). Do NOT work around a timeout with `--no-verify` — that
|
|
340
|
+
bypasses the quality gate and is on the standing deny list (R010).
|
|
341
|
+
Note: git worktrees run typecheck only (`.husky/pre-commit` lines 7-12 branch on
|
|
342
|
+
`[ -f .git ]` and exit 0), so the long budget matters most on the MAIN worktree.
|
|
300
343
|
6. On success: remove in-progress, add verify-ready
|
|
301
344
|
7. On failure: remove in-progress, add needs-review, comment error summary
|
|
302
345
|
|
|
@@ -425,6 +468,9 @@ steps:
|
|
|
425
468
|
⚠ do NOT add Closes/Fixes/Resolves keywords to this commit message (#1542) — this commit merges into
|
|
426
469
|
develop later via the release PR; a close keyword here bypasses the PR-body mechanism auto-tag.yml
|
|
427
470
|
relies on (see step 3.b below). Use `Refs #N` if a cross-reference is needed.
|
|
471
|
+
⚠ Bash timeout (#1645): delegate this commit with an explicit `timeout: 400000` — the
|
|
472
|
+
main-worktree pre-commit hook runs the full test suite (~165s) and the Bash default of
|
|
473
|
+
120000ms kills it with exit 143. `--no-verify` is NOT an acceptable workaround (R010).
|
|
428
474
|
h. mgr-gitnerd push release/v{NEW} to origin — NEVER push this commit to develop directly. Pushing to
|
|
429
475
|
develop here reproduces the diff=0 PR failure this reordering fixes (#1542).
|
|
430
476
|
i. mandatory verification (with existence guard for partial-update safety), run on release/v{NEW}:
|
package/templates/manifest.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
|
-
"version": "1.1.
|
|
3
|
-
"lastUpdated": "2026-09-
|
|
2
|
+
"version": "1.1.60",
|
|
3
|
+
"lastUpdated": "2026-09-03",
|
|
4
4
|
"omcustomMinClaudeCode": "2.1.121",
|
|
5
5
|
"omcustomMinClaudeCodeReason": "Sensitive-path direct Write/Edit on .claude/** under bypassPermissions (R010 deprecation, #1101)",
|
|
6
6
|
"components": [
|
|
@@ -54,6 +54,30 @@ steps:
|
|
|
54
54
|
"[pre-triage] WARNING: Issue #N references vX.Y.Z (not in git tags) — body may be stale"
|
|
55
55
|
- Output all warnings as advisory log; do NOT halt
|
|
56
56
|
|
|
57
|
+
Phase 0.5 — Measure the EFFECTIVE permission mode (advisory, do NOT halt).
|
|
58
|
+
|
|
59
|
+
Why: CC v2.1.257 stopped honoring `permissions.defaultMode` declared in PROJECT scope
|
|
60
|
+
(`.claude/settings.json` / `.claude/settings.local.json`). Only user/managed scope or an
|
|
61
|
+
explicit `--permission-mode` flag takes effect. R010 「Universal bypassPermissions」 assumes
|
|
62
|
+
the parent session runs unattended under bypassPermissions; if that assumption is silently
|
|
63
|
+
false, this pipeline stalls on a permission prompt mid-run with no diagnostic (#1644).
|
|
64
|
+
|
|
65
|
+
1. Read the user-scope setting (project scope is NOT authoritative on v2.1.257+):
|
|
66
|
+
user_mode=$(jq -r '.permissions.defaultMode // "unset"' ~/.claude/settings.json 2>/dev/null || echo "unset")
|
|
67
|
+
|
|
68
|
+
2. The `--permission-mode` launch flag cannot be read from inside the session. Treat it as
|
|
69
|
+
UNKNOWN — never infer that it was passed.
|
|
70
|
+
|
|
71
|
+
3. Report and, when not bypassPermissions, warn:
|
|
72
|
+
- user_mode == "bypassPermissions":
|
|
73
|
+
report: "[pre-triage] effective permission mode: bypassPermissions (user scope)"
|
|
74
|
+
- otherwise:
|
|
75
|
+
stderr warning: "[pre-triage] WARNING: effective permission mode is <user_mode>; unattended run may prompt. Project-scope permissions.defaultMode is IGNORED on CC v2.1.257+ (#1644). To run unattended, launch with --permission-mode bypassPermissions or set it in user-scope ~/.claude/settings.json."
|
|
76
|
+
- Do NOT halt (advisory only — a prompted run still completes with a human present).
|
|
77
|
+
|
|
78
|
+
4. Do NOT read, echo, or modify any credential material while checking settings — read the
|
|
79
|
+
single `permissions.defaultMode` field only (R001).
|
|
80
|
+
|
|
57
81
|
Phase 1 — Ensure required labels exist, then scan issues.
|
|
58
82
|
|
|
59
83
|
1. Create labels if missing (idempotent):
|
|
@@ -140,6 +164,13 @@ steps:
|
|
|
140
164
|
If approval was already granted THIS session for the same category+target, do not
|
|
141
165
|
re-request (R015 directive persistence).
|
|
142
166
|
|
|
167
|
+
무인 실행(`/fsd` 등 사용자가 실시간 응답할 수 없는 루프)에서는 `.claude/hooks/**` 대상 이슈를
|
|
168
|
+
이번 스코프에서 **분리·이월**하고 나머지 적격 이슈로 진행하며, 반복 경계(homework 게이트)에서
|
|
169
|
+
승인 질문을 **1회** 묶어 제시한다. 승인을 받으면 R015 directive persistence로 세션 내 동일
|
|
170
|
+
카테고리에 지속 적용. 유인 모드에서는 기존대로 즉시 승인 요청. 무인 판별 신호는 "오케스트레이터가
|
|
171
|
+
`/fsd`로 진입했거나 세션 지침이 autonomous임을 명시한 경우"로 판단한다 — 결정론적 판별 신호
|
|
172
|
+
확립은 후속 과제로 남긴다.
|
|
173
|
+
|
|
143
174
|
Output approval_required_paths (list, empty if none) as pipeline state.
|
|
144
175
|
|
|
145
176
|
Assign scoped issues to milestone.
|
|
@@ -170,6 +201,10 @@ steps:
|
|
|
170
201
|
- plan step: skip release-plan skill; single-response plan instead
|
|
171
202
|
- deep-plan step: skip deep-plan skill; single-response implementation notes instead
|
|
172
203
|
- deep-verify step: skip deep-verify skill; perform self-review checklist instead
|
|
204
|
+
— 단, 변경 집합에 `.claude/rules/**`(또는 agents/skills frontmatter 등 구조 표면)가
|
|
205
|
+
포함되면 self-review로 대체하지 않고 mgr-sauron R017 검증을 **단일 목표 위임으로 필수
|
|
206
|
+
실행**한다(Tier 2 lite의 R017 조항과 동일 원칙; Origin: v1.1.58 세션 — docs-only 티어였으나
|
|
207
|
+
sauron이 R002/R010 모순 advisory 1건을 포착).
|
|
173
208
|
- Log: "[compression-mode] docs-only compression activated (scope={n}, all docs/yaml labels)"
|
|
174
209
|
- ⚠ Analysis artifacts only. Every step's state-change side effect (milestone create/assign,
|
|
175
210
|
label add/remove, issue assign/comment/close) still runs in full — see "Cross-tier —
|
|
@@ -297,6 +332,14 @@ steps:
|
|
|
297
332
|
develop-bound commit auto-closes the issue on push — BEFORE release/tag/publish (observed v1.1.38,
|
|
298
333
|
07:49:26Z). Close keywords belong ONLY in the release-stage PR body (see release step 3.b) — auto-tag.yml
|
|
299
334
|
greps the PR body, not commit messages, to decide which issues to close.
|
|
335
|
+
⚠ Bash timeout (#1645): the main-worktree `.husky/pre-commit` runs typecheck + lint +
|
|
336
|
+
the FULL `bun test --coverage` suite (~165s measured) before the commit lands. The Bash
|
|
337
|
+
tool's default timeout is 120000ms, so a `git commit` delegation with the default budget
|
|
338
|
+
is killed mid-hook with exit 143 (SIGTERM). Delegate `git commit` with an explicit
|
|
339
|
+
`timeout: 400000` (≈6.7 min). Do NOT work around a timeout with `--no-verify` — that
|
|
340
|
+
bypasses the quality gate and is on the standing deny list (R010).
|
|
341
|
+
Note: git worktrees run typecheck only (`.husky/pre-commit` lines 7-12 branch on
|
|
342
|
+
`[ -f .git ]` and exit 0), so the long budget matters most on the MAIN worktree.
|
|
300
343
|
6. On success: remove in-progress, add verify-ready
|
|
301
344
|
7. On failure: remove in-progress, add needs-review, comment error summary
|
|
302
345
|
|
|
@@ -425,6 +468,9 @@ steps:
|
|
|
425
468
|
⚠ do NOT add Closes/Fixes/Resolves keywords to this commit message (#1542) — this commit merges into
|
|
426
469
|
develop later via the release PR; a close keyword here bypasses the PR-body mechanism auto-tag.yml
|
|
427
470
|
relies on (see step 3.b below). Use `Refs #N` if a cross-reference is needed.
|
|
471
|
+
⚠ Bash timeout (#1645): delegate this commit with an explicit `timeout: 400000` — the
|
|
472
|
+
main-worktree pre-commit hook runs the full test suite (~165s) and the Bash default of
|
|
473
|
+
120000ms kills it with exit 143. `--no-verify` is NOT an acceptable workaround (R010).
|
|
428
474
|
h. mgr-gitnerd push release/v{NEW} to origin — NEVER push this commit to develop directly. Pushing to
|
|
429
475
|
develop here reproduces the diff=0 PR failure this reordering fixes (#1542).
|
|
430
476
|
i. mandatory verification (with existence guard for partial-update safety), run on release/v{NEW}:
|