Skip to content

v0.18.0

Choose a tag to compare

@Kewton Kewton released this 02 Aug 08:36
ad773f8

Highlight: 検証と監視が「見ていないもの」を合格・完了として報告していた経路を塞いだリリース。 v0.17.0 で導入した検証基盤は、ワーカー自身が commandmate verify を回してタスクを終端させると、続く wait --verify が契約を引けずに scope ゲートを SKIP し、その SKIP を集計にも数えず RESULT passed / exit 0 を返していた — 宣言した scope を一度も judge していない run が合格を返す経路である(#1620)。「契約は在るがこの run が attach されていない」を区別し、exit 99(判定に到達せず)でタスク ID を名指しするようにした。あわせてゲートの started_at / finished_at が実行時刻ではなく記録時刻だった問題を直した(#1625) — live DB では 168 gate 中 145 が同値、6 分 24 秒かかった unit ゲートの開始と終了が同一ミリ秒だった。行を実行の前に開き、かつ計測値を明示的に運ぶ両方を採ることで finished_at - started_at === duration_ms がミリ秒単位で厳密に成立し、副次的に、実行中に落ちたゲートを記録する reconcile 経路が初めて到達可能になった(従来は行そのものが残らなかった)。監視側も同型の欠陥を潰しており、タスク台帳を引けなかったことを空(=契約なし)と区別できず推定のまま健全な COMPLETE を出していた問題(#1613)と、git の失敗を「作業ゼロ」と読んで未起動ワーカーを COMPLETE と誤報していた問題(#1614)を、いずれも終了コードの確認で塞いだ。2 リポジトリに同じスクリプトの実体を持つことによるドリフトは、ネットワークも cross-repo トークンも使わない pin 方式の対応表で検知する(#1612)。

Added

  • CommandMate ↔ commandmate-skills の同期対応表とドリフト検知を追加 (#1612): 同じスクリプトの実体を 2 リポジトリに持ちながら、対応関係を宣言した場所も、ずれを検知する仕組みも無かった。.claude/skills/sync-map.json に対応表を宣言し、tests/unit/skills/sync-map.test.ts が最後に同期した時点の sha256 と working tree を突き合わせる。ネットワーク・submodule・cross-repo トークンを一切使わない(skills は個人リポジトリで CI から書ける token を増やしたくない)。分類は Issue 本文が提案した identical / adapted の 2 分類ではなく 3 分類にした — 本文どおり adapted を検知対象から外すと #1613 と同じドリフトが再発するためである。実測では orchestrate-monitor/scripts/** はバイト一致ではない(コメント中の Issue 番号が CommandMate #1581/#1601 ↔ skills #1589/#1602 と食い違う)が、コメントを除いたコード差分は 8 ファイルすべて 0 行で機能変更は必ず両側へ移植しなければならない。よって byte-identical(逐語コピー必須)/ port-required(移植必須・バイト不問。編集すれば必ず鳴る)/ local-only(対応先なし・検知対象外)とし、「バイト一致を要求しない」と「検知対象から外す」を分離した。赤くなったときは移植先の具体的なパスと、赤を消す 2 通り(移植して pin を更新する/分類を変えて根拠を書く)を出す。対応表自体の腐敗も固定してある: .claude/skills/ 直下の全ディレクトリがちょうど 1 回分類されること、対応のあるパッケージは配下の全ファイルを列挙すること、列挙したパスが実在すること。既存の .claude/skills ↔ .agents/skills ガード(dual-placement.test.ts / mirror.test.ts)とは役割を分け、内容比較はせず「.agents 側に .claude の同名がある」ことだけを固定して網羅性を担保する。scripts/skills-sync-map.mjs は pin 更新(update)に加え、手元に counterpart の checkout があるときだけ実 diff を取る check --counterpart <dir> を持つ — pin 方式が構造的に捕まえられない skills → CommandMate 方向(#1613 の向き)はここでしか見えない。.md のみのパッケージを対応表に含めない根拠も実測つきで残した(同名の .claude/commands/*.md とは共通する非空行が数百行中 1〜2 行しかなく、コピー関係にない)。詳細は docs/skills-sync-map.md

Fixed

  • 検証ゲートの started_at / finished_at が実行時刻でなく記録時刻になっていた問題を修正 (#1625): gate-runner.ts の record() が createGateResult(started_at = now)→ 即 finishGateResult(finished_at = now)をゲート完了後に連続で呼んでいたため、両方の打刻が同一ミリ秒に落ち、finished_at - started_at は duration_ms と無関係だった(実測例: durationMs: 4010 に対し started と finished が同一ミリ秒)。履歴 API を読む側は「いつ走ったか」「どれだけかかったか」を timestamp から復元できない。素朴な 2 案のどちらか一方では足りないため両方を採った。(1) 行を実行の前に開く(案 A): createGateResult を spawn の前に呼ぶので実行中は status='running' の行が観測でき、途中で死んだときにどのゲートで死んだかが残る。これは #1543 の起動時 reconcile が持つ「開いたままの gate 行を error で閉じる」ループを初めて実際に到達可能にする — 従来 create と finish が隣接していたため、gate-runner 由来の行がそのループに掛かる窓は事実上存在しなかった(案 B 単独ではこの経路は到達不能なまま)。(2) 計測値を明示的に運ぶ(案 B): 行を開いた時刻は「ゲートに入った」ことの仮置きで実際の spawn はその数ミリ秒後になるため、GateOutcome / ScopeOutcome に startedAt を足し、finishGateResult の executionWindow で started_at を計測開始時刻へ上書きする。これで finished_at - started_at === duration_ms がミリ秒単位で厳密に成立する(案 A 単独では DB 書き込み分だけ区間が広がり、不変条件がロード依存の近似になる)。実行しなかったゲート(skipped と config 読み込み失敗の擬似ゲート config)は判断した瞬間を指す長さ 0 の区間とした — started_at は NOT NULL で finished_at = NULL は既に「まだ閉じていない」の意味なので、NULL に「実行していない」を兼ねさせると意図的な skip と孤児行が区別できなくなる。この扱いなら不変条件が全ステータスで成立する。既存行は UPDATE しない(履歴の改竄)。復元不能な過去行を読む側が判別できるよう、導出値 VerificationGateResult.timingsMeasured(カラムではない)を返す。判定は不変条件そのもので両方向に健全である — 修正後の行は構成上必ず真、修正前の行は 2 つの書き込みが隣接していたため区間が常に ~0 で、真になりうるのは duration_ms も 0 のとき=記録時刻と実行時刻が同一の瞬間であるときだけである。false は「この打刻を所要時間として読むな」を意味し、#1625 以前の行・実行中の行・reconcile で閉じた行を覆う。マイグレーションは追加していない(カラムを増やさず導出で足りる)。消費者の実測: report metrics(vibe-metrics.ts の gate fail breakdown)は gate を gate_id の COUNT にしか使わず期間の絞り込みは run 側の r.started_at、Web UI に検証履歴を読むコンポーネントは存在せず、CLI verify show は run の started/finished と gate の durationMs しか印字しない。gate の timestamp を外に出しているのは --json の VerificationGateResultView だけで、唯一の実消費者である commandmate-skills の cmate-verify-advisor は run 側の timestamp と gate の durationMs / exitCode / status しか読まない(durationMs だけを使う回避を実装済み)。よって product 内に gate timestamp の消費者はいない。run レベルの打刻は壊れていないことも確認した(createVerificationRun は実行前・finishVerificationRun は実行後で、gate の区間を包含する。回帰として固定した)。裁定は不変: gate status も run status も exit code も timestamp を使っていない。変異注入で空振りでないことを確認済み — 打刻を record 時点へ戻す(元の record() 形へ復元)と 4 件が赤(区間 0ms vs duration 400ms / タイムアウト 0ms vs 1004ms / work-evidence 1ms vs 17ms / running 行が観測できない)、executionWindow だけを外して案 A のみに戻すと 2 件が赤、timingsMeasured を定数 true にすると 4 件が赤。なお案 A のみの変異は in-memory DB では書き込みが 1ms 未満で終わるため素の計測では取り逃す(幸運で緑になる)ので、gate 行の INSERT を 60ms 遅らせる Proxy を噛ませて決定的に赤くなるテストを足してある

  • ワーカーが自分で verify するとオーケストレーターの scope ゲートが黙って無効化される問題を修正 (#1620): 契約は「ゲートが通る状態にしてから終えろ」と要求しており、それに従ったワーカーが commandmate verify を回すと、その run が契約タスクを succeeded(終端)へ遷移させる。その後にオーケストレーターが回す wait --verify は taskId を渡していなかったため getActiveTask が null を返し、契約なしの run として scope が SKIP され、SKIP は集計から除外される設計なので RESULT passed / exit 0 が返っていた(#1614 の実運用で発生。実測では 6 ファイルすべて allow 内で違反は無かったが、取り逃したのは違反ではなく違反を機械が見る機会である)。修正は 3 点。(1) wait --verify / --require-work は待ち始める時点=タスクがまだ active なうちに GET /api/worktrees/<id>/tasks?limit=1 でタスク id を読み、完了検知後の run に taskId として渡す(POST /verify は taskId を受け取り UUID 形式を検証する)。時点が要点で、エージェントが待機中に自己 verify でタスクを閉じても id は生き残る。最新タスクが開始時点で既に終端なら束ねない — 別の委任の契約を拾う危険を避けるためで、これが「resolveTask を最新タスクへ寄せる」案を採らなかった理由である(hooks-task.sh のコメント自身が同じ前提を PRECONDITION として危ぶんでいる)。(2) サーバ側の防御: run がタスクに結び付かず、しかしその worktree に scope を宣言した終端タスクが存在するとき、scope の log_tail を「契約が最初から無い」場合と別の文言(タスク id と status を名指しする scopeSkipDetachedContract())にし、その SKIP を集計に数える(run は error)。素の commandmate verify(契約が 1 件も無い worktree)は従来どおり passed で、requireScopeClean: false と pending も従来どおり無害な SKIP のままである。(3) タスク解決を failed / not_started まで広げた(getVerifiableTask / VERIFIABLE_TASK_STATUSES)。状態機械は verify_started をこの 2 つから受理する(再実行で正当に開き直る)のに、id を知らない呼び出し元はその task を見つけられなかった — つまり「ゲートが赤 → 直す → もう一度 verify」という最も普通の往復でも同じ穴が開いていた。getActiveTask は Auto-Yes・プロンプト事象用に active 3 状態のまま。対になる経路の点検結果: task = null のとき resolveContractGateIds は gateIds を undefined に落とすが、これは verify.yaml の全ゲートが走る=コマンド系ゲートについては厳しくなる方向で、緩むのは scope の判定と requireScopeClean だけだった(requireWorkEvidence も既定選択では常に true なので緩まない)。getActiveTaskForInstance を使う Auto-Yes ポリシー(src/lib/polling/auto-yes-policy.ts)はタスクが閉じると契約の denyPatterns が外れるという同型の性質を持つが、これは契約なしセッションを縛らないための設計であり、本 Issue の scope 外として据え置いた

  • orchestrate-monitor が外部コマンドの失敗を「作業ゼロ」「判定なし」と取り違えていた問題を修正 (#1614): hooks-git.sh は git ... | wc -l で数えていたため pipeline 後段の終了コードが採用され、git の失敗が 0 として出ていた。git worktree list --porcelain に至ってはヒアドキュメント内の command substitution で終了コードが到達不能で、空の record 集合が「該当 worktree 無し」と区別できず commit と uncommitted の両カウンタが同時に 0 へ沈む。その結果、完走したワーカーが NOT_STARTED と報告され続け、オペレータは「git が失敗した」と「ワーカーが何もしていない」を区別できない。さらに monitor.sh は capture の終了コードだけを見ており、その 8 行下の classify-state.sh と完了判定の verify-completion.sh は見ていなかった。起票時の影響評価 2 件は実測で覆った: (1) 「誤 COMPLETE は起きない」は誤りで、classify-state.sh が落ちると空 state が verify-completion.sh へ渡り、生存信号とみなされずヒューリスティクスへ落ちる — --started 1 --state '' --idle-streak 10 --idle-threshold 5 --commits 2 は COMPLETE(bash 3.2.57 実測)で、稼働中のワーカーが COMPLETE と報告されうる。(2) 現行の wc -l は過少計数していない(0 件→0 / 1 件→1 / 2 件→2)。過少計数は「終了コードを見るために出力を先に変数へ受ける」修正形で初めて起きる($() が末尾改行を落とすため 1 件→0)ので、数え方は printf '%s' "$out" | grep -c . || true を採用し、0 件 / 1 件 / 複数件の 3 サイズを回帰で固定した(|| echo 0 は 0 件で "0\n0" になる)。失敗時のカウンタ値は 0 のまま(commits=0 && uncommitted=0 は完了判定を COMPLETE を出さない側にしか倒さない)だが、黙った 0 ではなく原因ごとに worker あたり 1 行を stderr へ出す(毎ポーリングは出さない。既存の base-ref 警告と同じ粒度)。worktree-id が checkout へ解決できないケースも同じ粒度で報告する。monitor.sh は CLASSIFY / VERIFY の終了コードと空出力を capture と同じ扱いにし、前者はポーリングを捨て、後者は判定に使った入力ごと報告する(case "$verdict" に default が無く、従来は無言で素通りしていた)。同じ欠陥がコード差分 0 のまま Kewton/commandmate-skills にも存在したため両リポジトリを同時に直し(skills は cmate-orchestrate-monitor 0.4.0)、#1612 の sync-map で pin が実際に赤くなってから移植し、移植後に update して緑に戻した — この仕組みが空振りでないことの実運用での最初の証拠である。空振り検証として 7 変異を実測(classify ガード削除で実際に COMPLETE が出ること、git 失敗と真の作業ゼロが別テストで赤くなること、数え方を wc -l へ戻すと 1 件以上の計数が崩れることを含む)

  • orchestrate-monitor がタスク台帳の到達不能を検知できていなかった問題を修正 (#1613): hooks-task.sh の read_task_status は $CM task list を head にパイプしていたため終了コードが head のものに潰れ、「台帳が答えた/この worktree に契約は無い」と「台帳に訊けなかった」が同じ空文字になっていた。完了判定の一次ソースが丸ごと消えてもログは正常時と全く同じで、monitor は推定モードのまま COMPLETE を出し続ける。commandmate-skills の cmate-orchestrate-monitor 0.3.0 に入っていた 3 値化を移植し、read_task_status は <status> / "" / unavailable を返すようにした。unavailable は TaskStatus ではないので、この値を知らない verify-completion.sh に渡っても裁定に使われずヒューリスティクスに落ちる。monitor.sh は unavailable を worker ごとに 1 度だけ FALLBACK MODE として報告し、以降は空文字と同じ扱いにする(ポーリング自体は続くので、復帰したサーバは次の poll から拾える)。あわせて poll 行の task= は 値があるときだけ末尾に付くようにした(task=- は「読んでいないのに読んだ形をした値」であり、契約なし委任の poll 行を台帳導入前と 1 バイトも変えないため)。develop a46845c7 実測では、未知 worktree が exit 99 / Resource not found.、サーバ未起動が exit 1 / Server is not running.、既知 worktree でタスク 0 件は exit 0 のまま stdout 空であり、この最後の 1 つがあるため 「空 stdout = 異常」にはできない(判定は終了コードで行う)。既存テスト 2 件が現在の欠陥を固定していたので、monitor-task-source.test.ts の「非 0 終了も空」と monitor-observability.test.ts の task=(\S+) 必須 regex を新仕様側へ直した。コメントを除いたコード差分は 8 スクリプトすべてで 0 行(コメント中の Issue 番号と repo 文脈は CommandMate 側の表記を維持)

  • cmate-verify のゲート失敗理由が CI ログに一切残らない問題を修正 (#1607): verify-run.test.ts が CI で 1 度だけ赤くなったとき、残っていたのは not ok - parsing: ... の 3 行だけで、なぜ落ちたのかを追う手段が無かった。理由は 3 段で落ちていた — (1) verify-run.sh は失敗ログを stderr にしか出さず(stdout は machine-readable 契約)、しかも log が空だと emit_log_tail() が無言で return する、(2) run-tests.sh の run_verify() は stdout/stderr を out.N / err.N に分離するが assertion は out.N しか見ず、err.N は sandbox ごと EXIT trap で消える、(3) vitest ラッパが suite 出力を not ok 行だけに縮約していた。修正は主に harness 側で、run_verify() が exit code ≠ 0 のときだけ stderr を out.N に追記し(stdout/stderr の分離自体は契約なので維持)、失敗した assertion が out.N の path と中身を両方出し、vitest ラッパが suite 出力を縮約せず失敗メッセージに載せる。verify-run.sh 側では FAIL / TIMEOUT で必ず理由行を出すようにし、出力ゼロなら no output captured、maxLogTailBytes: 0 なら log tail disabled と明示する。出力ゼロで exit 126/127 のときは「コマンドが起動できていない可能性」を手がかりとして追記する(断定ではない)。stdout の契約が壊れていないことは assert_stdout_contract(stdout の全行が GATE ... / RESULT ... のいずれかであること)で固定した。なお元の CI 失敗は再現していない(parsing fixture を 4 burner 下で 100 回、set -m/pgid probe を 500 回で異常なし)。Issue 本文の仮説(set -m + watchdog レース)は実測と噛み合わないため実装から外している — parsing ゲートに timeoutSec は無く既定 600s だが CI の失敗は約 2.1s で起きており、set -m が効かない場合の劣化モードは「timeout 時に孫を取り逃す」側であって「瞬時に終わるゲートが false FAIL する」側ではない。本 PR の成果物は再現ではなく次に起きたときに原因が読めることである

  • send --contract が引数検証より先に task 行を作ってしまう問題を修正 (#1608): commandmate send <id> --contract ... --auto-yes --duration 2h は Task created: <uuid> を出したうえで Error: Invalid duration. Must be one of: 1h, 3h, 8h で exit 2 しており、メッセージは送られていないのに task 行だけが pending で残っていた。send.ts の action 冒頭には worktree ID / --contract と message の排他 / --agent / --instance / --register / --stop-pattern / --model の検証が並んでいるが、--duration だけがそこに無く enableAutoYes() の中で検証されていた。--contract の task 作成(POST /api/worktrees/:id/tasks)は enableAutoYes() より先に走るため、同ファイルが #1545 で掲げていた「副作用のあるものより前に検証する」原則から --duration だけが漏れていた。検証を resolveAutoYesDurationMs() として冒頭の検証群へ移し、enableAutoYes() は検証済みのミリ秒を受け取るだけにした(作成済み task を後から cancel する方式は状態機械が複雑になるため採らない)。--stop-pattern / --model と同様に --auto-yes の有無に関わらず検証するため、--duration 90m のような値が黙って捨てられることもなくなった(従来は --auto-yes が無いと未検証のまま無視されていた)。送信前に判定できる他の引数(worktree ID / --agent / --instance / --register の --instance・--agent 要件 / --stop-pattern 長 / --model と agent の組み合わせ)はすべて既に冒頭にあり修正不要であることを確認し、9 ケースの表駆動テストで「不正なら HTTP リクエストを 1 本も出さずに exit 2」という対称性を固定した。既存の「契約が不正なら送信しない」挙動も回帰テストで固定している

  • catalog:refresh が削除済み行・alias 行をコマンドとして再追加する問題を修正 (#1603): claude docs の | Command | Purpose | 表は「現行 built-in の一覧」ではなく、旧 CLI 向けの履歴行と alias 行が混在している。パーサが min-version しか読まず max-version を無視していたため、--check が /pr-comments("Removed in v2.1.91")と /vim("Removed in v2.1.92")を説明文が自身の削除告知であるコマンドとして追加候補に載せ、/cost /stats(Alias for /usage)も候補に含めていた(#1503 で消した幻コマンドの一部が復活する経路)。provider 出力を構造化し(maxVersion / status / aliasOf / kind)、engine 側で active かつ canonical な行だけを auto-add するようにした。判定は「セル先頭の max-version note」または「"Removed in vX" 文言」の二重シグナルで、/agents のようにセル途中に max-version note を持つ現役コマンドは現役のまま残す(name だけの永久 denylist だと復活した /agents と旧 /agents を区別できない)。あわせて (1) MDX note 除去が残す先頭空白で badge 除去 regex が ^ に効かなくなり /simplify の説明が "Skill" の 1 語に潰れていた transform 順序バグを修正(除去→trim→badge)、(2) badge 残骸や Removed in / Alias for で始まる marker 的説明文は捨てて placeholder にする suspect-description ガードを追加、(3) 同一 descriptionKey に tool 間で異なる説明が来たときの先着勝ちを廃止(provider 順が claude→codex 固定のため、claude の "Removed in v2.1.92" が codex の /vim の辞書エントリを汚染しうる)。--check は拒否理由を removed-row / alias-row / suspect-description / description-conflict の 4 カテゴリで出力する(実測: 履歴 2 件・alias 2 件を拒否し、/ide /rename /btw /copy /theme /stop の 6 件で claude と codex の説明食い違いを検出)。src/config/slash-commands-catalog.json 自体は変更していない(109 件の追加候補の選別は別作業)

  • orchestrate-monitor の介入が 1 回も届かず、しかも「送った」と記録されていた問題を修正 (#1601): monitor.sh の既定が SESSION_PREFIX="cm" だったため、承認 Enter / rate limit の a / リトライ枯渇後の再送はすべて実在しないセッション cm-<worktree-id> 宛に撃たれていた(実セッション名は mcbd-<cliToolId>-<worktreeId>[-<instance suffix>])。3 箇所の tmux send-keys は 2>/dev/null || true で失敗を握り潰し、ログは送信の前に「送った」と出し、承認カウンタも送信前に加算していたため、空振りが成功として記録され、一度も承認していないのに approvals= が増えるという三重の隠蔽になっていた。既定値を変えるだけでは不十分(単一の固定 prefix は claude と codex の混在フリートにも --instance codex-2 にも同時に一致しない)なので、連結をやめて capture ペイロードの cliToolId からの導出に変更した。cliToolId はそのポーリングでサーバが解決したツールそのものなので、分類したペインへ介入するという不変条件が構造的に保たれる。あわせて (1) 送信前に tmux has-session で存在を検証し、失敗を stderr へ NOT delivered として報告、(2) ログは送信の結果を書き、承認カウンタと再送予算は配信できたときだけ動かす、(3) 宛先を =<name>: の exact match(#1156)にして停止中 primary 宛の入力が -2 インスタンスへ漏れるのを防止、(4) ワーカー指定に <worktree-id>@<instance-id> を追加し、capture 側(--agent/--instance)と送信先の両方を同じ instance で切り替え(状態とログのキーも <id>@<instance> に分離)、(5) worktree id / instance id を tmux へ渡す前に検証(不正なら exit 2)、を入れた。--session-prefix は後方互換として残し、明示時は導出された mcbd-<cliToolId> の頭のみを置換する(instance suffix は維持)。介入先はワーカーごとに 1 回 intervention target = <session> として stdout に出る(誤配送を「最初の介入が必要になる前」に可視化するため、既定出力に 1 行だけ追加)。緑が空振りでないことは 6 変異(既定を cm に戻す / 存在検証を外す / カウンタを送信前に戻す / ログを送信前に戻す / ツール id 一覧を欠落させる / exact match を外す)で確認済み。あわせて --session-prefix に mcbd-claude を渡すよう案内していた既存ドキュメント 4 箇所を是正した(.claude/commands/orchestrate.md の実行例と exit 21 切り分け手順、日英 docs/**/user-guide/workflow-examples.md の実行例)。prefix を渡すと ml_session_name() が cliToolId からの導出を丸ごとバイパスするため、この案内に従うと codex / copilot のワーカーまで claude 扱いに固定され、#1601 が直したのと同型の誤配送になる(--session-prefix は導出できないセッション向けの escape hatch であり、混在フリートでは使わない旨を明記した)。同じ 3 箇所に <worktree-id>@<instance-id> 指定の案内も追加している。