0
0

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?

superpowers がうるさいので、Hooks で「それ使う?」と聞かせることにした

0
Posted at

superpowers というプラグインを入れてから、Claude Code が妙に重くなりました。「この関数のバグ直して」と頼んだだけなのに、要件定義の対話が始まる。悪気はないんです。プラグインがそう指示されているだけで。ただ、こっちは3行直したいだけなので。
かといって外すと、大きめの設計をやるときに困る。使いたいときはある。使いたくないときが多いだけで。
というわけで「使う直前に聞いてもらう」仕組みを Hooks で作りました。作る過程で permissionDecision: "ask" が使い物にならないことが分かって、代わりに AskUserQuestion に逃がす形に落ち着いています。そのへんの回り道も含めて書きます。

何がうるさいのか

superpowersbrainstormingwriting-planstest-driven-development みたいな「進め方」のスキルを14個くれるプラグインです。設計をちゃんと段取りしてくれるので、大きい作業では素直にありがたい。

問題は発動条件です。このプラグイン、SessionStart フックで using-superpowers というスキルの全文を毎回セッションに注入してきます。中身がこれ。

<EXTREMELY-IMPORTANT>
If you think there is even a 1% chance a skill might apply to what you are doing,
you ABSOLUTELY MUST invoke the skill.

IF A SKILL APPLIES TO YOUR TASK, YOU DO NOT HAVE A CHOICE. YOU MUST USE IT.

This is not negotiable. This is not optional.
You cannot rationalize your way out of this.
</EXTREMELY-IMPORTANT>

1%。交渉の余地なし。合理化して逃げるな。

さらに親切なことに「言い訳リスト」まで付いていて、逃げ道が事前に塞がれています。

"This is just a simple question"  →  Questions are tasks. Check for skills.
"The skill is overkill"          →  Simple things become complex. Use it.
"I'll just do this one thing first" →  Check BEFORE doing anything.

思想としては分かります。AI が「これは簡単だから」と勝手に手順を飛ばすのを防ぎたいわけで。ただ運用してみると、しきい値が明らかに低すぎました。

まず「ask」で書いた。そして失敗した

Claude Code の PreToolUse フックは permissionDecision を返せます。allow / deny / ask の3択。

ask は「確認ダイアログを出す」なので、これだろうと。Skill ツールの tool_input.skill を見て superpowers: で始まったら聞く、という10行くらいのフックを書きました。

process.stdout.write(JSON.stringify({
  hookSpecificOutput: {
    hookEventName: 'PreToolUse',
    permissionDecision: 'ask',
    permissionDecisionReason: `Superpowers スキル「${skill}」を使おうとしています。`
  }
}));

単体テストは全部通りました。実際に動かしてダイアログも出ました。で、拒否したら。

The user doesn't want to proceed with this tool use.
The tool use was rejected.
STOP what you are doing and wait for the user to tell you how to proceed.

会話が止まりました。

「このスキルは要らない」と言いたかっただけなのに、「作業をやめろ」と解釈される。毎回「続けて」と打ち直すことになって、うるさいのを減らすはずが操作が増えました。本末転倒です。

拒否理由のテキストに「これは中断要求ではない、続行しろ」と書き足してみましたが、無駄でした。当たり前で、ツール呼び出しが却下された時点でターンが終わるのはフックより上のレイヤーの話なので。フックが何を言おうと関係ない。

PermissionDenied フックにも裏切られた

PermissionDenied というイベントがあるのを見つけて、これで救えると思いました。公式ドキュメントを読んだら2箇所で詰みました。

ひとつ、発火条件が違う。auto mode の分類器がツールを拒否したときに発火するもので、人間が手動でダイアログを拒否したケースは対象外です。

ふたつ、出せる出力が逆向き。用意されているフィールドが hookSpecificOutput.retry: true、つまり「再試行してよい」と伝えるためのものでした。欲しいのは「止まるな」なので、方向が違う。

PermissionDenied | No | Exit code and stderr are ignored because the denial already occurred. Use JSON hookSpecificOutput.retry: true to tell the model it may retry

ここで一度諦めかけました。確認ダイアログを出す限り、拒否で止まるのは避けられない。だとすると「聞く」と「止まらない」は両立しないのでは、と。

AskUserQuestion に逃がせばいい

行き詰まったところで気づきました。AskUserQuestion を使えばいいのでは、と。

AskUserQuestion は Claude がユーザーに選択肢を出すための組み込みツールです。重要なのはClaude 自身が呼ぶツールだという点。ユーザーの回答はツール結果として返ってくるだけなので、どの選択肢を選んでもターンは終わりません。

権限ダイアログの停止問題を、そもそも権限ダイアログを使わないことで回避する。灯台下暗しでした。

流れはこうなります。

Claude が Skill(superpowers:brainstorming) を呼ぶ
  ↓
ゲートが deny を返す(ダイアログは出ない = 止まらない)
  ↓ 拒否理由に「AskUserQuestion で聞け」と手順が書いてある
Claude が「brainstorming 使う?」と1問だけ聞く
  ├─ 使う   → 承認トークンを発行 → Skill を再呼び出し → 通過
  └─ 使わない → スキルなしでそのまま作業を続ける

denypermissionDecisionReason は Claude に返るので、ここを手順書として使うのがポイントです。「ブロックした」で終わらせず、「ブロックしたから次はこうしろ」まで書き切る。フックの出力を、人間向けのエラーメッセージじゃなくて AI 向けの指示書として設計する感覚です。

残る問題は、「使う」と答えられた後の再呼び出しをどう通すか。ここはワンショットの承認トークンにしました。承認時に ~/.claude/.superpowers-consent.json{skill, at} を書いて、ゲートは読んだ瞬間に必ず削除する。スキル名が一致していて5分以内なら通す。1回の承認が永続的な抜け穴にならないようにしています。

実装

フック本体

~/.claude/hooks/superpowers-gate.js

#!/usr/bin/env node
const fs = require('fs');
const path = require('path');
const os = require('os');

const CLAUDE_DIR = path.join(os.homedir(), '.claude');
const CONFIG_PATH = path.join(CLAUDE_DIR, 'superpowers-gate.json');
const CONSENT_PATH = path.join(CLAUDE_DIR, '.superpowers-consent.json');
const CONSENT_TTL_MS = 5 * 60 * 1000;
const PREFIX = 'superpowers:';

// AskUserQuestion の選択肢に「何が起きるか」を出すための一文
const NOTES = {
  'superpowers:brainstorming': '実装前の要件・設計の対話フェーズが丸ごと入る',
  'superpowers:writing-plans': '実装計画ドキュメントの作成工程が入る',
  'superpowers:executing-plans': '計画をレビューチェックポイント付きで段階実行する',
  'superpowers:subagent-driven-development': 'タスクごとにサブエージェントを起動する',
  'superpowers:dispatching-parallel-agents': '複数エージェントを並列起動する',
  'superpowers:test-driven-development': 'テストを先に書く工程を強制する',
  'superpowers:using-git-worktrees': '専用の git worktree を切って作業する',
  'superpowers:requesting-code-review': 'レビュー用サブエージェントを起動する',
  'superpowers:verification-before-completion': '完了前の検証工程を追加する',
  'superpowers:systematic-debugging': '仮説検証を段階的に回すデバッグ手順に入る',
};

function readConfig() {
  try {
    return JSON.parse(fs.readFileSync(CONFIG_PATH, 'utf8'));
  } catch {
    return {};
  }
}

// 承認トークンを発行する(一度きり・TTLつき)
function issueConsent(skill) {
  if (!skill) {
    process.stderr.write('usage: superpowers-gate.js --consent <skill>\n');
    process.exit(1);
  }
  fs.writeFileSync(CONSENT_PATH, JSON.stringify({ skill, at: Date.now() }), 'utf8');
  process.stdout.write(
    `承認を記録: ${skill}(一度きり / ${CONSENT_TTL_MS / 60000}分で失効)\n`
  );
  process.exit(0);
}

// 承認トークンがあれば消費して true。使い捨てなので必ず削除する
function consumeConsent(skill) {
  let token;
  try {
    token = JSON.parse(fs.readFileSync(CONSENT_PATH, 'utf8'));
  } catch {
    return false;
  }
  try {
    fs.unlinkSync(CONSENT_PATH);
  } catch {
    // 消せなくても続行する。TTL で失効するので滞留はしない
  }
  if (token.skill !== skill) return false;
  if (!Number.isFinite(token.at)) return false;
  return Date.now() - token.at <= CONSENT_TTL_MS;
}

function readStdin() {
  try {
    return fs.readFileSync(0, 'utf8');
  } catch {
    return '';
  }
}

// 何も出力しない = 通常のパーミッション判定に委ねる
function passthrough() {
  process.exit(0);
}

// Claude に返す手順書。deny なのでダイアログは出ず、ターンも終わらない
function buildReason(skill) {
  const note = NOTES[skill];
  const lines = [
    `Superpowers スキル「${skill}」の自動起動をゲートがブロックした。`,
    'これは作業の中断要求ではない。停止してユーザーの指示を待ってはいけない。',
    '',
  ];
  if (note) lines.push(`このスキルの中身: ${note}。`, '');
  lines.push(
    '次のどちらかを行うこと:',
    '',
    'A) ユーザーが今回このスキル(または相当する工程)を明示的に指名していた場合',
    '   → 承認コマンドを実行し、そのうえで Skill を再呼び出しする。改めて質問しない。',
    '',
    'B) そうでない場合(Claude の判断で呼ぼうとした場合)',
    `   → AskUserQuestion で「${skill} を使うか」を1問だけ聞く。`,
    '     ・「使う」  → 承認コマンドを実行し、そのうえで Skill を再呼び出しする',
    '     ・「使わない」→ スキルなしで元のタスクをそのまま続行する',
    '',
    '承認コマンド:',
    `  node ${__filename} --consent ${skill}`,
    '',
    '禁止事項: 停止してユーザーの指示を待つ / 別の superpowers スキルで代替する /',
    '「スキルが使えないので実行できません」と報告する。'
  );
  return lines.join('\n');
}

function main() {
  if (process.argv[2] === '--consent') issueConsent(process.argv[3]);

  if (process.env.SUPERPOWERS_GATE === 'off') passthrough();

  const config = readConfig();
  if (config.mode === 'off') passthrough();

  let payload;
  try {
    payload = JSON.parse(readStdin());
  } catch {
    passthrough();
  }

  if (payload.tool_name !== 'Skill') passthrough();

  const skill = payload?.tool_input?.skill;
  if (typeof skill !== 'string' || !skill.startsWith(PREFIX)) passthrough();

  const allow = Array.isArray(config.allow) ? config.allow : [];
  if (allow.includes(skill)) passthrough();

  // ユーザーが「使う」と答えた直後だけトークンが存在する
  if (consumeConsent(skill)) passthrough();

  process.stdout.write(
    JSON.stringify({
      hookSpecificOutput: {
        hookEventName: 'PreToolUse',
        permissionDecision: 'deny',
        permissionDecisionReason: buildReason(skill),
      },
    })
  );
  process.exit(0);
}

main();

判断に迷わないところは全部フェイルオープンにしています。JSON のパースに失敗した、Skill 以外のツールだった、superpowers: 以外のスキル名だった。どれも黙って exit 0 で素通り。フックがコケて作業全体が止まるのが一番まずいので、分からないときは通す方に倒しました。

オフスイッチも最初に用意しました。環境変数 SUPERPOWERS_GATE=off と設定ファイルの {"mode": "off"} の2系統。自分で仕掛けた罠に自分でかかる未来が見えたので。

あと地味に効いているのが NOTES です。ここの一文がそのまま AskUserQuestion の選択肢の説明文になります。「brainstorming 使う?」だけ聞かれても判断できませんが、「実装前の要件・設計の対話フェーズが丸ごと入る」と添えてあれば判断できる。

配線

~/.claude/settings.json

{
  "hooks": {
    "PreToolUse": [
      {
        "matcher": "Skill",
        "hooks": [
          {
            "type": "command",
            "command": "node \"/Users/you/.claude/hooks/superpowers-gate.js\"",
            "timeout": 5,
            "statusMessage": "Superpowers ゲート確認中"
          }
        ]
      }
    ]
  }
}

承認コマンドが毎回パーミッション確認で止まったら意味がないので、そこだけ許可しておきます。範囲は --consent サブコマンドに限定。

{
  "permissions": {
    "allow": [
      "Bash(node /Users/you/.claude/hooks/superpowers-gate.js --consent:*)"
    ]
  }
}

CLAUDE.md 側のポリシー

フックだけだと足りません。deny を受けた Claude が「拒否された = 作業できない」と解釈して止まる可能性があるので、指示レイヤーでも書いておきます。

~/.claude/CLAUDE.md からインポート。

@superpowers-policy.md

~/.claude/superpowers-policy.md の要点だけ抜くとこうです。

## 原則: 制御点はゲート1箇所だけ

Claude 側で先回りして抑え込まない。
superpowers スキルが効きそうだと判断したら、これまで通り Skill を呼びに行く。

「過剰かどうか」を決めるのはユーザーであって、Claude が事前に諦めることではない。
Claude が呼ぶのをやめると、ゲートに届かない → 質問も出ない → 選択肢が消える。

## ゲートに deny されたときの手順

止まらないこと。ユーザーの指示を待たない。

A) ユーザーが明示的に指名していた場合
   → 改めて聞かない。承認コマンド → Skill 再呼び出し

B) Claude の判断で呼ぼうとした場合
   → AskUserQuestion で1問だけ聞く
     「使う」   → 承認コマンド → Skill 再呼び出し
     「使わない」→ スキルなしで元のタスクを続行

ここで都合がいいのは、superpowers 自身が優先順位を明文化していることです。

  1. User's explicit instructions (CLAUDE.md, GEMINI.md, AGENTS.md, direct requests) — highest priority
  2. Superpowers skills — override default system behavior where they conflict
  3. Default system prompt — lowest priority

CLAUDE.md からロードされる指示が第1位。つまりこのポリシーは、superpowers 自身のルールに則って正当に上書きしています。ハックじゃなくて仕様通りです。ここは作者の設計が誠実で、正直ちょっと感心しました。

一番ハマったところ

ここが本題かもしれません。

完成したと思って数時間動かしていたら、superpowers が一度も発火しなくなっていることに気づきました。ゲートが仕事をしすぎたのかと思ってフックを疑いましたが、単体テストは全部通る。オフスイッチも誤爆していない。

犯人はポリシーの1行目でした。最初こう書いていたんです。

## 原則
superpowers スキルは既定で使わない。依頼の重さに見合うときだけ、確認を経て使う。

これで抑制が二段になっていました。上段(ポリシー)で Claude がそもそも Skill を呼ばなくなるので、下段(ゲート)に到達しない。ゲートに届かないから AskUserQuestion も出ない。結果、選択肢そのものが消える

「過剰を減らす」つもりが「機能停止」になっていました。しかも静かに死ぬので気づきにくい。エラーが出るわけでもなく、ただ superpowers の話題が出なくなるだけなので。

教訓は制御点は1箇所に集約する、これに尽きます。ゲートで止めると決めたなら、指示レイヤーでは逆に「遠慮せず呼びに行け」と書く。両方で抑えると、ユーザーに選択が届く経路が塞がれます。

直したあとのポリシーは、むしろ発火を促す方向に振ってあります。

挨拶・一行の質問・ファイルを1つ読むだけ  → 呼ばない
小さな修正・単発の調査                   → 基本は呼ばない。迷ったらゲートに投げる
「作って」「実装して」など多段階の作業    → 呼びに行く。ユーザーが決める
不可逆・影響範囲が広い作業               → 必ず呼びに行く
ユーザーが明示指名                       → 即使う(質問を挟まず承認 → 再呼び出し)

抑える役はゲートに全部やらせて、Claude 側は今まで通り提案してこい、という分担です。

検証

フックは stdin に JSON を流すだけなので、シェルで一通り叩けます。CI に載せるほどのものでもない。

H=~/.claude/hooks/superpowers-gate.js
SP='{"tool_name":"Skill","tool_input":{"skill":"superpowers:brainstorming"}}'
rm -f ~/.claude/.superpowers-consent.json

# 承認なし → deny
echo "$SP" | node "$H" | jq -r '.hookSpecificOutput.permissionDecision'
#=> deny

# 承認 → 通過
node "$H" --consent superpowers:brainstorming
echo "$SP" | node "$H"          #=> 出力なし(= 通過)

# 使い捨てになっているか(2回目は再び deny)
echo "$SP" | node "$H" | jq -r '.hookSpecificOutput.permissionDecision'
#=> deny

# 別スキルの承認では通らないか
node "$H" --consent superpowers:writing-plans > /dev/null
echo "$SP" | node "$H" | jq -r '.hookSpecificOutput.permissionDecision'
#=> deny

# 対象外は全部素通り
echo '{"tool_name":"Skill","tool_input":{"skill":"gsd:plan-phase"}}' | node "$H"
echo '{"tool_name":"Bash","tool_input":{"command":"ls"}}' | node "$H"
echo "$SP" | SUPERPOWERS_GATE=off node "$H"
echo 'not json' | node "$H"

settings.json は壊すと全設定が黙って無効化されるので、書き換えたら必ず検証してください。ここも静かに死ぬタイプです。

jq -e '.hooks.PreToolUse[] | select(.matcher == "Skill") | .hooks[] | .command' \
  ~/.claude/settings.json

exit 0 でコマンドが表示されれば OK。exit 5 なら JSON が壊れているか入れ子が違います。

まとめ

permissionDecision: "ask" は「拒否 = ターン終了」です。選択肢の片方が作業停止を意味するので、「やるかやらないか選ばせたい」用途には使えません。ここはドキュメントに書いといてほしかった。

PermissionDenied フックも救済にはなりません。auto mode 専用で、出せるのは retry だけです。

代わりに deny を返して、拒否理由を Claude 向けの手順書として使い、選択を AskUserQuestion に逃がす。Claude 自身が呼ぶツールなので、どちらを選んでもターンが終わりません。再呼び出しはワンショットの承認トークンで通します。

そして、抑制の制御点は1箇所に。フックと指示の両方で抑えると機能が死にます。これは自分でやらかしました。

この構造自体は superpowers 固有のものではありません。Skill に限らず、任意のツールについて「AI の自動判断を、止まらない形で人間の選択に変換する」型として使えます。エージェントに主導権は渡しておきたい、でも要所は握りたい。そういうときに。

組んだばかりなので運用実績はまだこれからですが、「プラグインを外すか、うるさいのを我慢するか」の二択からは抜けられました。当面はこれで回してみます。

調整方法

# 一時的に無効化
SUPERPOWERS_GATE=off claude
// ~/.claude/superpowers-gate.json
{
  "mode": "off",                                  // 恒久的に無効化
  "allow": ["superpowers:systematic-debugging"]   // 特定スキルだけ素通り
}

参考

0
0
0

Register as a new user and use Qiita more conveniently

  1. You get articles that match your needs
  2. You can efficiently read back useful information
  3. You can use dark theme
What you can do with signing up
0
0

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?