~/field-notes — leeguoo@misonote — zsh EN 中文 ● 日本語
❯ field-notes v3.4.1
leeguoo@misonote:/ja/posts/goal-command-implementation/ $ 記事

# /goal コマンド実装の分析

Claude Code 2.1.139 headless 版のソースコードに基づき、/goal コマンドがセッションレベルの Stop hook を通じて、条件が満たされるまで Agent に作業を継続させる仕組みを分解します。

2026年5月12日 · 記事 · 公開 · 記事

このページの目次
1.1. データモデル2.2. 定数と文言3.3. Host-Framework 契約4.3.1 Session & リソースアクセス5.3.2 進入ゲート6.3.3 Session-scoped Hook レジストリ7.3.4 App State8.3.5 メッセージ補助9.3.6 ユーティリティ関数10.3.7 テレメトリ & UI フィードバック11.4. コマンド登録(headless が関心を持つ local 形態)12.5. 命令エントリポイント handleGoalText(cli.js L540182–540207)13.6. コアとなる set/clear ロジック(cli.js L517416–517504)14.7. ランタイム:Stop Hook フィードバックループ(cli.js L418198–418265)15.8. セッション復元:resume 時に goal を再び紐付ける(cli.js L601434–601456)16.9. エンドツーエンドの時系列17.10. 既知の落とし穴 / 校正記録18.11. 他プロジェクトへ移植するための最小実装チェックリスト19.12. シンボル対応早見表

もとファイル:2.1.139-unbundled/extracted/src/entrypoints/cli.js

再現さいげん目標もくひょう:Agent に**条件じょうけんたされるまで継続けいぞくして作業さぎょうさせる**。

実装じっそう原理げんりをひとことでいうと:/goal <cond>cond をセッションレベルの Stop hook(type: "prompt")として登録とうろくする。Agent が turn を終了しゅうりょうしようとするたびに、この hook が評価ひょうかされる。たされていなければ、モデルの stop reason をフィードバックとしてもどして作業さぎょうつづけさせ、たされたら自動じどう削除さくじょする。

命名めいめい規約きやく:それぞれの識別子しきべつしはじめて登場とうじょうするときに /* 原始混淆名 */ コメントをけ、もとの bundle を追跡ついせきしやすくする。


1. データモデル

$ ts
type ActiveGoal = {
  condition: string;        // ユーザーが入力した目標条件
  iterations: number;       // 未達成回数(Stop hook が1回遮断するたびに +1)
  setAt: number;            // Date.now() の作成時刻
  tokensAtStart: number;    // 作成時点の「累計出力 token 数」(⚠ output tokens のみ、§10 参照)
  lastReason?: string;      // 前回モデルが出した stop reason
};

type GoalStatusAttachment = {
  type: "goal_status";
  met: boolean;
  condition: string;
  sentinel?: boolean;       // true = ユーザー態の set/clear アンカー;UI はレンダリングしないが、resume フローで検索される
  reason?: string;          // 未達成時の stop reason
  iterations?: number;      // 達成時の統計
  durationMs?: number;
  tokens?: number;          // 期間中に消費した output tokens
};

appState.activeGoal の初期値は undefined(cli.js L456934)。

2. 定数と文言

$ ts
const MAX_CONDITION_LENGTH /* $oH */ = 4000;

const CLEAR_KEYWORDS /* l23 */ = new Set([
  "clear", "stop", "off", "reset", "none", "cancel",
]);

const POLICY_GATE_MESSAGE /* i23 */ =
  "/goal is disabled by your organization's policy (disableAllHooks).";

const TRUST_GATE_MESSAGE /* n23 */ =
  "/goal is only available in trusted workspaces. " +
  "Restart, accept the trust dialog, and try again.";

// ⭐ goal 設定後に即座にモデルへ送られる実行時指示(そのまま写せばよい)
const buildGoalDirective /* uD6 */ = (condition: string): string =>
  `A session-scoped Stop hook is now active with condition: "${condition}". ` +
  `Briefly acknowledge the goal, then immediately start (or continue) working toward it ` +
  `— treat the condition itself as your directive and do not pause to ask the user what to do. ` +
  `The hook will block stopping until the condition holds. ` +
  `It auto-clears once the condition is met — do not tell the user to run \`/goal clear\` after success; ` +
  `that's only for clearing a goal early.`;

3. Host-Framework 契約

ターゲットプロジェクトは、以下の機能を公開している必要がある。/goal 自体は約 150 行だが、この契約レイヤーこそが実際の工数の中心となる。

3.1 Session & リソースアクセス

$ ts
function getSessionId /* k_ */ (): string;            // cli.js L2288

// ⚠ 注意:これは「累積 output tokens」であり、「総 tokens」や「context 使用量」ではない
function getOutputTokenCount /* Wf */ (): number;     // cli.js L2409

3.2 進入ゲート

$ ts
function isNonInteractive /* Z8 */ (): boolean;       // cli.js L2656 —— !isInteractive
function isRemoteWorkspace /* V8 */ (): boolean;      // cli.js L3076
function isWorkspaceTrusted /* q3 */ (): boolean;     // cli.js L149640
function isHooksDisabledByPolicy /* eu */ (): boolean;// cli.js L255357

3.3 Session-scoped Hook レジストリ

これは仕組み全体の中核である。Hook は type: "prompt" をサポートしなければならず、Stop hook が発火した際、フレームワークは LLM にその prompt が成立しているかを判定させ、その結果を success/blocking に変換する必要がある。

$ ts
type SessionHook =
  | { type: "prompt"; prompt: string }
  | { type: "function"; id: string; timeout: number; callback: Function; errorMessage?: string }
  | { type: "command"; command: string; /* … */ };

type HookEntry = {
  matcher: string;                    // 空文字列は「matcher なし」を表す → /goal は空文字列を使う
  skillRoot?: string;                 // /goal は undefined を使う(skill が注入した hook と区別するため)
  hooks: SessionHook[];
};

interface SessionHooksRegistry add(sessionId: string, event: "Stop"

// cli.js L376600 iD_ → OI7 を参照:upsert by (matcher, skillRoot)
function installHook /* iD_ */ (
  setAppState: AppStateSetter,
  sessionId: string,
  event: "Stop",
  matcher: string,
  hook: SessionHook,
): void;

// その session のその event 配下にあるすべての HookEntry を取り出す
function getHooksForEvent /* twH */ (
  state: AppState, sessionId: string, event: string,
): Map<string, HookEntry[]>;

重要なセマンティクス(cli.js L418198–418265 が依存する契約):

  • Stop hook 実行後、フレームワークは message stream 内で attachment を 1 つ yield する:
    • 条件成立 → { type: "hook_success", stdout?, stderr? }
    • 条件不成立 → blockingError: { blockingError: string } も併せて付与し、さらに b.stopReason に理由を入れる
  • 同一フレームの b.hook フィールドから、その { type: "prompt", prompt: cond } hook を特定できなければならない。これにより /goalprompt === activeGoal.condition を比較できる。

3.4 App State

$ ts
interface AppStateCtx {
  getAppState(): AppState;
  setAppState(updater: (s: AppState) => AppState): void;
  applyMessageOp(op: { type: "append"; messages: Message[] }): void;
  sessionHooksRegistry: SessionHooksRegistry;
  messages: Message[];
}

interface AppState activeGoal: ActiveGoal

3.5 メッセージ補助

$ ts
function wrapAttachmentAsMessage /* z9 */ (att: GoalStatusAttachment): Message; // cli.js L423101

function makeUserMessage /* $6 */ (opts: content: string): Message;                                                                    // cli.js L450191

function formatBlockingError /* oV8 */ (err: { blockingError: string }): string;
// 実装:return `Stop hook feedback:\n${err.blockingError}`                      // cli.js L554731

3.6 ユーティリティ関数

$ ts
function pluralize /* x6 */ (n: number, singular: string, plural?: string): string; // cli.js L9966
function firstLine /* s5 */ (text: string): string;        // 最初の \n で切る      // cli.js L10026
function formatDuration /* r7 */ (ms: number, opts: { mostSignificantOnly?: boolean }): string;
function formatTokenCount /* F4 */ (n: number): string;

3.7 テレメトリ & UI フィードバック

$ ts
function track /* Q */ (event: string, props: Record<string, unknown>): void;       // cli.js L3256
function trackWithCode /* j6 */ (event: string, code: string): void;                // cli.js L43254
function showToast /* yH */ (id: "goal_set" | "goal_met"): void;                    // cli.js L43248

/goal が発火するイベント:tengu_stop_hook_addedtengu_stop_hook_removedtengu_goal_achievedtengu_goal_restored_on_resume、失敗プレフィックス goal_set:policy_gate


4. コマンド登録(headless が関心を持つ local 形態)

$ ts
// cli.js L540226–540237
const goalCommandNonInteractive /* gV3 */ = {
  type: "local" as const,
  name: "goal",
  supportsNonInteractive: true,
  thinClientDispatch: "post-text" as const,
  description: "Set a goal — keep working until the condition is met",
  argumentHint: "[<condition> | clear]",
  get isHidden() { return !isNonInteractive(); },
  isEnabled:  () => isNonInteractive() || isRemoteWorkspace(),
  load: () => Promise.resolve({ call: handleGoalText }),
};

⚠ 訂正:isNonInteractive() ≠ 「リモートクライアント」。これは単に !g_.isInteractive。このコマンドは非インタラクティブモードではデフォルトで表示され、非インタラクティブまたはリモートワークスペースの場合に有効化される。


5. 命令エントリポイント handleGoalText(cli.js L540182–540207)

$ ts
type GoalReply =
  | { type: "text";  value: string }
  | { type: "query"; value: string; prompt: string };

async function handleGoalText /* UV3 */ (input: string, ctx: AppStateCtx): Promise<GoalReply> {
  const q = input.trim();

  // (1) 空入力 → 現在の状態を表示
  if (q === "") {
    const goal = ctx.getAppState().activeGoal;
    if (!goal) return { type: "text", value: "No goal set. Usage: `/goal <condition>`" };

    const turns = goal.iterations === 0
      ? "not yet evaluated"
      : `${goal.iterations} ${pluralize(goal.iterations, "turn")}`;
    const tail = goal.lastReason ? `\n${formatLastCheckLine(goal.lastReason)}` : "";
    return { type: "text", value: `Goal active: ${goal.condition} (${turns})${tail}` };
  }

  // (2) clear/stop/off/reset/none/cancel → クリア
  if (isClearKeyword(q)) {
    const cleared = clearGoal(ctx);
    return { type: "text", value: cleared === null ? "No goal set" : `Goal cleared: ${cleared}` };
  }

  // (3) 文字数上限
  if (q.length > MAX_CONDITION_LENGTH) {
    trackWithCode("goal_set", "too_long");
    return {
      type: "text",
      value: `Goal condition is limited to ${MAX_CONDITION_LENGTH} characters (got ${q.length})`,
    };
  }

  // (4) 設定 → 失敗時はエラーテキストを返し、成功時は実行時指示付きの query を即時トリガー
  const errMsg = setGoal(q, ctx);
  if (errMsg !== null) return { type: "text", value: errMsg };
  return { type: "query", value: `Goal set: ${q}`, prompt: buildGoalDirective(q) };
}

6. コアとなる set/clear ロジック(cli.js L517416–517504)

$ ts
function isClearKeyword /* xD6 */ (s: string): boolean {
  return CLEAR_KEYWORDS.has(s.toLowerCase());
}

function formatLastCheckLine /* hjK */ (reason: string): string {
  return `Last check: ${firstLine(reason.trim())}`;
}

// 受け入れゲート
function checkGoalGate /* Wu8 */ (): message: string; code: "policy_gate" | null {
  if (isHooksDisabledByPolicy()) return { message: POLICY_GATE_MESSAGE, code: "policy_gate" };
  if (!isNonInteractive() && !isWorkspaceTrusted()) {
    return { message: TRUST_GATE_MESSAGE, code: "trust_gate" };
  }
  return null;
}

// このセッション内の「/goal によって注入された」Stop hooks をすべて列挙
function listGoalStopHooks /* mD6 */ (state: AppState, sessionId: string): SessionHook[] {
  const out: SessionHook[] = [];
  for (const entry of getHooksForEvent(state, sessionId, "Stop").get("Stop") ?? []) if (entry.matcher !== ""
  return out;
}

// sentinel 添付メッセージを構築(UI では描画されず、resume のアンカーとしてのみ使われる)
function makeGoalSentinelMessage /* EjK */ (met: boolean, condition: string): Message {
  return {
    type: "attachment",
    uuid: crypto.randomUUID(),
    timestamp: new Date().toISOString(),
    attachment: { type: "goal_status", met, sentinel: true, condition },
  };
}

// goal を設定 —— 成功時は null、失敗時はユーザーに表示するエラーテキストを返す
function setGoal /* YoH */ (condition: string, ctx: AppStateCtx): string | null {
  const gate = checkGoalGate();
  if (gate !== null) {
    trackWithCode("goal_set", gate.code);
    return gate.message;
  }

  const sessionId = getSessionId();

  // 同時に許可する goal hook は 1 つだけ:まず古いものを消す
  for (const old of listGoalStopHooks(ctx.getAppState(), sessionId)) {
    ctx.sessionHooksRegistry.remove(sessionId, "Stop", old);
  }

  // ⭐ condition を prompt 型 Stop hook として注入
  ctx.sessionHooksRegistry.add(
    sessionId, "Stop", "",
    { type: "prompt", prompt: condition },
  );

  const goal: ActiveGoal = {
    condition,
    iterations: 0,
    setAt: Date.now(),
    tokensAtStart: getOutputTokenCount(),
  };
  ctx.setAppState(s => ({ ...s, activeGoal: goal }));
  ctx.applyMessageOp({ type: "append", messages: [makeGoalSentinelMessage(false, condition)] });

  track("tengu_stop_hook_added", { promptLength: condition.length, via: "goal" });
  showToast("goal_set");
  return null;
}

// goal をクリア —— 存在しなければ null、存在すればクリアされた condition を返す
function clearGoal /* woH */ (ctx: AppStateCtx): string | null {
  const sessionId = getSessionId();
  const existing = listGoalStopHooks(ctx.getAppState(), sessionId);
  if (existing.length === 0) return null;

  const cleared = (existing[0] as { type: "prompt"; prompt: string }).prompt;
  for (const h of existing) ctx.sessionHooksRegistry.remove(sessionId, "Stop", h);

  ctx.setAppState(s => (s.activeGoal === undefined ? s : { ...s, activeGoal: undefined }));
  ctx.applyMessageOp({ type: "append", messages: [makeGoalSentinelMessage(true, cleared)] });

  track("tengu_stop_hook_removed", { via: "goal" });
  return cleared;
}

7. ランタイム:Stop Hook フィードバックループ(cli.js L418198–418265)

ここが、keep working until … が実際に効力を発揮する場所です。以下では、メイン query ループ内の goal に関係する 2 つの経路だけを抜き出します。

$ ts
// メインループが hook から生成された message stream を消費する
for await (const frame of hookStream) {
  // ヘルパー関数:frame 上の hook 記述子を、現在レジストリに登録されている hook に対応付ける
  // (Z = (b) => { … }、cli.js L418182 に対応)
  const matchedHook = (frame.hook && hookStillRegistered(frame.hook)) ? frame.hook : undefined;

  // ── 成功パス:hook がブロックしなかった → goal 達成と見なす ──────────────────────────────
  if (frame.message?.type === "attachment") {
    const att = frame.message.attachment;
    const isStop = att.hookEvent === "Stop" || att.hookEvent === "SubagentStop";

    if (isStop && att.type === "hook_success" && matchedHook?.type === "prompt") {
      ctx.sessionHooksRegistry.remove(getSessionId(), "Stop", matchedHook);
      const cur = ctx.getAppState().activeGoal;

      if (cur?.condition === matchedHook.prompt) {
        const iterations = cur.iterations + 1;
        const durationMs = Date.now() - cur.setAt;
        const tokens     = getOutputTokenCount() - cur.tokensAtStart;

        yield { type: "active_goal", value: undefined };       // 状態をクリア
        yield wrapAttachmentAsMessage({
          type: "goal_status",
          met: true,
          condition: matchedHook.prompt,
          reason: frame.stopReason,
          iterations, durationMs, tokens,
        });

        track("tengu_goal_achieved", {
          promptLength: matchedHook.prompt.length, iterations, durationMs, tokens,
        });
        showToast("goal_met");
      }
    }
  }

  // ── 失敗パス:hook がブロック → 未達成。reason をモデルへフィードバックして作業を継続 ──────────
  if (frame.blockingError) {
    // この meta メッセージは、次の LLM 呼び出しにシステムプロンプトとして注入される
    const feedback = makeUserMessage({
      content: formatBlockingError(frame.blockingError),   // "Stop hook feedback:\n<reason>"
      isMeta: true,
    });
    yield feedback;

    const cur = ctx.getAppState().activeGoal;
    if (matchedHook?.type === "prompt" && cur?.condition === matchedHook.prompt) {
      yield {
        type: "active_goal",
        value: { ...cur, iterations: cur.iterations + 1, lastReason: frame.stopReason },
      };
      yield wrapAttachmentAsMessage({
        type: "goal_status",
        met: false,
        condition: matchedHook.prompt,
        reason: frame.stopReason,
      });
    }
  }
}

重要な依存関係(再度強調。§3.3 で既出):あなたの Stop hook サブシステムは type: "prompt" をサポートしている必要があります。フレームワークは小さな LLM 呼び出しを 1 回使って、その prompt が成立しているかを判定し、それに基づいて「通過させる(hook_success)」か「ブロックする(blockingError)」かを選択します。現在のプロジェクトが shell/function 型 hook しかサポートしていない場合、これは先に補完すべき前提作業です。


8. セッション復元:resume 時に goal を再び紐付ける(cli.js L601434–601456)

transcript の末尾で未完了goal_status が見つかった場合にのみ再構築する。それ以外の場合はクリアする。

$ ts
// 直近の未達成 goal アンカーを探す(sentinel と失敗フィードバックを含む)
export function findGoalToRestore /* _mK */ (messages: Message[] | undefined): string | null {
  if (!messages) return null;
  for (let i = messages.length - 1; i >= 0; i--) const m = messages[i];
    if (m?.type !== "attachment"
  return null;
}

export function restoreGoalFromTranscript /* Rg3 */ (
  messages: Message[],
  setAppState: AppStateSetter,
): void {
  const condition = findGoalToRestore(messages);
  const gate = condition !== null ? checkGoalGate() : null;
  if (gate !== null) trackWithCode("goal_set", gate.code);

  if (condition === null || gate !== null) {
    setAppState(s => (s.activeGoal === undefined ? s : { ...s, activeGoal: undefined }));
    return;
  }

  installHook(setAppState, getSessionId(), "Stop", "",
              { type: "prompt", prompt: condition });

  setAppState(s => ({
    ...s,
    activeGoal: {
      condition,
      iterations: 0,
      setAt: Date.now(),
      tokensAtStart: getOutputTokenCount(),
    },
  }));

  track("tengu_goal_restored_on_resume", { promptLength: condition.length });
}

9. エンドツーエンドの時系列

user> /goal write a passing test for foo.ts
  └─ setGoal(cond, ctx)
       ├─ checkGoalGate() OK
       ├─ sessionHooksRegistry.add(sid, "Stop", "", { type:"prompt", prompt: cond })
       ├─ setAppState: activeGoal = { cond, iter:0, setAt, tokensAtStart }
       ├─ sentinel attachment を追加(met:false)
       └─ telemetry tengu_stop_hook_added
  └─ ただちに query をトリガーし、prompt = buildGoalDirective(cond)
       └─ モデル:"Acknowledged. Starting now…" + ツール呼び出し…

モデルが turn の終了を試みる
  └─ Stop hook が prompt を評価
       ├─ met=false → blockingError(reason)
       │    ├─ meta メッセージ "Stop hook feedback:\n<reason>" を yield
       │    ├─ activeGoal.iterations++, lastReason=reason
       │    ├─ goal_status{met:false, reason} を yield
       │    └─ モデルはこれに基づいて作業を続行
       └─ met=true  → hook_success
            ├─ sessionHooksRegistry.remove(...)
            ├─ activeGoal = undefined
            ├─ goal_status{met:true, iterations, durationMs, tokens} を yield
            ├─ telemetry tengu_goal_achieved
            └─ toast goal_met

user> /goal clear
  └─ clearGoal(ctx) → hook を削除 + activeGoal をクリア + sentinel(met:true) を追加

10. 既知の落とし穴 / 校正記録

  1. getOutputTokenCount は output tokens のみ —— cur.tokensAtStartgoal_status.tokens はいずれも「goal が設定されて以降の累積 output tokens」であり、context 使用量として見てはいけない。
  2. isNonInteractive() ≠ remote client —— これは単に !g_.isInteractive。そのためコマンドの表示/有効化ルールは、非対話モードではデフォルト表示、非対話またはリモートワークスペースでは有効化、となる。
  3. trust gate の意味 —— checkGoalGate!isNonInteractive() && !isWorkspaceTrusted() のときだけブロックする点に注意。つまり「対話モードかつワークスペースが信頼されていない」場合にのみ /goal をブロックし、非対話モードは trust の制限を受けない。
  4. /goal と他の Stop hook の共存 —— listGoalStopHooksmatcher === "" && skillRoot === undefined && type === "prompt" によってユーザー側の goal を識別し、settings から注入された Stop hook は削除しない。再実装時にこの組み合わせは変更不可
  5. sentinel は必ず transcript に保存する —— resume は完全に findGoalToRestoregoal_status 添付を逆順スキャンすることで hook を再登録すべきか判断している。

11. 他プロジェクトへ移植するための最小実装チェックリスト

依存順:

  1. Stop / SubagentStop hook サブシステム(最重要):
    • レジストリの add/removematcher + skillRoot 次元をサポート)。
    • { type: "prompt", prompt } hook をサポート —— 発火時に小さな LLM 呼び出しを 1 回使って、その prompt が成立するか判定する。判定結果は hook_success または blockingError に変換する。
    • メインの query ループがこれらの結果を消費し、blocking のフィードバックを次ラウンドのシステムプロンプトとして渡す。
  2. AppState フィールド activeGoal と、それに対応する setAppState/applyMessageOp 入口。
  3. メッセージストリームの goal_status 添付タイプ。少なくとも transcript に永続化できること(レンダリングは必須ではない)。
  4. /goal コマンド本体(§5 + §6)—— コピー&ペーストでよい。
  5. セッション resume フロー(§8)—— 履歴 transcript を読み込んだ後に restoreGoalFromTranscript を 1 回呼び出す。
  6. ゲート(§6 の checkGoalGate)—— MVP でも少なくとも trust gate は接続することを推奨。
  7. テレメトリ / Toast —— 任意。空の stub でよい。

12. シンボル対応早見表

ドキュメント上の明確な名前元の難読化名定義位置
MAX_CONDITION_LENGTH$oHL517487
CLEAR_KEYWORDSl23L517503
POLICY_GATE_MESSAGEi23L517493
TRUST_GATE_MESSAGEn23L517491
buildGoalDirectiveuD6L517489
isClearKeywordxD6L517416
formatLastCheckLinehjKL517434
checkGoalGateWu8L517445
listGoalStopHooksmD6L517437
makeGoalSentinelMessageEjKL517478
setGoalYoHL517450
clearGoalwoHL517465
handleGoalTextUV3L540182
findGoalToRestore_mKL601434
restoreGoalFromTranscriptRg3L601443
getSessionIdk_L2288
getOutputTokenCountWfL2409
isNonInteractiveZ8L2656
isRemoteWorkspaceV8L3076
isWorkspaceTrustedq3L149640
isHooksDisabledByPolicyeuL255357
trackQL3256
trackWithCodej6L43254
showToastyHL43248
pluralizex6L9966
firstLines5L10026
formatDurationr7L10804
formatTokenCountF4L10844
getHooksForEventtwHL376651
installHookiD_L376600
wrapAttachmentAsMessagez9L423101
makeUserMessage$6L450191
formatBlockingErroroV8L554731
← 前の記事
AI監査に向き合う:リバースエンジニアリングプロジェクトをどのように「合法化」できるか?
次の記事 →
補助的なコーディングから Agentic Coding へ:チームが AI プログラミングを本当に活用するには

コメント

コメントは即時公開されますが、ポリシー違反時は非表示になる場合があります。

最大 1000 文字。

    ⎇ main ● 0 errors · 0 warnings UTF-8 Markdown /ja/posts/goal-command-implementation/ © 2026 leeguoo