Skip to content

v0.26.0

Choose a tag to compare

@Kewton Kewton released this 20 Aug 15:40
· 37 commits to main since this release
21a41db

並列オーケストレーションの判定を壊す 3 つの穴(ポート衝突・flake・上流障害)を製品側で塞いだ回です。いずれも BorderFreeKidsMap の実運転で定量的に代償が出たもので、commandmate-skills 側の版と対になっています。

package 版
cmate-verify 0.5.0
cmate-verify-advisor 0.3.0
cmate-orchestrate 0.32.0
cmate-issue-authoring 0.9.0

mutex / retryOnFail / flakyIsPass / requireEnvClean は 4 実装すべてが同じ集合を受理します(docs/design/verification-config.md §9.5)。


Added

  • chore(skills): vendored な cmate-verify を上流と揃え、mutex / retryOnFail / flakyIsPass / requireEnvClean を受理させる (#1861): v0.26.0 の verify.yaml parity は 3 リポジトリ位置・4 実装にまたがるが、#1771 / #1772 は .claude/skills/sync-map.json の sha256 pin を壊さないため vendored copy を意図的に触っておらず、同じ verify.yaml が製品側(commandmate verify)では exit 0、vendored ランナー(.claude/skills/cmate-verify/scripts/verify-run.sh)では exit 2 に割れていた(実測 2026-08-20: unknown gate key: retryOnFail / flakyIsPass / mutex / unknown options key: requireEnvClean の 4 件で設定エラー)。上流 Kewton/commandmate-skills PR #225(6faa33f、skills #223 / #224)の verify-run.sh(554 → 963 行)を cmp でバイト一致する逐語コピーとして取り込み、.agents/skills/cmate-verify/ へもミラーした(同 verify.yaml の再実測で vendored も exit 0 / GATE e2e PASS exit=0 duration=0s waited=0s / GATE env-clean SKIP reason=no-baseline / RESULT passed)。SKILL.md は policy が port-required なので逐語コピーせず、キー表・ロック規約(~/.commandmate/locks/<name>.lock / mkdir / owner の pid・host・token / CM_VERIFY_LOCK_ROOT)・waited を duration に足さない契約・CM_WORKTREE_INDEX を standalone 側は設定しない理由を、CommandMate 側の Issue 番号(#1771 / #1772 / #1740)と相対パスに読み替えて書き直した。fixture は counterpart(tests/fixtures/cmate-verify/fixtures/)から 18 本を追加し、suite の assertion は 200 → 317(MIN_ASSERTIONS は bash 側・vitest ラッパ側とも 300 に引き上げ、新セクションが黙って落ちても緑にならないようにした)。counterpart は fixture と run-tests.sh を package の外に置くため skills-sync-map.mjs check --counterpart が scripts/tests/** を MISSING と報告するが、これは構造上の既知差(vendored は Node の無い導入先でも suite を回せるよう同梱する)であり、その旨を sync-map のrationale に明記した
  • feat(cli): wait が「ターンが成立したか」を見るようにする (#1839): 上流 API 障害でエージェントが何も実行せず composer に戻ると、wait は exit 0 を返し --verify は work-evidence ゼロ(exit 21)を返すため、呼び出し側が「ターンは成立したが成果物が無い」と誤読していた(実測 #1834: 529 × 13 に対し exit 21 が 12 回)。隔離環境の実測(2026-08-20 / stub 529 + 実 claude 2.1.236 / 実 API 不使用)で、崩れたターンでは Stop hook が1 度も届かない一方 Notification(idle_prompt) は +62 秒で届くことを確認し、Stop のみをターン終端とする判定を追加した(hooks が来ていないインスタンスの挙動は不変)。あわせて上流障害の署名をsrc/lib/detection/upstream-faults.ts に集約して capture --json の upstreamFault として公開し(canary の重複定義を解消)、opt-in の wait --fail-on-upstream-fault が exit 11 を返すようにした。wait の完了行には判定根拠(basis=hook_stop/session_gone/scraper_ready)が付く
  • feat(verification): FLAKY を一級の outcome にする(gate 単位 opt-in の同一 tree 再実行) (#1772): ランナーの結果は PASS / FAIL しか無く、「この 1 件だけ赤ならまず再実行」は人間の部族知識だった(実測 2026-08-10 / Kewton/BorderFreeKidsMap: unit ゲートの禁止語検査 not.toContain("fac-") が乱数 UUID の 9fac- に一致して failし、同一 tree で再実行したら pass = 1 fail 52 pass → 53 pass)。オーケストレーション配下ではワーカーもオペレータも赤の原因を自分の変更に求めて時間を焼く。gate 単位の opt-in retryOnFail: 1 を追加し、fail したゲートを同一 tree でもう 1 回だけ再実行して、fail→pass を FLAKY として両ランの exit code と duration ごと記録するようにした。値域は 0 か 1 のみ(2 以上は設定エラー = 十分な回数を回せばどんな赤も緑になるので上限そのものが機能の中身)、2 回とも fail は FAIL のまま、再実行するのは非ゼロ終了だけ(TIMEOUT は予算を使い切っており 2 回目が実時間を倍にする/mutex 待ちの SKIP と起動失敗はコマンドが 1 度も走っていない)、2 回目が裁定に到達しなければ1 回目の FAIL が立つ(2 回目を採ると work を裁定したゲートが exit 99 に化けて判定が弱くなる)。裁定上の扱いは flakyIsPass で選び、既定は「FLAKY は fail 扱い」= ゲートは 1 bit も弱くならない(retryOnFail: 1 が買うのは「何が起きたか」に名前が付くことだけ)。flakyIsPass は gate 単位とした(skills #224 はこちらを正とすること) — retryOnFail が gate 単位である以上ゲート宣言 1 つで裁定まで読めるべきであり、unit の乱数 fail と e2e の実レースを1 つの答えに強制せずに済み、options 単位だと決して発火しない宣言(retryOnFail 無しのゲートに対する宣言)が正当な設定として通ってしまうため。flakyIsPass: true を retryOnFail: 1 無しで書くのは設定エラー。verification_gate_results に列が無く DB マイグレーションは scope 外なので、両ランの数値は #1771 の waited と同じ**log_tail の行頭アンカー** [flaky] runs=2 outcome=flaky|fail exit=1,0 duration=45.0s,44.0s verdict=pass|fail で運ぶ(outcome=fail でもアンカーを書く = 2 回落ちは flakiness への反証であり advisor の分母。両ランのログ全文も残す = 「2 回で何が違ったか」がこの機能の唯一の問い)。列に入るのはその裁定を出したランの status/exit なので status=failed の隣に exit=0 が並ぶ行は作らず、duration_ms は両ランの和で #1625 の finished_at - started_at === duration_ms も不変。GATE 行の確定綴りは docs/design/verification-config.md §9.3 の表(standalone GATE unit FLAKY exit=1,0 duration=45s,44s / CommandMate GATE unit FLAKY (exit=1,0, 45.0s,44.0s)。flakyIsPass の値で綴りは変わらない — pass と数えた FLAKY を PASS と綴ると本機能が可視化する唯一の事実が消える)で、仕様は同 §10。verify show と verify --json / verify show --json(gates[].flaky)から履歴として読み戻せる(verify history の一覧行はゲート要約に log_tail を含まない設計のため出ない)。retryOnFail を宣言しないゲートの出力は 1 バイトも変わらない
  • feat(verification): ゲートに worktree ごとの env 注入と gate 単位の mutex を宣言できるようにする (#1771): verify.yaml のゲートはコマンドと timeout しか宣言できず、「このゲートはマシン上で同時に 1 つしか走れない」(固定ポート・ローカル DB・エミュレータ)を表現できなかった。並列 worktree で重なると後発が資源衝突で即 fail し、記録は GATE e2e FAIL exit=1 だけ = 変更の欠陥と環境の衝突が区別できない(実測: BorderFreeKidsMap で planner が 9 wave を計算したのに e2e ゲートが 60303 番ポートを専有するため 16 回の直列 dispatch に開き直した)。対処は 2 段構えで、並列度を保てるのは 1 つ目だけ。(1) コマンド系ゲートの実行 env に CM_WORKTREE_ID / CM_WORKTREE_INDEX を注入し、E2E_PORT=$((60400+CM_WORKTREE_INDEX)) で衝突自体を無くせるようにした。CommandMate は worktree を採番していない(Issue 本文の前提は実測と異なる — id は basename 由来の TEXT 主キーで順序列も作成時刻も無く、唯一の並びは updated_at DESC)ため番号は新設のレジストリ ~/.commandmate/worktree-index/<n> が O_EXCL で払い出す(同じ worktree は毎回同じ番号/同時に走る 2 worktree は必ず別番号。ハッシュ案は 30 worktree で約 35% 衝突するため fallback に留めた)。(2) gates[].mutex: <name> を追加し、同名を宣言したゲートを ~/.commandmate/locks/<name>.lock(mkdir 方式。macOS に flock(1) が無い)でマシン全体で 1 つに直列化する。待ち時間は duration に足さず waited= として別に記録する(混ぜると timeout 調整と advisor の入力が歪む)。ロックが timeoutSec の間空かなければ TIMEOUT ではなく SKIP reason=mutex-wait で run は error(exit 99 = 判定不能)= 20(不合格)ではない。lock path 規約・GATE 行の綴り・両ランナーが受理すべきキー集合は docs/design/verification-config.md §9 に確定した形で記載(skills 側 #223 がこれを正として実装する)。mutex を宣言しないゲートの出力は 1 バイトも変わらない
  • feat(polling): プロンプト dedup のスキップを capture --json に露出する (#1695): 重複抑止で落としたプロンプトの累積回数と最終スキップ時刻を promptDedup(skippedCount / lastSkippedAt)として公開し、「プロンプトが出たはずなのに保存されていない」ときに dedup が原因か検出漏れ(#1676)かを CLI から判別できるようにした。あわせて response 側 dedup(isDuplicateResponse、#1268)に duplicate-response-skipped ログを追加(従来ログすら無かった)
  • feat(verify): scope ゲートに「何がどの allow パターンで許可されたか」の証跡を残す (#1841): scope ゲートは違反 path しか報告せず、allow が完全一致 path だった頃は「パターン=ファイル」で足りていたが、#1546 で src/** のような glob が正式化された後はその run で実際に何が許可されたのかが契約からも log からも読めなくなっていた。log_tail に admitted: 節を足し、合否を問わず「許可された変更 path ← それを許可した allow パターン」を残す。記録するのは宣言順で最初に一致したパターン(allow: ["src/**", "src/lib/**"] なら src/lib/a.ts は src/**。最後に一致を名指すと「消しても判定が変わらないルール」を読者に提示することになる)、allow に無いのに許可された path は (exempt: .commandmate/) / (exempt: contract path) と括弧付きで名乗り(契約を grep しても見つからないのが事実だから)、deny で落ちた path は admitted: に入らず out of scope: 側に拒否した deny パターンが付く(「revert する」と「allow を広げる」の切り分け)。両節とも 100 件(MAX_REPORTED_VIOLATIONS)で切り、切ったことを ... (+N more) と名乗る — 切り詰めは表示規則であり、判定は全ファイルに対して行われる(切った分の違反も exit に反映される)。admitted: を out of scope: より前に置いたのは、CLI が不合格ゲートの log を末尾 40 行しか表示しないため(後ろに置くと違反一覧とガイダンスが画面外へ流れる)。あわせて verify --json / verify show --json の scope ゲート結果に機械可読の scope(admitted: [{path, pattern}] / violations: [path] / totals: {changed, admitted, violations})を足した(既存フィールドは 1 つも変えていない)。totals を別に持つのは 2 配列がレポートと同じ 100 件で切れるためで、「scope 外が在るか」は violations.length ではなく totals.violations で見る。pass / fail の裁定は 1 バイトも変えていない: ScopeMatcher.isViolation() は新しい classify() の否定に委譲するので、判定と証跡が食い違う経路そのものが無く、既存の scope-gate テスト 47 件は無改変で緑
  • feat(cli): stop-pattern が何にマッチしたかを capture --json に露出する (#1694): --stop-pattern の発火は autoYes.stopReason で分かるが、何にマッチしたかはどの層にも出ておらず、ビルドログがパターン文字列を含んだだけの誤爆(#1678 A-5)と正当な停止を運用者が切り分けられなかった。マッチ行+前後 1 行の抜粋を autoYes.stopMatchedText として露出する。抜粋は文字数ではなく UTF-8 バイトで 400 バイトに切り詰め(日本語フレームは 1 文字 3 バイトのため)、切り詰めたときだけ末尾に …[truncated] を付ける。抜粋は発火時のみ記録し、expired や手動 disable では持ち越さない(起きていない発火として読まれるため)
  • test(hooks): Auto-Yes v2(PermissionRequest 裁定)の実 TUI 検証を canary シナリオとして固定する (#1847): #1724 の手動検証 3 項目のうち未記録だった 2 項目を、実 claude を回す検出カナリア(#1727)に permission-hook-allow / permission-hook-no-decision として固定した。本番の buildClaudeLaunchCommand が書いた --settings をカナリア内の受け口(127.0.0.1:0 の ephemeral ポート)へ向け、裁定は本体の resolvePermissionRequest をそのまま呼ぶ(DB を要する契約読み出しと allow 監査の 2 箇所だけ PermissionDecisionDeps で差し替え)。allow でダイアログが出ずツールが走ること・denyPatterns 一致の no-decision でダイアログが出て autoYes.lastSuppression に理由が載ることを、pane と本番同一 getter で組んだ structuredEvents の両方で確認する。非空振りは新フラグ --mutate-verdict(受け口が逆の裁定を返す)で証明する。あわせて Claude Code 2.1.236 で既定の permission mode が auto mode になったため承認ダイアログ自体が描画されなくなっていた問題に対処し、全カナリアセッションを --permission-mode manual で起動するようにした(2 本目以降のセッションだけが起動タイムアウトで落ちる形で表面化していた)

Changed

  • refactor(hooks): AgentEventSource の I/F 申し送り 5 件を裁定する (#1846): #1759 の抽象は 6 ツールを I/F 変更ゼロで受け止めたが、その過程で 5 件の申し送りが報告だけされて未裁定のまま残っていた。7 本目のツールが同じ回避策を書く前に、全件に採用/不採用を付けて docs/design/agent-event-source-interface.md §3.3 に残した(不採用の理由も残す — 同じ申し送りが 3 回目に来ないようにするため)。線は 1 本: 2 実装以上が独立に同じ回避策へ到達したものだけ I/F に入れる。採用 2 件: ①prepareLaunch の引数を AgentLaunchContext{target, executablePath, worktreePath} へ(worktreePath は必須)。gemini の injectGeminiHookSettings() 別 export(#1762 の回避策。cli-tools/gemini.ts が 2 回呼んでいた)が消え、6 ソースとも設定書き出しが prepareLaunch 1 箇所に揃う。AgentInstanceRef はキーなので 3 フィールドのまま ②AgentLaunchPlan.env(必須)と renderAgentLaunchCommand。codex / copilot / gemini / antigravity の4 実装が独立に NAME=value を command へ前置しており(宣言されていない前提=「起動側はシェルである」に 4 箇所が乗っていた)、これを剥がして適用を 1 箇所に集約した。ペインに送られるバイト列は 1 バイトも変えていない。不採用 3 件: ③NoDecisionBehavior への denies 追加 — 前提が失効している。「agy は blocks で近似」は #1762 時点の話で、#1779 が agy 1.1.12 を実測して proceeds に直しており(src/lib/hooks/sources/antigravity/source.ts:181)、{} を送るのは CommandMate ではない ④supportedEvents の emittable/delivered 分割 — 「届く語」であると型 doc に明記するに留めた。分割すると消費層がどちらを見るか選ばされ、emittable を選ぶと copilot の pre_tool_use と gemini の BeforeTool でちょうど永久に待つ ⑤definePullEventSource への turn-gate 内蔵 — 状態機械は汎用でもフレーム語彙が完全に opencode 固有なので、pull 型を足すときの必須手順(§4 手順 6′ / §5)にした。裁定は不採用分も含め tests/unit/hooks/sources/launch-contract-1846.test.ts が固定する

Documentation

  • docs(cli): capture --json の各フィールドの意味論を明文化する (#1840):
    docs/user-guide/cli-operations-guide.md(および docs/en/ の対応節)の capture 節に、
    content / realtimeSnippet / lineCount / isRunning /
    sessionStatus・sessionStatusReason / structuredEvents・lastStopEventAt の 6 行表を追加した。
    監視スクリプトが実際に踏んでいた 2 つの誤読を名指しで潰している:
    content は lastCapturedLine 以降の差分なのでポーラーが先に保存していれば正常時でも空
    (src/lib/session/current-output-builder.ts:535-556)、
    isRunning は tmux セッションが存在して healthy という意味だけでターン進行中ではない
    (src/lib/session/claude-session.ts:543-556)。画面が空かどうかは
    realtimeSnippet.trim() === '' と lineCount で見る。あわせて wait 節に、完了判定が
    sessionStatus === 'ready'(未分類フレームでない)またはセッション消滅であって
    ターンの成立は見ていないことを明記した(src/cli/commands/wait.ts:356)

Fixed

  • chore(skills): vendored な cmate-verify が run の途中で自分の $WORKDIR を消していた欠陥を上流から取り込む (#1864): .claude/skills/cmate-verify/scripts/verify-run.sh は .claude/skills/sync-map.json の sha256 で counterpart に pin されているため、#1861 でバイト一致させた直後に上流 Kewton/commandmate-skills #228(PR #230 = 9604e8f)が欠陥を修正した結果、vendored copy だけが欠陥を抱えたままになっていた。欠陥は「速いゲートほど確実に踏む」もので、ゲートが watchdog の fork より先に終わると wait が即座に返り、その直後の kill -TERM "$rga_wpid"(timeout watchdog の後始末)が fork 直後の subshell に届く —— bash は fork した子で signal handler は reset するが EXIT trap の文字列は残すので、子が自分の signal disposition を戻す前に catch 可能な signal を受けると termsig_handler() → run_exit_trap() が親から継いだ rm -rf "$WORKDIR" を実行し、まだ走っている run の作業 directory が消える。以降のゲートは verify-run.sh: line 734: /tmp/cmate-verify.XXXXXX/gate-<id>.log: No such file or directory を出したうえで FAIL exit=1 duration=0s(出力なし)= 何も裁定していない不合格を返していた。修正は上流の逐語コピー(963 → 993 行、counterpart と cmp でバイト一致)で、引き金(kill -s KILL = SIGKILL は catch できないので死ぬプロセスの中で shell の code が 1 命令も走らない)と結果(EXIT trap を cleanup_workdir() にし、BASH_SUBSHELL が 0 = top-level shell でなければ何もしない)の両方を塞ぐ。回帰は counterpart から取り込んだ fixture workdir-lifetime.yaml(48 ゲートすべてが即座に fail するので runner は全ゲートの log tail を出さねばならない)を 12 回回す run-tests.sh の case 23(4 assertion。48×12 は上流の変異実測で「修正を全戻しすると 20 回中 20 回赤」になる最小単位で、24×6 では 20 回中 15 回しか赤にならない = 4 回に 1 回見逃す回帰テストになる)。あわせて #225 から在った別の flake —— duration=3s の literal 一致に依存した mutex assertion(duration は date +%s の秒単位計測なので 3 秒の hold が秒境界をまたぐと 4 と読める)を「waited はちょうど 0」と「duration は 3 秒以上」の 2 つに分割 —— も同じ移植で取り込んだ。suite の assertion は 317 → 322。.agents/skills/cmate-verify/ へもミラーし(diff -r 無差分)、sync-map の pin を 59 files へ張り直した(counterpart は fixture と run-tests.sh を package の外に置くため check --counterpart が scripts/tests/** を MISSING と報告するのは従来どおり構造上の既知差)。実測(macOS 26.6 / bash 3.2 / arm64): bash .claude/skills/cmate-verify/scripts/tests/run-tests.sh を 20 回連続で回して 20/20 緑(毎回 322 passed, 0 failed)。No such file or directory と no output captured の署名はどの run にも 0 件、not ok 行も 0 件。これを受けて #1863 が test-unit だけ ubuntu-latest に固定していた ci-pr.yml の TEMPORARY EXCEPTION を外し、他 9 ジョブと同じ fork フォールバック付き self-hosted 条件式へ戻した(ARM64 の self-hosted runner は本欠陥を毎 run 再現させていた唯一の環境で、固定はその再現環境を失わせていた)
  • ci(e2e): Playwright インストールのハングを実測に基づき apt 側で塞ぎ、ブラウザは解決後バージョンでキャッシュする (#1844): test-e2e の npx playwright install --with-deps chromium が繰り返しハングしていた(2026-08-19 だけで 88 分 / 3h55m / 3h48m、その後 #1830 のステップ上限 20 分に当たって run 32248871561 が赤)。Issue は ~/.cache/ms-playwright のキャッシュを最優先の主因対策として挙げていたが、当日の E2E ジョブ 6 本のステップログを Downloading Chrome for Testing 行で二分して実測すると、ブラウザのダウンロードは全サンプルで 0.09〜0.11 分と一定で、バラつきもタイムアウトも 100% apt 側だった(apt フェーズ 0.20m / 3.14m / 7.63m / 9.22m / 10.44m / タイムアウト)。ubuntu-24.04 ランナーでは Chromium の実ライブラリは全て導入済みで、--with-deps が実際に取得するのはフォント 9 パッケージ 21.1 MB だけ(azure.archive.ubuntu.com が 14〜21 kB/s に落ちるのが原因)。tests/e2e に画素比較(toHaveScreenshot / toMatchSnapshot)は 1 件も無く、スクリーンショットは失敗時の調査用のみなので、このフォントが合否を変えることはない。よって 1 ステップを分割し、(1) システム依存 playwright install-deps は 6 分予算の continue-on-error(超過時は ::warning を出して続行、本当に必要なライブラリが欠ければ Run E2E tests が Playwright 自身のエラーで落ちる)、(2) ブラウザは actions/cache で ~/.cache/ms-playwright をキャッシュし、キーは package.json の ^1.56.1 ではなく解決後の実バージョン(playwright-core/package.json の version)に紐付け、古いブラウザでのヒットを避けるため restore-keys は付けない。キャッシュヒット時はダウンロードステップ自体をスキップする
  • fix(cli): wait の抑止通知が全ての reason を「契約由来」と名乗る (#1843): formatSuppressionNotice の前置きが by contract policy 固定だったため、#1829 が追加した agent-launch-dialog(codex の起動ダイアログはツール自身の起動シーケンスに任せるという製品側の判断。契約は一切関与しない)まで契約の仕業として表示され、契約を使っていない worktree の運用者が存在しない denyPatterns を探す羽目になっていた。reason ごとに前置きを出し分け(契約由来の 4 種=mode-off / deny-pattern / deny-pattern-unusable / type-not-allowed は従来文言のまま、agent-launch-dialog は「起動ダイアログ表示中」と述べる)、未知の reason は契約由来を騙らずそのまま名指しする。出し分けは Record<AutoYesSuppressionReason, string> なので reason を足して文言を決め忘れると tsc が落ち、CLI 側のミラー型がサーバ側の union から離れた場合も tests/unit/cli/config/cross-validation.test.ts の型アサーションで tsc が落ちる。wait --json の autoYesSuppression.reason は不変