元ファイル:
2.1.139-unbundled/extracted/src/entrypoints/cli.js再現目標:Agent に**条件が満たされるまで継続して作業させる**。
実装原理をひとことでいうと:
/goal <cond>はcondをセッションレベルのStophook(type: "prompt")として登録する。Agent が turn を終了しようとするたびに、この hook が評価される。満たされていなければ、モデルの stop reason をフィードバックとして戻して作業を続けさせ、満たされたら自動で削除する。命名規約:それぞれの識別子が初めて登場するときに
/* 原始混淆名 */コメントを付け、元の bundle を追跡しやすくする。
1. データモデル
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. 定数と文言
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 & リソースアクセス
function getSessionId /* k_ */ (): string; // cli.js L2288
// ⚠ 注意:これは「累積 output tokens」であり、「総 tokens」や「context 使用量」ではない
function getOutputTokenCount /* Wf */ (): number; // cli.js L2409
3.2 進入ゲート
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 に変換する必要がある。
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 を特定できなければならない。これにより/goalはprompt === activeGoal.conditionを比較できる。
3.4 App State
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 メッセージ補助
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 ユーティリティ関数
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 フィードバック
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_added、tengu_stop_hook_removed、tengu_goal_achieved、tengu_goal_restored_on_resume、失敗プレフィックス goal_set:policy_gate。
4. コマンド登録(headless が関心を持つ local 形態)
// 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)
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)
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 つの経路だけを抜き出します。
// メインループが 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 が見つかった場合にのみ再構築する。それ以外の場合はクリアする。
// 直近の未達成 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. 既知の落とし穴 / 校正記録
getOutputTokenCountは output tokens のみ ——cur.tokensAtStartとgoal_status.tokensはいずれも「goal が設定されて以降の累積 output tokens」であり、context 使用量として見てはいけない。isNonInteractive()≠ remote client —— これは単に!g_.isInteractive。そのためコマンドの表示/有効化ルールは、非対話モードではデフォルト表示、非対話またはリモートワークスペースでは有効化、となる。- trust gate の意味 ——
checkGoalGateは!isNonInteractive() && !isWorkspaceTrusted()のときだけブロックする点に注意。つまり「対話モードかつワークスペースが信頼されていない」場合にのみ/goalをブロックし、非対話モードは trust の制限を受けない。 /goalと他の Stop hook の共存 ——listGoalStopHooksはmatcher === "" && skillRoot === undefined && type === "prompt"によってユーザー側の goal を識別し、settings から注入された Stop hook は削除しない。再実装時にこの組み合わせは変更不可。- sentinel は必ず transcript に保存する —— resume は完全に
findGoalToRestoreがgoal_status添付を逆順スキャンすることで hook を再登録すべきか判断している。
11. 他プロジェクトへ移植するための最小実装チェックリスト
依存順:
- Stop / SubagentStop hook サブシステム(最重要):
- レジストリの
add/remove(matcher+skillRoot次元をサポート)。 { type: "prompt", prompt }hook をサポート —— 発火時に小さな LLM 呼び出しを 1 回使って、その prompt が成立するか判定する。判定結果はhook_successまたはblockingErrorに変換する。- メインの query ループがこれらの結果を消費し、blocking のフィードバックを次ラウンドのシステムプロンプトとして渡す。
- レジストリの
- AppState フィールド
activeGoalと、それに対応するsetAppState/applyMessageOp入口。 - メッセージストリームの
goal_status添付タイプ。少なくとも transcript に永続化できること(レンダリングは必須ではない)。 /goalコマンド本体(§5 + §6)—— コピー&ペーストでよい。- セッション resume フロー(§8)—— 履歴 transcript を読み込んだ後に
restoreGoalFromTranscriptを 1 回呼び出す。 - ゲート(§6 の
checkGoalGate)—— MVP でも少なくとも trust gate は接続することを推奨。 - テレメトリ / Toast —— 任意。空の stub でよい。
12. シンボル対応早見表
| ドキュメント上の明確な名前 | 元の難読化名 | 定義位置 |
|---|---|---|
MAX_CONDITION_LENGTH | $oH | L517487 |
CLEAR_KEYWORDS | l23 | L517503 |
POLICY_GATE_MESSAGE | i23 | L517493 |
TRUST_GATE_MESSAGE | n23 | L517491 |
buildGoalDirective | uD6 | L517489 |
isClearKeyword | xD6 | L517416 |
formatLastCheckLine | hjK | L517434 |
checkGoalGate | Wu8 | L517445 |
listGoalStopHooks | mD6 | L517437 |
makeGoalSentinelMessage | EjK | L517478 |
setGoal | YoH | L517450 |
clearGoal | woH | L517465 |
handleGoalText | UV3 | L540182 |
findGoalToRestore | _mK | L601434 |
restoreGoalFromTranscript | Rg3 | L601443 |
getSessionId | k_ | L2288 |
getOutputTokenCount | Wf | L2409 |
isNonInteractive | Z8 | L2656 |
isRemoteWorkspace | V8 | L3076 |
isWorkspaceTrusted | q3 | L149640 |
isHooksDisabledByPolicy | eu | L255357 |
track | Q | L3256 |
trackWithCode | j6 | L43254 |
showToast | yH | L43248 |
pluralize | x6 | L9966 |
firstLine | s5 | L10026 |
formatDuration | r7 | L10804 |
formatTokenCount | F4 | L10844 |
getHooksForEvent | twH | L376651 |
installHook | iD_ | L376600 |
wrapAttachmentAsMessage | z9 | L423101 |
makeUserMessage | $6 | L450191 |
formatBlockingError | oV8 | L554731 |
コメント
コメントは即時公開されますが、ポリシー違反時は非表示になる場合があります。