oh-my-customcode 1.1.59 → 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 CHANGED
@@ -243,7 +243,7 @@ var init_package = __esm(() => {
243
243
  workspaces: [
244
244
  "packages/*"
245
245
  ],
246
- version: "1.1.59",
246
+ version: "1.1.60",
247
247
  description: "Batteries-included agent harness for Claude Code",
248
248
  type: "module",
249
249
  bin: {
package/dist/index.js CHANGED
@@ -2539,7 +2539,7 @@ var package_default = {
2539
2539
  workspaces: [
2540
2540
  "packages/*"
2541
2541
  ],
2542
- version: "1.1.59",
2542
+ version: "1.1.60",
2543
2543
  description: "Batteries-included agent harness for Claude Code",
2544
2544
  type: "module",
2545
2545
  bin: {
package/package.json CHANGED
@@ -3,7 +3,7 @@
3
3
  "workspaces": [
4
4
  "packages/*"
5
5
  ],
6
- "version": "1.1.59",
6
+ "version": "1.1.60",
7
7
  "description": "Batteries-included agent harness for Claude Code",
8
8
  "type": "module",
9
9
  "bin": {
@@ -38,6 +38,18 @@ 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]:-}"
@@ -48,8 +60,28 @@ is_readonly_single_command() {
48
60
  # A bare assignment has no side effect outside the shell; when followed by a
49
61
  # command, classify that command instead. Command substitutions inside the
50
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.
51
77
  case "$w1" in
52
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
53
85
  if [ "${#parts[@]}" -le 1 ]; then
54
86
  echo "true"
55
87
  return 0
@@ -62,13 +94,40 @@ is_readonly_single_command() {
62
94
  esac
63
95
 
64
96
  case "$w1" in
65
- ls|cat|head|tail|grep|rg|wc|jq|md5|md5sum|type|command|which|echo|printf|pwd|date)
97
+ ls|cat|head|tail|grep|rg|wc|jq|md5|md5sum|type|which|echo|printf|pwd|date)
66
98
  echo "true"
67
99
  return 0
68
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
+ ;;
69
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.
70
129
  case "$cmd" in
71
- *-delete*|*-exec*|*-fprintf*)
130
+ *-delete*|*-exec*|*-fprint*|*-fls*|*-ok*)
72
131
  echo "false"
73
132
  ;;
74
133
  *)
@@ -133,6 +192,61 @@ is_readonly_single_command() {
133
192
  esac
134
193
  }
135
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
+
136
250
  # Maximum recursion depth for nested command substitution / loop bodies.
137
251
  _RO_MAX_DEPTH=5
138
252
  # Maximum command length that is parsed at all (see Step 0 below).
@@ -235,9 +349,14 @@ is_readonly_bash_command() {
235
349
  # --- Step 3: strip harmless redirections BEFORE the ">" write check.
236
350
  # "2>&1", ">/dev/null", "2>/dev/null", "1>/dev/null", "&>/dev/null" produce no
237
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.
238
357
  local stripped
239
358
  stripped="$(printf '%s' "$cmd" \
240
- | sed -E 's/[0-9]?>&[0-9]/ /g; s#[0-9]?&?>[[:space:]]*/dev/null# #g')"
359
+ | sed -E 's/[0-9]?>&[12]([^0-9])/ \1/g; s/[0-9]?>&[12]$/ /; s#[0-9]?&?>[[:space:]]*/dev/null# #g')"
241
360
  case "$stripped" in
242
361
  *'>'*)
243
362
  echo "false"
@@ -296,10 +415,38 @@ is_readonly_bash_command() {
296
415
  ;;
297
416
  esac
298
417
 
299
- # --- Step 6: compound command — split on "&&", ";", or a single "|" and
300
- # require EVERY segment to be read-only (#1629). An empty segment (e.g. a
301
- # trailing separator) is ambiguous => write.
302
- if [[ "$cmd" == *'&&'* || "$cmd" == *';'* || "$cmd" == *'|'* ]]; then
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
303
450
  local normalized="$cmd"
304
451
  normalized="${normalized//&&/$'\n'}"
305
452
  normalized="${normalized//;/$'\n'}"
@@ -360,11 +507,25 @@ else
360
507
  file_path_full=$(printf '%s' "$input" | jq -r '.tool_input.file_path // ""')
361
508
  # file_path is display/guard only (basename in the messages); target_key is
362
509
  # the value matched against history, so only it carries the length tag.
363
- file_path=$(printf '%s' "$file_path_full" | head -c "$TARGET_KEY_CAP")
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)
364
514
  target_key=$(truncate_key "$file_path_full")
365
515
  fi
366
516
  is_error=$(printf '%s' "$input" | jq -r '.tool_output.is_error // false')
367
- output_preview=$(printf '%s' "$input" | jq -r '.tool_output.output // ""' | head -c 200)
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)
368
529
  raw_command=$(printf '%s' "$input" | jq -r '.tool_input.command // ""')
369
530
 
370
531
  # Distinguish "the same file edited 3 times with DIFFERENT content" (a normal
@@ -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 상태면 거부된다.
@@ -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
  ```
@@ -164,6 +164,13 @@ steps:
164
164
  If approval was already granted THIS session for the same category+target, do not
165
165
  re-request (R015 directive persistence).
166
166
 
167
+ 무인 실행(`/fsd` 등 사용자가 실시간 응답할 수 없는 루프)에서는 `.claude/hooks/**` 대상 이슈를
168
+ 이번 스코프에서 **분리·이월**하고 나머지 적격 이슈로 진행하며, 반복 경계(homework 게이트)에서
169
+ 승인 질문을 **1회** 묶어 제시한다. 승인을 받으면 R015 directive persistence로 세션 내 동일
170
+ 카테고리에 지속 적용. 유인 모드에서는 기존대로 즉시 승인 요청. 무인 판별 신호는 "오케스트레이터가
171
+ `/fsd`로 진입했거나 세션 지침이 autonomous임을 명시한 경우"로 판단한다 — 결정론적 판별 신호
172
+ 확립은 후속 과제로 남긴다.
173
+
167
174
  Output approval_required_paths (list, empty if none) as pipeline state.
168
175
 
169
176
  Assign scoped issues to milestone.
@@ -194,6 +201,10 @@ steps:
194
201
  - plan step: skip release-plan skill; single-response plan instead
195
202
  - deep-plan step: skip deep-plan skill; single-response implementation notes instead
196
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건을 포착).
197
208
  - Log: "[compression-mode] docs-only compression activated (scope={n}, all docs/yaml labels)"
198
209
  - ⚠ Analysis artifacts only. Every step's state-change side effect (milestone create/assign,
199
210
  label add/remove, issue assign/comment/close) still runs in full — see "Cross-tier —
@@ -1,5 +1,5 @@
1
1
  {
2
- "version": "1.1.59",
2
+ "version": "1.1.60",
3
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)",
@@ -164,6 +164,13 @@ steps:
164
164
  If approval was already granted THIS session for the same category+target, do not
165
165
  re-request (R015 directive persistence).
166
166
 
167
+ 무인 실행(`/fsd` 등 사용자가 실시간 응답할 수 없는 루프)에서는 `.claude/hooks/**` 대상 이슈를
168
+ 이번 스코프에서 **분리·이월**하고 나머지 적격 이슈로 진행하며, 반복 경계(homework 게이트)에서
169
+ 승인 질문을 **1회** 묶어 제시한다. 승인을 받으면 R015 directive persistence로 세션 내 동일
170
+ 카테고리에 지속 적용. 유인 모드에서는 기존대로 즉시 승인 요청. 무인 판별 신호는 "오케스트레이터가
171
+ `/fsd`로 진입했거나 세션 지침이 autonomous임을 명시한 경우"로 판단한다 — 결정론적 판별 신호
172
+ 확립은 후속 과제로 남긴다.
173
+
167
174
  Output approval_required_paths (list, empty if none) as pipeline state.
168
175
 
169
176
  Assign scoped issues to milestone.
@@ -194,6 +201,10 @@ steps:
194
201
  - plan step: skip release-plan skill; single-response plan instead
195
202
  - deep-plan step: skip deep-plan skill; single-response implementation notes instead
196
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건을 포착).
197
208
  - Log: "[compression-mode] docs-only compression activated (scope={n}, all docs/yaml labels)"
198
209
  - ⚠ Analysis artifacts only. Every step's state-change side effect (milestone create/assign,
199
210
  label add/remove, issue assign/comment/close) still runs in full — see "Cross-tier —