공개 문서

learn/canonical/knot/drain.sh.md

아래는 learn/canonical/knot/drain.sh.md 와 동일한 원문입니다. Markdown과 HTML 변환 결과를 각각 복사할 수 있습니다.

공개 문서 원문 (Markdown)

# KNOT_WIKI/ops/scripts/drain.sh

> Hub reference copy — MAS 정본: `tools/knot/scaffold/ops/scripts/drain.sh`

```bash
#!/bin/bash
# knot drain — inbox 일괄 ingest → lint → 리포트 (배포판).
# 기본 OFF: 자동으로 돌지 않는다. cron/launchd가 부를 때만 동작(설치 setup에서 등록).
#
# 검증된 정본 패턴 계승(맨바닥 재작성 금지):
#   - 건당 1처리 → 1커밋, idempotent(재개 = 재실행)
#   - 진척 판정 = "새 커밋 생김 + inbox 감소". 미충족이면 러너 조용한 실패·쿼터로 보고 중단
#   - 진척 없으면 git reset --hard + clean -fd 로 미완성분 무손상 복구(원본은 inbox에 남음)
#   - MAX_ITER 무한루프 방지, 시작 가드(트리 더티면 중단·reset 안 함)
#
# 알림 3단(아래 notify()):
#   ① 바닥(항상): STATUS 파일 + knot log.md 요약 커밋 + 실패 시 non-zero exit
#   ② 기본 push(제로설정): OS 데스크톱 알림(mac osascript / linux notify-send)
#   ③ 선택: vault 밖 외부 채널(KNOT_NOTIFY_CMD env 또는 ~/.config/knot/notify.sh)
#
# 보안: 이 스크립트는 공개 vault에 포함된다. 비밀값·웹훅·토큰·개인경로 0.
#       외부 알림 설정은 vault 밖에만 두고 여기선 "참조"만 한다.
set -u

# --- 설정 (env 또는 플래그로 주입; 번들에 사용자값을 박지 않는다) ---
RUNNER="${KNOT_RUNNER:-claude}"      # claude(기본·run-knot.sh와 동일) | agy | codex | gemini | 임의 CLI
MODEL="${KNOT_MODEL:-}"             # agy 등 per-call 모델 핀(비우면 러너 기본값)
MAX_ITER="${KNOT_MAX_ITER:-25}"

while [ $# -gt 0 ]; do
  case "$1" in
    --runner)   RUNNER="$2"; shift 2 ;;
    --model)    MODEL="$2"; shift 2 ;;
    --max-iter) MAX_ITER="$2"; shift 2 ;;
    -h|--help)  echo "사용: drain.sh [--runner agy|claude|codex|gemini] [--model <핀>] [--max-iter N]"; exit 0 ;;
    *)          echo "알 수 없는 인자: $1" >&2; exit 2 ;;
  esac
done

# --- vault 위치 ($KNOT_WIKI 기준) ---
VAULT="${KNOT_WIKI:-}"
if [ -z "$VAULT" ] || [ ! -d "$VAULT" ]; then
  echo "KNOT_WIKI 미설정 또는 디렉토리 없음" >&2
  exit 1
fi
cd "$VAULT" || exit 1

# 경로 fallback — migration 전(flat) / 후(schema·raw·ops) 모두 지원
if [ -d raw/inbox ]; then INBOX_DIR=raw/inbox
elif [ -d inbox ]; then INBOX_DIR=inbox
else INBOX_DIR=inbox; fi

if [ -f ops/scripts/lint.py ]; then LINT_PY=ops/scripts/lint.py
elif [ -f scripts/lint.py ]; then LINT_PY=scripts/lint.py
else LINT_PY=scripts/lint.py; fi

if [ -f schema/log.md ]; then LOG_MD=schema/log.md
elif [ -f log.md ]; then LOG_MD=log.md
else LOG_MD=log.md; fi

if [ -f schema/schema.md ]; then SCHEMA_REF=schema/schema.md
else SCHEMA_REF=schema.md; fi

if [ -d ops/prompts ]; then PROMPTS_DIR=ops/prompts
else PROMPTS_DIR=prompts; fi

PROMPT="${SCHEMA_REF}와 ${PROMPTS_DIR}/ingest.md를 정독하고 그대로 실행하라"

STATE="$VAULT/.knot"               # 런타임 상태(.gitignore 처리) — 트리 클린 유지
mkdir -p "$STATE"
STATUS="$STATE/drain-status.txt"
RUNLOG="$STATE/drain.log"

# 러너 1회 호출 — 모델 핀은 agy의 --model로 per-call 지정(공백 포함 이름 보존, bash 3.2 안전)
run_ingest() {
  case "$RUNNER" in
    agy)
      if [ -n "$MODEL" ]; then
        agy --model "$MODEL" --dangerously-skip-permissions --print-timeout 9m -p "$PROMPT"
      else
        agy --dangerously-skip-permissions --print-timeout 9m -p "$PROMPT"
      fi ;;
    claude) claude -p "$PROMPT" --dangerously-skip-permissions < /dev/null ;;
    codex)  codex exec "$PROMPT" < /dev/null ;;
    gemini) gemini -p "$PROMPT" ;;
    *)      "$RUNNER" -p "$PROMPT" ;;
  esac
}

# ②③ 알림 — 비밀값은 스크립트에 없다. ③은 vault 밖 설정을 참조만.
notify() {
  local msg="$1"
  if command -v osascript >/dev/null 2>&1; then
    osascript -e "display notification \"${msg//\"/}\" with title \"knot drain\"" >/dev/null 2>&1 || true
  elif command -v notify-send >/dev/null 2>&1; then
    notify-send "knot drain" "$msg" >/dev/null 2>&1 || true
  fi
  if [ -n "${KNOT_NOTIFY_CMD:-}" ]; then
    printf '%s\n' "$msg" | sh -c "$KNOT_NOTIFY_CMD" >/dev/null 2>&1 || true
  elif [ -x "$HOME/.config/knot/notify.sh" ]; then
    "$HOME/.config/knot/notify.sh" "$msg" >/dev/null 2>&1 || true
  fi
}

TOTAL=$(ls "$INBOX_DIR"/ 2>/dev/null | wc -l | tr -d ' ')
DONE=0
STOP_REASON=""
echo "[$(date '+%F %T')] drain 시작 — runner=$RUNNER inbox ${TOTAL}건" >> "$RUNLOG"

for ((i=1; i<=MAX_ITER; i++)); do
  COUNT=$(ls "$INBOX_DIR"/ 2>/dev/null | wc -l | tr -d ' ')
  [ "$COUNT" -eq 0 ] && break
  if [ -n "$(git status --porcelain)" ]; then
    STOP_REASON="시작 가드 실패: 트리 더티(외부 변경 가능성, reset 안 함)"
    break
  fi
  HEAD_BEFORE=$(git rev-parse HEAD 2>/dev/null || echo none)
  NEXT=$(ls "$INBOX_DIR"/ | head -1)
  echo "[$(date '+%F %T')] [$i] 처리: $NEXT (잔여 $COUNT)" >> "$RUNLOG"
  run_ingest >> "$RUNLOG" 2>&1
  HEAD_AFTER=$(git rev-parse HEAD 2>/dev/null || echo none)
  NEWCOUNT=$(ls "$INBOX_DIR"/ 2>/dev/null | wc -l | tr -d ' ')
  if [ "$HEAD_AFTER" = "$HEAD_BEFORE" ] || [ "$NEWCOUNT" -ge "$COUNT" ]; then
    git reset --hard HEAD >> "$RUNLOG" 2>&1
    git clean -fd >> "$RUNLOG" 2>&1
    STOP_REASON="진척 없음($NEXT: 새 커밋/inbox 감소 미충족 — 러너 조용한 실패·쿼터 가능성). reset 복원"
    break
  fi
  DONE=$((DONE+1))
  echo "[$(date '+%F %T')] [$i] 완료: $NEXT → $(git log --oneline -1)" >> "$RUNLOG"
  sleep 5
done

REMAIN=$(ls "$INBOX_DIR"/ 2>/dev/null | wc -l | tr -d ' ')
[ -z "$STOP_REASON" ] && [ "$REMAIN" -gt 0 ] && STOP_REASON="MAX_ITER($MAX_ITER) 도달"

# lint(기계 검사) — 트리 클린일 때만
LINT_LINE=""
if [ -z "$(git status --porcelain)" ] && [ -f "$LINT_PY" ]; then
  if python3 "$LINT_PY" >> "$RUNLOG" 2>&1; then LINT_LINE="lint OK"; else LINT_LINE="lint ERROR(상세 .knot/drain.log)"; fi
fi

SUMMARY="knot drain: 성공 $DONE/$TOTAL, 잔여 $REMAIN. ${STOP_REASON:-전건 완료}.${LINT_LINE:+ $LINT_LINE}"

# ① 바닥: STATUS 파일(항상)
{
  echo "drain 종료 $(date '+%F %T')"
  echo "runner=$RUNNER  성공 $DONE/$TOTAL  잔여 inbox $REMAIN"
  if [ -n "$STOP_REASON" ]; then echo "중단: $STOP_REASON"; echo "재개: drain.sh 재실행"; else echo "전건 완료"; fi
  [ -n "$LINT_LINE" ] && echo "$LINT_LINE"
  echo "HEAD: $(git log --oneline -1 2>/dev/null)"
} > "$STATUS"

# ① 바닥: knot log.md 요약 append + 커밋(트리 클린 + 실제 처리분 있을 때만)
if [ "$DONE" -gt 0 ] && [ -z "$(git status --porcelain)" ]; then
  {
    echo ""
    echo "## [$(date '+%F')] drain — inbox ingest $DONE/$TOTAL (runner=$RUNNER)"
    echo ""
    if [ -n "$STOP_REASON" ]; then echo "- 중단: $STOP_REASON (잔여 ${REMAIN}건, 재개=재실행)"; else echo "- 전건 완료(잔여 0)"; fi
  } >> "$LOG_MD"
  git add "$LOG_MD"
  git commit -q -m "drain: inbox ingest $DONE/$TOTAL" || true
fi

# ②③ 알림
notify "$SUMMARY"
echo "[$(date '+%F %T')] $SUMMARY" >> "$RUNLOG"

# ① 바닥: 진척 0인데 처리할 게 남았으면(쿼터·시작가드 등) 실패 신호 — cron이 조용히 죽지 않게
if [ "$DONE" -eq 0 ] && [ "$REMAIN" -gt 0 ]; then
  exit 1
fi
exit 0
```

공개 문서 변환 코드 (HTML)

<h1>KNOT_WIKI/ops/scripts/drain.sh</h1>
<blockquote>
<p>Hub reference copy — MAS 정본: <code>tools/knot/scaffold/ops/scripts/drain.sh</code></p>
</blockquote>
<pre><code class="language-bash">#!/bin/bash
# knot drain — inbox 일괄 ingest → lint → 리포트 (배포판).
# 기본 OFF: 자동으로 돌지 않는다. cron/launchd가 부를 때만 동작(설치 setup에서 등록).
#
# 검증된 정본 패턴 계승(맨바닥 재작성 금지):
#   - 건당 1처리 → 1커밋, idempotent(재개 = 재실행)
#   - 진척 판정 = &quot;새 커밋 생김 + inbox 감소&quot;. 미충족이면 러너 조용한 실패·쿼터로 보고 중단
#   - 진척 없으면 git reset --hard + clean -fd 로 미완성분 무손상 복구(원본은 inbox에 남음)
#   - MAX_ITER 무한루프 방지, 시작 가드(트리 더티면 중단·reset 안 함)
#
# 알림 3단(아래 notify()):
#   ① 바닥(항상): STATUS 파일 + knot log.md 요약 커밋 + 실패 시 non-zero exit
#   ② 기본 push(제로설정): OS 데스크톱 알림(mac osascript / linux notify-send)
#   ③ 선택: vault 밖 외부 채널(KNOT_NOTIFY_CMD env 또는 ~/.config/knot/notify.sh)
#
# 보안: 이 스크립트는 공개 vault에 포함된다. 비밀값·웹훅·토큰·개인경로 0.
#       외부 알림 설정은 vault 밖에만 두고 여기선 &quot;참조&quot;만 한다.
set -u

# --- 설정 (env 또는 플래그로 주입; 번들에 사용자값을 박지 않는다) ---
RUNNER=&quot;${KNOT_RUNNER:-claude}&quot;      # claude(기본·run-knot.sh와 동일) | agy | codex | gemini | 임의 CLI
MODEL=&quot;${KNOT_MODEL:-}&quot;             # agy 등 per-call 모델 핀(비우면 러너 기본값)
MAX_ITER=&quot;${KNOT_MAX_ITER:-25}&quot;

while [ $# -gt 0 ]; do
  case &quot;$1&quot; in
    --runner)   RUNNER=&quot;$2&quot;; shift 2 ;;
    --model)    MODEL=&quot;$2&quot;; shift 2 ;;
    --max-iter) MAX_ITER=&quot;$2&quot;; shift 2 ;;
    -h|--help)  echo &quot;사용: drain.sh [--runner agy|claude|codex|gemini] [--model &lt;핀&gt;] [--max-iter N]&quot;; exit 0 ;;
    *)          echo &quot;알 수 없는 인자: $1&quot; &gt;&amp;2; exit 2 ;;
  esac
done

# --- vault 위치 ($KNOT_WIKI 기준) ---
VAULT=&quot;${KNOT_WIKI:-}&quot;
if [ -z &quot;$VAULT&quot; ] || [ ! -d &quot;$VAULT&quot; ]; then
  echo &quot;KNOT_WIKI 미설정 또는 디렉토리 없음&quot; &gt;&amp;2
  exit 1
fi
cd &quot;$VAULT&quot; || exit 1

# 경로 fallback — migration 전(flat) / 후(schema·raw·ops) 모두 지원
if [ -d raw/inbox ]; then INBOX_DIR=raw/inbox
elif [ -d inbox ]; then INBOX_DIR=inbox
else INBOX_DIR=inbox; fi

if [ -f ops/scripts/lint.py ]; then LINT_PY=ops/scripts/lint.py
elif [ -f scripts/lint.py ]; then LINT_PY=scripts/lint.py
else LINT_PY=scripts/lint.py; fi

if [ -f schema/log.md ]; then LOG_MD=schema/log.md
elif [ -f log.md ]; then LOG_MD=log.md
else LOG_MD=log.md; fi

if [ -f schema/schema.md ]; then SCHEMA_REF=schema/schema.md
else SCHEMA_REF=schema.md; fi

if [ -d ops/prompts ]; then PROMPTS_DIR=ops/prompts
else PROMPTS_DIR=prompts; fi

PROMPT=&quot;${SCHEMA_REF}와 ${PROMPTS_DIR}/ingest.md를 정독하고 그대로 실행하라&quot;

STATE=&quot;$VAULT/.knot&quot;               # 런타임 상태(.gitignore 처리) — 트리 클린 유지
mkdir -p &quot;$STATE&quot;
STATUS=&quot;$STATE/drain-status.txt&quot;
RUNLOG=&quot;$STATE/drain.log&quot;

# 러너 1회 호출 — 모델 핀은 agy의 --model로 per-call 지정(공백 포함 이름 보존, bash 3.2 안전)
run_ingest() {
  case &quot;$RUNNER&quot; in
    agy)
      if [ -n &quot;$MODEL&quot; ]; then
        agy --model &quot;$MODEL&quot; --dangerously-skip-permissions --print-timeout 9m -p &quot;$PROMPT&quot;
      else
        agy --dangerously-skip-permissions --print-timeout 9m -p &quot;$PROMPT&quot;
      fi ;;
    claude) claude -p &quot;$PROMPT&quot; --dangerously-skip-permissions &lt; /dev/null ;;
    codex)  codex exec &quot;$PROMPT&quot; &lt; /dev/null ;;
    gemini) gemini -p &quot;$PROMPT&quot; ;;
    *)      &quot;$RUNNER&quot; -p &quot;$PROMPT&quot; ;;
  esac
}

# ②③ 알림 — 비밀값은 스크립트에 없다. ③은 vault 밖 설정을 참조만.
notify() {
  local msg=&quot;$1&quot;
  if command -v osascript &gt;/dev/null 2&gt;&amp;1; then
    osascript -e &quot;display notification \&quot;${msg//\&quot;/}\&quot; with title \&quot;knot drain\&quot;&quot; &gt;/dev/null 2&gt;&amp;1 || true
  elif command -v notify-send &gt;/dev/null 2&gt;&amp;1; then
    notify-send &quot;knot drain&quot; &quot;$msg&quot; &gt;/dev/null 2&gt;&amp;1 || true
  fi
  if [ -n &quot;${KNOT_NOTIFY_CMD:-}&quot; ]; then
    printf &#39;%s\n&#39; &quot;$msg&quot; | sh -c &quot;$KNOT_NOTIFY_CMD&quot; &gt;/dev/null 2&gt;&amp;1 || true
  elif [ -x &quot;$HOME/.config/knot/notify.sh&quot; ]; then
    &quot;$HOME/.config/knot/notify.sh&quot; &quot;$msg&quot; &gt;/dev/null 2&gt;&amp;1 || true
  fi
}

TOTAL=$(ls &quot;$INBOX_DIR&quot;/ 2&gt;/dev/null | wc -l | tr -d &#39; &#39;)
DONE=0
STOP_REASON=&quot;&quot;
echo &quot;[$(date &#39;+%F %T&#39;)] drain 시작 — runner=$RUNNER inbox ${TOTAL}건&quot; &gt;&gt; &quot;$RUNLOG&quot;

for ((i=1; i&lt;=MAX_ITER; i++)); do
  COUNT=$(ls &quot;$INBOX_DIR&quot;/ 2&gt;/dev/null | wc -l | tr -d &#39; &#39;)
  [ &quot;$COUNT&quot; -eq 0 ] &amp;&amp; break
  if [ -n &quot;$(git status --porcelain)&quot; ]; then
    STOP_REASON=&quot;시작 가드 실패: 트리 더티(외부 변경 가능성, reset 안 함)&quot;
    break
  fi
  HEAD_BEFORE=$(git rev-parse HEAD 2&gt;/dev/null || echo none)
  NEXT=$(ls &quot;$INBOX_DIR&quot;/ | head -1)
  echo &quot;[$(date &#39;+%F %T&#39;)] [$i] 처리: $NEXT (잔여 $COUNT)&quot; &gt;&gt; &quot;$RUNLOG&quot;
  run_ingest &gt;&gt; &quot;$RUNLOG&quot; 2&gt;&amp;1
  HEAD_AFTER=$(git rev-parse HEAD 2&gt;/dev/null || echo none)
  NEWCOUNT=$(ls &quot;$INBOX_DIR&quot;/ 2&gt;/dev/null | wc -l | tr -d &#39; &#39;)
  if [ &quot;$HEAD_AFTER&quot; = &quot;$HEAD_BEFORE&quot; ] || [ &quot;$NEWCOUNT&quot; -ge &quot;$COUNT&quot; ]; then
    git reset --hard HEAD &gt;&gt; &quot;$RUNLOG&quot; 2&gt;&amp;1
    git clean -fd &gt;&gt; &quot;$RUNLOG&quot; 2&gt;&amp;1
    STOP_REASON=&quot;진척 없음($NEXT: 새 커밋/inbox 감소 미충족 — 러너 조용한 실패·쿼터 가능성). reset 복원&quot;
    break
  fi
  DONE=$((DONE+1))
  echo &quot;[$(date &#39;+%F %T&#39;)] [$i] 완료: $NEXT → $(git log --oneline -1)&quot; &gt;&gt; &quot;$RUNLOG&quot;
  sleep 5
done

REMAIN=$(ls &quot;$INBOX_DIR&quot;/ 2&gt;/dev/null | wc -l | tr -d &#39; &#39;)
[ -z &quot;$STOP_REASON&quot; ] &amp;&amp; [ &quot;$REMAIN&quot; -gt 0 ] &amp;&amp; STOP_REASON=&quot;MAX_ITER($MAX_ITER) 도달&quot;

# lint(기계 검사) — 트리 클린일 때만
LINT_LINE=&quot;&quot;
if [ -z &quot;$(git status --porcelain)&quot; ] &amp;&amp; [ -f &quot;$LINT_PY&quot; ]; then
  if python3 &quot;$LINT_PY&quot; &gt;&gt; &quot;$RUNLOG&quot; 2&gt;&amp;1; then LINT_LINE=&quot;lint OK&quot;; else LINT_LINE=&quot;lint ERROR(상세 .knot/drain.log)&quot;; fi
fi

SUMMARY=&quot;knot drain: 성공 $DONE/$TOTAL, 잔여 $REMAIN. ${STOP_REASON:-전건 완료}.${LINT_LINE:+ $LINT_LINE}&quot;

# ① 바닥: STATUS 파일(항상)
{
  echo &quot;drain 종료 $(date &#39;+%F %T&#39;)&quot;
  echo &quot;runner=$RUNNER  성공 $DONE/$TOTAL  잔여 inbox $REMAIN&quot;
  if [ -n &quot;$STOP_REASON&quot; ]; then echo &quot;중단: $STOP_REASON&quot;; echo &quot;재개: drain.sh 재실행&quot;; else echo &quot;전건 완료&quot;; fi
  [ -n &quot;$LINT_LINE&quot; ] &amp;&amp; echo &quot;$LINT_LINE&quot;
  echo &quot;HEAD: $(git log --oneline -1 2&gt;/dev/null)&quot;
} &gt; &quot;$STATUS&quot;

# ① 바닥: knot log.md 요약 append + 커밋(트리 클린 + 실제 처리분 있을 때만)
if [ &quot;$DONE&quot; -gt 0 ] &amp;&amp; [ -z &quot;$(git status --porcelain)&quot; ]; then
  {
    echo &quot;&quot;
    echo &quot;## [$(date &#39;+%F&#39;)] drain — inbox ingest $DONE/$TOTAL (runner=$RUNNER)&quot;
    echo &quot;&quot;
    if [ -n &quot;$STOP_REASON&quot; ]; then echo &quot;- 중단: $STOP_REASON (잔여 ${REMAIN}건, 재개=재실행)&quot;; else echo &quot;- 전건 완료(잔여 0)&quot;; fi
  } &gt;&gt; &quot;$LOG_MD&quot;
  git add &quot;$LOG_MD&quot;
  git commit -q -m &quot;drain: inbox ingest $DONE/$TOTAL&quot; || true
fi

# ②③ 알림
notify &quot;$SUMMARY&quot;
echo &quot;[$(date &#39;+%F %T&#39;)] $SUMMARY&quot; &gt;&gt; &quot;$RUNLOG&quot;

# ① 바닥: 진척 0인데 처리할 게 남았으면(쿼터·시작가드 등) 실패 신호 — cron이 조용히 죽지 않게
if [ &quot;$DONE&quot; -eq 0 ] &amp;&amp; [ &quot;$REMAIN&quot; -gt 0 ]; then
  exit 1
fi
exit 0
</code></pre>