最近はナレッジをScrapboxに書き溜めています

マネジメント・メンタリティ・広義のエンジニアリングに関するナレッジ類を最近はScrapboxのほうに書いている。

scrapbox.io

EM(エンジニアリングマネージャー)の経験則や思考など、GPTに聞いたら出てくるような出てこないような不定な物事は書けるときに雑多に書き溜めるほうが効率がよく、ブログという形式では書かないようにしている。

というお知らせ投稿です。

GitHubでRequired Approvalsを設定したままRenovateのPRだけapproveなしでautomergeする

タイトルがややこしい。

Renovateによる依存アップデートのPRで、パッチとマイナーアップデートの場合はテストをPASSしていたら自動でマージして欲しい。

こういう設定をしている

{
  "packageRules": [
    {
      "matchUpdateTypes": ["minor", "patch"],
      "matchCurrentVersion": "!/^0/",
      "automerge": true
    }
  ]
}

しかし、リポジトリのRulesetsでRequired Approvalsを設定しているとPRのApproveがないので永遠にマージされない。

でもRenovate以外の人間とAIによるPRはApprove必須にしたいため、この設定を剥がすことはできない。しかし大量の依存アップデートに対して人間がチマチマApproveすることは避けたい。

この二律背反問題を解消する必要がある。

解決方法

この問題と解消方法については以下のDocumentに記載がある。

docs.renovatebot.com

  1. https://github.com/renovatebot/renovate-approve-bot を使う
  2. rulesetsのBYPASSを使う

1がラクなのだけど、落とし穴がありCODEOWNERファイルによる設定があると、この作戦はうまく機能しない。 これはBotをOWNERに指定できないからで、自動でReviewerにBotを追加できないから。

なのでCODEOWNER設定がある場合、2のパターンでBYPASSする必要がある。しかしこの設定がややこしい(というよりリファレンスがない)。

Rulesetsを分けてバイパス設定をする

設定のポイントは

  • block force pushやstatus checkのrulesetから Requied ApprovalsとRestrict update のruleを分離しておく
  • Bypasslist に OWNERとRenovateを追加し Exempt from rules にする

設定例

こうしておくと、Renovateが出すPRにはRequired Approvals設定が除外され、別のRulesetに設定しているStatus CheckによるCIが通ればautomergeによって自動でマージされていく。あとオーナーは自身のPRによる強制マージもできる。

対象ブランチのアップデートを保護により絞る場合、 Restrict updates のルールもRequired Approvedの設定側に分離しておく必要があり、これはmerge=target branch updateとなるからで、ややこしい。

Claude Codeの会話応答時間をStatuslineに表示する実装

この記事はClaude Codeとの共著で作成されました。実装の詳細や技術的な内容について、AIによる支援を受けて執筆しています。

↑の免責を10sで書いてくれたということがわかるようになる

Claude Codeを使っていて、1回の会話(プロンプト送信から応答完了まで)にどのくらい時間がかかっているかが気になった。特に複雑なタスクを依頼したとき、どの程度時間が経過しているのか分からなくなる。

Claude CodeからHookやStatuslineに渡されるコンテキスト情報を確認したが、会話の経過時間に関する情報は含まれていなかった。cost情報にはtotal_duration_msという項目があるものの、これは別の用途らしく、リアルタイムの会話時間は取得できない。

そのため、Hook機能を使って自分で時間を計測・記録するハック的な実装をすることにした。

全体の仕組み

Claude CodeにはHook機能があって、特定のイベントが発生したときにシェルコマンドを実行できる。これを使って以下の流れを作った:

  1. ユーザーがプロンプトを送信したとき→会話開始時刻を記録
  2. ツール実行中→会話の経過時間を更新
  3. 応答完了時→会話終了時刻を記録してステータスを完了に
  4. Statusline表示時→記録した時間データを読み取って表示

1回の会話ごとに時間を計測するので、新しいプロンプトが送信されるたびに計測がリセットされる。

状態の共有には一時ファイル(/tmp/claude-code-duration-{session_id}.json)を使った。

sequenceDiagram
    participant User as ユーザー
    participant CC as Claude Code
    participant USH as user-prompt-submit-hook.sh
    participant AIH as agent-in-progress-hook.sh  
    participant FRH as finished-responding-hook.sh
    participant TMP as 一時ファイル
    participant SL as Statusline
    participant CCD as claude-code-duration.ts

    User->>CC: プロンプト送信
    CC->>USH: UserPromptSubmit Hook実行
    USH->>TMP: 開始時刻を記録
    
    loop ツール実行中
        CC->>AIH: PreToolUse/PostToolUse Hook実行
        AIH->>TMP: 経過時間を更新
    end
    
    CC->>FRH: Stop Hook実行
    FRH->>TMP: 完了時刻を記録・status更新
    
    loop Statusline表示
        SL->>CCD: TypeScriptスクリプト実行
        CCD->>TMP: 時間データを読み取り
        CCD->>SL: フォーマット済み時間を返却
        SL->>User: Statuslineに表示
    end

settings.jsonの設定

.claude/settings.jsonにHookの設定を追加した:

{
  "hooks": {
    "UserPromptSubmit": [
      {
        "hooks": [
          {
            "type": "command",
            "command": "~/bin/claude-utils/duration-logic-hooks/user-prompt-submit-hook.sh"
          }
        ]
      }
    ],
    "PreToolUse": [
      {
        "hooks": [
          {
            "type": "command",
            "command": "~/bin/claude-utils/duration-logic-hooks/agent-in-progress-hook.sh"
          }
        ]
      }
    ],
    "PostToolUse": [
      {
        "hooks": [
          {
            "type": "command",
            "command": "~/bin/claude-utils/duration-logic-hooks/agent-in-progress-hook.sh"
          }
        ]
      }
    ],
    "Stop": [
      {
        "hooks": [
          {
            "type": "command",
            "command": "~/bin/claude-utils/duration-logic-hooks/finished-responding-hook.sh"
          }
        ]
      }
    ]
  },
  "statusLine": {
    "type": "command",
    "command": "bunx ccstatusline@latest",
    "padding": 0
  }
}

会話開始時の処理

user-prompt-submit-hook.shでは、プロンプト送信時に会話の開始時刻を記録する:

#!/bin/bash

session_data=$(cat)
session_id=$(echo "$session_data" | jq -r '.session_id // empty')

if [ -z "$session_id" ]; then
    exit 0
fi

tmp_file="${TMPDIR:-/tmp}/claude-code-duration-${session_id}.json"

# 割り込み検知の処理
check_interrupt_in_transcript() {
    local transcript_path="$1"
    
    if [ -z "$transcript_path" ] || [ ! -f "$transcript_path" ]; then
        return 1
    fi
    
    local last_line=$(grep -v '^$' "$transcript_path" | tail -n 1)
    local content=$(echo "$last_line" | jq -r '.message.content[0].text // .message.content // empty' 2>/dev/null)
    
    if [ "$content" = "[Request interrupted by user]" ]; then
        return 0
    fi
    
    return 1
}

current_timestamp=$(date -u +"%Y-%m-%dT%H:%M:%SZ")

if check_interrupt_in_transcript "$transcript_path" && [ -f "$tmp_file" ]; then
    # 割り込み後の継続
    existing_session_id=$(cat "$tmp_file" | jq -r '.sessionId')
    existing_start=$(cat "$tmp_file" | jq -r '.startTimestamp')
    existing_duration=$(cat "$tmp_file" | jq -r '.duration // 0')
    
    cat > "$tmp_file" << EOF
{
  "sessionId": "$existing_session_id",
  "startTimestamp": "$existing_start",
  "lastUpdate": "$current_timestamp",
  "duration": $existing_duration,
  "status": "interrupted"
}
EOF
else
    # 新規会話
    cat > "$tmp_file" << EOF
{
  "sessionId": "$session_id",
  "startTimestamp": "$current_timestamp",
  "lastUpdate": "$current_timestamp",
  "duration": 0,
  "status": "active"
}
EOF
fi

割り込み検知の部分が少し複雑だった。transcriptファイルを読んで最後の行に[Request interrupted by user]があるかチェックしている。

進行中の処理

agent-in-progress-hook.shでは、ツール実行前後に経過時間を更新する:

#!/bin/bash

session_data=$(cat)
session_id=$(echo "$session_data" | jq -r '.session_id // empty')

tmp_file="${TMPDIR:-/tmp}/claude-code-duration-${session_id}.json"

if [ ! -f "$tmp_file" ]; then
    exit 0
fi

existing_data=$(cat "$tmp_file")
start_timestamp=$(echo "$existing_data" | jq -r '.startTimestamp')

current_timestamp=$(date -u +"%Y-%m-%dT%H:%M:%SZ")

# ISO8601からUnixエポック秒への変換
start_epoch=$(date -j -f "%Y-%m-%dT%H:%M:%SZ" "$start_timestamp" +%s 2>/dev/null || date -d "$start_timestamp" +%s 2>/dev/null)
current_epoch=$(date -j -f "%Y-%m-%dT%H:%M:%SZ" "$current_timestamp" +%s 2>/dev/null || date -d "$current_timestamp" +%s 2>/dev/null)

duration=$(( (current_epoch - start_epoch) * 1000 ))

echo "$existing_data" | jq \
  --arg current_timestamp "$current_timestamp" \
  --argjson duration "$duration" \
  '.lastUpdate = $current_timestamp | .duration = $duration | .status = "active"' \
  > "$tmp_file"

時刻の扱いが面倒だった。ISO8601形式でUTCで記録して、計算時にUnixエポック秒に変換している。macOSLinuxでdateコマンドの使い方が違うので両対応した。

完了時の処理

finished-responding-hook.shでは、応答完了時に最終時刻を記録してstatusを"finished"に変更する。中身はagent-in-progress-hook.shとほぼ同じで、最後にstatusを変える部分だけ違う:

echo "$existing_data" | jq \
  --arg current_timestamp "$current_timestamp" \
  --argjson duration "$duration" \
  '.lastUpdate = $current_timestamp | .duration = $duration | .status = "finished"' \
  > "$tmp_file"

Statusline表示の実装

最初はStatuslineでだけTypeScriptで実装していた。後からHook機能を追加することにしたとき、Claude Codeに相談したらシェルスクリプトで書き始めたのでそのまま使った。深い意味はない。

TypeScriptで時間データを読み取って表示する部分:

#!/usr/bin/env bun

import { readFileSync, existsSync } from "fs";
import { tmpdir } from "os";
import { join } from "path";

interface StatuslineInput {
  session_id: string;
  cost: {
    total_duration_ms: number;
  };
}

interface DurationData {
  sessionId: string;
  startTimestamp: string;
  lastUpdate: string;
  duration: number;
  status: "active" | "finished" | "interrupted";
}

function parseStatuslineInput(input: string): StatuslineInput | null {
  try {
    return JSON.parse(input);
  } catch {
    return null;
  }
}

function readDurationData(sessionId: string): DurationData | null {
  try {
    const tmpFile = join(tmpdir(), `claude-code-duration-${sessionId}.json`);

    if (!existsSync(tmpFile)) {
      return null;
    }

    const content = readFileSync(tmpFile, "utf-8");
    const data = JSON.parse(content) as DurationData;

    return data;
  } catch {
    return null;
  }
}

function formatDuration(durationMs: number): string {
  const totalSeconds = Math.floor(durationMs / 1000);
  const hours = Math.floor(totalSeconds / 3600);
  const minutes = Math.floor((totalSeconds % 3600) / 60);
  const seconds = totalSeconds % 60;

  const parts: string[] = [];
  if (hours > 0) parts.push(`${hours}h`);
  if (minutes > 0) parts.push(`${minutes}m`);
  parts.push(`${seconds}s`);

  return parts.join(" ");
}

function formatDurationString(durationMs: number, status: string): string {
  const formattedTime = formatDuration(durationMs);
  const statusIcon = 
    status === "finished" ? "✅" : 
    status === "interrupted" ? "🗣️" : "💭";
  
  return `${statusIcon} ${formattedTime}`;
}

async function main() {
  try {
    let input = "";
    for await (const chunk of process.stdin) {
      input += chunk.toString();
    }

    const statuslineData = parseStatuslineInput(input);
    if (!statuslineData) {
      console.log("❌ Parse error");
      return;
    }

    const durationData = readDurationData(statuslineData.session_id);
    if (!durationData) {
      console.log(formatDurationString(0, "active"));
      return;
    }

    console.log(formatDurationString(durationData.duration, durationData.status));
  } catch {
    console.log("❌ Error occurred");
  }
}

if (import.meta.main) {
  await main();
}

ccstatuslineからセッション情報がJSON形式でstdinに渡されてくるので、それを解析してsession_idを取得し、対応する一時ファイルを読み取って時間を表示する。

ステータスに応じてアイコンを変えた: - 💭 進行中 - ✅ 完了 - 🗣️ 割り込み

実際の表示

Statuslineの設定ではbunx ccstatusline@latestを実行して、その中で上記のTypeScriptスクリプトが呼ばれる仕組みになっている。ccstatuslineは汎用のStatusline表示ツールで、カスタムコマンドも実行できる。

作ってみて分かったこと

Hook機能を使うことで、Claude Codeの動作に合わせて任意の処理を挟むことができた。一時ファイルを使った状態共有は単純だけど確実に動く。

時刻の扱いが一番面倒だった。UTCで統一してISO8601形式で保存し、計算時にUnixエポック秒に変換する方法に落ち着いた。

割り込み処理も考えていなかったが、transcriptファイルを見れば判定できることが分かった。これで中断後の再開時にも時間が引き継がれる。

Claude Codeは拡張性が高くて、こういう細かいカスタマイズができるのがいい。1回の会話時間が可視化されることで、どの程度の処理時間がかかっているかが把握しやすくなった。

とはいえ、これはハック的な実装なので、公式に会話時間の情報が提供されるようになったらいいなと思う。GitHubにも同様の要望が上がっているようで(Issue #5852)、将来的には標準機能として対応してもらえるかもしれない。