sync-skills-lock ルート直下の の を、upstream リポジトリの現状と照合して更新する。 対象ファイル - ルート : 呼び出し元リポジトリ直下の — このスキルが唯一編集するファイル - 除外 : submodule 配下の は編集しない(submodule 境界を跨がないため) 前提条件 - CLI がインストールされ、認証済みであること - / が利用可能であること( を使用するため)。 CLI は固定版( )で実行する。値と更新手順は「skills CLI のバージョン固定と更新手順」節を参照 - が利用可能であること(状態署名〔 / 〕と復元処理〔既存 ignored ファイルの比較・復元等〕で使用。主要 Linux / macOS には標準搭載されている。無い環境では Step 4 フェンスが npx 実行前に fail-closed で停止するため、導入してから再実行すること) - CLI が利用可能であること(未追跡バイナリファイルの種別表示に使用。未導入の環境では 種別が として表示され、承認前にサイズ・git blob ハッシュのみで 判断することになる。macOS / 主要 Linux ディストリビューションには標準搭載されている) - ルート直下の が存在すること - 実行前に に未コミットの変更がないこと (ステージ済み・未ステージ…

\\t'-

sync-skills-lock ルート直下の の を、upstream リポジトリの現状と照合して更新する。 対象ファイル - ルート : 呼び出し元リポジトリ直下の — このスキルが唯一編集するファイル - 除外 : submodule 配下の は編集しない(submodule 境界を跨がないため) 前提条件 - CLI がインストールされ、認証済みであること - / が利用可能であること( を使用するため)。 CLI は固定版( )で実行する。値と更新手順は「skills CLI のバージョン固定と更新手順」節を参照 - が利用可能であること(状態署名〔 / 〕と復元処理〔既存 ignored ファイルの比較・復元等〕で使用。主要 Linux / macOS には標準搭載されている。無い環境では Step 4 フェンスが npx 実行前に fail-closed で停止するため、導入してから再実行すること) - CLI が利用可能であること(未追跡バイナリファイルの種別表示に使用。未導入の環境では 種別が として表示され、承認前にサイズ・git blob ハッシュのみで 判断することになる。macOS / 主要 Linux ディストリビューションには標準搭載されている) - ルート直下の が存在すること - 実行前に に未コミットの変更がないこと (ステージ済み・未ステージ…

\\t'* ]]; then\n FILE_SIZE=\"$(wc -c \u003c \"${f}\" | tr -d '[:space:]')\"\n if command -v file >/dev/null 2>&1; then\n FILE_TYPE=\"$(file -b -- \"${f}\" 2>/dev/null || echo \"unknown\")\"\n else\n FILE_TYPE=\"file コマンド未検出\"\n fi\n FILE_HASH=\"$(git hash-object -- \"${f}\")\"\n # object format は repository 設定依存(既定 sha1 / 拡張 sha256)で出力桁数が変わる\n # (sha1: 40 桁 / sha256: 64 桁)。固定表記 \"git-blob-sha1\" だと sha256 リポジトリで\n # 実際のアルゴリズムと表示が食い違うため、表記自体をアルゴリズム非依存にする。\n OBJECT_FORMAT=\"$(git rev-parse --show-object-format 2>/dev/null || echo unknown)\"\n printf '%s\\n' \"==> バイナリファイル(内容は表示されません): type=${FILE_TYPE} size=${FILE_SIZE}bytes git-blob-hash(${OBJECT_FORMAT})=${FILE_HASH}\"\n else\n # --no-index は index を変更しない(git add -N は使わない。Step 6 の拒否経路が\n # index からの git checkout -- で承認済み他スキルの hash を復元する設計に依存しており、\n # intent-to-add エントリの混入はその復元設計と干渉するため)。\n # 差分ありのとき exit 1 を返す仕様のため、表示専用のこの呼び出しに限り || true で\n # set -e の中断を避ける。\n git diff --no-index -- /dev/null \"${f}\" || true\n fi\ndone \u003c \"${UNTRACKED_LIST_FILE}\"\nif [[ \"${UNTRACKED_COUNT}\" -eq 0 ]]; then\n echo \"==> 新規(未追跡)ファイル: なし\"\nfi\n```\n\n変更点を確認し、更新された `computedHash` の内容と未追跡ファイルの中身を合わせてユーザーに提示する。\n\n**checker(`scripts/check-skill-local-patches.sh`)を持つリポジトリの場合**、この時点の diff は **raw な upstream 差分**であり、local patch はまだ再適用されていない(Step 5.5 の再適用後の最終 diff と混同しないこと)。\n\n#### Step 5.5: local patch を再適用して最終検証する(checker を持つリポジトリのみ)\n\n`npx skills add` の上書きで消費側リポジトリの local patch が worktree から消えているため、**stage より前に**再適用と最終検証を行う。checker(apply)は当該スキルディレクトリのほか durable patch(`scripts/local-patches/`)も変更・stage し得るため、(1) 却下・失敗時の厳密復元用に apply 前の index を snapshot し、(2) apply 後は変更集合が契約範囲(`skills-lock.json`・当該スキル・`scripts/local-patches/`)に収まることを機械検証する。すべて成功した場合のみ Step 6 以降へ進める。\n\n```bash\nif [[ -f scripts/check-skill-local-patches.sh ]]; then\n # Step 4 と同一 shell セッションの前提を機械検証する。セッションが切れて失われた場合は、\n # outside_state / verify_outside_and_checker / restore_contract_scope / run_checker\n # (いずれも Step 4 で定義した関数)を Step 4 のとおり再定義し、\n # PRE_OUTSIDE / CHECKER_APPROVED_HASH には Step 4 が表示・承認した値を設定してから進む。\n # 値が不明なまま進んではならない(fail-closed で中止し、Step 6 の却下手順で戻す)\n : \"${PRE_OUTSIDE:?Step 4 で表示された基準 digest を設定してから実行する}\"\n : \"${CHECKER_APPROVED_HASH:?ユーザー承認済みの checker blob hash を設定してから実行する}\"\n\n # 却下・失敗時の復元は Step 4 で「checker 初回実行より前」に取得・表示済みの\n # PRE_SYNC_TREE(同期開始前の index snapshot)を使う。ここで snapshot を取り直しては\n # ならない — npx 後の index を基準にすると、復元先が「raw upstream + 同期前 check の\n # stage」になり、「同期前へ戻す」という契約に反する(local patch が外れた状態が残る)\n : \"${PRE_SYNC_TREE:?Step 4 で表示された同期開始前 snapshot を設定してから実行する}\"\n\n # 範囲外 digest の基準(PRE_OUTSIDE)・outside_state・verify_outside_and_checker は\n # Step 4 で checker の初回実行より前に定義・取得済みのものを同一 shell セッションで\n # そのまま使う(ここで取り直すと、同期前 check 以降の範囲外変更が基準へ取り込まれてしまう)\n\n # 実行対象は worktree の checker ではなく、承認済み blob(CHECKER_APPROVED_HASH)を\n # 毎回再検証してから取り出す一時ファイル(run_checker。Step 4 で定義済み)。checker\n # の各実行(同期前 check・apply・最終検証)の直前に必ずハッシュを再検証するため\n # (codex P1 指摘: verify-then-execute の隣接。apply は checker 自身のコードを実行\n # するため一時ファイルを自己書換えし得るが、直後の最終検証は run_checker が再検証\n # してから承認済み blob へ書き戻すので、書き換えられた内容がそのまま実行されることは\n # ない)、ここで個別に取り出し・検証するコードは不要(呼び出しごとに run_checker が\n # 行う)。\n\n # local patch の再適用(--3way fallback や index 復元により、patch 対象 file や durable patch が\n # stage されることがある)\n apply_rc=0\n run_checker apply || apply_rc=$?\n if [[ \"${CHECKER_RUN_VERIFY_FAILED}\" -eq 1 ]]; then\n restore_contract_scope || true\n exit 1\n fi\n if ! verify_outside_and_checker; then\n # 範囲外の破壊は verify_outside_and_checker の案内どおり手動復旧として残しつつ、\n # 契約範囲(部分適用された patch・checker 由来の index 変更)は Step 4 で定義済みの\n # restore_contract_scope で自動復元してから終了する(「すべての失敗経路で同期開始前へ\n # 戻す」契約。以下の 3 失敗分岐も同じ。shell セッションが切れて関数が失われている\n # 場合は Step 4 のとおり再定義してから実行する)\n restore_contract_scope || true\n exit 1\n fi\n if [[ \"${apply_rc}\" -ne 0 ]]; then\n echo \"エラー: local patch の再適用に失敗しました。stage・commit へ進まないでください(fail-closed)。\"\n restore_contract_scope || true\n exit 1\n fi\n\n # 再適用後の最終検証(worktree / index / durable patch)\n check_rc=0\n run_checker || check_rc=$?\n if [[ \"${CHECKER_RUN_VERIFY_FAILED}\" -eq 1 ]]; then\n restore_contract_scope || true\n exit 1\n fi\n if ! verify_outside_and_checker; then\n restore_contract_scope || true\n exit 1\n fi\n if [[ \"${check_rc}\" -ne 0 ]]; then\n echo \"エラー: 再適用後の最終検証に失敗しました。stage・commit へ進まないでください(fail-closed)。\"\n restore_contract_scope || true\n exit 1\n fi\nfi\n```\n\nいずれかが非 0 の場合は **fail-closed で停止**し、Step 6・7(承認・stage)へ進まない。**local patch が欠けた状態を承認済みとして stage してはならない**。すべての失敗分岐は `restore_contract_scope` が契約範囲(当該スキル・`skills-lock.json`・`scripts/local-patches/`)の index + worktree を同期開始前へ自動復元してから終了する(契約範囲内の未追跡ファイルは削除せず一時ディレクトリへ退避して案内する。退避自体に失敗した場合は worktree を復元せず index のみで停止する)。範囲外 path の破壊が報告された場合のみ、`verify_outside_and_checker` の案内に従って範囲外を手動復旧してから原因を調査する。\n\n#### Step 6: ユーザーに当該スキルの承認を求める\n\n差分がある場合のみ、ユーザーに「この更新を適用してよいか」を確認する。Step 5 のプレビュー\n(`git ls-files --others --exclude-standard`)・本 Step の拒否(`git clean -fd`)・Step 7 の承認\n(`git add`)は同じ集合(追跡ファイルの変更 + 非 ignore の未追跡ファイル)を対象とする。\n`.gitignore` 対象はいずれの経路でも扱わない。\n\n**checker を持つリポジトリの場合、承認の対象は Step 5.5 完了後の最終 diff**(upstream 更新 + local patch 再適用を含む commit 候補。durable patch `scripts/local-patches/` を含む)であり、次で確認する。`git diff HEAD` は未追跡ファイルを表示しないため、未追跡分は Step 5 と同じ手順(`git ls-files -z --others --exclude-standard` で列挙し、`git diff --no-index` / バイナリ判定で内容表示)を契約範囲の path に対して再実行し、tracked 差分と合わせて提示する:\n\n```bash\ngit diff HEAD -- skills-lock.json \".agents/skills/${SKILL_NAME}/\" scripts/local-patches/\n# 未追跡分は Step 5 のプレビュー処理(一時ファイル + NUL 区切り + 空/バイナリ判定。\n# git ls-files の失敗は fail-closed で中止)を、pathspec を\n# \".agents/skills/${SKILL_NAME}/\" scripts/local-patches/ の 2 つへ広げて再実行し、\n# 名前の列挙だけでなく内容(git diff --no-index / バイナリは種別・サイズ・hash)まで提示する\n```\n\n**却下された場合**は当該スキルのみ即座にリバートして**次スキルへ continue**する(全体を中止しない)。\nただし直後の検証でリバート未完了を検出した場合は、この skip 継続の対象外としてループ全体を\n`exit 1` で停止する(fail-closed。未完了のまま次スキルへ進むと、残留した却下対象の変更が\n次スキルの承認時に一緒に stage され得るため):\n\n```bash\n# 当該スキルの変更のみをリバート(追跡ファイル)。\n# git checkout -- \u003cfile> は HEAD ではなく「index(ステージ)」の内容を作業ツリーへ復元する。\n# 前スキルの承認変更は git add で既に index に載っているため、checkout 後の作業ツリーにも\n# 引き継がれ、承認済み computedHash が消えることはない。\n# 2つのパスを1つの `git checkout --` に渡すとアトミックに扱われ、どちらか一方が\n# 「追跡対象なし」(初回具現化・untracked のみの書き込み時)で pathspec エラーになると\n# コマンド全体が失敗し、もう一方(skills-lock.json)も復元されない。必ず1コマンド1パスで\n# 分離し、一方の失敗が他方の復元を阻害しないようにする。\n\n# skills-lock.json は初回生成(未追跡)だと pathspec エラーで非ゼロ終了するため、\n# revert_in_scope(行 1046-1084)と同じく || true で吸収する。errexit 有効シェルで\n# 実行された場合でもここで途中 abort しない(Issue #417。復元の成否はこの直後の\n# 検証ブロックで確認する — || true は成功扱いではなく確認を先送りするだけ)\ngit checkout -- skills-lock.json 2>/dev/null || true\ngit checkout -- \".agents/skills/${SKILL_NAME}/\" 2>/dev/null || true\n# skills-lock.json が npx の初回生成で未追跡のまま残っている場合、上の checkout は\n# 「追跡対象なし」で何もせず(|| true が吸収)ファイルは削除されない。Step 4 の事前\n# clean ガードにより実行前の未追跡 skills-lock.json は無いことが保証されているため、\n# ここで未追跡のまま見つかる skills-lock.json は今回の npx が生成したものに限られ、\n# 安全に削除できる。削除しないと直後の検証(git ls-files --others)が必ず残留を検出し、\n# 「却下時は当該スキルのみリバートして次スキルへ continue」という契約を満たせないまま\n# 必ず exit 1 になる(Issue #417 P1 指摘)。\n# この経路は checker を持たない(このフェンスは checker 非経由の単純リバート専用)ため、\n# 未追跡 skills-lock.json の唯一の出所は npx の初回生成であり削除して問題ない。checker\n# を持つリポジトリ向けの却下フェンス(下記「checker を持つリポジトリの却下は次を使う」節)が\n# 同種のファイルを削除せず一時ディレクトリへ退避するのは、checker(apply)が既存内容を\n# 書き換えた唯一のコピーである可能性があるためで、事情が異なる(この経路にはその可能性がない)。\n# `[[ -n \"$(cmd)\" ]]` は cmd の終了コードを見ず出力の空/非空だけを見るため、権限・index\n# 異常等で cmd 自体が失敗して空出力になった場合「残留なし」と誤判定し得る(Issue #417\n# P0 指摘)。command substitution は if の条件式として実行し、失敗時は変数を空のまま\n# fail-closed で停止してから、内容の空・非空を判定する\nif ! LOCK_UNTRACKED_CHECK=\"$(git ls-files -z --others --exclude-standard -- skills-lock.json)\"; then\n echo \"エラー: skills-lock.json の未追跡判定(git ls-files)に失敗しました。空出力を「未追跡なし」と誤判定しないため停止します(fail-closed)。権限・index 異常等の原因を解消してから再実行してください。\" >&2\n exit 1\nfi\nif [[ -n \"${LOCK_UNTRACKED_CHECK}\" ]]; then\n rm -f skills-lock.json\nfi\n# npx が新規作成した未追跡ファイルも削除(Step 4 の clean ガードで実行前は clean を保証済み)。\n# ${SKILL_NAME} は kebab-case 検証済みのため、対象は当該スキルディレクトリ配下に限定される。\n# git clean はディレクトリが存在しない場合に非ゼロ終了するため、revert_in_scope と同じく\n# 存在確認してから呼ぶ(errexit 有効シェルでの途中 abort 防止。Issue #417)\nif [[ -d \".agents/skills/${SKILL_NAME}\" ]]; then\n git clean -fd -- \".agents/skills/${SKILL_NAME}/\"\nfi\n# 上記 || true / 存在確認ガードは失敗を握り潰し得るため、却下リバートが実際に完了したかを\n# ここで実測してから次スキルへ進む。スキルディレクトリ配下が clean(porcelain 空)かつ\n# skills-lock.json が clean(tracked/untracked 双方)でなければ fail-closed でループ全体を\n# 停止する。\n# - `git diff --quiet` は worktree vs index の比較(他スキルの承認済み stage 分には影響\n# されない)であり、tracked な残留変更を検出する\n# - ただし `git diff --quiet` は untracked ファイルを無視するため、これだけでは\n# skills-lock.json が初回生成(未追跡)のまま残置されたケースを見逃す(`|| true` が\n# 吸収する pathspec エラーと同じ穴。Bugbot 指摘)。`git ls-files --others` で untracked\n# の残留も別途検出する\n# 却下リバートが未完了のまま次スキルへ進むと、残留した却下対象の変更が次スキルの承認時に\n# 一緒に stage され得るため、warning のみで継続してはならない(Issue #417。checker を\n# 持つリポジトリ向けの却下フェンス(同期開始前 snapshot からの復元)と同じ fail-closed\n# 扱いに揃える)\n# `[[ -n \"$(git status ...)\" ]]` / `[[ -n \"$(git ls-files ...)\" ]]` は command substitution\n# の終了コードを見ないため、権限・index 異常等でコマンド自体が失敗して空出力になった場合\n# 「残留なし」と誤判定し得る(Issue #417 P0 指摘。CI codex-review 指摘と同一の穴)。\n# 各出力を個別代入し、取得失敗を明示的に検知してから内容判定へ進む\nif ! FINAL_STATUS_OUT=\"$(git status --porcelain -- \".agents/skills/${SKILL_NAME}/\")\"; then\n echo \"エラー: ${SKILL_NAME} 配下の残留確認(git status)に失敗しました。空出力を「残留なし」と誤判定しないため停止します(fail-closed)。原因を解消してから再実行してください。\" >&2\n exit 1\nfi\nif ! FINAL_LOCK_UNTRACKED_OUT=\"$(git ls-files -z --others --exclude-standard -- skills-lock.json)\"; then\n echo \"エラー: skills-lock.json の未追跡残留確認(git ls-files)に失敗しました。空出力を「残留なし」と誤判定しないため停止します(fail-closed)。原因を解消してから再実行してください。\" >&2\n exit 1\nfi\nif [[ -n \"${FINAL_STATUS_OUT}\" ]] \\\n || ! git diff --quiet -- skills-lock.json \\\n || [[ -n \"${FINAL_LOCK_UNTRACKED_OUT}\" ]]; then\n echo \"エラー: 却下リバートが完了していません(${SKILL_NAME} 配下の残留変更、または skills-lock.json の worktree 差分・未追跡残留)。git status を確認し、手動復旧してから再実行してください(fail-closed。次スキルへは進めません)。\" >&2\n exit 1\nfi\n```\n\nStep 4 の clean ガードにより `npx` 実行前の当該ディレクトリは clean(未追跡含む)であることが保証されているため、`git clean` で削除される未追跡ファイルは `npx` が作成したものに限られる。`git clean` の対象は kebab-case 検証済みの `${SKILL_NAME}` 配下のみに限定されており、リポジトリ全体には影響しない。\n\nこのリバートは「次スキルの `npx skills add` 実行前」に行うため、`skills-lock.json` から戻るのは当該スキル分のみである。`git checkout --` は HEAD ではなく index から復元するため、承認済みの他スキルの hash は index にも作業ツリーにも保持されており、影響を受けない。\n\n**checker を持つリポジトリの却下は次を使う**(同期前 check・Step 5.5 の apply が当該スキルや durable patch の file を index へ stage している可能性があるため、`git checkout --`(index → worktree)だけでは戻らない。Step 4 で checker 初回実行より前に保存した `PRE_SYNC_TREE`(同期開始前の index snapshot)を source に、**契約パス限定**で index + worktree を復元する。npx 後に取得した snapshot を使ってはならない — 復元先が raw upstream 状態になり「同期前へ戻す」契約に反する。index 全体の `git read-tree` も使わない — 範囲外 path の index まで書き換わり、範囲外の手動復旧案内と矛盾する):\n\n```bash\n# PRE_SYNC_TREE は Step 4 が表示した同期開始前の snapshot hash。shell を跨いで変数が消えている\n# 場合は表示済みの値を代入してから実行する。未設定のまま復元してはならない\n: \"${PRE_SYNC_TREE:?Step 4 の同期開始前 snapshot hash を設定してから実行する(未設定のまま復元しない)}\"\n\n# npx・apply が新規作成した未追跡ファイルは git clean で即削除せず一時ディレクトリへ退避する\n# (checker が契約ディレクトリ内へ移動・新規作成したファイルの唯一のコピーであり得るため。\n# 退避先を確認し、不要と判断してから手動で削除する)。退避(mktemp -d / mktemp /\n# git ls-files / mkdir / mv)自体の失敗を検査せず git restore --worktree へ進むと、退避されなかった「唯一の\n# コピー」が PRE_SYNC_TREE の内容で無音に上書きされ、データ喪失になる(Issue #418)。\n# このフェンスはループ外側でユーザーが直接実行する手動手順のため、mktemp -d /\n# mktemp / git ls-files の失敗は復元へ一切進まず即座に停止する(自動復元経路(Step 4 の\n# restore_contract_scope)のような「index のみへ降格して継続」は行わない)\nif ! CONTRACT_UNTRACKED_BACKUP_DIR=\"$(mktemp -d)\"; then\n echo \"エラー: 未追跡ファイルの退避先ディレクトリ作成に失敗しました。復元の完全性を確認できないため、git restore には一切進みません(fail-closed)。原因を解消してから再実行してください。\"\n exit 1\nfi\n# skills-lock.json も対象に含める(checker が git rm --cached 等で未追跡化して内容変更した\n# 場合、その唯一の内容を退避せずに restore で上書きしないため。通常は tracked で列挙されない)。\n# パイプ経由の while はサブシェルで実行され git ls-files 自体の失敗が不可視になるため、\n# 一時ファイルへ書き出してから読む。mktemp 自体の失敗も検査する(検査しないと、errexit\n# オフのシェルでは空パスへのリダイレクトが後続で誤解を招くエラーになり、set -e 下では\n# fail-closed メッセージも退避先の掃除もないまま即中断する。Issue #418 と同型)\nif ! untracked_list=\"$(mktemp)\"; then\n echo \"エラー: 未追跡ファイル列挙用の一時ファイル作成に失敗しました。復元の完全性を確認できないため、git restore には一切進みません(fail-closed)。原因を解消してから再実行してください。\"\n # この時点で退避先はまだ空(mv 前)のため、空ディレクトリのみ削除して片付ける\n rmdir \"${CONTRACT_UNTRACKED_BACKUP_DIR}\" 2>/dev/null || true\n exit 1\nfi\nif ! git ls-files -z --others --exclude-standard -- skills-lock.json \".agents/skills/${SKILL_NAME}/\" scripts/local-patches/ > \"${untracked_list}\"; then\n echo \"エラー: 契約範囲内の未追跡ファイル列挙に失敗しました。復元の完全性を確認できないため、git restore には一切進みません(fail-closed)。原因を解消してから再実行してください。\"\n rm -f \"${untracked_list}\"\n # この時点でも退避先はまだ空(mv 前)のため、空ディレクトリのみ削除して片付ける\n rmdir \"${CONTRACT_UNTRACKED_BACKUP_DIR}\" 2>/dev/null || true\n exit 1\nfi\nbackup_failed=0\nfailed_paths=()\nmoved=0\nwhile IFS= read -r -d '' p; do\n if ! mkdir -p \"${CONTRACT_UNTRACKED_BACKUP_DIR}/$(dirname \"${p}\")\" \\\n || ! mv -- \"${p}\" \"${CONTRACT_UNTRACKED_BACKUP_DIR}/${p}\"; then\n # 退避に失敗したファイルはスキップし、残りのファイルの退避は継続する\n # (保全できるコピーを最大化する)。1 件でも失敗すれば worktree 復元は行わない\n backup_failed=1\n failed_paths+=(\"${p}\")\n continue\n fi\n moved=1\ndone \u003c \"${untracked_list}\"\nrm -f \"${untracked_list}\"\necho \"未追跡ファイルの退避先: ${CONTRACT_UNTRACKED_BACKUP_DIR}\"\n\n# 契約パス限定で index (+ 退避が全件成功していれば worktree も) を同期開始前へ復元する。\n# この同期(pre-check・npx・apply)で生じた契約範囲の stage だけが取り除かれ、承認済みの\n# 他スキル分・範囲外 path の index は一切変更されない。git restore は no-overlay が既定の\n# ため、同期開始前 tree に無い tracked ファイルは契約パス内に限り index・worktree から\n# 取り除かれる(pathspec は index にも照合されるため、snapshot 後に新規作成・stage された\n# ファイルも取り除かれる)。tree にも index にも無い pathspec の不一致エラーのみ復元対象\n# なしとして無視できるが、真の失敗と区別が付かないため path ごとに分離したうえで、成功\n# 可否は下の復元後検証で判定する\nrestore_targets=(--staged --worktree)\nif [[ \"${backup_failed}\" -eq 1 ]]; then\n # 退避の完全性を確認できない以上、worktree への git restore は行わない\n # (未退避の唯一のコピーを上書きするおそれがあるため)。index のみ復元へ降格する\n restore_targets=(--staged)\n echo \"エラー: 契約範囲内の未追跡ファイルの退避に失敗しました。worktree の復元は行いません(fail-closed)。\"\n echo \"退避できなかったパス: ${failed_paths[*]}\"\n if [[ \"${moved}\" -eq 1 ]]; then\n echo \"退避済み分は ${CONTRACT_UNTRACKED_BACKUP_DIR} に残しています。\"\n fi\nfi\ngit restore \"${restore_targets[@]}\" --source=\"${PRE_SYNC_TREE}\" -- skills-lock.json 2>/dev/null || true\ngit restore \"${restore_targets[@]}\" --source=\"${PRE_SYNC_TREE}\" -- \".agents/skills/${SKILL_NAME}/\" 2>/dev/null || true\ngit restore \"${restore_targets[@]}\" --source=\"${PRE_SYNC_TREE}\" -- scripts/local-patches/ 2>/dev/null || true\n\nif [[ \"${backup_failed}\" -eq 1 ]]; then\n echo \"エラー: 未追跡ファイルの退避に失敗したため、契約範囲の復元は index のみで停止しました(worktree は未復元)。未退避のファイルを手動で退避してから、git restore --worktree --source=${PRE_SYNC_TREE} -- \u003cpath> で契約範囲の worktree を復旧し、再実行してください(fail-closed。この却下は完了とは扱わず、ループ全体を停止します)。\"\n exit 1\nfi\n\n# 復元後検証(fail-closed): 契約パスの index・worktree が同期開始前(PRE_SYNC_TREE)と一致し、\n# 未追跡も残っていない(未追跡は上で退避済み)ことを実測してから完了と扱う。判定基準は\n# HEAD ではなく PRE_SYNC_TREE(HEAD 基準の git status --porcelain だと、同期前から存在した\n# 正当な staged 変更が復元完了後も非空として現れ、正しい復元を誤報する)。\n# 検証に失敗した場合はループ全体を exit 1 で停止する(Bugbot Medium 指摘: 従来はここで\n# echo するだけで完了扱いのまま次スキルへ continue していたため、「却下時は fail-closed」\n# というコメント上の意図に反し、半端に復元された契約範囲(skills-lock.json・当該スキル・\n# durable patch のいずれかに raw upstream + 同期前 check の stage が混在した状態)のまま\n# 次スキルの npx が走り得た)。この却下フェンスは Step 4・5.5 の自動失敗経路と異なり\n# ループの外側でユーザーが実行する手動手順のため、`revert_in_scope` の「次スキルへ\n# continue」という既定動作とは切り離し、検証失敗時だけ明示的に停止する。\nif git diff --cached --quiet \"${PRE_SYNC_TREE}\" -- skills-lock.json \".agents/skills/${SKILL_NAME}/\" scripts/local-patches/ \\\n && git diff --quiet \"${PRE_SYNC_TREE}\" -- skills-lock.json \".agents/skills/${SKILL_NAME}/\" scripts/local-patches/ \\\n && [[ -z \"$(git ls-files --others --exclude-standard -- skills-lock.json \".agents/skills/${SKILL_NAME}/\" scripts/local-patches/)\" ]]; then\n echo \"復元完了(検証済み)\"\nelse\n echo \"エラー: 復元後検証で契約範囲に差分が残っています。git diff --cached ${PRE_SYNC_TREE} -- \u003c契約パス> / git diff ${PRE_SYNC_TREE} -- \u003c契約パス> / git ls-files --others -- \u003c契約パス> で残留を確認し、git restore --staged --worktree --source=${PRE_SYNC_TREE} -- \u003cpath> で手動復旧してから再実行してください(fail-closed。この却下は完了とは扱わず、ループ全体を停止します)。\"\n exit 1\nfi\n```\n\n対象は kebab-case 検証済みの当該スキルディレクトリ配下・`skills-lock.json`・durable patch(`scripts/local-patches/`)のみで、承認済みの他スキルの stage にも範囲外 path の index にも影響しない。Step 4・5.5 の**失敗経路**では同じ復元を `restore_contract_scope` が自動実行するため、この手動フェンスはユーザー却下時にのみ使う。却下の復元後検証(上記フェンス)が失敗した場合は、この却下自体を完了扱いにせずループ全体を `exit 1` で停止する(「却下された場合は次スキルへ continue する」という上記の既定動作は、復元後検証が成功した場合にのみ成立する)。未追跡ファイルの退避(`mktemp -d` / `mktemp` / `git ls-files` / `mkdir` / `mv`)に失敗した場合は worktree への `git restore` を一切行わず fail-closed で停止する(退避されていない唯一のコピーを無音で上書きしないため)。\n\n#### Step 7: 承認されたスキルを stage する(ループ内で積み上げる)\n\n```bash\n# 当該スキルのファイルのみをステージング(tracked 変更 + Step 5 で提示した未追跡ファイル)\ngit add skills-lock.json \".agents/skills/${SKILL_NAME}/\"\n\n# checker を持つリポジトリは、承認対象(Step 6 の最終 diff)に含めた durable patch の\n# 変更も同じ承認単位で stage する\nif [[ -f scripts/check-skill-local-patches.sh && -d scripts/local-patches/ ]]; then\n git add scripts/local-patches/\nfi\n```\n\n`skills-lock.json` は単一 JSON ファイルのため行単位での部分ステージは現実的でない。しかし Step 1 の事前ガードで実行開始時の clean 状態を保証しているため、ファイル全体をステージしても sync 由来の変更のみが含まれ、無関係な編集が混入することはない。このコマンドをループ内で実行することで、複数スキルの全スキル sync でも処理した全スキルが過不足なく stage に積み上がる。**checker を持つリポジトリでは Step 5.5(apply + final check)の成功が stage の前提**であり、`computedHash` は npx が書いた upstream 版の値のまま変更しない(local patch で hash を更新しない)。\n\n### Step 8: コミット提案(ループ後に1回だけ実行)\n\nループ完了後、stage 済みの全承認スキルをまとめて1コミットにする。\n\n```bash\ngit commit -m \"$(cat \u003c\u003c'EOF'\nchore(skills-lock): upstream の最新ハッシュと同期\n\n\u003c変更内容の要約>\nEOF\n)\"\n```\n\nユーザーにコミットしてよいか確認する。承認済みスキルが1つもなかった場合(全却下・差分なし)はコミットせずその旨を伝える。\n\n## skills CLI のバージョン固定と更新手順\n\n**Why**: `npx skills add` をバージョン未固定で実行すると、npx はローカルキャッシュに無い場合レジストリのその時点の最新版を確認なしで即時取得・実行する。`skills`(vercel-labs/skills)パッケージが乗っ取られた場合、これは任意コード実行の経路になる。しかもこの実行は Step 5 の差分確認・Step 6 のユーザー承認より**前**に走るため、source の `Fandhe-AI/\u003crepo>` 完全一致検証では防げない。exact 版(`X.Y.Z`。dist-tag・`^`/`~` レンジは禁止)への固定が信頼アンカーになる。\n\n**固定版の決め方**:\n1. `npm view skills version` で現在の latest を確認する\n2. `npm view skills repository.url` が `vercel-labs/skills` であることを確認する\n3. `npm view skills time --json` 等で公開日時が不自然でないことを確認する\n4. upstream リポジトリの該当タグ間の差分・リリースノートを確認し、問題なければ採用する\n\n**更新手順**:\n1. `scripts/skills-lock-update.sh` の `SKILLS_CLI_VERSION` と、本ファイルの Step 4 フェンス内の `SKILLS_CLI_VERSION` を**同一コミット**で更新する(値は完全一致させる)\n2. `node --test skills/sync-skills-lock/tests/` で両ファイルの一致を検証する\n3. **`universal` が新版でも有効な agent id であることを確認する**。無効値へ変わっていた場合、CLI はエラー表示のうえ `exit 0` の no-op になる(実測: `[email protected]` で確認済み)ため、気付かずに運用すると「同期したつもりで何も更新されていない」状態になる。確認方法: スクラッチリポジトリで 1 スキルを実際に `--agent universal` で実行し、出力に `Invalid agents` が出ないこと、および `.agents/skills/\u003cname>/` が実際に更新されることを確認する\n4. 1 スキルで実際に実行し、差分が正常であること・書き込みが `.agents/skills/\u003cname>/` と `skills-lock.json` のみに限定されていること(`git status --porcelain` に `.claude/` 等の他ツリーが現れないこと)を確認する\n5. `chore(sync-skills-lock): skills CLI を X.Y.Z へ更新` でコミットする\n\n**fail-closed**: 固定版が解決できない場合(該当版の不存在・レジストリ障害)は `npx` が非ゼロ終了する。黙って最新版へフォールバックする経路は存在せず、dist-tag・レンジ指定への書き換えも禁止する。`npx` の失敗経路でも成功経路と同じスコープ外書き込み検査(後述の「多層防御」)を必ず実行する。両実行経路とも、`npx` 行だけ errexit を無効化(`set +e`/`set -e`)して終了コードを `PIPESTATUS` から保存し、成功・失敗いずれの経路でも事後のスコープ外検査を必ず実行する(`set -e`/`pipefail` の下でこの無効化を欠くと、`npx` の非ゼロ終了でパイプラインごとその場で異常終了し、事後検査・リバートに到達できないまま部分書き込みが残置される)。ただし復元方法は経路ごとに異なる: 本ファイルの Step 4 フェンス側は呼び出し元シェルの errexit 状態(`$-`)を保存し、元々有効だった場合のみ `set -e` で再有効化する条件付き復元であり、無条件 `set -e` は行わない(このフェンスは errexit が無効なエージェント対話シェルへコピペ実行され得るため、無条件復元だと呼び出し元シェルへ errexit を新規有効化してしまい、同一シェルで後続する Step 6 却下コマンド等がガードの無い非ゼロ終了で途中 abort し得る)。一方 `scripts/skills-lock-update.sh` はファイル先頭で `set -euo pipefail` を自ら宣言しており errexit 常時有効の前提が成立するため、同スクリプト側は無条件 `set -e` 復元のままで正しい(詳細は同スクリプト内コメント参照)。`scripts/skills-lock-update.sh` を単体実行した場合はスクリプト全体を `exit 1` で停止する(この行の前後だけ `set +e`/`set -e` を挟む理由は同スクリプト内のコメント参照)。一方、本ファイルの Step 4 フェンス(複数スキルをループで処理する経路)では、`npx` の失敗を検出したら事後検査のうえ Step 6 の却下時と同じ手順(`git checkout --` / `git clean -fd`)で当該スキル分の部分書き込みをリバートしてから skip(`continue`)して次スキルへ進む — Step 1/3 の他の skip 分岐と同じ制御フローであり、ループ全体を停止させるものではない。事後検査・リバートを挟まずに skip すると、失敗が部分書き込み後に発生した場合の残置変更(スコープ内は次スキルの `git add`(Step 7)が承認済み変更と一緒に stage してしまう、スコープ外は後続処理から「元から存在した dirty 状態」と誤認され得る)を防げないため、両方とも必須の手順である。**スコープ外書き込みの検出(`exit 1`)はこれとは別の停止経路であり、`continue` ではなくループ全体を止める**(詳細は次項「書き込みスコープの制限」を参照)。この停止経路は成功経路(`NPX_STATUS -eq 0`)だけでなく `npx` **失敗**経路にも及ぶ: 失敗後の事後検査でスコープ外差分・状態シグネチャ不一致を検出した場合も、成功経路と同じ判定基準で `NPX_STATUS_OUT_OF_SCOPE_DIRT=1` を立ててループ全体を `exit 1` で停止し、`continue` で次スキルへ進まない(そのまま skip すると未リバートのスコープ外差分が「元から存在した dirty 状態」と誤認され得るため)。\n\n## 書き込みスコープの制限(`--agent universal` とスコープ外検出)\n\n`npx skills add` にエージェント/パス制限を付けずに実行すると、CLI が検出した各エージェント向けツリー(`.claude/skills/` 等)へも書き込み得る。しかし clean ガード(前提条件節・Step 4)・プレビュー(Step 5)・リバート(Step 6)・承認 `git add`(Step 7)はいずれも `skills-lock.json` と `.agents/skills/\u003cname>/` のみを対象としているため、スコープ外への書き込みが発生すると (1) WIP 上書き、(2) レビュー(プレビュー)迂回、(3) 「clean と報告した後の dirty 残留」が起き得る。\n\n2 層で防ぐ:\n\n1. **一次防御(`--agent universal`)**: Step 4 の npx 呼び出しに `--agent universal` を付け、書き込み先を union ストア(`.agents/skills/\u003cname>/`)と `skills-lock.json` のみへ限定する。個別 agent 指定(`claude-code` 等)は `.agents/skills/` を経由せず対象ツリーへ直接コピーしレイアウトを変えてしまうため使わない\n2. **多層防御(実行前後スナップショット比較 + 状態シグネチャ比較)**: Step 4 の npx 実行直前・直後に `git status --porcelain -z -uall` でリポジトリ全体のスナップショットを取り、スコープ内(`skills-lock.json` / `.agents/skills/\u003cname>/`)を除いた差分を比較する。加えて、リポジトリルート全体(除外はスコープ内と、`.git` のうちこのフロー自身が前後スナップショット間に実行する `git status` で変動し得る `.git/index`・その一時 lock〔`.git/index.lock` の完全一致のみ。`.git/config.lock`・`.git/HEAD.lock` 等の永続 lock の残置は署名対象で、シグネチャ不一致として検出される〕のみ。`.git/objects`・`.git/refs`・`packed-refs`・`HEAD`・`logs`・`worktrees`・`modules` を含む残り全域と `.git/config`・`.git/hooks/` 等の永続 Git メタデータは署名対象に含め、履歴・参照の改変やフック仕込み・設定改変も検出する。`.agents` / `.agents/skills` 自身は実行前に存在した場合のみ署名対象で、実行前に不存在だった場合に限り自身のメタデータを omit して初回インストールの正当な親作成を許容する)の状態シグネチャを `repo_state_signature`(`path_state` を起点 `.` へ適用。python3。通常ファイルは mode + 内容の sha256、シンボリックリンクは mode + リンク先の sha256、ディレクトリ・gitlink は自身の mode + 配下全エントリを再帰的に相対パス・パーミッション・内容ハッシュで集約した sha256)で1本にまとめ、前後で比較する。`repo_state_signature` はさらに index の論理状態(`git ls-files --stage` + `git ls-files -v`。エントリ・skip-worktree / assume-unchanged ビット)の sha256 を連結し、prune している `.git/index` の背後での index 改変も前後不一致として検出する。git status に現れたパスだけをシグネチャ化する方式では、git がディレクトリの mode を追跡しない以上、スコープ外ディレクトリの chmod や `.gitignore` 対象ファイルの上書きを原理的に検出できないため、走査は status 由来のパス集合ではなく作業ツリー全体に対して行う。ステータス+パスの記録だけでなく全体シグネチャも一致して初めて「変化なし」と判定し、どちらかに差分があれば `--agent universal` が抑止しているはずの書き込みが発生したことを意味し、スコープ内をリバートしたうえで `exit 1` によりループ全体を停止する。**他の skip 分岐(`continue`)とは異なり、この検出はループを継続しない**: スコープ外の汚染は自動リバートされないため、続行すると最終報告が「clean」でも実際は dirty 残留となるため。停止時点までに承認・stage 済みの他スキル分は index に残ったままになるので、操作者は `git status` で確認したうえで、必要な分だけ `git commit` するか `git reset` で戻すかを判断する\n\n両層の前提として、まず実行場所がメイン worktree であることを npx 実行前に検証する(`git rev-parse --absolute-git-dir` と `--git-common-dir` を物理パスへ正規化して比較し、不一致なら fail-closed で停止する。linked worktree では実 Git ディレクトリ〔`.git/worktrees/\u003cname>/` と共有側の `refs`・`logs`・`config`・objects〕が作業ツリー外にあり `repo_state_signature` の走査対象に入らないため、npx が `git update-ref` 等で共有リポジトリを改変しても前後の全検査が一致してしまう)。この一致比較は linked worktree を除外するだけで実 Git ディレクトリの所在までは保証しないため、続けて「実 Git ディレクトリが作業ツリー直下の `.git` 実体ディレクトリである」ことも検証する(`--show-toplevel` を物理パスへ正規化して `--absolute-git-dir` の物理パスと `\u003ctoplevel>/.git` の厳密一致を要求し、さらに `\u003ctoplevel>/.git` を lstat して symlink ではなく directory であることを要求する。`git clone --separate-git-dir`・submodule checkout・`.git` が symlink の構成では git-dir と common-dir が一致したまま実体が作業ツリー外にあり、同じ検出不能に陥る)。次に許可先経路(`.agents` / `.agents/skills` / `.agents/skills/\u003cname>`)の各要素を lstat 検証し、存在する要素が symlink または非ディレクトリなら npx を実行せず fail-closed で停止する(Step 4 フェンス冒頭)。スコープ外検査は許可先を「パス文字列」で走査除外するため、経路が実行前からリポジトリ外を指す symlink だと npx のリンク先への書き込みがどの検査にも現れない。存在しない要素のみ、初回インストールの正当な新規作成として許容する。`skills-lock.json` も同じ段階で lstat 検証する: 状態シグネチャの `prune` と porcelain フィルタの双方でパス文字列により除外されるため、実行前から外向き symlink だと npx のリンク先への書き込みがどの検査にも現れない。存在する場合は regular file であることを要求し、symlink・非 regular file なら npx を実行せず fail-closed で停止する(不存在は初回生成として許容する)。加えて許可先**配下**も npx 実行前に `find -type l` で走査し、既存の symlink が 1 件でもあれば npx を実行せず fail-closed で停止する(許可先配下は `prune-under` により署名から除外されるため、配下の既存 symlink を npx が辿ってリポジトリ外へ書き込んでも前後シグネチャ比較には現れない。事前拒否だけが防御になる)。さらに許可先配下の**既存 ignored ファイル**(`.DS_Store` 等)は npx 実行前に一時バックアップディレクトリへ内容・mode ごと退避し(`cp -p`・相対パス構造を保持)、実行後にバックアップと比較して変更・削除を検出したらバックアップから復元する(成功経路では警告のうえ処理継続、失敗経路では `revert_in_scope` に組み込んで復元。復元自体が失敗した場合のみバックアップディレクトリを保全して非ゼロ終了する)。\n\nこの事前検査は開始時点しか見ない(TOCTOU)ため、さらに 4 つの防御を重ねる:\n\n1. **許可先要素自身の署名(`prune-under`)**: 状態シグネチャは既存の許可先ディレクトリを「エントリごと除外」するのではなく、配下のみ除外して要素自身(種別・mode・symlink のリンク先)は前後で署名する(`SKILL_DIR_SIG_SPEC` の `prune-under`。実行前に不存在だった初回インストールのみ `prune` で全除外する)。npx が**実行中に**許可先を外向き symlink へ置換すると、前後シグネチャの不一致として検出される\n2. **実行後の再検証(`verify_scope_path_after_run`)**: npx 実行後(事後シグネチャ取得前)に許可先経路の全要素を再度 lstat し、存在する要素が symlink・非ディレクトリなら fail-closed で停止する。初回インストールで npx が `.agents` 自体を外向き symlink として作成したケース(親要素の omit / 許可先の prune によりシグネチャに現れない経路)もこれが拒否する。検出時(`SCOPE_PATH_COMPROMISED=1`)の `revert_in_scope` は `skills-lock.json` の復元のみ行い、許可先配下への `git checkout` / `git clean` / ignored 削除は一切行わない — symlink を通じてリンク先(リポジトリ外)のファイルを削除・書き換える事故を避けるため、リンク先の内容とリポジトリ外への書き込み有無の手動確認を案内して非ゼロ終了する(ループ全体を停止する)\n3. **実行後の配下 symlink 走査**: npx 実行後(`verify_scope_path_after_run` に続けて)に許可先**配下**を再度 `find -type l` で走査する。事前走査は実行前の 0 件しか保証せず、npx が**実行中に**配下へ作った外向き symlink とそれ経由の書き込みは、配下を署名から除外している前後比較にも経路 3 要素しか見ない再検証にも現れない。事後に見つかる symlink はすべて npx 実行中の作成物であるため、1 件でも検出したら(走査自体の失敗も同様に)`SCOPE_PATH_COMPROMISED=1` の保全経路へ倒す: 許可先配下への `git checkout` / `git clean` / ignored 削除 / 既存 ignored の復元をすべてスキップし、`skills-lock.json` の復元のみ行い、検出パスとバックアップディレクトリを明示して非ゼロ終了する(手動復旧を案内。ループ全体を停止する)\n4. **実行後の `skills-lock.json` 再検証(`verify_lock_file_after_run`)**: npx 実行後(`verify_scope_path_after_run` に続けて)に `skills-lock.json` を再度 lstat し、存在するのに regular file でなければ fail-closed で停止する(実行前の不存在→生成のケースも regular file を要求する)。npx が**実行中に** `skills-lock.json` を外向き symlink へ置換してリンク先へ書き込んでも、同パスは署名の `prune` と porcelain フィルタの双方から除外されているためどの検査にも現れない。検出時(`LOCK_FILE_COMPROMISED=1`)の `revert_in_scope` は素の `git checkout -- skills-lock.json`(リンク先へ書き込み得る)を行わず、`rm` で symlink 自体を除去(リンク先は辿らない)してから checkout で index の内容を regular file として書き戻す。symlink 以外の想定外実体(ディレクトリ等)は一切触らず手動復旧を案内して非ゼロ終了する(ループ全体を停止する)\n\n**検出の限界**: `git status --porcelain` は `.gitignore` 対象を報告しないため、porcelain スナップショット比較(判定軸 1)**単独**では ignore されたパスへの書き込みを検出できない。しかし状態シグネチャ比較(判定軸 2)は `.gitignore` 対象を含む作業ツリー全体を走査して内容ハッシュへ取り込むため、ignore されたパスへの書き込みもシグネチャ不一致として検出できる(porcelain 由来の報告一覧に該当パスが載らないだけで、検出・停止自体は機能する)。シグネチャ比較はほかに、npx 実行前から M(追跡・変更済み)や ??(未追跡)だったスコープ外ファイルをステータス文字列・パスとも変えずに内容・パーミッションだけ上書きするケース、およびディレクトリ・gitlink(未初期化 submodule 含む)が異なる状態へ書き換わるケースも検出できる。`.git` 配下も `.git/index` と `.git/index.lock` を除く全域(`objects`・`refs`・`packed-refs`・`HEAD`・`logs`・`worktrees`・`modules`・`config`・`hooks`、`.git/config.lock` 等の永続 lock を含む)が署名対象であり、履歴・参照の改変も検出できる。スコープ内リバート(`revert_in_scope`)は、npx 実行前に取得した許可先配下の全ファイルインベントリ(`find -print0`。ignored 含む・NUL 区切り)との突き合わせで「npx が新規作成した `.gitignore` 対象ファイル」のみを個別削除するため、`.agents/skills/\u003cname>/` 配下へ書き込まれた ignored ファイルも異常終了時に残置されない一方、**実行前から存在した ignored ファイル(`.DS_Store` 等)は削除されず保全される**(`git clean -fdx` は既存 ignored ファイルまで巻き添え削除するため使わない)。既存 ignored ファイルを npx が上書き・削除したケースは、許可先配下が署名から除外されている以上シグネチャ比較には現れないため、npx 実行前に取得する内容・mode 込みのバックアップ(`IGNORED_BACKUP_DIR`)との比較で検出し、`restore_preexisting_ignored` がバックアップから復元する(成功経路では警告して継続、失敗経路では `revert_in_scope` の一部として復元。復元失敗時はバックアップディレクトリを保全して非ゼロ終了する)。この復元が regular file のみを前提にできるのは、npx 実行前の許可先配下 symlink 走査(`find -type l`)が既存 symlink を fail-closed で拒否しているためである。実際に残る制約は次の 2 点である: (1) prune している `.git/index`・`.git/index.lock` の**ファイルとしての**書き込みは検出できない(このフロー自身の `git status` が npx 実行後に必ず index を更新するため、署名対象へ含めると常に誤検知になる)。ただし index の**論理状態**(エントリの追加・削除・blob 差し替え、skip-worktree / assume-unchanged ビット)は `git ls-files --stage` + `git ls-files -v` の前後比較(`index_state_signature`)が検出するため、残る盲点は stat cache 相当の情報と `.git/index.lock` 自体に限られる。(2) `.git` の大半を署名対象にした代償として、同期実行中の並行 git 操作(他 worktree 含む)がシグネチャ不一致(誤検知)として同期を停止させ得る(注意事項参照。fail-closed 側に倒す設計判断)。この残余リスクは、一次防御(`--agent universal`)が書き込み自体を抑止していることと合わせて小さいと判断する。\n\n## 注意事項\n\n- **全スキル sync での途中却下**: 1スキルずつ承認・stage を行うため、途中で却下しても承認済みスキルの stage は保持される。全スキル処理後に一括コミットする\n- **同期実行中はこのリポジトリでの並行 git 操作(他 worktree 含む)を行わないこと**: 状態シグネチャは `.git` 配下を `.git/index`・一時 lock を除き署名対象に含めるため、fetch・commit・checkout 等の並行操作は `refs`・`objects`・`logs`・`worktrees` を変動させ、シグネチャ不一致(誤検知)として同期を停止させ得る。検出漏れ(fail-open)ではなく停止(fail-closed)側に倒す設計であり、誤検知した場合は並行操作を止めて再実行する\n- **linked worktree では実行できない**: `git worktree add` で作られた作業ツリーでは `.git` が gitdir を指す通常ファイルで、実 Git ディレクトリ(`.git/worktrees/\u003cname>/` と共有側の `refs`・`logs`・`config`・objects)が状態署名の走査対象外になるため、Step 4 冒頭の worktree 検証(`--absolute-git-dir` と `--git-common-dir` の物理パス比較)が npx 実行前に fail-closed で停止する。メイン worktree で実行し直す\n- **実 Git ディレクトリが作業ツリー外にある構成でも実行できない**: `git clone --separate-git-dir`(`.git` が gitdir を指す gitfile)・submodule checkout・`.git` が symlink の構成では、git-dir と common-dir が一致したまま実体が作業ツリー外にあり linked worktree と同じ検出不能に陥るため、Step 4 冒頭の追加検証(`--show-toplevel` の物理パスとの厳密一致 + `\u003ctoplevel>/.git` の lstat)が npx 実行前に fail-closed で停止する。`.git` が実体ディレクトリの通常構成で実行し直す\n- **許可先経路が symlink の場合は実行できない**: `.agents` / `.agents/skills` / `.agents/skills/\u003cname>` のいずれかが symlink または非ディレクトリだと、Step 4 冒頭の実体検証が npx 実行前に fail-closed で停止する(symlink 越しのリポジトリ外書き込みはスコープ外検査に現れないため)。実体ディレクトリへ置き換えてから再実行する。npx が**実行中に**許可先を symlink へ置換した場合(初回インストールで symlink として作成した場合を含む)も、許可先要素自身の署名(`prune-under`)と実行後再検証(`verify_scope_path_after_run`)が fail-closed で停止し、このとき許可先配下への `git clean` 等の削除系操作は行わない(symlink 越しのリンク先削除を避けるため。「書き込みスコープの制限」節を参照)。許可先**配下**に既存の symlink がある場合も、npx 実行前の `find -type l` 走査が fail-closed で停止する(配下は署名から除外されており、リンク先への書き込みがどの検査にも現れないため)。npx が**実行中に**配下へ symlink を作成した場合も、npx 実行後の再走査(`find -type l`)が検出して同じ保全経路(許可先配下への削除系操作・復元をスキップし、バックアップを保全して停止)へ倒す。該当 symlink を実体ファイルへ置き換えるか削除してから再実行する\n- **既存 ignored ファイルの変更は自動復元される**: 許可先配下に実行前から存在した ignored ファイル(`.DS_Store` 等)を npx が上書き・削除した場合、実行前バックアップとの比較で検出し、内容・mode をバックアップから復元する(成功経路では警告のうえ同期は継続)。復元に失敗した場合はバックアップディレクトリ(パスはエラーメッセージに表示)を残して停止するため、そこから手動で復旧する\n- **スコープ外書き込みを検出した場合はループ全体が停止する**: 「書き込みスコープの制限」節を参照。`continue` ではなく `exit 1` のため、承認・stage 済みの他スキル分が index に残ったまま処理が止まる。手動で `git status` を確認し、コミットするか `git reset` するかを判断する\n- **`skills-lock.json` が symlink・非 regular file の場合は実行できない**: `skills-lock.json` は署名の `prune` と porcelain フィルタの双方から除外されるため、symlink だとリンク先(リポジトリ外を含む)への書き込みがどの検査にも現れない。Step 4 冒頭の実体検証が npx 実行前に fail-closed で停止する(実体ファイルへ置き換えてから再実行する)。npx が**実行中に**置換した場合も実行後の再検証(`verify_lock_file_after_run`)が停止し、復元は symlink を `rm` で除去してから `git checkout` で書き戻す(リンク先への書き込みを避けるため。「書き込みスコープの制限」節を参照)\n- **`skills-lock.json` は実行前 clean 前提で全体をステージする**: 単一 JSON ファイルのため部分ステージは現実的でない。Step 1 の事前ガードで clean を保証し、sync 由来以外の変更の混入を防ぐ\n- **ルートの `skills-lock.json` のみを編集**: submodule 配下は手を付けない\n- **source 完全一致検証(必須)**: `source` を `OWNER/REPO` へ正規化した上で `Fandhe-AI/\u003crepo>` に完全一致しないエントリは skip する(`contribute-skill` と同じ安全弁)。前方一致では `../` を含む値が通過してしまうため、完全一致の正規表現で検証する。`skills-lock.json` の改ざんや誤設定から防御するため\n- **`npx skills add --yes` は上書き確認をスキップする**: upstream に破壊的変更がある場合は `git diff` で内容を必ず確認すること\n- **新スキルの取扱い**: ローカルに存在するが upstream に未登録のスキル(`contribute-skill`, `sync-skills-lock` 自身など)は、upstream マージ後に登録する。マージ前に `computedHash` を勝手に書き込まない\n- **Step 5 のプレビューは index を変更しない**: 未追跡ファイルの表示に `git add -N`(intent-to-add)ではなく `git diff --no-index` を使う。Step 6 の拒否経路が index からの `git checkout --` で承認済み他スキルの hash を復元する設計に依存しており、i-t-a エントリの混入はその復元設計と干渉するため\n- **skills CLI は固定版で実行する**: `npx skills add` はバージョン未固定で実行しない。固定版の決め方・更新手順は「skills CLI のバージョン固定と更新手順」節を参照\n- **local patch の保護(checker を持つリポジトリでは必須)**: npx 前の checker(Step 4)→ 承認前の再適用 + 最終検証 + 契約範囲検証(Step 5.5)→ 成功時のみ stage(Step 7)の順を省略しない。checker 非 0・台帳(`.agents/skills/LOCAL-PATCHES.md`)のみ存在は fail-closed で停止し、local patch が欠けた状態を stage・commit しない。apply が変更し得る durable patch(`scripts/local-patches/`)は承認(Step 6 の最終 diff + 未追跡プレビュー再実行)・stage(Step 7)・却下(`PRE_SYNC_TREE` からの index 復元)のすべてで同じ集合として扱い、未 stage の WIP が同 directory に残る状態では同期を開始しない(Step 4 で fail-closed)。`computedHash` は upstream 版の値を維持する\n\n## sandbox 環境での実行\n\nこのスキルはネットワーク越しの GitHub 操作(`npx skills add` による上流リポジトリの取得等)を必須とする。該当コマンドはコマンド単位で sandbox 無効にして実行する。ネットワーク遮断を解除できない環境では実行できない。\n\n## 検証\n\nコミット後、以下で完了を確認する。\n\n```bash\n# skills-lock.json が更新済みであることを確認\ngit show HEAD -- skills-lock.json | grep computedHash\n\n# 差分なし(sync 完了)を確認\ngit status --porcelain skills-lock.json\n```\n\n- コミットに sync 対象スキルの `computedHash` 更新が含まれること\n- ステージ・未ステージに残留変更がないこと\n\n### 未追跡ファイル可視化の手動回帰確認\n\nupstream にファイルが増えたケース(Step 5 のプレビュー拡張が効いているか)は、npx を実行せずに\n未追跡ファイルを模擬して確認できる。フラットファイルの追加と、upstream が新規サブディレクトリ\nごと追加する典型ケース(`references/` 等)の両方を確認する。**両ケースで手順3の照合方法が異なる**\n点に注意する(後述)。\n\n```bash\n# 1a. clean な状態で検証用ファイル(フラット)を作成し、npx が新規ファイルを増やした直後の状態を再現する\ntouch \".agents/skills/${SKILL_NAME}/__preview_regression_check__.md\"\n\n# 1b. 新規サブディレクトリごと追加されるケースも作成する\nmkdir -p \".agents/skills/${SKILL_NAME}/__preview_regression_dir__\"\ntouch \".agents/skills/${SKILL_NAME}/__preview_regression_dir__/new.md\"\n\n# 2. Step 5 のプレビュー部(上記コマンド)を実行し、両方の検証用ファイルの内容(0 byte でも\n# 「新規(未追跡)ファイル — 承認時に git add で取り込まれる集合:」の一覧に、サブディレクトリ\n# 配下のファイルも含めてフルパスで名前が出ること)が表示されることを確認する\n# (プレビューは git ls-files ベースのため、ディレクトリではなく個々のファイルパスを列挙する)\n\n# 3. git clean -fdn(dry-run)の一覧と照合する。\n# フラットファイルはプレビューの行と `git clean -fdn` の行が完全一致する。\n# 新規サブディレクトリは `git clean -fdn` が個々のファイルではなく親ディレクトリを\n# まとめて1行(`Would remove \u003cdir>/`)で報告するため、行単位の完全一致では照合できない。\n# この場合はプレビューの各ファイルパスが `git clean -fdn` のいずれかの出力行(ファイル\n# 自身、またはその祖先ディレクトリ)で始まることを確認する(前方一致で照合する)。\n# 完全一致を要求すると、正常に動作しているプレビューを「壊れている」と誤診断する。\ngit clean -fdn -- \".agents/skills/${SKILL_NAME}/\"\n\n# 4. 検証用ファイル・ディレクトリを削除して原状復帰する\nrm \".agents/skills/${SKILL_NAME}/__preview_regression_check__.md\"\nrm -rf \".agents/skills/${SKILL_NAME}/__preview_regression_dir__\"\n```\n\n## 既存スキルとの関係\n\n- `contribute-skill` でスキル改修が upstream にマージされた後に本スキルを実行する運用を推奨\n- `create-commit` の Conventional Commits を踏襲(Step 8)\n- 実行可能コマンド集として `scripts/skills-lock-update.sh` を参照","attachment_filenames":["scripts/skills-lock-update.sh","tests/checker-outside-state.test.mjs","tests/checker-run-verify.test.mjs","tests/errexit-fence.test.mjs","tests/path-state-dirnames.test.mjs","tests/preview-untracked.test.mjs","tests/restore-backup-failure-checker-lsfiles.test.mjs","tests/restore-backup-failure-npx-path.test.mjs","tests/restore-backup-failure-status-path.test.mjs","tests/restore-backup-failure.test.mjs","tests/restore-help-order.test.mjs","tests/restore-ignored-failure-revert-display.test.mjs","tests/scope-compromised-revert-display.test.mjs","tests/scope-guard.test.mjs","tests/version-pin.test.mjs"],"attachments":[{"filename":"scripts/skills-lock-update.sh","content":"#!/usr/bin/env bash\n# skills-lock-update.sh — skills-lock.json の computedHash を npx skills add で更新する\n#\n# 使い方(リポジトリルートから実行):\n# skills/sync-skills-lock/scripts/skills-lock-update.sh \u003cskill-name> \u003csource-repo>\n# (インストール先からは .agents/skills/sync-skills-lock/scripts/skills-lock-update.sh)\n# 例:\n# skills/sync-skills-lock/scripts/skills-lock-update.sh github-docs Fandhe-AI/agent-reference-skills\n#\n# このスクリプトは sync-skills-lock スキルが使用する実例コマンド集。\n# リポジトリルートから実行すること。\n\nset -euo pipefail\n\n# skills CLI (vercel-labs/skills) の固定実行バージョン。\n# exact 版のみ許可(dist-tag・レンジ禁止)。npx はバージョン未固定だと\n# ローカルキャッシュに無い場合レジストリの最新版を確認なしで即実行するため、\n# レジストリ乗っ取り時に任意コード実行を許す経路になる。この実行は\n# skills-lock.json の差分確認・ユーザー承認より前に走るため、source の\n# Fandhe-AI 完全一致検証(下記)では防げない。exact 版固定が信頼アンカー。\n# 更新手順は SKILL.md の「skills CLI のバージョン固定と更新手順」節を参照。\n# SKILL.md 側のフェンスと同時更新し、tests/version-pin.test.mjs が両者の\n# 一致を検証する。\nreadonly SKILLS_CLI_VERSION=\"1.5.22\" # 実装時に latest を再確認して確定(npm view skills version)\n\n# dist-tag(latest 等)・レンジ指定(^, ~ 等)の混入をコード上でも防ぐ形式ガード。\n# 不一致時は最新版へ暗黙フォールバックせず fail-closed で停止する。\nif [[ ! \"${SKILLS_CLI_VERSION}\" =~ ^[0-9]+\\.[0-9]+\\.[0-9]+$ ]]; then\n echo \"エラー: SKILLS_CLI_VERSION は exact semver(X.Y.Z)のみ許可: ${SKILLS_CLI_VERSION}\" >&2\n exit 1\nfi\n\nSKILL_NAME=\"${1:-}\"\nSOURCE_REPO=\"${2:-}\"\n\nif [[ -z \"$SKILL_NAME\" || -z \"$SOURCE_REPO\" ]]; then\n echo \"使い方: $0 \u003cskill-name> \u003csource-repo>\"\n echo \"例: $0 github-docs Fandhe-AI/agent-reference-skills\"\n exit 1\nfi\n\n# SKILL_NAME バリデーション: 小文字 kebab-case のみ許可(パストラバーサル防止)\nif [[ ! \"$SKILL_NAME\" =~ ^[a-z][a-z0-9-]+$ ]]; then\n echo \"エラー: SKILL_NAME は小文字 kebab-case のみ許可されています: ${SKILL_NAME}\" >&2\n exit 1\nfi\n\n# source の安全弁: Fandhe-AI org の単一リポジトリのみ許可(完全一致検証)\n# 前方一致では `../` を含む値が通過し、clone 時の URL パス正規化で\n# 組織外リポジトリを対象にできるため、OWNER/REPO へ正規化後に厳密検証する\nREPO_SLUG=\"${SOURCE_REPO#https://github.com/}\"\nREPO_SLUG=\"${REPO_SLUG%.git}\"\nif [[ ! \"$REPO_SLUG\" =~ ^Fandhe-AI/[A-Za-z0-9._-]+$ ]] \\\n || [[ \"$REPO_SLUG\" == \"Fandhe-AI/.\" || \"$REPO_SLUG\" == \"Fandhe-AI/..\" ]]; then\n echo \"エラー: 想定外の source: $SOURCE_REPO — Fandhe-AI/\u003crepo> の完全一致のみ許可されています\" >&2\n exit 1\nfi\n\n# skills-lock.json に source があれば SOURCE_REPO と照合する(誤 upstream 同期防止)。\n# jq 不在時にこの照合ブロックごと skip すると、lockfile の source 安全弁を経由せず\n# 任意のリポジトリを SOURCE_REPO として通過させられてしまうため、skills-lock.json が\n# 存在するのに jq が無い場合は照合を省略せず fail-closed で中止する。\nif [[ -f skills-lock.json ]]; then\n if ! command -v jq >/dev/null 2>&1; then\n echo \"エラー: jq が見つかりません。skills-lock.json の source 照合に jq の導入が必要です。中止します。\" >&2\n exit 1\n fi\n LOCK_SOURCE=$(jq -r \".skills[\\\"${SKILL_NAME}\\\"].source // empty\" skills-lock.json 2>/dev/null)\n if [[ -n \"${LOCK_SOURCE}\" ]]; then\n norm_lock=\"${LOCK_SOURCE#https://github.com/}\"; norm_lock=\"${norm_lock%.git}\"\n norm_arg=\"${SOURCE_REPO#https://github.com/}\"; norm_arg=\"${norm_arg%.git}\"\n if [[ \"${norm_lock}\" != \"${norm_arg}\" ]]; then\n echo \"エラー: 指定された source (${SOURCE_REPO}) が skills-lock.json の source (${LOCK_SOURCE}) と一致しません。中止します。\" >&2\n exit 1\n fi\n fi\nfi\n\n# gh CLI の認証確認\nif ! gh auth status &>/dev/null; then\n echo \"エラー: gh CLI が認証されていません。gh auth login を実行してください。\" >&2\n exit 1\nfi\n\n# python3 の存在確認(PR #412 codex P1 指摘): 状態署名(path_state /\n# index_state_signature)と復元処理(restore_preexisting_ignored 等)が python3 を\n# 必須実行する。無いまま進むと npx 実行後の検査・復元の途中で command-not-found に\n# なり、スコープ外検査が中途半端なまま停止する(fail-open 経路)。npx 実行より\n# 前のこの段階で fail-closed に停止する。\nif ! command -v python3 >/dev/null 2>&1; then\n echo \"エラー: python3 が見つかりません。状態署名・復元処理で python3 が必要です。導入してから再実行してください(fail-closed)。\" >&2\n exit 1\nfi\n\n# linked worktree の実行拒否(PR #412 codex P0 指摘): linked worktree\n# (git worktree add で作られた作業ツリー)では .git が gitdir を指す通常ファイルで、\n# 実 Git ディレクトリ(.git/worktrees/\u003cname>/ と common dir 側の refs・logs・config・\n# objects)は作業ツリーの外にある。repo_state_signature の走査(path_state .)は\n# 作業ツリー内しか対象にせず、index_state_signature も index の論理状態しか補わない\n# ため、npx が git update-ref 等で共有リポジトリの参照・設定を改変しても porcelain・\n# ツリー署名・index 署名のすべてが前後一致し成功扱いになる。メイン worktree でのみ\n# 成立する「--absolute-git-dir と --git-common-dir の一致」を一次根拠に判定し、\n# 不一致(linked worktree のほか、実 Git ディレクトリが署名対象に収まらない\n# submodule 等の変則構成すべて)は npx 未実行のまま fail-closed で拒否する。\n# --git-common-dir は相対パスを返し得る(--path-format=absolute は git 2.31+ のため\n# 使わない)ので、cd + pwd -P で双方を物理パスへ正規化してから比較する。\n# rev-parse・正規化の失敗も「メイン worktree と確認できない」であって\n# 「メインである」ではないため中止する。\nif ! GIT_DIR_ABS=\"$(git rev-parse --absolute-git-dir)\" \\\n || ! GIT_COMMON_DIR_RAW=\"$(git rev-parse --git-common-dir)\" \\\n || ! GIT_DIR_PHYS=\"$(cd \"${GIT_DIR_ABS}\" && pwd -P)\" \\\n || ! GIT_COMMON_DIR_PHYS=\"$(cd \"${GIT_COMMON_DIR_RAW}\" && pwd -P)\"; then\n echo \"エラー: Git ディレクトリの解決(git rev-parse --absolute-git-dir / --git-common-dir)に失敗しました。メイン worktree での実行と確認できないため中止します(fail-closed)。\" >&2\n exit 1\nfi\nif [[ \"${GIT_DIR_PHYS}\" != \"${GIT_COMMON_DIR_PHYS}\" ]]; then\n echo \"エラー: linked worktree(git worktree add で作られた作業ツリー)では実 Git ディレクトリ(.git/worktrees/\u003cname>/ と共有側の refs・logs・config・objects)が状態署名の対象外になり、npx による共有リポジトリの改変を検出できないため実行を拒否します(fail-closed)。メイン worktree で実行し直してください。\" >&2\n exit 1\nfi\n\n# git-dir と common-dir の一致は linked worktree を除外するだけで、「実 Git\n# ディレクトリが署名対象の作業ツリー内にある」ことまでは保証しない\n# (PR #412 Bugbot High 指摘)。git clone --separate-git-dir(.git が gitdir を\n# 指す gitfile)・submodule checkout(.git/modules/\u003cname>/ を指す gitfile)・\n# .git が symlink の構成では両者が一致したまま実体が作業ツリー外にあり、\n# path_state の走査(起点 .)が refs・hooks・objects を含まないため、npx の\n# 改変が porcelain・ツリー署名・index 署名のすべてをすり抜ける。そこで実 Git\n# ディレクトリが「作業ツリー直下の .git 実体ディレクトリ」であることまで検証\n# する: toplevel を上と同じ手法(cd + pwd -P)で物理パスへ正規化し、\n# (1) GIT_DIR_PHYS が \u003ctoplevel>/.git と厳密一致、(2) \u003ctoplevel>/.git を lstat\n# して symlink ではなく directory であること(gitfile = 通常ファイル・symlink\n# はいずれも拒否)、の両方を要求する。不成立は npx 未実行のまま中止する。\nif ! TOPLEVEL_RAW=\"$(git rev-parse --show-toplevel)\" \\\n || ! TOPLEVEL_PHYS=\"$(cd \"${TOPLEVEL_RAW}\" && pwd -P)\"; then\n echo \"エラー: 作業ツリールートの解決(git rev-parse --show-toplevel)に失敗しました。実 Git ディレクトリが作業ツリー内の .git ディレクトリであることを確認できないため中止します(fail-closed)。\" >&2\n exit 1\nfi\nif [[ \"${GIT_DIR_PHYS}\" != \"${TOPLEVEL_PHYS}/.git\" ]] \\\n || [[ -L \"${TOPLEVEL_PHYS}/.git\" ]] \\\n || [[ ! -d \"${TOPLEVEL_PHYS}/.git\" ]]; then\n echo \"エラー: 実 Git ディレクトリが作業ツリー直下の .git 実体ディレクトリであることを確認できません(git clone --separate-git-dir・submodule・.git の symlink 等)。実 Git ディレクトリが状態署名の対象外になり、npx による改変を検出できないため実行を拒否します(fail-closed)。通常構成のメイン worktree で実行し直してください。\" >&2\n exit 1\nfi\n\n# カレントディレクトリが作業ツリールートであることの検証(Bugbot Medium 指摘):\n# 上のブロックは GIT_DIR が \u003ctoplevel>/.git であることまでしか確認しない。\n# repo_state_signature は path_state の起点を「.」(cwd)にしているため、\n# サブディレクトリから実行すると .git(hooks・refs・config・objects)が起点の\n# 走査対象から外れ、上の worktree 検証をすべて満たしたまま npx による .git 改変を\n# 検出できなくなる。pwd -P を toplevel と同じ手法で物理パス化し、厳密一致しない\n# 場合は npx 未実行のまま中止する。\nif ! CWD_PHYS=\"$(pwd -P)\"; then\n echo \"エラー: カレントディレクトリの物理パス解決(pwd -P)に失敗しました。作業ツリールートでの実行と確認できないため中止します(fail-closed)。\" >&2\n exit 1\nfi\nif [[ \"${CWD_PHYS}\" != \"${TOPLEVEL_PHYS}\" ]]; then\n echo \"エラー: カレントディレクトリ(${CWD_PHYS})が作業ツリールート(${TOPLEVEL_PHYS})と一致しません。repo_state_signature はカレントディレクトリを起点に走査するため、サブディレクトリから実行すると .git(hooks・refs・config・objects)が署名対象外になり npx による改変を検出できません。作業ツリールートで実行し直してください(fail-closed)。\" >&2\n exit 1\nfi\n\n# 許可先経路の実体検証(PR #412 P0 指摘): スコープ外検査(porcelain 比較・状態\n# シグネチャ比較)は .agents/skills/${SKILL_NAME} を「パス文字列」で走査除外する。\n# この経路上のいずれかの要素が実行前から symlink だと、npx がリンク先(リポジトリ外を\n# 含む)へ書いた内容は除外側に吸われてどの検査にも現れない。そのため npx 実行前に\n# 各要素を lstat し、存在するものはすべて実体のディレクトリであることを要求する\n# (symlink・非ディレクトリは fail-closed で中止。npx は実行しない)。存在しない\n# 要素のみ、初回インストールで npx が正当に新規作成するケースとして許容する\n# (後段の REPO_SIG_OMITS の条件付き omit と同じ判定基準)。-d は symlink を辿るが、\n# 直前の -L 判定で symlink を先に排除しているため実体の種別だけを見る。\n# この検査は開始時点のレイアウトのみを保証する(TOCTOU)。npx が実行中に許可先を\n# 置換するケースは、許可先要素自身の署名(SKILL_DIR_SIG_SPEC の prune-under)と\n# 実行後の再検証(verify_scope_path_after_run)が受け持つ。\nfor SCOPE_PATH_COMPONENT in \".agents\" \".agents/skills\" \".agents/skills/${SKILL_NAME}\"; do\n if [[ -L \"${SCOPE_PATH_COMPONENT}\" ]]; then\n echo \"エラー: ${SCOPE_PATH_COMPONENT} がシンボリックリンクです。npx の書き込みがリンク先(リポジトリ外を含む)へ向かい、スコープ外書き込み検査で検出できないため中止します(fail-closed)。実体ディレクトリへ置き換えてから再実行してください。\" >&2\n exit 1\n fi\n if [[ -e \"${SCOPE_PATH_COMPONENT}\" && ! -d \"${SCOPE_PATH_COMPONENT}\" ]]; then\n echo \"エラー: ${SCOPE_PATH_COMPONENT} がディレクトリではありません。npx の書き込み先として想定外の実体のため中止します(fail-closed)。\" >&2\n exit 1\n fi\ndone\n\n# skills-lock.json の実体検証(PR #412 codex P0 指摘): skills-lock.json は状態\n# シグネチャの prune と porcelain フィルタの双方で「パス文字列」により除外されるため、\n# 実行前から外向き symlink だと npx がリンク先(リポジトリ外を含む)へ書き込んでも\n# どの検査にも現れない。存在する場合は lstat で regular file であることを要求する\n# (symlink・ディレクトリ等は npx 未実行のまま fail-closed で中止)。不存在は初回\n# 生成として許容する。-f は symlink を辿るため、-L 判定で symlink を先に排除する。\n# この検査も開始時点のみを保証する(TOCTOU)。npx が実行中に置換するケースは\n# 実行後の再検証(verify_lock_file_after_run)が受け持つ。\nif [[ -L \"skills-lock.json\" ]]; then\n echo \"エラー: skills-lock.json がシンボリックリンクです。npx の書き込みがリンク先(リポジトリ外を含む)へ向かい、スコープ外書き込み検査で検出できないため中止します(fail-closed)。実体ファイルへ置き換えてから再実行してください。\" >&2\n exit 1\nfi\nif [[ -e \"skills-lock.json\" && ! -f \"skills-lock.json\" ]]; then\n echo \"エラー: skills-lock.json が regular file ではありません。npx の書き込み先として想定外の実体のため中止します(fail-closed)。\" >&2\n exit 1\nfi\n\n# 作業ツリーの状態シグネチャ(種別 + パーミッション + 内容)を1行で返す。\n# 第1引数のパスを起点に、通常ファイルは内容の sha256、シンボリックリンクは\n# リンク先文字列の sha256(リンク先の解決はしない)、ディレクトリ・gitlink は\n# 自身の mode に加えて配下全エントリ(サブディレクトリの mode・ディレクトリ向け\n# symlink のリンク先と mode・ファイルの mode と内容ハッシュ)をバイト列ソートで\n# 決定的に再帰集約した sha256 を返す。存在しないパスは \"MISSING\"(それ自体が\n# 1つの状態であり、エラーではない)。第2引数以降で走査の除外を指定できる:\n# prune:\u003crel> — 起点からの相対パス \u003crel> をエントリごと走査から除外する。\n# パス区切りをまたがない per-segment glob(fnmatch)を使える\n# (例: .git/MERGE_* は .git 直下にのみ一致し .git/hooks/ 配下の\n# 同名ファイルには一致しない)。npx が書き換えてよいスコープ内と、\n# .git のうち通常の git 操作で変動し得る領域の限定除外に使う\n# prune-under:\u003crel> — \u003crel> 自身のメタデータ(種別・mode・symlink のリンク先)は\n# 記録するが、配下へは降下せず記録もしない(完全一致のみで glob\n# 不可)。既存の許可先ディレクトリに使う: 配下(npx の正当な書き込み\n# 先)は除外しつつ、要素自身のディレクトリ→symlink 置換・chmod は\n# 前後シグネチャの不一致として検出する(PR #412 P0 指摘: エントリ\n# ごと prune すると npx 実行中の symlink 置換が署名に現れない)\n# omit:\u003crel> — \u003crel> 自身のメタデータ(存在・mode)は記録しないが配下は走査する\n# (実行前に存在しなかった親ディレクトリを npx が正当に新規作成する\n# ケースの許容に使う。完全一致のみで glob 不可)\n#\n# PR #412 の P1 指摘群(porcelain に現れない状態変化の見逃し: 配下ファイルの\n# 内容上書き・ディレクトリと dirlink の変更・ディレクトリの chmod)は、いずれも\n# 「git status に現れたパスだけを個別にシグネチャ化する」構造に起因する同一クラス。\n# git はディレクトリの mode を追跡しないため、スコープ外ディレクトリの chmod は\n# status の前後どちらにも現れず、status 由来のパス集合をどれだけ精緻にハッシュ\n# しても原理的に検出できない。そのためこのシグネチャは status 由来のパスではなく\n# リポジトリルート全体(スコープ内と、.git のうち git 操作で変動し得る領域のみ\n# 除外。.git/config・.git/hooks/ 等の永続メタデータは署名対象)へ適用する。\n# .gitignore 対象の\n# ファイルも同じ理由(porcelain に現れない)で走査対象に含める。対象は skills\n# 配布リポジトリで作業ツリーが小さく、全走査 + 全ハッシュを前後 2 回行っても\n# 実用上問題ない。\n# gitlink(未初期化 submodule 等、os.walk が降りられない空ディレクトリ相当)は\n# entries が空集合のままになり「空ディレクトリ」と区別が付かないが、この用途は\n# 「npx 実行前後で変化したか」の検出のみで足りるため区別不要。stat コマンドの\n# 出力書式は環境(BSD/GNU)で異なるため、シェルの `stat` は使わず python3 の\n# os.lstat に統一する。取得エラー(lstat・open・走査失敗)は「読めなかっただけ」を\n# 「変化なし」と誤認する fail-open 経路になるため、握り潰さず即座に非ゼロ終了して\n# 呼び出し側で fail-closed に扱う。\npath_state() {\n local path=\"$1\"\n shift\n python3 - \"${path}\" \"$@\" \u003c\u003c'PYEOF'\nimport fnmatch, hashlib, os, stat, sys\n\npath = sys.argv[1]\n\n# 除外指定(prune: 走査ごと除外 / prune-under: 自身は記録し配下のみ除外 /\n# omit: 自身のメタデータのみ不記録)を解釈する。\nprunes = []\nprune_unders = set()\nomits = set()\nfor spec in sys.argv[2:]:\n label, _, rel = spec.partition(\":\")\n if label == \"prune\" and rel:\n prunes.append(rel)\n elif label == \"prune-under\" and rel:\n prune_unders.add(rel)\n elif label == \"omit\" and rel:\n omits.add(rel)\n else:\n print(f\"path_state: 不正な除外指定: {spec}\", file=sys.stderr)\n sys.exit(1)\n\n\ndef pruned(rel):\n # prune はパス区切りをまたがない per-segment glob で照合する。素の fnmatch は\n # `*` が `/` もまたいで一致するため、`.git/MERGE_*` のような浅い階層向けの\n # パターンが `.git/hooks/` 配下の同名ファイル(署名対象へ残したい深い階層)\n # まで巻き込んでしまう。セグメント数の一致を要求してから各セグメントを個別に\n # 照合し、除外が意図した深さの外へ広がらないようにする。\n segs = rel.split(os.sep)\n for pat in prunes:\n pat_segs = pat.split(\"/\")\n if len(pat_segs) == len(segs) and all(\n fnmatch.fnmatchcase(s, p) for s, p in zip(segs, pat_segs)\n ):\n return True\n return False\n\n\ndef fail(err):\n # 部分的なシグネチャを出力したまま正常終了すると、呼び出し側が欠損に気付けない。\n # ファイル内容は出力せず(秘密情報混入防止)、エラー要因のみ stderr へ出して\n # 非ゼロ終了する。\n print(f\"path_state: 状態取得に失敗: {err}\", file=sys.stderr)\n sys.exit(1)\n\n\ndef file_hash(p):\n fh = hashlib.sha256()\n with open(p, \"rb\") as f:\n while True:\n chunk = f.read(1 \u003c\u003c 20)\n if not chunk:\n break\n fh.update(chunk)\n return fh.hexdigest()\n\n\ntry:\n st = os.lstat(path)\nexcept FileNotFoundError:\n print(\"MISSING\")\n sys.exit(0)\nexcept OSError as e:\n fail(e)\n\nmode = oct(stat.S_IMODE(st.st_mode))\nkind = stat.S_IFMT(st.st_mode)\nh = hashlib.sha256()\n\ntry:\n if stat.S_ISLNK(st.st_mode):\n h.update(os.readlink(path).encode(\"utf-8\", \"surrogateescape\"))\n elif stat.S_ISREG(st.st_mode):\n h.update(file_hash(path).encode(\"ascii\"))\n else:\n # ディレクトリ・gitlink 等。配下の各エントリを相対パス・パーミッション・\n # 内容(種別に応じたハッシュ)でソートして正規化し、走査順に依存せず\n # 決定的な signature にする。os.walk は既定(followlinks=False)で\n # symlink の指す先へは降りないため、ディレクトリ向け symlink は dirnames\n # として自身(リンク先文字列・mode)だけを記録し、リンク先ディレクトリの\n # 中身が二重に取り込まれることはない。\n entries = []\n for dirpath, dirnames, filenames in os.walk(path, onerror=fail):\n kept = []\n for dname in sorted(dirnames):\n # dirnames 自体(配下ディレクトリ・ディレクトリ向け symlink)を\n # lstat して entries へ含める。os.walk は filenames 経由で列挙\n # しないため、ここで記録しないと配下ディレクトリの mode 変更や\n # ディレクトリ向け symlink のリンク先・mode 変更がシグネチャに\n # 反映されない(PR #412 P1 指摘)。\n dp = os.path.join(dirpath, dname)\n rel = os.path.relpath(dp, path)\n if pruned(rel):\n continue\n # prune-under は自身のエントリ(下の記録処理)は残しつつ降下だけを\n # 止める(kept へ入れない = os.walk がこの配下へ降りない)。\n if rel not in prune_unders:\n kept.append(dname)\n if rel in omits:\n continue\n dst = os.lstat(dp)\n dmode = oct(stat.S_IMODE(dst.st_mode))\n if stat.S_ISLNK(dst.st_mode):\n target_hash = hashlib.sha256(\n os.readlink(dp).encode(\"utf-8\", \"surrogateescape\")\n ).hexdigest()\n entries.append(f\"{rel}:{dmode}:dirlink:{target_hash}\")\n else:\n entries.append(f\"{rel}:{dmode}:dir\")\n # prune した名前を降下対象からも外す(os.walk は dirnames の\n # in-place 更新で走査対象を制御する仕様)。\n dirnames[:] = kept\n for name in sorted(filenames):\n p = os.path.join(dirpath, name)\n rel = os.path.relpath(p, path)\n if pruned(rel) or rel in omits:\n continue\n fst = os.lstat(p)\n fmode = oct(stat.S_IMODE(fst.st_mode))\n if stat.S_ISLNK(fst.st_mode):\n target_hash = hashlib.sha256(\n os.readlink(p).encode(\"utf-8\", \"surrogateescape\")\n ).hexdigest()\n entries.append(f\"{rel}:{fmode}:link:{target_hash}\")\n elif stat.S_ISREG(fst.st_mode):\n entries.append(f\"{rel}:{fmode}:reg:{file_hash(p)}\")\n else:\n # デバイスファイル等の特殊な種別は内容ハッシュが定義できない\n # ため種別・mode のみ記録する。\n entries.append(f\"{rel}:{fmode}:other\")\n entries.sort()\n for entry in entries:\n h.update(entry.encode(\"utf-8\", \"surrogateescape\"))\n h.update(b\"\\n\")\nexcept OSError as e:\n fail(e)\n\nprint(f\"{kind}:{mode}:{h.hexdigest()}\")\nPYEOF\n}\n\n# リポジトリルート全体の状態シグネチャ(スコープ外書き込み検出の実体)。\n# 除外は次の 3 種のみ:\n# - スコープ内 — skills-lock.json / .agents/skills/${SKILL_NAME}(npx の正当な書き込み先)。\n# ただし許可先ディレクトリ自身の除外方法は SKILL_DIR_SIG_SPEC(npx 実行前に一度\n# だけ確定)で切り替える: 既存なら prune-under(配下のみ除外・要素自身の種別・\n# mode・symlink 先は署名)にして、npx が実行中に許可先を外向き symlink へ置換して\n# リンク先へ書く TOCTOU を前後シグネチャ不一致として検出する(PR #412 P0 指摘。\n# 事前の lstat 検査は開始時点しか見ない)。実行前に不存在(初回インストール)の\n# 場合のみ prune(エントリごと除外)にして正当な新規作成を誤検知にしない — この\n# 場合の symlink 置換・symlink としての新規作成は、npx 実行後の許可先経路\n# 再検証(verify_scope_path_after_run)が fail-closed で拒否する\n# - .git のうち、このスクリプト自身が前後スナップショット間に実行する git コマンドで\n# 変動し得る領域のみ — 前後シグネチャの間に走る git 操作は\n# `git status --porcelain -z -uall`(実行後スナップショット取得)だけであり、\n# status が触るのは index の stat cache 更新(.git/index)とその一時 lock\n# (.git/index.lock)のみ。リバート用の git checkout / git clean は\n# 実行後シグネチャ取得より後の失敗経路でしか呼ばれないため、署名比較に影響しない。\n# 以前は objects・refs・packed-refs・HEAD・logs・worktrees 等も prune していたが、\n# npx がこれらへ書き込む(履歴・参照の改変)とスコープ外検査を丸ごと迂回できて\n# しまうため(PR #412 P0 指摘)、実測で避けられない index・index.lock 以外は\n# すべて署名対象に含める。lock の prune を `.git/*.lock` のワイルドカードに\n# すると、npx が残した永続 lock(.git/config.lock・.git/HEAD.lock 等)まで\n# 検査から漏れるため(PR #412 codex P1 指摘)、自プロセスの git status が\n# 作り得る .git/index.lock だけを完全一致で prune する。\n# prune した .git/index の背後で npx が index の論理状態を改変するケースは、\n# index_state_signature(下記)の前後比較が受け持つ。\n# 代償として、同期実行中にこのリポジトリで並行 git 操作\n# (他 worktree 含む)を行うとシグネチャ不一致(誤検知)として停止し得る\n# (SKILL.md の注意事項に明記。fail-closed 側に倒す設計判断)\n# - REPO_SIG_OMITS — 実行前に存在しなかった場合の .agents / .agents/skills\n# (npx 実行前スナップショットの直前に一度だけ確定する)。既存なら omit せず\n# 種別・mode・symlink 先を通常どおり署名するため、既存親ディレクトリの chmod や\n# ディレクトリ→symlink 置換は検出される(PR #412 P1 指摘)。不存在だった場合\n# のみ omit し、初回インストールで npx が親ディレクトリを正当に新規作成する\n# ケースを誤検知にしない。omit でも配下の走査は継続するため、同居する他スキルの\n# ツリー(スコープ外)は引き続き保護される\n#\n# REPO_SIG_OMITS は前後 2 回の呼び出しで同一でなければならない(実行後の存在有無で\n# 再判定すると、初回インストールの正当な新規作成が前後不一致=誤検知になる)。\n# 空配列の \"${arr[@]}\" 展開は bash 3.2 の set -u で unbound になるため\n# ${arr[@]+...} 形式で参照する。\nREPO_SIG_OMITS=()\n# 既定は prune-under(既存許可先向け)。初回インストール(実行前に不存在)の場合のみ\n# npx 実行前の判定ブロックで prune へ切り替える。前後 2 回の呼び出しで同一で\n# なければならない(REPO_SIG_OMITS と同じ理由)。\nSKILL_DIR_SIG_SPEC=\"prune-under:.agents/skills/${SKILL_NAME}\"\n\n# index の論理状態のシグネチャ。.git/index はファイルとしては prune せざるを得ない\n# (自プロセスの git status が stat cache を正当に更新するため)が、その背後で npx が\n# エントリの追加・削除・blob 差し替えや skip-worktree / assume-unchanged ビットの\n# 付与を行っても検出できなくなる(PR #412 codex P1 指摘。特に skip-worktree を\n# 立てられると、以後その tracked ファイルの変更が git status から恒久的に隠れる)。\n# stat cache と独立な論理状態 — `git ls-files --stage`(mode・object・stage・パス)と\n# `git ls-files -v`(状態タグ。skip-worktree は S、assume-unchanged は小文字)— を\n# sha256 へまとめ、repo_state_signature の出力へ連結して前後比較する。git status の\n# stat cache 更新はどちらの出力も変えないため、自プロセス起因の誤検知はない。\n# 取得失敗は「変化なしと確認できない」ため非ゼロで返し、呼び出し側の fail-closed\n# (sentinel 比較)へ倒す。\nindex_state_signature() {\n local staged tags digest\n staged=\"$(git ls-files --stage)\" || return 1\n tags=\"$(git ls-files -v)\" || return 1\n digest=\"$(printf '%s\\n--\\n%s\\n' \"${staged}\" \"${tags}\" \\\n | python3 -c 'import hashlib, sys; print(hashlib.sha256(sys.stdin.buffer.read()).hexdigest())')\" || return 1\n printf '%s\\n' \"${digest}\"\n}\n\nrepo_state_signature() {\n local tree_sig index_sig\n tree_sig=\"$(path_state . \\\n \"prune:skills-lock.json\" \\\n \"${SKILL_DIR_SIG_SPEC}\" \\\n \"prune:.git/index\" \\\n \"prune:.git/index.lock\" \\\n ${REPO_SIG_OMITS[@]+\"${REPO_SIG_OMITS[@]}\"})\" || return 1\n index_sig=\"$(index_state_signature)\" || return 1\n printf '%s:index:%s\\n' \"${tree_sig}\" \"${index_sig}\"\n}\n\n# git status --porcelain -z の1レコード(\"XY PATH\\0\")からスコープ内(skills-lock.json /\n# .agents/skills/${SKILL_NAME}/ 配下)を除いたレコードだけを NUL 区切りで outfile へ\n# 書き出す。これは Step 4 実行前後のスナップショット差分(scope-guard)用のフィルタで、\n# 未追跡ファイルのプレビュー表示(既存の git ls-files -z 経路。下部で継続使用)とは別物。\n# 検出の主体はリポジトリ全体の状態シグネチャ(repo_state_signature)であり、この\n# レコード列は status レベルの前後比較と、検出時の報告(どのパスが git status 上で\n# 変化したか)に使う。ディレクトリの chmod 等 status に現れない変化はこの一覧に\n# 載らず、シグネチャ不一致としてのみ検出される。\n# ステータス文字の後の空白1文字を含む固定長プレフィックス(3文字)を切り落とすことで\n# パスを取り出す。C-quote(改行等を含むパスのダブルクォート化)の影響を受けない\n# (-z 出力は raw byte のパスであり、path をそのままファイルアクセスに使ってよい)。\nfilter_out_of_scope() {\n local infile=\"$1\" outfile=\"$2\" record path\n : > \"${outfile}\"\n while IFS= read -r -d '' record; do\n path=\"${record:3}\"\n if [[ \"${path}\" == \"skills-lock.json\" || \"${path}\" == \".agents/skills/${SKILL_NAME}/\"* ]]; then\n continue\n fi\n printf '%s\\0' \"${record}\" >> \"${outfile}\"\n done \u003c \"${infile}\"\n}\n\n# npx が新規作成した .gitignore 対象ファイルのみを許可先配下から削除する\n# (revert_in_scope の補助)。`git clean -fdx` は npx 実行前から許可先配下に存在した\n# ignored ファイル(.DS_Store 等)まで削除してしまうため使わない(PR #412 Bugbot\n# Medium 指摘)。実行前インベントリ(SCOPE_INVENTORY_FILE。find -print0 の NUL 区切り\n# 全ファイル一覧)に存在しないパスに限り、許可先配下であることを再検証したうえで\n# 個別削除する。実行前から存在した ignored ファイルは内容が書き換えられていても\n# 削除しない(保全。変更・削除の検出と復元は restore_preexisting_ignored が\n# 実行前バックアップとの比較で行う)。パス名に\n# スペース・改行を含み得る前提で、一覧の受け渡しは全経路 NUL 区切りで行う。\n# npx が新規作成した「ignored ファイルのみを含む空ディレクトリ」は best-effort で\n# 残り得るが、ファイル残置と異なり後続処理の誤認を生まないため許容する。\n# ls-files の出力は pipe ではなく一時ファイルで python3 へ渡す(ヒアドキュメントで\n# プログラムを与える python3 は stdin をヒアドキュメントに占有されるため、pipe との\n# 併用ができない — 併用すると読み手のいない pipe への書き込みで SIGPIPE になる)。\nremove_new_ignored_in_scope() {\n local ignored_list rc=0\n ignored_list=\"$(mktemp)\" || return 1\n if git ls-files -z --others --ignored --exclude-standard -- \".agents/skills/${SKILL_NAME}/\" > \"${ignored_list}\" 2>/dev/null; then\n SKILL_DIR=\".agents/skills/${SKILL_NAME}\" INVENTORY_FILE=\"${SCOPE_INVENTORY_FILE}\" IGNORED_LIST_FILE=\"${ignored_list}\" python3 - \u003c\u003c'PYEOF' || rc=1\nimport os\n\nskill_dir = os.environ[\"SKILL_DIR\"]\nprefix = skill_dir + \"/\"\nwith open(os.environ[\"INVENTORY_FILE\"], \"rb\") as f:\n inventory = {p for p in f.read().split(b\"\\0\") if p}\nwith open(os.environ[\"IGNORED_LIST_FILE\"], \"rb\") as f:\n ignored_paths = [p for p in f.read().split(b\"\\0\") if p]\n\nfor raw in ignored_paths:\n text = raw.decode(\"utf-8\", \"surrogateescape\")\n # 削除は kebab-case 検証済みの許可先配下に厳密に限定する(ls-files の出力を\n # 信用しきらず、prefix 一致と `..` セグメント不在を自衛的に再検証する)。\n if not text.startswith(prefix) or \"..\" in text.split(\"/\"):\n continue\n if raw in inventory:\n continue # 実行前から存在した ignored ファイルは保全する\n try:\n if os.path.isdir(text) and not os.path.islink(text):\n continue # ディレクトリ自体は削除対象にしない(ファイル・symlink のみ)\n os.unlink(text)\n except FileNotFoundError:\n pass\nPYEOF\n else\n rc=1\n fi\n rm -f \"${ignored_list}\"\n return \"${rc}\"\n}\n\n# 実行前から許可先配下に存在した ignored ファイル(.DS_Store 等)を、npx 実行前の\n# バックアップ(IGNORED_BACKUP_DIR。相対パス構造・mode を保持)と比較し、変化\n# (内容・mode・種別の変更、削除)があればバックアップから復元する。許可先配下は\n# npx の正当な書き込み先だが、ignored ファイルは同期対象外であり npx が変更して\n# よい理由がないため、変更を検出したら復元して警告する(処理自体は継続してよい\n# 契約。復元の失敗のみ呼び出し側で非ゼロ終了へ倒す)。許可先配下は\n# repo_state_signature の prune-under で署名から除外されるため、この変化は\n# シグネチャ比較では検出できず、バックアップとの直接比較だけが検出手段になる。\n# 事前の symlink 走査(npx 実行前)により対象は regular file のみである前提。\n# 復元先が npx により symlink 化されている可能性に備え、書き込み前に unlink し、\n# 親ディレクトリの realpath が許可先内に収まることを検証する(リンク先への\n# 書き込み防止)。親ディレクトリの再作成は make_parent_dirs(存在する最深の\n# 祖先の containment 検証 + 1 階層ずつの lstat 付き os.mkdir)で行い、\n# os.makedirs が中間 symlink を辿ってリポジトリ外へディレクトリを作る経路を\n# 残さない。バックアップとの比較・復元は mode 比較を含むため、stat の\n# 出力書式差(BSD/GNU)を避けて python3 の os.lstat に統一する。\nrestore_preexisting_ignored() {\n # バックアップ対象が無ければ(初回インストール・既存 ignored なし)何もしない。\n [[ -s \"${IGNORED_BASELINE_FILE}\" ]] || return 0\n SKILL_DIR=\".agents/skills/${SKILL_NAME}\" IGNORED_BACKUP_DIR=\"${IGNORED_BACKUP_DIR}\" IGNORED_BASELINE_FILE=\"${IGNORED_BASELINE_FILE}\" python3 - \u003c\u003c'PYEOF'\nimport os, shutil, stat, sys\n\nskill_dir = os.environ[\"SKILL_DIR\"]\nprefix = skill_dir + \"/\"\nbackup_dir = os.environ[\"IGNORED_BACKUP_DIR\"]\nroot = os.path.realpath(skill_dir)\n\n\ndef fail(msg):\n # 復元に失敗したまま正常終了すると、破壊された既存 ignored ファイルが\n # 「復元済み」として扱われてしまうため、必ず非ゼロ終了して呼び出し側で\n # fail-closed(バックアップ dir の保全 + 手動復旧の案内)に扱わせる。\n print(f\"restore_preexisting_ignored: {msg}\", file=sys.stderr)\n sys.exit(1)\n\n\ndef make_parent_dirs(parent):\n # os.makedirs を先に呼ぶと、中間ディレクトリが外向き symlink へ置換されて\n # いた場合にリンク先(リポジトリ外)へディレクトリを作ってしまう — 作成\n # 自体がスコープ逸脱であり、後段の containment チェックで停止しても外側の\n # dir は残る(PR #412 Bugbot Medium 指摘)。そこで先に「存在する最深の祖先」\n # の realpath が許可先の内側か、許可先へ至る素の経路上(root の祖先)にある\n # ことを検証し、さらに作成は 1 階層ずつ lstat で symlink・非ディレクトリで\n # ないことを確認しながら os.mkdir で行う(symlink を辿る経路を残さない。\n # 実行後の配下 symlink 走査が先に停止させるため通常は到達しない防御多層)。\n anc = parent\n while anc and not os.path.lexists(anc):\n anc = os.path.dirname(anc)\n if anc:\n real_anc = os.path.realpath(anc)\n if not (\n real_anc == root\n or real_anc.startswith(root + os.sep)\n or root.startswith(real_anc + os.sep)\n ):\n fail(f\"復元先の祖先が許可先の外を指しています: {parent}\")\n cur = \"\"\n for part in parent.split(os.sep):\n cur = os.path.join(cur, part) if cur else part\n try:\n cst = os.lstat(cur)\n except FileNotFoundError:\n os.mkdir(cur)\n continue\n if not stat.S_ISDIR(cst.st_mode):\n fail(f\"復元先の中間経路がディレクトリではありません(symlink 等): {cur}\")\n\n\nwith open(os.environ[\"IGNORED_BASELINE_FILE\"], \"rb\") as f:\n paths = [p for p in f.read().split(b\"\\0\") if p]\n\nfor raw in paths:\n text = raw.decode(\"utf-8\", \"surrogateescape\")\n # 復元は kebab-case 検証済みの許可先配下に厳密に限定する(自前で列挙した\n # 一覧でも、prefix 一致と `..` セグメント不在を自衛的に再検証する)。\n if not text.startswith(prefix) or \"..\" in text.split(\"/\"):\n continue\n backup = os.path.join(backup_dir, text)\n try:\n bst = os.lstat(backup)\n changed = False\n reason = \"\"\n try:\n cst = os.lstat(text)\n if not stat.S_ISREG(cst.st_mode):\n changed, reason = True, \"種別が変化\"\n elif stat.S_IMODE(cst.st_mode) != stat.S_IMODE(bst.st_mode):\n changed, reason = True, \"mode が変化\"\n else:\n with open(backup, \"rb\") as f1, open(text, \"rb\") as f2:\n while True:\n c1 = f1.read(1 \u003c\u003c 20)\n c2 = f2.read(1 \u003c\u003c 20)\n if c1 != c2:\n changed, reason = True, \"内容が変化\"\n break\n if not c1:\n break\n except FileNotFoundError:\n changed, reason = True, \"削除されていた\"\n if not changed:\n continue\n parent = os.path.dirname(text)\n if parent:\n make_parent_dirs(parent)\n real_parent = os.path.realpath(parent)\n # 親ディレクトリが symlink 化されているとリンク先(リポジトリ外を\n # 含む)へ書き込んでしまうため、realpath の包含を検証してから書く。\n if real_parent != root and not real_parent.startswith(root + os.sep):\n fail(f\"復元先の親ディレクトリが許可先の外を指しています: {text}\")\n try:\n # 復元先自体が symlink 化されていた場合にリンク先へ書かないよう、\n # 既存エントリを外してから regular file として書き戻す。\n os.unlink(text)\n except FileNotFoundError:\n pass\n shutil.copyfile(backup, text)\n os.chmod(text, stat.S_IMODE(bst.st_mode))\n print(\n f\"警告: npx が実行前から存在した ignored ファイルを変更したため\"\n f\"({reason})、バックアップから復元しました: {text}\",\n file=sys.stderr,\n )\n except OSError as e:\n fail(f\"{text} の復元に失敗: {e}\")\nPYEOF\n}\n\n# スコープ内(skills-lock.json / .agents/skills/${SKILL_NAME}/)の変更をリバートする。\n# npx 失敗・スコープ外検出・実行後 git status 取得失敗のすべての異常終端経路が\n# 共有する(PR #412 P1: どの異常経路でも生成済みのスコープ内変更を残置しない契約)。\n# 2つのパスを1つの `git checkout --` に渡すとアトミックに扱われ、どちらか一方が\n# 「追跡対象なし」(初回具現化・untracked のみの書き込み時)で pathspec エラーになると\n# コマンド全体が失敗し、もう一方(skills-lock.json)も復元されないまま抜けてしまう\n# ため、必ず1コマンド1パスで分離する。git clean はディレクトリが存在しない場合に\n# 非ゼロ終了するため、存在確認してから呼ぶ(set -e 下で無条件に呼ぶと呼び出し元の\n# 案内メッセージより先にスクリプトが停止し得る)。.gitignore 対象の残置\n# (Issue #413)は -fdx ではなく remove_new_ignored_in_scope(実行前インベントリ\n# との突き合わせ)で解消する(既存 ignored ファイルの巻き添え削除防止)。\n# 実行後再検証(verify_scope_path_after_run)が許可先経路の symlink 化・非\n# ディレクトリ化を検出した場合、または実行後の配下 symlink 走査が npx 実行中に\n# 作られた symlink を検出した場合(いずれも SCOPE_PATH_COMPROMISED=1)は、\n# checkout・clean・ignored 削除のパス走査がリンク先(リポジトリ外を含む)へ\n# 向かい得るため、skills-lock.json の復元のみ行い、許可先配下への削除系操作は\n# 一切行わず案内に留める(symlink を通じた外部削除の防止。fail-closed)。\n# skills-lock.json 自体が npx 実行中に symlink へ置換されたケース\n# (LOCK_FILE_COMPROMISED=1)では、素の git checkout がリンク先(リポジトリ外を\n# 含む)へ書き込み得るため、先に rm で symlink 自体を除去(リンク先は辿らない)\n# してから checkout で index の内容を regular file として書き戻す。symlink 以外の\n# 想定外実体(ディレクトリ等)は rm -f で除去できず checkout も安全に働かないため、\n# 一切触らず手動復旧を案内する。\n# 第1引数(既定 0)を 1 にすると git clean -fd / remove_new_ignored_in_scope を\n# 一切実行しない「非破壊モード」になる(codex P0 指摘: preview_untracked の\n# git ls-files 失敗時など、未追跡集合を安全に列挙できなかった状態でこの関数を\n# 呼ぶと、checker / npx が作成した「唯一のコピーであり得る」未追跡ファイルを\n# バックアップなしで git clean -fd が削除してしまう。列挙が壊れている以上、\n# 何を消してよいか判定できないため、削除系操作は一切行わず手動復旧の案内に\n# 留める)。checkout(tracked のみが対象で未追跡ファイルには触れない)と\n# restore_preexisting_ignored(npx 実行前バックアップからの復元。列挙ではなく\n# 実行前に確定済みの一覧を使うため安全)はこのモードでも従来どおり実行する。\n#\n# Issue #422: 未追跡集合の「列挙自体」(git ls-files --others 等)が失敗し、1 件も\n# 退避できていない(=何が未退避か分からない)モードに限り、checkout が上書きし得る\n# worktree の現状を git 非依存の cp でまるごと保全してから tracked の checkout を\n# 行えるようにするための下ごしらえ。壊れている git には一切頼らず、ファイルシステム\n# 操作(cp -p / cp -RP)のみで skills-lock.json と .agents/skills/${SKILL_NAME}/ を\n# mktemp -d 先へ複製する。呼び出し元(revert_in_scope)は、この関数が成功した場合に\n# 限り checkout を実行し、失敗した場合は従来どおり checkout ごと丸ごとスキップする\n# (fail-closed。cp が失敗した状態で checkout すると、退避されていない worktree\n# 内容が無音に上書きされ得るため)。\ncontract_worktree_backup() {\n local dir\n if ! dir=\"$(mktemp -d)\"; then\n echo \"エラー: worktree 保全用の退避先ディレクトリ作成に失敗しました。\" >&2\n return 1\n fi\n local ok=1\n if [[ -e \"skills-lock.json\" ]]; then\n if [[ -L \"skills-lock.json\" ]]; then\n # symlink はリンク先(リポジトリ外を含む)を辿らないよう対象外にする\n # (SCOPE_PATH_COMPROMISED / LOCK_FILE_COMPROMISED と同じ fail-closed 判断。\n # 呼び出し元はこれらのフラグが 0 のときのみこの関数を呼ぶため通常到達しないが、\n # 二重の防御として保持する)。\n echo \"エラー: skills-lock.json がシンボリックリンクのため保全できません。\" >&2\n ok=0\n elif [[ -f \"skills-lock.json\" ]]; then\n cp -p -- \"skills-lock.json\" \"${dir}/skills-lock.json\" || ok=0\n fi\n fi\n if [[ \"${ok}\" -eq 1 && -e \".agents/skills/${SKILL_NAME}\" ]]; then\n if [[ -L \".agents/skills/${SKILL_NAME}\" ]]; then\n echo \"エラー: .agents/skills/${SKILL_NAME}/ がシンボリックリンクのため保全できません。\" >&2\n ok=0\n elif [[ -d \".agents/skills/${SKILL_NAME}\" ]]; then\n # -RP: シンボリックリンクを辿らずリンク自体を複製する(配下に symlink が\n # 混在していてもリンク先へは書き込み・読み取りしない)\n if ! mkdir -p \"${dir}/.agents/skills\"; then\n ok=0\n elif ! cp -RP -- \".agents/skills/${SKILL_NAME}\" \"${dir}/.agents/skills/${SKILL_NAME}\"; then\n ok=0\n fi\n fi\n fi\n if [[ \"${ok}\" -eq 1 ]]; then\n CONTRACT_WORKTREE_BACKUP_DIR=\"${dir}\"\n return 0\n fi\n # 部分コピーが残っている場合はデータ保全を優先し、削除せず退避先を保全する。\n # 何もコピーされていない(空のまま)場合のみ退避先ディレクトリを掃除する。\n if [[ -z \"$(find \"${dir}\" -mindepth 1 -print -quit 2>/dev/null)\" ]]; then\n rmdir \"${dir}\" 2>/dev/null || true\n else\n echo \"エラー: worktree の保全が一部失敗しましたが、退避できた分は ${dir} に残しています。\" >&2\n fi\n return 1\n}\n\nrevert_in_scope() {\n local skip_clean=\"${1:-0}\"\n # 呼び出し元が「復元を完全実行したか」を個別の原因フラグ(CONTRACT_UNTRACKED_\n # BACKUP_FAILED / SCOPE_PATH_COMPROMISED 等)の寄せ集めではなくこの単一フラグで\n # 判定できるよう、保全のため checkout / clean / 復元のいずれかをスキップした\n # 経路で必ず 1 を立てる(codex P1 指摘・PR #420: 原因フラグの個別参照では\n # SCOPE_PATH_COMPROMISED 経路のようにフラグが 0 のままスキップされる変種を\n # 取りこぼし、「リバートしました」の虚偽表示になる。スキップ経路が今後増えても\n # この関数内で 1 を立てれば表示分岐が漏れない)。呼び出しごとに 0 へ戻す。\n REVERT_IN_SCOPE_SKIPPED=0\n if [[ \"${CONTRACT_UNTRACKED_BACKUP_FAILED:-0}\" -eq 1 ]]; then\n # restore_contract_scope が契約範囲内の未追跡ファイル退避に失敗し、fail-closed で\n # index のみ復元(worktree は意図的に未復元)へ降格した直後の呼び出し。ここで\n # 通常どおり `git checkout -- \u003c契約パス>` を実行すると、その index(すでに\n # PRE_SYNC_TREE へ戻されている)を worktree へ書き戻し、restore_contract_scope が\n # 守ろうとした未退避の worktree 内容(checker / npx が作成した唯一のコピーで\n # あり得る)を無音に上書きしてしまう(Bugbot 指摘: Index restore enables later\n # overwrite / Issue #418 の再発)。既存 ignored の復元だけは untracked backup の\n # 失敗と無関係(別のバックアップ機構)のため、このブロックの外側で従来どおり実行する。\n #\n # Issue #422: ただし「未追跡集合の列挙自体」が失敗し 1 件も退避できていない\n # モード(個別 mv 失敗と異なり、何が未退避か分からないだけで worktree の現状\n # 自体は破損していない)に限り、checkout 前に git 非依存の cp で worktree を\n # まるごと保全できれば、tracked 分(skills-lock.json / .agents/skills 配下)の\n # checkout は行ってよい。これにより checker が無いリポジトリ(preview_untracked\n # ケース7)と同じ「tracked はリバートされる」挙動に揃える。妥協検出\n # (SCOPE_PATH_COMPROMISED / LOCK_FILE_COMPROMISED)がある場合は、checkout 先の\n # パス自体の信頼性が崩れているため、この救済経路には進まない。\n if [[ \"${CONTRACT_UNTRACKED_ENUM_FAILED:-0}\" -eq 1 \\\n && \"${SCOPE_PATH_COMPROMISED:-0}\" -eq 0 \\\n && \"${LOCK_FILE_COMPROMISED:-0}\" -eq 0 ]] \\\n && contract_worktree_backup; then\n # 1 コマンド 1 パスで分離する理由は他の checkout 呼び出しと同じ\n # (revert_in_scope 冒頭のコメント参照)。git clean は未追跡集合が依然\n # 不明なままのため、この経路でも実行しない。\n git checkout -- skills-lock.json 2>/dev/null || true\n git checkout -- \".agents/skills/${SKILL_NAME}/\" 2>/dev/null || true\n echo \"警告: 契約範囲内の未追跡ファイル列挙が失敗したため、checkout 前の worktree 内容を ${CONTRACT_WORKTREE_BACKUP_DIR} へ複製してから skills-lock.json / .agents/skills/${SKILL_NAME}/ の tracked 分のみ checkout しました(git clean は実行していません)。git status --porcelain -- skills-lock.json \\\".agents/skills/${SKILL_NAME}/\\\" で残留未追跡を確認し、必要なら手動で後始末してください(fail-closed。完全復元ではありません)。\" >&2\n else\n echo \"警告: 契約範囲内の未追跡ファイル退避が失敗したため、skills-lock.json / .agents/skills/${SKILL_NAME}/ の checkout・git clean は行いません(index のみ復元済みの worktree を上書きしないための保全)。git status --porcelain -- skills-lock.json \\\".agents/skills/${SKILL_NAME}/\\\" で内容を確認し、必要なら手動で復旧してください(fail-closed)。\" >&2\n fi\n REVERT_IN_SCOPE_SKIPPED=1\n else\n if [[ \"${LOCK_FILE_COMPROMISED}\" -ne 0 ]]; then\n if [[ -L \"skills-lock.json\" ]]; then\n rm -f skills-lock.json\n git checkout -- skills-lock.json 2>/dev/null || true\n elif [[ -e \"skills-lock.json\" && ! -f \"skills-lock.json\" ]]; then\n echo \"警告: skills-lock.json が想定外の実体(ディレクトリ等)に置換されているため自動復元しません。実体を確認・除去してから git checkout -- skills-lock.json で復元してください。\" >&2\n # skills-lock.json の復元をスキップしたため完全復元ではない\n REVERT_IN_SCOPE_SKIPPED=1\n else\n git checkout -- skills-lock.json 2>/dev/null || true\n fi\n else\n git checkout -- skills-lock.json 2>/dev/null || true\n fi\n if [[ \"${SCOPE_PATH_COMPROMISED}\" -ne 0 ]]; then\n # symlink 越しの走査を避けるため既存 ignored の復元も行わない。復元素材を\n # 失わないよう、バックアップ dir は削除せず保全して手動復旧に委ねる。\n IGNORED_BACKUP_KEEP=1\n echo \"警告: 許可先経路またはその配下が symlink 等へ置換・作成されているため、.agents/skills/${SKILL_NAME}/ への checkout・git clean・ignored 削除・既存 ignored の復元は行いません(リンク先への削除・書き込みを避けるため)。symlink の指す先と許可先の内容を手動確認し、symlink を除去してから復旧してください。既存 ignored ファイルのバックアップは ${IGNORED_BACKUP_DIR} に相対パス構造で残っています。\" >&2\n # codex P1 指摘(PR #420): この経路は CONTRACT_UNTRACKED_BACKUP_FAILED が\n # 0 のまま checkout・clean をスキップして return するため、原因フラグの個別\n # 参照で表示分岐すると「リバートしました」の虚偽表示になる。ここでも必ず立てる\n REVERT_IN_SCOPE_SKIPPED=1\n return 0\n fi\n git checkout -- \".agents/skills/${SKILL_NAME}/\" 2>/dev/null || true\n if [[ \"${skip_clean}\" == \"1\" ]]; then\n echo \"警告: 未追跡ファイル一覧を安全に取得できなかったため、.agents/skills/${SKILL_NAME}/ 配下の git clean は実行していません(checker / npx が作成した未追跡ファイルを誤って削除しないための保全。データ喪失防止を優先)。npx / checker による書き込み・未追跡ファイルが残っている可能性があるため、次を手動で確認してください: git status --porcelain -- \\\".agents/skills/${SKILL_NAME}/\\\"(内容を確認したうえで不要なもののみ git clean -fd \\\".agents/skills/${SKILL_NAME}/\\\" で削除する)。\" >&2\n # git clean をスキップし未追跡が残り得るため完全復元ではない\n REVERT_IN_SCOPE_SKIPPED=1\n elif [[ -d \".agents/skills/${SKILL_NAME}/\" ]]; then\n git clean -fd -- \".agents/skills/${SKILL_NAME}/\" || true\n remove_new_ignored_in_scope || true\n fi\n fi\n # 既存 ignored の変更・削除はここでバックアップから戻す(checkout / clean /\n # remove_new_ignored_in_scope はいずれも既存 ignored に触れないため、この復元が\n # 唯一の回復経路)。復元自体が失敗した場合はバックアップ dir を保全して案内する\n # (この関数の呼び出し元はすべて exit 1 で終端するため、非ゼロ終了の契約は保たれる)。\n if ! restore_preexisting_ignored; then\n IGNORED_BACKUP_KEEP=1\n # codex P1 指摘(PR #420・3 巡目): 復元失敗もフラグ契約の「復元をスキップした\n # (完全実行できなかった)経路」に含まれる。ここで立てないと npx 失敗・status\n # 取得失敗の呼び出し元が、既存 ignored が未復元のまま「変更はリバートしました」と\n # 表示してしまう\n REVERT_IN_SCOPE_SKIPPED=1\n echo \"エラー: 実行前から存在した ignored ファイルの復元に失敗しました。バックアップは ${IGNORED_BACKUP_DIR} に相対パス構造で残っています。手動で復旧してください。\" >&2\n fi\n}\n\n# npx 実行後の許可先経路の再検証(PR #412 P0 指摘: TOCTOU)。事前の lstat 検査は\n# 開始時点しか見ないため、npx が実行中に許可先(またはその親)を外向き symlink へ\n# 置換した・初回インストールで .agents 自体を symlink として作成したケースは、\n# 事後に全パス要素を再度 lstat しなければ検出できない(存在する要素はすべて実体の\n# ディレクトリであることを要求し、symlink・非ディレクトリは fail-closed で拒否)。\n# 併せて既存許可先は prune-under により要素自身が前後シグネチャへ署名されるため、\n# 置換自体もシグネチャ不一致として検出される(この関数はその場合の原因特定と、\n# 初回インストール(prune でシグネチャに現れない)の防御を担う)。\nverify_scope_path_after_run() {\n local component\n for component in \".agents\" \".agents/skills\" \".agents/skills/${SKILL_NAME}\"; do\n if [[ -L \"${component}\" ]]; then\n echo \"エラー: npx 実行後の再検証で ${component} がシンボリックリンクになっています。npx が実行中に許可先を symlink へ置換し、リンク先(リポジトリ外を含む)へ書き込んだ可能性があります。リンク先の内容とリポジトリ外への書き込み有無を手動確認してください(fail-closed)。\" >&2\n return 1\n fi\n if [[ -e \"${component}\" && ! -d \"${component}\" ]]; then\n echo \"エラー: npx 実行後の再検証で ${component} がディレクトリではありません。npx の書き込み先として想定外の実体のため、手動確認が必要です(fail-closed)。\" >&2\n return 1\n fi\n done\n return 0\n}\n\n# npx 実行後の skills-lock.json の実体再検証(PR #412 codex P0 指摘: TOCTOU)。\n# skills-lock.json は署名の prune と porcelain フィルタの双方からパスで除外される\n# ため、npx が実行中に外向き symlink へ置換してリンク先へ書き込んでもどの検査にも\n# 現れない。実行後に lstat し、存在するのに regular file でなければ fail-closed で\n# 拒否する(事前検証時の不存在→生成のケースも regular file であることを要求する。\n# 実行後の不存在は改変ではなく npx の書き込み不発のため、ここでは不問とする)。\nverify_lock_file_after_run() {\n if [[ -L \"skills-lock.json\" ]]; then\n echo \"エラー: npx 実行後の再検証で skills-lock.json がシンボリックリンクになっています。npx が実行中に置換し、リンク先(リポジトリ外を含む)へ書き込んだ可能性があります。リンク先の内容とリポジトリ外への書き込み有無を手動確認してください(fail-closed)。\" >&2\n return 1\n fi\n if [[ -e \"skills-lock.json\" && ! -f \"skills-lock.json\" ]]; then\n echo \"エラー: npx 実行後の再検証で skills-lock.json が regular file ではありません。npx の書き込み先として想定外の実体のため、手動確認が必要です(fail-closed)。\" >&2\n return 1\n fi\n return 0\n}\n\necho \"==> skills-lock.json を更新中: ${SKILL_NAME} (source: ${SOURCE_REPO})\"\necho \"\"\n\n# 更新前の computedHash を表示\necho \"変更前の computedHash:\"\nSKILL_NAME_VAR=\"${SKILL_NAME}\" python3 - \u003c\u003c'PYEOF'\nimport json, os, sys\nskill = os.environ['SKILL_NAME_VAR']\ntry:\n with open('skills-lock.json') as f:\n lock = json.load(f)\n skills = lock.get('skills', {})\n if skill in skills:\n print(skills[skill].get('computedHash', '(computedHash なし)'))\n else:\n print('(未登録)')\nexcept FileNotFoundError:\n print('(skills-lock.json が見つかりません)', file=sys.stderr)\n sys.exit(1)\nPYEOF\n\necho \"\"\n\n# skills-lock.json の clean チェック(sync 由来以外の変更の混入を防ぐ)\n# git diff 系は untracked を検出しないため porcelain を使う\nif [[ -n \"$(git status --porcelain -- skills-lock.json)\" ]]; then\n echo \"エラー: skills-lock.json に未コミットの変更があります。コミットまたは退避してから再実行してください。\" >&2\n exit 1\nfi\n\n# 当該スキルの install ツリーの clean チェック(npx による WIP 上書きを防ぐ)\n# git diff 系は untracked を検出しないため porcelain を使う(未追跡 WIP も保護対象)\nif [[ -n \"$(git status --porcelain -- \".agents/skills/${SKILL_NAME}/\")\" ]]; then\n echo \"エラー: .agents/skills/${SKILL_NAME}/ に未コミット変更(未追跡含む)があります。npx の上書きで失われるため中止します。コミットまたは退避してから再実行してください。\" >&2\n exit 1\nfi\n\n# 消費側リポジトリが vendored skill へ commit 済み local patch を適用している場合\n# (台帳: .agents/skills/LOCAL-PATCHES.md)、commit 済み patch は上の clean チェックを\n# 通過してしまうため、npx より前に repository-owned checker(無引数 = check mode)を\n# 必須にし、npx 後は stage より前に再適用(apply) + 最終検証を行う。\n# checker 非 0、および台帳があるのに checker が無い状態は fail-closed で停止する\nLOCAL_PATCH_GUARD=false\nif [[ -f scripts/check-skill-local-patches.sh ]]; then\n LOCAL_PATCH_GUARD=true\n # checker は導入先リポジトリが配置する実行可能コードであり、「存在するだけ」で実行しては\n # ならない(未信頼な checkout・未レビュー PR の任意コードが、差分提示・承認より前に\n # ユーザー権限で走る経路になる)。実行は次のすべてを満たす場合に限る(fail-closed):\n # (1) worktree の checker が symlink ではない regular file である(git hash-object は\n # symlink のリンク先内容を読むため、-L を先に拒否しないと「HEAD と同内容の外部\n # ファイルへの symlink」が (2) の一致検証をすり抜ける)\n # (2) HEAD に commit 済みで、worktree の内容が HEAD の blob と一致する\n # (未追跡・未コミット変更の checker は拒否 = レビューを経ていないコードを実行しない)\n # (3) HEAD 側のエントリ mode が 100644 / 100755 の regular file である\n # (120000 = symlink エントリの blob はリンク先文字列であり、実行対象にできない)\n # (4) ユーザーが由来(git log -1)と内容(git show)をレビューし、その blob hash を\n # CHECKER_APPROVED_HASH で明示承認している(承認は hash 単位。内容が変われば再承認)\n # 実行は worktree のファイルではなく、承認済み HEAD blob を取り出した一時ファイル\n # (CHECKER_EXEC)に対して行う。hash 確認後〜bash 実行の間に worktree の checker を\n # 差し替える TOCTOU 経路を、実行対象を承認済み blob へ固定することで断つ。\n # なお checker 自体は契約範囲外 path のため、apply がこれを書き換えた場合は\n # outside_state の前後 digest 比較でも fail-closed に検出される\n if [[ -L scripts/check-skill-local-patches.sh ]]; then\n echo \"エラー: scripts/check-skill-local-patches.sh がシンボリックリンクです。リンク先差し替えで HEAD 一致検証をすり抜けられるため同期を開始しません(fail-closed)。実体ファイルへ置き換えてから再実行してください。\" >&2\n exit 1\n fi\n if [[ ! -f scripts/check-skill-local-patches.sh ]]; then\n echo \"エラー: scripts/check-skill-local-patches.sh が regular file ではありません。実行対象にできないため同期を開始しません(fail-closed)。\" >&2\n exit 1\n fi\n CHECKER_HEAD_MODE=\"$(git ls-tree HEAD -- scripts/check-skill-local-patches.sh 2>/dev/null | awk '{print $1}')\"\n if [[ \"${CHECKER_HEAD_MODE}\" != \"100644\" && \"${CHECKER_HEAD_MODE}\" != \"100755\" ]]; then\n echo \"エラー: HEAD の scripts/check-skill-local-patches.sh が regular file ではありません(mode: ${CHECKER_HEAD_MODE:-エントリなし})。symlink 等は実行対象にできないため同期を開始しません(fail-closed)。\" >&2\n exit 1\n fi\n CHECKER_WORKTREE_HASH=\"$(git hash-object -- scripts/check-skill-local-patches.sh)\"\n CHECKER_HEAD_HASH=\"$(git rev-parse HEAD:scripts/check-skill-local-patches.sh 2>/dev/null || true)\"\n if [[ -z \"${CHECKER_HEAD_HASH}\" || \"${CHECKER_WORKTREE_HASH}\" != \"${CHECKER_HEAD_HASH}\" ]]; then\n echo \"エラー: scripts/check-skill-local-patches.sh が HEAD に commit 済みの内容と一致しません(未 commit・未追跡・未コミット変更)。任意コード実行を防ぐため同期を開始しません(fail-closed)。\" >&2\n exit 1\n fi\n if [[ \"${CHECKER_APPROVED_HASH:-}\" != \"${CHECKER_WORKTREE_HASH}\" ]]; then\n echo \"エラー: checker はユーザーがレビュー・承認した内容のみ実行できます。由来と内容を確認のうえ、blob hash を明示して再実行してください(fail-closed):\" >&2\n echo \" git log -1 -- scripts/check-skill-local-patches.sh # 由来(最終 commit)の確認\" >&2\n echo \" git show HEAD:scripts/check-skill-local-patches.sh # 実行される内容の確認\" >&2\n echo \" CHECKER_APPROVED_HASH=${CHECKER_WORKTREE_HASH} $0 ${SKILL_NAME} ${SOURCE_REPO}\" >&2\n exit 1\n fi\n # 承認済み HEAD blob を一時ファイルへ取り出す。以後の checker 実行(同期前 check /\n # apply / 最終 check)はすべてこのファイルを使い、worktree の checker は実行しない。\n # 取り出し失敗のまま進むと空ファイルの bash 実行(exit 0 の no-op)が「検証成功」に\n # 化けるため fail-closed で停止する。この trap は後段の単一 trap(mktemp 群の掃除)に\n # 置き換わるが、そちらにも CHECKER_EXEC の削除を含めてあるため掃除は途切れない\n CHECKER_EXEC=\"$(mktemp)\"\n trap '[[ -z \"${CHECKER_EXEC:-}\" ]] || rm -f \"${CHECKER_EXEC}\"' EXIT\n if ! git cat-file blob \"${CHECKER_HEAD_HASH}\" > \"${CHECKER_EXEC}\"; then\n echo \"エラー: 承認済み checker blob(${CHECKER_HEAD_HASH})の取り出しに失敗しました。同期を開始しません(fail-closed)。\" >&2\n exit 1\n fi\n\n # checker を実行する唯一の経路(codex P1 指摘: 単一プロセス内でも CHECKER_EXEC は\n # ただの一時ファイルであり、直前の実行〔特に apply は checker 自身のコードを実行する\n # ため自己書換えも可能〕による置換・改変〔TOCTOU〕が、検証されないまま次の実行へ\n # 紛れ込み得る。起動時に1度取り出した後は再検証せず bash に渡していたため、同期前\n # check の直後に CHECKER_EXEC が残置・置換された場合、承認済み blob と異なるコードが\n # apply・最終 check で実行される経路が残っていた)。呼び出しごとに毎回\n # `git hash-object` でハッシュを再検証し、不一致・不在なら承認済み blob から\n # 無条件に書き直してから実行する。検証と実行を1関数に閉じ込めることで、呼び出し\n # 箇所が増えても検証漏れが構造的に起きないようにする。\n # 戻り値の扱い: 「取り出し・検証自体の失敗」と「checker が実行された結果の非ゼロ\n # 終了」を呼び出し元が区別できるよう、前者は CHECKER_RUN_VERIFY_FAILED=1 を立てて\n # から非ゼロを返す(checker 自身の exit code と衝突しない専用シグナル)。\n run_checker() {\n CHECKER_RUN_VERIFY_FAILED=0\n if [[ -z \"${CHECKER_EXEC:-}\" || ! -f \"${CHECKER_EXEC}\" \\\n || \"$(git hash-object -- \"${CHECKER_EXEC}\" 2>/dev/null || echo missing)\" != \"${CHECKER_APPROVED_HASH}\" ]]; then\n # 置換前の一時ファイルを明示的に削除してから再代入する(Bugbot Medium / codex\n # P2 指摘: 再代入するだけだと EXIT trap は最後に代入された CHECKER_EXEC しか\n # 回収できず、途中で作られた旧一時ファイルが /tmp に残る。checker の apply に\n # よる自己書換えという通常ケースでもこの分岐は毎回通るため、mktemp の直前に\n # 確実に削除する)。\n [[ -z \"${CHECKER_EXEC:-}\" ]] || rm -f \"${CHECKER_EXEC}\"\n CHECKER_EXEC=\"$(mktemp)\"\n if ! git cat-file blob \"${CHECKER_APPROVED_HASH}\" > \"${CHECKER_EXEC}\" \\\n || [[ ! -s \"${CHECKER_EXEC}\" ]] \\\n || [[ \"$(git hash-object -- \"${CHECKER_EXEC}\")\" != \"${CHECKER_APPROVED_HASH}\" ]]; then\n echo \"エラー: 承認済み checker blob(${CHECKER_APPROVED_HASH})の取り出し・検証に失敗しました(fail-closed)。\" >&2\n CHECKER_RUN_VERIFY_FAILED=1\n return 1\n fi\n fi\n bash \"${CHECKER_EXEC}\" \"$@\"\n }\nelif [[ -f .agents/skills/LOCAL-PATCHES.md ]]; then\n echo \"エラー: .agents/skills/LOCAL-PATCHES.md があるのに scripts/check-skill-local-patches.sh がありません。local patch を検証できないため同期を開始しません(fail-closed)。\" >&2\n exit 1\nfi\n\nif [[ \"${LOCAL_PATCH_GUARD}\" == true ]]; then\n # durable patch 置き場は承認時に directory ごと stage し、却下時に index からの復元 +\n # git clean の対象になるため、未 stage の WIP・未追跡ファイルが残っていると巻き込まれて\n # 失われる。staged のみの変更(= 呼び出し元ループで承認済みに積み上がった分)は許容する。\n # producer(git status)を grep -q へ直接 pipe すると、-q の早期終了による SIGPIPE で\n # pipefail 下のパイプラインが偽になり WIP を見逃し得るため、一旦変数へ取得してから判定する\n LOCAL_PATCHES_STATUS=\"$(git status --porcelain -- scripts/local-patches/)\"\n if grep -q '^.[^ ]' \u003c\u003c\u003c\"${LOCAL_PATCHES_STATUS}\"; then\n echo \"エラー: scripts/local-patches/ に未 stage の変更・未追跡ファイルがあります。承認・却下経路が巻き込むため同期を開始しません(fail-closed)。\" >&2\n exit 1\n fi\n # 契約範囲(skills-lock.json / 当該スキル / durable patch)外の状態 digest。\n # worktree 側は「HEAD を基底にした一時 index へ範囲外のみ git add -A」した tree hash で\n # 捉える(未追跡・削除を含む全ファイルが実 blob hash で比較され、diff の表示文字列に\n # 依存しない = dirty なバイナリの上書きも検出する。範囲内は HEAD のまま固定されるため\n # 同期による正当な変更では digest が動かない)。index 側は ls-files -s の blob hash で捉える。\n # 基準(PRE_OUTSIDE)は checker の初回実行(同期前 check)より前に取得する。check の後に\n # 取得すると、check mode が行った範囲外変更が基準へ取り込まれ検出できなくなる。\n #\n # 【検出範囲の限界(重要)】この digest は Git が追跡・列挙できる対象(非 ignore の\n # worktree / index)に限られる。.gitignore 対象・.git/ 配下(config・hooks 等)・\n # リポジトリ外への書き込みは検出できない。同一権限で任意コードを実行した後の\n # tree 比較は書き込み制限の「保証」にはならず、信頼アンカーはあくまで実行前の\n # ユーザーによる checker 内容レビュー + blob hash 承認である。本検証はその上に\n # 重ねる best-effort の追加防御(defense-in-depth)として扱うこと\n OUTSIDE_PATHSPEC=(. \":(exclude)skills-lock.json\" \":(exclude).agents/skills/${SKILL_NAME}\" \":(exclude)scripts/local-patches\")\n # 内部コマンド(git read-tree / git add / git write-tree / git ls-files / git\n # hash-object)はいずれも `local` 代入・`$(...)` 経由で呼ぶため、失敗しても\n # `set -e` が必ずこの関数の呼び出し元(`$(outside_state)` を `[[ ]]` の条件式内で\n # 使う verify_outside_and_checker 等)まで伝播するとは限らない(`[[ ]]` の条件式は\n # errexit の伝播対象外であり、内部でコマンドが早期に失敗して空文字のまま関数を\n # 抜けても、その非ゼロ終了は握り潰されて文字列比較にしか使われない)。空 digest\n # 同士が偶然一致すると「契約範囲外の変更なし」と誤判定し得るため(Bugbot Medium\n # 指摘)、失敗時は他の呼び出し・他の成功時 digest と絶対に一致しない一意な\n # エラーマーカーを出力したうえで明示的に非ゼロを返す。PID($BASHPID。呼び出しごとに\n # 新しいサブシェルが fork されるため呼び出し間で重複しない)とナノ秒時刻を組み合わせ、\n # date が使えない環境向けに $RANDOM を二重フォールバックにする。\n outside_state() {\n local tmp_index_dir wt_tree idx_digest rc=0\n tmp_index_dir=\"$(mktemp -d)\" || rc=1\n if [[ \"${rc}\" -eq 0 ]] && ! GIT_INDEX_FILE=\"${tmp_index_dir}/index\" git read-tree HEAD; then\n rc=1\n fi\n if [[ \"${rc}\" -eq 0 ]] && ! GIT_INDEX_FILE=\"${tmp_index_dir}/index\" git add -A -- \"${OUTSIDE_PATHSPEC[@]}\"; then\n rc=1\n fi\n if [[ \"${rc}\" -eq 0 ]]; then\n wt_tree=\"$(GIT_INDEX_FILE=\"${tmp_index_dir}/index\" git write-tree)\" || rc=1\n fi\n rm -rf \"${tmp_index_dir}\"\n if [[ \"${rc}\" -eq 0 ]]; then\n idx_digest=\"$(git ls-files -s -z -- \"${OUTSIDE_PATHSPEC[@]}\" | git hash-object --stdin)\" || rc=1\n fi\n if [[ \"${rc}\" -ne 0 ]]; then\n echo \"(outside-state-error:${BASHPID:-$}:$(date +%s%N 2>/dev/null || echo \"${RANDOM}${RANDOM}\"))\"\n return 1\n fi\n echo \"${wt_tree}:${idx_digest}\"\n }\n if ! PRE_OUTSIDE=\"$(outside_state)\"; then\n echo \"エラー: 契約範囲外の基準 digest(PRE_OUTSIDE)の取得に失敗しました。以後の範囲外書き込み検出ができないため同期を開始しません(fail-closed。checker は未実行です)。\" >&2\n exit 1\n fi\n\n # checker の「すべての」実行(同期前 check / apply / 最終 check)の直後に、実行結果に\n # 関わらず範囲外 digest と checker 自身の blob hash を再検証する。範囲外を書き換えて\n # 非 0 終了するケース・checker 自身を未承認コードへ置換するケースを、次の実行より前に\n # fail-closed で検出するため。範囲外の破壊は契約範囲用の復元手順では戻らないため、\n # 手動復旧の案内を出す\n verify_outside_and_checker() {\n if [[ -L scripts/check-skill-local-patches.sh ]] \\\n || [[ \"$(git hash-object -- scripts/check-skill-local-patches.sh 2>/dev/null || echo missing)\" != \"${CHECKER_APPROVED_HASH}\" ]]; then\n echo \"エラー: checker 自身が書き換えられました(symlink 化を含む)。未承認の状態のため以後実行しません(fail-closed)。\" >&2\n return 1\n fi\n # outside_state の失敗は「変化なしと確認できない」であって「変化なし」ではない\n # ため、戻り値を明示的に検査する([[ ]] の条件式内で `$(outside_state)` を直接\n # 使うと、内部コマンドの失敗による非ゼロ終了が握り潰され空文字列同士の比較に\n # 落ちてしまう。関数自体が一意なエラーマーカーを返す設計にしてあるためこの\n # 比較でも安全側に倒れるが、ここでは戻り値も明示的に見て二重に fail-closed を\n # 担保する)。\n local outside_now\n if ! outside_now=\"$(outside_state)\"; then\n echo \"エラー: 契約範囲外の digest 取得に失敗しました。変化なしと確認できないため、範囲外書き込みありとして扱います(fail-closed)。\" >&2\n return 1\n fi\n if [[ \"${outside_now}\" != \"${PRE_OUTSIDE}\" ]]; then\n echo \"エラー: checker が契約範囲外の path を変更しました(fail-closed)。\" >&2\n echo \"git status --porcelain で範囲外の変更を特定し、tracked は git restore -- \u003cpath> / index は git restore --staged -- \u003cpath> で手動復旧してください(契約範囲用の復元手順では範囲外は戻りません)。checker 側の修正も必要です。\" >&2\n return 1\n fi\n return 0\n }\n\n # checker の「初回実行より前」に index snapshot を取得する。同期前 check(check mode)も\n # 契約上、契約範囲(当該スキル / skills-lock.json / durable patch)を変更・stage し得るため、\n # すべての失敗経路(pre-check 失敗・検証失敗・npx 失敗・apply / 最終 check 失敗)で\n # この snapshot で契約範囲全体を同期開始前へ戻す\n PRE_SYNC_TREE=\"$(git write-tree)\"\n\n # 契約範囲(当該スキル / skills-lock.json / durable patch)を同期開始前へ戻す共通処理。\n # checker(check mode 含む)は契約範囲を変更・stage し得るため、pre-check 失敗・検証失敗・\n # npx 失敗・apply / 最終 check 失敗のすべての失敗経路で、部分変更を残さず同期開始前へ\n # 復元してから終了する。復元は git restore の pathspec で契約パスに限定し、範囲外 path の\n # index・worktree には一切触れない(index 全体を git read-tree で書き換えると、\n # verify_outside_and_checker が「範囲外は git restore --staged -- \u003cpath> で手動復旧」と\n # 案内した直後にその案内自体を誤りにしてしまう)。git restore は no-overlay が既定のため、\n # 同期開始前 tree に無い tracked ファイルは契約パス内に限り index・worktree から取り除かれる。\n # 契約範囲内の未追跡ファイルは git clean で即削除せず一時ディレクトリへ退避する\n # (checker が契約ディレクトリ内へ移動・新規作成したファイルの唯一のコピーであり得るため、\n # 削除はデータ喪失になる。退避先を案内し、削除の判断は人間へ委ねる)\n restore_contract_scope() {\n : \"${PRE_SYNC_TREE:?同期開始前 snapshot が未設定のため復元できません}\"\n local restore_targets=(--staged --worktree) untracked_list moved=0 p\n # 退避(mkdir -p / mv)自体の失敗を検査するためのフラグ。呼び出し文は全箇所\n # `restore_contract_scope || true` の形であり、bash の仕様上 `||` の右辺・左辺の\n # コマンド文脈で呼ばれた関数内では set -e が抑止される(本体は set -euo pipefail\n # 下だが、この関数内の mkdir/mv の失敗は自動では中断にならない)。検査なしに\n # git restore --worktree へ進むと、checker が未追跡化・新規作成した「唯一の\n # コピー」が退避されないまま PRE_SYNC_TREE の内容で無音に上書きされ、データ喪失に\n # なる(Issue #418)。retreat 対象を 1 件ずつ処理し、失敗があれば worktree 復元を\n # 行わず index のみへ降格する(revert_in_scope の非破壊モードと同じ判断)\n local backup_failed=0 failed_paths=()\n # Issue #422: backup_failed のうち「列挙自体(mktemp / git ls-files / mktemp -d)が\n # 失敗し 1 件も退避していない」モードだけを区別するフラグ。個別ファイルの mv 失敗\n # (enum_failed=0 のまま backup_failed=1 になる)とは異なり、この場合は「未追跡集合が\n # 何か」自体が不明なため git clean は依然として行えないが、「worktree の現状自体は\n # 壊れていない」ため revert_in_scope が cp 退避を挟んで tracked の checkout を\n # 救済できる(revert_in_scope 側の CONTRACT_UNTRACKED_ENUM_FAILED 分岐参照)\n local enum_failed=0\n # npx 実行後検証が許可先経路・skills-lock.json の妥協(symlink 化等)を検出している場合、\n # worktree への書き込み・未追跡退避のパス走査がリンク先(リポジトリ外を含む)へ向かい得る\n # ため index のみ復元する(worktree 側は revert_in_scope が手動復旧を案内済み)。\n # 妥協フラグは npx 実行後にのみ設定されるため、それ以前の失敗経路では既定 0 で参照する\n if [[ \"${SCOPE_PATH_COMPROMISED:-0}\" -ne 0 || \"${LOCK_FILE_COMPROMISED:-0}\" -ne 0 ]]; then\n restore_targets=(--staged)\n echo \"許可先経路または skills-lock.json の妥協を検出しているため、契約範囲の復元は index のみ行います。worktree 側は案内済みの手順で手動復旧してください。\" >&2\n else\n if ! untracked_list=\"$(mktemp)\"; then\n echo \"エラー: 未追跡ファイル列挙用の一時ファイル作成に失敗しました。退避の完全性を確認できないため index のみ復元します。\" >&2\n backup_failed=1\n enum_failed=1\n fi\n # skills-lock.json も退避対象に含める。通常は tracked のため列挙されないが、checker が\n # git rm --cached 等で未追跡化して内容変更した後に失敗すると、退避なしの git restore が\n # その唯一の内容を上書きしてしまう\n if [[ \"${backup_failed}\" -eq 0 ]] \\\n && ! git ls-files -z --others --exclude-standard -- skills-lock.json \".agents/skills/${SKILL_NAME}/\" scripts/local-patches/ > \"${untracked_list}\"; then\n # 列挙自体が失敗した場合、退避の完全性を確認できないまま worktree 復元へ\n # 進むと未列挙の未追跡ファイルが無音に上書きされ得るため、警告に留めず\n # 失敗として扱う(revert_in_scope 導入時と同じ fail-closed 判断)\n echo \"エラー: 契約範囲内の未追跡ファイル列挙に失敗しました。退避の完全性を確認できないため index のみ復元します。\" >&2\n backup_failed=1\n enum_failed=1\n fi\n if [[ \"${backup_failed}\" -eq 0 ]]; then\n if ! CONTRACT_UNTRACKED_BACKUP_DIR=\"$(mktemp -d)\"; then\n echo \"エラー: 未追跡ファイルの退避先ディレクトリ作成に失敗しました。index のみ復元します。\" >&2\n backup_failed=1\n enum_failed=1\n fi\n fi\n if [[ \"${backup_failed}\" -eq 0 ]]; then\n while IFS= read -r -d '' p; do\n if ! mkdir -p \"${CONTRACT_UNTRACKED_BACKUP_DIR}/$(dirname \"${p}\")\" \\\n || ! mv -- \"${p}\" \"${CONTRACT_UNTRACKED_BACKUP_DIR}/${p}\"; then\n # 退避に失敗したファイルはスキップし、残りのファイルの退避は継続する\n # (保全できるコピーを最大化する)。1 件でも失敗すれば worktree 復元は\n # 行わない\n backup_failed=1\n failed_paths+=(\"${p}\")\n continue\n fi\n moved=1\n done \u003c \"${untracked_list}\"\n fi\n rm -f \"${untracked_list:-}\"\n if [[ \"${moved}\" -eq 1 ]]; then\n echo \"契約範囲内の未追跡ファイルは削除せず ${CONTRACT_UNTRACKED_BACKUP_DIR} に相対パス構造で退避しました。内容を確認し、不要なら手動で削除してください。\" >&2\n elif [[ -n \"${CONTRACT_UNTRACKED_BACKUP_DIR:-}\" ]]; then\n # backup_failed=1 かつ moved=0(退避先ディレクトリ作成には成功したが最初の\n # ファイルの mkdir/mv で失敗した)場合も、空のまま残る退避先ディレクトリを掃除する。\n # CONTRACT_UNTRACKED_BACKUP_DIR は mktemp -d 自体が失敗した経路(未追跡ファイル\n # 列挙用の一時ファイル作成失敗・git ls-files 失敗・mktemp -d 失敗)では未設定の\n # ままのため、set -u 下での unbound variable エラーを避けるため -n で存在確認\n # してから参照する\n rmdir \"${CONTRACT_UNTRACKED_BACKUP_DIR}\" 2>/dev/null || true\n fi\n if [[ \"${backup_failed}\" -eq 1 ]]; then\n # 退避の完全性を確認できない以上、worktree への git restore は行わない\n # (未退避の唯一のコピーを上書きするおそれがあるため)。index のみ復元へ降格する\n restore_targets=(--staged)\n echo \"エラー: 契約範囲内の未追跡ファイルの退避に失敗しました。worktree の復元は行いません(fail-closed)。\" >&2\n if [[ \"${#failed_paths[@]}\" -gt 0 ]]; then\n echo \"退避できなかったパス: ${failed_paths[*]}\" >&2\n fi\n if [[ \"${moved}\" -eq 1 ]]; then\n echo \"退避済み分は ${CONTRACT_UNTRACKED_BACKUP_DIR} に残しています。\" >&2\n fi\n echo \"未退避のファイルを手動で退避してから、git restore --worktree --source=${PRE_SYNC_TREE} -- \u003cpath> で契約範囲の worktree を復旧してください。\" >&2\n fi\n fi\n # revert_in_scope(この関数の直後に必ず呼ばれる)が backup_failed を検知できる\n # よう、global へも反映する。backup_failed=1 のとき restore_targets は\n # (--staged) へ降格して worktree には触れないが、`git restore --staged` 自体は\n # tracked な契約パスの index を PRE_SYNC_TREE へ戻すため、この後 revert_in_scope\n # が無条件に `git checkout -- \u003c契約パス>` を実行すると、その index を worktree へ\n # 書き戻して未退避の worktree 内容を上書きしてしまう(Bugbot 指摘: Index restore\n # enables later overwrite)。revert_in_scope 側でこの global を見て checkout /\n # clean を丸ごとスキップし、退避が失敗した worktree を意図的に未復元のまま残す。\n CONTRACT_UNTRACKED_BACKUP_FAILED=\"${backup_failed}\"\n CONTRACT_UNTRACKED_ENUM_FAILED=\"${enum_failed}\"\n # 複数パスを 1 コマンドへ渡すと pathspec 不一致 1 件で全体が失敗するため 1 コマンド\n # 1 パスで分離する(revert_in_scope と同じ理由)。pathspec は index に対しても照合される\n # ため、PRE_SYNC_TREE 取得後に新規作成・stage されたファイルは tree に無くても\n # no-overlay で index(・worktree)から取り除かれる(実測済み)。pathspec 不一致\n # (tree にも index にも無い)は復元対象なしを意味するため許容するが、真の失敗\n # (権限エラー等)と区別が付かないため、成功可否はコマンドの終了コードではなく\n # 下の復元後検証で fail-closed に判定する\n git restore \"${restore_targets[@]}\" --source=\"${PRE_SYNC_TREE}\" -- skills-lock.json 2>/dev/null || true\n git restore \"${restore_targets[@]}\" --source=\"${PRE_SYNC_TREE}\" -- \".agents/skills/${SKILL_NAME}/\" 2>/dev/null || true\n git restore \"${restore_targets[@]}\" --source=\"${PRE_SYNC_TREE}\" -- scripts/local-patches/ 2>/dev/null || true\n # 復元後検証(fail-closed): 契約パスの index が PRE_SYNC_TREE と一致し、worktree 復元\n # モードでは worktree 側も PRE_SYNC_TREE と一致し未追跡も残っていない(未追跡は上で\n # 退避済み)ことを実測してから成功を表示する。pathspec miss や restore の失敗を\n # 「復元対象なし」として成功扱いすると、新規 staged ファイルの残留(git add への\n # 混入経路)を見逃すため。worktree 側の判定基準は HEAD ではなく PRE_SYNC_TREE:\n # HEAD 基準の git status --porcelain を使うと、同期前から存在した正当な staged 変更\n # (scripts/local-patches/ で許容している「staged のみの変更」)が復元完了後も常に\n # 非空として現れ、PRE_SYNC_TREE と完全一致した正しい復元を誤報してしまう\n local verify_ok=1\n if [[ \"${backup_failed}\" -eq 1 ]]; then\n # 退避に失敗している以上、index 側が PRE_SYNC_TREE と一致していても\n # 「復元完了」ではない(worktree は意図的に未復元のまま残しているため)。\n # 無条件の成功メッセージを出さず、常に fail-closed の非ゼロで終了する\n verify_ok=0\n elif ! git diff --cached --quiet \"${PRE_SYNC_TREE}\" -- skills-lock.json \".agents/skills/${SKILL_NAME}/\" scripts/local-patches/; then\n verify_ok=0\n elif [[ \"${restore_targets[*]}\" == *--worktree* ]]; then\n if ! git diff --quiet \"${PRE_SYNC_TREE}\" -- skills-lock.json \".agents/skills/${SKILL_NAME}/\" scripts/local-patches/ \\\n || [[ -n \"$(git ls-files --others --exclude-standard -- skills-lock.json \".agents/skills/${SKILL_NAME}/\" scripts/local-patches/)\" ]]; then\n verify_ok=0\n fi\n fi\n if [[ \"${verify_ok}\" -eq 1 ]]; then\n echo \"契約範囲を同期開始前(PRE_SYNC_TREE=${PRE_SYNC_TREE})へ復元しました(範囲外 path の index・worktree には触れていません)。\" >&2\n elif [[ \"${backup_failed}\" -eq 1 ]]; then\n echo \"エラー: 未追跡ファイルの退避に失敗したため、契約範囲の復元は index のみで停止しました(worktree は未復元。fail-closed)。\" >&2\n # 呼び出し元が失敗を検知できるよう非ゼロを返す(Bugbot Medium 指摘: 従来は\n # echo するだけで成功扱いのまま返っていたため、呼び出し元が fail-closed に\n # 分岐できなかった)。全呼び出し箇所は本関数の呼び出し文を\n # `restore_contract_scope || true` の形にしてこの非ゼロを吸収している —\n # 本体は set -euo pipefail 下にあり、素の呼び出し文のまま非ゼロを返すと\n # 呼び出し元の revert_in_scope・後続の exit 1 の直前に置かれた案内 echo が\n # 実行されずスクリプトがその場で異常終了してしまう(cleanup が一部\n # スキップされる回帰)。`|| true` により本関数の失敗はここで吸収しつつ、\n # 呼び出し元は元から本関数の直後で無条件に exit 1 する設計のため、\n # 最終的な fail-closed の結果(非ゼロ終了・スコープ内リバート実行)は\n # 変わらない。\n return 1\n else\n echo \"エラー: 契約範囲の復元後検証で差分が残っています。git diff --cached ${PRE_SYNC_TREE} -- \u003c契約パス> / git diff ${PRE_SYNC_TREE} -- \u003c契約パス> / git ls-files --others -- \u003c契約パス> で残留を確認し、git restore --staged --worktree --source=${PRE_SYNC_TREE} -- \u003cpath> で手動復旧してください(fail-closed。復元完了とは扱いません)。\" >&2\n return 1\n fi\n }\n\n echo \"==> 同期前の local patch 検証(check)\"\n pre_check_rc=0\n # 実行対象は worktree のファイルではなく、承認済み blob を毎回再検証してから取り出す\n # CHECKER_EXEC(run_checker)\n run_checker || pre_check_rc=$?\n if [[ \"${CHECKER_RUN_VERIFY_FAILED}\" -eq 1 ]]; then\n restore_contract_scope || true\n echo \"(npx は実行していません)\" >&2\n exit 1\n fi\n if ! verify_outside_and_checker; then\n restore_contract_scope || true\n echo \"(npx は実行していません)\" >&2\n exit 1\n fi\n if [[ \"${pre_check_rc}\" -ne 0 ]]; then\n restore_contract_scope || true\n echo \"エラー: 同期前の local patch 検証に失敗しました。状態を修復してから再実行してください(fail-closed。npx は実行していません)。\" >&2\n exit 1\n fi\nfi\n\n# 一時ファイルは npx 実行前後のスナップショット比較に使うため、npx を呼ぶより前に\n# すべて用意し、単一の trap へまとめて登録する(trap は後勝ちのため、複数箇所に\n# 分散させると先に登録した分の掃除が消える)。UNTRACKED_LIST_FILE はこの後の\n# 未追跡ファイル一覧化(プレビュー表示)でも使うため、ここでまとめて確保する。\nSNAP_BEFORE=\"$(mktemp)\"\nSNAP_AFTER=\"$(mktemp)\"\nSNAP_FILTERED_BEFORE=\"$(mktemp)\"\nSNAP_FILTERED_AFTER=\"$(mktemp)\"\nNPX_OUTPUT_FILE=\"$(mktemp)\"\nUNTRACKED_LIST_FILE=\"$(mktemp)\"\nSCOPE_INVENTORY_FILE=\"$(mktemp)\"\nIGNORED_BASELINE_FILE=\"$(mktemp)\"\n# 既存 ignored ファイルの実行前バックアップ先(相対パス構造・mode を保持)。\n# 復元失敗時は IGNORED_BACKUP_KEEP=1 で削除を抑止し、手動復旧の素材として残す。\nIGNORED_BACKUP_DIR=\"$(mktemp -d)\"\nIGNORED_BACKUP_KEEP=0\ntrap 'rm -f \"${SNAP_BEFORE}\" \"${SNAP_AFTER}\" \"${SNAP_FILTERED_BEFORE}\" \"${SNAP_FILTERED_AFTER}\" \"${NPX_OUTPUT_FILE}\" \"${UNTRACKED_LIST_FILE}\" \"${SCOPE_INVENTORY_FILE}\" \"${IGNORED_BASELINE_FILE}\"; if [[ \"${IGNORED_BACKUP_KEEP}\" -eq 0 ]]; then rm -rf \"${IGNORED_BACKUP_DIR}\"; fi; [[ -z \"${CHECKER_EXEC:-}\" ]] || rm -f \"${CHECKER_EXEC}\"' EXIT\n\n# npx 実行後の許可先経路 再検証(verify_scope_path_after_run)と許可先配下の\n# 実行後 symlink 走査の結果フラグ。1 のとき revert_in_scope は許可先配下への\n# 削除系操作を行わない(symlink 越しのリポジトリ外削除の防止)。npx 実行直後に\n# 一度だけ更新する。\nSCOPE_PATH_COMPROMISED=0\n# skills-lock.json の実行後 再検証(verify_lock_file_after_run)の結果フラグ。\n# 1 のとき revert_in_scope は素の git checkout でリンク先へ書き込まないよう、\n# symlink を rm で除去してから復元する。npx 実行直後に一度だけ更新する。\nLOCK_FILE_COMPROMISED=0\n\n# npx 実行前の許可先配下の全ファイルインベントリ(.gitignore 対象を含む。\n# find -print0 の NUL 区切り)。revert_in_scope(remove_new_ignored_in_scope)が\n# 「npx が新規作成した ignored ファイル」だけを選別削除するための基準になる\n# (実行前から存在した ignored ファイルの巻き添え削除防止。PR #412 Bugbot Medium\n# 指摘)。取得に失敗すると選別基準を失い、リバートが既存 ignored ファイルを誤削除\n# し得るため fail-closed で中止する(この時点では npx 未実行のため残置なし)。\n# 許可先が不存在(初回インストール)の場合は空インベントリ(npx が作るものはすべて\n# 新規)とする。\nif [[ -e \".agents/skills/${SKILL_NAME}\" ]]; then\n # 許可先配下の既存 symlink の事前拒否。repo_state_signature は prune-under で\n # 許可先配下を署名から除外するため、配下に実行前から symlink があると、npx が\n # リンクを辿ってリポジトリ外へ書き込んでも前後比較のどの検査にも現れない\n # (ルート 3 要素の lstat 検証は経路自身しか見ず、配下は守れない)。1 件でも\n # あれば npx 未実行のまま fail-closed で中止する。この拒否により、後段の既存\n # ignored バックアップ・復元は対象を regular file のみと仮定できる。走査自体の\n # 失敗も「symlink なしと確認できない」であって「なし」ではないため中止する。\n if ! SCOPE_PREEXISTING_SYMLINKS=\"$(find \".agents/skills/${SKILL_NAME}\" -type l)\"; then\n echo \"エラー: 許可先配下の symlink 走査(find)に失敗しました。リポジトリ外への書き込み経路が無いことを確認できないため中止します(fail-closed)。\" >&2\n exit 1\n fi\n if [[ -n \"${SCOPE_PREEXISTING_SYMLINKS}\" ]]; then\n echo \"エラー: 許可先(.agents/skills/${SKILL_NAME})配下に既存のシンボリックリンクがあります。npx がリンクを辿ってリポジトリ外へ書き込んでも、配下を署名から除外している前後比較では検出できないため中止します(fail-closed)。以下を実体ファイルへ置き換えるか削除してから再実行してください:\" >&2\n printf '%s\\n' \"${SCOPE_PREEXISTING_SYMLINKS}\" >&2\n exit 1\n fi\n if ! find \".agents/skills/${SKILL_NAME}\" -print0 > \"${SCOPE_INVENTORY_FILE}\"; then\n echo \"エラー: npx 実行前の許可先インベントリ取得(find)に失敗しました。リバート時に既存 ignored ファイルを誤削除しないための基準を確保できないため中止します。\" >&2\n exit 1\n fi\n # 既存 ignored ファイル(実行前時点)の列挙とバックアップ。実行前インベントリは\n # パス一覧のみで内容を持たないため、npx が既存 ignored を上書き・削除しても\n # そのままでは復元も検出もできない(許可先配下は署名から除外され、シグネチャ\n # 比較にも現れない)。内容・mode を相対パス構造ごと退避し(cp -p)、実行後に\n # restore_preexisting_ignored が比較・復元する。列挙・バックアップの失敗は\n # 復元素材を確保できないため npx 未実行のまま中止する(fail-closed)。\n if ! git ls-files -z --others --ignored --exclude-standard -- \".agents/skills/${SKILL_NAME}/\" > \"${IGNORED_BASELINE_FILE}\"; then\n echo \"エラー: npx 実行前の既存 ignored ファイル列挙(git ls-files)に失敗しました。上書き・削除からの復元素材を確保できないため中止します(fail-closed)。\" >&2\n exit 1\n fi\n while IFS= read -r -d '' IGNORED_PATH; do\n if ! mkdir -p \"${IGNORED_BACKUP_DIR}/${IGNORED_PATH%/*}\" \\\n || ! cp -p -- \"${IGNORED_PATH}\" \"${IGNORED_BACKUP_DIR}/${IGNORED_PATH}\"; then\n echo \"エラー: 既存 ignored ファイルのバックアップに失敗しました: ${IGNORED_PATH} — 上書き・削除からの復元素材を確保できないため中止します(fail-closed)。\" >&2\n exit 1\n fi\n done \u003c \"${IGNORED_BASELINE_FILE}\"\nfi\n\n# npx 実行前のリポジトリ全体スナップショット(スコープ外書き込み検出の起点)。\n# -z は改行等を含むパスでも1レコード1件を保つため、後段の filter_out_of_scope が\n# C-quote に影響されず判定できる。-uall は新規ディレクトリの collapse\n# (`?? .agents/` のような1行への丸め)を防ぐために必須(丸められると\n# ディレクトリ配下の個々のパスがスコープ判定の対象に現れない)。\n# status.renames=false で rename 検出を明示的に無効化し、2フィールド\n# (新パス + 旧パス)のレコードが混入しない形にそろえる(既定でも通常は無効だが、\n# ユーザー環境の git config 依存にしない)。\nif ! git -c status.renames=false status --porcelain -z -uall > \"${SNAP_BEFORE}\"; then\n echo \"エラー: npx 実行前の git status 取得に失敗しました。スコープ外書き込みの検出ができないため中止します。\" >&2\n exit 1\nfi\n\nfilter_out_of_scope \"${SNAP_BEFORE}\" \"${SNAP_FILTERED_BEFORE}\"\n\n# .agents / .agents/skills の omit(自身のメタデータ不記録)は「npx 実行前に存在\n# したか」で決める。既存なら通常どおり署名対象にして chmod・ディレクトリ→symlink\n# 置換を検出し(PR #412 P1 指摘)、不存在だった場合のみ omit して初回インストール\n# での正当な親ディレクトリ新規作成を誤検知にしない。判定は npx 実行前のここで\n# 一度だけ行い、前後の両シグネチャで同じ除外集合を使う(実行後に再判定すると\n# 初回作成が前後不一致になる)。-e は壊れた symlink で偽になるため -L も併せて\n# 見る(symlink 自体は「存在」として署名対象に含める)。\nif [[ ! -e \".agents\" && ! -L \".agents\" ]]; then\n REPO_SIG_OMITS+=(\"omit:.agents\")\nfi\nif [[ ! -e \".agents/skills\" && ! -L \".agents/skills\" ]]; then\n REPO_SIG_OMITS+=(\"omit:.agents/skills\")\nfi\n# 許可先ディレクトリ自身も同じ基準で確定する: 既存なら prune-under(既定値)のまま\n# 要素自身を署名し、npx 実行中のディレクトリ→symlink 置換(TOCTOU)を前後不一致で\n# 検出する。実行前に不存在(初回インストール)の場合のみ prune へ切り替え、npx に\n# よる正当な新規作成を誤検知にしない(このケースの symlink 化は実行後の\n# verify_scope_path_after_run が拒否する)。\nif [[ ! -e \".agents/skills/${SKILL_NAME}\" && ! -L \".agents/skills/${SKILL_NAME}\" ]]; then\n SKILL_DIR_SIG_SPEC=\"prune:.agents/skills/${SKILL_NAME}\"\nfi\n\n# リポジトリ全体の状態シグネチャは「その時点のディスク内容・モード」を読むため、\n# 必ず npx を呼ぶ前にここで確定させる。npx 実行後(事後検査の直前)に取得すると、\n# 「変更前」のつもりが実質「変更後」と一致してしまい、実行前から M・?? だった\n# スコープ外ファイルの内容・モードだけの上書きを見逃す(事前シグネチャ化を npx の\n# 後にまとめて行った最初の実装で実測した回帰)。取得失敗時はスコープ外書き込みを\n# 検出できないため fail-closed で中止する(この時点では npx 未実行のため残置なし)。\nif ! REPO_STATE_BEFORE=\"$(repo_state_signature)\"; then\n echo \"エラー: npx 実行前の状態シグネチャ取得に失敗しました。スコープ外書き込みの検出ができないため中止します。\" >&2\n exit 1\nfi\n\n# npx skills add で CLI に computedHash を更新させる\n# --yes(1つ目)は npx 自体のインストール確認プロンプトを非対話でスキップする\n# ものであり、skills CLI へ渡す --yes(末尾)とは別物(位置で区別される)。\n# skills@${SKILLS_CLI_VERSION} で exact 版のみ解決させ、該当版が存在しない・\n# レジストリ到達不能の場合は npx が非ゼロ終了する(fail-closed。最新版への暗黙\n# フォールバック経路は存在しない)。\n# --agent universal は書き込み先を union ストア(.agents/skills/\u003cname>/)+\n# skills-lock.json のみに限定する一次防御。個別 agent 指定(claude-code 等)は\n# .agents/skills を経由せず .claude/skills/ 等へ直接コピーしレイアウトを変えるため\n# 使わない(実測: スクラッチリポジトリで --agent universal のみ .agents/skills/\n# 以外へ書き込みが無いことを確認済み)。npx の出力は tee で NPX_OUTPUT_FILE へも\n# 保存し、後段の「Invalid agents」no-op 検出に使う。\n#\n# 終了コードの扱い: この行を素の `set -euo pipefail` 下に置くと、npx が非ゼロ終了\n# した瞬間に pipefail でスクリプトが即停止し、下の事後スナップショット取得・\n# スコープ外検査(この行より後の NPX_STATUS 分岐)に到達できない。npx が部分的に\n# スコープ外へ書き込んだ後に失敗した場合、その残置がまったく検出されないまま停止して\n# しまうため、この行の前後だけ errexit を無効化し、pipeline の終了コードを PIPESTATUS\n# 経由で変数へ保存して明示的に分岐する(成功・失敗いずれの経路でも下の事後検査へ\n# 必ず到達させる)。\n# npx 呼び出し自体は bare のまま1行を維持する(tests/version-pin.test.mjs の静的抽出\n# が行頭 npx の1物理行を前提にしているため、バックスラッシュ継続で複数行に分けない)。\n# PIPESTATUS は pipeline 直後の「次のコマンド実行前」にしか正しい値を保持しない。\n# 変数代入も1個のコマンドと数えられるため、`NPX_STATUS=...; TEE_STATUS=...` のように\n# 2つの代入に分けると、1つ目の代入自体がその時点の PIPESTATUS を(要素数1・\n# 値0の配列へ)上書きしてしまい、2つ目の代入が参照する PIPESTATUS[1] は\n# `set -u` 下で unbound variable エラーになる(実測: bash 5.3 で再現)。\n# 配列全体を単一の代入 `arr=(\"${PIPESTATUS[@]}\")` でスナップショットし、\n# 添字アクセスはそのコピーに対して行う。npx([0])・tee([1])両方を読む理由:\n# tee がディスク容量不足等で非ゼロ終了すると NPX_OUTPUT_FILE が空・不完全なまま\n# 残り得るが、npx 自体は成功(exit 0)し得るため、tee 側の失敗は npx の終了コード\n# だけを見る分岐からは検出できない(Issue #410 CI 失敗指摘)。TEE_STATUS が非ゼロ\n# なら、その不完全な NPX_OUTPUT_FILE を前提にした「Invalid agents」no-op 判定を\n# 信頼せず、NPX_STATUS を強制的に失敗へ倒して以降の失敗経路(事後スコープ外検査・\n# スコープ内リバート)へ必ず合流させる。\n# 復元を無条件 set -e にしている理由(Issue #417): 本スクリプトはファイル先頭\n# (このファイルの set -euo pipefail 宣言)で errexit を常時有効にする前提を自ら\n# 宣言しているため、SKILL.md の Step 4 フェンス(呼び出し元シェルの errexit 状態を\n# 保存し、元々有効だった場合のみ条件付きで復元する)と異なり、無条件 set -e で\n# 復元しても呼び出し元シェルの状態を意図せず変えることはなく正しい。\nset +e\nnpx --yes \"skills@${SKILLS_CLI_VERSION}\" add \"${SOURCE_REPO}\" --skill \"${SKILL_NAME}\" --agent universal --yes 2>&1 | tee \"${NPX_OUTPUT_FILE}\"\nPIPE_EXIT_SNAPSHOT=(\"${PIPESTATUS[@]}\")\nset -e\nNPX_STATUS=\"${PIPE_EXIT_SNAPSHOT[0]}\"\nTEE_STATUS=\"${PIPE_EXIT_SNAPSHOT[1]}\"\n\nif [[ \"${TEE_STATUS}\" -ne 0 ]]; then\n echo \"エラー: npx の出力を ${NPX_OUTPUT_FILE} へ保存する tee が失敗しました(終了コード ${TEE_STATUS}。ディスク容量不足等)。出力ファイルが不完全なため、npx 自体の終了コード(${NPX_STATUS})に関わらず失敗として扱います。\" >&2\n NPX_STATUS=1\nfi\n\n# 許可先経路の実行後再検証(PR #412 P0 指摘: 実行中の symlink 置換 / 初回\n# インストールでの symlink 作成)。事後シグネチャ取得より前にここで一度だけ判定し、\n# 検出時は共通失敗経路へ合流させる(revert_in_scope は SCOPE_PATH_COMPROMISED を\n# 見て許可先配下への削除系操作を行わず、リポジトリ外書き込みの可能性の手動確認を\n# 案内する)。\nif ! verify_scope_path_after_run; then\n SCOPE_PATH_COMPROMISED=1\n NPX_STATUS=1\nfi\n\n# skills-lock.json の実行後 再検証(PR #412 codex P0 指摘: 実行中の symlink 置換)。\n# 検出時は共通失敗経路へ合流させる(revert_in_scope は LOCK_FILE_COMPROMISED を見て\n# symlink を rm で除去してから checkout し、リンク先への書き込みを避ける)。\nif ! verify_lock_file_after_run; then\n LOCK_FILE_COMPROMISED=1\n NPX_STATUS=1\nfi\n\n# 許可先配下の実行後 symlink 走査(PR #412 Bugbot High 指摘: TOCTOU)。事前走査は\n# npx 実行前の 0 件しか保証せず、npx が実行中に配下へ作った外向き symlink とそれ\n# 経由の書き込みは、配下を署名から除外している前後比較にも、経路 3 要素しか見ない\n# verify_scope_path_after_run にも現れない。事後に見つかる symlink はすべて npx\n# 実行中の作成物であり、これを残したまま checkout・clean・ignored 削除・復元を\n# 走らせるとリンク先(リポジトリ外を含む)への削除・書き込みになり得るため、\n# 1 件でも検出したら経路妥協(SCOPE_PATH_COMPROMISED)と同じ保全経路へ倒す。\n# 走査自体の失敗も「symlink なしと確認できない」であって「なし」ではないため\n# 同様に倒す(fail-closed)。\nif [[ \"${SCOPE_PATH_COMPROMISED}\" -eq 0 && -d \".agents/skills/${SKILL_NAME}\" ]]; then\n if ! SCOPE_POST_SYMLINKS=\"$(find \".agents/skills/${SKILL_NAME}\" -type l)\"; then\n echo \"エラー: npx 実行後の許可先配下 symlink 走査(find)に失敗しました。リンク先(リポジトリ外を含む)への書き込み経路が無いことを確認できないため、許可先配下への自動復旧を行わず停止します(fail-closed)。\" >&2\n SCOPE_PATH_COMPROMISED=1\n NPX_STATUS=1\n elif [[ -n \"${SCOPE_POST_SYMLINKS}\" ]]; then\n echo \"エラー: npx が実行中に許可先(.agents/skills/${SKILL_NAME})配下へシンボリックリンクを作成しました。リンク先(リポジトリ外を含む)へ書き込んだ可能性があり、symlink 越しの走査を避けるため許可先配下への自動復旧は行いません。リンク先の内容とリポジトリ外への書き込み有無を手動確認してください(fail-closed):\" >&2\n printf '%s\\n' \"${SCOPE_POST_SYMLINKS}\" >&2\n SCOPE_PATH_COMPROMISED=1\n NPX_STATUS=1\n fi\nfi\n\n# CLI がバージョン更新等で --agent universal を認識できなくなった場合、\n# エラー表示のうえ exit 0 の no-op になる(実測: [email protected] で確認済み)。\n# 検知せず先へ進むと「同期したつもりで何も更新されていない」まま完了扱いに\n# なるため、明示的にエラー化する(NPX_STATUS -eq 0 のときのみ意味を持つ判定。\n# 上の TEE_STATUS チェックで NPX_STATUS が強制失敗化されていればここは通らない)。\n# この分岐で直接 exit すると実行後スナップショット・シグネチャ比較・スコープ内\n# リバートをすべて迂回する(PR #412 P1 指摘)。「Invalid agents」は CLI の出力文言に\n# 過ぎず、将来版が部分書き込みの後に同じ文言を出しても no-op とは限らないため、\n# NPX_STATUS=1 を設定して下の共通失敗経路へ合流させ、「成功・失敗いずれの経路でも\n# 事後検査へ必ず到達」の契約を守る(NPX_NOOP_DETECTED は重複する汎用メッセージの\n# 抑止にのみ使い、検査・リバートの経路は変えない)。\nNPX_NOOP_DETECTED=0\nif [[ \"${NPX_STATUS}\" -eq 0 && \"${SCOPE_PATH_COMPROMISED}\" -eq 0 ]] && grep -q \"Invalid agents\" \"${NPX_OUTPUT_FILE}\"; then\n echo \"エラー: skills CLI が --agent universal を認識せず、何も実行していません(exit 0 の no-op)。SKILLS_CLI_VERSION 更新時は SKILL.md の「skills CLI のバージョン固定と更新手順」節に従い universal の有効性を再確認してください。\" >&2\n NPX_NOOP_DETECTED=1\n NPX_STATUS=1\nfi\n\nif [[ \"${NPX_STATUS}\" -ne 0 ]]; then\n if [[ \"${NPX_NOOP_DETECTED}\" -eq 0 && \"${SCOPE_PATH_COMPROMISED}\" -eq 0 && \"${LOCK_FILE_COMPROMISED}\" -eq 0 ]]; then\n echo \"エラー: skills@${SKILLS_CLI_VERSION} の実行が失敗しました(該当版の不存在・レジストリ障害・ダウンロード中断等、原因は問いません)。\" >&2\n fi\n # 失敗が部分書き込み後に発生した場合、skills-lock.json / .agents/skills/${SKILL_NAME}/\n # に加えてスコープ外(他エージェントツリー等)にも残置され得るため、失敗経路でも\n # 実行後スナップショットを取ってスコープ外残留を検査する(下の成功経路と同一ロジック。\n # ここで検査しないと、次にこのディレクトリを扱う処理がスコープ外の残置差分を\n # 「元から存在した dirty 状態」として誤認しかねない)。\n git -c status.renames=false status --porcelain -z -uall > \"${SNAP_AFTER}\" || true\n filter_out_of_scope \"${SNAP_AFTER}\" \"${SNAP_FILTERED_AFTER}\"\n # 実行後シグネチャの取得失敗は「変化なしと確認できない」であって「変化なし」では\n # ないため、比較不能な sentinel を入れて必ず不一致(= 残置疑いの報告)へ倒す。\n if ! REPO_STATE_AFTER=\"$(repo_state_signature)\"; then\n echo \"エラー: npx 実行後の状態シグネチャ取得に失敗しました。変化なしと確認できないため、スコープ外残置ありとして扱います(fail-closed)。\" >&2\n REPO_STATE_AFTER=\"(signature-error)\"\n fi\n if ! cmp -s \"${SNAP_FILTERED_BEFORE}\" \"${SNAP_FILTERED_AFTER}\" \\\n || [[ \"${REPO_STATE_BEFORE}\" != \"${REPO_STATE_AFTER}\" ]]; then\n echo \"エラー: 失敗した npx 実行がスコープ外へも書き込んだ可能性があります。以下を確認してください(削除はしていません):\" >&2\n while IFS= read -r -d '' rec; do printf ' %s\\n' \"${rec}\" >&2; done \u003c \"${SNAP_FILTERED_AFTER}\"\n echo \" (ディレクトリの chmod 等、git status に現れない変化は上記一覧に載りません。状態シグネチャの不一致として検出されています)\" >&2\n fi\n # 次にこのディレクトリを扱う呼び出し元(SKILL.md の全スキル sync ループ等)が、\n # この失敗による残置差分を承認済みの変更や「既存の dirty 状態」と混同しないよう、\n # スコープ内(skills-lock.json / .agents/skills/${SKILL_NAME}/)分のみ即座にリバート\n # してから exit 1 で終端する。\n if [[ \"${LOCAL_PATCH_GUARD}\" == true ]]; then\n # 同期前 check(check mode)が契約範囲を変更・stage していた可能性があり、index からの\n # worktree 復元しか行わない revert_in_scope だけでは同期開始前へ戻らない。契約パス限定の\n # restore_contract_scope で index も同期開始前へ戻す(許可先経路・skills-lock.json の\n # 妥協検出時は関数内で index のみの復元へ切り替わる)。呼び出しは revert_in_scope より\n # 「前」でなければならない: revert_in_scope の git clean -fd を先に走らせると、\n # restore_contract_scope が退避するはずの契約範囲内の未追跡ファイル(checker が\n # 移動・新規作成した唯一のコピーであり得る)が削除される。復元後の revert_in_scope は\n # 契約パスの checkout / clean が実質 no-op になり、ignored ファイルの選別削除・\n # 既存 ignored の復元・妥協検出時の保全案内だけが働く\n restore_contract_scope || true\n fi\n revert_in_scope\n # revert_in_scope は保全のため checkout・git clean・復元の一部をスキップして\n # return することがある(CONTRACT_UNTRACKED_BACKUP_FAILED / SCOPE_PATH_COMPROMISED\n # 等。いずれも関数内の警告 echo 済み)。ここで無条件に「リバートしました」と\n # 表示すると、実際には未復元の worktree 変更が残っているにもかかわらず復元完了\n # したかのように見え、利用者が復旧不要と誤認する(Bugbot 指摘 / Issue #418 再発、\n # codex P1 指摘 / PR #420 の変種)。個別の原因フラグの寄せ集めではなく、関数が\n # スキップ有無を一元通知する REVERT_IN_SCOPE_SKIPPED のみで分岐する。\n if [[ \"${REVERT_IN_SCOPE_SKIPPED:-0}\" -ne 1 ]]; then\n echo \"スコープ内(skills-lock.json / .agents/skills/${SKILL_NAME}/)の変更はリバートしました。固定版を外した再実行はしません。\" >&2\n else\n echo \"固定版を外した再実行はしません(スコープ内は上記警告のとおり一部未復元で、手動復旧が必要です)。\" >&2\n fi\n exit 1\nfi\n\n# npx 実行後のスナップショット。前後のスコープ外差分を見るため、取得条件は\n# SNAP_BEFORE と完全に揃える。取得失敗時にその場で exit すると、npx が生成済みの\n# スコープ内変更を残置したままスコープ外シグネチャ検査も行われない(PR #412 P1\n# 指摘)。状態シグネチャは git 非依存(python3 の走査)で取得できるため、可能な\n# 範囲で事後検査(スコープ外残置疑いの報告)を行い、スコープ内をリバートしてから\n# fail-closed で非ゼロ終了する。\nif ! git -c status.renames=false status --porcelain -z -uall > \"${SNAP_AFTER}\"; then\n echo \"エラー: npx 実行後の git status 取得に失敗しました。porcelain レコードでの前後比較ができません。\" >&2\n if REPO_STATE_AFTER=\"$(repo_state_signature)\"; then\n if [[ \"${REPO_STATE_BEFORE}\" != \"${REPO_STATE_AFTER}\" ]]; then\n echo \"エラー: スコープ外(skills-lock.json / .agents/skills/${SKILL_NAME}/ 以外)へも書き込まれた可能性があります(状態シグネチャ不一致)。git status / git diff で手動確認してください(削除はしていません)。\" >&2\n fi\n else\n # シグネチャも取れない場合は「変化なし」と確認できないため、残置の可能性ありと\n # して案内する(fail-closed。green 側へ倒さない)。\n echo \"エラー: 実行後の状態シグネチャ取得にも失敗しました。スコープ外残置の可能性を排除できません。git status / git diff で手動確認してください(fail-closed)。\" >&2\n fi\n if [[ \"${LOCAL_PATCH_GUARD}\" == true ]]; then\n # 同期前 check の staged 契約変更を残さない(fail-closed 契約)。git clean -fd による\n # 未追跡削除より先に退避を効かせるため revert_in_scope より前に呼ぶ(NPX_STATUS 分岐と同じ)\n restore_contract_scope || true\n fi\n revert_in_scope\n # 上の分岐と同じ理由(Bugbot 指摘 / Issue #418 再発、codex P1 指摘 / PR #420 の\n # 変種): revert_in_scope が保全のため checkout・git clean・復元の一部をスキップ\n # した場合(CONTRACT_UNTRACKED_BACKUP_FAILED / SCOPE_PATH_COMPROMISED 等)、\n # worktree は未復元のまま残るため、無条件の「リバートしました」表示はしない。\n # 個別の原因フラグではなく REVERT_IN_SCOPE_SKIPPED のみで分岐する。\n if [[ \"${REVERT_IN_SCOPE_SKIPPED:-0}\" -ne 1 ]]; then\n echo \"スコープ内(skills-lock.json / .agents/skills/${SKILL_NAME}/)の変更はリバートしました。\" >&2\n else\n echo \"スコープ内は上記警告のとおり一部未復元です。手動復旧してください。\" >&2\n fi\n exit 1\nfi\n\nfilter_out_of_scope \"${SNAP_AFTER}\" \"${SNAP_FILTERED_AFTER}\"\n\n# 実行後シグネチャの取得失敗は「変化なしと確認できない」であって「変化なし」では\n# ないため、比較不能な sentinel を入れて必ず不一致(= 検出・リバート・停止)へ倒す。\nif ! REPO_STATE_AFTER=\"$(repo_state_signature)\"; then\n echo \"エラー: npx 実行後の状態シグネチャ取得に失敗しました。変化なしと確認できないため、スコープ外書き込みありとして扱います(fail-closed)。\" >&2\n REPO_STATE_AFTER=\"(signature-error)\"\nfi\n\n# スコープ外の状態が前後で一致しなければ、--agent universal が抑止しているはずの\n# スコープ外書き込みが発生したことになる(一次防御を突破した場合の多層防御)。\n# 判定は 2 軸: (1) porcelain レコード(SNAP_FILTERED_*。git の porcelain -z 出力順は\n# 決定的なためソート不要で cmp -s のバイト列比較のみで判定できる)と、\n# (2) リポジトリ全体の状態シグネチャ(REPO_STATE_*、repo_state_signature の出力)。\n# 実行前から M・?? だったスコープ外ファイルの内容・モードだけの上書きや、\n# ディレクトリの chmod・ディレクトリ向け symlink の変更・.gitignore 対象ファイルの\n# 上書きは porcelain レコードに現れないため、シグネチャ側の不一致だけがそれを\n# 検出できる(レコード側は検出時の報告用としても使う)。\nif ! cmp -s \"${SNAP_FILTERED_BEFORE}\" \"${SNAP_FILTERED_AFTER}\" \\\n || [[ \"${REPO_STATE_BEFORE}\" != \"${REPO_STATE_AFTER}\" ]]; then\n echo \"エラー: npx skills add がスコープ外(skills-lock.json / .agents/skills/${SKILL_NAME}/ 以外)へ書き込みました。\" >&2\n echo \"==> 実行前のスコープ外状態:\" >&2\n while IFS= read -r -d '' rec; do printf ' %s\\n' \"${rec}\" >&2; done \u003c \"${SNAP_FILTERED_BEFORE}\"\n echo \"==> 実行後のスコープ外状態:\" >&2\n while IFS= read -r -d '' rec; do printf ' %s\\n' \"${rec}\" >&2; done \u003c \"${SNAP_FILTERED_AFTER}\"\n echo \"(ディレクトリの chmod 等、git status に現れない変化は上記一覧に載りません。その場合は状態シグネチャの不一致として検出されています)\" >&2\n echo \"スコープ内(skills-lock.json / .agents/skills/${SKILL_NAME}/)の変更はこれからリバートします。\" >&2\n echo \"スコープ外は既存の WIP を巻き込む恐れがあるため自動リバートしません。\" >&2\n echo \"上記パスの内容を git status / git diff で確認し、必要なら手動で\" >&2\n echo \" git checkout -- \u003cpath> (変更前が clean だった追跡ファイルの場合)\" >&2\n echo \" git clean -fd \u003cpath> (変更前に存在しなかった未追跡ファイルの場合)\" >&2\n echo \"を実行してください。\" >&2\n if [[ \"${LOCAL_PATCH_GUARD}\" == true ]]; then\n # 同期前 check の staged 契約変更を残さない(fail-closed 契約)。git clean -fd による\n # 未追跡削除より先に退避を効かせるため revert_in_scope より前に呼ぶ(NPX_STATUS 分岐と同じ)\n restore_contract_scope || true\n fi\n revert_in_scope\n exit 1\nfi\n\n# 成功経路でも既存 ignored ファイル(.DS_Store 等)の変更・削除を実行前バックアップ\n# との比較で検査する。許可先配下は署名から除外されるためシグネチャ比較には現れず、\n# この比較だけが検出手段になる。変化があればバックアップから復元して警告し、同期\n# 自体は継続する(ignored ファイルは同期対象外で、復元すれば成果物に影響しないため)。\n# 復元に失敗した場合のみバックアップ dir を保全して非ゼロ終了する(fail-closed)。\nif ! restore_preexisting_ignored; then\n IGNORED_BACKUP_KEEP=1\n echo \"エラー: 実行前から存在した ignored ファイルの復元に失敗しました。バックアップは ${IGNORED_BACKUP_DIR} に相対パス構造で残っています。手動で復旧してください。\" >&2\n if [[ \"${LOCAL_PATCH_GUARD}\" == true ]]; then\n # 失敗終端では同期前 check の staged 契約変更も残さない(fail-closed 契約。\n # npx の同期結果ごと同期開始前へ戻し、部分状態のまま git add へ進む経路を断つ)\n restore_contract_scope || true\n fi\n # 他の post-npx 失敗経路と同じくスコープ内をリバートする。checker が無い\n # リポジトリでは、ここで revert_in_scope を呼ばないと npx による skills-lock.json /\n # .agents/skills/${SKILL_NAME}/ への書き込みが worktree に残ったまま停止し、\n # 次回実行時の「実行前 clean」ガードに引っかかって当該スキルが skip され続ける。\n # revert_in_scope は内部で restore_preexisting_ignored を再試行するが、失敗が\n # 続いても IGNORED_BACKUP_KEEP=1 で警告するだけで非ゼロ終了はしない\n # (呼び出し元のこの分岐がすでに exit 1 を担保しているため)。\n revert_in_scope\n exit 1\nfi\n\necho \"\"\necho \"==> 更新完了。変更内容:\"\n# install ツリーの上書きも確認するため、skills-lock.json と当該スキルの install ツリー両方を diff する。\n# git diff は未追跡ファイルを表示しない。スクリプト冒頭の clean ガード(porcelain)により\n# npx 実行前の install ツリーは必ず clean のため、npx が新規作成したファイルは\n# 例外なく未追跡になる。tracked diff だけでは upstream 側のファイル増加を一切見せずに\n# 承認判断(呼び出し元の git add)へ進んでしまうため、未追跡分を別途列挙・表示する。\ngit diff -- skills-lock.json \".agents/skills/${SKILL_NAME}/\"\n\n# 承認(呼び出し元の git add、-f なし)が新規に取り込む集合、拒否(git clean -fd、-x なし)が\n# 削除する集合と同一のもの(.gitignore 対象を除く非追跡ファイル)を列挙し、\n# 中身が見えないまま承認 / 拒否のどちらか一方だけが通過する非対称を無くす。\n# git ls-files の既定出力は改行区切りのため、ファイル名自体に改行を含む\n# 未追跡ファイルがあると 1 パスが複数の存在しないパスへ分割される。分割後の\n# 各 git diff は失敗し || true で握り潰される一方、後続の git add は実ファイルを\n# そのまま取り込むため、内容を表示しないまま承認できてしまう(-z / NUL 区切りで防ぐ)。\n# 未追跡ファイルのプレビュー(列挙 + 内容表示)。引数の pathspec 群を対象に実行する。\n# 通常プレビュー(当該スキルのみ)と、local patch guard 時の最終確認(当該スキル +\n# scripts/local-patches/)の両方から呼ばれ、同一の表示・fail-closed 挙動を共有する。\n# ここで trap を追加登録してはならない(trap は後勝ちのため、冒頭で登録した単一 trap の\n# 一時ファイル掃除が丸ごと消える)。UNTRACKED_LIST_FILE は冒頭の mktemp 群で確保済み。\npreview_untracked() {\nUNTRACKED_COUNT=0\necho \"\"\n# git ls-files をプロセス置換(`\u003c \u003c(...)`)へ直接つなぐと、`set -euo pipefail` は\n# その終了コードを検査しない。`git ls-files` が失敗(破損 index・権限エラー等)しても\n# while は単に0回実行され UNTRACKED_COUNT=0 のまま「新規(未追跡)ファイル: なし」と\n# 誤表示し、実際には存在する未追跡ファイルの内容を確認しないまま呼び出し元が\n# git add で承認してしまう(このスクリプトが防ごうとしている非対称そのもの)。\n# 通常のコマンド置換で一時ファイルへ書き出し、`if ! ...` で明示的に終了コードを検査する\n# ことで fail-closed にする。UNTRACKED_LIST_FILE 自体は npx 実行前のスナップショット\n# 取得と同時に mktemp 済み(単一 trap にまとめるため)。2 回目の呼び出しはリダイレクトの\n# 上書きで前回分を破棄するため、呼び出しごとの mktemp・rm は不要。\nif ! git ls-files -z --others --exclude-standard -- \"$@\" > \"${UNTRACKED_LIST_FILE}\"; then\n echo \"エラー: git ls-files が失敗し、未追跡ファイルの一覧化を確認できません。内容未確認のまま承認できてしまうため中止します。\" >&2\n # 他の post-npx 失敗経路(NPX_STATUS 分岐等)と同じく tracked 分の契約範囲は\n # 復元するが、破壊的な cleanup(git clean -fd)は行わない(codex P0 指摘の\n # 再修正: git ls-files が壊れている以上、restore_contract_scope 自身の未追跡\n # バックアップ列挙・git clean -fd が対象とする「新規未追跡集合」のどちらも\n # 安全に確定できない。この状態で revert_in_scope(無引数)の git clean -fd を\n # 実行すると、checker / npx が作成した「唯一のコピーであり得る」未追跡\n # ファイルをバックアップなしで削除しデータ喪失になり得る。restore_contract_scope\n # は git restore(tracked 限定。未追跡には触れない)が主体で、未追跡バックアップ\n # 部分は列挙失敗時に警告するだけで削除は行わないため、`|| true` で呼んでも\n # 安全。revert_in_scope は第2引数 1(skip_clean)で clean 系を一切スキップし、\n # checkout(tracked のみ)と restore_preexisting_ignored(実行前バックアップ\n # からの復元。列挙非依存)のみ行う。\n if [[ \"${LOCAL_PATCH_GUARD}\" == true ]]; then\n restore_contract_scope || true\n fi\n revert_in_scope 1\n echo \"エラー: 未追跡ファイルの一覧化ができなかったため、.agents/skills/${SKILL_NAME}/ 配下の破壊的な後始末(git clean -fd)はスキップしました。npx / checker の書き込み・未追跡ファイルが残っている可能性があります。git status --porcelain -- \\\".agents/skills/${SKILL_NAME}/\\\" で確認し、内容を確認したうえで不要なもののみ手動で git clean -fd \\\".agents/skills/${SKILL_NAME}/\\\" してください(fail-closed)。\" >&2\n exit 1\nfi\nwhile IFS= read -r -d '' f; do\n if [[ \"${UNTRACKED_COUNT}\" -eq 0 ]]; then\n echo \"==> 新規(未追跡)ファイル — 承認時に git add で取り込まれる集合:\"\n fi\n UNTRACKED_COUNT=$((UNTRACKED_COUNT + 1))\n # 空ファイルは git diff --no-index が差分を出力しないため、diff の見出しだけでは\n # どのファイルが追加されるか分からない。先に printf でファイル名自体を明示してから\n # 内容の diff を表示する(0 byte のファイルでも名前は必ず見える)。\n printf '%s\\n' \"--- ${f} ---\"\n # バイナリファイルは git diff --no-index が \"Binary files ... differ\" としか出力せず、\n # 追加される中身を一切提示しない。numstat の追加/削除行数が両方 \"-\" になる出力で\n # バイナリ判定し、内容の代わりに種別・サイズ・ハッシュを明示することで、中身を\n # 確認できないまま承認(git add)だけが通ってしまう非対称を防ぐ。\n NUMSTAT=\"$(git diff --no-index --numstat -- /dev/null \"${f}\" 2>/dev/null || true)\"\n if [[ \"${NUMSTAT}\" == -

sync-skills-lock ルート直下の の を、upstream リポジトリの現状と照合して更新する。 対象ファイル - ルート : 呼び出し元リポジトリ直下の — このスキルが唯一編集するファイル - 除外 : submodule 配下の は編集しない(submodule 境界を跨がないため) 前提条件 - CLI がインストールされ、認証済みであること - / が利用可能であること( を使用するため)。 CLI は固定版( )で実行する。値と更新手順は「skills CLI のバージョン固定と更新手順」節を参照 - が利用可能であること(状態署名〔 / 〕と復元処理〔既存 ignored ファイルの比較・復元等〕で使用。主要 Linux / macOS には標準搭載されている。無い環境では Step 4 フェンスが npx 実行前に fail-closed で停止するため、導入してから再実行すること) - CLI が利用可能であること(未追跡バイナリファイルの種別表示に使用。未導入の環境では 種別が として表示され、承認前にサイズ・git blob ハッシュのみで 判断することになる。macOS / 主要 Linux ディストリビューションには標準搭載されている) - ルート直下の が存在すること - 実行前に に未コミットの変更がないこと (ステージ済み・未ステージ…

\\t'-

sync-skills-lock ルート直下の の を、upstream リポジトリの現状と照合して更新する。 対象ファイル - ルート : 呼び出し元リポジトリ直下の — このスキルが唯一編集するファイル - 除外 : submodule 配下の は編集しない(submodule 境界を跨がないため) 前提条件 - CLI がインストールされ、認証済みであること - / が利用可能であること( を使用するため)。 CLI は固定版( )で実行する。値と更新手順は「skills CLI のバージョン固定と更新手順」節を参照 - が利用可能であること(状態署名〔 / 〕と復元処理〔既存 ignored ファイルの比較・復元等〕で使用。主要 Linux / macOS には標準搭載されている。無い環境では Step 4 フェンスが npx 実行前に fail-closed で停止するため、導入してから再実行すること) - CLI が利用可能であること(未追跡バイナリファイルの種別表示に使用。未導入の環境では 種別が として表示され、承認前にサイズ・git blob ハッシュのみで 判断することになる。macOS / 主要 Linux ディストリビューションには標準搭載されている) - ルート直下の が存在すること - 実行前に に未コミットの変更がないこと (ステージ済み・未ステージ…

\\t'* ]]; then\n FILE_SIZE=\"$(wc -c \u003c \"${f}\" | tr -d '[:space:]')\"\n if command -v file >/dev/null 2>&1; then\n FILE_TYPE=\"$(file -b -- \"${f}\" 2>/dev/null || echo \"unknown\")\"\n else\n FILE_TYPE=\"file コマンド未検出\"\n fi\n FILE_HASH=\"$(git hash-object -- \"${f}\")\"\n # object format は repository 設定依存(既定 sha1 / 拡張 sha256)で出力桁数が変わる\n # (sha1: 40 桁 / sha256: 64 桁)。固定表記 \"git-blob-sha1\" だと sha256 リポジトリで\n # 実際のアルゴリズムと表示が食い違うため、表記自体をアルゴリズム非依存にする。\n OBJECT_FORMAT=\"$(git rev-parse --show-object-format 2>/dev/null || echo unknown)\"\n printf '%s\\n' \"==> バイナリファイル(内容は表示されません): type=${FILE_TYPE} size=${FILE_SIZE}bytes git-blob-hash(${OBJECT_FORMAT})=${FILE_HASH}\"\n else\n # --no-index は index を変更しない(git add -N は使わない。呼び出し元の拒否経路が\n # index からの git checkout -- で承認済み他スキルの hash を復元する設計に依存しており、\n # intent-to-add エントリを混入させるとその復元設計と干渉するため)。\n # 差分ありのとき exit 1 を返す仕様のため、表示専用のこの呼び出しに限り || true で\n # set -e の中断を避ける(clean ガード等の fail-closed 判定には影響しない)。\n git diff --no-index -- /dev/null \"${f}\" || true\n fi\ndone \u003c \"${UNTRACKED_LIST_FILE}\"\nif [[ \"${UNTRACKED_COUNT}\" -eq 0 ]]; then\n echo \"==> 新規(未追跡)ファイル: なし\"\nfi\n}\n\npreview_untracked \".agents/skills/${SKILL_NAME}/\"\n\nif [[ \"${LOCAL_PATCH_GUARD}\" == true ]]; then\n # 却下・失敗時の復元は「checker 初回実行より前」に取得済みの PRE_SYNC_TREE(同期開始前の\n # index snapshot)を使う。npx 後にここで snapshot を取り直すと、復元先が「raw upstream +\n # 同期前 check の stage」になり、「同期前へ戻す」という契約に反する(local patch が外れた\n # 状態が残る)。restore_contract_scope は契約パス限定の git restore で、この同期\n # (pre-check・npx・apply)で生じた契約範囲の index 変更だけを取り除き、承認済みの\n # 他スキル分にも範囲外 path の index にも触れない\n\n # ユーザー却下時の復元手順(stdout へ出す。失敗経路は restore_contract_scope が自動復元\n # するため使わない)。PRE_SYNC_TREE は本 process 終了後に環境から消えるため、値そのものを\n # 展開して案内する\n # 案内の順序は SKILL.md の却下フェンスと同じ retreat-first(先に未追跡を退避 → 退避が\n # 全件成功した場合にのみ worktree 復元)。restore を先に案内すると、checker が\n # git rm --cached で未追跡化・書き換えた skills-lock.json 等の「唯一のコピー」が\n # 退避されないまま snapshot の内容で無音に上書きされる(Issue #418 と同じ欠陥が\n # 案内文経由で再現する)\n restore_help() {\n echo \"同期前へ戻すには(契約パス限定で index + worktree を同期開始前 snapshot へ復元する。範囲外 path の index・worktree には触れない。必ず 1. 退避 → 2. 復元の順で実行する):\"\n echo \" # 1. 復元より先に、契約範囲内の未追跡ファイルを退避する(checker が git rm --cached 等で\"\n echo \" # 未追跡化・書き換えた skills-lock.json 等の「唯一のコピー」であり得るため、退避せず\"\n echo \" # git restore --worktree へ進むと snapshot の内容で無音に上書きされデータ喪失になる):\"\n echo \" backup_dir=\\\"\\$(mktemp -d)\\\" || exit 1 # 退避先の作成に失敗したら復元へ進まない(fail-closed)\"\n echo \" untracked_list=\\\"\\$(mktemp)\\\" || exit 1\"\n echo \" git ls-files -z --others --exclude-standard -- skills-lock.json \\\".agents/skills/${SKILL_NAME}/\\\" scripts/local-patches/ > \\\"\\${untracked_list}\\\" || exit 1 # 列挙に失敗したら復元へ進まない(fail-closed)\"\n echo \" while IFS= read -r -d '' p; do mkdir -p \\\"\\${backup_dir}/\\$(dirname \\\"\\${p}\\\")\\\" && mv -- \\\"\\${p}\\\" \\\"\\${backup_dir}/\\${p}\\\" || { echo \\\"退避失敗: \\${p}\\\" >&2; exit 1; }; done \u003c \\\"\\${untracked_list}\\\"; rm -f \\\"\\${untracked_list}\\\"\"\n echo \" echo \\\"未追跡ファイルの退避先: \\${backup_dir}\\\"\"\n echo \" # 2. 退避が全件成功した場合にのみ index + worktree を復元する(1 件でも退避に失敗した\"\n echo \" # 場合は worktree 復元を行わず、SKILL.md の却下フェンスに従って手動復旧する):\"\n echo \" git restore --staged --worktree --source=${PRE_SYNC_TREE} -- skills-lock.json\"\n echo \" git restore --staged --worktree --source=${PRE_SYNC_TREE} -- \\\".agents/skills/${SKILL_NAME}/\\\" 2>/dev/null || true\"\n echo \" git restore --staged --worktree --source=${PRE_SYNC_TREE} -- scripts/local-patches/ 2>/dev/null || true\"\n echo \" # 3. 復元後は契約範囲が同期開始前 snapshot と一致し未追跡も無いことを確認する\"\n echo \" # (基準は HEAD ではなく snapshot。git status だと同期前からの正当な staged 変更が誤検出される):\"\n echo \" git diff --cached ${PRE_SYNC_TREE} -- skills-lock.json \\\".agents/skills/${SKILL_NAME}/\\\" scripts/local-patches/ # 空出力が成功\"\n echo \" git diff ${PRE_SYNC_TREE} -- skills-lock.json \\\".agents/skills/${SKILL_NAME}/\\\" scripts/local-patches/ # 空出力が成功\"\n echo \" git ls-files --others --exclude-standard -- skills-lock.json \\\".agents/skills/${SKILL_NAME}/\\\" scripts/local-patches/ # 空出力が成功\"\n }\n\n echo \"\"\n echo \"==> local patch を再適用(--3way fallback や index 復元により対象 file・durable patch が stage されることがある)\"\n apply_rc=0\n # apply は checker 自身のコードを実行するため、一時ファイル(CHECKER_EXEC)自体を\n # 自己書換えし得る。run_checker が実行の直前に毎回ハッシュを再検証するため、apply が\n # 何を書き換えても次の呼び出し(最終検証)は必ず承認済み blob の内容へ戻ってから\n # 実行される(codex P1 指摘: verify-then-execute の隣接)\n run_checker apply || apply_rc=$?\n if [[ \"${CHECKER_RUN_VERIFY_FAILED}\" -eq 1 ]]; then\n restore_contract_scope || true\n exit 1\n fi\n if ! verify_outside_and_checker; then\n # 範囲外の破壊は上の案内どおり手動復旧として残しつつ、契約範囲(部分適用された patch・\n # checker 由来の index 変更)は自動復元してから終了する(「すべての失敗経路で同期開始前へ\n # 戻す」契約。以下の 3 失敗分岐も同じ)\n restore_contract_scope || true\n exit 1\n fi\n if [[ \"${apply_rc}\" -ne 0 ]]; then\n echo \"エラー: local patch の再適用に失敗しました。stage・commit へ進まないでください(fail-closed)。\" >&2\n restore_contract_scope || true\n exit 1\n fi\n echo \"\"\n echo \"==> 再適用後の最終検証\"\n check_rc=0\n run_checker || check_rc=$?\n if [[ \"${CHECKER_RUN_VERIFY_FAILED}\" -eq 1 ]]; then\n restore_contract_scope || true\n exit 1\n fi\n if ! verify_outside_and_checker; then\n restore_contract_scope || true\n exit 1\n fi\n if [[ \"${check_rc}\" -ne 0 ]]; then\n echo \"エラー: 再適用後の最終検証に失敗しました。stage・commit へ進まないでください(fail-closed)。\" >&2\n restore_contract_scope || true\n exit 1\n fi\n\n echo \"\"\n echo \"==> 更新完了。commit 候補の最終差分(upstream 更新 + local patch 再適用。durable patch を含む):\"\n git diff HEAD -- skills-lock.json \".agents/skills/${SKILL_NAME}/\" scripts/local-patches/\n echo \"\"\n echo \"==> 契約範囲内の未追跡ファイル(git diff HEAD には表示されない。apply が新規作成した durable patch 等を含めて内容まで提示する):\"\n preview_untracked \".agents/skills/${SKILL_NAME}/\" scripts/local-patches/\n echo \"\"\n echo \"却下する場合(当該スキルと durable patch を同期前へ戻す。他スキルの stage 済み変更には影響しない):\"\n restore_help\nfi\n\necho \"\"\necho \"コミットするには:\"\necho \" git add skills-lock.json\"\necho \" git add .agents/skills/${SKILL_NAME}/ # 上記の未追跡ファイルもここで取り込まれる\"\nif [[ \"${LOCAL_PATCH_GUARD}\" == true && -d scripts/local-patches/ ]]; then\n echo \" git add scripts/local-patches/ # 承認対象に含めた durable patch の変更も同じ承認単位で stage する\"\nfi\necho \" git commit -m 'chore(skills-lock): ${SKILL_NAME} の computedHash を upstream と同期'\"\n","content_type":"application/x-sh; charset=utf-8","language":"bash","size":139853,"content_sha256":"b185f4fc42afa31b0bdda72c0592f0ef046d2992912e26be8d27c6808066101c"},{"filename":"tests/checker-outside-state.test.mjs","content":"// checker-outside-state.test.mjs — Bugbot Medium 指摘(類型10)の回帰テスト。\n//\n// LOCAL_PATCH_GUARD(消費側が scripts/check-skill-local-patches.sh を持つ場合)の\n// outside_state() は、契約範囲(skills-lock.json / 当該スキル / durable patch)外の\n// worktree・index 状態を digest 化して checker 実行前後で比較する内部関数。\n// git read-tree / git add / git write-tree / git ls-files / git hash-object のいずれかが\n// 失敗しても、`$(outside_state)` を `[[ ]]` の条件式内で直接使う呼び出し元\n// (verify_outside_and_checker)では非ゼロ終了が握り潰され、空文字列同士の比較に\n// 落ちて「範囲外の変更なし」と誤判定し得た。\n//\n// このテストは outside_state() の内部で使う `git write-tree`(GIT_INDEX_FILE を\n// 明示的に指定して呼ばれる。PRE_SYNC_TREE 用の素の `git write-tree` 呼び出しとは\n// GIT_INDEX_FILE の有無で区別できる)だけを、指定回数目に限って失敗させる git\n// ラッパーで、次の 2 経路を検証する:\n// - 1 回目(Step 4 の PRE_OUTSIDE 取得)が失敗した場合、契約範囲外の基準 digest が\n// 取れないため fail-closed で停止し、npx は一度も実行されないこと\n// - 2 回目(同期前 check 直後の verify_outside_and_checker)が失敗した場合も\n// fail-closed で停止し、npx は一度も実行されないこと\n//\n// checker 本体は何もしない no-op(常に exit 0)を使う。テストの主眼は\n// outside_state() 自身の失敗検出であり、checker のロジックは無関係のため。\nimport { test } from 'node:test'\nimport assert from 'node:assert/strict'\nimport { execFileSync } from 'node:child_process'\nimport { mkdtempSync, writeFileSync, chmodSync, mkdirSync, rmSync, existsSync } from 'node:fs'\nimport { tmpdir } from 'node:os'\nimport { dirname, join } from 'node:path'\nimport { fileURLToPath } from 'node:url'\n\nconst REAL_GIT = execFileSync('/bin/bash', ['-c', 'command -v git'], { encoding: 'utf8' }).trim()\n\nconst SCRIPT_PATH = join(\n dirname(fileURLToPath(import.meta.url)),\n '..', 'scripts', 'skills-lock-update.sh',\n)\n\nconst SKILL_NAME = 'dummy-skill'\nconst SOURCE_REPO = 'Fandhe-AI/dummy-source'\n\nfunction sh(cmd, cwd, env) {\n return execFileSync('bash', ['-c', cmd], { cwd, env, encoding: 'utf8' })\n}\n\n// git write-tree のうち GIT_INDEX_FILE が設定されている呼び出し(= outside_state()\n// 内部からの呼び出しのみ。PRE_SYNC_TREE 用の素の `git write-tree` は GIT_INDEX_FILE を\n// 設定しないため対象外)を数え、`failAtCall` 回目のみ失敗させる git ラッパーを用意する。\nfunction setupRepo({ failAtCall }) {\n const repoDir = mkdtempSync(join(tmpdir(), 'sync-skills-lock-outside-state-'))\n const binDir = mkdtempSync(join(tmpdir(), 'sync-skills-lock-outside-state-bin-'))\n const callCountFile = join(binDir, 'write-tree-call-count')\n const argvLogFile = join(binDir, 'npx-argv.log')\n\n writeFileSync(join(binDir, 'gh'), '#!/usr/bin/env bash\\nexit 0\\n')\n chmodSync(join(binDir, 'gh'), 0o755)\n\n // npx スタブ: 標準的な成功パターン(skills-lock.json の computedHash 更新 + tracked\n // ファイルの上書き)。outside_state() の失敗経路はいずれも npx より前で停止する設計の\n // ため、このスタブが実行されること自体が回帰(fail-open)の証拠になる。\n const npxBody = `#!/usr/bin/env bash\nset -euo pipefail\nprintf '%s\\\\n' \"\\$@\" > \"${argvLogFile}\"\nskill_dir=\".agents/skills/${SKILL_NAME}\"\necho \"updated upstream content\" > \"\\${skill_dir}/SKILL.md\"\npython3 - \u003c\u003c'PYEOF'\nimport json\nwith open('skills-lock.json') as f:\n lock = json.load(f)\nlock['skills']['${SKILL_NAME}']['computedHash'] = 'sha256:updated-hash-value'\nwith open('skills-lock.json', 'w') as f:\n json.dump(lock, f, indent=2)\n f.write('\\\\n')\nPYEOF\nexit 0\n`\n writeFileSync(join(binDir, 'npx'), npxBody)\n chmodSync(join(binDir, 'npx'), 0o755)\n\n // no-op checker(check モード・apply モードいずれも常に exit 0)。outside_state() 自体の\n // 検証が主眼のため、checker のロジックは意図的に何もしない。\n writeFileSync(join(binDir, 'checker-source.sh'), '#!/usr/bin/env bash\\nexit 0\\n')\n\n const gitWrapper = `#!/usr/bin/env bash\nif [[ \"\\${1:-}\" == \"write-tree\" && -n \"\\${GIT_INDEX_FILE:-}\" ]]; then\n count=0\n [[ -f \"${callCountFile}\" ]] && count=\"\\$(cat \"${callCountFile}\")\"\n count=\\$((count + 1))\n echo \"\\${count}\" > \"${callCountFile}\"\n if [[ \"\\${count}\" == \"${failAtCall}\" ]]; then\n echo \"fatal: simulated outside_state write-tree failure (call \\${count})\" >&2\n exit 1\n fi\nfi\nexec \"${REAL_GIT}\" \"\\$@\"\n`\n writeFileSync(join(binDir, 'git'), gitWrapper)\n chmodSync(join(binDir, 'git'), 0o755)\n\n sh('git init -q', repoDir)\n sh('git config user.email [email protected]', repoDir)\n sh('git config user.name test', repoDir)\n\n mkdirSync(join(repoDir, '.agents', 'skills', SKILL_NAME), { recursive: true })\n writeFileSync(\n join(repoDir, '.agents', 'skills', SKILL_NAME, 'SKILL.md'),\n 'original content\\n',\n )\n writeFileSync(\n join(repoDir, 'skills-lock.json'),\n JSON.stringify(\n {\n skills: {\n [SKILL_NAME]: {\n source: `https://github.com/${SOURCE_REPO}`,\n computedHash: 'sha256:original-hash-value',\n },\n },\n },\n null,\n 2,\n ) + '\\n',\n )\n mkdirSync(join(repoDir, 'scripts'), { recursive: true })\n sh(`cp \"${join(binDir, 'checker-source.sh')}\" \"${join(repoDir, 'scripts', 'check-skill-local-patches.sh')}\"`)\n chmodSync(join(repoDir, 'scripts', 'check-skill-local-patches.sh'), 0o755)\n sh('git add -A && git commit -q -m init', repoDir)\n\n const checkerHash = sh(\n 'git hash-object -- scripts/check-skill-local-patches.sh',\n repoDir,\n ).trim()\n\n return { repoDir, binDir, argvLogFile, checkerHash }\n}\n\nfunction runScript({ repoDir, binDir, checkerHash }) {\n const env = {\n ...process.env,\n PATH: `${binDir}:${process.env.PATH}`,\n CHECKER_APPROVED_HASH: checkerHash,\n }\n return execFileSync('bash', [SCRIPT_PATH, SKILL_NAME, SOURCE_REPO], {\n cwd: repoDir,\n env,\n encoding: 'utf8',\n })\n}\n\ntest('ケース1: outside_state() の 1 回目の呼び出し(Step 4 の PRE_OUTSIDE 取得)が' +\n '失敗すると、範囲外書き込みの検出ができないため npx を実行せず fail-closed で' +\n '停止する(Bugbot Medium 指摘: 従来は素の代入文の暗黙の errexit 伝播に依存しており、' +\n '明示的な失敗検出ではなかった)', () => {\n const ctx = setupRepo({ failAtCall: 1 })\n try {\n assert.throws(\n () => runScript(ctx),\n (err) => {\n assert.notEqual(err.status, 0, '非ゼロ終了すること(fail-closed)')\n const combined = `${err.stdout ?? ''}${err.stderr ?? ''}`\n assert.match(\n combined,\n /契約範囲外の基準 digest(PRE_OUTSIDE)の取得に失敗しました/,\n '専用のエラーメッセージで停止すること',\n )\n return true\n },\n )\n assert.equal(\n existsSync(ctx.argvLogFile),\n false,\n 'npx が一度も実行されていないこと(PRE_OUTSIDE 取得が npx より前に走る)',\n )\n } finally {\n rmSync(ctx.repoDir, { recursive: true, force: true })\n rmSync(ctx.binDir, { recursive: true, force: true })\n }\n})\n\ntest('ケース2: outside_state() の 2 回目の呼び出し(同期前 check 直後の' +\n 'verify_outside_and_checker)が失敗すると、戻り値を明示的に検査して fail-closed で' +\n '停止する(「変化なしと確認できない」を「変化なし」と誤判定しない)', () => {\n const ctx = setupRepo({ failAtCall: 2 })\n try {\n assert.throws(\n () => runScript(ctx),\n (err) => {\n assert.notEqual(err.status, 0, '非ゼロ終了すること(fail-closed)')\n const combined = `${err.stdout ?? ''}${err.stderr ?? ''}`\n assert.match(\n combined,\n /契約範囲外の digest 取得に失敗しました/,\n '専用のエラーメッセージで停止すること',\n )\n assert.match(\n combined,\n /npx は実行していません/,\n 'npx 未実行の旨が案内されること',\n )\n return true\n },\n )\n assert.equal(\n existsSync(ctx.argvLogFile),\n false,\n 'npx が一度も実行されていないこと(verify_outside_and_checker が npx より前に走る)',\n )\n } finally {\n rmSync(ctx.repoDir, { recursive: true, force: true })\n rmSync(ctx.binDir, { recursive: true, force: true })\n }\n})\n\ntest('ケース3(非退行): outside_state() が失敗しない通常経路では、checker 検証を' +\n '通過して npx が実行される', () => {\n const ctx = setupRepo({ failAtCall: 0 })\n try {\n const out = runScript(ctx)\n assert.match(out, /更新完了|変更内容/, '同期が完走すること')\n assert.equal(\n existsSync(ctx.argvLogFile),\n true,\n 'npx が実行されていること(誤検知で止まっていないこと)',\n )\n } finally {\n rmSync(ctx.repoDir, { recursive: true, force: true })\n rmSync(ctx.binDir, { recursive: true, force: true })\n }\n})\n","content_type":"text/javascript; charset=utf-8","language":"javascript","size":9422,"content_sha256":"049dc6d0b9d9ea10dc6608c4543b3fbc71bda20c33082ddd45b41e12248d3472"},{"filename":"tests/checker-run-verify.test.mjs","content":"// checker-run-verify.test.mjs — codex P1(PR #416)の回帰テスト。\n//\n// LOCAL_PATCH_GUARD の checker は CHECKER_EXEC(承認済み blob を取り出した一時\n// ファイル)に対して bash で複数回実行される(同期前 check → apply → 最終検証)。\n// apply は checker 自身のコードを実行するため、CHECKER_EXEC 自身($0 で参照できる)を\n// 自己書換えできる。従来は起動時に1度だけ取り出したファイルを再検証せず使い回して\n// いたため、apply が自己書換えした内容がそのまま次(最終検証)の bash 実行に渡って\n// いた(verify-then-execute が隣接していない TOCTOU)。\n//\n// このテストの checker は apply 実行時に自身のファイル($0 = CHECKER_EXEC)を\n// 「常に exit 42 する」内容へ自己書換えする。修正後の run_checker は各実行の直前に\n// 必ずハッシュを承認済み blob(CHECKER_APPROVED_HASH)と照合し、不一致なら無条件で\n// 書き直してから実行するため、最終検証は自己書換え後も常に「元の(exit 0 の)\n// 承認済み内容」が実行され、exit 42 の改ざん後コードは一度も実行されない。\nimport { test } from 'node:test'\nimport assert from 'node:assert/strict'\nimport { execFileSync } from 'node:child_process'\nimport { mkdtempSync, writeFileSync, chmodSync, mkdirSync, rmSync, readFileSync } from 'node:fs'\nimport { tmpdir } from 'node:os'\nimport { dirname, join } from 'node:path'\nimport { fileURLToPath } from 'node:url'\n\nconst SCRIPT_PATH = join(\n dirname(fileURLToPath(import.meta.url)),\n '..', 'scripts', 'skills-lock-update.sh',\n)\n\nconst SKILL_NAME = 'dummy-skill'\nconst SOURCE_REPO = 'Fandhe-AI/dummy-source'\n\nfunction sh(cmd, cwd, env) {\n return execFileSync('bash', ['-c', cmd], { cwd, env, encoding: 'utf8' })\n}\n\nfunction setupRepo() {\n const repoDir = mkdtempSync(join(tmpdir(), 'sync-skills-lock-checker-verify-'))\n const binDir = mkdtempSync(join(tmpdir(), 'sync-skills-lock-checker-verify-bin-'))\n\n writeFileSync(join(binDir, 'gh'), '#!/usr/bin/env bash\\nexit 0\\n')\n chmodSync(join(binDir, 'gh'), 0o755)\n\n // npx スタブ: 標準的な成功パターン。\n const npxBody = `#!/usr/bin/env bash\nset -euo pipefail\nskill_dir=\".agents/skills/${SKILL_NAME}\"\necho \"updated upstream content\" > \"\\${skill_dir}/SKILL.md\"\npython3 - \u003c\u003c'PYEOF'\nimport json\nwith open('skills-lock.json') as f:\n lock = json.load(f)\nlock['skills']['${SKILL_NAME}']['computedHash'] = 'sha256:updated-hash-value'\nwith open('skills-lock.json', 'w') as f:\n json.dump(lock, f, indent=2)\n f.write('\\\\n')\nPYEOF\nexit 0\n`\n writeFileSync(join(binDir, 'npx'), npxBody)\n chmodSync(join(binDir, 'npx'), 0o755)\n\n sh('git init -q', repoDir)\n sh('git config user.email [email protected]', repoDir)\n sh('git config user.name test', repoDir)\n\n mkdirSync(join(repoDir, '.agents', 'skills', SKILL_NAME), { recursive: true })\n writeFileSync(\n join(repoDir, '.agents', 'skills', SKILL_NAME, 'SKILL.md'),\n 'original content\\n',\n )\n writeFileSync(\n join(repoDir, 'skills-lock.json'),\n JSON.stringify(\n {\n skills: {\n [SKILL_NAME]: {\n source: `https://github.com/${SOURCE_REPO}`,\n computedHash: 'sha256:original-hash-value',\n },\n },\n },\n null,\n 2,\n ) + '\\n',\n )\n\n // checker: check モード(無引数)は常に exit 0。apply モードでは自分自身($0 =\n // 実行中の一時ファイル CHECKER_EXEC)を「常に exit 42 する」内容へ書き換えたうえで\n // exit 0 する(apply 自体は成功として扱われるが、次に実行される一時ファイルの中身が\n // 改ざんされている状態を再現する)。\n mkdirSync(join(repoDir, 'scripts'), { recursive: true })\n writeFileSync(\n join(repoDir, 'scripts', 'check-skill-local-patches.sh'),\n [\n '#!/usr/bin/env bash',\n 'if [[ \"${1:-}\" == \"apply\" ]]; then',\n ' cat > \"$0\" \u003c\u003c\\'EVIL\\'',\n '#!/usr/bin/env bash',\n 'exit 42',\n 'EVIL',\n ' exit 0',\n 'fi',\n 'exit 0',\n '',\n ].join('\\n'),\n )\n chmodSync(join(repoDir, 'scripts', 'check-skill-local-patches.sh'), 0o755)\n sh('git add -A && git commit -q -m init', repoDir)\n\n const checkerHash = sh(\n 'git hash-object -- scripts/check-skill-local-patches.sh',\n repoDir,\n ).trim()\n\n return { repoDir, binDir, checkerHash }\n}\n\nfunction runScript({ repoDir, binDir, checkerHash }) {\n const env = {\n ...process.env,\n PATH: `${binDir}:${process.env.PATH}`,\n CHECKER_APPROVED_HASH: checkerHash,\n }\n return execFileSync('bash', [SCRIPT_PATH, SKILL_NAME, SOURCE_REPO], {\n cwd: repoDir,\n env,\n encoding: 'utf8',\n })\n}\n\ntest('ケース1: apply が CHECKER_EXEC 自身($0)を自己書換えしても、最終検証は' +\n '改ざん後コードを実行せず、承認済み blob から再検証・再取得した元の内容を実行する' +\n '(codex P1 指摘: run_checker が各実行の直前に毎回ハッシュを再検証する)', () => {\n const ctx = setupRepo()\n try {\n const out = runScript(ctx)\n // 改ざん後コード(exit 42)が実行されていれば「再適用後の最終検証に失敗しました」\n // で fail-closed 終了するはずだが、run_checker の再検証により元の(exit 0 の)\n // 承認済み内容が実行されるため、同期は完走する。\n assert.doesNotMatch(\n out,\n /再適用後の最終検証に失敗しました/,\n '改ざんされた一時ファイルの内容(exit 42)が実行されていないこと',\n )\n assert.match(out, /更新完了/, '同期が完走すること(改ざんが無害化されていること)')\n } finally {\n rmSync(ctx.repoDir, { recursive: true, force: true })\n rmSync(ctx.binDir, { recursive: true, force: true })\n }\n})\n\n// ケース2 専用: ハッシュ不一致による再生成が起きた直後に、置換前(apply が自己書換え\n// した)旧 CHECKER_EXEC が既に削除されているかどうかを、実行中の checker 自身に\n// チェックさせて結果ファイルへ書き出させる。apply モードで自身のパス($0 = 旧\n// CHECKER_EXEC)を marker ファイルへ記録してから自己書換えし、最終検証(再生成後の\n// 新 CHECKER_EXEC で実行される、改ざんされていない信頼済みコード)で marker の\n// パスがまだディスク上に残っているかを判定する。\nfunction setupLeakCheckRepo() {\n const repoDir = mkdtempSync(join(tmpdir(), 'sync-skills-lock-checker-leak-'))\n const binDir = mkdtempSync(join(tmpdir(), 'sync-skills-lock-checker-leak-bin-'))\n const markerFile = join(binDir, 'old-checker-exec-path.marker')\n const resultFile = join(binDir, 'leak-check-result.txt')\n\n writeFileSync(join(binDir, 'gh'), '#!/usr/bin/env bash\\nexit 0\\n')\n chmodSync(join(binDir, 'gh'), 0o755)\n\n const npxBody = `#!/usr/bin/env bash\nset -euo pipefail\nskill_dir=\".agents/skills/${SKILL_NAME}\"\necho \"updated upstream content\" > \"\\${skill_dir}/SKILL.md\"\npython3 - \u003c\u003c'PYEOF'\nimport json\nwith open('skills-lock.json') as f:\n lock = json.load(f)\nlock['skills']['${SKILL_NAME}']['computedHash'] = 'sha256:updated-hash-value'\nwith open('skills-lock.json', 'w') as f:\n json.dump(lock, f, indent=2)\n f.write('\\\\n')\nPYEOF\nexit 0\n`\n writeFileSync(join(binDir, 'npx'), npxBody)\n chmodSync(join(binDir, 'npx'), 0o755)\n\n sh('git init -q', repoDir)\n sh('git config user.email [email protected]', repoDir)\n sh('git config user.name test', repoDir)\n\n mkdirSync(join(repoDir, '.agents', 'skills', SKILL_NAME), { recursive: true })\n writeFileSync(\n join(repoDir, '.agents', 'skills', SKILL_NAME, 'SKILL.md'),\n 'original content\\n',\n )\n writeFileSync(\n join(repoDir, 'skills-lock.json'),\n JSON.stringify(\n {\n skills: {\n [SKILL_NAME]: {\n source: `https://github.com/${SOURCE_REPO}`,\n computedHash: 'sha256:original-hash-value',\n },\n },\n },\n null,\n 2,\n ) + '\\n',\n )\n\n // 信頼済みコード(committed blob。同期前 check と再生成後の最終検証の両方で\n // 実行される): apply モードでは自身のパス($0)を marker ファイルへ記録してから\n // 自己書換えする。check モード(無引数)では、marker ファイルが存在する場合に\n // 限り「marker が指す旧パスがまだディスク上に残っているか」を判定して結果ファイルへ\n // 書き出す(marker がまだ無い最初の pre-check 呼び出しでは何もしない)。\n mkdirSync(join(repoDir, 'scripts'), { recursive: true })\n writeFileSync(\n join(repoDir, 'scripts', 'check-skill-local-patches.sh'),\n [\n '#!/usr/bin/env bash',\n 'if [[ \"${1:-}\" == \"apply\" ]]; then',\n ` printf '%s' \"$0\" > \"${markerFile}\"`,\n ' cat > \"$0\" \u003c\u003c\\'EVIL\\'',\n '#!/usr/bin/env bash',\n 'exit 42',\n 'EVIL',\n ' exit 0',\n 'fi',\n `if [[ -f \"${markerFile}\" ]]; then`,\n ` old_path=\"$(cat \"${markerFile}\")\"`,\n ' if [[ -e \"${old_path}\" ]]; then',\n ` echo \"STALE_PRESENT\" > \"${resultFile}\"`,\n ' else',\n ` echo \"STALE_ABSENT\" > \"${resultFile}\"`,\n ' fi',\n 'fi',\n 'exit 0',\n '',\n ].join('\\n'),\n )\n chmodSync(join(repoDir, 'scripts', 'check-skill-local-patches.sh'), 0o755)\n sh('git add -A && git commit -q -m init', repoDir)\n\n const checkerHash = sh(\n 'git hash-object -- scripts/check-skill-local-patches.sh',\n repoDir,\n ).trim()\n\n return { repoDir, binDir, checkerHash, resultFile }\n}\n\ntest('ケース2: ハッシュ不一致による CHECKER_EXEC の再生成時、置換前(apply が' +\n '自己書換えした)旧一時ファイルが再生成の時点で既に削除されている(Bugbot Medium / ' +\n 'codex P2 指摘: 再代入するだけだと EXIT trap は最後に代入された CHECKER_EXEC しか' +\n '回収できず、途中で作られた旧一時ファイルが /tmp に残る)', () => {\n const ctx = setupLeakCheckRepo()\n try {\n const out = runScript(ctx)\n assert.match(out, /更新完了/, '同期が完走すること(前提条件)')\n const result = readFileSync(ctx.resultFile, 'utf8').trim()\n assert.equal(\n result,\n 'STALE_ABSENT',\n '再生成(最終検証)の時点で、apply が自己書換えした旧 CHECKER_EXEC が' +\n '既に削除されていること(再代入前に rm -f されていること)',\n )\n } finally {\n rmSync(ctx.repoDir, { recursive: true, force: true })\n rmSync(ctx.binDir, { recursive: true, force: true })\n }\n})\n","content_type":"text/javascript; charset=utf-8","language":"javascript","size":10792,"content_sha256":"d97c525b53f1db75e5cad59b17d2b046b5202161dfdfac82ae63518f0a48e3a6"},{"filename":"tests/errexit-fence.test.mjs","content":"// errexit-fence.test.mjs — Issue #417 の回帰テスト。\n//\n// SKILL.md の Step 4 フェンス(npx skills add を含む約1,400行の単一フェンス)は、\n// このフェンス自身が errexit 無効なエージェント対話シェルへコピペ実行され得る前提を\n// 持つ(コピペ実行の前提は tests/version-pin.test.mjs と同型の教訓)。修正前は\n// `npx` の前後で `set +e` → 無条件 `set -e` としており、呼び出し元シェルの errexit\n// 状態を保存せず、フェンス実行後に同一シェルで走る後続コマンド(Step 6 の却下フェンス\n// 等)へ errexit を新規に有効化してしまっていた。これにより、ガードの無いコマンドが\n// 復元処理の途中で非ゼロ終了 abort し得た。\n//\n// このテストは:\n// 1. Step 4 フェンスが `set +e` より前に `$-` を参照する errexit 状態保存を行い、\n// 無条件の `set -e` ではなく条件付き復元をしていること(テキスト検証)\n// 2. Step 6 の却下フェンス(checker を持たないリポジトリ向けの単純リバート経路)が\n// `revert_in_scope`(SKILL.md 本文の共有関数)と同等のガード(`|| true` /\n// ディレクトリ存在確認)を備えていること(テキスト検証)\n// 3. 1. の保存・条件付き復元が実際に呼び出し元シェルの errexit 状態を変えないこと\n// (挙動検証。errexit 無効/有効それぞれで開始した場合の事後状態を実測)\n// 4. Step 6 却下フェンスが、lock 未追跡・スキルディレクトリ不在という初回生成相当の\n// 状態で `set -e` 下でも abort せず完走すること(挙動検証。修正前は abort する\n// 回帰シナリオ)\nimport { test } from 'node:test'\nimport assert from 'node:assert/strict'\nimport { readFileSync, writeFileSync, existsSync, mkdtempSync, rmSync } from 'node:fs'\nimport { dirname, join } from 'node:path'\nimport { fileURLToPath } from 'node:url'\nimport { tmpdir } from 'node:os'\nimport { execFileSync } from 'node:child_process'\n\nconst SKILL_DIR = join(dirname(fileURLToPath(import.meta.url)), '..')\nconst SKILL_MD_PATH = join(SKILL_DIR, 'SKILL.md')\n\nfunction extractBashFences(content) {\n const fences = []\n const re = /```bash\\n([\\s\\S]*?)```/g\n let m\n while ((m = re.exec(content)) !== null) {\n fences.push(m[1])\n }\n return fences\n}\n\nfunction findStep4Fence(fences) {\n // npx skills add 実行行と PIPESTATUS スナップショット代入を両方含むフェンスを\n // Step 4 フェンスとみなす(version-pin.test.mjs と同じ抽出単位)。\n return fences.find(\n (fence) =>\n /^\\s*npx\\b.*\\bskills\\b/m.test(fence) && /PIPE_EXIT_SNAPSHOT=\\(\"\\$\\{PIPESTATUS\\[@\\]\\}\"\\)/.test(fence)\n )\n}\n\nfunction findStep6RejectFence(fences) {\n // git clean -fd と git checkout -- skills-lock.json を両方含み、かつ checker 向け\n // PRE_SYNC_TREE 経路(別フェンス)とは異なる単純リバート経路を対象にする。\n return fences.find(\n (fence) =>\n /git clean -fd/.test(fence) &&\n /git checkout -- skills-lock\\.json/.test(fence) &&\n !/PRE_SYNC_TREE/.test(fence)\n )\n}\n\nfunction findStep6CheckerRejectFence(fences) {\n // checker を持つリポジトリ向けの却下フェンス(PRE_SYNC_TREE からの契約範囲復元)。\n // git restore --source=\"${PRE_SYNC_TREE}\" と復元後検証の完了メッセージを両方含む。\n return fences.find(\n (fence) => /PRE_SYNC_TREE/.test(fence) && /復元完了\\(検証済み\\)/.test(fence)\n )\n}\n\ntest('SKILL.md: Step 4 フェンスは set +e より前に errexit 状態($-)を保存する', () => {\n const content = readFileSync(SKILL_MD_PATH, 'utf8')\n const fence = findStep4Fence(extractBashFences(content))\n assert.ok(fence, 'Step 4 フェンス(npx 実行 + PIPE_EXIT_SNAPSHOT を含む)が見つからない')\n const lines = fence.split('\\n')\n const saveIdx = lines.findIndex((l) => /case \\$- in \\*e\\*\\)/.test(l))\n const setPlusEIdx = lines.findIndex((l) => /^set \\+e\\s*$/.test(l))\n assert.notEqual(saveIdx, -1, 'errexit 状態保存(case $- in *e*))が見つからない')\n assert.notEqual(setPlusEIdx, -1, 'set +e 行が見つからない')\n assert.ok(saveIdx \u003c setPlusEIdx, 'errexit 状態保存が set +e より後にある')\n})\n\ntest('SKILL.md: Step 4 フェンスの errexit 復元は条件付きで、無条件 set -e ではない', () => {\n const content = readFileSync(SKILL_MD_PATH, 'utf8')\n const fence = findStep4Fence(extractBashFences(content))\n assert.ok(fence, 'Step 4 フェンスが見つからない')\n const lines = fence.split('\\n')\n const snapshotIdx = lines.findIndex((l) => /PIPE_EXIT_SNAPSHOT=\\(\"\\$\\{PIPESTATUS\\[@\\]\\}\"\\)/.test(l))\n assert.notEqual(snapshotIdx, -1, 'PIPE_EXIT_SNAPSHOT 代入行が見つからない')\n // スナップショット取得より後に、無条件の `set -e`(行頭・行全体が set -e のみ)が\n // 存在しないことを確認する。条件付き復元(`if [[ ... ]]; then set -e; fi`)は\n // 行全体が `set -e` だけにはならないため検知対象から除外される。\n const laterLines = lines.slice(snapshotIdx + 1)\n const unconditionalSetE = laterLines.find((l) => /^set -e\\s*$/.test(l))\n assert.equal(\n unconditionalSetE,\n undefined,\n `スナップショット取得後に無条件の set -e が残っている: ${JSON.stringify(unconditionalSetE)}`\n )\n const conditionalRestore = laterLines.find((l) => /ERREXIT_WAS_ON.*-eq 1.*then set -e; fi/.test(l))\n assert.ok(conditionalRestore, '条件付き復元(ERREXIT_WAS_ON による set -e)が見つからない')\n})\n\ntest('SKILL.md: Step 6 却下フェンスは lock checkout に || true を持つ', () => {\n const content = readFileSync(SKILL_MD_PATH, 'utf8')\n const fence = findStep6RejectFence(extractBashFences(content))\n assert.ok(fence, 'Step 6 却下フェンス(checker 非経由の単純リバート)が見つからない')\n const lockLine = fence\n .split('\\n')\n .find((l) => /git checkout -- skills-lock\\.json/.test(l))\n assert.ok(lockLine, 'skills-lock.json の checkout 行が見つからない')\n assert.match(lockLine, /\\|\\|\\s*true/, 'lock checkout 行に || true が無い(初回生成で pathspec エラーにより abort し得る)')\n})\n\ntest('SKILL.md: Step 6 却下フェンスは git clean -fd の前にディレクトリ存在確認を持つ', () => {\n const content = readFileSync(SKILL_MD_PATH, 'utf8')\n const fence = findStep6RejectFence(extractBashFences(content))\n assert.ok(fence, 'Step 6 却下フェンスが見つからない')\n const lines = fence.split('\\n')\n // 行頭(空白許容)から始まる実コマンドのみを対象にする。`.test(l)` の無条件部分一致だと\n // 実コマンドより前にある `# git clean は...` 等のコメント行にも一致し、cleanIdx が\n // コメント位置を指してしまい、後段の「存在確認が git clean より前にある」という検証が\n // 実際のコマンド配置を評価できなくなる(codex P2 指摘)。\n const cleanIdx = lines.findIndex((l) => /^\\s*git clean -fd(?:\\s|$)/.test(l))\n assert.notEqual(cleanIdx, -1, 'git clean -fd 行が見つからない')\n const guardIdx = lines.findIndex((l, i) => i \u003c cleanIdx && /\\[\\[ -d \".agents\\/skills\\/\\$\\{SKILL_NAME\\}\" \\]\\]/.test(l))\n assert.notEqual(guardIdx, -1, 'git clean -fd より前にディレクトリ存在確認([[ -d ... ]])が無い(ディレクトリ不在時に非ゼロ終了し abort し得る)')\n})\n\ntest('SKILL.md: Step 6 却下フェンス(checker 非経由)の残留検出は warning のみで完了扱いにせず exit 1 で停止する(Issue #417 P0 の回帰ピン留め)', () => {\n const content = readFileSync(SKILL_MD_PATH, 'utf8')\n const fence = findStep6RejectFence(extractBashFences(content))\n assert.ok(fence, 'Step 6 却下フェンス(checker 非経由)が見つからない')\n const lines = fence.split('\\n')\n // 残留検出の if 条件(porcelain / diff --quiet / ls-files --others の複合判定)を探し、\n // その then 節が echo のみで完了扱いにせず exit 1 で停止することを確認する。\n const ifIdx = lines.findIndex((l) => /git status --porcelain/.test(l))\n assert.notEqual(ifIdx, -1, '残留検出の if 条件(git status --porcelain)が見つからない')\n const thenIdx = lines.findIndex((l, i) => i > ifIdx && /^\\s*then\\s*$/.test(l))\n const bodyEnd = lines.findIndex((l, i) => i > (thenIdx === -1 ? ifIdx : thenIdx) && /^\\s*fi\\s*$/.test(l))\n assert.notEqual(bodyEnd, -1, '残留検出 if ブロックの fi が見つからない')\n const body = lines.slice(ifIdx, bodyEnd + 1).join('\\n')\n assert.match(\n body,\n /exit 1/,\n '残留検出の分岐が exit 1 を含まない(echo のみで完了扱いのまま次スキルへ進み得る。Issue #417 P0 の回帰)'\n )\n})\n\ntest('SKILL.md: Step 6 却下フェンス(checker 経由・PRE_SYNC_TREE 復元)の復元後検証も残留検出時に exit 1 で停止する(Issue #417 P0 と対称の回帰ピン留め)', () => {\n const content = readFileSync(SKILL_MD_PATH, 'utf8')\n const fence = findStep6CheckerRejectFence(extractBashFences(content))\n assert.ok(fence, 'Step 6 却下フェンス(checker 経由・PRE_SYNC_TREE 復元)が見つからない')\n const lines = fence.split('\\n')\n const ifIdx = lines.findIndex((l) => /^if git diff --cached --quiet \"\\$\\{PRE_SYNC_TREE\\}\"/.test(l))\n assert.notEqual(ifIdx, -1, '復元後検証の if 条件が見つからない')\n const elseIdx = lines.findIndex((l, i) => i > ifIdx && /^\\s*else\\s*$/.test(l))\n const fiIdx = lines.findIndex((l, i) => i > ifIdx && /^\\s*fi\\s*$/.test(l))\n assert.ok(elseIdx !== -1 && fiIdx !== -1 && elseIdx \u003c fiIdx, '復元後検証 if の else/fi が見つからない')\n const elseBody = lines.slice(elseIdx, fiIdx + 1).join('\\n')\n assert.match(\n elseBody,\n /exit 1/,\n '復元後検証の失敗分岐(else)が exit 1 を含まない(echo のみで完了扱いのまま次スキルへ進み得る)'\n )\n})\n\ntest('SKILL.md: Step 6 却下フェンス(checker 経由)は untracked_list の mktemp 失敗をガードし退避先を掃除して fail-closed する(Bugbot 指摘の回帰ピン留め)', () => {\n const content = readFileSync(SKILL_MD_PATH, 'utf8')\n const fence = findStep6CheckerRejectFence(extractBashFences(content))\n assert.ok(fence, 'Step 6 却下フェンス(checker 経由・PRE_SYNC_TREE 復元)が見つからない')\n const lines = fence.split('\\n')\n // bare な `untracked_list=\"$(mktemp)\"` が残っていないこと(errexit オフでは空パスへの\n // リダイレクトが後続の誤解を招くエラーになり、set -e 下ではメッセージなしで即中断する)\n const bareIdx = lines.findIndex((l) => /^untracked_list=\"\\$\\(mktemp\\)\"\\s*$/.test(l))\n assert.equal(bareIdx, -1, 'untracked_list の mktemp が失敗チェックなし(bare)のまま残っている')\n // CONTRACT_UNTRACKED_BACKUP_DIR / restore_contract_scope と同形式の if ! ガードであること\n const guardIdx = lines.findIndex((l) => /^if ! untracked_list=\"\\$\\(mktemp\\)\"; then$/.test(l))\n assert.notEqual(guardIdx, -1, 'untracked_list の mktemp に if ! ガードがない')\n const fiIdx = lines.findIndex((l, i) => i > guardIdx && /^fi\\s*$/.test(l))\n assert.notEqual(fiIdx, -1, 'mktemp ガードの fi が見つからない')\n const guardBody = lines.slice(guardIdx, fiIdx + 1).join('\\n')\n assert.match(guardBody, /fail-closed/, 'mktemp 失敗分岐に fail-closed メッセージがない')\n assert.match(\n guardBody,\n /rmdir \"\\$\\{CONTRACT_UNTRACKED_BACKUP_DIR\\}\"/,\n 'mktemp 失敗分岐が直前に作成した退避先ディレクトリを掃除していない'\n )\n assert.match(guardBody, /exit 1/, 'mktemp 失敗分岐が exit 1 で停止していない')\n})\n\ntest('挙動: Step 4 フェンスの保存・条件付き復元は呼び出し元の errexit 状態を変えない', () => {\n const content = readFileSync(SKILL_MD_PATH, 'utf8')\n const fence = findStep4Fence(extractBashFences(content))\n assert.ok(fence, 'Step 4 フェンスが見つからない')\n const lines = fence.split('\\n')\n const saveIdx = lines.findIndex((l) => /case \\$- in \\*e\\*\\)/.test(l))\n const restoreIdx = lines.findIndex((l) => /ERREXIT_WAS_ON.*-eq 1.*then set -e; fi/.test(l))\n assert.ok(saveIdx !== -1 && restoreIdx !== -1, '保存行・復元行の抽出に失敗した')\n let snippet = lines.slice(saveIdx, restoreIdx + 1).join('\\n')\n // 実ネットワークの npx 実行を避け、非ゼロ終了する擬似コマンドへ置換する\n // (PIPE_EXIT_SNAPSHOT[0] が非ゼロ捕捉されることも合わせて確認するため)。\n snippet = snippet.replace(/^\\s*npx --yes.*$/m, 'false | tee /dev/null')\n\n // errexit 無効で開始した場合: 実行後も無効のままであること。\n const outDisabled = execFileSync('bash', ['-c', `${snippet}\\necho \"STATE=$-\"\\necho \"SNAP0=\\${PIPE_EXIT_SNAPSHOT[0]}\"`], {\n encoding: 'utf8',\n })\n const stateDisabled = outDisabled.match(/STATE=(\\S*)/)?.[1] ?? ''\n const snap0Disabled = outDisabled.match(/SNAP0=(\\S*)/)?.[1] ?? ''\n assert.ok(!stateDisabled.includes('e'), `errexit 無効開始のはずが有効化された: $-=${stateDisabled}`)\n assert.equal(snap0Disabled, '1', `擬似 npx(false)の非ゼロ終了が捕捉されていない: SNAP0=${snap0Disabled}`)\n\n // errexit 有効(set -e 前置)で開始した場合: 実行後も有効のままであること\n // (元の状態への復元)。\n const outEnabled = execFileSync(\n 'bash',\n ['-c', `set -e\\n${snippet}\\necho \"STATE=$-\"\\necho \"SNAP0=\\${PIPE_EXIT_SNAPSHOT[0]}\"`],\n { encoding: 'utf8' }\n )\n const stateEnabled = outEnabled.match(/STATE=(\\S*)/)?.[1] ?? ''\n assert.ok(stateEnabled.includes('e'), `errexit 有効開始のはずが復元されなかった: $-=${stateEnabled}`)\n})\n\ntest('SKILL.md: Step 6 却下フェンス(checker 非経由)は git status / git ls-files の command substitution 失敗を明示的に検知して fail-closed する(Issue #417 P0 CI 指摘の回帰ピン留め)', () => {\n const content = readFileSync(SKILL_MD_PATH, 'utf8')\n const fence = findStep6RejectFence(extractBashFences(content))\n assert.ok(fence, 'Step 6 却下フェンス(checker 非経由)が見つからない')\n // `[[ -n \"$(cmd)\" ]]` は cmd の終了コードを見ないため、削除前の未追跡判定・最終残留\n // 検出のいずれも command substitution を if の条件式として実行し、失敗時に明示的な\n // エラーメッセージ付きで exit 1 することを確認する(出力を変数へ一度だけ代入してから\n // 内容判定する構造。同じ command substitution を条件式内で2回評価する旧実装ではない)。\n assert.match(\n fence,\n /if\\s+!\\s+\\w+=\"\\$\\(git ls-files -z --others --exclude-standard -- skills-lock\\.json\\)\";\\s*then/,\n '削除前の git ls-files 呼び出しが command substitution の失敗を検知する形(if ! VAR=\"$(...)\"; then)になっていない'\n )\n assert.match(\n fence,\n /if\\s+!\\s+\\w+=\"\\$\\(git status --porcelain -- \"\\.agents\\/skills\\/\\$\\{SKILL_NAME\\}\\/\"\\)\";\\s*then/,\n '最終検証の git status 呼び出しが command substitution の失敗を検知する形になっていない'\n )\n // 失敗検知の直後に fail-closed で exit 1 すること(値が空のまま「残留なし」に\n // フォールスルーしない)。\n const lines = fence.split('\\n')\n const lockCheckIdx = lines.findIndex((l) => /if\\s+!\\s+\\w+=\"\\$\\(git ls-files -z --others --exclude-standard -- skills-lock\\.json\\)\";\\s*then/.test(l))\n assert.notEqual(lockCheckIdx, -1)\n const lockCheckFiIdx = lines.findIndex((l, i) => i > lockCheckIdx && /^\\s*fi\\s*$/.test(l))\n assert.match(lines.slice(lockCheckIdx, lockCheckFiIdx + 1).join('\\n'), /exit 1/, '削除前 git ls-files 失敗検知の分岐が exit 1 を含まない')\n})\n\ntest('挙動: Step 6 却下フェンス(checker 非経由)は git status --porcelain が異常終了した場合、空出力を「残留なし」と誤判定せず exit 1 で停止する(Issue #417 P0 CI 指摘の回帰)', () => {\n const content = readFileSync(SKILL_MD_PATH, 'utf8')\n const fence = findStep6RejectFence(extractBashFences(content))\n assert.ok(fence, 'Step 6 却下フェンスが見つからない')\n\n const realGit = execFileSync('bash', ['-c', 'command -v git'], { encoding: 'utf8' }).trim()\n const tmp = mkdtempSync(join(tmpdir(), 'errexit-fence-step6-statusfail-'))\n const binDir = join(tmp, 'bin')\n try {\n execFileSync('git', ['init', '-q'], { cwd: tmp })\n execFileSync('git', ['config', 'user.email', '[email protected]'], { cwd: tmp })\n execFileSync('git', ['config', 'user.name', 'test'], { cwd: tmp })\n writeFileSync(join(tmp, 'skills-lock.json'), '{\"initial\":true}\\n')\n\n // git status --porcelain -- \u003c当該スキルディレクトリ> の呼び出しだけを異常終了させ、\n // 他の git 呼び出し(checkout / clean / diff / ls-files)は実 git へ委譲する擬似 git を\n // 用意する。これにより「コマンド自体が失敗して空出力になったケース」を再現する。\n execFileSync('mkdir', ['-p', binDir])\n writeFileSync(\n join(binDir, 'git'),\n `#!/bin/bash\\nif [[ \"$1\" == \"status\" && \"$2\" == \"--porcelain\" ]]; then\\n echo \"fake permission error\" >&2\\n exit 128\\nfi\\nexec '${realGit}' \"$@\"\\n`\n )\n execFileSync('chmod', ['+x', join(binDir, 'git')])\n\n const script = `set -euo pipefail\\ncd '${tmp}'\\nSKILL_NAME='test-fence-skill'\\nPATH='${binDir}:'\"$PATH\"\\n${fence}\\necho DONE\\n`\n let threw = false\n let stderr = ''\n try {\n execFileSync('bash', ['-c', script], { encoding: 'utf8' })\n } catch (err) {\n threw = true\n stderr = String(err.stderr ?? '')\n }\n assert.ok(threw, 'git status --porcelain が異常終了したにもかかわらず、フェンスが exit 1 せず完走した(空出力を「残留なし」と誤判定した可能性。Issue #417 P0 CI 指摘の回帰)')\n assert.match(stderr, /fail-closed/, 'git status 失敗時の fail-closed エラーメッセージが出力されていない')\n } finally {\n rmSync(tmp, { recursive: true, force: true })\n }\n})\n\ntest('挙動: Step 6 却下フェンスは初回生成相当の状態でも set -e 下で abort せず完走する', () => {\n const content = readFileSync(SKILL_MD_PATH, 'utf8')\n const fence = findStep6RejectFence(extractBashFences(content))\n assert.ok(fence, 'Step 6 却下フェンスが見つからない')\n\n const tmp = mkdtempSync(join(tmpdir(), 'errexit-fence-step6-'))\n try {\n execFileSync('git', ['init', '-q'], { cwd: tmp })\n execFileSync('git', ['config', 'user.email', '[email protected]'], { cwd: tmp })\n execFileSync('git', ['config', 'user.name', 'test'], { cwd: tmp })\n // 初回生成相当: skills-lock.json は未追跡(tracked にしない)、スキル\n // ディレクトリは存在しない。SKILL_NAME はテスト専用の kebab-case 値を使う。\n // skills-lock.json は実際にファイルとして作成しておく(未追跡「ファイルが\n // 存在する」経路を再現しないと、削除処理の欠落を検出できない。Issue #417 P1 指摘:\n // 従来のテストはこのファイルを作成しておらず、git ls-files --others が常に\n // 空集合を返すため経路自体を再現できていなかった)。\n writeFileSync(join(tmp, 'skills-lock.json'), '{\"initial\":true}\\n')\n const script = `set -euo pipefail\\ncd '${tmp}'\\nSKILL_NAME='test-fence-skill'\\n${fence}\\necho DONE\\n`\n const out = execFileSync('bash', ['-c', script], { encoding: 'utf8' })\n assert.match(out, /DONE/, 'Step 6 却下フェンスが set -e 下で完走しなかった(abort した可能性)')\n assert.equal(\n existsSync(join(tmp, 'skills-lock.json')),\n false,\n '未追跡の skills-lock.json が却下リバート後も残留している(次スキルの承認時に一緒に stage され得る)'\n )\n } finally {\n rmSync(tmp, { recursive: true, force: true })\n }\n})\n","content_type":"text/javascript; charset=utf-8","language":"javascript","size":20454,"content_sha256":"3e5f6f7b4bf0ae5a998d819e7a439e65dffee60f6367051e9dfc976d4bf54035"},{"filename":"tests/path-state-dirnames.test.mjs","content":"// path-state-dirnames.test.mjs — PR #412 P1 の回帰テスト(dirnames 自身の\n// mode 変更・ディレクトリ向け symlink の変更を状態シグネチャが見逃す件)。\n//\n// scripts/skills-lock-update.sh の path_state() は、ディレクトリ・gitlink を\n// os.walk() で再帰走査して signature を作るが、`filenames` のみを集約対象にして\n// `dirnames` 自身(配下ディレクトリの mode・ディレクトリ向け symlink のリンク先/\n// mode)を含めていなかった。この場合:\n// - 配下ディレクトリの chmod(パーミッション変更)\n// - ディレクトリを指す symlink のリンク先変更・mode 変更\n// のいずれも porcelain 記録・状態シグネチャの両方が前後不変に見え、fail-closed\n// 検出をすり抜け得た(scope-guard.test.mjs のケース9〜11 が実運用経路(スクリプト\n// 全体 + npx スタブ)で同クラスを検証し、このテストは path_state() 単体の\n// シグネチャ性質を直接検証する)。\n//\n// scope-guard.test.mjs のように git status / npx スタブ経由で再現するには\n// gitlink(submodule)の構築が必要で高コストなため、ここでは path_state() 関数\n// 定義をスクリプトから抽出し、bash 標準入力へ流し込んで直接呼び出すことで、\n// シグネチャが配下ディレクトリの mode・symlink 変更を反映することを検証する。\nimport { test } from 'node:test'\nimport assert from 'node:assert/strict'\nimport { execFileSync } from 'node:child_process'\nimport { mkdtempSync, writeFileSync, mkdirSync, rmSync, chmodSync, symlinkSync, unlinkSync } from 'node:fs'\nimport { tmpdir } from 'node:os'\nimport { dirname, join } from 'node:path'\nimport { fileURLToPath } from 'node:url'\nimport { readFileSync } from 'node:fs'\n\nconst SCRIPT_PATH = join(\n dirname(fileURLToPath(import.meta.url)),\n '..', 'scripts', 'skills-lock-update.sh',\n)\n\n// スクリプト本体から `path_state() { ... }` の関数定義ブロックだけを抽出する。\n// python3 ヒアドキュメント内にも `{` `}` を含む行があるため、開始行から\n// 直後の `PYEOF` に続く最初の `}` 行までを機械的に切り出す(構文上、関数の\n// 閉じ括弧は \"PYEOF\\n}\" という並びで必ず現れる。SCRIPT の実装に依存する\n// 抽出だが、version-pin.test.mjs と同様スクリプト側の変更を追随的に検知する\n// ドリフト検知テストとして機能させる)。\nfunction extractPathStateFunction(scriptContent) {\n const startIdx = scriptContent.indexOf('path_state() {')\n assert.notEqual(startIdx, -1, 'path_state() の定義が見つからない')\n const endMarker = \"PYEOF\\n}\"\n const endIdx = scriptContent.indexOf(endMarker, startIdx)\n assert.notEqual(endIdx, -1, 'path_state() の終端(PYEOF の閉じ括弧)が見つからない')\n return scriptContent.slice(startIdx, endIdx + endMarker.length)\n}\n\nfunction callPathState(fnDef, targetPath) {\n const script = `${fnDef}\\npath_state \"$1\"\\n`\n return execFileSync('bash', ['-c', script, 'bash', targetPath], { encoding: 'utf8' }).trim()\n}\n\ntest('path_state: 配下ディレクトリの chmod をシグネチャに反映する(PR #412 P1 の回帰)', () => {\n const fnDef = extractPathStateFunction(readFileSync(SCRIPT_PATH, 'utf8'))\n const root = mkdtempSync(join(tmpdir(), 'path-state-dir-mode-'))\n try {\n const subdir = join(root, 'subdir')\n mkdirSync(subdir)\n writeFileSync(join(subdir, 'file.txt'), 'unchanged content\\n')\n\n const before = callPathState(fnDef, root)\n chmodSync(subdir, 0o700)\n const after = callPathState(fnDef, root)\n\n assert.notEqual(\n before,\n after,\n '配下ディレクトリの mode 変更(内容・ファイル一覧は不変)がシグネチャへ反映されるべき',\n )\n } finally {\n rmSync(root, { recursive: true, force: true })\n }\n})\n\ntest('path_state: ディレクトリ向け symlink のリンク先変更をシグネチャに反映する(PR #412 P1 の回帰)', () => {\n const fnDef = extractPathStateFunction(readFileSync(SCRIPT_PATH, 'utf8'))\n const root = mkdtempSync(join(tmpdir(), 'path-state-dir-symlink-'))\n try {\n const targetA = join(root, 'target-a')\n const targetB = join(root, 'target-b')\n mkdirSync(targetA)\n mkdirSync(targetB)\n writeFileSync(join(targetA, 'a.txt'), 'a\\n')\n writeFileSync(join(targetB, 'b.txt'), 'b\\n')\n\n const linkPath = join(root, 'link-to-dir')\n symlinkSync('target-a', linkPath)\n\n const before = callPathState(fnDef, root)\n unlinkSync(linkPath)\n symlinkSync('target-b', linkPath)\n const after = callPathState(fnDef, root)\n\n assert.notEqual(\n before,\n after,\n 'ディレクトリを指す symlink のリンク先変更がシグネチャへ反映されるべき' +\n '(followlinks=False で symlink 自身の中身までは辿らないため、リンク先文字列' +\n '・mode 自体を dirnames として記録する必要がある)',\n )\n } finally {\n rmSync(root, { recursive: true, force: true })\n }\n})\n\ntest('path_state: ディレクトリ向け symlink 配下(リンク先の中身)は二重に取り込まない(非退行)', () => {\n const fnDef = extractPathStateFunction(readFileSync(SCRIPT_PATH, 'utf8'))\n const root = mkdtempSync(join(tmpdir(), 'path-state-dir-symlink-nofollow-'))\n try {\n const targetA = join(root, 'target-a')\n mkdirSync(targetA)\n writeFileSync(join(targetA, 'a.txt'), 'a\\n')\n symlinkSync('target-a', join(root, 'link-to-dir'))\n\n const before = callPathState(fnDef, root)\n // symlink が指す先の中身だけを変更しても、target-a 側の filenames 走査で\n // 検出されるべきであり(symlink 経由の二重降下ではなく実体側の直接検出)、\n // これは既存の filenames 集約が担う(回帰していないことの確認)。\n writeFileSync(join(targetA, 'a.txt'), 'changed\\n')\n const after = callPathState(fnDef, root)\n\n assert.notEqual(before, after, 'symlink の実体側ファイル内容変更は引き続き検出されるべき')\n } finally {\n rmSync(root, { recursive: true, force: true })\n }\n})\n","content_type":"text/javascript; charset=utf-8","language":"javascript","size":6305,"content_sha256":"22d585ebf16779dbba04511e29c9047b6b584929f5d9f608c0b6324e2d296a48"},{"filename":"tests/preview-untracked.test.mjs","content":"// preview-untracked.test.mjs — Issue #381 対応の決定的回帰テスト。\n//\n// skills-lock-update.sh の承認プレビュー(更新完了後の「変更内容:」表示)が、\n// `git diff` だけでは表示されない未追跡ファイルの存在・内容まで見せることを検証する。\n//\n// npx の実クローン処理は行わず、PATH 先頭に置いた npx / gh のスタブへ差し替える。\n// スタブの挙動は TEST_NPX_SCENARIO で切り替える:\n// - 'new-file' : skills-lock.json を更新し、.agents/skills/\u003cskill>/ 配下に\n// 新規(未追跡)ファイルを作成する(upstream にファイルが増えたケースの再現)\n// - 'edit-only' : skills-lock.json と既存の追跡ファイルのみ変更し、新規ファイルは作らない\n// (既存動作の非退行確認)\n// - 'newline-file' : ファイル名に改行を含む未追跡ファイルを作成する\n// (git ls-files の改行区切り出力で分割される回帰の再現)\n// - 'empty-file' : 中身が空(0 byte)の未追跡ファイルを作成する\n// (git diff --no-index が差分を出さず名前も分からなくなる回帰の再現)\n// - 'binary-file' : NUL バイトを含むバイナリの未追跡ファイルを作成する\n// (git diff --no-index が \"Binary files ... differ\" しか出さず、\n// 内容未確認のまま git add で取り込まれてしまう回帰の再現)\n// - 'new-subdir' : upstream が新規サブディレクトリごとファイルを追加するケースを再現する\n// (git ls-files はファイルパスを個々に返すが、git clean -fdn は親\n// ディレクトリをまとめて1行で報告するため、SKILL.md 手動回帰確認の\n// 照合手順が行単位の完全一致では機能しないことを検証する)\n// - 'ls-files-fail': `.agents/skills/\u003cskill>/` を git のワークツリー外へ差し替え、\n// git ls-files がエラー終了するケースを再現する(プロセス置換の終了コードが\n// 検査されず fail-open になる回帰の再現。テストは非ゼロ終了と\n// 「新規(未追跡)ファイル: なし」が出力されないことの両方を検証する)\n// git / jq / python3 は実物を使用する(ネットワーク不使用)。\nimport { test } from 'node:test'\nimport assert from 'node:assert/strict'\nimport { execFileSync } from 'node:child_process'\nimport { mkdtempSync, writeFileSync, chmodSync, mkdirSync, rmSync, existsSync, readFileSync } from 'node:fs'\nimport { tmpdir } from 'node:os'\nimport { dirname, join } from 'node:path'\nimport { fileURLToPath } from 'node:url'\n\n// git ls-files を意図的に失敗させる回帰確認('ls-files-fail' シナリオ)で、実 git へ\n// 委譲するために使う絶対パス。PATH の先頭を書き換えた後に `git` で解決すると自己参照に\n// なるため、テスト起動時(PATH 上書き前)に一度だけ解決する。\nconst REAL_GIT = execFileSync('/bin/bash', ['-c', 'command -v git'], { encoding: 'utf8' }).trim()\n\nconst SCRIPT_PATH = join(\n dirname(fileURLToPath(import.meta.url)),\n '..', 'scripts', 'skills-lock-update.sh',\n)\n\nconst SKILL_NAME = 'dummy-skill'\nconst SOURCE_REPO = 'Fandhe-AI/dummy-source'\n\nfunction sh(cmd, cwd, env) {\n return execFileSync('bash', ['-c', cmd], { cwd, env, encoding: 'utf8' })\n}\n\n// gh / npx を差し替えるスタブ bin ディレクトリと、npx シナリオを注入した git repo 一式を用意する。\n// clean ガード(porcelain チェック)を通す必要があるため、初期状態は必ず commit 済みにする。\nfunction setupRepo(scenario) {\n const repoDir = mkdtempSync(join(tmpdir(), 'sync-skills-lock-test-'))\n const binDir = mkdtempSync(join(tmpdir(), 'sync-skills-lock-bin-'))\n\n // gh スタブ: auth status のみ成功させる(他サブコマンドはこのスクリプトから呼ばれない)\n writeFileSync(join(binDir, 'gh'), '#!/usr/bin/env bash\\nexit 0\\n')\n chmodSync(join(binDir, 'gh'), 0o755)\n\n // npx スタブ: 実際の `npx skills add` の代わりに、シナリオに応じて\n // skills-lock.json の computedHash 更新・install ツリーの変更を模擬する。\n const npxBody = `#!/usr/bin/env bash\nset -euo pipefail\nskill_dir=\".agents/skills/${SKILL_NAME}\"\n# 既存の追跡ファイルを変更(tracked diff の対象)\necho \"updated upstream content\" > \"\\${skill_dir}/SKILL.md\"\n# skills-lock.json の computedHash を書き換える\npython3 - \u003c\u003c'PYEOF'\nimport json\nwith open('skills-lock.json') as f:\n lock = json.load(f)\nlock['skills']['${SKILL_NAME}']['computedHash'] = 'sha256:updated-hash-value'\nwith open('skills-lock.json', 'w') as f:\n json.dump(lock, f, indent=2)\n f.write('\\\\n')\nPYEOF\nif [[ \"\\${TEST_NPX_SCENARIO:-}\" == \"new-file\" ]]; then\n # upstream にファイルが増えたケース: npx 実行前は clean だったため、これは必ず未追跡になる\n echo \"brand new upstream file\" > \"\\${skill_dir}/NEW_FILE.md\"\nfi\nif [[ \"\\${TEST_NPX_SCENARIO:-}\" == \"newline-file\" ]]; then\n # 改行を含むファイル名の未追跡ファイル(-z / NUL 区切り処理の回帰確認用)\n printf 'newline file content\\\\n' > \"\\${skill_dir}/weird\"\\

sync-skills-lock ルート直下の の を、upstream リポジトリの現状と照合して更新する。 対象ファイル - ルート : 呼び出し元リポジトリ直下の — このスキルが唯一編集するファイル - 除外 : submodule 配下の は編集しない(submodule 境界を跨がないため) 前提条件 - CLI がインストールされ、認証済みであること - / が利用可能であること( を使用するため)。 CLI は固定版( )で実行する。値と更新手順は「skills CLI のバージョン固定と更新手順」節を参照 - が利用可能であること(状態署名〔 / 〕と復元処理〔既存 ignored ファイルの比較・復元等〕で使用。主要 Linux / macOS には標準搭載されている。無い環境では Step 4 フェンスが npx 実行前に fail-closed で停止するため、導入してから再実行すること) - CLI が利用可能であること(未追跡バイナリファイルの種別表示に使用。未導入の環境では 種別が として表示され、承認前にサイズ・git blob ハッシュのみで 判断することになる。macOS / 主要 Linux ディストリビューションには標準搭載されている) - ルート直下の が存在すること - 実行前に に未コミットの変更がないこと (ステージ済み・未ステージ…

\\\\n'\"name.md\"\nfi\nif [[ \"\\${TEST_NPX_SCENARIO:-}\" == \"empty-file\" ]]; then\n # 中身が空(0 byte)の未追跡ファイル(git diff --no-index が差分を出さないケースの確認用)\n : > \"\\${skill_dir}/EMPTY_FILE.md\"\nfi\nif [[ \"\\${TEST_NPX_SCENARIO:-}\" == \"binary-file\" ]]; then\n # NUL バイトを含むバイナリファイル(git diff --no-index が内容を出さないケースの確認用)\n printf 'binary\\\\x00marker\\\\x00content' > \"\\${skill_dir}/IMAGE.bin\"\nfi\nif [[ \"\\${TEST_NPX_SCENARIO:-}\" == \"new-subdir\" ]]; then\n # upstream が新規サブディレクトリごとファイルを追加するケース\n # (git clean -fdn は個々のファイルではなく親ディレクトリをまとめて1行で報告する)\n mkdir -p \"\\${skill_dir}/references\"\n echo \"brand new upstream reference doc\" > \"\\${skill_dir}/references/new.md\"\nfi\nexit 0\n`\n writeFileSync(join(binDir, 'npx'), npxBody)\n chmodSync(join(binDir, 'npx'), 0o755)\n\n sh('git init -q', repoDir)\n sh('git config user.email [email protected]', repoDir)\n sh('git config user.name test', repoDir)\n\n mkdirSync(join(repoDir, '.agents', 'skills', SKILL_NAME), { recursive: true })\n writeFileSync(\n join(repoDir, '.agents', 'skills', SKILL_NAME, 'SKILL.md'),\n 'original content\\n',\n )\n writeFileSync(\n join(repoDir, 'skills-lock.json'),\n JSON.stringify(\n {\n skills: {\n [SKILL_NAME]: {\n source: `https://github.com/${SOURCE_REPO}`,\n computedHash: 'sha256:original-hash-value',\n },\n },\n },\n null,\n 2,\n ) + '\\n',\n )\n sh('git add -A && git commit -q -m init', repoDir)\n\n return { repoDir, binDir, scenario }\n}\n\n// 'ls-files-fail' 回帰確認専用: `git ls-files --others ...`(未追跡ファイル列挙)のみを\n// 失敗させ、他の git 呼び出し(Step 4 の clean チェック・Step 5 の tracked diff 等)は\n// 実 git へ委譲する `git` ラッパーを PATH 先頭の binDir へ追加する。\n// pathspec 一致等の粗い偽装ではなく `--others` の有無で判定することで、スクリプトが\n// 未追跡ファイル列挙にだけ `git ls-files` を使っている実装を維持したまま、\n// 意図した呼び出し箇所だけを的確に失敗させる。`--ignored` を伴う呼び出し\n// (npx 実行前の既存 ignored 列挙・リバート時の新規 ignored 選別)は未追跡\n// プレビューとは別経路のため、失敗対象から除外して実 git へ委譲する。\nfunction addGitLsFilesFailure(binDir) {\n const gitWrapper = `#!/usr/bin/env bash\nif [[ \"\\${1:-}\" == \"ls-files\" ]]; then\n has_others=0\n has_ignored=0\n for a in \"\\$@\"; do\n if [[ \"\\${a}\" == \"--others\" ]]; then has_others=1; fi\n if [[ \"\\${a}\" == \"--ignored\" ]]; then has_ignored=1; fi\n done\n if [[ \"\\${has_others}\" -eq 1 && \"\\${has_ignored}\" -eq 0 ]]; then\n echo \"fatal: simulated git ls-files failure (index corrupt)\" >&2\n exit 128\n fi\nfi\nexec \"${REAL_GIT}\" \"\\$@\"\n`\n writeFileSync(join(binDir, 'git'), gitWrapper)\n chmodSync(join(binDir, 'git'), 0o755)\n}\n\nfunction runScript({ repoDir, binDir, scenario }) {\n const env = {\n ...process.env,\n PATH: `${binDir}:${process.env.PATH}`,\n TEST_NPX_SCENARIO: scenario,\n }\n return execFileSync('bash', [SCRIPT_PATH, SKILL_NAME, SOURCE_REPO], {\n cwd: repoDir,\n env,\n encoding: 'utf8',\n })\n}\n\nfunction cleanDryRunList(repoDir) {\n const out = sh(\n `git clean -fdn -- \".agents/skills/${SKILL_NAME}/\"`,\n repoDir,\n )\n // `Would remove \u003cpath>` 形式の行からパスのみを取り出す\n return out\n .trim()\n .split('\\n')\n .filter(Boolean)\n .map((line) => line.replace(/^Would remove /, '').trim())\n .sort()\n}\n\ntest('ケース1: upstream にファイルが増えた場合、プレビューが新規未追跡ファイルの内容を表示する', () => {\n const ctx = setupRepo('new-file')\n try {\n const out = runScript(ctx)\n\n assert.match(out, /新規(未追跡)ファイル/, '未追跡ファイルの見出しが出力されること')\n assert.match(out, /NEW_FILE\\.md/, '新規ファイル名が出力に含まれること')\n assert.match(\n out,\n /brand new upstream file/,\n '新規ファイルの内容(diff --no-index の出力)が表示されること',\n )\n\n // プレビューの未追跡集合と git clean -fdn(拒否経路の対象集合)が一致することを確認する\n assert.deepEqual(\n cleanDryRunList(ctx.repoDir),\n [`.agents/skills/${SKILL_NAME}/NEW_FILE.md`],\n )\n\n // tracked diff も従来どおり表示されること(非退行)\n assert.match(out, /SKILL\\.md/, 'tracked ファイルの diff も表示されること')\n } finally {\n rmSync(ctx.repoDir, { recursive: true, force: true })\n rmSync(ctx.binDir, { recursive: true, force: true })\n }\n})\n\ntest('ケース2: 追跡ファイルの変更のみの場合、「新規(未追跡)ファイル: なし」と表示される(既存動作の非退行)', () => {\n const ctx = setupRepo('edit-only')\n try {\n const out = runScript(ctx)\n\n assert.match(out, /新規(未追跡)ファイル: なし/)\n // tracked diff(SKILL.md の変更)は従来どおり表示される\n assert.match(out, /updated upstream content/)\n\n // 未追跡ファイルが無いため、拒否経路(git clean -fdn)の対象も空になる\n assert.deepEqual(cleanDryRunList(ctx.repoDir), [])\n } finally {\n rmSync(ctx.repoDir, { recursive: true, force: true })\n rmSync(ctx.binDir, { recursive: true, force: true })\n }\n})\n\ntest('ケース3: ファイル名に改行を含む未追跡ファイルでも、内容が省略されずに表示される(-z / NUL 区切りの回帰確認)', () => {\n const ctx = setupRepo('newline-file')\n try {\n const out = runScript(ctx)\n\n assert.match(out, /新規(未追跡)ファイル/, '未追跡ファイルの見出しが出力されること')\n assert.match(\n out,\n /newline file content/,\n '改行を含むファイル名の未追跡ファイルでも内容の diff が表示されること(改行区切り処理だと' +\n '存在しないパスへ分割され || true で握り潰されるため表示されない)',\n )\n\n // tracked diff も従来どおり表示されること(非退行)\n assert.match(out, /SKILL\\.md/, 'tracked ファイルの diff も表示されること')\n } finally {\n rmSync(ctx.repoDir, { recursive: true, force: true })\n rmSync(ctx.binDir, { recursive: true, force: true })\n }\n})\n\ntest('ケース4: 中身が空の新規ファイルでも、ファイル名がプレビューに明示される', () => {\n const ctx = setupRepo('empty-file')\n try {\n const out = runScript(ctx)\n\n assert.match(out, /新規(未追跡)ファイル/, '未追跡ファイルの見出しが出力されること')\n assert.match(\n out,\n /EMPTY_FILE\\.md/,\n '空ファイルでも git diff --no-index の見出しだけに頼らずファイル名が明示されること',\n )\n\n // プレビューの未追跡集合と git clean -fdn(拒否経路の対象集合)が一致することを確認する\n assert.deepEqual(\n cleanDryRunList(ctx.repoDir),\n [`.agents/skills/${SKILL_NAME}/EMPTY_FILE.md`],\n )\n } finally {\n rmSync(ctx.repoDir, { recursive: true, force: true })\n rmSync(ctx.binDir, { recursive: true, force: true })\n }\n})\n\ntest('ケース5: バイナリの未追跡ファイルは種別・サイズ・ハッシュが明示され、内容未確認のまま通過しない', () => {\n const ctx = setupRepo('binary-file')\n try {\n const out = runScript(ctx)\n\n assert.match(out, /新規(未追跡)ファイル/, '未追跡ファイルの見出しが出力されること')\n assert.match(out, /IMAGE\\.bin/, 'バイナリファイル名が出力に含まれること')\n assert.match(\n out,\n // object format は repository 設定依存(既定 sha1: 40 桁 / 拡張 sha256: 64 桁)で\n // 出力桁数が変わるため、アルゴリズム名・桁数を固定せず表記の形だけを検証する。\n /バイナリファイル(内容は表示されません): type=.+ size=\\d+bytes git-blob-hash\\([a-z0-9]+\\)=[0-9a-f]{40,64}/,\n 'git diff --no-index の \"Binary files ... differ\" だけに頼らず、種別・サイズ・' +\n 'git blob ハッシュが明示されること',\n )\n assert.doesNotMatch(\n out,\n /Binary files .* differ/,\n '内容未確認のまま \"Binary files ... differ\" だけが出て終わらないこと',\n )\n\n // プレビューの未追跡集合と git clean -fdn(拒否経路の対象集合)が一致することを確認する\n assert.deepEqual(\n cleanDryRunList(ctx.repoDir),\n [`.agents/skills/${SKILL_NAME}/IMAGE.bin`],\n )\n } finally {\n rmSync(ctx.repoDir, { recursive: true, force: true })\n rmSync(ctx.binDir, { recursive: true, force: true })\n }\n})\n\ntest('ケース6: upstream が新規サブディレクトリごとファイルを追加した場合、プレビューはファイルの' +\n 'フルパスを列挙し、git clean -fdn とは粒度が一致しない(前方一致でのみ照合できる)', () => {\n const ctx = setupRepo('new-subdir')\n try {\n const out = runScript(ctx)\n\n assert.match(out, /新規(未追跡)ファイル/, '未追跡ファイルの見出しが出力されること')\n // プレビューは git ls-files ベースのため、ディレクトリではなく個々のファイルの\n // フルパスをそのまま列挙する(SKILL.md の手動回帰確認手順3が要求する挙動)\n assert.match(\n out,\n /references\\/new\\.md/,\n 'サブディレクトリ配下のファイルもフルパスで一覧に出ること',\n )\n assert.match(out, /brand new upstream reference doc/, 'ファイル内容の diff も表示されること')\n\n // git clean -fdn は新規ディレクトリを個々のファイルではなく親ディレクトリ1行で\n // 報告する。行単位の完全一致ではプレビューと照合できないことを実測で示す\n // (SKILL.md 手順3が「一致」ではなく「前方一致」を要求する根拠)。\n const cleanList = cleanDryRunList(ctx.repoDir)\n assert.deepEqual(cleanList, [`.agents/skills/${SKILL_NAME}/references/`])\n assert.notDeepEqual(cleanList, [`.agents/skills/${SKILL_NAME}/references/new.md`])\n // 前方一致では照合できる\n assert.ok(\n cleanList.some((dir) => `.agents/skills/${SKILL_NAME}/references/new.md`.startsWith(dir)),\n 'プレビューのファイルパスは git clean -fdn の報告した親ディレクトリで始まること',\n )\n } finally {\n rmSync(ctx.repoDir, { recursive: true, force: true })\n rmSync(ctx.binDir, { recursive: true, force: true })\n }\n})\n\ntest('ケース7: git ls-files が失敗した場合、fail-open で「なし」と誤表示せずスクリプトが' +\n '非ゼロ終了し、tracked 分(skills-lock.json / SKILL.md)はリバートされるが、' +\n '未追跡ファイル(NEW_FILE.md)は destructive な git clean -fd を経ずに保全される' +\n '(codex P0 指摘の再修正: git ls-files が失敗している以上「何が npx の新規作成物か」' +\n 'を安全に確定できないため、この状態で git clean -fd を実行すると checker / npx が' +\n '作成した唯一のコピーであり得る未追跡ファイルをバックアップなしで削除しデータ喪失に' +\n 'なり得る。列挙が壊れている経路では削除系操作を一切行わず、tracked のみの非破壊的な' +\n '復元 + 手動復旧の案内に留める)', () => {\n const ctx = setupRepo('new-file')\n addGitLsFilesFailure(ctx.binDir)\n const newFile = join(ctx.repoDir, '.agents', 'skills', SKILL_NAME, 'NEW_FILE.md')\n try {\n assert.throws(\n () => runScript(ctx),\n (err) => {\n assert.notEqual(err.status, 0, '非ゼロ終了すること(fail-closed)')\n const combined = `${err.stdout ?? ''}${err.stderr ?? ''}`\n assert.match(\n combined,\n /git ls-files が失敗し/,\n '専用のエラーメッセージで停止すること',\n )\n assert.doesNotMatch(\n combined,\n /新規(未追跡)ファイル: なし/,\n '実際には未追跡ファイルが存在するのに「なし」と誤表示しないこと' +\n '(process substitution の終了コードが検査されない fail-open 回帰の再現)',\n )\n assert.match(\n combined,\n /git clean は実行していません/,\n '未追跡集合を安全に列挙できなかったため destructive cleanup をスキップした旨が' +\n '案内されること',\n )\n assert.match(\n combined,\n /git status --porcelain/,\n '手動確認手順(git status --porcelain 等)が案内されること',\n )\n return true\n },\n )\n\n // 未追跡ファイル(NEW_FILE.md。npx が新規作成したもの)は git clean -fd を経由しない\n // ため削除されず、内容もそのまま残ること(データ喪失防止が本修正の主眼)。\n assert.ok(\n existsSync(newFile),\n 'git ls-files 失敗時は git clean -fd を実行しないため、未追跡ファイルが削除されず' +\n '残ること(destructive cleanup のスキップ)',\n )\n assert.equal(\n readFileSync(newFile, 'utf8'),\n 'brand new upstream file\\n',\n '残された未追跡ファイルの内容が変化していないこと',\n )\n\n // tracked 分(skills-lock.json の computedHash 更新・SKILL.md の上書き)は\n // git checkout --(tracked のみが対象で未追跡には触れない)でリバートされること。\n const lockDiff = sh('git status --porcelain -- skills-lock.json', ctx.repoDir).trim()\n assert.equal(lockDiff, '', 'skills-lock.json は tracked のためリバートされ clean であること')\n const skillMdDiff = sh(\n `git status --porcelain -- \".agents/skills/${SKILL_NAME}/SKILL.md\"`,\n ctx.repoDir,\n ).trim()\n assert.equal(\n skillMdDiff,\n '',\n 'SKILL.md は tracked のためリバートされ clean であること',\n )\n\n // ディレクトリ全体としては、削除されず残った未追跡の NEW_FILE.md 分だけ非 clean\n // であること(destructive cleanup をスキップした結果として正しい状態)。\n const treeStatus = sh(\n `git status --porcelain -- \".agents/skills/${SKILL_NAME}/\"`,\n ctx.repoDir,\n ).trim()\n assert.equal(\n treeStatus,\n `?? .agents/skills/${SKILL_NAME}/NEW_FILE.md`,\n '未追跡の NEW_FILE.md のみが残り、他は clean であること',\n )\n } finally {\n rmSync(ctx.repoDir, { recursive: true, force: true })\n rmSync(ctx.binDir, { recursive: true, force: true })\n }\n})\n","content_type":"text/javascript; charset=utf-8","language":"javascript","size":20719,"content_sha256":"0a5bb19471e15ce3ccacd639316f0a58a0f88fdb4df6d0f7576782c2c2044e20"},{"filename":"tests/restore-backup-failure-checker-lsfiles.test.mjs","content":"// restore-backup-failure-checker-lsfiles.test.mjs — Issue #422 の回帰テスト。\n//\n// preview-untracked.test.mjs ケース7は「checker が無いリポジトリ」で git ls-files が\n// 失敗するケースを検証しており、tracked 分(skills-lock.json / SKILL.md)は\n// revert_in_scope 1 の checkout でリバートされる。\n//\n// しかし checker があるリポジトリ(LOCAL_PATCH_GUARD=true)では、同じ git 破損が\n// restore_contract_scope 内の未追跡列挙(`git ls-files --others ...`)も失敗させ、\n// CONTRACT_UNTRACKED_BACKUP_FAILED=1 になる。この状態で revert_in_scope 1 を呼ぶと、\n// skip_clean=1 の「clean はしないが tracked の checkout はする」という指定を無視して\n// checkout ごと丸ごとスキップしてしまい、npx が加えた tracked worktree の編集\n// (SKILL.md の書き換え)が残置される(checker の有無で挙動が非対称になる回帰)。\n//\n// このテストは checker ありのリポジトリで git ls-files を失敗させ、\n// (1) tracked 分(skills-lock.json / SKILL.md)が checkout でリバートされること、\n// (2) 未追跡ファイル(NEW_FILE.md)は destructive な git clean を経ずに保全されること、\n// (3) checker / npx が唯一のコピーとして書き換えた内容が、checkout 前に\n// git 非依存の cp で退避先へ複製され失われないこと(Issue #418 の非再発)\n// を検証する。\nimport { test } from 'node:test'\nimport assert from 'node:assert/strict'\nimport { execFileSync } from 'node:child_process'\nimport { mkdtempSync, writeFileSync, chmodSync, mkdirSync, rmSync, existsSync, readFileSync } from 'node:fs'\nimport { tmpdir } from 'node:os'\nimport { dirname, join } from 'node:path'\nimport { fileURLToPath } from 'node:url'\n\nconst REAL_GIT = execFileSync('/bin/bash', ['-c', 'command -v git'], { encoding: 'utf8' }).trim()\n\nconst SCRIPT_PATH = join(\n dirname(fileURLToPath(import.meta.url)),\n '..', 'scripts', 'skills-lock-update.sh',\n)\n\nconst SKILL_NAME = 'dummy-skill'\nconst SOURCE_REPO = 'Fandhe-AI/dummy-source'\nconst UNIQUE_MARKER = 'UNIQUE-CONTENT-422-checker-rewrote-this'\n\nfunction sh(cmd, cwd, env) {\n return execFileSync('bash', ['-c', cmd], { cwd, env, encoding: 'utf8' })\n}\n\n// preview-untracked.test.mjs の addGitLsFilesFailure と同じ判定基準(`--others` あり・\n// `--ignored` なしの ls-files 呼び出しのみを失敗させる)。restore_contract_scope の\n// 未追跡列挙も同じ呼び出し形のため、この 1 つのラッパーで両方の呼び出し箇所を狙い撃ちできる。\nfunction addGitLsFilesFailure(binDir) {\n const gitWrapper = `#!/usr/bin/env bash\nif [[ \"\\${1:-}\" == \"ls-files\" ]]; then\n has_others=0\n has_ignored=0\n for a in \"\\$@\"; do\n if [[ \"\\${a}\" == \"--others\" ]]; then has_others=1; fi\n if [[ \"\\${a}\" == \"--ignored\" ]]; then has_ignored=1; fi\n done\n if [[ \"\\${has_others}\" -eq 1 && \"\\${has_ignored}\" -eq 0 ]]; then\n echo \"fatal: simulated git ls-files failure (index corrupt)\" >&2\n exit 128\n fi\nfi\nexec \"${REAL_GIT}\" \"\\$@\"\n`\n writeFileSync(join(binDir, 'git'), gitWrapper)\n chmodSync(join(binDir, 'git'), 0o755)\n}\n\nfunction setupRepo() {\n const repoDir = mkdtempSync(join(tmpdir(), 'sync-skills-lock-restore-checker-lsfiles-'))\n const binDir = mkdtempSync(join(tmpdir(), 'sync-skills-lock-restore-checker-lsfiles-bin-'))\n\n writeFileSync(join(binDir, 'gh'), '#!/usr/bin/env bash\\nexit 0\\n')\n chmodSync(join(binDir, 'gh'), 0o755)\n\n // npx: exit 0 のまま (a) skills-lock.json の computedHash 書き換え、\n // (b) 既存 tracked ファイル(SKILL.md)を一意な内容へ上書き、(c) 未追跡の新規ファイルを作成する。\n const npxBody = `#!/usr/bin/env bash\nset -euo pipefail\nskill_dir=\".agents/skills/${SKILL_NAME}\"\nprintf '%s\\\\n' \"${UNIQUE_MARKER}\" > \"\\${skill_dir}/SKILL.md\"\npython3 - \u003c\u003c'PYEOF'\nimport json\nwith open('skills-lock.json') as f:\n lock = json.load(f)\nlock['skills']['${SKILL_NAME}']['computedHash'] = 'sha256:updated-hash-value'\nwith open('skills-lock.json', 'w') as f:\n json.dump(lock, f, indent=2)\n f.write('\\\\n')\nPYEOF\necho \"brand new upstream file\" > \"\\${skill_dir}/NEW_FILE.md\"\nexit 0\n`\n writeFileSync(join(binDir, 'npx'), npxBody)\n chmodSync(join(binDir, 'npx'), 0o755)\n\n sh('git init -q', repoDir)\n sh('git config user.email [email protected]', repoDir)\n sh('git config user.name test', repoDir)\n\n mkdirSync(join(repoDir, '.agents', 'skills', SKILL_NAME), { recursive: true })\n writeFileSync(\n join(repoDir, '.agents', 'skills', SKILL_NAME, 'SKILL.md'),\n 'original content\\n',\n )\n writeFileSync(\n join(repoDir, 'skills-lock.json'),\n JSON.stringify(\n {\n skills: {\n [SKILL_NAME]: {\n source: `https://github.com/${SOURCE_REPO}`,\n computedHash: 'sha256:original-hash-value',\n },\n },\n },\n null,\n 2,\n ) + '\\n',\n )\n\n // checker: check / apply とも exit 0(何も変更しない)。LOCAL_PATCH_GUARD を\n // 有効化するためだけに存在する最小実装。\n mkdirSync(join(repoDir, 'scripts'), { recursive: true })\n writeFileSync(\n join(repoDir, 'scripts', 'check-skill-local-patches.sh'),\n ['#!/usr/bin/env bash', 'exit 0', ''].join('\\n'),\n )\n chmodSync(join(repoDir, 'scripts', 'check-skill-local-patches.sh'), 0o755)\n sh('git add -A && git commit -q -m init', repoDir)\n\n const checkerHash = sh(\n 'git hash-object -- scripts/check-skill-local-patches.sh',\n repoDir,\n ).trim()\n\n return { repoDir, binDir, checkerHash }\n}\n\nfunction runScript({ repoDir, binDir, checkerHash }) {\n const env = {\n ...process.env,\n PATH: `${binDir}:${process.env.PATH}`,\n CHECKER_APPROVED_HASH: checkerHash,\n }\n let stdout = ''\n let status = 0\n try {\n stdout = execFileSync('bash', [SCRIPT_PATH, SKILL_NAME, SOURCE_REPO], {\n cwd: repoDir,\n env,\n encoding: 'utf8',\n })\n } catch (err) {\n stdout = (err.stdout || '') + (err.stderr || '')\n status = err.status ?? 1\n }\n return { stdout, status }\n}\n\ntest('checker 経路で git ls-files が失敗しても、tracked 分(skills-lock.json / SKILL.md)は' +\n 'checkout でリバートされ、未追跡ファイルは destructive な git clean を経ずに保全される' +\n '(Issue #422: checker の有無で revert 挙動が非対称になる回帰の是正)', () => {\n const ctx = setupRepo()\n addGitLsFilesFailure(ctx.binDir)\n const newFile = join(ctx.repoDir, '.agents', 'skills', SKILL_NAME, 'NEW_FILE.md')\n try {\n const { stdout, status } = runScript(ctx)\n\n assert.notEqual(status, 0, 'スクリプトは非ゼロ終了する(fail-closed)')\n\n // tracked 分は checkout でリバートされ、npx が書き換えた一意な内容は残らない\n const skillMdContent = readFileSync(\n join(ctx.repoDir, '.agents', 'skills', SKILL_NAME, 'SKILL.md'),\n 'utf8',\n )\n assert.equal(\n skillMdContent,\n 'original content\\n',\n 'SKILL.md は tracked のため checkout でリバートされ、npx が上書きした内容は残らないこと' +\n '(checker が無い経路と同じ挙動になること)',\n )\n\n const lockDiff = sh('git status --porcelain -- skills-lock.json', ctx.repoDir).trim()\n assert.equal(lockDiff, '', 'skills-lock.json も tracked のためリバートされ clean であること')\n\n // 未追跡ファイルは destructive cleanup(git clean -fd)を経由しないため削除されず残る\n assert.ok(\n existsSync(newFile),\n '未追跡ファイル(NEW_FILE.md)は git clean -fd を実行しないため削除されず残ること',\n )\n assert.equal(\n readFileSync(newFile, 'utf8'),\n 'brand new upstream file\\n',\n '残された未追跡ファイルの内容が変化していないこと',\n )\n\n // checkout 前に worktree の現状(npx が書き換えた一意な内容)が git 非依存の cp で\n // 退避されており、Issue #418 と同じデータ喪失が再発していないことを確認する。\n // 出力中の退避先ディレクトリパスを抽出し、そこに一意なマーカーが残っていることを検証する。\n const backupDirMatch = stdout.match(/checkout 前の worktree 内容を (\\S+) へ複製してから/)\n assert.ok(backupDirMatch, `出力に退避先ディレクトリのパスが含まれること: ${stdout}`)\n const backupSkillMd = join(backupDirMatch[1], '.agents', 'skills', SKILL_NAME, 'SKILL.md')\n assert.ok(\n existsSync(backupSkillMd),\n `checkout 前の worktree 内容が退避先 ${backupSkillMd} へ複製されていること`,\n )\n assert.match(\n readFileSync(backupSkillMd, 'utf8'),\n new RegExp(UNIQUE_MARKER),\n 'checkout で上書きされる前の一意な内容(npx が書き換えた SKILL.md)が退避先に保全されていること' +\n '(Issue #418 の非再発確認)',\n )\n } finally {\n rmSync(ctx.repoDir, { recursive: true, force: true })\n rmSync(ctx.binDir, { recursive: true, force: true })\n }\n})\n","content_type":"text/javascript; charset=utf-8","language":"javascript","size":9176,"content_sha256":"4632fd123f15270601b1834179f5f2db164f409a74d27a36fa019785cdc52400"},{"filename":"tests/restore-backup-failure-npx-path.test.mjs","content":"// restore-backup-failure-npx-path.test.mjs — Issue #418 の回帰テスト(npx 失敗パス)。\n//\n// restore-backup-failure.test.mjs は「同期前 check(pre-check)が失敗する経路」だけを\n// 検証していた。この経路は restore_contract_scope || true の直後に exit 1 するだけで\n// revert_in_scope を一切呼ばない。しかし本来の主要な失敗経路(npx 自体が非ゼロ終了する\n// 経路)は、restore_contract_scope || true の直後に必ず revert_in_scope を呼ぶ。\n//\n// restore_contract_scope は未追跡ファイルの退避(mkdir -p / mv)に失敗すると\n// backup_failed=1 とし、worktree には触れず index のみ PRE_SYNC_TREE へ戻す\n// (--staged のみ)。ここまでは worktree の一意な内容を守れている。しかし\n// revert_in_scope が無条件に `git checkout -- \u003c契約パス>` を実行すると、その\n// git checkout はいま index のみ復元済みの内容(PRE_SYNC_TREE)を worktree へ\n// 書き戻してしまい、restore_contract_scope が守ろうとした一意な内容を上書きする\n// (Bugbot 指摘: Index restore enables later overwrite)。\n//\n// このテストは npx 自体が契約範囲内(skills-lock.json)を書き換えたうえで失敗する\n// ケースを再現し、revert_in_scope 到達後も worktree の一意な内容が残ることを検証する。\nimport { test } from 'node:test'\nimport assert from 'node:assert/strict'\nimport { execFileSync } from 'node:child_process'\nimport { mkdtempSync, writeFileSync, chmodSync, mkdirSync, rmSync, readFileSync } from 'node:fs'\nimport { tmpdir } from 'node:os'\nimport { dirname, join } from 'node:path'\nimport { fileURLToPath } from 'node:url'\n\nconst SCRIPT_PATH = join(\n dirname(fileURLToPath(import.meta.url)),\n '..', 'scripts', 'skills-lock-update.sh',\n)\n\nconst SKILL_NAME = 'dummy-skill'\nconst SOURCE_REPO = 'Fandhe-AI/dummy-source'\nconst UNIQUE_MARKER = 'UNIQUE-CONTENT-9d1a3f9e-npx-rewrote-this'\n\nfunction sh(cmd, cwd, env) {\n return execFileSync('bash', ['-c', cmd], { cwd, env, encoding: 'utf8' })\n}\n\nfunction setupRepo() {\n const repoDir = mkdtempSync(join(tmpdir(), 'sync-skills-lock-restore-backup-npx-'))\n const binDir = mkdtempSync(join(tmpdir(), 'sync-skills-lock-restore-backup-npx-bin-'))\n\n writeFileSync(join(binDir, 'gh'), '#!/usr/bin/env bash\\nexit 0\\n')\n chmodSync(join(binDir, 'gh'), 0o755)\n\n // npx: 契約範囲内(skills-lock.json)を git rm --cached で未追跡化・一意な内容へ\n // 書き換えたうえで非ゼロ終了する(部分書き込み後の失敗を再現)。checker は無関係\n // (check モードは exit 0 のみ)であり、npx 自体がこの状態を作る点が\n // restore-backup-failure.test.mjs(checker が作る)との違い。\n writeFileSync(\n join(binDir, 'npx'),\n [\n '#!/usr/bin/env bash',\n 'git rm --cached -q -- skills-lock.json',\n `printf '%s\\\\n' \"${UNIQUE_MARKER}\" > skills-lock.json`,\n 'echo \"stub npx: forced failure after partial write\" >&2',\n 'exit 1',\n '',\n ].join('\\n'),\n )\n chmodSync(join(binDir, 'npx'), 0o755)\n\n // mv を常に失敗させるスタブ。restore_contract_scope の未追跡退避ループが使う\n // 唯一の mv 呼び出し箇所を狙い撃ちして失敗させ、退避失敗を再現する。\n writeFileSync(join(binDir, 'mv'), '#!/usr/bin/env bash\\necho \"stub mv: forced failure\" >&2\\nexit 1\\n')\n chmodSync(join(binDir, 'mv'), 0o755)\n\n sh('git init -q', repoDir)\n sh('git config user.email [email protected]', repoDir)\n sh('git config user.name test', repoDir)\n\n mkdirSync(join(repoDir, '.agents', 'skills', SKILL_NAME), { recursive: true })\n writeFileSync(\n join(repoDir, '.agents', 'skills', SKILL_NAME, 'SKILL.md'),\n 'original content\\n',\n )\n writeFileSync(\n join(repoDir, 'skills-lock.json'),\n JSON.stringify(\n {\n skills: {\n [SKILL_NAME]: {\n source: `https://github.com/${SOURCE_REPO}`,\n computedHash: 'sha256:original-hash-value',\n },\n },\n },\n null,\n 2,\n ) + '\\n',\n )\n\n // checker: check モード(無引数)・apply モードいずれも exit 0(何も変更しない)。\n // pre-check を通過させ、npx 実行フェーズまで到達させるための最小実装。\n mkdirSync(join(repoDir, 'scripts'), { recursive: true })\n writeFileSync(\n join(repoDir, 'scripts', 'check-skill-local-patches.sh'),\n ['#!/usr/bin/env bash', 'exit 0', ''].join('\\n'),\n )\n chmodSync(join(repoDir, 'scripts', 'check-skill-local-patches.sh'), 0o755)\n sh('git add -A && git commit -q -m init', repoDir)\n\n const checkerHash = sh(\n 'git hash-object -- scripts/check-skill-local-patches.sh',\n repoDir,\n ).trim()\n\n return { repoDir, binDir, checkerHash }\n}\n\nfunction runScript({ repoDir, binDir, checkerHash }) {\n const env = {\n ...process.env,\n PATH: `${binDir}:${process.env.PATH}`,\n CHECKER_APPROVED_HASH: checkerHash,\n }\n let stdout = ''\n let status = 0\n try {\n stdout = execFileSync('bash', [SCRIPT_PATH, SKILL_NAME, SOURCE_REPO], {\n cwd: repoDir,\n env,\n encoding: 'utf8',\n })\n } catch (err) {\n stdout = (err.stdout || '') + (err.stderr || '')\n status = err.status ?? 1\n }\n return { stdout, status }\n}\n\ntest('npx 失敗パス(revert_in_scope 到達)でも、退避(mv)失敗時は worktree の' +\n 'skills-lock.json が上書きされず一意な内容が残る(Bugbot 指摘の回帰: ' +\n 'restore_contract_scope の index 復元を revert_in_scope の git checkout が' +\n '上書きしないこと)', () => {\n const ctx = setupRepo()\n try {\n const { stdout, status } = runScript(ctx)\n\n assert.notEqual(status, 0, 'スクリプトは非ゼロ終了する(fail-closed)')\n\n const worktreeContent = readFileSync(join(ctx.repoDir, 'skills-lock.json'), 'utf8')\n assert.match(\n worktreeContent,\n new RegExp(UNIQUE_MARKER),\n '退避に失敗した未追跡ファイルの一意な内容が、revert_in_scope の checkout を' +\n '経由しても worktree にそのまま残っている(index 復元が checkout 経由で' +\n 'worktree へ書き戻されていない)',\n )\n\n assert.match(\n stdout,\n /checkout・git clean は行いません/,\n 'revert_in_scope が退避失敗を検知して checkout をスキップした旨の警告を出す',\n )\n\n assert.doesNotMatch(\n stdout,\n /契約範囲を同期開始前.*へ復元しました/,\n '退避失敗時は無条件の復元成功メッセージを出力しない',\n )\n\n assert.doesNotMatch(\n stdout,\n /スコープ内(skills-lock\\.json \\/ \\.agents\\/skills\\/[^)]+)の変更はリバートしました/,\n '退避失敗で checkout・git clean をスキップしたにもかかわらず「リバートしました」と' +\n '無条件表示しない(Issue #418 再発の回帰。実際には未退避の worktree 変更が残る)',\n )\n } finally {\n rmSync(ctx.repoDir, { recursive: true, force: true })\n rmSync(ctx.binDir, { recursive: true, force: true })\n }\n})\n","content_type":"text/javascript; charset=utf-8","language":"javascript","size":7191,"content_sha256":"9045c555d63be2d0b552a33c1f87ca09090de464c8bf44bf25ae4c7a27c714da"},{"filename":"tests/restore-backup-failure-status-path.test.mjs","content":"// restore-backup-failure-status-path.test.mjs — Issue #418 の回帰テスト(status 取得失敗パス)。\n//\n// restore-backup-failure-npx-path.test.mjs は「npx 自体が非ゼロ終了する経路」の\n// 呼び出し元(NPX_STATUS 分岐)を検証している。しかし revert_in_scope を呼ぶ\n// 失敗経路はもう 1 つある: npx は成功したが、実行後スナップショット取得の\n// `git -c status.renames=false status --porcelain -z -uall` が失敗する経路\n// (SNAP_AFTER 分岐)。この経路も restore_contract_scope || true → revert_in_scope\n// の直後に「スコープ内…の変更はリバートしました」を表示しており、退避\n// (mkdir -p / mv)失敗で revert_in_scope が checkout・git clean を保全のため\n// スキップした場合(CONTRACT_UNTRACKED_BACKUP_FAILED=1)でも無条件に完了表示して\n// いた(codex P1 指摘)。実際には未退避の worktree 変更が残っているため、利用者が\n// 復旧不要と誤認する。\n//\n// このテストは以下のスタブで status 取得失敗パスを再現する:\n// - npx: 契約範囲内(skills-lock.json)を git rm --cached で未追跡化・一意な内容へ\n// 書き換えたうえで marker ファイルを作成して exit 0(成功)する\n// - git: marker ファイルが存在する間、引数に `status` サブコマンドを含む呼び出し\n// のみ失敗させる(marker は npx が最後に作るため、実行前スナップショット等の\n// 事前 status 呼び出しには影響しない)。それ以外は実 git へ委譲する\n// - mv: 常に失敗(restore_contract_scope の未追跡退避を失敗させる)\n//\n// 検証内容: 保全スキップの警告が表示されること・「リバートしました」の完了表示が\n// 出ないこと・未退避の一意な内容が worktree に残ること。\nimport { test } from 'node:test'\nimport assert from 'node:assert/strict'\nimport { execFileSync } from 'node:child_process'\nimport { mkdtempSync, writeFileSync, chmodSync, mkdirSync, rmSync, readFileSync } from 'node:fs'\nimport { tmpdir } from 'node:os'\nimport { dirname, join } from 'node:path'\nimport { fileURLToPath } from 'node:url'\n\nconst SCRIPT_PATH = join(\n dirname(fileURLToPath(import.meta.url)),\n '..', 'scripts', 'skills-lock-update.sh',\n)\n\nconst SKILL_NAME = 'dummy-skill'\nconst SOURCE_REPO = 'Fandhe-AI/dummy-source'\nconst UNIQUE_MARKER = 'UNIQUE-CONTENT-4c7b21aa-npx-rewrote-this'\n\nfunction sh(cmd, cwd, env) {\n return execFileSync('bash', ['-c', cmd], { cwd, env, encoding: 'utf8' })\n}\n\nfunction setupRepo() {\n const repoDir = mkdtempSync(join(tmpdir(), 'sync-skills-lock-restore-backup-status-'))\n const binDir = mkdtempSync(join(tmpdir(), 'sync-skills-lock-restore-backup-status-bin-'))\n // npx 成功「後」からのみ git status を失敗させるための状態ファイル。\n // 実行前スナップショット(SNAP_BEFORE)等の事前 status 呼び出しは通す必要がある\n const markerPath = join(binDir, 'npx-completed.marker')\n // 生成時点の PATH(binDir 追加前)で実体の git を解決してラッパーへ埋め込む。\n // ラッパー内で PATH 検索すると自分自身へ再帰するため\n const realGit = sh('command -v git', tmpdir(), process.env).trim()\n\n writeFileSync(join(binDir, 'gh'), '#!/usr/bin/env bash\\nexit 0\\n')\n chmodSync(join(binDir, 'gh'), 0o755)\n\n // npx: 契約範囲内を書き換えたうえで marker を作成して成功終了する。\n // 「npx は成功したが直後の git status が失敗する」を再現するため exit 0 が要点\n // (restore-backup-failure-npx-path.test.mjs の exit 1 との違い)。\n writeFileSync(\n join(binDir, 'npx'),\n [\n '#!/usr/bin/env bash',\n 'git rm --cached -q -- skills-lock.json',\n `printf '%s\\\\n' \"${UNIQUE_MARKER}\" > skills-lock.json`,\n `: > \"${markerPath}\"`,\n 'echo \"stub npx: success with partial write\"',\n 'exit 0',\n '',\n ].join('\\n'),\n )\n chmodSync(join(binDir, 'npx'), 0o755)\n\n // git ラッパー: marker 存在時のみ `status` サブコマンドを失敗させる。\n // `-c status.renames=false` のようなオプション値は引数完全一致(== \"status\")に\n // 該当しないため誤爆しない。それ以外はすべて実体へ委譲する\n writeFileSync(\n join(binDir, 'git'),\n [\n '#!/usr/bin/env bash',\n `if [[ -e \"${markerPath}\" ]]; then`,\n ' for arg in \"$@\"; do',\n ' if [[ \"${arg}\" == \"status\" ]]; then',\n ' echo \"stub git: forced status failure after npx\" >&2',\n ' exit 1',\n ' fi',\n ' done',\n 'fi',\n `exec \"${realGit}\" \"$@\"`,\n '',\n ].join('\\n'),\n )\n chmodSync(join(binDir, 'git'), 0o755)\n\n // mv を常に失敗させ、restore_contract_scope の未追跡退避を失敗させる\n // (CONTRACT_UNTRACKED_BACKUP_FAILED=1 → revert_in_scope の保全スキップへ到達)\n writeFileSync(join(binDir, 'mv'), '#!/usr/bin/env bash\\necho \"stub mv: forced failure\" >&2\\nexit 1\\n')\n chmodSync(join(binDir, 'mv'), 0o755)\n\n sh('git init -q', repoDir)\n sh('git config user.email [email protected]', repoDir)\n sh('git config user.name test', repoDir)\n\n mkdirSync(join(repoDir, '.agents', 'skills', SKILL_NAME), { recursive: true })\n writeFileSync(\n join(repoDir, '.agents', 'skills', SKILL_NAME, 'SKILL.md'),\n 'original content\\n',\n )\n writeFileSync(\n join(repoDir, 'skills-lock.json'),\n JSON.stringify(\n {\n skills: {\n [SKILL_NAME]: {\n source: `https://github.com/${SOURCE_REPO}`,\n computedHash: 'sha256:original-hash-value',\n },\n },\n },\n null,\n 2,\n ) + '\\n',\n )\n\n // checker: check モード(無引数)・apply モードいずれも exit 0(何も変更しない)。\n // pre-check を通過させ、npx 実行フェーズまで到達させるための最小実装\n mkdirSync(join(repoDir, 'scripts'), { recursive: true })\n writeFileSync(\n join(repoDir, 'scripts', 'check-skill-local-patches.sh'),\n ['#!/usr/bin/env bash', 'exit 0', ''].join('\\n'),\n )\n chmodSync(join(repoDir, 'scripts', 'check-skill-local-patches.sh'), 0o755)\n sh('git add -A && git commit -q -m init', repoDir)\n\n const checkerHash = sh(\n 'git hash-object -- scripts/check-skill-local-patches.sh',\n repoDir,\n ).trim()\n\n return { repoDir, binDir, checkerHash }\n}\n\nfunction runScript({ repoDir, binDir, checkerHash }) {\n const env = {\n ...process.env,\n PATH: `${binDir}:${process.env.PATH}`,\n CHECKER_APPROVED_HASH: checkerHash,\n }\n let stdout = ''\n let status = 0\n try {\n stdout = execFileSync('bash', [SCRIPT_PATH, SKILL_NAME, SOURCE_REPO], {\n cwd: repoDir,\n env,\n encoding: 'utf8',\n })\n } catch (err) {\n stdout = (err.stdout || '') + (err.stderr || '')\n status = err.status ?? 1\n }\n return { stdout, status }\n}\n\ntest('status 取得失敗パス(revert_in_scope 到達)でも、退避(mv)失敗時は' +\n '「リバートしました」と無条件表示せず、未退避の worktree 内容が残る' +\n '(codex P1 指摘の回帰: SNAP_AFTER 分岐の無条件完了表示)', () => {\n const ctx = setupRepo()\n try {\n const { stdout, status } = runScript(ctx)\n\n assert.notEqual(status, 0, 'スクリプトは非ゼロ終了する(fail-closed)')\n\n assert.match(\n stdout,\n /npx 実行後の git status 取得に失敗しました/,\n 'npx 成功後の status 取得失敗(SNAP_AFTER 分岐)に到達している' +\n '(npx 失敗分岐ではなくこの経路を検証していることの確認)',\n )\n\n const worktreeContent = readFileSync(join(ctx.repoDir, 'skills-lock.json'), 'utf8')\n assert.match(\n worktreeContent,\n new RegExp(UNIQUE_MARKER),\n '退避に失敗した未追跡ファイルの一意な内容が worktree にそのまま残っている' +\n '(revert_in_scope の checkout で index が worktree へ書き戻されていない)',\n )\n\n assert.match(\n stdout,\n /checkout・git clean は行いません/,\n 'revert_in_scope が退避失敗を検知して checkout をスキップし、' +\n '手動復旧が必要な旨の残留警告を出す',\n )\n\n assert.doesNotMatch(\n stdout,\n /スコープ内(skills-lock\\.json \\/ \\.agents\\/skills\\/[^)]+)の変更はリバートしました/,\n '退避失敗で checkout・git clean をスキップしたにもかかわらず「リバートしました」と' +\n '無条件表示しない(codex P1 指摘の回帰。実際には未退避の worktree 変更が残る)',\n )\n } finally {\n rmSync(ctx.repoDir, { recursive: true, force: true })\n rmSync(ctx.binDir, { recursive: true, force: true })\n }\n})\n","content_type":"text/javascript; charset=utf-8","language":"javascript","size":8902,"content_sha256":"e3acc18131e6f8af5ccc7fed0c61eb3502c236a74094e7e81ae57df75e94c1fa"},{"filename":"tests/restore-backup-failure.test.mjs","content":"// restore-backup-failure.test.mjs — Issue #418 の回帰テスト。\n//\n// restore_contract_scope は git restore --worktree で契約範囲(当該スキル・\n// skills-lock.json・durable patch)を同期開始前(PRE_SYNC_TREE)へ戻す前に、checker が\n// 未追跡化・新規作成したファイルを一時ディレクトリへ退避する。この退避(mkdir -p /\n// mv)は `restore_contract_scope || true` という呼び出し形(bash の仕様上 `||`\n// 文脈では set -e が抑止される)のため、失敗しても検査されずそのまま git restore\n// --worktree へ進んでいた。checker が git rm --cached で skills-lock.json を\n// 未追跡化して一意な内容へ書き換えた直後に mv が失敗すると、その唯一のコピーが\n// PRE_SYNC_TREE の内容で無音に上書きされ、データが失われる。\n//\n// 修正後は退避失敗を検査し、index のみへ降格して worktree には触れずに非ゼロで\n// 終了する(fail-closed)。このテストは mv を常に失敗させるスタブで退避失敗を強制し、\n// worktree 上の一意な内容が上書きされずに残ることを検証する。\nimport { test } from 'node:test'\nimport assert from 'node:assert/strict'\nimport { execFileSync } from 'node:child_process'\nimport { mkdtempSync, writeFileSync, chmodSync, mkdirSync, rmSync, readFileSync } from 'node:fs'\nimport { tmpdir } from 'node:os'\nimport { dirname, join } from 'node:path'\nimport { fileURLToPath } from 'node:url'\n\nconst SCRIPT_PATH = join(\n dirname(fileURLToPath(import.meta.url)),\n '..', 'scripts', 'skills-lock-update.sh',\n)\n\nconst SKILL_NAME = 'dummy-skill'\nconst SOURCE_REPO = 'Fandhe-AI/dummy-source'\nconst UNIQUE_MARKER = 'UNIQUE-CONTENT-b6f2b6e6-checker-rewrote-this'\n\nfunction sh(cmd, cwd, env) {\n return execFileSync('bash', ['-c', cmd], { cwd, env, encoding: 'utf8' })\n}\n\nfunction setupRepo() {\n const repoDir = mkdtempSync(join(tmpdir(), 'sync-skills-lock-restore-backup-'))\n const binDir = mkdtempSync(join(tmpdir(), 'sync-skills-lock-restore-backup-bin-'))\n\n writeFileSync(join(binDir, 'gh'), '#!/usr/bin/env bash\\nexit 0\\n')\n chmodSync(join(binDir, 'gh'), 0o755)\n\n // npx はこのテストでは呼ばれない想定(pre-check 段階で失敗させるため)だが、\n // 呼ばれた場合に検知できるよう失敗させておく。\n writeFileSync(join(binDir, 'npx'), '#!/usr/bin/env bash\\necho \"npx should not run\" >&2\\nexit 1\\n')\n chmodSync(join(binDir, 'npx'), 0o755)\n\n // mv を常に失敗させるスタブ。restore_contract_scope の未追跡退避ループが使う\n // 唯一の mv 呼び出し箇所を狙い撃ちして失敗させ、退避失敗を再現する。\n writeFileSync(join(binDir, 'mv'), '#!/usr/bin/env bash\\necho \"stub mv: forced failure\" >&2\\nexit 1\\n')\n chmodSync(join(binDir, 'mv'), 0o755)\n\n sh('git init -q', repoDir)\n sh('git config user.email [email protected]', repoDir)\n sh('git config user.name test', repoDir)\n\n mkdirSync(join(repoDir, '.agents', 'skills', SKILL_NAME), { recursive: true })\n writeFileSync(\n join(repoDir, '.agents', 'skills', SKILL_NAME, 'SKILL.md'),\n 'original content\\n',\n )\n writeFileSync(\n join(repoDir, 'skills-lock.json'),\n JSON.stringify(\n {\n skills: {\n [SKILL_NAME]: {\n source: `https://github.com/${SOURCE_REPO}`,\n computedHash: 'sha256:original-hash-value',\n },\n },\n },\n null,\n 2,\n ) + '\\n',\n )\n\n // checker: check モード(無引数)で skills-lock.json を git rm --cached により\n // 未追跡化し、一意な内容へ書き換えたうえで exit 1 する(同期前検証の失敗を再現)。\n // これにより skills-lock.json は「PRE_SYNC_TREE には存在するが未追跡」という\n // データ喪失が起こり得る状態になる。\n mkdirSync(join(repoDir, 'scripts'), { recursive: true })\n writeFileSync(\n join(repoDir, 'scripts', 'check-skill-local-patches.sh'),\n [\n '#!/usr/bin/env bash',\n 'if [[ \"${1:-}\" == \"apply\" ]]; then',\n ' exit 0',\n 'fi',\n 'git rm --cached -q -- skills-lock.json',\n `printf '%s\\\\n' \"${UNIQUE_MARKER}\" > skills-lock.json`,\n 'exit 1',\n '',\n ].join('\\n'),\n )\n chmodSync(join(repoDir, 'scripts', 'check-skill-local-patches.sh'), 0o755)\n sh('git add -A && git commit -q -m init', repoDir)\n\n const checkerHash = sh(\n 'git hash-object -- scripts/check-skill-local-patches.sh',\n repoDir,\n ).trim()\n\n return { repoDir, binDir, checkerHash }\n}\n\nfunction runScript({ repoDir, binDir, checkerHash }) {\n const env = {\n ...process.env,\n PATH: `${binDir}:${process.env.PATH}`,\n CHECKER_APPROVED_HASH: checkerHash,\n }\n let stdout = ''\n let status = 0\n try {\n stdout = execFileSync('bash', [SCRIPT_PATH, SKILL_NAME, SOURCE_REPO], {\n cwd: repoDir,\n env,\n encoding: 'utf8',\n })\n } catch (err) {\n stdout = (err.stdout || '') + (err.stderr || '')\n status = err.status ?? 1\n }\n return { stdout, status }\n}\n\ntest('退避(mv)失敗時、worktree の skills-lock.json は上書きされず一意な内容が' +\n '残る(データ喪失なし。Issue #418: 退避失敗を検査せず git restore --worktree で' +\n '上書きしていた欠陥の回帰)', () => {\n const ctx = setupRepo()\n try {\n const { stdout, status } = runScript(ctx)\n\n assert.notEqual(status, 0, 'スクリプトは非ゼロ終了する(fail-closed)')\n\n const worktreeContent = readFileSync(join(ctx.repoDir, 'skills-lock.json'), 'utf8')\n assert.match(\n worktreeContent,\n new RegExp(UNIQUE_MARKER),\n '退避に失敗した未追跡ファイルの一意な内容が worktree にそのまま残っている' +\n '(PRE_SYNC_TREE の内容で上書きされていない)',\n )\n\n assert.doesNotMatch(\n stdout,\n /契約範囲を同期開始前.*へ復元しました/,\n '退避失敗時は無条件の復元成功メッセージを出力しない',\n )\n } finally {\n rmSync(ctx.repoDir, { recursive: true, force: true })\n rmSync(ctx.binDir, { recursive: true, force: true })\n }\n})\n","content_type":"text/javascript; charset=utf-8","language":"javascript","size":6180,"content_sha256":"4b72b0bbbeced5772c6e4d1a722be594962d7d997a838326d7b696c194771401"},{"filename":"tests/restore-help-order.test.mjs","content":"// restore-help-order.test.mjs — Issue #418 のフォローアップ回帰テスト。\n//\n// restore_help() はユーザー却下時の復元手順を stdout へ印字する案内文だが、従来は\n// `git restore --staged --worktree` を先に案内し、未追跡ファイルの退避は「残る場合は\n// 削除前に確認」という後段の注意書きに留まっていた。この順序で operator が実行すると、\n// checker が git rm --cached で未追跡化・書き換えた skills-lock.json 等の「唯一の\n// コピー」が退避されないまま snapshot の内容で無音に上書きされる — Issue #418 が修正\n// した欠陥そのものが案内文経由で再現する。\n//\n// このテストは restore_help の関数本体をスクリプトから抽出して単体実行し、印字された\n// 案内が SKILL.md の却下フェンスと同じ retreat-first(退避 → 復元 → 検証)の順序で\n// あることを検証する。スクリプト全体を却下分岐まで走らせる必要はなく、案内文の順序\n// 退行だけを軽量に検出する。\nimport { test } from 'node:test'\nimport assert from 'node:assert/strict'\nimport { execFileSync } from 'node:child_process'\nimport { readFileSync } from 'node:fs'\nimport { dirname, join } from 'node:path'\nimport { fileURLToPath } from 'node:url'\n\nconst SCRIPT_PATH = join(\n dirname(fileURLToPath(import.meta.url)),\n '..', 'scripts', 'skills-lock-update.sh',\n)\n\n// スクリプト本文から restore_help() の定義行〜対応する閉じ括弧行までを抽出する。\n// 関数本体は echo のみで構成される前提(定義位置のインデント 2 で閉じる)。\nfunction extractRestoreHelp() {\n const source = readFileSync(SCRIPT_PATH, 'utf8')\n const match = source.match(/^ {2}restore_help\\(\\) \\{\\n[\\s\\S]*?\\n {2}\\}/m)\n assert.ok(match, 'restore_help() の定義がスクリプトから抽出できること')\n return match[0]\n}\n\ntest('restore_help は退避を worktree 復元より先に案内する(retreat-first)', () => {\n const fn = extractRestoreHelp()\n // 案内文が参照する変数を与えて関数を単体実行し、実際の印字内容で順序を判定する\n const out = execFileSync('bash', ['-c', `${fn}\\nrestore_help`], {\n encoding: 'utf8',\n env: { ...process.env, SKILL_NAME: 'dummy-skill', PRE_SYNC_TREE: 'deadbeef' },\n })\n\n const lines = out.split('\\n')\n const indexOfLine = (pattern, label) => {\n const i = lines.findIndex((l) => l.includes(pattern))\n assert.ok(i >= 0, `案内に「${label}」の行が含まれること: ${pattern}`)\n return i\n }\n\n // 退避手順(mktemp -d の退避先作成・未追跡列挙・mv による退避)が最初の\n // git restore --staged --worktree より前に印字されること\n const backupDirLine = indexOfLine('mktemp -d', '退避先ディレクトリ作成')\n const listLine = indexOfLine('git ls-files -z --others', '未追跡ファイル列挙')\n const mvLine = indexOfLine('mv -- ', 'mv による退避')\n const firstRestoreLine = indexOfLine(\n 'git restore --staged --worktree',\n 'index + worktree 復元',\n )\n assert.ok(\n backupDirLine \u003c firstRestoreLine,\n '退避先ディレクトリ作成が worktree 復元より先に印字されること',\n )\n assert.ok(\n listLine \u003c firstRestoreLine,\n '未追跡ファイル列挙が worktree 復元より先に印字されること',\n )\n assert.ok(\n mvLine \u003c firstRestoreLine,\n 'mv による退避が worktree 復元より先に印字されること',\n )\n\n // 退避失敗時に復元へ進まない fail-closed の案内が退避手順に含まれること\n assert.ok(out.includes('fail-closed'), '退避失敗時の fail-closed が案内されること')\n assert.ok(\n out.includes('退避失敗'),\n 'mv 失敗時に中断する分岐(退避失敗)が案内に含まれること',\n )\n\n // 復元後検証(snapshot 基準の diff)が復元より後に案内されること\n const verifyLine = indexOfLine('git diff --cached deadbeef', '復元後検証')\n assert.ok(\n firstRestoreLine \u003c verifyLine,\n '復元後検証が worktree 復元より後に印字されること',\n )\n})\n","content_type":"text/javascript; charset=utf-8","language":"javascript","size":4214,"content_sha256":"56a5ae599a1836753b89dd854f952e84e766f25e17825e2ff551c0297863af0b"},{"filename":"tests/restore-ignored-failure-revert-display.test.mjs","content":"// restore-ignored-failure-revert-display.test.mjs — codex P1 指摘(PR #420・3 巡目)の回帰テスト。\n//\n// revert_in_scope の REVERT_IN_SCOPE_SKIPPED は「checkout / clean / 復元のいずれかを\n// スキップ(完全実行できなかった)した経路を一元的に表す」契約だが、\n// restore_preexisting_ignored が失敗する経路(IGNORED_BACKUP_KEEP=1 と警告出力のみ)で\n// このフラグを立てないと、npx 失敗分岐の呼び出し元が既存 ignored ファイル未復元のまま\n// 「スコープ内(...)の変更はリバートしました」と虚偽表示する。\n//\n// このテストは、実行前から存在した ignored ファイル(.DS_Store)を npx スタブが\n// 上書きしたうえで許可先ディレクトリを読み取り専用化(chmod 555)して非ゼロ終了する\n// ケースを再現する。restore_preexisting_ignored は復元のための os.unlink が EACCES で\n// 失敗して非ゼロ終了し、修正前のコードでは「リバートしました」が表示されてしまう\n// (このテストが RED になる)。修正後は完了を意味する文言を出さず、復元失敗の\n// エラーと手動復旧の案内(一部未復元)だけが出る。\nimport { test } from 'node:test'\nimport assert from 'node:assert/strict'\nimport { execFileSync } from 'node:child_process'\nimport { mkdtempSync, writeFileSync, chmodSync, mkdirSync, rmSync, readFileSync } from 'node:fs'\nimport { tmpdir } from 'node:os'\nimport { dirname, join } from 'node:path'\nimport { fileURLToPath } from 'node:url'\n\nconst SCRIPT_PATH = join(\n dirname(fileURLToPath(import.meta.url)),\n '..', 'scripts', 'skills-lock-update.sh',\n)\n\nconst SKILL_NAME = 'dummy-skill'\nconst SOURCE_REPO = 'Fandhe-AI/dummy-source'\n\nfunction sh(cmd, cwd, env) {\n return execFileSync('bash', ['-c', cmd], { cwd, env, encoding: 'utf8' })\n}\n\nfunction setupRepo() {\n const repoDir = mkdtempSync(join(tmpdir(), 'sync-skills-lock-restore-ignored-fail-'))\n const binDir = mkdtempSync(join(tmpdir(), 'sync-skills-lock-restore-ignored-fail-bin-'))\n\n writeFileSync(join(binDir, 'gh'), '#!/usr/bin/env bash\\nexit 0\\n')\n chmodSync(join(binDir, 'gh'), 0o755)\n\n // npx: 実行前から存在した ignored ファイル(.DS_Store)を上書きしたうえで、\n // 許可先ディレクトリを読み取り専用化して非ゼロ終了する。読み取り専用化により\n // restore_preexisting_ignored の復元(os.unlink → copyfile)が EACCES で失敗し、\n // 「既存 ignored が未復元のまま revert_in_scope が終わる」状態を決定的に再現する。\n writeFileSync(\n join(binDir, 'npx'),\n [\n '#!/usr/bin/env bash',\n `printf '%s\\\\n' \"npx-overwrote-ignored\" > \".agents/skills/${SKILL_NAME}/.DS_Store\"`,\n `chmod 555 \".agents/skills/${SKILL_NAME}\"`,\n 'echo \"stub npx: forced failure after ignored overwrite\" >&2',\n 'exit 1',\n '',\n ].join('\\n'),\n )\n chmodSync(join(binDir, 'npx'), 0o755)\n\n sh('git init -q', repoDir)\n sh('git config user.email [email protected]', repoDir)\n sh('git config user.name test', repoDir)\n\n mkdirSync(join(repoDir, '.agents', 'skills', SKILL_NAME), { recursive: true })\n writeFileSync(\n join(repoDir, '.agents', 'skills', SKILL_NAME, 'SKILL.md'),\n 'original content\\n',\n )\n // .DS_Store を ignored 化し、実行前から存在する既存 ignored ファイルとして置く\n // (restore_preexisting_ignored のバックアップ・比較・復元の対象になる)。\n writeFileSync(join(repoDir, '.gitignore'), '.DS_Store\\n')\n writeFileSync(\n join(repoDir, '.agents', 'skills', SKILL_NAME, '.DS_Store'),\n 'preexisting ignored content\\n',\n )\n writeFileSync(\n join(repoDir, 'skills-lock.json'),\n JSON.stringify(\n {\n skills: {\n [SKILL_NAME]: {\n source: `https://github.com/${SOURCE_REPO}`,\n computedHash: 'sha256:original-hash-value',\n },\n },\n },\n null,\n 2,\n ) + '\\n',\n )\n sh('git add -A && git commit -q -m init', repoDir)\n\n return { repoDir, binDir }\n}\n\nfunction runScript({ repoDir, binDir }) {\n const env = {\n ...process.env,\n PATH: `${binDir}:${process.env.PATH}`,\n }\n let output = ''\n let status = 0\n try {\n output = execFileSync('bash', [SCRIPT_PATH, SKILL_NAME, SOURCE_REPO], {\n cwd: repoDir,\n env,\n encoding: 'utf8',\n })\n } catch (err) {\n output = (err.stdout || '') + (err.stderr || '')\n status = err.status ?? 1\n }\n return { output, status }\n}\n\ntest('npx 失敗パスで既存 ignored ファイルの復元が失敗した場合、' +\n '「リバートしました」と虚偽表示せず、復元失敗のエラーと一部未復元の案内を出す' +\n '(codex P1 指摘・PR #420 の回帰: REVERT_IN_SCOPE_SKIPPED を復元失敗経路でも立てる)', () => {\n const ctx = setupRepo()\n try {\n const { output, status } = runScript(ctx)\n\n assert.notEqual(status, 0, 'スクリプトは非ゼロ終了する(fail-closed)')\n\n assert.match(\n output,\n /実行前から存在した ignored ファイルの復元に失敗しました/,\n 'restore_preexisting_ignored の失敗がエラーとして報告される(テスト前提の成立確認)',\n )\n\n assert.doesNotMatch(\n output,\n /スコープ内(skills-lock\\.json \\/ \\.agents\\/skills\\/[^)]+)の変更はリバートしました/,\n '既存 ignored が未復元のまま「変更はリバートしました」と完了を意味する表示をしない' +\n '(codex P1 指摘の回帰。実際にはバックアップからの復元が失敗している)',\n )\n\n assert.match(\n output,\n /一部未復元で、手動復旧が必要です/,\n '復元失敗時は REVERT_IN_SCOPE_SKIPPED 経由で「一部未復元・手動復旧」の案内を出す',\n )\n\n // 復元が実際に失敗していること(上書きされた内容が残置)を実体でも確認する。\n const ignoredContent = readFileSync(\n join(ctx.repoDir, '.agents', 'skills', SKILL_NAME, '.DS_Store'),\n 'utf8',\n )\n assert.match(\n ignoredContent,\n /npx-overwrote-ignored/,\n '既存 ignored ファイルは未復元のまま(表示と実体が一致していることの確認)',\n )\n // 復元失敗時はスクリプトが保全した ignored バックアップ dir(mktemp -d)が\n // OS の tmpdir に残る。テストでは手動復旧の素材は不要のため、案内メッセージから\n // パスを拾って掃除する(実行ごとの残骸蓄積の防止。テスト対象の検証とは無関係)。\n const backupDirMatch = output.match(/バックアップは (\\S+) に相対パス構造で残っています/)\n if (backupDirMatch && backupDirMatch[1].startsWith(tmpdir().replace(/\\/$/, ''))) {\n rmSync(backupDirMatch[1], { recursive: true, force: true })\n }\n } finally {\n // npx スタブが読み取り専用化した許可先を戻してから削除する。\n chmodSync(join(ctx.repoDir, '.agents', 'skills', SKILL_NAME), 0o755)\n rmSync(ctx.repoDir, { recursive: true, force: true })\n rmSync(ctx.binDir, { recursive: true, force: true })\n }\n})\n","content_type":"text/javascript; charset=utf-8","language":"javascript","size":7319,"content_sha256":"0770ddf97b6493456ba945dd8c032d47677ed5d07a9100b6ec793c5900ec358f"},{"filename":"tests/scope-compromised-revert-display.test.mjs","content":"// scope-compromised-revert-display.test.mjs — PR #420 codex P1 指摘の回帰テスト。\n//\n// restore-backup-failure-npx-path.test.mjs は CONTRACT_UNTRACKED_BACKUP_FAILED=1 の\n// スキップ経路のみを検証していた。しかし revert_in_scope は SCOPE_PATH_COMPROMISED != 0\n// の場合にも .agents/skills/${SKILL_NAME}/ への checkout・git clean を行わず return する。\n// この経路では CONTRACT_UNTRACKED_BACKUP_FAILED が 0 のままのため、npx 失敗分岐の\n// 完了表示が個別の原因フラグ(CONTRACT_UNTRACKED_BACKUP_FAILED)だけを見ていると、\n// worktree に変更を残した状態で「スコープ内…の変更はリバートしました」と表示され、\n// 復旧不要との誤認を招く(前回修正の変種)。\n//\n// 修正後は revert_in_scope が「復元を完全実行したか」を専用フラグ\n// REVERT_IN_SCOPE_SKIPPED で一元通知し、完了表示はそのフラグのみで分岐する。\n// このテストは npx スタブが許可先配下へ symlink を作成するケース(実行後 symlink\n// 走査が SCOPE_PATH_COMPROMISED=1 に倒す)を再現し、「リバートしました」が表示されず\n// 残留・手動復旧の警告のみが出ることを検証する。\nimport { test } from 'node:test'\nimport assert from 'node:assert/strict'\nimport { execFileSync } from 'node:child_process'\nimport { mkdtempSync, writeFileSync, chmodSync, mkdirSync, rmSync } from 'node:fs'\nimport { tmpdir } from 'node:os'\nimport { dirname, join } from 'node:path'\nimport { fileURLToPath } from 'node:url'\n\nconst SCRIPT_PATH = join(\n dirname(fileURLToPath(import.meta.url)),\n '..', 'scripts', 'skills-lock-update.sh',\n)\n\nconst SKILL_NAME = 'dummy-skill'\nconst SOURCE_REPO = 'Fandhe-AI/dummy-source'\n\nfunction sh(cmd, cwd, env) {\n return execFileSync('bash', ['-c', cmd], { cwd, env, encoding: 'utf8' })\n}\n\nfunction setupRepo() {\n const repoDir = mkdtempSync(join(tmpdir(), 'sync-skills-lock-scope-compromised-'))\n const binDir = mkdtempSync(join(tmpdir(), 'sync-skills-lock-scope-compromised-bin-'))\n\n writeFileSync(join(binDir, 'gh'), '#!/usr/bin/env bash\\nexit 0\\n')\n chmodSync(join(binDir, 'gh'), 0o755)\n\n // npx: 許可先配下へ外向き symlink を作成して exit 0 する。実行後の配下 symlink\n // 走査(TOCTOU 対策)が検出して SCOPE_PATH_COMPROMISED=1・NPX_STATUS=1 に倒し、\n // npx 失敗分岐 → revert_in_scope(checkout・clean スキップ)へ合流する。\n // CONTRACT_UNTRACKED_BACKUP_FAILED は 0 のまま(mv は成功する)という点が\n // restore-backup-failure-npx-path.test.mjs との違いであり、本指摘の核心。\n writeFileSync(\n join(binDir, 'npx'),\n [\n '#!/usr/bin/env bash',\n `ln -s /nonexistent-outside-target \".agents/skills/${SKILL_NAME}/evil-link\"`,\n 'exit 0',\n '',\n ].join('\\n'),\n )\n chmodSync(join(binDir, 'npx'), 0o755)\n\n sh('git init -q', repoDir)\n sh('git config user.email [email protected]', repoDir)\n sh('git config user.name test', repoDir)\n\n mkdirSync(join(repoDir, '.agents', 'skills', SKILL_NAME), { recursive: true })\n writeFileSync(\n join(repoDir, '.agents', 'skills', SKILL_NAME, 'SKILL.md'),\n 'original content\\n',\n )\n writeFileSync(\n join(repoDir, 'skills-lock.json'),\n JSON.stringify(\n {\n skills: {\n [SKILL_NAME]: {\n source: `https://github.com/${SOURCE_REPO}`,\n computedHash: 'sha256:original-hash-value',\n },\n },\n },\n null,\n 2,\n ) + '\\n',\n )\n\n // checker: check モード・apply モードいずれも exit 0(何も変更しない)。\n // pre-check を通過させ、npx 実行フェーズまで到達させるための最小実装。\n mkdirSync(join(repoDir, 'scripts'), { recursive: true })\n writeFileSync(\n join(repoDir, 'scripts', 'check-skill-local-patches.sh'),\n ['#!/usr/bin/env bash', 'exit 0', ''].join('\\n'),\n )\n chmodSync(join(repoDir, 'scripts', 'check-skill-local-patches.sh'), 0o755)\n sh('git add -A && git commit -q -m init', repoDir)\n\n const checkerHash = sh(\n 'git hash-object -- scripts/check-skill-local-patches.sh',\n repoDir,\n ).trim()\n\n return { repoDir, binDir, checkerHash }\n}\n\nfunction runScript({ repoDir, binDir, checkerHash }) {\n const env = {\n ...process.env,\n PATH: `${binDir}:${process.env.PATH}`,\n CHECKER_APPROVED_HASH: checkerHash,\n }\n let stdout = ''\n let status = 0\n try {\n stdout = execFileSync('bash', [SCRIPT_PATH, SKILL_NAME, SOURCE_REPO], {\n cwd: repoDir,\n env,\n encoding: 'utf8',\n })\n } catch (err) {\n stdout = (err.stdout || '') + (err.stderr || '')\n status = err.status ?? 1\n }\n return { stdout, status }\n}\n\ntest('SCOPE_PATH_COMPROMISED 経路(npx が許可先配下へ symlink を作成)では、' +\n '完了表示に「リバートしました」を出さず残留・手動復旧の警告のみを出す' +\n '(codex P1 指摘の回帰: 個別の原因フラグではなく REVERT_IN_SCOPE_SKIPPED で分岐)', () => {\n const ctx = setupRepo()\n try {\n const { stdout, status } = runScript(ctx)\n\n assert.notEqual(status, 0, 'スクリプトは非ゼロ終了する(fail-closed)')\n\n assert.match(\n stdout,\n /symlink/,\n '許可先配下への symlink 作成が検出・報告される',\n )\n\n assert.match(\n stdout,\n /checkout・git clean・ignored 削除・既存 ignored の復元は行いません/,\n 'revert_in_scope が SCOPE_PATH_COMPROMISED を検知して削除系操作を' +\n 'スキップした旨の警告を出す',\n )\n\n assert.doesNotMatch(\n stdout,\n /スコープ内(skills-lock\\.json \\/ \\.agents\\/skills\\/[^)]+)の変更はリバートしました/,\n 'SCOPE_PATH_COMPROMISED で checkout・git clean をスキップしたにもかかわらず' +\n '「リバートしました」と表示しない(CONTRACT_UNTRACKED_BACKUP_FAILED が 0 の' +\n 'ままでも完了表示が抑止されること = 前回修正の変種の回帰)',\n )\n\n assert.match(\n stdout,\n /一部未復元/,\n 'スキップ時は完了表示の代わりに一部未復元・手動復旧が必要である旨を案内する',\n )\n } finally {\n rmSync(ctx.repoDir, { recursive: true, force: true })\n rmSync(ctx.binDir, { recursive: true, force: true })\n }\n})\n","content_type":"text/javascript; charset=utf-8","language":"javascript","size":6444,"content_sha256":"c4e2c08cd3268af25be5a272b9936087bcf594bceb2d0b76f06174a6b1197015"},{"filename":"tests/scope-guard.test.mjs","content":"// scope-guard.test.mjs — Issue #410 の回帰テスト。\n//\n// `npx skills add` は元来エージェント/パス制限なしに実行され、検出した各エージェント\n// 向けツリー(`.claude/skills/` 等)へも書き込み得た。しかし clean ガード・プレビュー・\n// リバート(Step 6 相当)・承認 `git add`(Step 7 相当)はいずれも `skills-lock.json` と\n// `.agents/skills/\u003cname>/` のみを対象としており、スコープ外への書き込みが発生すると\n// (1) WIP 上書き、(2) レビュー(プレビュー)迂回、(3) 「clean と報告した後の dirty 残留」\n// が起き得た。\n//\n// scripts/skills-lock-update.sh は 2 層で塞ぐ:\n// 1. 書き込みスコープの制限(一次防御): 固定版呼び出しへ `--agent universal` を追加\n// 2. スコープ外書き込みの fail-closed 検出(多層防御): npx 実行前後の\n// `git status --porcelain -z -uall` スナップショット差分でスコープ外変化を検出し、\n// 検出時はスコープ内をリバートして停止・案内する\n//\n// npx の実クローン処理は行わず、preview-untracked.test.mjs と同じ方式で\n// PATH 先頭に置いた npx / gh のスタブへ差し替える。シナリオは TEST_NPX_SCENARIO で\n// 切り替える:\n// - 'out-of-scope-write' : スコープ内変更に加えて .claude/skills/dummy-skill/SKILL.md\n// (他エージェントツリー相当)と .cursor/rules/x.md\n// (任意の別ツリー)を作成する\n// - 'invalid-agents' : npx が「Invalid agents: ...」を出力して exit 0 する\n// (CLI バージョン更新で universal が無効化された場合の\n// silent no-op を再現する)\n// - 'content-overwrite-dirty' : PR #412 レビュー指摘(P1)の回帰。実行前から M\n// (追跡・変更済み)だったスコープ外ファイルの内容\n// だけを、パス・ステータス文字を変えずに上書きする\n// (porcelain の記録だけでは検出できず、内容ハッシュ\n// 比較でのみ検出できるケース)\n// - 'npx-failure-out-of-scope' : PR #412 レビュー指摘(P1)の回帰。スコープ外へ\n// 部分書き込みしたのち npx 自体が非ゼロ終了する\n// (失敗経路でも事後のスコープ外検査に到達する\n// ことを検証する)\n// - 'mode-change-dirty' : PR #412 レビュー指摘(第3巡・P1)の回帰。実行前\n// から M だったスコープ外ファイルのパーミッション\n// のみを変更し内容は変えない(porcelain の記録・\n// 内容ハッシュのいずれも前後不変に見え、モードを\n// 含む状態シグネチャ比較でのみ検出できるケース)\n// - 'tee-failure' : PR #412 CI 失敗指摘(P1)の回帰。npx 自体は\n// exit 0 で成功するが、その出力を保存する tee が\n// 非ゼロ終了する(ディスク容量不足等)。\n// NPX_OUTPUT_FILE が不完全なまま「Invalid agents」\n// no-op 検知をすり抜けて誤って成功扱いになって\n// いないことを検証する。\n// - 'dir-chmod-out-of-scope' : PR #412 レビュー指摘(P1)の回帰。スコープ外\n// ディレクトリの chmod のみを行う。git は\n// ディレクトリの mode を追跡しないため porcelain\n// の前後どちらにも現れず、リポジトリ全体の状態\n// シグネチャ(repo_state_signature)比較でのみ\n// 検出できるケース\n// - 'dir-symlink-retarget' : PR #412 レビュー指摘(P1)の回帰。実行前から\n// 未追跡(??)だったディレクトリ向け symlink の\n// リンク先だけを付け替える(porcelain レコードは\n// 前後とも同一の「?? パス」のままで、シグネチャ\n// 比較でのみ検出できるケース)\n// - 'nested-content-overwrite' : PR #412 レビュー指摘(P1)の回帰。実行前から\n// 未追跡だったディレクトリ配下のファイル内容だけを\n// 上書きする(porcelain レコードは前後とも同一の\n// 「?? パス」のままで、配下ファイルの内容ハッシュを\n// 含む全体シグネチャ比較でのみ検出できるケース)\n// - 'git-metadata-write' : PR #412 レビュー指摘(第4巡・P1)の回帰。\n// .git/hooks/ へのフックファイル追加と .git/config\n// の書き換えを行う。`.git` を丸ごと prune すると\n// 永続 Git メタデータの改変(フック仕込み・設定\n// 改変)が署名から漏れるため、変動し得る領域のみの\n// 限定 prune でこれを検出できることを検証する\n// - 'agents-dir-chmod' : PR #412 レビュー指摘(第4巡・P1)の回帰。実行前\n// から存在する .agents ディレクトリ自身の chmod\n// のみを行う。.agents 自身を無条件 omit すると\n// 検出できないため、「実行前に存在した場合は署名\n// 対象」の条件付き omit でこれを検出できることを\n// 検証する\n// - 'invalid-agents-partial-write' : PR #412 レビュー指摘(第5巡・P1)の回帰。\n// スコープ内 + スコープ外へ部分書き込みした後に\n// 「Invalid agents」文言を出して exit 0 する CLI 版を\n// 再現する(no-op 文言への依存は部分書き込みに対して\n// fail-open。no-op 検知分岐が直接 exit せず共通失敗\n// 経路へ合流し、事後のスコープ外検出・スコープ内\n// リバートへ必ず到達することを検証する)\n// - 'post-status-failure' : PR #412 レビュー指摘(第5巡・P1)の回帰。npx は\n// スコープ内書き込みに成功するが、実行後の\n// `git status --porcelain -z -uall`(2 回目の -uall\n// 呼び出し)だけを git スタブで失敗させる。この経路\n// でもスコープ内リバートが走り非ゼロ終了することを\n// 検証する\n// - 'symlink-scope-path' : PR #412 レビュー指摘(第6巡・P0)の回帰。実行前から\n// .agents/skills/\u003cname> がリポジトリ外を指す symlink に\n// なっているレイアウトを再現する(スコープ外検査は\n// 許可先をパス文字列で走査除外するため、symlink 越しの\n// リポジトリ外書き込みはどの検査にも現れない。npx を\n// 実行する前に lstat 検証で拒否することを検証する)\n// - 'git-refs-write' : PR #412 レビュー指摘(第6巡・P0)の回帰。.git/refs\n// 配下へ ref ファイルを追加する(.git の参照・\n// オブジェクト領域を prune していた旧実装では porcelain・\n// シグネチャのどちらにも現れず検出不能だった。prune を\n// index・lock のみへ縮小したことで検出できることを検証)\n// - 'ignored-residue-on-failure': Issue #413 残項目 + PR #412 Bugbot Medium の回帰。\n// npx がスコープ内へ .gitignore 対象ファイルを書いた後に\n// 非ゼロ終了する。異常終了時に npx が新規作成した\n// ignored ファイルは残置されない一方、実行前から存在\n// した ignored ファイル(.DS_Store 等)は実行前\n// インベントリとの突き合わせで保全されることを検証\n// - 'midrun-symlink-swap' : PR #412 レビュー指摘(第7巡・P0)の回帰。事前の\n// lstat 検査を通過した後、npx が実行中に許可先\n// ディレクトリごとリポジトリ外向き symlink へ置換して\n// リンク先へ書き込む(TOCTOU。実行後再検証 +\n// 許可先要素自身の署名(prune-under)で検出し、\n// git clean がリンク先を削除しないことを検証する)\n// - 'first-install-symlink-agents': PR #412 レビュー指摘(第7巡・P0)の回帰。初回\n// インストール(実行前に .agents 不存在 = 親要素が\n// omit / 許可先が prune でシグネチャに現れない)で、\n// npx が .agents 自体をリポジトリ外向き symlink として\n// 作成する。実行後再検証が fail-closed で拒否し、\n// リンク先への削除を行わないことを検証する\n// - 'preexisting-symlink-in-scope': PR #412 レビュー指摘(第12巡・P0-1)の回帰。許可先\n// ディレクトリ配下に実行前からリポジトリ外向き symlink が\n// 存在するレイアウトを再現する(許可先配下は prune-under で\n// 署名から除外されるため、npx がリンクを辿った外部書き込みは\n// 前後比較に現れない。npx 実行前の find -type l 走査だけが\n// 拒否できることを検証する)\n// - 'ignored-overwrite-success' : PR #412 レビュー指摘(第12巡・P0-2)の回帰。npx が\n// 実行前から存在した ignored ファイル(.DS_Store)を\n// 上書きして正常終了する。成功経路でバックアップとの\n// 比較が変更を検出し、元の内容へ復元されたうえで同期\n// 自体は完走することを検証する\n// - 'ignored-overwrite-on-failure': PR #412 レビュー指摘(第12巡・P0-2)の回帰。npx が\n// 既存 ignored ファイルを上書きした後に非ゼロ終了する。\n// 失敗経路(revert_in_scope)でもバックアップから復元\n// されることを検証する\n// - 'ignored-delete-success' : PR #412 レビュー指摘(第12巡・P0-2)の回帰。npx が\n// 既存 ignored ファイルを削除して正常終了する。削除も\n// 「変化」としてバックアップから書き戻されることを検証する\n// - 'config-lock-residue' : PR #412 codex P1 指摘の回帰。npx が永続 lock\n// (.git/config.lock)を残して正常終了する。\n// `.git/*.lock` のワイルドカード prune では署名から\n// 漏れていたケースで、prune を .git/index.lock の\n// 完全一致へ限定したことで検出できることを検証する\n// - 'skip-worktree-flip' : PR #412 codex P1 指摘の回帰。npx がスコープ外の\n// tracked ファイルへ `git update-index --skip-worktree`\n// を付ける(.git/index のみが変わり、ツリー署名・\n// porcelain のどちらにも現れない。index の論理状態\n// 比較(index_state_signature)でのみ検出できる)\n// - 'midrun-nested-symlink' : PR #412 Bugbot High 指摘の回帰。事前走査(0 件)を\n// 通過した後、npx が実行中に許可先配下へリポジトリ外\n// 向き symlink を作成してリンク先へ書き込む(TOCTOU。\n// 実行後の find -type l 再走査が検出し、許可先配下への\n// 削除系操作をスキップして停止することを検証する)\n// - 'restore-parent-symlink-swap': PR #412 Bugbot Medium 指摘の回帰。既存 ignored\n// ファイルの中間ディレクトリを npx がリポジトリ外向き\n// symlink へ置換する。復元の makedirs がリンク先\n// (リポジトリ外)へディレクトリを作らないことを検証する\n// - 'restore-parent-dir-deleted': 非退行。npx が既存 ignored ファイルを中間ディレクトリ\n// ごと削除する。復元が中間ディレクトリを 1 階層ずつ\n// 検証しながら再作成して書き戻すことを検証する\n// (make_parent_dirs の正常系)\n// - 'first-install' : 非退行。実行前に .agents ツリー自体が存在しない\n// 初回インストールで、npx が .agents/skills/\u003cname>\n// を新規作成しても誤検知せず完走することを検証する\n// (.agents / .agents/skills は実行前に不存在だった\n// 場合のみ omit される)\n// - 'linked-worktree' : PR #412 codex P0 指摘の回帰。git worktree add で\n// 作った linked worktree 内でスクリプトを実行する\n// (実 Git ディレクトリが状態署名の対象外になり、\n// npx による共有リポジトリの参照改変を検出できない\n// ため、npx を実行する前に worktree 検証が拒否する\n// ことを検証する。npx スタブは呼ばれない)\n// - 'separate-git-dir' : PR #412 Bugbot High 指摘の回帰。.git を作業ツリー外の\n// 実ディレクトリへ移し gitfile(gitdir: 参照)に\n// 差し替えた構成(git clone --separate-git-dir 相当)で\n// 実行する(git-dir と common-dir は一致するため\n// linked worktree 検証は通過するが、実 Git ディレクトリが\n// 署名対象外になるため、npx を実行する前に\n// 「作業ツリー直下の .git 実体ディレクトリ」検証が\n// 拒否することを検証する。npx スタブは呼ばれない)\n// - 'git-dir-symlink' : PR #412 Bugbot High 指摘の回帰。.git を作業ツリー外の\n// 実ディレクトリへの symlink に置換した構成で実行する\n// ('separate-git-dir' と同じ検出不能構成。lstat 検証が\n// npx を実行する前に拒否することを検証する。\n// npx スタブは呼ばれない)\n// - 'lockfile-symlink-preexisting': PR #412 codex P0 指摘の回帰。skills-lock.json が\n// 実行前からリポジトリ外向き symlink になっている\n// (symlink としてコミット済みのため clean ガードは\n// 通過する)レイアウトを再現する。skills-lock.json は\n// 署名の prune・porcelain フィルタの双方から除外される\n// ため、npx 実行前の lstat 検証だけが拒否できることを\n// 検証する(npx スタブは呼ばれない)\n// - 'midrun-lockfile-symlink-swap': PR #412 codex P0 指摘の回帰。事前の lstat 検査を\n// 通過した後、npx が実行中に skills-lock.json を\n// リポジトリ外向き symlink へ置換する(TOCTOU。実行後の\n// 再検証(verify_lock_file_after_run)が検出し、復元が\n// リンク先へ書き込まず symlink を除去して regular file を\n// 書き戻すことを検証する)\n// git / jq / python3 は実物を使用する(ネットワーク不使用)。\nimport { test } from 'node:test'\nimport assert from 'node:assert/strict'\nimport { execFileSync } from 'node:child_process'\nimport { mkdtempSync, writeFileSync, chmodSync, mkdirSync, rmSync, existsSync, readFileSync, statSync, lstatSync, symlinkSync, readlinkSync, renameSync } from 'node:fs'\nimport { tmpdir } from 'node:os'\nimport { dirname, join } from 'node:path'\nimport { fileURLToPath } from 'node:url'\n\nconst SCRIPT_PATH = join(\n dirname(fileURLToPath(import.meta.url)),\n '..', 'scripts', 'skills-lock-update.sh',\n)\n\nconst SKILL_NAME = 'dummy-skill'\nconst SOURCE_REPO = 'Fandhe-AI/dummy-source'\n// 'content-overwrite-dirty' / 'npx-failure-out-of-scope' シナリオで使う、\n// 実行前から追跡・変更済み(M)にしておくスコープ外ファイルのパス。\nconst OUT_OF_SCOPE_FILE = '.claude/skills/other-skill/NOTES.md'\n\nfunction sh(cmd, cwd, env) {\n return execFileSync('bash', ['-c', cmd], { cwd, env, encoding: 'utf8' })\n}\n\n// npx / gh スタブと、シナリオを注入した git repo 一式を用意する。\n// ARGV_LOG_FILE には npx スタブが実際に受け取った引数を1行1トークンで記録し、\n// --agent universal が実行経路に到達していることを静的テストとは別に検証する。\nfunction setupRepo(scenario) {\n const repoDir = mkdtempSync(join(tmpdir(), 'sync-skills-lock-scope-test-'))\n const binDir = mkdtempSync(join(tmpdir(), 'sync-skills-lock-scope-bin-'))\n const argvLogFile = join(binDir, 'npx-argv.log')\n // 'midrun-symlink-swap' / 'first-install-symlink-agents' で symlink の指す先になる\n // リポジトリ外の実ディレクトリ(ここへの書き込み・削除の有無を検証する)。\n const externalTargetDir = join(binDir, 'external-target')\n\n writeFileSync(join(binDir, 'gh'), '#!/usr/bin/env bash\\nexit 0\\n')\n chmodSync(join(binDir, 'gh'), 0o755)\n\n const npxBody = `#!/usr/bin/env bash\nset -euo pipefail\n# 受け取った引数をそのまま1行1トークンで記録する(--agent universal 到達の検証用)\nprintf '%s\\\\n' \"\\$@\" > \"${argvLogFile}\"\n\nif [[ \"\\${TEST_NPX_SCENARIO:-}\" == \"invalid-agents\" ]]; then\n # CLI バージョン更新で universal が無効な agent id になった場合の実測挙動\n # ([email protected] で確認済み: エラー表示のうえ exit 0 の no-op)。\n echo \"Invalid agents: universal\"\n echo \"Valid agents: claude-code, cursor, ...\"\n exit 0\nfi\n\nskill_dir=\".agents/skills/${SKILL_NAME}\"\nif [[ \"\\${TEST_NPX_SCENARIO:-}\" == \"first-install-symlink-agents\" ]]; then\n # 初回インストールで npx が .agents 自体をリポジトリ外向き symlink として作成する\n # ケースを再現する(以降の generic 書き込みは symlink 経由でリンク先へ落ちる)。\n mkdir -p \"${externalTargetDir}/skills\"\n ln -s \"${externalTargetDir}\" \".agents\"\nfi\n# 'first-install' シナリオでは実行前に .agents ツリーが存在しないため、実物の\n# npx skills add と同様に親ディレクトリごと作成する(既存時は no-op)。\nmkdir -p \"\\${skill_dir}\"\necho \"updated upstream content\" > \"\\${skill_dir}/SKILL.md\"\npython3 - \u003c\u003c'PYEOF'\nimport json\nwith open('skills-lock.json') as f:\n lock = json.load(f)\nlock['skills']['${SKILL_NAME}']['computedHash'] = 'sha256:updated-hash-value'\nwith open('skills-lock.json', 'w') as f:\n json.dump(lock, f, indent=2)\n f.write('\\\\n')\nPYEOF\n\nif [[ \"\\${TEST_NPX_SCENARIO:-}\" == \"out-of-scope-write\" ]]; then\n # スコープ外(他エージェントツリー相当 + 任意の別ツリー)への書き込みを再現する\n mkdir -p \".claude/skills/${SKILL_NAME}\"\n echo \"leaked into claude tree\" > \".claude/skills/${SKILL_NAME}/SKILL.md\"\n mkdir -p \".cursor/rules\"\n echo \"leaked into cursor tree\" > \".cursor/rules/x.md\"\nfi\n\nif [[ \"\\${TEST_NPX_SCENARIO:-}\" == \"content-overwrite-dirty\" ]]; then\n # 実行前から M(追跡・変更済み)だったスコープ外ファイルを、パス・ステータス文字は\n # 変えずに内容だけ上書きする(porcelain のレコードだけでは前後不変に見えるケース)。\n echo \"npx overwrote this content\" > \"${OUT_OF_SCOPE_FILE}\"\nfi\n\nif [[ \"\\${TEST_NPX_SCENARIO:-}\" == \"npx-failure-out-of-scope\" ]]; then\n # スコープ内書き込み(上の python ブロック)に加えてスコープ外へも部分書き込み\n # したのち、npx 自体が失敗するケースを再現する。\n echo \"npx overwrote this content during failure\" > \"${OUT_OF_SCOPE_FILE}\"\n exit 1\nfi\n\nif [[ \"\\${TEST_NPX_SCENARIO:-}\" == \"mode-change-dirty\" ]]; then\n # 実行前から M(追跡・変更済み)だったスコープ外ファイルの内容は変えず、\n # パーミッションのみを変更する(chmod 等での実行可能スクリプトコピーを再現)。\n chmod 755 \"${OUT_OF_SCOPE_FILE}\"\nfi\n\nif [[ \"\\${TEST_NPX_SCENARIO:-}\" == \"dir-chmod-out-of-scope\" ]]; then\n # スコープ外ディレクトリ自身の chmod のみ(配下・内容は不変)。git は\n # ディレクトリの mode を追跡しないため porcelain には一切現れない。\n chmod 700 \".claude/skills/other-skill\"\nfi\n\nif [[ \"\\${TEST_NPX_SCENARIO:-}\" == \"dir-symlink-retarget\" ]]; then\n # 実行前から ?? だったディレクトリ向け symlink のリンク先だけを付け替える\n # (porcelain レコードは「?? .claude/wip-link」のまま前後不変)。\n rm \".claude/wip-link\"\n ln -s \"wip-target-b\" \".claude/wip-link\"\nfi\n\nif [[ \"\\${TEST_NPX_SCENARIO:-}\" == \"nested-content-overwrite\" ]]; then\n # 実行前から未追跡だったディレクトリ配下のファイル内容だけを上書きする\n # (porcelain レコードは「?? .claude/wip-dir/notes.md」のまま前後不変)。\n echo \"npx overwrote nested wip\" > \".claude/wip-dir/notes.md\"\nfi\n\nif [[ \"\\${TEST_NPX_SCENARIO:-}\" == \"git-metadata-write\" ]]; then\n # 永続 Git メタデータへの改変(フック仕込み + 設定書き換え)を再現する。\n # .git 配下は porcelain に一切現れないため、シグネチャ比較でのみ検出できる。\n printf '#!/bin/sh\\\\necho pwned\\\\n' > \".git/hooks/post-checkout\"\n chmod 755 \".git/hooks/post-checkout\"\n printf '[alias]\\\\n\\\\tpwned = status\\\\n' >> \".git/config\"\nfi\n\nif [[ \"\\${TEST_NPX_SCENARIO:-}\" == \"invalid-agents-partial-write\" ]]; then\n # 部分書き込み(上のスコープ内書き込みに加えてスコープ外へも書く)の後に\n # 「Invalid agents」文言を出して exit 0 する CLI 版を再現する。文言だけを見て\n # 直接 exit すると、この残置がまったく検査・リバートされない(fail-open)。\n echo \"npx overwrote this content during noop-claim\" > \"${OUT_OF_SCOPE_FILE}\"\n echo \"Invalid agents: universal\"\n exit 0\nfi\n\nif [[ \"\\${TEST_NPX_SCENARIO:-}\" == \"git-refs-write\" ]]; then\n # .git の参照領域への書き込み(履歴・参照の改変)を再現する。porcelain には一切\n # 現れず、refs を prune していた旧実装ではシグネチャにも現れなかったケース。\n mkdir -p \".git/refs/heads\"\n printf '%s\\\\n' \"0000000000000000000000000000000000000000\" > \".git/refs/heads/injected-evil\"\nfi\n\nif [[ \"\\${TEST_NPX_SCENARIO:-}\" == \"ignored-residue-on-failure\" ]]; then\n # スコープ内へ .gitignore 対象ファイル(*.log)を書いた後に npx 自体が失敗する。\n # revert_in_scope が -x なしの git clean だとこのファイルだけ残置される。\n echo \"npx debug output\" > \".agents/skills/${SKILL_NAME}/debug.log\"\n exit 1\nfi\n\nif [[ \"\\${TEST_NPX_SCENARIO:-}\" == \"midrun-symlink-swap\" ]]; then\n # 事前 lstat 検査(実行前)を通過した後、実行中に許可先ディレクトリごと\n # リポジトリ外向き symlink へ置換し、リンク先へ書き込む(TOCTOU の再現)。\n rm -rf \"\\${skill_dir}\"\n mkdir -p \"${externalTargetDir}\"\n ln -s \"${externalTargetDir}\" \"\\${skill_dir}\"\n echo \"leaked outside repo\" > \"\\${skill_dir}/leaked.md\"\nfi\n\nif [[ \"\\${TEST_NPX_SCENARIO:-}\" == \"ignored-overwrite-success\" || \"\\${TEST_NPX_SCENARIO:-}\" == \"ignored-overwrite-on-failure\" ]]; then\n # 実行前から存在した ignored ファイル(.DS_Store)の内容だけを上書きする。\n # 許可先配下は署名から除外されるため、シグネチャ比較には現れないケース。\n echo \"npx clobbered ignored metadata\" > \".agents/skills/${SKILL_NAME}/.DS_Store\"\nfi\n\nif [[ \"\\${TEST_NPX_SCENARIO:-}\" == \"ignored-overwrite-on-failure\" ]]; then\n exit 1\nfi\n\nif [[ \"\\${TEST_NPX_SCENARIO:-}\" == \"ignored-delete-success\" ]]; then\n # 実行前から存在した ignored ファイルの削除を再現する(porcelain・シグネチャの\n # どちらにも現れず、実行前バックアップとの比較でのみ検出できる)。\n rm \".agents/skills/${SKILL_NAME}/.DS_Store\"\nfi\n\nif [[ \"\\${TEST_NPX_SCENARIO:-}\" == \"ignored-restore-failure-on-success\" ]]; then\n # npx 自体は成功するが、実行前から存在した ignored ファイル(.DS_Store)を\n # 同名のディレクトリへ置換する(symlink は使わない。実行後の許可先配下 symlink\n # 走査には現れず、restore_preexisting_ignored 内の os.unlink がディレクトリに\n # 対して IsADirectoryError で失敗する経路だけを単離して再現する)。\n rm \".agents/skills/${SKILL_NAME}/.DS_Store\"\n mkdir -p \".agents/skills/${SKILL_NAME}/.DS_Store\"\nfi\n\nif [[ \"\\${TEST_NPX_SCENARIO:-}\" == \"config-lock-residue\" ]]; then\n # 永続 lock の残置を再現する(.git/*.lock のワイルドカード prune では署名から\n # 漏れていたケース。index.lock のみの完全一致 prune で検出できることを検証)。\n : > \".git/config.lock\"\nfi\n\nif [[ \"\\${TEST_NPX_SCENARIO:-}\" == \"skip-worktree-flip\" ]]; then\n # スコープ外の tracked ファイルへ skip-worktree ビットを立てる。.git/index のみが\n # 変わるため、index の論理状態比較(index_state_signature)でのみ検出できる。\n git update-index --skip-worktree \"${OUT_OF_SCOPE_FILE}\"\nfi\n\nif [[ \"\\${TEST_NPX_SCENARIO:-}\" == \"midrun-nested-symlink\" ]]; then\n # 事前走査(0 件)通過後、実行中に許可先配下へ外向き symlink を作成して\n # リンク先へ書き込む(TOCTOU の再現。実行後再走査だけが検出できる)。\n mkdir -p \"${externalTargetDir}\"\n ln -s \"${externalTargetDir}\" \"\\${skill_dir}/evil-link\"\n echo \"leaked via nested link\" > \"\\${skill_dir}/evil-link/leaked.md\"\nfi\n\nif [[ \"\\${TEST_NPX_SCENARIO:-}\" == \"restore-parent-symlink-swap\" ]]; then\n # 既存 ignored ファイル(meta/sub/.DS_Store)の中間ディレクトリごと削除し、\n # meta をリポジトリ外向き symlink へ置換する。復元の makedirs がリンクを辿ると\n # リンク先(リポジトリ外)に sub/ が作られてしまうケースの再現。\n rm -rf \"\\${skill_dir}/meta\"\n mkdir -p \"${externalTargetDir}\"\n ln -s \"${externalTargetDir}\" \"\\${skill_dir}/meta\"\nfi\n\nif [[ \"\\${TEST_NPX_SCENARIO:-}\" == \"restore-parent-dir-deleted\" ]]; then\n # 既存 ignored ファイルを中間ディレクトリごと削除する(symlink なし)。復元が\n # 中間ディレクトリを再作成して書き戻す正常系の再現。\n rm -rf \"\\${skill_dir}/meta\"\nfi\n\nif [[ \"\\${TEST_NPX_SCENARIO:-}\" == \"agents-dir-chmod\" ]]; then\n # 実行前から存在する .agents ディレクトリ自身の chmod のみ(配下・内容は不変)。\n # git はディレクトリの mode を追跡しないため porcelain には現れない。\n chmod 700 \".agents\"\nfi\n\nif [[ \"\\${TEST_NPX_SCENARIO:-}\" == \"midrun-lockfile-symlink-swap\" ]]; then\n # 事前 lstat 検査(実行前)を通過した後、実行中に skills-lock.json をリポジトリ外\n # 向き symlink へ置換する(TOCTOU の再現。skills-lock.json は署名・porcelain の\n # 双方からパスで除外されるため、実行後の lstat 再検証だけが検出できる)。\n rm -f skills-lock.json\n ln -s \"${externalTargetDir}/victim-lock.json\" skills-lock.json\nfi\nexit 0\n`\n writeFileSync(join(binDir, 'npx'), npxBody)\n chmodSync(join(binDir, 'npx'), 0o755)\n\n // tee スタブ。既定は `command -p tee`(ユーザー PATH に依存しない標準ユーティリティ\n // 探索)で実物 tee の絶対パスを解決してから exec する(実物と同じ動作)。\n // TEST_NPX_SCENARIO=='tee-failure' のときのみ、入力を破棄したうえで非ゼロ終了し、\n // ディスク容量不足等での tee 失敗を再現する(Issue #410 CI 失敗指摘の回帰)。\n // `exec command -p tee \"$@\"` は使わない: POSIX では `command` は special builtin\n // ではないため、`exec` に builtin 名をそのまま渡すと厳密な POSIX 準拠シェルは\n // それを外部実行ファイル名として PATH 探索し `exec: command: not found` で失敗する\n // (bash は非 POSIX モードでは慣習的に許容するが、CI 環境で実際に発生した回帰)。\n // `command -v` で実物 tee の絶対パスを先に文字列として解決し、その絶対パスを\n // exec することで builtin 名を exec の引数に渡す経路自体を無くす。\n const teeBody = `#!/usr/bin/env bash\nif [[ \"\\${TEST_NPX_SCENARIO:-}\" == \"tee-failure\" ]]; then\n cat > /dev/null\n exit 1\nfi\nREAL_TEE=\"\\$(command -p -v tee)\"\nexec \"\\${REAL_TEE}\" \"\\$@\"\n`\n writeFileSync(join(binDir, 'tee'), teeBody)\n chmodSync(join(binDir, 'tee'), 0o755)\n\n // git スタブ。既定は実物 git(テストプロセスの PATH で解決した絶対パス)へ\n // そのまま exec する。TEST_NPX_SCENARIO=='post-status-failure' のときのみ、\n // `-uall` を含む status 呼び出し(スクリプトの前後スナップショット取得は\n // この 2 回だけ)の 2 回目(= npx 実行後)を失敗させ、実行後 git status の\n // 取得失敗を再現する(PR #412 第5巡 P1 の回帰)。リバート用の\n // git checkout / git clean は実物へ素通しされるため、この経路でスコープ内\n // リバートが実際に機能するかを end-to-end で検証できる。\n const realGit = execFileSync('bash', ['-c', 'command -v git'], { encoding: 'utf8' }).trim()\n const gitCountFile = join(binDir, 'git-uall-count')\n const gitBody = `#!/usr/bin/env bash\nif [[ \"\\${TEST_NPX_SCENARIO:-}\" == \"post-status-failure\" ]]; then\n has_uall=0\n for a in \"\\$@\"; do\n if [[ \"\\$a\" == \"-uall\" ]]; then has_uall=1; fi\n done\n if [[ \"\\$has_uall\" -eq 1 ]]; then\n n=\"\\$(cat \"${gitCountFile}\" 2>/dev/null || echo 0)\"\n n=\\$((n + 1))\n printf '%s\\\\n' \"\\$n\" > \"${gitCountFile}\"\n if [[ \"\\$n\" -ge 2 ]]; then\n echo \"fatal: simulated post-run status failure\" >&2\n exit 128\n fi\n fi\nfi\nexec \"${realGit}\" \"\\$@\"\n`\n writeFileSync(join(binDir, 'git'), gitBody)\n chmodSync(join(binDir, 'git'), 0o755)\n\n sh('git init -q', repoDir)\n sh('git config user.email [email protected]', repoDir)\n sh('git config user.name test', repoDir)\n\n // 'first-install' / 'first-install-symlink-agents' 用: 実行前に .agents ツリー自体が\n // 存在しない状態を再現するため、union ストアの事前作成をスキップする(npx スタブが\n // 新規作成する)。'symlink-scope-path' も実ディレクトリの事前作成をスキップする\n // (下で symlink を作る)。\n if (\n scenario !== 'first-install' &&\n scenario !== 'first-install-symlink-agents' &&\n scenario !== 'symlink-scope-path'\n ) {\n mkdirSync(join(repoDir, '.agents', 'skills', SKILL_NAME), { recursive: true })\n writeFileSync(\n join(repoDir, '.agents', 'skills', SKILL_NAME, 'SKILL.md'),\n 'original content\\n',\n )\n }\n writeFileSync(\n join(repoDir, 'skills-lock.json'),\n JSON.stringify(\n {\n skills: {\n [SKILL_NAME]: {\n source: `https://github.com/${SOURCE_REPO}`,\n computedHash: 'sha256:original-hash-value',\n },\n },\n },\n null,\n 2,\n ) + '\\n',\n )\n // 'lockfile-symlink-preexisting' 用: skills-lock.json をリポジトリ外の実ファイルへ\n // 向けた symlink に置き換える(リンク先は有効な lock JSON。source 照合・computedHash\n // 表示は symlink を辿って通過し、clean ガードも symlink としてコミットするため通過\n // する)。lstat 検証(npx 実行前)だけがこのレイアウトを拒否できる。\n // 'midrun-lockfile-symlink-swap' 用: npx スタブが置換後のリンク先にする外部実ファイル\n // (victim-lock.json)を用意し、リンク先が書き換えられないことを検証できるようにする。\n if (scenario === 'lockfile-symlink-preexisting') {\n const externalLock = join(binDir, 'external-lock.json')\n renameSync(join(repoDir, 'skills-lock.json'), externalLock)\n symlinkSync(externalLock, join(repoDir, 'skills-lock.json'))\n }\n if (scenario === 'midrun-lockfile-symlink-swap') {\n mkdirSync(externalTargetDir, { recursive: true })\n writeFileSync(join(externalTargetDir, 'victim-lock.json'), 'external victim content\\n')\n }\n\n // 'content-overwrite-dirty' / 'npx-failure-out-of-scope' / 'mode-change-dirty' 用:\n // スコープ外ファイルを baseline content でコミットしてから、コミット後に別内容へ\n // 書き換えて「実行前から M(追跡・変更済み)だった」状態を再現する。npx スタブは\n // 同じパス・同じ M ステータスのまま内容またはパーミッションをさらに上書きする。\n if (\n scenario === 'content-overwrite-dirty' ||\n scenario === 'npx-failure-out-of-scope' ||\n scenario === 'invalid-agents-partial-write' ||\n scenario === 'mode-change-dirty'\n ) {\n const outOfScopePath = join(repoDir, ...OUT_OF_SCOPE_FILE.split('/'))\n mkdirSync(dirname(outOfScopePath), { recursive: true })\n writeFileSync(outOfScopePath, 'baseline content\\n')\n sh('git add -A && git commit -q -m init', repoDir)\n writeFileSync(outOfScopePath, 'pre-existing wip edit (uncommitted)\\n')\n return { repoDir, binDir, scenario, argvLogFile, externalTargetDir }\n }\n\n // 'dir-chmod-out-of-scope' 用: スコープ外ディレクトリを clean な追跡状態で用意する\n // (ディレクトリの chmod は porcelain に現れないため、dirty 化は不要。追跡ファイルを\n // 1つ置くことでディレクトリ自体を確実に具現化してからコミットする)。\n // 'skip-worktree-flip' も同じ形(clean な追跡ファイル)を使う: skip-worktree ビットは\n // porcelain・ツリー署名のどちらにも現れず、index の論理状態比較だけが検出できる。\n if (scenario === 'dir-chmod-out-of-scope' || scenario === 'skip-worktree-flip') {\n const outOfScopePath = join(repoDir, ...OUT_OF_SCOPE_FILE.split('/'))\n mkdirSync(dirname(outOfScopePath), { recursive: true })\n writeFileSync(outOfScopePath, 'baseline content\\n')\n sh('git add -A && git commit -q -m init', repoDir)\n return { repoDir, binDir, scenario, argvLogFile, externalTargetDir }\n }\n\n // 'symlink-scope-path' 用: 許可先 .agents/skills/\u003cname> をリポジトリ外\n // (binDir 配下の実ディレクトリ)へ向けた symlink として作成し、コミットして\n // per-skill clean ガードを通過する状態にする。スコープ外検査は許可先をパス文字列で\n // 走査除外するため、このレイアウトは lstat 検証(npx 実行前)だけが拒否できる。\n if (scenario === 'symlink-scope-path') {\n const externalTarget = join(binDir, 'external-skill-store')\n mkdirSync(externalTarget, { recursive: true })\n mkdirSync(join(repoDir, '.agents', 'skills'), { recursive: true })\n symlinkSync(externalTarget, join(repoDir, '.agents', 'skills', SKILL_NAME))\n }\n\n // 'ignored-residue-on-failure' 用: *.log / .DS_Store を ignore する .gitignore を\n // コミットに含め、npx スタブが書く debug.log がスコープ内の .gitignore 対象ファイルに\n // なるようにする。加えて「実行前から存在した ignored ファイル」(.DS_Store)を\n // 許可先配下へ置き、abort 時のリバートで巻き添え削除されない(実行前インベントリで\n // 保全される)ことを検証できるようにする(PR #412 Bugbot Medium 指摘)。\n // ignored のため per-skill clean ガード(porcelain)は通過する。\n if (\n scenario === 'ignored-residue-on-failure' ||\n scenario === 'ignored-overwrite-success' ||\n scenario === 'ignored-overwrite-on-failure' ||\n scenario === 'ignored-delete-success' ||\n scenario === 'ignored-restore-failure-on-success'\n ) {\n writeFileSync(join(repoDir, '.gitignore'), '*.log\\n.DS_Store\\n')\n writeFileSync(\n join(repoDir, '.agents', 'skills', SKILL_NAME, '.DS_Store'),\n 'pre-existing finder metadata\\n',\n )\n }\n\n // 'restore-parent-symlink-swap' / 'restore-parent-dir-deleted' 用: 既存 ignored\n // ファイルを中間ディレクトリ(meta/sub/)付きで許可先配下へ置く。npx スタブが\n // meta を削除(+ 外向き symlink へ置換)した後の復元経路で、makedirs 相当が\n // リポジトリ外へディレクトリを作らないこと / 正当な再作成が機能することを検証する。\n if (\n scenario === 'restore-parent-symlink-swap' ||\n scenario === 'restore-parent-dir-deleted'\n ) {\n writeFileSync(join(repoDir, '.gitignore'), '.DS_Store\\n')\n mkdirSync(join(repoDir, '.agents', 'skills', SKILL_NAME, 'meta', 'sub'), { recursive: true })\n writeFileSync(\n join(repoDir, '.agents', 'skills', SKILL_NAME, 'meta', 'sub', '.DS_Store'),\n 'pre-existing nested finder metadata\\n',\n )\n }\n\n // 'preexisting-symlink-in-scope' 用: 許可先ディレクトリ配下(ルート 3 要素の lstat\n // 検証では見えない深さ)にリポジトリ外向き symlink を置いてコミットする。許可先\n // 配下は prune-under で署名から除外されるため、npx 実行前の find -type l 走査だけが\n // このレイアウトを拒否できる。\n if (scenario === 'preexisting-symlink-in-scope') {\n const externalTarget = join(binDir, 'external-skill-store')\n mkdirSync(externalTarget, { recursive: true })\n symlinkSync(externalTarget, join(repoDir, '.agents', 'skills', SKILL_NAME, 'evil-link'))\n }\n\n sh('git add -A && git commit -q -m init', repoDir)\n\n // 'dir-symlink-retarget' 用: コミット後に未追跡(??)のディレクトリ向け symlink を\n // 作り「実行前から ?? だった」状態を再現する。npx スタブはリンク先だけを\n // wip-target-b へ付け替える(porcelain レコードは前後不変)。\n if (scenario === 'dir-symlink-retarget') {\n mkdirSync(join(repoDir, '.claude', 'wip-target-a'), { recursive: true })\n mkdirSync(join(repoDir, '.claude', 'wip-target-b'), { recursive: true })\n symlinkSync('wip-target-a', join(repoDir, '.claude', 'wip-link'))\n }\n\n // 'nested-content-overwrite' 用: コミット後に未追跡ディレクトリ配下のファイルを\n // 作り「実行前から ?? だった」状態を再現する。npx スタブは同じパスの内容だけを\n // 上書きする(porcelain レコードは前後不変)。\n if (scenario === 'nested-content-overwrite') {\n mkdirSync(join(repoDir, '.claude', 'wip-dir'), { recursive: true })\n writeFileSync(join(repoDir, '.claude', 'wip-dir', 'notes.md'), 'pre-existing nested wip\\n')\n }\n\n return { repoDir, binDir, scenario, argvLogFile, externalTargetDir }\n}\n\n// cwdOverride は 'linked-worktree' シナリオ専用: メイン worktree(repoDir)ではなく\n// git worktree add で作った作業ツリーからスクリプトを実行するために使う。\nfunction runScript({ repoDir, binDir, scenario }, cwdOverride) {\n const env = {\n ...process.env,\n PATH: `${binDir}:${process.env.PATH}`,\n TEST_NPX_SCENARIO: scenario,\n }\n return execFileSync('bash', [SCRIPT_PATH, SKILL_NAME, SOURCE_REPO], {\n cwd: cwdOverride ?? repoDir,\n env,\n encoding: 'utf8',\n })\n}\n\ntest('ケース1: npx が --agent universal を実際に受け取る(argv 記録による実行経路検証)', () => {\n const ctx = setupRepo('edit-only-for-argv-check')\n try {\n runScript(ctx)\n const argv = readFileSync(ctx.argvLogFile, 'utf8').split('\\n').filter(Boolean)\n assert.ok(\n argv.includes('--agent') && argv.includes('universal'),\n `--agent universal が npx へ渡されていない: ${JSON.stringify(argv)}`,\n )\n } finally {\n rmSync(ctx.repoDir, { recursive: true, force: true })\n rmSync(ctx.binDir, { recursive: true, force: true })\n }\n})\n\ntest('ケース2: スコープ外書き込みを検出すると非ゼロ終了し、スコープ内はリバートされ、' +\n 'スコープ外は削除されずに残る', () => {\n const ctx = setupRepo('out-of-scope-write')\n try {\n assert.throws(\n () => runScript(ctx),\n (err) => {\n assert.notEqual(err.status, 0, '非ゼロ終了すること(fail-closed)')\n const combined = `${err.stdout ?? ''}${err.stderr ?? ''}`\n assert.match(combined, /スコープ外/, 'スコープ外検出のエラーメッセージが出ること')\n assert.match(combined, /\\.claude\\/skills/, '検出パスが列挙されること(.claude 側)')\n assert.match(combined, /\\.cursor\\/rules/, '検出パスが列挙されること(.cursor 側)')\n return true\n },\n )\n\n // スコープ内(skills-lock.json / .agents/skills/\u003cname>/)はリバートされ clean\n const lockDiff = sh(`git status --porcelain -- skills-lock.json`, ctx.repoDir).trim()\n assert.equal(lockDiff, '', 'skills-lock.json はリバートされ clean であること')\n const treeDiff = sh(\n `git status --porcelain -- \".agents/skills/${SKILL_NAME}/\"`,\n ctx.repoDir,\n ).trim()\n assert.equal(treeDiff, '', '.agents/skills/\u003cname>/ はリバートされ clean であること')\n\n // スコープ外は自動リバートせず、内容を確認できる状態のまま残す\n assert.ok(\n existsSync(join(ctx.repoDir, '.claude', 'skills', SKILL_NAME, 'SKILL.md')),\n 'スコープ外ファイル(.claude 側)は削除されず残存すること',\n )\n assert.ok(\n existsSync(join(ctx.repoDir, '.cursor', 'rules', 'x.md')),\n 'スコープ外ファイル(.cursor 側)は削除されず残存すること',\n )\n } finally {\n rmSync(ctx.repoDir, { recursive: true, force: true })\n rmSync(ctx.binDir, { recursive: true, force: true })\n }\n})\n\ntest('ケース3: 「Invalid agents」で exit 0 の no-op を検出し、非ゼロ終了する', () => {\n const ctx = setupRepo('invalid-agents')\n try {\n assert.throws(\n () => runScript(ctx),\n (err) => {\n assert.notEqual(err.status, 0, '非ゼロ終了すること(silent no-op を許容しない)')\n const combined = `${err.stdout ?? ''}${err.stderr ?? ''}`\n assert.match(\n combined,\n /Invalid agents/,\n 'no-op を検出したエラーメッセージが出ること',\n )\n return true\n },\n )\n\n // no-op のため何も更新されておらず、skills-lock.json も変化していないはず\n const lockDiff = sh(`git status --porcelain -- skills-lock.json`, ctx.repoDir).trim()\n assert.equal(lockDiff, '', 'no-op のため skills-lock.json は変化していないこと')\n } finally {\n rmSync(ctx.repoDir, { recursive: true, force: true })\n rmSync(ctx.binDir, { recursive: true, force: true })\n }\n})\n\ntest('ケース4: スコープ内のみの書き込みでは誤検出せず、従来どおり完走する(非退行)', () => {\n const ctx = setupRepo('edit-only')\n try {\n const out = runScript(ctx)\n assert.doesNotMatch(out, /スコープ外/, 'スコープ内のみの変更を誤検出しないこと')\n assert.match(out, /updated upstream content/, 'tracked diff は従来どおり表示されること')\n } finally {\n rmSync(ctx.repoDir, { recursive: true, force: true })\n rmSync(ctx.binDir, { recursive: true, force: true })\n }\n})\n\ntest('ケース5: 実行前から dirty だったスコープ外ファイルの内容だけの上書きを、' +\n '内容ハッシュ比較で検出する(PR #412 P1 の回帰)', () => {\n const ctx = setupRepo('content-overwrite-dirty')\n try {\n assert.throws(\n () => runScript(ctx),\n (err) => {\n assert.notEqual(err.status, 0, '非ゼロ終了すること(fail-closed)')\n const combined = `${err.stdout ?? ''}${err.stderr ?? ''}`\n assert.match(combined, /スコープ外/, 'スコープ外検出のエラーメッセージが出ること')\n assert.match(\n combined,\n /other-skill\\/NOTES\\.md/,\n '内容だけ上書きされたスコープ外ファイルのパスが列挙されること' +\n '(ステータス文字列・パスは前後で不変のため、これは内容ハッシュ比較でのみ検出できる)',\n )\n return true\n },\n )\n\n // スコープ内(skills-lock.json / .agents/skills/\u003cname>/)はリバートされ clean\n const lockDiff = sh(`git status --porcelain -- skills-lock.json`, ctx.repoDir).trim()\n assert.equal(lockDiff, '', 'skills-lock.json はリバートされ clean であること')\n const treeDiff = sh(\n `git status --porcelain -- \".agents/skills/${SKILL_NAME}/\"`,\n ctx.repoDir,\n ).trim()\n assert.equal(treeDiff, '', '.agents/skills/\u003cname>/ はリバートされ clean であること')\n } finally {\n rmSync(ctx.repoDir, { recursive: true, force: true })\n rmSync(ctx.binDir, { recursive: true, force: true })\n }\n})\n\ntest('ケース7: 実行前から dirty だったスコープ外ファイルのパーミッションのみの変更を、' +\n '状態シグネチャ比較で検出する(PR #412 第3巡 P1 の回帰)', () => {\n const ctx = setupRepo('mode-change-dirty')\n const outOfScopePath = join(ctx.repoDir, ...OUT_OF_SCOPE_FILE.split('/'))\n try {\n assert.throws(\n () => runScript(ctx),\n (err) => {\n assert.notEqual(err.status, 0, '非ゼロ終了すること(fail-closed)')\n const combined = `${err.stdout ?? ''}${err.stderr ?? ''}`\n assert.match(combined, /スコープ外/, 'スコープ外検出のエラーメッセージが出ること')\n assert.match(\n combined,\n /other-skill\\/NOTES\\.md/,\n 'パーミッションのみ変更されたスコープ外ファイルのパスが列挙されること' +\n '(ステータス文字列・パス・内容は前後で不変のため、これは状態シグネチャ' +\n '(モード込み)比較でのみ検出できる)',\n )\n return true\n },\n )\n\n // スコープ内(skills-lock.json / .agents/skills/\u003cname>/)はリバートされ clean\n const lockDiff = sh(`git status --porcelain -- skills-lock.json`, ctx.repoDir).trim()\n assert.equal(lockDiff, '', 'skills-lock.json はリバートされ clean であること')\n const treeDiff = sh(\n `git status --porcelain -- \".agents/skills/${SKILL_NAME}/\"`,\n ctx.repoDir,\n ).trim()\n assert.equal(treeDiff, '', '.agents/skills/\u003cname>/ はリバートされ clean であること')\n\n // スコープ外ファイルは自動リバートせず、変更後のパーミッション(755)のまま残す\n const mode = statSync(outOfScopePath).mode & 0o777\n assert.equal(mode, 0o755, 'パーミッション変更が自動リバートされず残存すること')\n } finally {\n rmSync(ctx.repoDir, { recursive: true, force: true })\n rmSync(ctx.binDir, { recursive: true, force: true })\n }\n})\n\ntest('ケース6: npx が非ゼロ終了しても事後のスコープ外検査へ到達する' +\n '(PR #412 P1 の回帰。failure 経路でも fail-closed)', () => {\n const ctx = setupRepo('npx-failure-out-of-scope')\n try {\n assert.throws(\n () => runScript(ctx),\n (err) => {\n assert.notEqual(err.status, 0, '非ゼロ終了すること(fail-closed)')\n const combined = `${err.stdout ?? ''}${err.stderr ?? ''}`\n assert.match(combined, /実行が失敗しました/, 'npx 失敗の警告が出ること')\n assert.match(\n combined,\n /スコープ外へも書き込んだ可能性/,\n 'npx 失敗経路でも事後のスコープ外検査が実行され、残置が報告されること',\n )\n assert.match(\n combined,\n /other-skill\\/NOTES\\.md/,\n '失敗経路での残置パスが列挙されること',\n )\n return true\n },\n )\n\n // 失敗経路でもスコープ内(skills-lock.json / .agents/skills/\u003cname>/)は\n // 即座にリバートされ clean であること\n const lockDiff = sh(`git status --porcelain -- skills-lock.json`, ctx.repoDir).trim()\n assert.equal(lockDiff, '', 'skills-lock.json はリバートされ clean であること')\n const treeDiff = sh(\n `git status --porcelain -- \".agents/skills/${SKILL_NAME}/\"`,\n ctx.repoDir,\n ).trim()\n assert.equal(treeDiff, '', '.agents/skills/\u003cname>/ はリバートされ clean であること')\n } finally {\n rmSync(ctx.repoDir, { recursive: true, force: true })\n rmSync(ctx.binDir, { recursive: true, force: true })\n }\n})\n\ntest('ケース8: npx が成功しても tee が失敗した場合、fail-closed で失敗扱いになる' +\n '(PR #412 CI 失敗指摘の P1 回帰。PIPESTATUS[1] 未確認による誤成功の防止)', () => {\n const ctx = setupRepo('tee-failure')\n try {\n assert.throws(\n () => runScript(ctx),\n (err) => {\n assert.notEqual(err.status, 0, '非ゼロ終了すること(fail-closed)')\n const combined = `${err.stdout ?? ''}${err.stderr ?? ''}`\n assert.match(\n combined,\n /tee が失敗しました/,\n 'tee 失敗を検知したエラーメッセージが出ること',\n )\n assert.match(\n combined,\n /実行が失敗しました/,\n 'npx 自体は exit 0 でも、tee 失敗により NPX_STATUS が失敗へ強制されること',\n )\n assert.doesNotMatch(\n combined,\n /Invalid agents を認識せず/,\n '不完全な NPX_OUTPUT_FILE を前提にした no-op 判定を経由しないこと',\n )\n return true\n },\n )\n\n // tee 失敗経路でもスコープ内(skills-lock.json / .agents/skills/\u003cname>/)は\n // 即座にリバートされ clean であること(npx 自体は scope-in へ書き込み済みのため、\n // リバートされずに残ると「tee は失敗したのに部分的に成功扱いのまま」になる)\n const lockDiff2 = sh(`git status --porcelain -- skills-lock.json`, ctx.repoDir).trim()\n assert.equal(lockDiff2, '', 'skills-lock.json はリバートされ clean であること')\n const treeDiff2 = sh(\n `git status --porcelain -- \".agents/skills/${SKILL_NAME}/\"`,\n ctx.repoDir,\n ).trim()\n assert.equal(treeDiff2, '', '.agents/skills/\u003cname>/ はリバートされ clean であること')\n } finally {\n rmSync(ctx.repoDir, { recursive: true, force: true })\n rmSync(ctx.binDir, { recursive: true, force: true })\n }\n})\n\ntest('ケース9: スコープ外ディレクトリ自身の chmod を全体シグネチャ比較で検出する' +\n '(PR #412 P1 の回帰。porcelain にはディレクトリの mode 変更が一切現れない)', () => {\n const ctx = setupRepo('dir-chmod-out-of-scope')\n const outOfScopeDir = join(ctx.repoDir, '.claude', 'skills', 'other-skill')\n try {\n assert.throws(\n () => runScript(ctx),\n (err) => {\n assert.notEqual(err.status, 0, '非ゼロ終了すること(fail-closed)')\n const combined = `${err.stdout ?? ''}${err.stderr ?? ''}`\n assert.match(combined, /スコープ外/, 'スコープ外検出のエラーメッセージが出ること')\n assert.match(\n combined,\n /状態シグネチャ/,\n 'git status に現れない変化がシグネチャ不一致として検出された旨の案内が出ること' +\n '(ディレクトリの chmod は porcelain の前後どちらにも現れないため、' +\n 'status 由来のパス集合をハッシュする方式では原理的に検出できない)',\n )\n return true\n },\n )\n\n // スコープ内(skills-lock.json / .agents/skills/\u003cname>/)はリバートされ clean\n const lockDiff = sh(`git status --porcelain -- skills-lock.json`, ctx.repoDir).trim()\n assert.equal(lockDiff, '', 'skills-lock.json はリバートされ clean であること')\n const treeDiff = sh(\n `git status --porcelain -- \".agents/skills/${SKILL_NAME}/\"`,\n ctx.repoDir,\n ).trim()\n assert.equal(treeDiff, '', '.agents/skills/\u003cname>/ はリバートされ clean であること')\n\n // スコープ外は自動リバートせず、変更後の mode(700)のまま残す\n const mode = statSync(outOfScopeDir).mode & 0o777\n assert.equal(mode, 0o700, 'ディレクトリの chmod が自動リバートされず残存すること')\n } finally {\n rmSync(ctx.repoDir, { recursive: true, force: true })\n rmSync(ctx.binDir, { recursive: true, force: true })\n }\n})\n\ntest('ケース10: 実行前から未追跡だったディレクトリ向け symlink のリンク先変更を' +\n '全体シグネチャ比較で検出する(PR #412 P1 の回帰)', () => {\n const ctx = setupRepo('dir-symlink-retarget')\n const linkPath = join(ctx.repoDir, '.claude', 'wip-link')\n try {\n assert.throws(\n () => runScript(ctx),\n (err) => {\n assert.notEqual(err.status, 0, '非ゼロ終了すること(fail-closed)')\n const combined = `${err.stdout ?? ''}${err.stderr ?? ''}`\n assert.match(\n combined,\n /スコープ外/,\n 'スコープ外検出のエラーメッセージが出ること' +\n '(porcelain レコードは「?? .claude/wip-link」のまま前後不変のため、' +\n 'これはリンク先文字列を含むシグネチャ比較でのみ検出できる)',\n )\n return true\n },\n )\n\n // スコープ内(skills-lock.json / .agents/skills/\u003cname>/)はリバートされ clean\n const lockDiff = sh(`git status --porcelain -- skills-lock.json`, ctx.repoDir).trim()\n assert.equal(lockDiff, '', 'skills-lock.json はリバートされ clean であること')\n const treeDiff = sh(\n `git status --porcelain -- \".agents/skills/${SKILL_NAME}/\"`,\n ctx.repoDir,\n ).trim()\n assert.equal(treeDiff, '', '.agents/skills/\u003cname>/ はリバートされ clean であること')\n\n // スコープ外の symlink は自動リバートせず、付け替え後のリンク先のまま残す\n assert.equal(\n readlinkSync(linkPath),\n 'wip-target-b',\n 'symlink のリンク先変更が自動リバートされず残存すること',\n )\n } finally {\n rmSync(ctx.repoDir, { recursive: true, force: true })\n rmSync(ctx.binDir, { recursive: true, force: true })\n }\n})\n\ntest('ケース11: 実行前から未追跡だったディレクトリ配下のファイル内容だけの上書きを' +\n '全体シグネチャ比較で検出する(PR #412 P1 の回帰)', () => {\n const ctx = setupRepo('nested-content-overwrite')\n try {\n assert.throws(\n () => runScript(ctx),\n (err) => {\n assert.notEqual(err.status, 0, '非ゼロ終了すること(fail-closed)')\n const combined = `${err.stdout ?? ''}${err.stderr ?? ''}`\n assert.match(combined, /スコープ外/, 'スコープ外検出のエラーメッセージが出ること')\n assert.match(\n combined,\n /wip-dir\\/notes\\.md/,\n '内容だけ上書きされた未追跡ファイルのパスが列挙されること' +\n '(porcelain レコードは「?? .claude/wip-dir/notes.md」のまま前後不変のため、' +\n '検出自体は配下ファイルの内容ハッシュを含むシグネチャ比較が担う)',\n )\n return true\n },\n )\n\n // スコープ内(skills-lock.json / .agents/skills/\u003cname>/)はリバートされ clean\n const lockDiff = sh(`git status --porcelain -- skills-lock.json`, ctx.repoDir).trim()\n assert.equal(lockDiff, '', 'skills-lock.json はリバートされ clean であること')\n const treeDiff = sh(\n `git status --porcelain -- \".agents/skills/${SKILL_NAME}/\"`,\n ctx.repoDir,\n ).trim()\n assert.equal(treeDiff, '', '.agents/skills/\u003cname>/ はリバートされ clean であること')\n\n // スコープ外は自動リバートせず、上書き後の内容のまま残す\n assert.equal(\n readFileSync(join(ctx.repoDir, '.claude', 'wip-dir', 'notes.md'), 'utf8'),\n 'npx overwrote nested wip\\n',\n '上書きされた内容が自動リバートされず残存すること(確認用に保全される)',\n )\n } finally {\n rmSync(ctx.repoDir, { recursive: true, force: true })\n rmSync(ctx.binDir, { recursive: true, force: true })\n }\n})\n\ntest('ケース12: .git/hooks/ へのフック追加と .git/config の書き換えを全体シグネチャ比較で' +\n '検出する(PR #412 第4巡 P1 の回帰。.git 丸ごと prune では署名から漏れる)', () => {\n const ctx = setupRepo('git-metadata-write')\n const hookPath = join(ctx.repoDir, '.git', 'hooks', 'post-checkout')\n try {\n assert.throws(\n () => runScript(ctx),\n (err) => {\n assert.notEqual(err.status, 0, '非ゼロ終了すること(fail-closed)')\n const combined = `${err.stdout ?? ''}${err.stderr ?? ''}`\n assert.match(combined, /スコープ外/, 'スコープ外検出のエラーメッセージが出ること')\n assert.match(\n combined,\n /状態シグネチャ/,\n '.git 配下は porcelain に一切現れないため、シグネチャ不一致として検出された旨の' +\n '案内が出ること(config・hooks が署名対象に含まれていることの検証)',\n )\n return true\n },\n )\n\n // スコープ内(skills-lock.json / .agents/skills/\u003cname>/)はリバートされ clean\n const lockDiff = sh(`git status --porcelain -- skills-lock.json`, ctx.repoDir).trim()\n assert.equal(lockDiff, '', 'skills-lock.json はリバートされ clean であること')\n const treeDiff = sh(\n `git status --porcelain -- \".agents/skills/${SKILL_NAME}/\"`,\n ctx.repoDir,\n ).trim()\n assert.equal(treeDiff, '', '.agents/skills/\u003cname>/ はリバートされ clean であること')\n\n // 改変された Git メタデータは自動リバートせず、確認できる状態のまま残す\n assert.ok(existsSync(hookPath), '追加されたフックファイルは削除されず残存すること')\n assert.match(\n readFileSync(join(ctx.repoDir, '.git', 'config'), 'utf8'),\n /pwned/,\n '.git/config の書き換えが自動リバートされず残存すること(確認用に保全される)',\n )\n } finally {\n rmSync(ctx.repoDir, { recursive: true, force: true })\n rmSync(ctx.binDir, { recursive: true, force: true })\n }\n})\n\ntest('ケース13: 実行前から存在する .agents ディレクトリ自身の chmod を全体シグネチャ比較で' +\n '検出する(PR #412 第4巡 P1 の回帰。無条件 omit では検出できない)', () => {\n const ctx = setupRepo('agents-dir-chmod')\n const agentsDir = join(ctx.repoDir, '.agents')\n try {\n assert.throws(\n () => runScript(ctx),\n (err) => {\n assert.notEqual(err.status, 0, '非ゼロ終了すること(fail-closed)')\n const combined = `${err.stdout ?? ''}${err.stderr ?? ''}`\n assert.match(combined, /スコープ外/, 'スコープ外検出のエラーメッセージが出ること')\n assert.match(\n combined,\n /状態シグネチャ/,\n 'ディレクトリの chmod は porcelain に現れないため、シグネチャ不一致として' +\n '検出された旨の案内が出ること(既存 .agents が署名対象であることの検証)',\n )\n return true\n },\n )\n\n // スコープ内(skills-lock.json / .agents/skills/\u003cname>/)はリバートされ clean\n const lockDiff = sh(`git status --porcelain -- skills-lock.json`, ctx.repoDir).trim()\n assert.equal(lockDiff, '', 'skills-lock.json はリバートされ clean であること')\n const treeDiff = sh(\n `git status --porcelain -- \".agents/skills/${SKILL_NAME}/\"`,\n ctx.repoDir,\n ).trim()\n assert.equal(treeDiff, '', '.agents/skills/\u003cname>/ はリバートされ clean であること')\n\n // スコープ外の chmod は自動リバートせず、変更後の mode(700)のまま残す\n const mode = statSync(agentsDir).mode & 0o777\n assert.equal(mode, 0o700, '.agents の chmod が自動リバートされず残存すること')\n } finally {\n rmSync(ctx.repoDir, { recursive: true, force: true })\n rmSync(ctx.binDir, { recursive: true, force: true })\n }\n})\n\ntest('ケース15: 部分書き込み後に「Invalid agents」文言を出す npx でも、no-op 検知が' +\n '事後検査・リバートを迂回しない(PR #412 第5巡 P1 の回帰)', () => {\n const ctx = setupRepo('invalid-agents-partial-write')\n try {\n assert.throws(\n () => runScript(ctx),\n (err) => {\n assert.notEqual(err.status, 0, '非ゼロ終了すること(fail-closed)')\n const combined = `${err.stdout ?? ''}${err.stderr ?? ''}`\n assert.match(combined, /Invalid agents/, 'no-op 検知のエラーメッセージ自体は出ること')\n assert.match(\n combined,\n /スコープ外/,\n 'no-op 文言があっても事後のスコープ外検査へ到達し、残置が報告されること' +\n '(no-op 文言だけを信じて直接 exit すると、この検出が丸ごと迂回される)',\n )\n assert.match(\n combined,\n /other-skill\\/NOTES\\.md/,\n '部分書き込みされたスコープ外パスが列挙されること',\n )\n return true\n },\n )\n\n // no-op 文言つき部分書き込みでも、スコープ内は共通失敗経路でリバートされ clean\n const lockDiff = sh(`git status --porcelain -- skills-lock.json`, ctx.repoDir).trim()\n assert.equal(lockDiff, '', 'skills-lock.json はリバートされ clean であること')\n const treeDiff = sh(\n `git status --porcelain -- \".agents/skills/${SKILL_NAME}/\"`,\n ctx.repoDir,\n ).trim()\n assert.equal(treeDiff, '', '.agents/skills/\u003cname>/ はリバートされ clean であること')\n\n // スコープ外は自動リバートせず、確認用に上書き後の内容のまま残す\n assert.equal(\n readFileSync(join(ctx.repoDir, ...OUT_OF_SCOPE_FILE.split('/')), 'utf8'),\n 'npx overwrote this content during noop-claim\\n',\n 'スコープ外の残置は削除されず確認可能な状態で残ること',\n )\n } finally {\n rmSync(ctx.repoDir, { recursive: true, force: true })\n rmSync(ctx.binDir, { recursive: true, force: true })\n }\n})\n\ntest('ケース16: npx 成功後の git status 取得失敗でもスコープ内リバートが走り、' +\n '非ゼロ終了する(PR #412 第5巡 P1 の回帰。生成済み変更の残置防止)', () => {\n const ctx = setupRepo('post-status-failure')\n try {\n assert.throws(\n () => runScript(ctx),\n (err) => {\n assert.notEqual(err.status, 0, '非ゼロ終了すること(fail-closed)')\n const combined = `${err.stdout ?? ''}${err.stderr ?? ''}`\n assert.match(\n combined,\n /実行後の git status 取得に失敗/,\n 'status 取得失敗のエラーメッセージが出ること',\n )\n assert.match(\n combined,\n /リバートしました/,\n 'status 取得失敗経路でもスコープ内リバートの実施が案内されること',\n )\n return true\n },\n )\n\n // status 取得失敗経路でも、npx が書き込み済みのスコープ内は残置されず clean\n // (検証側は実物 git を直接使う。スタブは runScript の PATH にのみ入る)\n const lockDiff = sh(`git status --porcelain -- skills-lock.json`, ctx.repoDir).trim()\n assert.equal(lockDiff, '', 'skills-lock.json はリバートされ clean であること')\n const treeDiff = sh(\n `git status --porcelain -- \".agents/skills/${SKILL_NAME}/\"`,\n ctx.repoDir,\n ).trim()\n assert.equal(treeDiff, '', '.agents/skills/\u003cname>/ はリバートされ clean であること')\n } finally {\n rmSync(ctx.repoDir, { recursive: true, force: true })\n rmSync(ctx.binDir, { recursive: true, force: true })\n }\n})\n\ntest('ケース14: 初回インストール(実行前に .agents 不存在)で npx が親ディレクトリを' +\n '新規作成しても誤検知せず完走する(条件付き omit の非退行)', () => {\n const ctx = setupRepo('first-install')\n try {\n const out = runScript(ctx)\n assert.doesNotMatch(\n out,\n /スコープ外/,\n '実行前に不存在だった .agents / .agents/skills の新規作成を誤検知しないこと',\n )\n assert.ok(\n existsSync(join(ctx.repoDir, '.agents', 'skills', SKILL_NAME, 'SKILL.md')),\n 'npx が新規作成した union ストアのファイルが残ること',\n )\n } finally {\n rmSync(ctx.repoDir, { recursive: true, force: true })\n rmSync(ctx.binDir, { recursive: true, force: true })\n }\n})\n\ntest('ケース17: .agents/skills/\u003cname> がリポジトリ外向き symlink の場合、npx を実行する前に' +\n '拒否して非ゼロ終了する(PR #412 第6巡 P0 の回帰)', () => {\n const ctx = setupRepo('symlink-scope-path')\n try {\n assert.throws(\n () => runScript(ctx),\n (err) => {\n assert.notEqual(err.status, 0, '非ゼロ終了すること(fail-closed)')\n const combined = `${err.stdout ?? ''}${err.stderr ?? ''}`\n assert.match(\n combined,\n /シンボリックリンク/,\n '許可先経路が symlink である旨のエラーメッセージが出ること',\n )\n return true\n },\n )\n\n // npx が実行されていないこと(実行されると argv ログが必ず書かれる)。\n // symlink 越しの書き込みはスコープ外検査に現れないため、実行前拒否だけが防御になる。\n assert.equal(\n existsSync(ctx.argvLogFile),\n false,\n 'npx が一度も実行されていないこと(lstat 検証が npx より前に走る)',\n )\n\n // symlink 自体は削除・置換されず、確認できる状態のまま残ること\n assert.equal(\n readlinkSync(join(ctx.repoDir, '.agents', 'skills', SKILL_NAME)),\n join(ctx.binDir, 'external-skill-store'),\n 'symlink は自動で置き換えられず残存すること(対処は人間の判断に委ねる)',\n )\n } finally {\n rmSync(ctx.repoDir, { recursive: true, force: true })\n rmSync(ctx.binDir, { recursive: true, force: true })\n }\n})\n\ntest('ケース18: .git/refs 配下への ref ファイル追加を全体シグネチャ比較で検出する' +\n '(PR #412 第6巡 P0 の回帰。refs を prune していた旧実装では検出不能)', () => {\n const ctx = setupRepo('git-refs-write')\n const injectedRef = join(ctx.repoDir, '.git', 'refs', 'heads', 'injected-evil')\n try {\n assert.throws(\n () => runScript(ctx),\n (err) => {\n assert.notEqual(err.status, 0, '非ゼロ終了すること(fail-closed)')\n const combined = `${err.stdout ?? ''}${err.stderr ?? ''}`\n assert.match(combined, /スコープ外/, 'スコープ外検出のエラーメッセージが出ること')\n assert.match(\n combined,\n /状態シグネチャ/,\n '.git 配下は porcelain に一切現れないため、シグネチャ不一致として検出された旨の' +\n '案内が出ること(refs が署名対象に含まれていることの検証)',\n )\n return true\n },\n )\n\n // スコープ内(skills-lock.json / .agents/skills/\u003cname>/)はリバートされ clean\n const lockDiff = sh(`git status --porcelain -- skills-lock.json`, ctx.repoDir).trim()\n assert.equal(lockDiff, '', 'skills-lock.json はリバートされ clean であること')\n const treeDiff = sh(\n `git status --porcelain -- \".agents/skills/${SKILL_NAME}/\"`,\n ctx.repoDir,\n ).trim()\n assert.equal(treeDiff, '', '.agents/skills/\u003cname>/ はリバートされ clean であること')\n\n // 追加された ref ファイルは自動リバートせず、確認できる状態のまま残す\n assert.ok(existsSync(injectedRef), '追加された ref ファイルは削除されず残存すること')\n } finally {\n rmSync(ctx.repoDir, { recursive: true, force: true })\n rmSync(ctx.binDir, { recursive: true, force: true })\n }\n})\n\ntest('ケース19: npx 異常終了時、npx 新規作成の .gitignore 対象ファイルは残置されず、' +\n '実行前から存在した ignored ファイルは保全される(Issue #413 + Bugbot Medium の回帰)', () => {\n const ctx = setupRepo('ignored-residue-on-failure')\n const ignoredResidue = join(ctx.repoDir, '.agents', 'skills', SKILL_NAME, 'debug.log')\n try {\n assert.throws(\n () => runScript(ctx),\n (err) => {\n assert.notEqual(err.status, 0, '非ゼロ終了すること(fail-closed)')\n const combined = `${err.stdout ?? ''}${err.stderr ?? ''}`\n assert.match(combined, /実行が失敗しました/, 'npx 失敗の警告が出ること')\n return true\n },\n )\n\n // 素の git clean -fd だと .gitignore 対象の debug.log だけがリバートをすり抜けて\n // 残る(npx 新規作成分はインベントリ突き合わせの個別削除が担う)\n assert.equal(\n existsSync(ignoredResidue),\n false,\n 'npx が新規作成した .gitignore 対象ファイルがリバートで削除されていること',\n )\n\n // 実行前から存在した ignored ファイルは実行前インベントリで保全される\n // (git clean -fdx だとここで巻き添え削除される。PR #412 Bugbot Medium 指摘)\n assert.equal(\n readFileSync(join(ctx.repoDir, '.agents', 'skills', SKILL_NAME, '.DS_Store'), 'utf8'),\n 'pre-existing finder metadata\\n',\n '実行前から存在した ignored ファイル(.DS_Store)が abort 時に保全されること',\n )\n\n // スコープ内(skills-lock.json / .agents/skills/\u003cname>/)はリバートされ clean\n const lockDiff = sh(`git status --porcelain -- skills-lock.json`, ctx.repoDir).trim()\n assert.equal(lockDiff, '', 'skills-lock.json はリバートされ clean であること')\n const treeDiff = sh(\n `git status --porcelain -- \".agents/skills/${SKILL_NAME}/\"`,\n ctx.repoDir,\n ).trim()\n assert.equal(treeDiff, '', '.agents/skills/\u003cname>/ はリバートされ clean であること')\n } finally {\n rmSync(ctx.repoDir, { recursive: true, force: true })\n rmSync(ctx.binDir, { recursive: true, force: true })\n }\n})\n\ntest('ケース20: npx が実行中に許可先を外向き symlink へ置換した場合、実行後再検証が拒否し、' +\n 'git clean がリンク先を削除しない(PR #412 第7巡 P0 の回帰。TOCTOU)', () => {\n const ctx = setupRepo('midrun-symlink-swap')\n const skillDirPath = join(ctx.repoDir, '.agents', 'skills', SKILL_NAME)\n try {\n assert.throws(\n () => runScript(ctx),\n (err) => {\n assert.notEqual(err.status, 0, '非ゼロ終了すること(fail-closed)')\n const combined = `${err.stdout ?? ''}${err.stderr ?? ''}`\n assert.match(\n combined,\n /実行後の再検証で .* がシンボリックリンクになっています/,\n '実行後再検証が symlink 置換を検出したエラーメッセージが出ること' +\n '(事前 lstat 検査は開始時点しか見ないため、この経路の防御は実行後再検証が担う)',\n )\n assert.match(\n combined,\n /手動確認/,\n 'リポジトリ外書き込みの可能性の手動確認が案内されること',\n )\n assert.match(\n combined,\n /ignored 削除・既存 ignored の復元は行いません/,\n 'symlink 検出時は許可先配下への削除系操作をスキップした旨が案内されること',\n )\n return true\n },\n )\n\n // リンク先(リポジトリ外)へ書かれたファイルが git clean 等で削除されていないこと\n // (symlink 越しの外部削除の防止。残置内容は人間の手動確認に委ねる)\n assert.equal(\n readFileSync(join(ctx.externalTargetDir, 'leaked.md'), 'utf8'),\n 'leaked outside repo\\n',\n 'symlink のリンク先(リポジトリ外)のファイルが削除・変更されず残ること',\n )\n\n // 置換された symlink 自体も自動で除去されず、確認できる状態のまま残ること\n assert.equal(\n readlinkSync(skillDirPath),\n ctx.externalTargetDir,\n '許可先を置換した symlink が自動除去されず残存すること(対処は人間の判断に委ねる)',\n )\n\n // skills-lock.json は checkout でリバートされ clean であること\n const lockDiff = sh(`git status --porcelain -- skills-lock.json`, ctx.repoDir).trim()\n assert.equal(lockDiff, '', 'skills-lock.json はリバートされ clean であること')\n } finally {\n rmSync(ctx.repoDir, { recursive: true, force: true })\n rmSync(ctx.binDir, { recursive: true, force: true })\n }\n})\n\ntest('ケース21: 初回インストールで npx が .agents 自体を外向き symlink として作成した場合、' +\n '実行後再検証が拒否する(PR #412 第7巡 P0 の回帰。omit/prune はシグネチャに現れない経路)', () => {\n const ctx = setupRepo('first-install-symlink-agents')\n try {\n assert.throws(\n () => runScript(ctx),\n (err) => {\n assert.notEqual(err.status, 0, '非ゼロ終了すること(fail-closed)')\n const combined = `${err.stdout ?? ''}${err.stderr ?? ''}`\n assert.match(\n combined,\n /実行後の再検証で \\.agents がシンボリックリンクになっています/,\n '実行後再検証が .agents の symlink 作成を検出したエラーメッセージが出ること' +\n '(初回インストールでは .agents が omit・許可先が prune のためシグネチャに' +\n '現れず、実行後再検証だけが防御になる)',\n )\n return true\n },\n )\n\n // リンク先(リポジトリ外)へ symlink 経由で書かれたファイルが削除されていないこと\n assert.ok(\n existsSync(join(ctx.externalTargetDir, 'skills', SKILL_NAME, 'SKILL.md')),\n 'symlink 経由でリンク先に書かれたファイルが削除されず残ること(手動確認に委ねる)',\n )\n\n // .agents の symlink 自体も自動で除去されず残ること\n assert.equal(\n readlinkSync(join(ctx.repoDir, '.agents')),\n ctx.externalTargetDir,\n 'npx が作成した .agents symlink が自動除去されず残存すること',\n )\n\n // skills-lock.json は checkout でリバートされ clean であること\n const lockDiff = sh(`git status --porcelain -- skills-lock.json`, ctx.repoDir).trim()\n assert.equal(lockDiff, '', 'skills-lock.json はリバートされ clean であること')\n } finally {\n rmSync(ctx.repoDir, { recursive: true, force: true })\n rmSync(ctx.binDir, { recursive: true, force: true })\n }\n})\n\ntest('ケース22: 許可先配下に実行前から symlink がある場合、npx を実行する前に拒否して' +\n '非ゼロ終了する(PR #412 第12巡 P0-1 の回帰)', () => {\n const ctx = setupRepo('preexisting-symlink-in-scope')\n const linkPath = join(ctx.repoDir, '.agents', 'skills', SKILL_NAME, 'evil-link')\n try {\n assert.throws(\n () => runScript(ctx),\n (err) => {\n assert.notEqual(err.status, 0, '非ゼロ終了すること(fail-closed)')\n const combined = `${err.stdout ?? ''}${err.stderr ?? ''}`\n assert.match(\n combined,\n /配下に既存のシンボリックリンク/,\n '許可先配下の symlink を検出したエラーメッセージが出ること',\n )\n assert.match(\n combined,\n /evil-link/,\n '検出された symlink のパスが列挙されること',\n )\n return true\n },\n )\n\n // npx が実行されていないこと(実行されると argv ログが必ず書かれる)。\n // 配下の symlink 越しの書き込みはどの前後比較にも現れないため、実行前拒否だけが\n // 防御になる。\n assert.equal(\n existsSync(ctx.argvLogFile),\n false,\n 'npx が一度も実行されていないこと(find -type l 走査が npx より前に走る)',\n )\n\n // symlink 自体は削除・置換されず、確認できる状態のまま残ること\n assert.equal(\n readlinkSync(linkPath),\n join(ctx.binDir, 'external-skill-store'),\n 'symlink は自動で置き換えられず残存すること(対処は人間の判断に委ねる)',\n )\n } finally {\n rmSync(ctx.repoDir, { recursive: true, force: true })\n rmSync(ctx.binDir, { recursive: true, force: true })\n }\n})\n\ntest('ケース23: npx が既存 ignored ファイルを上書きして正常終了した場合、成功経路で' +\n 'バックアップから復元されて同期は完走する(PR #412 第12巡 P0-2 の回帰)', () => {\n const ctx = setupRepo('ignored-overwrite-success')\n const dsStore = join(ctx.repoDir, '.agents', 'skills', SKILL_NAME, '.DS_Store')\n try {\n const out = runScript(ctx)\n assert.match(out, /更新完了/, '復元は警告扱いで、同期自体は完走すること')\n\n // 上書きされた既存 ignored ファイルが実行前バックアップの内容へ戻っていること\n // (許可先配下は署名から除外されるため、シグネチャ比較ではこの上書きを検出できず、\n // バックアップとの直接比較だけが検出・復元の手段になる)\n assert.equal(\n readFileSync(dsStore, 'utf8'),\n 'pre-existing finder metadata\\n',\n 'npx に上書きされた既存 ignored ファイルがバックアップから復元されること',\n )\n\n // スコープ内の正当な同期結果(SKILL.md の更新)は復元に巻き込まれず残ること\n assert.equal(\n readFileSync(join(ctx.repoDir, '.agents', 'skills', SKILL_NAME, 'SKILL.md'), 'utf8'),\n 'updated upstream content\\n',\n '同期本体の書き込みは復元の影響を受けないこと',\n )\n } finally {\n rmSync(ctx.repoDir, { recursive: true, force: true })\n rmSync(ctx.binDir, { recursive: true, force: true })\n }\n})\n\ntest('ケース24: npx が既存 ignored ファイルを上書きした後に失敗した場合、失敗経路の' +\n 'リバートでもバックアップから復元される(PR #412 第12巡 P0-2 の回帰)', () => {\n const ctx = setupRepo('ignored-overwrite-on-failure')\n const dsStore = join(ctx.repoDir, '.agents', 'skills', SKILL_NAME, '.DS_Store')\n try {\n assert.throws(\n () => runScript(ctx),\n (err) => {\n assert.notEqual(err.status, 0, '非ゼロ終了すること(fail-closed)')\n const combined = `${err.stdout ?? ''}${err.stderr ?? ''}`\n assert.match(combined, /実行が失敗しました/, 'npx 失敗の警告が出ること')\n return true\n },\n )\n\n // 失敗経路(revert_in_scope)でも既存 ignored ファイルが復元されること\n // (checkout / git clean / 新規 ignored 削除はいずれも既存 ignored に触れないため、\n // バックアップからの復元だけが破壊された内容を戻せる)\n assert.equal(\n readFileSync(dsStore, 'utf8'),\n 'pre-existing finder metadata\\n',\n '失敗経路でも既存 ignored ファイルがバックアップから復元されること',\n )\n\n // スコープ内(skills-lock.json / .agents/skills/\u003cname>/)はリバートされ clean\n const lockDiff = sh(`git status --porcelain -- skills-lock.json`, ctx.repoDir).trim()\n assert.equal(lockDiff, '', 'skills-lock.json はリバートされ clean であること')\n const treeDiff = sh(\n `git status --porcelain -- \".agents/skills/${SKILL_NAME}/\"`,\n ctx.repoDir,\n ).trim()\n assert.equal(treeDiff, '', '.agents/skills/\u003cname>/ はリバートされ clean であること')\n } finally {\n rmSync(ctx.repoDir, { recursive: true, force: true })\n rmSync(ctx.binDir, { recursive: true, force: true })\n }\n})\n\ntest('ケース25: npx が既存 ignored ファイルを削除した場合もバックアップから書き戻される' +\n '(PR #412 第12巡 P0-2 の回帰。削除も「変化」として復元対象)', () => {\n const ctx = setupRepo('ignored-delete-success')\n const dsStore = join(ctx.repoDir, '.agents', 'skills', SKILL_NAME, '.DS_Store')\n try {\n const out = runScript(ctx)\n assert.match(out, /更新完了/, '復元は警告扱いで、同期自体は完走すること')\n\n // 削除された既存 ignored ファイルがバックアップから書き戻されていること\n assert.ok(\n existsSync(dsStore),\n '削除された既存 ignored ファイルがバックアップから書き戻されること',\n )\n assert.equal(\n readFileSync(dsStore, 'utf8'),\n 'pre-existing finder metadata\\n',\n '書き戻された内容が実行前バックアップと一致すること',\n )\n } finally {\n rmSync(ctx.repoDir, { recursive: true, force: true })\n rmSync(ctx.binDir, { recursive: true, force: true })\n }\n})\n\ntest('ケース24b: npx 成功後に既存 ignored ファイルの復元自体が失敗した場合、' +\n 'スコープ内(skills-lock.json / .agents/skills/\u003cname>/)もリバートされる' +\n '(Bugbot Medium 指摘: revert_in_scope を呼ばずに exit 1 するだけだと、npx 成功分の' +\n '書き込みが worktree に残置され、次回実行時の実行前 clean ガードに引っかかって' +\n 'skip され続ける)', () => {\n const ctx = setupRepo('ignored-restore-failure-on-success')\n try {\n assert.throws(\n () => runScript(ctx),\n (err) => {\n assert.notEqual(err.status, 0, '非ゼロ終了すること(fail-closed)')\n const combined = `${err.stdout ?? ''}${err.stderr ?? ''}`\n assert.match(\n combined,\n /実行前から存在した ignored ファイルの復元に失敗しました/,\n 'restore_preexisting_ignored の失敗メッセージが出ること',\n )\n return true\n },\n )\n\n // npx 自体は成功しているため、revert_in_scope を呼ばないまま exit 1 すると\n // skills-lock.json / .agents/skills/\u003cname>/SKILL.md の更新が worktree に残る。\n // 修正後はスコープ内がリバートされ clean であること。\n const lockDiff = sh('git status --porcelain -- skills-lock.json', ctx.repoDir).trim()\n assert.equal(lockDiff, '', 'skills-lock.json はリバートされ clean であること')\n const treeDiff = sh(\n `git status --porcelain -- \".agents/skills/${SKILL_NAME}/\"`,\n ctx.repoDir,\n ).trim()\n assert.equal(treeDiff, '', '.agents/skills/\u003cname>/ はリバートされ clean であること')\n } finally {\n rmSync(ctx.repoDir, { recursive: true, force: true })\n rmSync(ctx.binDir, { recursive: true, force: true })\n }\n})\n\ntest('ケース26: npx が永続 lock(.git/config.lock)を残した場合、シグネチャ比較で検出する' +\n '(PR #412 codex P1 の回帰。.git/*.lock のワイルドカード prune では検出不能だった)', () => {\n const ctx = setupRepo('config-lock-residue')\n const configLock = join(ctx.repoDir, '.git', 'config.lock')\n try {\n assert.throws(\n () => runScript(ctx),\n (err) => {\n assert.notEqual(err.status, 0, '非ゼロ終了すること(fail-closed)')\n const combined = `${err.stdout ?? ''}${err.stderr ?? ''}`\n assert.match(combined, /スコープ外/, 'スコープ外検出のエラーメッセージが出ること')\n assert.match(\n combined,\n /状態シグネチャ/,\n '.git 配下は porcelain に現れないため、シグネチャ不一致として検出された旨の' +\n '案内が出ること(永続 lock が署名対象に含まれていることの検証)',\n )\n return true\n },\n )\n\n // スコープ内(skills-lock.json / .agents/skills/\u003cname>/)はリバートされ clean\n const lockDiff = sh(`git status --porcelain -- skills-lock.json`, ctx.repoDir).trim()\n assert.equal(lockDiff, '', 'skills-lock.json はリバートされ clean であること')\n const treeDiff = sh(\n `git status --porcelain -- \".agents/skills/${SKILL_NAME}/\"`,\n ctx.repoDir,\n ).trim()\n assert.equal(treeDiff, '', '.agents/skills/\u003cname>/ はリバートされ clean であること')\n\n // 残置された lock は自動削除せず、確認できる状態のまま残す\n assert.ok(existsSync(configLock), '.git/config.lock は削除されず残存すること')\n } finally {\n rmSync(ctx.repoDir, { recursive: true, force: true })\n rmSync(ctx.binDir, { recursive: true, force: true })\n }\n})\n\ntest('ケース27: npx が tracked ファイルへ skip-worktree ビットを立てた場合、index の' +\n '論理状態比較で検出する(PR #412 codex P1 の回帰。.git/index の prune の背後に隠れる)', () => {\n const ctx = setupRepo('skip-worktree-flip')\n try {\n assert.throws(\n () => runScript(ctx),\n (err) => {\n assert.notEqual(err.status, 0, '非ゼロ終了すること(fail-closed)')\n const combined = `${err.stdout ?? ''}${err.stderr ?? ''}`\n assert.match(\n combined,\n /スコープ外/,\n 'スコープ外検出のエラーメッセージが出ること' +\n '(skip-worktree は .git/index のみを変え、ツリー署名・porcelain の' +\n 'どちらにも現れないため、index の論理状態比較だけが検出できる)',\n )\n return true\n },\n )\n\n // skip-worktree ビットが実際に立っていること(検出対象が実在した証拠。\n // スコープ外の状態は自動リバートされないため、ビットは残存する)\n const tag = sh(`git ls-files -v -- \"${OUT_OF_SCOPE_FILE}\"`, ctx.repoDir).trim()\n assert.match(tag, /^S /, 'skip-worktree ビット(タグ S)が立ったまま残存すること')\n\n // スコープ内(skills-lock.json / .agents/skills/\u003cname>/)はリバートされ clean\n const lockDiff = sh(`git status --porcelain -- skills-lock.json`, ctx.repoDir).trim()\n assert.equal(lockDiff, '', 'skills-lock.json はリバートされ clean であること')\n const treeDiff = sh(\n `git status --porcelain -- \".agents/skills/${SKILL_NAME}/\"`,\n ctx.repoDir,\n ).trim()\n assert.equal(treeDiff, '', '.agents/skills/\u003cname>/ はリバートされ clean であること')\n } finally {\n rmSync(ctx.repoDir, { recursive: true, force: true })\n rmSync(ctx.binDir, { recursive: true, force: true })\n }\n})\n\ntest('ケース28: npx が実行中に許可先配下へ外向き symlink を作成した場合、実行後の再走査が' +\n '検出して許可先配下への削除系操作をスキップする(PR #412 Bugbot High の回帰。TOCTOU)', () => {\n const ctx = setupRepo('midrun-nested-symlink')\n const nestedLink = join(ctx.repoDir, '.agents', 'skills', SKILL_NAME, 'evil-link')\n try {\n assert.throws(\n () => runScript(ctx),\n (err) => {\n assert.notEqual(err.status, 0, '非ゼロ終了すること(fail-closed)')\n const combined = `${err.stdout ?? ''}${err.stderr ?? ''}`\n assert.match(\n combined,\n /実行中に許可先.*配下へシンボリックリンクを作成しました/,\n '実行後の find -type l 再走査が実行中作成の symlink を検出すること' +\n '(事前走査は 0 件を保証済みのため、事後に見つかる symlink は npx の作成物)',\n )\n assert.match(\n combined,\n /evil-link/,\n '検出された symlink のパスが列挙されること',\n )\n assert.match(\n combined,\n /ignored 削除・既存 ignored の復元は行いません/,\n '許可先配下への削除系操作をスキップした旨が案内されること',\n )\n assert.match(\n combined,\n /バックアップは .* に相対パス構造で残っています/,\n 'バックアップ dir の保全と所在が案内されること',\n )\n return true\n },\n )\n\n // リンク先(リポジトリ外)へ書かれたファイルが削除・変更されていないこと\n assert.equal(\n readFileSync(join(ctx.externalTargetDir, 'leaked.md'), 'utf8'),\n 'leaked via nested link\\n',\n 'symlink のリンク先(リポジトリ外)のファイルが削除・変更されず残ること',\n )\n\n // symlink 自体も自動除去されず、確認できる状態のまま残ること\n assert.equal(\n readlinkSync(nestedLink),\n ctx.externalTargetDir,\n '実行中に作成された symlink が自動除去されず残存すること(対処は人間の判断に委ねる)',\n )\n\n // skills-lock.json は checkout でリバートされ clean であること\n const lockDiff = sh(`git status --porcelain -- skills-lock.json`, ctx.repoDir).trim()\n assert.equal(lockDiff, '', 'skills-lock.json はリバートされ clean であること')\n } finally {\n rmSync(ctx.repoDir, { recursive: true, force: true })\n rmSync(ctx.binDir, { recursive: true, force: true })\n }\n})\n\ntest('ケース29: 復元先の中間ディレクトリが外向き symlink へ置換された場合、リポジトリ外に' +\n 'ディレクトリが作られない(PR #412 Bugbot Medium の回帰)', () => {\n const ctx = setupRepo('restore-parent-symlink-swap')\n try {\n assert.throws(\n () => runScript(ctx),\n (err) => {\n assert.notEqual(err.status, 0, '非ゼロ終了すること(fail-closed)')\n const combined = `${err.stdout ?? ''}${err.stderr ?? ''}`\n assert.match(\n combined,\n /実行中に許可先.*配下へシンボリックリンクを作成しました/,\n '中間ディレクトリの symlink 置換が実行後の再走査で検出されること',\n )\n return true\n },\n )\n\n // 旧実装では復元の os.makedirs が symlink を辿り、リンク先(リポジトリ外)に\n // sub/ を作ってしまっていた。保全経路(復元スキップ)+ make_parent_dirs の\n // 事前検証の双方により、リポジトリ外にはディレクトリもファイルも作られない。\n assert.equal(\n existsSync(join(ctx.externalTargetDir, 'sub')),\n false,\n 'リンク先(リポジトリ外)に中間ディレクトリが作成されないこと',\n )\n assert.equal(\n existsSync(join(ctx.externalTargetDir, 'sub', '.DS_Store')),\n false,\n 'リンク先(リポジトリ外)に復元ファイルが書き込まれないこと',\n )\n } finally {\n rmSync(ctx.repoDir, { recursive: true, force: true })\n rmSync(ctx.binDir, { recursive: true, force: true })\n }\n})\n\ntest('ケース30: npx が既存 ignored ファイルを中間ディレクトリごと削除した場合、復元が' +\n '中間ディレクトリを再作成して書き戻す(make_parent_dirs の正常系・非退行)', () => {\n const ctx = setupRepo('restore-parent-dir-deleted')\n const nestedDsStore = join(\n ctx.repoDir, '.agents', 'skills', SKILL_NAME, 'meta', 'sub', '.DS_Store',\n )\n try {\n const out = runScript(ctx)\n assert.match(out, /更新完了/, '復元は警告扱いで、同期自体は完走すること')\n\n // 削除された中間ディレクトリごと再作成され、内容がバックアップと一致すること\n // (1 階層ずつの lstat 付き os.mkdir が正当な復元を妨げないことの検証)\n assert.ok(\n existsSync(nestedDsStore),\n '中間ディレクトリごと削除された既存 ignored ファイルが書き戻されること',\n )\n assert.equal(\n readFileSync(nestedDsStore, 'utf8'),\n 'pre-existing nested finder metadata\\n',\n '書き戻された内容が実行前バックアップと一致すること',\n )\n } finally {\n rmSync(ctx.repoDir, { recursive: true, force: true })\n rmSync(ctx.binDir, { recursive: true, force: true })\n }\n})\n\ntest('ケース31: linked worktree(git worktree add)での実行は npx を呼ぶ前に拒否して' +\n '非ゼロ終了する(PR #412 codex P0 の回帰)', () => {\n const ctx = setupRepo('linked-worktree')\n const worktreeDir = join(ctx.binDir, 'linked-wt')\n try {\n // メイン worktree の HEAD から linked worktree を作る。skills-lock.json /\n // .agents ツリーはコミット済みのため checkout に含まれ、clean ガード・\n // 許可先経路の lstat 検証はいずれも通過し得る状態になる(worktree 検証\n // だけが拒否できるレイアウト)。\n sh(`git worktree add -q \"${worktreeDir}\" HEAD`, ctx.repoDir)\n assert.throws(\n () => runScript(ctx, worktreeDir),\n (err) => {\n assert.notEqual(err.status, 0, '非ゼロ終了すること(fail-closed)')\n const combined = `${err.stdout ?? ''}${err.stderr ?? ''}`\n assert.match(\n combined,\n /linked worktree/,\n 'linked worktree を拒否した旨のエラーメッセージが出ること',\n )\n assert.match(\n combined,\n /メイン worktree で実行し直して/,\n '復旧手順(メイン worktree での再実行)が案内されること',\n )\n return true\n },\n )\n\n // npx が実行されていないこと(実行されると argv ログが必ず書かれる)。\n // linked worktree では実 Git ディレクトリ(共有側の refs 等)が状態署名の\n // 対象外になり、npx による改変が前後比較に現れないため、実行前拒否だけが\n // 防御になる。\n assert.equal(\n existsSync(ctx.argvLogFile),\n false,\n 'npx が一度も実行されていないこと(worktree 検証が npx より前に走る)',\n )\n } finally {\n rmSync(ctx.repoDir, { recursive: true, force: true })\n rmSync(ctx.binDir, { recursive: true, force: true })\n }\n})\n\ntest('ケース32: separate-git-dir(.git が gitfile)での実行は npx を呼ぶ前に拒否して' +\n '非ゼロ終了する(PR #412 Bugbot High の回帰)', () => {\n const ctx = setupRepo('separate-git-dir')\n try {\n // git clone --separate-git-dir 相当の構成を再現する: .git 実体を作業ツリー外\n // (binDir 配下)へ移し、gitdir 参照の gitfile に差し替える。git-dir と\n // common-dir はどちらも外部ディレクトリを指して一致するため linked worktree\n // 検証は通過し、「作業ツリー直下の .git 実体ディレクトリ」検証だけが拒否できる。\n const externalGitDir = join(ctx.binDir, 'external-git-store')\n renameSync(join(ctx.repoDir, '.git'), externalGitDir)\n writeFileSync(join(ctx.repoDir, '.git'), `gitdir: ${externalGitDir}\\n`)\n assert.throws(\n () => runScript(ctx),\n (err) => {\n assert.notEqual(err.status, 0, '非ゼロ終了すること(fail-closed)')\n const combined = `${err.stdout ?? ''}${err.stderr ?? ''}`\n assert.match(\n combined,\n /作業ツリー直下の \\.git 実体ディレクトリであることを確認できません/,\n '実 Git ディレクトリの所在検証で拒否した旨のエラーメッセージが出ること',\n )\n assert.match(\n combined,\n /通常構成のメイン worktree で実行し直して/,\n '復旧手順(通常構成での再実行)が案内されること',\n )\n return true\n },\n )\n\n // npx が実行されていないこと(実行されると argv ログが必ず書かれる)。\n // 外部 Git ディレクトリは状態署名の走査対象に入らないため、実行前拒否だけが\n // 防御になる。\n assert.equal(\n existsSync(ctx.argvLogFile),\n false,\n 'npx が一度も実行されていないこと(所在検証が npx より前に走る)',\n )\n } finally {\n rmSync(ctx.repoDir, { recursive: true, force: true })\n rmSync(ctx.binDir, { recursive: true, force: true })\n }\n})\n\ntest('ケース33: .git が実体ディレクトリへの symlink の構成での実行は npx を呼ぶ前に' +\n '拒否して非ゼロ終了する(PR #412 Bugbot High の回帰)', () => {\n const ctx = setupRepo('git-dir-symlink')\n try {\n // .git 実体を作業ツリー外へ移し、.git をそこへの symlink に置換する。\n // git は symlink 経由でも動作し git-dir と common-dir は一致するため、\n // lstat 検証(symlink 拒否)だけがこのレイアウトを npx 実行前に拒否できる。\n const externalGitDir = join(ctx.binDir, 'external-git-store')\n renameSync(join(ctx.repoDir, '.git'), externalGitDir)\n symlinkSync(externalGitDir, join(ctx.repoDir, '.git'))\n assert.throws(\n () => runScript(ctx),\n (err) => {\n assert.notEqual(err.status, 0, '非ゼロ終了すること(fail-closed)')\n const combined = `${err.stdout ?? ''}${err.stderr ?? ''}`\n assert.match(\n combined,\n /作業ツリー直下の \\.git 実体ディレクトリであることを確認できません/,\n '実 Git ディレクトリの所在検証で拒否した旨のエラーメッセージが出ること',\n )\n assert.match(\n combined,\n /通常構成のメイン worktree で実行し直して/,\n '復旧手順(通常構成での再実行)が案内されること',\n )\n return true\n },\n )\n\n assert.equal(\n existsSync(ctx.argvLogFile),\n false,\n 'npx が一度も実行されていないこと(所在検証が npx より前に走る)',\n )\n } finally {\n rmSync(ctx.repoDir, { recursive: true, force: true })\n rmSync(ctx.binDir, { recursive: true, force: true })\n }\n})\n\ntest('ケース33b: 作業ツリールート以外のサブディレクトリから実行すると npx を呼ぶ前に' +\n '拒否して非ゼロ終了する(Bugbot Medium 指摘: GIT_DIR が \u003ctoplevel>/.git であることは' +\n '確認するが、cwd が toplevel であることは確認していなかったため、repo_state_signature の' +\n '走査起点(cwd)から .git が外れて npx による改変を検出できなくなっていた)', () => {\n const ctx = setupRepo('edit-only')\n const subDir = join(ctx.repoDir, '.agents')\n try {\n assert.throws(\n () => runScript(ctx, subDir),\n (err) => {\n assert.notEqual(err.status, 0, '非ゼロ終了すること(fail-closed)')\n const combined = `${err.stdout ?? ''}${err.stderr ?? ''}`\n assert.match(\n combined,\n /カレントディレクトリ.*が作業ツリールート.*と一致しません/,\n 'カレントディレクトリが作業ツリールートでない旨のエラーメッセージが出ること',\n )\n assert.match(\n combined,\n /作業ツリールートで実行し直して/,\n '復旧手順(作業ツリールートでの再実行)が案内されること',\n )\n return true\n },\n )\n\n // npx が実行されていないこと(実行されると argv ログが必ず書かれる)。\n // cwd 検証は許可先経路の lstat 検証より前、npx 実行前に走る。\n assert.equal(\n existsSync(ctx.argvLogFile),\n false,\n 'npx が一度も実行されていないこと(cwd 検証が npx より前に走る)',\n )\n } finally {\n rmSync(ctx.repoDir, { recursive: true, force: true })\n rmSync(ctx.binDir, { recursive: true, force: true })\n }\n})\n\ntest('ケース34: skills-lock.json が実行前から外向き symlink の場合は npx を呼ぶ前に' +\n '拒否して非ゼロ終了する(PR #412 codex P0 の回帰)', () => {\n const ctx = setupRepo('lockfile-symlink-preexisting')\n const externalLock = join(ctx.binDir, 'external-lock.json')\n try {\n const externalBefore = readFileSync(externalLock, 'utf8')\n assert.throws(\n () => runScript(ctx),\n (err) => {\n assert.notEqual(err.status, 0, '非ゼロ終了すること(fail-closed)')\n const combined = `${err.stdout ?? ''}${err.stderr ?? ''}`\n assert.match(\n combined,\n /skills-lock\\.json がシンボリックリンクです/,\n 'skills-lock.json の lstat 検証で拒否した旨のエラーメッセージが出ること',\n )\n assert.match(\n combined,\n /実体ファイルへ置き換えてから再実行して/,\n '復旧手順(実体ファイルへの置き換え)が案内されること',\n )\n return true\n },\n )\n\n // npx が実行されていないこと(実行されると argv ログが必ず書かれる)。\n // skills-lock.json は署名の prune・porcelain フィルタの双方から除外されるため、\n // symlink 越しのリンク先書き込みはどの検査にも現れず、実行前拒否だけが防御になる。\n assert.equal(\n existsSync(ctx.argvLogFile),\n false,\n 'npx が一度も実行されていないこと(lstat 検証が npx より前に走る)',\n )\n\n // リンク先(リポジトリ外)の実ファイルが変更されていないこと\n assert.equal(\n readFileSync(externalLock, 'utf8'),\n externalBefore,\n 'リンク先(リポジトリ外)のファイルが変更されないこと',\n )\n } finally {\n rmSync(ctx.repoDir, { recursive: true, force: true })\n rmSync(ctx.binDir, { recursive: true, force: true })\n }\n})\n\ntest('ケース35: npx が実行中に skills-lock.json を外向き symlink へ置換した場合、実行後の' +\n '再検証が検出し、リンク先へ書き込まずに regular file を復元する' +\n '(PR #412 codex P0 の回帰。TOCTOU)', () => {\n const ctx = setupRepo('midrun-lockfile-symlink-swap')\n const victimLock = join(ctx.externalTargetDir, 'victim-lock.json')\n const lockPath = join(ctx.repoDir, 'skills-lock.json')\n try {\n assert.throws(\n () => runScript(ctx),\n (err) => {\n assert.notEqual(err.status, 0, '非ゼロ終了すること(fail-closed)')\n const combined = `${err.stdout ?? ''}${err.stderr ?? ''}`\n assert.match(\n combined,\n /skills-lock\\.json がシンボリックリンクになっています/,\n '実行後の再検証(verify_lock_file_after_run)が実行中の置換を検出すること',\n )\n return true\n },\n )\n\n // リンク先(リポジトリ外)の実ファイルが書き換え・削除されていないこと\n // (素の git checkout はリンク先へ書き込み得るため、rm → checkout の順で復元する)\n assert.equal(\n readFileSync(victimLock, 'utf8'),\n 'external victim content\\n',\n 'symlink のリンク先(リポジトリ外)のファイルが書き換え・削除されないこと',\n )\n\n // skills-lock.json は symlink が除去され、index の内容が regular file として\n // 書き戻されていること\n assert.equal(\n lstatSync(lockPath).isSymbolicLink(),\n false,\n 'skills-lock.json の symlink が除去され regular file へ復元されること',\n )\n const lockDiff = sh(`git status --porcelain -- skills-lock.json`, ctx.repoDir).trim()\n assert.equal(lockDiff, '', 'skills-lock.json はリバートされ clean であること')\n\n // 許可先(.agents/skills/\u003cname>/)もリバートされ clean であること\n const treeDiff = sh(\n `git status --porcelain -- \".agents/skills/${SKILL_NAME}/\"`,\n ctx.repoDir,\n ).trim()\n assert.equal(treeDiff, '', '.agents/skills/\u003cname>/ はリバートされ clean であること')\n } finally {\n rmSync(ctx.repoDir, { recursive: true, force: true })\n rmSync(ctx.binDir, { recursive: true, force: true })\n }\n})\n\ntest('ケース36: python3 が PATH に無い場合は npx を呼ぶ前に拒否して非ゼロ終了する' +\n '(PR #412 codex P1 の回帰)', () => {\n const ctx = setupRepo('missing-python3')\n // python3 だけが欠けた PATH を組む: スクリプトが python3 チェックへ到達するまでに\n // 必要な実コマンド(bash: スタブ群の shebang `/usr/bin/env bash` が PATH から解決 /\n // jq: skills-lock.json の source 照合)のみを symlink した専用 dir を用意し、\n // PATH をスタブ dir + この dir に限定する(既存 PATH を含めると実 python3 が\n // 解決されてしまい、不在ケースを再現できない)。\n const restrictedBin = mkdtempSync(join(tmpdir(), 'sync-skills-lock-nopy-bin-'))\n try {\n for (const cmd of ['bash', 'jq']) {\n const real = execFileSync('bash', ['-c', `command -v ${cmd}`], { encoding: 'utf8' }).trim()\n symlinkSync(real, join(restrictedBin, cmd))\n }\n const env = {\n ...process.env,\n PATH: `${ctx.binDir}:${restrictedBin}`,\n TEST_NPX_SCENARIO: ctx.scenario,\n }\n assert.throws(\n () => execFileSync('bash', [SCRIPT_PATH, SKILL_NAME, SOURCE_REPO], {\n cwd: ctx.repoDir,\n env,\n encoding: 'utf8',\n }),\n (err) => {\n assert.notEqual(err.status, 0, '非ゼロ終了すること(fail-closed)')\n const combined = `${err.stdout ?? ''}${err.stderr ?? ''}`\n assert.match(\n combined,\n /python3 が見つかりません/,\n 'python3 不在を明示したエラーメッセージが出ること',\n )\n assert.match(\n combined,\n /導入してから再実行して/,\n '復旧手順(python3 の導入)が案内されること',\n )\n return true\n },\n )\n\n // npx が実行されていないこと(実行されると argv ログが必ず書かれる)。\n // python3 不在のまま npx が走ると、実行後の状態署名・復元処理が途中で\n // command-not-found になり、スコープ外検査が中途半端なまま停止するため、\n // 実行前拒否だけが防御になる。\n assert.equal(\n existsSync(ctx.argvLogFile),\n false,\n 'npx が一度も実行されていないこと(python3 チェックが npx より前に走る)',\n )\n } finally {\n rmSync(ctx.repoDir, { recursive: true, force: true })\n rmSync(ctx.binDir, { recursive: true, force: true })\n rmSync(restrictedBin, { recursive: true, force: true })\n }\n})\n","content_type":"text/javascript; charset=utf-8","language":"javascript","size":111630,"content_sha256":"430b3f87dde5926649b32e7b7e0ef46a1446bee07dae07779009321a95448c5e"},{"filename":"tests/version-pin.test.mjs","content":"// version-pin.test.mjs — Issue #380 の回帰テスト。\n//\n// `npx skills add` はバージョン未固定だと npx がレジストリの最新版を確認なしで\n// 即実行するため、`skills`(vercel-labs/skills)パッケージが乗っ取られた場合に\n// 任意コード実行の経路になる。この実行は Step 5 の差分確認・Step 6 のユーザー\n// 承認より前に走るため、source の Fandhe-AI 完全一致検証では防げない。\n//\n// 固定版の正は scripts/skills-lock-update.sh の SKILLS_CLI_VERSION 変数であり、\n// SKILL.md 側フェンスは同一リテラルを記載する契約(SKILL.md の\n// 「skills CLI のバージョン固定と更新手順」節参照)。このテストは:\n// 1. 両ファイルから固定版を抽出できること\n// 2. 両者が exact semver(X.Y.Z。dist-tag・レンジ禁止)であること\n// 3. 両者の値が完全一致すること(ドリフト検知)\n// 4. どちらのファイルにも「skills@\u003cversion>」を伴わない未固定の\n// `npx skills add` が残っていないこと\n// を検証する。\nimport { test } from 'node:test'\nimport assert from 'node:assert/strict'\nimport { readFileSync } from 'node:fs'\nimport { dirname, join } from 'node:path'\nimport { fileURLToPath } from 'node:url'\n\nconst SKILL_DIR = join(dirname(fileURLToPath(import.meta.url)), '..')\nconst SCRIPT_PATH = join(SKILL_DIR, 'scripts', 'skills-lock-update.sh')\nconst SKILL_MD_PATH = join(SKILL_DIR, 'SKILL.md')\n\nconst EXACT_SEMVER = /^[0-9]+\\.[0-9]+\\.[0-9]+$/\n\nfunction extractScriptVersion(content) {\n const m = content.match(/^readonly SKILLS_CLI_VERSION=\"([^\"]*)\"/m)\n return m ? m[1] : null\n}\n\nfunction extractSkillMdVersions(content) {\n // SKILL.md 内の `SKILLS_CLI_VERSION=\"X.Y.Z\"` 代入をすべて拾う(前提条件の\n // 言及・Step 4 フェンス・更新手順節など複数箇所に登場し得るため全件対象)。\n const matches = [...content.matchAll(/SKILLS_CLI_VERSION=\"([^\"]*)\"/g)]\n return matches.map((m) => m[1])\n}\n\ntest('scripts/skills-lock-update.sh は exact semver の SKILLS_CLI_VERSION を定義する', () => {\n const content = readFileSync(SCRIPT_PATH, 'utf8')\n const version = extractScriptVersion(content)\n assert.ok(version, 'SKILLS_CLI_VERSION の代入行が見つからない')\n assert.match(version, EXACT_SEMVER, `dist-tag・レンジ禁止: ${version}`)\n})\n\ntest('SKILL.md 内の SKILLS_CLI_VERSION 代入はすべて exact semver で、スクリプトと一致する', () => {\n const scriptVersion = extractScriptVersion(readFileSync(SCRIPT_PATH, 'utf8'))\n const skillMdVersions = extractSkillMdVersions(readFileSync(SKILL_MD_PATH, 'utf8'))\n assert.ok(skillMdVersions.length > 0, 'SKILL.md に SKILLS_CLI_VERSION 代入が見つからない')\n for (const v of skillMdVersions) {\n assert.match(v, EXACT_SEMVER, `dist-tag・レンジ禁止: ${v}`)\n assert.equal(v, scriptVersion, `SKILL.md (${v}) とスクリプト (${scriptVersion}) が不一致`)\n }\n})\n\n// 実際にシェルで実行されるコマンド行だけを対象にする。行頭(インデント許容)が\n// `npx` で始まる行に限定し、地の文・見出し・インラインコード引用中の「npx skills add」\n// 言及(バッククォート囲み等)を誤検出しないようにする。\nfunction extractExecLines(content) {\n return content.split('\\n').filter((line) => /^\\s*npx\\b/.test(line))\n}\n\ntest('scripts/skills-lock-update.sh に未固定の npx skills add が残っていない', () => {\n const content = readFileSync(SCRIPT_PATH, 'utf8')\n const execLines = extractExecLines(content).filter((line) => /\\bskills\\b/.test(line))\n assert.ok(execLines.length > 0, 'npx skills 実行行が見つからない(抽出ロジックの破損の可能性)')\n for (const line of execLines) {\n assert.match(line, /skills@\\$\\{SKILLS_CLI_VERSION\\}/, `未固定の npx 実行: ${line}`)\n }\n})\n\ntest('SKILL.md に未固定の npx skills add が残っていない', () => {\n const content = readFileSync(SKILL_MD_PATH, 'utf8')\n const execLines = extractExecLines(content).filter((line) => /\\bskills\\b/.test(line))\n assert.ok(execLines.length > 0, 'npx skills 実行行が見つからない(抽出ロジックの破損の可能性)')\n for (const line of execLines) {\n assert.match(line, /skills@\\$\\{SKILLS_CLI_VERSION\\}/, `未固定の npx 実行: ${line}`)\n }\n})\n\n// Issue #410: `npx skills add` の書き込みスコープを `.agents/skills/\u003cname>/` +\n// `skills-lock.json` に限定する一次防御。`--agent universal` を落として個別 agent\n// 指定や無指定へ退行すると、他エージェントツリー(`.claude/skills/` 等)へ書き込み\n// うる状態に静かに戻るため、両ファイルの npx 実行行を静的に検査する\n// (version pin と同型のドリフト検知。実行時の振る舞い検証は scope-guard.test.mjs)。\ntest('scripts/skills-lock-update.sh の npx skills 実行行は --agent universal を含む', () => {\n const content = readFileSync(SCRIPT_PATH, 'utf8')\n const execLines = extractExecLines(content).filter((line) => /\\bskills\\b/.test(line))\n assert.ok(execLines.length > 0, 'npx skills 実行行が見つからない(抽出ロジックの破損の可能性)')\n for (const line of execLines) {\n assert.match(line, /--agent universal/, `--agent universal を含まない npx 実行: ${line}`)\n }\n})\n\ntest('SKILL.md の npx skills 実行行は --agent universal を含む', () => {\n const content = readFileSync(SKILL_MD_PATH, 'utf8')\n const execLines = extractExecLines(content).filter((line) => /\\bskills\\b/.test(line))\n assert.ok(execLines.length > 0, 'npx skills 実行行が見つからない(抽出ロジックの破損の可能性)')\n for (const line of execLines) {\n assert.match(line, /--agent universal/, `--agent universal を含まない npx 実行: ${line}`)\n }\n})\n\ntest('scripts/skills-lock-update.sh は SKILLS_CLI_VERSION の形式ガードを持つ(fail-closed)', () => {\n const content = readFileSync(SCRIPT_PATH, 'utf8')\n assert.match(\n content,\n /SKILLS_CLI_VERSION.*=~.*\\^\\[0-9\\]/,\n '形式ガード(正規表現チェック)が見つからない'\n )\n})\n\n// #374 P1 R3(update-issue-tree の教訓): コードフェンスはそれぞれ独立シェルで\n// 実行され得るため、SKILLS_CLI_VERSION の代入が npx 実行行と別フェンスに\n// 分離されると、実行時に貼り付ける単位(npx 実行行を含むフェンス単体)では\n// 変数未定義(空文字列 = 未固定 skills@ への退行)になり得る。この退行は\n// 上の「未固定の npx skills add が残っていない」テストでは検知できない\n// (正規表現は文字列 `skills@${SKILLS_CLI_VERSION}` の存在だけを見ており、\n// その変数が同一フェンス内で定義済みかは見ていないため)。\n// このテストは SKILL.md の ```bash フェンスを個別に抽出し、npx 実行行を\n// 含むフェンスの中に、その行より前で SKILLS_CLI_VERSION= 代入があることを\n// 直接検証する。\nfunction extractBashFences(content) {\n const fences = []\n const re = /```bash\\n([\\s\\S]*?)```/g\n let m\n while ((m = re.exec(content)) !== null) {\n fences.push(m[1])\n }\n return fences\n}\n\ntest('SKILL.md: npx skills 実行行を含むフェンス自体に SKILLS_CLI_VERSION の代入が先行して存在する', () => {\n const content = readFileSync(SKILL_MD_PATH, 'utf8')\n const fences = extractBashFences(content)\n const execFences = fences.filter((fence) =>\n fence.split('\\n').some((line) => /^\\s*npx\\b/.test(line) && /\\bskills\\b/.test(line))\n )\n assert.ok(execFences.length > 0, 'npx skills 実行行を含むフェンスが見つからない')\n for (const fence of execFences) {\n const lines = fence.split('\\n')\n const execIdx = lines.findIndex((line) => /^\\s*npx\\b/.test(line) && /\\bskills\\b/.test(line))\n const assignIdx = lines.findIndex((line) => /^\\s*SKILLS_CLI_VERSION=/.test(line))\n assert.notEqual(\n assignIdx,\n -1,\n 'npx 実行行と同一フェンス内に SKILLS_CLI_VERSION= 代入が無い(フェンス分離による未定義変数の退行リスク)'\n )\n assert.ok(\n assignIdx \u003c execIdx,\n 'SKILLS_CLI_VERSION= 代入が npx 実行行より後にある(実行時に未定義になる)'\n )\n }\n})\n","content_type":"text/javascript; charset=utf-8","language":"javascript","size":8493,"content_sha256":"2d9393850bb208947f869125e19c943818cd49511eb1031cc6a200abe89e3ce3"}],"content_json":{"type":"doc","content":[{"type":"heading","attrs":{"level":1},"content":[{"text":"sync-skills-lock","type":"text"}]},{"type":"paragraph","content":[{"text":"ルート直下の ","type":"text"},{"text":"skills-lock.json","type":"text","marks":[{"type":"code_inline"}]},{"text":" の ","type":"text"},{"text":"computedHash","type":"text","marks":[{"type":"code_inline"}]},{"text":" を、upstream リポジトリの現状と照合して更新する。","type":"text"}]},{"type":"heading","attrs":{"level":2},"content":[{"text":"対象ファイル","type":"text"}]},{"type":"bullet_list","content":[{"type":"list_item","content":[{"type":"paragraph","content":[{"text":"ルート","type":"text","marks":[{"type":"strong"}]},{"text":": 呼び出し元リポジトリ直下の ","type":"text"},{"text":"skills-lock.json","type":"text","marks":[{"type":"code_inline"}]},{"text":" — このスキルが唯一編集するファイル","type":"text"}]}]},{"type":"list_item","content":[{"type":"paragraph","content":[{"text":"除外","type":"text","marks":[{"type":"strong"}]},{"text":": submodule 配下の ","type":"text"},{"text":"skills-lock.json","type":"text","marks":[{"type":"code_inline"}]},{"text":" は編集しない(submodule 境界を跨がないため)","type":"text"}]}]}]},{"type":"heading","attrs":{"level":2},"content":[{"text":"前提条件","type":"text"}]},{"type":"bullet_list","content":[{"type":"list_item","content":[{"type":"paragraph","content":[{"text":"gh","type":"text","marks":[{"type":"code_inline"}]},{"text":" CLI がインストールされ、認証済みであること","type":"text"}]}]},{"type":"list_item","content":[{"type":"paragraph","content":[{"text":"node","type":"text","marks":[{"type":"code_inline"}]},{"text":" / ","type":"text"},{"text":"npx","type":"text","marks":[{"type":"code_inline"}]},{"text":" が利用可能であること(","type":"text"},{"text":"npx skills add","type":"text","marks":[{"type":"code_inline"}]},{"text":" を使用するため)。","type":"text"},{"text":"skills","type":"text","marks":[{"type":"code_inline"}]},{"text":" CLI は固定版(","type":"text"},{"text":"SKILLS_CLI_VERSION","type":"text","marks":[{"type":"code_inline"}]},{"text":")で実行する。値と更新手順は「skills CLI のバージョン固定と更新手順」節を参照","type":"text"}]}]},{"type":"list_item","content":[{"type":"paragraph","content":[{"text":"python3","type":"text","marks":[{"type":"code_inline"}]},{"text":" が利用可能であること(状態署名〔","type":"text"},{"text":"path_state","type":"text","marks":[{"type":"code_inline"}]},{"text":" / ","type":"text"},{"text":"index_state_signature","type":"text","marks":[{"type":"code_inline"}]},{"text":"〕と復元処理〔既存 ignored ファイルの比較・復元等〕で使用。主要 Linux / macOS には標準搭載されている。無い環境では Step 4 フェンスが npx 実行前に fail-closed で停止するため、導入してから再実行すること)","type":"text"}]}]},{"type":"list_item","content":[{"type":"paragraph","content":[{"text":"file","type":"text","marks":[{"type":"code_inline"}]},{"text":" CLI が利用可能であること(未追跡バイナリファイルの種別表示に使用。未導入の環境では 種別が ","type":"text"},{"text":"file コマンド未検出","type":"text","marks":[{"type":"code_inline"}]},{"text":" として表示され、承認前にサイズ・git blob ハッシュのみで 判断することになる。macOS / 主要 Linux ディストリビューションには標準搭載されている)","type":"text"}]}]},{"type":"list_item","content":[{"type":"paragraph","content":[{"text":"ルート直下の ","type":"text"},{"text":"skills-lock.json","type":"text","marks":[{"type":"code_inline"}]},{"text":" が存在すること","type":"text"}]}]},{"type":"list_item","content":[{"type":"paragraph","content":[{"text":"実行前に ","type":"text","marks":[{"type":"strong"}]},{"text":"skills-lock.json","type":"text","marks":[{"type":"code_inline"},{"type":"strong"}]},{"text":" に未コミットの変更がないこと","type":"text","marks":[{"type":"strong"}]},{"text":"(ステージ済み・未ステージ問わず)。本スキルの実行中に発生する変更は sync 由来のみとなり、","type":"text"},{"text":"git add skills-lock.json","type":"text","marks":[{"type":"code_inline"}]},{"text":" で全体をステージしても無関係な変更が混入しない","type":"text"}]}]},{"type":"list_item","content":[{"type":"paragraph","content":[{"text":"対象スキルの ","type":"text","marks":[{"type":"strong"}]},{"text":".agents/skills/\u003cname>/","type":"text","marks":[{"type":"code_inline"},{"type":"strong"}]},{"text":" に未コミット変更がないこと","type":"text","marks":[{"type":"strong"}]},{"text":"。","type":"text"},{"text":"npx skills add","type":"text","marks":[{"type":"code_inline"}]},{"text":" は ","type":"text"},{"text":".agents/skills/\u003cname>/","type":"text","marks":[{"type":"code_inline"}]},{"text":" を upstream の最新版で上書きするため、そのディレクトリに WIP が存在すると即座に失われる。","type":"text"},{"text":"git checkout","type":"text","marks":[{"type":"code_inline"}]},{"text":" で戻せるのは「最後にコミットされた状態」のみであり、npx 実行前の未コミット編集は復元できない。","type":"text"},{"text":"未追跡ファイルとして存在する WIP も対象","type":"text","marks":[{"type":"strong"}]},{"text":"であり、","type":"text"},{"text":"git status --porcelain","type":"text","marks":[{"type":"code_inline"}]},{"text":" で検出する","type":"text"}]}]},{"type":"list_item","content":[{"type":"paragraph","content":[{"text":"消費側リポジトリが commit 済み local patch を持つ場合","type":"text","marks":[{"type":"strong"}]},{"text":": vendored skill(","type":"text"},{"text":".agents/skills/","type":"text","marks":[{"type":"code_inline"}]},{"text":" 配下)へ commit 済みの local patch を適用しているリポジトリは、その検証・再適用の入口として repository-owned checker ","type":"text"},{"text":"scripts/check-skill-local-patches.sh","type":"text","marks":[{"type":"code_inline"}]},{"text":"(無引数 = check / ","type":"text"},{"text":"apply","type":"text","marks":[{"type":"code_inline"}]},{"text":" の 2 モード)と台帳 ","type":"text"},{"text":".agents/skills/LOCAL-PATCHES.md","type":"text","marks":[{"type":"code_inline"}]},{"text":" を持つ。commit 済み patch は上記 clean ガードでは保護できないため、checker が存在する場合は同期の前後(Step 4 の pre-check・Step 5.5 の apply + 最終検証)での成功が必須(非 0 は fail-closed で同期・stage しない)。台帳があるのに checker が無い状態も検証不能として fail-closed で停止する。checker(apply)の書き込み先は当該スキルディレクトリ・","type":"text"},{"text":"skills-lock.json","type":"text","marks":[{"type":"code_inline"}]},{"text":"・durable patch 置き場 ","type":"text"},{"text":"scripts/local-patches/","type":"text","marks":[{"type":"code_inline"}]},{"text":" に限る契約とし、範囲外の変更は各実行直後の digest 比較で fail-closed に検出する(検出範囲は Git が追跡・列挙する対象に限る best-effort であり、書き込み制限の保証ではない。保証はユーザーによる checker 内容レビュー + blob hash 承認が担う。詳細は Step 4 の「検出範囲の限界」コメント)。checker は消費側が配置する実行可能コードのため「存在するだけ」では実行せず、symlink ではない regular file(HEAD 側 mode も 100644/100755)であり、HEAD に commit 済みで worktree と一致し、かつユーザーへ由来・内容を提示して blob hash 単位の明示承認を得た場合のみ実行する(Step 4 で機械検証)。実行対象は worktree のファイルではなく承認済み HEAD blob を取り出した一時ファイル(","type":"text"},{"text":"CHECKER_EXEC","type":"text","marks":[{"type":"code_inline"}]},{"text":")とし、hash 確認後に worktree の checker を差し替える TOCTOU 経路を断つ","type":"text"}]}]},{"type":"list_item","content":[{"type":"paragraph","content":[{"text":"通常構成のメイン worktree で実行すること","type":"text","marks":[{"type":"strong"}]},{"text":"。linked worktree(","type":"text"},{"text":"git worktree add","type":"text","marks":[{"type":"code_inline"}]},{"text":" で作られた作業ツリー)では ","type":"text"},{"text":".git","type":"text","marks":[{"type":"code_inline"}]},{"text":" が gitdir を指す通常ファイルになり、実 Git ディレクトリ(","type":"text"},{"text":".git/worktrees/\u003cname>/","type":"text","marks":[{"type":"code_inline"}]},{"text":" と共有側の ","type":"text"},{"text":"refs","type":"text","marks":[{"type":"code_inline"}]},{"text":"・","type":"text"},{"text":"logs","type":"text","marks":[{"type":"code_inline"}]},{"text":"・","type":"text"},{"text":"config","type":"text","marks":[{"type":"code_inline"}]},{"text":"・objects)が状態署名の対象外になるため、Step 4 フェンスが npx 実行前に ","type":"text"},{"text":"git rev-parse --absolute-git-dir","type":"text","marks":[{"type":"code_inline"}]},{"text":" / ","type":"text"},{"text":"--git-common-dir","type":"text","marks":[{"type":"code_inline"}]},{"text":" の不一致で検出して fail-closed で拒否する。同様に、実 Git ディレクトリが作業ツリー外にある構成(","type":"text"},{"text":"git clone --separate-git-dir","type":"text","marks":[{"type":"code_inline"}]},{"text":"・submodule checkout・","type":"text"},{"text":".git","type":"text","marks":[{"type":"code_inline"}]},{"text":" が symlink)も、「実 Git ディレクトリ = 作業ツリー直下の ","type":"text"},{"text":".git","type":"text","marks":[{"type":"code_inline"}]},{"text":" 実体ディレクトリ」の検証(","type":"text"},{"text":"--show-toplevel","type":"text","marks":[{"type":"code_inline"}]},{"text":" との厳密一致 + lstat)で npx 実行前に fail-closed で拒否する","type":"text"}]}]}]},{"type":"heading","attrs":{"level":2},"content":[{"text":"フロー","type":"text"}]},{"type":"heading","attrs":{"level":3},"content":[{"text":"Step 1: 引数を確認し、事前条件を検証する","type":"text"}]},{"type":"code_block","attrs":{"wrap":false,"language":"bash"},"content":[{"text":"TARGET=\"$ARGUMENTS\" # 空なら全スキル対象\n\n# 引数指定時は kebab-case のみ許可(パストラバーサル防止)\nif [[ -n \"${TARGET}\" && ! \"${TARGET}\" =~ ^[a-z][a-z0-9-]+$ ]]; then\n echo \"エラー: スキル名は小文字 kebab-case のみ許可されています: ${TARGET}\"\n exit 1\nfi","type":"text"}]},{"type":"paragraph","content":[{"text":"引数ありの場合は該当スキルのみ処理、なしの場合は ","type":"text"},{"text":"skills-lock.json","type":"text","marks":[{"type":"code_inline"}]},{"text":" の全エントリを対象にする。","type":"text"}]},{"type":"paragraph","content":[{"text":"次に ","type":"text"},{"text":"skills-lock.json","type":"text","marks":[{"type":"code_inline"}]},{"text":" の clean 状態を確認する。未コミット変更(ステージ済み・未ステージ問わず)があれば中止する。","type":"text"}]},{"type":"code_block","attrs":{"wrap":false,"language":"bash"},"content":[{"text":"# skills-lock.json に未コミット変更があれば中止(sync 由来以外の変更の混入を防ぐ)\n# git diff 系は untracked を検出しないため porcelain を使う\nif [[ -n \"$(git status --porcelain -- skills-lock.json)\" ]]; then\n echo \"エラー: skills-lock.json に未コミットの変更があります。コミットまたは退避してから再実行してください。\"\n exit 1\nfi","type":"text"}]},{"type":"heading","attrs":{"level":3},"content":[{"text":"Step 2: upstream 一覧を集計する","type":"text"}]},{"type":"paragraph","content":[{"text":"skills-lock.json","type":"text","marks":[{"type":"code_inline"}]},{"text":" を読み、","type":"text"},{"text":"source","type":"text","marks":[{"type":"code_inline"}]},{"text":" フィールドごとにスキルをグルーピングする(同一リポへの処理を 1 回にまとめるため)。","type":"text"}]},{"type":"code_block","attrs":{"wrap":false,"language":""},"content":[{"text":"Fandhe-AI/agent-cli-skills:\n - create-commit\n - create-issue\n - ...","type":"text"}]},{"type":"heading","attrs":{"level":3},"content":[{"text":"Step 3: source を検証する","type":"text"}]},{"type":"paragraph","content":[{"text":"安全弁","type":"text","marks":[{"type":"strong"}]},{"text":": 処理前に必ず ","type":"text"},{"text":"source","type":"text","marks":[{"type":"code_inline"}]},{"text":" フィールドが ","type":"text"},{"text":"Fandhe-AI/\u003crepo>","type":"text","marks":[{"type":"code_inline"}]},{"text":" に完全一致することを確認する。前方一致では ","type":"text"},{"text":"../","type":"text","marks":[{"type":"code_inline"}]},{"text":" を含む値が通過し、clone 時の URL パス正規化で組織外リポジトリを対象にできてしまうため、","type":"text"},{"text":"OWNER/REPO","type":"text","marks":[{"type":"code_inline"}]},{"text":" へ正規化後に厳密な正規表現で検証する。想定外の source は skip してユーザーに警告する。","type":"text"},{"text":"skills-lock.json","type":"text","marks":[{"type":"code_inline"}]},{"text":" の改ざん・誤設定によって untrusted リポジトリから clone することを防ぐためである。","type":"text"}]},{"type":"code_block","attrs":{"wrap":false,"language":"bash"},"content":[{"text":"REPO_SLUG=\"${SOURCE#https://github.com/}\"\nREPO_SLUG=\"${REPO_SLUG%.git}\"\nif [[ ! \"$REPO_SLUG\" =~ ^Fandhe-AI/[A-Za-z0-9._-]+$ ]] \\\n || [[ \"$REPO_SLUG\" == \"Fandhe-AI/.\" || \"$REPO_SLUG\" == \"Fandhe-AI/..\" ]]; then\n echo \"警告: 想定外の source: $SOURCE — このスキルは skip します\"\n continue\nfi","type":"text"}]},{"type":"heading","attrs":{"level":3},"content":[{"text":"Step 4–7: 対象スキルを1つずつ処理する(ループ)","type":"text"}]},{"type":"paragraph","content":[{"text":"対象スキルそれぞれについて、次の 4→5→6→7 を順に実行し、","type":"text"},{"text":"1スキル完了後に次スキルへ進む","type":"text","marks":[{"type":"strong"}]},{"text":"。全スキル sync であっても同時に複数スキルを処理せず、1スキルずつ完結させること。","type":"text"}]},{"type":"heading","attrs":{"level":4},"content":[{"text":"Step 4: npx skills add で computedHash を更新する","type":"text"}]},{"type":"paragraph","content":[{"text":"sha256sum","type":"text","marks":[{"type":"code_inline"}]},{"text":" などで手動計算するのではなく、","type":"text"},{"text":"npx skills add","type":"text","marks":[{"type":"code_inline"}]},{"text":" に計算を任せる。これにより CLI の内部アルゴリズムと完全に一致する。","type":"text"}]},{"type":"code_block","attrs":{"wrap":false,"language":"bash"},"content":[{"text":"# 当該スキルの install ツリーに未コミット変更があれば npx が上書きするため skip\n# git diff 系は untracked を検出しないため porcelain を使う(未追跡 WIP も保護対象)\nif [[ -n \"$(git status --porcelain -- \".agents/skills/${SKILL_NAME}/\")\" ]]; then\n echo \"警告: .agents/skills/${SKILL_NAME}/ に未コミット変更(未追跡含む)があります。npx の上書きで失われるため skip します。\"\n continue\nfi\n\n# python3 の存在確認(PR #412 codex P1 指摘): 状態署名(path_state /\n# index_state_signature)と復元処理(restore_preexisting_ignored 等)が python3 を\n# 必須実行する。無いまま進むと npx 実行後の検査・復元の途中で command-not-found に\n# なり、スコープ外検査が中途半端なまま停止する(fail-open 経路)。python3 の不在は\n# スキル単位の事情ではなく環境全体の条件のため、skip(continue)ではなくループ全体を\n# npx 実行前に exit 1 で停止する(fail-closed。この時点では npx 未実行のため残置なし)。\nif ! command -v python3 >/dev/null 2>&1; then\n echo \"エラー: python3 が見つかりません。状態署名・復元処理で python3 が必要です。導入してから再実行してください(fail-closed)。\" >&2\n exit 1\nfi\n\n# linked worktree の実行拒否(PR #412 codex P0 指摘): linked worktree\n# (git worktree add で作られた作業ツリー)では .git が gitdir を指す通常ファイルで、\n# 実 Git ディレクトリ(.git/worktrees/\u003cname>/ と common dir 側の refs・logs・config・\n# objects)は作業ツリーの外にある。repo_state_signature の走査(path_state .)は\n# 作業ツリー内しか対象にせず、index_state_signature も index の論理状態しか補わない\n# ため、npx が git update-ref 等で共有リポジトリの参照・設定を改変しても porcelain・\n# ツリー署名・index 署名のすべてが前後一致し成功扱いになる。メイン worktree でのみ\n# 成立する「--absolute-git-dir と --git-common-dir の一致」を一次根拠に判定し、\n# 不一致(linked worktree のほか、実 Git ディレクトリが署名対象に収まらない\n# submodule 等の変則構成すべて)は npx 未実行のまま fail-closed で拒否する。\n# --git-common-dir は相対パスを返し得る(--path-format=absolute は git 2.31+ のため\n# 使わない)ので、cd + pwd -P で双方を物理パスへ正規化してから比較する。\n# rev-parse・正規化の失敗も「メイン worktree と確認できない」であって\n# 「メインである」ではないため中止する。実行場所の異常はスキル単位の事情ではなく\n# リポジトリ全体の条件のため、skip(continue)ではなくループ全体を exit 1 で\n# 停止する(この時点では npx 未実行のため残置なし)。\nif ! GIT_DIR_ABS=\"$(git rev-parse --absolute-git-dir)\" \\\n || ! GIT_COMMON_DIR_RAW=\"$(git rev-parse --git-common-dir)\" \\\n || ! GIT_DIR_PHYS=\"$(cd \"${GIT_DIR_ABS}\" && pwd -P)\" \\\n || ! GIT_COMMON_DIR_PHYS=\"$(cd \"${GIT_COMMON_DIR_RAW}\" && pwd -P)\"; then\n echo \"エラー: Git ディレクトリの解決(git rev-parse --absolute-git-dir / --git-common-dir)に失敗しました。メイン worktree での実行と確認できないため中止します(fail-closed)。\" >&2\n exit 1\nfi\nif [[ \"${GIT_DIR_PHYS}\" != \"${GIT_COMMON_DIR_PHYS}\" ]]; then\n echo \"エラー: linked worktree(git worktree add で作られた作業ツリー)では実 Git ディレクトリ(.git/worktrees/\u003cname>/ と共有側の refs・logs・config・objects)が状態署名の対象外になり、npx による共有リポジトリの改変を検出できないため実行を拒否します(fail-closed)。メイン worktree で実行し直してください。\" >&2\n exit 1\nfi\n\n# git-dir と common-dir の一致は linked worktree を除外するだけで、「実 Git\n# ディレクトリが署名対象の作業ツリー内にある」ことまでは保証しない\n# (PR #412 Bugbot High 指摘)。git clone --separate-git-dir(.git が gitdir を\n# 指す gitfile)・submodule checkout(.git/modules/\u003cname>/ を指す gitfile)・\n# .git が symlink の構成では両者が一致したまま実体が作業ツリー外にあり、\n# path_state の走査(起点 .)が refs・hooks・objects を含まないため、npx の\n# 改変が porcelain・ツリー署名・index 署名のすべてをすり抜ける。そこで実 Git\n# ディレクトリが「作業ツリー直下の .git 実体ディレクトリ」であることまで検証\n# する: toplevel を上と同じ手法(cd + pwd -P)で物理パスへ正規化し、\n# (1) GIT_DIR_PHYS が \u003ctoplevel>/.git と厳密一致、(2) \u003ctoplevel>/.git を lstat\n# して symlink ではなく directory であること(gitfile = 通常ファイル・symlink\n# はいずれも拒否)、の両方を要求する。不成立は npx 未実行のまま中止する。\nif ! TOPLEVEL_RAW=\"$(git rev-parse --show-toplevel)\" \\\n || ! TOPLEVEL_PHYS=\"$(cd \"${TOPLEVEL_RAW}\" && pwd -P)\"; then\n echo \"エラー: 作業ツリールートの解決(git rev-parse --show-toplevel)に失敗しました。実 Git ディレクトリが作業ツリー内の .git ディレクトリであることを確認できないため中止します(fail-closed)。\" >&2\n exit 1\nfi\nif [[ \"${GIT_DIR_PHYS}\" != \"${TOPLEVEL_PHYS}/.git\" ]] \\\n || [[ -L \"${TOPLEVEL_PHYS}/.git\" ]] \\\n || [[ ! -d \"${TOPLEVEL_PHYS}/.git\" ]]; then\n echo \"エラー: 実 Git ディレクトリが作業ツリー直下の .git 実体ディレクトリであることを確認できません(git clone --separate-git-dir・submodule・.git の symlink 等)。実 Git ディレクトリが状態署名の対象外になり、npx による改変を検出できないため実行を拒否します(fail-closed)。通常構成のメイン worktree で実行し直してください。\" >&2\n exit 1\nfi\n\n# カレントディレクトリが作業ツリールートであることの検証(Bugbot Medium 指摘):\n# 上のブロックは GIT_DIR が \u003ctoplevel>/.git であることまでしか確認しない。\n# repo_state_signature は path_state の起点を「.」(cwd)にしているため、\n# サブディレクトリから実行すると .git(hooks・refs・config・objects)が起点の\n# 走査対象から外れ、上の worktree 検証をすべて満たしたまま npx による .git 改変を\n# 検出できなくなる。pwd -P を toplevel と同じ手法で物理パス化し、厳密一致しない\n# 場合は npx 未実行のまま中止する。\nif ! CWD_PHYS=\"$(pwd -P)\"; then\n echo \"エラー: カレントディレクトリの物理パス解決(pwd -P)に失敗しました。作業ツリールートでの実行と確認できないため中止します(fail-closed)。\" >&2\n exit 1\nfi\nif [[ \"${CWD_PHYS}\" != \"${TOPLEVEL_PHYS}\" ]]; then\n echo \"エラー: カレントディレクトリ(${CWD_PHYS})が作業ツリールート(${TOPLEVEL_PHYS})と一致しません。repo_state_signature はカレントディレクトリを起点に走査するため、サブディレクトリから実行すると .git(hooks・refs・config・objects)が署名対象外になり npx による改変を検出できません。作業ツリールートで実行し直してください(fail-closed)。\" >&2\n exit 1\nfi\n\n# 許可先経路の実体検証(PR #412 P0 指摘): スコープ外検査(porcelain 比較・状態\n# シグネチャ比較)は .agents/skills/${SKILL_NAME} を「パス文字列」で走査除外する。\n# この経路上のいずれかの要素が実行前から symlink だと、npx がリンク先(リポジトリ外を\n# 含む)へ書いた内容は除外側に吸われてどの検査にも現れない。そのため npx 実行前に\n# 各要素を lstat し、存在するものはすべて実体のディレクトリであることを要求する\n# (symlink・非ディレクトリは fail-closed で中止。npx は実行しない)。存在しない\n# 要素のみ、初回インストールで npx が正当に新規作成するケースとして許容する\n# (後段の REPO_SIG_OMITS の条件付き omit と同じ判定基準)。symlink 化された経路は\n# レイアウト自体の異常であり人間の確認を要するため、skip(continue)ではなく\n# ループ全体を exit 1 で停止する(この時点では npx 未実行のため残置なし)。\n# この検査は開始時点のレイアウトのみを保証する(TOCTOU)。npx が実行中に許可先を\n# 置換するケースは、許可先要素自身の署名(SKILL_DIR_SIG_SPEC の prune-under)と\n# 実行後の再検証(verify_scope_path_after_run)が受け持つ。\nfor SCOPE_PATH_COMPONENT in \".agents\" \".agents/skills\" \".agents/skills/${SKILL_NAME}\"; do\n if [[ -L \"${SCOPE_PATH_COMPONENT}\" ]]; then\n echo \"エラー: ${SCOPE_PATH_COMPONENT} がシンボリックリンクです。npx の書き込みがリンク先(リポジトリ外を含む)へ向かい、スコープ外書き込み検査で検出できないため中止します(fail-closed)。実体ディレクトリへ置き換えてから再実行してください。\" >&2\n exit 1\n fi\n if [[ -e \"${SCOPE_PATH_COMPONENT}\" && ! -d \"${SCOPE_PATH_COMPONENT}\" ]]; then\n echo \"エラー: ${SCOPE_PATH_COMPONENT} がディレクトリではありません。npx の書き込み先として想定外の実体のため中止します(fail-closed)。\" >&2\n exit 1\n fi\ndone\n\n# skills-lock.json の実体検証(PR #412 codex P0 指摘): skills-lock.json は状態\n# シグネチャの prune と porcelain フィルタの双方で「パス文字列」により除外されるため、\n# 実行前から外向き symlink だと npx がリンク先(リポジトリ外を含む)へ書き込んでも\n# どの検査にも現れない。存在する場合は lstat で regular file であることを要求する\n# (symlink・ディレクトリ等は npx 未実行のまま fail-closed で中止)。不存在は初回\n# 生成として許容する。-f は symlink を辿るため、-L 判定で symlink を先に排除する。\n# レイアウト自体の異常で人間の確認を要するため、skip(continue)ではなくループ全体を\n# exit 1 で停止する。この検査も開始時点のみを保証する(TOCTOU)。npx が実行中に\n# 置換するケースは実行後の再検証(verify_lock_file_after_run)が受け持つ。\nif [[ -L \"skills-lock.json\" ]]; then\n echo \"エラー: skills-lock.json がシンボリックリンクです。npx の書き込みがリンク先(リポジトリ外を含む)へ向かい、スコープ外書き込み検査で検出できないため中止します(fail-closed)。実体ファイルへ置き換えてから再実行してください。\" >&2\n exit 1\nfi\nif [[ -e \"skills-lock.json\" && ! -f \"skills-lock.json\" ]]; then\n echo \"エラー: skills-lock.json が regular file ではありません。npx の書き込み先として想定外の実体のため中止します(fail-closed)。\" >&2\n exit 1\nfi\n\n# 消費側リポジトリが vendored skill へ commit 済み local patch を適用している場合\n# (台帳: .agents/skills/LOCAL-PATCHES.md)、commit 済み patch は上の clean ガードを\n# 通過してしまうため、npx より前に repository-owned checker(check mode)を必須にする。\n# checker 非 0、および台帳があるのに checker が無い状態は、fail-closed で npx を\n# 実行しない(同期を開始しない)。LOCAL_PATCH_GUARD は checker の存在確認より後の\n# 全失敗経路で restore_contract_scope 呼び出しの条件に使う sticky フラグであり、\n# scripts/skills-lock-update.sh と同じ変数名・同じ用途で揃える(Bugbot Medium 指摘:\n# `-f scripts/check-skill-local-patches.sh` を毎回再チェックすると、npx や checker\n# 自身がそのパスを消した場合に「checker は存在した(このフラグは true のまま)」を\n# 見失い、契約範囲の復元が働かなくなる)。\nLOCAL_PATCH_GUARD=false\nif [[ -f scripts/check-skill-local-patches.sh ]]; then\n LOCAL_PATCH_GUARD=true\n # checker は導入先リポジトリが配置する実行可能コードであり、「存在するだけ」で実行しては\n # ならない(未信頼な checkout・未レビュー PR の任意コードが、差分提示・承認より前に\n # ユーザー権限で走る経路になる)。実行前に次のすべてを満たすことを確認する(fail-closed):\n # (1) worktree の checker が symlink ではない regular file である(git hash-object は\n # symlink のリンク先内容を読むため、-L を先に拒否しないと「HEAD と同内容の外部\n # ファイルへの symlink」が (2) の一致検証をすり抜ける)\n # (2) HEAD に commit 済みで、worktree の内容が HEAD の blob と一致する\n # (未追跡・未コミット変更の checker は拒否 = レビューを経ていないコードを実行しない)\n # (3) HEAD 側のエントリ mode が 100644 / 100755 の regular file である\n # (120000 = symlink エントリの blob はリンク先文字列であり、実行対象にできない)\n # (4) ユーザーへ由来と内容を提示し、この blob hash に対する実行の明示承認を得ている\n # (承認は hash 単位で本フロー全体に有効。内容が変われば再承認。\n # 提示: git log -1 -- scripts/check-skill-local-patches.sh /\n # git show HEAD:scripts/check-skill-local-patches.sh)\n # 実行は worktree のファイルではなく、承認済み HEAD blob を取り出した一時ファイル\n # (CHECKER_EXEC)に対して行う。hash 確認後〜bash 実行の間に worktree の checker を\n # 差し替える TOCTOU 経路を、実行対象を承認済み blob へ固定することで断つ\n if [[ -L scripts/check-skill-local-patches.sh ]]; then\n echo \"エラー: checker がシンボリックリンクです。リンク先差し替えで HEAD 一致検証をすり抜けられるため同期しません(fail-closed)。実体ファイルへ置き換えてから再実行する。\"\n exit 1\n fi\n if [[ ! -f scripts/check-skill-local-patches.sh ]]; then\n echo \"エラー: checker が regular file ではありません。実行対象にできないため同期しません(fail-closed)。\"\n exit 1\n fi\n CHECKER_HEAD_MODE=\"$(git ls-tree HEAD -- scripts/check-skill-local-patches.sh 2>/dev/null | awk '{print $1}')\"\n if [[ \"${CHECKER_HEAD_MODE}\" != \"100644\" && \"${CHECKER_HEAD_MODE}\" != \"100755\" ]]; then\n echo \"エラー: HEAD の checker が regular file ではありません(mode: ${CHECKER_HEAD_MODE:-エントリなし})。symlink 等は実行対象にできないため同期しません(fail-closed)。\"\n exit 1\n fi\n CHECKER_HASH=\"$(git hash-object -- scripts/check-skill-local-patches.sh)\"\n if [[ \"${CHECKER_HASH}\" != \"$(git rev-parse HEAD:scripts/check-skill-local-patches.sh 2>/dev/null || true)\" ]]; then\n echo \"エラー: checker が HEAD に commit 済みの内容と一致しません(未 commit・未追跡・未コミット変更)。任意コード実行を防ぐため同期しません(fail-closed)。\"\n exit 1\n fi\n echo \"CHECKER_HASH=${CHECKER_HASH} # 実行承認の対象となる blob hash。由来・内容と合わせてユーザーへ提示する\"\n # ユーザー承認(上記 (4))を得たら、承認された hash を CHECKER_APPROVED_HASH に設定する。\n # 承認は変数の設定によってのみ成立し、未設定・不一致のまま checker を実行する経路は無い\n if [[ \"${CHECKER_APPROVED_HASH:-}\" != \"${CHECKER_HASH}\" ]]; then\n echo \"エラー: checker はユーザー承認済みの blob hash(CHECKER_APPROVED_HASH)と一致する場合のみ実行できます。承認を得てから再実行してください(fail-closed)。\"\n exit 1\n fi\n # 承認済み HEAD blob を一時ファイルへ取り出す。以後の checker 実行(同期前 check /\n # Step 5.5 の apply・最終 check)はすべてこのファイルを使い、worktree の checker は\n # 実行しない。取り出し失敗のまま進むと空ファイルの bash 実行(exit 0 の no-op)が\n # 「検証成功」に化けるため fail-closed で停止する\n CHECKER_EXEC=\"$(mktemp)\"\n if ! git cat-file blob \"${CHECKER_APPROVED_HASH}\" > \"${CHECKER_EXEC}\"; then\n echo \"エラー: 承認済み checker blob(${CHECKER_APPROVED_HASH})の取り出しに失敗しました。同期しません(fail-closed)。\"\n exit 1\n fi\n echo \"CHECKER_EXEC=${CHECKER_EXEC} # 実行対象(承認済み blob の取り出し先)。Step 5.5 まで同一 shell で保持する(失われたら承認済み hash から再作成する)\"\n\n # checker を実行する唯一の経路(codex P1 指摘: CHECKER_EXEC はただの一時ファイルで\n # あり、直前の実行〔特に apply は checker 自身のコードを実行するため自己書換えも\n # 可能〕による置換・改変〔TOCTOU〕が、検証されないまま次の実行へ紛れ込み得る。\n # 起動時に1度取り出した後は再検証せず bash に渡していたため、同期前 check の直後に\n # CHECKER_EXEC が残置・置換された場合、承認済み blob と異なるコードが Step 5.5 の\n # apply・最終検証で実行される経路が残っていた)。呼び出しごとに毎回\n # `git hash-object` でハッシュを再検証し、不一致・不在なら承認済み blob から\n # 無条件に書き直してから実行する。検証と実行を1関数に閉じ込めることで、呼び出し\n # 箇所(同期前 check・Step 5.5 の apply・最終検証)が増えても検証漏れが構造的に\n # 起きないようにする。Step 5.5 で shell セッションが切れて本関数が失われている\n # 場合は、他の Step 4 定義関数(outside_state 等)と同様にこの定義を再実行してから\n # 使う。戻り値の扱い: 「取り出し・検証自体の失敗」と「checker が実行された結果の\n # 非ゼロ終了」を呼び出し元が区別できるよう、前者は CHECKER_RUN_VERIFY_FAILED=1 を\n # 立ててから非ゼロを返す(checker 自身の exit code と衝突しない専用シグナル)。\n run_checker() {\n CHECKER_RUN_VERIFY_FAILED=0\n if [[ -z \"${CHECKER_EXEC:-}\" || ! -f \"${CHECKER_EXEC}\" \\\n || \"$(git hash-object -- \"${CHECKER_EXEC}\" 2>/dev/null || echo missing)\" != \"${CHECKER_APPROVED_HASH}\" ]]; then\n # 置換前の一時ファイルを明示的に削除してから再代入する(Bugbot Medium / codex\n # P2 指摘: 再代入するだけだと旧一時ファイルが /tmp に残る。checker の apply に\n # よる自己書換えという通常ケースでもこの分岐は毎回通るため、mktemp の直前に\n # 確実に削除する)。\n [[ -z \"${CHECKER_EXEC:-}\" ]] || rm -f \"${CHECKER_EXEC}\"\n CHECKER_EXEC=\"$(mktemp)\"\n if ! git cat-file blob \"${CHECKER_APPROVED_HASH}\" > \"${CHECKER_EXEC}\" \\\n || [[ ! -s \"${CHECKER_EXEC}\" ]] \\\n || [[ \"$(git hash-object -- \"${CHECKER_EXEC}\")\" != \"${CHECKER_APPROVED_HASH}\" ]]; then\n echo \"エラー: 承認済み checker blob(${CHECKER_APPROVED_HASH})の取り出し・検証に失敗しました(fail-closed)。\"\n CHECKER_RUN_VERIFY_FAILED=1\n return 1\n fi\n fi\n bash \"${CHECKER_EXEC}\" \"$@\"\n }\n\n # durable patch 置き場は承認時に directory ごと stage し、却下時に index からの復元 +\n # git clean の対象になるため、未 stage の WIP・未追跡ファイルが残っていると巻き込まれて\n # 失われる。staged のみの変更(= 本ループ内で承認済みに積み上がった分)は許容する。\n # producer(git status)を grep -q へ直接 pipe すると、-q の早期終了による SIGPIPE で\n # pipefail 下のパイプラインが偽になり WIP を見逃し得るため、一旦変数へ取得してから判定する\n LOCAL_PATCHES_STATUS=\"$(git status --porcelain -- scripts/local-patches/)\"\n if grep -q '^.[^ ]' \u003c\u003c\u003c\"${LOCAL_PATCHES_STATUS}\"; then\n echo \"エラー: scripts/local-patches/ に未 stage の変更・未追跡ファイルがあります。承認・却下経路が巻き込むため同期しません(fail-closed)。\"\n exit 1\n fi\n # 契約範囲(skills-lock.json / 当該スキル / durable patch)外の状態 digest。worktree 側は\n # 「HEAD を基底にした一時 index へ範囲外のみ git add -A」した tree hash で捉える\n # (未追跡・削除を含む全ファイルが実 blob hash で比較され、diff の表示文字列に依存しない\n # = dirty なバイナリの上書きも検出する。範囲内は HEAD のまま固定されるため同期による\n # 正当な変更では digest が動かない)。index 側は ls-files -s の blob hash で捉える。\n # 基準(PRE_OUTSIDE)は checker の初回実行(同期前 check)より前に取得する。check の後に\n # 取得すると、check mode が行った範囲外変更が基準へ取り込まれ検出できなくなる。\n #\n # 【検出範囲の限界(重要)】この digest は Git が追跡・列挙できる対象(非 ignore の\n # worktree / index)に限られる。.gitignore 対象・.git/ 配下(config・hooks 等)・\n # リポジトリ外への書き込みは検出できない。同一権限で任意コードを実行した後の\n # tree 比較は書き込み制限の「保証」にはならず、信頼アンカーはあくまで実行前の\n # ユーザーによる checker 内容レビュー + blob hash 承認である。本検証はその上に\n # 重ねる best-effort の追加防御(defense-in-depth)として扱うこと\n OUTSIDE_PATHSPEC=(. \":(exclude)skills-lock.json\" \":(exclude).agents/skills/${SKILL_NAME}\" \":(exclude)scripts/local-patches\")\n # 内部コマンド(git read-tree / git add / git write-tree / git ls-files / git\n # hash-object)はいずれも `local` 代入・`$(...)` 経由で呼ぶため、失敗しても\n # `set -e` が必ずこの関数の呼び出し元(`$(outside_state)` を `[[ ]]` の条件式内で\n # 使う verify_outside_and_checker 等)まで伝播するとは限らない(`[[ ]]` の条件式は\n # errexit の伝播対象外であり、内部でコマンドが早期に失敗して空文字のまま関数を\n # 抜けても、その非ゼロ終了は握り潰されて文字列比較にしか使われない)。空 digest\n # 同士が偶然一致すると「契約範囲外の変更なし」と誤判定し得るため(Bugbot Medium\n # 指摘)、失敗時は他の呼び出し・他の成功時 digest と絶対に一致しない一意な\n # エラーマーカーを出力したうえで明示的に非ゼロを返す。PID($BASHPID。呼び出しごとに\n # 新しいサブシェルが fork されるため呼び出し間で重複しない)とナノ秒時刻を組み合わせ、\n # date が使えない環境向けに $RANDOM を二重フォールバックにする。\n # git ls-files | git hash-object のような素のパイプラインは、このフェンスが\n # pipefail を有効化していない限り末尾コマンドの終了状態しか見ない(Bugbot\n # Medium / codex P1 指摘)。git ls-files だけが失敗しても空 stdin が正常に\n # hash 化されて rc=0 のままになり、上の fail-closed 分岐(エラーマーカー)を\n # 通らない。scripts/skills-lock-update.sh はファイル先頭の `set -euo pipefail`\n # で保護されているため同型の記述でも安全だが、このフェンス単体では保証がないため、\n # パイプラインを使わず producer(git ls-files)の出力を一時ファイルへ書き出して\n # から終了コードを個別に検査し、その後 git hash-object をファイル入力で実行する\n # 形に分離する。\n outside_state() {\n local tmp_index_dir wt_tree idx_digest idx_list_file rc=0\n tmp_index_dir=\"$(mktemp -d)\" || rc=1\n if [[ \"${rc}\" -eq 0 ]] && ! GIT_INDEX_FILE=\"${tmp_index_dir}/index\" git read-tree HEAD; then\n rc=1\n fi\n if [[ \"${rc}\" -eq 0 ]] && ! GIT_INDEX_FILE=\"${tmp_index_dir}/index\" git add -A -- \"${OUTSIDE_PATHSPEC[@]}\"; then\n rc=1\n fi\n if [[ \"${rc}\" -eq 0 ]]; then\n wt_tree=\"$(GIT_INDEX_FILE=\"${tmp_index_dir}/index\" git write-tree)\" || rc=1\n fi\n rm -rf \"${tmp_index_dir}\"\n idx_list_file=\"\"\n if [[ \"${rc}\" -eq 0 ]]; then\n idx_list_file=\"$(mktemp)\" || rc=1\n fi\n if [[ \"${rc}\" -eq 0 ]] && ! git ls-files -s -z -- \"${OUTSIDE_PATHSPEC[@]}\" > \"${idx_list_file}\"; then\n rc=1\n fi\n if [[ \"${rc}\" -eq 0 ]]; then\n idx_digest=\"$(git hash-object --stdin \u003c \"${idx_list_file}\")\" || rc=1\n fi\n [[ -z \"${idx_list_file}\" ]] || rm -f \"${idx_list_file}\"\n if [[ \"${rc}\" -ne 0 ]]; then\n echo \"(outside-state-error:${BASHPID:-$}:$(date +%s%N 2>/dev/null || echo \"${RANDOM}${RANDOM}\"))\"\n return 1\n fi\n echo \"${wt_tree}:${idx_digest}\"\n }\n if ! PRE_OUTSIDE=\"$(outside_state)\"; then\n echo \"エラー: 契約範囲外の基準 digest(PRE_OUTSIDE)の取得に失敗しました。以後の範囲外書き込み検出ができないため同期を開始しません(fail-closed。checker は未実行です)。\"\n exit 1\n fi\n echo \"PRE_OUTSIDE=${PRE_OUTSIDE} # 範囲外検証の基準 digest。Step 5.5 まで同一 shell で保持する(失われたら再設定に使う)\"\n\n # checker の「初回実行より前」に index snapshot を取得する。同期前 check(check mode)も\n # 契約上、契約範囲(当該スキル / skills-lock.json / durable patch)を変更・stage し得るため、\n # すべての失敗経路(pre-check 失敗・検証失敗・npx 失敗・Step 5.5 の apply / 最終 check 失敗)と\n # Step 6 の却下で、この snapshot で契約範囲全体を同期開始前へ戻す\n PRE_SYNC_TREE=\"$(git write-tree)\"\n echo \"PRE_SYNC_TREE=${PRE_SYNC_TREE} # 失敗・却下時の契約範囲復元に使う snapshot hash。控えておくこと\"\n\n # 契約範囲を同期開始前へ戻す共通処理。部分変更(checker の stage 含む)を残して終了しない。\n # 復元は git restore の pathspec で契約パスに限定し、範囲外 path の index・worktree には\n # 一切触れない(index 全体を git read-tree で書き換えると、verify_outside_and_checker が\n # 「範囲外は git restore --staged -- \u003cpath> で手動復旧」と案内した直後にその案内自体を\n # 誤りにしてしまう)。git restore は no-overlay が既定のため、同期開始前 tree に無い\n # tracked ファイルは契約パス内に限り index・worktree から取り除かれる。契約範囲内の\n # 未追跡ファイルは git clean で即削除せず一時ディレクトリへ退避する(checker が契約\n # ディレクトリ内へ移動・新規作成したファイルの唯一のコピーであり得るため、削除は\n # データ喪失になる。退避先を案内し、削除の判断は人間へ委ねる)。退避自体に失敗した\n # 場合は worktree 復元を行わず index のみへ降格して fail-closed で停止する(Issue #418)\n restore_contract_scope() {\n : \"${PRE_SYNC_TREE:?同期開始前 snapshot が未設定のため復元できません}\"\n local restore_targets=(--staged --worktree) untracked_list moved=0 p\n # 退避(mkdir -p / mv)自体の失敗を検査するためのフラグ。呼び出し文は全箇所\n # `restore_contract_scope || true` の形であり、bash の仕様上 `||` の右辺・左辺の\n # コマンド文脈で呼ばれた関数内では set -e が抑止される(この関数内の mkdir/mv の\n # 失敗は自動では中断にならない)。検査なしに git restore --worktree へ進むと、\n # checker が未追跡化・新規作成した「唯一のコピー」が退避されないまま\n # PRE_SYNC_TREE の内容で無音に上書きされ、データ喪失になる(Issue #418)。退避対象を\n # 1 件ずつ処理し、失敗があれば worktree 復元を行わず index のみへ降格する\n # (revert_in_scope の非破壊モードと同じ判断)\n local backup_failed=0 failed_paths=()\n # Issue #422: backup_failed のうち「列挙自体(mktemp / git ls-files / mktemp -d)が\n # 失敗し 1 件も退避していない」モードだけを区別するフラグ。個別ファイルの mv 失敗\n # (enum_failed=0 のまま backup_failed=1 になる)とは異なり、この場合は「未追跡集合が\n # 何か」自体が不明なため git clean は依然として行えないが、「worktree の現状自体は\n # 壊れていない」ため revert_in_scope が cp 退避を挟んで tracked の checkout を\n # 救済できる(revert_in_scope 側の CONTRACT_UNTRACKED_ENUM_FAILED 分岐参照)\n local enum_failed=0\n # npx 実行後検証が許可先経路・skills-lock.json の妥協(symlink 化等)を検出している場合、\n # worktree への書き込み・未追跡退避のパス走査がリンク先(リポジトリ外を含む)へ向かい得る\n # ため index のみ復元する(worktree 側は revert_in_scope が手動復旧を案内済み)。\n # 妥協フラグは npx 実行後にのみ設定されるため、それ以前の失敗経路では既定 0 で参照する\n if [[ \"${SCOPE_PATH_COMPROMISED:-0}\" -ne 0 || \"${LOCK_FILE_COMPROMISED:-0}\" -ne 0 ]]; then\n restore_targets=(--staged)\n echo \"許可先経路または skills-lock.json の妥協を検出しているため、契約範囲の復元は index のみ行います。worktree 側は案内済みの手順で手動復旧してください。\"\n else\n if ! untracked_list=\"$(mktemp)\"; then\n echo \"エラー: 未追跡ファイル列挙用の一時ファイル作成に失敗しました。退避の完全性を確認できないため index のみ復元します。\"\n backup_failed=1\n enum_failed=1\n fi\n # skills-lock.json も退避対象に含める。通常は tracked のため列挙されないが、checker が\n # git rm --cached 等で未追跡化して内容変更した後に失敗すると、退避なしの git restore が\n # その唯一の内容を上書きしてしまう\n if [[ \"${backup_failed}\" -eq 0 ]] \\\n && ! git ls-files -z --others --exclude-standard -- skills-lock.json \".agents/skills/${SKILL_NAME}/\" scripts/local-patches/ > \"${untracked_list}\"; then\n # 列挙自体が失敗した場合、退避の完全性を確認できないまま worktree 復元へ\n # 進むと未列挙の未追跡ファイルが無音に上書きされ得るため、警告に留めず\n # 失敗として扱う(revert_in_scope 導入時と同じ fail-closed 判断)\n echo \"エラー: 契約範囲内の未追跡ファイル列挙に失敗しました。退避の完全性を確認できないため index のみ復元します。\"\n backup_failed=1\n enum_failed=1\n fi\n if [[ \"${backup_failed}\" -eq 0 ]]; then\n if ! CONTRACT_UNTRACKED_BACKUP_DIR=\"$(mktemp -d)\"; then\n echo \"エラー: 未追跡ファイルの退避先ディレクトリ作成に失敗しました。index のみ復元します。\"\n backup_failed=1\n enum_failed=1\n fi\n fi\n if [[ \"${backup_failed}\" -eq 0 ]]; then\n while IFS= read -r -d '' p; do\n if ! mkdir -p \"${CONTRACT_UNTRACKED_BACKUP_DIR}/$(dirname \"${p}\")\" \\\n || ! mv -- \"${p}\" \"${CONTRACT_UNTRACKED_BACKUP_DIR}/${p}\"; then\n # 退避に失敗したファイルはスキップし、残りのファイルの退避は継続する\n # (保全できるコピーを最大化する)。1 件でも失敗すれば worktree 復元は\n # 行わない\n backup_failed=1\n failed_paths+=(\"${p}\")\n continue\n fi\n moved=1\n done \u003c \"${untracked_list}\"\n fi\n rm -f \"${untracked_list:-}\"\n if [[ \"${moved}\" -eq 1 ]]; then\n echo \"契約範囲内の未追跡ファイルは削除せず ${CONTRACT_UNTRACKED_BACKUP_DIR} に相対パス構造で退避しました。内容を確認し、不要なら手動で削除してください。\"\n elif [[ -n \"${CONTRACT_UNTRACKED_BACKUP_DIR:-}\" ]]; then\n # backup_failed=1 かつ moved=0(退避先ディレクトリ作成には成功したが最初の\n # ファイルの mkdir/mv で失敗した)場合も、空のまま残る退避先ディレクトリを掃除する。\n # CONTRACT_UNTRACKED_BACKUP_DIR は mktemp -d 自体が失敗した経路(未追跡ファイル\n # 列挙用の一時ファイル作成失敗・git ls-files 失敗・mktemp -d 失敗)では未設定の\n # ままのため、set -u 下での unbound variable エラーを避けるため -n で存在確認\n # してから参照する\n rmdir \"${CONTRACT_UNTRACKED_BACKUP_DIR}\" 2>/dev/null || true\n fi\n if [[ \"${backup_failed}\" -eq 1 ]]; then\n # 退避の完全性を確認できない以上、worktree への git restore は行わない\n # (未退避の唯一のコピーを上書きするおそれがあるため)。index のみ復元へ降格する\n restore_targets=(--staged)\n echo \"エラー: 契約範囲内の未追跡ファイルの退避に失敗しました。worktree の復元は行いません(fail-closed)。\"\n if [[ \"${#failed_paths[@]}\" -gt 0 ]]; then\n echo \"退避できなかったパス: ${failed_paths[*]}\"\n fi\n if [[ \"${moved}\" -eq 1 ]]; then\n echo \"退避済み分は ${CONTRACT_UNTRACKED_BACKUP_DIR} に残しています。\"\n fi\n echo \"未退避のファイルを手動で退避してから、git restore --worktree --source=${PRE_SYNC_TREE} -- \u003cpath> で契約範囲の worktree を復旧してください。\"\n fi\n fi\n # revert_in_scope(この関数の直後に必ず呼ばれる)が backup_failed を検知できる\n # よう、global へも反映する。backup_failed=1 のとき restore_targets は\n # (--staged) へ降格して worktree には触れないが、`git restore --staged` 自体は\n # tracked な契約パスの index を PRE_SYNC_TREE へ戻すため、この後 revert_in_scope\n # が無条件に `git checkout -- \u003c契約パス>` を実行すると、その index を worktree へ\n # 書き戻して未退避の worktree 内容を上書きしてしまう(Bugbot 指摘: Index restore\n # enables later overwrite)。revert_in_scope 側でこの global を見て checkout /\n # clean を丸ごとスキップし、退避が失敗した worktree を意図的に未復元のまま残す。\n CONTRACT_UNTRACKED_BACKUP_FAILED=\"${backup_failed}\"\n CONTRACT_UNTRACKED_ENUM_FAILED=\"${enum_failed}\"\n # 複数パスを 1 コマンドへ渡すと pathspec 不一致 1 件で全体が失敗するため 1 コマンド\n # 1 パスで分離する。pathspec は index に対しても照合されるため、PRE_SYNC_TREE 取得後に\n # 新規作成・stage されたファイルは tree に無くても no-overlay で index(・worktree)から\n # 取り除かれる(実測済み)。pathspec 不一致(tree にも index にも無い)は復元対象なしを\n # 意味するため許容するが、真の失敗(権限エラー等)と区別が付かないため、成功可否は\n # コマンドの終了コードではなく下の復元後検証で fail-closed に判定する\n git restore \"${restore_targets[@]}\" --source=\"${PRE_SYNC_TREE}\" -- skills-lock.json 2>/dev/null || true\n git restore \"${restore_targets[@]}\" --source=\"${PRE_SYNC_TREE}\" -- \".agents/skills/${SKILL_NAME}/\" 2>/dev/null || true\n git restore \"${restore_targets[@]}\" --source=\"${PRE_SYNC_TREE}\" -- scripts/local-patches/ 2>/dev/null || true\n # 復元後検証(fail-closed): 契約パスの index が PRE_SYNC_TREE と一致し、worktree 復元\n # モードでは worktree 側も PRE_SYNC_TREE と一致し未追跡も残っていない(未追跡は上で\n # 退避済み)ことを実測してから成功を表示する。pathspec miss や restore の失敗を\n # 「復元対象なし」として成功扱いすると、新規 staged ファイルの残留(Step 7 の git add\n # への混入経路)を見逃すため。worktree 側の判定基準は HEAD ではなく PRE_SYNC_TREE:\n # HEAD 基準の git status --porcelain を使うと、同期前から存在した正当な staged 変更\n # (scripts/local-patches/ で許容している「staged のみの変更」)が復元完了後も常に\n # 非空として現れ、PRE_SYNC_TREE と完全一致した正しい復元を誤報してしまう\n local verify_ok=1\n if [[ \"${backup_failed}\" -eq 1 ]]; then\n # 退避に失敗している以上、index 側が PRE_SYNC_TREE と一致していても\n # 「復元完了」ではない(worktree は意図的に未復元のまま残しているため)。\n # 無条件の成功メッセージを出さず、常に fail-closed の非ゼロで終了する\n verify_ok=0\n elif ! git diff --cached --quiet \"${PRE_SYNC_TREE}\" -- skills-lock.json \".agents/skills/${SKILL_NAME}/\" scripts/local-patches/; then\n verify_ok=0\n elif [[ \"${restore_targets[*]}\" == *--worktree* ]]; then\n if ! git diff --quiet \"${PRE_SYNC_TREE}\" -- skills-lock.json \".agents/skills/${SKILL_NAME}/\" scripts/local-patches/ \\\n || [[ -n \"$(git ls-files --others --exclude-standard -- skills-lock.json \".agents/skills/${SKILL_NAME}/\" scripts/local-patches/)\" ]]; then\n verify_ok=0\n fi\n fi\n if [[ \"${verify_ok}\" -eq 1 ]]; then\n echo \"契約範囲を同期開始前(PRE_SYNC_TREE=${PRE_SYNC_TREE})へ復元しました(範囲外 path の index・worktree には触れていません)。\"\n elif [[ \"${backup_failed}\" -eq 1 ]]; then\n echo \"エラー: 未追跡ファイルの退避に失敗したため、契約範囲の復元は index のみで停止しました(worktree は未復元。fail-closed)。\"\n # 呼び出し元が失敗を検知できるよう非ゼロを返す(Bugbot Medium 指摘: 従来は\n # echo するだけで成功扱いのまま返っていたため、呼び出し元が fail-closed に\n # 分岐できなかった)。呼び出し文は `restore_contract_scope || true` の形で\n # この非ゼロを吸収し、set -e/pipefail の下で本関数の呼び出し文自体が\n # 呼び出し元の revert_in_scope・案内 echo をスキップして異常終了する\n # (cleanup が一部欠落する回帰)ことを防ぐ。継続後の可否判定が必要な\n # 呼び出し元(Step 4 npx 失敗経路)は戻り値を明示的に変数へ拾う。\n return 1\n else\n echo \"エラー: 契約範囲の復元後検証で差分が残っています。git diff --cached ${PRE_SYNC_TREE} -- \u003c契約パス> / git diff ${PRE_SYNC_TREE} -- \u003c契約パス> / git ls-files --others -- \u003c契約パス> で残留を確認し、git restore --staged --worktree --source=${PRE_SYNC_TREE} -- \u003cpath> で手動復旧してください(fail-closed。復元完了とは扱いません)。\"\n return 1\n fi\n }\n\n # checker の「すべての」実行(同期前 check / Step 5.5 の apply・最終 check)の直後に、\n # 実行結果に関わらず範囲外 digest と checker 自身の blob hash を再検証する。範囲外を\n # 書き換えて非 0 終了するケース・checker 自身を未承認コードへ置換するケースを、次の\n # 実行より前に fail-closed で検出するため\n verify_outside_and_checker() {\n if [[ -L scripts/check-skill-local-patches.sh ]] \\\n || [[ \"$(git hash-object -- scripts/check-skill-local-patches.sh 2>/dev/null || echo missing)\" != \"${CHECKER_APPROVED_HASH}\" ]]; then\n echo \"エラー: checker 自身が書き換えられました(symlink 化を含む)。未承認の状態のため以後実行しません(fail-closed)。\"\n return 1\n fi\n # outside_state の失敗は「変化なしと確認できない」であって「変化なし」ではない\n # ため、戻り値を明示的に検査する([[ ]] の条件式内で `$(outside_state)` を直接\n # 使うと、内部コマンドの失敗による非ゼロ終了が握り潰され空文字列同士の比較に\n # 落ちてしまう。関数自体が一意なエラーマーカーを返す設計にしてあるためこの\n # 比較でも安全側に倒れるが、ここでは戻り値も明示的に見て二重に fail-closed を\n # 担保する)。\n local outside_now\n if ! outside_now=\"$(outside_state)\"; then\n echo \"エラー: 契約範囲外の digest 取得に失敗しました。変化なしと確認できないため、範囲外書き込みありとして扱います(fail-closed)。\"\n return 1\n fi\n if [[ \"${outside_now}\" != \"${PRE_OUTSIDE}\" ]]; then\n echo \"エラー: checker が契約範囲外の path を変更しました(fail-closed)。\"\n echo \"git status --porcelain で範囲外の変更を特定し、tracked は git restore -- \u003cpath> / index は git restore --staged -- \u003cpath> で手動復旧してください(契約範囲用の却下手順では範囲外は戻りません)。checker 側の修正も必要です。\"\n return 1\n fi\n return 0\n }\n\n echo \"==> 同期前の local patch 検証(check)\"\n pre_check_rc=0\n # 実行対象は worktree のファイルではなく、承認済み blob を毎回再検証してから取り出す\n # CHECKER_EXEC(run_checker)\n run_checker || pre_check_rc=$?\n if [[ \"${CHECKER_RUN_VERIFY_FAILED}\" -eq 1 ]]; then\n restore_contract_scope || true\n echo \"(npx は実行していません)\"\n exit 1\n fi\n if ! verify_outside_and_checker; then\n restore_contract_scope || true\n echo \"(npx は実行していません)\"\n exit 1\n fi\n if [[ \"${pre_check_rc}\" -ne 0 ]]; then\n restore_contract_scope || true\n echo \"エラー: 同期前の local patch 検証に失敗しました。修復してから再実行してください(fail-closed。npx は実行していません)。\"\n exit 1\n fi\nelif [[ -f .agents/skills/LOCAL-PATCHES.md ]]; then\n echo \"エラー: .agents/skills/LOCAL-PATCHES.md があるのに scripts/check-skill-local-patches.sh がありません。local patch を検証できないため同期しません(fail-closed)。\"\n exit 1\nfi\n\n# skills CLI (vercel-labs/skills) は固定版でのみ実行する(未固定 npx はレジストリ\n# 最新版の無検証即時実行になり、差分確認・承認より前に走る supply chain 経路になる)。\n# 1つ目の --yes は npx 自体のインストール確認プロンプトのスキップ、末尾の --yes は\n# skills CLI へ渡す確認プロンプトのスキップで、別物(位置で区別される)。\nSKILLS_CLI_VERSION=\"1.5.22\" # scripts/skills-lock-update.sh と同一値。更新手順は下記節を参照\n\n# 作業ツリーの状態シグネチャ(種別 + パーミッション + 内容)を1行で返す。\n# 第1引数のパスを起点に、通常ファイルは内容の sha256、シンボリックリンクは\n# リンク先文字列の sha256(リンク先の解決はしない)、ディレクトリ・gitlink は\n# 自身の mode に加えて配下全エントリ(サブディレクトリの mode・ディレクトリ向け\n# symlink のリンク先と mode・ファイルの mode と内容ハッシュ)をバイト列ソートで\n# 決定的に再帰集約した sha256 を返す。存在しないパスは \"MISSING\"(それ自体が\n# 1つの状態であり、エラーではない)。第2引数以降で走査の除外を指定できる:\n# prune:\u003crel> — 起点からの相対パス \u003crel> をエントリごと走査から除外する。\n# パス区切りをまたがない per-segment glob(fnmatch)を使える\n# (例: .git/MERGE_* は .git 直下にのみ一致し .git/hooks/ 配下の\n# 同名ファイルには一致しない)。npx が書き換えてよいスコープ内と、\n# .git のうち通常の git 操作で変動し得る領域の限定除外に使う\n# prune-under:\u003crel> — \u003crel> 自身のメタデータ(種別・mode・symlink のリンク先)は\n# 記録するが、配下へは降下せず記録もしない(完全一致のみで glob\n# 不可)。既存の許可先ディレクトリに使う: 配下(npx の正当な書き込み\n# 先)は除外しつつ、要素自身のディレクトリ→symlink 置換・chmod は\n# 前後シグネチャの不一致として検出する(PR #412 P0 指摘: エントリ\n# ごと prune すると npx 実行中の symlink 置換が署名に現れない)\n# omit:\u003crel> — \u003crel> 自身のメタデータ(存在・mode)は記録しないが配下は走査する\n# (実行前に存在しなかった親ディレクトリを npx が正当に新規作成する\n# ケースの許容に使う。完全一致のみで glob 不可)\n#\n# PR #412 の P1 指摘群(porcelain に現れない状態変化の見逃し: 配下ファイルの\n# 内容上書き・ディレクトリと dirlink の変更・ディレクトリの chmod)は、いずれも\n# 「git status に現れたパスだけを個別にシグネチャ化する」構造に起因する同一クラス。\n# git はディレクトリの mode を追跡しないため、スコープ外ディレクトリの chmod は\n# status の前後どちらにも現れず、status 由来のパス集合をどれだけ精緻にハッシュ\n# しても原理的に検出できない。そのためこのシグネチャは status 由来のパスではなく\n# リポジトリルート全体(スコープ内と、.git のうち git 操作で変動し得る領域のみ\n# 除外。.git/config・.git/hooks/ 等の永続メタデータは署名対象)へ適用する。\n# .gitignore 対象の\n# ファイルも同じ理由(porcelain に現れない)で走査対象に含める。対象は skills\n# 配布リポジトリで作業ツリーが小さく、全走査 + 全ハッシュを前後 2 回行っても\n# 実用上問題ない。python3 は主要 Linux / macOS に標準搭載されている。stat\n# コマンドの出力書式は環境(BSD/GNU)で異なるため、シェルの `stat` は使わず\n# python3 の os.lstat に統一する。取得エラー(lstat・open・走査失敗)は\n# 「読めなかっただけ」を「変化なし」と誤認する fail-open 経路になるため、\n# 握り潰さず即座に非ゼロ終了して呼び出し側で fail-closed に扱う。\npath_state() {\n local path=\"$1\"\n shift\n python3 - \"${path}\" \"$@\" \u003c\u003c'PYEOF'\nimport fnmatch, hashlib, os, stat, sys\n\npath = sys.argv[1]\n\n# 除外指定(prune: 走査ごと除外 / prune-under: 自身は記録し配下のみ除外 /\n# omit: 自身のメタデータのみ不記録)を解釈する。\nprunes = []\nprune_unders = set()\nomits = set()\nfor spec in sys.argv[2:]:\n label, _, rel = spec.partition(\":\")\n if label == \"prune\" and rel:\n prunes.append(rel)\n elif label == \"prune-under\" and rel:\n prune_unders.add(rel)\n elif label == \"omit\" and rel:\n omits.add(rel)\n else:\n print(f\"path_state: 不正な除外指定: {spec}\", file=sys.stderr)\n sys.exit(1)\n\n\ndef pruned(rel):\n # prune はパス区切りをまたがない per-segment glob で照合する。素の fnmatch は\n # `*` が `/` もまたいで一致するため、`.git/MERGE_*` のような浅い階層向けの\n # パターンが `.git/hooks/` 配下の同名ファイル(署名対象へ残したい深い階層)\n # まで巻き込んでしまう。セグメント数の一致を要求してから各セグメントを個別に\n # 照合し、除外が意図した深さの外へ広がらないようにする。\n segs = rel.split(os.sep)\n for pat in prunes:\n pat_segs = pat.split(\"/\")\n if len(pat_segs) == len(segs) and all(\n fnmatch.fnmatchcase(s, p) for s, p in zip(segs, pat_segs)\n ):\n return True\n return False\n\n\ndef fail(err):\n # 部分的なシグネチャを出力したまま正常終了すると、呼び出し側が欠損に気付けない。\n # ファイル内容は出力せず(秘密情報混入防止)、エラー要因のみ stderr へ出して\n # 非ゼロ終了する。\n print(f\"path_state: 状態取得に失敗: {err}\", file=sys.stderr)\n sys.exit(1)\n\n\ndef file_hash(p):\n fh = hashlib.sha256()\n with open(p, \"rb\") as f:\n while True:\n chunk = f.read(1 \u003c\u003c 20)\n if not chunk:\n break\n fh.update(chunk)\n return fh.hexdigest()\n\n\ntry:\n st = os.lstat(path)\nexcept FileNotFoundError:\n print(\"MISSING\")\n sys.exit(0)\nexcept OSError as e:\n fail(e)\n\nmode = oct(stat.S_IMODE(st.st_mode))\nkind = stat.S_IFMT(st.st_mode)\nh = hashlib.sha256()\n\ntry:\n if stat.S_ISLNK(st.st_mode):\n h.update(os.readlink(path).encode(\"utf-8\", \"surrogateescape\"))\n elif stat.S_ISREG(st.st_mode):\n h.update(file_hash(path).encode(\"ascii\"))\n else:\n # ディレクトリ・gitlink 等。配下の各エントリを相対パス・パーミッション・\n # 内容(種別に応じたハッシュ)でソートして正規化し、走査順に依存せず\n # 決定的な signature にする。os.walk は既定(followlinks=False)で\n # symlink の指す先へは降りないため、ディレクトリ向け symlink は dirnames\n # として自身(リンク先文字列・mode)だけを記録し、リンク先ディレクトリの\n # 中身が二重に取り込まれることはない。\n entries = []\n for dirpath, dirnames, filenames in os.walk(path, onerror=fail):\n kept = []\n for dname in sorted(dirnames):\n # dirnames 自体(配下ディレクトリ・ディレクトリ向け symlink)を\n # lstat して entries へ含める。os.walk は filenames 経由で列挙\n # しないため、ここで記録しないと配下ディレクトリの mode 変更や\n # ディレクトリ向け symlink のリンク先・mode 変更がシグネチャに\n # 反映されない(PR #412 P1 指摘)。\n dp = os.path.join(dirpath, dname)\n rel = os.path.relpath(dp, path)\n if pruned(rel):\n continue\n # prune-under は自身のエントリ(下の記録処理)は残しつつ降下だけを\n # 止める(kept へ入れない = os.walk がこの配下へ降りない)。\n if rel not in prune_unders:\n kept.append(dname)\n if rel in omits:\n continue\n dst = os.lstat(dp)\n dmode = oct(stat.S_IMODE(dst.st_mode))\n if stat.S_ISLNK(dst.st_mode):\n target_hash = hashlib.sha256(\n os.readlink(dp).encode(\"utf-8\", \"surrogateescape\")\n ).hexdigest()\n entries.append(f\"{rel}:{dmode}:dirlink:{target_hash}\")\n else:\n entries.append(f\"{rel}:{dmode}:dir\")\n # prune した名前を降下対象からも外す(os.walk は dirnames の\n # in-place 更新で走査対象を制御する仕様)。\n dirnames[:] = kept\n for name in sorted(filenames):\n p = os.path.join(dirpath, name)\n rel = os.path.relpath(p, path)\n if pruned(rel) or rel in omits:\n continue\n fst = os.lstat(p)\n fmode = oct(stat.S_IMODE(fst.st_mode))\n if stat.S_ISLNK(fst.st_mode):\n target_hash = hashlib.sha256(\n os.readlink(p).encode(\"utf-8\", \"surrogateescape\")\n ).hexdigest()\n entries.append(f\"{rel}:{fmode}:link:{target_hash}\")\n elif stat.S_ISREG(fst.st_mode):\n entries.append(f\"{rel}:{fmode}:reg:{file_hash(p)}\")\n else:\n # デバイスファイル等の特殊な種別は内容ハッシュが定義できない\n # ため種別・mode のみ記録する。\n entries.append(f\"{rel}:{fmode}:other\")\n entries.sort()\n for entry in entries:\n h.update(entry.encode(\"utf-8\", \"surrogateescape\"))\n h.update(b\"\\n\")\nexcept OSError as e:\n fail(e)\n\nprint(f\"{kind}:{mode}:{h.hexdigest()}\")\nPYEOF\n}\n\n# リポジトリルート全体の状態シグネチャ(スコープ外書き込み検出の実体)。\n# 除外は次の 3 種のみ:\n# - スコープ内 — skills-lock.json / .agents/skills/${SKILL_NAME}(npx の正当な書き込み先)。\n# ただし許可先ディレクトリ自身の除外方法は SKILL_DIR_SIG_SPEC(npx 実行前に一度\n# だけ確定)で切り替える: 既存なら prune-under(配下のみ除外・要素自身の種別・\n# mode・symlink 先は署名)にして、npx が実行中に許可先を外向き symlink へ置換して\n# リンク先へ書く TOCTOU を前後シグネチャ不一致として検出する(PR #412 P0 指摘。\n# 事前の lstat 検査は開始時点しか見ない)。実行前に不存在(初回インストール)の\n# 場合のみ prune(エントリごと除外)にして正当な新規作成を誤検知にしない — この\n# 場合の symlink 置換・symlink としての新規作成は、npx 実行後の許可先経路\n# 再検証(verify_scope_path_after_run)が fail-closed で拒否する\n# - .git のうち、このフロー自身が前後スナップショット間に実行する git コマンドで\n# 変動し得る領域のみ — 前後シグネチャの間に走る git 操作は\n# `git status --porcelain -z -uall`(実行後スナップショット取得)だけであり、\n# status が触るのは index の stat cache 更新(.git/index)とその一時 lock\n# (.git/index.lock)のみ。リバート用の git checkout / git clean は\n# 実行後シグネチャ取得より後の失敗経路でしか呼ばれないため、署名比較に影響しない。\n# 以前は objects・refs・packed-refs・HEAD・logs・worktrees 等も prune していたが、\n# npx がこれらへ書き込む(履歴・参照の改変)とスコープ外検査を丸ごと迂回できて\n# しまうため(PR #412 P0 指摘)、実測で避けられない index・index.lock 以外は\n# すべて署名対象に含める。lock の prune を `.git/*.lock` のワイルドカードに\n# すると、npx が残した永続 lock(.git/config.lock・.git/HEAD.lock 等)まで\n# 検査から漏れるため(PR #412 codex P1 指摘)、自プロセスの git status が\n# 作り得る .git/index.lock だけを完全一致で prune する。\n# prune した .git/index の背後で npx が index の論理状態を改変するケースは、\n# index_state_signature(下記)の前後比較が受け持つ。\n# 代償として、同期実行中にこのリポジトリで並行 git 操作\n# (他 worktree 含む)を行うとシグネチャ不一致(誤検知)として停止し得る\n# (注意事項に明記。fail-closed 側に倒す設計判断)\n# - REPO_SIG_OMITS — 実行前に存在しなかった場合の .agents / .agents/skills\n# (npx 実行前スナップショットの直前に一度だけ確定する)。既存なら omit せず\n# 種別・mode・symlink 先を通常どおり署名するため、既存親ディレクトリの chmod や\n# ディレクトリ→symlink 置換は検出される(PR #412 P1 指摘)。不存在だった場合\n# のみ omit し、初回インストールで npx が親ディレクトリを正当に新規作成する\n# ケースを誤検知にしない。omit でも配下の走査は継続するため、同居する他スキルの\n# ツリー(スコープ外)は引き続き保護される\n#\n# REPO_SIG_OMITS は前後 2 回の呼び出しで同一でなければならない(実行後の存在有無で\n# 再判定すると、初回インストールの正当な新規作成が前後不一致=誤検知になる)。\n# 空配列の \"${arr[@]}\" 展開は bash 3.2 の set -u で unbound になるため\n# ${arr[@]+...} 形式で参照する。\nREPO_SIG_OMITS=()\n# 既定は prune-under(既存許可先向け)。初回インストール(実行前に不存在)の場合のみ\n# npx 実行前の判定ブロックで prune へ切り替える。前後 2 回の呼び出しで同一で\n# なければならない(REPO_SIG_OMITS と同じ理由)。\nSKILL_DIR_SIG_SPEC=\"prune-under:.agents/skills/${SKILL_NAME}\"\n\n# index の論理状態のシグネチャ。.git/index はファイルとしては prune せざるを得ない\n# (このフロー自身の git status が stat cache を正当に更新するため)が、その背後で\n# npx がエントリの追加・削除・blob 差し替えや skip-worktree / assume-unchanged\n# ビットの付与を行っても検出できなくなる(PR #412 codex P1 指摘。特に skip-worktree\n# を立てられると、以後その tracked ファイルの変更が git status から恒久的に隠れる)。\n# stat cache と独立な論理状態 — `git ls-files --stage`(mode・object・stage・パス)と\n# `git ls-files -v`(状態タグ。skip-worktree は S、assume-unchanged は小文字)— を\n# sha256 へまとめ、repo_state_signature の出力へ連結して前後比較する。git status の\n# stat cache 更新はどちらの出力も変えないため、このフロー自身に起因する誤検知はない。\n# 取得失敗は「変化なしと確認できない」ため非ゼロで返し、呼び出し側の fail-closed\n# (sentinel 比較)へ倒す。\nindex_state_signature() {\n local staged tags digest\n staged=\"$(git ls-files --stage)\" || return 1\n tags=\"$(git ls-files -v)\" || return 1\n digest=\"$(printf '%s\\n--\\n%s\\n' \"${staged}\" \"${tags}\" \\\n | python3 -c 'import hashlib, sys; print(hashlib.sha256(sys.stdin.buffer.read()).hexdigest())')\" || return 1\n printf '%s\\n' \"${digest}\"\n}\n\nrepo_state_signature() {\n local tree_sig index_sig\n tree_sig=\"$(path_state . \\\n \"prune:skills-lock.json\" \\\n \"${SKILL_DIR_SIG_SPEC}\" \\\n \"prune:.git/index\" \\\n \"prune:.git/index.lock\" \\\n ${REPO_SIG_OMITS[@]+\"${REPO_SIG_OMITS[@]}\"})\" || return 1\n index_sig=\"$(index_state_signature)\" || return 1\n printf '%s:index:%s\\n' \"${tree_sig}\" \"${index_sig}\"\n}\n\n# git status --porcelain -z の1レコード(\"XY PATH\\0\")からスコープ内\n# (skills-lock.json / .agents/skills/${SKILL_NAME}/ 配下)を除いたレコードだけを\n# outfile へ NUL 区切りで書き出す。検出の主体はリポジトリ全体の状態シグネチャ\n# (repo_state_signature)であり、このレコード列は status レベルの前後比較と、\n# 検出時の報告(どのパスが git status 上で変化したか)に使う。ディレクトリの\n# chmod 等 status に現れない変化はこの一覧に載らず、シグネチャ不一致としてのみ\n# 検出される。固定長プレフィックス(ステータス2文字+空白1文字=3文字)を\n# 切り落としてパスを取り出すため、C-quote(改行等を含むパスのダブルクォート化)の\n# 影響を受けない(-z 出力は raw byte のパスであり、path をそのままファイルアクセス\n# に使ってよい)。\nfilter_out_of_scope() {\n local infile=\"$1\" outfile=\"$2\" record path\n : > \"${outfile}\"\n while IFS= read -r -d '' record; do\n path=\"${record:3}\"\n if [[ \"${path}\" == \"skills-lock.json\" || \"${path}\" == \".agents/skills/${SKILL_NAME}/\"* ]]; then\n continue\n fi\n printf '%s\\0' \"${record}\" >> \"${outfile}\"\n done \u003c \"${infile}\"\n}\n\n# npx が新規作成した .gitignore 対象ファイルのみを許可先配下から削除する\n# (revert_in_scope の補助)。`git clean -fdx` は npx 実行前から許可先配下に存在した\n# ignored ファイル(.DS_Store 等)まで削除してしまうため使わない(PR #412 Bugbot\n# Medium 指摘)。実行前インベントリ(SCOPE_INVENTORY_FILE。find -print0 の NUL 区切り\n# 全ファイル一覧)に存在しないパスに限り、許可先配下であることを再検証したうえで\n# 個別削除する。実行前から存在した ignored ファイルは内容が書き換えられていても\n# 削除しない(保全。変更・削除の検出と復元は restore_preexisting_ignored が\n# 実行前バックアップとの比較で行う)。パス名に\n# スペース・改行を含み得る前提で、一覧の受け渡しは全経路 NUL 区切りで行う。\n# npx が新規作成した「ignored ファイルのみを含む空ディレクトリ」は best-effort で\n# 残り得るが、ファイル残置と異なり後続処理の誤認を生まないため許容する。\n# ls-files の出力は pipe ではなく一時ファイルで python3 へ渡す(ヒアドキュメントで\n# プログラムを与える python3 は stdin をヒアドキュメントに占有されるため、pipe との\n# 併用ができない — 併用すると読み手のいない pipe への書き込みで SIGPIPE になる)。\nremove_new_ignored_in_scope() {\n local ignored_list rc=0\n ignored_list=\"$(mktemp)\" || return 1\n if git ls-files -z --others --ignored --exclude-standard -- \".agents/skills/${SKILL_NAME}/\" > \"${ignored_list}\" 2>/dev/null; then\n SKILL_DIR=\".agents/skills/${SKILL_NAME}\" INVENTORY_FILE=\"${SCOPE_INVENTORY_FILE}\" IGNORED_LIST_FILE=\"${ignored_list}\" python3 - \u003c\u003c'PYEOF' || rc=1\nimport os\n\nskill_dir = os.environ[\"SKILL_DIR\"]\nprefix = skill_dir + \"/\"\nwith open(os.environ[\"INVENTORY_FILE\"], \"rb\") as f:\n inventory = {p for p in f.read().split(b\"\\0\") if p}\nwith open(os.environ[\"IGNORED_LIST_FILE\"], \"rb\") as f:\n ignored_paths = [p for p in f.read().split(b\"\\0\") if p]\n\nfor raw in ignored_paths:\n text = raw.decode(\"utf-8\", \"surrogateescape\")\n # 削除は kebab-case 検証済みの許可先配下に厳密に限定する(ls-files の出力を\n # 信用しきらず、prefix 一致と `..` セグメント不在を自衛的に再検証する)。\n if not text.startswith(prefix) or \"..\" in text.split(\"/\"):\n continue\n if raw in inventory:\n continue # 実行前から存在した ignored ファイルは保全する\n try:\n if os.path.isdir(text) and not os.path.islink(text):\n continue # ディレクトリ自体は削除対象にしない(ファイル・symlink のみ)\n os.unlink(text)\n except FileNotFoundError:\n pass\nPYEOF\n else\n rc=1\n fi\n rm -f \"${ignored_list}\"\n return \"${rc}\"\n}\n\n# 実行前から許可先配下に存在した ignored ファイル(.DS_Store 等)を、npx 実行前の\n# バックアップ(IGNORED_BACKUP_DIR。相対パス構造・mode を保持)と比較し、変化\n# (内容・mode・種別の変更、削除)があればバックアップから復元する。許可先配下は\n# npx の正当な書き込み先だが、ignored ファイルは同期対象外であり npx が変更して\n# よい理由がないため、変更を検出したら復元して警告する(処理自体は継続してよい\n# 契約。復元の失敗のみ呼び出し側で非ゼロ終了へ倒す)。許可先配下は\n# repo_state_signature の prune-under で署名から除外されるため、この変化は\n# シグネチャ比較では検出できず、バックアップとの直接比較だけが検出手段になる。\n# 事前の symlink 走査(npx 実行前)により対象は regular file のみである前提。\n# 復元先が npx により symlink 化されている可能性に備え、書き込み前に unlink し、\n# 親ディレクトリの realpath が許可先内に収まることを検証する(リンク先への\n# 書き込み防止)。親ディレクトリの再作成は make_parent_dirs(存在する最深の\n# 祖先の containment 検証 + 1 階層ずつの lstat 付き os.mkdir)で行い、\n# os.makedirs が中間 symlink を辿ってリポジトリ外へディレクトリを作る経路を\n# 残さない。バックアップとの比較・復元は mode 比較を含むため、stat の\n# 出力書式差(BSD/GNU)を避けて python3 の os.lstat に統一する。\nrestore_preexisting_ignored() {\n # バックアップ対象が無ければ(初回インストール・既存 ignored なし)何もしない。\n [[ -s \"${IGNORED_BASELINE_FILE}\" ]] || return 0\n SKILL_DIR=\".agents/skills/${SKILL_NAME}\" IGNORED_BACKUP_DIR=\"${IGNORED_BACKUP_DIR}\" IGNORED_BASELINE_FILE=\"${IGNORED_BASELINE_FILE}\" python3 - \u003c\u003c'PYEOF'\nimport os, shutil, stat, sys\n\nskill_dir = os.environ[\"SKILL_DIR\"]\nprefix = skill_dir + \"/\"\nbackup_dir = os.environ[\"IGNORED_BACKUP_DIR\"]\nroot = os.path.realpath(skill_dir)\n\n\ndef fail(msg):\n # 復元に失敗したまま正常終了すると、破壊された既存 ignored ファイルが\n # 「復元済み」として扱われてしまうため、必ず非ゼロ終了して呼び出し側で\n # fail-closed(バックアップ dir の保全 + 手動復旧の案内)に扱わせる。\n print(f\"restore_preexisting_ignored: {msg}\", file=sys.stderr)\n sys.exit(1)\n\n\ndef make_parent_dirs(parent):\n # os.makedirs を先に呼ぶと、中間ディレクトリが外向き symlink へ置換されて\n # いた場合にリンク先(リポジトリ外)へディレクトリを作ってしまう — 作成\n # 自体がスコープ逸脱であり、後段の containment チェックで停止しても外側の\n # dir は残る(PR #412 Bugbot Medium 指摘)。そこで先に「存在する最深の祖先」\n # の realpath が許可先の内側か、許可先へ至る素の経路上(root の祖先)にある\n # ことを検証し、さらに作成は 1 階層ずつ lstat で symlink・非ディレクトリで\n # ないことを確認しながら os.mkdir で行う(symlink を辿る経路を残さない。\n # 実行後の配下 symlink 走査が先に停止させるため通常は到達しない防御多層)。\n anc = parent\n while anc and not os.path.lexists(anc):\n anc = os.path.dirname(anc)\n if anc:\n real_anc = os.path.realpath(anc)\n if not (\n real_anc == root\n or real_anc.startswith(root + os.sep)\n or root.startswith(real_anc + os.sep)\n ):\n fail(f\"復元先の祖先が許可先の外を指しています: {parent}\")\n cur = \"\"\n for part in parent.split(os.sep):\n cur = os.path.join(cur, part) if cur else part\n try:\n cst = os.lstat(cur)\n except FileNotFoundError:\n os.mkdir(cur)\n continue\n if not stat.S_ISDIR(cst.st_mode):\n fail(f\"復元先の中間経路がディレクトリではありません(symlink 等): {cur}\")\n\n\nwith open(os.environ[\"IGNORED_BASELINE_FILE\"], \"rb\") as f:\n paths = [p for p in f.read().split(b\"\\0\") if p]\n\nfor raw in paths:\n text = raw.decode(\"utf-8\", \"surrogateescape\")\n # 復元は kebab-case 検証済みの許可先配下に厳密に限定する(自前で列挙した\n # 一覧でも、prefix 一致と `..` セグメント不在を自衛的に再検証する)。\n if not text.startswith(prefix) or \"..\" in text.split(\"/\"):\n continue\n backup = os.path.join(backup_dir, text)\n try:\n bst = os.lstat(backup)\n changed = False\n reason = \"\"\n try:\n cst = os.lstat(text)\n if not stat.S_ISREG(cst.st_mode):\n changed, reason = True, \"種別が変化\"\n elif stat.S_IMODE(cst.st_mode) != stat.S_IMODE(bst.st_mode):\n changed, reason = True, \"mode が変化\"\n else:\n with open(backup, \"rb\") as f1, open(text, \"rb\") as f2:\n while True:\n c1 = f1.read(1 \u003c\u003c 20)\n c2 = f2.read(1 \u003c\u003c 20)\n if c1 != c2:\n changed, reason = True, \"内容が変化\"\n break\n if not c1:\n break\n except FileNotFoundError:\n changed, reason = True, \"削除されていた\"\n if not changed:\n continue\n parent = os.path.dirname(text)\n if parent:\n make_parent_dirs(parent)\n real_parent = os.path.realpath(parent)\n # 親ディレクトリが symlink 化されているとリンク先(リポジトリ外を\n # 含む)へ書き込んでしまうため、realpath の包含を検証してから書く。\n if real_parent != root and not real_parent.startswith(root + os.sep):\n fail(f\"復元先の親ディレクトリが許可先の外を指しています: {text}\")\n try:\n # 復元先自体が symlink 化されていた場合にリンク先へ書かないよう、\n # 既存エントリを外してから regular file として書き戻す。\n os.unlink(text)\n except FileNotFoundError:\n pass\n shutil.copyfile(backup, text)\n os.chmod(text, stat.S_IMODE(bst.st_mode))\n print(\n f\"警告: npx が実行前から存在した ignored ファイルを変更したため\"\n f\"({reason})、バックアップから復元しました: {text}\",\n file=sys.stderr,\n )\n except OSError as e:\n fail(f\"{text} の復元に失敗: {e}\")\nPYEOF\n}\n\n# スコープ内(skills-lock.json / .agents/skills/${SKILL_NAME}/)の変更をリバートする。\n# npx 失敗・スコープ外検出・実行後 git status 取得失敗のすべての異常終端経路が\n# 共有する(PR #412 P1: どの異常経路でも生成済みのスコープ内変更を残置しない契約)。\n# 2つのパスを1つの `git checkout --` に渡すとアトミックに扱われ、どちらか一方が\n# 「追跡対象なし」(初回具現化・untracked のみの書き込み時)で pathspec エラーになると\n# コマンド全体が失敗し、もう一方(skills-lock.json)も復元されないまま抜けてしまう\n# ため、必ず1コマンド1パスで分離する。git clean はディレクトリが存在しない場合に\n# 非ゼロ終了するため、存在確認してから呼ぶ(set -e 下で無条件に呼ぶと呼び出し元の\n# 案内メッセージより先に停止し得る)。.gitignore 対象の残置\n# (Issue #413)は -fdx ではなく remove_new_ignored_in_scope(実行前インベントリ\n# との突き合わせ)で解消する(既存 ignored ファイルの巻き添え削除防止)。\n# 実行後再検証(verify_scope_path_after_run)が許可先経路の symlink 化・非\n# ディレクトリ化を検出した場合、または実行後の配下 symlink 走査が npx 実行中に\n# 作られた symlink を検出した場合(いずれも SCOPE_PATH_COMPROMISED=1)は、\n# checkout・clean・ignored 削除のパス走査がリンク先(リポジトリ外を含む)へ\n# 向かい得るため、skills-lock.json の復元のみ行い、許可先配下への削除系操作は\n# 一切行わず案内に留める(symlink を通じた外部削除の防止。fail-closed)。\n# skills-lock.json 自体が npx 実行中に symlink へ置換されたケース\n# (LOCK_FILE_COMPROMISED=1)では、素の git checkout がリンク先(リポジトリ外を\n# 含む)へ書き込み得るため、先に rm で symlink 自体を除去(リンク先は辿らない)\n# してから checkout で index の内容を regular file として書き戻す。symlink 以外の\n# 想定外実体(ディレクトリ等)は rm -f で除去できず checkout も安全に働かないため、\n# 一切触らず手動復旧を案内する。\n# SKILL_NAME はループの現在値を呼び出し時に参照する。\n# 第1引数(既定 0)を 1 にすると git clean -fd / remove_new_ignored_in_scope を\n# 一切実行しない「非破壊モード」になる(codex P0 指摘: preview_untracked の\n# git ls-files 失敗時など、未追跡集合を安全に列挙できなかった状態でこの関数を\n# 呼ぶと、checker / npx が作成した「唯一のコピーであり得る」未追跡ファイルを\n# バックアップなしで git clean -fd が削除してしまう。列挙が壊れている以上、\n# 何を消してよいか判定できないため、削除系操作は一切行わず手動復旧の案内に\n# 留める)。checkout(tracked のみが対象で未追跡ファイルには触れない)と\n# restore_preexisting_ignored(npx 実行前バックアップからの復元。列挙ではなく\n# 実行前に確定済みの一覧を使うため安全)はこのモードでも従来どおり実行する。\n#\n# Issue #422: 未追跡集合の「列挙自体」(git ls-files --others 等)が失敗し、1 件も\n# 退避できていない(=何が未退避か分からない)モードに限り、checkout が上書きし得る\n# worktree の現状を git 非依存の cp でまるごと保全してから tracked の checkout を\n# 行えるようにするための下ごしらえ。壊れている git には一切頼らず、ファイルシステム\n# 操作(cp -p / cp -RP)のみで skills-lock.json と .agents/skills/${SKILL_NAME}/ を\n# mktemp -d 先へ複製する。呼び出し元(revert_in_scope)は、この関数が成功した場合に\n# 限り checkout を実行し、失敗した場合は従来どおり checkout ごと丸ごとスキップする\n# (fail-closed。cp が失敗した状態で checkout すると、退避されていない worktree\n# 内容が無音に上書きされ得るため)。\ncontract_worktree_backup() {\n local dir\n if ! dir=\"$(mktemp -d)\"; then\n echo \"エラー: worktree 保全用の退避先ディレクトリ作成に失敗しました。\" >&2\n return 1\n fi\n local ok=1\n if [[ -e \"skills-lock.json\" ]]; then\n if [[ -L \"skills-lock.json\" ]]; then\n # symlink はリンク先(リポジトリ外を含む)を辿らないよう対象外にする\n # (SCOPE_PATH_COMPROMISED / LOCK_FILE_COMPROMISED と同じ fail-closed 判断。\n # 呼び出し元はこれらのフラグが 0 のときのみこの関数を呼ぶため通常到達しないが、\n # 二重の防御として保持する)。\n echo \"エラー: skills-lock.json がシンボリックリンクのため保全できません。\" >&2\n ok=0\n elif [[ -f \"skills-lock.json\" ]]; then\n cp -p -- \"skills-lock.json\" \"${dir}/skills-lock.json\" || ok=0\n fi\n fi\n if [[ \"${ok}\" -eq 1 && -e \".agents/skills/${SKILL_NAME}\" ]]; then\n if [[ -L \".agents/skills/${SKILL_NAME}\" ]]; then\n echo \"エラー: .agents/skills/${SKILL_NAME}/ がシンボリックリンクのため保全できません。\" >&2\n ok=0\n elif [[ -d \".agents/skills/${SKILL_NAME}\" ]]; then\n # -RP: シンボリックリンクを辿らずリンク自体を複製する(配下に symlink が\n # 混在していてもリンク先へは書き込み・読み取りしない)\n if ! mkdir -p \"${dir}/.agents/skills\"; then\n ok=0\n elif ! cp -RP -- \".agents/skills/${SKILL_NAME}\" \"${dir}/.agents/skills/${SKILL_NAME}\"; then\n ok=0\n fi\n fi\n fi\n if [[ \"${ok}\" -eq 1 ]]; then\n CONTRACT_WORKTREE_BACKUP_DIR=\"${dir}\"\n return 0\n fi\n # 部分コピーが残っている場合はデータ保全を優先し、削除せず退避先を保全する。\n # 何もコピーされていない(空のまま)場合のみ退避先ディレクトリを掃除する。\n if [[ -z \"$(find \"${dir}\" -mindepth 1 -print -quit 2>/dev/null)\" ]]; then\n rmdir \"${dir}\" 2>/dev/null || true\n else\n echo \"エラー: worktree の保全が一部失敗しましたが、退避できた分は ${dir} に残しています。\" >&2\n fi\n return 1\n}\n\nrevert_in_scope() {\n local skip_clean=\"${1:-0}\"\n # 呼び出し元が「実際に checkout / clean が走ったか」を判定できるよう、この\n # 呼び出し内で保全のため checkout・clean をスキップした場合のみ 1 を立てる\n # (Bugbot Medium 指摘・Issue #418 系: post-npx の呼び出し元がこの値を見ずに\n # 「スコープ内の変更はリバートしました」と無条件表示すると、worktree に未退避の\n # 変更が残っているのに復元済みと誤認させる。codex P1 指摘・PR #420: 個別の原因\n # フラグの寄せ集めで表示分岐すると SCOPE_PATH_COMPROMISED 経路のような変種を\n # 取りこぼすため、スキップの有無はこの単一フラグへ一元化する。呼び出しごとに\n # 0 へ戻し、checkout / clean / 復元のいずれかをスキップした経路で必ず 1 にする)。\n REVERT_IN_SCOPE_SKIPPED=0\n if [[ \"${CONTRACT_UNTRACKED_BACKUP_FAILED:-0}\" -eq 1 ]]; then\n # restore_contract_scope が契約範囲内の未追跡ファイル退避に失敗し、fail-closed で\n # index のみ復元(worktree は意図的に未復元)へ降格した直後の呼び出し。ここで\n # 通常どおり `git checkout -- \u003c契約パス>` を実行すると、その index(すでに\n # PRE_SYNC_TREE へ戻されている)を worktree へ書き戻し、restore_contract_scope が\n # 守ろうとした未退避の worktree 内容(checker / npx が作成した唯一のコピーで\n # あり得る)を無音に上書きしてしまう(Bugbot 指摘: Index restore enables later\n # overwrite / Issue #418 の再発)。既存 ignored の復元だけは untracked backup の\n # 失敗と無関係(別のバックアップ機構)のため、このブロックの外側で従来どおり実行する。\n #\n # Issue #422: ただし「未追跡集合の列挙自体」が失敗し 1 件も退避できていない\n # モード(個別 mv 失敗と異なり、何が未退避か分からないだけで worktree の現状\n # 自体は破損していない)に限り、checkout 前に git 非依存の cp で worktree を\n # まるごと保全できれば、tracked 分(skills-lock.json / .agents/skills 配下)の\n # checkout は行ってよい。これにより checker が無いリポジトリ(preview_untracked\n # ケース7)と同じ「tracked はリバートされる」挙動に揃える。妥協検出\n # (SCOPE_PATH_COMPROMISED / LOCK_FILE_COMPROMISED)がある場合は、checkout 先の\n # パス自体の信頼性が崩れているため、この救済経路には進まない。\n if [[ \"${CONTRACT_UNTRACKED_ENUM_FAILED:-0}\" -eq 1 \\\n && \"${SCOPE_PATH_COMPROMISED:-0}\" -eq 0 \\\n && \"${LOCK_FILE_COMPROMISED:-0}\" -eq 0 ]] \\\n && contract_worktree_backup; then\n # 1 コマンド 1 パスで分離する理由は他の checkout 呼び出しと同じ\n # (revert_in_scope 冒頭のコメント参照)。git clean は未追跡集合が依然\n # 不明なままのため、この経路でも実行しない。\n git checkout -- skills-lock.json 2>/dev/null || true\n git checkout -- \".agents/skills/${SKILL_NAME}/\" 2>/dev/null || true\n echo \"警告: 契約範囲内の未追跡ファイル列挙が失敗したため、checkout 前の worktree 内容を ${CONTRACT_WORKTREE_BACKUP_DIR} へ複製してから skills-lock.json / .agents/skills/${SKILL_NAME}/ の tracked 分のみ checkout しました(git clean は実行していません)。git status --porcelain -- skills-lock.json \\\".agents/skills/${SKILL_NAME}/\\\" で残留未追跡を確認し、必要なら手動で後始末してください(fail-closed。完全復元ではありません)。\" >&2\n else\n echo \"警告: 契約範囲内の未追跡ファイル退避が失敗したため、skills-lock.json / .agents/skills/${SKILL_NAME}/ の checkout・git clean は行いません(index のみ復元済みの worktree を上書きしないための保全)。git status --porcelain -- skills-lock.json \\\".agents/skills/${SKILL_NAME}/\\\" で内容を確認し、必要なら手動で復旧してください(fail-closed)。\" >&2\n fi\n REVERT_IN_SCOPE_SKIPPED=1\n else\n if [[ \"${LOCK_FILE_COMPROMISED}\" -ne 0 ]]; then\n if [[ -L \"skills-lock.json\" ]]; then\n rm -f skills-lock.json\n git checkout -- skills-lock.json 2>/dev/null || true\n elif [[ -e \"skills-lock.json\" && ! -f \"skills-lock.json\" ]]; then\n echo \"警告: skills-lock.json が想定外の実体(ディレクトリ等)に置換されているため自動復元しません。実体を確認・除去してから git checkout -- skills-lock.json で復元してください。\" >&2\n # skills-lock.json の復元をスキップしたため完全復元ではない\n REVERT_IN_SCOPE_SKIPPED=1\n else\n git checkout -- skills-lock.json 2>/dev/null || true\n fi\n else\n git checkout -- skills-lock.json 2>/dev/null || true\n fi\n if [[ \"${SCOPE_PATH_COMPROMISED}\" -ne 0 ]]; then\n # symlink 越しの走査を避けるため既存 ignored の復元も行わない。復元素材を\n # 失わないよう、バックアップ dir は削除せず保全して手動復旧に委ねる。\n IGNORED_BACKUP_KEEP=1\n echo \"警告: 許可先経路またはその配下が symlink 等へ置換・作成されているため、.agents/skills/${SKILL_NAME}/ への checkout・git clean・ignored 削除・既存 ignored の復元は行いません(リンク先への削除・書き込みを避けるため)。symlink の指す先と許可先の内容を手動確認し、symlink を除去してから復旧してください。既存 ignored ファイルのバックアップは ${IGNORED_BACKUP_DIR} に相対パス構造で残っています。\" >&2\n # codex P1 指摘(PR #420): この経路は CONTRACT_UNTRACKED_BACKUP_FAILED が\n # 0 のまま checkout・clean をスキップして return するため、ここでも必ず立てる\n REVERT_IN_SCOPE_SKIPPED=1\n return 0\n fi\n git checkout -- \".agents/skills/${SKILL_NAME}/\" 2>/dev/null || true\n if [[ \"${skip_clean}\" == \"1\" ]]; then\n echo \"警告: 未追跡ファイル一覧を安全に取得できなかったため、.agents/skills/${SKILL_NAME}/ 配下の git clean は実行していません(checker / npx が作成した未追跡ファイルを誤って削除しないための保全。データ喪失防止を優先)。npx / checker による書き込み・未追跡ファイルが残っている可能性があるため、次を手動で確認してください: git status --porcelain -- \\\".agents/skills/${SKILL_NAME}/\\\"(内容を確認したうえで不要なもののみ git clean -fd \\\".agents/skills/${SKILL_NAME}/\\\" で削除する)。\" >&2\n # git clean をスキップし未追跡が残り得るため完全復元ではない\n REVERT_IN_SCOPE_SKIPPED=1\n elif [[ -d \".agents/skills/${SKILL_NAME}/\" ]]; then\n git clean -fd -- \".agents/skills/${SKILL_NAME}/\" || true\n remove_new_ignored_in_scope || true\n fi\n fi\n # 既存 ignored の変更・削除はここでバックアップから戻す(checkout / clean /\n # remove_new_ignored_in_scope はいずれも既存 ignored に触れないため、この復元が\n # 唯一の回復経路)。復元自体が失敗した場合はバックアップ dir を保全して案内し、\n # IGNORED_RESTORE_FAILED で呼び出し元へ伝える(手動復旧を要するため、npx 失敗\n # 経路でも skip(continue)せずループ全体を exit 1 で停止させる)。\n if ! restore_preexisting_ignored; then\n IGNORED_RESTORE_FAILED=1\n IGNORED_BACKUP_KEEP=1\n # codex P1 指摘(PR #420・3 巡目): 復元失敗もフラグ契約の「復元をスキップした\n # (完全実行できなかった)経路」に含まれる。ここで立てないと status 取得失敗の\n # 呼び出し元が、既存 ignored が未復元のまま「変更はリバートしました」と表示して\n # しまう(IGNORED_RESTORE_FAILED はループ停止の判定用で、表示分岐はこの単一\n # フラグに一元化する)\n REVERT_IN_SCOPE_SKIPPED=1\n echo \"エラー: 実行前から存在した ignored ファイルの復元に失敗しました。バックアップは ${IGNORED_BACKUP_DIR} に相対パス構造で残っています。手動で復旧してください。\" >&2\n fi\n}\n\n# npx 実行後の許可先経路の再検証(PR #412 P0 指摘: TOCTOU)。事前の lstat 検査は\n# 開始時点しか見ないため、npx が実行中に許可先(またはその親)を外向き symlink へ\n# 置換した・初回インストールで .agents 自体を symlink として作成したケースは、\n# 事後に全パス要素を再度 lstat しなければ検出できない(存在する要素はすべて実体の\n# ディレクトリであることを要求し、symlink・非ディレクトリは fail-closed で拒否)。\n# 併せて既存許可先は prune-under により要素自身が前後シグネチャへ署名されるため、\n# 置換自体もシグネチャ不一致として検出される(この関数はその場合の原因特定と、\n# 初回インストール(prune でシグネチャに現れない)の防御を担う)。\nverify_scope_path_after_run() {\n local component\n for component in \".agents\" \".agents/skills\" \".agents/skills/${SKILL_NAME}\"; do\n if [[ -L \"${component}\" ]]; then\n echo \"エラー: npx 実行後の再検証で ${component} がシンボリックリンクになっています。npx が実行中に許可先を symlink へ置換し、リンク先(リポジトリ外を含む)へ書き込んだ可能性があります。リンク先の内容とリポジトリ外への書き込み有無を手動確認してください(fail-closed)。\" >&2\n return 1\n fi\n if [[ -e \"${component}\" && ! -d \"${component}\" ]]; then\n echo \"エラー: npx 実行後の再検証で ${component} がディレクトリではありません。npx の書き込み先として想定外の実体のため、手動確認が必要です(fail-closed)。\" >&2\n return 1\n fi\n done\n return 0\n}\n\n# npx 実行後の skills-lock.json の実体再検証(PR #412 codex P0 指摘: TOCTOU)。\n# skills-lock.json は署名の prune と porcelain フィルタの双方からパスで除外される\n# ため、npx が実行中に外向き symlink へ置換してリンク先へ書き込んでもどの検査にも\n# 現れない。実行後に lstat し、存在するのに regular file でなければ fail-closed で\n# 拒否する(事前検証時の不存在→生成のケースも regular file であることを要求する。\n# 実行後の不存在は改変ではなく npx の書き込み不発のため、ここでは不問とする)。\nverify_lock_file_after_run() {\n if [[ -L \"skills-lock.json\" ]]; then\n echo \"エラー: npx 実行後の再検証で skills-lock.json がシンボリックリンクになっています。npx が実行中に置換し、リンク先(リポジトリ外を含む)へ書き込んだ可能性があります。リンク先の内容とリポジトリ外への書き込み有無を手動確認してください(fail-closed)。\" >&2\n return 1\n fi\n if [[ -e \"skills-lock.json\" && ! -f \"skills-lock.json\" ]]; then\n echo \"エラー: npx 実行後の再検証で skills-lock.json が regular file ではありません。npx の書き込み先として想定外の実体のため、手動確認が必要です(fail-closed)。\" >&2\n return 1\n fi\n return 0\n}\n\n# npx 実行前後のスナップショット比較(スコープ外書き込みの多層防御)用の一時ファイル。\n# -uall は新規ディレクトリの collapse(`?? .agents/` への丸め)を防ぐため必須。\n# status.renames=false でユーザー環境の git config に依存せず rename 検出を無効化する。\nSNAP_BEFORE=\"$(mktemp)\"\nSNAP_AFTER=\"$(mktemp)\"\nSNAP_FILTERED_BEFORE=\"$(mktemp)\"\nSNAP_FILTERED_AFTER=\"$(mktemp)\"\nNPX_OUTPUT_FILE=\"$(mktemp)\"\nSCOPE_INVENTORY_FILE=\"$(mktemp)\"\nIGNORED_BASELINE_FILE=\"$(mktemp)\"\n# 既存 ignored ファイルの実行前バックアップ先(相対パス構造・mode を保持)。\n# 復元失敗時は IGNORED_BACKUP_KEEP=1 で削除を抑止し、手動復旧の素材として残す。\nIGNORED_BACKUP_DIR=\"$(mktemp -d)\"\nIGNORED_BACKUP_KEEP=0\nIGNORED_RESTORE_FAILED=0\n\n# npx 実行後の許可先経路 再検証(verify_scope_path_after_run)と許可先配下の\n# 実行後 symlink 走査の結果フラグ。1 のとき revert_in_scope は許可先配下への\n# 削除系操作を行わない(symlink 越しのリポジトリ外削除の防止)。npx 実行直後に\n# 一度だけ更新する。\nSCOPE_PATH_COMPROMISED=0\n# skills-lock.json の実行後 再検証(verify_lock_file_after_run)の結果フラグ。\n# 1 のとき revert_in_scope は素の git checkout でリンク先へ書き込まないよう、\n# symlink を rm で除去してから復元する。npx 実行直後に一度だけ更新する。\nLOCK_FILE_COMPROMISED=0\n\n# npx 実行前の許可先配下の全ファイルインベントリ(.gitignore 対象を含む。\n# find -print0 の NUL 区切り)。revert_in_scope(remove_new_ignored_in_scope)が\n# 「npx が新規作成した ignored ファイル」だけを選別削除するための基準になる\n# (実行前から存在した ignored ファイルの巻き添え削除防止。PR #412 Bugbot Medium\n# 指摘)。取得に失敗すると選別基準を失い、リバートが既存 ignored ファイルを誤削除\n# し得るため fail-closed で中止する(この時点では npx 未実行のため残置なし)。\n# 許可先が不存在(初回インストール)の場合は空インベントリ(npx が作るものはすべて\n# 新規)とする。\nif [[ -e \".agents/skills/${SKILL_NAME}\" ]]; then\n # 許可先配下の既存 symlink の事前拒否。repo_state_signature は prune-under で\n # 許可先配下を署名から除外するため、配下に実行前から symlink があると、npx が\n # リンクを辿ってリポジトリ外へ書き込んでも前後比較のどの検査にも現れない\n # (ルート 3 要素の lstat 検証は経路自身しか見ず、配下は守れない)。1 件でも\n # あれば npx 未実行のまま fail-closed で中止する。この拒否により、後段の既存\n # ignored バックアップ・復元は対象を regular file のみと仮定できる。走査自体の\n # 失敗も「symlink なしと確認できない」であって「なし」ではないため中止する。\n # レイアウト自体の異常で人間の確認を要するため、skip(continue)ではなく\n # ループ全体を exit 1 で停止する(事前 lstat 検査と同じ扱い。npx 未実行のため残置なし)。\n if ! SCOPE_PREEXISTING_SYMLINKS=\"$(find \".agents/skills/${SKILL_NAME}\" -type l)\"; then\n echo \"エラー: 許可先配下の symlink 走査(find)に失敗しました。リポジトリ外への書き込み経路が無いことを確認できないため中止します(fail-closed)。\" >&2\n exit 1\n fi\n if [[ -n \"${SCOPE_PREEXISTING_SYMLINKS}\" ]]; then\n echo \"エラー: 許可先(.agents/skills/${SKILL_NAME})配下に既存のシンボリックリンクがあります。npx がリンクを辿ってリポジトリ外へ書き込んでも、配下を署名から除外している前後比較では検出できないため中止します(fail-closed)。以下を実体ファイルへ置き換えるか削除してから再実行してください:\" >&2\n printf '%s\\n' \"${SCOPE_PREEXISTING_SYMLINKS}\" >&2\n exit 1\n fi\n if ! find \".agents/skills/${SKILL_NAME}\" -print0 > \"${SCOPE_INVENTORY_FILE}\"; then\n echo \"エラー: npx 実行前の許可先インベントリ取得(find)に失敗しました。リバート時に既存 ignored ファイルを誤削除しないための基準を確保できないため中止します。\" >&2\n exit 1\n fi\n # 既存 ignored ファイル(実行前時点)の列挙とバックアップ。実行前インベントリは\n # パス一覧のみで内容を持たないため、npx が既存 ignored を上書き・削除しても\n # そのままでは復元も検出もできない(許可先配下は署名から除外され、シグネチャ\n # 比較にも現れない)。内容・mode を相対パス構造ごと退避し(cp -p)、実行後に\n # restore_preexisting_ignored が比較・復元する。列挙・バックアップの失敗は\n # 復元素材を確保できないため npx 未実行のまま中止する(fail-closed)。\n if ! git ls-files -z --others --ignored --exclude-standard -- \".agents/skills/${SKILL_NAME}/\" > \"${IGNORED_BASELINE_FILE}\"; then\n echo \"エラー: npx 実行前の既存 ignored ファイル列挙(git ls-files)に失敗しました。上書き・削除からの復元素材を確保できないため中止します(fail-closed)。\" >&2\n exit 1\n fi\n while IFS= read -r -d '' IGNORED_PATH; do\n if ! mkdir -p \"${IGNORED_BACKUP_DIR}/${IGNORED_PATH%/*}\" \\\n || ! cp -p -- \"${IGNORED_PATH}\" \"${IGNORED_BACKUP_DIR}/${IGNORED_PATH}\"; then\n echo \"エラー: 既存 ignored ファイルのバックアップに失敗しました: ${IGNORED_PATH} — 上書き・削除からの復元素材を確保できないため中止します(fail-closed)。\" >&2\n exit 1\n fi\n done \u003c \"${IGNORED_BASELINE_FILE}\"\nfi\n\nif ! git -c status.renames=false status --porcelain -z -uall > \"${SNAP_BEFORE}\"; then\n echo \"エラー: npx 実行前の git status 取得に失敗しました。スコープ外書き込みの検出ができないため中止します。\" >&2\n exit 1\nfi\n\nfilter_out_of_scope \"${SNAP_BEFORE}\" \"${SNAP_FILTERED_BEFORE}\"\n\n# .agents / .agents/skills の omit(自身のメタデータ不記録)は「npx 実行前に存在\n# したか」で決める。既存なら通常どおり署名対象にして chmod・ディレクトリ→symlink\n# 置換を検出し(PR #412 P1 指摘)、不存在だった場合のみ omit して初回インストール\n# での正当な親ディレクトリ新規作成を誤検知にしない。判定は npx 実行前のここで\n# 一度だけ行い、前後の両シグネチャで同じ除外集合を使う(実行後に再判定すると\n# 初回作成が前後不一致になる)。-e は壊れた symlink で偽になるため -L も併せて\n# 見る(symlink 自体は「存在」として署名対象に含める)。\nif [[ ! -e \".agents\" && ! -L \".agents\" ]]; then\n REPO_SIG_OMITS+=(\"omit:.agents\")\nfi\nif [[ ! -e \".agents/skills\" && ! -L \".agents/skills\" ]]; then\n REPO_SIG_OMITS+=(\"omit:.agents/skills\")\nfi\n# 許可先ディレクトリ自身も同じ基準で確定する: 既存なら prune-under(既定値)のまま\n# 要素自身を署名し、npx 実行中のディレクトリ→symlink 置換(TOCTOU)を前後不一致で\n# 検出する。実行前に不存在(初回インストール)の場合のみ prune へ切り替え、npx に\n# よる正当な新規作成を誤検知にしない(このケースの symlink 化は実行後の\n# verify_scope_path_after_run が拒否する)。\nif [[ ! -e \".agents/skills/${SKILL_NAME}\" && ! -L \".agents/skills/${SKILL_NAME}\" ]]; then\n SKILL_DIR_SIG_SPEC=\"prune:.agents/skills/${SKILL_NAME}\"\nfi\n\n# リポジトリ全体の状態シグネチャは「その時点のディスク内容・モード」を読むため、\n# 必ず npx を呼ぶ前にここで確定させる。npx 実行後に取得すると「変更前」のつもりが\n# 実質「変更後」と一致してしまい、実行前から M・?? だったスコープ外ファイルの\n# 内容・モードだけの上書きを見逃す。取得失敗時はスコープ外書き込みを検出できない\n# ため fail-closed で中止する(この時点では npx 未実行のため残置なし)。\nif ! REPO_STATE_BEFORE=\"$(repo_state_signature)\"; then\n echo \"エラー: npx 実行前の状態シグネチャ取得に失敗しました。スコープ外書き込みの検出ができないため中止します。\" >&2\n exit 1\nfi\n\n# CLI に computedHash を更新させる。固定版が解決できない場合(該当版の不存在・\n# レジストリ障害)は npx が非ゼロ終了する。その場合は当該スキルを中止(skip)し、\n# 固定版を外した再実行はしない(fail-closed。暗黙の最新版フォールバックはしない)。\n# ここでの fail-closed は「他スキルへの処理を止めない」という Step 1/3 の他の skip\n# 分岐と同じ意味であり、「script 全体を停止する」という意味ではない\n# (`scripts/skills-lock-update.sh` 単体実行時の set -euo pipefail による停止とは別軸。\n# 詳細は下記「skills CLI のバージョン固定と更新手順」節の fail-closed 記述を参照)。\n# --agent universal は書き込み先を union ストア(.agents/skills/\u003cname>/)+\n# skills-lock.json のみに限定する一次防御。個別 agent 指定(claude-code 等)は\n# .agents/skills を経由せず .claude/skills/ 等へ直接コピーしレイアウトを変えるため\n# 使わない(実測: スクラッチリポジトリで --agent universal のみ .agents/skills/\n# 以外へ書き込みが無いことを確認済み)。npx の出力を tee で NPX_OUTPUT_FILE へも\n# 保存し、後段の「Invalid agents」no-op 検出に使う。PIPESTATUS は pipeline 直後の\n# 「次のコマンド実行前」にしか正しい値を保持しない。変数代入も1個のコマンドと\n# 数えられるため、`NPX_STATUS=...; TEE_STATUS=...` のように2つの代入に分けると、\n# 1つ目の代入自体がその時点の PIPESTATUS を(要素数1・値0の配列へ)上書きして\n# しまい、2つ目の代入が参照する PIPESTATUS[1] は `set -u` 下で unbound variable\n# エラーになる(実測: bash 5.3 で再現)。配列全体を単一の代入\n# `arr=(\"${PIPESTATUS[@]}\")` でスナップショットし、添字アクセスはそのコピーに対して\n# 行う。npx([0])・tee([1])両方を読む理由: tee がディスク容量不足等で非ゼロ終了\n# すると NPX_OUTPUT_FILE が空・不完全なまま残り得るが、npx 自体は成功(exit 0)\n# し得るため、tee 側の失敗は npx の終了コードだけを見る分岐からは検出できない\n# (Issue #410 CI 失敗指摘)。TEE_STATUS が非ゼロなら、その不完全な NPX_OUTPUT_FILE\n# を前提にした「Invalid agents」no-op 判定を信頼せず、NPX_STATUS を強制的に失敗へ\n# 倒して以降の失敗経路(事後スコープ外検査・スコープ内リバート・skip)へ合流させる。\n# errexit の無効化(scripts/skills-lock-update.sh と同じ理由): このフェンスが\n# `set -e`/`pipefail` の下で実行される場合、npx が非ゼロ終了すると tee との\n# パイプライン全体が失敗し、次行の PIPE_EXIT_SNAPSHOT 取得より前にシェルが\n# その場で終了してしまう。そうなると事後のスコープ外検査・スコープ内リバート\n# (下の NPX_STATUS 分岐)に到達できず、`skills-lock.json` / スキルツリーの\n# 部分書き込みが検査もリバートもされないまま残置される(Bugbot High 指摘)。\n# npx 行の前後だけ errexit を無効化し、pipeline の終了コードを PIPESTATUS から\n# 読んでから元の状態(`$-`)を保存し、元々有効だった場合のみ戻す。本フェンスは\n# scripts/skills-lock-update.sh と異なり、errexit が無効なエージェント対話シェルへ\n# コピペ実行され得る。無条件 `set -e` で復元すると呼び出し元シェルへ errexit を\n# 新規に有効化してしまい、同一シェルで後続する Step 6 却下コマンド等がガードの\n# 無い非ゼロ終了で途中 abort し得るため、元々有効だった場合のみ再有効化する\n# (Issue #417)。\ncase $- in *e*) ERREXIT_WAS_ON=1 ;; *) ERREXIT_WAS_ON=0 ;; esac\nset +e\nnpx --yes \"skills@${SKILLS_CLI_VERSION}\" add \"${SOURCE}\" --skill \"${SKILL_NAME}\" --agent universal --yes 2>&1 | tee \"${NPX_OUTPUT_FILE}\"\nPIPE_EXIT_SNAPSHOT=(\"${PIPESTATUS[@]}\")\nif [[ \"${ERREXIT_WAS_ON}\" -eq 1 ]]; then set -e; fi\nNPX_STATUS=\"${PIPE_EXIT_SNAPSHOT[0]}\"\nTEE_STATUS=\"${PIPE_EXIT_SNAPSHOT[1]}\"\n\nif [[ \"${TEE_STATUS}\" -ne 0 ]]; then\n echo \"警告: npx の出力を ${NPX_OUTPUT_FILE} へ保存する tee が失敗しました(終了コード ${TEE_STATUS}。ディスク容量不足等)。出力ファイルが不完全なため、npx 自体の終了コード(${NPX_STATUS})に関わらず失敗として扱います。\"\n NPX_STATUS=1\nfi\n\n# 許可先経路の実行後再検証(PR #412 P0 指摘: 実行中の symlink 置換 / 初回\n# インストールでの symlink 作成)。事後シグネチャ取得より前にここで一度だけ判定し、\n# 検出時は共通失敗経路へ合流させる(revert_in_scope は SCOPE_PATH_COMPROMISED を\n# 見て許可先配下への削除系操作を行わず、リポジトリ外書き込みの可能性の手動確認を\n# 案内する)。\nif ! verify_scope_path_after_run; then\n SCOPE_PATH_COMPROMISED=1\n NPX_STATUS=1\nfi\n\n# skills-lock.json の実行後 再検証(PR #412 codex P0 指摘: 実行中の symlink 置換)。\n# 検出時は共通失敗経路へ合流させる(revert_in_scope は LOCK_FILE_COMPROMISED を見て\n# symlink を rm で除去してから checkout し、リンク先への書き込みを避ける)。\nif ! verify_lock_file_after_run; then\n LOCK_FILE_COMPROMISED=1\n NPX_STATUS=1\nfi\n\n# 許可先配下の実行後 symlink 走査(PR #412 Bugbot High 指摘: TOCTOU)。事前走査は\n# npx 実行前の 0 件しか保証せず、npx が実行中に配下へ作った外向き symlink とそれ\n# 経由の書き込みは、配下を署名から除外している前後比較にも、経路 3 要素しか見ない\n# verify_scope_path_after_run にも現れない。事後に見つかる symlink はすべて npx\n# 実行中の作成物であり、これを残したまま checkout・clean・ignored 削除・復元を\n# 走らせるとリンク先(リポジトリ外を含む)への削除・書き込みになり得るため、\n# 1 件でも検出したら経路妥協(SCOPE_PATH_COMPROMISED)と同じ保全経路へ倒す。\n# 走査自体の失敗も「symlink なしと確認できない」であって「なし」ではないため\n# 同様に倒す(fail-closed)。\nif [[ \"${SCOPE_PATH_COMPROMISED}\" -eq 0 && -d \".agents/skills/${SKILL_NAME}\" ]]; then\n if ! SCOPE_POST_SYMLINKS=\"$(find \".agents/skills/${SKILL_NAME}\" -type l)\"; then\n echo \"エラー: npx 実行後の許可先配下 symlink 走査(find)に失敗しました。リンク先(リポジトリ外を含む)への書き込み経路が無いことを確認できないため、許可先配下への自動復旧を行わず停止します(fail-closed)。\" >&2\n SCOPE_PATH_COMPROMISED=1\n NPX_STATUS=1\n elif [[ -n \"${SCOPE_POST_SYMLINKS}\" ]]; then\n echo \"エラー: npx が実行中に許可先(.agents/skills/${SKILL_NAME})配下へシンボリックリンクを作成しました。リンク先(リポジトリ外を含む)へ書き込んだ可能性があり、symlink 越しの走査を避けるため許可先配下への自動復旧は行いません。リンク先の内容とリポジトリ外への書き込み有無を手動確認してください(fail-closed):\" >&2\n printf '%s\\n' \"${SCOPE_POST_SYMLINKS}\" >&2\n SCOPE_PATH_COMPROMISED=1\n NPX_STATUS=1\n fi\nfi\n\n# CLI がバージョン更新等で --agent universal を認識できなくなった場合、\n# エラー表示のうえ exit 0 の no-op になる(実測: [email protected] で確認済み)。\n# 検知しないまま先へ進むと「同期したつもりで何も更新されていない」まま完了扱いに\n# なるため、npx 自体の終了コードとは別に明示的な失敗として扱う(上の TEE_STATUS\n# チェックで NPX_STATUS が強制失敗化されていればここは通らない)。\n# この分岐で直接 exit すると実行後スナップショット・シグネチャ比較・スコープ内\n# リバートをすべて迂回する(PR #412 P1 指摘)。「Invalid agents」は CLI の出力文言に\n# 過ぎず、将来版が部分書き込みの後に同じ文言を出しても no-op とは限らないため、\n# NPX_STATUS=1 を設定して下の共通失敗経路へ合流させ、「成功・失敗いずれの経路でも\n# 事後検査へ必ず到達」の契約を守る。universal 無効は以降の全スキルでも再現し\n# 続行に意味がないため、共通失敗経路の末尾で continue ではなく exit 1 で終端する\n# (NPX_NOOP_DETECTED がその分岐と汎用メッセージの抑止を担う)。\nNPX_NOOP_DETECTED=0\nif [[ \"${NPX_STATUS}\" -eq 0 && \"${SCOPE_PATH_COMPROMISED}\" -eq 0 ]] && grep -q \"Invalid agents\" \"${NPX_OUTPUT_FILE}\"; then\n echo \"エラー: skills CLI が --agent universal を認識せず、何も実行していません(exit 0 の no-op)。SKILLS_CLI_VERSION 更新時は「skills CLI のバージョン固定と更新手順」節に従い universal の有効性を再確認してください。\" >&2\n NPX_NOOP_DETECTED=1\n NPX_STATUS=1\nfi\n\nif [[ \"${NPX_STATUS}\" -ne 0 ]]; then\n if [[ \"${NPX_NOOP_DETECTED}\" -eq 0 && \"${SCOPE_PATH_COMPROMISED}\" -eq 0 && \"${LOCK_FILE_COMPROMISED}\" -eq 0 ]]; then\n echo \"警告: skills@${SKILLS_CLI_VERSION} の実行が失敗しました(該当版の不存在・レジストリ障害・ダウンロード中断等、原因は問わない)。\"\n fi\n # 失敗が部分書き込み後に発生した場合、skills-lock.json / .agents/skills/${SKILL_NAME}/ に\n # 加えてスコープ外(他エージェントツリー等)にも残置され得るため、失敗経路でも\n # 実行後スナップショットを取ってスコープ外残留を検査する(下の成功経路と同一ロジック)。\n git -c status.renames=false status --porcelain -z -uall > \"${SNAP_AFTER}\" || true\n filter_out_of_scope \"${SNAP_AFTER}\" \"${SNAP_FILTERED_AFTER}\"\n # 実行後シグネチャの取得失敗は「変化なしと確認できない」であって「変化なし」では\n # ないため、比較不能な sentinel を入れて必ず不一致(= 残置疑いの報告)へ倒す。\n if ! REPO_STATE_AFTER=\"$(repo_state_signature)\"; then\n echo \"エラー: npx 実行後の状態シグネチャ取得に失敗しました。変化なしと確認できないため、スコープ外残置ありとして扱います(fail-closed)。\" >&2\n REPO_STATE_AFTER=\"(signature-error)\"\n fi\n # 成功経路の多層防御(下記の判定表・NPX_STATUS -eq 0 の分岐)と同じ検出基準を\n # 失敗経路にも適用する。ここで検出したら NPX_STATUS_OUT_OF_SCOPE_DIRT=1 を立てて\n # 下の exit 1 判定に合流させる(Bugbot High 指摘: 検出しても continue するだけでは\n # 未リバートのスコープ外差分が「元から存在した dirty 状態」と誤認され得る。\n # `scripts/skills-lock-update.sh` はこの検出を単に「変化なし」ではないため\n # ループ全体を止める理由に含めており、このフェンスも同じ停止経路を取る)。\n NPX_STATUS_OUT_OF_SCOPE_DIRT=0\n if ! cmp -s \"${SNAP_FILTERED_BEFORE}\" \"${SNAP_FILTERED_AFTER}\" \\\n || [[ \"${REPO_STATE_BEFORE}\" != \"${REPO_STATE_AFTER}\" ]]; then\n echo \"エラー: 失敗した npx 実行がスコープ外へも書き込んだ可能性があります。以下を確認してください(削除はしていません):\" >&2\n while IFS= read -r -d '' rec; do printf ' %s\\n' \"${rec}\" >&2; done \u003c \"${SNAP_FILTERED_AFTER}\"\n echo \" (ディレクトリの chmod 等、git status に現れない変化は上記一覧に載りません。状態シグネチャの不一致として検出されています)\" >&2\n NPX_STATUS_OUT_OF_SCOPE_DIRT=1\n fi\n # 次スキルの `git add skills-lock.json`(Step 7)が\n # この残置変更を承認済みの変更と一緒に stage してしまわないよう、Step 6 の却下時と\n # 同じ手順で当該スキル分のみを即座にリバートしてから skip する。\n CONTRACT_RESTORE_VERIFY_FAILED=0\n if [[ \"${LOCAL_PATCH_GUARD}\" == true ]]; then\n # checker を持つリポジトリでは、同期前 check(check mode)が契約範囲(durable patch 含む)を\n # 変更・stage している可能性があり、index からの worktree 復元しか行わない\n # revert_in_scope だけでは同期開始前へ戻らない。契約パス限定の restore_contract_scope で\n # index も同期開始前へ戻す(前スキルの承認済み積上げは PRE_SYNC_TREE に含まれるため保持。\n # 許可先経路・skills-lock.json の妥協検出時は関数内で index のみの復元へ切り替わる)。\n # 呼び出しは revert_in_scope より「前」でなければならない: revert_in_scope の\n # git clean -fd を先に走らせると、restore_contract_scope が退避するはずの契約範囲内の\n # 未追跡ファイル(checker が移動・新規作成した唯一のコピーであり得る)が削除される。\n # 復元後の revert_in_scope は契約パスの checkout / clean が実質 no-op になり、ignored\n # ファイルの選別削除・既存 ignored の復元・妥協検出時の保全案内だけが働く。\n # 戻り値を明示的に拾う(Bugbot Medium 指摘: この分岐は他の失敗経路と異なり\n # 条件次第で continue し得るため、restore_contract_scope の復元後検証が失敗した\n # 場合に「契約範囲が半端に復元されたまま次スキルの npx へ進む」ことを防ぐには、\n # 下の exit 1 判定へ明示的に合流させる必要がある)。\n if ! restore_contract_scope; then\n CONTRACT_RESTORE_VERIFY_FAILED=1\n fi\n fi\n revert_in_scope\n rm -f \"${SNAP_BEFORE}\" \"${SNAP_AFTER}\" \"${SNAP_FILTERED_BEFORE}\" \"${SNAP_FILTERED_AFTER}\" \"${NPX_OUTPUT_FILE}\" \"${SCOPE_INVENTORY_FILE}\" \"${IGNORED_BASELINE_FILE}\"\n if [[ \"${IGNORED_BACKUP_KEEP}\" -eq 0 ]]; then rm -rf \"${IGNORED_BACKUP_DIR}\"; fi\n # universal 無効の no-op は以降の全スキルでも再現するため、skip(continue)ではなく\n # ループ全体を停止する(事後検査・リバートは上で完了済み)。許可先経路の\n # symlink 化(SCOPE_PATH_COMPROMISED)もレイアウト自体の異常で人間の確認を要する\n # ため、同様にループ全体を停止する(事前 lstat 検査の exit 1 と同じ扱い)。\n # 既存 ignored の復元失敗(IGNORED_RESTORE_FAILED)も手動復旧を要するため停止する\n # (バックアップ dir は revert_in_scope が保全済み)。skills-lock.json の置換\n # (LOCK_FILE_COMPROMISED)もリンク先への書き込み有無の手動確認を要するため停止する。\n # スコープ外書き込みの検出(NPX_STATUS_OUT_OF_SCOPE_DIRT)も、上のスコープ外書き込み\n # 検出(多層防御)と同じ停止経路であり、continue(skip)ではなくループ全体を止める。\n # 契約範囲の復元後検証失敗(CONTRACT_RESTORE_VERIFY_FAILED)も同様: 半端に復元された\n # 契約範囲のまま次スキルの npx を続けると、その npx が汚染された前提状態の上で動く\n # ため continue せずループ全体を止める(Bugbot Medium 指摘)。\n if [[ \"${NPX_NOOP_DETECTED}\" -eq 1 || \"${SCOPE_PATH_COMPROMISED}\" -eq 1 || \"${LOCK_FILE_COMPROMISED}\" -eq 1 || \"${IGNORED_RESTORE_FAILED}\" -eq 1 || \"${NPX_STATUS_OUT_OF_SCOPE_DIRT}\" -eq 1 || \"${CONTRACT_RESTORE_VERIFY_FAILED}\" -eq 1 ]]; then\n exit 1\n fi\n echo \"警告: 固定版を外した再実行はせず、当該スキルの変更をリバートして skip します。\"\n continue\nfi\n\n# npx 実行後のスナップショット。前後のスコープ外差分を見るため、取得条件は\n# SNAP_BEFORE と完全に揃える。取得失敗時にその場で exit すると、npx が生成済みの\n# スコープ内変更を残置したままスコープ外シグネチャ検査も行われない(PR #412 P1\n# 指摘)。状態シグネチャは git 非依存(python3 の走査)で取得できるため、可能な\n# 範囲で事後検査(スコープ外残置疑いの報告)を行い、スコープ内をリバートしてから\n# fail-closed で exit 1 する(ループ停止。以降のスキルも同じ git 障害に当たるため\n# skip 継続に意味がない)。\nif ! git -c status.renames=false status --porcelain -z -uall > \"${SNAP_AFTER}\"; then\n echo \"エラー: npx 実行後の git status 取得に失敗しました。porcelain レコードでの前後比較ができません。\" >&2\n if REPO_STATE_AFTER=\"$(repo_state_signature)\"; then\n if [[ \"${REPO_STATE_BEFORE}\" != \"${REPO_STATE_AFTER}\" ]]; then\n echo \"エラー: スコープ外(skills-lock.json / .agents/skills/${SKILL_NAME}/ 以外)へも書き込まれた可能性があります(状態シグネチャ不一致)。git status / git diff で手動確認してください(削除はしていません)。\" >&2\n fi\n else\n # シグネチャも取れない場合は「変化なし」と確認できないため、残置の可能性ありと\n # して案内する(fail-closed。green 側へ倒さない)。\n echo \"エラー: 実行後の状態シグネチャ取得にも失敗しました。スコープ外残置の可能性を排除できません。git status / git diff で手動確認してください(fail-closed)。\" >&2\n fi\n if [[ \"${LOCAL_PATCH_GUARD}\" == true ]]; then\n # 同期前 check の staged 契約変更を残さない(fail-closed 契約)。git clean -fd による\n # 未追跡削除より先に退避を効かせるため revert_in_scope より前に呼ぶ(NPX_STATUS 分岐と同じ)\n restore_contract_scope || true\n fi\n revert_in_scope\n # revert_in_scope が CONTRACT_UNTRACKED_BACKUP_FAILED を検知して checkout・clean を\n # 保全のためスキップした場合、worktree には未退避の変更が残っている(「リバート\n # しました」は虚偽になる)。REVERT_IN_SCOPE_SKIPPED で分岐し、その場合は\n # revert_in_scope 自身が案内済みの警告と重複させず、完了を意味する文言は出さない\n # (Bugbot Medium 指摘: Stale revert success after backup skip / Issue #418 系)。\n if [[ \"${REVERT_IN_SCOPE_SKIPPED:-0}\" -ne 1 ]]; then\n echo \"スコープ内(skills-lock.json / .agents/skills/${SKILL_NAME}/)の変更はリバートしました。\" >&2\n fi\n rm -f \"${SNAP_BEFORE}\" \"${SNAP_AFTER}\" \"${SNAP_FILTERED_BEFORE}\" \"${SNAP_FILTERED_AFTER}\" \"${NPX_OUTPUT_FILE}\" \"${SCOPE_INVENTORY_FILE}\" \"${IGNORED_BASELINE_FILE}\"\n if [[ \"${IGNORED_BACKUP_KEEP}\" -eq 0 ]]; then rm -rf \"${IGNORED_BACKUP_DIR}\"; fi\n exit 1\nfi\n\nfilter_out_of_scope \"${SNAP_AFTER}\" \"${SNAP_FILTERED_AFTER}\"\n\n# 実行後シグネチャの取得失敗は「変化なしと確認できない」であって「変化なし」では\n# ないため、比較不能な sentinel を入れて必ず不一致(= 検出・リバート・停止)へ倒す。\nif ! REPO_STATE_AFTER=\"$(repo_state_signature)\"; then\n echo \"エラー: npx 実行後の状態シグネチャ取得に失敗しました。変化なしと確認できないため、スコープ外書き込みありとして扱います(fail-closed)。\" >&2\n REPO_STATE_AFTER=\"(signature-error)\"\nfi\n\n# スコープ外の状態が前後で一致しなければ、--agent universal が抑止しているはずの\n# スコープ外書き込みが発生したことになる(一次防御を突破した場合の多層防御)。\n# 判定は 2 軸: (1) porcelain レコード(SNAP_FILTERED_*)と (2) リポジトリ全体の\n# 状態シグネチャ(REPO_STATE_*、repo_state_signature の出力)。実行前から M・??\n# だったスコープ外ファイルの内容・モードだけの上書きや、ディレクトリの chmod・\n# ディレクトリ向け symlink の変更・.gitignore 対象ファイルの上書きは porcelain\n# レコードに現れないため、シグネチャ側の不一致だけがそれを検出できる。\n# 他の skip 分岐と異なり、ここは\n# continue ではなく exit 1 でループ全体を停止する。スコープ外の汚染は自動リバート\n# されないため、続行すると最終報告が「clean」でも実際は dirty 残留となり、この\n# issue が問題視している状態そのものになる。この時点までに承認・stage 済みの他\n# スキル分は index に残ったままになるため、停止後は `git status` で確認し、必要な\n# 分だけ `git commit` するか `git reset` で戻すかを判断すること。\nif ! cmp -s \"${SNAP_FILTERED_BEFORE}\" \"${SNAP_FILTERED_AFTER}\" \\\n || [[ \"${REPO_STATE_BEFORE}\" != \"${REPO_STATE_AFTER}\" ]]; then\n echo \"エラー: npx skills add がスコープ外(skills-lock.json / .agents/skills/${SKILL_NAME}/ 以外)へ書き込みました。\" >&2\n echo \"==> 実行前のスコープ外状態:\" >&2\n while IFS= read -r -d '' rec; do printf ' %s\\n' \"${rec}\" >&2; done \u003c \"${SNAP_FILTERED_BEFORE}\"\n echo \"==> 実行後のスコープ外状態:\" >&2\n while IFS= read -r -d '' rec; do printf ' %s\\n' \"${rec}\" >&2; done \u003c \"${SNAP_FILTERED_AFTER}\"\n echo \"(ディレクトリの chmod 等、git status に現れない変化は上記一覧に載りません。その場合は状態シグネチャの不一致として検出されています)\" >&2\n echo \"スコープ内(skills-lock.json / .agents/skills/${SKILL_NAME}/)の変更はこれからリバートします。\" >&2\n echo \"スコープ外は既存の WIP を巻き込む恐れがあるため自動リバートしません。\" >&2\n echo \"上記パスの内容を git status / git diff で確認し、必要なら手動で\" >&2\n echo \" git checkout -- \u003cpath> (変更前が clean だった追跡ファイルの場合)\" >&2\n echo \" git clean -fd \u003cpath> (変更前に存在しなかった未追跡ファイルの場合)\" >&2\n echo \"を実行してください。\" >&2\n if [[ \"${LOCAL_PATCH_GUARD}\" == true ]]; then\n # 同期前 check の staged 契約変更を残さない(fail-closed 契約)。git clean -fd による\n # 未追跡削除より先に退避を効かせるため revert_in_scope より前に呼ぶ(NPX_STATUS 分岐と同じ)\n restore_contract_scope || true\n fi\n revert_in_scope\n rm -f \"${SNAP_BEFORE}\" \"${SNAP_AFTER}\" \"${SNAP_FILTERED_BEFORE}\" \"${SNAP_FILTERED_AFTER}\" \"${NPX_OUTPUT_FILE}\" \"${SCOPE_INVENTORY_FILE}\" \"${IGNORED_BASELINE_FILE}\"\n if [[ \"${IGNORED_BACKUP_KEEP}\" -eq 0 ]]; then rm -rf \"${IGNORED_BACKUP_DIR}\"; fi\n exit 1\nfi\n\n# 成功経路でも既存 ignored ファイル(.DS_Store 等)の変更・削除を実行前バックアップ\n# との比較で検査する。許可先配下は署名から除外されるためシグネチャ比較には現れず、\n# この比較だけが検出手段になる。変化があればバックアップから復元して警告し、同期\n# 自体は継続する(ignored ファイルは同期対象外で、復元すれば成果物に影響しないため)。\n# 復元に失敗した場合のみバックアップ dir を保全し、手動復旧を要するためループ全体を\n# exit 1 で停止する(fail-closed)。\nif ! restore_preexisting_ignored; then\n IGNORED_BACKUP_KEEP=1\n echo \"エラー: 実行前から存在した ignored ファイルの復元に失敗しました。バックアップは ${IGNORED_BACKUP_DIR} に相対パス構造で残っています。手動で復旧してください。\" >&2\n if [[ \"${LOCAL_PATCH_GUARD}\" == true ]]; then\n # 失敗終端では同期前 check の staged 契約変更も残さない(fail-closed 契約。\n # npx の同期結果ごと同期開始前へ戻し、部分状態のまま Step 7 の git add へ進む経路を断つ)\n restore_contract_scope || true\n fi\n # 他の post-npx 失敗経路と同じくスコープ内をリバートする。checker が無い\n # リポジトリでは、ここで revert_in_scope を呼ばないと npx による skills-lock.json /\n # .agents/skills/${SKILL_NAME}/ への書き込みが worktree に残ったまま停止し、\n # 次回実行時の「実行前 clean」ガードに引っかかって当該スキルが skip され続ける。\n # revert_in_scope は内部で restore_preexisting_ignored を再試行するが、失敗が\n # 続いても IGNORED_BACKUP_KEEP=1 で警告するだけで非ゼロ終了はしない\n # (呼び出し元のこの分岐がすでに exit 1 を担保しているため)。\n revert_in_scope\n rm -f \"${SNAP_BEFORE}\" \"${SNAP_AFTER}\" \"${SNAP_FILTERED_BEFORE}\" \"${SNAP_FILTERED_AFTER}\" \"${NPX_OUTPUT_FILE}\" \"${SCOPE_INVENTORY_FILE}\" \"${IGNORED_BASELINE_FILE}\"\n exit 1\nfi\n\nrm -f \"${SNAP_BEFORE}\" \"${SNAP_AFTER}\" \"${SNAP_FILTERED_BEFORE}\" \"${SNAP_FILTERED_AFTER}\" \"${NPX_OUTPUT_FILE}\" \"${SCOPE_INVENTORY_FILE}\" \"${IGNORED_BASELINE_FILE}\"\nif [[ \"${IGNORED_BACKUP_KEEP}\" -eq 0 ]]; then rm -rf \"${IGNORED_BACKUP_DIR}\"; fi","type":"text"}]},{"type":"paragraph","content":[{"text":"npx skills add","type":"text","marks":[{"type":"code_inline"}]},{"text":" は以下を行う:","type":"text"}]},{"type":"bullet_list","content":[{"type":"list_item","content":[{"type":"paragraph","content":[{"text":"upstream の最新スキルをダウンロード","type":"text"}]}]},{"type":"list_item","content":[{"type":"paragraph","content":[{"text":"インストール先(","type":"text"},{"text":".agents/skills/\u003cname>/","type":"text","marks":[{"type":"code_inline"}]},{"text":")を最新化","type":"text"}]}]},{"type":"list_item","content":[{"type":"paragraph","content":[{"text":"skills-lock.json","type":"text","marks":[{"type":"code_inline"}]},{"text":" の ","type":"text"},{"text":"computedHash","type":"text","marks":[{"type":"code_inline"}]},{"text":" を CLI 算出値で更新","type":"text"}]}]}]},{"type":"paragraph","content":[{"text":"重要な副作用","type":"text","marks":[{"type":"strong"}]},{"text":": ","type":"text"},{"text":"npx skills add","type":"text","marks":[{"type":"code_inline"}]},{"text":" はインストール済みファイルを最新の upstream 版で上書きする。upstream との同期が目的のため、これは意図した動作である。上記の per-skill clean ガードは ","type":"text"},{"text":"git status --porcelain","type":"text","marks":[{"type":"code_inline"}]},{"text":" を使い、ステージ済み・未ステージ・","type":"text"},{"text":"未追跡ファイルも含めて","type":"text","marks":[{"type":"strong"}]},{"text":"検出する。WIP がある場合は npx 実行前に skip するため、未コミット編集の消失は防止される。","type":"text"}]},{"type":"paragraph","content":[{"text":"注意","type":"text","marks":[{"type":"strong"}]},{"text":": clean ガードを通過したスキルについては、npx が即座に ","type":"text"},{"text":"skills-lock.json","type":"text","marks":[{"type":"code_inline"}]},{"text":" と ","type":"text"},{"text":".agents/skills/\u003cname>/","type":"text","marks":[{"type":"code_inline"}]},{"text":" を書き換える。ユーザー承認(Step 6)の前に変更が確定するため、承認しない場合は Step 6 の案内に従いリバートが必要。","type":"text"}]},{"type":"paragraph","content":[{"text":"書き込みスコープの制限(","type":"text","marks":[{"type":"strong"}]},{"text":"--agent universal","type":"text","marks":[{"type":"code_inline"},{"type":"strong"}]},{"text":")","type":"text","marks":[{"type":"strong"}]},{"text":": ","type":"text"},{"text":"npx skills add","type":"text","marks":[{"type":"code_inline"}]},{"text":" はエージェント/パス制限なしで実行すると、検出した各エージェント向けツリー(","type":"text"},{"text":".claude/skills/","type":"text","marks":[{"type":"code_inline"}]},{"text":" 等)へも書き込み得る。しかし clean ガード・Step 5 のプレビュー・Step 6 のリバート・Step 7 の ","type":"text"},{"text":"git add","type":"text","marks":[{"type":"code_inline"}]},{"text":" はいずれも ","type":"text"},{"text":"skills-lock.json","type":"text","marks":[{"type":"code_inline"}]},{"text":" と ","type":"text"},{"text":".agents/skills/\u003cname>/","type":"text","marks":[{"type":"code_inline"}]},{"text":" のみを対象としており、スコープ外への書き込みが発生すると WIP 上書き・レビュー迂回・「clean 報告後の dirty 残留」が起き得る。","type":"text"},{"text":"--agent universal","type":"text","marks":[{"type":"code_inline"}]},{"text":" により書き込みは ","type":"text"},{"text":".agents/skills/\u003cname>/","type":"text","marks":[{"type":"code_inline"}]},{"text":" と ","type":"text"},{"text":"skills-lock.json","type":"text","marks":[{"type":"code_inline"}]},{"text":" に限定され、他エージェントツリー(","type":"text"},{"text":".claude/skills/","type":"text","marks":[{"type":"code_inline"}]},{"text":" 等)へは書かない(実測: スクラッチリポジトリで確認済み)。万一 CLI のバージョン更新等でこの前提が崩れて書き込まれた場合も、npx 実行前後のスナップショット比較で fail-closed に停止する(多層防御)。","type":"text"}]},{"type":"heading","attrs":{"level":4},"content":[{"text":"Step 5: 当該スキルの差分を表示する","type":"text"}]},{"type":"paragraph","content":[{"text":"git diff","type":"text","marks":[{"type":"code_inline"}]},{"text":" は未追跡ファイルを表示しない。Step 4 の clean ガード(","type":"text"},{"text":"git status --porcelain","type":"text","marks":[{"type":"code_inline"}]},{"text":")により ","type":"text"},{"text":"npx skills add","type":"text","marks":[{"type":"code_inline"}]},{"text":" 実行前の当該ディレクトリは必ず clean であるため、","type":"text"},{"text":"upstream 側でファイルが増えた ケースでは、その新規ファイルは例外なく未追跡になる","type":"text","marks":[{"type":"strong"}]},{"text":"。tracked diff だけを見せて Step 6 の承認判断へ 進むと、その内容を一切確認しないまま承認できてしまうため、tracked 差分と未追跡ファイルの内容を分けて 両方提示する。","type":"text"}]},{"type":"code_block","attrs":{"wrap":false,"language":"bash"},"content":[{"text":"# 当該スキルにスコープした tracked 差分を表示する\ngit diff -- skills-lock.json \".agents/skills/${SKILL_NAME}/\"\n\n# 未追跡ファイルを列挙し、内容を diff 形式で表示する。\n# この集合は Step 7(承認・git add)が新規に取り込む集合、Step 6(拒否・git clean -fd)が\n# 削除する集合と同一(.gitignore 対象を除く非追跡ファイル)であり、\n# 「プレビュー = 承認 = 拒否」の 3 経路が同じ対象を扱うことを保証する。\n# git ls-files の既定出力は改行区切りのため、ファイル名自体に改行を含む未追跡ファイルが\n# あると 1 パスが複数の存在しないパスへ分割される。分割後の各 git diff は失敗し || true で\n# 握り潰される一方、Step 7 の git add は実ファイルをそのまま取り込むため、内容を表示しない\n# まま承認できてしまう(-z / NUL 区切りで防ぐ)。\nUNTRACKED_COUNT=0\n# git ls-files をプロセス置換へ直接つなぐと、set -euo pipefail はその終了コードを\n# 検査しない。失敗(破損 index・権限エラー等)しても while は0回実行され「なし」と\n# 誤表示するため、一時ファイルへ書き出し if ! ... で明示的に終了コードを検査する\n# (scripts/skills-lock-update.sh と同一のガード)。\nUNTRACKED_LIST_FILE=\"$(mktemp)\"\ntrap 'rm -f \"${UNTRACKED_LIST_FILE}\"' EXIT\nif ! git ls-files -z --others --exclude-standard -- \".agents/skills/${SKILL_NAME}/\" > \"${UNTRACKED_LIST_FILE}\"; then\n echo \"エラー: git ls-files が失敗し、未追跡ファイルの一覧化を確認できません。中止します。\" >&2\n # scripts/skills-lock-update.sh の同一経路(preview_untracked)と揃える(Bugbot\n # Medium 指摘: 従来はここで exit 1 するだけで、npx が成功させた契約範囲(tracked 分)\n # の復元も行われなかった)。ただし git ls-files が壊れている以上「何が npx の新規\n # 作成物か」を安全に確定できないため、破壊的な git clean -fd は行わない(codex P0\n # 指摘の再発防止)。tracked 分の非破壊的な復元(restore_contract_scope || true →\n # revert_in_scope 1〔非破壊モード。git clean -fd / remove_new_ignored_in_scope を\n # スキップする〕)だけを行い、未追跡ファイルは手動確認へ委ねる。\n if [[ \"${LOCAL_PATCH_GUARD}\" == true ]]; then\n restore_contract_scope || true\n fi\n revert_in_scope 1\n echo \"エラー: 未追跡ファイルの一覧化ができなかったため、.agents/skills/${SKILL_NAME}/ 配下の破壊的な後始末(git clean -fd)はスキップしました。npx / checker の書き込み・未追跡ファイルが残っている可能性があります。git status --porcelain -- \\\".agents/skills/${SKILL_NAME}/\\\" で確認し、内容を確認したうえで不要なもののみ手動で git clean -fd \\\".agents/skills/${SKILL_NAME}/\\\" してください(fail-closed)。\" >&2\n exit 1\nfi\nwhile IFS= read -r -d '' f; do\n if [[ \"${UNTRACKED_COUNT}\" -eq 0 ]]; then\n echo \"==> 新規(未追跡)ファイル — 承認時に git add で取り込まれる集合:\"\n fi\n UNTRACKED_COUNT=$((UNTRACKED_COUNT + 1))\n # 空ファイルは git diff --no-index が差分を出力しないため、diff の見出しだけでは\n # どのファイルが追加されるか分からない。先に printf でファイル名自体を明示してから\n # 内容の diff を表示する(0 byte のファイルでも名前は必ず見える)。\n printf '%s\\n' \"--- ${f} ---\"\n # バイナリファイルは git diff --no-index が \"Binary files ... differ\" としか出力せず、\n # 追加される中身を一切提示しない。numstat の追加/削除行数が両方 \"-\" になる出力で\n # バイナリ判定し、内容の代わりに種別・サイズ・ハッシュを明示することで、中身を\n # 確認できないまま承認(Step 7 の git add)だけが通ってしまう非対称を防ぐ。\n NUMSTAT=\"$(git diff --no-index --numstat -- /dev/null \"${f}\" 2>/dev/null || true)\"\n if [[ \"${NUMSTAT}\" == -

sync-skills-lock ルート直下の の を、upstream リポジトリの現状と照合して更新する。 対象ファイル - ルート : 呼び出し元リポジトリ直下の — このスキルが唯一編集するファイル - 除外 : submodule 配下の は編集しない(submodule 境界を跨がないため) 前提条件 - CLI がインストールされ、認証済みであること - / が利用可能であること( を使用するため)。 CLI は固定版( )で実行する。値と更新手順は「skills CLI のバージョン固定と更新手順」節を参照 - が利用可能であること(状態署名〔 / 〕と復元処理〔既存 ignored ファイルの比較・復元等〕で使用。主要 Linux / macOS には標準搭載されている。無い環境では Step 4 フェンスが npx 実行前に fail-closed で停止するため、導入してから再実行すること) - CLI が利用可能であること(未追跡バイナリファイルの種別表示に使用。未導入の環境では 種別が として表示され、承認前にサイズ・git blob ハッシュのみで 判断することになる。macOS / 主要 Linux ディストリビューションには標準搭載されている) - ルート直下の が存在すること - 実行前に に未コミットの変更がないこと (ステージ済み・未ステージ…

\\t'-

sync-skills-lock ルート直下の の を、upstream リポジトリの現状と照合して更新する。 対象ファイル - ルート : 呼び出し元リポジトリ直下の — このスキルが唯一編集するファイル - 除外 : submodule 配下の は編集しない(submodule 境界を跨がないため) 前提条件 - CLI がインストールされ、認証済みであること - / が利用可能であること( を使用するため)。 CLI は固定版( )で実行する。値と更新手順は「skills CLI のバージョン固定と更新手順」節を参照 - が利用可能であること(状態署名〔 / 〕と復元処理〔既存 ignored ファイルの比較・復元等〕で使用。主要 Linux / macOS には標準搭載されている。無い環境では Step 4 フェンスが npx 実行前に fail-closed で停止するため、導入してから再実行すること) - CLI が利用可能であること(未追跡バイナリファイルの種別表示に使用。未導入の環境では 種別が として表示され、承認前にサイズ・git blob ハッシュのみで 判断することになる。macOS / 主要 Linux ディストリビューションには標準搭載されている) - ルート直下の が存在すること - 実行前に に未コミットの変更がないこと (ステージ済み・未ステージ…

\\t'* ]]; then\n FILE_SIZE=\"$(wc -c \u003c \"${f}\" | tr -d '[:space:]')\"\n if command -v file >/dev/null 2>&1; then\n FILE_TYPE=\"$(file -b -- \"${f}\" 2>/dev/null || echo \"unknown\")\"\n else\n FILE_TYPE=\"file コマンド未検出\"\n fi\n FILE_HASH=\"$(git hash-object -- \"${f}\")\"\n # object format は repository 設定依存(既定 sha1 / 拡張 sha256)で出力桁数が変わる\n # (sha1: 40 桁 / sha256: 64 桁)。固定表記 \"git-blob-sha1\" だと sha256 リポジトリで\n # 実際のアルゴリズムと表示が食い違うため、表記自体をアルゴリズム非依存にする。\n OBJECT_FORMAT=\"$(git rev-parse --show-object-format 2>/dev/null || echo unknown)\"\n printf '%s\\n' \"==> バイナリファイル(内容は表示されません): type=${FILE_TYPE} size=${FILE_SIZE}bytes git-blob-hash(${OBJECT_FORMAT})=${FILE_HASH}\"\n else\n # --no-index は index を変更しない(git add -N は使わない。Step 6 の拒否経路が\n # index からの git checkout -- で承認済み他スキルの hash を復元する設計に依存しており、\n # intent-to-add エントリの混入はその復元設計と干渉するため)。\n # 差分ありのとき exit 1 を返す仕様のため、表示専用のこの呼び出しに限り || true で\n # set -e の中断を避ける。\n git diff --no-index -- /dev/null \"${f}\" || true\n fi\ndone \u003c \"${UNTRACKED_LIST_FILE}\"\nif [[ \"${UNTRACKED_COUNT}\" -eq 0 ]]; then\n echo \"==> 新規(未追跡)ファイル: なし\"\nfi","type":"text"}]},{"type":"paragraph","content":[{"text":"変更点を確認し、更新された ","type":"text"},{"text":"computedHash","type":"text","marks":[{"type":"code_inline"}]},{"text":" の内容と未追跡ファイルの中身を合わせてユーザーに提示する。","type":"text"}]},{"type":"paragraph","content":[{"text":"checker(","type":"text","marks":[{"type":"strong"}]},{"text":"scripts/check-skill-local-patches.sh","type":"text","marks":[{"type":"code_inline"},{"type":"strong"}]},{"text":")を持つリポジトリの場合","type":"text","marks":[{"type":"strong"}]},{"text":"、この時点の diff は ","type":"text"},{"text":"raw な upstream 差分","type":"text","marks":[{"type":"strong"}]},{"text":"であり、local patch はまだ再適用されていない(Step 5.5 の再適用後の最終 diff と混同しないこと)。","type":"text"}]},{"type":"heading","attrs":{"level":4},"content":[{"text":"Step 5.5: local patch を再適用して最終検証する(checker を持つリポジトリのみ)","type":"text"}]},{"type":"paragraph","content":[{"text":"npx skills add","type":"text","marks":[{"type":"code_inline"}]},{"text":" の上書きで消費側リポジトリの local patch が worktree から消えているため、","type":"text"},{"text":"stage より前に","type":"text","marks":[{"type":"strong"}]},{"text":"再適用と最終検証を行う。checker(apply)は当該スキルディレクトリのほか durable patch(","type":"text"},{"text":"scripts/local-patches/","type":"text","marks":[{"type":"code_inline"}]},{"text":")も変更・stage し得るため、(1) 却下・失敗時の厳密復元用に apply 前の index を snapshot し、(2) apply 後は変更集合が契約範囲(","type":"text"},{"text":"skills-lock.json","type":"text","marks":[{"type":"code_inline"}]},{"text":"・当該スキル・","type":"text"},{"text":"scripts/local-patches/","type":"text","marks":[{"type":"code_inline"}]},{"text":")に収まることを機械検証する。すべて成功した場合のみ Step 6 以降へ進める。","type":"text"}]},{"type":"code_block","attrs":{"wrap":false,"language":"bash"},"content":[{"text":"if [[ -f scripts/check-skill-local-patches.sh ]]; then\n # Step 4 と同一 shell セッションの前提を機械検証する。セッションが切れて失われた場合は、\n # outside_state / verify_outside_and_checker / restore_contract_scope / run_checker\n # (いずれも Step 4 で定義した関数)を Step 4 のとおり再定義し、\n # PRE_OUTSIDE / CHECKER_APPROVED_HASH には Step 4 が表示・承認した値を設定してから進む。\n # 値が不明なまま進んではならない(fail-closed で中止し、Step 6 の却下手順で戻す)\n : \"${PRE_OUTSIDE:?Step 4 で表示された基準 digest を設定してから実行する}\"\n : \"${CHECKER_APPROVED_HASH:?ユーザー承認済みの checker blob hash を設定してから実行する}\"\n\n # 却下・失敗時の復元は Step 4 で「checker 初回実行より前」に取得・表示済みの\n # PRE_SYNC_TREE(同期開始前の index snapshot)を使う。ここで snapshot を取り直しては\n # ならない — npx 後の index を基準にすると、復元先が「raw upstream + 同期前 check の\n # stage」になり、「同期前へ戻す」という契約に反する(local patch が外れた状態が残る)\n : \"${PRE_SYNC_TREE:?Step 4 で表示された同期開始前 snapshot を設定してから実行する}\"\n\n # 範囲外 digest の基準(PRE_OUTSIDE)・outside_state・verify_outside_and_checker は\n # Step 4 で checker の初回実行より前に定義・取得済みのものを同一 shell セッションで\n # そのまま使う(ここで取り直すと、同期前 check 以降の範囲外変更が基準へ取り込まれてしまう)\n\n # 実行対象は worktree の checker ではなく、承認済み blob(CHECKER_APPROVED_HASH)を\n # 毎回再検証してから取り出す一時ファイル(run_checker。Step 4 で定義済み)。checker\n # の各実行(同期前 check・apply・最終検証)の直前に必ずハッシュを再検証するため\n # (codex P1 指摘: verify-then-execute の隣接。apply は checker 自身のコードを実行\n # するため一時ファイルを自己書換えし得るが、直後の最終検証は run_checker が再検証\n # してから承認済み blob へ書き戻すので、書き換えられた内容がそのまま実行されることは\n # ない)、ここで個別に取り出し・検証するコードは不要(呼び出しごとに run_checker が\n # 行う)。\n\n # local patch の再適用(--3way fallback や index 復元により、patch 対象 file や durable patch が\n # stage されることがある)\n apply_rc=0\n run_checker apply || apply_rc=$?\n if [[ \"${CHECKER_RUN_VERIFY_FAILED}\" -eq 1 ]]; then\n restore_contract_scope || true\n exit 1\n fi\n if ! verify_outside_and_checker; then\n # 範囲外の破壊は verify_outside_and_checker の案内どおり手動復旧として残しつつ、\n # 契約範囲(部分適用された patch・checker 由来の index 変更)は Step 4 で定義済みの\n # restore_contract_scope で自動復元してから終了する(「すべての失敗経路で同期開始前へ\n # 戻す」契約。以下の 3 失敗分岐も同じ。shell セッションが切れて関数が失われている\n # 場合は Step 4 のとおり再定義してから実行する)\n restore_contract_scope || true\n exit 1\n fi\n if [[ \"${apply_rc}\" -ne 0 ]]; then\n echo \"エラー: local patch の再適用に失敗しました。stage・commit へ進まないでください(fail-closed)。\"\n restore_contract_scope || true\n exit 1\n fi\n\n # 再適用後の最終検証(worktree / index / durable patch)\n check_rc=0\n run_checker || check_rc=$?\n if [[ \"${CHECKER_RUN_VERIFY_FAILED}\" -eq 1 ]]; then\n restore_contract_scope || true\n exit 1\n fi\n if ! verify_outside_and_checker; then\n restore_contract_scope || true\n exit 1\n fi\n if [[ \"${check_rc}\" -ne 0 ]]; then\n echo \"エラー: 再適用後の最終検証に失敗しました。stage・commit へ進まないでください(fail-closed)。\"\n restore_contract_scope || true\n exit 1\n fi\nfi","type":"text"}]},{"type":"paragraph","content":[{"text":"いずれかが非 0 の場合は ","type":"text"},{"text":"fail-closed で停止","type":"text","marks":[{"type":"strong"}]},{"text":"し、Step 6・7(承認・stage)へ進まない。","type":"text"},{"text":"local patch が欠けた状態を承認済みとして stage してはならない","type":"text","marks":[{"type":"strong"}]},{"text":"。すべての失敗分岐は ","type":"text"},{"text":"restore_contract_scope","type":"text","marks":[{"type":"code_inline"}]},{"text":" が契約範囲(当該スキル・","type":"text"},{"text":"skills-lock.json","type":"text","marks":[{"type":"code_inline"}]},{"text":"・","type":"text"},{"text":"scripts/local-patches/","type":"text","marks":[{"type":"code_inline"}]},{"text":")の index + worktree を同期開始前へ自動復元してから終了する(契約範囲内の未追跡ファイルは削除せず一時ディレクトリへ退避して案内する。退避自体に失敗した場合は worktree を復元せず index のみで停止する)。範囲外 path の破壊が報告された場合のみ、","type":"text"},{"text":"verify_outside_and_checker","type":"text","marks":[{"type":"code_inline"}]},{"text":" の案内に従って範囲外を手動復旧してから原因を調査する。","type":"text"}]},{"type":"heading","attrs":{"level":4},"content":[{"text":"Step 6: ユーザーに当該スキルの承認を求める","type":"text"}]},{"type":"paragraph","content":[{"text":"差分がある場合のみ、ユーザーに「この更新を適用してよいか」を確認する。Step 5 のプレビュー (","type":"text"},{"text":"git ls-files --others --exclude-standard","type":"text","marks":[{"type":"code_inline"}]},{"text":")・本 Step の拒否(","type":"text"},{"text":"git clean -fd","type":"text","marks":[{"type":"code_inline"}]},{"text":")・Step 7 の承認 (","type":"text"},{"text":"git add","type":"text","marks":[{"type":"code_inline"}]},{"text":")は同じ集合(追跡ファイルの変更 + 非 ignore の未追跡ファイル)を対象とする。 ","type":"text"},{"text":".gitignore","type":"text","marks":[{"type":"code_inline"}]},{"text":" 対象はいずれの経路でも扱わない。","type":"text"}]},{"type":"paragraph","content":[{"text":"checker を持つリポジトリの場合、承認の対象は Step 5.5 完了後の最終 diff","type":"text","marks":[{"type":"strong"}]},{"text":"(upstream 更新 + local patch 再適用を含む commit 候補。durable patch ","type":"text"},{"text":"scripts/local-patches/","type":"text","marks":[{"type":"code_inline"}]},{"text":" を含む)であり、次で確認する。","type":"text"},{"text":"git diff HEAD","type":"text","marks":[{"type":"code_inline"}]},{"text":" は未追跡ファイルを表示しないため、未追跡分は Step 5 と同じ手順(","type":"text"},{"text":"git ls-files -z --others --exclude-standard","type":"text","marks":[{"type":"code_inline"}]},{"text":" で列挙し、","type":"text"},{"text":"git diff --no-index","type":"text","marks":[{"type":"code_inline"}]},{"text":" / バイナリ判定で内容表示)を契約範囲の path に対して再実行し、tracked 差分と合わせて提示する:","type":"text"}]},{"type":"code_block","attrs":{"wrap":false,"language":"bash"},"content":[{"text":"git diff HEAD -- skills-lock.json \".agents/skills/${SKILL_NAME}/\" scripts/local-patches/\n# 未追跡分は Step 5 のプレビュー処理(一時ファイル + NUL 区切り + 空/バイナリ判定。\n# git ls-files の失敗は fail-closed で中止)を、pathspec を\n# \".agents/skills/${SKILL_NAME}/\" scripts/local-patches/ の 2 つへ広げて再実行し、\n# 名前の列挙だけでなく内容(git diff --no-index / バイナリは種別・サイズ・hash)まで提示する","type":"text"}]},{"type":"paragraph","content":[{"text":"却下された場合","type":"text","marks":[{"type":"strong"}]},{"text":"は当該スキルのみ即座にリバートして","type":"text"},{"text":"次スキルへ continue","type":"text","marks":[{"type":"strong"}]},{"text":"する(全体を中止しない)。 ただし直後の検証でリバート未完了を検出した場合は、この skip 継続の対象外としてループ全体を ","type":"text"},{"text":"exit 1","type":"text","marks":[{"type":"code_inline"}]},{"text":" で停止する(fail-closed。未完了のまま次スキルへ進むと、残留した却下対象の変更が 次スキルの承認時に一緒に stage され得るため):","type":"text"}]},{"type":"code_block","attrs":{"wrap":false,"language":"bash"},"content":[{"text":"# 当該スキルの変更のみをリバート(追跡ファイル)。\n# git checkout -- \u003cfile> は HEAD ではなく「index(ステージ)」の内容を作業ツリーへ復元する。\n# 前スキルの承認変更は git add で既に index に載っているため、checkout 後の作業ツリーにも\n# 引き継がれ、承認済み computedHash が消えることはない。\n# 2つのパスを1つの `git checkout --` に渡すとアトミックに扱われ、どちらか一方が\n# 「追跡対象なし」(初回具現化・untracked のみの書き込み時)で pathspec エラーになると\n# コマンド全体が失敗し、もう一方(skills-lock.json)も復元されない。必ず1コマンド1パスで\n# 分離し、一方の失敗が他方の復元を阻害しないようにする。\n\n# skills-lock.json は初回生成(未追跡)だと pathspec エラーで非ゼロ終了するため、\n# revert_in_scope(行 1046-1084)と同じく || true で吸収する。errexit 有効シェルで\n# 実行された場合でもここで途中 abort しない(Issue #417。復元の成否はこの直後の\n# 検証ブロックで確認する — || true は成功扱いではなく確認を先送りするだけ)\ngit checkout -- skills-lock.json 2>/dev/null || true\ngit checkout -- \".agents/skills/${SKILL_NAME}/\" 2>/dev/null || true\n# skills-lock.json が npx の初回生成で未追跡のまま残っている場合、上の checkout は\n# 「追跡対象なし」で何もせず(|| true が吸収)ファイルは削除されない。Step 4 の事前\n# clean ガードにより実行前の未追跡 skills-lock.json は無いことが保証されているため、\n# ここで未追跡のまま見つかる skills-lock.json は今回の npx が生成したものに限られ、\n# 安全に削除できる。削除しないと直後の検証(git ls-files --others)が必ず残留を検出し、\n# 「却下時は当該スキルのみリバートして次スキルへ continue」という契約を満たせないまま\n# 必ず exit 1 になる(Issue #417 P1 指摘)。\n# この経路は checker を持たない(このフェンスは checker 非経由の単純リバート専用)ため、\n# 未追跡 skills-lock.json の唯一の出所は npx の初回生成であり削除して問題ない。checker\n# を持つリポジトリ向けの却下フェンス(下記「checker を持つリポジトリの却下は次を使う」節)が\n# 同種のファイルを削除せず一時ディレクトリへ退避するのは、checker(apply)が既存内容を\n# 書き換えた唯一のコピーである可能性があるためで、事情が異なる(この経路にはその可能性がない)。\n# `[[ -n \"$(cmd)\" ]]` は cmd の終了コードを見ず出力の空/非空だけを見るため、権限・index\n# 異常等で cmd 自体が失敗して空出力になった場合「残留なし」と誤判定し得る(Issue #417\n# P0 指摘)。command substitution は if の条件式として実行し、失敗時は変数を空のまま\n# fail-closed で停止してから、内容の空・非空を判定する\nif ! LOCK_UNTRACKED_CHECK=\"$(git ls-files -z --others --exclude-standard -- skills-lock.json)\"; then\n echo \"エラー: skills-lock.json の未追跡判定(git ls-files)に失敗しました。空出力を「未追跡なし」と誤判定しないため停止します(fail-closed)。権限・index 異常等の原因を解消してから再実行してください。\" >&2\n exit 1\nfi\nif [[ -n \"${LOCK_UNTRACKED_CHECK}\" ]]; then\n rm -f skills-lock.json\nfi\n# npx が新規作成した未追跡ファイルも削除(Step 4 の clean ガードで実行前は clean を保証済み)。\n# ${SKILL_NAME} は kebab-case 検証済みのため、対象は当該スキルディレクトリ配下に限定される。\n# git clean はディレクトリが存在しない場合に非ゼロ終了するため、revert_in_scope と同じく\n# 存在確認してから呼ぶ(errexit 有効シェルでの途中 abort 防止。Issue #417)\nif [[ -d \".agents/skills/${SKILL_NAME}\" ]]; then\n git clean -fd -- \".agents/skills/${SKILL_NAME}/\"\nfi\n# 上記 || true / 存在確認ガードは失敗を握り潰し得るため、却下リバートが実際に完了したかを\n# ここで実測してから次スキルへ進む。スキルディレクトリ配下が clean(porcelain 空)かつ\n# skills-lock.json が clean(tracked/untracked 双方)でなければ fail-closed でループ全体を\n# 停止する。\n# - `git diff --quiet` は worktree vs index の比較(他スキルの承認済み stage 分には影響\n# されない)であり、tracked な残留変更を検出する\n# - ただし `git diff --quiet` は untracked ファイルを無視するため、これだけでは\n# skills-lock.json が初回生成(未追跡)のまま残置されたケースを見逃す(`|| true` が\n# 吸収する pathspec エラーと同じ穴。Bugbot 指摘)。`git ls-files --others` で untracked\n# の残留も別途検出する\n# 却下リバートが未完了のまま次スキルへ進むと、残留した却下対象の変更が次スキルの承認時に\n# 一緒に stage され得るため、warning のみで継続してはならない(Issue #417。checker を\n# 持つリポジトリ向けの却下フェンス(同期開始前 snapshot からの復元)と同じ fail-closed\n# 扱いに揃える)\n# `[[ -n \"$(git status ...)\" ]]` / `[[ -n \"$(git ls-files ...)\" ]]` は command substitution\n# の終了コードを見ないため、権限・index 異常等でコマンド自体が失敗して空出力になった場合\n# 「残留なし」と誤判定し得る(Issue #417 P0 指摘。CI codex-review 指摘と同一の穴)。\n# 各出力を個別代入し、取得失敗を明示的に検知してから内容判定へ進む\nif ! FINAL_STATUS_OUT=\"$(git status --porcelain -- \".agents/skills/${SKILL_NAME}/\")\"; then\n echo \"エラー: ${SKILL_NAME} 配下の残留確認(git status)に失敗しました。空出力を「残留なし」と誤判定しないため停止します(fail-closed)。原因を解消してから再実行してください。\" >&2\n exit 1\nfi\nif ! FINAL_LOCK_UNTRACKED_OUT=\"$(git ls-files -z --others --exclude-standard -- skills-lock.json)\"; then\n echo \"エラー: skills-lock.json の未追跡残留確認(git ls-files)に失敗しました。空出力を「残留なし」と誤判定しないため停止します(fail-closed)。原因を解消してから再実行してください。\" >&2\n exit 1\nfi\nif [[ -n \"${FINAL_STATUS_OUT}\" ]] \\\n || ! git diff --quiet -- skills-lock.json \\\n || [[ -n \"${FINAL_LOCK_UNTRACKED_OUT}\" ]]; then\n echo \"エラー: 却下リバートが完了していません(${SKILL_NAME} 配下の残留変更、または skills-lock.json の worktree 差分・未追跡残留)。git status を確認し、手動復旧してから再実行してください(fail-closed。次スキルへは進めません)。\" >&2\n exit 1\nfi","type":"text"}]},{"type":"paragraph","content":[{"text":"Step 4 の clean ガードにより ","type":"text"},{"text":"npx","type":"text","marks":[{"type":"code_inline"}]},{"text":" 実行前の当該ディレクトリは clean(未追跡含む)であることが保証されているため、","type":"text"},{"text":"git clean","type":"text","marks":[{"type":"code_inline"}]},{"text":" で削除される未追跡ファイルは ","type":"text"},{"text":"npx","type":"text","marks":[{"type":"code_inline"}]},{"text":" が作成したものに限られる。","type":"text"},{"text":"git clean","type":"text","marks":[{"type":"code_inline"}]},{"text":" の対象は kebab-case 検証済みの ","type":"text"},{"text":"${SKILL_NAME}","type":"text","marks":[{"type":"code_inline"}]},{"text":" 配下のみに限定されており、リポジトリ全体には影響しない。","type":"text"}]},{"type":"paragraph","content":[{"text":"このリバートは「次スキルの ","type":"text"},{"text":"npx skills add","type":"text","marks":[{"type":"code_inline"}]},{"text":" 実行前」に行うため、","type":"text"},{"text":"skills-lock.json","type":"text","marks":[{"type":"code_inline"}]},{"text":" から戻るのは当該スキル分のみである。","type":"text"},{"text":"git checkout --","type":"text","marks":[{"type":"code_inline"}]},{"text":" は HEAD ではなく index から復元するため、承認済みの他スキルの hash は index にも作業ツリーにも保持されており、影響を受けない。","type":"text"}]},{"type":"paragraph","content":[{"text":"checker を持つリポジトリの却下は次を使う","type":"text","marks":[{"type":"strong"}]},{"text":"(同期前 check・Step 5.5 の apply が当該スキルや durable patch の file を index へ stage している可能性があるため、","type":"text"},{"text":"git checkout --","type":"text","marks":[{"type":"code_inline"}]},{"text":"(index → worktree)だけでは戻らない。Step 4 で checker 初回実行より前に保存した ","type":"text"},{"text":"PRE_SYNC_TREE","type":"text","marks":[{"type":"code_inline"}]},{"text":"(同期開始前の index snapshot)を source に、","type":"text"},{"text":"契約パス限定","type":"text","marks":[{"type":"strong"}]},{"text":"で index + worktree を復元する。npx 後に取得した snapshot を使ってはならない — 復元先が raw upstream 状態になり「同期前へ戻す」契約に反する。index 全体の ","type":"text"},{"text":"git read-tree","type":"text","marks":[{"type":"code_inline"}]},{"text":" も使わない — 範囲外 path の index まで書き換わり、範囲外の手動復旧案内と矛盾する):","type":"text"}]},{"type":"code_block","attrs":{"wrap":false,"language":"bash"},"content":[{"text":"# PRE_SYNC_TREE は Step 4 が表示した同期開始前の snapshot hash。shell を跨いで変数が消えている\n# 場合は表示済みの値を代入してから実行する。未設定のまま復元してはならない\n: \"${PRE_SYNC_TREE:?Step 4 の同期開始前 snapshot hash を設定してから実行する(未設定のまま復元しない)}\"\n\n# npx・apply が新規作成した未追跡ファイルは git clean で即削除せず一時ディレクトリへ退避する\n# (checker が契約ディレクトリ内へ移動・新規作成したファイルの唯一のコピーであり得るため。\n# 退避先を確認し、不要と判断してから手動で削除する)。退避(mktemp -d / mktemp /\n# git ls-files / mkdir / mv)自体の失敗を検査せず git restore --worktree へ進むと、退避されなかった「唯一の\n# コピー」が PRE_SYNC_TREE の内容で無音に上書きされ、データ喪失になる(Issue #418)。\n# このフェンスはループ外側でユーザーが直接実行する手動手順のため、mktemp -d /\n# mktemp / git ls-files の失敗は復元へ一切進まず即座に停止する(自動復元経路(Step 4 の\n# restore_contract_scope)のような「index のみへ降格して継続」は行わない)\nif ! CONTRACT_UNTRACKED_BACKUP_DIR=\"$(mktemp -d)\"; then\n echo \"エラー: 未追跡ファイルの退避先ディレクトリ作成に失敗しました。復元の完全性を確認できないため、git restore には一切進みません(fail-closed)。原因を解消してから再実行してください。\"\n exit 1\nfi\n# skills-lock.json も対象に含める(checker が git rm --cached 等で未追跡化して内容変更した\n# 場合、その唯一の内容を退避せずに restore で上書きしないため。通常は tracked で列挙されない)。\n# パイプ経由の while はサブシェルで実行され git ls-files 自体の失敗が不可視になるため、\n# 一時ファイルへ書き出してから読む。mktemp 自体の失敗も検査する(検査しないと、errexit\n# オフのシェルでは空パスへのリダイレクトが後続で誤解を招くエラーになり、set -e 下では\n# fail-closed メッセージも退避先の掃除もないまま即中断する。Issue #418 と同型)\nif ! untracked_list=\"$(mktemp)\"; then\n echo \"エラー: 未追跡ファイル列挙用の一時ファイル作成に失敗しました。復元の完全性を確認できないため、git restore には一切進みません(fail-closed)。原因を解消してから再実行してください。\"\n # この時点で退避先はまだ空(mv 前)のため、空ディレクトリのみ削除して片付ける\n rmdir \"${CONTRACT_UNTRACKED_BACKUP_DIR}\" 2>/dev/null || true\n exit 1\nfi\nif ! git ls-files -z --others --exclude-standard -- skills-lock.json \".agents/skills/${SKILL_NAME}/\" scripts/local-patches/ > \"${untracked_list}\"; then\n echo \"エラー: 契約範囲内の未追跡ファイル列挙に失敗しました。復元の完全性を確認できないため、git restore には一切進みません(fail-closed)。原因を解消してから再実行してください。\"\n rm -f \"${untracked_list}\"\n # この時点でも退避先はまだ空(mv 前)のため、空ディレクトリのみ削除して片付ける\n rmdir \"${CONTRACT_UNTRACKED_BACKUP_DIR}\" 2>/dev/null || true\n exit 1\nfi\nbackup_failed=0\nfailed_paths=()\nmoved=0\nwhile IFS= read -r -d '' p; do\n if ! mkdir -p \"${CONTRACT_UNTRACKED_BACKUP_DIR}/$(dirname \"${p}\")\" \\\n || ! mv -- \"${p}\" \"${CONTRACT_UNTRACKED_BACKUP_DIR}/${p}\"; then\n # 退避に失敗したファイルはスキップし、残りのファイルの退避は継続する\n # (保全できるコピーを最大化する)。1 件でも失敗すれば worktree 復元は行わない\n backup_failed=1\n failed_paths+=(\"${p}\")\n continue\n fi\n moved=1\ndone \u003c \"${untracked_list}\"\nrm -f \"${untracked_list}\"\necho \"未追跡ファイルの退避先: ${CONTRACT_UNTRACKED_BACKUP_DIR}\"\n\n# 契約パス限定で index (+ 退避が全件成功していれば worktree も) を同期開始前へ復元する。\n# この同期(pre-check・npx・apply)で生じた契約範囲の stage だけが取り除かれ、承認済みの\n# 他スキル分・範囲外 path の index は一切変更されない。git restore は no-overlay が既定の\n# ため、同期開始前 tree に無い tracked ファイルは契約パス内に限り index・worktree から\n# 取り除かれる(pathspec は index にも照合されるため、snapshot 後に新規作成・stage された\n# ファイルも取り除かれる)。tree にも index にも無い pathspec の不一致エラーのみ復元対象\n# なしとして無視できるが、真の失敗と区別が付かないため path ごとに分離したうえで、成功\n# 可否は下の復元後検証で判定する\nrestore_targets=(--staged --worktree)\nif [[ \"${backup_failed}\" -eq 1 ]]; then\n # 退避の完全性を確認できない以上、worktree への git restore は行わない\n # (未退避の唯一のコピーを上書きするおそれがあるため)。index のみ復元へ降格する\n restore_targets=(--staged)\n echo \"エラー: 契約範囲内の未追跡ファイルの退避に失敗しました。worktree の復元は行いません(fail-closed)。\"\n echo \"退避できなかったパス: ${failed_paths[*]}\"\n if [[ \"${moved}\" -eq 1 ]]; then\n echo \"退避済み分は ${CONTRACT_UNTRACKED_BACKUP_DIR} に残しています。\"\n fi\nfi\ngit restore \"${restore_targets[@]}\" --source=\"${PRE_SYNC_TREE}\" -- skills-lock.json 2>/dev/null || true\ngit restore \"${restore_targets[@]}\" --source=\"${PRE_SYNC_TREE}\" -- \".agents/skills/${SKILL_NAME}/\" 2>/dev/null || true\ngit restore \"${restore_targets[@]}\" --source=\"${PRE_SYNC_TREE}\" -- scripts/local-patches/ 2>/dev/null || true\n\nif [[ \"${backup_failed}\" -eq 1 ]]; then\n echo \"エラー: 未追跡ファイルの退避に失敗したため、契約範囲の復元は index のみで停止しました(worktree は未復元)。未退避のファイルを手動で退避してから、git restore --worktree --source=${PRE_SYNC_TREE} -- \u003cpath> で契約範囲の worktree を復旧し、再実行してください(fail-closed。この却下は完了とは扱わず、ループ全体を停止します)。\"\n exit 1\nfi\n\n# 復元後検証(fail-closed): 契約パスの index・worktree が同期開始前(PRE_SYNC_TREE)と一致し、\n# 未追跡も残っていない(未追跡は上で退避済み)ことを実測してから完了と扱う。判定基準は\n# HEAD ではなく PRE_SYNC_TREE(HEAD 基準の git status --porcelain だと、同期前から存在した\n# 正当な staged 変更が復元完了後も非空として現れ、正しい復元を誤報する)。\n# 検証に失敗した場合はループ全体を exit 1 で停止する(Bugbot Medium 指摘: 従来はここで\n# echo するだけで完了扱いのまま次スキルへ continue していたため、「却下時は fail-closed」\n# というコメント上の意図に反し、半端に復元された契約範囲(skills-lock.json・当該スキル・\n# durable patch のいずれかに raw upstream + 同期前 check の stage が混在した状態)のまま\n# 次スキルの npx が走り得た)。この却下フェンスは Step 4・5.5 の自動失敗経路と異なり\n# ループの外側でユーザーが実行する手動手順のため、`revert_in_scope` の「次スキルへ\n# continue」という既定動作とは切り離し、検証失敗時だけ明示的に停止する。\nif git diff --cached --quiet \"${PRE_SYNC_TREE}\" -- skills-lock.json \".agents/skills/${SKILL_NAME}/\" scripts/local-patches/ \\\n && git diff --quiet \"${PRE_SYNC_TREE}\" -- skills-lock.json \".agents/skills/${SKILL_NAME}/\" scripts/local-patches/ \\\n && [[ -z \"$(git ls-files --others --exclude-standard -- skills-lock.json \".agents/skills/${SKILL_NAME}/\" scripts/local-patches/)\" ]]; then\n echo \"復元完了(検証済み)\"\nelse\n echo \"エラー: 復元後検証で契約範囲に差分が残っています。git diff --cached ${PRE_SYNC_TREE} -- \u003c契約パス> / git diff ${PRE_SYNC_TREE} -- \u003c契約パス> / git ls-files --others -- \u003c契約パス> で残留を確認し、git restore --staged --worktree --source=${PRE_SYNC_TREE} -- \u003cpath> で手動復旧してから再実行してください(fail-closed。この却下は完了とは扱わず、ループ全体を停止します)。\"\n exit 1\nfi","type":"text"}]},{"type":"paragraph","content":[{"text":"対象は kebab-case 検証済みの当該スキルディレクトリ配下・","type":"text"},{"text":"skills-lock.json","type":"text","marks":[{"type":"code_inline"}]},{"text":"・durable patch(","type":"text"},{"text":"scripts/local-patches/","type":"text","marks":[{"type":"code_inline"}]},{"text":")のみで、承認済みの他スキルの stage にも範囲外 path の index にも影響しない。Step 4・5.5 の","type":"text"},{"text":"失敗経路","type":"text","marks":[{"type":"strong"}]},{"text":"では同じ復元を ","type":"text"},{"text":"restore_contract_scope","type":"text","marks":[{"type":"code_inline"}]},{"text":" が自動実行するため、この手動フェンスはユーザー却下時にのみ使う。却下の復元後検証(上記フェンス)が失敗した場合は、この却下自体を完了扱いにせずループ全体を ","type":"text"},{"text":"exit 1","type":"text","marks":[{"type":"code_inline"}]},{"text":" で停止する(「却下された場合は次スキルへ continue する」という上記の既定動作は、復元後検証が成功した場合にのみ成立する)。未追跡ファイルの退避(","type":"text"},{"text":"mktemp -d","type":"text","marks":[{"type":"code_inline"}]},{"text":" / ","type":"text"},{"text":"mktemp","type":"text","marks":[{"type":"code_inline"}]},{"text":" / ","type":"text"},{"text":"git ls-files","type":"text","marks":[{"type":"code_inline"}]},{"text":" / ","type":"text"},{"text":"mkdir","type":"text","marks":[{"type":"code_inline"}]},{"text":" / ","type":"text"},{"text":"mv","type":"text","marks":[{"type":"code_inline"}]},{"text":")に失敗した場合は worktree への ","type":"text"},{"text":"git restore","type":"text","marks":[{"type":"code_inline"}]},{"text":" を一切行わず fail-closed で停止する(退避されていない唯一のコピーを無音で上書きしないため)。","type":"text"}]},{"type":"heading","attrs":{"level":4},"content":[{"text":"Step 7: 承認されたスキルを stage する(ループ内で積み上げる)","type":"text"}]},{"type":"code_block","attrs":{"wrap":false,"language":"bash"},"content":[{"text":"# 当該スキルのファイルのみをステージング(tracked 変更 + Step 5 で提示した未追跡ファイル)\ngit add skills-lock.json \".agents/skills/${SKILL_NAME}/\"\n\n# checker を持つリポジトリは、承認対象(Step 6 の最終 diff)に含めた durable patch の\n# 変更も同じ承認単位で stage する\nif [[ -f scripts/check-skill-local-patches.sh && -d scripts/local-patches/ ]]; then\n git add scripts/local-patches/\nfi","type":"text"}]},{"type":"paragraph","content":[{"text":"skills-lock.json","type":"text","marks":[{"type":"code_inline"}]},{"text":" は単一 JSON ファイルのため行単位での部分ステージは現実的でない。しかし Step 1 の事前ガードで実行開始時の clean 状態を保証しているため、ファイル全体をステージしても sync 由来の変更のみが含まれ、無関係な編集が混入することはない。このコマンドをループ内で実行することで、複数スキルの全スキル sync でも処理した全スキルが過不足なく stage に積み上がる。","type":"text"},{"text":"checker を持つリポジトリでは Step 5.5(apply + final check)の成功が stage の前提","type":"text","marks":[{"type":"strong"}]},{"text":"であり、","type":"text"},{"text":"computedHash","type":"text","marks":[{"type":"code_inline"}]},{"text":" は npx が書いた upstream 版の値のまま変更しない(local patch で hash を更新しない)。","type":"text"}]},{"type":"heading","attrs":{"level":3},"content":[{"text":"Step 8: コミット提案(ループ後に1回だけ実行)","type":"text"}]},{"type":"paragraph","content":[{"text":"ループ完了後、stage 済みの全承認スキルをまとめて1コミットにする。","type":"text"}]},{"type":"code_block","attrs":{"wrap":false,"language":"bash"},"content":[{"text":"git commit -m \"$(cat \u003c\u003c'EOF'\nchore(skills-lock): upstream の最新ハッシュと同期\n\n\u003c変更内容の要約>\nEOF\n)\"","type":"text"}]},{"type":"paragraph","content":[{"text":"ユーザーにコミットしてよいか確認する。承認済みスキルが1つもなかった場合(全却下・差分なし)はコミットせずその旨を伝える。","type":"text"}]},{"type":"heading","attrs":{"level":2},"content":[{"text":"skills CLI のバージョン固定と更新手順","type":"text"}]},{"type":"paragraph","content":[{"text":"Why","type":"text","marks":[{"type":"strong"}]},{"text":": ","type":"text"},{"text":"npx skills add","type":"text","marks":[{"type":"code_inline"}]},{"text":" をバージョン未固定で実行すると、npx はローカルキャッシュに無い場合レジストリのその時点の最新版を確認なしで即時取得・実行する。","type":"text"},{"text":"skills","type":"text","marks":[{"type":"code_inline"}]},{"text":"(vercel-labs/skills)パッケージが乗っ取られた場合、これは任意コード実行の経路になる。しかもこの実行は Step 5 の差分確認・Step 6 のユーザー承認より","type":"text"},{"text":"前","type":"text","marks":[{"type":"strong"}]},{"text":"に走るため、source の ","type":"text"},{"text":"Fandhe-AI/\u003crepo>","type":"text","marks":[{"type":"code_inline"}]},{"text":" 完全一致検証では防げない。exact 版(","type":"text"},{"text":"X.Y.Z","type":"text","marks":[{"type":"code_inline"}]},{"text":"。dist-tag・","type":"text"},{"text":"^","type":"text","marks":[{"type":"code_inline"}]},{"text":"/","type":"text"},{"text":"~","type":"text","marks":[{"type":"code_inline"}]},{"text":" レンジは禁止)への固定が信頼アンカーになる。","type":"text"}]},{"type":"paragraph","content":[{"text":"固定版の決め方","type":"text","marks":[{"type":"strong"}]},{"text":":","type":"text"}]},{"type":"ordered_list","attrs":{"order":1,"listStyle":"number"},"content":[{"type":"list_item","content":[{"type":"paragraph","content":[{"text":"npm view skills version","type":"text","marks":[{"type":"code_inline"}]},{"text":" で現在の latest を確認する","type":"text"}]}]},{"type":"list_item","content":[{"type":"paragraph","content":[{"text":"npm view skills repository.url","type":"text","marks":[{"type":"code_inline"}]},{"text":" が ","type":"text"},{"text":"vercel-labs/skills","type":"text","marks":[{"type":"code_inline"}]},{"text":" であることを確認する","type":"text"}]}]},{"type":"list_item","content":[{"type":"paragraph","content":[{"text":"npm view skills time --json","type":"text","marks":[{"type":"code_inline"}]},{"text":" 等で公開日時が不自然でないことを確認する","type":"text"}]}]},{"type":"list_item","content":[{"type":"paragraph","content":[{"text":"upstream リポジトリの該当タグ間の差分・リリースノートを確認し、問題なければ採用する","type":"text"}]}]}]},{"type":"paragraph","content":[{"text":"更新手順","type":"text","marks":[{"type":"strong"}]},{"text":":","type":"text"}]},{"type":"ordered_list","attrs":{"order":1,"listStyle":"number"},"content":[{"type":"list_item","content":[{"type":"paragraph","content":[{"text":"scripts/skills-lock-update.sh","type":"text","marks":[{"type":"code_inline"}]},{"text":" の ","type":"text"},{"text":"SKILLS_CLI_VERSION","type":"text","marks":[{"type":"code_inline"}]},{"text":" と、本ファイルの Step 4 フェンス内の ","type":"text"},{"text":"SKILLS_CLI_VERSION","type":"text","marks":[{"type":"code_inline"}]},{"text":" を","type":"text"},{"text":"同一コミット","type":"text","marks":[{"type":"strong"}]},{"text":"で更新する(値は完全一致させる)","type":"text"}]}]},{"type":"list_item","content":[{"type":"paragraph","content":[{"text":"node --test skills/sync-skills-lock/tests/","type":"text","marks":[{"type":"code_inline"}]},{"text":" で両ファイルの一致を検証する","type":"text"}]}]},{"type":"list_item","content":[{"type":"paragraph","content":[{"text":"universal","type":"text","marks":[{"type":"code_inline"},{"type":"strong"}]},{"text":" が新版でも有効な agent id であることを確認する","type":"text","marks":[{"type":"strong"}]},{"text":"。無効値へ変わっていた場合、CLI はエラー表示のうえ ","type":"text"},{"text":"exit 0","type":"text","marks":[{"type":"code_inline"}]},{"text":" の no-op になる(実測: ","type":"text"},{"text":"[email protected]","type":"text","marks":[{"type":"code_inline"}]},{"text":" で確認済み)ため、気付かずに運用すると「同期したつもりで何も更新されていない」状態になる。確認方法: スクラッチリポジトリで 1 スキルを実際に ","type":"text"},{"text":"--agent universal","type":"text","marks":[{"type":"code_inline"}]},{"text":" で実行し、出力に ","type":"text"},{"text":"Invalid agents","type":"text","marks":[{"type":"code_inline"}]},{"text":" が出ないこと、および ","type":"text"},{"text":".agents/skills/\u003cname>/","type":"text","marks":[{"type":"code_inline"}]},{"text":" が実際に更新されることを確認する","type":"text"}]}]},{"type":"list_item","content":[{"type":"paragraph","content":[{"text":"1 スキルで実際に実行し、差分が正常であること・書き込みが ","type":"text"},{"text":".agents/skills/\u003cname>/","type":"text","marks":[{"type":"code_inline"}]},{"text":" と ","type":"text"},{"text":"skills-lock.json","type":"text","marks":[{"type":"code_inline"}]},{"text":" のみに限定されていること(","type":"text"},{"text":"git status --porcelain","type":"text","marks":[{"type":"code_inline"}]},{"text":" に ","type":"text"},{"text":".claude/","type":"text","marks":[{"type":"code_inline"}]},{"text":" 等の他ツリーが現れないこと)を確認する","type":"text"}]}]},{"type":"list_item","content":[{"type":"paragraph","content":[{"text":"chore(sync-skills-lock): skills CLI を X.Y.Z へ更新","type":"text","marks":[{"type":"code_inline"}]},{"text":" でコミットする","type":"text"}]}]}]},{"type":"paragraph","content":[{"text":"fail-closed","type":"text","marks":[{"type":"strong"}]},{"text":": 固定版が解決できない場合(該当版の不存在・レジストリ障害)は ","type":"text"},{"text":"npx","type":"text","marks":[{"type":"code_inline"}]},{"text":" が非ゼロ終了する。黙って最新版へフォールバックする経路は存在せず、dist-tag・レンジ指定への書き換えも禁止する。","type":"text"},{"text":"npx","type":"text","marks":[{"type":"code_inline"}]},{"text":" の失敗経路でも成功経路と同じスコープ外書き込み検査(後述の「多層防御」)を必ず実行する。両実行経路とも、","type":"text"},{"text":"npx","type":"text","marks":[{"type":"code_inline"}]},{"text":" 行だけ errexit を無効化(","type":"text"},{"text":"set +e","type":"text","marks":[{"type":"code_inline"}]},{"text":"/","type":"text"},{"text":"set -e","type":"text","marks":[{"type":"code_inline"}]},{"text":")して終了コードを ","type":"text"},{"text":"PIPESTATUS","type":"text","marks":[{"type":"code_inline"}]},{"text":" から保存し、成功・失敗いずれの経路でも事後のスコープ外検査を必ず実行する(","type":"text"},{"text":"set -e","type":"text","marks":[{"type":"code_inline"}]},{"text":"/","type":"text"},{"text":"pipefail","type":"text","marks":[{"type":"code_inline"}]},{"text":" の下でこの無効化を欠くと、","type":"text"},{"text":"npx","type":"text","marks":[{"type":"code_inline"}]},{"text":" の非ゼロ終了でパイプラインごとその場で異常終了し、事後検査・リバートに到達できないまま部分書き込みが残置される)。ただし復元方法は経路ごとに異なる: 本ファイルの Step 4 フェンス側は呼び出し元シェルの errexit 状態(","type":"text"},{"text":"$-","type":"text","marks":[{"type":"code_inline"}]},{"text":")を保存し、元々有効だった場合のみ ","type":"text"},{"text":"set -e","type":"text","marks":[{"type":"code_inline"}]},{"text":" で再有効化する条件付き復元であり、無条件 ","type":"text"},{"text":"set -e","type":"text","marks":[{"type":"code_inline"}]},{"text":" は行わない(このフェンスは errexit が無効なエージェント対話シェルへコピペ実行され得るため、無条件復元だと呼び出し元シェルへ errexit を新規有効化してしまい、同一シェルで後続する Step 6 却下コマンド等がガードの無い非ゼロ終了で途中 abort し得る)。一方 ","type":"text"},{"text":"scripts/skills-lock-update.sh","type":"text","marks":[{"type":"code_inline"}]},{"text":" はファイル先頭で ","type":"text"},{"text":"set -euo pipefail","type":"text","marks":[{"type":"code_inline"}]},{"text":" を自ら宣言しており errexit 常時有効の前提が成立するため、同スクリプト側は無条件 ","type":"text"},{"text":"set -e","type":"text","marks":[{"type":"code_inline"}]},{"text":" 復元のままで正しい(詳細は同スクリプト内コメント参照)。","type":"text"},{"text":"scripts/skills-lock-update.sh","type":"text","marks":[{"type":"code_inline"}]},{"text":" を単体実行した場合はスクリプト全体を ","type":"text"},{"text":"exit 1","type":"text","marks":[{"type":"code_inline"}]},{"text":" で停止する(この行の前後だけ ","type":"text"},{"text":"set +e","type":"text","marks":[{"type":"code_inline"}]},{"text":"/","type":"text"},{"text":"set -e","type":"text","marks":[{"type":"code_inline"}]},{"text":" を挟む理由は同スクリプト内のコメント参照)。一方、本ファイルの Step 4 フェンス(複数スキルをループで処理する経路)では、","type":"text"},{"text":"npx","type":"text","marks":[{"type":"code_inline"}]},{"text":" の失敗を検出したら事後検査のうえ Step 6 の却下時と同じ手順(","type":"text"},{"text":"git checkout --","type":"text","marks":[{"type":"code_inline"}]},{"text":" / ","type":"text"},{"text":"git clean -fd","type":"text","marks":[{"type":"code_inline"}]},{"text":")で当該スキル分の部分書き込みをリバートしてから skip(","type":"text"},{"text":"continue","type":"text","marks":[{"type":"code_inline"}]},{"text":")して次スキルへ進む — Step 1/3 の他の skip 分岐と同じ制御フローであり、ループ全体を停止させるものではない。事後検査・リバートを挟まずに skip すると、失敗が部分書き込み後に発生した場合の残置変更(スコープ内は次スキルの ","type":"text"},{"text":"git add","type":"text","marks":[{"type":"code_inline"}]},{"text":"(Step 7)が承認済み変更と一緒に stage してしまう、スコープ外は後続処理から「元から存在した dirty 状態」と誤認され得る)を防げないため、両方とも必須の手順である。","type":"text"},{"text":"スコープ外書き込みの検出(","type":"text","marks":[{"type":"strong"}]},{"text":"exit 1","type":"text","marks":[{"type":"code_inline"},{"type":"strong"}]},{"text":")はこれとは別の停止経路であり、","type":"text","marks":[{"type":"strong"}]},{"text":"continue","type":"text","marks":[{"type":"code_inline"},{"type":"strong"}]},{"text":" ではなくループ全体を止める","type":"text","marks":[{"type":"strong"}]},{"text":"(詳細は次項「書き込みスコープの制限」を参照)。この停止経路は成功経路(","type":"text"},{"text":"NPX_STATUS -eq 0","type":"text","marks":[{"type":"code_inline"}]},{"text":")だけでなく ","type":"text"},{"text":"npx","type":"text","marks":[{"type":"code_inline"}]},{"text":" ","type":"text"},{"text":"失敗","type":"text","marks":[{"type":"strong"}]},{"text":"経路にも及ぶ: 失敗後の事後検査でスコープ外差分・状態シグネチャ不一致を検出した場合も、成功経路と同じ判定基準で ","type":"text"},{"text":"NPX_STATUS_OUT_OF_SCOPE_DIRT=1","type":"text","marks":[{"type":"code_inline"}]},{"text":" を立ててループ全体を ","type":"text"},{"text":"exit 1","type":"text","marks":[{"type":"code_inline"}]},{"text":" で停止し、","type":"text"},{"text":"continue","type":"text","marks":[{"type":"code_inline"}]},{"text":" で次スキルへ進まない(そのまま skip すると未リバートのスコープ外差分が「元から存在した dirty 状態」と誤認され得るため)。","type":"text"}]},{"type":"heading","attrs":{"level":2},"content":[{"text":"書き込みスコープの制限(","type":"text"},{"text":"--agent universal","type":"text","marks":[{"type":"code_inline"}]},{"text":" とスコープ外検出)","type":"text"}]},{"type":"paragraph","content":[{"text":"npx skills add","type":"text","marks":[{"type":"code_inline"}]},{"text":" にエージェント/パス制限を付けずに実行すると、CLI が検出した各エージェント向けツリー(","type":"text"},{"text":".claude/skills/","type":"text","marks":[{"type":"code_inline"}]},{"text":" 等)へも書き込み得る。しかし clean ガード(前提条件節・Step 4)・プレビュー(Step 5)・リバート(Step 6)・承認 ","type":"text"},{"text":"git add","type":"text","marks":[{"type":"code_inline"}]},{"text":"(Step 7)はいずれも ","type":"text"},{"text":"skills-lock.json","type":"text","marks":[{"type":"code_inline"}]},{"text":" と ","type":"text"},{"text":".agents/skills/\u003cname>/","type":"text","marks":[{"type":"code_inline"}]},{"text":" のみを対象としているため、スコープ外への書き込みが発生すると (1) WIP 上書き、(2) レビュー(プレビュー)迂回、(3) 「clean と報告した後の dirty 残留」が起き得る。","type":"text"}]},{"type":"paragraph","content":[{"text":"2 層で防ぐ:","type":"text"}]},{"type":"ordered_list","attrs":{"order":1,"listStyle":"number"},"content":[{"type":"list_item","content":[{"type":"paragraph","content":[{"text":"一次防御(","type":"text","marks":[{"type":"strong"}]},{"text":"--agent universal","type":"text","marks":[{"type":"code_inline"},{"type":"strong"}]},{"text":")","type":"text","marks":[{"type":"strong"}]},{"text":": Step 4 の npx 呼び出しに ","type":"text"},{"text":"--agent universal","type":"text","marks":[{"type":"code_inline"}]},{"text":" を付け、書き込み先を union ストア(","type":"text"},{"text":".agents/skills/\u003cname>/","type":"text","marks":[{"type":"code_inline"}]},{"text":")と ","type":"text"},{"text":"skills-lock.json","type":"text","marks":[{"type":"code_inline"}]},{"text":" のみへ限定する。個別 agent 指定(","type":"text"},{"text":"claude-code","type":"text","marks":[{"type":"code_inline"}]},{"text":" 等)は ","type":"text"},{"text":".agents/skills/","type":"text","marks":[{"type":"code_inline"}]},{"text":" を経由せず対象ツリーへ直接コピーしレイアウトを変えてしまうため使わない","type":"text"}]}]},{"type":"list_item","content":[{"type":"paragraph","content":[{"text":"多層防御(実行前後スナップショット比較 + 状態シグネチャ比較)","type":"text","marks":[{"type":"strong"}]},{"text":": Step 4 の npx 実行直前・直後に ","type":"text"},{"text":"git status --porcelain -z -uall","type":"text","marks":[{"type":"code_inline"}]},{"text":" でリポジトリ全体のスナップショットを取り、スコープ内(","type":"text"},{"text":"skills-lock.json","type":"text","marks":[{"type":"code_inline"}]},{"text":" / ","type":"text"},{"text":".agents/skills/\u003cname>/","type":"text","marks":[{"type":"code_inline"}]},{"text":")を除いた差分を比較する。加えて、リポジトリルート全体(除外はスコープ内と、","type":"text"},{"text":".git","type":"text","marks":[{"type":"code_inline"}]},{"text":" のうちこのフロー自身が前後スナップショット間に実行する ","type":"text"},{"text":"git status","type":"text","marks":[{"type":"code_inline"}]},{"text":" で変動し得る ","type":"text"},{"text":".git/index","type":"text","marks":[{"type":"code_inline"}]},{"text":"・その一時 lock〔","type":"text"},{"text":".git/index.lock","type":"text","marks":[{"type":"code_inline"}]},{"text":" の完全一致のみ。","type":"text"},{"text":".git/config.lock","type":"text","marks":[{"type":"code_inline"}]},{"text":"・","type":"text"},{"text":".git/HEAD.lock","type":"text","marks":[{"type":"code_inline"}]},{"text":" 等の永続 lock の残置は署名対象で、シグネチャ不一致として検出される〕のみ。","type":"text"},{"text":".git/objects","type":"text","marks":[{"type":"code_inline"}]},{"text":"・","type":"text"},{"text":".git/refs","type":"text","marks":[{"type":"code_inline"}]},{"text":"・","type":"text"},{"text":"packed-refs","type":"text","marks":[{"type":"code_inline"}]},{"text":"・","type":"text"},{"text":"HEAD","type":"text","marks":[{"type":"code_inline"}]},{"text":"・","type":"text"},{"text":"logs","type":"text","marks":[{"type":"code_inline"}]},{"text":"・","type":"text"},{"text":"worktrees","type":"text","marks":[{"type":"code_inline"}]},{"text":"・","type":"text"},{"text":"modules","type":"text","marks":[{"type":"code_inline"}]},{"text":" を含む残り全域と ","type":"text"},{"text":".git/config","type":"text","marks":[{"type":"code_inline"}]},{"text":"・","type":"text"},{"text":".git/hooks/","type":"text","marks":[{"type":"code_inline"}]},{"text":" 等の永続 Git メタデータは署名対象に含め、履歴・参照の改変やフック仕込み・設定改変も検出する。","type":"text"},{"text":".agents","type":"text","marks":[{"type":"code_inline"}]},{"text":" / ","type":"text"},{"text":".agents/skills","type":"text","marks":[{"type":"code_inline"}]},{"text":" 自身は実行前に存在した場合のみ署名対象で、実行前に不存在だった場合に限り自身のメタデータを omit して初回インストールの正当な親作成を許容する)の状態シグネチャを ","type":"text"},{"text":"repo_state_signature","type":"text","marks":[{"type":"code_inline"}]},{"text":"(","type":"text"},{"text":"path_state","type":"text","marks":[{"type":"code_inline"}]},{"text":" を起点 ","type":"text"},{"text":".","type":"text","marks":[{"type":"code_inline"}]},{"text":" へ適用。python3。通常ファイルは mode + 内容の sha256、シンボリックリンクは mode + リンク先の sha256、ディレクトリ・gitlink は自身の mode + 配下全エントリを再帰的に相対パス・パーミッション・内容ハッシュで集約した sha256)で1本にまとめ、前後で比較する。","type":"text"},{"text":"repo_state_signature","type":"text","marks":[{"type":"code_inline"}]},{"text":" はさらに index の論理状態(","type":"text"},{"text":"git ls-files --stage","type":"text","marks":[{"type":"code_inline"}]},{"text":" + ","type":"text"},{"text":"git ls-files -v","type":"text","marks":[{"type":"code_inline"}]},{"text":"。エントリ・skip-worktree / assume-unchanged ビット)の sha256 を連結し、prune している ","type":"text"},{"text":".git/index","type":"text","marks":[{"type":"code_inline"}]},{"text":" の背後での index 改変も前後不一致として検出する。git status に現れたパスだけをシグネチャ化する方式では、git がディレクトリの mode を追跡しない以上、スコープ外ディレクトリの chmod や ","type":"text"},{"text":".gitignore","type":"text","marks":[{"type":"code_inline"}]},{"text":" 対象ファイルの上書きを原理的に検出できないため、走査は status 由来のパス集合ではなく作業ツリー全体に対して行う。ステータス+パスの記録だけでなく全体シグネチャも一致して初めて「変化なし」と判定し、どちらかに差分があれば ","type":"text"},{"text":"--agent universal","type":"text","marks":[{"type":"code_inline"}]},{"text":" が抑止しているはずの書き込みが発生したことを意味し、スコープ内をリバートしたうえで ","type":"text"},{"text":"exit 1","type":"text","marks":[{"type":"code_inline"}]},{"text":" によりループ全体を停止する。","type":"text"},{"text":"他の skip 分岐(","type":"text","marks":[{"type":"strong"}]},{"text":"continue","type":"text","marks":[{"type":"code_inline"},{"type":"strong"}]},{"text":")とは異なり、この検出はループを継続しない","type":"text","marks":[{"type":"strong"}]},{"text":": スコープ外の汚染は自動リバートされないため、続行すると最終報告が「clean」でも実際は dirty 残留となるため。停止時点までに承認・stage 済みの他スキル分は index に残ったままになるので、操作者は ","type":"text"},{"text":"git status","type":"text","marks":[{"type":"code_inline"}]},{"text":" で確認したうえで、必要な分だけ ","type":"text"},{"text":"git commit","type":"text","marks":[{"type":"code_inline"}]},{"text":" するか ","type":"text"},{"text":"git reset","type":"text","marks":[{"type":"code_inline"}]},{"text":" で戻すかを判断する","type":"text"}]}]}]},{"type":"paragraph","content":[{"text":"両層の前提として、まず実行場所がメイン worktree であることを npx 実行前に検証する(","type":"text"},{"text":"git rev-parse --absolute-git-dir","type":"text","marks":[{"type":"code_inline"}]},{"text":" と ","type":"text"},{"text":"--git-common-dir","type":"text","marks":[{"type":"code_inline"}]},{"text":" を物理パスへ正規化して比較し、不一致なら fail-closed で停止する。linked worktree では実 Git ディレクトリ〔","type":"text"},{"text":".git/worktrees/\u003cname>/","type":"text","marks":[{"type":"code_inline"}]},{"text":" と共有側の ","type":"text"},{"text":"refs","type":"text","marks":[{"type":"code_inline"}]},{"text":"・","type":"text"},{"text":"logs","type":"text","marks":[{"type":"code_inline"}]},{"text":"・","type":"text"},{"text":"config","type":"text","marks":[{"type":"code_inline"}]},{"text":"・objects〕が作業ツリー外にあり ","type":"text"},{"text":"repo_state_signature","type":"text","marks":[{"type":"code_inline"}]},{"text":" の走査対象に入らないため、npx が ","type":"text"},{"text":"git update-ref","type":"text","marks":[{"type":"code_inline"}]},{"text":" 等で共有リポジトリを改変しても前後の全検査が一致してしまう)。この一致比較は linked worktree を除外するだけで実 Git ディレクトリの所在までは保証しないため、続けて「実 Git ディレクトリが作業ツリー直下の ","type":"text"},{"text":".git","type":"text","marks":[{"type":"code_inline"}]},{"text":" 実体ディレクトリである」ことも検証する(","type":"text"},{"text":"--show-toplevel","type":"text","marks":[{"type":"code_inline"}]},{"text":" を物理パスへ正規化して ","type":"text"},{"text":"--absolute-git-dir","type":"text","marks":[{"type":"code_inline"}]},{"text":" の物理パスと ","type":"text"},{"text":"\u003ctoplevel>/.git","type":"text","marks":[{"type":"code_inline"}]},{"text":" の厳密一致を要求し、さらに ","type":"text"},{"text":"\u003ctoplevel>/.git","type":"text","marks":[{"type":"code_inline"}]},{"text":" を lstat して symlink ではなく directory であることを要求する。","type":"text"},{"text":"git clone --separate-git-dir","type":"text","marks":[{"type":"code_inline"}]},{"text":"・submodule checkout・","type":"text"},{"text":".git","type":"text","marks":[{"type":"code_inline"}]},{"text":" が symlink の構成では git-dir と common-dir が一致したまま実体が作業ツリー外にあり、同じ検出不能に陥る)。次に許可先経路(","type":"text"},{"text":".agents","type":"text","marks":[{"type":"code_inline"}]},{"text":" / ","type":"text"},{"text":".agents/skills","type":"text","marks":[{"type":"code_inline"}]},{"text":" / ","type":"text"},{"text":".agents/skills/\u003cname>","type":"text","marks":[{"type":"code_inline"}]},{"text":")の各要素を lstat 検証し、存在する要素が symlink または非ディレクトリなら npx を実行せず fail-closed で停止する(Step 4 フェンス冒頭)。スコープ外検査は許可先を「パス文字列」で走査除外するため、経路が実行前からリポジトリ外を指す symlink だと npx のリンク先への書き込みがどの検査にも現れない。存在しない要素のみ、初回インストールの正当な新規作成として許容する。","type":"text"},{"text":"skills-lock.json","type":"text","marks":[{"type":"code_inline"}]},{"text":" も同じ段階で lstat 検証する: 状態シグネチャの ","type":"text"},{"text":"prune","type":"text","marks":[{"type":"code_inline"}]},{"text":" と porcelain フィルタの双方でパス文字列により除外されるため、実行前から外向き symlink だと npx のリンク先への書き込みがどの検査にも現れない。存在する場合は regular file であることを要求し、symlink・非 regular file なら npx を実行せず fail-closed で停止する(不存在は初回生成として許容する)。加えて許可先","type":"text"},{"text":"配下","type":"text","marks":[{"type":"strong"}]},{"text":"も npx 実行前に ","type":"text"},{"text":"find -type l","type":"text","marks":[{"type":"code_inline"}]},{"text":" で走査し、既存の symlink が 1 件でもあれば npx を実行せず fail-closed で停止する(許可先配下は ","type":"text"},{"text":"prune-under","type":"text","marks":[{"type":"code_inline"}]},{"text":" により署名から除外されるため、配下の既存 symlink を npx が辿ってリポジトリ外へ書き込んでも前後シグネチャ比較には現れない。事前拒否だけが防御になる)。さらに許可先配下の","type":"text"},{"text":"既存 ignored ファイル","type":"text","marks":[{"type":"strong"}]},{"text":"(","type":"text"},{"text":".DS_Store","type":"text","marks":[{"type":"code_inline"}]},{"text":" 等)は npx 実行前に一時バックアップディレクトリへ内容・mode ごと退避し(","type":"text"},{"text":"cp -p","type":"text","marks":[{"type":"code_inline"}]},{"text":"・相対パス構造を保持)、実行後にバックアップと比較して変更・削除を検出したらバックアップから復元する(成功経路では警告のうえ処理継続、失敗経路では ","type":"text"},{"text":"revert_in_scope","type":"text","marks":[{"type":"code_inline"}]},{"text":" に組み込んで復元。復元自体が失敗した場合のみバックアップディレクトリを保全して非ゼロ終了する)。","type":"text"}]},{"type":"paragraph","content":[{"text":"この事前検査は開始時点しか見ない(TOCTOU)ため、さらに 4 つの防御を重ねる:","type":"text"}]},{"type":"ordered_list","attrs":{"order":1,"listStyle":"number"},"content":[{"type":"list_item","content":[{"type":"paragraph","content":[{"text":"許可先要素自身の署名(","type":"text","marks":[{"type":"strong"}]},{"text":"prune-under","type":"text","marks":[{"type":"code_inline"},{"type":"strong"}]},{"text":")","type":"text","marks":[{"type":"strong"}]},{"text":": 状態シグネチャは既存の許可先ディレクトリを「エントリごと除外」するのではなく、配下のみ除外して要素自身(種別・mode・symlink のリンク先)は前後で署名する(","type":"text"},{"text":"SKILL_DIR_SIG_SPEC","type":"text","marks":[{"type":"code_inline"}]},{"text":" の ","type":"text"},{"text":"prune-under","type":"text","marks":[{"type":"code_inline"}]},{"text":"。実行前に不存在だった初回インストールのみ ","type":"text"},{"text":"prune","type":"text","marks":[{"type":"code_inline"}]},{"text":" で全除外する)。npx が","type":"text"},{"text":"実行中に","type":"text","marks":[{"type":"strong"}]},{"text":"許可先を外向き symlink へ置換すると、前後シグネチャの不一致として検出される","type":"text"}]}]},{"type":"list_item","content":[{"type":"paragraph","content":[{"text":"実行後の再検証(","type":"text","marks":[{"type":"strong"}]},{"text":"verify_scope_path_after_run","type":"text","marks":[{"type":"code_inline"},{"type":"strong"}]},{"text":")","type":"text","marks":[{"type":"strong"}]},{"text":": npx 実行後(事後シグネチャ取得前)に許可先経路の全要素を再度 lstat し、存在する要素が symlink・非ディレクトリなら fail-closed で停止する。初回インストールで npx が ","type":"text"},{"text":".agents","type":"text","marks":[{"type":"code_inline"}]},{"text":" 自体を外向き symlink として作成したケース(親要素の omit / 許可先の prune によりシグネチャに現れない経路)もこれが拒否する。検出時(","type":"text"},{"text":"SCOPE_PATH_COMPROMISED=1","type":"text","marks":[{"type":"code_inline"}]},{"text":")の ","type":"text"},{"text":"revert_in_scope","type":"text","marks":[{"type":"code_inline"}]},{"text":" は ","type":"text"},{"text":"skills-lock.json","type":"text","marks":[{"type":"code_inline"}]},{"text":" の復元のみ行い、許可先配下への ","type":"text"},{"text":"git checkout","type":"text","marks":[{"type":"code_inline"}]},{"text":" / ","type":"text"},{"text":"git clean","type":"text","marks":[{"type":"code_inline"}]},{"text":" / ignored 削除は一切行わない — symlink を通じてリンク先(リポジトリ外)のファイルを削除・書き換える事故を避けるため、リンク先の内容とリポジトリ外への書き込み有無の手動確認を案内して非ゼロ終了する(ループ全体を停止する)","type":"text"}]}]},{"type":"list_item","content":[{"type":"paragraph","content":[{"text":"実行後の配下 symlink 走査","type":"text","marks":[{"type":"strong"}]},{"text":": npx 実行後(","type":"text"},{"text":"verify_scope_path_after_run","type":"text","marks":[{"type":"code_inline"}]},{"text":" に続けて)に許可先","type":"text"},{"text":"配下","type":"text","marks":[{"type":"strong"}]},{"text":"を再度 ","type":"text"},{"text":"find -type l","type":"text","marks":[{"type":"code_inline"}]},{"text":" で走査する。事前走査は実行前の 0 件しか保証せず、npx が","type":"text"},{"text":"実行中に","type":"text","marks":[{"type":"strong"}]},{"text":"配下へ作った外向き symlink とそれ経由の書き込みは、配下を署名から除外している前後比較にも経路 3 要素しか見ない再検証にも現れない。事後に見つかる symlink はすべて npx 実行中の作成物であるため、1 件でも検出したら(走査自体の失敗も同様に)","type":"text"},{"text":"SCOPE_PATH_COMPROMISED=1","type":"text","marks":[{"type":"code_inline"}]},{"text":" の保全経路へ倒す: 許可先配下への ","type":"text"},{"text":"git checkout","type":"text","marks":[{"type":"code_inline"}]},{"text":" / ","type":"text"},{"text":"git clean","type":"text","marks":[{"type":"code_inline"}]},{"text":" / ignored 削除 / 既存 ignored の復元をすべてスキップし、","type":"text"},{"text":"skills-lock.json","type":"text","marks":[{"type":"code_inline"}]},{"text":" の復元のみ行い、検出パスとバックアップディレクトリを明示して非ゼロ終了する(手動復旧を案内。ループ全体を停止する)","type":"text"}]}]},{"type":"list_item","content":[{"type":"paragraph","content":[{"text":"実行後の ","type":"text","marks":[{"type":"strong"}]},{"text":"skills-lock.json","type":"text","marks":[{"type":"code_inline"},{"type":"strong"}]},{"text":" 再検証(","type":"text","marks":[{"type":"strong"}]},{"text":"verify_lock_file_after_run","type":"text","marks":[{"type":"code_inline"},{"type":"strong"}]},{"text":")","type":"text","marks":[{"type":"strong"}]},{"text":": npx 実行後(","type":"text"},{"text":"verify_scope_path_after_run","type":"text","marks":[{"type":"code_inline"}]},{"text":" に続けて)に ","type":"text"},{"text":"skills-lock.json","type":"text","marks":[{"type":"code_inline"}]},{"text":" を再度 lstat し、存在するのに regular file でなければ fail-closed で停止する(実行前の不存在→生成のケースも regular file を要求する)。npx が","type":"text"},{"text":"実行中に","type":"text","marks":[{"type":"strong"}]},{"text":" ","type":"text"},{"text":"skills-lock.json","type":"text","marks":[{"type":"code_inline"}]},{"text":" を外向き symlink へ置換してリンク先へ書き込んでも、同パスは署名の ","type":"text"},{"text":"prune","type":"text","marks":[{"type":"code_inline"}]},{"text":" と porcelain フィルタの双方から除外されているためどの検査にも現れない。検出時(","type":"text"},{"text":"LOCK_FILE_COMPROMISED=1","type":"text","marks":[{"type":"code_inline"}]},{"text":")の ","type":"text"},{"text":"revert_in_scope","type":"text","marks":[{"type":"code_inline"}]},{"text":" は素の ","type":"text"},{"text":"git checkout -- skills-lock.json","type":"text","marks":[{"type":"code_inline"}]},{"text":"(リンク先へ書き込み得る)を行わず、","type":"text"},{"text":"rm","type":"text","marks":[{"type":"code_inline"}]},{"text":" で symlink 自体を除去(リンク先は辿らない)してから checkout で index の内容を regular file として書き戻す。symlink 以外の想定外実体(ディレクトリ等)は一切触らず手動復旧を案内して非ゼロ終了する(ループ全体を停止する)","type":"text"}]}]}]},{"type":"paragraph","content":[{"text":"検出の限界","type":"text","marks":[{"type":"strong"}]},{"text":": ","type":"text"},{"text":"git status --porcelain","type":"text","marks":[{"type":"code_inline"}]},{"text":" は ","type":"text"},{"text":".gitignore","type":"text","marks":[{"type":"code_inline"}]},{"text":" 対象を報告しないため、porcelain スナップショット比較(判定軸 1)","type":"text"},{"text":"単独","type":"text","marks":[{"type":"strong"}]},{"text":"では ignore されたパスへの書き込みを検出できない。しかし状態シグネチャ比較(判定軸 2)は ","type":"text"},{"text":".gitignore","type":"text","marks":[{"type":"code_inline"}]},{"text":" 対象を含む作業ツリー全体を走査して内容ハッシュへ取り込むため、ignore されたパスへの書き込みもシグネチャ不一致として検出できる(porcelain 由来の報告一覧に該当パスが載らないだけで、検出・停止自体は機能する)。シグネチャ比較はほかに、npx 実行前から M(追跡・変更済み)や ??(未追跡)だったスコープ外ファイルをステータス文字列・パスとも変えずに内容・パーミッションだけ上書きするケース、およびディレクトリ・gitlink(未初期化 submodule 含む)が異なる状態へ書き換わるケースも検出できる。","type":"text"},{"text":".git","type":"text","marks":[{"type":"code_inline"}]},{"text":" 配下も ","type":"text"},{"text":".git/index","type":"text","marks":[{"type":"code_inline"}]},{"text":" と ","type":"text"},{"text":".git/index.lock","type":"text","marks":[{"type":"code_inline"}]},{"text":" を除く全域(","type":"text"},{"text":"objects","type":"text","marks":[{"type":"code_inline"}]},{"text":"・","type":"text"},{"text":"refs","type":"text","marks":[{"type":"code_inline"}]},{"text":"・","type":"text"},{"text":"packed-refs","type":"text","marks":[{"type":"code_inline"}]},{"text":"・","type":"text"},{"text":"HEAD","type":"text","marks":[{"type":"code_inline"}]},{"text":"・","type":"text"},{"text":"logs","type":"text","marks":[{"type":"code_inline"}]},{"text":"・","type":"text"},{"text":"worktrees","type":"text","marks":[{"type":"code_inline"}]},{"text":"・","type":"text"},{"text":"modules","type":"text","marks":[{"type":"code_inline"}]},{"text":"・","type":"text"},{"text":"config","type":"text","marks":[{"type":"code_inline"}]},{"text":"・","type":"text"},{"text":"hooks","type":"text","marks":[{"type":"code_inline"}]},{"text":"、","type":"text"},{"text":".git/config.lock","type":"text","marks":[{"type":"code_inline"}]},{"text":" 等の永続 lock を含む)が署名対象であり、履歴・参照の改変も検出できる。スコープ内リバート(","type":"text"},{"text":"revert_in_scope","type":"text","marks":[{"type":"code_inline"}]},{"text":")は、npx 実行前に取得した許可先配下の全ファイルインベントリ(","type":"text"},{"text":"find -print0","type":"text","marks":[{"type":"code_inline"}]},{"text":"。ignored 含む・NUL 区切り)との突き合わせで「npx が新規作成した ","type":"text"},{"text":".gitignore","type":"text","marks":[{"type":"code_inline"}]},{"text":" 対象ファイル」のみを個別削除するため、","type":"text"},{"text":".agents/skills/\u003cname>/","type":"text","marks":[{"type":"code_inline"}]},{"text":" 配下へ書き込まれた ignored ファイルも異常終了時に残置されない一方、","type":"text"},{"text":"実行前から存在した ignored ファイル(","type":"text","marks":[{"type":"strong"}]},{"text":".DS_Store","type":"text","marks":[{"type":"code_inline"},{"type":"strong"}]},{"text":" 等)は削除されず保全される","type":"text","marks":[{"type":"strong"}]},{"text":"(","type":"text"},{"text":"git clean -fdx","type":"text","marks":[{"type":"code_inline"}]},{"text":" は既存 ignored ファイルまで巻き添え削除するため使わない)。既存 ignored ファイルを npx が上書き・削除したケースは、許可先配下が署名から除外されている以上シグネチャ比較には現れないため、npx 実行前に取得する内容・mode 込みのバックアップ(","type":"text"},{"text":"IGNORED_BACKUP_DIR","type":"text","marks":[{"type":"code_inline"}]},{"text":")との比較で検出し、","type":"text"},{"text":"restore_preexisting_ignored","type":"text","marks":[{"type":"code_inline"}]},{"text":" がバックアップから復元する(成功経路では警告して継続、失敗経路では ","type":"text"},{"text":"revert_in_scope","type":"text","marks":[{"type":"code_inline"}]},{"text":" の一部として復元。復元失敗時はバックアップディレクトリを保全して非ゼロ終了する)。この復元が regular file のみを前提にできるのは、npx 実行前の許可先配下 symlink 走査(","type":"text"},{"text":"find -type l","type":"text","marks":[{"type":"code_inline"}]},{"text":")が既存 symlink を fail-closed で拒否しているためである。実際に残る制約は次の 2 点である: (1) prune している ","type":"text"},{"text":".git/index","type":"text","marks":[{"type":"code_inline"}]},{"text":"・","type":"text"},{"text":".git/index.lock","type":"text","marks":[{"type":"code_inline"}]},{"text":" の","type":"text"},{"text":"ファイルとしての","type":"text","marks":[{"type":"strong"}]},{"text":"書き込みは検出できない(このフロー自身の ","type":"text"},{"text":"git status","type":"text","marks":[{"type":"code_inline"}]},{"text":" が npx 実行後に必ず index を更新するため、署名対象へ含めると常に誤検知になる)。ただし index の","type":"text"},{"text":"論理状態","type":"text","marks":[{"type":"strong"}]},{"text":"(エントリの追加・削除・blob 差し替え、skip-worktree / assume-unchanged ビット)は ","type":"text"},{"text":"git ls-files --stage","type":"text","marks":[{"type":"code_inline"}]},{"text":" + ","type":"text"},{"text":"git ls-files -v","type":"text","marks":[{"type":"code_inline"}]},{"text":" の前後比較(","type":"text"},{"text":"index_state_signature","type":"text","marks":[{"type":"code_inline"}]},{"text":")が検出するため、残る盲点は stat cache 相当の情報と ","type":"text"},{"text":".git/index.lock","type":"text","marks":[{"type":"code_inline"}]},{"text":" 自体に限られる。(2) ","type":"text"},{"text":".git","type":"text","marks":[{"type":"code_inline"}]},{"text":" の大半を署名対象にした代償として、同期実行中の並行 git 操作(他 worktree 含む)がシグネチャ不一致(誤検知)として同期を停止させ得る(注意事項参照。fail-closed 側に倒す設計判断)。この残余リスクは、一次防御(","type":"text"},{"text":"--agent universal","type":"text","marks":[{"type":"code_inline"}]},{"text":")が書き込み自体を抑止していることと合わせて小さいと判断する。","type":"text"}]},{"type":"heading","attrs":{"level":2},"content":[{"text":"注意事項","type":"text"}]},{"type":"bullet_list","content":[{"type":"list_item","content":[{"type":"paragraph","content":[{"text":"全スキル sync での途中却下","type":"text","marks":[{"type":"strong"}]},{"text":": 1スキルずつ承認・stage を行うため、途中で却下しても承認済みスキルの stage は保持される。全スキル処理後に一括コミットする","type":"text"}]}]},{"type":"list_item","content":[{"type":"paragraph","content":[{"text":"同期実行中はこのリポジトリでの並行 git 操作(他 worktree 含む)を行わないこと","type":"text","marks":[{"type":"strong"}]},{"text":": 状態シグネチャは ","type":"text"},{"text":".git","type":"text","marks":[{"type":"code_inline"}]},{"text":" 配下を ","type":"text"},{"text":".git/index","type":"text","marks":[{"type":"code_inline"}]},{"text":"・一時 lock を除き署名対象に含めるため、fetch・commit・checkout 等の並行操作は ","type":"text"},{"text":"refs","type":"text","marks":[{"type":"code_inline"}]},{"text":"・","type":"text"},{"text":"objects","type":"text","marks":[{"type":"code_inline"}]},{"text":"・","type":"text"},{"text":"logs","type":"text","marks":[{"type":"code_inline"}]},{"text":"・","type":"text"},{"text":"worktrees","type":"text","marks":[{"type":"code_inline"}]},{"text":" を変動させ、シグネチャ不一致(誤検知)として同期を停止させ得る。検出漏れ(fail-open)ではなく停止(fail-closed)側に倒す設計であり、誤検知した場合は並行操作を止めて再実行する","type":"text"}]}]},{"type":"list_item","content":[{"type":"paragraph","content":[{"text":"linked worktree では実行できない","type":"text","marks":[{"type":"strong"}]},{"text":": ","type":"text"},{"text":"git worktree add","type":"text","marks":[{"type":"code_inline"}]},{"text":" で作られた作業ツリーでは ","type":"text"},{"text":".git","type":"text","marks":[{"type":"code_inline"}]},{"text":" が gitdir を指す通常ファイルで、実 Git ディレクトリ(","type":"text"},{"text":".git/worktrees/\u003cname>/","type":"text","marks":[{"type":"code_inline"}]},{"text":" と共有側の ","type":"text"},{"text":"refs","type":"text","marks":[{"type":"code_inline"}]},{"text":"・","type":"text"},{"text":"logs","type":"text","marks":[{"type":"code_inline"}]},{"text":"・","type":"text"},{"text":"config","type":"text","marks":[{"type":"code_inline"}]},{"text":"・objects)が状態署名の走査対象外になるため、Step 4 冒頭の worktree 検証(","type":"text"},{"text":"--absolute-git-dir","type":"text","marks":[{"type":"code_inline"}]},{"text":" と ","type":"text"},{"text":"--git-common-dir","type":"text","marks":[{"type":"code_inline"}]},{"text":" の物理パス比較)が npx 実行前に fail-closed で停止する。メイン worktree で実行し直す","type":"text"}]}]},{"type":"list_item","content":[{"type":"paragraph","content":[{"text":"実 Git ディレクトリが作業ツリー外にある構成でも実行できない","type":"text","marks":[{"type":"strong"}]},{"text":": ","type":"text"},{"text":"git clone --separate-git-dir","type":"text","marks":[{"type":"code_inline"}]},{"text":"(","type":"text"},{"text":".git","type":"text","marks":[{"type":"code_inline"}]},{"text":" が gitdir を指す gitfile)・submodule checkout・","type":"text"},{"text":".git","type":"text","marks":[{"type":"code_inline"}]},{"text":" が symlink の構成では、git-dir と common-dir が一致したまま実体が作業ツリー外にあり linked worktree と同じ検出不能に陥るため、Step 4 冒頭の追加検証(","type":"text"},{"text":"--show-toplevel","type":"text","marks":[{"type":"code_inline"}]},{"text":" の物理パスとの厳密一致 + ","type":"text"},{"text":"\u003ctoplevel>/.git","type":"text","marks":[{"type":"code_inline"}]},{"text":" の lstat)が npx 実行前に fail-closed で停止する。","type":"text"},{"text":".git","type":"text","marks":[{"type":"code_inline"}]},{"text":" が実体ディレクトリの通常構成で実行し直す","type":"text"}]}]},{"type":"list_item","content":[{"type":"paragraph","content":[{"text":"許可先経路が symlink の場合は実行できない","type":"text","marks":[{"type":"strong"}]},{"text":": ","type":"text"},{"text":".agents","type":"text","marks":[{"type":"code_inline"}]},{"text":" / ","type":"text"},{"text":".agents/skills","type":"text","marks":[{"type":"code_inline"}]},{"text":" / ","type":"text"},{"text":".agents/skills/\u003cname>","type":"text","marks":[{"type":"code_inline"}]},{"text":" のいずれかが symlink または非ディレクトリだと、Step 4 冒頭の実体検証が npx 実行前に fail-closed で停止する(symlink 越しのリポジトリ外書き込みはスコープ外検査に現れないため)。実体ディレクトリへ置き換えてから再実行する。npx が","type":"text"},{"text":"実行中に","type":"text","marks":[{"type":"strong"}]},{"text":"許可先を symlink へ置換した場合(初回インストールで symlink として作成した場合を含む)も、許可先要素自身の署名(","type":"text"},{"text":"prune-under","type":"text","marks":[{"type":"code_inline"}]},{"text":")と実行後再検証(","type":"text"},{"text":"verify_scope_path_after_run","type":"text","marks":[{"type":"code_inline"}]},{"text":")が fail-closed で停止し、このとき許可先配下への ","type":"text"},{"text":"git clean","type":"text","marks":[{"type":"code_inline"}]},{"text":" 等の削除系操作は行わない(symlink 越しのリンク先削除を避けるため。「書き込みスコープの制限」節を参照)。許可先","type":"text"},{"text":"配下","type":"text","marks":[{"type":"strong"}]},{"text":"に既存の symlink がある場合も、npx 実行前の ","type":"text"},{"text":"find -type l","type":"text","marks":[{"type":"code_inline"}]},{"text":" 走査が fail-closed で停止する(配下は署名から除外されており、リンク先への書き込みがどの検査にも現れないため)。npx が","type":"text"},{"text":"実行中に","type":"text","marks":[{"type":"strong"}]},{"text":"配下へ symlink を作成した場合も、npx 実行後の再走査(","type":"text"},{"text":"find -type l","type":"text","marks":[{"type":"code_inline"}]},{"text":")が検出して同じ保全経路(許可先配下への削除系操作・復元をスキップし、バックアップを保全して停止)へ倒す。該当 symlink を実体ファイルへ置き換えるか削除してから再実行する","type":"text"}]}]},{"type":"list_item","content":[{"type":"paragraph","content":[{"text":"既存 ignored ファイルの変更は自動復元される","type":"text","marks":[{"type":"strong"}]},{"text":": 許可先配下に実行前から存在した ignored ファイル(","type":"text"},{"text":".DS_Store","type":"text","marks":[{"type":"code_inline"}]},{"text":" 等)を npx が上書き・削除した場合、実行前バックアップとの比較で検出し、内容・mode をバックアップから復元する(成功経路では警告のうえ同期は継続)。復元に失敗した場合はバックアップディレクトリ(パスはエラーメッセージに表示)を残して停止するため、そこから手動で復旧する","type":"text"}]}]},{"type":"list_item","content":[{"type":"paragraph","content":[{"text":"スコープ外書き込みを検出した場合はループ全体が停止する","type":"text","marks":[{"type":"strong"}]},{"text":": 「書き込みスコープの制限」節を参照。","type":"text"},{"text":"continue","type":"text","marks":[{"type":"code_inline"}]},{"text":" ではなく ","type":"text"},{"text":"exit 1","type":"text","marks":[{"type":"code_inline"}]},{"text":" のため、承認・stage 済みの他スキル分が index に残ったまま処理が止まる。手動で ","type":"text"},{"text":"git status","type":"text","marks":[{"type":"code_inline"}]},{"text":" を確認し、コミットするか ","type":"text"},{"text":"git reset","type":"text","marks":[{"type":"code_inline"}]},{"text":" するかを判断する","type":"text"}]}]},{"type":"list_item","content":[{"type":"paragraph","content":[{"text":"skills-lock.json","type":"text","marks":[{"type":"code_inline"},{"type":"strong"}]},{"text":" が symlink・非 regular file の場合は実行できない","type":"text","marks":[{"type":"strong"}]},{"text":": ","type":"text"},{"text":"skills-lock.json","type":"text","marks":[{"type":"code_inline"}]},{"text":" は署名の ","type":"text"},{"text":"prune","type":"text","marks":[{"type":"code_inline"}]},{"text":" と porcelain フィルタの双方から除外されるため、symlink だとリンク先(リポジトリ外を含む)への書き込みがどの検査にも現れない。Step 4 冒頭の実体検証が npx 実行前に fail-closed で停止する(実体ファイルへ置き換えてから再実行する)。npx が","type":"text"},{"text":"実行中に","type":"text","marks":[{"type":"strong"}]},{"text":"置換した場合も実行後の再検証(","type":"text"},{"text":"verify_lock_file_after_run","type":"text","marks":[{"type":"code_inline"}]},{"text":")が停止し、復元は symlink を ","type":"text"},{"text":"rm","type":"text","marks":[{"type":"code_inline"}]},{"text":" で除去してから ","type":"text"},{"text":"git checkout","type":"text","marks":[{"type":"code_inline"}]},{"text":" で書き戻す(リンク先への書き込みを避けるため。「書き込みスコープの制限」節を参照)","type":"text"}]}]},{"type":"list_item","content":[{"type":"paragraph","content":[{"text":"skills-lock.json","type":"text","marks":[{"type":"code_inline"},{"type":"strong"}]},{"text":" は実行前 clean 前提で全体をステージする","type":"text","marks":[{"type":"strong"}]},{"text":": 単一 JSON ファイルのため部分ステージは現実的でない。Step 1 の事前ガードで clean を保証し、sync 由来以外の変更の混入を防ぐ","type":"text"}]}]},{"type":"list_item","content":[{"type":"paragraph","content":[{"text":"ルートの ","type":"text","marks":[{"type":"strong"}]},{"text":"skills-lock.json","type":"text","marks":[{"type":"code_inline"},{"type":"strong"}]},{"text":" のみを編集","type":"text","marks":[{"type":"strong"}]},{"text":": submodule 配下は手を付けない","type":"text"}]}]},{"type":"list_item","content":[{"type":"paragraph","content":[{"text":"source 完全一致検証(必須)","type":"text","marks":[{"type":"strong"}]},{"text":": ","type":"text"},{"text":"source","type":"text","marks":[{"type":"code_inline"}]},{"text":" を ","type":"text"},{"text":"OWNER/REPO","type":"text","marks":[{"type":"code_inline"}]},{"text":" へ正規化した上で ","type":"text"},{"text":"Fandhe-AI/\u003crepo>","type":"text","marks":[{"type":"code_inline"}]},{"text":" に完全一致しないエントリは skip する(","type":"text"},{"text":"contribute-skill","type":"text","marks":[{"type":"code_inline"}]},{"text":" と同じ安全弁)。前方一致では ","type":"text"},{"text":"../","type":"text","marks":[{"type":"code_inline"}]},{"text":" を含む値が通過してしまうため、完全一致の正規表現で検証する。","type":"text"},{"text":"skills-lock.json","type":"text","marks":[{"type":"code_inline"}]},{"text":" の改ざんや誤設定から防御するため","type":"text"}]}]},{"type":"list_item","content":[{"type":"paragraph","content":[{"text":"npx skills add --yes","type":"text","marks":[{"type":"code_inline"},{"type":"strong"}]},{"text":" は上書き確認をスキップする","type":"text","marks":[{"type":"strong"}]},{"text":": upstream に破壊的変更がある場合は ","type":"text"},{"text":"git diff","type":"text","marks":[{"type":"code_inline"}]},{"text":" で内容を必ず確認すること","type":"text"}]}]},{"type":"list_item","content":[{"type":"paragraph","content":[{"text":"新スキルの取扱い","type":"text","marks":[{"type":"strong"}]},{"text":": ローカルに存在するが upstream に未登録のスキル(","type":"text"},{"text":"contribute-skill","type":"text","marks":[{"type":"code_inline"}]},{"text":", ","type":"text"},{"text":"sync-skills-lock","type":"text","marks":[{"type":"code_inline"}]},{"text":" 自身など)は、upstream マージ後に登録する。マージ前に ","type":"text"},{"text":"computedHash","type":"text","marks":[{"type":"code_inline"}]},{"text":" を勝手に書き込まない","type":"text"}]}]},{"type":"list_item","content":[{"type":"paragraph","content":[{"text":"Step 5 のプレビューは index を変更しない","type":"text","marks":[{"type":"strong"}]},{"text":": 未追跡ファイルの表示に ","type":"text"},{"text":"git add -N","type":"text","marks":[{"type":"code_inline"}]},{"text":"(intent-to-add)ではなく ","type":"text"},{"text":"git diff --no-index","type":"text","marks":[{"type":"code_inline"}]},{"text":" を使う。Step 6 の拒否経路が index からの ","type":"text"},{"text":"git checkout --","type":"text","marks":[{"type":"code_inline"}]},{"text":" で承認済み他スキルの hash を復元する設計に依存しており、i-t-a エントリの混入はその復元設計と干渉するため","type":"text"}]}]},{"type":"list_item","content":[{"type":"paragraph","content":[{"text":"skills CLI は固定版で実行する","type":"text","marks":[{"type":"strong"}]},{"text":": ","type":"text"},{"text":"npx skills add","type":"text","marks":[{"type":"code_inline"}]},{"text":" はバージョン未固定で実行しない。固定版の決め方・更新手順は「skills CLI のバージョン固定と更新手順」節を参照","type":"text"}]}]},{"type":"list_item","content":[{"type":"paragraph","content":[{"text":"local patch の保護(checker を持つリポジトリでは必須)","type":"text","marks":[{"type":"strong"}]},{"text":": npx 前の checker(Step 4)→ 承認前の再適用 + 最終検証 + 契約範囲検証(Step 5.5)→ 成功時のみ stage(Step 7)の順を省略しない。checker 非 0・台帳(","type":"text"},{"text":".agents/skills/LOCAL-PATCHES.md","type":"text","marks":[{"type":"code_inline"}]},{"text":")のみ存在は fail-closed で停止し、local patch が欠けた状態を stage・commit しない。apply が変更し得る durable patch(","type":"text"},{"text":"scripts/local-patches/","type":"text","marks":[{"type":"code_inline"}]},{"text":")は承認(Step 6 の最終 diff + 未追跡プレビュー再実行)・stage(Step 7)・却下(","type":"text"},{"text":"PRE_SYNC_TREE","type":"text","marks":[{"type":"code_inline"}]},{"text":" からの index 復元)のすべてで同じ集合として扱い、未 stage の WIP が同 directory に残る状態では同期を開始しない(Step 4 で fail-closed)。","type":"text"},{"text":"computedHash","type":"text","marks":[{"type":"code_inline"}]},{"text":" は upstream 版の値を維持する","type":"text"}]}]}]},{"type":"heading","attrs":{"level":2},"content":[{"text":"sandbox 環境での実行","type":"text"}]},{"type":"paragraph","content":[{"text":"このスキルはネットワーク越しの GitHub 操作(","type":"text"},{"text":"npx skills add","type":"text","marks":[{"type":"code_inline"}]},{"text":" による上流リポジトリの取得等)を必須とする。該当コマンドはコマンド単位で sandbox 無効にして実行する。ネットワーク遮断を解除できない環境では実行できない。","type":"text"}]},{"type":"heading","attrs":{"level":2},"content":[{"text":"検証","type":"text"}]},{"type":"paragraph","content":[{"text":"コミット後、以下で完了を確認する。","type":"text"}]},{"type":"code_block","attrs":{"wrap":false,"language":"bash"},"content":[{"text":"# skills-lock.json が更新済みであることを確認\ngit show HEAD -- skills-lock.json | grep computedHash\n\n# 差分なし(sync 完了)を確認\ngit status --porcelain skills-lock.json","type":"text"}]},{"type":"bullet_list","content":[{"type":"list_item","content":[{"type":"paragraph","content":[{"text":"コミットに sync 対象スキルの ","type":"text"},{"text":"computedHash","type":"text","marks":[{"type":"code_inline"}]},{"text":" 更新が含まれること","type":"text"}]}]},{"type":"list_item","content":[{"type":"paragraph","content":[{"text":"ステージ・未ステージに残留変更がないこと","type":"text"}]}]}]},{"type":"heading","attrs":{"level":3},"content":[{"text":"未追跡ファイル可視化の手動回帰確認","type":"text"}]},{"type":"paragraph","content":[{"text":"upstream にファイルが増えたケース(Step 5 のプレビュー拡張が効いているか)は、npx を実行せずに 未追跡ファイルを模擬して確認できる。フラットファイルの追加と、upstream が新規サブディレクトリ ごと追加する典型ケース(","type":"text"},{"text":"references/","type":"text","marks":[{"type":"code_inline"}]},{"text":" 等)の両方を確認する。","type":"text"},{"text":"両ケースで手順3の照合方法が異なる","type":"text","marks":[{"type":"strong"}]},{"text":" 点に注意する(後述)。","type":"text"}]},{"type":"code_block","attrs":{"wrap":false,"language":"bash"},"content":[{"text":"# 1a. clean な状態で検証用ファイル(フラット)を作成し、npx が新規ファイルを増やした直後の状態を再現する\ntouch \".agents/skills/${SKILL_NAME}/__preview_regression_check__.md\"\n\n# 1b. 新規サブディレクトリごと追加されるケースも作成する\nmkdir -p \".agents/skills/${SKILL_NAME}/__preview_regression_dir__\"\ntouch \".agents/skills/${SKILL_NAME}/__preview_regression_dir__/new.md\"\n\n# 2. Step 5 のプレビュー部(上記コマンド)を実行し、両方の検証用ファイルの内容(0 byte でも\n# 「新規(未追跡)ファイル — 承認時に git add で取り込まれる集合:」の一覧に、サブディレクトリ\n# 配下のファイルも含めてフルパスで名前が出ること)が表示されることを確認する\n# (プレビューは git ls-files ベースのため、ディレクトリではなく個々のファイルパスを列挙する)\n\n# 3. git clean -fdn(dry-run)の一覧と照合する。\n# フラットファイルはプレビューの行と `git clean -fdn` の行が完全一致する。\n# 新規サブディレクトリは `git clean -fdn` が個々のファイルではなく親ディレクトリを\n# まとめて1行(`Would remove \u003cdir>/`)で報告するため、行単位の完全一致では照合できない。\n# この場合はプレビューの各ファイルパスが `git clean -fdn` のいずれかの出力行(ファイル\n# 自身、またはその祖先ディレクトリ)で始まることを確認する(前方一致で照合する)。\n# 完全一致を要求すると、正常に動作しているプレビューを「壊れている」と誤診断する。\ngit clean -fdn -- \".agents/skills/${SKILL_NAME}/\"\n\n# 4. 検証用ファイル・ディレクトリを削除して原状復帰する\nrm \".agents/skills/${SKILL_NAME}/__preview_regression_check__.md\"\nrm -rf \".agents/skills/${SKILL_NAME}/__preview_regression_dir__\"","type":"text"}]},{"type":"heading","attrs":{"level":2},"content":[{"text":"既存スキルとの関係","type":"text"}]},{"type":"bullet_list","content":[{"type":"list_item","content":[{"type":"paragraph","content":[{"text":"contribute-skill","type":"text","marks":[{"type":"code_inline"}]},{"text":" でスキル改修が upstream にマージされた後に本スキルを実行する運用を推奨","type":"text"}]}]},{"type":"list_item","content":[{"type":"paragraph","content":[{"text":"create-commit","type":"text","marks":[{"type":"code_inline"}]},{"text":" の Conventional Commits を踏襲(Step 8)","type":"text"}]}]},{"type":"list_item","content":[{"type":"paragraph","content":[{"text":"実行可能コマンド集として ","type":"text"},{"text":"scripts/skills-lock-update.sh","type":"text","marks":[{"type":"code_inline"}]},{"text":" を参照","type":"text"}]}]}]}]},"metadata":{"date":"2026-11-02","name":"sync-skills-lock","model":"sonnet","author":"@skillopedia","source":{"stars":2,"repo_name":"agent-cli-skills","origin_url":"https://github.com/Fandhe-AI/agent-cli-skills/blob/HEAD/skills/sync-skills-lock/SKILL.md","repo_owner":"Fandhe-AI","body_sha256":"d4aa81fb433c4420ecb426a23e6e4c03e27b3addea1ea24506b68bfd549925b8","cluster_key":"e4447726d3e74500371c63f6f147a52bbb6030ddf34ca0639d8f9ee987f75248","clean_bundle":{"format":"clean-skill-bundle-v1","source":"Fandhe-AI/agent-cli-skills/skills/sync-skills-lock/SKILL.md","text_bytes":377094,"source_commit":"b1c76148d77c8f8897e0f7206af34b126739fd83","skills_sh_rank":10611,"attachment_count":15,"text_attachments":15,"attachment_storage":"skill_attachments","binary_attachments":0},"cluster_size":1,"skill_md_path":"skills/sync-skills-lock/SKILL.md","import_metadata":{"date":"2026-11-02","author":"@skillopedia","version":"v1","category":"general","category_label":"General"},"exact_dupes_collapsed_into_this":0},"version":"v1","category":"general","import_tag":"skills-sh-top20k-2026-11","description":"ルート直下の `skills-lock.json` の `computedHash` を upstream リポジトリの最新状態と照合して更新する。`source` が `Fandhe-AI/\u003crepo>` に完全一致しないエントリは clone せず skip (安全弁)。submodule 配下の `skills-lock.json` は触らない。contribute-skill のマージ後や upstream 同期後、「ハッシュ更新」「skills-lock 同期」などで使用。","argument-hint":"[skill-name] (省略時は全スキル)","user-invocable":true}},"renderedAt":1793666412913}

sync-skills-lock ルート直下の の を、upstream リポジトリの現状と照合して更新する。 対象ファイル - ルート : 呼び出し元リポジトリ直下の — このスキルが唯一編集するファイル - 除外 : submodule 配下の は編集しない(submodule 境界を跨がないため) 前提条件 - CLI がインストールされ、認証済みであること - / が利用可能であること( を使用するため)。 CLI は固定版( )で実行する。値と更新手順は「skills CLI のバージョン固定と更新手順」節を参照 - が利用可能であること(状態署名〔 / 〕と復元処理〔既存 ignored ファイルの比較・復元等〕で使用。主要 Linux / macOS には標準搭載されている。無い環境では Step 4 フェンスが npx 実行前に fail-closed で停止するため、導入してから再実行すること) - CLI が利用可能であること(未追跡バイナリファイルの種別表示に使用。未導入の環境では 種別が として表示され、承認前にサイズ・git blob ハッシュのみで 判断することになる。macOS / 主要 Linux ディストリビューションには標準搭載されている) - ルート直下の が存在すること - 実行前に に未コミットの変更がないこと (ステージ済み・未ステージ…